
Практически каждый ML‑разработчик сталкивался с прогнозированием временных рядов, ведь окружающие нас сущности и метрики зачастую зависят от времени.
Меня зовут Александр Елизаров, я работаю в группе аналитики ключевых показателей в бизнес‑группе Поиска и рекламных технологий. В течение нескольких лет нам приходилось прогнозировать большое количество временных рядов разных доменных областей: от поисковой доли Яндекса до DAU определённых сервисов. Чтобы успешно справляться с этой задачей, мы вместе с коллегами разработали собственный прогнозный фреймворк. В этой статье я расскажу, как создать универсальный и гибкий пайплайн для прогнозирования. Под катом рассмотрим:
правильно выстроенную иерархию данных;
методы консистентного предсказания абсолютных и относительных метрик;
частые проблемы моделей и то, как мы их фиксили;
а также все важные этапы, о которых нельзя забывать, когда работаешь с временными рядами.
С чего мы начали
Изначально мы делали пайплайны в Jupyter Notebook (дальше буду называть их просто ноутбуками), которые нужно было либо копипастить и рефакторить из более старой версии для каждой задачи отдельно, либо вообще писать с нуля. Но по мере увеличения количества задач и частоты обновления данных регулярное переписывание и экспериментирование в старых ноутбуках показалось неудобным, времязатратным и немасштабируемым решением.
В какой‑то момент мы подумали, что нужно создать инструмент, который позволит автоматизировать прогнозы, — собственный фреймворк, который отвечал бы нашим потребностям. Он должен был соответствовать следующим требованиям, которые мы выделили исходя из нашего опыта и наиболее частых проблем в работе:
Инкапсуляция интерфейса. Пользователь может использовать фреймворк, даже если его знания о коде и моделях минимальны.
Гибкость. Фреймворк должен быть тонко настраиваемым под бизнес‑логику задачи. Подробно об этом я расскажу в разделе «Модели, но не ML».
Независимость. Фреймворк может использовать разные ML‑модели для разных бизнес‑задач.
Встроенная очистка. Как автоматическая, так и ручная очистка от выбросов и сдвигов тренда. Ручная нужна, если автоматическая не справится на специфических периодах (например, COVID).
Оптимизация гиперпараметров. Автоматический подбор гиперпараметров для всех рядов или для отдельных подмножеств. Возможность задать гиперпараметры для каждого ряда вручную.
Постпроцессинг. Корректировка выходов модели под бизнес‑нужды.
Визуализация. Иллюстрация полученных прогнозов, которая сразу показывает на вероятные ошибки модели.
Конечно, эти требования не разрабатывались все сразу. Мы начинали с простых.py‑скриптов, которые по мере необходимости обрастали разными фичами, такими как автоматические подбор параметров и очистка. Нашей первоначальной задачей было просто отделить прогнозный пайплайн от бизнес‑логики.
Почему не готовые решения?
В опенсорс‑фреймворках, таких как Darts, Merlion и Kats, много интересных фич для построения прогнозных пайплайнов — они умеют визуализировать, детектить аномалии и использовать различные ML‑модели. Но они не совсем подходили для наших задач. Нам было важно получить готовый удобно настраиваемый пайплайн, который от начала и до конца работает под капотом, охватывая весь прогнозный процесс — от получения данных до загрузки прогноза на кластер.
Например, если нам нужно было быстро протестировать новый подход или модель, опенсорс‑решения требовали ручного переписывания нескольких шагов алгоритма. Наш же фреймворк позволяет поменять ML‑модель и/или гиперпараметры в конфиге и сразу получить готовый прогноз, что существенно ускоряет эксперименты и упрощает работу.
Пайплайн
Исходя из прошлого опыта работы в ноутбуках и переписывания на Python‑скрипты продакшенизации, мы выделили пять основных шагов хорошего процесса:
Получение данных и первичная обработка.
Комбинирование рядов.
Очистка данных.
Прогноз.
Визуализация.
Подробно рассмотрим каждый из них по порядку.
Получение данных и первичная обработка

Первой проблемой было структурирование временных рядов. Допустим, нам нужно спрогнозировать количество поисковых запросов. Запросами называют загрузки страницы поисковой выдачи. Если вы сначала поискали в Яндексе котиков, а потом решили узнать рецепт оливье — это будет два запроса. И существует множество разбивок, которые могут быть интересны для прогноза: например, в каком браузере сделан запрос, на каком устройстве, с какой ОС и так далее.
Возникает проблема: как работать с этими рядами? Заказчику важно смотреть на метрики в разных срезах. Как запросы ведут себя на планшетах? А как на iOS в целом? Мы можем сгруппировать запросы по ОС, сделать первый прогноз, потом сгруппировать по браузерам и сделать второй прогноз. DataFrame факта имел бы примерно такой вид:

При группировке по полю ds
и суммировании обеих таблиц мы получим одинаковые ряды факта. Выходит, можно сделать df.groupby(['ds', 'name'])[['y']].sum()
для обеих таблиц, отправить в модель и получить прогнозы желаемых разбивок:
Разбивка 1: запросы на iOS, запросы на Android, запросы на Linux, запросы на Windows, запросы на macOS.
Разбивка 2: запросы на телефонах, запросы на планшетах, запросы на десктопе.
Вроде бы, всё просто: можем отдавать цифры проджект‑менеджеру и радоваться. Но что, если мы захотим посмотреть временной ряд всех запросов? Подневно (если одна точка ряда соответствует дню) сложим ряды прогнозов для разбивки 1 и разбивки 2:
Прогноз 1: тотал‑запросы_1 = запросы на iOS + запросы на Android + запросы на Linux + запросы на Windows + запросы на macOS.
Прогноз 2: тотал‑запросы_2 = запросы на телефонах + запросы на планшетах + запросы на десктопе.

Вот и ключевая проблема: pred_total_queries_1 != pred_total_queries_2
— прогнозы не будут равны. Дело в том, что мы подаём модели по‑разному сгруппированные ряды факта, и она не учитывает, что суммы прогнозов 1 и 2 должны быть одинаковыми.
Перед нами стояла задача спрогнозировать ряды так, чтобы консистентность не нарушалась и сложение было корректным. Решением стало представление всех возможных разбивок метрики в виде дерева. Вершина дерева — сумма всех рядов в листьях, «дети» вершины — группировки по одному столбцу, «дети детей» — группировки по двум столбцам и так далее.


Благодаря такой структуре консистентность сохраняется: нам нужно спрогнозировать только листья этого дерева — ряды, сгруппированные по всем нужным столбцам разбивок. А после прогноза данные можно будет группировать по каким угодно полям — тотал‑сумма меняться не будет.
В итоге на этапе первоначальной обработки данных мы создаём из рядов древовидную структуру и преобразуем их в таблицу, как на рисунке ниже. В таблице содержатся только данные рядов‑листьев, без группировок.

Комбинирование рядов

DataFrame, который мы получили на шаге сбора данных, содержит только листья дерева рядов. Это правильно, ведь прогнозировать мы собираемся только их. Но наша конечная цель — провалидировать на адекватность прогноз разбивок, таких как Total#Phones
или Total#iOS
. Для этого в таблицу, полученную на предыдущем шаге, надо добавить необходимые агрегаты. Они нужны для дальнейшей визуализации и финальной загрузки данных. Таблица на выходе этого шага должна выглядеть так:

Столбец is_combined
даёт фреймворку знать, нужно ли спрогнозировать ряд или он комбинированный и нужен лишь для визуализации и выгрузки.


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

И что делать, если прогноз доли всё‑таки нужен? Верным решением будет работать с числителем и знаменателем отдельно. Мы можем прогнозировать запросы из Яндекса и сумму запросов из остальных поисковиков, получив долю вычислением формулы:
Работа с абсолютными значениями, а не с долями показывает более стабильные результаты, а также прогноз доли проще интерпретировать через две составные метрики.
Получается, что мы снова прогнозируем абсолюты в листьях, а на этапе комбинирования делим их на абсолюты родителя, получая долю. Для представления дерева мы используем библиотеку NetworkX.
Далее ряды с флагом is_combined = False
отправляются на очистку и в ML‑модель.
Очистка данных

Не секрет, что для получения хороших цифр на выходе алгоритма нужно подать ему правильные цифры на вход (см. Trash In → Trash Out). Поэтому очистка очень важна для предсказания, в особенности при работе с временными рядами.
Необходимо удалять/заполнять периоды, когда резко изменились тренды и сезонность практически всех метрик, чтобы модель не думала, что это часть нормы и можно дублировать эти паттерны в прогнозе. Также бывают ситуации, когда происходит резкий скачок тренда: обычно это случается при большом притоке пользователей в сервис в связи с появлением новой фичи или закрытием конкурентов.

Сначала мы чистили данные руками, убирая откровенные выбросы, сдвиги тренда, аномальные периоды. Для этого использовали функции вида:
remove_trend_shift('ios#yandex_browser', df.index <= '2023-11-19', '2023-11-19', '2023-12-24')
remove_points('android#opera', '2024-03-07', '2024-03-14')
fill_points('desktop#google_chrome', '2022-02-22', '2022-04-23')
Первый аргумент функции — название ряда, поскольку применяется она к каждому ряду отдельно.
remove_trend_shift
убирает в рядах сдвиг тренда. В данном примере мы вычисляем разницу между значениями ряда в точках, соответствующих 2023-11-19
и 2023-11-23
, и прибавляем это к данным до 2023-11-19
.

remove_points
удаляет точки в данных за выбранный период 2024-03-07
, 2024-03-14
, а fill_points
заполняет данные линейно.

В целом это хорошая стратегия: всегда можно подправить данные как хочется, особенно в аномальные периоды, которые сложно детектируются алгоритмами (тот же COVID, который длился достаточно долго). Также единожды выполнив эту очистку, её можно переиспользовать для тех же рядов, если со временем требуется сделать перепрогноз.
Но у этого решения есть очевидный большой минус: ручная чистка занимает уйму времени, особенно когда количество рядов измеряется десятками, а дедлайны поджимают. Поэтому мы решили автоматизировать процесс очистки (хотя бы частично), что позволило сэкономить время прогнозного процесса в разы.
В основе автоматической очистки лежат алгоритмы двух библиотек — etna и ruptures. etna позволяет удалить выбросные точки с помощью нескольких алгоритмов, в том числе методом, основанным на модели обнаружения выбросов. Выбросами здесь являются все точки, выходящие за пределы прогнозируемого интервала. Допустим, модель Prophet может вычислять доверительные интервалы для предсказания, таким образом мы можем идти скользящим окном по ряду, делать прогноз и убирать точки, которые выходят за интервал n% (n — гиперпараметр).

ruptures отслеживает сдвиги тренда, которые не являются регулярной тенденцией для ряда. Мы выбрали этот инструмент, потому что он надёжный, простой в использовании и гибко настраиваемый. При желании можно выбрать функцию потерь, на которой будет основываться алгоритм, и даже написать её самостоятельно!

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

За прогнозную часть у нас отвечает модель Prophet, хотя архитектура фреймворка позволяет выбрать любую доступную для применения модель.
Почему Prophet? Проджект‑менеджерам зачастую необходима интерпретируемость прогноза: им важно понимать, почему цифры получились именно такие. Prophet может ответить на этот вопрос, потому что раскладывает временной ряд на компоненты, прогнозируя их отдельно:
Здесь — функция тренда, которая моделирует непериодические изменения значений временного ряда;
представляет периодические изменения (например, еженедельную и ежегодную сезонности);
представляет последствия праздников, которые происходят по потенциально нерегулярному графику в течение одного или более дней;
— ошибка, которая отражает любые специфические изменения, которые не учитываются моделью.
Аддитивный характер предсказания делает модель хорошо интерпретируемой. Prophet предлагает удобный интерфейс для понятной корректировки прогноза (например, если модель неверно определила точку смены тренда, с помощью гиперпараметров можно переопределить её самому), а также гибкой настройки типов сезонностей, кастомных праздников, добавления внешних регрессоров и много чего ещё.

Но модель всё же была неидеальна, поэтому мы часто сталкивались с ситуациями, когда прогноз выглядел как‑то не так. Основных таких ситуаций было три:
Прогноз не выходил из факта.
Модель не подхватывала изменение дисперсии данных.
Prophet не определял специфичные сезонности «из коробки».
Первая и вторая проблема связаны между собой: мы так или иначе хотим нормализовать прогноз. Это можно сделать, либо преобразовав дисперсию, чтобы она была такой же, как у последних n точек, либо искусственно поднять или опустить прогноз к точкам факта. Вот так может выглядеть ситуация из пункта 1:

Видно, что прогноз сильно улетел вверх и не отвечает уровню факта. Тогда мы можем вычесть среднее последних n точек факта из прогноза (n задаём сами) и получить адекватную картину.

Так уже выглядит лучше!
Похожая история с дисперсией (пункт 2). Иногда Prophet может не подхватить изменившуюся дисперсию данных, как на картинке ниже:

Чтобы исправить это, мы считаем дисперсию факта и прогноза отдельно, делим одно на другое. Получаем коэффициент, на который умножаем сезонную компоненту прогноза, любезно предоставленную Prophet. Важно работать именно с сезонной компонентой: если умножать просто весь ряд, то у нас тренд тоже будет меняться, а это не ок.
Получаем нормальную картину:

Для иллюстрации ситуации из пункта 3 есть такая картинка:

Видим, что в летний период у нас сильно меняется характер сезонности, но модель отказывается это признавать. Чтобы это исправить, в df факта можно добавить дополнительные столбцы — регрессоры. Так Prophet поймёт, что для этого периода нужно делать отдельную оптимизацию. Выглядит это примерно так:

Эта табличка подаётся в модель (подробнее можете почитать в документации), и мы получаем совсем другой летний прогноз:

Стоит сказать, что Prophet — не панацея. Мы планируем расширять пул моделей для точного и быстрого прогноза. Например, на наших данных достаточно хорошо отрабатывает MSTL из библиотеки statsmodels.
Оптимизация параметров
Для любого ML‑пайплайна важно подбирать гиперпараметры модели, оценивать честную метрику на кросс‑валидации. Наш — не исключение. Во фреймворке есть режим cv
, который как раз выполняет кросс‑валидацию, специфичную для временных рядов. Её цель — не подсматривать в будущее.

С помощью кросс‑валидации мы можем подбирать гиперпараметры модели, отвечающие лучшей метрике. Для этих целей мы используем библиотеку optuna, которая довольно хорошо справляется с оптимизацией заданной нами функции потерь.
Как это работает: в YAML‑конфиге (подробнее о нём будет в разделе «Модели, но не ML») мы задаём левую и правую границы для числовых параметров, а для категориальных просто пишем возможные значения. Пример для Prophet:
optimize:
absolute_params:
seasonality_prior_scale: [0.1, 20]
holidays_prior_scale: [0.1, 20]
changepoint_prior_scale: [0.01, 1]
categorical_params:
growth: [linear, flat]
seasonality_mode: [additive, multiplicative]
Также задаём число итераций для оптимизатора, чтобы достичь лучшей метрики. В качестве метрики мы обычно выбираем среднее MAPE по фолдам.
Когда оптимизация завершилась, лучшие параметры дозаписываются в конфиг для конкретного ряда, чтобы удобно было его прогнозировать в следующий раз.
Кеширование
Важной частью прогнозного процесса является то, что в процессе работы пайплайна мы сохраняем каждый файл с прогнозом конкретного ряда в качестве кеша. Дело в том, что зачастую при тюнинге гиперпараметров вручную мы запускаем прогнозы рядов по отдельности. Если алгоритм видит, что в кеше уже есть этот прогноз без специального флага в командной строке, он его просто проигнорирует.
Эта фича сэкономила нам много времени, поскольку в изначальной версии весь пайплайн перезапускался заново, и процесс этот был очень долгим.
Визуализация

В работе с временными рядами очень важно хотя бы верхнеуровнево отсматривать прогноз, поскольку метрика может быть неплохой, а на деле ряд идёт то выше, то ниже факта. Поэтому мы подумали, что обычной визуализации «факт — прогноз» в изначальном скейле (допустим, подневно) недостаточно.
Прежде всего, полезно смотреть на очищенный факт, на котором, собственно, и училась модель. В очищенном факте тренды должны быть адекватными, а аномалии вырезаны.

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

При анализе прогноза важно оценить остатки между фактом и получившимся прогнозом в прошлое. Это нужно, чтобы понять, насколько хорошо мы описали поведение данных. Если видно, что остатки пришли не из нормального распределения, у них есть какая‑то сезонность и тренд — это означает, что в прогнозе мы что‑то не учли и нужно больше порыться в данных.

Модели, но не ML
Выше я описал общий пайплайн, который подходит для любых временных рядов. Однако для разных метрик могут отличаться логика получения данных, ручная очистка, комбинирование рядов, постпроцессинг, параметры ML‑модели в конфиге, да и сама ML‑модель. Вообще, вся идея создания фреймворка крутилась вокруг отделения бизнес‑логики отдельных доменов от алгоритма. Пайплайн — один, но мы можем гибко настраивать его для каждой задачи.
Как это реализуется? Допустим, мы хотим прогнозировать знакомые нам поисковые запросы. Структура данных выглядит следующим образом:

Создадим класс, в котором настроим логику пайплайна конкретно для этой задачи. Связку этого класса и конфига мы и называем моделью — не по названию ML‑модели, которая прогнозирует ряды, а по области применения. Она состоит из двух файлов:
py, в котором прописаны логика получения, комбинирования, очистки, загрузки данных, а также конфиг;
yaml, в котором прописаны гиперпараметры для ML‑моделей прогнозирования, автоподбора параметров и визуализации.
Рассмотрим их подробнее:
class QueriesModel(ProphetModel):
SLICE_SEPARATOR = '#'
Можно заметить, что класс QueriesModel
наследуется от ProphetModel
. Это означает, что для прогнозирования метрики Queries мы будем использовать модель Prophet. Сам ProphetModel
— это класс с fit‑predict‑интерфейсом с нашими фишками (добавление кастомной сезонности, регрессоров, нормализаций).
Получение данных происходит в obtain_data
— функции, предназначенной для скачивания и первичной обработки данных (переименование рядов, колонок).
@classmethod
def obtain_data(cls, config: Dict[str, Any], measure: Optional[str], slice_name: Optional[str], cli_params: Dict[str, Any]) -> pd.DataFrame:
"""Obtains raw data
:return: pd.DataFrame with columns 'ds', 'measure', 'slice', 'y'
"""
Логика очистки для множества рядов прописывается в clean_data
. За ручное преобразование рядов отвечают функции remove_trend_shift
, fill_points
, remove_points
, а логика автоматической очистки прописана в родительском классе. clean_data
вызывается для каждого ряда отдельно, поэтому, например, remove_trend_shift('ios#yandex_browser',…)
не сработает для ряда android#yandex_browser
— функция просто пропустит эту строчку.
def clean_data(self, df: pd.DataFrame, **fit_params) -> pd.DataFrame:
"""Cleans and prepares the raw data
:param df: pd.DataFrame with columns 'ds', 'measure', 'slice', 'y', 'is_combined'
:param fit_params: dict with fit parameters
:return: pd.DataFrame with columns 'ds', 'slice', 'y'
"""
slice_name = self.init_params['slice_name']
# Очистка для конкретных рядов
remove_trend_shift('ios#yandex_browser', df.index <= '2023-11-19', '2023-11-19', '2023-12-24')
remove_points('android#opera', '2024-03-07', '2024-03-14')
fill_points('desktop#google_chrome', '2022-02-22', '2022-04-23')
return df
В combine_slices
реализуется логика, описанная в разделе «Комбинирование рядов». В этом примере помимо всех спрогнозированных рядов мы хотим проанализировать графики суммы по Desktop и Android. Эти ряды не будут спрогнозированы — они соберутся из факта и прогноза других рядов на этапе визуализации.
@classmethod
def combine_slices(cls, df: pd.DataFrame, config: Dict[str, Any], mode: str, type: str) -> pd.DataFrame:
# Агрегируем ряды для визуализации разных разбивок,
# функция вызывается отдельно для факта и прогноза
:param df: pd.DataFrame with columns 'ds', 'measure', 'slice' and raw model output.
For prediction df there is also 'cutoff' column
:param config: dict with model params
:param mode: str, 'forecast' or 'cv'
:param type: str, 'forecast', 'raw fact', 'cleaned fact'
:return: pd.DataFrame appended with a higher-level slices
"""
# Выделяем отдельные категории, на которые хотим смотреть, — типы девайсов и OS
desktop = df[df.slice.str.contains('desktop')]
desktop['slice'] = 'desktop'
android = df[df.slice.str.contains('android')]
android['slice'] = 'android'
aggregated = pd.concat([desktop,android])
value_to_groupby = 'yhat' if type == 'forecast' else 'y'
columns_to_agg = ['ds', 'measure', 'slice']
aggregated = (aggregated
.groupby(columns_to_agg, as_index=False)[[value_to_groupby]]
.agg(agg_func)
)
aggregated['is_combined'] = True
return pd.concat([df, aggregated])
Замыкает круг загрузка данных на сервер:
@classmethod
def publish_data(cls, config: Dict[str, Any], mode: str, measure: Optional[str], cli_params: Dict[str, Any]) -> None:
"""Publishes forecasts to YT
:param config: dict with params from YAML config
:param mode: str, 'forecast' or 'cv'
:param measure: str
:param cli_params: CLI params
"""
Можно заметить, что этапа визуализации здесь нет. Потому что как таковых настроек визуализации не так много, мы всегда строим все графики (какие именно — описано в разделе «Пайплайн»), а то, что можно настроить, находится в конфиге модели. Поговорим о нём подробнее.
Настройки модели хранятся в YAML‑файле, часть из них может быть переопределена с помощью параметров командной строки. В секции default
указываются настройки по умолчанию для разных измерений, в slices
— настройки конкретных рядов, а в cv
— параметры кросс‑валидации.
default:
general:
# Настройки по умолчанию для всех рядов ВСЕХ измерений
# Настройка автоочистки
cleaning:
enabled: True
# Настройка подбора гиперпараметров
optimize:
absolute_params:
seasonality_prior_scale: [0.1, 20] # Название параметра и левая и правая границы подбора
categorical_params:
growth: [linear, flat]
# Настройки для графиков
plots:
enabled: True
min_date: 2016-01-01 # Минимальная дата на графике
max_date: 2023-01-01 # Максимальная дата на графике
queries:
# Настройки по умолчанию для всех рядов измерения queries,
# накладываются поверх настроек из default -> general
slices:
queries:
# Настройки конкретного ряда, накладываются поверх настроек из default -> queries
'ios#google_chrome': # Пример параметров для конкретного ряда, который прогнозируется Prophet
forecast_params:
growth: logistic
seasonality_mode: additive
n_changepoints: 4
changepoint_prior_scale: 0.02
cv:
folds: 12 # Количество фолдов
horizon: 90 # Прогноз на 90 дней вперёд
n_trials: 10 # Количество шагов оптимизации при mode='optimize'
step: 'M' # Расстояние между началом фолдов
# 'W', 'M', 'Q', 'Y' для прогнозов
# от НАЧАЛА календарной недели, месяца, квартала или года
# Также можно указать точное число дней, например 30
Огромный плюс такого конфига — в его тонкой настройке. Гиперпараметры рядов подбираются автоматически, но если нужно что‑то подправить руками, можно сделать это для любой гранулярности — например, только для конкретной метрики или конкретного ряда. По умолчанию параметры либо подбираются алгоритмом, либо наследуются от дефолтных из секции general.
Связь моделей с пайплайном
Окей, мы прописали все настройки в классе и конфиге модели, но как их запустить? Вся пошаговая логика прописана в файле forecast.py. Он принимает на вход класс модели, вызывает поочерёдно все функции алгоритма, кеширует прогнозы, строит графики. Упрощённо получается вот такая схема процесса:

Фишка в том, что фреймворку на вход подаётся именно класс, например знакомый нам class QueriesModel(ProphetModel)
. То есть нет необходимости даже создавать объекты класса — достаточно его самого. Все специфичные методы для него прописаны, конфиг настроен — остаётся только запустить и получить результат.
Итоги: зачем же мы придумали свой фреймворк
Когда прогнозов стало много, а ноутбуки размножались быстрее дедлайнов, команда собрала единый инструмент: запускаешь скрипт ‑ получаешь чистый и консистентный прогноз любой метрики без копипаста.
Лучшие практики внутри:
Дерево разбивок. Превращаем все срезы (Total → Device → OS) в иерархию и прогнозируем только листья. Суммы на верхних уровнях всегда сходятся, а новые срезы можно добавлять без переобучения.
Автоматическое комбинирование рядов. Сразу считаем нужные агрегаты (например, долю Яндекса как Яндекс‑запросы / все запросы). Относительные метрики получаются из абсолютных, поэтому они стабильнее и понятнее.
Очистка данных «auto + manual». etna вырезает выбросы, ruptures ловит сдвиги тренда; руками доочищаем COVID или Новый год. Чистка сохраняется и переиспользуется — модель всегда получает «здоровые» данные.
-
Интерпретируемый прогноз на Prophet с постпроцессингом. Prophet даёт тренд + сезонности, а сверху:
нормируем уровень и дисперсию, если прогноз не выходит из факта;
добавляем кастомные регрессоры, чтобы учесть особенности летней сезонности.
Тюнинг через optuna и скользящую CV. Параметры ищутся автоматически, причём без подсматривания в будущее — честная метрика на каждом фолде.
Кеш прогнозов. Уже посчитанный ряд повторно не прогнозируем. Точечная правка не заставляет пересчитывать всё — экономит часы.
Визуализация трёх слоёв. На одном графике: оригинальный факт, очищенный факт и прогноз (плюс backcast Prophet). Видно, где чистка сработала, где модель ошибается, и как ведут себя остатки.
Модульность «класс + YAML». Для каждой бизнес‑метрики — отдельный класс‑обёртка и YAML с гиперпараметрами, чисткой, графиками. Алгоритм общий, логика данных изолирована; новую метрику можно добавить за несколько десятков строк.
Что мы получили:
Скорость: типовой прогноз сократился с нескольких недель до пары рабочих дней.
Лёгкость проведения экспериментов: меняешь параметры в YAML — получаешь новый результат без переписывания пайплайна.
Консистентные агрегаты: Total всегда равен сумме подразделов.
Прозрачность для бизнеса: графики и разложение Prophet объясняют прогнозные цифры на выходе.
Результат
В итоге мы разработали именно то, что хотели, — гибкий фреймворк со множеством классных фич, который не зависит от бизнес‑логики задачи и способен прогнозировать любые ряды, и пользователь которого может тюнить прогноз и корректировать результаты под свои нужды.
Фреймворк сэкономил нам массу времени — по сравнению с тем периодом, когда мы делали всё на ноутбуках или одиночными скриптами. В частности, задачи регулярного обновления прогнозов, экспериментов с новыми моделями и анализа различных комбинаций метрик существенно упростились и ускорились. Например, теперь вместо того, чтобы несколько дней переписывать код вручную, достаточно изменить несколько параметров в конфиге. В результате по мере разработки фич прогнозный процесс отдельной задачи может сократиться с недели до одного дня!
Надеюсь, опыт нашей команды будет полезен читателям. Всегда рад обсудить детали в комментариях!
Комментарии (9)
proxy3d
24.07.2025 19:13Не понял только, почему нельзя использовать сетки? Это идеальная задача для Linoss-im (модификация SSM). Она улавливает и сезонности и затухания и все остальное. Идеально ложится на описание выше. Добавив в начале свёртки как в mamba, получим дополнительную очистку от шума.
mechupe Автор
24.07.2025 19:13Не пробовали такое, насколько она требовательна к качеству данных? Некоторые ряды могут быть крайне шумными
proxy3d
24.07.2025 19:13Ранее уже на Habr указывал про них. Это более развитая модель SSM
Статья про них: https://openreview.net/pdf?id=GRMfXcAAFhи
GitHub : https://github.com/tk-rusch/linoss/tree/main
По классу у них разделение:
S5 : Как пружина, которая быстро затухает.LinOSS-IM : Как маятник, который колеблется, но со временем останавливается.
LinOSS-IMEX : Как маятник без трения — колебания продолжаются бесконечно.
Насчет шума, то есть два решения. Либо как в Mamba добавить свертку вначале. Либо обернуть это в петлю гистерезис.
"Гомеостазом" тут называю гистерезис, так как описывал аналогию с биологией. 1) target 2) без гистерезис 3) с гистерезис Здесь писал про гистерезис. https://t.me/greenruff/2170
Видно, что гистерезис помогает избавиться от шума. Я ввел это понятие вместо residual связей.
Там раздел в PDF есть в конце про гистерезис. Но документ в целом надо переделывать. Но про сам гистерезис там верно.
https://t.me/c/1238949244/7769Сейчас я его изменил, но в изменения носят другой характер, связанный с предварительным преобразованием сигнала. Так что можно использовать этот, его результат выше на картинке для гамматон фильтров (обучение аудио фильтрам - аналогом слуха).
Гистерезис почти сразу подавляет шум. Его смысл заключается в том, что устойчивые сигналы проявление устойчивой асимметрии вероятности. То есть, если есть полезный сигнал, значит вероятности вмещаются относительно например 50/50. А значит, к примеру в какую-то сторону будет перекос. Следовательно, это можно уловить за счет ввода разной скорости роста и спада амплитудной, или частотной или фазовой. Выше как раз амплитудная. Шум в этом случае сразу подавляется, так как скорость спада у него больше, скорости роста. Оба параметра обучаемые.
Kryptonets
24.07.2025 19:13У ребят из NIXTLA есть модуль реконсиляции на Python для согласованности прогнозов на разных уровнях с минимизацией потери в точности прогнозирования.
Ravius
А может в open source?)
mechupe Автор
Пока таких планов нет, но учтем)