☰
用 Hypothesis 属性测试破解浮点均值:从溢出到次正规数的完整实战指南
2026/9/25 11:32:58 网站建设 项目流程
  • 测试
  • 开发工具

【免费下载链接】hypothesis

The property-based testing library for Python

项目地址:https://gitcode.com/gh_mirrors/hy/hypothesis
点击查看免费下载

给定一个由普通浮点数(无 NaN、无 Infinity,仅"简单"有限浮点数)组成的列表,计算其均值(平均)。这看起来是一个教科书级别的入门问题——但事实证明,这是一道难题:想做到"大致正确"都很困难。本篇指南以 Hypothesis 官方案例为基础,逐步演示如何用属性测试(property-based testing)暴露朴素均值实现中的浮点缺陷,剖析底层原因(溢出、舍入误差、次正规数),并引入min(ls) <= mean(ls) <= max(ls)这类宽松约束作为普适的测试技巧。读完你将掌握:@given、floats(allow_nan=False, allow_infinity=False)、lists(..., min_size=1)的组合用法,浮点极端值(最大有限数、次正规数)为何能让sum(ls)/len(ls)、numpy均值甚至 Python 3.4statistics模块全部翻车,以及"先保证不崩溃、再叠加结果约束"的分层测试策略。

问题:计算一个浮点数列表的均值

假设你有一个浮点数列表,元素都是"普通"浮点数——没有 NaN,也没有 Infinity,只是常见的、有限的浮点数。任务是计算均值(平均值)。

关键问题在于:你做得对吗?

这个问题远比看起来困难。为了验证正确性,先写出一个基于 Hypothesis 的属性测试:

from hypothesis import given from hypothesis.strategies import floats, lists @given(lists(floats(allow_nan=False, allow_infinity=False), min_size=1)) def test_mean_is_within_reasonable_bounds(ls): assert min(ls) <= mean(ls) <= max(ls)

这个测试对正确性的要求其实极其宽松:它只要求均值落在列表的最小值和最大值之间,完全没有验证均值与各元素的精确关系。事实上,min、max、中位数等一大堆函数都能通过这个测试——它们根本不是均值。

然而,几乎所有人的均值实现都过不了这个测试。这就是属性测试的价值:即使是如此宽松的约束,也能轻松揪出浮点计算的深层错误。

先理解测试用到的两个策略,它们分别来自 floats 定义 与 lists 定义:

  • floats(allow_nan=False, allow_infinity=False):生成有限的浮点数。在源码 numbers.py 中可以看到,allow_nan默认在min_value与max_value均为None时为True;这里显式关闭 NaN 与 Infinity,正是为了模拟"没有 nasty tricks"的普通浮点数场景。
  • lists(..., min_size=1):生成长度至少为 1 的浮点数列表,避免对空列表求均值。源码中的校验逻辑保证0 <= min_size <= max_size(见 collections.py)。

第一个实现:sum(ls) / len(ls)与溢出

按照均值定义直接写一个实现:

def mean(ls): return sum(ls) / len(ls)

这看起来足够合理——它就是均值的定义——但它是错的。Hypothesis 立刻给出反例:

assert inf <= 8.98846567431158e+307 + where inf = mean([8.988465674311579e+307, 8.98846567431158e+307]) + and 8.98846567431158e+307 = max([8.988465674311579e+307, 8.98846567431158e+307]) Failing test case: test_mean_is_within_reasonable_bounds( ls=[8.988465674311579e+307, 8.98846567431158e+307] )

问题根源在于:有限的浮点数可能大到它们的和溢出为无穷大。两个约8.988e+307的数相加(这是接近 IEEE-754 double 最大有限值的量级),其结果超出 double 的表示范围,溢出为inf。接下来用有限数除inf仍然得到inf——而inf显然超出了[min(ls), max(ls)]的范围。

这是经典的"先求和后求平均"陷阱:分子在求和阶段就已溢出,无论分母(列表长度)多么普通都于事无补。

第二个实现:先除后加与精度丢失

为了避免求和溢出,尝试先把每个数除以长度,再求和:

def mean(ls): return sum(l / len(ls) for l in ls)

这次的反例完全不同:

assert min(ls) <= mean(ls) <= max(ls) assert 1.390671161567e-309 <= 1.390671161566996e-309 where 1.390671161567e-309 = min([1.390671161567e-309, 1.390671161567e-309, 1.390671161567e-309]) and 1.390671161566996e-309 = mean([1.390671161567e-309, 1.390671161567e-309, 1.390671161567e-309]) Failing test case: test_mean_is_within_reasonable_bounds( ls=[1.390671161567e-309, 1.390671161567e-309, 1.390671161567e-309] )

注意反例中的量级:1.390671161567e-309。这是次正规数(subnormal number)——接近 double 能表示的最小正数。这次的问题不是溢出,而是浮点数的精度限制:

  • 浮点数只能精确表示"2 的幂乘以整数"形式的数值;
  • 除以 3 会引入舍入误差,导致(x / 3) * 3 != x在一般情况下成立;
  • 列表中有三个1.390671161567e-309,每个元素先除以 3 再求和,三次舍入误差累积后,均值竟比最小值还小一点点(1.390671161566996e-309 < 1.390671161567e-309)。

从源码结构看,Hypothesis 的floats策略在无界时默认allow_subnormal=True(见 numbers.py 的推断逻辑),因此它会系统性地生成次正规数来考验你的实现;这正是"先除后加"方案翻车的原因。仓库中 test_subnormal_floats.py 还验证了allow_subnormal=True与边界参数组合时的参数校验行为,说明次正规数是该策略的一等公民。

现有实现的战绩:numpy 与 Python 3.4 statistics

用同样的测试考验现有实现。

numpy 版本:

import numpy as np def mean(ls): return np.array(ls).mean()

它撞上了第一个实现同样的问题——求和溢出:

assert min(ls) <= mean(ls) <= max(ls) assert inf <= 8.98846567431158e+307 where inf = mean([8.988465674311579e+307, 8.98846567431158e+307]) and 8.98846567431158e+307 = max([8.988465674311579e+307, 8.98846567431158e+307]) Failing test case: test_mean_is_within_reasonable_bounds( ls=[8.988465674311579e+307, 8.98846567431158e+307] )

Python 3.4 新增的statistics模块同样失败(该问题在 3.5.2 中修复):

OverflowError: integer division result too large for a float Failing test case: test_mean_is_within_reasonable_bounds( ls=[8.988465674311579e+307, 8.98846567431158e+307] )

与"溢出为无穷"不同,这里直接抛出OverflowError。原因在于statistics模块内部把一切转换为Fraction类型——一种任意精度有理数类型——而"从 Fraction 转换回 float 的时机与位置"的细节问题,导致产生了无法轻易转回 float 的有理数。

结论很清晰:连语言标准库和主流数值库的朴素均值实现,都无法满足一个如此宽松的属性测试。

作弊方案:钳制(clamp)能过测试,但不是均值

要让测试通过其实很容易——只要"作弊",不去真正计算均值即可:

def clamp(lo, v, hi): return min(hi, max(lo, v)) def mean(ls): return clamp(min(ls), sum(ls) / len(ls), max(ls))

即直接把结果限制在期望区间[min(ls), max(ls)]内。这个函数能满足上述测试,但它掩盖了真实的计算错误,并不值得推荐——它提醒我们:通过属性测试不代表实现正确,属性测试的价值在于"证伪"而非"证明"。真正正确的均值实现(能够通过该测试)相当困难,业界甚至有针对"计算两个数的均值"这一子问题的专门研究论文。

这一测试揭示的通用技巧与浮点测试的反思

这个例子是通用测试策略的一个绝佳示范,也与 Getting started with Hypothesis 中提出的思路一脉相承:

  1. 先让代码不崩溃:先用随机数据调用函数,处理掉你不关心的异常(Hypothesis 提供reject()丢弃无效样本)。
  2. 再叠加对结果值的约束:一旦"不崩溃"测试跑通,就可以开始对返回值施加额外约束。即使约束非常宽松——如本例的min(ls) <= mean(ls) <= max(ls)——也常常能捕获有趣的 bug。

该测试还揭示了一个更深层的问题:浮点数学非常难,因此它不太适合用 Hypothesis 测试。这不是因为 Hypothesis 不擅长测试浮点代码,恰恰相反,正是因为它太擅长揭示"编程实际有多难"——而浮点代码比人们愿意承认的还要难得多。Hypothesis 会发现的这类 bug,多数开发者往往不打算修复,态度通常是:"这些数字太怪了,我们不太关心,大概够用了。"很少有人愿意为浮点正确性投入数值敏感性分析(numerical sensitivity analysis)所需的工作量。

结语:直面困难,而非回避

作者坦承,因为"告诉人们他们不想修的 bug 既换不来 bug 修复,也换不来朋友",已经不再常用这个例子来演示 Hypothesis。但了解这个问题的存在仍然值得:

  • 编程确实很难,忽略问题并不会让它变简单。你可以暂时无视正确性问题,直到它们真正咬到你,但最好在它们发生时不要感到意外。
  • 通用技术仍然值得记住:这个技巧不只对浮点数有用——大多数代码都能从中受益,而且大多数时候测试告诉你的 bug 远没有这么令人不适。Hypothesis 的官方入门教程(introduction.rst)同样展示了"先lists(integers())排序、再加floats(allow_nan=False)"的渐进式约束思路,与本例异曲同工。

下次面对"简单"的数值问题时,不妨先问一句:我的实现真的正确吗?让 Hypothesis 来回答。

  • 测试
  • 开发工具

【免费下载链接】hypothesis

The property-based testing library for Python

项目地址:https://gitcode.com/gh_mirrors/hy/hypothesis
点击查看免费下载

相关推荐

上一篇:洛雪音乐助手:免费跨平台音乐播放器的完整使用指南
下一篇:在 Create React App 项目中集成 Socket.IO:从实时服务器到 React 客户端完整实战

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询