Все дело в особенностях

Когда мы думаем об усиленных деревьях в пространстве временных рядов, мы обычно имеем в виду соревнование M5, где значительная часть десяти лучших записей использовала LightGBM. Однако, если посмотреть на производительность усиленных деревьев в одномерном случае, когда нет множества экзогенных функций, которые можно было бы использовать, их производительность была… грубой.

До настоящего времени.

Раздел 1: Введение

Во-первых, я должен сделать несколько предостережений. Занявший второе место в соревновании M4 ДЕЙСТВИТЕЛЬНО использовал усиленные деревья. Однако это была метамодель для объединения других, более традиционных методов временных рядов. Все бенчмарки, которые я видел на M4 с более стандартными усиленными деревьями, были довольно ужасными, иногда даже не конкурируя с наивными прогнозистами. Основным ресурсом, который я здесь использую, является отличная работа, проделанная для пакета Sktime и их бумаги[1]:

Любая модель с «XGB» или «RF» использует ансамбль на основе дерева. Мы видим один пример, когда Xgboost обеспечивает лучший результат в почасовом наборе данных с 10,9! Затем мы понимаем, что это только те модели, которые они пробовали в своей структуре, и победитель M4 опубликовал 9,3 для того же набора данных…

Попытайтесь запомнить некоторые числа из этого графика на потом, в частности 10,9 для почасового набора данных из XGB-s и «лучшие» результаты для деревьев в недельном наборе данных: 9,0 из RF-t-s.

Наша цель — полностью разбить эти цифры с помощью быстрой процедуры LightGBM, которая подходит для отдельных временных рядов и сравнима со статистическими методами с точки зрения скорости.

Так что нет времени на оптимизацию.

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

Но все дело в характеристиках.

Раздел 2: Особенности

При рассмотрении других реализаций деревьев в одномерном пространстве вы увидите некоторые инженерные функции, такие как объединение в бины, использование запаздывающих значений вашей цели, простой счетчик, сезонные фиктивные значения и, возможно, базисные функции Фурье здесь и там. Все это замечательно, если вы хотите пожалеть об установке усиленных деревьев и пожелать, чтобы вместо этого вы использовали экспоненциальное сглаживание. Суть в том, что мы должны выделить наш элемент времени и представить его в виде табличных данных для подачи в дерево, и наша реализация LazyProphet не является исключением. Но у нас есть один дополнительный элемент разработки функций, которого я нигде не видел (хотя он довольно прост, так что в этом нет ничего нового).

Мы… «соединим точки».

Проще говоря, мы соединяем первую точку нашего ряда и проводим линию с другой точкой на полпути, а затем соединяем эту точку с конечной точкой. Повторите это пару раз, меняя точку, которую следует использовать в качестве «излома», и вы в деле.

Надеюсь, этот график хорошо это иллюстрирует. Синяя линия — это временной ряд, а остальные линии просто «соединяют точки»:

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

Вот и все. Добавьте некоторые из этих плохих парней вместе с запаздывающими целевыми значениями и базисными функциями Фурье, и вы сделали это! Почти самая современная производительность для определенных задач, и от нас требуется очень мало, отсюда и название «LazyProphet».

Но давайте получим некоторые результаты, подтверждающие это.

Раздел 3: Кодекс

Все наборы данных с открытым исходным кодом и живут на M-соревнованиях github. Он разделен на стандартные поезда и тесты, поэтому мы будем использовать CSV-файл поезда для подбора, а тестовый CSV-файл — только для оценки с помощью SMAPE. Давайте продолжим и импортируем данные вместе с LazyProphet, если вы еще не установили его, возьмите его из pip.

pip install LazyProphet

После установки приступаем к кодированию:

import matplotlib.pyplot as plt
import numpy as np
from tqdm import tqdm
import pandas as pd
from LazyProphet  import LazyProphet as lp

train_df = pd.read_csv(r'm4-weekly-train.csv')
test_df = pd.read_csv(r'm4-weekly-test.csv')
train_df.index = train_df['V1']
train_df = train_df.drop('V1', axis = 1)
test_df.index = test_df['V1']
test_df = test_df.drop('V1', axis = 1)

Здесь мы просто импортируем все необходимые пакеты и считываем данные как стандартные кадры данных для еженедельных данных. Затем давайте создадим нашу функцию SMAPE, которая будет возвращать SMAPE с учетом прогнозов и фактических данных:

def smape(A, F):
    return 100/len(A) * np.sum(2 * np.abs(F - A) / (np.abs(A) +       np.abs(F)))

Для нашего эксперимента мы возьмем среднее значение по всем временным рядам для сравнения с другими моделями. Для проверки работоспособности мы также получим «наивный» средний SMAPE, чтобы убедиться, что то, что мы делаем, соответствует тому, что было сделано на соревнованиях. С учетом сказанного, мы просто будем перебирать фрейм данных, лениво подгонять и прогнозировать. Код можно было бы оптимизировать, не выполняя цикл for, но это сойдет!

smapes = []
naive_smape = []
j = tqdm(range(len(train_df)))
for row in j:
    y = train_df.iloc[row, :].dropna()
    y_test = test_df.iloc[row, :].dropna()
    j.set_description(f'{np.mean(smapes)}, {np.mean(naive_smape)}')
    lp_model = LazyProphet(scale=True,
                            seasonal_period=52,
                            n_basis=10,
                            fourier_order=10,
                            ar=list(range(1, 53)),
                            decay=.99,
                            linear_trend=None,
                            decay_average=False)
    fitted = lp_model.fit(y)
    predictions = lp_model.predict(len(y_test)).reshape(-1)
    smapes.append(smape(y_test.values,      pd.Series(predictions).clip(lower=0)))
    naive_smape.append(smape(y_test.values, np.tile(y.iloc[-1], len(y_test))))  
print(np.mean(smapes))
print(np.mean(naive_smape))

Прежде чем мы посмотрим на результаты, давайте быстро взглянем на параметры LazyProphet.

  1. scale:Это просто, нужно просто масштабировать данные. По умолчанию установлено значение True, поэтому здесь мы просто указываем явно.
  2. seasonal_period: Этот параметр управляет базисными функциями Фурье для сезонности, так как это еженедельная частота, мы используем 52.
  3. n_basis: Этот параметр управляет нашими запатентованными взвешенными кусочно-линейными базисными функциями. Это просто целое число для количества используемых функций.
  4. fourier_order: количество пар синусов и косинусов, используемых для определения сезонности.
  5. ar: Какие запаздывающие значения целевых переменных использовать. Можно взять список за несколько, и мы просто передаем список от 1 до 52.
  6. decay: Наш коэффициент затухания, используемый для штрафа за «правую» часть наших базисных функций. Значение 0.99 означает, что наклон умножается на (1-0,99) или 0,01.
  7. linear_trend: Одним из основных недостатков деревьев является то, что они не могут экстраполировать данные за пределы предыдущих данных. Я уже говорил об этом? Да, это может быть огромной проблемой. Чтобы преодолеть это, существуют некоторые импровизированные тесты для полиномиального тренда, и если он обнаруживается, мы подгоняем линейную регрессию для устранения тренда. Прохождение None означает, что будут тесты, прохождение True означает ВСЕГДА удаление тренда, а прохождение False означает НЕ тестировать и никогда не использовать линейный тренд.
  8. decay_average: бесполезный параметр при использовании скорости затухания. В основном это странная темная магия, которая творит странные вещи. Попробуйте! Но не используйте его. Передача True по сути просто усредняет все будущие значения базовой функции. Это было полезно при настройке с помощью процедуры эластичной сети, но менее полезно с LightGBM в моем тестировании.

Прежде чем мы перейдем к результатам, давайте продолжим и подойдем к почасовому набору данных:

train_df = pd.read_csv(r'm4-hourly-train.csv')
test_df = pd.read_csv(r'm4-hourly-test.csv')
train_df.index = train_df['V1']
train_df = train_df.drop('V1', axis = 1)
test_df.index = test_df['V1']
test_df = test_df.drop('V1', axis = 1)
smapes = []
naive_smape = []
j = tqdm(range(len(train_df)))
for row in j:
    y = train_df.iloc[row, :].dropna()
    y_test = test_df.iloc[row, :].dropna()
    j.set_description(f'{np.mean(smapes)}, {np.mean(naive_smape)}')
    lp_model = LazyProphet(seasonal_period=[24,168],
                            n_basis=10,
                            fourier_order=10,
                            ar=list(range(1, 25)),
                            decay=.99)
    fitted = lp_model.fit(y)
    predictions = lp_model.predict(len(y_test)).reshape(-1)
    smapes.append(smape(y_test.values, pd.Series(predictions).clip(lower=0)))
    naive_smape.append(smape(y_test.values, np.tile(y.iloc[-1], len(y_test))))  
print(np.mean(smapes))
print(np.mean(naive_smape))

Хорошо, все, что мы действительно изменили, это аргументы seasonal_period и ar. При передаче списка в seasonal_period будут построены сезонные базисные функции для всего в списке. ar был скорректирован, чтобы соответствовать новому основному сезонному периоду 24. Вот и все!

Раздел 4. Результаты

Помните результаты Sktime сверху? Вам на самом деле не нужно, вот таблица:

Таким образом, LazyProphet превзошел лучшие модели Sktime, которые включали несколько различных методов на основе дерева. Мы проиграли победителю M4 в почасовом наборе данных, но в среднем мы фактически превосходим ES-RNN в целом. Здесь важно понимать, что мы сделали это с параметрами по умолчанию…

boosting_params = {
                    "objective": "regression",
                    "metric": "rmse",
                    "verbosity": -1,
                    "boosting_type": "gbdt",
                    "seed": 42,
                    'linear_tree': False,
                    'learning_rate': .15,
                    'min_child_samples': 5,
                    'num_leaves': 31,
                    'num_iterations': 50
                    }

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

Но давайте сравним наши результаты с нашими целями:

  1. Мы провели оптимизацию по нулевым параметрам (с небольшими модификациями для разных сезонностей).
  2. Мы подходим к каждому временному ряду индивидуально.
  3. Мы создавали прогнозы «лениво» чуть более чем за минуту на моей локальной машине.
  4. Мы превзошли все остальные методы дерева из нашего теста, черт возьми, мы даже превзошли победителя M4 в среднем.

Я бы сказал, что мы довольно успешно!

Вы можете спросить: «Где результаты других наборов данных?»

К сожалению, наш успех был недолгим. В других наборах данных, как правило, было гораздо меньше данных, поэтому наш метод, как правило, значительно снижал производительность. Из того, что я могу сказать, LazyProphet имеет тенденцию сиять высокой частотой и приличным объемом данных. Конечно, мы могли бы попытаться подогнать все временные ряды к одной модели LightGBM, но мы можем приберечь это до следующего раза!

Поскольку мы просто используем LightGBM, вы можете изменить цель и попробовать классификацию временных рядов! Или используйте квантильную цель для прогнозных границ! Много классных вещей, которые можно попробовать.

Если вы нашли это интересным, я рекомендую вам ознакомиться с другим моим взглядом на соревнование M4 с другим домашним методом: ThymeBoost.







Ссылки:

[1] Маркус Лёнинг, Франц Кирай: «Прогнозирование с помощью sktime: разработка нового API прогнозирования sktime и его применение для воспроизведения и расширения исследования M4, 2020; архив: 2005.08067»