Самый неудобный вопрос, который мне задавали про A/B-тест, звучал так: «Окей, за две недели метрика выросла. А что будет через полгода?» Честный ответ — «не знаю». Тест живёт две недели, потому что дольше держать трафик дорого и очередь из гипотез не ждёт. А эффекты, ради которых всё затевалось — retention, LTV, отток, — копятся месяцами. Novelty-эффект, усталость от фичи, кликбейтные механики, которые сначала поднимают активность, а потом роняют доверие, — всё это в двухнедельное окно не влезает
Я продуктовый аналитик, до этого — мехмат МГУ. Несколько лет я жила в этой развилке: либо быстрые тесты с риском поймать мираж, либо тесты-марафоны, на которые никто не согласится.
В январе 2024-го команда Netflix выложила короткий практичный пейпер: Evaluating the Surrogate Index as a Decision-Making Tool Using 200 A/B Tests at Netflix (Vickie Zhang, Michael Zhao, Anh Le, Maria Dimakopoulou, Nathan Kallus). Они взяли 1098 тестовых групп из 200 реальных A/B-тестов и проверили: если бы решения принимались по суррогатному индексу на 14-й день, а не по прямому замеру на 63-й — насколько бы решения разошлись? Спойлер: почти никак, и это меняет экономику экспериментов. Ниже — как метод устроен, мой код, на котором его можно пощупать, и два сценария, где он молча врёт
Идея: предсказывать долгий эффект, а не ждать его
Сама конструкция называется суррогатным индексом и придумана не в Netflix — базовая работа это Athey, Chetty, Imbens, Kang (2019). На Хабре про прокси-метрики писали, например, коллеги из X5 — хороший концептуальный обзор, но без кода и без эмпирики. Здесь я разбираю конкретную реализацию, которую Netflix проверил на своих 200 тестах, и воспроизвожу её на симуляции
Netflix использует самый скромный вариант — линейный auto-surrogate. «Auto» означает, что для предсказания долгосрочной метрики не нужны никакие другие метрики: только она сама, измеренная раньше
Обозначения. Для пользователя i есть дневные значения метрики Y_i1, Y_i2, … (у Netflix это стриминговая активность, у вас может быть что угодно с ежедневным значением). Долгосрочный результат — среднее за 63 дня:
μ_i = (1/63) · Σ Y_it, t = 1…63
Прямой замер эффекта — обычная разность средних μ между тестом и контролем. Проблема одна: чтобы её посчитать, нужно прожить все 63 дня
Суррогатный индекс подменяет μ_i его предсказанием по первым T дням (в пейпере T = 14):
μ̂_i = β₀ + Σ β_t · Y_it, t = 1…T
Дальше всё как обычно: difference-in-means, только не по сырой двухнедельной метрике, а по предсказанным 63-дневным значениям. Стандартная ошибка, t-статистика — тем же способом, что и всегда
Весь вопрос в том, откуда взять коэффициенты β. И вот тут главная красота метода: их обучают на данных, где 63-дневный горизонт уже прожит. Netflix пробует два источника:
- Pre-test: те же пользователи, 63 дня их истории до попадания в эксперимент. Модель учится на «мирном времени»: как первые две недели активности связаны с двухмесячным средним
- Similar test: завершённый эксперимент из той же продуктовой области, где все 63 дня уже отнаблюдали
Оба варианта в пейпере дали практически одинаковые результаты. Это важно: значит, метод не капризный к источнику обучающих данных
Если написать это кодом, весь метод — четыре строки:
from sklearn.linear_model import LinearRegression
# pre: матрица (пользователи × 63 дня) ДО эксперимента
model = LinearRegression().fit(pre[:, :14], pre.mean(axis=1))
# y_t, y_c: первые 14 дней теста и контроля в эксперименте
effect = model.predict(y_t[:, :14]).mean() - model.predict(y_c[:, :14]).mean()
Никакой платформы, никакого ML-пайплайна. Линейная регрессия на данных, которые и так лежат в хранилище
Что показали 200 тестов Netflix
Теперь цифры из пейпера — они и есть главный вклад работы, потому что сам метод известен с 2019 года, а вот масштабной проверки «а совпадут ли решения» до этого не публиковал никто
- ~95% статистических выводов совпали. Решение по суррогату на 14-й день и решение по прямому замеру на 63-й день разошлись в одном случае из двадцати
- Precision 79%, recall 65% по решению «запускаем». Из фич, которые суррогат предложил запустить, 79% подтвердились прямым замером. Из фич, которые стоило запустить по 63-дневному замеру, суррогат поймал 65%
- Ни одного катастрофического промаха. Во всех 1098 группах не нашлось ни одной, где суррогат сказал бы «запускаем», а прямой замер показал бы статзначимое ухудшение. Худшее, что случалось, — суррогат зажигал зелёный там, где длинный замер говорил «эффект не доказан»
- Пропускная способность растёт до +300%. Цикл теста сжимается с двух месяцев до двух недель — на том же трафике помещается вчетверо больше тестов. Даже с поправкой на recall 65% (часть хороших фич суррогат пропустит, придётся прогнать примерно в полтора раза больше тестов) арифметика уверенно в плюс
Отдельная честная деталь: суррогатные оценки чаще оказывались статистически незначимыми, чем прямые (86,5% против 79%). Суррогат — консервативный судья: у него ниже дисперсия, но и сигнал он видит только тот, что успел проявиться за 14 дней
Щупаем на симуляции
Пейпер не приложил код, а данные Netflix нам никто не даст, поэтому я собрала симуляцию: 20 000 пользователей, дневная активность с лог-нормальными базовыми уровнями (тяжёлый хвост, как в жизни), автокоррелированным шумом и недельной сезонностью. Полный скрипт — в конце статьи, здесь только суть
Сценарий: фича с novelty-эффектом. Истинный долгосрочный лифт +1%, но первые дни пользователи тыкают в новую кнопку из любопытства — сверху затухающий буст +4%. Классическая ситуация, в которой двухнедельный тест врёт

Что получилось на 14-й день:
| Оценка | Лифт | SE | Решение |
|---|---|---|---|
| Наивный замер за 14 дней | +3,30% | 0,83 п.п. | запускать |
| Суррогатный индекс (T=14) | +3,04% | 0,73 п.п. | запускать |
| Прямой замер за 63 дня | +2,22% | 0,78 п.п. | запускать |
Два наблюдения. Суррогат ближе к длинному ответу, чем наивный замер, и стандартная ошибка у него ниже — модель отфильтровывает часть шума, взвешивая дни неравномерно (в моей симуляции наибольшие веса получили первый и последний день окна). Но чудо-фильтрации novelty не произошло: суррогат тоже завышает, потому что обучался на «мирных» данных и ничего не знает про затухание чужого любопытства. Он честно проецирует те 14 дней, которые видел
Потом я прогнала портфель из 200 симулированных тестов с реалистичным распределением эффектов — большинство около нуля, редкие настоящие победители и проигравшие:

Решения совпали в 88% случаев. Все 20 фич, которые стоило запускать, суррогат нашёл, и ни разу не предложил запустить статзначимо вредное. Расплата — 22 ложных «запускать» там, где длинный замер сказал бы «не доказано»: у суррогата ниже дисперсия, ему проще дотянуться до значимости. У Netflix на реальных данных recall был скромнее (65% против моих 100%) — в моей симуляции нет их fat-tailed эффектов и дрейфа аудитории, так что мои цифры — это верхняя граница оптимизма, а не обещание
Как внедрить у себя
Что нужно из данных: дневные значения целевой метрики по пользователям — за период эксперимента и за 2–3 месяца до него. В любой компании с ClickHouse/Greenplum и обычной событийной таблицей это один запрос с GROUP BY user_id, toDate(ts)
Рецепт по шагам:
- Выбери долгосрочную метрику как среднее дневных значений за горизонт H (63 дня — это калибровка Netflix, подставь свою: 90 для LTV-прокси, 30 для ранних продуктов)
- Собери матрицу «пользователь × день» за H дней до эксперимента для тех же пользователей
- Обучи линейную регрессию: признаки — первые T дней, таргет — среднее за H
- Примени модель к первым T дням эксперимента, посчитай difference-in-means и SE по предсказанным значениям
- Раз в квартал — валидация: на завершённых длинных тестах сравни решения по суррогату с прямым замером и посчитай свою confusion-матрицу, как Netflix. Без этого шага метод внедрять нельзя: только собственная confusion-матрица показывает, что surrogacy на ваших данных вообще выполняется
Пара инженерных замечаний. T = 63 в этой конструкции даёт в точности прямой замер, так что метод честно вкладывает наивный подход как частный случай. Регуляризация (Ridge) на практике полезна: соседние дни сильно коррелированы. И не удивляйся, что коэффициенты β не похожи на равномерные веса — модель сама решает, какие дни информативнее
Кстати, если у вас уже внедрён CUPED, вы почти всё умеете: там pre-experiment данные снижают дисперсию, здесь они же дают модель для прогноза горизонта. Обучающая выборка и там и там одна — история пользователей до теста
Где метод молча врёт
Всё держится на одном предположении — surrogacy: короткое окно должно содержать весь путь от фичи к долгосрочному эффекту. Формально — условная независимость долгосрочного исхода от тритмента при известных краткосрочных наблюдениях. Если эффект качественно меняется после окна наблюдения, суррогат ошибётся и подкрепит ошибку уверенным p-value
Я собрала такой сценарий специально: механика-кликбейт. Первые недели активность растёт (+10% с затуханием), а долгосрочный эффект отрицательный (−2%): пользователи выгорают

На 14-й день наивный замер показывает +2,0%, суррогат — +2,1%, оба со значимостью, оба говорят «запускать». Прямой замер на 63-й день: −1,6%, статзначимое падение. Суррогат не защитил вообще: вся информация о развороте лежит за пределами его окна, а взять её неоткуда
Отсюда практические границы:
- Не применяй к механикам, которые по своей природе двухфазные: триалы и промо-периоды, пуш-кампании, изменение цен, всё, где «сначала всплеск, потом расплата» — это не редкий сбой, а ожидаемый паттерн
- Суррогат не отменяет длинные тесты совсем. Netflix смог посчитать свои 95% только потому, что 200 тестов дожили до 63-го дня. Рабочая схема для обычной компании: каждый пятый-десятый тест честно доживает до полного горизонта и пополняет валидационную выборку
- SE считается по предсказанным значениям и не знает, что β оценены с ошибкой. При выборках в десятки тысяч пользователей и коротком T это несущественно; при маленьких выборках интервалы будут самоуверенными
- Метрика должна быть «накопительной» по природе — активность, транзакции, время. Для событий с длинным лагом (первая покупка через 40 дней после онбординга) первые 14 дней просто не содержат сигнала, и никакая регрессия его не изобретёт
Вывод
Суррогатный индекс — сделка: вы обмениваете часть надёжности (recall 65–80%, риск пропустить хорошую фичу) на четырёхкратную пропускную способность экспериментов. На 200 тестах Netflix сделка выглядит выгодной: 95% тех же решений и ни одного запущенного статзначимо вредного изменения. Работает она, только пока выполняется surrogacy, — поэтому валидация на своих длинных тестах и запрет на двухфазные механики входят в цену метода
Реализация — линейная регрессия на данных, которые у вас уже есть. Из всех методов ускорения экспериментов, что я разбирала, у этого лучшее соотношение «эффект на решения / стоимость внедрения»
Если готовитесь к собеседованиям по A/B-тестам и экспериментам — задачи на novelty-эффект, peeking и дизайн экспериментов (включая те, где спрашивают про долгосрочные эффекты) я собираю в каталоге задач — часть открыта бесплатно
Полный скрипт симуляции
~120 строк, numpy + scikit-learn. Все числа в статье — из его вывода
# -*- coding: utf-8 -*-
"""Симуляция для статьи про surrogate index (по мотивам arXiv:2311.11922).
Сценарий: дневная активность пользователей, тест с novelty-эффектом.
Сравниваем три оценки 63-дневного эффекта:
- naive-14: difference-in-means по первым 14 дням
- surrogate-14: auto-surrogate модель (обучена на pre-test данных)
- direct-63: прямой замер за 63 дня (истина, доступная через 2 месяца)
"""
import numpy as np
from numpy.random import default_rng
from sklearn.linear_model import LinearRegression
H = 63 # горизонт "долгосрочного" эффекта, дней
T = 14 # окно суррогата, дней
N_USERS = 20000 # пользователей в тесте (пополам на группы)
def simulate_users(rng, n, days, base=None):
"""Дневная метрика: активность в минутах, AR(1)-шум вокруг базового уровня."""
if base is None:
base = rng.lognormal(mean=3.0, sigma=0.5, size=n) # ~20 мин медиана, тяжёлый хвост
y = np.empty((n, days))
eps = rng.normal(0, 0.35, size=(n, days))
ar = np.empty((n, days))
ar[:, 0] = eps[:, 0]
for t in range(1, days):
ar[:, t] = 0.6 * ar[:, t - 1] + eps[:, t]
weekly = 1 + 0.10 * np.sin(2 * np.pi * np.arange(days) / 7)
y = base[:, None] * weekly[None, :] * np.exp(ar - ar.var() / 2)
return base, y
def apply_effect(y, days, long_term, novelty, tau=7.0):
"""Мультипликативный эффект: долгосрочный уровень + затухающий novelty-буст."""
t = np.arange(days)
lift = long_term + novelty * np.exp(-t / tau)
return y * (1 + lift)[None, :]
def run_one_test(rng, long_term, novelty, n_users=N_USERS, return_curves=False):
n = n_users // 2
# pre-period: 63 дня истории ДО аллокации — на них учится auto-surrogate
base_c, pre_c = simulate_users(rng, n, H)
base_t, pre_t = simulate_users(rng, n, H)
pre = np.vstack([pre_c, pre_t])
model = LinearRegression().fit(pre[:, :T], pre.mean(axis=1))
# эксперимент: те же пользователи (тот же базовый уровень), новые 63 дня
_, y_c = simulate_users(rng, n, H, base=base_c)
_, y_t = simulate_users(rng, n, H, base=base_t)
y_t = apply_effect(y_t, H, long_term, novelty)
def dim(a, b): # difference in means + SE
d = a.mean() - b.mean()
se = np.sqrt(a.var(ddof=1) / len(a) + b.var(ddof=1) / len(b))
return d, se
naive14 = dim(y_t[:, :T].mean(axis=1), y_c[:, :T].mean(axis=1))
mu_t, mu_c = model.predict(y_t[:, :T]), model.predict(y_c[:, :T])
surr14 = dim(mu_t, mu_c)
direct63 = dim(y_t.mean(axis=1), y_c.mean(axis=1))
out = {'naive14': naive14, 'surr14': surr14, 'direct63': direct63,
'betas': model.coef_, 'control_mean': y_c.mean()}
if return_curves:
# бегущие оценки по дням: direct(t) и surrogate(t) для графика
run_direct, run_surr = [], []
for tt in range(2, H + 1):
run_direct.append(dim(y_t[:, :tt].mean(axis=1), y_c[:, :tt].mean(axis=1)))
m = LinearRegression().fit(pre[:, :tt], pre.mean(axis=1))
run_surr.append(dim(m.predict(y_t[:, :tt]), m.predict(y_c[:, :tt])))
out['run_direct'], out['run_surr'] = run_direct, run_surr
return out
def decision(d, se):
z = d / se
if z > 1.96: return 'ship'
if z < -1.96: return 'negative'
return 'no call'
if __name__ == '__main__':
rng = default_rng(7)
# --- Пример одного теста: novelty маскирует настоящий эффект ---
one = run_one_test(rng, long_term=0.01, novelty=0.04, return_curves=True)
cm = one['control_mean']
print('=== Один тест: истинный долгосрочный лифт +1.0%, novelty +4% с затуханием ===')
for k in ('naive14', 'surr14', 'direct63'):
d, se = one[k]
print(f'{k:>9}: лифт {100*d/cm:+.2f}% (SE {100*se/cm:.2f} п.п.), решение: {decision(d, se)}')
print('веса модели по дням (норм.):',
np.round(one['betas'] / one['betas'].sum(), 3))
# --- 200 симулированных тестов: согласованность решений ---
n_tests = 200
agree = 0
conf = {} # (direct_decision, surr_decision) -> count
ship_direct = ship_surr_hits = 0
for i in range(n_tests):
r = default_rng(1000 + i)
# реалистичный портфель тестов: большинство эффектов ~0, хвосты в обе стороны
lt = r.normal(0, 0.008)
nv = abs(r.normal(0, 0.02))
res = run_one_test(r, lt, nv, n_users=8000)
d_dir = decision(*res['direct63'])
d_sur = decision(*res['surr14'])
conf[(d_dir, d_sur)] = conf.get((d_dir, d_sur), 0) + 1
agree += (d_dir == d_sur)
if d_dir == 'ship':
ship_direct += 1
ship_surr_hits += (d_sur == 'ship')
print(f'\n=== {n_tests} тестов ===')
print(f'согласованность решений day-14 surrogate vs day-63 direct: {100*agree/n_tests:.0f}%')
print(f'recall по "запускать": {ship_surr_hits}/{ship_direct}',
f'({100*ship_surr_hits/ship_direct:.0f}%)' if ship_direct else '')
print('confusion:', conf)
# --- Подводный камень: эффект-ловушка (кликбейт) ---
print('\n=== Ловушка: первые недели рост, потом падение (нарушение surrogacy) ===')
rng2 = default_rng(42)
trap = run_one_test(rng2, long_term=-0.02, novelty=0.10, n_users=60000)
cm = trap['control_mean']
for k in ('naive14', 'surr14', 'direct63'):
d, se = trap[k]
print(f'{k:>9}: лифт {100*d/cm:+.2f}%, решение: {decision(d, se)}')