Files
natural-memory-nm21/diagnose_write_survival.py
T
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

136 lines
5.9 KiB
Python

"""How many written facts actually survive into the bank, and does the target survive?
End-to-end runs on realistic data reported ``records_active`` of 2-9 while the harness
writes up to 32 candidate facts per episode, and making the router fully responsible for
ranking moved answerable accuracy by one case. Both observations point at the same
suspicion: the binding constraint is not ranking, it is *what got stored*.
This probe measures it directly and cheaply -- it only writes, never generates:
* how many candidates were written vs how many records are active afterwards;
* whether the episode's *target* fact survived, which is a hard prerequisite for any
ranker to be able to answer;
* the dominant rejection reason, read from the runtime (write gate vs parse vs supersede).
Usage::
python -m V2_dpskw.diagnose_write_survival --eval-file data/realistic_v2/eval.jsonl --cases 40
"""
from __future__ import annotations
import argparse
import json
from collections import Counter, defaultdict
from pathlib import Path
import torch
from .eval_end_to_end_memory import write_fact
from .qwen_integration import infer_memory_metadata, load_memory_config, load_qwen_dynamic, load_tokenizer
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--package", default="qwen3_5_4b_natural_memory_v2_1")
parser.add_argument("--eval-file", default="data/realistic_v2/eval.jsonl")
parser.add_argument("--cases", type=int, default=40)
parser.add_argument("--output", default="write_survival.json")
args = parser.parse_args()
rows = []
with Path(args.eval_file).open("r", encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if line:
rows.append(json.loads(line))
# One episode per category, so the report is not dominated by one shape.
by_category: dict[str, list[dict]] = defaultdict(list)
for row in rows:
by_category[row["metadata"]["category"]].append(row)
picked = []
per = max(1, args.cases // max(1, len(by_category)))
for category, items in sorted(by_category.items()):
picked.extend(items[:per])
model_path = Path(args.package)
config = load_memory_config(model_path)
model = load_qwen_dynamic(model_path, memory_config=config, load_in_4bit=True,
max_memory={0: "10.5GiB", "cpu": "48GiB"})
model.eval()
tokenizer = load_tokenizer(model_path)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
per_category: dict[str, dict] = defaultdict(lambda: {"episodes": 0, "written": 0, "active": 0,
"target_survived": 0,
"answerable": 0, "parsed": 0})
target_missing_examples = []
for row in picked:
candidates = [c["text"] for c in row["candidates"]]
positives = row.get("positive_indices") or []
acceptable = [str(v) for v in (row["metadata"].get("acceptable") or [])]
model.reset_memory(batch_size=1, device=device)
for text in candidates:
write_fact(model, tokenizer, text, device)
bank = model.memory_os_v2.bank
active = [r for r in bank.records.values() if r.status == "active"]
active_text = "\n".join(r.text or "" for r in active)
category = row["metadata"]["category"]
bucket = per_category[category]
bucket["episodes"] += 1
bucket["written"] += len(candidates)
bucket["active"] += len(active)
bucket["parsed"] += sum(1 for text in candidates
if infer_memory_metadata(text).get("attribute"))
if positives:
bucket["answerable"] += 1
survived = any(value and value in active_text for value in acceptable)
bucket["target_survived"] += int(survived)
if not survived and len(target_missing_examples) < 6:
target_missing_examples.append({
"category": category,
"query": row["query"],
"target_text": candidates[positives[0]][:90],
"active_records": len(active),
})
report = {
"package": str(model_path),
"eval_file": args.eval_file,
"episodes": len(picked),
"per_category": {},
"target_missing_examples": target_missing_examples,
}
for category, bucket in sorted(per_category.items()):
report["per_category"][category] = {
"episodes": bucket["episodes"],
"candidates_written": bucket["written"],
"active_after_write": bucket["active"],
"write_survival_pct": round(100 * bucket["active"] / max(1, bucket["written"]), 2),
"candidates_parsed_as_attribute_pct": round(
100 * bucket["parsed"] / max(1, bucket["written"]), 2),
"answerable_episodes": bucket["answerable"],
"target_survived": bucket["target_survived"],
"target_survival_pct": round(
100 * bucket["target_survived"] / max(1, bucket["answerable"]), 2),
}
total_written = sum(b["written"] for b in per_category.values())
total_active = sum(b["active"] for b in per_category.values())
total_answerable = sum(b["answerable"] for b in per_category.values())
total_survived = sum(b["target_survived"] for b in per_category.values())
report["overall"] = {
"candidates_written": total_written,
"active_after_write": total_active,
"write_survival_pct": round(100 * total_active / max(1, total_written), 2),
"answerable_episodes": total_answerable,
"target_survival_pct": round(100 * total_survived / max(1, total_answerable), 2),
}
Path(args.output).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())