返回机制实验室

W10 · S64—S70 · 总 Day 154160

发布判断:平均提升掩盖了什么

对齐样本 ID,比较总体和子群,辨认丢失的困难案例。

作者准备的学习示例 · 不计真实学习进度 · 不代表生产 / GPU / 真机结果

在仓库根目录运行;只输出合成示例,不写文件、不访问网络

npm run learning:p2 -- w10
跳到完整源码 ↓

核心问题

新版本平均准确率更高,是否就应该替换旧版本?如果提升来自容易样本,而关键困难样本退步,答案并不显然。若两个版本甚至没跑在同一批案例上,比较还可能混入样本选择变化。

对应 S64~S70。这是学习比较方法,不建设新发布 Gate,也不为八条示例做上线结论。

1. 先对齐案例,再讨论数字

本例有 c0~c7 八个案例。前六个是 routine,后两个是 hard。每个版本记录同一 ID 的结果,按 ID 配对而不是按数组位置。重复 ID 会报错;同一 ID 的 slice 被改变也会报错,避免悄悄换了子群定义。

每个配对案例的差值为 candidateCorrect - baselineCorrect,可取 -1、0、1。这样可以定位“哪条从对变错”和“哪条从错变对”,比单看两个平均值更有解释力。

缺失案例单独列入 missingFromCandidate;候选额外案例列入 candidateOnly。配对统计只在交集上计算。因此比较输出时,必须先看缺失列表和 pair count,再看均值。

2. 手算默认例子

npm run learning:p2 -- w10

旧版本 c0、c1 错,其余正确,整体为 6/8=75%。新版本修好了 c0、c1,却把 c7 做错,整体为 7/8=87.5%。整体上升 12.5 个百分点,但 hard 子群从 2/2 降到 1/2,即下降 50 个百分点。

两条困难案例太少,不能据此估计真实人群退步幅度或统计显著性。这个构造例子只证明总体和子群可能方向不同,提醒你追问失败案例、影响程度和样本覆盖。

incompleteCandidate 故意移除 c7。交集里候选会显得更好,但 missing 列表明确暴露它少了最关键的退步案例。不能把“没有结果”自动归为成功,也不能静默删除它再宣布版本进步。

3. 比较设计比单个分数更重要

进一步使用真实材料时,要固定任务定义、数据划分、采样规则、评分规则和成本边界;模型输出有随机性时,还要考虑重复运行与方差。子群应该有业务或科学理由,而不是看完结果才挑出最有利的切片。

Offline 比较观察固定样本,shadow 观察真实流量但不实际影响用户,canary 会产生用户影响且需要单独授权与运行条件。这三个词不代表同一证据层级。本例没有运行其中任何线上流程。

延迟、成本、任务质量与错误严重性可能互相交换。没有必要把它们压成唯一总分;可以保留一个变化表,解释自己当前最关心的取舍与不知道的地方。

4. 轻量修改

只把 c7 在候选中的 correct 改为 true,观察整体与 hard 的差值如何一起变化。或把候选数组顺序颠倒,结果应不变,因为按 ID 对齐。二选一即可。

深入问题:如果困难案例经常超时而缺结果,缺失不是随机发生的。你会如何保留这类案例,并区分“模型回答错”和“系统未完成”?只需写出分类,不添加自动否决门槛。

5. 专业阅读与 P3 桥接

  • Stanford CS329S:从部署、监控和持续变化的系统视角思考比较条件。
  • FSDL 2022:选 Continual Learning,关注反馈如何改变数据与后续版本,而不是只做一次静态评估。

P3 会继续追问能力泛化、分布外表现和研究证据。配对与切片是基本工具,但不能单独识别“通用智能”;benchmark 上升还需要排除污染、选择偏差和任务特化。可记三句:整体改变了什么;哪个案例值得读;还有什么证据不够。

配套日课:按需要补充理论

本实验贯穿一周,不要求一天做完。

可运行源码

src/learning/ai-systems/w10-paired-comparison.ts · 构建时直接读取源文件,避免讲义代码与实现各自漂移。

interface Result { id: string; slice: 'routine' | 'hard'; correct: boolean }
export const baseline: Result[] = Array.from({ length: 8 }, (_, i) => ({ id: `c${i}`, slice: i < 6 ? 'routine' : 'hard', correct: i >= 2 }))
export const candidate: Result[] = baseline.map(row => ({ ...row, correct: row.id !== 'c7' }))

export function compare(before: Result[], after: Result[]) {
  if (new Set(before.map(r => r.id)).size !== before.length || new Set(after.map(r => r.id)).size !== after.length) throw new Error('Duplicate case IDs')
  const indexed = new Map(after.map(row => [row.id, row]))
  const matched = before.flatMap(row => {
    const next = indexed.get(row.id)
    if (!next) return []
    if (next.slice !== row.slice) throw new Error('Slice changed for the same case')
    return [{ id: row.id, slice: row.slice, delta: Number(next.correct) - Number(row.correct), before: Number(row.correct), after: Number(next.correct) }]
  })
  return {
    missingFromCandidate: before.filter(row => !indexed.has(row.id)).map(row => row.id),
    candidateOnly: after.filter(row => !before.some(x => x.id === row.id)).map(row => row.id),
    slices: ['all', 'routine', 'hard'].map(slice => {
      const rows = matched.filter(r => slice === 'all' || r.slice === slice)
      const average = (field: 'before' | 'after' | 'delta') => rows.length ? rows.reduce((n, r) => n + r[field], 0) / rows.length : null
      return { slice, pairs: rows.length, before: average('before'), after: average('after'), delta: average('delta') }
    }),
    changedCases: matched.filter(r => r.delta !== 0),
  }
}

export function run() {
  return {
    baseline, candidate, paired: compare(baseline, candidate),
    incompleteCandidate: compare(baseline, candidate.filter(row => row.id !== 'c7')),
    boundary: 'Eight authored examples; no significance claim, production rollout, judge, or pass/fail gate.',
  }
}