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
+173
View File
@@ -0,0 +1,173 @@
"""Independent audit of a router dataset: category integrity and train/eval leakage.
The builder reports its own ``group_overlap`` check, but a benchmark that will be
quoted as evidence deserves an independent audit that streams the frozen files and
verifies, without trusting the builder's counters:
* per-family / per-category episode counts and the answerable-vs-unknown split;
* candidate width, positive-count and hop distributions per category (a category
that silently lost its positives would make every router look identical);
* **text-level leakage**: how many eval queries and eval candidate texts also
occur in the training split, and how many ``group_id`` values overlap.
Usage::
python -m V2_dpskw.audit_router_dataset ^
--train-file data/router_training_v5/train.jsonl ^
--eval-file data/router_training_v5/eval.jsonl ^
--output router_dataset_audit.json
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
import time
from collections import Counter, defaultdict
from pathlib import Path
if __package__ in {None, ""}:
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
def _digest(text: str) -> bytes:
return hashlib.sha1(text.encode("utf-8", "replace")).digest()
def _scan(path: Path) -> dict:
per_category: dict[str, Counter] = defaultdict(Counter)
families = Counter()
categories = Counter()
group_ids: set[str] = set()
query_digests: set[bytes] = set()
candidate_digests: set[bytes] = set()
positive_by_query: dict[bytes, set[bytes]] = defaultdict(set)
queries_by_digest: dict[bytes, str] = {}
episodes = 0
started = time.perf_counter()
with path.open("r", encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if not line:
continue
row = json.loads(line)
episodes += 1
family = str(row.get("family", "?") or "?")
metadata = row.get("metadata") if isinstance(row.get("metadata"), dict) else {}
category = str(metadata.get("category", "") or "(none)")
positives = row.get("positive_indices") or []
candidates = row.get("candidates") or []
families[family] += 1
categories[category] += 1
group_ids.add(str(row.get("group_id", "")))
bucket = per_category[category]
bucket["episodes"] += 1
bucket["answerable" if positives else "unknown"] += 1
bucket[f"candidates_{len(candidates)}"] += 1
bucket[f"positives_{min(len(positives), 4)}"] += 1
bucket[f"hop_{int(row.get('hop', 0))}"] += 1
bucket["candidate_slots"] += len(candidates)
bucket["positive_total"] += len(positives)
query = str(row.get("query", "")).strip()
query_digest = _digest(query) if query else None
if query_digest is not None:
query_digests.add(query_digest)
queries_by_digest.setdefault(query_digest, query)
texts: list[bytes] = []
for position, candidate in enumerate(candidates):
if not isinstance(candidate, dict):
continue
text = str(candidate.get("text", "")).strip()
if not text:
continue
digest = _digest(text)
candidate_digests.add(digest)
texts.append(digest)
# Evidence that a memorising router could exploit: the query text
# together with the exact positive evidence it should retrieve.
if query_digest is not None:
for position in positives:
if 0 <= int(position) < len(texts):
positive_by_query[query_digest].add(texts[int(position)])
return {
"episodes": episodes,
"families": dict(families),
"categories": dict(categories),
"per_category": {name: dict(counter) for name, counter in sorted(per_category.items())},
"group_ids": group_ids,
"query_digests": query_digests,
"candidate_digests": candidate_digests,
"positive_by_query": dict(positive_by_query),
"queries_by_digest": queries_by_digest,
"seconds": round(time.perf_counter() - started, 1),
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--train-file", default="data/router_training_v5/train.jsonl")
parser.add_argument("--eval-file", default="data/router_training_v5/eval.jsonl")
parser.add_argument("--output", default="router_dataset_audit.json")
args = parser.parse_args()
train_path = Path(args.train_file)
eval_path = Path(args.eval_file)
print(f"scanning {train_path} ...", flush=True)
train = _scan(train_path)
print(f"scanning {eval_path} ...", flush=True)
evaluation = _scan(eval_path)
report = {
"train": {key: value for key, value in train.items() if not key.endswith("_digests") and key not in {"group_ids", "positive_by_query", "queries_by_digest"}},
"eval": {key: value for key, value in evaluation.items() if not key.endswith("_digests") and key not in {"group_ids", "positive_by_query", "queries_by_digest"}},
"leakage": {},
}
overlapping_queries = evaluation["query_digests"] & train["query_digests"]
shared_evidence = [
digest for digest in overlapping_queries
if train["positive_by_query"].get(digest) and evaluation["positive_by_query"].get(digest)
and (train["positive_by_query"][digest] & evaluation["positive_by_query"][digest])
]
report["leakage"] = {
"group_id_overlap": len(train["group_ids"] & evaluation["group_ids"]),
"eval_queries_seen_in_train": len(overlapping_queries),
"eval_queries_total": len(evaluation["query_digests"]),
"query_overlap_rate": len(overlapping_queries) / max(1, len(evaluation["query_digests"])),
# The number that actually matters: a repeated query is only leakage when
# the same query also carries the same positive evidence in both splits.
"queries_with_shared_positive_evidence": len(shared_evidence),
"queries_with_shared_positive_evidence_examples": [
evaluation["queries_by_digest"].get(digest, "")[:60] for digest in shared_evidence[:5]
],
"eval_candidate_texts_seen_in_train": len(evaluation["candidate_digests"] & train["candidate_digests"]),
"eval_candidate_texts_total": len(evaluation["candidate_digests"]),
"candidate_overlap_rate": (
len(evaluation["candidate_digests"] & train["candidate_digests"])
/ max(1, len(evaluation["candidate_digests"]))
),
}
Path(args.output).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"families": report["eval"]["families"], "categories": report["eval"]["categories"],
"leakage": report["leakage"]}, ensure_ascii=False, indent=2), flush=True)
print("\n| category | episodes | answerable | unknown | avg candidates | avg positives | hop dist |", flush=True)
print("|---|---:|---:|---:|---:|---:|---|", flush=True)
for name, block in report["eval"]["per_category"].items():
episodes = block.get("episodes", 0)
print("| {name} | {ep} | {ans} | {unk} | {cand:.2f} | {pos:.2f} | {hop} |".format(
name=name,
ep=episodes,
ans=block.get("answerable", 0),
unk=block.get("unknown", 0),
cand=block.get("candidate_slots", 0) / max(1, episodes),
pos=block.get("positive_total", 0) / max(1, episodes),
hop={key.split("_")[1]: value for key, value in block.items() if key.startswith("hop_")},
), flush=True)
print(f"\nwrote {args.output}", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())