Мы собираемся использовать заголовки ежедневных мировых новостей с Reddit, чтобы предсказать начальное значение промышленного индекса Доу-Джонса. Данные для этого проекта взяты из набора данных о Kaggle и охватывают почти восемь лет (с 2008-08-08 по 2016-07-01).

В этом проекте мы собираемся использовать более крупные общие векторы сканирования GloVe для создания встраиваемых слов и Keras для построения нашей модели. Эта модель была вдохновлена ​​работой, описанной в этой статье. Как и в статье, мы будем использовать CNN, за которыми следуют RNN, но наша архитектура будет немного другой, и мы будем использовать LSTM вместо GRU.

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

Примечание. Как и в других моих статьях, я пропущу несколько частей проекта, но при необходимости предоставлю ссылку на важную информацию. Кроме того, вы можете увидеть полную версию этого проекта на его странице GitHub.

Данные для этого проекта находятся в двух разных файлах. В связи с этим нам необходимо обеспечить одинаковые даты в каждом из наших фреймов данных. Здесь нам поможет функция isin().

news = news[news.Date.isin(dj.Date)]

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

dj = dj.set_index('Date').diff(periods=1)
dj['Date'] = dj.index
dj = dj1.reset_index(drop=True)

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

price = []
headlines = []
for row in dj.iterrows():
    daily_headlines = []
    date = row[1]['Date']
    price.append(row[1]['Open'])
    for row_ in news[news.Date==date].iterrows():
        daily_headlines.append(row_[1]['News'])

Каждый день по большей части включает 25 заголовков. Это то, что составляет наши новости. Нам нужно очистить эти данные, чтобы получить от них максимальную отдачу. Для этого мы преобразуем его в нижний регистр, заменим сокращения на их более длинные формы, удалим ненужные символы, переформатируем слова, чтобы они лучше соответствовали векторам слов GloVe, и удалим стоп-слова. Список сокращений можно найти в записной книжке jupyter этого проекта.

def clean_text(text, remove_stopwords = True):
    
    text = text.lower()
    
    # Replace contractions with their longer forms 
    if True:
        text = text.split()
        new_text = []
        for word in text:
            if word in contractions:
                new_text.append(contractions[word])
            else:
                new_text.append(word)
        text = " ".join(new_text)
    
    # Format words and remove unwanted characters
    text = re.sub(r'&', '', text) 
    text = re.sub(r'0,0', '00', text) 
    text = re.sub(r'[_"\-;%()|.,+&=*%.,!?:#@\[\]]', ' ', text)
    text = re.sub(r'\'', ' ', text)
    text = re.sub(r'\$', ' $ ', text)
    text = re.sub(r'u s ', ' united states ', text)
    text = re.sub(r'u n ', ' united nations ', text)
    text = re.sub(r'u k ', ' united kingdom ', text)
    text = re.sub(r'j k ', ' jk ', text)
    text = re.sub(r' s ', ' ', text)
    text = re.sub(r' yr ', ' year ', text)
    text = re.sub(r' l g b t ', ' lgbt ', text)
    text = re.sub(r'0km ', '0 km ', text)
    
    # Optionally, remove stop words
    if remove_stopwords:
        text = text.split()
        stops = set(stopwords.words("english"))
        text = [w for w in text if not w in stops]
        text = " ".join(text)
return text

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

Чтобы создать веса, которые будут использоваться для вложений модели, мы создадим матрицу, состоящую из вложений, относящихся к словам в нашем словаре. Если слово найдено в словаре GloVe, мы будем использовать его предварительно обученный вектор. Если слово не найдено в словаре Glove, мы создадим для него случайное вложение. Вложения будут обновляться по мере обучения модели, поэтому наши новые «случайные» вложения будут более точными к концу обучения.

# Need to use 300 for embedding dimensions to match GloVe's vectors.
embedding_dim = 300
nb_words = len(vocab_to_int)
# Create matrix with default values of zero
word_embedding_matrix = np.zeros((nb_words, embedding_dim))
for word, i in vocab_to_int.items():
    if word in embeddings_index:
        word_embedding_matrix[i] = embeddings_index[word]
    else:
        new_embedding = np.array(np.random.uniform(-1.0, 1.0, 
                                 embedding_dim))
        embeddings_index[word] = new_embedding
        word_embedding_matrix[i] = new_embedding

Последний шаг в подготовке данных для заголовков - сделать новости одинаковой длины для каждого дня. Мы собираемся увеличить длину любого заголовка до 16 слов (это длина заголовка 75-го процентиля) и максимизировать длину новостей любого дня до 200 слов. Эти значения были выбраны, чтобы обеспечить хороший баланс между количеством слов в заголовке и количеством используемых заголовков. Я ожидаю, что использование большего количества слов для каждодневных новостей (то есть увеличение лимита в 200 слов) будет полезным, но я не хотел, чтобы мое время обучения становилось слишком длинным, поскольку я просто использую свой macbook pro.

max_headline_length = 16
max_daily_length = 200
pad_headlines = []
for date in int_headlines:
    pad_daily_headlines = []
    for headline in date:
        if len(headline) <= max_headline_length:
            for word in headline:
                pad_daily_headlines.append(word)
        else:
            headline = headline[:max_headline_length]
            for word in headline:
                pad_daily_headlines.append(word)
    
    # Pad daily_headlines if they are less than max length
    if len(pad_daily_headlines) < max_daily_length:
        for i in range(max_daily_length-len(pad_daily_headlines)):
            pad = vocab_to_int["<PAD>"]
            pad_daily_headlines.append(pad)
    else:
        pad_daily_headlines = pad_daily_headlines[:max_daily_length]
    pad_headlines.append(pad_daily_headlines)

Когда я впервые попытался обучить свою модель, она изо всех сил пыталась внести какие-либо улучшения. Решение, которое я нашел, заключалось в нормализации моих целевых данных между значениями от 0 до 1.

max_price = max(price)
min_price = min(price)
mean_price = np.mean(price)
def normalize(price):
    return ((price-min_price)/(max_price-min_price))

Как я упоминал во введении к этой статье, мы будем использовать поиск по сетке для обучения нашей модели. Ниже вы увидите переменные «шире» и «глубже». Это два способа, которыми я изменяю модель. «Шире» удваивает значения некоторых гиперпараметров, а «глубже» добавляет дополнительный слой свертки к каждой ветви, а также добавляет дополнительный полностью связанный слой к последней части модели.

Я был удивлен, что эта модель противоречит общепринятым представлениям о том, что чем больше слоев, тем лучше. Использование всего одного слоя и сети меньшего размера дало наилучшие результаты.

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

filter_length1 = 3
filter_length2 = 5
dropout = 0.5
learning_rate = 0.001
weights = initializers.TruncatedNormal(mean=0.0, stddev=0.1, seed=2)
nb_filter = 16
rnn_output_size = 128
hidden_dims = 128
wider = True
deeper = True
if wider == True:
    nb_filter *= 2
    rnn_output_size *= 2
    hidden_dims *= 2
def build_model():
    
    model1 = Sequential()
    
    model1.add(Embedding(nb_words, 
                         embedding_dim,
                         weights=[word_embedding_matrix], 
                         input_length=max_daily_length))
    model1.add(Dropout(dropout))
    
    model1.add(Convolution1D(filters = nb_filter, 
                             kernel_size = filter_length1, 
                             padding = 'same',
                            activation = 'relu'))
    model1.add(Dropout(dropout))
    
    if deeper == True:
        model1.add(Convolution1D(filters = nb_filter, 
                                 kernel_size = filter_length1, 
                                 padding = 'same',
                                 activation = 'relu'))
        model1.add(Dropout(dropout))
    
    model1.add(LSTM(rnn_output_size, 
                   activation=None,
                   kernel_initializer=weights,
                   dropout = dropout))
    
    ####
    model2 = Sequential()
    
    model2.add(Embedding(nb_words, 
                         embedding_dim,
                         weights=[word_embedding_matrix], 
                         input_length=max_daily_length))
    model2.add(Dropout(dropout))
    
    
    model2.add(Convolution1D(filters = nb_filter, 
                             kernel_size = filter_length2, 
                             padding = 'same',
                             activation = 'relu'))
    model2.add(Dropout(dropout))
    
    if deeper == True:
        model2.add(Convolution1D(filters = nb_filter, 
                                 kernel_size = filter_length2, 
                                 padding = 'same',
                                 activation = 'relu'))
        model2.add(Dropout(dropout))
    
    model2.add(LSTM(rnn_output_size, 
                    activation=None,
                    kernel_initializer=weights,
                    dropout = dropout))
    
    ####
    model = Sequential()
    model.add(Merge([model1, model2], mode='concat'))
    
    model.add(Dense(hidden_dims, kernel_initializer=weights))
    model.add(Dropout(dropout))
    
    if deeper == True:
        model.add(Dense(hidden_dims//2, kernel_initializer=weights))
        model.add(Dropout(dropout))
    model.add(Dense(1, 
                    kernel_initializer = weights,
                    name='output'))
    model.compile(loss='mean_squared_error',
                  optimizer=Adam(lr=learning_rate,clipvalue=1.0))
    return model

Метод, который я использовал для создания поиска по сетке, тот же, что и в моей статье Прогнозирование настроения при просмотре фильмов с помощью TensorFlow и TensorBoard. Однако здесь мы используем Keras, поэтому остальной код совсем другой.

for deeper in [False]:
    for wider in [True,False]:
        for learning_rate in [0.001]:
            for dropout in [0.3, 0.5]:
                model = build_model()
                print("Current model: 
                        Deeper={},Wider={},LR={},Dropout={}".format(
                            deeper,wider,learning_rate,dropout))
                save_best_weights = \
                    'question_pairs_weights_deeper={}_wider={}_
                     lr={}_dropout={}.h5'.format(
                         deeper,wider,learning_rate,dropout)
callbacks = [ModelCheckpoint(save_best_weights,       
                             monitor='val_loss', 
                             save_best_only=True),
             EarlyStopping(monitor='val_loss', 
                           patience=5, 
                           verbose=1, 
                           mode='auto'),
             ReduceLROnPlateau(monitor='val_loss', 
                               factor=0.2, 
                               patience=3, 
                               verbose=1)]
history = model.fit([x_train,x_train],
                    y_train,
                    batch_size=128,
                    epochs=100,
                    validation_split=0.15,
                    verbose=True,
                    shuffle=True,
                    callbacks = callbacks)

Используя метод «for loop», вы сможете настроить практически любые (если не все) функции модели. Важно помнить, что каждую итерацию модели следует сохранять с отдельной строкой, иначе они будут перезаписывать друг друга.

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

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

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

deeper=False
wider=False
dropout=0.3
learning_Rate = 0.001
model = build_model()
model.load_weights('./question_pairs_weights_deeper={}_wider={}_
                   lr={}_dropout={}.h5'.format(
                        deeper,wider,learning_rate,dropout))
predictions = model.predict([x_test,x_test], verbose = True)

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

def unnormalize(price):
    price = price*(max_price-min_price)+min_price
    return(price)
mae(unnorm_y_test, unnorm_predictions)

Средняя абсолютная ошибка для этой модели составляет 74,15. Вот сравнение прогнозируемых значений и фактических значений.

Я подробно расскажу о том, почему результаты такие посредственные, но дам вам короткую версию: прогнозирование будущего фондового рынка - сложная и почти невыполнимая задача. В его ценность входит большой объем данных и даже эмоции, и использование 25 ежедневных заголовков из Reddit не сможет учесть все сложности.

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

Сделать собственные прогнозы - довольно простой процесс. Для этой модели я обнаружил, что лучше всего заполнить все 200 слов входных данных новостями, а не использовать какие-либо отступы. В моем блокноте jupyter у меня есть 25 заголовков новостей с Reddit, которые вы можете использовать в качестве новостей по умолчанию. Внесите любые изменения, которые хотите, и вы увидите, какое влияние они окажут!

create_news = ""
clean_news = clean_text(create_news)
int_news = news_to_int(clean_news)
pad_news = padding_news(int_news)
pad_news = np.array(pad_news).reshape((1,-1))
pred = model.predict([pad_news,pad_news])
price_change = unnormalize(pred)
print("The Dow should open: {} from the previous open.".format(np.round(price_change[0][0],2)))

Вот и все для этого проекта! Надеюсь, вам он показался достаточно интересным и информативным. Keras довольно хорош, потому что вы можете создавать свои модели намного быстрее, чем в TensorFlow, и их легче понять (по крайней мере, с точки зрения архитектуры).

Если вы хотите расширить этот проект и сделать его еще лучше, у меня есть для вас несколько идей:

  • Используйте заголовки 30 компаний, входящих в промышленный индекс Доу-Джонса.
  • Включите заголовки предыдущего дня.
  • Включите изменение стоимости за предыдущий день.

Спасибо за чтение, и если у вас есть идеи о том, как улучшить этот проект, или вы хотите поделиться чем-то интересным, то оставьте комментарий ниже!