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

213 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 度量缺陷修复:为什么之前报的数字是错的
> 日期: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`):
```python
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)
**这是语料/评测装置的缺陷,不是模型缺陷。**
生成器写的是:
```python
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「会上也提过一嘴…没有正式确认过」),噪声上下文的失败里有一半是**该答却拒答**。