Эксперименты и бизнес
~18 мин

A/B-тесты в RecSys

Interleaving, on-policy vs off-policy, метрики вовлечённости, долгосрочные эффекты.

A/B-тесты в рекомендациях — почему всё сложнее, чем кажется

Загрузка интерактивного виджета...

Ты натренировал новую модель. Офлайн-метрики выросли: NDCG +2%, Recall@10 +3%. Значит ли это, что пользователи будут довольнее? Не факт. Офлайн-метрики и бизнес-результат расходятся постоянно: модель может лучше ранжировать исторические данные, но хуже работать на живом трафике из-за feedback loops, position bias или сдвига распределения.

Единственный способ проверить — A/B-тест на реальных пользователях. Но A/B-тесты для рекомендательных систем радикально сложнее, чем для обычных фичей (цвет кнопки, текст заголовка). Почему? Потому что рекомендации создают петли обратной связи: модель рекомендует → пользователь кликает → модель учится на кликах → рекомендует ещё агрессивнее. Стандартные предположения A/B-тестов (независимость наблюдений, стационарность) нарушаются.

В этой ноде — полная картина A/B-тестирования для RecSys: от базовой механики (контроль vs treatment) до продвинутых техник (interleaving, guardrail-метрики). Разберём подводные камни, которые убивают эксперименты: неправильное сплитование, novelty effect, сезонность.

Большая картина: как устроен A/B-тест для рекомендаций

A/B-тест — это контролируемый эксперимент. Пользователей случайно делят на две группы: Контроль (A): видит текущую модель рекомендаций — ту, что уже в продакшне. Treatment (B): видит новую модель — ту, которую ты хочешь проверить. После достаточного количества наблюдений сравниваем метрики между группами. Если разница статистически значима — новая модель действительно лучше (или хуже), а не случайно повезло.

Звучит просто, но для RecSys есть три фундаментальных отличия от «обычных» A/B-тестов: 1. Метрики многомерны. Нельзя смотреть только CTR — пользователь может кликать кликбейт и уходить через 5 секунд. Нужно отслеживать десятки метрик одновременно: engagement, retention, revenue, diversity. 2. Эффект проявляется не сразу. Изменение цвета кнопки влияет на конверсию мгновенно. Изменение модели рекомендаций может повлиять на retention только через недели — когда пользователь либо останется, либо уйдёт. 3. Модели взаимодействуют с данными. Если модель рекомендует только популярные фильмы → пользователи кликают популярное → модель получает ещё больше данных про популярное → цикл замыкается. A/B-тест измеряет не статичную «качество модели», а динамику всей системы.

Классический A/B тест vs Interleaving
Классический A/B делит пользователей на группы. Interleaving сравнивает модели внутри одного пользователя — убирает межпользовательскую дисперсию.

Сплитование: по пользователям, не по запросам

Критический момент, который ломает эксперименты: сплит должен быть по user_id, а не по отдельным запросам. Если один и тот же пользователь иногда видит модель A, иногда модель B — это катастрофа. Ты не сможешь понять, какая модель повлияла на его поведение. Пользователь видит странную «прыгающую» ленту, его UX ухудшается, и ты меряешь не качество модели, а степень раздражения.

Стандартный подход: берём hash(user_id) % 100 и назначаем пользователя в группу. Хеш детерминирован — один и тот же пользователь всегда попадает в одну группу. Это также позволяет запускать несколько экспериментов параллельно, разделяя «слоты» (0-49 — эксперимент 1, 50-99 — эксперимент 2).

import hashlib

def assign_group(user_id: str, experiment: str, num_groups: int = 2) -> int:
    ""    .
    
        user_id     .
     experiments   seed   .
    ""
    key = f"{experiment}:{user_id}"
    hash_val = int(hashlib.md5(key.encode()).hexdigest(), 16)
    return hash_val % num_groups

# Пользователь 42 всегда в одной группе для данного эксперимента
print(assign_group("user_42", "recsys_v2"))  # → 0 (контроль)
print(assign_group("user_42", "recsys_v2"))  # → 0 (тот же результат)
print(assign_group("user_42", "recsys_v3"))  # → 1 (другой эксперимент — другая группа)

Сетевые эффекты — когда сплит по user_id недостаточен

В социальных сетях, маркетплейсах и платформах с общим инвентарём рекомендации одного пользователя влияют на других. Если модель B рекомендует всем один товар → он раскупается → пользователи группы A тоже его не находят. Это network effect (сетевой эффект). Решения: кластерный рандомизированный эксперимент (сплит по городам, регионам), или switchback design (чередование A/B во времени).

Interleaving — сравниваем модели в 100× быстрее

Классический A/B-тест делит пользователей пополам. Проблема: разные пользователи — разное поведение. Кто-то кликает на всё подряд, кто-то — раз в неделю. Эта межпользовательская дисперсия огромна и «заглушает» тонкую разницу между моделями. Чтобы увидеть +0.5% CTR, нужны сотни тысяч пользователей и недели эксперимента.

Interleaving решает эту проблему радикально: вместо разделения пользователей, каждый пользователь видит результаты обеих моделей вперемешку в одном списке. Модель A предложила [X, Y, Z], модель B — [Y, W, X]. Interleaved список: [X, Y, W, Z, ...]. Каждый клик пользователя — «голос» за ту модель, которая предложила этот айтем.

Почему это быстрее? Сравнение происходит внутри одного пользователя — межпользовательская дисперсия полностью убирается. Netflix показал: interleaving обнаруживает различия между моделями в 100 раз быстрее классического A/B. Вместо 2 недель на 1 миллионе пользователей — 1 день на 10 тысячах.

def team_draft_interleaving(ranking_a: list, ranking_b: list, k: int) -> dict:
    ""Team-Draft Interleaving:    .
    
       :     
      .  ,     .
    ""
    interleaved = []
    team_a, team_b = set(), set()
    i, j = 0, 0
    
    while len(interleaved) < k:
        if len(team_a) <= len(team_b):
            # Очередь модели A: добавляем первый айтем, которого ещё нет
            while i < len(ranking_a) and ranking_a[i] in set(interleaved):
                i += 1
            if i < len(ranking_a):
                interleaved.append(ranking_a[i])
                team_a.add(ranking_a[i])
                i += 1
        else:
            while j < len(ranking_b) and ranking_b[j] in set(interleaved):
                j += 1
            if j < len(ranking_b):
                interleaved.append(ranking_b[j])
                team_b.add(ranking_b[j])
                j += 1
    
    return {"interleaved": interleaved, "team_a": team_a, "team_b": team_b}

# Пример: две модели ранжируют фильмы
result = team_draft_interleaving(
    ranking_a=["Inception", "Matrix", "Interstellar", "Tenet"],
    ranking_b=["Matrix", "Inception", "Arrival", "Blade Runner"],
    k=4,
)
# interleaved: [Inception, Matrix, Interstellar, Arrival]
# team_a: {Inception, Interstellar}, team_b: {Matrix, Arrival}
# Если юзер кликнул Inception и Matrix → score 1:1 (tie)
# Если кликнул Inception и Arrival → score 1:1 (tie)
# Если кликнул Inception и Interstellar → score 2:0 → модель A лучше

Interleaving ≠ замена A/B

Interleaving отвечает на вопрос «какая модель лучше ранжирует?», но не измеряет бизнес-эффект: revenue, retention, LTV. Для этого всё равно нужен классический A/B. На практике interleaving используют как скрининг: быстро отсеивают плохие модели, а оставшихся кандидатов тестируют через полноценный A/B.

Novelty effect — когда первые результаты обманывают

Запустил A/B-тест. Через 3 дня — CTR в treatment группе +8%! Победа? Нет. Подожди. Это классический novelty effect: пользователи видят новые, непривычные рекомендации и начинают кликать из любопытства. Через неделю-две эффект исчезает — engagement возвращается к базовому уровню или даже падает ниже.

Обратная ситуация — change aversion: новая модель меняет привычную ленту, пользователи раздражены, CTR падает. Через 2 недели привыкают, и метрики восстанавливаются. Если ты остановил тест на 3-й день — ты убил хорошую модель.

Как бороться: 1. Минимальная длительность — 2 полные недели. Это покрывает рабочие дни + выходные (паттерн потребления отличается). Идеально — 3-4 недели. 2. Смотри на тренд, а не на среднее. Строй график метрики по дням. Если в первые дни +8%, а потом выходит на +2% — реальный эффект ≈ +2%. 3. Burn-in period. Первые 2-3 дня эксперимента — исключай из анализа. Считай статистику только после стабилизации.

Метрики: что именно сравниваем

CTR — первое, что приходит в голову. И первая ловушка. Пользователь кликает кликбейт, разочаровывается, уходит. CTR вырос, а retention упал. Хорошая рекомендательная система оптимизирует долгосрочную ценность, не мгновенные клики.

Метрики выстраиваются по горизонтам: Краткосрочные (дни): CTR, время сессии, глубина просмотра, количество взаимодействий за визит. Реагируют быстро, но могут быть обманчивы. Среднесрочные (недели): Retention D7/D14/D30, конверсия в покупку, средний чек, частота возвращений. Главные метрики для большинства продуктов. Долгосрочные (месяцы): LTV (Lifetime Value), diversity потребления (не скатился ли пользователь в «пузырь»), churn rate. Самые ценные, но труднее всего измерить — A/B-тест такой длительности непрактичен.

Как выбрать primary metric (основную метрику решения)? Это зависит от продукта: • E-commerce: revenue per user, конверсия в покупку • Стриминг (видео/музыка): время просмотра/прослушивания, retention • Новостная лента: время на платформе, количество сессий в день • Маркетплейс: GMV (Gross Merchandise Volume), retention обеих сторон (покупатели + продавцы)

Sample size и длительность — сколько ждать

Два самых частых вопроса: «сколько пользователей нужно?» и «сколько дней держать тест?». Ответ зависит от четырёх параметров: 1. Baseline метрика (p₀). Текущий CTR = 5%. Чем выше базовый уровень, тем легче заметить изменение. 2. Минимальный детектируемый эффект (MDE). Какой прирост хотим заметить? +0.5 процентных пункта? +1%? Чем меньше MDE, тем больше нужно данных. 3. Уровень значимости (α). Вероятность ложного срабатывания (false positive). Стандарт: α = 0.05 (5%). 4. Мощность теста (1−β). Вероятность обнаружить реальный эффект, если он есть. Стандарт: 0.8 (80%).

from scipy import stats
import numpy as np

def sample_size_proportions(p0: float, mde: float,
                            alpha: float = 0.05, power: float = 0.8) -> int:
    ""     A/B    (CTR).
    
    p0: baseline CTR (, 0.05 = 5%)
    mde:    (,  0.005 = +0.5 ..)
    ""
    p1 = p0 + mde
    z_alpha = stats.norm.ppf(1 - alpha / 2)  # двусторонний тест
    z_beta = stats.norm.ppf(power)
    
    # Формула для z-теста пропорций
    p_avg = (p0 + p1) / 2
    n = ((z_alpha * np.sqrt(2 * p_avg * (1 - p_avg)) +
          z_beta * np.sqrt(p0 * (1 - p0) + p1 * (1 - p1))) / mde) ** 2
    
    return int(np.ceil(n))

# Пример: CTR = 5%, хотим заметить +0.5 п.п. (MDE = 0.005)
n = sample_size_proportions(p0=0.05, mde=0.005)
print(f"Нужно {n:,} пользователей в каждой группе")
# → ~31,000 на группу → 62,000 всего

# Хотим заметить +0.2 п.п. (MDE = 0.002)
n_small = sample_size_proportions(p0=0.05, mde=0.002)
print(f"MDE 0.2 п.п.: {n_small:,} на группу")
# → ~190,000 на группу — в 6× больше!

Длительность = sample_size / DAU × 2. Если нужно 60K пользователей на группу и DAU = 100K → тест займёт ~2 дня по трафику. Но из-за novelty effect и сезонности минимум — 2 полные недели, чтобы покрыть циклы рабочие/выходные. Никогда не запускай A/B-тест в Чёрную пятницу, перед Новым годом или в период акций — сезонность исказит результаты.

Guardrail-метрики: что НЕ должно сломаться

Primary metric выросла — отлично. Но что если при этом latency увеличилась в 3 раза? Или crash rate подскочил? Или жалобы в поддержку удвоились? Guardrail-метрики — это «красные линии», которые новая модель не должна пересекать.

Типичные guardrails для рекомендательных систем: • Latency p99 — время ответа для 99-го перцентиля. Если рекомендации стали медленнее → пользователь уходит, не дождавшись • Error rate — процент запросов с ошибкой. Новая модель может быть нестабильнее • Coverage — какую долю каталога система рекомендует. Если coverage упала → модель скатилась к узкому набору популярных айтемов • Diversity — насколько разнообразны рекомендации. Рост CTR за счёт «кликбейтных» повторов — плохо • Retention (как guardrail) — даже если primary metric = revenue, retention не должен падать • Жалобы / отписки / uninstalls — прямой сигнал о проблемах

Правило: эксперимент проходит, только если primary metric значимо выросла И ни один guardrail не пробит. Если CTR +3%, но latency p99 выросла на 200ms — эксперимент не проходит. Сначала чини latency, потом перезапускай.

Поправка на множественное сравнение

Если ты смотришь на 20 метрик одновременно с α = 0.05, то в среднем 1 из 20 покажет ложную значимость. Поправка Бонферрони — самая простая: α_corrected = 0.05 / 20 = 0.0025. Для guardrails это слишком строго — обычно используют Benjamini-Hochberg (контролирует FDR — долю ложных среди значимых, а не среди всех). Либо разделяют: primary metric с α = 0.05, guardrails с α = 0.01.

Полный пример: от гипотезы до решения

import numpy as np
from scipy import stats

def ab_test_analysis(control: dict, treatment: dict, alpha: float = 0.05):
    """Анализ A/B-теста для рекомендаций: z-test для CTR + доверительный интервал."""
    ctr_c = control['clicks'] / control['views']
    ctr_t = treatment['clicks'] / treatment['views']
    
    # Pooled proportion и z-test
    p_pool = (control['clicks'] + treatment['clicks']) / (control['views'] + treatment['views'])
    se = np.sqrt(p_pool * (1 - p_pool) * (1/control['views'] + 1/treatment['views']))
    z_stat = (ctr_t - ctr_c) / se
    p_value = 2 * (1 - stats.norm.cdf(abs(z_stat)))
    
    # Относительный lift и 95% доверительный интервал
    lift = (ctr_t - ctr_c) / ctr_c
    se_lift = np.sqrt(ctr_t*(1-ctr_t)/treatment['views'] + ctr_c*(1-ctr_c)/control['views'])
    ci_low = (ctr_t - ctr_c) - 1.96 * se_lift
    ci_high = (ctr_t - ctr_c) + 1.96 * se_lift
    
    return {
        "ctr_control": f"{ctr_c:.4f}",
        "ctr_treatment": f"{ctr_t:.4f}",
        "lift": f"{lift:+.2%}",
        "p_value": f"{p_value:.4f}",
        "significant": p_value < alpha,
        "ci_95": f"[{ci_low:+.4f}, {ci_high:+.4f}]",
    }

# Сценарий: новая модель рекомендаций для e-commerce
# 2 недели, 50K пользователей в каждой группе
control = {"clicks": 5100, "views": }     # CTR = 5.10%
treatment = {"clicks": 5350, "views": }   # CTR = 5.35%

result = ab_test_analysis(control, treatment)
print(result)
# {'ctr_control': '0.0510', 'ctr_treatment': '0.0535',
#  'lift': '+4.90%', 'p_value': '0.0097', 'significant': True,
#  'ci_95': '[+0.0006, +0.0044]'}
# 
# Решение: lift = +4.9%, p < 0.01, CI не включает 0 → раскатываем.
# НО! Проверяем guardrails: latency p99, error rate, retention D7.

Собираем всё вместе

Полный pipeline валидации новой модели рекомендаций: Шаг 1. Офлайн-метрики — NDCG, Recall@K на hold-out. Если хуже текущей модели → не тратим трафик. Шаг 2. Off-policy evaluation — по логам старой модели оцениваем, как бы работала новая (IPS, doubly robust). Фильтруем совсем плохих кандидатов. Шаг 3. Interleaving — быстрый скрининг на малом трафике (1-5% аудитории, 2-3 дня). Отвечает: «ранжирует ли новая модель лучше?» Шаг 4. A/B-тест — полноценный эксперимент с бизнес-метриками. Сплит по user_id, 2-4 недели, primary metric + guardrails. Шаг 5. Раскатка — если A/B пройден → постепенная раскатка (10% → 50% → 100%) с мониторингом guardrails.

Если запомнить одну вещь: A/B-тест для рекомендаций — это не «сравни CTR за 3 дня». Это минимум 2 недели, сплит по user_id, десяток guardrail-метрик и обязательная проверка на novelty effect. Поспешные решения на основе коротких тестов — главная причина провалов в продакшне.

🎯 На собеседовании

Junior

Как сплитовать A/B-тест для рекомендаций? По user_id (хеш), НЕ по запросам. Один пользователь должен видеть только одну модель. Иначе — pollution между группами. • Что такое novelty effect? Пользователи кликают на новое из любопытства. Первые дни CTR завышен, потом возвращается к реальному уровню. Поэтому минимальная длительность теста — 2 недели. • Какие метрики смотреть? Не только CTR: engagement (время, глубина), retention (D7, D30), revenue. CTR может расти за счёт кликбейта.

Middle

Что такое interleaving и зачем? Результаты обеих моделей показываются вперемешку одному пользователю. Убирает межпользовательскую дисперсию → нужно в 100× меньше трафика. Но не измеряет бизнес-эффект — это скрининг, не замена A/B. • Как рассчитать sample size? По формуле z-теста: зависит от baseline CTR, MDE (минимальный эффект), α (ложные срабатывания), power (1−β). Типично: MDE = 1% от baseline, α = 0.05, power = 0.8. • Guardrail-метрики — что это? Метрики, которые НЕ должны ухудшиться: latency, error rate, coverage, diversity. Тест проходит, только если primary metric выросла И guardrails не пробиты.

Senior

Сетевые эффекты в A/B-тесте рекомендаций? Рекомендации влияют на общий инвентарь (товар раскупается, контент набирает просмотры). Сплит по user_id не спасает — нужен кластерный рандомизированный эксперимент (по регионам/сегментам) или switchback design. • Off-policy evaluation — как работает? Оцениваем новую модель по логам старой. IPS (Inverse Propensity Scoring): перевзвешиваем награды по p(новая модель показала бы этот айтем) / p(старая показала). Doubly robust — комбинирует IPS с прямой оценкой для снижения дисперсии. • Как бороться с multiple testing? Бонферрони (α/n) — строго, но консервативно. Benjamini-Hochberg контролирует FDR (False Discovery Rate). На практике: primary metric с α = 0.05, guardrails с α = 0.01, exploratory метрики — без формальной коррекции.