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

249 lines
12 KiB
Python

"""Runtime-level end-to-end evaluation over the zero-overlap paraphrase eval set.
The 16-case router-critical suite is too small to separate real effects from one flipped
case (each case is 6.25% of the score). This evaluator drives the *full* zero-overlap eval
split -- every episode writes all of its 24 same-shape candidate facts into the runtime,
then asks the paraphrased question -- so each case is ~1.3% at 80 cases and the recorded
rate is far less sensitive to a single example.
Scoring is deliberately conservative and code-based rather than wording-based:
* answerable episode: correct when the expected code appears in the reply;
* ``wrong_attribute``: a *different* candidate's code appears and the expected one does not;
* unknown episode: ``leaked`` when any candidate code appears (the runtime should decline).
Usage::
python -m V2_dpskw.eval_runtime_zero_overlap_e2e --cases 64 --blends 0.0,0.5
"""
from __future__ import annotations
import argparse
import json
import random
import time
from collections import Counter
from pathlib import Path
import torch
from .eval_end_to_end_memory import write_fact
from .eval_router_critical_e2e import answer
from .qwen_integration import load_memory_config, load_qwen_dynamic, load_tokenizer
def load_episodes(path: Path, count: int, seed: int, only: str = "all") -> list[dict]:
rows = []
with path.open("r", encoding="utf-8") as handle:
for line in handle:
line = line.strip()
if line:
rows.append(json.loads(line))
rng = random.Random(seed)
answerable = [row for row in rows if row.get("positive_indices")]
unknown = [row for row in rows if not row.get("positive_indices")]
rng.shuffle(answerable)
rng.shuffle(unknown)
if only == "unknown":
return unknown[:count]
if only == "answerable":
return answerable[:count]
# Keep the dataset's own proportion of abstention episodes.
share = len(unknown) / max(1, len(rows))
wanted_unknown = int(round(count * share))
picked = answerable[: max(0, count - wanted_unknown)] + unknown[:wanted_unknown]
rng.shuffle(picked)
return picked[:count]
@torch.inference_mode()
def run_episodes(model, tokenizer, episodes, device, *, max_new_tokens: int, label: str) -> dict:
rows = []
started = time.perf_counter()
for index, episode in enumerate(episodes, 1):
# Prefer the dataset's own answer strings: they are the official scoring target
# and they generalise beyond the "我的X是 VAL-…。" template, so the same evaluator
# can run on the v6 mega episodes as well.
acceptable = [str(item) for item in (episode["metadata"].get("acceptable") or [])]
if not acceptable:
acceptable = [str(episode["metadata"].get("answer", ""))]
acceptable = [item for item in acceptable if item]
# Every candidate's answer surface, used to detect "answered a different attribute".
other_surfaces: list[str] = []
for candidate in episode["candidates"]:
for item in (candidate.get("acceptable") or []):
other_surfaces.append(str(item))
if not candidate.get("acceptable") and candidate.get("text"):
other_surfaces.append(str(candidate["text"].split()[-1].rstrip("。")))
expected = acceptable[0] if acceptable else ""
model.reset_memory(batch_size=1, device=device)
for candidate in episode["candidates"]:
write_fact(model, tokenizer, candidate["text"], device)
active = sum(1 for record in model.memory_os_v2.bank.records.values()
if record.status == "active")
reply = answer(model, tokenizer, episode["query"], device, max_new_tokens)
decision = model.runtime.v2_last_decisions[-1] if model.runtime.v2_last_decisions else {}
lowered = reply.lower()
hit_expected = any(item.lower() in lowered for item in acceptable)
other_hits = [item for item in set(other_surfaces)
if item.lower() not in {a.lower() for a in acceptable}
and item.lower() in lowered]
answerable = bool(episode.get("positive_indices"))
rows.append({
"id": episode["id"],
"attribute": episode["metadata"].get("attribute", ""),
"answerable": answerable,
"expected": expected,
"reply": reply[:140],
"correct": bool(hit_expected) if answerable else False,
"wrong_attribute": bool(answerable and not hit_expected and other_hits),
"leaked": bool((not answerable) and other_hits),
"records_written": len(episode["candidates"]),
"records_active": active,
"stop_reason": str(decision.get("stop_reason")),
"records_selected": len(decision.get("record_ids") or []),
"top_score": float(decision.get("top_score") or 0.0),
"need_memory": bool(decision.get("need_memory")),
})
if index % 8 == 0:
print(json.dumps({"label": label, "case": index, "total": len(episodes),
"accuracy_pct": round(100 * sum(r["correct"] for r in rows)
/ max(1, len(rows)), 2)}), flush=True)
answerable_rows = [row for row in rows if row["answerable"]]
unknown_rows = [row for row in rows if not row["answerable"]]
def score_band(block: list[dict]) -> dict:
"""Report the top-score distribution, so a separating threshold can be judged."""
values = sorted(row["top_score"] for row in block)
if not values:
return {"n": 0}
def pct(fraction: float) -> float:
return values[min(len(values) - 1, int(fraction * len(values)))]
return {
"n": len(values),
# Raw decision scores, NOT probabilities: the runtime's top_score is an
# unbounded relevance value (observed up to ~851), so it must never be
# rendered as a percentage.
"min": round(values[0], 2),
"p10": round(pct(0.10), 2),
"p50": round(pct(0.50), 2),
"p90": round(pct(0.90), 2),
"max": round(values[-1], 2),
}
summary = {
"router": label,
"cases": len(rows),
"answerable_cases": len(answerable_rows),
"unknown_cases": len(unknown_rows),
"accuracy_pct": 100 * sum(row["correct"] for row in answerable_rows) / max(1, len(answerable_rows)),
"wrong_attribute_pct": 100 * sum(row["wrong_attribute"] for row in answerable_rows)
/ max(1, len(answerable_rows)),
"unknown_leak_pct": 100 * sum(row["leaked"] for row in unknown_rows) / max(1, len(unknown_rows)),
"mean_records_selected": sum(row["records_selected"] for row in rows) / max(1, len(rows)),
"records_active_min": min((row["records_active"] for row in rows), default=0),
"records_active_max": max((row["records_active"] for row in rows), default=0),
"stop_reasons": dict(Counter(row["stop_reason"] for row in rows)),
"top_score_answerable": score_band(answerable_rows),
"top_score_unknown": score_band(unknown_rows),
"seconds": round(time.perf_counter() - started, 1),
}
return {"summary": summary, "rows": rows}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--eval-file", default="data/zero_overlap/eval.jsonl")
parser.add_argument("--package", default="qwen3_5_4b_natural_memory_v2")
parser.add_argument("--cases", type=int, default=64)
parser.add_argument("--seed", type=int, default=20260912)
parser.add_argument("--only", choices=("all", "answerable", "unknown"), default="all",
help="restrict the sample to one class of episode")
parser.add_argument("--blends", default="0.0,0.5")
parser.add_argument("--prior-scales", default="1.0",
help="comma list of PagedMemoryBankV2._record_scores prior scales; "
"1.0 is historical, 0.0 leaves ordering to the learned scorer")
parser.add_argument("--router", default="checkpoints/router_replay_v7_v2_128/memory_router_v2.pt")
parser.add_argument("--max-new-tokens", type=int, default=48)
parser.add_argument("--output", default="runtime_zero_overlap_e2e.json")
parser.add_argument("--markdown", default="runtime_zero_overlap_e2e.md")
args = parser.parse_args()
blends = [float(value) for value in args.blends.split(",") if value.strip()]
episodes = load_episodes(Path(args.eval_file), args.cases, args.seed, args.only)
print(json.dumps({"episodes": len(episodes),
"answerable": sum(1 for e in episodes if e.get("positive_indices")),
"candidate_count": len(episodes[0]["candidates"])}), flush=True)
model_path = Path(args.package)
memory_config = load_memory_config(model_path)
model = load_qwen_dynamic(model_path, memory_config=memory_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")
router_path = Path(args.router)
if router_path.exists():
state = torch.load(router_path, map_location="cpu", weights_only=True)
model.memory_router_v2.load_state_dict(state.get("router_state_dict", state), strict=True)
model.memory_router_v2.to(device).eval()
print(json.dumps({"router_swapped": str(router_path)}), flush=True)
results = {}
for prior_scale in [float(v) for v in args.prior_scales.split(",") if v.strip()]:
model.memory_config.memory_prior_scale = prior_scale
for blend in blends:
model.memory_config.memory_record_router_blend = blend
label = "prior=%.2f blend=%.2f" % (prior_scale, blend)
block = run_episodes(model, tokenizer, episodes, device,
max_new_tokens=args.max_new_tokens, label=label)
results[label] = block
print(json.dumps(block["summary"], ensure_ascii=False), flush=True)
report = {
"eval_file": args.eval_file,
"episodes": len(episodes),
"candidate_count": len(episodes[0]["candidates"]),
"router": str(router_path),
"results": {label: block["summary"] for label, block in results.items()},
"rows": {label: block["rows"] for label, block in results.items()},
}
Path(args.output).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
lines = ["# 运行时端到端评测(零重叠改写,大样本)", "",
"每条 episode 把全部 {0} 个同形候选事实写入运行时,再问改写后的问题。".format(
len(episodes[0]["candidates"])),
"随机基线 1/{0} = {1:.2f}%。全部为百分比。".format(
len(episodes[0]["candidates"]), 100.0 / len(episodes[0]["candidates"])), "",
"| blend | 用例 | 可回答 | 回答正确率 | 答成别的属性 | 未知泄漏率 | 平均选中记录 | 活跃记录 min/max |",
"|---|---:|---:|---:|---:|---:|---:|---|"]
for label, block in results.items():
s = block["summary"]
lines.append("| {0} | {1} | {2} | {3:.2f}% | {4:.2f}% | {5:.2f}% | {6:.4f} | {7}/{8} |".format(
label, s["cases"], s["answerable_cases"], s["accuracy_pct"], s["wrong_attribute_pct"],
s["unknown_leak_pct"], s["mean_records_selected"],
s["records_active_min"], s["records_active_max"]))
lines += ["", "## 检索最高分分布(原始分,非百分比;用于判断阈值能否分开『未知』与『可回答』)", "",
"| 组 | n | min | p10 | p50 | p90 | max |", "|---|---:|---:|---:|---:|---:|---:|"]
for label, block in results.items():
s = block["summary"]
for key, nice in (("top_score_answerable", "可回答"), ("top_score_unknown", "不可回答")):
band = s.get(key) or {}
if band.get("n"):
lines.append("| {0} · {1} | {2} | {3} | {4} | {5} | {6} | {7} |".format(
label, nice, band["n"], band["min"], band["p10"], band["p50"],
band["p90"], band["max"]))
text = "\n".join(lines) + "\n"
Path(args.markdown).write_text(text, encoding="utf-8")
print(text, flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())