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
+324
View File
@@ -0,0 +1,324 @@
"""Do the frozen features encode *which attribute* a paraphrased question asks about?
The previous measurement killed the score-geometry route to abstention (best fitted-head AUC
~0.61, and near chance for three of four scorers). The remaining structural route is:
map the question to the attribute it asks about, then check whether the bank holds it
That only works if attribute identity is recoverable from the query's frozen key *for
phrasings never seen during training*. This script tests exactly that, and nothing else:
* query vectors are the same 2560-dim frozen keys the runtime scores;
* a linear classifier is fitted on the **train** split's queries and scored on the **eval**
split's queries, which are *different paraphrases of the same attributes* -- so the
reported number is generalisation to unseen wording, not memorisation;
* chance level is 1 / (number of attributes).
It also reports the "none" case that abstention needs: accuracy when the query's attribute
is absent from the candidate set (the unknown episodes), i.e. can we even tell that the
question refers to something the bank does not hold.
Usage::
python -m V2_dpskw.analyze_query_attribute_classifier
"""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
import numpy as np
def text_key(text: str) -> str:
return hashlib.sha1(str(text).encode("utf-8", "replace")).hexdigest()
def load(path: Path) -> 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))
return rows
def softmax_linear_fit(X: np.ndarray, y: np.ndarray, classes: int, *, steps: int = 4000,
lr: float = 0.5, l2: float = 1e-3):
"""Plain multinomial logistic regression (no sklearn dependency)."""
W = np.zeros((X.shape[1], classes))
b = np.zeros(classes)
n = len(y)
onehot = np.zeros((n, classes))
onehot[np.arange(n), y] = 1.0
for _ in range(steps):
logits = X @ W + b
logits -= logits.max(axis=1, keepdims=True)
probs = np.exp(logits)
probs /= probs.sum(axis=1, keepdims=True)
grad = probs - onehot
W -= lr * (X.T @ grad / n + l2 * W)
b -= lr * grad.mean(axis=0)
return W, b
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--data-dir", default="data/zero_overlap")
parser.add_argument("--feature-cache", default=r"H:\Memory\nm_cache\nm_zero_overlap\feature_cache")
parser.add_argument("--output", default="query_attribute_classifier.json")
parser.add_argument("--markdown", default="query_attribute_classifier.md")
parser.add_argument("--holdout-attributes", type=int, default=0,
help=("train on all but N attributes and evaluate only on those N; "
"tests whether the head generalises to attributes it has never seen"))
parser.add_argument("--none-class", action="store_true",
help=("train an explicit NONE class using queries of attributes held out "
"of the vocabulary, then test on a *different* held-out set"))
args = parser.parse_args()
cache = Path(args.feature_cache)
bank = np.load(cache / "features.f16.npy", mmap_mode="r")
lookup = json.loads((cache / "index.json").read_text(encoding="utf-8"))
attributes = sorted({row["metadata"]["attribute"] for row in load(Path(args.data_dir) / "train.jsonl")})
index_of = {name: position for position, name in enumerate(attributes)}
classes = len(attributes)
def vectors(rows):
X, y, unknown, present = [], [], [], []
for row in rows:
key = text_key(row["query"])
if key not in lookup:
raise SystemExit(f"query missing from the bank: {row['query']!r}")
X.append(np.asarray(bank[lookup[key]], dtype=np.float32))
y.append(index_of[row["metadata"]["attribute"]])
unknown.append(0 if row.get("positive_indices") else 1)
# Which attributes does this episode's candidate set actually hold? The
# abstention question is whether the *asked-about* attribute is among them.
present.append({c.get("attribute") for c in row["candidates"] if c.get("attribute")})
return np.array(X), np.array(y), np.array(unknown), present
train_X, train_y, _, _ = vectors(load(Path(args.data_dir) / "train.jsonl"))
eval_X, eval_y, eval_unknown, eval_present = vectors(load(Path(args.data_dir) / "eval.jsonl"))
# L2-normalise and scale: the frozen keys are pooled hidden states.
train_Xn = train_X / (np.linalg.norm(train_X, axis=1, keepdims=True) + 1e-6)
eval_Xn = eval_X / (np.linalg.norm(eval_X, axis=1, keepdims=True) + 1e-6)
holdout_report = None
if args.none_class:
# The production shape of the problem: the bank holds a *vocabulary* of attributes,
# and a user may ask about one outside it. Train K attribute classes PLUS an
# explicit NONE class, where NONE examples are real queries about attributes that
# are deliberately excluded from the vocabulary. A separate, further held-out set
# of attributes is then used for testing, so NONE recall is measured out of sample.
order = np.argsort([attributes[position] for position in range(classes)])
vocabulary = set(order[:12].tolist())
none_train = set(order[12:18].tolist())
none_eval = set(order[18:].tolist())
compact = {original: position for position, original in enumerate(sorted(vocabulary))}
none_label = len(compact)
rows_train = np.concatenate([np.where(np.isin(train_y, list(vocabulary)))[0],
np.where(np.isin(train_y, list(none_train)))[0]])
labels_train = np.array([
compact.get(int(train_y[index]), none_label) for index in rows_train])
W_n, b_n = softmax_linear_fit(train_Xn[rows_train], labels_train, len(compact) + 1)
def predict(X: np.ndarray) -> np.ndarray:
return (X @ W_n + b_n).argmax(axis=1)
eval_answerable = eval_unknown == 0
vocab_rows = eval_answerable & np.isin(eval_y, list(vocabulary))
none_rows = eval_answerable & np.isin(eval_y, list(none_eval))
vocab_pred = predict(eval_Xn[vocab_rows])
none_pred = predict(eval_Xn[none_rows])
vocab_correct = float(np.mean(vocab_pred == np.array(
[compact[int(label)] for label in eval_y[vocab_rows]])))
none_recall = float(np.mean(none_pred == none_label))
none_precision = float(np.sum(none_pred == none_label) / max(1, none_pred.size))
none_report = {
"vocabulary_attributes": [attributes[position] for position in sorted(vocabulary)],
"none_train_attributes": [attributes[position] for position in sorted(none_train)],
"none_eval_attributes": [attributes[position] for position in sorted(none_eval)],
"train_rows": int(rows_train.size),
"eval_vocabulary_queries": int(vocab_rows.sum()),
"eval_never_seen_queries": int(none_rows.sum()),
"chance_pct": round(100.0 / (len(compact) + 1), 2),
"vocabulary_accuracy_pct": round(100 * vocab_correct, 2),
"never_seen_rejected_pct": round(100 * none_recall, 2),
"known_false_rejection_pct": round(
100 * float(np.mean(vocab_pred == none_label)), 2),
"note": ("an explicit NONE class trained on held-out attributes; the never-seen "
"attributes used for testing are disjoint from both the vocabulary and "
"the NONE training set"),
}
Path(args.output).write_text(json.dumps(none_report, ensure_ascii=False, indent=2),
encoding="utf-8")
print(json.dumps(none_report, ensure_ascii=False, indent=2))
return 0
if args.holdout_attributes > 0:
# Open-set test: N attributes are removed from the *training* label set entirely,
# so the head has never seen them. A production bank cannot know in advance which
# attributes a user will ask about, so the question that matters is: when a
# never-trained attribute is asked about, does the head (a) land on some *present*
# attribute -- which would make the coverage rule answer wrongly -- or (b) betray
# its uncertainty, giving a usable reject option?
rng = np.random.default_rng(20260912)
held = set(rng.choice(classes, size=min(args.holdout_attributes, classes - 2),
replace=False).tolist())
kept = sorted(set(range(classes)) - held)
compact = {original: position for position, original in enumerate(kept)}
keep_train = np.array([label in compact for label in train_y])
W_h, b_h = softmax_linear_fit(
train_Xn[keep_train],
np.array([compact[label] for label in train_y[keep_train]]),
len(kept))
def max_probability(X: np.ndarray) -> np.ndarray:
logits = X @ W_h + b_h
logits -= logits.max(axis=1, keepdims=True)
probs = np.exp(logits)
probs /= probs.sum(axis=1, keepdims=True)
return probs.max(axis=1)
held_mask = np.array([(label in held) and (unknown == 0)
for label, unknown in zip(eval_y, eval_unknown)])
kept_mask = np.array([(label in compact) and (unknown == 0)
for label, unknown in zip(eval_y, eval_unknown)])
held_prob = max_probability(eval_Xn[held_mask])
kept_prob = max_probability(eval_Xn[kept_mask])
held_pred = (eval_Xn[held_mask] @ W_h + b_h).argmax(axis=1)
# Would the closed-set head claim a *present* attribute for a never-trained one?
held_pred_names = [attributes[kept[position]] for position in held_pred]
held_present = np.array([
name in present for name, present in zip(held_pred_names, np.array(eval_present, dtype=object)[held_mask])
])
threshold = float(np.percentile(kept_prob, 5)) if kept_prob.size else 0.0
holdout_report = {
"held_out_attributes": sorted(attributes[position] for position in held),
"trained_on_attributes": len(kept),
"eval_held_out_queries": int(held_mask.sum()),
"eval_kept_queries": int(kept_mask.sum()),
"max_prob_held_out": {
"p10": round(float(np.percentile(held_prob, 10)), 4),
"p50": round(float(np.percentile(held_prob, 50)), 4),
"p90": round(float(np.percentile(held_prob, 90)), 4),
},
"max_prob_kept": {
"p10": round(float(np.percentile(kept_prob, 10)), 4),
"p50": round(float(np.percentile(kept_prob, 50)), 4),
"p90": round(float(np.percentile(kept_prob, 90)), 4),
},
"openset_auc_held_out_vs_kept": round(
float(np.mean(held_prob[:, None] < kept_prob[None, :])), 4),
"closed_set_false_claim_pct": round(100 * float(np.mean(held_present)), 2),
"reject_threshold_from_kept_p05": round(threshold, 4),
"held_out_rejected_pct_at_threshold": round(
100 * float(np.mean(held_prob < threshold)), 2),
"kept_retained_pct_at_threshold": round(
100 * float(np.mean(kept_prob >= threshold)), 2),
"note": ("held-out attributes never appear as training labels, so this is the "
"open-set case a real user bank faces"),
}
Path(args.output).write_text(json.dumps(holdout_report, ensure_ascii=False, indent=2),
encoding="utf-8")
print(json.dumps(holdout_report, ensure_ascii=False, indent=2))
return 0
W, b = softmax_linear_fit(train_Xn, train_y, classes)
eval_logits = eval_Xn @ W + b
eval_pred = eval_logits.argmax(axis=1)
train_pred = (train_Xn @ W + b).argmax(axis=1)
answerable = eval_unknown == 0
accuracy_all = float(np.mean(eval_pred == eval_y))
accuracy_answerable = float(np.mean((eval_pred == eval_y)[answerable]))
report = {
"attributes": classes,
"chance_pct": round(100.0 / classes, 2),
"train_queries": len(train_y),
"eval_queries": len(eval_y),
"distinct_train_queries": len({text_key(t) for t in []}) or None,
"train_accuracy_pct": round(100 * float(np.mean(train_pred == train_y)), 2),
"eval_accuracy_pct": round(100 * accuracy_all, 2),
"eval_accuracy_answerable_pct": round(100 * accuracy_answerable, 2),
"note": ("eval queries are different paraphrases of the same attributes, so this is "
"generalisation to unseen wording"),
}
# Per-attribute breakdown for the answerable eval queries.
per_attribute = {}
for position, name in enumerate(attributes):
mask = (eval_y == position) & answerable
if mask.sum():
per_attribute[name] = {
"eval_queries": int(mask.sum()),
"correct_pct": round(100 * float(np.mean(eval_pred[mask] == position)), 2),
}
report["per_attribute"] = per_attribute
# --- the structural abstention signal -------------------------------------------
# Predicted asked-about attribute vs the attributes the candidate set actually holds.
# Answerable episodes should land inside the set; abstention episodes should land
# outside it. A categorical split here means abstention needs no fragile threshold.
predicted_names = [attributes[position] for position in eval_pred]
inside = np.array([name in present for name, present in zip(predicted_names, eval_present)])
coverage = {
"rule": "predict the asked-about attribute; refuse when the candidate set lacks it",
"answerable_inside_pct": round(100 * float(np.mean(inside[answerable])), 2),
"unknown_inside_pct": round(100 * float(np.mean(inside[~answerable])), 2),
"answerable_cases": int(answerable.sum()),
"unknown_cases": int((~answerable).sum()),
}
# What a naive "refuse when outside" rule would yield on this split:
refused = ~inside
tp = int(np.sum(refused & ~answerable)) # unknown correctly refused
fn = int(np.sum(~refused & ~answerable)) # unknown wrongly answered
fp = int(np.sum(refused & answerable)) # answerable wrongly refused
tn = int(np.sum(~refused & answerable)) # answerable correctly answered
coverage.update({
"unknown_refusal_pct": round(100 * tp / max(1, tp + fn), 2),
"known_false_refusal_pct": round(100 * fp / max(1, fp + tn), 2),
"counts": {"tp": tp, "fn": fn, "fp": fp, "tn": tn},
})
report["coverage_rule"] = coverage
Path(args.output).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
lines = [
"# 冻结特征是否编码「提问问的是哪个属性」?", "",
f"属性数 {classes}(随机基线 {100.0/classes:.2f}%);训练查询 {len(train_y)} 条,"
f"评测查询 {len(eval_y)} 条(**同为这些属性的不同改写,训练时从未见过**)。", "",
"| 指标 | 值 |", "|---|---:|",
f"| 训练集准确率 | {report['train_accuracy_pct']:.2f}% |",
f"| **评测集准确率(未见改写)** | **{report['eval_accuracy_pct']:.2f}%** |",
f"| 评测集·仅可回答子集 | {report['eval_accuracy_answerable_pct']:.2f}% |",
f"| 随机基线 | {report['chance_pct']:.2f}% |", "",
"## 结构化拒答规则(判属性 → 查是否在库里)", "",
f"规则:{coverage['rule']}。", "",
"| 指标 | 值 |", "|---|---:|",
f"| 可回答 episode 中「预测属性在候选集内」 | **{coverage['answerable_inside_pct']:.2f}%**({coverage['answerable_cases']} 条) |",
f"| 未知 episode 中「预测属性在候选集内」 | **{coverage['unknown_inside_pct']:.2f}%**({coverage['unknown_cases']} 条) |",
f"| 未知拒答率(规则命中) | **{coverage['unknown_refusal_pct']:.2f}%** |",
f"| 已知问题被误拒率 | **{coverage['known_false_refusal_pct']:.2f}%** |",
f"| 混淆计数 | tp={coverage['counts']['tp']} fn={coverage['counts']['fn']} fp={coverage['counts']['fp']} tn={coverage['counts']['tn']} |",
"",
"## 按属性(评测集可回答)", "",
"| 属性 | 评测问法数 | 正确率 |", "|---|---:|---:|",
]
for name, body in sorted(per_attribute.items(), key=lambda kv: -kv[1]["correct_pct"]):
lines.append(f"| {name} | {body['eval_queries']} | {body['correct_pct']:.2f}% |")
text = "\n".join(lines) + "\n"
Path(args.markdown).write_text(text, encoding="utf-8")
print(text)
return 0
if __name__ == "__main__":
raise SystemExit(main())