概述

循环神经网络(Recurrent Neural Network,RNN)是一种具有循环连接的神经网络结构,被广泛应用于自然语言处理、语音识别、时序数据分析等任务中。相较于传统神经网络,RNN的主要特点在于它可以处理序列数据,能够捕捉到序列中的时序信息。

RNN的基本单元是一个循环单元(Recurrent Unit),它接收一个输入和一个来自上一个时间步的隐藏状态,并输出当前时间步的隐藏状态。在传统的RNN中,循环单元通常使用tanh或ReLU等激活函数。

基本循环神经网络

原理

基本的 循环神经网络,结构由 输入层、一个隐藏层和输出层 组成。

在这里插入图片描述
   x x x是输入向量, o o o是输出向量, s s s表示隐藏层的值; U U U是输入层到隐藏层的权重矩阵, V V V是隐藏层到输出层的权重矩阵。循环神经网络的隐藏层的值s不仅仅取决于当前这次的输入 x x x,还取决于上一次隐藏层的值 s s s。权重矩阵W就是隐藏层上一次的值作为这一次的输入的权重。
  
将上图的基本RNN结构在时间维度展开(RNN是一个链式结构,每个时间片使用的是相同的参数,t表示t时刻):
在这里插入图片描述
现在看上去就会清楚许多,这个网络在t时刻接收到输入 x t x_t xt之后,隐藏层的值是 s t s_t st,输出值是 o t o_t ot。关键一点是 s t s_t st的值不仅仅取决于 x t x_t xt,还取决于 s t − 1 s_{t−1} st1
公式1: s t = f ( U ∗ x t + W ∗ s t − 1 + B 1 ) s_t=f(U∗x_t+W∗s_{t−1}+B1) st=f(Uxt+Wst1+B1)
公式2: o t = g ( V ∗ s t + B 2 ) o_t=g(V∗s_t+B2) ot=g(Vst+B2)

  • 式1是隐藏层的计算公式,它是循环层。U是输入x的权重矩阵,W是上一次隐藏层值 S t − 1 S_{t−1} St1作为这一次的输入的权重矩阵,f是激活函数。
  • 式2是输出层的计算公式,V是输出层的权重矩阵,g是激活函数,B1,B2是偏置假设为0。

隐含层有两个输入,第一是U与 x t x_t xt向量的乘积,第二是上一隐含层输出的状态 s t − 1 s_t−1 st1和W的乘积。等于上一个时刻计算的 s t − 1 s_t−1 st1需要缓存一下,在本次输入 x t x_t xt一起计算,共同输出最后的 o t o_t ot

如果反复把式1带入式2,我们将得到:
在这里插入图片描述
从上面可以看出,循环神经网络的输出值ot,是受前面历次输入值、、、、、、、、 x t x_t xt x t − 1 x_{t−1} xt1 x t − 2 x_{t−2} xt2 x t − 3 x_{t−3} xt3、…影响的,这就是为什么循环神经网络可以往前看任意多个输入值的原因。这样其实不好,因为如果太前面的值和后面的值已经没有关系了,循环神经网络还考虑前面的值的话,就会影响后面值的判断。

上面是整个单向单层NN的前向传播过程

为了更快理解输入x输入格式下面使用nlp中Word Embedding讲解下。

Word Embedding

首先我们需要对输入文本x进行编码,使之成为计算机可以读懂的语言,在编码时,我们期望句子之间保持词语间的相似行,词的向量表示是进行机器学习和深度学习的基础。

word embedding的一个基本思路就是,我们把一个词映射到语义空间的一个点,把一个词映射到低维的稠密空间,这样的映射使得语义上比较相似的词,他在语义空间的距离也比较近,如果两个词的关系不是很接近,那么在语义空间中向量也会比较远。

在这里插入图片描述
如上图英语和西班牙语映射到语义空间,语义相同的数字他们在语义空间分布的位置是相同的
简单回顾一下word embedding,对于nlp来说,我们输入的是一个个离散的符号,对于神经网络来说,它处理的都是向量或者矩阵。所以第一步,我们需要把一个词编码成向量。最简单的就是one-hot的表示方法。如下图所示:
在这里插入图片描述
python代码(one-hot),比如

import numpy as np

word_array = ['apple', 'kiwi', 'mango']
word_dict = {'apple': 0, 'banana': 1, 'orange': 2, 'grape': 3, 'melon': 4, 'peach': 5, 'pear': 6, 'kiwi': 7, 'plum': 8, 'mango': 9}

# 创建一个全为0的矩阵
one_hot_matrix = np.zeros((len(word_array), len(word_dict)))

# 对每个单词进行one-hot编码
for i, word in enumerate(word_array):
    word_index = word_dict[word]
    one_hot_matrix[i, word_index] = 1

print(one_hot_matrix)

输出:

[[1. 0. 0. 0. 0. 0. 0. 0. 0. 0.]   #这就是apple的one-hot编码
 [0. 0. 0. 0. 0. 0. 0. 1. 0. 0.]    #这就是kiwi的one-hot编码
 [0. 0. 0. 0. 0. 0. 0. 0. 0. 1.]]    #这就是mango的one-hot编码

行表示每个单词,列表示语料库,每个列对应一个语料单词,也就是特征列

虽然one-hot编码是一种简单有效的特征表示方法,但它也存在一些缺点:

  1. 高维度表示:使用one-hot编码时,每个特征都需要创建一个很大的稀疏向量,维度与特征的唯一值数量相等。这会导致高维度的输入数据,增加了计算和存储的开销。特别是在处理具有大量离散特征的问题时,会导致非常庞大的特征空间。

  2. 维度独立性:one-hot编码将每个特征都表示为独立的二进制特征,没有考虑到特征之间的相关性和语义关系。这可能会导致模型难以捕捉到特征之间的相互作用和关联性,从而影响了模型的性能。

  3. 无法处理未知特征:one-hot编码要求特征的唯一值在训练集中都出现过,否则会出现问题。如果在测试集或实际应用中遇到了未在训练集中出现的特征值,就无法进行one-hot编码,这可能导致模型无法处理这些未知特征。

  4. 特征稀疏性:由于one-hot编码的特征向量是稀疏的,大部分元素都是0,这会导致数据稀疏性增加,对于一些算法(如线性模型)可能会带来一些问题。

综上所述,尽管one-hot编码在某些情况下是一种简单有效的特征表示方法,但它也存在一些缺点,特别是在处理高维度离散特征、考虑特征间关系和处理未知特征值时可能会遇到问题。

使用nn.Embedding替代one-hot编码的原因主要有两点:

  1. 维度灵活性:使用one-hot编码时,每个特征都需要创建一个很大的稀疏向量,维度与特征的唯一值数量相等。这会导致高维度的输入,增加了计算和存储的开销。而使用嵌入(embedding)可以将离散特征映射为低维度的连续向量表示,减少了存储和计算的成本。

  2. 语义关系和相似性:嵌入向量可以捕捉到特征之间的语义关系和相似性。例如,在自然语言处理任务中,使用嵌入向量可以将单词映射为连续的向量表示,使得具有相似语义含义的单词在嵌入空间中距离较近。这样的特性可以帮助模型更好地理解和学习特征之间的关系,提升模型的性能。

因此,使用nn.Embedding替代one-hot编码可以提高模型的效率和性能,特别是在处理高维度的离散特征时。

好的,我们来看一个简单的例子来手推nn.embedding的两个参数的作用。

假设我们有一个句子分类任务,我们的输入是一个句子,每个单词都是一个特征。我们有5个不同的单词,分别是[“I”, “love”, “deep”, “learning”, “!” ]。

我们可以使用nn.embedding来将这些单词映射为嵌入向量(在坐标系中有一个位置指向了这个单词)。假设我们将每个单词嵌入为一个3维的向量。这里,num_embeddings为5,表示我们有5个不同的单词;embedding_dim为3,表示每个单词嵌入为一个3维的向量。

我们可以用下面的表格来表示每个单词的嵌入向量:

单词嵌入向量
“I”[0.1, 0.2, 0.3]
“love”[0.4, 0.5, 0.6]
“deep”[0.7, 0.8, 0.9]
“learning”[0.2, 0.3, 0.4]
“!”[0.5, 0.6, 0.7]

通过nn.embedding,我们可以将句子中的每个单词转换为对应的嵌入向量。例如,句子"I love deep learning!"可以转换为以下嵌入向量序列:

[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9], [0.2, 0.3, 0.4], [0.5, 0.6, 0.7]]

这样,我们就可以将离散的单词特征转换为连续的嵌入向量,在深度学习模型中使用。

以下是pytorch(python入门)的使用

 # 创建词汇表
        vocab = {"I": 0, "love": 1, "deep": 2, "learning": 3, "!": 4}
        strings=["I", "love", "deep", "learning", "!" ]
        # 将字符串序列转换为整数索引序列
        input = t.LongTensor([vocab[word] for word in strings])
        #注意第一个参数是词汇表的个数,并不是输入单词的长度,你在这里就算填100也不影响最终的输出维度,这个输入值影响的是算出来的行向量值
        #nn.Embedding模块会随机初始化嵌入矩阵。在深度学习中,模型参数通常会使用随机初始化的方法来开始训练,以便模型能够在训练过程中学习到合适的参数值。
        #在nn.Embedding中,嵌入矩阵的每个元素都会被随机初始化为一个小的随机值,这些值将作为模型在训练过程中学习的可训练参数,可以使用manual_seed固定。
        t.manual_seed(1234)
        embedding=nn.Embedding(len(vocab),3)
        print(embedding(input))

输出结果为:
tensor([[-0.1117, -0.4966, 0.1631],
[-0.8817, 0.0539, 0.6684],
[-0.0597, -0.4675, -0.2153],
[ 0.8840, -0.7584, -0.3689],
[-0.3424, -1.4020, 0.3206]], grad_fn=)

注意Embedding第一个参数不是输入的字符的长度,而是词汇表的长度,比如有词汇表
{“I”: 0, “love”: 1, “deep”: 2, “learning”: 3, “!”: 4},而输入input可能是:i love,此时应该传入的是5而不是2,因为预测最后隐藏层需要做个全连接用来预测当前输入单词对于整个词汇表的所有单词的概率。

nn.Embedding 是一个简单的查找表,它的计算过程非常直接和简单。让我们来看一个具体的例子来说明它的计算过程。

假设我们有一个词汇表包含以下单词:

"apple" - 索引为0
"banana" - 索引为1
"orange" - 索引为2
"grape" - 索引为3

我们还假设我们正在构建一个3维的词嵌入矩阵,所以每个单词会映射到一个3维向量。现在,让我们看看如何计算单词 “banana” 的向量坐标。
初始化词嵌入矩阵
初始化词嵌入矩阵的过程通常是随机的,其中每个单词的向量都会被初始化为随机的数值。这是因为在大多数情况下,我们不会有关于单词向量的先验信息,因此随机初始化是一个常见的做法。

在实际中,词嵌入矩阵的初始化可以采用不同的方法,其中最常见的是以下两种:

  • 均匀分布初始化:每个单词的向量从一个均匀分布中随机抽样,通常在[-1, 1]或者[0, 1]之间。
  • 正态分布初始化:每个单词的向量从一个正态分布(高斯分布)中随机抽样,通常具有均值为0和标准差为1的标准正态分布。

在 PyTorch 中,默认情况下,nn.Embedding 层的权重(即词嵌入矩阵)会在初始化时使用均匀分布或者正态分布进行随机初始化,具体取决于所选择的初始化方法。

假设我们的初始化词嵌入矩阵如下所示(这里只是一个示例):

[
[0.1, 0.2, 0.3] 
[0.4, 0.5, 0.6] 
[0.7, 0.8, 0.9] 
[1.0, 1.1, 1.2] 
]

计算 “banana” 的向量坐标:由于 “banana” 的索引为1,因此我们只需找到词嵌入矩阵中的第二行,即:

[
[0.4, 0.5, 0.6]
]

所以 “banana” 的向量坐标为 [0.4, 0.5, 0.6]。

这就是 nn.Embedding 计算单词向量坐标的过程。它简单地根据单词的索引来查找词嵌入矩阵中对应的行。

nn.Embedding 层在体现语义相关性方面的能力来自于它的训练过程和所使用的语料库。虽然 nn.Embedding 本身只是一个简单的查找表,它将每个单词映射到一个固定长度的向量,但是这些向量在训练过程中会根据模型任务的损失函数进行调整,以使得模型在语义上更加相似的单词在向量空间中更加接近。

具体来说,当使用诸如语言模型、机器翻译、文本分类等任务进行端到端的训练时,nn.Embedding 层会根据模型的输出和损失函数的反馈进行调整,以最大程度地提高模型在任务上的性能。在这个过程中,如果两个单词在语义上相似(如 “apple” 和 “orange”),它们的词嵌入向量在向量空间中也会更加接近,以使得模型能够更好地捕捉它们之间的语义关系。

另外,训练过程中使用的语料库也对词嵌入向量的语义相关性产生影响。如果语料库足够大且涵盖了各种语言使用情况,那么词嵌入向量往往能够更好地捕捉单词之间的语义关系。

总的来说,nn.Embedding 层体现语义相关性的能力主要来自于两个方面:一是在训练过程中根据任务反馈调整词嵌入向量,使得语义相似的单词在向量空间中更加接近;二是在训练过程中使用的语料库的质量和规模。

pytorch rnn

以下是pytorch使用rnn最简单的一个例子,用来熟悉pytorch rnn
注意pytorch的rnn并不处理隐藏层到输出层的逻辑,他只是关注隐藏层的输出结果,如果需要将隐藏层转换为结果输出,可以在添加一个全连接层即可,这里暂不关注这部分

#%%

import torch
import torch.nn as nn

# 定义输入数据
input_size = 10   # 输入特征的维度
sequence_length = 5   # 时间步个数
batch_size = 3   # 批次大小

# 创建随机输入数据
#输入数据的维度为(sequence_length, batch_size, input_size),表示有sequence_length个时间步,
#每个时间步有batch_size个样本,每个样本的特征维度为input_size。
input_data = torch.randn(sequence_length, batch_size, input_size)
print("输入数据",input_data)
# 定义RNN模型
# 定义RNN模型时,我们指定了输入特征的维度input_size、隐藏层的维度hidden_size、隐藏层的层数num_layers等参数。
# batch_first=False表示输入数据的维度中批次大小是否在第一个维度,我们在第二个维度上。
rnn = nn.RNN(input_size, hidden_size=20, num_layers=1, batch_first=False)
"""
在前向传播过程中,我们将输入数据传递给RNN模型,并得到输出张量output和最后一个时间步的隐藏状态hidden。
输出张量的大小为(sequence_length, batch_size, hidden_size),表示每个时间步的隐藏层输出。
最后一个时间步的隐藏状态的大小为(num_layers, batch_size, hidden_size)。
"""
# 前向传播,第二个参数h0未传递,默认为0
output, hidden = rnn(input_data)
print("最后一个隐藏层",hidden.shape)
print("输出所有隐藏层",output.shape)

# 打印每个隐藏层的权重和偏置项
# weight_ih表示输入到隐藏层的权重,weight_hh表示隐藏层到隐藏层的权重,注意这里使出是转置的结果。
# bias_ih表示输入到隐藏层的偏置,bias_hh表示隐藏层到隐藏层的偏置。
for name, param in rnn.named_parameters():
    if 'weight' in name or 'bias' in name:
        print(name, param.data)

输出

最后一个隐藏层 torch.Size([1, 3, 20])
输出所有隐藏层 torch.Size([5, 3, 20])

在这里插入图片描述

权重为什么是10行20列参数卷积神经网络的原理
数据最外层的行的长度决定了前向传播时间序列的个数。
这个input_size是输入数据的维度,比如一个单词转换为one-hot后列就是字典的特征长度
这个hidden_size是隐藏层神经元的个数也就是最终隐藏层输入的特征数。
num_layer中是堆叠的多层隐藏层。

常见的结构

RNN(循环神经网络)常用的结果类型包括单输入单输出、单输入多输出、多输入多输出和多输入单输出。下面我将详细解释每种结果类型以及它们的应用场景。

  1. 单输入单输出(Single Input Single Output,SISO):这是最常见的RNN结果类型,输入是一个序列,输出是一个单一的预测值。例如,给定一段文本,预测下一个词语;给定一段时间序列数据,预测下一个时间步的值。这种结果类型适用于许多序列预测任务,如语言模型、时间序列预测等。
    举个例子,假设我们要预测房屋价格,可能会使用多个特征,如房屋的面积、卧室数量、浴室数量等。这样,我们可以将这些特征组合成一个特征向量作为模型的输入,而模型的输出则是预测的房屋价格。因此,线性回归可以用来解决多特征到单个输出的问题,因此被称为单输入单输出模型。

  2. 单输入多输出(Single Input Multiple Output,SIMO):这种结果类型中,输入是一个序列,但输出是多个预测值。例如,给定一段文本,同时预测下一个词语和该词语的词性标签;给定一段音频信号,同时预测语音情感和说话者身份。这种结果类型适用于需要同时预测多个相关任务的情况。

  3. 多输入多输出(Multiple Input Multiple Output,MIMO):这种结果类型中,有多个输入序列和多个输出序列。例如,在机器翻译任务中,输入是源语言的句子序列,输出是目标语言的句子序列;在对话系统中,输入是用户的问题序列,输出是系统的回答序列。这种结果类型适用于需要处理多个输入和输出序列的任务,mimo有两种一种输入和输出个数相等和不相等。

  4. 多输入单输出(Multiple Input Single Output,MISO):这种结果类型中,有多个输入序列,但只有一个输出。例如,在图像描述生成任务中,输入是图像序列,输出是对图像的描述;在自动驾驶中,输入是多个传感器的数据序列,输出是车辆的控制命令。这种结果类型适用于需要将多个输入序列映射到单个输出序列的任务。

线性回归是一种简单的机器学习模型,它的输入可以是多个特征,但是输出只有一个。这里的“单输入单输出”是指模型的输入是一个向量(多个特征的组合),输出是一个标量(一个预测值)。在线性回归中,我们通过对输入特征进行线性组合,得到一个预测值。因此,尽管输入可以是多个元素,但输出只有一个。

在这里插入图片描述

双向循环神经网络

普通的RNN只能依据之前时刻的时序信息来预测下一时刻的输出,但在有些问题中,当前时刻的输出不仅和之前的状态有关,还可能和未来的状态有关系。

比如预测一句话中缺失的单词不仅需要根据前文来判断,还需要考虑它后面的内容,真正做到基于上下文判断。

BRNN有两个RNN上下叠加在一起组成的,输出由这两个RNN的状态共同决定。
在这里插入图片描述
先对图片和公式中的符号集中说明,需要时方便查看:

  • h t 1 h_t^1 ht1表示t 时刻,Cell1 中从左到右获得的 memory(信息);
  • W 1 , U 1 W^1,U^1 W1,U1 表示图中 Cell1 的可学习参数,W是隐藏层的参数U是输入层参数;
  • f 1 f_1 f1 表示 Cell1 的激活函数;
  • h t 2 h_t^2 ht2 表示 t 时刻,Cell2 中从右到左获得的 memory;
  • W 2 W^2 W2, U 2 U^2 U2 表示图中 Cell2 的可学习参数;
  • f 2 f_2 f2 表示 Cell2 的激活函数;
  • V V V 是输出层的参数,可以理解为 MLP;
  • f 3 f_3 f3 是输出层的激活函数;
  • y t y_t yt 是 t 时刻的输出值;

在图1-1中,对于 t 时刻的输入 x t x_t xt ,可以结合从左到右的 memory h t − 1 1 h^1_{t-1} ht11 , 获得当前时刻的 memory h t 1 h^1_t ht1:
在这里插入图片描述
同理也可以结合从右到左的 memory h t − 1 2 h^2_{t−1} ht12 , 获得当前时刻的 memory h t 2 h^2_t ht2:
在这里插入图片描述
然后将 h t 1 h^1_t ht1 h t 2 h^2_t ht2 首尾级联在一起通过输出层网络 V V V 得到输出 y t y_t yt :
在这里插入图片描述
这样对于任何一个时刻 t 可以看到从不同方向获得的 memory, 使模型更容易优化,加速了模型的收敛速度。

pytorch rnn

下面是一个使用PyTorch中nn.RNN模块实现双向RNN的最简单例子:

import torch
import torch.nn as nn

# 定义输入数据
input_size = 10   # 输入特征的维度
sequence_length = 5   # 时间步个数
batch_size = 3   # 批次大小

# 创建随机输入数据
input_data = torch.randn(sequence_length, batch_size, input_size)

# 定义双向RNN模型
rnn = nn.RNN(input_size, hidden_size=20, num_layers=1, batch_first=False, bidirectional=True)

# 前向传播
output, hidden = rnn(input_data)

# 输出结果
print("输出张量大小:", output.size())
print("最后一个时间步的隐藏状态大小:", hidden.size())

输出

输出张量大小: torch.Size([5, 3, 40])
最后一个时间步的隐藏状态大小: torch.Size([2, 3, 20])

这个例子中,输入数据的维度和之前的例子相同。

定义双向RNN模型时,我们在RNN模型的参数中设置bidirectional=True,表示我们希望构建一个双向RNN模型。

在前向传播过程中,我们将输入数据传递给双向RNN模型,并得到输出张量output和最后一个时间步的隐藏状态hidden。输出张量的大小为(sequence_length, batch_size, hidden_sizenum_directions),其中num_directions为2,表示正向和反向两个方向。最后一个时间步的隐藏状态的大小为(num_layersnum_directions, batch_size, hidden_size)。

双向RNN可以同时利用过去和未来的信息,可以更好地捕捉到时间序列数据中的特征。你可以根据需要调整输入数据的大小、RNN模型的参数等进行实验。

双向RNN的输出通常是正向和反向隐藏状态的组合,它们被存储在一个数组中。具体来说,如果使用PyTorch中的nn.RNN模块实现双向RNN,输出张量的形状将是(sequence_length,batch_size, hidden_size * 2),其中hidden_size * 2表示正向和反向隐藏状态的大小之和。这个输出张量包含了每个时间步上正向和反向隐藏状态的信息,可以在后续的任务中使用。
双向rnn的最后的隐藏层大小是(2, batch_size, hidden_size)

Deep RNN(多层 RNN)

前文我们介绍的 RNN,是数据在时间维度上的变换。不论时间维度多长,只有一个 RNN 模块, 即只有一组待学习参数 (W, U),属于单层 RNN。deep RNN 也叫做多层 RNN,顾名思义它由多个 RNN 级联组成,是输入数据在空间维度上变换。如图, 这是 L 层的 RNN 架构。每一层是一个单独的RNN,共有L个RNN。
在这里插入图片描述

在每一层的水平方向,只有一组可学习参数,如第 l l l 层的参数 W l U l W^lU^l WlUl。水平方向是数据沿着时间维度变换,变换机制与单个 RNN 的机制一致,具体参考式上一篇文章。在每个时刻 t 的垂直方向,共有 L 组可学习参数( W i , U i W^i,U^i Wi,Ui) i = 1, 2, …, L。在第 l l l 层的第 t 时刻 Cell 的输入数据来自 2 个方向:一个是来自上一层的输出 h t l − 1 h^{l−1}_t htl1 :
在这里插入图片描述
一个是来自第 l l l 层, t − 1 t − 1 t1 时刻的 memory 数据 h t − 1 l h^l_{t−1} ht1l :
在这里插入图片描述
所以 Cell 的输出 h t l h^l_t htl
在这里插入图片描述
本质上,Deep RNN 在单个 RNN 的基础上,将当前时刻的输入修改为上层的输出。这样 RNN 便完成了空间上的数据变换。额外提一下:DeepRNN的每一层也可以是一个双向RNN。

pytorch rnn

下面是一个使用nn.RNN模块实现多层RNN的最简单例子:

import torch
import torch.nn as nn

# 定义输入数据和参数
input_size = 5
hidden_size = 10
num_layers = 2
batch_size = 3
sequence_length = 4

# 创建输入张量
input_tensor = torch.randn(sequence_length, batch_size, input_size)

# 创建多层RNN模型
rnn = nn.RNN(input_size, hidden_size, num_layers)

# 前向传播
output, hidden = rnn(input_tensor)

# 打印输出张量和隐藏状态的大小
print("Output shape:", output.shape)
print("Hidden state shape:", hidden.shape)

在上面的例子中,我们首先定义了输入数据的维度、RNN模型的参数(输入大小、隐藏状态大小和层数),以及批次大小和序列长度。然后,我们创建了一个输入张量,其形状为(sequence_length, batch_size, input_size)。接下来,我们使用nn.RNN模块创建一个多层RNN模型,其中包含两层。最后,我们通过将输入张量传递给RNN模型的前向方法来进行前向传播,并打印输出张量和隐藏状态的大小。

请注意,输出张量的形状为(sequence_length, batch_size, hidden_size),其中sequence_length和batch_size保持不变,hidden_size是隐藏状态的大小。隐藏状态的形状为(num_layers, batch_size, hidden_size),其中num_layers是RNN模型的层数。

RNN缺点

梯度爆炸和消失问题

实践中前面介绍的几种RNNs并不能很好的处理较长的序列,RNN在训练中很容易发生梯度爆炸和梯度消失,这导致梯度不能在较长序列中一直传递下去,从而使RNN无法捕捉到长距离的影响。

通常来说,梯度爆炸更容易处理一些。因为梯度爆炸的时候,我们的程序会收到NaN错误。我们也可以设置一个梯度阈值,当梯度超过这个阈值的时候可以直接截取。

梯度消失更难检测,而且也更难处理一些。总的来说,我们有三种方法应对梯度消失问题:

1、合理的初始化权重值。初始化权重,使每个神经元尽可能不要取极大或极小值,以躲开梯度消失的区域。

2、使用relu代替sigmoid和tanh作为激活函数。。

3、使用其他结构的RNNs,比如长短时记忆网络(LTSM)和Gated Recurrent Unit(GRU),这是最流行的做法

短期记忆

假如需要判断用户的说话意图(问天气、问时间、设置闹钟…),用户说了一句“what time is it?”我们需要先对这句话进行分词:
在这里插入图片描述
然后按照顺序输入 RNN ,我们先将 “what”作为 RNN 的输入,得到输出「01」
在这里插入图片描述
然后,我们按照顺序,将“time”输入到 RNN 网络,得到输出「02」。

这个过程我们可以看到,输入 “time” 的时候,前面 “what” 的输出也产生了影响(隐藏层中有一半是黑色的)。
在这里插入图片描述
以此类推,前面所有的输入都对未来的输出产生了影响,大家可以看到圆形隐藏层中包含了前面所有的颜色。如下图所示:
在这里插入图片描述
当我们判断意图的时候,只需要最后一层的输出「05」,如下图所示:
在这里插入图片描述
RNN 的缺点也比较明显
在这里插入图片描述
通过上面的例子,我们已经发现,短期的记忆影响较大(如橙色区域),但是长期的记忆影响就很小(如黑色和绿色区域),这就是 RNN 存在的短期记忆问题。

  1. RNN 有短期记忆问题,无法处理很长的输入序列
  2. 训练 RNN 需要投入极大的成本

RNN 的优化算法

LSTM – 长短期记忆网络

RNN 是一种死板的逻辑,越晚的输入影响越大,越早的输入影响越小,且无法改变这个逻辑。
LSTM 做的最大的改变就是打破了这个死板的逻辑,而改用了一套灵活了逻辑——只保留重要的信息。
简单说就是:抓重点!
在这里插入图片描述
举个例子,我们先快速的阅读下面这段话:
在这里插入图片描述
当我们快速阅读完之后,可能只会记住下面几个重点:
在这里插入图片描述
LSTM 类似上面的划重点,他可以保留较长序列数据中的「重要信息」,忽略不重要的信息。这样就解决了 RNN 短期记忆的问题。

原理

原始RNN的隐藏层只有一个状态,即h,它对于短期的输入非常敏感。那么如果我们再增加一个门(gate)机制用于控制特征的流通和损失,即c,让它来保存长期的状态,这就是长短时记忆网络(Long Short Term Memory,LSTM)。
在这里插入图片描述
新增加的状态c,称为单元状态。我们把LSTM按照时间维度展开:
其中图像上的标识 σ \sigma σ标识使用sigmod激活到[0-1], tanh ⁡ \tanh tanh激活到[-1,1]
⨀ 是一个数学符号,表示逐元素乘积(element-wise product)或哈达玛积(Hadamard product)。当两个相同维度的矩阵、向量或张量进行逐元素相乘时,可以使用 ⨀ 符号来表示。

例如,对于两个向量 [a1,a2,a3] ⨀ [b1, b2, b3]=[a1b1,a2b2,a3*b3],它们的逐元素乘积可以表示
在这里插入图片描述
可以看到在t时刻,

LSTM的输入有三个:当前时刻网络的输出值 x t x_t xt、上一时刻LSTM的输出值 h t − 1 h_{t−1} ht1、以及上一时刻的记忆单元向量 c t − 1 c_{t−1} ct1

LSTM的输出有两个:当前时刻LSTM输出值 h t h_t ht、当前时刻的隐藏状态向量 h t h_t ht、和当前时刻的记忆单元状态向量 c t c_t ct

注意:记忆单元c在LSTM 层内部结束工作,不向其他层输出。LSTM的输出仅有隐藏状态向量h。

LSTM 的关键是单元状态,即贯穿图表顶部的水平线,有点像传送带。这一部分一般叫做单元状态(cell state)它自始至终存在于LSTM的整个链式系统中。
在这里插入图片描述

遗忘门

f t f_t ft叫做遗忘门,表示 C t − 1 C_{t−1} Ct1的哪些特征被用于计算 C t C_t Ct f t f_t ft是一个向量,向量的每个元素均位于(0~1)范围内。通常我们使用 sigmoid 作为激活函数,sigmoid 的输出是一个介于于(0~1)区间内的值,但是当你观察一个训练好的LSTM时,你会发现门的值绝大多数都非常接近0或者1,其余的值少之又少。
在这里插入图片描述

输入门

C t C_t Ct 表示单元状态更新值,由输入数据 x t x_t xt和隐节点 h t − 1 h_{t−1} ht1经由一个神经网络层得到,单元状态更新值的激活函数通常使用tanh。 i t i_t it叫做输入门,同 f t f_t ft 一样也是一个元素介于(0~1)区间内的向量,同样由 x t x_t xt h t − 1 h_{t−1} ht1经由sigmoid激活函数计算而成
在这里插入图片描述

输出门

最后,为了计算预测值 y t y^t yt和生成下个时间片完整的输入,我们需要计算隐节点的输出 h t h_t ht
在这里插入图片描述

lstm写诗

首先我们研究下pytorch中lstm的用法
单层lstm

sequence_length =3
batch_size =2
input_size =4
#这里如果是输入比如[张三,李四,王五],一般实际使用需要通过embedding后生成一个[时间步是3,批量1(这里是1,但是如果是真实数据集可能有分批处理,就是实际的批次值),3(三个值的坐标表示一个张三或者李四)]
input=t.randn(sequence_length,batch_size,input_size)
lstmModel=nn.LSTM(input_size,3,1)
#其中,output是RNN每个时间步的输出,hidden是最后一个时间步的隐藏状态。
output, (h, c) =lstmModel(input)
#因为是3个时间步,每个时间步都有一个隐藏层,每个隐藏层都有2条数据,隐藏层的维度是3,最终(3,2,3)
print("LSTM隐藏层输出的维度",output.shape)
#
print("LSTM隐藏层最后一个时间步输出的维度",h.shape)
print("LSTM隐藏层最后一个时间步细胞状态",c.shape)

输出

LSTM隐藏层输出的维度 torch.Size([3, 2, 3])
LSTM隐藏层最后一个时间步输出的维度 torch.Size([1, 2, 3])
LSTM隐藏层最后一个时间步细胞状态 torch.Size([1, 2, 3])

双层lstm

sequence_length =3
batch_size =2
input_size =4
input=t.randn(sequence_length,batch_size,input_size)
lstmModel=nn.LSTM(input_size,3,num_layers=2)
#其中,output是RNN每个时间步的输出,hidden是最后一个时间步的隐藏状态。
output, (h, c) =lstmModel(input)
print("2层LSTM隐藏层输出的维度",output.shape)
print("2层LSTM隐藏层最后一个时间步输出的维度",h.shape)
print("2层LSTM隐藏层最后一个时间步细胞状态",c.shape)

输出:
2层LSTM隐藏层输出的维度 torch.Size([3, 2, 3])
2层LSTM隐藏层最后一个时间步输出的维度 torch.Size([2, 2, 3])
2层LSTM隐藏层最后一个时间步细胞状态 torch.Size([2, 2, 3])

2层的话输出的是最后一层的隐藏层的输出,h,c是一个时间步就有两层的隐藏层和记忆细胞

开始写诗的例子
这是项目的目录结构
在这里插入图片描述

加载数据

实验数据来自Github上中文爱好者收集的5万多首唐诗,作者在此基础上进行了一些数据处理,由于数据处理很耗时间,且不是pytorch学习的重点,这里省略。作者提供了一个numpy的压缩包tang.npz,下载地址
数据具体结构可参考,以下代码main部分

from torch.utils.data import  Dataset,DataLoader
import numpy as np
class PoetryDataset(Dataset):
    def __init__(self,root):
        self.data=np.load(root, allow_pickle=True)
    def __len__(self):
        return len(self.data["data"])
    def __getitem__(self, index):
        return self.data["data"][index]
    def getData(self):
        return self.data["data"],self.data["ix2word"].item(),self.data["word2ix"].item()
if __name__=="__main__":
    datas=PoetryDataset("./tang.npz").data
    # data是一个57580 * 125的numpy数组,即总共有57580首诗歌,每首诗歌长度为125个字符(不足125补空格,超过125的丢弃)
    print(datas["data"].shape)
    #这里都字符已经转换成了索引
    print(datas["data"][0])
    # 使用item将numpy转换为字典类型,ix2word存储这下标对应的字,比如{0: '憁', 1: '耀'}
    ix2word = datas['ix2word'].item()
    print(ix2word)
    # word2ix存储这字对应的小标,比如{'憁': 0, '耀': 1}
    word2ix = datas['word2ix'].item()
    print(word2ix)
    # 将某一首古诗转换为索引表示,转换后:[5272, 4236, 3286, 6933, 6010, 7066, 774, 4167, 2018, 70, 3951]
    str="床前明月光,疑是地上霜"
    print([word2ix[i] for i in str])
    #将第一首古诗打印出来
    print([ix2word[i] for i in datas["data"][0]])

定义模型
import torch.nn as nn
class Net(nn.Module):
    """
        :param vocab_size 表示输入单词的格式
        :param embedding_dim 表示将一个单词映射到embedding_dim维度空间
        :param hidden_dim 表示lstm输出隐藏层的维度
    """
    def __init__(self, vocab_size, embedding_dim, hidden_dim):
        super(Net, self).__init__()
        self.hidden_dim = hidden_dim
        #Embedding层,将单词映射成vocab_size行embedding_dim列的矩阵,一行的坐标代表第一行的词
        self.embeddings = nn.Embedding(vocab_size, embedding_dim)
        #两层lstm,输入词向量的维度和隐藏层维度
        self.lstm = nn.LSTM(embedding_dim, self.hidden_dim, num_layers=2, batch_first=False)
        #最后将隐藏层的维度转换为词汇表的维度
        self.linear1 = nn.Linear(self.hidden_dim, vocab_size)

    def forward(self, input, hidden=None):
        #获取输入的数据的时间步和批次数
        seq_len, batch_size = input.size()
        #如果没有传入上一个时间的隐藏值,初始一个,注意是2层
        if hidden is None:
            h_0 = input.data.new(2, batch_size, self.hidden_dim).fill_(0).float()
            c_0 = input.data.new(2, batch_size, self.hidden_dim).fill_(0).float()
        else:
            h_0, c_0 = hidden
        #将输入的数据embeddings为(input行数,embedding_dim)
        embeds = self.embeddings(input)     # (seq_len, batch_size, embedding_dim), (1,1,128)
        output, hidden = self.lstm(embeds, (h_0, c_0))      #(seq_len, batch_size, hidden_dim), (1,1,256)
        output = self.linear1(output.view(seq_len*batch_size, -1))      # ((seq_len * batch_size),hidden_dim), (1,256) → (1,8293)
        return output, hidden

训练

下述代码:input, target = (data[:-1, :]), (data[1:, :])解释:

在使用LSTM进行词预测时,输入和标签的设置是为了将输入序列和目标序列对齐。

在语言模型中,我们希望根据前面的单词来预测下一个单词。因此,输入序列是前面的单词,而目标序列是下一个单词。

考虑以下例子:
假设我们有一个句子:“I love deep learning.”
我们可以将其分解为以下形式的输入和目标序列:
输入序列:[“I”, “love”, “deep”]
目标序列:[“love”, “deep”, “learning”]

在这个例子中,输入序列是前面的单词[“I”, “love”, “deep”],而目标序列是相应的下一个单词[“love”, “deep”, “learning”]。

在代码中,data是一个包含所有单词的数据集,其中每一行代表一个单词。将data切片为input和target时,我们使用data[:-1, :]作为输入序列,即除了最后一个单词。而data[1:, :]作为目标序列,即从第二个单词开始。

这样设置输入和目标序列的目的是为了将输入和标签对齐,使得模型可以根据前面的单词来预测下一个单词。

import fire
import torch.nn as nn
import torch as t
from data.dataset import PoetryDataset
from models.model import Net
num_epochs=5
data_root="./data/tang.npz"
batch_size=10
def train(**kwargs):
    datasets=PoetryDataset(data_root)
    data,ix2word,word2ix=datasets.getData()
    lenData=len(data)
    data = t.from_numpy(data)
    dataloader = t.utils.data.DataLoader(data, batch_size=batch_size, shuffle=True, num_workers=1)
    #总共有8293的词。模型定义:vocab_size, embedding_dim, hidden_dim = 8293 * 128 * 256
    model=Net(len(word2ix),128,256)
    #定义损失函数
    criterion = nn.CrossEntropyLoss()
    model=model.cuda()
    optimizer = t.optim.Adam(model.parameters(), lr=1e-3)
    iteri=0
    filename = "example.txt"
    totalIter=lenData*num_epochs/batch_size
    for epoch in range(num_epochs):  # 最大迭代次数为8
        for i, data in enumerate(dataloader):  # 一批次数据 128*125
            data = data.long().transpose(0,1).contiguous() .cuda()
            optimizer.zero_grad()
            input, target = (data[:-1, :]), (data[1:, :])
            output, _ = model(input)
            loss = criterion(output, target.view(-1))  # torch.Size([15872, 8293]), torch.Size([15872])
            loss.backward()
            optimizer.step()
            iteri+=1
            if(iteri%500==0):
                print(str(iteri+1)+"/"+str(totalIter)+"epoch")
            if (1 + i) % 1000 == 0:  # 每575个batch可视化一次
                with open(filename, "a") as file:
                    file.write(str(i) + ':' + generate(model, '床前明月光', ix2word, word2ix)+"\n")
    t.save(model.state_dict(), './checkpoints/model_poet_2.pth')
def generate(model, start_words, ix2word, word2ix):     # 给定几个词,根据这几个词生成一首完整的诗歌
    txt = []
    for word in start_words:
        txt.append(word)
    input = t.Tensor([word2ix['<START>']]).view(1,1).long()      # tensor([8291.]) → tensor([[8291.]]) → tensor([[8291]])
    input = input.cuda()
    hidden = None
    num = len(txt)
    for i in range(48):      # 最大生成长度
        output, hidden = model(input, hidden)
        if i < num:
            w = txt[i]
            input = (input.data.new([word2ix[w]])).view(1, 1)
        else:
            top_index = output.data[0].topk(1)[1][0]
            w = ix2word[top_index.item()]
            txt.append(w)
            input = (input.data.new([top_index])).view(1, 1)
        if w == '<EOP>':
            break
    return ''.join(txt)
if __name__=="__main__":
    fire.Fire()

5epoch,10batch,普通pc,GTX1050,2GB显存,训练时间30分钟。
50epoch 128batch colab免费gpu 16GB显存,训练时间1小时

测试
def test():
    datasets = PoetryDataset(data_root)
    data, ix2word, word2ix = datasets.getData()
    modle = Net(len(word2ix), 128, 256)  # 模型定义:vocab_size, embedding_dim, hidden_dim —— 8293 * 128 * 256
    if t.cuda.is_available() == True:
        modle.cuda()
        modle.load_state_dict(t.load('./checkpoints/model_poet_2.pth'))
        modle.eval()
        name = input("请输入您的开头:")
        txt = generate(modle, name, ix2word, word2ix)
        print(txt)

由于才训练了5epoch效果不是太好,可视化loss后可多次epoch看看效果,还有个问题,如果输入不变,生成的结果就是相同的,所以这个可能需要一个噪声干扰。
5epoch版本效果

(env380) D:\code\deeplearn\learn_rnn\pytorch\4.nn模块\案例\生成古诗\tang>python main.py test
请输入您的开头:唧唧复唧唧
唧唧复唧唧,不知何所如?君不见此地,不如此中生。一朝一杯酒,一日相追寻。一朝一杯酒,一醉一相逢。

(env380) D:\code\deeplearn\learn_rnn\pytorch\4.nn模块\案例\生成古诗\tang>python main.py test
请输入您的开头:我儿小谦谦
我儿小谦谦,不是天地间。有时有所用,不是无为名。有时有所用,不是无生源。有时有所用,不是无生源。

50epoch版本效果

(env380) D:\code\deeplearn\learn_rnn\pytorch\4.nn模块\案例\生成古诗\tang>python main.py test
请输入您的开头:我家小谦谦
我家小谦谦,今古何为郎。我生不相识,我心不可忘。我来不我见,我亦不得尝。君今不我见,我亦不足伤。

(env380) D:\code\deeplearn\learn_rnn\pytorch\4.nn模块\案例\生成古诗\tang>python main.py test
请输入您的开头:床前明月光
床前明月光,上客不可见。玉楼金阁深,玉瑟风光紧。玉指滴芭蕉,飘飘出罗幕。玉堂无尘埃,玉节凌风雷。
(env380) D:\code\deeplearn\learn_rnn\pytorch\4.nn模块\案例\生成古诗\tang>python main.py test
请输入您的开头:唧唧复唧唧
唧唧复唧唧,胡儿女卿侯。妾本邯郸道,相逢两不游。妾心不可再,妾意不能休。妾本不相见,妾心如有钩。

GRU

Gated Recurrent Unit – GRU 是 LSTM 的一个变体。他保留了 LSTM 划重点,遗忘不重要信息的特点,在long-term 传播的时候也不会被丢失。

LSTM 的参数太多,计算需要很长时间。因此,最近业界又提出了 GRU(Gated RecurrentUnit,门控循环单元)。GRU 保留了 LSTM使用门的理念,但是减少了参数,缩短了计算时间。

相对于 LSTM 使用隐藏状态和记忆单元两条线,GRU只使用隐藏状态。异同点如下:
在这里插入图片描述
GRU的计算图
在这里插入图片描述
GRU计算图,σ节点和tanh节点有专用的权重,节点内部进行仿射变换(“1−”节点输入x,输出1 − x)
在这里插入图片描述
GRU 中进行的计算由上述 4 个式子表示(这里 xt和 ht−1 都是行向量),如图所示,GRU 没有记忆单元,只有一个隐藏状态h在时间方向上传播。这里使用r和z共两个门(LSTM 使用 3 个门),r称为 reset 门,z称为 update 门。

r(reset门)**决定在多大程度上“忽略”过去的隐藏状态。根据公式2.3,如果r是 0,则新的隐藏状态h~仅取决于输入 x t x_t xt。也就是说,此时过去的隐藏状态将完全被忽略。

z(update门)**是更新隐藏状态的门,它扮演了 LSTM 的 forget 门和input 门两个角色。公式2.4 的(1−z)⊙ h t − 1 h_{t−1} ht1部分充当 forget 门的功能,从过去的隐藏状态中删除应该被遗忘的信息。z⊙ h   h^~ h 的部分充当 input 门的功能,对新增的信息进行加权。

Logo

CSDN联合极客时间,共同打造面向开发者的精品内容学习社区,助力成长!

更多推荐