uCheckeruChecker
11 мин чтения

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):

SQL schema
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.

Python
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 (пробовать новые слоты, чтобы обнаружить изменения в поведении). Для новых подписчиков бандит естественным образом исследует все слоты, пока не накопит статистику. Для пользователей с историей - быстро сходится к лучшему времени.

Python
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: что делать без истории

Новый подписчик - нет открытий, нет истории. Три стратегии:

  1. Когортный fallback. Группируем пользователей по часовому поясу и источнику подписки. Агрегированный профиль когорты используем как начальное приближение. Для B2B рассылок когорта «рабочие часы по локальному времени» - уже разумный старт.
  2. Hierarchical prior. В бандитном подходе инициализируем параметры бета-распределения не единицами, а агрегатами по когорте: Beta(a_cohort, b_cohort). Бандит стартует с разумной гипотезой и корректирует её по мере накопления индивидуальных данных.
  3. Exploration burst. Первые 3-5 рассылок отправляем в случайные слоты (с ограничением: только в рабочие часы для B2B). Это даёт данные для обучения за счёт небольшого снижения open rate на старте.

Продакшн-архитектура: от модели к отправке

Обученная модель бесполезна без инфраструктуры, которая подставляет предсказания в конвейер отправки. Типичная архитектура выглядит так:

  1. Batch inference. За несколько часов до кампании запускаем предсказание для всех получателей. Результат - таблица user_id → best_slot. Для базы в миллион записей при 24 слотах на пользователя LightGBM справляется за 2-3 минуты.
  2. Очередь отправки. Планировщик разбивает список получателей на 24 группы по слотам и закидывает задания в очередь (Redis, RabbitMQ, Kafka). Каждая группа обрабатывается в назначенный час.
  3. Throttling. Нельзя отправить 200 000 писем ровно в 10:00 - почтовые серверы начнут throttle-ить или отклонять соединения. Внутри каждого слота отправка растягивается на 15-30 минут с равномерным распределением.
  4. Feedback loop. После отправки события открытий и кликов записываются обратно в таблицу событий. Модель переобучается по расписанию (раз в неделю для бустинга, непрерывно для бандита).
pipeline
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 - рациональный выбор.

Чек-лист внедрения

  1. Валидировать базу. Убрать невалидные адреса, спам-ловушки, одноразовые ящики. Без этого шага обучение модели будет на зашумлённых данных.
  2. Проверить трекинг. Открытия должны писаться с UTC-метками. Фильтровать Apple Mail prefetch. Убедиться, что delivery-to-open latency вычисляется корректно.
  3. Определить часовые пояса. IP geolocation при открытии или JavaScript при подписке. Хранить смещение на уровне события.
  4. Выбрать подход. Для старта - гистограмма или встроенный STO в ESP. Для продвинутых - бустинг или contextual bandit.
  5. Настроить A/B-тест. Контрольная группа 10-20% с фиксированным временем. Минимум 3-4 кампании для статистически значимого результата.
  6. Мониторить. Распределение отправок по слотам (не должно схлопываться), open rate по группам, regret для бандита.

Главное

Send Time Optimization - не магия, а стандартная задача ML: сбор данных, feature engineering, обучение, инференс, feedback loop. Наибольший эффект достигается при чистых данных и корректном трекинге. Первый шаг - всегда валидация базы.


Перед тем как включать STO, убедитесь, что модель будет обучаться на чистых данных. Проверьте базу в uChecker - валидация, скоринг риска, отсев спам-ловушек и одноразовых адресов.

send time optimizationSTO emailAI оптимизация времени отправкимашинное обучение время emailалгоритмы STOThompson Samplingвалидация email
← Все статьи