精通 PyTorch——深度循环模型架构

263 阅读34分钟

神经网络是强大的机器学习工具,用于帮助我们学习数据集的输入 (X) 和输出 (y) 之间的复杂模式。在第二章《深度卷积神经网络架构》中,我们讨论了卷积神经网络,它们学习了X与y之间的一对一映射关系;也就是说,每个输入X是相互独立的,数据集中的每个输出y也是相互独立的。

在上一章中,我们将CNN模型与循环模型(LSTM)结合,构建了一个图像字幕生成器。在本章中,我们将进一步扩展循环模型。我们将讨论一类可以对序列进行建模的神经网络,其中X(或y)不仅仅是一个独立的数据点,而是一个时间序列的数据点 [X1, X2, .. Xt](或 [y1, y2, .. yt])。需要注意的是,X2(即时间步长2的数据点)依赖于X1,X3依赖于X2和X1,依此类推。

这种网络被归类为循环神经网络(RNNs)。这些网络能够通过在模型中加入额外的权重来创建网络中的循环,从而对数据的时间特性进行建模。这有助于维护一个状态,如下图所示:

image.png

循环的概念解释了“循环性”这个术语,这种循环性有助于在这些网络中建立记忆的概念。基本上,这类网络允许在时间步t使用中间输出作为时间步t+1的输入,同时维持一个隐藏的内部状态。这些跨时间步的连接称为循环连接。

本章将重点介绍多年来开发的各种循环神经网络架构,如不同类型的RNNs、长短期记忆网络(LSTM)和门控循环单元(GRUs)。我们将使用PyTorch来实现其中一些架构,并在实际的序列建模任务中训练和测试循环模型。除了模型的训练和测试外,我们还将学习如何高效地使用PyTorch加载和预处理序列数据。在本章结束时,你将能够使用PyTorch中的RNNs解决带有序列数据集的机器学习问题。

本章涵盖以下主题:

  • 探索循环网络的演变
  • 训练RNNs进行情感分析
  • 构建双向LSTM
  • 讨论GRUs和基于注意力的模型

本章的所有代码文件可以在以下地址找到:github.com/arj7192/Mas…

探索循环网络的演变

循环网络自20世纪80年代以来就已经存在。在本节中,我们将探索自其诞生以来循环网络架构的演变过程。我们将讨论并分析RNN架构演变过程中关键里程碑上的发展。 在深入了解时间线之前,我们将快速回顾不同类型的RNNs,以及它们与一般前馈神经网络的关系。

循环神经网络的类型

虽然大多数监督机器学习模型只能建模一对一的关系,但RNNs能够建模以下类型的输入-输出关系:

  • 多对多(瞬时)
    例子:命名实体识别——给定一句话/文本,为其中的单词标注命名实体类别,如人名、组织、地点等。
  • 多对多(编码器-解码器)
    例子:机器翻译(如从英文翻译到德文)——接受一句自然语言的句子/文本,将其编码为一个固定大小的表示,并解码该表示以生成另一种语言的等效句子/文本。
  • 多对一
    例子:情感分析——给定一句话/文本,将其分类为积极、消极、中性等。
  • 一对多
    例子:图像字幕生成——给定一张图像,生成描述该图像的句子/文本。
  • 一对一(虽然不太有用)
    例子:图像分类(通过按顺序处理图像像素)。

下图展示了这些RNN类型与常规神经网络的对比:

image.png

注意
在第3章《结合CNNs和LSTMs》中,我们在图像字幕生成练习中提供了一个一对多循环神经网络的例子。

正如我们所看到的,循环神经网络架构中具有在常规神经网络中不存在的循环连接。这些循环连接在上图中沿时间维度展开。下图展示了RNN在时间折叠形式和时间展开形式下的结构:

image.png

在接下来的部分中,我们将使用时间展开版本来演示RNN架构。在前面的图中,我们将RNN层标记为红色,作为神经网络的隐藏层。虽然网络看起来似乎只有一个隐藏层,但当这个隐藏层沿时间维度展开时,我们可以看到网络实际上有T个隐藏层。这里的T是序列数据中的时间步总数。

RNN的一个强大功能是它们可以处理具有不同序列长度(T)的序列数据。处理这种长度变化的一种方法是对较短的序列进行填充并截断较长的序列,正如我们将在本章后面的练习中看到的那样。

接下来,我们将深入探讨循环架构的历史和演变,从基本的RNNs开始。

RNNs

RNN的思想随着1982年Hopfield网络的出现而变得清晰起来,Hopfield网络是一种特殊类型的RNN,试图模拟人类记忆的工作原理。RNNs后来在1986年基于David Rumelhart等人的工作逐渐形成。RNNs能够处理带有内在记忆概念的序列。从此,RNN架构进行了系列改进,如下图所示:

image.png

上图并未涵盖RNN架构演变的全部历史,但它涵盖了重要的里程碑。接下来,我们将按时间顺序讨论RNN的继任者,从双向RNN开始。

双向RNNs

虽然RNN在处理序列数据方面表现良好,但后来发现某些与序列相关的任务(例如语言翻译)可以通过同时考虑过去和未来的信息更为高效。例如,英语中的“I see you”翻译成法语为“Je te vois”。这里,“te”表示“you”,“vois”表示“see”。因此,为了正确地将英语翻译成法语,我们需要先获取英语中的所有三个单词,才能写出法语中的第二和第三个单词。

为了克服这一限制,双向RNNs在1997年被发明出来。这种网络与传统RNNs非常相似,但双向RNNs在内部有两个RNN在工作:一个从头到尾运行序列,另一个从尾到头逆向运行序列,如下图所示:

image.png

接下来,我们将学习LSTM。

LSTMs

尽管RNN能够处理序列数据并记住信息,但它们存在梯度爆炸和消失的问题。这是由于在时间维度上展开循环网络后形成的极深网络所导致的。

1997年,一种不同的方法被提出。RNN的单元被一种更复杂的记忆单元——长短期记忆(LSTM)单元所取代。通常,RNN单元使用的是sigmoid或tanh激活函数。

选择这些函数是因为它们能够将输出控制在0(无信息流动)到1(完全信息流动)之间,对于tanh来说,则是-1到1之间。

此外,tanh函数还具有提供0均值输出值和通常较大梯度的优势——这两点都有助于更快的学习(收敛)。这些激活函数应用于当前时间步输入与前一时间步隐藏状态的连接,如下图所示:

image.png

在反向传播过程中,由于梯度在时间展开的RNN单元之间的乘积,梯度要么逐渐减小,要么不断增大。因此,虽然RNN能够记住短序列中的顺序信息,但由于乘法次数的增加,它们在处理长序列时往往会遇到困难。LSTM通过使用门控机制来控制其输入和输出,从而解决了这一问题。

LSTM层本质上由多个时间展开的LSTM单元组成。信息以单元状态的形式从一个单元传递到另一个单元。这些单元状态通过门控机制的乘法和加法来控制或操作。

如下图所示,这些门控制了信息向下一个单元的流动,同时保留或遗忘来自前一个单元的信息:

image.png

LSTM革新了循环网络,因为它们能够高效处理更长的序列。接下来,我们将讨论LSTM的更高级变体。

扩展的和双向LSTM

最初,LSTM在1997年被发明时只有输入门和输出门。不久之后,在2000年,带有遗忘门的扩展LSTM被开发出来,并成为如今最常用的版本。几年后,在2005年,双向LSTM被开发出来,它们的概念与双向RNN类似。

多维RNNs

在2007年,多维RNN(MDRNNs)被发明。在这里,RNN单元之间的单一循环连接被替换为与数据维度数量相同的多个连接。这在视频处理等领域非常有用,因为视频数据本质上是一个由图像序列组成的二维数据。

堆叠LSTM

虽然单层LSTM网络确实解决了梯度消失和爆炸的问题,但在多层LSTM层的堆叠上证明了其在学习各种序列处理任务中的高度复杂模式时更加有用,例如语音识别。

这些强大的模型被称为堆叠LSTM。下图展示了一个带有两层LSTM的堆叠LSTM模型:

image.png

LSTM单元本质上是在LSTM层的时间维度上堆叠的。在空间维度上堆叠多个这样的层,为它们提供了额外的深度。然而,这些模型的缺点是,由于它们具有额外的深度和额外的循环连接,训练速度显著减慢。此外,额外的LSTM层在每次训练迭代中都需要展开(在时间维度上)。因此,通常情况下,堆叠的循环模型的训练无法并行化。

GRUs

LSTM单元有两种状态——内部状态和外部状态,并且有三个不同的门——输入门、遗忘门和输出门。类似的单元,名为门控循环单元(GRU),于2014年发明,目的是在有效解决梯度爆炸和消失问题的同时,学习长期依赖关系。

GRU只有一个状态,并且只有两个门——重置门(输入门和遗忘门的结合)和更新门。下图展示了一个GRU网络:

image.png

接下来是网格LSTM。

网格LSTM

一年后,在2015年,网格LSTM模型被开发出来,作为MDLSTM模型的继任者,是多维RNNs的LSTM等价模型。在网格LSTM模型中,LSTM单元被排列成一个多维网格。这些单元沿着数据的时空维度以及网络层之间连接。

门控正交循环单元

在2017年,门控正交循环单元被设计出来,将GRU和单一RNN的思想结合在一起。单一RNN基于使用单位矩阵(即正交矩阵)作为RNN的隐藏状态循环矩阵的想法,以解决梯度爆炸和消失的问题。这之所以有效,是因为偏离的梯度归因于隐藏到隐藏权重矩阵的特征值偏离1。为了解决梯度问题,这些矩阵被正交矩阵替代。你可以在原始论文中阅读更多关于单一RNN的信息【1】。

在本节中,我们简要概述了循环神经网络架构的演变。接下来,我们将通过一个基于文本分类任务的简单RNN模型架构的练习,深入了解RNNs。我们还将探讨PyTorch在处理序列数据以及构建和评估循环模型方面的重要作用。

训练RNN进行情感分析

在本节中,我们将使用PyTorch训练一个RNN模型来完成文本分类任务——情感分析。该任务中,模型接收一段文本(一个单词序列)作为输入,并输出1(表示积极情感)或0(表示消极情感)。为了将文本转换为1和0,我们需要借助分词和嵌入技术。

分词 是将单词转换为数字标记或整数的过程,如我们在本练习中将看到的那样。句子等同于一个数字数组,其中数组中的每个数字代表一个单词。虽然分词为我们提供了每个单词的整数索引,但我们仍然希望将每个单词表示为一个向量——作为词语的特征向量——在词语的特征空间中进行表示。为什么呢?因为单词包含的信息无法仅仅用一个数字来表示。这种将单词表示为向量的过程称为嵌入,我们将在稍后在此练习中使用。嵌入矩阵可以在模型训练过程中学习,作为词向量的查找表。如果某个单词的标记索引是123,那么该单词的嵌入是嵌入矩阵中第123行包含的向量。

对于这个涉及序列数据的二分类任务,我们将使用一个单向单层RNN。

在训练模型之前,我们将手动处理文本数据并将其转换为可用的数字形式。在训练模型后,我们将在一些样本文本上对其进行测试。我们将演示如何使用PyTorch的各种功能来高效地执行此任务。本练习的代码可以在我们的GitHub仓库中找到【2】。

加载和预处理文本数据集

对于本次练习,我们需要导入一些依赖项:

首先,执行以下导入语句:

import os
import time
import numpy as np
from tqdm import tqdm
from string import punctuation
from collections import Counter
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
torch.use_deterministic_algorithms(True)

除了导入常规的torch依赖项外,我们还导入了用于文本处理的punctuationCounter。我们还导入了matplotlib来显示图像,numpy用于数组操作,tqdm用于可视化进度条。除了导入之外,我们还设置了随机种子以确保此练习的可重复性,如代码片段的最后一行所示。

接下来,我们将从文本文件中读取数据。对于本次练习,我们将使用IMDb情感分析数据集【3】。该IMDb数据集包含了多部电影评论的文本及其相应的情感标签(正面或负面)。首先,我们将下载数据集并运行以下代码行,以读取和存储文本列表及其对应的情感标签:

# 从文本文件中读取情感和评论数据
review_list = []
label_list = []
for label in ['pos', 'neg']:
    for fname in tqdm(os.listdir(
        f'./aclImdb/train/{label}/')):
        if 'txt' not in fname:
            continue
        with open(os.path.join(f'./aclImdb/train/{label}/',
                              fname), encoding="utf8") as f:
            review_list += [f.read()]
            label_list += [label]
print('Number of reviews :', len(review_list))

这将输出以下内容:

Number of reviews : 25000

正如我们所见,总共有25000条电影评论,其中12500条是正面的,12500条是负面的。

数据集引用
Andrew L. Maas, Raymond E. Daly, Peter T. Pham, Dan Huang, Andrew Y. Ng, and Christopher Potts. (2011). Learning Word Vectors for Sentiment Analysis. The 49th Annual Meeting of the Association for Computational Linguistics (ACL 2011).

在数据加载步骤之后,我们将开始处理文本数据,如下所示:

# 预处理评论文本
review_list = [review.lower() for review in review_list]
review_list = [''.join([letter for letter in review
                        if letter not in punctuation])
                        for review in tqdm(review_list)]
# 将所有评论文本汇总在一起
reviews_blob = ' '.join(review_list)
# 生成所有评论中所有单词的列表
review_words = reviews_blob.split()
# 获取单词计数
count_words = Counter(review_words)
# 按计数排序单词(降序排列)
total_review_words = len(review_words)
sorted_review_words = count_words.most_common(total_review_words)
print(sorted_review_words[:10])

这将输出以下内容:

[('the', 334691), ('and', 162228), ('a', 161940), ('of', 145326), ('to', 135042), ('is', 106855), ('in', 93028), ('it', 77099), ('i', 75719), ('this', 75190)]

如你所见,首先,我们将整个文本语料库转换为小写,然后从评论文本中删除所有标点符号。接着,我们将所有评论中的单词汇总在一起,获取单词计数,并按计数的降序对单词进行排序,以查看最常用的单词。请注意,最常用的单词都是非名词,如限定词、代词等,如上面的输出所示。

理想情况下,这些非名词(也称为停用词)应从语料库中删除,因为它们不携带太多意义。然而,为了简化,我们将跳过这些高级的文本处理步骤。

我们将继续进行数据处理,将这些单词转换为数字或标记。这是一个关键步骤,因为机器学习模型只能理解数字,而非单词:

# 创建单词到整数(标记)的字典
# 以便将文本编码为数字
vocab_to_token = {word:idx+1 for idx,
                  (word, count) in enumerate(sorted_review_words)}
print(list(vocab_to_token.items())[:10])

这将输出以下内容:

[('the', 1), ('and', 2), ('a', 3), ('of', 4), ('to', 5), ('is', 6), ('in', 7), ('it', 8), ('i', 9), ('this', 10)]

从最常用的单词开始,依次将数字分配给单词,从1开始。

我们在上一步中获得了单词到整数的映射,也就是我们数据集的词汇表。在这一步中,我们将使用该词汇表将数据集中的电影评论翻译为数字列表:

reviews_tokenized = []
for review in review_list:
    word_to_token = [vocab_to_token[word] for word in 
                     review.split()]
    reviews_tokenized.append(word_to_token)
print(review_list[0])
print()
print(reviews_tokenized[0])

这将输出如下内容:

for a movie that gets no respect there sure are a lot of memorable quotes listed for this gem imagine a movie where joe piscopo is actually funny maureen stapleton is a scene stealer the moroni character is an absolute scream watch for alan the skipper hale jr as a police sgt
[15, 3, 17, 11, 201, 56, 1165, 47, 242, 23, 3, 168, 4, 891, 4325, 3513, 15, 10, 1514, 822, 3, 17, 112, 884, 14623, 6, 155, 161, 7307, 15816, 6, 3, 134, 20049, 1, 32064, 108, 6, 33, 1492, 1943, 103, 15, 1550, 1, 18993, 9055, 1809, 14, 3, 549, 6906]

我们还将情感标签“pos”和“neg”分别编码为数字1和0:

# 将情感编码为0或1
encoded_label_list = [1 if label =='pos'
                      else 0 for label in label_list]
reviews_len = [len(review) for review in reviews_tokenized]
reviews_tokenized = [reviews_tokenized[i] 
                     for i, l in enumerate(reviews_len)
                     if l>0 ]
encoded_label_list = np.array([encoded_label_list[i]
                              for i, l in enumerate(reviews_len)
                              if l> 0 ], dtype''float3'')

在训练模型之前,我们需要进行最后的数据处理步骤。不同的评论可能具有不同的长度。然而,我们将为一个固定的序列长度定义我们的简单RNN模型。因此,我们需要对不同长度的评论进行规范化,使它们都具有相同的长度。

为此,我们将定义一个序列长度L(在本例中为512),然后对长度小于L的序列进行填充,并截断长度超过L的序列:

def pad_sequence(reviews_tokenized, sequence_length):
    '''返回填充''s或截断为sequence_length的标记化评论序列。'''
    padded_reviews = np.zeros((len(reviews_tokenized), 
                               sequence_length),
                               dtype=int)
    for idx, review in enumerate(reviews_tokenized):
        review_len = len(review)
        if review_len <= sequence_length:
            zeroes = list(np.zeros(
                sequence_length-review_len))
            new_sequence = zeroes + review
        elif review_len > sequence_length:
            new_sequence = review[0:sequence_length]
        padded_reviews[idx,:] = np.array(new_sequence)
    return padded_reviews

sequence_length = 512
padded_reviews = pad_sequence(reviews_tokenized=reviews_tokenized, 
             sequence_length=sequence_length)
plt.hist(reviews_len);

输出如下所示:

image.png

正如我们所见,大多数评论的长度都在500字以下,因此我们选择了512(2的幂次方)作为我们模型的序列长度,并相应地修改了长度不正好为512个单词的序列。

最后,我们可以开始训练模型。为此,我们必须将数据集按75:25的比例划分为训练集和验证集:

train_val_split = 0.75
train_X = padded_reviews[:int(train_val_split * len(padded_reviews))]
train_y = encoded_label_list[:int(train_val_split * len(padded_reviews))]
validation_X = padded_reviews[int(train_val_split * len(padded_reviews)):]
validation_y = encoded_label_list[int(train_val_split * len(padded_reviews)):]

在此阶段,我们可以开始使用PyTorch从处理后的数据生成数据集和数据加载器对象:

# 生成torch数据集
train_dataset = TensorDataset(
    torch.from_numpy(train_X).to(device),
    torch.from_numpy(train_y).to(device))
validation_dataset = TensorDataset(
    torch.from_numpy(validation_X).to(device),
    torch.from_numpy(validation_y).to(device))

batch_size = 32

# torch数据加载器(打乱数据)
train_dataloader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
validation_dataloader = DataLoader(validation_dataset, batch_size=batch_size, shuffle=True)

为了在将数据传入模型之前对其进行感性理解,让我们可视化一批32条评论及其对应的情感标签:

# 获取一批训练数据
train_data_iter = iter(train_dataloader)
X_example, y_example = next(train_data_iter)

# batch_size, seq_length
print('Example Input size: ', X_example.size()) 
print('Example Input:\n', X_example)
print()

# batch_size
print('Example Output size: ', y_example.size()) 
print('Example Output:\n', y_example)

输出如下:

Example Input size:  torch.Size([32, 512])
Example Input:
tensor([[    0,     0,     0,  ...,    31,   183,   472],
[    0,     0,     0,  ...,   410,     7,  1272],
[    0,     0,     0,  ...,     5,     3, 27493],
...,
[    0,     0,     0,  ...,    63,     4,  3226],
[    0,     0,     0,  ...,    89,   713,     8],
[    0,     0,     0,  ...,    22,    15,     8]])

Example Output size:  torch.Size([32])
Example Output:
tensor([1., 1., 1., 0., 0., 1., 0., 1., 1., 1., 0., 1., 0., 1., 0., 1., 0., 1., 1., 1., 0., 1., 1., 0., 1., 1., 1., 1., 1., 1., 1., 1.])

在将文本数据加载并处理为数字标记序列后,接下来我们将在PyTorch中创建RNN模型对象并训练RNN模型。

实例化和训练模型

现在我们已经准备好了数据集,可以实例化我们的单向单层RNN模型。首先,PyTorch通过其nn.RNN模块使RNN层的实例化变得非常紧凑。它只需要输入/嵌入维度、隐藏状态维度和层数。让我们开始吧:

让我们定义自己的RNN包装类。这个类实例化了整个RNN模型,它由嵌入层、RNN层以及最后的全连接层组成,如下所示:

class RNN(nn.Module):
    def __init__(self, input_dimension, embedding_dimension, 
                 hidden_dimension, output_dimension):
        super().__init__()
        self.embedding_layer = nn.Embedding(input_dimension,
                                            embedding_dimension)
        self.rnn_layer = nn.RNN(embedding_dimension, 
                                hidden_dimension,
                                num_layers=1)
        self.fc_layer = nn.Linear(hidden_dimension,
                                  output_dimension)
    def forward(self, sequence):
        # 序列形状 = (sequence_length, batch_size)
        embedding = self.embedding_layer(sequence)
        # 嵌入形状 = [sequence_length, batch_size, 
        #             embedding_dimension]
        output, hidden_state = self.rnn_layer(embedding)
        # 输出形状 = [sequence_length, batch_size, 
        #            hidden_dimension]
        # 隐藏状态形状 = [1, batch_size, 
        #                hidden_dimension]
        final_output = self.fc_layer(
            hidden_state[-1,:,:].squeeze(0))
        return final_output

嵌入层的功能由nn.Embedding模块提供,该模块以查找表的形式存储词嵌入,并使用索引检索它们。在本练习中,我们将嵌入维度设置为100。这意味着如果我们的词汇表中共有1000个单词,那么嵌入查找表的大小将是1000x100。例如,单词"it"在我们的词汇表中被标记为数字8,它将作为一个大小为100的向量存储在查找表的第8行。你可以使用预训练的嵌入来初始化查找表以获得更好的性能,但在本练习中,我们将从头开始训练它。

在以下代码中,我们实例化RNN模型:

input_dimension = len(vocab_to_token) + 1  # +1 以考虑填充
embedding_dimension = 100
hidden_dimension = 32
output_dimension = 1
rnn_model = RNN(input_dimension, embedding_dimension,
                hidden_dimension, output_dimension)
optim = optim.Adam(rnn_model.parameters())
loss_func = nn.BCEWithLogitsLoss()
rnn_model = rnn_model.to(device)
loss_func = loss_func.to(device)

我们使用nn.BCEWithLogitsLoss模块来计算损失。这个PyTorch模块提供了一个数值稳定的计算方法,先计算sigmoid函数,然后计算二元交叉熵函数,这正是我们用于二分类问题的理想损失函数。隐藏维度为32意味着每个RNN单元(隐藏)状态将是一个大小为32的向量。

我们还将定义一个准确率度量,以衡量我们训练的模型在验证集上的表现。我们将在本练习中使用简单的0-1准确率:

def accuracy_metric(predictions, ground_truth):
    """
    返回给定预测集和真实值集的0-1准确率
    """
    # 将预测值四舍五入为0或1
    rounded_predictions = \
        torch.round(torch.sigmoid(predictions))
    # 转换为浮点数以进行除法
    success = (rounded_predictions == ground_truth).float()
    accuracy = success.sum() / len(success)
    return accuracy

完成模型实例化和度量定义后,我们可以定义训练和验证流程。训练流程的代码如下:

def train(model, dataloader, optim, loss_func):
    loss = 0
    accuracy = 0
    model.train()
    for sequence, sentiment in dataloader:
        optim.zero_grad()
        preds = model(sequence.T).squeeze()
        loss_curr = loss_func(preds, sentiment)
        accuracy_curr = accuracy_metric(preds, sentiment)
        loss_curr.backward()
        optim.step()
        loss += loss_curr.item()
        accuracy += accuracy_curr.item()
    return loss / len(dataloader), accuracy / len(dataloader)

验证流程的代码如下:

def validate(model, dataloader, loss_func):
    loss = 0
    accuracy = 0
    model.eval()
    with torch.no_grad():
        for sequence, sentiment in dataloader:
            preds = model(sequence.T).squeeze()
            loss_curr = loss_func(preds, sentiment)
            accuracy_curr = accuracy_metric(preds, sentiment)
            loss += loss_curr.item()
            accuracy += accuracy_curr.item()
    return loss / len(dataloader), accuracy / len(dataloader)

最后,我们现在可以训练模型了:

num_epochs = 10
best_validation_loss = float('inf')
for ep in range(num_epochs):
    time_start = time.time()
    training_loss, train_accuracy = train(rnn_model, 
                                          train_dataloader,
                                          optim, loss_func)
    validation_loss, validation_accuracy = validate(
        rnn_model, validation_dataloader, loss_func)
    time_end = time.time()
    time_delta = time_end - time_start
    if validation_loss < best_validation_loss:
        best_validation_loss = validation_loss
        torch.save(rnn_model.state_dict(), 'rnn_model.pt')
    print(f'epoch number: {ep + 1} | time elapsed: {time_delta}s')
    print(f'training loss: {training_loss:.3f} | training accuracy: {train_accuracy * 100:.2f}%')
    print(f'\tvalidation loss: {validation_loss:.3f} | validation accuracy: {validation_accuracy * 100:.2f}%')

输出如下所示:

epoch number: 1 | time elapsed: 170.42595100402832s
training loss: 0.614 | training accuracy: 67.31%
validation loss: 1.011 | validation accuracy: 31.37%
 
epoch number: 2 | time elapsed: 156.29844784736633s
training loss: 0.540 | training accuracy: 73.79%
validation loss: 0.762 | validation accuracy: 51.39%
...
epoch number: 9 | time elapsed: 156.29339694976807s
training loss: 0.212 | training accuracy: 92.17%
validation loss: 1.392 | validation accuracy: 49.42%
 
epoch number: 10 | time elapsed: 154.8834547996521s
training loss: 0.179 | training accuracy: 93.62%
validation loss: 1.033 | validation accuracy: 63.94%

模型似乎在训练集上表现非常好,但出现了过拟合的现象。该模型在时间维度上有512层,这解释了为什么这个强大的模型可以很好地学习训练集。验证集的性能从一个较低的值开始,然后上升并波动。

让我们快速定义一个帮助函数,以便在训练好的模型上进行实时推理:

def sentiment_inference(model, sentence):
    model.eval()
    # 文本转换
    sentence = sentence.lower()
    sentence = ''.join([c for c in sentence
                       if c not in punctuation])
    tokenized = [vocab_to_token.get(token, 0)
                 for token in sentence.split()]
    tokenized = np.pad(tokenized,
                       (512 - len(tokenized), 0), 'constant')
    # 模型推理
    model_input = torch.LongTensor(tokenized).to(device)
    model_input = model_input.unsqueeze(1)
    pred = torch.sigmoid(model(model_input))
    return pred.item()

作为本次练习的最后一步,我们将在一些手动输入的评论文本上测试该模型的表现:

print(sentiment_inference(rnn_model,
                          "This film is horrible"))
print(sentiment_inference(rnn_model,
                          "Director tried too hard but \
                           this film is bad"))
print(sentiment_inference(rnn_model,
                          "This film will be houseful for weeks"))
print(sentiment_inference(rnn_model,
                          "I just really loved the movie"))

输出如下所示:

0.005014493595808744
0.05119464173913002
0.4609886109828949
0.5695606470108032

在这里,我们可以看到模型确实能够识别出正面和负面的情感。此外,它似乎能够处理长度可变的序列,即使它们的长度都远远小于512个单词。

在本练习中,我们训练了一个相对简单的RNN模型,该模型不仅在架构方面存在限制,而且在数据处理方面也存在局限性。在下一个练习中,我们将使用一个更进化的循环架构——双向LSTM模型——来处理相同的任务。我们将使用一些正则化方法来克服本练习中观察到的过拟合问题。此外,我们将使用PyTorch的torchtext模块来更高效、更简洁地处理数据加载和处理流程。

构建双向LSTM

到目前为止,我们已经在情感分析任务(基于文本数据的二分类任务)上训练并测试了一个简单的RNN模型。在本节中,我们将尝试通过使用更高级的循环架构——LSTM——来改进相同任务的性能。

LSTM由于其记忆单元门控的特性,更加能够处理较长的序列,这些门控帮助保留了之前时间步的重要信息,并忘记了即使是最近但不相关的信息。在控制住梯度爆炸和消失问题的情况下,LSTM在处理长电影评论时应该能够表现出色。

此外,我们将使用双向模型,因为它在任何时间步上都扩大了上下文窗口,使模型能够对电影评论的情感做出更明智的决策。我们在之前的练习中看到的RNN模型在训练过程中过拟合了数据集,因此,为了解决这个问题,我们将在LSTM模型中使用dropout作为正则化机制。

加载和预处理文本数据集

在本次练习中,我们将展示PyTorch的torchtext模块的强大功能。在之前的练习中,我们花了大约一半的时间来加载和处理文本数据集。使用torchtext,我们将在不到10行代码中完成相同的工作。

我们将使用torchtext.legacy.datasets中的预定义IMDb数据集,而不是手动下载数据集。我们还将使用torchtext.legacy.data来对单词进行分词并生成词汇表。

最后,我们将使用nn.LSTM模块直接填充序列,而不是手动填充。请注意,torchtext.legacy在PyTorch V2中不再受支持,因此我们在本次练习中使用V1.9。该练习的代码可以在我们的GitHub仓库中找到【4】。让我们开始吧:

对于本次练习,我们需要导入一些依赖项。首先,我们将执行与之前练习中相同的导入语句。此外,我们还需要导入以下内容:

import random
from torchtext.legacy import data, datasets

我们使用torchtext的旧版API,以便在接下来的步骤中使用FieldLabelField数据结构,这些结构在torchtext模块中自版本0.9.0起已被弃用【5】。

接下来,我们将使用torchtext(legacy)模块中的datasets子模块直接下载IMDb情感分析数据集。我们将评论文本和情感标签分别分成两个字段,并将数据集拆分为训练集、验证集和测试集:

TEXT_FIELD = data.Field(tokenize=data.get_tokenizer("basic_english"),
                   include_lengths=True)
LABEL_FIELD = data.LabelField(dtype=torch.float)
train_dataset, test_dataset = datasets.IMDB.splits(
    TEXT_FIELD, LABEL_FIELD)
train_dataset, valid_dataset = train_dataset.split(
    random_state=random.seed(123))

接下来,我们将使用torchtext.legacy.data.Fieldtorchtext.legacy.data.LabelFieldbuild_vocab方法分别为电影评论文本数据集和情感标签构建词汇表:

MAX_VOCABULARY_SIZE = 25000
TEXT_FIELD.build_vocab(train_dataset, max_size=MAX_VOCABULARY_SIZE)
LABEL_FIELD.build_vocab(train_dataset)

正如我们所见,仅需三行代码即可使用预定义函数构建词汇表。

在深入了解与模型相关的细节之前,我们还将为训练集、验证集和测试集创建数据集迭代器。

现在我们已经加载并处理了数据集并派生了数据集迭代器,让我们创建LSTM模型对象并训练LSTM模型。

实例化和训练LSTM模型

在本节中,我们将实例化LSTM模型对象。然后,我们将定义优化器、损失函数和模型训练性能指标。最后,我们将使用定义的模型训练和验证流程运行模型训练循环。让我们开始吧:

首先,我们必须实例化带有dropout的双向LSTM模型。虽然大部分模型实例化看起来与之前的练习相同,但以下代码行是关键区别:

self.lstm_layer = nn.LSTM(
    embedding_dimension, hidden_dimension, num_layers=1,
    bidirectional=True, dropout=dropout)

我们为词汇表添加了两种特殊类型的标记——unknown_token(表示词汇表中不存在的单词)和padding_token(表示仅用于填充序列的标记)。因此,我们需要将这两个标记的嵌入权重设置为全零:

UNK_INDEX = TEXT_FIELD.vocab.stoi[TEXT_FIELD.unk_token]
lstm_model.embedding_layer.weight.data[UNK_INDEX] = \
    torch.zeros(EMBEDDING_DIMENSION)
lstm_model.embedding_layer.weight.data[PAD_INDEX] = \
    torch.zeros(EMBEDDING_DIMENSION)

接下来,我们将定义优化器(Adam)和损失函数(sigmoid后接二元交叉熵)。我们还将定义准确率计算函数,如在之前的练习中所做的那样。

然后,我们将定义训练和验证流程。

最后,我们将运行10个epoch的训练循环。输出应如下所示:

epoch number: 1 | time elapsed: 1547.8699600696564s
training loss: 0.686 | training accuracy: 54.57%
validation loss: 0.666 | validation accuracy: 60.02%
 
epoch number: 2 | time elapsed: 1537.510666847229s
training loss: 0.650 | training accuracy: 61.54%
validation loss: 0.607 | validation accuracy: 68.02%
...
epoch number: 9 | time elapsed: 1367.163740158081s
training loss: 0.526 | training accuracy: 73.12%
validation loss: 0.549 | validation accuracy: 76.29%
 
epoch number: 10 | time elapsed: 1369.546238899231s
training loss: 0.430 | training accuracy: 80.90%
validation loss: 0.556 | validation accuracy: 75.66%

正如我们所见,模型在各个epoch中学习得很好。此外,dropout似乎控制住了过拟合,因为训练集和验证集的准确率都在以相似的速度上升。然而,与RNN相比,LSTM的训练速度较慢。正如我们所见,LSTM的epoch时间大约是RNN的9到10倍。这也是因为我们在本次练习中使用了双向网络。

上一步还保存了表现最好的模型。在此步骤中,我们将加载表现最好的模型,并在测试集上对其进行评估:

lstm_model.load_state_dict(torch.load('lstm_model.pt'))
test_loss, test_accuracy = validate(
    lstm_model, test_data_iterator, loss_func)
print(f'test loss: {test_loss:.3f} | test accuracy: {test_accuracy*100:.2f}%')

输出应如下所示:

test loss: 0.561 | test accuracy: 76.31%

最后,我们将定义情感推理函数,如在之前的练习中所做的那样,并对一些手动输入的电影评论文本进行测试:

print(sentiment_inference(rnn_model, "This film is horrible"))
print(sentiment_inference(rnn_model,\
                          "Director tried too hard \
                           but this film is bad"))
print(sentiment_inference(rnn_model, \
                          "This film will be houseful \
                           for weeks"))
print(sentiment_inference(rnn_model, \
                          "I just really loved the movie"))

输出应如下所示:

0.14579516649246216
0.03841548413038254
0.6569563150405884
0.8203923106193542

显然,LSTM模型在验证集上的表现优于RNN模型。Dropout帮助防止了过拟合,双向LSTM架构似乎学会了电影评论文本句子中的顺序模式。

前两个练习都是关于多对一类型的序列任务,其中输入是一个序列,输出是一个二元标签。这两个练习,再加上第3章《结合CNNs和LSTMs》中的一对多练习,应该已经为你提供了足够的上下文来使用PyTorch实践不同的循环架构。

在接下来的最后一节中,我们将简要讨论GRU及其在PyTorch中的使用方法。然后,我们将介绍注意力机制及其在循环架构中的应用。

讨论GRU和基于注意力的模型

在本章的最后一节中,我们将简要介绍GRU,探讨它们与LSTM的相似之处和不同之处,并如何使用PyTorch初始化GRU模型。我们还将讨论基于注意力的RNN。最后,我们将描述在序列建模任务中,纯基于注意力的模型(没有循环或卷积)如何超越循环神经网络(RNN)家族的模型。

GRU和PyTorch

正如我们在“探索循环网络的演变”一节中所讨论的那样,GRU是一种具有两个门控(重置门和更新门)以及一个隐藏状态向量的记忆单元。在配置方面,GRU比LSTM更简单,但在处理梯度爆炸和消失问题时同样有效。大量研究比较了LSTM和GRU的性能。虽然在各种与序列相关的任务中,LSTM和GRU都比简单的RNN表现更好,但在某些任务上一个模型略优于另一个,反之亦然。

GRU的训练速度比LSTM更快,并且在许多任务(如语言建模)中,GRU可以在更少的训练数据下表现得与LSTM一样好。然而,从理论上讲,LSTM应该能够比GRU更好地保留来自更长序列的信息。PyTorch提供了nn.GRU模块,可以用一行代码实例化一个GRU层。以下代码创建了一个具有两个双向GRU层的深度GRU网络,每个GRU层具有80%的循环dropout:

self.gru_layer = nn.GRU(input_size, hidden_size, num_layers=2, dropout=0.8, bidirectional=True)

正如我们所见,使用PyTorch的GRU模型只需一行代码即可启动。我鼓励你将gru_layer插入到前面的练习中,替换lstm_layerrnn_layer,看看它对模型训练时间和性能的影响。

基于注意力的模型

我们在本章中讨论的模型在解决与序列数据相关的问题方面取得了突破性进展。然而,在2017年,一种全新的纯基于注意力的方法被发明出来,这种方法随后使这些循环网络失去了光彩。注意力的概念来源于这样一个想法:我们作为人类,在不同时间对序列(如文本)的不同部分给予不同程度的关注。

例如,如果我们要完成以下句子:"Martha sings beautifully, I am hooked to ___ voice." 我们会更多地关注"Martha"这个词来猜测缺失的词可能是"her"。另一方面,如果我们要完成以下句子:"Martha sings beautifully, I am hooked to her ____." 那么我们会更多地关注"sings"这个词来猜测缺失的词可能是"voice"、"songs"、"singing"等。

在我们讨论的所有循环架构中,都没有一种机制可以专注于序列中的特定部分,以预测当前时间步的输出。相反,循环模型只能以浓缩的隐藏状态向量形式获取过去序列的摘要。

基于注意力的循环网络大约在2014-2015年间首次利用了注意力的概念。在这些模型中,在常规的循环层之上添加了一个额外的注意力层。这个注意力层学习了序列中每个前置单词的注意力权重。

上下文向量被计算为所有前置单词的隐藏状态向量的注意力加权平均值。这个上下文向量在任一时间步t被馈送到输出层,除了常规的隐藏状态向量之外。下图展示了基于注意力的RNN的架构:

image.png

在这种架构中,在每个时间步都会计算一个全局上下文向量。随后,人们设计了使用局部上下文向量的架构变体——这些变体并不关注所有前面的单词,而是只关注前面k个单词。基于注意力的RNN在诸如机器翻译等任务上超越了当时最先进的循环模型。

几年后,在2017年,论文《Attention Is All You Need》展示了仅通过注意力机制就能解决序列任务的能力,而不需要循环层。在过去的几年中,这类使用注意力的模型在各类任务上都超越了循环模型,并推动了自然语言处理(NLP)和深度学习领域的巨大进展。循环网络需要在时间维度上展开,这使它们无法并行化。然而,一种新的模型——Transformer模型——诞生了,它没有任何循环(也没有卷积)层,使其既能并行化,又在计算量方面更加轻量化(即计算量较小)。我们将在下一章中详细讨论这种模型。

总结

在本章中,我们深入探讨了循环神经网络架构。在下一章中,我们将详细阐述Transformer及其他类似的模型架构,这些架构既非纯循环,也非卷积,但却在多个领域取得了最先进的成果。