一、开篇:从「能跑」到「能跑好」的鸿沟

一句话点睛:搭一个网络跑通不难,难的是让它收敛、稳定、不 OOM。

上一篇(快速入门 PyTorch)里我们用 100 行代码跑通了 y = x 2 y = x^2 y=x2 的拟合。看着 loss 一路下降,曲线完美贴合,那种感觉确实让人上头。

但真到实际项目,你大概率会撞上这几面墙:

  • loss.backward() 跑完,模型却越来越笨 → 梯度忘记清零
  • RuntimeError: mat1 and mat2 shapes cannot be multiplied维度没对齐
  • CUDA out of memorybatch_size 没算对 / 没用 no_grad() 推理
  • ❌ loss 震荡、训练集正确率 99%、测试集 60% → 过拟合 / 没做正则化
  • ❌ 训练到一半 loss 突然变成 NaN学习率太大 / 梯度爆炸

这些坑的根源,不在网络结构,而在「训练」本身。本文要讲透的就是这件事——

  • 损失函数:怎么衡量「错」
  • 数据加载:怎么把数据高效送进模型
  • 自动微分(Autograd)loss.backward() 这一行的魔法
  • 优化器与学习率调度:梯度算出来之后,参数怎么更新
  • 参数初始化:训练开始前,权重长什么样
  • 正则化:怎么让模型不"死记硬背"

📝 阅读路线

本文是 PyTorch 系列第 2 篇。前置知识:快速入门 PyTorch(Tensor、nn.Module、训练循环基础)。


二、损失函数:模型"错"了多少?

损失函数衡量的是模型预测值和真实值之间的差距。训练的本质就是最小化这个差距。所以选错损失函数,相当于给模型立了错的目标——再努力也白搭。

损失函数的选择完全取决于任务类型

任务 推荐损失 备注
回归(连续值) MAE / MSE / Smooth L1 预测房价、温度等
二分类(是/否) BCEWithLogitsLoss 垃圾邮件、疾病诊断
多分类(互斥类别) CrossEntropyLoss 手写数字、ImageNet

⚠️ 参数顺序约定
PyTorch 所有损失函数的参数顺序都是 先 pred,后 target

loss = loss_fn(output, target)   # ✅ 先预测,再真实
loss = loss_fn(target, output)   # ❌ 传反了不会报错,但 loss 算的是错的

传反了模型也能跑(类型兼容时),但 loss 算的是错的,模型不会收敛。

2.1 回归任务:MAE / MSE / Smooth L1

【MAE(L1Loss)】

平均绝对误差——预测值与真实值之差的绝对值的平均。对异常值不敏感(梯度恒定):

MAE = 1 n ∑ i = 1 n ∣ y i − y ^ i ∣ \text{MAE} = \frac{1}{n} \sum_{i=1}^{n} |y_i - \hat{y}_i| MAE=n1i=1nyiy^i

output = torch.randn(5)         # 模型预测
target = torch.randn(5)         # 真实值
loss = nn.L1Loss()(output, target)
【MSE(MSELoss)】

均方误差——预测值与真实值之差的平方的平均。对异常值敏感(平方会放大误差):

MSE = 1 n ∑ i = 1 n ( y i − y ^ i ) 2 \text{MSE} = \frac{1}{n} \sum_{i=1}^{n} (y_i - \hat{y}_i)^2 MSE=n1i=1n(yiy^i)2

loss = nn.MSELoss()(output, target)

💡 MAE vs MSE

特性 MAE MSE
对异常值敏感度 低(梯度恒定为 ±1) (平方放大)
梯度特性 远离 0 也不增大 越远离 0 梯度越大
适用场景 数据有较多异常值 数据干净、追求平滑
【Smooth L1Loss】

结合 MAE 和 MSE 的优点——误差小时用 MSE(梯度平滑好优化),误差大时退化为 MAE(防梯度爆炸)。目标检测的回归分支几乎都用它(Faster R-CNN、YOLO):

SmoothL1 ( x ) = { 0.5 x 2 , ∣ x ∣ < 1 ∣ x ∣ − 0.5 , otherwise \text{SmoothL1}(x) = \begin{cases} 0.5 x^2, & |x| < 1 \\ |x| - 0.5, & \text{otherwise} \end{cases} SmoothL1(x)={0.5x2,x0.5,x<1otherwise

loss = nn.SmoothL1Loss()(output, target)

2.2 二分类:BCE 系列

适用于二分类任务(是 / 不是)。衡量两个概率分布的差距。

💡 二分类的输出形状
二分类不是输出 2 个值,而是每个样本只输出 1 个值——表示属于类别 1 的概率 P ( y = 1 ) P(y=1) P(y=1) P ( y = 0 ) P(y=0) P(y=0) 1 − P ( y = 1 ) 1 - P(y=1) 1P(y=1) 隐式得到。

L = − 1 n ∑ i = 1 n [ y i log ⁡ y ^ i + ( 1 − y i ) log ⁡ ( 1 − y ^ i ) ] L = -\frac{1}{n} \sum_{i=1}^{n} \left[ y_i \log \hat{y}_i + (1 - y_i) \log(1 - \hat{y}_i) \right] L=n1i=1n[yilogy^i+(1yi)log(1y^i)]

【两步做法】

先过 Sigmoid 再算 BCE,容易数值不稳定 log ⁡ ( 0 ) \log(0) log(0) 会爆):

output = torch.randn(5)                     # 原始 logits
proba = torch.sigmoid(output)               # 手动画到 (0, 1)
target = torch.tensor([0., 0., 1., 1., 0.])
loss = nn.BCELoss()(proba, target)
【一步做法(推荐)】

BCEWithLogitsLossSigmoid + BCE 合并,内部用 log-sum-exp 技巧数值更稳定:

output = torch.randn(5)                     # 原始 logits(不过 Sigmoid)
target = torch.tensor([0., 0., 1., 1., 0.])
loss = nn.BCEWithLogitsLoss()(output, target)

💡 推荐写法
日常开发直接用 BCEWithLogitsLoss——网络最后输出层不要再加 nn.Sigmoid(),让 loss 内部处理。

2.3 多分类:CrossEntropyLoss

适用于多分类任务(类别互斥,如手写数字 0~9、ImageNet 1000 类)。

内部已集成 Softmax,输入是原始 logits,模型最后输出层不要nn.Softmax()

CE = − log ⁡ ( y ^ c ) 其中  c  是正确类别的索引 \text{CE} = -\log(\hat{y}_c) \quad \text{其中 } c \text{ 是正确类别的索引} CE=log(y^c)其中 c 是正确类别的索引

【target 是类别索引(最常用)】
output = torch.randn(8, 6)                    # [batch=8, num_classes=6]
target = torch.tensor([1, 0, 5, 4, 2, 3, 0, 5])  # 类别索引(int64)
loss = nn.CrossEntropyLoss()(output, target)
【target 是概率分布(标签平滑/蒸馏)】
target_onehot = torch.zeros(8, 6)
target_onehot[torch.arange(8), target] = 1   # one-hot
loss = nn.CrossEntropyLoss()(output, target_onehot)

📝 CrossEntropyLoss 关键细节

  • 已内置 Softmax,模型末尾不要再加 nn.Softmax()
  • target 通常是类别索引(torch.int64 / Long 类型)
  • 也支持概率分布作为 target(标签平滑、知识蒸馏场景)

2.4 损失函数速查表

损失函数 类名 适用场景 关键提醒
平均绝对误差 nn.L1Loss() 回归(抗异常值) 梯度恒定,收敛慢
均方误差 nn.MSELoss() 回归(平滑优化) 对异常值敏感
Smooth L1 nn.SmoothL1Loss() 回归(目标检测) MAE + MSE 的结合
二元交叉熵 nn.BCELoss() 二分类 需先过 Sigmoid
二元交叉熵(推荐) nn.BCEWithLogitsLoss() 二分类 内置 Sigmoid,数值稳定
多分类交叉熵 nn.CrossEntropyLoss() 多分类 内置 Softmax

三、数据加载:Dataset 与 DataLoader

把数据送进模型不是"循环里手动取"。PyTorch 提供了两个工具类:

  • Dataset:定义数据集,负责单条数据的读取(“怎么取一条”)
  • DataLoader:封装 Dataset,自动分 batch、打乱、多线程加载(“怎么分批往外送”)

3.1 自定义 Dataset

必须继承 torch.utils.data.Dataset 并实现两个方法:

方法 作用
__len__() 返回数据集总大小
__getitem__(idx) 按索引返回单条数据
from torch.utils.data import Dataset

class MyDataset(Dataset):
    """自定义数据集:传入一个 list,每条样本是 (特征, 标签)"""
    def __init__(self, features, labels):
        self.features = features      # 特征数据
        self.labels = labels          # 标签数据

    def __len__(self):
        # 必须有!告诉 DataLoader 总共有多少条
        return len(self.features)

    def __getitem__(self, idx):
        # 必须有!根据 idx 返回一条数据
        x = self.features[idx]
        y = self.labels[idx]
        return x, y

# 使用
features = [torch.randn(3) for _ in range(100)]   # 100 个 3 维特征
labels   = [torch.tensor(i % 2) for i in range(100)]  # 0/1 标签
dataset = MyDataset(features, labels)
print(dataset[0])   # (tensor([...]), tensor(0))  ← 一条数据

3.2 内置 TensorDataset

当数据已经是 Tensor 时,TensorDataset 免去自定义

from torch.utils.data import TensorDataset

# 100 个样本,3 个特征,二分类
X = torch.randn(100, 3)
y = torch.randint(0, 2, (100,))

dataset = TensorDataset(X, y)
print(dataset[0])   # (tensor([...]), tensor(0))  ← 自动配对

💡 TensorDataset 的本质
它就是把多个 Tensor 按第一维对齐打包——索引 i 同时取每个 Tensor 的第 i 行。适合"特征和标签都是 Tensor"的场景。

3.3 DataLoader:分批 + 打乱 + 多线程

DataLoader 把 Dataset 包装成可迭代对象,自动完成所有脏活:

from torch.utils.data import DataLoader

loader = DataLoader(
    dataset,
    batch_size=32,      # 每批 32 条
    shuffle=True,       # 每个 epoch 是否打乱
    num_workers=0,      # 加载数据的子进程数(0 = 主进程,调试用)
    drop_last=False,    # 样本数不能被 batch_size 整除时,是否丢弃最后不完整的 batch
    pin_memory=False,   # GPU 训练时建议 True,加速 CPU→GPU 数据传输
)

# 迭代使用
for batch_idx, (x_batch, y_batch) in enumerate(loader):
    # x_batch.shape = [32, 3]  ← 自动按 batch 维度堆叠
    # y_batch.shape = [32]
    print(f"Batch {batch_idx}: x={x_batch.shape}, y={y_batch.shape}")
参数 说明 经验值
batch_size 每批样本数 32 / 64 / 128
shuffle 是否打乱 训练集 True,测试集 False
num_workers 数据加载子进程数 CPU 核心数(Windows 建议 0
drop_last 丢弃最后不完整 batch 多 GPU 训练时设 True
pin_memory 锁页内存 GPU 训练设 True 加速

3.4 标准训练循环里的用法

dataset = TensorDataset(X, y)
loader  = DataLoader(dataset, batch_size=32, shuffle=True)

for epoch in range(10):
    for batch_x, batch_y in loader:           # 每次一个 batch
        # batch_x: [32, 3]   batch_y: [32]
        output = model(batch_x)
        loss = loss_fn(output, batch_y)

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

⚠️ num_workers > 0 在 Windows 上的坑
Windows 下 num_workers > 0 必须用 if __name__ == '__main__': 保护,否则会无限递归开新进程(“炸进程”)。Linux/Mac 没这个问题。


四、梯度下降:训练的核心引擎

损失函数衡量了模型的"错",训练的目标就是找到一组参数让损失最小。理论上对损失函数求导、解出导数为 0 的点就行——但深度网络参数动辄百万,这个方程根本解不出来

梯度下降法(Gradient Descent)就是用迭代方式逼近最小值的核心方法。

4.1 核心思想

梯度 ∇ f \nabla f f 指向函数值增长最快的方向,所以负梯度方向就是下降最快的方向。沿着负梯度迈一小步,函数值就会变小一点:

∇ f ( x 1 , x 2 ) = ( ∂ f ∂ x 1 , ∂ f ∂ x 2 ) \nabla f(x_1, x_2) = \left( \frac{\partial f}{\partial x_1}, \frac{\partial f}{\partial x_2} \right) f(x1,x2)=(x1f,x2f)

x 1 ′ = x 1 − η ∂ f ∂ x 1 , x 2 ′ = x 2 − η ∂ f ∂ x 2 x_1' = x_1 - \eta \frac{\partial f}{\partial x_1}, \quad x_2' = x_2 - \eta \frac{\partial f}{\partial x_2} x1=x1ηx1f,x2=x2ηx2f

其中 η \eta η学习率(learning rate),控制每次更新的步长。

⚠️ 学习率的选择

  • 过大 → loss 震荡,甚至发散(变成 NaN
  • 过小 → 收敛极慢,陷入局部最优
  • 通常从 10 − 3 10^{-3} 103 10 − 1 10^{-1} 101 开始试,配合学习率调度器动态调整

4.2 四个核心概念

概念 含义 举例
SGD 随机梯度下降,每次用一小批样本算梯度
Batch Size 每批样本数 batch_size=64 表示每次用 64 个样本
Iteration 一次迭代 一个 Batch 的前向 + 反向 + 更新
Epoch 一个周期 模型完整遍历一次整个训练集

Batch Size 的权衡:

  • Batch Size = 1:单样本梯度噪声大、不稳定
  • Batch Size = 全体:梯度准但计算量巨大,易收敛到尖锐极小值
  • Mini-batch(通常 16~512):梯度稳定、计算高效、泛化能力好——实际训练的标准做法

💡 一个具体例子
数据集 2000 个样本,训练 10 个 Epoch,Batch Size=64

每个 Epoch 的 Batch 数    = ceil(2000 / 64) = 32 个(最后一批 8 个)
每个 Epoch 的 Iteration  = 32
总 Iteration             = 10 × 32 = 320
总处理样本数              = 10 × 2000 = 20000

4.3 SGD 训练伪代码

# SGD 训练伪代码
for epoch in range(num_epochs):           # 遍历整个数据集多次
    for batch in dataloader:              # 每次一个 mini-batch
        x, y = batch                      # 取出一批数据
        output = model(x)                 # 1. 前向传播
        loss = loss_fn(output, y)         # 2. 算 loss

        optimizer.zero_grad()             # 3. 清空上次梯度(重要!)
        loss.backward()                   # 4. 反向传播,算梯度
        optimizer.step()                  # 5. 更新参数

💡 为什么叫"随机"梯度下降?
每次选取的 mini-batch 是随机的,因此每次迭代的梯度都是真实梯度的带噪声估计。这个噪声反而有助于跳出局部极小点。


五、自动微分:PyTorch 最性感的部分

终于到了本文的重头戏

loss.backward() 这一行到底干了什么?梯度是怎么算出来的?动态图又是啥?

这就要从反向传播算法讲起——它是训练深度神经网络的核心算法

5.1 链式法则:微积分的奠基石

反向传播的数学根基是链式法则(Chain Rule)——复合函数求导的规则。

考虑复合函数 y = f ( g ( x ) ) y = f(g(x)) y=f(g(x)),令 u = g ( x ) u = g(x) u=g(x)

d y d x = d y d u ⋅ d u d x = f ′ ( g ( x ) ) ⋅ g ′ ( x ) \frac{dy}{dx} = \frac{dy}{du} \cdot \frac{du}{dx} = f'(g(x)) \cdot g'(x) dxdy=dudydxdu=f(g(x))g(x)

💡 一句话记忆
链式法则:一个参数的梯度 = 从输出到该参数的路径上所有局部导数的乘积

神经网络视角

神经网络本质上是一个超大规模复合函数

a ( 1 ) = W ( 1 ) x + b ( 1 ) , a ( 2 ) = W ( 2 ) σ ( a ( 1 ) ) + b ( 2 ) , y ^ = σ ( a ( 2 ) ) a^{(1)} = W^{(1)}x + b^{(1)}, \quad a^{(2)} = W^{(2)}\sigma(a^{(1)}) + b^{(2)}, \quad \hat{y} = \sigma(a^{(2)}) a(1)=W(1)x+b(1),a(2)=W(2)σ(a(1))+b(2),y^=σ(a(2))

损失 L \mathcal{L} L 关于第一层权重 W ( 1 ) W^{(1)} W(1) 的导数,根据链式法则拆解:

∂ L ∂ W ( 1 ) = ∂ L ∂ y ^ ⋅ ∂ y ^ ∂ a ( 2 ) ⋅ ∂ a ( 2 ) ∂ a ( 1 ) ⋅ ∂ a ( 1 ) ∂ W ( 1 ) \frac{\partial \mathcal{L}}{\partial W^{(1)}} = \frac{\partial \mathcal{L}}{\partial \hat{y}} \cdot \frac{\partial \hat{y}}{\partial a^{(2)}} \cdot \frac{\partial a^{(2)}}{\partial a^{(1)}} \cdot \frac{\partial a^{(1)}}{\partial W^{(1)}} W(1)L=y^La(2)y^a(1)a(2)W(1)a(1)

关键洞察:模块化

链式法则带来的最大好处是模块化——每一层只关心两件事:

  1. 自己的局部梯度(forward 时算出)
  2. 来自上层的梯度信号(backward 时接收)
训练阶段 每层做什么
前向传播 计算输出 y k = f k ( y k − 1 ) y_k = f_k(y_{k-1}) yk=fk(yk1),并缓存局部导数
反向传播 接收上游累积梯度,乘以本地局部导数,传递给前一层
参数更新 用累积梯度 × 局部导数算出对参数的梯度

这种"局部计算、全局传递"的范式让每一层的实现完全独立——这正是反向传播能规模化的根本原因。

与数值微分的对比
方法 计算复杂度 精确度 适用性
数值微分 O ( n 2 ) O(n^2) O(n2) 近似(截断误差) 梯度校验
解析微分(链式 + BP) O ( n ) O(n) O(n) 精确 实际训练

链式法则让梯度计算从 O ( n 2 ) O(n^2) O(n2) 降到了 O ( n ) O(n) O(n)使得训练深层网络成为可能

5.2 计算图:把代码变成图

计算图就是一个有向无环图——节点是张量或运算操作,边是数据流动方向。考虑最简单的单层神经网络,具有输入 x、参数 w、偏置 b 以及损失函数:
在这里插入图片描述

PyTorch 把节点分成两类:

  • 叶子节点(Leaf Nodes):用户直接创建的张量,没有 grad_fn。典型代表是 weightbias、输入数据。
  • 非叶子节点(Non-leaf Nodes):通过运算产生的中间张量,都有一个 grad_fn 指向产生它的运算(如 AddBackward0MulBackward0),反向传播时用来定位上游梯度。
import torch

x = torch.tensor(2.0, requires_grad=True)   # 叶子节点
w = torch.tensor(3.0, requires_grad=True)   # 叶子节点
y = w * x                                    # 非叶子节点,grad_fn=<MulBackward0>
z = y + 1                                    # 非叶子节点,grad_fn=<AddBackward0>

print(x.is_leaf, w.is_leaf)                  # True True
print(y.is_leaf, z.is_leaf)                  # False False
print(z.grad_fn)                             # <AddBackward0 object>
概念 含义 说明
requires_grad 是否追踪梯度 设为 True 才参与反向传播
grad_fn 记录如何计算此张量 反向传播时定位上游节点
is_leaf 是否叶子节点 用户创建为 True,计算得到为 False
backward() 从该节点反向传播 标量直接调,向量需传 gradient
data 张量的实际数据 访问底层存储

5.3 动态图 vs 静态图

理解 PyTorch 的计算图,必须先和传统的静态图(TensorFlow 1.x)对比。

静态图(Define-and-Run):先完整定义整个计算流程,再放入 Session 中执行。图一旦构建便固定不变,灵活性差,调试困难。

动态图(Define-by-Run):PyTorch 采用"定义即执行"模式——图是在代码运行时、随着前向传播过程逐步、即时构建的。这意味着:

  • 可以直接用 Python 原生的 if/elsefor 等控制流动态改变网络结构
  • 每次前向传播都会生成一张新的计算图
  • 调试时可以像普通 Python 代码一样 print 中间结果、加断点
# PyTorch:原生 Python 控制流自然地改变图结构
def forward(x, n):
    for i in range(n):          # 控制流直接参与图构建
        x = x * 2 + 1
    if n > 5:                   # 条件分支也不需要特殊 API
        x = x.relu()
    return x

💡 这就是 PyTorch 能成为学术界主流的原因
研究阶段的"试错成本"低得多——新想法写出来就能跑,能跑就能 debug。

TensorFlow 2.x 已经默认采用 Eager(即动态图),并通过 tf.function 兼顾静态图优化。但 PyTorch 一直以动态图为第一性原则。

5.4 Autograd:自动微分实战

训练神经网络时,框架会自动构建计算图、跟踪数据流过哪些操作,并通过反向传播算梯度。这就是 Autograd——PyTorch 的自动微分引擎。

import torch

# 定义输入和真实标签
x = torch.tensor([[1.0]])
y_true = torch.tensor([[2.0]])

# 初始化模型参数(requires_grad=True 表示追踪梯度)
w = torch.randn(1, 1, requires_grad=True)
b = torch.randn(1, requires_grad=True)

# 前向传播
z = x * w + b
loss = torch.nn.MSELoss()(z, y_true)

# 反向传播 —— 这一行就是 Autograd 的魔法
loss.backward()

# 查看梯度
print("w.grad =", w.grad)   # 损失对 w 的偏导
print("b.grad =", b.grad)   # 损失对 b 的偏导

# 检查叶子节点
print("x.is_leaf =", x.is_leaf)             # True
print("z.is_leaf =", z.is_leaf)             # False(计算得到)
print("loss.is_leaf =", loss.is_leaf)       # False
4 种常见用法
【用法 1:标量求导(最常见)】
import torch

x = torch.tensor(2.0, requires_grad=True)
y = x ** 2 + 3 * x + 1
y.backward()                            # y 是标量,直接调用
print(x.grad)                           # tensor(7.)  → dy/dx = 2x+3 = 7
【用法 2:非标量求导(需传 gradient)】
x = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)
y = x ** 2                               # y 是向量
y.backward(gradient=torch.ones_like(y)) # 需要传入「上游梯度」
print(x.grad)                           # tensor([2., 4., 6.])

非标量反传时必须显式传入 gradient 参数(与 y 同形状),代表"输出端的初始梯度",默认为全 1。

【用法 3:梯度累积(容易踩坑)】
x = torch.tensor(2.0, requires_grad=True)
y1 = x ** 2
y1.backward()                           # x.grad = 4
y2 = x ** 3
y2.backward()                           # x.grad = 4 + 12 = 16(累加!)
print(x.grad)                           # tensor(16.)

梯度累加器
.grad累加器而非"覆盖器"——多次 backward() 的结果会累加。训练循环里必须loss.backward() 之前调用 optimizer.zero_grad(),否则梯度会越加越乱,模型不收敛。

【用法 4:保留非叶子节点梯度】
x = torch.tensor(2.0, requires_grad=True)
y = x * 3
z = y * 2

# 显式保留非叶子节点的梯度
y.retain_grad()
z.retain_grad()
z.backward()

print(x.grad)                           # tensor(6.)
print(y.grad, z.grad)                   # tensor(2.)  tensor(1.)

默认情况下,非叶子节点的梯度在反向传播后会被释放(除非显式 retain_grad())。

5.5 计算图的生命周期

动态图每次前向传播都会新建一张图。如果不管理,内存很快爆掉。PyTorch 通过一系列机制精确控制生命周期。

默认行为:即用即弃

默认情况下,调用 .backward() 后,中间计算图立即被释放grad_fnNone)。这是为了节省内存——绝大多数训练场景下每个 batch 只需要一次反向传播。

如果对已经反传过的同一张图再次调用 .backward(),会报错:

RuntimeError: Trying to backward through the graph a second time
4 种典型控制手段
【手段 1:retain_graph=True(保留图)】
# 某些复杂损失(如梯度惩罚、共享编码器)需要多次反传
loss1 = compute_loss_a(model, x)
loss1.backward(retain_graph=True)        # 第一次反传,保留图

loss2 = compute_loss_b(model, x)
loss2.backward()                          # 第二次反传仍能成功

⚠️ 内存代价
retain_graph=True 会把整张中间图留在内存里,只在确实需要时使用

【手段 2:torch.no_grad()(推理 / 参数冻结)】
# 验证 / 测试阶段
model.eval()
with torch.no_grad():                     # 所有运算都不进计算图
    for x, y in test_loader:
        pred = model(x)
        loss = loss_fn(pred, y)

节省显存 + 加速推理,绝大多数情况下这是 eval 循环的标准写法

【手段 3:tensor.detach()(剥离某个分支)】
# 切断不需要反传的分支(典型场景:冻结预训练 backbone)
frozen_feat = pretrained_backbone(x).detach()  # 不参与梯度计算
output = new_head(frozen_feat)                 # 只需更新 new_head

.detach() 返回一个共享数据脱离计算图的新张量,常用于迁移学习、RL 中的策略冻结等场景。

【手段 4:requires_grad_(False)(原地关闭追踪)】
# 把叶子节点彻底转为不需要梯度的普通张量
x.requires_grad_(False)                    # 原地修改
# 等价于 x = x.detach()(但不改变对象身份)
图生命周期的完整流程
[创建叶子节点 requires_grad=True]
        ↓
   [前向传播]
        ↓
 [构建动态计算图]
        ↓
  ┌──────────────┐
  │ 是否需要多次反传? │
  └──────┬───────┘
    否   │   是
    ↓    │    ↓
  loss.  │  loss.backward
backward │  retain_graph=True
    │    │    │
    └────┴────┘
         ↓
[沿 grad_fn 链逆向求梯度]
         ↓
   [梯度存入 .grad]
         ↓
 [中间图立即释放,grad_fn 置 None]
         ↓
[optimizer.step 更新参数]
         ↓
[optimizer.zero_grad(set_to_none=True)]
         ↓
   [下一个 epoch]

5.6 Autograd 常见坑

【坑 1:原地操作破坏计算图】
x = torch.tensor(2.0, requires_grad=True)
y = x.relu()
y.add_(1)                              # ⚠️ 原地加法,可能破坏图
y.backward()                           # ❌ RuntimeError

对策:用 y = y + 1 替代 y.add_(1),让 PyTorch 创建新节点。

【坑 2:忘记 zero_grad】
for epoch in range(10):
    for x, y in loader:
        loss = model(x, y)
        loss.backward()                # ⚠️ 梯度累加
        optimizer.step()
        # 忘记 optimizer.zero_grad(),下次反传时梯度是叠加的

对策:用 optimizer.zero_grad(set_to_none=True)(PyTorch 1.7+),比 =0 稍快且更省内存。

【坑 3:GPU 张量直接转 numpy】
x = torch.tensor([1.0], device="cuda", requires_grad=True)
x.numpy()                              # ❌ RuntimeError

对策:先 x.detach().cpu().numpy(),再转 numpy。

【坑 4:in-place 操作与 autograd 版本不匹配】

有些 in-place 操作表面上能跑,但反向传播时会触发:

one of the variables needed for gradient computation has been modified by an inplace operation

对策:默认优先写"非原地"代码,需要极致性能时再分析是否安全。


六、训练循环:从零搭建标准模板

理论说了一堆,是时候把所有零件拼起来。PyTorch 训练的所有任务(CNN、RNN、Transformer),本质上都是下面这 5 行核心循环的无限重复

for epoch in range(epochs):
    pred = model(x)              # 1. 前向
    loss = loss_fn(pred, y)      # 2. 算 loss
    optimizer.zero_grad()        # 3. 清空梯度
    loss.backward()              # 4. 反向传播
    optimizer.step()             # 5. 更新参数

6.1 通用训练模板

import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

def train(model, train_loader, val_loader, optimizer, loss_fn, device, epochs=10):
    """标准训练循环:每个 epoch 训练 + 验证一次"""
    for epoch in range(epochs):
        # ====== 训练阶段 ======
        model.train()                              # 切到训练模式(启用 Dropout/BN 更新)
        train_loss = 0
        for x, y in train_loader:
            x, y = x.to(device), y.to(device)      # 数据搬到 device

            pred = model(x)                        # 1. 前向
            loss = loss_fn(pred, y)                # 2. 算 loss
            optimizer.zero_grad()                  # 3. 清空梯度
            loss.backward()                        # 4. 反向传播
            optimizer.step()                       # 5. 更新参数
            train_loss += loss.item()

        # ====== 验证阶段 ======
        model.eval()                               # 切到评估模式(关闭 Dropout/BN 更新)
        val_loss, correct, total = 0, 0, 0
        with torch.no_grad():                      # 推理不建图,省显存
            for x, y in val_loader:
                x, y = x.to(device), y.to(device)
                pred = model(x)
                val_loss += loss_fn(pred, y).item()
                correct += (pred.argmax(1) == y).sum().item()
                total += y.size(0)

        # ====== 打印日志 ======
        avg_train = train_loss / len(train_loader)
        avg_val   = val_loss / len(val_loader)
        acc       = correct / total
        print(f"Epoch {epoch+1:2d} | "
              f"train_loss={avg_train:.4f} | "
              f"val_loss={avg_val:.4f} | "
              f"val_acc={acc:.4f}")

💡 关键 3 个开关

  • model.train():启用 Dropout、BN 更新滑动统计量
  • model.eval():关闭 Dropout、BN 用滑动均值
  • with torch.no_grad():推理不建计算图,显著省显存

三个的完整调用顺序是:训练 model.train(),验证 model.eval() + no_grad()

6.2 完整实战:手写数字二分类

把上面所有内容串起来,做一个手写数字二分类(区分 0 和 1)的完整例子。

import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
from torchvision import datasets, transforms

# ============== 1. 准备数据(从 MNIST 取 0 和 1) ==============
transform = transforms.Compose([
    transforms.ToTensor(),                       # [0,255] → [0,1]
    transforms.Normalize((0.1307,), (0.3081,)),   # 标准化
    transforms.Lambda(lambda x: x.view(-1)),      # 28x28 → 784
])

# 下载 MNIST(首次会下载,之后从缓存读)
mnist = datasets.MNIST(
    root="./data", train=True, download=True, transform=transform
)

# 只取标签为 0 或 1 的样本
mask = (mnist.targets == 0) | (mnist.targets == 1)
mnist.data = mnist.data[mask]
mnist.targets = mnist.targets[mask]

# 划分训练 / 验证
train_set, val_set = torch.utils.data.random_split(mnist, [len(mnist)-2000, 2000])

train_loader = DataLoader(train_set, batch_size=64, shuffle=True)
val_loader   = DataLoader(val_set,   batch_size=64, shuffle=False)

# ============== 2. 定义模型 ==============
class BinaryClassifier(nn.Module):
    """3 层 MLP,二分类(0 vs 1)"""
    def __init__(self):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(784, 256),
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(256, 64),
            nn.ReLU(),
            nn.Dropout(0.2),
            nn.Linear(64, 1),                    # 输出 1 个值(P(y=1))
        )

    def forward(self, x):
        return self.net(x)

model = BinaryClassifier()

# ============== 3. 损失 + 优化器 ==============
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = model.to(device)

loss_fn = nn.BCEWithLogitsLoss()                 # 二分类推荐
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4)

# ============== 4. 训练 ==============
print(f"Device: {device}")
for epoch in range(5):
    model.train()
    for x, y in train_loader:
        x, y = x.to(device), y.to(device).float()  # BCE 要 float 类型

        pred = model(x)                            # [batch, 1]
        loss = loss_fn(pred.squeeze(1), y)         # [batch] vs [batch]

        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

    # 验证
    model.eval()
    correct, total = 0, 0
    with torch.no_grad():
        for x, y in val_loader:
            x, y = x.to(device), y.to(device)
            pred = model(x).squeeze(1)
            correct += ((pred > 0).float() == y).sum().item()
            total += y.size(0)
    print(f"Epoch {epoch+1} | val_acc = {correct/total:.4f}")

预期输出

Device: cuda
Epoch 1 | val_acc = 0.9985
Epoch 2 | val_acc = 0.9990
Epoch 3 | val_acc = 0.9995
Epoch 4 | val_acc = 0.9995
Epoch 5 | val_acc = 0.9995

5 个 epoch 就能在 MNIST 二分类上达到 99.9%+ 的准确率。

💡 代码里几个关键点

  1. pred.squeeze(1):模型输出 [batch, 1],BCEWithLogitsLoss 需要 [batch]
  2. y.float():BCE 系列的 target 必须是 float
  3. (pred > 0).float():因为我们没加 Sigmoid,logits > 0 即判为 1
  4. with torch.no_grad():验证时不建计算图,省显存

七、参数初始化:训练开始前的「底子」

参数初始化直接影响模型能否收敛以及收敛速度。初始化不当会导致梯度消失 / 爆炸,模型根本训不动。

核心原则:让每层输出的方差保持稳定,避免信号在传播过程中被放大或缩小到零。

import torch.nn.init as init

7.1 常数初始化(仅用于偏置)

init.zeros_(m.bias)           # 偏置通常初始化为 0
init.constant_(m.weight, 1)   # 权重全相同 → 对称性灾难!
init.eye_(m.weight)           # 单位矩阵初始化(仅用于方阵权重)

⚠️ 对称性灾难
权重全设相同值,所有神经元学到同样的特征,模型表达能力归零。

7.2 随机初始化

init.normal_(m.weight, mean=0, std=0.01)   # 正态分布,小标准差
init.uniform_(m.weight, -0.1, 0.1)         # 均匀分布

简单随机初始化仍会导致梯度消失(方差逐层缩小或放大),所以实际很少单独使用。

7.3 Xavier 初始化(Glorot)

适合 Tanh / Sigmoid 等饱和型激活函数。 核心思想是让每层输出的方差等于输入的方差,反向传播时梯度的方差也保持稳定:

init.xavier_uniform_(m.weight)   # 均匀分布版(默认推荐)
init.xavier_normal_(m.weight)    # 正态分布版

均匀分布方差 = 2 f a n i n + f a n o u t \frac{2}{fan_{in} + fan_{out}} fanin+fanout2

7.4 He 初始化(Kaiming)

适合 ReLU / PReLU / LeakyReLU 等非饱和型激活函数。 ReLU 会将一半神经元置零导致方差减半,He 初始化通过放大方差来补偿:

init.kaiming_uniform_(m.weight, mode='fan_in', nonlinearity='relu')  # 默认推荐
init.kaiming_normal_(m.weight, mode='fan_in', nonlinearity='relu')

ReLU 版方差 = 2 f a n i n \frac{2}{fan_{in}} fanin2(比 Xavier 大一倍)

7.5 批量初始化(apply 递归)

通过 model.apply() 递归地对所有层统一应用初始化策略,适合为不同类型的层设置不同规则

def init_weights(m):
    if isinstance(m, nn.Linear):
        init.kaiming_uniform_(m.weight)        # Linear 用 He
        if m.bias is not None:
            init.zeros_(m.bias)                 # bias 全 0
    elif isinstance(m, nn.Conv2d):
        init.kaiming_uniform_(m.weight, mode='fan_in', nonlinearity='relu')

model.apply(init_weights)

7.6 快速选择指南

激活函数 推荐初始化 原理
ReLU / LeakyReLU / PReLU He (Kaiming) 补偿 ReLU 置零导致的方差减半
Tanh / Sigmoid Xavier (Glorot) 保持前向/反向方差一致
无激活 / Linear 输出层 Xavier 或 He 均可
偏置 bias 常数 0 无特殊需求一律初始化为 0

💡 好消息
PyTorch 的 nn.Linearnn.Conv2d 等层已经内置了合理的默认初始化(Linear 用 Kaiming、Conv2d 用 Kaiming)。大多数情况下不用手动初始化,模型就能训。手动初始化通常只在复现特定论文时需要。


八、优化器:拿到梯度后,参数怎么更新?

优化器决定"拿到梯度后,参数怎么更新"。PyTorch 在 torch.optim 里内置了所有主流优化器。

8.1 SGD + Momentum(最经典)

Momentum(动量法)会保存历史梯度并给予一定权重,让更新方向更稳定:

v ← α v − η ∇ v \leftarrow \alpha v - \eta \nabla vαvη

W ← W + v W \leftarrow W + v WW+v

optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9)

8.2 学习率调度器

固定学习率很难同时满足"前期快速下降"和"后期精细收敛"的需求。学习率调度器让 lr 随训练进程动态调整

【StepLR(等间隔衰减)】
scheduler = optim.lr_scheduler.StepLR(
    optimizer, step_size=30, gamma=0.1
)   # 每 30 个 epoch,lr 乘以 0.1
【MultiStepLR(指定间隔衰减)】
scheduler = optim.lr_scheduler.MultiStepLR(
    optimizer, milestones=[30, 60, 90], gamma=0.1
)   # 在 30/60/90 epoch 处衰减
【ExponentialLR(指数衰减)】
scheduler = optim.lr_scheduler.ExponentialLR(
    optimizer, gamma=0.99
)   # 每个 epoch lr *= 0.99(缓慢下降)
【CosineAnnealingLR(余弦退火)】
scheduler = optim.lr_scheduler.CosineAnnealingLR(
    optimizer, T_max=100, eta_min=1e-6
)   # 按余弦曲线从初始 lr 平滑降到 eta_min

💡 调度器调用顺序

for epoch in range(epochs):
    train(...)
    validate(...)
    optimizer.step()   # 先更新参数
    scheduler.step()   # 再更新 lr(每个 epoch 调一次)

8.3 自适应优化器

自适应优化器给不同参数不同的学习率——梯度大的参数小步走,梯度小的参数大步走。

【Adagrad(自适应梯度)】

给不同参数不同的学习率,适合稀疏数据(如 NLP 中的词嵌入):

optimizer = optim.Adagrad(model.parameters(), lr=0.01)
【RMSprop】

Adagrad 累积平方梯度会单调递增导致学习率过早降到 0。RMSProp 引入指数加权移动平均(EMA) 逐步遗忘旧梯度:

h ← α h + ( 1 − α ) ∇ 2 h \leftarrow \alpha h + (1-\alpha)\nabla^2 hαh+(1α)2

W ← W − η 1 h + ϵ ∇ W \leftarrow W - \eta \frac{1}{\sqrt{h} + \epsilon} \nabla WWηh +ϵ1

optimizer = optim.RMSprop(model.parameters(), lr=0.001, alpha=0.99)
【Adam(最常用默认)】

Adam = Momentum + RMSprop,同时维护一阶矩(均值)二阶矩(方差) 的指数移动平均,几乎是所有深度学习任务的默认起点

m t = β 1 m t − 1 + ( 1 − β 1 ) g t m_t = \beta_1 m_{t-1} + (1 - \beta_1) g_t mt=β1mt1+(1β1)gt

v t = β 2 v t − 1 + ( 1 − β 2 ) g t 2 v_t = \beta_2 v_{t-1} + (1 - \beta_2) g_t^2 vt=β2vt1+(1β2)gt2

m ^ t = m t 1 − β 1 t , v ^ t = v t 1 − β 2 t \hat{m}_t = \frac{m_t}{1 - \beta_1^t}, \quad \hat{v}_t = \frac{v_t}{1 - \beta_2^t} m^t=1β1tmt,v^t=1β2tvt

θ t = θ t − 1 − η v ^ t + ϵ m ^ t \theta_t = \theta_{t-1} - \frac{\eta}{\sqrt{\hat{v}_t} + \epsilon} \hat{m}_t θt=θt1v^t +ϵηm^t

optimizer = optim.Adam(model.parameters(), lr=0.001, betas=(0.9, 0.999))
【AdamW(Transformer 时代标配)】

Adam 中 PyTorch 的 weight_decay 并非严格等价于 L2 正则(会和自适应学习率耦合)。AdamW 把权重衰减与梯度更新解耦,效果更好:

optimizer = optim.AdamW(model.parameters(), lr=0.001, weight_decay=0.01)

💡 推荐:训练 Transformer / 大模型时优先用 AdamW

8.4 优化器选择速查表

优化器 适用场景 学习率 推荐度
SGD + Momentum 图像分类(CNN)、追求极致泛化 0.01 ~ 0.1 ⭐⭐⭐
Adam 通用默认、RNN、稀疏数据 1e-4 ~ 1e-3 ⭐⭐⭐⭐⭐
AdamW Transformer、大模型迁移学习 1e-4 ~ 1e-3 ⭐⭐⭐⭐⭐
RMSprop RNN(已逐渐被 Adam 取代) 1e-4 ⭐⭐
Adagrad 稀疏数据(NLP 词嵌入) 0.01 ⭐⭐

💡 三个关键超参

  • lr(学习率):最敏感。太大 loss 震荡,太小学得慢。一般从 1e-3 试起
  • momentum(动量):SGD 专属,常见 0.9。Adam 内部已处理,不需要
  • weight_decay(权重衰减):L2 正则强度,防过拟合。常用 1e-4 ~ 1e-2

九、正则化:让模型不"死记硬背"

过拟合的本质是模型对训练集拟合太好,反而学不到泛化规律。正则化通过额外约束让模型学到更平滑的规律。

9.1 Batch Normalization(批量标准化)

📝 官方文档:详见 BatchNorm Layers 文档

BN 的核心目的是调整各层的激活值分布使其拥有适当的广度,从而缓解"内部协变量偏移"问题。

插入位置

线性层(全连接层 / 卷积层)之后,激活函数之前

Linear / Conv2d  →  BatchNorm  →  Activation
三大优点
  • 使学习快速进行(允许更高的学习率
  • 不那么依赖初始值(对初始值不用那么神经质)
  • 抑制过拟合(降低 Dropout 等的必要性)
计算流程

μ = 1 n ∑ i = 1 n x i , σ 2 = 1 n ∑ i = 1 n ( x i − μ ) 2 \mu = \frac{1}{n}\sum_{i=1}^{n} x_i, \quad \sigma^2 = \frac{1}{n}\sum_{i=1}^{n}(x_i - \mu)^2 μ=n1i=1nxi,σ2=n1i=1n(xiμ)2

x ^ = x − μ σ 2 + ε , y = γ x ^ + β \hat{x} = \frac{x - \mu}{\sqrt{\sigma^2 + \varepsilon}}, \quad y = \gamma \hat{x} + \beta x^=σ2+ε xμ,y=γx^+β

💡 为什么要 γ \gamma γ β \beta β
标准化后分布被强制为标准正态,会限制网络表达能力。所以允许网络自己学"最合适的分布"。

训练 vs 推理
  • 训练时:用当前 batch 的均值和方差做归一化,并更新滑动平均统计量
  • 推理时:用训练阶段累积的滑动均值 / 方差,保证结果稳定
PyTorch 实现

通过 torch.nn 下的 _BatchNorm 类实现,针对不同输入形状调用不同子类:

【BatchNorm1d(全连接)】

适用于 (N, C)(N, C, L) 形状的输入。

import torch
from torch import nn

x = torch.randint(0, 10, (5, 3)).float()    # 5 样本,3 特征
bn = nn.BatchNorm1d(num_features=3)         # num_features = 特征数
y = bn(x)
【BatchNorm2d(图像,最常用)】

适用于 (N, C, H, W) 形状的输入。

x = torch.randn(8, 64, 28, 28)              # batch=8,64 通道
bn = nn.BatchNorm2d(num_features=64)        # num_features = 通道数
y = bn(x)                                    # shape 不变
【BatchNorm3d(视频)】

适用于 (N, C, D, H, W) 形状的输入。

x = torch.randn(4, 16, 8, 32, 32)
bn = nn.BatchNorm3d(num_features=16)
y = bn(x)

9.2 权值衰减(Weight Decay)

通过在学习过程中对大的权重进行"惩罚",可以有效抑制过拟合。这种方法被称为权值衰减,因为很多过拟合的原因就是权重参数取值过大。

一般对损失函数加上 L2 范数的平方

L ′ = L + 1 2 ⋅ λ ⋅ ∥ W ∥ 2 2 L' = L + \frac{1}{2} \cdot \lambda \cdot \|W\|_2^2 L=L+21λW22

惩罚项对 W W W 求导得到 λ W \lambda W λW。权重更新公式变为:

W ← ( 1 − η λ ) W − η ∂ L ∂ W W \leftarrow (1 - \eta\lambda)W - \eta \frac{\partial L}{\partial W} W(1ηλ)WηWL

可见每一步更新时,权重都会先被乘以一个小于 1 的系数——这就是"衰减"的来源。

📝 PyTorch 实现
在 PyTorch 优化器中,默认就是 L2 正则化。通过 weight_decay 参数控制强度:

from torch import optim

optimizer = optim.SGD(model.parameters(), lr=0.1, weight_decay=1e-2)
# 常用取值:1e-4(轻微)~ 1e-2(较强)

💡 训练 Transformer / 大模型时用 AdamW 配合 weight_decay=0.01 是当前最佳实践。

9.3 Dropout(随机失活)

Dropout 在训练时以概率 p p p 随机关闭神经元(输出置 0),未被关闭的输出以 1 1 − p \frac{1}{1-p} 1p1 的比例缩放(Inverted Dropout),以保持期望值不变。

  • 训练时:随机失活,输出要缩放
  • 测试时不使用 Dropout,所有神经元保持激活,直接推理

每次迭代训练的是结构不同的子网络,最终推理相当于对大量子网络做了模型集成(Ensemble)

⚠️ 务必切换 model.eval()
推理前必须调用 model.eval(),PyTorch 才会自动关闭 Dropout。否则推理结果会有随机性,预测不稳定。

import torch
from torch import nn

x = torch.randint(1, 10, (10,)).float()
dropout = nn.Dropout(p=0.5)
dropout.train()         # 训练模式:失活生效
x = dropout(x)

常用取值

  • 全连接层:p = 0.5
  • 卷积层:p = 0.1 ~ 0.3(卷积层冗余度较低,不宜过大)
  • RNN:推荐变分 Dropout(Variational Dropout)

9.4 三种正则化方法速查表

方法 核心思想 主要作用 位置 超参
BatchNorm 标准化每层输入分布 加速训练 + 轻微正则 线性层之后,激活函数之前 eps, momentum
权值衰减 L2 范数惩罚大权重 抑制过拟合 优化器层 weight_decay(λ)
Dropout 训练时随机失活 强制冗余 + 隐式集成 激活函数之后,线性层之前 p(失活概率)

实际工程中三者经常叠加使用BN + Dropout + 适度 weight_decay 是非常常见的组合。


十、总结

一张图总结

                    [数据准备]
                        ↓
                Dataset + DataLoader
                        ↓
    [模型定义] nn.Module → 初始化 → 搬到 device
                        ↓
              [损失函数] loss_fn (按任务选)
                        ↓
              [优化器] optim (Adam/AdamW 默认)
                        ↓
    ┌───────────── 训练循环 5 行 ─────────────┐
    │  pred = model(x)                        │
    │  loss = loss_fn(pred, y)                │
    │  optimizer.zero_grad()                  │
    │  loss.backward()    ← Autograd 自动求导  │
    │  optimizer.step()                       │
    └────────────────────────────────────────┘
                        ↓
        [验证] model.eval() + no_grad()
                        ↓
            [调优] lr scheduler / 正则化

本文关键 API 速记

类别 API 一句话
损失 nn.CrossEntropyLoss() 多分类(内置 Softmax)
损失 nn.BCEWithLogitsLoss() 二分类(内置 Sigmoid)
损失 nn.MSELoss() / nn.L1Loss() 回归
数据 TensorDataset(X, y) Tensor 打包成数据集
数据 DataLoader(ds, batch_size, shuffle) 分批 + 打乱 + 多线程
自动微分 loss.backward() 反向传播,自动算梯度
自动微分 tensor.requires_grad=True 开启梯度追踪
自动微分 torch.no_grad() 推理 / 冻结参数
优化器 optim.AdamW(params, lr, weight_decay) Transformer 时代标配
优化器 optim.lr_scheduler.StepLR(opt, step_size, gamma) 等间隔衰减 lr
初始化 init.kaiming_uniform_(m.weight) ReLU 用 He
初始化 init.xavier_uniform_(m.weight) Tanh/Sigmoid 用 Xavier
正则化 nn.BatchNorm2d(num_features) 卷积后归一化
正则化 nn.Dropout(p=0.5) 全连接后失活
正则化 optim.AdamW(..., weight_decay=0.01) L2 正则

推荐下一步阅读

如果本文对你有帮助,欢迎点赞、收藏、转发 👍

有任何问题,评论区见!

Logo

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

更多推荐