修复CI浮点数单元测试失败:本地与CI结果不一致?
2025-04-27 07:55:25
CI vs. 本地:为何我的单元测试浮点数结果不一样?
写单元测试的时候,你可能碰到过这样一个怪事:一段涉及浮点数计算的代码,在你的本地机器上跑得好好的,单元测试稳稳通过;可一旦提交到 CI/CD 流水线(比如 GitHub Actions、GitLab CI),同样的测试,有时就会挂掉,而且报错显示的计算结果和你本地跑出来的还不一样!就像提问者遇到的情况:本地结果是 0.586...
,CI 上却变成了 1.549...
。代码没变,库版本也锁定了,Python、NumPy、SciPy 版本都一样,本地是 macOS,CI 是 Ubuntu。这到底是怎么回事?
探究原因:为何结果会飘忽不定?
这背后通常不是什么魔法,而是计算机处理浮点数时的一些固有特性和环境差异在作祟。
浮点数的“不精确”本质
计算机表示浮点数(比如 float
或 double
类型)遵循 IEEE 754 标准。这个标准很高效,但在表示某些十进制小数时会有微小的精度损失,因为它使用二进制来近似。更重要的是,浮点数的运算,比如加法、乘法,其结果的精度也可能受到运算顺序、处理器架构(CPU)、编译器优化选项,甚至底层数学库(稍后会细说)的影响。
想象一下,(a + b) + c
可能和 a + (b + c)
在浮点数世界里得到极其微小的不同结果。对于复杂的数学计算,这种微小差异经过多步累积,就可能导致最终结果看起来“差之毫厘,谬以千里”。
底层数学库 (BLAS/LAPACK) 的差异
NumPy 和 SciPy 这类科学计算库,背后常常依赖于高度优化的底层数学库来执行矩阵运算、线性代数等操作。这些库被称为 BLAS (Basic Linear Algebra Subprograms) 和 LAPACK (Linear Algebra PACKage)。
常见的实现包括:
- OpenBLAS: 一个开源且广泛使用的优化库。
- MKL (Math Kernel Library): Intel 开发的高性能库,在 Intel CPU 上表现通常很强。
- ATLAS (Automatically Tuned Linear Algebra Software): 另一个自动优化的开源库。
- Accelerate Framework: macOS 自带的优化框架,NumPy/SciPy 在 macOS 上默认可能链接到它。
问题来了:不同的操作系统环境,或者即使是同一操作系统,安装 NumPy/SciPy 的方式不同(比如通过 pip 轮子文件 vs. Conda vs. 源码编译),都可能导致它们链接到不同的 BLAS/LAPACK 实现,或者同一实现的不同版本/编译选项!
这些底层库的实现细节、优化策略、甚至使用的具体算法可能略有差异。这足以解释为什么在 macOS (可能用了 Accelerate 或特定编译的 MKL/OpenBLAS) 和 Ubuntu (可能用了 OpenBLAS 或 MKL 的不同版本) 上,相同的 NumPy 代码会跑出不同的浮点数结果。提问者遇到的情况(macOS vs Ubuntu ubuntu-latest
)很可能就是这个原因。
编译器和优化
代码是如何被编译的也会有影响。虽然 Python 是解释型语言,但 NumPy/SciPy 包含大量 C 或 Fortran 编译的代码。编译器优化选项(例如, -O2
vs -O3
, 或者是否启用了 -ffast-math
这类可能牺牲部分 IEEE 754 兼容性的激进优化)也可能导致计算结果的细微变化。CI 环境和本地开发环境的编译工具链、编译参数可能并不完全一致。
其他潜在因素
- 随机性: 如果你的计算中(哪怕是间接)用到了随机数生成,并且没有为测试固定随机种子,那么每次运行结果自然可能不同。检查代码中是否有
numpy.random
或标准库random
的调用。 - 未初始化的内存: 极少情况下,如果代码涉及到底层扩展,并且有读取未初始化内存的 bug,结果可能受内存先前内容的影响,表现出不确定性。但这通常是代码本身的严重缺陷。
- 并发/多线程: 如果计算涉及并行处理(例如,使用了 Dask、Joblib 或自定义的多线程/多进程代码),不同运行环境下线程调度或进程间通信的微小差异,也可能影响浮点数加和的顺序,进而改变最终结果。
解决之道:让测试结果稳定下来
既然知道了原因,我们就可以对症下药。目标是让测试要么能容忍这些微小差异,要么彻底消除环境差异。
方案一:调整断言容忍度 (Tolerance)
这是最常见也是通常推荐的做法。既然浮点数运算本身就存在微小的、难以完全避免的差异,那就不应该强求结果的“精确”相等。应该检查结果是否“足够接近”预期值。
原理:
不直接比较 computed == expected
或 abs(computed - expected) < hardcoded_limit
,而是使用考虑相对误差 (relative tolerance, rtol
) 和绝对误差 (absolute tolerance, atol
) 的比较方法。
- 相对误差 (
rtol
): 允许的差异与预期值的大小成比例。适用于预期值本身较大的情况。 - 绝对误差 (
atol
): 一个固定的最小允许差异。适用于预期值接近零的情况。
比较通常遵循 abs(computed - expected) <= atol + rtol * abs(expected)
的规则。
操作步骤:
NumPy 提供了专门的测试函数来处理这个问题:
numpy.testing.assert_allclose(actual, desired, rtol=1e-07, atol=0, ...)
: 这是最常用的。如果actual
和desired
数组(或标量)在指定的容忍度内不接近,它会抛出AssertionError
。numpy.allclose(a, b, rtol=1e-05, atol=1e-08, ...)
: 返回一个布尔值,表示两个数组是否在容忍度内接近。你可以在自己的断言逻辑中使用它。
对于提问者的问题,原先的断言是 assert np.nanmean((analytic_truth - computed)**2) < limit
,这里的 limit
是基于本地结果 0.58...
设置的 1
。现在 CI 结果是 1.54...
,超出了 1
。
可以这样修改:
-
直接调整标量结果的比较: 既然
np.nanmean(...)
得到的是一个标量值,可以使用pytest.approx
(如果使用 pytest 测试框架) 或手动检查容忍度。-
使用
pytest.approx
: 这是 pytest 用户推荐的方式,它能智能处理相对和绝对容忍度。import numpy as np import pytest # 假设 analytic_truth 和 computed 是你的向量 # analytic_truth = ... # computed = ... mean_squared_error = np.nanmean((analytic_truth - computed)**2) # 假设我们基于分析或经验,认为 1.6 作为一个上限是可接受的 # 或者我们希望结果接近某个理论值,比如 target_error = 0.6 # 并设置一个相对容忍度 # 方式一:检查是否低于一个调整后的上限(可能需要略高于 CI 观测到的最大值) acceptable_upper_limit = 1.6 # 根据 CI 的结果和可接受的误差范围调整 assert mean_squared_error < acceptable_upper_limit # 方式二:如果有一个期望的中心值,用 pytest.approx 比较 # 比如,我们期望这个误差在 0.6 左右,允许一定偏差 expected_error_approx = 0.6 # 只是个例子 # 允许 50% 的相对误差 (rel=0.5) 或 0.1 的绝对误差 (abs=0.1) # pytest 会选择两者中较大的那个容忍范围 # 你需要根据实际情况决定合适的容忍度 assert mean_squared_error == pytest.approx(expected_error_approx, rel=0.5, abs=0.1) # 或者,如果目标就是确保误差低于某个值,但想用相对比较 # 比如 CI 结果 1.55,本地 0.59。我们接受 1.6 为上限。 # 假设理论上的“真实”误差难以精确得知,但我们观察到了波动 # 也许可以直接用 pytest.approx 对 CI 观察到的值做比较,设定一个容忍度 # 表明只要结果在这个值附近(比如上下浮动 10%)我们就接受 assert mean_squared_error == pytest.approx(1.55, rel=0.1) # 检查是否约等于 1.55 (允许 10% 偏差) # 注意:这种做法依赖于观察到的值,可能不够健壮
-
手动检查容忍度:
import numpy as np # ... 计算 mean_squared_error ... # 假设基于 CI 的 1.55,我们设定一个更宽松的上限,比如 1.6 # 这个值需要根据你对算法稳定性的理解和业务要求来定 limit = 1.6 assert mean_squared_error < limit, f"Mean squared error {mean_squared_error} exceeds limit {limit}" # 或者,如果你预期误差在一个范围内,比如 [0.5, 1.6] assert 0.5 <= mean_squared_error <= 1.6, f"Error {mean_squared_error} out of range [0.5, 1.6]"
-
-
比较向量本身
assert_allclose
: 这通常是更根本的检查,确认computed
向量本身与analytic_truth
是否足够接近,然后再 计算它们的均方误差。import numpy as np from numpy.testing import assert_allclose # ... analytic_truth, computed ... # 先检查向量本身是否足够接近 # 需要根据向量中数值的量级 (10^5) 和问题要求选择合适的 rtol, atol # 对于数量级 10^5 的数,可能需要一个较小的 rtol,例如 1e-6 或 1e-7 # atol 可以设小一些,除非你预期有接近零的值 try: assert_allclose(computed, analytic_truth, rtol=1e-6, atol=1e-8) except AssertionError as e: print(f"Vectors are not close enough: {e}") # 可以选择在这里就失败,或者继续计算误差作为诊断信息 mean_squared_error = np.nanmean((analytic_truth - computed)**2) print(f"Resulting mean squared error: {mean_squared_error}") raise # 重新抛出异常 # 如果向量接近,再进行原来的检查,可能 limit 就不需要那么宽松了 # 或者这一步检查都可以省略,如果向量接近性本身就是测试目标 mean_squared_error = np.nanmean((analytic_truth - computed)**2) limit = 1.0 # 也许此时 1.0 的限制就足够了,因为向量已被确认接近 assert mean_squared_error < limit
如何选择 rtol
和 atol
?
这没有通用答案,需要根据:
- 你的计算涉及的数值范围。
- 算法本身的数值稳定性。
- 业务上可接受的误差界限。
一开始可以设得宽松一些(比如rtol=1e-5
,atol=1e-8
),让测试通过,然后再根据需要逐步收紧。观察几次 CI 和本地运行结果的差异范围,也能帮助你设定合理的容忍度。
优点: 实现简单,解决了大部分浮点数比较问题。
缺点: 如果差异过大,说明可能有更深层次的问题,仅仅放宽容忍度可能掩盖了 bug 或严重的数值不稳定。
方案二:统一底层数学库
如果想追求更一致的结果(或者差异实在太大,难以用容忍度覆盖),可以尝试确保本地和 CI 使用相同的 BLAS/LAPACK 实现和版本。
原理:
消除因底层库实现不同导致的计算差异。
操作步骤:
-
使用 Conda: Conda 环境在管理这类复杂依赖方面通常比 pip 更方便。你可以指定使用哪个 BLAS 实现:
# 创建环境时指定使用 openblas conda create -n myenv python=3.11 numpy scipy "blas=*=openblas" conda activate myenv # 或者在已有环境中安装/更新时指定 conda install numpy scipy "blas=*=openblas"
确保本地和 CI 都使用这样配置的 Conda 环境。
-
使用 Docker: 将整个开发和测试环境容器化。在 Dockerfile 中精确控制操作系统、系统库、Python 版本、库版本以及 BLAS/LAPACK 的安装。本地开发和 CI 都在同一个 Docker 镜像中运行,环境差异几乎可以完全消除。这是保证环境一致性的最彻底方法。
# Example Dockerfile snippet FROM ubuntu:22.04 RUN apt-get update && apt-get install -y \ python3.11 python3.11-dev python3-pip \ libopenblas-dev # 安装 OpenBLAS 开发库 # 配置 pip 使用系统 OpenBLAS (可能需要设置环境变量或构建选项) # 或者直接安装预编译好的、链接到 OpenBLAS 的 NumPy/SciPy (e.g., from conda-forge via pip or micromamba) # 设置 Python 别名等 RUN ln -sf /usr/bin/python3.11 /usr/bin/python && \ ln -sf /usr/bin/pip3 /usr/bin/pip # 安装其他依赖 COPY requirements.txt . # 可能需要强制重新编译 numpy/scipy RUN pip install --no-cache-dir --no-binary :all: -r requirements.txt # 尝试强制编译,但这可能很慢且复杂 # 更好的方式是找到或构建链接到特定 BLAS 的轮子,或用 Conda/Mamba # ... Rest of Dockerfile ...
-
控制 Pip 安装 (较难): 如果必须用 pip,事情会复杂些。直接用
pip install numpy scipy
安装的轮子文件可能链接到不同的库。你可以尝试:- 找到明确链接到特定 BLAS (如 OpenBLAS) 的非官方轮子。
- 通过设置环境变量(如
OPENBLAS=/path/to/openblas
)并使用pip install --no-binary numpy scipy numpy scipy
强制从源码编译。这要求本地和 CI 都安装了对应的 BLAS 开发库和编译器,且编译过程可能很慢。
优点: 可能得到更一致的结果,有助于发现是否是底层库差异引起的。
缺点: 配置复杂,特别是跨平台或仅使用 pip 时。Docker 是最可靠的,但也引入了容器化本身的开销。不一定能完全消除所有差异(例如 CPU 指令集不同仍可能产生微小影响)。
方案三:审视数学计算本身
有时候问题不在于测试,而在于计算代码本身对浮点数误差特别敏感。
原理:
改进算法的数值稳定性,使其不易受平台或运算顺序的微小变化影响。
操作步骤:
- 检查算法选择: 是否有其他已知数值更稳定的算法可以完成同样的目标?
- 运算顺序: 避免大数吃小数(例如,将一个很大的数加到一个很小的数上,可能导致小数精度丢失)。可以尝试改变求和顺序(如 Kahan 求和算法,或 Python 的
math.fsum
),或者对中间步骤进行归一化。 - 使用更高精度: 如果计算精度要求非常高且库和平台支持,可以考虑使用
numpy.longdouble
(通常是 80 位或 128 位浮点数),但这会牺牲性能,并且需要确保本地和 CI 环境对longdouble
的支持和行为一致。 - 中间结果处理: 是否有中间步骤产生了极端值或 NaN/Inf,被后续运算掩盖了?
np.nanmean
会忽略 NaN,但这可能隐藏了问题。
优点: 从根本上提高代码的健壮性。
缺点: 需要深入理解数值计算和所用算法。改动可能很大,且不一定总有效果或必要。
方案四:确保确定性,排除干扰
最后,排除那些不太常见但仍有可能的原因。
原理:
确保测试运行是完全确定性的,不受随机性、外部状态等影响。
操作步骤:
- 固定随机种子: 如果代码中任何地方用到了随机数(哪怕是测试数据生成环节),在测试设置 (setup) 阶段固定种子:
或者使用 pytest 的 fixture 来完成。import numpy as np import random import unittest class MyTestCase(unittest.TestCase): def setUp(self): # 在每个测试方法运行前固定种子 np.random.seed(42) random.seed(42) # 如果用了其他库的随机功能,也要固定它们的种子 def test_my_math_code(self): # ... 测试代码 ... pass
- 检查外部依赖: 测试是否依赖了某些文件?确保这些文件在本地和 CI 中完全一致(通过 Git LFS 或其他方式管理)。测试是否读取了环境变量?确保关键环境变量一致或不影响计算结果。
- 审视并发: 如果代码是并行化的,尝试暂时关闭并行(比如设置线程数为 1),看看结果是否稳定。如果是并发导致的问题,可能需要使用更鲁棒的并行求和/聚合方式。
优点: 排除潜在的“诡异”问题来源。
缺点: 这些因素通常不是导致纯数学计算差异的主要原因,但排查一下总没错。
结论(不是结论的结论)
遇到 CI 和本地浮点数测试结果不一致,首选通常是调整断言,使用带容忍度的比较 (numpy.testing.assert_allclose
或 pytest.approx
) 。这是最务实且符合浮点数运算现实的做法。
如果差异巨大,或者你对一致性有非常高的要求,那么研究统一底层数学库(优先考虑 Docker 或 Conda) 是下一步。同时,审视计算代码本身的数值稳定性 和排除随机性等干扰因素 也是有价值的补充排查手段。理解这些差异来源,能帮你写出更健壮、更可靠的科学计算代码和测试。