PyTorch-1-x-自然语言处理实用指南-二-

40 阅读1小时+

PyTorch 1.x 自然语言处理实用指南(二)

原文:zh.annas-archive.org/md5/da825e03093e3d0e5022fb90bb0f3499

译者:飞龙

协议:CC BY-NC-SA 4.0

第三部分:使用 PyTorch 1.x 的真实世界 NLP 应用

在本节中,我们将使用 PyTorch 中提供的各种自然语言处理NLP)技术来构建各种真实世界的应用程序。使用 PyTorch 进行情感分析、文本摘要、文本分类以及构建聊天机器人应用程序是本节将涵盖的一些任务。

本节包含以下章节:

  • 第五章递归神经网络与情感分析

  • 第六章用于文本分类的卷积神经网络

  • 第七章使用序列到序列神经网络进行文本翻译

  • 第八章使用基于注意力的神经网络构建聊天机器人

  • 第九章前方的道路

第五章:递归神经网络和情感分析

在本章中,我们将研究递归神经网络RNNs),这是 PyTorch 中的基本前馈神经网络的变体,我们在第一章**,机器学习基础中学习了如何构建。通常情况下,RNNs 可以用于数据可以表示为序列的任何任务。这包括诸如股票价格预测的事情,使用时间序列的历史数据表示为序列。我们通常在 NLP 中使用 RNNs,因为文本可以被视为单词的序列并且可以建模为这样的序列。虽然传统神经网络将单个向量作为输入模型,但是 RNN 可以接受整个向量序列。如果我们将文档中的每个单词表示为向量嵌入,我们可以将整个文档表示为向量序列(或三阶张量)。然后,我们可以使用 RNNs(以及称为长短期记忆LSTM)的更复杂形式的 RNN)从我们的数据中学习。

本章将涵盖 RNN 的基础知识以及更高级的 LSTM。然后,我们将看看情感分析,并通过一个实际例子演示如何使用 PyTorch 构建 LSTM 来对文档进行分类。最后,我们将在 Heroku 上托管我们的简单模型,这是一个简单的云应用平台,可以让我们使用我们的模型进行预测。

本章涵盖以下主题:

  • 构建 RNNs

  • 使用 LSTM

  • 使用 LSTM 构建情感分析器

  • 在 Heroku 上部署应用程序

技术要求

本章中使用的所有代码都可以在 github.com/PacktPublishing/Hands-On-Natural-Language-Processing-with-PyTorch-1.x/tree/master/Chapter5 找到。Heroku 可以从 www.heroku.com 安装。数据来自 archive.ics.uci.edu/ml/datasets/Sentiment+Labelled+Sentences

构建 RNNs

RNN 由递归层组成。虽然在许多方面与标准前馈神经网络中的全连接层相似,但这些递归层包含一个在顺序输入的每个步骤中更新的隐藏状态。这意味着对于任何给定的序列,模型都以隐藏状态初始化,通常表示为一维向量。然后,我们的序列的第一步被送入模型,隐藏状态根据一些学习参数进行更新。然后再将第二个单词送入网络,隐藏状态再次根据其他学习参数进行更新。重复这些步骤,直到整个序列被处理完毕,我们得到最终的隐藏状态。这个计算循环,隐藏状态从先前的计算中传递并更新,是我们称之为递归的网络的原因。然后将这个最终的隐藏状态连接到更进一步的全连接层,并预测最终的分类。

我们的递归层大致如下所示,其中h为隐藏状态,x为我们序列中各个时间步的输入。对于每次迭代,我们更新每个时间步的隐藏状态x

图 5.1 – 循环层

图 5.1 – 循环层

或者,我们可以将其扩展到整个时间步骤序列,如下所示:

图 5.2 – 时间步骤序列

图 5.2 – 时间步骤序列

此层适用于n个时间步骤的输入。我们的隐藏状态在状态h0 中初始化,然后使用第一个输入x1 来计算下一个隐藏状态h1。还有两组权重矩阵需要学习——矩阵U学习隐藏状态在时间步骤之间的变化,矩阵W学习每个输入步骤如何影响隐藏状态。

我们还对结果乘积应用tanh激活函数,将隐藏状态的值保持在-1 到 1 之间。计算任意隐藏状态ht 的方程如下:

然后对输入序列中的每个时间步骤重复此过程,该层的最终输出是我们的最后一个隐藏状态hn。当网络学习时,我们执行网络的前向传播,计算最终的分类。然后根据这个预测计算损失,并像以前一样反向传播,计算梯度。这个反向传播过程发生在递归层内的所有步骤中,学习每个输入步骤和隐藏状态之间的参数。

后面我们将看到,实际上我们可以获取每个时间步的隐藏状态,而不是使用最终的隐藏状态,这对于自然语言处理中的序列到序列翻译任务非常有用。然而,目前我们将隐藏层的输出作为网络的其余部分的输出。

使用 RNN 进行情感分析

在情感分析的背景下,我们的模型是在一个评论情感分析数据集上训练的,该数据集包含多个文本评论和一个标签,标签为 0 或 1,取决于评论是负面还是正面。这意味着我们的模型成为一个分类任务(其中两个类别是负面/正面)。我们的句子通过一个学习到的词嵌入层,形成包含多个向量(每个单词一个向量)的句子表示。然后,这些向量按顺序馈送到我们的 RNN 层,最终隐藏状态通过另一个全连接层。我们模型的输出是一个介于 0 和 1 之间的单个值,取决于我们的模型是否预测从句子中获得负面或正面情感。这意味着我们完整的分类模型看起来像这样:

图 5.3 – 分类模型

图 5.3 – 分类模型

现在,我们将重点介绍 RNN 中的一个问题——梯度爆炸和梯度消失——以及我们如何使用梯度裁剪来解决这个问题。

梯度爆炸和梯度消失

在 RNN 中经常遇到的一个问题是梯度爆炸或梯度消失。我们可以将递归层视为一个非常深的网络。在计算梯度时,我们在每次隐藏状态迭代时进行。如果在任何给定位置,损失相对于权重的梯度变得非常大,这将在通过递归层所有迭代时产生乘性效应。这可能会导致梯度因快速增长而爆炸。如果我们有大的梯度,这可能会导致网络不稳定。另一方面,如果我们的隐藏状态中的梯度非常小,这将再次产生乘性效应,并且梯度将接近 0。这意味着梯度可能变得太小,无法通过梯度下降准确更新我们的参数,从而导致我们的模型无法学习。

我们可以使用的一种技术来防止梯度爆炸是使用梯度裁剪。这种技术限制了我们的梯度,防止它们变得太大。我们只需选择一个超参数 C,可以计算我们的裁剪梯度如下:

下图显示了两个变量之间的关系:

图 5.4 – 梯度裁剪比较

图 5.4 – 梯度裁剪比较

另一种防止梯度爆炸或消失的技术是缩短输入序列长度。我们的递归层的有效深度取决于输入序列的长度,因为序列长度决定了我们在隐藏状态上需要执行多少次迭代更新。在这个过程中步骤越少,隐藏状态之间的梯度累积的乘法效应就越小。通过在模型中智能地选择最大序列长度作为超参数,我们可以帮助防止梯度爆炸和消失。

引入 LSTMs

虽然 RNN 允许我们将单词序列作为模型的输入,但它们远非完美。RNN 有两个主要缺陷,可以通过使用更复杂版本的 RNN——LSTM——来部分解决。

RNN 的基本结构意味着它们很难长期保留信息。考虑一个有 20 个单词的句子。从句子的第一个单词影响初始隐藏状态到句子的最后一个单词,我们的隐藏状态被更新了 20 次。从句子开头到最终的隐藏状态,对于 RNN 来说,很难保留句子开头单词的信息。这意味着 RNN 不擅长捕捉序列中的长期依赖关系。这也与前面提到的梯度消失问题相关联,即通过长而稀疏的向量序列反向传播非常低效。

考虑一个长段落,我们试图预测下一个单词。句子以I study math…开头,以my final exam is in…结束。直觉上,我们期望下一个单词是math或某个与数学相关的领域。然而,在一个长序列的 RNN 模型中,我们的隐藏状态可能在到达句子末尾时难以保留句子开头的信息,因为它经历了多次更新步骤。

我们还应该注意,RNN 不擅长捕捉句子内单词的整体上下文。我们之前看到,在 n-gram 模型中,句子中的单词的含义取决于它在句子中的上下文,这由它之前出现的单词和之后出现的单词决定。在 RNN 中,我们的隐藏状态仅向一个方向更新。在单向传递中,我们的隐藏状态被初始化,并且序列中的第一个单词被传递给它。然后,这个过程被所有后续单词依次重复,直到我们留下最终的隐藏状态。这意味着对于句子中的任何给定单词,我们仅考虑到了在该点之前出现的单词的累积效应。我们不考虑其后的任何单词,这意味着我们未能捕捉到句子中每个单词的完整上下文。

在另一个例子中,我们再次想预测句子中的缺失词语,但这次出现在句子的开头而不是结尾。我们有这样的句子 I grew up in…so I can speak fluent Dutch。在这里,我们可以直观地猜测这个人在荷兰长大了,因为他们说荷兰语。然而,因为 RNN 顺序解析信息,它只会使用 I grew up in… 来做预测,错过了句子中的其他关键上下文。

这两个问题可以部分通过 LSTM 得到解决。

使用长短期记忆网络(LSTM)

LSTM 是 RNN 的更高级版本,并包含两个额外的属性 —— 更新门遗忘门。这两个增加使得网络更容易学习长期依赖性。考虑以下电影评论:

这部电影太棒了。我周二下午和妻子以及女儿们一起去看的。虽然我没抱太大的期望,但结果却非常有趣。如果有机会的话,我们一定会再去看的。

在情感分析中,显然句子中并非所有词语都对判断评论是积极还是消极有帮助。我们将重复这个句子,但这次突出显示对评估评论情感有帮助的词语:

这部电影太棒了。我周二下午和妻子以及女儿们一起去看的。虽然我没抱太大的期望,但结果却非常有趣。如果有机会的话,我们一定会再去看的。

LSTM 尝试正是这样做 —— 在遗忘所有无关信息的同时记住句子中相关的词语。通过这样做,它阻止无关信息稀释相关信息,从而更好地学习长序列中的长期依赖性。

LSTM 在结构上与 RNN 非常相似。虽然在 LSTM 内部存在一个在步骤之间传递的隐藏状态,但 LSTM 细胞本身的内部工作与 RNN 不同:

图 5.5 – LSTM 细胞

图 5.5 – LSTM 细胞

LSTM 细胞

而 RNN 细胞只需使用前一个隐藏状态和新的输入步骤,并使用一些学习参数计算下一个隐藏状态,LSTM 细胞的内部工作则复杂得多:

图 5.6 – LSTM 细胞的内部工作原理

图 5.6 – LSTM 细胞的内部工作原理

虽然看起来比 RNN 复杂得多,但我们会逐步解释 LSTM 细胞的每个组成部分。我们首先来看 遗忘门(用粗体矩形标示):

图 5.7 – 遗忘门

图 5.7 – 遗忘门

遗忘门基本上学习了要忘记序列中哪些元素。将前一个隐藏状态 ht-1 和最新的输入步骤 x1 连接在一起,通过遗忘门上的学习权重矩阵和将值压缩在 0 到 1 之间的 Sigmoid 函数进行处理。得到的矩阵 ft 与前一步的单元状态 ct-1 逐点相乘。这有效地对前一单元状态应用了一个蒙版,以便仅将前一单元状态中的相关信息带入下一个时间步。

接下来,我们将看看输入门

图 5.8 – 输入门

图 5.8 – 输入门

输入门再次接受连接的上一个隐藏状态 ht-1 和当前序列输入 xt,并通过一个带有学习参数的 Sigmoid 函数进行处理,输出另一个矩阵 it,其值在 0 到 1 之间。连接的隐藏状态和序列输入还会经过一个 tanh 函数,将输出压缩在 -1 到 1 之间。然后将其与 it 矩阵相乘。这意味着生成 it 所需的学习参数有效地学习了在当前时间步中哪些元素应该被保留在我们的单元状态中。然后将此结果加到当前单元状态中,以获得我们的最终单元状态,该状态将传递到下一个时间步。

最后,我们来看 LSTM 单元的最后一个元素——输出门

图 5.9 – 输出门

图 5.9 – 输出门

输出门计算 LSTM 单元的最终输出——包括单元状态和传递到下一步的隐藏状态。单元状态 ct 与前两步相同,是遗忘门和输入门的产物。最终的隐藏状态 ht 是通过取连接的前一个隐藏状态 ht-1 和当前时间步输入 xt,并通过带有一些学习参数的 Sigmoid 函数进行处理以获得输出门输出 ot 来计算的。最终的单元状态 ct 经过一个 tanh 函数并与输出门输出 ot 相乘,以计算最终的隐藏状态 ht。这意味着输出门上的学习参数有效地控制了前一个隐藏状态和当前输出的哪些元素与最终单元状态结合,以作为新的隐藏状态传递到下一个时间步。

在我们的前向传播中,我们简单地迭代模型,初始化我们的隐藏状态和单元状态,并在每个时间步使用 LSTM 单元来更新它们,直到我们得到一个最终的隐藏状态,然后将其输出到神经网络的下一层。通过所有 LSTM 层的反向传播,我们可以计算相对于网络损失的梯度,因此我们知道通过梯度下降更新我们的参数的方向。我们得到几个矩阵或参数——一个用于输入门,一个用于输出门,一个用于遗忘门。

由于我们比简单 RNN 获得了更多的参数,且计算图更复杂,通过网络进行反向传播并更新权重的过程可能会比简单 RNN 更耗时。然而,尽管训练时间较长,我们已经证明 LSTM 在许多方面都比传统的 RNN 表现出显著优势,因为输出门、输入门和遗忘门的结合赋予了模型确定哪些输入元素用于更新隐藏状态,哪些隐藏状态元素在前进时应被遗忘的能力,这意味着模型更能形成长期依赖关系并保留以前序列步骤的信息。

双向 LSTMs

我们之前提到简单 RNN 的一个缺点是它们无法捕捉单词在句子中的完整上下文,因为它们只能向后看。在 RNN 的每个时间步中,只考虑先前看到的单词,并且不考虑在句子中紧接在后面的单词。尽管基本的 LSTMs 同样是向后看的,但我们可以使用一种改进版的 LSTM,称为双向 LSTM,它在每个时间步内考虑单词的前后两侧。

双向 LSTMs 同时以正常顺序和反向顺序处理序列,保持两个隐藏状态。我们称前向隐藏状态为ft,并使用rt 表示反向隐藏状态:

图 5.10 – 双向 LSTM 处理过程

图 5.10 – 双向 LSTM 处理过程

这里,我们可以看到在整个过程中我们保持了这两个隐藏状态,并使用它们来计算最终的隐藏状态,ht。因此,如果我们希望计算时间步t处的最终隐藏状态,我们使用已看到包括输入xt 的所有单词的前向隐藏状态ft,以及已看到包括xt 之后所有单词的反向隐藏状态rt。因此,我们的最终隐藏状态ht 包括了看到整个句子中所有单词的隐藏状态,而不仅仅是在时间步t之前发生的单词。这意味着可以更好地捕捉句子中任何给定单词的上下文。双向 LSTM 已被证明在几个自然语言处理任务中比传统的单向 LSTM 表现更好。

使用 LSTMs 构建情感分析器

现在我们将看看如何构建我们自己简单的 LSTM 来根据它们的情感分类句子。我们将在一个包含 3000 条评论的数据集上训练我们的模型,这些评论已被分类为积极或消极。这些评论来自三个不同的来源——电影评论、产品评论和地点评论,以确保我们的情感分析器是稳健的。数据集是平衡的,由 1500 条积极评论和 1500 条消极评论组成。我们将从导入数据集并检查它开始:

with open("sentiment labelled sentences/sentiment.txt") as f:
    reviews = f.read()

data = pd.DataFrame([review.split('\t') for review in                      reviews.split('\n')])
data.columns = ['Review','Sentiment']
data = data.sample(frac=1)

这将返回以下输出:

图 5.11 – 数据集的输出

图 5.11 – 数据集的输出

我们从文件中读取数据集。我们的数据集是用制表符分隔的,所以我们通过制表符和换行符将其拆分开来。我们重新命名列,然后使用样本函数对数据进行随机重排。查看我们的数据集,我们需要做的第一件事是预处理我们的句子以输入到我们的 LSTM 模型中。

数据预处理

首先,我们创建一个函数来标记我们的数据,将每个评论拆分为单独的预处理单词列表。我们遍历我们的数据集,对每个评论,我们去除任何标点符号,将字母转换为小写,并移除任何尾随空白。然后我们使用 NLTK 分词器从这个预处理文本创建单独的标记:

def split_words_reviews(data):
    text = list(data['Review'].values)
    clean_text = []
    for t in text:
        clean_text.append(t.translate(str.maketrans('', '',                   punctuation)).lower().rstrip())
    tokenized = [word_tokenize(x) for x in clean_text]
    all_text = []
    for tokens in tokenized:
        for t in tokens:
            all_text.append(t)
    return tokenized, set(all_text)
reviews, vocab = split_words_reviews(data)
reviews[0]

这导致以下输出:

图 5.12 – NTLK 分词的输出

图 5.12 – NTLK 分词的输出

我们返回评论本身,以及所有评论中的所有单词的集合(即词汇/语料库),我们将使用它们创建我们的词汇字典。

为了充分准备我们的句子以输入到神经网络中,我们必须将我们的单词转换为数字。为了做到这一点,我们创建了一些字典,这些字典将允许我们从单词转换为索引,从索引转换为单词。为此,我们简单地循环遍历我们的语料库,并为每个唯一单词分配一个索引:

def create_dictionaries(words):
    word_to_int_dict = {w:i+1 for i, w in enumerate(words)}
    int_to_word_dict = {i:w for w, i in word_to_int_dict.                            items()}
    return word_to_int_dict, int_to_word_dict
word_to_int_dict, int_to_word_dict = create_dictionaries(vocab)
int_to_word_dict

这给出以下输出:

图 5.13 – 为每个单词分配索引

图 5.13 – 为每个单词分配索引

我们的神经网络将接受固定长度的输入;然而,如果我们探索我们的评论,我们会发现我们的评论长度各不相同。为了确保所有的输入都是相同长度的,我们将对我们的输入句子进行填充。这基本上意味着我们在较短的句子中添加空令牌,以便所有句子的长度都相同。我们必须首先决定我们希望实施的填充长度。我们首先计算我们输入评论中句子的最大长度,以及平均长度:

print(np.max([len(x) for x in reviews]))
print(np.mean([len(x) for x in reviews]))

这给出以下结果:

图 5.14 – 长度数值

图 5.14 – 长度数值

我们可以看到,最长的句子有70个词,平均句子长度为11.78个词。为了捕获所有句子的信息,我们希望所有的句子长度都为 70。然而,使用更长的句子意味着更长的序列,这会导致我们的 LSTM 层变得更深。这意味着模型训练时间更长,因为我们必须通过更多层进行梯度反向传播,但也意味着我们的大部分输入会变得稀疏并充满空标记,这使得从我们的数据中学习变得不那么高效。这一点可以通过我们的最大句子长度远远大于平均句子长度来说明。为了捕获大部分句子信息而又不必要地填充我们的输入并使其过于稀疏,我们选择使用输入大小为50。您可能希望尝试使用介于2070之间的不同输入大小,看看这如何影响您的模型性能。

我们将创建一个函数,允许我们对句子进行填充,使它们的大小都相同。对于比序列长度短的评论,我们用空标记进行填充。对于比序列长度长的评论,我们简单地丢弃超过最大序列长度的任何标记:

def pad_text(tokenized_reviews, seq_length):

    reviews = []

    for review in tokenized_reviews:
        if len(review) >= seq_length:
            reviews.append(review[:seq_length])
        else:
            reviews.append(['']*(seq_length-len(review)) +                    review)

    return np.array(reviews)
padded_sentences = pad_text(reviews, seq_length = 50)
padded_sentences[0]

我们的填充句子看起来像这样:

图 5.15 – 对句子进行填充

图 5.15 – 对句子进行填充

我们必须进行进一步的调整,以允许在我们的模型中使用空标记。目前,我们的词汇字典不知道如何将空标记转换为整数以在我们的网络中使用。因此,我们手动将它们添加到我们的字典中,索引为0,这意味着当输入到我们的模型中时,空标记将被赋予值0

int_to_word_dict[0] = ''
word_to_int_dict[''] = 0

现在我们几乎可以开始训练我们的模型了。我们进行最后一步预处理,将所有填充后的句子编码为数值序列,以输入我们的神经网络。这意味着先前的填充句子现在看起来像这样:

encoded_sentences = np.array([[word_to_int_dict[word] for word in review] for review in padded_sentences])
encoded_sentences[0]

我们编码的句子表示如下:

图 5.16 – 对句子进行编码

图 5.16 – 对句子进行编码

现在我们已经将所有输入序列编码为数值向量,我们可以开始设计我们的模型架构了。

模型架构

我们的模型将由几个主要部分组成。除了输入和输出层外,这些层对许多神经网络都是通用的,我们首先需要一个嵌入层。这样,我们的模型就能学习到它所训练的单词的向量表示。我们可以选择使用预先计算的嵌入(如 GloVe),但为了演示目的,我们将训练自己的嵌入层。我们的输入序列通过输入层,并出现为向量序列。

这些向量序列然后被送入我们的LSTM 层。正如本章前面详细解释的那样,LSTM 层从我们的嵌入序列中逐步学习,并输出一个代表 LSTM 层最终隐藏状态的单个向量输出。这个最终隐藏状态最终通过进一步的隐藏层,然后再通过最终输出节点预测一个值(介于 0 和 1 之间),指示输入序列是正面还是负面评价。这意味着我们的模型架构看起来像这样:

图 5.17 – 模型架构

图 5.17 – 模型架构

现在我们将演示如何使用 PyTorch 从头开始编写这个模型。我们创建一个名为SentimentLSTM的类,它继承自nn.Module类。我们定义我们的init参数为我们词汇表的大小,模型将具有的 LSTM 层数量,以及我们模型隐藏状态的大小:

class SentimentLSTM(nn.Module):

    def __init__(self, n_vocab, n_embed, n_hidden, n_output,    n_layers, drop_p = 0.8):
        super().__init__()

        self.n_vocab = n_vocab  
        self.n_layers = n_layers 
        self.n_hidden = n_hidden 

然后,我们定义网络的每一层。首先,我们定义嵌入层,它的长度为词汇表中单词的数量,嵌入向量的大小作为一个可以指定的n_embed超参数。我们使用从嵌入层输出的向量大小定义我们的 LSTM 层,模型隐藏状态的长度以及我们 LSTM 层将具有的层数。我们还添加了一个参数来指定我们的 LSTM 可以在数据批次上进行训练,并允许我们通过 dropout 实现网络正则化。我们定义了一个进一步的 dropout 层,具有概率drop_p(一个在模型创建时指定的超参数),以及我们的最终全连接层和输出/预测节点(带有 sigmoid 激活函数):

       self.embedding = nn.Embedding(n_vocab, n_embed)
        self.lstm = nn.LSTM(n_embed, n_hidden, n_layers,                        batch_first = True, dropout = drop_p)
        self.dropout = nn.Dropout(drop_p)
        self.fc = nn.Linear(n_hidden, n_output)
        self.sigmoid = nn.Sigmoid()

接下来,我们需要在我们的模型类中定义我们的前向传播。在这个前向传播中,我们将一个层的输出链接到下一个层的输入。在这里,我们可以看到我们的嵌入层以input_words作为输入,并输出嵌入的单词。然后,我们的 LSTM 层以嵌入的单词作为输入,并输出lstm_out。这里唯一的微妙之处在于,我们使用view()来重塑我们的张量,从 LSTM 输出到与我们的全连接层的输入正确大小相匹配。对于重塑隐藏层输出以匹配我们输出节点的大小也适用相同的方法。注意,我们的输出将返回一个对class = 0class = 1的预测,因此我们切片输出以仅返回class = 1的预测——也就是说,我们的句子是正面的概率:

 def forward (self, input_words):

        embedded_words = self.embedding(input_words)
        lstm_out, h = self.lstm(embedded_words) 
        lstm_out = self.dropout(lstm_out)
        lstm_out = lstm_out.contiguous().view(-1,                             self.n_hidden)
        fc_out = self.fc(lstm_out)                  
        sigmoid_out = self.sigmoid(fc_out)              
        sigmoid_out = sigmoid_out.view(batch_size, -1)  

        sigmoid_last = sigmoid_out[:, -1]

        return sigmoid_last, h

我们还定义了一个名为 init_hidden() 的函数,它用我们的批量大小来初始化我们的隐藏层。这允许我们的模型同时训练和预测多个句子,而不仅仅是一次训练一个句子,如果我们愿意的话。请注意,在这里我们将 device 定义为 "cpu",以在本地处理器上运行它。然而,如果您有一个支持 CUDA 的 GPU,也可以将其设置为在 GPU 上进行训练:

    def init_hidden (self, batch_size):

        device = "cpu"
        weights = next(self.parameters()).data
        h = (weights.new(self.n_layers, batch_size,\
                 self.n_hidden).zero_().to(device),\
             weights.new(self.n_layers, batch_size,\
                 self.n_hidden).zero_().to(device))

        return h

然后,我们通过创建 SentimentLSTM 类的一个新实例来初始化我们的模型。我们传递我们词汇表的大小、嵌入的大小、隐藏状态的大小、以及输出的大小和我们 LSTM 中的层数:

n_vocab = len(word_to_int_dict)
n_embed = 50
n_hidden = 100
n_output = 1
n_layers = 2
net = SentimentLSTM(n_vocab, n_embed, n_hidden, n_output, n_layers)

现在我们已经完全定义了我们的模型架构,是时候开始训练我们的模型了。

训练模型

要训练我们的模型,我们必须首先定义我们的数据集。我们将使用训练数据集来训练我们的模型,在每一步中评估我们训练过的模型在验证数据集上的表现,然后最终,使用未见过的测试数据集来衡量我们模型的最终性能。我们之所以使用一个与验证训练分开的测试集,是因为我们可能希望基于对验证集的损失来微调我们的模型超参数。如果我们这样做,我们可能会选择在性能上仅对特定验证数据集最优的超参数。我们最后一次评估未见过的测试集,以确保我们的模型对其以前在训练循环的任何部分都没有见过的数据泛化良好。

我们已经将我们的模型输入 (x) 定义为 encoded_sentences,但我们还必须定义我们的模型输出 (y)。我们可以简单地这样做:

labels = np.array([int(x) for x in data['Sentiment'].values])

接下来,我们定义我们的训练和验证比例。在这种情况下,我们将在 80% 的数据上训练我们的模型,在额外的 10% 的数据上验证,最后在剩下的 10% 的数据上测试:

train_ratio = 0.8
valid_ratio = (1 - train_ratio)/2

然后,我们使用这些比例来切分我们的数据,并将它们转换为张量,然后再转换为数据集:

total = len(encoded_sentences)
train_cutoff = int(total * train_ratio)
valid_cutoff = int(total * (1 - valid_ratio))
train_x, train_y = torch.Tensor(encoded_sentences[:train_cutoff]).long(), torch.Tensor(labels[:train_cutoff]).long()
valid_x, valid_y = torch.Tensor(encoded_sentences[train_cutoff : valid_cutoff]).long(), torch.Tensor(labels[train_cutoff : valid_cutoff]).long()
test_x, test_y = torch.Tensor(encoded_sentences[valid_cutoff:]).long(), torch.Tensor(labels[valid_cutoff:])
train_data = TensorDataset(train_x, train_y)
valid_data = TensorDataset(valid_x, valid_y)
test_data = TensorDataset(test_x, test_y)

然后,我们使用这些数据集创建 PyTorch DataLoader 对象。 DataLoader 允许我们使用 batch_size 参数批处理我们的数据集,可以轻松地将不同的批次大小传递给我们的模型。在这个例子中,我们将保持简单,设置 batch_size = 1,这意味着我们的模型将在单个句子上进行训练,而不是使用更大的数据批次。我们还选择随机打乱我们的 DataLoader 对象,以便数据以随机顺序通过我们的神经网络,而不是每个 epoch 使用相同的顺序,可能会从训练顺序中移除任何偏倚结果:

batch_size = 1
train_loader = DataLoader(train_data, batch_size = batch_size,                          shuffle = True)
valid_loader = DataLoader(valid_data, batch_size = batch_size,                          shuffle = True)
test_loader = DataLoader(test_data, batch_size = batch_size,                         shuffle = True)

现在,我们为我们的三个数据集中的每一个定义了DataLoader对象之后,我们定义我们的训练循环。我们首先定义了一些超参数,这些参数将在我们的训练循环中使用。最重要的是,我们将我们的损失函数定义为二元交叉熵(因为我们正在预测一个单一的二元类),并将我们的优化器定义为使用学习率为0.001Adam。我们还定义了我们的模型来运行一小部分时期(以节省时间),并设置clip = 5以定义我们的梯度裁剪:

print_every = 2400
step = 0
n_epochs = 3
clip = 5  
criterion = nn.BCELoss()
optimizer = optim.Adam(net.parameters(), lr = 0.001)

我们训练循环的主体如下所示:

for epoch in range(n_epochs):
    h = net.init_hidden(batch_size)

    for inputs, labels in train_loader:
        step += 1  
        net.zero_grad()
        output, h = net(inputs)
        loss = criterion(output.squeeze(), labels.float())
        loss.backward()
        nn.utils.clip_grad_norm(net.parameters(), clip)
        optimizer.step()

在这里,我们只是训练我们的模型一定数量的时期,对于每个时期,我们首先使用批量大小参数初始化我们的隐藏层。在这种情况下,我们将batch_size = 1设置为一次只训练我们的模型一句话。对于我们的训练加载器中的每批输入句子和标签,我们首先将梯度清零(以防止它们累积),并使用模型当前状态的前向传递计算我们的模型输出。然后,使用模型的预测输出和正确标签来计算我们的损失。接着,我们通过网络进行反向传播,计算每个阶段的梯度。接下来,我们使用grad_clip_norm()函数裁剪我们的梯度,因为这将阻止我们的梯度爆炸,正如本章前面提到的。我们定义了clip = 5,这意味着在任何给定节点的最大梯度为5。最后,我们通过调用optimizer.step()来使用反向传播计算出的梯度更新我们的权重。

如果我们单独运行这个循环,我们将训练我们的模型。然而,我们希望在每个时期之后评估我们模型在验证数据集上的表现,以确定其性能。我们按照以下步骤进行:

if (step % print_every) == 0:            
            net.eval()
            valid_losses = []
            for v_inputs, v_labels in valid_loader:

                v_output, v_h = net(v_inputs)
                v_loss = criterion(v_output.squeeze(),                                    v_labels.float())
                valid_losses.append(v_loss.item())
            print("Epoch: {}/{}".format((epoch+1), n_epochs),
                  "Step: {}".format(step),
                  "Training Loss: {:.4f}".format(loss.item()),
                  "Validation Loss: {:.4f}".format(np.                                     mean(valid_losses)))
            net.train()

这意味着在每个时期结束时,我们的模型调用net.eval()来冻结我们模型的权重,并像以前一样使用我们的数据进行前向传递。请注意,在评估模式下,我们不应用 dropout。然而,这一次,我们不是使用训练数据加载器,而是使用验证加载器。通过这样做,我们可以计算模型在当前状态下在验证数据集上的总损失。最后,我们打印我们的结果,并调用net.train()来解冻我们模型的权重,以便我们可以在下一个时期再次训练。我们的输出看起来像这样:

图 5.18 – 训练模型

图 5.18 – 训练模型

最后,我们可以保存我们的模型以供将来使用:

torch.save(net.state_dict(), 'model.pkl')

在为我们的模型训练了三个 epochs 之后,我们注意到了两个主要的事情。我们先说好消息——我们的模型正在学习!我们的训练损失不仅下降了,而且我们还可以看到,每个 epoch 后验证集上的损失也在下降。这意味着我们的模型在仅仅三个 epochs 后在未见过的数据集上的情感预测能力有所提高!然而,坏消息是,我们的模型严重过拟合了。我们的训练损失远远低于验证损失,这表明虽然我们的模型学会了如何在训练数据集上进行很好的预测,但这并不太适用于未见过的数据集。这是预料之中的,因为我们使用了一个非常小的训练数据集(只有 2400 个训练句子)。由于我们正在训练一个整个嵌入层,许多单词可能仅在训练集中出现一次,而在验证集中从未出现,反之亦然,这使得模型实际上不可能对语料库中所有不同的单词类型进行泛化。实际上,我们希望在更大的数据集上训练我们的模型,以使其能够更好地学会泛化。我们还在非常短的时间内训练了这个模型,并且没有执行超参数调整来确定我们模型的最佳迭代次数。请随意尝试更改模型中的某些参数(如训练时间、隐藏状态大小、嵌入大小等),以提高模型的性能。

尽管我们的模型出现了过拟合,但它仍然学到了一些东西。现在我们希望在最终的测试数据集上评估我们的模型。我们使用之前定义的测试加载器对数据进行最后一次遍历。在这一遍历中,我们循环遍历所有的测试数据,并使用我们的最终模型进行预测:

net.eval()
test_losses = []
num_correct = 0
for inputs, labels in test_loader:
    test_output, test_h = net(inputs)
    loss = criterion(test_output, labels)
    test_losses.append(loss.item())

    preds = torch.round(test_output.squeeze())
    correct_tensor = preds.eq(labels.float().view_as(preds))
    correct = np.squeeze(correct_tensor.numpy())
    num_correct += np.sum(correct)

print("Test Loss: {:.4f}".format(np.mean(test_losses)))
print("Test Accuracy: {:.2f}".format(num_correct/len(test_loader.dataset)))    

我们在测试数据集上的表现如下:

图 5.19 – 输出数值

图 5.19 – 输出数值

然后,我们将我们的模型预测与真实标签进行比较,得到correct_tensor,这是一个向量,评估了我们模型的每个预测是否正确。然后我们对这个向量进行求和并除以其长度,得到我们模型的总准确率。在这里,我们得到了 76%的准确率。虽然我们的模型显然还远非完美,但考虑到我们非常小的训练集和有限的训练时间,这已经不错了!这只是为了说明在处理自然语言处理数据时,LSTM 可以有多么有用。接下来,我们将展示如何使用我们的模型对新数据进行预测。

使用我们的模型进行预测

现在我们已经有了一个训练好的模型,应该可以重复我们的预处理步骤来处理一个新的句子,将其传递到我们的模型中,并对其情感进行预测。我们首先创建一个函数来预处理我们的输入句子以进行预测:

def preprocess_review(review):
    review = review.translate(str.maketrans('', '',                    punctuation)).lower().rstrip()
    tokenized = word_tokenize(review)
    if len(tokenized) >= 50:
        review = tokenized[:50]
    else:
        review= ['0']*(50-len(tokenized)) + tokenized

    final = []

    for token in review:
        try:
            final.append(word_to_int_dict[token])

        except:
            final.append(word_to_int_dict[''])

    return final

我们去除标点符号和尾随空格,将字母转换为小写,并像之前一样对我们的输入句子进行分词。我们将我们的句子填充到长度为50的序列中,然后使用我们预先计算的字典将我们的标记转换为数值。请注意,我们的输入可能包含我们的网络以前未见过的新词。在这种情况下,我们的函数将这些视为空标记。

接下来,我们创建我们实际的predict()函数。我们预处理输入评论,将其转换为张量,并传递到数据加载器中。然后,我们循环通过这个数据加载器(即使它只包含一个句子),将我们的评论通过网络以获取预测。最后,我们评估我们的预测并打印出它是正面还是负面评论:

def predict(review):
    net.eval()
    words = np.array([preprocess_review(review)])
    padded_words = torch.from_numpy(words)
    pred_loader = DataLoader(padded_words, batch_size = 1,                             shuffle = True)
    for x in pred_loader:
        output = net(x)[0].item()

    msg = "This is a positive review." if output >= 0.5 else           "This is a negative review."
    print(msg)
    print('Prediction = ' + str(output))

最后,我们只需调用predict()对我们的评论进行预测:

predict("The film was good")

这导致以下输出:

图 5.20 – 正值上的预测字符串

图 5.20 – 正值上的预测字符串

我们还尝试在负值上使用predict()

predict("It was not good")

这导致以下输出:

图 5.21 – 负值上的预测字符串

图 5.21 – 负值上的预测字符串

我们现在已经从头开始构建了一个 LSTM 模型来执行情感分析。虽然我们的模型还远未完善,但我们已经演示了如何采用一些带有情感标签的评论来训练模型,使其能够对新评论进行预测。接下来,我们将展示如何将我们的模型托管在 Heroku 云平台上,以便其他人可以使用您的模型进行预测。

在 Heroku 上部署应用程序

我们现在在本地机器上训练了我们的模型,并可以使用它进行预测。然而,如果您希望其他人能够使用您的模型进行预测,这可能并不好。如果我们将我们的模型托管在 Heroku 等云平台上,并创建一个基本 API,其他人就能够调用 API 来使用我们的模型进行预测。

引入 Heroku

Heroku 是一个基于云的平台,您可以在上面托管自己的基本程序。虽然 Heroku 的免费版上传大小最大为 500 MB,处理能力有限,但这应足以让我们托管我们的模型并创建一个基本 API,以便使用我们的模型进行预测。

第一步是在 Heroku 上创建一个免费账户并安装 Heroku 应用程序。然后,在命令行中,输入以下命令:

heroku login

使用您的帐户详细信息登录。然后,通过键入以下命令创建一个新的heroku项目:

heroku create sentiment-analysis-flask-api

请注意,所有项目名称必须是唯一的,因此您需要选择一个不是sentiment-analysis-flask-api的项目名称。

我们的第一步是使用 Flask 构建一个基本 API。

使用 Flask 创建 API – 文件结构

使用 Flask 创建 API 非常简单,因为 Flask 包含了制作 API 所需的默认模板:

首先,在命令行中,为您的 Flask API 创建一个新文件夹并导航到其中:

mkdir flaskAPI
cd flaskAPI

然后,在文件夹中创建一个虚拟环境。这将是您的 API 将使用的 Python 环境:

python3 -m venv vir_env

在您的环境中,使用pip安装所有您将需要的软件包。这包括您在模型程序中使用的所有软件包,例如 NLTK、pandas、NumPy 和 PyTorch,以及您运行 API 所需的软件包,例如 Flask 和 Gunicorn:

pip install nltk pandas numpy torch flask gunicorn

然后,我们创建一个我们的 API 将使用的需求列表。请注意,当我们将其上传到 Heroku 时,Heroku 将自动下载并安装此列表中的所有软件包。我们可以通过输入以下内容来实现这一点:

pip freeze > requirements.txt

我们需要做的一个调整是将requirements.txt文件中的torch行替换为以下内容:

**https://download.pytorch.org/whl/cpu/torch-1.3.1%2Bcpu-cp37-cp37m-linux_x86_64.whl**

这是 PyTorch 版本的 wheel 文件的链接,它仅包含 CPU 实现。完整版本的 PyTorch 包括完整的 GPU 支持,大小超过 500 MB,因此无法在免费的 Heroku 集群上运行。使用这个更紧凑的 PyTorch 版本意味着您仍然可以在 Heroku 上使用 PyTorch 运行您的模型。最后,我们在我们的文件夹中创建了另外三个文件,以及用于我们模型的最终目录:

touch app.py
touch Procfile
touch wsgi.py
mkdir models

现在,我们已经创建了所有我们在 Flash API 中将需要的文件,并且我们准备开始对我们的文件进行调整。

创建使用 Flask 的 API 文件

在我们的app.py文件中,我们可以开始构建我们的 API:

  1. 我们首先进行所有的导入并创建一个predict路由。这允许我们使用predict参数调用我们的 API 以运行 API 中的predict()方法:

    import flask
    from flask import Flask, jsonify, request
    import json
    import pandas as pd
    from string import punctuation
    import numpy as np
    import torch
    from nltk.tokenize import word_tokenize
    from torch.utils.data import TensorDataset, DataLoader
    from torch import nn
    from torch import optim
    app = Flask(__name__)
    @app.route('/predict', methods=['GET'])
    
  2. 接下来,在我们的app.py文件中定义我们的predict()方法。这在很大程度上是我们模型文件的重新整理,为了避免重复的代码,建议您查看本章节技术要求部分链接的 GitHub 存储库中的完成的app.py文件。您将看到还有几行额外的代码。首先,在我们的preprocess_review()函数中,我们将看到以下几行:

    with open('models/word_to_int_dict.json') as handle:
    word_to_int_dict = json.load(handle)
    

    这需要我们在主要的模型笔记本中计算的word_to_int字典,并将其加载到我们的模型中。这样做是为了保持我们的输入文本与我们训练过的模型的一致的单词索引。然后,我们使用此字典将我们的输入文本转换为编码序列。确保从原始笔记本输出中获取word_to_int_dict.json文件,并将其放置在models目录中。

  3. 类似地,我们还必须从我们训练过的模型中加载权重。我们首先定义我们的SentimentLSTM类,并使用torch.load加载我们的权重。我们将使用来自原始笔记本的.pkl文件,请确保将其放置在models目录中:

    model = SentimentLSTM(5401, 50, 100, 1, 2)
    model.load_state_dict(torch.load("models/model_nlp.pkl"))
    
  4. 我们还必须定义 API 的输入和输出。我们希望我们的模型从 API 接收输入,并将其传递给我们的preprocess_review()函数。我们使用request.get_json()来实现这一点:

    request_json = request.get_json()
    i = request_json['input']
    words = np.array([preprocess_review(review=i)])
    
  5. 为了定义我们的输出,我们返回一个 JSON 响应,其中包含来自我们模型的输出和一个响应码200,这是我们预测函数返回的内容:

    output = model(x)[0].item()
    response = json.dumps({'response': output})
    	return response, 200
    
  6. 随着我们应用程序主体的完成,我们还需要添加两个额外的内容以使我们的 API 运行。首先,我们必须将以下内容添加到我们的wsgi.py文件中:

    from app import app as application
    if __name__ == "__main__":
        application.run()
    
  7. 最后,将以下内容添加到我们的 Procfile 中:

    web: gunicorn app:app --preload
    

这就是使应用程序运行所需的全部内容。我们可以通过首先使用以下命令在本地启动 API 来测试我们的 API 是否运行:

gunicorn --bind 0.0.0.0:8080 wsgi:application -w 1

一旦 API 在本地运行,我们可以通过向其传递一个句子来请求 API 以预测结果:

curl -X GET http://0.0.0.0:8080/predict -H "Content-Type: application/json" -d '{"input":"the film was good"}'

如果一切正常,您应该从 API 收到一个预测结果。现在我们已经让我们的 API 在本地进行预测,是时候将其托管到 Heroku,这样我们就可以在云端进行预测了。

使用 Flask 创建 API - 在 Heroku 上托管

我们首先需要以类似于在 GitHub 上提交文件的方式将我们的文件提交到 Heroku。我们通过简单地运行以下命令来将我们的工作flaskAPI目录定义为git文件夹:

git init

在这个文件夹中,我们将以下代码添加到.gitignore文件中,这将阻止我们向 Heroku 存储库添加不必要的文件:

vir_env
__pycache__/
.DS_Store

最后,我们添加了我们的第一个commit函数,并将其推送到我们的heroku项目中:

git add . -A 
git commit -m 'commit message here'
git push heroku master

这可能需要一些时间来编译,因为系统不仅需要将所有文件从您的本地目录复制到 Heroku,而且 Heroku 还将自动构建您定义的环境,安装所有所需的软件包并运行您的 API。

现在,如果一切正常,您的 API 将自动在 Heroku 云上运行。为了进行预测,您可以简单地通过使用您的项目名称而不是sentiment-analysis-flask-api向 API 发出请求:

curl -X GET https://sentiment-analysis-flask-api.herokuapp.com/predict -H "Content-Type: application/json" -d '{"input":"the film was good"}'

您的应用程序现在将从模型返回一个预测结果。恭喜您,您现在已经学会了如何从头开始训练 LSTM 模型,将其上传到云端,并使用它进行预测!接下来,本教程希望为您训练自己的 LSTM 模型并自行部署到云端提供基础。

摘要

在本章中,我们讨论了 RNN 的基础知识及其主要变体之一,即 LSTM。然后,我们演示了如何从头开始构建自己的 RNN,并将其部署到基于云的平台 Heroku 上。虽然 RNN 经常用于 NLP 任务的深度学习,但并不是唯一适合此任务的神经网络架构。

在下一章中,我们将讨论卷积神经网络,并展示它们如何用于自然语言处理学习任务。

第六章:用于文本分类的卷积神经网络

在上一章中,我们展示了如何使用 RNNs 为文本提供情感分类。然而,RNNs 并不是唯一可以用于 NLP 分类任务的神经网络架构。卷积神经网络CNNs)是另一种这样的架构。

RNNs 依赖于顺序建模,维护隐藏状态,然后逐词遍历文本,每次迭代更新状态。CNNs 不依赖于语言的顺序元素,而是试图通过感知句子中的每个单词并学习其与句子中相邻单词的关系来从文本中学习。

虽然 CNNs 更常用于基于以下原因分类图像,但它们也被证明在文本分类上是有效的。尽管我们把文本视为序列,但我们也知道句子中每个单词的含义取决于它们的上下文及相邻单词。虽然 CNNs 和 RNNs 从文本中学习的方式不同,但它们都被证明在文本分类中是有效的,而在特定任务中选择哪种取决于任务的性质。

在本章中,我们将探讨 CNNs 的基本理论,并从头构建一个 CNN,用于文本分类。我们将涵盖以下主题:

  • 探索 CNNs

  • 构建用于文本分类的 CNN

让我们开始吧!

技术要求

本章的所有代码可以在 github.com/PacktPublishing/Hands-On-Natural-Language-Processing-with-PyTorch-1.x 找到。

探索 CNNs

CNN 的基础来自计算机视觉领域,但在概念上也可以扩展到自然语言处理。人类大脑处理和理解图像的方式并不是基于像素级别,而是将图像视为整体的映射,并理解图像中各部分的关联。

CNNs 的一个好比喻是人类大脑处理图片与处理句子的方式。考虑句子 This is a sentence about a cat。当你阅读这句话时,你读取第一个词,然后是第二个词,依此类推。现在,考虑一张猫的图片。通过查看第一个像素,然后是第二个像素来同化图片中的信息是愚蠢的。相反,当我们看东西时,我们一次性地感知整个图像,而不是作为一个序列。

例如,如果我们将图像的黑白表示(在这种情况下是数字 1),我们可以将其转换为向量表示,其中每个像素的颜色由 0 或 1 表示:

图 6.1 – 图像的向量表示

图 6.1 – 图像的向量表示

然而,如果我们从机器学习的角度思考并将该向量视为模型的特征,单个像素是黑色还是白色会使照片更有可能是特定数字吗?右上角的白色像素会使照片更有可能是四还是七吗?想象一下,如果我们尝试检测更复杂的事物,比如一张照片是狗还是猫。屏幕中央的褐色像素会使照片更有可能是猫还是狗吗?直觉上,我们看到单个像素值在图像分类方面并不意味着太多。然而,我们感兴趣的是像素之间的关系。

在数字表示的情况下,我们知道一个长竖线很有可能是一个一,而任何带有闭环的照片更有可能是零、六、八或九。通过识别和学习图像中的模式,而不仅仅是查看单个像素,我们可以更好地理解和识别这些图像。这正是 CNN 的目标所在。

卷积

CNN 的基本概念是卷积。卷积本质上是一个应用于矩阵的滑动窗口函数,以捕获周围像素的信息。在以下图表中,我们可以看到卷积的示例运作:

图 6.2 – 卷积的运作

图 6.2 – 卷积的运作

在左侧,我们有正在处理的图像,而在顶部,我们有希望应用的卷积核。对于我们图像中的每个 3x3 块,我们将其乘以我们的核,得到我们在底部的卷积矩阵。然后我们对卷积矩阵求和(或平均),以获得我们在初始图像中这个 3x3 块的单个输出值。请注意,在我们的 5x5 初始图像中,我们可以叠加到九种可能的 3x3 块。当我们为初始图像中的每个 3x3 块应用此卷积过程时,我们得到最终处理的卷积结果为 3x3。

在大图像中(或在自然语言处理中的复杂句子),我们还需要实现池化层。在我们前面的示例中,将 3x3 卷积应用于 5x5 图像会得到 3x3 的输出。但是,如果我们将 3x3 卷积应用于 100x100 像素图像,则仅将输出减少至 98x98。这并没有降低图像的维度以有效地进行深度学习(因为我们必须为每个卷积层学习 98x98 个参数)。因此,我们应用池化层来进一步降低层的维度。

池化层对卷积层的输出应用一个函数(通常是最大函数),以降低其维度。这个函数是在一个滑动窗口上应用的,类似于我们执行卷积的方式,只是现在我们的卷积不重叠。假设我们的卷积层输出为 4x4,并且我们对输出应用一个 2x2 的最大池化函数。这意味着对于我们层内的每个较小的 2x2 网格,我们应用一个最大函数并保留生成的输出。我们可以在以下图中看到这一点:

图 6.3 - 池化层

图 6.3 - 池化层

这些池化层已被证明可以有效地降低数据的维度,同时仍保留了卷积层中大部分基本信息。

这种卷积和池化层的组合基本上是 CNN 从图像中学习的方式。我们可以看到,通过应用许多这些卷积过程(也称为卷积层),我们能够捕捉任给像素与其邻近像素的关系的信息。在 CNN 中,我们试图学习的参数是卷积核本身的值。这意味着我们的模型有效地学习如何应该在图像上进行卷积以提取必要的信息以进行分类。

在这种情况下,使用卷积的两个主要优点。首先,我们能够将一系列低级特征组合成一个高级特征;也就是说,我们初始图像上的一个 3x3 块被组合成一个单个值。这实际上起到了一种特征减少的作用,使我们能够仅从图像中提取相关信息。使用卷积的另一个优点是,它使我们的模型具有位置不变性。在我们的数字检测器示例中,我们不关心数字出现在图像的右侧还是左侧;我们只想要能够检测到它。由于我们的卷积将在图像中检测特定模式(即边缘),这使得我们的模型在任何地方检测到相同的特征都会被理论上通过卷积捕捉到,从而使我们的模型具有位置不变性。

尽管这些原则对于理解卷积在图像数据中的工作方式很有用,但也可以应用到自然语言处理数据中。我们将在下一节中进行讨论。

自然语言处理的卷积

正如我们在本书中很多次看到的,我们可以将单词表示为向量,将整个句子和文档表示为向量序列。当我们将我们的句子表示为向量序列时,我们可以将其表示为一个矩阵。如果我们有一个给定句子的矩阵表示,我们立即注意到这与我们在图像卷积中卷积过的图像相似。因此,我们可以类似地将卷积应用到自然语言处理中,只要我们能够将我们的文本表示为矩阵。

让我们首先考虑使用这种方法的基础。当我们之前研究 n 元组时,我们看到句子中的一个词的上下文取决于它前面的词和后面的词。因此,如果我们能以一种允许我们捕捉单词与周围单词关系的方式对句子进行卷积,我们可以在理论上检测语言中的模式,并用此来更好地分类我们的句子。

我们的卷积方法与图像上的卷积略有不同,值得注意。在我们的图像矩阵中,我们希望捕获单个像素相对于周围像素的上下文,而在句子中,我们希望捕获整个词向量相对于周围向量的上下文。因此,在自然语言处理中,我们希望跨整个词向量执行卷积,而不是在词向量内部执行。以下图表展示了这一点。

我们首先将我们的句子表示为单个词向量:

图 6.4 – 词向量

图 6.4 – 词向量

然后我们在矩阵上应用 (2 x n) 的卷积(其中 n 是我们词向量的长度;在这个例子中,n = 5)。我们可以使用 (2 x n) 的滤波器进行四次卷积,从而得到四个输出。您会注意到,这类似于一个二元组模型,在一个五个词的句子中可以有四个可能的二元组:

图 6.5 – 将词向量卷积成二元组

图 6.5 – 将词向量卷积成二元组

同样地,我们可以针对任意数量的 n 元组执行此操作;例如,n=3:

图 6.6 – 将词向量卷积成 n 元组

图 6.6 – 将词向量卷积成 n 元组

这类卷积模型的一个好处是我们可以无限制地对 n 元组进行卷积。我们还能同时对多个不同的 n 元组进行卷积。因此,为了捕获二元组和三元组,我们可以设置我们的模型如下:

图 6.7 – 将词向量卷积成二元组和三元组

图 6.7 – 将词向量卷积成二元组和三元组

尽管卷积神经网络在自然语言处理中具有如前文所述的优势,但它们也有其局限性。

在图像的卷积神经网络中,假设某个像素可能与周围像素相关联是合理的。当应用于自然语言处理时,尽管这种假设部分正确,但词语可以在语义上相关,即使它们不直接接近。句子开头的词可能与句子结尾的词相关。

尽管我们的循环神经网络模型可以通过长期记忆依赖来检测这种关系,但我们的卷积神经网络可能会遇到困难,因为卷积神经网络只能捕获周围单词的上下文。

话虽如此,尽管我们的语言假设不一定成立,但 CNN 在 NLP 中已被证明在某些任务中表现非常好。可以说,使用 CNN 进行 NLP 的主要优势是速度和效率。卷积可以在 GPU 上轻松实现,允许进行快速并行计算和训练。

捕捉单词之间关系的方式也更加高效。在真正的 n-gram 模型中,模型必须学习每个单独 n-gram 的表示,而在我们的 CNN 模型中,我们只需学习卷积核,它将自动提取给定单词向量之间的关系。

现在我们已经定义了我们的 CNN 将如何从我们的数据中学习,我们可以开始从头编写一个模型的代码。

为文本分类构建 CNN

现在我们已经了解了 CNN 的基础知识,我们可以开始从头构建一个。在上一章中,我们为情感预测构建了一个模型,其中情感是一个二元分类器;1表示积极,0表示消极。然而,在这个例子中,我们的目标是构建一个用于多类文本分类的 CNN。在多类问题中,一个特定的例子只能被分类为多个类别之一。如果一个例子可以被分类为许多不同的类别,那么这是多标签分类。由于我们的模型是多类的,这意味着我们的模型将致力于预测我们的输入句子被分类为几个类别中的哪一个。虽然这个问题比我们的二元分类任务要困难得多(因为我们的句子现在可以属于多个,而不是两个类别之一),我们将展示 CNN 在这个任务上可以提供良好的性能。我们首先开始定义我们的数据。

定义一个多类别分类数据集

在上一章中,我们查看了一些评论,并学习了基于评论是积极的还是消极的二元分类。对于这个任务,我们将查看来自 TREC (trec.nist.gov/data/qa.html) 数据集的数据,这是一个常用的数据集,用于评估模型文本分类任务的性能。该数据集包含一系列问题,每个问题都属于我们训练模型将学习分类的六个广泛语义类别之一。这六个类别如下:

图 6.8 – TREC 数据集中的语义类别

图 6.8 – TREC 数据集中的语义类别

这意味着与我们之前的分类类不同,我们的模型输出不是在01之间的单一预测,而是我们的多类预测模型现在为每个六个可能类别之一返回一个概率。我们假设所做的预测是针对具有最高预测的类别:

图 6.9 – 预测值

图 6.9 – 预测值

这样一来,我们的模型现在能够在多个类别上执行分类任务,不再局限于之前看到的 0 或 1 的二元分类。由于多类模型需要区分更多不同的类别,因此在预测方面可能会受到影响。

在一个二分类模型中,假设我们有一个平衡的数据集,如果仅进行随机猜测,我们预期模型的准确率为 50%,而具有五个不同类别的多类模型的基准准确率仅为 20%。这意味着,仅仅因为多类模型的准确率远低于 100%,并不意味着模型本身在进行预测时存在问题。这在训练需要预测数百种不同类别的模型时尤为真实。在这些情况下,准确率仅为 50%的模型被认为是表现非常良好的。

现在,我们已经定义了我们的多类分类问题,需要加载我们的数据以便训练模型。

创建用于加载数据的迭代器

在上一章节的 LSTM 模型中,我们简单地使用了一个包含所有用于训练模型的数据的.csv文件。然后,我们手动将这些数据转换为输入张量,并逐个将它们馈送到网络中进行训练。虽然这种方法完全可以接受,但并不是最有效的方法。

在我们的 CNN 模型中,我们将考虑从我们的数据中创建数据迭代器。这些迭代器对象允许我们从输入数据中轻松生成小批量数据,从而允许我们使用小批量而不是将输入数据逐个馈送到网络中进行训练。这意味着网络内部的梯度是跨整个数据批次计算的,并且参数调整发生在每个批次之后,而不是在每个数据行通过网络之后。

对于我们的数据,我们将从 TorchText 包中获取我们的数据集。这不仅包含了用于模型训练的多个数据集,还允许我们使用内置函数轻松地对句子进行标记化和向量化。

按照以下步骤进行操作:

  1. 首先,我们从 TorchText 导入数据和数据集函数:

    from torchtext import data
    from torchtext import datasets
    
  2. 接下来,我们创建一个字段和标签字段,这些字段可以与TorchText包一起使用。这些定义了模型处理数据的初始步骤:

    questions = data.Field(tokenize = ‘spacy’, batch_first = True)
    labels = data.LabelField(dtype = torch.float)
    

    在这里,我们将 tokenize 设置为spacy,以设置如何对输入句子进行标记化。然后,TorchText使用spacy包自动对输入句子进行标记化。spacy包含英语语言的索引,因此任何单词都会自动转换为相关的标记。您可能需要在命令行中安装spacy才能使其工作。可以通过输入以下内容来完成这一步骤:

    pip3 install spacy
    python3 -m spacy download en
    

    这将安装spacy并下载英语词汇索引。

  3. 我们还将我们的标签数据类型定义为浮点数,这将允许我们计算我们的损失和梯度。在定义完我们的字段之后,我们可以使用它们来分割我们的输入数据。使用TorchText中的TREC数据集,我们将传递问题和标签字段以相应地处理数据集。然后,我们调用split函数自动将数据集分成训练集和验证集:

    train_data, _ = datasets.TREC.splits(questions, labels)
    train_data, valid_data = train_data.split()
    

    请注意,通常情况下,我们可以通过简单调用训练数据来查看我们的数据集:

    train_data
    

然而,在这里,我们处理的是一个TorchText数据集对象,而不是像我们可能习惯看到的加载到 pandas 中的数据集。这意味着我们从上述代码得到的输出如下所示:

图 6.10 – TorchText 对象的输出

图 6.10 – TorchText 对象的输出

我们可以查看此数据集对象中的单个数据,只需调用.examples参数。每个示例都会有一个文本和一个标签参数,我们可以像这样检查文本:

train_data.examples[0].text

这将返回以下输出:

图 6.11 – 数据集对象中的数据

图 6.11 – 数据集对象中的数据

标签代码如下运行:

train_data.examples[0].label

这为我们提供了以下输出:

图 6.12 – 数据集对象的标签

图 6.12 – 数据集对象的标签

因此,我们可以看到我们的输入数据包括一个标记化的句子,我们的标签包括我们希望分类的类别。我们还可以检查我们的训练集和验证集的大小,如下所示:

print(len(train_data))
print(len(valid_data))

这导致以下输出:

图 6.13 – 训练集和验证集的大小

图 6.13 – 训练集和验证集的大小

这显示我们的训练到验证比例大约为 70%到 30%。值得注意的是我们的输入句子是如何被标记化的,即标点符号被视为它们自己的标记。

现在我们知道我们的神经网络不会将原始文本作为输入,我们必须找到一些方法将其转换为某种嵌入表示形式。虽然我们可以训练自己的嵌入层,但我们可以使用我们在第三章**,执行文本嵌入中讨论过的预先计算的glove向量来转换我们的数据。这还有一个额外的好处,可以使我们的模型训练更快,因为我们不需要手动从头开始训练我们的嵌入层:

questions.build_vocab(train_data,
                 vectors = “glove.6B.200d”, 
                 unk_init = torch.Tensor.normal_)
labels.build_vocab(train_data)

在这里,我们可以看到通过使用 build_vocab 函数,并将我们的问题和标签作为我们的训练数据传递,我们可以构建一个由 200 维 GLoVe 向量组成的词汇表。请注意,TorchText 包将自动下载和获取 GLoVe 向量,因此在这种情况下无需手动安装 GLoVe。我们还定义了我们希望如何处理词汇表中的未知值(即,如果模型传递了一个不在预训练词汇表中的标记时模型将如何处理)。在这种情况下,我们选择将它们视为具有未指定值的普通张量,尽管稍后我们将更新这个值。

通过调用以下命令,我们现在可以看到我们的词汇表由一系列预训练的 200 维 GLoVe 向量组成:

questions.vocab.vectors

这将导致以下输出:

图 6.14 – 张量内容

图 6.14 – 张量内容

接下来,我们创建我们的数据迭代器。我们为我们的训练和验证数据分别创建单独的迭代器。我们首先指定一个设备,以便在有 CUDA 启用的 GPU 时能够更快地训练我们的模型。在我们的迭代器中,我们还指定了由迭代器返回的批次的大小,在这种情况下是64。您可能希望尝试使用不同的批次大小来进行模型训练,因为这可能会影响训练速度以及模型收敛到全局最优的速度:

device = torch.device(‘cuda’ if torch.cuda.is_available() else                       ‘cpu’)
train_iterator, valid_iterator = data.BucketIterator.splits(
    (train_data, valid_data), 
    batch_size = 64, 
    device = device)

构建 CNN 模型

现在我们已经加载了数据,准备好创建模型了。我们将使用以下步骤来完成:

  1. 我们希望构建我们的 CNN 的结构。我们像往常一样从定义我们的模型作为一个从nn.Module继承的类开始:

    class CNN(nn.Module):
        def __init__(self, vocab_size, embedding_dim,     n_filters, filter_sizes, output_dim, dropout,     pad_idx):
    
            super().__init__()
    
  2. 我们的模型被初始化为几个输入,所有这些输入很快将被覆盖。接下来,我们单独定义网络中的各个层,从我们的嵌入层开始:

    self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx = pad_idx)
    

    嵌入层将包含词汇表中每个可能单词的嵌入,因此层的大小是我们词汇表的长度和我们嵌入向量的长度。我们使用的是 200 维的 GLoVe 向量,因此在这个例子中长度将为200。我们还必须传递填充索引,该索引是我们嵌入层中用于获取填充我们句子的嵌入的索引,以便它们的长度都相同。当我们初始化我们的模型时,我们稍后将手动定义这个嵌入。

  3. 接下来,我们定义网络内部的实际卷积层:

    self.convs = nn.ModuleList([
    nn.Conv2d(in_channels = 1, 
         out_channels = n_filters, 
         kernel_size = (fs, embedding_dim)) 
         		for fs in filter_sizes
               ])
    
  4. 我们首先使用 nn.ModuleList 来定义一系列卷积层。ModuleList 接受一个模块列表作为输入,并且在您希望定义多个单独的层时使用。由于我们希望在输入数据上训练几个不同大小的卷积层,我们可以使用 ModuleList 来实现。我们可以理论上像这样分别定义每一层:

    self.conv_2 = nn.Conv2d(in_channels = 1, 
         out_channels = n_filters, 
         kernel_size = (2, embedding_dim)) 
    self.conv_3 = nn.Conv2d(in_channels = 1, 
         out_channels = n_filters, 
         kernel_size = (3, embedding_dim)) 
    

这里,滤波器的尺寸分别为23。然而,将这些操作合并到一个函数中会更加高效。此外,如果我们将不同的滤波器尺寸传递给函数,而不是每次添加新层时手动定义每一层,我们的层将会自动生成。

我们还将out_channels值定义为我们希望训练的滤波器数量;kernel_size将包含我们嵌入的长度。因此,我们可以将我们的ModuleList函数传递给我们希望训练的滤波器长度和数量,它将自动生成卷积层。以下是给定一组变量时此卷积层可能的示例:

图 6.15 – 寻找变量的卷积层

图 6.15 – 寻找变量的卷积层

我们可以看到我们的ModuleList函数适应于我们希望训练的滤波器数量和大小。接下来,在我们的 CNN 初始化中,我们定义剩余的层,即线性层,用于分类我们的数据,以及 dropout 层,用于正则化我们的网络:

self.fc = nn.Linear(len(filter_sizes) * n_filters, output_dim)
self.dropout = nn.Dropout(dropout)

请注意,过去,我们的线性层的尺寸始终为1,因为我们只需要一个单独的输出节点来执行二元分类。由于我们现在正在解决多类分类问题,我们希望对每个潜在类别进行预测,因此我们的输出维度现在是可变的,而不仅仅是1。当我们初始化网络时,我们将设置输出维度为6,因为我们正在预测句子来自六个类别中的哪一个。

接下来,与所有我们的神经网络一样,我们必须定义我们的forward传递:

def forward(self, text):
emb = self.embedding(text).unsqueeze(1)
conved = [F.relu(c(emb)).squeeze(3) for c in self.convs]
pooled = [F.max_pool1d(c, c.shape[2]).squeeze(2) 
          for c in conved]
concat = self.dropout(torch.cat(pooled, dim = 1))
return self.fc(concat)

这里,我们首先通过我们的嵌入层将输入文本传递,以获取句子中所有单词的嵌入。接下来,对于我们之前定义的每一个卷积层,我们将嵌入的句子传递,应用一个relu激活函数并挤压结果,移除结果输出的第四个维度。这对于我们所有定义的卷积层都是重复的,以便conved包含我们所有卷积层输出的列表。

对于这些输出的每一个,我们应用一个池化函数来减少我们卷积层输出的维度,如前所述。然后,我们将所有池化层的输出连接起来,并在传递到我们最终的全连接层之前应用一个 dropout 函数,这将做出我们的类预测。在完全定义了我们的 CNN 类之后,我们创建模型的一个实例。我们定义超参数,并使用它们创建 CNN 类的一个实例:

input_dimensions = len(questions.vocab)
output_dimensions = 6
embedding_dimensions = 200
pad_index = questions.vocab.stoi[questions.pad_token]
number_of_filters = 100
filter_sizes = [2,3,4]
dropout_pc = 0.5
model = CNN(input_dimensions, embedding_dimensions, number_of_filters, filter_sizes, output_dimensions, dropout_pc, pad_index)

我们的输入维度将始终是我们词汇表的长度,而输出维度将是我们希望预测的类别数。在这里,我们预测六种不同的类别,因此我们的输出向量长度将为6。我们的嵌入维度是我们的 GLoVe 向量的长度(在本例中为200)。填充索引可以手动从我们的词汇表中获取。

接下来的三个超参数可以手动调整,因此您可能希望尝试选择不同的值,以查看这如何影响您的网络的最终输出。我们传递一个过滤器大小的列表,以便我们的模型将使用大小为234的卷积训练卷积层。我们将为每种过滤器大小训练 100 个这些过滤器,因此总共将有 300 个过滤器。我们还为我们的网络定义了 50% 的丢失率,以确保它足够规范化。如果模型似乎容易过度拟合或欠拟合,可以提高/降低此值。一个一般的经验法则是,如果模型欠拟合,尝试降低丢失率,如果模型过拟合,则尝试提高丢失率。

在初始化我们的模型之后,我们需要将权重加载到我们的嵌入层中。这可以通过以下简单完成:

glove_embeddings = questions.vocab.vectors
model.embedding.weight.data.copy_(glove_embeddings)

这将产生以下输出:

图 6.16 – 降低丢失率后的张量输出

图 6.16 – 降低丢失率后的张量输出

接下来,我们需要定义我们的模型如何处理模型账户中未知的标记,这些标记不包含在嵌入层中,并且我们的模型将如何将填充应用到我们的输入句子中。幸运的是,解决这两种情况的最简单方法是使用一个由全零组成的向量。我们确保这些零值张量与我们的嵌入向量长度相同(在这个实例中为200):

unknown_index = questions.vocab.stoi[questions.unk_token]
model.embedding.weight.data[unknown_index] = torch.zeros(embedding_dimensions)
model.embedding.weight.data[pad_index] = torch.zeros(embedding_dimensions)

最后,我们定义我们的优化器和准则(损失)函数。请注意,我们选择使用交叉熵损失而不是二元交叉熵,因为我们的分类任务不再是二元的。我们还使用.to(device)来使用指定的设备训练我们的模型。这意味着如果有可用的 CUDA 启用的 GPU,我们的训练将在其上进行:

optimizer = torch.optim.Adam(model.parameters())
criterion = nn.CrossEntropyLoss().to(device)
model = model.to(device)

现在我们的模型结构已经完全定义,我们准备开始训练模型。

训练 CNN

在我们定义训练过程之前,我们需要计算一个性能指标,以说明我们希望我们模型的性能(希望如此!)随时间增加。在我们的二元分类任务中,准确度是我们用来衡量性能的一个简单指标。对于我们的多分类任务,我们将再次使用准确度,但是计算它的过程略微复杂,因为我们现在必须弄清楚我们模型预测了哪一个六个类别中的哪一个,并且哪一个六个类别是正确的。

首先,我们定义一个名为multi_accuracy的函数来计算这个:

def multi_accuracy(preds, y):
    pred = torch.max(preds,1).indices
    correct = (pred == y).float()
    acc = correct.sum() / len(correct)
    return acc

在这里,对于我们的预测,我们的模型使用 torch.max 函数为每个预测返回最高值的索引。对于每个预测,如果此预测的索引与标签的索引相同,则将其视为正确预测。然后我们计算所有这些正确预测的数量,并将其除以总预测数量以得到多类别准确度的度量。我们可以在训练循环中使用此函数来测量每个 epoch 的准确度。

接下来,我们定义我们的训练函数。我们最初将该 epoch 的损失和准确度设置为 0,并调用 model.train() 以允许在训练模型时更新模型内部的参数:

def train(model, iterator, optimizer, criterion):

    epoch_loss = 0
    epoch_acc = 0

    model.train()

接下来,我们在迭代器中循环每个数据批次并执行训练步骤。我们首先将梯度清零,以防止从先前批次计算的累积梯度。然后,我们使用当前批次中句子的模型当前状态进行预测,然后与我们的标签进行比较以计算损失。使用我们在前面部分定义的准确度函数,我们可以计算给定批次的准确度。然后我们反向传播我们的损失,通过梯度下降更新我们的权重并通过我们的优化器进行步进:

for batch in iterator:

optimizer.zero_grad()

preds = model(batch.text).squeeze(1)
loss = criterion(preds, batch.label.long())

acc = multi_accuracy(preds, batch.label)

loss.backward()

optimizer.step()

最后,我们将这一批次的损失和准确度加到整个 epoch 的总损失和准确度上。当我们遍历完整个 epoch 中的所有批次后,我们计算该 epoch 的总损失和准确度,并返回它:

epoch_loss += loss.item()
epoch_acc += acc.item()

total_epoch_loss = epoch_loss / len(iterator)
total_epoch_accuracy = epoch_acc / len(iterator)

return total_epoch_loss, total_epoch_accuracy

类似地,我们可以定义一个名为 eval 的函数,在我们的验证数据上调用它,以计算我们训练过的模型在我们尚未训练的一组数据上的性能。虽然这个函数与我们之前定义的训练函数几乎相同,但我们必须做两个关键的添加:

model.eval()

with torch.no_grad():

这两个步骤将我们的模型设置为评估模式,忽略任何 dropout 函数,并确保不计算和更新梯度。这是因为我们希望在评估性能时冻结模型中的权重,并确保我们的模型不会使用验证数据进行训练,因为我们希望将其与用于训练模型的数据分开保留。

现在,我们只需在与数据迭代器结合的循环中调用我们的训练和评估函数,以训练模型。我们首先定义我们希望模型训练的 epoch 数量。我们还定义到目前为止模型已经达到的最低验证损失。这是因为我们只希望保留验证损失最低的训练模型(即性能最佳的模型)。这意味着如果我们的模型训练了多个 epoch 并开始过拟合,只会保留这些模型中表现最佳的一个,这样选择较高数量的 epoch 将会减少后果。

我们将最低验证损失初始化为无穷大:

epochs = 10
lowest_validation_loss = float(‘inf’)

接下来,我们定义我们的训练循环,一次处理一个 epoch。我们记录训练的开始和结束时间,以便计算每个步骤的持续时间。然后,我们简单地使用训练数据迭代器在我们的模型上调用训练函数,计算训练损失和准确率,并在此过程中更新我们的模型。接着,我们使用验证数据迭代器上的评估函数,计算验证数据上的损失和准确率,但不更新我们的模型:

for epoch in range(epochs):
    start_time = time.time()

    train_loss, train_acc = train(model, train_iterator,                            optimizer, criterion)
    valid_loss, valid_acc = evaluate(model, valid_iterator,                            criterion)

    end_time = time.time()

之后,我们确定我们的模型在当前 epoch 之后是否优于迄今为止表现最佳的模型:

if valid_loss < lowest_validation_loss:
    lowest_validation_loss = valid_loss
    torch.save(model.state_dict(), ‘cnn_model.pt’)

如果这个 epoch 之后的损失低于迄今为止最低的验证损失,我们将验证损失设置为新的最低验证损失,并保存当前模型权重。

最后,我们只需在每个 epoch 之后打印结果。如果一切正常,我们应该看到每个 epoch 后训练损失下降,希望验证损失也跟随下降:

print(f’Epoch: {epoch+1:02} | Epoch Time: {int(end_time -       start_time)}s’)
print(f’\tTrain Loss: {train_loss:.3f} | Train Acc: {train_      acc*100:.2f}%’)
print(f’\t Val. Loss: {valid_loss:.3f} |  Val. Acc: {valid_      acc*100:.2f}%’)

这导致以下输出:

图 6.17 – 测试模型

图 6.17 – 测试模型

幸运的是,我们看到情况似乎确实如此。每个 epoch 后,训练损失和验证损失都在下降,准确率上升,显示我们的模型确实在学习!经过多个训练 epoch 后,我们可以使用最佳模型进行预测。

使用训练好的 CNN 进行预测

幸运的是,使用我们完全训练好的模型进行预测是一个相对简单的任务。我们首先使用load_state_dict函数加载我们的最佳模型:

model.load_state_dict(torch.load(‘cnn_model.pt’))

我们的模型结构已经定义好,所以我们只需从之前保存的文件中加载权重。如果一切正常,您将看到以下输出:

图 6.18 – 预测输出

图 6.18 – 预测输出

接下来,我们定义一个函数,该函数将以句子作为输入,对其进行预处理,将其传递给我们的模型,并返回预测:

def predict_class(model, sentence, min_len = 5):

    tokenized = [tok.text for tok in nlp.tokenizer(sentence)]
    if len(tokenized) < min_len:
        tokenized += [‘<pad>’] * (min_len - len(tokenized))
    indexed = [questions.vocab.stoi[t] for t in tokenized]
    tensor = torch.LongTensor(indexed).to(device)
    tensor = tensor.unsqueeze(0)

我们首先将输入句子传递给我们的分词器,以获取标记列表。如果句子长度低于最小句子长度,我们然后对这个句子进行填充。然后,我们使用我们的词汇表获取所有这些单个标记的索引,最后创建一个张量,其中包含这些索引的向量。如果 GPU 可用,我们将其传递给 GPU,然后展开输出,因为我们的模型期望三维张量输入而不是单个向量。

接下来,我们进行预测:

model.eval()
prediction = torch.max(model(tensor),1).indices.item()
pred_index = labels.vocab.itos[prediction]
    return pred_index

首先,我们将模型设置为评估模式(与我们的评估步骤相同),以便不计算模型的梯度并且不调整权重。然后,我们将句子张量传递给我们的模型,并获取长度为6的预测向量,其中包含每个六类别的单独预测。然后,我们取最大预测值的索引,并在标签索引中使用此索引返回预测类别的名称。

为了进行预测,我们只需在任何给定的句子上调用predict_class函数。让我们使用以下代码:

pred_class = predict_class(model, “How many roads must a man                            walk down?”)
print(‘Predicted class is: ‘ + str(pred_class))

这返回以下预测:

图 6.19 – 预测值

图 6.19 – 预测值

这个预测是正确的!我们的输入问题包含How many,表明这个问题的答案是一个数字值。这正是我们的模型也预测到的!你可以继续在任何其他你想测试的问题上验证模型,希望能获得类似的积极结果。恭喜!你现在已经成功训练了一个能够定义任何给定问题类别的多类 CNN。

总结

在本章中,我们展示了 CNN 如何从 NLP 数据中学习,以及如何使用 PyTorch 从头开始训练 CNN。虽然深度学习方法与 RNN 中使用的方法非常不同,但在概念上,CNN 以算法方式使用 n-gram 语言模型背后的动机,以从上下文中的相邻单词中提取单词的隐含信息。现在我们已经掌握了 RNN 和 CNN,我们可以开始扩展这些技术,以构建更先进的模型。

在下一章中,我们将学习如何构建既利用卷积神经网络又利用递归神经网络元素的模型,并将它们用于序列以执行更高级的功能,如文本翻译。这些被称为序列到序列网络。

第七章:使用序列到序列神经网络进行文本翻译

在前两章中,我们使用神经网络来分类文本并执行情感分析。这两项任务都涉及接收 NLP 输入并预测某个值。在情感分析中,这是一个介于 0 和 1 之间的数字,表示我们句子的情感。在句子分类模型中,我们的输出是一个多类别预测,表示句子属于的几个类别之一。但如果我们希望不仅仅是进行单一预测,而是预测整个句子呢?在本章中,我们将构建一个序列到序列模型,将一个语言中的句子作为输入,并输出这个句子在另一种语言中的翻译。

第五章**,递归神经网络和情感分析中,我们已经探讨了用于 NLP 学习的几种类型的神经网络架构,即递归神经网络,以及第六章**,使用 CNN 进行文本分类中的卷积神经网络。在本章中,我们将再次使用这些熟悉的 RNN,但不再仅构建简单的 RNN 模型,而是将 RNN 作为更大、更复杂模型的一部分,以执行序列到序列的翻译。通过利用我们在前几章学到的 RNN 基础知识,我们可以展示如何扩展这些概念,以创建适合特定用途的各种模型。

在本章中,我们将涵盖以下主题:

  • 序列到序列模型的理论

  • 为文本翻译构建序列到序列神经网络

  • 后续步骤

技术要求

本章的所有代码都可以在github.com/PacktPublishing/Hands-On-Natural-Language-Processing-with-PyTorch-1.x找到。

序列到序列模型的理论

到目前为止,序列到序列模型与我们迄今所见的传统神经网络结构非常相似。其主要区别在于,对于模型的输出,我们期望得到另一个序列,而不是一个二进制或多类别预测。这在翻译等任务中特别有用,我们希望将一个完整的句子转换成另一种语言。

在以下示例中,我们可以看到我们的英语到西班牙语翻译将单词映射到单词:

图 7.1 – 英语到西班牙语翻译

图 7.1 – 英语到西班牙语翻译

我们输入句子中的第一个单词与输出句子中的第一个单词非常匹配。如果所有语言都是这种情况,我们可以简单地通过我们训练过的模型逐个传递我们句子中的每个单词来获得一个输出句子,那么就不需要进行任何序列到序列建模,如本例所示:

图 7.2 – 英语到西班牙语的单词翻译

](gitee.com/OpenDocCN/f…)

图 7.2 – 英语到西班牙语单词的翻译

然而,我们从自然语言处理的经验中知道,语言并不像这么简单!一种语言中的单词可能映射到另一种语言中的多个单词,并且这些单词在语法正确的句子中出现的顺序可能不同。因此,我们需要一个能够捕获整个句子上下文并输出正确翻译的模型,而不是直接翻译单个单词的模型。这就是序列到序列建模变得至关重要的地方,正如在这里所看到的:

图 7.3 – 用于翻译的序列到序列建模

图 7.3 – 用于翻译的序列到序列建模

要训练一个序列到序列模型,捕捉输入句子的上下文并将其转换为输出句子,我们基本上会训练两个较小的模型来实现这一点:

  • 一个编码器模型,它捕获我们句子的上下文并将其输出为单个上下文向量

  • 一个解码器,它接受我们原始句子的上下文向量表示,并将其翻译为另一种语言

所以,实际上,我们的完整序列到序列翻译模型看起来会像这样:

图 7.4 – 完整的序列到序列模型

图 7.4 – 完整的序列到序列模型

通过将我们的模型拆分为单独的编码器和解码器元素,我们有效地模块化了我们的模型。这意味着,如果我们希望训练多个模型从英语翻译成不同的语言,我们不需要每次重新训练整个模型。我们只需训练多个不同的解码器来将我们的上下文向量转换为输出句子。然后,在进行预测时,我们可以简单地替换我们希望用于翻译的解码器:

图 7.5 – 详细模型布局

图 7.5 – 详细模型布局

接下来,我们将检查序列到序列模型的编码器和解码器组件。

编码器

我们序列到序列模型的编码器元素的目的是能够完全捕获我们输入句子的上下文,并将其表示为向量。我们可以通过使用循环神经网络或更具体地说是长短期记忆网络来实现这一点。正如您可能从我们之前的章节中记得的那样,循环神经网络接受顺序输入并在整个序列中维护隐藏状态。序列中的每个新单词都会更新隐藏状态。然后,在序列结束时,我们可以使用模型的最终隐藏状态作为我们下一层的输入。

在我们的编码器的情况下,隐藏状态代表了我们整个句子的上下文向量表示,这意味着我们可以使用 RNN 的隐藏状态输出来表示整个输入句子:

图 7.6 – 检查编码器

图 7.6 – 检查编码器

我们使用我们的最终隐藏状态 h^n 作为我们的上下文向量,然后使用经过训练的解码器来解码它。同时值得注意的是,在我们的序列到序列模型的背景下,我们在输入句子的开头和结尾分别附加了 "start" 和 "end" 令牌。这是因为我们的输入和输出并没有固定的长度,我们的模型需要能够学习何时结束一个句子。我们的输入句子总是以 "end" 令牌结束,这向编码器表明此时的隐藏状态将被用作该输入句子的最终上下文向量表示。类似地,在解码器步骤中,我们将看到我们的解码器将继续生成词汇,直到预测到一个 "end" 令牌。这使得我们的解码器能够生成实际的输出句子,而不是无限长度的令牌序列。

接下来,我们将看看解码器如何利用这个上下文向量学习将其翻译成输出句子。

解码器

我们的解码器接收来自我们编码器层的最终隐藏状态,并将其解码成另一种语言的句子。我们的解码器是一个 RNN,类似于我们的编码器,但是在我们的编码器更新其隐藏状态时考虑当前句子中的当前词汇,我们的解码器在每次迭代中更新其隐藏状态并输出一个令牌,考虑到当前的隐藏状态和先前预测的句子中的词汇。可以在以下图表中看到这一点:

![图 7.7 – 检查解码器 img/B12365_07_07.jpg)图 7.7 – 检查解码器首先,我们的模型将上下文向量作为我们编码器步骤的最终隐藏状态 h0。然后,我们的模型旨在预测句子中的下一个词汇,给定当前隐藏状态,然后是句子中的前一个词汇。我们知道我们的句子必须以一个 "start" 令牌开始,因此在第一步,我们的模型尝试根据先前的隐藏状态 h0 和句子中的先前词汇(在这种情况下是 "start" 令牌)预测句子中的第一个词汇。我们的模型做出预测("pienso"),然后更新隐藏状态以反映模型的新状态 h1。然后,在下一步中,我们的模型使用新的隐藏状态和上次预测的词汇来预测句子中的下一个词汇。这一过程持续进行,直到模型预测到 "end" 令牌,此时我们的模型停止生成输出词汇。这个模型背后的直觉与我们迄今为止对语言表征的理解是一致的。任何给定句子中的词汇都依赖于它之前的词汇。因此,预测句子中的任何给定词汇而不考虑其之前预测的词汇是没有意义的,因为任何给定句子中的词汇都不是彼此独立的。我们学习模型参数的方法与之前相同:通过进行前向传播,计算目标句子与预测句子的损失,并通过网络反向传播此损失,随着过程更新参数。然而,使用这种过程学习可能非常缓慢,因为起初,我们的模型预测能力很弱。由于我们目标句子中的单词预测不是独立的,如果我们错误地预测了目标句子的第一个单词,那么输出句子中的后续单词也可能不正确。为了帮助这个过程,我们可以使用一种称为教师强迫的技术。## 使用教师强迫由于我们的模型最初预测不良好,我们会发现任何初始错误都会呈指数增长。如果我们在句子中第一个预测的单词不正确,那么句子的其余部分很可能也是错误的。这是因为我们模型的预测依赖于它先前的预测。这意味着我们模型遇到的任何损失都可能会成倍增加。由于此原因,我们可能面临梯度爆炸问题,使得我们的模型很难学习任何东西:![图 7.8 – 使用教师强迫图 7.8 – 使用教师强迫然而,通过使用教师强迫,我们训练模型时使用正确的先前目标词,这样一次错误预测不会阻碍模型从正确预测中学习。这意味着如果我们的模型在句子的某一点上做出错误预测,它仍然可以使用后续单词进行正确的预测。虽然我们的模型仍然会有错误的预测单词,并且会有损失可以用来更新我们的梯度,但现在我们不再遭受梯度爆炸,我们的模型会学习得更快:图 7.9 – 更新损失

图 7.9 – 更新损失

您可以将教师强迫视为一种帮助我们的模型在每个时间步独立学习的方法。这样,由于在早期时间步骤的误预测造成的损失不会传递到后续时间步骤。

通过结合编码器和解码器步骤,并应用教师强迫来帮助我们的模型学习,我们可以构建一个序列到序列的模型,允许我们将一种语言的序列翻译成另一种语言。在接下来的部分,我们将演示如何使用 PyTorch 从头开始构建这个模型。

构建文本翻译的序列到序列模型

为了构建我们的序列到序列翻译模型,我们将实现之前概述的编码器/解码器框架。这将展示我们的模型的两个部分如何结合在一起,以通过编码器捕获数据的表示,然后使用解码器将这个表示翻译成另一种语言。为了做到这一点,我们需要获取我们的数据。

准备数据

现在我们已经了解足够多的关于机器学习的知识,知道对于这样的任务,我们需要一组带有相应标签的训练数据。在这种情况下,我们将需要 Torchtext 库,我们在前一章中使用的这个库包含一个数据集,可以帮助我们获得这些数据。

Torchtext 中的 Multi30k 数据集包含大约 30,000 个句子及其在多种语言中的对应翻译。对于这个翻译任务,我们的输入句子将是英文,输出句子将是德文。因此,我们完全训练好的模型将能够将英文句子翻译成德文

我们将开始提取和预处理我们的数据。我们将再次使用 spacy,它包含一个内置的词汇表字典,我们可以用它来标记化我们的数据:

  1. 我们首先将 spacy 标记器加载到 Python 中。我们将需要为每种语言执行一次此操作,因为我们将为此任务构建两个完全独立的词汇表:

    spacy_german = spacy.load(‘de’)
    spacy_english = spacy.load(‘en’)
    

    重要说明

    您可能需要通过以下命令行安装德语词汇表(我们在前一章中安装了英语词汇表):python3 -m spacy download de

  2. 接下来,我们为每种语言创建一个函数来对我们的句子进行标记化。请注意,我们对输入的英文句子进行标记化时会颠倒 token 的顺序:

    def tokenize_german(text):
        return [token.text for token in spacy_german.            tokenizer(text)]
    def tokenize_english(text):
        return [token.text for token in spacy_english.            tokenizer(text)][::-1]
    

    虽然反转输入句子的顺序并非强制性的,但已被证明可以提高模型的学习能力。如果我们的模型由两个连接在一起的 RNN 组成,我们可以展示在反转输入句子时,模型内部的信息流得到了改善。例如,让我们来看一个基本的英文输入句子,但不进行反转,如下所示:

    图 7.10 – 反转输入单词

    图 7.10 – 反转输入单词

    在这里,我们可以看到为了正确预测第一个输出词 y0,我们的第一个英文单词从 x0 必须通过三个 RNN 层后才能进行预测。从学习的角度来看,这意味着我们的梯度必须通过三个 RNN 层进行反向传播,同时通过网络保持信息的流动。现在,让我们将其与反转输入句子的情况进行比较:

    图 7.11 – 反转输入句子

    图 7.11 – 反转输入句子

    现在我们可以看到,输入句子中真正的第一个单词与输出句子中相应单词之间的距离仅为一个 RNN 层。这意味着梯度只需反向传播到一个层,这样网络的信息流和学习能力与输入输出单词之间距离为三层时相比要大得多。

    如果我们计算反向和非反向变体中输入单词与其输出对应单词之间的总距离,我们会发现它们是相同的。然而,我们先前已经看到,输出句子中最重要的单词是第一个单词。这是因为输出句子中的单词依赖于它们之前的单词。如果我们错误地预测输出句子中的第一个单词,那么后面的单词很可能也会被错误地预测。然而,通过正确预测第一个单词,我们最大化了正确预测整个句子的机会。因此,通过最小化输出句子中第一个单词与其输入对应单词之间的距离,我们可以增加模型学习这种关系的能力。这增加了此预测正确的机会,从而最大化了整个输出句子被正确预测的机会。

  3. 有了我们构建的分词器,现在我们需要为分词定义字段。请注意,在这里我们如何在我们的序列中添加开始和结束标记,以便我们的模型知道何时开始和结束序列的输入和输出。为了简化起见,我们还将所有的输入句子转换为小写:

    SOURCE = Field(tokenize = tokenize_english, 
                init_token = ‘<sos>’, 
                eos_token = ‘<eos>’, 
                lower = True)
    TARGET = Field(tokenize = tokenize_german, 
                init_token = ‘<sos>’, 
                eos_token = ‘<eos>’, 
                lower = True)
    
  4. 我们定义了字段后,我们的分词化变成了一个简单的一行代码。包含 30,000 个句子的数据集具有内置的训练、验证和测试集,我们可以用于我们的模型:

    train_data, valid_data, test_data = Multi30k.splits(exts = (‘.en’, ‘.de’), fields = (SOURCE, TARGET))
    
  5. 我们可以使用数据集对象的examples属性来检查单个句子。在这里,我们可以看到源(src)属性包含我们英语输入句子的反向,而目标(trg)包含我们德语输出句子的非反向:

    print(train_data.examples[0].src)
    print(train_data.examples[0].trg)
    

    这给了我们以下输出:

    ![图 7.12 – 训练数据示例

    图 7.12 – 训练数据示例

  6. 现在,我们可以检查每个数据集的大小。在这里,我们可以看到我们的训练数据集包含 29,000 个示例,而每个验证集和测试集分别包含 1,014 和 1,000 个示例。在过去,我们通常将训练和验证数据拆分为 80%/20%。然而,在像这样输入输出字段非常稀疏且训练集有限的情况下,通常最好利用所有可用数据进行训练:

    print(“Training dataset size: “ + str(len(train_data.       examples)))
    print(“Validation dataset size: “ + str(len(valid_data.       examples)))
    print(“Test dataset size: “ + str(len(test_data.       examples)))
    

    这将返回以下输出:

    图 7.13 – 数据样本长度

  7. 现在,我们可以构建我们的词汇表并检查它们的大小。我们的词汇表应包含数据集中发现的每个唯一单词。我们可以看到我们的德语词汇表比我们的英语词汇表大得多。我们的词汇表比每种语言的真实词汇表大小要小得多(英语词典中的每个单词)。因此,由于我们的模型只能准确地翻译它以前见过的单词,我们的模型不太可能能够很好地泛化到英语语言中的所有可能句子。这就是为什么像这样准确训练模型需要极大的 NLP 数据集(例如 Google 可以访问的那些)的原因:

    SOURCE.build_vocab(train_data, min_freq = 2)
    TARGET.build_vocab(train_data, min_freq = 2)
    print(“English (Source) Vocabulary Size: “ +        str(len(SOURCE.vocab)))
    print(“German (Target) Vocabulary Size: “ +        str(len(TARGET.vocab)))
    

    这将得到以下输出:

    ![图 7.14 – 数据集的词汇量 图 7.14 – 数据集的词汇量

    图 7.14 – 数据集的词汇量

  8. 最后,我们可以从我们的数据集创建数据迭代器。与以前一样,我们指定使用支持 CUDA 的 GPU(如果系统上可用),并指定我们的批量大小:

    device = torch.device(‘cuda’ if torch.cuda.is_available()                       else ‘cpu’)
    batch_size = 32
    train_iterator, valid_iterator, test_iterator = BucketIterator.splits(
        (train_data, valid_data, test_data), 
        batch_size = batch_size, 
        device = device)
    

现在我们的数据已经预处理完成,我们可以开始构建模型本身。

建立编码器

现在,我们可以开始建立我们的编码器:

  1. 首先,我们通过从我们的nn.Module类继承来初始化我们的模型,就像我们之前的所有模型一样。我们初始化一些参数,稍后我们会定义,以及我们的 LSTM 层中隐藏层的维度数和 LSTM 层的数量:

    class Encoder(nn.Module):
        def __init__(self, input_dims, emb_dims, hid_dims,     n_layers, dropout):
            super().__init__()   
            self.hid_dims = hid_dims
            self.n_layers = n_layers
    
  2. 接下来,我们在编码器内定义我们的嵌入层,这是输入维度数量和嵌入维度数量的长度:

    self.embedding = nn.Embedding(input_dims, emb_dims)
    
  3. 接下来,我们定义实际的 LSTM 层。这需要我们从嵌入层获取嵌入的句子,保持定义长度的隐藏状态,并包括一些层(稍后我们将定义为 2)。我们还实现dropout以对网络应用正则化:

    self.rnn = nn.LSTM(emb_dims, hid_dims, n_layers, dropout                    = dropout)
    self.dropout = nn.Dropout(dropout)
    
  4. 然后,我们在编码器内定义前向传播。我们将嵌入应用于我们的输入句子并应用 dropout。然后,我们将这些嵌入传递到我们的 LSTM 层,输出我们的最终隐藏状态。这将由我们的解码器用于形成我们的翻译句子:

    def forward(self, src):
        embedded = self.dropout(self.embedding(src))
        outputs, (h, cell) = self.rnn(embedded)
        return h, cell
    

我们的编码器将包括两个 LSTM 层,这意味着我们的输出将输出两个隐藏状态。这也意味着我们的完整 LSTM 层,以及我们的编码器,将看起来像这样,我们的模型输出两个隐藏状态:

图 7.15 – 具有编码器的 LSTM 模型

图 7.15 – 具有编码器的 LSTM 模型

现在我们已经建立了编码器,让我们开始建立我们的解码器。

建立解码器

我们的解码器将从编码器的 LSTM 层中获取最终的隐藏状态,并将其转化为另一种语言的输出句子。我们首先通过几乎完全相同的方式初始化我们的解码器,与编码器的方法略有不同的是,我们还添加了一个全连接线性层。该层将使用 LSTM 的最终隐藏状态来预测句子中正确的单词:

class Decoder(nn.Module):
    def __init__(self, output_dims, emb_dims, hid_dims,     n_layers, dropout):
        super().__init__()

        self.output_dims = output_dims
        self.hid_dims = hid_dims
        self.n_layers = n_layers

        self.embedding = nn.Embedding(output_dims, emb_dims)

        self.rnn = nn.LSTM(emb_dims, hid_dims, n_layers,                           dropout = dropout)

        self.fc_out = nn.Linear(hid_dims, output_dims)

        self.dropout = nn.Dropout(dropout)

我们的前向传播与编码器非常相似,只是增加了两个关键步骤。首先,我们将前一层的输入展开,以使其适合输入到嵌入层中。然后,我们添加一个全连接层,该层接收我们的 RNN 层的输出隐藏层,并用其来预测序列中的下一个单词:

def forward(self, input, h, cell):

    input = input.unsqueeze(0)

    embedded = self.dropout(self.embedding(input))

    output, (h, cell) = self.rnn(embedded, (h, cell))

    pred = self.fc_out(output.squeeze(0))

    return pred, h, cell

类似于编码器,我们在解码器内部使用了一个两层的 LSTM 层。我们取出编码器的最终隐藏状态,并用它们生成序列中的第一个单词 Y1. 然后,我们更新我们的隐藏状态,并使用它和 Y1 生成下一个单词 Y2,重复此过程,直到我们的模型生成一个结束标记。我们的解码器看起来像这样:

图 7.16 – 带有解码器的 LSTM 模型

图 7.16 – 带有解码器的 LSTM 模型

在这里,我们可以看到单独定义编码器和解码器并不特别复杂。然而,当我们将这些步骤组合成一个更大的序列到序列模型时,事情开始变得有趣:

构建完整的序列到序列模型

现在,我们必须将我们模型的两个部分连接起来,以产生完整的序列到序列模型:

  1. 我们首先创建一个新的序列到序列类。这将允许我们将编码器和解码器作为参数传递给它:

    class Seq2Seq(nn.Module):
        def __init__(self, encoder, decoder, device):
            super().__init__()
    
            self.encoder = encoder
            self.decoder = decoder
            self.device = device
    
  2. 接下来,我们在我们的 Seq2Seq 类中创建 forward 方法。这可以说是模型中最复杂的部分。我们将编码器与解码器结合起来,并使用教师强迫来帮助我们的模型学习。我们首先创建一个张量,其中存储我们的预测。我们将其初始化为一个全零张量,但随着我们生成预测,我们会更新它。全零张量的形状将是目标句子的长度、批量大小的宽度和目标(德语)词汇表大小的深度:

    def forward(self, src, trg, teacher_forcing_rate = 0.5):
        batch_size = trg.shape[1]
        target_length = trg.shape[0]
        target_vocab_size = self.decoder.output_dims
    
         outputs = torch.zeros(target_length, batch_size,                     target_vocab_size).to(self.device)
    
  3. 接下来,我们将输入句子传递到编码器中,以获取输出的隐藏状态:

    h, cell = self.encoder(src)
    
  4. 然后,我们必须循环遍历我们的解码器模型,为输出序列中的每个步骤生成一个输出预测。输出序列的第一个元素始终是 <start> 标记。我们的目标序列已将其作为第一个元素,因此我们只需将初始输入设置为这个,通过获取列表的第一个元素:

    input = trg[0,:]
    
  5. 接下来,我们循环并进行预测。我们将我们的隐藏状态(从编码器的输出中获得)传递给我们的解码器,以及我们的初始输入(仅是<start>标记)。这将为我们序列中的所有单词返回一个预测。然而,我们只对当前步骤中的单词感兴趣;也就是说,序列中的下一个单词。请注意,我们从 1 开始循环,而不是从 0 开始,因此我们的第一个预测是序列中的第二个单词(因为始终预测的第一个单词将始终是起始标记)。

  6. 此输出由目标词汇长度的向量组成,每个词汇中都有一个预测。我们使用argmax函数来识别模型预测的实际单词。

    接下来,我们需要为下一步选择新的输入。我们将我们的教师强制比例设置为 50%,这意味着有 50%的时间,我们将使用我们刚刚做出的预测作为我们解码器的下一个输入,而另外 50%的时间,我们将采用真实的目标值。正如我们之前讨论的那样,这比仅依赖于模型预测能够更快地让我们的模型学习。

    然后,我们继续这个循环,直到我们对序列中的每个单词都有了完整的预测:

    for t in range(1, target_length):
    output, h, cell = self.decoder(input, h, cell)
    
    outputs[t] = output
    
    top = output.argmax(1) 
    
    input = trg[t] if (random.random() < teacher_forcing_                   rate) else top
    
    return outputs
    
  7. 最后,我们创建一个准备好进行训练的 Seq2Seq 模型的实例。我们使用一些超参数初始化了一个编码器和一个解码器,所有这些超参数都可以稍微改变模型:

    input_dimensions = len(SOURCE.vocab)
    output_dimensions = len(TARGET.vocab)
    encoder_embedding_dimensions = 256
    decoder_embedding_dimensions = 256
    hidden_layer_dimensions = 512
    number_of_layers = 2
    encoder_dropout = 0.5
    decoder_dropout = 0.5
    
  8. 然后,我们将我们的编码器和解码器传递给我们的Seq2Seq模型,以创建完整的模型:

    encod = Encoder(input_dimensions,\
                    encoder_embedding_dimensions,\
                    hidden_layer_dimensions,\
                    number_of_layers, encoder_dropout)
    decod = Decoder(output_dimensions,\
                    decoder_embedding_dimensions,\
                    hidden_layer_dimensions,\
                    number_of_layers, decoder_dropout)
    model = Seq2Seq(encod, decod, device).to(device)
    

尝试在这里用不同的参数进行实验,并查看它们如何影响模型的性能。例如,在隐藏层中使用更大数量的维度可能会导致模型训练速度较慢,尽管最终模型的性能可能会更好。或者,模型可能会过拟合。通常来说,这是一个通过实验来找到最佳性能模型的问题。

在完全定义了我们的 Seq2Seq 模型之后,我们现在准备开始训练它。

训练模型

我们的模型将从整个模型的各个部分开始以 0 权重进行初始化。虽然理论上模型应该能够学习到没有(零)权重的情况,但已经证明使用随机权重初始化可以帮助模型更快地学习。让我们开始吧:

  1. 在这里,我们将使用从正态分布中随机抽取的随机样本的权重来初始化我们的模型,其值介于-0.1 到 0.1 之间:

    def initialize_weights(m):
        for name, param in m.named_parameters():
            nn.init.uniform_(param.data, -0.1, 0.1)
    
    model.apply(initialize_weights)
    
  2. 接下来,与我们的其他所有模型一样,我们定义我们的优化器和损失函数。我们使用交叉熵损失,因为我们正在执行多类别分类(而不是二元交叉熵损失用于二元分类):

    optimizer = optim.Adam(model.parameters())
    criterion = nn.CrossEntropyLoss(ignore_index = TARGET.               vocab.stoi[TARGET.pad_token])
    
  3. 接下来,在名为train()的函数中定义训练过程。首先,我们将模型设置为训练模式,并将 epoch 损失设置为0

    def train(model, iterator, optimizer, criterion, clip):
        model.train()
        epoch_loss = 0
    
  4. 然后,我们在我们的训练迭代器中循环遍历每个批次,并提取要翻译的句子(src)和这个句子的正确翻译(trg)。然后我们将梯度归零(以防止梯度累积),通过将我们的输入和输出传递给模型函数来计算模型的输出:

    for i, batch in enumerate(iterator):
    src = batch.src
    trg = batch.trg
    optimizer.zero_grad()
    output = model(src, trg)
    
  5. 接下来,我们需要通过比较我们的预测输出和真实的正确翻译句子来计算模型预测的损失。我们使用形状和视图函数来重塑我们的输出数据和目标数据,以便创建两个可以比较的张量,以计算损失。我们在我们的输出和 trg 张量之间计算 loss 损失标准,然后通过网络反向传播这个损失:

    output_dims = output.shape[-1]
    output = output[1:].view(-1, output_dims)
    trg = trg[1:].view(-1)
    
    loss = criterion(output, trg)
    
    loss.backward()
    
  6. 然后,我们实施梯度裁剪以防止模型内出现梯度爆炸,通过梯度下降来步进我们的优化器执行必要的参数更新,最后将批次的损失添加到 epoch 损失中。这整个过程针对单个训练 epoch 中的所有批次重复执行,最终返回每批次的平均损失:

    torch.nn.utils.clip_grad_norm_(model.parameters(), clip)
    
    optimizer.step()
    
    epoch_loss += loss.item()
    
    return epoch_loss / len(iterator)
    
  7. 之后,我们创建一个名为 evaluate() 的类似函数。这个函数将计算整个网络中验证数据的损失,以评估我们的模型在翻译它之前未见的数据时的表现。这个函数几乎与我们的 train() 函数相同,唯一的区别是我们切换到评估模式:

    model.eval()
    
  8. 由于我们不对权重进行任何更新,我们需要确保实现 no_grad 模式:

    with torch.no_grad():
    
  9. 另一个不同之处在于,我们需要确保在评估模式下关闭教师强迫。我们希望评估模型在未见数据上的表现,并且启用教师强迫将使用正确的(目标)数据来帮助我们的模型做出更好的预测。我们希望我们的模型能够完美地做出预测:

    output = model(src, trg, 0)
    
  10. 最后,我们需要创建一个训练循环,在其中调用我们的 train()evaluate() 函数。我们首先定义我们希望训练的 epoch 数量以及我们的最大梯度(用于梯度裁剪)。我们还将我们的最低验证损失设置为无穷大。稍后将使用它来选择我们表现最佳的模型:

    epochs = 10
    grad_clip = 1
    lowest_validation_loss = float(‘inf’)
    
  11. 然后,我们循环遍历每一个 epoch,在每一个 epoch 中,使用我们的 train()evaluate() 函数计算训练和验证损失。我们还通过调用 time.time() 函数在训练过程前后计时:

    for epoch in range(epochs):
    
        start_time = time.time()
    
        train_loss = train(model, train_iterator, optimizer,                       criterion, grad_clip)
        valid_loss = evaluate(model, valid_iterator,                          criterion)
    
        end_time = time.time()
    
  12. 接下来,对于每个 epoch,我们确定刚刚训练的模型是否是迄今为止表现最佳的模型。如果我们的模型在验证数据上表现最佳(如果验证损失是迄今为止最低的),我们会保存我们的模型:

    if valid_loss < lowest_validation_loss:
    lowest_validation_loss = valid_loss
    torch.save(model.state_dict(), ‘seq2seq.pt’) 
    
  13. 最后,我们简单地打印我们的输出:

    print(f’Epoch: {epoch+1:02} | Time: {np.round(end_time-start_time,0)}s’)
    print(f’\tTrain Loss: {train_loss:.4f}’)
    print(f’\t Val. Loss: {valid_loss:.4f}’)
    

    如果我们的训练工作正确,我们应该看到训练损失随时间减少,如下所示:

Figure 7.17 – 训练模型

图 7.17 – 训练模型

在这里,我们可以看到我们的训练和验证损失随时间逐渐下降。我们可以继续训练我们的模型多个 epochs,理想情况下直到验证损失达到最低可能值。现在,我们可以评估我们表现最佳的模型,看看它在进行实际翻译时的表现如何。

评估模型

为了评估我们的模型,我们将使用我们的测试数据集,将我们的英语句子通过我们的模型,得到翻译成德语的预测。然后,我们将能够将其与真实预测进行比较,以查看我们的模型是否进行了准确的预测。让我们开始吧!

  1. 我们首先创建一个translate()函数。这个函数与我们创建的evaluate()函数在功能上是一样的,用来计算验证集上的损失。但是,这一次我们不关心模型的损失,而是关心预测的输出。我们向模型传递源语句和目标语句,并确保关闭教师强制,这样我们的模型不会用它们来进行预测。然后,我们获取模型的预测结果,并使用argmax函数来确定我们预测输出句子中每个词的索引:

    output = model(src, trg, 0)
    preds = torch.tensor([[torch.argmax(x).item()] for x         in output])
    
  2. 然后,我们可以使用这个索引从我们的德语词汇表中获取实际预测的词。最后,我们将英语输入与包含正确德语句子和预测德语句子的模型进行比较。请注意,在这里,我们使用[1:-1]来删除预测中的起始和结束标记,并且我们反转了英语输入的顺序(因为输入句子在进入模型之前已经被反转):

    print(‘English Input: ‘ + str([SOURCE.vocab.itos[x] for x        in src][1:-1][::-1]))
    print(‘Correct German Output: ‘ + str([TARGET.vocab.       itos[x] for x in trg][1:-1]))
    print(‘Predicted German Output: ‘ + str([TARGET.vocab.       itos[x] for x in preds][1:-1]))
    

    通过这样做,我们可以将我们的预测输出与正确输出进行比较,以评估我们的模型是否能够进行准确的预测。从我们模型的预测中可以看出,我们的模型能够将英语句子翻译成德语,尽管远非完美。一些模型的预测与目标数据完全相同,表明我们的模型完美地翻译了这些句子:

图 7.18 – 翻译输出第一部分

图 7.18 – 翻译输出第一部分

在其他情况下,我们的模型只差一个词。在这种情况下,我们的模型预测单词hüten而不是mützen;然而,hüten实际上是mützen的可接受翻译,尽管这些词在语义上可能不完全相同:

图 7.19 – 翻译输出第二部分

图 7.19 – 翻译输出第二部分

我们还可以看到一些似乎被错误翻译的例子。在下面的例子中,我们预测的德语句子的英语等效句子是“A woman climbs through one”,这与“Young woman climbing rock face”不相等。然而,模型仍然成功翻译了英语句子的关键元素(woman 和 climbing):

图 7.20 – 翻译输出第三部分

图 7.20 – 翻译输出第三部分

在这里,我们可以看到,虽然我们的模型显然尝试着将英语翻译成德语,但远非完美,并且存在多个错误。它肯定无法欺骗一个德语母语者!接下来,我们将讨论如何改进我们的序列到序列翻译模型的几种方法。

下一步

虽然我们展示了我们的序列到序列模型在执行语言翻译方面是有效的,但我们从头开始训练的模型绝不是完美的翻译器。部分原因在于我们训练数据的相对较小规模。我们在一组 30,000 个英语/德语句子上训练了我们的模型。虽然这可能看起来非常大,但要训练一个完美的模型,我们需要一个几个数量级更大的训练集。

理论上,我们需要每个单词在整个英语和德语语言中的多个例子,才能使我们的模型真正理解其上下文和含义。就我们训练集中的情况而言,这包括仅有 6,000 个独特单词的 30,000 个英语句子。据说,一个英语人士的平均词汇量在 20,000 到 30,000 之间,这让我们对需要训练一个完美执行的模型有了一定的了解。这也许是为什么最准确的翻译工具通常由拥有大量语言数据的公司(如 Google)拥有。

总结

在本章中,我们介绍了如何从头开始构建序列到序列模型。我们学习了如何分别编码和解码组件,并如何将它们整合成一个能够将一种语言的句子翻译成另一种语言的单一模型。

尽管我们的序列到序列模型包括编码器和解码器,在序列翻译中很有用,但它已不再是最先进的技术。在过去几年中,结合序列到序列模型和注意力模型已经被用来实现最先进的性能。

在下一章中,我们将讨论注意力网络如何在序列到序列学习的背景下使用,并展示我们如何同时使用这两种技术来构建聊天机器人。

第八章:使用基于注意力的神经网络构建对话机器人

如果你看过任何未来主义科幻电影,你可能会看到人类与机器人交谈。基于机器的智能一直是小说作品中的一个长期特征;然而,由于自然语言处理和深度学习的最新进展,与计算机的对话不再是幻想。虽然我们离真正的智能可能还有很多年的距离,即使是现在,计算机至少能够进行基本的对话并给出初步的智能印象。

在上一章中,我们讨论了如何构建序列到序列模型来将句子从一种语言翻译成另一种语言。一个能进行基本交互的对话机器人工作方式类似。当我们与机器人交谈时,我们的句子成为模型的输入。输出是机器人选择回复的内容。因此,我们不是训练机器人如何解释我们的输入句子,而是教会它如何回应。

我们将在上一章的序列到序列模型基础上增加一种称为注意力的东西。这种改进使得我们的序列到序列模型学会了在输入句子中寻找需要的信息,而不是全盘使用输入句子的决定。这种改进允许我们创建具有最先进性能的高效序列到序列模型。

本章将讨论以下主题:

  • 神经网络中的注意力理论

  • 在神经网络中实现注意力以构建对话机器人

技术要求

本章的所有代码可以在 github.com/PacktPublishing/Hands-On-Natural-Language-Processing-with-PyTorch-1.x 找到。

神经网络中的注意力理论

在上一章中,在我们的序列到序列模型中进行句子翻译(未实现注意力)时,我们使用了编码器和解码器。编码器从输入句子中获得了隐藏状态,这是我们句子的表示。解码器然后使用这个隐藏状态执行翻译步骤。其基本的图形说明如下:

图 8.1 – 序列到序列模型的图形表示

图 8.1 – 序列到序列模型的图形表示

然而,在整个隐藏状态上解码并不一定是使用此任务的最有效方式。这是因为隐藏状态代表输入句子的整体;然而在某些任务中(例如预测句子中的下一个单词),我们并不需要考虑输入句子的整体,只需要考虑与我们试图做出的预测相关的部分。我们可以通过在我们的序列到序列神经网络中使用注意力来表明这一点。我们可以教导我们的模型只看输入中相关的部分来做出预测,从而得到一个更加高效和准确的模型。

考虑以下例子:

我将于 3 月 2 日去法国的首都巴黎。我的航班将从伦敦希思罗机场起飞,大约需要一个小时。

假设我们正在训练一个模型来预测句子中的下一个单词。我们可以先输入句子的开头:

法国的首都是 _____。

在这种情况下,我们希望我们的模型能够检索单词巴黎。如果我们使用基本的序列到序列模型,我们会将整个输入转换为一个隐藏状态,然后我们的模型会尝试从中提取相关的信息。这包括关于航班的所有无关信息。您可能会注意到,我们只需要查看输入句子的一个小部分即可识别完成句子所需的相关信息:

我将于 3 月 2 日去法国的首都巴黎。我的航班将从伦敦希思罗机场起飞,大约需要一个小时。

因此,如果我们可以训练我们的模型仅使用输入句子中的相关信息,我们可以做出更准确和相关的预测。我们可以在我们的网络中实现注意力来实现这一点。

我们可以实现的两种主要注意机制是本地注意力和全局注意力。

比较本地和全局注意力

我们可以在我们的网络中实现的两种注意机制非常相似,但有微妙的关键区别。我们将从本地注意力开始。

本地注意力中,我们的模型只关注来自编码器的几个隐藏状态。例如,如果我们正在执行一个句子翻译任务,并且正在计算我们翻译中的第二个单词,模型可能只希望查看与输入句子中第二个单词相关的编码器的隐藏状态。这意味着我们的模型需要查看编码器的第二个隐藏状态(h2),但可能还需要查看其之前的隐藏状态(h1)。

在以下图表中,我们可以看到这一实践:

图 8.2 – 本地注意力模型

图 8.2 – 本地注意力模型

我们首先通过计算对齐位置,pt,从我们的最终隐藏状态,hn,得知我们需要查看哪些隐藏状态来进行预测。然后我们计算我们的局部权重,并将其应用于我们的隐藏状态,以确定我们的上下文向量。这些权重可能告诉我们更多地关注最相关的隐藏状态(h2),但对前一个隐藏状态(h1)的关注较少。

然后,我们将我们的上下文向量传递给我们的解码器,以进行其预测。在我们基于非注意力的序列到序列模型中,我们只会传递我们的最终隐藏状态,hn,但我们在这里看到,相反地,我们只考虑我们的模型认为必要以进行预测的相关隐藏状态。

全局注意力模型的工作方式与局部注意力模型非常相似。但是,与仅查看少数隐藏状态不同,我们希望查看我们模型的所有隐藏状态 — 因此称为全局。我们可以在这里看到全局注意力层的图形说明:

图 8.3 – 全局注意力模型

图 8.3 – 全局注意力模型

我们可以看到在上图中,虽然这看起来与我们的局部注意力框架非常相似,但是我们的模型现在正在查看所有的隐藏状态,并计算跨所有隐藏状态的全局权重。这使得我们的模型可以查看它认为相关的输入句子的任何部分,而不限于由局部注意力方法确定的局部区域。我们的模型可能希望只关注一个小的局部区域,但这是模型的能力范围内。全局注意力框架的一个简单理解方式是,它本质上是在学习一个只允许与我们的预测相关的隐藏状态通过的掩码:

图 8.4 – 组合模型

图 8.4 – 组合模型

我们可以看到在上图中,通过学习要关注的隐藏状态,我们的模型控制着在解码步骤中使用哪些状态来确定我们的预测输出。一旦我们决定要关注哪些隐藏状态,我们可以使用多种不同的方法来结合它们,无论是通过串联还是加权点积。

使用带有注意力的序列到序列神经网络构建聊天机器人

在我们的神经网络中准确实现注意力的最简单方式是通过一个例子来进行说明。现在我们将通过使用应用注意力框架的序列到序列模型来从头开始构建聊天机器人的步骤。

与我们所有其他的自然语言处理模型一样,我们的第一步是获取和处理数据集,以用于训练我们的模型。

获取我们的数据集

为了训练我们的聊天机器人,我们需要一组对话数据,通过这些数据,我们的模型可以学习如何回应。我们的聊天机器人将接受人类输入的一行,并用生成的句子作出回应。因此,理想的数据集应包含一些对话行及其适当的响应。对于这样的任务,理想的数据集将是两个人用户之间的实际聊天记录。不幸的是,这些数据包含私人信息,很难在公共领域内获取,因此,对于这个任务,我们将使用一组电影剧本数据集。

电影剧本由两个或更多角色之间的对话组成。尽管这些数据不是我们想要的格式,但我们可以轻松地将其转换为我们需要的格式。例如,考虑两个角色之间的简单对话:

  • 第 1 行:你好,贝瑟恩。

  • 第 2 行:你好,汤姆,你好吗?

  • 第 3 行:我很好,谢谢,今晚你要做什么?

  • 第 4 行:我没有什么计划。

  • 第 5 行:你想和我一起吃晚饭吗?

现在,我们需要将这些转换为呼叫和响应的输入输出对,其中输入是剧本中的一行(呼叫),期望的输出是剧本的下一行(响应)。我们可以将包含n行的剧本转换为n-1对输入/输出:

图 8.5 – 输入输出表

图 8.5 – 输入输出表

我们可以使用这些输入/输出对来训练我们的网络,其中输入代表人类输入的代理,输出是我们期望从模型得到的响应。

构建我们模型的第一步是读取这些数据并执行所有必要的预处理步骤。

处理我们的数据集

幸运的是,提供给本示例的数据集已经被格式化,以便每行表示单个输入/输出对。我们可以首先读取数据并检查一些行:

corpus = "movie_corpus"
corpus_name = "movie_corpus"
datafile = os.path.join(corpus, "formatted_movie_lines.txt")
with open(datafile, 'rb') as file:
    lines = file.readlines()

for line in lines[:3]:
    print(str(line) + '\n')

这将打印出以下结果:

图 8.6 – 检查数据集

图 8.6 – 检查数据集

您将首先注意到我们的行按预期显示,因为第一行的后半部分成为下一行的前半部分。我们还可以注意到,每行的呼叫和响应部分由制表符(/t)分隔,每行之间由换行符(/n)分隔。在处理数据集时,我们必须考虑到这一点。

第一步是创建一个包含数据集中所有唯一单词的词汇表或语料库。

创建词汇表

在过去,我们的语料库由几个字典组成,包含语料库中唯一单词和单词与索引之间的查找。但是,我们可以通过创建一个包含所有所需元素的词汇表类的更加优雅的方式来完成这项工作:

  1. 我们首先创建我们的Vocabulary类。我们用空字典——word2indexword2count——初始化这个类。我们还用占位符初始化index2word字典,用于我们的填充标记,以及我们的句子开头SOS)和句子结尾EOS)标记。我们还保持我们词汇表中单词数的运行计数,作为我们的语料库已经包含了提到的三个标记的默认值(初始为 3)。这些是一个空词汇表的默认值;然而,随着我们读取数据,它们将被填充:

    PAD_token = 0 
    SOS_token = 1
    EOS_token = 2
    class Vocabulary:
        def __init__(self, name):
            self.name = name
            self.trimmed = False
            self.word2index = {}
            self.word2count = {}
            self.index2word = {PAD_token: "PAD", SOS_token:                           "SOS", EOS_token: "EOS"}
            self.num_words = 3
    
  2. 接下来,我们创建用于填充我们词汇表的函数。addWord接受一个单词作为输入。如果这是一个不在我们词汇表中的新单词,我们将此单词添加到我们的索引中,将此单词的计数设置为 1,并将我们词汇表中的总单词数增加 1。如果所讨论的单词已经在我们的词汇表中,则简单地将此单词的计数增加 1:

    def addWord(self, w):
        if w not in self.word2index:
            self.word2index[w] = self.num_words
            self.word2count[w] = 1
            self.index2word[self.num_words] = w
            self.num_words += 1
        else:
            self.word2count[w] += 1
    
  3. 我们还使用addSentence函数将addWord函数应用于给定句子中的所有单词:

    def addSentence(self, sent):
        for word in sent.split(' '):
            self.addWord(word)
    

    我们可以做的一件事是加快模型训练的速度,即减小词汇表的大小。这意味着任何嵌入层将会更小,模型内学习的参数总数也会减少。一个简单的方法是从我们的词汇表中移除任何低频词汇。在我们的数据集中出现一次或两次的词汇不太可能有很大的预测能力,因此从我们的语料库中移除它们,并在最终模型中用空白标记替换它们,可以减少模型训练的时间,减少过拟合的可能性,而对模型预测的负面影响不大。

  4. 要从我们的词汇表中删除低频词汇,我们可以实现一个trim函数。该函数首先遍历单词计数字典,如果单词的出现次数大于所需的最小计数,则将其添加到一个新列表中:

    def trim(self, min_cnt):
        if self.trimmed:
            return
        self.trimmed = True
        words_to_keep = []
        for k, v in self.word2count.items():
            if v >= min_cnt:
                words_to_keep.append(k)
        print('Words to Keep: {} / {} = {:.2%}'.format(
            len(words_to_keep), len(self.word2index),    
            len(words_to_keep) / len(self.word2index)))
    
  5. 最后,我们从新的words_to_keep列表重新构建我们的索引。我们将所有索引设置为它们的初始空值,然后通过循环遍历我们保留的单词使用addWord函数来重新填充它们:

        self.word2index = {}
        self.word2count = {}
        self.index2word = {PAD_token: "PAD",\
                           SOS_token: "SOS",\
                           EOS_token: "EOS"}
        self.num_words = 3
        for w in words_to_keep:
            self.addWord(w)
    

现在我们已经定义了一个词汇表类,可以轻松地填充我们的输入句子。接下来,我们实际上需要加载我们的数据集来创建我们的训练数据。

加载数据

我们将使用以下步骤开始加载数据:

  1. 读取我们数据的第一步是执行任何必要的步骤来清理数据并使其更易于阅读。我们首先将其从 Unicode 格式转换为 ASCII 格式。我们可以轻松地使用一个函数来实现这一点:

    def unicodeToAscii(s):
        return ''.join(
            c for c in unicodedata.normalize('NFD', s)
            if unicodedata.category(c) != 'Mn'
        )
    
  2. 接下来,我们希望处理我们的输入字符串,使它们全部小写,并且不包含任何尾随的空白或标点符号,除了最基本的字符。我们可以通过使用一系列正则表达式来实现这一点:

    def cleanString(s):
        s = unicodeToAscii(s.lower().strip())
        s = re.sub(r"([.!?])", r" \1", s)
        s = re.sub(r"[^a-zA-Z.!?]+", r" ", s)
        s = re.sub(r"\s+", r" ", s).strip()
        return s
    
  3. 最后,我们在更广泛的函数内应用此函数——readVocs。此函数将我们的数据文件读取为行,并对每行应用cleanString函数。它还创建了我们之前创建的Vocabulary类的实例,这意味着此函数输出了我们的数据和词汇表:

    def readVocs(datafile, corpus_name):
        lines = open(datafile, encoding='utf-8').\
            read().strip().split('\n')
        pairs = [[cleanString(s) for s in l.split('\t')]               for l in lines]
        voc = Vocabulary(corpus_name)
        return voc, pairs
    

    接下来,我们根据它们的最大长度过滤我们的输入对。再次这样做是为了减少模型的潜在维度。预测数百个单词长的句子将需要非常深的架构。为了训练时间的利益,我们希望在此限制我们的训练数据,使输入和输出都少于 10 个单词长。

  4. 为此,我们创建了一对过滤函数。第一个函数,filterPair,根据当前行的输入和输出长度是否小于最大长度返回布尔值。我们的第二个函数,filterPairs,则简单地将此条件应用于数据集中的所有对,仅保留符合条件的对:

    def filterPair(p, max_length):
        return len(p[0].split(' ')) < max_length and len(p[1].split(' ')) < max_length
    def filterPairs(pairs, max_length):
        return [pair for pair in pairs if filterPair(pair,             max_length)]
    
  5. 现在,我们只需要创建一个最终函数,将之前所有的函数整合起来,并运行它以创建我们的词汇表和数据对:

    def loadData(corpus, corpus_name, datafile, save_dir, max_length):
        voc, pairs = readVocs(datafile, corpus_name)
        print(str(len(pairs)) + " Sentence pairs")
        pairs = filterPairs(pairs,max_length)
        print(str(len(pairs))+ " Sentence pairs after           trimming")
        for p in pairs:
            voc.addSentence(p[0])
            voc.addSentence(p[1])
        print(str(voc.num_words) + " Distinct words in           vocabulary")
        return voc, pairs
    max_length = 10 
    voc, pairs = loadData(corpus, corpus_name, datafile,                       max_length)
    

    我们可以看到,我们的输入数据集包含超过 200,000 对。当我们将其过滤为输入和输出长度都小于 10 个单词的句子时,这就减少到仅有 64,000 对,包含 18,000 个不同的单词:

    图 8.7 – 数据集中句子的价值

    图 8.7 – 数据集中句子的价值

  6. 我们可以打印出我们处理过的输入/输出对的一部分,以验证我们的函数是否都运行正确:

    print("Example Pairs:")
    for pair in pairs[-10:]:
        print(pair)
    

    生成以下输出:

图 8.8 – 处理过的输入/输出对

图 8.8 – 处理过的输入/输出对

我们已成功将数据集分割成输入和输出对,用以训练我们的网络。

最后,在我们开始构建模型之前,我们必须从我们的语料库和数据对中移除稀有单词。

移除稀有单词

正如先前提到的,包括数据集中仅出现几次的单词会增加模型的维度,增加模型的复杂性以及训练模型的时间。因此,最好将它们从训练数据中移除,以保持我们的模型尽可能简洁和高效。

您可能还记得我们在词汇表中构建了一个trim函数,它允许我们从词汇表中删除不常见的单词。我们现在可以创建一个函数来删除这些稀有单词,并调用词汇表中的trim方法作为我们的第一步。您将看到这将从我们的词汇表中删除大部分单词,表明大多数词汇中的单词出现不频繁。这是预期的,因为任何语言模型中的单词分布将遵循长尾分布。我们将使用以下步骤来删除这些单词:

  1. 我们首先计算我们模型中将保留的单词百分比:

    def removeRareWords(voc, all_pairs, minimum):
        voc.trim(minimum)
    

    这导致以下输出:

    图 8.9 – 需保留的单词百分比

    图 8.9 – 需保留的单词百分比

  2. 在同一个函数中,我们循环遍历输入和输出句子中的所有单词。如果对于给定的句对,输入或输出句子中有一个单词不在我们的新修剪语料库中,我们将删除这个句对。我们打印输出并看到,尽管我们删除了超过一半的词汇,但我们只删除了大约 17% 的训练句对。这再次反映了我们的单词语料库如何分布在个别训练句对上:

    pairs_to_keep = []
    for p in all_pairs:
        keep = True
        for word in p[0].split(' '):
            if word not in voc.word2index:
                keep = False
                break
        for word in p[1].split(' '):
            if word not in voc.word2index:
                keep = False
                break
        if keep:
            pairs_to_keep.append(p)
    print("Trimmed from {} pairs to {}, {:.2%} of total".\
           format(len(all_pairs), len(pairs_to_keep),
                  len(pairs_to_keep)/ len(all_pairs)))
    return pairs_to_keep
    minimum_count = 3
    pairs = removeRareWords(voc, pairs, minimum_count)
    

    这导致以下输出:

图 8.10 – 构建数据集后的最终值

图 8.10 – 构建数据集后的最终值

现在我们有了最终的数据集,我们需要构建一些函数,将我们的数据集转换为我们可以传递给模型的张量批次。

将句子对转换为张量

我们知道,我们的模型不会接受原始文本作为输入,而是句子的张量表示。我们也不会逐句处理,而是分批次处理。为此,我们需要将输入和输出句子都转换为张量,张量的宽度表示我们希望训练的批次大小:

  1. 我们首先创建了几个辅助函数,用于将我们的句对转换为张量。我们首先创建了一个indexFromSentence函数,该函数从词汇表中获取句子中每个单词的索引,并在末尾添加一个 EOS 标记:

    def indexFromSentence(voc, sentence):
        return [voc.word2index[word] for word in\
                sent.split(' ')] + [EOS_token]
    
  2. 其次,我们创建一个zeroPad函数,它用零填充任何张量,使张量中的所有句子的长度有效相同:

    def zeroPad(l, fillvalue=PAD_token):
        return list(itertools.zip_longest(*l,\
                    fillvalue=fillvalue))
    
  3. 然后,为了生成我们的输入张量,我们应用这两个函数。首先,我们获取我们输入句子的索引,然后应用填充,然后将输出转换为LongTensor。我们还将获取每个输入句子的长度,并将其作为张量输出:

    def inputVar(l, voc):
        indexes_batch = [indexFromSentence(voc, sentence)\
                         for sentence in l]
        padList = zeroPad(indexes_batch)
        padTensor = torch.LongTensor(padList)
        lengths = torch.tensor([len(indexes) for indexes\                            in indexes_batch])
        return padTensor, lengths
    
  4. 在我们的网络中,我们通常应忽略我们的填充标记。我们不希望在这些填充标记上训练我们的模型,因此我们创建一个布尔掩码来忽略这些标记。为此,我们使用一个 getMask 函数,将其应用于我们的输出张量。这只是简单地在输出包含单词时返回 1,在包含填充标记时返回 0

    def getMask(l, value=PAD_token):
        m = []
        for i, seq in enumerate(l):
            m.append([])
            for token in seq:
                if token == PAD_token:
                    m[i].append(0)
                else:
                    m[i].append(1)
        return m
    
  5. 然后我们将其应用于我们的 outputVar 函数。这与 inputVar 函数相同,不同之处在于,除了索引化的输出张量和长度张量外,我们还返回我们输出张量的布尔掩码。这个布尔掩码在输出张量中有单词时返回 True,在存在填充标记时返回 False。我们还返回输出张量中句子的最大长度:

    def outputVar(l, voc):
        indexes_batch = [indexFromSentence(voc, sentence) 
                         for sentence in l]
        max_target_len = max([len(indexes) for indexes in
                              indexes_batch])
        padList = zeroPad(indexes_batch)
        mask = torch.BoolTensor(getMask(padList))
        padTensor = torch.LongTensor(padList)
        return padTensor, mask, max_target_len
    
  6. 最后,为了同时创建我们的输入和输出批次,我们遍历批次中的对,并为每对使用我们之前创建的函数创建输入和输出张量。然后返回所有必要的变量:

    def batch2Train(voc, batch):
        batch.sort(key=lambda x: len(x[0].split(" ")),\
                   reverse=True)
    
        input_batch = []
        output_batch = []
    
        for p in batch:
            input_batch.append(p[0])
            output_batch.append(p[1])
    
        inp, lengths = inputVar(input_batch, voc)
        output, mask, max_target_len = outputVar(output_                                   batch, voc)
    
        return inp, lengths, output, mask, max_target_len
    
  7. 此函数应该是我们将训练对转换为用于训练模型的张量所需的全部内容。我们可以通过在我们的数据的随机选择上执行我们的 batch2Train 函数的单次迭代来验证其是否工作正确。我们将我们的批量大小设为 5 并运行一次:

    test_batch_size = 5
    batches = batch2Train(voc, [random.choice(pairs) for _\                            in range(test_batch_size)])
    input_variable, lengths, target_variable, mask, max_target_len = batches
    

    在这里,我们可以验证我们的输入张量是否已正确创建。注意句子如何以填充(0 标记)结尾,其中句子长度小于张量的最大长度(在本例中为 9)。张量的宽度也与批量大小相对应(在本例中为 5):

图 8.11 – 输入张量

图 8.11 – 输入张量

我们还可以验证相应的输出数据和掩码。注意掩码中的 False 值如何与输出张量中的填充标记(零)重叠:

图 8.12 – 目标张量和掩码张量

图 8.12 – 目标张量和掩码张量

现在我们已经获取、清理和转换了我们的数据,我们准备开始训练基于注意力机制的模型,这将成为我们聊天机器人的基础。

构建模型

与我们其他的序列到序列模型一样,我们首先通过创建我们的编码器来开始。这将把我们输入句子的初始张量表示转换为隐藏状态。

构建编码器

现在我们将通过以下步骤创建编码器:

  1. 与我们所有的 PyTorch 模型一样,我们首先创建一个继承自 nn.ModuleEncoder 类。这里的所有元素应该与之前章节中使用的元素看起来很熟悉:

    class EncoderRNN(nn.Module):
        def __init__(self, hidden_size, embedding,\
                     n_layers=1, dropout=0):
            super(EncoderRNN, self).__init__()
            self.n_layers = n_layers
            self.hidden_size = hidden_size
            self.embedding = embedding
    

    接下来,我们创建我们的循环神经网络RNN)模块。在这个聊天机器人中,我们将使用门控循环单元GRU)而不是我们之前看到的长短期记忆LSTM)模型。GRUs 比 LSTMs 稍微简单一些,尽管它们仍然通过 RNN 控制信息的流动,但它们不像 LSTMs 那样有单独的遗忘和更新门。我们在这种情况下使用 GRUs 有几个主要原因:

    a) GRUs 已被证明在计算效率上更高,因为要学习的参数更少。这意味着我们的模型将比使用 LSTMs 更快地训练。

    b) GRUs 已被证明在短数据序列上具有与 LSTMs 类似的性能水平。当学习较长的数据序列时,LSTMs 更有用。在这种情况下,我们仅使用包含 10 个单词或更少的输入句子,因此 GRUs 应产生类似的结果。

    c) GRUs 已被证明在从小数据集中学习方面比 LSTMs 更有效。由于我们的训练数据相对于我们试图学习的任务的复杂性很小,我们应该选择使用 GRUs。

  2. 现在,我们定义我们的 GRU,考虑到我们输入的大小、层数,以及是否应该实施 dropout:

    self.gru = nn.GRU(hidden_size, hidden_size, n_layers,
                      dropout=(0 if n_layers == 1 else\
                               dropout), bidirectional=True)
    

    注意这里我们如何将双向性实现到我们的模型中。您会从前几章中回忆起,双向 RNN 允许我们从一个句子中顺序地向前移动,同时也可以顺序地向后移动。这使我们能够更好地捕捉每个单词在句子中相对于前后出现的单词的上下文。我们 GRU 中的双向性意味着我们的编码器看起来像这样:

    图 8.13 – 编码器布局

    图 8.13 – 编码器布局

    我们在输入句子中维护两个隐藏状态,以及每一步的输出。

  3. 接下来,我们需要为我们的编码器创建一个前向传播。我们通过首先对我们的输入句子进行嵌入,然后在我们的嵌入上使用pack_padded_sequence函数来完成这一操作。该函数“打包”我们的填充序列,使得所有的输入都具有相同的长度。然后,我们通过我们的 GRU 传递打包的序列来执行前向传播:

    def forward(self, input_seq, input_lengths, hidden=None):
        embedded = self.embedding(input_seq)
        packed = nn.utils.rnn.pack_padded_sequence(embedded,
                                          input_lengths)
        outputs, hidden = self.gru(packed, hidden)
    
  4. 在此之后,我们取消我们的填充并汇总 GRU 的输出。然后,我们可以返回这个总和输出以及我们的最终隐藏状态,以完成我们的前向传播:

    outputs, _ = nn.utils.rnn.pad_packed_sequence(outputs)
    outputs = outputs[:, :, :self.hidden_size] + a \
              outputs[:, : ,self.hidden_size:]
    return outputs, hidden
    

现在,我们将继续在下一节中创建一个注意模块。

构建注意模块

接下来,我们需要构建我们的注意模块,我们将应用它到我们的编码器上,以便我们可以从编码器输出的相关部分学习。我们将按以下方式执行:

  1. 首先,创建一个注意模型的类:

    class Attn(nn.Module):
        def __init__(self, hidden_size):
            super(Attn, self).__init__()
            self.hidden_size = hidden_size
    
  2. 然后,在这个类中创建dot_score函数。该函数简单地计算我们的编码器输出与我们的隐藏状态输出的点积。虽然有其他将这两个张量转换为单一表示的方法,但使用点积是其中最简单的之一:

    def dot_score(self, hidden, encoder_output):
        return torch.sum(hidden * encoder_output, dim=2)
    
  3. 然后,我们在我们的前向传播中使用此函数。首先,基于dot_score方法计算注意力权重/能量,然后转置结果,并返回经过 softmax 转换的概率分数:

    def forward(self, hidden, encoder_outputs):
        attn_energies = self.dot_score(hidden, \
                                       encoder_outputs)
        attn_energies = attn_energies.t()
        return F.softmax(attn_energies, dim=1).unsqueeze(1)
    

接下来,我们可以在我们的解码器中使用这个注意力模块来创建一个关注注意力的解码器。

构建解码器。

现在我们将构建解码器,如下所示:

  1. 我们首先创建我们的DecoderRNN类,继承自nn.Module并定义初始化参数:

    class DecoderRNN(nn.Module):
        def __init__(self, embedding, hidden_size, \
                     output_size, n_layers=1, dropout=0.1):
            super(DecoderRNN, self).__init__()
            self.hidden_size = hidden_size
            self.output_size = output_size
            self.n_layers = n_layers
            self.dropout = dropout
    
  2. 然后,在此模块中创建我们的层。我们将创建一个嵌入层和一个相应的丢弃层。我们再次使用 GRU 作为我们的解码器;但是,这次我们不需要使我们的 GRU 层双向,因为我们将按顺序解码我们的编码器输出。我们还将创建两个线性层——一个常规层用于计算输出,一个可用于连接的层。此层的宽度是常规隐藏层的两倍,因为它将用于两个长度为hidden_size的连接向量。我们还从上一节初始化我们注意力模块的一个实例,以便能够在我们的Decoder类中使用它:

    self.embedding = embedding
    self.embedding_dropout = nn.Dropout(dropout)
    self.gru = nn.GRU(hidden_size, hidden_size, n_layers,  dropout=(0 if n_layers == 1 else dropout))
    self.concat = nn.Linear(2 * hidden_size, hidden_size)
    self.out = nn.Linear(hidden_size, output_size)
    self.attn = Attn(hidden_size)
    
  3. 在定义了所有的层之后,我们需要为解码器创建一个前向传播。注意前向传播将逐步(单词)使用。我们首先获取当前输入单词的嵌入,并通过 GRU 层进行前向传播以获取我们的输出和隐藏状态:

    def forward(self, input_step, last_hidden, encoder_outputs):
        embedded = self.embedding(input_step)
        embedded = self.embedding_dropout(embedded)
        rnn_output, hidden = self.gru(embedded, last_hidden)
    
  4. 接下来,我们使用注意力模块从 GRU 输出中获取注意力权重。然后将这些权重与编码器输出相乘,有效地给出我们的注意力权重和编码器输出的加权和:

    attn_weights = self.attn(rnn_output, encoder_outputs)
    context = attn_weights.bmm(encoder_outputs.transpose(0,
                                                         1))
    
  5. 然后,我们将我们的加权上下文向量与我们的 GRU 输出连接起来,并应用一个tanh函数来获得我们的最终连接输出:

    rnn_output = rnn_output.squeeze(0)
    context = context.squeeze(1)
    concat_input = torch.cat((rnn_output, context), 1)
    concat_output = torch.tanh(self.concat(concat_input))
    
  6. 在我们解码器的最后一步中,我们简单地使用这个最终连接的输出来预测下一个单词并应用softmax函数。前向传播最终返回此输出,以及最终的隐藏状态。这个前向传播将迭代进行,下一个前向传播使用句子中的下一个单词和这个新的隐藏状态:

    output = self.out(concat_output)
    output = F.softmax(output, dim=1)
    return output, hidden
    

现在我们已经定义了我们的模型,我们准备定义训练过程。

定义训练过程。

训练过程的第一步是为我们的模型定义损失度量。由于我们的输入张量可能包含填充序列,因为我们的输入句子长度各不相同,我们不能简单地计算真实输出和预测输出张量之间的差异。为此,我们将定义一个损失函数,该函数在我们的输出上应用布尔掩码,并仅计算非填充标记的损失:

  1. 在以下函数中,我们可以看到我们计算整个输出张量的交叉熵损失。然而,为了得到总损失,我们只对布尔掩码选定的张量元素进行平均:

    def NLLMaskLoss(inp, target, mask):
        TotalN = mask.sum()
        CELoss = -torch.log(torch.gather(inp, 1,\                        target.view(-1, 1)).squeeze(1))
        loss = CELoss.masked_select(mask).mean()
        loss = loss.to(device)
        return loss, TotalN.item()
    
  2. 在大部分训练过程中,我们需要两个主要函数——一个函数train(),用于对训练数据的单个批次进行训练,另一个函数trainIters(),用于迭代整个数据集并在每个单独的批次上调用train()。我们首先定义train()函数以便在单个数据批次上进行训练。创建train()函数,然后将梯度置零,定义设备选项,并初始化变量:

    def train(input_variable, lengths, target_variable,\
              mask, max_target_len, encoder, decoder,\
              embedding, encoder_optimizer,\
              decoder_optimizer, batch_size, clip,\
              max_length=max_length):
        encoder_optimizer.zero_grad()
        decoder_optimizer.zero_grad()
        input_variable = input_variable.to(device)
        lengths = lengths.to(device)
        target_variable = target_variable.to(device)
        mask = mask.to(device)
        loss = 0
        print_losses = []
        n_totals = 0
    
  3. 然后,执行输入和序列长度的前向传递,通过编码器获取输出和隐藏状态:

    encoder_outputs, encoder_hidden = encoder(input_variable, lengths)
    
  4. 接下来,我们创建初始解码器输入,每个句子都以 SOS 标记开头。然后,我们将解码器的初始隐藏状态设置为与编码器相等:

    decoder_input = torch.LongTensor([[SOS_token for _ in \
                                       range(batch_size)]])
    decoder_input = decoder_input.to(device)
    decoder_hidden = encoder_hidden[:decoder.n_layers]
    

    接下来,我们实现教师强制。如果你还记得上一章节,教师强制在生成输出序列时,我们使用真实的前一个输出标记,而不是预测的前一个输出标记来生成下一个单词。使用教师强制可以帮助我们的模型更快地收敛;然而,我们必须小心,不要将教师强制比率设置得太高,否则我们的模型将过于依赖教师强制,无法独立学习生成正确的输出。

  5. 确定当前步骤是否应该使用教师强制:

    use_TF = True if random.random() < teacher_forcing_ratio else False
    
  6. 然后,如果我们确实需要实现教师强制,运行以下代码。我们通过解码器传递每个序列批次以获得输出。然后,我们将下一个输入设置为真实输出(target)。最后,我们使用我们的损失函数计算并累积损失,并将其打印到控制台:

    for t in range(max_target_len):
    decoder_output, decoder_hidden = decoder(
      decoder_input, decoder_hidden, encoder_outputs)
    decoder_input = target_variable[t].view(1, -1)
    mask_loss, nTotal = NLLMaskLoss(decoder_output, \
         target_variable[t], mask[t])
    loss += mask_loss
    print_losses.append(mask_loss.item() * nTotal)
    n_totals += nTotal
    
  7. 如果在给定批次上不实现教师强制,该过程几乎相同。但是,我们不是使用真实输出作为序列中的下一个输入,而是使用模型生成的输出:

    _, topi = decoder_output.topk(1)
    decoder_input = torch.LongTensor([[topi[i][0] for i in \
                                       range(batch_size)]])
    decoder_input = decoder_input.to(device)
    
  8. 最后,和我们所有的模型一样,最后的步骤是执行反向传播,实施梯度裁剪,并且通过我们的编码器和解码器优化器来更新权重,使用梯度下降。记住,我们剪切梯度以防止消失/爆炸梯度问题,这在前几章已经讨论过。最后,我们的训练步骤返回我们的平均损失:

    loss.backward()
    _ = nn.utils.clip_grad_norm_(encoder.parameters(), clip)
    _ = nn.utils.clip_grad_norm_(decoder.parameters(), clip)
    encoder_optimizer.step()
    decoder_optimizer.step()
    return sum(print_losses) / n_totals
    
  9. 接下来,如前所述,我们需要创建trainIters()函数,它反复调用我们的训练函数来处理不同的输入数据批次。我们首先使用我们之前创建的batch2Train函数将数据分成批次:

    def trainIters(model_name, voc, pairs, encoder, decoder,\
                   encoder_optimizer, decoder_optimizer,\
                   embedding, encoder_n_layers, \
                   decoder_n_layers, save_dir, n_iteration,\
                   batch_size, print_every, save_every, \
                   clip, corpus_name, loadFilename):
        training_batches = [batch2Train(voc,\
                           [random.choice(pairs) for _ in\
                            range(batch_size)]) for _ in\
                            range(n_iteration)]
    
  10. 然后,我们创建一些变量,这些变量将允许我们计算迭代次数并跟踪每个时代的总损失:

    print('Starting ...')
    start_iteration = 1
    print_loss = 0
    if loadFilename:
        start_iteration = checkpoint['iteration'] + 1
    
  11. 接下来,我们定义我们的训练循环。对于每个迭代,我们从我们的批次列表中获取一个训练批次。然后,我们从批次中提取相关字段,并使用这些参数运行单个训练迭代。最后,我们将这一批次的损失添加到我们的总损失中:

    print("Beginning Training...")
    for iteration in range(start_iteration, n_iteration + 1):
        training_batch = training_batches[iteration - 1]
        input_variable, lengths, target_variable, mask, \
              max_target_len = training_batch
        loss = train(input_variable, lengths,\
                     target_variable, mask, max_target_len,\
                     encoder, decoder, embedding, \
                     encoder_optimizer, decoder_optimizer,\
                     batch_size, clip)
        print_loss += loss
    
  12. 在每次迭代中,我们还确保打印我们目前的进度,跟踪我们已经完成了多少次迭代以及每个时代的损失是多少:

    if iteration % print_every == 0:
        print_loss_avg = print_loss / print_every
        print("Iteration: {}; Percent done: {:.1f}%;\
        Mean loss: {:.4f}".format(iteration,
                              iteration / n_iteration \
                              * 100, print_loss_avg))
        print_loss = 0
    
  13. 为了完成,我们还需要在每几个时代之后保存我们的模型状态。这样可以让我们重新查看我们训练过的任何历史模型;例如,如果我们的模型开始过拟合,我们可以回到之前的迭代:

    if (iteration % save_every == 0):
        directory = os.path.join(save_dir, model_name,\
                                 corpus_name, '{}-{}_{}'.\
                                 format(encoder_n_layers,\
                                 decoder_n_layers, \
                                 hidden_size))
                if not os.path.exists(directory):
                    os.makedirs(directory)
                torch.save({
                    'iteration': iteration,
                    'en': encoder.state_dict(),
                    'de': decoder.state_dict(),
                    'en_opt': encoder_optimizer.state_dict(),
                    'de_opt': decoder_optimizer.state_dict(),
                    'loss': loss,
                    'voc_dict': voc.__dict__,
                    'embedding': embedding.state_dict()
                }, os.path.join(directory, '{}_{}.tar'.format(iteration, 'checkpoint')))
    

现在我们已经完成了所有必要的步骤来开始训练我们的模型,我们需要创建函数来允许我们评估模型的性能。

定义评估过程

评估聊天机器人与评估其他序列到序列模型略有不同。在我们的文本翻译任务中,一个英文句子将直接翻译成德文。虽然可能有多个正确的翻译,但大部分情况下,从一种语言到另一种语言的翻译只有一个正确的。

对于聊天机器人,有多个不同的有效输出。以下是与聊天机器人对话中的三行内容:

输入:"Hello"

输出:"Hello"

输入:"Hello"

输出:"Hello. How are you?"

输入:"*Hello"

输出:"What do you want?"

在这里,我们有三个不同的响应,每一个都同样有效作为响应。因此,在与聊天机器人对话的每个阶段,不会有单一的“正确”响应。因此,评估要困难得多。测试聊天机器人是否产生有效输出的最直观方法是与其对话!这意味着我们需要设置我们的聊天机器人,使其能够与我们进行对话,以确定其是否工作良好:

  1. 我们将从定义一个类开始,这个类将允许我们解码编码的输入并生成文本。我们通过使用所谓的GreedyEncoder()类与我们预训练的编码器和解码器来做到这一点:

    class GreedySearchDecoder(nn.Module):
        def __init__(self, encoder, decoder):
            super(GreedySearchDecoder, self).__init__()
            self.encoder = encoder
            self.decoder = decoder
    
  2. 接下来,定义我们的解码器的前向传播。我们通过我们的编码器传递输入以获取我们编码器的输出和隐藏状态。我们将编码器的最终隐藏层作为解码器的第一个隐藏输入:

    def forward(self, input_seq, input_length, max_length):
        encoder_outputs, encoder_hidden = \
                        self.encoder(input_seq, input_length)
        decoder_hidden = encoder_hidden[:decoder.n_layers]
    
  3. 然后,使用 SOS 标记创建解码器输入,并初始化张量以附加解码的单词(初始化为单个零值):

    decoder_input = torch.ones(1, 1, device=device, dtype=torch.long) * SOS_token
    all_tokens = torch.zeros([0], device=device, dtype=torch.long)
    all_scores = torch.zeros([0], device=device)
    
  4. 然后,逐个解码序列中的单词。我们通过编码器进行前向传播,并添加一个max函数来获取最高得分的预测单词及其分数,然后将其附加到all_tokensall_scores变量中。最后,我们取这个预测的标记并将其用作我们的解码器的下一个输入。在整个序列迭代完毕后,我们返回完整的预测句子:

    for _ in range(max_length):
        decoder_output, decoder_hidden = self.decoder\
            (decoder_input, decoder_hidden, encoder_outputs)
        decoder_scores, decoder_input = \
             torch.max (decoder_output, dim=1)
        all_tokens = torch.cat((all_tokens, decoder_input),\
                                dim=0)
        all_scores = torch.cat((all_scores, decoder_scores),\
                                dim=0)
        decoder_input = torch.unsqueeze(decoder_input, 0)
    return all_tokens, all_scores
    

    所有的部分都开始串联在一起了。我们已经定义了训练和评估函数,所以最后一步是编写一个实际将我们的输入作为文本、传递给我们的模型并从模型获取响应的函数。这将是我们聊天机器人的“接口”,在这里我们实际上与我们的聊天机器人对话。

  5. 我们首先定义一个evaluate()函数,该函数接受我们的输入函数并返回预测的输出单词。我们开始通过我们的词汇表将输入句子转换为索引。然后,我们获得每个这些句子的长度的张量,并将其转置:

    def evaluate(encoder, decoder, searcher, voc, sentence,\
                 max_length=max_length):
        indices = [indexFromSentence(voc, sentence)]
        lengths = torch.tensor([len(indexes) for indexes \
                                in indices])
        input_batch = torch.LongTensor(indices).transpose(0, 1)
    
  6. 然后,我们将我们的长度和输入张量分配给相关设备。接下来,通过搜索器(GreedySearchDecoder)运行输入,以获取预测输出的单词索引。最后,我们将这些单词索引转换回单词标记,然后将它们作为函数输出返回:

    input_batch = input_batch.to(device)
    lengths = lengths.to(device)
    tokens, scores = searcher(input_batch, lengths, \
                              max_length)
    decoded_words = [voc.index2word[token.item()] for \
                     token in tokens]
    return decoded_words
    
  7. 最后,我们创建一个runchatbot函数,它作为与我们的聊天机器人的接口。这个函数接受人类输入并打印聊天机器人的响应。我们将此函数创建为一个while循环,直到我们终止函数或在输入中键入quit为止:

    def runchatbot(encoder, decoder, searcher, voc):
        input_sentence = ''
        while(1):
            try:
                input_sentence = input('> ')
                if input_sentence == 'quit': break
    
  8. 然后,我们获取输入的内容并对其进行标准化,然后将标准化的输入传递给我们的evaluate()函数,该函数从聊天机器人返回预测的单词:

    input_sentence = cleanString(input_sentence)
    output_words = evaluate(encoder, decoder, searcher,\
                            voc, input_sentence)
    
  9. 最后,我们获取这些输出单词并格式化它们,在打印聊天机器人的响应之前忽略 EOS 和填充标记。因为这是一个while循环,这允许我们无限期地与聊天机器人继续对话:

    output_words[:] = [x for x in output_words if \
                       not (x == 'EOS' or x == 'PAD')]
    print('Response:', ' '.join(output_words))
    

现在我们已经构建了训练、评估和使用我们的聊天机器人所需的所有函数,是时候开始最后一步了——训练我们的模型并与我们训练过的聊天机器人交流了。

训练模型

由于我们已经定义了所有必要的函数,训练模型只是初始化我们的超参数并调用我们的训练函数的情况:

  1. 首先我们初始化我们的超参数。虽然这些只是建议的超参数,但我们的模型已经被设置成可以适应任何传递给它们的超参数。通过尝试不同的超参数来看哪些超参数会导致最佳的模型配置是一个良好的实践。在这里,您可以尝试增加编码器和解码器的层数,增加或减少隐藏层的大小,或增加批处理大小。所有这些超参数都会影响您的模型学习效果,以及训练模型所需的时间:

    model_name = 'chatbot_model'
    hidden_size = 500
    encoder_n_layers = 2
    decoder_n_layers = 2
    dropout = 0.15
    batch_size = 64
    
  2. 之后,我们可以加载我们的检查点。如果我们以前训练过一个模型,我们可以加载以前迭代的检查点和模型状态。这样可以避免每次重新训练模型:

    loadFilename = None
    checkpoint_iter = 4000
    if loadFilename:
        checkpoint = torch.load(loadFilename)
        encoder_sd = checkpoint['en']
        decoder_sd = checkpoint['de']
        encoder_optimizer_sd = checkpoint['en_opt']
        decoder_optimizer_sd = checkpoint['de_opt']
        embedding_sd = checkpoint['embedding']
        voc.__dict__ = checkpoint['voc_dict']
    
  3. 之后,我们可以开始构建我们的模型。我们首先从词汇表中加载我们的嵌入。如果我们已经训练了一个模型,我们可以加载训练好的嵌入层:

    embedding = nn.Embedding(voc.num_words, hidden_size)
    if loadFilename:
        embedding.load_state_dict(embedding_sd)
    
  4. 接着我们对编码器和解码器进行同样的操作,使用定义好的超参数创建模型实例。如果我们已经训练过一个模型,我们只需加载训练好的模型状态到我们的模型中:

    encoder = EncoderRNN(hidden_size, embedding, \
                         encoder_n_layers, dropout)
    decoder = DecoderRNN(embedding, hidden_size, \ 
                         voc.num_words, decoder_n_layers,
                         dropout)
    if loadFilename:
        encoder.load_state_dict(encoder_sd)
        decoder.load_state_dict(decoder_sd)
    
  5. 最后但同样重要的是,我们为每个模型指定一个设备进行训练。请记住,如果您希望使用 GPU 进行训练,这是一个至关重要的步骤:

    encoder = encoder.to(device)
    decoder = decoder.to(device)
    print('Models built and ready to go!')
    

    如果一切工作正常,而且您的模型创建没有错误,您应该会看到以下内容:

    图 8.14 – 成功的输出

    图 8.14 – 成功的输出

    现在我们已经创建了编码器和解码器的实例,我们准备开始训练它们。

    我们首先初始化一些训练超参数。与我们的模型超参数一样,这些可以调整以影响训练时间和我们模型的学习方式。Clip 控制梯度裁剪,而 teacher forcing 控制我们在模型中使用 teacher forcing 的频率。请注意,我们使用了一个 teacher forcing 比率为 1,以便我们始终使用 teacher forcing。降低 teacher forcing 比率会导致我们的模型收敛时间更长;然而,从长远来看,这可能会帮助我们的模型更好地自动生成正确的句子。

  6. 我们还需要定义我们模型的学习率和解码器的学习率比。您会发现,当解码器在梯度下降过程中执行较大的参数更新时,您的模型表现会更好。因此,我们引入了一个解码器学习率比来将一个乘数应用于学习率,使得解码器的学习率比编码器的大。我们还定义了我们的模型打印和保存结果的频率,以及我们希望我们的模型运行多少个 epochs:

    save_dir = './'
    clip = 50.0
    teacher_forcing_ratio = 1.0
    learning_rate = 0.0001
    decoder_learning_ratio = 5.0
    epochs = 4000
    print_every = 1
    save_every = 500
    
  7. 接下来,像往常一样,在 PyTorch 中训练模型时,我们将模型切换到训练模式,以便更新参数:

    encoder.train()
    decoder.train()
    
  8. 接下来,我们为编码器和解码器创建优化器。我们将这些初始化为 Adam 优化器,但其他优化器同样有效。尝试不同的优化器可能会产生不同水平的模型性能。如果以前已经训练过一个模型,也可以在需要时加载优化器状态:

    print('Building optimizers ...')
    encoder_optimizer = optim.Adam(encoder.parameters(), \
                                   lr=learning_rate)
    decoder_optimizer = optim.Adam(decoder.parameters(), 
                   lr=learning_rate * decoder_learning_ratio)
    if loadFilename:
        encoder_optimizer.load_state_dict(\
                                       encoder_optimizer_sd)
        decoder_optimizer.load_state_dict(\
                                       decoder_optimizer_sd)
    
  9. 在运行训练之前的最后一步是确保 CUDA 已配置好以便进行 GPU 训练。为此,我们简单地循环遍历编码器和解码器的优化器状态,并在所有状态上启用 CUDA:

    for state in encoder_optimizer.state.values():
        for k, v in state.items():
            if isinstance(v, torch.Tensor):
                state[k] = v.cuda()
    for state in decoder_optimizer.state.values():
        for k, v in state.items():
            if isinstance(v, torch.Tensor):
                state[k] = v.cuda()
    
  10. 最后,我们准备训练我们的模型。这可以通过简单调用trainIters函数并传入所有必要的参数来完成:

    print("Starting Training!")
    trainIters(model_name, voc, pairs, encoder, decoder,\
               encoder_optimizer, decoder_optimizer, \
               embedding, encoder_n_layers, \
               decoder_n_layers, save_dir, epochs, \
                batch_size,print_every, save_every, \
                clip, corpus_name, loadFilename)
    

    如果一切正常,您将看到以下输出开始打印:

图 8.15 – 模型训练

图 8.15 – 模型训练

您的模型现在正在训练!根据多个因素(例如您为模型设置了多少个 epoch 以及是否使用 GPU 等),您的模型可能需要一些时间来训练。训练完成后,您将看到以下输出。如果一切正常,您的模型平均损失将显著低于训练开始时的水平,表明您的模型已经学到了一些有用的东西:

图 8.16 – 4,000 次迭代后的平均损失

图 8.16 – 4,000 次迭代后的平均损失

现在我们的模型已经训练完成,我们可以开始评估过程并开始使用我们的聊天机器人。

评估模型

现在我们成功创建并训练了我们的模型,是时候评估其性能了。我们将通过以下步骤来进行:

  1. 开始评估之前,我们首先将模型切换到评估模式。与所有其他 PyTorch 模型一样,这是为了防止在评估过程中发生任何进一步的参数更新:

    encoder.eval()
    decoder.eval()
    
  2. 我们还初始化了GreedySearchDecoder的一个实例,以便能够执行评估并将预测输出作为文本返回:

    searcher = GreedySearchDecoder(encoder, decoder)
    
  3. 最后,要运行聊天机器人,我们只需调用runchatbot函数,传入encoderdecodersearchervoc

    runchatbot(encoder, decoder, searcher, voc)
    

    这样做将打开一个输入提示,让您输入文本:

图 8.17 – 输入文本的用户界面元素

图 8.17 – 输入文本的用户界面元素

在此处输入您的文本并按Enter将您的输入发送给聊天机器人。使用我们训练过的模型,我们的聊天机器人将创建一个响应并将其打印到控制台:

图 8.18 – 聊天机器人的输出

图 8.18 – 聊天机器人的输出

您可以重复此过程多次,与聊天机器人进行“对话”。在简单的对话水平上,聊天机器人可以产生令人惊讶的良好结果:

图 8.19 – 聊天机器人的输出

图 8.19 – 聊天机器人的输出

然而,一旦对话变得更复杂,很明显聊天机器人无法达到与人类相同水平的对话能力:

图 8.20 – 聊天机器人的局限性

图 8.20 – 聊天机器人的局限性

在许多情况下,您的聊天机器人的回复可能是无意义的:

图 8.21 – 错误输出

图 8.21 – 错误输出

显然,我们创建了一个能够进行简单来回对话的聊天机器人。但在我们的聊天机器人能够通过图灵测试并使我们相信我们在与人类交谈之前,我们还有很长的路要走。然而,考虑到我们的模型训练的相对较小的数据语料库,我们在序列到序列模型中使用的注意力显示出了相当不错的结果,展示了这些架构有多么的多才多艺。

虽然最好的聊天机器人是在数十亿数据点的庞大语料库上训练的,但我们的模型在相对较小的数据集上证明了相当有效。然而,基本的注意力网络不再是最先进的,在下一章中,我们将讨论一些用于自然语言处理学习的最新发展,这些发展导致了更加逼真的聊天机器人。

摘要

在本章中,我们应用了我们从递归模型和序列到序列模型中学到的所有知识,并结合注意力机制构建了一个完全工作的聊天机器人。虽然与我们的聊天机器人交谈不太可能与真人交谈无异,但通过一个相当大的数据集,我们可能希望实现一个更加逼真的聊天机器人。

尽管在 2017 年,带有注意力的序列到序列模型是最先进的,但机器学习是一个快速发展的领域,自那时以来,对这些模型进行了多次改进。在最后一章中,我们将更详细地讨论一些这些最先进的模型,并涵盖用于自然语言处理的其他当代技术,其中许多仍在开发中。