Короткий ответ (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;
Разбор ключевых кусков:
MAX(...) FILTER (WHERE variant = ...)— трюк «pivot без extension». Превращаем две строки (control/treatment) в одну с четырьмя колонками. Работает потому, что в каждой группе значение одно,MAXего просто вытаскивает.1.0/n_c— важно писать1.0, а не1, чтобы не словить целочисленное деление (хотяnужеnumeric, привычка спасает в других местах).sqrt(...)в PostgreSQL работает сdouble precisionиnumeric— приведение не нужно.
Результат:
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 отлично считает то, что сводится к агрегатам и подстановке в формулу. Но у него есть жёсткие границы:
- Непрерывные метрики (revenue, time-on-site). Тут нужен t-тест, а для скошенных распределений — лог-преобразование или Манна–Уитни. Дисперсию посчитать в SQL можно (
stddev), но правильно применить критерий с проверкой предпосылок — уже нет. - Бутстрап. Перевыборка 10 000 раз для доверительного интервала медианы или ratio-метрики — это цикл, а не агрегат. В SQL мучительно, в Python —
numpy.random.choiceв две строки. - Дельта-метод и ratio-метрики. Метрики вида «клики на сессию» имеют коррелированные числитель и знаменатель. Наивная дисперсия врёт, нужен дельта-метод.
- Множественная проверка гипотез. Тестируете 15 метрик — вероятность ложного срабатывания растёт. Поправки Бонферрони или Бенджамини–Хохберга удобнее навесить в Python.
- CUPED, стратификация, последовательный анализ. Снижение дисперсии через ковариаты и корректные peeking-поправки — это уже не 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 на собеседовании разобраны в гайде по интервью аналитика.