Каузальный вывод для аналитиков: как оценить эффект без A/B теста с помощью DiD и синтетического контроля

Каузальный вывод для аналитиков: как оценить эффект без A/B теста с помощью DiD и синтетического контроля

Представьте ситуацию: продуктовая команда выкатила новую механику ценообразования сразу на всех пользователей, потому что частичный запуск был технически невозможен. Через месяц выручка выросла на 12%. Вопрос от руководителя закономерный: это эффект новой механики или сезонность, приток новых клиентов, курс валюты? Ответить без контрольной группы, которую обычно дает A/B тест, приходится другими способами.

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

В статье разберем три рабочих метода, которые закрывают именно эти ситуации: разность разностей (difference-in-differences), синтетический контроль и propensity score matching. Для каждого - логика, код на Python и честные ограничения. Это не теория ради теории, а рабочий набор приемов для случаев, когда классический эксперимент недоступен, а решение принимать нужно уже сейчас.

Коротко:

  • Когда рандомизация недоступна, ищите готовую квазиэкспериментальную структуру: похожий регион, похожую когорту, четкий период до и после события.
  • Разность разностей подходит, если есть явная затронутая группа и контрольная, похожая по динамике до изменения.
  • Синтетический контроль нужен, когда естественной контрольной группы нет, но есть много донорских единиц - регионов, магазинов, филиалов.
  • Propensity score matching выравнивает группы по наблюдаемым признакам, но не спасает от скрытых различий между теми, кто попал в воздействие, и теми, кто нет.
  • Главная ошибка новичка - принять параллельность трендов на веру, без графика за период до изменения.
  • Результат считается надежным, если он проходит плацебо-проверку и не разваливается при смене набора контролей.

Почему рандомизация иногда просто невозможна

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

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

Что позволяет доказать квазиэксперимент, а что нет

Причинная логика строится вокруг одного вопроса: что произошло бы, если бы вмешательства не было. Этот гипотетический сценарий называют контрфактическим, и именно его отсутствие мешает простому сравнению «было-стало». Выручка выросла после запуска фичи не значит, что выросла из-за фичи - могла подрасти сезонная волна, могли одновременно снизиться цены конкурентов.

Здесь и начинается каузальный вывод для аналитика в практическом смысле: вместо прямого сравнения до и после нужно построить правдоподобную оценку того самого контрфактического сценария. DiD, синтетический контроль и PSM - три разных способа его сконструировать, каждый со своим набором допущений. Ни один из них не дает стопроцентной гарантии, как рандомизация, но при аккуратном применении дает результат, на который можно опереться в решении.

Difference-in-differences: сравниваем не уровни, а изменения

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

Формула выглядит так: эффект равен (изменение в затронутой группе) минус (изменение в контрольной группе). Ключевое допущение - параллельность трендов: если бы вмешательства не было, обе группы менялись бы одинаковым темпом. Это допущение нельзя доказать напрямую, но можно частично проверить, построив график метрики за несколько периодов до события и убедившись, что линии шли примерно параллельно.

Пример: сеть магазинов подняла цену в одном городе, оставив соседний город без изменений. Динамика продаж в обоих городах похожа по сезонности, значит соседний город можно взять как контроль.

import pandas as pd
import statsmodels.formula.api as smf

df['post'] = (df['period'] == 'after').astype(int)
df['treated'] = (df['city'] == 'test_city').astype(int)

model = smf.ols('sales ~ treated * post', data=df).fit()
print(model.summary())
# коэффициент при treated:post - это и есть DiD-оценка эффекта

Коэффициент при взаимодействии treated:post в выводе модели - это и есть искомая величина. Если он статистически значим и по знаку совпадает с ожиданием, есть основание говорить об эффекте изменения цены, а не о случайном совпадении.

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

DiD работает, если есть подходящая контрольная единица. Но что делать, если решение затронуло единственный крупный объект - целую страну, единственный завод, весь федеральный рынок? Здесь на помощь приходит синтетический контроль: вместо одной похожей единицы конструируется искусственная комбинация из нескольких донорских единиц, которая до события повторяла динамику затронутой.

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

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

import numpy as np
from scipy.optimize import minimize

def loss(weights, donors_pre, treated_pre):
    synthetic = donors_pre @ weights
    return np.sum((treated_pre - synthetic) ** 2)

n_donors = donors_pre.shape[1]
init = np.repeat(1 / n_donors, n_donors)
bounds = [(0, 1)] * n_donors
constraints = {'type': 'eq', 'fun': lambda w: np.sum(w) - 1}

result = minimize(loss, init, args=(donors_pre, treated_pre),
                   bounds=bounds, constraints=constraints)
weights = result.x

synthetic_post = donors_post @ weights
effect = treated_post - synthetic_post

Для продакшен-задач удобнее готовые библиотеки, например пакет pysyncon для Python, который реализует классический алгоритм Абади и коллег с автоматическим подбором весов и встроенными плацебо-тестами. Для учебной или разовой оценки хватает ручной оптимизации, как в примере выше.

Propensity score matching: выравниваем группы по наблюдаемым признакам

DiD и синтетический контроль работают на уровне агрегированных единиц - городов, стран, филиалов. Но часто вопрос стоит на уровне пользователей: часть аудитории сама включила новую функцию в настройках, а часть не заметила или не захотела. Это не случайный выбор, а самоотбор, и прямое сравнение таких групп даст смещенную оценку.

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

from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import NearestNeighbors

covariates = ['activity_score', 'tenure_days', 'is_paying']

ps_model = LogisticRegression()
ps_model.fit(df[covariates], df['treated'])
df['propensity'] = ps_model.predict_proba(df[covariates])[:, 1]

treated = df[df['treated'] == 1]
control = df[df['treated'] == 0]

nn = NearestNeighbors(n_neighbors=1).fit(control[['propensity']])
distances, indices = nn.kneighbors(treated[['propensity']])
matched_control = control.iloc[indices.flatten()]

att = treated['outcome'].mean() - matched_control['outcome'].mean()

После матчинга обязательно нужно проверить баланс: сравнить средние значения ковариат в подобранных парах до и после матчинга. Если после подбора группы все еще заметно различаются по ключевым признакам, оценке доверять нельзя, нужно менять модель или добавлять переменные.

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

Как выбрать метод под свою ситуацию

Ситуация с даннымиКакой метод подходитЧто проверить перед тем, как доверять результату
Есть четкая затронутая группа и похожая контрольная с данными до и после событияDifference-in-differencesПараллельность динамики в период до изменения
Затронута одна крупная единица без естественного аналога, но есть 8-10+ похожих доноровСинтетический контрольТочность подгонки синтетической копии на предпериоде
Воздействие получили не случайные пользователи, а те, кто сам себя отобрал по наблюдаемым признакамPropensity score matchingБаланс ковариат в подобранных парах после матчинга

Типичные ошибки при оценке эффекта без рандомизации

Ошибки в квазиэкспериментах повторяются из компании в компанию, и почти все они связаны с тем, что аналитик поверил допущению, не проверив его на данных.

  • Считать тренды параллельными без графика: коэффициент DiD посчитан, но никто не построил линии метрик за 6-8 периодов до события, чтобы увидеть расхождение заранее.
  • Брать в донорский пул для синтетического контроля единицы, которые сами затронуты похожим событием в это же время, из-за чего синтетическая копия искажена.
  • Матчить пользователей по двум-трем удобным полям вместо всех значимых признаков, из-за чего после подбора пары остаются системно разными по важной переменной.
  • Игнорировать эффект предвосхищения: пользователи меняют поведение еще до официального запуска, если знали о нем заранее, и период «до» оказывается уже загрязнен.
  • Путать статистическую значимость коэффициента с доказанной причинностью: значимый коэффициент при нарушенном допущении параллельности все равно даст смещенную оценку.
  • Применять PSM в ситуации с сильным неучтенным самоотбором, где выравнивание по видимым признакам создает иллюзию контроля там, где ее нет.

Как проверить, что результат не случайность

Оценка без рандомизации требует дополнительных проверок, которые в классическом A/B тесте не нужны, потому что там за честность сравнения отвечает случайное распределение.

Плацебо-тест - самый универсальный прием. Для DiD и синтетического контроля можно применить ту же процедуру к единице, которую точно не затронуло событие, и посмотреть, не покажет ли метод эффект там, где его быть не должно. Если плацебо-эффект окажется таким же большим, как основной результат, доверие к оценке падает.

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

Для синтетического контроля полезен leave-one-out: последовательно убирать по одному донору из пула и смотреть, насколько сильно меняется итоговая оценка эффекта. Если результат держится на одном-двух донорах, он хрупкий.

Для PSM применяют анализ чувствительности к неучтенным факторам - границы Розенбаума показывают, насколько сильным должен быть скрытый confounder, чтобы обнулить найденный эффект. Если для этого нужен крайне маловероятный фактор, оценка более надежна.

Когда даже эти методы не спасут

Квазиэксперимент не универсальное решение, и честная статья должна сказать об этом прямо. Синтетический контроль плохо работает, если донорский пул меньше 8-10 единиц - веса начинают подгонять шум, а не структуру. DiD ломается, если ровно в момент вмешательства случилось что-то еще, что затронуло только тестовую группу, например локальный сбой инфраструктуры или региональная новость.

PSM никогда не устранит смещение от факторов, которые не попали в данные. Если пользователи сами выбирали функцию из-за скрытой мотивации, которую компания не измеряет, никакой матчинг это не исправит - здесь помогут только методы вроде инструментальных переменных или регрессионного разрыва, а это уже отдельная тема.

Короткий период данных до события тоже подрывает все три метода: и параллельность тренда, и точность синтетической копии, и стабильность оценки пропенсити сложно проверить на трех точках наблюдения.

Чеклист перед тем, как отдавать оценку в отчет

  • Есть ли явное событие с четкой датой начала, а не размытый период раскатки
  • Построен график метрики за 6+ периодов до события для затронутой и контрольной группы
  • Проверена параллельность тренда на предпериоде, а не принята на глаз
  • Для синтетического контроля собран пул минимум из 8-10 донорских единиц
  • Точность подгонки синтетической копии на предпериоде приемлема, ошибка невелика
  • Для PSM модель пропенсити включает все значимые наблюдаемые ковариаты, а не удобные три поля
  • Баланс ковариат после матчинга проверен и приемлем
  • Проведен плацебо-тест на единице, которую точно не затронуло событие
  • Для синтетического контроля сделан leave-one-out по донорам
  • Результат обсужден с командой, которая знает контекст рынка, а не только с цифрами

Стандартные ошибки в DiD: почему кластеризация меняет выводы

Обычная OLS-регрессия из блока про разность разностей по умолчанию считает каждое наблюдение независимым от других. На практике это предположение почти всегда нарушено: продажи одного города коррелируют между собой день ото дня сильнее, чем продажи разных городов. Если игнорировать эту связь, стандартные ошибки коэффициента при взаимодействии оказываются заниженными, а p-value выглядит более убедительным, чем есть на самом деле.

Решение - кластеризовать стандартные ошибки на уровне той единицы, где происходит вмешательство: города, филиала, региона. В statsmodels это делается одним параметром при вызове fit.

model = smf.ols('sales ~ treated * post', data=df).fit(
    cov_type='cluster',
    cov_kwds={'groups': df['city']}
)
print(model.summary())

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

Отдельная ловушка - слишком мало кластеров. Если контрольных единиц меньше десяти, кластерные стандартные ошибки сами становятся неточными, и обычные асимптотические формулы работают плохо. В такой ситуации помогает wild cluster bootstrap - процедура пересемплирования остатков внутри кластеров, которая честно оценивает разброс даже на маленьком числе групп.

Event study: смотрим на динамику по периодам, а не на одну цифру

Классическая разность разностей сжимает весь результат в один коэффициент: разница между «до» и «после» для затронутой группы относительно контрольной. Это удобно для отчета, но скрывает важную деталь - как именно менялась разница во времени. Событийное исследование, или event study, решает эту проблему: вместо одной переменной post в модель добавляют отдельный индикатор для каждого периода относительно момента события.

import pandas as pd

df['period_num'] = df['period'] - df['event_period']
dummies = pd.get_dummies(df['period_num'], prefix='rel')
dummies = dummies.drop(columns=['rel_-1'])  # базовый период

X = pd.concat([df[['treated']], dummies.mul(df['treated'], axis=0)], axis=1)
model = smf.ols('sales ~ treated + ' + ' + '.join(dummies.columns) +
                ' + treated:' + ' + treated:'.join(dummies.columns),
                data=pd.concat([df, dummies], axis=1)).fit()

Результат такой модели - набор коэффициентов, по одному на каждый период до и после события. Если коэффициенты за периоды до события близки к нулю и статистически незначимы, это прямое подтверждение параллельности тренда, а не догадка по графику на глаз. Коэффициенты после события показывают, нарастает ли разница со временем, выходит на плато или затухает - и это важная информация для бизнеса, которая пропадает в обычной DiD-оценке.

Период относительно событияОценка разницыЧто это говорит
-3, -2, -1около нуля, незначимотренды до события шли параллельно, допущение выполняется
0, +1небольшая, растущаярезультат только формируется, пользователи адаптируются
+2, +3, +4стабильная, значимаяустойчивый результат, на него можно опираться в прогнозе

Если коэффициенты после периода +4 начинают снижаться, это повод проверить, не появился ли конкурирующий фактор, который постепенно съедает первоначальную разницу.

Как объяснить результат команде без потери доверия

Даже безупречно посчитанная оценка бесполезна, если руководитель услышит только «мы применили синтетический контроль» и потеряет нить рассуждения. Каузальный анализ без рандомизации требует больше объяснений, чем A/B тест, потому что решение опирается на допущения, а не на случайное распределение.

Рабочая формула для отчета:

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

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

FAQ

Что такое каузальный вывод для аналитика простыми словами

Это способ ответить на вопрос «действие X вызвало результат Y, а не просто совпало с ним по времени». В отличие от обычного сравнения показателей, здесь строится оценка того, что произошло бы без действия, и с ней сравнивается фактический результат.

Чем DiD отличается от синтетического контроля

DiD берет одну реальную контрольную группу и сравнивает изменения. Синтетический контроль конструирует контрольную группу искусственно из нескольких доноров, когда одной подходящей единиц