1.1 人工神经元
一句话
一个神经元 = 加权求和 + 偏置 + 激活函数。这是许多网络的基本可学习单元;注意力、归一化、池化这些运算则是另外的结构。
从生物到数学 :生物神经元用树突接收信号,胞体把信号累加,累加超过阈值就沿轴突发放一个电脉冲。1943 年 McCulloch 和 Pitts 把它抽象成二值阈值单元(M-P 神经元):输入取 0 或 1,权重固定,加权和超过阈值输出 1,否则输出 0。现代的人工神经元保留了「加权求和 → 非线性」这个骨架,把硬阈值换成可导的激活函数,把权重变成可以从数据里学的参数。
符号
名字
作用
x
输入
n 维向量
w
权重
每个输入的重要程度和正负方向
b
偏置
平移决策边界
z = w·x + b
预激活(pre-activation)
激活前的加权和
f
激活函数
引入非线性,见 1.3
y = f(z)
激活值
神经元的输出
几何意义 :w·x + b = 0 是 n 维空间里的一个超平面。神经元算的是「输入点落在超平面哪一侧、离它多远」(w·x + b 和到超平面的有符号距离成正比,真正的距离要再除以 ‖w‖),再用 f 做一次非线性变换(sigmoid、tanh 会压到有界范围,ReLU 不会)。所以激活函数单调、再按阈值判类别时(感知机就是这样),单个神经元只能画一个超平面把数据分开,这就是感知机解不了 XOR 的原因(见 2.4.2)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import torchimport torch.nn as nnx = torch.tensor([1.0 , 2.0 , 3.0 ]) w = torch.tensor([0.2 , -0.5 , 0.1 ]) b = torch.tensor(0.3 ) z = w @ x + b y = torch.sigmoid(z) print (f"z = {z.item():.2 f} , y = {y.item():.4 f} " ) layer = nn.Linear(3 , 4 ) print (layer.weight.shape, layer.bias.shape) X = torch.randn(8 , 3 ) print (torch.allclose(layer(X), X @ layer.weight.T + layer.bias))
1.2 一层、多层与非线性
一层 = 很多神经元并排 。m 个神经元共享同一个输入 x,各自有一行权重,合起来是一个 m × n 的矩阵:
多层 = 一层的输出当下一层的输入 :
为什么必须有非线性 :去掉 f,两层线性变换相乘还是一个线性变换,W2(W1 x) = (W2 W1) x,叠多少层都等于一层。激活函数让每一层能把空间「弯折」一次,叠起来才能拟合复杂边界。
万能逼近定理 (Cybenko 1989、Hornik 1991、Leshno 1993):激活函数用 sigmoid、tanh、ReLU 这类满足定理条件的函数时,只要隐藏层足够宽,一个隐藏层就能在紧集(有界闭集)上以任意精度逼近任何连续函数。对常用的连续激活函数,条件是「不是多项式」,只说「非线性」不够:激活取 x² 时,单隐层网络无论多宽都只能表示二次多项式。定理只保证「存在」,不保证能训出来,也不说需要多宽。实践中用「加深」代替「加宽」:有些函数用浅网络需要指数级的神经元,用深网络只要多项式级(Telgarsky 2016 的锯齿函数例子)。深度学习的「深」指的就是这个。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import torchimport torch.nn as nntorch.manual_seed(0 ) l1, l2 = nn.Linear(5 , 8 ), nn.Linear(8 , 3 ) x = torch.randn(4 , 5 ) W = l2.weight @ l1.weight b = l2.weight @ l1.bias + l2.bias print (torch.allclose(l2(l1(x)), x @ W.T + b, atol=1e-6 )) xs = torch.linspace(-3.14 , 3.14 , 200 ).unsqueeze(1 ) ys = torch.sin(2 * xs) for width in [2 , 8 , 64 ]: torch.manual_seed(0 ) net = nn.Sequential(nn.Linear(1 , width), nn.Tanh(), nn.Linear(width, 1 )) opt = torch.optim.Adam(net.parameters(), lr=1e-2 ) for _ in range (3000 ): loss = ((net(xs) - ys) ** 2 ).mean() opt.zero_grad(); loss.backward(); opt.step() print (f"隐藏单元 {width:3d} 个:MSE = {loss.item():.5 f} " )
1.3 激活函数
Φ 是标准正态分布的累积分布函数。按热度排:
名称
输出范围
常用位置
特点
ReLU
[0, +∞)
CNN、MLP 的隐藏层默认值
正区梯度恒为 1,不衰减;计算最便宜;负区梯度为 0,神经元可能「死掉」
GELU
约 [−0.17, +∞)
Transformer(BERT、GPT、ViT)
ReLU 的平滑版,负区有一点小梯度
SiLU / Swish
约 [−0.28, +∞)
EfficientNet、扩散模型;SwiGLU 门控 FFN(LLaMA 系列)
平滑、非单调;SwiGLU = SiLU(xW₁) ⊙ (xW₂)
Sigmoid
(0, 1)
门控(LSTM/GRU 的门)、二分类输出
两端饱和,导数最大 0.25,深层堆叠会梯度消失
Tanh
(−1, 1)
RNN 隐状态、输出需要有界时
零中心,但同样两端饱和
Softmax
概率分布
多分类输出、注意力权重
作用在整个向量上;只看相对差值,见 1.4
LeakyReLU
(−∞, +∞)
GAN 判别器
负区给一个固定的小斜率(如 0.2),避免死神经元
ELU
(−α, +∞)
早期 Dreamer 等强化学习网络
负区是 α(eˣ − 1),导数 αeˣ,往负方向平滑地饱和到 −α;输出均值更接近 0
梯度消失从哪来 :反向传播时每经过一层都要乘一次权重矩阵和激活函数的导数(见 1.5)。sigmoid 的导数最大只有 0.25,只看激活导数这部分,10 层连乘最多剩 0.25¹⁰ ≈ 10⁻⁶;权重没有大到能补偿时,浅层就几乎学不动(权重很大时也可能反过来爆炸)。ReLU 在正区导数恒为 1,这是 2012 年以后深网络能训起来的原因之一。
怎么选 :隐藏层默认 ReLU(卷积网络)或 GELU(Transformer);门控用 sigmoid(要一个 0 到 1 的「开度」);RNN 隐状态用 tanh;输出层由任务决定,见下一节。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import torchimport torch.nn.functional as Fx = torch.linspace(-3 , 3 , 7 , requires_grad=True ) for name, fn in [("relu" , F.relu), ("gelu" , F.gelu), ("silu" , F.silu), ("sigmoid" , torch.sigmoid), ("tanh" , torch.tanh)]: y = fn(x) (g,) = torch.autograd.grad(y.sum (), x) print (f"{name:8s} f(x) ={y.detach().numpy().round (2 )} " ) print (f"{'' :8s} f'(x)={g.numpy().round (2 )} " ) print ("sigmoid 导数的最大值:" , torch.sigmoid(torch.tensor(0. )).item() * (1 - torch.sigmoid(torch.tensor(0. )).item()))print ("10 个 sigmoid 导数连乘最多剩:" , 0.25 ** 10 ) z = torch.tensor([2.0 , 1.0 , 0.1 ]) print ("softmax:" , F.softmax(z, dim=0 ).numpy().round (3 ))
1.4 输出层、logits 与损失函数
logits 分类网络最后一层输出的、还没有归一化成概率的数,叫 logits 。下面先以多分类的 softmax 头为例,每个类别一个 logit;二分类、多标签用 sigmoid 的情况见本节后面。
性质
说明
取值
任意实数,可正可负
是不是概率
不是。它没有被赋予「归一化概率」的含义,一般也不在 (0, 1)、加起来不等于 1(数值碰巧落在 0 到 1 之间也不是概率)
怎么变成概率
多分类过一次 softmax;二分类、多标签的单个 logit 各自过 sigmoid
名字
统计里 logit(p) = log(p / (1 − p)) 是对数几率;深度学习借这个名字指「未归一化的对数概率」
softmax 只看差值 :整排 logits 同时加一个常数,softmax 结果完全不变。所以在多分类的 softmax 头里,单个 logit 本身的大小没有意义,有意义的是类别之间的相对高低。二分类只用一个 logit 过 sigmoid 时不一样:这个 logit 连同正负号的取值直接决定概率(+2 和 −2 对应的概率不同),不能随意平移;多标签任务也是每个标签一个 logit、各自过 sigmoid。
logits 可加,概率不可加 :如果两路特征拼接后接一个线性层,那么 logits = W_a·φ_a + W_b·φ_b + b 是精确的加法分解,每一路贡献多少可以单独拿出来看。过了 softmax 之后这个加法关系就没有了。分析多模态网络里「哪一路在起作用」时,通常在 logits 这一层做(见 2.8.5)。
谁有 logits :分类头、语言模型的词表头、把动作离散成 token 的策略(RT-2、OpenVLA)有;回归头、扩散/流匹配头(输出噪声或速度,用 MSE 训练)没有。
输出层与损失的搭配
任务
输出层
损失
PyTorch
多分类
K 个 logits
交叉熵
nn.CrossEntropyLoss(内部自带 log_softmax,输入直接给 logits)
回归
线性输出
均方误差 MSE
nn.MSELoss
二分类 / 多标签
每个标签 1 个 logit
二元交叉熵
nn.BCEWithLogitsLoss(内部自带 sigmoid)
有离群点的回归
线性输出
L1 / Huber
nn.L1Loss、nn.HuberLoss
分布对分布
概率或对数概率
KL 散度
nn.KLDivLoss(输入要求是 log 概率)
度量学习
嵌入向量
对比损失 InfoNCE、三元组损失
见 2.6.1
从似然看损失:交叉熵和 MSE 是同一件事 似然 :固定训练数据,把模型在这些数据上给出的 p_θ(y | x) 看成参数 θ 的函数,叫似然。y 是离散值时 p_θ 是概率;y 是连续值时是概率密度,密度可以大于 1,所以连续情形的 NLL 可以是负数。训练就是让所有训练样本的似然尽量大,叫最大似然估计(MLE) 。连乘会下溢,所以取对数;优化器习惯最小化,所以加负号,得到负对数似然(NLL) :
输出是类别分布时,NLL 就是交叉熵
输出假设成固定方差的高斯分布 N(μ_θ(x), σ²) 时,代进去:
NLL 退化成 MSE 。所以 MSE 回归可以理解成「假设条件分布是固定方差的单峰高斯」时的最大似然。这是 MSE 的一种概率解释,用 MSE 并不需要先做这个假设。对任何二阶矩有限的条件分布都有:
第一项和预测值 a 无关,所以 MSE 的最优点预测总是条件均值,它单独表达不了多峰的分布。由此有三个推论:
MSE 的最优解是条件均值 E[y | x];L1 的最优解是条件中位数
如果同一个 x 对应几个都正确的 y(多峰),MSE 给出的是按条件概率加权的均值。它可能落在峰之间的低概率区域,得到一个训练集里从没出现过的值;也可能靠近某个峰(2.10.2 有例子)。落在哪都一样,一个均值表达不了完整的分布。机器人演示里「从左绕」和「从右绕」左右对称、概率相近,平均成「直着撞上去」,就是前一种情况
想表达多峰,要换分布假设,换损失的名字没用:混合高斯(MDN,2.10.2)、离散化后用交叉熵(RT-2/OpenVLA 的做法)、扩散/流匹配(2.2.1、2.2.2)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import torchimport torch.nn as nnimport torch.nn.functional as Flogits = torch.tensor([[2.0 , 0.5 , -1.0 ]]) target = torch.tensor([0 ]) ce = nn.CrossEntropyLoss()(logits, target) manual = -F.log_softmax(logits, dim=1 )[0 , 0 ] print (f"交叉熵 {ce.item():.4 f} ,手算 {manual.item():.4 f} " ) print ("softmax 概率:" , F.softmax(logits, 1 ).numpy().round (2 )) print ("平移不变:" , torch.allclose(F.softmax(logits, 1 ), F.softmax(logits + 100 , 1 ))) print ("MSE:" , nn.MSELoss()(torch.tensor([2.5 , 0.0 ]), torch.tensor([3.0 , -0.5 ])).item()) print ("BCE:" , nn.BCEWithLogitsLoss()(torch.tensor([0.8 ]), torch.tensor([1.0 ])).item()) torch.manual_seed(0 ) y = torch.randint(0 , 2 , (1000 ,)).float () * 2 - 1 c = torch.zeros(1 , requires_grad=True ) opt = torch.optim.SGD([c], lr=0.1 ) for _ in range (200 ): loss = ((y - c) ** 2 ).mean() opt.zero_grad(); loss.backward(); opt.step() print (f"MSE 学到 {c.item():.3 f} ,数据均值 {y.mean().item():.3 f} ,但数据里只有 ±1" )
1.5 反向传播与自动求导
一句话
前向算出损失,反向用链式法则从损失往回逐层算出每个参数的梯度,梯度给出损失上升最快的方向,沿负梯度走一小步就能让损失下降,优化器据此构造每一步的更新。
单个神经元的链式法则 :设 z = wx + b,a = σ(z),L = (a − y)²:
一层网络的矩阵形式 :记 δ_l = ∂L/∂z_l 为第 l 层预激活的误差信号,h_{l−1} 是该层的输入:
读法:误差从后一层传回来(乘 Wᵀ),再乘本层激活函数的导数,就得到本层的误差;本层权重的梯度 = 本层误差 × 本层输入。
几件要知道的事
计算图 :前向时 PyTorch 把每一步运算记成一张图,保存反向要用的中间结果。反向按图的逆序走一遍,代价约为前向的两倍。这也是训练比推理多占显存的原因:中间结果(激活值)要一直存到反向用完
梯度会累加 :.backward() 把梯度加到 .grad 上,不会覆盖,所以每步更新前要 optimizer.zero_grad()。故意不清零可以实现「梯度累积」,用小 batch 模拟大 batch
torch.no_grad() :推理时不建计算图,省显存、更快;.detach() 把一个张量从图上摘下来,梯度不再沿它回传
所有梯度都从同一个输出误差出发 (只看这个输出对应的主任务损失,不含权重正则和辅助损失;多个损失时总梯度是各项梯度的加权和):通往任何参数的路径,第一步都是 ∂L/∂输出。多分类时它是一个向量,softmax 交叉熵下等于 p − onehot(y);各个分支再乘上各自的雅可比矩阵,拿到的梯度大小、方向各不相同。预测已经正确且很有把握时,这个误差向量趋近 0,所有分支的梯度一起变小;有把握但预测错了时,误差并不小
梯度消失与爆炸 :δ 每往回传一层,就乘一次这一层的雅可比矩阵(由 Wᵀ 和 f′ 组成)。连乘几十次后,如果这些矩阵一直在压缩梯度所在的方向,梯度就越传越小(消失);一直在放大,就可能越传越大(爆炸)。不能按单个权重是不是小于 1 来判断:所有元素都是 0.75 的 2×2 矩阵会把 (1, 1) 方向放大 1.5 倍。每层雅可比的最大奇异值都不超过同一个小于 1 的数时,梯度一定指数衰减;最大奇异值大于 1 则不保证一定爆炸,还要看梯度的方向。1.8 节的部件大多是在对付这个问题
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 import torchx, y = torch.tensor(1.5 ), torch.tensor(1.0 ) w = torch.tensor(0.8 , requires_grad=True ) b = torch.tensor(-0.2 , requires_grad=True ) a = torch.sigmoid(w * x + b) loss = (a - y) ** 2 loss.backward() manual = 2 * (a - y) * a * (1 - a) * x print ("dL/dw 自动:" , round (w.grad.item(), 6 ), " 手推:" , round (manual.item(), 6 ))torch.manual_seed(0 ) X, Y = torch.randn(16 , 3 ), torch.randn(16 , 1 ) W1 = torch.randn(3 , 8 , requires_grad=True ); b1 = torch.zeros(8 , requires_grad=True ) W2 = torch.randn(8 , 1 , requires_grad=True ); b2 = torch.zeros(1 , requires_grad=True ) Z1 = X @ W1 + b1 H1 = torch.tanh(Z1) P = H1 @ W2 + b2 loss = ((P - Y) ** 2 ).mean() with torch.no_grad(): dP = 2 * (P - Y) / len (X) dW2, db2 = H1.T @ dP, dP.sum (0 ) dH1 = dP @ W2.T dZ1 = dH1 * (1 - H1 ** 2 ) dW1, db1 = X.T @ dZ1, dZ1.sum (0 ) loss.backward() for name, m, a_ in [("W1" , dW1, W1.grad), ("b1" , db1, b1.grad), ("W2" , dW2, W2.grad), ("b2" , db2, b2.grad)]: print (name, "手写 = 自动:" , torch.allclose(m, a_, atol=1e-6 )) v = torch.tensor(1.0 , requires_grad=True ) (3 * v).backward(); (3 * v).backward() print ("两次 backward 后 v.grad =" , v.grad.item())
1.6 优化器与学习率
有了梯度 g,优化器决定参数怎么更新。按热度排:
优化器
更新思路
什么时候用
AdamW
Adam + 解耦的权重衰减
Transformer、扩散模型、绝大多数新项目的默认值
Adam
按每个参数的梯度历史自适应步长
小网络、快速实验
SGD + Momentum
沿梯度的滑动平均方向走
CNN 图像分类(ResNet 经典配方),泛化常略好于 Adam
SGD
直接沿负梯度走一步
教学、理论分析
Muon、Lion 等
新近提出的优化器(Muon 对矩阵参数的更新做正交化)
大模型预训练里开始使用,还在验证中
m̂ = m / (1 − β₁ᵗ)、v̂ = v / (1 − β₂ᵗ) 是偏差修正,抵消 m、v 初始为 0 带来的偏小。直觉:m 是梯度的「平均方向」,√v 是梯度的「典型幅度」,两者相除后每个参数的步长大致在 η 的量级,梯度大的参数不容易冲过头,梯度小的参数也能动起来(这是直觉,不是保证)。AdamW 把权重衰减从梯度里拿出来单独做(θ ← θ − ηλθ),这和 Adam 里加 L2 正则不等价,实践中通常效果更好。
学习率 是最重要的超参:太大发散,太小学不动。常用起点:
场景
学习率
小网络 + Adam
1e-3
Transformer 预训练 + AdamW
1e-4 ~ 6e-4,配 warmup
微调预训练模型
1e-5 ~ 1e-4,通常比预训练小一个数量级
ResNet + SGD(batch 256)
0.1,momentum 0.9
学习率调度 :训练中改变学习率。最常见的是 warmup + cosine :开头几百到几千步线性升到峰值(避免一开始 Adam 的二阶矩估计不准导致大步乱跳),然后按余弦曲线降到接近 0。也有项目实测 warmup 没收益、直接用常数学习率(OpenVLA 用了固定的 2e-5)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 import mathimport torchdef rosenbrock (p ): x, y = p return (1 - x) ** 2 + 100 * (y - x ** 2 ) ** 2 for name, make in [("SGD" , lambda p: torch.optim.SGD([p], lr=1e-3 )), ("SGD+Momentum" , lambda p: torch.optim.SGD([p], lr=1e-3 , momentum=0.9 )), ("Adam" , lambda p: torch.optim.Adam([p], lr=2e-2 ))]: p = torch.tensor([-1.5 , 2.0 ], requires_grad=True ) opt = make(p) for _ in range (3000 ): loss = rosenbrock(p) opt.zero_grad(); loss.backward(); opt.step() print (f"{name:13s} 停在 {p.detach().numpy().round (3 )} ,loss = {loss.item():.5 f} " ) model = torch.nn.Linear(10 , 1 ) opt = torch.optim.AdamW(model.parameters(), lr=3e-4 , weight_decay=0.01 ) warmup, total = 100 , 1000 sched = torch.optim.lr_scheduler.LambdaLR( opt, lambda s: s / warmup if s < warmup else 0.5 * (1 + math.cos(math.pi * (s - warmup) / (total - warmup)))) lrs = [] for step in range (total): opt.step(); sched.step() lrs.append(sched.get_last_lr()[0 ]) print ("第 50 / 100 / 550 / 999 步的学习率:" , [f"{lrs[i]:.2 e} " for i in (49 , 99 , 549 , 998 )])
同一个起点、同样 3000 步:普通 SGD 在弯曲的谷底走得很慢;加了动量或用 Adam 都能走到最小值。
1.7 标准训练循环
上图是一次训练迭代(以「图像 + 触觉 → 预测」为例):①–⑥ 是前向计算,⑦ loss.backward() 算出每个参数的梯度,⑧ optimizer.step() 更新参数;梯度裁剪、梯度调制插在 ⑦ 和 ⑧ 之间。
术语
词
意思
step / iteration
一次参数更新(做梯度累积时,几个小 batch 才更新一次,iteration 和 step 就不是一回事)
batch size
一步用多少样本
epoch
训练集完整过一遍
训练 / 验证 / 测试集
训练集用来更新参数;验证集用来调超参、选 checkpoint;测试集只在最后报成绩时用一次
过拟合
模型把训练集里的噪声和偶然规律也学了进去,泛化变差;典型表现是训练损失继续降、验证损失开始升
model.train() / model.eval()
切换 Dropout 和 BatchNorm 的行为,推理前忘了 eval() 是常见 bug
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 import torchimport torch.nn as nnfrom torch.utils.data import TensorDataset, DataLoadertorch.manual_seed(0 ) n = 1000 r = torch.cat([torch.rand(n // 2 ), 1.5 + torch.rand(n // 2 )]) theta = torch.rand(n) * 2 * torch.pi X = torch.stack([r * torch.cos(theta), r * torch.sin(theta)], dim=1 ) y = torch.cat([torch.zeros(n // 2 ), torch.ones(n // 2 )]).long() perm = torch.randperm(n) X, y = X[perm], y[perm] train_dl = DataLoader(TensorDataset(X[:800 ], y[:800 ]), batch_size=64 , shuffle=True ) X_val, y_val = X[800 :], y[800 :] device = "cuda" if torch.cuda.is_available() else "cpu" model = nn.Sequential(nn.Linear(2 , 32 ), nn.ReLU(), nn.Linear(32 , 32 ), nn.ReLU(), nn.Linear(32 , 2 )).to(device) loss_fn = nn.CrossEntropyLoss() opt = torch.optim.Adam(model.parameters(), lr=1e-2 ) for epoch in range (30 ): model.train() for xb, yb in train_dl: xb, yb = xb.to(device), yb.to(device) logits = model(xb) loss = loss_fn(logits, yb) opt.zero_grad() loss.backward() opt.step() model.eval () with torch.no_grad(): acc = (model(X_val.to(device)).argmax(1 ) == y_val.to(device)).float ().mean().item() if epoch % 10 == 9 : print (f"epoch {epoch + 1 } : train loss {loss.item():.3 f} , val acc {acc:.3 f} " )
1.8 让深网络训得动的部件
按热度排:
部件
解决什么
用在哪
残差连接
缓解深层退化和梯度消失:y = x + F(x),梯度多了一条沿 x 直达浅层的路
几乎所有深网络(详见 2.3.2)
归一化
层间数值尺度漂移,允许更大学习率
见下表
初始化
一开始就让每层输出的方差稳定,不消失不爆炸
Xavier 配 tanh/sigmoid,Kaiming(He)配 ReLU。PyTorch 的 nn.Linear、nn.Conv 默认是按输入维度缩放的均匀分布 U(−1/√fan_in, 1/√fan_in),方差只有 Kaiming 的 1/6,深层 ReLU 网络常手动换成 Kaiming
Dropout
过拟合:训练时随机把一部分激活置 0,推理时关掉
MLP、Transformer
权重衰减
过拟合:每步把权重往 0 拉一点
AdamW 默认带
梯度裁剪
偶发的梯度爆炸:梯度范数超过阈值就等比缩小
RNN、Transformer、扩散策略
数据增强、早停
过拟合
图像任务必备;早停按验证集损失停
几种归一化 (按指定的维度控制数值尺度,区别在「一组」怎么划。LayerNorm、BatchNorm、GroupNorm 减均值、除标准差,再乘 γ、加 β;RMSNorm 不减均值,只除以均方根,通常只有缩放 γ):
归一化
在哪些维度上统计
依赖 batch
用在
LayerNorm
序列 (B, T, D) 上用 LayerNorm(D) 时,每个 token 单独在自己的 D 维上统计;normalized_shape 也可以指定更多尾部维度
否
Transformer、RNN
RMSNorm
同上,只除以均方根,不减均值
否
LLaMA 等大模型(更省、效果相当)
BatchNorm
每个通道在 (B, H, W) 上
是
CNN,batch 较大时比较可靠(batch 太小时统计量噪声大);推理时用训练中累计的滑动均值
GroupNorm
(B, C, H, W) 上,每个样本把通道分成 G 组,每组的 (C/G, H, W) 一起统计
否
小 batch 的检测、扩散 U-Net;和 EMA 一起用时比 BatchNorm 稳
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 import torchimport torch.nn as nntorch.manual_seed(0 ) x = torch.randn(4 , 3 , 8 , 8 ) * 5 + 2 bn = nn.BatchNorm2d(3 ) print ("BN 后每个通道的均值:" , bn(x).mean(dim=(0 , 2 , 3 )).detach().numpy().round (4 ))seq = torch.randn(2 , 5 , 16 ) * 3 + 1 print ("LN 后每个 token 的均值:" , nn.LayerNorm(16 )(seq).mean(-1 ).abs ().max ().item() < 1e-5 )rms = nn.RMSNorm(16 ) print ("RMSNorm 后每个 token 的均方根:" , rms(seq).pow (2 ).mean(-1 ).sqrt()[0 ].detach().numpy().round (3 ))drop = nn.Dropout(p=0.5 ) v = torch.ones(10 ) drop.train(); print ("Dropout 训练时:" , drop(v).numpy()) drop.eval (); print ("Dropout 推理时:" , drop(v).numpy()) class ResidualBlock (nn.Module): """Pre-LN 残差块:Transformer 里 FFN 子层的标准写法""" def __init__ (self, d, hidden, p=0.1 ): super ().__init__() self.norm = nn.LayerNorm(d) self.ff = nn.Sequential(nn.Linear(d, hidden), nn.GELU(), nn.Dropout(p), nn.Linear(hidden, d)) def forward (self, x ): return x + self.ff(self.norm(x)) net = nn.Sequential(*[ResidualBlock(16 , 64 ) for _ in range (24 )]) for m in net.modules(): if isinstance (m, nn.Linear): nn.init.kaiming_normal_(m.weight, nonlinearity="relu" ) nn.init.zeros_(m.bias) loss = net(seq).pow (2 ).mean() loss.backward() total = torch.nn.utils.clip_grad_norm_(net.parameters(), max_norm=1.0 ) print (f"裁剪前梯度总范数 {total:.1 f} ,裁剪后不超过 1.0" )
1.9 训练工程:精度、显存、EMA、检查点、自检
数值精度 上图上半是各数值格式的位分布:
指数位决定动态范围 ,尾数位决定精度
训练用 BF16 :BF16 和 FP32 的指数位都是 8 位,范围相同(约 10⁻³⁸ 到 10³⁸),只是有效数字少。FP16 只有 5 位指数,最小正规数约 6×10⁻⁵,再往下只能靠非正规数(最小约 6×10⁻⁸),有效位数越来越少,更小的就下溢成 0。很多梯度落在这一段,所以 FP16 训练要配 loss scaling(先把损失放大再反传)。大多数训练对精度的容忍度比对范围的高,BF16 的取舍通常合适
混合精度 :主权重保持 FP32,前向和反向里的矩阵乘等算子走 BF16。具体怎么存取决于框架:上图是另存一份 BF16 权重、梯度也是 BF16 的实现;PyTorch 原生的 torch.autocast 按算子临时选精度,参数和梯度本身仍是 FP32(下面代码里能看到参数是 float32)
INT8 / INT4 主要用于推理 :把一组权重映射到整数格点,另存一个缩放因子。大模型单样本推理的瓶颈是把权重从显存搬到计算单元的带宽,权重越小搬得越快;但反量化多一步计算,INT8 有时反而比 BF16 慢,INT4 省的带宽多,通常更快。训练里也有用到的地方,比如量化感知训练,以及 QLoRA 把冻结的基座存成 4 bit、只训练 LoRA 参数(3.1),但梯度和可训练参数仍是浮点
显存怎么估
场景
每个参数占多少字节
7B 模型
BF16 推理
2
≈ 14 GB,另加 KV cache 和激活
INT4 推理
0.5
≈ 3.5 GB
Adam 混合精度训练
BF16 权重 2 + BF16 梯度 2 + FP32 主权重 4 + Adam 两份状态 8 = 16
≈ 112 GB,另加激活值
两种常见实现折算下来都约 16 字节/参数:表里是「BF16 权重和梯度 + FP32 主权重」的口径;PyTorch 原生 AMP 常见的是参数和梯度都存 FP32(4 + 4),再加 Adam 两份状态 8。两种都不含激活、临时张量和精度转换的缓存,也没算多卡分片。
EMA、检查点、自检
EMA(指数移动平均)权重 :另存一份权重 = 历史权重的滑动平均,评测和部署用它,比当前权重稳。扩散模型、扩散策略的标配
检查点里要存 :模型权重、EMA 权重、优化器状态(断点续训要用)、当前步数、配置、数据归一化统计量(推理时要用同一套);用到的话还要存学习率调度器、AMP 的 GradScaler、各个随机数生成器的状态和数据采样进度,否则续训的轨迹会和不中断时不同
跑全量训练前先做自检 :
过拟合一个 batch:反复训练同一个 batch,损失应接近 0。降不下去时,先排查数据、标签、维度或损失有没有写错;模型太小、正则太强、用了标签平滑或优化设置不当,也会让损失停在 0 以上
打乱标签再训(训练预算不变):独立验证集上的表现应该降到无信息基线附近(比如总猜多数类的准确率)。如果还明显更好,先排查标签信息是不是从别的路漏进了输入(数据划分、特征来源),再考虑类别不平衡、随机波动这些解释。只看训练损失没用,容量够大的网络连随机标签也能背下来(Zhang 2017)
把预测结果反归一化后画出来,和真值叠在一起看
日志里除了损失,记录学习率、各分支的梯度范数、显存、每步耗时
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 import copy, os, tempfileimport torchimport torch.nn as nntorch.manual_seed(0 ) model = nn.Sequential(nn.Linear(64 , 256 ), nn.GELU(), nn.Linear(256 , 10 )) x = torch.randn(32 , 64 ) with torch.autocast(device_type="cpu" , dtype=torch.bfloat16): out = model(x) print ("autocast 内输出的 dtype:" , out.dtype, "| 参数仍是:" , model[0 ].weight.dtype)def train_mem_gb (n_params, bytes_per_param=16 ): return n_params * bytes_per_param / 1e9 print (f"7B 模型 Adam 混合精度训练(不含激活)约 {train_mem_gb(7e9 ):.0 f} GB,BF16 推理约 {train_mem_gb(7e9 , 2 ):.0 f} GB" )w = torch.randn(256 , 256 ) * 0.05 scale = w.abs ().max () / 127 w_int8 = torch.clamp((w / scale).round (), -127 , 127 ).to(torch.int8) w_back = w_int8.float () * scale print (f"INT8 量化的相对误差: {(w - w_back).norm() / w.norm():.4 f} " )ema = torch.optim.swa_utils.AveragedModel(model, multi_avg_fn=torch.optim.swa_utils.get_ema_multi_avg_fn(0.999 )) opt = torch.optim.AdamW(model.parameters(), lr=1e-3 ) y = torch.randint(0 , 10 , (32 ,)) for step in range (300 ): loss = nn.functional.cross_entropy(model(x), y) opt.zero_grad(); loss.backward(); opt.step() ema.update_parameters(model) print (f"单 batch 过拟合后的损失: {loss.item():.4 f} " )ckpt = dict (model=model.state_dict(), ema=ema.state_dict(), optimizer=opt.state_dict(), step=300 , config={"lr" : 1e-3 }, stats={"mean" : 0.0 , "std" : 1.0 }) path = os.path.join(tempfile.mkdtemp(), "step_000300.pt" ) torch.save(ckpt, path) restored = copy.deepcopy(model) restored.load_state_dict(torch.load(path)["model" ]) print ("恢复后输出一致:" , torch.allclose(restored(x), model(x)))
1.10 反向传播之外的学习规则
反向传播属于「误差修正学习」。经典教材(Haykin《Neural Networks: A Comprehensive Foundation》第 2 版第 2 章)把神经网络的学习方式分成五类,第二部分里的老网络各用其中一种。按热度排:
学习规则
一句话
更新公式(大意)
用它的网络
误差修正
按「目标 − 输出」的误差调权重
Δw = η(d − y)x,多层时就是反向传播
感知机、MLP、几乎所有现代网络
基于记忆
把训练样本存起来,预测时按相似度找最近的几个
无训练(k 近邻),或只解一个线性方程(中心取在样本上的 RBF 插值)
k 近邻、RBF 插值;核回归、注意力的前向计算也是「按相似度检索」,但注意力的参数靠反向传播训练,属于误差修正
竞争学习
神经元互相竞争,只有获胜者(和邻居)更新
w_c ← w_c + η(x − w_c)
SOM、k-means
Hebb 学习
一起激活的神经元,连接加强
Δw_ij = η·y_i·x_j
Hopfield 网络、Oja 规则(学主成分)
玻尔兹曼学习
让模型统计量逼近数据统计量
Δw_ij = η(⟨s_i s_j⟩_数据 − ⟨s_i s_j⟩_模型)
玻尔兹曼机、RBM
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 import torchtorch.manual_seed(0 ) X = torch.randn(2000 , 2 ) @ torch.tensor([[2.0 , 1.8 ], [0.0 , 0.6 ]]) X = X - X.mean(0 ) w = torch.randn(2 ) for x in X: y = w @ x w += 0.001 * y * (x - y * w) pc1 = torch.linalg.eigh(X.T @ X).eigenvectors[:, -1 ] print ("Oja 学到的方向:" , (w / w.norm()).numpy().round (3 ), " PCA 第一主成分:" , pc1.numpy().round (3 ))centers = torch.tensor([[-4. , 0. ], [0. , 4. ], [4. , 0. ]]) labels = torch.arange(3 ).repeat_interleave(300 ) data = centers[labels] + 0.5 * torch.randn(900 , 2 ) perm = torch.randperm(900 ) data, labels = data[perm], labels[perm] protos = torch.tensor([[-1. , -1. ], [0. , 1. ], [1. , -1. ]]) for x in data: win = ((protos - x) ** 2 ).sum (1 ).argmin() protos[win] += 0.05 * (x - protos[win]) print ("竞争学习找到的原型:\n" , protos.numpy().round (2 ))q = torch.tensor([[3.8 , 0.2 ]]) idx = torch.cdist(q, data).topk(5 , largest=False ).indices[0 ] print ("k 近邻的 5 个邻居标签:" , labels[idx].tolist(), "→ 预测" , labels[idx].mode().values.item())
1.11 从神经元到架构:连接方式与归纳偏置
第二部分的所有网络,都可以看成对同一种神经元做了四个选择:哪些神经元相连、哪些权重共享、有没有环、哪里引入随机性 。这些选择对数据结构做了先验假设,叫归纳偏置(inductive bias) 。假设和数据吻合,少量数据就能学好;假设不吻合,再多数据也别扭。
架构
连接方式
权重共享
对数据的假设
假设不成立时
MLP
全连接
不共享
几乎没有假设
参数随输入维度爆炸,非常费数据
CNN
只连局部邻域
卷积核在空间上共享
局部性 + 平移等变
长程依赖要堆很多层
RNN
当前输入 + 上一步状态
在时间上共享
时序 + 马尔可夫式状态递推
长程遗忘,训练不能并行
Transformer
任意两个 token 动态相连(注意力)
每个位置用同一组投影
几乎没有假设,只靠位置编码给一点结构
小数据易过拟合,O(T²) 开销
GNN
沿图的边
每个节点用同一组函数
图结构 + 节点顺序无关
图建错了全盘错
PointNet / DeepSets
每个元素独立 + 对称池化
每个元素用同一个 MLP
集合无序
局部结构抓得弱
SSM / Mamba
线性状态递推
在时间上共享
时序,状态大小固定
精确回忆长上下文里的任意片段较弱
一个反复出现的现象 :Transformer 假设最少,数据足够多时常常是它更好;数据少时 CNN 反而稳。这是经验规律,具体胜负还取决于任务、模型规模、训练方法和算力。机器人模仿学习常常只有 50–200 条演示,原版 Diffusion Policy 把卷积 U-Net 作为默认去噪网络,论文给的理由是它对超参不敏感、多数任务开箱即用;Transformer 版在动作变化快的任务上更好,但要更多调参。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import torchimport torch.nn as nncount = lambda m: sum (p.numel() for p in m.parameters()) fc = nn.Linear(3 * 32 * 32 , 3 * 32 * 32 ) conv = nn.Conv2d(3 , 3 , kernel_size=3 , padding=1 ) print (f"全连接参数 {count(fc):,} ,卷积参数 {count(conv):,} " ) img = torch.zeros(1 , 3 , 32 , 32 ); img[..., 8 :12 , 8 :12 ] = 1 shift = lambda t: torch.roll(t, shifts=(5 , 5 ), dims=(2 , 3 )) print ("卷积平移等变:" , torch.allclose(conv(shift(img)), shift(conv(img)), atol=1e-6 ))print ("全连接平移等变:" , torch.allclose(fc(shift(img).flatten(1 )), shift(fc(img.flatten(1 )).view(1 , 3 , 32 , 32 )).flatten(1 ), atol=1e-4 ))phi = nn.Sequential(nn.Linear(3 , 16 ), nn.ReLU()) pts = torch.randn(1 , 100 , 3 ) perm = torch.randperm(100 ) print ("求和池化置换不变:" , torch.allclose(phi(pts).sum (1 ), phi(pts[:, perm]).sum (1 ), atol=1e-5 ))