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
+185
View File
@@ -0,0 +1,185 @@
# 零重叠改写成语(zero-overlap paraphrase)阶段报告
本阶段针对 `E2E_FINDINGS.md` 定位到的**唯一**泛化缺口:查询与事实之间**没有任何独特字重叠**时的语义匹配。
所有速率均为百分比。所有数字均来自磁盘上保存的 scorecard / 评测 JSON,不引用滚动日志。
逐轴对照表见 `zero_overlap_phase.md`(由 `report_zero_overlap_phase.py` 从 scorecard JSON 渲染)。
---
## 1. 结果摘要
新增了一个**可验证的**零重叠改写数据集、一个**合并特征库**,以及一个重放微调后的路由器
`REPLAY-128`(2,037,774 参数,128 维,每条记录地址 512 字节 —— 与现网路由器几何完全一致)。
| 评测 | 指标 | v6 最终版 | REPLAY-128 | 变化 |
|---|---|---|---|---|
| 零重叠(300 条,24 个**未见过**问法) | Top-1 正确率 | 18.40% | **59.60%** | **+41.20pp** |
| 零重叠 | Recall@3 | 36.00% | **83.20%** | **+47.20pp** |
| 零重叠 | MRR | 35.07% | **73.06%** | **+37.99pp** |
| v6 全集(21,920 条,全部 22 轴) | Top-1 正确率 | 94.37% | 94.14% | −0.23pp |
| v6 全集 | Recall@3 | 96.40% | 96.48% | +0.08pp |
| v6 全集 | 多跳证据全中(Top-3) | 95.95% | 96.22% | +0.27pp |
| v6 全集 | hop 正确率 | 100.00% | 100.00% | 0 |
| v6 全集 | 未知拒答率 | 100.00% | 100.00% | 0 |
| v6 全集 | 已知问题被误拒率 | 0.00% | 0.00% | 0 |
零重叠评测集随机猜测基线为 4.17%(24 个同形候选),因此 59.60% 约为**随机水平的 14 倍**。
v6 全集的全部策略轴**逐位不变**(见第 3 节的冻结机制),检索轴变化在 ±0.27pp 以内。
部署契约:`check_router_swap.py` 判定 **DROP-IN REPLACEMENT OK** —— 16/16 键匹配、无缺失/多余、
无形状不符、分数有限,且实测可驱动 `PagedMemoryBankV2` 路由(`bank_routed_records: 3`)。
延迟(`bench_router_latency.py`,7 轮交错取中位数):单查询 1.2042 ms → 1.2087 ms(**+0.37%**),
batch=256 QPS 190,103 → 192,600(**+1.3%**)。batch=64 QPS 读数低 9.2%,但该轮自身离散度为
17.4%,且同架构同参数同 FLOPs 下 batch=256 反而更快 —— 方向不一致,判为测量噪声而非回归。
---
## 2. 数据集:属性由构造保证,而非相信手写意图
`make_zero_overlap_paraphrase_data.py` 生成 24 个属性 × 3 个改写问法:
* 每条查询与其**自身**目标事实的独特字重叠必须为 **0**(属性字符、停用字除外);
* 前 2 个改写用于训练(48 个问法),第 3 个**留出**用于评测(24 个问法)——两个划分的
**查询字符串完全不相交**,因此评测衡量的是"换一种说法"的泛化,而不是记住训练字符串;
* 候选是 24 条**同句式、不同属性**的事实,值为随机码,只出现在一条事实里(可检测证据混用);
* 15% 为不可回答 episode(改写指向候选中不存在的属性)。
关键设计判断:改写与其他**无关**属性的字符重叠被**刻意保留**(生成器统计 54.53% 的 episode 至少
含一个这类"陷阱"候选)。因为命中错误候选会得到**错误答案**,这只会让任务更难,不会提供捷径;
真正必须为 0 的是与**自身**目标的重叠。最初把二者混为一谈时,24 个属性中有 7 个因为
"起床时间"的"时"、"办公城市"的"公"等高频字而被判为"无可用的改写"。
`verify_zero_overlap_dataset.py` 用**独立重写**的停用字集合与重叠算法复核(不 import 生成器):
* 结构问题 **0**,查询与自身目标重叠 **0**,两个划分共享查询字符串 **0**;
* 每条的答案码只出现在唯一一条候选中,候选属性标签唯一;
* `answerable` / `need_memory` / `hop` 与正例索引一致。
(生成器报的陷阱比例 54.53% 与校验器报的 44.00%/50.00% 不同,因为两者用不同的停用字集合
定义"独特字";这是定义差异,不是矛盾。)
---
## 3. 两次微调的对照:朴素微调会造成灾难性遗忘
先做朴素微调(只用零重叠数据,全部损失项默认权重,从 v6 最终版权重出发,6000 步):
* 零重叠 Top-1 18.40% → **66.80%**,但 v6 全集同时退化:
`random_position` MRR 99.02% → 93.37%、多跳证据全中 100.00% → 95.65%、
hop 100.00% → 87.10%、**已知问题被误拒率 0.00% → 13.85%**、`unknown` Top-1 58.99% → 47.52%。
* 训练日志还显示策略门发生了"用误拒换拒答"的交换:未知拒答率升到 40.00%,代价是
**38.00% 的可回答问题被误拒**。
这就是单指标视角会误判为胜利、而多轴记分卡会否掉的典型情形。
修正方案由两点组成:
1. **冻结策略头**(`--need-loss-weight 0 --hop-loss-weight 0`)。在零重叠集合上,`need_memory`
门只看查询、看不到候选,而"不可回答"的改写与可回答的改写句式完全一样,因此该门在此数据上
**原理上不可学**;强行训练只会破坏 v6 学到的门。实测证明冻结生效:合并评测的
`need_specificity` 在每一次 eval 中都是 `0.9879692011549567`,逐位不变。
2. **重放**(把零重叠数据并入 v6 数据一起训练),避免排序通路被新分布带偏。
---
## 4. 合并语料:复制已验证的行,而不是重新编码
`build_replay_corpus.py` 把 v6 库(2,124,552 行)与零重叠库(36,072 行)合并为
2,160,624 行。**没有重新编码** v6 特征 —— 那既耗时数小时,又会让历史测量所依赖的参考特征
发生无声漂移。合并的强制不变量:
* 每个源库的每一行都恰好被复制一次,或因文本完全相同而跳过(本次重复 **0** 行);
* `index` 条目数 == 库行数(训练器会拒绝不一致的库);
* 合并后 train/eval 引用的**每一个文本**都能解析到一行(缺失 **0**);
* **2000 行抽查逐字节相等**;全库扫描全零行 **0**;
* manifest 携带**合并后**数据文件的 sha256(训练器会据此校验冻结输入)。
合并语料:train 88,355 episode / eval 22,220 episode;
`data/router_replay_v7/`、`H:\Memory\nm_cache\nm_replay_v7\feature_cache`。
---
## 5. 关键负面结论:路由器排序不影响端到端答案
`REPLAY-128` 与 `V2-128-v6` 在 `eval_router_critical_e2e.py`(16 个零重叠用例)上的结果**逐位相同**:
| top_k_records | 路由器 | 回答正确率 | 答成别的属性 | 平均选中记录 |
|---|---:|---:|---:|---:|
| 默认 | V2-128-v6 / REPLAY-128 | 37.50% / 37.50% | 25.00% / 25.00% | 5.88 / 5.88 |
| 1 | V2-128-v6 / REPLAY-128 | 12.50% / 12.50% | 43.75% / 43.75% | 0.94 / 0.94 |
| 3 | V2-128-v6 / REPLAY-128 | 31.25% / 31.25% | 25.00% / 31.25% | 2.44 / 2.44 |
路由器级 Top-1 提升了 41.20pp,端到端却**一个用例都没变**。因此做了一个判决性实验:保留
`need_memory` / `hop_controller` / `head_gate` 全部原权重,只把排序通路
(`query_projection`、`key_projection`、`pair_scorer`)替换为同形状**随机权重**
(`checkpoints/router_v6_v2_128/ranking_scrambled_probe.pt`,非有限值 0 个)。
结果在默认 Top-K 与 Top-K=1 下**都完全不变**(37.50% / 12.50%,选中记录数相同)。
机制(已逐项实测,含两次被自己数据推翻的中间假设):
* `memory_os_v2.py:1671` — 带 `semantic_key`(张量)的记录,其
`_score_candidates()`(即 `MemoryRouterV2.projected_scores`)分数会被
`self.record_scorer` **逐位置覆盖**;实测残差与 text_retriever 匹配 18/18,与
`memory_router_v2` 匹配 0/18。
* `qwen_integration.py:1359` `_score_semantic_memory_records` — 该回调走
`self.text_retriever`。**注意:最初以为"部署目录没有 `text_retriever.pt` 所以走余弦兜底"
是错的** —— 重排器被烘焙进合并包(safetensors 内有 6 个
`dynamic_memory.text_retriever.*` 张量,1,573,377 参数),
`_text_retriever_ready` 实测为 **True**,余弦兜底分支根本不执行。
* 路由器在此配置下**唯一实际生效的杠杆是 `need_memory` 门**:部署权重下门开启率
**68.75%**(11/16),而三份随机初始化的路由器只有 **37.5%**(6/16)。判决性探针只随机化了
排序通路、**保留了门控头**,所以"端到端零变化"是由构造保证的,而不是巧合。
* 这 16 个用例里 **93.75%(15/16)实际注入的是旧版 16 槽路径**的证据
(`legacy_slot` + `text_memory_top_k=2`),而不是 V2 库记录;地址命中 **0/16**,
直接词面命中 6/16。`skip_semantic_expansion` 在这 6 例里直接跳过整个语义 hop 循环。
* 更严重的是:一次写入 20 条不同属性的事实后,**只剩 12 条 active,8 条在查询前就已被
retract**(16 个用例的 8 个里,目标记录本身就在被删的那批里)。**任何排序器都救不回一条
已经不存在的记录**,因此端到端正确率存在 50% 的硬上限。
结论分两层,不要混为一谈:
1. 路由器记分卡上 22/22 的统治性和本次 +41.20pp 的零重叠提升,都是路由器工件**真实**的增益;
2. 但在这套运行时里,记录级顺序由打包的 `text_retriever` 决定,路由器分数被覆盖,且相当比例的
读取根本不走 V2 库、目标记录还会被写入路径删除。所以路由器增益**不会**传递到最终答案。
要让增益落地,需要的不是继续加大路由器,而是:
1. 修写入路径:不要让 20 条不同属性的事实互相淘汰(当前最大瓶颈,直接决定 50% 上限);
2. 让 V2 库记录真正参与读取:目前 93.75% 的用例走旧版 16 槽路径;
3. 训练/替换 `text_retriever`(记录级重排器)—— 零重叠数据集可直接复用为它的训练/评测集;
4. 让路由器分数对 `semantic_key` 记录也参与排序(改动 `memory_os_v2.py:1671` 的覆盖逻辑)。
(曾据此改过一版,实测在部署配置下**不生效**,已回退并留注释。)
---
## 6. 产物清单
| 文件 | 作用 |
|---|---|
| `make_zero_overlap_paraphrase_data.py` | 生成零重叠数据集(属性由构造保证,划分字符串不相交) |
| `verify_zero_overlap_dataset.py` | 独立复核数据集(不共享生成器代码) |
| `build_replay_corpus.py` | 合并两个数据集与两个特征库,含逐字节抽查 |
| `report_zero_overlap_phase.py` | 由 scorecard JSON 渲染对照表(`zero_overlap_phase.md`) |
| `data/zero_overlap/` | train 1,200 / eval 300(含 `manifest.json`、`zero_overlap_verification.json`) |
| `data/router_replay_v7/` | 合并语料 train 88,355 / eval 22,220 |
| `H:\Memory\nm_cache\nm_replay_v7\feature_cache` | 合并特征库 2,160,624 行 |
| `checkpoints/router_replay_v7_v2_128/` | `REPLAY-128` 最终版权重(可 drop-in) |
| `compare_record_rankers.py` | 在同一批冻结特征上对比"余弦 / 各路由器"的排序能力 |
| `measure_write_path_updates.py` | 量写入路径的更新判定(证明重排器打分不是 retract 的原因) |
| `probe_record_selection.py` | 逐记录拆解打分项、门控扫描、目标记录存活状态 |
| `migrate_bank_paths.py` | 把特征库路径从 D: 迁移到 `H:\Memory\nm_cache`(含校验) |
| `zero_overlap_phase.md` | 本阶段的逐轴对照表 |
特征库现存于 `H:\Memory\nm_cache\`(`nm_router_v6` / `nm_router_v5` / `nm_zero_overlap` /
`nm_replay_v7` / `nm_probe_bf16`,共约 35.9 GB;D 盘已不再保存项目数据)。迁移后用
`verify_feature_bank.py` 全盘复扫(2,124,552 行、全零行 0、`complete=true`)并在新路径下
复算了排序器对比(59.60 / 30.40 逐位一致)。
历史运行记录(`checkpoints/*/metrics.jsonl`、`*/training_stdout.log`、
`*/router_v5_training.json`、`feature_bank_verification.json`)**刻意保留原始 D: 路径**,
因为那是当时真实的运行事实,改写它们等于篡改记录。
对照用的负面结果与判决性探针:`checkpoints/router_zov_v2_128/`(朴素微调)、
`checkpoints/router_v6_v2_128/ranking_scrambled_probe.pt`(随机排序探针)、
`forget_check_zov.json`、`router_critical_e2e_scramble*.json`。