1.1 人工神经元

<center>1.1 人工神经元
1.1 人工神经元
<center>1.1 人工神经元
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 torch
import torch.nn as nn

# 一个神经元:3 个输入 → 1 个输出
x = 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 # 加权求和:0.2 - 1.0 + 0.3 + 0.3 = -0.2
y = torch.sigmoid(z) # 激活
print(f"z = {z.item():.2f}, y = {y.item():.4f}") # z = -0.20, y = 0.4502

# PyTorch 里一个 nn.Linear 就是一排神经元,权重矩阵形状是 (out, in)
layer = nn.Linear(3, 4) # 4 个神经元,每个看同样的 3 个输入
print(layer.weight.shape, layer.bias.shape) # torch.Size([4, 3]) torch.Size([4])
X = torch.randn(8, 3) # 一个 batch:8 个样本
print(torch.allclose(layer(X), X @ layer.weight.T + layer.bias)) # True

1.2 一层、多层与非线性

<center>1.2 一层与多层
1.2 一层与多层
<center>1.2 一层与多层
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 torch
import torch.nn as nn

# 1) 没有激活函数,两层线性 = 一层线性
torch.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)) # True:白叠了一层

# 2) 万能逼近:单隐层网络拟合 sin,隐藏单元越多越准
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():.5f}")
<center>万能逼近
万能逼近
<center>万能逼近
万能逼近

1.3 激活函数

<center>1.3 激活函数的位置
1.3 激活函数的位置
<center>1.3 激活函数的位置
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
<center>激活函数
激活函数
<center>激活函数
激活函数

梯度消失从哪来:反向传播时每经过一层都要乘一次权重矩阵和激活函数的导数(见 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 torch
import torch.nn.functional as F

x = 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) # ≈ 9.5e-07

z = torch.tensor([2.0, 1.0, 0.1])
print("softmax:", F.softmax(z, dim=0).numpy().round(3)) # [0.659 0.242 0.099],和为 1

1.4 输出层、logits 与损失函数

<center>1.4 输出层与损失
1.4 输出层与损失
<center>1.4 输出层与损失
1.4 输出层与损失

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 的最优点预测总是条件均值,它单独表达不了多峰的分布。由此有三个推论:

  1. MSE 的最优解是条件均值 E[y | x];L1 的最优解是条件中位数
  2. 如果同一个 x 对应几个都正确的 y(多峰),MSE 给出的是按条件概率加权的均值。它可能落在峰之间的低概率区域,得到一个训练集里从没出现过的值;也可能靠近某个峰(2.10.2 有例子)。落在哪都一样,一个均值表达不了完整的分布。机器人演示里「从左绕」和「从右绕」左右对称、概率相近,平均成「直着撞上去」,就是前一种情况
  3. 想表达多峰,要换分布假设,换损失的名字没用:混合高斯(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 torch
import torch.nn as nn
import torch.nn.functional as F

logits = torch.tensor([[2.0, 0.5, -1.0]]) # 模型原始输出,没过 softmax
target = torch.tensor([0]) # 类别下标
ce = nn.CrossEntropyLoss()(logits, target)
manual = -F.log_softmax(logits, dim=1)[0, 0]
print(f"交叉熵 {ce.item():.4f},手算 {manual.item():.4f}") # 0.2413,两者相等
print("softmax 概率:", F.softmax(logits, 1).numpy().round(2)) # [[0.79 0.18 0.04]]
print("平移不变:", torch.allclose(F.softmax(logits, 1), F.softmax(logits + 100, 1))) # True

print("MSE:", nn.MSELoss()(torch.tensor([2.5, 0.0]), torch.tensor([3.0, -0.5])).item()) # 0.25
print("BCE:", nn.BCEWithLogitsLoss()(torch.tensor([0.8]), torch.tensor([1.0])).item()) # -log σ(0.8) ≈ 0.371

# 多峰目标:同一个输入,一半样本 y=+1、一半 y=-1。用 MSE 拟合一个常数,学到的是均值 0
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():.3f},数据均值 {y.mean().item():.3f},但数据里只有 ±1")

1.5 反向传播与自动求导

<center>1.5 反向传播
1.5 反向传播
<center>1.5 反向传播
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 torch

# 1) 单个神经元:手推链式法则 vs 自动求导
x, 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))

# 2) 两层网络:手写矩阵形式的反向传播,和 autograd 对比
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 # (16, 8) 前向,保存中间结果
H1 = torch.tanh(Z1)
P = H1 @ W2 + b2 # (16, 1)
loss = ((P - Y) ** 2).mean()

with torch.no_grad(): # 手写反向:从损失往回逐层
dP = 2 * (P - Y) / len(X) # ∂L/∂P
dW2, db2 = H1.T @ dP, dP.sum(0) # 本层权重梯度 = 本层输入ᵀ × 本层误差
dH1 = dP @ W2.T # 误差传回上一层
dZ1 = dH1 * (1 - H1 ** 2) # 乘激活函数导数 tanh' = 1 - tanh²
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))

# 3) 梯度会累加
v = torch.tensor(1.0, requires_grad=True)
(3 * v).backward(); (3 * v).backward()
print("两次 backward 后 v.grad =", v.grad.item()) # 6.0,所以训练循环里要 zero_grad()

1.6 优化器与学习率

<center>1.6 优化器
1.6 优化器
<center>1.6 优化器
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 math
import torch

def rosenbrock(p): # 最小值在 (1, 1);谷底狭长弯曲,用来比较优化器
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():.5f}")

# warmup + cosine 调度
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() # 真实训练里 opt.step() 前有 backward
lrs.append(sched.get_last_lr()[0])
print("第 50 / 100 / 550 / 999 步的学习率:", [f"{lrs[i]:.2e}" for i in (49, 99, 549, 998)])
<center>优化器轨迹与学习率调度
优化器轨迹与学习率调度
<center>优化器轨迹与学习率调度
优化器轨迹与学习率调度

同一个起点、同样 3000 步:普通 SGD 在弯曲的谷底走得很慢;加了动量或用 Adam 都能走到最小值。

1.7 标准训练循环

<center>1.7 训练迭代
1.7 训练迭代
<center>1.7 训练迭代
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 torch
import torch.nn as nn
from torch.utils.data import TensorDataset, DataLoader

torch.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) # (1000, 2)
y = torch.cat([torch.zeros(n // 2), torch.ones(n // 2)]).long() # (1000,)
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():.3f}, val acc {acc:.3f}") # train loss 是这轮最后一个 batch 的,不是整轮平均

1.8 让深网络训得动的部件

<center>1.8 残差与归一化
1.8 残差与归一化
<center>1.8 残差与归一化
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 torch
import torch.nn as nn

torch.manual_seed(0)
x = torch.randn(4, 3, 8, 8) * 5 + 2 # (B, C, H, W),尺度和均值都偏
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 # (B, T, D)
print("LN 后每个 token 的均值:", nn.LayerNorm(16)(seq).mean(-1).abs().max().item() < 1e-5)
rms = nn.RMSNorm(16) # PyTorch 2.4+ 自带
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()) # 约一半为 0,其余放大到 2(保持期望不变)
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)) # 残差:x 原样加回来

net = nn.Sequential(*[ResidualBlock(16, 64) for _ in range(24)]) # 24 层也能正常回传梯度
for m in net.modules(): # 初始化示例:Kaiming 正态
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:.1f},裁剪后不超过 1.0")

1.9 训练工程:精度、显存、EMA、检查点、自检

<center>1.9 数值精度与混合精度
1.9 数值精度与混合精度
<center>1.9 数值精度与混合精度
1.9 数值精度与混合精度

数值精度

上图上半是各数值格式的位分布:

  • 指数位决定动态范围,尾数位决定精度
  • 训练用 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、各个随机数生成器的状态和数据采样进度,否则续训的轨迹会和不中断时不同
  • 跑全量训练前先做自检:
    1. 过拟合一个 batch:反复训练同一个 batch,损失应接近 0。降不下去时,先排查数据、标签、维度或损失有没有写错;模型太小、正则太强、用了标签平滑或优化设置不当,也会让损失停在 0 以上
    2. 打乱标签再训(训练预算不变):独立验证集上的表现应该降到无信息基线附近(比如总猜多数类的准确率)。如果还明显更好,先排查标签信息是不是从别的路漏进了输入(数据划分、特征来源),再考虑类别不平衡、随机波动这些解释。只看训练损失没用,容量够大的网络连随机标签也能背下来(Zhang 2017)
    3. 把预测结果反归一化后画出来,和真值叠在一起看
    4. 日志里除了损失,记录学习率、各分支的梯度范数、显存、每步耗时
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, tempfile
import torch
import torch.nn as nn

torch.manual_seed(0)
# 1) 混合精度:autocast 区域内的矩阵乘自动用 BF16
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)

# 2) 显存估算
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):.0f} GB,BF16 推理约 {train_mem_gb(7e9, 2):.0f} GB")

# 3) 对称 INT8 量化:浮点权重 → 整数格点 + 一个缩放因子
w = torch.randn(256, 256) * 0.05
scale = w.abs().max() / 127
w_int8 = torch.clamp((w / scale).round(), -127, 127).to(torch.int8) # 存这个,占 1/4 空间
w_back = w_int8.float() * scale # 用的时候反量化
print(f"INT8 量化的相对误差: {(w - w_back).norm() / w.norm():.4f}")

# 4) EMA 权重
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,))

# 5) 自检:过拟合单个 batch,损失应该能降到接近 0
for step in range(300):
loss = nn.functional.cross_entropy(model(x), y)
opt.zero_grad(); loss.backward(); opt.step()
ema.update_parameters(model) # 每步更新一次 EMA
print(f"单 batch 过拟合后的损失: {loss.item():.4f}")

# 6) 检查点:权重保存与恢复的示例(完整续训还要存调度器、随机状态等,见上文)
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 反向传播之外的学习规则

<center>1.10 学习规则
1.10 学习规则
<center>1.10 学习规则
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 torch

torch.manual_seed(0)
# 二维相关数据,主方向大约是 (1, 1)/√2
X = torch.randn(2000, 2) @ torch.tensor([[2.0, 1.8], [0.0, 0.6]])
X = X - X.mean(0)

# Hebb 学习(Oja 规则):Δw = η·y·(x − y·w),单个线性神经元学到第一主成分
w = torch.randn(2)
for x in X:
y = w @ x
w += 0.001 * y * (x - y * w) # 纯 Hebb 项 y·x 会让 w 无限增大,减去 y²w 使其稳定
pc1 = torch.linalg.eigh(X.T @ X).eigenvectors[:, -1]
print("Oja 学到的方向:", (w / w.norm()).numpy().round(3), " PCA 第一主成分:", pc1.numpy().round(3))
# 两者只差一个正负号(主成分的方向本来就不分正反)

# 三簇数据,标签 0 / 1 / 2
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]

# 竞争学习(= 在线 k-means):只有离输入最近的原型被拉过去
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))

# 基于记忆的学习(k 近邻):不训练,预测时在记忆里找最近的 k 个样本投票
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 从神经元到架构:连接方式与归纳偏置

<center>1.11 连接方式
1.11 连接方式
<center>1.11 连接方式
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 torch
import torch.nn as nn

count = lambda m: sum(p.numel() for p in m.parameters())

# 同样把 32×32×3 的图映射成 32×32×3:全连接 vs 3×3 卷积
fc = nn.Linear(3 * 32 * 32, 3 * 32 * 32) # 每个输出像素看所有输入像素
conv = nn.Conv2d(3, 3, kernel_size=3, padding=1) # 每个输出像素只看 3×3 邻域,权重全图共享
print(f"全连接参数 {count(fc):,},卷积参数 {count(conv):,}") # 9,440,256 vs 84

# 卷积的平移等变:输入平移,输出跟着平移
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))

# 集合的置换不变:逐元素共享 MLP + 求和池化,打乱元素顺序输出不变
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))