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

AI 数值计算:浮点误差、稳定性、向量化、条件数与精度选择

机器学习中的许多“模型问题”,实际上首先是数值计算问题:损失变成 NaN,梯度突然归零,softmax 输出全是 0 或 1,线性代数求解器对输入的微小变化给出完全不同的结果,或者模型离线评测正常、上线后因为硬件精度变化而跨过权限阈值。

要系统理解这些现象,需要区分五件事:

  1. 浮点误差:计算机如何表示和运算近似实数。
  2. 稳定性:算法是否会放大已有的舍入误差。
  3. 向量化:如何把标量计算组织成批量运算,以及这如何影响性能和数值结果。
  4. 条件数:问题本身对输入扰动是否敏感。
  5. 精度选择:在正确性、吞吐、内存、成本和风险之间如何做出可验证的取舍。

一、浮点数不是“带小数点的实数”

1.1 浮点表示的基本形式

二进制浮点数通常表示为:

x=(1)s×m×2ex=(-1)^s \times m \times 2^e

其中:

  • ss 是符号位;
  • mm 是有效数或尾数;
  • ee 是指数;
  • 有限的位数决定了 mm 能保留多少有效信息;
  • 有限的指数范围决定了数值能表示多大或多小。

以 IEEE 754 binary32,也就是常说的 float32 为例:

部分 位数
符号位 1
指数位 8
尾数存储位 23
有效精度 24 位二进制有效位

隐藏的最高有效位使得正常数的有效精度是 24 位,而不是 23 位。

十进制小数只有在能够写成有限二进制分数时,才可能被浮点数精确表示。例如:

0.5=210.5 = 2^{-1}

可以精确表示,但:

0.1=1100.1 = \frac{1}{10}

的二进制展开是无限循环,因此通常只能存储一个邻近值。

x = 0.1
print(format(x, ".17f"))
print(0.1 + 0.2 == 0.3)
print(format(0.1 + 0.2, ".17f"))

典型输出为:

0.10000000000000001
False
0.30000000000000004

这里的 0.10.20.3 都不是对应十进制实数的精确二进制表示。0.1 + 0.2 的结果只是“对真实加法结果进行舍入后的浮点值”。

因此,浮点数比较通常不能简单使用:

a == b

而应根据问题尺度使用绝对误差和相对误差:

abatol+rtolb|a-b| \leq \text{atol}+\text{rtol}|b|

NumPy 中可写成:

import numpy as np

a = 0.1 + 0.2
b = 0.3

print(np.isclose(a, b, rtol=1e-12, atol=1e-12))

但容差不能机械地设置为一个固定小数。对于接近零的量,相对误差没有意义;对于数量级很大的量,只看绝对误差也会误判。


1.2 机器精度、单位舍入和 ULP

设浮点格式在 1 附近的有效精度为 pp 位,则相邻浮点数之间的间隔大约与 2p2^{-p} 同量级。常见的机器精度可写为:

εmach=2(p1)\varepsilon_{\text{mach}} = 2^{-(p-1)}

在常见的“舍入到最近值”模式下,对正常范围内的单次运算,常用模型是:

fl(xy)=(xy)(1+δ),δu\operatorname{fl}(x \mathbin{\circ} y) = (x \mathbin{\circ} y)(1+\delta), \qquad |\delta|\leq u

其中:

  • fl\operatorname{fl} 表示实际浮点运算;
  • \circ 表示加、减、乘或除;
  • uu 是单位舍入误差,约等于机器精度的一半;
  • δ\delta 表示本次运算的相对舍入误差。

这个模型有前提:结果没有溢出,输入和中间值处于正常浮点范围,硬件使用了符合该舍入模型的运算。出现次正规数、溢出、刷新为零或硬件近似运算时,不能无条件套用它。

ULP(Unit in the Last Place)表示某个浮点数与相邻可表示浮点数之间的间隔。ULP 随指数变化:

  • 数值越大,相邻浮点数间距通常越大;
  • 数值越小,间距通常越小;
  • 接近零时会进入次正规数区域,间距和行为又会变化。

下面的例子展示了“加法没有改变结果”:

import numpy as np

x = np.float32(1e8)
y = np.float32(1.0)

print(x + y)
print((x + y) == x)

典型输出:

100000000.0
True

并不是加法没有数学意义,而是 float3210810^8 附近的相邻可表示数已经大于 1,结果舍入回了 x


1.3 溢出、下溢、次正规数与 NaN

浮点异常通常有四类:

  • 上溢(overflow):结果绝对值太大,变成 inf
  • 下溢(underflow):结果太接近零,可能变成次正规数或 0;
  • 除零:产生 inf-inf
  • 非法运算:例如 0 / 0inf - inf,产生 NaN
import numpy as np

print(np.exp(np.float32(100)))
print(np.exp(np.float32(-100)))
print(np.float32(0.0) / np.float32(0.0))

输出可能包含:

inf
0.0
nan

在深度学习中,NaN 往往不是根因,而是错误传播后的最后表现:

overflowinf非法减法或乘法NaN\text{overflow} \rightarrow \text{inf} \rightarrow \text{非法减法或乘法} \rightarrow \text{NaN}

例如,某一层激活值过大,进入指数函数后变成 inf;随后 softmax 做 inf / inf,最终得到 NaN

次正规数用于表示比最小正常数更接近零的数,但某些硬件为了性能会启用 flush-to-zero,直接把次正规数视为零。这会让极小梯度丢失。是否启用、如何处理,取决于硬件和运行时,不能只根据高级框架代码判断。


二、误差的来源:输入误差、舍入误差与算法误差

设真实问题为:

y=f(x)y=f(x)

实际计算得到:

y^=fl(algorithm(x))\hat y=\operatorname{fl}(\text{algorithm}(x))

两者不同,可能有三个来源。

2.1 输入误差

输入本身可能已经是近似值,例如:

  • 传感器测量值;
  • 经过压缩、量化或标准化的数据;
  • 从十进制文本转换到二进制浮点数后的值;
  • 训练数据中的标注误差。

如果输入是 x+Δxx+\Delta x,那么即使算法在无限精度下执行,也只能得到:

f(x+Δx)f(x+\Delta x)

而不是 f(x)f(x)

2.2 舍入误差

每一个浮点运算都可能被舍入。一次误差通常很小,但连续计算会累积。累积方式取决于运算结构,不能简单地认为“每一步误差相加”。

2.3 算法误差

有些算法在数学上等价,但浮点实现并不等价。例如:

(a+b)+ca+(b+c)(a+b)+c \neq a+(b+c)

这不是浮点加法违反了结合律,而是浮点舍入使结合律不再成立。

如果算法还使用了近似迭代、截断、采样或低精度量化,那么这些也会成为误差来源。


三、灾难性消去:小结果来自两个大数相减

灾难性消去是最常见、也最容易被误判的浮点问题之一。

考虑:

f(x)=x+1xf(x)=\sqrt{x+1}-\sqrt{x}

xx 很大时,两个平方根都很大,但它们的差非常小。直接计算时,两个近似的大数在减法中会抵消掉大量前导有效位,留下的结果可能只有很少的可信位。

利用有理化:

x+1x=(x+1x)(x+1+x)x+1+x\sqrt{x+1}-\sqrt{x} = \frac{(\sqrt{x+1}-\sqrt{x})(\sqrt{x+1}+\sqrt{x})} {\sqrt{x+1}+\sqrt{x}}

得到:

f(x)=1x+1+xf(x)=\frac{1}{\sqrt{x+1}+\sqrt{x}}

这两个表达式在实数数学中等价,但第二个表达式不会直接相减两个接近的数,因此数值上更稳定。

import math

x = 1e16

direct = math.sqrt(x + 1.0) - math.sqrt(x)
stable = 1.0 / (math.sqrt(x + 1.0) + math.sqrt(x))

print("direct:", direct)
print("stable:", stable)

在双精度环境中,x + 1.0 甚至可能已经舍入为 x,导致直接表达式得到 0;有理化形式仍能保留正确数量级。

这里有一个重要边界:改写表达式只能减少算法引入的误差,不能恢复已经在输入阶段丢失的信息。如果 x + 1 在所选精度中就等于 x,算法无法知道真实的 x+1 应该是多少。


四、稳定性:算法是否放大误差

4.1 前向误差与后向误差

对于真实结果 yy 和计算结果 y^\hat y

  • 前向误差衡量结果错了多少:

y^y\|\hat y-y\|

或相对前向误差:

y^yy\frac{\|\hat y-y\|}{\|y\|}

  • 后向误差衡量计算结果是否等价于某个略微扰动后的输入:

y^=f(x+Δx)\hat y=f(x+\Delta x)

如果 Δx\Delta x 很小,则称算法具有较好的后向稳定性。

后向稳定的意义是:算法没有把输入问题进一步恶化,只是在一个很接近原始输入的问题上得到了精确或近似精确的结果。

4.2 条件数与稳定性的关系

最终误差通常同时由两部分决定:

结果误差问题条件数×输入或舍入误差+算法额外误差\text{结果误差} \approx \text{问题条件数} \times \text{输入或舍入误差} + \text{算法额外误差}

因此:

  • 问题本身敏感,称为病态问题
  • 算法放大了不必要的误差,称为数值不稳定
  • 一个问题可能病态,但算法仍然是稳定的;
  • 一个条件良好的问题,也可能被不稳定算法算坏。

不能只看到结果误差很大,就断言“算法不稳定”。


五、条件数:问题对扰动的敏感程度

5.1 一般定义

对于函数 ff,在输入 xx 附近,条件数描述输入相对扰动如何影响输出相对扰动:

κf(x)输出相对变化输入相对变化\kappa_f(x) \approx \frac{\text{输出相对变化}} {\text{输入相对变化}}

ff 可微,标量情况下常写为:

κf(x)=xf(x)f(x)\kappa_f(x) = \left| \frac{x f'(x)}{f(x)} \right|

这一定义解释了条件数的直觉:

  • κ1\kappa\approx 1:输入有 1% 的相对误差,输出通常也是同量级误差;
  • κ1\kappa\gg 1:很小的输入扰动可能被放大很多倍;
  • κ1\kappa\ll 1:该函数在局部会衰减相对误差。

5.2 线性方程组的条件数

对于:

Ax=bAx=b

常用矩阵条件数为:

κ(A)=AA1\kappa(A)=\|A\|\|A^{-1}\|

其中 \|\cdot\| 是选定的矩阵范数。

AA 可逆,输入 bb 发生扰动 Δb\Delta b,则:

A(x+Δx)=b+ΔbA(x+\Delta x)=b+\Delta b

因此:

Δx=A1Δb\Delta x=A^{-1}\Delta b

取范数:

ΔxA1Δb\|\Delta x\| \leq \|A^{-1}\|\|\Delta b\|

再结合:

b=AxAx\|b\|=\|Ax\|\leq \|A\|\|x\|

可得到近似相对误差界:

Δxxκ(A)Δbb\frac{\|\Delta x\|}{\|x\|} \lesssim \kappa(A) \frac{\|\Delta b\|}{\|b\|}

所以当 κ(A)=108\kappa(A)=10^8 时,输入只有 10810^{-8} 级别的相对扰动,就可能造成约 1 级别的相对解误差。

考虑:

A=[1111+ϵ]A= \begin{bmatrix} 1 & 1\\ 1 & 1+\epsilon \end{bmatrix}

其行列式为:

det(A)=ϵ\det(A)=\epsilon

ϵ\epsilon 接近 0 时,两行几乎线性相关,矩阵接近不可逆。此时 A1A^{-1} 中包含 1/ϵ1/\epsilon,条件数会迅速增大。

这不是求解器“粗心”造成的。即使使用后向稳定的求解器,输入中极小的测量误差、浮点误差或特征提取误差,也可能被问题本身放大。

5.3 条件数不是误差本身

以下三句话含义不同:

  1. “矩阵条件数很大”:问题对输入扰动敏感;
  2. “算法后向稳定”:算法产生的结果对应于一个很接近的输入问题;
  3. “最终结果误差很大”:实际输出偏离目标较多。

一个病态问题仍然应该使用稳定算法,因为不稳定算法会在问题固有敏感性之外再增加误差。条件数无法通过“换一个更聪明的求解器”完全消除,但可以通过重新建模、缩放、正则化或增加输入精度来改善。


六、累加误差:为什么求和顺序会改变结果

设有 nn 个数相加:

s=x1+x2++xns=x_1+x_2+\cdots+x_n

如果逐个浮点累加,常见误差界可以写成:

fl(s)sγn1i=1nxi|\operatorname{fl}(s)-s| \lesssim \gamma_{n-1}\sum_{i=1}^{n}|x_i|

其中:

γk=ku1ku\gamma_k=\frac{ku}{1-ku}

要求 ku<1ku<1。这个界说明两点:

  1. 项数越多,误差上界通常越大;
  2. 真正结果 s|s| 如果远小于 xi\sum |x_i|,相对误差可能非常大。

例如:

1016+1101610^{16}+1-10^{16}

在实数中等于 1,但如果先把 10^16 + 1 舍入为 10^16,结果就会变成 0。

import math

values = [1e16, 1.0, -1e16]

print(sum(values))
print(math.fsum(values))

math.fsum 使用更精细的补偿策略,通常能得到更接近精确结果的求和。它不代表所有场景都免费且最快,但适合需要高精度标量求和的场景。

常见的数值改进方法包括:

  • 从小到大排序后求和:减少小数被大数吞掉的机会;
  • 成对求和(pairwise summation):把相近规模的项先相加;
  • 补偿求和:额外保存被舍弃的小量;
  • 高精度累加:例如输入为 float32,累加器使用 float64
  • 分块累加:局部求和后再合并。

但是,成对求和和并行归约仍然可能因为执行树不同而产生不同的最低位结果。GPU、分布式训练和多线程环境中,归约顺序往往不是固定的,因此“同一输入每次最低位完全一致”并不是默认保证。


七、稳定的指数、对数与概率计算

生成模型、分类模型和注意力机制大量使用指数与对数。直接写数学公式,往往会造成溢出或下溢。

7.1 Softmax 的溢出问题

Softmax 定义为:

softmax(zi)=ezijezj\operatorname{softmax}(z_i) = \frac{e^{z_i}}{\sum_j e^{z_j}}

若:

z=[1000,1001]z=[1000,1001]

则直接计算 e1000e^{1000}e1001e^{1001} 会溢出。

利用分子分母同时除以 eme^m,其中:

m=maxizim=\max_i z_i

得到:

softmax(zi)=ezimjezjm\operatorname{softmax}(z_i) = \frac{e^{z_i-m}}{\sum_j e^{z_j-m}}

由于 zim0z_i-m\leq 0,指数输入不会为正的大数。

import numpy as np

z = np.array([1000.0, 1001.0])

def softmax_stable(x):
    shifted = x - np.max(x)
    exp_x = np.exp(shifted)
    return exp_x / np.sum(exp_x)

print(softmax_stable(z))

预期结果约为:

[0.26894142 0.73105858]

这里减去最大值不会改变最终概率,因为分子分母同时乘除同一个正数。它改变的是计算路径,而不是数学定义。

7.2 LogSumExp

交叉熵、语言模型损失和注意力计算常需要:

log(iezi)\log\left(\sum_i e^{z_i}\right)

直接计算可能溢出。稳定形式为:

LSE(z)=m+log(iezim)\operatorname{LSE}(z) = m+\log\left(\sum_i e^{z_i-m}\right)

其中 m=maxizim=\max_i z_i

import numpy as np

def logsumexp_stable(x):
    m = np.max(x)
    return m + np.log(np.sum(np.exp(x - m)))

print(logsumexp_stable(np.array([1000.0, 1001.0])))

结果约为:

1001.3132616875182

实际框架通常提供经过优化的 logsumexp 或交叉熵实现。自己实现时不仅要处理溢出,还要确认归约维度、掩码位置和空集合行为。

7.3 Sigmoid 的分支稳定实现

Sigmoid 为:

σ(x)=11+ex\sigma(x)=\frac{1}{1+e^{-x}}

xx 很小时,exp(-x) 可能溢出;当 xx 很大时,直接计算还可能造成 exp(-x) 下溢。可以按符号分支:

σ(x)={11+ex,x0ex1+ex,x<0\sigma(x)= \begin{cases} \dfrac{1}{1+e^{-x}}, & x\geq 0\\[6pt] \dfrac{e^x}{1+e^x}, & x<0 \end{cases}

import numpy as np

def sigmoid_stable(x):
    x = np.asarray(x, dtype=np.float64)
    result = np.empty_like(x)

    positive = x >= 0
    result[positive] = 1.0 / (1.0 + np.exp(-x[positive]))

    exp_x = np.exp(x[~positive])
    result[~positive] = exp_x / (1.0 + exp_x)
    return result

print(sigmoid_stable(np.array([-1000.0, 0.0, 1000.0])))

预期结果接近:

[0.  0.5 1. ]

注意:对于极端输入,结果数学上本来就极接近 0 或 1。稳定实现不能让 float32 获得超出自身精度的中间信息,它只是避免了不必要的 infNaN


八、向量化:不仅是“把循环写成数组”

8.1 向量化的计算含义

对样本矩阵 XRn×dX\in\mathbb{R}^{n\times d}、权重 wRdw\in\mathbb{R}^{d} 和偏置 bb,批量线性模型为:

y=Xw+by=Xw+b

逐样本写法是:

yi=j=1dXijwj+by_i=\sum_{j=1}^{d}X_{ij}w_j+b

向量化把 nn 个样本的相同操作交给底层数组库、BLAS、CPU SIMD 单元或 GPU kernel 执行。

import numpy as np

rng = np.random.default_rng(0)
X = rng.normal(size=(4, 3))
w = np.array([0.2, -0.5, 1.0])
b = 0.1

y = X @ w + b
print(y.shape)
print(y)

这里:

  • X @ w 对最后一个维度做矩阵乘向量;
  • + b 对每个样本广播同一个偏置;
  • 输出形状为 (4,)

向量化的主要收益来自:

  • 减少 Python 解释器层面的循环;
  • 使用连续内存和专门的矩阵乘法内核;
  • 利用 SIMD、GPU 并行和高效缓存;
  • 将多个标量操作批量提交,降低调度开销。

它不是数学精度保证。向量化实现可能改变求和树、融合算子或使用不同硬件指令,因此最后几位结果可以与逐元素实现不同。

8.2 广播必须有明确的形状语义

例如:

X = np.ones((2, 3))
b = np.array([1.0, 2.0, 3.0])

print((X + b).shape)

输出为 (2, 3)b 被广播到每一行。

但以下代码的含义不同:

b = np.array([[1.0], [2.0]])
print((X + b).shape)

输出仍可能是 (2, 3),此时 b 被广播到每一列。广播没有报错,不代表语义一定正确。机器学习中一个常见错误是把形状为 (batch,) 的标签误写成 (batch, 1),导致损失函数隐式生成 (batch, batch) 的结果。

因此,向量化代码应同时检查:

  • 输入维度;
  • 广播方向;
  • 归约轴;
  • 输出维度;
  • 是否发生了非预期复制。

8.3 向量化的内存代价

下面两个表达式数学上可能等价:

y = a * b + c

和:

tmp = a * b
y = tmp + c

第二种明确创建了中间数组。第一种也不保证完全没有中间存储,因为底层实现可能创建临时缓冲区;但编译器或框架有机会进行算子融合。

在大模型和大批量推理中,内存读写经常比浮点乘加更昂贵。向量化的性能不能只看 FLOP 数,还要看:

  • 数据是否连续;
  • 是否触发隐式类型转换;
  • 中间张量数量;
  • CPU/GPU 之间的数据传输;
  • kernel 启动次数;
  • batch 是否足够填满硬件。

把一个循环改成数组表达式,可能提高速度,也可能因为生成巨大临时数组而降低速度。

8.4 并行归约与可重复性

向量化和 GPU 归约通常将数据拆分成多个局部块:

  1. 每个线程或向量 lane 计算局部和;
  2. 局部结果再进行树形合并;
  3. 合并顺序由调度、硬件和实现决定。

由于浮点加法不满足结合律,不同归约树可能得到不同的最低位结果。对于普通回归误差,这种差异通常很小;但如果分数接近分类、风控或权限阈值,小差异可能改变离散决策。

因此需要区分:

  • 数值接近:允许一定容差;
  • 位级确定性:每次得到完全相同的比特模式;
  • 业务决策稳定:阈值附近不能因微小数值变化频繁翻转。

九、精度格式:位数决定的不只是“小数位数”

9.1 常见格式的范围与精度

下表中的“机器精度”指 1 附近相邻浮点数间隔的典型值;具体行为还受舍入模式、次正规数和硬件实现影响。

格式 指数位 尾数存储位 有效精度 典型机器精度 主要特征
float64 11 52 53 位 2522^{-52} 精度和范围都较高
float32 8 23 24 位 2232^{-23} 通用训练和推理常用
IEEE float16 5 10 11 位 2102^{-10} 精度和范围都较小
bfloat16 8 7 8 位 272^{-7} 范围接近 float32,精度较低

float16bfloat16 的关键差异是:

  • float16 有更多尾数位,但指数范围窄;
  • bfloat16 指数位与 float32 相同或相近,但尾数位明显少。

因此:

  • float16 更容易发生激活、梯度或损失上溢;
  • bfloat16 更不容易因范围不足而溢出,但小的增量更容易被舍入掉。

TF32、FP8 等名称还可能表示硬件内部的计算格式,而不是可以在所有语言和设备上直接声明的通用 IEEE 存储类型。是否启用、输入截断位数和累加精度依赖硬件、库和配置,不能把某一平台的行为当作所有平台的规范保证。

9.2 精度与动态范围

精度决定“相邻可表示数有多近”,动态范围决定“最大和最小能表示到哪里”。

假设一个优化器更新为:

wnew=wηgw_{\text{new}}=w-\eta g

如果 ww 很大,而 ηg\eta g 很小,那么在低精度下可能有:

fl(wηg)=w\operatorname{fl}(w-\eta g)=w

更新被完全吞掉。反过来,如果梯度过大,乘以损失或学习率后可能上溢。

所以“模型参数的绝对值没有溢出”并不意味着训练安全,还要观察:

  • 梯度相对于参数的大小;
  • 激活的最大值;
  • 损失的数量级;
  • 归一化层的统计量;
  • 归约和点积的累加范围;
  • 量化后的零点和缩放因子。

9.3 混合精度的实际计算路径

混合精度不是简单地把所有张量改成 float16。一个常见的训练路径是:

  1. 权重、激活和部分算子使用低精度;
  2. 矩阵乘法内部使用更高精度累加器;
  3. 梯度归约使用较高精度;
  4. 优化器状态和主权重保留较高精度;
  5. 必要时对 loss 进行缩放;
  6. 更新前检查 infNaN

对于 float16loss scaling 的动机是防止小梯度下溢:

L=sLL'=sL

反向传播得到:

L=sL\nabla L'=s\nabla L

先在低精度中计算放大后的梯度,再在更新前除以 ss。如果放大后出现溢出,则降低 ss。动态缩放需要同时处理:

  • 溢出检测;
  • 跳过本次更新;
  • 缩放因子调整;
  • 优化器状态不能被错误更新。

bfloat16 因范围较大,通常更少依赖这类溢出保护,但它的尾数精度更低,仍然需要高精度累加、归一化和评测验证。


十、点积、矩阵乘法与累加精度

矩阵乘法中的单个元素为:

Cij=k=1KAikBkjC_{ij}=\sum_{k=1}^{K}A_{ik}B_{kj}

即使 AABB 使用低精度存储,累加器也可能使用更高精度。存储精度和累加精度是两个不同维度:

  • 输入低精度影响乘法输入的有效信息;
  • 累加精度影响 KK 项相加时的误差;
  • 输出精度决定结果最终保存时会丢失多少信息。

例如,下面的代码显式比较 float32 输入在不同累加精度下的结果:

import numpy as np

x = np.array([1e8, 1.0, -1e8], dtype=np.float32)

s32 = np.sum(x, dtype=np.float32)
s64 = np.sum(x, dtype=np.float64)

print("float32 accumulator:", s32)
print("float64 accumulator:", s64)

结果受具体归约实现影响,但通常可以观察到:

  • float32 累加可能丢掉小量;
  • float64 累加通常更接近数学和;
  • 输入 x 已经是 float32,所以高精度累加不能恢复输入转换时丢失的信息。

对长序列注意力、词表 logits、批量统计量和梯度归约而言,累加长度 KK 很大,误差上界中的 γK\gamma_K 也会增大。因此工程中经常对归约、归一化统计量和损失计算使用更高精度,而不是只看张量的存储 dtype。


十一、精度选择应由误差预算驱动

精度选择不是“越高越好”或“能跑就用最低精度”。应先确定允许的输出误差,再分析计算链路。

11.1 从业务输出反推数值要求

如果模型输出用于连续评分,允许误差可能是:

s^s103|\hat s-s|\leq 10^{-3}

如果输出用于阈值决策,则更重要的是边界安全。例如阈值为 tt,模型分数为 ss,需要关注:

st|s-t|

st|s-t| 小于可能的数值误差时,低精度结果可能改变决策。权限、审核、风控等场景不能只报告平均精度,还要统计阈值附近的翻转率。

如果输出是概率,还需检查:

  • 是否仍在 [0,1][0,1]
  • 多分类概率和是否接近 1;
  • 交叉熵是否稳定;
  • 校准指标是否发生变化。

11.2 根据操作类型分配精度

不同操作对精度和范围的敏感性不同:

  • 归一化、方差、softmax、logsumexp:容易受到溢出、消去和归约误差影响;
  • 大规模矩阵乘:低精度输入通常更容易获得吞吐收益,但累加精度仍重要;
  • 参数更新和优化器状态:长期累积误差可能显著;
  • 量化后的离散化和阈值判断:误差可能产生非连续结果;
  • 数据读取和统计:错误的类型转换会污染后续训练和评测。

一种可验证的分层方案是:

  • 输入和大部分中间激活:根据硬件支持选择 float16bfloat16
  • 矩阵乘输入:低精度,但明确确认累加精度;
  • 损失、归一化、softmax、统计量:使用较高精度;
  • 主权重、优化器状态和检查点:保留足够精度;
  • 最终评测和阈值决策:必要时回到 float32float64
  • 量化模型:保存缩放因子,并用代表性数据校准和回归测试。

这不是所有模型都适用的固定配置,而是需要通过误差实验确认的候选路径。

11.3 精度越低,成本不一定越低

低精度可能减少:

  • 参数和激活占用的内存;
  • 内存带宽压力;
  • 部分硬件上的计算时间;
  • 推理实例数量和运行成本。

但也可能增加:

  • 校准和回归测试成本;
  • 算子不支持低精度时的转换开销;
  • 调试难度;
  • 不稳定训练的重试成本;
  • 由于阈值翻转产生的业务审核和回滚成本。

因此应把模型、数据、评测、权限和成本视为同一生产系统的一部分。一个低精度模型若离线指标略好但上线权限决策不稳定,不能认为它降低了系统成本。


十二、向量化与精度选择的组合示例

下面的程序演示三个概念:

  1. 矩阵运算使用批量向量化;
  2. softmax 使用稳定形式;
  3. 计算结果同时比较不同 dtype。
import numpy as np

rng = np.random.default_rng(42)

batch_size = 8
feature_dim = 16
class_count = 4

X32 = rng.normal(size=(batch_size, feature_dim)).astype(np.float32)
W32 = rng.normal(size=(feature_dim, class_count)).astype(np.float32)
b32 = np.zeros(class_count, dtype=np.float32)

def stable_softmax(logits, axis=-1):
    shifted = logits - np.max(logits, axis=axis, keepdims=True)
    exp_logits = np.exp(shifted)
    return exp_logits / np.sum(exp_logits, axis=axis, keepdims=True)

logits32 = X32 @ W32 + b32
probs32 = stable_softmax(logits32)

print("logits shape:", logits32.shape)
print("probs shape:", probs32.shape)
print("row sums:", np.sum(probs32, axis=1))
print("finite:", np.isfinite(probs32).all())

预期结果:

  • logits shape(8, 4)
  • probs shape(8, 4)
  • 每一行概率和接近 1;
  • finiteTrue

这里的 axis=1 表示对每个样本的类别维度归一化。如果误写成 axis=0,程序仍可能正常运行,但含义会变成“跨样本归一化”,这属于逻辑错误而非浮点异常。

可以进一步比较 float32float64

X64 = X32.astype(np.float64)
W64 = W32.astype(np.float64)
b64 = b32.astype(np.float64)

probs64 = stable_softmax(X64 @ W64 + b64)

max_diff = np.max(np.abs(probs32.astype(np.float64) - probs64))
print("max absolute difference:", max_diff)

差异通常很小,但不能只用一个随机输入得出结论。应加入:

  • 极大和极小 logits;
  • 长序列;
  • 全零输入;
  • 含有负无穷掩码的输入;
  • 接近业务阈值的输出;
  • 实际生产数据分布。

十三、诊断数值故障的路径

13.1 先定位第一个非有限值

遇到 NaNinf 时,不要只检查最终损失。应沿计算图寻找第一个非有限张量:

import numpy as np

def check_finite(name, x):
    x = np.asarray(x)
    if not np.isfinite(x).all():
        bad = np.logical_not(np.isfinite(x))
        print(
            name,
            "has non-finite values:",
            "count =", bad.sum(),
            "min =", np.nanmin(x),
            "max =", np.nanmax(x),
        )
        return False
    return True

检查顺序通常是:

  1. 原始输入和标签;
  2. 预处理后的特征;
  3. 模型第一层输出;
  4. 每个关键归一化和激活;
  5. loss;
  6. 梯度;
  7. 优化器状态和参数。

第一个出现 NaN 的位置,才更接近根因。后续所有张量出现 NaN 只是传播结果。

13.2 通过精度对照判断问题类型

可以建立一个诊断矩阵:

  • float64 正常、float32 异常:可能是舍入、范围或累加问题;
  • 两者都异常:可能是数据、公式、形状或条件数问题;
  • 单线程正常、并行归约不稳定:可能是求和顺序和非确定性;
  • 训练正常、评测阈值翻转:可能是边界敏感,而非平均误差过大;
  • 低精度推理异常、高精度推理正常:检查算子支持、转换和校准。

还可以进行扰动测试:

x=x(1+δ)x' = x(1+\delta)

对输入加入很小的相对扰动,观察输出变化。如果输出变化远大于预期,说明问题可能病态;如果只有某个实现变化巨大,则应怀疑算法或并行路径不稳定。

13.3 检查条件数和缩放

对于线性代数问题,可计算或估计条件数:

import numpy as np

A = np.array([
    [1.0, 1.0],
    [1.0, 1.0 + 1e-8],
])

print(np.linalg.cond(A))

条件数很大时,应优先考虑:

  • 对输入和特征做尺度归一化;
  • 避免显式计算矩阵逆;
  • 使用适合问题结构的分解方法;
  • 使用正则化;
  • 提高计算和累加精度;
  • 重新检查特征是否近线性相关。

直接写:

x = np.linalg.inv(A) @ b

通常不如:

x = np.linalg.solve(A, b)

因为求逆会引入额外计算和舍入误差,而 solve 可以直接使用分解求解。对于病态矩阵,这种改写不能消除病态性,但能避免不必要的不稳定步骤。


十四、生产系统中的验证、权限与回滚

数值实现上线时,应把数据流和决策流一起验证:

flowchart LR
    A[原始数据] --> B[预处理与类型转换]
    B --> C[低/高精度模型计算]
    C --> D[输出与校准]
    D --> E[离线评测]
    D --> F[阈值与权限决策]
    E --> G[发布门禁]
    F --> H[线上审计与监控]
    G --> I[部署]
    I --> J[回滚版本]

关键路径不是“模型能否运行”,而是:

  1. 数据在什么精度中存储和转换;
  2. 哪些算子使用低精度;
  3. 哪些归约和决策使用高精度;
  4. 评测是否覆盖极端值和阈值附近样本;
  5. 权限结果是否记录了模型版本、精度路径和阈值;
  6. 异常时能否切换到已验证的精度或旧版本。

上线前至少应比较以下指标:

  • 高精度基线与目标精度的最大绝对误差;
  • 相对误差和分位数误差;
  • NaNinf 和下溢为零的比例;
  • 分类标签变化率;
  • 阈值附近的决策翻转率;
  • 生成模型的 token 级 logits 差异、序列级输出差异;
  • 长上下文、极端长度和异常输入上的稳定性;
  • 吞吐、显存、延迟和失败重试成本。

对于生成式 AI,低精度导致的 logits 微小变化可能改变采样排序。贪心解码只关心最大值,采样解码则还受温度、top-k、top-p 和随机数影响。若两个候选 token 的 logits 很接近,低精度误差可能改变排序;若 logits 间隔很大,则相同误差通常不会改变选择。因此应报告“候选排序变化”而不仅是平均 logits 误差。

权限系统尤其要关注边界。若允许阈值为 tt,模型分数 ss 满足:

stϵnumeric|s-t| \leq \epsilon_{\text{numeric}}

则该样本处于数值不确定区间。可以采用更高精度复核、保守决策或人工审核,而不是假设任意低精度结果都具有相同可信度。


十五、常见误解与对应边界

误解一:使用 float64 就不会有浮点误差

float64 仍然不能精确表示大多数十进制小数,也仍然存在溢出、下溢、消去和不稳定算法。它只是拥有更高的有效精度和更大的范围。

误解二:误差小就说明算法稳定

一个结果可能碰巧接近正确值,但算法仍然缺乏稳定性。应在输入扰动、不同规模、不同求和顺序和不同硬件下验证,而不是只看一个算例。

误解三:条件数大是求解器的 bug

大条件数说明问题本身敏感。求解器需要尽量保持后向稳定,但无法从有噪声的输入中恢复原本不存在的精确信息。

误解四:向量化一定更快且结果相同

向量化通常能利用底层高性能内核,但可能增加临时内存、触发类型转换或改变运算顺序。它还可能因为并行归约带来最低位差异。

误解五:float16bfloat16 只是存储大小不同

二者的指数位和尾数位分配不同,导致一个主要受动态范围限制,另一个主要受有效精度限制。训练稳定性不能仅根据“都是 16 位”判断。

误解六:只要离线准确率没变,低精度就安全

准确率可能掩盖概率校准、长尾样本、阈值翻转、生成排序变化和权限错误。精度变更必须使用与线上决策一致的评测指标。


十六、一个可执行的精度决策过程

可以按以下因果顺序进行,而不是先指定某种 dtype:

  1. 定义输出容差:明确允许的绝对误差、相对误差和决策翻转率。
  2. 识别敏感操作:找出指数、对数、减法消去、长归约、矩阵求解和阈值判断。
  3. 估计问题条件性:检查输入缩放、特征相关性、矩阵条件数和输出对扰动的敏感程度。
  4. 确定累加精度:低精度输入不等于低精度累加。
  5. 建立高精度基线:用经过验证的 float32float64 实现产生参考结果。
  6. 逐算子降精度:不要一次性把整个模型切换到低精度。
  7. 加入极端样本:包括最大值、最小值、空输入、长序列、稀疏输入和阈值附近样本。
  8. 评测系统指标:同时记录数值误差、模型指标、权限翻转、延迟、显存和成本。
  9. 保留可回滚路径:保存精度配置、校准参数、硬件和运行时版本。
  10. 上线后监控:持续检查非有限值、输出分布漂移、边界决策和失败重试。

最终应形成一份可追溯的精度契约:哪些张量可以低精度,哪些计算必须高精度,允许多大误差,哪些输入触发降级或复核,以及发生数值异常时如何回滚。

浮点数的核心事实是:计算结果不仅由数学公式决定,还由表示格式、运算顺序、算法结构、硬件归约和输入条件共同决定。稳定性解决的是算法如何对待误差,条件数说明问题本身会不会放大误差,向量化决定计算如何在系统中执行,而精度选择则是在这些因素和生产约束之间建立可验证的边界。


系列导航与关联阅读

官方资料

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