Временные ряды: скользящее среднее, сглаживание и ARIMA
Анализ временных рядов на дневном DAU: тренд, сезонность и шум, скользящее среднее в SQL, экспоненциальное сглаживание и метод Хольта в Python, идея ARIMA.
Содержание статьи
В понедельник менеджер присылает скриншот: в субботу DAU упал на 10,8% к пятнице, что сломалось? Скорее всего, ничего — это суббота. Тот же день к прошлой субботе дал +8,1%. Дневная метрика почти всегда складывается из трёх слоёв: медленного роста, недельного ритма и случайного шума. Пока они смешаны, любое движение графика выглядит событием. Разберём анализ временных рядов на дневном DAU учебного сервиса «Маяк»: собрать ряд без ловушек, посчитать скользящее среднее в SQL, проверить недельную сезонность, построить прогноз экспоненциальным сглаживанием и методом Хольта и сравнить его с наивными. В конце — что такое ARIMA и когда она нужна.
Коротко
Временной ряд — это значения одной метрики, упорядоченные по равным интервалам времени: DAU по дням, выручка по неделям, заказы по часам. В отличие от обычной выборки, соседние наблюдения здесь зависят друг от друга, а порядок несёт информацию.
- Ряд раскладывают на тренд, сезонность и остаток. Если сезонные колебания постоянны в штуках, декомпозиция аддитивная, если в процентах — мультипликативная.
- Скользящее среднее за 7 дней убирает недельную сезонность: в каждое окно попадает ровно по одному понедельнику, вторнику и так далее.
- Изменение день к дню на дневной метрике в основном показывает день недели. Сравнивайте неделю к неделе или скользящие средние.
- Экспоненциальное сглаживание прогнозирует уровень, метод Хольта — уровень и наклон. На растущем DAU «Маяка» Хольт с поправкой на день недели ошибся в среднем на 3,1% против 6,4% у прогноза «как вчера».
- ARIMA моделирует ряд через его прошлые значения и прошлые ошибки после дифференцирования. Её берут, когда простые методы уже проверены и проиграли.
Что такое временной ряд: тренд, сезонность и шум
Тренд — долгосрочное направление ряда: продукт растёт, стагнирует или теряет аудиторию. Сезонность — повторяющийся рисунок с известным периодом: неделя для дневных продуктовых метрик, год для розницы, сутки для почасовой нагрузки. Остаток, или шум, — всё, что осталось после вычитания первых двух. Иногда выделяют ещё цикл — колебания без фиксированного периода, но на ряду в несколько месяцев его не отличить от тренда.
Декомпозиция бывает двух видов. В аддитивной компоненты складываются: воскресенье отнимает от DAU примерно одно и то же число пользователей, сколько бы их ни было. В мультипликативной — перемножаются: воскресенье отнимает одинаковую долю, и при росте аудитории провал растёт в штуках. Мультипликативный ряд превращается в аддитивный логарифмированием, поэтому растущие в разы метрики часто смотрят на логарифмической шкале. Какой вариант ближе к данным, проверяют, а не угадывают, — ниже это сделано на DAU «Маяка».
yₜ = Tₜ + Sₜ + Rₜ yₜ = Tₜ × Sₜ × RₜT — тренд, S — сезонная компонента, R — остаток. Логарифм второй формулы даёт первую: log y = log T + log S + log R.
| Компонента | Что показывает | В дневном DAU | Чем выделить |
|---|---|---|---|
| Тренд | Направление за недели и месяцы | Рост аудитории после запуска | Скользящее среднее за 7 дней, метод Хольта |
| Сезонность | Повтор с известным периодом | Будни выше, выходные ниже | Средний профиль дня недели относительно тренда |
| Остаток | Случайные отклонения | Пятница, которая вышла слабее обычной | Ряд минус тренд и сезонность |
| Разовые события | Сдвиг в конкретный день | Релиз, сбой, праздник, рассылка | Календарь событий рядом с графиком |
Как собрать ряд DAU и почему последний день надо отрезать
Дневной ряд DAU — одна строка на календарный день: дата и число разных пользователей, которые открыли приложение. В учебной базе «Маяк» это событие app_open в таблице events, время записано в UTC+3, и открытие пишется не чаще раза в день на пользователя. Как определить активность для своего продукта и не посчитать события вместо людей, разобрано в статье как считать DAU.
Первым делом смотрим на края ряда. Выгрузка базы закрыта 30 августа в 13:00, и последний день выглядит как обвал: 221 пользователь против 506 накануне, −56,3%. Время последнего открытия, 12:59, выдаёт причину — день не закончился. Неполный день портит всё, что стоит дальше: скользящее среднее на 30 августа опустилось бы с 518,1 до 481,9, а прогноз начал бы с ложного падения. Поэтому ряд берём по 29 августа включительно, условием event_time < timestamp '2026-08-30'. В рабочей базе так каждое утро: сегодняшний день неполный, а при задержке событий — и вчерашний.
| День | DAU | Первое открытие | Последнее открытие |
|---|---|---|---|
| 27 августа | 541 | 06:05 | 20:54 |
| 28 августа | 567 | 06:17 | 20:59 |
| 29 августа | 506 | 06:06 | 20:54 |
| 30 августа | 221 | 06:04 | 12:59 |
select
cast(event_time as date) as day,
count(distinct user_id) as dau,
min(event_time) as first_open,
max(event_time) as last_open
from events
where event_name = 'app_open'
and event_time >= timestamp '2026-08-27'
group by cast(event_time as date)
order by day;Скользящее среднее за 7 дней в SQL
Скользящее среднее за 7 дней — среднее значение метрики за текущий день и шесть предыдущих. В SQL это оконная функция AVG(...) OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW). Запрос ниже сначала строит календарь через generate_series, затем присоединяет к нему дневные значения. В «Маяке» пропусков нет, все 90 дней на месте, но календарь — страховка: рамка ROWS считает строки, а не дни, и если день выпал из данных, окно молча захватит восьмой. Подробнее — в статьях про ряд дат через generate_series и про рамку окна ROWS и RANGE.
Запрос выполняется без изменений в DuckDB и PostgreSQL и возвращает одинаковые 90 строк. Столбец days_in_window показывает, сколько дней попало в окно: в первые шесть дней их меньше семи, и среднее считается по тому, что есть, — 32,0 за 1 июня, 40,0 за 2 июня. Такие значения лучше не показывать или помечать.
Почему окно именно 7, а не 5 или 10? В окно длиной ровно в период сезонности каждый день недели попадает один раз. Сезонные отклонения за неделю в сумме дают почти ноль, и в среднем остаются тренд и сглаженный шум. Окно в 5 дней то включает выходные, то нет, и среднее качается с недельным ритмом.
У такого окна есть цена — запаздывание. Среднее за последние 7 дней описывает середину окна, то есть ситуацию примерно трёхдневной давности. На растущем ряду оно всегда ниже текущего уровня: 29 августа среднее 518,1, а четыре дня подряд до этого DAU был выше 510. Для отчётов это приемлемо, для прогноза нет — к нему вернёмся ниже.
Учебная база «Маяк», результат запроса выше. Подписи — числа августа; 2, 9, 16 и 23 августа — воскресенья. Дневная линия проседает каждые выходные, скользящее среднее растёт ровно.
with calendar as (
select cast(x as date) as day
from generate_series(timestamp '2026-06-01', timestamp '2026-08-29', interval '1 day') as g(x)
),
daily as (
select cast(event_time as date) as day, count(distinct user_id) as dau
from events
where event_name = 'app_open'
and event_time >= timestamp '2026-06-01'
and event_time < timestamp '2026-08-30' -- 30 августа неполный
group by cast(event_time as date)
)
select
c.day,
coalesce(d.dau, 0) as dau,
round(cast(avg(coalesce(d.dau, 0)) over (
order by c.day rows between 6 preceding and current row
) as numeric(18, 6)), 1) as ma7,
count(*) over (order by c.day rows between 6 preceding and current row) as days_in_window
from calendar c
left join daily d on d.day = c.day
order by c.day;То же в pandas: rolling и граница окна
В pandas скользящее среднее считает rolling(7).mean(), и здесь первая ловушка: по умолчанию pandas требует полного окна и для первых шести дней возвращает NaN. SQL в той же ситуации усредняет то, что есть. Чтобы результаты совпали, нужен min_periods=1; чтобы честно не показывать неполные окна — оставьте NaN.
Вторая ловушка — центрированное окно center=True: три дня до и три после. Для декомпозиции оно правильнее, потому что не запаздывает. Но на правом краю ряда, то есть на самых свежих днях, оно не определено: для последних трёх дней нет будущего. Поэтому центрированное среднее — инструмент разбора истории, а на дашборде мониторинга стоит обычное, по прошлым дням.
| Вариант | 1 июня | 2 июня | 4 июня | 7 июня | 10 июня |
|---|---|---|---|---|---|
| rolling(7) | NaN | NaN | NaN | 67,3 | 90,9 |
| rolling(7, min_periods=1) | 32,0 | 40,0 | 56,8 | 67,3 | 90,9 |
| rolling(7, center=True) | NaN | NaN | 67,3 | 90,9 | NaN |
import pandas as pd
dau = pd.Series([32, 48, 67, 80, 100, 76, 68, 95, 96, 121],
index=pd.date_range('2026-06-01', periods=10, freq='D'))
print(dau.rolling(7).mean().round(1).tolist()) # первые 6 дней — NaN
print(dau.rolling(7, min_periods=1).mean().round(1).tolist()) # как AVG OVER в SQL
print(dau.rolling(7, center=True).mean().round(1).tolist()) # центрированное окноЕсть ли недельная сезонность: профиль дня недели
Чтобы увидеть сезонность, сначала убирают тренд, иначе на растущем ряду поздние дни недели окажутся выше ранних просто потому, что наступили позже. Для каждого дня берём центрированное среднее за 7 дней как оценку тренда, считаем отклонение DAU от него в штуках и в долях и усредняем по дням недели. Берём только дни с полным окном — с 4 июня по 26 августа, ровно 12 недель.
Недельная сезонность в данных есть, и она заметная. Среда, четверг и пятница выше тренда на 16–19 пользователей, суббота ниже на 13, воскресенье — на 29. В долях воскресенье даёт 0,885 от тренда, то есть −11,5%. Понедельник и вторник близки к тренду. Сумма семи отношений — 7,017, почти ровно 7: профиль сбалансирован, как и должно быть.
Теперь вопрос про вид декомпозиции. До 16 июля воскресенье отставало от тренда на 30,7 пользователя, после — на 27,8. В долях это −16,5% и −6,6%. За это время уровень ряда вырос больше чем вдвое, а провал в штуках остался прежним. Это признак аддитивной сезонности: в «Маяке» воскресенье «стоит» примерно 30 пользователей, а не фиксированный процент. По остальным дням картина шумнее; прогноз ниже этот вывод подтвердит.
| День | Разница с трендом | Отношение к тренду | Разница до 16 июля | Разница после | Отношение до 16 июля | Отношение после |
|---|---|---|---|---|---|---|
| Пн | −5,0 | 0,983 | −5,2 | −4,7 | 0,975 | 0,990 |
| Вт | −5,1 | 0,984 | −2,1 | −8,1 | 0,985 | 0,983 |
| Ср | +17,7 | 1,058 | +15,3 | +20,0 | 1,074 | 1,043 |
| Чт | +19,2 | 1,075 | +10,0 | +28,5 | 1,079 | 1,072 |
| Пт | +16,5 | 1,080 | +22,7 | +10,3 | 1,136 | 1,024 |
| Сб | −13,3 | 0,952 | −10,8 | −15,7 | 0,942 | 0,962 |
| Вс | −29,2 | 0,885 | −30,7 | −27,8 | 0,835 | 0,934 |
Учебная база «Маяк», 4 июня — 26 августа 2026; тренд — центрированное среднее за 7 дней.
with daily as (
select cast(event_time as date) as day, count(distinct user_id) as dau
from events
where event_name = 'app_open'
and event_time >= timestamp '2026-06-01'
and event_time < timestamp '2026-08-30'
group by cast(event_time as date)
),
smoothed as (
select
day,
dau,
avg(dau) over (order by day rows between 3 preceding and 3 following) as trend,
count(*) over (order by day rows between 3 preceding and 3 following) as n
from daily
)
select
extract(isodow from day) as weekday, -- 1 = понедельник, 7 = воскресенье
count(*) as days,
round(cast(avg(dau - trend) as numeric(18, 6)), 1) as diff,
round(cast(avg(dau / trend) as numeric(18, 6)), 3) as ratio,
round(cast(avg(dau - trend) filter (where day < date '2026-07-16') as numeric(18, 6)), 1) as diff_early,
round(cast(avg(dau - trend) filter (where day >= date '2026-07-16') as numeric(18, 6)), 1) as diff_late,
round(cast(avg(dau / trend) filter (where day < date '2026-07-16') as numeric(18, 6)), 3) as ratio_early,
round(cast(avg(dau / trend) filter (where day >= date '2026-07-16') as numeric(18, 6)), 3) as ratio_late
from smoothed
where n = 7
group by extract(isodow from day)
order by weekday;Неделя к неделе или день к дню
Изменение день к дню (DoD) сравнивает разные дни недели и на ряду с недельной сезонностью в основном показывает календарь: за 16–29 августа DoD прыгал от −10,8% до +11,8% и был отрицательным в 6 днях из 14. Неделя к неделе (WoW) сравнивает субботу с субботой: те же две недели дают от −4,0% до +14,5% и только 3 отрицательных дня. Рост продукта виден во втором столбце и почти не виден в первом.
В SQL оба сравнения делает LAG: lag(dau, 1) — вчера, lag(dau, 7) — тот же день неделю назад. Это снова сдвиг на строки, а не на дни, поэтому на ряду с пропусками его считают после календаря из предыдущего раздела. Функция разобрана в статьях LAG и LEAD и оконные функции ROW_NUMBER и LAG.
Самое устойчивое сравнение для еженедельного отчёта — скользящее среднее к самому себе неделю назад. 29 августа оно равно 518,1, 22 августа — 480,6: рост на 7,8% за неделю, без влияния того, какой сегодня день.
| День | DAU | День к дню | Неделя к неделе |
|---|---|---|---|
| Вс, 23 августа | 475 | +1,5% | +6,3% |
| Пн, 24 августа | 478 | +0,6% | −4,0% |
| Вт, 25 августа | 511 | +6,9% | +11,8% |
| Ср, 26 августа | 549 | +7,4% | +7,4% |
| Чт, 27 августа | 541 | −1,5% | +12,2% |
| Пт, 28 августа | 567 | +4,8% | +13,2% |
| Сб, 29 августа | 506 | −10,8% | +8,1% |
with daily as (
select cast(event_time as date) as day, count(distinct user_id) as dau
from events
where event_name = 'app_open'
and event_time >= timestamp '2026-06-01'
and event_time < timestamp '2026-08-30'
group by cast(event_time as date)
),
lagged as (
select
day,
dau,
lag(dau, 1) over (order by day) as prev_day,
lag(dau, 7) over (order by day) as week_ago
from daily
)
select
day,
extract(isodow from day) as weekday,
dau,
round(cast(100.0 * (dau - prev_day) / prev_day as numeric(18, 6)), 1) as dod_pct,
round(cast(100.0 * (dau - week_ago) / week_ago as numeric(18, 6)), 1) as wow_pct
from lagged
where day >= date '2026-08-16'
order by day;Экспоненциальное сглаживание и метод Хольта
Скользящее среднее даёт семи дням одинаковый вес и целиком забывает восьмой. Экспоненциальное сглаживание мягче: новая оценка уровня — взвешенная сумма нового значения и прошлой оценки. Вес последнего дня равен α, предыдущего — α(1 − α), ещё более раннего — α(1 − α)², веса убывают как геометрическая прогрессия. Метод предложили в конце 1950-х; в учебнике Хайндмана и Атанасопулоса «Forecasting: Principles and Practice» его истоки связывают с работами Брауна, Хольта и Уинтерса.
Параметр α от 0 до 1 задаёт память. При α близком к 1 модель почти повторяет последнее значение, при малом α долго помнит прошлое и сильно сглаживает. Прогноз простого экспоненциального сглаживания (SES) на любой горизонт — одно и то же число, последний уровень. Для ряда без тренда это разумно, для растущего — систематически занижено.
Метод Хольта добавляет вторую компоненту — наклон, то есть ожидаемый прирост за шаг. Уровень обновляется с учётом наклона, наклон — по изменению уровня со своим параметром β. Прогноз на h дней вперёд — уровень плюс h наклонов, то есть прямая из последней точки. Уинтерс добавил третью, сезонную компоненту (метод Хольта — Уинтерса). Мы сделаем похожее вручную: вычтем недельный профиль, сгладим ряд Хольтом и вернём профиль в прогноз.
SES: ℓₜ = α·yₜ + (1 − α)·ℓₜ₋₁; ŷₜ₊ₕ = ℓₜ. Хольт: ℓₜ = α·yₜ + (1 − α)·(ℓₜ₋₁ + bₜ₋₁); bₜ = β·(ℓₜ − ℓₜ₋₁) + (1 − β)·bₜ₋₁; ŷₜ₊ₕ = ℓₜ + h·bₜℓ — уровень, b — наклон (прирост за день), α и β — параметры сглаживания от 0 до 1, h — горизонт прогноза.
Прогноз на 14 дней: сравниваем с наивными методами
Модель учится на прошлом и отвечает на будущее, которого не видела: обучаем на 1 июня — 15 августа (76 дней), прогнозируем 16–29 августа (14 дней) и сравниваем с фактом. Параметры α и β подбираем перебором по сетке с шагом 0,05 только на обучающем отрезке, по ошибке прогноза на день вперёд. Недельный профиль тоже считаем только по обучающим дням. SES и Хольт написаны на numpy без statsmodels: по два десятка строк, зато видно, что внутри, и результат легко сверить с готовой библиотекой.
Любую модель нужно сравнивать с наивными прогнозами, иначе непонятно, заработала ли она свою сложность. Берём три: «как вчера» (последнее значение на все 14 дней), «как неделю назад» (повторяем последнюю неделю, это сезонный наивный прогноз) и «среднее последних 7 дней».
Метрики ошибки — MAE, средняя абсолютная ошибка в пользователях, и MAPE, средняя абсолютная ошибка в процентах от факта. MAPE удобна для рядов разного масштаба, но ломается при факте около нуля и несимметрична. Для DAU в сотни пользователей она работает нормально.
MAE = (1/n) · Σ |yₜ − ŷₜ| MAPE = (100% / n) · Σ |yₜ − ŷₜ| / yₜyₜ — факт, ŷₜ — прогноз, n — число дней проверки (здесь 14).
| Метод | Параметры | Прогноз | MAE, пользователей | MAPE |
|---|---|---|---|---|
| Как вчера | — | 472 на все дни | 33,6 | 6,4% |
| Как неделю назад | — | неделя 9–15 августа дважды | 42,2 | 8,2% |
| Среднее 7 дней | — | 458 на все дни | 43,1 | 8,3% |
| SES | α = 0,85 | 473 на все дни | 33,1 | 6,3% |
| Хольт | α = 0,65, β = 0,05 | от 482 до 547 | 25,3 | 5,2% |
| Хольт + день недели, аддитивно | α = 0,4, β = 0,1 | от 448 до 547 | 15,4 | 3,1% |
| Хольт + день недели, мультипликативно | α = 0,35, β = 0,15 | от 411 до 563 | 19,8 | 4,1% |
Учебная база «Маяк», расчёт numpy/pandas из блока выше. Подписи — числа августа.
import numpy as np
import pandas as pd
# DAU по дням, 1 июня — 29 августа 2026: результат запроса из раздела про скользящее среднее
dau = [32, 48, 67, 80, 100, 76, 68, 95, 96, 121, 131, 128, 123, 109, 139, 151,
167, 172, 194, 175, 162, 193, 202, 227, 222, 239, 202, 196, 230, 232,
268, 270, 283, 257, 248, 257, 276, 285, 277, 325, 275, 248, 315, 330,
361, 397, 377, 336, 324, 380, 370, 374, 413, 370, 365, 370, 367, 420,
410, 436, 455, 409, 394, 435, 408, 470, 484, 429, 430, 424, 435, 434,
479, 484, 478, 472, 447, 498, 457, 511, 482, 501, 468, 475, 478, 511,
549, 541, 567, 506]
y = pd.Series(dau, index=pd.date_range('2026-06-01', periods=len(dau), freq='D'))
train, test = y[:'2026-08-15'], y['2026-08-16':] # 76 дней на подбор, 14 на проверку
x, fact = train.to_numpy(float), test.to_numpy(float)
h = np.arange(1, len(test) + 1) # горизонт: 1…14 дней вперёд
def ses(x, alpha):
level, fitted = x[0], []
for v in x:
fitted.append(level) # прогноз дня по прошлым дням
level = alpha * v + (1 - alpha) * level
return level, np.array(fitted)
def holt(x, alpha, beta):
level, trend, fitted = x[0], x[1] - x[0], []
for v in x:
fitted.append(level + trend)
prev = level
level = alpha * v + (1 - alpha) * (level + trend)
trend = beta * (level - prev) + (1 - beta) * trend
return level, trend, np.array(fitted)
# параметры подбираем только на обучающем отрезке: по ошибке прогноза на день вперёд
grid = np.round(np.arange(0.05, 1.0, 0.05), 2)
fit_ses = lambda x: min(grid, key=lambda a: np.sum((x[1:] - ses(x, a)[1][1:]) ** 2))
fit_holt = lambda x: min(((a, b) for a in grid for b in grid),
key=lambda p: np.sum((x[2:] - holt(x, *p)[2][2:]) ** 2))
a = fit_ses(x)
level, _ = ses(x, a)
ph = fit_holt(x)
lh, th, _ = holt(x, *ph)
# недельный профиль тоже только по обучающему отрезку
trend7 = train.rolling(7, center=True).mean()
dev = (train - trend7).dropna() # аддитивный: «плюс-минус N человек»
add = dev.groupby(dev.index.dayofweek).mean()
add -= add.mean()
rat = (train / trend7).dropna() # мультипликативный: «плюс-минус N %»
mul = rat.groupby(rat.index.dayofweek).mean()
mul /= mul.mean()
wd, wd_test = train.index.dayofweek, test.index.dayofweek
xa = x - add.loc[wd].to_numpy()
pa = fit_holt(xa)
la, ta, _ = holt(xa, *pa)
xm = x / mul.loc[wd].to_numpy()
pm = fit_holt(xm)
lm, tm, _ = holt(xm, *pm)
forecasts = {
'вчера': np.full(len(test), x[-1]),
'неделю назад': np.resize(x[-7:], len(test)),
'среднее 7 дней': np.full(len(test), x[-7:].mean()),
f'SES, α={a}': np.full(len(test), level),
f'Хольт, α={ph[0]}, β={ph[1]}': lh + h * th,
f'Хольт + день недели, аддитивно, α={pa[0]}, β={pa[1]}': la + h * ta + add.loc[wd_test].to_numpy(),
f'Хольт + день недели, мультипликативно, α={pm[0]}, β={pm[1]}': (lm + h * tm) * mul.loc[wd_test].to_numpy(),
}
print('факт ', fact.astype(int).tolist())
for name, f in forecasts.items():
mae = np.mean(np.abs(fact - f))
mape = 100 * np.mean(np.abs(fact - f) / fact)
print(f'{name:<52} MAE {mae:5.1f} MAPE {mape:4.1f}% {np.round(f).astype(int).tolist()}')
print('Хольт: уровень', round(lh, 1), 'тренд', round(th, 2), 'в день')
print('аддитивный профиль, пн…вс:', add.round(1).tolist())Что показало сравнение прогнозов
Хуже всех — «среднее 7 дней» и «неделю назад»: на растущем ряду они смотрят в прошлое и занижают почти каждый день проверки. Простое сглаживание подобрало α = 0,85 и превратилось в прогноз «как вчера»: 473 против 472. Модели, которая умеет только уровень, на ряду с трендом выгоднее всего держаться за последнее значение.
Хольт оценил наклон в 5,0 пользователя в день и снизил ошибку до 5,2%. Но его прямая не видит недельного рисунка: во все четыре выходных дня проверки — 16, 22, 23 и 29 августа — прогноз выше факта на 35–44 пользователя. Добавление недельного профиля исправляет именно это: аддитивный вариант дал MAPE 3,1%, вдвое меньше прогноза «как вчера». Мультипликативный профиль оказался хуже, 4,1%: он переносит в август большие июньские проценты провала выходных. Это тот же вывод, что дал профиль дня недели в SQL.
Главное ограничение: это одна точка старта и 14 дней, на другом отрезке порядок методов может поменяться. В рабочей задаче прогноз проверяют из нескольких точек подряд — 1, 8 и 15 августа — и усредняют ошибку. И Хольт продолжает наклон бесконечно: на горизонте в месяцы прямая уйдёт туда, куда продукт не дойдёт, поэтому для дальних прогнозов наклон гасят (затухающий тренд).
ARIMA простыми словами
ARIMA — семейство моделей, которые описывают значение ряда через его прошлые значения и прошлые ошибки прогноза. Название складывается из трёх частей. AR, авторегрессия порядка p: сегодняшнее значение — линейная комбинация p предыдущих. MA порядка q: зависимость от q прошлых ошибок прогноза. Это не скользящее среднее из начала статьи, совпадение названий сбивает с толку многих. I, интегрированность порядка d: сколько раз ряд нужно продифференцировать, прежде чем строить AR и MA.
Дифференцирование нужно ради стационарности: у стационарного ряда нет тренда и сезонности, среднее и разброс не зависят от момента наблюдения. DAU «Маяка» нестационарен дважды: он растёт, и у него недельный ритм. Первая разность yₜ − yₜ₋₁ убирает тренд, сезонная разность yₜ − yₜ₋₇ — недельный рисунок. Модель, которая умеет и то и другое, называют сезонной ARIMA и записывают ARIMA(p,d,q)(P,D,Q) с периодом 7. Подход популяризировала книга Бокса и Дженкинса «Time Series Analysis: Forecasting and Control» 1970 года.
Руками ARIMA никто не пишет. В Python есть statsmodels: классы ARIMA и SARIMAX в модуле statsmodels.tsa, там же SimpleExpSmoothing, Holt, ExponentialSmoothing и декомпозиция STL. Для рядов с несколькими сезонностями и праздниками часто берут Prophet — открытую библиотеку команды Core Data Science компании Facebook для Python и R. Она складывает нелинейный тренд, недельную и годовую сезонность и эффекты праздников. Порядок работы прежний: сначала наивный прогноз и сглаживание, и только если они проиграли на честной проверке, — ARIMA или Prophet. На ряду в 90 дней сложная модель скорее подстроится под шум.
| Задача | Метод | Что учесть |
|---|---|---|
| Сгладить график для отчёта | Скользящее среднее за 7 дней | Отстаёт на ~3 дня, неполные окна в начале |
| Разложить историю на компоненты | Центрированное среднее + профиль дня недели, STL | Последние 3 дня без оценки тренда |
| Прогноз ряда без тренда | SES | На растущем ряду занижает |
| Прогноз ряда с трендом | Метод Хольта | Прямая вечна — для дальних горизонтов гасить наклон |
| Тренд и сезонность | Хольт — Уинтерс или Хольт по очищенному ряду | Аддитивную или мультипликативную сезонность проверить |
| Сложная автокорреляция, длинная история | ARIMA / SARIMA, Prophet | Сначала обыграть наивный прогноз |
Типичные ошибки в анализе временных рядов
Большинство ошибок здесь не про формулы, а про то, какие данные попали в расчёт и что с чем сравнили. Похожие ловушки с составом и календарём собраны в статье почему метрика обманывает, а про выбор самой метрики активности — в разборе DAU и MAU.
- Прогноз растущего ряда средним. Среднее за неделю на 16–29 августа ошиблось на 8,3% — больше всех методов. Среднее и SES годятся для ряда без тренда, для растущего нужен наклон.
- Утечка будущего. Центрированное среднее использует три будущих дня, профиль по всему ряду включает дни проверки, параметры, подобранные на тестовом отрезке, подогнаны под ответ. Всё для прогноза считается только по дням до его начала.
- Неполный последний день. 30 августа в «Маяке» выглядит как падение на 56%, хотя это полдня. Отрезайте текущий день.
- Разрыв ряда. Если день без событий выпал из выборки,
ROWS BETWEEN 6 PRECEDINGиlag(dau, 7)сдвигаются на строки, а не на дни. Календарь черезgenerate_seriesиLEFT JOINпоказывает пропуск нулём. - Сравнение периодов разной длины. В выгрузке июль длиннее августа на два дня, их суммы несравнимы. Сравнивайте среднее за день или равное число полных недель.
- Праздники и разовые события. Праздник ведёт себя как выходной не в свой день и портит профиль, если его не пометить. В учебной базе 12 июня, пятница, не выделяется: отношение к тренду 1,058 при среднем для пятниц 1,080. В реальном продукте держите календарь праздников, релизов и рассылок рядом с рядом.
Частые вопросы
Какое окно скользящего среднего выбрать?
Равное периоду сезонности: 7 для дневных данных с недельным ритмом, 24 для почасовых с суточным, 12 для месячных с годовым. Окно другой длины оставит сезонный рисунок в сглаженной линии.
Чем экспоненциальное сглаживание лучше скользящего среднего?
Оно плавно снижает вес старых наблюдений, а не обрезает их, и сразу даёт прогноз. Скользящее среднее описывает прошлое и на растущем ряду всегда отстаёт.
Как понять, что сезонность аддитивная, а не мультипликативная?
Посчитайте сезонные отклонения в штуках и в долях на ранней и поздней части ряда. Что стабильнее, тот вид и ближе. В «Маяке» воскресный провал держится около 30 пользователей, а в процентах слабеет с 16,5% до 6,6% — сезонность аддитивная.
Нужна ли ARIMA для прогноза DAU?
Обычно нет, пока не проверены наивный прогноз и Хольт с поправкой на день недели — на DAU «Маяка» он ошибается на 3,1%. ARIMA оправдана на длинной истории со сложной автокорреляцией.
Почему MAPE иногда вводит в заблуждение?
Она делит ошибку на факт: при факте около нуля процент взлетает, а при факте ноль не определён. Для метрик с малыми значениями используйте MAE или делите суммарную ошибку на суммарный факт.
Итог
Анализ временного ряда начинается с честного ряда: только закрытые дни, календарь без пропусков, явные границы. Скользящее среднее за 7 дней отделяет тренд от недельного ритма, профиль дня недели показывает вид сезонности, сравнение неделя к неделе заменяет «день к дню». Для прогноза на две недели часто хватает Хольта с поправкой на день недели, если он обыграл наивный прогноз на днях, которых не видел.
Все SQL-запросы статьи выполняются на учебной базе «Маяк» как есть — повторите их в песочнице и попробуйте задачи на оконные функции и даты в сборнике SQL-задач.
Материалы по теме
Почему метрика обманывает: состав, календарь, база и декомпозиция
Как честно посчитанная метрика приводит к неверному выводу: парадокс Симпсона, регрессия к среднему, сезонность, аномалии и декомпозиция роста по вкладам сегментов.

Распределение Пуассона: формула, примеры и проверка на данных
Распределение Пуассона простыми словами: формула Пуассона, пример расчёта вручную, почему λ — это и среднее, и дисперсия, проверка платежей по дням в SQL и Python, сверхдисперсия и пороги алертов.
Какой статистический метод выбрать: t-тест, хи-квадрат, бутстреп, Байес или регрессия
Навигатор по методам для аналитика: когда сравнивать средние t-тестом, доли и категории хи-квадратом, где нужен бутстреп, байесовский апдейт, линейная или логистическая регрессия — и что каждый из них не скажет.