损失函数与反向传播

损失函数把「这次错得多离谱」压成一个数字,反向传播把这个数字沿着网络倒着摊回每一个参数,算出各自该负多少责任。两件事合起来只回答一个问题:下一步该往哪边调、调多少。注意它们都不改参数。

30″30 秒看懂损失与反向传播

接着上一页的公司比喻往下走:老板拍完板,结果送到客户手里,客户退回来一张批改过的卷子。卷子上圈着一个刺眼的数字——这次总共扣了 12 分。接下来公司要做的,不是马上换人,而是先把这 12 分摊回到每一级:老板担 6 分、总监担 4 分、主管担 2 分。

把一整张卷子的对错概括成一个数字,这件事叫损失函数(loss function)。把这个数字沿着汇报链条倒着摊回每个人头上,这件事叫反向传播(back propagation)。摊到某个人头上的那份责任,就是他的梯度

图① 30 秒看懂:损失函数是那张卷子的总扣分,反向传播只摊责任不改人
图① 30 秒看懂:损失函数是那张卷子的总扣分,反向传播只摊责任不改人

图里右侧那个大括号写着一句必须记死的话:反向传播只算责任,不改人。它算完 6 分 / 4 分 / 2 分就收工了,谁都没被调岗。真正按责任大小去调整每个人的,是紧随其后的优化器那一步。这条分工贯穿整页。

比喻里的角色对应的技术概念它到底是什么
客户退回的卷子预测值 vs 真实值网络输出 y_pred 和标签 y_true 摆在一起比对
卷面总扣分损失 loss一个标量;越大说明这次错得越离谱
扣分怎么算损失函数分类用交叉熵,回归用 MSE / MAE / SmoothL1,选错了整个训练方向就偏了
「你担 6 分」梯度 .grad损失对某个参数的偏导数,即「这个参数该为当前误差负多少责任」
逐级往回摊反向传播 / 链式法则从输出层往输入层,把各段导数连乘着传回去
按责任调整每个人optimizer.step()唯一真正改参数的一步
清掉上次的账optimizer.zero_grad()梯度默认累加,不清零这次就会带着上次的旧账算
一批卷子批一次一次 iteration一个 batch 走完「前向 → 算损失 → 清零 → 反向 → 更新」
全班卷子批完一轮一个 epoch全部样本完整过一遍
⛔ 整页只有一条铁律 损失函数负责给分,反向传播负责摊责任,两者都不改参数。 loss.backward() 做完之后,模型里每个参数的 .grad 被填上了,而参数本身一个数都没变;真正动手的是 optimizer.step()。所以训练循环的五步顺序是死的:前向 → 算损失 → 清零 → 反向 → 更新,少一步或换一次顺序,都会得到一个不报错但永远学不好的模型。
为什么非得压成一个数字 卷子上有几十道题,每道题都有对错,但公司需要的是一个能比大小的总分——这次 12 分、上次 15 分,才说得上「有进步」。数学上也是同一个要求:backward() 只能对标量调用,因为梯度回答的是「这个数变化一点点,各参数各负多少责任」,而「这个数」必须先是一个数。所以任何损失函数的最后一步,都是把一批样本的误差汇总成一个标量

01概念:给分、摊责任,以及训练的三个计量单位

损失函数的定位与别名、前向与反向的分工,还有 epoch / batch / iteration 到底怎么换算

1.1 损失函数是什么

损失函数用来衡量模型当前这套参数的质量,衡量方式很朴素:拿网络输出和真实答案比,把差异折算成一个数。这个数越小,说明这套参数越好。整个训练过程要做的事,就是把这个数变小

同一个东西在不同资料里有四个名字,看到别慌,说的都是它:

叫法英文侧重点
损失函数loss function单个样本的预测与真实值之间的差异
代价函数cost function整个训练集(或一批)损失的平均,规模更大
目标函数objective function优化过程要最小化的那个东西,可能还包含正则项
误差函数error function强调「和正确答案的偏差」这层含义

工程实践中这四个词基本混用。真正要区分清楚的不是名字,而是「这个任务该用哪一个具体的损失函数」——分类和回归的选法完全不同,这是 02 节的主线。

损失值的绝对大小几乎没有可比性 交叉熵的 0.69 和 MSE 的 0.69 之间没有任何关系;同一个 MSE,标签是「万元」还是「元」,数值能差八个数量级。损失只用来纵向比较同一个任务的不同轮次,横向拿它比模型好坏是无效的。判断训练好不好,要看它是否收敛到平台,再配一个独立指标(准确率、和真值的差距)。

1.2 前向传播与反向传播的分工

对比项前向传播 forward反向传播 backward
方向输入层 → 隐藏层 → 输出层输出层 → 隐藏层 → 输入层
在算什么用当前参数算出预测值用链式法则算出每个参数的偏导数
产出预测值,进而得到 loss每个参数的 .grad
改参数吗不改不改
比喻里是什么一线往上逐级汇报,最后老板拍板卷子扣的分逐级往回摊,算出各自担几分

把这两件事和「更新」摆在一起,就是那条五步主循环。其中只有第 ⑤ 步碰了参数

① 前向y_pred = model(x)
② 算损失loss = criterion(y_pred, y)
③ 清零optimizer.zero_grad()
④ 反向loss.backward() 填满 .grad
⑤ 更新optimizer.step() 真正改参数
下一批回到 ①

更新那一步的公式仍然是梯度下降:w ← w − lr × ∂loss/∂w。学习率 lr 太小则每轮挪一点点、训练时间成本高;太大则可能一步跨过最优解,在两侧来回震荡甚至发散。所以学习率往往还要随训练过程动态调整——那是优化器那一页的主题。

1.3 epoch、batch_size、iteration

这三个词天天出现在训练日志里,定义其实极简单:

术语定义在比喻里
epoch全部训练样本完整过一遍,即训练轮次全班的卷子批完一整轮
batch_size一次参数更新用多少条样本一次抱多少份卷子上来批
iteration用一个 batch 完成一次参数更新的过程批完这一摞,调整一次做法
图③ epoch / batch / iteration 的三层关系与换算
图③ epoch / batch / iteration 的三层关系与换算

换算只有一条:一个 epoch 的 iteration 数 = 样本总数 ÷ batch_size(除不尽则向上取整,把最后不满的一批也算上)。以图里的数为例:

问题算式结果
一个 epoch 要过多少条样本50000 条,全部
训练集能切成几个 batch50000 ÷ 256 = 195 余 80196 个(最后一批只有 80 条)
一个 epoch 有几次 iteration每个 batch 更新一次196 次
10 个 epoch 一共更新几次196 × 101960 次

batch_size 的取法,梯度下降分成三种,区别只在这一个数上

名称batch_size一个 epoch 更新几次特点
全批量 BGD全部样本1 次梯度方向最准,但一个 epoch 才走一步,显存也扛不住
小批量 Mini-Batch通常 16~512N/B 向上取整工程上唯一实用的选择,在方向准确度和更新频率之间折中
随机 SGD1N 次更新最频繁,但单条样本的梯度方向抖动极大,训练很慢
batch_size 该怎么定 先按显存能装下的最大值取,再往 2 的幂次上对齐(32 / 64 / 128 / 256),这是硬件访存效率最高的档位。改了 batch_size 通常要跟着改学习率:一批样本更多,梯度估计更稳,学习率可以相应调大。显存实在不够又想要大 batch 的效果时,用 4.3 节的梯度累加。

02原理:怎么给分,责任怎么摊回去

分类与回归两条选型路线,链式法则逐步手推,以及梯度消失与爆炸的成因

2.1 分类任务的损失函数

分类任务的输出是「属于每一类的概率」,衡量方式是交叉熵(cross entropy)。它的思想一句话说得完:只盯着正确答案那一格的预测概率,概率越低惩罚越重

单条样本的损失 = −log( p[正确类别] )

为什么是 −log?看几个数就懂了:

正确类别的预测概率−log(p)含义
1.0(完全确信且答对)0不扣分
0.5约 0.69半信半疑,扣一点
0.1约 2.30基本答错,重扣
趋近 0(完全确信但答错)趋向 +∞自信地答错,扣分无上限

最后一行才是交叉熵的精髓:它对「又错又自信」的惩罚是爆炸式的,这会逼着网络在没把握时把概率摊平,而不是乱赌。

多分类:nn.CrossEntropyLoss

多分类的完整路径是 logits → softmax → 取正确类那一格 → 取负对数 → 一批取平均。PyTorch 把这四步全部打包进了 nn.CrossEntropyLoss(),所以它也常被叫作 softmax 损失。由此推出两条硬规矩:

⛔ 用 CrossEntropyLoss 的两条硬规矩 ① 网络最后一层不要再加 softmax。 加了等于连做两次 softmax,概率被压平、损失失真、梯度变钝——而且完全不报错
② 标签必须是 int64 的类别下标,形状 (batch,),取值 0 到 类别数−1。不是 one-hot,也不是 float。

下面这段用纯 Python 把那四步手写一遍,再和官方实现对账,顺便演示「多加一次 softmax」之后损失变成了什么样:

ce_vs_softmax.py —— 交叉熵手算四步与官方实现对账
"""CrossEntropyLoss 自带 softmax 的证明,以及手动再加一次 softmax 的后果。

手算路径:
    1) 对 logits 做 softmax,得到每一类的概率 p
    2) 取出真实类别那一格的概率 p[label]
    3) 这一条样本的损失 = -log(p[label])
    4) 一批样本取平均
把这四步写出来,和 nn.CrossEntropyLoss() 的结果对账。
"""
import math

import torch
import torch.nn as nn


def manual_cross_entropy(logits, labels):
    """纯 Python 版交叉熵,只用 math,不借助任何 torch 的损失函数。"""
    losses = []
    for row, label in zip(logits.tolist(), labels.tolist()):
        m = max(row)                                  # 减最大值,防指数溢出
        exps = [math.exp(v - m) for v in row]
        total = sum(exps)
        p_true = exps[label] / total
        losses.append(-math.log(p_true))
    return sum(losses) / len(losses)


def main():
    # 真实值必须是 int64 的类别下标,不是 one-hot,也不是浮点
    y_true = torch.tensor([1, 2], dtype=torch.int64)
    # 预测值是没过任何激活函数的原始分数 logits
    y_pred = torch.tensor([[0.2, 0.6, 0.2],
                           [0.1, 0.8, 0.1]], requires_grad=True)

    official = nn.CrossEntropyLoss()(y_pred, y_true)
    manual = manual_cross_entropy(y_pred.detach(), y_true)

    print('nn.CrossEntropyLoss = %.10f' % official.item())
    print('手算四步          = %.10f' % manual)
    print('两者之差 = %.12f —— 说明这个损失函数内部确实做了 softmax'
          % abs(official.item() - manual))

    # ---------------------------------------------------------- 错误示范
    # 如果模型最后一层已经加了 softmax,再交给 CrossEntropyLoss,
    # 等于把概率当 logits 又压了一次。
    probs = torch.softmax(y_pred.detach(), dim=-1)
    wrong = nn.CrossEntropyLoss()(probs, y_true)
    print('\n先手动 softmax 再进 CrossEntropyLoss = %.10f' % wrong.item())
    print('正确写法 = %.10f' % official.item())
    print('两次 softmax 之后概率被压平,损失数值失真,梯度也被削弱')
    print('压平前的概率分布:', [round(v, 4) for v in probs[0].tolist()])
    print('再压一次之后    :',
          [round(v, 4) for v in torch.softmax(probs, dim=-1)[0].tolist()])

    # ---------------------------------------------------------- 取出预测类别
    # 推理时才需要 softmax,而且只是为了把分数变成好看的概率;
    # 只想知道预测哪一类的话,直接对 logits 取 argmax 就够了。
    print('\nargmax(logits) =', y_pred.argmax(dim=-1).tolist())
    print('argmax(softmax) =', probs.argmax(dim=-1).tolist(),
          '—— softmax 不改变大小顺序')

    # ---------------------------------------------------------- 二分类的对照
    # 二分类换成 sigmoid + BCELoss:预测值必须已经落在 (0,1)
    p = torch.tensor([0.6901, 0.5459, 0.2469], requires_grad=True)
    t = torch.tensor([0., 1., 0.])
    bce = nn.BCELoss()(p, t)
    hand = -sum(ti * math.log(pi) + (1 - ti) * math.log(1 - pi)
                for pi, ti in zip(p.tolist(), t.tolist())) / 3
    print('\nnn.BCELoss = %.10f   手算 = %.10f' % (bce.item(), hand))
    # 若模型最后一层没加 sigmoid,就该用 BCEWithLogitsLoss,它内部会补上


if __name__ == '__main__':
    main()

二分类:nn.BCELoss

只有两类时不需要 softmax,用 sigmoid 把输出压成一个 (0,1) 的概率 ŷ 就够了,损失换成二分类交叉熵

L = −[ y·log(ŷ) + (1−y)·log(1−ŷ) ]

这个式子是个开关:标签 y=1 时只剩前半截 −log(ŷ)y=0 时只剩后半截 −log(1−ŷ)。两种情况都在说同一件事——正确那一侧的概率越低,扣分越多

场景损失函数网络最后一层标签类型标签形状
多分类nn.CrossEntropyLoss()什么都不加int64 类别下标(batch,)
二分类nn.BCELoss()必须加 sigmoidfloat32 的 0./1.与预测值一致
二分类(推荐)nn.BCEWithLogitsLoss()什么都不加float32 的 0./1.与预测值一致
为什么推荐 BCEWithLogitsLoss 它把 sigmoid 和 BCE 合成一步,内部用了数值稳定的写法。分开写时,若 sigmoid 输出恰好被浮点舍入成 0 或 1,log(0) 会得到 inf,损失直接变 nan。合起来算能避开这个坑。同理,多分类也不要自己写 log(softmax(x))

2.2 回归任务的损失函数

回归的输出是一个连续数值,衡量方式就是「差多少」。三个常用选项的区别,不在损失值本身,而在误差变大时惩罚涨得多快

图② 损失函数选型决策树:先分任务类型,再看怕不怕离群点
图② 损失函数选型决策树:先分任务类型,再看怕不怕离群点
损失公式梯度PyTorch优缺点
MAE(L1)|ŷ − y| 取均值恒为 ±1nn.L1Loss()对离群点不敏感,稳;零点不光滑,逼近最优解时会跨过极小值
MSE(L2)(ŷ − y)² 取均值2×误差,随误差线性增长nn.MSELoss()处处光滑、收敛快;误差大时梯度容易爆炸,一条标错的样本就能主导整批梯度
Smooth L1|误差|<1 时按 L2 算,否则按 L1 算近处随误差变小,远处封顶在 ±1nn.SmoothL1Loss()两头的好处都要:零点光滑、离群点不炸

y_pred = [1.0, 1.0, 1.9]y_true = [2.0, 2.0, 2.0] 这组数手算一遍(三个误差是 −1、−1、−0.1):

损失手算式结果
MAE(1 + 1 + 0.1) ÷ 3约 0.7
MSE(1 + 1 + 0.01) ÷ 3约 0.67
Smooth L1(0.5 + 0.5 + 0.5×0.1²) ÷ 3约 0.335 —— 三个误差都在 ±1 以内,全部走 L2 分支

光看这三个数分不出优劣。把误差拉大到 100 再看梯度,差距就出来了:MSE 的梯度是 200,MAE 和 SmoothL1 都只有 1。这就是「离群点会梯度爆炸」的确切含义——不是损失值大,是梯度大到能把整批更新带偏。

regression_loss_curve.py —— 三种回归损失的值与梯度对照,含离群点实验
"""MSE / MAE / SmoothL1 三条曲线:比损失值更该看的是它们的梯度。

选损失函数其实是在选「误差变大时,惩罚涨得多快」:
    MSE      惩罚按误差平方涨,梯度 = 2·误差,离群点能把梯度顶上天
    MAE      惩罚按误差线性涨,梯度恒为 ±1,稳,但零点处不可导
    SmoothL1 误差小于 1 时像 MSE,大于 1 时像 MAE,两头的好处都要
"""
import torch
import torch.nn as nn


def loss_and_grad(criterion, err):
    """给定一个误差值,返回该损失函数的损失值与对预测值的梯度。"""
    pred = torch.tensor([err], requires_grad=True)
    target = torch.tensor([0.0])
    loss = criterion(pred, target)
    loss.backward()
    return loss.item(), pred.grad.item()


def table():
    mse, mae, smooth = nn.MSELoss(), nn.L1Loss(), nn.SmoothL1Loss()

    print('%8s | %10s %10s | %10s %10s | %10s %10s'
          % ('误差', 'MSE 值', 'MSE 梯度', 'MAE 值', 'MAE 梯度',
             'SL1 值', 'SL1 梯度'))
    for err in (0.1, 0.5, 1.0, 2.0, 5.0, 20.0, 100.0):
        lm, gm = loss_and_grad(mse, err)
        la, ga = loss_and_grad(mae, err)
        ls, gs = loss_and_grad(smooth, err)
        print('%8.1f | %10.2f %10.2f | %10.2f %10.2f | %10.2f %10.2f'
              % (err, lm, gm, la, ga, ls, gs))

    # 看最后一行:误差 100 时 MSE 的梯度是 200,MAE 和 SmoothL1 都只有 1。
    # 一条标错的样本就能让 MSE 主导整批梯度,这就是「离群点梯度爆炸」。


def outlier_effect():
    """同一批数据,只把一条改成离群点,看三种损失各自变化多少。"""
    pred = torch.tensor([1.0, 2.0, 3.0, 4.0])
    clean = torch.tensor([1.1, 2.1, 2.9, 3.9])
    dirty = clean.clone()
    dirty[3] = 100.0                       # 第四条标注错了

    for name, criterion in (('MSE', nn.MSELoss()),
                            ('MAE', nn.L1Loss()),
                            ('SmoothL1', nn.SmoothL1Loss())):
        a = criterion(pred, clean).item()
        b = criterion(pred, dirty).item()
        print('%-9s 干净数据 %8.3f -> 含一条离群点 %10.3f   放大 %8.1f 倍'
              % (name, a, b, b / a))


def zero_point():
    """MAE 在零点不光滑:左右两侧的导数是 -1 和 +1,跳变。"""
    for err in (-0.001, -1e-8, 0.0, 1e-8, 0.001):
        _, g = loss_and_grad(nn.L1Loss(), err)
        print('误差 %12.1e 时 MAE 的梯度 = %+.1f' % (err, g))
    print('梯度不随误差变小而变小,逼近最优解时会在极小值附近来回跨步')

    print()
    for err in (-0.001, -1e-8, 0.0, 1e-8, 0.001):
        _, g = loss_and_grad(nn.SmoothL1Loss(), err)
        print('误差 %12.1e 时 SmoothL1 的梯度 = %+.1e' % (err, g))
    print('SmoothL1 在零点附近梯度跟着误差一起变小,能稳稳停住')


if __name__ == '__main__':
    table()
    print('-' * 60)
    outlier_effect()
    print('-' * 60)
    zero_point()
✅ 三步选定损失函数 先问任务是分类还是回归 · 分类看几类:两类走 BCE 系,多类走 CrossEntropy · 回归看数据脏不脏:干净用 MSE,离群点多用 L1,拿不准用 SmoothL1。图②那棵决策树就是这三步。

2.3 反向传播:链式法则一步步摊回去

有了损失这个标量,接下来的问题是:它对每一个参数的偏导数是多少。答案由链式法则给出——复合函数求导,等于把路径上每一段的导数连乘起来。

以一条最短的路径为例。输出层某个权重 w₅ 影响损失的路径是三段:

第几段这一段在问什么对应的偏导
第 1 段输出变一点,损失变多少∂E/∂out —— 由损失函数的形式决定
第 2 段内部状态值变一点,输出变多少∂out/∂net —— 激活函数的导数,sigmoid 是 out(1−out)
第 3 段权重变一点,内部状态值变多少∂net/∂w₅ —— 就是这条连线上游传来的激活值
合起来∂E/∂w₅ = ∂E/∂out × ∂out/∂net × ∂net/∂w₅,三段相乘

靠近输入的权重要复杂一点,但只多一条规则:一个中间节点如果同时喂给了多个下游节点,它的责任要把各条路径的贡献加起来。比喻里就是——一个主管同时给两位总监供料,两边的扣分都得算他一份。

路径再长也是这两条规则的重复。4.1 节会拿一个带具体数字的两层网络,把每一步手算出来再和 PyTorch 对账;这里先看它在张量层面的样子:

backprop_manual.py —— 矩阵形式的手推反向传播与 autograd 对账
"""手推一次反向传播,再让 PyTorch 算一遍,两个结果必须对得上。

网络:x(2,5) -> z = x @ w + b -> (2,3),损失用 MSE,真实值全 0。
把链式法则一步步写出来,就知道 autograd 没有魔法。
"""
import torch

torch.manual_seed(42)

x = torch.ones(2, 5)                       # 2 个样本、5 个特征,全设成 1 方便手算
y = torch.zeros(2, 3)                      # 真实值全 0
w = torch.randn(5, 3, requires_grad=True)  # 5 个输入 -> 3 个输出
b = torch.randn(3, requires_grad=True)

# ---------- 前向 ----------
z = x @ w + b
loss = torch.nn.MSELoss()(z, y)
print('loss =', loss.item())

# ---------- 反向(交给 autograd)----------
loss.backward()
print('autograd 给的 w.grad:\n', w.grad)
print('autograd 给的 b.grad:\n', b.grad)

# ---------- 反向(自己推)----------
# MSE: loss = (1/N) * Σ (z - y)^2,这里 N = 2*3 = 6,且 y = 0
#   ∂loss/∂z = (2/N) * (z - y) = z / 3
#   z = x·w + b,所以 ∂z/∂w = x,∂z/∂b = 1
#   链式相乘并对样本维求和:
#      ∇w = xᵀ @ (z/3)
#      ∇b = (z/3) 按样本维求和
grad_z = (2.0 / y.numel()) * (z - y)
manual_w = x.t() @ grad_z
manual_b = grad_z.sum(dim=0)

print('手推 w 的最大误差 = %.10f' % (w.grad - manual_w).abs().max().item())
print('手推 b 的最大误差 = %.10f' % (b.grad - manual_b).abs().max().item())
⛔ 梯度默认累加,所以「清零」必须单列一步 PyTorch 的 .grad累加的:不清零的话,第 100 次反向拿到的是前 100 次梯度之和,参数会被推到离谱的地方,表现为 loss 先降后暴涨、最后变 nan。这不是缺陷——梯度累加正是显存不够时模拟大 batch 的手段(见 4.3)。既然累加有用,就必须由你显式声明「这一轮重新开始」。3.2 节会把这个累加过程逐次打印出来,数字可以手算核对。

2.4 梯度消失与梯度爆炸

既然反向传播是连乘,那么整条链的命运就由「每一段的倍率」决定:

梯度消失 vanishing

每段倍率小于 1,连乘之后趋近 0。sigmoid 的导数上限是 0.25,0.25⁵ ≈ 0.000980.25¹⁰ ≈ 9.5×10⁻⁷——靠近输入的几层几乎收不到梯度,参数原地不动,表现为 loss 降一点就停住。

梯度爆炸 exploding

每段倍率大于 1,连乘之后放大到溢出。权重初始化过大或学习率过高时,十层连乘就是十次方,1.5¹⁰ ≈ 57.71.5²⁰ ≈ 3325,很快超出 float32 的表示范围,loss 直接变 nan

问题典型现象处理手段
梯度消失loss 早早停在高位;打印各层梯度范数,前几层比后几层小几个数量级隐藏层换 ReLU;用 He 初始化;加 BatchNorm;结构上用残差连接
梯度爆炸loss 突然飙升或变 nan,往往在训练前几十步就出现梯度裁剪 clip_grad_norm_;降学习率;换更保守的初始化
vanishing_gradient.py —— 逐层梯度范数实验、爆炸复现与梯度裁剪
"""梯度消失与梯度爆炸:把每一层收到的梯度大小逐层打出来。

链式法则是连乘。每一段的倍率小于 1,乘到前面就趋近 0(梯度消失);
每一段大于 1,乘到前面就炸上天(梯度爆炸)。
这份代码把「倍率」这件事直接做成实验。
"""
import torch
import torch.nn as nn


def deep_net(depth, activation, weight_std=None):
    """搭一个 depth 层的窄网络,每层都是 16 -> 16。"""
    layers = []
    for _ in range(depth):
        linear = nn.Linear(16, 16)
        if weight_std is not None:
            nn.init.normal_(linear.weight, mean=0.0, std=weight_std)
            nn.init.zeros_(linear.bias)
        layers.append(linear)
        layers.append(activation())
    layers.append(nn.Linear(16, 1))
    return nn.Sequential(*layers)


def layer_grad_norms(model, x, y):
    """反向一次,返回每个线性层权重梯度的范数(从输入侧数到输出侧)。"""
    loss = ((model(x) - y) ** 2).mean()
    model.zero_grad()
    loss.backward()
    return [m.weight.grad.norm().item()
            for m in model if isinstance(m, nn.Linear)]


def compare_activation():
    torch.manual_seed(0)
    x = torch.randn(64, 16)
    y = torch.randn(64, 1)

    for name, act in (('Sigmoid', nn.Sigmoid), ('Tanh', nn.Tanh), ('ReLU', nn.ReLU)):
        torch.manual_seed(0)
        model = deep_net(10, act)
        norms = layer_grad_norms(model, x, y)
        print('%-8s 第 1 层 %.3e   第 5 层 %.3e   最后一层 %.3e   首尾比 %.2e'
              % (name, norms[0], norms[4], norms[-1],
                 norms[0] / max(norms[-1], 1e-30)))

    print('sigmoid 的导数上限是 0.25,十层连乘最好情形也只有 0.25^10 ≈ 9.5e-07,')
    print('所以靠近输入的那几层几乎收不到梯度,等于没在训练。')


def explode():
    """把权重初始化放大,制造梯度爆炸。"""
    torch.manual_seed(0)
    x = torch.randn(64, 16)
    y = torch.randn(64, 1)

    for std in (0.1, 0.5, 1.0, 2.0):
        torch.manual_seed(0)
        model = deep_net(10, nn.ReLU, weight_std=std)
        norms = layer_grad_norms(model, x, y)
        first = norms[0]
        flag = ' <- 已经溢出成 nan/inf' if not torch.isfinite(
            torch.tensor(first)) else ''
        print('权重 std=%-4s  第 1 层梯度范数 = %.3e%s' % (std, first, flag))

    print('std 每放大一档,连乘十层就放大十次方,很快超出 float32 能表示的范围。')


def clip_demo():
    """梯度裁剪:把整体梯度范数按比例压回阈值以内,方向不变、长度变短。"""
    torch.manual_seed(0)
    model = deep_net(6, nn.ReLU, weight_std=1.5)
    x, y = torch.randn(64, 16), torch.randn(64, 1)

    loss = ((model(x) - y) ** 2).mean()
    model.zero_grad()
    loss.backward()

    before = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
    after = torch.sqrt(sum((p.grad ** 2).sum() for p in model.parameters()))
    print('裁剪前的总梯度范数 = %.4f' % before.item())
    print('裁剪后的总梯度范数 = %.4f(阈值 1.0)' % after.item())
    print('clip_grad_norm_ 必须放在 backward() 之后、step() 之前')


if __name__ == '__main__':
    compare_activation()
    print('-' * 60)
    explode()
    print('-' * 60)
    clip_demo()
梯度裁剪必须放在 backward 之后、step 之前 clip_grad_norm_ 改的是已经算好的 .grad:把整体梯度的长度按比例压回阈值以内,方向不变、只变短。放在 backward() 之前等于裁上一轮的旧梯度,放在 step() 之后则完全没起作用——两种写法都不报错。

03最小代码:五个损失函数各调一次

先把「谁吃什么形状、什么类型」跑一遍,再谈训练

3.1 五个损失函数的最小调用

损失函数的用法都是两步:先实例化,再把预测值和真实值传进去。真正会卡住人的从来不是这两步,而是每个损失函数对输入的类型和形状要求不一样。下面这段把五个常用损失各调一次,重点看注释里标的要求:

loss_min.py —— 五个损失函数的最小调用最小可跑
"""五个损失函数的最小调用:先看清各自吃什么形状、什么类型。"""
import torch
import torch.nn as nn

# ---------- 多分类:CrossEntropyLoss ----------
# 真实值是类别下标,必须 int64;不用自己做 one-hot
y_true = torch.tensor([1, 2], dtype=torch.int64)
# 预测值是每一类的原始分数 logits,不要提前过 softmax
y_pred = torch.tensor([[0.2, 0.6, 0.2],
                       [0.1, 0.8, 0.1]], requires_grad=True)
print('CrossEntropy:', nn.CrossEntropyLoss()(y_pred, y_true).item())

# ---------- 二分类:BCELoss ----------
# 预测值必须已经过 sigmoid,落在 (0,1);真实值是 0/1 的 float
y_pred = torch.tensor([0.6901, 0.5459, 0.2469], requires_grad=True)
y_true = torch.tensor([0, 1, 0], dtype=torch.float32)
print('BCE:', nn.BCELoss()(y_pred, y_true).item())

# ---------- 回归三兄弟 ----------
y_pred = torch.tensor([1.0, 1.0, 1.9], requires_grad=True)
y_true = torch.tensor([2.0, 2.0, 2.0])
print('L1  (MAE):', nn.L1Loss()(y_pred, y_true).item())        # (1+1+0.1)/3
print('MSE (L2) :', nn.MSELoss()(y_pred, y_true).item())       # (1+1+0.01)/3
print('SmoothL1 :', nn.SmoothL1Loss()(y_pred, y_true).item())  # 近处像 L2,远处像 L1
损失函数预测值要求真实值要求最常见的报错
CrossEntropyLoss原始分数 logits,(batch, 类别数)int64 类别下标(batch,)expected scalar type Long but found Float
BCELoss已过 sigmoid,落在 (0,1)float32 的 0./1.all elements of input should be between 0 and 1
L1Loss预测值与真实值形状必须一致,都是浮点不报错但被广播,损失静默算错
MSELoss同上同上
SmoothL1Loss同上同上

手算核对一下多分类那一段:第一条样本的分数是 [0.2, 0.6, 0.2]、正确类别是 1。softmax 之后正确类的概率约 0.427,−log(0.427) ≈ 0.85;第二条样本的分数是 [0.1, 0.8, 0.1] 而正确类别是 2,正确类的概率只有约 0.249,−log(0.249) ≈ 1.39。两条取平均约 1.12第二条明明分数最高的是第 1 类,正确答案却是第 2 类,所以它的扣分接近第一条的两倍——交叉熵的惩罚逻辑在这两个数上看得清清楚楚。

回归那三个损失最危险的地方在于它们不挑剔 MSELoss 接受任意两个能广播的形状。预测值 (batch, 1) 配标签 (batch,) 时它不报错,而是广播成 (batch, batch),算出 batch² 个差值的均值。这个损失数值看起来完全正常,模型却永远学不好。算损失之前打印一次两边的 .shape,是最划算的习惯。

3.2 反向传播的最小三步

损失算出来之后,拿到梯度只要一行 loss.backward()。把它和清零、更新连起来,就是训练循环的后三步。下面这段用一个单参数的例子,演示「不清零」和「清零」的差别——这是整页最该亲手跑一遍的代码

grad_accumulate.py —— 清零与不清零的逐次对照最小可跑
"""梯度累加实验:证明「不清零」到底会错成什么样。"""
import torch

# 同一个前向算三次反向,梯度会一路叠加
x = torch.tensor(3.0, requires_grad=True)
for i in range(1, 4):
    y = x ** 2                 # dy/dx = 2x = 6
    y.backward()
    print('第 %d 次反向后 x.grad = %.1f(正确值应当是 6.0)' % (i, x.grad.item()))
# 输出 6.0 / 12.0 / 18.0 —— 第二次起就已经错了

print('---- 每次反向前清零 ----')
x = torch.tensor(3.0, requires_grad=True)
for i in range(1, 4):
    y = x ** 2
    if x.grad is not None:
        x.grad.zero_()         # 训练循环里 optimizer.zero_grad() 干的就是这件事
    y.backward()
    print('第 %d 次反向后 x.grad = %.1f' % (i, x.grad.item()))
# 三次都是 6.0

这段完全可以手算验证:y = x² 的导数是 2xx = 3 时梯度应当是 6。不清零时连续三次反向会得到 6、12、18(一路累加,全程零报错零警告);每次反向前清零则三次都是 6。能解释清楚 12 和 18 是怎么来的,就理解了为什么训练循环必须有 zero_grad() 这一步。

环境要求 Python 3.10 以上 + pip install torch(CPU 版足够)。本页所有网络都只有几十到几千个参数,是否有 GPU 不影响任何结论。grad_check.py 用到双精度,也只在 CPU 上跑。

04完整案例

先把一个两层网络的反向传播手算到底,再跑通一个 epoch,然后是梯度累加与数值校验

4.1 两层网络:手推一遍,再让 PyTorch 算一遍

这是整页最值得花时间的一段。网络小到能用计算器算完,全程没有随机数,每一个中间值都可以自己复算:

部件取值说明
输入i₁ = 0.05,i₂ = 0.10一条样本,两个特征
隐藏层权重w₁=0.15, w₂=0.20, w₃=0.25, w₄=0.30两个神经元 h₁、h₂,各两个权重
隐藏层偏置b₁ = 0.35这个例子里两个神经元共用一个偏置值
输出层权重w₅=0.40, w₆=0.45, w₇=0.50, w₈=0.55两个输出神经元 o₁、o₂
输出层偏置b₂ = 0.60
激活函数全部用 sigmoid选它是因为导数形式简单:out × (1 − out)
目标值t₁ = 0.01,t₂ = 0.99损失用 E = Σ ½(t − out)²,系数 ½ 是为了求导后正好抵消

第一步:前向,把每个中间值算出来

算式结果
net_h10.15×0.05 + 0.20×0.10 + 0.350.3775
out_h1sigmoid(0.3775)0.593269992
net_h20.25×0.05 + 0.30×0.10 + 0.350.3925
out_h2sigmoid(0.3925)0.596884378
net_o10.40×out_h1 + 0.45×out_h2 + 0.601.105905967
out_o1sigmoid(net_o1)0.751365070
out_o2sigmoid(1.224921404)0.772928465
E_total½(0.01−out_o1)² + ½(0.99−out_o2)²0.298371109

第二步:反向求 w₅,三段相乘

算式结果
∂E/∂out_o1−(t₁ − out_o1) = out_o1 − 0.010.741365070
∂out_o1/∂net_o1out_o1 × (1 − out_o1)0.186815602
∂net_o1/∂w₅上游传来的激活值 out_h10.593269992
∂E/∂w₅三者相乘0.082167041
更新后的 w₅0.40 − 0.5 × 0.082167041(学习率取 0.5)0.358916480

第三步:反向求 w₁,多一步「两条路径求和」

w₁ 在第一层,它先影响 out_h1,而 out_h1 同时喂给了 o₁ 和 o₂ 两个输出神经元,所以两边的责任都得算它一份:

算式结果
∂E/∂out_h1经 o₁ 的那一份 + 经 o₂ 的那一份0.036350306
∂out_h1/∂net_h1out_h1 × (1 − out_h1)0.241300709
∂net_h1/∂w₁输入 i₁0.05
∂E/∂w₁三者相乘0.000438568
更新后的 w₁0.15 − 0.5 × 0.0004385680.149780716

∂E/∂w₅ ≈ 0.0822∂E/∂w₁ ≈ 0.000439 摆在一起看,两者差了约 187 倍。同一次反向,越往输入侧走梯度越小——这正是 2.4 节梯度消失的具体数字版:sigmoid 的导数(这里是 0.1868 和 0.2413)每经一层就乘一次,再乘上小于 1 的权重,链条越长衰减越狠。

bp_two_layer.py —— 两层网络逐步手推与 autograd 逐参数对账完整案例
"""两层小网络的反向传播:先纯手算,再让 PyTorch 算,两边逐个参数对账。

网络结构(激活函数全用 sigmoid,损失用 0.5·Σ(target − out)²):
    输入 i1=0.05, i2=0.10
    隐藏层 h1, h2    权重 w1..w4,共享偏置 b1=0.35
    输出层 o1, o2    权重 w5..w8,共享偏置 b2=0.60
    目标 t1=0.01, t2=0.99

全程没有随机数,每个中间值都能用计算器复算。
"""
import math

import torch


def sigmoid(z):
    return 1.0 / (1.0 + math.exp(-z))


def manual():
    i1, i2 = 0.05, 0.10
    w1, w2, w3, w4, b1 = 0.15, 0.20, 0.25, 0.30, 0.35
    w5, w6, w7, w8, b2 = 0.40, 0.45, 0.50, 0.55, 0.60
    t1, t2 = 0.01, 0.99

    # ---------------- 前向 ----------------
    net_h1 = w1 * i1 + w2 * i2 + b1
    out_h1 = sigmoid(net_h1)
    net_h2 = w3 * i1 + w4 * i2 + b1
    out_h2 = sigmoid(net_h2)

    net_o1 = w5 * out_h1 + w6 * out_h2 + b2
    out_o1 = sigmoid(net_o1)
    net_o2 = w7 * out_h1 + w8 * out_h2 + b2
    out_o2 = sigmoid(net_o2)

    e1 = 0.5 * (t1 - out_o1) ** 2
    e2 = 0.5 * (t2 - out_o2) ** 2

    print('前向:net_h1=%.6f out_h1=%.9f' % (net_h1, out_h1))
    print('      net_h2=%.6f out_h2=%.9f' % (net_h2, out_h2))
    print('      net_o1=%.9f out_o1=%.9f' % (net_o1, out_o1))
    print('      net_o2=%.9f out_o2=%.9f' % (net_o2, out_o2))
    print('      E1=%.9f  E2=%.9f  E_total=%.9f' % (e1, e2, e1 + e2))

    # ---------------- 反向:先求 w5 ----------------
    # 链条三段:E 对 out_o1 → out_o1 对 net_o1 → net_o1 对 w5
    dE_dout_o1 = -(t1 - out_o1)                  # = out_o1 - t1
    dout_o1_dnet_o1 = out_o1 * (1 - out_o1)      # sigmoid 的导数形式
    dnet_o1_dw5 = out_h1                         # net_o1 = w5·out_h1 + ...
    dE_dw5 = dE_dout_o1 * dout_o1_dnet_o1 * dnet_o1_dw5

    print('\n反向 w5:%.9f × %.9f × %.9f = %.9f'
          % (dE_dout_o1, dout_o1_dnet_o1, dnet_o1_dw5, dE_dw5))
    print('学习率 0.5 时 w5 更新为 %.9f' % (w5 - 0.5 * dE_dw5))

    # ---------------- 反向:再求 w1 ----------------
    # w1 藏在第一层,它影响 out_h1,而 out_h1 同时喂给 o1 和 o2,
    # 所以「责任」要从两条路径加起来。
    dE1_dnet_o1 = dE_dout_o1 * dout_o1_dnet_o1
    dE2_dnet_o2 = -(t2 - out_o2) * out_o2 * (1 - out_o2)
    dE_dout_h1 = dE1_dnet_o1 * w5 + dE2_dnet_o2 * w7      # 两条路径求和
    dout_h1_dnet_h1 = out_h1 * (1 - out_h1)
    dE_dw1 = dE_dout_h1 * dout_h1_dnet_h1 * i1

    print('\n反向 w1:两条路径相加得 dE/dout_h1 = %.9f' % dE_dout_h1)
    print('        × %.9f × %.2f = %.9f'
          % (dout_h1_dnet_h1, i1, dE_dw1))
    print('学习率 0.5 时 w1 更新为 %.9f' % (w1 - 0.5 * dE_dw1))

    return dE_dw5, dE_dw1


def by_autograd():
    """同一个网络交给 PyTorch,看它算出来是不是同一组数。"""
    i = torch.tensor([0.05, 0.10])
    w_h = torch.tensor([[0.15, 0.20],       # h1 的权重 w1, w2
                        [0.25, 0.30]], requires_grad=True)   # h2 的 w3, w4
    b_h = torch.tensor([0.35, 0.35], requires_grad=True)
    w_o = torch.tensor([[0.40, 0.45],       # o1 的权重 w5, w6
                        [0.50, 0.55]], requires_grad=True)   # o2 的 w7, w8
    b_o = torch.tensor([0.60, 0.60], requires_grad=True)
    t = torch.tensor([0.01, 0.99])

    out_h = torch.sigmoid(w_h @ i + b_h)
    out_o = torch.sigmoid(w_o @ out_h + b_o)
    loss = (0.5 * (t - out_o) ** 2).sum()    # 注意是求和不是求平均

    loss.backward()
    print('\nautograd:loss = %.9f' % loss.item())
    print('autograd:dE/dw5 = %.9f' % w_o.grad[0, 0].item())
    print('autograd:dE/dw1 = %.9f' % w_h.grad[0, 0].item())
    return w_o.grad[0, 0].item(), w_h.grad[0, 0].item()


if __name__ == '__main__':
    m5, m1 = manual()
    a5, a1 = by_autograd()
    print('\nw5 两边之差 = %.12f' % abs(m5 - a5))
    print('w1 两边之差 = %.12f' % abs(m1 - a1))
    print('对得上,说明 autograd 做的就是这套链式法则,没有别的魔法')
✅ 手推和 autograd 对上了,意味着什么 意味着 backward()没有任何魔法,它做的就是你刚才用计算器做的事——按计算图倒着走,每段乘一次导数,分叉处求和。以后遇到梯度异常,你可以像查算术题一样一段段查,而不是把它当黑箱。

4.2 跑通一个完整的 epoch

把损失、反向、更新串起来,加上 DataLoader 的分批,就是一个真正能训练的循环。这段代码故意造了一批线性可分的假数据(标签由一组固定权重生成),这样「学得动」是有保证的——损失不降就一定是代码写错了,而不是数据太难

train_one_epoch.py —— 完整的训练循环与准确率统计完整案例
"""把损失、反向、更新串成一个完整的 epoch,并打印损失是否真的在降。"""
import torch
from torch import nn, optim
from torch.utils.data import TensorDataset, DataLoader


def make_data(n=512, in_features=8, n_class=3, seed=0):
    """造一批线性可分的假数据:标签由一组固定权重决定,保证学得动。"""
    torch.manual_seed(seed)
    x = torch.randn(n, in_features)
    true_w = torch.randn(in_features, n_class)
    y = (x @ true_w).argmax(dim=1)          # 类别下标,int64
    return TensorDataset(x, y), in_features, n_class


class Net(nn.Module):
    def __init__(self, in_features, n_class):
        super().__init__()
        self.fc = nn.Linear(in_features, 32)
        self.out = nn.Linear(32, n_class)

    def forward(self, x):
        # 输出原始 logits,softmax 由 CrossEntropyLoss 内部完成
        return self.out(torch.relu(self.fc(x)))


def main():
    dataset, in_features, n_class = make_data()
    loader = DataLoader(dataset, batch_size=32, shuffle=True)

    model = Net(in_features, n_class)
    criterion = nn.CrossEntropyLoss()
    optimizer = optim.Adam(model.parameters(), lr=1e-3)

    for epoch in range(10):
        total_loss, total_num, correct = 0.0, 0, 0
        for bx, by in loader:
            logits = model(bx)                 # 前向
            loss = criterion(logits, by)       # 算损失

            optimizer.zero_grad()              # 清零:否则梯度累加
            loss.backward()                    # 反向:只算梯度,参数没动
            optimizer.step()                   # 更新:这一步才真的改参数

            # loss 是这一批的平均值,乘回样本数才能跨批次累加
            total_loss += loss.item() * len(by)
            total_num += len(by)
            correct += (logits.argmax(dim=1) == by).sum().item()

        print('epoch %2d  loss %.5f  acc %.4f'
              % (epoch + 1, total_loss / total_num, correct / total_num))


if __name__ == '__main__':
    main()
代码位置为什么这么写
y = (x @ true_w).argmax(dim=1)argmax 生成类别下标,天然就是 int64,正好满足 CrossEntropyLoss 的要求
return self.out(torch.relu(self.fc(x)))输出原始 logits,不加 softmax——损失函数内部已经含了
zero_grad() → backward() → step()三步顺序不可调换。清零必须在反向之前,更新必须在反向之后
total_loss += loss.item() * len(by)两件事: .item() 取出 Python 浮点数,否则整张计算图被挂住不释放,长训练必爆内存; 乘回样本数,因为 loss 是这一批的平均值,最后一批往往不满,直接按批次平均会把结果带偏
(logits.argmax(dim=1) == by).sum()算准确率不需要 softmax——它不改变大小顺序,直接对 logits 取 argmax 即可
判断训练成功,不能只看 loss 在降 loss 下降只说明「在往拟合这批数据的方向走」。真正的判据是独立指标:分类看准确率,回归看学出来的系数与真值的差距。而且数据自带噪声时 loss 根本不可能降到 0——降到平台就是到头了,继续压下去反而是在背噪声。

4.3 梯度累加:显存不够时怎么模拟大 batch

「梯度默认累加」这件事在 2.3 节是个坑,这里它是个功能。想用 batch_size=128 但显存只装得下 32 时,做法是:连跑 4 个小批只反向不更新,让梯度自然叠加,第 4 批之后再更新一次

环节常规训练梯度累加(累加 4 步)
前向 + 算损失每批一次每批一次
损失缩放不用loss ÷ 4,否则等效学习率被放大 4 倍
backward()每批一次每批一次(梯度自动叠加)
step() / zero_grad()每批一次每 4 批一次
显存占用按大 batch 算只按小 batch 算,这就是全部收益
耗时不省时间,前向反向的次数一次没少

这么做真的等效吗?下面这段把两条路径放在同一组初始权重、同一批数据上跑,直接比最后的梯度:「batch_size=8 累加 4 步」与「batch_size=32 直接算」的梯度应当完全相同。最后一段还故意不除以累加步数,把「梯度被放大几倍」这件事量化出来:

accumulate_large_batch.py —— 累加与大 batch 等效性验证,含真实循环写法完整案例
"""梯度累加模拟大 batch:小批跑 4 次只反向不更新,第 4 次之后才更新一次。

要证明的事:
    「batch_size=8 累加 4 步」得到的梯度,和「batch_size=32 直接算」完全相同。
证明方式:两条路径都从同一份初始权重、同一批数据出发,最后比梯度。
"""
import torch
from torch import nn


def build_model(seed=0):
    """每次都从同一组初始权重出发,两条路径才有可比性。"""
    torch.manual_seed(seed)
    return nn.Sequential(nn.Linear(6, 12), nn.ReLU(), nn.Linear(12, 1))


def big_batch_grad(x, y):
    """常规做法:一次性把 32 条样本喂进去。"""
    model = build_model()
    criterion = nn.MSELoss()

    loss = criterion(model(x), y)
    model.zero_grad()
    loss.backward()
    return [p.grad.clone() for p in model.parameters()], loss.item()


def accumulated_grad(x, y, micro=8, accum_steps=4):
    """省显存做法:每次只喂 8 条,连做 4 次反向,梯度自然叠加。"""
    model = build_model()
    criterion = nn.MSELoss()

    model.zero_grad()                       # 整轮开始前清一次,之后中途不清
    total = 0.0
    for step in range(accum_steps):
        bx = x[step * micro:(step + 1) * micro]
        by = y[step * micro:(step + 1) * micro]

        loss = criterion(model(bx), by)
        # 关键的一除:每个小批的 loss 是它自己那 8 条的均值,
        # 直接叠 4 次相当于把梯度放大 4 倍,等于偷偷把学习率调大了 4 倍。
        loss = loss / accum_steps
        loss.backward()                     # 只反向,不 step、不 zero_grad
        total += loss.item()

    return [p.grad.clone() for p in model.parameters()], total


def main():
    torch.manual_seed(42)
    x = torch.randn(32, 6)
    y = torch.randn(32, 1)

    big, big_loss = big_batch_grad(x, y)
    acc, acc_loss = accumulated_grad(x, y)

    print('一次性大 batch 的 loss = %.8f' % big_loss)
    print('累加四步得到的 loss     = %.8f' % acc_loss)

    worst = max((a - b).abs().max().item() for a, b in zip(big, acc))
    print('两条路径的梯度最大差异 = %.10f' % worst)
    print('差异为 0(或仅剩浮点误差),说明累加确实等效于大 batch')

    # ------------------------------------------------------------ 忘了除会怎样
    model = build_model()
    criterion = nn.MSELoss()
    model.zero_grad()
    for step in range(4):
        bx = x[step * 8:(step + 1) * 8]
        by = y[step * 8:(step + 1) * 8]
        criterion(model(bx), by).backward()     # 故意不除以 4
    no_div = [p.grad.clone() for p in model.parameters()]
    ratio = no_div[0].norm().item() / max(big[0].norm().item(), 1e-12)
    print('\n忘记除以累加步数时,梯度范数是正确值的 %.2f 倍' % ratio)
    print('等效学习率被放大同样的倍数,训练很容易发散')


def train_loop_sketch(model, loader, criterion, optimizer, accum_steps=4):
    """真实训练里的写法,重点在 step / zero_grad 的位置和收尾。"""
    model.train()
    optimizer.zero_grad()
    pending = False                          # 有没有还没生效的累计梯度

    for i, (bx, by) in enumerate(loader, start=1):
        loss = criterion(model(bx), by) / accum_steps
        loss.backward()
        pending = True

        if i % accum_steps == 0:
            optimizer.step()                 # 每 accum_steps 批才更新一次
            optimizer.zero_grad()
            pending = False

    # 收尾:样本数不能被整除时,最后剩下的几批也要补一次更新,
    # 否则这部分梯度算了却从未生效。
    if pending:
        optimizer.step()
        optimizer.zero_grad()


if __name__ == '__main__':
    main()
别忘了把损失除以累加步数 不除的话,4 步叠加出来的梯度是常规大 batch 的 4 倍,等效于把学习率偷偷调大了 4 倍,训练很容易发散。另外收尾也要留意:样本总数不能被「小 batch × 累加步数」整除时,最后剩下的那几批也要补一次 step(),否则这部分梯度算了却从未生效。

4.4 数值梯度校验:怎么确认反向传播没写错

自己实现一个层或一个自定义损失时,怎么知道梯度算对了?用梯度的定义去验它:把某个参数挪动一个极小量 ε,看损失变了多少。

∂loss/∂w ≈ [ loss(w + ε) − loss(w − ε) ] ÷ (2ε)

这叫中心差分。它慢得离谱——每个参数要做两次完整前向,百万参数的模型根本不可能——但正因为它只依赖损失函数本身、完全不碰反向传播的代码,所以能当独立裁判

grad_check.py —— 中心差分与 autograd 的逐参数比对完整案例
"""数值梯度校验:不信 autograd?用「挪一点点看损失变多少」验它一遍。

梯度的定义就是变化率:
    ∂loss/∂w ≈ [ loss(w + ε) − loss(w − ε) ] / (2ε)
这叫中心差分。它慢得没法用来训练,但用来核对反向传播写得对不对,
是自己实现层或自定义损失时最可靠的一招。
"""
import torch
import torch.nn as nn


def numeric_grad(model, params, x, y, criterion, eps=1e-3):
    """对每个参数逐个元素做一次中心差分,返回同形状的数值梯度。"""
    grads = []
    for p in params:
        g = torch.zeros_like(p)
        flat_p, flat_g = p.data.view(-1), g.view(-1)
        for i in range(flat_p.numel()):
            original = flat_p[i].item()

            flat_p[i] = original + eps
            loss_plus = criterion(model(x), y).item()

            flat_p[i] = original - eps
            loss_minus = criterion(model(x), y).item()

            flat_p[i] = original                    # 用完立刻还原,不能留痕
            flat_g[i] = (loss_plus - loss_minus) / (2 * eps)
        grads.append(g)
    return grads


def main():
    torch.manual_seed(0)

    # 小网络 + 小数据:数值梯度是 O(参数量) 次前向,规模一大就跑不动
    model = nn.Sequential(
        nn.Linear(3, 4),
        nn.Tanh(),          # 用 tanh 而不是 relu:relu 在 0 点不可导,差分会失真
        nn.Linear(4, 1),
    ).double()              # 双精度能把差分的舍入误差压下去

    x = torch.randn(8, 3, dtype=torch.float64)
    y = torch.randn(8, 1, dtype=torch.float64)
    criterion = nn.MSELoss()

    params = list(model.parameters())

    # ---------------------------------------------------------- autograd
    model.zero_grad()
    criterion(model(x), y).backward()
    auto = [p.grad.clone() for p in params]

    # ---------------------------------------------------------- 中心差分
    with torch.no_grad():
        numeric = numeric_grad(model, params, x, y, criterion)

    print('%-14s %-14s %-14s %s' % ('参数', 'autograd 范数', '数值梯度范数', '相对误差'))
    for (name, _), a, n in zip(model.named_parameters(), auto, numeric):
        rel = (a - n).norm() / (a.norm() + n.norm() + 1e-12)
        print('%-14s %-14.8f %-14.8f %.3e'
              % (name, a.norm().item(), n.norm().item(), rel.item()))

    print('\n相对误差在 1e-6 量级以内就说明反向传播是对的。')
    print('误差偏大时先查三件事:')
    print('  1) eps 太小被浮点舍入吃掉,或太大导致差分不准,先试 1e-3 ~ 1e-5')
    print('  2) 网络里有 relu 这类折点函数,正好踩在不可导点上')
    print('  3) 模型里有 dropout / BatchNorm 这类每次前向结果都变的层,')
    print('     校验前必须先 model.eval()')

    # ---------------------------------------------------------- 代价有多大
    n_param = sum(p.numel() for p in params)
    print('\n本例参数量 %d,数值校验做了 %d 次前向;' % (n_param, 2 * n_param))
    print('参数上百万时这个做法完全不可行——这正是反向传播的价值:')
    print('一次反向就拿到全部参数的梯度,代价约等于一次前向。')


if __name__ == '__main__':
    main()
校验时的讲究为什么
网络转成 .double()float32 的舍入误差和差分本身的量级接近,会淹没结果
用 tanh 而不是 relurelu 在 0 点不可导,差分正好跨过折点时结果会失真
ε 取 1e-3 到 1e-5太大差分不准,太小被浮点舍入吃掉,两头都不行
model.eval()Dropout 这类层每次前向结果都不同,两次前向就没有可比性了
改完参数立刻还原差分是「试探」,试探完必须把参数放回原位,否则后续全错

相对误差在 1e-6 量级以内就可以认为反向传播是对的。代码最后一段还顺手算了笔账:数值校验要做 2×参数量 次前向,而反向传播一次就能拿到全部参数的梯度,代价约等于一次前向——这正是反向传播这个算法的全部价值所在。

05骨架模板:拿去改就能用

一份按任务类型分支的损失选型模板,外加一份上手前的形状自检

选损失函数这件事,出错基本集中在两处:选错了类型(回归任务用了交叉熵),或者类型选对了但输入不合规(标签是 float、预测值没过 sigmoid、两边形状不一致)。下面这份模板把两处都固化下来——先按任务挑分支,再让 check_shapes 把类型和形状卡一遍

loss_skeleton.py —— 损失选型与输入自检模板,只改 TODO 处可复用模板
"""损失函数选型骨架:按任务类型挑一条分支,其余删掉。"""
import torch
import torch.nn as nn

TASK = 'multiclass'     # TODO: 'multiclass' / 'binary' / 'regression'


def build_criterion(task=TASK):
    if task == 'multiclass':
        # 网络最后一层不加 softmax;标签是 int64 的类别下标
        return nn.CrossEntropyLoss()
    if task == 'binary':
        # 网络最后一层要加 sigmoid;标签是 0./1. 的 float
        return nn.BCELoss()
    if task == 'regression':
        # TODO: 数据干净用 MSELoss,离群点多用 L1Loss,折中用 SmoothL1Loss
        return nn.SmoothL1Loss()
    raise ValueError('未知任务类型:%s' % task)


def check_shapes(logits, target, task=TASK):
    """上手前先自检一遍形状和类型,能挡掉八成的 RuntimeError。"""
    if task == 'multiclass':
        assert logits.dim() == 2, 'logits 应为 (batch, 类别数)'
        assert target.dtype == torch.int64, '多分类标签必须是 int64'
        assert target.dim() == 1, '多分类标签应为 (batch,) 的类别下标'
    elif task == 'binary':
        assert target.dtype == torch.float32, '二分类标签必须是 float32'
        assert logits.shape == target.shape, '二分类预测与标签形状要一致'
    else:
        assert logits.shape == target.shape, '回归预测与标签形状要一致'


if __name__ == '__main__':
    criterion = build_criterion()
    logits = torch.randn(4, 3, requires_grad=True)   # TODO: 换成模型输出
    target = torch.tensor([0, 2, 1, 1])              # TODO: 换成真实标签
    check_shapes(logits, target)
    print(criterion(logits, target).item())

改哪几处

位置多分类二分类回归
TASK'multiclass''binary''regression'
网络最后一层什么都不加torch.sigmoid什么都不加
标签 dtypeint64 类别下标float32 的 0./1.float32
标签形状(batch,)与预测值一致与预测值一致
具体损失CrossEntropyLossBCELoss(或不加 sigmoid 改用 BCEWithLogitsLoss干净数据 MSELoss;离群点多 L1Loss;拿不准 SmoothL1Loss
✅ 模板里那个 check_shapes 值多少钱 三行 assert,挡掉的是本页 06 节里绝大多数报错,其中包括唯一那条不报错的——预测值 (batch, 1) 配标签 (batch,) 被静默广播。断言失败时抛出的是你自己写的中文提示,比框架那句 expected scalar type Long but found Float 好懂得多。训练脚本第一次跑通之后可以留着它,几乎不耗时间。

配套的训练循环骨架

损失函数选好之后,把它塞进五步循环就能训练。循环部分沿用 4.2 那份脚本的结构,四个位置是必须照抄的:

必须照抄的写法漏了会怎样
每个 epoch 开头 model.train()Dropout / BatchNorm 停在推理模式,训练行为不对;验证前则要切 model.eval()
zero_grad()backward()step() 三连漏清零则梯度累加、loss 先降后飙成 nan;顺序错则静默学不动
total_loss += loss.item() * len(by)不用 .item() 会挂住计算图直至 OOM;不乘样本数则最后一个不满的批会把平均值带偏
开头 torch.manual_seed(n)两次结果不一样,无法判断某个改动到底有没有效
模板没替你做的事 没有验证集、没有早停、没有学习率调度、没有梯度裁剪、没有 GPU 搬运。这些各有取舍,要按任务选;学习率与正则化会在《优化器、学习率与正则化》那一页展开。唯一建议提前加上的是梯度裁剪——深层网络或 RNN 上它几乎零成本,位置固定在 backward() 之后、step() 之前。

06易错点汇总

按「损失选型 / 输入类型与形状 / 反向传播 / 训练循环」四类归并,每条都给现象和修法

⚠️ 一、损失函数选型

  • 多分类时网络最后一层加了 softmax,损失又用 CrossEntropyLoss 现象:不报错,但等于连做两次 softmax,概率被压平、损失失真、梯度变钝,训练明显比别人慢。修法:用 CrossEntropyLoss 时输出层直接返回原始分数
  • 二分类忘了加 sigmoid 就用 BCELoss 现象:all elements of input should be between 0 and 1修法:要么前面补 torch.sigmoid,要么直接换 nn.BCEWithLogitsLoss()(推荐,数值更稳)。
  • 回归任务的输出层套了激活函数。 现象:预测值被硬压进 (0,1) 或 [0,+∞),标签一旦超出这个范围就永远学不到。修法:回归输出层什么都不加。
  • 数据里有离群点还在用 MSE。 现象:loss 突然飙升,甚至变 nan。因为 MSE 的梯度是 2×误差,误差 100 时梯度就有 200,一条标错的样本能主导整批更新修法:换 SmoothL1Loss,或先清洗数据。
  • 自己写 log(softmax(x)) 现象:偶发 nan。softmax 输出被浮点舍入成 0 时 log(0) 就是 -inf修法:用框架提供的合并实现,它内部做了数值稳定处理。
  • 拿损失的绝对值横向比模型。 交叉熵的 0.69 和 MSE 的 0.69 之间没有任何关系;换个单位 MSE 能差几个数量级。损失只用于纵向比较同一任务的不同轮次。

⚠️ 二、输入的类型与形状

  • 多分类标签传成了 float。 现象:expected scalar type Long but found Float修法:标签必须是 int64类别下标,用 torch.tensor(labels, dtype=torch.int64)
  • 多分类标签做成了 one-hot。 现象:维度不符而报错。修法CrossEntropyLoss 要的是 (batch,) 的下标,不是 (batch, 类别数) 的 one-hot——它内部自己会取正确类那一格。
  • 类别下标越界。 3 类的任务里出现了下标 3。现象:报越界错误,有时还伴随难懂的 assert 信息。修法:合法取值是 0 到 类别数−1;标签从 1 开始编号的数据集要整体减 1。
  • 预测值 (batch, 1) 与标签 (batch,) 直接算 MSE。 现象:不报错,广播成 (batch, batch),损失数值看着正常、实际完全错误,模型永远学不好。这是本页最危险的一条。 修法:统一 reshape(-1, 1),或算损失前 assert 两边形状一致。
  • 回归标签是 float64 现象:expected scalar type Float but found Double。NumPy / pandas 给的默认是 float64,网络权重是 float32修法:建张量时显式 dtype=torch.float32
  • 把带梯度的 loss 直接 .numpy() 现象:Can't call numpy() on Tensor that requires grad修法loss.item(),或 loss.detach().numpy()

⚠️ 三、反向传播

  • 对非标量调用 backward() 现象:grad can be implicitly created only for scalar outputs修法:先 .sum().mean()。真实训练里这件事由损失函数自动完成——所以损失永远是一个数
  • 同一张计算图 backward() 两次。 现象:Trying to backward through the graph a second time。反向一次之后中间结果就被释放了。修法:重新前向一次;确有需要才用 retain_graph=True,它会显著增加显存占用。
  • 以为 backward() 会更新参数。 它只把 .grad 填上,参数一个都没动。不写 step() 的训练循环 loss 会一直不降,而且不报任何错——最折磨人的静默失败。
  • 去找中间结果的 .grad 现象:打印出 None 并附带一条警告。只有叶子节点默认保留梯度。确实需要时用 .retain_grad()
  • torch.no_grad() 里算损失又想反向。 现象:element 0 of tensors does not require gradno_grad 是给推理和参数更新用的,训练的前向不能包在里面。
  • 梯度裁剪放错位置。 放在 backward() 之前裁的是上一轮的旧梯度,放在 step() 之后完全不起作用,两种都不报错修法:固定放在 backward()step() 之间。

⚠️ 四、训练循环

  • 忘了 zero_grad() 现象:loss 先下降后突然飙升,最后变 nan,因为梯度一轮轮累加、步子越迈越大。修法:把「清零 → 反向 → 更新」当成不可拆的三连。
  • 三步顺序放错。 清零放在 backward() 之后等于把刚算出的梯度抹掉,参数永远不变;step() 放在 backward() 之前用的是上一轮的旧梯度。两种都不报错,只是学不动。
  • 累加 loss 时忘了 .item() 现象:内存/显存持续增长直到 OOM,因为每个 loss 张量都挂着一整张计算图。修法total_loss += loss.item() * len(by)
  • 按批次数求平均而不是按样本数。 最后一个 batch 往往不满,直接除以批数会把平均损失算偏。修法:乘回 len(by) 再除以总样本数。
  • 梯度累加时忘了把损失除以累加步数。 现象:等效学习率被放大数倍,训练发散。修法loss = loss / accum_steps;同时注意收尾时剩余的批次要补一次 step()
  • 验证前忘了 model.eval()torch.no_grad() 现象:Dropout 仍在随机丢弃,验证指标偏低且每次不同;不加 no_grad 还会白白占显存。
  • epoch 和 iteration 搞混。 日志里「step 1000」指的是第 1000 次参数更新,不是第 1000 轮。50000 条样本、batch_size=256 时,一个 epoch 只有 196 次 iteration。

把报错现场跑一遍

上面的条目读一遍记不住,自己触发一遍就记住了。下面这份把六种最常见的损失函数报错原地复现,用 try/except 抓住并打印报错原文,跑到底不会中断:

label_shape_debug.py —— 六种损失函数报错的原地复现与修法排错速查
"""损失函数最常见的六种报错,原地复现一遍,看清报错原文和修法。

每一段都用 try/except 抓住异常,跑到底不中断。
自己触发一次,比读十条注意事项记得牢。
"""
import torch
import torch.nn as nn


def case(title, fn):
    print('=' * 58)
    print(title)
    try:
        fn()
        print('  -> 没有报错')
    except Exception as exc:                     # noqa: BLE001 演示用,要看原文
        print('  -> %s: %s' % (type(exc).__name__, str(exc).split('\n')[0]))


def wrong_label_dtype():
    """多分类标签写成了 float。"""
    logits = torch.randn(4, 3)
    labels = torch.tensor([0., 2., 1., 1.])      # 应该是 int64
    nn.CrossEntropyLoss()(logits, labels)


def right_label_dtype():
    logits = torch.randn(4, 3)
    labels = torch.tensor([0, 2, 1, 1], dtype=torch.int64)
    print('  修法:标签用 int64 的类别下标,损失 = %.6f'
          % nn.CrossEntropyLoss()(logits, labels).item())


def one_hot_label():
    """把标签做成了 one-hot 又当类别下标传。"""
    logits = torch.randn(2, 3)
    labels = torch.tensor([[0, 1, 0], [0, 0, 1]], dtype=torch.int64)
    nn.CrossEntropyLoss()(logits, labels)


def label_out_of_range():
    """类别下标越界:3 类的任务里出现了下标 3。"""
    logits = torch.randn(2, 3)
    labels = torch.tensor([1, 3], dtype=torch.int64)     # 合法范围是 0..2
    nn.CrossEntropyLoss()(logits, labels).backward()


def bce_without_sigmoid():
    """BCELoss 要求预测值落在 (0,1),直接喂原始分数会报错。"""
    logits = torch.tensor([2.5, -1.2, 0.3])
    target = torch.tensor([1., 0., 1.])
    nn.BCELoss()(logits, target)


def bce_fixed():
    logits = torch.tensor([2.5, -1.2, 0.3])
    target = torch.tensor([1., 0., 1.])
    a = nn.BCELoss()(torch.sigmoid(logits), target).item()
    b = nn.BCEWithLogitsLoss()(logits, target).item()
    print('  修法一 sigmoid + BCELoss   = %.8f' % a)
    print('  修法二 BCEWithLogitsLoss   = %.8f(数值更稳,推荐)' % b)


def silent_broadcast():
    """最危险的一种:不报错,但算出来的损失是错的。"""
    pred = torch.randn(8, 1)          # 模型输出
    target = torch.randn(8)           # 标签少了一维
    loss = nn.MSELoss()(pred, target)
    print('  形状 %s%s 被广播成 %s'
          % (tuple(pred.shape), tuple(target.shape),
             tuple((pred - target).shape)))
    print('  损失值 %.6f 看起来完全正常,实际是 64 个差值的均值' % loss.item())
    fixed = nn.MSELoss()(pred, target.reshape(-1, 1))
    print('  修正后 = %.6f,两个数完全不同' % fixed.item())


def backward_twice():
    """同一张计算图反向两次。"""
    w = torch.tensor(2.0, requires_grad=True)
    loss = (w * 3) ** 2
    loss.backward()
    loss.backward()                   # 图已被释放


if __name__ == '__main__':
    case('一、多分类标签是 float', wrong_label_dtype)
    right_label_dtype()
    case('二、多分类标签做成了 one-hot', one_hot_label)
    case('三、类别下标越界', label_out_of_range)
    case('四、BCELoss 收到未过 sigmoid 的原始分数', bce_without_sigmoid)
    bce_fixed()
    case('五、预测与标签形状不一致(不报错,最危险)', silent_broadcast)
    case('六、同一张计算图 backward 两次', backward_twice)
    print('=' * 58)
    print('第五条是唯一不抛异常的一条:算损失前先 assert 两边形状一致。')
第五条才是真正危险的那一种 另外五条都会抛异常,报错就能修。只有「预测与标签形状不一致」这一条不报错:它把 (8,1)(8,) 广播成 (8,8),算出 64 个差值的均值当损失。这个数字看起来完全正常,模型却永远学不好。养成「算损失前先 assert 两边形状」的习惯,这一条就再也不会发生。

按现象反查原因

你看到的现象最可能的原因怎么确认
loss 一直不降,纹丝不动漏了 step(),或参数没进优化器训练前后各打印一次某个权重,看有没有变化
loss 先降后暴涨成 nan忘了 zero_grad(),或学习率过大先把 lr 降一个数量级;仍然如此就查清零那一行
头几步就 nan梯度爆炸,或 log(0)打印总梯度范数;把手写的 log/softmax 换成框架合并实现
loss 降得很慢,前几层梯度接近 0梯度消失逐层打印 weight.grad.norm(),看首尾相差几个数量级
loss 在降但准确率不动标签或形状接错了,损失被静默算错打印 y_pred.shapey.shapey.dtype 三个值
显存一路涨到 OOM累加 loss 时漏了 .item()搜一遍代码里所有 += loss
同样的代码两次结果不同没固定随机种子脚本开头加 torch.manual_seed(n)

07自测题

点击题目展开答案;这些题都能说清楚,这一页就通了

一、损失函数
损失函数、代价函数、目标函数、误差函数有什么区别?

侧重点不同,工程里基本混用:损失函数强调单个样本的差异;代价函数是整个训练集(或一批)损失的平均;目标函数是优化过程要最小化的东西,可能还含正则项;误差函数强调与正确答案的偏差。真正要分清的不是名字,而是「这个任务该用哪一个具体损失」。

交叉熵为什么用 −log(p) 而不是 1 − p

因为 −log 对「又错又自信」的惩罚是爆炸式的:正确类概率趋近 0 时损失趋向 +∞,而 1−p 最多只能扣 1 分。这会逼着网络在没把握时把概率摊平而不是乱赌。对照几个数:p=1 时损失 0,p=0.5 时约 0.69,p=0.1 时约 2.30。

nn.CrossEntropyLoss 时,网络最后一层要不要加 softmax?为什么?

不要加。 这个损失函数内部已经包含了完整四步:softmax → 取正确类那一格 → 取负对数 → 一批取平均。自己再加一次等于连做两次 softmax,概率被压平、损失失真、梯度变钝,而且完全不报错。所以输出层直接返回原始分数 logits。

多分类的标签应该是什么类型和形状?one-hot 行不行?

必须是 int64 的类别下标,形状 (batch,),取值 0 到 类别数−1。不能传 one-hot,也不能是 float——损失函数内部会自己去取正确类那一格。标签从 1 开始编号的数据集要整体减 1,否则会越界报错。

MSE、MAE、SmoothL1 各自的梯度有什么特点?数据里有离群点该选哪个?

MSE 的梯度是 2×误差,随误差线性增长,误差 100 时梯度就有 200 —— 一条标错的样本能主导整批更新,这就是「离群点梯度爆炸」。MAE 的梯度恒为 ±1,对离群点不敏感,但零点不光滑,逼近最优解时会跨过极小值。SmoothL1 在 ±1 以内像 L2、以外像 L1,两头好处都要。有离群点就选 SmoothL1(或先清洗数据)。

二、反向传播
反向传播会改变参数吗?真正改参数的是哪一步?

不会。 loss.backward() 只把每个参数的 .grad 填上,参数本身一个数都没变。真正改参数的是 optimizer.step()。这就是本页铁律:损失函数负责给分,反向传播负责摊责任,两者都不改参数。

写出训练循环的五步,并说明哪两步的顺序绝对不能换。

① 前向 → ② 算损失 → ③ 梯度清零 → ④ 反向 → ⑤ 更新。 清零必须在反向之前(放后面等于把刚算出的梯度抹掉,参数永远不变),更新必须在反向之后(放前面用的是上一轮的旧梯度)。两种顺序错误都不报错,只是静默学不动。

求某个输出层权重的梯度,链条分几段?靠近输入的权重多了什么规则?

输出层权重分三段连乘:∂E/∂out(由损失函数决定)× ∂out/∂net激活函数的导数,sigmoid 是 out(1−out))× ∂net/∂w(上游传来的激活值)。靠近输入的权重多一条规则:一个节点同时喂给多个下游时,各条路径的贡献要相加——比喻里就是一个主管给两位总监供料,两边的扣分都算他一份。

在 4.1 的例子里,∂E/∂w₅ ≈ 0.0822∂E/∂w₁ ≈ 0.000439,差了约 187 倍。这说明什么?

说明越靠近输入的层,梯度越小。因为每往回传一层就要多乘一次激活函数的导数(这里是 0.1868 和 0.2413,都远小于 1),再乘上小于 1 的权重,连乘越多衰减越狠。这就是梯度消失在具体数字上的样子:链条再长几层,前面的参数基本就不动了。

为什么 PyTorch 的 .grad 默认累加而不是自动清零?

因为梯度累加本身是有用的功能:显存装不下大 batch 时,可以连跑几个小 batch 只反向不更新,让梯度自然叠加,等效于一个大 batch。既然累加有用,框架就不能替你决定什么时候「重新开始」,必须由你显式调 zero_grad()。代价是忘了这一步会让 loss 先降后飙成 nan,且全程零报错。

同一张计算图连续 backward() 两次会发生什么?

Trying to backward through the graph a second time:第一次反向之后中间结果就被释放了。正常做法是重新前向一次。确有需要才传 retain_graph=True,但它会显著增加显存占用,不要当默认写法。

三、梯度异常与训练计量
梯度消失和梯度爆炸的共同成因是什么?各自怎么处理?

共同成因是链式法则的连乘:每段倍率小于 1 就逐层衰减到 0(消失),大于 1 就逐层放大到溢出(爆炸)。消失:隐藏层换 ReLU、用 He 初始化、加 BatchNorm、上残差连接。爆炸:梯度裁剪 clip_grad_norm_、降学习率、换更保守的初始化。

sigmoid 的导数上限是多少?十层连乘之后还剩多少?

上限 0.25(出现在 x=0 处)。最好的情形连乘十层是 0.25¹⁰ ≈ 9.5×10⁻⁷,五层是 0.25⁵ ≈ 0.00098。所以 sigmoid 网络五层之内就会出现梯度消失,靠近输入的层几乎收不到梯度。ReLU 正半轴导数恒为 1,连乘多少次都是 1。

梯度裁剪该放在训练循环的哪个位置?放错了会怎样?

固定放在 backward() 之后step() 之前,因为它改的是已经算好的 .grad——把整体梯度长度按比例压回阈值以内,方向不变、只变短。放在 backward() 之前裁的是上一轮旧梯度,放在 step() 之后完全不起作用,两种都不报错

50000 条样本、batch_size=256,一个 epoch 有几次 iteration?10 个 epoch 呢?

50000 ÷ 256 = 195 余 80,保留最后不满的那一批就是 196 次 iteration;10 个 epoch 就是 1960 次参数更新。最后一批只有 80 条样本——这也是为什么累加损失时要乘回样本数再除以总数,按批次数求平均会把结果带偏。

梯度累加模拟大 batch 时,为什么要把损失除以累加步数?它能省时间吗?

不除的话,累加 4 步得到的梯度是常规大 batch 的 4 倍,等效于偷偷把学习率调大 4 倍,很容易发散。它只省显存,不省时间——前向和反向的次数一次都没少。另外收尾时剩余的批次要补一次 step(),否则这部分梯度算了却从未生效。

怎么独立验证自己实现的反向传播是对的?为什么这招不能用来训练?

中心差分∂loss/∂w ≈ [loss(w+ε) − loss(w−ε)] ÷ (2ε),和 autograd 的结果比,相对误差在 1e-6 量级以内就算对。它只依赖损失函数本身、完全不碰反向传播的代码,所以能当独立裁判。不能用来训练是因为每个参数都要做两次完整前向,代价是 2×参数量 次前向;而反向传播一次就拿到全部梯度,代价约等于一次前向——这正是这个算法的全部价值。

术语表

术语含义
损失函数 loss function衡量当前参数质量的函数,把预测与真实值的差异汇总成一个标量
代价函数 / 目标函数 / 误差函数同一件事的不同叫法;代价函数指整批的平均,目标函数可能还含正则项
logits网络输出层未经任何激活的原始分数;CrossEntropyLoss 要的就是它
交叉熵 cross entropy分类损失;单条样本的值是 −log(正确类的预测概率),对「又错又自信」惩罚极重
nn.CrossEntropyLoss多分类损失,内部已含 softmax;标签必须是 int64 的类别下标
nn.BCELoss二分类交叉熵;预测值必须已过 sigmoid,标签是 float32 的 0./1.
nn.BCEWithLogitsLosssigmoid 与 BCE 的合并实现,数值更稳,网络最后一层不用加 sigmoid
MAE / L1 Loss绝对误差均值;梯度恒为 ±1,抗离群点,但零点不光滑
MSE / L2 Loss平方误差均值;梯度是 2×误差,处处光滑,离群点会梯度爆炸
Smooth L1 Loss分段函数:误差在 ±1 内按 L2 算、外按 L1 算,兼顾光滑与抗离群点
前向传播 forward数据从输入层逐层算到输出层,得到预测值与损失;不改参数
反向传播 back propagation从损失出发、沿计算图倒推,用链式法则算出每个参数的梯度;不改参数
链式法则复合函数求导:把路径上各段导数连乘;一个节点喂多个下游时,各路径求和
梯度 gradient损失对某参数的偏导数,含义是「这个参数该为当前误差负多少责任」
叶子节点自己创建、无 grad_fn 的张量,即模型参数;只有它默认保留 .grad
zero_grad().grad 清零;梯度默认累加,所以每轮必须显式清一次
optimizer.step()w ← w − lr × ∂loss/∂w 更新参数,唯一真正改参数的一步
学习率 lr每步迈多大;太小训练慢,太大会跨过最优解甚至发散
epoch全部训练样本完整过一遍
batch_size一次参数更新用多少条样本;常取 2 的幂次
iteration用一个 batch 完成一次参数更新;一个 epoch 的次数 = 向上取整(样本数 ÷ batch_size)
BGD / Mini-Batch / SGD按 batch_size 取全部 / 一小批 / 一条区分的三种梯度下降,区别只在这一个数
梯度累加连跑几个小批只反向不更新,让梯度叠加后再更新一次;省显存不省时间,损失要除以累加步数
梯度消失 vanishing各段倍率小于 1,连乘后趋近 0,靠近输入的层收不到梯度
梯度爆炸 exploding各段倍率大于 1,连乘后溢出,loss 突然飙升或变 nan
梯度裁剪 clip_grad_norm_把整体梯度长度压回阈值内,方向不变;位置固定在 backward()step() 之间
中心差分 / 数值梯度[loss(w+ε) − loss(w−ε)] ÷ (2ε);用来独立校验反向传播,慢到无法用于训练
✅ 一句话收束本页 损失函数把一整张卷子压成一个分数,反向传播把这个分数沿着网络倒着摊回每个参数,优化器再按责任大小动手改。三者各司其职,顺序是死的:前向 → 算损失 → 清零 → 反向 → 更新。到这里训练已经能跑起来了,但「每一步该迈多大、怎么防止死记硬背」还没解决——那是优化器与正则化那一页的事。