Files
WpyQwq 643e22ecb9 Natural Memory NM2.1: 记忆路由器分叉、数据集缺陷修复与全轴评测证据
- 引入 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,读写关闭时与原生模型逐位相同
2026-09-19 11:11:31 +08:00

10 KiB
Raw Permalink Blame History

度量缺陷修复:为什么之前报的数字是错的

日期: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

也就是说:这个类别在构造上要求模型答一个库里已经被改掉的值。 模型「跟着最新值走」反而是更接近产品正确的行为,却一律判错。

修法(两处,都必要):

  1. 写入顺序按时间:--write-order answer-last(默认)。干扰项先写、答题事实最后写, 期望答案成为最新证据 —— 这也符合真实会话(旧事实先来)。 → update_conflict 44.00% → 64.00%。
  2. 答题事实必须预留槽位:改成 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 判定门是否真能抓「编造」而非「重复拒答」

两条本轮踩到、值得记住的坑:

  1. 配对错位:运行结果按「类别字典序」排,语料按文件序存。 直接 zip(语料, 运行结果) 会整行错配,而且同一类别里查询重复 → 检查不出来, 表现是「凭空多出一个 0.94 的 AUC」和「模型拒答数从 13 变成 2」。 我因此得出过一次错误的乐观结论,靠 verify_pairing.py 的独立证人抓回来。 以后一律用 rescore_e2e.load_corpus 的排序加载器。
  2. re.split 的捕获组会混进结果列表:re.split(r"\{[sv]\}", ...) 里的 [sv] 是捕获组, 返回值会插入 's'/'v',拼出来的正则全错(表现为「属性归组恒为 0」)。 另外先 re.escape 再按占位符切分会失败({s} 已变成 \{s\}),必须先切分、再逐段 escape。

7. 下一步(按证据排序)

  1. 未知属性 12 条编造 —— 这是用户最在意的轴,且已证明「打分几何 + margin」都抓不住。 剩下唯一有希望的方向是让门学会「问的属性在不在库里」这件事本身: 已有 make_answerability_data.py 造好的 6400 条平衡监督样本 (400/类别 × 8,正负各半,训练族与评测族属性不相交),下一步是训练这个门。
  2. 更新冲突 9 条 —— 时间序写入已把类别变成可解,接下来查「同一属性多值时读取端是否按新鲜度仲裁」。
  3. 长事实 / 噪声上下文各 7 条 —— 长事实的判别线索是确定性措辞(「最后确认…按这个执行」 vs「会上也提过一嘴…没有正式确认过」),噪声上下文的失败里有一半是该答却拒答。