Декомпозиция ряда: аддитивная и STL

Разбираем ряд на тренд, сезонность и остаток руками, чтобы понять, что делают готовые функции декомпозиции.

Классическая декомпозиция оценивает тренд скользящим средним, затем выделяет сезонные индексы по позициям внутри периода, а остаток объявляет шумом.

Зачем разлагать ряд явно

Готовые функции (seasonal_decompose, STL) возвращают три кривые, но если не понимать, как они получены, легко неверно их трактовать. Сделав декомпозицию руками на маленьком ряде, вы увидите всю механику: где берётся тренд, как считаются сезонные индексы и почему остаток должен быть бесструктурным.

Представьте, что вы отвечаете за прогноз продаж интернет-магазина. Сырой ряд выручки скачет: декабрьский всплеск перед праздниками, январский провал, медленный годовой рост на фоне маркетинга. Если смотреть на «сырую» кривую, невозможно сказать, выросли ли продажи по существу или это просто декабрь. Декомпозиция отвечает на этот вопрос буквально: она отделяет долгосрочное направление (растёт ли бизнес) от календарного узора (что повторяется каждый год) и от случайных всплесков (акция, сбой, разовый крупный заказ). Менеджер, который видит чистый тренд без сезонного шума, принимает совсем другие решения, чем тот, кто реагирует на каждый зубец графика.

Та же логика работает для трафика сайта (недельный цикл «будни против выходных»), для нагрузки на серверы (суточный профиль), для посещаемости кафе или загрузки техподдержки. Везде, где данные дышат в ритме календаря, разложение на компоненты — это первый честный взгляд на то, что на самом деле происходит. И поскольку библиотечные функции делают это в одну строку, велик соблазн применять их вслепую. Разобрав механику на крошечном примере, вы перестанете путать «упал тренд» с «сейчас просто низкий сезон» — а это самая частая ошибка чтения таких графиков.

Шаг 1: тренд через центрированное среднее

Тренд оценивают скользящим средним шириной в период. Для чётного периода окно центрируют, давая крайним точкам половинный вес.

Идея проста: если усреднить ровно один полный период, то сезонные подъёмы и спады внутри него взаимно гасятся, и остаётся только медленное движение уровня. Поэтому ширину окна берут равной периоду — не больше и не меньше. Если период чётный (например, 4 квартала или 12 месяцев), у окна нет единственного центрального элемента, и его «двигают» так, чтобы оно симметрично легло вокруг текущей точки: крайние два значения берут с весом по 0.5, а внутренние — с весом 1. Так центр окна точно совпадает с моментом времени, для которого мы считаем тренд, и фаза не сдвигается.

data = [10,20,30,20, 12,22,32,22, 14,24,34,24]  # период 4

def centered_ma(xs, period):
    half = period // 2
    out = [None] * len(xs)
    for i in range(half, len(xs) - half):
        win = xs[i-half:i+half+1]
        s = 0.5*win[0] + sum(win[1:-1]) + 0.5*win[-1]
        out[i] = round(s / period, 2)
    return out

trend = centered_ma(data, 4)
print("Тренд:", trend)

Вывод:

Тренд: [None, None, 20.25, 20.75, 21.25, 21.75, 22.25, 22.75, 23.25, 23.75, None, None]

Тренд плавно растёт с 20.25 до 23.75 — сезонные пики и провалы усреднились, осталось медленное направление. По краям None: центрированное окно не помещается.

Обратите внимание на эти None по краям — это не баг, а неизбежная плата за центрированное окно: для первых и последних точек ряда просто нет данных слева или справа, чтобы окно легло симметрично. На практике это означает, что свежий конец ряда — именно тот, который интереснее всего для прогноза — оценивается тренда хуже всего. Профессиональные методы (вроде STL или сглаживания Хольта) умеют дотягивать тренд до самого края, но классическое центрированное среднее честно признаётся, что не знает. Запомните это ограничение: оно объясняет, почему «ручные» декомпозиции часто обрезают начало и конец графика.

Шаг 2: сезонные индексы

Вычитаем тренд (получаем detrended-ряд) и усредняем остаток по позициям внутри периода — это и есть сезонные индексы.

Логика второго шага зеркальна первому. На первом шаге мы усреднением убрали сезонность и оставили тренд. Теперь, наоборот, мы вычитаем тренд и смотрим, что систематически остаётся на каждой позиции цикла. Если третий месяц каждого года стабильно оказывается выше тренда, это и есть сезонный вклад этой позиции. Усреднение по всем циклам сглаживает случайные колебания и оставляет устойчивый профиль: «насколько эта позиция периода обычно выше или ниже базовой линии».

from collections import defaultdict
data = [10,20,30,20, 12,22,32,22, 14,24,34,24]
trend = [None,None,20.25,20.75,21.25,21.75,22.25,22.75,23.25,23.75,None,None]

detr = [None if trend[i] is None else round(data[i]-trend[i], 2)
        for i in range(len(data))]
buckets = defaultdict(list)
for i, v in enumerate(detr):
    if v is not None:
        buckets[i % 4].append(v)

season = {pos: round(sum(v)/len(v), 2) for pos, v in buckets.items()}
print("Сезонные индексы по позиции:", dict(sorted(season.items())))

Вывод:

Сезонные индексы по позиции: {0: -9.25, 1: 0.25, 2: 9.75, 3: -0.75}

Позиция 2 в периоде даёт +9.75 (пик), позиция 0 даёт -9.25 (провал). Это устойчивый сезонный профиль: что бы ни делал тренд, третий элемент каждого цикла всегда выше базы. Остаток после вычитания тренда и сезонности — шум.

В аддитивной модели сезонные индексы в сумме должны давать примерно ноль: то, что недобрали в провальные позиции, добирается в пиковые, иначе сезонность тайком сместила бы общий уровень. Здесь -9.25 + 0.25 + 9.75 - 0.75 = 0 — баланс соблюдён. Это полезная проверка: если индексы не центрированы около нуля, их обычно нормируют, вычитая среднее. Практический смысл такого профиля огромен: зная, что позиция 2 даёт +9.75, вы можете заранее планировать запасы, персонал или мощности под предсказуемый пик и не паниковать в провальные позиции, понимая, что это календарь, а не падение спроса.

STL: гибкая декомпозиция

STL (Seasonal-Trend decomposition using Loess) — современная альтернатива. Вместо жёсткого скользящего среднего она использует локальную регрессию (loess), позволяет сезонности медленно меняться со временем и устойчива к выбросам. На проде STL предпочитают классике именно за гибкость.

from statsmodels.tsa.seasonal import STL

result = STL(series, period=12).fit()
trend = result.trend
seasonal = result.seasonal
resid = result.resid

Чем STL ценна на реальных данных? Классическая декомпозиция считает, что декабрьский всплеск из года в год одинаков по величине. Но бизнес меняется: магазин рос, и амплитуда праздничного пика в абсолютных рублях тоже росла; вкусы аудитории сайта смещались, и «выходной» профиль трафика медленно эволюционировал. STL не приколачивает сезонность гвоздями — она позволяет узору плавно подстраиваться. Кроме того, один разовый выброс (день распродажи, сбой счётчика) в классике искажает сезонный индекс всей позиции, а робастный режим STL такие точки занижает по весу, чтобы они не отравили оценку.

Как работает под капотом

Классическая декомпозиция предполагает неизменный сезонный профиль — один набор индексов на весь ряд. STL снимает это ограничение: она прогоняет несколько итераций loess-сглаживания, попеременно уточняя сезонную и трендовую компоненты, и сезонность может плавно эволюционировать. Цена — больше вычислений и параметров (окна сглаживания), но результат честнее для длинных рядов.

Внутри STL крутится цикл из двух чередующихся проходов. Сначала из ряда вычитают текущую оценку тренда и сглаживают остаток отдельно по каждой позиции цикла (loess вдоль «подсерий») — так получают сезонную компоненту, которой разрешено медленно дрейфовать. Затем сезонность вычитают и loess-сглаживанием обновляют тренд. Эти два шага повторяют несколько раз, и оценки сходятся. Ключевые ручки настройки — ширина сезонного окна (насколько быстро профиль может меняться: большое окно держит сезонность почти постоянной, маленькое позволяет ей гулять) и ширина трендового окна (насколько гладким будет тренд). Понимание этих рычагов отличает осмысленное применение STL от вызова с параметрами по умолчанию.

Частые ошибки

  • Применять аддитивную декомпозицию к мультипликативному ряду — остаток будет «дышать» с уровнем.
  • Использовать нечётное окно для чётного периода без центрирования — тренд сместится по фазе.
  • Считать остаток шумом, не проверив его ACF на остаточную структуру.
  • Задать неверный период (например, 7 вместо 12 для месячных данных) — тогда декомпозиция «увидит» несуществующий цикл и размажет настоящую сезонность по тренду и остатку.

Особенно коварна первая ошибка. Если выручка растёт, а вместе с уровнем растёт и амплитуда сезонных колебаний (декабрьский пик в крупном году в рублях больше, чем в маленьком), то модель аддитивна неверно — сезонность здесь умножает уровень, а не прибавляется к нему. Признак на глаз: разброс вокруг тренда расширяется «воронкой» по мере роста. Лекарство — либо мультипликативная декомпозиция, либо лог-преобразование ряда, после которого мультипликативная структура снова становится аддитивной.

Итоги

  • Тренд оценивают скользящим средним в период, сезонность — усреднением остатка по позициям.
  • Остаток после вычитания тренда и сезонности должен быть бесструктурным шумом.
  • STL гибче классики: сезонность может меняться, метод устойчив к выбросам.
  • Декомпозиция — это инструмент понимания: она отвечает на вопрос «рост это или просто сезон», прежде чем вы начнёте что-либо прогнозировать.
Проверьте себя
1. Как в классической декомпозиции получают сезонные индексы?
AЛогарифмированием ряда
BУсреднением detrended-ряда по позициям внутри периода
CДифференцированием дважды
DЧерез тест Дики-Фуллера
2. Чем STL-декомпозиция гибче классической?
AОна быстрее
BОна позволяет сезонности меняться со временем и устойчива к выбросам
CОна не требует периода
DОна убирает тренд автоматически