Keras 神经网络秘籍(四)

原文:annas-archive.org/md5/b038bef44808c012b36120474e9e0841

译者:飞龙

协议:CC BY-NC-SA 4.0

第十一章:构建循环神经网络

在上一章中,我们介绍了多种将文本表示为向量的方式,并在这些表示之上执行情感分类。

这种方法的一个缺点是我们没有考虑到单词的顺序——例如,句子 A is faster than BB is faster than A 将有相同的表示,因为这两个句子中的单词完全相同,而单词的顺序不同。

循环神经网络RNNs)在需要保留单词顺序的场景中非常有用。在本章中,您将学习以下主题:

  • 从零开始在 Python 中构建 RNN 和 LSTM

  • 实现 RNN 进行情感分类

  • 实现 LSTM 进行情感分类

  • 实现堆叠 LSTM 进行情感分类

介绍

RNN 可以通过多种方式架构。以下是一些可能的方式:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/79db1776-f471-4fe6-89b0-67cbae844bfc.png

底部的框是输入,接着是隐藏层(中间的框),顶部是输出层。此一对一架构是典型的神经网络,输入层和输出层之间有一个隐藏层。不同架构的示例如下:

架构示例
一对多输入是图像,输出是图像的描述
多对一输入是电影的评论(输入中包含多个单词),输出是与评论相关的情感
多对多将一种语言的句子翻译成另一种语言的句子

RNN 架构的直觉

当我们希望在给定一系列事件时预测下一个事件时,RNN 非常有用。

其中一个例子是预测 This is an _____ 后面跟的单词。

假设在现实中,句子是 This is an example

传统的文本挖掘技术通常通过以下方式解决问题:

  1. 对每个单词进行编码,同时为潜在的新单词设置额外的索引:
This: {1,0,0,0}
is: {0,1,0,0}
an: {0,0,1,0}
  1. 编码短语 This is an
This is an: {1,1,1,0}
  1. 创建训练数据集:
Input --> {1,1,1,0}
Output --> {0,0,0,1}
  1. 构建一个包含输入和输出的模型

该模型的一个主要缺点是输入表示在输入句子中没有变化;它可以是 this is anan is thisthis an is

然而,直观上,我们知道前面每个句子的结构是不同的,不能用相同的数学结构表示。这就需要采用不同的架构,结构如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/a4da81fc-763e-4ed2-b638-f3694aebd985.png

在前面的架构中,句子中的每个单词都会进入输入框中的独立框中。然而,句子的结构将得到保留。例如,this 进入第一个框,is 进入第二个框,an 进入第三个框。

顶部的输出框将是 example 的输出。

解释 RNN

你可以将 RNN 看作一种存储记忆的机制——其中记忆被保存在隐藏层内。它可以被如下方式可视化:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/ef7343c2-b306-43b0-8e2d-3dc8f10fa443.png

右侧的网络是左侧网络的展开版本。右侧的网络在每个时间步输入一个数据,并在每个时间步提取输出。然而,如果我们关注第四个时间步的输出,我们需要提供前三个时间步的输入,而第三个时间步的输出则是第四个时间步的预测值。

请注意,在预测第三个时间步的输出时,我们通过隐藏层引入了前两个时间步的值,隐藏层将跨时间步连接这些值。

让我们探索前面的图示:

  • U 权重表示连接输入层与隐藏层的权重

  • W 权重表示隐藏层之间的连接

  • V 权重表示隐藏层与输出层之间的连接

为什么要存储记忆?

需要存储记忆,就像前面的示例中,甚至在文本生成中,下一词不一定仅依赖于前一个词,而是依赖于前面词语的上下文。

由于我们关注前面的词语,应该有一种方式来将它们保存在记忆中,以便我们能够更准确地预测下一个词。

此外,我们还需要按顺序存储记忆;也就是说,通常情况下,最近的词语比距离要预测的词更远的词语更有助于预测下一个词。

从头开始在 Python 中构建 RNN

在这个教程中,我们将从头开始构建一个 RNN,使用一个简单的示例,以帮助你更好地理解 RNN 如何帮助解决考虑事件(单词)顺序的问题。

准备开始

请注意,典型的神经网络包含一个输入层,之后是隐藏层的激活,最后是输出层的 softmax 激活。

RNN 采用类似的结构,并在这种结构上进行修改,使得前一个时间步的隐藏层被考虑在当前时间步中。

在将 RNN 应用于更实际的用例之前,我们会先通过一个简单的示例构建 RNN 的工作细节。

让我们考虑一个如下的示例文本:This is an example

当前任务是给定一对单词序列,预测第三个词。

因此,数据集的转换如下:

InputOutput
this, isan
is, anexample

给定输入 this is,我们期望预测 example 作为输出。

我们将采用以下策略来构建一个 RNN:

  1. 对单词进行独热编码

  2. 确定输入的最大长度:

    • 将其余输入填充至最大长度,使得所有输入的长度相同
  3. 将输入中的单词转换为一个独热编码版本

  4. 将输出中的单词转换为一个独热编码版本

  5. 处理输入和输出数据,然后拟合 RNN 模型

如何做…

上述策略的代码如下(代码文件可在 GitHub 的Building_a_Recurrent_Neural_Network_from_scratch-in_Python.ipynb中找到):

  1. 让我们在代码中定义输入和输出,如下所示:
#define documents
docs = ['this, is','is an']
# define class labels
labels = ['an','example']
  1. 让我们对数据集进行预处理,以便将其传递给 RNN:
from collections import Counter
counts = Counter()
for i,review in enumerate(docs+labels):
      counts.update(review.split())
words = sorted(counts, key=counts.get, reverse=True)
vocab_size=len(words)
word_to_int = {word: i for i, word in enumerate(words, 1)}

在前面的步骤中,我们识别了数据集中所有唯一的单词及其对应的频率(计数),并为每个单词分配了一个 ID 号。在前面的代码中,word_to_int的输出如下所示:

print(word_to_int)
# {'an': 2, 'example': 4, 'is': 1, 'this': 3}
  1. 按照如下方式修改输入和输出单词及其对应的 ID:
encoded_docs = []
for doc in docs:
      encoded_docs.append([word_to_int[word] for word in doc.split()])
encoded_labels = []
for label in labels:
      encoded_labels.append([word_to_int[word] for word in label.split()])
print('encoded_docs: ',encoded_docs)
print('encoded_labels: ',encoded_labels)
# encoded_docs: [[3, 1], [1, 2]]
# encoded_labels: [[2], [4]]

在前面的代码中,我们将输入句子中每个单词的 ID 附加到一个列表中,从而使输入(encoded_docs)成为一个包含列表的列表。

同样,我们将输出中每个单词的 ID 附加到一个列表中。

  1. 在编码输入时需要考虑的另一个因素是输入的长度。在情感分析的情况下,输入文本的长度可能因评论而异。然而,神经网络要求输入的大小是固定的。为了解决这个问题,我们在输入上进行填充。填充确保所有输入被编码为相似的长度。虽然在我们的示例中两个输入的长度都是 2,但实际上我们很可能会遇到输入长度不同的情况。在代码中,我们按如下方式进行填充:
# pad documents to a max length of 2 words
max_length = 2
padded_docs = pad_sequences(encoded_docs, maxlen=max_length, padding='pre')

在前面的代码中,我们将encoded_docs传递给pad_sequences函数,确保所有输入数据点的长度相同——即等于maxlen参数。此外,对于那些长度小于maxlen的参数,它会用 0 填充这些数据点,直到达到maxlen的总长度,并且零填充会在pre位置完成——也就是在原始编码句子的左侧。

现在输入数据集已经创建完毕,接下来让我们对输出数据集进行预处理,以便将其传递给模型训练步骤。

  1. 对输出的典型处理是将其转换为虚拟值,即制作输出标签的独热编码版本,方法如下:
one_hot_encoded_labels = to_categorical(encoded_labels, num_classes=5)
print(one_hot_encoded_labels)
# [[0\. 0\. 1\. 0\. 0.] [0\. 0\. 0\. 0\. 1.]]

请注意,给定输出值(encoded_labels)为{2, 4},输出向量在第二和第四位置分别为 1。

  1. 让我们构建模型:

    1. RNN 期望输入的形状为(batch_sizetime_stepsfeatures_per_timestep)。因此,我们首先将padded_docs输入重塑为以下格式:
padded_docs = padded_docs.reshape(2,2,1)

请注意,理想情况下我们会为每个单词(在这个特定情况下是 ID)创建一个词嵌入。然而,鉴于本教程的目的是了解 RNN 的工作细节,我们将不涉及 ID 的嵌入,并假设每个输入不是 ID 而是一个值。话虽如此,我们将在下一个教程中学习如何执行 ID 嵌入。

    1. 定义模型——在这里我们指定将使用SimpleRNN方法初始化 RNN:
# define the model
embed_length=1
max_length=2
model = Sequential()
model.add(SimpleRNN(1,activation='tanh', return_sequences=False,recurrent_initializer='Zeros',input_shape=(max_length,embed_length),unroll=True))

在前一步中,我们明确指定了recurrent_initializer为零,这样可以更容易理解 RNN 的工作细节。实际上,我们不会将循环初始化器设置为 0。

return_sequences参数指定是否希望在每个时间步获得隐藏层值。若return_sequences为 false,表示我们只希望在最后一个时间步获得隐藏层输出。

通常,在多对一任务中,当输入很多(每个时间步一个输入)并且有输出时,return_sequences会设置为 false,这样输出仅会在最后一个时间步获得。例如,给定过去五天的股票价格序列,预测第二天的股票价格就是一个典型的例子。

然而,在尝试在每个时间步获取隐藏层值的情况下,return_sequences将被设置为True。例如,机器翻译就是一个例子,其中有多个输入和多个输出。

    1. 将 RNN 输出连接到输出层的五个节点:
model.add(Dense(5, activation='softmax'))

我们已经执行了一个Dense(5),因为有五个可能的输出类别(每个样本的输出有 5 个值,每个值对应它属于word ID 0word ID 4的概率)。

    1. 编译并总结模型:
# compile the model
model.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['acc'])
# summarize the model
print(model.summary())

模型总结如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/54388466-b7f3-4314-a618-964e8acfd193.png

现在我们已经定义了模型,在拟合输入和输出之前,让我们了解每一层中参数数量的原因。

模型的简单 RNN 部分的输出形状为(None, 1)。输出形状中的None表示batch_size。None 是指定batch_size可以是任意数值的方式。由于我们指定了从简单 RNN 中输出一个单位的隐藏层,列数为 1。

现在我们理解了simpleRNN的输出形状,让我们理解为什么在simpleRNN层中参数数量是 3。注意,隐藏层值只在最后一个时间步输出。鉴于每个时间步的输入值为 1(每个时间步一个特征),而输出也为一个值,输入实际上是与一个单一的权重相乘。如果输出(隐藏层值)有 40 个隐藏层单元,输入就应该与 40 个单位相乘来获得输出(更多内容请参见实现 RNN 进行情感分类)。

除了连接输入和隐藏层值的一个权重外,还有一个与权重一起的偏置项。另一个参数来自于前一个时间步的隐藏层值与当前时间步隐藏层的连接,最终得到三个参数。

从隐藏层到最终输出有 10 个参数,因为有五个可能的类别,结果是五个权重和五个偏置连接到隐藏层的值(该值是一个单位)——总共 10 个参数。

    1. 训练模型以预测从输入到输出的结果:
model.fit(padded_docs,np.array(one_hot_encoded_labels),epochs=500)
  1. 提取第一个输入数据点的预测值:
model.predict(padded_docs[0].reshape(1,2,1))

提取的输出如下:

array([[0.3684635, 0.33566403, 0.61344165, 0.378485, 0.4069949 ]],      dtype=float32)

验证输出结果

现在模型已经训练完成,让我们通过从后向前分析来理解 RNN 的工作原理——也就是说,提取模型的权重,通过权重前馈输入以匹配预测值,使用 NumPy(代码文件可以在 GitHub 上找到,名为Building_a_Recurrent_Neural_Network_from_scratch_in_Python.ipynb)。

  1. 检查权重:
model.weights
[<tf.Variable 'simple_rnn_2/kernel:0' shape=(1, 1) dtype=float32_ref>,
 <tf.Variable 'simple_rnn_2/recurrent_kernel:0' shape=(1, 1) dtype=float32_ref>,
 <tf.Variable 'simple_rnn_2/bias:0' shape=(1,) dtype=float32_ref>,
 <tf.Variable 'dense_2/kernel:0' shape=(1, 5) dtype=float32_ref>,
 <tf.Variable 'dense_2/bias:0' shape=(5,) dtype=float32_ref>]

上述内容为我们提供了权重在输出中呈现的顺序的直觉。

在前面的例子中,kernel表示权重,recurrent表示隐藏层从一个时刻到另一个时刻的连接。

请注意,simpleRNN有连接输入层到隐藏层的权重,也有连接前一时刻隐藏层到当前时刻隐藏层的权重。

dense_2层中的核和偏置表示连接隐藏层值到最终输出的层:

    1. 提取权重:
model.get_weights()

前面的代码行给出了每个权重的计算值。

  1. 将输入通过第一时刻传递——输入值如下:
padded_docs[0]
#array([3, 1], dtype=int32)

在前面的代码中,第一时刻的值为3,第二时刻的值为1。我们将按如下方式初始化第一时刻的值:

input_t0 = 3
    1. 第一时刻的值与连接输入到隐藏层的权重相乘,然后加上偏置值:
input_t0_kernel_bias = input_t0*model.get_weights()[0] + model.get_weights()[2]
    1. 此时刻的隐藏层值通过tanh激活函数计算得出(因为这是我们在定义模型时指定的激活函数):
hidden_layer0_value = np.tanh(input_t0_kernel_bias)
  1. 计算时间步 2 时的隐藏层值;此时输入的值为1(注意padded_docs[0]的值为[3, 1]):
input_t1 = 1
    1. 当第二个时间步的输入通过权重和偏置时,输出值如下:
input_t1_kernel_bias = input_t1*model.get_weights()[0] + model.get_weights()[2]

请注意,乘以输入的权重在任何时间步中都是相同的。

    1. 在不同时间步计算隐藏层的过程如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/0fe5cacd-023b-479f-8a90-874d5b0f3c42.png

其中Φ是执行的激活(通常使用tanh激活)。

从输入层到隐藏层的计算包含两个部分:

    • 输入层值与核权重的矩阵乘法

    • 前一时刻隐藏层和循环权重的矩阵乘法

给定时间步的最终隐藏层值的计算将是前两次矩阵乘法的总和。将结果通过tanh激活函数处理:

input_t1_recurrent = hidden_layer0_value*model.get_weights()[1]

在经过tan激活之前的总值如下:

total_input_t1 = input_t1_kernel_bias + input_t1_recurrent

隐藏层的输出值通过tan激活函数计算,具体如下:

output_t1 = np.tanh(total_input_t1)
  1. 将最后一个时间步的隐藏层输出通过全连接层传递,该层将隐藏层与输出层连接:
final_output = output_t1*model.get_weights()[3] + model.get_weights()[4]

请注意,model.get_weights()方法的第四和第五个输出对应的是从隐藏层到输出层的连接。

  1. 将前面的输出通过 softmax 激活函数(如模型中定义)传递,以获得最终输出:
np.exp(final_output)/np.sum(np.exp(final_output))
# array([[0.3684635, 0.33566403, 0.61344165, 0.378485, 0.40699497]], dtype=float32)

你应该注意到,通过网络的前向传播得到的输出与model.predict函数输出的结果是相同的。

实现 RNN 进行情感分类

为了理解如何在 Keras 中实现 RNN,让我们实现我们在第十章,“使用词向量进行文本分析”章节中进行的航空公司推文情感分类练习。

如何做到这一点…

任务将按以下方式执行(代码文件在 GitHub 上可用,名为RNN_and_LSTM_sentiment_classification.ipynb):

  1. 导入相关包和数据集:
from keras.layers import Dense, Activation
from keras.layers.recurrent import SimpleRNN
from keras.models import Sequential
from keras.utils import to_categorical
from keras.layers.embeddings import Embedding
from sklearn.cross_validation import train_test_split
import numpy as np
import nltk
from nltk.corpus import stopwords
import re
import pandas as pd
data=pd.read_csv('https://www.dropbox.com/s/8yq0edd4q908xqw/airline_sentiment.csv')
data.head()
  1. 对文本进行预处理,移除标点符号,将所有单词标准化为小写,并移除停用词,如下所示:
import nltk
nltk.download('stopwords')
stop = nltk.corpus.stopwords.words('english')
def preprocess(text):
    text=text.lower()
    text=re.sub('[⁰-9a-zA-Z]+',' ',text)
    words = text.split()
    words2=[w for w in words if (w not in stop)]
    #words3=[ps.stem(w) for w in words]
    words4=' '.join(words2)
    return(words4)
data['text'] = data['text'].apply(preprocess)
  1. 提取构成数据集的所有单词到整数的映射:
from collections import Counter
counts = Counter()
for i,review in enumerate(t['text']):
    counts.update(review.split())
words = sorted(counts, key=counts.get, reverse=True

在前一步骤中,我们提取了数据集中所有单词的频率。提取的部分单词示例如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/f9b4e5b9-a37a-4944-941f-6647af1ba595.jpg

nb_chars = len(words)
word_to_int = {word: i for i, word in enumerate(words, 1)}
int_to_word = {i: word for i, word in enumerate(words, 1)}

在前面的代码中,我们遍历所有单词,并为每个单词分配一个索引。整数到单词字典的示例部分如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/c12eb915-d624-4999-b83c-6daeff8e36e0.jpg

  1. 将给定句子中的每个单词映射到与其相关联的单词:
mapped_reviews = []
for review in data['text']:
    mapped_reviews.append([word_to_int[word] for word in review.split()])

在前一步骤中,我们将文本评论转换为一个包含多个列表的列表,每个列表包含一个句子中单词的 ID。原始评论和映射评论的示例如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/04c3a122-9b47-4e60-87a5-bf3bed019521.jpg

  1. 提取句子的最大长度,并通过填充将所有句子标准化为相同的长度。在以下代码中,我们循环遍历所有评论并存储每个评论对应的长度。此外,我们还在计算评论(推文文本)的最大长度:
length_sent = []
for i in range(len(mapped_reviews)):
      length_sent.append(len(mapped_reviews[i]))
sequence_length = max(length_sent)

我们应该注意到,不同的推文长度不同。然而,RNN 期望每个输入的时间步数相同。在下面的代码中,如果评论的长度小于数据集中所有评论的最大长度,我们会通过值为 0 的填充对评论进行映射。这样,所有输入都会有相同的长度。

from keras.preprocessing.sequence import pad_sequences
X = pad_sequences(maxlen=sequence_length, sequences=mapped_reviews, padding="post", value=0)
  1. 准备训练集和测试集:
y=data['airline_sentiment'].values
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.30,random_state=42)
y_train2 = to_categorical(y_train)
y_test2 = to_categorical(y_test)

在前一步骤中,我们将原始数据拆分为训练集和测试集,并将因变量转换为独热编码变量。

  1. 构建 RNN 架构并编译模型:
embedding_vecor_length=32
max_review_length=26
model = Sequential()
model.add(Embedding(input_dim=12533, output_dim=32, input_length = 26))

请注意,嵌入层以所有独特词汇的总数作为输入,并为每个词汇创建一个向量,其中output_dim表示词汇表示的维度数,input_length表示每个句子的词汇数:

model.add(SimpleRNN(40, return_sequences=False))

请注意,在 RNN 层中,如果我们希望提取每个时间步的输出,我们会将return_sequences参数设置为True。然而,在我们当前要解决的用例中,我们只在读取完所有输入词汇后才提取输出,因此return_sequences = False

model.add(Dense(2, activation='softmax'))
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])
print(model.summary())

模型的总结如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/7d2328cb-7549-4f74-9844-a2f5f3d89b04.png

让我们理解为什么在嵌入层中有401056个参数需要估计。总共有 12,532 个独特的词汇,如果我们考虑到没有索引为 0 的词汇,总共有 12,533 个可能的词汇,每个词汇在 32 维度中表示,因此需要估计(12,533 x 32 = 401,056)个参数。

现在,让我们试着理解为什么在simpleRNN层中有 2,920 个参数。

有一组权重将输入连接到 RNN 的 40 个单元。鉴于每个时间步有 32 个输入(同一组权重在每个时间步重复),因此总共有 32 x 40 个权重用于将输入连接到隐藏层。这为每个输入提供了 1 x 40 的输出。

此外,为了使 X * W[xh] 和 *h[(t-1) ] W[hh] 之间的求和发生(其中 X 是输入值,W[xh] 是连接输入层和隐藏层的权重,W[hh] 是连接前一个时间步的隐藏层与当前时间步隐藏层的权重,而 h[(t-1)] 是前一个时间步的隐藏层)——鉴于 X W[xh] 输入的输出是 1 x 40——h[(t-1)] X W[hh] 的输出也应该是 1 x 40。因此,W[hh] 矩阵的维度将是 40 x 40,因为 h[(t-1)] 的维度是 1 x 40。

除了权重外,我们还将为每个 40 个输出单元设置 40 个偏置项,因此总共有(32 x 40 + 40 x 40 + 40 = 2,920)个权重。

最后一层总共有 82 个权重,因为最后一个时间步的 40 个单元与两个可能的输出相连接,结果是 40 x 2 个权重和 2 个偏置项,因此总共有 82 个单元。

  1. 适配模型:
model.fit(X_train, y_train2, validation_data=(X_test, y_test2), epochs=10, batch_size=32)

训练和测试数据集中的准确率和损失值的图示如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/afc6244e-0a9d-463a-956d-c6b4956c784d.jpg

上述模型的输出大约为 89%,并未比我们在使用词向量进行文本分析章节中构建的基于词向量的网络提供显著改进。

然而,随着数据点数量的增加,预计会有更好的准确性。

还有更多内容…

一个考虑多个时间步给出预测的传统 RNN 可以如下可视化:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/a7fcb1be-8d05-49f7-811f-8f716f209092.png

请注意,随着时间步的增加,早期层次的输入影响会变得较小。可以在这里看到这种直觉(暂时忽略偏置项):

h[5] = WX[5] + Uh[4] = WX5 + UWX[4] + U²WX[3] + U³WX[2] + U⁴WX[1]

你可以看到,随着时间步的增加,当U>1时,隐藏层的值高度依赖于X[1];当U<1时,依赖性则会大大降低。

对 U 矩阵的依赖也可能导致梯度消失问题,当U的值非常小的时候;当U的值非常大时,也可能导致梯度爆炸问题。

上述现象导致在预测下一个词时,若存在长期依赖性,会出现问题。为了解决这个问题,我们将在下一个教程中使用长短期记忆LSTM)架构。

从零开始在 Python 中构建 LSTM 网络

在上一节关于传统 RNN 问题的部分,我们了解到当存在长期依赖性时,RNN 并不能有效工作。例如,假设输入句子如下:

I live in India. I speak ____.

在前述语句中,空白处可以通过查看关键词India来填充,而India距我们要预测的词有三个时间步的距离。

以类似的方式,如果关键词离要预测的词很远,梯度消失/爆炸问题需要被解决。

准备工作

在本教程中,我们将学习 LSTM 如何帮助克服 RNN 架构的长期依赖性问题,并构建一个简单的示例,以便我们理解 LSTM 的各个组件。

LSTM 结构如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/f2243925-4b0f-400b-bb2d-6d485e0d1401.png

你可以看到,虽然输入X和隐藏层输出(h)保持不变,但隐藏层内会发生不同的激活(某些情况下是 sigmoid 激活,其他情况下是 tanh 激活)。

让我们仔细检查一个时间步内发生的各种激活:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/4c51bfa7-310d-4ee5-97dd-aff0de7c9669.png

在前面的图示中,Xh分别代表输入层和隐藏层。

长期记忆存储在单元状态C中。

需要遗忘的内容是通过遗忘门获得的:

f[t]=σ(W[xf]x((t))+W[hf]h((t-1))+b[f])

Sigmoid 激活使网络能够选择性地识别需要遗忘的内容。

在我们确定需要遗忘的内容后,更新的单元状态如下:

c[t]=(c[t-1] https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/598b937a-e234-4d91-a30e-ae8b81fbf55e.png f)

请注意,https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/dc04ccba-f496-4601-93a6-3f1f35655156.png表示逐元素相乘。

例如,如果句子的输入序列是I live in India. I speak ___,则空白处可以根据输入词India来填充。在填充空白后,我们不一定需要该国家名称的具体信息。

我们根据当前时间步需要遗忘的内容来更新单元状态。

在下一步中,我们将根据当前时间步提供的输入向单元状态添加额外的信息。此外,更新的幅度(正向或负向)通过 tanh 激活函数获得。

输入可以按如下方式指定:

i[t]=σ(W[xi]x((t))+W[hi]h((t-1))+bi)

调制(输入更新的幅度)可以按如下方式指定:

g[t]=tanh(W[xg]x((t))+W[hg]h((t-1))+bg)

单元状态——在一个时间步中,我们忘记某些信息并在同一时间步中添加额外的信息——按如下方式更新:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/b2688aa0-958e-4f11-9c52-f6661cacac20.png

在最后一个门中,我们需要指定输入的组合(当前时间步输入与前一时间步的隐藏层值的组合)和单元状态中需要输出到下一个隐藏层的部分:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/9f52fca7-fe04-4a8f-a16a-ae6386c325f1.png

最终的隐藏层表示如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/962e9401-a248-417f-8f8d-0ebdfff4aff3.png

通过这种方式,我们能够利用 LSTM 中的各种门来选择性地识别需要存储在记忆中的信息,从而克服 RNN 的局限性。

如何做…

为了获得该理论如何工作的实际直觉,让我们看看我们在理解 RNN 时做过的相同示例,但这次使用 LSTM。

请注意,数据预处理步骤在两个示例中是相同的。因此,我们将重用预处理部分(在从零开始构建 RNN 的 Python 教程中的步骤 1步骤 4),并直接进入模型构建部分(代码文件在 GitHub 中的LSTM_working_details.ipynb可用):

  1. 定义模型:
embed_length=1
max_length=2
model = Sequential()
model.add(LSTM(1,activation='tanh',return_sequences=False,
recurrent_initializer='Zeros',recurrent_activation='sigmoid',
input_shape=(max_length,embed_length),unroll=True))

请注意,在前面的代码中,我们将递归初始化器和递归激活函数初始化为某些值,仅为了简化此示例;其目的是帮助您理解后端发生了什么。

model.add(Dense(5, activation='softmax'))
# compile the model
model.compile(optimizer='adam', loss='binary_crossentropy', metrics=['acc'])
# summarize the model
print(model.summary())

模型的总结如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/04377b35-d2b0-4091-9dae-5d5558039585.png

在 LSTM 层中,参数的数量是12,因为有四个门(遗忘门、输入门、细胞门和输出门),这导致有四个权重和四个偏置将输入与隐藏层连接。此外,递归层包含对应四个门的权重值,因此我们总共有12个参数。

Dense 层总共有 10 个参数,因为输出有五个可能的类别,因此有五个权重和五个偏置,分别对应从隐藏层到输出层的每个连接。

  1. 让我们拟合模型:
model.fit(padded_docs.reshape(2,2,1),np.array(one_hot_encoded_labels),epochs=500)
  1. 该模型的权重顺序如下:
model.weights[<tf.Variable 'lstm_19/kernel:0' shape=(1, 4) dtype=float32_ref>,
 <tf.Variable 'lstm_19/recurrent_kernel:0' shape=(1, 4) dtype=float32_ref>,
 <tf.Variable 'lstm_19/bias:0' shape=(4,) dtype=float32_ref>,
 <tf.Variable 'dense_18/kernel:0' shape=(1, 5) dtype=float32_ref>,
 <tf.Variable 'dense_18/bias:0' shape=(5,) dtype=float32_ref>]

可以按如下方式获取权重:

model.get_weights()

从前面的代码(model.weights)中,我们可以看到 LSTM 层中权重的顺序如下:

    • 输入的权重(核)

    • 对应于隐藏层的权重(recurrent_kernel

    • LSTM 层中的偏置

类似地,在密集层(连接隐藏层与输出的层)中,权重的顺序如下:

    • 与隐藏层相乘的权重

    • 偏置

以下是 LSTM 层中权重和偏置的顺序(在前面的输出中没有提供,但可以在 Keras 的 GitHub 仓库中找到):

    • 输入门

    • 遗忘门

    • 调制门(单元门)

    • 输出门

  1. 计算输入的预测。

我们使用的是未经编码的原始输入值(1、2、3),而没有将其转换为嵌入值——仅仅是为了看看计算是如何工作的。实际操作中,我们会将输入转换为嵌入值。

  1. 为预测方法重塑输入,以使其符合 LSTM 所期望的数据格式(批量大小、时间步数、每个时间步的特征):
model.predict(padded_docs[0].reshape(1,2,1))
# array([[0.05610514, 0.11013522, 0.38451442, 0.0529648, 0.39628044]], dtype=float32)

上面代码中的注释行提供了预测方法的输出。

验证输出

现在我们已经从模型中获得了预测概率,让我们使用 NumPy 通过权重的前向传递来运行输入,以获得与刚才相同的输出。

这样做是为了验证我们对 LSTM 内部工作原理的理解。我们验证所构建模型输出的步骤如下:

  1. 更新时间步 1 中的遗忘门。此步骤查看输入,并提供对到目前为止已知的单元状态(记忆)需要遗忘多少的估计(请注意在这方面使用了 sigmoid 函数):
input_t0 = 3
cell_state0 = 0
forget0 = input_t0*model.get_weights()[0][0][1] + model.get_weights()[2][1]
forget1 = 1/(1+np.exp(-(forget0)))
  1. 基于更新的遗忘门更新单元状态。前一步的输出将在此处用于指导从单元状态(记忆)中忘记多少值:
cell_state1 = forget1 * cell_state0
  1. 更新时间步 1 中的输入门值。此步骤给出了根据当前输入将有多少新信息注入到单元状态中的估计:
input_t0_1 = input_t0*model.get_weights()[0][0][0] + model.get_weights()[2][0]
input_t0_2 = 1/(1+np.exp(-(input_t0_1)))
  1. 基于更新的输入值更新单元状态。这是一个步骤,其中前一步的输出被用来指示应对单元状态(记忆)进行多少信息更新:
input_t0_cell1 = input_t0*model.get_weights()[0][0][2] +model.get_weights()[2][2]
input_t0_cell2 = np.tanh(input_t0_cell1)

之前的tanh激活有助于确定输入的更新是否会对单元状态(记忆)进行加法或减法操作。这提供了一个额外的杠杆,因为如果某些信息已经在当前时间步传递,并且在未来的时间步不再有用,那么我们最好将其从单元状态中删除,这样这些额外的信息(在下一步可能没有帮助)就能从记忆中擦除:

input_t0_cell3 = input_t0_cell2*input_t0_2
input_t0_cell4 = input_t0_cell3 + cell_state1
  1. 更新输出门。此步骤提供对当前时间步将传递多少信息的估计(请注意在这方面使用了 sigmoid 函数):
output_t0_1 = input_t0*model.get_weights()[0][0][3] + model.get_weights()[2][3]
output_t0_2 = 1/(1+np.exp(-output_t0_1))
  1. 计算时间步 1 的隐藏层值。请注意,某一时间步的最终隐藏层值是当前时间步的记忆和输出用于传递单一时间步的结合:
hidden_layer_1 = np.tanh(input_t0_cell4)*output_t0_2

我们已经完成了从第一时间步得到的隐藏层输出值的计算。在接下来的步骤中,我们将把时间步长 1 更新后的单元状态值和时间步长 1 的隐藏层输出作为输入传递给时间步长 2。

  1. 传递时间步长为 2 的输入值和进入时间步长 2 的单元状态值:
input_t1 = 1
cell_state1 = input_t0_cell4
  1. 更新忘记门的值:
forget21 = hidden_layer_1*model.get_weights()[1][0][1] + model.get_weights()[2][1] + input_t1*model.get_weights()[0][0][1]
forget_22 = 1/(1+np.exp(-(forget21)))
  1. 更新时间步长 2 中的单元状态值:
cell_state2 = cell_state1 * forget_22
input_t1_1 = input_t1*model.get_weights()[0][0][0] + model.get_weights()[2][0] + hidden_layer_1*model.get_weights()[1][0][0]
input_t1_2 = 1/(1+np.exp(-(input_t1_1)))
input_t1_cell1 = input_t1*model.get_weights()[0][0][2] + model.get_weights()[2][2]+ hidden_layer_1*model.get_weights()[1][0][2]
input_t1_cell2 = np.tanh(input_t1_cell1)
input_t1_cell3 = input_t1_cell2*input_t1_2
input_t1_cell4 = input_t1_cell3 + cell_state2
  1. 根据更新后的单元状态与需要输出的量的结合更新隐藏层输出值:
output_t1_1 = input_t1*model.get_weights()[0][0][3] + model.get_weights()[2][3]+ hidden_layer_1*model.get_weights()[1][0][3]
output_t1_2 = 1/(1+np.exp(-output_t1_1))
hidden_layer_2 = np.tanh(input_t1_cell4)*output_t1_2
  1. 将隐藏层输出通过全连接层传递:
final_output = hidden_layer_2 * model.get_weights()[3][0] +model.get_weights()[4]
  1. 在我们刚刚得到的输出上运行 softmax:
np.exp(final_output)/np.sum(np.exp(final_output))
# array([0.05610514, 0.11013523, 0.3845144, 0.05296481, 0.39628044],dtype=float32)

你应该注意到,这里得到的输出与我们从 model.predict 方法中获得的输出完全相同。

通过这个练习,我们更有能力理解 LSTM 的工作细节。

实现 LSTM 用于情感分类

实现 RNN 用于情感分类 配方中,我们使用 RNN 实现了情感分类。在本配方中,我们将探讨如何使用 LSTM 实现它。

如何做…

我们将采用的步骤如下(代码文件在 GitHub 上作为 RNN_and_LSTM_sentiment_classification.ipynb 提供):

  1. 定义模型。与我们在 实现 RNN 用于情感分类 配方中看到的代码的唯一变化是将模型架构部分的 simpleRNN 改为 LSTM(我们将重用 实现 RNN 用于情感分类 配方中的 第 1 步第 6 步 的代码):
embedding_vecor_length=32
max_review_length=26
model = Sequential()
model.add(Embedding(input_dim=12533, output_dim=32, input_length = 26))

嵌入层的输入是数据集中出现的唯一 ID 的总数,以及每个词需要转换的期望维度(output_dim)。

此外,我们还将指定输入的最大长度,以便下一个步骤中的 LSTM 层能获得所需的信息——批量大小、时间步长数(input_length)和每个时间步的特征数(step(output_dim)):

model.add(LSTM(40, return_sequences=False))
model.add(Dense(2, activation='softmax'))
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])
print(model.summary())

模型的总结如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/b4e00271-4c1b-464a-bdc1-6dbc45594704.png

尽管第一层和最后一层的参数与我们在 实现 RNN 用于情感分类 配方中看到的一样,LSTM 层的参数数量不同。

让我们理解 LSTM 层中如何获得 11,680 个参数:

W = model.layers[1].get_weights()[0]
U = model.layers[1].get_weights()[1]
b = model.layers[1].get_weights()[2]
print(W.shape,U.shape,b.shape)

输出将如下所示:

((32, 160), (40, 160), (160,))

注意,前述权重的总和是 (32160) + (40160) + 160 = 11,680 个参数。

W 表示将输入连接到四个单元(ifco)的权重,U 表示隐藏层到隐藏层的连接,b 表示每个门中的偏置。

输入门、忘记门、单元状态门和输出门的各个权重可以按如下方式获得:

units = 40
W_i = W[:, :units]
W_f = W[:, units: units * 2]
W_c = W[:, units * 2: units * 3]
W_o = W[:, units * 3:]
U_i = U[:, :units]
U_f = U[:, units: units * 2]
U_c = U[:, units * 2: units * 3]
U_o = U[:, units * 3:]
b_i = b[:units]
b_f = b[units: units * 2]
b_c = b[units * 2: units * 3]
b_o = b[units * 3:]
  1. 按如下方式拟合模型:
model.fit(X_train, y_train2, validation_data=(X_test, y_test2), epochs=50, batch_size=32)

训练和测试数据集上随着训练轮数增加,损失和准确率的变化如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/ffc8b5d6-d220-487e-b635-67ce7423dddf.jpg

使用 LSTM 层时,预测准确率为 91%,略优于使用 simpleRNN 层时的预测准确率。通过微调 LSTM 单元的数量,我们可能能够进一步改善结果。

实现堆叠 LSTM 进行情感分类

在前一个食谱中,我们使用 Keras 实现了基于 LSTM 的情感分类。在本食谱中,我们将探讨如何实现相同的功能,但堆叠多个 LSTM。堆叠多个 LSTM 可能会捕捉到更多数据的变化,因此有可能得到更好的准确率。

如何实现…

堆叠 LSTM 的实现方式如下(代码文件可在 GitHub 的RNN_and_LSTM_sentiment_classification.ipynb中找到):

  1. 我们之前看到的代码唯一的变化是将return_sequences参数设置为 true。这确保了第一个 LSTM 返回一个输出序列(与 LSTM 单元的数量相同),然后可以将该输出作为输入传递给模型架构部分中的另一个 LSTM(有关return_sequences参数的更多详细信息,请参见Sequence to Sequence 学习章节):
embedding_vecor_length=32
max_review_length=26
model = Sequential()
model.add(Embedding(input_dim=12533, output_dim=32, input_length = 26))
model.add(LSTM(40, return_sequences=True))
model.add(LSTM(40, return_sequences=False))
model.add(Dense(2, activation='softmax'))
model.compile(loss='categorical_crossentropy', optimizer='adam', metrics=['accuracy'])
print(model.summary())

模型架构的总结如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/5e6ca342-8153-47a5-92ed-a04e9dcd64d8.png

请注意,在前面的架构中,有一个额外的 LSTM 堆叠在另一个 LSTM 之上。第一个 LSTM 在每个时间步的输出为40个值,因此输出形状为(None2640),其中None代表batch_size26代表时间步数,40代表考虑的 LSTM 单元数。

现在有40个输入值,第二个 LSTM 中的参数数量与之前的做法相同,如下所示:

W = model.layers[2].get_weights()[0]
U = model.layers[2].get_weights()[1]
b = model.layers[2].get_weights()[2]
print(W.shape,U.shape,b.shape)

执行前述代码后,我们得到以下值:

((40, 160), (40, 160), (160,))

这导致总共有 12,960 个参数,如输出所示。

W 的形状为 40 x 160,因为它有 40 个输入映射到 40 个输出,并且有 4 个不同的门需要控制,因此总共有 40 x 40 x 4 个权重。

  1. 按如下方式实现模型:
model.fit(X_train, y_train2, validation_data=(X_test, y_test2), epochs=50, batch_size=32)

在训练集和测试集上,随着 epoch 数的增加,损失和准确率的变化如下所示:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/d2377a1a-3446-4819-bc41-7a6583e1c92e.jpg

这导致了 91%的准确率,就像我们在使用单个 LSTM 层时看到的那样;然而,随着数据量的增加,堆叠的 LSTM 可能比普通的 LSTM 捕捉到更多数据的变化。

还有更多…

门控循环单元GRU)是我们可以使用的另一种架构,其准确率与 LSTM 相似。有关 GRU 的更多信息,请访问arxiv.org/abs/1412.3555

第十二章:多对一架构 RNN 的应用

在上一章中,我们了解了 RNN 和 LSTM 的工作原理。我们还学习了情感分类,它是一个经典的多对一应用,因为输入中的许多单词对应一个输出(正面或负面情感)。

在本章中,我们将通过以下食谱进一步加深对多对一架构 RNN 的理解:

  • 生成文本

  • 电影推荐

  • 使用嵌入进行主题建模

  • 预测股票价格的价值

生成文本

在我们在第十一章中进行的情感分类任务中,构建循环神经网络,我们尝试预测一个离散事件(情感分类)。这属于多对一架构。在本食谱中,我们将学习如何实现多对多架构,其中输出将是给定 10 个单词序列之后可能的下一个 50 个单词。

准备工作

我们生成文本的策略如下:

  1. 导入项目古腾堡的爱丽丝梦游仙境数据集,该数据集可以从www.gutenberg.org/files/11/11-0.txt下载。

  2. 对文本数据进行预处理,以便将每个单词转换为相同的大小写,并移除标点符号。

  3. 为每个唯一单词分配一个 ID,然后将数据集转换为一个单词 ID 的序列。

  4. 遍历整个数据集,每次处理 10 个单词。将这 10 个单词视为输入,并将随后的第 11 个单词视为输出。

  5. 构建并训练一个模型,通过对输入单词 ID 进行嵌入,并将嵌入连接到 LSTM,再通过隐藏层将其连接到输出层。输出层中的值是输出的独热编码版本。

  6. 通过选取一个随机位置的单词,并考虑该位置之前的历史单词来预测随后的单词。

  7. 将输入单词的窗口从我们之前选择的种子单词的位置向前移动一个位置,第十个时间步的单词将是我们在上一步骤中预测的单词。

  8. 继续这一过程以不断生成文本。

如何做到这一点……

RNN 的典型需求,我们将查看给定的 10 个单词序列,以预测下一个可能的单词。在这个练习中,我们将采用《爱丽丝梦游仙境》数据集来生成单词,如下所示(代码文件可以在 GitHub 上的RNN_text_generation.ipynb中找到):

  1. 导入相关的包和数据集:
from keras.models import Sequential
from keras.layers import Dense,Activation
from keras.layers.recurrent import SimpleRNN
from keras.layers import LSTM
import numpy as np
fin=open('alice.txt',encoding='utf-8-sig')
lines=[]
for line in fin:
  line = line.strip().lower()
  if(len(line)==0):
    continue
  lines.append(line)
fin.close()
text = " ".join(lines)

输入文本的示例如下所示:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/e3436727-b2bd-4f9c-af35-991347215f99.png

  1. 标准化文本以移除标点符号并将其转换为小写:
import re
text = text.lower()
text = re.sub('[⁰-9a-zA-Z]+',' ',text)
  1. 将唯一的单词分配给一个索引,以便在构建训练和测试数据集时引用:
from collections import Counter
counts = Counter()
counts.update(text.split())
words = sorted(counts, key=counts.get, reverse=True)
nb_words = len(text.split())
word2index = {word: i for i, word in enumerate(words)}
index2word = {i: word for i, word in enumerate(words)}
  1. 构建输入单词集,从中生成输出单词。请注意,我们考虑的是10个单词的序列,并尝试预测第 11个(th)单词:
SEQLEN = 10
STEP = 1
input_words = []
label_words = []
text2=text.split()
for i in range(0,nb_words-SEQLEN,STEP):
     x=text2[i:(i+SEQLEN)]
     y=text2[i+SEQLEN]
     input_words.append(x)
     label_words.append(y)

input_wordslabel_words列表的示例如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/a1db4f29-47c6-403b-a342-3442e1107f62.png

注意,input_words是一个包含列表的列表,而output_words则不是。

  1. 构建输入和输出数据集的向量:
total_words = len(set(words))
X = np.zeros((len(input_words), SEQLEN, total_words), dtype=np.bool)
y = np.zeros((len(input_words), total_words), dtype=np.bool)

在前面的步骤中,我们创建了空数组,这些数组将在接下来的代码中被填充:

# Create encoded vectors for the input and output values
for i, input_word in enumerate(input_words):
     for j, word in enumerate(input_word):
         X[i, j, word2index[word]] = 1
     y[i,word2index[label_words[i]]]=1

在前面的代码中,第一个for循环用于遍历输入词序列中的所有单词(输入中有10个单词),第二个for循环用于遍历选定的输入词序列中的每个单词。此外,由于输出是一个列表,我们不需要通过第二个for循环来更新它(因为没有 ID 序列)。Xy的输出形状如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/d6fc2f39-53df-42c7-b878-1a7b062b084c.png

  1. 定义模型的架构:
HIDDEN_SIZE = 128
BATCH_SIZE = 32
NUM_ITERATIONS = 100
NUM_EPOCHS_PER_ITERATION = 1
NUM_PREDS_PER_EPOCH = 100

model = Sequential()
model.add(LSTM(HIDDEN_SIZE,return_sequences=False,input_shape=(SEQLEN,total_words)))
model.add(Dense(total_words, activation='softmax'))
model.compile(optimizer='adam', loss='categorical_crossentropy')
model.summary()

模型的总结如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/1369b6ce-e893-4f4c-8c0c-156ad528671e.png

  1. 拟合模型。观察输出随着轮次的增加如何变化。生成一个随机的10个单词的序列,并尝试预测下一个可能的单词。我们可以观察到,随着轮次的增加,我们的预测逐渐变得更好:
for iteration in range(50):
     print("=" * 50)
     print("Iteration #: %d" % (iteration))
     model.fit(X, y, batch_size=BATCH_SIZE, epochs=NUM_EPOCHS_PER_ITERATION, validation_split = 0.1)
     test_idx = np.random.randint(int(len(input_words)*0.1)) * (-1)
     test_words = input_words[test_idx]
     print("Generating from seed: %s" % (test_words))
     for i in range(NUM_PREDS_PER_EPOCH): 
         Xtest = np.zeros((1, SEQLEN, total_words))
         for i, ch in enumerate(test_words):
             Xtest[0, i, word2index[ch]] = 1
         pred = model.predict(Xtest, verbose=0)[0]
         ypred = index2word[np.argmax(pred)]
         print(ypred,end=' ')
         test_words = test_words[1:] + [ypred]

在前面的代码中,我们正在为一个轮次(epoch)拟合我们的模型,使用输入和输出数组。此外,我们选择一个随机的种子词(test_idx – 这是一个随机数,位于输入数组的最后 10%中(因为validation_split0.1),并在随机位置收集输入单词。我们将输入 ID 序列转换为 one-hot 编码版本(因此得到的数组形状为 1 x 10 x total_words)。

最后,我们对刚刚创建的数组进行预测,并获得概率最高的单词。我们来看一下第一轮(epoch)输出的结果,并与第*25^(th)*轮的输出进行对比:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/241f2377-e03e-4480-9486-3f2db105aaf5.png

注意,第一轮的输出总是the。然而,在经过 50 轮训练后,输出变得更加合理,如下所示:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/e2a8dab4-322a-446e-b552-0c8844540497.png

Generating from seed行是预测结果的集合。

注意,虽然训练损失随着轮次的增加而减少,但在 50 轮结束时,验证损失变得更糟。随着我们在更多文本上训练和/或进一步微调模型,这将得到改善。

此外,这个模型可以通过使用双向 LSTM 进一步改进,我们将在序列到序列学习一章中讨论。使用双向 LSTM 后的输出如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/76fac46c-8de1-4a31-a8b5-190b4ba55fa9.png

电影推荐

推荐系统在用户发现过程中的作用非常重要。想象一下一个电商目录,其中包含成千上万种不同的产品。此外,某个产品的不同变体也会存在。在这种情况下,向用户普及产品或事件(例如某些产品正在打折)成为增加销售的关键。

准备就绪

在这个案例中,我们将学习如何为一个包含用户对电影评分的数据库构建推荐系统。这个练习的目标是最大化电影与用户的相关性。在定义目标时,我们还应考虑到,虽然推荐的电影可能仍然相关,但用户可能不会立即观看。同时,我们还应确保所有推荐内容不都属于同一类型。尤其在零售环境下,我们不希望在所有推荐中都推荐同一产品的不同变体。

让我们正式定义我们的目标和约束:

  • 目标:最大化推荐内容对用户的相关性

  • 约束:增加推荐的多样性,并向用户提供最多 12 个推荐

相关性的定义因用例而异,通常由商业原则指导。在本例中,我们将相关性定义得比较狭窄;也就是说,如果用户购买了在给定用户的前 12 个推荐商品中的任何一项,就视为成功。

基于此,让我们定义构建模型的步骤:

  1. 导入数据。

  2. 推荐一部用户会高评分的电影——因此,让我们根据用户历史上喜欢的电影来训练模型。用户不喜欢某些电影的洞察将有助于进一步改善我们的推荐。然而,先保持简单。

  3. 只保留观看了超过五部电影的用户。

  4. 为独特的用户和电影分配 ID。

  5. 鉴于用户的偏好可能随时间变化,我们需要考虑用户的历史,历史中的不同事件有不同的权重。鉴于这是一个时间序列分析问题,我们将利用 RNN 来解决该问题。

  6. 对数据进行预处理,以便将其传递给 LSTM:

    • 输入将是用户观看的历史五部电影

    • 输出是用户观看的第六部电影

  7. 构建一个执行以下操作的模型:

    1. 为输入的电影创建嵌入

    2. 将嵌入通过 LSTM 层

    3. 将 LSTM 的输出通过一个全连接层

    4. 在最终层应用 softmax,以生成推荐的电影列表

如何做…

现在我们已经了解了执行各种步骤的策略,让我们开始编写代码(代码文件在 GitHub 上的Chapter_12_Recommender_systems.ipynb中提供):

  1. 导入数据。我们将使用一个包含用户列表、用户对不同电影评分以及用户提供评分的时间戳的数据集:
import numpy as np
import pandas as pd
ratings = pd.read_csv('..') # Path to the file containing required fields

数据集的一个示例如下所示:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/033e8f33-fbdb-4a5c-b80a-626371a6ca12.png

  1. 过滤掉用户没有喜欢的电影或没有足够历史记录的用户。在以下代码中,我们排除了用户给出低评分的电影:
ratings = ratings[ratings['rating']>3]
ratings = ratings.sort_values(by='timestamp')
ratings.reset_index(inplace=True)
ratings = ratings.drop(['index'],axis=1)

在以下代码中,我们仅保留那些在历史记录中提供超过5个评分(评分值大于3)的用户:

user_movie_count =ratings.groupby('User').agg({'Movies':'nunique'}).reset_index()
user_movie_count.columns = ['User','Movie_count']
ratings2 = ratings.merge(user_movie_count,on='User',how='inner')
movie_count = ratings2[ratings2['Movie_count']>5]
movie_count = movie_count.sort_values('timestamp')
movie_count.reset_index(inplace=True)
movie_count = movie_count.drop(['index'],axis=1)
  1. 为独特的usersMovies分配 ID,以便后续使用:
ratings = movie_count
users = ratings.User.unique()
articles = ratings.Movies.unique()
userid2idx = {o:i for i,o in enumerate(users)}
articlesid2idx = {o:i for i,o in enumerate(articles)}
idx2userid = {i:o for i,o in enumerate(users)}
idx2articlesid = {i:o for i,o in enumerate(articles)}

ratings['Movies2'] = ratings.Movies.apply(lambda x: articlesid2idx[x])
ratings['User2'] = ratings.User.apply(lambda x: userid2idx[x])
  1. 对数据进行预处理,使得输入是最后五部电影,输出是第六部观看的电影:
user_list = movie_count['User2'].unique()
historical5_watched = []
movie_to_predict = []
for i in range(len(user_list)):
     total_user_movies = movie_count[movie_count['User2']==user_list[i]].copy()
     total_user_movies.reset_index(inplace=True)
     total_user_movies = total_user_movies.drop(['index'],axis=1)
     for j in range(total_user_movies.shape[0]-6):
         historical5_watched.append(total_user_movies.loc[j:(j+4),'Movies2'].tolist())                                                                          movie_to_predict.append(total_user_movies.loc[(j+5),'Movies2'].tolist())
  1. historical5_watchedmovie_to_predict变量进行预处理,以便将其传递给模型,然后创建训练集和测试集:
movie_to_predict2 = to_categorical(y, num_classes = max(y)+1)
trainX = np.array(historical5_watched[:40000])
testX = np.array(historical5_watched[40000:])
trainY = np.array(movie_to_predict2[:40000])
testY = np.array(movie_to_predict2[40000:])
  1. 构建模型:
src_vocab = ratings['Movies2'].nunique()
n_units = 32
src_timesteps = 5
tar_vocab = len(set(y))

from keras.models import Sequential, Model
from keras.layers import Embedding
from keras.layers import LSTM, RepeatVector, TimeDistributed, Dense, Bidirectional

model = Sequential()
model.add(Embedding(src_vocab, n_units, input_length=src_timesteps))
model.add((LSTM(100)))
model.add(Dense(1000,activation='relu'))
model.add(Dense(max(y)+1,activation='softmax'))
model.summary()

请注意,在最后一层中,我们对可能的激活值加 1,因为没有 ID 为 0 的电影,如果我们仅将值设置为max(y),最终的电影将被排除在外。

模型摘要如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/82bd4fe6-5261-4651-b339-d7ee590096d0.png

  1. 拟合模型:
model.fit(trainX, trainY, epochs=5, batch_size=32, validation_data=(testX, testY), verbose = 1)
  1. 对测试数据进行预测:
pred = model.predict(testX)
  1. 了解数据点的数量(用户),其中接下来观看的电影在前五部历史电影之后位于前12个推荐中:
count = 0
for i in range(testX.shape[0]):
    rank = np.argmax(np.argsort(pred[i])[::-1]==np.argmax(testY[i]))
    if rank<12:
        count+=1
count/testX.shape[0]
# 0.104

我们应该注意到,在总案例的 10.4%中,推荐给用户的电影恰好是他们接下来要观看的电影。

考虑用户历史

在发布前 12 个推荐电影时,我们需要考虑的一个因素是如果用户已经观看过某部电影,他们不太可能再次观看同一部电影(请注意,这一假设在零售环境中并不成立,因为在零售环境中会有相当数量的重复订单)。

让我们继续应用这个逻辑,进行前 12 个推荐的预测。

首先,我们将存储所有(不仅仅是最近观看的五部)用户在观看我们尝试预测的电影之前观看过的电影:

historically_watched = []
for i in range(len(user_list)):
     total_user_movies = movie_count[movie_count['User2']==user_list[i]].copy()
     total_user_movies.reset_index(inplace=True)
     total_user_movies = total_user_movies.drop(['index'],axis=1)
     for j in range(total_user_movies.shape[0]-6):
         historically_watched.append(total_user_movies.loc[0:(j+4),'Movies2'].tolist())

在前述代码中,我们过滤出所有用户观看过的电影。

如果用户已经观看过一部电影,我们将该用户-电影组合的概率覆盖为零:

for j in range(pred.shape[0]):
  for i in range(pred.shape[1]):
    pred[j][i]= np.where(i in historically_watched[j], 0 , pred[j][i])

在以下代码中,我们计算测试数据中用户观看的电影是否在前 12 个推荐电影中所占的百分比:

count = 0
for i in range(testX.shape[0]):
  rank = np.argmax(np.argsort(pred[i])[::-1]==np.argmax(testY[i]))
  if rank<12:
    count+=1
count/testX.shape[0]
#12.6

通过前述方法,推荐有效的用户比例从上次迭代的 10.4%上升至 12.6%。

主题建模,使用嵌入

在前面的配方中,我们学习了如何为用户可能观看的电影生成预测。以前生成预测的方式的一个限制是,如果我们没有在电影预测之上进行进一步处理,那么电影推荐的多样性将受到限制。

多样化的推荐非常重要;如果没有多样性,用户只会发现某些类型的产品。

在这个配方中,我们将基于它们的相似性对电影进行分组,并识别电影的共同主题。此外,我们还将探讨如何增加向用户提供的推荐多样性。尽管如此,在电影推荐的具体案例中,这种策略的可行性可能较低,因为与在零售/电子商务设置中产品类别和替代品的数量相比,电影的类别和替代品要少得多。

准备工作

我们将采取的基于相似性分组电影的策略如下:

  1. 从我们在电影推荐配方中构建的模型中提取每部电影的嵌入值

    • 我们还可以使用 gensim 为每部电影创建嵌入

    • 用户观看的所有电影可以被视为句子中的单词

    • 创建一个由形成句子的单词 ID 列表的列表

    • 通过 gensim 的 Word2Vec 方法传递列表列表,以提取单词向量(电影 ID 向量)

  2. 将电影的嵌入值(向量)通过 k-means 聚类过程,提取出若干个簇

  3. 确定最优簇的数量

  4. 在每个簇中识别高概率购买的产品(在历史上未购买的产品中),并根据它们的概率重新排名产品

  5. 推荐前 n 个产品

在此过程中,一个变量是要形成的簇的数量。簇的数量越多,每个簇中的产品越少,同时,同一簇内的每个产品之间的相似性越大。基本上,数据点数量与同一簇内数据点相似性之间存在权衡。

通过计算所有点相对于它们的聚类中心的平方距离之和,我们可以得出一组内部点相似性的度量。惯性度量不会显著减少的簇的数量是最优簇的数量。

如何做到这一点…

现在我们已经形成了在我们的推荐中获取各种产品的策略,让我们编写代码(我们将从 电影推荐 配方的第 3 步继续)。 代码文件在 GitHub 中可作为 Chapter_12_Recommender_systems.ipynb 获得。

  1. 使用 Word2Vec 提取每部电影的嵌入值。

    1. 创建所有用户观看的各种电影列表的列表:
user_list = movie_count['User2'].unique()
user_movies = []
for i in range(len(user_list)):
     total_user_movies = movie_count[movie_count['User2']==user_list[i]].copy()
     total_user_movies.reset_index(inplace=True)
     total_user_movies = total_user_movies.drop(['index'],axis=1)
     total_user_movies['Movies3'] = total_user_movies['Movies2'].astype(str)
     user_movies.append(total_user_movies['Movies3'].tolist())

在前述代码中,我们过滤了所有用户观看的电影,并创建了所有用户观看的电影列表。

  1. 提取每部电影的词向量:
from gensim.models import Word2Vec
w2v_model = Word2Vec(user_movies,size=100,window=5,min_count=5, iter = 500)
  1. 提取电影的TSNE值,以便对我们在上一阶段提取的电影词嵌入进行可视化表示:
from sklearn.manifold import TSNE
tsne_model = TSNE(n_components=2, verbose=1, random_state=0)
tsne_img_label = tsne_model.fit_transform(w2v_model.wv.syn0)
tsne_df = pd.DataFrame(tsne_img_label, columns=['x', 'y'])
tsne_df['image_label'] = list(w2v_model.wv.vocab.keys())

from ggplot import *
chart = ggplot(tsne_df, aes(x='x', y='y'))+geom_point(size=70,alpha=0.5)
chart

2D 空间中嵌入的可视化如下所示:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/cb69b499-e7ba-41a8-b2ea-dd2b11c96e87.png

从前面的输出中,我们可以看到有些电影被分组在一起(这些区域比较密集)。

  1. 将电影 ID 和电影索引值存储在数据框中:
idx2movie = pd.DataFrame([idx2moviesid.keys(), idx2moviesid.values()]).T
idx2movie.columns = ['image_label','movieId']
  1. 合并tsne_dfidx2movie数据集,这样我们就能在一个单独的数据框中得到所有的值:
tsne_df['image_label'] = tsne_df['image_label'].astype(int)
tsne_df2 = pd.merge(tsne_df, idx2movie, on='image_label', how='right')
  1. 导入movies数据集:
movies = pd.read_csv('...') # Path to movies dataset
  1. TSNE数据集与电影数据合并,并删除不需要的列:
tsne_df3 = pd.merge(tsne_df2, movies, left_on='movieId', right_on = 0, how='inner')
tsne_df4 = tsne_df3.drop([2,3,4],axis=1)
tsne_df4.rename(columns={1:'movie_name'}, inplace=True)
  1. 排除包含 NaN 值的行(由于某些电影出现的频率较低,导致Word2Vec没有为这些稀有词提供词向量(由于min_count参数):
tsne_df5 = tsne_df4.loc[~np.isnan(tsne_df4['x']),]
  1. 通过了解惯性变化(所有点到各自聚类中心的总平方距离)来确定最佳聚类数量:
X = tsne_df5.loc[:,['x','y']]
inertia = []
for i in range(10):
      km = KMeans((i+1)*10)
      km.fit(X)
      inertia.append(km.inertia_)

import matplotlib.pyplot as plt
%matplotlib inline
plt.plot((np.arange(10)+1)*10,inertia)
plt.title('Variation of inertia over different number of clusters')
plt.xlabel('Number of clusters')
plt.ylabel('Inertia')

不同数量聚类的惯性变化如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/d667c7e1-453a-402c-a79b-1decc6dfa5bb.png

从前面的曲线来看,我们可以看到,当聚类数量超过40时,惯性下降的幅度没有那么大。因此,我们将40定为我们数据集中电影的最佳聚类数量。

  1. 通过手动检查某些落入同一聚类的电影来验证聚类结果,检查这些电影是否合理地分在同一聚类:
km = KMeans(40)
km.fit(X)
tsne_df5['clusterlabel'] = km.labelstsne_df5[tsne_df5['cluster_label']==0].head()

一旦执行代码,你会注意到位于cluster_label0的电影主要是浪漫和喜剧类型的电影。

  1. 移除用户已观看的电影:
for j in range(pred.shape[0]):
     for i in range(pred.shape[1]):
         pred[j][i]= np.where(i in historically_watched[j], 0 , pred[j][i])
  1. 对于每个用户,映射电影的概率和电影所属的聚类编号,以便我们可以为每个用户提取该聚类中概率最高的电影。然后从不同聚类中提取前 12 部电影进行推荐:
movie_cluster_id = tsne_df5[['image_label','cluster_label']]
count = 0
for j in range(pred.shape[0]):
      t = movie_cluster_id.copy()
      t['pred']=pred[j,list(movie_cluster_id['image_label'])]
      t2= t.sort_values(by='pred',ascending=False).groupby('cluster_label').first().reset_index()
      t3 = t2.sort_values(by='pred',ascending=False).reset_index()
      final_top_preds = t3.loc[:11]['image_label'].values
      if (np.argmax(testY[j]) in final_top_preds):
            count+=1

前述结果显示,13.6%的用户观看了推荐给他们的电影。

虽然前述结果仅比没有任何推荐多样性的 12.6%结果稍好,但考虑到不仅是下一次购买,而是所有用户未来的购买,拥有多样性的推荐更可能带来更好的效果。

还有更多…

尽管我们已经考虑了为用户生成预测,并提高推荐多样性,但我们还可以通过以下方法进一步改进结果:

  • 融入用户不喜欢的电影信息

  • 融入用户的人口统计信息

  • 融入有关电影的详细信息,例如上映年份和演员阵容

预测股票价格的价值

专家们进行的技术分析有很多种,用以提出股票的买卖建议。大多数技术分析依赖于历史模式,假设历史会重复,只要我们对某些事件进行标准化处理。

鉴于我们到目前为止所做的也是通过考虑历史来做决策的,让我们继续应用我们迄今为止学到的技巧来预测股票价格。

然而,在依赖算法分析做出买卖决策时,如股票价格预测,务必小心。与其他方法的区别在于,其他方法中的决策是可逆的(例如:如果生成的文本不合适,可以撤销),或者是有成本的(糟糕的推荐意味着客户不会再次购买该产品),而股票价格预测中的决策是不可逆的。一旦资金损失,就无法追回。

牢记这一点,让我们继续应用我们迄今为止学到的技巧来预测股票价格。

准备工作

为了预测股票价格,让我们应用两种策略:

  • 仅根据过去五天的股票价格来预测股票价格

  • 基于过去五天的股票价格和公司最新新闻的结合来预测股票价格

对于第一次分析,我们可以准备数据集,方式与为 LSTM 准备数据集非常相似;第二次分析则需要不同的数据准备方式,因为它涉及到数值和文本数据。

我们将为上述讨论的两种方法处理数据的方式如下:

  • 仅使用过去五天的股票价格

    1. 按照从最旧到最新的日期排序数据集

    2. 以前5个股票价格作为输入,第六个股票价格作为输出

    3. 将其滑动,以便在下一个数据点中,第二到第六个数据点作为输入,第七个数据点作为输出,依此类推,直到我们达到最后一个数据点:

      1. 这五个数据点作为 LSTM 中的五个时间步骤的输入

      2. 第六个数据点是输出

    4. 鉴于我们预测的是一个连续的数字,这次的损失函数将是均方误差

  • 过去五天的股票价格加上新闻标题、公司数据:对于这个分析,有两种数据预处理方式。虽然过去五天股票价格的数据预处理保持不变,但新闻标题和数据的预处理步骤是此分析中要执行的额外步骤。让我们看看如何将这两者融入到我们的模型中:

    1. 鉴于这些是两种不同的数据类型,让我们使用两个不同的模型:

      • 一个使用历史五天股票价格数据的模型。

      • 另一个模型,通过增加或减少过去五天股票价格模型的输出,来修改其结果。

      • 第二个模型来源于新闻头条数据集。假设正面新闻头条更可能提升股票价格,而负面新闻则可能降低股票价格。

    2. 为简化问题,假设在预测股票价值的当天,只有最新的新闻头条会影响股票的预测结果

    3. 鉴于我们有两个不同的模型,使用函数式 API 以便我们结合两者因素的影响

如何操作…

我们将解决此问题的方法分为三个部分(代码文件在 GitHub 中以 Chapter_12_stock_price_prediction.ipynb 呈现):

  • 仅基于过去五天的股票价格预测股票价格

    • 随机训练和测试集划分的陷阱
  • 为较新的股票价格赋予更高的权重

  • 将过去五天的股票价格与新闻文章头条的文本数据结合

仅使用过去五天的股票价格

在本菜谱中,我们仅基于过去五个数据点来预测股票价格。在下一个菜谱中,我们将基于新闻和历史数据来预测股票价格:

  1. 导入相关的包和数据集:
import pandas as pd
data2 = pd.read_csv('/content/stock_data.csv')
  1. 准备数据集,其中输入为过去五天的股票价格,输出为第六天的股票价格:
x= []
y = []
for i in range(data2.shape[0]-5):
     x.append(data2.loc[i:(i+4)]['Close'].values)
     y.append(data2.loc[i+5]['Close'])

import numpy as np
x = np.array(x)
y = np.array(y)
  1. 将数据集重新整形为 batch_sizetime_stepsfeatures_per_time_step 形式:
x = x.reshape(x.shape[0],x.shape[1],1)
  1. 创建训练集和测试集:
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(x, y, test_size=0.30,random_state=10)
  1. 构建模型:
model = Sequential()
model.add(Dense(100, input_shape = (5,1), activation = 'relu'))
model.add((LSTM(100)))
model.add(Dense(1000,activation='relu'))
model.add(Dense(1,activation='linear'))
model.summary()

模型的总结如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/5800a442-ce90-4d0c-a004-b02a43688d92.png

  1. 编译模型以定义 loss 函数并调整学习率:
from keras.optimizers import Adam
adam = Adam(lr=0.0001)
model.compile(optimizer=adam, loss='mean_squared_error')
  1. 拟合模型:
model.fit(X_train, y_train, epochs=400, batch_size=64, validation_data=(X_test, y_test), verbose = 1)

之前的结果表明,在测试数据集上的均方误差值为 $641(每次预测大约 ~$25)。预测股票价格与实际股票价格的对比图如下:

pred = model.predict(X_test)

import matplotlib.pyplot as plt
%matplotlib inline
plt.figure(figsize=(20,10))
plt.plot(y_test,'r')
plt.plot(pred,'--')

plt.title('Variation of actual and predicted stock price')
plt.ylabel('Stock price')

预测值与实际价格的差异如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/a2feff03-73e4-4623-93c1-b925100125da.png

陷阱

现在我们有了相当准确的预测,实际上,预测效果非常好,让我们深入了解这些优秀预测的原因。

在我们的训练数据集中,既有很久以前的数据,也有非常近期的数据。这是一种数据泄漏,因为在构建模型时,我们无法获得未来的股票价格。由于我们的数据构建方式,我们的训练数据集可能包含来自 12 月 20 日的数据,而 12 月 19 日的数据则可能在测试数据集中。

让我们用训练集和测试集按照相应日期重新构建我们的模型:

X_train = x[:2100,:,:]
y_train = y[:2100]
X_test = x[2100:,:,:]
y_test = y[2100:]

在新测试数据集上,我们在 仅使用过去 5 天的股票价格 部分构建的模型输出如下(测试数据集的损失大约为 57,000):

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/117796e9-8284-4993-b262-5909a8774f44.png

注意,与上一次迭代相比,结果实际与预测的股价图现在更糟。然而,在此部分生成的图表比最近 5 天的股票价格部分获得的图表更具现实情况。

现在我们已经获得了前面的图表,让我们试着理解图表看起来如此的原因,通过检查股价随时间变化的绘图,如下所示:

plt.plot(data2['Close'])

股价随时间变化的图表如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/74f903fe-d2e4-415a-b085-4a4c73cc32da.png

注意,股票价格在开始时缓慢上升,并在中间加速,最后减速。

模型由于以下原因表现不佳:

  • 对于早期和最近的预测错误,都赋予了相等的权重。

  • 我们没有考虑减速趋势。

分配不同的权重给不同的时间段。

我们了解到,我们将为最近的时间段分配更高的权重,而为历史时间段分配较低的权重。

我们可以如下制定训练weights

weights = np.arange(X_train.shape[0]).reshape((X_train.shape[0]),1)/2100

前面的代码将最历史数据点的权重分配为0,将最近数据点的权重分配为1。所有中间数据点的权重值将在01之间。

现在我们已经定义了weights,让我们定义我们的自定义损失函数,该函数在计算平方误差损失时应用先前初始化的损失:

import numpy as np
from keras.layers import Dense, Input
from keras import Model
import keras.backend as K
from functools import partial

def custom_loss(y_true, y_pred, weights):
     return K.square(K.abs(y_true - y_pred) * weights)
cl = partial(custom_loss, weights=weights_tensor)

现在我们已经初始化了weights并定义了自定义损失函数,让我们使用功能 API 将输入层和权重值提供给模型(我们使用功能 API 因为在训练模型时传递了多个输入):

input_layer = Input(shape=(5,1))
weights_tensor = Input(shape=(1,))

i1 = Dense(100, activation='relu')(input_layer)
i2 = LSTM(100)(i1)
i3 = Dense(1000, activation='relu')(i2)
out = Dense(1, activation='linear')(i3)
model = Model([input_layer, weights_tensor], out)

现在我们已经定义了模型,该模型与最近 5 天的股票价格部分中的参数相同,但还有一个额外的输入,即权重张量。让我们编译我们的模型:

from keras.optimizers import Adam
adam = Adam(lr=0.0001)
model = Model([input_layer, weights_tensor], out)
model.compile(adam, cl)

现在我们已经编译了我们的模型,让我们拟合它:

model.fit(x=[X_train, weights], y=y_train, epochs=300,batch_size = 32, validation_data = ([X_test, test_weights], y_test))

模型在测试数据集上返回了 40,000 的平方误差损失,与陷阱部分的 57,000 损失相比。让我们在测试数据集上绘制预测股价与实际股价的值:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/42c55eae-c592-4663-8ec3-eccb1898150c.png

我们现在注意到,在最近的历史记录中(图表的最右侧部分),预测的股价与实际股价之间存在相关性,而图表中间的尖峰未被预测所考虑。

在下一个案例中,让我们看看新闻标题是否能包含中间的尖峰。

最近五天的股票价格加上新闻数据

在下面的代码中,我们将包含来自感兴趣公司的标题的文本数据(从《卫报》网站提供的开放源代码 API 获取),以及过去五天的股价数据。然后我们将结合自定义损失函数,该函数会考虑事件的时效性:

  1. 从这里导入《卫报》网站的标题数据:open-platform.theguardian.com/(请注意,您需要申请自己的访问密钥,才能从该网站下载数据集)。下载标题及其出现的对应日期,然后预处理日期,使其转换为日期格式:
from bs4 import BeautifulSoup
from bs4 import BeautifulSoup
import urllib, json

dates = []
titles = []
for i in range(100):
 try:
        url = 'https://content.guardianapis.com/search?from-date=2010-01-01&section=business&page-          size=200&order-by=newest&page='+str(i+1)+'&q=amazon&api-key=0d7'
        response = urllib.request.urlopen(url)
        encoding = response.info().get_content_charset('utf8')
        data = json.loads(response.read().decode(encoding))
        print(i)
        for j in range(len(data['response']['results'])):
              dates.append(data['response']['results'][j]['webPublicationDate'])
              titles.append(data['response']['results'][j]['webTitle']) 
 except:
       break

import pandas as pd
data = pd.DataFrame(dates, titles)
data = data.reset_index()
data.columns = ['title','date']
data['date']=data['date'].str[:10]
data['date']=pd.to_datetime(data['date'], format = '%Y-%m-%d')
data = data.sort_values(by='date')
data_final = data.groupby('date').first().reset_index()
  1. 通过Date将历史价格数据集和文章标题数据集合并:
data2['Date'] = pd.to_datetime(data2['Date'],format='%Y-%m-%d')
data3 = pd.merge(data2,data_final, left_on = 'Date', right_on = 'date', how='left')
  1. 对文本数据进行预处理,去除停用词和标点符号,然后像我们在第十一章《构建循环神经网络》中做的那样,对文本输入进行编码:
import nltk
import re
nltk.download('stopwords')
stop = nltk.corpus.stopwords.words('english')
def preprocess(text):
     text = str(text)
     text=text.lower()
     text=re.sub('[⁰-9a-zA-Z]+',' ',text)
     words = text.split()
     words2=[w for w in words if (w not in stop)]
     words3=' '.join(words2)
     return(words3)
data3['title'] = data3['title'].apply(preprocess)
data3['title']=np.where(data3['title'].isnull(),'-','-'+data3['title'])
docs = data3['title'].values
from collections import Counter
counts = Counter()
for i,review in enumerate(docs):
     counts.update(review.split())
words = sorted(counts, key=counts.get, reverse=True)
vocab_size=len(words)
word_to_int = {word: i for i, word in enumerate(words, 1)}
encoded_docs = []
for doc in docs:
     encoded_docs.append([word_to_int[word] for word in doc.split()])
max_length = 20
from keras.preprocessing.sequence import pad_sequences
padded_docs = pad_sequences(encoded_docs, maxlen=max_length,padding='pre')
  1. 以过去五天的股价和最新的标题(在股价预测日期之前的标题)为输入。让我们预处理数据,获取输入和输出值,然后准备训练集和测试集:

在以下代码中,x1 对应历史股价,x2 对应股价预测日期的文章标题:

x1 = []
x2 = []
y = []
for i in range(data3.shape[0]-5):
     x1.append(data3.loc[i:(i+4)]['Close'].values)
     x2.append(padded_docs[i+5])
     y.append(data3.loc[i+5]['Close'])

x1 = np.array(x1)
x2 = np.array(x2)
y = np.array(y)
x1 = x1.reshape(x1.shape[0],x1.shape[1],1)
X1_train = x1[:2100,:,:]
X2_train = x2[:2100,:]
y_train = y[:2100]

X1_test = x1[2100:,:,:]
X2_test = x2[2100:,:]
y_test = y[2100:]
  1. 鉴于我们将多个变量作为输入(历史股价、编码的文本数据和权重值),我们将使用函数式 API 来构建模型:
input1 = Input(shape=(20,))
model = Embedding(input_dim=vocab_size+1, output_dim=32, input_length=20)(input1)
model = (LSTM(units=100))(model)
model = (Dense(1, activation='tanh'))(model)

input2 = Input(shape=(5,1))
model2 = Dense(100, activation='relu')(input2)
model2 = LSTM(units=100)(model2)
model2 = (Dense(1000, activation="relu"))(model2)
model2 = (Dense(1, activation="linear"))(model2)

from keras.layers import multiply
conc = multiply([model, model2])
conc2 = (Dense(1000, activation="relu"))(conc)
out = (Dense(1, activation="linear"))(conc2)

请注意,我们已经将股价模型和文本数据模型的输出值相乘,因为文本数据需要与历史股价模型的输出进行调整:

model = Model([input1, input2, weights_tensor], out)

前述模型的架构如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/95795ad5-7d62-4cf3-a38d-2cc879bb6826.png

  1. 定义损失函数并编译模型:
def custom_loss(y_true, y_pred, weights):
 return K.square(K.abs(y_true - y_pred) * weights)
cl = partial(custom_loss, weights=weights_tensor)

model = Model([input1, input2, weights_tensor], out)
model.compile(adam, cl)
  1. 训练模型:
model.fit(x=[X2_train, X1_train, weights], y=y_train, epochs=300,batch_size = 32, validation_data = ([X2_test, X1_test, test_weights], y_test))
  1. 绘制测试集中的实际股价与预测股价的比较图:
pred = model.predict([X2_test, X1_test, test_weights])

import matplotlib.pyplot as plt
%matplotlib inline
plt.figure(figsize=(20,10))
plt.plot(y_test,'r',label='actual')
plt.plot(pred,'--', label = 'predicted')
plt.title('Variation of actual and predicted stock price')
plt.ylabel('Stock price')
plt.legend()

实际股价与预测股价的变化如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/a804018e-6956-4350-9d12-2428053d76ac.png

请注意,在这一轮迭代中,与没有文本数据的版本相比,中间部分的斜率稍好一些,并且平方误差稍低,为 35,000,而前一轮的平方误差为 40,000。

还有更多内容…

如本教程开头所提到的,预测股价时要非常小心,因为股价的波动受多种因素的影响,所有这些因素在预测时都需要考虑进去。

此外,您还应该注意,虽然实际值和预测值看起来相关,但预测值线比实际值线稍有延迟。这种延迟可能会导致从买入决策变为卖出决策的最佳策略发生显著变化。因此,在股价变动显著的前一日期,应给予更大的权重——这进一步复杂化了我们的损失函数。

我们也可以考虑整合更多信息源,如额外的新闻标题和季节性因素(例如:某些股票在假期季节通常表现良好)以及其他宏观经济因素,在进行预测时使用。

最后,我们本可以对数据集进行缩放,以便神经网络的输入不是一个巨大的数字。

第十三章:序列到序列学习

在前面的章节中,我们学习了 RNN 的应用,其中有多个输入(每个时间步长一个输入)和一个输出。然而,还有一些应用场景涉及多个输入以及多个时间步长——例如机器翻译,其中源句子有多个输入词,目标句子有多个输出词。鉴于有多个输入和多个输出,这就变成了一个多输出的基于 RNN 的应用——本质上是一个序列到序列的学习任务。这要求我们在构建模型架构时采用不同于目前为止的方式,这将在本章中进行讲解。本章将涵盖以下内容:

  • 从网络返回序列

  • 双向 LSTM 如何帮助命名实体提取

  • 提取意图和实体以构建聊天机器人

  • 编码器-解码器网络架构的工作原理

  • 使用编码器-解码器架构将英语句子翻译成法语

  • 通过使用注意力机制改善翻译结果

介绍

在前面的章节中,我们了解到 LSTM,甚至是 RNN,是从最后一个时间步长返回结果的(最后一个时间步长的隐藏状态值会传递给下一层)。假设输出是五维的,其中五个维度是五个输出(而不是五个类别的 softmax 值)。为了进一步解释这个想法,假设我们不仅预测下一天的股价,而是预测未来五天的股价。或者,我们不仅预测下一个词,而是预测给定输入序列的下一个五个词的序列。

这种情况需要采用不同的方法来构建网络。在接下来的部分,我们将探讨构建网络的多个场景,以便在不同时间步长中提取输出结果。

场景 1:命名实体提取

在命名实体提取中,我们试图为句子中的每个词分配一个标签——判断它是否与人名、地名相关。因此,这变成了输入词与其是否为人名或地名的输出类别之间的一对一映射问题。尽管输入和输出之间是一个一对一的映射,但在某些情况下,周围的词语会在决定某个输入是否是命名实体时起到作用。例如,单独的new可能不是命名实体,但如果newyork一起出现,那么我们就知道它是一个命名实体。因此,这是一个问题,输入的时间步长在决定一个词是否是命名实体时起到了作用,即使在大多数情况下,输入和输出之间可能会存在一对一的映射。

此外,这是一个序列返回问题,因为我们是基于输入的词序列来判断命名实体是否存在。鉴于此,这是一个输入与输出之间一一对应的关系问题,且相邻时间步的输入在决定输出时起着关键作用。只要确保时间步中两个方向的词都能影响输出,传统的 LSTM 就可以工作。因此,双向 LSTM 在解决这种问题时非常有用。

双向 LSTM 的架构如下所示:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/94365b7c-707e-40a3-91cf-6d910224b71e.png

请注意,在前面的图示中,我们通过使输入之间相互连接并反向流动,修改了传统的 LSTM,从而确保信息能够从两个方向流动。我们将在后续部分学习更多关于双向 LSTM 如何工作的内容,以及如何应用它。

场景 2:文本摘要

文本摘要任务需要不同于我们之前讨论的架构,因为我们通常需要在阅读完整个输入句子(本例中的输入文本/评论)后,才能生成摘要。

这要求将所有输入编码为一个向量,然后基于输入的编码向量生成输出。此外,鉴于给定文本中的一系列词可能有多个输出(多个词),这就变成了一个多输出生成问题,因此,另一个可以利用 RNN 的多输入多输出特性的场景也随之而来。

让我们来看一下如何构建模型来得出解决方案:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/8b95da24-105a-44a0-95b4-f7013121e0f0.png

请注意,在前述架构中,我们将所有输入文本编码为输入序列的最后一个词生成的向量,然后将该编码向量作为输入传递给解码器序列。本章后续部分将提供更多关于如何构建此网络的信息。

场景 3:机器翻译

在前面的场景中,我们将输入编码为一个向量,并希望该向量也能包含词序信息。但是,如果我们通过网络显式提供一种机制,让网络能够根据我们正在解码的词的位置,给输入词在给定位置上的不同加权,该怎么办呢?例如,如果源语言和目标语言的词对齐方式相似,也就是说,两种语言的词序相似,那么源语言开头的词对目标语言最后一个词的影响很小,但对决定目标语言第一个词的影响却很大。

注意力机制如下所示:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/436d46d9-15db-42f7-9905-f830e190e66a.png

请注意,注意力向量(中间部分)受输入编码向量和输出值的隐藏状态的影响。更多关于如何利用注意力机制的内容将在后文中讨论。

通过理解不同编码器-解码器架构的原因,让我们深入了解如何在 Keras 中生成输出序列。

从网络中返回输出序列

正如我们在上一节中讨论的那样,生成输出序列的网络架构有多种方式。在本节中,我们将学习如何通过编码器-解码器方式生成输出,并且通过一个玩具数据集学习输入到输出的逐一映射网络,以便更好地理解这一过程。

让我们定义一个输入序列和一个对应的输出序列,如下所示(代码文件可在 GitHub 上的Return_state_and_sequences_working_details.ipynb找到):

input_data = np.array([[1,2],[3,4]])
output_data = np.array([[3,4],[5,6]])

我们可以看到,输入中有两个时间步,并且每个输入都有相应的输出。

如果我们用传统的方法来解决这个问题,我们会像下面的代码那样定义模型架构。请注意,我们使用的是函数式 API,因为在后续的场景中,我们将提取多个输出,并检查中间层:

# define model
inputs1 = Input(shape=(2,1))
lstm1 = LSTM(1, activation = 'tanh', return_sequences=False,recurrent_initializer='Zeros',recurrent_activation='sigmoid')(inputs1)
out= Dense(2, activation='linear')(lstm1)
model = Model(inputs=inputs1, outputs=out)
model.summary()

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/41ed74e0-5c52-4375-8fb3-a94922771671.png

请注意,在上述场景中,LSTM 接收的数据形状是(batch_size,时间步,时间步每个特征)。由于 LSTM 不返回一系列输出,LSTM 的输出是隐藏层中的一个值(因为 LSTM 的单元数为 1)。

由于输出是二维的,我们将添加一个全连接层,该层接受隐藏层的输出并从中提取2个值。

让我们开始拟合模型,如下所示:

model.compile(optimizer='adam',loss='mean_squared_error')
model.fit(input_data.reshape(2,2,1), output_data,epochs=1000)
print(model.predict(input_data[0].reshape(1,2,1)))
# [[2.079641 1.8290598]]

现在我们有了输出,让我们像上一章那样验证结果(注意,这段代码与上一章中的完全相同——它的解释已经在第十一章的从头开始构建 LSTM一节中提供)。

input_t0 = 1
cell_state0 = 0
forget0 = input_t0*model.get_weights()[0][0][1] + model.get_weights()[2][1]
forget1 = 1/(1+np.exp(-(forget0)))
cell_state1 = forget1 * cell_state0
input_t0_1 = input_t0*model.get_weights()[0][0][0] + model.get_weights()[2][0]
input_t0_2 = 1/(1+np.exp(-(input_t0_1)))
input_t0_cell1 = input_t0*model.get_weights()[0][0][2] + model.get_weights()[2][2]
input_t0_cell2 = np.tanh(input_t0_cell1)
input_t0_cell3 = input_t0_cell2*input_t0_2
input_t0_cell4 = input_t0_cell3 + cell_state1
output_t0_1 = input_t0*model.get_weights()[0][0][3] + model.get_weights()[2][3]
output_t0_2 = 1/(1+np.exp(-output_t0_1))
hidden_layer_1 = np.tanh(input_t0_cell4)*output_t0_2
input_t1 = 2
cell_state1 = input_t0_cell4
forget21 = hidden_layer_1*model.get_weights()[1][0][1] + model.get_weights()[2][1] + input_t1*model.get_weights()[0][0][1]
forget_22 = 1/(1+np.exp(-(forget21)))
cell_state2 = cell_state1 * forget_22
input_t1_1 = input_t1*model.get_weights()[0][0][0] + model.get_weights()[2][0] + hidden_layer_1*model.get_weights()[1][0][0]
input_t1_2 = 1/(1+np.exp(-(input_t1_1)))
input_t1_cell1 = input_t1*model.get_weights()[0][0][2] + model.get_weights()[2][2]+ hidden_layer_1*model.get_weights()[1][0][2]
input_t1_cell2 = np.tanh(input_t1_cell1)
input_t1_cell3 = input_t1_cell2*input_t1_2
input_t1_cell4 = input_t1_cell3 + cell_state2
output_t1_1 = input_t1*model.get_weights()[0][0][3] + model.get_weights()[2][3]+ hidden_layer_1*model.get_weights()[1][0][3]
output_t1_2 = 1/(1+np.exp(-output_t1_1))
hidden_layer_2 = np.tanh(input_t1_cell4)*output_t1_2
final_output = hidden_layer_2 * model.get_weights()[3][0] + model.get_weights()[4]

final_output的输出如下:

[[2.079 1.829]]

你应该注意到,前面生成的final_output与我们在model.predict输出中看到的是完全相同的。

通过这种方式生成输出的一个缺点是,在时间步 1的输出显然不依赖于时间步 2的情况下,我们使得模型很难找到将时间步 2的值对时间步 1的影响隔离开来的方法,因为我们正在获取时间步 2的隐藏层输出(它是时间步 1和时间步 2输入值的组合)。

我们可以通过从每个时间步提取隐藏层值,然后将其传递到全连接层来解决这个问题。

返回每个时间步隐藏层值的序列

在接下来的代码中,我们将了解如何返回每个时间步的隐藏层值序列:

# define model
inputs1 = Input(shape=(2,1))
lstm1 = LSTM(1, activation = 'tanh', return_sequences=False,recurrent_initializer='Zeros',recurrent_activation='sigmoid')(inputs1)
out= Dense(1, activation='linear')(lstm1)
model = Model(inputs=inputs1, outputs=out)
model.summary()

注意我们所做的两个代码更改如下:

  • return_sequences参数的值更改为True

  • 给定输出为1的全连接层:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/a0f4c974-43dd-4375-8968-774c5e1e97d0.png

注意,因为我们提取了每个时间步的隐藏层值(其中隐藏层只有一个单元),所以 LSTM 的输出形状是(批量大小,时间步,1)。

此外,由于有一个全连接层将 LSTM 的输出连接到每个时间步的最终输出,因此输出形状保持不变。

让我们继续训练模型,如下所示:

model.compile(optimizer='adam',loss='mean_squared_error')
model.fit(input_data.reshape(2,2,1), output_data.reshape(2,2,1),epochs=1000)

预测值如下所示:

print(model.predict(input_data[0].reshape(1,2,1)))

前面的执行将给出以下输出:

[[[1.7584195] [2.2500749]]]

与前一部分类似,我们将通过输入通过权重进行前向传播,然后匹配我们的预测值来验证结果。

我们将提取第一个时间步的输出,如下所示:

input_t0 = 1
cell_state0 = 0
forget0 = input_t0*model.get_weights()[0][0][1] + model.get_weights()[2][1]
forget1 = 1/(1+np.exp(-(forget0)))
cell_state1 = forget1 * cell_state0
input_t0_1 = input_t0*model.get_weights()[0][0][0] + model.get_weights()[2][0]
input_t0_2 = 1/(1+np.exp(-(input_t0_1)))
input_t0_cell1 = input_t0*model.get_weights()[0][0][2] + model.get_weights()[2][2]
input_t0_cell2 = np.tanh(input_t0_cell1)
input_t0_cell3 = input_t0_cell2*input_t0_2
input_t0_cell4 = input_t0_cell3 + cell_state1
output_t0_1 = input_t0*model.get_weights()[0][0][3] + model.get_weights()[2][3]
output_t0_2 = 1/(1+np.exp(-output_t0_1))
hidden_layer_1 = np.tanh(input_t0_cell4)*output_t0_2
final_output_1 = hidden_layer_1 * model.get_weights()[3][0] + model.get_weights()[4]
final_output_1
*# 1.7584*

你应该注意到final_output_1值与第一个时间步的预测值相匹配。同样,我们继续验证第二个时间步的预测:

input_t1 = 2
cell_state1 = input_t0_cell4
forget21 = hidden_layer_1*model.get_weights()[1][0][1] + model.get_weights()[2][1] + input_t1*model.get_weights()[0][0][1]
forget_22 = 1/(1+np.exp(-(forget21)))
cell_state2 = cell_state1 * forget_22
input_t1_1 = input_t1*model.get_weights()[0][0][0] + model.get_weights()[2][0] + hidden_layer_1*model.get_weights()[1][0][0]
input_t1_2 = 1/(1+np.exp(-(input_t1_1)))
input_t1_cell1 = input_t1*model.get_weights()[0][0][2] + model.get_weights()[2][2]+ hidden_layer_1*model.get_weights()[1][0][2]
input_t1_cell2 = np.tanh(input_t1_cell1)
input_t1_cell3 = input_t1_cell2*input_t1_2
input_t1_cell4 = input_t1_cell3 + cell_state2
output_t1_1 = input_t1*model.get_weights()[0][0][3] + model.get_weights()[2][3]+ hidden_layer_1*model.get_weights()[1][0][3]
output_t1_2 = 1/(1+np.exp(-output_t1_1))
hidden_layer_2 = np.tanh(input_t1_cell4)*output_t1_2
final_output_2 = hidden_layer_2 * model.get_weights()[3][0] + model.get_weights()[4]
final_output_2
*# 2.250*

你应该注意到,这会返回与第二个时间步的model.predict值完全相同的结果。

现在我们理解了网络中的return_sequences参数,让我们继续学习另一个参数——return_state。我们知道网络的两个输出是隐藏层值(当return_sequencesFalse时,它也是 LSTM 在最终时间步的输出,而当return_sequencesTrue时,它是 LSTM 在每个时间步的输出)和细胞状态值。

return_state有助于提取网络的细胞状态值。

提取细胞状态对于将输入文本编码为向量时很有用,我们不仅会传递编码向量,还会将输入编码器的最终细胞状态传递给解码器网络(更多内容请见 机器翻译的编码器解码器架构 部分)。

在接下来的部分,我们来了解return_state是如何工作的。请注意,这只是为了帮助我们理解每个时间步的细胞状态是如何生成的,因为实际上我们会将此步骤的输出(隐藏层值和细胞状态值)作为输入传递给解码器:


inputs1 = Input(shape=(2,1))
lstm1,state_h,state_c = LSTM(1, activation = 'tanh', return_sequences=True, return_state = True, recurrent_initializer='Zeros',recurrent_activation='sigmoid')(inputs1)
model = Model(inputs=inputs1, outputs=[lstm1, state_h, state_c])

在前面的代码中,我们同样将return_state参数设置为True。注意现在 LSTM 的输出:

  • lstm1:每个时间步的隐藏层(因为在前面的情境中,return_sequencesTrue

  • state_h:最终时间步的隐藏层值

  • state_c:最终时间步的细胞状态值

让我们继续预测值,如下所示:

print(model.predict(input_data[0].reshape(1,2,1)))

我们将得到以下值:

[array([[[-0.256911 ], [-0.6683883]]], dtype=float32), array([[-0.6683883]], dtype=float32), array([[-0.96862674]], dtype=float32)]

您应该会看到三个输出数组,正如我们之前讨论的:隐藏层值序列、最终隐藏层值,以及按顺序排列的单元状态值。

让我们验证之前得到的数字:

input_t0 = 1
cell_state0 = 0
forget0 = input_t0*model.get_weights()[0][0][1] + model.get_weights()[2][1]
forget1 = 1/(1+np.exp(-(forget0)))
cell_state1 = forget1 * cell_state0
input_t0_1 = input_t0*model.get_weights()[0][0][0] + model.get_weights()[2][0]
input_t0_2 = 1/(1+np.exp(-(input_t0_1)))
input_t0_cell1 = input_t0*model.get_weights()[0][0][2] + model.get_weights()[2][2]
input_t0_cell2 = np.tanh(input_t0_cell1)
input_t0_cell3 = input_t0_cell2*input_t0_2
input_t0_cell4 = input_t0_cell3 + cell_state1
output_t0_1 = input_t0*model.get_weights()[0][0][3] + model.get_weights()[2][3]
output_t0_2 = 1/(1+np.exp(-output_t0_1))
hidden_layer_1 = np.tanh(input_t0_cell4)*output_t0_2
print(hidden_layer_1)

前述计算中的hidden_layer_1值为-0.2569,这是我们从model.predict方法中获得的值:

input_t1 = 2
cell_state1 = input_t0_cell4
forget21 = hidden_layer_1*model.get_weights()[1][0][1] + model.get_weights()[2][1] + input_t1*model.get_weights()[0][0][1]
forget_22 = 1/(1+np.exp(-(forget21)))
cell_state2 = cell_state1 * forget_22
input_t1_1 = input_t1*model.get_weights()[0][0][0] + model.get_weights()[2][0] + hidden_layer_1*model.get_weights()[1][0][0]
input_t1_2 = 1/(1+np.exp(-(input_t1_1)))
input_t1_cell1 = input_t1*model.get_weights()[0][0][2] + model.get_weights()[2][2]+ hidden_layer_1*model.get_weights()[1][0][2]
input_t1_cell2 = np.tanh(input_t1_cell1)
input_t1_cell3 = input_t1_cell2*input_t1_2
input_t1_cell4 = input_t1_cell3 + cell_state2
output_t1_1 = input_t1*model.get_weights()[0][0][3] + model.get_weights()[2][3]+ hidden_layer_1*model.get_weights()[1][0][3]
output_t1_2 = 1/(1+np.exp(-output_t1_1))
hidden_layer_2 = np.tanh(input_t1_cell4)*output_t1_2
print(hidden_layer_2, input_t1_cell4)

hidden_layer_2input_t1_cell4的值分别是-0.6683-0.9686

您会注意到,输出与我们在predict函数中看到的完全相同。

在双向网络的情况下,我们在计算时从两个方向同时引入隐藏层值,代码如下:

inputs1 = Input(shape=(2,1))
lstm1,state_fh,state_fc,state_bh,state_bc = Bidirectional(LSTM(1, activation = 'tanh', return_sequences=True, return_state = True, recurrent_initializer='Zeros',recurrent_activation='sigmoid'))(inputs1)
model = Model(inputs=inputs1, outputs=[lstm1, state_fh,state_fc,state_bh,state_bc])
model.summary()

请注意,在双向 LSTM 中,最终的隐藏状态有两个输出,一个是从左到右考虑输入时间步长时的输出,另一个是从右到左考虑输入时间步长时的输出。以类似的方式,我们也有两个可能的单元状态值。

通常,我们会将得到的隐藏状态连接成一个单一的向量,并将单元状态也连接成另一个单一的向量。

为简洁起见,本书中不对双向 LSTM 的输出进行验证。不过,您可以在本章附带的 Jupyter Notebook 中查看相关验证。

构建聊天机器人

在某些场景下,聊天机器人非常有用,尤其是当机器人能够自动处理一些常见查询时。这在实际场景中非常有用,尤其是在你只需要从数据库中查找结果,或查询 API 以获得与查询相关的结果时。

基于此,您可以设计聊天机器人的两种潜在方式,如下所示:

  • 将非结构化的用户查询转换为结构化格式:

    • 根据转换后的结构从数据库查询
  • 根据输入文本生成回应

在本次练习中,我们将采用第一种方法,因为它更可能提供可以在呈现给用户之前进一步调整的预测结果。此外,我们还将了解为什么在机器翻译和文本摘要案例研究后,可能不希望根据输入文本生成回应。

将用户查询转换为结构化格式涉及以下两个步骤:

  1. 为查询中的每个单词分配实体

  2. 理解查询的意图

命名实体识别是一个应用广泛的技术,适用于多个行业。例如,用户想去哪里旅行?用户考虑购买哪个产品?等等。从这些例子中,我们可能会认为命名实体识别只是从现有城市名称或产品名称的字典中进行简单查找。然而,考虑一种情境,当用户说“我想从波士顿到西雅图”时,机器虽然知道波士顿西雅图是城市名,但我们无法判断哪个是from城市,哪个是to城市。

尽管我们可以添加一些启发式规则,比如在“to”前面有名字的是to city,另一个是from city,但在多个类似示例中复制这个过程时它并不可扩展。神经网络在这种情况下非常有用,因为我们不再依赖手动调整特征。我们将让机器处理特征工程的部分,以提供输出。

准备就绪

基于前述的直觉,让我们继续定义解决这个问题的方法,假设数据集包含与航空公司相关的用户查询。

目标:从查询中提取各种实体,同时提取查询的意图。

方法

  • 我们将找到一个数据集,其中包含查询标签和每个查询单词所属的实体:

    • 如果没有标注数据集,我们将手动标注查询中的实体,对于合理数量的示例进行标注,以便训练我们的模型。
  • 考虑到周围的词汇可能会影响给定单词分类为某一类别的结果,让我们使用基于 RNN 的技术来解决这个问题。

  • 另外,考虑到周围的单词可能位于给定单词的左侧或右侧,我们将使用双向 RNN 来解决这个问题。

  • 预处理输入数据集,以便可以输入到 RNN 的多个时间步中。

  • 对输出数据集进行一热编码,以便我们可以优化模型。

  • 构建模型,返回查询中每个单词所对应的实体。

  • 同样,构建另一个模型,提取查询的意图。

如何做到…

让我们按照之前定义的方法编写代码,如下所示(代码文件可在 GitHub 上的Intent_and_entity_extraction.ipynb中找到):

  1. 导入数据集,如以下代码所示:
!wget https://www.dropbox.com/s/qpw1wnmho8v0gi4/atis.zip
!unzip atis.zip

加载训练数据集:

import numpy as np 
import pandas as pd
import pickle
DATA_DIR="/content"
def load_ds(fname='atis.train.pkl'):
     with open(fname, 'rb') as stream:
     ds,dicts = pickle.load(stream)
     print('Done loading: ', fname)
     print(' samples: {:4d}'.format(len(ds['query'])))
     print(' vocab_size: {:4d}'.format(len(dicts['token_ids'])))
     print(' slot count: {:4d}'.format(len(dicts['slot_ids'])))
     print(' intent count: {:4d}'.format(len(dicts['intent_ids'])))
     return ds,dicts
import os
train_ds, dicts = load_ds(os.path.join(DATA_DIR,'atis.train.pkl'))
test_ds, dicts = load_ds(os.path.join(DATA_DIR,'atis.test.pkl'))

上述代码输出如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/05d0278c-db84-4685-817d-f16a1f3dd349.png

注意,附加数据集中的样本是用户查询,slot 是单词所属的实体,而 intent 是查询的整体意图。

  1. 对查询、slot 和 intent 中的每个单词应用 ID:
t2i, s2i, in2i = map(dicts.get, ['token_ids', 'slot_ids','intent_ids'])
i2t, i2s, i2in = map(lambda d: {d[k]:k for k in d.keys()}, [t2i,s2i,in2i])
query, slots, intent = map(train_ds.get, ['query', 'slot_labels', 'intent_labels'])

词汇中的标记(单词)、slot(单词的实体)和 intent 的 ID 示例如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/176a3d2e-003c-4b7a-b8ec-fb5a2a0cea62.png

最后,查询、槽位和意图被转换为 ID 值,如下所示(我们报告第一个查询、意图和槽位的输出):

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/340d4444-f66e-435e-af01-a205b4b24886.png

查询、意图和与查询中词对应的实体示例如下:

for j in range(len(query[i])):
        print('{:>33} {:>40}'.format(i2t[query[i][j]],
                                     i2s[slots[i][j]]))

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/c5a633a2-8dd1-41bc-bf74-c6e032c8af78.png

查询是前面截图顶部的语句。槽位表示每个词所属的对象类型。请注意,O表示对象,其他每个实体名称都是自描述的。此外,总共有 23 个可能的意图类,它们在总体上描述查询。

在以下代码中,我们将所有数据转换为一个列表的列表,其中每个列表对应数据集中的一个查询:

i2t2 = []
i2s2 = []
c_intent=[]
for i in range(4978):
     a_query = []
     b_slot = []
     c_intent.append(i2in[intent[i][0]])
     for j in range(len(query[i])):
         a_query.append(i2t[query[i][j]])
         b_slot.append(i2s[slots[i][j]])
     i2t2.append(a_query)
     i2s2.append(b_slot)
i2t2 = np.array(i2t2)
i2s2 = np.array(i2s2)
i2in2 = np.array(c_intent)

一些令牌、意图和查询的示例如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/ac4fb687-0852-40b5-98b4-b9716ee3dff6.png

  1. 创建索引化的输入和输出:
final_sentences = []
final_targets = []
final_docs = []
for i in range(len(i2t2)):
  tokens = ''
  entities = ''
  intent = ''
  for j in range(len(i2t2[i])):
    tokens= tokens + i2t2[i][j] + ' '
    entities = entities + i2s2[i][j] +' '
  intent = i2in2[i]
  final_sentences.append(tokens)
  final_targets.append(entities)
  final_docs.append(intent)

前面的代码为我们提供了最终查询和目标的列表,如下所示:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/e318c431-d86c-4258-8a57-42afef0e49c2.png

现在,我们将每个输入句子转换为其组成词的对应 ID 列表:

from collections import Counter
counts = Counter()
for i,sentence in enumerate(final_sentences):
     counts.update(sentence.split())
sentence_words = sorted(counts, key=counts.get, reverse=True)
chars = sentence_words
nb_chars = len(chars)
sentence_word_to_int = {word: i for i, word in enumerate(sentence_words, 1)}
sentence_int_to_word = {i: word for i, word in enumerate(sentence_words, 1)}
mapped_reviews = []
for review in final_sentences:
     mapped_reviews.append([sentence_word_to_int[word] for word in review.split()])

在以下代码中,我们将每个输出词转换为其组成的词 ID:

from collections import Counter
counts = Counter()
for i,sentence in enumerate(final_targets):
    counts.update(sentence.split())
target_words = sorted(counts, key=counts.get, reverse=True)
chars = target_words
nb_chars = len(target_words)
target_word_to_int = {word: i for i, word in enumerate(target_words, 1)}
target_int_to_word = {i: word for i, word in enumerate(target_words, 1)}
mapped_targets = []
for review in final_targets:
    mapped_targets.append([target_word_to_int[word] for word in review.split()])
  1. 填充输入并对输出进行独热编码:
from keras.preprocessing.sequence import pad_sequences
y = pad_sequences(maxlen=124, sequences=mapped_targets, padding="post", value=0)
from keras.utils import to_categorical
y2 = [to_categorical(i, num_classes=124) for i in y]
y3 = np.array(y2)

在以下代码中,我们决定在填充输入之前查询的最大长度:

length_sent = []
for i in range(len(mapped_reviews)):
     a = mapped_reviews[i]
     b = len(a)
     length_sent.append(b)
np.max(length_sent)

在前面的代码中,我们决定在填充输入之前查询的最大长度——这恰好是48

在以下代码中,我们使用最大长度为50来填充输入和输出,因为没有输入查询的长度超过48个词(即max(length_sent)):

from keras.preprocessing.sequence import pad_sequences
X = pad_sequences(maxlen=50, sequences=mapped_reviews, padding="post", value=0)
Y = pad_sequences(maxlen=50, sequences=mapped_targets, padding="post", value=0)

在以下代码中,我们将输出转换为独热编码版本:

from keras.utils import to_categorical
y2 = [to_categorical(i, num_classes=124) for i in Y]
y2 = np.array(y2)

我们总共有124个类,因为总共有123个唯一类,且词汇索引从1开始。

  1. 构建、训练和测试数据集,以及模型:
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X,y2, test_size=0.30,random_state=10)

在前面的代码中,我们将数据集分割为训练集和测试集:

input = Input(shape=(50,))
model = Embedding(input_dim=891, output_dim=32, input_length=50)(input)
model = Dropout(0.1)(model)
model = Bidirectional(LSTM(units=100, return_sequences=True, recurrent_dropout=0.1))(model)
out = (Dense(124, activation="softmax"))(model)
model = Model(input, out)
model.summary()

模型的总结如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/083bf99b-1860-45af-a90a-5537a1108d2a.png

请注意,在前面的代码中,我们使用了双向 LSTM,因此隐藏层有 200 个单元(因为 LSTM 层有 100 个单元)。

  1. 编译并拟合模型,如下所示:
model.compile(optimizer="adam", loss="categorical_crossentropy", metrics=["accuracy"])
model.fit(X_train,y_train, batch_size=32, epochs=5, validation_data = (X_test,y_test), verbose=1)

前面的代码产生了一个模型,该模型在查询中的每个词上正确识别实体的准确率为 95%:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/69930f68-e78b-4661-9ec4-0ed72bf4c79e.png

从前面的输出中,我们可以看到我们为每个词分配正确实体的准确率超过 95%。

意图提取

现在我们已经构建了一个具有良好准确性的模型,能够预测查询中的实体,接下来让我们找出查询的意图。

我们将重用在前一个模型中初始化的大部分变量:

  1. 将每个查询的意图转换为 ID:
from collections import Counter
counts = Counter()
for i,sentence in enumerate(final_docs):
     counts.update(sentence.split())
intent_words = sorted(counts, key=counts.get, reverse=True)
chars = intent_words
nb_chars = len(intent_words)
intent_word_to_int = {word: i for i, word in enumerate(intent_words, 1)}
intent_int_to_word = {i: word for i, word in enumerate(intent_words, 1)}
mapped_docs = []
for review in final_docs:
     mapped_docs.append([intent_word_to_int[word] for word in review.split()])
  1. 提取意图的独热编码版本:
from keras.utils import to_categorical
doc2 = [to_categorical(i[0], num_classes=23) for i in mapped_docs]
doc3 = np.array(doc2)
  1. 构建模型,如以下代码所示:
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X,doc3, test_size=0.30,random_state=10)
input = Input(shape=(50,))
model2 = Embedding(input_dim=891, output_dim=32, input_length=50)(input)
model2 = Dropout(0.1)(model2)
model2 = Bidirectional(LSTM(units=100))(model2)
out = (Dense(23, activation="softmax"))(model2)
model2 = Model(input, out)
model2.compile(optimizer="adam", loss="categorical_crossentropy", metrics=["accuracy"])
model2.fit(X_train,y_train, batch_size=32, epochs=5, validation_data = (X_test,y_test), verbose=1)

前面的代码结果是一个模型,它在验证数据集上正确识别查询意图的准确率为 90%:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/7653b79e-d64e-4f58-a465-ed0c04bfb805.png

将所有内容整合起来

在上一节中,我们构建了两个模型,第一个模型预测查询中的实体,第二个模型提取查询的意图。

在本节中,我们将定义一个函数,接受查询并将其转换为结构化格式:

  1. 预处理新的输入文本,以便将其传递给模型:
def preprocessing(text):
     text2 = text.split()
     a=[]
     for i in range(len(text2)):
         a.append(sentence_word_to_int[text2[i]])
     return a
  1. 预处理输入文本,将其转换为单词 ID 列表:
text = "BOS i would fly from boston to dallas EOS"
indexed_text = preprocessing(text)
padded_text=np.zeros(50)
padded_text[:len(indexed_text)]=indexed_text
padded_text=padded_text.reshape(1,50)

前面的结果处理后的输入文本如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/f247e339-2e03-4054-96d4-1e0073daf1c6.png

现在,我们将预测前面列表的意图:

pred_index_intent = np.argmax(model2.predict(c),axis=1)
entity_int_to_word[pred_index_intent[0]]

前面的代码结果是查询的意图是关于航班的,如下所示:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/6b2208a9-fac9-4df0-88bf-75c54c8f2dff.png

  1. 提取查询中与单词相关的实体:
pred_entities = np.argmax(model.predict(padded_text),axis=2)

for i in range(len(pred_entities[0])):
      if pred_entities[0][i]>1:
            print('word: ',text.split()[i], 'entity: ',target_int_to_word[pred_entities[0][i]])

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/aaae4594-3625-403a-8025-1476f985fb32.png

从前面的代码中,我们可以看到模型已经正确地将一个单词分类到正确的实体中。

现在我们已经识别出实体和意图,可以使用预定义的 SQL 查询(或 API),其参数由提取的实体填充,每个意图可能具有不同的 API/SQL 查询来为用户提取信息。

机器翻译

到目前为止,我们已经看到一个输入和输出一一对应的场景。在本节中,我们将探讨如何构建能够将所有输入数据映射到一个向量,然后将其解码为输出向量的架构。

在这个案例研究中,我们将把一段英文输入文本翻译成法语文本。

准备就绪

我们将定义的用于执行机器翻译的架构如下:

  • 获取一个标记化的数据集,其中包含输入句子和对应的法语翻译

  • 对英文和法语文本中频繁出现的单词进行标记化和提取:

    • 为了识别频繁的单词,我们将统计每个单词的频率

    • 构成所有单词总累计频率前 80% 的单词被认为是频繁单词

  • 对于所有不属于频繁单词的单词,将其替换为未知(unk)符号

  • 为每个单词分配一个 ID

  • 构建一个编码器 LSTM,提取输入文本的向量

  • 通过密集层传递编码向量,以便在每个时间步骤提取解码文本的概率

  • 训练模型以最小化输出的损失

如何做到这一点…

可能有多种模型架构可以帮助翻译输入文本。我们将在以下章节中介绍其中的一些(代码文件在 GitHub 上可用,名为Machine_translation.ipynb)。

数据预处理

为了将输入和输出数据传递给我们的模型,我们需要像下面这样预处理数据集:

  1. 导入相关的包和数据集:
import pandas as pd
import numpy as np
import string
from string import digits
import matplotlib.pyplot as plt
%matplotlib inline
import re
from sklearn.model_selection import train_test_split
from keras.models import Model
from keras.layers import Input, LSTM, Dense
import numpy as np
$ wget https://www.dropbox.com/s/2vag8w6yov9c1qz/english%20to%20french.txt
lines= pd.read_table('english to french.txt', names=['eng', 'fr'])
  1. 鉴于数据集中有超过 140,000 个句子,我们将仅考虑前 50,000 对句子翻译对来构建模型:
lines = lines[0:50000]
  1. 将输入和输出文本转换为小写并移除标点符号:
lines.eng=lines.eng.apply(lambda x: x.lower())
lines.fr=lines.fr.apply(lambda x: x.lower())
exclude = set(string.punctuation)
lines.eng=lines.eng.apply(lambda x: ''.join(ch for ch in x if ch not in exclude))
lines.fr=lines.fr.apply(lambda x: ''.join(ch for ch in x if ch not in exclude))
  1. 为输出句子(法语句子)添加开始和结束标记。我们添加这些标记是为了在编码器-解码器架构中起到帮助作用。这个方法的作用将在编码器解码器架构用于机器翻译部分说明:
lines.fr = lines.fr.apply(lambda x : 'start '+ x + ' end')

数据的示例如下所示:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/b5773770-4057-4fec-a4af-4d033094e420.png

  1. 识别常见单词。我们定义一个单词为常见,如果它出现在频率构成所有单词总频率 80%的单词列表中:
# fit a tokenizer
from keras.preprocessing.text import Tokenizer
import json
from collections import OrderedDict
def create_tokenizer(lines):
     tokenizer = Tokenizer()
     tokenizer.fit_on_texts(lines)
     return tokenizer
eng_tokenizer = create_tokenizer(lines.eng)
output_dict = json.loads(json.dumps(eng_tokenizer.word_counts))
df =pd.DataFrame([output_dict.keys(), output_dict.values()]).T
df.columns = ['word','count']
df = df.sort_values(by='count',ascending = False)
df['cum_count']=df['count'].cumsum()
df['cum_perc'] = df['cum_count']/df['cum_count'].max()
final_eng_words = df[df['cum_perc']<0.8]['word'].values

前面的代码提取了累计构成输入中 80%总英语单词的英语单词数量:

fr_tokenizer = create_tokenizer(lines.fr)
output_dict = json.loads(json.dumps(fr_tokenizer.word_counts))
df =pd.DataFrame([output_dict.keys(), output_dict.values()]).T
df.columns = ['word','count']
df = df.sort_values(by='count',ascending = False)
df['cum_count']=df['count'].cumsum()
df['cum_perc'] = df['cum_count']/df['cum_count'].max()
final_fr_words = df[df['cum_perc']<0.8]['word'].values

前面的代码提取了累计构成输出中 80%总法语单词的法语单词数量。

  1. 过滤掉不常见的单词。如果某个单词不在常见单词列表中,我们将用一个未知单词unk来替代它:
def filter_eng_words(x):
     t = []
     x = x.split()
     for i in range(len(x)):
         if x[i] in final_eng_words:
             t.append(x[i])
         else:
             t.append('unk')
     x3 = ''
     for i in range(len(t)):
         x3 = x3+t[i]+' '
     return x3

前面的代码以句子为输入,提取唯一的单词,如果某个单词不在更常见的英语单词(final_eng_words)中,则用unk替代:

def filter_fr_words(x):
     t = []
     x = x.split()
     for i in range(len(x)):
         if x[i] in final_fr_words:
             t.append(x[i])
         else:
             t.append('unk')
     x3 = ''
     for i in range(len(t)):
         x3 = x3+t[i]+' '
     return x3

前面的代码以句子为输入,提取唯一的单词,如果某个单词不在更常见的法语单词(final_fr_words)中,则用unk替代。

例如,在一个包含常见单词和不常见单词的随机句子中,输出结果如下所示:

filter_eng_words('he is extremely good')

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/7e4f6d3b-1d82-4d60-bf15-dffc91a1ba42.png

lines['fr']=lines['fr'].apply(filter_fr_words)
lines['eng']=lines['eng'].apply(filter_eng_words)

在前面的代码中,我们根据之前定义的函数替换所有的英语和法语句子。

  1. 给每个单词在英语(输入)和法语(输出)句子中分配一个 ID:

    1. 存储数据中所有唯一单词的列表(英语和法语句子):
all_eng_words=set()
for eng in lines.eng:
     for word in eng.split():
         if word not in all_eng_words:
             all_eng_words.add(word)

all_french_words=set()
for fr in lines.fr:
     for word in fr.split():
         if word not in all_french_words:
             all_french_words.add(word)
input_words = sorted(list(all_eng_words))
target_words = sorted(list(all_french_words))
num_encoder_tokens = len(all_eng_words)
num_decoder_tokens = len(all_french_words)
    1. 创建输入单词及其对应索引的字典:
input_token_index = dict( [(word, i+1) for i, word in enumerate(input_words)])
target_token_index = dict( [(word, i+1) for i, word in enumerate(target_words)])
  1. 提取输入和目标句子的最大长度,以便所有句子具有相同的大小:
length_list=[]
for l in lines.fr:
     length_list.append(len(l.split(' ')))
fr_max_length = np.max(length_list)
length_list=[]
for l in lines.eng:
     length_list.append(len(l.split(' ')))
eng_max_length = np.max(length_list)

现在我们已经处理好数据集,让我们在数据集上尝试多种架构,比较它们的表现。

传统的多对多架构

在这个架构中,我们将每个输入单词嵌入到一个 128 维的向量中,得到形状为(batch_size, 128, 17)的输出向量。我们这样做是因为在这个版本中,我们希望测试输入数据有 17 个时间步,输出数据集也有 17 个时间步的场景。

我们将通过 LSTM 将每个输入时间步连接到输出时间步,然后对预测结果执行 softmax:

  1. 创建输入和输出数据集。注意我们有decoder_input_datadecoder_target_data。现在,让我们将decoder_input_data创建为目标句子单词对应的单词 ID。decoder_target_data是目标数据的独热编码版本,包含在start标记后的所有单词:
encoder_input_data = np.zeros((len(lines.eng), fr_max_length),dtype='float32')
decoder_input_data = np.zeros((len(lines.fr), fr_max_length),dtype='float32')
decoder_target_data = np.zeros((len(lines.fr), fr_max_length, num_decoder_tokens+1),dtype='float32')

请注意,我们在num_decodder_tokens中添加了+1,因为在我们在步骤 7b中创建的字典中没有对应于索引0的单词。

for i, (input_text, target_text) in enumerate(zip(lines.eng, lines.fr)):
     for t, word in enumerate(input_text.split()):
         encoder_input_data[i, t] = input_token_index[word]
     for t, word in enumerate(target_text.split()):
 # decoder_target_data is ahead of decoder_input_data by one timestep
         decoder_input_data[i, t] = target_token_index[word]
         if t>0: 
 # decoder_target_data will be ahead by one timestep
 # and will not include the start character.
             decoder_target_data[i, t - 1, target_token_index[word]] = 1.
         if t== len(target_text.split())-1:
             decoder_target_data[i, t:, 89] = 1

在上面的代码中,我们正在循环遍历输入文本和目标文本,将英语或法语中的句子替换为对应的英语和法语单词 ID。

此外,我们在解码器中对目标数据进行独热编码,以便将其传递给模型。由于现在所有句子具有相同的长度,我们在for循环中将目标数据的值替换为在第 89 个索引处的 1(因为89是结束索引),当句子长度超出时:

for i in range(decoder_input_data.shape[0]):
     for j in range(decoder_input_data.shape[1]):
         if(decoder_input_data[i][j]==0):
             decoder_input_data[i][j] = 89

在上面的代码中,我们将解码器输入数据中零的值替换为 89(因为 89 是结束标记,零在我们创建的单词索引中没有任何单词对应)。

注意我们创建的三个数据集的形状如下:

print(decoder_input_data.shape,encoder_input_data.shape,decoder_target_data.shape)

以下是前面代码的输出:

(50000, 17) (50000, 17) (50000, 17, 359)
  1. 按照如下方式构建和拟合模型:
model = Sequential()
model.add(Embedding(len(input_words)+1, 128, input_length=fr_max_length, mask_zero=True))
model.add((Bidirectional(LSTM(256, return_sequences = True))))
model.add((LSTM(256, return_sequences=True)))
model.add((Dense(len(target_token_index)+1, activation='softmax')))

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/5b1c05e4-823c-4fae-8955-1d164f10ebdd.png

model.compile(optimizer='adam', loss='categorical_crossentropy',metrics=['acc'])
model.fit(encoder_input_data, decoder_target_data,
 batch_size=32, epochs=5, validation_split=0.05)

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/58c5e0ac-b3aa-4bf6-a6cb-0800b766d9dd.png

注意,模型输出的准确度可能具有误导性,因为它也将end标记计入准确度衡量中。

  1. 计算正确翻译的单词数量:
count = 0
correct_count = 0
pred = model2.predict(encoder_input_data[47500:])
for i in range(2500):
  t = np.argmax(pred[i], axis=1)
  act = np.argmax(decoder_target_data[47500],axis=1)
  correct_count += np.sum((act==t) & (act!=89))
  count += np.sum(act!=89)
correct_count/count
# 0.19

在上面的代码中,我们正在对测试数据进行预测(测试数据是总数据集的最后 5%,因为验证集为 5%)。

从前面的代码可以看出,大约 19%的总单词被正确翻译。

多对隐藏到多架构

之前架构的一个缺点是,我们必须人为地将输入的时间步数增加到 17,尽管我们知道输入最大只有八个时间步,其中有一些输入。

在这个架构中,构建一个模型,提取输入的最后时间步的隐藏状态值。此外,它将隐藏状态值复制 17 次(因为输出有 17 个时间步)。它将复制的隐藏时间步通过一个 Dense 层,最终提取输出中的可能类别。让我们按以下方式编写逻辑:

  1. 重新创建输入和输出数据集,以便输入有 8 个时间步,输出有 17 个时间步。这与之前的迭代不同,因为输入在之前版本中有 17 个时间步,而当前版本中为 8 个:
encoder_input_data = np.zeros(
    (len(lines.eng), eng_max_length),
    dtype='float32')
decoder_input_data = np.zeros(
    (len(lines.fr), fr_max_length),
    dtype='float32')
decoder_target_data = np.zeros(
    (len(lines.fr), fr_max_length, num_decoder_tokens+1),
    dtype='float32')

for i, (input_text, target_text) in enumerate(zip(lines.eng, lines.fr)):
    for t, word in enumerate(input_text.split()):
        encoder_input_data[i, t] = input_token_index[word]
    for t, word in enumerate(target_text.split()):
        # decoder_target_data is ahead of decoder_input_data by one timestep
        decoder_input_data[i, t] = target_token_index[word]
        if t>0: 
            # decoder_target_data will be ahead by one timestep
            # and will not include the start character.
          decoder_target_data[i, t - 1, target_token_index[word]] = 1.
          if t== len(target_text.split())-1:
            decoder_target_data[i, t:, 89] = 1

for i in range(decoder_input_data.shape[0]):
  for j in range(decoder_input_data.shape[1]):
    if(decoder_input_data[i][j]==0):
      decoder_input_data[i][j] = 89 
  1. 构建模型。注意,RepeatVector层将双向层的输出复制 17 次:
model2 = Sequential()
model2.add(Embedding(len(input_words)+1, 128, input_length=eng_max_length, mask_zero=True))
model2.add((Bidirectional(LSTM(256))))
model2.add(RepeatVector(fr_max_length))
model2.add((LSTM(256, return_sequences=True)))
model2.add((Dense(len(target_token_index)+1, activation='softmax')))

模型的总结如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/0049d9b6-60e5-4351-a51d-7281e266f1c8.png

  1. 编译并拟合模型:
model2.compile(optimizer='adam', loss='categorical_crossentropy',metrics=['acc'])
model2.fit(encoder_input_data, decoder_target_data,
 batch_size=128,epochs=5,validation_split=0.05)

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/4d726ed2-1d9e-488d-8982-4608468389d6.png

  1. 计算总单词中正确翻译的百分比:
count = 0
correct_count = 0
pred = model2.predict(encoder_input_data[47500:])
for i in range(2500):
  t = np.argmax(pred[i], axis=1)
  act = np.argmax(decoder_target_data[47500],axis=1)
  correct_count += np.sum((act==t) & (act!=89))
  count += np.sum(act!=89)
correct_count/count

以上结果准确率为 19%,与之前的迭代几乎相当。

这是可以预期的,因为当所有输入时间步的信息仅存储在最后一个隐藏层的值中时,我们往往会丢失大量信息。

另外,我们没有利用单元状态,该状态包含关于需要在每个时间步忘记哪些信息的相当多的内容。

机器翻译的编码器解码器架构

在我们之前定义的架构中,有两个潜在的逻辑增强:

  1. 在生成翻译时,利用单元状态中存在的信息

  2. 在预测下一个单词时,利用之前翻译过的单词作为输入

第二种技术称为教师强制。本质上,通过在生成当前时间步时,给定前一个时间步的实际值作为输入,我们可以更快地调整网络,且在实践中更加准确。

准备就绪

我们将采用的策略是使用编码器-解码器架构构建机器翻译系统,具体如下:

  • 在准备输入和输出数据集时,我们有两个解码器数据集:

    • decoder_input_dataencoder_input_data的组合为输入,decoder_target_data为输出

    • decoder_input_datastart单词开始

  • 当我们预测解码器中的第一个单词时,我们使用单词输入集,将其转换为向量,然后通过一个以start为输入的解码器模型。预期的输出是start后面的第一个单词。

  • 我们以类似的方式继续,其中输出的实际第一个单词作为输入,同时预测第二个单词

  • 我们将基于这个策略计算模型的准确率

如何操作…

有了这个,我们继续在之前准备好的输入和输出数据集上构建模型(前一部分的第 1 步中,许多到隐藏到许多的架构保持不变)。代码文件可以在 GitHub 上的Machine_translation.ipynb中找到。

  1. 按如下方式构建模型:
# We shall convert each word into a 128 sized vector
embedding_size = 128
    1. 准备编码器模型:
encoder_inputs = Input(shape=(None,))
en_x= Embedding(num_encoder_tokens+1, embedding_size)(encoder_inputs)
encoder = LSTM(256, return_state=True)
encoder_outputs, state_h, state_c = encoder(en_x)
# We discard `encoder_outputs` and only keep the states.
encoder_states = [state_h, state_c]

请注意,由于我们正在提取编码器网络的中间层,并且将多个数据集作为输入(编码器输入数据和解码器输入数据),因此我们使用的是功能性 API。

    1. 准备解码器模型:
decoder_inputs = Input(shape=(None,))
dex= Embedding(num_decoder_tokens+1, embedding_size)
final_dex= dex(decoder_inputs)
decoder_lstm = LSTM(256, return_sequences=True, return_state=True)
decoder_outputs, _, _ = decoder_lstm(final_dex, initial_state=encoder_states)
decoder_outputs = Dense(2000,activation='tanh')(decoder_outputs)
decoder_dense = Dense(num_decoder_tokens+1, activation='softmax')
decoder_outputs = decoder_dense(decoder_outputs)
  1. 按如下方式构建模型:
model3 = Model([encoder_inputs, decoder_inputs], decoder_outputs)
model3.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['acc'])

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/7d7eed63-bb60-43d2-a998-0b25454011cf.png

  1. 按照以下代码拟合模型:
history3 = model3.fit([encoder_input_data, decoder_input_data], decoder_target_data,
 batch_size=32,epochs=5,validation_split=0.05)

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/920127b1-562b-4a64-b5eb-b5100a0d54fd.jpg

  1. 计算准确转录的单词百分比:
act = np.argmax(decoder_target_data, axis=2)
count = 0
correct_count = 0
pred = model3.predict([encoder_input_data[47500:],decoder_input_data[47500:]])
for i in range(2500):
     t = np.argmax(pred[i], axis=1)
     correct_count += np.sum((act[47500+i]==t) & (act[47500+i]!=0))
     count += np.sum(decoder_input_data[47500+i]!=0)
correct_count/count

请注意,在此场景下,我们已正确翻译了总词汇的 44%。

然而,请注意,在计算测试数据集的准确性时,我们不应使用decoder_input_data,因为在实际场景中我们无法访问此数据。

这要求我们将上一时间步的预测单词作为当前时间步的解码器输入单词,如下所示。

我们将重新初始化decoder_input_datadecoder_input_data_pred

decoder_input_data_pred = np.zeros((len(lines.fr),fr_max_length),dtype='float32')
final_pred = []
for i in range(2500):
word = 284
     for j in range(17):
         decoder_input_data_pred[(47500+i), j] = word
         pred =         model3.predict([encoder_input_data[(47500+i)].reshape(1,8),decoder_input_data_pred[47500+i].reshape(1,17)])
         t = np.argmax(pred[0][j])
         word = t
         if word==89:
             break
     final_pred.append(list(decoder_input_data_pred[47500+i]))

请注意,在前面的代码中,单词索引 284 对应起始单词。我们将起始单词作为解码器输入的第一个单词,并预测下一时间步中概率最高的单词。

一旦我们预测出第二个单词,我们就更新decoder_input_word_pred,预测第三个单词,并继续直到遇到停止词。

现在我们已经修改了预测的翻译单词,让我们来计算翻译的准确性:

final_pred2 = np.array(final_pred)
count = 0
correct_count = 0
for i in range(2500):
     correct_count += np.sum((decoder_input_data[47500+i]==final_pred2[i]) & (decoder_input_data[47500+i]!=89))
     count += np.sum(decoder_input_data[47500+i]!=89)
correct_count/count

这样做的结果是,所有单词中有 46%通过此方法被正确翻译。

尽管相较于之前的方法,翻译的准确性有了显著提高,但我们仍未考虑到这样一个直觉:在源语言中位于开头的单词,在目标语言中也很可能位于开头,也就是说,单词的对齐并未被考虑。接下来的部分,我们将探讨如何解决单词对齐的问题。

带有注意力机制的编码器解码器架构用于机器翻译

在上一节中,我们学习了通过启用教师强制技术(即使用目标序列中上一时间步的实际单词作为模型输入)可以提高翻译准确度。

在本节中,我们将进一步扩展这一思路,并根据编码器和解码器向量在每个时间步的相似度为输入编码器分配权重。通过这种方式,我们可以根据解码器的时间步,确保某些单词在编码器的隐藏向量中具有更高的权重。

如何做……

有了这个,让我们看看如何构建编码器解码器架构,并结合注意力机制。代码文件在 GitHub 上的Machine_translation.ipynb中可用。

  1. 构建编码器,如以下代码所示:
encoder_inputs = Input(shape=(eng_max_length,))
en_x= Embedding(num_encoder_tokens+1, embedding_size)(encoder_inputs)
en_x = Dropout(0.1)(en_x)
encoder = LSTM(256, return_sequences=True, unroll=True)(en_x)
encoder_last = encoder[:,-1,:]
  1. 构建解码器,如下所示:
decoder_inputs = Input(shape=(fr_max_length,))
dex= Embedding(num_decoder_tokens+1, embedding_size)
decoder= dex(decoder_inputs)
decoder = Dropout(0.1)(decoder)
decoder = LSTM(256, return_sequences=True, unroll=True)(decoder, initial_state=[encoder_last, encoder_last])

请注意,在前面的代码中,我们并没有最终确定解码器架构。我们只是提取了解码器中的隐藏层值。

  1. 构建注意力机制。注意力机制将基于编码器隐藏向量与解码器隐藏向量在每个时间步的相似度。基于这种相似度(执行 softmax 操作以提供一个加权值,所有可能的输入时间步的加权值总和为 1),我们将给编码器向量赋予权重,如下所示。

将编码器解码器向量通过激活层和密集层处理,以便在进行点积(衡量相似度——余弦相似度)之前实现进一步的非线性:

t = Dense(5000, activation='tanh')(decoder)
t2 = Dense(5000, activation='tanh')(encoder)
attention = dot([t, t2], axes=[2, 2])

确定需要给输入时间步长分配的权重:

attention = Dense(eng_max_length, activation='tanh')(attention)
attention = Activation('softmax')(attention)

计算加权编码器向量,方法如下:

context = dot([attention, encoder], axes = [2,1])
  1. 将解码器和加权编码器向量结合起来:
decoder_combined_context = concatenate([context, decoder])
  1. 将解码器和加权编码向量的组合连接到输出层:
output_dict_size = num_decoder_tokens+1
decoder_combined_context=Dense(2000, activation='tanh')(decoder_combined_context)
output=(Dense(output_dict_size, activation="softmax"))(decoder_combined_context)
  1. 编译并拟合模型,下面是相关代码:
model4 = Model(inputs=[encoder_inputs, decoder_inputs], outputs=[output])
model4.compile(optimizer='adam', loss='categorical_crossentropy',metrics = ['accuracy'])

结构图如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/05ba35a2-0d0a-4d38-aad8-640520b67267.png

model4.fit([encoder_input_data, decoder_input_data], decoder_target_data,
 batch_size=32,epochs=5,validation_split=0.05)

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/nn-keras-cb/img/ca8e9bfd-b064-4ba3-8414-c9b80eda03ec.png

一旦你拟合了模型,你会发现该模型的验证损失略优于之前的迭代。

  1. 以我们在上一部分所做的类似方式计算翻译的准确率:
decoder_input_data_pred=np.zeros((len(lines.fr), fr_max_length), dtype='float32')
final_pred_att = []
for i in range(2500):
     word = 284
     for j in range(17):
         decoder_input_data_pred[(47500+i), j] = word
         pred =         model4.predict([encoder_input_data[(47500+i)].reshape(1,8),decoder_input_data_pred[47500+i].reshape(1,17)])
         t = np.argmax(pred[0][j])
         word = t
         if word==89:
             break
     final_pred_att.append(list(decoder_input_data_pred[47500+i]))
final_pred2_att = np.array(final_pred_att)
count = 0
correct_count = 0
for i in range(2500):
     correct_count += np.sum((decoder_input_data[47500+i]==final_pred2_att[i]) & (decoder_input_data[47500+i]!=89))
     count += np.sum(decoder_input_data[47500+i]!=89)
correct_count/count

前面的代码结果是 52%的总词汇被正确翻译,相较于上一个迭代有了改进。

  1. 现在我们已经构建了一个具有合理准确率的翻译系统,让我们检查一下测试数据集中的一些翻译(测试数据集是总数据集的最后 5%,因为我们将validation_split指定为 5%),如下所示:
k = -1500
t = model4.predict([encoder_input_data[k].reshape(1,encoder_input_data.shape[1]),decoder_input_data[k].reshape(1,decoder_input_data.shape[1])]).reshape(decoder_input_data.shape[1], num_decoder_tokens+1)

提取按词汇计算的预测翻译:

t2 = np.argmax(t,axis=1)
for i in range(len(t2)):
     if int(t2[i])!=0:
         print(list(target_token_index.keys())[int(t2[i]-1)])

将英文句子转换为法文后的前面代码输出如下:

je unk manger pas manger end end

提取实际的翻译,以词汇为单位:

t2 = decoder_input_data[k]
for i in range(len(t2)):
     if int(t2[i])!=89:
         print(list(target_token_index.keys())[int(t2[i]-1)])

前面的代码输出如下:

 je unk ne pas manger ça end

我们看到预测的翻译与原始翻译非常接近。以类似的方式,我们来探索验证数据集中的更多翻译:

原始翻译预测翻译
我在这个未知的周末忙得不可开交我为更多的未知周末忙碌
我只是做我所说的未知我做的正是我所做的未知
我有做这个未知的周末我做这个未知的周末

从上表中,我们可以看到有一个不错的翻译,然而,仍有一些潜在的改进空间:

  • 考虑到词汇相似性:

    • jej’ai这样的词汇是相当相似的,因此它们不应该受到过多惩罚,即使这会导致准确度指标的下降
  • 减少unk词汇的数量:

    • 我们减少了unk词汇的数量,以降低数据集的维度

    • 当我们收集更大的语料库,并在工业级配置的机器上工作时,我们可能能够处理高维数据

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值