Arx / training /evaluate.py
umer-wasim's picture
Desktop app 0.4.0 (Ask the assistant about this report), audit-aware chat search, v0.4 pipeline, converter for 1.5B
fbb1758 verified
Raw History Blame Contribute Delete
7.86 kB
#!/usr/bin/env python3
"""Evaluate the auditor model on held-out test examples, fully offline.
Measures, per task:
- valid JSON the reply parses as JSON
- grounded passes training/check_faithfulness.py (the same check the app uses)
- fields match finding: check_id, status and risk level equal the target;
summary: score and top gaps equal the target; ai: tools and approval flags equal the target
Run it for the base model and for the fine-tuned adapter to compare.
Usage:
training/.venv/bin/python training/evaluate.py --count 30 # base model
training/.venv/bin/python training/evaluate.py --count 30 --adapter training/models/auditor-lora/final
"""
import argparse
import json
import os
import random
import sys
import time
os.environ["HF_HUB_OFFLINE"] = "1"
os.environ["TRANSFORMERS_OFFLINE"] = "1"
import torch # noqa: E402
from transformers import AutoModelForCausalLM, AutoTokenizer # noqa: E402
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, "training"))
from check_faithfulness import check as grounding_problems # noqa: E402
from build_chat import check_chat # noqa: E402
CITE = __import__("re").compile(r"\[(\d+)\]")
REFUSAL = __import__("re").compile(r"can only help|don't have reliable information|won't guess|can't see your device", __import__("re").I)
def chat_scores(example, reply):
"""Chat answers: grounded = citations exist and numbers appear in the sources; correct = cites the reference's
passage, or (for questions the reference declines) declines without citations."""
graded = {"messages": example["messages"][:-1] + [{"role": "assistant", "content": reply}]}
grounded = not check_chat(graded)
target = example["messages"][-1]["content"]
want, got = set(CITE.findall(target)), set(CITE.findall(reply))
correct = bool(want & got) if want else (not got and bool(REFUSAL.search(reply)))
return grounded, correct
def pick(examples, count, seed):
"""Balanced sample: findings across statuses, plus summaries and AI classifications."""
rng = random.Random(seed)
groups = {}
for item in examples:
meta = item["meta"]
groups.setdefault((meta["task"], meta.get("status", "")), []).append(item)
for items in groups.values():
rng.shuffle(items)
chosen, keys = [], sorted(groups)
while len(chosen) < count and any(groups[k] for k in keys):
for key in keys:
if groups[key] and len(chosen) < count:
chosen.append(groups[key].pop())
return chosen
def fields_match(task, answer, target):
if task == "finding":
return (answer.get("check_id") == target["check_id"] and answer.get("status") == target["status"]
and (answer.get("risk") or {}).get("level") == target["risk"]["level"])
if task == "summary":
return (answer.get("score") == target["score"]
and [g.get("check_id") for g in answer.get("top_gaps", [])] == [g["check_id"] for g in target["top_gaps"]])
return ([(t.get("tool"), t.get("approved")) for t in answer.get("tools", [])]
== [(t["tool"], t["approved"]) for t in target["tools"]])
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--base", default=os.path.join(ROOT, "training", "models", "Qwen2.5-0.5B-Instruct"))
parser.add_argument("--adapter", help="LoRA adapter folder (omit to evaluate the base model)")
parser.add_argument("--data", default=os.path.join(ROOT, "training", "out", "test.jsonl"))
parser.add_argument("--count", type=int, default=30)
parser.add_argument("--max-new-tokens", type=int, default=700)
parser.add_argument("--seed", type=int, default=5)
parser.add_argument("--save", help="write every reply to this JSONL file")
parser.add_argument("--results", help="write the score table to this JSON file (for model cards)")
args = parser.parse_args()
torch.set_num_threads(os.cpu_count())
tokenizer = AutoTokenizer.from_pretrained(args.base, local_files_only=True)
model = AutoModelForCausalLM.from_pretrained(args.base, local_files_only=True, torch_dtype=torch.float32,
attn_implementation="eager")
if args.adapter:
from peft import PeftModel
model = PeftModel.from_pretrained(model, args.adapter)
model = model.merge_and_unload()
model.eval()
with open(args.data, encoding="utf-8") as handle:
examples = pick([json.loads(line) for line in handle], args.count, args.seed)
totals = {}
saved = []
started = time.time()
for number, example in enumerate(examples, 1):
task = example["meta"]["task"]
prompt_ids = tokenizer.apply_chat_template(example["messages"][:-1], add_generation_prompt=True, return_tensors="pt")
with torch.no_grad():
output = model.generate(prompt_ids, max_new_tokens=args.max_new_tokens, do_sample=False,
pad_token_id=tokenizer.eos_token_id)
reply = tokenizer.decode(output[0, prompt_ids.shape[1]:], skip_special_tokens=True).strip()
row = totals.setdefault(task, {"n": 0, "json": 0, "grounded": 0, "fields": 0})
row["n"] += 1
if task == "chat":
grounded, correct = chat_scores(example, reply)
row["json"] += 1 # chat is plain text: counted as well-formed
row["grounded"] += grounded
row["fields"] += correct
saved.append({"task": task, "meta": example["meta"], "reply": reply})
print(f"[{number}/{len(examples)}] {task:<18} {time.time() - started:6.0f}s", flush=True)
continue
target = json.loads(example["messages"][-1]["content"])
try:
answer = json.loads(reply)
row["json"] += 1
graded = {"messages": example["messages"][:-1] + [{"role": "assistant", "content": reply}]}
if not grounding_problems(graded):
row["grounded"] += 1
if fields_match(task, answer, target):
row["fields"] += 1
except json.JSONDecodeError:
pass
saved.append({"task": task, "meta": example["meta"], "reply": reply})
print(f"[{number}/{len(examples)}] {task:<18} {time.time() - started:6.0f}s", flush=True)
label = f"adapter {args.adapter}" if args.adapter else "base model"
print(f"\nResults for {label} on {len(examples)} test examples:")
print(f"{'task':<18} {'n':>3} {'valid JSON':>11} {'grounded':>9} {'fields match':>13}")
for task, row in sorted(totals.items()):
n = row["n"]
print(f"{task:<18} {n:>3} {row['json'] / n:>10.0%} {row['grounded'] / n:>9.0%} {row['fields'] / n:>13.0%}")
if args.results:
with open(args.results, "w", encoding="utf-8") as handle:
json.dump({"model": label, "data": os.path.relpath(args.data, ROOT), "count": len(examples), "seed": args.seed,
"tasks": {t: {"n": r["n"], "valid_json": r["json"] / r["n"], "grounded": r["grounded"] / r["n"],
"fields_match": r["fields"] / r["n"]} for t, r in sorted(totals.items())},
"overall": {k: sum(r[c] for r in totals.values()) / max(1, sum(r["n"] for r in totals.values()))
for k, c in (("valid_json", "json"), ("grounded", "grounded"), ("fields_match", "fields"))}},
handle, indent=2)
if args.save:
with open(args.save, "w", encoding="utf-8") as handle:
for item in saved:
handle.write(json.dumps(item, ensure_ascii=False) + "\n")
if __name__ == "__main__":
main()