- 引入 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,读写关闭时与原生模型逐位相同
174 lines
7.8 KiB
Python
174 lines
7.8 KiB
Python
"""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())
|