- 引入 MemoryRouterXL 与 v5/v6 流式多线程训练/编码管线 - 修复 prepare_memory_router_dataset 候选池重建缺陷(mega 家族 3568x 加速,输出逐字节相同) - 修复 v5 被破坏的拒答与多跳标签(train 未知样本 319 -> 16319,multi_hop 平均正例 1.00 -> 2.00) - 同存储预算下 V2-128 v6 逐轴 22/22 通过:Top-1 41.12% -> 94.62%,未知拒答 0.00% -> 100.00% - 记录三条被实测推翻的显然优化(logits_to_keep=1 反而慢 55%、XL 容量未带来收益) - 记忆手术跨架构可移植性 14/14,读写关闭时与原生模型逐位相同
10 KiB
度量缺陷修复:为什么之前报的数字是错的
日期:2026-09-15 | 全部数字为百分比 | 语料:
data/realistic_v2/eval.jsonl(200 条,8 类)
0. 一句话结论
之前上报的「总体 66.50% / 可回答 76.00% / 未知拒答 0.00%」不是模型的真实水平, 而是三个度量/语料缺陷叠出来的假象。修正后同一份权重、同一份语料的真实水平是 总体 82.00% / 可回答 86.29% / 未知拒答 52.00% / 已知被误拒 2.29%。
更重要的副产品:「多跳只有 24%、是最大瓶颈」这个判断本身是错的 —— 多跳真实是 100.00%。
1. 缺陷一:评分器对空格敏感(影响最大)
原实现(eval_end_to_end_memory.score_case):
accepted = [value for value in case["acceptable"] if value and value.lower() in lowered]
症状:期望锚点是 值班人-5259,模型回答 值班人 -5259(只多一个空格)→ 判错。
规模:逐条核对,19 条多跳用例全部栽在这一个空格上。
以基线跑分(rv2_new_e2e,同一份权重、同一份语料)逐级展开:
| 评分器状态 | 总体 | 可回答 | 未知拒答 |
|---|---|---|---|
| 原始(两个缺陷都在) | 63.50% | 72.57% | 0.00% |
| 只修拒答检测 | 70.00% | — | 52.00% |
| 两个都修 | 79.50% | 83.43% | 52.00% |
rescore_e2e 的 legacy 列 = 「旧空白逻辑 + 新拒答检测」(70.00%),
修正列 = 两个都修(79.50%)。因此两个缺陷各自的贡献是:
| 缺陷 | 总体贡献 | 单项最大影响 |
|---|---|---|
| 拒答检测只认关键词 | +6.50pp | 未知拒答 0.00% → 52.00% |
| 评分器对空格敏感 | +9.50pp | 多跳 24.00% → 100.00% |
多跳一项单独看:
| 旧评分 | 修正后 | |
|---|---|---|
| multi_hop | 24.00% | 100.00% |
核对方法:audit_score_flips.py 逐条打印锚点与回复,19 条 flip 全部是
「原始锚点不存在、去掉空白后在同一位置命中」,没有一条是「本来不该算对」。
修法:eval_scoring.squash() 做 NFKC 归一 + 去掉所有空白(含全角空格)后再做包含判定。
锚点在本项目里是标识符/版本号/时间/人名,空白不携带语义。
2. 缺陷二:拒答检测只认固定关键词
原实现:ABSTENTION_MARKERS = ("不知道", "没有记录", "无法确认", "未找到", "不清楚", "没有相关信息", "不知道。")
症状:模型实际在 25 条未知属性用例里拒答了 13 条,措辞是
「关于您的 weekly 会议安排,当前长期记忆中未包含相关信息,无法回答。」 「关于报警触发条件,当前记忆中没有相关记录。」
—— 一条都不在关键词表里。于是「未知拒答率」被报成 0.00%。
规模:
| 旧检测 | 修正后 | |
|---|---|---|
| unknown_attribute 拒答 | 0.00% | 52.00%(13/25) |
核对方法:audit_refusal_both_ways.py 把两个方向都打出来 ——
13 条判为拒答的逐条都是真拒答;7 条「可回答但被判误拒」的逐条都是真的「我答不出来」;
另有 2 条虽然带拒答字样但同时命中了正确锚点,不计入误拒(wrongly_abstained 要求未命中锚点)。
修法:eval_scoring.is_refusal() 改为模式集(未包含|未找到|未记录|无法回答|证据不足| 记忆中没有…|未知。 等),并把 refused 单独记进每行结果,便于离线复核。
3. 缺陷三:update_conflict 是用例本身不成立(25/25)
这是语料/评测装置的缺陷,不是模型缺陷。
生成器写的是:
candidates = [old_fact, new_fact] + [frames[0].format(s="我", v=_value(rng)) for _ in range(3)]
后 3 条干扰项用的是 old_fact 同一个句式,也就是同一个属性、随机值。
而评测装置当年的写入顺序是 positives 在前,于是:
期望答案被最先写进库,随后又被 4 条同属性事实覆盖。
analyze_update_conflict.py 用生成器自己的句式模板做属性归组后确认:
| 检查 | 结果 |
|---|---|
| 该属性被写入的事实条数 | 5 条(25/25 用例) |
最新一条 = 期望答案 |
0/25 |
| 模型答了「最新那条」 | 9/25 |
| 模型答了「期望答案」 | 10/25 |
| 两者都不是 | 6/25 |
也就是说:这个类别在构造上要求模型答一个库里已经被改掉的值。 模型「跟着最新值走」反而是更接近产品正确的行为,却一律判错。
修法(两处,都必要):
- 写入顺序按时间:
--write-order answer-last(默认)。干扰项先写、答题事实最后写, 期望答案成为最新证据 —— 这也符合真实会话(旧事实先来)。 →update_conflict44.00% → 64.00%。 - 答题事实必须预留槽位:改成 answer-last 后我第一次直接写成
(distractors + positives)[:facts],而noise_context是 1 条答案 + 8 条干扰、 槽位只有 6 → 答案被整个截掉,该类别从 72.00% 崩到 0.00%。 改成distractors[:facts - len(positives)] + positives后恢复。 → 已加单元测试锁死(EvidenceWriteOrderTest)。
4. 修正后的完整状态
三份跑的同一 200 条语料、同一份权重,用修正后的评分器重新打分(rescored_e2e.py,
从存盘回复离线重算,不需要重跑 4B 模型):
| 配置 | 总体 | 可回答(175) | 未知拒答(25) | 已知被误拒 |
|---|---|---|---|---|
| ① 原始(answer-first,含 supersede 修复前) | 79.50% | 83.43% | 52.00% | 2.86% |
| ② + 补丁式更新修复(answer-first) | 80.00% | 84.00% | 52.00% | 4.00% |
| ③ + 时间序写入 + 预留槽位(当前) | 82.00% | 86.29% | 52.00% | 2.29% |
分类型(当前配置):
| 类别 | 正确率 | 错几条 |
|---|---|---|
| alias_paraphrase | 100.00% | 0 |
| multi_hop | 100.00% | 0 |
| near_miss | 100.00% | 0 |
| multi_entity | 96.00% | 1 |
| long_fact | 72.00% | 7 |
| noise_context | 72.00% | 7 |
| update_conflict | 64.00% | 9 |
| unknown_attribute | 52.00% | 12 |
真正剩下的短板只有 4 项、共 36 条:未知属性 12、更新冲突 9、长事实 7、噪声上下文 7。
5. 一个判决性的负面结论:margin 门(省得以后再试)
看到「未知拒答」是短板后,我按正确顺序先量了最简单的信号,没有直接动手改运行时:
- 记
cos(查询, 最好记录) - cos(查询, 次好记录)为 margin; - 训练/评测属性族不相交,阈值只在训练族上拟合、再套到评测族。
| 信号 | 训练 AUC | 评测 AUC |
|---|---|---|
| 最大余弦 | — | 0.6741 |
| margin | 0.8348 | 0.8629 |
AUC 看起来能跨属性族泛化(0.86),但它不能用来做这件事: 把评测集的 25 条未知属性用例拆成「模型已拒答的 13 条」与「模型编造的 12 条」后 ——
- 已拒答的 margin:0.000 ~ 0.107
- 编造的 margin:0.003 ~ 0.082
两者完全交织。取阈值 0.020 时:抓住 4/12 条编造,却把 6/13 条本来已经拒答的再判一次, 并新增 6/75 条误拒。净效果是「未知拒答 52%→68%、误拒 2.29%→10.29%」,得不偿失。
原因:那 0.86 的 AUC 来自跨类别的记录集形状差异,而不是「这一条到底有没有对应记录」的判别力。 结论:margin 门不接入运行时。
6. 工具与纪律(本轮新增,可复用)
| 文件 | 作用 |
|---|---|
eval_scoring.py |
唯一评分真源(空白无关 + 模式化拒答),可离线重算 |
rescore_e2e.py |
从存盘回复离线重算,不用重跑 4B 模型即可用新评分器比较历史跑分 |
verify_pairing.py |
用存盘的 matched 字段独立证明「运行行 ↔ 语料用例」配对正确 |
audit_score_flips.py |
逐条打印每个判定翻转的锚点与回复,证明翻转是空白造成的 |
audit_refusal_both_ways.py |
拒答检测两个方向复核(漏检 + 过检) |
analyze_update_conflict.py |
用生成器句式证明类别是否自相矛盾 |
probe_answerability_cosine.py / fit_answerability_threshold.py |
先量信号再改代码 |
probe_margin_vs_fabrications.py |
判定门是否真能抓「编造」而非「重复拒答」 |
两条本轮踩到、值得记住的坑:
- 配对错位:运行结果按「类别字典序」排,语料按文件序存。
直接
zip(语料, 运行结果)会整行错配,而且同一类别里查询重复 → 检查不出来, 表现是「凭空多出一个 0.94 的 AUC」和「模型拒答数从 13 变成 2」。 我因此得出过一次错误的乐观结论,靠verify_pairing.py的独立证人抓回来。 以后一律用rescore_e2e.load_corpus的排序加载器。 re.split的捕获组会混进结果列表:re.split(r"\{[sv]\}", ...)里的[sv]是捕获组, 返回值会插入's'/'v',拼出来的正则全错(表现为「属性归组恒为 0」)。 另外先re.escape再按占位符切分会失败({s}已变成\{s\}),必须先切分、再逐段 escape。
7. 下一步(按证据排序)
- 未知属性 12 条编造 —— 这是用户最在意的轴,且已证明「打分几何 + margin」都抓不住。
剩下唯一有希望的方向是让门学会「问的属性在不在库里」这件事本身:
已有
make_answerability_data.py造好的 6400 条平衡监督样本 (400/类别 × 8,正负各半,训练族与评测族属性不相交),下一步是训练这个门。 - 更新冲突 9 条 —— 时间序写入已把类别变成可解,接下来查「同一属性多值时读取端是否按新鲜度仲裁」。
- 长事实 / 噪声上下文各 7 条 —— 长事实的判别线索是确定性措辞(「最后确认…按这个执行」 vs「会上也提过一嘴…没有正式确认过」),噪声上下文的失败里有一半是该答却拒答。