循环神经网络(Recurrent Neural Network,RNN)是一类具有短期记忆能力的神经网络.在循环神经网络中,神经元不但可以接受其他神经元的信息,也可以接受自身的信息,形成具有环路的网络结构.和前馈神经网络相比,循环神经网络更加符合生物神经网络的结构.目前,循环神经网络已经被广泛应用在语音识别、语言模型以及自然语言生成等任务上.
本章内容基于《神经网络与深度学习》第6章:循环神经网络的相关内容进行设计。在阅读本章之前,建议先了解如图6.1所示的关键知识点,以便更好地理解和掌握相应的理论和实践知识。
本章内容主要包含两部分:
循环神经网络非常擅于处理序列数据,通过使用带自反馈的神经元,能够处理任意长度的序列数据.给定输入序列 [ x 0 , x 1 , x 2 , . . . ] [\boldsymbol{x}_0, \boldsymbol{x}_1, \boldsymbol{x}_2, ...] [x0,x1,x2,...],循环神经网络从左到右扫描该序列,并不断调用一个相同的组合函数 f ( ⋅ ) f(\cdot) f(⋅)来处理时序信息.这个函数也称为循环神经网络单元(RNN Cell). 在每个时刻 t t t,循环神经网络接受输入信息 x t ∈ R M \boldsymbol{x}_t \in \mathbb{R}^{M} xt∈RM,并与前一时刻的隐状态 h t − 1 ∈ R D \boldsymbol{h}_{t-1} \in \mathbb{R}^D ht−1∈RD一起进行计算,输出一个新的当前时刻的隐状态 h t \boldsymbol{h}_t ht.
h t = f ( h t − 1 , x t ) , \boldsymbol{h}_t = f(\boldsymbol{h}_{t-1}, \boldsymbol{x}_t), ht=f(ht−1,xt),
其中 h 0 = 0 \boldsymbol{h}_{0} = 0 h0=0, f ( ⋅ ) f(\cdot) f(⋅)是一个非线性函数.
循环神经网络的参数可以通过梯度下降法来学习。和前馈神经网络类似,我们可以使用随时间反向传播(BackPropagation Through Time,BPTT)算法高效地手工计算梯度,也可以使用自动微分的方法,通过计算图自动计算梯度。
循环神经网络被认为是图灵完备的,一个完全连接的循环神经网络可以近似解决所有的可计算问题。然而,虽然理论上循环神经网络可以建立长时间间隔的状态之间的依赖关系,但是由于具体的实现方式和参数学习方式会导致梯度爆炸或梯度消失问题,实际上,通常循环神经网络只能学习到短期的依赖关系,很难建模这种长距离的依赖关系,称为长程依赖问题(Long-Term Dependencies Problem)。
循环神经网络的一种简单实现是简单循环网络(Simple Recurrent Network,SRN).
令向量 x t ∈ R M \boldsymbol{x}_t \in \mathbb{R}^M xt∈RM表示在时刻 t t t时网络的输入, h t ∈ R D \boldsymbol{h_t} \in \mathbb{R}^D ht∈RD 表示隐藏层状态(即隐藏层神经元活性值),则 h t \boldsymbol{h}_t ht不仅和当前时刻的输入 x t \boldsymbol{x}_t xt相关,也和上一个时刻的隐藏层状态 h t − 1 \boldsymbol{h}_{t-1} ht−1相关. 简单循环网络在时刻 t t t的更新公式为
h t = f ( W x t + U h t − 1 + b ) , \boldsymbol{h}_t = f(\boldsymbol{W}\boldsymbol{x}_t + \boldsymbol{U}\boldsymbol{h}_{t-1} + b), ht=f(Wxt+Uht−1+b),
其中 h t \boldsymbol{h}_{t} ht为隐状态向量, U ∈ R D × D \boldsymbol{U} \in \mathbb{R}^{D\times D} U∈RD×D为状态-状态权重矩阵, W ∈ R D × M \boldsymbol{W} \in \mathbb{R}^{D\times M} W∈RD×M为状态-输入权重矩阵, b ∈ R D \boldsymbol{b}\in \mathbb{R}^{D} b∈RD为偏置向量。
图6.2 展示了一个按时间展开的循环神经网络。
简单循环网络在参数学习时存在长程依赖问题,很难建模长时间间隔(Long Range)的状态之间的依赖关系。为了测试简单循环网络的记忆能力,本节构建一个数字求和任务进行实验。
数字求和任务的输入是一串数字,前两个位置的数字为0-9,其余数字随机生成(主要为0),预测目标是输入序列中前两个数字的加和。图6.3展示了长度为10的数字序列.
如果序列长度越长,准确率越高,则说明网络的记忆能力越好.因此,我们可以构建不同长度的数据集,通过验证简单循环网络在不同长度的数据集上的表现,从而测试简单循环网络的长程依赖能力.
我们首先构建不同长度的数字预测数据集DigitSum.
由于在本任务中,输入序列的前两位数字为 0 − 9,其组合数是固定的,所以可以穷举所有的前两位数字组合,并在后面默认用0填充到固定长度. 但考虑到数据的多样性,这里对生成的数字序列中的零位置进行随机采样,并将其随机替换成0-9的数字以增加样本的数量.
我们可以通过设置 k k k的数值来指定一条样本随机生成的数字序列数量.当生成某个指定长度的数据集时,会同时生成训练集、验证集和测试集。当 k k k=3时,生成训练集。当 k k k=1时,生成验证集和测试集. 代码实现如下:
import os
length = 5
print(os.path.exists(f"./datasets/{length}/"))
运行结果:
True
需要在根目录下创建datasets文件夹,这个结果是在运行了第二块代码后,出现的。目的是判断datasets文件夹里是否有名字是“5”的文件夹
import random
import numpy as np
import os
# 固定随机种子
random.seed(0)
np.random.seed(0)
def generate_data(length, k, save_path):
if length < 3:
raise ValueError("The length of data should be greater than 2.")
if k == 0:
raise ValueError("k should be greater than 0.")
# 生成100条长度为length的数字序列,除前两个字符外,序列其余数字暂用0填充
base_examples = []
for n1 in range(0, 10):
for n2 in range(0, 10):
seq = [n1, n2] + [0] * (length - 2)
label = n1 + n2
base_examples.append((seq, label))
examples = []
# 数据增强:对base_examples中的每条数据,默认生成k条数据,放入examples
for base_example in base_examples:
for _ in range(k):
# 随机生成替换的元素位置和元素
idx = np.random.randint(2, length)
val = np.random.randint(0, 10)
# 对序列中的对应零元素进行替换
seq = base_example[0].copy()
label = base_example[1]
seq[idx] = val
examples.append((seq, label))
# 保存增强后的数据
with open(save_path, "w", encoding="utf-8") as f:
for example in examples:
# 将数据转为字符串类型,方便保存
seq = [str(e) for e in example[0]]
label = str(example[1])
line = " ".join(seq) + "\t" + label + "\n"
f.write(line)
print(f"generate data to: {save_path}.")
# 定义生成的数字序列长度
lengths = [5, 10, 15, 20, 25, 30, 35]
for length in lengths:
# 判断长度为length的数据文件是否存在,不存在则创建
if not os.path.exists(f"./datasets/{length}/"):
os.makedirs(f"./datasets/{length}")
# 生成长度为length的训练数据
save_path = f"./datasets/{length}/train.txt"
k = 3
generate_data(length, k, save_path)
# 生成长度为length的验证数据
save_path = f"./datasets/{length}/dev.txt"
k = 1
generate_data(length, k, save_path)
# 生成长度为length的测试数据
save_path = f"./datasets/{length}/test.txt"
k = 1
generate_data(length, k, save_path)
运行结果:
generate data to: ./datasets/5/train.txt.
generate data to: ./datasets/5/dev.txt.
generate data to: ./datasets/5/test.txt.
generate data to: ./datasets/10/train.txt.
generate data to: ./datasets/10/dev.txt.
generate data to: ./datasets/10/test.txt.
generate data to: ./datasets/15/train.txt.
generate data to: ./datasets/15/dev.txt.
generate data to: ./datasets/15/test.txt.
generate data to: ./datasets/20/train.txt.
generate data to: ./datasets/20/dev.txt.
generate data to: ./datasets/20/test.txt.
generate data to: ./datasets/25/train.txt.
generate data to: ./datasets/25/dev.txt.
generate data to: ./datasets/25/test.txt.
generate data to: ./datasets/30/train.txt.
generate data to: ./datasets/30/dev.txt.
generate data to: ./datasets/30/test.txt.
generate data to: ./datasets/35/train.txt.
generate data to: ./datasets/35/dev.txt.
generate data to: ./datasets/35/test.txt.
为方便使用,本实验提前生成了长度分别为5、10、 15、20、25、30和35的7份数据,存放于“./datasets”目录下,读者可以直接加载使用。代码实现如下:
import os
# 加载数据
def load_data(data_path):
# 加载训练集
train_examples = []
train_path = os.path.join(data_path, "train.txt")
with open(train_path, "r", encoding="utf-8") as f:
for line in f.readlines():
# 解析一行数据,将其处理为数字序列seq和标签label
items = line.strip().split("\t")
seq = [int(i) for i in items[0].split(" ")]
label = int(items[1])
train_examples.append((seq, label))
# 加载验证集
dev_examples = []
dev_path = os.path.join(data_path, "dev.txt")
with open(dev_path, "r", encoding="utf-8") as f:
for line in f.readlines():
# 解析一行数据,将其处理为数字序列seq和标签label
items = line.strip().split("\t")
seq = [int(i) for i in items[0].split(" ")]
label = int(items[1])
dev_examples.append((seq, label))
# 加载测试集
test_examples = []
test_path = os.path.join(data_path, "test.txt")
with open(test_path, "r", encoding="utf-8") as f:
for line in f.readlines():
# 解析一行数据,将其处理为数字序列seq和标签label
items = line.strip().split("\t")
seq = [int(i) for i in items[0].split(" ")]
label = int(items[1])
test_examples.append((seq, label))
return train_examples, dev_examples, test_examples
# 设定加载的数据集的长度
length = 5
# 该长度的数据集的存放目录
data_path = f"./datasets/{length}"
# 加载该数据集
train_examples, dev_examples, test_examples = load_data(data_path)
print("dev example:", dev_examples[:2])
print("训练集数量:", len(train_examples))
print("验证集数量:", len(dev_examples))
print("测试集数量:", len(test_examples))
运行结果:
dev example: [([0, 0, 6, 0, 0], 0), ([0, 1, 0, 0, 8], 1)]
训练集数量: 300
验证集数量: 100
测试集数量: 100
为了方便使用梯度下降法进行优化,我们构造了DigitSum数据集的Dataset类,函数__getitem__
负责根据索引读取数据,并将数据转换为张量。代码实现如下:
import torch
from torch.utils.data import Dataset
class DigitSumDataset(Dataset):
def __init__(self, data):
self.data = data
def __getitem__(self, idx):
example = self.data[idx]
seq = torch.tensor(example[0], dtype=torch.int64)
label = torch.tensor(example[1], dtype=torch.int64)
return seq, label
def __len__(self):
return len(self.data)
使用SRN模型进行数字加和任务的模型结构为如图6.4所示.
整个模型由以下几个部分组成:
(1) 嵌入层:将输入的数字序列进行向量化,即将每个数字映射为向量;
(2) SRN 层:接收向量序列,更新循环单元,将最后时刻的隐状态作为整个序列的表示;
(3) 输出层:一个线性层,输出分类的结果.
本任务输入的样本是数字序列,为了更好地表示数字,需要将数字映射为一个嵌入(Embedding)向量。嵌入向量中的每个维度均能用来刻画该数字本身的某种特性。由于向量能够表达该数字更多的信息,利用向量进行数字求和任务,可以使得模型具有更强的拟合能力。
首先,我们构建一个嵌入矩阵(Embedding Matrix) E ∈ R 10 × M \boldsymbol{E}\in \mathbb{R}^{10\times M} E∈R10×M,其中第 i i i行对应数字 i i i的嵌入向量,每个嵌入向量的维度是 M M M。如图6.5所示。
给定一个组数字序列 S ∈ R B × L \boldsymbol{S} \in \mathbb{R}^{B\times L} S∈RB×L,其中 B B B为批大小, L L L为序列长度,可以通过查表将其映射为嵌入表示 X ∈ R B × L × M \boldsymbol{X}\in \mathbb{R}^{B\times L \times M} X∈RB×L×M。
提醒:为了和代码的实现保持一致性,这里使用形状为 ( 样 本 数 量 × 序 列 长 度 × 特 征 维 度 ) (样本数量\times 序列长度\times 特征维度) (样本数量×序列长度×特征维度)的张量来表示一组样本。
或者也可以将每个数字表示为10维的one-hot向量,使用矩阵运算得到嵌入表示:
X = S ′ E , \boldsymbol{X} = \boldsymbol{S}^{'} \boldsymbol{E}, X=S′E,
其中 S ′ ∈ R B × L × 10 \boldsymbol{S}' \in \mathbb{R}^{B\times L\times 10} S′∈RB×L×10是序列 S \boldsymbol{S} S对应的one-hot表示。
思考:如果不使用嵌入层,直接将数字作为SRN层输入有什么问题?
基于索引方式的嵌入层的实现如下:
import torch.nn as nn
class Embedding(nn.Module):
def __init__(self, num_embeddings, embedding_dim):
super(Embedding, self).__init__()
W_attr = torch.randn([num_embeddings, embedding_dim])
W_attr = torch.nn.init.xavier_uniform_(torch.as_tensor(W_attr, dtype=torch.float32), gain=1.0)
# 定义嵌入矩阵
self.W = torch.nn.Parameter(W_attr)
def forward(self, inputs):
# 根据索引获取对应词向量
embs = self.W[inputs]
return embs
emb_layer = Embedding(10, 5)
inputs = torch.tensor([0, 1, 2, 3])
emb_layer(inputs)
数字序列 S ∈ R B × L \boldsymbol{S} \in \mathbb{R}^{B\times L} S∈RB×L经过嵌入层映射后,转换为 X ∈ R B × L × M \boldsymbol{X}\in \mathbb{R}^{B\times L\times M} X∈RB×L×M,其中 B B B为批大小, L L L为序列长度, M M M为嵌入维度。
在时刻 t t t,SRN将当前的输入 X t ∈ R B × M \boldsymbol{X}_t \in \mathbb{R}^{B \times M} Xt∈RB×M与隐状态 H t − 1 ∈ R B × D \boldsymbol{H}_{t-1} \in \mathbb{R}^{B \times D} Ht−1∈RB×D进行线性变换和组合,并通过一个非线性激活函数 f ( ⋅ ) f(\cdot) f(⋅)得到新的隐状态,SRN的状态更新函数为:
H t = Tanh ( X t W + H t − 1 U + b ) , \boldsymbol{H}_t = \text{Tanh}(\boldsymbol{X}_t\boldsymbol{W} + \boldsymbol{H}_{t-1}\boldsymbol{U} + \boldsymbol{b}), Ht=Tanh(XtW+Ht−1U+b),
其中 W ∈ R M × D , U ∈ R D × D , b ∈ R 1 × D \boldsymbol{W} \in \mathbb{R}^{M \times D}, \boldsymbol{U} \in \mathbb{R}^{D \times D}, \boldsymbol{b} \in \mathbb{R}^{1 \times D} W∈RM×D,U∈RD×D,b∈R1×D是可学习参数, D D D表示隐状态向量的维度。
简单循环网络的代码实现如下:
import torch
import torch.nn as nn
import torch.nn.functional as F
torch.manual_seed(0)
# SRN模型
class SRN(nn.Module):
def __init__(self, input_size, hidden_size, W_attr=None, U_attr=None, b_attr=None):
super(SRN, self).__init__()
# 嵌入向量的维度
self.input_size = input_size
# 隐状态的维度
self.hidden_size = hidden_size
W_attr = torch.randn([input_size, hidden_size])
W_attr = torch.nn.init.xavier_uniform_(torch.as_tensor(W_attr, dtype=torch.float32), gain=1.0)
U_attr = torch.randn([hidden_size, hidden_size])
U_attr = torch.nn.init.xavier_uniform_(torch.as_tensor(U_attr, dtype=torch.float32), gain=1.0)
b_attr = torch.randn([1, hidden_size])
b_attr = torch.nn.init.xavier_uniform_(torch.as_tensor(b_attr, dtype=torch.float32), gain=1.0)
# 定义模型参数W,其shape为 input_size x hidden_size
self.W = torch.nn.Parameter(W_attr)
# 定义模型参数U,其shape为hidden_size x hidden_size
self.U = torch.nn.Parameter(U_attr)
# 定义模型参数b,其shape为 1 x hidden_size
self.b = torch.nn.Parameter(b_attr)
# 初始化向量
def init_state(self, batch_size):
hidden_state = torch.zeros([batch_size, self.hidden_size], dtype=torch.float32)
return hidden_state
# 定义前向计算
def forward(self, inputs, hidden_state=None):
# inputs: 输入数据, 其shape为batch_size x seq_len x input_size
batch_size, seq_len, input_size = inputs.shape
# 初始化起始状态的隐向量, 其shape为 batch_size x hidden_size
if hidden_state is None:
hidden_state = self.init_state(batch_size)
# 循环执行RNN计算
for step in range(seq_len):
# 获取当前时刻的输入数据step_input, 其shape为 batch_size x input_size
step_input = inputs[:, step, :]
# 获取当前时刻的隐状态向量hidden_state, 其shape为 batch_size x hidden_size
hidden_state = F.tanh(torch.matmul(step_input, self.W) + torch.matmul(hidden_state, self.U) + self.b)
return hidden_state
# 初始化参数并运行
W_attr = torch.nn.Parameter(torch.tensor([[0.1, 0.2], [0.1,0.2]]))
U_attr = torch.nn.Parameter(torch.tensor([[0.0, 0.1], [0.1,0.0]]))
b_attr = torch.nn.Parameter(torch.tensor([[0.1, 0.1]]))
srn = SRN(2, 2, W_attr=W_attr, U_attr=U_attr, b_attr=b_attr)
inputs = torch.tensor([[[1, 0],[0, 2]]], dtype=torch.float32)
hidden_state = srn(inputs)
print("hidden_state", hidden_state)
运行结果:
hidden_state tensor([[-0.9611, -0.9281]], grad_fn=)
提醒: 这里只保留了简单循环网络的最后一个时刻的输出向量。
pytorch框架已经内置了SRN的API torch.nn.RNNCell
,其与自己实现的SRN不同点在于其实现时采用了两个偏置,同时矩阵相乘时参数在输入数据前面,如下公式所示:
H t = Tanh ( W X t + b x + U H t − 1 + b h ) , \boldsymbol{H}_t = \text{Tanh}(\boldsymbol{W}\boldsymbol{X}_t + \boldsymbol{b}_x + \boldsymbol{U}\boldsymbol{H}_{t-1} + \boldsymbol{b}_h), Ht=Tanh(WXt+bx+UHt−1+bh),
其中 W ∈ R M × D , U ∈ R D × D , b x ∈ R 1 × D , b h ∈ R 1 × D \boldsymbol{W} \in \mathbb{R}^{M \times D}, \boldsymbol{U} \in \mathbb{R}^{D \times D}, \boldsymbol{b}_x \in \mathbb{R}^{1 \times D}, \boldsymbol{b}_h \in \mathbb{R}^{1 \times D} W∈RM×D,U∈RD×D,bx∈R1×D,bh∈R1×D是可学习参数, M M M表示嵌入向量的维度, D D D表示隐状态向量的维度。
这里我们可以将自己实现的SRN和pytorch框架内置的SRN返回的结果进行打印展示,实现代码如下。
batch_size, seq_len, input_size = 8, 20, 32
inputs = torch.randn(size=[batch_size, seq_len, input_size])
# 设置模型的hidden_size
hidden_size = 32
paddle_srn = nn.RNN(input_size, hidden_size)
self_srn = SRN(input_size, hidden_size)
self_hidden_state = self_srn(inputs)
paddle_outputs, paddle_hidden_state = paddle_srn(inputs)
print("self_srn hidden_state: ", self_hidden_state.shape)
print("torch_srn outpus:", paddle_outputs.shape)
print("torch_srn hidden_state:", paddle_hidden_state.shape)
运行结果:
self_srn hidden_state: torch.Size([8, 32])
torch_srn outpus: torch.Size([8, 20, 32])
torch_srn hidden_state: torch.Size([1, 20, 32])
另外,内置SRN API在执行完前向计算后,会返回两个参数:序列向量和最后时刻的隐状态向量。在pytorch实现时,考虑到了双向和多层SRN的因素,返回的向量附带了这些信息。
其中序列向量outputs是指最后一层SRN的输出向量,其shape为[batch_size, seq_len, num_directions * hidden_size];最后时刻的隐状态向量shape为[num_layers * num_directions, batch_size, hidden_size]。
这里我们可以将自己实现的SRN和pytorch框架内置的SRN返回的结果进行打印展示,实现代码如下。
# 这里创建一个随机数组作为测试数据,数据shape为batch_size x seq_len x input_size
batch_size, seq_len, input_size, hidden_size = 2, 5, 10, 10
inputs = torch.randn(size=[batch_size, seq_len, input_size])
# 设置模型的hidden_size
bx_attr = torch.nn.Parameter(torch.tensor(torch.zeros([hidden_size, ])))
paddle_srn = nn.RNN(input_size, hidden_size)
# 获取paddle_srn中的参数,并设置相应的paramAttr,用于初始化SRN
W_attr = torch.nn.Parameter(torch.tensor(paddle_srn.weight_ih_l0.T))
U_attr = torch.nn.Parameter(torch.tensor(paddle_srn.weight_hh_l0.T))
b_attr = torch.nn.Parameter(torch.tensor(paddle_srn.bias_hh_l0))
self_srn = SRN(input_size, hidden_size, W_attr=W_attr, U_attr=U_attr, b_attr=b_attr)
# 进行前向计算,获取隐状态向量,并打印展示
self_hidden_state = self_srn(inputs)
paddle_outputs, paddle_hidden_state = paddle_srn(inputs)
print("torch SRN:\n", paddle_hidden_state.detach().numpy().squeeze(0))
print("self SRN:\n", self_hidden_state.detach().numpy())
运行结果:
torch SRN:
[[-0.5923652 -0.8190415 0.40035477 -0.5075322 -0.35371515 -0.22017957
0.65085465 0.45628175 -0.90666026 0.20130762]
[-0.46359056 -0.57503724 0.4194529 -0.68626827 -0.2125462 -0.7227544
-0.38344145 0.3288192 -0.7057079 0.7228523 ] [-0.7226276 -0.24671507 0.30507863 -0.8134507 -0.27101347 0.45272112
-0.8702079 0.30729052 -0.7665854 -0.4678279 ]
[-0.6271971 -0.32233694 0.22267935 -0.63571733 0.40924022 -0.00917704 -0.07235457 -0.17374675 -0.20947492 0.5138506 ]
[-0.23913841 -0.25116107 -0.23262611 -0.18957138 0.17247549 -0.26798296
-0.05431988 -0.4798586 0.42398912 -0.14127134]]self SRN:
[[-0.628695 0.994756 0.77542424 -0.96934986 -0.89226896 0.97340995
-0.95874524 0.03473029 0.67272 0.9668805 ] [-0.832219 0.9734621 0.5238256 0.6817964 0.65934086 -0.613697
-0.47475585 -0.4833269 0.7003928 0.5869132 ]]
可以看到,两者的输出基本是一致的。另外,还可以进行对比两者在运算速度方面的差异。代码实现如下:
import time
# 这里创建一个随机数组作为测试数据,数据shape为batch_size x seq_len x input_size
batch_size, seq_len, input_size, hidden_size = 2, 5, 10, 10
inputs = torch.randn(size=[batch_size, seq_len, input_size])
# 实例化模型
self_srn = SRN(input_size, hidden_size)
paddle_srn = nn.RNN(input_size, hidden_size)
# 计算自己实现的SRN运算速度
model_time = 0
for i in range(100):
strat_time = time.time()
out = self_srn(inputs)
# 预热10次运算,不计入最终速度统计
if i < 10:
continue
end_time = time.time()
model_time += (end_time - strat_time)
avg_model_time = model_time / 90
print('self_srn speed:', avg_model_time, 's')
# 计算Paddle内置的SRN运算速度
model_time = 0
for i in range(100):
strat_time = time.time()
out = paddle_srn(inputs)
# 预热10次运算,不计入最终速度统计
if i < 10:
continue
end_time = time.time()
model_time += (end_time - strat_time)
avg_model_time = model_time / 90
print('torch_srn speed:', avg_model_time, 's')
运行结果:
self_srn speed: 0.0003100686603122287 s
torch_srn speed: 0.00012180805206298828 s
可以看到,由于pytorch内部相关算子由C++实现,pytorch框架实现的SRN的运行效率显著高于自己实现的SRN效率。
线性层会将最后一个时刻的隐状态向量 H L ∈ R B × D \boldsymbol{H}_L \in \mathbb{R}^{B \times D} HL∈RB×D进行线性变换,输出分类的对数几率(Logits)为:
Y = H L W o + b o , \boldsymbol{Y} = \boldsymbol{H}_L \boldsymbol{W}_o + \boldsymbol{b}_o, Y=HLWo+bo,
其中 W o ∈ R D × 19 \boldsymbol{W}_o \in \mathbb{R}^{D \times 19} Wo∈RD×19, b o ∈ R 19 \boldsymbol{b}_o \in \mathbb{R}^{19} bo∈R19为可学习的权重矩阵和偏置。
提醒:在分类问题的实践中,我们通常只需要模型输出分类的对数几率(Logits),而不用输出每个类的概率。这需要损失函数可以直接接收对数几率来损失计算。
线性层直接使用torch.nn.Linear
算子。
在定义了每一层的算子之后,我们定义一个数字求和模型Model_RNN4SeqClass,该模型会将嵌入层、SRN层和线性层进行组合,以实现数字求和的功能.
具体来讲,Model_RNN4SeqClass会接收一个SRN层实例,用于处理数字序列数据,同时在__init__
函数中定义一个Embedding
嵌入层,其会将输入的数字作为索引,输出对应的向量,最后会使用torch.nn.Linear
定义一个线性层。
提醒:为了方便进行对比实验,我们将SRN层的实例化放在\code{Model_RNN4SeqClass}类外面。通常情况下,模型内部算子的实例化是放在模型里面。
在forward
函数中,调用上文实现的嵌入层、SRN层和线性层处理数字序列,同时返回最后一个位置的隐状态向量。代码实现如下:
# 基于RNN实现数字预测的模型
class Model_RNN4SeqClass(nn.Module):
def __init__(self, model, num_digits, input_size, hidden_size, num_classes):
super(Model_RNN4SeqClass, self).__init__()
# 传入实例化的RNN层,例如SRN
self.rnn_model = model
# 词典大小
self.num_digits = num_digits
# 嵌入向量的维度
self.input_size = input_size
# 定义Embedding层
self.embedding = Embedding(num_digits, input_size)
# 定义线性层
self.linear = nn.Linear(hidden_size, num_classes)
def forward(self, inputs):
# 将数字序列映射为相应向量
inputs_emb = self.embedding(inputs)
# 调用RNN模型
hidden_state = self.rnn_model(inputs_emb)
# 使用最后一个时刻的状态进行数字预测
logits = self.linear(hidden_state)
return logits
# 实例化一个input_size为4, hidden_size为5的SRN
srn = SRN(4, 5)
# 基于srn实例化一个数字预测模型实例
model = Model_RNN4SeqClass(srn, 10, 4, 5, 19)
# 生成一个shape为 2 x 3 的批次数据
inputs = torch.tensor([[1, 2, 3], [2, 3, 4]])
# 进行模型前向预测
logits = model(inputs)
print(logits)
运行结果:
tensor([[-0.0620, -0.8865, -0.0472, -0.0610, 0.7749, -0.6986, 0.0397, -1.1050,
-0.2843, -0.5944, -0.0269, 0.2886, 0.3048, 0.7880, 0.0324, -0.8883,
0.2734, -0.0778, 0.1322],
[-0.0485, -0.9465, -0.0383, -0.0758, 0.6737, -0.6975, 0.0283, -0.9822,
-0.1676, -0.8250, -0.1196, 0.3432, 0.2035, 0.9329, 0.1150, -0.9753,
0.3522, -0.1318, 0.2576]], grad_fn=)
基于RunnerV3类进行训练,只需要指定length便可以加载相应的数据。设置超参数,使用Adam优化器,学习率为 0.001,实例化模型,使用第4.5.4节定义的Accuracy计算准确率。使用Runner进行训练,训练回合数设为500。代码实现如下:
import os
import random
import torch
import numpy as np
from nndl import Accuracy, RunnerV3
# 训练轮次
num_epochs = 500
# 学习率
lr = 0.001
# 输入数字的类别数
num_digits = 10
# 将数字映射为向量的维度
input_size = 32
# 隐状态向量的维度
hidden_size = 32
# 预测数字的类别数
num_classes = 19
# 批大小
batch_size = 8
# 模型保存目录
save_dir = "./checkpoints"
# 通过指定length进行不同长度数据的实验
def train(length):
print(f"\n====> Training SRN with data of length {length}.")
# 固定随机种子
np.random.seed(0)
random.seed(0)
torch.manual_seed(0)
# 加载长度为length的数据
data_path = f"./datasets/{length}"
train_examples, dev_examples, test_examples = load_data(data_path)
train_set, dev_set, test_set = DigitSumDataset(train_examples), DigitSumDataset(dev_examples), DigitSumDataset(test_examples)
train_loader = torch.utils.data.DataLoader(train_set, batch_size=batch_size)
dev_loader = torch.utils.data.DataLoader(dev_set, batch_size=batch_size)
test_loader = torch.utils.data.DataLoader(test_set, batch_size=batch_size)
# 实例化模型
base_model = SRN(input_size, hidden_size)
model = Model_RNN4SeqClass(base_model, num_digits, input_size, hidden_size, num_classes)
# 指定优化器
optimizer = torch.optim.Adam(model.parameters(), lr)
# 定义评价指标
metric = Accuracy()
# 定义损失函数
loss_fn = nn.CrossEntropyLoss()
# 基于以上组件,实例化Runner
runner = RunnerV3(model, optimizer, loss_fn, metric)
# 进行模型训练
model_save_path = os.path.join(save_dir, f"best_srn_model_{length}.pdparams")
runner.train(train_loader, dev_loader, num_epochs=num_epochs, eval_steps=100, log_steps=100, save_path=model_save_path)
return runner
接下来,分别进行数据长度为10, 15, 20, 25, 30, 35的数字预测模型训练实验,训练后的runner
保存至runners
字典中。
srn_runners = {}
lengths = [10, 15, 20, 25, 30, 35]
for length in lengths:
runner = train(length)
srn_runners[length] = runner
定义plot_training_loss
函数,分别画出各个长度的数字预测模型训练过程中,在训练集和验证集上的损失曲线,实现代码实现如下:
import matplotlib.pyplot as plt
def plot_training_loss(runner, fig_name, sample_step):
plt.figure()
train_items = runner.train_step_losses[::sample_step]
train_steps = [x[0] for x in train_items]
train_losses = [x[1] for x in train_items]
plt.plot(train_steps, train_losses, color='#e4007f', label="Train loss")
dev_steps = [x[0] for x in runner.dev_losses]
dev_losses = [x[1] for x in runner.dev_losses]
plt.plot(dev_steps, dev_losses, color='#f19ec2', linestyle='--', label="Dev loss")
# 绘制坐标轴和图例
plt.ylabel("loss", fontsize='large')
plt.xlabel("step", fontsize='large')
plt.legend(loc='upper right', fontsize='x-large')
plt.savefig(fig_name)
plt.show()
# 画出训练过程中的损失图
for length in lengths:
runner = srn_runners[length]
fig_name = f"./images/6.6_{length}.pdf"
plot_training_loss(runner, fig_name, sample_step=100)
下图展示了在6个数据集上的损失变化情况,数据集的长度分别为10、15、20、25、30和35. 从输出结果看,随着数据序列长度的增加,虽然训练集损失逐渐逼近于0,但是验证集损失整体趋向越来越大,这表明当序列变长时,SRN模型保持序列长期依赖能力在逐渐变弱,越来越无法学习到有用的知识。
在模型评价时,加载不同长度的效果最好的模型,然后使用测试集对该模型进行评价,观察模型在测试集上预测的准确度. 同时记录一下不同长度模型在训练过程中,在验证集上最好的效果。代码实现如下。
srn_dev_scores = []
srn_test_scores = []
for length in lengths:
print(f"Evaluate SRN with data length {length}.")
runner = srn_runners[length]
# 加载训练过程中效果最好的模型
model_path = os.path.join(save_dir, f"best_srn_model_{length}.pdparams")
runner.load_model(model_path)
# 加载长度为length的数据
data_path = f"D:/datasets/{length}"
train_examples, dev_examples, test_examples = load_data(data_path)
test_set = DigitSumDataset(test_examples)
test_loader = torch.utils.data.DataLoader(test_set, batch_size=batch_size)
# 使用测试集评价模型,获取测试集上的预测准确率
score, _ = runner.evaluate(test_loader)
srn_test_scores.append(score)
srn_dev_scores.append(max(runner.dev_scores))
for length, dev_score, test_score in zip(lengths, srn_dev_scores, srn_test_scores):
print(f"[SRN] length:{length}, dev_score: {dev_score}, test_score: {test_score: .5f}")
接下来,将SRN在不同长度的验证集和测试集数据上的表现,绘制成图片进行观察。
import matplotlib.pyplot as plt
plt.plot(lengths, srn_dev_scores, '-o', color='#e4007f', label="Dev Accuracy")
plt.plot(lengths, srn_test_scores,'-o', color='#f19ec2', label="Test Accuracy")
#绘制坐标轴和图例
plt.ylabel("accuracy", fontsize='large')
plt.xlabel("sequence length", fontsize='large')
plt.legend(loc='upper right', fontsize='x-large')
fig_name = "./images/6.7.pdf"
plt.savefig(fig_name)
plt.show()
下图展示了SRN模型在不同长度数据训练出来的最好模型在验证集和测试集上的表现。可以看到,随着序列长度的增加,验证集和测试集的准确度整体趋势是降低的,这同样说明SRN模型保持长期依赖的能力在不断降低。