продуктовая аналитикаA/B-тестыdifference-in-differencescausal inferencePython

Как оценить эффект фичи, если A/B невозможен

2026-07-15 12 мин

К тебе подходит продакт: «Месяц назад мы выкатили новый блок рекомендаций — сразу на всех. Заказы вроде подросли. Посчитаешь, сколько дала фича?» A/B-тест никто не заложил, контрольной группы нет, фича уже у каждого пользователя. И такое в продуктовой аналитике случается куда чаще, чем хочется признавать.

A/B-тест — золотой стандарт причинности, но золото есть не всегда. Ниже — три рабочих способа оценить эффект раскатки, когда честного эксперимента не было: difference-in-differences, синтетический контроль и старое доброе «до/после». Каждый со своими оговорками, и главный навык здесь — понимать, где метод начинает врать.

Почему A/B-тест — роскошь, а не данность

Причин, по которым фичу нельзя было разбить на группы, обычно несколько, и все жизненные.

Сетевые эффекты. Если запускаешь фичу на маркетплейсе или в соцсети, контроль и тест «протекают» друг в друга: продавец из тестовой группы влияет на покупателя из контрольной. Рандомизация по пользователям ломает сам эффект, который ты меришь.

Нельзя разбить аудиторию. Цену, условия доставки, изменения бренда или доверия по-разному разным людям не покажешь — это и нечестно, и часто юридически нельзя.

Раскатка «всем сразу». Инфраструктурные миграции, обязательные обновления, редизайн — их не держат в двух версиях.

Гео-раскатка. Логистику, поддержку, склад включают по регионам: сначала Москва, через месяц — миллионники, потом остальные. Это не баг, это операционная реальность — и, кстати, готовый естественный эксперимент.

Мало трафика. Чтобы добить статистическую мощность на редком событии (крупная B2B-сделка, дорогая конверсия), теста пришлось бы ждать полгода.

И самая частая причина: фича уже уехала в прод без сплита, а вопрос «сколько она принесла» прилетел постфактум.

Почему «после запуска стало лучше» — это ещё не эффект

Соблазн простой: взять метрику за месяц до и месяц после, вычесть, отрапортовать рост. На моей практике самый частый источник ошибки здесь — не статистика, а календарь.

Представь: блок рекомендаций включили 1 ноября, заказы выросли на 18%. Красиво. Только ноябрь против октября — это ещё и разгон предновогоднего спроса, и маркетинг, который именно в ноябре залил бюджет, и сезонный сдвиг поведения. Сколько из этих 18% — фича, а сколько — фон, простое вычитание не скажет.

Причинный вывод всегда строится на одном вопросе: что было бы с метрикой, если бы фичу НЕ включили? Этой альтернативной реальности у нас нет, её приходится восстанавливать. Все три метода ниже — про то, как построить правдоподобный counterfactual и вычесть его.

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

Difference-in-differences: вычитаем фон честно

Идея DiD простая до неприличия. Берём группу, которая получила фичу (например, Москва), и похожую группу без фичи (другие регионы). Смотрим, как изменилась метрика в каждой, и вычитаем одно изменение из другого. Фон — сезон, общий тренд, рекламу — предполагаем одинаковым для обеих групп, поэтому при вычитании он сокращается.

Сначала соберём панель «регион × неделя». Это обычный GROUP BY по усечённой дате:

select
    region,
    date_trunc('week', order_ts) as week,
    count(*)                     as orders
from orders
where order_ts >= '2026-01-01'
group by region, date_trunc('week', order_ts)
order by region, week;

Если date_trunc и агрегаты по неделям — пока не автоматизм, это ровно тот навык, что гоняют на SQL-тренажёре: собрать временную панель без единой лишней строки.

На двух точках логика видна невооружённым глазом:

treat_before, treat_after = 1200, 1440   # заказы/день там, где включили фичу
ctrl_before,  ctrl_after  = 1000, 1080   # заказы/день в похожих регионах без фичи

lift_treat = treat_after - treat_before   # +240
lift_ctrl  = ctrl_after  - ctrl_before    # +80  — это фон: сезон, реклама, тренд
did        = lift_treat - lift_ctrl       # +160 — вот это уже вклад фичи

Наивный ответ был бы +240 («Москва выросла на 20%»). DiD говорит: 80 из них дала бы и без фичи, честный эффект — +160.

На реальных данных считать хочется не по двум точкам, а по всей панели, да ещё с доверительным интервалом. Классическая форма — регрессия на взаимодействии treat×post:

import statsmodels.formula.api as smf

# df: region, week, orders, treat (1 — регион с фичей), post (1 — недели после запуска)
did = smf.ols('orders ~ treat * post', data=df) \
         .fit(cov_type='cluster', cov_kwds={'groups': df['region']})

print(did.params['treat:post'])              # оценка эффекта
print(did.conf_int().loc['treat:post'])      # 95%-й доверительный интервал

Коэффициент при treat:post — это и есть DiD-оценка. Обрати внимание на cov_type='cluster': стандартные ошибки кластеризуем по региону. Без этого ты примешь автокорреляцию соседних недель за независимые наблюдения и получишь фантастически узкий интервал (классическая ловушка, разобранная у Bertrand, Duflo и Mullainathan ещё в 2004-м).

Когда юнитов и периодов много, надёжнее two-way fixed effects — фиксируем и регион, и неделю, а эффект ловим одной меткой «тест в пост-периоде»:

df['treated_now'] = df['treat'] * df['post']   # 1 только для теста после запуска

twfe = smf.ols('orders ~ treated_now + C(region) + C(week)', data=df) \
          .fit(cov_type='cluster', cov_kwds={'groups': df['region']})
print(twfe.params['treated_now'])

Приведение данных к такому виду — все эти merge, pivot и метки групп — рутина, которую стоит довести до автоматизма: базу для неё даёт курс по pandas, а погонять на задачах — Python-тренажёр.

Одна оговорка на будущее: если регионы включались в разное время (staggered rollout), обычный TWFE может дать смещённую оценку — тогда смотри в сторону оценщиков Callaway–Sant'Anna. Но для «включили в одном регионе, сравниваем с остальными» классического DiD достаточно.

Как понять, что DiD тебя не обманывает

Весь DiD держится на одном допущении — параллельных трендов: без фичи тест и контроль двигались бы синхронно. Доказать его нельзя, но проверить косвенно — обязательно.

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

Плацебо-тест. Сдвинь дату запуска на пару недель назад, в период, когда фичи ещё не было, и посчитай DiD на нём. Настоящего эффекта там быть не может — если он «нашёлся», значит метод ловит не фичу, а разницу в трендах.

И трезво оценивай число кластеров. Если тестовый регион всего один, кластеризовать по региону не выйдет — данных для устойчивого интервала попросту мало. Это не тупик, это сигнал, что пора доставать следующий инструмент.

Синтетический контроль: когда одного контроля мало

DiD любит, чтобы контрольная группа была похожа на тестовую. А если фичу включили в одном большом регионе и ни один другой по отдельности на него не похож? Тогда контроль можно собрать искусственно — как взвешенную смесь остальных.

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

from scipy.optimize import minimize
import numpy as np

# Y_treat_pre  — метрика тестового региона ДО запуска (вектор по неделям)
# Y_donors_pre — матрица: столбцы = регионы-доноры, строки = те же недели ДО

def loss(w):
    return np.sum((Y_treat_pre - Y_donors_pre @ w) ** 2)

n = Y_donors_pre.shape[1]
cons   = {'type': 'eq', 'fun': lambda w: w.sum() - 1}  # веса суммируются в 1
bounds = [(0, 1)] * n                                   # без отрицательных весов
w = minimize(loss, x0=np.ones(n) / n, bounds=bounds, constraints=cons).x

# эффект по неделям после запуска:
effect = Y_treat_post - Y_donors_post @ w

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

Метод хорош именно для агрегатов: один регион, один крупный клиент, одна площадка. Для пользовательских данных с миллионами строк он избыточен — там обычно хватает DiD.

А если контроля нет вообще?

Бывает и так: фичу включили всем и сразу, сравнивать не с кем. Остаётся только история самого продукта. Строишь модель на данных «до» — тренд плюс недельная и годовая сезонность — экстраполируешь на период после запуска и берёшь разницу между фактом и прогнозом. Это interrupted time series, и рядом с ней — байесовские структурные модели, где counterfactual собирается из коррелирующих, но не затронутых фичей рядов.

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

Так какой метод выбирать?

СитуацияМетод
Много похожих юнитов, есть контроль, параллельные трендыdifference-in-differences
Один крупный юнит (регион/клиент), много доноровсинтетический контроль
Контроля нет, только история самого продуктадо/после с модельным counterfactual

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

Оговорки, из-за которых красивый вывод разваливается

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

Протечки между группами. Если пользователи из контроля видят фичу (сменили регион, поделились ссылкой, сетевой эффект), DiD занизит эффект — контроль «заражён» тестом. Это нарушение SUTVA, и лечится оно только аккуратным выбором групп.

Смена состава. Иногда метрика двигается не потому, что люди стали вести себя иначе, а потому, что фича привела других людей. Рост среднего чека при этом может означать, что просто ушли мелкие заказы. Всегда проверяй, не поменялась ли аудитория под метрикой.

Одна метрика, объявленная заранее. Если перебрать двадцать метрик и выбрать ту, где p-value красивый, ты найдёшь «эффект» на пустом месте. Целевую метрику фиксируй до анализа.

Значимость статистическая и практическая — не одно и то же. +160 заказов в день может быть статзначимо и при этом не окупать разработку. Возвращай вывод к деньгам и к решению, которое продакт принимает по итогам.

И, наконец, честность формулировки. Правильный ответ продакту звучит не «фича дала +160 заказов», а «по DiD оценка +160 в день, доверительный интервал такой-то, при допущении параллельных трендов; пред-тренды и плацебо чистые». Это не занудство — это то, что отличает оценку, которой можно верить, от красивого числа.

Где потренироваться

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

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