🎯 目标:掌握深度学习核心概念,熟练使用PyTorch框架进行模型开发,理解RNN/LSTM/GRU序列模型。 📋 前置要求:阶段一(Python基础、微积分、线性代数、机器学习基础)


本阶段知识依赖图

阶段一基础
    ├──→ 神经网络基础 ──→ 反向传播 ──→ 激活函数/正则化
    │         │
    │         ├──→ CNN(图像处理)──→ 经典CNN模型 ──→ 迁移学习
    │         │
    │         └──→ RNN(序列处理)──→ LSTM ──→ GRU
    │                                    │
    │                                    └──→ 深度/双向RNN
    └──→ PyTorch框架(贯穿始终)
              ├──→ 张量操作 ──→ 自动求导
              ├──→ 模型构建 ──→ 训练循环
              ├──→ 数据加载 ──→ Dataset/DataLoader
              └──→ 训练优化 ──→ 混合精度/学习率调度

模块一:神经网络与PyTorch基础

1.1 神经网络基础——从生物到数学

什么是神经网络?

类比:一个决策工厂

想象你要判断一张图片是否是猫。你的大脑会怎么做?

  1. 先识别边缘(这里有条线,那里有个弧形)
  2. 再组合成形状(这个弧形+那个三角形 = 耳朵?)
  3. 最后做出判断(有尖耳朵+胡须+毛茸茸 → 大概率是猫)

神经网络做的就是同样的事——分层提取特征,逐层抽象,最终做出判断

生物神经元:                     人工神经元:
    树突(输入信号)              输入 x₁, x₂, x₃
        ↓                              ↓
    细胞体(加权求和)            z = w₁x₁ + w₂x₂ + w₃x₃ + b
        ↓                              ↓
    轴突(激活判断)              a = activation(z)
        ↓                              ↓
    突触(输出信号)              输出 a

每个神经元在做什么? 两件事:

  1. 加权求和:把所有输入乘以各自的权重再加起来(“每个因素的重要程度不同”)
  2. 激活函数:对求和结果做一个非线性变换(“做出是否激活的决定”)

类比:一个神经元就像一个"评委"——它听取多方意见(输入),给每个意见不同的权重(重要程度),最后综合所有意见给出自己的评分(输出)。

神经网络的结构

输入层          隐藏层1         隐藏层2         输出层
  ○──────────────○──────────────○──────────────○
  ○──────────────○──────────────○──────────────○
  ○──────────────○──────────────○
  ○──────────────○
  
  3个输入        4个神经元       3个神经元       2个输出

前向传播(Forward Pass):数据从左到右流过网络,逐层计算

h₁ = activation(W₁ · x + b₁)     # 第一层:原始输入 → 低级特征
h₂ = activation(W₂ · h₁ + b₂)    # 第二层:低级特征 → 高级特征
y = W₃ · h₂ + b₃                 # 输出层:高级特征 → 最终预测

每一层在做什么?

第1层:看到像素 → 识别边缘("这里有条竖线")
第2层:看到边缘 → 识别形状("这个形状像耳朵")
第3层:看到形状 → 做出判断("有耳朵+胡须 → 是猫")

这就是"分层抽象"——每一层把上一层的输出当作输入,提取更高层次的特征。

为什么需要激活函数?——非线性的力量

如果没有激活函数会怎样?

没有激活函数:
h₁ = W₁·x + b₁      (线性变换)
h₂ = W₂·h₁ + b₂     (线性变换)
y = W₃·h₂ + b₃       (线性变换)

合并起来:
y = W₃·(W₂·(W₁·x + b₁) + b₂) + b₃
  = W·x + b            (还是一个线性变换!)

再多层也等于一层!因为线性变换的组合还是线性变换。

激活函数的作用——引入非线性

有了激活函数:
h₁ = σ(W₁·x + b₁)         (非线性变换)
h₂ = σ(W₂·h₁ + b₂)        (非线性变换)
y = W₃·h₂ + b₃

这时,两层网络 ≠ 一层网络!
因为非线性变换的组合可以逼近任意复杂的函数。

这就是"万能近似定理"(Universal Approximation Theorem):
一个有足够多神经元的单隐层网络,可以逼近任意连续函数。

常用激活函数对比

激活函数    公式                    特点                     使用场景
──────────────────────────────────────────────────────────────────────
Sigmoid     1/(1+e^(-x))           输出(0,1),有梯度消失问题   二分类输出层
Tanh        (e^x-e^(-x))/(e^x+e^(-x))  输出(-1,1),零中心化    RNN中常用
ReLU        max(0, x)              简单高效,无梯度消失        隐藏层首选
LeakyReLU   max(0.01x, x)          解决ReLU"死神经元"问题     ReLU的改进
GELU        x·Φ(x)                 平滑版ReLU                 Transformer中常用
Swish       x·σ(x)                 自门控,平滑               LLaMA中使用

ReLU为什么成为主流?

Sigmoid的问题:
σ'(x) = σ(x)·(1-σ(x))
当x很大或很小时,σ'(x) ≈ 0 → 梯度消失 → 网络学不动

ReLU的优势:
ReLU'(x) = 1(当x>0时)或 0(当x≤0时)
当x>0时,梯度恒为1 → 不会消失!
计算极其简单(就是一个max操作)

类比:
Sigmoid像一个"渐变开关"——输入越大越开,但永远不会完全开
ReLU像一个"硬开关"——要么全开(x>0),要么全关(x≤0)
硬开关虽然粗糙,但胜在简单高效。

1.2 反向传播——让网络"学习"的魔法

核心问题:如何更新权重?

我们有了损失函数L(衡量预测和真实值的差距),目标是找到让L最小的权重。方法就是梯度下降

w_new = w_old - η · ∂L/∂w

关键是如何计算∂L/∂w?这就是反向传播要解决的问题。

类比:工厂流水线的责任追溯

想象一个工厂流水线生产了一个次品(损失L很大):

原材料 → 工序1 → 工序2 → 工序3 → 次品

老板想知道"谁的责任最大",以便调整每个工序:

  • 反向传播就是从"次品"开始,逆向追溯每个工序的"责任"
  • 每个工序的"责任" = 它对最终误差的贡献度 = 梯度

反向传播的完整推导(以2层网络为例)

网络结构

输入x → [线性层1] → z₁ = W₁x + b₁ → [激活] → a₁ = σ(z₁) → [线性层2] → z₂ = W₂a₁ + b₂ → [损失] → L

前向传播(已知):

z₁ = W₁·x + b₁
a₁ = σ(z₁)
z₂ = W₂·a₁ + b₂
L = ½(y - z₂)²    (均方误差)

反向传播(从右往左,逐步计算梯度):

Step 1:L对z₂的梯度

∂L/∂z₂ = ∂/∂z₂ [½(y - z₂)²] = -(y - z₂) = z₂ - y

直觉:预测值和真实值的差距越大,梯度越大 → 需要调整的力度越大

Step 2:L对W₂和b₂的梯度

∂L/∂W₂ = ∂L/∂z₂ · ∂z₂/∂W₂ = (z₂ - y) · a₁^T

∂L/∂b₂ = ∂L/∂z₂ · ∂z₂/∂b₂ = (z₂ - y)

直觉:W₂的梯度 = 误差信号(z₂-y) × 输入信号(a₁)
      如果a₁很大,说明这个权重"参与度高",需要调整更多

Step 3:L对a₁的梯度(误差从第2层传回第1层)

∂L/∂a₁ = ∂L/∂z₂ · ∂z₂/∂a₁ = W₂^T · (z₂ - y)

直觉:第2层的误差"按权重比例"分配给第1层的每个神经元
      权重越大,分配到的误差越多 → "谁的影响力大,谁的责任就大"

Step 4:L对z₁的梯度

∂L/∂z₁ = ∂L/∂a₁ · ∂a₁/∂z₁ = W₂^T · (z₂ - y) ⊙ σ'(z₁)

其中⊙是逐元素相乘,σ'(z₁) = σ(z₁)·(1-σ(z₁))

直觉:误差信号通过激活函数的导数"过滤"——
      如果激活函数在该点的导数很小(Sigmoid的饱和区),误差信号被大幅衰减
      → 这就是"梯度消失"的根本原因!

Step 5:L对W₁和b₁的梯度

∂L/∂W₁ = ∂L/∂z₁ · ∂z₁/∂W₁ = [W₂^T · (z₂-y) ⊙ σ'(z₁)] · x^T

∂L/∂b₁ = ∂L/∂z₁ = W₂^T · (z₂-y) ⊙ σ'(z₁)

总结——反向传播的核心规律

每一层的梯度计算都可以分解为3步:
1. 接收来自下一层的误差信号
2. 乘以本层激活函数的导数
3. 乘以本层的输入信号

∂L/∂W = (误差信号) × (激活函数导数) × (输入)

计算图——现代深度学习框架的核心

什么是计算图? 把数学运算画成一个有向图,每个节点是一个操作,每条边是数据流。

示例:y = (x + 1) × (x + 2)

计算图:
x ──┬──→ [+] → x+1 ──┬──→ [×] → y
    │                  │
    └──→ [+] → x+2 ──┘

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张量:一摞表格                            例如:彩色图片 (高, 宽, 通道)
4D张量:一批图片                            例如:batch of images (批次, 通道, 高, 宽)

为什么叫"张量"而不是"数组"?

张量和数组在数据结构上是一样的,但张量有两个关键特性:
1. 可以在GPU上运算(NumPy数组只能在CPU上)
2. 支持自动求导(自动计算梯度)

所以张量 = 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) — 负值变0,正值不变
sigmoid_output = torch.sigmoid(h)       # Sigmoid: 1/(1+e^(-x)) — 压缩到(0,1)
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)(Attention中常用)
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自动求导的工作原理

1. 前向传播时:PyTorch记录每一步操作,构建"计算图"
2. 调用.backward()时:从输出节点开始,沿计算图反向传播
3. 每个节点:通过链式法则计算梯度
4. 结果:存储在每个叶子节点的.grad属性中

类比:前向传播像"记账"(记录每一步操作)
     反向传播像"审计"(追溯每一步的贡献)

完整的PyTorch训练循环——最重要的模板

这个模板适用于所有PyTorch模型! 无论多复杂的模型(CNN、RNN、Transformer),核心都是这个循环。

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()        # 更新参数

如果不清零,梯度会越来越大 → 参数更新过猛 → 训练不稳定

模块二:CNN与图像处理

2.1 卷积神经网络原理

为什么全连接网络处理图像效果不好?

问题:参数爆炸

一张224×224的彩色图片 = 224×224×3 = 150,528个像素
如果用全连接层,第一个隐藏层有1000个神经元:
  需要 150,528 × 1000 = 1.5亿个参数!

问题:
1. 计算量巨大(1.5亿次乘法,每张图片!)
2. 显存不够(1.5亿个float32 = 600MB,仅第一层!)
3. 容易过拟合(参数太多,模型容易"死记硬背"图片)

核心洞察:图像有两大特性,全连接网络没有利用

1. 局部性(Locality):一个像素主要和它周围的像素相关,和远处的像素关系不大
    不需要每个神经元连接所有150,528个输入!

2. 平移不变性(Translation Invariance):猫在图片左上角和右下角,识别方法是一样的
    同一个特征检测器应该能用在图片的任何位置!

卷积操作——局部感知 + 参数共享

卷积核是什么? 一个小的权重矩阵(比如3×3),像一个"滑动窗口"在图片上滑动。

输入图片(5×5):              卷积核(3×3):
1  1  1  0  0                  1  0  1
0  1  1  1  0                  0  1  0
0  0  1  1  1                  1  0  1
0  0  1  1  0
0  1  1  0  0

卷积核在左上角位置的计算:
[1 1 1]     [1 0 1]
[0 1 1]  ⊙  [0 1 0]  = 1×1 + 1×0 + 1×1 + 0×0 + 1×1 + 1×0 + 0×1 + 0×0 + 1×1 = 4
[0 0 1]     [1 0 1]
(⊙表示逐元素相乘,然后求和)

卷积核滑到下一个位置,重复计算...
最终得到输出特征图(3×3):
4  3  4
2  4  3
2  3  4

卷积的三大优势

1. 局部感知:每个输出只看3×3的局部区域(不是全部150,528个像素)
   → 参数量:3×3 = 9个(vs 全连接的150,528个!)

2. 参数共享:同一个卷积核用在图片的所有位置
   → 不管图片多大,参数量都是3×3 = 9个

3. 平移不变性:因为参数共享,猫在任何位置都能被同一个卷积核检测到

类比:卷积核就像一个"手电筒",你在黑暗中用手电筒照亮图片的一小块区域,检查那里有没有你想要的特征(比如边缘),然后移动手电筒到下一个位置,重复检查。

卷积神经网络的核心组件

输入图片 → [卷积层] → [激活函数ReLU] → [池化层] → [卷积层] → ... → [全连接层] → 输出
            │            │                 │
        提取局部特征   引入非线性        降低空间尺寸

各层的作用详解

1. 卷积层(Conv2d):
   - 用多个不同的卷积核提取不同的特征
   - 第1个卷积核可能检测"竖线",第2个可能检测"横线",第3个可能检测"斜线"
   - 输出叫"特征图"(Feature Map),每个通道对应一个卷积核的检测结果

2. 激活函数(ReLU):
   - 在每个卷积层后面加一个非线性变换
   - 让网络能够学习更复杂的模式

3. 池化层(MaxPool):
   - 把特征图缩小(比如2×2的MaxPool把尺寸减半)
   - MaxPool取每个2×2区域的最大值
   - 作用:减少计算量,增强平移不变性("物体稍微移动一点不影响识别")

4. 全连接层(Linear):
   - 把最后的特征图展平成一维向量
   - 做最终的分类决策

一个CNN的完整数据流(以28×28灰度图为例)

输入:(1, 28, 28)           ← 1通道,28×28像素
  ↓ Conv2d(1→32, 3×3)
(32, 28, 28)                ← 32个特征图,每个28×28
  ↓ MaxPool(2×2)
(32, 14, 14)                ← 尺寸减半
  ↓ Conv2d(32→64, 3×3)
(64, 14, 14)                ← 64个特征图
  ↓ MaxPool(2×2)
(64, 7, 7)                  ← 尺寸再减半
  ↓ Flatten
(64×7×7) = (3136)           ← 展平为一维向量
  ↓ Linear(3136→128)
(128)                       ← 隐藏层
  ↓ Linear(128→10)
(10)                        ← 输出10个类别的概率

经典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)

传统层:y = F(x)           ← 网络需要从零学习F(x) = y
残差层:y = F(x) + x       ← 网络只需要学习"改进量" F(x) = y - x

类比:
传统层像"从白纸画一幅画"——很难
残差层像"在原图上做修改"——容易得多!

为什么残差连接能解决梯度消失?

反向传播时:
传统层:∂L/∂x = ∂L/∂y · ∂F/∂x    (梯度经过F,可能消失)
残差层:∂L/∂x = ∂L/∂y · (∂F/∂x + 1)  (多了一个+1的"梯度高速公路")

即使∂F/∂x接近0,梯度仍然可以通过"+1"这条路径直接传回去!
这就是为什么ResNet可以训练152层甚至更深的网络。

2.2 PyTorch实现CNN

import torch.nn as nn

class SimpleCNN(nn.Module):
    def __init__(self):
        super(SimpleCNN, self).__init__()
        
        # 卷积部分:提取特征
        self.features = nn.Sequential(
            # 第1个卷积块:1通道→32通道
            nn.Conv2d(1, 32, kernel_size=3, padding=1),  # padding=1保持尺寸不变
            nn.ReLU(),                                     # 非线性激活
            nn.MaxPool2d(2),                               # 尺寸减半:28→14
            
            # 第2个卷积块:32通道→64通道
            nn.Conv2d(32, 64, kernel_size=3, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(2),                               # 尺寸减半:14→7
        )
        
        # 分类部分:全连接层
        self.classifier = nn.Sequential(
            nn.Flatten(),                    # 将(64,7,7)展平为(3136,)
            nn.Linear(64 * 7 * 7, 128),      # 全连接层
            nn.ReLU(),
            nn.Linear(128, 10),              # 输出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) — 10个类别的logits

2.3 数据加载——Dataset与DataLoader

为什么需要Dataset和DataLoader?

问题:训练集可能有几百万张图片,不可能一次性全部加载到内存(RAM不够)。

解决方案

Dataset:定义"数据在哪里,如何获取第i个样本"
DataLoader:定义"如何把多个样本打包成一个batch",并负责多进程加载

类比:
Dataset = 菜谱(告诉厨师每道菜怎么做)
DataLoader = 传菜员(把多道菜一起端上来,还负责多个传菜员同时工作)
from torch.utils.data import Dataset, DataLoader

# Dataset:定义"如何获取一个样本"
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]  # 获取第idx个样本

# DataLoader:定义"如何组织一个批次"
dataset = MyDataset(data, labels)
dataloader = DataLoader(
    dataset, 
    batch_size=32,    # 每批32个样本
    shuffle=True,     # 打乱顺序(训练集要打乱,测试集不用)
    num_workers=4     # 4个子进程并行加载数据(加速IO)
)

# 训练时遍历数据
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

结果:显存节省约50%,训练速度提升2-3倍,精度几乎无损!
from torch.cuda.amp import autocast, GradScaler

scaler = GradScaler()  # 用于防止float16的下溢出

for batch in dataloader:
    optimizer.zero_grad()
    
    with autocast():                    # 自动选择精度(大部分用float16)
        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)来补偿

BatchNorm——加速训练的"万金油"

self.bn = nn.BatchNorm1d(256)  # 对256维的特征做归一化

BatchNorm在做什么?

对每个mini-batch的数据做标准化:
1. 计算这个batch的均值μ和方差σ²
2. 标准化:x̂ = (x - μ) / √(σ² + ε)
3. 缩放和平移:y = γ·x̂ + β(γ和β是可学习的参数)

为什么要缩放和平移?
因为标准化后数据分布被强制为N(0,1),可能损失有用信息。
γ和β让网络自己学到"最优的分布"。

为什么BatchNorm有效?

1. 减少内部协变量偏移:每层输入的分布稳定了,学习更容易
2. 允许更大学习率:稳定的梯度 → 可以走更大的步
3. 轻微正则化:因为每个batch的均值和方差有随机性,相当于加了噪声
4. 减少对初始化的敏感性:即使初始权重不太好,BatchNorm也能帮忙修正

模块三:RNN与序列模型

3.1 序列数据与文本预处理

什么是序列数据?

核心特征:顺序很重要,当前值依赖于之前的值

文本:"我 爱 大 模 型"    → "大 模 型 爱 我" 意思完全不同!
股票:[100, 102, 101, 105] → 今天的股价和昨天的股价相关
音频:[0.1, 0.3, 0.5, ...] → 声音是随时间变化的信号

全连接网络的问题:它把输入当作独立的,不考虑顺序
CNN的问题:它只看局部窗口,不能建模长距离依赖
→ 需要一种新的网络结构来处理序列数据 → RNN

文本预处理流程

原始文本:"I love AI"

Step 1 - 分词(Tokenize):
"I love AI" → ["I", "love", "AI"]

Step 2 - 建立词表(Vocabulary):
{"I": 0, "love": 1, "AI": 2, "<PAD>": 3, "<UNK>": 4}
词表把每个词映射到一个唯一的整数ID

Step 3 - 转为数字序列:
["I", "love", "AI"] → [0, 1, 2]

Step 4 - 填充/截断(Padding):
不同句子长度不同,需要统一长度
短的用<PAD>填充,长的截断
[0, 1, 2] → [0, 1, 2, 3, 3](填充到长度5)

Step 5 - 送入模型:
模型的输入是数字序列 [0, 1, 2, 3, 3]

3.2 RNN原理与实现

为什么需要RNN?

类比:读书

你读一本书时,理解当前这句话需要记住之前的内容。

  • 读到"他打开了门" → 你知道"他"是谁(因为前文提过)
  • 读到"因为下雨,所以…" → 你知道这是一个因果关系

RNN做的同样的事——用"隐藏状态"记住之前的信息,辅助理解当前的输入

时间步t=1: h₁ = tanh(W_h · h₀ + W_x · x₁ + b)   # 读第1个词,产生记忆h₁
时间步t=2: h₂ = tanh(W_h · h₁ + W_x · x₂ + b)   # 读第2个词,结合记忆h₁产生h₂
时间步t=3: h₃ = tanh(W_h · h₂ + W_x · x₃ + b)   # 读第3个词,结合记忆h₂产生h₃

h₃包含了前3个词的信息!

关键点:每个时间步使用相同的权重(W_h, W_x)——这就是"参数共享"。

  • 不管句子有多长,参数量是固定的
  • 同一个RNN Cell可以处理任意长度的序列

RNN的展开形式——理解RNN的关键

虽然RNN看起来只有一个Cell,但展开后就像一个很深的网络:

         h₀     h₁     h₂     h₃
          │      │      │      │
x₁ → [RNN Cell] → h₁
x₂ →          [RNN Cell] → h₂
x₃ →                   [RNN Cell] → h₃

每个Cell共享同一组权重,但每个时间步有不同的输入和输出。

RNN的致命缺陷——梯度消失(用数字说明)

反向传播时,梯度需要从h_T一路传回h₁:
∂L/∂h₁ = ∂L/∂h_T · ∂h_T/∂h_(T-1) · ∂h_(T-1)/∂h_(T-2) · ... · ∂h₂/∂h₁

问题:每一步的 ∂h_t/∂h_(t-1) 的最大值约等于 W_h 的谱范数
     如果这个值 < 1(比如0.9):
     0.9^10 ≈ 0.35  (10步后梯度衰减到35%)
     0.9^50 ≈ 0.005 (50步后梯度衰减到0.5%!)
     0.9^100 ≈ 0.00003(100步后梯度几乎为0!)

结果:远处的信息无法影响当前的参数更新
     RNN只能"记住"最近几个词的信息
     → RNN学不到长距离依赖!

类比:传话游戏

10个人排成一排传话。
第1个人说"明天下午3点开会",传到第10个人可能变成"后天中午吃饭"。
信息在传递过程中逐级失真——这就是梯度消失的形象比喻。

LSTM和GRU的解决方案:给信息一条"直达通道",不让它被逐级衰减。

3.3 LSTM——解决长期依赖的"神器"

LSTM的核心思想——细胞状态 + 三个门

类比:你的笔记本

想象你有一个笔记本(细胞状态),每天要做三件事:

  1. 擦掉不再重要的旧笔记(遗忘门)
  2. 写入今天重要的新信息(输入门)
  3. 决定今天给老板看哪些内容(输出门)
细胞状态 C_t:一条"信息高速公路",信息可以无损地流过
              ─────────────────────────────────────→ C_t
                   ↑            ↑            ↑
              遗忘门(×)    输入门(+)    输出门(→h_t)

遗忘门 f_t = σ(W_f · [h_(t-1), x_t] + b_f)    → 0到1的值
    "旧记忆中,哪些应该保留?" 0=全忘,1=全留

输入门 i_t = σ(W_i · [h_(t-1), x_t] + b_i)    → 0到1的值
候选值 C̃_t = tanh(W_C · [h_(t-1), x_t] + b_C)  → -1到1的值
    "新信息中,哪些值得写入?写入多少?"

输出门 o_t = σ(W_o · [h_(t-1), x_t] + b_o)    → 0到1的值
    "从记忆中,今天展示哪些内容?"

更新公式:
    C_t = f_t ⊙ C_(t-1) + i_t ⊙ C̃_t    ← 关键!是加法,不是乘法
    h_t = o_t ⊙ tanh(C_t)

完整数值示例

假设处理句子"The cat, which is very cute, sat on the mat"

当处理到"sat"时,需要知道主语是"cat"(中间隔了4个词)。

遗忘门:看到"sat"时,f_t ≈ [1, 1, 0.01, ...]
       → 保留"cat"的信息,丢弃"which is very cute"的细节

输入门:看到"sat"是一个动词,i_t ≈ [0, 0, 0.9, ...]
       → 写入"sat"的信息

结果:细胞状态中同时保存了"cat"(很久之前的信息)和"sat"(刚看到的信息)
     → LSTM知道"cat sat on the mat",理解了主谓关系

为什么LSTM能解决梯度消失?

数学解释

普通RNN的梯度链:∂h_t/∂h_(t-1) = W · diag(σ'(z))  → 连乘,指数衰减

LSTM的细胞状态梯度:∂C_t/∂C_(t-1) = f_t  (遗忘门的值)

当 f_t ≈ 1 时:∂C_t/∂C_(t-1) ≈ 1 → 梯度无损传递!
这就是LSTM的"梯度高速公路"——只要遗忘门接近1,信息就能无损地流过任意长的距离。

类比:传送带 vs 口口相传

普通RNN像"口口相传":信息逐级传递,每传一次就失真一点
LSTM像"传送带":信息放在传送带上直接送到目的地,不会失真

传送带的"速度"由遗忘门控制:
- 遗忘门=1:传送带全速运转,信息无损传递
- 遗忘门=0:传送带停了,信息被丢弃

3.4 GRU——LSTM的"简化版"

GRU的设计哲学:用更少的参数达到类似的效果

LSTM(3个门 + 候选细胞状态 → 4个线性变换):
    遗忘门 f_t:决定忘记什么
    输入门 i_t:决定记住什么
    输出门 o_t:决定输出什么
    候选值 C̃_t:新的候选记忆

GRU(2个门 → 3个线性变换):
    重置门 r_t:决定如何结合新输入和旧记忆(类似"输入门"的一部分)
    更新门 z_t:同时控制"忘记旧的"和"记住新的"(合并了遗忘门和输入门)
GRU的公式:
重置门:  r_t = σ(W_r · [h_(t-1), x_t])
更新门:  z_t = σ(W_z · [h_(t-1), x_t])
候选状态:h̃_t = tanh(W · [r_t ⊙ h_(t-1), x_t])
最终状态:h_t = (1 - z_t) ⊙ h_(t-1) + z_t ⊙ h̃_t

注意最后一行:
(1-z_t) ⊙ h_(t-1):保留多少旧记忆(当z_t=0时,完全保留)
z_t ⊙ h̃_t:添加多少新信息(当z_t=1时,完全更新)

z_t同时控制了"遗忘"和"输入",这就是GRU比LSTM少一个门的原因。

LSTM vs GRU 如何选择?

特性              LSTM                    GRU
─────────────────────────────────────────────────
门的数量          3个门                   2个门
参数量            更多                    更少(约少25%)
训练速度          较慢                    较快
长序列效果        略好                    略差
数据量少时        可能过拟合              更好(参数少)

实践建议:
- 先试GRU(更快),效果不好再换LSTM
- 如果序列很长(1000+步),LSTM通常更好
- 如果数据量很少,GRU更好(不容易过拟合)

3.5 深度/双向RNN

深度RNN——多层堆叠

类比:CNN的层次化特征提取

CNN:边缘 → 纹理 → 形状 → 物体
深度RNN:词法 → 语法 → 语义 → 情感

第3层:  h₃₁ → h₃₂ → h₃₃ → ...    ← 学习最高层特征(语义、情感)
第2层:  h₂₁ → h₂₂ → h₂₃ → ...    ← 学习中层特征(语法结构)
第1层:  h₁₁ → h₁₂ → h₁₃ → ...    ← 学习底层特征(词法信息)
输入:    x₁    x₂    x₃

每层的输出作为上一层的输入,层层抽象。

双向RNN——同时看过去和未来

问题:有些任务需要同时理解前后文

示例:"我去银行存钱" vs "我在河岸散步"
- "银行"的含义取决于后面的"存钱"
- 只看左边无法确定"银行"是金融机构还是河岸

双向RNN的解决方案:
正向:  h→₁ → h→₂ → h→₃ → h→₄     ← 从左到右阅读
反向:  h←₁ ← h←₂ ← h←₃ ← h←₄     ← 从右到左阅读

输出:  [h→₁,h←₁] [h→₂,h←₂] [h→₃,h←₃] [h→₄,h←₄]
        ← 每个位置同时包含"前文信息"和"后文信息"

应用场景:命名实体识别、文本分类、BERT的预训练
不能用于:语言模型、文本生成(因为生成时看不到"未来"的词)

模块四:词嵌入与Seq2Seq

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:通过子词的上下文来学习词义(分布假说的扩展)

类比:
分布假说 = "物以类聚,人以群分"
→ 经常在一起出现的词,含义相似
→ 词嵌入就是把这种"相似性"用向量距离来表达

分布假说的局限

1. 多义词问题:"苹果"在不同上下文中含义不同
   → Word2Vec给"苹果"一个固定的向量,无法区分
   → 后来ELMo、BERT通过上下文相关的词向量解决了这个问题

2. 反义词问题:"好"和"坏"经常出现在相似的上下文中
   → "这个东西很__" → 好/坏都可能
   → 但它们含义相反!
   → 分布假说无法区分反义词(这是词嵌入的已知局限)

从独热编码到词嵌入

独热编码的问题

假设词表有50,000个词:
"猫" = [1, 0, 0, 0, ..., 0]    # 50,000维,只有1个1
"狗" = [0, 1, 0, 0, ..., 0]    # 50,000维,只有1个1
"汽车" = [0, 0, 1, 0, ..., 0]  # 50,000维,只有1个1

距离("猫", "狗") = 距离("猫", "汽车") = √2
→ 无法表达"猫和狗更相似"这个事实!
→ 独热编码没有语义信息。

词嵌入的解决方案

用一个低维向量(比如300维)来表示每个词,语义相似的词有相似的向量:
"猫"   = [0.2, 0.8, -0.1, 0.5, ...]   # 300维
"狗"   = [0.3, 0.7, -0.2, 0.4, ...]   # 和"猫"很接近
"汽车" = [-0.5, 0.1, 0.9, -0.3, ...]  # 和"猫"很远

距离("猫", "狗") << 距离("猫", "汽车")
→ 词嵌入能表达语义关系!

Word2Vec的两种模式

CBOW(连续词袋)——用上下文预测中心词:
    输入:[我, __, 大, 模型]
    目标:预测__ = "爱"
    
    类比:完形填空——给你上下文,猜中间的词

Skip-gram——用中心词预测上下文:
    输入:爱
    目标:预测[我, 大, 模型]
    
    类比:看一个词,猜它周围会出现什么词

Word2Vec的训练过程(Skip-gram简化版)

1. 准备训练数据:从大量文本中提取(中心词, 上下文词)配对
   句子"我爱大模型" → ("爱", "我"), ("爱", "大"), ("爱", "模型")

2. 构建一个简单的神经网络:
   输入层:中心词的独热编码 → 嵌入层 → 输出层 → softmax → 预测上下文词

3. 训练这个网络:
   最大化 P(上下文词 | 中心词)
   
4. 训练完成后,嵌入层的权重就是词向量!
   每个词对应嵌入矩阵的一行 → 300维向量

Word2Vec的经典发现——词向量的"魔法"

king - man + woman ≈ 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"的向量 = 所有子词向量的和

优势:
1. 处理未登录词(OOV):即使没见过"unhappiness",也能用"un"+"happi"+"ness"的子词向量组合
2. 利用形态学信息:词根、前缀、后缀都有含义
3. 对中文也有用:字级别的n-gram能捕获偏旁部首的信息

ELMo——上下文相关的词向量(BERT的前身)

Word2Vec/GloVe/FastText的共同问题:
同一个词在所有语境下的向量都相同!

"苹果很好吃"  中的"苹果" = [0.2, 0.8, ...]  (应该是"水果"的意思)
"苹果发布新手机"中的"苹果" = [0.2, 0.8, ...]  (应该是"公司"的意思)
→ 向量完全一样!这不合理。

ELMo的解决方案:用双向LSTM,根据上下文动态生成词向量
"苹果很好吃"  → 双向LSTM → "苹果"的向量偏向"水果"
"苹果发布新手机" → 双向LSTM → "苹果"的向量偏向"公司"

核心思想:词的含义取决于上下文!
这就是后来BERT的核心思想——上下文相关的词表示。

4.3 Seq2Seq与Encoder-Decoder架构

Seq2Seq——序列到序列

应用场景

机器翻译:  "I love AI" → "我爱人工智能"
文本摘要:  "很长的文章..." → "一句话摘要"
对话系统:  "你好吗?" → "我很好,谢谢!"

架构

Encoder(编码器):读取输入序列,压缩成一个固定长度的向量
Decoder(解码器):从这个向量生成输出序列

"I" → ┌───────┐         ┌───────┐ → "我"
"love"→│Encoder│→ 向量c →│Decoder│→ "爱"
"AI" → └───────┘         └───────┘ → "人工智能"

Encoder用RNN/LSTM逐词读入,最后一个隐藏状态就是"语义向量"
Decoder用另一个RNN/LSTM从语义向量逐词生成输出

Seq2Seq的问题——信息瓶颈

问题:整个输入序列被压缩成一个固定长度的向量(比如256维)
如果输入有100个词,256维要容纳100个词的全部信息 → 太拥挤了!

类比:把一本书的全部内容压缩成一句话 → 一定会丢失大量信息

后果:句子越长,翻译质量越差

4.4 注意力机制——让模型学会"关注"

注意力的核心思想——不要压缩,要"关注"

类比:同声传译

Seq2Seq的做法:先把整本书读完记住,然后闭着眼睛翻译
→ 信息量太大,记不住

注意力的做法:翻译每个词时,回头看原文的相关部分
→ 翻译"猫"时看原文的"cat"
→ 翻译"坐"时看原文的"sit"
→ 每一步都"关注"最相关的部分

注意力的三步计算

假设编码器输出了4个隐藏状态 [h₁, h₂, h₃, h₄]
解码器当前状态是 s_t

Step 1:计算注意力分数("每个位置和我有多相关?")
    score_i = s_t^T · h_i    (内积越大,越相关)
    比如:score = [0.1, 0.8, 0.05, 0.05]  ← 第2个位置最相关

Step 2:Softmax归一化("把分数变成概率")
    attention_weights = softmax(scores)
    = [0.1, 0.8, 0.05, 0.05]  ← 已经是概率分布了

Step 3:加权求和("按重要性混合信息")
    context = Σ(attention_weights_i · h_i)
    = 0.1·h₁ + 0.8·h₂ + 0.05·h₃ + 0.05·h₄
    ← 主要信息来自h₂(最相关的位置)

这个context向量就是"注意力的输出"——它聚合了编码器所有位置的信息,
但重点关注了最相关的部分。

多头注意力(Multi-Head Attention)——从多个角度看

单头注意力:只用一种方式计算相关性
多头注意力:用多种方式并行计算不同类型的相关性

类比:你和朋友看同一张照片
- 你关注颜色("这张照片色调很暖")
- 朋友关注构图("主体在黄金分割点上")
- 另一个朋友关注内容("这是在海边拍的")

每个人从不同角度"关注"同一张照片,综合所有人的观察才能全面理解。

多头注意力同理:
- Head 1 可能学到语法关系(主语-谓语)
- Head 2 可能学到语义关系(同义词)
- Head 3 可能学到位置关系(相邻词)
- ...
所有头的结果拼接起来 → 全面的语义表示

这就是Transformer的核心组件,也是GPT、BERT的基础。


📝 自测题

  1. 神经网络:画出一个2层全连接网络的前向传播过程,标注每个矩阵的维度
  2. 反向传播:给定网络 y = σ(Wx+b),手推 ∂L/∂W 的梯度公式
  3. 激活函数:为什么ReLU比Sigmoid更适合做隐藏层的激活函数?
  4. PyTorch:用PyTorch定义一个CNN模型,完成MNIST手写数字识别的训练循环
  5. CNN:解释卷积操作的三大优势(局部感知、参数共享、平移不变性)
  6. ResNet:用一句话解释残差连接为什么能训练更深的网络
  7. RNN:解释RNN的梯度消失问题,用数字说明为什么长序列会"遗忘"
  8. LSTM:解释三个门的作用,以及为什么细胞状态能保留长距离信息
  9. LSTM vs GRU:GRU如何用2个门实现LSTM 3个门的功能?
  10. 注意力机制:解释注意力的三个步骤,并说明它如何解决Seq2Seq的信息瓶颈问题

📚 推荐补充资源

知识点推荐资源说明
神经网络3Blue1Brown《神经网络》系列最直观的神经网络可视化教程
反向传播Andrej Karpathy《Yes you should understand backprop》反向传播的直觉讲解
PyTorchPyTorch官方教程跟着做一遍就能上手
CNNCS231n(斯坦福计算机视觉课程)CNN的经典课程
RNN/LSTMChris Olah《Understanding LSTM》图解LSTM的经典文章
注意力机制Jay Alammar《The Illustrated Seq2Seq》图解Seq2Seq和注意力
Word2VecJay Alammar《Illustrated Word2Vec》图解Word2Vec