Modern Recurrent Neural Networks

经典循环神经网络(Modern Recurrent Neural Networks) #


长短时记忆网络(Long Short-Term Memory, LSTM) #

循环神经网络(RNN)通过反向传播进行训练之后,人们发现了学习长期依赖问题的显著难点,这主要是由于梯度消失和梯度爆炸问题。虽然梯度裁剪(gradient clipping)可以部分缓解梯度爆炸,但处理梯度消失需要更复杂的解决方案。Hochreiter和Schmidhuber于1997年提出的长短时记忆网络(LSTM)是解决梯度消失问题的早期且成功的技术之一。

LSTM与标准的RNN类似,但在LSTM中,每个普通的循环节点被替换为一个记忆单元(memory cell)。记忆单元内部包含一个内部状态(internal state),这是一个具有固定权重为1的自连接回边的节点。这种设计确保了梯度能够在多个时间步内传播,而不会因梯度消失或梯度爆炸而中断。


门控记忆单元(Gated Memory Cell) #

门控记忆单元通过内部状态(internal state)和多个乘性门控机制(multiplicative gates)管理信息流动。具体包括以下三种门控:

  1. 输入门(Input Gate):控制是否允许当前输入影响记忆单元的内部状态。它决定了多少输入值应该加入当前的记忆单元状态
  2. 遗忘门(Forget Gate):决定是否清除部分或全部内部状态。
  3. 输出门(Output Gate):确定内部状态是否可以影响单元的输出

在LSTM中,输入门(Input Gate)、遗忘门(Forget Gate)和输出门(Output Gate)的计算依赖于当前时间步的输入数据前一时间步的隐藏状态,如下图所示。三个全连接层通过sigmoid激活函数计算输入门、遗忘门和输出门的值,因此这三个门的值都被限制在0到1的区间内。此外,我们还需要一个输入节点,通常使用tanh激活函数进行计算。

数学上,假设有 \(h\) 个隐藏单元,批次大小为 \(n\) ,输入的维度为 \(d\) ,则输入为 \(\mathbf{X}_t \in \mathbb{R}^{n \times d}\) ,前一时间步的隐藏状态为 \(\mathbf{H}_{t-1} \in \mathbb{R}^{n \times h}\) 。在此基础上,输入门、遗忘门和输出门的定义如下:输入门为 \(\mathbf{I}_t \in \mathbb{R}^{n \times h}\) ,遗忘门为 \(\mathbf{F}_t \in \mathbb{R}^{n \times h}\) ,输出门为 \(\mathbf{O}_t \in \mathbb{R}^{n \times h}\) 。它们的计算公式为:

\[ \begin{split}\begin{aligned} \mathbf{I}_t &= \sigma(\mathbf{X}_t \mathbf{W}_{xi} + \mathbf{H}_{t-1} \mathbf{W}_{hi} + \mathbf{b}_i),\\ \mathbf{F}_t &= \sigma(\mathbf{X}_t \mathbf{W}_{xf} + \mathbf{H}_{t-1} \mathbf{W}_{hf} + \mathbf{b}_f),\\ \mathbf{O}_t &= \sigma(\mathbf{X}_t \mathbf{W}_{xo} + \mathbf{H}_{t-1} \mathbf{W}_{ho} + \mathbf{b}_o), \end{aligned}\end{split} \]

Note: 尽管公式形式类似,LSTM 中的门和一般 RNN 中的 Hidden state 的核心作用完全不同

  1. 门的作用是“控制流动”
    • 每个门的输出 [0, 1] 表示“通过”信息的比例。
    • 它们主要用于调节信息的流动,而不是直接参与信息存储。
  2. Hidden State 的作用是“存储和传递信息”
    • Hidden state 是序列模型的核心状态,用于传递时间步之间的主要信息。
    • 它不仅受门控机制影响,还通过非线性变换从记忆单元中提取信息。

我们需要设计了一个输入节点(Input node)。输入节点的计算方式类似于前面提到的门控单元(gate),但它使用一个具有特定值范围的激活函数(tanh),函数的值范围为 \((-1, 1)\) 。具体来说,输入节点的计算公式为: \[ \tilde{\mathbf{C}}_t = \textrm{tanh}(\mathbf{X}_t \mathbf{W}_{\textrm{xc}} + \mathbf{H}_{t-1} \mathbf{W}_{\textrm{hc}} + \mathbf{b}_\textrm{c}), \]

Note: 为什么门的公式需要 Sigmoid 激活

  • LSTM 的门本质上是一种控制开关,它的输出必须在 [0, 1] 之间,这样才能直观地表示“通过的信息比例”
  • 而 Hidden state 的激活函数使用的是 tanh,其范围为 [-1, 1],更适合表示信息本身的动态特征

输入门 \(I_t\) (input gate)控制我们在多大程度上考虑新输入数据 \(\tilde{\mathbf{C}}_t\) ,而遗忘门 \(F_{t}\) (forget gate)则决定了我们保留多少旧的记忆单元内部状态 \(\mathbf{C}_{t-1} \in \mathbb{R}^{n \times h}\) 。通过使用Hadamard积 \(\odot\) (逐元素相乘)运算符,LSTM的记忆单元内部状态的更新方程为: \[ \mathbf{C}_t = \mathbf{F}_t \odot \mathbf{C}_{t-1} + \mathbf{I}_t \odot \tilde{\mathbf{C}}_t \]

关于 Hadamard积 \(\odot\) Hadamard 积是逐元素相乘的操作,对于两个相同形状的矩阵或向量 \(\mathbf{a}\) \(\mathbf{b}\) ,定义如下: \(\mathbf{c} = \mathbf{a} \odot \mathbf{b}, \quad c_i = a_i \cdot b_i\) 。这里的 \(\odot\) 表示逐元素相乘,而不是普通的矩阵乘法。

例如: \[ \mathbf{a} = \begin{bmatrix} 1 \\ 2 \\ 3 \end{bmatrix}, \quad \mathbf{b} = \begin{bmatrix} 0.1 \\ 0.5 \\ 0.9 \end{bmatrix}, \quad \mathbf{a} \odot \mathbf{b} = \begin{bmatrix} 1 \cdot 0.1 \\ 2 \cdot 0.5 \\ 3 \cdot 0.9 \end{bmatrix} = \begin{bmatrix} 0.1 \\ 1 \\ 2.7 \end{bmatrix} \]

如果遗忘门 \(\mathbf{F}_t = [1, 0.5, 0]\) ,则:

  • 第一维的旧信息完全保留(*1)。
  • 第二维的旧信息减半(*0.5)。
  • 第三维的旧信息完全丢弃(*0)。

Note: Cell State(Internal State) 是 LSTM 相比经典 RNN 的核心创新之一,用于长期存储信息。它是贯穿整个时间序列的一条“主线”,可以通过加法和遗忘门机制实现对信息的选择性记忆或删除。它负责长时依赖信息的存储。可以直接从一个时间步传播到下一个时间步(通过“直通”机制),不会像hidden state那样受到非线性变换的影响。因为cell state的“直通性”,梯度不会像传统RNN中那样容易消失或爆炸,从而使LSTM能够更好地捕捉长距离依赖关系。

而Hidden state 是LSTM在每个时间步的输出。它是对当前时间步下所有输入信息和记忆信息的非线性处理结果,是一种“短时记忆”。提供即时输出信息,作为当前时间步的表征(representation)。在序列的每个时间步中,hidden state通过非线性变换与cell state交互,提取即时特征。Hidden state通常被用于后续任务(例如,分类或生成)中。

如果遗忘门始终为1且输入门始终为0,则记忆单元的内部状态将保持不变,传递到每个后续时间步。然而,输入门和遗忘门赋予模型灵活性,使其能够学习何时保持值不变,以及何时根据后续输入调整这一值。这一设计有效缓解了梯度消失问题,尤其在处理长序列数据集时,使得模型训练变得更加容易。

最后,隐藏状态 \(I_t\) (Hidden State)定义了记忆单元的输出方式,它由输出门 \(O_t\) (Output Gate)控制。具体计算步骤如下:首先,对记忆单元的内部状态 \(\mathbf{C}_t\) 应用 \(\tanh\) 函数,使其值被规范化到 \((-1, 1)\) 区间内。然后,将这一结果与输出门的值 \(\mathbf{O}_t\) 逐元素相乘,计算得到隐藏状态 \(\mathbf{H}_t\) \[ \mathbf{H}_t = \mathbf{O}_t \odot \tanh(\mathbf{C}_t) \]

Note: 输出门(Output Gate) 的主要作用是控制 当前时刻的隐藏状态的输出内容,从而决定 LSTM 的对外信息传递。Cell State 是 LSTM 的内部长期记忆,但它并不直接对外输出。输出门通过选择性地提取 Cell State 中的信息,并结合门控机制生成 新的Hidden State(短期记忆的表达),作为当前时间步的隐藏状态对外输出。这种机制确保 LSTM 在对外传递信息时,不会将 Cell State 中的所有内容暴露出去,避免噪声干扰,同时保留最相关的信息。

这一机制确保了隐藏状态 \(\mathbf{H}_t\) 的值始终在 \((-1, 1)\) 区间内。

  • 当输出门 \(\mathbf{O}_t\) 的值接近 1 时,记忆单元的内部状态 \(\mathbf{C}_t\) 会直接影响后续网络层;
  • 当输出门 \(\mathbf{O}_t\) 的值接近 0 时,记忆单元当前的状态对其他层没有影响。

这种设计使得记忆单元可以在多个时间步内积累信息,而不会对网络的其他部分造成干扰(只要输出门保持接近 0)。当输出门的值突然从接近 0 变为接近 1 时,记忆单元会迅速对网络的其他部分产生显著影响。这种特性允许 LSTM 高效地处理长时间的依赖关系。

LSTM 和RNN 一样 Hidden state 是对 当前时刻输入和历史信息的总结,而 非直接表示最终的概率输出最终输出需要通过额外的线性变换和可能的激活函数处理,来生成模型的最终预测值或概率分布。


LSTM解决的问题和原因 #

LSTM(Long Short-Term Memory)主要解决了标准RNN在处理长序列时的 梯度消失(vanishing gradients)和梯度爆炸(exploding gradients) 问题。 这些问题导致普通RNN在处理长期依赖(long-term dependencies)时性能较差,无法有效捕获长时间跨度的信息。

  1. 细胞状态(Cell State)作为长期记忆的载体
    • LSTM引入了一个额外的细胞状态 \(\mathbf{C}_t = \mathbf{F}_t \odot \mathbf{C}_{t-1} + \mathbf{I}_t \odot \tilde{\mathbf{C}}_t\) ,它可以通过直通路径(“constant error carousel”)跨时间步传播信息,几乎不受梯度消失或梯度爆炸的影响
    • \(\mathbf{F}_t \odot \mathbf{C}_{t-1}\) 这一项将上一时间步的细胞状态 \(\mathbf{C}_{t-1}\) 直接传递到当前时间步,乘以遗忘门 \(\mathbf{F}_t\) 的值来控制保留的比例。由于 这部分没有激活函数的非线性变换,梯度可以在反向传播中稳定地通过时间步传播
    • \(\mathbf{I}_t \odot \tilde{\mathbf{C}}_t\) 这一项将当前时间步的候选记忆 \(\tilde{\mathbf{C}}_t\) 加入到细胞状态中,比例由输入门 \(\mathbf{I}_t\) 控制。这使得 LSTM 能够灵活地选择哪些新的信息需要加入长期记忆
  2. 梯度传播更稳定
    • 普通RNN的梯度通过时间步传播时,会被反复乘以隐状态的权重矩阵 \(\mathbf{W}\) 。当权重矩阵的特征值远离 1 时,梯度会出现指数增长(梯度爆炸)或指数衰减(梯度消失)。
    • 在LSTM中,细胞状态通过线性加权方式更新(不直接经过激活函数的非线性变换),从而避免了梯度的剧烈变化。门机制确保了信息流动的可控性,进一步减轻了梯度不稳定的问题。
  3. 更强的记忆能力
    • LSTM能同时捕获短期依赖(通过隐藏状态 \(\mathbf{H}_t\) )和长期依赖(通过细胞状态 \(\mathbf{C}_t\)
    • 在长时间序列中,它可以动态调整对不同时间跨度的信息的关注程度,使其既能够记住长期信息,又不会因记忆过多导致模型过载。
  • LSTM 代码实现
import torch
import torch.nn as nn

class LSTMScratch(nn.Module):
    def __init__(self, num_inputs, num_hiddens, sigma=0.01):
        super(LSTMScratch, self).__init__()
        self.num_hiddens = num_hiddens

        # 定义初始化函数
        def init_weight(*shape):
            return nn.Parameter(torch.randn(*shape) * sigma)

        # 定义门所需的权重和偏置初始化
        def triple():
            return (init_weight(num_inputs, num_hiddens),  # 输入到隐藏层
                    init_weight(num_hiddens, num_hiddens), # 隐藏到隐藏层
                    nn.Parameter(torch.zeros(num_hiddens))) # 偏置

        # 初始化各门的权重和偏置
        self.W_xi, self.W_hi, self.b_i = triple()  # 输入门
        self.W_xf, self.W_hf, self.b_f = triple()  # 遗忘门
        self.W_xo, self.W_ho, self.b_o = triple()  # 输出门
        self.W_xc, self.W_hc, self.b_c = triple()  # 候选细胞状态

    def forward(self, inputs, H_C=None):
        """
        前向传播
        inputs: [seq_length, batch_size, num_inputs]
        H_C: 一个元组 (H, C),分别为初始化的隐藏状态和细胞状态
        """
        seq_length, batch_size, _ = inputs.shape
        if H_C is None:
            H = torch.zeros((batch_size, self.num_hiddens), device=inputs.device)
            C = torch.zeros((batch_size, self.num_hiddens), device=inputs.device)
        else:
            H, C = H_C

        outputs = []
        for t in range(seq_length):
            X_t = inputs[t]  # 当前时间步输入: [batch_size, num_inputs]
            # 输入门
            I = torch.sigmoid(torch.matmul(X_t, self.W_xi) +
                              torch.matmul(H, self.W_hi) + self.b_i)
            # 遗忘门
            F = torch.sigmoid(torch.matmul(X_t, self.W_xf) +
                              torch.matmul(H, self.W_hf) + self.b_f)
            # 输出门
            O = torch.sigmoid(torch.matmul(X_t, self.W_xo) +
                              torch.matmul(H, self.W_ho) + self.b_o)
            # 候选细胞状态
            C_tilde = torch.tanh(torch.matmul(X_t, self.W_xc) +
                                 torch.matmul(H, self.W_hc) + self.b_c)
            # 更新细胞状态
            C = F * C + I * C_tilde
            # 更新隐藏状态
            H = O * torch.tanh(C)
            outputs.append(H)
        # 将所有时间步的隐藏状态拼接成张量
        outputs = torch.stack(outputs, dim=0)  # [seq_length, batch_size, num_hiddens]
        return outputs, (H, C)

门控循环单元(Gated Recurrent Units, GRU) #

GRU(门控循环单元)是LSTM记忆单元的简化版本并保留内部状态和乘法门控机制(multiplicative gating mechanisms)的核心思想。相比LSTM,GRU在很多任务中可以达到相似的性能,但由于结构更加简单,计算速度更快。

在GRU(Gated Recurrent Unit)中,LSTM的三个门被替换为两个门:重置门(Reset Gate)更新门(Update Gate)。这两个门使用了Sigmoid激活函数,输出值限制在区间 [0, 1] 内。

  • 重置门:决定了当前状态需要记住多少之前隐藏状态的信息。
  • 更新门:控制新状态有多少是继承自旧状态的。

在GRU中,给定当前时间步的输入 \(\mathbf{X}_t\) 和上一时间步的隐藏状态 \(\mathbf{H}_{t-1}\) ,重置门和更新门通过两个全连接层计算,激活函数为 Sigmoid。

对于给定的时间步 \(t\) ,假设输入是一个小批量 \(\mathbf{X}_t \in \mathbb{R}^{n \times d}\) (样本个数 \(n\) ,输入个数 \(d\) ),上一个时间步的隐状态是 \(\mathbf{H}_{t-1} \in \mathbb{R}^{n \times h}\) (隐藏单元个数 \(t\) )。那么,重置门 \(\mathbf{R}_t \in \mathbb{R}^{n \times h}\) 和更新门 \(\mathbf{Z}_t \in \mathbb{R}^{n \times h}\) 的计算如下所示: \[ \begin{split}\begin{aligned} \mathbf{R}_t = \sigma(\mathbf{X}_t \mathbf{W}_{xr} + \mathbf{H}_{t-1} \mathbf{W}_{hr} + \mathbf{b}_r),\\ \mathbf{Z}_t = \sigma(\mathbf{X}_t \mathbf{W}_{xz} + \mathbf{H}_{t-1} \mathbf{W}_{hz} + \mathbf{b}_z), \end{aligned}\end{split} \]

重置门(reset gate) \(\mathbf{R}_t\) 与标准更新机制相结合,生成时间步 \(t\) 候选隐藏状态(candidate hidden state) \(\tilde{\mathbf{H}}_t \in \mathbb{R}^{n \times h}\) ,公式如下: \[ \tilde{\mathbf{H}}_t = \tanh(\mathbf{X}_t \mathbf{W}_{xh} + \left(\mathbf{R}_t \odot \mathbf{H}_{t-1}\right) \mathbf{W}_{hh} + \mathbf{b}_h), \]

重置门 \(\mathbf{R}t\) 控制前一时间步隐藏状态 \(\mathbf{H}_{t-1}\) 对候选隐藏状态的影响:

  • \(\mathbf{R}_t\) 的某些元素接近 1,公式退化为标准RNN。
  • \(\mathbf{R}_t\) 的某些元素接近 0,前一时间步隐藏状态被忽略,候选隐藏状态仅依赖于当前输入 \(\mathbf{x}_t\)

候选隐藏状态中通过 \(\left(\mathbf{R}_t \odot \mathbf{H}_{t-1}\right) \mathbf{W}_{hh}\) 限制了 \(\mathbf{H}_{t-1}\) 的影响,增强了模型的灵活性,使其能够在必要时“重置”某些隐藏状态。

Note: 重置门主要是对长期记忆进行筛选,在计算候选隐藏状态时,抑制过去隐藏状态中的某些部分,使得模型更多地依赖当前输入。这种机制让模型能够专注于短期依赖,通过结合当前输入产生一个更符合短期记忆的候选状态

更新门(update gate, \(\mathbf{Z}_t\) )决定了新隐藏状态 \(\mathbf{H}_t \in \mathbb{R}^{n \times h}\) 在多大程度上保留旧状态 \(\mathbf{H}_{t-1}\) 与新候选状态 \(\tilde{\mathbf{H}}_t\) 的信息。具体而言, \(\mathbf{Z}_t\) 控制了二者的加权组合,公式如下: \[ \mathbf{H}_t = \mathbf{Z}_t \odot \mathbf{H}_{t-1} + (1 - \mathbf{Z}_t) \odot \tilde{\mathbf{H}}_t. \]

  • \(\mathbf{Z}_t\) 接近 1 时,旧状态 \(\mathbf{H}_{t-1}\) 被主要保留,忽略了新候选状态 \(\tilde{\mathbf{H}}_t\) ,从而在依赖链中跳过了当前时间步 \(t\)
  • \(\mathbf{Z}_t\) 接近 0 时,隐藏状态 \(\mathbf{H}_t\) 接近于新候选状态 \(\tilde{\mathbf{H}}_t\) ,将当前时间步的信息更强地融入到模型中。

这些设计可以帮助我们处理循环神经网络中的梯度消失问题,并更好地捕获时间步距离很长的序列的依赖关系。例如,如果整个子序列的所有时间步的更新门都接近于 1,则无论序列的长度如何,在序列起始时间步的旧隐状态都将很容易保留并传递到序列结束。

Note: 更新门通过在过去隐藏状态(长期记忆)和候选隐藏状态(短期记忆)之间进行加权平均,决定当前隐藏状态的更新方式。当更新门更倾向于长期记忆时,模型保留更多的历史信息;当更倾向于短期记忆时,模型更关注当前输入。这样,更新门实现了长期和短期记忆的动态平衡

总结来说,门控循环单元具有以下两个显著特征:

  1. 重置门(reset gate)帮助捕捉序列中的 短期依赖关系
  2. 更新门(update gate)帮助捕捉序列中的 长期依赖关系

Note: 重置门(reset gate) 通过控制当前时刻的隐藏状态与之前隐藏状态的结合程度,帮助捕捉短期依赖关系。当重置门的值接近0时,模型几乎完全忽略过去的信息,只依赖当前输入来计算候选隐藏状态,这使得模型能够专注于当前时刻的短期信息,从而适应短期依赖。

更新门(update gate) 则决定当前时刻的隐藏状态是由之前的隐藏状态(长期记忆)和当前输入(短期信息)以何种比例组合而成。更新门的值接近1时,模型保留大部分的长期记忆,接近0时则依赖更多的当前输入。这种机制帮助GRU捕捉长期依赖关系,因为更新门可以调节模型在序列中如何传递和利用过去的记忆。

  • GRU 代码实现
import torch
import torch.nn as nn

class GRUScratch(nn.Module):
    def __init__(self, num_inputs, num_hiddens, sigma=0.01):
        super(GRUScratch, self).__init__()
        self.num_hiddens = num_hiddens

        # 定义初始化函数
        def init_weight(*shape):
            return nn.Parameter(torch.randn(*shape) * sigma)

        # 定义权重和偏置初始化
        def double():
            return (init_weight(num_inputs, num_hiddens),  # 输入到隐藏层
                    init_weight(num_hiddens, num_hiddens), # 隐藏到隐藏层
                    nn.Parameter(torch.zeros(num_hiddens))) # 偏置

        # 更新门权重和偏置
        self.W_xz, self.W_hz, self.b_z = double()  # 更新门
        # 重置门权重和偏置
        self.W_xr, self.W_hr, self.b_r = double()  # 重置门
        # 候选隐藏状态权重和偏置
        self.W_xh, self.W_hh, self.b_h = double()  # 候选隐藏状态

    def forward(self, inputs, H=None):
        """
        前向传播
        inputs: [seq_length, batch_size, num_inputs]
        H: 初始化的隐藏状态 [batch_size, num_hiddens]
        """
        seq_length, batch_size, _ = inputs.shape
        if H is None:
            H = torch.zeros((batch_size, self.num_hiddens), device=inputs.device)

        outputs = []
        for t in range(seq_length):
            X_t = inputs[t]  # 当前时间步输入: [batch_size, num_inputs]

            # 更新门
            Z = torch.sigmoid(torch.matmul(X_t, self.W_xz) +
                              torch.matmul(H, self.W_hz) + self.b_z)
            # 重置门
            R = torch.sigmoid(torch.matmul(X_t, self.W_xr) +
                              torch.matmul(H, self.W_hr) + self.b_r)
            # 候选隐藏状态
            H_tilde = torch.tanh(torch.matmul(X_t, self.W_xh) +
                                 torch.matmul(R * H, self.W_hh) + self.b_h)
            # 新的隐藏状态
            H = Z * H + (1 - Z) * H_tilde
            outputs.append(H)

        # 将所有时间步的隐藏状态拼接成张量
        outputs = torch.stack(outputs, dim=0)  # [seq_length, batch_size, num_hiddens]
        return outputs, H

深层循环神经网络(Deep Recurrent Neural Networks, DRNN) #

深层循环神经网络(Deep Recurrent Neural Networks, DRNN)是通过堆叠多个RNN层实现的。单隐藏层的RNN网络结构,由一个序列输入、一层隐藏层(hidden layer),以及一个输出层组成。尽管这样的网络在时间方向上只有一层隐藏层,但输入在初始时间步的影响可以通过隐藏层在时间上的递归传播。

然而,这种单层结构在捕捉时间步内部输入与输出之间复杂关系时存在局限。因此,为了同时增强 模型对时间依赖(temporal dependency)时间步内部输入与输出关系 的建模能力,常会构造在时间方向和输入输出方向上都更深的RNN网络。这种深度的概念类似于在多层感知机(MLP)和深度卷积神经网络(CNN)中见到的层级加深方法。

在深层RNN中,每一时间步的隐藏单元 不仅依赖于同层前一个时间步的隐藏状态,还依赖于 前一层相同时间步的隐藏状态。这种结构使得深层RNN能够 同时捕获长期依赖关系(long-term dependency)和复杂的输入-输出关系

假设在时间步 \(t\) ,我们有一个小批量输入 \(\mathbf{X}_t \in \mathbb{R}^{n \times d}\) (样本数: \(n\) ,每个样本中的输入数: \(d\) ) 同时,将 \(l^\mathrm{th}\) 隐藏层( \(l=1,\ldots,L\) )的隐状态设为 \(\mathbf{H}_t^{(l)} \in \mathbb{R}^{n \times h}\) (隐藏单元数: \(h\) ), 输出层变量设为 \(\mathbf{O}_t \in \mathbb{R}^{n \times q}\) (输出数: \(q\) )。 设置 \(\mathbf{H}_t^{(0)} = \mathbf{X}_t\) , 第 \(l\) 个隐藏层的隐状态使用激活函数 \(\phi_l\) ,则: \[ \mathbf{H}_t^{(l)} = \phi_l(\mathbf{H}_t^{(l-1)} \mathbf{W}_{xh}^{(l)} + \mathbf{H}_{t-1}^{(l)} \mathbf{W}_{hh}^{(l)} + \mathbf{b}_h^{(l)}), \] 其中,权重 \(\mathbf{W}_{xh}^{(l)} \in \mathbb{R}^{h \times h}\) \(\mathbf{W}_{hh}^{(l)} \in \mathbb{R}^{h \times h}\) 和偏置 \(\mathbf{b}_h^{(l)} \in \mathbb{R}^{1 \times h}\) 都是第 \(l\) 个隐藏层的模型参数。

最后,输出层的计算仅基于第 \(l\) 个隐藏层最终的隐状态: \[ \mathbf{O}_t = \mathbf{H}_t^{(L)} \mathbf{W}_{hq} + \mathbf{b}_q, \]

其中,权重 \(\mathbf{W}_{hq} \in \mathbb{R}^{h \times q}\) 和偏置 \(\mathbf{b}_q \in \mathbb{R}^{1 \times q}\) 都是输出层的模型参数。


双向循环神经网络(Bidirectional Recurrent Neural Networks) #

我们之前使用的例子主要关于是语言建模(language modeling),目标是 根据序列中所有前面的token(单词或符号)预测下一个token。在这种情况下,我们只需要依赖左侧的上下文信息,因此使用 单向RNN(unidirectional RNN) 是合理的。然而,在某些序列学习任务中,预测每个时间步的结果时可以同时利用左侧和右侧的上下文信息。例如,词性标注(part of speech detection) 就是一个典型任务(i.e. 为句子中的每个单词分配其语法类别(词性)。词性反映了单词在句子中的语法功能和作用,例如名词、动词、形容词等。),在判断一个单词的词性时,考虑其两侧的上下文会更加准确。

Note: 另一个常见任务是 文本中随机遮盖部分token(masking tokens),并训练模型预测这些缺失的token。这种任务通常被用于预训练模型(pretraining),之后再进行特定任务的微调(fine-tuning)。例如,根据句子中缺失位置的上下文,不同的填充值可能有显著变化:

  • “I am ___.” (可能是 “happy”)
  • “I am ___ hungry.” (可能是 “not” 或 “very”)
  • “I am ___ hungry, and I can eat half a pig.” (“not” 在上下文中显然不合适)

双向RNN(Bidirectional RNN) 是一种简单但有效的方法,它将单向RNN扩展为同时考虑两个方向的上下文信息。具体实现方式如下:

  1. 在同一个输入序列上,构建两个单向RNN层
  • 第一个RNN层从左到右(forward direction)处理输入序列,第一个输入为 \(X_1\) ,最后一个输入为 \(X_T\)
  • 第二个RNN层从右到左(backward direction)处理输入序列,第一个输入为 \(X_T\) ,最后一个输入为 \(X_1\)
  1. 双向RNN层的输出是两个单向RNN层在每个时间步的输出拼接(concatenate)

对于任意时间步 \(t\) ,给定一个小批量的输入数据 \(\mathbf{X}_t \in \mathbb{R}^{n \times d}\) (样本数 \(n\) ,每个示例中的输入数 \(d\) ), 并且令隐藏层激活函数为 \(\phi\) 。在双向架构中,我们设该时间步的前向和反向隐状态分别为 \(\overrightarrow{\mathbf{H}}_t \in \mathbb{R}^{n \times h}\) \(\overleftarrow{\mathbf{H}}_t \in \mathbb{R}^{n \times h}\) , 其中 \(h\) 是隐藏单元的数目。前向和反向隐状态的更新如下: \[ \begin{split}\begin{aligned} \overrightarrow{\mathbf{H}}_t &= \phi(\mathbf{X}_t \mathbf{W}_{xh}^{(f)} + \overrightarrow{\mathbf{H}}_{t-1} \mathbf{W}_{hh}^{(f)} + \mathbf{b}_h^{(f)}),\\ \overleftarrow{\mathbf{H}}_t &= \phi(\mathbf{X}_t \mathbf{W}_{xh}^{(b)} + \overleftarrow{\mathbf{H}}_{t+1} \mathbf{W}_{hh}^{(b)} + \mathbf{b}_h^{(b)}), \end{aligned}\end{split} \]

其中,权重 \(\mathbf{W}_{xh}^{(f)} \in \mathbb{R}^{d \times h}, \mathbf{W}_{hh}^{(f)} \in \mathbb{R}^{h \times h}, \mathbf{W}_{xh}^{(b)} \in \mathbb{R}^{d \times h}, \mathbf{W}_{hh}^{(b)} \in \mathbb{R}^{h \times h}\) 和偏置 \(\mathbf{b}_h^{(f)} \in \mathbb{R}^{1 \times h}, \mathbf{b}_h^{(b)} \in \mathbb{R}^{1 \times h}\) 都是模型参数。

接下来,将前向隐状态 \(\overrightarrow{\mathbf{H}}_t\) 和反向隐状态 \(\overleftarrow{\mathbf{H}}_t\) 连接起来,获得需要送入输出层的隐状态 \(\mathbf{H}_t \in \mathbb{R}^{n \times 2h}\) 。在具有多个隐藏层的深度双向循环神经网络中,该信息作为输入传递到下一个双向层。 \[ \mathbf{H}_t = \begin{bmatrix} \overrightarrow{\mathbf{H}}_t \\ \overleftarrow{\mathbf{H}}_t \end{bmatrix} \] 最后,输出层计算得到的输出为 \(\mathbf{O}_t \in \mathbb{R}^{n \times q}\) \(q\) 是输出单元的数目): \[ \mathbf{O}_t = \mathbf{H}_t \mathbf{W}_{hq} + \mathbf{b}_q. \]


编码器-解码器(Encoder-Decoder)架构 #

在序列到序列(sequence-to-sequence)问题中(如机器翻译),输入和输出通常具有不同的长度,且无法直接对齐。为了解决这一问题,通常采用编码器-解码器(encoder-decoder)架构。这个架构包括两个主要组件:

  1. 编码器(Encoder):接收一个变长的输入序列,并将其编码成一个固定长度的状态向量(state)
  2. 解码器(Decoder):作为一个条件语言模型(conditional language model),根据编码器生成的状态向量以及目标序列的左侧上下文,逐步预测目标序列中的下一个标记(token)

Note: 固定形状是指该向量的维度是预先设定的,不依赖于输入序列的长度。例如,假设我们设定上下文变量的维度为 d,那么无论输入序列包含 5 个、50 个还是 500 个词,最终生成的上下文变量都会是一个 d-维向量。

例如,在将英语翻译成法语的任务中,假设输入序列为:“They”, “are”, “watching”, “.”,编码器会将这个变长的输入序列编码为一个状态向量。随后,解码器利用该状态向量逐步生成翻译后的序列:“Ils”, “regardent”, “.”。

Note: Encoder 的 核心目的 是找到一个能够浓缩输入序列中长期记忆和短期记忆的隐藏状态(hidden state),并将其作为 Decoder 的输入,从而让 Decoder 能够基于这些信息生成合理的输出。

Note: RNN与 Encoder-Decoder 架构的区别

  1. 上下文向量(Context Vector)
    • 在标准的 Encoder-Decoder 架构中,编码器生成一个全局的上下文向量 c,该向量是解码器生成输出的主要依据。
    • 在 RNN 中,每个时间步的隐藏状态,既充当上下文向量的角色,也直接作为解码的输入。
  2. 输入输出的对齐
    • RNN 假设输入和输出的长度一致,并且在时间维度上严格对齐(例如时间序列预测、语言模型)。
    • Encoder-Decoder 架构专为解决输入与输出长度不对齐的问题设计(例如机器翻译)。
  3. 信息流方向
    • 在 RNN 中,信息流是逐时间步递归的,依赖于当前时刻的隐藏状态。
    • 在 Encoder-Decoder 中,编码器先完成输入序列的处理,生成上下文向量,解码器再从上下文向量开始生成输出

序列到序列学习(seq2seq) #

在序列到序列(sequence-to-sequence)问题中,例如机器翻译(machine translation),输入和输出都是变长的、未对齐的序列。在这种情况下,我们通常依赖编码器-解码器(encoder-decoder)架构来处理这些任务。

特定的<eos>表示序列结束词元。 一旦输出序列生成此词元,模型就会停止预测。在设计中,通常有两个特别的设计决策:首先,每个输入序列开始时都会有一个特殊的序列开始标记(<bos>),它是解码器的输入序列的第一个词元;其次,使用循环神经网络编码器最终的隐状态来初始化解码器的隐状态。


编码器(Encoder) 部分 #

Encoder的主要作用是将一个 长度可变的输入序列 转换为 固定形状的上下文变量(context variable)。假设输入序列为 \(\{x_1, x_2, \dots, x_T\}\) ,其中 \(x_t\) 是第 \(t\) 个时间步的输入标记(token)。在时间步 \(t\) ,RNN 根据输入特征向量 \(x_t\) 和前一个时间步的隐藏状态 \(h_{t-1}\) 来计算当前的隐藏状态 \(h_t\) 。这一过程可表示为: \[ \mathbf{h}_t = f(\mathbf{x}_t, \mathbf{h}_{t-1}). \]

其中 \(f\) 表示 RNN 的递归计算函数(例如 GRU 或 LSTM 的单元函数)。

Note: Encoder 的设计目的

  1. 压缩输入信息:将输入序列的所有信息压缩到一个低维表示中,确保模型能够以固定大小的特征表示处理任意长度的输入。
  2. 捕捉序列的全局语义: Encoder 会通过递归网络(如 RNN、GRU 或 LSTM)处理输入序列,将序列中的时序依赖关系和语义信息编码到隐藏状态中。
  3. 作为中间表示: Encoder 的输出(隐藏状态或上下文变量)提供了一种抽象的、高效的输入表示,适合传递给其他模块(如 Decoder)或用于分类、翻译等下游任务。

Encoder 会利用自定义的函数 \(g\) 将所有时间步的隐藏状态 \(\{h_1, h_2, \dots, h_T\}\) 转换为一个固定形状的上下文变量 \(c\) \[ \mathbf{c} = q(\mathbf{h}_1, \ldots, \mathbf{h}_T). \]

在某些情况下,上下文变量 \(c\) 可以直接选取为最后一个时间步的隐藏状态 \(h_T\) ,即: \(c = h_T\)

在实现 Encoder 时,常使用 嵌入层(Embedding Layer) 来将每个输入标记转换为对应的特征向量:

  • 嵌入层的权重是一个矩阵,行数等于词汇表大小 \(vocab\_size\) ,列数等于嵌入向量维度 \(embed\_size\)

  • 对于输入标记的索引 \(i\) ,嵌入层返回权重矩阵的第 \(i\) 行,作为该标记的特征向量。

  • Encoder 示例代码实现

    import torch
    import torch.nn as nn
    
    class Seq2SeqEncoder(nn.Module):
        """用于序列到序列学习的循环神经网络编码器"""
        def __init__(self, vocab_size, embed_size, num_hiddens, num_layers, 
                    dropout=0.1, bidirectional=True):
            super().__init__()
            # 嵌入层
            self.embedding = nn.Embedding(vocab_size, embed_size)
            # GRU 层
            self.rnn = nn.GRU(embed_size, num_hiddens, num_layers, 
                            dropout=dropout, bidirectional=bidirectional)
    
            # 双向 GRU 会将隐藏状态的维度翻倍
            self.bidirectional = bidirectional
            self.num_hiddens = num_hiddens
            self.num_layers = num_layers
    
        def forward(self, X):
            """
            参数:
            - X: 输入序列,形状为 (batch_size, num_steps)
    
            返回:
            - output: 所有时间步的隐藏状态,形状为 (num_steps, batch_size, num_hiddens * (2 if bidirectional else 1))
            - state: 最后一层每个方向的隐藏状态,形状为 (num_layers * (2 if bidirectional else 1), batch_size, num_hiddens)
            """
            # 嵌入层输出形状为 (batch_size, num_steps, embed_size)
            X = self.embedding(X)
            # 调整输入形状为 (num_steps, batch_size, embed_size)
            X = X.permute(1, 0, 2)
            # output 的形状为 (num_steps, batch_size, num_hiddens * num_directions)
            # state 的形状为 (num_layers * num_directions, batch_size, num_hiddens)
            output, state = self.rnn(X)
            return output, state
    

解码器(Decoder) 部分 #

在序列到序列(Seq2Seq)模型中,解码器(decoder)负责根据目标输出序列 \(y_1, y_2, \dots, y_T\) ,在每个时间步 \(t\) 预测下一步的输出 \(y_t\) 。解码器的核心是基于目标序列中前一时间步的输出 \(y_{t-1}\) 、前一时间步的隐藏状态 \(\mathbf{s}_{t-1}\) 和上下文变量 \(\mathbf{c}\) 来计算当前时间步的隐藏状态 \(\mathbf{s}_t\) 。公式如下: \[ \mathbf{s}_{t} = g(y_{t}, \mathbf{c}, \mathbf{s}_{t}). \] 在得到当前时间步的隐藏状态 \(\mathbf{s}_t\) 后,通过输出层和 softmax 操作计算下一步的输出 \(y_t\) 的概率分布 \(P(y_{t} \mid y_1, \ldots, y_{t}, \mathbf{c})\)

当实现解码器时, 我们直接使用编码器最后一个时间步的隐状态来初始化解码器的隐状态。 这就要求使用循环神经网络实现的编码器和解码器具有相同数量的层和隐藏单元。 为了进一步包含经过编码的输入序列的信息, 上下文变量在所有的时间步与解码器的输入进行拼接(concatenate)。 为了预测输出词元的概率分布, 在循环神经网络解码器的最后一层使用全连接层来变换隐状态。

  • Decoder 示例代码实现
    import torch
    from torch import nn
    
    class Seq2SeqDecoder(nn.Module):
        """用于序列到序列学习的循环神经网络解码器"""
        def __init__(self, vocab_size, embed_size, num_hiddens, num_layers, dropout=0):
            super().__init__()
            self.embedding = nn.Embedding(vocab_size, embed_size)
            self.rnn = nn.GRU(embed_size + num_hiddens, num_hiddens, num_layers, dropout=dropout)
            self.dense = nn.Linear(num_hiddens, vocab_size)
    
        def init_state(self, enc_outputs, *args):
            # 初始化解码器的隐状态
            return enc_outputs[1]  # 通常是编码器的最后一层隐状态
    
        def forward(self, X, state):
            # 'X' 的形状: (batch_size, num_steps)
            X = self.embedding(X).permute(1, 0, 2)  # 转换为 (num_steps, batch_size, embed_size)
            # 获取编码器传来的 context,重复以匹配时间步长
            context = state[-1].repeat(X.shape[0], 1, 1)  # (num_layers, batch_size, num_hiddens)
            # 将嵌入向量与 context 拼接
            X_and_context = torch.cat((X, context), dim=2)
            output, state = self.rnn(X_and_context, state)  # 使用 GRU 处理序列
            output = self.dense(output).permute(1, 0, 2)  # 转换为 (batch_size, num_steps, vocab_size)
            return output, state
    

强制教学(teacher forcing) #

在序列到序列模型中,编码器 (encoder) 的运行相对直接,但解码器 (decoder) 的输入和输出处理需要更加谨慎。最常见的方法是 强制教学(teacher forcing)。在这种方法中,解码器的 输入使用的是目标序列 (target sequence) 的原始标签。具体来说,解码器的输入由特殊的起始标记 <bos> 和目标序列(去掉最后一个标记)拼接而成,而解码器的输出(用于训练的标签)是原始目标序列 向右偏移一个标记。例如:

  • 输入: <bos>, “Ils”, “regardent”, “.”
  • 输出: “Ils”, “regardent”, “.”, <eos>

这种设计确保解码器的每一步输入可以准确地参考目标序列,从而加速训练并提高初期学习效果。


损失函数 #

在序列到序列(sequence-to-sequence)任务中,每个时间步的解码器会为输出标记预测一个概率分布。通过 softmax 可以得到该分布,并使用交叉熵损失(cross-entropy loss)进行优化。为了高效处理长度不同的序列,在小批量中会对齐形状,在序列末尾 填充特殊的填充标记(padding tokens)。然而,这些填充标记不应参与损失的计算。

为了解决这个问题,可以使用 掩码(masking)技术,将无关的填充部分设为零,使得这些无关部分在与预测结果相乘时结果仍为零,从而避免其对损失计算的影响。

假设预测分布为 \(\mathbf{P}\) ,目标分布为 \(\mathbf{T}\) ,掩码为 \(\mathbf{M}\) ,交叉熵损失的计算可以表示为: \[ \text{Loss} = - \sum_{i} \mathbf{M}_i \cdot \mathbf{T}_i \cdot \log(\mathbf{P}_i) \] 其中 \(\mathbf{M}_i\) 对应填充部分为零,非填充部分为一。

e.g. 目标序列(Target Sequences):

  1. [“I”, “am”, “happy”, <PAD>, <PAD>]
  2. [“You”, “are”, “amazing”, “too”, <PAD>]
  3. [“We”, “are”, “here”, “to”, “learn”]

–> M = [[1, 1, 1, 0, 0], [1, 1, 1, 1, 0], [1, 1, 1, 1, 1]]

  • seq2seq 训练示例代码实现
    import torch
    from torch import nn, optim
    from torch.utils.data import DataLoader, Dataset
    import torch.nn.functional as F
    
    # 定义模型
    class Seq2Seq(nn.Module):
        """序列到序列模型,集成编码器和解码器"""
        def __init__(self, encoder, decoder):
            super().__init__()
            self.encoder = encoder
            self.decoder = decoder
    
        def forward(self, src, tgt, src_valid_len):
            # 编码器前向传播
            enc_outputs = self.encoder(src)
            enc_state = self.decoder.init_state(enc_outputs)
            # 解码器前向传播
            dec_outputs, _ = self.decoder(tgt, enc_state)
            return dec_outputs
    
    # 训练过程
    def train_seq2seq(model, data_iter, loss_fn, optimizer, num_epochs, tgt_vocab, device):
        """训练序列到序列模型"""
        model.to(device)
        for epoch in range(num_epochs):
            model.train()
            total_loss = 0
            for src, tgt in data_iter:
                src, tgt = src.to(device), tgt.to(device)
                tgt_input = tgt[:, :-1]
                tgt_output = tgt[:, 1:]
                valid_len = (tgt_output != tgt_vocab["<pad>"]).sum(dim=1)
    
                optimizer.zero_grad()
                pred = model(src, tgt_input, valid_len)
                loss = loss_fn(pred, tgt_output, valid_len)
                loss.mean().backward()
                optimizer.step()
                total_loss += loss.sum().item()
    
            print(f"Epoch {epoch + 1}, Loss: {total_loss / len(data_iter.dataset):.4f}")
    

预测部分 #

在序列预测任务中,解码器会在每个时间步中将前一时间步的预测结果作为输入。具体来说,在每一步中,解码器会选择 预测概率最高的标记(token)作为当前时间步的输出。这一策略被称为 greedy decoding(贪婪解码)。

在预测开始时,初始输入是序列的开始标记(<bos>)。当解码器输出序列的结束标记(<eos>)时,预测过程结束。

  • 预测示例代码实现
# 简化版的预测函数
def predict_seq2seq(net, src_sentence, src_vocab, tgt_vocab, num_steps, device):
    net.eval()
    
    # 处理源句子并转为索引
    src_tokens = src_vocab[src_sentence.lower().split(' ')] + [src_vocab['<eos>']]
    src_tokens = torch.tensor(src_tokens, dtype=torch.long, device=device).unsqueeze(0)  # (1, num_steps)

    # 编码器的输出
    enc_outputs = net.encoder(src_tokens)
    dec_state = net.decoder.init_state(enc_outputs)

    # 解码器初始输入是目标语言的 <bos> 标记
    dec_X = torch.tensor([tgt_vocab['<bos>']], dtype=torch.long, device=device).unsqueeze(0)  # (1, 1)
    
    output_seq = []
    for _ in range(num_steps):
        Y, dec_state = net.decoder(dec_X, dec_state)
        dec_X = Y.argmax(dim=2)  # 选择最大概率的词元作为下一个输入
        pred = dec_X.squeeze(dim=0).item()  # 取出预测的词元
        if pred == tgt_vocab['<eos>']:  # 遇到 <eos> 时停止
            break
        output_seq.append(pred)
    
    # 将预测的词元索引转换回词语
    return ' '.join(tgt_vocab.to_tokens(output_seq))

预测序列的评估 #

我们可以通过与真实的标签序列进行比较来评估预测序列。虽然 (Papineni et al., 2002) 提出的 BLEU(bilingual evaluation understudy) 最先是用于评估机器翻译的结果,但现在它已经被广泛用于测量许多应用的输出序列的质量。 原则上说,对于预测序列中的任意n元语法(n-grams), BLEU的评估都是这个n元语法是否出现在标签序列中。我们将BLEU定义为: \[ \exp\left(\min\left(0, 1 - \frac{\mathrm{len}_{\text{label}}}{\mathrm{len}_{\text{pred}}}\right)\right) \prod_{n=1}^k p_n^{1/2^n},\]

  • 惩罚因子(BP, brevity penalty) \( \exp\left(\min\left(0, 1 - \frac{\mathrm{len}{\text{label}}}{\mathrm{len}{\text{pred}}}\right)\right) \)
    • \(\mathrm{len}{\text{label}}\) :目标序列(ground truth)的长度。
    • \(\mathrm{len}{\text{pred}}\) :预测序列的长度。
    • \(\mathrm{len}{\text{pred}} \geq \mathrm{len}{\text{label}}\) ,此时, \(\text{BP} = \exp(0) = 1\) ,说明预测序列长度足够,不会被惩罚。
    • \(\mathrm{len}{\text{pred}} < \mathrm{len}{\text{label}},\text{BP}\) 会小于 1,表示预测序列过短,从而受到惩罚。
    • 作用:防止模型为了提升 n-gram 精确度而倾向于生成不完整的短序列。
  • 精确度分数(n-gram precision) \(\prod_{n=1}^k p_n^{w_n}\)
    • \(p_n\) : n-gram 的精确度,即预测序列中与目标序列匹配的 n-gram 个数占预测序列中所有 n-gram 的比例。
    • \(w_n\) :权重,通常是 \(\frac{1}{k}\) ,或者特定任务中递减的权重(如 \(w_n = \frac{1}{2^n}\) )。

e.g. 目标序列:the cat is on the mat,预测序列:the cat the mat

  • 1-gram 匹配:the, cat, mat(3 个匹配),总共 4 个 1-gram,故 p_1 = 3/4 。
  • 2-gram 匹配:the cat, the mat(2 个匹配),总共 3 个 2-gram,故 p_2 = 2/3 。