zl程序教程

您现在的位置是:首页 >  前端

当前栏目

【NLP】第 5 章:循环神经网络和情感分析

循环神经网络 分析 NLP 情感
2023-09-14 09:06:10 时间

     🔎大家好,我是Sonhhxg_柒,希望你看完之后,能对你有所帮助,不足请指正!共同学习交流🔎

📝个人主页-Sonhhxg_柒的博客_CSDN博客 📃

🎁欢迎各位→点赞👍 + 收藏⭐️ + 留言📝​

📣系列专栏 - 机器学习【ML】 自然语言处理【NLP】  深度学习【DL】

 🖍foreword

✔说明⇢本人讲解主要包括Python、机器学习(ML)、深度学习(DL)、自然语言处理(NLP)等内容。

如果你对这个系列感兴趣的话,可以关注订阅哟👋

目录

技术要求

构建 RNN

使用 RNN 进行情绪分析

爆炸和收缩梯度

介绍 LSTM

使用 LSTM

LSTM 细胞

双向 LSTM

使用 LSTM 构建情绪分析器

预处理数据

模型架构

训练模型

使用我们的模型进行预测

在 Heroku 上部署应用程序

介绍 Heroku

使用 Flask 创建 API – 文件结构

使用 Flask 创建 API – API 文件

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

概括


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

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

本章涵盖以下主题:

  • 构建 RNN
  • 使用 LSTM
  • 使用 LSTM 构建情绪分析器
  • 在 Heroku 上部署应用程序

技术要求

Heroku 可以从www.heroku.com安装。数据取自UCI Machine Learning Repository: Sentiment Labelled Sentences Data Set

构建 RNN

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

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

图 5.1 – 循环层

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

图 5.2 – 时间步长序列

该层用于n 个时间步长的输入。我们的隐藏状态在状态0中初始化,然后使用我们的第一个输入1来计算下一个隐藏状态1。还学习了两组权重矩阵——矩阵U,它学习隐藏状态如何变化在时间步长之间,以及矩阵W,它学习每个输入步长如何影响隐藏状态。

我们还将tanh激活函数应用于生成的乘积,将隐藏状态的值保持在 -1 和 1 之间。计算任何隐藏状态的等式t变为以下:

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

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

使用 RNN 进行情绪分析

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

图 5.3 – 分类模型

现在,我们将强调其中一个问题使用 RNN——爆炸和收缩梯度——以及我们如何使用梯度裁剪来解决这个问题。

爆炸和收缩梯度

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

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

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

图 5.4 – 渐变裁剪的比较

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

介绍 LSTM

虽然 RNN 允许我们使用单词序列作为模型的输入,但它们远非完美。循环神经网络有两个主要缺陷,可以通过使用更复杂的 RNN 版本(称为LSTM )来部分弥补。

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

考虑一个很长的段落,我们试图预测下一个单词。这句话以我学习数学开始……我的期末考试在……结束。直觉上,我们希望下一个词是数学或一些与数学相关的领域。然而,在长序列的 RNN 模型中,我们的隐藏状态可能难以在到达句子结尾时保留句子开头的信息,因为它需要多个更新步骤。

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

在另一个例子中,我们再次想要预测句子中缺失的单词,但它现在出现在开头而不是结尾。我们有我长大的句子……所以我可以说流利的荷兰语。在这里,我们可以从他们说荷兰语的事实中直观地猜测出这个人是在荷兰长大的。然而,因为 RNN 会按顺序解析这些信息,所以它只会使用我在……中长大来进行预测,而忽略了句子中的其他关键上下文。

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

使用 LSTM

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

这部电影太棒了。星期二下午我和我的妻子和女儿一起去看了它。虽然我没想到它会很有趣,但结果却很有趣。如果有机会,我们肯定会再回去看看。

在情感分析中,很明显并非句子中的所有单词都与确定它是正面评论还是负面评论相关。我们将重复这句话,但这次强调与衡量评论情绪相关的词:

这部电影太棒了。星期二下午我和我的妻子和女儿一起去看了它。虽然我没想到它会很有趣,但结果却很有趣。如果有机会,我们肯定会再回去看看。

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

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


图 5.5 – LSTM 单元

LSTM 细胞

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

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

虽然这看起来比 RNN 更令人生畏,但我们将解释 LSTM 的每个组件细胞依次。我们将首先看一下遗忘门(由粗体矩形):

图 5.7 – 遗忘门

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

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

图 5.8 – 输入门

输入门再次获取连接的先前隐藏状态t-1和当前序列输入t,并将其通过具有学习参数的 sigmoid 函数,该函数输出另一个矩阵t,该矩阵由 0 到1. 级联隐藏状态和序列输入也通过一个 tanh 函数,该函数将输出压缩在 -1 和 1 之间。这乘以t矩阵。这意味着生成它所需的学习参数可以有效地学习在我们的细胞状态中应该从当前时间步保留哪些元素。然后将其添加到当前单元格状态以获得我们的最终单元格状态,该状态将延续到下一个时间步。

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

图 5.9 – 输出门

输出门计算 LSTM 单元的最终输出——单元状态和隐藏状态,并传递到下一步。单元状态t与前两个步骤相比没有变化是遗忘门和输入门的乘积。最终的隐藏状态t是通过将连接的先前隐藏状态t-1和当前时间步输入t计算出来的,并通过带有一些学习参数的 sigmoid 函数得到输出门输出,t。最终单元状态t通过 tanh 函数并乘以输出门输出t以计算最终隐藏状态t. 这意味着输出门上的学习参数有效地控制了先前隐藏状态和当前输出的哪些元素与最终单元状态相结合,以作为新的隐藏状态传递到下一个时间步。

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

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

双向 LSTM

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

双向 LSTM 同时以正序和逆序处理序列,保持两个隐藏状态。我们将调用前向隐藏状态t并将t用于反向隐藏状态:

图 5.10 – 双向 LSTM 过程

在这里,我们可以看到我们在整个过程中维护了这两个隐藏状态,并使用它们来计算最终的隐藏状态t。因此,如果我们希望计算时间步t的最终隐藏状态,我们使用前向隐藏状态t,它已经看到所有单词,包括输入t,以及反向隐藏状态t,它已经看到了 x t 之后并包括t的所有单词。因此,我们的最终隐藏状态t包括已经看到句子中所有单词的隐藏状态,而不仅仅是单词发生在时间步t之前。这意味着可以更好地捕捉整个句子中任何给定单词的上下文。事实证明,与传统的单向 LSTM 相比,双向 LSTM 在多个 NLP 任务上提供了更好的性能。

使用 LSTM 构建情绪分析器

我们现在来看如何构建我们自己的简单 LSTM 来根据句子的情绪对句子进行分类。我们将在一个包含 3000 条评论的数据集上训练我们的模型,这些评论被归类为正面或消极的。这些评论来自三个不同的来源——电影评论、产品评论和位置评论——以确保我们的情绪分析器是稳健的。数据集是平衡的,因此它包含 1,500 条正面评论和 1,500 条负面评论。我们将首先导入我们的数据集并检查它:

with open("sentiment labelled sentences/sentiment.txt") as f:
    reviews = f.read()
   
data = pd.DataFrame([review.split('\t') for review in reviews.split('\n')])                     
data.columns = ['Review','Sentiment']
data = data.sample(frac=1)

这将返回以下输出:

图 5.11 – 数据集的输出

我们从文件中读取数据集。我们的数据集是制表符分隔的,所以我们用制表符和换行符。我们重命名列,然后使用示例函数随机打乱我们的数据。查看我们的数据集,我们需要做的第一件事是预处理我们的句子以将它们输入到我们的 LSTM 模型中。

预处理数据

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

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

reviews, vocab = split_words_reviews(data)
reviews[0]

这导致以下输出:

图 5.12 – NTLK 标记化的输出

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

为了让我们的句子充分准备好进入神经网络,我们必须将我们的单词转换成数字。为了做到这一点,我们创建了几个字典,这将允许我们将数据从单词转换为索引,从索引转换为单词。为此,我们只需遍历我们的语料库并为每个唯一单词分配一个索引:

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

word_to_int_dict, int_to_word_dict = create_dictionaries(vocab)
int_to_word_dict

这给出了以下输出:

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

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

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

这给出了以下内容:

图 5.14 – 长度值

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

我们将创建一个函数,允许我们填充我们的句子,使它们的大小都相同。对于短于序列长度的评论,我们用空标记填充它们。对于超过序列长度的评论,我们只需删除超过最大序列长度的任何标记:

def pad_text(tokenized_reviews, seq_length):    
    reviews = []    
    for review in tokenized_reviews:
        if len(review) >= seq_length:
            reviews.append(review[:seq_length])
        else:
            reviews.append(['']*(seq_length-len(review)) + review)        
    return np.array(reviews)

padded_sentences = pad_text(reviews, seq_length = 50)
padded_sentences[0]

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

图 5.15 – 填充句子

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

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

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

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

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

图 5.16 – 对句子进行编码

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

模型架构

我们的模型将包括几个主要部分。除了许多神经网络共有的输入和输出层外,我们首先需要一个嵌入层。这是为了让我们的模型学习向量的表示它正在接受训练的话。我们可以选择使用预先计算的嵌入(例如 GLoVe),但出于演示目的,我们将训练自己的嵌入层。我们的输入序列通过输入层输入并作为向量序列输出。

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

图 5.17 – 模型架构

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

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

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

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

接下来,我们需要在模型类中定义前向传递。在这个前向传递中,我们只是将一层的输出链接在一起,成为下一层的输入。在这里,我们可以看到我们的嵌入层将input_words作为输入并输出嵌入的单词。然后,我们的 LSTM 层将嵌入的单词作为输入并输出lstm_out。这里唯一的细微差别是我们使用view()将 LSTM 输出中的张量重塑为正确的大小,以便输入到我们的全连接层。这同样适用于重塑我们隐藏层的输出以匹配我们的输出节点的输出。请注意,我们的输出将返回class = 0class = 1的预测,因此我们对输出进行切片以仅返回类别 = 1的预测——即我们的句子为正的概率:

def forward (self, input_words):                          
        embedded_words = self.embedding(input_words)
        lstm_out, h = self.lstm(embedded_words)
        lstm_out = self.dropout(lstm_out)
        lstm_out = lstm_out.contiguous().view(-1,self.n_hidden)                             
        fc_out = self.fc(lstm_out)                  
        sigmoid_out = self.sigmoid(fc_out)              
        sigmoid_out = sigmoid_out.view(batch_size, -1)          
        sigmoid_last = sigmoid_out[:, -1]
       
        return sigmoid_last, h

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

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

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

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

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

训练模型

为了训练我们的模型,我们必须首先定义我们的数据集。我们将使用训练数据集训练我们的模型,在验证集上的每一步评估我们训练的模型,最后,使用看不见的测试数据集测量我们模型的最终性能。我们使用与验证训练分开的测试集的原因是,我们可能希望根据验证集的损失来微调我们的模型超参数。如果我们这样做,我们最终可能会选择仅在该特定验证数据集上性能最佳的超参数。我们针对一个看不见的测试集评估最后一次,以确保我们的模型能够很好地泛化到它在训练循环的任何部分之前从未见过的数据。

我们已经将模型输入 ( x ) 定义为encoded_sentences,但我们还必须定义模型输出 ( y )。我们这样做很简单,如下所示:

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

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

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

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

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

train_data = TensorDataset(train_x, train_y)
valid_data = TensorDataset(valid_x, valid_y)
test_data = TensorDataset(test_x, test_y)

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

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

现在我们已经为三个数据集定义了DataLoader对象,我们定义了我们的训练循环。我们首先定义了一些超参数,它们将在我们的训练循环。最重要的是,我们将损失函数定义为二元交叉熵(因为我们正在处理预测单个二元类),我们将优化器定义为Adam,学习率为0.001。我们还定义了我们的模型以运行少量的 epoch(以节省时间)并设置clip = 5来定义我们的渐变剪裁:

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

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

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

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

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

如果我们自己运行这个循环,我们将训练我们的模型。但是,我们希望在每个 epoch 之后评估我们的模型性能,以确定它在验证数据集上的性能。我们这样做如下:

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()

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

图 5.18 – 训练模型

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

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

在训练我们的模型三个 epoch 之后,我们注意到两个主要的事情。我们将首先从好消息开始——我们的模型正在学习一些东西!不仅我们的训练损失下降了,而且我们还可以看到我们在验证集上的损失在每个 epoch 之后都下降了。这意味着我们的模型在仅仅三个时期之后就可以更好地预测一组看不见的数据的情绪!然而,坏消息是我们的模型严重过度拟合。我们的训练损失远低于验证损失,这表明虽然我们的模型已经学会了如何很好地预测训练数据集,但这并不能很好地推广到看不见的数据集。这预计会像我们一样发生使用非常小的训练数据集(只有 2,400 个训练句子)。由于我们正在训练整个嵌入层,有可能许多单词在训练集中只出现一次,而从不在验证集中出现,反之亦然,这使得模型实际上不可能泛化其中的所有不同种类的单词我们的语料库。在实践中,我们希望在更大的数据集上训练我们的模型,让我们的模型学习如何更好地泛化。我们还在很短的时间内训练了这个模型,并且没有执行超参数调整来确定我们模型的最佳迭代。随意尝试更改模型中的一些参数(例如训练时间、隐藏状态大小、嵌入大小等)以提高模型的性能。

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

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

    preds = torch.round(test_output.squeeze())
    correct_tensor = preds.eq(labels.float().view_as(preds))
    correct = np.squeeze(correct_tensor.numpy())
    num_correct += np.sum(correct)
    
print("Test Loss: {:.4f}".format(np.mean(test_losses)))
print("Test Accuracy: {:.2f}".format(num_correct/len(test_loader.dataset))) 

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

图 5.19 – 输出值

然后,我们将我们的模型预测与我们的真实标签进行比较,以获得正确的张量,这是一个评估我们模型的每个预测是否正确的向量。然后我们对这个向量求和,然后除以它的长度,得到我们模型的总准确率。在这里,我们得到了 76% 的准确率。虽然我们的模型肯定远非完美,但考虑到我们非常小的训练集和有限的训练时间,这一点也不差!这个只是用来说明 LSTM 在从 NLP 数据中学习时有多么有用。接下来,我们将展示如何使用我们的模型从新数据中进行预测。

使用我们的模型进行预测

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

def preprocess_review(review):
    review = review.translate(str.maketrans('', '',punctuation)).lower().rstrip()
    tokenized = word_tokenize(review)
    if len(tokenized) >= 50:
        review = tokenized[:50]
    else:
        review= ['0']*(50-len(tokenized)) + tokenized    
    final = []    
    for token in review:
        try:
            final.append(word_to_int_dict[token])            
        except:
            final.append(word_to_int_dict[''])
        
    return final

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

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

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

    msg = "This is a positive review." if output >= 0.5 else  "This is a negative review."

    print(msg)
    print('Prediction = ' + str(output))

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

predict("It was not good")

这导致以下输出:

图 5.20 – 正值的预测字符串

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

predict("It was not good")

这将产生以下输出:

图 5.21 – 负值的预测字符串

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

在 Heroku 上部署应用程序

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

介绍 Heroku

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

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

heroku login

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

heroku create sentiment-analysis-flask-api

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

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

使用 Flask 创建 API – 文件结构

创建 API 相当简单使用 Flask 作为 Flask 包含一个制作 API 所需的默认模板:

首先,在命令行中,创建您的烧瓶 API 的新文件夹并导航到它:

mkdir flaskAPI
cd flaskAPI

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

python3 -m venv vir_env

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

pip install nltk pandas numpy torch flask gunicorn

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

pip freeze > requirements.txt

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

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

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

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

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

使用 Flask 创建 API – API 文件

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

  1. 我们首先进行我们所有的进口并创建一个预测路线。这允许我们使用predict参数调用我们的 API,以便在我们的 API 中运行predict()方法:
    import flask
    from flask import Flask, jsonify, request
    import json
    import pandas as pd
    from string import punctuation
    import numpy as np
    import torch
    from nltk.tokenize import word_tokenize
    from torch.utils.data import TensorDataset, DataLoader
    from torch import nn
    from torch import optim
    app = Flask(__name__)
    @app.route('/predict', methods=['GET'])

  2. 接下来,我们在app.py文件中定义predict()方法。这在很大程度上是我们模型文件的重新散列,因此为避免代码重复,建议您查看本章技术要求部分链接的 GitHub 存储库中完整的app.py文件。你会看到还有一些额外的线。首先,在我们的preprocess_review()函数中,我们将看到以下几行:
    with open('models/word_to_int_dict.json') as handle:
    word_to_int_dict = json.load(handle)

    这需要我们在主模型笔记本中计算的word_to_int字典并将其加载到我们的模型。这是为了让我们的词索引与我们训练的模型一致。然后我们使用这个字典将我们的输入文本转换成一个编码序列。请务必从原始笔记本输出中获取word_to_int_dict.json文件并将其放在模型目录中。

  3. 同样,我们还必须从我们训练的模型中加载权重。我们首先定义我们的SentimentLSTM类并使用torch.load加载我们的权重。我们将使用原始笔记本中的.pkl文件,因此请务必将其放在模型目录中:
    model = SentimentLSTM(5401, 50, 100, 1, 2)
    model.load_state_dict(torch.load("models/model_nlp.pkl"))

  4. 我们还必须定义 API 的输入和输出。我们希望我们的模型从我们的 API 中获取输入并将其传递给我们的preprocess_review()函数。我们使用request.get_json()来做到这一点:
    request_json = request.get_json()
    i = request_json['input']
    words = np.array([preprocess_review(review=i)])

  5. 为了定义我们的输出,我们返回一个 JSON 响应,其中包含我们模型的输出和一个响应代码200,这是我们的预测函数返回的:
    output = model(x)[0].item()
    response = json.dumps({'response': output})
    return response, 200

  6. 随着我们的主体应用程序完成后,我们必须添加另外两个额外的东西才能使我们的 API 运行。我们必须首先将以下内容添加到我们的wsgi.py文件中:
    from app import app as application
    if __name__ == "__main__":
        application.run()

  7. 最后,将以下内容添加到我们的 Procfile 中:
    web: gunicorn app:app --preload

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

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

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

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

如果一切正常,您应该会收到来自 API 的预测。现在我们的 API 在本地进行预测,是时候将其托管在 Heroku 上,以便我们可以在云中进行预测。

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

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

git init

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

vir_env
__pycache__/
.DS_Store

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

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

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

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

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

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

概括

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

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