在 从线性回归到神经网络 里,一个 2→4→1 的网络 200 步就学会了 XOR。那把它加到 50 层,是不是就能学会更难的东西?直接试会发现,损失纹丝不动,或者第一步就变成 nan。深度网络的难点不在于写出来,而在于让信号能从第一层顺利走到最后一层、梯度能从最后一层顺利走回第一层。这一篇沿着这条线索,依次讲初始化、归一化、残差、优化器,最后讲图像为什么要用卷积。
前置:从线性回归到神经网络
信号在深网络里会消失或爆炸
做一个实验:1000 个样本、512 维输入,穿过 10 层 512×512 的随机线性层加激活,看最后一层输出的标准差。
import torch
torch.manual_seed(0)
x0 = torch.randn(1000, 512)
for name, scale in [("std=0.01", lambda n: 0.01), ("std=1", lambda n: 1.0),
("Xavier 1/sqrt(n)", lambda n: n ** -0.5), ("Kaiming sqrt(2/n)", lambda n: (2 / n) ** 0.5)]:
for act in ("tanh", "relu"):
x = x0
for _ in range(10):
W = torch.randn(512, 512) * scale(512)
x = x @ W
x = torch.tanh(x) if act == "tanh" else torch.relu(x)
print(f"{name:18s} {act:4s} layer10 std {x.std():.2e}")std=0.01 tanh layer10 std 3.34e-07
std=0.01 relu layer10 std 8.58e-09
std=1 tanh layer10 std 9.82e-01
std=1 relu layer10 std 7.56e+11
Xavier 1/sqrt(n) tanh layer10 std 2.29e-01
Xavier 1/sqrt(n) relu layer10 std 2.26e-02
Kaiming sqrt(2/n) tanh layer10 std 5.53e-01
Kaiming sqrt(2/n) relu layer10 std 7.55e-01权重太小(0.01),每层把信号缩小,十层后只剩 到 。权重为 1 时,ReLU 网络的输出到了 ;tanh 网络的标准差看起来正常(0.98),其实所有激活都挤在 ±1,tanh 完全饱和,导数 ,梯度一样传不回去。
为什么会这样?一层的输出 是 项之和。若 方差为 1、 方差为 且相互独立,。每过一层方差乘以 ,十层就是 。要让它不变,必须 ,这就是 Xavier 初始化的核心(原文取前向和反向的折中 )。ReLU 把一半的输入砍成 0,方差再减半,于是要补一个 2:,这就是 Kaiming(He)初始化。表里最后一行 ReLU 配 Kaiming,十层后标准差还在 0.75,配 Xavier 则掉到 0.02,正好对应每层多损失一半。
反向传播时梯度要乘同样一串雅可比矩阵,也是同一个问题的镜像。初始化不对,前向信号消失,反向梯度也会消失。nn.Linear 默认用的是一种 Kaiming uniform 变体,所以平时不需要手动处理,但你自己写层、或者改激活函数时要记得这件事。
激活函数的选择也是同一个故事。sigmoid 导数最大只有 0.25,十层连乘就是 ;它的输出也不以 0 为中心。tanh 好一些但两端饱和。ReLU 在正半轴导数恒为 1,梯度不衰减,计算也便宜,于是成了默认;它的毛病是一个神经元如果输入恒为负,就永远输出 0、永远没梯度(“死 ReLU”),学习率太大时容易出现。LeakyReLU、GELU、SiLU 都是在负半轴留一点斜率的改良,Transformer 里常见 GELU 和 SiLU。
一个 ReLU 网络每层宽度 1024,权重用 std = 1/sqrt(1024) 初始化,共 30 层。前向时激活的尺度大致会怎样?
1/n 的方差能保持线性层方差,但 ReLU 把一半输入置零,每层方差再乘 1/2,30 层就是 2 的 -30 次方量级。所以 ReLU 要用 Kaiming 的 2/n。
"基本不变"忽略了 ReLU;"翻倍"是方差设成 4/n 才会出现的情况;输入尺度只影响起点,不改变逐层缩放的倍数。
归一化:让每层输入的分布保持稳定
好的初始化只保证训练开始时尺度正常。参数一更新,每层输入的分布就跟着变,后面的层要不停地适应前面的变化。2015 年的 Batch Normalization 给出一个直接的办法:既然想要每层输入均值 0、方差 1,那就在网络里显式地把它归一化。
对一个 batch 的激活 ,对每个特征(列):
是这个 batch 里第 列的均值和方差。 是可学习参数,让网络在需要时能把归一化”撤销”掉,不至于损失表达力。
LayerNorm 只换了一个轴:对每个样本(行),在它自己的所有特征上算均值方差。两者手写对照 PyTorch:
import torch
torch.manual_seed(0)
x = torch.randn(8, 4) * 5 + 3
bn = torch.nn.BatchNorm1d(4); ln = torch.nn.LayerNorm(4)
mine_bn = (x - x.mean(0)) / torch.sqrt(x.var(0, unbiased=False) + 1e-5)
mine_ln = (x - x.mean(1, keepdim=True)) / torch.sqrt(x.var(1, unbiased=False, keepdim=True) + 1e-5)
print("BN diff", (bn(x) - mine_bn).abs().max().item() < 1e-5, " LN diff", (ln(x) - mine_ln).abs().max().item() < 1e-5)
print("BN per-column mean", mine_bn.mean(0).abs().max().item() < 1e-6, " LN per-row mean", mine_ln.mean(1).abs().max().item() < 1e-6)
print("running_mean after 1 batch:", [round(v, 3) for v in bn.running_mean.tolist()])
bn.eval(); one = x[:1]
print("eval mode works on batch of 1:", tuple(bn(one).shape))BN diff True LN diff True
BN per-column mean True LN per-row mean True
running_mean after 1 batch: [0.397, 0.129, 0.419, 0.269]
eval mode works on batch of 1: (1, 4)BatchNorm 依赖 batch 统计量,带来两个后果。第一,推理时可能只有一个样本,没法算 batch 均值,所以训练时要维护一个滑动平均 running_mean/running_var(默认动量 0.1,所以一个 batch 后 running_mean 是 batch 均值的十分之一左右),推理时用它代替。这也是 model.train() 和 model.eval() 必须切换的原因之一,忘了切换,推理结果会随 batch 组成而变。第二,batch 太小时统计量噪声很大,效果变差。
LayerNorm 每个样本自己归一化,和 batch 大小无关,训练推理行为一致,也天然适合长度可变的序列。所以 CNN 里多用 BatchNorm,RNN 和 Transformer 里用 LayerNorm。大模型里进一步简化成只除均方根、不减均值的 RMSNorm,放在子层之前(pre-norm),细节见 Transformer 架构。
BatchNorm 为什么有效,原论文的解释是减少”内部协变量偏移”,后来的研究认为更主要的作用是让损失曲面更平滑,从而允许更大的学习率。机制仍有争论,但效果是确定的:它让很深的 CNN 能用大学习率稳定训练。
关于 BatchNorm 和 LayerNorm,哪些说法正确?
BatchNorm 对每个特征跨 batch 求统计量,推理时改用 running 统计量;LayerNorm 对每个样本跨特征求统计量,与其他样本无关。
两者的区别正是归一化的轴不同,不是 eps。gamma/beta 只是保留了"撤销"的可能,归一化本身仍然改变了优化的几何,让训练更稳定。
残差连接:给梯度修一条直达通道
有了好的初始化和 BatchNorm,20 层的网络能训了。但 2015 年 He 等人发现一个怪现象:56 层的普通 CNN,训练误差比 20 层的还高。这不是过拟合(过拟合是训练误差低、测试误差高),是优化本身出了问题。可从逻辑上讲,深网络至少不该比浅网络差:把多出来的层都设成恒等映射,就和浅网络一样。问题是让一叠”卷积 + 非线性”去学恒等映射并不容易。
ResNet 的修正非常简单:让每个块学的是残差。
学成 0 就是恒等,这对优化器容易得多。更重要的是反向:。那个 意味着梯度可以不经过任何权重矩阵,从最后一层直达第一层。把它跑出来看,比较第一层权重的梯度范数:
import torch, torch.nn as nn
torch.manual_seed(0)
def grad_norm_first(depth, residual, norm):
layers = nn.ModuleList([nn.Linear(64, 64) for _ in range(depth)])
norms = nn.ModuleList([nn.LayerNorm(64) for _ in range(depth)])
for l in layers: nn.init.normal_(l.weight, std=64 ** -0.5); nn.init.zeros_(l.bias)
x = torch.randn(32, 64)
h = x
for l, n in zip(layers, norms):
f = torch.tanh(l(n(h) if norm else h))
h = h + f if residual else f
h.pow(2).mean().backward()
return layers[0].weight.grad.norm().item()
for depth in (5, 20, 50):
print(f"depth {depth:2d} plain {grad_norm_first(depth, False, False):.2e} "
f"residual {grad_norm_first(depth, True, False):.2e} residual+LN {grad_norm_first(depth, True, True):.2e}")depth 5 plain 5.50e-02 residual 9.49e-01 residual+LN 7.15e-01
depth 20 plain 2.61e-02 residual 6.25e+00 residual+LN 2.30e+00
depth 50 plain 1.12e-02 residual 4.98e+01 residual+LN 5.39e+00普通网络随深度增加,第一层梯度一路变小。加残差后梯度不再衰减,但出现了另一个问题:每个块都往 上加一点东西, 的尺度随深度增长,梯度也跟着变大,50 层时已经到 50。在块内先做归一化(,这就是 pre-norm 的形状),每个 看到的输入尺度固定,增长被压住了。今天几乎所有深网络,从 ResNet 到 GPT,都是”残差主干 + 块内归一化”这个骨架。
ResNet 的块还有一些工程细节:基本块是两个 3×3 卷积;更深的版本(ResNet-50 起)用 1×1 降维、3×3 卷积、1×1 升维的”瓶颈块”省计算;当块的输入输出通道数或分辨率不同时,捷径上加一个 1×1 卷积对齐形状。
ResNet 论文里 56 层普通网络的训练误差高于 20 层。这说明什么?
过拟合表现为训练误差低、测试误差高,这里训练误差本身就更高,所以是优化困难而不是泛化问题。
表达能力上深网络至少能模拟浅网络(多余层取恒等),不会更弱;数据量不足同样会表现为过拟合,而不是训练误差更高。残差连接正是针对这个优化问题。
优化器:从 SGD 到 Adam
从线性回归到神经网络 里已经看到,曲面在不同方向弯曲程度不同时,单一学习率很尴尬。用一个刻意造的峡谷 来比较, 方向的曲率是 方向的 50 倍:
import numpy as np
# f = 0.5*(x^2 + 50 y^2): 条件数 50 的峡谷
grad = lambda p: np.array([p[0], 50 * p[1]])
f = lambda p: 0.5 * (p[0] ** 2 + 50 * p[1] ** 2)
def run(opt, steps=100, lr=0.035):
p = np.array([10.0, 1.0]); v = np.zeros(2); m = np.zeros(2); s = np.zeros(2)
for t in range(1, steps + 1):
g = grad(p)
if opt == "sgd": p = p - lr * g
elif opt == "momentum": v = 0.9 * v + g; p = p - 0.035 * v
elif opt == "adam":
m = 0.9 * m + 0.1 * g; s = 0.999 * s + 0.001 * g * g
mh = m / (1 - 0.9 ** t); sh = s / (1 - 0.999 ** t)
p = p - 0.5 * mh / (np.sqrt(sh) + 1e-8)
return f(p), p
for o in ("sgd", "momentum", "adam"):
val, p = run(o); print(f"{o:8s} f={val:.2e} x={p[0]:+.3f} y={p[1]:+.4f}")
print(f"sgd lr=0.041: f={run('sgd', lr=0.041)[0]:.2e}")sgd f=4.02e-02 x=+0.284 y=+0.0000
momentum f=1.45e-03 x=+0.045 y=+0.0042
adam f=1.36e-03 x=-0.042 y=-0.0043
sgd lr=0.041: f=4.32e+05SGD 的学习率被最陡的 方向卡住:稳定条件是 ,取 0.041 就发散到 。取 0.035 能稳住, 早就到了 0,但在平缓的 方向每步只走 3.5%,100 步后还停在 0.28。
Momentum 维护一个速度 ,参数沿 走。在 方向梯度来回变号,累积时相互抵消;在 方向梯度始终同号,累积起来越走越快,有效步长放大到约 倍。同样的学习率, 收敛快了好几倍。
Adam 换了个思路:给每个参数各自的步长。它维护梯度的滑动平均 (一阶矩,相当于动量)和梯度平方的滑动平均 (二阶矩),更新量是 。 方向梯度大, 也大,步长被压小; 方向梯度小,步长被放大。结果每个方向的有效步长都接近学习率本身,和梯度的绝对尺度无关。因为 从 0 开始,前几步会偏小,所以要除以 做偏差修正。默认 。
Adam 对超参数不敏感,几乎成了默认选择;在图像分类上,精调过的 SGD + momentum 有时泛化略好。训练大模型时用的是 AdamW,它把权重衰减从梯度里拆出来单独做,它的细节和显存开销(每个参数多存 两份)归 并行训练。
学习率本身也不该一成不变。常见做法是开头几百到几千步线性 warmup(Adam 早期的 估计不准,大步长容易出事),之后按余弦或分段衰减。很多”训不动”的问题,先试把学习率降 3 到 10 倍,往往就好了。
在上面的峡谷函数里,为什么 SGD 不能简单地把学习率调大来加快 x 方向的收敛?
单一学习率对所有方向都一样,稳定性由最陡的方向决定;y 方向曲率 50,学习率超过 0.04 就在 y 上来回放大直到发散,正文 lr=0.041 已跑出 4e5。
x 方向梯度不为 0,只是小;这个例子里梯度是精确的,没有随机噪声;在稳定范围内学习率越大 x 越快,问题恰恰是不能更大。
图像为什么要用卷积
前面所有例子都是全连接层。拿到一张 224×224×3 的图片,全连接层先把它拉平成 150528 维的向量。这么做有两个问题。一是参数量:输出也取同样大小的话,一层就是 量级。二是它扔掉了”像素有邻居”这个信息:把像素顺序打乱,全连接层的学习难度完全不变,但图像的意义全没了。
卷积基于两个关于图像的先验。局部性:一个像素的含义主要由它附近的像素决定,所以每个输出只看一个小窗口(比如 3×3)。平移不变性:一只猫的耳朵出现在左上角还是右下角,识别方法是一样的,所以所有位置共享同一组权重。一个卷积核就是一个在图上滑动的小模板,每个位置算一次点积。多个卷积核得到多个输出通道,每个通道是一张”特征图”。
手写一个 conv2d,和 F.conv2d 对照:
import torch, torch.nn.functional as F
torch.manual_seed(0)
x = torch.randn(1, 3, 5, 5); w = torch.randn(2, 3, 3, 3)
def conv2d(x, w, stride=1, pad=0):
x = F.pad(x, (pad,) * 4)
N, C, H, W = x.shape; O, _, k, _ = w.shape
Ho, Wo = (H - k) // stride + 1, (W - k) // stride + 1
out = torch.zeros(N, O, Ho, Wo)
for i in range(Ho):
for j in range(Wo):
patch = x[:, :, i*stride:i*stride+k, j*stride:j*stride+k]
# 每个输出像素 = 所有输入通道上同一个 kxk 窗口与卷积核的点积
out[:, :, i, j] = torch.einsum('nchw,ochw->no', patch, w)
return out
for s, p in [(1, 0), (1, 1), (2, 1)]:
mine = conv2d(x, w, s, p); ref = F.conv2d(x, w, stride=s, padding=p)
print(f"stride {s} pad {p}: shape {tuple(mine.shape)} max diff {(mine-ref).abs().max():.1e}")
print("Conv2d(3,64,3) params:", sum(p.numel() for p in torch.nn.Conv2d(3, 64, 3).parameters()))
print("dense equivalent weights:", 3*224*224 * 64*222*222)
print("maxpool:", F.max_pool2d(torch.arange(16.).view(1,1,4,4), 2).view(-1).tolist())stride 1 pad 0: shape (1, 2, 3, 3) max diff 9.5e-07
stride 1 pad 1: shape (1, 2, 5, 5) max diff 9.5e-07
stride 2 pad 1: shape (1, 2, 3, 3) max diff 9.5e-07
Conv2d(3,64,3) params: 1792
dense equivalent weights: 474791804928
maxpool: [5.0, 7.0, 13.0, 15.0]输出尺寸公式 :5×5 输入、3×3 核、不填充,输出 3×3;填充 1 圈保持 5×5(“same” 卷积);步长 2 下采样到 3×3。卷积核的形状是 (C_out, C_in, k, k),每个输出通道看所有输入通道。一个 3→64 通道的 3×3 卷积只有 个参数,而把同样的输入输出用全连接实现,需要约 个权重。这两个数之间的差距,就是局部性和权重共享这两条先验的价值。
池化把一个窗口压成一个数,最大池化取最大值:4×4 的 0 到 15,2×2 池化得到每块的最大值 [5, 7, 13, 15]。它没有参数,作用是降分辨率、让特征对小幅平移不敏感。现代网络也常直接用步长 2 的卷积代替池化。
层层堆叠后,感受野逐渐扩大:两层 3×3 卷积的输出,每个像素看到原图 5×5 的区域,三层看到 7×7。VGG 的观察是,三层 3×3 和一层 7×7 感受野相同,参数却更少( 对 ,按每对输入输出通道算),中间还多了两次非线性。浅层的卷积核学到边缘和颜色,深层学到纹理、部件,再到整个物体,这个层级结构是 CNN 可视化里反复看到的现象。
输入 (N, 64, 32, 32),经过 nn.Conv2d(64, 128, kernel_size=3, stride=2, padding=1),输出形状和该层参数量分别是?
尺寸 (32 + 2 - 3) // 2 + 1 = 16;每个输出通道的核覆盖全部 64 个输入通道,所以权重是 128*64*3*3,再加 128 个偏置。
忘了步长会得到 32;不加 padding 才会得到 15;输出通道数由 out_channels=128 决定,不是 64;参数只算 128*3*3 是忽略了输入通道。
经典 CNN 与数据增强
把上面的零件按时间串起来,就是 CNN 的演化史,每一步都在回答”怎么更深”:
| 网络 | 年份 | 深度 | 关键改进 |
|---|---|---|---|
| LeNet | 1998 | 5 层 | 卷积 + 池化 + 全连接的基本骨架,识别手写数字 |
| AlexNet | 2012 | 8 层 | ReLU、dropout、GPU 训练,ImageNet top-5 错误率从约 26% 降到约 16% |
| VGG | 2014 | 16/19 层 | 全部用 3×3 卷积堆叠,结构规整 |
| GoogLeNet | 2014 | 22 层 | Inception 块:多种尺寸卷积并行,1×1 卷积降维 |
| ResNet | 2015 | 50–152 层 | 残差连接 + BatchNorm,深度不再是障碍 |
AlexNet 之所以是转折点,不是因为结构多新,而是数据(ImageNet 一百多万张标注图)、算力(GPU)和几个训练技巧同时到位。ResNet 之后,“网络能不能训深”基本不再是问题,研究重心转向效率(MobileNet 的深度可分离卷积、EfficientNet 的宽度/深度/分辨率联合缩放)。再后来 ViT 把图片切成小块当作 token 送进 Transformer,在大数据量下追平甚至超过 CNN,见 多模态。
网络越深越大,越容易过拟合,而标注数据很贵。数据增强用低成本的方式”造”新样本:随机裁剪、水平翻转、颜色抖动、小角度旋转。它在告诉模型哪些变化不该改变答案,本质上是把关于任务的先验写进数据里。要注意增强必须保持标签不变:识别猫狗时水平翻转没问题,识别数字时把 6 旋转 180° 就变成 9 了。增强只在训练时做,验证和测试用原图(或固定的中心裁剪)。
另一个省数据的办法是迁移学习:拿在 ImageNet 上预训练好的网络,换掉最后的分类层,在自己的小数据集上微调。浅层学到的边缘、纹理对几乎所有视觉任务都通用,不必从头学。这个”先大规模预训练、再小规模微调”的范式后来被 NLP 和大模型完全继承。
给一个手写数字识别任务做数据增强,下面哪个操作最可能伤害模型?
数字不具备左右对称性,翻转后 2、3、7 等会变成不是数字的形状,甚至和其他类混淆,等于给模型喂了错误标签。
小幅平移、小角度旋转、亮度变化都是书写和拍照中真实存在的变化,不改变数字的身份,属于合理的增强。
记住这三件事
- 深网络难训的根源是信号和梯度要连乘几十次:初始化让每层方差保持(ReLU 用
2/n),归一化在训练过程中持续保持它。 - 残差连接给梯度一条不经过权重矩阵的直达通道,“残差主干 + 块内归一化”是从 ResNet 到 GPT 的共同骨架。
- 卷积 = 局部连接 + 权重共享,用两个关于图像的先验把参数量从千亿级降到千级;Adam 则用每参数自适应步长解决曲率不均。