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:
WpyQwq
2026-09-19 11:11:31 +08:00
commit 643e22ecb9
484 changed files with 306821 additions and 0 deletions
+167
View File
@@ -0,0 +1,167 @@
# 未知拒答:从 75% 泄漏到 100% 拒答(机制 + 证据 + 边界)
这是本项目目前最差的一个数字(**未知问题泄漏 75.00%**:问库里不存在的属性时照样编一个答案),
本轮把它定位、排除了整类无效修法,并验证了一个可用的机制。
---
## 1. 先排除掉一整类无效修法(有测量支撑)
最直觉的修法是"检索分数不够高就拒答"。用**只在训练集拟合、评测集算 AUC** 的口径测了四种打分器
的分数几何(top1 / margin / top-k 均值 / 离散度 / 熵 / 近邻数,共 9 个特征):
| 打分器 | 最佳单特征 AUC | 拟合头 AUC(评测集) |
|---|---:|---:|
| cosine(冻结键余弦) | 0.6257(margin) | 0.6086 |
| 打包 `text_retriever` | 0.6076(margin) | 0.4752 |
| 50/50 混合 | 0.5446 | 0.5062 |
| 训练过的路由器 | 0.5711 | 0.5130 |
操作点同样不可用:混合打分器想标出 22% 的未知要误拒 **28.40%** 的可回答问题;
cosine 想标出 46% 要误拒 **30.00%**。
**结论:75% 的泄漏不可能靠分数阈值或分数上的分类头修好。** 这不是调参空间不够,
而是这个信号根本不存在 —— 24 条同句式候选对任何提问都"一样像"。
## 2. 有效的机制:先判「问的是哪个属性」,再查库里有没有
换一个问题问就成立了:不是"有没有候选匹配得好",而是"这个提问在问哪个属性,这个属性在库里吗"。
**封闭词表**(提问的属性在模型学过的属性集合内、但不在候选/库里):
| 指标 | 值 |
|---|---:|
| 可回答 episode 中「预测属性在候选集内」 | **100.00%**(250 条) |
| 未知 episode 中「预测属性在候选集内」 | **0.00%**(50 条) |
| **未知拒答率** | **100.00%** |
| **已知问题被误拒率** | **0.00%** |
| 混淆矩阵 | tp=50 fn=0 fp=0 tn=250 |
属性识别本身在**未见改写**上也是 **100.00%**(250 条可回答 episode 全对,24 类,随机基线 4.17%)。
注意:这是**类别式规则**,不依赖任何脆弱阈值。
## 3. 开放集:真实用户会问模型没见过的属性,必须显式训 NONE 类
只靠最大概率调阈值是不够的 —— 实测**没有拒绝选项时,从未见过的属性 100.00% 会被认成某个存在的属性**
(`closed_set_false_claim_pct: 100.00%`)。用最大 softmax 做拒绝的 AUC 是 0.9629 / 0.9760(留出 6 / 12 个属性),
可用但有取舍:留出 6 个属性时,保住 100% 已知的阈值只能拒掉 **44.64%** 的未见属性。
所以改成**显式训练一个 NONE 类**:词表取 12 个属性作为类别,另取 6 个属性(词表外)的真实提问作为
NONE 的负样本,最后用**第三组、与两者都不相交的 6 个全新属性**做测试:
| 指标 | 值 |
|---|---:|
| 词表内属性识别准确率(133 条未见改写) | **100.00%** |
| **从未见过的属性被拒绝**(52 条,与词表、NONE 训练集均不相交) | **100.00%** |
| **已知问题被误拒率** | **0.00%** |
| 随机基线(13 类) | 7.69% |
对照当前运行时:未知泄漏 **75.00%**、A 段未知拒答率 **56.67%~60.00%**。
## 4. 落地形态与必须处理的边界
**运行时规则**(三步,全部可审计、无阈值调参):
1. 属性分类头读提问的冻结键 → 输出「某个属性」或 **NONE**;
2. 若为 NONE → 拒绝(明确说不知道);
3. 若为某属性 → 查库的活跃属性集合(即 `bank.active_by_conflict` 的键)是否包含它;不含 → 拒绝。
**必须处理的边界(诚实列出,未解决)**:
* **增量写入**:头部在训练时固定了属性词表;用户**训练之后**才写下的全新属性会被判成 NONE 而误拒。
缓解方向:把检索分数作为并联条件(NONE **且** 没有强匹配记录才拒答),或用属性分类体系扩大词表,
以及写入新属性时增量微调头部。
* **词表随库变化**:生产环境属性集合是用户相关的,头部需要用「该用户当前库的属性集合」构建类别,
本实验固定为 24 个。
* **规模**:本实验 24 个属性、300 条评测;生产需要上千属性、上万条改写问法的规模验证。
* 本机制只解决**拒答**;「答成别的属性 35.00%」是另一个问题(排序),本轮未动。
## 5. 集成尝试:**失败**(已回退,须如实记录)
把覆盖门接进运行时后,端到端整体测试的结果是**灾难性**的:
| 指标 | NM2.1(无门) | NM2.1+覆盖门 |
|---|---:|---:|
| A 可回答正确率 | **100.00%** | **6.25%** |
| A 已知问题被误拒率 | 0.00% | **7.50%** |
| A 未知拒答率 | 56.67% | 43.33% |
| B 回答正确率 | 68.75% | 75.00% |
| C 未知泄漏率 | 75.00% | 62.50% |
| C 可回答正确率 | 65.00% | 65.00% |
| D 重启持久化 | 通过 | 通过 |
**门把几乎全部可回答问题都拒掉了**(可回答正确率 100.00% → 6.25%),而它本该修好的 C 段泄漏
只从 75.00% 降到 62.50%。**已把 NM2.1 的 `memory_coverage_gate` 改回 `false`**,恢复为已验证的可用状态
(52 个单元测试通过)。属性头工件保留在 `checkpoints/memory_attribute_head/`,未随包启用。
### 两个根因(离线评测掩盖了它们)
1. **输入表示不匹配**:属性头是在**裸提问文本**的冻结键上训练的,而运行时 `read()` 收到的是
`self.runtime.v2_query_key`,它是 **`_encode_model_key(input_ids)`**,其中 `input_ids` 是
**套了 chat 模板的完整提示**(还含系统前缀)。头在分布外的输入上工作,argmax 基本是随机的 ——
所以它既误拒了大量可回答问题,又没能拦住该拦的。
2. **库覆盖索引不可靠**:门要求 `user::<属性>` 出现在 `bank.active_by_conflict` 里。A 段(v6 mega 事实)
的文本很多**无法被 `infer_memory_metadata` 解析出属性**,于是冲突键为空 → 门把所有这类问题一律拒掉,
这正是可回答正确率崩到 6.25% 的原因。
**离线 100.00% / 0.00% 是真实的,但它只证明了机制在"裸提问键 + 24 个可解析属性"这个条件下成立,
并不等于接上运行时就能用。** 这一点必须在任何对外表述里讲清楚。
### 正确的下一步(按顺序,每步都有验收口径)
1. 用**运行时实际喂进来的那个表示**(chat 模板编码)重建训练集,而不是裸提问键;
验收:门开启后 A 段可回答正确率不低于 99.00%。
2. 把覆盖判断从"必须有结构化冲突键"改为"结构化键 **或** 检索到强匹配记录",覆盖
`infer_memory_metadata` 解析不出属性的真实事实文本;验收:A 段已知问题被误拒率 ≤ 1.00%。
3. 两项都达标后再开 C 段验收:未知泄漏 ≤ 5.00%(现为 75.00%)。
## 6. 集成第二轮:修好一半 —— 目标域泄漏 0.00%,但换域即崩
第一轮失败后定位到真正原因(并不是我先前猜的"输入表示不匹配":`retrieval_ids = memory_query_input_ids
if ... else input_ids`,喂给 `v2_query_key` 的就是**裸提问**,与训练一致)。真正的机制缺口是:
**`read()` 返回空记录后不会停止,`_build_text_prefix` 会继续落到旧版 16 槽注入路径**,把无关记忆又塞回去。
所以在 `_build_text_prefix` 里加了短路:覆盖门拒绝时直接返回 guard 前缀,不再回落。
效果(C 段,48 用例,24 同形候选 —— 也就是属性头训练覆盖的那个域):
| C 段指标 | NM2.1(无门) | NM2.1+门+短路 |
|---|---:|---:|
| **未知泄漏率** | 75.00% | **0.00%** |
| 可回答正确率 | 65.00% | 65.00%(无损失) |
| 答成别的属性 | 35.00% | 35.00% |
| 活跃记录 | 23/24 | 23/24 |
**但在 A 段(v6 mega 事实)上是灾难**:可回答正确率 **0.00%**、已知被误拒率 7.50%、总体 12.73%。
根因已测定:
* A 段事实里 **94.03% 是"这是普通对话噪声…不需要长期保存"**,只有 **5.97%(63/1056)**能解析出属性;
* 更关键的是 **A 段的属性空间("备用联系人"等)完全不在属性头训练的 24 个属性之内**,头对 A 的提问只能
输出那 24 个之一 → 必然不在库里 → 门把一切都拒掉。
也就是说:**这个机制目前是"域内可用、跨域不可用"**。已把 NM2.1 的 `memory_coverage_gate` 改回 `false`
(52 个单元测试通过),并在 `memory_config.json` 里记下"已验证域"与"阻塞点"。
### 正确的最终形态:开放词表的属性匹配
不要再做"24 类闭集分类头",而是**把提问与库里真实存在的属性名做匹配**:
* 库里有哪些属性 → 从 `bank.active_by_conflict`(或记录上的 `attribute` 字段)取,随写入动态变化;
* 打分 = `similarity(query_key, encode(attribute_name))`,对**每个存在的属性**算一次,取最大;
* 低于阈值或库里属性为空 → 拒答。
这样词表不需要预训练固定,新写入的属性自动纳入。验收口径不变:**先保证 A 段可回答正确率 ≥ 99.00%
且已知问题被误拒率 ≤ 1.00%,再要求 C 段未知泄漏 ≤ 5.00%。**
## 7. 产物
| 文件 | 内容 |
|---|---|
| `analyze_abstention_separability.py` / `abstention_separability.{json,md}` | 第 1 节:分数几何不可分(四打分器 AUC 与操作点) |
| `analyze_query_attribute_classifier.py` / `query_attribute_classifier.{json,md}` | 第 2 节:封闭词表 100%/0%,含按属性拆解 |
| `holdout_6.json` / `holdout_12.json` | 第 3 节:开放集最大概率 AUC 与操作点 |
| `none_class_result.json` | 第 3 节:显式 NONE 类,100.00% 拒答 / 0.00% 误拒 |
| `train_attribute_head.py` / `attribute_head_training.json` | 第 3 节工件:属性头训练脚本与指标 |
| `checkpoints/memory_attribute_head/attribute_head.pt` + `attribute_head_meta.json` | **可部署工件**(当前未在包内启用) |
| `nm2_1_gate_*.json/md`、`nm2_battery_comparison_gate.{json,md}` | 第 5 节第一轮失败证据 |
| `nm2_1_gate2_runtime_e2e.{json,md}` | 第 6 节:目标域泄漏 **0.00%** |
| `nm2_1_gate2A_e2e.{json,md}` | 第 6 节:跨域崩溃证据(A 段 0.00%) |