Предиктивная оптимизация отправки email: beyond STO
Send Time Optimization определяет лучший час для отправки. Но час - только одна переменная. Сколько писем отправить на этой неделе? Какой канал выбрать? Готов ли подписчик к промо, или лучше подождать? Предиктивная оптимизация отправки решает все эти вопросы одновременно. Разберём архитектуру, модели и инженерные решения.
Почему STO - это только начало
STO отвечает на узкий вопрос: в какой час отправить конкретное письмо конкретному человеку? Это полезно, но неполно. Маркетолог ежедневно принимает десятки решений, которые STO не затрагивает. Частота рассылок. Приоритет кампаний, когда их четыре на одну неделю. Пауза для подписчика, который получил три письма за два дня и не открыл ни одного. Каналы: email, push, SMS - и где именно этот подписчик отреагирует.
Предиктивная оптимизация отправки - это система, которая объединяет время, частоту, приоритет и канал в одну модель решений. Вместо четырёх изолированных инструментов - один конвейер, который для каждого подписчика и каждой кампании выдаёт конкретный ответ: отправить сейчас, отложить, или вообще пропустить.
Различие принципиальное. STO оптимизирует один параметр при фиксированных остальных. Предиктивная оптимизация работает с пространством решений, где параметры взаимозависимы: отправка в идеальное время бесполезна, если подписчик уже перегружен письмами за неделю.
Пространство решений
Для каждой пары (подписчик, кампания) система принимает решение по четырём осям:
- Время - часовой слот отправки. Это классический STO, здесь ничего нового.
- Приоритет - если на этот день запланированы две кампании, какую отправить первой? Какую можно отложить на завтра без потери конверсии?
- Частота - сколько писем в неделю выдерживает этот подписчик до того, как перестанет открывать или отпишется?
- Канал - email, push-уведомление или SMS? Для каждого подписчика вероятность реакции в каждом канале различается.
При 24 часовых слотах, 3 каналах и бинарном решении по каждой кампании пространство решений для одного подписчика на одну неделю - сотни комбинаций. Перебирать их вручную невозможно. Но для ML-модели это рутинная задача.
Прогноз усталости подписчика
Усталость - главный враг email-маркетинга, который STO не видит в принципе. Подписчик получает пять промо за неделю, каждое отправлено в идеальный час. К пятому письму он перестаёт открывать что-либо - не потому что время плохое, а потому что достигнут предел внимания.
Формализуем. Для каждого подписчика u в момент времени t определяем fatigue score - вероятность того, что следующее письмо будет проигнорировано из-за перенасыщения, а не из-за нерелевантности контента. Входные сигналы:
- Количество писем, доставленных за последние 7 и 14 дней.
- Доля открытых из доставленных за тот же период (скользящий open rate).
- Динамика: open rate растёт, стабилен или падает? Градиент важнее абсолютного значения.
- Время с последнего клика. Открытие без клика - слабый сигнал вовлечённости. Клик - сильный.
- Количество отписок в когорте за последнюю неделю - внешний индикатор, что частота для сегмента слишком высока.
Модель (градиентный бустинг или логистическая регрессия - достаточно простой архитектуры) обучается на исторических данных: метка 1, если подписчик не открыл три следующих письма подряд после данного момента, 0 - если открыл хотя бы одно. Fatigue score обновляется после каждой отправки.
import lightgbm as lgb
import numpy as np
# Features per subscriber at time t
# emails_7d, emails_14d, open_rate_7d, open_rate_14d,
# open_rate_gradient, days_since_last_click,
# cohort_unsub_rate_7d
model = lgb.LGBMClassifier(
n_estimators=300,
max_depth=5,
learning_rate=0.05,
)
model.fit(X_train, y_fatigue)
def fatigue_score(user_features: np.ndarray) -> float:
return model.predict_proba(
user_features.reshape(1, -1)
)[0, 1]
# Decision: suppress if fatigue > threshold
FATIGUE_THRESHOLD = 0.65
def should_send(user_features: np.ndarray) -> bool:
return fatigue_score(user_features) < FATIGUE_THRESHOLDПорог 0.65 - отправная точка. На практике его калибруют через A/B-тест: контрольная группа получает все запланированные письма, тестовая - с fatigue-фильтром. Метрики для сравнения: совокупный open rate за месяц, количество отписок, revenue per subscriber.
Динамическая частота: индивидуальный лимит
Фиксированный лимит «не больше трёх писем в неделю» - компромисс. Для активного подписчика, который открывает всё, три письма мало. Для того, кто открывает одно из пяти, три - уже много.
Динамическая частота рассчитывает персональный лимит. Простой подход: определить для каждого подписчика оптимальное количество писем в неделю как максимум, при котором средний open rate не падает ниже порога (например, 15%).
Более точный подход - рассматривать частоту как переменную в модели fatigue. Вместо бинарного «отправлять/не отправлять» модель выдаёт ожидаемый open rate для каждой возможной частоты: 1, 2, 3, 4, 5 писем в неделю. Оптимум - та частота, при которой суммарное количество открытий максимально.
Пример
Подписчик A: при 2 письмах в неделю открывает 80% (1.6 открытия), при 5 - 35% (1.75). Оптимум - 5 писем. Подписчик B: при 2 письмах - 50% (1.0), при 5 - 10% (0.5). Оптимум - 2 письма. Фиксированный лимит 3 для обоих - хуже персонального в обоих случаях.
Приоритизация кампаний
Маркетинговый календарь перегружен. На одну неделю запланированы промо-рассылка, дайджест, анонс вебинара и triggered-письмо по брошенной корзине. Подписчик с персональным лимитом два письма получит только два. Какие?
Приоритизация строится на ожидаемой ценности. Для каждой пары (подписчик, кампания) модель оценивает вероятность конверсии и умножает на ценность этой конверсии. Брошенная корзина с товаром за 15 000 рублей при вероятности покупки 12% даёт ожидаемую ценность 1 800 рублей. Дайджест с вероятностью клика 8% и косвенной ценностью 50 рублей - 4 рубля. Ответ очевиден.
Технически это сводится к задаче ранжирования с ограничением: отсортировать кампании по ожидаемой ценности, взять top-K, где K - персональный лимит подписчика. Оставшиеся кампании либо пропускаются, либо переносятся на следующий доступный слот.
from dataclasses import dataclass
@dataclass
class CampaignScore:
campaign_id: str
p_conversion: float # predicted probability
value: float # monetary value of conversion
expected_value: float # p_conversion * value
def prioritize(
scores: list[CampaignScore],
weekly_limit: int,
) -> list[CampaignScore]:
ranked = sorted(
scores,
key=lambda s: s.expected_value,
reverse=True,
)
return ranked[:weekly_limit]
# Example: subscriber with limit=2, four campaigns
scores = [
CampaignScore("cart_abandon", 0.12, 15000, 1800),
CampaignScore("promo_sale", 0.06, 8000, 480),
CampaignScore("webinar", 0.09, 500, 45),
CampaignScore("digest", 0.08, 50, 4),
]
selected = prioritize(scores, weekly_limit=2)
# -> cart_abandon (1800), promo_sale (480)Маршрутизация по каналам
Email - не единственный канал. У части подписчиков push-уведомления дают более высокий response rate, чем письмо. Кто-то реагирует на SMS, но игнорирует email. Предиктивная оптимизация добавляет выбор канала в уравнение.
Модель оценивает вероятность реакции для каждой тройки (подписчик, кампания, канал). Это расширение задачи приоритизации: вместо ранжирования кампаний ранжируем пары (кампания, канал). Ограничение - лимит на суммарную частоту по всем каналам, а не только по email.
Важный нюанс: стоимость каналов различается. SMS стоит в десятки раз дороже email. Модель учитывает это через параметр cost-per-send, который вычитается из ожидаемой ценности. Дорогой канал оправдан, только если прирост вероятности конверсии компенсирует разницу в стоимости.
Архитектура предиктивного конвейера
Четыре модели (STO, fatigue, приоритизация, канал) нужно объединить в один pipeline, который работает до отправки каждой кампании.
Campaign Calendar (next 7 days)
│
▼
Weekly Planner (batch, runs Sunday night)
│
├── 1. Fetch subscriber features (feature store)
│
├── 2. Fatigue scoring
│ → fatigue_score per subscriber
│
├── 3. Dynamic frequency
│ → weekly_limit per subscriber
│
├── 4. Campaign prioritization
│ → ranked campaigns per subscriber
│ → top-K selected (K = weekly_limit)
│
├── 5. Channel routing
│ → (campaign, channel) per subscriber
│
├── 6. STO per selected (subscriber, campaign)
│ → send_slot assignment
│
└── 7. Output: send_plan table
(subscriber_id, campaign_id, channel,
send_slot, expected_value)
│
▼
Daily Dispatcher
│
├── Filter today's sends from send_plan
├── Group by slot → queues
└── Throttled send via ESP / MTA
│
▼
Feedback Loop
│
├── Events (open, click, convert, unsub)
└── → feature store + model retrainWeekly Planner запускается раз в неделю и генерирует полный план отправок на семь дней. Daily Dispatcher исполняет план: извлекает задания на текущий день, группирует по слотам и отправляет. Feedback loop обновляет feature store после каждого события.
Разделение на планировщик и диспетчер даёт гибкость. Если в среду появляется незапланированная срочная кампания, планировщик может пересчитать оставшиеся дни, не трогая уже отправленные письма. Персональные лимиты пересчитываются с учётом уже потраченного бюджета внимания.
STO vs. предиктивная оптимизация
Метрики: что измерять
STO оценивается по open rate. Предиктивная оптимизация - по другим метрикам, потому что часть писем намеренно не отправляется.
Revenue per subscriber per month. Главный показатель. Учитывает и отправленные, и пропущенные письма. Если подавление части рассылок увеличило выручку на подписчика - система работает.
Unsub rate. Снижение отписок - прямое следствие fatigue-фильтра. Типичный результат: -20-40% отписок при том же или большем revenue.
Emails per conversion. Сколько писем нужно отправить, чтобы получить одну конверсию. Чем ниже - тем эффективнее система расходует внимание подписчиков.
Suppression rate. Доля подавленных отправок. Если система подавляет 40% писем и revenue растёт - раньше 40% рассылок были бесполезными или вредными. Это неприятная правда, но полезная.
Качество данных как предусловие
Модель fatigue обучается на открытиях и кликах. Если 15% базы - мёртвые адреса, модель видит их как подписчиков с максимальной усталостью и начинает подавлять рассылки живым людям с похожим профилем.
Предиктивная оптимизация чувствительнее к качеству базы, чем STO. У STO одна модель и один вход. Здесь - четыре модели, каждая со своими данными, и ошибки каскадируют. Невалидный адрес заражает fatigue score, fatigue score занижает персональный лимит, заниженный лимит отфильтровывает кампании, которые подписчик бы открыл.
Три точки, где валидация критична:
- Перед обучением моделей. Из обучающей выборки убираются события, связанные с невалидными адресами. Иначе модель fatigue обучается на ложных негативах, а модель приоритизации занижает конверсию для целых категорий.
- Перед batch-планированием. Weekly Planner не должен тратить compute на адреса, которые не существуют. Bulk-валидация за 12-24 часа до планирования.
- В real-time при подписке. Одноразовые адреса, опечатки, спам-ловушки отсекаются до того, как попадают в feature store и загрязняют данные.
Чем больше моделей в конвейере, тем дороже обходится грязная база. В STO потери линейны. В предиктивной оптимизации - мультипликативны.
Распространённые ошибки
Оптимизация open rate вместо revenue. Если fatigue-фильтр подавляет 50% писем, open rate оставшихся взлетит просто из-за отбора лучшей аудитории. Это не рост эффективности - это селекция. Метрика должна быть на уровне подписчика, а не на уровне отправки.
Слишком агрессивное подавление. Модель fatigue обучена на исторических данных с высокой частотой. Если в тестовый период частота резко снижается, модель недооценивает вовлечённость. Нужен warm-up: начинать с мягкого порога и ужесточать после накопления данных в новом режиме.
Нет fallback для новых подписчиков. У нового подписчика нет истории, fatigue score не определён. Без когортного fallback система либо не отправляет ему ничего, либо заваливает письмами. Стартовый профиль на основе когорты - обязателен.
Обучение на невалидных адресах. Об этом выше, но стоит повторить: одна эта ошибка обесценивает весь конвейер. Мёртвые адреса - шум, который модели воспринимают как сигнал.
Чек-лист внедрения
- Валидировать базу. Убрать невалидные адреса, одноразовые ящики, спам-ловушки. Это фундамент для всех следующих шагов.
- Начать с STO. Если ещё не включено - включить. Одна кнопка в ESP, +5-15% к open rate. Базовая оптимизация перед усложнением.
- Собрать данные для fatigue-модели. Минимум три месяца истории: отправки, открытия, клики, отписки. Без исторических данных модель fatigue нечем обучить.
- Обучить fatigue scoring. Начать с логистической регрессии. Усложнять после подтверждения эффекта через A/B-тест.
- Внедрить динамическую частоту. Персональные лимиты на основе fatigue score. Начать с мягкого порога.
- Добавить приоритизацию. Ранжировать кампании по ожидаемой ценности. Требует модели конверсии или хотя бы исторических данных по CTR каждой категории кампаний.
- Multi-channel - последний шаг. Добавлять, когда три предыдущих компонента стабильны. Это кратное усложнение пространства решений.
Главное
Предиктивная оптимизация отправки - это не замена STO, а его расширение. STO отвечает на вопрос «когда». Предиктивная оптимизация отвечает на вопросы «когда», «сколько», «что» и «где» одновременно. Но каждая дополнительная модель усиливает зависимость от качества данных. Чистая база - условие, без которого каскад моделей деградирует вместо того, чтобы усиливать друг друга.
Перед тем как строить предиктивный конвейер, убедитесь, что модели будут обучаться на чистых данных. Проверьте базу в uChecker - валидация, скоринг риска, отсев спам-ловушек и одноразовых адресов.
