上一篇:速通 AI(一):编程与人工智能数学基础 — Python 基础、微积分、线性代数、概率统计、机器学习入门。
目标:掌握深度学习核心概念,熟练使用 PyTorch 框架进行模型开发,理解 RNN/LSTM/GRU 序列模型。
前置要求:阶段一(Python 基础、微积分、线性代数、机器学习基础)
本阶段知识依赖图
flowchart TD
A[阶段一基础] --> B[神经网络基础]
A --> PyTorch["PyTorch框架(贯穿始终)"]
B --> B1[反向传播] --> B2[激活函数/正则化]
B --> C["CNN(图像处理)"] --> C1[经典CNN模型] --> C2[迁移学习]
B --> D["RNN(序列处理)"] --> D1[LSTM] --> D2[GRU]
D1 --> D3[深度/双向RNN]
PyTorch --> P1[张量操作] --> P1a[自动求导]
PyTorch --> P2[模型构建] --> P2a[训练循环]
PyTorch --> P3[数据加载] --> P3a["Dataset/DataLoader"]
PyTorch --> P4[训练优化] --> P4a[混合精度/学习率调度]
1. 神经网络与 PyTorch 基础
1.1 神经网络基础——从生物到数学
什么是神经网络?
类比:一个决策工厂
想象你要判断一张图片是否是猫。你的大脑会怎么做?
- 先识别边缘(这里有条线,那里有个弧形)
- 再组合成形状(这个弧形+那个三角形 = 耳朵?)
- 最后做出判断(有尖耳朵+胡须+毛茸茸 → 大概率是猫)
神经网络做的就是同样的事——分层提取特征,逐层抽象,最终做出判断。
| 生物神经元 | 人工神经元 |
|---|---|
| 树突(输入信号) | 输入 $x_1, x_2, x_3$ |
| 细胞体(加权求和) | $z = w_1 x_1 + w_2 x_2 + w_3 x_3 + b$ |
| 轴突(激活判断) | $a = \text{activation}(z)$ |
| 突触(输出信号) | 输出 $a$ |
每个神经元在做什么? 两件事:
- 加权求和:把所有输入乘以各自的权重再加起来(“每个因素的重要程度不同”)
- 激活函数:对求和结果做一个非线性变换(“做出是否激活的决定”)
类比:一个神经元就像一个"评委"——它听取多方意见(输入),给每个意见不同的权重(重要程度),最后综合所有意见给出自己的评分(输出)。
神经网络的结构
| 输入层 | 隐藏层 1 | 隐藏层 2 | 输出层 |
|---|---|---|---|
| 3 个输入 | 4 个神经元 | 3 个神经元 | 2 个输出 |
前向传播(Forward Pass):数据从左到右流过网络,逐层计算
$$ \begin{aligned} h_1 &= \text{activation}(W_1 \cdot x + b_1) \quad \text{(第1层:原始输入 → 低级特征)} \\ h_2 &= \text{activation}(W_2 \cdot h_1 + b_2) \quad \text{(第2层:低级特征 → 高级特征)} \\ y &= W_3 \cdot h_2 + b_3 \quad \text{(输出层:高级特征 → 最终预测)} \end{aligned} $$每一层在做什么?
- 第 1 层:看到像素 → 识别边缘(“这里有条竖线”)
- 第 2 层:看到边缘 → 识别形状(“这个形状像耳朵”)
- 第 3 层:看到形状 → 做出判断(“有耳朵+胡须 → 是猫”)
这就是"分层抽象"——每一层把上一层的输出当作输入,提取更高层次的特征。
为什么需要激活函数?——非线性的力量
如果没有激活函数会怎样?
没有激活函数:
$$ \begin{aligned} h_1 &= W_1 \cdot x + b_1 \\ h_2 &= W_2 \cdot h_1 + b_2 \\ y &= W_3 \cdot h_2 + b_3 \end{aligned} $$合并起来:$y = W_3 \cdot (W_2 \cdot (W_1 \cdot x + b_1) + b_2) + b_3 = W \cdot x + b$(还是一个线性变换!)
再多层也等于一层!因为线性变换的组合还是线性变换。
激活函数的作用——引入非线性:
有了激活函数:
$$ \begin{aligned} h_1 &= \sigma(W_1 \cdot x + b_1) \\ h_2 &= \sigma(W_2 \cdot h_1 + b_2) \\ y &= W_3 \cdot h_2 + b_3 \end{aligned} $$这时,两层网络 ≠ 一层网络!因为非线性变换的组合可以逼近任意复杂的函数。
这就是"万能近似定理"(Universal Approximation Theorem):一个有足够多神经元的单隐层网络,可以逼近任意连续函数。
常用激活函数对比:
| 激活函数 | 公式 | 特点 | 使用场景 |
|---|---|---|---|
| Sigmoid | $\frac{1}{1+e^{-x}}$ | 输出(0,1),有梯度消失问题 | 二分类输出层 |
| Tanh | $\frac{e^x-e^{-x}}{e^x+e^{-x}}$ | 输出(-1,1),零中心化 | RNN 中常用 |
| ReLU | $\max(0, x)$ | 简单高效,无梯度消失 | 隐藏层首选 |
| LeakyReLU | $\max(0.01x, x)$ | 解决 ReLU"死神经元"问题 | ReLU 的改进 |
| GELU | $x \cdot \Phi(x)$ | 平滑版 ReLU | Transformer 中常用 |
| Swish | $x \cdot \sigma(x)$ | 自门控,平滑 | LLaMA 中使用 |
Softmax 的工作原理——从分数到概率
Softmax 将一组原始分数(logits)转化为概率分布(所有值 > 0,且和为 1)。公式和完整推导(含数值稳定性优化)在第一篇 §2.2 已详细讲解,这里用一个分类场景的数值示例回顾:
$$ \text{softmax}(z_i) = \frac{e^{z_i}}{\sum_j e^{z_j}} $$数值示例:假设模型对一张图片输出3个类别的原始分数(logits):猫=2.0,狗=1.0,鸟=0.5。
| 类别 | logit $z_i$ | $e^{z_i}$ | 概率 $\frac{e^{z_i}}{\sum e^{z_j}}$ |
|---|---|---|---|
| 猫 | 2.0 | $e^{2.0} = 7.39$ | $7.39 / 11.76 = 0.63$ |
| 狗 | 1.0 | $e^{1.0} = 2.72$ | $2.72 / 11.76 = 0.23$ |
| 鸟 | 0.5 | $e^{0.5} = 1.65$ | $1.65 / 11.76 = 0.14$ |
| 总和 | - | 11.76 | 1.00 |
解读:原始分数 2.0:1.0:0.5 经过 Softmax 变成了概率 63%:23%:14%。Softmax 的两个关键特性:(1) 所有输出为正数且和为1(合法概率分布);(2) 最大的分数被"放大"(2.0对1.0的优势从2倍变成了2.7倍),这使得模型的预测更"自信"。
ReLU 为什么成为主流?
Sigmoid 的问题:$\sigma'(x) = \sigma(x) \cdot (1 - \sigma(x))$。当 $x$ 很大或很小时,$\sigma'(x) \approx 0$,导致梯度消失,网络学不动。
ReLU 的优势:$\text{ReLU}'(x) = 1$(当 $x > 0$ 时)或 $0$(当 $x \leq 0$ 时)。当 $x > 0$ 时,梯度恒为 1 ,不会消失!计算极其简单(就是一个 max 操作)。
类比: Sigmoid 像一个"渐变开关"——输入越大越开,但永远不会完全开。 ReLU 像一个"硬开关"——要么全开($x > 0$),要么全关($x \leq 0$)。硬开关虽然粗糙,但胜在简单高效。
下图展示了各激活函数及其导数的形状对比,可以直观看到 Sigmoid 的梯度饱和问题和 ReLU 的"硬开关"特性:
1.2 反向传播——让网络"学习"的魔法
核心问题:如何更新权重?
我们有了损失函数 $L$(衡量预测和真实值的差距),目标是找到让 $L$ 最小的权重。方法就是梯度下降:
$$ w_{\text{new}} = w_{\text{old}} - \eta \cdot \frac{\partial L}{\partial w} $$类比:下山找路。你站在一座山上(当前损失值),想走到山谷(损失最小点)。每一步你看看哪个方向最陡(梯度),然后朝那个方向的反方向走一步(更新权重)。步子多大由学习率 $\eta$ 决定——步子太大可能走过头,步子太小走得太慢。
梯度下降的完整数值示例($y = (x-3)^2$,6 步迭代表格)和学习率的影响分析在第一篇 §3.1 已详细推导,此处不再重复。其中 $\eta$ 是学习率——太大会震荡发散,太小收敛极慢。
实际神经网络中的损失函数是高维的(几百万个参数),但原理完全一样:计算每个参数的梯度,然后沿梯度反方向更新。下图展示了梯度下降在二维损失曲面上的收敛路径——可以看到初始步子大、接近最优点时步子变小的自适应特性:
关键问题是:如何高效计算梯度? 这就是反向传播要解决的问题。
类比:工厂流水线的责任追溯
想象一个工厂流水线生产了一个次品(损失 L 很大):原材料 → 工序 1 → 工序 2 → 工序 3 → 次品
老板想知道"谁的责任最大",以便调整每个工序:
- 反向传播就是从"次品"开始,逆向追溯每个工序的"责任"
- 每个工序的"责任" = 它对最终误差的贡献度 = 梯度
反向传播的完整推导(以 2 层网络为例)
网络结构:输入 $x$ → [线性层 1] → $z_1 = W_1 x + b_1$ → [激活] → $a_1 = \sigma(z_1)$ → [线性层 2] → $z_2 = W_2 a_1 + b_2$ → [损失] → $L$
为什么用 MSE 作为损失函数? MSE(均方误差)$L = \frac{1}{2}(y - z_2)^2$ 的本质是最大似然估计:假设模型的预测误差服从正态分布 $y = f(x) + \epsilon$,其中 $\epsilon \sim N(0, \sigma^2)$,那么最大化似然函数等价于最小化均方误差。换句话说,用 MSE 损失 = 假设误差是正态分布的。这对回归任务(预测房价、温度等连续值)是合理的——误差通常近似正态分布。但对分类任务(判断是猫还是狗),误差不是正态分布,应该用交叉熵损失(详见第一篇文章)。
前向传播(已知):
$$ z_1 = W_1 \cdot x + b_1, \quad a_1 = \sigma(z_1), \quad z_2 = W_2 \cdot a_1 + b_2, \quad L = \frac{1}{2}(y - z_2)^2 $$反向传播(从右往左,逐步计算梯度):
Step 1: L 对 $z_2$ 的梯度
$$ \frac{\partial L}{\partial z_2} = \frac{\partial}{\partial z_2}\left[\frac{1}{2}(y - z_2)^2\right] = -(y - z_2) = z_2 - y $$直觉:预测值和真实值的差距越大,梯度越大 → 需要调整的力度越大。
Step 2: L 对 $W_2$ 和 $b_2$ 的梯度
$$ \frac{\partial L}{\partial W_2} = \frac{\partial L}{\partial z_2} \cdot \frac{\partial z_2}{\partial W_2} = (z_2 - y) \cdot a_1^T $$$$ \frac{\partial L}{\partial b_2} = \frac{\partial L}{\partial z_2} \cdot \frac{\partial z_2}{\partial b_2} = z_2 - y $$直觉:$W_2$ 的梯度 = 误差信号 $(z_2 - y)$ × 输入信号 $(a_1)$。如果 $a_1$ 很大,说明这个权重"参与度高",需要调整更多。
Step 3: L 对 $a_1$ 的梯度(误差从第 2 层传回第 1 层)
$$ \frac{\partial L}{\partial a_1} = \frac{\partial L}{\partial z_2} \cdot \frac{\partial z_2}{\partial a_1} = W_2^T \cdot (z_2 - y) $$直觉:第 2 层的误差"按权重比例"分配给第 1 层的每个神经元。权重越大,分配到的误差越多 → “谁的影响力大,谁的责任就大”。
Step 4: L 对 $z_1$ 的梯度
$$ \frac{\partial L}{\partial z_1} = \frac{\partial L}{\partial a_1} \cdot \frac{\partial a_1}{\partial z_1} = W_2^T \cdot (z_2 - y) \odot \sigma'(z_1) $$其中 $\odot$ 是逐元素相乘,$\sigma'(z_1) = \sigma(z_1) \cdot (1 - \sigma(z_1))$。
直觉:误差信号通过激活函数的导数"过滤"——如果激活函数在该点的导数很小(Sigmoid 的饱和区),误差信号被大幅衰减。→ 这就是"梯度消失"的根本原因!
Step 5: L 对 $W_1$ 和 $b_1$ 的梯度
$$ \frac{\partial L}{\partial W_1} = \frac{\partial L}{\partial z_1} \cdot \frac{\partial z_1}{\partial W_1} = \left[W_2^T \cdot (z_2-y) \odot \sigma'(z_1)\right] \cdot x^T $$$$ \frac{\partial L}{\partial b_1} = \frac{\partial L}{\partial z_1} = W_2^T \cdot (z_2-y) \odot \sigma'(z_1) $$总结——反向传播的核心规律:
每一层的梯度计算都可以分解为 3 步:
- 接收来自下一层的误差信号
- 乘以本层激活函数的导数
- 乘以本层的输入信号
动手计算:反向传播完整数值推导
为了真正理解反向传播,我们用具体数字走一遍完整流程。假设一个极简网络:
网络结构:输入 $x=2.0$ → [线性层 1] → $z_1 = w_1 \cdot x + b_1$ → [Sigmoid] → $a_1 = \sigma(z_1)$ → [线性层 2] → $z_2 = w_2 \cdot a_1 + b_2$ → [MSE 损失] → $L = \frac{1}{2}(y - z_2)^2$
初始参数:$w_1 = 0.5$,$b_1 = 0.1$,$w_2 = 0.8$,$b_2 = -0.2$,真实值 $y = 1.0$,学习率 $\eta = 0.5$
第一步:前向传播
$$ \begin{aligned} z_1 &= w_1 \cdot x + b_1 = 0.5 \times 2.0 + 0.1 = 1.1 \\ a_1 &= \sigma(z_1) = \frac{1}{1 + e^{-1.1}} = \frac{1}{1 + 0.3329} = 0.7503 \\ z_2 &= w_2 \cdot a_1 + b_2 = 0.8 \times 0.7503 + (-0.2) = 0.4002 \\ L &= \frac{1}{2}(y - z_2)^2 = \frac{1}{2}(1.0 - 0.4002)^2 = \frac{1}{2} \times 0.3598 = 0.1799 \end{aligned} $$当前损失 $L = 0.1799$,预测值 0.4002 离真实值 1.0 还很远,需要通过反向传播更新参数。
第二步:反向传播(从右往左)
Step 1 — 损失对 $z_2$ 的梯度:
$$ \frac{\partial L}{\partial z_2} = z_2 - y = 0.4002 - 1.0 = -0.5998 $$负号说明 $z_2$ 需要增大(往 $y$ 靠拢)。
Step 2 — 损失对 $w_2$、$b_2$ 的梯度:
$$ \begin{aligned} \frac{\partial L}{\partial w_2} &= \frac{\partial L}{\partial z_2} \cdot a_1 = (-0.5998) \times 0.7503 = -0.4500 \\ \frac{\partial L}{\partial b_2} &= \frac{\partial L}{\partial z_2} = -0.5998 \end{aligned} $$Step 3 — 误差从第 2 层传回第 1 层($L$ 对 $a_1$ 的梯度):
$$ \frac{\partial L}{\partial a_1} = \frac{\partial L}{\partial z_2} \cdot w_2 = (-0.5998) \times 0.8 = -0.4798 $$Step 4 — 损失对 $z_1$ 的梯度(通过 Sigmoid 导数过滤):
$$ \begin{aligned} \sigma'(z_1) &= a_1 \cdot (1 - a_1) = 0.7503 \times 0.2497 = 0.1874 \\ \frac{\partial L}{\partial z_1} &= \frac{\partial L}{\partial a_1} \cdot \sigma'(z_1) = (-0.4798) \times 0.1874 = -0.0899 \end{aligned} $$注意:误差信号从 $-0.4798$ 衰减到 $-0.0899$,缩小了约 5.3 倍!这就是 Sigmoid 的梯度消失——如果网络更深,梯度会进一步衰减。
Step 5 — 损失对 $w_1$、$b_1$ 的梯度:
$$ \begin{aligned} \frac{\partial L}{\partial w_1} &= \frac{\partial L}{\partial z_1} \cdot x = (-0.0899) \times 2.0 = -0.1798 \\ \frac{\partial L}{\partial b_1} &= \frac{\partial L}{\partial z_1} = -0.0899 \end{aligned} $$第三步:参数更新(梯度下降)
$$ \begin{aligned} w_2 &\leftarrow 0.8 - 0.5 \times (-0.4500) = 0.8 + 0.2250 = 1.0250 \\ b_2 &\leftarrow -0.2 - 0.5 \times (-0.5998) = -0.2 + 0.2999 = 0.0999 \\ w_1 &\leftarrow 0.5 - 0.5 \times (-0.1798) = 0.5 + 0.0899 = 0.5899 \\ b_1 &\leftarrow 0.1 - 0.5 \times (-0.0899) = 0.1 + 0.0450 = 0.1450 \end{aligned} $$验证:用更新后的参数重新前向传播:
$$ z_1 = 0.5899 \times 2.0 + 0.1450 = 1.3248, \quad a_1 = \sigma(1.3248) = 0.7899 $$$$ z_2 = 1.0250 \times 0.7899 + 0.0999 = 0.9096, \quad L = \frac{1}{2}(1.0 - 0.9096)^2 = 0.0041 $$损失从 0.1799 降到 0.0041,下降了 44 倍!一次反向传播就让预测值从 0.4002 逼近到 0.9096。这正是反向传播的威力——通过链式法则精确计算每个参数的"责任",然后有针对性地调整。
梯度消失与梯度爆炸——深层网络的两大杀手
上面的数值示例中,误差信号从 $-0.4798$ 衰减到 $-0.0899$(缩小了 5.3 倍)。如果网络有 10 层,梯度会衰减为 $0.1874^{10} \approx 6 \times 10^{-8}$——参数几乎不更新,网络"学不动了"。这就是梯度消失。
梯度爆炸则是相反的问题:当权重矩阵的谱范数(最大特征值)大于 1 时,多个大于 1 的导数连乘,梯度会指数级增长。例如 10 层网络中每层梯度放大 1.5 倍:$1.5^{10} \approx 57.7$——梯度变得巨大,参数更新过猛,训练不稳定甚至发散。
| 问题 | 原因 | 表现 | 解决方案 |
|---|---|---|---|
| 梯度消失 | 激活函数导数 < 1(如 Sigmoid 最大导数仅 0.25),连乘后指数衰减 | 深层参数几乎不更新,训练停滞 | ReLU(正区间导数恒为 1)、残差连接(梯度高速公路)、BatchNorm(平滑损失曲面)、梯度裁剪 |
| 梯度爆炸 | 权重矩阵谱范数 > 1,连乘后指数增长 | loss 突然变为 NaN 或震荡发散 | 梯度裁剪(clip_grad_norm)、权重初始化(He/Xavier)、BatchNorm、残差连接 |
梯度裁剪的 PyTorch 实现:
# 在 optimizer.step() 之前调用——限制梯度的范数不超过 max_norm
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
# 如果梯度的 L2 范数超过 1.0,会等比例缩小到 1.0
# 类比:给梯度加一个"限速器",防止更新步子太大
面试要点:回答"梯度消失和梯度爆炸的原因和解决方案?“时,从链式法则的连乘效应出发,分别解释导数 < 1(消失)和导数 > 1(爆炸)的情况,再给出具体解决方案(ReLU、残差连接、梯度裁剪、BatchNorm)。
计算图——现代深度学习框架的核心
什么是计算图? 把数学运算画成一个有向图,每个节点是一个操作,每条边是数据流。
示例:$y = (x + 1) \times (x + 2)$。计算过程:$x$ → $[+1]$ → $x+1$ 和 $x$ → $[+2]$ → $x+2$,然后 $(x+1) \times (x+2) = y$。
PyTorch 在前向传播时自动构建这个图,反向传播时沿着图的边反向计算梯度。
import torch
# requires_grad=True 告诉PyTorch:"请追踪这个张量的所有操作,构建计算图"
x = torch.tensor(2.0, requires_grad=True)
y = (x + 1) * (x + 2) # PyTorch在背后记录了这个计算过程
y.backward() # 沿计算图反向传播,自动计算梯度
print(x.grad) # dy/dx = (x+2) + (x+1) = 4 + 3 = 7
为什么 PyTorch 用"动态"计算图?
- 动态图(PyTorch):每次前向传播时实时构建。优点是可用 Python 的 if/for 等控制流,调试方便;缺点是每次都要重新构建。
- 静态图(TensorFlow 1.x):先定义图结构,再执行。优点是可以提前优化,运行更快;缺点是调试困难,不灵活。
PyTorch 选择了"易用性优先"的动态图策略,这也是它在研究界流行的主要原因。
1.3 PyTorch 环境搭建
CUDA 安装——为什么需要 GPU ?
- CPU(中央处理器):少核心(8-16 个),每个核心很强 → 适合串行复杂任务
- GPU(图形处理器):多核心(几千个),每个核心较弱 → 适合并行简单任务
神经网络训练的本质 = 大量矩阵乘法 = 高度并行运算 → GPU 更适合!
类比: CPU 像一个数学教授——能解很难的题,但一次只能解一道。 GPU 像一群小学生——每个人只会简单加减乘除,但几千人同时算,总速度更快。矩阵乘法正好是"大量简单运算的组合”,所以 GPU 完胜。
安装步骤
# 1. 确认显卡型号和CUDA版本
nvidia-smi
# 2. 安装CUDA Toolkit(根据显卡选择版本)
# 从 https://developer.nvidia.com/cuda-toolkit-archive 下载
# 3. 安装cuDNN(CUDA的深度学习加速库)
# 从 https://developer.nvidia.com/cudnn 下载
# 4. 安装PyTorch(选择对应CUDA版本)
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
# 5. 验证安装
python -c "import torch; print(torch.cuda.is_available())" # 应输出True
如果显卡不够怎么办?
- Google Colab:免费 GPU (T4),适合学习和小实验
- AutoDL 等云平台:按小时付费,可选 A100/4090 等高端 GPU
- CPU 也能跑:小模型(如 BERT-base)用 CPU 也能训练,只是慢 10-50 倍
1.4 PyTorch 张量操作——深度学习的"积木"
什么是张量?
类比:从数字到张量的"升维之路"
| 维度 | 名称 | 示例 |
|---|---|---|
| 0D | 标量 | 学习率 lr = 0.001 |
| 1D | 向量 | 一个词的嵌入 [0.2, 0.8, -0.1] |
| 2D | 矩阵 | 一个 batch 的数据 (32, 784) |
| 3D | 3D 张量 | 彩色图片 (高, 宽, 通道) |
| 4D | 4D 张量 | batch of images (批次, 通道, 高, 宽) |
为什么叫"张量"而不是"数组"?
张量和数组在数据结构上是一样的,但张量有两个关键特性:
- 可以在 GPU 上运算(NumPy 数组只能在 CPU 上)
- 支持自动求导(自动计算梯度)
所以 张量 = NumPy 数组 + GPU 支持 + 自动求导,这就是深度学习框架的核心数据结构。
张量创建
import torch
# 从Python列表创建
t1 = torch.tensor([1, 2, 3])
# 创建特定形状的张量
zeros = torch.zeros(3, 4) # 全0矩阵,shape: (3, 4)
ones = torch.ones(2, 3) # 全1矩阵
rand = torch.randn(2, 3) # 标准正态分布随机数(最常用于初始化权重)
# 从NumPy转换
import numpy as np
np_arr = np.array([1, 2, 3])
tensor = torch.from_numpy(np_arr) # 共享内存!修改一方会影响另一方
张量运算——神经网络的基本操作
# ========== 矩阵乘法(最重要的操作!)==========
a = torch.randn(3, 4) # shape: (3, 4)
b = torch.randn(4, 5) # shape: (4, 5)
c = a @ b # shape: (3, 5),等价于 torch.matmul(a, b)
# 维度规则:(3, 4) @ (4, 5) = (3, 5),中间维度必须相同
# ========== 线性变换 y = Wx + b ==========
x = torch.randn(1, 784) # 输入:1个784维样本
W = torch.randn(784, 256) # 权重矩阵
b = torch.randn(1, 256) # 偏置
h = x @ W + b # 隐藏层输出:shape (1, 256)
# 这就是神经网络中最基本的操作!每一层都在做 output = input @ weight + bias
# ========== 激活函数 ==========
relu_output = torch.relu(h) # ReLU: max(0, x)
sigmoid_output = torch.sigmoid(h) # Sigmoid: 1/(1+e^(-x))
softmax_output = torch.softmax(h, dim=1) # Softmax: 转为概率分布
# ========== 形状操作(在Transformer中大量使用)==========
x = torch.randn(2, 3, 4) # shape: (2, 3, 4)
x.reshape(6, 4) # 改变形状为 (6, 4)
x.permute(2, 0, 1) # 转置维度:(2,3,4) → (4,2,3)
x.unsqueeze(0) # 在第0维增加一个维度:(2,3,4) → (1,2,3,4)
x.squeeze() # 去掉大小为1的维度
1.5 PyTorch 自动求导与训练循环
自动求导(Autograd)——PyTorch 的"杀手锏"
没有自动求导的时代:研究者需要手推每个模型的梯度公式,然后手动写代码实现。一个新模型可能需要几周时间来推导和验证梯度。
有了自动求导:只需要定义前向传播, PyTorch 自动帮你计算梯度。新模型的实现时间从几周缩短到几小时。
import torch
# requires_grad=True 告诉PyTorch:"请追踪这个张量的所有操作"
x = torch.tensor(2.0, requires_grad=True)
y = x**3 + 2*x**2 + x # PyTorch在背后构建了计算图
y.backward() # 自动反向传播,计算梯度
print(x.grad) # dy/dx = 3x² + 4x + 1 = 12 + 8 + 1 = 21
PyTorch 自动求导的工作原理:
- 前向传播时: PyTorch 记录每一步操作,构建"计算图"
- 调用
.backward()时:从输出节点开始,沿计算图反向传播 - 每个节点:通过链式法则计算梯度
- 结果:存储在每个叶子节点的
.grad属性中
类比:前向传播像"记账"(记录每一步操作),反向传播像"审计"(追溯每一步的贡献)
完整的 PyTorch 训练循环——最重要的模板
这个模板适用于所有 PyTorch 模型! 无论多复杂的模型(CNN 、 RNN 、 Transformer),核心都是这个循环。
flowchart LR
A["① 前向传播<br/>y_pred = model(x)"] --> B["② 计算损失<br/>loss = criterion(y_pred, y)"]
B --> C["③ 反向传播<br/>loss.backward()"]
C --> D["④ 更新参数<br/>optimizer.step()"]
D -->|"下一个 epoch"| A
import torch
import torch.nn as nn
# ===== 第1步:定义模型 =====
model = nn.Linear(1, 1) # 简单线性回归 y = wx + b
# ===== 第2步:定义损失函数和优化器 =====
criterion = nn.MSELoss() # 均方误差损失
optimizer = torch.optim.SGD(model.parameters(), lr=0.01) # 随机梯度下降
# ===== 第3步:准备数据 =====
x_train = torch.randn(100, 1) # 100个样本
y_train = 3 * x_train + 2 + torch.randn(100, 1) * 0.1 # y = 3x + 2 + 噪声
# ===== 第4步:训练循环(核心!)=====
for epoch in range(1000):
# 4a. 前向传播:用当前参数计算预测值
y_pred = model(x_train)
# 4b. 计算损失:预测值和真实值的差距
loss = criterion(y_pred, y_train)
# 4c. 反向传播:计算每个参数的梯度
optimizer.zero_grad() # 清零梯度(重要!PyTorch默认累加梯度)
loss.backward() # 自动计算梯度
# 4d. 更新参数:沿着梯度的反方向走一步
optimizer.step()
if epoch % 100 == 0:
print(f'Epoch {epoch}, Loss: {loss.item():.4f}')
# ===== 第5步:查看学到的参数 =====
print(f'w = {model.weight.item():.2f}') # 应接近3
print(f'b = {model.bias.item():.2f}') # 应接近2
为什么要 optimizer.zero_grad()?
PyTorch 的设计哲学:梯度默认是累加的(而不是覆盖的)。为什么要这样设计?某些场景下需要"累积多个 batch 的梯度"再更新一次。
但大多数情况下,你需要在每次更新前手动清零:
optimizer.zero_grad() # 清零
loss.backward() # 计算新梯度
optimizer.step() # 更新参数
如果不清零,梯度会越来越大 → 参数更新过猛 → 训练不稳定。
优化器的演进——从 SGD 到 Adam
上面的训练循环用了 SGD(随机梯度下降),但它只是最基础的优化器。实际训练中,我们通常用 Adam 或 AdamW。为什么?让我们从 SGD 的问题说起。
SGD 的问题:震荡和收敛慢
SGD 的更新规则:$w \leftarrow w - \eta \cdot g$($g$ 为当前梯度)。问题:如果损失曲面在某个方向很陡、另一个方向很平,SGD 会在陡的方向上来回震荡,在平的方向上走得很慢。
Momentum(动量):给梯度加"惯性"
$$ v_t = \beta \cdot v_{t-1} + g_t \quad \text{(累积历史梯度)} \\ w \leftarrow w - \eta \cdot v_t $$类比:球从山上滚下来,有惯性——不会因为一个方向的梯度突然变小就停下。$\beta$ 通常取 0.9,表示"记住90%的历史趋势"。效果:减少震荡,在一致方向上加速。
Adam(Adaptive Moment Estimation):自适应学习率
Adam = Momentum + RMSProp(每个参数独立调整学习率):
$$ m_t = \beta_1 m_{t-1} + (1-\beta_1) g_t \quad \text{(一阶矩:梯度的均值,类似Momentum)} \\ v_t = \beta_2 v_{t-1} + (1-\beta_2) g_t^2 \quad \text{(二阶矩:梯度的方差,自适应缩放)} \\ w \leftarrow w - \eta \cdot \frac{\hat{m}_t}{\sqrt{\hat{v}_t} + \epsilon} $$直觉:对于梯度一直很大的参数($v_t$ 大),学习率自动变小(避免走过头);对于梯度一直很小的参数($v_t$ 小),学习率自动变大(加速学习)。默认超参数 $\beta_1=0.9, \beta_2=0.999, \epsilon=10^{-8}$。
对比总结:
| 优化器 | 核心思想 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| SGD | 沿梯度反方向走 | 简单、泛化好 | 收敛慢、震荡 | 理论研究、小模型 |
| SGD+Momentum | 加惯性 | 减少震荡、加速 | 学习率需手动调 | CV 任务常用 |
| Adam | 自适应学习率 | 收敛快、对学习率不敏感 | 泛化可能略差 | NLP/Transformer 首选 |
| AdamW | Adam + 解耦权重衰减 | Adam 的正则化修正 | 需要调权重衰减系数 | Transformer 训练标准 |
PyTorch 代码:
# SGD + Momentum
optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
# Adam(最常用)
optimizer = torch.optim.Adam(model.parameters(), lr=0.001, betas=(0.9, 0.999))
# AdamW(Transformer 训练标准,权重衰减与梯度更新解耦)
optimizer = torch.optim.AdamW(model.parameters(), lr=0.001, weight_decay=0.01)
训练循环四步法总结
| 步骤 | 代码 | 作用 | 类比 |
|---|---|---|---|
| 1. 前向传播 | y_pred = model(x) | 用当前参数计算预测值 | 考试答题 |
| 2. 计算损失 | loss = criterion(y_pred, y) | 衡量预测和真实值的差距 | 对答案 |
| 3. 反向传播 | optimizer.zero_grad() + loss.backward() | 计算每个参数的梯度 | 分析错因 |
| 4. 更新参数 | optimizer.step() | 沿梯度反方向调整参数 | 改正错误 |
模块小结:神经网络与 PyTorch 基础
| 你学到了什么 | 为什么重要 |
|---|---|
| 神经网络结构与前向传播 | 深度学习的基本计算单元 |
| 激活函数与非线性 | 让网络逼近任意复杂函数 |
| 反向传播算法 | 网络"学习"的核心机制 |
| PyTorch 张量操作 | 深度学习框架的基本功 |
| 自动求导与训练循环 | 所有 PyTorch 模型的通用模板 |
2. CNN 与图像处理
前面我们学习了全连接网络如何通过反向传播"学习",但全连接网络处理图像有两个致命问题:参数爆炸和无法利用图像的空间结构。本节将介绍卷积神经网络(CNN),它通过局部感知和参数共享完美解决这两个问题。
2.1 卷积神经网络原理
为什么全连接网络处理图像效果不好?
问题:参数爆炸
一张 $224 \times 224$ 的彩色图片 = $224 \times 224 \times 3 = 150{,}528$ 个像素。如果用全连接层,第一个隐藏层有 1000 个神经元,需要 $150{,}528 \times 1000 = 1.5$ 亿个参数!
问题:
- 计算量巨大(1.5 亿次乘法,每张图片!)
- 显存不够(1.5 亿个 float32 = 600MB ,仅第一层!)
- 容易过拟合(参数太多,模型容易"死记硬背"图片)
核心洞察:图像有两大特性,全连接网络没有利用
- 局部性(Locality):一个像素主要和它周围的像素相关,和远处的像素关系不大 → 不需要每个神经元连接所有 150,528 个输入!
- 平移不变性(Translation Invariance):猫在图片左上角和右下角,识别方法是一样的 → 同一个特征检测器应该能用在图片的任何位置!
卷积操作——局部感知 + 参数共享
卷积核是什么? 一个小的权重矩阵(比如 3×3),像一个"滑动窗口"在图片上滑动。
动手计算:卷积操作的完整数值示例
假设一张 $5 \times 5$ 的输入图和一个 $3 \times 3$ 的卷积核(步长为1,无填充):
输入图:
$$ \begin{bmatrix} 1 & 1 & 1 & 0 & 0 \\ 0 & 1 & 1 & 1 & 0 \\ 0 & 0 & 1 & 1 & 1 \\ 0 & 0 & 1 & 1 & 0 \\ 0 & 1 & 1 & 0 & 0 \end{bmatrix} $$卷积核(假设在检测"对角线"特征):
$$ \begin{bmatrix} 1 & 0 & 1 \\ 0 & 1 & 0 \\ 1 & 0 & 1 \end{bmatrix} $$第1步:左上角位置(滑动窗口在左上角)
$$ \begin{bmatrix} 1 & 1 & 1 \\ 0 & 1 & 1 \\ 0 & 0 & 1 \end{bmatrix} \odot \begin{bmatrix} 1 & 0 & 1 \\ 0 & 1 & 0 \\ 1 & 0 & 1 \end{bmatrix} = 1 \times 1 + 1 \times 0 + 1 \times 1 + 0 \times 0 + 1 \times 1 + 1 \times 0 + 0 \times 1 + 0 \times 0 + 1 \times 1 = 4 $$第2步:向右滑动一格
$$ \begin{bmatrix} 1 & 1 & 0 \\ 1 & 1 & 1 \\ 0 & 1 & 1 \end{bmatrix} \odot \begin{bmatrix} 1 & 0 & 1 \\ 0 & 1 & 0 \\ 1 & 0 & 1 \end{bmatrix} = 1+0+0+0+1+0+0+0+1 = 3 $$继续滑动,得到完整的 $3 \times 3$ 输出特征图:
$$ \begin{bmatrix} 4 & 3 & 4 \\ 2 & 4 & 3 \\ 2 & 3 & 4 \end{bmatrix} $$解读:输出图中值越大(如4),说明该位置的"对角线"特征越明显。这就是卷积核在"扫描"图片——同一个核在所有位置滑动,检测同一个特征。整个过程只用了9个参数,而全连接层需要 $25 \times 9 = 225$ 个参数!
卷积的三大优势:
- 局部感知:每个输出只看 $3 \times 3$ 的局部区域(不是全部 150,528 个像素) → 参数量:$3 \times 3 = 9$ 个(vs 全连接的 150,528 个!)
- 参数共享:同一个卷积核用在图片的所有位置 → 不管图片多大,参数量都是 $3 \times 3 = 9$ 个
- 平移不变性:因为参数共享,猫在任何位置都能被同一个卷积核检测到
类比:卷积核就像一个"手电筒",你在黑暗中用手电筒照亮图片的一小块区域,检查那里有没有你想要的特征(比如边缘),然后移动手电筒到下一个位置,重复检查。
卷积神经网络的核心组件
各层的作用详解:
- 卷积层(Conv2d):用多个不同的卷积核提取不同的特征。第 1 个卷积核可能检测"竖线",第 2 个可能检测"横线",第 3 个可能检测"斜线"。输出叫"特征图"(Feature Map),每个通道对应一个卷积核的检测结果。
- 激活函数(ReLU):在每个卷积层后面加一个非线性变换,让网络能够学习更复杂的模式。
- 池化层(MaxPool):把特征图缩小(比如 2×2 的 MaxPool 把尺寸减半),取每个 2×2 区域的最大值。作用是减少计算量,增强平移不变性。
- 全连接层(Linear):把最后的特征图展平成一维向量,做最终的分类决策。
CNN 核心组件功能表
| 组件 | 输入到输出 | 参数量 | 作用 |
|---|---|---|---|
| Conv2d(1到32, 3x3) | (1,28,28) 到 (32,28,28) | 32x(9+1)=320 | 提取低级特征(边缘、纹理) |
| MaxPool(2x2) | (32,28,28) 到 (32,14,14) | 0 | 降维,增强平移不变性 |
| Conv2d(32到64, 3x3) | (32,14,14) 到 (64,14,14) | 64x(288+1)=18,496 | 提取高级特征(形状、部件) |
| MaxPool(2x2) | (64,14,14) 到 (64,7,7) | 0 | 再次降维 |
| Linear(3136到128) | (3136) 到 (128) | 401,536 | 综合特征,做分类决策 |
| Linear(128到10) | (128) 到 (10) | 1,290 | 输出 10 个类别的概率 |
一个 CNN 的完整数据流(以 28×28 灰度图为例):
- 输入:(1, 28, 28) ← 1 通道, 28×28 像素
- ↓ Conv2d(1→32, 3×3) → (32, 28, 28) ← 32 个特征图
- ↓ MaxPool(2×2) → (32, 14, 14) ← 尺寸减半
- ↓ Conv2d(32→64, 3×3) → (64, 14, 14)
- ↓ MaxPool(2×2) → (64, 7, 7)
- ↓ Flatten → (3136)
- ↓ Linear(3136→128) → (128)
- ↓ Linear(128→10) → (10) ← 输出 10 个类别的概率
flowchart LR
A["Input\n(1,28,28)"] --> B["Conv2d\n1→32\n(32,28,28)"]
B --> C["MaxPool\n(32,14,14)"]
C --> D["Conv2d\n32→64\n(64,14,14)"]
D --> E["MaxPool\n(64,7,7)"]
E --> F["Flatten\n(3136)"]
F --> G["FC\n(128)"]
G --> H["Output\n(10 classes)"]
经典 CNN 模型演进——从浅到深的进化
| 模型 | 年份 | 层数 | 关键创新 | ImageNet 错误率 |
|---|---|---|---|---|
| LeNet-5 | 1998 | 5 层 | 首个成功的 CNN | (手写数字) |
| AlexNet | 2012 | 8 层 | ReLU + Dropout + GPU | 16.4% |
| VGG-16 | 2014 | 16 层 | 小卷积核堆叠(3×3) | 7.3% |
| GoogLeNet | 2014 | 22 层 | Inception 模块 | 6.7% |
| ResNet-152 | 2015 | 152 层 | 残差连接 | 3.6% (超越人类!) |
ResNet 的核心创新——残差连接
问题:层数太深反而效果变差(不是过拟合,而是训练困难——梯度消失/爆炸)
解决:残差连接(Skip Connection)
$$ \text{传统层:} y = F(x) \quad \text{(网络需要从零学习 } F(x) = y \text{)} $$$$ \text{残差层:} y = F(x) + x \quad \text{(网络只需要学习"改进量" } F(x) = y - x \text{)} $$类比:传统层像"从白纸画一幅画"——很难;残差层像"在原图上做修改"——容易得多!
为什么残差连接能解决梯度消失?
反向传播时:
$$ \text{传统层:} \frac{\partial L}{\partial x} = \frac{\partial L}{\partial y} \cdot \frac{\partial F}{\partial x} \quad \text{(梯度经过F,可能消失)} $$$$ \text{残差层:} \frac{\partial L}{\partial x} = \frac{\partial L}{\partial y} \cdot \left(\frac{\partial F}{\partial x} + 1\right) \quad \text{(多了一个+1的"梯度高速公路")} $$即使 $\frac{\partial F}{\partial x}$ 接近 0 ,梯度仍然可以通过"+1"这条路径直接传回去!这就是为什么 ResNet 可以训练 152 层甚至更深的网络。
残差连接的代价:额外的加法操作(计算量可忽略);训练时需要保存前向传播的 activation 用于反向传播(显存增加);假设 $F(x)$ 学习的是"残差"而非完整映射——如果任务本身不需要残差(如浅层网络),残差连接反而可能引入噪声。
2.2 PyTorch 实现 CNN
import torch.nn as nn
class SimpleCNN(nn.Module):
def __init__(self):
super(SimpleCNN, self).__init__()
# 卷积部分:提取特征
self.features = nn.Sequential(
nn.Conv2d(1, 32, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2),
nn.Conv2d(32, 64, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2),
)
# 分类部分:全连接层
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Linear(64 * 7 * 7, 128),
nn.ReLU(),
nn.Linear(128, 10),
)
def forward(self, x):
x = self.features(x)
x = self.classifier(x)
return x
# 使用
model = SimpleCNN()
input_img = torch.randn(1, 1, 28, 28) # 1张28×28灰度图
output = model(input_img) # shape: (1, 10)
完整训练示例:MNIST 手写数字识别
把前面学过的模型定义、数据加载、训练循环串联起来,完成一个端到端的图像分类任务。其中 DataLoader、transforms 等数据加载工具将在 2.3 节详细介绍,这里先通过代码注释理解其作用。
import torch
import torch.nn as nn
import torch.optim as optim
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
# 1. 数据准备
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,)) # MNIST的全局均值和标准差
])
train_dataset = datasets.MNIST('./data', train=True, download=True, transform=transform)
test_dataset = datasets.MNIST('./data', train=False, transform=transform)
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=1000, shuffle=False)
# 2. 模型定义(复用上面的SimpleCNN)
model = SimpleCNN()
# 3. 损失函数和优化器
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
# 4. 训练循环
for epoch in range(5):
model.train()
total_loss = 0
for batch_idx, (data, target) in enumerate(train_loader):
optimizer.zero_grad()
output = model(data)
loss = criterion(output, target)
loss.backward()
optimizer.step()
total_loss += loss.item()
avg_loss = total_loss / len(train_loader)
print(f'Epoch {epoch+1}/5, 平均损失: {avg_loss:.4f}')
# 5. 测试评估
model.eval()
correct = 0
total = 0
with torch.no_grad():
for data, target in test_loader:
output = model(data)
pred = output.argmax(dim=1)
correct += (pred == target).sum().item()
total += len(target)
accuracy = 100. * correct / total
print(f'\n测试准确率: {accuracy:.2f}%')
# 预期输出:测试准确率: ~99%
关键点:5个epoch就能达到约99%的准确率,因为CNN的卷积层天然适合图像——局部特征提取(卷积)+ 位置不变性(池化)+ 全局决策(全连接)。
2.3 数据加载——Dataset 与 DataLoader
为什么需要 Dataset 和 DataLoader ?
问题:训练集可能有几百万张图片,不可能一次性全部加载到内存(RAM 不够)。
解决方案:
- Dataset:定义"数据在哪里,如何获取第 i 个样本"。类比:菜谱(告诉厨师每道菜怎么做)。
- DataLoader:定义"如何把多个样本打包成一个 batch",并负责多进程加载。类比:传菜员(把多道菜一起端上来)。
from torch.utils.data import Dataset, DataLoader
class MyDataset(Dataset):
def __init__(self, data, labels):
self.data = data
self.labels = labels
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
return self.data[idx], self.labels[idx]
dataset = MyDataset(data, labels)
dataloader = DataLoader(dataset, batch_size=32, shuffle=True, num_workers=4)
for batch_data, batch_labels in dataloader:
predictions = model(batch_data)
loss = criterion(predictions, batch_labels)
# ...
2.4 混合精度训练
什么是混合精度训练?
类比:用计算器的精度选择
- float32(32 位浮点数)= 高精度计算器 → 结果精确,但计算慢、占内存大
- float16(16 位浮点数)= 低精度计算器 → 结果略有误差,但计算快、占内存小
- 混合精度 = 大部分计算用低精度(快),关键计算用高精度(准)
为什么能工作? 神经网络训练中, 95% 的计算是矩阵乘法 → 用 float16 足够精确; 5% 的关键操作(损失计算、梯度累积)→ 保留 float32 。
数值对比——float32 vs float16 vs 混合精度:
以一个 7B 参数模型(如 LLaMA-7B)的训练为例:
| 指标 | float32 | float16 | 混合精度(AMP) |
|---|---|---|---|
| 每个参数占用 | 4 bytes | 2 bytes | ~2-3 bytes(平均) |
| 模型参数显存 | 28 GB | 14 GB | ~18 GB |
| 梯度显存 | 28 GB | 14 GB | 14 GB |
| 优化器状态(Adam) | 56 GB | 28 GB | 56 GB(保持 float32) |
| 总显存需求 | ~112 GB | ~56 GB | ~88 GB |
| 训练速度 | 1x(基线) | 2-3x(但可能数值溢出) | 2-3x(安全) |
| 精度 | 最高 | 可能出现 loss 溢出/下溢 | 与 float32 持平 |
关键洞察:纯 float16 训练会出现梯度下溢(小梯度变为 0)和损失溢出(大数值变为 inf),导致训练不稳定。混合精度通过"关键操作用 float32 + 缩放因子放大梯度"巧妙地避免了这两个问题,同时享受 float16 的速度优势。
from torch.cuda.amp import autocast, GradScaler
scaler = GradScaler()
for batch in dataloader:
optimizer.zero_grad()
with autocast(): # 自动选择精度
output = model(batch)
loss = criterion(output, target)
scaler.scale(loss).backward() # 缩放损失后反向传播
scaler.step(optimizer) # 更新参数
scaler.update() # 更新缩放因子
2.5 深度学习进阶——正则化与优化技巧
Dropout——训练时随机"丢弃"神经元
self.dropout = nn.Dropout(p=0.5) # 训练时50%的神经元会被随机置零
类比:期末考试随机缺席。想象一个班级有 20 个学生,期末考试时随机让一半学生缺席。这迫使每个学生都必须自己学会知识,不能依赖抄别人的答案。同样, Dropout 迫使每个神经元独立学习有用的特征,不能依赖其他神经元。
效果:减少"协同适应"(co-adaptation)。推理时不用 Dropout ,但所有权重乘以 $(1-p)$ 来补偿。
Dropout 的代价:训练时间增加(每次前向传播都是不同的子网络);推理时需要缩放权重(或训练时用 inverted Dropout);与 BatchNorm 同时使用时可能产生冲突(两者对数值分布的假设不同)。
BatchNorm——加速训练的"万金油"
self.bn = nn.BatchNorm1d(256) # 对256维的特征做归一化
BatchNorm 在做什么?
对每个 mini-batch 的数据做标准化:
- 计算这个 batch 的均值 $\mu$ 和方差 $\sigma^2$
- 标准化:$\hat{x} = (x - \mu) / \sqrt{\sigma^2 + \varepsilon}$
- 缩放和平移:$y = \gamma \cdot \hat{x} + \beta$($\gamma$ 和 $\beta$ 是可学习的参数)
为什么要缩放和平移?因为标准化后数据分布被强制为 $N(0,1)$,可能损失有用信息。$\gamma$ 和 $\beta$ 让网络自己学到"最优的分布"。
为什么 BatchNorm 有效?
原始论文认为 BatchNorm 通过"减少内部协变量偏移"(Internal Covariate Shift)起作用——即让每层输入的分布稳定。但后来的研究(Santurkar et al., 2018)指出,BatchNorm 真正的作用是平滑了损失函数的曲面,让梯度更加稳定、步长选择更容易。实际效果有:
- 允许更大学习率:损失曲面更平滑,大学习率也不会导致梯度爆炸 → 训练速度更快
- 减轻对初始化的敏感性:即使初始权重不太好,BatchNorm 也能帮忙修正
- 轻微正则化:因为每个 batch 的均值和方差有随机性,相当于加了噪声
无论根本原因是什么,BatchNorm 的实际效果是明确的:训练更快、更稳定、对超参数更不敏感。
BatchNorm 的工程取舍——问题 → 方案 → 代价
| 维度 | 分析 |
|---|---|
| 问题 | 深层网络中,每层输入的分布随前面层参数更新而不断变化,导致后续层需要不断重新适应,训练难以稳定;损失曲面在某些方向极陡、某些方向极平,导致学习率选择困难 |
| 方案 | 对每个 mini-batch 做标准化,再用可学习的 $\gamma$、$\beta$ 恢复表达能力;效果是平滑损失曲面,允许使用更大的学习率加速收敛,同时减轻对初始化的依赖 |
| 代价 | 引入额外计算(标准化 + 缩放平移),训练和推理时行为不同(训练用 batch 统计量,推理用全局统计量,需维护 running mean/var);batch size 很小时统计量不稳定;不适合变长序列(此时用 LayerNorm) |
数值直觉:假设某隐藏层 256 维特征的均值=5.0、方差=10.0,BatchNorm 将其标准化为均值=0、方差=1,使所有特征处于同一量级,避免某几维数值过大主导梯度方向。
学习率调度——动态调整学习率
训练初期需要大步前进(大学习率),后期需要精细调整(小学习率)。手动调太麻烦,PyTorch 提供了自动调度器。
# 方式1:StepLR——每隔固定步数衰减学习率
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5)
# 每10个epoch,学习率乘以0.5
# epoch 1-10: lr=0.01, epoch 11-20: lr=0.005, epoch 21-30: lr=0.0025
# 方式2:CosineAnnealingLR——余弦退火,学习率平滑下降
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50)
# 学习率按余弦曲线从初始值平滑降到接近0,适合需要精细调优的场景
# 方式3:CosineAnnealingWarmRestarts——带热重启的余弦退火
scheduler = torch.optim.lr_scheduler.CosineAnnealingWarmRestarts(optimizer, T_0=10, T_mult=2)
# 每次重启周期翻倍:10 → 20 → 40 → ...,在每个周期内学习率从大到小
# 重启时跳出局部最优,寻找更好的解
训练循环中使用:
for epoch in range(num_epochs):
train_one_epoch()
scheduler.step() # 每个epoch结束后更新学习率
类比:登山找路。刚出发时大步走(高学习率),到了山区变小步(中等学习率),接近山顶时小心翼翼(低学习率)。CosineAnnealingWarmRestarts 相当于"走累了回到营地休息一下,然后重新出发"——可能发现之前忽略的更好路径。
如何选择?
- 分类任务 + 固定训练轮数 → StepLR(简单可靠)
- 需要精细调优 → CosineAnnealingLR(平滑下降)
- 容易陷入局部最优 → CosineAnnealingWarmRestarts(重启探索)
正则化与优化技巧对比
| 技巧 | 原理 | 训练时 | 推理时 | 典型设置 |
|---|---|---|---|---|
| Dropout | 随机丢弃神经元 | 随机置零(p=0.5) | 关闭,权重乘(1-p) | 全连接层后 |
| BatchNorm | 标准化每层输入 | 用 batch 统计量 | 用全局统计量 | 卷积层后 |
| 权重衰减 | L2 正则化 | 惩罚大权重 | 同训练 | optimizer 中设置 |
| 梯度裁剪 | 限制梯度范数 | 防止梯度爆炸 | 不需要 | RNN 中常用 |
| 数据增强 | 变换训练数据 | 随机变换 | 不需要 | 图像任务必备 |
| 学习率调度 | 动态调整学习率 | 按策略衰减 | 不需要 | 训练后期使用 |
模块小结: CNN 与图像处理
| 你学到了什么 | 为什么重要 |
|---|---|
| 卷积操作原理 | 局部感知 + 参数共享 |
| CNN 核心组件 | 卷积层→激活→池化→全连接 |
| ResNet 残差连接 | 解决深层网络的梯度消失 |
| Dataset/DataLoader | 高效加载大规模数据 |
| 混合精度训练 | 节省显存、加速训练 |
| 学习率调度 | 训练后期精细调整,提高收敛质量 |
3. RNN 与序列模型
CNN 擅长处理图像(空间数据),但面对文本、语音这类序列数据就力不从心了——它无法建模"顺序"和"长距离依赖"。本节将介绍循环神经网络(RNN)及其改进版本 LSTM、GRU,它们专门为序列数据设计。
3.1 序列数据与文本预处理
什么是序列数据?
核心特征:顺序很重要,当前值依赖于之前的值
- 文本:“我 爱 大 模 型” → “大 模 型 爱 我” 意思完全不同!
- 股票:[100, 102, 101, 105] → 今天的股价和昨天的股价相关
- 音频:[0.1, 0.3, 0.5, …] → 声音是随时间变化的信号
全连接网络的问题:它把输入当作独立的,不考虑顺序。 CNN 的问题:它只看局部窗口,不能建模长距离依赖。→ 需要一种新的网络结构来处理序列数据 → RNN 。
文本预处理流程
- 分词(Tokenize):“I love AI” → [“I”, “love”, “AI”]
- 建立词表(Vocabulary):
{"I": 0, "love": 1, "AI": 2, "<PAD>": 3, "<UNK>": 4},词表把每个词映射到一个唯一的整数 ID - 转为数字序列:[“I”, “love”, “AI”] → [0, 1, 2]
- 填充/截断(Padding):不同句子长度不同,需要统一长度。短的用
<PAD>填充,长的截断。[0, 1, 2] → [0, 1, 2, 3, 3](填充到长度 5) - 送入模型:模型的输入是数字序列 [0, 1, 2, 3, 3]
3.2 RNN 原理与实现
为什么需要 RNN ?
类比:读书。你读一本书时,理解当前这句话需要记住之前的内容。 RNN 做的同样的事——用"隐藏状态"记住之前的信息,辅助理解当前的输入。
$$ \begin{aligned} h_1 &= \tanh(W_h \cdot h_0 + W_x \cdot x_1 + b) \\ h_2 &= \tanh(W_h \cdot h_1 + W_x \cdot x_2 + b) \\ h_3 &= \tanh(W_h \cdot h_2 + W_x \cdot x_3 + b) \end{aligned} $$- $h_1$:读第 1 个词,产生记忆
- $h_2$:读第 2 个词,结合记忆 $h_1$ 产生 $h_2$
- $h_3$:读第 3 个词,结合记忆 $h_2$ 产生 $h_3$
$h_3$ 包含了前 3 个词的信息!
关键点:每个时间步使用相同的权重($W_h, W_x$)——这就是"参数共享"。不管句子有多长,参数量是固定的。同一个 RNN Cell 可以处理任意长度的序列。
RNN 的展开形式——理解 RNN 的关键
虽然 RNN 看起来只有一个 Cell ,但展开后就像一个很深的网络:
- $x_1$ → [RNN Cell] → $h_1$
- $x_2$ → [RNN Cell] → $h_2$(共享权重)
- $x_3$ → [RNN Cell] → $h_3$(共享权重)
每个 Cell 共享同一组权重,但每个时间步有不同的输入和输出。
RNN 的致命缺陷——梯度消失(用数字说明)
反向传播时,梯度需要从 $h_T$ 一路传回 $h_1$:
$$ \frac{\partial L}{\partial h_1} = \frac{\partial L}{\partial h_T} \cdot \frac{\partial h_T}{\partial h_{T-1}} \cdot \frac{\partial h_{T-1}}{\partial h_{T-2}} \cdots \frac{\partial h_2}{\partial h_1} $$问题:每一步的 $\frac{\partial h_t}{\partial h_{t-1}}$ 的最大值约等于 $W_h$ 的谱范数。如果这个值 < 1 (比如 0.9):
- $0.9^{10} \approx 0.35$(10 步后梯度衰减到 35%)
- $0.9^{50} \approx 0.005$(50 步后梯度衰减到 0.5% !)
- $0.9^{100} \approx 0.00003$(100 步后梯度几乎为 0 !)
结果:远处的信息无法影响当前的参数更新。 RNN 只能"记住"最近几个词的信息 → RNN 学不到长距离依赖!
类比:传话游戏。 10 个人排成一排传话。第 1 个人说"明天下午 3 点开会",传到第 10 个人可能变成"后天中午吃饭"。信息在传递过程中逐级失真——这就是梯度消失的形象比喻。
动手计算:RNN 梯度消失的数值演示
假设一个极简 RNN:$h_t = \tanh(W_h \cdot h_{t-1})$,其中 $W_h = 0.5$(标量简化),初始隐藏状态 $h_0 = 1.0$。
前向传播(4步):
| 时间步 | 计算 | $h_t$ |
|---|---|---|
| $t=0$ | 初始状态 | 1.0 |
| $t=1$ | $\tanh(0.5 \times 1.0) = \tanh(0.5)$ | 0.462 |
| $t=2$ | $\tanh(0.5 \times 0.462) = \tanh(0.231)$ | 0.227 |
| $t=3$ | $\tanh(0.5 \times 0.227) = \tanh(0.114)$ | 0.113 |
反向传播:假设损失 $L$ 对 $h_3$ 的梯度为 1.0,需要计算 $\frac{\partial L}{\partial h_0}$。
$$ \frac{\partial h_t}{\partial h_{t-1}} = W_h \cdot \tanh'(W_h \cdot h_{t-1}) = W_h \cdot (1 - h_t^2) $$| 传播步 | $\tanh'$ 值 | 梯度乘数 | 累积梯度 |
|---|---|---|---|
| $h_3 \to h_2$ | $1 - 0.113^2 = 0.987$ | $0.5 \times 0.987 = 0.494$ | 0.494 |
| $h_2 \to h_1$ | $1 - 0.227^2 = 0.949$ | $0.5 \times 0.949 = 0.474$ | $0.494 \times 0.474 = 0.234$ |
| $h_1 \to h_0$ | $1 - 0.462^2 = 0.787$ | $0.5 \times 0.787 = 0.394$ | $0.234 \times 0.394 = 0.092$ |
解读:从 $h_3$ 到 $h_0$ 只经过3步,梯度就从 1.0 衰减到 0.092(衰减约 91%)。原因是:(1) $W_h = 0.5$ 每步都缩小一半;(2) $\tanh'$ 在极端值时接近 0,进一步压缩梯度。实际 RNN 序列可能有几百甚至几千步,梯度衰减更加严重——远处的信息完全无法影响当前的参数更新。
LSTM 和 GRU 的解决方案:给信息一条"直达通道"(细胞状态),不让它被逐级衰减。
3.3 LSTM——解决长期依赖的"神器"
LSTM 的核心思想——细胞状态 + 三个门
类比:你的笔记本
想象你有一个笔记本(细胞状态),每天要做三件事:
- 擦掉不再重要的旧笔记(遗忘门)
- 写入今天重要的新信息(输入门)
- 决定今天给老板看哪些内容(输出门)
细胞状态 $C_t$:一条"信息高速公路",信息可以无损地流过。
三个门的公式:
$$ \begin{aligned} \text{遗忘门:} \quad f_t &= \sigma(W_f \cdot [h_{t-1}, x_t] + b_f) \quad \text{(0到1的值)} \\ \text{输入门:} \quad i_t &= \sigma(W_i \cdot [h_{t-1}, x_t] + b_i) \quad \text{(0到1的值)} \\ \text{候选值:} \quad \tilde{C}_t &= \tanh(W_C \cdot [h_{t-1}, x_t] + b_C) \quad \text{(-1到1的值)} \\ \text{输出门:} \quad o_t &= \sigma(W_o \cdot [h_{t-1}, x_t] + b_o) \quad \text{(0到1的值)} \end{aligned} $$更新公式:
$$ C_t = f_t \odot C_{t-1} + i_t \odot \tilde{C}_t \quad \text{(关键!是加法,不是乘法)} $$$$ h_t = o_t \odot \tanh(C_t) $$概念性示例:
假设处理句子"The cat, which is very cute, sat on the mat"。当处理到"sat"时,需要知道主语是"cat"(中间隔了 4 个词)。
- 遗忘门:看到"sat"时,$f_t \approx [1, 1, 0.01, \ldots]$ → 保留"cat"的信息,丢弃"which is very cute"的细节
- 输入门:看到"sat"是一个动词,$i_t \approx [0, 0, 0.9, \ldots]$ → 写入"sat"的信息
- 结果:细胞状态中同时保存了"cat"(很久之前的信息)和"sat"(刚看到的信息)→ LSTM 知道"cat sat on the mat",理解了主谓关系
动手计算:LSTM 门控机制的数值演示
为了真正理解 LSTM 的三个门如何工作,我们用具体数字走一遍完整流程。假设一个标量简化版本(每个门只有1维):
初始状态:$C_{t-1} = 0.5$(旧记忆),$h_{t-1} = 0.3$(旧隐藏状态),$x_t = 0.8$(当前输入)
第1步:计算三个门(假设权重已经训练好)
$$ \begin{aligned} f_t &= \sigma(1.0 \times 0.3 + 0.5 \times 0.8 + 0.0) = \sigma(0.7) = 0.67 \quad \text{(遗忘门:保留67%旧记忆)} \\ i_t &= \sigma(0.5 \times 0.3 + 1.0 \times 0.8 - 0.5) = \sigma(0.45) = 0.61 \quad \text{(输入门:写入61%新信息)} \\ \tilde{C}_t &= \tanh(0.8 \times 0.3 + 0.6 \times 0.8 - 0.2) = \tanh(0.52) = 0.48 \quad \text{(候选值)} \\ o_t &= \sigma(0.7 \times 0.3 + 0.3 \times 0.8 - 0.1) = \sigma(0.35) = 0.59 \quad \text{(输出门)} \end{aligned} $$第2步:更新细胞状态
$$ C_t = f_t \odot C_{t-1} + i_t \odot \tilde{C}_t = 0.67 \times 0.5 + 0.61 \times 0.48 = 0.335 + 0.293 = 0.628 $$解读:新细胞状态 $C_t = 0.628$ 由两部分组成:旧记忆贡献了 $0.335$(67%保留),新信息贡献了 $0.293$(61%写入)。这就是 LSTM 的"加法更新"——旧记忆不是被覆盖,而是被部分保留、部分更新。
第3步:计算输出
$$ h_t = o_t \odot \tanh(C_t) = 0.59 \times \tanh(0.628) = 0.59 \times 0.557 = 0.329 $$关键观察:如果遗忘门 $f_t = 1.0$(完全保留旧记忆),那么 $C_t = C_{t-1} + i_t \odot \tilde{C}_t$,旧信息完全无损传递——这就是梯度高速公路。反向传播时 $\frac{\partial C_t}{\partial C_{t-1}} = f_t = 1.0$,梯度不衰减!
为什么 LSTM 能解决梯度消失?
数学解释:
普通 RNN 的梯度链:$\frac{\partial h_t}{\partial h_{t-1}} = W \cdot \text{diag}(\sigma'(z))$ → 连乘,指数衰减。
LSTM 的细胞状态梯度:$\frac{\partial C_t}{\partial C_{t-1}} = f_t$(遗忘门的值)
当 $f_t \approx 1$ 时:$\frac{\partial C_t}{\partial C_{t-1}} \approx 1$ → 梯度无损传递!
这就是 LSTM 的"梯度高速公路"——只要遗忘门接近 1 ,信息就能无损地流过任意长的距离。
类比:传送带 vs 口口相传。普通 RNN 像"口口相传":信息逐级传递,每传一次就失真一点。 LSTM 像"传送带":信息放在传送带上直接送到目的地,不会失真。
3.4 GRU——LSTM 的"简化版"
GRU 的设计哲学:用更少的参数达到类似的效果
- LSTM(3 个门 + 候选细胞状态 → 4 个线性变换):遗忘门 $f_t$ + 输入门 $i_t$ + 输出门 $o_t$ + 候选值 $\tilde{C}_t$
- GRU(2 个门 → 3 个线性变换):重置门 $r_t$(类似"输入门"的一部分)+ 更新门 $z_t$(合并了遗忘门和输入门)
GRU 的公式:
$$ \begin{aligned} \text{重置门:} \quad r_t &= \sigma(W_r \cdot [h_{t-1}, x_t]) \\ \text{更新门:} \quad z_t &= \sigma(W_z \cdot [h_{t-1}, x_t]) \\ \text{候选状态:} \quad \tilde{h}_t &= \tanh(W \cdot [r_t \odot h_{t-1}, x_t]) \\ \text{最终状态:} \quad h_t &= (1 - z_t) \odot h_{t-1} + z_t \odot \tilde{h}_t \end{aligned} $$注意最后一行:$(1-z_t) \odot h_{t-1}$ 保留多少旧记忆(当 $z_t=0$ 时完全保留),$z_t \odot \tilde{h}_t$ 添加多少新信息(当 $z_t=1$ 时完全更新)。$z_t$ 同时控制了"遗忘"和"输入",这就是 GRU 比 LSTM 少一个门的原因。
动手计算:GRU 数值示例
设定:$h_{t-1} = [0.5]$,$x_t = [1.0]$,所有权重简化为 1,偏置为 0。
Step 1:重置门 $r_t = \sigma(W_r \cdot [h_{t-1}, x_t]) = \sigma([0.5, 1.0]) = \sigma(1.5) = 0.82$
Step 2:更新门 $z_t = \sigma(W_z \cdot [h_{t-1}, x_t]) = \sigma([0.5, 1.0]) = \sigma(1.5) = 0.82$
Step 3:候选状态 $\tilde{h}_t = \tanh(W \cdot [r_t \odot h_{t-1}, x_t]) = \tanh([0.82 \times 0.5, 1.0]) = \tanh([0.41, 1.0]) = 0.89$
Step 4:最终状态 $h_t = (1 - z_t) \odot h_{t-1} + z_t \odot \tilde{h}_t$
$h_t = (1-0.82) \times 0.5 + 0.82 \times 0.89 = 0.09 + 0.73 = 0.82$
解读:更新门 $z_t = 0.82$(接近 1),意味着 GRU 大部分采用新信息(候选状态),少量保留旧记忆。如果 $z_t$ 接近 0,则完全保留旧记忆——这就是 GRU 解决梯度消失的核心机制。
LSTM vs GRU 如何选择?
| 特性 | LSTM | GRU |
|---|---|---|
| 门的数量 | 3 个门 | 2 个门 |
| 参数量 | 更多 | 更少(约少 25%) |
| 训练速度 | 较慢 | 较快 |
| 长序列效果 | 略好 | 略差 |
| 数据量少时 | 可能过拟合 | 更好(参数少) |
实践建议:
- 先试 GRU (更快),效果不好再换 LSTM
- 如果序列很长(1000+步), LSTM 通常更好
- 如果数据量很少, GRU 更好(不容易过拟合)
3.5 深度/双向 RNN
深度 RNN——多层堆叠
类比 CNN 的层次化特征提取:
- CNN :边缘 → 纹理 → 形状 → 物体
- 深度 RNN :词法 → 语法 → 语义 → 情感
第 3 层:$h_{31} \to h_{32} \to h_{33} \to \cdots$ ← 学习最高层特征(语义、情感) 第 2 层:$h_{21} \to h_{22} \to h_{23} \to \cdots$ ← 学习中层特征(语法结构) 第 1 层:$h_{11} \to h_{12} \to h_{13} \to \cdots$ ← 学习底层特征(词法信息) 输入:$x_1, x_2, x_3$
每层的输出作为上一层的输入,层层抽象。
双向 RNN——同时看过去和未来
问题:有些任务需要同时理解前后文。示例:“我去银行存钱” vs “我在河岸散步”——“银行"的含义取决于后面的"存钱”。只看左边无法确定"银行"是金融机构还是河岸。
双向 RNN 的解决方案:
- 正向:$\vec{h}_1 \to \vec{h}_2 \to \vec{h}_3 \to \vec{h}_4$(从左到右阅读)
- 反向:$\overleftarrow{h}_1 \leftarrow \overleftarrow{h}_2 \leftarrow \overleftarrow{h}_3 \leftarrow \overleftarrow{h}_4$(从右到左阅读)
- 输出:$[\vec{h}_i, \overleftarrow{h}_i]$ — 每个位置同时包含"前文信息"和"后文信息"
应用场景:命名实体识别、文本分类、 BERT 的预训练。 不能用于:语言模型、文本生成(因为生成时看不到"未来"的词)。
序列模型演进对比
| 模型 | 年份 | 核心创新 | 解决的问题 | 局限 |
|---|---|---|---|---|
| RNN | 1986 | 隐藏状态传递 | 处理序列数据 | 梯度消失/爆炸 |
| LSTM | 1997 | 三门 + 细胞状态 | 长距离依赖 | 计算量大,串行 |
| GRU | 2014 | 二门简化设计 | 减少参数量 | 超长序列效果略差 |
| Transformer | 2017 | Self-Attention | 并行计算 + 长距离依赖 | O(n²) 复杂度 |
| BERT | 2018 | 双向 Encoder | 理解类任务 | 不能生成 |
| GPT | 2018 | 单向 Decoder | 生成类任务 | 只看前文 |
为什么 Transformer 能替代 RNN? 从上表可以看出,Transformer 的 Self-Attention 一举解决了 RNN 的两个核心问题:(1) 长距离依赖——注意力可以直接"看到"序列中任意位置,不需要像 RNN 那样逐级传递;(2) 并行计算——RNN 必须等前一步算完才能算下一步(串行),Transformer 可以一次性计算所有位置的注意力(并行),训练速度快得多。代价是 $O(n^2)$ 的计算复杂度(序列长度的平方),但对于大多数场景这个代价是可以接受的。详细的 Transformer 架构将在下一篇文章中介绍。
模块小结: RNN 与序列模型
| 你学到了什么 | 为什么重要 |
|---|---|
| RNN 原理与展开 | 处理序列数据的基础结构 |
| LSTM 三个门机制 | 解决长距离依赖问题 |
| GRU 简化设计 | 更少参数,类似效果 |
| 双向/深度 RNN | 同时看前后文,多层抽象 |
4. 词嵌入与 Seq2Seq
前面的 RNN 能处理序列数据,但它把每个词当作一个数字 ID,无法理解词义。本节将介绍词嵌入(让计算机"理解"词义)和 Seq2Seq 架构(把一个序列转换成另一个序列),以及催生 Transformer 的注意力机制。
4.1 Word2Vec——让计算机"理解"词义
Distributional Hypothesis (分布假说)——词嵌入的理论基础
核心思想:一个词的含义由它的上下文决定
语言学家 John Rupert Firth 在 1957 年提出:“You shall know a word by the company it keeps."(你可以通过一个词的"同伴"来认识它)
示例:
- “我养了一只__,它很可爱” → 空格处大概率是"猫"或"狗”
- “我开了一辆__去上班” → 空格处大概率是"车"
→ 如果两个词经常出现在相似的上下文中,它们的含义就相似。“猫"和"狗"的上下文相似(可爱、养、宠物)→ 它们的向量应该接近。
分布假说是所有词嵌入方法(Word2Vec 、 GloVe 、 FastText)的理论基础:
- Word2Vec:通过预测上下文来学习词义(隐式利用分布假说)
- GloVe:通过统计共现矩阵来学习词义(显式利用分布假说)
- FastText:通过子词的上下文来学习词义(分布假说的扩展)
类比:分布假说 = “物以类聚,人以群分”。经常在一起出现的词,含义相似。词嵌入就是把这种"相似性"用向量距离来表达。
分布假说的局限:
- 多义词问题:“苹果"在不同上下文中含义不同。 Word2Vec 给"苹果"一个固定的向量,无法区分。后来 ELMo 、 BERT 通过上下文相关的词向量解决了这个问题。
- 反义词问题:“好"和"坏"经常出现在相似的上下文中(“这个东西很__” → 好/坏都可能),但它们含义相反!分布假说无法区分反义词(这是词嵌入的已知局限)。
从独热编码到词嵌入
独热编码的问题:假设词表有 50,000 个词,“猫” = [1, 0, 0, …, 0],“狗” = [0, 1, 0, …, 0]。$\text{距离}(\text{猫}, \text{狗}) = \text{距离}(\text{猫}, \text{汽车}) = \sqrt{2}$。→ 无法表达"猫和狗更相似"这个事实!独热编码没有语义信息。
词嵌入的解决方案:用一个低维向量(比如 300 维)来表示每个词,语义相似的词有相似的向量。$\text{距离}(\text{猫}, \text{狗}) \ll \text{距离}(\text{猫}, \text{汽车})$。→ 词嵌入能表达语义关系!
Word2Vec 的两种模式
- CBOW (连续词袋)——用上下文预测中心词:输入:[我, , 大, 模型],目标:预测 = “爱”。类比:完形填空。
- Skip-gram——用中心词预测上下文:输入:爱,目标:预测[我, 大, 模型]。类比:看一个词,猜它周围会出现什么词。
Word2Vec 的训练过程(Skip-gram 简化版):
- 准备训练数据:从大量文本中提取(中心词, 上下文词)配对。句子"我爱大模型” → (“爱”, “我”), (“爱”, “大”), (“爱”, “模型”)
- 构建一个简单的神经网络:输入层 → 嵌入层 → 输出层 → softmax → 预测上下文词
- 训练这个网络:最大化 $P(\text{上下文词} | \text{中心词})$
- 训练完成后,嵌入层的权重就是词向量!每个词对应嵌入矩阵的一行 → 300 维向量
动手计算:Word2Vec 训练一步(Skip-gram,简化为 2 维嵌入)
词汇表:{我, 爱, 大, 模型},嵌入维度 = 2
Step 1:训练数据——(“爱”, “我”) 即中心词"爱”,上下文词"我”
Step 2:前向传播
- 输入:one-hot(“爱”) = [0, 1, 0, 0]
- 嵌入矩阵 $W_{emb}$(4×2):[[0.1, 0.3], [0.5, 0.8], [0.2, 0.6], [0.4, 0.1]]
- “爱"的嵌入:$[0, 1, 0, 0] \cdot W_{emb} = [0.5, 0.8]$
- 输出层:$[0.5, 0.8] \cdot W_{out}$(2×4)→ logits → softmax → 预测概率
Step 3:计算损失
真实标签:one-hot(“我”) = [1, 0, 0, 0]
假设 softmax 输出为 [0.6, 0.2, 0.1, 0.1]
损失:$L = -\log(0.6) = 0.51$
Step 4:反向传播更新 $W_{emb}$
梯度会调整"爱"的嵌入向量 $[0.5, 0.8]$,让模型下次更容易预测出"我”。
训练百万个(中心词, 上下文词)对后,语义相近的词(如"冰"和"水")的嵌入向量会自然接近。
Word2Vec 的经典发现——词向量的"魔法"
$$ \text{king} - \text{man} + \text{woman} \approx \text{queen} $$(国王的向量 - 男人的向量 + 女人的向量 ≈ 女王的向量)
这意味着词嵌入学到了:
- 性别关系: man→woman 类似于 king→queen
- 时态关系: walking→walked 类似于 swimming→swam
- 地理关系: Paris→France 类似于 Rome→Italy
这些关系完全是自动从文本中学到的,没有任何人工标注!
4.2 GloVe 、 FastText 与 ELMo
GloVe——全局统计 + 局部上下文
Word2Vec 只看局部上下文窗口(比如前后 5 个词), GloVe 利用全局共现矩阵(统计整个语料库中所有词对的共现次数)。
GloVe 的核心思想:如果词 i 和词 j 经常一起出现 → 它们的向量应该接近。“冰"和"水"经常共现 → 向量接近。“冰"和"蒸汽"的共现模式类似但有差异 → 向量差反映了"固态 vs 气态"的关系。效果:在类比任务上, GloVe 通常优于 Word2Vec 。
FastText——子词嵌入
Word2Vec/GloVe 的问题:每个词是一个整体,遇到没见过的词(OOV)就无法处理。
FastText 的解决方案:把词拆成字符 n-gram 。“where” → [”<wh”, “whe”, “her”, “ere”, “re>"]。“where"的向量 = 所有子词向量的和。
优势:
- 处理未登录词(OOV):即使没见过"unhappiness”,也能用"un”+“happi”+“ness"的子词向量组合
- 利用形态学信息:词根、前缀、后缀都有含义
- 对中文也有用:字级别的 n-gram 能捕获偏旁部首的信息
ELMo——上下文相关的词向量(BERT 的前身)
Word2Vec/GloVe/FastText 的共同问题:同一个词在所有语境下的向量都相同!
- “苹果很好吃"中的"苹果” = [0.2, 0.8, …](应该是"水果"的意思)
- “苹果发布新手机"中的"苹果” = [0.2, 0.8, …](应该是"公司"的意思)
- → 向量完全一样!这不合理。
ELMo 的解决方案:用双向 LSTM ,根据上下文动态生成词向量。
- “苹果很好吃” → 双向 LSTM → “苹果"的向量偏向"水果”
- “苹果发布新手机” → 双向 LSTM → “苹果"的向量偏向"公司”
核心思想:词的含义取决于上下文!这就是后来 BERT 的核心思想——上下文相关的词表示。
词嵌入方法对比
| 方法 | 核心思想 | 上下文相关? | OOV 处理 | 典型维度 |
|---|---|---|---|---|
| Word2Vec | 预测上下文/中心词 | 静态 | 无法处理 | 300 |
| GloVe | 全局共现矩阵分解 | 静态 | 无法处理 | 300 |
| FastText | 子词 n-gram | 静态 | 可用子词组合 | 300 |
| ELMo | 双向 LSTM | 动态 | 可处理 | 1024 |
| BERT | Transformer Encoder | 动态 | 子词分词 | 768/1024 |
4.3 Seq2Seq 与 Encoder-Decoder 架构
Seq2Seq——序列到序列
应用场景:
- 机器翻译:“I love AI” → “我爱人工智能”
- 文本摘要:“很长的文章…” → “一句话摘要”
- 对话系统:“你好吗?” → “我很好,谢谢!”
架构:
- Encoder(编码器):读取输入序列,压缩成一个固定长度的向量
- Decoder(解码器):从这个向量生成输出序列
“I” → [Encoder] → 向量 $c$ → [Decoder] → “我” “love” → [Encoder] → 向量 $c$ → [Decoder] → “爱” “AI” → [Encoder] → 向量 $c$ → [Decoder] → “人工智能”
Encoder 用 RNN/LSTM 逐词读入,最后一个隐藏状态就是"语义向量”。 Decoder 用另一个 RNN/LSTM 从语义向量逐词生成输出。
Seq2Seq 的问题——信息瓶颈
问题:整个输入序列被压缩成一个固定长度的向量(比如 256 维)。如果输入有 100 个词, 256 维要容纳 100 个词的全部信息 → 太拥挤了!
类比:把一本书的全部内容压缩成一句话 → 一定会丢失大量信息。后果:句子越长,翻译质量越差。
4.4 注意力机制——让模型学会"关注"
注意力的核心思想——不要压缩,要"关注"
类比:同声传译
- Seq2Seq 的做法:先把整本书读完记住,然后闭着眼睛翻译 → 信息量太大,记不住
- 注意力的做法:翻译每个词时,回头看原文的相关部分 → 每一步都"关注"最相关的部分
注意力的三步计算:
假设编码器输出了 4 个隐藏状态 $[h_1, h_2, h_3, h_4]$,解码器当前状态是 $s_t$。
Step 1:计算注意力分数(“每个位置和我有多相关?")
$$ \text{score}_i = s_t^T \cdot h_i \quad \text{(内积越大,越相关)} $$Step 2: Softmax 归一化(“把分数变成概率”)
$$ \text{attention\_weights} = \text{softmax}(\text{scores}) $$Step 3:加权求和(“按重要性混合信息”)
$$ \text{context} = \sum_i \text{attention\_weights}_i \cdot h_i = 0.1 \cdot h_1 + 0.8 \cdot h_2 + 0.05 \cdot h_3 + 0.05 \cdot h_4 $$主要信息来自 $h_2$(最相关的位置)。这个 context 向量就是"注意力的输出”——它聚合了编码器所有位置的信息,但重点关注了最相关的部分。
多头注意力(Multi-Head Attention)——从多个角度看
单头注意力:只用一种方式计算相关性。多头注意力:用多种方式并行计算不同类型的相关性。
类比:你和朋友看同一张照片。你关注颜色,朋友关注构图,另一个朋友关注内容。每个人从不同角度"关注"同一张照片,综合所有人的观察才能全面理解。
多头注意力同理:
- Head 1 可能学到语法关系(主语-谓语)
- Head 2 可能学到语义关系(同义词)
- Head 3 可能学到位置关系(相邻词)
所有头的结果拼接起来 → 全面的语义表示。
这就是 Transformer 的核心组件,也是 GPT 、 BERT 的基础。
动手计算:一个简单的注意力示例
上面介绍的是 Seq2Seq 中的交叉注意力(解码器查询编码器)。下面展示 Transformer 中的自注意力——Q、K、V 都来自同一个序列,这是 GPT 和 BERT 的核心机制。
假设输入句子 “我 爱 AI” 经过线性变换得到3组 Q、K、V 向量(为简化,用2维向量):
$$ h_1 = [1, 0] \quad h_2 = [0, 1] \quad h_3 = [1, 1] $$第1步:生成 Q、K、V(实际中通过线性变换,这里简化为直接赋值)
$$ Q = [1, 0], \quad K_1 = [1, 0], \quad K_2 = [0, 1], \quad K_3 = [1, 1] $$第2步:计算注意力分数(Q 与每个 K 做点积)
$$ \text{score}_1 = Q \cdot K_1 = 1 \times 1 + 0 \times 0 = 1 $$$$ \text{score}_2 = Q \cdot K_2 = 1 \times 0 + 0 \times 1 = 0 $$$$ \text{score}_3 = Q \cdot K_3 = 1 \times 1 + 0 \times 1 = 1 $$第3步:Softmax 归一化
$$ \text{weights} = \text{softmax}([1, 0, 1]) = [0.42, 0.16, 0.42] $$第4步:加权求和得到输出
$$ \text{output} = 0.42 \times [1,0] + 0.16 \times [0,1] + 0.42 \times [1,1] = [0.84, 0.58] $$解读:输出向量主要融合了 $h_1$(“我”)和 $h_3$(“AI”)的信息,$h_2$(“爱”)的权重较低。这说明模型在当前位置更关注"我"和"AI"——这正是注意力机制的价值:让模型自己学会关注哪里。
模块小结:词嵌入与 Seq2Seq
| 你学到了什么 | 为什么重要 |
|---|---|
| Word2Vec 原理 | 让计算机"理解"词义 |
| GloVe/FastText/ELMo | 从静态到动态词向量 |
| Seq2Seq 架构 | 序列到序列的生成范式 |
| 注意力机制 | Transformer 的核心前身 |
推荐补充资源
| 知识点 | 推荐资源 | 说明 |
|---|---|---|
| 神经网络 | 3Blue1Brown《神经网络》系列 | 最直观的神经网络可视化教程 |
| 反向传播 | Andrej Karpathy《Yes you should understand backprop》 | 反向传播的直觉讲解 |
| PyTorch | PyTorch 官方教程 | 跟着做一遍就能上手 |
| CNN | CS231n (斯坦福计算机视觉课程) | CNN 的经典课程 |
| RNN/LSTM | Chris Olah《Understanding LSTM》 | 图解 LSTM 的经典文章 |
| 注意力机制 | Jay Alammar《The Illustrated Seq2Seq》 | 图解 Seq2Seq 和注意力 |
| Word2Vec | Jay Alammar《Illustrated Word2Vec》 | 图解 Word2Vec |
下一篇预告:本文深入理解了神经网络、反向传播、CNN 和 RNN。在速通 AI(三):Transformer 架构详解中,你将看到 RNN 的两大缺陷如何催生了 Transformer——一种完全抛弃循环结构、只用注意力机制的新架构。