SQL-пайплайны для A/B тестов: коротко

от автора

Привет, Хабр!

В этой статье разберём, как построить SQL-пайплайн для анализа A/B тестов с акцентом на чистку данных — дедупликацию, фильтрацию фрода, агрегацию и финальную сводку по пользователям.

Писать будем на PostgreSQL 13+, так как он отлично поддерживает нужные нам конструкции: FILTER, ROW_NUMBER(), CASE, NULLIF, CTE.

Задача: от событий к пользователям

Допустим, есть таблица events со следующими полями:

CREATE TABLE events (     user_id     INT,     event_id    BIGINT,     event_time  TIMESTAMP,     event_type  VARCHAR(50), -- click, conversion и т.д.     source      VARCHAR(50)  -- web, mobile, bot и т.п. );

Цель — собрать корректную пользовательскую статистику, исключив дубли, ботов и события вне нужного диапазона. Классическая ошибка здесь — просто сделать COUNT(*), не проверяя уникальность, валидность и контекст события.

Агрегация по типам событий

На первом этапе собираем количество событий по типам для каждого пользователя. Используем COUNT(DISTINCT ...) вместе с FILTER, чтобы контролировать выборку:

SELECT      user_id,     COUNT(DISTINCT event_id) FILTER (WHERE event_type = 'click') AS click_count,     COUNT(DISTINCT event_id) FILTER (WHERE event_type = 'conversion') AS conversion_count FROM events GROUP BY user_id;

Это даёт агрегацию без дублей, при условии, что event_id — действительно уникальный идентификатор события. Но если в логах встречаются дубли с разным временем — этого мало.

Дедупликация через ROW_NUMBER()

Одна из типичных проблем — когда один пользователь совершает несколько кликов подряд, и каждый фиксируется как отдельное событие. Нужно оставить только одно событие на пользователя и тип.

Используем ROW_NUMBER() OVER (PARTITION BY user_id, event_type ORDER BY event_time) для нумерации событий. Почему именно ROW_NUMBER(), а не RANK()? Потому что ROW_NUMBER() гарантирует строгое первое место, даже если несколько записей имеют одинаковое время — RANK() может дать одну и ту же позицию разным строкам, что ломает дедупликацию.

Фильтрация по rn = 1 даёт строгий контроль. И тот же DISTINCT в таком случае уже не нужен, если event_id уникален.

WITH ranked_events AS (     SELECT          user_id,         event_id,         event_time,         event_type,         ROW_NUMBER() OVER (             PARTITION BY user_id, event_type              ORDER BY event_time         ) AS rn     FROM events     WHERE event_type IN ('click', 'conversion') ) SELECT      user_id,     COUNT(CASE WHEN event_type = 'click' AND rn = 1 THEN 1 END) AS dedup_clicks,     COUNT(CASE WHEN event_type = 'conversion' AND rn = 1 THEN 1 END) AS dedup_conversions FROM ranked_events GROUP BY user_id;

Если нужны более строгие критерии, например, только один клик в 10 секунд, можно вместо ROW_NUMBER() использовать LAG() и считать дельты времени.

Анализ по источникам и защита от фрода

Один и тот же пользователь может приходить как с веба, так и от имени бота. Мы хотим понимать, откуда приходят конверсии, и кто их делает.

Пример агрегации с фильтрацией по source:

SELECT      user_id,     COUNT(DISTINCT event_id) FILTER (WHERE event_type = 'click' AND source = 'web') AS web_clicks,     COUNT(DISTINCT event_id) FILTER (WHERE event_type = 'conversion' AND source = 'web') AS web_conversions,     COUNT(DISTINCT event_id) FILTER (WHERE event_type = 'click' AND source = 'bot') AS bot_clicks,     COUNT(DISTINCT event_id) FILTER (WHERE event_type = 'conversion' AND source = 'bot') AS bot_conversions,     COUNT(DISTINCT event_id) FILTER (WHERE event_type = 'conversion' AND source = 'web')         / NULLIF(COUNT(DISTINCT event_id) FILTER (WHERE event_type = 'click' AND source = 'web'), 0) AS web_conversion_rate FROM events GROUP BY user_id;

NULLIF защищает от деления на ноль, если пользователь вообще не кликал.

Полный пайплайн

Соберём весь пайплайн целиком, с чисткой мусора, предположим, что анализируем январь 2025 года:

WITH filtered_events AS (     SELECT *     FROM events     WHERE source NOT IN ('bot', 'crawler')       AND event_time BETWEEN '2025-01-01' AND '2025-01-31' ), ranked_events AS (     SELECT          user_id,         event_id,         event_time,         event_type,         ROW_NUMBER() OVER (             PARTITION BY user_id, event_type              ORDER BY event_time         ) AS rn     FROM filtered_events ), aggregated_events AS (     SELECT          user_id,         COUNT(CASE WHEN event_type = 'click' AND rn = 1 THEN 1 END) AS dedup_clicks,         COUNT(CASE WHEN event_type = 'conversion' AND rn = 1 THEN 1 END) AS dedup_conversions     FROM ranked_events     GROUP BY user_id ) SELECT      u.user_id,     u.username,     ae.dedup_clicks,     ae.dedup_conversions,     CASE          WHEN ae.dedup_clicks > 0               THEN ROUND(ae.dedup_conversions::numeric / ae.dedup_clicks, 4)         ELSE NULL     END AS conversion_rate FROM users u LEFT JOIN aggregated_events ae ON u.user_id = ae.user_id;

Балансировка A/B групп

Чтобы A/B тест был валидным, группы A и B должны быть сбалансированы по ключевым метрикам на старте. Например, по числу активных пользователей, среднему количеству кликов и конверсий до T0.

Пример агрегации по группам до начала эксперимента:

WITH base_events AS (     SELECT *     FROM events     WHERE event_time < '2025-01-15' ), user_metrics AS (     SELECT          user_id,         group_id,         COUNT(DISTINCT CASE WHEN event_type = 'click' THEN event_id END) AS clicks,         COUNT(DISTINCT CASE WHEN event_type = 'conversion' THEN event_id END) AS conversions     FROM base_events     GROUP BY user_id, group_id ), initial_metrics AS (     SELECT          group_id,         COUNT(DISTINCT user_id) AS users_at_T0,         ROUND(AVG(clicks)::numeric, 2) AS avg_clicks,         ROUND(AVG(conversions)::numeric, 2) AS avg_conversions     FROM user_metrics     GROUP BY group_id ) SELECT * FROM initial_metrics;

Если группы A и B в этом отчёте показывают существенные расхождения — нужно либо применять корректировки (например, стратификацию), либо учитывать это при интерпретации результатов.

Заключение

Пайплайн можно вынести в DAG, обернуть в dbt-модель или встроить в CI/CD-аналитику. Главное — понимать, что валидность данных начинается задолго до вывода метрики на дашборд.

Если вы уже строили подобные пайплайны и у вас есть интересным опыт — делитесь опытом в комментариях.


Если вы хотите углубить свои знания в области управления требованиями и работы с заинтересованными сторонами, рекомендуем посетить открытые уроки в Otus, где профессионалы поделятся актуальными методами и подходами. Эти темы помогут вам эффективно решать сложные задачи в реальных проектах:

  • 10 апреля — Сквозной процесс управления требованиями.
    Подробнее

  • 22 апреля — Основы работы с потребностями заинтересованных сторон и бизнес-проблемами.
    Подробнее


ссылка на оригинал статьи https://habr.com/ru/articles/898114/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *