02_传统序列模型

1 RNN

在上一节中,已经解决了词向量的表示,虽然已经可以表示出每个 token 的语义信息,但是对于自然语言来说,token 的顺序对于理解句子的含义非常重要,比如“猫吃鱼”和“鱼吃猫”的含义完全不同。

因此在 1980 年代提出了一种新的方法 RNN(Recurrent Neural Network,循环神经网络),RNN 通过逐个读取 token 的信息,并在每一步结合当前词和上下文信息,从而不断更新对句子的理解。

1.1 基础结构

RNN 的核心结构是一个具有循环连接的隐藏层,以时间步(time step)为单位,对输入序列中的每一个 token 进行处理。在每一个时间步中,RNN 层根据当前输入的 token 和上一个时间步输出的隐藏层状态,生成新的隐藏层状态,并作为下一层的隐藏层输入。

ht = f(Whht − 1 + Wxxt + bh)

在一层 RNN 中共享权重,使用相同激活函数,一般选择 tanh。

上图中 4 个时间步的输入分别为 x1, x2, x3, x4,每个输入向量维度为 3,隐藏层神经元个数为 4,每个词的输出向量维度则为 4。每个时间步的输入向量 xi 和权重 Wx 相乘,上一个时间步的隐状态 hi − 1 和权重 Wh 相乘,求和后加上偏置,得到当前时间步的隐状态 hi

本质上就是传统的神经网络加上一个循环操作。

下图抽象为一层 RNN 的结构,输入 xt,输出 ht,右边是沿时间步展开的一层 RNN 结构。

1.2 多层结构

将多个 RNN 层按堆叠起来,底层网络更容易捕捉局部模式(如词组、短语),而高层网络则能学习更抽象的语义信息(如句子主题或语境)。每一层的输出序列作为下一层的输入序列,最底层 RNN 接收原始输入序列,顶层 RNN 的输出作为最终结果用于后续任务。

在下图中,按照时间步顺序先向上传播,或者按照层的顺序,先向右传播,没有先后依赖顺序,在 PyTorch 中的实现是按照时间步顺序,先向上传播。

1.3 双向结构

基础的 RNN 在每个时间步只输出一个隐藏状态,该状态仅包含来自上文的信息,而无法利用当前词之后的下文。于是引入了双向 RNN (Bidirectional RNN),可以在每个时间步同时利用前文和后文信息,有助于提升序列标注等任务的预测效果。

  • 正向 RNN:从前到后处理序列
  • 反向 RNN:从后到前处理序列。

由于正向反向没有依赖关系,所以可以并行计算。每个时间步的输出,是正向和反向隐藏状态的组合,从而同时获得上下文信息,例如拼接或求和,在 PyTorch 中的实现是拼接。

1.4 多层+双向结构

多层结构和双向结构组合使用,每层都是双向 RNN。

1.5 API 使用

RNN 层的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import torch
import torch.nn as nn

rnn = nn.RNN(
input_size=3, # 输入词向量维度
hidden_size=5, # 隐藏层维度,决定神经元的个数,对应抽取的词向量维度
num_layers=3, # RNN 层数,默认为 1
nonlinearity="tanh", # 激活函数,默认为 tanh
bias=True, # 是否使用偏置项,默认为 True
batch_first=True, # 输入张量和输出张量第一维是否为 batch_size
dropout=0.0, # 除最后一层外,每层 dropout 的概率,默认为 0.0
bidirectional=True, # 是否使用双向 RNN,默认为 False
device=None, # 运行设备,如'cuda', 'cpu'
dtype=None # 数据类型
)

RNN 层的输入输出:

1
2
3
4
5
input = torch.randn(2, 5, 3)
hx = torch.randn(2 * 3, 2, 5)

output, hn = rnn(input, hx)
output.shape, hn.shape
(torch.Size([2, 5, 10]), torch.Size([6, 2, 5]))
  • input: 输入序列,[seq_len, batch_size, input_size],若 batch_first=True,则为[batch_size, seq_len, input_size]
  • hx: 初始隐状态,可选,默认为 0,[num_layers * num_directions, batch_size, hidden_size]
  • output: 输出序列,包含最后一层每个时间步的隐状态,[seq_len, batch_size, hidden_size * num_directions],若 batch_first=True,则为[batch_size, seq_len, hidden_size * num_directions]
  • hn: 包含每一层每个方向最后一个时间步的隐状态,[num_layers * num_directions, batch_size, hidden_size]

1.6 存在问题

RNN 训练时采用的是时间反向传播(Backpropagation Through Time, BPTT)方法,在反向传播过程中,梯度需要在每个时间步上不断链式传递。

tanh 的导数范围为 (0, 1]:

  • 如果 W 的值也小于 1,经过多次连乘,早期时间步的梯度会呈指数级衰减,并迅速接近 0,从而导致梯度消失。由于早期时间步的梯度几乎为 0,总梯度几乎只受最近时间步的影响,也就是说早期的输入信息几乎不会对 Wh 的更新产生贡献。导致模型只能学到短期依赖,而无法学到长期依赖
  • 如果 W 的值大于 1,经过多次连乘,早期时间步的梯度又会呈指数级增长,导致梯度爆炸,又会使参数更新极其不稳定,甚至出现溢出。

由于梯度消失和梯度爆炸的问题,当输入序列很长时,RNN 难以有效学习早期输入对最终输出的影响,也就是长期依赖建模困难。并且由于每个时间步的输入依赖上一个时间步的输出,无法并行计算。

2 LSTM

为了缓解梯度消失和梯度爆炸的问题,Hochreiter 和 Schmidhuber 于 1997 年提出了长短期记忆网络(Long Short-Term Memory, LSTM)。

2.1 基础结构

LSTM 通过引入特殊的记忆单元(Memory Cell)和三个门结构———遗忘门、输入门、输出门,有效提升了模型对长序列依赖关系的建模能力。

  • 记忆单元(Memory Cell): 负责在序列中长期保存关键信息,在多个时间步之间直接传递信息,记忆单元是缓解梯度消失和梯度爆炸问题的核心。
  • 遗忘门(Forget Gate): 决定当前时间步要忘记多少过去的记忆。根据当前时间步的输入 xt 和上一个时间步的隐状态 ht − 1,生成一个 0~1 的系数,与上一时间步的记忆单元状态 Ct − 1 做哈达玛积,从而调整哪些记忆需要遗忘。
    • ft = Sigmoid(Wxf ⋅ xt + Whf ⋅ ht − 1 + bf)
  • 输入门(Input Gate): 控制当前时间步的输入向记忆单元存入多少信息。当前时间步的信息由当前输入 xt 和上一个时间步的隐状态 ht − 1 计算得出,输入门也由 xt 和 $h_{t-1} 计算得出。输入门生成 0~1 的系数,与当前时间步的信息相乘,得到需要存入记忆单元的信息。
    • $$\hat{h_t} = tanh(W_x\cdot x_t+W_h\cdot h_{t-1}+b) \\ i_t = Sigmoid(W_x^i\cdot x_t+W_h^i\cdot h_{t-1}+b_i)$$
    • 记忆单元更新公式:遗忘门 上一时间步的记忆 + 输入门 当前时间步的信息 $$C_t = f_t \odot C_{t-1} + i_t \odot \hat {h_t}$$
  • 输出门(Output Gate): 控制从记忆单元中读取多少信息作为当前时间步的隐状态输出。根据当前时间步的输入 xt 和上一时间步的隐状态 ht − 1 计算得出 0~1 的系数,由于记忆单元一直在累加,为了数值的稳定性,需要对记忆单元做 tanh 进行规范,压缩至 -1~1 的范围,然后与输出门做哈达玛积,决定输出多少信息。
    • ot = Sigmoid(Wxo ⋅ xt + Who ⋅ ht − 1 + bo)
    • 当前时间步隐状态:ht = ot ⊙ tanh(Ct)

2.2 多层结构

LSTM 也可以通过堆叠多个层来构建更深的网络,以增强模型对序列特征的建模能力。每一层 LSTM 的输出隐藏状态,会作为下一层 LSTM 的输入,注意每一层会维护独立的记忆单元,而不会传递给下一层。

2.3 双向结构

LSTM 同样可以通过双向结构,捕获序列中的上文信息和下文信息,进一步提升模型的建模能力。每个时间步同时得到两个隐藏状态,通常将它们进行拼接,形成最终的输出。

2.4 多层+双向结构

LSTM 同样可以多层结构和双向结构组合使用。

2.5 API 使用

构造 LSTM 层和 RNN 的参数几乎一致,唯一不同是少了 nonlinearity 参数,多了一个 proj_size 参数,详见官方文档。基本思想是记忆单元 Ct 承载信息较多,而 ht 的信息较少,没有必要使二者维度相同,可以使用 proj_sizeht 映射为更低维度的向量,减少计算负担,提高效率。

1
2
3
4
5
6
7
8
9
10
11
12
lstm = nn.LSTM(
input_size=3, # 输入词向量维度
hidden_size=5, # 隐藏层维度,决定神经元的个数,对应抽取的词向量维度
num_layers=3, # LSTM 层数,默认为 1
bias=True, # 是否使用偏置项,默认为 True
batch_first=True, # 输入张量和输出张量第一维是否为 batch_size
dropout=0.0, # 除最后一层外,每层 dropout 的概率,默认为 0.0
bidirectional=True, # 是否使用双向 LSTM,默认为 False
proj_size=0, # 将 hidden_size 投影到 proj_size 维度,减少计算负担
device=None, # 运行设备,如'cuda', 'cpu'
dtype=None # 数据类型
)

LSTM 的输入输出:

1
2
3
4
5
6
input = torch.rand(2, 5, 3)
hx = torch.rand(3 * 2, 2, 5)
cx = torch.rand(3 * 2, 2, 5)

output, (hn, cn) = lstm(input, (hx, cx))
output.shape, hn.shape, cn.shape
(torch.Size([2, 5, 10]), torch.Size([6, 2, 5]), torch.Size([6, 2, 5]))
  • input: 输入序列,[seq_len, batch_size, input_size],若 batch_first=True,则为[batch_size, seq_len, input_size]
  • hx: 初始隐状态,可选,默认为 0,[num_layers * num_directions, batch_size, hidden_size]
  • cx: 初始细胞状态,可选,默认为 0,和 hx 形状一致, [num_layers * num_directions, batch_size, hidden_size]
  • output: 输出序列,包含最后一层每个时间步的隐状态,[seq_len, batch_size, hidden_size * num_directions],若 batch_first=True,则为[batch_size, seq_len, hidden_size * num_directions]
  • hn: 包含每一层每个方向最后一个时间步的隐状态,[num_layers * num_directions, batch_size, hidden_size]
  • cn: 每一层每个方向最后一个时间步的细胞状态,[num_layers * num_directions, batch_size, hidden_size]

注意:如果 RNN 和 GRU 的 hx 参数为 None,调用 rnn/gru(x, hx) 时 hx 默认为全零向量。而 LSTM 的参数是一个元组 (hx, cx),如果为 None 则必须 lstm(x),不能传入为 None 的 hx 和 cx。

2.6 存在问题

LSTM 通过引入记忆单元,提供了一条稳定的梯度传播路径,就像一条 “高速公路”,信息可以在上面长距离流动,而不会受到太多的衰减。记忆单元更新公式为: $$C_t = f_t \odot C_{t-1} + i_t \odot \hat {h_t}$$

记忆单元的更新方式主要是加法,而不是乘法,求偏导后为 $\frac{\partial C_t}{\partial C_{t-1}}=f_t$,沿记忆单元传播路径,实际就是 ft 连乘,而 ft 是 0~1 的遗忘门,完全杜绝了梯度爆炸的问题。在实际任务中,遗忘门倾向于忘得少,通常接近 1,梯度衰减速度远远小于传统 RNN 中隐状态链式传播的指数衰减。使得早期时间步的输入也能通过记忆单元路径影响最终梯度,参与到参数更新中,提升了模型对长序列依赖的处理能力。

  • 长期依赖建模能力有限:LSTM 虽然解决了梯度爆炸,但只是延缓了梯度消失,并不能完全消除。当序列很长时,LSTM 依然难以捕捉远距离的依赖关系。
  • 难以并行计算:这里指时间步之间的计算无法并行,后一个时间步的输入依赖前一个时间步的输出。当然这不仅仅是 LSTM 的问题,RNN 和变体这些序列模型都存在这种问题。
  • 计算开销大:LSTM 内部包含三个门控机构和记忆单元,参数量大、结构复杂,计算开销比 RNN 高很多。

3 GRU

2014年,Cho et al. 提出了Gated Recurrent Unit(GRU, 门控循环单元),是为了简化 LSTM 结构、降低计算成本而提出的一种变体。GRU 保留了门控机制的核心思想,但结构更为简洁,参数更少,训练效率更高。

在上一节 LSTM 的问题中,GRU 仅仅减轻了计算开销大的问题。GRU 在保持类似性能的同时,能够显著减少训练时间。

3.1 基础结构

GRU 取消了记忆单元,核心结构包括重置门(Reset Gate)和更新门(Update Gate)。

  • 重置门(Reset Gate):控制获取多少历史信息。由当前输入 xt 和上一个时间步的隐状态 ht − 1 计算得出 0~1 的门值。作用在上一个时间步的隐状态 ht − 1 中,从而控制获取多少历史信息。
    • rt = Sigmoid(Wxr * xt + Whr * ht − 1 + br)
    • 当前时间步的信息(候选隐状态): $$\hat {h_t} = tanh(W_x * x_t + W_h * (r_t \odot h_{t-1}) + b_h)$$
  • 更新门(Update Gate, z):控制新信息和旧信息的融合比例。也是由当前输入 xt 和上一个时间步的隐状态 ht − 1 计算得出 0~1 的门值 zt。分别将 zt1 − zt 作用到新信息(候选隐状态)和旧信息(上一个时间步的隐状态),从而控制引入多少新信息,保留多少旧信息。
    • zt = Sigmoid(Wxz * xt + Whz * ht − 1 + bz)
    • 当前时间步的隐状态: $$h_t = z_t \odot \hat{h_t} + (1 - z_t) \odot h_{t-1}$$

在不同资料中,更新门有不同融合方式,分别是 $$z_t \odot \hat{h_t} + (1 - z_t) \odot h_{t-1}$$ $$(1 - z_t) \odot \hat{h_t} + z_t \odot h_{t-1}$$ 两种写法在实际应用中没有本质的区别,基本思想是一样的,只是表述方法不一样。

3.2 复杂结构和 API

GRU 也同样支持多层结构、双向结构,以及组合使用,和 RNN 基本一致,不再赘述。

由于从外部来看,GRU 和 RNN 几乎是一致的,都是输入 input 和 hx,输出 output 和 hn,并且维度也都是一致的,只需要将 RNN 的 API 名字换为 GRU 即可。唯一区别就是取消了 nononlinearity 参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
gru = nn.GRU(
input_size=3, # 输入词向量维度
hidden_size=5, # 隐藏层维度,决定神经元的个数,对应抽取的词向量维度
num_layers=3, # RNN 层数,默认为 1
bias=True, # 是否使用偏置项,默认为 True
batch_first=True, # 输入张量和输出张量第一维是否为 batch_size
dropout=0.0, # 除最后一层外,每层 dropout 的概率,默认为 0.0
bidirectional=True, # 是否使用双向 RNN,默认为 False
device=None, # 运行设备,如'cuda', 'cpu'
dtype=None # 数据类型
)

input = torch.randn(2, 5, 3)
hx = torch.randn(2 * 3, 2, 5)

output, hn = gru(input, hx)
output.shape, hn.shape
(torch.Size([2, 5, 10]), torch.Size([6, 2, 5]))

3.3 存在问题

虽然 GRU 的长序列依赖建模比 RNN 效果好很多,并且计算量相较于 LSTM 也少很多。但是 GRU 仍然没有完全解决梯度消失的问题,在超长依赖建模时仍然存在限制,并且也存在 RNN 及变体的通病———不能并行计算。


02_传统序列模型
https://zhubaoduo.com/2024/08/14/大模型开发/06_自然语言处理/02_传统序列模型/
作者
baoduozhu
发布于
2024年8月14日
许可协议