A/B-тестыSQLстатистикаPostgreSQLаналитика

A/B-тест в SQL: расчёт результатов без Python

2026-07-05 12 мин

Короткий ответ (BLUF): да, результаты A/B-теста можно посчитать целиком в SQL — конверсии по группам, абсолютную и относительную разницу, z-тест пропорций с p-value, доверительный интервал и проверку SRM. Для бинарной метрики (конверсия) SQL закрывает 90% случаев прямо в одном запросе. Для непрерывных метрик, бутстрапа и последовательного анализа лучше уйти в Python — но чтобы понять «релизим или нет», часто хватает WITH и пары CASE.

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


Пример данных

Возьмём типичную таблицу назначений в эксперимент и событий. Одна строка — один пользователь в группе, плюс факт целевого действия (например, оформил заказ).

CREATE TABLE ab_events (
    user_id    bigint,
    variant    text,      -- 'control' или 'treatment'
    bucketed_at timestamp,
    converted  int        -- 1 если совершил целевое действие, иначе 0
);

INSERT INTO ab_events VALUES
    (1, 'control',   '2026-06-01', 0),
    (2, 'control',   '2026-06-01', 1),
    (3, 'treatment', '2026-06-01', 1),
    (4, 'treatment', '2026-06-01', 0);
-- ... и так далее на десятки тысяч строк

Одно правило, которое экономит часы дебага: одна строка на пользователя. Если пользователь попал в таблицу дважды (двойной bucketing, ретаргетинг, повторный визит), агрегаты поедут. Перед расчётом всегда дедуплицируйте — DISTINCT ON (user_id) по первому назначению.


Шаг 1. Конверсии по группам

Базовый агрегат — количество пользователей, число конверсий и доля. AVG(converted::numeric) по колонке из нулей и единиц — это и есть конверсия.

SELECT
    variant,
    COUNT(*)                              AS users,
    SUM(converted)                        AS conversions,
    ROUND(AVG(converted::numeric), 4)     AS conversion_rate
FROM ab_events
GROUP BY variant;

Результат:

 variant   | users  | conversions | conversion_rate
-----------+--------+-------------+-----------------
 control   | 24310  |    1215     |    0.0500
 treatment | 24188  |    1330     |    0.0550

Контроль 5.00%, тест 5.50%. Абсолютная разница — половина процентного пункта. Значимо ли это? Пока непонятно — при малых выборках такая разница могла бы быть шумом. Нужен статистический тест.


Шаг 2. z-тест пропорций прямо в SQL

Для сравнения двух конверсий (двух пропорций) классический инструмент — двухвыборочный z-тест. Он проверяет гипотезу $H_0: p_1 = p_2$ против $H_1: p_1 \neq p_2$.

Статистика теста:

$$z = \frac{\hat{p}_1 - \hat{p}_2}{\sqrt{\hat{p}(1-\hat{p})\left(\frac{1}{n_1} + \frac{1}{n_2}\right)}}$$

где $\hat{p}_1, \hat{p}_2$ — конверсии в группах, $n_1, n_2$ — размеры групп, а $\hat{p}$ — объединённая (pooled) конверсия:

$$\hat{p} = \frac{x_1 + x_2}{n_1 + n_2}$$

Здесь $x_1, x_2$ — число конверсий в каждой группе. Pooled-пропорция используется потому, что при истинной $H_0$ обе группы имеют одну и ту же вероятность конверсии, и объединённая оценка точнее.

Переносим формулу в запрос один-в-один. Собираем агрегаты по группам в CTE, разворачиваем их в одну строку через FILTER, считаем z:

WITH agg AS (
    SELECT
        variant,
        COUNT(*)::numeric        AS n,
        SUM(converted)::numeric  AS x
    FROM ab_events
    GROUP BY variant
),
stats AS (
    SELECT
        MAX(n) FILTER (WHERE variant = 'control')   AS n_c,
        MAX(x) FILTER (WHERE variant = 'control')   AS x_c,
        MAX(n) FILTER (WHERE variant = 'treatment') AS n_t,
        MAX(x) FILTER (WHERE variant = 'treatment') AS x_t
    FROM agg
),
calc AS (
    SELECT
        x_c / n_c                          AS p_c,
        x_t / n_t                          AS p_t,
        (x_c + x_t) / (n_c + n_t)          AS p_pool,
        n_c, n_t
    FROM stats
)
SELECT
    ROUND(p_c, 4)  AS conv_control,
    ROUND(p_t, 4)  AS conv_treatment,
    ROUND(p_t - p_c, 4)                    AS abs_diff,
    ROUND((p_t - p_c) / p_c, 4)            AS rel_uplift,
    ROUND(
        (p_t - p_c) /
        sqrt(p_pool * (1 - p_pool) * (1.0/n_c + 1.0/n_t))
    , 4) AS z_score
FROM calc;

Разбор ключевых кусков:

Результат:

 conv_control | conv_treatment | abs_diff | rel_uplift | z_score
--------------+----------------+----------+------------+---------
    0.0500    |    0.0550      |  0.0050  |   0.1000   |  2.55

z = 2.55. Относительный аплифт 10%. Осталось превратить z в p-value.


Шаг 3. p-value из z-score

Вот единственное место, где чистый SQL спотыкается: в PostgreSQL нет встроенной функции нормального CDF (Φ). Двусторонний p-value считается как:

$$p = 2 \cdot \left(1 - \Phi(|z|)\right)$$

Есть два практичных пути.

Вариант А — таблица критических значений. Для решения «релизить или нет» точное p-value часто не нужно, нужен порог. Сравниваем $|z|$ с критическими значениями:

SELECT
    z_score,
    CASE
        WHEN abs(z_score) >= 2.576 THEN 'p < 0.01  (значимо на 99%)'
        WHEN abs(z_score) >= 1.960 THEN 'p < 0.05  (значимо на 95%)'
        WHEN abs(z_score) >= 1.645 THEN 'p < 0.10  (на грани)'
        ELSE 'не значимо'
    END AS verdict
FROM (/* запрос из шага 2 */) t;

$|z| = 2.55 > 1.96$, значит p < 0.05 — разница значима на уровне 95%. Для большинства продуктовых решений этого достаточно.

Вариант Б — аппроксимация нормального CDF. Если хочется число, а не диапазон, вставляем приближение функции ошибок (Abramowitz–Stegun). Точность около $10^{-7}$ — с запасом для аналитики:

CREATE OR REPLACE FUNCTION norm_cdf(z double precision)
RETURNS double precision AS $$
    SELECT 0.5 * (1 + sign(z) * sqrt(1 - exp(-2 * z * z / pi())));
$$ LANGUAGE sql IMMUTABLE;

Это компактная аппроксимация Полиа. Тогда двусторонний p-value:

SELECT ROUND((2 * (1 - norm_cdf(abs(z_score))))::numeric, 5) AS p_value
FROM (/* запрос из шага 2 */) t;
-- 0.01077

Для z = 2.55 получаем p ≈ 0.011 — совпадает с тем, что выдаст scipy.stats до третьего знака. Если нужна аптечная точность (публикация, регуляторика) — считайте в Python, но для внутреннего дашборда аппроксимация честно закрывает вопрос.


Шаг 4. Доверительный интервал разницы

p-value отвечает «значимо ли», а доверительный интервал — «насколько велик эффект и где он лежит». Продакту второе важнее: аплифт 10% с интервалом [1%, 19%] и с интервалом [9.5%, 10.5%] — это два разных уровня уверенности при одном и том же p.

95% CI для разницы пропорций (не-pooled стандартная ошибка, потому что тут мы оцениваем сам эффект, а не проверяем равенство):

$$(\hat{p}_t - \hat{p}_c) \pm z_{\alpha/2} \cdot \sqrt{\frac{\hat{p}_c(1-\hat{p}_c)}{n_c} + \frac{\hat{p}_t(1-\hat{p}_t)}{n_t}}$$

где $z_{\alpha/2} = 1.96$ для 95%.

WITH calc AS (/* p_c, p_t, n_c, n_t из шага 2 */)
SELECT
    ROUND(p_t - p_c, 5) AS diff,
    ROUND((p_t - p_c) - 1.96 * sqrt(
        p_c * (1 - p_c) / n_c + p_t * (1 - p_t) / n_t
    ), 5) AS ci_low,
    ROUND((p_t - p_c) + 1.96 * sqrt(
        p_c * (1 - p_c) / n_c + p_t * (1 - p_t) / n_t
    ), 5) AS ci_high
FROM calc;
  diff   |  ci_low  | ci_high
---------+----------+---------
 0.00500 | 0.00114  | 0.00886

Интервал [0.11 п.п., 0.89 п.п.] не содержит нуля — это ещё одна проверка значимости, эквивалентная p < 0.05. Если бы нижняя граница ушла в минус, эффект был бы неотличим от нуля.


Шаг 5. Проверка SRM (Sample Ratio Mismatch)

Прежде чем радоваться результату — проверьте, что рандомизация не сломалась. SRM это когда фактическое соотношение групп отклоняется от запланированного (например, ждали 50/50, а получили 52/48). Причины: баг в сплиттере, разная скорость загрузки вариантов, боты, потеря событий на одной стороне. При SRM любой результат теста недействителен — почините сплит и перезапустите.

Проверяется через хи-квадрат критерий согласия:

$$\chi^2 = \sum_i \frac{(O_i - E_i)^2}{E_i}$$

где $O_i$ — наблюдённое число пользователей в группе, $E_i$ — ожидаемое при плановом сплите.

WITH obs AS (
    SELECT
        COUNT(*) FILTER (WHERE variant = 'control')   AS o_c,
        COUNT(*) FILTER (WHERE variant = 'treatment') AS o_t,
        COUNT(*)                                      AS total
    FROM ab_events
),
chi AS (
    SELECT
        o_c, o_t, total,
        total * 0.5 AS e_c,        -- ожидаем 50/50
        total * 0.5 AS e_t
    FROM obs
)
SELECT
    o_c, o_t,
    ROUND(
        power(o_c - e_c, 2) / e_c +
        power(o_t - e_t, 2) / e_t
    , 3) AS chi_square,
    CASE
        WHEN power(o_c - e_c, 2) / e_c + power(o_t - e_t, 2) / e_t > 3.841
        THEN 'SRM! Рандомизация сломана — тест недействителен'
        ELSE 'OK — сплит в норме'
    END AS srm_verdict
FROM chi;

Порог 3.841 — критическое значение $\chi^2$ с одной степенью свободы при $\alpha = 0.05$. Если статистика выше — соотношение групп отклонилось сильнее, чем допустимо случайностью. У нас 24310 против 24188 при ожидаемых ~24249 — статистика около 0.3, всё чисто.

SRM ловят реже, чем стоило бы, а он обесценивает добрую половину «выигрышных» тестов в проде. Сделайте его первым запросом в шаблоне, а не последним.


Шаг 6. Guardrail-метрики

Основная метрика выросла — но не ценой ли деградации остального? Guardrail-метрики (защитные) это показатели, которые не должны падать: время загрузки, средний чек, отписки, ошибки. Их считают тем же z-тестом, только гипотеза односторонняя — «не стало хуже».

Удобно собрать все метрики в один запрос через UNION ALL над CTE-агрегатами. Скелет для двух метрик:

WITH per_user AS (
    SELECT
        user_id, variant,
        MAX(converted)          AS conv,       -- основная: конверсия
        AVG(load_ms)            AS load_ms,     -- guardrail: скорость
        SUM(revenue)            AS revenue      -- guardrail: выручка
    FROM ab_events e
    JOIN ab_metrics m USING (user_id)
    GROUP BY user_id, variant
)
SELECT
    'conversion' AS metric,
    ROUND(AVG(conv) FILTER (WHERE variant='treatment')::numeric, 4) AS treatment,
    ROUND(AVG(conv) FILTER (WHERE variant='control')::numeric, 4)   AS control
FROM per_user
UNION ALL
SELECT
    'avg_load_ms',
    ROUND(AVG(load_ms) FILTER (WHERE variant='treatment')::numeric, 1),
    ROUND(AVG(load_ms) FILTER (WHERE variant='control')::numeric, 1)
FROM per_user
UNION ALL
SELECT
    'revenue_per_user',
    ROUND(AVG(revenue) FILTER (WHERE variant='treatment')::numeric, 2),
    ROUND(AVG(revenue) FILTER (WHERE variant='control')::numeric, 2)
FROM per_user;

Правило принятия решения простое: основная метрика выросла значимо И ни одна guardrail-метрика не просела значимо → релизим. Просела хоть одна → разбираемся, стоит ли эффект того.

Обратите внимание: revenue и load_ms — непрерывные метрики. Для них z-тест пропорций уже не подходит, нужен t-тест или сравнение средних, и тут SQL начинает проигрывать Python. О чём и поговорим.


Где SQL заканчивается и начинается Python

SQL отлично считает то, что сводится к агрегатам и подстановке в формулу. Но у него есть жёсткие границы:

Практичное разделение: SQL — для конверсий, дашбордов и быстрого «значимо / нет» по бинарным метрикам. Python — когда метрика непрерывная, нужен бутстрап или несколько метрик с поправками. Полный разбор критериев с кодом лежит в статье A/B-тесты в Python: scipy.stats, а потренировать сам SQL на живых данных с автопроверкой можно в SQL-тренажёре — там 545 задач, включая когорты и воронки.

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


Частые вопросы

Можно ли доверять A/B-тесту, посчитанному целиком в SQL?

Для бинарных метрик (конверсия, retention как флаг) — да, полностью. z-тест пропорций, доверительный интервал и SRM в SQL дают ровно те же числа, что и statsmodels, потому что это одна и та же формула. Единственный компромисс — p-value через аппроксимацию нормального CDF (расхождение с Python в 4-5 знаке после запятой, для решения нерелевантно). Как только метрика становится непрерывной или метрик несколько с поправками — переходите в Python.

Что делать, если z-тест показал значимость, а бизнес-эффект копеечный?

Это классическая ловушка больших выборок: при миллионе пользователей значимой становится разница в 0.01 п.п., которая ничего не стоит. Именно поэтому в шаге 4 мы считаем доверительный интервал, а не только p-value. Смотрите на нижнюю границу интервала аплифта: если даже она не окупает затраты на раскатку — эффект статистически есть, а практически нет. p-value отвечает «реально ли», CI и MDE — «стоит ли».

Почему для z-теста используется pooled-пропорция, а для доверительного интервала — нет?

Разные задачи. При проверке $H_0: p_1 = p_2$ мы предполагаем, что группы одинаковы, поэтому объединяем данные в одну оценку $\hat{p}$ — так стандартная ошибка точнее под нулевой гипотезой. А доверительный интервал оценивает саму разницу без предположения о равенстве, поэтому берём раздельные дисперсии каждой группы. Перепутаете — получите слегка смещённые числа; на больших выборках разница мала, но методологически это две разные формулы.

Как часто нужно проверять SRM и что делать при его обнаружении?

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

Закрепи на практике
545 SQL-задач с автопроверкой в браузере. Первые — без регистрации.
Открыть SQL-тренажёр →