Обнаружение аномалий во временных рядах

Учимся находить во временном ряде точки, выбивающиеся из нормального поведения, тремя практичными способами.

Аномалия — наблюдение, резко отклоняющееся от ожидаемого поведения ряда: всплеск трафика от ботов, провал продаж из-за сбоя, ошибочная выгрузка.

Зачем искать аномалии

Аномалии бывают двух родов: вредные (ошибка данных, которую надо вычистить перед обучением) и ценные (реальное событие — атака, поломка, всплеск спроса, на который надо среагировать). В обоих случаях их нужно автоматически находить: вручную просматривать тысячи рядов невозможно. Детекция аномалий — отдельная прикладная задача поверх анализа рядов.

Цена пропущенной аномалии бывает огромной. Для финтеха необнаруженный всплеск транзакций — это пропущенное мошенничество и прямые потери. Для DevOps просадка метрики, не пойманная вовремя, — это упавший сервис и недовольные пользователи. Для отдела продаж аномальный провал выручки в одном регионе может означать сломавшуюся интеграцию с платёжкой, о которой иначе узнали бы только из жалоб. Везде важна скорость: чем раньше система подняла флаг, тем дешевле обходится инцидент, поэтому детекция работает в реальном времени и без человека в цикле.

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

Способ 1: z-оценка

Простейший детектор: считаем, на сколько стандартных отклонений точка отстоит от среднего. Порог обычно 3 (или 2 для чувствительности).

import statistics
xs = [10, 11, 9, 10, 12, 40, 11, 9, 10]

m = statistics.mean(xs)
sd = statistics.pstdev(xs)
print("mean", round(m, 2), "sd", round(sd, 2))

for i, x in enumerate(xs):
    z = (x - m) / sd
    if abs(z) > 2:
        print("аномалия: индекс", i, "значение", x, "z =", round(z, 2))

Вывод:

mean 13.56 sd 9.39
аномалия: индекс 5 значение 40 z = 2.82

Точка 40 на индексе 5 выбивается на 2.82 сигмы — детектор её поймал. Но обратите внимание: сам выброс раздул среднее (13.56) и сигму (9.39), маскируя себя. Это слабость глобальной z-оценки на рядах с трендом и сезонностью.

Эффект самомаскировки стоит запомнить. Без выброса среднее было бы около 10, а сигма — меньше единицы, и точка 40 дала бы z в десятки сигм. Но выброс участвует в расчёте собственных эталонов: он тянет среднее к себе и раздувает разброс, занижая свою же z-оценку до 2.82. Если выбросов несколько, они «прикроют» друг друга, и порог их вовсе пропустит. Робастные варианты обходят это, заменяя среднее на медиану, а сигму — на устойчивую к выбросам MAD (медианное абсолютное отклонение), которую один скачок почти не сдвигает.

Способ 2: скользящая медиана

Чтобы детектор не «слепило» трендом, сравнивают точку не с глобальным средним, а с локальной медианой в окне. Медиана устойчива к выбросам — один скачок её почти не двигает.

vals = [10, 11, 12, 11, 10, 30, 12, 11]
w = 3

def median(a):
    s = sorted(a); n = len(s)
    return s[n//2] if n % 2 else (s[n//2-1] + s[n//2]) / 2

for i in range(len(vals)):
    window = vals[max(0, i-w):i]      # только прошлое
    if len(window) >= 2:
        med = median(window)
        if abs(vals[i] - med) > 5:
            print("индекс", i, "значение", vals[i], "медиана", med, "остаток", vals[i] - med)

Вывод:

индекс 5 значение 30 медиана 11 остаток 19

Точка 30 отстоит от локальной медианы 11 на 19 — явная аномалия. Поскольку окно берёт только прошлое, такой детектор работает в реальном времени и не «подсматривает» будущее.

Локальность окна решает сразу две проблемы. Во-первых, на ряде с трендом глобальное среднее безнадёжно устаревает: точка, нормальная для сегодняшнего уровня, выглядит «аномальной» относительно среднего за всю историю. Скользящее окно сравнивает точку с её ближайшими соседями, поэтому медленный тренд не порождает ложных тревог. Во-вторых, ширина окна w — это ручка чувствительности: узкое окно быстро адаптируется, но шумит, широкое — стабильнее, но медленнее замечает смену уровня. Подбор w под частоту и волатильность конкретного ряда — практическая часть настройки детектора.

Способ 3: остатки декомпозиции

Самый надёжный путь для рядов с трендом и сезонностью — сначала разложить ряд (раздел 6), а аномалии искать в остатке. После вычитания тренда и сезонности нормальные точки дают маленький остаток, а аномальные — большой. Так детектор не путает законный сезонный пик с настоящим выбросом.

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

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

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

Из этой единой схемы вытекает практический закон выбора порога — баланс между ложными тревогами и пропусками. Опустите порог — поймаете больше настоящих аномалий, но утоните в ложных срабатываниях, и команда перестанет реагировать на алерты. Поднимете — алертов меньше, но пропустите реальные инциденты. Где провести черту, зависит от цены ошибки каждого типа: в фроде дешевле перепроверить ложную тревогу, чем пропустить мошенника, поэтому порог опускают; в шумном мониторинге, наоборот, ценят тишину и порог поднимают. Универсального числа нет — есть осознанный компромисс под конкретную задачу.

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

  • Применять глобальную z-оценку к ряду с трендом — нормальные точки «убегают» за порог.
  • Считать всякий сезонный пик аномалией — сначала уберите сезонность.
  • Брать окно, включающее будущее, — детектор станет неприменим в реальном времени.
  • Ставить порог вслепую, не взвесив цену ложной тревоги против цены пропуска.
  • Чистить ряд от всех выбросов подряд, выкидывая вместе с шумом реальные ценные события.

Итоги

  • Аномалия — резкое отклонение от ожидаемого поведения ряда; бывает вредной и ценной.
  • z-оценка проста, но слепнет на тренде; скользящая медиана устойчивее.
  • Лучший путь — искать аномалии в остатке после декомпозиции тренда и сезонности.
  • Выбор порога — компромисс между ложными тревогами и пропущенными инцидентами.
Проверьте себя
1. Почему глобальная z-оценка плохо ловит аномалии в ряде с трендом?
AОна слишком медленная
BТренд и сам выброс смещают среднее и сигму, маскируя аномалии
Cz-оценка не работает с числами
DНужен numpy
2. Где надёжнее всего искать аномалии в ряде с трендом и сезонностью?
AВ исходном уровне ряда
BВ остатке после декомпозиции тренда и сезонности
CВ первой точке ряда
DВ заголовке файла