Добавить в корзинуПозвонить
Найти в Дзене
Цифровая Переплавка

Зачем машинному обучению SQL: шесть паттернов, которые ловят мошенников быстрее нейросетей

Сразу к делу: пост Fixel Smith — программного аналитика по целостности данных (Program Integrity Analyst), который работает с публичными финансовыми потоками — за пару дней разлетелся по сообществу аналитиков. И не зря. Это редкий жанр: не маркетинговый текст про «AI-powered fraud detection», а конкретные SQL-шаблоны от человека, который ловит мошенников каждый день и объясняет, почему оконные функции выигрывают у дорогущих ML-пайплайнов в большинстве реальных задач. Разберём все шесть паттернов, добавлю свой комментарий — и сразу скажу: тезис «лучше нейросетей» из заголовка перевода нужно понимать с оговорками. Главный аргумент автора звучит примерно так: когда аналитик может сформулировать новую гипотезу о мошенничестве как SQL-фильтр, а не как тикет в команду ML-инженеров, цикл итерации сокращается с недель до часов. Это не пафос, это организационная правда. ML-модель надо обучить, отвалидировать, прогнать через A/B, задеплоить, мониторить дрейф — и всё это под надзором безопасников
Оглавление

Сразу к делу: пост 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