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.