new-lvl.pro · Статьи · Кейс · Квазиэксперименты
Кейс // 18 мин чтения

A/B нельзя —
измеряем эффект
ценовой политики
через PSM + DiD

Маркетплейс поднял комиссию в 3 регионах. Рандомизировать продавцов внутри региона нельзя — это бизнес-решение, не эксперимент. PSM выравнивает группы по наблюдаемым признакам, DiD считает причинный эффект. Результат: +6.8% к revenue per seller, CI [3.1; 10.6]. Разбираем пошагово с кодом.

Апрель 2024: бизнес поднимает комиссию в трёх регионах

Маркетплейс решил поднять комиссию продавцам на 1.5 п.п. в трёх крупных регионах: Москва, Санкт-Петербург, Нижний Новгород. Четыре региона остались без изменений: Краснодар, Екатеринбург, Казань, Новосибирск.

Задача аналитика — оценить, как изменение повлияло на поведение продавцов: не ушли ли они с платформы, не упал ли их revenue, не изменилась ли активность. Метрика интереса: revenue per active seller за 30 дней после изменения.

Почему нельзя запустить A/B

Первый вопрос, который обычно возникает: а почему не сделать классический A/B — часть продавцов в тест, часть в контроль? Три причины, по которым это не работает в данном случае.

// 01
Региональная политика
Комиссия — это условие договора с продавцом, оно устанавливается по региону. Нельзя одному продавцу в Москве дать 5%, а соседнему — 6.5%. Юридически и операционно это один документ на весь регион.
// 02
Рыночные эффекты
Если в одном регионе часть продавцов платит больше, а часть нет — они начинают сравнивать условия. Это spillover: контрольная группа заражается «лечением» через социальный сигнал. A/B ломается.
// 03
Бизнес-решение без рандомизации
Регионы выбраны не случайно — это крупнейшие рынки, где комиссионная нагрузка исторически ниже. A/B предполагает случайное назначение. Здесь — систематическое, по бизнес-логике.

Вывод: нужен квазиэксперимент. Мы не контролируем назначение в группы, но можем статистически выровнять группы и оценить причинный эффект. Метод: PSM для выравнивания + DiD для оценки эффекта.

Схема PSM + DiD

// Pipeline: от данных до оценки эффекта
01
Propensity Score
LogReg → P(treated)
02
Matching
Nearest Neighbor, caliper 0.05
03
Balance Check
SMD < 0.1
04
DiD Regression
treated × post, HC3
05
Результат
+6.8%, CI [3.1; 10.6]

Логика двухшагового подхода: PSM решает проблему selection bias — обработанные и контрольные регионы изначально разные (Москва vs Краснодар — не одно и то же). После матчинга группы статистически сопоставимы по наблюдаемым признакам. DiD затем оценивает причинный эффект, используя временную структуру «до/после».

// Почему не просто DiD без PSM
Простой DiD предполагает, что обработанная и контрольная группы были схожи до вмешательства. Если Москва и Краснодар структурно разные (разная средняя выручка продавцов, другой mix категорий) — параллельные тренды нарушены даже до начала теста. PSM выравнивает группы и делает этот assumption более правдоподобным.
Шаг 01
Propensity Score: вероятность попасть в «лечение»
Для каждого продавца считаем вероятность того, что он работает в «обработанном» регионе — на основе его pre-period признаков (январь–март 2024). Признаки: avg_revenue_90d, orders_90d, days_active_90d, avg_check, category_encoded. Обучаем логистическую регрессию, получаем propensity score.
Python Propensity Score через логистическую регрессию
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler
import pandas as pd
import numpy as np

features = ['avg_revenue_90d', 'orders_90d', 'days_active_90d',
            'avg_check', 'category_encoded']

X = pre_df[features]
y = pre_df['treated']   # 1 = обработанный регион, 0 = контрольный

scaler = StandardScaler()
X_scaled = scaler.fit_transform(X)

lr = LogisticRegression(random_state=42, max_iter=500, C=1.0)
lr.fit(X_scaled, y)

# Propensity score = P(treated=1 | X)
pre_df['propensity'] = lr.predict_proba(X_scaled)[:, 1]

print(f"Treated mean propensity: {pre_df[pre_df['treated']==1]['propensity'].mean():.3f}")
print(f"Control mean propensity: {pre_df[pre_df['treated']==0]['propensity'].mean():.3f}")
# Expected: treated ~0.72, control ~0.31 — до матчинга группы сильно различаются
Интерпретация: продавцы с высоким propensity score — «типично московские» (высокая выручка, много заказов). Контрольная группа в среднем имеет низкий score. Матчинг подберёт продавцов из Краснодара/Екатеринбурга, максимально похожих на московских по pre-period поведению.
Шаг 02
Nearest Neighbor Matching с caliper 0.05
Для каждого продавца из обработанного региона находим ближайшего «двойника» в контрольной группе по propensity score. Caliper 0.05 — максимально допустимая разница в propensity. Если в caliper никого нет — продавец исключается из анализа (unmatched).
Python Nearest Neighbor с caliper, without replacement
treated = pre_df[pre_df['treated'] == 1].copy().reset_index(drop=True)
control = pre_df[pre_df['treated'] == 0].copy().reset_index(drop=True)

caliper = 0.05
matched_pairs = []
used_control_idx = set()

for _, t_row in treated.iterrows():
    # Расстояние по propensity до каждого контрольного
    dists = np.abs(control['propensity'] - t_row['propensity'])
    # Исключаем уже использованных (without replacement)
    dists[list(used_control_idx)] = np.inf
    best_idx = dists.idxmin()

    if dists[best_idx] <= caliper:
        matched_pairs.append({
            'treated_id': t_row['seller_id'],
            'control_id': control.loc[best_idx, 'seller_id'],
            'ps_diff':    dists[best_idx]
        })
        used_control_idx.add(best_idx)

matched_df_info = pd.DataFrame(matched_pairs)
print(f"Matched pairs: {len(matched_df_info)}")
print(f"Unmatched treated: {len(treated) - len(matched_df_info)}")
# Пример вывода: Matched pairs: 1847, Unmatched treated: 203
// Важно: without replacement
Матчинг без возврата — один контрольный продавец может быть «двойником» только одного обработанного. Это снижает размер выборки, но даёт более честное сравнение. С возвратом те же несколько «идеальных» контрольных продавцов повторяются многократно и занижают дисперсию.
Шаг 03
Balance Check: SMD до и после матчинга
Стандартизованное среднее отличие (SMD, Standardized Mean Difference) показывает, насколько группы отличаются по каждому признаку. Порог: SMD < 0.1 считается хорошим балансом. Если после матчинга SMD по какому-то признаку остаётся высоким — возможно, нужно добавить признак в logistic regression или изменить caliper.
Python SMD до и после матчинга
def smd(treated_col, control_col):
    mean_diff = treated_col.mean() - control_col.mean()
    pooled_std = np.sqrt((treated_col.std()**2 + control_col.std()**2) / 2)
    return abs(mean_diff / pooled_std)

# Полный датасет (до матчинга)
treated_full = pre_df[pre_df['treated']==1]
control_full  = pre_df[pre_df['treated']==0]

# После матчинга — только matched продавцы
treated_matched = matched_df[matched_df['treated']==1]
control_matched  = matched_df[matched_df['treated']==0]

print(f"{'Признак':<25} {'SMD до':>10} {'SMD после':>10} {'OK?':>6}")
for feat in features:
    before = smd(treated_full[feat], control_full[feat])
    after  = smd(treated_matched[feat], control_matched[feat])
    ok = '✓' if after < 0.1 else '✗'
    print(f"{feat:<25} {before:>10.3f} {after:>10.3f} {ok:>6}")

Типичный вывод после матчинга в нашем кейсе:

Признак SMD до матчинга SMD после матчинга Баланс
avg_revenue_90d 0.412 0.048 ✓ OK
orders_90d 0.387 0.062 ✓ OK
days_active_90d 0.231 0.071 ✓ OK
avg_check 0.358 0.055 ✓ OK
category_encoded 0.194 0.083 ✓ OK
// Что означает таблица
До матчинга группы сильно отличались — SMD 0.4+ по выручке значит, что московские продавцы в среднем продавали намного больше краснодарских. После матчинга разрыв сократился до 0.05–0.08 — группы теперь сопоставимы. Это «паспорт качества» матчинга: без него DiD-результатам доверять нельзя.
Шаг 04
DiD: регрессия с гетероскедастичными ошибками (HC3)
После матчинга строим панельный датасет: для каждой matched пары — две наблюдения (pre-period: январь–март, post-period: апрель–май). Переменные: treated (1 = обработанный регион), post (1 = после изменения), did = treated × post — это и есть интересующий нас коэффициент. HC3 — робастные ошибки, так как остатки могут быть гетероскедастичны по регионам.
Python DiD OLS с робастными ошибками HC3
import statsmodels.formula.api as smf

# did = treated * post — interaction term, это и есть DiD-оценка
df_did['did'] = df_did['treated'] * df_did['post']

model = smf.ols(
    'revenue_per_seller ~ treated + post + did'
    '+ avg_revenue_90d + orders_90d',  # контрольные переменные pre-period
    data=df_did
).fit(cov_type='HC3')  # робастные ошибки — гетероскедастичность по регионам

# Основные результаты
print(model.summary().tables[1])

# Ключевые выводы из output:
# Intercept:  ...  (базовый уровень control pre-period)
# treated:    ...  (фиксированный эффект региона)
# post:       ...  (общий временной тренд)
# did:        +6.8%,  p=0.006,  95% CI [3.1; 10.6]  ← ЭТО НАШ ОТВЕТ
Python Тест параллельных трендов (pre-period)
# Проверяем, что до вмешательства тренды в группах шли параллельно
# Берём только pre-period и разбиваем на два под-периода: янв-фев vs март

pre_only = df_did[df_did['post'] == 0].copy()
pre_only['late_pre'] = (pre_only['month'] == 'march').astype(int)
pre_only['placebo_did'] = pre_only['treated'] * pre_only['late_pre']

placebo = smf.ols(
    'revenue_per_seller ~ treated + late_pre + placebo_did',
    data=pre_only
).fit(cov_type='HC3')

print(f"Placebo DiD (должен быть незначим): {placebo.params['placebo_did']:.4f}")
print(f"p-value: {placebo.pvalues['placebo_did']:.3f}")
# Результат: placebo_did = 0.0031, p = 0.71 — незначимо ✓
# Параллельные тренды в pre-period подтверждены

Эффект новой ценовой политики

// DiD-оценка: revenue per active seller, апрель–май 2024
+6.8%
95% CI [3.1%; 10.6%] · p = 0.006 · n = 1847 matched pairs
Контроль: revenue per seller вырос на ~2.1% (сезонность).
Обработанная группа: +8.9% за тот же период.
Разность разностей: +6.8 п.п. — это и есть эффект повышения комиссии.

Интерпретация: повышение комиссии не привело к оттоку продавцов или сокращению их выручки на платформе. Напротив, среди matched продавцов (контролируя на pre-period паттерн активности) мы видим положительный эффект. Вероятная причина: в Москве и СПб спрос достаточно высок, чтобы продавцы компенсировали комиссию ростом оборота — или не меняли поведение вовсе.

Доверительный интервал [3.1%; 10.6%] — достаточно узкий для практического решения. Нижняя граница +3.1% уже покрывает рост комиссионной нагрузки. Решение: изменение приносит доход платформе без ущерба для продавцов — можно масштабировать.

Где метод может подвести

PSM + DiD — мощный инструмент для наблюдательных данных, но не серебряная пуля. Вот что может сломаться в этом конкретном кейсе.

// 01
Параллельные тренды — assumption
Тест плацебо показал незначимость, но это не гарантия. Если в апреле в Москве был дополнительный внешний шок (рекламная кампания, конкурент ушёл), DiD припишет его к эффекту комиссии. Обязательно проверяй новостной фон.
// 02
Spillover между регионами
Часть продавцов работает в нескольких регионах одновременно. Если московский продавец начал активнее продавать в Краснодаре (контрольном регионе) — контрольная группа «заражается» эффектом. Идеально — исключить таких продавцов из анализа.
// 03
Composition bias (отток)
Если часть продавцов ушла из обработанных регионов из-за комиссии — в выборке остались «лучшие», survivorship bias завышает эффект. Проверяй: изменилось ли число активных продавцов в регионах после изменения?
// 04
Unobserved confounders
PSM выравнивает только по наблюдаемым признакам. Если есть важная ненаблюдаемая переменная (например, «намерение масштабироваться» у продавца), матчинг её не поймает. Это фундаментальное ограничение любого PSM.

PSM + DiD, просто DiD или Synthetic Control?

PSM + DiD — не всегда лучший выбор. Вот навигация по трём ближайшим методам:

Ситуация Метод Когда предпочесть
Региональный rollout, группы изначально похожи Просто DiD Группы сравнимы по pre-period метрикам без матчинга. SMD уже низкий. PSM добавляет сложность без выигрыша.
Региональный rollout, группы сильно разные PSM + DiD Нужно выровнять selection bias перед DiD. Наш кейс — именно здесь: Москва vs Краснодар структурно разные.
Один регион / одна единица в обработке Synthetic Control Нет подходящей контрольной группы. Синтетический контроль строит «виртуальный двойник» из взвешенной комбинации контрольных единиц. Подробнее — в статье про DiD.
Можно рандомизировать A/B тест Всегда предпочтительнее квазиэксперимента, если рандомизация возможна. Не усложняй без нужды.
DiD без PSM: разбор метода с нуля
Если PSM + DiD кажется сложным — начни с простого DiD. Отдельная статья: суть за 5 минут, тест параллельных трендов, Python-пример на двух регионах и Synthetic Control в конце.
Читать про DiD

Частые вопросы

В чём разница между PSM и просто DiD?
DiD предполагает, что обработанная и контрольная группы были изначально сопоставимы (параллельные тренды). PSM — это предобработка: мы выравниваем группы по наблюдаемым признакам до DiD, чтобы сделать assumption параллельных трендов более реалистичным. PSM + DiD = «сначала выравниваем, потом считаем эффект».
Как выбрать caliper для nearest neighbor matching?
Стандартная рекомендация — 0.2 × стандартное отклонение propensity score в полной выборке (правило Rosenbaum & Rubin). На практике: начни с 0.05–0.1, смотри на процент unmatched treated. Если потери > 20% — увеличивай caliper. Если SMD после матчинга всё ещё > 0.1 — уменьшай. Это компромисс между bias (широкий caliper) и потерей выборки (узкий).
Почему HC3, а не обычные OLS-ошибки?
HC3 (heteroskedasticity-consistent) — робастные ошибки, которые работают, когда дисперсия остатков неодинакова по наблюдениям. В панельных данных с регионами это почти всегда так: Москва и Краснодар имеют разный разброс revenue. Обычные OLS-ошибки занижают стандартные ошибки → завышают значимость. HC3 даёт более консервативные, но честные CI.
Что делать, если тест параллельных трендов не проходит?
Если placebo DiD в pre-period значим — параллельные тренды нарушены и DiD-оценку доверять нельзя. Варианты: (1) добавить дополнительные ковариаты в PSM и/или DiD-регрессию; (2) рассмотреть более длинный pre-period для проверки стабильности; (3) переключиться на Synthetic Control, который менее требователен к параллельным трендам; (4) честно написать, что метод неприменим в данном случае.
Можно ли использовать PSM + DiD, если «лечение» шло поэтапно по разным датам?
Да, но нужен staggered DiD — расширение, где разные единицы получают вмешательство в разные периоды. Стандартный DiD (treated × post) в этом случае даёт смещённую оценку. Используй did-пакет в R или pyfixest в Python для корректной реализации через Callaway & Sant'Anna (2021) или Borusyak et al. (2021).

Связанные материалы

Главное про PSM + DiD

Когда A/B невозможен — квазиэксперименты дают честную оценку причинного эффекта. PSM выравнивает selection bias, DiD использует временную структуру для изоляции эффекта. Вместе они закрывают большую часть угроз внутренней валидности наблюдательных исследований.

Три вещи, которые делают результат убедительным: (1) хороший balance check — SMD < 0.1 по всем ковариатам; (2) пройденный тест параллельных трендов в pre-period; (3) честное описание лимитов — spillover, composition bias, unobserved confounders. Без этого даже красивый CI [3.1; 10.6] — просто число.

Стек для воспроизведения: Python 3.10+, scikit-learn, statsmodels, pandas, numpy. Данные для кейса — синтетические, паттерны основаны на реальных маркетплейс-распределениях.

АТ
Андрей Тарасенко
// Продуктовый аналитик · Авито · Ментор

Квазиэксперименты — моя любимая тема, потому что они требуют и статистического, и продуктового мышления одновременно. Надо понять, почему рандомизация невозможна, выбрать подходящий метод и честно объяснить его лимиты. Это не «запустил тест и посмотрел p-value» — здесь думать интереснее.

Написать в Telegram
// ХОЧЕШЬ ЕЩЁ КЕЙСОВ?

Практикуй кейсы на тренажёре

В SQL-тренажёре — задачи по мотивам реальных собесов: воронки, когорты, retention, revenue. Пишешь запрос — получаешь проверку.

▶ Открыть SQL-тренажёр ★ Premium · 80 задач
Все материалы: База знаний · Telegram