NNDL 实验七 循环神经网络(1)RNN记忆能力实验

  • 第6章 循环神经网络
    • 6.1 循环神经网络的记忆能力实验
      • 6.1.1 数据集构建
        • 6.1.1.1 数据集的构建函数
        • 6.1.1.2 加载数据并进行数据划分
        • 6.1.1.3 构造Dataset类
      • 6.1.2 模型构建
        • 6.1.2.1 嵌入层
        • One-Hot编码
        • One-Hot编码的作用
        • 6.1.2.2 SRN层
        • torch.nn.rnn的使用
        • 6.1.2.3 线性层
        • 6.1.2.4 模型汇总
      • 6.1.3 模型训练
        • 6.1.3.1 训练指定长度的数字预测模型
        • 6.1.3.2 多组训练
        • 6.1.3.3 损失曲线展示
      • 6.1.4 模型评价
    • 总结:
    • 参考

第6章 循环神经网络

循环神经网络(Recurrent Neural Network,RNN)是一类具有短期记忆能力的神经网络.在循环神经网络中,神经元不但可以接受其他神经元的信息,也可以接受自身的信息,形成具有环路的网络结构.和前馈神经网络相比,循环神经网络更加符合生物神经网络的结构.目前,循环神经网络已经被广泛应用在语音识别、语言模型以及自然语言生成等任务上.

本章内容基于《神经网络与深度学习》第6章:循环神经网络的相关内容进行设计。在阅读本章之前,建议先了解如图6.1所示的关键知识点,以便更好地理解和掌握相应的理论和实践知识。


图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} xtRM,并与前一时刻的隐状态 h t − 1 ∈ R D \boldsymbol{h}_{t-1} \in \mathbb{R}^D ht1RD一起进行计算,输出一个新的当前时刻的隐状态 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(ht1,xt),

其中 h 0 = 0 \boldsymbol{h}_{0} = 0 h0=0 f ( ⋅ ) f(\cdot) f()是一个非线性函数.

循环神经网络的参数可以通过梯度下降法来学习。和前馈神经网络类似,我们可以使用随时间反向传播(BackPropagation Through Time,BPTT)算法高效地手工计算梯度,也可以使用自动微分的方法,通过计算图自动计算梯度。

循环神经网络被认为是图灵完备的,一个完全连接的循环神经网络可以近似解决所有的可计算问题。然而,虽然理论上循环神经网络可以建立长时间间隔的状态之间的依赖关系,但是由于具体的实现方式和参数学习方式会导致梯度爆炸或梯度消失问题,实际上,通常循环神经网络只能学习到短期的依赖关系,很难建模这种长距离的依赖关系,称为长程依赖问题(Long-Term Dependencies Problem)

6.1 循环神经网络的记忆能力实验

循环神经网络的一种简单实现是简单循环网络(Simple Recurrent Network,SRN).

令向量 x t ∈ R M \boldsymbol{x}_t \in \mathbb{R}^M xtRM表示在时刻 t t t时网络的输入, h t ∈ R D \boldsymbol{h_t} \in \mathbb{R}^D htRD 表示隐藏层状态(即隐藏层神经元活性值),则 h t \boldsymbol{h}_t ht不仅和当前时刻的输入 x t \boldsymbol{x}_t xt相关,也和上一个时刻的隐藏层状态 h t − 1 \boldsymbol{h}_{t-1} ht1相关. 简单循环网络在时刻 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+Uht1+b),

其中 h t \boldsymbol{h}_{t} ht为隐状态向量, U ∈ R D × D \boldsymbol{U} \in \mathbb{R}^{D\times D} URD×D状态-状态权重矩阵, W ∈ R D × M \boldsymbol{W} \in \mathbb{R}^{D\times M} WRD×M状态-输入权重矩阵, b ∈ R D \boldsymbol{b}\in \mathbb{R}^{D} bRD为偏置向量。

图6.2 展示了一个按时间展开的循环神经网络。


图6.2 循环神经网络结构

简单循环网络在参数学习时存在长程依赖问题,很难建模长时间间隔(Long Range)的状态之间的依赖关系。为了测试简单循环网络的记忆能力,本节构建一个数字求和任务进行实验。

数字求和任务的输入是一串数字,前两个位置的数字为0-9,其余数字随机生成(主要为0),预测目标是输入序列中前两个数字的加和。图6.3展示了长度为10的数字序列.


图6.3 数字求和任务示例

如果序列长度越长,准确率越高,则说明网络的记忆能力越好.因此,我们可以构建不同长度的数据集,通过验证简单循环网络在不同长度的数据集上的表现,从而测试简单循环网络的长程依赖能力.

6.1.1 数据集构建

我们首先构建不同长度的数字预测数据集DigitSum.

6.1.1.1 数据集的构建函数

由于在本任务中,输入序列的前两位数字为 0 − 9,其组合数是固定的,所以可以穷举所有的前两位数字组合,并在后面默认用0填充到固定长度. 但考虑到数据的多样性,这里对生成的数字序列中的零位置进行随机采样,并将其随机替换成0-9的数字以增加样本的数量.

我们可以通过设置 k k k的数值来指定一条样本随机生成的数字序列数量.当生成某个指定长度的数据集时,会同时生成训练集、验证集和测试集。当 k k k=3时,生成训练集。当 k k k=1时,生成验证集和测试集. 代码实现如下:

import random
import numpy as np

# 固定随机种子
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的训练数据
    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)

NNDL 实验七 循环神经网络(1)RNN记忆能力实验_第1张图片

6.1.1.2 加载数据并进行数据划分

为方便使用,本实验提前生成了长度分别为5、10、 15、20、25、30和35的7份数据,存放于“./datasets”目录下,读者可以直接加载使用。代码实现如下:



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

在这里插入图片描述

6.1.1.3 构造Dataset类

为了方便使用梯度下降法进行优化,我们构造了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)

6.1.2 模型构建

使用SRN模型进行数字加和任务的模型结构为如图6.4所示.


图6.4 基于SRN模型的数字预测

整个模型由以下几个部分组成:
(1) 嵌入层:将输入的数字序列进行向量化,即将每个数字映射为向量;
(2) SRN 层:接收向量序列,更新循环单元,将最后时刻的隐状态作为整个序列的表示;
(3) 输出层:一个线性层,输出分类的结果.

6.1.2.1 嵌入层

本任务输入的样本是数字序列,为了更好地表示数字,需要将数字映射为一个嵌入(Embedding)向量。嵌入向量中的每个维度均能用来刻画该数字本身的某种特性。由于向量能够表达该数字更多的信息,利用向量进行数字求和任务,可以使得模型具有更强的拟合能力。

首先,我们构建一个嵌入矩阵(Embedding Matrix) E ∈ R 10 × M \boldsymbol{E}\in \mathbb{R}^{10\times M} ER10×M,其中第 i i i行对应数字 i i i的嵌入向量,每个嵌入向量的维度是 M M M。如图6.5所示。
给定一个组数字序列 S ∈ R B × L \boldsymbol{S} \in \mathbb{R}^{B\times L} SRB×L,其中 B B B为批大小, L L L为序列长度,可以通过查表将其映射为嵌入表示 X ∈ R B × L × M \boldsymbol{X}\in \mathbb{R}^{B\times L \times M} XRB×L×M

图6.5 嵌入矩阵

提醒:为了和代码的实现保持一致性,这里使用形状为 ( 样 本 数 量 × 序 列 长 度 × 特 征 维 度 ) (样本数量\times 序列长度\times 特征维度) (××)的张量来表示一组样本。

或者也可以将每个数字表示为10维的one-hot向量,使用矩阵运算得到嵌入表示:

X = S ′ E , \boldsymbol{X} = \boldsymbol{S}^{'} \boldsymbol{E}, X=SE

其中 S ′ ∈ R B × L × 10 \boldsymbol{S}' \in \mathbb{R}^{B\times L\times 10} SRB×L×10是序列 S \boldsymbol{S} S对应的one-hot表示。

思考:如果不使用嵌入层,直接将数字作为SRN层输入有什么问题?
不能直接将数字作为SRN的输入,因为SRN无法识别数字,和一些高级语义,只能将其转换为one-hot类型(独热)的变量才能简单循环神经网络作为输入进行识别。

基于索引方式的嵌入层的实现如下:

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)
'''
Tensor([[-0.79542428,  0.64475256, -0.36605513,  0.77095968,  0.71169198],
        [ 0.80350786, -0.72439903, -0.18109815, -0.37508604, -0.78172743],
        [ 0.44200459, -0.68664825, -0.33440509,  0.06880792, -0.08246894],
        [ 0.37811810,  0.17228007, -0.70293051,  0.80796665,  0.58285153]])
'''

思考:请同学们思考基于one-hot编码的嵌入层应该如何实现.
建立一个具有总类型的大小的数组,每一种类型的数据对占用一个大小,变成1,其余的为0.

One-Hot编码

又称为一位有效编码,主要是采用N位状态寄存器来对N个状态进行编码,每个状态都由他独立的寄存器位,并且在任意时候只有一位有效。
年级
[“初一”,“初二”,“初三”]
初一:1 0 0 初二:0 1 0初三:0 0 1
学校
[“一中”,“二中”,“三中”,“四中”]
一中:1 0 0 0二中:0 1 0 0三中:0 0 1 0四中:0 0 0 1
One-Hot编码是分类变量作为二进制向量的表示。这首先要求将分类值映射到整数值。然后,每个整数值被表示为二进制向量,除了整数的索引之外,它都是零值,它被标记为1。

听概念的话显得比较复杂,我们来看一个例子。
假设我们有一群学生,他们可以通过四个特征来形容,分别是:

性别:[“男”,“女”]
年级:[“初一”,“初二”,“初三”]
学校:[“一中”,“二中”,“三中”,“四中”]
举个例子,用上述四个特征来描述小明同学,即“男生,初一,来自二中”,如果特征类别是有序的话,我们能够用表示顺序的数组表示

即“男生,初一,来自一中” ==> [0,0,1]
因此,当我们再来描述小明的时候,就可以采用 [1 0 1 0 0 0 1 0 0]

但是这样的特征处理并不能直接放入机器学习算法中,因为类别之间是无序的。

One-Hot编码的作用

之所以使用One-Hot编码,是因为在很多机器学习任务中,特征并不总是连续值,也有可能是离散值(如上表中的数据)。将这些数据用数字来表示,执行的效率会高很多。

性别:[“男”,“女”]
年级:[“初一”,“初二”,“初三”]
学校:[“一中”,“二中”,“三中”,“四中”]
若是直接转换成数字的话,[“男”,“初二”,“四中”]的表示方式就是[0,1,3]。

然而,即使转化为数字表示后,上述数据也不能直接用在分类器中。因为分类器往往默认数据数据是连续的、有序的。但是,直接数字并不是有序的,而是随机分配的。为了解决上述问题,其中一种可能的解决方法是采用独热编码。

这时候就可以用独热编码的形式来表示了,我们用采用N位状态寄存器来对N个状态进行编码,拿上面的例子来说,就是:

6.1.2.2 SRN层

数字序列 S ∈ R B × L \boldsymbol{S} \in \mathbb{R}^{B\times L} SRB×L经过嵌入层映射后,转换为 X ∈ R B × L × M \boldsymbol{X}\in \mathbb{R}^{B\times L\times M} XRB×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} XtRB×M与隐状态 H t − 1 ∈ R B × D \boldsymbol{H}_{t-1} \in \mathbb{R}^{B \times D} Ht1RB×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+Ht1U+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} WRM×D,URD×D,bR1×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)

在这里插入图片描述
飞桨框架已经内置了SRN的API nn.RNN,其与自己实现的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+UHt1+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} WRM×D,URD×D,bxR1×D,bhR1×D是可学习参数, M M M表示嵌入向量的维度, D D D表示隐状态向量的维度。

另外,内置SRN API在执行完前向计算后,会返回两个参数:序列向量和最后时刻的隐状态向量。在torch实现时,考虑到了双向和多层SRN的因素,返回的向量附带了这些信息。

torch.nn.rnn的使用

NNDL 实验七 循环神经网络(1)RNN记忆能力实验_第2张图片

nn.RNN(input_size, hidden_size, num_layers=1, nonlinearity=tanh, bias=True, batch_first=False, dropout=0, bidirectional=False)
参数说明
input_size输入特征的维度, 一般rnn中输入的是词向量,那么 input_size 就等于一个词向量的维度
hidden_size隐藏层神经元个数,或者也叫输出的维度(因为rnn输出为各个时间步上的隐藏状态)
num_layers网络的层数
nonlinearity激活函数
bias是否使用偏置
batch_first输入数据的形式,默认是 False,就是这样形式,(seq(num_step), batch, input_dim),也就是将序列长度放在第一位,batch 放在第二位
dropout是否应用dropout, 默认不使用,如若使用将其设置成一个0-1的数字即可
birdirectional是否使用双向的 rnn,默认是 False
注意某些参数的默认值在标题中已注明
输入输出shape
input_shape = [时间步数, 批量大小, 特征维度] = [num_steps(seq_length), batch_size, input_dim]
在前向计算后会分别返回输出和隐藏状态h,其中输出指的是隐藏层在各个时间步上计算并输出的隐藏状态,它们通常作为后续输出层的输⼊。需要强调的是,该“输出”本身并不涉及输出层计算,形状为(时间步数, 批量大小, 隐藏单元个数);隐藏状态指的是隐藏层在最后时间步的隐藏状态:当隐藏层有多层时,每⼀层的隐藏状态都会记录在该变量中;对于像⻓短期记忆(LSTM),隐藏状态是⼀个元组(h, c),即hidden state和cell state(此处普通rnn只有一个值)隐藏状态h的形状为(层数, 批量大小,隐藏单元个数)

其中序列向量outputs是指最后一层SRN的输出向量,其shape为[batch_size, seq_len, num_directions * hidden_size];最后时刻的隐状态向量shape为[num_layers * num_directions, batch_size, hidden_size]。

这里我们可以将自己实现的SRN和Paddle框架内置的SRN返回的结果进行打印展示,实现代码如下。


# 这里创建一个随机数组作为测试数据,数据shape为batch_size x seq_len x input_size
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("paddle_srn outpus:", paddle_outputs.shape)
print("paddle_srn hidden_state:", paddle_hidden_state.shape)
'''
self_srn hidden_state:  [8, 32]
paddle_srn outpus: [8, 20, 32]
paddle_srn hidden_state: [1, 8, 32]
'''

可以看到,自己实现的SRN由于没有考虑多层因素,因此没有层次这个维度,因此其输出shape为[8, 32]。同时由于在以上代码使用Paddle内置API实例化SRN时,默认定义的是1层的单向SRN,因此其shape为[1, 8, 32],同时隐状态向量为[8,20, 32].

接下来,我们可以将自己实现的SRN与Paddle内置的SRN在输出值的精度上进行对比,这里首先根据torch内置的SRN实例化模型(为了进行对比,在实例化时只保留一个偏置,将偏置 b x b_x bx设置为0),然后提取该模型对应的参数,使用该参数去初始化自己实现的SRN,从而保证两者在参数初始化时是一致的。

在进行实验时,首先定义输入数据inputs,然后将该数据分别传入Paddle内置的SRN与自己实现的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("paddle SRN:\n", paddle_hidden_state.detach().numpy().squeeze(0))
print("self SRN:\n", self_hidden_state.detach().numpy())
'''
paddle SRN:
 [[ 0.32466057 -0.05465738 -0.3090897  -0.5160461  -0.11149617  0.42673123
   0.47200012 -0.06585313  0.8531997   0.18898566]
 [-0.4299355  -0.6067489  -0.59150505  0.3024527  -0.03939504  0.6146276
   0.40302172  0.498835    0.02484459 -0.3851626 ]]
self SRN:
 [[ 0.3246606  -0.05465741 -0.30908972 -0.51604617 -0.11149608  0.42673123
   0.47200012 -0.06585314  0.8531997   0.18898572]
 [-0.4299354  -0.6067489  -0.59150505  0.3024528  -0.03939504  0.6146276
   0.40302178  0.498835    0.02484459 -0.3851626 ]]
'''

可以看到,两者的输出基本是一致的。另外,还可以进行对比两者在运算速度方面的差异。代码实现如下:


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('paddle_srn speed:', avg_model_time, 's')

'''
self_srn speed: 0.001403069496154785 s
paddle_srn speed: 0.00044725206163194443 s
'''

可以看到,由于Paddle内部相关算子由C++实现,Paddle框架实现的SRN的运行效率显著高于自己实现的SRN效率。

6.1.2.3 线性层

线性层会将最后一个时刻的隐状态向量 H L ∈ R B × D \boldsymbol{H}_L \in \mathbb{R}^{B \times D} HLRB×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} WoRD×19 b o ∈ R 19 \boldsymbol{b}_o \in \mathbb{R}^{19} boR19为可学习的权重矩阵和偏置。

提醒:在分类问题的实践中,我们通常只需要模型输出分类的对数几率(Logits),而不用输出每个类的概率。这需要损失函数可以直接接收对数几率来损失计算。

线性层直接使用torch.nn.Linear算子。

6.1.2.4 模型汇总

在定义了每一层的算子之后,我们定义一个数字求和模型Model_RNN4SeqClass,该模型会将嵌入层、SRN层和线性层进行组合,以实现数字求和的功能.

具体来讲,Model_RNN4SeqClass会接收一个SRN层实例,用于处理数字序列数据,同时在__init__函数中定义一个Embedding嵌入层,其会将输入的数字作为索引,输出对应的向量,最后会使用torch.nn.Linear定义一个线性层。

提醒:为了方便进行对比实验,我们将SRN层的实例化放在{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)

NNDL 实验七 循环神经网络(1)RNN记忆能力实验_第3张图片

6.1.3 模型训练

6.1.3.1 训练指定长度的数字预测模型

基于RunnerV3类进行训练,只需要指定length便可以加载相应的数据。设置超参数,使用Adam优化器,学习率为 0.001,实例化模型,使用第4.5.4节定义的Accuracy计算准确率。使用Runner进行训练,训练回合数设为500。代码实现如下:


import os
import random
import torch
import numpy as np


# 训练轮次
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"D:/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

6.1.3.2 多组训练

接下来,分别进行数据长度为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

NNDL 实验七 循环神经网络(1)RNN记忆能力实验_第4张图片

6.1.3.3 损失曲线展示

定义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"D:/images/6.6_{length}.pdf"
    plot_training_loss(runner, fig_name, sample_step=100)

NNDL 实验七 循环神经网络(1)RNN记忆能力实验_第5张图片

NNDL 实验七 循环神经网络(1)RNN记忆能力实验_第6张图片

NNDL 实验七 循环神经网络(1)RNN记忆能力实验_第7张图片

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='#8E004D', 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='#E20079', linestyle='--', label="Dev loss")

    #绘制坐标轴和图例
    plt.ylabel("loss", fontsize='x-large')
    plt.xlabel("step", fontsize='x-large')
    plt.legend(loc='upper right', fontsize='x-large')

    plt.savefig(fig_name)
    plt.show()
    # 画出训练过程中的损失图
for length in lengths:
    runner = srn_runners[length]f
    fig_name = f"./images/6.6_{length}.pdf"
    plot_training_loss(runner, fig_name, sample_step=100)

图6.6展示了在6个数据集上的损失变化情况,数据集的长度分别为10、15、20、25、30和35. 从输出结果看,随着数据序列长度的增加,虽然训练集损失逐渐逼近于0,但是验证集损失整体趋向越来越大,这表明当序列变长时,SRN模型保持序列长期依赖能力在逐渐变弱,越来越无法学习到有用的知识.

NNDL 实验七 循环神经网络(1)RNN记忆能力实验_第8张图片
图6.6 SRN在不同长度数据集训练损失变化图

6.1.4 模型评价

在模型评价时,加载不同长度的效果最好的模型,然后使用测试集对该模型进行评价,观察模型在测试集上预测的准确度. 同时记录一下不同长度模型在训练过程中,在验证集上最好的效果。代码实现如下。

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"./datasets/{length}"
    train_examples, dev_examples, test_examples = load_data(data_path)
    test_set = DigitSumDataset(test_examples)
    test_loader = paddle.io.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}")
    '''
Evaluate SRN with data length 10.
Evaluate SRN with data length 15.
Evaluate SRN with data length 20.
Evaluate SRN with data length 25.
Evaluate SRN with data length 30.
Evaluate SRN with data length 35.
[SRN] length:10, dev_score: 0.9, test_score:  0.81000
[SRN] length:15, dev_score: 0.71, test_score:  0.64000
[SRN] length:20, dev_score: 0.8, test_score:  0.74000
[SRN] length:25, dev_score: 0.56, test_score:  0.53000
[SRN] length:30, dev_score: 0.45, test_score:  0.34000
[SRN] length:35, dev_score: 0.54, test_score:  0.49000
    '''

接下来,将SRN在不同长度的验证集和测试集数据上的表现,绘制成图片进行观察。

import matplotlib.pyplot as plt

plt.plot(lengths, srn_dev_scores, '-o', color='#8E004D',  label="Dev Accuracy")
plt.plot(lengths, srn_test_scores,'-o', color='#E20079', label="Test Accuracy")

#绘制坐标轴和图例
plt.ylabel("loss", fontsize='x-large')
plt.xlabel("step", fontsize='x-large')
plt.legend(loc='upper right', fontsize='x-large')

fig_name = "./images/6.7.pdf"
plt.savefig(fig_name)
plt.show()

图6.7 展示了SRN模型在不同长度数据训练出来的最好模型在验证集和测试集上的表现。可以看到,随着序列长度的增加,验证集和测试集的准确度整体趋势是降低的,这同样说明SRN模型保持长期依赖的能力在不断降低.

NNDL 实验七 循环神经网络(1)RNN记忆能力实验_第9张图片
图6.7 SRN在不同长度的验证集和测试集的准确度变化图

总结:

通过这次实验,对不同序列长度的数据集进行训练,我们发现相关信息和需要该信息的位置间的距离可能非常远。当距离逐渐增大时,RNN对于如何将这样的信息连接起来无能为力,也就是说,RNN并没有能力来学习这些。而后面即将学习的,LSTM作为一种特殊的RNN,则能够学习到长期依赖关系,循环神经网络也是NLP自然语言处理常用的神经网络。学到了一些编码方式如one-hot编码,整数编码等。

参考

循环神经网络
RNN总结
一文搞懂one-hot和embedding

你可能感兴趣的:(rnn,深度学习,神经网络)