Самый неловкий момент в моей практике аналитика выглядит так. Конец квартала, руководство просит посчитать, сколько принесли эксперименты. Я складываю эффекты запущенных фич: +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 поправка растёт линейно бесконечно. На графике разница видна лучше, чем в формулах:

Проверка на реальных данных: 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 п.п. Теперь три способа отчитаться:

Наивная сумма — 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, из данных — одна таблица
- Выгрузите историю тестов за год-полтора: оценка эффекта и её стандартная ошибка по ключевой метрике. Важно: все тесты, включая незапущенные и отрицательные — приор строится по полному портфелю, иначе он сам получится смещённым. Эффекты приведите к одной шкале, у меня это процентные пункты конверсии
- Оцените τ функцией
fit_tauвыше. От 50 тестов в истории оценка уже осмысленная; пересчитывать достаточно раз в квартал - Каждому новому тесту считайте постериорное среднее и интервал функцией
shrink— и кладите в отчёт их, а не сырую оценку - Суммарный импакт за период — сумма постериорных средних запущенных тестов. Интервал на сумму — из постериорных дисперсий
Совместимость с остальной кухней экспериментов обычная: 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 признана экстремистской организацией, её деятельность запрещена на территории РФ