Marketing Mix Modeling (MMM) вернулся в конце 2020-х с крахом cookie-based атрибуции. Но переход от академических статей к production-среде — это совсем другой уровень. Robyn, открытый инструмент Meta с 2021 года, привязывает этот переход к инженерной дисциплине: предлагает конкретные инструменты для перемещения статистических концепций — кривых насыщения, затухания adstock и валидации holdout — из R-скриптов в операционный pipeline. В этой статье показываем, как настроить три механизма, лежащих в основе Robyn — затухание эффекта рекламы во времени, выход на насыщение отношения расходов к доходу и процесс holdout, тестирующий предсказательную силу модели — в production setup.

Adstock Decay: Распределение рекламного эффекта во времени

ТВ-спот не создаёт продажи в день трансляции — эффект длится неделю. Поисковая реклама может привести к конверсии в момент клика, но вспомнивание бренда триггирует покупку через три дня. Adstock — это математическая конструкция, моделирующая эту временную задержку. В Robyn есть два типа adstock: geometric и Weibull. Geometric — это простой экспоненциальный спад; эффект каждого дня умножается на параметр theta. Weibull более гибкий — позволяет независимо контролировать кривую подъёма и спада эффекта.

В практической настройке параметры adstock устанавливаются по типу канала. Paid search обычно theta=0.3 (быстрый спад), ТВ theta=0.7 (длинный хвост), display около theta=0.5. Эти значения не произвольны — они находятся гиперпараметрическим поиском на исторических holdout-наборах. В функции robyn_inputs() Robyn задаёте аргумент adstock по каналам:

InputCollect <- robyn_inputs(
  dt_input = dt_simulated_weekly,
  adstock = "geometric",
  adstock_params = list(
    tv_s = c(0.3, 0.8),
    search_clicks_p = c(0.0, 0.3),
    facebook_i = c(0.0, 0.5)
  )
)

Здесь c(min, max) определяет диапазон; алгоритм оптимизации Nevergrad ищет оптимальное значение theta в этом диапазоне. Если вместо geometric используете Weibull, добавляются параметры shape и scale. Преимущество Weibull в том, что он лучше подходит для каналов типа display с "поздним пиком" — эффект низкий в первые два дня, достигает пика на 3–5-й день.

Неправильная настройка adstock приводит к неверному распределению вклада каналов. Например, если моделировать ТВ с geometric theta=0.1, эффект приписывается только дню трансляции, упускается органический трафик на протяжении недели. Наоборот, присвоение paid search'у theta=0.9 означает, что сегодняшняя продажа приписывается клику неделю назад — нелогично. Поэтому настройка adstock должна соответствовать характеру канала и ограничиваться domain knowledge.

Кривая насыщения: Выход отношения расходов-доходов на плато

Линейная регрессия предполагает, что каждый рубль расходов даёт одинаковый доход. На самом деле первые 10 тыс. рублей дают ROAS 8, 100 тыс. рублей — 3, 1 млн рублей — менее 1. Предельный доход падает по кривой. Насыщение — это трансформация, моделирующая эту кривую. В Robyn наиболее распространённый тип насыщения — уравнение Hill (Michaelis-Menten):

y = Vmax * (x^S) / (K^S + x^S)

Где Vmax — максимальный эффект, K — уровень расходов, при котором достигается полусыщение (точка перегиба), S — крутизна кривой (shape). Если K низкий, канал быстро насыщается; если высокий — позже. Когда S>1, кривая принимает S-образную форму — медленное начало, быстрый рост, затем замедление.

В Robyn параметры Hill устанавливаются также по каналам:

hyperparameters <- list(
  tv_s_alphas = c(0.5, 3),
  tv_s_gammas = c(0.3, 1),
  search_clicks_p_alphas = c(0.5, 3),
  search_clicks_p_gammas = c(0.3, 1)
)

alphas соответствует параметру S Hill, gammas — параметру K (нотация Robyn). Оптимизация ищет лучший fit в этих диапазонах. Но не полагайтесь на слепой поиск — если уже тратите 80% бюджета на ТВ, насыщение должно быть >90%, иначе модель выдаст нереалистичный предельный ROAS.

Настройка насыщения напрямую влияет на стратегию бюджетного распределения. Если модель правильно построила кривую насыщения, можно рассчитать предельный ROAS каждого канала и перераспределить бюджет. Функция robyn_allocator() делает именно это — при фиксированном общем бюджете какой канал урезать и на какой направить, чтобы максимизировать продажи? Но это рекомендация валидна только если параметры насыщения правильны. Неправильное значение K — это ошибка в миллионы рублей.

Holdout Validation: Тестирование предсказательной силы модели

Главный риск MMM — переобучение: модель заучивает исторические данные, не предсказывает будущее. Чтобы это предотвратить, нужна временная валидация на holdout-наборе. В Robyn'е последние 4–8 недель отделяются как holdout-набор, модель обучается на остальных данных и делает прогноз на holdout-период. Если NRMSE (нормализованная среднеквадратичная ошибка) и MAPE (средняя абсолютная процентная ошибка) низкие, модель обобщается.

InputCollect <- robyn_inputs(
  dt_input = dt_simulated_weekly,
  window_start = "2022-01-01",
  window_end = "2023-10-31",
  rollingWindowStartWhich = 1,
  rollingWindowEndWhich = 52,
  rollingWindowLength = 4
)

rollingWindowLength = 4 откладывает последние 4 недели как holdout. Модель обучается без этих недель, затем делает прогноз. В выходах Robyn показывается holdout NRMSE для каждой модели — ниже 10% хорошо, выше 20% подозрительно. Но не принимайте решение по одной метрике; проверьте, есть ли аномалии в holdout-периоде (кампания, праздник). Например, если неделя Black Friday попадает в holdout, модель недооценит, потому что в нормальном паттерне спроса таких скачков нет.

После holdout'а обычно переобучают модель — финальный fit на всех данных, но гиперпараметры выбираются на основе holdout-результатов. Это цикл "train-validate-finalize". В Robyn это делается через robyn_refresh():

Robyn1 <- robyn_run(InputCollect = InputCollect, plot_folder = OutputCollect$plot_folder)
OutputCollect <- robyn_outputs(Robyn1, select_model = "1_100_3")
RobynRefresh <- robyn_refresh(Robyn1, dt_input = dt_simulated_weekly, refresh_steps = 4)

refresh_steps = 4 обновляет модель на новых 4 неделях данных, но сохраняет параметры насыщения и adstock (калибровка сохраняется). Это основа постоянно работающего production-pipeline — каждую неделю добавляется новая строка, модель переподгоняется, dashboard обновляется.

Перемещение Robyn Pipeline'а в Production

Robyn — не R-скрипт, а инструмент, который должен быть интегрирован в production data-pipeline. Типичная архитектура: таблица маркетинг-расходов в BigQuery + таблица конверсий из GA4 + таблица выручки из CRM → dbt создаёт еженедельный агрегированный стол → DAG'а в Cloud Composer (Airflow) триггирует Robyn R-скрипт → результат JSON в Looker Studio dashboard. Этот стек работает в контексте архитектуры first-party данных.

Первый шаг — стандартизировать схему данных. Robyn ждёт таблицу dt_input со столбцами: DATE (еженедельно), revenue, tv_spend, search_spend, facebook_impressions и т. д. Каждый канал — отдельный столбец; без разделения organic/paid модель не может делать attribution. Пропущенные недели нужно заполнить (нулями или интерполяцией), выбросы — отметить. Пример dbt-модели:

with base as (
  select
    date_trunc(event_date, week) as week_start,
    sum(case when source = 'google/cpc' then cost else 0 end) as search_spend,
    sum(case when source = 'facebook' then cost else 0 end) as facebook_spend,
    count(distinct case when event_name = 'purchase' then user_pseudo_id end) as conversions
  from `project.analytics_123456789.events_*`
  where _table_suffix between '20220101' and '20231231'
  group by 1
)
select * from base
order by week_start

Этот стол экспортируется из BigQuery в CSV и подаётся в Robyn-скрипт, или лучше — извлекается прямо через пакет R bigrquery. Второй вариант предпочтителен — гарантирует свежесть данных.

DAG в Airflow для этапа Robyn:

from airflow.operators.bash import BashOperator

run_robyn = BashOperator(
    task_id='run_robyn_mmm',
    bash_command='Rscript /path/to/robyn_model.R ',
    dag=dag
)

Внутри скрипта модель сохраняется через robyn_save() в формате RDS и загружается в GCS. На следующие недели robyn_refresh() загружает её обратно. Таким образом, каждую неделю вместо переобучения с нуля делается incremental update — время вычисления падает с 2 часов до 15 минут.

Метрики holdout сохраняются в JSON выходе, пишутся в BigQuery, график тренда появляется в Looker Studio. Если NRMSE резко скачет (например, с 8% на 18%), срабатывает alert — модель сломалась, нужна пересчётка. Без этого мониторинга MMM молчаливо отказывает; неправильное распределение бюджета остаётся незамеченным 3 месяца.

Привязка выходов модели к механизму принятия решений

Выход Robyn — не pie chart вклада канала, а таблица предельного ROAS. Доход последнего рубля расходов каждого канала. На этой основе запускается бюджетный оптимизатор: если предельный ROAS ТВ равен 2, а поиска — 5, нужно сдвинуть бюджет в пользу поиска. Но эта механическая оптимизация может конфликтовать со стратегией бренда — если ТВ работает на brand awareness, краткосрочный ROAS вводит в заблуждение.

Поэтому результаты MMM не должны быть инструментом изолированного решения, а синтезироваться с другими сигналами в слое data analytics: brand lift study, incrementality test, customer lifetime value. Если Robyn говорит contribution 30%, а geo-lift test показывает 15%, нужно их согласовать — в предположениях модели ошибка (например, adstock decay установлен слишком высоко).

В production MMM обновляется еженедельно, но решения по бюджету принимаются ежемесячно или ежеквартально. Модель работает каждую неделю, метрики идут в тренд, но вы смотрите на 4-недельное усреднение. Сдвиг в миллионы по одной неделе вызывает волатильность. К тому же holdout-период 4 недели, так что цикл пересмотра бюджета должен совпадать с окном holdout.

Наконец, MMM не заменяет, а дополняет incremental attribution