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,读写关闭时与原生模型逐位相同
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
# 度量缺陷修复:为什么之前报的数字是错的
|
||||
|
||||
> 日期: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「会上也提过一嘴…没有正式确认过」),噪声上下文的失败里有一半是**该答却拒答**。
|
||||
Reference in New Issue
Block a user