循环神经网络
1、前言
前馈神经网络不考虑数据之间的关联性,网络的输出只和当前时刻网络的输入相关。然而在解决很多实际问题的时候我们发现,现实问题中存在着很多序列型的数据(文本、语音以及视频等),我们的语言需要通过上下文的关系来确认所表达的含义。这些序列型的数据往往都是具有时序上的关联性的,既某一时刻网络的输出除了与当前时刻的输入相关之外,与之前某一时刻或某几个时刻的输出相关。前馈神经网络不能处理好这种关联性,因为它没有记忆能力,所以前面的输出不能传递到后面的时刻。
因此,就有了现在的循环神经网络,其本质是:拥有记忆的能力,会根据这些记忆的内容来进行推断。因此,它的输出就依赖于当前的输入和记忆。相比于前馈神经网络,该网络内部具有很强的记忆性,利用内部的记忆来处理任意时序的输入序列。
2、RNN定义
RNN引入“记忆”的概念,也就是输出需要依赖之前的输入序列,并把关键输入记住。
假设我们在时间步t有小批量输入 X t X_t Xt∈ R n×d。换言之,对于n个序列样本 的小批量, X t X_t Xt的每一行对应于来自该序列的时间步t处的一个样本。接下来,用 H t H_t Ht ∈ R n×h 表示时间步t的隐藏变量。与多层感知机不同的是,在这里保存了前一个时间步的隐藏变量 H t − 1 H_{t−1} Ht−1,并引入了一个新的权重参数 W h h W_{hh} Whh ∈ R h×h,来描述如何在当前时间步中使用前一个时间步的隐藏变量。具体地说,当前时间步隐藏变量由当前时间步的输入与前一个时间步的隐藏变量一起计算得出:
H
t
=
ϕ
(
X
t
W
x
h
+
H
t
−
1
W
h
h
+
b
h
)
H_t = ϕ(X_tW_{xh} + H_{t−1}W_{hh} + b_h)
Ht=ϕ(XtWxh+Ht−1Whh+bh)。
这些变量捕获并保留了序列直到其当前时间步的历史信息,就如当前时间步下神经网络的状态或记忆,因此这样的隐藏变量被称为隐状态。由于在当前时间步中,隐状态使用的定义与前一个时间步中使用的定义相同,因此计算是循环的。于是基于循环计算的隐状态神经网络被命名为循环神经网络,也称递归神经网络。运行如下图。
3、RNN原理
一个基本的循环神经网络结构:输入层,隐藏层,输出层
隐藏层的计算(以中间为例):
1、拼接当前时间步t的输入 X t X_t Xt和前一时间步t − 1的隐状态 H t − 1 H_{t−1} Ht−1
2、将拼接的结果送入带有激活函数ϕ的全连接层。全连接层的输出是当前时间步t的隐状态 H t H_t Ht。
H t = ϕ ( X t W x h + H t − 1 W h h + b h ) H_t = ϕ(X_tW_{xh} + H_{t−1}W_{hh} + b_h) Ht=ϕ(XtWxh+Ht−1Whh+bh)
X t ∗ W x h X_t*W_{xh} Xt∗Wxh表示输入数据的比例,其中 W x h W_{xh} Wxh是对输入数据的权重;同时,对于上一个神经元保留的隐状态 H t − 1 H_{t-1} Ht−1,也需要有一个权重,为 W h h W_{hh} Whh,当前输入状态的数据 X t ∗ W x h X_t*W_{xh} Xt∗Wxh与上一神经元的数据 H t − 1 ∗ W h h H_{t-1}*W_{hh} Ht−1∗Whh的和,再加偏置量 b n b_n bn,通过激活函数继续传给下一个神经元,以此类推,直到最后一个神经元或输出结束。
4、基于循环神经网络的字符级语言模型和困惑度
4.1 基于循环神经网络的字符级语言模型
上文讲到,对具有序型的数据(如文本,语言,音频)是需要通过上下文的方式来确定所表达的含义。而针对于语言模型而言,我们的目标是根据过往和当前的词元来预测下一个词元,因此使用循环神经网络来构建语言模型。我们考虑使用字符级语言模型,将文本词元化为字符而不是单词。如下图演示了如何通过基于字符级语言建模的循环神经网络,使用当前的和先前的字符预测下一个字符。
在训练过程中,对每个时间步的输出层的输出进行softmax操作,然后利用交叉熵损失计算模型输出和标签之间的误差。所需要实现的目标是:通过输入数据"machin",预测出输出层的标签"achine"。以第三个输入为例,第三个时间步的 O 3 O_3 O3,是由文本序列"m",“a”,"c"确定,训练数据中这个文本序列的下一个字符是“h”,也就是想要预测的标签,因此第3个时间步的损失将取决于下一个字符的概率分布,而下一个字符是基于特征序列“m”“a”“c”和这个时间步的标签“h”生成的。
注:这里简单理解文本预测,后续代码实现可理解文本预测。
4.2困惑度
对于预测结果,我们如何去度量语言模型的质量呢?困惑度(Perplexity)是一个量化指标,用于评估模型对测试数据的预测能力。较低的困惑度值意味着模型能够更好地预测测试数据中的下一个词或句子,即模型对数据的理解更为准确。
考虑一下由不同的语言模型给出的对“It is raining ⋯”(“⋯下雨了”)的续写:
1、“It is raining outside”(外面下雨了);
2、“It is raining banana tree”(香蕉树下雨了);
3、 “It is raining piouw;kcj pwepoiut”(piouw;kcj pwepoiut下雨了)。
就质量而言,例1显然是最合乎情理、在逻辑上最连贯的。虽然这个模型可能没有很准确地反映出后续词的语 义,比如,“It is raining in San Francisco”(旧金山下雨了)和“It is raining in winter”(冬天下雨了)可能 才是更完美的合理扩展,但该模型已经能够捕捉到跟在后面的是哪类单词。例2则要糟糕得多,因为其产生 了一个无意义的续写。尽管如此,至少该模型已经学会了如何拼写单词,以及单词之间的某种程度的相关性。 最后,例3表明了训练不足的模型是无法正确地拟合数据的。
困惑度的计算公式:
Perplexity = exp ( − 1 N ∑ i = 1 N log P ( x i ∣ x i − 1 , x i − 2 , … , x 1 ) ) \text{Perplexity} = \exp\left(-\frac{1}{N} \sum_{i=1}^{N} \log P(x_i | x_{i-1}, x_{i-2}, \ldots, x_1)\right) Perplexity=exp(−N1i=1∑NlogP(xi∣xi−1,xi−2,…,x1))
1) N 是测试集中的样本数量(通常是词的数量)。
2) ( P ( x i ∣ x i − 1 , x i − 2 , … , x 1 ) (P(x_i | x_{i-1}, x_{i-2}, \ldots, x_1) (P(xi∣xi−1,xi−2,…,x1) 是模型在给定前文 x i − 1 , x i − 2 , … , x 1 x_{i-1}, x_{i-2}, \ldots, x_1 xi−1,xi−2,…,x1的情况下预测第 (i) 个词 x i x_i xi的概率。
3) ∑ i = 1 N log P ( x i ∣ x i − 1 , x i − 2 , … , x 1 ) \sum_{i=1}^{N} \log P(x_i | x_{i-1}, x_{i-2}, \ldots, x_1) ∑i=1NlogP(xi∣xi−1,xi−2,…,x1) 是整个测试集上所有词的对数概率之和。
4) e x p ( − 1 N ⋅ 上述和 ) exp(-\frac{1}{N} \cdot \text{上述和}) exp(−N1⋅上述和)是将上述平均值取指数,得到困惑度值。
5、循环神经网络实现
5.1 代码如下
import torch
from d2l import torch as d2l
from torch import nn
from torch.nn import functional as F
batch_size, num_steps = 32, 35
# 加载数据 加载《时间机器》的数据集
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)
num_hiddens = 256
# 构建一个具有256个隐藏单元的单隐藏层的循环神经网络
rnn_layer = nn.RNN(len(vocab), num_hiddens)
class RNNModel(nn.Module):
"""循环神经网络模型"""
def __init__(self, rnn_layer, vocab_size, **kwargs):
super(RNNModel, self).__init__(**kwargs)
# 定义RNN层数,词汇表,隐藏单元
self.rnn = rnn_layer
self.vocab_size = vocab_size
self.num_hiddens = self.rnn.hidden_size
# 根据RNN层是否是双向的(这里不是),决定输出层的权重矩阵大小。
# 如果不是双向RNN,则输出层权重矩阵的大小为(num_hiddens, vocab_size);
# 如果是双向RNN,则因为两个方向的隐藏状态会被拼接,所以大小为(num_hiddens * 2, vocab_size)
if not self.rnn.bidirectional:
self.num_directions = 1
self.linear = nn.Linear(self.num_hiddens, self.vocab_size)
else:
self.num_directions = 2
self.linear = nn.Linear(self.num_hiddens * 2, self.vocab_size)
def forward(self, inputs, state):
# 转换为one-hot编码
X = F.one_hot(inputs.T.long(), self.vocab_size)
X = X.to(torch.float32)
Y, state = self.rnn(X, state)
# 全连接层首先将Y的形状改为(时间步数*批量大小,隐藏单元数)
# 它的输出形状是(时间步数*批量大小,词表大小)。
output = self.linear(Y.reshape((-1, Y.shape[-1])))
return output, state
def begin_state(self, device, batch_size=1):
if not isinstance(self.rnn, nn.LSTM):
# nn.GRU以张量作为隐状态,直接返回全零张量作为隐藏状态的初始值
return torch.zeros((self.num_directions * self.rnn.num_layers,
batch_size, self.num_hiddens),
device=device)
else:
# nn.LSTM以元组作为隐状态
return (torch.zeros((
self.num_directions * self.rnn.num_layers,
batch_size, self.num_hiddens), device=device),
torch.zeros((
self.num_directions * self.rnn.num_layers,
batch_size, self.num_hiddens), device=device))
device = d2l.try_gpu()
net = RNNModel(rnn_layer, vocab_size=len(vocab))
net = net.to(device)
d2l.predict_ch8('time traveller', 10, net, vocab, device)
num_epochs, lr = 500, 1
d2l.train_ch8(net, train_iter, vocab, lr, num_epochs, device)
5.2 运行结果
6、循环神经网络的反向传播
RNN与ANN(神经网络),CNN(卷积神经网络)相比,优势是具有“记忆”,需要记住之前神经元的关键序列。对于常规的神经网络的操作:模型训练,前向传播,损失函数计算,反向传播,模型参数调优。但是否想过有这样一个问题?循环神经网络如何进行反向传播?如果依照常规反向传播的逻辑进行反向传播,是否存在问题呢?
答案是会存在问题!通常情况下,对于反向传播使用的是梯度下降算法,对权重,偏置等一系列参数求导,那对于循环神经网络而言,要求我们将循环神经网络的计算图一次展开一个时间步,以获得模型变量和参数之间的依赖关系,其权重和参数务必会很长(根据链式求导法则)。当文本序列很长时,RNN在时间维度上的深度可能会非常大,可能出现梯度消失(梯度接近0)或者梯度爆炸(梯度很大)。
如何去解决这一问题?
为了缓解这些问题,一种常见的策略是在反向传播过程中引入截断机制,限制梯度传播的时间范围或大小。
- 时间截断(Temporal Truncation)
- 这种方法也称为“梯度截断”或“截断反向传播”(Truncated BPTT)。它不是在整个序列长度上进行反向传播,而是只在一个较短的固定长度窗口内进行。这个窗口从序列的某个点开始(通常是序列的末尾或某个较近的点),然后反向传播到这个窗口的起始点。通过限制反向传播的时间范围,可以减少梯度消失或爆炸的影响。
- 梯度范数截断(Gradient Norm Clipping)
- 这种方法不是截断时间,而是截断梯度本身的大小。在每次参数更新之前,检查梯度的范数(如L2范数),如果它超过了某个阈值,就按比例缩放梯度,以确保其范数不会超过该阈值。这样可以防止梯度爆炸,同时允许梯度继续传播以学习长期依赖。
7、长短期记忆网络(LSTM)
可以说,长短期记忆网络的设计灵感来自于计算机的逻辑门。长短期记忆网络引入了记忆元(memory cell), 或简称为单元(cell)。有些文献认为记忆元是隐状态的一种特殊类型,它们与隐状态具有相同的形状,其设计目的是用于记录附加的信息。为了控制记忆元,我们需要许多门。其中一个门用来从单元中输出条目,我们将其称为输出门(output gate)。另外一个门用来决定何时将数据读入单元,我们将其称为输入门(input gate)。我们还需要一种机制来重置单元的内容,由遗忘门(forget gate)来管理,这种设计的动机与门控循环单元相同,能够通过专用机制决定什么时候记忆或忽略隐状态中的输入。
LSTM通过引入三个关键的门控机制(遗忘门、输入门和输出门)以及一个细胞状态,有效地解决RNN中的梯度消失问题。
-
遗忘门:负责决定从细胞状态中丢弃哪些信息,通过sigmoid函数控制信息保留的比例。
-
输入门:由两部分组成,一个sigmoid层决定哪些值将要更新,一个tanh层创建一个新的候选值向量。
-
细胞状态:LSTM网络中的关键概念,携带有关观察到的输入序列的信息,并通过遗忘门和输入门的控制进行更新。
-
输出门:决定下一个隐藏状态的值,隐藏状态包含关于前一时间步的信息,并用于产生输出。
LSTM的门控机制允许网络自主决定信息的流动,遗忘门可以去除无关的信息,输入门可以引入新的信息,而输出门可以决定哪些信息传递到下一个时间步。此外,细胞状态的直接连接允许梯度在网络中更有效地流动,避免了传统RNN中的链式法则导致的梯度消失。
7.1运行代码
import torch
from d2l import torch as d2l
batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)
def get_lstm_params(vocab_size, num_hiddens, device):
num_inputs = num_outputs = vocab_size
def normal(shape):
return torch.randn(size=shape, device=device) * 0.01
def three():
return (normal((num_inputs, num_hiddens)),
normal((num_hiddens, num_hiddens)),
torch.zeros(num_hiddens, device=device))
W_xi, W_hi, b_i = three() # 输入门参数
W_xf, W_hf, b_f = three() # 遗忘门参数
W_xo, W_ho, b_o = three() # 输出门参数
W_xc, W_hc, b_c = three() # 候选记忆元参数
# 输出层参数
W_hq = normal((num_hiddens, num_outputs))
b_q = torch.zeros(num_outputs, device=device)
# 附加梯度
params = [W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc, b_c, W_hq, b_q]
for param in params:
param.requires_grad_(True)
return params
def init_lstm_state(batch_size, num_hiddens, device):
return (torch.zeros((batch_size, num_hiddens), device=device),
torch.zeros((batch_size, num_hiddens), device=device))
def lstm(inputs, state, params):
[W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc, b_c,
W_hq, b_q] = params
(H, C) = state
outputs = []
for X in inputs:
I = torch.sigmoid((X @ W_xi) + (H @ W_hi) + b_i)
F = torch.sigmoid((X @ W_xf) + (H @ W_hf) + b_f)
O = torch.sigmoid((X @ W_xo) + (H @ W_ho) + b_o)
C_tilda = torch.tanh((X @ W_xc) + (H @ W_hc) + b_c)
C = F * C + I * C_tilda
H = O * torch.tanh(C)
Y = (H @ W_hq) + b_q
outputs.append(Y)
return torch.cat(outputs, dim=0), (H, C)
vocab_size, num_hiddens, device = len(vocab), 256, d2l.try_gpu()
num_epochs, lr = 500, 1
model = d2l.RNNModelScratch(len(vocab), num_hiddens, device, get_lstm_params,
init_lstm_state, lstm)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)
7.2 运行结果
7.3 简便代码
num_inputs = vocab_size
lstm_layer = nn.LSTM(num_inputs, num_hiddens)
model = d2l.RNNModel(lstm_layer, len(vocab))
model = model.to(device)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)
批量规范化
训练深层神经网络是十分困难的,特别是在较短的时间内使他们收敛更加棘手。批量规范化(batch normalization)可持续加速深层网络的收敛速度。
优点
1、加速网络收敛
- 稳定数据分布:在深层神经网络的训练过程中,数据的分布在各层之间会发生变化,这种变化被称为内部协变量偏移(Internal Covariate Shift)。批量规范化通过规范每一层的输入,使其具有稳定的均值和方差,从而减缓了内部协变量偏移,使得网络训练更加稳定,加速了收敛速度。
- 提高学习率:由于批量规范化稳定了数据的分布,使得网络对于学习率的变化不那么敏感,因此可以使用更高的学习率进行训练,从而进一步加速收敛。
2、减少过拟合
- 正则化效果:批量规范化具有一定的正则化效果,因为它在每个小批量中对数据进行规范化,相当于在每个小批量中加入了噪声,这种噪声有助于防止模型过拟合。
- 降低模型复杂度:通过批量规范化,模型在训练过程中能够自动学习到更加合适的分布,这有助于降低模型的复杂度,从而减少过拟合的风险。
3、优化网络性能
- 提升模型精度:虽然批量规范化对模型精度的直接提升作用可能不如其他方法显著,但它通过加速收敛和减少过拟合,间接地提升了模型的最终精度。
- 简化调参过程:批量规范化减少了网络对初始化权重和偏置的依赖,降低了调参的难度,使得网络更容易训练。
4、适应不同层和数据类型
- 全连接层:对于全连接层,批量规范化作用于特征维,对每一列的特征向量进行规范化操作。
- 卷积层:对于卷积层,批量规范化作用于通道维,对每个通道上的数据进行规范化。这使得批量规范化能够灵活地应用于不同类型的网络层。
公式
从形式上来说,用x ∈ B表示一个来自小批量B的输入,批量规范化BN根据以下表达式转换x:
BN ( x ) = γ ⊙ x − μ ^ B σ ^ B + β \text{BN}(x) = \gamma \odot \frac{x - {\widehat{\mu}_B}}{\widehat{\sigma}_{\mathcal{B}}} + \beta BN(x)=γ⊙σ Bx−μ B+β
u ^ B \widehat{u}_B u B是小批量B的样本均值, σ ^ B \widehat{σ}_ B σ B是小批量B的样本标准差。应用标准化后,生成的小批量的平均值为0和单位方差为1。由于单位方差(与其他一些魔法数)是一个主观的选择,因此我们通常包含 拉伸参数 (scale)γ和偏移参数(shift)β,它们的形状与x相同。请注意,γ和β是需要与其他模型参数一起学习的参数。由于在训练过程中,中间层的变化幅度不能过于剧烈,而批量规范化将每一层主动居中,并将它们重新调整为给定的平均值和大小(通过 u ^ B \widehat{u}_B u B和 σ ^ B \widehat{σ}_ B σ B)。
μ B ^ = 1 ∣ B ∣ ∑ x ∈ B x , \mu_{\hat{\mathcal{B}}} = \frac{1}{|\mathcal{B}|} \sum_{x \in \mathcal{B}} x, μB^=∣B∣1x∈B∑x,
σ B ^ 2 = 1 ∣ B ∣ ∑ x ∈ B ( x − μ B ^ ) 2 + ϵ . \sigma_{\hat{\mathcal{B}}}^2 = \frac{1}{|\mathcal{B}|} \sum_{x \in \mathcal{B}} (x - \mu_{\hat{\mathcal{B}}})^2 + \epsilon. σB^2=∣B∣1x∈B∑(x−μB^)2+ϵ.
请注意,我们在方差估计值中添加一个小的常量ϵ > 0,以确保永远不会尝试除以零,即使在经验方差估计值可能消失的情况下也是如此。
从零实现批量规范化代码
import torch
from d2l.torch import d2l
from torch import nn
def batch_norm(X, gamma, beta, moving_mean, moving_var, eps, momentum):
# 通过is_grad_enabled来判断当前模式是训练模式还是预测模式
if not torch.is_grad_enabled():
# 如果是在预测模式下,直接使用传入的移动平均所得的均值和方差
X_hat = (X - moving_mean) / torch.sqrt(moving_var + eps)
else:
assert len(X.shape) in (2, 4)
if len(X.shape) == 2:
# 使用全连接层的情况,计算特征维上的均值和方差
mean = X.mean(dim=0)
var = ((X - mean) ** 2).mean(dim=0)
else:
# 使用二维卷积层的情况,计算通道维上(axis=1)的均值和方差。
# 这里我们需要保持X的形状以便后面可以做广播运算
mean = X.mean(dim=(0, 2, 3), keepdim=True)
var = ((X - mean) ** 2).mean(dim=(0, 2, 3), keepdim=True)
# 训练模式下,用当前的均值和方差做标准化
X_hat = (X - mean) / torch.sqrt(var + eps)
# 更新移动平均的均值和方差
moving_mean = momentum * moving_mean + (1.0 - momentum) * mean
moving_var = momentum * moving_var + (1.0 - momentum) * var
Y = gamma * X_hat + beta # 缩放和移位
return Y, moving_mean.data, moving_var.data
class BatchNorm(nn.Module):
# num_features:完全连接层的输出数量或卷积层的输出通道数。
# num_dims:2表示完全连接层,4表示卷积层
def __init__(self, num_features, num_dims):
super().__init__()
if num_dims == 2:
shape = (1, num_features)
else:
shape = (1, num_features, 1, 1)
# 参与求梯度和迭代的拉伸和偏移参数,分别初始化成1和0
self.gamma = nn.Parameter(torch.ones(shape))
self.beta = nn.Parameter(torch.zeros(shape))
# 非模型参数的变量初始化为0和1
self.moving_mean = torch.zeros(shape)
self.moving_var = torch.ones(shape)
def forward(self, X):
# 如果X不在内存上,将moving_mean和moving_var
# 复制到X所在显存上
if self.moving_mean.device != X.device:
self.moving_mean = self.moving_mean.to(X.device)
self.moving_var = self.moving_var.to(X.device)
# 保存更新过的moving_mean和moving_var
Y, self.moving_mean, self.moving_var = batch_norm(
X, self.gamma, self.beta, self.moving_mean,
self.moving_var, eps=1e-5, momentum=0.9)
return Y
# 将BN应用于LeNet模型,批量规范化是在卷积层或全连接层之后、相应的激活函数之前应用的
net = nn.Sequential(
nn.Conv2d(1, 6, kernel_size=5), BatchNorm(6, num_dims=4), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2),
nn.Conv2d(6, 16, kernel_size=5), BatchNorm(16, num_dims=4), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2), nn.Flatten(),
nn.Linear(16 * 4 * 4, 120), BatchNorm(120, num_dims=2), nn.Sigmoid(),
nn.Linear(120, 84), BatchNorm(84, num_dims=2), nn.Sigmoid(),
nn.Linear(84, 10))
lr, num_epochs, batch_size = 1.0, 10, 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
d2l.train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())
简便代码(直接调库)
net = nn.Sequential(
nn.Conv2d(1, 6, kernel_size=5), nn.BatchNorm2d(6), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2),
nn.Conv2d(6, 16, kernel_size=5), nn.BatchNorm2d(16), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2), nn.Flatten(),
nn.Linear(256, 120), nn.BatchNorm1d(120), nn.Sigmoid(),
nn.Linear(120, 84), nn.BatchNorm1d(84), nn.Sigmoid(),
nn.Linear(84, 10))
d2l.train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())