Условие
В МТС Банке запустили A/B-тест на странице, где можно оформить заявку на банковскую карту:
- В варианте A из 2574 клиентов, зашедших на страницу, на баннер нажали 350.
- В варианте B из 2855 клиентов нажали 375.
Какой вариант баннера предложить продакту и почему?
Решение
Подход
Сравнить две пропорции (CTR на баннер) z-тестом, посчитать CI на разность, и не делать вывод только по точечной оценке.
Реализация
import numpy as np
from statsmodels.stats.proportion import proportions_ztest, confint_proportions_2indep
n_a, x_a = 2574, 350
n_b, x_b = 2855, 375
p_a = x_a / n_a
p_b = x_b / n_b
print(f"CTR A = {p_a:.4f} ({p_a*100:.2f}%)")
print(f"CTR B = {p_b:.4f} ({p_b*100:.2f}%)")
print(f"abs diff = {p_a - p_b:.4f}, rel diff = {(p_a-p_b)/p_b*100:.2f}%")
# Двусторонний z-test
z, p_value = proportions_ztest([x_a, x_b], [n_a, n_b])
print(f"z = {z:.3f}, p-value = {p_value:.4f}")
# 95% CI на разность пропорций
lo, hi = confint_proportions_2indep(x_a, n_a, x_b, n_b)
print(f"95% CI(p_A - p_B) = [{lo:.4f}, {hi:.4f}]")Расчёт вручную
p_A = 350/2574 = 0.1360 (13.60%).p_B = 375/2855 = 0.1313 (13.13%).- Абсолютная разница:
+0.0047(~0.47 п.п. в пользу A). - Pooled p =
(350+375)/(2574+2855) = 725/5429 = 0.1335. - SE =
sqrt(p̂(1-p̂)·(1/n_A + 1/n_B)) = sqrt(0.1335·0.8665·(1/2574 + 1/2855)) ≈ 0.00925. - z =
0.0047 / 0.00925 ≈ 0.508. - p-value (двусторонний) ≈
0.611.
Решение
p-value = 0.61 ≫ 0.05 — разница не значима. CI на разность пропорций пересекает 0 — мы не можем утверждать, что A лучше B.
Что говорить продакту:
- Не выкатывать ни один вариант на основании этих данных. Разница (0.47 п.п.) укладывается в шум.
- Если продакт хочет «всё равно выбрать» — выбирайте по нестатистическим причинам: дизайн, бренд-гайдлайны, стоимость поддержки. Главное — не подменять «нет статистической разницы» словом «они одинаковые».
- Чтобы поймать MDE 0.5 п.п. на baseline 13%, нужно сильно больше выборки:
from statsmodels.stats.power import NormalIndPower
from statsmodels.stats.proportion import proportion_effectsize
es = proportion_effectsize(0.135, 0.130) # отн. сдвиг 0.5 п.п.
n = NormalIndPower().solve_power(es, alpha=0.05, power=0.8)
print(f"Нужно ~{int(n)} на группу")
# ~70 000 на группуСейчас в каждой группе ~2500 — в 30 раз меньше нужного для адекватного MDE.
Дополнительные вопросы
- Какой целевой метрикой мы хотим оптимизировать — CTR на баннер, заполнение заявки или одобрение карты? CTR — ранняя метрика; решение по downstream важнее.
- Длительность теста — насколько репрезентативно 5429 человек?
- Сегменты: новые vs возвращающиеся, мобайл vs веб.
Подводные камни
- Точечное сравнение «13.6% > 13.1% — A лучше». Без CI/p-value это решение мелкой подписи. Junior'ы часто срезают этот шаг.
- Маленькая выборка. Power < 20% при таком объёме и MDE — тест ничего не выявит.
- CTR — proxy. Конверсия в заявку и одобрение важнее. Можно нарастить CTR агрессивным баннером и потерять в конверсии.
- Нет sanity-чеков. SRM (соотношение размеров групп: 2574 vs 2855 = 47.4% / 52.6%, отклонение от 50/50 — норма для маленькой выборки, но проверить chi-square стоит).
- «p > 0.05» = «нет эффекта». Нет — «недостаточно данных». Корректная фраза: «не отвергаем H0».
Эталонный ответ
Z-test двух пропорций даёт p ≈ 0.61, разница 0.5 п.п. укладывается в шум. Не выкатываем ни один вариант на основании этих данных. Если решение нужно — берём по нестатистическим причинам и закладываем больший размер выборки на следующий тест. Главный навык — не путать «p > 0.05» с «варианты одинаковы».