文章目录
RNN
一、为什么需要 RNN?
在自然语言处理、时间序列预测等任务中,数据往往以序列形式存在(如句子是单词的序列,股票价格是时间点的序列),且序列中前后元素存在时序依赖关系(如 “下雨天要带伞” 中,“下雨” 与 “伞” 存在逻辑关联)。
传统的前馈神经网络(如全连接网络)存在两个致命缺陷:
- 无法处理变长序列:输入长度固定,而实际序列(如句子)长度不固定;
- 没有 “记忆” 能力:每个输入的处理独立于历史信息,无法捕捉时序依赖。
RNN(循环神经网络)通过循环结构引入 “记忆” 机制,让模型能:
- 捕捉时序依赖:传统前馈神经网络假设输入独立,而RNN通过隐藏状态(Hidden State)传递历史信息,建模序列中前后元素的关系。
- 处理变长输入:RNN可以处理任意长度的序列(通过时间步展开),适用于动态长度的输入输出。
- 共享参数:RNN在不同时间步共享权重参数( W h h , W x h {W}_{hh}, {W}_{xh} Whh,Wxh),减少参数量并提升泛化能力。
二、RNN 的原理
RNN(循环神经网络,Recurrent Neural Network)是一类专门用于处理序列数据的神经网络,其核心特点是通过 “循环结构” 引入 “记忆” 机制,能够捕捉序列中元素的时序依赖关系,在自然语言处理、时间序列分析等领域有着广泛应用。
RNN 的核心是隐藏状态(Hidden State) 的循环传递,它通过将当前输入与前一时刻的隐藏状态结合,生成新的隐藏状态。这个过程使得RNN能够记住序列中的历史信息,并在后续时间步中使用这些信息。隐藏状态 S t {S}_{t} St不仅作为当前时间步的输出特征,还传递给下一时刻的RNN单元,形成时间上的循环连接。
三、RNN 模型框架与内部结构
常见的RNN架构如下图两种:
左图可以理解为,先将每一层的网络简化,再将网络旋转90度得到的简化图
而右边两种类型,可以理解为,再将左图继续进行简化
1. RNN 的基本结构
RNN(循环神经网络)的核心设计是通过隐藏状态(Hidden State)传递历史信息,从而建模序列数据的时序依赖性。其结构主要包括以下三部分:
(1) 输入层(Input Layer)
- 功能:接收序列数据的每个时间步的输入 x t {x}_{t} xt(如文本中的单词、时间序列中的数值)。
- 输入形状:通常为 (batch_size, sequence_length, input_size) ,其中:
batch_size
:批量大小。sequence_length
:序列长度(时间步数)。input_size
:每个时间步的特征维度。
(2) 隐藏层(Hidden Layer)
-
核心:隐藏状态 S t {S}_{t} St 是 RNN 的记忆单元,用于存储序列的历史信息。
-
递归计算:每个时间步的隐藏状态由当前输入 x t {x}_{t} xt 和前一时刻的隐藏状态 S t − 1 {S}_{t-1} St−1 共同决定。
-
数学公式:
s t = f ( U ⋅ x t + W ⋅ s t − 1 ) s_t=f(\mathbf{U}·x_t+\mathbf{W}·s_{t-1}) st=f(U⋅xt+W⋅st−1)在这个公式中, S t {S}_{t} St表示在时间步t的隐藏状态, x t {x}_{t} xt是当前时间步的输入,U 和 W分别是输入到隐藏状态和隐藏状态到隐藏状态的权重矩阵。
(3) 输出层(Output Layer)
- 功能:根据隐藏状态生成当前时间步的输出
- 数学公式:
O
t
{O}_{t}
Ot = g(V
s
t
{s}_{t}
st)
- O t {O}_{t} Ot是时间步t的输出
- V是从隐藏状态到输出层的权重矩阵
- g是另一个非线性函数,常用于输出层
(4) 循环连接(Recurrent Connections)
- 关键机制:隐藏状态 s t {s}_{t} st 在时间步间传递,形成链式结构(图1)。
- 特点:
- 参数共享:所有时间步共享相同的权重
- U是输入到隐藏层的权重矩阵
- W是隐藏层到隐藏层的权重矩阵
- V是隐藏层到输出的权重矩阵
- 时序依赖:当前输出不仅依赖当前输入,还依赖所有历史信息(通过 s t − 1 {s}_{t-1} st−1)。
- 参数共享:所有时间步共享相同的权重
2. RNN 的内部工作流程
- 初始化:
- 初始隐藏状态 S 0 {S}_{0} S0 通常设为零向量。
- 时间步循环:
- 对每个时间步
t
=
1
{t} = 1
t=1 到
T
{T}
T:
- 计算隐藏状态 S t {S}_{t} St。
- 生成输出 y t {y}_{t} yt。
- 对每个时间步
t
=
1
{t} = 1
t=1 到
T
{T}
T:
- 输出方式:
- 序列到单值:仅返回最后一个时间步的输出 y T {y}_{T} yT(如分类任务)。
- 序列到序列:返回所有时间步的输出 y 1 , y 2 , … , y T {y}_{1}, {y}_{2}, \dots, {y}_{T} y1,y2,…,yT(如序列标注)。
3.RNN模型输入输出关系对应模式
通过改变RNN的结构,即调整其输入和输出的数量和形式,可以让它适应各种不同的任务。以下是几种常见的RNN结构调整示例,以及它们各自适用的任务类型:
- 一对多(One-to-Many):这种结构输入是单个样本(非序列),输出是变长序列这种模式常用于“看图说话”的任务,即给定一张图片(单个输入),RNN生成一段描述该图片的文本(一系列输出)。在这种情况下,RNN的结构被调整为首先对输入图片进行编码,然后根据这个编码连续生成文本序列中的词语。
- 多对一(Many-to-One):输入是变长序列,输出是单个结果,即 “用一串时序数据提炼一个总结性结论”,是 RNN 处理序列分类的最常用模式。这种结构适用于如文本分类和情感分析等任务,其中模型需要阅读和理解整个文本(一系列输入),然后决定文本属于哪个类别(单个输出)。在图片生成的上下文中,这种结构可以通过分析一系列的特征或指令来生成单个图片输出。
- 多对多(Many-to-Many):这种结构输入是变长序列,输出也是变长序列,是 RNN 处理 “时序到时序” 映射的核心模式。这在需要输入和输出均为序列的任务中非常有用,例如机器翻译,其中模型需要读取一个语言的文本(一系列输入),然后生成另一种语言的对应文本(一系列输出)。另一个例子是小说生成,其中RNN可以基于给定的开头或主题(一系列输入),连续生成故事的后续内容(一系列输出)。
4. PyTorch 实现示例(简单 RNN)
import torch
import torch.nn as nn
# 定义模型
input_size = 4 # 输入特征维度
hidden_size = 3 # 隐藏层维度
num_layers = 1 # 单层
batch_first = True # 输入形状为 (batch, seq_len, input_size)
rnn = nn.RNN(input_size, hidden_size, num_layers, batch_first=True)
# 模拟输入 (batch_size=2, sequence_length=5)
input = torch.randn(2, 5, input_size)
h0 = torch.zeros(num_layers, 2, hidden_size) # 初始隐藏状态
# 前向传播
output, hn = rnn(input, h0)
print("Output shape:", output.shape) # (2, 5, 3)
print("Final hidden state:", hn.shape) # (1, 2, 3)
四、RNN 实现代码
1.核心参数
参数 | 作用 | 默认值 | 示例 |
---|---|---|---|
input_size | 输入特征的维度(每个时间步的输入特征数)。 | 必须指定 | input_size=4 表示每个时间步有 4 个特征。 |
hidden_size | 隐藏状态的维度(每个时间步的隐藏层输出特征数)。 | 必须指定 | hidden_size=3 表示隐藏层输出 3 个特征。 |
num_layers | RNN 层数(堆叠的 RNN 单元数量)。 | 1 | num_layers=2 表示堆叠两个 RNN 层。 |
nonlinearity | 非线性激活函数(仅适用于 RNN ,可选 tanh 或 relu )。 | tanh | nonlinearity='relu' 使用 ReLU 激活函数。 |
bias | 是否使用偏置(True 表示使用偏置项)。 | True | bias=False 表示不添加偏置。 |
batch_first | 输入/输出张量的维度顺序是否以 batch 为第一维(True 表示 [batch, seq, feature] )。 | False | batch_first=True 时输入形状为 (batch_size, sequence_length, input_size) 。 |
dropout | 除最后一层外的 RNN 层之间的 dropout 概率(0 表示不使用)。 | 0 | dropout=0.5 表示在每层之间随机丢弃 50% 的神经元。 |
bidirectional | 是否为双向 RNN(True 表示同时处理正向和反向序列)。 | False | bidirectional=True 会生成双向 RNN,输出维度翻倍(2 * hidden_size )。 |
假设正在处理一个文本分类任务,每个单词已经被嵌入为一个100维的向量,我们的序列长度(sequence length)是50(即最长句子有50个单词),批量大小(batch size)是32(一次处理32个句子),我们设定的隐藏层大小(hidden size)是128。
1. 输入维度
- 每个单词被嵌入为 100 维向量(
input_size = 100
)。 - 句子长度固定为 50 个单词(
sequence_length = 50
)。 - 批量大小为 32 个句子(
batch_size = 32
)。 - 输入张量形状:
(batch_size, sequence_length, input_size)
→(32, 50, 100)
。
2. 隐藏层计算
- RNN 每个时间步处理一个单词的嵌入向量(100 维),并基于上一时间步的隐藏状态生成当前隐藏状态。
- 隐藏状态维度设置为 128(
hidden_size = 128
)。 - 每个时间步的隐藏状态形状:
(batch_size, hidden_size)
→(32, 128)
。
3. 输出维度
- RNN 输出包含所有时间步的隐藏状态,形状为:
(batch_size, sequence_length, hidden_size)
→(32, 50, 128)
。 - 分类任务通常仅使用 最后一个时间步的隐藏状态(
[32, 128]
)作为输入。 - 全连接层(Dense Layer)将 128 维隐藏状态映射到分类数量(如二分类 → 2 维)。
- 最终输出形状:
(batch_size, num_classes)
→(32, 2)
(表示 32 个样本的分类概率分布)。
【示例代码】手动实现一个简单的RNN的前向传播过程
import numpy as np
# 初始化参数【s,h】
x = np.random.rand(3,2)
# 定义RNN参数
input_size = 2 # 词嵌入之后的结果
hidden_size = 3
output_size = 6 # 输出头 词表大小
# 初始化权重矩阵
U = np.random.rand(input_size,hidden_size) # 输入权重矩阵
W = np.random.rand(hidden_size,hidden_size) # 隐藏层权重矩阵
V = np.random.rand(hidden_size,output_size) # 输出权重矩阵
# 偏置
b_h = np.zeros((hidden_size,))
b_o = np.zeros((output_size,))
def tanh(x):
return np.tanh(x)
# 初始化隐藏层状态
H_prev = np.zeros((hidden_size,))
# 解码时间步 :每个单词
# 第一个单词
x1 = x[0,:]
H1 = tanh(np.dot(x1,U) + np.dot(H_prev,W) + b_h)
y1 = np.dot(H1,V) + b_o
# 时间步2
x2 = x[1,:]
H2 = tanh(np.dot(x2,U) + np.dot(H1,W) + b_h)
y2 = np.dot(H2,V) + b_o
# 时间步3
x3 = x[2,:]
H3 = tanh(np.dot(x3,U) + np.dot(H2,W) + b_h)
y3 = np.dot(H3,V) + b_o
print("时间步1的结果",H1,y1)
print("时间步2的结果",H2,y2)
print("时间步3的结果",H3,y3)
时间步1的结果 [0.15786976 0.14616117 0.19599538] [0.30847211 0.29100385 0.22966804 0.35643783 0.33077437 0.23346962]
时间步2的结果 [0.46865815 0.43445979 0.47375999] [0.81418741 0.80473318 0.65843277 0.96870814 0.92042698 0.65268124]
时间步3的结果 [0.79376069 0.70767975 0.75874627] [1.32934236 1.32428635 1.08278497 1.59445713 1.5166766 1.07724672]
2.RNNCell
RNNCell
是构成 RNN
(循环神经网络)的基本单元,负责处理 单个时间步 的输入,并基于前一时间步的隐藏状态更新当前隐藏状态。两者是 “组件” 与 “整体” 的关系:RNN
本质上是多个 RNNCell
在时间维度上的串联,通过循环调用 RNNCell
实现对序列数据的处理。
RNNCell 的工作流程
- 初始化 - 隐藏状态 h 0 h_0 h0 初始化为全零向量,形状为 [ b a t c h _ s i z e , h i d d e n _ s i z e ] [batch\_size, hidden\_size] [batch_size,hidden_size]。
- 逐时间步处理 - 对每个时间步 t t t,将输入 x t x_t xt 和上一时刻的隐藏状态 h t − 1 h_{t-1} ht−1 输入 RNNCell,计算得到当前隐藏状态 h t h_t ht 和输出 y t y_t yt。
- 循环展开 - RNNCell 被重复调用 s e q u e n c e _ l e n g t h sequence\_length sequence_length 次,形成完整的 RNN 层。
【RNNCell代码实现】
"""
承上启下为后面rnn api做准备
"""
import torch
from torch import nn
class RNNCellBase(nn.Module):
'''
batch_first=True输入数据直接为 [batch_size, seq_len, input_size]
'''
def __init__(self,input_size,hidden_size,batch_first):
super(RNNCellBase, self).__init__()
self.rnncell = nn.RNNCell(input_size, hidden_size)
self.input_size = input_size
self.hidden_size = hidden_size
# 判断数据格式的flag是否是RNN需要的【seq_len,batch_size,embedding_size】
self.batch_first = batch_first
'''
如果 batch_first=False,
输入格式为 [seq_len, batch_size, input_size]
'''
# 初始化隐藏状态
def _initialize_hidden(self,batch_size):
return torch.zeros(batch_size,self.hidden_size)
def forward(self,input,p_rev=None):
if self.batch_first:
batch_size,seq_len,_ = input.size()
else:
seq_len,batch_size,_ = input.size()
hiddens = [] # 初始化隐藏状态的结果
if p_rev is None:
p_rev = self._initialize_hidden(batch_size)
hidden_t = p_rev
# 时间步解码
for t in range(seq_len):
hidden_t = self.rnncell(input[:,t,:],hidden_t)
hiddens.append(hidden_t)
# 堆叠所有隐藏层状态结果成为一个新的张量
hiddens = torch.stack(hiddens)
# 数据转为【b,s,hidden_size】
if self.batch_first:
hiddens = hiddens.permute(1, 0, 2)
return hiddens
if __name__ == '__main__':
rnncell = RNNCellBase(10, 20, batch_first=True)
# 模拟词嵌入之后的结果
input = torch.randn(5, 3, 10)
output = rnncell(input)
print(output.shape)
nn.RNNCell
本质上只返回隐藏状态,它没有单独的输出结果。一般在 RNN
中,隐藏状态既可以被视为输出,也可以通过一个线性层将隐藏状态转化为实际的输出。
3.单层单向 RNN
步骤 1:导入库
import torch
from torch import nn
步骤 2:定义模型参数
vocab_size = 10 # 词汇表大小(输入的索引范围)
input_size = 5 # 词嵌入维度(每个词转换为 input_size 维向量)
hidden_size = 20 # RNN 隐藏层维度
batch_first = True # 输入/输出格式为 [batch_size, seq_len, ...]
步骤 3:定义 RNN 模型类
class RNN(nn.Module):
def __init__(self, vocab_size, input_size, hidden_size, batch_first):
super(RNN, self).__init__()
# 1. 词嵌入层:将词汇索引转为向量
self.embed = nn.Embedding(vocab_size, input_size)
# 2. RNN 层:处理序列数据
self.rnn = nn.RNN(input_size, hidden_size, batch_first=batch_first)
# 3. 输出层:将隐藏状态映射回词汇表大小
self.fn = nn.Linear(hidden_size, vocab_size)
Embedding
:将离散的词汇索引(如[1, 3, 5]
)转为连续向量。RNN
:处理序列输入,输出每个时间步的隐藏状态。Linear
:将隐藏状态转换为词汇表大小的输出(如预测下一个词)。
步骤 4:定义前向传播逻辑
def forward(self, x):
# 1. 词嵌入:[batch_size, seq_len] → [batch_size, seq_len, input_size]
x = self.embed(x)
# 2. RNN:[batch_size, seq_len, input_size] → [batch_size, seq_len, hidden_size]
x, h = self.rnn(x)
# 3. 输出层:[batch_size, seq_len, hidden_size] → [batch_size, seq_len, vocab_size]
x = self.fn(x)
return x
步骤 5:测试模型
if __name__ == '__main__':
# 生成输入数据:[batch_size=4, seq_len=3](每个样本有3个词)
x = torch.randint(9, (4, 3))
# 实例化模型
model = RNN(vocab_size, input_size, hidden_size, batch_first)
# 前向传播
out = model(x)
# 输出形状:[4, 3, 10](batch_size × seq_len × vocab_size)
print(out.shape)
print(out)
- 输出是每个时间步对下一个词的预测概率(共 10 个可能的词)。
4.单层双向RNN
双向RNN(Bidirectional RNN,Bi-RNN)是标准RNN的扩展,核心在于同时处理序列的正向和反向信息。与单向RNN仅依赖过去的时间步不同,Bi-RNN通过并行运行两个独立的RNN层——一个从前向后处理序列,另一个从后向前处理——最终将两者的隐藏状态融合,从而捕捉更全面的上下文依赖关系。
Bi-RNN的两个方向在计算时互不干扰,正向RNN基于当前输入和前一时间步的状态生成隐藏状态,反向RNN则基于当前输入和后一时间步的状态生成隐藏状态。在每一步,这两个方向的隐藏状态会被拼接或加权求和,形成最终的隐藏状态。例如,在自然语言处理中,判断某个词的含义时,Bi-RNN能同时参考其前后的词汇信息,而单向RNN只能依赖单侧上下文。
实现
在代码实现上,Bi-RNN的关键参数是bidirectional=True
,这会使得RNN层的输出维度翻倍(如hidden_size*2
)
self.birnn = nn.RNN(input_size, hidden_size, batch_first=batch_first,bidirectional= True)
self.fn = nn.Linear(hidden_size*2, vacab_size)
例如,使用PyTorch的nn.RNN
时,若设置双向模式,输出形状会从[batch_size, seq_len, hidden_size]
变为[batch_size, seq_len, 2*hidden_size]
。此外,Bi-RNN的隐藏状态通常需要手动拼接(如正向和反向的最后一个时间步状态),或通过全局池化(如平均池化)压缩序列长度,以适配多对一的任务需求。
output = torch.mean(out, dim=1)
接下来是完整实现步骤:
步骤 1:导入库与定义参数
import torch
import torch.nn as nn
# 模型参数
vocab_size = 10 # 词表大小(输入的索引范围)
input_size = 5 # 词嵌入维度
hidden_size = 20 # RNN 隐藏层维度
batch_first = True # 输入/输出格式为 [batch_size, seq_len, ...]
- 双向 RNN 特点:与单向 RNN 不同,双向 RNN 同时处理序列的正向和反向信息。
bidirectional=True
是关键参数,会将输出扩展为2 * hidden_size
。
步骤 2:定义模型结构
class BIRNN(nn.Module):
def __init__(self, vocab_size, input_size, hidden_size, batch_first):
super(BIRNN, self).__init__()
# 1. 词嵌入层:将词汇索引转为向量
self.embedding = nn.Embedding(vocab_size, input_size)
# 2. 双向 RNN 层:处理序列,输出正向和反向隐藏状态
self.birnn = nn.RNN(input_size, hidden_size, batch_first=batch_first, bidirectional=True)
# 3. 输出层:将双向隐藏状态拼接后映射回词表大小
self.fn = nn.Linear(hidden_size * 2, vocab_size) # 双向输出需乘以 2
与单向 RNN 的区别:
- 单向 RNN 的输出维度是
[batch_size, seq_len, hidden_size]
。 - 双向 RNN 的输出维度是
[batch_size, seq_len, 2 * hidden_size]
,因为正向和反向隐藏状态被拼接。
步骤 3:前向传播逻辑
def forward(self, x):
x = self.embedding(x) # [4,3] → [4,3,5](词嵌入)
out, h_n = self.birnn(x) # [4,3,5] → [4,3,40](双向 RNN 输出)
output = torch.mean(out, dim=1) # [4,3,40] → [4,40](取时间步的平均值)
out = self.fn(output) # [4,40] → [4,10](映射回词表大小)
return out
- 双向 RNN 输出:
out
包含所有时间步的正向和反向隐藏状态。例如,输入长度为 3 的序列,输出形状为[4,3,40]
(2 * hidden_size = 40
)。 - 全局池化:使用
torch.mean(out, dim=1)
将时间步维度压缩,得到固定长度的表示([4,40]
),适合“多对一”任务(如分类)。 - 单向 RNN 对比:单向 RNN 通常直接取最后一个时间步的隐藏状态( s n {s}_{n} sn),而双向 RNN 需要额外处理正向和反向的 s n {s}_{n} sn(例如拼接)。
步骤 4:测试模型
if __name__ == '__main__':
# 生成输入数据:[batch_size=4, seq_len=3](每个样本有 3 个词)
x = torch.randint(9, (4, 3))
model = BIRNN(vocab_size, input_size, hidden_size, batch_first)
out = model(x)
print(out.shape) # 输出形状: [4, 10](4 个样本 × 10 个词预测)
print(out)
输出解释:
- 双向 RNN 的最终输出是
[batch_size, vocab_size]
,每个样本对应一个类别预测(如分类任务)。 - 单向 RNN 在“多对一”任务中通常只取最后一个时间步的隐藏状态( s n {s}_{n} sn),而双向 RNN 需要结合正向和反向的 s n {s}_{n} sn(例如拼接后输入线性层)。
完整代码展示:
import torch
import torch.nn as nn
# 模型参数
vacab_size = 10
input_size = 5
hidden_size = 20
batch_first = True
class BIRNN(nn.Module):
def __init__(self,vacab_size,input_size,hidden_size,batch_first):
super(BIRNN, self).__init__()
# 1.词嵌入层
self.embedding = nn.Embedding(vacab_size, input_size)
# 2.RNN
self.birnn = nn.RNN(input_size, hidden_size, batch_first=batch_first,bidirectional= True)
self.fn = nn.Linear(hidden_size*2, vacab_size)
def forward(self, x):
x = self.embedding(x) # [4,3] -> [4,3,5]
out, h_n = self.birnn(x) # [4,3,5] -> [4,3,40]
output = torch.mean(out, dim=1) # [4,3,40] -> [4,40]
out = self.fn(output) # [4,40] -> [4,10]
return out
if __name__ == '__main__':
# 初始数据
# 生成一个形状为 [4, 3] 的张量,
# 其中每个元素是从 0 到 8 的随机整数(不包括 9)
x = torch.randint(9,(4,3))
model = BIRNN(vacab_size,input_size,hidden_size,batch_first)
out = model(x)
print(out.shape)
print(out)
【注意事项】
-
双向 RNN 的隐藏状态拼接:
-
s n {s}_{n} sn 的形状是
[2, batch_size, hidden_size]
(2
表示正向和反向)。若需要直接使用 s n {s}_{n} sn,需手动拼接:h_n = torch.cat([h_n[0], h_n[1]], dim=1) # [batch_size, 2 * hidden_size]
-
本例通过全局池化简化了处理,避免手动拼接。
-
-
双向 RNN 的训练效率:
- 双向 RNN 会翻倍计算量,但能捕捉更全面的上下文信息(正向和反向依赖)。
-
任务适配性:
- “多对一”任务(如分类)需将序列压缩为固定长度表示(如平均池化)。
- “多对多”任务(如序列标注)需保留所有时间步的输出(如 NER 任务)。
五、RNN训练方法—BPTT
BPTT的核心思想
BPTT(Backpropagation Through Time)是训练循环神经网络(RNN)的核心算法,其本质是将RNN在时间维度上展开为一个等效的深层前馈网络,然后应用反向传播计算梯度并更新参数。
给定长度为
T
{T}
T 的输入序列
x
1
,
x
2
,
…
,
x
T
{x}_{1}, {x}_{2}, \dots, {x}_{T}
x1,x2,…,xT,RNN在每个时间步共享参数
U
{U}
U、
W
{W}
W、
V
{V}
V,并通过隐藏状态
s
t
{s}_{t}
st 传递历史信息。BPTT从最后一个时间步
t
=
T
{t} = {T}
t=T 开始,将误差沿时间轴反向传播,计算损失对所有参数的梯度,最终完成参数更新。
BPTT的计算流程
- 前向传播
在每个时间步 t {t} t,RNN依次计算隐藏状态和输出:
s t = tanh ( U x t + W s t − 1 + b ) {s}_{t} = \tanh({U} {x}_{t} + {W} {s}_{t-1} + {b}) st=tanh(Uxt+Wst−1+b)
o t = V s t + c {o}_{t} = {V} {s}_{t} + {c} ot=Vst+c
若用于分类任务,输出层通常接 softmax 函数得到预测分布 y ^ t = s o f t m a x ( o t ) {\hat{y}}_{t} = \mathrm{softmax}({o}_{t}) y^t=softmax(ot)。初始状态 s 0 {s}_{0} s0 一般设为零向量。
-
损失计算
总损失为各时间步损失之和:
L = ∑ t = 1 T L t {L} = \sum_{t=1}^{T} {\mathcal{L}}_{t} L=∑t=1TLt,其中 L t {\mathcal{L}}_{t} Lt 可为交叉熵损失 − ∑ y t log y ^ t -\sum {y}_{t} \log {\hat{y}}_{t} −∑ytlogy^t。
- 反向传播
从 t = T {t} = {T} t=T 开始,逐时间步反向计算梯度:
-
输出误差: δ o t = ∂ L ∂ o t {\delta} {o}_{t} = \frac{\partial {L}}{\partial {o}_{t}} δot=∂ot∂L
-
隐藏层误差: δ s t = V ⊤ δ o t + W ⊤ δ s t + 1 ⊙ ( 1 − s t 2 ) {\delta} {s}_{t} = {V}^\top {\delta} {o}_{t} + {W}^\top {\delta} {s}_{t+1} \odot (1 - {s}_{t}^2) δst=V⊤δot+W⊤δst+1⊙(1−st2)
(注意: δ s T + 1 = 0 {\delta} {s}_{T+1} = 0 δsT+1=0)
-
参数梯度累加
每个时间步对共享参数的梯度贡献如下:
∂ L ∂ U + = δ s t x t ⊤ {\frac{\partial {L}}{\partial {U}}} += {\delta} {s}_{t} {x}_{t}^\top ∂U∂L+=δstxt⊤
∂ L ∂ W + = δ s t s t − 1 ⊤ {\frac{\partial {L}}{\partial {W}}} += {\delta} {s}_{t} {s}_{t-1}^\top ∂W∂L+=δstst−1⊤
∂ L ∂ V + = δ o t s t ⊤ {\frac{\partial {L}}{\partial {V}}} += {\delta} {o}_{t} {s}_{t}^\top ∂V∂L+=δotst⊤
- 参数更新
使用梯度下降法更新参数:
U ← U − η ∂ L ∂ U {U} \leftarrow {U} - {\eta} {\frac{\partial {L}}{\partial {U}}} U←U−η∂U∂L
W ← W − η ∂ L ∂ W {W} \leftarrow {W} - {\eta} {\frac{\partial {L}}{\partial {W}}} W←W−η∂W∂L
V ← V − η ∂ L ∂ V {V} \leftarrow {V} - {\eta} {\frac{\partial {L}}{\partial {V}}} V←V−η∂V∂L
RNN 中的主要问题
a.梯度消失与梯度爆炸
RNN 通过 BPTT训练时,梯度需要沿时间轴多次连乘。若权重矩阵的特征值小于1,梯度会指数级衰减(梯度消失);若特征值大于1,则梯度会指数级爆炸(梯度爆炸)。这导致模型难以学习长期依赖关系。
- 数学原理:
对于隐藏状态 s t = tanh ( W s t − 1 + U x t ) {s}_{t} = \tanh({W} {s}_{t-1} + {U} {x}_{t}) st=tanh(Wst−1+Uxt),其梯度计算涉及:
∂ L ∂ W = ∑ t = 1 T ∂ L ∂ s t ∂ s t ∂ W = ∑ t = 1 T ∏ k = 1 t ∂ s k ∂ s k − 1 ⋅ ∂ s 1 ∂ W {\frac{\partial {L}}{\partial {W}}} = \sum_{t=1}^{T} {\frac{\partial {L}}{\partial {s}_{t}}} {\frac{\partial {s}_{t}}{\partial {W}}} = \sum_{t=1}^{T} \prod_{k=1}^{t} {\frac{\partial {s}_{k}}{\partial {s}_{k-1}}} \cdot {\frac{\partial {s}_{1}}{\partial {W}}} ∂W∂L=t=1∑T∂st∂L∂W∂st=t=1∑Tk=1∏t∂sk−1∂sk⋅∂W∂s1
若 ∂ s k ∂ s k − 1 < 1 {\frac{\partial {s}_{k}}{\partial {s}_{k-1}}} < 1 ∂sk−1∂sk<1,连乘效应会导致梯度消失;反之则爆炸。
b.长期依赖问题
RNN 理论上能处理任意长度的序列,但实际中难以捕捉远距离依赖。例如,在句子“只有在记住最前面的 student 是复数还是单数才能准确得到后面的结果”中,RNN 可能忽略开头信息,导致预测错误。
根本原因:
梯度消失使得早期时间步的权重无法有效更新,导致模型无法学习到长期依赖。
c.计算资源消耗大
- 训练速度慢:RNN 依赖序列顺序计算,无法并行化,导致长序列训练效率低。
- 内存开销高:BPTT 需存储所有时间步的中间状态,长序列时占用大量内存。