| |
| """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) |
|
|
| |
| 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() |
| 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) |
| |
| 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 |
| |
| 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 |
| |
| 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] |
| |
| |
| |
| 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 |
|
|
| |
| 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() |
|
|
| |
| stacked_prob = torch.stack(per_model_probs) |
| 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}") |
| |
| 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': |
| |
| 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 |
|
|
| |
| 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 |
|
|
| 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}") |
|
|
| |
| 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 |
|
|
| |
| 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_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 |
| 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}") |
|
|
| |
| 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 = {} |
| 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 |
|
|
| |
| 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() |
|
|