Python zip:评测为何静默丢样本
标签有三条,模型输出只有两条,评测代码却算出了100%的准确率。问题可能还没到指标公式:普通 zip 在较短的输入结束时就停止,缺少的那条样本没有进入比较。需要等长输入时,Python 3.10起可以使用 zip(..., strict=True),把静默截断变成错误。
文章目录
但 strict 不是完整的数据校验,也不是事务。它在迭代走到长度不一致的位置才报错;已经写出的结果不会自动撤回,等长但顺序错误的数据仍然能通过。要让评测可信,需要分别检查数量、身份和结果提交时机。
100%的分数可能来自少算了一条
用一个完全虚构的小例子:标签为 [1, 0, 1],预测为 [1, 0]。普通 zip 只产生 (1, 1)、(0, 0) 两对。如果分母采用配对数,两个都正确,结果是2/2;如果分母采用原标签数,则得到2/3。代码没有凭空改变预测,却因为分母与缺失处理方式不同,产生了两种数字。
这里不能把2/3直接宣布为“修正后的真实准确率”。第三条预测为什么缺失、应当算失败还是剔除,必须由预先确定的评测协议决定。若契约要求每条样本都有预测,那么这份输入应先判为不完整,而不是挑一个看起来更保守的分数发表。
相同风险也存在于训练数据与权重、检索结果与相关性标签、请求ID与异步返回结果的配对中。zip 不知道业务预期的总体大小,也不知道某条缺失是否允许;它只是执行一种迭代组合规则。评测程序需要自己表达“这些输入必须覆盖同一批样本”。
strict检查发生在消费时
PEP 618 为 zip 增加了可选的 strict 参数,适用版本从Python 3.10开始。它没有改变默认截断行为;调用方必须明确选择检查。更容易遗漏的是,它返回的仍然是迭代器,而不是在构造时就完成全量验证的容器。
因此,仅执行 z = zip(labels, predictions, strict=True) 还没有证明两边等长。示例中,前两次 next 都能正常得到配对,第三次才发现一边先结束并抛出 ValueError。如果调用方只读取第一对就停止,也不能把“没有报错”当成整批输入通过检查。
对流式数据,这个时间点很自然:在真正走到尾部之前,程序未必知道还有多少条。对评测系统,它意味着不能先发布一部分结果,再期待尾部检查替你保证整批有效。报错所发现的是契约违背,报错之前发生的事情则是另一项需要设计的责任。失败后也不要假定原迭代器仍在起点;重试需要可重建的数据源或明确的恢复位置。
检测错误不会撤销已经写出的前缀
考虑循环体直接追加结果的情况。前两对已经被写入列表,第三次迭代才报错;捕获异常后,这两条仍然存在。如果循环体执行的是文件追加、数据库写入或消息发送,strict 同样没有提供撤回能力。
小批量、可放入内存的数据,可以先用 list 完整消费严格配对,再执行提交。长度不一致时,赋值不能完成,else 分支不运行,因此示例的 committed 仍为空。暂存会额外保存整批配对,空间随样本数增长。这只隔离了我们安排在验证之后的列表写入,不是为任意外部系统建立了事务。
上游迭代器仍可能已经读取文件、调用接口或产生其他副作用;暂存不能让这些动作倒退。对大文件或在线流,需要临时输出、事务或可撤回的批次提交机制,并明确何时对读者可见。不要把“我用了 strict”写成整条流水线原子性的证明。
完整复现:数量通过以后,还要核对ID
将下面代码保存为 zip_strict_example.py,用Python 3.10或更新版本运行;本地实际执行环境为Python 3.15.0。代码使用整数和 Fraction 保持演示数字精确,没有调用模型或使用真实用户数据。运行时不要加 -O,以保留用于验证演示结果的断言。
from itertools import zip_longest
from fractions import Fraction
labels = [1, 0, 1]
predictions = [1, 0]
pairs = list(zip(labels, predictions))
assert len(pairs) == 2
assert Fraction(sum(y == p for y, p in pairs), len(pairs)) == 1
assert Fraction(sum(y == p for y, p in pairs), len(labels)) == Fraction(2, 3)
# strict checking happens as iteration reaches the length mismatch.
z = zip(labels, predictions, strict=True)
assert next(z) == (1, 1)
assert next(z) == (0, 0)
try:
next(z)
except ValueError:
pass
else:
raise AssertionError('length mismatch not detected')
written = []
try:
for pair in zip(labels, predictions, strict=True):
written.append(pair)
except ValueError:
pass
assert written == [(1, 1), (0, 0)] # Earlier effects remain.
committed = []
try:
staged = list(zip(labels, predictions, strict=True))
except ValueError:
pass
else:
committed.extend(staged)
assert committed == []
truth = [('a', 1), ('b', 0)]
reordered = [('b', 0), ('a', 1)]
wrong_pairs = list(zip(truth, reordered, strict=True))
assert len(wrong_pairs) == 2 # Equal length does not establish identity.
assert any(a[0] != b[0] for a, b in wrong_pairs)
def checked_score(truth_rows, prediction_rows):
hits = count = 0
for (truth_id, y), (prediction_id, p) in zip(
truth_rows, prediction_rows, strict=True):
if truth_id != prediction_id:
raise ValueError('sample IDs differ')
hits += y == p
count += 1
if count == 0:
raise ValueError('empty evaluation')
return Fraction(hits, count)
assert checked_score(truth, truth) == 1
for bad in [reordered, truth[:1], []]:
try:
checked_score(truth, bad)
except ValueError:
pass
else:
raise AssertionError('invalid input accepted')
try:
checked_score([], [])
except ValueError:
pass
else:
raise AssertionError('empty evaluation accepted')
missing = object()
rows = list(zip_longest([None, 1], [None], fillvalue=missing))
assert rows[0] == (None, None)
assert rows[1][1] is missing
print('truncation, delayed error, partial effects, staging, IDs, missing: passed')
truth 的顺序是 a、b,reordered 的顺序是 b、a。两边长度相同,因此严格 zip 可以完整消费;但按位置比较的是不同样本。checked_score 在每对比较前检查ID,把身份约束与数量约束分开,也拒绝空评测,避免把“没有样本”悄悄解释成某种分数。
函数只在循环完整结束后返回分数。遇到缺失、长度不等或ID不一致时,即便局部计数器已累加,也没有向调用方返回部分指标。这个局部性质比“捕获异常后继续使用已算出的 hits”更适合完整批次评测。
这还不是通用评测平台。示例没有检查ID的全局唯一性:如果两边都重复同一个ID,逐对相等仍会通过。它也没有验证标签取值域或处理分布式分片。真正的输入契约应补上这些独立条件,不能认为一个等长开关能替代所有数据质量检查。
需要保留缺失时,先把缺失表示清楚
有时任务并不要求拒绝长度差异,而是要把缺失行交给报告程序逐条解释。这时可以考虑 itertools.zip_longest。但它只是保留尾部并填充值,不会决定缺失应计为错误、允许重试还是排除,也不解决样本顺序。
示例用独立的 object 作为哨兵,再通过 is 判断。这样输入中合法的 None 与“这一侧没有元素”不会混在一起。若填充值本身也是合法数据,报告层很容易把缺失误读成普通预测;表示方式会影响后续判断,所以缺失应是明确状态,而不是随手选的默认值。
对顺序不同的结果,正确方向往往是按稳定ID对齐,并明确重复、缺失和额外ID的策略。不能只因为 zip_longest 把行数补齐,就继续按位置计算。若允许重试,还要保证同一样本的多次返回不会在合并时变成多条独立样本。
把配对契约放在指标之前
在修改模型或解释分数上升前,先核对这次参与评分的是哪一批样本:预期样本数是否一致、输出是否完整、ID是否一一对应、失败样本按什么协议处理。数量一致只能回答其中一个问题。固定总体之后,再讨论准确率、F1或其他汇总指标,才有可比较的基础。
这与F1评测先固定类别集合是不同层面的控制:那篇关注指标汇总所使用的类别,本篇关注送进指标之前的样本配对。两层都可能让分数变化,却不意味着模型行为变好了。
对于必须等长的有限输入,strict 是一个便宜而清楚的契约检查;如果业务本来就允许取最短前缀,默认 zip 可以是正确选择,但应把截断策略写清楚。先决定想保留什么,再选择工具,不要让工具的默认停止位置替业务决定评测总体。
Read the complete English version。本文保留2026年10月27日23:30(北京时间)的原计划日期,实际于2026年10月29日补发。PEP 618创建于2020年,说明的功能早于原计划日期;这不是新版本新闻。
参考:PEP 618:zip的可选长度检查。文中数据、反例及两幅流程图为独立教学示例,不是模型测评结果。
如果这些可复现检查帮你发现了静默丢样本或部分写入,欢迎自愿支持后续技术写作。
支持
如果文中的解释或示例帮你解决了问题、节省了时间,欢迎自愿打赏支持本站。
二维码可点击放大。更多支持方式见支持页面。


