AI 工程基础体系 · 第 8/100 篇。内容覆盖机器学习、深度学习与生成式 AI;模型、数据、评测、权限和成本会作为同一生产系统处理。

深度学习基础:神经网络、反向传播、激活、归一化和泛化

1. 先建立一张完整的概念地图

深度学习模型本质上是在数据上学习一个函数:

y^=fθ(x)\hat{y}=f_\theta(x)

其中:

  • xx 是输入,例如图像像素、文本 token 的向量或结构化特征;
  • y^\hat y 是模型输出,例如分类 logits、回归值或生成模型的下一个 token 概率;
  • θ\theta 是模型参数,包括权重和偏置;
  • fθf_\theta 是由多层参数化运算组成的函数。

训练的目标不是让模型记住某一批样本,而是使它在未见过但来自相同任务分布的数据上表现良好。通常定义损失函数:

L(θ;x,y)\mathcal{L}(\theta; x,y)

再通过训练集上的平均损失近似优化目标:

R^(θ)=1ni=1nL(θ;xi,yi)\hat R(\theta) = \frac{1}{n}\sum_{i=1}^{n} \mathcal{L}(\theta;x_i,y_i)

这里的 R^\hat R 称为经验风险。真正关心的是数据分布上的期望风险:

R(θ)=E(x,y)P[L(θ;x,y)]R(\theta) = \mathbb{E}_{(x,y)\sim P} [ \mathcal{L}(\theta;x,y) ]

训练集损失下降而验证集损失上升,说明模型可能在降低经验风险的同时损害了泛化能力。这正是神经网络、反向传播、激活函数、归一化和泛化之间的共同主线。


2. 神经网络到底是什么

2.1 线性层:最基本的参数化函数

一个全连接层可以写成:

z=Wx+bz = Wx+b

假设:

  • 输入 xRdinx\in\mathbb{R}^{d_{\text{in}}}
  • 输出 zRdoutz\in\mathbb{R}^{d_{\text{out}}}
  • WRdout×dinW\in\mathbb{R}^{d_{\text{out}}\times d_{\text{in}}}
  • bRdoutb\in\mathbb{R}^{d_{\text{out}}}

jj 个输出为:

zj=k=1dinWjkxk+bjz_j=\sum_{k=1}^{d_{\text{in}}}W_{jk}x_k+b_j

线性层可以学习缩放、旋转、投影和线性组合,但多个线性层连续堆叠仍然只是一个线性变换:

W2(W1x+b1)+b2=(W2W1)x+(W2b1+b2)W_2(W_1x+b_1)+b_2 = (W_2W_1)x+(W_2b_1+b_2)

因此,如果中间没有非线性函数,增加层数并不会增加函数表达能力。

2.2 多层感知机

在层之间加入非线性激活函数 σ\sigma,就得到多层感知机:

h(1)=σ(W(1)x+b(1))h^{(1)}=\sigma(W^{(1)}x+b^{(1)})

h(2)=σ(W(2)h(1)+b(2))h^{(2)}=\sigma(W^{(2)}h^{(1)}+b^{(2)})

y^=W(3)h(2)+b(3)\hat y=W^{(3)}h^{(2)}+b^{(3)}

每一层都完成两件事:

  1. 线性变换:重新组合当前表示;
  2. 非线性变换:让后续层能够表示弯曲的决策边界或复杂函数。

“深度”通常指这类可学习变换的层数,而不是参数数量本身。宽度表示某层的神经元数量。深度增加可以形成逐级抽象,例如图像中的边缘、纹理、部件和物体;文本模型中则可能形成从局部 token 关系到长距离语义的表示。

2.3 神经元不是生物神经元的完整模拟

“神经元”通常只是指一个形式:

a=σ(wx+b)a=\sigma(w^\top x+b)

其中 wx+bw^\top x+b 是加权求和,σ\sigma 是激活函数。它是一个数学计算单元,不意味着模型具有生物神经系统的结构、意识或认知机制。


3. 激活函数:为什么需要非线性

激活函数把线性层输出 zz 映射为:

a=σ(z)a=\sigma(z)

它影响模型的表达能力、梯度传播、数值稳定性和训练速度。

3.1 Sigmoid

σ(z)=11+ez\sigma(z)=\frac{1}{1+e^{-z}}

输出范围为 (0,1)(0,1),导数为:

σ(z)=σ(z)(1σ(z))\sigma'(z)=\sigma(z)(1-\sigma(z))

zz 很大或很小时,σ(z)\sigma(z) 接近 1 或 0,导数接近 0。这叫做饱和。多层网络反复相乘这些小导数时,梯度可能迅速变小,形成梯度消失。

Sigmoid 仍然适合二分类输出层,但通常不直接把它放在隐藏层中。二分类常用:

logits = model(x)                 # 不先 sigmoid
loss = torch.nn.functional.binary_cross_entropy_with_logits(
    logits, targets.float()
)

BCEWithLogitsLoss 将 sigmoid 和二元交叉熵合并,并采用更稳定的数值计算。训练时不应先手动 sigmoid 再传给这个损失函数,否则会重复计算。

3.2 Tanh

tanh(z)=ezezez+ez\tanh(z)=\frac{e^z-e^{-z}}{e^z+e^{-z}}

输出范围为 (1,1)(-1,1),在 z=0z=0 附近梯度较大,且输出居中于 0。但它在绝对值较大时同样会饱和。

3.3 ReLU

ReLU(z)=max(0,z)\operatorname{ReLU}(z)=\max(0,z)

其导数在通常实现中为:

ReLU(z)={1,z>00,z<0\operatorname{ReLU}'(z)= \begin{cases} 1,&z>0\\ 0,&z<0 \end{cases}

ReLU 的优点是正区间梯度不随输入变小,计算简单。问题是“死亡 ReLU”:如果某个神经元长期处于 z<0z<0,它的梯度为 0,参数可能不再更新。

例如某神经元的权重因为一次过大的学习率更新后,使所有训练样本都满足 Wx+b<0Wx+b<0,那么该神经元在后续训练中可能一直没有梯度。减小学习率、使用合理初始化或改用 Leaky ReLU 可以降低这种风险。

3.4 Leaky ReLU 和 GELU

Leaky ReLU 在负区间保留一个较小斜率:

LeakyReLU(z)={z,z0αz,z<0\operatorname{LeakyReLU}(z)= \begin{cases} z,&z\ge 0\\ \alpha z,&z<0 \end{cases}

其中 α\alpha 是较小的正数。

GELU 可以理解为根据输入大小进行平滑门控,常见近似为:

GELU(x)x2[1+tanh(2π(x+0.044715x3))]\operatorname{GELU}(x) \approx \frac{x}{2} \left[ 1+\tanh\left( \sqrt{\frac{2}{\pi}} \left(x+0.044715x^3\right) \right) \right]

Transformer 和许多现代语言模型经常使用 GELU 或其变体。它不是因为“必然优于所有激活函数”,而是与特定网络结构、初始化和优化设置共同形成了有效组合。

3.5 Softmax:通常用于输出概率,而非隐藏层

对分类 logits zRCz\in\mathbb{R}^C,Softmax 为:

pi=ezij=1Cezjp_i=\frac{e^{z_i}}{\sum_{j=1}^{C}e^{z_j}}

它将 logits 转换为和为 1 的类别概率。实际计算常先减去最大值:

pi=ezimax(z)jezjmax(z)p_i= \frac{e^{z_i-\max(z)}}{ \sum_j e^{z_j-\max(z)} }

因为减去同一个常数不会改变结果,但能避免 ezie^{z_i} 溢出。

多分类训练通常直接使用:

loss = torch.nn.functional.cross_entropy(logits, labels)

其中 logits 的形状通常是 [batch_size, num_classes]labels 是形状 [batch_size] 的整数类别索引。不要在传入 CrossEntropyLoss 前手动执行 Softmax,因为该损失函数已经包含了适合数值稳定计算的 log_softmax 和负对数似然。


4. 前向传播:从输入到损失

一次前向传播可以分为以下步骤:

  1. 读取一批输入 xx 和标签 yy
  2. 依次执行每一层的线性变换、激活和归一化;
  3. 得到模型输出 y^\hat y 或 logits;
  4. 用标签计算损失;
  5. 保存反向传播所需要的中间结果。

以一个隐藏层网络为例:

z1=W1x+b1z_1=W_1x+b_1

a1=σ(z1)a_1=\sigma(z_1)

z2=W2a1+b2z_2=W_2a_1+b_2

如果是回归任务,可以直接令 y^=z2\hat y=z_2。如果是多分类任务,z2z_2 是 logits,之后由交叉熵处理。

前向传播不仅产生输出,也构建了一个计算图。计算图记录了张量之间的依赖关系,例如:

xz1a1z2Lx \rightarrow z_1 \rightarrow a_1 \rightarrow z_2 \rightarrow L

PyTorch 的 Autograd 会利用这些依赖,在反向传播时自动应用链式法则。


5. 反向传播:链式法则如何得到梯度

5.1 梯度的含义

参数 θ\theta 的梯度:

θL\nabla_\theta L

表示参数发生微小变化时,损失如何变化。对于单个参数:

Lθ>0\frac{\partial L}{\partial \theta}>0

意味着增大该参数会使损失倾向于增大;反之则倾向于降低损失。因此梯度下降更新为:

θθηθL\theta\leftarrow\theta-\eta\nabla_\theta L

其中 η\eta 是学习率。

反向传播不是另一种损失函数,也不是“从输出倒推输入”的预测过程,而是高效计算损失对所有参数偏导数的算法。

5.2 一个完整数值算例

考虑一个输入为 x=2x=2 的单隐藏神经元网络:

z1=w1x+b1z_1=w_1x+b_1

a1=sigmoid(z1)a_1=\operatorname{sigmoid}(z_1)

y^=w2a1+b2\hat y=w_2a_1+b_2

使用损失:

L=12(y^y)2L=\frac{1}{2}(\hat y-y)^2

设:

  • w1=0.5w_1=0.5
  • b1=0b_1=0
  • w2=1w_2=1
  • b2=0b_2=0
  • 目标 y=1y=1

第一步:前向计算

z1=0.5×2+0=1z_1=0.5\times2+0=1

a1=sigmoid(1)0.7310586a_1=\operatorname{sigmoid}(1)\approx0.7310586

y^=1×0.7310586+0=0.7310586\hat y=1\times0.7310586+0=0.7310586

L=12(0.73105861)20.036164L=\frac12(0.7310586-1)^2 \approx0.036164

第二步:从损失向输出层反向传播

Ly^=y^y=0.2689414\frac{\partial L}{\partial \hat y} = \hat y-y = -0.2689414

因为:

y^=w2a1+b2\hat y=w_2a_1+b_2

所以:

Lw2=Ly^y^w2=0.2689414×0.73105860.1966119\frac{\partial L}{\partial w_2} = \frac{\partial L}{\partial \hat y} \frac{\partial\hat y}{\partial w_2} = -0.2689414\times0.7310586 \approx-0.1966119

Lb2=Ly^=0.2689414\frac{\partial L}{\partial b_2} = \frac{\partial L}{\partial \hat y} = -0.2689414

第三步:继续穿过隐藏层

La1=Ly^y^a1=0.2689414×1=0.2689414\frac{\partial L}{\partial a_1} = \frac{\partial L}{\partial\hat y} \frac{\partial\hat y}{\partial a_1} = -0.2689414\times1 = -0.2689414

Sigmoid 在 z1=1z_1=1 处的导数为:

a1z1=a1(1a1)0.1966119\frac{\partial a_1}{\partial z_1} = a_1(1-a_1) \approx0.1966119

因此:

Lz1=La1a1z10.2689414×0.19661190.052877\frac{\partial L}{\partial z_1} = \frac{\partial L}{\partial a_1} \frac{\partial a_1}{\partial z_1} \approx -0.2689414\times0.1966119 \approx-0.052877

由于:

z1=w1x+b1z_1=w_1x+b_1

得到:

Lw1=Lz1x0.105754\frac{\partial L}{\partial w_1} = \frac{\partial L}{\partial z_1}x \approx-0.105754

Lb1=Lz10.052877\frac{\partial L}{\partial b_1} = \frac{\partial L}{\partial z_1} \approx-0.052877

第四步:执行一次学习率为 0.10.1 的更新

w1=0.50.1(0.105754)0.510575w_1'=0.5-0.1(-0.105754) \approx0.510575

b1=00.1(0.052877)0.005288b_1'=0-0.1(-0.052877) \approx0.005288

w2=10.1(0.196612)1.019661w_2'=1-0.1(-0.196612) \approx1.019661

b2=00.1(0.268941)0.026894b_2'=0-0.1(-0.268941) \approx0.026894

这次更新让输出更接近目标 1。实际训练中,每个参数的梯度通常来自一个 batch 的平均损失,而不是单个样本。

5.3 矩阵形式的反向传播

对于:

Z=XW+bZ=XW+b

其中 XX 是形状 [batch, input_dim] 的矩阵,若上游梯度为:

G=LZG=\frac{\partial L}{\partial Z}

则:

LW=XG\frac{\partial L}{\partial W}=X^\top G

Lb=batchG\frac{\partial L}{\partial b}=\sum_{\text{batch}}G

LX=GW\frac{\partial L}{\partial X}=GW^\top

这解释了为什么 batch 训练可以通过矩阵乘法高效运行,也解释了张量形状错误为何常常发生在矩阵乘法边界。

5.4 PyTorch 中的梯度生命周期

典型训练步骤为:

optimizer.zero_grad()
logits = model(x)
loss = criterion(logits, y)
loss.backward()
optimizer.step()

每一步的意义是:

  1. zero_grad():清除参数上一步保存的梯度;
  2. model(x):构建本次前向计算图;
  3. criterion(...):得到标量损失;
  4. backward():沿计算图计算梯度;
  5. step():根据梯度更新参数。

PyTorch 默认会累加梯度。如果忘记 zero_grad(),当前梯度会与上一步相加,通常导致训练不稳定。

验证和推理不需要构建反向图:

model.eval()

with torch.no_grad():
    logits = model(x)

model.eval()torch.no_grad() 作用不同:

  • eval() 改变 Dropout、BatchNorm 等模块的行为;
  • no_grad() 关闭梯度记录,降低内存和计算开销。

只调用其中一个并不能替代另一个。


6. 归一化:控制表示的尺度与分布

“归一化”不是单一算法。它至少包括输入特征缩放、BatchNorm、LayerNorm、RMSNorm 等不同机制。它们的归一化维度、统计范围和推理行为不同。

6.1 输入特征标准化

对某个特征使用训练集统计量:

x=xμtrainσtrain2+ϵx'=\frac{x-\mu_{\text{train}}} {\sqrt{\sigma^2_{\text{train}}+\epsilon}}

其中:

  • μtrain\mu_{\text{train}} 是训练集均值;
  • σtrain2\sigma^2_{\text{train}} 是训练集方差;
  • ϵ\epsilon 防止除零。

关键点是:均值和方差只能使用训练集计算。若把验证集或测试集的数据也用于计算统计量,就把评测信息泄漏进了训练流程。

归一化不会自动解决所有问题。例如异常值严重时,均值和方差可能被少数异常点主导;此时可以考虑稳健缩放,但具体方法必须与业务数据分布匹配。

6.2 BatchNorm

对一个 batch 的激活进行归一化。对某个通道,训练阶段通常计算:

μB=1mi=1mxi\mu_B=\frac{1}{m}\sum_{i=1}^m x_i

σB2=1mi=1m(xiμB)2\sigma_B^2=\frac{1}{m}\sum_{i=1}^m(x_i-\mu_B)^2

x^i=xiμBσB2+ϵ\hat x_i= \frac{x_i-\mu_B} {\sqrt{\sigma_B^2+\epsilon}}

再进行可学习的缩放和平移:

yi=γx^i+βy_i=\gamma\hat x_i+\beta

γ\gammaβ\beta 让网络不必被限制在固定均值和方差上。

训练阶段使用当前 batch 的统计量,并通常更新运行均值和运行方差;推理阶段则使用这些运行统计量。由此产生两个常见错误:

  • 训练或验证时忘记切换 train()/eval()
  • batch 太小,当前 batch 统计量噪声很大,导致训练或推理不稳定。

6.3 LayerNorm

LayerNorm 通常对单个样本的特征维度归一化。对 xRdx\in\mathbb{R}^d

μ=1dj=1dxj\mu=\frac1d\sum_{j=1}^d x_j

σ2=1dj=1d(xjμ)2\sigma^2=\frac1d\sum_{j=1}^d(x_j-\mu)^2

LN(x)j=γjxjμσ2+ϵ+βj\operatorname{LN}(x)_j = \gamma_j \frac{x_j-\mu} {\sqrt{\sigma^2+\epsilon}} +\beta_j

它不依赖 batch 维度,因此 batch 很小、序列长度变化或自回归推理时通常更方便。Transformer 常在注意力和前馈子层周围使用 LayerNorm 或 RMSNorm。

6.4 RMSNorm

RMSNorm 不减去均值,只按均方根缩放:

RMS(x)=1dj=1dxj2+ϵ\operatorname{RMS}(x) = \sqrt{\frac1d\sum_{j=1}^d x_j^2+\epsilon}

RMSNorm(x)j=γjxjRMS(x)\operatorname{RMSNorm}(x)_j = \gamma_j\frac{x_j}{\operatorname{RMS}(x)}

它计算较简单,但与 LayerNorm 不是等价替换。选择哪一种,取决于模型架构、训练稳定性和实验结果。

6.5 Pre-Norm 和 Post-Norm

以 Transformer 子层 F(x)F(x) 为例:

Post-Norm:

y=LN(x+F(x))y=\operatorname{LN}(x+F(x))

Pre-Norm:

y=x+F(LN(x))y=x+F(\operatorname{LN}(x))

两者都会使用残差连接,但归一化位置不同。Pre-Norm 常被用于较深的 Transformer,因为残差路径更直接,优化深层网络时通常更容易稳定;这不是一个脱离架构和训练配置的绝对保证。


7. 归一化与反向传播的关系

归一化不仅改变前向数值,也改变梯度传播路径。

以简单标准化:

x^i=xiμσ2+ϵ\hat x_i=\frac{x_i-\mu}{\sqrt{\sigma^2+\epsilon}}

为例,μ\muσ2\sigma^2 本身依赖整个 batch 或特征维度。因此某个样本的输出不仅依赖自己的输入,也依赖同一统计组中的其他输入。反向传播时,梯度会沿着均值和方差的计算路径传播给同组的多个元素。

这带来两个后果:

  1. 归一化可以让激活尺度更稳定,降低某些层因数值过大导致的优化困难;
  2. 归一化统计量可能引入样本间耦合,batch 组成、batch 大小和训练/推理模式会影响结果。

因此,归一化不是简单的“把数字变成 0 到 1”。必须明确:

  • 归一化在哪个维度进行;
  • 统计量由哪些样本计算;
  • 训练和推理是否使用不同统计量;
  • 统计量是否会造成数据泄漏;
  • 混合精度下是否存在数值精度问题。

8. 泛化:模型为什么能处理未见样本

8.1 经验风险与真实风险

训练集上的低损失只能说明模型适合训练样本:

R^(θ) 小\hat R(\theta)\text{ 小}

泛化要求真实风险也较小:

R(θ) 小R(\theta)\text{ 小}

二者的差:

R(θ)R^(θ)R(\theta)-\hat R(\theta)

可以理解为泛化间隙。它受模型复杂度、数据量、噪声、训练过程和数据分布变化影响。

8.2 欠拟合与过拟合

欠拟合通常表现为:

  • 训练损失仍然很高;
  • 训练集和验证集指标都不好;
  • 增加训练轮数未必解决;
  • 可能是模型容量不足、特征不足、优化失败或标签问题。

过拟合通常表现为:

  • 训练损失持续下降;
  • 验证损失先下降后上升;
  • 训练指标远好于验证指标;
  • 模型对训练样本中的噪声、重复模式或偶然相关性过度依赖。

一个重要反例是:训练集准确率 100% 并不证明模型“理解”了任务。如果训练集包含重复用户、同一视频的相邻帧,或者标签泄漏,模型可能只是识别了数据来源或记住了近邻样本。

8.3 数据划分比许多正则化技巧更基础

训练集、验证集和测试集承担不同职责:

  • 训练集:更新参数;
  • 验证集:选择模型、超参数和停止轮次;
  • 测试集:在决策基本完成后进行最终评估。

时间序列应按时间切分,用户相关数据应按用户切分,医学或设备数据应考虑按患者、设备或地点切分。随机逐行切分可能把同一个实体的高度相似记录分散到训练和测试中,导致指标虚高。

数据预处理也必须遵守切分边界。错误流程是:

  1. 合并训练集和测试集;
  2. 计算全量均值、方差、词表或特征选择结果;
  3. 再切分数据。

正确流程是只在训练集拟合预处理器,再把同一组参数应用到验证集和测试集。

8.4 正则化的作用与边界

正则化是限制模型过度依赖训练数据的方法。

权重衰减在目标中加入:

λθ22\lambda\|\theta\|_2^2

传统 SGD 中,这相当于对参数施加收缩。对 Adam 类优化器,直接在损失中加入 L2 惩罚与解耦权重衰减并不完全相同;AdamW 的设计是将权重衰减从自适应梯度更新中解耦。不能把“L2 正则化”和“所有优化器下的 weight decay”简单视为完全相同。

Dropout 在训练时随机将部分激活置零,并对保留激活进行缩放;推理时关闭随机丢弃。它改变了训练时的网络子结构,通常能降低共适应,但会增加训练噪声。

早停 在验证集指标不再改善时停止训练。它相当于限制优化过程继续拟合训练集,但如果频繁根据测试集调整停止点,就会把测试集变成验证集,产生评测泄漏。

数据增强 通过保持标签语义的变换扩充训练分布。增强必须满足任务不变性。例如图像分类中水平翻转可能合理,但医学影像或文字方向任务中不一定合理。

8.5 深度网络为何能在过参数化下泛化

现代神经网络参数量可能大于训练样本数量,却仍能在实际任务上泛化。这并不意味着参数越多越好,也不意味着经典“参数少才不容易过拟合”的直觉完全失效。

实际结果同时受到以下因素影响:

  • 数据的结构和冗余;
  • 参数初始化;
  • 优化器和学习率;
  • 训练时间;
  • 归一化和残差结构;
  • 数据增强;
  • 目标函数和标签噪声;
  • 模型对简单模式的偏好,即隐式正则化。

因此,参数数量只是容量的一个粗略指标。应通过独立验证集、跨时间或跨实体评测来观察实际泛化,而不是仅根据参数量推断。


9. 分类、回归和生成任务中的输出解释

9.1 多分类

模型输出 logits:

zRCz\in\mathbb{R}^C

交叉熵为:

L=logezyj=1CezjL=-\log \frac{e^{z_y}}{\sum_{j=1}^C e^{z_j}}

其中 yy 是正确类别索引。logits 的差值决定概率比:

pipj=ezizj\frac{p_i}{p_j}=e^{z_i-z_j}

所以整体给所有 logits 加同一个常数不会改变 Softmax 概率。

9.2 回归

回归输出通常是任意实数,常用均方误差:

L=1ni(y^iyi)2L=\frac1n\sum_i(\hat y_i-y_i)^2

对异常值敏感。如果标签噪声或异常点较多,可以考虑 MAE、Huber Loss 等,但必须根据业务损失解释选择,而不是只看训练曲线。

9.3 生成式 AI

语言模型通常输出每个位置下一个 token 的 logits:

ztRVz_t\in\mathbb{R}^{|V|}

训练目标是交叉熵:

L=tlogpθ(xtx<t)L=-\sum_t\log p_\theta(x_t\mid x_{<t})

训练阶段可以并行计算整段序列的 next-token loss;生成阶段则是自回归地将上一步输出 token 追加到上下文,再预测下一步。训练和生成的数据流不同,评测也不能只使用 token-level loss,还应考虑事实性、安全性、任务完成率、延迟和成本。


10. 一个可运行的 PyTorch 端到端示例

下面的例子使用合成二维数据完成二分类,包含:

  • 训练集和验证集划分;
  • 只用训练集计算输入标准化统计量;
  • DatasetDataLoader 和训练循环;
  • LayerNormGELUDropout
  • CrossEntropyLoss
  • 验证阶段的 eval()no_grad()
  • 保存和恢复检查点。

前置条件是安装 PyTorch。官方 API 的具体细节应以当前 PyTorch 文档和安装版本为准。

import random
from pathlib import Path

import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset


def set_seed(seed: int = 42) -> None:
    random.seed(seed)
    torch.manual_seed(seed)
    if torch.cuda.is_available():
        torch.cuda.manual_seed_all(seed)


def build_data(n: int = 2000):
    """
    构造两个二维高斯簇:
    y=0 的中心约为 (-1, -1)
    y=1 的中心约为 ( 1,  1)
    """
    x0 = torch.randn(n // 2, 2) + torch.tensor([-1.0, -1.0])
    x1 = torch.randn(n - n // 2, 2) + torch.tensor([1.0, 1.0])

    x = torch.cat([x0, x1], dim=0)
    y = torch.cat([
        torch.zeros(len(x0), dtype=torch.long),
        torch.ones(len(x1), dtype=torch.long),
    ])

    permutation = torch.randperm(len(x))
    return x[permutation], y[permutation]


class MLP(nn.Module):
    def __init__(self, input_dim: int = 2, num_classes: int = 2):
        super().__init__()
        self.network = nn.Sequential(
            nn.Linear(input_dim, 32),
            nn.LayerNorm(32),
            nn.GELU(),
            nn.Dropout(p=0.10),

            nn.Linear(32, 32),
            nn.LayerNorm(32),
            nn.GELU(),
            nn.Dropout(p=0.10),

            nn.Linear(32, num_classes),
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        if x.ndim != 2 or x.shape[1] != 2:
            raise ValueError(
                f"expected x with shape [batch, 2], got {tuple(x.shape)}"
            )
        return self.network(x)


def evaluate(model, loader, criterion, device):
    model.eval()
    total_loss = 0.0
    total_correct = 0
    total_count = 0

    with torch.no_grad():
        for x, y in loader:
            x = x.to(device)
            y = y.to(device)

            logits = model(x)
            loss = criterion(logits, y)

            total_loss += loss.item() * len(y)
            total_correct += (logits.argmax(dim=1) == y).sum().item()
            total_count += len(y)

    return total_loss / total_count, total_correct / total_count


def main():
    set_seed(42)

    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    print("device:", device)

    x, y = build_data()

    # 先划分数据,再计算标准化统计量。
    # 这样验证集不会参与预处理器拟合。
    n_train = int(len(x) * 0.8)
    x_train, y_train = x[:n_train], y[:n_train]
    x_valid, y_valid = x[n_train:], y[n_train:]

    train_mean = x_train.mean(dim=0)
    train_std = x_train.std(dim=0).clamp_min(1e-6)

    x_train = (x_train - train_mean) / train_std
    x_valid = (x_valid - train_mean) / train_std

    train_loader = DataLoader(
        TensorDataset(x_train, y_train),
        batch_size=64,
        shuffle=True,
    )
    valid_loader = DataLoader(
        TensorDataset(x_valid, y_valid),
        batch_size=256,
        shuffle=False,
    )

    model = MLP().to(device)
    criterion = nn.CrossEntropyLoss()
    optimizer = torch.optim.AdamW(
        model.parameters(),
        lr=1e-3,
        weight_decay=1e-4,
    )

    best_valid_loss = float("inf")
    checkpoint_path = Path("mlp_checkpoint.pt")

    for epoch in range(30):
        model.train()
        train_loss_sum = 0.0
        train_count = 0

        for batch_x, batch_y in train_loader:
            batch_x = batch_x.to(device)
            batch_y = batch_y.to(device)

            optimizer.zero_grad(set_to_none=True)

            logits = model(batch_x)
            loss = criterion(logits, batch_y)

            if not torch.isfinite(loss):
                raise FloatingPointError(
                    f"non-finite loss at epoch {epoch + 1}: {loss.item()}"
                )

            loss.backward()

            # 防止偶发的异常梯度破坏参数。
            torch.nn.utils.clip_grad_norm_(
                model.parameters(),
                max_norm=1.0,
            )

            optimizer.step()

            train_loss_sum += loss.item() * len(batch_y)
            train_count += len(batch_y)

        train_loss = train_loss_sum / train_count
        valid_loss, valid_acc = evaluate(
            model, valid_loader, criterion, device
        )

        print(
            f"epoch={epoch + 1:02d} "
            f"train_loss={train_loss:.4f} "
            f"valid_loss={valid_loss:.4f} "
            f"valid_acc={valid_acc:.4f}"
        )

        if valid_loss < best_valid_loss:
            best_valid_loss = valid_loss
            torch.save(
                {
                    "model_state": model.state_dict(),
                    "train_mean": train_mean,
                    "train_std": train_std,
                    "epoch": epoch + 1,
                    "valid_loss": valid_loss,
                },
                checkpoint_path,
            )

    # 恢复验证集表现最好的参数。
    if not checkpoint_path.exists():
        raise FileNotFoundError(
            f"checkpoint was not created: {checkpoint_path}"
        )

    checkpoint = torch.load(
        checkpoint_path,
        map_location=device,
    )
    model.load_state_dict(checkpoint["model_state"])
    model.eval()

    final_valid_loss, final_valid_acc = evaluate(
        model, valid_loader, criterion, device
    )
    print(
        "best checkpoint:",
        f"epoch={checkpoint['epoch']}, "
        f"valid_loss={final_valid_loss:.4f}, "
        f"valid_acc={final_valid_acc:.4f}",
    )


if __name__ == "__main__":
    main()

代码中几个容易被忽略的因果关系

LayerNorm 作用于隐藏表示,而输入标准化作用于原始特征。两者不能互相替代:前者是模型内部结构的一部分,后者是数据预处理的一部分。

Dropout 只在 model.train() 下随机丢弃激活。验证阶段如果忘记执行 model.eval(),同一个输入多次评估可能得到不同结果,验证指标也会产生额外噪声。

模型输出的是两个 logits,而不是两个概率。CrossEntropyLoss 内部处理了 Softmax 相关计算,所以输出层没有显式 Softmax。

检查点不仅保存模型参数,还保存了输入标准化所需的 train_meantrain_std。如果部署时只恢复权重而忘记恢复预处理状态,线上输入尺度会与训练时不一致。


11. 训练失败时如何从机制定位问题

11.1 损失一开始就是 NaN 或 Inf

常见原因包括:

  • 学习率过大;
  • 输入或标签中已有 NaN/Inf;
  • 手动计算指数、对数或 Softmax 时数值溢出;
  • 混合精度下某些操作溢出;
  • 梯度爆炸;
  • 归一化方差接近 0 且没有合适的 ϵ\epsilon

诊断顺序应先检查输入、标签和第一步前向输出:

print(torch.isfinite(x).all())
print(torch.isfinite(logits).all())
print(torch.isfinite(loss).all())

然后检查梯度:

for name, parameter in model.named_parameters():
    if parameter.grad is not None:
        print(name, parameter.grad.norm().item())

不要一开始就同时更换模型、优化器、损失和数据处理,否则无法判断真正原因。梯度裁剪可以限制异常梯度的影响,但不能替代对 NaN 来源的定位。

11.2 训练损失不下降

可能是:

  • 标签编码错误;
  • 输出维度与损失函数要求不匹配;
  • 忘记调用 optimizer.step()
  • 参数被冻结或没有加入优化器;
  • 学习率过小;
  • 激活长期饱和;
  • 数据和标签没有对应关系;
  • 归一化把有效信号错误地消除了。

一个实用的最小验证是:取很小的数据集,例如 8 到 32 个样本,尝试让模型过拟合它们。如果连小样本都无法拟合,优先怀疑实现、标签、形状或优化流程,而不是泛化能力。

11.3 训练很好、验证很差

这更像过拟合或分布问题,而不一定是“模型太大”。应检查:

  • 训练和验证预处理是否完全一致;
  • 是否存在重复实体跨集合泄漏;
  • 标签定义是否在两个集合中不同;
  • 验证集是否来自不同时间、地域或用户群;
  • 训练时是否使用了增强而验证时没有相应处理;
  • 评估时是否调用了 eval()

如果训练和验证数据分布确实不同,增加 Dropout 只能缓解部分方差,不能解决分布偏移。此时需要重新定义数据切分、补充目标分布数据或改变任务建模方式。


12. 混合精度、批次与稳定性

混合精度通常让部分计算使用低精度类型,同时让敏感操作或参数更新保持更高精度。它可以降低显存占用并提高部分硬件上的吞吐,但不是无条件安全的优化。

在 PyTorch 中,自动混合精度 API 和推荐写法会随版本变化,应以当前文档为准。使用时需要特别注意:

  • loss 是否出现 NaN;
  • 梯度缩放器是否正确处理溢出;
  • 归一化、归约和 Softmax 等操作的数值稳定性;
  • 保存和恢复检查点时是否同时保存优化器状态与梯度缩放状态;
  • 不同硬件上的 dtype 支持差异。

batch size 也会影响机制:

  • 较大的 batch 通常让梯度估计更稳定,但占用更多显存;
  • 较小的 batch 梯度噪声更大,BatchNorm 统计量也更不可靠;
  • 梯度累积可以在显存有限时模拟较大的有效 batch,但它不是完全等价的,因为 Dropout、BatchNorm 和优化器状态更新仍可能不同。

有效 batch size 还会影响学习率、训练步数和成本。工程上应同时记录样本数、优化器更新次数、吞吐、显存峰值和验证指标,而不只记录 epoch。


13. 从实验模型到生产系统

深度学习系统的可用性不只由模型参数决定。一个完整的数据流通常是:

flowchart LR
    A[原始数据] --> B[权限与数据治理]
    B --> C[数据切分]
    C --> D[训练预处理器]
    D --> E[训练集]
    C --> F[验证集]
    C --> G[测试集]
    E --> H[前向传播]
    H --> I[损失]
    I --> J[反向传播]
    J --> K[优化器更新]
    K --> L[检查点]
    L --> M[离线评测]
    M --> N[部署推理]
    N --> O[线上监控]
    O --> P[回滚或重新训练]

关键路径中,权限控制必须早于数据读取和特征生成。生产系统应明确:

  • 哪些主体可以读取原始数据;
  • 训练任务能否访问个人信息或敏感字段;
  • 检查点、日志和错误样本是否包含敏感内容;
  • 模型服务账户是否拥有不必要的存储或数据库权限;
  • 推理请求和生成结果的保留期限。

模型、数据、评测、权限和成本应作为一个系统共同管理。一个精度更高但推理延迟、显存或调用成本无法接受的模型,不一定适合生产;一个训练指标很高但数据权限不可审计的流程,也不是真正可交付的流程。

13.1 检查点不是只有权重

完整恢复训练通常需要保存:

  • 模型参数;
  • 优化器状态;
  • 学习率调度器状态;
  • 当前 epoch 或全局 step;
  • 随机数状态;
  • 混合精度梯度缩放状态;
  • 预处理器统计量;
  • 代码、配置和数据版本标识。

只保存模型权重可以支持部分推理场景,但不能保证从中断处精确恢复训练。加载不可信来源的检查点时,还应考虑反序列化安全风险,并限制文件来源和访问权限。

13.2 评测必须与部署输入一致

如果离线评测使用了与线上不同的:

  • tokenization;
  • 特征标准化参数;
  • 缺失值处理;
  • 类别映射;
  • 截断长度;
  • 权重或阈值;

那么离线指标不能代表线上表现。分类任务还应根据业务代价选择阈值,并检查混淆矩阵、不同群体指标和概率校准,而不是只看准确率。

13.3 成本是训练和推理路径的属性

训练成本大致受以下因素共同影响:

成本样本数×训练步数×每步计算量\text{成本} \propto \text{样本数} \times \text{训练步数} \times \text{每步计算量}

推理成本还取决于:

  • 模型参数量;
  • 输入和输出 token 数;
  • batch 与并发;
  • 显存容量;
  • 量化或混合精度;
  • 缓存命中率;
  • 失败重试策略。

降低成本不应只压缩模型,也可能通过减少无效数据、改进批处理、缓存共享前缀、缩短上下文或使用更合适的评测频率实现。任何优化都需要重新验证精度、稳定性和安全边界。


14. 常见误解与反例

误解一:层越深,模型一定越强

没有激活函数时,多层线性层仍等价于一个线性层。即便有激活函数,深度增加也可能造成优化困难、过拟合或推理成本上升。残差连接、合理归一化和初始化解决的是部分优化问题,不是能力的自动保证。

误解二:归一化后所有数据都变成 0 到 1

标准化通常产生均值接近 0、标准差接近 1 的数据,输出可能是负数,也可能超过 1。Min-Max 缩放才通常映射到固定区间,但它对异常值更敏感。

误解三:训练准确率高就说明模型可靠

训练准确率只描述训练数据。若数据泄漏、样本重复、标签偏差或分布变化存在,训练准确率可以很高而实际效果很差。

误解四:Dropout 在推理时也应该保留

如果推理时保留 Dropout,输出会随机变化,通常会降低确定性和可复现性。特殊的不确定性估计方法可能会故意在推理时启用随机性,但那是明确设计的评估方法,不是普通推理的默认行为。

误解五:更大的 batch 总是更好

大 batch 能降低梯度噪声,却可能需要调整学习率和训练步数,也可能削弱某些训练噪声带来的正则化效果。BatchNorm 还会受到 batch 统计量变化影响。batch size 是优化和系统资源之间的共同参数。

误解六:验证集越多次使用,指标仍然是客观的

如果反复根据验证集结果修改架构、超参数和数据处理,验证集也会逐渐被“适配”。最终应使用未参与决策的测试集,或采用时间后移、跨实体等更严格的外部评测。


15. 学完这些基础后应能回答的问题

一个可靠的深度学习训练流程至少应能解释:

  1. 输入和标签的形状分别是什么,损失函数要求什么格式;
  2. 每个线性层的矩阵乘法维度是否成立;
  3. 激活函数为什么放在该位置,它对梯度有什么影响;
  4. 归一化的统计量在哪个维度、由哪些数据计算;
  5. 训练阶段和推理阶段哪些模块行为不同;
  6. backward() 计算了哪些梯度,step() 如何使用它们;
  7. 训练损失、验证损失和最终测试指标分别回答什么问题;
  8. 指标下降来自模型能力、数据泄漏、评测偏差还是分布变化;
  9. 检查点是否足以恢复推理或继续训练;
  10. 模型在实际权限、延迟、显存和成本约束下是否可部署。

神经网络提供函数表达能力,激活函数提供非线性,反向传播高效计算参数梯度,归一化改善特定条件下的数值和优化行为,而泛化决定模型能否超越训练样本。只有把这些机制放进同一条数据、训练、评测和部署链路中,深度学习模型才不仅是一个能运行的程序,也是一个可解释、可验证和可维护的工程系统。


系列导航与关联阅读

官方资料

本文依据研究论文、标准组织与主流框架官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。