Сразу к делу: пост Fixel Smith — программного аналитика по целостности данных (Program Integrity Analyst), который работает с публичными финансовыми потоками — за пару дней разлетелся по сообществу аналитиков. И не зря. Это редкий жанр: не маркетинговый текст про «AI-powered fraud detection», а конкретные SQL-шаблоны от человека, который ловит мошенников каждый день и объясняет, почему оконные функции выигрывают у дорогущих ML-пайплайнов в большинстве реальных задач. Разберём все шесть паттернов, добавлю свой комментарий — и сразу скажу: тезис «лучше нейросетей» из заголовка перевода нужно понимать с оговорками.
Контекст: почему вообще SQL, а не ML
Главный аргумент автора звучит примерно так: когда аналитик может сформулировать новую гипотезу о мошенничестве как SQL-фильтр, а не как тикет в команду ML-инженеров, цикл итерации сокращается с недель до часов. Это не пафос, это организационная правда. ML-модель надо обучить, отвалидировать, прогнать через A/B, задеплоить, мониторить дрейф — и всё это под надзором безопасников и регуляторов. SQL-запрос пишется за полчаса, тестируется на исторических данных за десять минут, и если он находит фрод — его можно поставить на стрим уже сегодня.
Я работал в подобной парадигме на нескольких хакатонах (в том числе на финтех-задачах MOEX), и могу подтвердить: 80% «фрод-эвристик» — это правила, которые формулируются на естественном языке за минуту и выражаются на SQL за пять. ML начинает имеет смысл там, где правила становятся слишком сложными для человека или их слишком много, чтобы поддерживать вручную. Но точка входа в фрод-мониторинг — это всегда SQL.
Поехали по паттернам.
Паттерн первый: скользящее окно скорости транзакций (velocity)
Идея простая: если по карте за 5 минут прошло 5+ операций — это либо тест украденных данных, либо вендинговый автомат на распродаже. Ключевая техника — RANGE BETWEEN INTERVAL в оконной функции:
SELECT
cardholder_id,
timestamp,
COUNT(*) OVER (
PARTITION BY cardholder_id
ORDER BY timestamp
RANGE BETWEEN INTERVAL '5 minutes' PRECEDING AND CURRENT ROW
) AS tx_in_last_5min
FROM transactions
QUALIFY tx_in_last_5min >= 5
ORDER BY cardholder_id, timestamp;
Важная техническая деталь, которую обычно опускают в туториалах:
⚙️ Конструкция QUALIFY работает в Snowflake, BigQuery, Databricks, Teradata — это сахар для фильтрации результата оконной функции
🐘 В PostgreSQL QUALIFY нет, поэтому весь запрос оборачивается в CTE, а WHERE ставится снаружи — тот же результат, чуть больше букв
🕐 Автор советует параллельно гонять три версии: 1 минута, 5 минут, 1 час — потому что разные виды фрода работают на разных временных масштабах. Card-testing бьёт по серверу секундами, а социалка с «продажей льгот» может растянуться на полдня
Ложноположительные срабатывания неизбежны: операторы вендинговых сетей, перезагрузка предоплаченных карт оптом, кассиры в супермаркетах. Поэтому белый список после первого прохода — обязательная часть пайплайна.
Паттерн второй: «невозможные перемещения» (impossible travel)
Любимая многими история: карта прошла транзакцию в Чикаго, а через семь минут — в Лос-Анджелесе. Расстояние 2800 км, на коммерческом авиалайнере это 4+ часа. Одна из этих транзакций — клон.
Технически это считается через формулу гаверсинуса (Haversine), которая даёт расстояние между двумя точками на сфере по широте и долготе. Автор использует порог 600 миль/час — крейсерская скорость гражданского лайнера. Если вычисленная «скорость перемещения» между двумя последовательными транзакциями превышает это значение, значит, физически такое невозможно.
WITH paired AS (
SELECT
cardholder_id,
timestamp,
lat, lon,
LAG(timestamp) OVER (PARTITION BY cardholder_id ORDER BY timestamp) AS prev_ts,
LAG(lat) OVER (PARTITION BY cardholder_id ORDER BY timestamp) AS prev_lat,
LAG(lon) OVER (PARTITION BY cardholder_id ORDER BY timestamp) AS prev_lon
FROM transactions
)
SELECT *,
-- здесь подставляется формула haversine, возвращающая расстояние в милях
haversine_miles(lat, lon, prev_lat, prev_lon) /
NULLIF(EXTRACT(EPOCH FROM (timestamp - prev_ts))/3600.0, 0) AS implied_mph
FROM paired
WHERE prev_ts IS NOT NULL
AND haversine_miles(lat, lon, prev_lat, prev_lon) /
NULLIF(EXTRACT(EPOCH FROM (timestamp - prev_ts))/3600.0, 0) > 600;
Слабое место паттерна — он не ловит фрод внутри одного мегаполиса. Клонированную карту, которой расплатились в Москве и через 20 минут — тоже в Москве, этот метод не увидит. Поэтому он почти никогда не используется в одиночку.
Паттерн третий: аномалии в суммах
Тут две разных подгруппы сигналов, и важно их не смешивать:
💵 Круглые маленькие суммы ($1.00, $5.00, $10.00) — почти всегда тест украденной карты. Реальный человек редко покупает что-то ровно на доллар: кофе стоит $4.73, бензин — $52.81. Округлённость и есть сигнал.
🚧 Суммы прямо под порогом: $99.99 (порог проверки документов на кассе), $499.99 (дневной лимит банкомата). Это уже не тест, а обход правил. Тот, кто это делает, знает регламент и держится на доллар ниже триггера.
SELECT cardholder_id, timestamp, amount, merchant_id
FROM transactions
WHERE (amount >= 99.50 AND amount < 100.00)
OR (amount >= 499.50 AND amount < 500.00)
OR amount IN (1.00, 5.00, 10.00)
ORDER BY cardholder_id, timestamp;
Автор честно отмечает: для соцвыплат (benefits transactions) паттерн круглых сумм не работает — там и так все суммы стандартизированы. То есть универсальных правил нет, каждая доменная область требует адаптации порогов.
Паттерн четвёртый: скомпрометированные мерчанты
Это самый красивый паттерн с точки зрения дизайна. Сценарий: скиммер ставится на считыватель карт у бензоколонки. Дальше две недели каждая прошедшая через эту колонку карта оказывается в чьей-то базе. С точки зрения мерчанта симптом — резкий всплеск разнородных карт, тратящих больше обычного.
Наивный подход — поставить статический порог «больше 50 уникальных карт в час — подозрительно». И он, конечно, развалится: у крупного супермаркета такие цифры — норма, а у маленькой автозаправки даже 10 — уже выброс. Поэтому автор предлагает сравнивать мерчанта самого с собой:
WITH merchant_hourly AS (
SELECT
merchant_id,
DATE_TRUNC('hour', timestamp) AS hour_bucket,
COUNT(DISTINCT cardholder_id) AS unique_cards
FROM transactions
WHERE timestamp >= current_date - INTERVAL '60 days'
GROUP BY merchant_id, DATE_TRUNC('hour', timestamp)
),
baseline AS (
SELECT
merchant_id,
hour_bucket,
unique_cards,
AVG(unique_cards) OVER (
PARTITION BY merchant_id
ORDER BY hour_bucket
RANGE BETWEEN INTERVAL '168 hours' PRECEDING AND INTERVAL '1 hour' PRECEDING
) AS rolling_avg
FROM merchant_hourly
)
SELECT *
FROM baseline
WHERE unique_cards > 3 * rolling_avg;
Магическое число здесь — 168 часов, то есть ровно неделя. Это не случайность: торговля имеет жёсткую недельную сезонность, и скользящее среднее за 7 дней естественным образом гасит «понедельник тише субботы». Сравнивать день недели сам с собой — стандартный приём в эконометрике временных рядов, и фрод-аналитика его уверенно перенимает.
Паттерн пятый: транзакции «не в своё время»
У большинства людей есть устойчивый временной профиль. Один в 7 утра берёт кофе, другой по средам играет в покер до часа ночи и тратит карту в баре. Идея пятого паттерна — построить 90-дневный поведенческий бейзлайн по часам суток для каждой карты, и флажить любую транзакцию в час, который для этого держателя нетипичен.
Важная тонкость от автора: чтобы час считался «нормальным», в нём должно быть минимум две покупки за 90 дней. Одна — это статистический шум, две — уже паттерн. Если в три часа ночи человек заходил в магазин один раз за квартал — и вдруг там идёт операция, это сигнал.
WITH cardholder_hour_baseline AS (
SELECT
cardholder_id,
EXTRACT(HOUR FROM timestamp) AS hour_of_day,
COUNT(*) AS purchases_in_hour
FROM transactions
WHERE timestamp >= current_date - INTERVAL '90 days'
GROUP BY cardholder_id, EXTRACT(HOUR FROM timestamp)
HAVING COUNT(*) >= 2
)
SELECT t.*
FROM transactions t
LEFT JOIN cardholder_hour_baseline b
ON t.cardholder_id = b.cardholder_id
AND EXTRACT(HOUR FROM t.timestamp) = b.hour_of_day
WHERE b.cardholder_id IS NULL
AND t.timestamp >= current_date - INTERVAL '1 day';
Этот паттерн фактически — простейший байесовский классификатор без формализма. Мы говорим: «если эта операция произошла во время, в которое держатель раньше почти ничего не покупал, вероятность фрода повышена». Никакого ML, чистая статистика часов.
Паттерн шестой: оконные примитивы как библиотека
А вот это, на мой взгляд, главная идея всего поста — и причина, по которой я бы рекомендовал прочитать оригинал даже тем, кому первые пять паттернов знакомы. Автор предлагает не писать каждый фрод-запрос «с нуля», а собрать в виде переиспользуемых view или CTE набор примитивов:
🧱 previous_tx_for_card — предыдущая транзакция держателя через LAG
🧱 rank_per_card — порядковый номер транзакции через ROW_NUMBER
🧱 time_since_prev — секунды до предыдущей операции
🧱 distance_from_prev — расстояние от предыдущей точки (haversine)
🧱 merchant_zscore — z-score суммы относительно среднего по мерчанту
И тогда новая фрод-гипотеза превращается в одну строчку с булевой комбинацией этих примитивов. Аналитик не должен помнить синтаксис RANGE BETWEEN INTERVAL, он просто пишет «дай мне все строки, где time_since_prev < 300 AND distance_from_prev > 50». Это та же идея, что в feature store у ML-команд, только дешевле в десять раз и читается без дешифратора.
Моё мнение: где автор перегибает, а где попадает в десятку
Тезис про «лучше нейросетей» в переводе заголовка я бы смягчил. В оригинале автор аккуратнее: он не говорит, что SQL детектирует фрод лучше, чем ML. Он говорит, что итерационный цикл на SQL короче, а значит — на практике вы поймаете больше реального фрода, чем команда с дорогим ML-стэком, которая катит каждую новую гипотезу по три недели. Это две разных метрики.
Где SQL реально обыгрывает ML:
🎯 Скоринг по 5–10 интерпретируемым правилам — там, где регулятор требует объяснимости (банковский комплаенс, государственные программы)
⚡ Реактивная адаптация под новый вид атаки — увидели card-testing рваными суммами на $13.37, через час уже фильтр в проде
📉 Случаи с экстремально несбалансированными классами (фрод — это 0,1% транзакций), где ML без феноменальной feature engineering вырождается в детектор большинства
Где SQL начинает буксовать и нужен ML:
🕸️ Графовые задачи — обнаружение колец мошенников через социальные связи. Это автор сам отмечает: «detecting fraud rings is a social-graph problem»
📊 Когда сигналов становятся сотни и поддерживать вручную их веса невозможно — здесь градиентный бустинг даёт реальный прирост
🧠 Поведенческие модели на основе последовательностей — где RNN/трансформеры могут найти паттерны, которые человек не сформулирует как правило
И последнее, что мне понравилось у автора и что часто упускают: ни один из шести паттернов не работает в одиночку. Скорость даёт ложные срабатывания на вендинговых сетях. Невозможные перемещения промахиваются внутри одного города. Аномальные суммы не применимы к соцвыплатам. Правильная схема — гонять все шесть параллельно и считать, сколько из них «выстрелили» по конкретной транзакции. Три-четыре сработавших сигнала — это почти гарантированный фрод. Один сигнал — это бабушка, которая в отпуске странно расплачивается дебеткой.
Что в итоге
Этот пост — отличный пример того, как опытный практик упаковывает многолетний опыт в шесть конкретных шаблонов. Без хайпа, без обещаний «AI-powered fraud prevention», без графовых нейросетей. Просто оконные функции, разумные пороги, скользящие бейзлайны и здравый смысл.
Если вы только начинаете строить фрод-мониторинг, начните с первого паттерна — velocity. Он даст много фрода и мало шума, прост в реализации, дёшев в эксплуатации. Если у вас уже есть первые пять — самые большие дивиденды принесёт шестой: библиотека оконных примитивов, после которой новый фрод-сигнал перестаёт быть проектом и становится двухминутным запросом.
А ML и нейросети — это следующий слой. Не альтернатива SQL, а надстройка над ним, которая берёт те же фичи (velocity, distance, z-score) и учит на них что-то более тонкое. Один без другого — половинка стратегии.
Источники
📰 Six SQL patterns I use to catch transaction fraud — Fixel Smith (Analytics)
🔄 Six SQL Patterns for Scalable Transaction Fraud Detection — Dev|Journal (обзор статьи)
🏗️ Fraud Detection Architecture With Real-Time Intelligence — Microsoft Fabric Docs
📊 SQL for Fraud Analysts: How to Spot Suspicious Transactions in Minutes — Medium
🔍 Detecting Fraud with SQL: Patterns, Time Series, and Anomalies — Medium