«Конверсия в контроле 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.