← К портфолио CRM Experiment Lab — сегментная рандомизация, GCG и нетто-эффект preview

Решение за кейсом

Ставка

Ставка: рандомизировать нужно там, где формируется сегмент, а не там, где пользователь впервые видит отправку.

Ситуация
Продуктовые A/B-практики, перенесённые в CRM, ломают рандомизацию: пользователя бакетируют при первом показе, а не при сборке сегмента
Задача
Собрать лабораторию, где каждый этап CRM-эксперимента проверяется на данных с известным ответом и может сломаться наглядно
Действия
Симулятор на 20 000 пользователей и 12 кампаний: сегментная рандомизация, глобальная контрольная группа, балансировщик отправок, эффект нетто отписок
Результат
Сегментная рандомизация держит SRM p=0.266, GCG-аплифт +0.9968 [0.9433, 1.0502], а нетто-эффект +50.5 на 1 000 обработанных — положительный, но guardrail отписок пробит.
Стек
Pythonpandas / NumPySciPystatsmodelsmatplotlibpytestuv
Исходники Смотреть на GitHub → Обновлено: Oct 25, 2026
Содержание
  1. Ситуация
  2. Задача
  3. Действия
  4. Симулятор и замороженная модель данных
  5. Рандомизация: сегмент против показа
  6. Глобальная контрольная группа
  7. Балансировщик отправок
  8. Нетто-эффект с ценой отписок
  9. Окно экспозиции и guardrails
  10. A/B-ридаут
  11. Проверки
  12. Результат
  13. Ограничения
  14. Документация

CRM Experiment Lab — сегментная рандомизация, GCG и нетто-эффект

Ситуация

CRM-команда переносит привычки продуктового A/B-теста в рассылки и ломается уже на рандомизации. Пользователя бакетируют в тот момент, когда он «появился» в день запуска, а не когда собран сегмент кампании. В контрольную группу попадает лишь часть опоздавших, доли плеч расходятся, и SRM-проверка срабатывает на чистом назначении.

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

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

Задача

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

Действия

Симулятор и замороженная модель данных

Один прогон — 20 000 пользователей, 28 дней, 12 кампаний, seed 42. Пять таблиц (users, campaigns, assignments, exposures, outcomes) зафиксированы контрактом до реализации: модули не могут тихо разойтись в схеме.

  • assignments хранит group ∈ {test, control, gcg} и stage ∈ {segment, exposure} — этап рандомизации виден в данных;
  • campaigns несёт true_lift_pp и true_unsub_pp, то есть истинный эффект каждой кампании;
  • outcomes даёт покупки, сумму покупки, отписку и сессии на пользователя-день.

Рандомизация: сегмент против показа

Сравниваются два способа бакетирования при одном и том же назначении. Сегментный держит фиксированную долю и проходит SRM; бакетирование по первому показу отбрасывает опоздавших из контроля и раздувает долю теста.

Этап Доля теста SRM p-value
сегмент (корректно) 0.4958 0.266
первый показ (продуктовый) 0.5629 0

Глобальная контрольная группа

Аплифт считается как «маркетинг минус глобальный контроль», поэтому стабильность контроля — часть оценки. Полное перемешивание поднимает коэффициент вариации тренда до 0.3280, частичная ротация по конфигу — до 0.0090.

Балансировщик отправок

Дневной кап — три отправки на пользователя. Балансировщик распределяет 12 кампаний по пользователю и дню; превышений капа — ноль user-days. Без него частота отправок растёт вместе с числом кампаний, и отписки перестают быть управляемой ценой.

Нетто-эффект с ценой отписок

Кампания окупается, только если инкрементальные покупки перекрывают инкрементальные отписки. Обе стороны нормируются на 1 000 обработанных: +212.8 покупок против −162.3 отписок, нетто +50.5 при отношении 1.311.

Окно экспозиции и guardrails

Метрика Is Exposed = True привязывает оба плеча к первому дню показа. На постоянном эффекте это якорь горизонта, а не снижение ошибки; на транзиентном — отношение эффекта к SE растёт с 7.55 до 12.28. Иерархия метрик — OEC (revenue), драйверы, прокси, отладочные и guardrails — задана явно, чтобы одна метрика не оптимизировалась в одиночку.

A/B-ридаут

Отдельный отчёт фиксирует гипотезу, дизайн и решение до запуска: MDE 0.06765 абсолютных (10% относительных), 751 пользователь на плечо, alpha 0.05, power 80%. SRM на запланированной доле: chi2=1.2395, p=0.2656. Кластер-робастная ошибка по пользователю совпадает с построчной (1.00x), потому что генератор не даёт внутрипользовательской корреляции.

Проверки

Набор pytest из девяти модулей калибрует статистику, балансировщик, GCG, окно экспозиции и позитивность; ruff держит стиль. Пайплайн запускается одной командой и переписывает отчёт и три фигуры.

uv sync --all-groups
uv run pytest
uv run python scripts/run_full_pipeline.py

Результат

Анализ Результат Чтение
Рандомизация (SRM) сегмент p=0.266, показ p=0 фиксированная доля сбалансирована, бакетирование по активности — нет
GCG-аплифт к дню 27 +0.9968 [0.9433, 1.0502] 95% интервал не содержит нуля
Стабильность GCG CV полная 0.3280 против частичной 0.0090 частичная ротация держит базу
Нетто на 1 000 обработанных +50.5 (212.8 − 162.3) положительно, отношение 1.311
Окно экспозиции SE 0.00066 против 0.00124 окно якорит горизонт, а не режет SE
Guardrails нарушен 1 из 2 отписки пробиты, дневной кап держится

Практический вывод — не «кампания работает», а «работает при явном churn-бюджете». Строгий guardrail «отписки не выросли» недостижим ни при каком объёме отправок: тест получает сообщения, контроль — нет. С бюджетом 12% отправка проходит при капе 2, а максимум нетто приходится на кап 2 (+56.8 на 1 000): меньше отправок — меньше отписок, и потеря аплифта меньше выигрыша по churn. Вердикт ридаута — Investigate, а не Ship: основная метрика выигрывает, guardrail падает.

Ограничения

Главная честная оговорка: data/experiment_sample.csv — пример схемы исходов на 300 строк без колонки group, поэтому заголовочные числа из него не воспроизводятся. Все цифры выше получены симулятором (scripts/run_full_pipeline.py), а не этим CSV.

Данные синтетические и из известного генератора: нет интерференции между пользователями, ботов, отложенных конверсий и потерь логов, а внедрённый аплифт постоянен во времени. Сравнения GCG и окна экспозиции — оценки, прогнанные на одном seed, а не исследование мощности. Настоящего сервиса назначения, хранилища метрик и мониторинга здесь нет: это площадка для одного эксперимента, а не платформа экспериментирования.

Документация