A/B-тестыratio-метрикидельта-метод

Ratio-метрики в A/B: почему дисперсия обманывает

2026-07-11 11 мин

Если ты гоняешь A/B на CTR или среднем чеке и считаешь t-тест по строкам таблицы событий — твой p-value врёт, причём почти всегда в опасную сторону. Причина одна: ты рандомизируешь пользователей, а метрику считаешь по показам или заказам. Единица эксперимента и единица метрики не совпадают, наблюдения внутри одного юзера скоррелированы, и наивная дисперсия оказывается заниженной. Итог — ложные «стат-значимые» победы, которые не воспроизводятся на проде. Лечится это дельта-методом (линеаризацией) или бутстрапом по юзерам. Ниже разберу, откуда берётся ошибка и как считать честно.

Почему единица рандомизации важнее, чем кажется?

Возьмём типичную ratio-метрику: CTR = сумма кликов / сумма показов. Или средний чек: выручка / число заказов. У обеих в знаменателе не пользователи, а события — показы, заказы, сессии.

А сплитуем мы по юзерам. Когда пользователь попадает в вариант B, туда попадают все его показы разом. Один активный юзер приносит сотни строк в таблицу events, и все они «его». Клики этого юзера похожи друг на друга: кто кликает много — кликает много везде, кто игнорит баннеры — игнорит их стабильно. Строки внутри юзера — это не независимые наблюдения, а один кластер.

Обычный t-тест по показам молча предполагает, что каждый показ независим. Представь 50 000 юзеров и 5 миллионов показов. Наивный тест думает, что у тебя n = 5 000 000 независимых наблюдений. На самом деле независимых единиц — 50 000, по числу юзеров. Ты раздул эффективный размер выборки в сто раз, а стандартную ошибку во столько же раз ужал.

Отсюда простое правило, которое стоит повесить на стену: считать надо на уровне единицы рандомизации. Раздал по юзерам — агрегируй до юзера. Потренировать саму агрегацию можно в SQL-тренажёре: GROUP BY user_id с суммами кликов и показов — это первый шаг любого честного ratio-теста.

Почему наивная дисперсия занижена?

Дисперсия среднего масштабируется как дисперсия / n. Весь вопрос — какое n подставить. Наивный подход берёт число событий, а честный — число независимых кластеров (юзеров).

Внутри кластера наблюдения положительно скоррелированы. Есть даже коэффициент для этого — внутриклассовая корреляция, ICC. Чем сильнее показы одного юзера похожи друг на друга, тем больше «design effect» — множитель, на который реальная дисперсия среднего превышает наивную. Формула в словах простая: реальная дисперсия ≈ наивная × (1 + (m − 1) × ICC), где m — среднее число событий на юзера. При m = 100 даже скромный ICC = 0.05 раздувает дисперсию в шесть раз.

Что это значит на практике. Стандартная ошибка занижена, значит z-статистика (разница делить на SE) завышена, значит p-value крошечное там, где эффекта нет. Ты систематически ловишь false positives. Самое коварное — метрика выглядит идеально: узкие доверительные интервалы, красивая значимость. Только эта уверенность фальшивая, она построена на несуществующих наблюдениях. Подробнее про то, как это всплывает на собеседованиях, — в разборах вопросов по A/B.

Что такое дельта-метод простыми словами?

Ratio — это отношение двух средних: R = среднее(X) / среднее(Y), где X — клики юзера, Y — его показы. Проблема в том, что отношение — нелинейная функция. Нельзя просто взять дисперсию числителя и знаменателя по отдельности и сложить, они связаны.

Дельта-метод решает это через приближение. Нелинейную функцию раскладывают в ряд Тейлора первого порядка вокруг средних — то есть заменяют кривую отношения на касательную прямую в рабочей точке. А для линейной комбинации дисперсия уже считается честно.

На выходе получается линеаризованная метрика — по одному числу на юзера:

L_i = (X_i − R × Y_i) / среднее(Y)

Здесь X_i — клики юзера i, Y_i — его показы, R — общий ratio по всей группе, среднее(Y) — средние показы на юзера в этой группе. По построению сумма X_i − R × Y_i равна нулю, поэтому среднее L тоже около нуля, а вся полезная информация уходит в его разброс.

Дальше всё сводится к привычному:

Var(R) ≈ Var(L) / n

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

Как посчитать дельта-метод на реальных данных?

Схема из трёх шагов: агрегировать события до юзера, посчитать R и линеаризацию в каждой группе, сравнить группы. Вот рабочий скелет на pandas:

import numpy as np
import pandas as pd

# events: user_id, variant, impression (0/1), click (0/1)
per_user = (events
    .groupby(['variant', 'user_id'])
    .agg(clicks=('click', 'sum'),
         imps=('impression', 'sum'))
    .reset_index())

def delta_stats(df):
    """R и его дисперсия по одной группе через линеаризацию."""
    X, Y = df['clicks'].to_numpy(), df['imps'].to_numpy()
    R = X.sum() / Y.sum()
    L = (X - R * Y) / Y.mean()      # линеаризованная метрика по юзеру
    var_R = L.var(ddof=1) / len(L)  # дисперсия ratio
    return R, var_R

a = per_user[per_user.variant == 'control']
b = per_user[per_user.variant == 'treatment']

R_a, var_a = delta_stats(a)
R_b, var_b = delta_stats(b)

diff = R_b - R_a
se = np.sqrt(var_a + var_b)
z = diff / se
print(f'ΔCTR = {diff:.5f}, SE = {se:.5f}, z = {z:.2f}')

Обрати внимание: L.var() считается по строкам, где каждая строка — юзер. Никаких показов в качестве наблюдений. Если бы ты вместо этого прогнал scipy.stats.ttest_ind по сырой таблице показов, z был бы больше в разы — та самая иллюзия значимости.

Хочешь пощупать руками — собери синтетические events и прогони оба подхода рядом в Python-тренажёре, разница в SE видна сразу. Синтаксис агрегаций под рукой в справочнике по Python.

Дельта-метод или бутстрап — что выбрать?

Оба метода отвечают на один вопрос: какой реальный разброс у ratio, если бы мы повторили эксперимент. Разница в подходе.

Дельта-метод даёт формулу в закрытом виде. Он быстрый — один проход по данным, масштабируется на миллиарды строк, идеально ложится в SQL-пайплайны и ежедневные скоринги экспериментов. Слабое место — это приближение. Оно опирается на то, что средних «достаточно много» и распределение не слишком дикое. На CTR при десятках тысяч юзеров работает безупречно.

Бутстрап ничего не приближает — он честно пересобирает выборку много раз и смотрит, как гуляет метрика. Он медленнее и требует памяти, зато не боится тяжёлых хвостов и кривых распределений. Для среднего чека (выручка / заказы), где один корпоративный клиент может весить как тысяча обычных, бутстрап часто надёжнее: дельта-метод на таких хвостах занижает дисперсию, а бутстрап ловит реальную нестабильность.

Мой практический выбор: CTR, конверсия показ-в-клик, глубина просмотра — дельта-метод. Выручка на юзера, средний чек, ARPPU и всё, что деньги, — бутстрап или хотя бы оба рядом для сверки. Как считать сами денежные метрики честно — в разделе про метрики и в подборке кейсов с разбором продуктовых A/B.

Как сделать бутстрап по юзерам?

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

def bootstrap_diff(a, b, n_boot=2000, seed=42):
    rng = np.random.default_rng(seed)
    xa, ya = a['clicks'].to_numpy(), a['imps'].to_numpy()
    xb, yb = b['clicks'].to_numpy(), b['imps'].to_numpy()
    na, nb = len(xa), len(xb)
    diffs = np.empty(n_boot)
    for i in range(n_boot):
        ia = rng.integers(0, na, na)   # ресемплим ЮЗЕРОВ, не показы
        ib = rng.integers(0, nb, nb)
        R_a = xa[ia].sum() / ya[ia].sum()
        R_b = xb[ib].sum() / yb[ib].sum()
        diffs[i] = R_b - R_a
    lo, hi = np.percentile(diffs, [2.5, 97.5])
    return diffs.mean(), (lo, hi)

mean_diff, ci = bootstrap_diff(a, b)
print(f'ΔCTR = {mean_diff:.5f}, 95% ДИ = [{ci[0]:.5f}, {ci[1]:.5f}]')

Если доверительный интервал не накрывает ноль — эффект есть. Приятный бонус: этот же цикл без изменений работает для любой ratio-метрики, надо только подставить нужные числитель и знаменатель. И его результат легко объяснить продакту — «в 95% пересборок разница держалась в этих границах» звучит понятнее, чем «p меньше 0.05».

Какие ошибки чаще всего убивают эксперимент?

За годы я собрал короткий список граблей, на которые наступают снова и снова.

Первое — t-тест по показам или заказам вместо юзеров. Это база всей статьи и причина большинства невоспроизводимых побед.

Второе — путаница между «средним по-юзерным CTR» и «ratio сумм». mean(clicks_i / imps_i) и sum(clicks) / sum(imps) — это две разные метрики. Первая даёт всем юзерам равный вес, вторая взвешивает по активности. Выбирать надо осознанно под бизнес-вопрос, а не потому что так короче написалось. Иначе рискуешь поймать парадокс Симпсона, когда общий тренд противоречит поюзерному.

Третье — забыть про снижение дисперсии. Дельта-метод чинит смещение SE, но CUPED и стратификация могут дополнительно ужать доверительный интервал и ускорить эксперимент. Это не замена, а следующий слой поверх честной дисперсии.

Четвёртое — знаменатель, который сам зависит от теста. Если фича меняет число показов (например, показывает баннер чаще), то и числитель, и знаменатель ratio двигаются одновременно. Тут нужна аккуратность: иногда честнее смотреть на числитель и знаменатель как на отдельные метрики.

Пятое — выбросы в денежных ratio. Один клиент с гигантским заказом перекашивает средний чек. Тут спасают винзоризация, лог-преобразование или тот же бутстрап. Разобрать такие пограничные ситуации на реальных задачах помогает раздел заданий и стартовый курс по SQL, где агрегации на уровне юзера отрабатываются с нуля.

Что запомнить и куда идти дальше?

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

Правило, которое стоит довести до автоматизма: прежде чем считать любой стат-тест, спроси себя, совпадает ли строка в моей таблице с единицей рандомизации. Если нет — сначала агрегируй, потом тестируй. Этого одного хватает, чтобы не выкатывать в прод фичи, которые «выиграли» только на бумаге.

Если хочешь довести дельта-метод и бутстрап до мышечной памяти — собери свой мини-эксперимент в Python-тренажёре, прогони оба подхода на одних данных и сравни доверительные интервалы. А чтобы системно закрыть A/B, статистику и SQL перед собеседованием, в Pro открывается полный доступ к задачам, кейсам и AI-разбору твоих решений — обычно пары недель предметной практики хватает, чтобы такие вопросы перестали пугать.

Готовься к собесу аналитика
Вопросы по статистике и A/B с разбором — попробуй бесплатно.
Банк вопросов →