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.

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