A/B-тестыPythonстатистикаscipyаналитика

A/B-тесты в Python: scipy.stats и расчёт значимости

2026-04-25 13 мин

«Конверсия в контроле 5%, в тесте 5.2%. Это значимо?» — самый частый вопрос продакта. Без статистики ответ — пожимание плечами. С scipy.stats — три строки кода и p-value.

Эта статья — практический гайд по A/B-тестам в Python. Никакой высшей математики, только что и когда применять, с готовыми сниппетами для t-test, chi-square, Mann-Whitney и бутстрапа.


Что нужно знать перед стартом

A/B-тест проверяет одну гипотезу:

p-value = вероятность увидеть наблюдаемое различие (или ещё больше) если H0 верна. Меньше 0.05 → отвергаем H0 → разница значима.

p-value = 0.03 → 3% шанс что разница случайна → значимо ✅
p-value = 0.15 → 15% шанс что разница случайна → не значимо ❌
Главное непонимание новичков
p-value — это НЕ «вероятность что H1 верна». Это «вероятность увидеть такое различие если разницы реально нет». Это разные вещи. На собеседовании любят ловить на этом.


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 НЕ подходит


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


Подводные камни

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» — посчитай конверсию и значимость на реальных данных. Лучше один раз увидеть как scipy выдаёт p-value, чем 10 раз прочитать про H0.

Частые вопросы

Что делать, если p-value получился больше 0.05?

Это значит, что данных не хватает, чтобы отвергнуть H0 — но не доказывает, что разницы нет. Проверьте мощность и MDE: возможно, выборка мала для нужного эффекта. Не катите вариант как победивший, пока значимости нет.

Можно ли останавливать A/B-тест раньше, если уже видна значимость?

Нет. Ранняя остановка и подглядывание раздувают ошибку первого рода — поймаете значимость, которой на деле нет. Заранее фиксируйте размер выборки и срок, а для промежуточных проверок берите последовательные критерии.

Нужна ли поправка на множественные сравнения, если метрик несколько?

Да. Проверяя пять метрик на уровне 0.05, вы почти наверняка получите ложное срабатывание хотя бы на одной. Примените поправку Бонферрони или Бенджамини-Хохберга либо заранее выберите одну ключевую метрику.

Как посчитать доверительный интервал для разницы конверсий в Python?

Для конверсий подойдёт z-интервал через statsmodels, а для малых выборок — бутстрап. Интервал полезнее голого p-value: он показывает размер эффекта в процентных пунктах. Потренироваться можно в Python-тренажёре.

Односторонний или двусторонний тест выбрать для A/B?

Почти всегда двусторонний: заранее вы не знаете, станет метрика лучше или хуже, и ловить нужно оба исхода. Односторонний оправдан, только если падение метрики для вас равнозначно отсутствию эффекта.

Реши задачи на A/B
353 Python-задачи: статистика, scipy, pandas, A/B-тесты. С автопроверкой кода. Первые 5 бесплатно.
Открыть Python-тренажёр →