关于AI归一化的几点记录

先说清楚这次为什么写:模型训练到第 30 轮,loss 突然从 0.3 跳到 800,然后整串 NaN。

看日志确认了几个现象:梯度在某个 batch 突然放大、学习率不算大(1e-4)、网络深度也不夸张(8 层)。

训练又炸了

先说清楚这次为什么写:模型训练到第 30 轮,loss 突然从 0.3 跳到 800,然后整串 NaN。

这不是第一次,但这次我决定停下来仔细看看。

看日志确认了几个现象:梯度在某个 batch 突然放大、学习率不算大(1e-4)、网络深度也不夸张(8 层)。真正的问题在数据:某个特征的范围是 0-10000,另一个是 0-1,还有个是正负分布。

不同特征量级差太多,权重更新时根本不平衡——大数值的特征会抢走梯度,小数值的特征几乎不动。这个判断其实早就知道,但每次都是简单除以最大值,这次打算彻底梳理一下归一化的完整实践链路。

问题是什么

简单用两句话把问题说清楚:

  1. 数据未归一化:不同特征量级差异过大,导致权重更新不平衡、梯度爆炸/消失
  2. 内部协变量偏移:网络深层输入分布不断变化,每层都要重新适应,训练慢且不稳定

这两个问题在深层网络、大 batch 训练、复杂特征分布时会尤其明显。

先把数据归一化做了

最基础的一步是把输入数据拉到一个合理的范围。

Min-Max 归一化

把特征线性映射到 [0, 1] 或 [-1, 1]:

def minmax_normalize(X, feature_range=(0, 1)):
    min_val = X.min(axis=0)
    max_val = X.max(axis=0)
    normalized = (X - min_val) / (max_val - min_val)
    return normalized * (feature_range[1] - feature_range[0]) + feature_range[0]

这个方法适合特征分布比较均匀、没有明显离群值的情况。但如果有极端值,比如大部分样本在 0-10 之间,突然来个 10000,几乎所有样本都会被压缩到很小的区间。

Z-Score 标准化

减去均值,除以标准差:

def zscore_normalize(X):
    mean = X.mean(axis=0)
    std = X.std(axis=0)
    return (X - mean) / std

这个对离群值更鲁棒一些,因为它是基于分布的统计量而不是极值。标准化后的数据均值为 0,标准差为 1,大部分值会落在 -3 到 3 之间。

踩坑记录

实际使用时遇到了几个问题:

  1. 训练/测试不一致:如果在训练集上算均值和标准差,但测试集用了不同的统计量,分布就对不上了。正确做法是把训练集的统计量保存下来,测试集直接用。
# 训练时
train_mean = X_train.mean(axis=0)
train_std = X_train.std(axis=0)

X_train_norm = (X_train - train_mean) / train_std

# 测试时
X_test_norm = (X_test - train_mean) / train_std  # 用训练集的统计量
  1. 标准差为 0:某个特征在训练集上全部是同一个值,标准差就是 0,除法会爆炸。需要加一个小的 epsilon:
eps = 1e-8
X_norm = (X - mean) / (std + eps)
  1. Online 场景:如果数据是流式的,不能一次性拿到全部,就得用滑动窗口或增量更新的统计量。这会比全量统计更复杂,但在实时系统中常常是必须的。

BatchNorm:把归一化搬进网络

数据预处理解决了输入层的分布问题,但深层网络每层的输入分布都在不断变化——内部协变量偏移。2015 年的 BatchNorm 论文提出在每一层都做一次归一化。

基本原理

对一个 batch 的每个维度(特征通道)独立做标准化:

def batch_norm(x, gamma, beta, eps=1e-5, momentum=0.1, training=True):
    if training:
        # 训练时用当前 batch 的统计量
        mean = x.mean(dim=(0, 2, 3), keepdim=True)
        var = x.var(dim=(0, 2, 3), keepdim=True)

        # 更新全局统计量(用于推理)
        running_mean = momentum * running_mean + (1 - momentum) * mean.data
        running_var = momentum * running_var + (1 - momentum) * var.data
    else:
        # 推理时用全局统计量
        mean = running_mean
        var = running_var

    x_norm = (x - mean) / torch.sqrt(var + eps)
    return gamma * x_norm + beta  # 可学习的缩放和平移参数

这里的 gammabeta 是关键:标准化会破坏网络的表达能力,比如强制均值为 0 标准差为 1,但某些层可能就需要非零均值或非单位标准差。这两个可学习参数让网络能"恢复"必要的表达能力。

BatchNorm 的完整计算流程可以概括为以下步骤:

flowchart TD A[Input Batch<br/>B, C, H, W] --> B[Compute mean per channel<br/>μ = mean across B,H,W] B --> C[Compute variance per channel<br/>σ² = var across B,H,W] C --> D[Normalize<br/>x̂ = x - μ / √σ² + ε] D --> E[Scale<br/>Multiply by γ] E --> F[Shift<br/>Add β] F --> G[Output Batch<br/>γ · x̂ + β] style A fill:#e1f5ff style D fill:#d4edda style G fill:#f8d7da

这张图想说明的是:BatchNorm 的核心是"先归一化,再恢复表达能力"。归一化解决了分布不稳定问题,而可学习的参数让网络不会因此丢失必要的表达空间。

为什么有效

不只是缓解梯度问题,BatchNorm 还做了几件事:

  1. 允许更大的学习率:因为梯度对输入变化的敏感度降低了
  2. 减少初始化敏感度:不同初始化下的训练表现更稳定
  3. 轻微的正则化效果:每个 batch 的统计量都带噪声,相当于一种数据增强

但最后这点不是 BatchNorm 的设计目的,实际正则化效果也不如 Dropout 明显。

踩坑记录

BatchNorm 用起来有几个容易踩的坑:

  1. Batch 太小:如果 batch size 是 2 或 4,统计量的方差会非常大,训练会抖动。CNN 中 batch size 小于 8 时 BatchNorm 效果会明显下降。
# 小 batch 时考虑用 SyncBatchNorm 或换成 LayerNorm
if batch_size < 8:
    model = layer_norm_model  # 或者用 GroupNorm
  1. 推理时统计量错乱:如果模型在训练模式下保存,但推理时忘记切到 eval 模式,仍然会用当前 batch 的统计量,导致结果不稳定且不可复现。
model.train()   # 训练模式,统计量会更新
model.eval()    # 推理模式,统计量固定
  1. 多 GPU 同步问题:如果用 DistributedDataParallel,每个 GPU 会算自己的统计量,需要用 SyncBatchNorm 同步:
from torch.nn import SyncBatchNorm

# 把普通 BatchNorm 换成同步版本
model = SyncBatchNorm.convert_sync_batchnorm(model)
  1. RNN/LSTM 的尴尬:序列模型的每个时间步长度不一,BatchNorm 的维度对不上。这种场景更适合用 LayerNorm。

LayerNorm:给序列模型准备的

LayerNorm 不跨样本算统计量,而是在单个样本内部计算。

def layer_norm(x, gamma, beta, eps=1e-5):
    mean = x.mean(dim=-1, keepdim=True)  # 对最后一个维度求均值
    var = x.var(dim=-1, keepdim=True)
    x_norm = (x - mean) / torch.sqrt(var + eps)
    return gamma * x_norm + beta

这个特性让 LayerNorm 天然适合变长序列、小 batch 场景,所以在 Transformer、RNN 中几乎成了标配。

下面这张图对比了 BatchNorm 和 LayerNorm 在统计量计算上的关键差异:

graph LR subgraph BatchNorm["BatchNorm (图像/CNN)"] B1[统计量跨 batch 计算] B2[每个通道独立归一化] B3[需要足够大的 batch size] end subgraph LayerNorm["LayerNorm (序列/Transformer)"] L1[统计量在样本内计算] L2[跨特征维度归一化] L3[不受 batch size 影响] end BatchNorm -->|适合| C1[固定尺寸数据<br/>图像 CNN] LayerNorm -->|适合| C2[变长序列<br/>NLP 时间序列]

简单说:BatchNorm 是"横向"的,跨样本对齐同一通道的分布;LayerNorm 是"纵向"的,在单个样本内部把不同特征拉到同一个量级。

什么时候用 LayerNorm

简单判断:

  • 序列数据(NLP、时间序列)优先用 LayerNorm
  • 图像数据、固定长度的结构化数据优先用 BatchNorm
  • Batch size 很小(小于 8)时考虑 LayerNorm 或 GroupNorm

其他归一化方案

除了上面两个,还有一些针对特定场景的方案。

InstanceNorm

实例归一化,在风格迁移中用得比较多。它对每个样本的每个通道独立归一化,不跨 batch 也不跨通道。风格迁移希望保留风格特征,抑制内容特征,InstanceNorm 正好满足这个需求。

GroupNorm

把通道分成若干组,每组内部归一化。是 LayerNorm 和 BatchNorm 的折中方案:既不依赖 batch size,又能利用通道间的信息。在目标检测、分割任务中常用(这些任务 batch size 通常很小)。

import torch.nn as nn

# 把 64 个通道分成 8 组,每组 8 个通道一起归一化
conv = nn.Conv2d(64, 64, 3, padding=1)
gn = nn.GroupNorm(8, 64)  # num_groups, num_channels

WeightNorm

直接对权重进行归一化,而不是对输入。计算量更小,但在实践中用得不多,主要是 BatchNorm/GroupNorm 已经能覆盖大多数场景。

实践建议

最后总结一下不同场景下的选型建议。

图像分类/CNN

  • Batch size 大于 8:用 BatchNorm
  • Batch size 小于 8:用 GroupNorm 或 SyncBatchNorm
  • 推理对延迟敏感:考虑 GroupNorm(没有统计量更新的开销)

NLP/Transformer

  • 首选 LayerNorm,几乎都是这个
  • Batch size 很大且训练非常稳定时可以试试 BatchNorm,但收益不明显

目标检测/分割

  • Batch size 通常很小(2-4),GroupNorm 是主流选择
  • Mask R-CNN、Detectron2 等框架默认都是 GroupNorm

实时/在线推理

  • 推理时统计量固定,BatchNorm 和 GroupNorm 差不多
  • 如果内存/延迟敏感,可以先把 BatchNorm 的统计量"固化"成固定计算:
# 把 BatchNorm 固化成普通算子
model = torch.jit.trace(model, example_input)

如果不确定该选哪种,可以参考下面的决策树:

flowchart TD A[选择归一化方案] --> B{数据类型?} B -->|图像/CNN| C{Batch Size?} B -->|序列/Transformer| D[LayerNorm] B -->|目标检测/分割| E[GroupNorm] C -->|≥ 8| F[BatchNorm] C -->|< 8| G{多GPU训练?} G -->|是| H[SyncBatchNorm] G -->|否| E F --> I[✓ 标准选择] D --> J[✓ 几乎默认] E --> K[✓ 小 batch 首选] H --> L[✓ 多 GPU 同步] style D fill:#d4edda style F fill:#d4edda style E fill:#fff3cd

这次解决了什么

回到开头的问题,模型训练崩溃的直接原因是特征量级差异导致梯度不平衡。

解决方案分两层:

  1. 数据层面:输入特征做 Min-Max 或 Z-Score 归一化,保证所有特征在相似的量级范围内
  2. 网络层面:用 BatchNorm/GroupNorm 缓解内部协变量偏移,允许更大的学习率和更稳定的训练

实际改完后,同样的学习率下 loss 曲线不再突然爆炸,收敛速度也快了大概 20%。更重要的是,不同初始化、不同 batch size 下训练结果的方差明显变小了。

归一化不是一个"神奇技巧",但它确实把很多本来会崩掉的训练救回来了。如果你也遇到过 loss 突然 NaN、梯度爆炸、调学习率像调参彩票这些问题,不妨先检查一下归一化是否到位——很多时候答案就藏在这里。

参考与延伸

版权声明: 本文首发于 指尖魔法屋-关于AI归一化的几点记录https://blog.thinkmoon.cn/post/338-ai-normalization-data-training-stable-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!