Собесов
06 августа 2026 г. · 1 тыс. просмотров

Тест на мощности 35% завышает эффект в 1.7 раза: почему отчёт об импакте экспериментов не сходится с фактом

Проклятие победителя в A/B-тестах: почему сумма эффектов запущенных фич завышена на десятки процентов, формула Airbnb, глобальный shrinkage и Bayesian Hybrid Shrinkage с кодом.

Самый неловкий момент в моей практике аналитика выглядит так. Конец квартала, руководство просит посчитать, сколько принесли эксперименты. Я складываю эффекты запущенных фич: +1.8% конверсии от одной, +2.4% от другой, ещё десяток поменьше. Получается красивое число, его вставляют в презентацию. А потом кто-то накладывает эту сумму на фактическую динамику метрики — и она туда не помещается. Метрика выросла, но заметно меньше, чем обещала сумма побед

Первая реакция — искать, где сломалась атрибуция или кто выкатил фичу мимо процесса. У меня плохие новости: даже если каждый тест проведён идеально, сумма всё равно будет завышена. Это не ошибка в данных, а свойство самой процедуры «запускаем то, что статзначимо». У него есть имя — проклятие победителя, winner's curse. С гуглом осторожнее: по-русски термин почти целиком занят теорией аукционов, где победитель торгов в среднем переплачивает. В экспериментах механика похожая, только переплачиваем мы завышенными ожиданиями от фичи

Я продуктовый аналитик в финтехе, до этого — мехмат МГУ.

В марте 2026-го команда из Meta* выложила пейпер Breaking the Winner's Curse with Bayesian Hybrid Shrinkage (Mudd, Zaidi, Friedberg, Gorbachev, Choubey, Nassif; первая версия появилась в ноябре 2025-го, метод показывали на конференции CODE@MIT). Они предлагают поправку, которая считается по замкнутой формуле, требует из данных только историю ваших прошлых тестов и проверена на 167 реальных экспериментах с репликациями. Ниже — как устроено смещение, как его чинили раньше, что нового у Meta и мой код, на котором всё можно пощупать

Почему значимый результат — это завышенный результат

Механика простая, и её стоит один раз прочувствовать. Оценка эффекта из теста — это истинный эффект плюс шум выборки: θ̂ = θ + ε. Запускаем мы то, что перешагнуло порог значимости, то есть тесты с большим θ̂. А большим θ̂ бывает по двум причинам: либо эффект правда большой, либо шум в этот раз сыграл в плюс. Отбирая победителей, мы систематически отбираем и удачный шум

Насколько всё плохо, зависит от мощности теста. Если мощность 80%, значимость почти гарантирована самим эффектом, и шум добавляет сверху немного. Если мощность 35% — порог выше типичного эффекта, и перепрыгнуть его без помощи шума почти невозможно. Условное матожидание значимой оценки считается по усечённому нормальному распределению, и картинка получается такая:

Во сколько раз значимый результат завышает эффект в зависимости от мощности теста

Тест на мощности 80% завышает эффект в среднем в 1.1 раза, на мощности 35% — уже в 1.7. На мощности 10% значимый результат в 3.6 раза больше истины. Эту кривую в применении к науке разобрали ван Цвет и Катор в работе The significance filter, the winner's curse and the need to shrink (2021), а Рон Кохави годами повторяет то же самое про индустрию: значимый результат недомощного теста — это почти наверняка преувеличение, и опытные команды редко видят честные лифты больше пары процентов (A/B Testing Intuition Busters, KDD 2022)

По одному тесту поправить ничего нельзя. Глядя на изолированный значимый θ̂ = +2%, невозможно сказать, сколько в нём эффекта и сколько удачи. Информация появляется, только когда рядом лежит портфель других тестов — по нему видно, какие эффекты в вашем продукте вообще бывают

Как с этим жили до сих пор

На Хабре эта тема живёт кусками. Проклятие победителя мелькало в ML-контексте — выбор лучшей из полусотни моделей по валидации завышает её метрику (разбор у OTUS). Про завышение эффектов при низкой мощности есть хорошая диагностика: Антон Москвин считал Type S/M ошибки и на реальном тесте с мощностью 0.30 получил завышение в 1.8 раза — кривая выше с ним согласна. Диагноз, получается, по-русски уже поставлен. Лечения — метода, который чинит сами оценки, — я не нашла, поэтому дальше о нём

Вариант «никак» — самый популярный. Сумма значимых лифтов уходит в отчёт как есть. Airbnb ещё в 2017-м проверила такую сумму мета-экспериментом: собрала шесть запущенных фич в один холдаут, где часть пользователей не видела ни одной. Bottom-up сумма обещала +7.2% к целевой метрике, холдаут показал +4% (Selection Bias in Online Experimentation, Milan Shen)

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

Вариант «формула Airbnb». Годом позже, на KDD 2018, Lee и Shen (Winner's Curse: Bias Estimation for Total Effects of Features in Online Controlled Experiments) вывели поправку: смещение суммы выражается через плотности нормального распределения на порогах отбора, вычитаем его — получаем оценку с поправкой на смещение, приближённо несмещённую, а доверительный интервал строим бутстрепом. На их данных скорректированная сумма из примера выше — +5.3% вместо +7.2%. Подход рабочий, Airbnb встроила его в свою платформу. Главное ограничение: формула чинит итоговую сумму по портфелю, а оценки отдельных тестов в отчётах так и остаются сырыми

Вариант «глобальный shrinkage». Идея эмпирического Байеса: раз портфель тестов показывает, какие эффекты бывают в принципе, используем его как приор и «сожмём» каждую оценку к среднему (shrinkage — сжатие оценки в сторону приора). Так делает Amazon: в 2024-м Райан Кесслер описал ровно такой подход для оценки импакта запущенных фич, а платформа Eppo называет байесовский пересчёт импакта новым стандартом анализа. Работает лучше наивной суммы, но у глобального сжатия есть неприятное свойство: чем сильнее сигнал, тем больше абсолютная поправка. Настоящих чемпионов — фичи с редким крупным эффектом — глобальный shrinkage наказывает сильнее всех, хотя именно им смещение грозит меньше всего

Что предложила Meta*: сжимать с оглядкой на сам тест

Модель BHS — иерархическая. Истинный эффект теста i считается случайным: θ_i ~ N(0, λ_i · τ), а наблюдаемая оценка — θ̂_i ~ N(θ_i, σ_i²), где σ_i — стандартная ошибка из обычного отчёта по тесту. Расшифрую в терминах продукта: τ — «типичный разброс эффектов в вашей компании», а λ_i — персональный множитель теста, о нём ниже

Шаг 1: оценить τ по истории. Берём прошлые эксперименты (все, не только запущенные) и максимизируем правдоподобие: каждая прошлая оценка θ̂_k распределена как N(0, τ + σ_k²). Одномерная оптимизация, три строки на scipy. Это единственное место, где нужны исторические данные, и его достаточно пересчитывать раз в квартал

Шаг 2: локальный множитель λ_i. В глобальном варианте λ_i = 1 для всех. Meta* ставит на λ_i слабоинформативный приор с тяжёлыми хвостами — вместе с нормалью на эффект он даёт на θ распределение Коши, которое не удивляется выбросам, — и берёт моду постериора. Вывод моды, включая четвёрку в знаменателе, есть в пейпере, здесь беру готовую формулу:

λ*_i = (θ̂_i² + τ) / (4τ)

Смысл формулы читается прямо: если наблюдаемый эффект мал относительно типичного разброса, λ падает к своему минимуму 1/4 — слабый сигнал BHS жмёт к нулю даже жёстче глобального метода. Если θ̂_i большой — λ растёт квадратично, приор расширяется, и постериор почти не отличается от сырой оценки: сильному сигналу метод верит

Шаг 3: постериор в замкнутой форме. С подставленной λ*_i условное постериорное среднее — обычное взвешенное:

θ̃_i = w_i · θ̂_i, где w_i = λ_i · τ / (λ_i · τ + σ_i²)

и такая же короткая формула для постериорной дисперсии. Это empirical-Bayes-приближение: моду по λ подставляем как константу, полный постериор не интегрируем — ровно за счёт этого никакого MCMC, всё считается на лету для тысяч тестов. Авторы прямо пишут, что закладывали масштабируемость под продакшн

У этой конструкции есть доказанное свойство (теорема 3 пейпера), которое авторы называют redescending: поправка BHS стремится к нулю при росте наблюдаемого эффекта. У глобального shrinkage поправка растёт линейно бесконечно. На графике разница видна лучше, чем в формулах:

Redescending: поправка BHS убывает с ростом наблюдаемого эффекта, у глобального shrinkage — растёт

Проверка на реальных данных: 167 экспериментов Meta*, у каждого была парная репликация — повторный запуск, который даёт независимую оценку истинного эффекта. Сравнивали среднюю абсолютную ошибку прогноза относительно репликации. Сырые оценки — 1.280 (×10⁻³), глобальный shrinkage — 1.121, BHS — 1.012. Против наивного варианта ошибка ниже на 21%, и это на портфеле, где Meta* уже старается запускать мощные тесты. В симуляциях с нарушенными предположениями разрыв виден резче: когда у истинных эффектов тяжёлые хвосты, покрытие 90%-х интервалов у глобального shrinkage проваливается ниже 50%, у сырых оценок держится около 65–70%, а BHS держится выше 80% во всём диапазоне сценариев

Симуляция: щупаем метод руками

Пейпер пейпером, но мне важно было увидеть, как метод ведёт себя на портфеле, похожем на реальный. Я собрала симуляцию: 400 тестов, у 75% фич истинный эффект около нуля (продуктовая правда жизни), у 25% — настоящий сигнал со средним +0.3 п.п. Стандартные ошибки у тестов разные — трафика на всех не хватает. Запускаем всё, что значимо в плюс (z > 1.96). Заметьте: приор здесь заведомо не нормальный (смесь двух групп), то есть условия для BHS нечестные — так интереснее

Ядро метода — действительно 30 строк:

import numpy as np
from scipy.optimize import minimize_scalar

def fit_tau(theta_hat, se):
    """tau*: максимум маргинального правдоподобия по истории тестов."""
    def nll(log_tau):
        tau = np.exp(log_tau)
        var = tau + se ** 2
        return np.sum(0.5 * np.log(var) + 0.5 * theta_hat ** 2 / var)
    res = minimize_scalar(nll, bounds=(-12, 3), method='bounded')
    return np.exp(res.x)

def shrink(theta_hat, se, tau, hybrid=True):
    """Постериорное среднее и SD. hybrid=False — глобальный shrinkage."""
    lam = (theta_hat ** 2 + tau) / (4 * tau) if hybrid else np.ones_like(theta_hat)
    v = lam * tau                      # эффективная дисперсия приора
    w = v / (v + se ** 2)              # вес наблюдения
    post_mean = w * theta_hat
    post_sd = np.sqrt(1 / (1 / se ** 2 + 1 / v))
    return post_mean, post_sd

# использование — эскиз: подставьте два массива из выгрузки своей платформы,
# оценки эффектов и их SE; история нужна вся, не только запущенные тесты
tau = fit_tau(history_effects, history_se)
bhs_mean, bhs_sd = shrink(effects, se, tau)

τ по истории из 300 тестов оценился в 0.065 (типичный эффект ±0.26 п.п.). Из 400 свежих тестов порог перешагнули 50. Истинный суммарный эффект этих 50 фич — 23.7 п.п. Теперь три способа отчитаться:

Суммарный импакт портфеля: наивная сумма, глобальный shrinkage и BHS против истинного эффекта

Наивная сумма — 30.1 п.п., завышение на 27%. Глобальный shrinkage перестарался: 18.3 п.п., минус 23% — он задавил в том числе настоящих чемпионов. BHS — 22.5 п.п., ошибка 5%. На уровне отдельного запущенного теста средний перекос: +0.128 п.п. у сырой оценки, −0.107 у глобального, −0.023 у BHS. Неудачно откалиброванная поправка бывает вреднее её отсутствия: у глобального shrinkage покрытие 90%-х интервалов вышло 64% — хуже 74% у сырых оценок; BHS держит 80%

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

Группа тестов Наблюдаемое / истинное
большие (низкий SE) 1.09
средние 1.26
маленькие (высокий SE) 1.64

Маленькие значимые тесты врут в 1.6 раза. Если у вас в компании принято «ну давайте прогоним на неделе на 5% трафика, вдруг прокрасится» — каждый такой прокрас почти наверняка дутый

И пример того самого redescending на конкретных числах из симуляции: наблюдаемый лифт +0.25 п.п. со стандартной ошибкой 0.12 метод BHS сжимает до +0.17 (глобальный оставил бы +0.20), а уверенный лифт +1.50 п.п. с той же ошибкой — лишь до +1.46, тогда как глобальный урезал бы его до +1.23. Отдельная история — большой лифт из шумного теста: +2.0 п.п. при стандартной ошибке 0.82 BHS превращает в +1.2 п.п., потому что информации в таком замере мало и доверять большому числу особо нечему. Глобальный, для сравнения, удавил бы его до +0.2

Полный скрипт симуляции (~100 строк, numpy + scipy):

# -*- coding: utf-8 -*-
"""Симуляция для статьи про winner's curse (Lee & Shen KDD'18 + Meta BHS arXiv:2603.12867).

Портфель A/B-тестов: большинство эффектов ~0, редкие настоящие победители.
Запускаем то, что статзначимо положительно, и смотрим:
  - насколько наивная сумма наблюдаемых эффектов завышает истину
  - как её чинят глобальный shrinkage и Bayesian Hybrid Shrinkage (BHS)
"""
import numpy as np
from numpy.random import default_rng
from scipy.optimize import minimize_scalar
from scipy.stats import norm

def simulate_portfolio(rng, n_tests):
    """Истинные эффекты в п.п. лифта: 75% фич ~ничего, 25% — настоящий сигнал."""
    is_real = rng.random(n_tests) < 0.25
    theta = np.where(is_real, rng.normal(0.30, 0.35, n_tests),
                     rng.normal(0.0, 0.03, n_tests))
    # SE зависит от трафика: тесты разного размера -> разная мощность
    se = rng.uniform(0.08, 0.35, n_tests)
    theta_hat = rng.normal(theta, se)
    return theta, theta_hat, se

def fit_tau(theta_hat, se):
    """Глобальная дисперсия prior tau*: максимум маргинального правдоподобия
    theta_hat_k ~ N(0, tau + se_k^2) по истории экспериментов (eq. 9 пейпера)."""
    def nll(log_tau):
        tau = np.exp(log_tau)
        var = tau + se ** 2
        return np.sum(0.5 * np.log(var) + 0.5 * theta_hat ** 2 / var)
    res = minimize_scalar(nll, bounds=(-12, 3), method='bounded')
    return np.exp(res.x)

def shrink(theta_hat, se, tau, hybrid=True):
    """Постериорное среднее и SD. hybrid=True — BHS (лямбда по eq. 11, m0=0, a=b=1),
    hybrid=False — глобальный shrinkage (лямбда=1, Kessler 2024)."""
    lam = (theta_hat ** 2 + tau) / (4 * tau) if hybrid else np.ones_like(theta_hat)
    v = lam * tau                      # эффективная дисперсия prior
    w = v / (v + se ** 2)              # вес наблюдения
    post_mean = w * theta_hat
    post_sd = np.sqrt(1 / (1 / se ** 2 + 1 / v))
    return post_mean, post_sd

if __name__ == '__main__':
    rng = default_rng(7)

    # история: 300 прошлых тестов -> оценка tau* (в реальности — таблица тестов за год)
    th_h, thh_h, se_h = simulate_portfolio(rng, 300)
    tau = fit_tau(thh_h, se_h)
    print(f'tau* по истории из 300 тестов: {tau:.4f} (sd prior {np.sqrt(tau):.3f} п.п.)')

    # текущий год: 400 тестов, запускаем статзначимо положительные
    theta, theta_hat, se = simulate_portfolio(rng, 400)
    z = theta_hat / se
    launched = z > 1.96
    nL = launched.sum()

    g_mean, g_sd = shrink(theta_hat, se, tau, hybrid=False)
    b_mean, b_sd = shrink(theta_hat, se, tau, hybrid=True)

    true_sum = theta[launched].sum()
    print(f'\nзапущено {nL} из 400; истинный суммарный эффект: {true_sum:.2f} п.п.')
    for name, est in [('наивная сумма (face value)', theta_hat),
                      ('глобальный shrinkage', g_mean),
                      ('BHS (Meta 2026)', b_mean)]:
        s = est[launched].sum()
        print(f'{name:>28}: {s:6.2f} п.п.  (завышение {100*(s-true_sum)/true_sum:+.0f}%)')

    # bias на уровне отдельного запущенного теста + покрытие 90% интервалов
    print(f'\nсредний bias на запущенный тест, п.п.:')
    print(f'{"face value":>28}: {np.mean(theta_hat[launched]-theta[launched]):+.3f}')
    print(f'{"глобальный shrinkage":>28}: {np.mean(g_mean[launched]-theta[launched]):+.3f}')
    print(f'{"BHS":>28}: {np.mean(b_mean[launched]-theta[launched]):+.3f}')

    zq = norm.ppf(0.95)
    cov_fv = np.mean(np.abs(theta_hat[launched]-theta[launched]) <= zq*se[launched])
    cov_g = np.mean(np.abs(g_mean[launched]-theta[launched]) <= zq*g_sd[launched])
    cov_b = np.mean(np.abs(b_mean[launched]-theta[launched]) <= zq*b_sd[launched])
    print(f'\nпокрытие 90% интервалов на запущенных: '
          f'face value {100*cov_fv:.0f}%, global {100*cov_g:.0f}%, BHS {100*cov_b:.0f}%')

    # завышение как функция мощности: значимые слабые тесты врут сильнее всех
    # (считаем на большом прогоне — 4000 тестов, чтобы убрать шум терцилей)
    thL, thhL, seL = simulate_portfolio(default_rng(11), 4000)
    launchedL = thhL / seL > 1.96
    print('\nзавышение face value по терцилям SE (мощности) среди запущенных, 4000 тестов:')
    qs = np.quantile(seL[launchedL], [1/3, 2/3])
    for lo, hi, label in [(0, qs[0], 'большие тесты (низкий SE)'),
                          (qs[0], qs[1], 'средние'),
                          (qs[1], 9, 'маленькие тесты (высокий SE)')]:
        m = launchedL & (seL > lo) & (seL <= hi)
        ratio = thhL[m].sum() / max(thL[m].sum(), 1e-9)
        print(f'  {label:>28}: наблюдаемое/истинное = {ratio:.2f}')

    # redescending: сильный сигнал BHS почти не трогает, слабый — жмёт;
    # третий пример — большой лифт из шумного теста
    print('\nпример shrinkage (tau*={:.3f}):'.format(tau))
    for th_obs, s in [(0.25, 0.12), (1.50, 0.12), (2.00, 0.82)]:
        bm, _ = shrink(np.array([th_obs]), np.array([s]), tau, hybrid=True)
        gm, _ = shrink(np.array([th_obs]), np.array([s]), tau, hybrid=False)
        print(f'  наблюдаемый лифт {th_obs:.2f} п.п. (SE {s}): '
              f'BHS -> {bm[0]:.2f}, global -> {gm[0]:.2f}')

Как завести у себя

Метод — постпроцессинг поверх любой платформы экспериментов, самописной в том числе. Вендорские инструменты не нужны — и это кстати, потому что западные experimentation-платформы вроде Statsig и Eppo в России сейчас малодоступны. Всё считается поверх выгрузки, из зависимостей только numpy и scipy, из данных — одна таблица

  1. Выгрузите историю тестов за год-полтора: оценка эффекта и её стандартная ошибка по ключевой метрике. Важно: все тесты, включая незапущенные и отрицательные — приор строится по полному портфелю, иначе он сам получится смещённым. Эффекты приведите к одной шкале, у меня это процентные пункты конверсии
  2. Оцените τ функцией fit_tau выше. От 50 тестов в истории оценка уже осмысленная; пересчитывать достаточно раз в квартал
  3. Каждому новому тесту считайте постериорное среднее и интервал функцией shrink — и кладите в отчёт их, а не сырую оценку
  4. Суммарный импакт за период — сумма постериорных средних запущенных тестов. Интервал на сумму — из постериорных дисперсий

Совместимость с остальной кухней экспериментов обычная: CUPED (снижение дисперсии за счёт данных о пользователях до эксперимента) и стратификация уменьшают σ_i, отчего сжатие становится мягче — методы дружат. Для ratio-метрик стандартную ошибку считайте как привыкли, дельта-методом или линеаризацией, shrinkage применяется уже к готовой паре «оценка + SE»

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

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

Где метод ломается или требует аккуратности — по мотивам пейпера и моих прогонов:

  • Селекция по нескольким метрикам. Если решение о запуске принимается по одной метрике, а корректируете вы другую, скоррелированную, смещение просачивается: в симуляциях авторов при корреляции 0.8 покрытие у глобального shrinkage падает примерно до 84%, а BHS уходит в другую сторону — его интервалы становятся шире номинала. Корректируйте ту метрику, по которой отбираете
  • Приор с ненулевым центром. Формула λ*_i выше предполагает m₀ = 0: типичный тест ничего не меняет. Для зрелого портфеля это честно и подтверждается литературой, но если у вас отобранный поток заведомо сильных изменений, ноль занизит оценки — в пейпере есть общий вид формулы с m₀
  • Короткая история. На 20 тестах τ оценится с большим шумом, и вся поправка унаследует этот шум. До полусотни тестов я бы ограничилась формулой Airbnb для суммы или просто дисклеймером о завышении
  • Аддитивность. Складывать эффекты фич — само по себе приближение: фичи взаимодействуют, эффекты каннибализируют друг друга. Shrinkage чинит статистическое смещение отбора, а не взаимодействия — холдаут он не заменяет
  • Peeking и множественные подглядывания метод не лечит. Если тест останавливают на красивом p-value, у вас смещение поверх смещения, и сначала стоит починить процедуру остановки
  • Мощность он тоже не заменяет. Поправка честно скажет, что от значимого результата слабого теста стоит ожидать вдвое меньшего, но информацию, которой в данных нет, она не создаст. Первое лекарство — считать MDE (минимальный детектируемый эффект) до теста, BHS — второе
  • Политика. Будьте готовы к разговору с командой, у которой «+2.1%» в отчёте превратился в «+1.4%». Мой аргумент: скорректированная цифра — та, которая с куда большей вероятностью сойдётся с фактом через квартал, и лучше её назвать самим, чем объяснять расхождение постфактум

Вывод

Проклятие победителя — это арифметика процедуры отбора: значимость покупается в том числе удачным шумом, и чем слабее тест, тем больше доля удачи в цене. Наивная сумма эффектов запущенных фич завышена на десятки процентов — у Airbnb расхождение с холдаутом было 7.2% против 4%, в моей симуляции +27%. BHS нравится мне тем, что честно решает практическую задачу: замкнутые формулы, один параметр из истории тестов, метод не наказывает сильные сигналы и сжимает слабые к реальности. Способа дешевле сделать годовой отчёт об импакте честным я не знаю: выгрузка истории, τ и тридцать строк Python

Задачи на selection bias, мощность и дизайн экспериментов разбираю в каталоге задач


*Meta признана экстремистской организацией, её деятельность запрещена на территории РФ

Связанные задачи

Другие статьи