Files
whetstone_DSL/specialists/eval/eval_specialist_pt.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

297 lines
10 KiB
Python

#!/usr/bin/env python3
"""
eval_specialist_pt.py
Extended evaluation for PyTorch specialist checkpoints.
Drop-in replacement for eval_specialist.py, but loads checkpoint.pt instead
of checkpoint.bin + fabricate.db.
Output: JSON to stdout (and optionally --out file).
Usage:
python3 specialists/eval/eval_specialist_pt.py \
--checkpoint /tmp/runs/worker_type/checkpoint.pt \
--dataset specialists/data/generated/worker_type_eval.tsv \
--labels "implementer,reviewer,architect,qa" \
[--out specialists/eval/results/worker_type_eval.json] \
[--quiet]
"""
import argparse
import json
import time
from pathlib import Path
import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, Dataset
# ── Copied from train_specialist_pt.py ───────────────────────────────────────
def _tokenize(text):
return text.lower().split()
class Vocab:
PAD, UNK = 0, 1
def __init__(self, str_to_id=None):
if str_to_id is not None:
self.str_to_id = str_to_id
self.id_to_str = [None] * len(str_to_id)
for s, i in str_to_id.items():
self.id_to_str[i] = s
else:
self.str_to_id = {"<pad>": 0, "<unk>": 1}
self.id_to_str = ["<pad>", "<unk>"]
def encode(self, text, seq_len):
ids = [self.str_to_id.get(t, self.UNK) for t in _tokenize(text)]
ids = ids[:seq_len]
ids += [self.PAD] * (seq_len - len(ids))
return ids
def __len__(self):
return len(self.id_to_str)
class SpecialistModel(nn.Module):
def __init__(self, vocab_size, hidden_dim, layers, heads, n_labels, seq_len):
super().__init__()
self.seq_len = seq_len
self.tok_emb = nn.Embedding(vocab_size, hidden_dim, padding_idx=0)
self.pos_emb = nn.Embedding(seq_len, hidden_dim)
encoder_layer = nn.TransformerEncoderLayer(
d_model=hidden_dim,
nhead=heads,
dim_feedforward=hidden_dim * 4,
dropout=0.0,
batch_first=True,
)
self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=layers)
self.head = nn.Linear(hidden_dim, n_labels)
def forward(self, x):
pad_mask = (x == 0)
pos = torch.arange(x.size(1), device=x.device).unsqueeze(0)
h = self.tok_emb(x) + self.pos_emb(pos)
h = self.encoder(h, src_key_padding_mask=pad_mask)
lengths = (~pad_mask).float().sum(dim=1, keepdim=True).clamp(min=1)
h = (h * (~pad_mask).unsqueeze(-1).float()).sum(dim=1) / lengths
return self.head(h)
# ── Eval ──────────────────────────────────────────────────────────────────────
def softmax(x):
e = np.exp(x - x.max(axis=-1, keepdims=True))
return e / e.sum(axis=-1, keepdims=True)
def evaluate(checkpoint_path, dataset_path, label_tokens, seq_len=96, batch_size=256):
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
ckpt = torch.load(checkpoint_path, map_location=device)
cfg = ckpt["config"]
# Reconstruct vocab from checkpoint
vocab_obj = ckpt.get("vocab_obj")
if vocab_obj is None:
vocab_obj = Vocab(str_to_id=ckpt["vocab"])
model = SpecialistModel(
vocab_size=cfg["vocab_size"],
hidden_dim=cfg["hidden_dim"],
layers=cfg["layers"],
heads=cfg["heads"],
n_labels=cfg["n_labels"],
seq_len=cfg["seq_len"],
).to(device)
model.load_state_dict(ckpt["model"])
model.eval()
# Load eval data
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])
n = len(labels_gt)
labels_gt_arr = np.array(labels_gt, dtype=np.int32)
# Run inference
all_preds = []
all_confs = []
t0 = time.perf_counter()
with torch.no_grad():
for start in range(0, n, batch_size):
batch_texts = texts[start:start + batch_size]
ids = [vocab_obj.encode(t, cfg["seq_len"]) for t in batch_texts]
x = torch.tensor(ids, dtype=torch.long, device=device)
logits = model(x).cpu().numpy().astype(np.float64)
probs = softmax(logits)
pred = np.argmax(probs, axis=1)
conf = probs[np.arange(len(pred)), pred]
all_preds.append(pred)
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_arr)
overall_acc = float(correct.mean() * 100)
# Per-class accuracy
per_class = {}
for i, name in enumerate(label_tokens):
mask = labels_gt_arr == 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
n_labels = len(label_tokens)
confusion = np.zeros((n_labels, n_labels), dtype=int)
for gt, pred in zip(labels_gt_arr, preds):
confusion[gt, pred] += 1
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_arr == i).sum()),
})
confusion_summary.sort(key=lambda x: -x["count"])
# 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 (lowest threshold with ≥95% accepted accuracy)
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 confusion_summary 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],
"threshold_analysis": threshold_results,
"guardrail_threshold": guardrail_threshold,
"guardrail_abstain_rate": guardrail_abstain_rate,
"eval_latency_ms_per_example": round(eval_latency_ms, 3),
"model_config": cfg,
"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("--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)
ap.add_argument("--quiet", action="store_true")
# Accept --db for compatibility with old callers (ignored)
ap.add_argument("--db", default=None)
args = ap.parse_args()
label_tokens = [l.strip() for l in args.labels.split(",")]
result = evaluate(args.checkpoint, 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()