A/B-тест
A/B-тест — эксперимент. Пользователей случайно делят на две группы. Группа A видит старую версию, группа B — новую. Потом сравнивают метрику.
Пример. Мы поменяли кнопку «Купить». Файл ab_test.csv: 2000 пользователей в каждой группе, converted = 1, если пользователь купил.
Случайное деление важно. Только так группы похожи, и разницу можно связать с изменением, а не с чем-то ещё. Это и есть тот эксперимент, которого не хватает корреляции.
Шаг 1. Смотрим на данные
Заголовок раздела «Шаг 1. Смотрим на данные»import csv
with open("data/ab_test.csv") as f: ab = list(csv.DictReader(f))
a = [int(r["converted"]) for r in ab if r["grp"] == "A"]b = [int(r["converted"]) for r in ab if r["grp"] == "B"]
print(len(a), sum(a), sum(a) / len(a))print(len(b), sum(b), sum(b) / len(b))Ответ:
2000 200 0.12000 250 0.125SELECT grp, count(*) AS users, sum(converted) AS buyers, avg(converted) AS conversionFROM ab_testGROUP BY grpORDER BY grp;Ответ:
grp users buyers conversionA 2000 200 0.1B 2000 250 0.125Конверсия A — 10%, B — 12,5%. Разница 2,5 процентного пункта. Это эффект кнопки или случайность? В другой день группа A могла бы набрать 11%.
Шаг 2. Гипотезы
Заголовок раздела «Шаг 2. Гипотезы»- H0 (нулевая гипотеза): разницы нет. Кнопка не влияет. Разница — случайность.
- H1: разница есть.
Мы не доказываем H1. Мы спрашиваем: если H0 верна, как часто мы увидели бы такую разницу или больше? Эту вероятность называют p-value.
- p-value маленькое (меньше 0,05) → такая разница при H0 очень редка → отвергаем H0. Результат статистически значим.
- p-value большое → разницу легко объяснить случайностью → H0 не отвергаем.
Порог 0,05 — общее соглашение. Его выбирают до теста.
Шаг 3. z-тест для двух долей
Заголовок раздела «Шаг 3. z-тест для двух долей»Считаем:
- Общую долю покупателей:
p = (200 + 250) / 4000 = 0,1125. - Стандартную ошибку разницы:
SE = √( p × (1 − p) × (1/n_A + 1/n_B) ). - z-оценку:
z = (p_B − p_A) / SE. - p-value по нормальному распределению:
2 × (1 − cdf(|z|)). Двойка — потому что разница может быть в обе стороны.
import mathfrom statistics import NormalDist
n_a, n_b = len(a), len(b)p_a, p_b = sum(a) / n_a, sum(b) / n_bp = (sum(a) + sum(b)) / (n_a + n_b)
se = math.sqrt(p * (1 - p) * (1 / n_a + 1 / n_b))z = (p_b - p_a) / sep_value = 2 * (1 - NormalDist().cdf(abs(z)))
print(round(z, 2), round(p_value, 4))Ответ:
2.5 0.0124В DuckDB нет функции p-value, но z посчитать можно. Сравните его с 1,96.
WITH g AS ( SELECT grp, count(*) AS n, avg(converted) AS p FROM ab_test GROUP BY grp),t AS ( SELECT a.n AS n_a, b.n AS n_b, a.p AS p_a, b.p AS p_b, (SELECT avg(converted) FROM ab_test) AS p FROM g a, g b WHERE a.grp = 'A' AND b.grp = 'B')SELECT round(p_b - p_a, 4) AS diff, round((p_b - p_a) / sqrt(p * (1 - p) * (1.0 / n_a + 1.0 / n_b)), 2) AS zFROM t;Ответ:
diff z0.025 2.5z = 2,5 больше 1,96, значит, p-value < 0,05. Точное значение из Python: 0,0124.
Если кнопка не влияет, такая разница (или больше) случается примерно в 1,2% случаев. Это редко. Мы отвергаем H0: разница статистически значима.
Шаг 4. Насколько велик эффект
Заголовок раздела «Шаг 4. Насколько велик эффект»p-value говорит только «есть ли разница». Размер эффекта показывает доверительный интервал разницы:
разница ± 1,96 × √( p_A(1−p_A)/n_A + p_B(1−p_B)/n_B )se_diff = math.sqrt(p_a * (1 - p_a) / n_a + p_b * (1 - p_b) / n_b)low = (p_b - p_a) - 1.96 * se_diffhigh = (p_b - p_a) + 1.96 * se_diffprint(round(low, 4), round(high, 4))0.0054 0.0446Читаем: новая кнопка добавляет от 0,5 до 4,5 процентного пункта конверсии. Ноль в интервал не попал. Это то же самое, что p-value < 0,05.
Интервал широкий: от «чуть-чуть» до «очень хорошо». Чтобы уточнить, нужно больше пользователей.
Частые ошибки
Заголовок раздела «Частые ошибки»| Ошибка | Почему плохо |
|---|---|
Остановить тест, как только p < 0,05 | Если смотреть каждый день, рано или поздно p-value случайно упадёт ниже 0,05. Решите размер выборки заранее |
| Тест на малом числе пользователей | Эффект не заметен. Разница есть, а p-value большое |
| Считать значимое = важное | При миллионе пользователей значима и разница в 0,01%. Смотрите на размер эффекта |
| Много метрик сразу | Проверяя 20 метрик, одна покажет p < 0,05 случайно |
| p-value = «вероятность, что H0 верна» | Нет. Это вероятность таких данных при условии, что H0 верна |
Практика
Заголовок раздела «Практика»Задание 1
Заголовок раздела «Задание 1»Что будет, если в группе B было бы не 250, а 230 покупателей (11,5%)? Посчитайте z и p-value.
Проверка: z = 1,53, p-value = 0,1257. Разница не значима: H0 не отвергаем.
Решение
conv_a, conv_b = 200, 230p_a, p_b = conv_a / 2000, conv_b / 2000p = (conv_a + conv_b) / 4000se = math.sqrt(p * (1 - p) * (2 / 2000))z = (p_b - p_a) / seprint(round(z, 2), round(2 * (1 - NormalDist().cdf(abs(z))), 4))Разница 1,5 пункта при таких группах не отличима от случайной. Небольшое изменение цифр меняет вывод. Поэтому смотрите не только на «значимо / не значимо», но и на доверительный интервал.
Задание 2
Заголовок раздела «Задание 2»Посчитайте относительный рост конверсии (lift): на сколько процентов B лучше A?
Проверка: (0,125 − 0,10) / 0,10 = 25%. Конверсия выросла в относительном выражении на 25%. Абсолютно — на 2,5 процентного пункта.
Решение
print(round((p_b - p_a) / p_a * 100, 1))Если вы уже меняли p_a и p_b в задании 1, верните значения 0,10 и 0,125.
Задание 3
Заголовок раздела «Задание 3»Новую кнопку запустили всем. В месяц приходит 100 000 посетителей. Сколько дополнительных заказов в месяц мы ждём, исходя из нижней и верхней границы интервала разницы (0,54% и 4,46%)?
Проверка: от 540 до 4 460 дополнительных заказов в месяц.
Решение
visitors = 100_000print(round(visitors * 0.0054), round(visitors * 0.0446))Разброс в 8 раз. Хороший пример, почему бизнесу показывают диапазон, а не одно число 2,5%.
Дальше: Итоговая практика.