📊 A/B-тест: что делаем после окончания эксперимента? Считаем итоги! 🚀Как вы можете заметить у меня в канале реально много полезного кода. Что ж продолжим эту тенденцию! 😄
В прошлом посте я показала, как
до запуска теста рассчитать минимальный детектируемый эффект (MDE) для бинарной метрики (конверсии).
Мы выяснили, что при базовой конверсии ~4.94% и выборке 50 000 пользователей мы сможем «увидеть» только изменения
больше 10.99% относительного прироста.
А теперь давайте
смоделируем реальный тест – сгенерируем данные, где в тестовой группе конверсия
реально выше, и посмотрим, что получится.
Спойлер: наш фактический эффект окажется достаточно большим, чтобы его обнаружить при таком размере выборки. А статистическую значимость мы проверим отдельно. 😉
🎯 Настройки экспериментаОбщий размер выборки:
51 500 пользователей (разбивка 50/50)
Базовая конверсия в контроле:
4,9% (control_conversion = 0.049)
Ожидаемая конверсия в тесте:
5,5% (test_conversion = 0.055)
Относительный прирост:
(0,055 – 0,049) / 0,049 ≈ 12,2%Это тот эффект, который мы
хотим обнаружить. Но сможет ли тест его увидеть? Давайте проверим!
🧬 Генерируем синтетические данныеФиксируем seed=42 для воспроизводимости.
import numpy as np
import pandas as pd
from scipy.stats import chisquare, norm
from statsmodels.stats.proportion import proportions_ztest
import math
n = 51500
control_conversion = 0.049
test_conversion = 0.055
np.random.seed(42)
# Генерируем ID и метку группы (50/50)
active_user_id = np.arange(1, n + 1)
test_id = np.random.binomial(1, 0.5, size=n)
df = pd.DataFrame({'active_user_id': active_user_id, 'test_id': test_id})
# Генерируем конверсии отдельно для каждой группы
df['is_converted'] = 0
df.loc[df['test_id'] == 0, 'is_converted'] = np.random.binomial(1, control_conversion,
size=(df['test_id'] == 0).sum())
df.loc[df['test_id'] == 1, 'is_converted'] = np.random.binomial(1, test_conversion,
size=(df['test_id'] == 1).sum())
df.head()
🔍
Шаг 1. Проверяем разбивку на группы (SRM)Перед тем как смотреть на конверсию, сначала проверяем,
правильно ли пользователи распределились между группами.
Почему это важно?
Если вместо ожидаемых 50/50 мы получили, например, 60/40, результат эксперимента уже вызывает вопросы.
Ожидаем, что в каждой группе будет примерно по 25 750 пользователей.
from scipy.stats import chisquare
control = df[df['test_id']==0]['active_user_id'].nunique()
test = df[df['test_id']==1]['active_user_id'].nunique()
# Ожидаемое распределение (50/50)
total = control + test
print('В тесте присвоены номера групп для:',total,'устройств')
expected = total / 2
# Критерий хи-квадрат
chi2_stat, p_value = chisquare([control, test], f_exp=[expected, expected])
print(f"Хи-квадрат статистика: {chi2_stat:.4f}")
print(f"P-value: {p_value:.6f}")
# Вывод решения
alpha = 0.05
if p_value < alpha:
print("SRM обнаружен — тест ненадёжен.")
else:
print("SRM не обнаружено. Разбивка на группы правильная.")
Результат:Контроль: 25767, Тест: 25733
Хи-квадрат: 0.0224, p-value: 0.8809
✅ Разбивка корректна – можно продолжать.
📈 Шаг 2. Фактические конверсии – и вот сюрприз!Считаем реальные доли (в жизни то, что получилось после двух-трех недель томительного ожидания, у нас - то что нагенерировалось после np.random.binomial:
control_users = len(df[df['test_id'] == 0])
test_users = len(df[df['test_id'] == 1])
control_conv = df[df['test_id'] == 0]['is_converted'].sum()
test_conv = df[df['test_id'] == 1]['is_converted'].sum()
cr_control = control_conv / control_users
cr_test = test_conv / test_users
uplift = (cr_test / cr_control - 1) * 100
effect_pp = (cr_test - cr_control) * 100
print(f'Control users: {control_users}')
print(f'Control conversions: {control_conv}')
print(f'CR control: {cr_control:.2%}')
print('==============================')
print(f'Test users: {test_users}')
print(f'Test conversions: {test_conv}')
print(f'Test control: {cr_test:.2%}')
print('==============================')
print(f'Effect: +{effect_pp:.2f} п.п.')
print(f'uplift: {uplift:.2f}%')
Вывод (примерный, при seed=42
):Control users: 25767
Control conversions: 1176
CR control:
4.56%===================================
Test users: 25733
Test conversions: 1407
Test control:
5.47%===================================
Effect: +0.90 п.п.
📈 uplift: 19.8%
Почему контроль не 4,9%, а 4,56%? Это
нормально. Когда мы задаём: control_conversion = 0.049 мы не говорим: «В контроле будет ровно 4,9% конверсий». Мы говорим: «Каждый пользователь имеет
вероятность 4,9% сконвертироваться».
А np.random.binomial() генерирует случайную выборку из этого распределения.
Поэтому фактическая конверсия в конечной выборке может немного отличаться от заданной.
Это ровно то, что происходит и в реальном эксперименте:
ожидаемый baseline и фактический baseline — не обязательно одно и то же!
И здесь у нас:
ожидали: 4,9%
получили: 4,56%
А в тесте:
ожидали: 5,5%
получили: 5,47%
Именно с
фактическими значениями мы дальше и работаем.
🧮 Шаг 3. Считаем фактический эффект.
Проводим z‑тест для двух пропорцийТеперь главный вопрос:
Можем ли мы считать разницу между 4,56% и 5,47% статистически значимой?
Для двух независимых конверсий используем
z-тест для двух пропорций.
stat, p_value = proportions_ztest(count=[test_conv, control_conv],
nobs=[test_users, control_users], alternative='two-sided')
print(f'z-stat: {stat:.4f}')
print(f'p-value: {p_value:.10f}')
z-stat: 4.6981
p-value: 0.0000026
p_value < 0.05 поэтому отвергаем нулевую гипотезу.🎉
Тест прокрасился! Эффект статистически значим.
🔁 Шаг 4. Сравниваем с MDE, пересчитанным после теста на фактическом baseline и размере выборкиИ вот здесь возвращаемся к тому, с чего начали.
MDE показывает,
какой минимальный эффект способен обнаружить наш тест при заданных параметрах.
После завершения эксперимента мы можем дополнительно пересчитать его, используя:
- фактический baseline -
4,56%;
- фактический общий размер выборки -
51 500.
Это не «новый правильный MDE», а
дополнительная оценка чувствительности теста на фактических данных.
n_total = control_users + test_users
mde_abs, mde_rel, lower, upper = mde_for_conversion(cr_control, n_total)
# Фактический эффект в процентных пунктах
effect_pp = (cr_test - cr_control) * 100
print(f"Конверсия в контроле: {cr_control:.2%}")
print(f"Конверсия в тесте: {cr_test:.2%}")
print(f"Фактический эффект: +{effect_pp:.2f} п.п.")
print("MDE: ±", round(mde_abs*100, 2), "п.п. - абсолютный (", round(mde_rel, 2), "% - относительный)")
print(f'uplift: {uplift:.2f}%')
if uplift > mde_rel:
print("✅ Наш эффект превышает MDE")
else:
print("❌ Эффект меньше MDE")
Получим:Конверсия в контроле: 4.56%
Конверсия в тесте: 5.47%
Фактический эффект: +0.90 п.п.
MDE: ± 0.52 п.п. - абсолютный ( 11.29 % - относительный)
uplift: 19.80%
✅ Наш фактический эффект (19.80%) превышает MDE(
11,29%)
Это означает, что
эффект такого масштаба находится в пределах чувствительности теста.
🏁 ИтогоМы прошли полный путь от сырых данных до вывода:
1️⃣ Проверили SRM → разбивка 50/50 корректна
2️⃣ Посчитали фактический CR → 4,56% vs 5,47%
3️⃣ Посчитали эффект →
+0,9 п.п. / +19,80%4️⃣ Проверили статистическую значимость → p-value < 0,05
5️⃣ Сравнили эффект с MDE →
19,80% > 11,29%И чтобы не путаться, еще раз:
MDE и статистическая значимость отвечают на разные вопросы.MDE:Насколько большим должен быть эффект, чтобы наш тест имел достаточную чувствительность его обнаружить?
p-value:Достаточно ли данных, чтобы считать наблюдаемую разницу статистически значимой?
📎 Вот
ноутбук, чтобы Вы смогли поэкспериментировать.