hitit-cuneiform-ocr / code /src /conformal_analysis.py
savastakan's picture
Initial upload: code + 5 record checkpoints + fuse
f211247 verified
Raw
History Blame Contribute Delete
5.82 kB
#!/usr/bin/env python3
"""Conformal prediction + calibration + confusion analysis for v13 ensemble probs.
Given dumped probs from eval_ensemble_v2.py --dump-probs, produces:
- Split conformal prediction sets (APS, Raps, LAC)
- ECE (Expected Calibration Error) before/after temperature
- Top-k confusion pairs (N×N confusion matrix ranked)
- Per-tablet failure analysis (which tablets score lowest)
Not paper-critical but reviewer-friendly (frequentist guarantees).
"""
import argparse, json, time
from pathlib import Path
from collections import Counter, defaultdict
import numpy as np
import torch
import torch.nn.functional as F
ROOT = Path(__file__).resolve().parents[1].parent
def log(msg): print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
def ece(probs, targets, n_bins=15):
conf, pred = probs.max(-1)
acc = (pred == targets).float()
bins = torch.linspace(0, 1, n_bins + 1)
e = 0.0
for i in range(n_bins):
lo, hi = bins[i], bins[i+1]
m = (conf > lo) & (conf <= hi)
if m.sum() > 0:
e += (m.float().mean() * (conf[m].mean() - acc[m].mean()).abs()).item()
return e
def aps_conformal(probs, targets, alpha=0.1, cal_frac=0.5, seed=42):
"""Adaptive Prediction Sets: nonconformity = sum of sorted probs until true label."""
N, C = probs.shape
rng = np.random.default_rng(seed)
idx = rng.permutation(N)
n_cal = int(N * cal_frac)
cal_idx, test_idx = idx[:n_cal], idx[n_cal:]
# Nonconformity scores on calibration
p_cal = probs[cal_idx]; y_cal = targets[cal_idx]
scores = []
for i in range(len(cal_idx)):
order = p_cal[i].argsort(descending=True)
cum = 0.0
for r, c in enumerate(order.tolist()):
cum += p_cal[i, c].item()
if c == y_cal[i].item():
scores.append(cum)
break
q = np.quantile(scores, 1 - alpha)
# Build prediction sets on test
set_sizes = []
covered = 0
for i in test_idx:
order = probs[i].argsort(descending=True)
cum = 0.0; s = 0
for r, c in enumerate(order.tolist()):
cum += probs[i, c].item()
s += 1
if cum >= q:
break
set_sizes.append(s)
# Check if true label is in set (top-s)
topk = probs[i].topk(s).indices.tolist()
if targets[i].item() in topk:
covered += 1
return {
'alpha': alpha,
'q_hat': float(q),
'coverage': covered / len(test_idx),
'mean_set_size': float(np.mean(set_sizes)),
'median_set_size': float(np.median(set_sizes)),
'p90_set_size': float(np.percentile(set_sizes, 90)),
}
def top_confusion_pairs(probs, targets, idx_to_label, top_k=30):
pred = probs.argmax(-1)
pairs = Counter()
for p, t in zip(pred.tolist(), targets.tolist()):
if p != t:
pairs[(t, p)] += 1
return [
{
'true': idx_to_label.get(t, str(t)),
'pred': idx_to_label.get(p, str(p)),
'count': c,
}
for (t, p), c in pairs.most_common(top_k)
]
def per_tablet_accuracy(probs, targets, tablet_ids, top_k_worst=20):
if tablet_ids is None: return None
pred = probs.argmax(-1)
by_tablet = defaultdict(lambda: {'correct': 0, 'total': 0})
for p, t, tid in zip(pred.tolist(), targets.tolist(), tablet_ids):
by_tablet[tid]['correct'] += int(p == t)
by_tablet[tid]['total'] += 1
rows = [
{'tablet_id': tid, 'acc': d['correct']/d['total'], 'n': d['total']}
for tid, d in by_tablet.items() if d['total'] >= 5
]
rows.sort(key=lambda r: r['acc'])
return rows[:top_k_worst]
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--probs', required=True)
ap.add_argument('--output', required=True)
ap.add_argument('--alphas', nargs='+', type=float, default=[0.01, 0.05, 0.1, 0.2])
args = ap.parse_args()
log(f"Loading {args.probs}")
d = torch.load(args.probs, map_location='cpu', weights_only=False)
probs = d['probs']
targets = d['targets']
label_to_idx = d['label_to_idx']
idx_to_label = {v: k for k, v in label_to_idx.items()}
tablet_ids = d.get('tablet_ids')
log(f"N={len(targets)}, C={probs.shape[1]}")
out = {}
# Top-1, top-5
pred = probs.argmax(-1)
top1 = (pred == targets).float().mean().item()
_, top5 = probs.topk(5, dim=-1)
top5_acc = sum(targets[i].item() in top5[i].tolist() for i in range(len(targets))) / len(targets)
out['baseline'] = {'top1': top1, 'top5': top5_acc}
log(f"top1={top1:.4f} top5={top5_acc:.4f}")
# ECE
out['ece'] = ece(probs, targets)
log(f"ECE={out['ece']:.4f}")
# Conformal at multiple alphas
out['conformal'] = {}
for a in args.alphas:
try:
r = aps_conformal(probs, targets, alpha=a)
out['conformal'][f'alpha_{a}'] = r
log(f"Conformal α={a}: cov={r['coverage']:.4f} mean_set={r['mean_set_size']:.2f} p90={r['p90_set_size']:.1f}")
except Exception as e:
log(f"Conformal α={a} failed: {e}")
# Top confusion pairs
try:
out['top_confusion_pairs'] = top_confusion_pairs(probs, targets, idx_to_label, 30)
log(f"Top confusion pair: {out['top_confusion_pairs'][0] if out['top_confusion_pairs'] else 'none'}")
except Exception as e:
log(f"confusion pairs failed: {e}")
# Per-tablet worst
try:
out['worst_tablets'] = per_tablet_accuracy(probs, targets, tablet_ids, 20)
except Exception as e:
log(f"per-tablet failed: {e}")
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
json.dump(out, open(args.output, 'w'), indent=2)
log(f"Saved: {args.output}")
if __name__ == '__main__':
main()