Данные временных рядов чрезвычайно распространены в современной практике науки о данных. Одним из наиболее ярких примеров этого являются биржевые данные, временные ряды, которые определяют большую часть современной экономической жизни. В этом посте мы попытаемся обучить одномерную модель глубокого обучения на временных рядах и посмотрим, можно ли прогнозировать дневные цены закрытия в течение пятидневных окон.
Во-первых, давайте разобьем эту проблему на части:
- Извлеките и преобразуйте наш набор данных. Мы будем использовать финансовый API Yahoo для получения необработанных данных. Оттуда нам нужно преобразовать наши данные в массив NumPy с формой [(len(data) / timestep, timestep, #features] (мы поймем, почему это необходимо, когда начнем строить наши модели).
- Нам нужно превратить нашу проблему временных рядов в задачу контролируемого обучения. Для этого мы создадим массив NumPy обучающих функций и обучающих меток. Однако, поскольку наши данные представляют собой просто необработанные цены закрытия, обучающими функциями будут цены в момент времени i, а обучающими метками будут цены в момент времени i+1.
- Мы создадим функции для прогнозирования.
- Далее мы создадим метрики оценки. Для этой проблемы я предпочитаю использовать среднюю абсолютную процентную ошибку (MAPE) для оценки наших прогнозов.
- Мы создадим функцию для визуализации наших результатов. Для этого мы просто сопоставим нашу базовую правду (фактические цены закрытия) с нашими прогнозами.
- И, наконец, мы создадим, скомпилируем и оценим несколько архитектур моделей, чтобы определить, какая из них лучше.
Теперь, когда у нас есть дорожная карта, давайте приступим к строительству!
Зависимости
Вот что нам понадобится для этого эксперимента:
import pandas as pd import numpy as np import yfinance as yf import tensorflow as tf from sklearn.metrics import mean_absolute_percent_error from keras.models import Sequential from keras.layers import Dense from keras.layers import LSTM import matplotlib.pyplot as plt from keras.layers.convolutional import Conv1D from keras.layers.convolutional import MaxPooling1D from keras.layers import Flatten from keras.layers import TimeDistributed from keras.layers import RepeatVector
Извлечь и загрузить
Поскольку нам нужно как можно больше данных для этой задачи, нам нужно будет извлечь исторические данные. К счастью для нас, в API финансов Yahoo есть простой, написанный на языке Python способ добиться этого:
def extract_historic_data(ticker, period='max') -> pd.Series: """ gets historical data from yf api. """ t = yf.Ticker(ticker) history = t.history(period='max') return history.Close
Здесь history
— это кадр данных pandas с индексом даты и времени и столбцами Open, High, Low, Close, Adj Close и Volume, но нас интересуют только цены закрытия.
Теперь, когда мы извлекли наши данные, пришло время подготовить их для глубокого обучения. Здесь нам нужно сделать кое-какой выбор. Для целей этого эксперимента я решил прогнозировать цены закрытия на следующую неделю (пятидневные окна), поскольку акции торгуются с понедельника по пятницу, но не в выходные дни. Однако мы напишем общую функцию, которая позволит пользователю настраивать timestep
по своему усмотрению:
def window_and_reshape(data, timestep) -> np.array: """ Reformats data into shape our model needs, namely, [# samples, timestep, # feautures] """ NUM_FEATURES = 1 samples = int(data.shape[0] / timestep) result = np.array(np.array_split(data, samples)) return result.reshape((samples, timestep, NUM_FEATURES))
Однако вы заметите, что если вы запустите этот код на наших необработанных данных, так это то, что если длина нашей серии pandas не делится на samples
, функция выдаст ошибку. Чтобы предотвратить это, мы создадим функцию-оболочку, которая отбрасывает остаток, отбрасывая самые старые данные:
def transform(train, test, timestep=5) -> tuple[np.array, np.array]: train_remainder = train.shape[0] % timestep test_remainder = test.shape[0] % timestep if train_remainder != 0 and test_remainder != 0: train = train[train_remainder:] test = test[test_remainder:] elif train_remainder != 0: train = train[train_remainder:] elif test_remainder != 0: test = test[test_remainder:] return window_and_reshape(train, timestep), window_and_reshape(test, timestep)
Наконец, мы объединим все это с помощью функции etl
.
def etl(ticker) -> tuple[np.array, np.array]: """ Runs complete ETL """ data = extract_historic_data(ticker) train, test = split_data(data) return transform(train, test)
Итак, теперь у нас есть все наши данные, извлеченные и преобразованные в правильную форму. Пришло время создать наборы X_train
, y_train
, чтобы мы могли использовать обучение с учителем, чтобы научить модель прогнозировать цены закрытия.
Преобразование задачи временных рядов в задачу контролируемого обучения
Чтобы использовать контролируемое обучение, нам нужны функции и метки. Однако на данный момент все, что у нас есть, это цены закрытия. Фактически, мы используем цены закрытия для прогнозирования цен закрытия. Итак… цены закрытия – это наши особенности… и наши лейблы?? Да! Говоря более строго, предыдущие цены — это наши признаки, а будущие цены — это наши метки, или цены на временном шаге i — это наши признаки, а цены на временном шаге i+1 — наши метки.
Но одна из критических проблем с биржевыми данными заключается в том, что их не так много. Это может показаться безумием, в конце концов, акции существуют с 17 века, что вы имеете в виду под недостатком данных? Ну, во-первых, вы не можете извлечь данные для голландской Ост-Индской компании из финансового API Yahoo (насколько мне известно). И большинство компаний не торгуют так долго. Быстрый поиск в Google подтвердит, что, по оценкам McKinsey & Company, средняя продолжительность жизни публичных компаний составляет менее 18 лет. Наконец, модели, которые мы будем обучать, требуют много данных. Соедините все это вместе, и нам понадобятся все данные, которые мы сможем получить. Таким образом, вместо того, чтобы создавать непересекающиеся 5-дневные окна, мы создадим перекрывающиеся, скользящие 5-дневные окна. Итак, вот функция, которая делает именно это: создает перекрывающиеся скользящие окна, где y_train
— это цены на следующие пять дней, а X_train
— цены на предыдущие пять дней.
def to_supervised(train, n_input=5, n_out=5) -> tuple[np.array, np.array]: """ Converts our time series prediction problem to a supervised learning problem. """ # flatted the data data = train.reshape((train.shape[0]*train.shape[1], train.shape[2])) X, y = [], [] in_start = 0 # step over the entire history one time step at a time for _ in range(len(data)): # define the end of the input sequence in_end = in_start + n_input out_end = in_end + n_out # ensure we have enough data for this instance if out_end <= len(data): x_input = data[in_start:in_end, 0] x_input = x_input.reshape((len(x_input), 1)) X.append(x_input) y.append(data[in_end:out_end, 0]) # move along one time step in_start += 1 return np.array(X), np.array(y)
Теперь, когда мы правильно сформулировали нашу задачу для обучения с учителем, давайте напишем наши функции прогнозирования и оценки.
Прогнозы и оценки
Для аспекта прогнозов нам понадобятся две функции: одна, которая берет все предыдущие ценовые данные и прогнозирует одно 5-дневное окно, а другая, которая прогнозирует длину всего нашего тестового набора.
def forecast(model, history, n_input=5) -> np.array: """ Given last weeks actual data, forecasts next weeks prices. """ # flatten data data = np.array(history) data = data.reshape((data.shape[0]*data.shape[1], data.shape[2])) # retrieve last observations for input data input_x = data[-n_input:, 0] # reshape into [1, n_input, 1] input_x = input_x.reshape((1, len(input_x), 1)) # forecast the next week yhat = model.predict(input_x, verbose=0) # we only want the vector forecast yhat = yhat[0] return yhat def get_predictions(train, test, model, n_input) -> np.array: """ compiles models predictions week by week over entire test set. """ # history is a list of weekly data history = [x for x in train] # walk-forward validation over each week predictions = [] for i in range(len(test)): yhat_sequence = forecast(model, history, n_input) # store the predictions predictions.append(yhat_sequence) # get real observation and add to history for predicting the next week history.append(test[i, :]) return np.array(predictions)
Точно так же для оценок мы будем получать еженедельные оценки MAPE и общие MAPE:
def evaluate_model_with_mape(actual, predicted) -> tuple: overall = mean_absolute_percentage_error(actual.flatten(), predicted.flatten()) weekly_scores = [] for i in range(len(actual)): mape = mean_absolute_percentage_error(actual[i], predicted[i]) weekly_scores.append(mape) return np.array(overall), np.array(weekly_scores)
Теперь нам просто нужна функция визуализации, и тогда мы готовы к обучению!
Визуализация
Эта функция довольно проста, поэтому я просто оставлю ее здесь:
def plot_results(test, preds, df, ylabel='AAPL stock Price') -> None: """ Plots training data in blue, actual values in red, and predictions in green, over time. """ fig, ax = plt.subplots(figsize=(20,6)) plot_test = test[1:] plot_preds = preds[1:] x = df[-(plot_test.shape[0]*plot_test.shape[1]):].index # df can be the pd.Series of closing prices plot_test = plot_test.reshape((plot_test.shape[0]*plot_test.shape[1], 1)) plot_preds = plot_preds.reshape((plot_test.shape[0]*plot_test.shape[1], 1)) ax.plot(x, plot_test, label='actual') ax.plot(x, plot_preds, label='preds') ax.set_xlabel('Date') ax.set_ylabel(ylabel) ax.legend() plt.show()
Самое интересное: обучение некоторых моделей
Фу. Мы сделали это. Мы настроили все вспомогательные функции, которые понадобятся нам после обучения наших моделей, теперь пришло время поговорить об архитектуре. Как Ван и др. отмечают в своей статье Изучение нестационарных временных рядов с помощью динамического извлечения шаблонов,рекуррентные нейронные сети и, в частности, модели с долговременной краткосрочной памятью (LSTM), довольно хорошо справляются с финансовыми временными рядами. В случае вышеупомянутых авторов эти модели были применены к данным форекс, но у меня есть подозрение, что эти модели будут прилично предсказывать и наши биржевые данные, поэтому давайте начнем с этого.
Мы начнем с создания модели с одним слоем LSTM и парой слоев Dense:
def build_model(train, n_input=5) -> tf.keras.Model: """ Compiles and fits an LSTM model. """ # prepare the data X_train, y_train = to_supervised(train) # define params VERBOSE, NUM_EPOCHS, BATCH_SIZE = 0, 3, 30 # if you want to see it train, set VERBOSE=1 n_timesteps, n_features, n_outputs = X_train.shape[1], X_train.shape[2], y_train.shape[1] # define model model = Sequential() model.add(LSTM(200, activation='relu', input_shape=(n_timesteps, n_features))) model.add(Dense(100, activation='relu')) model.add(Dense(n_outputs)) # compile the model print('compiling model....') model.compile(optimizer='adam', loss='mse') print("fitting model....") # fit the model model.fit(X_train, y_train, epochs=NUM_EPOCHS, batch_size=BATCH_SIZE, verbose=VERBOSE, callbacks=[early_stopping]) print('model is fit....') return model
Теперь давайте посмотрим, обучаем ли мы нашу модель на данных Apple (AAPL). Давайте сначала посмотрим на нашу визуализацию:
Если мы посмотрим на наши оценки, у нас будет MAPE примерно 6%, а с точки зрения еженедельного MAPE наша лучшая неделя у нас будет MAPE 0,4%, что действительно хорошо, но наша худшая неделя была MAPE 20%. И, как показывает наш график, наши прогнозы, особенно в конце тестового периода, очень нестабильны. То есть наши прогнозы колеблются заметно больше, чем истинные цены закрытия. Таким образом, несмотря на то, что эта модель удивительно эффективна для своей простоты, мы можем добиться большего.
Во-первых, давайте проверим, является ли это чистой проблемой отсутствия данных. Мы сократим тестовый период и посмотрим, станет ли лучше. Вместо 20-процентного разделения давайте сделаем 10-процентное разделение. Вот наша новая визуализация:
Как мы видим выше, нам удалось сделать наши прогнозы более гладкими, однако наши оценки MAPE на самом деле не сильно изменились. Наш худший недельный прогноз снизился с 20% до 17%, но наша общая MAPE осталась неизменной на уровне 6%, и наша лучшая недельная MAPE также практически не изменилась. Таким образом, большее количество данных приводит к более гладким прогнозам, но нам понадобится больше, чем просто данные, если мы хотим, чтобы наши прогнозы были действительно хорошими (то есть действенными).
Давайте попробуем другую архитектуру. Мы собираемся добавить слои Convolutional Neural Net на переднюю часть нашей модели. Эти слои хорошо распознают тонкие закономерности в наборах данных (именно поэтому они обычно используются в задачах компьютерного зрения) и могут использоваться для выбора признаков. Именно так мы собираемся их использовать: CNN найдут закономерности в наших ценах закрытия и передадут эти распознанные закономерности в наши слои LSTM, где может произойти наша магия временных рядов.
Но хватит болтать, вот код:
def build_cnn_lstm(train, n_input=5): """ Builds and trains our CNN-LSTM model. """ # prepare the data X_train, y_train = to_supervised(train, n_input, n_input) # define params VERBOSE, NUM_EPOCHS, BATCH_SIZE = 0, 20, 16 n_timesteps, n_features, n_outputs = X_train.shape[1], X_train.shape[2], y_train.shape[1] y_train = y_train.reshape((y_train.shape[0], y_train.shape[1], 1)) # define model model = Sequential() # input layer: this is taking our raw data and convoluting it, essentially its picking up salient features. model.add(Conv1D(filters=64, kernel_size=3, activation='relu', input_shape=(n_timesteps, n_features), padding='same')) # this is our first hidden layer, its taking in the ouput of our input layer and finding subtle patterns in our features model.add(Conv1D(filters=64, kernel_size=3, activation='relu', padding='same')) model.add(MaxPooling1D(pool_size=2)) model.add(Flatten()) model.add(RepeatVector(n_outputs)) # time series magic occurs model.add(LSTM(200, activation='relu', input_shape=(n_timesteps, n_features), return_sequences=True)) model.add(LSTM(200, activation='relu', input_shape=(n_timesteps, n_features), return_sequences=True)) model.add(TimeDistributed(Dense(100, activation='relu'))) # gonna change this from relu to softmax model.add(TimeDistributed(Dense(1))) # compile the model print('compiling model....') model.compile(optimizer='adam', loss='mse') print("fitting model....") # fit the model model.fit(X_train, y_train, epochs=NUM_EPOCHS, batch_size=BATCH_SIZE, verbose=VERBOSE, callbacks=[early_stopping]) print('model is fit....') return model
Теперь давайте посмотрим, как эта новая, более глубокая модель работает с нашим набором данных об акциях Apple. Еще раз, вот график:
По крайней мере, на мой взгляд, этот график уже выглядит лучше, чем два других. Это довольно хорошо отражает основную правду, и этот субъективный взгляд подкрепляется нашими оценками MAPE. Общий показатель MAPE упал с 6% до 4%, а наш худший результат за неделю снизился на 1% с 17% до 16% при примерно таком же минимальном уровне MAPE. Таким образом, наша гибридная модель CNN-LSTM, похоже, работает лучше, чем одна только модель LSTM.
Однако, как мы видели, самый простой способ улучшить модель — это не изменить архитектуру, а просто обучить ее на большем количестве данных. Вероятно, мы довели эту модель Apple до предела своих возможностей. Чтобы увидеть, как наша новая модель CNN-LSTM работает с гораздо большим набором данных, давайте обучим ее на S&P 500. Вот прогнозы, сгенерированные нашей моделью после обучения на данных S&P 500 за 1928–2013 гг.
Как видно из графика, добавление большого количества данных в нашу модель сотворило чудеса. Он очень хорошо отслеживает основную правду и делает это гладко. Наши оценки MAPE подтверждают это: наш общий MAPE ниже 2%, а наш худший прогноз на неделю — 11%. К сожалению, не каждая акция соответствует этому сценарию замков золота — тонна данных и относительно низкая волатильность базовых данных. По этой причине решение проблемы прогнозирования акций в целом намного сложнее, чем простое прогнозирование данных S&P 500. На следующей неделе мы сможем изучить методы прогнозирования обычных цен на акции с использованием небольших наборов данных.
Заключение
Прогнозирование цен на акции — очень сложная задача с точки зрения машинного обучения. Данные зашумлены, и, во-первых, их не так много. Однако, несмотря на это, в конкретных случаях, таких как случай S&P 500 выше, мы можем построить довольно хорошие модели. Теперь возникает вопрос, можем ли мы применить то, что мы узнали из наших вышеперечисленных экспериментов, к акциям с более короткой торговой историей? Мы займемся этим на следующей неделе. Цепочка жива (N=2), спасибо за прочтение!