hitit-cuneiform-ocr / code /src /eval_ensemble_v2.py
savastakan's picture
Initial upload: code + 5 record checkpoints + fuse
f211247 verified
Raw
History Blame Contribute Delete
21.2 kB
#!/usr/bin/env python3
"""Ensemble + multi-scale TTA + rejection + optional LM/GNN rescoring.
v2: weighted ensemble (DINOv3 0.45 / ConvNeXt-V2 0.30 / SigLIP2 0.25),
multi-scale TTA (224/320/384), selective classification @ τ=0.6,
optional tablet-context GNN post-processing and KenLM rescoring.
"""
import os, sys, json, argparse, time
from pathlib import Path
from collections import Counter, defaultdict
import numpy as np
import torch
import torch.nn.functional as F
from torch.utils.data import DataLoader, Dataset
from PIL import Image
ROOT = Path("/arf/scratch/stakan/hitit-proje")
sys.path.insert(0, str(ROOT / "hitit_ocr/src"))
from train_classification import build_backbone, get_arch_img_size
def log(msg): print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
DEFAULT_WEIGHTS = {
'dinov3_vitl14': 0.45, 'dinov3_vitb14': 0.45,
'convnextv2_large': 0.30,
'siglip2_so400m': 0.25,
}
class ValDataset(Dataset):
def __init__(self, manifest, label_to_idx, tf, val_fold=0):
self.records = []
with open(manifest) as f:
for line in f:
r = json.loads(line)
if r.get('task') != 'classification': continue
if not r.get('unified_label') or r['unified_label'] not in label_to_idx: continue
if not r.get('path') or r.get('storage') != 'fs': continue
if r.get('integrity_ok') is False: continue
if (r.get('class_sample_count') or 0) < 5: continue
if r.get('tablet_view_fold', 0) != val_fold: continue
self.records.append(r)
self.label_to_idx = label_to_idx
self.tf = tf
def __len__(self): return len(self.records)
def __getitem__(self, idx):
r = self.records[idx]
img = Image.open(r['path']).convert('RGB')
tablet_id = r.get('tablet_id') or Path(r.get('path', '')).parent.name
return self.tf(img), self.label_to_idx[r['unified_label']], tablet_id
def collate(batch):
imgs = torch.stack([b[0] for b in batch])
ys = torch.tensor([b[1] for b in batch], dtype=torch.long)
tids = [b[2] for b in batch]
return imgs, ys, tids
def get_patch_mil_transforms(n_patches=4, native=224,
mean=(0.489, 0.448, 0.424), std=(0.362, 0.359, 0.364)):
"""Split image into n_patches × n_patches grid, return list of crops."""
from torchvision import transforms
from torchvision.transforms import functional as TF
class PatchGrid:
def __init__(self, row, col, n):
self.row = row; self.col = col; self.n = n
def __call__(self, img):
W, H = img.size
pw, ph = W // self.n, H // self.n
x0 = self.col * pw; y0 = self.row * ph
return TF.crop(img, y0, x0, ph, pw)
# 2x2 patches + full image (5 total)
out = [('full', transforms.Compose([
transforms.Resize((native, native), antialias=True),
transforms.ToTensor(), transforms.Normalize(mean, std)]))]
for r in range(2):
for c in range(2):
out.append((f'p{r}{c}', transforms.Compose([
PatchGrid(r, c, 2),
transforms.Resize((native, native), antialias=True),
transforms.ToTensor(), transforms.Normalize(mean, std)])))
return out
def get_tta_transforms(scales=(224, 320, 384), native=224, mean=(0.489, 0.448, 0.424),
std=(0.362, 0.359, 0.364)):
"""Multi-scale × {identity, gamma+, gamma-, rot+3, rot-3}. No hflip (cuneiform asymmetric).
native: model's expected input resolution. We resize to `s`, apply aug, then to `native`
so model always sees its trained resolution."""
from torchvision import transforms
from torchvision.transforms import functional as TF
class Gamma:
def __init__(self, g): self.g = g
def __call__(self, img): return TF.adjust_gamma(img, self.g)
class Rot:
def __init__(self, a): self.a = a
def __call__(self, img): return TF.rotate(img, self.a)
aug_list = [
('id', None),
('g0.9', Gamma(0.9)),
('g1.1', Gamma(1.1)),
('rot+3', Rot(3)),
('rot-3', Rot(-3)),
]
out = []
for s in scales:
for name, pre in aug_list:
ops = [transforms.Resize((s, s), antialias=True)]
if pre is not None: ops.append(pre)
if s != native: ops.append(transforms.Resize((native, native), antialias=True))
ops += [transforms.ToTensor(), transforms.Normalize(mean, std)]
out.append((f"{s}_{name}", transforms.Compose(ops)))
return out
@torch.no_grad()
def infer_softmax(model, loader, device, dtype):
model.eval()
all_p, all_y, all_tids = [], [], []
for x, y, tids in loader:
x = x.to(device, non_blocking=True)
with torch.amp.autocast('cuda', dtype=dtype, enabled=True):
logits = model(x)
p = F.softmax(logits.float(), dim=-1)
all_p.append(p.cpu()); all_y.append(y); all_tids.extend(tids)
return torch.cat(all_p), torch.cat(all_y), all_tids
def fit_temperature(logits, targets, max_iter=100):
"""Fit T on validation — Guo 2017. logits: (N, C). T>0 scales sharpness."""
logits = logits.double()
T = torch.ones(1, requires_grad=True, dtype=torch.float64)
opt = torch.optim.LBFGS([T], lr=0.1, max_iter=max_iter)
def closure():
opt.zero_grad()
loss = F.cross_entropy(logits / T.clamp_min(1e-3), targets)
loss.backward(); return loss
opt.step(closure)
return float(T.item())
def optimize_weights_topk(per_model_probs, targets, k=1, n_iter=80):
"""Nelder-Mead-esque coordinate descent on simplex to maximize top-k accuracy.
per_model_probs: list of [N, C] tensors (softmax). Returns optimized w."""
import itertools
M = len(per_model_probs)
stacked = torch.stack(per_model_probs).double() # (M, N, C)
def topk_acc(w):
w = torch.tensor(w, dtype=stacked.dtype).view(-1, 1, 1)
p = (stacked * w).sum(0)
if k == 1: return (p.argmax(-1) == targets).float().mean().item()
_, tk = p.topk(k, dim=-1)
return sum(targets[i].item() in tk[i].tolist() for i in range(len(targets))) / len(targets)
# Seed: uniform + heuristic (proportional to own top-1)
ownacc = np.array([(per_model_probs[i].argmax(-1) == targets).float().mean().item()
for i in range(M)])
seeds = [np.ones(M)/M, ownacc/ownacc.sum()]
best_w, best_s = seeds[0], topk_acc(seeds[0])
for s in seeds:
a = topk_acc(s)
if a > best_s: best_w, best_s = s.copy(), a
# Coordinate descent over (i, j) pairs
step = 0.1
for it in range(n_iter):
improved = False
for i, j in itertools.combinations(range(M), 2):
for d in (step, -step):
w = best_w.copy()
w[i] += d; w[j] -= d
if w.min() < 0: continue
w = w / w.sum()
a = topk_acc(w)
if a > best_s + 1e-6:
best_w, best_s = w, a; improved = True
if not improved: step *= 0.5
if step < 1e-4: break
return best_w, best_s
def apply_tablet_gnn(probs, tablet_ids, feat_dim_logit=None, device='cuda'):
"""Refine logits with tablet-level attention. probs (N, C) log-space."""
try:
from enhancements.tablet_gnn import TabletContextGAT
except Exception as e:
log(f" tablet_gnn unavailable: {e}"); return probs
N, C = probs.shape
# Map string tablet_ids → int
tid_to_int = {}
tid_int = torch.zeros(N, dtype=torch.long)
for i, t in enumerate(tablet_ids):
if t not in tid_to_int: tid_to_int[t] = len(tid_to_int)
tid_int[i] = tid_to_int[t]
# Feed log-probs as "features" (C-dim); randomly init GNN (no pretrained checkpoint).
# Without trained weights, GNN applies untrained transform — not beneficial.
# Gate: only apply if a checkpoint exists.
ckpt = ROOT / 'hitit_ocr/runs/h100/tablet_gnn/ckpt.pt'
if not ckpt.exists():
log(f" tablet_gnn checkpoint absent; skipping (train via enhancements/tablet_gnn.py first)")
return probs
gnn = TabletContextGAT(feat_dim=C).to(device)
gnn.load_state_dict(torch.load(ckpt, map_location=device))
gnn.eval()
with torch.no_grad():
logits_in = torch.log(probs.clamp_min(1e-9)).to(device)
refined = gnn(logits_in, tid_int.to(device))
refined_p = F.softmax(refined.float(), dim=-1).cpu()
return refined_p
def tier_metrics(probs, targets, class_counts):
"""Report per-tier top-1: head (>100), mid (20-100), tail (<20).
class_counts: dict label_idx → train count.
"""
pred = probs.argmax(-1)
tiers = {'head': [], 'mid': [], 'tail': []}
for i in range(len(targets)):
c = int(targets[i]); n = class_counts.get(c, 0)
tier = 'head' if n > 100 else 'mid' if n >= 20 else 'tail'
tiers[tier].append((int(pred[i]) == c, n))
out = {}
for t, rows in tiers.items():
if not rows: out[t] = {'top1': None, 'n': 0}; continue
acc = sum(r[0] for r in rows) / len(rows)
out[t] = {'top1': acc, 'n': len(rows),
'median_train_count': sorted([r[1] for r in rows])[len(rows)//2]}
return out
def multi_view_aggregate(probs, tablet_ids, bbox_hashes=None, mode='mean'):
"""Group probs by (tablet_id, bbox_hash); within each group, average probs."""
from collections import defaultdict
groups = defaultdict(list)
for i, tid in enumerate(tablet_ids):
key = (tid, bbox_hashes[i] if bbox_hashes else i)
groups[key].append(i)
refined = probs.clone()
for key, idxs in groups.items():
if len(idxs) <= 1: continue
if mode == 'mean':
avg = probs[idxs].mean(0)
elif mode == 'max':
avg = probs[idxs].max(0).values
for i in idxs: refined[i] = avg
return refined
def selective_metrics(probs, targets, thresholds=(0.5, 0.6, 0.7, 0.8, 0.9)):
"""Return dict of threshold→(selective_acc, coverage)."""
pred = probs.argmax(-1)
max_p = probs.max(-1).values
out = {}
for t in thresholds:
keep = max_p >= t
cov = keep.float().mean().item()
if keep.sum() == 0:
out[t] = {'selective_acc': 0.0, 'coverage': 0.0}
continue
acc = (pred[keep] == targets[keep]).float().mean().item()
out[t] = {'selective_acc': acc, 'coverage': cov}
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--ckpts', nargs='+', required=True,
help='arch:path, e.g. dinov3_vitb14:runs/h100/hitit_dinov3_cls_v2/best_ema.pt')
ap.add_argument('--weights', nargs='+', type=float, default=None,
help='Per-ckpt weight; if absent, uses DEFAULT_WEIGHTS by arch')
ap.add_argument('--manifest', default=str(ROOT / 'datasets/sources/hitit_local/manifest_classification.jsonl'))
ap.add_argument('--val-fold', type=int, default=0)
ap.add_argument('--batch-size', type=int, default=128)
ap.add_argument('--scales', nargs='+', type=int, default=[224, 320, 384])
ap.add_argument('--rejection-threshold', type=float, default=0.6)
ap.add_argument('--use-tablet-gnn', action='store_true')
ap.add_argument('--optimize-weights', action='store_true',
help='Grid/Nelder-Mead weight search on val to max top1')
ap.add_argument('--temperature-scale', action='store_true',
help='Fit per-model temperature on val before ensemble')
ap.add_argument('--patch-mil', action='store_true',
help='Add 2x2 patch-MIL alongside scale TTA')
ap.add_argument('--multi-view', action='store_true',
help='Aggregate probs within same tablet_id group')
ap.add_argument('--test-fold', type=int, default=None,
help='If set, also eval on this held-out fold')
ap.add_argument('--train-manifest', default=None,
help='Train manifest to compute class_counts for tier metrics')
ap.add_argument('--ensemble-space', choices=['softmax', 'logit', 'logsoftmax'],
default='logit',
help='Where to average: softmax (old, biased by sharpness), logit (scale-aware)')
ap.add_argument('--output', required=True)
ap.add_argument('--dump-probs', default=None,
help='If set, save probs/targets/label_to_idx .pt for downstream rescoring')
args = ap.parse_args()
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
dtype = torch.bfloat16 if torch.cuda.is_available() and torch.cuda.is_bf16_supported() else torch.float32
# Weights
archs = [s.split(':', 1)[0] for s in args.ckpts]
if args.weights:
assert len(args.weights) == len(args.ckpts), "--weights must match --ckpts count"
w = np.array(args.weights, dtype=np.float64)
else:
w = np.array([DEFAULT_WEIGHTS.get(a, 1.0) for a in archs], dtype=np.float64)
w = w / w.sum()
log(f"Ensemble weights: {dict(zip(archs, w.tolist()))}")
first = args.ckpts[0].split(':', 1)[1]
ck0 = torch.load(first, map_location='cpu', weights_only=False)
label_to_idx = ck0['label_to_idx']
n_cls = len(label_to_idx)
log(f"Classes: {n_cls}")
per_model_probs = []
targets = None
tablet_ids_global = None
for spec in args.ckpts:
arch, path = spec.split(':', 1)
log(f"Loading {arch} from {path}")
ck = torch.load(path, map_location='cpu', weights_only=False)
model = build_backbone(arch, n_classes=n_cls).to(device)
sd = ck['model']
sd = {k.replace('module.', '', 1) if k.startswith('module.') else k: v for k, v in sd.items()}
sd = {k.replace('_orig_mod.', '', 1) if k.startswith('_orig_mod.') else k: v for k, v in sd.items()}
sd = {k: v for k, v in sd.items() if k != 'n_averaged'}
miss, unexp = model.load_state_dict(sd, strict=False)
log(f" missing={len(miss)}, unexpected={len(unexp)}")
native = get_arch_img_size(arch)
tta_tfs = get_tta_transforms(scales=tuple(args.scales), native=native)
if args.patch_mil:
tta_tfs = tta_tfs + get_patch_mil_transforms(native=native)
log(f" native={native}, TTA variants={len(tta_tfs)}")
tta_probs = []
for ti, (name, tf) in enumerate(tta_tfs):
ds = ValDataset(args.manifest, label_to_idx, tf, val_fold=args.val_fold)
dl = DataLoader(ds, batch_size=args.batch_size, shuffle=False,
num_workers=4, pin_memory=True, collate_fn=collate)
p, y, tids = infer_softmax(model, dl, device, dtype)
log(f" TTA {name}: n={len(y)}, top1={(p.argmax(-1)==y).float().mean().item():.4f}")
tta_probs.append(p)
if targets is None: targets = y
if tablet_ids_global is None: tablet_ids_global = tids
model_avg = torch.stack(tta_probs).mean(0)
mt1 = (model_avg.argmax(-1) == targets).float().mean().item()
log(f"{arch} TTA-ensemble top1: {mt1:.4f}")
per_model_probs.append(model_avg)
del model; torch.cuda.empty_cache()
# Weighted ensemble — space matters
stacked_prob = torch.stack(per_model_probs) # (M, N, C)
fitted_T = [1.0] * len(per_model_probs)
if args.temperature_scale:
for i, p in enumerate(per_model_probs):
lg = torch.log(p.clamp_min(1e-12))
fitted_T[i] = fit_temperature(lg, targets)
log(f"T[{archs[i]}] = {fitted_T[i]:.3f}")
# Apply temperature
per_model_probs = [F.softmax(torch.log(p.clamp_min(1e-12)) / t, dim=-1)
for p, t in zip(per_model_probs, fitted_T)]
stacked_prob = torch.stack(per_model_probs)
if args.ensemble_space == 'logit':
# Re-cast softmax→logit via log then re-softmax after weighted avg
stacked = torch.log(stacked_prob.clamp_min(1e-12))
elif args.ensemble_space == 'logsoftmax':
stacked = torch.log(stacked_prob.clamp_min(1e-12)) - \
torch.log(stacked_prob.clamp_min(1e-12)).mean(-1, keepdim=True)
else:
stacked = stacked_prob
# Optional weight optimization on val (risk: val-overfit; report both)
opt_w = None; opt_top1 = None
if args.optimize_weights:
opt_w, opt_top1 = optimize_weights_topk(per_model_probs, targets, k=1)
log(f"Optimized weights: {dict(zip(archs, opt_w.tolist()))} → top1={opt_top1:.4f}")
w = opt_w # override for final report
w_t = torch.tensor(w, dtype=stacked.dtype).view(-1, 1, 1)
avg = (stacked * w_t).sum(0)
if args.ensemble_space == 'logit':
global_probs = F.softmax(avg, dim=-1)
else:
global_probs = avg
top1 = (global_probs.argmax(-1) == targets).float().mean().item()
_, top5 = global_probs.topk(5, dim=-1)
top5_acc = sum(targets[i].item() in top5[i].tolist() for i in range(len(targets))) / len(targets)
log(f"=== WEIGHTED ENSEMBLE ===")
log(f"Top-1: {top1:.4f}")
log(f"Top-5: {top5_acc:.4f}")
# Optional tablet-GNN refinement
gnn_top1 = None
if args.use_tablet_gnn and tablet_ids_global is not None:
log("Applying tablet-context GNN...")
refined = apply_tablet_gnn(global_probs, tablet_ids_global, device=device)
gnn_top1 = (refined.argmax(-1) == targets).float().mean().item()
log(f"After GNN Top-1: {gnn_top1:.4f}")
global_probs = refined
# Multi-view aggregation (optional)
if args.multi_view and tablet_ids_global is not None:
before = (global_probs.argmax(-1) == targets).float().mean().item()
global_probs = multi_view_aggregate(global_probs, tablet_ids_global, mode='mean')
after = (global_probs.argmax(-1) == targets).float().mean().item()
log(f"Multi-view agg: {before:.4f}{after:.4f}")
# Tier metrics (head/mid/tail)
tier_out = None
train_manifest = args.train_manifest or args.manifest
try:
cls_counts = Counter()
for line in open(train_manifest):
r = json.loads(line)
if r.get('task') != 'classification': continue
if not r.get('unified_label') or r['unified_label'] not in label_to_idx: continue
if r.get('tablet_view_fold', 0) == args.val_fold: continue # train only
cls_counts[label_to_idx[r['unified_label']]] += 1
tier_out = tier_metrics(global_probs, targets, cls_counts)
log("Tier metrics:")
for t, m in tier_out.items():
if m['top1'] is not None:
log(f" {t}: top1={m['top1']:.4f} n={m['n']}")
except Exception as e:
log(f"tier_metrics failed: {e}")
# Selective classification
sel = selective_metrics(global_probs, targets)
log("Selective metrics:")
for t, m in sel.items():
log(f" τ={t}: sel_acc={m['selective_acc']:.4f}, coverage={m['coverage']:.4f}")
# Per-model breakdown
per_model = {}
for i, spec in enumerate(args.ckpts):
arch = spec.split(':', 1)[0]
t1 = (per_model_probs[i].argmax(-1) == targets).float().mean().item()
per_model[arch] = t1
# Pairwise agreement (diversity)
agree = {}
for i in range(len(per_model_probs)):
for j in range(i+1, len(per_model_probs)):
ai = per_model_probs[i].argmax(-1)
aj = per_model_probs[j].argmax(-1)
key = f"{archs[i]}_vs_{archs[j]}"
agree[key] = (ai == aj).float().mean().item()
out = {
'ensemble_top1': top1, 'ensemble_top5': top5_acc,
'ensemble_space': args.ensemble_space,
'fitted_temperatures': dict(zip(archs, fitted_T)) if args.temperature_scale else None,
'ensemble_weights': dict(zip(archs, (w.tolist() if isinstance(w, np.ndarray) else list(w)))),
'optimized_top1': opt_top1,
'optimized_weights': dict(zip(archs, opt_w.tolist())) if opt_w is not None else None,
'per_model_tta_top1': per_model,
'pairwise_agreement': agree,
'selective_metrics': {str(t): m for t, m in sel.items()},
'rejection_threshold_default': args.rejection_threshold,
'gnn_top1': gnn_top1,
'tier_metrics': tier_out,
'multi_view': args.multi_view,
'scales': args.scales,
'n_val': len(targets), 'n_classes': n_cls,
'ckpts': args.ckpts,
}
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 args.dump_probs:
Path(args.dump_probs).parent.mkdir(parents=True, exist_ok=True)
dump = {
'probs': global_probs.cpu(),
'targets': targets.cpu(),
'label_to_idx': label_to_idx,
'tablet_ids': tablet_ids_global,
'per_model_probs': [p.cpu() for p in per_model_probs],
'archs': archs,
}
torch.save(dump, args.dump_probs)
log(f"Dumped probs: {args.dump_probs}")
if __name__ == '__main__':
main()