Python zip:评测为何静默丢样本

黎 浩然/ 27 10 月, 2026/ 教程/TUTORIAL, 计算机/COMPUTER/ 0 comments

标签有三条,模型输出只有两条,评测代码却算出了100%的准确率。问题可能还没到指标公式:普通 zip 在较短的输入结束时就停止,缺少的那条样本没有进入比较。需要等长输入时,Python 3.10起可以使用 zip(..., strict=True),把静默截断变成错误。

文章目录
  1. 100%的分数可能来自少算了一条
  2. strict检查发生在消费时
  3. 检测错误不会撤销已经写出的前缀
  4. 完整复现:数量通过以后,还要核对ID
  5. 需要保留缺失时,先把缺失表示清楚
  6. 把配对契约放在指标之前

但 strict 不是完整的数据校验,也不是事务。它在迭代走到长度不一致的位置才报错;已经写出的结果不会自动撤回,等长但顺序错误的数据仍然能通过。要让评测可信,需要分别检查数量、身份和结果提交时机。

100%的分数可能来自少算了一条

用一个完全虚构的小例子:标签为 [1, 0, 1],预测为 [1, 0]。普通 zip 只产生 (1, 1)、(0, 0) 两对。如果分母采用配对数,两个都正确,结果是2/2;如果分母采用原标签数,则得到2/3。代码没有凭空改变预测,却因为分母与缺失处理方式不同,产生了两种数字。

这里不能把2/3直接宣布为“修正后的真实准确率”。第三条预测为什么缺失、应当算失败还是剔除,必须由预先确定的评测协议决定。若契约要求每条样本都有预测,那么这份输入应先判为不完整,而不是挑一个看起来更保守的分数发表。

相同风险也存在于训练数据与权重、检索结果与相关性标签、请求ID与异步返回结果的配对中。zip 不知道业务预期的总体大小,也不知道某条缺失是否允许;它只是执行一种迭代组合规则。评测程序需要自己表达“这些输入必须覆盖同一批样本”。

标签:a→1,b→0,c→1
预测:a→1,b→0,c→缺失
普通位置配对:只剩 a、b → 2/2
图1:教学数据的第三条没有进入配对。100%来自已配对前缀,不能据此声称完整评测成功。

strict检查发生在消费时

PEP 618 为 zip 增加了可选的 strict 参数,适用版本从Python 3.10开始。它没有改变默认截断行为;调用方必须明确选择检查。更容易遗漏的是,它返回的仍然是迭代器,而不是在构造时就完成全量验证的容器。

因此,仅执行 z = zip(labels, predictions, strict=True) 还没有证明两边等长。示例中,前两次 next 都能正常得到配对,第三次才发现一边先结束并抛出 ValueError。如果调用方只读取第一对就停止,也不能把“没有报错”当成整批输入通过检查。

对流式数据,这个时间点很自然:在真正走到尾部之前,程序未必知道还有多少条。对评测系统,它意味着不能先发布一部分结果,再期待尾部检查替你保证整批有效。报错所发现的是契约违背,报错之前发生的事情则是另一项需要设计的责任。失败后也不要假定原迭代器仍在起点;重试需要可重建的数据源或明确的恢复位置。

检测错误不会撤销已经写出的前缀

考虑循环体直接追加结果的情况。前两对已经被写入列表,第三次迭代才报错;捕获异常后,这两条仍然存在。如果循环体执行的是文件追加、数据库写入或消息发送,strict 同样没有提供撤回能力。

小批量、可放入内存的数据,可以先用 list 完整消费严格配对,再执行提交。长度不一致时,赋值不能完成,else 分支不运行,因此示例的 committed 仍为空。暂存会额外保存整批配对,空间随样本数增长。这只隔离了我们安排在验证之后的列表写入,不是为任意外部系统建立了事务。

上游迭代器仍可能已经读取文件、调用接口或产生其他副作用;暂存不能让这些动作倒退。对大文件或在线流,需要临时输出、事务或可撤回的批次提交机制,并明确何时对读者可见。不要把“我用了 strict”写成整条流水线原子性的证明。

直接写入:第1对写出 → 第2对写出 → 长度错误 → 前缀仍在
先暂存:完整消费检查 → 若报错则不提交 → 下游保持未写入
图2:两种提交顺序的差别。暂存方案只保护检查之后的下游提交,不回滚上游读取和其他副作用。

完整复现:数量通过以后,还要核对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的可选长度检查。文中数据、反例及两幅流程图为独立教学示例,不是模型测评结果。

如果这些可复现检查帮你发现了静默丢样本或部分写入,欢迎自愿支持后续技术写作。

支持

如果文中的解释或示例帮你解决了问题、节省了时间,欢迎自愿打赏支持本站。

微信支持二维码,点击查看大图
微信
支付宝支持二维码,点击查看大图
支付宝
Buy Me a Coffee,点击支持本站
Buy Me a Coffee

二维码可点击放大。更多支持方式见支持页面。

Share this Post

Leave a Comment

您的邮箱地址不会被公开。 必填项已用 * 标注

*
*