Send time optimization: как AI подбирает идеальное время отправки
Большинство ESP позиционируют Send Time Optimization как кнопку: включил - и open rate вырос на 10%. За кнопкой стоит конвейер из сбора событий, feature engineering, обучения модели и инференса на миллионах адресов. Эта статья разбирает STO на уровне алгоритмов и структур данных - для тех, кому важно понимать, что происходит внутри.
Задача в формальных терминах
Есть множество подписчиков U и окно отправки W, разбитое на слоты (обычно часовые или получасовые). Для каждого пользователя u нужно найти слот t*, максимизирующий вероятность целевого действия - открытия или клика. Формально: t*(u) = argmax P(open | u, t). Задача выглядит тривиальной, пока не сталкиваешься с реальностью данных.
Входные сигналы: что попадает в модель
STO-модель обучается на исторических событиях взаимодействия с рассылками. Минимальный набор сигналов, который используют большинство реализаций:
- Временные метки открытий и кликов - основной источник. Из них извлекаются час дня, день недели и смещение относительно UTC. Важен именно локальный час пользователя, а не серверный.
- Задержка между отправкой и открытием (delivery-to-open latency). Письмо отправлено в 9:00, открыто в 9:47 - задержка 47 минут. Этот признак ценнее абсолютного времени открытия, потому что устраняет зависимость от часа отправки.
- Тип устройства - desktop или mobile. Паттерны различаются: мобильные открытия смещены к утренним и вечерним часам, десктопные - к рабочему времени.
- Категория письма - промо, транзакционное, триггерное. Человек может мгновенно открывать уведомления о заказе, но откладывать промо на вечер.
- Часовой пояс - если есть в профиле или выводится по IP последнего взаимодействия.
Продвинутые реализации добавляют частоту проверки почты (промежутки между последовательными открытиями), историю покупок, сегмент активности. Но базовый набор из пяти признаков выше покрывает 80-90% полезного сигнала.
Модель данных: как хранить историю
Для STO нужна таблица событий с гранулярностью до минуты. Типичная схема в колоночной СУБД (ClickHouse, BigQuery):
CREATE TABLE email_events (
user_id UInt64,
campaign_id UInt64,
event_type Enum('send','open','click'),
event_ts DateTime64(3), -- UTC
tz_offset Int16, -- минуты от UTC
device_type Enum('desktop','mobile','tablet'),
campaign_type Enum('promo','transactional','trigger')
) ENGINE = MergeTree()
ORDER BY (user_id, event_ts);Ключевой момент - хранение tz_offset на уровне события. Часовой пояс пользователя может меняться (командировки, переезд), и привязка к профилю даёт устаревшие данные. Лучше вычислять смещение по IP в момент открытия и записывать рядом с событием.
Для feature engineering из этой таблицы агрегируем профиль пользователя: распределение открытий по часам за последние 90 дней, медиана delivery-to-open latency, доля мобильных открытий. Окно 90 дней - компромисс: короче - мало данных, длиннее - устаревшие паттерны.
Три подхода к моделированию
На практике встречаются три архитектуры STO, от простой к сложной. Каждая имеет свою область применения.
1. Гистограммный подход (per-user histogram)
Для каждого пользователя строим гистограмму открытий по 24 часовым бинам. Слот с максимальной частотой - кандидат на отправку. Сглаживание (Лапласа или ядерное) предотвращает переобучение на выбросах. Если у пользователя меньше 5-10 открытий, откатываемся на когортный профиль (средний по сегменту).
Плюсы: прозрачность, скорость, нулевые затраты на инференс. Минусы: не учитывает зависимости между признаками (день недели, тип кампании), плохо работает для новых подписчиков.
2. Градиентный бустинг (classification per slot)
Формулируем задачу как бинарную классификацию: для каждой пары (пользователь, слот) предсказываем вероятность открытия. Признаки - профиль пользователя (агрегаты из таблицы событий) плюс характеристики слота (час, день недели, рабочий/выходной). Обучаем XGBoost или LightGBM на исторических отправках. На инференсе прогоняем все 24 слота для каждого пользователя, выбираем argmax.
import lightgbm as lgb
import numpy as np
# features: user profile + slot features
# label: 1 if opened within 2h of delivery, else 0
model = lgb.LGBMClassifier(
n_estimators=500,
max_depth=6,
learning_rate=0.05,
subsample=0.8,
colsample_bytree=0.8,
)
model.fit(X_train, y_train)
# inference: score all 24 slots for a user
def predict_best_slot(user_features: np.ndarray) -> int:
slots = np.arange(24)
scores = []
for slot in slots:
slot_features = encode_slot(slot) # hour, dow, is_weekend
x = np.concatenate([user_features, slot_features])
scores.append(model.predict_proba(x.reshape(1, -1))[0, 1])
return int(np.argmax(scores))Этот подход используют Brevo и Mailchimp (судя по опубликованным техническим описаниям). Он хорошо масштабируется: обучение одной модели на всех пользователях, а персонализация - через признаки. Инференс на миллион пользователей (24 * 10^6 пар) занимает минуты на одном GPU.
3. Multi-armed bandit (Thompson Sampling)
Каждый часовой слот - рука бандита. Для каждого пользователя поддерживаем бета-распределение Beta(a_t, b_t), где a_t - количество открытий при отправке в слот t, b_t - количество неоткрытий. При отправке сэмплируем из каждого распределения, выбираем слот с максимальным сэмплом.
Преимущество бандита - он балансирует между exploitation (отправлять в проверенное время) и exploration (пробовать новые слоты, чтобы обнаружить изменения в поведении). Для новых подписчиков бандит естественным образом исследует все слоты, пока не накопит статистику. Для пользователей с историей - быстро сходится к лучшему времени.
import numpy as np
class ThompsonSTO:
def __init__(self, n_slots: int = 24):
# prior: Beta(1, 1) = uniform
self.alpha = np.ones(n_slots)
self.beta = np.ones(n_slots)
def select_slot(self) -> int:
samples = np.random.beta(self.alpha, self.beta)
return int(np.argmax(samples))
def update(self, slot: int, opened: bool):
if opened:
self.alpha[slot] += 1
else:
self.beta[slot] += 1На практике бандит часто комбинируют с контекстными признаками (contextual bandit), добавляя день недели и тип кампании в вектор контекста. Это даёт гибкость бустинга с exploration-свойствами бандита.
| Подход | Сильная сторона | Ограничение |
|---|---|---|
| Гистограмма | Прозрачность, нулевой инференс | Не учитывает контекст |
| Градиентный бустинг | Учитывает признаки, масштабируется | Нет exploration, переобучение |
| Thompson Sampling | Адаптивность, exploration | Медленная сходимость без контекста |
Cold start: что делать без истории
Новый подписчик - нет открытий, нет истории. Три стратегии:
- Когортный fallback. Группируем пользователей по часовому поясу и источнику подписки. Агрегированный профиль когорты используем как начальное приближение. Для B2B рассылок когорта «рабочие часы по локальному времени» - уже разумный старт.
- Hierarchical prior. В бандитном подходе инициализируем параметры бета-распределения не единицами, а агрегатами по когорте: Beta(a_cohort, b_cohort). Бандит стартует с разумной гипотезой и корректирует её по мере накопления индивидуальных данных.
- Exploration burst. Первые 3-5 рассылок отправляем в случайные слоты (с ограничением: только в рабочие часы для B2B). Это даёт данные для обучения за счёт небольшого снижения open rate на старте.
Продакшн-архитектура: от модели к отправке
Обученная модель бесполезна без инфраструктуры, которая подставляет предсказания в конвейер отправки. Типичная архитектура выглядит так:
- Batch inference. За несколько часов до кампании запускаем предсказание для всех получателей. Результат - таблица
user_id → best_slot. Для базы в миллион записей при 24 слотах на пользователя LightGBM справляется за 2-3 минуты. - Очередь отправки. Планировщик разбивает список получателей на 24 группы по слотам и закидывает задания в очередь (Redis, RabbitMQ, Kafka). Каждая группа обрабатывается в назначенный час.
- Throttling. Нельзя отправить 200 000 писем ровно в 10:00 - почтовые серверы начнут throttle-ить или отклонять соединения. Внутри каждого слота отправка растягивается на 15-30 минут с равномерным распределением.
- Feedback loop. После отправки события открытий и кликов записываются обратно в таблицу событий. Модель переобучается по расписанию (раз в неделю для бустинга, непрерывно для бандита).
email_events (ClickHouse)
│
▼
feature_engineering (scheduled job, daily)
│
▼
model_training (weekly retrain / continuous bandit update)
│
▼
batch_inference → user_slots table
│
▼
campaign_scheduler → slot queues (Redis)
│
▼
send_workers (throttled, per-slot)
│
▼
ESP / MTA → delivery
│
▼
webhook events → email_events (feedback loop)Метрики: как измерять эффект STO
Нельзя просто включить STO и сравнить open rate с прошлым месяцем - слишком много конфаундеров: сезонность, контент, размер базы. Корректный подход - A/B-тест на уровне пользователей.
Контрольная группа (10-20% базы) получает письма в фиксированное время (например, 10:00 по локальному времени пользователя). Тестовая группа - по предсказанию модели. Рандомизация на уровне user_id % 100, чтобы один пользователь всегда попадал в одну группу.
Первичная метрика - open rate. Вторичные: click rate, conversion rate (если есть), delivery-to-open latency. Отдельно стоит следить за распределением отправок по слотам: если модель «схлопывается» в 2-3 часа, скорее всего она переобучилась на когортный паттерн и не персонализирует.
Реалистичные цифры
На базах с чистой историей (валидные адреса, корректный трекинг) STO даёт +5-15% к open rate по сравнению с фиксированным временем. На грязных базах эффект ниже или отсутствует: модель обучается на шуме мёртвых адресов. Для базы в 500K подписчиков разница в 5% - это 25 000 дополнительных открытий на кампанию без изменений в контенте.
Ловушки, которые ломают STO
Модель технически работает, open rate не растёт. Четыре причины, которые встречаются чаще всего:
- Невалидные адреса в обучающей выборке. Мёртвый ящик не откроет письмо ни в какое время. Но модель видит это как «пользователь, который не реагирует ни на один слот» и пытается подобрать ему время. Результат - шум в обучении, деградация точности для живых подписчиков. Валидация базы перед обучением STO - обязательный шаг.
- Apple Mail Privacy Protection. С iOS 15 Mail скачивает содержимое письма в момент доставки, а не открытия. Пиксель трекинга срабатывает при доставке - модель учится на фантомных открытиях. Фильтровать Apple Mail user-agent из обучающей выборки - грубый, но рабочий подход.
- Неправильный часовой пояс. Если для 30% пользователей часовой пояс определён неверно, модель обучается на смещённых данных. IP-based geolocation неточна для VPN. Лучший источник - JavaScript-определение на сайте при подписке.
- Слишком узкое окно отправки. Если бизнес ограничивает отправку «с 9 до 18», модель может оптимизировать только внутри этого окна. При коротком окне разница между слотами минимальна, и STO не даёт заметного эффекта.
Качество данных как фундамент STO
Все три подхода (гистограмма, бустинг, бандит) строятся на одном допущении: события в таблице отражают реальное поведение живых людей. Как только это допущение нарушается - невалидные адреса, спам-ловушки, одноразовые ящики - модель теряет сигнал.
На практике это выглядит так: в базе из 200 000 адресов 15% невалидны. Это 30 000 строк в обучающей выборке, для которых все открытия - нули. Модель интерпретирует их как подписчиков, потерявших интерес. Начинает подстраиваться под паттерны, которых нет. Точность деградирует для всех.
Валидация базы перед построением STO - не маркетинговый совет, а инженерная необходимость. Так же, как очистка данных перед обучением любой ML-модели: garbage in, garbage out.
STO оптимизирует момент доставки. Если адрес несуществующий, оптимизировать нечего. Чистая база - precondition для любой модели, которая работает с событиями взаимодействия.
Реализация STO на стороне ESP vs. собственная
Если используете Mailchimp, Brevo, HubSpot или Salesforce Marketing Cloud - STO уже встроен. Включается одним переключателем в настройках кампании. Модель обучена на агрегированных данных всех клиентов платформы, что помогает с cold start: даже новый аккаунт получает предсказания на основе коллективного паттерна.
Собственная реализация имеет смысл при двух условиях: база больше 500K подписчиков (достаточно данных для индивидуальной модели) и есть команда, которая будет поддерживать pipeline - дообучение, мониторинг, A/B-тесты. Для остальных STO от ESP - рациональный выбор.
Чек-лист внедрения
- Валидировать базу. Убрать невалидные адреса, спам-ловушки, одноразовые ящики. Без этого шага обучение модели будет на зашумлённых данных.
- Проверить трекинг. Открытия должны писаться с UTC-метками. Фильтровать Apple Mail prefetch. Убедиться, что delivery-to-open latency вычисляется корректно.
- Определить часовые пояса. IP geolocation при открытии или JavaScript при подписке. Хранить смещение на уровне события.
- Выбрать подход. Для старта - гистограмма или встроенный STO в ESP. Для продвинутых - бустинг или contextual bandit.
- Настроить A/B-тест. Контрольная группа 10-20% с фиксированным временем. Минимум 3-4 кампании для статистически значимого результата.
- Мониторить. Распределение отправок по слотам (не должно схлопываться), open rate по группам, regret для бандита.
Главное
Send Time Optimization - не магия, а стандартная задача ML: сбор данных, feature engineering, обучение, инференс, feedback loop. Наибольший эффект достигается при чистых данных и корректном трекинге. Первый шаг - всегда валидация базы.
Перед тем как включать STO, убедитесь, что модель будет обучаться на чистых данных. Проверьте базу в uChecker - валидация, скоринг риска, отсев спам-ловушек и одноразовых адресов.
