Python 自然语言处理实用指南:第三部分

2023-04-27 15:18:17 浏览数 (1)

第三部分:使用 PyTorch 1.x 的实际 NLP 应用

在本节中,我们将使用 PyTorch 中可用的各种自然语言处理NLP)技术来构建各种实际 -使用 PyTorch 的世界应用。 情感分析,文本摘要,文本分类以及使用 PyTorch 构建聊天机器人应用是本节将介绍的一些任务。

本节包含以下章节:

  • “第 5 章”,“循环神经网络和情感分析”
  • “第 6 章”,“用于文本分类的卷积神经网络”
  • “第 7 章”,“使用序列到序列神经网络的文本翻译”
  • “第 8 章”,“使用基于注意力的神经网络构建聊天机器人”
  • “第 9 章”,“未来之路”

五、循环神经网络和情感分析

在本章中,我们将研究循环神经网络RNN),这是 PyTorch 中基本前馈神经网络的变体,我们在第 1 章“机器学习基础”中学习了如何构建它。 通常,RNN 可用于任何可以将数据表示为序列的任务。 其中包括使用以序列表示的历史数据的时间序列进行股票价格预测之类的事情。 我们通常在 NLP 中使用 RNN,因为可以将文本视为单个单词的序列,并可以对其进行建模。 传统的神经网络将单个向量作为模型的输入,而 RNN 可以采用整个向量序列。 如果我们将文档中的每个单词表示为向量嵌入,则可以将整个文档表示为向量序列(或 3 阶张量)。 然后,我们可以使用 RNN(以及更复杂的 RNN 形式,称为长短期记忆LSTM))从我们的数据中学习。

在本章中,我们将介绍 RNN 的基础知识和更高级的 LSTM。 然后,我们将研究情感分析,并通过一个实际的示例来研究如何使用 PyTorch 构建 LSTM 对文档进行分类。 最后,我们将在简单的云应用平台 Heroku 上托管我们的简单模型,这将使​​我们能够使用我们的模型进行预测。

本章涵盖以下主题:

  • 建立 RNN
  • 使用 LSTM
  • 使用 LSTM 构建情感分析器
  • 在 Heroku 上部署应用

技术要求

本章中使用的所有代码都可以在这个页面中找到。 可以从 www.heroku.com 安装 Heroku。 数据来自这里。

构建 RNN

RNN 由循环层组成。 尽管它们在许多方面类似于标准前馈神经网络中的全连接层,但这些循环层由隐藏状态组成,该隐藏状态在顺序输入的每个步骤中进行更新。 这意味着对于任何给定的序列,模型都使用隐藏状态初始化,该状态通常表示为一维向量。 然后,将序列的第一步输入模型,并根据一些学习到的参数更新隐藏状态。 然后将第二个单词馈入网络,并根据其他一些学习到的参数再次更新隐藏状态。 重复这些步骤,直到处理完整个序列,并且使我们处于最终的隐藏状态。 该计算使用从先前的计算中继承并更新的隐藏状态来循环执行,这就是为什么我们将这些网络称为循环的原因。 然后,将这个最终的隐藏状态连接到另一个全连接层,并预测最终的分类。

我们的循环层如下所示,其中h是隐藏状态,而x是我们在序列中各个时间步的输入。 对于每次迭代,我们都会在每个时间步x中更新隐藏状态:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fSpYGxu1-1681785833967)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_05_1.jpg)]

图 5.1 –循环层

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iJ3vMzqk-1681785833968)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_05_2.jpg)]

图 5.2 –时间步序

该层用于n时间步长的输入。 我们的隐藏状态在状态h0中初始化,然后使用我们的第一个输入x1来计算下一个隐藏状态h1。 还学习了两组权重矩阵:矩阵U和矩阵W,矩阵U了解隐藏状态如何在时间步之间变化,矩阵W隐藏状态。

我们还将 tanh 激活函数应用于所得产品,将隐藏状态的值保持在 -1 和 1 之间。用于计算任何隐藏状态的公式h[t]变为以下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4TGcBVt0-1681785833969)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/Formula_05_001.png)]

然后在输入序列中的每个时间步重复此操作,此层的最终输出是我们的最后一个隐藏状态h[n]。 当我们的网络学习时,我们像以前一样通过网络进行正向传播,以计算最终分类。 然后,我们根据此预测来计算损失,并像以前一样通过网络反向传播,并随即计算梯度。 反向传播过程在循环层的所有步骤中进行,每个输入步骤和隐藏状态之间的参数都将被学习。

稍后我们将看到,我们实际上可以在每个时间步长都采用隐藏状态,而不是使用最终的隐藏状态,这对于 NLP 中的序列到序列翻译任务很有用。 但是,暂时来说,我们只是将隐藏层作为对网络其余部分的输出。

使用 RNN 的情感分析

在情感分析的上下文中,我们的模型是根据评论的情感分析数据集训练的,该数据集由多个文本评论和 0 或 1 的标签组成,具体取决于评论是负面还是正面 。 这意味着我们的模型成为分类任务(两个类别为负/正)。 我们的句子经过一层学习的单词嵌入,以形成包含多个向量的句子的表示(每个单词一个)。 然后将这些向量顺序地馈入我们的 RNN 层,最终的隐藏状态将通过另一个全连接层。 我们的模型输出是介于 0 到 1 之间的单个值,具体取决于我们的模型是根据句子预测的是消极情感还是正面情感。 这意味着我们完整的分类模型如下所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3VYiBxHQ-1681785833969)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_05_3.jpg)]

图 5.3 –分类模型

现在,我们将重点介绍 RNN 的问题之一-爆炸和收缩梯度-以及我们如何使用梯度剪切来对此进行补救。

梯度爆炸和收缩

RNN 中我们经常面临的一个问题是梯度爆炸或收缩。 我们可以将循环层视为一个非常深的网络。 在计算梯度时,我们在隐藏状态的每次迭代中都这样做。 如果在给定位置的损失相对于权重的梯度变得很大,则在循环层的所有迭代中前馈时,这将产生乘法效果。 这会导致梯度爆炸,因为它们会很快变得非常大。 如果我们的梯度较大,则可能导致网络不稳定。 另一方面,如果隐藏状态下的梯度非常小,这将再次产生乘法效果,并且梯度将接近 0。这意味着梯度可能变得太小而无法通过梯度下降准确地更新参数, 表示我们的模型无法学习。

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bzKD0RsW-1681785833969)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/Formula_05_002.png)]

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dv0kpI3L-1681785833970)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_05_4.jpg)]

图 5.4 –梯度裁剪的比较

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

LSTM 介绍

尽管 RNN 允许我们将单词序列用作模型输入,但它们远非完美。 RNN 有两个主要缺陷,可以使用更高级的 RNN 版本 LSTM 来部分弥补。

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

考虑一段较长的段落,我们试图预测下一个单词。 句子的开头是I study math…,而结尾处是my final exam is in….中。 直观地,我们希望下一个单词是math或某些与数学相关的字段。 但是,在较长序列的 RNN 模型中,由于需要多个更新步骤,我们的隐藏状态可能难以在到达句子结尾时保留句子开头的信息。

我们还应该注意,RNN 在捕获整个句子中单词的上下文方面很差。 在查看 N 元组模型时,我们前面已经看到,一个单词在句子中的含义取决于它在句子中的上下文,上下文取决于它之前出现的单词和之后出现的单词。 在 RNN 中,我们的隐藏状态仅在一个方向上更新。 在单个正向传播中,我们的隐藏状态被初始化,并且序列中的第一个单词被传递到其中。 然后依次对句子中的所有后续单词重复此过程,直到我们处于最终的隐藏状态。 这意味着对于句子中的任何给定单词,我们只考虑了到该点之前在句子中出现的单词的累积效果。 我们不会考虑其后的任何单词,这意味着我们不会捕获句子中每个单词的完整上下文。

在另一个示例中,我们再次希望预测句子中丢失的单词,但是现在它出现在开头而不是结尾。 I grew up in…so I can speak fluent Dutch. 在这里,我们可以凭直觉猜测该人在荷兰长大,这是因为他们会说荷兰语。 但是,由于 RNN 会顺序分析此信息,因此它将仅使用I grew up in…进行预测,而缺少句子中的其他关键上下文。

使用 LSTM 可以部分解决这两个问题。

使用 LSTM

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

代码语言:javascript复制
The film was amazing. I went to see it with my wife and my daughters on Tuesday afternoon. Although I didn't expect it to be very entertaining, it turned out to be loads of fun. We would definitely go back and see it again given the chance.

在情感分析中,很明显,并不是句子中的所有单词在确定是正面评论还是负面评论时都相关。 我们将重复这句话,但是这次重点介绍与评估评论情感相关的词:

代码语言:javascript复制
The film was amazing. I went to see it with my wife and my daughters on Tuesday afternoon. Although I didn't expect it to be very entertaining, it turned out to be loads of fun. We would definitely go back and see it again given the chance.

LSTM 试图做到这一点—在忘记所有不相关信息的同时记住句子中的相关单词。 通过这样做,它可以阻止无关信息稀释相关信息,这意味着可以更好地了解长序列的长期依赖性。

LSTM 与 RNN 的结构非常相似。 虽然 LSTM 的各个步骤之间存在一个隐藏状态,但 LSTM 单元本身的内部工作方式不同于 RNN:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cfbm4h4h-1681785833970)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_05_5.jpg)]

图 5.5 – LSTM 单元

LSTM 单元

尽管 RNN 单元仅采用之前的隐藏状态和新的输入步骤,并使用一些学习到的参数来计算下一个隐藏状态,但 LSTM 单元的内部工作却要复杂得多:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-R4H1B1nj-1681785833970)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_05_6.jpg)]

图 5.6 – LSTM 单元的内部工作原理

尽管这看起来比 RNN 更加令人生畏,但我们将依次解释 LSTM 单元的每个组件。 我们首先来看遗忘门(由粗体矩形指示):

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bzqJQrZm-1681785833971)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_05_7.jpg)]

图 5.7 –遗忘门

遗忘门本质上是学习要忘记序列中的哪些元素。 先前的隐藏状态h[t-1]和最新的输入步骤x1被连接在一起,并通过了在遗忘门上习得的权重矩阵,以及 Sigmoid 函数,它将值压缩为 0 到 1 之间。将得到的矩阵f[t]逐点乘以上一步c[t-1]单元状态。 这有效地将掩码应用于先前的单元状态,使得仅来自先前的单元状态的相关信息被提出。

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CnTQgEhx-1681785833971)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_05_8.jpg)]

图 5.8 –输入门

输入门再次采用连接的先前隐藏状态h[t-1]和当前序列输入x[t],并将其通过具有学习参数的 Sigmoid 函数,从而输出另一个矩阵i[t],它由 0 到 1 之间的值组成。连接的隐藏状态和序列输入也通过 tanh 函数,该函数将输出压缩在 -1 和 1 之间。 通过i[t]矩阵。 这意味着生成i[t]所需的学习参数可以有效地了解应从当前时间步长将哪些元素保留在我们的单元状态中。 然后将其添加到当前单元状态以获得最终单元状态,该状态将继续进行到下一个时间步骤。

最后,我们有是 LSTM 单元的最后一个元素-输出门

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UyhVDvKG-1681785833971)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_05_9.jpg)]

图 5.9 –输出门

输出门计算 LSTM 单元的最终输出-单元状态和隐藏状态,并继续进行下一步。 单元状态c[t]与前两个步骤相同,是遗忘门和输入门的乘积。 通过获取连接的先前隐藏状态h[t-1]和当前时间步输入x[t],可以计算出最终隐藏状态h[t],并通过具有一些学习参数的 Sigmoid 函数来获得输出门输出o[t]。 最终单元状态c[t]通过 tanh 函数并乘以输出门输出o[t],以计算最终隐藏状态h[t]。 这意味着在输出门上学习到的参数可以有效地控制将先前隐藏状态和当前输出中的哪些元素与最终单元状态进行组合,以作为新的隐藏状态延续到下一个时间步长。

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

因为我们获得的参数要比简单的 RNN 多,并且我们的计算图更加复杂,所以与简单的 RNN 相比,通过网络反向传播和更新权重的过程可能会花费更长的时间。 但是,尽管训练时间更长,但是我们已经证明,与传统的 RNN 相比,LSTM 具有显着的优势,因为输出门,输入门和遗忘门都结合在一起,使模型能够确定应使用输入的哪些元素。 更新隐藏状态,并且以后应该忘记隐藏状态的哪些元素,这意味着该模型能够更好地形成长期依存关系并保留先前序列步骤中的信息。

双向 LSTM

前面我们曾提到,简单 RNN 的缺点是,由于它们只是向后看,它们无法捕获句子中单词的完整上下文。 在 RNN 的每个时间步骤中,仅考虑先前看到的单词,而不考虑句子中接下来出现的单词。 虽然基本 LSTM 类似地是向后的,但我们可以使用 LSTM 的改进版本,称为双向 LSTM,它在序列中的每个时间步都考虑它之前和之后的词。

双向 LSTM 同时处理规则顺序和反向顺序的序列,并保持两个隐藏状态。 我们将前向隐藏状态称为f[t],并将r[t]用作反向隐藏状态:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3yBd75V0-1681785833971)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_05_10.jpg)]

图 5.10 –双向 LSTM 过程

在这里,我们可以看到我们在整个过程中都保持了这两个隐藏状态,并使用它们来计算最终的隐藏状态h[t]。 因此,如果我们希望计算时间步t的最终隐藏状态,则可以使用前向隐藏状态f[t],该状态已看到所有单词,包括输入x[t],以及反向隐藏状态r[t],其中已经看到了x[t]之后的所有单词。 因此,我们最终的隐藏状态h[t]包含隐藏状态,这些状态已经看到了句子中的所有单词,而不仅仅是出现在时间步t之前的单词。 这意味着可以更好地捕获整个句子中任何给定单词的上下文。 事实证明,双向 LSTM 可以在多个 NLP 任务上提供比常规单向 LSTM 更高的表现。

使用 LSTM 构建情感分析器

现在,我们将研究如何构建自己的简单 LSTM,以根据句子的情感对句子进行分类。 我们将在 3,000 条评论被归为肯定或否定的评论的数据集上训练模型。 这些评论来自三个不同的来源(电影评论,产品评论和位置评论),以确保我们的情感分析器功能强大。 数据集是平衡的,因此包含 1,500 个正面评论和 1,500 个负面评论。 我们将从导入数据集并对其进行检查开始:

代码语言:javascript复制
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)

这将返回以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZDrA6dgj-1681785833972)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_05_11.jpg)]

图 5.11 –数据集的输出

我们从文件中读取数据集。 我们的数据集是制表符分隔的,因此我们将其与制表符和新行字符分开。 我们重命名我们的列,然后使用示例函数随机地随机整理数据。 查看我们的数据集,我们需要做的第一件事是对句子进行预处理,以将其输入到我们的 LSTM 模型中。

预处理数据

首先,我们创建一个函数标记数据,将每个评论分为单个预处理词的列表。 我们遍历数据集,并为每条评论删除所有标点符号,将字母转换为小写字母,并删除任何尾随空格。 然后,我们使用 NLTK 标记生成器根据此预处理文本创建单个标记:

代码语言:javascript复制
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]

这将产生以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CJYxBJMn-1681785833972)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_05_12.jpg)]

图 5.12 – NTLK 分词的输出

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

为了充分准备将句子输入神经网络,我们必须将单词转换为数字。 为了做到这一点,我们创建了两个字典,这将使我们能够将数据从单词转换为索引以及从索引转换为单词。 为此,我们只需遍历语料库并为每个唯一单词分配一个索引:

代码语言:javascript复制
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

这给出以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hMBVGVdY-1681785833972)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_05_13.jpg)]

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

我们的神经网络将接受固定长度的输入; 但是,如果我们浏览我们的评论,我们会发现我们的评论都是不同长度的。 为了确保所有输入的长度相同,我们将填充我们的输入句子。 这实质上意味着我们将空标记添加到较短的句子中,以便所有句子的长度相同。 我们必须首先决定要实现的填充长度。 我们首先计算输入评论中句子的最大长度以及平均长度:

代码语言:javascript复制
print(np.max([len(x) for x in reviews]))
print(np.mean([len(x) for x in reviews]))

这给出了以下内容:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wvWqJEtb-1681785833972)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_05_14.png)]

图 5.14 –长度值

我们可以看到最长的句子是70个字长,平均句子长度是11.78。 为了捕获所有句子中的所有信息,我们希望填充所有句子,使它们的长度为 70。但是,使用更长的句子意味着更长的序列,这会使我们的 LSTM 层变得更深。 这意味着模型训练需要更长的时间,因为我们必须通过更多层反向传播梯度,但是这也意味着我们输入的很大一部分只是稀疏并且充满了空标记,这使得从数据中学习的效率大大降低 。 我们的最大句子长度远大于我们的平均句子长度,这说明了这一点。 为了捕获我们大部分的句子信息而不会不必要地填充我们的输入并使它们太稀疏,我们选择使用50的输入大小。 您可能希望尝试在2070之间使用不同的输入大小,以了解这如何影响模型表现。

我们将创建一个函数,使我们能够填充句子,使它们的大小相同。 对于短于序列长度的评论,我们用空标记填充它们。 对于长度超过序列长度的评论,我们只需丢弃超过最大序列长度的所有标记:

代码语言:javascript复制
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]

我们的填充语句如下所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zppiSnx0-1681785833973)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_05_15.jpg)]

图 5.15 –填充句子

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

代码语言:javascript复制
int_to_word_dict[0] = ''
word_to_int_dict[''] = 0

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

代码语言:javascript复制
encoded_sentences = np.array([[word_to_int_dict[word] for word in review] for review in padded_sentences])
encoded_sentences[0]

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zS9JzuHt-1681785833973)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_05_16.jpg)]

图 5.16 –编码句子

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

模型架构

我们的模型将由几个主要部分组成。 除了许多神经网络通用的输入和输出层之外,我们首先需要一个嵌入层。 这样我们的模型就可以学习正在训练的单词的向量表示形式。 我们可以选择使用预先计算的嵌入(例如 GLoVe),但是出于演示目的,我们将训练自己的嵌入层。 我们的输入序列通过输入层进行馈送,并作为向量序列出现。

将这些向量序列送入我们的 LSTM 层。 如本章前面所详细解释的那样,LSTM 层从我们的嵌入序列中顺序学习,并输出代表 LSTM 层最终隐藏状态的单个向量输出。 在最终输出节点预测值介于 0 到 1 之间之前,该最终隐藏状态最终通过另一个隐藏层传递,指示输入序列是肯定还是否定。 这意味着我们的模型架构看起来像这样:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-P19rkuZs-1681785833973)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_05_17.jpg)]

图 5.17 –模型架构

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

代码语言:javascript复制
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 进行批量数据训练,并添加一个参数以允许我们通过丢弃实现网络正则化。 我们用概率drop_p(将在模型创建时指定的超参数)定义另外一个丢弃层,以及对最终全连接层和输出/预测节点的定义(具有 Sigmoid 激活函数) :

代码语言:javascript复制
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的预测— 也就是说,我们的句子为正的概率为:

代码语言:javascript复制
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 的 GPU 上进行训练:

代码语言:javascript复制
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类的新实例来初始化模型。 我们传递 vocab 的大小,嵌入的大小,隐藏状态的大小,输出大小以及 LSTM 中的层数:

代码语言:javascript复制
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)定义为encode_sentences,但是我们还必须定义模型输出(y)。 我们这样做很简单,如下所示:

代码语言:javascript复制
labels = np.array([int(x) for x in data['Sentiment'].values])

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

代码语言:javascript复制
train_ratio = 0.8
valid_ratio = (1 - train_ratio)/2

然后,我们使用这些比率对数据进行切片并将其转换为张量,然后转换为张量数据集:

代码语言:javascript复制
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对象,以便数据以随机顺序(而不是每个周期相同)通过神经网络传递,从而有可能从训练顺序中消除任何有偏差的结果:

代码语言:javascript复制
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对象,接下来我们定义训练循环。 我们首先定义许多超参数,这些超参数将在我们的训练循环中使用。 最重要的是,我们将损失函数定义为二进制交叉熵(因为我们正在处理单个二进制类别的预测),并且将优化器定义为 Adam,学习率为0.001。 我们还定义了模型以运行较短的时间(以节省时间),并设置clip = 5来定义梯度裁剪:

代码语言:javascript复制
print_every = 2400
step = 0
n_epochs = 3
clip = 5  
criterion = nn.BCELoss()
optimizer = optim.Adam(net.parameters(), lr = 0.001)

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

代码语言:javascript复制
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(),使用在反向传播中计算出的梯度来更新权重。

如果我们自己运行此循环,我们将训练我们的模型。 但是,我们想在每个周期之后评估模型的表现,以便根据验证数据集确定模型的表现。 我们这样做如下:

代码语言:javascript复制
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()冻结模型的权重,并像以前一样使用我们的数据进行正向传播。 请注意,当我们处于评估模式时,也不会应用丢弃。 但是,这次,我们使用验证加载程序,而不是使用训练数据加载程序。 通过这样做,我们可以在我们的验证数据集中计算模型当前状态的总损失。 最后,我们打印结果并调用net.train()解冻模型的权重,以便我们可以在下一个周期再次进行训练。 我们的输出看起来像,如下所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uJO8jgQq-1681785833973)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_05_18.jpg)]

图 5.18 –训练模型

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

代码语言:javascript复制
torch.save(net.state_dict(), 'model.pkl')

在为三个周期训练了我们的模型之后,我们注意到了两个主要方面。 我们将首先从好消息开始-我们的模型正在学习一些东西! 我们的训练损失不仅下降了,而且在每个周期之后,我们在验证集上的损失也下降了。 这意味着我们的模型仅在三个周期后就可以更好地预测看不见的数据的情感! 坏消息是,我们的模型过拟合。 我们的训练损失比验证损失要低得多,这表明虽然我们的模型已经学会了如何很好地预测训练数据集,但这并不能推广到看不见的数据集。 预期会发生这种情况,因为我们使用的训练数据非常少(仅 2,400 个训练语句)。 当我们训练整个嵌入层时,很可能许多单词在训练集中只出现一次,而在验证集中却没有出现,反之亦然,这使得该模型几乎不可能归纳出我们的语料库中所有不同的单词。 在实践中,我们希望在更大的数据集上训练我们的模型,以使我们的模型学习如何更好地归纳。 我们还在很短的时间内训练了该模型,并且没有执行超参数调整来确定模型的最佳迭代。 随意尝试更改模型中的某些参数(例如训练时间,隐藏状态大小,嵌入大小等),以提高模型的表现。

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

代码语言:javascript复制
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)))

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-808th4wj-1681785833974)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_05_19.jpg)]

图 5.19 –输出值

然后,我们将模型预测与真实标签进行比较,以获得correct_tensor,这是一个评估我们模型的每个预测是否正确的向量。 然后,我们对该向量求和,然后将其除以其长度,以获得模型的总精度。 在这里,我们获得了 76% 的准确率。 尽管我们的模型肯定还远非完美,但鉴于我们的训练量很小且训练时间有限,这一点也不差! 仅用于说明从 NLP 数据学习时 LSTM 的有用性。 接下来,我们将展示如何使用模型从新数据进行预测。

将我们的模型用于预测

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

代码语言:javascript复制
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()函数。 我们预处理输入检查,将其转换为张量,然后将其传递给数据加载器。 然后,我们遍历该数据加载器(即使它仅包含一个句子),并通过我们的网络进行审查以获得预测。 最后,我们评估我们的预测并打印出正面还是负面的评价:

代码语言:javascript复制
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()来做出预测:

代码语言:javascript复制
predict("The film was good")

这将产生以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Df4zN6NE-1681785833974)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_05_20.jpg)]

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

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

代码语言:javascript复制
predict("It was not good")

结果为以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-r3BhgGCa-1681785833979)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_05_21.jpg)]

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

现在,我们已经建立了一个 LSTM 模型,可以从头开始进行情感分析。 尽管我们的模型远非完美,但我们已演示了如何获取带有情感标签的评论并训练模型以能够对新评论做出预测。 接下来,我们将展示如何在 Heroku 云平台上托管我们的模型,以便其他人可以使用您的模型进行预测

在 Heroku 上部署应用

现在,我们已经在本地计算机上训练了模型,我们可以使用它来进行预测。 但是,如果您希望其他人能够使用您的模型进行预测,则不一定有好处。 如果我们将模型托管在基于云的平台(例如 Heroku)上并创建基本的 API,其他人将能够调用该 API 以使用我们的模型进行预测。

Heroku 介绍

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

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

代码语言:javascript复制
heroku login

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

代码语言:javascript复制
heroku create sentiment-analysis-flask-api

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

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

使用 Flask 创建 API-文件结构

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

首先,在命令行中,为 flask API 创建新文件夹并导航至该文件夹:

代码语言:javascript复制
mkdir flaskAPI
cd flaskAPI

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

代码语言:javascript复制
python3 -m venv vir_env

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

代码语言:javascript复制
pip install nltk pandas numpy torch flask gunicorn

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

代码语言:javascript复制
pip freeze > requirements.txt

我们需要进行的一项调整是,用以下内容替换requirements.txt文件中的torch行:

代码语言:javascript复制
https://download.pytorch.org/whl/cpu/torch-1.3.1+cpu-cp37-cp37m-linux_x86_64.whl

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

代码语言:javascript复制
touch app.py
touch Procfile
touch wsgi.py
mkdir models

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

使用 Flask 创建 API-API 文件

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

我们首先进行所有的导入,并创建一个predict路由。这样我们就可以用predict参数来调用我们的 API,以便在 API 中运行predict()方法。

代码语言:javascript复制
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'])

接下来,我们在app.py文件中定义·predict()方法。这在很大程度上是我们模型文件的翻版,所以为了避免代码重复,建议你查看本章“技术需求”部分链接的 GitHub 仓库中完成的app.py文件。你会发现有几行额外的内容。首先,在preprocess_review()函数中,我们会看到以下几行。

代码语言:javascript复制
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文件,并将其放置在model目录中。

同样,我们也必须从我们训练的模型中加载权重。我们首先定义我们的SentimentLSTM类,然后使用torch.load加载我们的权重。我们将使用我们原始笔记本中的.pkl文件,所以一定要把它也放在models目录下。

代码语言:javascript复制
model = SentimentLSTM(5401, 50, 100, 1, 2)
model.load_state_dict(torch.load("models/model_nlp.pkl"))

我们还必须定义我们 API 的输入和输出。我们希望我们的模型能够从 API 中获取输入,并将其传递给我们的precess_review()函数。我们使用request.get_json()来完成。

代码语言:javascript复制
request_json = request.get_json()
i = request_json['input']
words = np.array([preprocess_review(review=i)])

为了定义我们的输出,我们返回一个 JSON 响应,由我们模型的输出和响应代码200组成,这就是我们预测函数返回的内容。

代码语言:javascript复制
output = model(x)[0].item()
response = json.dumps({'response': output})
return response, 200

随着我们的应用主体的完成,我们还必须添加两件额外的事情来使我们的 API 运行。首先,我们必须在wsgi.py文件中添加以下内容。

代码语言:javascript复制
from app import app as application
if __name__ == "__main__":
    application.run()

最后,在我们的 Procfile 中添加以下内容。

代码语言:javascript复制
web: gunicorn app:app --preload

这就是应用运行所需要的全部。 我们可以先使用以下命令在本地启动 API,以测试 API 是否运行:

代码语言:javascript复制
gunicorn --bind 0.0.0.0:8080 wsgi:application -w 1

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

代码语言:javascript复制
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 上托管

我们首先需要将文件提交到 Heroku,其方式类似于使用 GitHub 提交文件的方式。 我们只需运行以下命令,即可将工作中的flaskAPI目录定义为git文件夹:

代码语言:javascript复制
git init

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

代码语言:javascript复制
vir_env
__pycache__/
.DS_Store

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

代码语言:javascript复制
git add . -A
git commit -m 'commit message here'
git push heroku master

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

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

代码语言:javascript复制
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 任务的深度学习,但它们绝不是唯一适合此任务的神经网络架构。

在下一章中,我们将研究卷积神经网络,并展示如何将其用于 NLP 学习任务。

六、用于文本分类的卷积神经网络

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

RNN 依赖顺序建模,保持隐藏状态,然后逐个单词顺序地遍历文本,并在每次迭代时更新状态。 CNN 不依赖于语言的顺序元素,而是尝试通过分别感知句子中的每个单词并了解其与句子中周围单词的关系来从文本中学习。

尽管出于此处提到的原因,CNN 更常用于对图像进行分类,但事实表明,它们也可以有效地对文本进行分类。 尽管我们确实将文本视为一个序列,但我们也知道句子中各个单词的含义取决于它们的上下文和它们旁边出现的单词。 尽管 CNN 和 RNN 以不同的方式从文本中学习,但是它们都显示出对文本分类有效的方法,并且在任何给定情况下使用哪种依赖于任务的性质。

在本章中,我们将探讨 CNN 背后的基本理论,并从头开始构建一个 CNN,该 CNN 将用于对文本进行分类。 我们将涵盖以下主题:

  • 探索 CNN
  • 构建用于文本分类的 CNN

让我们开始吧!

技术要求

本章的所有代码都可以在这个页面中找到。

探索 CNN

CNN 的基础来自计算机视觉领域,但可以从概念上扩展到 NLP。 人脑处理和理解图像的方式不是以像素为单位,而是作为图像的整体图以及图像的每个部分与其他部分的关系。

CNN 的一个很好的类比是人的思维如何处理图片而不是句子。 考虑句子This is a sentence about a cat.。 当您阅读该句子时,您将阅读第一个单词,然后阅读第二个单词,依此类推。 现在,考虑一张猫的照片。 通过先看第一个像素,再看第二个像素,吸收图片中的信息是愚蠢的。 取而代之的是,当我们看着某物时,我们会立即感知到整个图像,而不是一个序列。

例如,如果我们对图像进行黑白表示(在本例中为数字 1),则可以看到可以将其转换为向量表示,其中每个像素的颜色由 0 或 1 表示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Aru5VS5N-1681785833979)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_06_01.jpg)]

图 6.1 –图像的向量表示

但是,如果我们从机器学习的角度考虑这一问题并将此向量视为模型的特征,那么单个像素为黑色或白色的事实是否会使图像具有给定位数的可能性更大或更小? 右上角的白色像素会使图片更可能是四或七吗? 想象一下,如果我们试图检测更复杂的事物,例如图片是狗还是猫。 屏幕中间的棕色像素会使照片更有可能是猫还是狗? 直观地,我们看到,在图像分类方面,单个像素值没有多大意义。 但是,我们感兴趣的是像素彼此之间的关系。

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

卷积

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iHsGY4o5-1681785833979)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_06_02.jpg)]

图 6.2 –实际的卷积

左侧是正在处理的图像,而顶部则是希望应用的卷积核。 对于图像中的每个3x3块,我们将其乘以核,以在底部处获得卷积矩阵。 然后,我们对卷积矩阵求和(或取平均值),以获得初始图像中该3x3块的单个输出值。 请注意,在我们的5x5初始图像中,我们可以覆盖 9 个可能的3x3块。 当我们对初始图像中的每个3x3块应用此卷积过程时,剩下的最终处理卷积为3x3

在大图像中(对于 NLP,则为复杂的句子),我们还需要实现池化层。 在我们前面的示例中,将3x3卷积应用于5x5图像会产生3x3输出。 但是,如果将3x3卷积应用于100x100像素的图像,则只会将输出降低到98x98。 这还不足以降低图像的尺寸,不足以有效地执行深度学习(因为我们必须在每个卷积层学习98x98参数)。 因此,我们应用池化层以进一步减小该层的尺寸。

池化层将函数(通常为max函数)应用于卷积层的输出,以减小其维数。 此函数应用于滑动窗口,类似于执行卷积的,但现在卷积不再重叠。 假设卷积层的输出为4x4,并且对输出应用2x2的最大值函数。 这意味着对于我们层中每个较小的2x2网格,我们将应用最大函数并保留结果输出。 我们可以在下图中看到这一点:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tF1Y7p0y-1681785833979)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_06_03.jpg)]

图 6.3 –池化层

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

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

在这种情况下,使用卷积有两个主要的优势。 首先,我们能够将一系列低级特征组合成高级特征; 也就是说,我们初始图像上的3x3色块是组成的单个值。 这实际上是减少特征的一种形式,仅允许我们从图像中提取相关信息。 使用卷积的另一个优点是它使我们的模型位置不变。 在我们的数字检测器示例中,我们不在乎数字是否出现在图像的右侧或左侧; 我们只是希望能够检测到它。 由于我们的卷积会检测图像中的特定图案(即边缘),因此我们的模型位置不变,因为从理论上讲,无论这些卷积出现在图像中的哪个位置,相同的特征都将被拾取。

尽管这些原理对于理解卷积如何在图像数据中起作用很有用,但它们也可以应用于 NLP 数据。 我们将在下一部分中对此进行讨论。

用于 NLP 的卷积

正如我们在本书中多次看到的那样,我们可以将单个单词在数字上表示为向量,将整个句子和文档表示为向量序列。 当我们将句子表示为向量序列时,我们可以将其表示为矩阵。 如果我们具有给定句子的矩阵表示形式,我们会立即注意到,这类似于我们在图像卷积中卷积的图像。 因此,只要我们可以将文本表示为矩阵,就可以以类似于图像的方式将卷积应用于 NLP。

让我们首先考虑使用这种方法的基础。 以前查看 N 元组时,我们发现句子中一个单词的上下文取决于其前面的单词和后面的单词。 因此,如果我们能够以允许我们捕获一个单词与其周围单词的关系的方式对一个句子进行卷积,那么我们就可以从理论上检测语言中的模式并将其用于更好地对句子进行分类。

还值得注意的是,我们的卷积方法与图像卷积略有不同。 在我们的图像矩阵中,我们希望捕获相对于周围像素的单个像素的上下文,而在句子中,我们希望捕获相对于其周围其他向量的整个单词向量的上下文。 因此,在 NLP 中,我们希望在整个单词向量上而不是在单词向量内执行卷积。 下图中演示了。

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Xd0pwTsw-1681785833980)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_06_04.jpg)]

图 6.4 –词向量

然后,我们在矩阵上应用(2 x n)卷积(其中n是我们字向量的长度;在这种情况下,n = 5) 。 我们可以使用(2 x n)过滤器对四个不同时间进行卷积,该过滤器可减少为四个输出。 您会注意到,这类似于二元语法模型,在一个五字句子中有四个可能的二元语法:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dqsvsYa6-1681785833980)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_06_05.jpg)]

图 6.5 –将词向量卷积为二元语法

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-e53jakZf-1681785833980)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_06_06.jpg)]

图 6.6 –将单词向量卷积为 n 元语法

像这样的卷积模型的好处之一是,我们可以卷积的 N 元组的数量没有限制。 我们还能够同时对多个不同的 N 元组进行卷积。 因此,要捕获二元语法和三元语法,我们可以像这样设置模型:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-B2mYS6xV-1681785833980)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_06_07.jpg)]

图 6.7 –将词向量卷积为二元语法和三元语法

尽管用于 NLP 的 CNN 具有诸如前面部分所述的优点,但它们确实有其缺点。

在图像的 CNN 中,一个给定像素可能与其周围像素有关的假设是一个合理的假设。 当应用于 NLP 时,尽管此假设部分正确,但单词可以在语义上相关,即使它们彼此之间并不紧邻。 句子开头的单词可能与句子结尾的单词相关。

虽然我们的 RNN 模型可能能够通过长期记忆依赖性来检测这种关系,但我们的 CNN 可能会遇到困难,因为 CNN 仅捕获目标单词周围单词的上下文。

话虽如此,尽管我们的语言假设不一定成立,但事实证明,用于 NLP 的 CNN 在某些任务中表现良好。 可以说,将 CNN 用于 NLP 的主要优点是速度和效率。 卷积可以在 GPU 上轻松实现,从而实现快速并行的计算和训练。

捕获单词之间的关系的方式也更加有效。 在真正的 N 元组模型中,该模型必须学习每个单个 N 元组的个体表示,而在我们的 CNN 模型中,我们仅学习卷积核,该卷积核将自动提取给定单词向量之间的关系。

既然我们已经定义了 CNN 如何从数据中学习,我们就可以开始从头开始编码模型。

构建用于文本分类的 CNN

既然我们了解了 CNN 的基础知识,我们就可以从头开始构建。 在上一章中,我们建立了情感预测模型,其中情感是二分类器;1表示正,0表示负。 但是,在此示例中,我们的目标是为多类文本分类构建 CNN。 在多类别问题中,特定示例只能分类为几种类别之一。 如果一个示例可以分类为许多不同的类别,则这就是多标签分类。 由于我们的模型是多类的,因此这意味着我们的模型将旨在预测我们的输入句子被归为几类中的哪一类。 尽管此问题比二分类任务要困难得多(因为我们的句子现在可以属于许多类别之一,而不是两个类别之一),但我们将证明 CNN 可以在此任务上提供良好的表现。 首先,我们将定义数据。

定义多类分类数据集

在上一章中,我们查看了评论的选择,并根据评论是肯定的还是负面的,学习了二分类。 对于此任务,我们将查看来自 TREC 数据集的数据,这是用于评估文本分类任务的模型表现的通用数据集。 数据集由一系列问题组成,每个问题都属于我们训练过的模型将要学习分类的六大语义类别之一。 这六个类别如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SCbvXEUH-1681785833981)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_06_08.jpg)]

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1FKPGwVz-1681785833981)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_06_09.jpg)]

图 6.9 –预测值

通过这种方式,我们的模型现在将能够在多个类上执行分类任务,并且我们不再局限于我们之前看过的 0 或 1 二分类。 具有更多类的模型可能会因预测而受到影响,因为有更多不同的类可以区分。

在二元分类模型中,假设我们有一个平衡的数据集,我们希望我们的模型仅执行随机猜测就可以达到 50% 的准确率,而具有五个不同类别的多类模型只能具有基线精度 20%。 这意味着仅仅因为多类模型的准确率远低于 100%,并不意味着模型本身固有地在做出预测方面就很糟糕。 当涉及从数百个不同类别进行预测的训练模型时,尤其如此。 在这些情况下,仅具有 50% 准确率的模型将被认为表现良好。

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

创建迭代器来加载数据

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

在我们的 CNN 模型中,我们将改为根据数据创建数据迭代器。 这些迭代器对象使我们能够轻松地从输入数据中生成小批数据,从而使我们能够使用小批数据来训练模型,而不是将输入数据一一输入到网络中。 这意味着我们网络中的梯度是针对整批数据计算的,并且参数调整是在每批数据之后进行的,而不是在每行数据通过网络传递之后进行的。

对于我们的数据,我们将从torchtext包中获取数据集。 这样的优势不仅在于包含许多用于模型训练的数据集,而且还使我们能够使用内置函数轻松地对句子进行分词和向量化。

按着这些次序:

我们首先从torchtext导入数据和数据集函数。

代码语言:javascript复制
from torchtext import data
from torchtext import datasets

接下来,我们创建一个字段和标签字段,我们可以使用TorchText包。这些定义了我们的模型将用于处理我们的数据的初始处理步骤。

代码语言:javascript复制
questions = data.Field(tokenize = 'spacy', batch_first = True)
labels = data.LabelField(dtype = torch.float)

在这里,我们将分词设置为等于spacy,以设置输入句子的分词方式。 TorchText然后使用spacy包自动标记输入的句子。 spacy由英语索引组成,因此任何单词都会自动转换为相关标记。 为了使它有效,您可能需要安装spacy。 可以在命令行中通过键入以下命令来完成:

代码语言:javascript复制
pip3 install spacy
python3 -m spacy download en

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

我们还将标签的数据类型定义为浮动,这将允许我们计算损失和梯度。在定义了我们的字段之后,我们可以使用这些字段来分割我们的输入数据。使用TorchTextTREC 数据集,我们将我们的问题和标签字段传递给这个数据集,以便对数据集进行相应的处理。然后,我们调用split函数,以便将我们的数据集自动划分为一个训练集和一个验证集。

代码语言:javascript复制
train_data, _ = datasets.TREC.splits(questions, labels)
train_data, valid_data = train_data.split()

请注意,通常,我们可以通过简单地调用训练数据来使用 Python 查看数据集:

代码语言:javascript复制
train_data

但是,在这里,我们正在处理TorchText数据集对象,而不是像我们以前所看到的那样,将数据集加载到 Pandas 中。 这意味着前面代码的输出如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-W2u1WtMz-1681785833981)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_06_10.png)]

图 6.10 –torchtext对象的输出

我们可以查看此数据集对象内的单个数据; 我们只需要调用.examples参数即可。 这些示例每个都有一个文本和一个label参数,我们可以像检查文本一样检查它们:

代码语言:javascript复制
train_data.examples[0].text

这将返回以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mJtK0Lt1-1681785833981)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_06_11.jpg)]

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

标签代码如下运行:

代码语言:javascript复制
train_data.examples[0].label

这给我们以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yn6mBXUx-1681785833981)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_06_012.png)]

图 6.12 –数据集对象的标签

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

代码语言:javascript复制
print(len(train_data))
print(len(valid_data))

结果为以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5VKstH0w-1681785833982)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_06_013.png)]

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

这表明我们的训练验证率约为 70% 至 30%。 值得注意的是,我们的输入句子是如何被分词的,即标点符号被当作它们自己的标记。

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

代码语言:javascript复制
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 向量组成:

代码语言:javascript复制
questions.vocab.vectors

结果为以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ofnS8Ca7-1681785833982)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_06_014.jpg)]

图 6.14 –张量内容

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

代码语言:javascript复制
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 模型

现在我们已经加载了数据,现在可以创建模型了。 我们将使用以下步骤进行操作:

我们希望建立我们 CNN 的结构。我们像往常一样,首先将我们的模型定义为一个继承自nn.Module的类。

代码语言:javascript复制
class CNN(nn.Module):
    def __init__(
        self, vocab_size, embedding_dim, n_filters, 
        filter_sizes, output_dim, dropout, pad_idx
    ):
        
        super().__init__()

我们的模型是用几个输入来初始化的,所有的输入都会在后面介绍。接下来,我们分别定义网络中的层,从嵌入层开始。

代码语言:javascript复制
self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx = pad_idx)

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

接下来,我们定义我们网络中的实际卷积层。

代码语言:javascript复制
self.convs = nn.ModuleList([
    nn.Conv2d(
        in_channels = 1,
        out_channels = n_filters,
        kernel_size = (fs, embedding_dim)
    ) for fs in filter_sizes
])

我们首先使用nn.ModuleList来定义一系列卷积层。ModuleList接受一个模块列表作为输入,当你希望定义一些单独的层时,就可以使用它。由于我们希望在输入数据上训练多个不同大小的卷积层,我们可以使用ModuleList来实现。理论上我们可以像这样分别定义每个层。

代码语言:javascript复制
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函数的长度传递给我们希望训练的过滤器长度以及每个过滤器的数量,它将自动生成卷积层。 该卷积层如何查找给定变量集的示例如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8SpnvY9r-1681785833982)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_06_015.jpg)]

图 6.15 –卷积层寻找变量

我们可以看到我们的ModuleList函数适应了我们想要训练的过滤器的数量和大小。 接下来,在 CNN 初始化中,我们定义其余的层,即将对数据进行分类的线性层和将对网络进行正则化的丢弃层:

代码语言:javascript复制
self.fc = nn.Linear(len(filter_sizes) * n_filters, output_dim)
self.dropout = nn.Dropout(dropout)

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

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

代码语言:javascript复制
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 激活函数并压缩结果,删除结果输出的第四维。 对所有定义的卷积层重复此操作,以便使transforms包含在所有卷积层的输出列表中。

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

代码语言:javascript复制
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% 的丢弃率,以确保其充分正规化。 如果模型似乎容易过拟合或过拟合,则可以升高/降低此值。 一般的经验法则是,如果模型欠拟合,则尝试降低丢弃率;如果模型过拟合,则尝试提高丢弃率。

初始化模型后,我们需要将权重加载到嵌入层中。 可以很容易地完成以下操作:

代码语言:javascript复制
glove_embeddings = questions.vocab.vectors
model.embedding.weight.data.copy_(glove_embeddings)

结果为以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qetFddxn-1681785833982)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_06_016.jpg)]

图 6.16 –降低压差后的张量输出

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

代码语言:javascript复制
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(如果有)上完成:

代码语言:javascript复制
optimizer = torch.optim.Adam(model.parameters())
criterion = nn.CrossEntropyLoss().to(device)
model = model.to(device)

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

训练 CNN

在定义训练过程之前,我们需要计算表现指标以说明模型的表现(希望!)如何随时间增加。 在我们的二分类任务中,准确率是我们用来衡量表现的简单指标。 对于我们的多分类任务,我们将再次使用准确率,但是计算准确率的过程稍微复杂些,因为我们现在必须确定模型预测的六个类别中的哪个类别以及六个类别中的哪个类别是正确的类别。

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

代码语言:javascript复制
def multi_accuracy(preds, y):
    pred = torch.max(preds,1).indices
    correct = (pred == y).float()
    acc = correct.sum() / len(correct)
    return acc

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

接下来,我们定义训练函数。 最初,我们将时间段的损失和准确率设置为0,我们将其称为model.train()以允许我们在训练模型时更新模型中的参数:

代码语言:javascript复制
def train(model, iterator, optimizer, criterion):
    
    epoch_loss = 0
    epoch_acc = 0
    
    model.train()

接下来,我们遍历迭代器中的每批数据并执行训练步骤。 我们首先将梯度归零,以防止从上一批中计算出累积梯度。 然后,我们使用模型的当前状态根据当前批量中的句子进行预测,然后将其与标签进行比较以计算损失。 使用我们在上一节中定义的精度函数,我们可以计算给定批量的精度。 然后,我们反向传播损失,通过梯度下降更新权重并逐步通过优化器:

对于迭代器中的批量:

代码语言:javascript复制
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()

最后,我们将这一批量的损失和准确率加到整个周期的总损失和准确率中。 在循环遍历该周期内的所有批量之后,我们计算该周期的总损失和准确率并返回:

代码语言:javascript复制
    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的函数,该函数将在验证数据上调用,以根据尚未训练模型的一组数据来计算训练后的模型表现。 尽管此函数与我们之前定义的训练函数几乎相同,但是我们必须做两个关键的补充:

代码语言:javascript复制
model.eval()
    
with torch.no_grad():

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

现在,我们只需要与数据迭代器一起循环调用训练和评估函数,即可训练模型。 我们首先定义希望模型训练的周期数。 我们还定义了我们的模型迄今为止所实现的最低验证损失。 这是因为我们只希望使训练后的模型具有最低的验证损失(即表现最佳的模型)。 这意味着,如果我们的模型训练了多个周期并开始过拟合,那么只有这些模型的最佳表现将被保留,这意味着选择大量周期的后果会更少。

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

代码语言:javascript复制
epochs = 10
lowest_validation_loss = float('inf')

接下来,我们定义训练循环,一次将跨一个周期。 我们记录训练的开始和结束时间,以便我们可以计算出每个步骤花费的时间。 然后,我们只需使用训练数据迭代器对模型调用训练函数来计算训练损失和准确率,并在此过程中更新模型。 然后,我们使用验证迭代器上的评估函数重复此过程,以计算验证数据的损失和准确率,而无需更新模型:

代码语言:javascript复制
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()

此后,我们确定在当前周期之后,我们的模型是否优于目前表现最好的模型:

如果有效损失

代码语言:javascript复制
if valid_loss < lowest_validation_loss:
    lowest_validation_loss = valid_loss
    torch.save(model.state_dict(), 'cnn_model.pt')

如果此周期之后的损失低于到目前为止的最低验证损失,则将验证损失设置为新的最低验证损失,并保存我们当前的模型权重。

最后,我们仅在每个周期之后打印结果。 如果一切工作正常,我们应该看到我们的训练损失在每个周期之后都有所下降,而我们的验证损失有望照此执行:

代码语言:javascript复制
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}%')

结果为以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AEfdYZ6k-1681785833983)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_06_017.jpg)]

图 6.17 –测试模型

值得庆幸的是,我们发现确实存在。 训练和验证损失在每个周期都会下降,准确率也会提高,这表明我们的模型确实在学习! 经过多次训练后,我们可以采用最佳模型并进行预测。

使用经过训练的 CNN 的预测

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

代码语言:javascript复制
model.load_state_dict(torch.load('cnn_model.pt'))

我们的模型结构已经定义,因此我们只需从先前保存的文件中加载权重即可。 如果此操作正常,您将看到以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UNITptGV-1681785833983)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_06_018.jpg)]

图 6.18 –预测输出

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

代码语言:javascript复制
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(如果可用),然后取消压缩输出,因为我们的模型需要三维张量输入而不是单个向量。

接下来,我们做出预测:

代码语言:javascript复制
model.eval()
prediction = torch.max(model(tensor),1).indices.item()
pred_index = labels.vocab.itos[prediction]
    return pred_index

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

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

代码语言:javascript复制
pred_class = predict_class(model, "How many roads must a man                            walk down?")
print('Predicted class is: '   str(pred_class))

这将返回以下预测:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bAWEY6VE-1681785833983)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_06_019.jpg)]

图 6.19 –预测值

这个预测是正确的! 我们的输入问题包含how many,表明该问题的答案是一个数值。 这正是我们的模型所预测的! 您可以继续在其他可能要测试的问题上验证模型,希望能获得同样积极的结果。 祝贺您-您现在已经成功地训练了可以定义任何给定问题类别的多类 CNN。

总结

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

在下一章中,我们将学习如何构建利用卷积神经网络和循环神经网络元素的模型,并在序列上使用它们来执行更高级的操作,例如文本翻译。 这些被称为序列到序列网络。

七、使用序列到序列神经网络的文本翻译

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

我们已经探索了用于 NLP 学习的几种类型的神经网络架构,即 “第 5 章”,“循环神经网络和情感分析”中的循环神经网络,以及“第 6 章”,“使用 CNN 的文本分类”中的卷积神经网络。 在本章中,我们将再次使用这些熟悉的 RNN,而不仅仅是构建简单的 RNN 模型,我们还将 RNN 用作更大,更复杂的模型的一部分,以执行序列到序列的翻译。 通过使用我们在前几章中了解到的 RNN 的基础,我们可以展示如何扩展这些概念,以创建适合目的的各种模型。

在本章中,我们将介绍以下主题:

  • 序列到序列模型理论
  • 构建用于文本翻译的序列到序列神经网络
  • 下一步

技术要求

本章的所有代码都可以在这个页面中找到。

序列到序列模型理论

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EStdUVPt-1681785833983)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_07_01.jpg)]

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

输入句子中的第一个单词与输出句子中的第一个单词很好地映射。 如果所有语言都是这种情况,我们可以简单地通过训练过的模型将句子中的每个单词逐个传递以获得输出句子,并且不需要任何序列到序列建模,如下所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DQV7vcZI-1681785833983)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_07_02.jpg)]

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

但是,我们从的 NLP 经验中得知,语言并不像这样简单! 一种语言中的单个单词可能会映射到其他语言中的多个单词,并且这些单词在语法正确的句子中出现的顺序可能并不相同。 因此,我们需要一个可以捕获整个句子的上下文并输出正确翻译的模型,而不是旨在直接翻译单个单词的模型。 这是序列到序列建模必不可少的地方,如下所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zIzBAMgL-1681785833984)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_07_03.jpg)]

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

为了训练一个序列到序列模型,该模型捕获输入句子的上下文并将其转换为输出句子,我们将实质上训练两个较小的模型,使我们能够做到这一点:

  • 编码器模型,其中捕获句子的上下文并将其作为单个上下文向量输出
  • 解码器,它使用原始句子的上下文向量表示并将其翻译成另一种语言

因此,实际上,我们完整的序列到序列翻译模型实际上将如下所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MPQk2VoJ-1681785833984)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_07_04.jpg)]

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GFSAkjIo-1681785833984)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_07_05.jpg)]

图 7.5 –详细的模型布局

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

编码器

我们的序列到序列模型的编码器元素的目的是能够完全捕获我们输入句子的上下文并将其表示为向量。 我们可以通过使用 RNN 或更具体地说是 LSTM 来实现。 您可能从我们前面的章节中回忆过,RNN 接受顺序输入并在整个顺序中保持隐藏状态。 序列中的每个新单词都会更新隐藏状态。 然后,在序列的最后,我们可以使用模型的最终隐藏状态作为下一层的输入。

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-X5Z34vU2-1681785833985)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_07_06.jpg)]

图 7.6 –检查编码器

我们使用最终的隐藏状态h[n]作为上下文向量,然后使用训练过的解码器对其进行解码。 也值得观察,在我们的序列到序列模型的上下文中,我们分别在输入句子的开头和结尾添加了startend标记。 这是因为我们的输入和输出没有有限的长度,并且我们的模型需要能够学习句子何时结束。 我们的输入语句将始终以end标记结尾,该标记向编码器发出信号,表明此时的隐藏状态将用作此输入语句的最终上下文向量表示形式。 类似地,在解码器步骤中,我们将看到我们的解码器将继续生成单词,直到它预测到end标记为止。 这使我们的解码器可以生成实际的输出语句,而不是无限长的标记序列。

接下来,我们将研究解码器如何获取此上下文向量,并学习将其转换为输出语句。

解码器

我们的解码器从我们的编码器层获取最终隐藏状态,并将其解码为另一种语言的句子。 我们的解码器是 RNN,类似于我们的编码器,但是我们的编码器会根据当前的隐藏状态和句子中的当前单词来更新其隐藏状态,而解码器会在每次迭代时更新其隐藏状态并输出标记, 当前隐藏状态和句子中的先前预测单词。 在下图中可以看到:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1IYYevx0-1681785833985)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_07_07.jpg)]

图 7.7 –检查解码器

首先,我们的模型将上下文向量作为编码器步骤h0的最终隐藏状态。 然后,我们的模型旨在根据给定的当前隐藏状态预测句子中的下一个单词,然后预测句子中的前一个单词。 我们知道我们的句子必须以“开始”标记开头,因此,在第一步中,我们的模型会尝试根据给定的先前隐藏状态h0来预测句子中的第一个单词, 句子(在这种情况下,是“开始”标记)。 我们的模型进行预测("pienso"),然后更新隐藏状态以反映模型的新状态h1。 然后,在下一步中,我们的模型将使用新的隐藏状态和最后的预测单词来预测句子中的下一个单词。 这一直持续到模型预测出end标记为止,这时我们的模型停止生成输出字。

该模型背后的直觉与到目前为止我们所学的关于语言表示的知识一致。 给定句子中的单词取决于其前面的单词。 因此,要预测句子中的任何给定单词而不考虑之前已被预测的单词,这将是没有意义的,因为任何给定句子中的单词都不是彼此独立的。

我们像以前一样学习模型参数:通过向前传递,根据预测句子计算目标句子的损失,并通过网络反向传播此损失,并随即更新参数。 但是,使用此过程进行学习可能会非常缓慢,因为首先,我们的模型具有很小的预测能力。 由于我们对目标句子中单词的预测不是彼此独立的,因此,如果我们错误地预测目标句子中的第一个单词,则输出句子中的后续单词也不太可能是正确的。 为了帮助完成此过程,我们可以使用一种称为教师强制的技术。

使用教师强制

由于我们的模型最初并未做出良好的预测,因此我们会发现任何初始误差都会成倍增加。 如果我们在句子中的第一个预测单词不正确,那么句子的其余部分也可能不正确。 这是因为我们的模型所做的预测取决于之前所做的预测。 这意味着我们的模型所遭受的任何损失都可以成倍增加。 因此,我们可能会遇到梯度爆炸问题,这使得我们的模型很难学习任何东西:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2XfagHB0-1681785833985)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_07_08.jpg)]

图 7.8 –使用教师强制

但是,通过使用教师强制,我们使用正确的先前目标词来训练我们的模型,以便一个错误的预测不会抑制我们的模型从正确的预测中学习的能力。 这意味着,如果我们的模型在句子中的某一点做出了错误的预测,那么它仍然可以使用后续单词来做出正确的预测。 尽管我们的模型仍然会错误地预测单词,并且会损失损失以更新梯度,但是现在,我们没有遭受梯度爆炸的困扰,并且我们的模型将更快地学习:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jpklOYkv-1681785833985)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_07_09.jpg)]

图 7.9 –更新损失

您可以考虑使用教师强制作为一种帮助我们的模型在每个时间步上独立于其先前预测进行学习的方式。 这样一来,早期阶段错误预测所导致的损失就不会转移到后续阶段。

通过组合编码器和解码器步骤,并应用教师强制来帮助我们的模型学习,我们可以构建一个序列到序列模型,该模型将允许我们将一种语言的序列翻译成另一种语言。 在下一节中,我们将说明如何使用 PyTorch 从头开始构建它。

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

为了建立我们的序列到序列模型进行翻译,我们将实现前面概述的编码器/解码器框架。 这将显示如何将模型的两半一起使用,以便使用编码器捕获数据的表示形式,然后使用我们的解码器将该表示形式转换为另一种语言。 为此,我们需要获取数据。

准备数据

到现在为止,我们对机器学习有了足够的了解,知道对于这样的任务,我们将需要一组带有相应标签的训练数据。 在这种情况下,我们将需要一种语言的句子以及另一种语言的相应翻译。 幸运的是,我们在上一章中使用的Torchtext库包含一个数据集,可让我们获取此信息。

Torchtext中的 Multi30k 数据集由大约 30,000 个句子以及相应的多种语言翻译组成。 对于此翻译任务,我们的输入句子将使用英语,而我们的输出句子将使用德语。 因此,我们经过全面训练的模型将允许我们将英语句子翻译成德语

我们将从提取数据并对其进行预处理开始。 我们将再次使用spacy,其中包含内置词汇表,可用于标记数据:

我们首先将spacy分词器加载到 Python 中。我们需要为每一种语言做一次,因为我们将为这个任务构建两个完全独立的词汇表。

代码语言:javascript复制
spacy_german = spacy.load('de')
spacy_english = spacy.load('en')

重要的提示

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

接下来,我们为每种语言创建一个函数来标记我们的句子。请注意,我们为输入的英语句子创建的分词器将标记的顺序颠倒了。

代码语言:javascript复制
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 组成,则可以证明反转输入句子时模型中的信息流得到改善。 例如,让我们以英语作为基本输入句子,但不作反述,如下所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dm6UEzzx-1681785833985)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_07_10.jpg)]

图 7.10 –反转输入字

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gW1QOqRI-1681785833986)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_07_101.jpg)]

图 7.11 –反转输入语句

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

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

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

代码语言:javascript复制
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)

定义了我们的字段后,我们的分词就变成了简单的单行本。包含 30000 个句子的数据集内置了训练、验证和测试集,我们可以将其用于我们的模型。

代码语言:javascript复制
train_data, valid_data, test_data = Multi30k.splits(exts = ('.en', '.de'), fields = (SOURCE, TARGET))

我们可以使用数据集对象的examples属性检查单个句子。在这里,我们可以看到源(src)属性包含了我们的英语反向输入句,而我们的目标(trg)包含了我们的德语非反向输出句。

代码语言:javascript复制
print(train_data.examples[0].src)
print(train_data.examples[0].trg)

这给我们以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ymikq0fV-1681785833986)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_07_12.jpg)]

图 7.12 –训练数据示例

现在,我们可以检查我们每个数据集的大小。在这里,我们可以看到,我们的训练数据集由 29,000 个例子组成,而我们的每个验证和测试集分别由 1,014 个和 1,000 个例子组成。在过去,我们对训练和验证数据使用了 80%/20% 的分割。然而,在这样的情况下,当我们的输入和输出字段非常稀疏,而我们的训练集规模有限时,在可用的数据上进行训练往往是有益的。

代码语言:javascript复制
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)))

这将返回以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9QDGuuBX-1681785833986)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_07_13.jpg)]

图 7.13 –数据样本长度

现在,我们可以建立我们的词汇表并检查它们的大小。我们的词汇表应该包括在我们的数据集中找到的每一个独特的单词。我们可以看到,我们的德语词汇量比英语词汇量大得多。我们的词汇量明显小于每种语言的每个词汇的真实大小(英语词典中的每个单词)。因此,由于我们的模型只能准确地翻译它以前见过的单词,所以我们的模型不太可能很好地泛化到英语中所有可能的句子。这就是为什么要准确地训练这样的模型,需要极其庞大的 NLP 数据集(比如谷歌能够获得的数据集)。

代码语言:javascript复制
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)))

这给出以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-63JtRlI7-1681785833986)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_07_14.jpg)]

图 7.14 –数据集的词汇量

最后,我们可以从我们的数据集创建我们的数据迭代器。就像我们之前所做的那样,我们指定使用支持 CUDA 的 GPU(如果我们的系统中可用的话),并指定我们的批次大小。

代码语言:javascript复制
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
)

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

构建编码器

现在,我们准备开始构建我们的编码器:

首先,我们通过继承我们的nn.Module类来初始化我们的模型,就像我们之前所有的模型一样。我们用几个参数进行初始化,这些参数我们将在后面定义,以及我们 LSTM 层中隐藏层的维数和 LSTM 层的数量。

代码语言:javascript复制
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

接下来,我们在编码器内定义我们的嵌入层,即输入维数的长度和嵌入维数的深度。

代码语言:javascript复制
self.embedding = nn.Embedding(input_dims, emb_dims)

接下来,我们定义我们实际的 LSTM 层。这从嵌入层中获取我们的嵌入句子,保持一个定义长度的隐藏状态,并由若干层组成(我们稍后将定义为 2)。我们还实现了丢弃来对我们的网络进行正则化。

代码语言:javascript复制
self.rnn = nn.LSTM(emb_dims, hid_dims, n_layers, dropout                    = dropout)
self.dropout = nn.Dropout(dropout)

然后,我们在编码器内定义正向传播。我们将嵌入应用到我们的输入句子,并应用丢弃。然后,我们将这些嵌入通过我们的 LSTM 层,它输出我们的最终隐藏状态。这将被我们的解码器用来形成我们的翻译句子。

代码语言:javascript复制
def forward(self, src):
    embedded = self.dropout(self.embedding(src))
    outputs, (h, cell) = self.rnn(embedded)
    return h, cell

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TxD5u6lg-1681785833986)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_07_15.jpg)]

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

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

构建解码器

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

代码语言:javascript复制
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 层的输出隐藏层,并使用它来预测序列中的下一个单词:

代码语言:javascript复制
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,重复此过程,直到我们的模型生成结束标记。 我们的解码器看起来像这样:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nzoOCLVB-1681785833987)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_07_16.jpg)]

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

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

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

现在,我们必须将模型的两半拼接在一起,以产生完整的序列到序列模型:

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

代码语言:javascript复制
class Seq2Seq(nn.Module):
    def __init__(self, encoder, decoder, device):
        super().__init__()
        
        self.encoder = encoder
        self.decoder = decoder
        self.device = device

接下来,我们在Seq2Seq类中创建forward方法。这可以说是模型中最复杂的部分。我们将我们的编码器与解码器结合起来,并使用教师强制来帮助我们的模型学习。我们首先创建一个张量,我们仍然在其中存储我们的预测。我们将其初始化为一个充满零的张量,但我们仍然会在做出预测时用我们的预测更新它。我们的零点张量的形状将是我们的目标句子的长度,我们的批次大小的宽度,以及我们的目标(德语)词汇大小的深度。

代码语言:javascript复制
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)

接下来,我们将输入的句子输入到编码器中,得到输出的隐藏状态。

代码语言:javascript复制
h, cell = self.encoder(src)

然后,我们必须在解码器模型中循环,为输出序列中的每一步生成一个输出预测。我们输出序列的第一个元素将始终是<start>标记。我们的目标序列已经包含这个作为第一个元素,所以我们只需通过取列表中的第一个元素来设置我们的初始输入等于此。

代码语言:javascript复制
input = trg[0,:]

接下来,我们遍历并做出预测。 我们将隐藏状态(从编码器的输出)传递到解码器,以及初始输入(即<start>标记)。 这将返回我们序列中所有单词的预测。 但是,我们只对当前步骤中的单词感兴趣。 也就是说,序列中的下一个单词。 请注意,我们是如何从 1 而不是 0 开始循环的,所以我们的第一个预测是序列中的第二个单词(因为预测的第一个单词将始终是起始标记)。

这个输出由一个目标词汇长度的向量组成,并对词汇中的每个单词进行预测。我们采取argmax函数来确定模型预测的实际单词。

然后,我们需要为下一步选择新的输入。 我们将教师强制率设置为 50%,这意味着 50% 的时间,我们将使用刚刚做出的预测作为解码器的下一个输入,而其他 50% 的时间,我们将采用真正的目标 。 正如我们之前所讨论的,这比仅依赖模型的预测可以帮助我们的模型更快地学习。

然后,我们继续执行此循环,直到对序列中的每个单词有完整的预测为止:

代码语言:javascript复制
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

最后,我们创建一个Seq2Seq模型的实例,准备进行训练。我们用选定的超参数初始化一个编码器和一个解码器,所有这些参数都可以改变以稍微改变模型。

代码语言:javascript复制
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

然后我们将编码器和解码器传递给我们的Seq2Seq模型,以便创建完整的模型。

代码语言:javascript复制
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 的初始化。 尽管理论上该模型应该能够在没有(零)权重的情况下进行学习,但事实表明,使用随机权重进行初始化可以帮助模型更快地学习。 让我们开始吧:

在这里,我们将用来自正态分布的随机样本的权重来初始化我们的模型,数值在 -0.1 到 0.1 之间。

代码语言:javascript复制
def initialize_weights(m):
    for name, param in m.named_parameters():
        nn.init.uniform_(param.data, -0.1, 0.1)
        
model.apply(initialize_weights)

接下来,与我们所有其他模型一样,我们定义我们的优化器和损失函数。我们正在使用交叉熵损失,因为我们正在执行多类分类(而不是二分类的二进制交叉熵损失)。

代码语言:javascript复制
optimizer = optim.Adam(model.parameters())
criterion = nn.CrossEntropyLoss(ignore_index = TARGET.vocab.stoi[TARGET.pad_token])

接下来,我们在一个名为train()的函数内定义训练过程。首先,我们将我们的模型设置为训练模式,并将epoch_loss设置为0

代码语言:javascript复制
def train(model, iterator, optimizer, criterion, clip):
    model.train()
    epoch_loss = 0

然后,我们在训练迭代器内循环检查每个批次,并提取要翻译的句子(src)和这个句子的正确翻译(trg)。然后我们将我们的梯度归零(以防止梯度累积),并通过传递我们的模型函数我们的输入和输出来计算我们模型的输出。

代码语言:javascript复制
for i, batch in enumerate(iterator):
src = batch.src
trg = batch.trg
optimizer.zero_grad()
output = model(src, trg)

接下来,我们需要通过比较我们的预测输出和真实的、正确的翻译句子来计算我们模型预测的损失。我们使用shapeview函数重塑我们的输出数据和目标数据,以便创建两个可以比较的张量来计算损失。我们计算我们的输出和trg向量之间的loss标准,然后通过网络反推这个损失。

代码语言:javascript复制
output_dims = output.shape[-1]
output = output[1:].view(-1, output_dims)
trg = trg[1:].view(-1)
        
loss = criterion(output, trg)
        
loss.backward()

然后,我们实现梯度剪裁以防止模型内的梯度爆炸,通过梯度下降对我们的优化器进行必要的参数更新,最后将批次的损失添加到周期损失中。整个过程对一个训练周期内的所有批次重复进行,从而返回每个批次的最终平均损失。

代码语言:javascript复制
torch.nn.utils.clip_grad_norm_(model.parameters(), clip)
        
optimizer.step()
        
epoch_loss  = loss.item()
        
return epoch_loss / len(iterator)

之后,我们创建一个类似的函数,叫做evaluate()。这个函数将计算我们的验证数据在整个网络中的损失,以便评估我们的模型在翻译它以前没有见过的数据时的表现。这个函数与我们的train()函数几乎相同,只是我们切换到了评估模式。

代码语言:javascript复制
model.eval()

由于我们不对我们的权重进行任何更新,我们需要确保实现no_grad模式。

代码语言:javascript复制
with torch.no_grad():

唯一不同的是,我们需要确保在评估模式下关闭教师强制。我们希望评估我们的模型在未见数据上的表现,而启用教师强迫将使用我们正确的(目标)数据来帮助我们的模型做出更好的预测。我们希望我们的模型能够做出完美的、无辅助的预测。

代码语言:javascript复制
output = model(src, trg, 0)

最后,我们需要创建一个训练循环,在这个循环中调用train()evaluate()函数。我们首先定义了我们希望训练的次数和最大梯度(用于梯度剪接)。我们还将最低验证损失设置为无穷大。这将在后面用来选择我们表现最好的模型。

代码语言:javascript复制
epochs = 10
grad_clip = 1
lowest_validation_loss = float('inf')

然后,我们循环浏览我们的每个周期,并在每个周期内,使用我们的train()和·evaluate()函数计算我们的训练和验证损失。我们还通过在训练过程前后调用time.time()来计算时间。

代码语言:javascript复制
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()

接下来,对于每个周期,我们确定我们刚刚训练的模型是否是我们迄今为止看到的表现最好的模型。如果我们的模型在我们的验证数据上表现最好(如果验证损失是我们迄今为止看到的最低的),我们就保存我们的模型。

代码语言:javascript复制
if valid_loss < lowest_validation_loss:
    lowest_validation_loss = valid_loss
    torch.save(model.state_dict(), 'seq2seq.pt')

最后,我们只需打印我们的输出。

代码语言:javascript复制
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}')

如果我们的训练工作正常,我们应该看到训练损失会随着时间而减少,就像这样:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hnR20qt0-1681785833987)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_07_17.jpg)]

图 7.17 –训练模型

在这里,我们可以看到我们的训练和验证损失似乎都随着时间而下降。 我们可以继续训练模型很多时间,理想情况下,直到验证损失达到最低值为止。 现在,我们可以评估表现最佳的模型,以查看进行实际翻译时的表现。

评估模型

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

我们首先创建一个translate()函数。这在功能上与我们创建的evaluate()函数相同,以计算验证集的损失。然而,这一次,我们不关心我们的模型的损失,而是预测的输出。我们将源句和目标句传递给模型,同时确保我们将教师强制关闭,这样我们的模型就不会使用这些句子来进行预测。然后我们把我们模型的预测结果,用argmax函数来确定我们模型预测输出句子中每个单词的索引。

代码语言:javascript复制
output = model(src, trg, 0)
preds = torch.tensor([[torch.argmax(x).item()] for x in output])

然后,我们可以使用这个指数从我们的德语词汇中获得实际的预测词。最后,我们将英语输入与我们的模型进行比较,该模型包含正确的德语句子和预测的德语句子。请注意,在这里,我们使用[1:-1]从我们的预测中删除开始和结束标记,并且我们将英语输入的顺序反过来(因为输入句子在被输入到模型之前已经被反过来了)。

代码语言:javascript复制
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]))

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7SFbOjx0-1681785833987)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_07_18.jpg)]

图 7.18 –翻译输出第一部分

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VLnnu6YY-1681785833987)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_07_19.jpg)]

图 7.19 –翻译输出第二部分

我们还可以看到一些似乎被误解的示例。 在下面的示例中,我们预测的德语句子的英语等同词为A woman climbs through one,这不等于Young woman climbing rock face。 但是,该模型仍然设法翻译了英语句子(女性和攀岩)的关键元素:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-u9rocvY1-1681785833987)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_07_20.jpg)]

图 7.20 –翻译输出第三部分

在这里,我们可以看到尽管我们的模型清楚地尝试了将英语翻译成德语的尝试,但它远非完美,并且会犯一些错误。 当然,它不能愚弄以德语为母语的人! 接下来,我们将讨论几种改进序列到序列翻译模型的方法。

后续步骤

虽然我们已经证明序列到序列模型可以有效地执行语言翻译,但是从头开始训练的模型无论如何都不是完美的翻译器。 部分原因是由于我们的训练数据相对较小。 我们用 30,000 个英语/德语句子集训练了模型。 尽管这看起来可能很大,但是为了训练一个完美的模型,我们需要一个大几个数量级的训练集。

从理论上讲,我们需要为整个英语和德语中的每个单词提供几个示例,以使我们的模型真正理解其上下文和含义。 就上下文而言,我们训练中的 30,000 个英语句子仅包含 6,000 个独特单词。 据说说英语的人的平均词汇量在 20,000 到 30,000 个单词之间,这使我们了解到,要训练一个表现出色的模型,我们需要多少个例句。 这可能就是为什么最准确的翻译工具归能够访问大量语言数据的公司(例如 Google)所有的原因。

总结

在本章中,我们介绍了如何从头开始构建序列到序列模型。 我们学习了如何分别对编码器和解码器组件进行编码,以及如何将它们集成到一个模型中,该模型能够将句子从一种语言翻译成另一种语言。

尽管我们的由编码器和解码器组成的序列到序列模型对于序列翻译很有用,但它已不再是最新技术。 在过去的几年中,已经完成了将序列到序列模型与注意力模型结合起来以实现最新表现的方法。

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

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

如果您曾经看过任何未来派科幻电影,那么您很有可能会看到与机器人的人类对话。 基于机器的情报一直是小说作品中的长期特征。 但是,由于 NLP 和深度学习的最新发展,与计算机的对话不再是幻想。 虽然我们可能距离真正的智能还很多年,在这种情况下,计算机能够以与人类相同的方式理解语言的含义,但机器至少能够进行基本的对话并提供基本的智能印象。

在上一章中,我们研究了如何构建序列到序列模型以将句子从一种语言翻译成另一种语言。 能够进行基本交互的对话型聊天机器人的工作方式几乎相同。 当我们与聊天机器人交谈时,我们的句子将成为模型的输入。 输出是聊天机器人选择回复的内容。 因此,我们正在训练它如何响应,而不是训练我们的聊天机器人来学习如何解释输入的句子。

我们将在上一章中扩展序列到序列模型,在模型中增加注意力。 对序列到序列模型的这种改进意味着我们的模型可以学习输入句子中要查找的位置以获得所需信息的方式,而不是使用整个输入句子决策。 这项改进使我们能够创建具有最先进表现的效率更高的序列到序列模型。

在本章中,我们将研究以下主题:

  • 神经网络中的注意力理论
  • 在神经网络内实现注意力来构建聊天机器人

技术要求

本章的所有代码都可以在这个页面中找到。

神经网络中的注意力理论

在上一章中,在用于句子翻译的序列到序列模型中(没有引起注意),我们同时使用了编码器和解码器。 编码器从输入句子中获得了隐藏状态,这是我们句子的一种表示形式。 然后,解码器使用此隐藏状态执行转换步骤。 对此的基本图形说明如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-63ryHFmJ-1681785833988)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_08_1.jpg)]

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

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

考虑以下示例:

代码语言:javascript复制
I will be traveling to Paris, the capital city of France, on the 2nd of March. My flight leaves from London Heathrow airport and will take approximately one hour.

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

代码语言:javascript复制
The capital city of France is _____.

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

代码语言:javascript复制
I will be traveling to Paris, the capital city of France, on the 2nd of March. My flight leaves from London Heathrow airport and will take approximately one hour.

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

我们可以采用两种主要的注意力机制:局部和全局注意力。

比较本地和全局注意力

我们可以在网络中通过实现的两种注意形式与非常相似,但存在细微的关键区别。 我们将从关注本地开始。

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

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-C5XOChSO-1681785833988)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_08_2.jpg)]

图 8.2 –本地注意力模型

我们首先从最终隐藏状态h[n]计算对齐位置p[t]。 这告诉我们需要进行观察才能发现哪些隐藏状态。 然后,我们计算局部权重并将其应用于隐藏状态,以确定上下文向量。 这些权重可能告诉我们,更多地关注最相关的隐藏状态(h2),而较少关注先前的隐藏状态(h1)。

然后,我们获取上下文向量,并将其转发给解码器以进行预测。 在我们基于非注意力的序列到序列模型中,我们只会向前传递最终的隐藏状态h[n],但在这里我们看到的是,我们仅考虑了我们的相关隐藏状态,模型认为它对于做出预测是必要的。

全局注意力模型的运作方式与非常相似。 但是,我们不仅要查看所有隐藏状态,还希望查看模型的所有隐藏状态,因此命名为全局。 我们可以在此处看到全局注意力层的图形化图示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iH0tQcuQ-1681785833988)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_08_3.jpg)]

图 8.3 –全局注意力模型

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9TkzDP65-1681785833988)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_08_4.jpg)]

图 8.4 –组合模型

我们在前面的图中可以看到,通过了解要注意的隐藏状态,我们的模型可以控制解码步骤中使用哪些状态来确定我们的预测输出。 一旦确定了要注意的隐藏状态,我们就可以使用多种不同的方法将它们组合在一起-通过连接或采用加权的点积。

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

准确说明如何在神经网络中实现注意力的最简单方法是通过示例。 现在,我们将使用应用了关注框架的序列到序列模型,完成从头构建聊天机器人的所有步骤。

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

获取我们的数据集

要训​​练我们的聊天机器人,我们需要一个会话数据集,模型可以通过该数据集学习如何响应。 我们的聊天机器人将接受一系列人工输入,并使用生成的句子对其进行响应。 因此,理想的数据集将由多行对话和适当的响应组成。 诸如此类任务的理想数据集将是来自两个人类用户之间的对话的实际聊天记录。 不幸的是,这些数据由私人信息组成,很难在公共领域获得,因此对于此任务,我们将使用电影脚本的数据集。

电影脚本由两个或更多角色之间的对话组成。 尽管此数据不是我们希望的自然格式,但我们可以轻松地将其转换为所需的格式。 以两个字符之间的简单对话为例:

  • 第 1 行Hello Bethan.
  • 第 2 行Hello Tom, how are you?
  • 第 3 行I'm great thanks, what are you doing this evening?
  • 第 4 行I haven't got anything planned.
  • 第 5 行Would you like to come to dinner with me?

现在,我们需要将其转换为调用和响应的输入和输出对,其中输入是脚本中的一行(调用),预期输出是脚本的下一行(响应)。 我们可以将n行的脚本转换为n-1对输入/输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MTfynBYB-1681785833988)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_08_05.jpg)]

图 8.5 –输入和输出表

我们可以使用这些输入/输出对来训练我们的网络,其中输入是人工输入的代理,而输出则是我们希望从模型中获得的响应。

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

处理我们的数据集

幸运的是,为该示例提供的数据集已经被格式化,因此每行代表一个输入/输出对。 我们可以先读取其中的数据并检查一些行:

代码语言:javascript复制
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')

打印以下结果:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YErp1TbF-1681785833989)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_08_06.jpg)]

图 8.6 –检查数据集

首先,您会注意到我们的行与预期的一样,因为第一行的下半部分成为下一行的前半部分。 我们还可以注意到,每行的通话和响应半部分由制表符分隔符(t)分隔,我们的每行均由新的行分隔符(n)。 在处理数据集时,我们必须考虑到这一点。

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

创建词汇表

过去,我们的语料库由几个词典组成,这些词典由我们的语料库中的唯一单词以及在单词和索引之间的查找组成。 但是,我们可以通过创建一个包含所有必需元素的词汇表类,以一种更为优雅的方式来实现此目的:

我们先创建Vocabulary类。我们用空字典–word2indexword2count来初始化这个类。我们还用填充标记的占位符以及句子开始SOS)和句子结束EOS)标记初始化了index2word字典。我们也会对词汇中的单词数量进行统计(首先是 3 个,因为我们的语料库已经包含了上述三个标记)。这些是一个空词汇的默认值,但是,当我们读入数据时,它们会被填充。

代码语言:javascript复制
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

接下来,我们创建我们将用来填充词汇的函数。addWord接收一个单词作为输入。如果这是个新词,还没有在我们的词汇中,我们就把这个词添加到我们的索引中,把这个词的计数设为 1,并把我们词汇中的总词数递增 1。如果这个词已经在我们的词汇中,我们只需将这个词的数量增加 1。

代码语言:javascript复制
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

我们还使用addSentence函数将addWord函数应用于给定句子中的所有单词。

代码语言:javascript复制
def addSentence(self, sent):
    for word in sent.split(' '):
        self.addWord(word)

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

为了从词汇中删除低频词,我们可以实现一个trim函数。该函数首先循环浏览单词计数词典,如果该单词的出现次数大于所需的最小计数,则将其追加到一个新的列表中。

代码语言:javascript复制
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)))

最后,我们的索引从新的words_to_keep列表中重建。我们将所有的索引设置为初始的空值,然后通过addWord函数循环浏览我们保留的单词来重新填充它们。

代码语言:javascript复制
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)

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

加载数据

我们将通过以下步骤开始加载数据:

读取我们的数据的第一步是执行任何必要的步骤来清理数据,使其更易于人类阅读。我们首先将数据从 Unicode 转换为 ASCII 格式。我们可以很容易地使用一个函数来完成这个工作。

代码语言:javascript复制
def unicodeToAscii(s):
    return ''.join(
        c for c in unicodedata.normalize('NFD', s)
        if unicodedata.category(c) != 'Mn'
    )
Next, we want to process our input s

接下来,我们要处理我们的输入字符串,使它们都是小写的,除了最基本的字符外,不包含任何尾部的空格或标点符号。我们可以通过使用一系列的正则表达式来实现。

代码语言:javascript复制
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

最后,我们在一个更广泛的函数–readVocs中应用这个函数。这个函数将我们的数据文件读成行,然后将cleanString函数应用到每一行。它还创建了一个我们前面创建的Vocabulary类的实例,这意味着这个函数同时输出我们的数据和词汇。

代码语言:javascript复制
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 个字长的实例。

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

代码语言:javascript复制
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)]

现在,我们只需要创建一个最后的函数,应用我们之前整理的所有函数,并运行它来创建我们的词汇和数据对。

代码语言:javascript复制
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 个单词的句子时,这将减少为仅由 18,000 个不同单词组成的 64,000 对:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-76R6aEWB-1681785833989)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_08_07.jpg)]

图 8.7 –数据集中句子的值

我们可以打印我们处理过的输入/输出对中的一部分,以验证我们的函数是否全部正确工作。

代码语言:javascript复制
print("Example Pairs:")
for pair in pairs[-10:]:
    print(pair)

生成以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1YMBeteF-1681785833989)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_08_08.jpg)]

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

看来我们已经成功地将数据集分为输入和输出对,可以在上面训练网络。

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

删除稀有词

如前所述,仅在数据集中出现几次的单词会增加模型的维数,从而增加模型的复杂度以及训练模型所需的时间。 因此,最好将其从我们的训练数据中删除,以使我们的模型尽可能简化和高效。

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

我们首先要计算出我们将保留在模型中的词的百分比。

代码语言:javascript复制
def removeRareWords(voc, all_pairs, minimum):
    voc.trim(minimum)

结果为以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-etOabeK3-1681785833989)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_08_09.jpg)]

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

在这个函数中,我们循环检查输入和输出句子中的所有单词。如果对于一个给定的对子,无论是输入句还是输出句都有一个不在我们新修剪的语料中的单词,我们就从我们的数据集中删除这个对子。我们打印输出结果,发现即使我们放弃了一半以上的词汇,也只放弃了 17% 左右的训练对。这再次反映了我们的词汇语料库是如何分布在我们的各个训练对上的。

代码语言:javascript复制
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)

结果为以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ela1j7Gy-1681785833989)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_08_10.png)]

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

现在我们有了完成的数据集,我们需要构建一些函数,将我们的数据集转换为成批的张量,然后将它们传递给模型。

将句子对转换为张量

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

我们首先创建几个辅助函数,用来将我们的词对转化为时序。我们首先创建一个indexFromSentence函数,它从词汇中抓取句子中每个单词的索引,并在句尾附加一个 EOS 标记。

代码语言:javascript复制
def indexFromSentence(voc, sentence):
    return [voc.word2index[word] for word in
            sent.split(' ')]   [EOS_token]

其次,我们创建了一个zeroPad函数,它可以将任何张量用零来填充,这样张量中的所有句子实际上都是相同的长度。

代码语言:javascript复制
def zeroPad(l, fillvalue=PAD_token):
    return list(itertools.zip_longest(*l,
                fillvalue=fillvalue))

然后,为了生成我们的输入张量,我们应用这两个函数。首先,我们得到我们输入句子的指数,然后应用填充,然后将输出转化为LongTensor。我们还将获得我们每个输入句子的长度输出这个作为一个张量。

代码语言:javascript复制
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

在我们的网络中,我们的填充标记一般应该被忽略。我们不想在这些填充的标记上训练我们的模型,所以我们创建一个布尔掩码来忽略这些标记。为此,我们使用getMask函数,将其应用到我们的输出张量上。如果输出由一个词组成,则返回1,如果由一个填充标记组成,则返回0

代码语言:javascript复制
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

然后我们将其应用于outputVar函数。这和inputVar函数是一样的,只是除了有索引的输出张量和长度张量之外,我们还返回输出张量的布尔掩码。这个布尔掩码只是在输出张量内有词时返回True,有填充标记时返回False。我们还返回输出张量中句子的最大长度。

代码语言:javascript复制
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

最后,为了同时创建我们的输入和输出批次,我们循环浏览批次中的对,并使用之前创建的函数为两个对创建输入和输出时序。然后我们返回所有必要的变量。

代码语言:javascript复制
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

这个函数应该是我们将训练对转化为训练模型所需的全部内容。我们可以通过在随机选择的数据上执行batch2Train函数的单次迭代来验证这个函数是否正确。我们将我们的批次大小设置为5,然后运行一次。

代码语言:javascript复制
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):

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lXy1vS9x-1681785833990)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_08_11.jpg)]

图 8.11 –输入张量

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sahH60F5-1681785833990)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_08_12.jpg)]

图 8.12 –目标和模板张量

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

构建模型

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

构建编码器

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

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

代码语言:javascript复制
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)模型。 尽管 GRU 仍然控制通过 RNN 的信息流,但其的复杂度比 LSTM 小,但它们没有像 LSTM 这样的单独的门和更新门。 我们在这种情况下使用 GRU 的原因有几个:

a)由于需要学习的参数较少,因此 GRU 已被证明具有更高的计算效率。 这意味着我们的模型使用 GRU 进行训练要比使用 LSTM 进行训练更快。

b)已证明 GRU 在短数据序列上具有与 LSTM 相似的表现水平。 当学习更长的数据序列时,LSTM 更有用。 在这种情况下,我们仅使用 10 个单词或更少的输入句子,因此 GRU 应该产生相似的结果。

c)事实证明,GRU 在学习小型数据集方面比 LSTM 更有效。 由于我们的训练数据的规模相对于我们要学习的任务的复杂性而言较小,因此我们应该选择使用 GRU。

现在我们定义我们的 GRU,考虑到输入的大小,层数,以及是否应该实现丢弃。

代码语言:javascript复制
self.gru = nn.GRU(hidden_size, hidden_size, n_layers,
                  dropout=(0 if n_layers == 1 else dropout), 
                  bidirectional=True)

注意这里我们如何在模型中实现双向性。 您会从前面的章节中回顾到,双向 RNN 允许我们学习从句子向前移动到句子之间以及向后顺序移动的句子。 这使我们可以更好地捕获句子中每个单词相对于前后单词的上下文。 GRU 中的双向性意味着我们的编码器如下所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uCCqidAo-1681785833990)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_08_13.jpg)]

图 8.13 –编码器布局

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

接下来,我们需要为我们的编码器创建一个正向传播。我们首先将输入句子嵌入,然后使用pack_padded_sequence函数对我们的嵌入进行处理。这个函数对我们的填充序列进行 “打包”,使我们所有的输入都具有相同的长度。然后,我们将打包后的序列通过 GRU 传递出去,进行正向传播。

代码语言:javascript复制
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)

在这之后,我们解包我们的填充并对 GRU 输出进行求和。然后,我们可以返回这个加和后的输出,以及我们最终的隐藏状态,来完成我们的正向传播。

代码语言:javascript复制
outputs, _ = nn.utils.rnn.pad_packed_sequence(outputs)
outputs = outputs[:, :, :self.hidden_size]   a 
          outputs[:, : ,self.hidden_size:]
return outputs, hidden

现在,我们将在下一部分继续创建关注模块。

构建注意力模块

接下来,我们需要构建我们的注意力模块,该模块将应用于我们的编码器,以便我们可以从编码器输出的相关部分中学习。 我们将按照以下方式进行:

首先为注意力模型创建一个类。

代码语言:javascript复制
class Attn(nn.Module):
    def __init__(self, hidden_size):
        super(Attn, self).__init__()
        self.hidden_size = hidden_size

然后,在这个类中创建dot_score函数。这个函数简单地计算我们的编码器输出与我们的编码器输出的隐藏状态的点积。虽然还有其他的方法可以将这两个张量转化为单一的表示方式,但使用点积是最简单的方法之一。

代码语言:javascript复制
def dot_score(self, hidden, encoder_output):
    return torch.sum(hidden * encoder_output, dim=2)

然后,我们在前传内使用这个函数。首先,根据dot_score方法计算注意力权重/能量,然后对结果进行转置,并返回 softmax 变换后的概率分数。

代码语言:javascript复制
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)

接下来,我们可以在解码器中使用此关注模块来创建关注焦点的解码器。

构建解码器

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

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

代码语言:javascript复制
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

然后我们在这个模块中创建我们的层。我们将创建一个嵌入层和一个相应的丢弃层。我们再次为我们的解码器使用 GRU;但是,这次我们不需要使我们的 GRU 层成为双向的,因为我们将依次对编码器的输出进行解码。我们还将创建两个线性层–一个是用于计算我们的输出的常规层,另一个是可用于连接的层。这个层的宽度是常规隐藏层的两倍,因为它将用于两个连通向量,每个向量的长度为hidden_size。我们还初始化了上一节中的注意力模块的一个实例,以便能够在我们的Decoder类中使用它。

代码语言:javascript复制
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)

在定义了所有的层之后,我们需要为解码器创建一个前向通道。请注意前向通证将如何一步一步(单词)地使用。我们首先得到当前输入词的嵌入,然后通过 GRU 层进行前向通证,得到我们的输出和隐藏状态。

代码语言:javascript复制
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)

接下来,我们使用注意力模块从 GRU 输出中获取注意力权重。然后将这些权重与编码器输出相乘,从而有效地得到我们的注意力权重和编码器输出的加权和。

代码语言:javascript复制
attn_weights = self.attn(rnn_output, encoder_outputs)
context = attn_weights.bmm(encoder_outputs.transpose(0, 1))

然后,我们将加权上下文向量与 GRU 的输出相连接,并应用tanh函数得到最终的连接输出。

代码语言:javascript复制
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))

对于我们解码器内的最后一步,我们只需使用这个最终的连通输出来预测下一个词,并应用一个 softmax 函数。正向传播最后会返回这个输出,以及最终的隐藏状态。这个前向通证将被迭代,下一个前向通证将使用句子中的下一个词和这个新的隐藏状态。

代码语言:javascript复制
output = self.out(concat_output)
output = F.softmax(output, dim=1)
return output, hidden

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

定义训练过程

训练过程的第一步是为我们的模型定义损失的度量。 由于我们的输入张量可能由填充序列组成,由于我们输入的句子都具有不同的长度,因此我们不能简单地计算真实输出和预测输出张量之间的差。 为了解决这个问题,我们将定义一个损失函数,该函数将布尔掩码应用于输出,并且仅计算未填充标记的损失:

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

代码语言:javascript复制
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()

对于我们的大部分训练,我们需要两个主要函数–一个函数train(),它对我们的单批训练数据进行训练,另一个函数trainIters(),它遍历我们的整个数据集,并对每个单独的批次调用train()。我们先定义train(),以便对单批数据进行训练。创建train()函数,然后让梯度为 0,定义设备选项,并初始化变量。

代码语言:javascript复制
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

然后,通过编码器执行输入和序列长度的正向传播,得到输出和隐藏状态。

代码语言:javascript复制
encoder_outputs, encoder_hidden = encoder(input_variable, lengths)

接下来,我们创建我们的初始解码器输入,从每个句子的 SOS 标记开始。然后我们将解码器的初始隐藏状态设置为与编码器的状态相等。

代码语言:javascript复制
decoder_input = torch.LongTensor(
    [[SOS_token for _ in range(batch_size)]])
decoder_input = decoder_input.to(device)
decoder_hidden = encoder_hidden[:decoder.n_layers]

接下来,我们实现教师强迫。 如果您从上一章的教师强迫中回想起,当以给定的概率生成输出序列时,我们将使用真正的上一个输出标记而不是预测的上一个输出标记来生成输出序列中的下一个单词。 使用教师强制可以帮助我们的模型更快收敛。 但是,我们必须小心,不要使教师强迫率过高,否则我们的模型将过于依赖教师强迫,并且不会学会独立产生正确的输出。

确定我们是否应该对当前步骤使用教师强制。

代码语言:javascript复制
use_TF = True if random.random() < teacher_forcing_ratio else False

然后,如果我们确实需要实现教师强制,请运行以下代码。我们将每一个序列批次通过解码器来获得我们的输出。然后,我们将下一个输入设置为真实输出(目标)。最后,我们使用我们的损失函数计算和累积损失,并将其打印到控制台。

代码语言:javascript复制
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

如果我们不对给定的批次实现教师强迫,程序几乎是相同的。但是,我们不使用真实输出作为序列的下一个输入,而是使用模型生成的输出。

代码语言:javascript复制
_, topi = decoder_output.topk(1)
decoder_input = torch.LongTensor([[topi[i][0] for i in 
                                   range(batch_size)]])
decoder_input = decoder_input.to(device)

最后,与我们所有的模型一样,最后的步骤是执行反向传播,实现梯度剪接,并通过我们的编码器和解码器优化器来使用梯度下降更新权重。请记住,我们剪掉梯度是为了防止梯度消失/爆炸的问题,这在前面的章节中已经讨论过。最后,我们的训练步骤返回我们的平均损失。

代码语言:javascript复制
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

接下来,如前所述,我们需要创建trainIters()函数,它在不同批次的输入数据上反复调用我们的训练函数。我们首先使用之前创建的batch2Train函数将我们的数据分成若干批次。

代码语言:javascript复制
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)]

然后,我们创建一些变量,使我们能够计算迭代次数,并跟踪每个周期的总损失。

代码语言:javascript复制
print('Starting ...')
start_iteration = 1
print_loss = 0
if loadFilename:
    start_iteration = checkpoint['iteration']   1

接下来,我们定义我们的训练循环。对于每次迭代,我们从我们的批次列表中得到一个训练批次。然后,我们从我们的批次中提取相关字段,并使用这些参数运行一次训练迭代。最后,我们将这个批次的损失加入到我们的总体损失中。

代码语言:javascript复制
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

在每一次迭代中,我们还确保打印出迄今为止的进度,跟踪我们已经完成了多少次迭代,以及每个周期的损失是多少。

代码语言:javascript复制
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

为了完成,我们还需要在每隔几个周期后保存我们的模型状态。这让我们可以重新审视我们已经训练过的任何历史模型;例如,如果我们的模型开始过拟合,我们可以恢复到早期的迭代。

代码语言:javascript复制
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?

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

我们首先要定义一个类,让我们能够对编码输入进行解码并生成文本。我们通过使用所谓的GreedyEncoder来实现这一目标。这简单地说,在解码器的每一步,我们的模型都将预测概率最高的词作为输出。我们先用预先训练好的编码器和解码器初始化GreedyEncoder类。

代码语言:javascript复制
class GreedySearchDecoder(nn.Module):
    def __init__(self, encoder, decoder):
        super(GreedySearchDecoder, self).__init__()
        self.encoder = encoder
        self.decoder = decoder

接下来,为我们的解码器定义一个正向传播。我们将输入通过编码器得到我们编码器的输出和隐藏状态。我们把编码器的最后一个隐藏层作为解码器的第一个隐藏输入。

代码语言:javascript复制
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]

然后,用 SOS 标记创建解码器输入,并初始化附加解码词的标记(初始化为单个零值)。

代码语言:javascript复制
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)

之后,对序列进行迭代,每次解码一个词。我们对编码器进行正向传播,并添加一个max函数,以获得得分最高的预测词及其得分,然后将其追加到all_tokensall_scores变量中。最后,我们将这个预测的标记作为我们解码器的下一个输入。在整个序列被迭代过后,我们返回完整的预测句。

代码语言:javascript复制
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

所有的部分都开始融合在一起。 我们具有已定义的训练和评估函数,因此最后一步是编写一个函数,该函数实际上会将我们的输入作为文本,将其传递给我们的模型,并从模型中获取响应。 这将是我们聊天机器人的“界面”,我们实际上在那里进行对话。

我们首先定义一个evaluate()函数,它接受我们的输入函数并返回预测的输出词汇。我们首先使用我们的词汇将输入句子转化为指数。然后,我们获得这些句子中每个句子的长度的张量,并对其进行转置。

代码语言:javascript复制
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)

然后,我们将我们的长度和输入时序分配给相关设备。接下来,通过搜索器(GreedySearchDecoder)运行输入,以获得预测输出的词索引。最后,我们将这些词索引转化回词标记,再作为函数输出返回。

代码语言:javascript复制
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

最后,我们创建一个runchatbot函数,作为我们聊天机器人的接口。这个函数接受人类输入的信息并打印聊天机器人的响应。我们将这个函数创建为一个while循环,一直到我们终止该函数或输入quit为止。

代码语言:javascript复制
def runchatbot(encoder, decoder, searcher, voc):
    input_sentence = ''
    while(1):
        try:
            input_sentence = input('> ')
            if input_sentence == 'quit': break

然后,我们将输入的类型化输入进行归一化处理,再将归一化输入传给我们的evaluate()函数,该函数返回聊天机器人的预测词。

代码语言:javascript复制
input_sentence = cleanString(input_sentence)
output_words = evaluate(encoder, decoder, searcher,
                        voc, input_sentence)

最后,我们将这些输出词进行格式化,忽略 EOS 和填充标记,然后再打印聊天机器人的响应。因为这是一个while循环,这让我们可以无限期地继续与聊天机器人对话。

代码语言:javascript复制
output_words[:] = [x for x in output_words if 
                   not (x == 'EOS' or x == 'PAD')]
print('Response:', ' '.join(output_words))

现在我们已经构建了训练,评估和使用聊天机器人所需的所有函数,现在该开始最后一步了—训练模型并与训练过的聊天机器人进行对话。

训练模型

当我们定义了所有必需的函数时,训练模型就成为一种情况或初始化我们的超参数并调用我们的训练函数:

我们首先初始化我们的超参数。虽然这些只是建议的超参数,但我们的模型已经被设置为允许它们适应任何传递给它们的超参数的方式。用不同的超参数进行实验,看看哪些超参数能带来最佳的模型配置,这是一个很好的做法。在这里,你可以试验增加编码器和解码器的层数,增加或减少隐藏层的大小,或者增加批次大小。所有这些超参数都会对模型的学习效果产生影响,同时也会影响其他一些因素,例如训练模型所需的时间。

代码语言:javascript复制
model_name = 'chatbot_model'
hidden_size = 500
encoder_n_layers = 2
decoder_n_layers = 2
dropout = 0.15
batch_size = 64

之后,我们可以加载我们的检查点。如果我们之前已经训练过一个模型,我们可以加载之前迭代中的检查点和模型状态。这就节省了我们每次都要重新训练我们的模型。

代码语言:javascript复制
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']

之后,我们可以开始构建我们的模型。我们首先从词汇中加载我们的嵌入。如果我们已经训练了一个模型,我们可以加载训练好的嵌入层。

代码语言:javascript复制
embedding = nn.Embedding(voc.num_words, hidden_size)
if loadFilename:
    embedding.load_state_dict(embedding_sd)
We then do the same for our encoder and decoder, creating model instances using

然后,我们对编码器和解码器做同样的工作,使用定义的超参数创建模型实例。同样,如果我们已经训练了一个模型,我们只需将训练好的模型状态加载到我们的模型中。

代码语言:javascript复制
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)

最后但并非最不重要的是,我们为我们的每个模型指定一个要训练的设备。请记住,如果你想使用 GPU 训练,这是至关重要的一步。

代码语言:javascript复制
encoder = encoder.to(device)
decoder = decoder.to(device)
print('Models built and ready to go!')

如果一切正常,并且创建的模型没有错误,则应该看到以下内容:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TiR6To48-1681785833990)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_08_14.jpg)]

图 8.14 –成功的输出

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

我们首先初始化一些训练超参数。 以与模型超参数相同的方式,可以调整这些参数以影响训练时间以及模型的学习方式。 裁剪控制梯度裁剪,教师强迫控制我们在模型中使用教师强迫的频率。 请注意,我们如何使用教师强制比 1,以便始终使用教师强制。 降低教学强迫率将意味着我们的模型需要更长的时间才能收敛。 但是,从长远来看,这可能有助于我们的模型更好地自行生成正确的句子。

我们还需要定义模型的学习率和解码器的学习率。你会发现,当解码器在梯度下降过程中进行较大的参数更新时,你的模型表现会更好。因此,我们引入一个解码器学习率,对学习率施加一个倍数,使解码器的学习率大于编码器的学习率。我们还定义了我们的模型打印和保存结果的频率,以及我们希望我们的模型运行多少个周期。

代码语言:javascript复制
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

接下来,和以往在 PyTorch 中训练模型时一样,我们将模型切换到训练模式,以便更新参数。

代码语言:javascript复制
encoder.train()
decoder.train()

接下来,我们为编码器和解码器创建优化器。我们将这些优化器初始化为 Adam 优化器,但其他优化器也同样适用。用不同的优化器进行实验可能会产生不同级别的模型表现。如果你之前已经训练过一个模型,如果需要的话,你也可以加载优化器的状态。

代码语言:javascript复制
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)

运行训练前的最后一步是确保 CUDA 被配置为被调用,如果你想使用 GPU 训练。要做到这一点,我们只需简单地循环编码器和解码器的优化器状态,并在所有状态中启用 CUDA。

代码语言:javascript复制
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()

最后,我们准备好训练我们的模型。这可以通过简单地调用trainIters函数来完成,其中包含所有所需参数。

代码语言:javascript复制
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)

如果此操作正常,您应该看到以下输出开始打印:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KOSGu2tC-1681785833990)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_08_15.jpg)]

图 8.15 –训练模型

您的模型正在训练中! 根据许多因素,例如您将模型设置为训练多少个周期以及是否使用 GPU,模型可能需要一些时间来训练。 完成后,您将看到以下输出。 如果一切正常,则模型的平均损失将大大低于开始训练时的损失,这表明模型已经学到了一些有用的信息:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1FVmycku-1681785833991)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_08_16.jpg)]

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

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

评估模型

既然我们已经成功创建并训练了我们的模型,那么现在该评估其表现了。 我们将通过以下步骤进行操作:

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

代码语言:javascript复制
encoder.eval()
decoder.eval()

我们还初始化了一个GreedySearchDecoder的实例,以便能够进行评估,并将预测的输出结果作为文本返回

代码语言:javascript复制
searcher = GreedySearchDecoder(encoder, decoder)

最后,要运行聊天机器人,我们只需调用runchatbot函数,将encoderdecodersearchervoc传递给它。

代码语言:javascript复制
runchatbot(encoder, decoder, searcher, voc)

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-To1xpnW9-1681785833991)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_08_17.jpg)]

图 8.17 –用于输入文本的 UI 元素

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rdtvmbME-1681785833991)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_08_18.jpg)]

图 8.18 –聊天机器人的输出

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rAvrIOQu-1681785833991)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_08_19.jpg)]

图 8.19 –聊天机器人的输出

但是,一旦对话变得更加复杂,就很明显,聊天机器人无法进行与人类相同级别的对话:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Otak7sWz-1681785833991)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_08_20.jpg)]

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

在许多情况下,您的聊天机器人的响应可能没有意义:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-di4NNYdz-1681785833992)(https://gitcode.net/apachecn/apachecn-dl-zh/-/raw/master/docs/handson-nlp-pt-1x/img/B12365_08_21.jpg)]

图 8.21 –错误的输出

很明显,我们已经创建了一个聊天机器人,能够进行简单的来回对话。 但是,我们的聊天机器人要通过图灵测试并说服我们我们实际上正在与人类交谈,我们还有很长的路要走。 但是,考虑到我们训练模型所涉及的数据量相对较小,在序列到序列模型中使用注意已显示出相当不错的结果,证明了这些架构的通用性。

虽然最好的聊天机器人是在数十亿个数据点的庞大数据集上进行训练的,但事实证明,相对较小的聊天机器人,该模型是相当有效的。 但是,基本注意力网络已不再是最新技术,在下一章中,我们将讨论 NLP 学习的一些最新发展,这些发展已使聊天机器人更加逼真。

总结

在本章中,我们运用了从循环模型和序列到序列模型中学到的所有知识,并将它们与注意力机制结合起来,构建了一个可以正常工作的聊天机器人。 尽管与聊天机器人进行对话与与真实的人交谈并不太容易,但是我们可能希望通过一个更大的数据集来实现一个更加现实的聊天机器人。

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

0 人点赞