Oversimplified Time Series
Шпаргалка по временным рядам
Временной ряд - наблюдение во времени за каким-либо процессом или явлением
Привет, дорогой друг или подруга.
Сегодня мне хотелось бы изложить свои мысли по поводу временных рядов в максимально упрощённой, лишенной всяческой академичности форме.
Сразу предупреждаю, что всё сказанное тут является моим личным мнением, которое может содержать кучу неточностей, ошибок, допущений и упрощений. Если хочется точности и выверенности формулировок - это тебе к вузовским учебникам по математике, статистике и экономике.
А теперь, после того, как обязательный дисклеймер проговорён - начнём!
Что является необходимым признаком временного ряда? Ну, время же, скажете вы, и будите правы. Значит если у нас есть наблюдения за каким угодно явлением, событием или процессом, записанные в какие-то временные промежутки, это будет является временным рядом?
Технически - да.
Давайте рассмотрим два наблюдения. В первом случае будем наблюдать за ростом какого-то условного ребёнка, во втором случае будем наблюдать за курсом обмена USD (доллара американского) к CHF (франк швейцарский). Просто взглянув на графики, мы можем сказать, что красная линяя (рост ребёнка) постоянно поднимается вверх, что логично: люди до определённого возраста растут. Для этого графика мы легко можем предположить как процесс будет развиваться дальше, где примерно будет расположена следующая точка. Потому что ребёнок скорее всего не прекратит расти. Со вторым процессом всё несколько веселее.

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

Давайте применим эти элементы в деле.
Представим что у нас есть ряд: 1, 2, 3, 4, 5, х
Внимание, вопрос: чему скорее всего равен х?
Теперь другой ряд: 0, 0, 5, 0, 0, 5, 0, 0, у
Вопрос тот же: чему скорее всего равен у?
Если у вас х=6, а у=5 - поздравляю, вы отлично справились!
Причём в первом случае вы предсказали значение ряда при помощи авторегрессионной компоненты, а во втором при помощи сезонной. Круто? Круто!
"А зачем мне вообще могут быть полезны временные ряды?" - спросите вы.
Ну как зачем? Для предсказаний, конечно. Смотрите сами. Наиболее вероятный прогноз погоды на завтра - это то, что будет плюс-минус также как сегодня. *Но, несмотря на, это у нас есть жаркие лета и холодные зимы. Значит при предсказании погоды на завтра нам важно знать погоду сегодня (а ещё лучше знать и погоду, которая была вчера и позавчера и т.д. в глубину веков).
Когда мы смотрим на почти любой развивающийся процесс мы интуитивно понимаем, что предыдущее состояние системы важно.
Но что-то у меня затянулась вступительная часть, давайте ближе к магии и предсказаниям.

Модели предсказания временных рядов
ARIMA и всё что с ней связано
SARIMAX или SMA, а может быть ARMA? ARMA - это вроде стрелялка какая-то.
Схема не такая страшная, как можно подумать. Первое, что нужно заметить для того, чтобы пройти по схеме, нам нужно ответить на ряд вопросов, для этого нам пригодятся как методы статистического анализа (тест на стационарность, графики корреляции и пр.) так и общие знания о процессе и данных (глубина прогноза, сезонность, экзогенные переменные). Экзогенные переменные - просто внешние переменные которые могут влиять на процесс. Грубо говоря, ко временному ряду, отражающему покупки зонтов и дождевиков, мы добавим ряд, показывающий величину осадков.
Но самое главное на схеме это модели, давайте разбираться что это ещё за ARMA, ARIMA, SARIMAX.
Рассмотрим на примере SARIMAX итак буквы обозначают:
  • AR - autoregression - авторегрессионная компонента
  • I - integrated - интегрированная (данные продифференцированы)
  • MA - moving average - скользящее среднее
  • S - seasonal - сезонная компонента
  • X - exogenous variables - экзогенные переменные
Давайте чуть подробнее о каждом компоненте и, кстати, в гиперпараметрах этих моделей встречаются p, d, q, иногда даже P, D, Q, s, о них тоже скажем пару слов:
  • AR - говорит нам о том что каждое следующее значение ряда может быть получено путём простых математических манипуляций, проведённых с предыдущим членом. Ряд 1, 2, 3, 4, ? Ну 5 же! А почему? А потому, что каждый последующий - это предыдущий увеличенный 1. Эта компонента соответствует параметру p (ориентируемся на PACF)
  • I - говорит о том, что мы вместо исходных значений ряда используем разницу (diff) текущим и предыдущим значением ряда. (Сколько раз дифференцировали до достижения стационарности, такой параметр d и указываем)
  • MA - скользящее среднее, сглаживание биений и ошибок. Это параметр q (ориентируемся на ACF)
  • S - сезонная компонента, просто длительность сезона (через сколько штук наблюдений они "повторяются")
  • X - экзогенные переменные, жуть, которую используют в моделях семейства GARCH, о них чуть позже (возможно никогда)
  • Параметры P, Q, D по смыслу те же p, q, d но для сезонной составляющей
  • P представляет собой порядок сезонной авторегрессии, выведенный из PACF. В отличие от малого p, необходимо учитывать временной лаг, кратный длине сезона. Например, если продолжительность сезона 24, то на диаграмме pacf следует проверить интенсивность лагов 24,48,72. Если pacf последовательности с лагом 48 является значимым, то P равно 2.
  • Q ведёт себя аналогично, но выбираются через диаграмму ACF.
  • D представляет порядок сезонной разницы, которая обычно составляет 0 или 1.
В целом всё. Вся статья писалась ради картинки выше и объяснения этой картинки, но давайте всё же немного попрактикуемся.
Исходные данные - цена на акцию Норникеля в диапазоне 19.08.2023 - 17.11.2023
О чём думает почти каждый уважающий себя студент, впервые столкнувшись со временными рядами и возможностью выполнения предсказаний по ним?
"Составлю-ка я модель, предсказывающую цену на акцию и быстро и безжалостно разбогатею!"
Мы с вами не будем исключением и в качестве исходных данных возьмём цену на акцию Норникеля в диапазоне 19.08.2023 - 17.11.2023.
Можно было бы подушнить на тему, "если бы предсказания цены на акцию было таким простым - все бы этим занимались" и "невозможно точно предсказать значения какого-либо процесса опираясь только на знания о предыдущих значений данного процесса". Но зачем нам это? Мы хотим разбогатеть! А не душнить! Поехали.
Обзор данных, визуализация, тесты.
Читаем данные, строим график


import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

df = pd.read_csv('stock_price_nn.csv').set_index('Date')
plt.plot(df)
plt.xticks([1, 15, 30, 45, 60]) 
plt.show()
Построим коррелограммы ACF и PACF
Два слова о том, что это и как это читать.
AutoCorrelation Function - функция автокорреляции, показывает нам как сильно скоррелированы данные в рассматриваемом временном ряду. Как сильно каждое последующее значение коррелирует с предыдущим и пред-предыдущем и пред-пред-предыдущем и т.д. По данному графику мы можем сделать вывод о наличии тренда, сезонности, определить параметры p и q и много другое, но подробнее об этом в другой статье (пока справляйтесь гуглом или яндексом).
Partial AutoCorrelation Function - функция частичной автокорреляции, отличается она от ACF тем, что убирает влияние каждого предыдущего шага на последующий. Т.е. алгоритм примерно такой:
  1. Мы находимся в точке 0 (нулевой лаг - начало данных, для примера - "понедельник")
  2. Делаем шаг в 1 лаг, попадаем в следующее значение ("вторник")
  3. Считаем коэффициент корреляции между "вторником" и "понедельником", отмечаем его на графике. (На данном этапе ACF и PACF - идентичны)
  4. Делаем шаг в ещё 1 лаг, попадаем в "среду", ACF и PACF уже не идентичны, потому что
  5. ACF считает корреляцию "среды" с "понедельником", PACF считает корреляцию "среды" с "понедельником", устраняя корреляцию "вторника" (устраняется влияние "вторника" чуть сложнее обычной разницы, но об этом я никогда не расскажу когда-нибудь в другой раз. Получившиеся значения корреляции откладываются на график.
  6. Повторяем шаг 5 пока данные не закончатся.
Для нас важно знать, что:
  • Лаги считаются с нуля (первый столбик - корреляция значения с самим собой = 1)
  • Светло синяя область - доверительный интервал случайности (всё, что попало внутрь этой области, статистически не значимо, по умолчанию с 95% вероятностью)
  • По ACF определяем параметр q как максимально далёкий от лага 0 статистически значимый лаг (в нашем случае q = 5)
  • По PACF определяем параметр p идентично тому, как мы определяли q (в нашем случае p = 1, но можно глянуть в сторону p = 7)

import statsmodels.graphics.tsaplots as sgt

def plot_acf_pacf(series):
    plt.rcParams["figure.figsize"] = 6, 9

    fig, axes = plt.subplots(2, 1)

    sgt.plot_acf(series, ax=axes[0])
    sgt.plot_pacf(series, ax=axes[1], method='ywm')
    plt.show()

plot_acf_pacf(df)
Осталось определить параметр d.
Его мы будем определять по порядку дифференцирования, при котором достигнута стационарность временного ряда. Опять же, пара слов об этом.
Дифференцированием временного ряда называется замена значений временного ряда на разницы между значениями этого ряда. Т.е. значение исходного "понедельника" мы заменим на "понедельник_ - "вторник", исходного "вторника" на "вторник" - "среда" и т.д.
Надо различать два понятия: порядок дифференцирования и шаг дифференцирования. Под порядком дифференцирования следует понимать сколько раз мы проводили дифференцирование к примеру:
  • в случае .diff() - мы получим 1 -ю разницу, порядок дифференцирования d = 1
  • .diff().diff() - мы получили разницу разницы, порядок дифференцирования d = 2
  • .diff().diff().diff() - мы получили разницу разницы разницы, d = 3
  • и так далее, пока не надоест (или не будет достигнута стационарность временного ряда, забегая вперёд, скажу, что стационарность вполне может быть не достигнута никогда, но о этом я никогда не расскажу когда-нибудь в другой раз.
Под шагом дифференцирования следует принимать указание на то, между какими значениями мы вычисляем разницу (по умолчанию мы вычисляем разницу между текущим и предыдущим значениями или текущим и последующим, главное, что между ближайшими).
Важно, что при указании .diff(2) мы не получим 2-ой дифферент, мы не дважды продифференцируем ряд, мы значение исходного "понедельника" заменим на "понедельник_ - "среду", исходного "вторника" на "вторник" - "четверг" и т.д.
Для определения параметра d важно, сколько раз, а не между какими.

Итак, проведём тест на стационарность (предсказуемость) временного ряда и определим параметр d:

from statsmodels.tsa.stattools import adfuller

stats = adfuller(df) # Исходный ряд
print("adf: ", stats[0])
print("p-value: ", stats[1])
print("Critical values: ", stats[4])
if stats[0] > stats[4]["5%"]:
    print("ряд нестационарен")
else:
    print("ряд стационарен")

#adf:  -1.539
#p-value:  0.513
#Critical values:  {'1%': -3.533, '5%': -2.906, '10%': -2.590}
#ряд нестационарен
    
stats = adfuller(df.diff().dropna()) # 1-й дифферент
print("adf: ", stats[0])
print("p-value: ", stats[1])
print("Critical values: ", stats[4])
if stats[0] > stats[4]["5%"]:
    print("ряд нестационарен")
else:
    print("ряд стационарен")

#adf:  -7.988
#p-value:  0.000
#Critical values:  {'1%': -3.535, '5%': -2.907, '10%': -2.591}
#ряд стационарен
Проведя такие нехитрые манипуляции, мы пришли к таким стартовым параметрам:
  • p = 1
  • d = 1
  • q = 5
Разобьём данные на трейн и тест, и начнём моделировать:

train = df[:-15]
test = df[-15:]
filler = df[-16:-14]

plt.plot(pd.concat([train, filler]))
plt.plot(test)
plt.xticks([1, 15, 30, 45, 60]) 
plt.show()
Использовать для моделирования будем statsmodels.tsa.statespace.sarimax.SARIMAX()
Как было показано на картинке выше, эта модель, в зависимости от переданных в неё параметров, может быть "чистой" AR, и ARMA, и ARIMA.
Главными для нас являются параметры order = (p, d, q) и seasonal_order = (P, D, Q, s).
Давайте попробуем использовать параметры, определённые нами выше:


from statsmodels.tsa.statespace.sarimax import SARIMAX

model = SARIMAX(train, order=(1, 1, 5), seasonal_order=(0, 0, 0, 0)).fit(maxiter=100)
pred = model.predict(start=test.index[0], end=test.index[-1])
test['pred'] = pred

plt.plot(pd.concat([train, filler]))
plt.plot(test)
plt.xticks([1, 15, 30, 45, 60]) 
plt.show()
Неплохо, посмотрим поближе


plt.plot(pd.concat([train, filler]))
plt.plot(test)
plt.xticks([47, 52, 57]) 
plt.xlim('2023-10-20', '2023-11-09')
plt.ylim(17000, 18250)
plt.grid()
plt.show()
В долгосрочной перспективе модель, конечно, не очень точна, но как минимум 2 лага попали хорошо.
Теперь можно пойти двумя путями, либо перебирать значения p, d, q, P, D, Q, s, дабы заставить модель быть "более точной". Подбирать можно методом трёх "П" (палец-пол-потолок), либо подключить инструменты автоматического подбора, они не гарантируют улучшение результата, к примеру pmdarima.arima.auto_arima() предложит нам использовать следующие параметры:


import pmdarima as pm

smodel = pm.auto_arima(
    train,
    start_p=1,
    d=1,
    start_q=1,
    max_p=5,
    max_q=5,
    D=1,
    start_P=0,
    m=5,
    test="adf",
    error_action="ignore",
    trace=True,
)

smodel.summary()
Что в целом неплохо, но долгосрочная перспектива всё ещё страдает. Можно продолжить подбор и добиться большей похожести на исходные данные, но предсказательная сила, да и в целом полезность этой модели сильно упадёт.
Важно не гнаться за идеальным сходством с тестовым участком, а сохранить предсказательную ценность модели. Проверить эту ценность просто: давайте подвинем точку теста вперёд назад по данным и посмотрим, как поведёт себя модель.


for i in range(27):
    train = df[:-30+i]
    test = df[-30+i:]
    filler = df[-31+i:-29+i]
    model = SARIMAX(train, order=(0, 1, 0), seasonal_order=(0, 1, 1, 5)).fit(maxiter=100)
    pred = model.predict(start=test.index[0], end=test.index[3])
    test['pred'] = pred

    plt.plot(pd.concat([train, filler]))
    plt.plot(test)
    plt.show()
Как можно заметить, с этапом роста модель справляется нормально, а вот перестроиться на падение цены модели так и не хватило данных (времени). Что же делать дальше?
Вариантов много, можно дальше перебирать параметры p, d, q, P, D, Q, s. Можно попробовать перебор остальных гиперпараметров модели. Скажем, можно обратить внимание на гиперпараметр exog = None: array_like, optional, который отвечает за гетероскедастичность (неоднородность дисперсии ошибок). Если очень коротко, то гетероскедастичные переменные - это переменные, которые имеют воздействия на рассматриваемый временно ряд. Скажем температура воздуха - гетероскедастичная переменная для графика продажи мороженного.
Или можно доработать модель в сторону постоянной подстройки. Т.е. не пытаться предсказать значение стоимости акций на сто лет вперёд, а сконцентрироваться на предсказании на следующий торговый день (от силы на два дня). И будет нам счастье и радость.

Так. А если я чуть более ленивый?
Есть что-то попроще?
Prophet - ПО с открытым исходным кодом, выпущенное командой Facebook CDS.
Prophet -библиотека для прогнозирования временных рядов для R и Python от Facebook’s Core Data Science team. Говорят, она даёт неплохие default'ные прогнозы и обладает простым и понятным синтаксисом.
Let’s check it out!

Установим библиотеку и подготовим наши данные для работы в ней.


!pip install prophet

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from prophet import Prophet

df = pd.read_csv('stock_price_nn.csv')
idx = pd.to_datetime(df['Date'])
df = pd.DataFrame(df['Value']).set_index(idx)
print(df.shape) #(67, 1)
df = df.asfreq(freq='D')
df = df.interpolate(method='linear')
print(df.shape) #(93, 1)

df.reset_index(inplace= True)
df.columns = ['ds', 'y'] #требование prophet
train = df[:-20]
test = df[-20:]
filler = df[-21:-19]

plt.plot(pd.concat([train, filler]).y)
plt.plot(test.y)
plt.grid()
plt.show()
Учим модель на трейне делаем предсказание на тесте


model = Prophet()
model.fit(train)

future = model.make_future_dataframe(periods=test.shape[0])
forecast = model.predict(future)

print(', '.join(forecast.columns))

# ds, trend, yhat_lower, yhat_upper, trend_lower, trend_upper, additive_terms,
# additive_terms_lower, additive_terms_upper, weekly, weekly_lower, weekly_upper, 
# multiplicative_terms, multiplicative_terms_lower, multiplicative_terms_upper, yhat
По умолчанию forecast сделает предсказания и заполнит таблицу со столбцами (ds, trend, yhat_lower, yhat_upper, trend_lower, trend_upper, additive_terms, additive_terms_lower, additive_terms_upper, weekly, weekly_lower, weekly_upper, multiplicative_terms, multiplicative_terms_lower, multiplicative_terms_upper, yhat)
Нас будут интересовать:
  • yhat - предсказанное значение
  • yhat_lower - нижняя граница доверительного интервала
  • yhat_upper - верхняя граница доверительного интервала
Соединим с исходными данными и отрисуем результат.
"Фу! Какая гадость" - скажите вы.
"Погодите-погодите" - отвечу я.
SARIMAX на долгосрочную перспективу, также, убегала куда-то ввысь. Давайте попробуем реализовать прогноз на 3 шага вперёд и потихоньку пойдём по данным - посмотрим сможет ли prophet перестроится на падение (напомню, SARIMAX так и не смогла, не смирилась с тем что акции Нориникеля могу падать).

for i in range(10, df.shape[0], 5):
  batch = df.iloc[:i]
  model = Prophet()
  model.fit(batch)
  future = model.make_future_dataframe(periods=3)
  forecast = model.predict(future)
  cmp_df = forecast.set_index('ds')[['yhat', 'yhat_lower', 'yhat_upper']].join(df.set_index('ds'))
  plt.plot(df.set_index('ds'), label='real')
  plt.plot(cmp_df.yhat_lower, alpha=0.5, label='lower')
  plt.plot(cmp_df.yhat_upper, alpha=0.5, label='upper')
  plt.plot(cmp_df.yhat, label='predicted')
  plt.grid()
  plt.legend()
Ого-го - вот это уже разговор, модель распознала падение и успела перестроиться.
И всё это, попрошу заметить, без указания всяких там ваших p, d, q.
Однозначный лайк!
Точно стоит углубиться в возможности данной библиотеки когда-нибудь позже.
Вместо вывода. Временные ряды - крутая вещь, особенно если с её помощью не пытаться победить рынок.
Прогнозирование поведения временных рядов - очень глубокая и интересная тема. Настолько глубокая, что вылилась в отдельную науку - эконометрику. И если вам хочется погрузиться в математические обоснования и усложнять предсказательные модели "по уму", милости просим в специализированные учебники.
Я же попытался в максимально упрощённой форме подсветить основные идеи и методы, используемые при анализе и предсказание временных рядов. Если суммировать эту статью, то можно сказать:
  • Не все процессы, протекающие во времени, являются временными рядами.
  • То, что всё-таки является временным рядом, совсем не обязано поддаваться предсказанию с удовлетворительной точностью.
  • Зачастую действительно важно знать предыдущее состояние системы для анализа и построения прогноза, но интегрируя данные знания в модель (добавляя авторегрессионную компоненту), нужно быть очень аккуратным и обязательно регулировать степень воздействия этой компоненты на модель, в противном случае вся модель сведётся к подсчёту авторегрессии.
Вам понравилась эта статья?