Add Natural Memory architecture and tooling

This commit is contained in:
WpyQwq
2026-09-05 08:53:41 +08:00
parent 0acf8b06ee
commit 516351f0b5
56 changed files with 18319 additions and 0 deletions
+291
View File
@@ -0,0 +1,291 @@
"""Paired teacher/student benchmark for Natural Memory versus full KV context."""
from __future__ import annotations
import argparse
import gc
import json
import re
import sys
import time
from collections import defaultdict
from pathlib import Path
from typing import Any, Iterable
import torch
from .qwen_integration import load_memory_config, load_qwen_base, load_qwen_dynamic, load_tokenizer
from .stream_chat_qwen_memory import _chat_tensor, _write_turn
PROJECT_ROOT = Path(__file__).resolve().parent
def _path(value: str | Path) -> Path:
path = Path(value)
if path.is_absolute() or path.exists():
return path
return PROJECT_ROOT / path
def _normalize(text: str) -> str:
return re.sub(r"[\s`*_#,。!?、;:,.!?;:'\"()()\[\]{}]", "", str(text)).lower()
def _contains(text: str, choices: Iterable[str]) -> bool:
normalized = _normalize(text)
return any(_normalize(choice) and _normalize(choice) in normalized for choice in choices)
def _passed(response: str, case: dict[str, Any]) -> bool:
return _contains(response, case.get("acceptable", [])) and not _contains(
response, case.get("forbidden", [])
)
def _read_cases(path: Path, *, limit: int | None, offset: int, category: str | None) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
with path.open("r", encoding="utf-8") as handle:
for raw in handle:
if not raw.strip():
continue
case = json.loads(raw)
if category and case.get("category") != category:
continue
if offset > 0:
offset -= 1
continue
rows.append(case)
if limit is not None and len(rows) >= limit:
break
if not rows:
raise ValueError("no validation cases selected")
return rows
def _teacher_messages(case: dict[str, Any]) -> list[dict[str, str]]:
messages: list[dict[str, str]] = []
for fact in case["facts"]:
messages.append({"role": "user", "content": str(fact["text"])})
messages.append({"role": "assistant", "content": str(fact.get("assistant", "好的。"))})
messages.append({"role": "user", "content": str(case["query"])})
return messages
@torch.inference_mode()
def _generate_teacher(model: Any, tokenizer: Any, case: dict[str, Any], max_new_tokens: int) -> str:
device = model.get_input_embeddings().weight.device
encoded = tokenizer.apply_chat_template(
_teacher_messages(case),
tokenize=True,
add_generation_prompt=True,
return_tensors="pt",
return_dict=True,
enable_thinking=False,
)
encoded = {key: value.to(device) for key, value in encoded.items() if isinstance(value, torch.Tensor)}
output = model.generate(
**encoded,
max_new_tokens=max_new_tokens,
do_sample=False,
use_cache=True,
pad_token_id=tokenizer.pad_token_id,
)
return tokenizer.decode(output[0, encoded["input_ids"].shape[1] :], skip_special_tokens=True).strip()
@torch.inference_mode()
def _generate_student(
model: Any,
tokenizer: Any,
case: dict[str, Any],
*,
max_new_tokens: int,
force_write: bool,
) -> tuple[str, dict[str, Any]]:
device = model._find_layer_device()
model.reset_memory(batch_size=1, device=device)
write_rows: list[dict[str, Any]] = []
for fact in case["facts"]:
changed = _write_turn(
model,
tokenizer,
str(fact["text"]),
device,
force_write=force_write,
)
last_slot = model.runtime.text_last_written_slot
write_rows.append(
{
"kind": fact.get("kind", "fact"),
"should_write": bool(fact.get("should_write", True)),
"changed": bool(changed),
"slot": int(last_slot[0].item()) if isinstance(last_slot, torch.Tensor) else -1,
}
)
encoded = {key: value.to(device) for key, value in _chat_tensor(
tokenizer,
str(case["query"]),
).items()}
query = tokenizer(str(case["query"]), add_special_tokens=False, return_tensors="pt")
query_ids = query["input_ids"].to(device)
query_mask = query.get("attention_mask")
if query_mask is None:
query_mask = torch.ones_like(query_ids)
query_mask = query_mask.to(device)
output = model.generate(
**encoded,
max_new_tokens=max_new_tokens,
do_sample=False,
update_memory=False,
memory_query_input_ids=query_ids,
memory_query_attention_mask=query_mask,
memory_query_text=str(case["query"]),
use_cache=True,
pad_token_id=tokenizer.pad_token_id,
)
response = tokenizer.decode(output[0, encoded["input_ids"].shape[1] :], skip_special_tokens=True).strip()
return response, {
"writes": write_rows,
"valid_slots": int(model.runtime.text_slot_valid.sum().item())
if isinstance(model.runtime.text_slot_valid, torch.Tensor)
else 0,
"v2": model.memory_v2_stats(),
}
def _release(model: Any) -> None:
del model
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
def _summarize(rows: list[dict[str, Any]]) -> dict[str, Any]:
categories: dict[str, list[float]] = defaultdict(list)
for row in rows:
categories[str(row["category"])].append(float(row["passed"]))
return {
"cases": len(rows),
"accuracy": sum(float(row["passed"]) for row in rows) / max(1, len(rows)),
"categories": {
category: {"cases": len(values), "accuracy": sum(values) / len(values)}
for category, values in sorted(categories.items())
},
}
def main() -> None:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--model-path", default="qwen3_5_4b_natural_memory_v2")
parser.add_argument("--adapter", default=None, help="optional candidate adapter; omitted uses embedded policy")
parser.add_argument("--data", default="data/mega_validation/memory_validation_100k.jsonl")
parser.add_argument("--output", default="mega_memory_vs_full_kv_report.json")
parser.add_argument("--limit", type=int, default=32)
parser.add_argument("--offset", type=int, default=0)
parser.add_argument("--category", default=None)
parser.add_argument("--max-new-tokens", type=int, default=24)
parser.add_argument("--force-write", action="store_true")
parser.add_argument("--no-4bit", action="store_true")
args = parser.parse_args()
model_path = _path(args.model_path)
data_path = _path(args.data)
output_path = _path(args.output)
cases = _read_cases(data_path, limit=args.limit, offset=args.offset, category=args.category)
tokenizer = load_tokenizer(model_path)
use_4bit = not args.no_4bit
report: dict[str, Any] = {
"format_version": 1,
"model_path": str(model_path),
"data": str(data_path),
"selected_cases": len(cases),
"force_write": bool(args.force_write),
"quantization": "4bit_nf4" if use_4bit else "none",
}
started = time.perf_counter()
teacher = load_qwen_base(model_path, load_in_4bit=use_4bit)
teacher.eval()
teacher_rows: list[dict[str, Any]] = []
for index, case in enumerate(cases, 1):
response = _generate_teacher(teacher, tokenizer, case, args.max_new_tokens)
teacher_rows.append(
{
"id": case["id"],
"category": case["category"],
"response": response,
"passed": _passed(response, case),
}
)
if index % 16 == 0:
print(f"teacher {index}/{len(cases)}")
report["teacher"] = _summarize(teacher_rows)
_release(teacher)
adapter_path = _path(args.adapter) if args.adapter else None
config_source = adapter_path or model_path
config = load_memory_config(config_source)
config.persistent_memory = True
config.natural_language_memory = True
config.automatic_memory = True
student = load_qwen_dynamic(model_path, memory_config=config, load_in_4bit=use_4bit)
if adapter_path is not None:
student.load_memory_adapter(adapter_path, strict=True)
student.eval()
student_rows: list[dict[str, Any]] = []
for index, case in enumerate(cases, 1):
response, diagnostics = _generate_student(
student,
tokenizer,
case,
max_new_tokens=args.max_new_tokens,
force_write=args.force_write,
)
student_rows.append(
{
"id": case["id"],
"category": case["category"],
"response": response,
"passed": _passed(response, case),
"diagnostics": diagnostics,
}
)
if index % 16 == 0:
print(f"student {index}/{len(cases)}")
report["student"] = _summarize(student_rows)
_release(student)
teacher_accuracy = float(report["teacher"]["accuracy"])
student_accuracy = float(report["student"]["accuracy"])
teacher_by_id = {row["id"]: row for row in teacher_rows}
paired_teacher_pass = sum(bool(teacher_by_id[row["id"]]["passed"]) for row in student_rows)
paired_student_pass = sum(bool(row["passed"]) and teacher_by_id[row["id"]]["passed"] for row in student_rows)
category_gate: dict[str, Any] = {}
for category in sorted({str(case["category"]) for case in cases}):
teacher_cat = [row for row in teacher_rows if row["category"] == category]
student_cat = [row for row in student_rows if row["category"] == category]
t = sum(float(row["passed"]) for row in teacher_cat) / max(1, len(teacher_cat))
s = sum(float(row["passed"]) for row in student_cat) / max(1, len(student_cat))
category_gate[category] = {"teacher_accuracy": t, "student_accuracy": s, "ratio": s / max(t, 1e-9), "pass": s >= 0.95 * t}
report["parity"] = {
"teacher_accuracy": teacher_accuracy,
"student_accuracy": student_accuracy,
"student_to_teacher_ratio": student_accuracy / max(teacher_accuracy, 1e-9),
"paired_teacher_pass": paired_teacher_pass,
"paired_student_pass": paired_student_pass,
"paired_ratio": paired_student_pass / max(1, paired_teacher_pass),
"category_gate": category_gate,
"required_ratio": 0.95,
"pass": student_accuracy >= 0.95 * teacher_accuracy and all(item["pass"] for item in category_gate.values()),
}
report["elapsed_seconds"] = time.perf_counter() - started
report["failures"] = [row for row in student_rows if not row["passed"]][:100]
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"teacher": report["teacher"], "student": report["student"], "parity": report["parity"], "output": str(output_path)}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()