Бран · продюсер «Хроник Пограничья»
Про бот-ферму в апреле я узнал из вашего отчёта через месяц. Хочу, чтобы о таком мне писал робот наутро. Только чтобы он не орал каждую субботу, иначе я его отключу на второй неделе.
В уроке про DAU мы нашли бот-ферму farm…x вручную: четыре аккаунта, которые сидели в игре почти круглые сутки. Бран просит, чтобы такое находил запрос, сам и каждый день. Это алерт. В прошлом уроке мы научились отличать сезон от тренда. Сегодня научим запрос отличать нормальный день от ненормального.
Метрика для алерта: прохождения квестов за день, quest_runs. Боты проходят квесты сотнями, так что авария должна быть видна.
Пожарная сигнализация
Датчик дыма на кухне орёт от подгоревшего тоста. Если орёт каждое утро, его снимают с потолка, и в день реального пожара он молчит на полке. Если сделать его глухим к тостам, он заметит пожар, когда дым уже под потолком. Хорошая сигнализация не идеальная. Она настроена так, что люди ей ещё верят.
С алертом то же. Каждый ошибочный звонок это ложная тревога, каждая пропущенная авария это пропуск. Убрать оба сразу нельзя, можно только выбрать, чем платить.
Правило: среднее плюс-минус k отклонений
Самое понятное правило: день ненормальный, если его значение отличается от среднего за прошлые 28 дней больше чем на k стандартных отклонений тех же 28 дней. Это тот же z из урока про описательную статистику, только среднее и отклонение каждый день свои, по скользящему окну.
WITH daily AS (
SELECT started_at::date AS day, count(*) AS runs
FROM quest_runs
GROUP BY 1
), stats AS (
SELECT day, runs,
avg(runs) OVER w AS mean_28,
stddev_samp(runs) OVER w AS sd_28,
count(*) OVER w AS history
FROM daily
WINDOW w AS (ORDER BY day ROWS BETWEEN 28 PRECEDING AND 1 PRECEDING)
)
SELECT day, runs, round(mean_28, 1) AS mean_28, round(sd_28, 1) AS sd_28,
round((runs - mean_28) / sd_28, 2) AS z
FROM stats
WHERE history = 28 AND abs(runs - mean_28) > 3 * sd_28
ORDER BY day;
Главное в запросе это рамка 28 PRECEDING AND 1 PRECEDING: в окно не входит сам проверяемый день. Разницу видно на цифрах. С честной рамкой 2 апреля даёт z = 6,78. Если взять 27 PRECEDING AND CURRENT ROW, выброс сам себе поднимает среднее и раздувает отклонение, и z падает до 4,13. У такого окна есть потолок: день, который входит в своё окно из 28 строк, не может получить z больше 27 / sqrt(28), то есть 5,1, какой бы ни была авария. Алерт сравнивает день с прошлым, как и в жизни: утром ты знаешь только вчерашние дни.
history = 28 выкидывает первые четыре недели игры: по пяти точкам отклонение ничего не значит.
day | runs | mean_28 | sd_28 | z
------------+------+---------+-------+------
2026-02-21 | 211 | 131.6 | 22.1 | 3.59
2026-04-02 | 349 | 184.0 | 24.3 | 6.78
2026-04-03 | 523 | 191.6 | 38.1 | 8.69
2026-04-04 | 718 | 204.6 | 72.8 | 7.05
2026-04-05 | 972 | 223.4 | 121.2 | 6.18
2026-04-06 | 1014 | 251.8 | 185.8 | 4.10
Бот-ферма поймана в первый же день, 2 апреля. Брану пришло бы письмо утром 3-го. Но в списке есть два изъяна.
Изъян 1: алерт слепнет
Боты сидели до 14 апреля, а тревоги кончились 6-го. 7 апреля было 978 прохождений, впятеро больше нормы, а z всего 2,97. Аномальные дни сами вошли в окно: среднее выросло до 282, отклонение до 234. Через неделю аварии алерт считает её нормой.
Три способа лечить, от простого к сложному:
- сравнивать день с тем же днём недели, тогда аномалия попадает в окно реже (ниже);
- брать вместо среднего и отклонения медиану и разброс квантилей: им несколько выбросов почти не мешают;
- не пускать в окно дни, на которые алерт уже сработал. В SQL это неудобно: нужен рекурсивный запрос или таблица отмеченных дней.
урок из курса
Урок из курса «Аналитика данных с нуля». Курс покупается один раз и остаётся навсегда: 500 ₽.
Оплата пока не подключена: премиум и курсы открываются промокодом.