Это первая инструкция серии про пет-проекты аналитика. Общий контекст — зачем нужен проект, где брать данные, как упаковать — в хабе серии. Остальные инструкции: когортный анализ и retention, дашборд продаж в DataLens, воронка продукта. Код проекта — в репозитории серии, папка 01-ab-test.
Что собираем
Продуктовая команда выкатила новую кнопку на половину трафика (группа B), второй половине оставила старую (группа A). Вопрос проекта: новая кнопка реально повышает конверсию, или разница между группами — случайность?
Сравнить «11% против 9%» в лоб нельзя: при конечном числе пользователей цифры всегда немного гуляют. Нужна статистика, которая скажет, можно ли верить разнице. Именно этот навык проверяют на A/B-секции собеседования, и именно поэтому проект стоит собрать руками — включая сами статистические функции, без scipy. «Вызвала функцию и поверила результату» и «понимаю, что считаю» — два разных ответа на вопрос интервьюера.
Параметры: 60 000 пользователей, колонки user_id, group, converted, revenue. Стек: Python, pandas, numpy, matplotlib. Время: 2–3 часа. Все цифры ниже — из реального прогона с seed=42; запустив код, вы получите ровно их.
Шаг 1. Понять вопрос и придумать данные
Зачем: без модели данных нечего анализировать. Настоящих логов у вас нет, поэтому генерируете их сами — на каждого пользователя «бросаете монетку» с нужной вероятностью покупки.
Ключевое решение — заложить в группу B реальный эффект: истинная конверсия 10,0% у A против 11,2% у B. Так проект превращается в честный эксперимент над самим собой: вы знаете правду о данных и проверяете, восстановит ли её анализ. Это сильный аргумент на собеседовании — вы валидировали метод на данных с известным ответом.
import numpy as np
import pandas as pd
P_A, P_B = 0.100, 0.112 # истинные конверсии — их анализ должен восстановить
N = 60_000
Что должно получиться: понимание структуры — одна строка на пользователя, группа, факт покупки, выручка.
Шаг 2. Зафиксировать сид
Зачем: воспроизводимость. Без фиксированного сида каждый прогон даёт другие числа, и проект невозможно проверить — ни вам через месяц, ни интервьюеру.
np.random.seed(42) # в самом начале, до любых случайных операций
Что должно получиться: любой человек, запустивший скрипт, увидит те же 9,39% и 11,27%, что и вы.
Шаг 3. Сохранить данные в CSV
Зачем: генерация и анализ должны быть разделены. Анализ читает готовый файл — так же, как в реальной работе он читал бы выгрузку из базы. Это архитектурная привычка, которую замечают при просмотре репозитория.
groups = np.random.choice(["A", "B"], size=N)
p = np.where(groups == "A", P_A, P_B)
converted = (np.random.rand(N) < p).astype(int)
revenue = np.where(converted == 1,
np.random.normal(1500, 400, N).clip(min=0), 0)
df = pd.DataFrame({"user_id": np.arange(1, N + 1),
"group": groups,
"converted": converted,
"revenue": revenue.round(2)})
df.to_csv("data/ab_test.csv", index=False)
Что должно получиться: data/ab_test.csv на 60 000 строк; у купивших выручка около 1500 ± 400, у остальных ноль.
Шаг 4. Посчитать конверсию по группам
Зачем: первый взгляд на результат. Группируете по group, берёте среднее по converted — это доля купивших.
df = pd.read_csv("data/ab_test.csv")
a = df[df["group"] == "A"]
b = df[df["group"] == "B"]
n_a, n_b = len(a), len(b)
conv_a, conv_b = int(a["converted"].sum()), int(b["converted"].sum())
p_a, p_b = conv_a / n_a, conv_b / n_b
Что должно получиться:
Группа A: 29991 польз., 2816 конверсий, CR = 9.39%
Группа B: 30009 польз., 3382 конверсии, CR = 11.27%
B выше — но это ещё не доказательство. Разница могла возникнуть случайно; следующие шаги проверяют, могла ли.
Шаг 5. Проверить SRM — что группы не перекошены
Зачем: Sample Ratio Mismatch — первая проверка любого A/B-теста в индустрии. Вы ждали делёж 50/50; если фактический делёж статистически значимо отличается от ожидаемого, рандомизация сломана, и всем дальнейшим цифрам верить нельзя. Знание SRM отличает человека, который читал про A/B-тесты, от человека, который их проводил.
n_total = n_a + n_b
exp = n_total / 2.0
srm_chi2 = (n_a - exp) ** 2 / exp + (n_b - exp) ** 2 / exp
srm_p = chi2_sf_df1(srm_chi2) # хвост хи-квадрат с df=1, см. шаг 6
srm_ok = srm_p >= 0.05 # ok, если НЕ значимо
Что должно получиться:
Sample Ratio Mismatch: chi2 = 0.005, p = 0.9414 -> распределение здоровое (OK)
Обратите внимание на логику: здесь большой p-value — хорошая новость. Мы проверяем гипотезу «делёж честный» и рады, что не смогли её отвергнуть.
Шаг 6. Проверить значимость: z-тест двух пропорций
Зачем: главный шаг проекта. Z-тест отвечает на вопрос, насколько разрыв между группами велик по сравнению с шумом. Пишем руками, включая функцию нормального распределения:
import math
def norm_cdf(x):
"""Функция распределения стандартного нормального через erf."""
return 0.5 * (1.0 + math.erf(x / math.sqrt(2.0)))
def chi2_sf_df1(x):
"""P(Chi2_1 > x): для df=1 хи-квадрат = z^2."""
return 2.0 * (1.0 - norm_cdf(math.sqrt(x))) if x > 0 else 1.0
# z-тест разницы двух пропорций (pooled SE — по H0 конверсии равны)
p_pool = (conv_a + conv_b) / (n_a + n_b)
se_pool = math.sqrt(p_pool * (1 - p_pool) * (1 / n_a + 1 / n_b))
diff = p_b - p_a
z = diff / se_pool
p_value = 2.0 * (1.0 - norm_cdf(abs(z))) # двусторонний
Для надёжности результат дублируется хи-квадратом на таблице 2×2 с поправкой Йейтса — независимый способ посчитать то же самое; проверки обязаны сойтись.
Что должно получиться:
Z-тест: z = 7.567, p-value ≈ 3.8e-14
Хи-квадрат 2x2 (Йейтс): chi2 = 57.060, p-value < 0.00001
p-value — вероятность увидеть такую (или большую) разницу, если кнопки на самом деле одинаковы. Здесь она исчезающе мала.
Шаг 7. Построить доверительный интервал разницы
Зачем: p-value говорит «эффект есть», доверительный интервал говорит «какого он размера». Для бизнеса второе важнее: раскатка решается не фактом значимости, а величиной аплифта.
# для ДИ — unpooled SE: здесь мы уже не предполагаем равенство конверсий
se_unpooled = math.sqrt(p_a * (1 - p_a) / n_a + p_b * (1 - p_b) / n_b)
ci_low = diff - 1.96 * se_unpooled
ci_high = diff + 1.96 * se_unpooled
Что должно получиться:
Абсолютный аплифт: +1.88 п.п. Относительный: +20.03%
95% ДИ разницы: [+1.39 п.п.; +2.37 п.п.]
Интервал целиком в плюсе и не задевает ноль — эффект устойчивый. Заметьте деталь с SE: в z-тесте использовалась pooled-оценка (по нулевой гипотезе конверсии равны), в интервале — unpooled (мы оцениваем реальную разницу). Вопрос «почему SE разные» на собеседовании задают, и правильный ответ на него дорогого стоит.
Шаг 8. Нарисовать график
Зачем: вывод должен быть виден за секунду. Bar-chart двух конверсий с усами 95%-х доверительных интервалов показывает и разницу, и уверенность в ней.
import matplotlib.pyplot as plt
ci_a = 1.96 * math.sqrt(p_a * (1 - p_a) / n_a)
ci_b = 1.96 * math.sqrt(p_b * (1 - p_b) / n_b)
fig, ax = plt.subplots(figsize=(7, 5))
ax.bar(["A (старая)", "B (новая)"],
[p_a * 100, p_b * 100],
yerr=[ci_a * 100, ci_b * 100], capsize=10)
ax.set_ylabel("Конверсия, %")
fig.savefig("outputs/conversion_by_group.png", dpi=130)
Что должно получиться: outputs/conversion_by_group.png — два столбца, усы которых не пересекаются.
Шаг 9. Сделать вывод по порогу 0.05
Зачем: анализ заканчивается решением, а не числом. Порог значимости фиксируется до теста; итог формулируется человеческим языком.
Что должно получиться:
ВЫВОД: разница статистически значима (p < 0.05).
Новая кнопка повышает конверсию на 1.88 п.п. (+20% к базе),
95% ДИ [+1.39; +2.37] не задевает ноль. Раскатываем на всех.
И финальная сверка с правдой: в данные закладывали 10,0% против 11,2% (истинный аплифт +1,2 п.п.), анализ оценил +1,88 п.п. с ДИ [+1,39; +2,37]. Оценка выше истины — в конкретной выборке эффект реализовался сильнее среднего, так бывает; направление и значимость восстановлены верно. Честное обсуждение этого зазора в README — плюс, а не минус.
Как рассказывать про проект на собеседовании
Заготовка на 90 секунд: «Я собрала A/B-тест целиком, от данных до решения. Сгенерировала 60 000 пользователей с заложенным эффектом — истинные конверсии 10,0% и 11,2%, — чтобы проверить метод на данных с известным ответом. Дальше стандартный пайплайн: проверка SRM хи-квадратом, z-тест двух пропорций, сверка хи-квадратом 2×2, доверительный интервал разницы. Всю статистику написала руками, без scipy, чтобы понимать каждую формулу. Анализ поймал эффект: +1,88 п.п., p порядка 1e-14, ДИ целиком в плюсе. Вывод — раскатывать».
Акценты, которые работают: заложенный эффект и валидация метода; SRM до теста, а не после; ручная реализация статистики; разделение генерации и анализа.
Вопросы, которые зададут по этому проекту
— Что такое p-value в вашем тесте? Вероятность увидеть разницу конверсий +1,88 п.п. или больше, если на самом деле кнопки одинаковы. У меня она порядка 3,8e-14 — при верной нулевой гипотезе такие данные практически не встречаются, поэтому гипотезу отвергаем. Важно, чем p-value не является: это не вероятность, что нулевая гипотеза верна.
— Зачем проверять SRM, если группы делили 50/50? Делили — по замыслу; проверяю — факт. В реальных системах сплит ломается регулярно: баги рандомизации, фильтрация ботов в одной группе, редиректы. Если фактический делёж значимо отличается от планового, тесту верить нельзя независимо от красоты p-value. У меня chi2 = 0.005, p = 0.94 — делёж честный.
— Почему в z-тесте SE pooled, а в доверительном интервале unpooled? Z-тест работает в предположении нулевой гипотезы «конверсии равны», поэтому дисперсию честнее оценивать по объединённой конверсии. Доверительный интервал описывает реальную разницу без этого предположения — каждая группа даёт свою дисперсию.
— Разница значима. Этого достаточно, чтобы раскатывать? Нет, значимость — только фильтр от случайности. Дальше смотрю размер эффекта и его практическую ценность: +1,88 п.п. при базе 9,4% — это +20% конверсии, для бизнеса существенно. Если бы значимый аплифт был +0,05 п.п., раскатка могла не окупить сложность изменения.
— А если бы p-value вышло 0.06? Не «эффекта нет», а «не доказали». Варианты: продлить тест, если позволяет план; проверить мощность — возможно, выборки мало для ожидаемого эффекта; зафиксировать результат как неубедительный. Что нельзя делать — доливать данные до значимости и останавливаться в первый значимый день: это peeking, он раздувает долю ложных срабатываний.
— Ваши данные синтетические. Что изменится на реальных? Механика теста та же, добавятся грязь и проверки: дубли пользователей, боты, выбросы в выручке, пользователи, попавшие в обе группы. И главное отличие — на реальных данных я не знаю правды, поэтому валидация через A/A-тест и SRM становится обязательной, а не учебной.
— Почему тест двусторонний, вы же ждали роста? До эксперимента я не имею права исключать, что новая кнопка ухудшит конверсию, — такое случается. Односторонний тест дал бы меньший p-value, но ценой слепоты к деградации. По умолчанию — двусторонний.
Что дальше
Следующий проект серии — когортный анализ и retention: от разовой оценки эффекта к динамике удержания во времени. Обзор всей серии и источники данных — в хабе.
Источники
- Ron Kohavi, Diane Tang, Ya Xu. Trustworthy Online Controlled Experiments: A Practical Guide to A/B Testing. Cambridge University Press, 2020 — главы про SRM и надёжность экспериментов.
- Aleksander Fabijan et al. Diagnosing Sample Ratio Mismatch in Online Controlled Experiments. KDD 2019.
- Документация pandas — https://pandas.pydata.org/docs/
- Документация numpy (random, seed) — https://numpy.org/doc/
- Документация matplotlib — https://matplotlib.org/stable/
Проект закрывает «покажите, как вы считаете A/B-тест». Теоретические вопросы той же секции — p-value, мощность, CUPED, peeking — тренируются на задачах: в каталоге есть отдельный блок по статистике и A/B с разборами, часть задач открыта бесплатно.