Files
natural-memory-nm21/ABSTENTION_BREAKTHROUGH.md
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

未知拒答:从 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%)