NLP & LLM 课程学习笔记

Lecture 04: 神经语言模型

核心主题:NPLM (Bengio 2003)、RNN、BPTT、梯度消失/爆炸、LSTM

1. 神经概率语言模型 (NPLM, Bengio et al. 2003)

1.1 核心公式

条件概率(softmax 输出):

$$\hat{P}(w_t \mid w_{t-1}, \ldots, w_{t-N+1}) = \frac{e^{y_{w_t}}}{\sum_i e^{y_i}}$$

未归一化 log 概率

$$y = b + Wx + U \cdot \tanh(d + Hx)$$

输入表示(拼接嵌入向量):

$$x = (C(w_{t-1}), C(w_{t-2}), \ldots, C(w_{t-N+1})) \in \mathbb{R}^{(N-1) \times m}$$
核心思想:将离散的词索引通过共享嵌入矩阵 $C$ 映射为连续向量,再经前馈网络计算下一词概率。

1.2 参数表

参数 维度 角色
$C$ $\mathbb{R}^{|V| \times m}$ 词嵌入矩阵(所有词共享)
$H$ $\mathbb{R}^{h \times (N-1)m}$ 输入 → 隐藏层权重
$d$ $\mathbb{R}^h$ 隐藏层偏置
$U$ $\mathbb{R}^{|V| \times h}$ 隐藏 → 输出层权重
$W$ $\mathbb{R}^{|V| \times (N-1)m}$ 直接连接权重(可选跳跃连接)
$b$ $\mathbb{R}^{|V|}$ 输出偏置

1.3 参数量对比

模型 参数量级 说明
N-gram $O(|V|^N)$ 指数级增长,存储所有 N-gram 计数
NPLM $O(|V| \cdot N \cdot m)$ 线性级,通过共享嵌入实现泛化
关键优势:通过共享嵌入矩阵 $C$,NPLM 参数量从指数降为线性,同时相似词获得相似表示,实现语义泛化。

1.4 架构图

[输出层] ← softmax(y) → P(w_t = i | context)
     ↑
(Wx + b) + U·tanh(Hx + d)    ← "主要计算在此"
     ↑              ↑
  [直接连接 W]    [tanh 隐藏层 H]
     ↑              ↑
     x = [C(w_{t-n+1}), ..., C(w_{t-1})]   ← 拼接嵌入
     ↑
[查表 C] ← 共享嵌入矩阵
     ↑
index for w_{t-n+1}, ..., w_{t-1}

1.5 实验结果

2. 循环神经网络 (RNN)

2.1 RNN 方程

隐藏状态更新

$$h_t = \phi_h(W_{xh}^T x_t + W_{hh}^T h_{t-1} + b_h)$$

输出计算

$$o_t = W_{yh} \cdot h_t + b_y$$

预测概率

$$\hat{y}_t = \text{softmax}(o_t)$$
与 NPLM 的区别:RNN 通过隐藏状态 $h_t$ 理论上可以建模任意长度的上下文,而 NPLM 只能看固定窗口 $N-1$ 个词。

2.2 损失函数

交叉熵损失(对所有时间步求和):

$$L(\hat{y}, y) = -\sum_{t=1}^T y_t \log \hat{y}_t = -\sum_{t=1}^T L_t$$

3. 时间反向传播 (BPTT)

3.1 对 $W_{yh}$ 的梯度(简单情况)

$W_{yh}$ 只直接影响当前时间步的输出,梯度计算直接:

$$\frac{\partial L}{\partial W_{yh}} = \sum_{t=1}^T \frac{\partial L_t}{\partial \hat{y}_t} \cdot \frac{\partial \hat{y}_t}{\partial o_t} \cdot \frac{\partial o_t}{\partial W_{yh}}$$

3.2 对 $W_{hh}$ 的梯度(关键难点)

$W_{hh}$ 影响所有未来隐藏状态,需要累加所有路径的梯度贡献:

$$\frac{\partial L}{\partial W_{hh}} = \sum_{t=1}^T \sum_{k=1}^t \frac{\partial L_t}{\partial \hat{y}_t} \cdot \frac{\partial \hat{y}_t}{\partial h_t} \cdot \frac{\partial h_t}{\partial h_k} \cdot \frac{\partial h_k}{\partial W_{hh}}$$

核心项 — 隐藏状态之间的梯度传播:

$$\frac{\partial h_t}{\partial h_k} = \prod_{j=k}^{t-1} \frac{\partial h_{j+1}}{\partial h_j}$$
关键问题:当 $t - k$ 很大时,这个连乘项要么指数衰减(梯度消失),要么指数增长(梯度爆炸)。

3.3 Jacobian 矩阵

相邻时间步的 Jacobian:

$$\frac{\partial h_t}{\partial h_{t-1}} = \text{Diag}(\phi'_h) \cdot W_{hh}$$

其中 $\text{Diag}(\phi'_h)$ 是激活函数导数构成的对角矩阵。对于 tanh,$\phi'_h \in (0, 1]$。

4. 梯度消失与爆炸

4.1 范数分析

$$\left\|\frac{\partial h_t}{\partial h_k}\right\| \leq \prod_{i=k}^{t-1} \|\text{Diag}(\phi'_h)\| \cdot \|W_{hh}\| \leq (\gamma_h \cdot \gamma_w)^{t-k}$$

其中:

4.2 两种情况

条件 结果 后果
$\gamma_h \cdot \gamma_w < 1$ $(\gamma_h \gamma_w)^{t-k} \to 0$ 梯度消失 — 无法学习长距离依赖
$\gamma_h \cdot \gamma_w > 1$ $(\gamma_h \gamma_w)^{t-k} \to \infty$ 梯度爆炸 — 训练不稳定,loss 发散
直觉:tanh 的导数最大为 1,如果 $\|W_{hh}\|$ 的谱范数也小于 1,则每传一步梯度都缩小,经过几十步后梯度几乎为零。反之若谱范数大于 1 则梯度指数爆炸。

4.3 解决方案

方案 解决问题 方法
梯度裁剪 梯度爆炸 若 $\|g\| > \text{threshold}$,则 $g \leftarrow \frac{\text{threshold}}{\|g\|} \cdot g$
Leaky Integration 梯度消失 $h_t = \alpha h_{t-1} + (1-\alpha) f(x_t, h_{t-1})$,保留部分旧状态
LSTM / GRU 梯度消失 门控机制 + 加法更新,梯度可无阻碍流动

5. LSTM (长短期记忆网络)

5.1 四个组件

1) 遗忘门(决定丢弃什么信息):

$$f_t = \sigma(W_f \cdot [h_{t-1}, x_t] + b_f)$$

2) 输入门(决定存储什么新信息):

$$i_t = \sigma(W_i \cdot [h_{t-1}, x_t] + b_i)$$

3) 候选细胞值

$$\tilde{C}_t = \tanh(W_C \cdot [h_{t-1}, x_t] + b_C)$$

4) 细胞状态更新

$$C_t = f_t \odot C_{t-1} + i_t \odot \tilde{C}_t$$

5) 输出门

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

6) 隐藏状态输出

$$h_t = o_t \odot \tanh(C_t)$$
符号说明:$\sigma$ = sigmoid 函数,$\odot$ = 逐元素乘法(Hadamard product),$[\cdot, \cdot]$ = 向量拼接。

5.2 为什么 LSTM 解决梯度消失

细胞状态 $C_t$ 使用加法更新(而非乘法),梯度可以沿着"恒定误差传送带"(Constant Error Carousel) 无阻碍流动:

$$C_t = f_t \odot C_{t-1} + i_t \odot \tilde{C}_t$$

当遗忘门 $f_t \approx 1$ 时:

$$\frac{\partial C_t}{\partial C_{t-1}} = f_t \approx 1$$

梯度不衰减,信息可以跨越很长的时间步传播。这与 vanilla RNN 中 $\frac{\partial h_t}{\partial h_{t-1}} = \text{Diag}(\phi'_h) \cdot W_{hh}$(连乘导致衰减)形成鲜明对比。

5.3 实验结果 (WikiText-2)

Epoch Train PPL Valid PPL
1 1521.01 971.39
5 398.48 433.80
10 212.12 317.88

测试集 PPL = 326.15(2层 LSTM, hidden=200, 10 个 epoch)

对比:LSTM 相比 vanilla RNN 可以更有效地捕获长距离依赖,PPL 显著更低。但相比后续的 Transformer 架构,LSTM 仍受限于序列化计算。

6. PyTorch 自动微分 (Autograd)

核心概念:PyTorch 通过计算图自动追踪所有涉及 requires_grad=True 张量的操作,调用 .backward() 即可自动计算梯度。

import torch

# 创建需要梯度的张量
x = torch.ones(2, 2, requires_grad=True)
y = x + 2
z = y * y * 3
out = z.mean()

# 反向传播
out.backward()
print(x.grad)  # tensor([[4.5000, 4.5000],
                #         [4.5000, 4.5000]])

验证:$\text{out} = \frac{3}{4}\sum(x+2)^2$,因此 $\frac{\partial \text{out}}{\partial x} = \frac{3}{2}(x+2) = \frac{3}{2} \times 3 = 4.5$ ✓

Tensor/NumPy 共享内存

import torch

a = torch.ones(5)
b = a.numpy()       # 共享内存!
a.add_(1)           # a 和 b 都变成 [2, 2, 2, 2, 2]
print(b)            # [2. 2. 2. 2. 2.]

# 反向:numpy -> tensor 也共享
import numpy as np
c = np.ones(5)
d = torch.from_numpy(c)
np.add(c, 1, out=c)  # c 和 d 都变成 [2, 2, 2, 2, 2]
注意:共享内存意味着修改其中一个会影响另一个。在需要独立副本时使用 .clone().copy()

7. Micrograd(手写自动微分引擎)

Micrograd 是 Karpathy 实现的极简自动微分引擎,核心是 Value 类,演示链式法则如何在计算图中传播梯度:

class Value:
    def __init__(self, data, _children=(), _op=''):
        self.data = data
        self.grad = 0
        self._backward = lambda: None
        self._prev = set(_children)

    def __add__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data + other.data, (self, other), '+')

        def _backward():
            self.grad += out.grad       # dL/d(self) = dL/d(out) * 1
            other.grad += out.grad      # dL/d(other) = dL/d(out) * 1
        out._backward = _backward
        return out

    def __mul__(self, other):
        other = other if isinstance(other, Value) else Value(other)
        out = Value(self.data * other.data, (self, other), '*')

        def _backward():
            self.grad += other.data * out.grad  # dL/d(self) = other * dL/d(out)
            other.grad += self.data * out.grad  # dL/d(other) = self * dL/d(out)
        out._backward = _backward
        return out

    def backward(self):
        # 拓扑排序 -> 逆序应用链式法则
        topo = []
        visited = set()
        def build_topo(v):
            if v not in visited:
                visited.add(v)
                for child in v._prev:
                    build_topo(child)
                topo.append(v)
        build_topo(self)
        self.grad = 1
        for v in reversed(topo):
            v._backward()
链式法则的本质:每个运算节点只需要知道"输出梯度"和"局部导数",二者相乘即得"输入梯度"。backward() 通过拓扑排序保证每个节点在其所有消费者之后才被处理。

8. NPLM 完整实现 (BengioNPLM)

import torch
import torch.nn as nn

class BengioNPLM(nn.Module):
    """
    Neural Probabilistic Language Model (Bengio et al., 2003)
    y = b + Wx + U * tanh(d + Hx)
    """
    def __init__(self, vocab_size, embedding_dim, context_size, hidden_dim):
        super().__init__()
        self.context_size = context_size
        self.embedding_dim = embedding_dim

        # C: 共享词嵌入矩阵 |V| x m
        self.emb = nn.Embedding(vocab_size, embedding_dim)

        # H: 输入 -> 隐藏层  (h x (N-1)*m)
        self.hidden = nn.Linear(context_size * embedding_dim, hidden_dim)

        # U: 隐藏 -> 输出层  (|V| x h)
        self.hidden_to_vocab = nn.Linear(hidden_dim, vocab_size)

        # b: 输出偏置  (|V|,)
        self.output_bias = nn.Parameter(torch.zeros(vocab_size))

        # W: 直接连接(可选跳跃连接)  (|V| x (N-1)*m)
        self.direct = nn.Linear(context_size * embedding_dim, vocab_size)

    def forward(self, input_ids):
        # input_ids: (B, context_size) — 上下文词索引
        B = input_ids.size(0)

        # 查表 + 拼接: (B, context_size * embedding_dim)
        x = self.emb(input_ids).reshape(B, -1)

        # tanh 隐藏层: tanh(Hx + d)
        h = torch.tanh(self.hidden(x))

        # 输出: U*h + b + W*x
        logits = self.hidden_to_vocab(h) + self.output_bias
        logits = logits + self.direct(x)

        return logits  # (B, vocab_size)

训练循环要点

核心要点总结

  1. NPLM 比 N-gram 参数量从指数级降为线性级,通过共享嵌入矩阵 $C$ 实现语义泛化,相似词自动获得相似预测
  2. RNN 的 BPTT 本质上是序列化的,$\prod_{j=k}^{t-1} \frac{\partial h_{j+1}}{\partial h_j}$ 连乘无法并行化,这是后续 Transformer 要解决的核心问题
  3. 梯度消失/爆炸是 vanilla RNN 的核心缺陷,源于 Jacobian 矩阵 $\text{Diag}(\phi'_h) \cdot W_{hh}$ 的反复相乘
  4. LSTM 通过加法细胞状态更新解决梯度消失:$C_t = f_t \odot C_{t-1} + i_t \odot \tilde{C}_t$,当 $f_t \approx 1$ 时梯度可以无损传播
  5. 梯度裁剪是稳定 RNN/LSTM 训练的必备技巧,防止梯度爆炸导致参数更新过大
  6. 自动微分(PyTorch Autograd / Micrograd)将链式法则自动化,开发者只需定义前向计算,梯度由框架自动处理