- 测试
- 开发工具
【免费下载链接】hypothesis
The property-based testing library for Python
给定一个由普通浮点数(无 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 中提出的思路一脉相承:
- 先让代码不崩溃:先用随机数据调用函数,处理掉你不关心的异常(Hypothesis 提供
reject()丢弃无效样本)。 - 再叠加对结果值的约束:一旦"不崩溃"测试跑通,就可以开始对返回值施加额外约束。即使约束非常宽松——如本例的
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
相关推荐
Qlib:当量化投资遇上AI,普通人的智能投资新体验
Qlib:当量化投资遇上AI,普通人的智能投资新体验 你知道吗?曾经需要博士学历和多年编程经验才能玩转的量化投资,现在有了全新的打开方式。Qlib——这个由微软
测试开发工具终极指南:如何快速掌握Hypothesis属性测试从新手到专家
终极指南:如何快速掌握Hypothesis属性测试从新手到专家 Hypothesis是一个强大、灵活且易于使用的Python属性测试库,它能帮助开发者编写更健壮
测试开发工具OpenCore Legacy Patcher完全实操:8步免费让旧Mac流畅运行最新macOS
OpenCore Legacy Patcher完全实操:8步免费让旧Mac流畅运行最新macOS 软件更新永远停在了旧版本、新应用纷纷提示"系统版本过低"、换机
CLI数据分析
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考