课程7. RNN与文本分类
课程计划
- 回忆:文本数据分析
- 循环神经网络
- RNN 单元变体:GRU、LSTM
- 实践:构建一个循环神经网络用于文本分类任务
回忆:文本数据分析
在上一课中我们开始讨论文本处理。我们学习了如何创建甚至学习文本部分(单词、单词的部分)的信息向量表示。
我们还讨论了一种基于文本部分的矢量表示来创建整个文本的矢量表示的方法。这种方法是将文本中所有单词的向量求和或者求平均,得到整个文本的一个向量。但这种方法有明显的缺点:
- 没有考虑到文本中单词的不同重要性;
- 它没有考虑文本中的词序。
当然,在使用嵌入时,您可以关注单词的重要性:例如,不仅对句子中所有单词的嵌入求平均值,而且取与单词重要性成比例的权重的加权平均值。然而,这里出现了一个问题:如何确定每个词的重要性。我希望模型能够理解句子中的每个单词。
嗯,我们直观地了解到文本的含义并不等于其单词的平均含义。这是一件更加复杂的事情。
现在我们将熟悉循环神经网络(RNN),它是为了处理具有序列特性的数据而发明的。文本具有这种性质,因此 RNN 非常适合处理文本。
循环神经网络
经典的RNN结构
RNN 背后的想法是什么:
文本和音频以及其他类型的数据(例如图像)之间的主要区别之一是存在时间成分。我们不是立即阅读文本,而是按照严格定义的顺序逐字阅读。于是产生了一个想法,即建立一个神经网络来考虑到这类数据的这一特征。可以通过逐字“阅读”来处理文本的神经网络。
为了构建 RNN,让我们回顾一下常规全连接神经网络的工作原理:
以向量形式呈现的对象被馈送到神经网络的输入。这个对象穿过网络各层,网络产生响应。当神经网络将下一个对象作为输入时,神经网络会对其执行与前一个对象作为输入完全相同的转换。神经网络中没有保留前一个物体的任何信息。
如果我们希望神经网络能够逐字逐句地阅读文本,我们就需要以某种方式为其配备“记忆”机制。
循环神经网络与全连接神经网络类似。但是在每一层上都会添加另一种连接——从自己到自己。这将成为这一层的“记忆”。
现在我们将详细研究这种网络的层和整个网络的结构。让我们描述一个常规的全连接层:
它有两个可训练参数(
W
W
W和
b
b
b)。循环层将多出三个可训练的参数,并且出现一个新的实体——该层的隐藏状态向量。这将是该层的“记忆”。
现在基于输入 X T X^T XT,该层的隐藏状态向量 h t − 1 − > h t h^{t-1} -> h^t ht−1−>ht 将首先被更新,然后该层的输出将基于更新后的向量 h t h^t ht 进行计算
为了清楚起见,完全连接和循环神经网络的层可以用不同的方式绘制:
这就是一个循环层的前向传递的发生方式。让我们转到演示文稿来演示具有
n
n
n 个循环层的循环神经网络的前向传递。
循环神经网络的训练也采用梯度下降法。差异仅出现在参数梯度的计算中。你可以在深度学习学校的讲座中找到更多关于此内容的详细信息。
RNN 中的激活函数
我们再看一下我们得到的RNN层公式:
我们来看一下隐藏状态向量更新公式中的激活函数,我们将其表示为
σ
\sigma
σ。在我们刚刚看过的标准 RNN 及其各种修改版(我们将在接下来看到其中一些)中,正切函数(tanh)最常用作隐藏状态向量更新公式中的激活函数。
事实上,就 RNN 而言,以下几点对我们来说很重要:
- 使得每个时间点更新的隐藏状态向量的元素具有大致相同的值尺度,并且这些值不会具有非常大的模量;
- 使得激活函数的导数的幅度既不是很大也不是很小。
这对我们很重要,因为当使用反向传播算法训练 RNN 网络时,经常会出现梯度衰减和爆炸的问题。正确选择向量 h t h^t ht的激活函数有助于解决这些问题。您可以从 深度学习学校讲座 中了解有关为什么在训练 RNN 时会出现这些问题的更多信息。
并且我们会注意到,在Sigmoid,Tanh和ReLu等常见的激活函数中,Tanh的特性最适合我们的需求:
在左侧您可以看到 Sigmoid 和 Tanh 函数的导数的图。很明显,S 型导数的模非常小,对于任何自变量的值都远小于 1。正因为如此,当使用Sigmoid作为隐藏状态向量
h
t
h^t
ht的激活函数时,很容易出现梯度衰减问题。可以看出,导数 Tanh 在零附近的许多点上模量比导数 Sigmoid 大得多,但其模量不超过 1。
在右侧,您可以看到 ReLu 激活函数及其导数的图。 ReLU函数不受上界影响,其导数在很多点上都等于1。由于函数本身及其导数的值可以取很大的绝对值,因此在使用 ReLu 作为隐藏状态向量 h t h^t ht 的激活函数时很容易出现梯度爆炸问题。
由于这些效应,Tanh 通常被选为 h t h^t ht 公式中的激活函数。
Embedding层(嵌入层)
还剩下一个细微差别:在循环神经网络中,词嵌入通常不是从word2vec中获取的,而是通过学习获得的。为了实现这一点,在循环层之前添加了一个嵌入层。该层是一个大小为 n ∗ k n*k n∗k 的矩阵,其中 n n n 是字典大小, k k k 是嵌入大小。单词以独热向量的形式被馈送到网络输入。经过 embedding one-hot 层之后,向量变成 embedding 矩阵的第 i i i 行,其中 i i i 是词典中的单词索引。
因此,对于文本分类等任务,RNN 的一般结构如下所示:
LSTM与GRU
尽管正如我们上面讨论过的,在 RNN 中,信息会随着时间的推移从层的前一个隐藏状态转移到下一个隐藏状态,但这种“经典”的 RNN 设备存在一个严重的问题——遗忘。当足够长的文本作为输入被馈送到 RNN 时,在其处理结束时,循环层的隐藏状态向量主要包含来自最后时刻(即文本末尾)的信息,而最初时刻积累的信息会被抹去并实际上消失。据称,RNN 会“忘记”文本的开头。
当然,这会极大地损害模型的质量。例如,假设我们正在解决文本分类问题。在这种情况下,我们希望我们的模型“阅读”整个文本,然后给出传入文本属于哪个类的答案。如果文本足够长,那么在处理结束时,RNN 层将只“记住”其结尾,并且神经网络将仅根据原始文本的结尾给出任务的答案。这似乎不是我们所希望的。
关于如何解决这个问题,有几种想法。其中之一是使用稍微修改过的循环层类型——LSTM 或 GRU。这个想法有助于解决上述问题,尽管它并不能完全解决问题。也就是说,当使用 LSTM 或 GRU 时,RNN 开始在长文本上表现得更好,但仍然存在遗忘问题。在课程的后面,我们将了解另一个有助于彻底解决这个问题的想法。
LSTM
LSTM(长短期记忆)是一个 RNN 层,经过一些修改,可以帮助它更好地处理长序列。现在我们将弄清楚它是如何工作的。
首先,让我们这样说:在文章/视频中,你经常可以看到以下形式的经典 RNN 层的示例:
其中:
h
t
=
t
a
n
h
(
W
X
t
+
U
h
t
−
1
+
b
h
)
h^t = tanh(WX^t + Uh^{t-1} + b_h)
ht=tanh(WXt+Uht−1+bh)
y
t
=
σ
(
W
y
h
t
+
b
y
)
y^t = \sigma(W_yh^t + b_y)
yt=σ(Wyht+by)
有时假设 y t = h t y^t = h^t yt=ht,即,时刻 t t t的某一层的输出是其时刻 t t t的隐藏状态向量。然后你可以对这个输出应用一个完全连接的层,然后你就会得到上面写的公式 y t y^t yt。
与常规的 RNN 层相比,LSTM 层可以表示如下:
在 LSTM 中已经有两个隐藏状态向量 -
C
t
C^t
Ct 和
h
t
h^t
ht。它们尺寸一样。
类似这样的函数:
- f t = σ ( W f ⋅ [ h t − 1 , x t ] + b f ) f^t = \sigma(W_f \cdot [h^{t-1}, x^t] + b_f) ft=σ(Wf⋅[ht−1,xt]+bf)
- i t = σ ( W i ⋅ [ h t − 1 , x t ] + b i ) i^t = \sigma(W_i \cdot [h^{t-1}, x^t] + b_i) it=σ(Wi⋅[ht−1,xt]+bi)
- C a d d t = t a n h ( W C ⋅ [ h t − 1 , x t ] + b C ) C^t_{add} = tanh(W_C \cdot [h^{t-1}, x^t] + b_C) Caddt=tanh(WC⋅[ht−1,xt]+bC)
- C t = f t ∗ C t − 1 + i t ∗ C a d d t C^{t} = f^t * C^{t-1} + i^t * C^t_{add} Ct=ft∗Ct−1+it∗Caddt
- o t = σ ( W o ⋅ [ h t − 1 , x t ] + b o ) o^t = \sigma(W_o \cdot [h^{t-1}, x^t] + b_o) ot=σ(Wo⋅[ht−1,xt]+bo)
- h t = o t ∗ t a n h ( C t ) h^t = o^t * tanh(C^t) ht=ot∗tanh(Ct)
- y t = σ ( W y h t + b y ) y^t = \sigma(W_yh^t + b_y) yt=σ(Wyht+by)
这里 W f , b f , W i , b i , W c , b c , W o , b o , W y , b y W_f, b_f, W_i, b_i, W_c, b_c, W_o, b_o, W_y, b_y Wf,bf,Wi,bi,Wc,bc,Wo,bo,Wy,by 是可训练参数。向量 f t , i t , o t , C a d d t , C t f^t, i^t, o^t, C^t_{add}, C^t ft,it,ot,Caddt,Ct 的大小与 h t h^t ht 和 x t x^t xt 相同。
这里的实体已经比常规 RNN 层中的实体更多。 LSTM 层已经有两个隐藏状态向量 — C t C^t Ct 和 h t h^t ht。它们是有道理的 — LSTM 层的 C t C^t Ct 向量可以被认为是该层的“长期记忆”,而 h t h^t ht 向量可以被认为是该层的“短期记忆”。 LSTM 还具有更多可训练的参数。
事实上,LSTM 的每个组件都有其含义。 LSTM 层的工作在概念上包括三个阶段:
- “忘记门”(“forget gate”)。在这个阶段,不必要的信息被从长期记忆(向量 C t C^t Ct)中删除;
- “输入门”(“input gate”)。在这个阶段,新的信息被添加到长期记忆(向量 C t C^t Ct)中;
- 更新隐藏状态 h t h^t ht并计算层输出(“output gate”)。在这个阶段,一些信息从长期记忆(向量 C t C^t Ct)转移到短期记忆(向量 h t h^t ht),并计算层输出。
让我们详细了解一下 LSTM 层的工作原理。
Forget Gate(遗忘门)
Foget Gate 的想法是从 C t C^t Ct 的长期记忆中删除该点不再需要的信息。
为此,我们根据短期记忆的当前状态 h t − 1 h^{t-1} ht−1 和新输入 x t x^t xt 计算向量 f t f^t ft。该向量与向量 h t − 1 h^{t-1} ht−1 和 x t x^t xt 的大小相同。在 f t f^t ft的公式中, σ \sigma σ是sigmoid激活函数,因此 f t f^t ft是一个值在0到1之间的向量。然后,将此向量逐个元素乘以长期记忆 C t − 1 C^{t-1} Ct−1的当前状态。结果是向量 C t − 1 C^{t-1} Ct−1 的每个值都乘以一个从 0 到 1 的值,也就是说, C t − 1 C^{t-1} Ct−1 每个值中的一些信息被抹去了。这是从 C t − 1 C^{t-1} Ct−1 中“删除”信息。
Input Gate(输入门)
input gate的想法是将新的信息添加到长期记忆
C
t
C^t
Ct中。
为此,基于短期记忆的当前状态 h t − 1 h^{t-1} ht−1 和新输入 x t x^t xt,我们计算向量 C a d d t C^t_{add} Caddt,其中包含我们想要添加到 C t C^t Ct 的信息。为了计算 C a d d t C^t_{add} Caddt,向量 h t − 1 h^{t-1} ht−1 和 x t x^t xt 被连接起来并输入到全连接层的输入。
接下来,根据短期记忆的当前状态 h t − 1 h^{t-1} ht−1和新的输入 x t x^t xt,计算向量 i t i^t it。在 i t i^t it的公式中, σ \sigma σ是sigmoid激活函数,因此 i t i^t it是一个值在0到1之间的向量。这个向量与 C a d d t C^t_{add} Caddt的大小相同。接下来,将 i t i^t it 逐个元素乘以 C a d d t C^t_{add} Caddt,即将向量 C a d d t C^t_{add} Caddt 的每个值乘以从 0 到 1 的值。事实证明,对于每个分量 C a d d t C^t_{add} Caddt,我们决定要从中添加多少信息到向量 C t C^t Ct。
然后将 C t C^t Ct 收集为上一步获得的 C t e m p t C^t_{temp} Ctempt 与 i ∗ t ⋅ C a d d t i*t \cdot C^t_{add} i∗t⋅Caddt 的总和。因此,新的信息被添加到长期记忆向量 C t C^t Ct中。
Output Gate(输出门)
Output Gate的思想是更新短期记忆
h
t
h^t
ht的当前状态,并计算当前步骤该层的输出。
首先,根据短期记忆的当前状态 h t − 1 h^{t-1} ht−1和新的输入 x t x^t xt,计算向量 o t o^t ot。在 o t o^t ot的公式中, σ \sigma σ是sigmoid激活函数,因此 o t o^t ot是一个值在0到1之间的向量。这个向量与 C t C^t Ct的大小相同。然后将其逐个元素地乘以向量 C t C^t Ct,该向量首先通过切线激活函数。
通过构建向量 o t o^t ot,决定来自 C t C^t Ct的哪部分信息需要在当前步骤转移到短期记忆 h t h^t ht。然后根据更新后的状态 h t h^t ht 照常计算层输出。或者,正如我们上面所说的,通常假设某一层的输出是 h t h^t ht。
关于 LSTM 更详细的描述可以在这个演示文稿中找到。
GRU
上面描述的 LSTM 层选项并不是唯一可能的选项。关于如何在层内构建元素的结构,有很多种选择。上面描述的选项是经典的选项,也是最常见的选项。
然而,还有一个选项也被认为是“经典”的——GRU(门控循环单元)。这是 LSTM 的“轻量级”版本,可训练参数更少,因此该层的前向和后向传递速度更快。
GRU及其公式可以表示如下:
就像 LSTM 结构一样,GRU 公式中也有一些意义。
RNN 用于解决文本分类问题
我们将解决 IMDB 电影评论的二元分类问题。
数据集由 (文本,标签) 对组成,其中文本是来自 imdb 数据库的电影评论文本,标签是值 0 或 1。类别 1 表示评论是正面的,0 表示评论是负面的。
首先,让我们导入必要的库:
import os # 导入该库的一部分,用于与计算机上的数据进行交互,以及访问运行notebook的环境
from random import sample # random是库的一部分,用于处理随机数
import numpy as np # 用于处理向量和矩阵的库
import torch # 这里包含所有的神经网络相关内容
import torch.nn as nn # 这里包含所有的神经网络模块和层
import torch.nn.functional as F # 这里包含我们可能会用到的函数
import matplotlib.pyplot as plt # 用于绘制图表
from IPython.display import clear_output
# 用于交互式地绘制图表
# clear_output函数允许逐帧绘制收敛图表
from tqdm import tqdm_notebook
from torch.utils.data import Dataset, DataLoader
device = 'cuda' if torch.cuda.is_available() else 'cpu'
device
输出:cuda
下载并预处理数据
让我们安装数据集库。它包含许多数据集,其中一个是“imdb”,我们将使用它。
! pip install datasets # 或者在shell里直接运行pip
我们导入库并加载数据集:
import datasets
dataset = datasets.load_dataset('imdb')
输出:
让我们看看数据集中包含哪些部分以及包含多少个元素:
dataset
输出:
让我们输出一个文本及其标签的示例:
dataset['train'][0]['text'], dataset['train'][0]['label']
输出:
如您所见,数据集中的文本未经预处理。让我们对它们进行预处理:将所有内容转换为小写,删除标点符号,并将文本拆分为标记。
# 用于处理字符串的库。在它的帮助下,我们将删除标点符号
import string
# counter 创建字数统计器
from collections import Counter
# 我们将使用这个库将文本拆分成标记
import nltk
from nltk.tokenize import word_tokenize
# 下载nltk库运行所需的数据包
nltk.download('punkt_tab')
nltk.download('punkt')
# 处理建议的函数
def process_and_tokenize_text(text):
# 转换为小写并删除标点符号
prccessed_text = text.lower().translate(
str.maketrans('', '', string.punctuation)
)
# 标记文本
tokens = word_tokenize(prccessed_text)
return tokens
# 用于存储标记训练和测试数据的数组
train_data = []
test_data = []
# 空的字数统计器
words = Counter()
# 我们浏览训练数据集的文本并对其进行预处理
for example in tqdm_notebook(dataset['train']):
text = example['text']
label = example['label']
text_processed = process_and_tokenize_text(text)
train_data.append((text_processed, label))
# 增加字典中每个单词的计数器
for word in text_processed:
words[word] += 1
# 我们浏览测试数据集的文本并对其进行预处理
for example in tqdm_notebook(dataset['test']):
text = example['text']
label = example['label']
text_processed = process_and_tokenize_text(text)
test_data.append((text_processed, label))
输出:
其中:
- 定义 process_and_tokenize_text 函数,该函数接受一个文本字符串作为输入。首先,使用 lower() 方法将文本转换为小写形式,然后使用 translate() 方法和 str.maketrans() 函数删除文本中的标点符号。接着,使用 word_tokenize 函数对处理后的文本进行分词,并返回分词后的结果。
- word_tokenize 函数用于将文本字符串拆分成单个的单词(即标记化)。
- 遍历训练数据集中的每个样本。对于每个样本,提取文本内容和标签,使用 process_and_tokenize_text 函数对文本进行处理和分词,将处理后的文本和标签作为元组添加到 train_data 列表中。同时,遍历分词后的文本,使用 Counter 对象 words 统计每个单词的出现次数。
# 创建一个作为单词集的词典
vocab = set(['<unk>', '<bos>', '<eos>', '<pad>'])
# 我们将只向词典中添加那些
# 在训练数据中至少出现 25 次
counter_threshold = 25
# 我们用来自 words 的单词填充字典
for char, cnt in words.items():
if cnt > counter_threshold:
vocab.add(char)
其中:
vocab = set(['<unk>', '<bos>', '<eos>', '<pad>'])
这行代码创建了一个 Python 的集合(set)对象vocab,并初始化了一些特殊的标记:
<unk>
通常表示未知单词(unknown word),当遇到不在词汇表中的单词时,可以用它来代替。
<bos>
代表句子开始(beginning of sentence)标记,用于标识一个句子的起始位置。
<eos>
表示句子结束(end of sentence)标记,用于标记句子的结束。
<pad>
是填充(padding)标记,在对文本进行批量处理时,为了使所有文本序列长度一致,会用这个标记进行填充。
让我们看看我们得到的字典大小:
len(vocab)
输出:11399
让我们创建字典——字典单词和它们的序数之间的对应关系。
word2ind = {token: i for i, token in enumerate(vocab)}
ind2word = {i: token for token, i in word2ind.items()}
这两行代码主要是创建了两个字典,用于实现单词(token)和索引(index)之间的相互映射,这在自然语言处理中是非常常见且重要的操作,方便将文本数据转换为模型可以处理的数值数据,以及将模型输出的数值结果转换回可读的文本。
补充知识:
- 功能:创建一个名为 word2ind 的字典,其作用是将单词(token)映射到对应的索引(index)。
- 实现方式:
- 使用了 Python 的字典推导式。enumerate(vocab) 函数会遍历 vocab 集合(前面代码创建的词汇表),并为每个元素分配一个从 0 开始的索引。
- i 是索引,token 是词汇表中的单词。
- {token: i for i, token in enumerate(vocab)} 表示对于 vocab 中的每个单词,将其作为键,对应的索引作为值,存储到 word2ind 字典中。
示例
假设vocab
集合包含以下元素:{'apple', 'banana', 'cherry', '<unk>', '<bos>', '<eos>', '<pad>'}
,那么经过上述代码处理后:
word2ind
字典可能如下(索引分配可能因vocab
元素的遍历顺序不同而有所差异):
{
'<pad>': 0,
'<bos>': 1,
'<eos>': 2,
'<unk>': 3,
'apple': 4,
'banana': 5,
'cherry': 6
}
ind2word
字典则是word2ind
的反向映射:
{
0: '<pad>',
1: '<bos>',
2: '<eos>',
3: '<unk>',
4: 'apple',
5: 'banana',
6: 'cherry'
}
通过这两个字典,我们可以方便地将文本中的单词转换为索引(使用
word2ind
),以及将模型输出的索引转换回单词(使用ind2word
)。
对于文本来说,数据元素可以有不同的长度。让我们看一下训练数据集中标记文本长度的分布:
plt.hist([len(x[0]) for x in train_data], bins=100);
输出:
在形成网络训练的批次时,需要批次的所有元素具有相同的大小,即相同的长度。为确保这一点,请执行以下操作:
- 修复批次元素的最大长度max_len;
- 计算batch元素真正的最大长度max_seq_len;
- 选择当前批次所有元素的长度为max_len = min(max_len, max_seq_len);
- 所有长度超过max_len的批次元素都被截断为max_len;
- 所有短于 max_len 的批次元素都使用特殊的技术标记 <pad> 补充到 max_len。
让我们编写一个整理函数,将一批数据作为输入并对其进行转换以提供给网络输入。数据加载器在形成批次时将使用此函数。此功能将执行上面描述的操作。即对输入的批量数据进行处理,包括截断、标记到索引的转换、填充操作,并将处理后的数据转换为 PyTorch 张量,最终返回一个包含处理后文本和标签的字典,方便后续模型的输入。
def collate_fn_with_padding(input_batch, max_len=256):
# input_batch — 成对的批次(标记化的文本,标签)
texts = [x[0] for x in input_batch]
labels = [x[1] for x in input_batch]
# 对于每个批次元素,我们都会得到标记化文本的长度(以标记为单位)
seq_lens = [len(x) for x in texts]
# 定义当前批次中元素的最大长度
max_seq_len = min(max(seq_lens), max_len)
# 我们遍历批次中的元素,并用字典中的索引替换标记
# 短于 max_seq_len 的序列也用标记 <pad> 进行补充
processed_texts = []
for text, label in zip(texts, labels):
text = text[:max_seq_len]
text = [word2ind[x] if x in vocab else word2ind['<unk>'] for x in text]
for _ in range(max_seq_len - len(text)):
text.append(word2ind['<pad>'])
processed_texts.append(text)
# 将批次元素转换为张量格式
processed_texts = torch.LongTensor(processed_texts).to(device)
labels = torch.LongTensor(labels).to(device)
# 编译批处理
processed_batch = {
'input_ids': processed_texts,
'label': labels
}
return processed_batch
我们将测试数据test_data分为验证数据和测试数据,并为数据的各个部分创建dataloader。
# 将测试数据分为val和test
np.random.seed(42)
val_indices = np.random.choice(np.arange(len(test_data)), 10000)
test_indices = [x for x in range(len(test_data)) if x not in val_indices]
val_data = [test_data[i] for i in val_indices]
test_data = [test_data[i] for i in test_indices]
# 启动数据加载器
batch_size = 128
train_dataloader = DataLoader(
train_data, shuffle=True, collate_fn=collate_fn_with_padding, batch_size=batch_size)
val_dataloader = DataLoader(
val_data, shuffle=False, collate_fn=collate_fn_with_padding, batch_size=batch_size)
test_dataloader = DataLoader(
test_data, shuffle=False, collate_fn=collate_fn_with_padding, batch_size=batch_size)
网络构建
让我们建立一个 RNN 网络来解决我们的文本分类问题。它将由嵌入层、循环层和全连接层组成。
class SimpleRNN(nn.Module):
def __init__(
self, hidden_dim, vocab_size, num_classes,
aggregation_type: str = 'last'
):
super().__init__()
# 嵌入层
self.embedding = nn.Embedding(vocab_size, hidden_dim)
# 循环层
# 参数——token 嵌入大小、隐藏状态向量大小、批次中的数据表示格式
self.rnn = nn.RNN(hidden_dim, hidden_dim, batch_first=True)
# 两个完全连接的层
self.fc1 = nn.Linear(hidden_dim, hidden_dim)
self.fc2 = nn.Linear(hidden_dim, num_classes)
# 层丢失
self.dropout = nn.Dropout(p=0.1)
# 不同时间点的 RNN 层输出将如何
# 在进一步馈送到全连接层的输入之前进行聚合
self.aggregation_type = aggregation_type
def forward(self, input_batch) -> torch.Tensor:
embeddings = self.embedding(input_batch) # [batch_size, seq_len, hidden_dim]
output, _ = self.rnn(embeddings) # [batch_size, seq_len, hidden_dim]
if self.aggregation_type == 'max':
output = output.max(dim=1)[0] #[batch_size, hidden_dim]
elif self.aggregation_type == 'mean':
output = output.mean(dim=1) #[batch_size, hidden_dim]
elif self.aggregation_type == 'last':
output = output[:, -1, :]
else:
raise ValueError("Invalid aggregation_type")
output = F.tanh(output)
output = F.tanh(self.dropout(self.fc1(output))) # [batch_size, hidden_dim]
output = self.fc2(output) # [batch_size, num_classes]
return output
我们还编写用于训练和测试模型的函数:
def evaluate(model, dataloader):
"""
计算来自数据加载器的数据的准确性。
"""
predictions = []
target = []
with torch.no_grad():
for batch in tqdm_notebook(dataloader, desc=f'Evaluating'):
logits = model(batch['input_ids'])
predictions.append(logits.argmax(dim=1))
target.append(batch['label'])
predictions = torch.cat(predictions)
target = torch.cat(target)
accuracy = (predictions == target).float().mean().item()
return accuracy
def train(model, optimizer, criterion, num_epoch=5, eval_steps=100):
losses = []
accs_train = []
accs_val = []
for epoch in range(num_epoch):
epoch_losses = []
model.train()
for i, batch in enumerate(tqdm_notebook(train_dataloader, desc=f'Training epoch {epoch}:')):
optimizer.zero_grad()
logits = model(batch['input_ids'])
loss = criterion(logits, batch['label'])
loss.backward()
optimizer.step()
epoch_losses.append(loss.item())
if i % eval_steps == 0:
model.eval()
accs_train.append(evaluate(model, train_dataloader))
accs_val.append(evaluate(model, val_dataloader))
model.train()
losses.append(sum(epoch_losses) / len(epoch_losses))
return losses, accs_train, accs_val
让我们使用不同的聚合类型进行 10 个时期的网络训练:
num_epoch = 10
eval_steps = len(train_dataloader) // 2
losses_type = {}
accs_train_type = {}
accs_val_type = {}
for aggregation_type in ['max', 'mean', 'last']:
print(f"Starting training for {aggregation_type}")
losses = []
acc = []
model = SimpleRNN(hidden_dim=256,
vocab_size=len(vocab),
num_classes=2,
aggregation_type=aggregation_type
).to(device)
criterion = nn.CrossEntropyLoss(ignore_index=word2ind['<pad>'])
optimizer = torch.optim.Adam(model.parameters())
losses, accs_train, accs_val = train(model,
optimizer,
criterion,
num_epoch=5,
eval_steps=len(train_dataloader) // 2)
losses_type[aggregation_type] = losses
accs_train_type[aggregation_type] = accs_train
accs_val_type[aggregation_type] = accs_val
输出:
对于所有三种聚合类型,我们对训练数据集上的损失函数值的变化以及训练过程中训练和验证数据集上的准确度的变化进行了可视化:
for aggregation_type in ['max', 'mean', 'last']:
plt.plot(losses_type[aggregation_type])
plt.title('Losses')
plt.legend(['max', 'mean', 'last'])
plt.show()
for aggregation_type in ['max', 'mean', 'last']:
plt.plot(accs_train_type[aggregation_type])
plt.title('Train accs')
plt.legend(['max', 'mean', 'last'])
plt.show()
for aggregation_type in ['max', 'mean', 'last']:
plt.plot(accs_val_type[aggregation_type])
plt.title('Val accs')
plt.legend(['max', 'mean', 'last'])
plt.show()
输出: