返回

修复CI浮点数单元测试失败:本地与CI结果不一致?

python

CI vs. 本地:为何我的单元测试浮点数结果不一样?

写单元测试的时候,你可能碰到过这样一个怪事:一段涉及浮点数计算的代码,在你的本地机器上跑得好好的,单元测试稳稳通过;可一旦提交到 CI/CD 流水线(比如 GitHub Actions、GitLab CI),同样的测试,有时就会挂掉,而且报错显示的计算结果和你本地跑出来的还不一样!就像提问者遇到的情况:本地结果是 0.586...,CI 上却变成了 1.549...。代码没变,库版本也锁定了,Python、NumPy、SciPy 版本都一样,本地是 macOS,CI 是 Ubuntu。这到底是怎么回事?

探究原因:为何结果会飘忽不定?

这背后通常不是什么魔法,而是计算机处理浮点数时的一些固有特性和环境差异在作祟。

浮点数的“不精确”本质

计算机表示浮点数(比如 floatdouble 类型)遵循 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 == expectedabs(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, ...): 这是最常用的。如果 actualdesired 数组(或标量)在指定的容忍度内不接近,它会抛出 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

可以这样修改:

  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]"
      
  2. 比较向量本身 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
    

如何选择 rtolatol
这没有通用答案,需要根据:

  • 你的计算涉及的数值范围。
  • 算法本身的数值稳定性。
  • 业务上可接受的误差界限。
    一开始可以设得宽松一些(比如 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) 阶段固定种子:
    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
    
    或者使用 pytest 的 fixture 来完成。
  • 检查外部依赖: 测试是否依赖了某些文件?确保这些文件在本地和 CI 中完全一致(通过 Git LFS 或其他方式管理)。测试是否读取了环境变量?确保关键环境变量一致或不影响计算结果。
  • 审视并发: 如果代码是并行化的,尝试暂时关闭并行(比如设置线程数为 1),看看结果是否稳定。如果是并发导致的问题,可能需要使用更鲁棒的并行求和/聚合方式。

优点: 排除潜在的“诡异”问题来源。
缺点: 这些因素通常不是导致纯数学计算差异的主要原因,但排查一下总没错。

结论(不是结论的结论)

遇到 CI 和本地浮点数测试结果不一致,首选通常是调整断言,使用带容忍度的比较 (numpy.testing.assert_allclosepytest.approx) 。这是最务实且符合浮点数运算现实的做法。

如果差异巨大,或者你对一致性有非常高的要求,那么研究统一底层数学库(优先考虑 Docker 或 Conda) 是下一步。同时,审视计算代码本身的数值稳定性排除随机性等干扰因素 也是有价值的补充排查手段。理解这些差异来源,能帮你写出更健壮、更可靠的科学计算代码和测试。