LoHoSearch 的任务分布和失败轨迹——两份材料对着读才不漏判

文章导读
只看总体通过率的风险在于:它把不同任务类型混成了一个数。如果结果文件里简单类型的任务占了大部分,而少数困难类型几乎全错,总通过率仍然可能显得体面,但结论无法支撑“LoHoSearch 在这些任务上可用”。可行的做法是把两份材料对着读:一份是任务类型计数表,说明结果里各类任务各有多少;另一份是失败轨迹的首个失分步骤频次表,说明错误集中在哪个环节。两份表交叉后,再决定结论要写到多大范围。
📋 目录
  1. 一 用脚本把任务类型计数成一张表
  2. 二 统计失败轨迹里反复出现的同一步
  3. 三 把类型分布和失败频次交叉
  4. 四 把交叉结果整理成复核清单
A A

只看总体通过率的风险在于:它把不同任务类型混成了一个数。如果结果文件里简单类型的任务占了大部分,而少数困难类型几乎全错,总通过率仍然可能显得体面,但结论无法支撑“LoHoSearch 在这些任务上可用”。可行的做法是把两份材料对着读:一份是任务类型计数表,说明结果里各类任务各有多少;另一份是失败轨迹的首个失分步骤频次表,说明错误集中在哪个环节。两份表交叉后,再决定结论要写到多大范围。

总通过率无法回答“高分是否由少数任务类型支撑”。建议先用计数表核对任务规模与类型占比,再用失败样本的首个失分步骤频次表定位经常出错的位置,最后把两张表交叉成「类型 × 首个失分步骤」矩阵。行样本量过少的格子标注为样本不足,只作线索;类型字段缺失比例偏高时,所有按类型划分的结论都需要缩小范围。

用脚本把任务类型计数成一张表

计数脚本通常放在结果文件生成之后、撰写评估说明之前执行,输入是逐行的 JSON/JSONL 结果文件,输出是一张三列表格。脚本里的字段名是占位名,需要按实际结果文件替换:下面用 task_type、task_id、tags 表示类型来源,运行前先列一条记录确认字段确实存在。

# count_by_type.py —— 输出任务类型计数表并做总数对账
import json, sys, collections

TYPE_KEYS = ("task_type", "type", "category", "tags")

def pick_type(rec):
    for k in TYPE_KEYS:
        v = rec.get(k)
        if isinstance(v, str) and v.strip():
            return v.strip()
        if isinstance(v, list) and v:
            return str(v[0])          # 多标签只取首个,这个规则要写进表注
    tid = str(rec.get("task_id", ""))
    prefix = tid.split("-")[0] if "-" in tid else ""
    return prefix or "unknown"        # 兜底:按 task_id 前缀归类

counts = collections.Counter()
total = valid = 0
seen = set()
with open(sys.argv[1], encoding="utf-8") as f:
    for line in f:
        line = line.strip()
        if not line:
            continue
        total += 1
        try:
            rec = json.loads(line)
        except json.JSONDecodeError:
            continue
        valid += 1
        seen.add(rec.get("task_id"))
        counts[pick_type(rec)] += 1

print("| 任务类型 | 数量 | 占本份结果 |")
print("|---|---|---|")
for t, n in counts.most_common():
    print(f"| {t} | {n} | {n / valid:.1%} |")
print(f"\n# 对账: 总行数={total} 可解析={valid} 去重task_id={len(seen)} 计数合计={sum(counts.values())}")

字段缺失在自建评估里很常见,替代归类的优先级建议是:显式类型字段 → tags 首个标签 → task_id 前缀 → 数据集目录名 → unknown。每一步降级都要写进表注,因为“按前缀猜类型”和“读显式字段”得到的结果可信度不同。unknown 行必须保留在表里,不能悄悄并入其他类型;unknown 占比明显偏高时,后面所有按类型的判断都只能当作线索。

对账做两件事:一是计数合计应等于可解析行数,可解析行数应等于总行数减去解析失败行数,不相等通常说明有记录被 pick_type 返回了空值后又被累加到别处;二是去重后的 task_id 数应与总行数接近,去重数明显少于总行数,说明结果文件被追加写过,需要先按 task_id 去重再统计,否则重复样本会把某些类型的占比放大。

统计失败轨迹里反复出现的同一步

失败轨迹的价值在位置而不在数量。做法是只取失败样本,在 trace 里顺序扫描,找到第一个被标记失败、或分数低于约定阈值的步骤,记录该步骤的名称并计频。步骤名通常来自 step_name、stage、name 一类的字段,具体字段需要打开一条失败记录的 trace 确认;如果整份结果没有步骤级标记,就退化到按 error_code、异常类型或错误信息前缀计频,并在表头注明退化口径。

# first_fail_step.py —— 只统计失败样本的首个失分步骤
import json, sys, collections

MIN_N = 5    # 频次下限,按失败样本总量自定

def first_failed_step(rec):
    steps = rec.get("trace") or rec.get("steps") or []
    for s in steps:
        ok = s.get("passed", s.get("ok"))
        score = s.get("score")
        if ok is False or (isinstance(score, (int, float)) and score < 0.5):
            return s.get("step_name") or s.get("stage") or s.get("name") or "unnamed"
    return rec.get("error_code") or rec.get("exception") or "unknown"

counter = collections.Counter()
failed = 0
for line in open(sys.argv[1], encoding="utf-8"):
    line = line.strip()
    if not line:
        continue
    rec = json.loads(line)
    if rec.get("passed") is True or rec.get("status") == "pass":
        continue
    failed += 1
    counter[first_failed_step(rec)] += 1

print(f"# 失败样本数={failed}")
print("| 首个失分步骤 | 频次 | 占失败样本 |")
print("|---|---|---|")
for s, n in counter.most_common():
    note = "" if n >= MIN_N else "(样本少,仅作线索)"
    print(f"| {s} | {n} | {n / failed:.1%} {note}")

读频次表主要看三种形态。第一种,排名前一两位的步骤占了失败样本的多数,说明是单点缺陷,修掉这一处对结果影响最直接。第二种,同一步骤在不同任务类型里都出现,通常指向公共链路,比如检索、解析、超时控制,这类问题不看类型也值得先查。第三种,长尾里大量步骤频次只有一两次,基本不能单独解释,只能作为进一步抽样复查的入口。

样本量下限需要一个明确约定:建议单条目频次低于 5 时只标注为线索,不写进结论;失败样本总数本身很少时,连排名都不必解读。这些阈值要事先写进脚本,避免先看结果再调阈值。表里的占比一律以失败样本为分母,不要和总体通过率混用。

把类型分布和失败频次交叉

交叉表用于区分两类失败:某一类任务整体表现差,和某一类任务里某一步骤特别差。设计上以任务类型为行,以首个失分步骤为列,通过样本单独记一列 PASS。行的合计表示该类型样本量,单元格表示该类型在该步骤上首次失分的次数。类型字段的归类函数直接复用第一份脚本,保证两张表口径一致。

# cross.py —— 类型 × 首个失分步骤(通过样本记为 PASS)
import json, sys, collections

MIN_ROW = 10   # 行样本量下限,按任务总量自定

rows = collections.defaultdict(collections.Counter)
for line in open(sys.argv[1], encoding="utf-8"):
    line = line.strip()
    if not line:
        continue
    rec = json.loads(line)
    t = pick_type(rec)
    step = "PASS" if rec.get("passed") else first_failed_step(rec)
    rows[t][step] += 1

freq = collections.Counter()
for r in rows.values():
    freq.update(r)
steps = [s for s, _ in freq.most_common() if s != "PASS"][:8]

print("| 类型 | 样本数 | " + " | ".join(steps) + " |")
for t in sorted(rows):
    r = rows[t]
    n = sum(r.values())
    mark = "" if n >= MIN_ROW else " ※样本不足"
    print(f"| {t}{mark} | {n} | " + " | ".join(str(r.get(s, 0)) for s in steps) + " |")

某类型样本量低于下限时,用 ※ 标注并把它排除在横向比较之外,而不是照常参与排序。低于下限的行,单元格里的数字只代表这一批样本的分布,换成另一批任务完全可能变样;如果这类任务本身重要,应当先补充样本再下判断。

以下几种形态需要提高警惕。高位通过率集中在少数几行、且这几行占总样本很大比重,其余行普遍失败,说明总体数字由简单任务贡献的;某一行的失败几乎全部落在同一个步骤,说明该类型存在固定断点,修一处就可能明显改变结果,此时先确认该行样本量是否支撑这种结论;失败均匀散布在所有类型、且列方向集中在同一两个步骤,问题大概率在公共环节而非某个任务类型;unknown 行占比高,交叉表本身的可读性就要打折扣,先补类型字段再谈分布。

把交叉结果整理成复核清单

清单是给第二个复核条目用的,每一项都要能在结果文件里找到对应字段,并且给出明确判定,而不是“看起来还行”。建议把下面四项连同统计口径一起落到记录里,随报告一并留存。

  • 任务覆盖是否均衡:看类型计数表的类型数与各类型占比。若头部一两个类型占了大多数任务,而结论描述覆盖了全部任务,应改写为按类型分别陈述;判断依据是计数表,不是主观印象。
  • 失败是否集中:看首个失分步骤频次表,若靠前的一两个步骤占失败样本多数,把它记为待查单点;若失败分布在十个以上步骤、且每个频次都很低,记为问题分散,先扩大抽样再定位。
  • 样本量是否足够:检查交叉表中带 ※ 的行和频次低于阈值的列。这些位置不进入结论,只作线索;需要时说明“该类型样本不足,暂不下判断”,而不是用现有数字外推。
  • 结论是否需要缩小范围:交叉表若显示高通过率集中在少数类型、其他类型失败率偏高,最终表述应限制在这些通过率较高的类型上,并注明其余类型的已知失分步骤,避免用一句总体通过率覆盖全部场景。

复核时建议直接对着脚本输出操作:先核对计数表的合计与总行数是否一致,再确认频次表的分母是否等于失败样本数,最后逐条对照交叉表填清单。任何一项对不上,先修统计口径,再讨论结论;口径没对上的数字,不建议写进对外说明。字段名、阈值和归类规则都属于环境相关配置,换一份结果文件时需要重新确认,不能直接套用上一份的设置。