Files
whetstone_DSL/specialists/eval/eval_specialist.py
Bill 3fc9deac5b specialists: switch training to PyTorch, add capacity sweep infrastructure
- train_specialist_pt.py: pure PyTorch trainer replacing Fabricate binary.
  No SQLite/WAL — saves checkpoint.pt + history.json only. Fabricate was
  causing D-state IO blocking on the HDD due to continuous WAL writes.
- eval_specialist_pt.py: PyTorch eval with confusion matrix, threshold
  analysis, guardrail assessment, and deployment verdict.
- capacity_sweep.py: updated to call PyTorch scripts; TIERS now include
  heads field (4/6/8) since PyTorch supports multi-head unlike Fabricate.
- prereq_op_binary_split.py: evaluate_combined rewritten in PyTorch.
- Performance fix in train_specialist_pt.py: load training data onto GPU
  once and sample via torch.randint instead of DataLoader (eliminates
  Python/CPU overhead on tiny datasets). Fix applied, not yet timed.

Next session: time the fix, then run sweep_all_gates.sh --pilot-only.
See HANDOFF-2026-03-31.md for full context.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 22:55:51 -06:00

283 lines
9.8 KiB
Python

#!/usr/bin/env python3
"""
eval_specialist.py
Extended evaluation for whetstone specialist checkpoints.
Produces confusion matrix, per-class accuracy, confidence distribution,
and threshold analysis (accepted precision + abstain rate).
Output: JSON to stdout or --out file.
Usage:
python3 specialists/eval/eval_specialist.py \
--checkpoint /mnt/storage/fabricate_runs/whetstone_worker_type/checkpoint.bin \
--db /mnt/storage/fabricate_runs/whetstone_worker_type/fabricate.db \
--dataset specialists/data/generated/worker_type_eval.tsv \
--labels "implementer,reviewer,architect,qa" \
[--out specialists/eval/results/worker_type_eval.json]
[--thresholds 0.5,0.7,0.8,0.9]
[--seq-len 96]
"""
import argparse
import json
import sys
import time
from collections import defaultdict
from pathlib import Path
import numpy as np
FABRICATE_TOOLS = Path("/home/bill/Documents/WhetstoneAI_Fabricate/tools")
sys.path.insert(0, str(FABRICATE_TOOLS))
from eval_with_vocab import (
load_checkpoint, load_vocab, load_layers, build_runtime,
tokenize_batch, forward_batch,
)
def softmax(x):
e = np.exp(x - x.max(axis=-1, keepdims=True))
return e / e.sum(axis=-1, keepdims=True)
def load_labels(s):
p = Path(s)
if p.exists():
return [t.strip() for t in p.read_text().strip().split(",") if t.strip()]
return [t.strip() for t in s.split(",") if t.strip()]
def evaluate(checkpoint_path, db_path, dataset_path, label_tokens,
seq_len=96, batch_size=256):
weights, _ = load_checkpoint(checkpoint_path)
_, str_to_id = load_vocab(db_path)
layers = load_layers(db_path)
vocab_size = len(str_to_id)
_, emb_cnt = layers["token_embedding"]
D = emb_cnt // vocab_size
_, ff1_cnt = layers["block0.ff1_weight"]
F = ff1_cnt // D
L = sum(1 for k in layers if k.startswith("block") and k.endswith(".qkv_weight"))
cfg = dict(seq_len=seq_len, D=D, F=F, V=vocab_size, L=L)
runtime = build_runtime(weights, layers, cfg)
label_vocab_ids = []
missing = []
for tok in label_tokens:
vid = str_to_id.get(tok)
if vid is None:
missing.append(tok)
vid = 1
label_vocab_ids.append(vid)
label_vocab_ids_arr = np.array(label_vocab_ids, dtype=np.int32)
labels_gt, texts = [], []
with open(dataset_path) as f:
for line in f:
parts = line.strip().split("\t", 2)
if len(parts) < 3:
continue
labels_gt.append(int(parts[0]))
texts.append(parts[2])
labels_gt = np.array(labels_gt, dtype=np.int32)
n = len(labels_gt)
n_labels = len(label_tokens)
all_preds = []
all_confs = [] # max probability for predicted class
t0 = time.perf_counter()
for start in range(0, n, batch_size):
stop = min(start + batch_size, n)
token_ids = tokenize_batch(texts[start:stop], str_to_id, seq_len)
logits = forward_batch(token_ids, weights, layers, cfg, runtime=runtime)
label_logits = logits[:, label_vocab_ids_arr]
probs = softmax(label_logits.astype(np.float64))
pred_idx = np.argmax(probs, axis=1)
conf = probs[np.arange(len(pred_idx)), pred_idx]
all_preds.append(pred_idx)
all_confs.append(conf)
eval_latency_ms = (time.perf_counter() - t0) * 1000 / max(n, 1)
preds = np.concatenate(all_preds)
confs = np.concatenate(all_confs)
correct = (preds == labels_gt)
# --- Overall accuracy ---
overall_acc = float(correct.mean() * 100)
# --- Per-class accuracy ---
per_class = {}
for i, name in enumerate(label_tokens):
mask = labels_gt == i
if mask.sum() == 0:
per_class[name] = {"acc": None, "n": 0}
else:
per_class[name] = {
"acc": float(correct[mask].mean() * 100),
"n": int(mask.sum()),
}
# --- Confusion matrix ---
confusion = np.zeros((n_labels, n_labels), dtype=int)
for gt, pred in zip(labels_gt, preds):
confusion[gt, pred] += 1
# Adjacent confusion (common mistake pairs)
confusion_summary = []
for i in range(n_labels):
for j in range(n_labels):
if i != j and confusion[i, j] > 0:
confusion_summary.append({
"gt": label_tokens[i],
"pred": label_tokens[j],
"count": int(confusion[i, j]),
"gt_total": int((labels_gt == i).sum()),
})
confusion_summary.sort(key=lambda x: -x["count"])
# --- Confidence distribution ---
conf_bins = [0.0, 0.5, 0.6, 0.7, 0.8, 0.9, 0.95, 1.0]
conf_hist = {}
for lo, hi in zip(conf_bins[:-1], conf_bins[1:]):
mask = (confs >= lo) & (confs < hi)
n_in_bin = int(mask.sum())
if n_in_bin == 0:
continue
acc_in_bin = float(correct[mask].mean() * 100) if n_in_bin > 0 else None
conf_hist[f"{lo:.2f}-{hi:.2f}"] = {"n": n_in_bin, "acc": acc_in_bin}
# --- Threshold analysis ---
threshold_results = []
for threshold in [0.5, 0.6, 0.7, 0.8, 0.9]:
accepted_mask = confs >= threshold
n_accepted = int(accepted_mask.sum())
n_abstained = n - n_accepted
if n_accepted == 0:
threshold_results.append({
"threshold": threshold,
"accepted": 0,
"abstain_rate": 1.0,
"accepted_accuracy": None,
})
else:
acc = float(correct[accepted_mask].mean() * 100)
threshold_results.append({
"threshold": threshold,
"accepted": n_accepted,
"abstain_rate": round(n_abstained / n, 3),
"accepted_accuracy": round(acc, 1),
})
# --- Guardrail assessment ---
# Find lowest threshold where accepted_accuracy >= 95%
guardrail_threshold = None
guardrail_abstain_rate = None
for tr in threshold_results:
if tr["accepted_accuracy"] is not None and tr["accepted_accuracy"] >= 95.0:
guardrail_threshold = tr["threshold"]
guardrail_abstain_rate = tr["abstain_rate"]
break
# --- Deployment verdict ---
if overall_acc >= 97:
verdict = "deploy_direct"
elif guardrail_threshold is not None and guardrail_abstain_rate is not None and guardrail_abstain_rate <= 0.30:
verdict = "deploy_with_guardrails"
elif overall_acc >= 80:
verdict = "scale_more"
elif len(confusion_summary) > 0 and confusion_summary[0]["count"] / n > 0.10:
verdict = "decompose_or_relabel"
else:
verdict = "needs_investigation"
return {
"overall_accuracy": round(overall_acc, 1),
"n_eval": n,
"per_class": per_class,
"confusion_summary": confusion_summary[:10],
"confidence_histogram": conf_hist,
"threshold_analysis": threshold_results,
"guardrail_threshold": guardrail_threshold,
"guardrail_abstain_rate": guardrail_abstain_rate,
"eval_latency_ms_per_example": round(eval_latency_ms, 3),
"missing_label_tokens": missing,
"model_config": {"D": D, "F": F, "L": L, "vocab": vocab_size},
"verdict": verdict,
}
def print_report(result, labels):
print(f"\n{'='*60}")
print(f" Overall accuracy: {result['overall_accuracy']:.1f}% (n={result['n_eval']})")
print(f" Verdict: {result['verdict']}")
print(f"{'='*60}")
print("\nPer-class accuracy:")
for name, stat in result["per_class"].items():
if stat["acc"] is None:
print(f" {name:20s} (no examples)")
else:
print(f" {name:20s} {stat['acc']:5.1f}% (n={stat['n']})")
if result["confusion_summary"]:
print("\nTop confusions (gt → pred):")
for c in result["confusion_summary"][:6]:
pct = c["count"] / c["gt_total"] * 100
print(f" {c['gt']:20s}{c['pred']:20s} {c['count']}x ({pct:.0f}% of gt class)")
print("\nThreshold analysis:")
print(f" {'thresh':>6} {'accepted':>8} {'abstain%':>8} {'acc@thresh':>10}")
for tr in result["threshold_analysis"]:
abstain_pct = tr["abstain_rate"] * 100
acc = f"{tr['accepted_accuracy']:.1f}%" if tr["accepted_accuracy"] is not None else " n/a"
print(f" {tr['threshold']:>6.2f} {tr['accepted']:>8} {abstain_pct:>7.1f}% {acc:>10}")
if result["guardrail_threshold"]:
print(f"\n ✓ Guarded deployment viable at threshold={result['guardrail_threshold']:.2f}"
f" (abstain {result['guardrail_abstain_rate']*100:.0f}%)")
else:
print("\n ✗ No threshold achieves 95% accepted accuracy")
print(f"\nEval latency: {result['eval_latency_ms_per_example']:.3f} ms/example")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--checkpoint", required=True)
ap.add_argument("--db", required=True)
ap.add_argument("--dataset", required=True)
ap.add_argument("--labels", required=True)
ap.add_argument("--seq-len", type=int, default=96)
ap.add_argument("--batch-size", type=int, default=256)
ap.add_argument("--out", default=None, help="Write JSON result to file")
ap.add_argument("--quiet", action="store_true", help="Suppress human-readable output")
args = ap.parse_args()
label_tokens = load_labels(args.labels)
result = evaluate(
args.checkpoint, args.db, args.dataset,
label_tokens, seq_len=args.seq_len, batch_size=args.batch_size,
)
if not args.quiet:
print_report(result, label_tokens)
if args.out:
out_path = Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(result, indent=2))
print(f"\nJSON → {args.out}")
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()