#!/usr/bin/env python3 """Evaluate a trained Memory-LoRA head: recall EM/EditSim on held-out QA, split into in-corpus (ir_test) and cross-corpus (cr_val/cr_test), plus a manual spot-check comparing the LoRA-adapted model against the bare base model on hand-picked Code2LoRA-paper questions (proof the adapter, not general pretraining, is doing the recall). Forked from Code2LoRA's evaluation metrics (EM after whitespace collapsing + trailing-punctuation removal with relaxed prefix matching; EditSim via difflib.SequenceMatcher). Usage: python scripts/eval_memory_lora.py --checkpoint runs/full1/head.best.pt """ from __future__ import annotations import argparse import difflib import json import re import sys from pathlib import Path from typing import Any, Dict, List import torch from transformers import AutoModelForImageTextToText, AutoTokenizer HERE = Path(__file__).resolve().parent REPO_ROOT = HERE.parent sys.path.insert(0, str(REPO_ROOT)) from memory_lora.data_paths import EMBEDDINGS_DIR, QNA_DIR # noqa: E402 from memory_lora.core import ( # noqa: E402 MemoryLoRAHead, DEFAULT_ROOT_PREFIX, get_module_specs, inject_lora_weights, load_doc_rows, load_qna_rows, replace_with_lora, ) DEFAULT_MODEL = "google/gemma-4-E2B" DEFAULT_TARGET_MODULES = [ "q_proj", "k_proj", "v_proj", "o_proj", "up_proj", "gate_proj", "down_proj", ] SPOT_CHECK_QUESTIONS = [ ("What LoRA rank does Code2LoRA's static hypernetwork use?", "16"), ("How many trainable parameters does Code2LoRA-Static have?", "720 million"), ("What is the cross-repo exact match of Code2LoRA-Static on the static track?", "63.8%"), ("How many Python repositories are in RepoPeftBench?", "604"), ("What is the base LLM used in Code2LoRA's experiments?", "Qwen2.5-Coder-1.5B"), ] def normalize(s: str) -> str: s = s.strip().rstrip(".,;:!?") s = re.sub(r"\s+", " ", s) return s.lower() def exact_match(pred: str, target: str) -> bool: p, t = normalize(pred), normalize(target) return p == t or p.startswith(t) or t.startswith(p) def edit_sim(pred: str, target: str) -> float: return difflib.SequenceMatcher(None, normalize(pred), normalize(target)).ratio() @torch.no_grad() def generate(base_model, tokenizer, prefix: str, device, max_new_tokens: int = 12) -> str: """Greedy-decode the answer, then truncate at the first newline. Without this the model frequently keeps going past the answer into a hallucinated ``\\nQ: `` continuation (base models without an EOS-triggering chat template rarely stop cleanly on a bare completion prompt); comparing the *untruncated* string against the gold answer would mark an otherwise-correct short answer wrong just because of what it rambled into afterward. """ enc = tokenizer(prefix, return_tensors="pt").to(device) out = base_model.generate( **enc, max_new_tokens=max_new_tokens, do_sample=False, pad_token_id=tokenizer.pad_token_id or tokenizer.eos_token_id, ) gen_ids = out[0][enc["input_ids"].shape[1]:] text = tokenizer.decode(gen_ids, skip_special_tokens=True) return text.split("\n")[0] def load_head_and_model(checkpoint: Path, model_name: str, target_modules: List[str], root_prefix: str, device: torch.device, dtype: torch.dtype, attn_implementation: str): # weights_only=False: these checkpoints carry the run's config/args dicts, # not just tensors, and torch>=2.6 defaults the strict unpickler on -- # which rejects them ("Unsupported operand"). They are produced by this # project's own training script, so loading them fully is intended. ckpt = torch.load(checkpoint, map_location="cpu", weights_only=False) tokenizer = AutoTokenizer.from_pretrained(model_name) if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token base_model = AutoModelForImageTextToText.from_pretrained( model_name, torch_dtype=dtype, attn_implementation=attn_implementation, ).to(device) base_model.eval() for p in base_model.parameters(): p.requires_grad = False specs = get_module_specs(base_model, target_modules, root_prefix=root_prefix) rank = ckpt["config"]["rank"] alpha = ckpt["args"].get("alpha", 32.0) replace_with_lora(base_model, specs, rank=rank, alpha=alpha) head = MemoryLoRAHead( input_dim=ckpt["config"]["input_dim"], type_dims={k: tuple(v) for k, v in ckpt["config"]["type_dims"].items()}, hidden_dim=ckpt["config"]["hidden_dim"], rank=rank, ).to(device) head.load_state_dict(ckpt["state_dict"]) head.eval() return base_model, head, specs, tokenizer def eval_suite(base_model, head, specs, tokenizer, doc_rows, qnas_by_doc, device, max_qna_per_doc: int = 20) -> Dict[str, float]: n_em, n_total, sum_editsim = 0, 0, 0.0 for dr in doc_rows: pairs = qnas_by_doc.get(dr.doc_id, [])[:max_qna_per_doc] if not pairs: continue ctx = torch.from_numpy(dr.doc_embedding).to(device).unsqueeze(0) head_out = head(ctx) inject_lora_weights(base_model, specs, head_out, batch_index=0) for p in pairs: pred = generate(base_model, tokenizer, p["prefix"], device) target = p["target"] if exact_match(pred, target): n_em += 1 sum_editsim += edit_sim(pred, target) n_total += 1 return { "em": n_em / max(n_total, 1), "editsim": sum_editsim / max(n_total, 1), "n": n_total, } def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--checkpoint", required=True) ap.add_argument("--embeddings-path", default=str(EMBEDDINGS_DIR / "doc_embeddings.parquet")) ap.add_argument("--qna-path", default=str(QNA_DIR / "qna.jsonl")) ap.add_argument("--model-name", default=DEFAULT_MODEL) ap.add_argument("--target-modules", nargs="+", default=DEFAULT_TARGET_MODULES) ap.add_argument("--root-prefix", default=DEFAULT_ROOT_PREFIX) ap.add_argument("--device", default="mps") ap.add_argument("--dtype", default="bfloat16", choices=["bfloat16", "float16", "float32"]) ap.add_argument("--attn-implementation", default="sdpa", choices=["sdpa", "eager"]) ap.add_argument("--suites", nargs="+", default=["cr_val", "cr_test", "ir_test"]) ap.add_argument("--max-qna-per-doc", type=int, default=20) ap.add_argument("--limit-docs", type=int, default=0, help="Random-sample at most N docs per suite (fixed seed) " "for a fast estimate -- cr_test has thousands of real " "repos, far too many to greedy-generate on CPU.") ap.add_argument("--skip-spot-check", action="store_true") args = ap.parse_args() device = torch.device(args.device if (args.device != "mps" or torch.backends.mps.is_available()) else "cpu") dtype = {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}[args.dtype] base_model, head, specs, tokenizer = load_head_and_model( Path(args.checkpoint), args.model_name, args.target_modules, args.root_prefix, device, dtype, args.attn_implementation, ) all_docs = load_doc_rows(Path(args.embeddings_path)) all_qnas = load_qna_rows(Path(args.qna_path)) qnas_by_doc_all = {} qnas_held_out_by_doc = {} for q in all_qnas: qnas_by_doc_all.setdefault(q.doc_id, []).append({"prefix": q.prefix, "target": q.target}) if q.qna_split == "held_out": qnas_held_out_by_doc.setdefault(q.doc_id, []).append({"prefix": q.prefix, "target": q.target}) train_docs = [d for d in all_docs if d.split == "train"] results: Dict[str, Any] = {} for suite in args.suites: if suite in ("cr_val", "cr_test"): rows, q_by_doc = [d for d in all_docs if d.split == suite], qnas_by_doc_all elif suite == "ir_test": rows, q_by_doc = train_docs, qnas_held_out_by_doc else: continue if args.limit_docs and len(rows) > args.limit_docs: import random as _r rows = _r.Random(3407).sample(rows, args.limit_docs) print(f"Evaluating {suite} ({len(rows)} docs) ...", flush=True) m = eval_suite(base_model, head, specs, tokenizer, rows, q_by_doc, device, max_qna_per_doc=args.max_qna_per_doc) results[suite] = m print(f" {suite}: EM={m['em']:.3f} EditSim={m['editsim']:.3f} n={m['n']}", flush=True) print(json.dumps(results, indent=2)) if not args.skip_spot_check: print("\n=== Spot check: base model vs. LoRA-adapted, Code2LoRA paper facts ===", flush=True) paper_doc = next((d for d in all_docs if d.doc_id == "code2lora_paper"), None) if paper_doc is None: print(" [skip] code2lora_paper doc not found in embeddings", flush=True) else: ctx = torch.from_numpy(paper_doc.doc_embedding).to(device).unsqueeze(0) head_out = head(ctx) for q, gold in SPOT_CHECK_QUESTIONS: prefix = f"Q: {q}\nA:" # base: zero out LoRA (A=B=None) by re-wrapping without injection for sp in specs: named = dict(base_model.named_modules()) named[sp.full_name].A = None named[sp.full_name].B = None base_pred = generate(base_model, tokenizer, prefix, device) inject_lora_weights(base_model, specs, head_out, batch_index=0) adapted_pred = generate(base_model, tokenizer, prefix, device) print(f"Q: {q}") print(f" gold: {gold}") print(f" base: {base_pred!r}") print(f" adapted: {adapted_pred!r}") print() if __name__ == "__main__": main()