Compactor-Qwen3.5-4B / eval_compaction.py
nbeerbower's picture
Compaction summariser: 0/45 -> 45/45 task lines, fact recall 0.157 -> 0.661
00bb63e verified
Raw
History Blame Contribute Delete
5.41 kB
"""Does the summary preserve what is needed to continue?
Loss is the wrong headline. The failure this tune exists to fix is specific: a summary that drops
the task, after which the agent invents a new one — sixteen searches of research compacted into 396
characters, answered with "fresh project scaffolded". So the metric is preservation, not fluency.
Four checks, all deterministic, no judge:
- **has_task** — is there a `Task:` line at all. The reference summaries all have one.
- **task_overlap** — word-level F1 between the generated task line and the reference's. Catches a
summary that states *a* task, just not this one.
- **fact_recall** — fraction of the reference's distinctive tokens (paths, identifiers, numbers,
errors) that survive into the generated summary. This is the one that catches a fluent summary
with the specifics sanded off, which is what a small model tends to produce.
- **compression** — length ratio. A "summary" that is 90% of the input has not compacted anything,
and one that is 2% has thrown the state away.
Run base and tuned through the identical path; the difference is the adapter.
Usage:
python3 eval_compaction.py --out base_eval.json
python3 eval_compaction.py --adapter out/adapter --out tuned_eval.json
python3 eval_compaction.py --compare base_eval.json tuned_eval.json
"""
import argparse
import json
import re
import sys
MODEL = "Lazarus-Ai/ReAligned-Qwen3.5-4B"
# Tokens worth preserving: paths, dotted identifiers, CamelCase, numbers with units, errors.
DISTINCTIVE = re.compile(
r"(?:/[\w.\-/]{4,})"
r"|(?:\b\w+\.(?:ts|py|json|jsonl|toml|md|sh|jinja|gguf|safetensors)\b)"
r"|(?:\b[A-Z][a-z]+[A-Z]\w+\b)"
r"|(?:\b\d{2,}(?:\.\d+)?[kKmMgG]?\b)"
r"|(?:\b[A-Z][A-Z_]{3,}\b)"
)
def task_line(text: str) -> str:
m = re.search(r"^\s*task:\s*(.+)$", text, re.I | re.M)
return m.group(1).strip() if m else ""
def f1(a: str, b: str) -> float:
ta = {w.lower() for w in re.findall(r"\w+", a) if len(w) > 2}
tb = {w.lower() for w in re.findall(r"\w+", b) if len(w) > 2}
if not ta or not tb:
return 0.0
hit = len(ta & tb)
p, r = hit / len(ta), hit / len(tb)
return 0.0 if p + r == 0 else 2 * p * r / (p + r)
def score(generated: str, reference: str, prompt: str) -> dict:
ref_facts = set(DISTINCTIVE.findall(reference))
gen_facts = set(DISTINCTIVE.findall(generated))
return {
"has_task": bool(task_line(generated)),
"task_overlap": round(f1(task_line(generated), task_line(reference)), 3),
"fact_recall": round(len(ref_facts & gen_facts) / len(ref_facts), 3) if ref_facts else None,
"compression": round(len(generated) / max(len(prompt), 1), 4),
"chars": len(generated),
}
def run(args) -> None:
import torch
from transformers import AutoModelForImageTextToText, AutoTokenizer
tok = AutoTokenizer.from_pretrained(MODEL)
tok.padding_side = "left"
if tok.pad_token_id is None:
tok.pad_token = tok.eos_token
model = AutoModelForImageTextToText.from_pretrained(
MODEL, dtype=torch.bfloat16, device_map="cuda:0")
if args.adapter:
from peft import PeftModel
model = PeftModel.from_pretrained(model, args.adapter)
model.eval()
rows = [json.loads(l) for l in open(args.val)][: args.limit]
results = []
for i, r in enumerate(rows):
enc = tok(r["prompt"], return_tensors="pt").to(model.device)
with torch.no_grad():
out = model.generate(**enc, max_new_tokens=1200, do_sample=False,
pad_token_id=tok.pad_token_id)
gen = tok.decode(out[0][enc["input_ids"].shape[1]:], skip_special_tokens=True).strip()
s = score(gen, r["chosen"], r["prompt"])
s["text"] = gen[:1500]
results.append(s)
print(f"[{i+1}/{len(rows)}] task={s['has_task']} overlap={s['task_overlap']} "
f"facts={s['fact_recall']} chars={s['chars']}", flush=True)
summarise(results, args.adapter or "base")
json.dump({"adapter": args.adapter, "results": results}, open(args.out, "w"), indent=1)
def summarise(results: list, label: str) -> None:
n = len(results)
has = sum(r["has_task"] for r in results)
ov = [r["task_overlap"] for r in results]
fr = [r["fact_recall"] for r in results if r["fact_recall"] is not None]
comp = [r["compression"] for r in results]
print(f"\n--- {label} ({n} held-out windows) ---")
print(f" has Task: line {has}/{n} ({100*has/n:.0f}%)")
print(f" task overlap {sum(ov)/n:.3f} mean")
print(f" fact recall {sum(fr)/len(fr):.3f} mean" if fr else " fact recall n/a")
print(f" compression {sum(comp)/n:.3f} mean (reference ~0.12)")
print(f" mean chars {sum(r['chars'] for r in results)//n}")
def compare(a: str, b: str) -> int:
for path in (a, b):
d = json.load(open(path))
summarise(d["results"], d.get("adapter") or "base")
return 0
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--adapter")
ap.add_argument("--val", default="compaction_val.jsonl")
ap.add_argument("--limit", type=int, default=45)
ap.add_argument("--out", default="compaction_eval.json")
ap.add_argument("--compare", nargs=2)
a = ap.parse_args()
sys.exit(compare(*a.compare) if a.compare else run(a))