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

Netflix решает судьбу фичи за 14 дней, когда эффект виден через два месяца. Разбираю суррогатный индекс с кодом

Как работает суррогатный индекс: решение по A/B-тесту на 14-й день вместо 63-го, проверка Netflix на 200 тестах, симуляция с кодом и два сценария, где метод молча врёт.

Самый неудобный вопрос, который мне задавали про 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%. Классическая ситуация, в которой двухнедельный тест врёт

Бегущая оценка эффекта по дням: наивный замер, суррогат и прямой 63-дневный

Что получилось на 14-й день:

Оценка Лифт SE Решение
Наивный замер за 14 дней +3,30% 0,83 п.п. запускать
Суррогатный индекс (T=14) +3,04% 0,73 п.п. запускать
Прямой замер за 63 дня +2,22% 0,78 п.п. запускать

Два наблюдения. Суррогат ближе к длинному ответу, чем наивный замер, и стандартная ошибка у него ниже — модель отфильтровывает часть шума, взвешивая дни неравномерно (в моей симуляции наибольшие веса получили первый и последний день окна). Но чудо-фильтрации novelty не произошло: суррогат тоже завышает, потому что обучался на «мирных» данных и ничего не знает про затухание чужого любопытства. Он честно проецирует те 14 дней, которые видел

Потом я прогнала портфель из 200 симулированных тестов с реалистичным распределением эффектов — большинство около нуля, редкие настоящие победители и проигравшие:

Согласованность решений суррогата и прямого замера на 200 симулированных тестах

Решения совпали в 88% случаев. Все 20 фич, которые стоило запускать, суррогат нашёл, и ни разу не предложил запустить статзначимо вредное. Расплата — 22 ложных «запускать» там, где длинный замер сказал бы «не доказано»: у суррогата ниже дисперсия, ему проще дотянуться до значимости. У Netflix на реальных данных recall был скромнее (65% против моих 100%) — в моей симуляции нет их fat-tailed эффектов и дрейфа аудитории, так что мои цифры — это верхняя граница оптимизма, а не обещание

Как внедрить у себя

Что нужно из данных: дневные значения целевой метрики по пользователям — за период эксперимента и за 2–3 месяца до него. В любой компании с ClickHouse/Greenplum и обычной событийной таблицей это один запрос с GROUP BY user_id, toDate(ts)

Рецепт по шагам:

  1. Выбери долгосрочную метрику как среднее дневных значений за горизонт H (63 дня — это калибровка Netflix, подставь свою: 90 для LTV-прокси, 30 для ранних продуктов)
  2. Собери матрицу «пользователь × день» за H дней до эксперимента для тех же пользователей
  3. Обучи линейную регрессию: признаки — первые T дней, таргет — среднее за H
  4. Примени модель к первым T дням эксперимента, посчитай difference-in-means и SE по предсказанным значениям
  5. Раз в квартал — валидация: на завершённых длинных тестах сравни решения по суррогату с прямым замером и посчитай свою 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)}')

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

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