Стекинг для прогноза спроса: обучаем второй уровень на прошлом

Планировщик склада получает два прогноза спроса. Один учитывает недавний уровень продаж, второй держит в памяти разные дни недели. Хочется обучить модель второго уровня, которая возьмёт оба числа и соберёт итоговый прогноз. Если дать ей предсказания базовых моделей на тех же данных, на которых они обучались, второй уровень увидит ошибки другого типа и размера, чем при работе на новых неделях.

Собираем прошлые прогнозы так, как они появились бы в сервисе

На первой дате отсечения обучите обе базы на доступном прошлом и предскажите следующий период. Сдвиньте отсечение, повторите и сохраните новые предсказания рядом с фактом, который позже стал известен. Так получается таблица out-of-fold, или OOF: каждая строка второго уровня относится к периоду, который её базовые модели при подгонке ещё не видели. Для раннего отрезка такой строки нет. Он нужен, чтобы базы вообще могли начать предсказывать.

Сохраните последнюю неделю для внешней проверки. На ней заново получите прогнозы обоих базовых алгоритмов, обученных на всём разрешённом прошлом, и примените готовый второй уровень. Если в эксплуатации база обновляется каждую неделю, моделируйте этот ритм. Если базу после релиза фиксируют на месяц, при проверке полезно удерживать её неизменной тот же срок. Чем сильнее расходятся режимы переобучения, тем менее сопоставимы полученные метрики.

Пример на 42 днях

Пусть к воскресенью доступны продажи всей прошедшей недели. Раз в неделю делаем прогноз на следующие семь дней. Первая база усредняет последние семь наблюдений. Вторая берёт среднее для нужного дня недели по истории. Второй уровень обучает один вес смешивания по минимуму суммы квадратов ошибок. Вес ограничен от нуля до единицы, поэтому итог остаётся между исходными прогнозами. Так проще увидеть, что именно обучается. Оценим отдельную неделю после всей подгонки.

from statistics import mean

week_pattern = (0, 3, 4, 2, 6, 14, 12)
weekly_levels = (30, 30, 34, 36, 38, 40)
demand = [level + effect for level in weekly_levels for effect in week_pattern]

def forecast(history, days):
    recent_mean = mean(history[-7:])
    weekday_means = [mean(history[i::7]) for i in range(7)]
    return [(recent_mean, weekday_means[day % 7]) for day in days]

oof = []
for cutoff in range(14, 35, 7):
    for day, (recent, weekday) in zip(
        range(cutoff, cutoff + 7),
        forecast(demand[:cutoff], range(cutoff, cutoff + 7)),
        strict=True,
    ):
        oof.append((recent, weekday, demand[day]))

numerator = sum((b - a) * (y - a) for a, b, y in oof)
denominator = sum((b - a) ** 2 for a, b, _ in oof)
weight = min(1, max(0, numerator / denominator)) if denominator else 0
test = forecast(demand[:35], range(35, 42))
actual = demand[35:]
combined = [a + weight * (b - a) for a, b in test]

def mae(predicted):
    return mean(abs(p - y) for p, y in zip(predicted, actual, strict=True))

assert len(oof) == 21
print(f"OOF: {len(oof)} строк, вес дней недели: {weight:.3f}")
print(f"MAE: недавнее {mae(a for a, _ in test):.2f}, "
      f"дни недели {mae(b for _, b in test):.2f}, смесь {mae(combined):.2f}")

Таблица для второго уровня начинается с дня 14 и содержит 21 строку. Первые две недели играют роль стартовой истории. Последняя неделя начинается с дня 35 и используется только для проверки. В выводе есть вес и три значения MAE, поэтому сразу видно, обошло ли смешивание обе базы на этом отрезке. В этом примере смесь даёт MAE 4,85 и уступает среднему за недавнюю неделю с MAE 3,88. Перед выпуском оставьте эту простую базу в сравнении на нескольких следующих окнах. Отдельно можно заметить, что оптимизировали квадратичную ошибку, а проверяли абсолютную. Если критична именно MAE, зафиксируйте эту цель ещё при выборе метода обучения второго уровня.

Где простое разбиение даёт сбой

В scikit-learn StackingRegressor строит вход второго уровня через cross_val_predict. Этот метод ожидает разбиение, где каждая строка ровно один раз появляется в тестовой части. У TimeSeriesSplit стартовый отрезок в тест не попадает. Поэтому соберите прогнозы на временных блоках вручную, как в примере. Параметр cv='prefit' требует другого процесса: обучить базы отдельно и дать второму уровню независимый набор их прогнозов.

Для реальных данных в каждом блоке заново обучайте и преобразования признаков. Вставьте разрыв перед тестом, если исход становится известен позже даты наблюдения. Удобнее вычислять доступность по календарной дате прихода метки, когда интервалы нерегулярны. Добавьте к сохранённой таблице дату прогноза, границу обучения и версию каждой базы. Так при плохом результате можно понять, обесценился один вход или устарела их комбинация.

Практика на Latorn

В бесплатном уроке по train/test есть базовая практика разделения данных и проверки модели. Затем повторите её с несколькими временными границами.

В платном курсе «ML Engineer / Data Scientist» вы подготовите табличные признаки, сравните CatBoost и LightGBM и сохраните воспроизводимый пайплайн. Это даёт две полноценные базы для эксперимента со смешиванием. Посмотреть программу курса.

Продолжить на практике

  • ML Engineer / Data Scientist: табличные модели в продакшн ↗

    Уже обучали модели на Python? Научитесь доводить их до работы в приложении: проверять качество без утечек, сравнивать CatBoost и LightGBM, объяснять прогнозы через SHAP и запускать модель через FastAPI. Практика на табличных данных, Docker, пакетный инференс и мониторинг.