2025, Nov 07 15:02

Скользящее стандартное отклонение в pandas: почему меняется при срезе хвоста и как это интерпретировать

Почему rolling.std в pandas различается на хвостовых срезах: привязка окон, онлайн‑алгоритм, численные эффекты и как получить согласованные результаты.

Почему скользящее стандартное отклонение в pandas может меняться при срезе с конца

Кажется, что скользящие метрики детерминированы: одинаковое окно, те же значения — и результат должен совпадать. Но в pandas применение скользящего стандартного отклонения к разным срезам хвоста одного и того же Series может дать разные результаты для перекрывающихся окон. Этот разбор объясняет, почему так происходит, что именно вычисляется «под капотом» и как интерпретировать результаты, не натыкаясь на численные ловушки.

Воспроизводим поведение

Рассмотрим короткий Series, где одно значение на порядки больше остальных. Посчитаем скользящее стандартное отклонение с окном из трёх по разным срезам с конца.

import numpy as np
import pandas as pd
series_x = pd.Series(np.random.default_rng(seed=123).random(size=5))
series_x[1] = 10000000  # очень большое значение
series_x
# 0    6.823519e-01
# 1    1.000000e+07
# 2    2.203599e-01
# 3    1.843718e-01
# 4    1.759059e-01
# dtype: float64
series_x.tail(3).rolling(window=3, min_periods=1).std()
# 2         NaN
# 3    0.025447
# 4    0.023604
# dtype: float64
series_x.tail(4).rolling(window=3, min_periods=1).std()
# 1             NaN
# 2    7.071068e+06
# 3    5.773503e+06
# 4    0.000000e+00
# dtype: float64
series_x.tail(5).rolling(window=3, min_periods=1).std()
# 0             NaN
# 1    7.071067e+06
# 2    5.773502e+06
# 3    5.773503e+06
# 4    0.000000e+00
# dtype: float64

Теперь вызовем те же окна, но каждое окно посчитаем независимо через apply с Series.std. Последний результат становится согласованным между срезами:

series_x.tail(3).rolling(window=3, min_periods=1).apply(pd.Series.std)
# 2         NaN
# 3    0.025447
# 4    0.023604
# dtype: float64
series_x.tail(4).rolling(window=3, min_periods=1).apply(pd.Series.std)
# 1             NaN
# 2    7.071068e+06
# 3    5.773503e+06
# 4    2.360426e-02
# dtype: float64
series_x.tail(5).rolling(window=3, min_periods=1).apply(pd.Series.std)
# 0             NaN
# 1    7.071067e+06
# 2    5.773502e+06
# 3    5.773503e+06
# 4    2.360426e-02
# dtype: float64

Что происходит

Здесь действуют два независимых эффекта. Первый — про то, как определяются скользящие окна. Второй — численный и проявляется, когда в данных смешиваются огромные и крошечные величины.

Во‑первых, скользящие окна привязаны к концу Series. Для пяти элементов a, b, c, d, e и окна размера три движок последовательно считает такие окна:

std(a)          # 0         NaN
std(a, b)       # 1    0.444438
std(a, b, c)    # 2    0.325633
std(b, c, d)    # 3    0.087630
std(c, d, e)    # 4    0.023604

Если взять tail(3), начальные окна меняются, поэтому первые n−1 значений для окна размера n неизбежно будут другими. Для tail(3) последовательность такая:

std(c)          # 2         NaN
std(c, d)       # 3    0.025447
std(c, d, e)    # 4    0.023604

Здесь std(c, d) не равно std(b, c, d), так что ранние значения ожидаемо отличаются из‑за этой привязки.

Второй эффект объясняет, почему в примере с крайне большим числом рядом с малыми последняя величина может обнулиться при rolling.std, но остаётся небольшим положительным числом при rolling.apply(pd.Series.std). Согласно реализации pandas, скользящая дисперсия (квадрат std) считается «онлайн» и поддерживает, среди прочего, сумму квадратов отклонений от среднего, обозначенную как ssqdm_x. Когда скользящее окно проходит мимо большого значения, шаг обновления remove_var вычитает очень крупный член (val - prev_mean) * (val - mean_x[0]) из уже неточной большой ssqdm_x. Так как эти промежуточные величины имеют тип float64 и различаются на многие порядки, вычитание не восстанавливает малую истинную дисперсию; оставшаяся ssqdm_x может быть настолько велика, что последующие крошечные вклады становятся пренебрежимо малыми, и дисперсия для финальных окон «схлопывается» почти к нулю. Это онлайн‑обновление использует общие промежуточные значения между окнами, поэтому численная проблема распространяется. В отличие от этого, rolling.apply(pd.Series.std) оценивает каждую тройку независимо, избегая такого распространения ошибки.

Небольшое изменение, которое выявляет границу точности

Уменьшите большое значение на один ноль — и поведение заметно улучшается, потому что разрыв по порядкам сокращается, а float64 может точнее представить промежуточные величины в том же массиве.

series_y = pd.Series(np.random.default_rng(seed=123).random(size=5))
series_y[1] = 1000000
series_y.rolling(window=3, min_periods=1).std()
# 0              NaN
# 1    707106.298691
# 2    577350.008599
# 3    577350.152354
# 4         0.021852
# dtype: float64

Разница ещё нагляднее, если посмотреть на саму дисперсию:

series_y.rolling(window=3, min_periods=1).var()
# 0             NaN
# 1    4.999993e+11
# 2    3.333330e+11
# 3    3.333332e+11
# 4    4.775168e-04  # разрыв примерно в 15 порядков
# dtype: float64

Эти числа показывают, как одно очень большое значение может доминировать в накоплении квадратов отклонений и почему онлайн‑обновления иногда не способны восстановить крошечную «хвостовую» дисперсию после выхода большого значения из окна.

Что с этим делать?

Сама процедура работает как задумано: окна привязаны, а дисперсия поддерживается онлайн‑алгоритмом. Если вам нужны согласованные результаты для последнего окна при смешении экстремальных масштабов, считайте каждое окно независимо через rolling.apply(pd.Series.std). Если уменьшить экстремальное значение (например, с 10000000 до 1000000 в этом примере), итоговый rolling.std уже не схлопывается, потому что промежуточные float64 сохраняют достаточную точность при общем состоянии.

Почему это важно

В анализе временных рядов часто встречаются смешанные масштабы: счётчики со всплесками или метрики, сочетающие выбросы и мелкие колебания. Понимание того, что rolling.std — это онлайн‑вычисление с общими промежуточными величинами, объясняет, почему выброс, уже покинувший окно, всё ещё может косвенно влиять на последующие результаты из‑за ограничений плавающей точности. Это также проясняет, почему срез с конца меняет первые n−1 значений для окна размера n — из‑за смещения «якорных» окон.

Выводы

Ожидайте, что первые n−1 позиций скользящего окна размера n будут зависеть от того, с какого среза вы начинаете, потому что окна привязаны. Когда данные объединяют огромные и очень малые значения, общие промежуточные величины в онлайн‑алгоритме могут терять точность и давать почти нулевые дисперсии даже после того, как выброс вышел из окна. Чтобы получить согласованные результаты по каждому окну в таких случаях, считайте их независимо через rolling.apply(pd.Series.std). И если это возможно, уменьшение разрыва по порядкам помогает изначально избежать потери точности.

Документация: pandas Rolling.std — https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.core.window.rolling.Rolling.std.html

Статья основана на вопросе на StackOverflow от KamiKimi 3 и ответе mozway.