NLP-AHU-181

1. 引言

序列模型是用来处理序列数据序列数据,顾名思义,我们很容易想到一个特性--顺序性,其具有强烈的顺序依赖关系。比如时间序列,自然语言处理,音频信号等等,他们的顺序都是万万不能颠倒的,一旦颠倒,与其要表达的意思将会大相径庭。也就是数据的排列顺序承载着核心信息。

举两个栗子:时间序列:股票今天的价格与昨天、上周的价格紧密相关,顺序不能颠倒。
自然语言:“猫追老鼠”和“老鼠追猫”是完全不同的意思,词语顺序决定了语义。
除了顺序性外,序列数据还包括可变长度、长期与短期依赖、上下文敏感性等性质就不再过多赘述。

我们知道传统神经网络包含输入层、隐层、输出层,其通过激活函数控制输出,层与层之间的连接包含权值。但是这种简单的层次结构叠加无法处理序列关系。如下图所示,传统网络每次计算都是独立的,仅依赖当前输入,因此不具有记忆性,也就无法处理序列关系,无法利用上下文信息。为了让 AI 拥有“记忆”,科学家们设计了一系列天才般的算法。今天,我们就来扒一扒序列建模的三大功臣:RNN、LSTM 和 BiLSTM

先在这里对这三大模型进行总结:

RNN:

  • 具有”记忆”功能:能够记住之前处理过的信息
  • 参数共享:在不同时间步使用相同的参数
  • 存在短期依赖:只能记住距离较近的信息

LSTM:

  • 引入门控机制:遗忘门、输入门和输出门选择性记忆和遗忘
  • 双状态传递:增加长期记忆状态
  • 解决长期依赖:缓解了梯度消失问题

BiLSTM:

  • 双向捕捉信息:两个独立反向的 LSTM 层叠加
  • 全局上下文感知:掌握上下文的复杂信息
2. RNN(递归神经网络)

RNN(Recurrent Neural Network)是一类用于处理序列数据的神经网络,具有独特的特征,即通过使用其内部状态来处理输入序列,从而保持对先前输入的记忆。正如前面总结的,RNN的核心特点是“参数共享”:在处理整个序列的不同时间步时,网络使用的是同一套权重矩阵,这使得它能够处理任意长度的输入。

设计过程

  • 核心思想:引入循环连接,在隐藏层的不同时间步之间加入连接,使得前一时刻的隐藏状态能够传递到当前时刻,使隐藏状态传递历史信息。
  • 结构:输入层、隐藏层(循环单元)、输出层;隐藏状态$h_t$作为“记忆”。

数学表达:

  • 隐藏状态更新:$h_t = \tanh(W_h h_{t-1} + W_x x_t + b_h)$
  • 时间步长t处的隐藏状态展开:$$ h_t = \sigma_h\left( W_x x_t + W_h \sigma_h\left( W_x x_{t-1} + W_h h_{t-2} + b_h \right) + b_h \right) $$
  • 输出计算:$y_t = softmax(W_y h_t + b_y)$
    注:$W_h$为循环连接的权重矩阵、$W_x$为输入层和隐藏层之间的权重矩阵、$W_y$为隐藏层和输出层之间的权重矩阵,$b_h$,$b_y$为偏置向量,$\tanh$,$softmax$为常用激活函数,$\sigma_h$代表某种激活函数。注意这里$W_x$和$W_y$是共用的权重矩阵
  • 梯度计算(反向传播): $$ \frac{\partial L}{\partial W_h} = \sum_t \frac{\partial L}{\partial h_t} \cdot \frac{\partial h_t}{\partial W_h} $$
  • 局限性:梯度消失/爆炸问题(梯度链式法则导致$\frac{\partial h_t}{\partial h_{t-k}}$指数衰减)梯度在反向传播期间呈指数级增长或收缩,导致网络无法学习"很久之前"的信息。

算法细节

  • 前向传播:输入序列逐步处理,每个时间步更新隐藏状态。
  • 关键步骤:初始化隐藏状态$h_0$,迭代计算$h_t$和输出$y_t$。
  • pytorch示例:
    • import torch
      import torch.nn as nn
      
      class SimpleRNN(nn.Module):
          def __init__(self, input_size, hidden_size, output_size, num_layers=1):
              super(SimpleRNN, self).__init__()
              self.hidden_size = hidden_size
              self.num_layers = num_layers
              
              # 定义 RNN 层
              # batch_first=True 表示输入数据的形状需为 (batch_size, sequence_length, input_size)
              self.rnn = nn.RNN(input_size, hidden_size, num_layers, batch_first=True)
              
              # 定义全连接输出层,将 RNN 的隐藏状态映射到最终的输出维度
              self.fc = nn.Linear(hidden_size, output_size)
      
          def forward(self, x):
              # 获取当前的 batch_size
              batch_size = x.size(0)
              
              # 1. 初始化隐藏状态 h_0 (形状: num_layers, batch_size, hidden_size)
              # 通常初始化为全0张量,并确保它和输入 x 在同一个设备上
              h0 = torch.zeros(self.num_layers, batch_size, self.hidden_size).to(x.device)
              
              # 2. 前向传播
              # out 包含了所有时间步的隐藏状态 (batch_size, seq_length, hidden_size)
              # h_n 包含了最后一个时间步的隐藏状态 (num_layers, batch_size, hidden_size)
              out, h_n = self.rnn(x, h0)
              
              # 3. 计算输出
              # 通常在分类任务中,我们只关心整个序列处理完后的最终状态
              # 所以提取 out 的最后一个时间步(索引为 -1)传入全连接层
              final_output = self.fc(out[:, -1, :]) 
              
              return final_output
      
      # --- 使用示例 ---
      # 假设输入特征维度为 10,隐藏层特征维度为 20,输出类别数为 2
      model = SimpleRNN(input_size=10, hidden_size=20, output_size=2)
      
      # 模拟一个 batch_size=32,序列长度 seq_len=5 的数据
      dummy_input = torch.randn(32, 5, 10) 
      predictions = model(dummy_input)
      print("输出形状:", predictions.shape) # 预期输出: torch.Size([32, 2])
      

3. LSTM(长短期记忆网络):学会选择性遗忘

RNN虽然具备了“记忆”的功能,但是存在梯度消失问题,无法拥有长期依赖,为了解决这个问题,受门控电路的启发(如Hochreiter & Schmidhuber, 1997),LSTM 应运而生,它模拟“选择性记忆”机制从而保留长期依赖,通过门控单元控制信息流。

设计过程

核心思想:在隐藏层引入长期状态$C_t$(长期记忆)和三个门(遗忘门、输入门、输出门)。遗忘门决定丢弃的旧信息,输入门决定存储哪些新信息,输出门输出内容。

遗忘门:通过当前的输入和上一个时间步的隐藏状态通过一个全连接层并应用 sigmoid 函数得到遗忘门的值。其输出矩阵每个元素都在[0,1]内,0 表示完全遗忘,1 表示完全保留。将输出矩阵与$C_t-1$相乘即通过乘法门筛选要遗忘多少信息。

遗忘门公式: \[ f_t = \sigma\left(W_f \cdot \left[h_{t-1}, x_t\right] + b_f\right) \]
其中,$W_f$ 是权重矩阵,$b_f$ 是偏置项,$h_{t-1}$ 是输入的上一时刻的隐藏状态,$x_t$ 是当前输入,$\sigma$ 是sigmoid函数。

 输入门与候选值:LSTM需要决定哪些新的信息需要存储到记忆细胞中。这包括两部分:一部分是输入门,决定我们将更新记忆细胞的哪些部分;另一部分是一个$\tanh$层,创建一个新的候选值向量,可能会被添加到记忆细胞中,输入门的值和候选值都是通过当前的输入$x_t$和上一个时间步的隐藏状态$h_{t-1}$计算得到的。

候选值公式:$$ C'_t = \tanh\left(W_C \cdot \left[h_{t-1}, x_t\right] + b_C\right) $$
输入门公式:$$ i_t = \sigma\left(W_i \cdot \left[h_{t-1}, x_t\right] + b_i\right) $$
注:$W_C, W_i$ 是权重矩阵,$b_C, b_i$ 是偏置项,$h_{t-1}$ 是上一时刻的隐藏状态,$x_t$ 是当前输入,$\sigma$ 是sigmoid函数,$\tanh$ 是双曲正切函数。

更新记忆细胞:将输入门的值与候选值相乘并加上去,表示我们添加了一部分新的状态信息。这里的细胞状态的更新是加法运算,不是单纯的矩阵连乘。这保证了在反向传播时,梯度能够跨越多个时间步回传,有效的缓解了梯度消失问题。
更新细胞状态公式:$$ C_t = f_t \odot C_{t-1} + i_t \odot C'_t $$
其中,$f_t$ 是遗忘门的输出,$C_{t-1}$ 是上一时刻的细胞状态,$i_t$ 是输入门值,$C'_t$ 是候选值。 

输出门与隐藏状态输出:$x_t$和$h_{t-1}$通过$\sigma$层得到输出门$o_t$,长期状态$C_{t-1}$通过tanh层处理,两者相乘得到输出的$h_t$

$$ o_t = \sigma(W_o \cdot [h_{t-1},x_t]+b_o) $$
$$ h_t = o_t \odot \tanh(C_t) $$

LSTM成功处理长序列数据,但由于参数量庞大,在数据量较小的任务上,LSTM 极易发生过拟合,通常需要强力的 Dropout 或正则化手段来约束。如在LSTM结构中,仅对层间输入(如 )进行dropout操作,而循环的隐藏状态(如 )和记忆单元( )不添加dropout。这种策略既保留了LSTM的长期记忆能力,又通过dropout实现了正则化,避免了传统dropout对循环连接造成的噪声累积问题。

  • 算法细节
    • 门控操作:每个门使用sigmoid函数输出[0,1]值,控制信息更新。
    • 关键步骤:计算遗忘门$f_t$、输入门$i_t$、候选状态$C'_t$、输出门$o_t$,更新$C_t$和$h_t$。
    • PyTorch示例:
      class LSTM(nn.Module):
          def __init__(self):
              super(LSTM, self).__init__()
              self.lstm = nn.LSTM(input_size=n_class, hidden_size=n_hidden)
              # fc
              self.fc = nn.Linear(n_hidden, n_class)
      
          def forward(self, X):
              # X: [batch_size, max_len, n_class]
              batch_size = X.shape[0]
              input = X.transpose(0, 1)  # input : [max_len, batch_size, n_class]
      
              hidden_state = torch.randn(1, batch_size, n_hidden)   # [num_layers(=1), batch_size, n_hidden]
              cell_state = torch.randn(1, batch_size, n_hidden)     # [num_layers(=1), batch_size, n_hidden]
      
              outputs, (_, _) = self.lstm(input, (hidden_state, cell_state))
              outputs = outputs[-1]  # [batch_size, n_hidden]
              model = self.fc(outputs)  # model : [batch_size, n_class]
              return model
      
      model = LSTM()
      criterion = nn.CrossEntropyLoss()
      optimizer = optim.Adam(model.parameters(), lr=0.001)
      

4. BiLSTM(双向LSTM):拥有上帝视角

LSTM 虽然解决了长期依赖问题,但它依然是“从左到右”单向传递的。在自然语言处理中,一个词的意思往往不仅取决于它前面的词,还取决于它后面的词。比如:“我喜欢吃苹果”和“我喜欢用苹果手机”。在看到“手机”之前,你很难确定“苹果”的词性。BiLSTM的出现就是为了充分利用上下文信息

设计过程

  • 核心思想:使用两个独立LSTM层,第一层 LSTM 正序读取序列,第二层 LSTM 逆序读取序列,输出拼接合并。从图中可以看出隐藏层之间不再是单向的传递,而是双向传递。
  • 结构:输入序列同时处理前向($t=1$到$T$)和后向($t=T$到$1$),最后融合隐藏状态。
    下面是双层的BiLSTM示意图,比单层的多了堆叠的操作

  • 拿情感分类来举例子,一般用到的都是单层的BiLSTM,我们现在需要分析“我爱中国”这句话的情感。正序的LSTM$_L$依次处理“我”,“爱”,“中国”,得到向量$\{h_{L0}, h_{L1}, h_{L2}\}$,$h_{L2}\}$包含了正序的全部信息。逆序的LSTM$_R$依次处理“中国”,“爱”,“我”,得到向量$\{h_{R0}, h_{R1}, h_{R2}\}$,$h_{R2}\}$包含了逆序的全部信息。一般我们只拼接$h_{L2}\}$和$h_{R2}\}$得到$h_c\}$就可以。

  • 下面这个图也很直观,对“特别喜欢这种好看的狗狗”进行情感分类。第一层是正向的处理,第二层对输入进行逆向的处理,将两个处理后向量进行拼接得到上下文特征向量,再对其进行处理。最终我们得到很大概率这句话是积极的情感。
  • 算法细节
    • 前向LSTM处理正向序列,后向LSTM处理逆向序列。
    • 关键步骤:计算前向$h_t^{\rightarrow}$和后向$h_t^{\leftarrow}$,合并为最终输出。
    • PyTorch示例:
      class BiLSTM(nn.Module):
          def __init__(self):
              super(BiLSTM, self).__init__()
              self.lstm = nn.LSTM(input_size=n_class, hidden_size=n_hidden, bidirectional=True)
              # fc
              self.fc = nn.Linear(n_hidden * 2, n_class)
      
          def forward(self, X):
              # X: [batch_size, max_len, n_class]
              batch_size = X.shape[0]
              input = X.transpose(0, 1)  # input : [max_len, batch_size, n_class]
      
              hidden_state = torch.randn(1*2, batch_size, n_hidden)   # [num_layers(=1) * num_directions(=2), batch_size, n_hidden]
              cell_state = torch.randn(1*2, batch_size, n_hidden)     # [num_layers(=1) * num_directions(=2), batch_size, n_hidden]
      
              outputs, (_, _) = self.lstm(input, (hidden_state, cell_state))
              outputs = outputs[-1]  # [batch_size, n_hidden * 2]
              model = self.fc(outputs)  # model : [batch_size, n_class]
              return model
      
      model = BiLSTM()
      criterion = nn.CrossEntropyLoss()
      optimizer = optim.Adam(model.parameters(), lr=0.001)
      
      

  • 数学表达
    • 前向LSTM隐藏状态:$h_t^{\rightarrow} = \text{LSTM}(x_t, h_{t-1}^{\rightarrow})$
    • 后向LSTM隐藏状态:$h_t^{\leftarrow} = \text{LSTM}(x_t, h_{t+1}^{\leftarrow})$
    • 合并输出:$h_t^{\text{combined}} = [h_t^{\rightarrow}, h_t^{\leftarrow}]$
    • 最终输出:$y_t = W_y h_t^{\text{combined}}$
    • 其中,$\text{LSTM}(\cdot)$表示LSTM单元的完整计算(见第3节)。
5. 个人思考

RNN 和 LSTM 的机制很符合人的直觉,但是是串行结构,很难充分利用现代 GPU 的并行加速能力。LSTM 设计的门控开关机制十分精妙。通过 Sigmoid 函数输出 0 到 1 之间的值,再与其他矩阵相乘,这就相当于在神经网络里加了几个可以自动调节的水龙头,灵活决定信息的去留。BiLSTM 融合了上下文,效果确实比单向的要好很多。但是必须等一段序列全部输入完毕后,才能逆序提取特征。因此只能用于离线任务。总而言之,从 RNN 到 BiLSTM 的演进,本质上就是在解决“如何更好地记住并利用历史信息”这个问题。把这些基本功吃透,在以后面对复杂的时序数据任务时,就能更准确地判断该用什么模型。

6. 结论

总结:RNN为基础,LSTM通过门控解决长期依赖,BiLSTM扩展为双向上下文,三者构成序列建模核心。

应用展望:在Transformer时代,这些算法仍是基础,建议结合实战PyTorch实现深化理解。

本文参考了许多优秀的博主帖子,图片来源于他们,对部分进行了二创,在此向所有原创者致以诚挚的感谢,参考资源链接并未放全,在此致歉。

参考资源(8 封私信 / 49 条消息) 详解BiLSTM及代码实现 - 知乎

一幅图真正理解LSTM、BiLSTM_bilstm和lstm的区别-CSDN博客

LSTM网络详解与公式推导-CSDN博客

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐