Когда A/B нельзя запустить
A/B-тест требует одного: возможности случайно разделить пользователей на группы до вмешательства. В теории — просто. На практике это часто невозможно, и вот почему.
В таких ситуациях у аналитика два варианта: сказать «нельзя измерить» и уйти, или использовать квазиэкспериментальные методы. Difference-in-Differences (DiD) — один из самых надёжных из них и при этом один из самых понятных.
Суть DiD: разность разностей
Представь простую ситуацию. Компания изменила алгоритм ранжирования товаров в Москве, а в Петербурге оставила старый. Через три месяца ты хочешь оценить эффект на конверсию. Что можно сделать?
Плохой вариант — просто до-после в Москве. Конверсия выросла на 4%. Но одновременно началась весна, закончились новогодние праздники, конкурент поднял цены. Ты не знаешь, сколько из этих 4% — эффект алгоритма.
DiD вычитает общий тренд через контрольную группу. Смотришь, как изменилась конверсия в Петербурге за тот же период. Если там тоже +2% (сезонность, рынок) — то эффект алгоритма составляет 4% − 2% = 2%.
Ключевая идея: контрольная группа показывает, каким был бы тренд в группе лечения, если бы вмешательства не было. Это называется контрфактуал. Разность между реальным исходом и контрфактуалом — и есть причинный эффект.
Тест параллельных трендов: как и зачем
DiD работает только при одном условии: в отсутствие вмешательства обе группы развивались бы параллельно. Это допущение параллельных трендов (parallel trends assumption). Проверить его напрямую нельзя — мы не знаем, что было бы с группой лечения без вмешательства. Но косвенно проверить можно.
Как проверить: визуально
Возьми данные до события. Нарисуй обе группы на одном графике. Если тренды идут параллельно (не пересекаются, не расходятся — движутся примерно с одинаковым наклоном) — допущение правдоподобно. Уровни могут быть разными, важен наклон, а не совпадение значений.
Как проверить: статистически
Построй регрессию только на предпериодных данных с interaction-термом «время × группа»:
# Данные только до события pre = df[df['period'] == 'before'].copy() # Линейный тренд × группа лечения pre['time_trend'] = pre['month'] # порядковый номер месяца pre['interaction'] = pre['treated'] * pre['time_trend'] model_pre = smf.ols( 'conversion ~ treated + time_trend + interaction', data=pre ).fit() # Если coef(interaction) ≈ 0 и p-value > 0.05 → тренды параллельны print(model_pre.summary())
Если коэффициент при interaction близок к нулю и статистически незначим — тренды параллельны и ты можешь доверять DiD. Если коэффициент значим — группы уже расходились до события, и оценка будет смещённой.
Пример на Python: два региона, до и после
Задача: компания изменила политику бесплатной доставки в Москве (от 1000 руб. вместо 2000 руб.). В Екатеринбурге ничего не менялось. Оцениваем эффект на конверсию в заказ за 6 месяцев после.
import pandas as pd import numpy as np import statsmodels.formula.api as smf # Создаём синтетический датасет # В реальной задаче — загрузить из базы np.random.seed(42) n = 12 # 12 месяцев: 6 до + 6 после months = pd.date_range('2024-01', periods=n, freq='MS') event_month = pd.Timestamp('2024-07') moscow = pd.DataFrame({ 'month': months, 'city': 'moscow', 'treated': 1, 'post': (months >= event_month).astype(int), # базовый тренд + эффект лечения 0.025 после события 'conversion': 0.12 + np.arange(n) * 0.001 + (months >= event_month) * 0.025 + np.random.normal(0, 0.003, n) }) ekb = pd.DataFrame({ 'month': months, 'city': 'ekb', 'treated': 0, 'post': (months >= event_month).astype(int), # тот же базовый тренд, без эффекта лечения 'conversion': 0.10 + np.arange(n) * 0.001 + np.random.normal(0, 0.003, n) }) df = pd.concat([moscow, ekb], ignore_index=True) # interaction-терм: treated × post — сердце DiD df['did'] = df['treated'] * df['post']
# Базовая DiD-регрессия # Y = α + β₁·treated + β₂·post + β₃·(treated×post) + ε # β₃ — это и есть наша DiD-оценка эффекта model = smf.ols('conversion ~ treated + post + did', data=df).fit() print(model.summary()) # Итоговая оценка did_estimate = model.params['did'] conf_int = model.conf_int().loc['did'] print(f"\nDiD-оценка эффекта: {did_estimate:.4f}") print(f"95% CI: [{conf_int[0]:.4f}, {conf_int[1]:.4f}]") print(f"p-value: {model.pvalues['did']:.4f}")
# Ожидаемый вывод (приблизительно): DiD-оценка эффекта: 0.0248 95% CI: [0.0168, 0.0328] p-value: 0.0001 # Интерпретация: # Снижение порога бесплатной доставки увеличило конверсию # в Москве на ~2.5 п.п. (от базы ~12% → ~14.5%) # относительно контрфактуального тренда из Екатеринбурга. # Эффект статистически значим (p < 0.001).
Коэффициент при did — это и есть оценка причинного эффекта. β₁ (treated) захватывает исходную разницу между регионами. β₂ (post) — общий временной тренд, одинаковый для обоих. β₃ (did = treated × post) — то, что изменилось в группе лечения сверх общего тренда. Это и есть DiD.
C(city) + C(month) в формулу. Это убирает неизменяемые различия между группами и общие временные шоки.
Где DiD ломается
DiD — мощный инструмент, но не серебряная пуля. Вот четыре ситуации, когда он даёт смещённую оценку.
Синтетический контроль: когда один регион без пары
Что делать, если у тебя один регион лечения и нет подходящей контрольной группы? Москва — это Москва: экономика другая, потребительское поведение другое, сезонность другая. Ни один регион не подходит как контроль.
В этом случае работает Synthetic Control Method (Абади и Гарадасабал, 2003). Идея: вместо реального контроля строим синтетический — взвешенную комбинацию нескольких потенциальных контролей, которая наилучшим образом воспроизводит предпериодную динамику группы лечения.
| DiD | Synthetic Control | |
|---|---|---|
| Контроль | Один или несколько реальных регионов | Взвешенная сумма регионов-доноров |
| Когда применять | Есть похожая контрольная группа, тренды параллельны | Нет подходящего контроля; один уникальный регион лечения |
| Ограничения | Параллельные тренды обязательны | Нужно ≥5–7 потенциальных доноров; только агрегированные данные |
| Значимость | Стандартные p-value через регрессию | Permutation inference (placebo tests) |
# Концептуальный скетч (не production-код) # Для полной реализации — библиотека SyntheticControlMethods from scipy.optimize import minimize # donors — матрица предпериодных метрик по регионам-донорам # treatment_pre — вектор метрики группы лечения в предпериоде def objective(weights): """Минимизируем расстояние между синтетическим и реальным трендом лечения""" synthetic = donors.T @ weights return np.sum((treatment_pre - synthetic) ** 2) # Ограничения: веса неотрицательны и в сумме = 1 constraints = {'type': 'eq', 'fun': lambda w: np.sum(w) - 1} bounds = [(0, 1)] * donors.shape[1] w0 = np.ones(donors.shape[1]) / donors.shape[1] result = minimize(objective, w0, method='SLSQP', bounds=bounds, constraints=constraints) optimal_weights = result.x # Синтетический контрфактуал в постпериоде synthetic_post = donors_post.T @ optimal_weights did_synth = treatment_post - synthetic_post print(f"Синтетический DiD: {did_synth.mean():.4f}")
Если хочешь готовую реализацию — смотри библиотеки SyntheticControlMethods (Python) или Synth (R). Проверка значимости в синтетическом контроле делается через placebo tests: применяешь тот же алгоритм к каждому региону-донору и смотришь, насколько уникален эффект лечения по сравнению с «ненастоящими» лечениями.
Частые вопросы
Что такое Difference-in-Differences?
Чем DiD отличается от простого «до-после»?
Как проверить параллельные тренды?
Когда DiD лучше, чем PSM?
Сколько нужно периодов для DiD?
Что такое TWFE и когда он нужен?
C(city) + C(month) в OLS или библиотека linearmodels с PanelOLS.Связанные материалы
Главное про DiD
DiD — не замена A/B, а инструмент на случай, когда A/B физически невозможен. Он работает при одном ключевом условии: параллельные тренды в предпериоде. Без этой проверки оценка ничего не стоит.
Базовый рецепт: данные по двум группам × два периода → регрессия с interaction-термом treated × post → коэффициент β₃ — твой DiD. Если групп много — TWFE. Если контрольной группы нет совсем — синтетический контроль.
Следующий уровень — event study design: когда вместо одной оценки строишь временной ряд эффектов по каждому периоду. Это позволяет увидеть предпериодные тренды и постпериодную динамику в одном графике.