Условие
Маркетинг рассылает промокод «−300 ₽ при заказе от 1500 ₽». Половина заказов с промокодом приходит от пользователей, которые в любом случае заказали бы (взяли купон «попутно»). Как оценить инкрементальный эффект промокода (выручка / прибыль)?
Решение
Простое сравнение не работает
«Заказы с промокодом vs без» сильно смещено: пользователи с промокодом — это активные / лояльные → systematic bias.
Дизайн 1: A/B holdout
Случайно разделить аудиторию рассылки на 75% target / 25% holdout (не получают промокод). Сравнить GMV / прибыль через 2 недели.
incremental_GMV = GMV_target - GMV_holdout · (n_target / n_holdout)
incremental_profit = GMV_inc - promo_cost - cost_of_extra_orders
Это самый чистый дизайн, но дорогой (теряем потенциальную выручку с holdout).
Дизайн 2: stratified holdout
Стратификация по cohort × city × past 30d activity → меньше variance, тот же дизайн.
Дизайн 3: PSM (если A/B сделать нельзя)
Подобрать «парную» неэкспонированную группу по характеристикам:
- past 30d orders, AOV, last order date, city, segment.
from sklearn.linear_model import LogisticRegression
# 1) Модель propensity: получил ли пользователь промокод
X = features
y = received_promo # 0/1
ps = LogisticRegression().fit(X, y).predict_proba(X)[:, 1]
# 2) Nearest neighbour matching
from sklearn.neighbors import NearestNeighbors
nn = NearestNeighbors(n_neighbors=1).fit(ps[y==0].reshape(-1, 1))
_, idx = nn.kneighbors(ps[y==1].reshape(-1, 1))
paired_control = df.loc[y==0].iloc[idx.flatten()]
paired_treat = df.loc[y==1]
incremental_gmv = paired_treat.gmv.mean() - paired_control.gmv.mean()Дизайн 4: Difference-in-Differences
Если промокод выкатили в одном городе, в соседнем — нет:
DiD = (GMV_city1_after - GMV_city1_before) - (GMV_city2_after - GMV_city2_before)
Предположение: parallel trends — до промокода метрики двух городов росли одинаково.
Дизайн 5: Uplift модель
Для каждого пользователя предсказать «инкрементальный заказ» с промокодом:
Uplift(user) = P(order | treatment=1) - P(order | treatment=0)
Обучается на A/B-данных (target + holdout). Дальше — таргетировать только пользователей с положительным uplift.
# Two-Model (T-learner)
from xgboost import XGBClassifier
m1 = XGBClassifier().fit(X[treatment==1], y[treatment==1])
m0 = XGBClassifier().fit(X[treatment==0], y[treatment==0])
uplift = m1.predict_proba(X)[:,1] - m0.predict_proba(X)[:,1]Метрики
- Incremental GMV = (GMV target − GMV holdout × n_t/n_h)
- Incremental orders — то же по orders.
- iROI = incremental revenue / promo cost.
- iROAS = incremental GMV / promo spend.
- Cannibalization rate = доля «free-rider» заказов в total promo orders.
Cannibalization напрямую
Зная inc orders и total promo orders:
cannibalization_rate = 1 - (incremental_orders / total_promo_orders)
Если 50% заказов с промокодом «freerider» → каннибализация 50%.
Подводные камни
- Не делать holdout = вечная неопределённость с эффектом. Каждый промо-кэйс — без honest baseline.
- Холдаут < 10% — низкая power; нужна неделя+ при большом N.
- PSM ловит только наблюдаемые confounders. Ненаблюдаемые (мотивация заказать сегодня) — нет.
- DiD ломается при шоках, специфичных для одной группы (например, ливень в city1).
- Uplift на маленькой A/B: ошибка модели часто больше эффекта; нужно ≥ 100K в каждой ветке.
- Promo cost ≠ GMV − 300 ₽: есть ещё subsidies, влияние на retention.
- Promo на новых vs существующих: ROI разный, агрегировать опасно.
- Sequential promos: один и тот же пользователь получает 5 промо в месяц → assignment не iid, классический A/B-анализ не работает.
Эталонный ответ
Чистый инкремент = A/B holdout (target vs control с одинаковыми правилами таргетинга, кроме промокода). Без holdout — propensity score matching или Difference-in-Differences (если есть гео-вариация).
Метрики: incremental GMV/orders, iROI = inc_revenue / promo_cost, cannibalization rate = 1 − inc_orders / total_promo_orders. Для долгосрочного отбора пользователей — uplift-модель (T-learner или Causal Forest).
Главное: «GMV с промокодом» — vanity-метрика; бизнесу нужен инкрементальный эффект, а он измеряется только через holdout / quasi-эксперимент.