←返回大模型学习资料
学习资料第 5 章 · 神经网络基础

CHAPTER 05 · NEURAL NETWORKS

神经网络
基础

神经网络把本章前面的线性模型、损失、梯度和数据边界连成一条可训练的计算链。本章从零解释神经元、层、激活函数、张量形状、自动微分、优化器、批次、正则化和卷积,并用 RNN、LSTM、Seq2Seq 与注意力搭起通往 Transformer 的桥梁;你会看到每个参数为什么更新、每个 loss 数字代表什么,以及一个模型怎样被保存、复盘和交付。示例优先使用 PyTorch 官方接口,同时保留可以手算的小实验。

难度:基础进阶建议:先懂链路,再堆层数代码:Python 3.11+ / PyTorch小节:27 张独立学习卡片
TEXTBOOK MAP · NEURAL NETWORKS

本章学习地图

从一个加权和走到一条能复盘的训练流水线。

阅读方式:先按小节顺序学习;需要回看时可从地图直接跳到某个主题。页面会把每个大节显示为一张独立“书页”,底部的上一节/下一节只在大节之间翻页。

本章的判断标准:不是层数越多越好,而是每个张量形状、损失分母、梯度路径、数据边界、模式切换和检查点都能解释。文中的 PyTorch 示例遵循官方教程的基本职责:模块定义 forward,训练阶段计算 loss、backward、更新参数,评估阶段关闭梯度并切换 eval 模式。

01 · NEURON & PERCEPTRON

神经元与感知机:从一个加权和开始

神经网络并不是一团不可解释的黑箱。最小的神经元只做三件事:读取输入、按权重加权并加上偏置、把结果交给激活函数。理解这条短链路,后面多层网络的参数、形状、梯度和训练循环就都有了落脚点。

5.1.1 加权和与偏置:一个神经元到底学什么

把输入写成向量 x=(x₁,x₂,…,xₙ),把权重写成 w=(w₁,w₂,…,wₙ),神经元先计算 z=w·x+b=Σwᵢxᵢ+b。每个权重表示对应特征对输出的影响方向和强度,偏置 b 则让模型不必被迫经过原点。若输入是“阅读分钟、完成比例、练习次数”,权重的单位会随特征单位变化;所以看到一个很大的权重,不能脱离尺度就说它更重要。

加权和本身仍然是线性的(严格地说,含偏置时是仿射变换)。无论输入有多少维,若只堆叠没有激活函数的线性层,最终仍可合并成一层线性变换。神经网络真正获得表达能力,依赖的是在层与层之间加入非线性,并通过数据和损失函数调整参数。先把一个神经元的职责分清楚,比直接背“深度网络能拟合复杂函数”更重要。

参数和超参数也要分开。权重、偏置是训练中由梯度更新的参数;学习率、隐藏层宽度、激活函数和 batch size 是训练前或训练过程中由我们设定的超参数。参数属于模型状态,超参数属于实验配置。保存检查点时只保存权重而忘记激活选择、输入归一化或类别顺序,仍然无法复现一次预测。

表 5.1A · 一个神经元的符号契约
对象含义形状示例调试问题
x输入特征(n_features,)顺序、单位、范围是否固定
w可学习权重与输入同长是否连接到正确特征
b可学习偏置一个标量是否重复加了偏置
z激活前结果一个或多个 logit是否出现溢出或尺度过大
ŷ激活后或任务输出与目标接口一致输出层是否匹配损失
PYTHON · one_neuron.py
def neuron(features, weights, bias):
    if len(features) != len(weights):
        raise ValueError("特征与权重长度不一致")
    linear = sum(value * weight for value, weight in zip(features, weights)) + bias
    return linear

features = [42.0, 0.75, 3.0]
weights = [0.08, 1.2, -0.15]
bias = 0.5
score = neuron(features, weights, bias)
print("pre-activation:", round(score, 3))
assert isinstance(score, float)

手算一次很有价值。若把完成比例从 0.75 改成 0.85,其他条件不变,输出变化应当约为 1.2×0.1;若把练习次数增加一个,输出反而下降,因为对应权重是负的。这个局部解释只适用于当前模型和输入尺度,不能直接上升为因果结论。它的用途是检查列顺序、符号和单位是否写对。

参数量:先用算术估算模型大小

一个全连接层从 in_features 个输入连接到 out_features 个神经元,若包含偏置,参数量是 in_features×out_features+out_features。例如从 4 个特征到 8 个神经元共有 40 个参数。估算参数量能帮助你发现“模型突然变大”的原因,也能在显存和推理预算有限时提前做取舍。

参数量不是能力的同义词。重复堆叠相同的线性层而没有激活,只是另一种写法;把输入复制很多次也不会制造新信息。模型大小还要和数据量、正则化、推理延迟以及错误成本一起看。记录参数量、激活张量大小和 batch size,才能解释一次训练为何耗尽内存。

表 5.1B · 全连接层参数量速算
层权重偏置总量
4 → 84×8=32840
8 → 88×8=64872
8 → 38×3=24327
三层合计12019139
NEURON LAB 5.1.1解释一次权重变化

设计一个输入为“阅读分钟、收藏次数、是否完成”的神经元。写出三个权重、偏置和输出,再只改变一个输入,说明输出变化来自哪一项乘法。

先固定特征顺序和单位,再用 sum(xᵢwᵢ)+b 计算。改变第 j 个输入 Δx 时,线性输出应改变约 wⱼΔx;如果结果不符,优先检查顺序、单位和偏置是否重复。

5.1.2 激活函数与非线性:为什么不能只堆线性层

激活函数把线性组合 z 映射成新的表示。最常见的 ReLU 是 max(0,z):正数原样通过,负数变成零;Sigmoid 把结果压到 0 和 1 之间;Tanh 把结果压到 -1 和 1 之间;GELU 则以平滑方式保留一部分负值。每个选择都同时影响输出范围、梯度大小、数值稳定性和表达方式。

没有激活函数时,两个线性层可以合并:W₂(W₁x+b₁)+b₂=(W₂W₁)x+(W₂b₁+b₂)。因此“层数多”不等于“函数更复杂”。加入非线性后,网络可以分段组合不同的局部变换,逐层把原始输入变成更适合任务的表示。这个过程不是凭空创造信息,而是在已有输入和训练信号允许的范围内重新组织信息。

激活函数也会带来边界。Sigmoid 和 Tanh 在绝对值很大时会饱和,导数接近零,深层网络的梯度可能越来越小;ReLU 在负区间梯度为零,某个单元若长期落在负区间就可能“死亡”。GELU 或其他平滑激活并不是自动更好,仍然需要在相同数据、切分和预算下比较。

图 5.1 · 激活函数的输出形状自制示意:先看范围,再看梯度
ReLU 正区间
保留 z
Sigmoid 两端
梯度小
Tanh 中心
零均值
GELU
平滑

示意条只帮助建立直觉;真实比较要保存输入范围、输出统计和梯度分布。

表 5.1C · 常用激活函数怎么选
函数输出范围主要优点常见风险
ReLU[0,+∞)简单、正区间梯度稳定负区间可能死亡
Sigmoid(0,1)二分类概率直觉两端饱和、非零均值
Tanh(-1,1)零中心、适合小范围状态两端仍会饱和
GELU近似无界平滑地保留小负值实现与计算略复杂
Softmax各类和为 1把 logits 变成分布不应与交叉熵重复使用
PYTHON · activations_by_hand.py
import math

def relu(value):
    return max(0.0, value)

def sigmoid(value):
    if value >= 0:
        return 1.0 / (1.0 + math.exp(-value))
    exp_value = math.exp(value)
    return exp_value / (1.0 + exp_value)

def tanh(value):
    return math.tanh(value)

for value in (-8.0, -1.0, 0.0, 1.0, 8.0):
    print(value, relu(value), round(sigmoid(value), 4), round(tanh(value), 4))

上面的 Sigmoid 写成了分情况版本,是为了避免对很大的负数直接做指数时溢出。框架里的实现通常还会使用融合算子和更稳定的公式。学习时自己写一个小版本,是为了看懂输出范围和边界;生产训练应优先使用经过验证的库实现,不要因为手算代码更短就替换掉稳定实现。

输出层不是隐藏层的复制品

隐藏层激活负责形成表示,输出层激活要和目标及损失函数配套。回归通常保留线性输出,让模型可以表达负数或较大的连续值;二分类可以输出一个 logit,再交给带 logits 的二元交叉熵;多分类通常输出每类一个未归一化 logit,由交叉熵内部完成稳定的 LogSoftmax。若已经把概率送进一个期待 logits 的损失,梯度和数值范围都会被改变。

不要把激活名称写成模糊的“最后加个 Softmax”。要在模型契约中标注输出是 logit、概率还是类别索引,标注类别轴和形状。推理时可以显式计算概率用于展示,训练时则按损失函数要求传入正确对象。把训练和推理的两种表示混在一起,是初学者最常见也最隐蔽的错误之一。

表 5.1D · 输出、标签与损失的接口
任务模型输出标签损失输入
回归任意实数浮点值MSE/MAE 与预测值
二分类一个 logit0/1 浮点或整数BCEWithLogitsLoss
多分类(B,C) logits(B,) 类别索引CrossEntropyLoss
多标签(B,C) logits(B,C) 0/1逐标签 BCE
NEURON LAB 5.1.2找出激活与损失的错配

下面三种搭配分别有什么问题:① 多分类模型先 Softmax 再传入 CrossEntropyLoss;② 回归输出接 Sigmoid 却要预测任意价格;③ 二分类把类别索引 0/1 传给需要浮点 logit 的接口。写出修正方案。

交叉熵通常直接接 logits;任意价格需要线性输出或对目标做明确变换;二分类要确认损失期望的是单 logit、概率还是两类 logits,并匹配标签 dtype 与形状。关键不是背 API 名称,而是核对输出契约。

5.1.3 多层感知机:把神经元组织成可复用的模块

多层感知机(MLP)把若干全连接层和激活函数串起来。第一层接收输入特征,隐藏层逐步重写表示,最后一层输出任务需要的值。PyTorch 中,模型通常继承 nn.Module,在 __init__ 中声明子模块,在 forward 中描述数据如何流动。调用 model(x) 会触发框架包装过的前向过程,同时保留自动微分需要的计算历史,不要直接调用 model.forward(x) 来绕过模块机制。

隐藏层宽度决定每层能同时保存多少中间方向,深度决定可以连续组合多少次变换。宽而浅的网络可能参数很多但组合步骤少,窄而深的网络可能更难优化;不存在脱离数据和预算的“最佳层数”。从一个能过拟合很小训练集的最小网络开始,再用验证集和错误样例决定是否增加容量,比一开始堆几十层更容易定位问题。

nn.Sequential 适合直线型结构,分支、跳连、共享权重或需要缓存中间结果时则应写出自定义 forward。模块化的价值在于参数会被注册、model.parameters() 能被优化器发现、state_dict() 能保存状态。若把层放在普通 Python 列表而不是 nn.ModuleList,它们可能不会出现在参数清单里,训练看似进行却没有更新。

表 5.1E · MLP 设计前要写的接口
接口要明确的内容示例错误信号
输入特征数、dtype、批次轴(B,4) float32Linear 报形状错
隐藏层宽度、激活、是否归一化4→16→16梯度为零或过大
输出任务类型、类别数、表示(B,3) logits损失期望与输出不符
模式训练/评估差异Dropout、BatchNorm同一输入结果漂移
状态参数与缓冲区state_dict加载后结果不一致
PYTHON · mlp_module.py
import torch
from torch import nn

class MLP(nn.Module):
    def __init__(self, in_features=4, hidden=16, classes=3):
        super().__init__()
        self.network = nn.Sequential(
            nn.Linear(in_features, hidden),
            nn.ReLU(),
            nn.Linear(hidden, hidden),
            nn.ReLU(),
            nn.Linear(hidden, classes),
        )

    def forward(self, features):
        if features.ndim != 2 or features.shape[1] != 4:
            raise ValueError("期望形状为 (batch, 4)")
        return self.network(features)

model = MLP()
batch = torch.randn(5, 4)
logits = model(batch)
print(logits.shape)
assert logits.shape == (5, 3)

这个模型返回 logits,而不是类别编号。训练时类别编号交给损失函数,推理时才用 argmax(dim=1) 得到最可能的类别;如果需要概率,再对 logits 做 Softmax。把离散决策提前写进 forward 会切断一部分可学习的连续信号,也会让阈值、校准和错误分析失去灵活性。

PYTHON · inspect_parameters.py
def count_parameters(model):
    return sum(parameter.numel() for parameter in model.parameters() if parameter.requires_grad)

def summarize(model):
    rows = []
    for name, parameter in model.named_parameters():
        rows.append({"name": name, "shape": tuple(parameter.shape), "trainable": parameter.requires_grad})
    return rows

# 假设 model 是上一段创建的 MLP
print("trainable parameters:", count_parameters(model))
for row in summarize(model):
    print(row)

初始化和命名:让第一个错误有线索

线性层通常需要合理的随机初始化,而不是把所有权重都设成同一个常数。若同一层的所有神经元从完全相同的参数开始,它们会收到相同梯度,训练后仍然重复,无法形成不同特征。偏置常初始化为零并不等于权重也要为零;框架提供的默认初始化通常是经过实践验证的起点。

为层和输出命名时使用稳定、可读的约定,例如 encoder.0.weight、classifier.bias。名称会出现在错误日志、检查点和参数消融报告中。改变模块顺序或重命名可能导致旧 state_dict 无法严格加载,所以结构版本也应写进实验清单。

表 5.1F · MLP 的最小验收清单
检查通过条件失败时先看什么
形状输入 (B,F) 输出符合任务Linear 的 in/out_features
参数所有层出现在 named_parameters是否误用普通列表
前向小批次无 NaN/Infdtype、初始化、输入范围
输出训练输出保留 logits是否过早 argmax/Softmax
保存加载后同一输入结果一致结构、设备、模式
NEURON LAB 5.1.3把线性基线升级成 MLP

把第 4 章的四维表格任务改写成一个 4→8→3 的 MLP。打印每层输出形状、可训练参数量和一批 logits,写出为什么训练阶段不应直接返回 argmax。

模型应继承 nn.Module,在 forward 中保留连续 logits;参数量按每个 Linear 的权重加偏置求和。形状应从 (B,4) 变为 (B,8) 再变为 (B,3),类别决策留给损失或推理策略。
表 5.1G · 本节完成标准
能力最低证据迁移到大模型学习
读神经元能解释 w·x+b 与单位理解线性层和 QKV 投影
选激活能说明范围、梯度与损失接口理解 Transformer 激活与 logits
写模块能定义 nn.Module 并检查形状构造 Encoder/Head
查参数能统计参数并保存结构版本估算显存与推理预算

从权重到决策边界:把公式落在坐标纸上

一个神经元的几何意义很直观:当输出写成 w·x+b 时,令输出等于零,就得到一条超平面。二维输入时它是一条直线,三维输入时它是一个平面,更高维时仍然是同一个“把空间切成两边”的概念。权重向量决定法线方向,偏置决定这条边界离原点多远。改变某个权重会让边界旋转,改变偏置会让边界平移;这比把权重只看成“重要性分数”更准确。

如果特征的量纲差异很大,几何解释会被尺度遮住。比如年龄在 0 到 100,点击次数在 0 到 100000,即使点击次数的权重很小,它对加权和的贡献也可能更大。标准化、对数变换或按业务上限缩放不是为了让曲线好看,而是让优化器看到较为均衡的坐标。预处理必须只用训练集统计量拟合,再把同一组统计量应用到验证、测试和线上输入,不能为每个切分单独“调到最好”。

感知机更新还可以帮助理解训练信号。对一个被错分的样本,经典规则会沿着标签指向调整权重和偏置;现代神经网络通常使用可微损失和梯度下降,但“错误样本推动参数改变”的直觉仍然保留。若一个特征始终没有梯度,可能是它没有进入 forward、被错误地转成常数,或激活把信号截断。调试时可以固定其他输入,只改变一列,观察输出变化是否符合权重的符号。

同时要警惕把相关性写成因果性。神经元只知道在当前数据分布上怎样降低损失,它不能证明“练习次数增加导致成绩提高”。如果输入列中混入了未来信息、人工打分或由标签计算出来的字段,模型可能得到很高分,却只是读取了答案。任务卡应该记录每个特征的生成时刻、可用范围和是否经过人工处理。

图 5.1H · 一次特征扰动的解释路径自制示意:从单位到边界
固定其他列同一条样本
→
只改 xⱼ记录 Δxⱼ
→
观察 Δz约等于 wⱼΔxⱼ
→
回查单位避免伪重要性

这是局部敏感性检查,不是因果证明;要把数据生成机制和时间边界一起记录。

表 5.1H · 特征尺度与解释风险
情况示例可能现象应做的检查
量纲接近比例、温度都在 0~1权重大小较易比较仍确认单位与缺失值
尺度差异大年龄与点击次数小权重也可能贡献很大标准化或明确缩放
稀疏指示量是否完成、是否有权限偏置可能承担基线检查 0/1 编码与缺失语义
未来字段结算后才产生的分数离线异常高分按时间重建可用边界
标签派生字段由 y 计算的统计量隐性泄漏删除或只在结果分析使用
PYTHON · neuron_probe.py
def score(features, weights, bias):
    if len(features) != len(weights):
        raise ValueError("长度必须一致")
    return sum(x * w for x, w in zip(features, weights)) + bias

def local_sensitivity(features, weights, bias, index, delta=1e-3):
    changed = list(features)
    changed[index] += delta
    before = score(features, weights, bias)
    after = score(changed, weights, bias)
    return (after - before) / delta

x = [2.0, 0.5, 4.0]
w = [0.2, -1.5, 0.1]
b = 0.3
for index in range(len(x)):
    estimate = local_sensitivity(x, w, b, index)
    print(index, round(estimate, 4), "expected", w[index])
    assert abs(estimate - w[index]) < 1e-9

这段探针把“权重代表局部斜率”变成了可重复检查:在线性区域内,输入第 j 列增加一个很小的 delta,输出变化率应该接近 w[j]。它也提醒我们,激活函数之后的整体模型不一定保持这个简单关系;如果要解释完整 MLP,应分别观察每层表示、输入扰动和最终决策,而不是只看第一层的权重。

最后做一个小型手算:给定 x=(2,0.5,4)、w=(0.2,-1.5,0.1)、b=0.3,先算出线性结果,再把第二列增加 0.2。前后差异应当只由第二个乘积贡献。若你把输入改成标准化后的值,输出的数值会变化,但训练与推理必须使用同一套变换;否则同一个权重在不同入口代表的含义就不再一致。

从感知机到概率输出:边界相同,解释不同

感知机常用一个阈值把 w·x+b 变成 0 或 1,这种硬决策便于理解直线边界,却不能直接提供“有多确定”的连续信号。把同一个线性结果交给 Sigmoid,可以得到 0 到 1 之间的分数,再用二元交叉熵训练;训练阶段保留 logit,推理阶段才按业务阈值转换成类别。阈值不一定是 0.5,应该在验证集上结合误报和漏报成本选择。

权重的正负说明局部方向,但不能单独说明全局贡献。两个高度相关的特征可能分摊同一信号,标准化方式不同也会改变权重的数值。想比较特征贡献时,至少同时看尺度、输入扰动、置换实验和错误样例;如果特征之间存在明显相关性,报告“在当前模型和分布下的敏感性”,不要写成脱离条件的因果排名。

偏置还可以理解为基线。若所有输入都为零,输出仍由偏置决定;这对类别不平衡或目标均值偏离零的任务很重要。把输入中心化后,偏置更接近总体基线,优化通常也更容易。没有偏置的线性层会被迫经过原点,只有在确有物理约束或后续层已经承担平移时才适合这样设置。

初始化的随机性不是噪声垃圾,而是打破对称性的必要条件。若同一层的神经元拥有完全相同的权重,它们收到的梯度也相同,只会学习出重复的方向。随机初始化、合理的输入尺度和固定 seed 要同时出现:固定 seed 便于复现,保留多 seed 才能评估初始化对结果的影响。

表 5.1I · 线性分数到决策的层次
层次输出示例适合回答不应直接推出
线性 logit−2.4、0.7相对方向与边界校准后的概率
Sigmoid 分数0.08、0.67二分类排序与阈值必然发生的概率
硬类别0 或 1执行规则与统计训练时的连续梯度
人工解释某样本为何错复盘和改进假设仅凭权重证明因果

因此,学习第一个神经元时可以同时保留四份记录:输入列和单位、权重与偏置、logit/概率的转换规则、验证集上的阈值与错误成本。它们把公式、代码和业务解释接在一起,也为后面的 MLP、损失函数和评估指标提供共同语言。若其中一份缺失,模型即使能运行,别人也很难复核你的判断。

神经元的输入检查:先问“这列数据能不能用”

在把特征交给神经元之前,先为每一列写一张小卡片:名称、单位、允许范围、缺失值表示、生成时刻和是否可在预测时取得。这样做看似慢,却能避免模型把“未知”当成零、把百分比 80 当成 0.8,或把结算后的字段偷偷带入训练。输入检查通过后,再讨论权重和激活,模型解释才有意义。

数值范围还决定初始化和梯度的起点。特征全部在很大范围时,第一层的加权和可能一开始就进入激活函数的饱和区;特征全部接近零时,信号又可能小到难以区分。标准化不是固定公式,而是对数据分布的描述:记录训练集均值、方差、分位数或业务上下限,并在推理时复用同一份配置。

把“单个神经元的解释”扩展到 MLP 时,推荐保留一个可替换的线性基线。先用 y=w·x+b 建立可解释结果,再加入激活和隐藏层;如果新模型没有改善,回看输入卡片、损失和错误样例。每次只增加一个机制,才能知道非线性究竟解决了什么。

表 5.1J · 输入特征卡片
字段示例训练前检查线上风险
阅读分钟0~240单位与极端值计时器是否可用
完成比例0~1百分比转换缺失是否代表未开始
练习次数非负整数是否含未来次数刷新延迟
用户分组train/valid/test是否按组切分新用户无历史

完成这张卡片后,你可以更有把握地解释权重符号,也能在下一节的 Tensor 形状检查中把每一列映射到正确的轴。神经元的第一课不是记住一个公式,而是学会让公式面对可信、可追溯的输入。

02 · FORWARD PASS & SHAPES

前向传播与张量形状:先让每个轴有名字

前向传播就是把输入沿着网络从左到右计算一次。难点通常不在乘法,而在确认张量的形状、dtype、设备和每个轴的含义。本节用小批次把“一个样本”和“一批样本”区分开,再追踪一条输入经过层、激活和输出头的完整路径。

5.2.1 Tensor 是带形状和设备的数值容器

PyTorch 的 Tensor 可以看成带有形状、数据类型和设备信息的多维数组。标量形状是空元组,向量形状可能是 (F,),一批表格特征通常是 (B,F),图像批次常见 (B,C,H,W)。形状不仅是“有几行几列”,还隐含了轴的语义:B 是 batch,C 是通道,H/W 是空间尺寸,不能只因为数字相等就随意交换。

dtype 决定每个元素如何表示,float32、float64、float16 和 bfloat16 在精度、范围、速度和显存之间各有取舍。整数 Tensor 适合类别索引和计数,不应直接拿来承载需要梯度的连续参数。设备决定数据在哪个计算单元上,CPU、CUDA 或其他加速器之间不能无提示地混用。将模型移到 GPU 而忘记把 batch 移过去,通常会得到清晰的设备错误;更危险的是隐式转换导致结果悄悄改变。

创建 Tensor 时要区分“复制数据”和“共享视图”。torch.tensor 通常会创建新数据,torch.from_numpy 可能与 NumPy 数组共享内存;原数组改变时,Tensor 也可能随之变化。reshape 只在元素数量相同的前提下改变形状,permute 改变轴顺序而不改变数据本身。学习阶段每次变换后打印 shape、dtype、device,并用断言把假设写下来。

表 5.2A · 常见 Tensor 形状的轴语义
对象形状轴含义典型层
单个表格样本(F,)特征Linear 的一个输入
表格批次(B,F)批次、特征MLP/Linear
灰度图批次(B,1,H,W)批次、通道、高、宽Conv2d
序列嵌入(B,T,D)批次、token、隐藏维Embedding/Transformer
分类 logits(B,C)批次、类别CrossEntropyLoss
PYTHON · tensor_contract.py
import torch

features = torch.tensor([[42.0, 0.75, 3.0, 1.0], [18.0, 0.40, 1.0, 0.0]], dtype=torch.float32)
labels = torch.tensor([2, 0], dtype=torch.long)

print("features:", features.shape, features.dtype, features.device)
print("labels:", labels.shape, labels.dtype)
assert features.ndim == 2 and features.shape == (2, 4)
assert labels.dtype == torch.long and labels.shape == (2,)

# reshape 保持元素数量,permute 只交换轴的解释
sequence = torch.arange(24, dtype=torch.float32).reshape(2, 3, 4)
swapped = sequence.permute(0, 2, 1)
assert sequence.numel() == swapped.numel()
print(sequence.shape, swapped.shape)

注意 labels 和 features 的 dtype 不同是有意的:分类标签是索引,特征是连续值。把标签误转成 float 可能让某些损失报错,也可能在广播后生成意义错误的计算。把图像从 (B,H,W,C) 改为卷积层需要的 (B,C,H,W) 时,必须明确是 permute 而不是 reshape;后者只会重新解释线性存储顺序,图像像素会被打乱。

形状检查的三个问题

第一问是“这个轴代表谁”:如果两个轴长度都为 4,不能只看数字判断哪个是特征。第二问是“这个操作改变了什么”:unsqueeze 增加一个长度为 1 的轴,squeeze 可能删除不止你想删除的轴,permute 改变顺序,flatten 合并连续轴。第三问是“下一个模块期待什么”:Linear 关心最后一维,Conv2d 关心通道位置,CrossEntropyLoss 关心类别轴。

把关键 shape 写进日志或错误信息,而不是只抛出“mat1 and mat2 shapes cannot be multiplied”。一个好的错误应该同时给出实际形状、期望形状、数据样例数量和触发模块名称。这样调试从猜测变成比对,也能在数据管道改版后快速发现接口变化。

SHAPE LAB 5.2.1给每个轴写标签

分别为表格、图像和文本批次写出 Tensor 形状,并解释每个轴。再把一个图像张量从 (B,H,W,C) 转成 (B,C,H,W),说明为什么要用 permute。

表格通常是 (B,F),图像常见 (B,C,H,W),文本嵌入常见 (B,T,D)。交换图像轴要保持元素与像素位置的对应关系,因此用 permute(0,3,1,2);不能用只改变线性排列的 reshape。

5.2.2 批次、广播与矩阵乘法:能算不代表语义正确

批次让模型一次处理多个样本。对表格 MLP,输入通常是 (B,F),权重矩阵是 (F,H),矩阵乘法得到 (B,H)。这里的 B 是同时计算的样本数,不应被 Linear 当成特征数。换一个 batch size,正确模型的参数形状不应改变;如果 batch 从 8 改到 32 就必须重建层,往往意味着轴设计错了。

广播允许形状兼容的 Tensor 在逐元素运算时自动扩展。例如 (B,F) 加上 (F,) 可以把每个特征的偏置应用到整个批次;(B,1) 加到 (B,F) 则是每个样本一个偏移。广播很方便,却可能掩盖错误:一个本应是 (B,F) 的张量误写成 (F,) 后,程序也许照样运行,只是每个样本共享了本不该共享的值。

点乘、矩阵乘法和逐元素乘法是三种不同关系。* 常表示逐元素乘法,@ 或 torch.matmul 表示矩阵乘法;高维 matmul 会按照最后两维相乘并保留前面的批次维。写公式时同时写形状,例如 (B,F)@(F,H)→(B,H),比只写“乘一个权重”更不容易错。

表 5.2B · 形状运算与风险
写法结果示例它表达什么易错点
x * scale逐元素按同形状或可广播值缩放把它误当矩阵乘法
x @ weight(B,F)@(F,H)每行做线性组合内侧维不一致
x.sum(dim=1)(B,)合并特征轴归约错轴
x.unsqueeze(1)(B,1,F)增加广播/序列轴后续 squeeze 误删
x.permute(0,2,1)轴顺序交换改变语义排列忘记 contiguous/接口要求
PYTHON · batch_broadcast.py
import torch

x = torch.tensor([[1.0, 2.0, 3.0], [10.0, 20.0, 30.0]])
feature_bias = torch.tensor([0.1, 0.2, 0.3])
sample_bias = torch.tensor([[1.0], [2.0]])

by_feature = x + feature_bias
by_sample = x + sample_bias
print(by_feature, by_sample)
assert by_feature.shape == x.shape
assert by_sample.shape == x.shape

weight = torch.ones(3, 2)
output = x @ weight
assert output.shape == (2, 2)
print("matrix output:", output)

上面的两个偏置都能广播成功,但含义不同。特征偏置为每一列加一个值,样本偏置为每一行加一个值。如果把一个形状为 (B,) 的样本权重直接乘到 (B,F) 输入上,PyTorch 会尝试对最后一维广播,可能报错或产生错误方向;更稳妥的写法是先 weights.unsqueeze(1),并在代码中写出每个轴。

用小形状替代大数据调试

形状错误不需要加载完整数据。先用 B=2,F=3,H=4,C=2 的小 Tensor,让每个位置填入可辨认的数,例如按序号递增。逐步打印中间结果,就能看出是轴顺序错、归约错,还是广播方向错。等小样本的手算与代码一致后,再换真实 batch,问题范围会小很多。

如果网络包含变长序列,还要区分“补齐后的长度”和“有效 token 数”。padding 位置可能参与了线性计算,却不应参与损失或注意力。保存 mask 的形状和 dtype,并在每个需要它的模块前检查是否可广播;不要把一个全为 True 的 mask 当作已经完成了屏蔽。

SHAPE LAB 5.2.2判断广播是否符合语义

给定 x 形状 (4,6),分别设计“每个特征一个缩放值”和“每个样本一个缩放值”的 Tensor。写出它们的形状、广播结果和一条能防止方向写反的断言。

特征缩放可用 (6,),样本缩放应写成 (4,1)。两者结果都为 (4,6),但轴语义不同;可以断言 feature_scale.shape == (6,)、sample_scale.shape == (4,1),并在注释中标出广播轴。

5.2.3 追踪一次 forward:从输入到 logits 的每个站点

前向传播最好能画成一条站点清晰的链:输入校验、线性层、激活、正则化或归一化、输出头。每个站点都应知道输入形状、输出形状、是否包含可学习参数、是否改变训练/评估行为。这样当最终 loss 异常时,可以回到第一个出现 NaN、尺度暴涨或形状改变的站点,而不是盲目调学习率。

在 PyTorch 中,模块调用会记录必要的计算历史,并把参数注册到模型树。可以打印模型结构、遍历 named_modules,也可以临时使用 forward hook 记录输入输出形状。hook 适合诊断,不应把业务逻辑写进 hook;调试结束后要移除句柄,避免重复注册导致日志成倍输出或保留不必要的引用。

输出头要对接下一节的损失。三分类 MLP 的 logits 形状是 (B,3),每行三个数不要求和为 1;CrossEntropyLoss 会把它们当作未归一化分数。若在 forward 里直接 argmax,输出变成离散整数,梯度无法通过这个决策回到前面的权重。训练和推理的最后一步应明确分开。

图 5.2 · 一次 forward 的检查站自制流程:每站都留下形状和统计
输入校验shape/dtype/device
→
Linear参数与线性组合
→
Activation非线性与范围
→
Headlogits/值
表 5.2C · forward 诊断记录
站点至少记录正常信号异常优先级
输入shape、dtype、device、有限值与数据契约一致最高:先修数据
线性层权重范数、输出均值/标准差没有突然爆炸初始化/尺度
激活零比例、饱和比例不是全零或全饱和激活/学习率
归一化训练/评估模式、统计量模式切换有记录状态与批次
输出头logits 范围、类别轴形状匹配损失接口错配
PYTHON · trace_forward.py
import torch
from torch import nn

class TraceMLP(nn.Module):
    def __init__(self):
        super().__init__()
        self.layers = nn.Sequential(nn.Linear(4, 8), nn.ReLU(), nn.Linear(8, 3))

    def forward(self, x):
        if x.ndim != 2 or x.shape[-1] != 4:
            raise ValueError(f"期望 (B,4),实际 {tuple(x.shape)}")
        if not torch.isfinite(x).all():
            raise ValueError("输入含 NaN 或 Inf")
        logits = self.layers(x)
        if logits.shape != (x.shape[0], 3):
            raise RuntimeError("输出类别轴错误")
        return logits

model = TraceMLP()
batch = torch.randn(2, 4)
logits = model(batch)
print("logits:", logits.shape, logits.mean().item(), logits.std().item())

注意这里用 torch.isfinite 先挡住非有限输入,使用 tuple(x.shape) 把形状放进错误信息。日志不应打印完整的敏感文本或用户数据;对大模型输入可以记录 token 数、哈希和统计摘要。诊断信息要足够定位问题,又不能因为调试而扩大数据暴露范围。

PYTHON · forward_hooks.py
def shape_hook(name):
    def hook(module, inputs, output):
        input_shapes = [tuple(item.shape) for item in inputs if hasattr(item, "shape")]
        output_shape = tuple(output.shape) if hasattr(output, "shape") else type(output).__name__
        print(name, "in=", input_shapes, "out=", output_shape)
    return hook

handles = []
for name, module in model.named_modules():
    if name and isinstance(module, (nn.Linear, nn.ReLU)):
        handles.append(module.register_forward_hook(shape_hook(name)))

_ = model(torch.randn(2, 4))
for handle in handles:
    handle.remove()

forward 的职责边界

forward 应描述数据如何通过网络,并进行必要的形状约束;数据读取、指标计算、保存文件和阈值选择应放在外部函数。职责清楚后,模型可以被训练、验证、导出和单元测试,而不会因为某个调用场景缺少文件或标签就改变输出。

如果模型需要额外的 mask、长度或条件向量,显式写在参数中,例如 forward(tokens, attention_mask=None)。不要把它们偷偷塞进全局变量。接口越明确,越容易在一批小数据上验证,也越容易迁移到后续的 Transformer 模块。

表 5.2D · forward 单元测试
测试输入应检查
最小批次(1,F)输出保留 batch 轴
常规批次(B,F)输出 (B,C)
空批次(0,F)明确拒绝或返回约定形状
非有限值含 NaN/Inf在输入边界失败
错误特征数(B,F+1)错误信息带实际形状
SHAPE LAB 5.2.3绘制你的 forward 站点图

选择一个 4→8→3 的 MLP,写出输入、两层 Linear、激活和输出的形状。再设计一个 hook 或手动日志,记录每站的均值、标准差和非有限计数。

输入是 (B,4),第一层为 (B,8),激活不改变形状,输出头为 (B,3)。记录统计时要注明 batch、dtype 和模式;若发现某站出现 NaN,先定位该站再看其上游输入和参数。
表 5.2E · 本节完成标准
能力最低证据进入下一节前要做到
读 Tensor能解释 shape、dtype、device 与轴语义为损失和批次写正确输入
读广播能区分逐元素、矩阵和归约避免静默的轴错误
追踪 forward能打印每站形状和有限性定位 loss 前的异常
守住职责forward 不混入读写与指标为 autograd 保留连续输出

形状推理四步法:先写契约,再写算子

第一步给每个轴命名,不要只写一串数字。比如 (32,10,768) 可以是 32 条、每条 10 个 token、每个 token 768 维,也可以是别的含义;只有在变量名和数据说明中写清楚,后续的 transpose 才有方向。第二步写出操作前后的形状,尤其是矩阵乘法、拼接、池化和 flatten。第三步确认 dtype 与设备,最后才运行代码。把这四步写进函数的注释或断言,能把隐式假设变成接口。

形状正确仍不代表语义正确。广播会在长度为 1 的轴上复制数值,代码因此可以运行,但可能把一个样本的偏置广播到错误的轴;mean 也可能沿着 batch 轴求平均,导致每个样本被混在一起。任何 reduction 都要写出“在哪个轴、为了什么、分母是什么”。如果输出要和标签逐元素比较,先检查两者的轴顺序和有效 mask,再调用损失。

在图像和序列中,连续内存与轴顺序还会影响性能。permute 常返回非连续视图,某些算子需要先 contiguous();这不是为了修复语义,而是为了满足底层布局要求。reshape 有时会复制、有时会返回视图,不能把它当作“任意换轴”的工具。初学阶段宁可多写一条断言,也不要用一个能跑但含义不清的 view(-1) 掩盖问题。

批次大小是运行时变量,不应进入模型的参数形状。若把第一批的 8 写死为 Linear 的输入维度,换成 32 就报错,通常说明把 batch 轴误当成特征轴。相反,序列长度是否固定要看模块:Embedding 与 Transformer 通常允许长度变化,某些 flatten 后的全连接头则可能要求固定长度。接口文档应明确哪些维度可变、哪些维度固定。

表 5.2F · 形状操作的语义检查
操作主要改变不会改变典型风险
unsqueeze增加长度为 1 的轴元素与顺序轴加错位置
squeeze删除长度为 1 的轴其余元素batch=1 时误删 batch
permute轴的排列元素对应关系把 H/W/C 语义弄反
reshape形状与视图布局元素数量把不同轴混成一条轴
flatten合并指定范围的轴范围外轴丢失时间或空间边界
mean减少一个或多个轴未归约轴分母与指标含义改变
PYTHON · shape_guard.py
import torch

def require_shape(tensor, expected, name):
    if tuple(tensor.shape) != tuple(expected):
        raise ValueError(f"{name}: 实际 {tuple(tensor.shape)},期望 {tuple(expected)}")

def require_batch_features(tensor, features, name):
    if tensor.ndim != 2 or tensor.shape[-1] != features:
        raise ValueError(f"{name}: 期望 (B, {features}),实际 {tuple(tensor.shape)}")

batch = torch.randn(6, 4)
require_batch_features(batch, 4, "features")
expanded = batch.unsqueeze(1)
require_shape(expanded, (6, 1, 4), "expanded")
restored = expanded.squeeze(1)
require_shape(restored, (6, 4), "restored")
print("shape contract passed")

可以把断言分成“数据入口断言”和“模块边界断言”。入口断言检查列数、标签范围、批次是否为空;模块边界断言检查每一层输出、类别轴和 mask 对齐;出口断言检查 logits 是否有限、是否保留 batch 轴。这样定位错误时能知道它是在数据、模块还是指标处发生,而不是等到最后一行 loss 才看到一个笼统异常。

读日志时还要保留最小样本。发生形状错误时,保存触发它的一个 batch 的 shape、dtype、device、样本 ID 和预处理版本,之后用相同 batch 单独运行 forward。不要为了“让程序先跑起来”在未知位置加 unsqueeze 或 flatten;每一次修复都要说明轴的语义改变,并补一条回归断言。

广播不是魔法:用一个反例看见轴的含义

假设输入是 (B,F),想给每个特征加一个校准值,校准向量应当是 (F,),结果仍是 (B,F)。如果误写成 (B,),当 B 和 F 恰好相等时可能还能运行,却把每个样本的数值广播到所有特征;数字没有报错,语义已经错了。解决办法是显式 reshape 成 (1,F) 或先断言轴名,而不是依赖广播“刚好成功”。

矩阵乘法也要区分最后一维与批次维。(B,F) @ (F,H) 得到 (B,H),每个 batch 样本共享同一组权重;如果把权重写成 (B,F,H),就变成每个样本一套权重,参数量和学习含义都会改变。后一种写法不是绝对错误,但必须明确这是按样本变化的条件计算,而不是普通 Linear 的替代品。

拼接、堆叠和加法有不同语义。cat 沿已有轴连接,要求其他轴一致;stack 新增一个轴,常用来把多个同形状样本组合起来;逐元素加法要求可广播。做残差连接时,两条分支通常需要同形状,若通道数不同就要用投影层对齐。看到“shape mismatch”时先问两条分支是否表达同一批次、同一位置和同一隐藏维。

形状日志还应包含设备和内存布局。训练时模型在 CUDA、数据却在 CPU 会直接失败;某些非连续视图在 view 时失败,改成 reshape 可能解决布局问题,但不能替代轴语义检查。把 is_contiguous() 作为性能诊断,而不是把它当作形状正确性的证明。

表 5.2G · 四种常见张量组合
操作例子结果形状语义问题
逐元素加(B,F)+(1,F)(B,F)校准值是否按特征
矩阵乘(B,F)@(F,H)(B,H)F 是否真是输入轴
拼接cat((B,F1),(B,F2),dim=1)(B,F1+F2)ID 与特征是否同批次
堆叠stack([x1,x2],dim=0)新增一个轴新增轴是否代表时间/视图
残差相加x+f(x)通常与 x 同形分支是否对齐位置与维度
图 5.2G · 形状错误的定位顺序自制示意:从输入到模块边界
输入断言轴名/范围
→
算子断言乘法/广播
→
输出断言形状/有限
→
指标断言标签对齐

每个边界都记录实际与期望值,错误会在最早的模块停止。

当你改变 batch size、序列长度或图像分辨率时,重新执行这套检查。若只有输入的可变轴发生变化,模型参数和输出语义应该保持稳定;若输出类别轴也跟着变化,通常说明某处把数据尺寸写进了模型结构。为后续的大模型学习做准备时,尤其要把 B、T、D 和 V 写全,避免把 token 数误当词表大小。

03 · OBJECTIVES & OUTPUTS

损失函数与输出层:模型究竟在被要求学什么

神经网络不会直接理解“更准确”,它只会根据损失函数得到一个数,再通过梯度把这个数压低。损失的输入、标签形状、归约方式和屏蔽规则共同定义了训练目标。本节把回归、分类和序列任务放在同一张接口表里,避免把输出激活、标签编码和损失函数随意拼接。

5.3.1 回归损失:平均误差背后的取舍

回归模型通常输出连续值 ŷ,损失函数把预测和目标 y 的差异变成可优化的标量。均方误差 MSE 计算 (ŷ-y)² 的平均值,大误差会被平方放大;平均绝对误差 MAE 计算 |ŷ-y|,单位和标签相同,对离群点相对稳健;Huber 损失在小误差区域近似平方、大误差区域近似绝对值,试图兼顾平滑梯度和异常点的影响。

损失和业务指标不一定相同。训练可以使用 MSE,报告却使用 MAE、分位数误差或超出安全范围的比例。只要明确二者职责,差异不是问题;问题是把优化损失当成业务承诺,或者在不同实验中偷偷改变 reduction 让数字看起来更小。mean、sum 和 none 会改变尺度、梯度大小以及不同 batch 之间的可比性。

目标变换也会改变损失的含义。对价格取对数后训练 MSE,实际上更重视相对误差而非绝对金额;把目标裁剪到区间会让模型无法表达边界外的真实值。每次变换都要保存逆变换、单位和适用范围,并在原始业务尺度上重新评估。否则训练 loss 下降,用户看到的误差却可能没有改善。

表 5.3A · 回归损失的行为
损失公式直觉对大误差适合先问什么
MSE残差平方的平均强烈放大大错误是否必须重点惩罚
MAE残差绝对值的平均线性增加典型单位误差是多少
Huber小误差平方、大误差近似绝对可控是否同时需要平滑与稳健
Quantile非对称地惩罚高估/低估按方向不同是否要预测分位数
PYTHON · regression_losses.py
def mse(actual, predicted):
    if len(actual) != len(predicted) or not actual:
        raise ValueError("需要等长且非空的序列")
    return sum((a - p) ** 2 for a, p in zip(actual, predicted)) / len(actual)

def mae(actual, predicted):
    return sum(abs(a - p) for a, p in zip(actual, predicted)) / len(actual)

def huber(actual, predicted, delta=1.0):
    total = 0.0
    for a, p in zip(actual, predicted):
        error = abs(a - p)
        total += 0.5 * error * error if error <= delta else delta * (error - 0.5 * delta)
    return total / len(actual)

y = [10.0, 12.0, 30.0]
pred = [11.0, 11.0, 18.0]
print(mse(y, pred), mae(y, pred), huber(y, pred))

手算上面的三种损失,注意最后一个离群点如何改变 MSE。若业务希望每个订单的金额误差都不超过固定元数,MAE 更直观;若极大错误会造成严重损失,MSE 可能更合适;若标签中有偶发异常,Huber 可以作为折中起点。最终选择应通过验证集和真实代价检验,而不是因为某个函数名更流行。

reduction 决定“平均的是谁”

一个 batch 中每个样本可以有一个损失,序列任务甚至每个 token 都有一个损失。sum 会让 batch 越大,梯度通常越大;mean 会把有效项平均,但如果不同样本的有效 token 数差异很大,简单的全局平均可能让长样本权重更高。none 则保留逐项损失,方便掩码、分组和错误分析。

建议在日志中同时记录损失总和、有效项数量和平均值。只打印一个平均 loss,无法判断它是样本增加了、有效 token 增加了,还是模型真的变好了。对加权损失还要记录权重的总和以及权重是否随时间、类别或群组改变。

表 5.3B · 回归损失的审计字段
字段示例为什么保留
原始目标单位分钟/元/评分解释 MAE 的含义
目标变换log1p 或无判断损失关注相对还是绝对误差
reductionmean / sum / none比较 batch 与梯度尺度
有效样本数batch 中 56 个防止缺失标签伪造下降
业务护栏误差超过 10 分钟比例连接优化与行动风险
LOSS LAB 5.3.1选择一个能解释的回归目标

假设要预测文章阅读时长,比较 MSE、MAE 和 Huber。分别写出它们更关注的错误,并设计一个业务护栏指标,说明为什么不能只看训练 loss。

可以把“平均偏差分钟数”交给 MAE,把极大误差惩罚交给 MSE,把异常点影响控制在一定范围交给 Huber;护栏可选 P90 绝对误差或超过 10 分钟的比例。报告时同时保留原始单位和有效样本数。

5.3.2 分类损失:logits、标签编码与概率不是一回事

分类模型的最后一层通常输出 logits:每个类别一个没有归一化的实数。logit 越大,表示在当前参数下该类别相对更受偏好;它不是概率,不要求为正,也不要求总和为 1。多分类的 CrossEntropyLoss 通常直接接 (B,C) logits 和形状 (B,) 的类别索引;它内部结合了 LogSoftmax 与负对数似然,避免我们先算概率再取对数时的数值风险。

二分类有两种常见接口。一个输出单 logit,配合 BCEWithLogitsLoss;两个输出 logits,配合 CrossEntropyLoss。两种都可以,但标签 dtype、输出形状和正类索引必须一致。把单 logit 随手 squeeze() 可能在 batch size 为 1 时连 batch 轴一起删掉;更稳妥的是明确指定维度并写断言。

类别不平衡时,准确率会掩盖少数类的损失。可以使用 class weight、重采样或 focal 类目标,但每种方法改变了训练偏好,不会凭空创造少数类信息。验证和测试仍要按真实分布报告 precision、recall、F1、PR-AUC、校准和混淆矩阵,并记录权重、采样比例以及决策阈值。

表 5.3C · 分类输出与标签形状
任务输出形状标签形状/dtype训练时的注意
二分类单 logit(B,1) 或 (B,)同形状浮点 0/1用 BCEWithLogitsLoss,别重复 Sigmoid
二分类两 logits(B,2)(B,) long用 CrossEntropyLoss
多分类(B,C)(B,) long标签范围是 0 到 C-1
多标签(B,C)(B,C) float每个标签独立计算 BCE
PYTHON · classification_loss_contract.py
import torch
from torch import nn

logits = torch.tensor([[2.0, 0.5, -1.0], [0.2, 1.4, 0.1]])
targets = torch.tensor([0, 1], dtype=torch.long)
loss_fn = nn.CrossEntropyLoss()
loss = loss_fn(logits, targets)
predicted = logits.argmax(dim=1)
print("loss:", loss.item(), "predicted:", predicted.tolist())
assert logits.shape[0] == targets.shape[0]
assert targets.min().item() >= 0 and targets.max().item() < logits.shape[1]

binary_logits = torch.tensor([1.2, -0.7])
binary_targets = torch.tensor([1.0, 0.0])
binary_loss = nn.BCEWithLogitsLoss()(binary_logits, binary_targets)
print("binary loss:", binary_loss.item())

训练阶段保留 logits 还有一个好处:同一组输出可以在验证时重新扫描阈值、计算校准或调整成本矩阵。若 forward 直接返回概率或类别,后续策略被锁死,无法区分“模型分数变化”和“阈值变化”。输出层的接口要写进模型文档,不能只靠读者猜。

标签 smoothing、权重与忽略项都改变目标

标签 smoothing 会把原本 one-hot 的目标分布稍微摊平,减少模型对单一类别的过度自信;class weight 会让某些类别的错误更贵;ignore_index 会把指定标签从损失归约中排除。它们不是无害的“训练技巧”,而是对目标函数的正式修改。

修改后应重新解释指标。加权损失的训练平均值不再等同于每个样本等权的平均误差;忽略项过多时 loss 下降可能只是有效项变少。日志中保存有效项数量、类别权重和忽略比例,才能判断一次改进来自模型还是目标口径。

表 5.3D · 分类损失的变更记录
变更改变的偏好必须补的证据
class weight少数类错误更贵各类支持数、宏/加权指标
label smoothing降低过度自信校准、置信度分布
ignore_index部分位置不参与损失有效位置数、屏蔽比例
重采样训练看到的类别比例改变真实分布上的测试指标
LOSS LAB 5.3.2修复一个分类接口

模型输出形状为 (8,4),标签却是 (8,1) 的 float。请判断它更像哪种任务,写出两种合法修复,并说明何时应该选择其中一种。

若是四分类,标签应转为形状 (8,) 的 long 类别索引,直接传入 CrossEntropyLoss;若是四个独立标签,标签应保留 (8,4) 的 float,并用逐标签 BCE。不能只改 dtype 而不确认任务语义。

5.3.3 序列与掩码:每个 token 都算吗

语言模型、序列标注和像素级分类会为每个位置输出一个预测。常见 logits 形状是 (B,T,V),其中 B 是 batch,T 是序列长度,V 是词表或类别数;CrossEntropyLoss 更常用的输入形状是把类别轴放到第二维,例如 (B,V,T),或先展平为 (B×T,V)。标签通常是 (B,T) 的 long 索引。轴顺序错一位,代码可能仍然运行但把 token 轴当成类别轴。

padding 位置没有真实目标,不应把它们当作普通类别参与平均。可以使用 ignore_index,也可以先取得 reduction='none' 的逐 token loss,再乘以 mask 并用有效 token 数归一化。后者更灵活,因为可以按样本、时间段或任务权重统计;无论哪种方式,都要防止一个 batch 全部被屏蔽时出现除零。

自回归语言模型还存在移位关系:输入位置 t 预测目标位置 t+1。如果把同一位置的 token 当标签,模型可能学会复制当前 token,训练 loss 看似下降却没有完成“预测下一个 token”的任务。移位、因果 mask、padding mask 和有效长度要在一小段手工序列上逐项打印。

表 5.3E · 序列损失的轴与掩码
对象常见形状语义检查
输入 token(B,T)每个位置的整数 id范围在词表内
logits(B,T,V)每个位置对 V 类的分数类别轴确实是 V
标签(B,T)目标 token/类别与输入按位对齐或移位
mask(B,T) bool有效位置有效数大于零
loss标量或 (B,T)优化/审计值分母是有效位置数
PYTHON · masked_token_loss.py
import torch
from torch import nn

batch, steps, classes = 2, 4, 5
logits = torch.randn(batch, classes, steps)
targets = torch.tensor([[1, 2, 3, -100], [0, 4, -100, -100]], dtype=torch.long)
loss_fn = nn.CrossEntropyLoss(ignore_index=-100, reduction="none")
per_position = loss_fn(logits, targets)
valid = targets.ne(-100)
valid_count = valid.sum()
loss = per_position.masked_select(valid).sum() / valid_count.clamp_min(1)
print("loss:", loss.item(), "valid tokens:", valid_count.item())
assert per_position.shape == targets.shape

上面的代码把类别轴放在第二维,正好匹配二维空间损失的约定。若你的模型输出是 (B,T,V),可以先 transpose(1,2),也可以展平后调用损失,但必须在代码中写出转换前后形状。不要依赖一个“刚好能广播”的调用来掩盖轴错误。

有效分母:长文本不应自动拥有更大话语权

如果直接对所有 token 做总和再除以 batch size,长度更长的样本会拥有更大权重;如果把每个样本先平均、再对样本求平均,则每个样本权重相同,但短样本的单 token 误差对整体影响更大。两种口径都可能合理,关键是预先选择并报告。对大模型训练,常见的 token 平均、序列平均和 packed batch 统计方式不能混为一谈。

掩码还要作用在评估上。困惑度、token accuracy 和格式遵循度如果把 padding 或 prompt 部分算进去,会得到与用户体验不一致的数字。建议分别记录 prompt token、目标 token、有效 token 和被忽略 token 数,必要时按长度分桶报告。有效项为零时返回明确的跳过状态,不要让 NaN 进入优化器。

表 5.3F · 序列训练日志
日志项作用异常信号
input_tokens输入规模突然超过截断上限
target_tokens实际学习的目标数移位或截断为空
ignored_tokenspadding/特殊位置数量屏蔽比例异常升高
denominatorloss 的有效分母为零或随 batch 乱跳
shift输入与目标的偏移复制当前 token 的假任务
LOSS LAB 5.3.3手算一次移位与掩码

给定 token 序列 [11, 12, 13, 0],其中 0 是 padding。写出自回归输入、目标、有效 mask,并说明最后一个可预测位置为什么可能被忽略。

输入可为 [11,12,13],目标为 [12,13,0],最后位置若对应 padding 就不计入损失;也可以保留完整长度并用 ignore_index=0。必须在报告中说明 BOS/EOS、padding 和移位约定。
表 5.3G · 本节完成标准
能力最低证据进入下一节前要做到
读损失能解释公式、单位和 reduction知道 loss 下降代表什么
配输出能区分 logits、概率和类别避免重复 Softmax/Sigmoid
处理序列能写出轴、移位和 mask为反向传播提供正确标量
审计目标记录有效分母与类别权重让训练曲线可比较

损失账本:一个数字必须能追溯到分子和分母

训练日志中的 loss 不是脱离上下文的“成绩”。同样显示 0.4,可能来自 4 个样本的平均,也可能来自 400 个 token 的平均;若一个版本忽略 padding,另一个版本只在有效 token 上平均,数字就不能直接比较。记录总损失、有效元素数、被忽略元素数和 reduction,才能知道曲线变化来自模型还是来自统计口径。

回归目标也需要单位。把房价从元改成万元,MSE 会按平方尺度变化;这不代表模型突然变差。可以在训练时对目标做稳定的缩放,但评估时要还原到业务单位,同时报告 MAE、相对误差或分位数误差。对于长尾目标,先画目标分布和残差,再决定是否使用对数变换或 Huber,而不是看到几个大误差就随意换损失。

分类损失还涉及标签字典和类别不平衡。类别索引必须由一个版本化的映射生成,训练、验证、推理不能各自排序。若正负样本比例极端,accuracy 可能在模型只预测多数类时仍然很高;可以报告每类召回、精确率、宏平均 F1,并明确阈值是在验证集上选择的。使用 class weight 或采样策略时,也要记录它改变的是优化目标还是数据分布。

序列任务的 mask 不只是一个布尔数组,而是“哪些位置对目标有责任”的声明。自回归语言模型常把输入右移一位,让位置 t 预测下一个 token;padding、BOS、EOS 的处理要在数据集、模型和损失三处保持一致。若 logits 是 (B,T,V),展平时必须同时展平目标和 mask,不能只把 logits reshape 后让目标沿错误的顺序排列。

表 5.3H · loss 数字的可比条件
记录项必须回答的问题缺失时的后果
目标单位元、万元、标准化值还是 token id不同实验的数值不可比
reductionsum、mean 还是 none 后自定义batch/长度改变会改曲线
有效分母样本数、token 数还是非 padding 数mask 变化伪装成进步
标签映射索引 0 对应哪个类别混淆矩阵轴被误读
权重与采样类别是否被重新加权优化目标与业务指标错位
阈值概率阈值在哪个切分上选择测试集被间接调参
PYTHON · loss_audit.py
import torch
from torch import nn

logits = torch.tensor([[2.0, 0.0, -1.0], [0.2, 1.4, 0.1]])
targets = torch.tensor([0, 2])
criterion = nn.CrossEntropyLoss(reduction="none")
per_sample = criterion(logits, targets)
print("per sample:", per_sample.tolist())
print("mean:", per_sample.mean().item())

token_logits = torch.randn(2, 4, 5)
token_targets = torch.tensor([[1, 2, 0, 3], [4, 0, 0, 1]])
valid = token_targets.ne(0)
flat_loss = nn.CrossEntropyLoss(reduction="none")(
    token_logits.reshape(-1, token_logits.shape[-1]),
    token_targets.reshape(-1),
)
masked_mean = flat_loss[valid.reshape(-1)].mean()
print("valid tokens:", int(valid.sum()), "masked mean:", masked_mean.item())

上面的审计代码先保留逐样本、逐 token 的损失,再选择分母。遇到异常时不要只打印一个标量,可以打印损失最大的样本 ID、对应标签、logits 范围和 mask;如果某个类别长期损失很高,优先检查标签映射与样本质量。把逐元素损失保存几步,通常比盲目调学习率更快找到接口错误。

一个稳妥的验收顺序是:先用两三个手工样本验证损失公式,再用全零或恒定 logits 检查边界,再确认模型输出的形状和 dtype,最后才扩大到完整数据。每次改变损失或标签处理,都保留旧版本在同一批金样本上的结果。这样即使最终指标没有提升,也能说明新版本改变了什么。

从 loss 到业务指标:训练目标与验收目标要对齐

损失函数是优化器每天看到的目标,业务指标是你最后要作出的判断,两者可以相关但不必相同。交叉熵希望提升真实类别的对数似然,F1 关心阈值下的精确率与召回率,MAE 关注平均绝对偏差;如果只训练一个指标,却用另一个指标决定上线,必须在验证集上验证它们是否一致。报告中同时列出 loss、主指标和护栏指标,才能解释“loss 下降但 F1 不升”的情况。

指标聚合也有样本级与 batch 级的区别。把每个 batch 的 accuracy 简单平均,会让小 batch 与大 batch 权重相同;正确做法通常是累加正确数和样本数,最后再相除。对于 token 任务,按 batch 平均 loss 还会让短序列和长序列权重不同,应该根据有效 token 数聚合。任何自定义指标都要写出分子、分母和忽略规则。

类别权重会改变训练的损失景观。给少数类更高权重可以让模型更重视漏报,但输出分数的概率含义也可能发生变化;阈值和校准要在同一套权重策略下重新选择。过采样则改变了模型看到的样本频率,评估仍应在原始分布上进行。不要把“加权后的训练 loss”与“原始分布的验证 loss”直接当作同一个数字比较。

对于生成或序列标注,teacher forcing、移位和 mask 共同决定哪些预测被计入。输入 [BOS, a, b] 的目标可能是 [a, b, EOS],最后一个位置是否有目标取决于任务定义;padding 只表示没有样本,不应被当作一个真实类别。把这三者画在一行上,通常比只读一段代码更容易发现 off-by-one。

表 5.3I · 训练损失与验收指标
任务常用 loss验收指标需记录的分母
回归MSE/MAE/HuberMAE、RMSE、分位数样本数与目标单位
二分类BCEWithLogitsPR/F1/召回正负样本与阈值
多分类CrossEntropy宏/微 F1、每类召回类别数与有效样本
序列预测token CEtoken accuracy、困惑度有效 token、移位范围
多标签逐标签 BCE每标签与样本级指标标签矩阵中的有效项

一个可靠的 loss 单元测试至少覆盖:完美预测、完全相反预测、全 padding、单个有效位置、类别权重和空 batch 的处理。先在手工构造的张量上验证,再把同一测试接到训练脚本。这样当你换数据格式或加入 label smoothing 时,能知道改变的是优化目标、指标分母还是仅仅日志呈现。

最后把“训练更好”写成可检验句子,例如“在相同切分和三次 seed 下,宏 F1 平均提高,同时少数类召回不下降,验证 loss 的有效 token 分母保持一致”。这种表述比“loss 变小所以模型更好”更专业,也会自然引导你保存需要的证据。

用极端样本审计损失:先验证边界,再扩大数据

极端样本是损失函数的放大镜。回归可以测试完全相同的预测、误差为零、误差很大的离群点;分类可以测试真实类别 logit 明显最大、完全错误、所有 logits 相同;序列可以测试全 padding、只有一个有效 token 和长度为一的输入。预期的相对关系比某个固定小数更重要。

还要检查损失对输入的单调性。提高真实类别的 logit,交叉熵应不增;把预测向目标移动,MSE 应不增;把 mask 中一个有效位置改成 padding,有效分母应随之减少。用这些性质写测试,可以防止重构时把类别轴、标签顺序或 mask 广播错位。

损失值不能直接当作概率或业务收益。一个模型可能交叉熵更低,却在高代价少数类上漏报更多;另一个模型 MAE 稍高,却在关键区间更稳定。先定义主指标和护栏,再决定 loss 是否需要加权、截断或换形式。任何损失改动都应附上旧版/新版在同一金样本上的对照。

表 5.3J · 极端样本测试
测试输入预期性质发现的问题
预测等于目标MSE/MAE 接近零目标单位或广播错
真实类 logit 增大CE 不应上升类别轴、标签索引错
所有位置 padding按约定拒绝或返回可解释值分母为零
只保留一个 tokenmask 与移位仍对齐off-by-one
类别完全不平衡accuracy 与宏 F1 分离只看多数类

当极端样本通过后,再把损失接入完整训练循环。这样你能把“公式实现正确”和“模型在数据上表现好”分成两个问题,调试时不会把二者混在一起。

04 · AUTOGRAD & BACKPROPAGATION

自动微分与反向传播:让每个参数知道该往哪边走

训练神经网络的核心不是“调用一个神奇的 fit”,而是先前向得到损失,再沿计算图反向计算每个参数的梯度。PyTorch 的 autograd 会记录张量运算历史,利用链式法则把局部导数组合起来。本节把 requires_grad、grad、detach、no_grad 和梯度累积拆开讲清楚。

5.4.1 计算图与 requires_grad:梯度从哪里来

当一个 Tensor 的 requires_grad=True 时,PyTorch 会在可微运算中记录它参与的计算,结果张量通常带有 grad_fn。这些运算组成一张有向无环图:叶子张量是用户创建的参数或输入,内部节点保存加法、乘法、矩阵乘法和激活等操作,根节点常常是一个标量 loss。调用 loss.backward() 后,梯度沿图从根回到叶子,并存入叶子参数的 .grad。

只有标量输出才能直接无参数调用 backward()。如果输出是向量,需要先求和、求平均,或显式提供同形状的外部梯度。这个要求不是框架的刁难,而是因为“向量对参数的导数”是一个 Jacobian,必须说明你想要哪个方向或哪种聚合。损失函数通常负责把每个样本的误差归约为一个标量,正好成为优化器的目标。

默认情况下,计算图在 backward 后会释放中间缓存,以节约内存。如果要对同一张图再次反向,需要明确 retain_graph=True,但长期依赖它通常会增加显存和逻辑复杂度。更常见的做法是重新执行 forward,或者把不需要梯度的支路用 detach 切断。图的生命周期应和一次训练 step 对齐,避免把历史图意外保留在日志列表里。

表 5.4A · autograd 中的关键属性
对象/操作职责常见状态易错点
requires_grad是否追踪可微历史参数通常为 True误把标签打开梯度
grad_fn结果由哪个操作产生非叶子常有把它当成梯度值
.grad叶子累计的导数backward 后出现忘记清零而累加
detach()返回不再追踪的视图/张量用于停止某条路径误切断需要学习的分支
no_grad临时关闭记录评估/推理使用训练中错误包住 forward
PYTHON · autograd_minimal.py
import torch

weight = torch.tensor(2.0, requires_grad=True)
bias = torch.tensor(1.0, requires_grad=True)
feature = torch.tensor(3.0)
target = torch.tensor(10.0)

prediction = weight * feature + bias
loss = (prediction - target) ** 2
print("prediction:", prediction.item(), "grad_fn:", prediction.grad_fn)
loss.backward()
print("dw:", weight.grad.item(), "db:", bias.grad.item())
assert weight.grad is not None and bias.grad is not None

在这个例子里,预测为 2×3+1=7,误差是 -3,loss 是 9。根据链式法则,∂loss/∂weight=2(prediction-target)×feature=-18,∂loss/∂bias=-6。梯度为负表示如果想降低 loss,参数更新通常会向正方向移动。手算与 autograd 对照,是确认你理解目标、符号和参数连接的最快方法。

叶子张量、视图与 in-place 操作

优化器需要更新注册在模型中的叶子参数。对参数做普通算术会产生非叶子结果,结果的梯度通常不会自动保存在 .grad,除非显式调用 retain_grad。调试中想查看中间激活的梯度,可以临时保留;生产代码应谨慎,因为这会增加内存。

带下划线的 in-place 操作,如 add_、relu_ 或对 Tensor 的切片赋值,可能覆盖反向传播需要的旧值。不是所有 in-place 都错误,但一旦出现“变量被修改后版本不匹配”,先关闭 in-place 选项,确认梯度正确,再讨论内存优化。不要为了少一个张量就牺牲可解释性。

表 5.4B · 计算图的边界检查
检查期望异常提示
参数requires_grad=True参数没有梯度
标签不需要梯度图被无谓放大
loss标量且有限backward 报非标量/NaN
中间值必要时才 retain显存持续增长
原地修改不覆盖保存的值版本计数错误
AUTOGRAD LAB 5.4.1手算并核对两个梯度

令 ŷ=w×x+b,loss=(ŷ-y)²,取 w=2,x=3,b=1,y=10。手算对 w 和 b 的梯度,再用 PyTorch 验证,并解释梯度的符号。

预测为 7,误差为 -3,∂loss/∂w=-18,∂loss/∂b=-6。负号表示增大对应参数会在当前点降低 loss;实际更新还要乘学习率,不能把梯度直接当参数改变量。

5.4.2 链式法则与反向传播:局部导数如何接成全局梯度

假设 a=f(x)、z=g(a)、loss=h(z),链式法则给出 ∂loss/∂x=(∂loss/∂z)(∂z/∂a)(∂a/∂x)。反向传播从末端开始,先求 loss 对 z 的影响,再乘以 g 的局部导数,继续回到 a 和 x。深层网络只是把这条链延长,并在分支处把来自不同路径的梯度相加。

矩阵层的梯度也可以用形状来检查。若 Y=XW+b,其中 X 为 (B,F)、W 为 (F,H),则输出为 (B,H);反向时 ∂loss/∂W 仍是 (F,H),与 W 形状一致,∂loss/∂X 与 X 形状一致。看到某个参数梯度形状不符,通常意味着转置或轴语义写错。

梯度默认会累积。这样做允许把多个小 batch 的梯度加起来模拟更大 batch,但普通训练循环每一步都应在 backward 前清零,否则上一步的方向会混入当前 batch。optimizer.zero_grad() 是常见做法,也可以用 set_to_none=True 让未产生梯度的参数保持 None;选择哪种方式要和日志、梯度裁剪及稀疏参数的处理一致。

表 5.4C · 反向传播的形状对照
量前向形状反向梯度形状检查方法
X(B,F)dX (B,F)与输入逐元素对应
W(F,H)dW (F,H)与参数同形状
b(H,)db (H,)沿 batch 求和/归约
激活(B,H)同形状负区间梯度是否为零
loss标量反向起点有限且可复现
PYTHON · gradient_accumulation.py
import torch

parameter = torch.tensor(1.0, requires_grad=True)
first = (parameter - 3.0) ** 2
first.backward()
print("after first:", parameter.grad.item())
second = (parameter + 2.0) ** 2
second.backward()
print("after second, accumulated:", parameter.grad.item())

parameter.grad.zero_()
third = (parameter + 2.0) ** 2
third.backward()
print("after clear:", parameter.grad.item())

示例故意连续 backward 两个 loss,第二次的梯度被加到第一次上;清零后再算,才只剩当前目标的导数。训练循环里应把清零、forward、loss、backward、step 的顺序固定下来,并在代码注释和日志中说明。梯度累积用于模拟大 batch 时,要把 loss 除以累积步数,或明确你希望的梯度尺度。

detach、detach_ 与 no_grad 的区别

detach() 返回一个不参与当前计算图的新视图,适合把目标网络、缓存表示或记录值与主损失隔离;detach_() 会尝试原地修改当前 Tensor 的追踪状态,使用前要确认没有其他计算依赖它。torch.no_grad() 是上下文范围内关闭梯度记录,通常用于验证和推理;它不会把模型参数永久改成不需要梯度。

不要用 tensor.data 绕过 autograd。它可能让计算图看到被悄悄修改的值,产生难以追踪的错误。需要读取数值用于日志时使用 tensor.detach().cpu().item() 或 .numpy()(先确保不需要梯度),需要更新参数时交给优化器或在 torch.no_grad() 中明确执行。

表 5.4D · 什么时候切断梯度
场景推荐做法不要做什么
验证预测model.eval() + no_grad保留整张图
记录标量loss.detach().item()把带图 Tensor 存列表
目标/教师网络显式 detach意外更新目标分支
参数更新优化器或 no_grad直接改 .data
AUTOGRAD LAB 5.4.2判断梯度为什么被放大

一个训练循环把两个 batch 的 loss 分别 backward 后才调用 zero_grad。说明第二个 batch 的梯度意味着什么,并写出普通训练与梯度累积两种正确顺序。

第二个 batch 的梯度包含前一个 batch 的累积值;普通训练应在每个 step 的 backward 前清零。若要累积 N 个 batch,则每次用 loss/N backward,N 次后再 step 和 zero_grad,并记录有效样本数。

5.4.3 梯度检查与推理模式:先证明反向算对,再追求速度

自动微分可靠,但模型代码仍可能把目标、维度或 mask 写错。有限差分可以在小规模参数上验证梯度:用 (L(θ+ε)-L(θ-ε))/(2ε) 估计某个参数的导数,再与 autograd 的结果比较。ε 太大有截断误差,太小有浮点消去;应尝试几个数量级,并使用相对误差和绝对误差而不是要求完全相等。

梯度检查要在双精度、小张量、确定随机种子和无随机层的条件下进行。Dropout、批归一化的训练统计、非光滑阈值和离散采样会让数值比较不稳定;可以先把模型置于 eval 或替换成平滑小模块,再逐步恢复真实结构。gradcheck 是工具,不是证明整个训练系统正确的替代品。

评估阶段要同时做两件事:调用 model.eval() 切换 Dropout、BatchNorm 等模块的行为,再用 torch.no_grad() 或 inference mode 关闭梯度记录。只做其中一件会留下隐患:eval 但仍记录梯度浪费内存,no_grad 但忘记 eval 会让同一输入因随机失活或批次统计而变化。

表 5.4E · 梯度检查的实验条件
条件推荐设置原因
dtypefloat64减少有限差分舍入误差
规模少量参数与样本逐项定位更容易
随机性固定 seed/关闭随机层两次 loss 可比较
步长多组 ε观察截断与消去的折中
误差相对+绝对容差避免零附近误判
PYTHON · finite_difference_gradient.py
import torch

torch.manual_seed(7)
weight = torch.tensor(0.4, dtype=torch.float64, requires_grad=True)
target = torch.tensor(1.7, dtype=torch.float64)

loss = (weight * 2.0 - target) ** 2
loss.backward()
autograd_value = weight.grad.item()

def objective(value):
    return (value * 2.0 - target.item()) ** 2

epsilon = 1e-5
numeric = (objective(weight.item() + epsilon) - objective(weight.item() - epsilon)) / (2 * epsilon)
print("autograd:", autograd_value, "numeric:", numeric)
assert abs(autograd_value - numeric) < 1e-8

这个示例只检查一个标量参数,但同样的思想可以应用到一个小 Linear 层的单个元素。不要在完整大模型、混合精度和随机增强同时开启时直接做全量 gradcheck;先缩小问题,再逐层增加复杂度。每次修复后保留一条通过的梯度检查作为回归测试。

哪些操作不适合直接求梯度

取整、argmax、硬阈值和根据索引选择离散路径,通常在大部分输入区域没有有用梯度。若它们只用于推理后的展示,可以放在图外;若必须参与训练,需要使用可微近似、软选择或专门的估计方法,并说明近似改变了什么。把一个不可导操作塞进 forward 后再期待 optimizer 自动解决,是概念上的错位。

自定义 autograd Function 只有在内置算子无法表达或确实需要特殊前后向时才使用。自定义 backward 必须和 forward 的保存值、dtype、设备及边界行为一致;先用有限差分和极小输入验证,再考虑性能。多数基础网络不需要自定义 Function,优先组合经过验证的 PyTorch 运算。

表 5.4F · 反向传播完成标准
能力最低证据进入下一章前要做到
读计算图能解释 requires_grad、grad_fn 和 grad知道 loss 从哪里回传
控梯度能区分 zero_grad、detach、no_grad写出不累积的训练 step
查正确性能用有限差分核对小参数定位符号、轴和 mask 错误
分离模式能正确切换 train/eval为优化器与数据工程做准备
AUTOGRAD LAB 5.4.3设计一条梯度回归测试

选一个只有两个参数的函数,写出解析梯度、有限差分梯度和允许误差。再列出一个会让检查不稳定的因素,并说明如何隔离它。

函数要可微且规模小,例如平方误差或两层线性组合;使用 float64、固定输入和多个 ε。随机层、整型操作、硬阈值和过小步长都会影响结果,可先关闭随机性、改用平滑函数或调整步长。
表 5.4G · 本节完成标准
能力最低证据进入下一节的连接
解释链式法则能从局部导数说到全局梯度理解 optimizer.step
控制图生命周期知道何时重算、切断或关闭梯度减少训练内存
验证梯度有一条有限差分回归测试信任训练曲线
处理边界能说明不可导操作的替代方案选择可训练的输出路径

梯度的生命周期:一次 backward 之后发生了什么

可以把梯度想成写在参数旁边的一张便签。前向计算时,autograd 记录产生 loss 所需的运算;调用 backward() 后,便签上写入当前这一次目标对参数的偏导;调用 optimizer.step() 读取便签并修改参数;下一批数据到来前,必须用 zero_grad() 清掉旧便签。若不清零,PyTorch 默认会把新梯度累加,累加有时是有意的梯度累积,有时却只是忘记清理。

叶子 Tensor 和中间 Tensor 的行为也不同。模型参数通常是叶子 Tensor,梯度会保存在它们的 .grad 中;中间结果默认不保留梯度,除非显式调用 retain_grad()。这能节省内存,但调试时如果直接打印中间激活的 .grad,看到 None 不一定表示反向失败。应先确认它是否为叶子、是否参与了最终 loss,再决定是否暂时保留。

原地修改是计算图中一个常见边界。若某个后续梯度需要旧值,而代码在原地覆盖了它,autograd 可能报错;即使没有报错,绕开图的 .data 之类写法也会让参数状态难以推理。更安全的做法是使用返回新 Tensor 的表达式,或在明确不需要梯度的上下文中复制后修改。遇到“变量被原地修改”的异常,应回到最小 batch,而不是直接关闭 autograd。

detach() 表示从当前图切断梯度关系,常用于把特征交给日志、缓存或另一个不应反向的分支;它不会复制数据,后续原地操作仍可能影响共享存储。torch.no_grad() 则是一个上下文,暂时不记录其中的运算,适合验证和推理。二者都不是“让模型更准确”的开关,而是对计算图生命周期的明确声明。

表 5.4H · autograd 边界与选择
工具/状态作用适合场景要留意的事
requires_grad=True记录相关运算可训练参数、梯度实验会增加图与内存
backward()把梯度写回叶子一次训练更新前默认累积,不会自动清零
zero_grad()清理已有梯度下一批之前梯度累积时按计划调用
detach()切断当前图缓存、日志、冻结分支可能共享存储
no_grad()关闭记录验证、推理、参数读取不能替代 eval()
inference_mode()更彻底的推理优化只读推理路径不能在其中继续训练
PYTHON · gradient_lifecycle.py
import torch

weight = torch.tensor(2.0, requires_grad=True)
input_value = torch.tensor(3.0)

loss_a = (weight * input_value - 1.0) ** 2
loss_a.backward()
first_gradient = weight.grad.item()
print("after first backward:", first_gradient)

weight.grad.zero_()
loss_b = (weight * input_value + 2.0) ** 2
loss_b.backward()
second_gradient = weight.grad.item()
print("after clear and second backward:", second_gradient)

with torch.no_grad():
    prediction = weight * input_value
print("prediction has grad_fn:", prediction.grad_fn)
assert prediction.grad_fn is None

在这个最小例子中,清零发生在第二次 backward 之前,因此第二个数字只对应 loss_b。如果把 zero_() 删除,第二个梯度会包含两次损失的贡献。真实训练中的“梯度累积”正是有计划地延迟 step、累加若干小批次的梯度,但必须按累积步数缩放 loss,并在指定时机清零,否则有效学习率会悄悄变大。

反向传播的调试应同时看数值和结构:参数是否有 grad、梯度是否有限、范数是否突然变大、哪些层长期为零。可以先在一个固定 batch 上运行一次,再打印每层梯度范数;如果同一 batch 的结果每次不同,先关闭 Dropout 和随机增强。完成最小检查后再恢复随机性,才能把计算图问题与统计波动分开。

图 5.4H · 梯度生命周期自制示意:记录、回传、更新、清理
forward建立计算图
→
loss.backward写入梯度
→
optimizer.step读取并更新
→
zero_grad准备下一批

验证路径跳过 backward 与 step,但仍需要正确的 eval/no_grad 边界。

最后区分“不可导”和“梯度很小”。argmax、硬阈值和整数索引通常不提供对输入的连续梯度;Sigmoid/Tanh 饱和时仍然可导,但导数接近零。面对学习停滞,先检查是否在 forward 中过早做了离散决策,再查看激活分布和梯度范数。若业务必须输出离散结果,把离散化留到 loss 之后或推理阶段。

反向传播的最小证明:数值、路径和状态三件套

验证梯度时同时做三件事。第一是数值对照:对一个标量参数使用中心有限差分,与 autograd 结果比较;第二是路径对照:故意切断一条分支,确认只有依赖该分支的参数梯度发生变化;第三是状态对照:检查 backward 前后、zero_grad 前后的 .grad,确认没有把旧梯度误当成新梯度。

梯度检查要控制变量。使用小规模、固定输入和 float64,避免随机层、整数索引和硬阈值;epsilon 试几个数量级,观察相对误差是否先降后升。若差异很大,先检查函数是否连续、是否有原地操作、是否在比较同一个参数,而不是立即怀疑框架。

反向图的内存也需要管理。训练循环中只保存标量日志和 detached 的可视化数据;不要把每个 batch 的 loss Tensor 直接放进长期列表。需要多次使用同一图时,明确为什么要保留图,并估计额外内存。验证和推理使用 no_grad 或 inference_mode,但仍要用 eval 切换 Dropout、BatchNorm 等模块行为。

如果只训练模型的一部分,先把冻结规则写成配置,再检查优化器参数列表。冻结参数的梯度应为 None 或不参与更新;可训练参数应在每次 step 后有可观察变化。把参数状态、梯度范数和更新前后差值打印一次,通常就能发现“代码运行了但模型没有学”的问题。

表 5.4J · 梯度检查三件套
检查层面最小做法通过标准
数值float64 中心差分相对误差在容差内
路径切断一条分支再 backward受影响参数变化可解释
状态记录 grad/zero_grad/step无意累积或空梯度
内存日志使用 item/detach迭代内存不持续增长
冻结对照参数更新前后只有指定层发生更新
图 5.4J · 从异常梯度回到最小复现自制示意:逐层缩小问题
完整训练发现异常
→
固定 batch去掉随机性
→
两参数函数手算/差分
→
恢复模块逐层接回

每次只恢复一个变量,直到能指出第一个出现差异的边界。

当这三套证据一致时,才可以放心进入优化器、混合精度或更深网络。自动微分替你完成了求导,但“是否在正确的图上求导”仍然需要你负责。

一个实用的梯度回归测试还应保存参数初值和随机种子。测试时复制模型、在同一输入上计算 loss,比较关键参数的解析梯度与有限差分;测试结束后丢弃临时模型,避免把检查过程中的参数改动带入正式训练。对于包含随机层的模型,先用 eval 或固定随机状态得到确定性输出,再分别测试训练模式下的随机行为。

表 5.4K · 反向传播回归记录
字段示例作用
参数名encoder.0.weight定位差异层
初值摘要均值/范数/哈希确认比较同一模型
epsilon1e-3、1e-4观察差分稳定区间
绝对/相对误差1e-7 / 1e-5定义通过容差

记录这些字段后,梯度回归就能在重构模块、升级 PyTorch 或更换设备后再次运行;它保护的是计算关系,而不是某一次训练的最终分数。若差异超出容差,先比较金样本和第一个异常层,再决定是否接受由浮点实现带来的小幅变化。

05 · OPTIMIZATION LOOP

优化器与训练循环:把一次更新写成可观察的程序

优化器负责根据梯度修改参数,但它不负责定义任务、读取数据或判断模型是否上线。一个可靠的训练循环要明确 batch、epoch、loss、zero_grad、backward、step、验证模式和日志分母。本节从最简单的 SGD 开始,理解 AdamW、学习率和梯度裁剪为何改变训练轨迹。

5.5.1 SGD 与学习率:每一步到底走多远

最基本的梯度下降更新是 θ←θ−η∇L(θ),其中 θ 是参数,η 是学习率,∇L 是当前 loss 对参数的梯度。梯度指向 loss 增长最快的方向,所以减去它才是局部下降。学习率太小,loss 可能下降得很慢;太大,可能越过低谷、来回震荡甚至产生 NaN。学习率不是越小越安全,因为过小也会让模型在预算内几乎没有学到东西。

随机梯度下降用一个 batch 的梯度近似全体数据的梯度。batch 小,更新更频繁、噪声更大,可能帮助跳出尖锐区域;batch 大,估计更稳定,但显存和单步成本更高。epoch 只是完整遍历一次数据,不等于一次参数更新。报告训练进度时要区分 step、epoch、样本数和有效 token 数,不能用“训练了十轮”掩盖实际看到的数据量差异。

Momentum 在更新中保留一部分历史方向,减少狭长谷底里的来回摆动;学习率调度器则根据 step、epoch 或验证指标改变步长。调度器的调用时机必须写清楚:有的每个 batch 调用,有的每个 epoch 调用,有的在验证后调用。保存检查点时一起保存优化器和调度器状态,否则恢复训练会从错误的动量或学习率开始。

表 5.5A · 影响训练轨迹的四个量
量作用过小/不足过大/过强
学习率 η控制更新步长收敛慢、停滞震荡、发散
batch size估计梯度的样本数噪声大、吞吐低显存高、更新少
momentum积累历史方向摆动明显过冲、难以改变方向
epoch/steps重复看到数据次数欠训练过拟合、资源浪费
PYTHON · sgd_update_by_hand.py
def sgd_step(parameter, gradient, learning_rate):
    if learning_rate <= 0:
        raise ValueError("学习率必须为正")
    return parameter - learning_rate * gradient

parameter = 4.0
for step in range(5):
    gradient = 2 * (parameter - 1.0)
    parameter = sgd_step(parameter, gradient, learning_rate=0.2)
    print(step, parameter, gradient)
assert abs(parameter - 1.0) < 1.0

手算版本展示了学习率的意义:目标函数是 (θ-1)² 时,梯度为 2(θ-1)。学习率 0.2 会让参数逐渐靠近 1;若改成 1.2,更新很可能跨过最低点。真实网络的曲面高维、不同参数的尺度不同,不能只凭一个标量实验选择最终学习率,但这个实验能帮助你读懂训练日志中的震荡与停滞。

学习率搜索要有预算和记录

一个实用的搜索顺序是先用对数尺度挑几个候选,例如 1e-4、3e-4、1e-3、3e-3,再在验证集上观察前若干个 epoch 的下降速度、梯度范数和是否出现非有限值。不要只看最终分数;早期就发散的候选应记录为失败,训练很慢但最终不错的候选要和资源成本一起比较。

如果使用 warmup,说明 warmup 的 step 数和后续调度函数。warmup 不是“让模型更聪明”的开关,而是让初始阶段的更新更温和,尤其在大 batch、混合精度或新初始化下有助于避免早期不稳定。任何调度策略都应和基础学习率、优化器、总 step 和恢复方式一起保存。

表 5.5B · 学习率实验记录
字段示例为什么保留
base_lr0.001复现更新尺度
warmup_steps200解释早期曲线
schedulercosine/plateau知道何时改变学习率
step_intervalper_batch/per_epoch避免调用时机错误
effective_batch64×4 accumulation比较梯度噪声与吞吐
OPTIM LAB 5.5.1解释发散与停滞

给出三组训练现象:loss 在前两步暴涨、loss 几乎不变但梯度非零、训练 loss 降而验证 loss 升。分别列出两个优先检查项,不要直接写“换更大的模型”。

暴涨先查学习率、输入尺度、梯度非有限和标签错配;停滞查学习率是否过小、参数是否被冻结、梯度是否被截断;训练降而验证升查过拟合、切分、正则化和验证模式。每个判断都要用日志或小实验验证。

5.5.2 AdamW、权重衰减与梯度裁剪:让更新更稳而不是更神秘

Adam 会为每个参数维护梯度的一阶和二阶移动统计,根据历史尺度调整更新。它通常在初始阶段比较容易工作,但“自适应”不代表无需学习率、权重衰减或数据审计。AdamW 把权重衰减从梯度自适应更新中解耦,在实践中更容易把“限制参数变大”和“沿梯度优化”分开解释。

权重衰减是正则化的一种实现方式,倾向于把参数拉回较小的范围,但不应机械地施加到所有参数。常见做法是对线性/卷积权重衰减,对 bias 和归一化参数不衰减;具体规则要写在参数分组里并记录。把 dropout、数据增强、标签 smoothing 和 weight decay 同时打开后,训练偏好会共同变化,必须通过对照实验确认每项的边际价值。

梯度裁剪限制梯度范数或元素范围,常用于序列模型和容易爆炸的训练。裁剪不是修复数据或学习率错误的万能药;如果每一步都触发裁剪,说明原始更新尺度可能不合理。日志应同时记录裁剪前范数、裁剪后范数和触发比例。使用混合精度时,通常先 unscale 梯度再裁剪,避免缩放因子让阈值失去意义。

表 5.5C · 优化器与稳定化工具
工具改变什么需要记录不能替代
SGD按梯度统一步长lr、momentum、batch合理的数据与目标
AdamW按历史尺度自适应更新lr、betas、eps、decay验证与泛化诊断
weight decay抑制参数规模参数分组、系数处理标签噪声
clip_grad_norm限制整体梯度范数阈值、触发率消除所有发散原因
PYTHON · optimizer_groups.py
import torch
from torch import nn

class TinyNet(nn.Module):
    def __init__(self):
        super().__init__()
        self.linear = nn.Linear(4, 3)
        self.norm = nn.LayerNorm(3)

    def forward(self, x):
        return self.norm(self.linear(x))

model = TinyNet()
decay, no_decay = [], []
for name, parameter in model.named_parameters():
    (no_decay if name.endswith("bias") or "norm" in name else decay).append(parameter)
optimizer = torch.optim.AdamW([
    {"params": decay, "weight_decay": 1e-2},
    {"params": no_decay, "weight_decay": 0.0},
], lr=3e-4)
print([len(group["params"]) for group in optimizer.param_groups])

参数分组代码的关键不是数值本身,而是让规则可读、可检查。可以打印每组参数名称和数量,确认没有遗漏或重复;如果新增模块后它的参数没有进入任何组,优化器就不会更新它。实验清单中保存分组策略,加载检查点时也要使用相同的结构。

裁剪和混合精度的边界

梯度范数是所有参数梯度的整体大小,逐元素裁剪则对每个元素独立截断。前者更能保持方向,后者更直接但可能改变方向;选择要根据任务和异常模式。裁剪阈值应在验证训练中通过梯度分布设定,不能看到一次爆炸就随手写一个很小的值。

混合精度通常用较低精度计算前向和反向,用更高精度或缩放器保护更新。它会改变舍入、溢出和吞吐,不能和“完全相同的数值结果”同时承诺。先在小数据上用 float32 得到稳定基线,再开启 autocast、GradScaler 或 bfloat16,并比较 loss、指标、非有限计数和显存。

表 5.5D · 训练稳定性日志
日志用途异常阈值示例
grad_norm_before观察原始更新尺度连续远高于历史分位
clip_fraction判断是否频繁裁剪长期接近 1
nonfinite_count捕获 NaN/Inf大于 0 立即停
parameter_norm观察权重爆炸/塌缩跨 epoch 异常漂移
optimizer_step确认更新确实发生梯度为 None 或 step 未增长
OPTIM LAB 5.5.2写出参数分组规则

一个模型有 Linear 权重、Linear bias、LayerNorm 权重和 LayerNorm bias。设计 weight decay 参数组,并说明为什么不能只按“所有参数一起衰减”处理。

常见规则是 Linear 权重进入衰减组,bias 和归一化参数进入不衰减组;实际项目可按模型论文或基线验证调整。必须打印每组名称、数量和系数,避免新层遗漏。权重衰减是实验变量,不是永远正确的默认。

5.5.3 标准训练循环:每个 step 的职责不能混在一起

一个清晰的训练循环通常分成 train loop 和 evaluation loop。训练阶段调用 model.train(),取出一个 batch,搬到目标设备,前向得到预测,计算 loss,清零旧梯度,反向传播,必要时裁剪,再让 optimizer.step 更新参数。验证阶段调用 model.eval(),用 no_grad 关闭梯度,只累积 loss 和业务指标,不改变参数和归一化统计。

代码顺序要稳定。把 optimizer.step() 放在 backward 之前不会得到当前 batch 的梯度;忘记 zero_grad 会累积;在验证时忘记 eval 会让 dropout 或 batch norm 继续按训练行为工作;只取最后一个 batch 的 loss 会误导整轮曲线。建议把“一个 batch 的训练”“一轮验证”“指标聚合”“保存最佳检查点”拆成职责明确的函数。

日志的分母和计算图生命周期同样重要。累积 loss 时使用 batch 样本数或有效 token 数加权,而不是简单平均不同大小 batch 的平均值;记录标量时调用 detach().item(),不要把带图 Tensor 存入列表。每个 epoch 输出训练/验证 loss、主指标、学习率、梯度统计、耗时和样本数,异常时保留最后一个成功 batch 的信息。

表 5.5E · 一个标准 step 的顺序
顺序动作职责漏掉后的表现
1zero_grad清除上一步梯度方向被累积污染
2forward由输入得到输出没有当前预测
3loss把输出与目标比较无训练信号
4backward计算参数梯度grad 为 None
5clip/step控制并应用更新参数不变或发散
PYTHON · train_eval_loop.py
import torch

def train_one_epoch(loader, model, loss_fn, optimizer, device):
    model.train()
    total_loss = 0.0
    total_items = 0
    for features, targets in loader:
        features, targets = features.to(device), targets.to(device)
        optimizer.zero_grad(set_to_none=True)
        logits = model(features)
        loss = loss_fn(logits, targets)
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
        optimizer.step()
        batch_size = targets.shape[0]
        total_loss += loss.detach().item() * batch_size
        total_items += batch_size
    return total_loss / max(total_items, 1)

def evaluate(loader, model, loss_fn, device):
    model.eval()
    total_loss, total_items = 0.0, 0
    with torch.no_grad():
        for features, targets in loader:
            logits = model(features.to(device))
            loss = loss_fn(logits, targets.to(device))
            total_loss += loss.item() * targets.shape[0]
            total_items += targets.shape[0]
    return total_loss / max(total_items, 1)

函数把训练和评估的模式切换写在内部,调用者只需要提供 loader、模型、损失和优化器。返回值使用样本数加权的平均 loss,避免最后一个小 batch 影响过大。真实项目还应返回预测、标签或混淆矩阵所需的中间结果,但不要在训练循环里偷偷修改测试集或保存敏感原文。

PYTHON · fit_with_early_stop.py
def fit(train_loader, valid_loader, model, loss_fn, optimizer, device, epochs=20, patience=3):
    best_loss = float("inf")
    best_state = None
    stale = 0
    history = []
    for epoch in range(1, epochs + 1):
        train_loss = train_one_epoch(train_loader, model, loss_fn, optimizer, device)
        valid_loss = evaluate(valid_loader, model, loss_fn, device)
        history.append({"epoch": epoch, "train_loss": train_loss, "valid_loss": valid_loss})
        if valid_loss < best_loss:
            best_loss = valid_loss
            best_state = {key: value.detach().cpu().clone() for key, value in model.state_dict().items()}
            stale = 0
        else:
            stale += 1
        if stale >= patience:
            break
    if best_state is not None:
        model.load_state_dict(best_state)
    return history, epoch

早停不是看起来更好的数字

早停用验证指标决定何时停止并回到最佳检查点,属于模型选择的一部分。patience、最小改善阈值和主指标都要写进配置;如果验证集被反复搜索,测试集仍然只能最后使用。保存最佳状态时要复制 Tensor,而不是保存会继续变化的引用。

训练循环结束后,最好用独立的评估函数重新载入最佳检查点,再对验证和测试各跑一次,并保存完整预测与错误样例。这样可以避免循环中的临时统计、最后一轮状态或模式切换影响最终报告。对大模型训练,恢复检查点还要验证 tokenizer、数据版本和 optimizer 状态。

表 5.5F · 训练循环验收
能力最低证据常见漏项
写 stepzero→forward→loss→backward→step顺序错误/梯度累积
分模式train 与 eval/no_grad 成对出现Dropout/Norm 行为错
聚合指标记录分母、样本数和有效 token最后 batch 偏差
保存最佳配置、模型、优化器和 epoch 一起保存无法恢复/复盘
早停验证规则与 patience 可复现测试集参与选择
OPTIM LAB 5.5.3审查一段训练循环

找出下列流程的三个错误并排序:先 optimizer.step(),再 loss.backward();验证时没有 model.eval();把每个 batch 的平均 loss 直接取平均;保存的是最后一轮而非最佳轮。

更新顺序错误会让 step 使用旧梯度;验证模式错误会影响 Dropout/BatchNorm;不加权平均可能被小 batch 扭曲;只保存最后一轮无法保证验证表现最好。应先修会直接改变参数或指标含义的错误,再优化日志样式。
表 5.5G · 本节完成标准
能力最低证据进入下一节的连接
解释 SGD能说明学习率、batch 和 momentum选择训练预算
管理优化器能写参数组、衰减和裁剪理解工程稳定性
写训练循环能分离 train/eval 并正确聚合接入 Dataset/DataLoader
恢复最佳能按验证规则早停并保存状态构建可复现实验

把优化器看成实验控制器

优化器并不“理解”任务,它只看到参数和梯度。学习率决定每次沿梯度移动的尺度,momentum 或 Adam 的状态决定是否利用历史方向,权重衰减决定是否额外压缩参数。换一个优化器,实际上同时换了参数轨迹和隐含的正则化;因此比较优化器时要固定数据、模型、训练预算和随机种子,而不是只贴一条更好看的曲线。

学习率是最值得先画出来的超参数。固定一个 batch,尝试几个数量级,观察 loss 是几乎不动、稳定下降、震荡还是发散。若一开始就出现 NaN,先缩小学习率并检查输入/梯度范围;若 loss 长时间不变,可能是学习率太小、参数没有被优化器注册、梯度为零或标签/损失接口错误。把这些现象对应到诊断表,比“多训练几轮”更有信息。

学习率调度器改变的是时间轴上的步长,不会替代正确的基础学习率。余弦退火、分段衰减、ReduceLROnPlateau 等各有适用条件;使用它们时记录 scheduler.step 的时机,是按 batch 还是按 epoch,以及恢复检查点时是否恢复调度器状态。对新模型,先用恒定学习率跑通最小任务,再引入调度器,避免把两个变量同时改变。

梯度裁剪是护栏,不是修复损失错配的药。按范数裁剪会把过大的梯度整体缩放,按值裁剪则逐元素截断;二者都会改变更新方向或尺度。日志要同时记录裁剪前后范数和触发次数,若每一步都被裁剪,通常仍要回查学习率、初始化、序列长度和数值精度。混合精度训练还需要 loss scaling,但初学时先理解全精度的更新顺序。

表 5.5H · 训练曲线与优化旋钮
现象优先检查可做的单变量实验不要先做什么
loss 几乎水平参数是否有梯度、学习率是否过小学习率乘 10 或检查参数注册直接加深模型
loss 震荡学习率、batch 噪声、标签范围学习率除 3 或增大 batch只看最后一个 batch
快速发散输入尺度、初始化、非有限梯度降低学习率并记录范数默默丢弃 NaN
训练好验证差切分、容量、正则和泄漏固定其余项做一次消融反复调测试集
恢复后轨迹不同optimizer/scheduler/随机状态做 checkpoint round-trip只加载模型权重
PYTHON · optimizer_probe.py
import torch
from torch import nn

torch.manual_seed(7)
model = nn.Linear(2, 1)
optimizer = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9)
inputs = torch.tensor([[1.0, 0.0], [0.0, 1.0]])
targets = torch.tensor([[1.0], [-1.0]])
criterion = nn.MSELoss()

for step in range(5):
    optimizer.zero_grad(set_to_none=True)
    prediction = model(inputs)
    loss = criterion(prediction, targets)
    loss.backward()
    grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
    optimizer.step()
    print(step, round(loss.item(), 5), round(float(grad_norm), 5))

这里使用 set_to_none=True 清理梯度,通常可以减少不必要的写入;读取梯度时要先判断是否为 None。训练日志至少包含 step/epoch、样本数、loss、主指标、学习率、梯度范数和非有限计数。只记录一个 loss 小数点后两位,会把震荡、溢出和分母变化隐藏起来。

批次大小和学习率经常一起变化,但不能默认它们可以任意线性缩放。增大 batch 会减少梯度噪声、改变每个 epoch 的更新次数和显存占用;若使用梯度累积,有效 batch 是多个小批次的总和,但 BatchNorm 看到的仍是小批次。比较不同 batch 时,报告每个样本的更新次数、总 token/样本预算和 wall-clock 时间,而不只是 epoch。

早停与最佳检查点也属于优化流程。每个验证周期先计算主指标,再判断是否超过历史最好;设置 patience 时说明容忍多少个未改善周期,保存的应是最佳状态而不是最后状态。若主指标越小越好,比较符号要与日志一致。恢复训练时从保存的 epoch/step 继续,避免把同一批数据重复计入预算。

06 · DATA & TRAINING ENGINEERING

数据集、批次与训练工程:让模型每次看到同一种输入

模型只是训练链的一段,数据怎样被索引、拼 batch、转换 dtype、搬到设备和保存版本,同样决定结果。PyTorch 用 Dataset 描述“一个样本如何取得”,DataLoader 描述“如何按批次迭代”。本节把数据契约、设备模式和检查点连成可复现的工程闭环。

5.6.1 Dataset 与 DataLoader:把一行样本交给模型

Dataset 的核心职责是回答两个问题:数据集有多少个样本,以及给定索引如何返回一个样本。自定义 Dataset 通常实现 __len__ 和 __getitem__,返回特征、标签以及可选的样本 ID 或 mask。DataLoader 在此之上负责 batch、shuffle、并行读取、丢弃最后不完整批次和自定义 collate。把文件扫描、标签生成、模型前向和指标计算全部写在 __getitem__ 中,会让数据难以测试和复用。

Dataset 返回的对象结构要稳定。若第一个样本返回两个 Tensor,第二个样本因为缺失字段返回三个值,默认 collate 会失败;更隐蔽的是不同样本的 dtype、长度或标签范围不一致,拼 batch 时被静默填充或广播。建议先在单样本上打印类型、形状、范围和 ID,再取两个不同索引交给 DataLoader,确认批次结构符合模型 forward。

shuffle 只应在训练集使用,验证和测试通常保持确定顺序,便于错误样例回溯。对时间序列、同一用户多条事件或配对数据,随机打乱可能破坏任务边界;此时要在 Dataset 之前完成分组和切分。自定义 sampler、batch_sampler 或 collate_fn 都是改变抽样分布的实验变量,必须在配置和报告中记录。

表 5.6A · Dataset/DataLoader 的职责
组件负责什么不应负责什么验收方式
Dataset按索引返回一个完整样本更新模型参数单索引类型/形状检查
Transform确定的样本转换读取未来标签前后范围与版本
collate_fn把样本拼成 batch选择模型/阈值变长、空值和 mask 测试
DataLoader批次、顺序、并行读取改变标签语义batch 数、顺序、吞吐
训练循环前向、loss、更新悄悄修改原始数据step 与样本数日志
PYTHON · tabular_dataset.py
import torch
from torch.utils.data import Dataset, DataLoader

class TabularDataset(Dataset):
    def __init__(self, rows):
        self.rows = rows

    def __len__(self):
        return len(self.rows)

    def __getitem__(self, index):
        row = self.rows[index]
        features = torch.tensor(row["features"], dtype=torch.float32)
        label = torch.tensor(row["label"], dtype=torch.long)
        return features, label, row["id"]

rows = [{"id": "a", "features": [1.0, 0.2, 3.0], "label": 0},
        {"id": "b", "features": [0.4, 0.8, 1.0], "label": 2}]
loader = DataLoader(TabularDataset(rows), batch_size=2, shuffle=False)
features, labels, ids = next(iter(loader))
print(features.shape, labels.shape, ids)
assert features.shape == (2, 3) and labels.dtype == torch.long

示例返回了 ID,但默认 collate 会把字符串收集成字符串列表,这正好用于错误回溯。真实数据集可以返回字典,让字段名比位置更清楚;无论选择 tuple 还是 dict,都要让训练循环、评估脚本和保存预测使用同一份约定。不要因为“模型只需要 features 和 labels”就丢掉之后定位错误所需的样本 ID。

转换的顺序和随机性

数据转换应分为确定性转换和训练增强。类型转换、单位换算、归一化参数应用属于确定性部分;随机裁剪、噪声、遮挡或 token masking 属于增强,只在训练集开启。验证和测试必须使用可复现的转换,否则每次评估都在改变样本。若增强需要随机数,保存 seed、生成器或增强配置,并在日志中记录版本。

归一化统计量只能从训练数据拟合,再应用到验证和测试。对于图像可以记录通道均值方差,对于表格记录每列中心和尺度,对于文本记录 tokenizer 与截断策略。把全量数据的统计放进 Transform 会造成数据泄漏,和第 4 章的 Pipeline 原则相同。

表 5.6B · 数据转换检查
转换训练时评估时记录
dtype/单位开启同样开启目标 dtype、单位
训练增强随机关闭或确定性参数、seed、版本
标准化用 train 拟合复用 train 参数均值、尺度、来源
截断/padding按契约同一上限长度、mask、丢弃数
标签转换明确编码不重新映射类别字典、ignore 值
DATA LAB 5.6.1设计一个返回稳定字典的 Dataset

为“预测阅读完成度”的数据写 Dataset,要求返回 features、target、sample_id 和 mask。列出两个会让默认 collate 失败或产生错配的输入,并设计测试。

要固定字段名、dtype 和形状;变长序列需在 collate 中显式 padding 和 mask,不能让默认堆叠猜测。测试覆盖空数据、缺失字段、不同长度、未知标签、重复 ID,并核对 batch 中每个 ID 与标签仍一一对应。

5.6.2 设备、dtype 与 train/eval:同一模型在两个模式下不应混乱

训练前要选择设备并把模型与 batch 放在同一设备。常见做法是 device = torch.device("cuda" if torch.cuda.is_available() else "cpu"),然后 model.to(device)、features.to(device)。设备选择应是可记录的运行条件,不要在模型内部写死 GPU;没有加速器时,代码仍应能在 CPU 上用小数据跑通。

dtype 也要一致。输入特征通常是 float32,分类标签通常是 long,权重可能在混合精度下使用 float16 或 bfloat16。把 float 标签传给 CrossEntropyLoss,或把 long 特征交给需要梯度的 Linear,都会造成错误或隐式转换。每个 batch 首次进入模型时,记录 dtype、device、形状和有限性,遇到异常就停止而不是继续产生坏 checkpoint。

model.train() 与 model.eval() 会切换 Dropout、BatchNorm 等模块的行为;它们不会自动打开或关闭梯度。训练 loop 需要 train,验证和推理需要 eval 并配合 no_grad。只在函数外部设置一次模式很脆弱:中途验证后忘记切回 train,后续 epoch 可能一直使用评估行为。

表 5.6C · 设备与模式的配对
场景模式梯度数据动作
训练 batchmodel.train()开启模型与输入同设备
验证 batchmodel.eval()no_grad不更新参数/统计
最终推理eval()no_grad/inference保存 ID、输出和版本
梯度检查固定模式需要小 Tensor、float64
恢复训练train()开启加载 optimizer 状态
PYTHON · device_contract.py
import torch
from torch import nn

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = nn.Linear(4, 2).to(device)
features = torch.randn(3, 4, device=device, dtype=torch.float32)
labels = torch.tensor([0, 1, 0], device=device, dtype=torch.long)

assert next(model.parameters()).device == features.device
assert features.dtype == torch.float32 and labels.dtype == torch.long
model.train()
logits = model(features)
print(device, logits.shape, logits.dtype)
with torch.no_grad():
    evaluation = model(features)
assert evaluation.shape == logits.shape

代码用 next(model.parameters()).device 检查模型设备,而不是假设所有子模块都已迁移。自定义 Tensor 常量若没有注册为 buffer,也可能仍在 CPU;需要随模型迁移的固定张量应使用 register_buffer。这种细节在位置编码、类别先验和 mask 中非常常见。

随机性、并行读取与可复现的边界

固定 Python、NumPy、PyTorch 和 DataLoader worker 的随机种子只能提高可复现性,不保证所有硬件和算法都逐位一致。GPU 算子、并行读取顺序、非确定性卷积和混合精度都可能引入差异。报告时区分“完全相同”“在容差内相近”和“趋势一致”三种承诺,不要把设置一个 seed 写成绝对复现。

多进程 DataLoader 会改变读取速度、worker 随机状态和错误堆栈位置。先用 num_workers=0 验证数据正确,再逐步增加并行度。pin_memory、persistent_workers 和 prefetch_factor 是性能变量,不应在模型尚未稳定时同时调节。每次只改变一个主要变量,才能知道速度或指标变化来自哪里。

表 5.6D · 运行环境清单
层次记录内容异常表现
软件Python、PyTorch、CUDA、依赖同代码行为不同
硬件CPU/GPU 型号、显存、dtype速度/溢出差异
随机性seed、worker、确定性选项曲线漂移
读取worker、shuffle、sampler顺序或标签错位
模式train/eval/no_grad验证不稳定
DATA LAB 5.6.2定位一个设备错配

模型在 CUDA,输入在 CPU,标签是 float32。列出可能出现的错误,并写出进入 forward 前的三条断言。说明为什么标签的设备也要检查。

输入与模型设备不同会报 device mismatch;CrossEntropyLoss 需要 long 类别索引,float 标签会报 dtype 或语义错误。检查模型参数设备、features/labels 设备与 dtype、形状和有限性,三者都应在同一 batch 契约中。

5.6.3 检查点与复现:保存的不只是权重

模型的 state_dict 是参数和注册缓冲区的字典,适合保存模型状态;恢复时先实例化同一结构,再调用 load_state_dict。若要继续训练,还要保存 optimizer、scheduler、epoch、最佳指标、随机状态和数据/代码版本。只保存一份“最好权重”可以做推理,却无法解释为什么在那一轮最好,也无法无缝恢复训练。

检查点文件应该和一份人类可读的实验清单配套。清单包含任务、数据快照、模型结构、超参数、设备、指标、阈值和已知限制;文件名包含实验 ID 和版本,不要反复覆盖 best.pt。保存前先写临时文件并校验能否读取,完成后再原子替换目标路径,避免进程中断留下一个看似存在但无法加载的文件。

跨设备加载时使用 map_location,并在载入后检查所有参数的 dtype、device 和形状。PyTorch 官方教程建议优先保存 state_dict,并在需要加载时限制反序列化行为;不要从不可信来源直接加载完整 Python 对象。模型结构的代码版本必须可获得,否则 state_dict 的键虽然存在,含义也可能已经变了。

表 5.6E · 一个可恢复检查点的字段
字段用途缺失后果
model_state_dict恢复参数与缓冲区无法推理
optimizer_state_dict恢复动量/二阶统计继续训练轨迹改变
epoch/step定位进度与调度学习率错位
best_metric判断是否覆盖最佳早停不可复核
config/data_version还原实验边界结果无法解释
rng_state接近原随机序列恢复后波动更大
PYTHON · checkpoint_manifest.py
from pathlib import Path
import torch

def save_checkpoint(path, model, optimizer, epoch, metric, config):
    payload = {
        "model_state_dict": model.state_dict(),
        "optimizer_state_dict": optimizer.state_dict(),
        "epoch": epoch,
        "metric": metric,
        "config": config,
        "rng_state": torch.get_rng_state(),
    }
    temporary = Path(str(path) + ".tmp")
    torch.save(payload, temporary)
    check = torch.load(temporary, map_location="cpu", weights_only=True)
    if check["epoch"] != epoch or check["config"] != config:
        raise ValueError("检查点回读校验失败")
    temporary.replace(path)

# 练习:加载后先检查键,再把 state_dict 放回同一结构

代码示例把“保存后回读”作为最低闭环,但真实项目还要做哈希、权限和空间清理。清理旧检查点时保留最佳、最近和可回滚的稳定版本;删除前确认报告已经引用了它。大模型训练的 checkpoint 还可能包含 tokenizer、词表、优化器分片和训练数据游标,不能只凭一个权重文件宣称“可以恢复”。

从 checkpoint 走向实验账本

每次运行都生成唯一实验 ID,把配置、日志、checkpoint、预测、错误样例和环境摘要放在同一目录。日志写入时加入时间、step、样本数和版本,避免只保存终端滚屏。若运行失败,也保存失败原因和最后成功的 checkpoint;失败实验是边界证据,不应被删除后假装没有发生。

加载成功不代表加载正确。用一批固定金样本比较保存前后的 logits、损失和预测,允许明确的浮点容差;再在验证集跑一次,确认模式、类别字典和预处理都一致。对继续训练,比较恢复前后的 optimizer step、学习率和梯度范数,确认调度没有跳跃。

表 5.6F · checkpoint 验收
验收通过条件失败提示
可读取文件存在且键完整临时文件未替换
结构一致load_state_dict 无意外 missing/unexpected模型代码版本变了
金样本一致logits/预测在容差内模式、dtype、预处理错
可继续训练optimizer/scheduler/step 连续只保存了权重
可追溯配置、数据和代码版本齐全文件名含糊、覆盖旧结果
DATA LAB 5.6.3设计一份恢复演练

假设训练在第 7 个 epoch 中断,列出恢复前必须检查的文件和字段。恢复后用哪三组证据确认训练没有从错误状态继续?

检查模型/优化器/调度器状态、epoch/step、配置、数据版本、随机状态和环境。恢复后比较金样本输出、学习率与 optimizer step、下一批 loss/梯度趋势,并确认 train/eval 模式与类别字典未变化。
表 5.6G · 本节完成标准
能力最低证据进入下一节的连接
写 Dataset能返回稳定样本并验证 collate构造可控训练数据
控设备模式能配对 train/eval、dtype 与 device理解 Dropout/Norm
保存恢复能回读 state_dict 与训练状态做早停和实验复盘
记环境能写数据、代码、硬件和随机性摘要评估泛化而非偶然结果

数据管道的边界:样本、batch 与模型各自负责什么

Dataset 应该回答“索引 i 对应哪一条样本”,而不是偷偷做训练、更新参数或读取验证指标。它可以负责解析文件、转换特征、返回标签和元数据,但转换必须有稳定的输入输出契约。DataLoader 决定一次取几条、是否打乱、怎样并行读取;模型只接收已经整理好的 batch。把三种职责塞进一个 __getitem__,会让缓存、复现和单元测试都变得困难。

变长序列最能暴露 collate 的设计。一个 batch 必须把不同长度的 token 对齐到同一张量,通常需要 padding、长度字段或 attention mask。padding 值不能被模型当成真实 token,也不能在 loss 中贡献错误梯度。若使用左填充或右填充,位置编码、移位目标和生成逻辑都要同步;数据集、collate 和模型之间应共享同一份约定。

shuffle 只应改变训练样本的顺序,不应改变标签、ID 或时间关系。时间序列、同一用户的多条记录、同一文档的切片都可能需要按组或按时间切分;随机打散后再划分会把近重复样本泄漏到验证集。对每次切分保存样本 ID 清单,计算 train/valid/test 的交集,能快速发现“分数异常好”的来源。

多进程 DataLoader 还会带来随机种子、文件句柄和内存复制问题。先用 num_workers=0 验证数据内容,再逐步增加 worker;如果结果改变,检查 worker 初始化、随机增强、缓存和第三方库的随机源。不要把 GPU Tensor 留在 Dataset 中长期缓存,通常在训练循环中统一搬运设备更容易管理。

表 5.6H · 数据管道的职责分界
层负责不负责最小测试
原始数据索引文件、ID、原始标签模型参数与指标路径与版本可读
Dataset单样本特征/标签/元数据跨样本 batch 对齐随机抽三个索引
collate堆叠、padding、mask改变标签语义变长样本 batch
DataLoader迭代顺序、batch、worker学习率与 backward长度与顺序日志
训练循环搬设备、forward、loss猜测缺失标签一个 batch 完整更新
评估脚本固定模式与指标聚合调测试阈值金样本输出一致
PYTHON · collate_contract.py
import torch
from torch.nn.utils.rnn import pad_sequence

samples = [
    {"tokens": torch.tensor([4, 8, 2]), "label": 1, "id": "a"},
    {"tokens": torch.tensor([9, 3]), "label": 0, "id": "b"},
    {"tokens": torch.tensor([5, 6, 7, 1]), "label": 1, "id": "c"},
]

def collate(batch, padding_id=0):
    token_rows = [item["tokens"] for item in batch]
    tokens = pad_sequence(token_rows, batch_first=True, padding_value=padding_id)
    lengths = torch.tensor([len(row) for row in token_rows], dtype=torch.long)
    mask = torch.arange(tokens.shape[1]).unsqueeze(0) < lengths.unsqueeze(1)
    labels = torch.tensor([item["label"] for item in batch], dtype=torch.long)
    ids = [item["id"] for item in batch]
    return {"tokens": tokens, "mask": mask, "lengths": lengths, "labels": labels, "ids": ids}

batch = collate(samples)
print(batch["tokens"].shape, batch["mask"].sum(dim=1).tolist())
assert batch["tokens"].shape == (3, 4)
assert batch["mask"].sum().item() == 9

这个 collate 同时返回 tokens、mask、lengths 和 IDs,避免模型或指标脚本重新猜测有效位置。IDs 不一定进入网络,但在错误分析、去重和恢复数据时很有价值。真实项目还应检查 padding_id 是否与词表约定一致、标签范围是否有效、空序列是否允许,以及 batch 内是否存在无法编码的样本。

数据质量检查要在训练前运行一次,也要在训练中抽样运行。检查缺失比例、重复 ID、标签分布、特征范围、文本长度分位数和非法值;对每个切分保存摘要。若数据来自文件系统,记录文件大小、修改时间或内容哈希;若来自查询,记录 SQL/过滤条件和抽取时间。这样当曲线改变时,能够先回答“数据是不是换了”。

验证和测试的读取顺序通常不需要 shuffle,方便结果与样本 ID 对齐。训练开启 shuffle 也不表示可以忽略 seed,因为 worker、增强和采样器都会产生随机性。复现目标要分层:先保证同一环境同一 seed 的近似一致,再评估不同硬件或版本下的可接受差异;浮点计算不应被要求逐位相同,报告容差和金样本即可。

最后区分“数据搬运失败”和“数据语义失败”。设备不一致会立即报错,语义错位却可能安静地训练:例如把标签列也拼进特征、把字符串类别按字典序编码、把标准化统计量用到了测试集。每次扩展 Dataset 都应保留一条原始样本到 batch 的可追踪路径,抽查模型收到的第一批值是否仍然符合任务定义。

梯度流量表:找到“哪里没有学到”

反向传播可以按层看成一股从 loss 返回输入的信号。若某层输出对 loss 没有影响,它的梯度为零或不存在;若某层把数值放大太多,梯度可能爆炸。把每个参数的梯度范数、参数范数和非有限计数写入日志,就能区分“没有路径”“路径被截断”和“数值不稳定”。只看最终 loss 往往已经太晚。

计算图是一次 forward 的产物,不能默认跨 iteration 复用。循环中把带图的 Tensor 追加到列表,会让旧图一直被引用,内存逐步增长;记录标量时用 loss.item(),记录特征时用 detach()。如果确实需要对同一图多次反向,要理解 retain_graph=True 的内存代价,并确认这不是因为训练循环写错。

冻结参数与切断输入梯度也有差别。设置 parameter.requires_grad_(False) 会让该参数不再计算梯度,但它仍参与 forward;对中间特征调用 detach() 则切断从该点往前的路径。做迁移学习时,先明确哪些层冻结、哪些层训练,再检查优化器是否只接收可训练参数。冻结了却仍把它们放进优化器,虽然通常没有更新,但会让配置和日志含义含混。

自定义操作的边界要更加谨慎。只要 forward 中出现 Python 标量、整数索引、条件分支或不可导的离散选择,就要问 backward 是否仍然有定义。可以用有限差分、极小输入和手算结果验证局部导数;如果只是为了日志或阈值判断,将这部分放到图外,而不是在训练路径中用硬编码“修梯度”。

表 5.4I · 梯度诊断信号
观察更可能的原因验证方式下一步
grad is None未参与图或参数冻结检查 requires_grad、路径补连接或明确冻结
梯度全为零饱和、ReLU 死亡或硬决策看激活分布与输入范围改尺度/激活/初始化
梯度突然变大学习率、长链或数值溢出记录范数和首个异常层裁剪并回查上游
每轮内存增长保存了带图 Tensor对照 detach/item 后运行清理引用与日志缓存
有限差分不符实现、dtype 或 epsilon 有问题float64 多尺度检查缩小到最小函数
图 5.4I · 反向传播的观测点自制示意:每层都能被验证
loss标量根节点
←
输出头logits 梯度
←
隐藏层范数/零值
←
输入尺度/是否断图

箭头表示梯度方向;每个观测点都保留对应的 shape、dtype 和模式。

当梯度检查通过后,再考虑性能优化,例如混合精度、梯度累积或编译。优化会改变数值路径和内存生命周期,应该以已通过的全精度金样本为基线。这样即使优化版本出现差异,也有一个可回退、可比较的参照。

07 · GENERALIZATION & REGULARIZATION

泛化、正则化与归一化:让模型学会而不是记住

网络可以把训练集记得非常好,却在新数据上失败。泛化问题需要同时看数据边界、模型容量、训练时长和随机性;正则化通过限制有效复杂度减少记忆,归一化通过稳定中间表示帮助优化。本节把过拟合曲线、Dropout、权重衰减、BatchNorm 和 LayerNorm 放在同一个判断框架中。

5.7.1 过拟合与早停:训练分数变好不等于模型变好

过拟合是模型在训练数据上的误差继续下降,但在验证数据上的误差开始上升。它可能来自模型容量太大、训练太久、数据重复、标签噪声或验证边界不独立。欠拟合则是训练和验证都表现不佳,可能因为模型太简单、训练步数不足、学习率不合适或输入没有包含有效信号。只看最终训练 loss 无法区分这些情况。

训练曲线应该至少同时画 train loss、valid loss 和一个与任务对应的指标。曲线中的拐点是证据,不是自动答案:验证 loss 上升可能是过拟合,也可能是验证标签延迟、数据分布变化或评估代码错误。早停使用验证集选择最佳检查点,patience 和最小改善阈值要固定;测试集不能被用来决定停在哪一轮。

泛化还依赖样本独立性。若同一用户、文档或模板跨训练和验证,曲线可能看起来很好;若未来时间段的标签尚未成熟,曲线可能看起来很差。把第 4 章的数据边界审计带回训练过程,按时间、群组、长度和类别分桶查看曲线,才能知道模型究竟在哪一类样本上学会或失败。

图 5.3 · 训练/验证曲线的三种信号自制示意:曲线是诊断入口
欠拟合
训练≈验证且都高
合适
验证先降后稳
过拟合
训练降、验证升

示意条不代表具体分数;保存每轮原始数据与样本支持数。

表 5.7A · 从曲线到动作
观察可能解释优先动作不要直接做
训练/验证都高欠拟合、特征弱、优化未开始查数据、学习率、容量只加正则
训练降、验证升过拟合或边界不独立早停、正则、重审切分看测试集挑轮次
两条曲线都抖batch 小、lr 大、标签噪声看梯度和多 seed只取最好一次
验证突然跳变数据版本/模式/指标问题核对样本与 eval立刻换模型
PYTHON · curve_diagnostics.py
def diagnose(history):
    if len(history) < 3:
        return "样本太少,先继续记录"
    train = [row["train_loss"] for row in history]
    valid = [row["valid_loss"] for row in history]
    if train[-1] > train[0] and valid[-1] > valid[0]:
        return "优化或数据可能有问题"
    if train[-1] < train[0] and valid[-1] > valid[min(len(valid) - 1, 1)]:
        return "检查过拟合、切分与标签"
    return "结合分桶指标与梯度继续判断"

history = [{"train_loss": 1.8, "valid_loss": 1.9},
           {"train_loss": 1.2, "valid_loss": 1.3},
           {"train_loss": 0.8, "valid_loss": 1.5}]
print(diagnose(history))

诊断函数只是把明显信号提醒出来,不能替代曲线和错误样例。记录时还要保存每轮学习率、训练/验证样本数、有效 token、主指标和随机种子;否则同样的曲线无法判断是训练条件变了还是数据变了。

容量、数据与训练时间是同一个旋钮的三面

增加层数或宽度提高了函数容量,增加训练轮数让模型有更多机会使用容量,减少数据则让每个参数看到的证据变少。三者共同决定记忆风险。一个小网络在一百条样本上可能过拟合,一个大网络在百万条多样样本上却能泛化;因此“模型大”不是单独的诊断。

控制复杂度时优先做可解释的对照:固定数据和训练预算,只改变宽度;固定结构和数据,只改变 epoch;固定模型和 epoch,只增加样本。把每次对照的曲线、最佳轮次、参数量和资源保存下来,才能区分“容量带来的收益”和“预算带来的收益”。

表 5.7B · 泛化诊断的最小证据
证据回答的问题最低保存项
曲线何时开始记忆训练集每轮 train/valid loss
分桶哪类样本先退化时间、群组、长度指标
多 seed是否偶然均值、标准差、seed
切分摘要边界是否独立实体交集、时间范围
错误样例退化是什么样子脱敏输入与预测
REGULAR LAB 5.7.1读一张训练曲线

假设训练 loss 从 1.8 降到 0.4,验证 loss 先降到 0.9 后升到 1.5。列出三种可能原因,并分别指定一个能区分它们的检查。

可能是过拟合、验证集边界污染或标签/指标错误。可检查最佳轮次与早停、多次切分和实体交集、验证样本与指标实现;不能只看最后一轮或立即扩大模型。

5.7.2 Dropout、权重衰减与数据增强:限制记忆的不同方式

Dropout 在训练时随机把一部分激活置零并进行尺度校正,让网络不能依赖某几个固定单元;评估时关闭随机失活,使用完整网络。它是一种随机正则化,强度由概率控制。Dropout 太小可能效果不明显,太大则让优化困难;它是否有用取决于模型、数据量和其他正则化。

权重衰减倾向于限制参数规模,数据增强则通过改变输入表面形式增加训练分布的多样性。图像可以裁剪、翻转或加噪声,文本可以做保守的遮盖、同义替换或 token dropout,但增强必须保持标签语义。把会改变标签的变换当作“增强”会制造噪声,训练越久越糟。

正则化要在验证集上比较,并观察训练/验证差距、主指标、校准、资源和错误结构。Dropout、weight decay、label smoothing、增强和早停可以同时存在,但不能把所有收益归因给其中一个。每次只改变一个主要正则化变量,保留无正则基线和失败结果。

表 5.7C · 正则化手段的作用位置
手段作用位置适合观察主要风险
Dropout激活/表示训练/验证差距、稳定性推理忘记 eval
Weight decay参数更新参数范数、验证指标衰减不该衰减的参数
Data augmentation输入样本鲁棒性、标签一致性增强改变标签
Label smoothing目标分布置信度、校准少数类边界变模糊
Early stopping训练时长最佳轮次、曲线验证集反复选择
PYTHON · dropout_modes.py
import torch
from torch import nn

torch.manual_seed(3)
model = nn.Sequential(nn.Linear(4, 4), nn.Dropout(p=0.5))
x = torch.ones(2, 4)

model.train()
train_a = model(x)
train_b = model(x)
print("train equal:", torch.equal(train_a, train_b))

model.eval()
eval_a = model(x)
eval_b = model(x)
print("eval equal:", torch.equal(eval_a, eval_b))
assert torch.equal(eval_a, eval_b)

示例说明模式切换是正则化的一部分。训练两次可能不同是预期行为,评估两次应一致(在没有其他随机操作时)。如果把 dropout 模块写成普通函数而没有遵守 train/eval,推理结果可能继续随机;如果在训练后保存了处于 train 模式的模型,加载脚本仍要显式调用 eval。

正则化的目标是降低错误,不是让曲线更好看

当加入 Dropout 后训练 loss 变高、验证 loss 变低,通常说明模型牺牲了一部分记忆能力换来更好的泛化;如果训练和验证都变差,可能正则太强、学习率未重新适配或增强破坏标签。评估时保留无正则、单项和组合对照,不要只展示最终冠军。

正则化也可能改变不同群组的表现。强增强对短文本、少数类或边界图像的影响不一定相同,整体平均提升可能掩盖某个群组退化。按群组和输入难度报告指标,必要时为敏感样本设置护栏或回退路径。

表 5.7D · 正则化对照实验
版本改变主指标护栏
A 基线无额外正则验证 F1/MAE参数范数、训练时间
B仅 dropout同一切分推理一致性
C仅 weight decay同一 epoch分组指标
D增强或 smoothing真实分布标签一致性
E组合+早停冻结规则资源与回滚
REGULAR LAB 5.7.2设计一个正则化消融

为一个小型分类器安排 A/B/C 三个版本,分别只改变 Dropout、weight decay 和数据增强。写出必须固定的条件与要记录的指标。

固定数据快照、切分、结构、epoch 上限、seed 组和主指标口径;每次只改变一项。记录 train/valid 曲线、F1/PR-AUC、分组指标、推理一致性、参数范数、耗时和错误样例。

5.7.3 BatchNorm 与 LayerNorm:归一化如何改变训练接口

归一化层把中间激活按某些维度重新缩放,通常还带可学习的 scale 和 bias。BatchNorm 使用当前 batch 的均值方差进行训练,并维护运行统计量供评估使用;batch 太小、分布变化或模式切换错误时,运行统计可能不可靠。LayerNorm 通常在每个样本的特征维上归一化,不依赖 batch 中其他样本,因此更适合变长序列和 Transformer 风格的表示。

“归一化”不是统一操作。要写明沿哪些轴求均值方差、是否有 affine 参数、训练时是否更新 running stats、评估时使用哪份统计。对 (B,T,D) 序列,LayerNorm 通常对最后的 D 维工作;把 normalized_shape 写错,可能得到形状正确但语义错误的结果。

归一化会改变优化轨迹,也会影响权重衰减参数分组。BatchNorm 的运行均值和方差是 buffer,不是需要梯度的参数;保存 state_dict 时要包含它们,恢复后用 eval 才能得到一致推理。监控激活均值、标准差、非有限计数和训练/评估差异,能早发现归一化问题。

表 5.7E · BatchNorm 与 LayerNorm
方面BatchNormLayerNorm调试重点
统计轴通常跨 batch/空间维通常在每样本特征维确认 normalized shape
训练行为更新运行统计按当前样本计算train/eval 是否切换
小 batch统计可能噪声大相对不依赖 batch比较 batch size
state_dict含 running mean/var含可学习参数检查 buffers/keys
常见场景图像/表格网络序列/Transformer轴与语义一致
PYTHON · normalization_modes.py
import torch
from torch import nn

torch.manual_seed(5)
batch = torch.randn(2, 3, 4)
batch_norm = nn.BatchNorm1d(4)
layer_norm = nn.LayerNorm(4)

batch_norm.train()
train_out = batch_norm(batch.reshape(-1, 4)).reshape(2, 3, 4)
batch_norm.eval()
eval_out = batch_norm(batch.reshape(-1, 4)).reshape(2, 3, 4)
layer_out = layer_norm(batch)
print(train_out.shape, eval_out.shape, layer_out.shape)
print("running mean:", batch_norm.running_mean)
assert layer_out.shape == batch.shape

这里为了匹配 BatchNorm1d 的特征接口,把序列和 batch 轴暂时合并;真实网络应确认这样做是否符合统计假设。LayerNorm 直接作用于最后一维。不要只看输出形状判断正确,还要检查统计轴和训练/评估时输出差异。

归一化与数据分布变化

训练集的均值方差、BatchNorm 的 running stats 和线上输入分布可能不一致。若线上 batch 很小或分布漂移,BatchNorm 的表现可能下降;可以评估冻结统计、改用 LayerNorm、重新校准或增加监控。任何修复都要在时间切分和新群组上验证,不能用同一验证集反复挑选。

归一化也会影响解释。归一化前的权重与原始单位有关,归一化后要结合 scale、bias 和前处理参数才能讨论特征影响。保存模型时把预处理和归一化层放在同一版本,推理端不能只加载网络而漏掉输入标准化。

表 5.7F · 归一化失败信号
信号可能原因先做的检查
train/eval 差异巨大模式或 running stats 错同一 batch 双模式对照
小 batch 指标抖动BatchNorm 统计噪声比较 batch size/LayerNorm
激活全零/爆炸轴、初始化或学习率均值、标准差、梯度
加载后变差buffer/预处理未保存state_dict keys 和金样本
线上退化分布漂移或尺度不一致输入摘要与 running stats
REGULAR LAB 5.7.3选择归一化层

分别为图像小批次、只有 2 个样本的表格 batch、长度不同的文本序列选择 BatchNorm 或 LayerNorm,并写出理由与必须验证的轴。

图像常见 BatchNorm,但要验证通道/空间轴;极小 batch 的表格任务需谨慎使用 BatchNorm,可比较 LayerNorm;变长文本通常按最后特征维使用 LayerNorm。选择不是定律,需用相同切分、模式和指标做对照。
表 5.7G · 本节完成标准
能力最低证据进入下一节的连接
读曲线能区分欠拟合、过拟合和边界异常安排优化与数据实验
做正则能设计单项消融并保留基线控制卷积/序列模型复杂度
切模式能解释 Dropout/Norm 的 train/eval正确保存与推理
守统计能说清归一化轴、参数和 buffer进入端到端项目

用曲线而不是感觉判断泛化

训练曲线至少要同时画训练损失、验证损失和一个与任务相关的主指标。训练损失下降、验证损失先降后升,是典型的过拟合信号;两者都高且几乎不动,可能是欠拟合、学习率或数据接口问题;两者都很低但测试突然变差,要优先怀疑切分泄漏、分布变化或指标实现。曲线的横轴应注明 step、epoch 或已看过的样本/token 数,否则不同 batch size 的图不能公平比较。

正则化不是越多越好。Dropout、权重衰减、数据增强和减小网络容量都可能降低训练集表现;只有验证或未来数据改善时,才说明它们限制的复杂度有价值。一次只改变一项,并保留无正则基线,才能知道收益来自哪一个机制。对噪声标签,强正则可能把可学信号也抹掉,应先检查标签质量和样本覆盖。

数据增强必须符合任务不变性。图像水平翻转只有在左右方向不改变标签时才合理;文本替换要保持语义和标签,时间序列不能用未来窗口制造“增强”。增强应只作用于训练集,验证和测试保持原始分布;如果增强依赖全局统计,统计量也只能从训练数据估计。把不成立的先验强行加进去,会让模型学到错误规律。

BatchNorm 与 LayerNorm 的差异不只是 API 名称。BatchNorm 维护运行均值和方差,训练和评估看到的统计来源不同;小 batch、跨设备同步和分布漂移都会影响它。LayerNorm 通常在单个样本的特征维上归一化,不依赖同批其他样本,更适合变长序列和自回归场景。无论选哪一种,都要写出归一化轴、可学习参数、buffer 以及 train/eval 行为。

表 5.7H · 泛化问题的证据与动作
曲线/现象可能解释先收集的证据合理动作
train 降、valid 升过拟合或验证泄漏反向容量、切分、重复 ID早停、正则、重做切分
train/valid 都高欠拟合或接口错误小集过拟合能力、梯度先修数据/损失,再增容量
valid 抖动大样本少、随机增强或 batch 统计多 seed、样本数、eval 状态固定评估与报告区间
测试突然变差分布漂移或阈值过拟合时间/群组切分、错误桶补数据、校准、设护栏
加正则后全都变差信号被抑制或强度过大训练损失、激活、梯度单变量调强度
PYTHON · curve_report.py
from dataclasses import dataclass, asdict
import json

@dataclass
class EpochRecord:
    epoch: int
    samples_seen: int
    train_loss: float
    valid_loss: float
    valid_metric: float
    learning_rate: float
    mode: str

history = [
    EpochRecord(1, 128, 0.91, 0.98, 0.52, 1e-3, "eval"),
    EpochRecord(2, 256, 0.63, 0.71, 0.68, 1e-3, "eval"),
    EpochRecord(3, 384, 0.41, 0.79, 0.64, 5e-4, "eval"),
]
best = min(history, key=lambda row: row.valid_loss)
with open("curve.json", "w", encoding="utf-8") as handle:
    json.dump([asdict(row) for row in history], handle, ensure_ascii=False, indent=2)
print("best epoch:", best.epoch, "valid loss:", best.valid_loss)

把曲线存成结构化记录后,才能重新绘图、计算最佳 epoch 和比较多次 seed。除了均值,还可以报告标准差、四分位数或最差运行;当样本量小、验证曲线起伏大时,单次最高点很容易是偶然。最佳检查点应与记录中的 epoch、配置哈希和数据版本绑定,避免“文件名叫 best”却不知道依据是什么。

归一化层的调试可以用同一个 batch 分别运行 train 与 eval,再比较输出均值、方差和差异。若切换模式后差异远超预期,检查是否忘记 model.eval()、是否在评估阶段仍做随机增强、是否加载了正确的运行统计。Dropout 则应在 eval 中关闭;如果评估结果仍然随机,说明模式边界没有真正传到子模块。

当训练集很小,先做“能否过拟合十个样本”的测试,再做泛化。小集过拟合失败通常指向模型、损失、标签或优化流程,继续加正则只会让问题更难发现。小集过拟合成功后,再逐步扩大数据和加入增强,记录每一步对训练/验证曲线的影响。这个顺序把“能学会”与“能泛化”拆成两个可验证问题。

正则化的选择顺序:先修数据,再限制模型

看到过拟合时,第一问不是“Dropout 设多少”,而是训练与验证是否来自相同的可用分布。重复用户、同一文档切片、未来字段和预处理统计泄漏,都会制造虚假的训练优势;此时加正则只会把问题藏得更深。先重做切分、检查 ID 交集、确认预处理只在训练集拟合,再讨论模型容量。

第二问是模型是否真的需要更多容量。让一个很小的模型在十个样本上过拟合,是检查损失、标签和优化流程的快速方法;如果连这个都做不到,增加正则或减少宽度会让诊断更难。小集测试通过后,再逐步增加数据、深度和正则,并记录训练/验证曲线的拐点。

第三问是正则化是否符合结构。图像增强应保持类别不变,文本遮盖不应破坏关键实体,时间序列不能使用未来信息;Dropout 放在不同位置会产生不同的随机路径,权重衰减则直接约束参数更新。把强度、放置位置和随机种子写入配置,避免“开启了增强”却无法知道具体做了什么。

归一化层还会影响正则化的解释。BatchNorm 的运行统计来自训练过程,和 weight decay、batch size、分布漂移相互作用;LayerNorm 在样本内部工作,通常不会因为 batch 改变而改变统计。若要比较两者,固定模型宽度、学习率和训练预算,分别检查激活均值、方差、验证曲线和小 batch 行为。

表 5.7I · 正则化决策树
先问什么证据若答案为“是”若答案为“否”
切分独立吗ID/时间/群组交集修复数据边界继续看容量
小集能过拟合吗十个样本的训练 loss继续做泛化实验修模型、标签或 loss
增强保持标签吗人工抽样与不变性说明比较增强强度删除或改写增强
模式切换正确吗Dropout/Norm 输出对照再调正则强度修 train/eval 边界
多 seed 稳定吗均值、标准差、最差值报告泛化区间增加数据或降复杂度

早停也要有清楚的统计口径。若验证指标噪声很大,patience 太小会过早停止,太大则浪费预算;可以记录连续未改善周期、最佳 epoch 和每次改善的幅度。最佳 checkpoint 只代表当前验证规则下的最优,不代表未来数据必然最好。把早停参数与主指标方向写进实验账本,才能复盘为什么训练在那一轮结束。

正则化最终要服务于错误类型。如果过拟合集中在少数长尾类别,补充数据或分层采样可能比全局 Dropout 更有效;如果错误集中在边界噪声,校准阈值和人工复核可能更重要。先用错误样例提出假设,再选择一个正则化变量,保持模型改动可解释。

正则化强度要用曲线校准

把 Dropout 概率、weight decay 或增强强度视为连续旋钮,而不是开关。对每个候选值记录训练 loss、验证 loss、主指标、参数范数和训练时间;如果强度增加后训练与验证同时变差,说明信号被过度抑制;如果训练继续变好而验证不再改善,说明仍可能需要更强限制或更早停止。

小数据集上的验证曲线很容易受随机切分影响。至少使用多个 seed 或多个分层切分,报告均值与最差结果;对时间或群组数据则按业务边界切分,不能为了降低方差而泄漏未来。正则化的收益必须在可接受的分布变化下成立。

归一化层与正则化组合时,记录层的位置和顺序。Linear→Norm→Activation 与 Linear→Activation→Norm 的输出分布不同,Dropout 放在 Norm 前后也可能改变统计。不要只在配置里写一个 use_norm=True,而要写具体类型、归一化轴和 train/eval 行为。

表 5.7J · 正则化实验记录
变量候选值固定项主要观察
dropout0、0.1、0.3结构、seed、预算train-valid gap
weight decay0、1e-4、1e-3损失、学习率参数范数与验证曲线
增强强度轻/中/强原始验证集标签不变性与错误桶
归一化无/Batch/Layer宽度、优化器小 batch 稳定性

完成一轮实验后,把最佳值、选择依据和未验证的范围写进模型卡。这样“用了正则化”才不是一句口号,而是一条可以回放、可以质疑、也可以继续改进的证据。

08 · SPATIAL & SEQUENTIAL MODELS

卷积与序列模型桥梁:用结构先验减少无谓的连接

全连接层把每个输入位置都视为同等重要;卷积利用局部邻域和权重共享,序列模块则利用顺序、位置和长度。理解这些结构先验,才能知道为什么图像常用 Conv2d、文本先用 Embedding,RNN 为什么沿时间传递状态,以及 Transformer 后续为何改用位置表示和注意力。本节建立从循环序列模型到 Transformer 的完整入口,不把模型排行榜当作结论。

5.8.1 卷积与池化:局部窗口如何扫描空间

二维卷积可以理解为一个小核在图像上滑动,每次取一个局部窗口做加权和,得到一个特征图。卷积核在不同位置共享同一组权重,因此参数量与图像宽高关系较小;它假设相邻像素之间有局部结构,并且相同模式可能出现在不同位置。输入常见形状为 (B,C,H,W),输出通道数由卷积核数量决定。

stride 决定窗口移动步长,padding 决定边缘是否补值,kernel_size 决定局部感受野。输出空间尺寸可用公式计算:floor((H+2P−D(K−1)−1)/S+1),宽度同理。初学时先用无膨胀的 K=3、S=1、P=1,输出高度保持不变;把公式和代码的 shape 打印出来,比凭感觉堆卷积更可靠。

池化或带 stride 的卷积可以降低空间分辨率、扩大后续层的有效感受野。MaxPool 选择局部最大值,平均池化取均值;它们没有或只有很少可学习参数,却会丢失精细位置。若任务需要像素级定位,过早下采样可能造成不可恢复的细节损失,必须把分辨率、精度和计算成本一起评估。

图 5.4 · 卷积块的形状流自制示意:通道变化与空间下采样
(B,3,32,32)RGB 输入
→ Conv3×3 →
(B,16,32,32)局部特征
→ Pool2×2 →
(B,16,16,16)分辨率减半
表 5.8A · 卷积参数与形状
参数作用改变什么检查问题
in_channels输入通道数权重第一维是否与数据 C 一致
out_channels卷积核数量输出通道 C后续层是否接得上
kernel_size局部窗口大小感受野/参数量边缘和细节损失
stride滑动步长空间分辨率输出尺寸公式
padding边缘补值是否保持尺寸边界伪影
pooling无参数下采样压缩 H/W定位精度下降
PYTHON · conv_shape.py
import torch
from torch import nn

block = nn.Sequential(
    nn.Conv2d(in_channels=3, out_channels=16, kernel_size=3, padding=1),
    nn.ReLU(),
    nn.MaxPool2d(kernel_size=2),
)
images = torch.randn(4, 3, 32, 32)
features = block(images)
print("input:", images.shape, "output:", features.shape)
assert features.shape == (4, 16, 16, 16)
params = sum(parameter.numel() for parameter in block.parameters())
print("trainable parameters:", params)

卷积核参数量为 out_channels×(in_channels×kernel_size²+1)(含 bias)。示例的卷积层只有 16×(3×9+1)=448 个参数,与 32×32 的像素数量无关;这就是权重共享带来的效率。它也带来归纳偏置:如果数据的有用关系不是局部平移结构,卷积未必合适。

Flatten 是卷积到分类头的接口

卷积块输出四维 Tensor,Linear 通常需要最后一维是特征。torch.flatten(features, start_dim=1) 会保留 batch 轴,把通道和空间轴合并;展平后的特征数取决于输入尺寸、padding、stride 和池化次数。不要手写一个“看起来差不多”的 in_features,先用小 batch 前向打印真实形状,或使用 AdaptiveAvgPool 把空间尺寸固定。

池化和卷积的下采样会影响样本的边界行为。训练增强改变图像尺寸时,分类头仍需接收固定形状;若使用自适应池化,记录输出空间尺寸和是否丢失定位信息。卷积网络的特征图也可能很大,显存通常由激活而不是参数主导,batch size 要按峰值激活估算。

表 5.8B · 卷积网络的形状验收
位置期待失败时先查
输入(B,C,H,W)通道轴是否在第 2 位
卷积后通道变为 out_channels层参数和数据 C
池化后H/W 按公式变化stride、padding、ceil_mode
展平保留 B,合并其余轴start_dim 是否为 1
分类头(B,C_classes)in_features 与实际一致
SPATIAL LAB 5.8.1计算一段卷积的输出尺寸

输入为 (8,3,64,64),先用 Conv2d(3, 12, kernel=5, stride=2, padding=2),再用 MaxPool2d(2)。写出每层形状,并计算卷积层参数量。

卷积后空间尺寸为 32×32,形状 (8,12,32,32);池化后为 (8,12,16,16)。参数量含 bias 是 12×(3×5×5+1)=912。应让代码打印形状核对公式。

5.8.2 Embedding 与序列:顺序、长度和 mask 要一起传递

Embedding 把离散 token id 查成连续向量。输入是整数 Tensor,常见形状 (B,T);输出是 (B,T,D),其中 D 是嵌入维度。id 没有大小关系,不能把 token 42 当作比 token 7 “更大”的数值特征。Embedding 表的行是可学习参数,训练目标会让有用的 token 组合在表示空间中形成结构。

序列模型要知道顺序和有效长度。RNN/GRU/LSTM 通过逐步更新隐藏状态表达历史;卷积序列模型用局部窗口;Transformer 通过注意力在位置之间建立条件连接。无论选择哪一种,padding 位置都不是自然语言内容,必须用 mask、pack 或 ignore_index 防止它们参与状态和损失。

自回归任务常把同一序列错开一位:输入看到前面的 token,目标是下一个 token。分类任务可能只取最后一个有效位置或使用池化;序列标注则保留每个位置的输出。代码应明确输出是 (B,T,D) 还是 (B,D),明确最后一个位置是固定索引还是按 mask 找到的有效末端。

表 5.8C · 序列模块的接口
对象形状职责常见错误
token ids(B,T) long离散输入转成 float 或超出词表
embedding(B,T,D)可学习表示把 id 当连续值
attention mask(B,T) 或更高维标记可见/有效位置True/False 方向反
sequence output(B,T,H)每位置隐藏状态误取 padding 位置
pooled output(B,H)整段摘要长度不同却固定取最后一列
PYTHON · embedding_sequence.py
import torch
from torch import nn

vocab_size, embedding_dim = 100, 8
embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=0)
token_ids = torch.tensor([[4, 9, 2, 0], [7, 3, 0, 0]], dtype=torch.long)
hidden = embedding(token_ids)
mask = token_ids.ne(0)
lengths = mask.sum(dim=1)
pooled = (hidden * mask.unsqueeze(-1)).sum(dim=1) / lengths.clamp_min(1).unsqueeze(-1)
print(hidden.shape, mask.shape, lengths.tolist(), pooled.shape)
assert hidden.shape == (2, 4, 8) and pooled.shape == (2, 8)

示例用 mask 做有效位置平均池化,避免 padding embedding 贡献到摘要。即使设置了 padding_idx,也不要假设所有后续层都会自动忽略 padding;乘法、归一化和损失仍需显式处理。长度为零的样本用 clamp_min 防止除零,但业务上是否允许空序列要在数据契约中决定。

序列模型的记忆与计算成本

RNN 按时间步递归,长序列难以并行且可能遇到梯度消失或爆炸;卷积序列模型并行性较好但一次层只能看到有限窗口;自注意力可以直接建立任意位置的连接,却通常让序列长度带来近似二次的分数矩阵。选择结构要结合长度、延迟、上下文范围、显存和数据量,而不是只看某个任务的最高分。

位置编码是序列模型的另一个接口。Embedding 本身只知道 token,不知道它在第几个位置;位置表示可以是固定函数、可学习向量或相对偏置。更换位置方案会改变长短序列和外推行为,必须在长度分桶和新时间窗口上评估。对中文、代码和数字混合序列,分词策略同样决定序列长度与有效信息。

表 5.8D · 序列诊断清单
检查通过条件风险
词表id 在 [0,vocab_size)越界或特殊符号冲突
长度mask 与真实 token 一致padding 进入表示
移位输入/目标偏移符合任务复制当前 token
位置方案、最大长度有版本长序列退化
池化只汇总有效位置取到 padding/空样本
SEQUENCE LAB 5.8.2比较最后位置与 mask 池化

对两个不同长度的 token 序列,分别用固定最后一列和有效位置平均池化得到句向量。说明固定索引为什么会把 padding 当内容,并写出空序列的处理策略。

不同长度时最后一列可能是 padding,固定索引会混入无效 embedding;mask 池化只对有效 token 求平均。空序列可以拒绝、返回零向量并打标记,或交给特殊 token,必须写进数据契约。

从 RNN、LSTM 和 Seq2Seq 走到 Transformer:注意力解决了哪一步

Embedding 只把 token 变成向量,本身不知道前后顺序,也不会让相隔很远的位置交换信息。早期序列模型常使用循环神经网络(Recurrent Neural Network,RNN):它从左到右读取序列,把当前 token 向量与上一步隐藏状态合成新的隐藏状态。第 t 步可以写成 h_t=f(W_x x_t+W_h h_{t-1}+b)。这个状态像一张不断改写的便签,后面的 token 可以间接携带前面的信息。

普通 RNN 的问题不只是“旧信息会忘记”。训练时,梯度也要沿时间步逐步传回;序列很长时,多次相乘可能让梯度趋近于零或迅速变大,形成梯度消失和梯度爆炸。长短期记忆网络(Long Short-Term Memory,LSTM)增加输入门、遗忘门和输出门,门控循环单元(Gated Recurrent Unit,GRU)用更少的门控制保留与更新。门不是人工规则,而是由参数学习的 0 到 1 权重;它们缓解长程记忆问题,却仍然保留按时间步串行计算的路径。

编码器—解码器(Encoder–Decoder)把输入序列编码成状态,再由解码器逐步生成输出,形成 Seq2Seq 框架。如果只把整句压成最后一个固定长度向量,长句信息会拥挤在一个瓶颈中。注意力机制让解码器在生成每个位置时重新查看所有编码器状态:当前查询决定更关注哪些输入位置,再把相应值加权汇总。注意力最初是对 Seq2Seq 固定向量瓶颈的改进,后来 Transformer 进一步把注意力变成主要的信息混合方式。

Transformer 不再依靠一个隐藏状态沿序列传递全部信息,而是让每个位置通过自注意力直接读取允许看到的其他位置。训练时,同一层的所有位置通常可以并行计算;代价是标准注意力要构造位置两两关系,长度增加时计算和显存增长很快。它仍然需要位置表示说明顺序,需要 mask 区分 padding 和未来位置,也仍然需要前馈层、残差连接与归一化。理解这条演化线的目的不是宣布 RNN 已经“过时”,而是知道每个结构在交换什么:递归用顺序状态换取线性流式计算,注意力用显式位置关系换取更短的信息路径和更强并行性。

表 5.8J · 从循环序列模型到 Transformer 的职责变化
结构信息怎样流动主要优点主要限制
RNN隐藏状态逐步传递结构简单,适合流式序列长程梯度和串行路径
LSTM / GRU门控状态决定保留与遗忘比普通 RNN 更能保留长期信息仍需按时间步递归
Seq2Seq编码器状态交给逐步解码器输入输出长度可以不同单一最终状态形成信息瓶颈
Seq2Seq + Attention每个输出位置重新加权输入状态减少固定向量瓶颈,可观察对齐编码器和解码器通常仍含递归
Transformer位置之间直接做自注意力训练并行、长程信息路径短标准注意力随序列长度平方增长
PYTHON · recurrent_vs_attention.py
from math import exp, sqrt, tanh


def recurrent_states(tokens, memory=0.65):
    """用同维度状态演示按顺序递归;这不是完整可训练 RNN。"""
    state = [0.0] * len(tokens[0])
    states = []
    for token in tokens:
        state = [tanh(value + memory * old)
                 for value, old in zip(token, state)]
        states.append(state)
    return states


def dot(left, right):
    return sum(a * b for a, b in zip(left, right))


def attention(query, keys, values):
    scale = sqrt(len(query))
    scores = [dot(query, key) / scale for key in keys]
    peak = max(scores)
    weights = [exp(score - peak) for score in scores]
    total = sum(weights)
    weights = [weight / total for weight in weights]
    context = [sum(weight * value[index]
                   for weight, value in zip(weights, values))
               for index in range(len(values[0]))]
    return weights, context


tokens = [[1.0, 0.0], [0.2, 0.8], [0.0, 1.0]]
states = recurrent_states(tokens)
weights, context = attention([1.0, 0.0], tokens, tokens)
print("RNN final state:", [round(value, 3) for value in states[-1]])
print("attention weights:", [round(value, 3) for value in weights])
print("attention context:", [round(value, 3) for value in context])
assert len(states) == len(tokens)
assert abs(sum(weights) - 1.0) < 1e-12

这段程序只暴露两种信息路径,不尝试替代框架实现。递归函数必须按顺序更新状态,最后状态间接包含前文;注意力函数先计算 query 与所有 key 的分数,再一次汇总所有 value。真正的 RNN 会学习输入和状态矩阵,LSTM 会维护门和单元状态,Transformer 还会进行多头投影、残差、归一化和前馈变换。运行玩具例子的验收重点是能指出每个列表的形状、为什么权重和为 1,以及改变第一个 token 时两种表示如何变化。

图 5.8J · 信息路径从链式传递变成直接读取自制示意:箭头长度代表依赖路径,不代表模型优劣
RNNx₁ → h₁ → h₂ → h₃
→
LSTM / GRU门控保留状态
→
Seq2Seq + Attention解码时回看输入
→
Transformer位置直接交互

注意力缩短了远距离位置之间的信息路径,并允许训练时并行处理位置;位置编码和 mask 继续负责顺序与可见范围。

可运行实验包:下载 大模型基础衔接实验包,运行 labs/sequence_bridge.py。实验会分别打印递归状态、注意力权重和因果 mask 的结果,并用断言检查权重与形状。
SEQUENCE BRIDGE LAB 5.8.2用同一条依赖比较两种信息路径

构造一句至少 8 个 token 的序列,让最后一个 token 的判断依赖第一个 token。先画出普通 RNN 中信息必须经过的状态数量,再画出自注意力中最后位置读取第一个位置的路径。修改示例代码,使 query 更偏向第一个 token,并记录注意力权重变化。

答案要区分“依赖路径更短”和“模型一定更准确”:自注意力允许直接连接,但是否学会正确权重仍取决于数据和训练。RNN 的最后状态经过多个时间步,LSTM/GRU 用门缓解遗忘却没有消除串行路径。代码验收包括权重和约为 1、输入长度改变后输出形状仍正确、空序列被明确拒绝。

5.8.3 选择结构:先问数据的局部性、顺序与不变性

模型结构是一种先验。MLP 假设每个特征的组合关系由全连接权重学习,适合规模适中的表格输入;CNN 假设局部邻域和位置平移有意义,适合图像和部分网格/音频;RNN/GRU 假设按顺序递归积累状态;Transformer 用注意力让位置之间动态选择上下文。没有结构能自动适应所有问题,选择应由数据生成机制和部署约束解释。

比较模型时必须保持任务边界、输入预处理、切分、指标和预算一致。给 CNN 更高分辨率、给 Transformer 更长上下文、给某个模型更多调参次数,都会让比较失去公平。报告参数量、激活峰值、训练时间、推理延迟、上下文长度、错误分桶和多次种子结果;如果模型只在训练集或随机切分上胜出,不足以证明结构更好。

混合结构通常来自接口需要:CNN 先提取局部视觉特征,MLP 做分类头;Embedding 后接 Transformer 处理长程依赖;序列模型输出再接一个小的任务 head。每增加一个模块,就增加形状、模式、损失和保存的边界。先用最小可行结构跑通,再一次增加一个有理由的模块。

表 5.8E · 结构选择的决策表
数据/目标可先试结构先验要验证
小型表格MLP/线性基线特征组合切分、缺失、参数量
图像分类CNN局部与平移分辨率、增强、定位损失
短序列MLP+池化/小 RNN顺序与长度mask、移位、延迟
长文本Embedding+Attention远距离上下文上下文成本、位置外推
实时设备轻量 CNN/MLP有限资源P99 延迟、峰值内存
PYTHON · model_interface_probe.py
import torch
from torch import nn

class ImageHead(nn.Module):
    def __init__(self, classes=3):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(),
            nn.AdaptiveAvgPool2d((1, 1)),
        )
        self.classifier = nn.Linear(8, classes)

    def forward(self, images):
        if images.ndim != 4 or images.shape[1] != 1:
            raise ValueError(f"期望 (B,1,H,W),实际 {tuple(images.shape)}")
        features = self.features(images).flatten(1)
        return self.classifier(features)

model = ImageHead()
logits = model(torch.randn(5, 1, 28, 28))
print(logits.shape)
assert logits.shape == (5, 3)

AdaptiveAvgPool 把任意空间尺寸压成 1×1,减少分类头对输入大小的耦合;代价是丢失精细位置信息。接口探针只验证形状,不证明模型学到了有用模式,仍需接入训练、验证和错误分析。对序列模型也可以写类似探针,分别测试单 token、短序列、最大长度、空 padding 和超长截断。

结构选择的最低理由

写模型方案时至少回答四句:输入的局部性或顺序是什么?需要多远的上下文?允许多少参数、显存和延迟?失败时是漏掉局部细节、忘记长程关系,还是被数据边界污染?如果四句都说不清,先用线性/MLP 基线收集证据。

结构并不是一次性决定。可以先用小型 MLP 验证标签和指标,再把同一数据接口接到 CNN 或 Attention,比较新增结构是否改善指定错误桶。保持 head、损失、切分和报告格式一致,模型差异才有可解释性。

表 5.8F · 本节完成标准
能力最低证据进入下一章的连接
读卷积能计算输出尺寸与参数量搭建 PyTorch CNN
读序列能解释 Embedding、长度和 mask理解 Transformer 输入
选结构能用数据先验和资源说明理由设计端到端项目
守接口能写 shape probe 与边界测试减少训练前返工
SPATIAL LAB 5.8.3为同一任务提出两种结构

选择“图像分类”或“文本主题分类”,提出一个简单结构和一个带结构先验的结构。固定数据与指标,列出公平比较所需的五项记录。

图像可比较 MLP flatten 与 CNN,文本可比较词袋/MLP 与 Embedding+Attention。记录数据快照、预处理/tokenizer、切分、参数与激活、训练预算、延迟、主/护栏指标、错误桶和多 seed;不能只报最高准确率。
表 5.8G · 空间与序列的共同纪律
纪律卷积体现序列体现下一章要复用
形状契约(B,C,H,W)(B,T,D)每层打印与断言
有效范围边缘/paddingpadding/mask损失有效分母
资源预算激活显存、分辨率上下文、注意力矩阵吞吐与 P99
公平比较同一增强与切分同一 tokenizer 与长度实验清单与回滚

卷积的三个数字:窗口、步幅和感受野

二维卷积输出空间尺寸可以用 floor((H+2P−K)/S)+1 计算,其中 H 是输入高度,K 是核大小,P 是 padding,S 是 stride。先用公式算一遍,再对照实际 Tensor shape,能发现“少了一圈像素”或“尺寸突然减半”的原因。padding 保留边缘上下文,stride 大于 1 会下采样;二者都改变后续层看到的信息范围和计算量。

卷积核的参数量主要由输入通道、输出通道和核大小决定:Cin×Cout×K×K+Cout(含偏置)。它与图像的宽高无关,因为同一组核在不同位置共享。共享带来平移等结构先验,也限制了模型表达:如果任务需要全局关系,单纯扩大局部核可能不如堆叠层、池化或注意力。参数量小不等于计算量小,输出特征图和中间激活也要计入显存。

感受野会随着层数、核大小和步幅逐步扩大。两个 3×3 卷积叠起来的有效覆盖范围大于一个 3×3,但中间加入非线性,表达能力也不同。池化或 stride 会降低空间分辨率、增加单位像素对应的原图范围,却可能丢失细节。设计网络时同时画出每层 shape 与感受野,不要只看参数表。

Embedding 是另一种“查表”结构。token id 是离散索引,Embedding 输出连续向量;序列长度 T 可以变化,但 padding、位置编码和 mask 要贯穿整个模型。仅有词向量不能表达顺序,“我喜欢你”和“你喜欢我”会使用同一组向量,只是排列不同;后续需要位置表示、卷积、递归或注意力来利用顺序关系。任何序列模型都应明确 padding side、最大长度和截断策略。

表 5.8H · 空间/序列结构的计算契约
对象输入输出必须固定的约定
Conv2d(B,Cin,H,W)(B,Cout,Hout,Wout)通道顺序、K/P/S
池化特征图更小空间尺寸窗口、stride、边缘策略
Embedding(B,T) long id(B,T,D)词表、padding id、未知词
位置表示长度/位置索引与 D 对齐的向量最大长度与偏移规则
序列头(B,T,D)逐 token 或汇聚输出mask、pooling、目标移位
PYTHON · receptive_field.py
def conv_output(size, kernel, padding=0, stride=1):
    numerator = size + 2 * padding - kernel
    return numerator // stride + 1

def update_receptive_field(field, jump, kernel, stride=1):
    new_field = field + (kernel - 1) * jump
    new_jump = jump * stride
    return new_field, new_jump

height = 32
field, jump = 1, 1
for kernel, stride, padding in [(3, 1, 1), (3, 2, 1), (3, 1, 1)]:
    height = conv_output(height, kernel, padding, stride)
    field, jump = update_receptive_field(field, jump, kernel, stride)
    print("shape:", height, "receptive field:", field, "jump:", jump)
assert height == 16

这段代码只计算尺寸和感受野,不负责创建模型;把公式独立出来便于在改结构前做纸面检查。实际网络还要确认 padding 是否对称、不同框架的卷积定义是否一致、通道数是否匹配,以及 flatten 前的空间尺寸是否确定。对于输入尺寸可变的任务,尽量使用自适应池化或保留全局汇聚,避免把某个训练尺寸写死在全连接层。

模型选择可以从“数据中的局部性、顺序与不变性”开始,而不是从热门架构开始。图像中相邻像素通常相关,卷积的局部共享是合理先验;文本的远距离依赖和可变长度更需要 Embedding 与序列建模;表格数据若样本量小,简单 MLP 或树模型可能更容易审计。先建立简单基线,再证明结构先验带来的增益,报告参数量、吞吐、延迟和错误类型。

比较 CNN、MLP 或序列模型时,预处理也必须一致。图像的归一化、裁剪和颜色顺序,文本的 tokenizer、词表和最大长度,都会改变输入分布。若换模型同时换了预处理,结果差异无法归因。最小实验矩阵应固定数据快照、切分、指标、预算和 seed,只替换结构;如果资源有限,至少保存一组可复核的金样本输出。

图 5.8H · 从局部到全局的结构路线自制示意:结构先验逐步扩大上下文
局部窗口Conv / n-gram
→
多层堆叠扩大感受野
→
汇聚/位置保留顺序
→
全局关系Attention 入口

这是概念路线,不表示每个任务都必须按顺序使用所有模块。

进入大模型学习时,把 (B,T,D)、mask、位置和 logits 当作新的形状契约。Embedding 表的大小、注意力矩阵的轴、padding 的有效分母和输出头的类别数,都可以用本节的方法逐层打印与断言。先能解释一个小序列模型,再去理解 Transformer block,复杂度会显著下降。

结构选择实验:把先验变成可验证的对照

选择 CNN、MLP 或序列模型时,可以先写出数据生成机制的三句话:相邻位置是否相关、同一模式是否会平移或重复出现、远距离位置是否需要交互。然后为每个假设设计一个最小模型。若相邻像素相关,比较 flatten+MLP 与 Conv;若 token 顺序重要,比较词袋/平均池化与 Embedding+序列模块;若全局交互才是关键,再引入注意力。结构先验要靠对照验证,而不是靠名字判断。

卷积的边缘处理值得单独检查。padding 过小会让边界信息快速消失,过大可能引入大量零值;stride 改变分辨率,也会改变后续分类头的输入。对一张很小的手工图像,逐步打印每层特征图的 shape 和数值范围,观察一个局部亮点经过卷积、激活和池化后是否仍能被定位。这种可视化比只看最终准确率更能解释结构。

序列模型要同时记录长度和有效位置。Embedding 的词表大小决定可索引行数,隐藏维决定每个 token 的表示宽度;二者不能因为数值都叫“维度”就混用。padding mask 影响注意力和 loss,截断策略影响哪些信息从输入中消失。改变最大长度时,比较吞吐、显存和错误长度桶,而不仅是平均指标。

公平比较还需要统一预处理与预算。CNN 的图像裁剪、MLP 的 flatten、序列模型的 tokenizer 都可能改变有效输入;训练轮数相同不等于看到的样本/token 数相同,参数量相同也不等于激活显存相同。至少保存数据快照、预处理版本、结构、参数量、总样本/token、主指标、P95/P99 延迟和两个错误桶。

表 5.8I · 结构对照的最低记录
记录项CNN 示例序列示例为什么必要
输入契约(B,C,H,W)(B,T) ids避免轴与词表混淆
结构先验局部/平移共享顺序/长度/mask解释为何选择模型
参数与激活通道、核、特征图词表、D、注意力矩阵估算资源
预处理裁剪、归一化、颜色tokenizer、截断、padding保证输入可比
错误桶边缘、小目标、遮挡长序列、未知词、边界把指标变成改进假设
图 5.8I · 结构选择的实验循环自制示意:假设—基线—对照—复盘
写结构假设局部/顺序/全局
→
建简单基线固定数据预算
→
只换结构保留指标
→
按错误复盘决定下一步

每次实验只回答一个问题,避免把结构、预处理和训练预算同时更换。

这些纪律会直接迁移到 Transformer:输入从图像轴换成 token 轴,局部卷积换成可学习的全局交互,但 shape、mask、loss、资源和错误分析的职责仍然存在。先能用小模型证明一个结构假设,再学习更大架构的细节,理解会比背诵模块名称牢固。

09 · END-TO-END PROJECT

端到端神经网络实验:把概念变成可复跑的证据

最后一节不追求训练一个“大模型”,而是完成一个足够小、可以手算和重复运行的非线性分类项目。你会从任务卡、数据切分和基线开始,定义 MLP、损失和优化器,记录曲线与错误样例,再用消融实验解释“为什么变好”。这条链路会成为下一章 PyTorch 实战的模板。

5.9.1 迷你项目:用 MLP 学会一个非线性边界

选择 XOR 风格的二维数据,是因为它比线性可分问题多一个关键教学点:单个线性分类器无论怎样调整直线,都不能同时把相对角落的正类分开;加入隐藏层和非线性后,模型可以组合多个局部区域。这个项目不证明神经网络“什么都能学”,只用一个可控反例说明激活函数和层叠为何必要。

先写项目契约:每个样本是一个二维点,标签为 0 或 1;数据由固定随机种子生成,训练/验证/测试按相同规则切分;模型输入 (B,2),输出 (B,2) logits;主指标是测试集准确率,护栏是混淆矩阵、loss 曲线和不同 seed 的方差。测试集只在方案冻结后读取,不能为了让 XOR 看起来更好而反复调整它。

基线可以是多数类预测或一个没有隐藏层的 Linear(2,2)。线性基线在 XOR 上会留下明显错误,但它仍然有价值:你可以确认数据、标签和评估代码真的能工作,再把新增能力归因到隐藏层和激活,而不是归因到标签错位。每一步都先保存输入、切分摘要和参数配置。

图 5.5 · 从数据到证据的项目链自制流程:每一站都可单独复核
任务卡样本/标签/指标
→
数据切分train/valid/test
→
MLP 训练loss/backward/step
→
报告曲线/错误/版本
表 5.9A · XOR 迷你项目契约
项目本例约定替换成真实任务时要改什么验收证据
样本二维坐标点用户/订单/文本等实体一行代表谁
标签0/1,生成规则已知人工或延迟标签成熟窗口与类别定义
输入(B,2) float32特征数、token 或图像轴shape/dtype/device
输出(B,2) logits类别数/回归值与损失匹配
指标accuracy+混淆矩阵PR/F1/MAE/成本测试快照一次报告
版本seed、代码、数据线上快照与 tokenizermanifest 可回读
PYTHON · make_xor_data.py
import torch
from torch.utils.data import TensorDataset, DataLoader, random_split

def make_xor(n_per_corner=80, noise=0.16, seed=17):
    generator = torch.Generator().manual_seed(seed)
    corners = torch.tensor([[-1., -1.], [-1., 1.], [1., -1.], [1., 1.]])
    labels = torch.tensor([0, 1, 1, 0], dtype=torch.long)
    points = corners.repeat_interleave(n_per_corner, dim=0)
    points = points + noise * torch.randn(points.shape, generator=generator)
    targets = labels.repeat(n_per_corner)
    return points.float(), targets

features, targets = make_xor_data()
dataset = TensorDataset(features, targets)
train, valid, test = random_split(dataset, [220, 50, 50], generator=torch.Generator().manual_seed(9))
train_loader = DataLoader(train, batch_size=32, shuffle=True)
valid_loader = DataLoader(valid, batch_size=64, shuffle=False)
test_loader = DataLoader(test, batch_size=64, shuffle=False)
print(len(train), len(valid), len(test), features.shape)
assert features.shape[1] == 2 and targets.dtype == torch.long

数据生成器和切分生成器使用不同但固定的 seed,避免把数据噪声与抽样顺序混成一个不可解释的随机过程。训练集开启 shuffle,验证和测试保持确定顺序。真实项目中替换为文件或 Dataset 后,仍然要保留同样的输出:样本数、标签比例、ID 交集、时间范围和版本。

先验证基线,再验证非线性

可以先用一个 nn.Linear(2,2) 训练若干轮,记录它在 XOR 测试集上的表现;再换成 Linear→ReLU→Linear,其他数据、优化器和预算保持不变。比较两者的决策边界或错误点,才能把新增的收益与非线性联系起来。若 MLP 也完全学不会,先查标签、损失、学习率和输入尺度,不要马上加更多层。

项目不应承诺某个固定准确率,因为噪声、初始化和训练预算会改变结果。报告“在三次 seed 下均值、标准差和最差结果”,比只贴一次 100% 更诚实。若线性基线已经满足业务门槛,复杂网络没有必要;如果 MLP 提升但推理成本超过预算,也不能算可交付改进。

表 5.9B · 迷你项目的第一轮对照
版本结构固定项主要问题
BaselineLinear(2,2)数据/切分/指标/预算线性边界够不够
MLP-1Linear→ReLU→Linear同上非线性是否解决 XOR
MLP-2更宽隐藏层同上容量收益还是过拟合
MLP-regMLP+Dropout/decay同上稳定性与泛化
PROJECT LAB 5.9.1先写任务卡再训练 XOR

在运行代码前写出样本、标签、输入输出形状、切分比例、主指标、护栏和停止条件。再训练 Linear 基线与带 ReLU 的 MLP,保存三次 seed 的结果。

合格项目要能说明每个样本是一个二维点、标签生成规则、训练/验证/测试职责,以及 logits 与 CrossEntropyLoss 的接口。结果不要求固定数值,但必须保存 seed、曲线、混淆矩阵和失败案例,并说明复杂模型是否真的值得。

5.9.2 诊断与消融:解释模型为什么变好或变坏

训练完成后不要只看 accuracy。先按类别建立混淆矩阵,再把错误点画回二维空间或按样本 ID 列表,观察它们是否集中在边界、噪声区域或某个切分。对真实任务,错误样例要包含输入摘要、真实标签、预测类别、logits/概率、版本和人工备注,必要时脱敏。定性案例不是装饰,而是帮助你提出下一轮实验假设。

消融实验一次只移除或改变一个因素:去掉隐藏层、换激活、改变学习率、关闭 Dropout、改变 batch size 或减少训练轮数。所有版本共享数据快照、切分、指标和 seed 集合。若同时改了模型、数据增强和阈值,最终差异无法归因;如果预算有限,先选择最能回答当前问题的一项。

训练稳定性需要看多次运行的方差、梯度范数、参数范数和非有限计数。一个版本平均分略高但最差 seed 崩溃,可能不如稍低但稳定的版本。报告主指标、护栏、资源和回滚条件,把“平均好”与“可交付”分开。对大模型训练,这种记录还要扩展到吞吐、显存、token 数和 checkpoint 恢复。

表 5.9C · 诊断与消融矩阵
问题对照主要指标护栏
非线性是否必要Linear vs MLP测试 F1/accuracy参数量、耗时
宽度是否过大hidden=4/8/32valid 曲线train-valid gap
学习率是否合适1e-4/1e-3/1e-2收敛速度发散、梯度范数
正则是否有效dropout/decay on/off多 seed 均值最差 seed、校准
边界是否可信随机/按规则切分未来/新群组指标交集、重复、泄漏
PYTHON · confusion_report.py
import torch

def confusion_matrix(targets, predictions, classes):
    matrix = torch.zeros(classes, classes, dtype=torch.long)
    for truth, estimate in zip(targets.tolist(), predictions.tolist()):
        matrix[truth, estimate] += 1
    return matrix

def collect_predictions(loader, model, device):
    model.eval()
    all_targets, all_predictions = [], []
    with torch.no_grad():
        for features, targets in loader:
            logits = model(features.to(device))
            all_predictions.append(logits.argmax(dim=1).cpu())
            all_targets.append(targets.cpu())
    return torch.cat(all_targets), torch.cat(all_predictions)

# targets, predictions = collect_predictions(test_loader, model, device)
# print(confusion_matrix(targets, predictions, classes=2))

混淆矩阵的行通常是真实类别,列是预测类别,但不同工具的约定可能相反;报告中明确轴标题。对二分类,矩阵能直接读出 TP、FP、FN、TN;对多分类,按行/列归一化可以分别观察召回和预测精度。不要只贴一张没有标签的热图。

PYTHON · ablation_manifest.py
from dataclasses import dataclass, asdict
import json

@dataclass(frozen=True)
class RunSpec:
    run_id: str
    architecture: str
    activation: str
    learning_rate: float
    dropout: float
    seed: int
    data_version: str

runs = [
    RunSpec("linear-s1", "linear", "none", 1e-3, 0.0, 7, "xor-v1"),
    RunSpec("mlp-s1", "mlp", "relu", 1e-3, 0.0, 7, "xor-v1"),
    RunSpec("mlp-drop-s1", "mlp", "relu", 1e-3, 0.2, 7, "xor-v1"),
]
with open("runs.json", "w", encoding="utf-8") as handle:
    json.dump([asdict(run) for run in runs], handle, ensure_ascii=False, indent=2)
print("saved", len(runs), "run specifications")

把错误样例变成下一轮实验

错误复盘要写成假设—对照—结果的句子。例如“边界附近错误多,假设隐藏层太窄;只把 hidden 从 4 改为 16,其他条件不变;若边界错误下降且最差 seed 不恶化,则支持假设”。如果结果不支持,也要保存,因为它排除了一个方向。不要把“模型没学好”当作最终解释。

消融报告还要写不可推出的结论。XOR 上 MLP 胜过 Linear,只能说明这组二维任务需要非线性边界;不能推出 MLP 在真实文本、图像或未来数据上一定更好。小项目的价值是建立实验纪律,不是制造泛化神话。

表 5.9D · 错误样例卡片
字段示例用途
sample_idtest-037回到原始数据
truth/pred1 / 0定位 FN/FP
logits/prob[-0.1,0.2]判断边界置信度
输入摘要x=0.12,y=-0.84观察空间位置
版本与 seedmlp-s1/7复现差异
下一步假设边界样本需更多数据安排可验证实验
PROJECT LAB 5.9.2写一张消融实验卡

从你的 XOR 或其他小任务中选择一个问题,写出假设、只改变的变量、固定变量、主指标、护栏和停止条件。结果不支持假设时,说明如何保留这次失败。

实验卡应让别人知道只改了什么、其余条件是否相同,以及哪一个数字会支持/否定假设。失败运行保留配置、曲线、错误样例和原因,不要从 runs.json 中删除或只保留冠军。

5.9.3 章末复盘:带着训练纪律进入 PyTorch 实战

回看本章主线:神经元先做加权和与偏置,激活函数提供非线性,多层模块在 forward 中传递张量;损失把输出和目标变成标量,autograd 沿计算图求梯度,优化器按学习率更新参数;Dataset/DataLoader 提供稳定 batch,train/eval 和 no_grad 划分模式,正则化与归一化控制泛化和数值,检查点把状态与版本保存下来。

真正的掌握不是能背出 API,而是能在出现异常时定位层次。loss 为 NaN,先查输入、dtype、logits 和梯度;训练高验证低,查切分、容量、正则和标签;验证每次不同,查 eval、随机增强和 DataLoader;加载后结果变了,查 state_dict、buffer、预处理和类别字典。每个问题都应该有一条最小复现和一条回归测试。

下一章会更系统地使用 PyTorch:安装与环境、Tensor 操作、Dataset/DataLoader、模块化模型、训练循环、保存加载和一个真实小数据集。你不需要把本章所有公式一次背完,但要带走四份交付物:任务卡、形状/损失契约、训练脚本、实验账本。它们会让后续进入卷积、Embedding、Transformer 和大模型微调时仍然保持可追溯。

表 5.9E · 神经网络基础能力地图
能力最低证据第 6 章的延伸大模型中的对应
建模能写 nn.Module 与 forward组合模块与自定义 DatasetTransformer block/head
目标能匹配 logits、标签和 loss处理 batch/mask/reductiontoken-level cross entropy
求导能解释 backward 与梯度检查调试 autograd/裁剪混合精度与梯度累积
训练能写 train/eval loop记录曲线与指标分布式/长训练监控
工程能保存 checkpoint 与 manifest恢复和部署推理权重、tokenizer、配置版本
PYTHON · chapter5_checklist.py
checklist = {
    "task_card": True,
    "shape_contract": True,
    "loss_contract": True,
    "train_eval_modes": True,
    "gradient_check": True,
    "seed_runs": 3,
    "error_samples_saved": True,
    "checkpoint_round_trip": True,
}
required = [
    "task_card", "shape_contract", "loss_contract",
    "train_eval_modes", "gradient_check", "error_samples_saved",
    "checkpoint_round_trip",
]
missing = [name for name in required if not checklist[name]]
if checklist["seed_runs"] < 2:
    missing.append("multiple_seed_runs")
if missing:
    raise ValueError(f"进入下一章前仍缺少: {missing}")
print("chapter 5 checklist passed")

清单中的每个 True 都应有文件或输出作为证据。比如 gradient_check 对应一份数值与 autograd 的对照,checkpoint_round_trip 对应保存前后金样本 logits,seed_runs 对应至少两次完整配置。把清单当作门槛而不是装饰,能避免“看过教程但没有真正训练过”的错觉。

官方资料与继续学习

PyTorch 官方入门教程把学习路径拆成 Tensor、Dataset/DataLoader、构建模型、自动微分、优化循环和保存加载;本章的结构与这条路径对应,但额外强调了任务契约、指标分母、错误样例和版本证据。阅读官方文档时,先确认接口的输入输出与模式,再把最小代码放进自己的实验,而不是整段复制后只看最终准确率。

建议先通读 nn.Module 的模型定义示例,再阅读 autograd mechanics 和 optimization loop。遇到 CrossEntropyLoss 时,核对它期望的是未归一化 logits、类别索引和哪些形状;保存模型时,理解 state_dict 与 optimizer state 的差别。官方页面会随着版本更新,运行前请记录自己的 PyTorch 版本和设备。

PROJECT LAB 5.9.3提交一页神经网络实验报告

报告必须包含:任务卡、数据快照、模型结构、输入输出形状、损失与 reduction、训练/验证曲线、至少两次 seed、混淆矩阵或回归指标、两个错误样例、checkpoint 回读和下一轮实验假设。

读者应能从报告复现关键数字,并知道哪些结论只适用于当前数据。若缺少 seed、分母、错误样例或版本,就把它们列为未完成项;不要用一张漂亮曲线替代证据链。

最终验收:你是否真的理解了“训练”

不查资料,尝试回答:为什么没有激活的多层仍是线性?为什么 CrossEntropyLoss 通常接 logits?为什么 backward 之后要清零梯度?为什么验证要同时 eval 和 no_grad?为什么 Dataset 不应更新参数?为什么 checkpoint 要保存 optimizer?为什么一个 seed 的高分不足以证明泛化?每个答案都配一个你自己运行过的代码片段。

如果某个问题只能背一句定义,回到对应小节重跑最小例子。学习神经网络的关键不是把代码复制到能运行,而是能预测一条修改会怎样影响形状、loss、梯度、曲线和错误。带着这种预测能力进入下一章,PyTorch 的 API 才会从“命令”变成可推理的工具。

表 5.9F · 本章最终交付物
交付物最低内容完成标志
任务卡样本、标签、指标、边界一页可复述
模型卡结构、形状、参数、模式forward 探针通过
训练脚本数据、loss、backward、step曲线可重跑
实验账本配置、seed、指标、错误JSON 可回读
检查点模型/优化器/版本金样本一致
复盘报告证据、限制、下一步第三方能复核

项目运行手册:每一步都留下可回放的证据

第一步先生成或读取数据快照,打印样本数、类别比例、特征范围和前五个样本;第二步固定切分与 seed,保存 ID 清单;第三步运行一个线性基线,确认损失会下降且指标脚本正常;第四步只加入一个隐藏层和激活,比较决策边界与错误点;第五步做多 seed、检查点回读和一次消融。每一步都应能单独重跑,任何失败都保留日志,不要直接覆盖上一轮。

项目的“完成”不是屏幕上出现一个高准确率,而是第三方可以回答:数据从哪里来、标签如何生成、模型输出什么、loss 的分母是什么、哪个 epoch 最好、训练为何停止、错误集中在哪里、恢复后输出是否一致。把这些问题写成报告字段,能迫使代码补齐缺失职责。若某个字段无法填写,就把它列为下一轮工程任务,而不是用一句“默认如此”掩盖。

迷你项目还适合练习失败注入。故意把标签打乱、把 ReLU 删除、把学习率提高十倍、把 eval 改成 train、把 checkpoint 中的类别字典换掉,再观察哪一项证据最先报警。能主动制造并定位失败,比只运行成功案例更接近真实工作。完成后恢复正确版本,并把一个失败案例加入回归测试或文档中的错误样例。

报告要区分观察、解释和结论。观察是“第 3 轮验证 loss 上升”;解释是假设“模型开始记忆训练点”;结论必须由对照支持,例如加入正则后多 seed 的验证指标改善且训练集指标下降。不要把一次曲线形状直接写成普遍规律,也不要把 XOR 上的结果外推到所有文本或图像任务。每个结论附上数据版本、配置和限制,读者才能判断它的适用范围。

表 5.9G · 端到端项目的回放字段
阶段保存字段回放问题最低验收
数据快照、ID、标签规则、切分今天看到的数据是否相同交集为零且版本可读
模型结构、参数量、shape probe输入如何变成 logits固定 batch 无 NaN
训练optimizer、lr、epoch、seed参数为何沿这条轨迹更新曲线可重绘
评估指标分母、阈值、混淆矩阵高分是否只是多数类错误样例可定位
交付checkpoint、环境、限制能否在新进程回读金样本输出在容差内一致
PYTHON · report_record.py
from dataclasses import dataclass, asdict
from pathlib import Path
import json

@dataclass(frozen=True)
class Evidence:
    data_version: str
    model_version: str
    seed: int
    best_epoch: int
    valid_metric: float
    checkpoint: str
    limitations: tuple[str, ...]

evidence = Evidence(
    data_version="xor-v1",
    model_version="mlp-relu-h8",
    seed=7,
    best_epoch=42,
    valid_metric=0.98,
    checkpoint="runs/mlp-s1.pt",
    limitations=("toy data", "single split"),
)
Path("evidence.json").write_text(
    json.dumps(asdict(evidence), ensure_ascii=False, indent=2),
    encoding="utf-8",
)
print("evidence saved")

项目报告中的数值应能回到原始记录,而不是手工抄写。用 JSON 保存配置和指标,用单独的文本或表格保存错误样例;文件名包含 run_id,但内容也要包含版本字段。读取报告时做 schema 检查,缺少 seed、分母或限制就标记为不完整。这样未来把 XOR 换成小型文本分类时,只需替换数据和模型部分,证据链仍然成立。

进入下一章前,建议把项目拆成四个函数或模块:make_data 负责数据,build_model 负责结构,train_one_run 负责更新与日志,evaluate_and_save 负责评估与交付。函数的职责比代码行数更重要;每个函数都有输入、输出和最小测试,才能在加入真实 Dataset、GPU 和更长训练时保持清楚。你可以把本节的报告字段当作第一份实验模板。