«Конверсия в контроле 5%, в тесте 5.2%. Это значимо?» — самый частый вопрос продакта. Без статистики ответ — пожимание плечами. С scipy.stats — три строки кода и p-value.
Эта статья — практический гайд по A/B-тестам в Python. Никакой высшей математики, только что и когда применять, с готовыми сниппетами для t-test, chi-square, Mann-Whitney и бутстрапа.
Что нужно знать перед стартом
A/B-тест проверяет одну гипотезу:
- H0 (нулевая): метрика в тесте такая же как в контроле (разницы НЕТ)
- H1 (альтернативная): метрика отличается
p-value = вероятность увидеть наблюдаемое различие (или ещё больше) если H0 верна. Меньше 0.05 → отвергаем H0 → разница значима.
p-value = 0.03 → 3% шанс что разница случайна → значимо ✅
p-value = 0.15 → 15% шанс что разница случайна → не значимо ❌
T-test — для непрерывных метрик
Используется для средних значений: средний чек, среднее время на странице, среднее количество кликов.
from scipy import stats
control = [100, 120, 95, 110, 130, 105, 115, 125, 90, 100] # ARPU контроля
test = [115, 130, 110, 125, 140, 120, 135, 130, 110, 115] # ARPU теста
t_stat, p_value = stats.ttest_ind(control, test)
print(f"t-statistic: {t_stat:.3f}, p-value: {p_value:.4f}")
# t-statistic: -3.124, p-value: 0.0061 → значимо
Когда t-test НЕ подходит
- Метрика — конверсия (доля), а не среднее →
chi2 - Распределение очень skewed (выручка, лонг-тейл) → бутстрап или log-transform
- Очень мало наблюдений (<30) → Mann-Whitney
Chi-square — для конверсий
«Конверсия в покупку 5% vs 5.2%» — это про долю успехов. Используется chi2_contingency.
import numpy as np
from scipy.stats import chi2_contingency
# Контроль: 1000 показов, 50 покупок (5%)
# Тест: 1000 показов, 65 покупок (6.5%)
table = np.array([
[50, 950], # control: success, fail
[65, 935], # test: success, fail
])
chi2, p_value, dof, expected = chi2_contingency(table)
print(f"chi2: {chi2:.3f}, p-value: {p_value:.4f}")
# chi2: 1.96, p-value: 0.1614 → НЕ значимо при текущей выборке
Альтернатива — z-test для долей через statsmodels:
from statsmodels.stats.proportion import proportions_ztest
count = np.array([50, 65])
nobs = np.array([1000, 1000])
z_stat, p_value = proportions_ztest(count, nobs)
print(f"z: {z_stat:.3f}, p-value: {p_value:.4f}")
Mann-Whitney U — непараметрический тест
Если данные не нормальные (выручка с длинным хвостом, время в сессии) — t-test может врать. Mann-Whitney сравнивает ранги, не значения.
from scipy.stats import mannwhitneyu
control = [10, 12, 15, 100, 11, 13, 14] # один аутлайер
test = [12, 14, 16, 13, 15, 17, 18]
u_stat, p_value = mannwhitneyu(control, test, alternative='two-sided')
print(f"U: {u_stat}, p-value: {p_value:.4f}")
# U: 11.0, p-value: 0.0521
Mann-Whitney устойчив к выбросам, но менее «мощный» чем t-test на нормальных данных.
Бутстрап — универсальное решение
Если не уверен в распределении или нужен доверительный интервал произвольной метрики (медиана, перцентиль, кастомный KPI) — бутстрап.
import numpy as np
def bootstrap_diff(control, test, n=10000, metric=np.mean):
diffs = []
for _ in range(n):
c_sample = np.random.choice(control, len(control), replace=True)
t_sample = np.random.choice(test, len(test), replace=True)
diffs.append(metric(t_sample) - metric(c_sample))
return np.array(diffs)
control = np.random.exponential(100, 1000) # log-normal-ish revenue
test = np.random.exponential(110, 1000)
diffs = bootstrap_diff(control, test, metric=np.mean)
ci_low, ci_high = np.percentile(diffs, [2.5, 97.5])
p_value = (diffs <= 0).mean() * 2 # для two-sided
print(f"95% CI: [{ci_low:.2f}, {ci_high:.2f}], p-value: {p_value:.4f}")
Бутстрап хорош тем что работает с любой метрикой — медиана, p95, кастомное.
Расчёт MDE (Minimum Detectable Effect)
Перед запуском теста нужно понять: «какую разницу мы вообще сможем поймать на нашей выборке?». Это MDE.
from statsmodels.stats.power import zt_ind_solve_power
# Хотим обнаружить эффект 1% при 80% power и alpha=0.05
mde = zt_ind_solve_power(
effect_size=None,
nobs1=10000, # размер контроля
alpha=0.05,
power=0.80,
ratio=1.0 # размер теста = размер контроля
)
print(f"MDE: {mde:.4f} стандартных отклонений")
Если ожидаемый эффект меньше MDE — даже не запускай тест, не докажешь.
Расчёт необходимого размера выборки
Обратная задача: «хочу поймать прирост конверсии с 5% до 5.5% — сколько юзеров нужно?»
from statsmodels.stats.power import NormalIndPower
from statsmodels.stats.proportion import proportion_effectsize
baseline = 0.05
mde = 0.005 # +0.5pp
new_rate = baseline + mde
effect_size = proportion_effectsize(new_rate, baseline)
analysis = NormalIndPower()
n_per_group = analysis.solve_power(effect_size=effect_size, alpha=0.05, power=0.80)
print(f"Нужно {int(n_per_group)} юзеров на группу = {int(n_per_group * 2)} всего")
# Нужно 31543 юзеров на группу = 63086 всего
Чек-лист перед запуском A/B
- ✅ Сформулирована метрика (конверсия / ARPU / retention)
- ✅ Определены контроль и тест (50/50?)
- ✅ Посчитан размер выборки под желаемый MDE
- ✅ Запланирована длительность (минимум 1 неделя для устранения недельной сезонности)
- ✅ Известно, какой статтест применить
- ✅ Заранее определены гайардрейлы (что не должно ухудшиться)
Подводные камни
Peeking (подглядывание)
Каждый день смотришь p-value «не значимо ли уже?» — каждый раз шанс ошибиться 5%. За 14 дней даже если эффекта нет, ты увидишь «значимо» с вероятностью 50%.
Решение: либо считай тест строго в конце, либо используй sequential testing (например, Always Valid Inference от Optimizely).
Multiple comparisons
Если тестируешь 10 метрик — хотя бы одна покажет p<0.05 случайно. Корректируй: Bonferroni (p × N) или Benjamini-Hochberg.
Симпсон в когортах
Тест выиграл в каждой стране, но проиграл в общем — потому что в тесте больше юзеров из стран с низкой базовой конверсией. Стратифицируй или взвешивай.
Связанные материалы
- Python-тренажёр: задачи на A/B — 353 задач с pandas/scipy
- Когортный анализ retention в SQL — другая половина продуктовой аналитики
- Метрики продукта — какие метрики обычно тестируют
- Продуктовые кейсы — реальные кейсы A/B-тестов из e-commerce, fintech, SaaS
- AI-собеседование — потренируй вопросы на собес по A/B
Открой Python-тренажёр, найди задачу с тегом «A/B» — посчитай конверсию и значимость на реальных данных. Лучше один раз увидеть как scipy выдаёт p-value, чем 10 раз прочитать про H0.
Частые вопросы
Что делать, если p-value получился больше 0.05?
Это значит, что данных не хватает, чтобы отвергнуть H0 — но не доказывает, что разницы нет. Проверьте мощность и MDE: возможно, выборка мала для нужного эффекта. Не катите вариант как победивший, пока значимости нет.
Можно ли останавливать A/B-тест раньше, если уже видна значимость?
Нет. Ранняя остановка и подглядывание раздувают ошибку первого рода — поймаете значимость, которой на деле нет. Заранее фиксируйте размер выборки и срок, а для промежуточных проверок берите последовательные критерии.
Нужна ли поправка на множественные сравнения, если метрик несколько?
Да. Проверяя пять метрик на уровне 0.05, вы почти наверняка получите ложное срабатывание хотя бы на одной. Примените поправку Бонферрони или Бенджамини-Хохберга либо заранее выберите одну ключевую метрику.
Как посчитать доверительный интервал для разницы конверсий в Python?
Для конверсий подойдёт z-интервал через statsmodels, а для малых выборок — бутстрап. Интервал полезнее голого p-value: он показывает размер эффекта в процентных пунктах. Потренироваться можно в Python-тренажёре.
Односторонний или двусторонний тест выбрать для A/B?
Почти всегда двусторонний: заранее вы не знаете, станет метрика лучше или хуже, и ловить нужно оба исхода. Односторонний оправдан, только если падение метрики для вас равнозначно отсутствию эффекта.