首页/知识库/数值计算与稳定性

数值计算与稳定性

✍️ AI Master📅 创建 2026-04-12📖 16 min 阅读
💡

文章摘要

从浮点精度到梯度裁剪,理解深度学习中的数值稳定性问题

1浮点数表示与精度

深度学习中的所有数值计算都建立在浮点数之上。IEEE 754 标准定义了浮点数的二进制表示方式:符号位、指数位和尾数位。单精度(FP32)使用 32 位,其中 1 位符号、8 位指数、23 位尾数,能表示约 7 位十进制有效数字。双精度(FP64)使用 64 位,提供约 16 位有效数字。

精度问题在深度学习中无处不在。当两个相近的大数相减时会发生灾难性抵消(Catastrophic Cancellation),导致有效数字大量丢失。例如计算 (1 + 1e-8) - 1 在 FP32 中可能得到零,因为 1e-8 超出了 FP32 在 1 附近的精度范围。神经网络中的梯度计算、权重更新、归一化操作都可能遇到类似问题。

理解浮点数的机器精度(machine epsilon)至关重要。FP32 的机器精度约为 1.19e-7,这意味着任何小于这个量级的变化在与大数相加时都会被舍入掉。在设计数值稳定的算法时,我们必须始终考虑运算顺序,避免大数吃小数的问题。

python
import numpy as np

# 演示机器精度
eps_32 = np.finfo(np.float32).eps
eps_64 = np.finfo(np.float64).eps
print(f"FP32 机器精度: {eps_32}")  # 约 1.19e-7
print(f"FP64 机器精度: {eps_64}")  # 约 2.22e-16

# 灾难性抵消演示
a = np.float32(1e8)
b = np.float32(1e8 + 1)
print(f"(1e8+1) - 1e8 = {b - a}")  # 可能为 0!
python
# 浮点数表示范围
info_32 = np.finfo(np.float32)
print(f"FP32 最大值: {info_32.max:.2e}")   # 约 3.4e+38
print(f"FP32 最小正值: {info_32.tiny:.2e}")  # 约 1.18e-38
print(f"FP32 最小正规数: {info_32.smallest_normal:.2e}")

# 大数吃小数问题
x = np.float32(1e8)
y = np.float32(1.0)
print(f"1e8 + 1.0 = {x + y}")  # 结果等于 1e8
精度类型位数指数位尾数位有效数字范围

FP16

16

5

10

~3位

6.1e-5 ~ 6.5e+4

BF16

16

8

7

~2位

1.2e-38 ~ 3.4e+38

FP32

32

8

23

~7位

1.2e-38 ~ 3.4e+38

FP64

64

11

52

~16位

2.2e-308 ~ 1.8e+308

在关键数值计算中使用 FP64 或 Kahan 求和算法可以显著减少累积误差

不要假设浮点数运算满足结合律:(a + b) + c 不等于 a + (b + c)

2上溢与下溢

上溢(Overflow)发生在数值超出浮点数可表示的最大范围时,结果为无穷大(Inf)。下溢(Underflow)发生在数值过小,接近或低于最小可表示的正数时,结果被刷新为零(或次正规数)。这两种现象在深度学习中极其常见且极具破坏性。

指数函数是上溢和下溢的重灾区。计算 exp(x) 时,当 x > 88.7(FP32)就会上溢为 Inf,当 x < -87.3 就会下溢为 0。这在 Softmax、交叉熵损失、注意力机制等核心操作中频繁出现。一个未处理的上溢会导致整个训练过程崩溃,梯度变成 NaN 并传播到所有参数。

下溢同样危险。当概率值下溢为 0 时,对数运算 log(0) 会产生负无穷,进而导致损失函数为 NaN。在语言模型中,长序列的联合概率是许多小于 1 的概率相乘,极易下溢。解决方案是在对数空间中计算:将乘法变为加法,将 exp 和 log 配对使用。

python
import numpy as np

# 上溢演示
x = np.float32(100.0)
print(f"exp(100) = {np.exp(x)}")  # inf!

# 下溢演示
x = np.float32(-100.0)
print(f"exp(-100) = {np.exp(x)}")  # 0!

# 对数下溢
p = np.exp(np.float32(-100.0))
print(f"log(exp(-100)) = {np.log(p)}")  # -inf!
python
# 安全的指数计算(上溢保护)
def safe_exp(x, dtype=np.float32):
    info = np.finfo(dtype)
    x = np.clip(x, a_min=None, a_max=info.max * 0.9)
    x = np.clip(x, a_min=info.min * 0.9, a_max=None)
    return np.exp(x)

# 安全的对数计算(下溢保护)
def safe_log(x, eps=1e-7):
    return np.log(np.clip(x, eps, None))

# 测试
print(f"safe_exp(100) = {safe_exp(100.0)}")
print(f"safe_log(0) = {safe_log(0.0)}")
现象原因后果典型场景防御策略

上溢

数值过大

Inf → NaN

exp(大数), softmax

下溢

数值过小

0 → log(-inf)

长序列概率连乘

梯度爆炸

上溢传播

参数变为 NaN

深层网络, 大学习率

梯度消失

下溢传播

参数不更新

sigmoid, tanh 深层

在对数空间中计算概率乘积,用 log-sum-exp 技巧处理 Softmax

FP32 中 exp(x) 在 x > 88.7 时必然上溢,这是硬限制而非实现问题

3Softmax 的数值稳定实现

Softmax 函数将实数向量转换为概率分布,公式为 softmax(x_i) = exp(x_i) / sum(exp(x_j))。这是分类模型输出层的标准选择,也是注意力机制的核心组件。然而,直接按公式实现会遇到严重的数值稳定性问题。

当输入向量中存在较大的正值时,exp 会立即上溢。例如 x = [1000, 2000, 3000] 中,exp(3000) 远超 FP32 的表示范围。解决方案基于 Softmax 的平移不变性:softmax(x) = softmax(x - c),其中 c 是任意常数。选择 c = max(x),就能确保所有 exp 的指数都不超过 0,彻底消除上溢风险。

平移后的 Softmax 中,最大值对应的 exp 为 exp(0) = 1,其余值都是 exp(负数),全部落在 (0, 1] 区间内。分母的求和也完全安全,因为所有项都是有限的正数。这种技巧被所有主流深度学习框架采用,是数值稳定性的经典案例。

python
import numpy as np

# 不稳定的 Softmax 实现
def unstable_softmax(x):
    e = np.exp(x)
    return e / np.sum(e)

# 数值稳定的 Softmax 实现
def stable_softmax(x):
    shifted = x - np.max(x)  # 关键技巧
    e = np.exp(shifted)
    return e / np.sum(e)

# 测试
x = np.array([1000, 2000, 3000], dtype=np.float64)
# print(unstable_softmax(x))  # 崩溃!
print(stable_softmax(x))  # [2.06e-435, ..., 1.0] 近似 [0, 0, 1]
python
# 批量矩阵的数值稳定 Softmax(沿最后一维)
def batch_stable_softmax(logits):
    """logits shape: (batch, seq_len, vocab_size)"""
    max_vals = np.max(logits, axis=-1, keepdims=True)
    shifted = logits - max_vals
    exp_vals = np.exp(shifted)
    sum_vals = np.sum(exp_vals, axis=-1, keepdims=True)
    return exp_vals / (sum_vals + 1e-8)

# 模拟 Transformer 注意力中的 Softmax
batch_size, seq_len, vocab_size = 2, 128, 10000
logits = np.random.randn(batch_size, seq_len, vocab_size) * 5
probs = batch_stable_softmax(logits)
print(f"概率和: {np.sum(probs, axis=-1)[0, 0]:.6f}")  # 应为 1.0
实现方式上溢风险下溢风险适用场景复杂度

直接计算

仅限小数值输入

O(n)

减去最大值

通用

O(n)

带温度系数

依赖 T

依赖 T

知识蒸馏

O(n)

带 mask

注意力机制

O(n)

在 Transformer 中,attention 的 Softmax 前必须加上 attention mask 的负无穷值

减去最大值只能防止上溢,极小的概率值仍会下溢为 0,在长序列中需额外注意

4Log-Sum-Exp 技巧

Log-Sum-Exp(LSE)是机器学习中最重要的数值稳定技巧之一。它的核心任务是安全地计算 log(sum(exp(x_i)))。这个操作看似简单,但直接计算会同时面临上溢和下溢的双重威胁:exp 可能上溢,而极小的 exp 值相加后再取对数可能因精度损失而不准确。

LSE 技巧与 Softmax 使用同样的平移思想:log(sum(exp(x_i))) = c + log(sum(exp(x_i - c)))。取 c = max(x),则所有 exp(x_i - c) 都不超过 1,上溢被彻底消除。最小值对应的项即使下溢为 0 也不影响结果的准确性,因为该项本身在总和中的贡献就可以忽略。

这个技巧广泛应用于计算交叉熵损失、归一化流模型、条件随机场和能量模型。PyTorch 的 torch.logsumexp 和 SciPy 的 scipy.special.logsumexp 都内置了这种数值稳定实现。理解 LSE 的推导过程,有助于你在遇到新的数值计算挑战时,自主构造稳定算法。

python
import numpy as np
from scipy.special import logsumexp

# 不稳定的 LSE
def unstable_lse(x):
    return np.log(np.sum(np.exp(x)))

# 数值稳定的 LSE
def stable_lse(x):
    c = np.max(x)
    return c + np.log(np.sum(np.exp(x - c)))

# 测试
x = np.array([1000, 200, 300], dtype=np.float64)
# unstable_lse(x)  → 上溢崩溃
print(f"stable LSE: {stable_lse(x):.4f}")
print(f"scipy LSE:  {logsumexp(x):.4f}")
python
# 用 LSE 计算数值稳定的交叉熵损失
def stable_cross_entropy(logits, target_idx):
    """
    交叉熵 = -log(softmax(x)[target])
           = log(sum(exp(x))) - x[target]
           = LSE(x) - x[target]
    """
    lse = logsumexp(logits)
    return lse - logits[target_idx]

# 测试
logits = np.array([2.0, 1.0, 0.1])
target = 0
loss = stable_cross_entropy(logits, target)
print(f"交叉熵损失: {loss:.4f}")

# 对比:直接用 softmax 再取 log 可能因下溢而不准确
softmax_probs = np.exp(logits - np.max(logits))
softmax_probs /= np.sum(softmax_probs)
naive_loss = -np.log(softmax_probs[target])
print(f"naive 损失: {naive_loss:.4f}")
操作直接计算稳定计算误差来源应用场景

softmax

exp(x_i)/sum

减 max 后计算

上溢

分类输出层

log softmax

log(softmax)

x_i - LSE(x)

上溢+下溢

交叉熵

LSE

log(sum(exp))

c + log(sum(exp-c))

上溢

概率归一化

log 概率相加

sum(log p_i)

log(sum(exp)) 替代

下溢

语言模型

计算交叉熵损失时,永远使用 log-softmax 路径而不是 softmax-then-log

LSE 中的减法 c + log(sum(exp(x-c))) 在 c 极大时可能损失精度,极端情况需要特殊处理

5梯度消失与爆炸的数值根源

梯度消失和梯度爆炸是训练深度神经网络时最经典的数值问题。它们的根源可以追溯到链式法则中的连乘操作。在 L 层网络的反向传播中,梯度是 L 个雅可比矩阵的乘积。如果每个矩阵的谱范数大于 1,梯度会指数级增长(爆炸);如果谱范数小于 1,梯度会指数级衰减到 0(消失)。

Sigmoid 和 Tanh 激活函数是梯度消失的主要推手。Sigmoid 的导数最大值为 0.25,Tanh 为 1.0。在深层网络中,经过数十次连乘后,梯度可以缩小到 FP32 的下溢阈值以下。这就是为什么 2010 年之前的神经网络很难超过 5 层的原因之一。ReLU 的引入部分缓解了这个问题,因为它的导数在正区间恒为 1。

梯度爆炸在 RNN 中尤为严重,因为同一家权重矩阵在时间步之间反复相乘。LSTM 的门控机制通过引入恒等连接(identity connection),构建了梯度的高速公路,使得梯度可以直接跨时间步传播而不经过矩阵乘法,这是 LSTM 能够学习长期依赖的关键。

python
import numpy as np

# 演示梯度消失:sigmoid 的连乘效应
def sigmoid_grad(x):
    s = 1 / (1 + np.exp(-x))
    return s * (1 - s)

# 模拟 20 层网络的梯度传播
initial_grad = np.float64(1.0)
for layer in range(20):
    initial_grad *= sigmoid_grad(0.0)  # 最大值 0.25
    print(f"Layer {layer+1}: grad = {initial_grad:.2e}")

# 20 层后: ~0.25^20 ≈ 9e-13,接近下溢阈值
python
# 梯度裁剪(Gradient Clipping)
def clip_gradient_norm(grads, max_norm=1.0):
    """按全局范数裁剪梯度"""
    total_norm = np.sqrt(sum(np.sum(g ** 2) for g in grads))
    clip_coef = max_norm / (total_norm + 1e-6)
    if clip_coef < 1.0:
        return [g * clip_coef for g in grads]
    return grads

# 模拟梯度爆炸场景
explosive_grads = [np.random.randn(100) * 50 for _ in range(5)]
original_norm = np.sqrt(sum(np.sum(g ** 2) for g in explosive_grads))
clipped = clip_gradient_norm(explosive_grads, max_norm=5.0)
clipped_norm = np.sqrt(sum(np.sum(g ** 2) for g in clipped))
print(f"原始范数: {original_norm:.1f}, 裁剪后: {clipped_norm:.1f}")
激活函数导数范围梯度问题层数限制解决方案

Sigmoid

(0, 0.25]

严重消失

~5层

避免使用

Tanh

(0, 1.0]

中度消失

~10层

Xavier 初始化

ReLU

{0, 1}

不消失但可能死亡

无限制

He 初始化

Leaky ReLU

{0.01, 1}

轻微消失

无限制

推荐默认

使用残差连接(ResNet)让梯度拥有直接回传路径,从根本上缓解消失问题

梯度裁剪不能解决梯度消失,只能防止爆炸。消失问题需要从架构层面解决

6混合精度训练(FP16/BF16)

混合精度训练是现代深度学习加速的核心技术之一。它的基本思路是:在前向传播和反向传播中使用半精度浮点数(FP16 或 BF16),在权重更新和优化器状态中保持 FP32 精度。这样既能享受半精度带来的 2-3 倍加速和减半显存占用,又能保证模型的数值稳定性和收敛质量。

FP16 和 BF16 各有特点。FP16(半精度)保留 10 位尾数,精度较高但动态范围小(6.1e-5 ~ 6.5e+4),容易发生上溢。BF16(Brain Float 16)仅保留 7 位尾数但拥有 8 位指数,动态范围与 FP32 相同,因此几乎不存在上溢风险。这就是为什么现代框架(如 PyTorch 2.0+)更倾向于推荐 BF16。

混合精度训练需要 Loss Scaling 技术来防止 FP16 梯度的下溢。具体做法是在反向传播前将损失乘以一个放大系数(如 1024),使 FP16 梯度保持在可表示范围内,然后在更新权重前将梯度缩小回去。动态 Loss Scaling 会在训练过程中自动调整缩放系数,当检测到 Inf/NaN 时自动降低,否则逐步增大。

python
import torch

# PyTorch 原生 AMP(自动混合精度)
scaler = torch.cuda.amp.GradScaler()

for inputs, targets in dataloader:
    with torch.cuda.amp.autocast():
        outputs = model(inputs)
        loss = criterion(outputs, targets)

    scaler.scale(loss).backward()
    scaler.step(optimizer)
    scaler.update()

# BF16 训练(推荐 Ampere+ GPU)
with torch.autocast(device_type='cuda', dtype=torch.bfloat16):
    outputs = model(inputs)
    loss = criterion(outputs, targets)
python
# 手动 Loss Scaling 实现
class ManualLossScaler:
    def __init__(self, init_scale=2**16, growth_factor=2.0, backoff_factor=0.5):
        self.scale = init_scale
        self.growth_factor = growth_factor
        self.backoff_factor = backoff_factor
        self.good_steps = 0

    def step(self, found_inf):
        if found_inf:
            self.scale *= self.backoff_factor
            self.good_steps = 0
        else:
            self.good_steps += 1
            if self.good_steps >= 2000:
                self.scale *= self.growth_factor
                self.good_steps = 0

    def unscale_grads(self, grads):
        return [g / self.scale for g in grads]

scaler = ManualLossScaler()
print(f"当前缩放系数: {scaler.scale}")
精度类型指数位尾数位最大正值适用场景

FP32

8

23

3.4e+38

优化器状态, 权重备份

FP16

5

10

6.5e+4

前向/反向, 需 Loss Scaling

BF16

8

7

3.4e+38

前向/反向, 推荐首选

FP8

5

2~3

5.7e+4

推理, 前沿实验

在 Ampere 及更新架构的 GPU 上优先使用 BF16,它比 FP16 稳定得多

FP16 的动态范围过小,不做 Loss Scaling 时梯度极易下溢为 0

7PyTorch 数值稳定性最佳实践

将前面的所有数值稳定技巧整合到 PyTorch 实践中,形成一套可操作的规范。PyTorch 框架本身已经内置了大部分数值稳定实现,但正确理解和使用它们仍然需要开发者具备扎实的基础知识。

首先,优先使用框架内置的稳定函数。torch.logsumexp、torch.nn.functional.log_softmax、torch.nn.functional.cross_entropy(内部自动使用 log-softmax 路径)都经过高度优化。其次,合理使用 torch.set_printoptions 和 torch.autograd.detect_anomaly() 来调试数值问题。当训练中出现 NaN 时,detect_anomaly 会精确追踪到出问题的操作。

第三,选择正确的初始化方法。Xavier 初始化适用于 Sigmoid/Tanh,He 初始化适用于 ReLU 系列。不当的初始化会在训练初期就引入数值不稳定。第四,使用梯度裁剪(torch.nn.utils.clip_grad_norm_)作为梯度爆炸的安全网。第五,对于极端数值敏感的场景,可以局部使用 torch.double(FP64)来提高精度。

python
import torch
import torch.nn as nn

# 数值稳定性调试工具
def train_with_debug(model, loader, optimizer):
    torch.autograd.set_detect_anomaly(True)  # 追踪 NaN 来源

    for batch in loader:
        optimizer.zero_grad()
        out = model(batch)
        loss = nn.functional.cross_entropy(out, targets)

        # 检查损失
        if torch.isnan(loss):
            print("NaN 检测!")
            break

        loss.backward()

        # 梯度裁剪
        nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

        # 检查梯度
        for name, param in model.named_parameters():
            if param.grad is not None and torch.isnan(param.grad).any():
                print(f"NaN 梯度: {name}")
python
# 数值稳定初始化 + 训练模板
def init_weights(m):
    if isinstance(m, nn.Linear):
        nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
        if m.bias is not None:
            nn.init.zeros_(m.bias)
    elif isinstance(m, nn.LayerNorm):
        nn.init.ones_(m.weight)
        nn.init.zeros_(m.bias)

model.apply(init_weights)

# 稳定性 checklist
print("数值稳定性检查:")
print("1. 使用 log_softmax + NLLLoss 代替 softmax + MSELoss")
print("2. 设置合适的学习率 + warmup")
print("3. 启用 AMP 混合精度")
print("4. 添加梯度裁剪")
print("5. 使用 LayerNorm 而不是 BatchNorm(Transformer)")
print("6. 监控 loss 和梯度范数")
检查项推荐做法避免做法PyTorch 工具

初始化

Xavier / He

随机正态 N(0,1)

nn.init.kaiming_normal_

激活函数

ReLU / GELU

Sigmoid / Tanh

nn.GELU()

损失函数

CrossEntropyLoss

MSE for classification

F.cross_entropy

归一化

LayerNorm

BatchNorm (不稳定)

nn.LayerNorm

精度

AMP (BF16)

纯 FP16

torch.autocast

梯度保护

clip_grad_norm_

无裁剪

nn.utils.clip_grad_norm_

在关键路径上使用 torch.autograd.detect_anomaly(True) 可以快速定位 NaN 的精确来源

set_detect_anomaly 会显著降低训练速度,仅用于调试,不要在正常训练时开启

继续你的 AI 学习之旅

浏览更多 AI 知识库文章,或者探索 GitHub 上的优质 AI 项目