你发布了一个为支持工单打标签的机器学习模型。测试套件全绿,每个测试都通过了。
但这些测试没有一个真正检查标签是否正确。你不知道正确答案是什么,也没人知道。对于真实世界的输入,“正确”输出实际上是不可知的,于是你只能退而求其次,检查函数没有崩溃,或者输出形状符合预期。这不是测试,这是碰运气。
这就是 oracle problem,它无处不在:编译器、模拟、优化算法、欺诈检测器,以及任何 ground truth 昂贵、主观或无法计算的系统。当你无法定义预期输出时,传统的单元测试就瓦解了。
Metamorphic testing 提供了一条出路。与其问”这个输出正确吗”,不如问”相关的输入是否产生了满足已知关系的输出”。如果没有,你的代码就有问题;如果有,你就获得了真正的信心——而整个过程从未需要知道正确答案。
什么是 metamorphic testing?
Metamorphic testing 由 Tsong Yueh Chen 及其同事在 1990 年代末提出,以回应一个令人沮丧的现实:许多程序确实很有用,但在传统 oracle 下实际上无法测试。
核心思想很简单。你识别出一个 metamorphic relation:一个应该在程序多次执行的输入和输出之间成立的性质。你在源输入上运行程序,按照该关系转换输入,再次运行,然后检查输出是否按照该性质的预测相互关联。
不需要预期输出,不需要人工标注,不需要黄金数据集。
考虑一个在加权图中寻找最短路径的函数:
from typing import List, Tuple, Optional
def shortest_path(
edges: List[Tuple[int, int, float]],
start: int,
end: int
) -> Optional[float]:
"""Dijkstra's algorithm. Returns path length or None if unreachable."""
import heapq
graph = {}
for u, v, w in edges:
graph.setdefault(u, []).append((v, w))
dist = {start: 0.0}
heap = [(0.0, start)]
while heap:
d, u = heapq.heappop(heap)
if u == end:
return d
if d > dist.get(u, float('inf')):
continue
for v, w in graph.get(u, []):
nd = d + w
if nd < dist.get(v, float('inf')):
dist[v] = nd
heapq.heappush(heap, (nd, v))
return None
对于复杂的图,手工计算预期的最短路径很繁琐。但我们知道几个必须成立的 metamorphic relations:
-
关于边权重的单调性。 如果你增加任意一条边的权重,最短路径不应该变短。它可以保持不变(如果那条边不在最优路径上)或者变长。
-
缩放下的同质性。 如果你将每条边的权重乘以一个正常数,最短路径长度应该按相同比例缩放。
-
无向图上的路径对称性。 如果图是无向的,交换起点和终点应该得到相同的路径长度。
这些不是启发式规则,而是数学性质。只要其中任何一个不成立,实现就是错的,毫无商量余地。
如何编写 metamorphic test
下面是单调性关系在实践中的样子:
import random
def test_shortest_path_monotonicity():
# Generate a random connected graph
nodes = list(range(10))
edges = []
for i in range(len(nodes) - 1):
edges.append((i, i + 1, random.uniform(1.0, 10.0)))
# Add some random cross edges
for _ in range(10):
u, v = random.sample(nodes, 2)
edges.append((u, v, random.uniform(1.0, 10.0)))
start, end = 0, 9
original = shortest_path(edges, start, end)
assert original is not None
# Increase the weight of one arbitrary edge
idx = random.randrange(len(edges))
u, v, w = edges[idx]
modified_edges = list(edges)
modified_edges[idx] = (u, v, w + 5.0)
modified = shortest_path(modified_edges, start, end)
assert modified is not None
assert modified >= original
这个测试从不计算预期的最短路径,也不需要。它检查一个结构性性质是否成立,而这足以捕获种类惊人的 bug:符号错误、权重累积中的差一错误、优先队列排序错误等等。
缩放关系测试起来更简单:
def test_shortest_path_scaling():
nodes = list(range(8))
edges = []
for i in range(len(nodes) - 1):
edges.append((i, i + 1, random.uniform(2.0, 5.0)))
start, end = 0, 7
original = shortest_path(edges, start, end)
factor = 3.5
scaled_edges = [(u, v, w * factor) for u, v, w in edges]
scaled = shortest_path(scaled_edges, start, end)
assert abs(scaled - original * factor) < 1e-9
注意浮点容差。Metamorphic tests 也无法幸免于数值精度问题,所以写断言时要像对待其他数值测试一样谨慎。
这种方法真正有用的场景
Metamorphic testing 在传统 oracle 薄弱或根本不存在的领域大放异彩。
机器学习。 你不知道某条影评的确切情感分数,但你知道加上”terrible”这个词不应该增加正面情感。你不知道目标检测器的确切边界框,但你知道水平翻转图像应该同时翻转边界框坐标。
编译器。 验证优化后的二进制文件对每个可能的程序都产生与未优化版本完全相同的输出是不可能的。但你可以检查:先编译一个程序,再用一次无操作转换(比如重命名变量)再次编译,产生的二进制文件在语义上是等价的。
科学计算。 你不知道复杂模拟中粒子的精确轨迹,但你知道反转时间应该反转轨迹。你不知道分子的精确基态能量,但你知道随着基组大小增加,它应该下降(或保持不变)。
在每个案例中,洞察都是一样的:正确性并不总是关于匹配单个预期值,有时它关乎在转换过程中保持结构。
权衡与局限
Metamorphic testing 不是免费的,也不能替代所有其他类型的测试。
关系可能不完整。 一个程序可以通过你定义的所有 metamorphic relations,但仍然可能是错的。如果你的关系集没有覆盖某个特定的 bug 类别,那个 bug 就会溜过去。这就是覆盖问题,而且确实存在。
关系可能是错的。 如果你错误地断言某个性质成立,而事实并非如此,你的测试就会变成假阳性的工厂。我曾见过一个团队断言 k-means 聚类应该对特征缩放保持不变性。事实并非如此,质心会随数据缩放。测试通过了好几个月,因为测试数据中的缩放因子恰好是 1.0。当真实数据到来时,模型在静默退化,而测试仍然全绿。
调试失败更难。 传统单元测试失败时,你确切知道预期输出是什么。Metamorphic test 失败时,你知道某个关系被违反了,但仍然不知道正确输出是什么。你必须从性质违反反向推理到底层 bug,这可能更费力。
测试数据生成很重要。 随机图、随机句子和随机图像不能代表真实输入。基于合成数据的 metamorphic test 可能通过,而生产系统却在你生成器从未产生的边缘情况上失败。使用像 Hypothesis 这样的 property-based testing 库来帮忙,但对你自己的 generators 保持怀疑。
如何从今天开始使用
你不需要新框架,只需要三件事:
-
选一个 oracle 薄弱的函数。 模型推理方法、几何计算、模拟步骤都可以。那种你现在只能测试”它没崩溃”并希望做得更多的函数。
-
头脑风暴三个关系。 问自己:什么转换应该让输出保持不变?什么转换应该以可预测的方式改变输出?什么样的输入对应该产生相互关联的输出?把它们写下来,即使看起来很明显。
-
把其中一个关系实现为测试。 在随机输入上运行它。如果失败,你要么发现了 bug,要么发现了错误的关系。两者都有价值。
针对我之前提到的 k-means 例子,一组正确的关系看起来是这样的:
import numpy as np
from sklearn.cluster import KMeans
def test_kmeans_translation_invariance():
X = np.random.rand(100, 3)
shift = np.array([10.0, -5.0, 2.0])
km1 = KMeans(n_clusters=3, random_state=42, n_init=10).fit(X)
km2 = KMeans(n_clusters=3, random_state=42, n_init=10).fit(X + shift)
# Centroids should differ by exactly the shift vector
np.testing.assert_allclose(km1.cluster_centers_ + shift, km2.cluster_centers_)
如果聚类逻辑错误处理了坐标系,这个测试就会失败——而它从未断言正确的质心应该是什么。
当正确答案不可知时,测试结构
Oracle problem 不是测试的边缘案例,而是大量有用软件的默认状态。Metamorphic testing 无法完全解决它,但能让你从”我没法测试这个”转变为”我可以测试那些重要的性质”。
从一个关系、一个函数,以及它捕获的一个真实 bug 开始。这就足以支撑使用这种方法了。其他的不过是多加几个关系而已。