Апрель 2024: бизнес поднимает комиссию в трёх регионах
Маркетплейс решил поднять комиссию продавцам на 1.5 п.п. в трёх крупных регионах: Москва, Санкт-Петербург, Нижний Новгород. Четыре региона остались без изменений: Краснодар, Екатеринбург, Казань, Новосибирск.
Задача аналитика — оценить, как изменение повлияло на поведение продавцов: не ушли ли они с платформы, не упал ли их revenue, не изменилась ли активность. Метрика интереса: revenue per active seller за 30 дней после изменения.
Почему нельзя запустить A/B
Первый вопрос, который обычно возникает: а почему не сделать классический A/B — часть продавцов в тест, часть в контроль? Три причины, по которым это не работает в данном случае.
Вывод: нужен квазиэксперимент. Мы не контролируем назначение в группы, но можем статистически выровнять группы и оценить причинный эффект. Метод: PSM для выравнивания + DiD для оценки эффекта.
Схема PSM + DiD
Логика двухшагового подхода: PSM решает проблему selection bias — обработанные и контрольные регионы изначально разные (Москва vs Краснодар — не одно и то же). После матчинга группы статистически сопоставимы по наблюдаемым признакам. DiD затем оценивает причинный эффект, используя временную структуру «до/после».
avg_revenue_90d, orders_90d, days_active_90d, avg_check, category_encoded. Обучаем логистическую регрессию, получаем 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 — до матчинга группы сильно различаются
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
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 |
treated (1 = обработанный регион), post (1 = после изменения), did = treated × post — это и есть интересующий нас коэффициент. 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] ← ЭТО НАШ ОТВЕТ
# Проверяем, что до вмешательства тренды в группах шли параллельно # Берём только 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 подтверждены
Эффект новой ценовой политики
Обработанная группа: +8.9% за тот же период.
Разность разностей: +6.8 п.п. — это и есть эффект повышения комиссии.
Интерпретация: повышение комиссии не привело к оттоку продавцов или сокращению их выручки на платформе. Напротив, среди matched продавцов (контролируя на pre-period паттерн активности) мы видим положительный эффект. Вероятная причина: в Москве и СПб спрос достаточно высок, чтобы продавцы компенсировали комиссию ростом оборота — или не меняли поведение вовсе.
Доверительный интервал [3.1%; 10.6%] — достаточно узкий для практического решения. Нижняя граница +3.1% уже покрывает рост комиссионной нагрузки. Решение: изменение приносит доход платформе без ущерба для продавцов — можно масштабировать.
Где метод может подвести
PSM + DiD — мощный инструмент для наблюдательных данных, но не серебряная пуля. Вот что может сломаться в этом конкретном кейсе.
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 тест | Всегда предпочтительнее квазиэксперимента, если рандомизация возможна. Не усложняй без нужды. |
Частые вопросы
В чём разница между PSM и просто DiD?
Как выбрать caliper для nearest neighbor matching?
Почему HC3, а не обычные OLS-ошибки?
HC3 (heteroskedasticity-consistent) — робастные ошибки, которые работают, когда дисперсия остатков неодинакова по наблюдениям. В панельных данных с регионами это почти всегда так: Москва и Краснодар имеют разный разброс revenue. Обычные OLS-ошибки занижают стандартные ошибки → завышают значимость. HC3 даёт более консервативные, но честные CI.Что делать, если тест параллельных трендов не проходит?
Можно ли использовать PSM + DiD, если «лечение» шло поэтапно по разным датам?
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. Данные для кейса — синтетические, паттерны основаны на реальных маркетплейс-распределениях.