Привет, Хабр!

Про A/B‑тесты написано столько, что кажется, будто там нечего обсуждать. Разбили пользователей на две группы, посчитали метрику, прогнали t‑тест, посмотрели на p‑value. Если меньше 0.05 — катим.

На практике эта схема ломается регулярно, причём данные при этом абсолютно честные: сплиттер не врёт, метрика посчитана правильно, формула теста взята из учебника. Ломается статистика, а конкретно — оценка дисперсии, из которой растёт и доверительный интервал, и p‑value. Ошибиться в ней можно четырьмя очень популярными способами, и каждый даёт один и тот же результат: вы уверенно катите то, что на самом деле ничего не улучшило.

Разберём все четыре.

Метрика — отношение, а вы считаете её по строкам

Самая частая история. Метрика — CTR: клики, делённые на показы. Данные лежат в таблице показов, по строке на показ, в каждой флаг клика и id пользователя. Аналитик берёт колонку кликов и гонит t‑тест.

from scipy import stats

a = impressions[impressions.group == "A"].clicked
b = impressions[impressions.group == "B"].clicked
stats.ttest_ind(a, b)   # p = 0.011, ура, катим

Пользователей вы рандомизировали, а тест считаете по показам. У одного пользователя показов может быть три, а может быть триста, и его показы — не независимые наблюдения: человек, который кликает на всё подряд, накликает вам сотню единиц подряд.

t‑тест же верит, что перед ним n независимых наблюдений. Он берёт огромное n (число показов), делит на корень из него дисперсию — и получает микроскопическую стандартную ошибку. Интервал схлопывается, p‑value падает, и вы видите значимость там, где её нет.

Проверить это можно за минуту симуляцией, где никакого эффекта нет вовсе:

import numpy as np
from scipy import stats

rng = np.random.default_rng(0)

def simulate_aa(n_users=2000):
    # у каждого пользователя своя склонность кликать — вот она и создаёт зависимость
    p_user = rng.beta(2, 20, size=n_users)
    n_imp = rng.integers(1, 300, size=n_users)
    group = rng.integers(0, 2, size=n_users)

    rows = []
    for u in range(n_users):
        clicks = rng.binomial(1, p_user[u], size=n_imp[u])
        rows.append((group[u], clicks.sum(), n_imp[u]))
    return np.array(rows)

false_positives = 0
for _ in range(1000):
    data = simulate_aa()
    # разворачиваем обратно в показы и делаем "как все"
    a = np.concatenate([np.r_[np.ones(c), np.zeros(n - c)] for g, c, n in data if g == 0])
    b = np.concatenate([np.r_[np.ones(c), np.zeros(n - c)] for g, c, n in data if g == 1])
    if stats.ttest_ind(a, b).pvalue < 0.05:
        false_positives += 1

print(false_positives / 1000)   # порядка 0.30 вместо ожидаемых 0.05

Эффекта нет, группы одинаковые, а тест кричит о победе в трети случаев. Тридцать процентов ложных срабатываний вместо пяти.

Фиксится это тем, что дисперсию надо считать на уровне единицы рандомизации, то есть по пользователям. Самый прямой способ — дельта‑метод. Метрика у нас — отношение двух сумм: суммы кликов к сумме показов, где и то и другое агрегируется по пользователям. Дисперсия такого отношения раскладывается так:

def ratio_var(clicks, impressions):
    """Дисперсия метрики sum(clicks)/sum(impressions) по дельта-методу."""
    n = len(clicks)
    x, y = clicks.mean(), impressions.mean()
    var_x, var_y = clicks.var(ddof=1), impressions.var(ddof=1)
    cov = np.cov(clicks, impressions, ddof=1)[0, 1]
    return (var_x / y**2
            - 2 * x * cov / y**3
            + x**2 * var_y / y**4) / n

def ratio_ttest(a_clicks, a_imps, b_clicks, b_imps):
    ctr_a = a_clicks.sum() / a_imps.sum()
    ctr_b = b_clicks.sum() / b_imps.sum()
    se = np.sqrt(ratio_var(a_clicks, a_imps) + ratio_var(b_clicks, b_imps))
    z = (ctr_b - ctr_a) / se
    return ctr_b - ctr_a, 2 * (1 - stats.norm.cdf(abs(z)))

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

Если дельта‑метод пугать формулой, есть более тупой и надёжный путь: бутстрап по пользователям.

def bootstrap_ratio(clicks, imps, n_boot=2000):
    n = len(clicks)
    idx = rng.integers(0, n, size=(n_boot, n))     # ресемплим ПОЛЬЗОВАТЕЛЕЙ, не показы
    return clicks[idx].sum(axis=1) / imps[idx].sum(axis=1)

Ключевое слово — пользователей. Ресемплить показы бессмысленно, вы просто воспроизведёте ту же ошибку в новой обёртке.

Единица анализа должна совпадать с единицей рандомизации. Рандомизировали пользователей — считайте по пользователям. Рандомизировали магазины, города, сессии — считайте по ним. Если данные лежат построчно на более мелком уровне, сначала агрегируйте до уровня рандомизации, потом считайте статистику.

Подглядывание

Классический t‑тест устроен так: вы заранее объявляете размер выборки, набираете его и смотрите на результат ровно один раз. Пятипроцентная ошибка первого рода — это про один взгляд. Каждый дополнительный взгляд — ещё один шанс случайно поймать шум.

Симуляция объясняет лучше слов:

def peeking_experiment(n_days=14, per_day=500):
    a = np.array([])
    b = np.array([])
    for day in range(n_days):
        a = np.r_[a, rng.normal(0, 1, per_day)]
        b = np.r_[b, rng.normal(0, 1, per_day)]   # эффекта нет, распределения одинаковые
        if stats.ttest_ind(a, b).pvalue < 0.05:
            return True    # "нашли" значимость и остановились
    return False

print(np.mean([peeking_experiment() for _ in range(2000)]))   # около 0.20

Никакого эффекта нет, а мы «находим» его в каждом пятом эксперименте. Заглядывать чаще — будет ещё хуже: при ежечасной проверке доля ложных находок уходит далеко за треть.

Вариантов фикса три, и выбирать надо по тому, насколько вам действительно нужно останавливаться раньше срока.

Самый простой: посчитать размер выборки заранее и не смотреть до конца. Скучно, зато работает.

from statsmodels.stats.power import tt_ind_solve_power
n = tt_ind_solve_power(effect_size=0.05, alpha=0.05, power=0.8)

Если бизнес не готов ждать две недели вслепую, берите последовательный тест. Групповой последовательный дизайн (альфа‑спендинг по О'Брайену‑Флемингу или Пококу) заранее фиксирует, сколько промежуточных проверок вы сделаете, и распределяет между ними общий бюджет ошибки: на ранних взглядах порог значимости строгий, к концу смягчается. Реализация есть в statsmodels и в специализированных пакетах.

Третий вариант, который сейчас популярен в продуктовой аналитике, — always‑valid доверительные интервалы. Они шире классических (это плата), но смотреть в них можно хоть каждую минуту, и гарантия ошибки первого рода не ломается. Если у вас дашборд с результатами, который видят продакты, это единственный вариант: запретить людям смотреть в дашборд вы всё равно не сможете.

Один кит ломает всё

Метрика — выручка на пользователя. Распределение выручки выглядит так: девяносто пять процентов людей приносят ноль или копейки, а один человек купил корпоративный тариф на миллион.

t‑тест опирается на среднее и на дисперсию. И то и другое у тяжёлого хвоста ведёт себя отвратительно: дисперсия огромная, доверительный интервал в половину среднего, чувствительность теста на нуле. Хуже того, попадание кита в одну из групп двигает средние сильнее, чем любой реальный эффект.

revenue = np.r_[np.zeros(9500), rng.exponential(50, 490), rng.exponential(50000, 10)]
print(revenue.mean(), revenue.std())   # среднее ~60, стандартное отклонение ~1600

С такими числами тест не увидит ничего, кроме китов, сколько бы вы его ни гоняли.

Что делать.

  • Первое — винзоризация: обрезать хвост по высокому квантилю. Это смещает оценку, зато обваливает дисперсию, и в сумме почти всегда выигрывает.

cap = np.quantile(revenue, 0.995)
revenue_w = np.minimum(revenue, cap)
print(revenue_w.mean(), revenue_w.std())   # среднее ~40, стандартное отклонение ~200

Порог берётся один и тот же для обеих групп и фиксируется до начала теста, иначе вы просто подгоняете результат.

  • Второе — сменить метрику. Если вопрос звучит как «стали ли люди чаще покупать», отвечать на него надо конверсией в покупку, а не средней выручкой: бинарная метрика от китов не страдает вовсе. Средний чек тогда считается отдельно и только среди покупателей.

  • И третье: разделять эффект на компоненты. Выручка на пользователя = конверсия × средний чек. Часто оказывается, что конверсия выросла значимо, чек не изменился, а сумма из‑за дисперсии чека не показывает ничего. Два узких вопроса дают ответ там, где один широкий не даёт.

Дисперсия, которую можно было убрать заранее

Последняя история про то, как получить чувствительность в полтора‑два раза выше на тех же данных.

Пользователи очень разные, и большая часть разброса метрики объясняется не вашим экспериментом, а тем, каким пользователь был до него. Кто много покупал в прошлом месяце, тот и в эксперименте купит много, независимо от того, какую кнопку вы ему покрасили. Эта часть дисперсии — чистый шум для теста, и её можно вычесть.

Механика: берём ковариату из предпериода (например, ту же метрику за две недели до старта), строим скорректированную метрику и тестируем уже её.

def cuped(y, y_pre):
    theta = np.cov(y, y_pre, ddof=1)[0, 1] / y_pre.var(ddof=1)
    return y - theta * (y_pre - y_pre.mean())

y_a_adj = cuped(y_a, y_pre_a)
y_b_adj = cuped(y_b, y_pre_b)
stats.ttest_ind(y_a_adj, y_b_adj)

Скорректированная метрика имеет то же матожидание (несмещённость сохраняется), но дисперсию меньше — ровно во столько раз, насколько предпериод коррелирует с экспериментом. Корреляция 0.7 даёт снижение дисперсии примерно вдвое, а это либо вдвое меньше пользователей на тот же эффект, либо вдвое более слабый эффект на той же выборке.

Два условия, которые нельзя нарушать. Ковариата берётся строго до начала эксперимента — иначе вы затащите в неё сам эффект и получите смещённую оценку. И theta считается на объединённой выборке обеих групп, а не отдельно в каждой.

Работает это не всегда. Для новых пользователей предпериода просто нет, и корректировать нечего. Для метрик, слабо связанных со своим прошлым (первая покупка, разовые действия), корреляция низкая и выигрыш околонулевой. Но для выручки, посещений, времени в приложении у существующих пользователей это самый дешёвый способ поднять мощность теста, который вообще существует.

Что со всем этим делать

Все четыре истории — про одно: значимость считается не по данным, а по предположениям о данных, и когда предположения врут, красивое число в конце ничего не значит.

Поэтому вопрос «какой у нас p‑value» гораздо менее полезен, чем четыре скучных вопроса до него. На каком уровне мы рандомизировали и на каком считаем. Сколько раз мы уже смотрели в результаты и планировали ли это заранее. Как выглядит распределение метрики — не среднее, а гистограмма и максимум. Есть ли у нас предпериод, из которого можно вычесть шум.

И самая полезная привычка, которую можно завести в команде: перед каждым настоящим экспериментом прогонять A/A‑тест. Разбиваете пользователей на две группы, ничего не меняете, гоняете свой обычный пайплайн анализа. Если он находит значимые различия чаще, чем в пяти процентах случаев, ваш пайплайн сломан, и никакие A/B‑результаты, посчитанные им, ничего не стоят.


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

  • 10 августа, 18:00. «Технологии продвинутого Data Science: что под капотом». Записаться
    Поговорим о современных методах Data Science и принципах их работы.

  • 24 августа, 20:00. «Современные модели прогнозирования — TimesNet, TimeGPT и новое поколение моделей временных рядов». Записаться
    Поговорим о современных подходах к прогнозированию и работе с временными данными.

Больше бесплатных уроков июля смотрите в дайджесте.

Комментарии (0)