Обнаружение аномалий во временных рядах
Учимся находить во временном ряде точки, выбивающиеся из нормального поведения, тремя практичными способами.
Аномалия — наблюдение, резко отклоняющееся от ожидаемого поведения ряда: всплеск трафика от ботов, провал продаж из-за сбоя, ошибочная выгрузка.
Зачем искать аномалии
Аномалии бывают двух родов: вредные (ошибка данных, которую надо вычистить перед обучением) и ценные (реальное событие — атака, поломка, всплеск спроса, на который надо среагировать). В обоих случаях их нужно автоматически находить: вручную просматривать тысячи рядов невозможно. Детекция аномалий — отдельная прикладная задача поверх анализа рядов.
Цена пропущенной аномалии бывает огромной. Для финтеха необнаруженный всплеск транзакций — это пропущенное мошенничество и прямые потери. Для 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-оценка проста, но слепнет на тренде; скользящая медиана устойчивее.
- Лучший путь — искать аномалии в остатке после декомпозиции тренда и сезонности.
- Выбор порога — компромисс между ложными тревогами и пропущенными инцидентами.