| |
| """Knowledge distillation: ensemble teacher → single DINOv3-L student. |
| |
| Loss: DIST (Huang et al., NeurIPS 2022) — Pearson correlation across class dim |
| + classical KD (KL soft + CE hard). |
| |
| Usage: |
| python distillation.py \ |
| --teachers dinov3_vitb14:...ema.pt convnextv2_large:... siglip2_so400m:... \ |
| --teacher-weights 0.45 0.30 0.25 \ |
| --student-arch dinov3_vitb14 \ |
| --manifest ... --output runs/h100/distilled_student/ |
| """ |
| import os, sys, json, argparse, time |
| from pathlib import Path |
|
|
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
| from torch.utils.data import DataLoader, Dataset |
| from torch.optim.swa_utils import AveragedModel |
| 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, HititClsDataset |
|
|
| def log(m): print(f"[{time.strftime('%H:%M:%S')}] {m}", flush=True) |
|
|
| def dist_loss(student_logits, teacher_probs, T=4.0): |
| """DIST = KL(teacher || student) + Pearson correlation.""" |
| |
| s_logp = F.log_softmax(student_logits / T, dim=-1) |
| t_p = teacher_probs |
| t_p_T = F.softmax(torch.log(t_p.clamp_min(1e-12)) / T, dim=-1) |
| kd = F.kl_div(s_logp, t_p_T, reduction='batchmean') * (T * T) |
|
|
| |
| def _zscore(x): |
| return (x - x.mean(dim=-1, keepdim=True)) / (x.std(dim=-1, keepdim=True) + 1e-6) |
| s_z = _zscore(F.softmax(student_logits, dim=-1)) |
| t_z = _zscore(t_p) |
| inter = 1.0 - (s_z * t_z).mean() |
|
|
| |
| s_z2 = _zscore(F.softmax(student_logits, dim=-1).T) |
| t_z2 = _zscore(t_p.T) |
| intra = 1.0 - (s_z2 * t_z2).mean() |
|
|
| return kd + inter + intra |
|
|
| @torch.no_grad() |
| def ensemble_teacher_probs(teachers, teacher_ws, x, dtype): |
| probs = [] |
| for m in teachers: |
| with torch.amp.autocast('cuda', dtype=dtype, enabled=True): |
| logits = m(x) |
| probs.append(F.softmax(logits.float(), dim=-1)) |
| stacked = torch.stack(probs) |
| w = torch.tensor(teacher_ws, dtype=stacked.dtype, device=stacked.device).view(-1, 1, 1) |
| return (stacked * w).sum(0) |
|
|
| def load_model(arch, ckpt_path, n_cls, device): |
| model = build_backbone(arch, n_classes=n_cls).to(device) |
| ck = torch.load(ckpt_path, map_location='cpu', weights_only=False) |
| sd = ck['model'] |
| sd = {k.replace('module.', '', 1): v for k, v in sd.items()} |
| sd = {k.replace('_orig_mod.', '', 1): v for k, v in sd.items()} |
| sd = {k: v for k, v in sd.items() if k != 'n_averaged'} |
| model.load_state_dict(sd, strict=False) |
| return model, ck |
|
|
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument('--teachers', nargs='+', required=True, help='arch:path ...') |
| ap.add_argument('--teacher-weights', nargs='+', type=float, default=None) |
| ap.add_argument('--student-arch', default='dinov3_vitb14') |
| ap.add_argument('--manifest', default=str(ROOT / 'datasets/sources/hitit_local/manifest_classification.jsonl')) |
| ap.add_argument('--output', required=True) |
| ap.add_argument('--epochs', type=int, default=30) |
| ap.add_argument('--batch-size', type=int, default=64) |
| ap.add_argument('--lr', type=float, default=1e-4) |
| ap.add_argument('--T', type=float, default=4.0) |
| ap.add_argument('--hard-weight', type=float, default=0.3) |
| ap.add_argument('--val-fold', type=int, default=0) |
| 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 |
|
|
| first_arch, first_path = args.teachers[0].split(':', 1) |
| ck0 = torch.load(first_path, map_location='cpu', weights_only=False) |
| label_to_idx = ck0['label_to_idx'] |
| n_cls = len(label_to_idx) |
|
|
| |
| teachers = [] |
| for t in args.teachers: |
| a, p = t.split(':', 1) |
| m, _ = load_model(a, p, n_cls, device) |
| for param in m.parameters(): param.requires_grad = False |
| m.eval() |
| teachers.append(m) |
| tw = args.teacher_weights or [1.0/len(teachers)]*len(teachers) |
| s = sum(tw); tw = [x/s for x in tw] |
| log(f"Teachers: {[t.split(':', 1)[0] for t in args.teachers]} weights={tw}") |
|
|
| |
| student, _ = None, None |
| for t in args.teachers: |
| a, p = t.split(':', 1) |
| if a == args.student_arch: |
| student, _ = load_model(a, p, n_cls, device) |
| log(f"Student init from teacher {a}:{p}"); break |
| if student is None: |
| student = build_backbone(args.student_arch, n_classes=n_cls).to(device) |
| log(f"Student fresh: {args.student_arch}") |
|
|
| |
| import yaml |
| cfg = {'training': {'batch_size': args.batch_size}} |
| train_ds = HititClsDataset(args.manifest, cfg, is_train=True, |
| val_fold=args.val_fold, label_to_idx=label_to_idx) |
| val_ds = HititClsDataset(args.manifest, cfg, is_train=False, |
| val_fold=args.val_fold, label_to_idx=label_to_idx) |
| tr_dl = DataLoader(train_ds, batch_size=args.batch_size, shuffle=True, |
| num_workers=6, pin_memory=True, drop_last=True) |
| va_dl = DataLoader(val_ds, batch_size=args.batch_size, shuffle=False, |
| num_workers=4, pin_memory=True) |
|
|
| opt = torch.optim.AdamW(student.parameters(), lr=args.lr, weight_decay=0.05) |
| sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=args.epochs) |
|
|
| ema = AveragedModel(student, avg_fn=lambda a, n, _: 0.9995*a + 0.0005*n) |
|
|
| out_dir = Path(args.output); out_dir.mkdir(parents=True, exist_ok=True) |
| best_acc = 0.0 |
| for ep in range(args.epochs): |
| student.train() |
| tloss, nb = 0.0, 0 |
| for x, y in tr_dl: |
| x = x.to(device, non_blocking=True); y = y.to(device, non_blocking=True) |
| t_probs = ensemble_teacher_probs(teachers, tw, x, dtype) |
| with torch.amp.autocast('cuda', dtype=dtype, enabled=True): |
| s_logits = student(x) |
| loss_kd = dist_loss(s_logits, t_probs, T=args.T) |
| loss_ce = F.cross_entropy(s_logits, y, label_smoothing=0.1) |
| loss = (1 - args.hard_weight) * loss_kd + args.hard_weight * loss_ce |
| opt.zero_grad(set_to_none=True); loss.backward() |
| torch.nn.utils.clip_grad_norm_(student.parameters(), 1.0) |
| opt.step() |
| ema.update_parameters(student) |
| tloss += loss.item(); nb += 1 |
| sched.step() |
|
|
| |
| ema.eval() |
| correct, total = 0, 0 |
| with torch.no_grad(): |
| for x, y in va_dl: |
| x = x.to(device, non_blocking=True); y = y.to(device, non_blocking=True) |
| with torch.amp.autocast('cuda', dtype=dtype, enabled=True): |
| logits = ema(x) |
| correct += (logits.argmax(-1) == y).sum().item(); total += y.size(0) |
| acc = correct / max(1, total) |
| log(f"ep {ep+1}/{args.epochs}: loss={tloss/max(1,nb):.4f} val_acc={acc:.4f}") |
| if acc > best_acc: |
| best_acc = acc |
| torch.save({'model': ema.state_dict(), 'label_to_idx': label_to_idx, |
| 'arch': args.student_arch, 'acc': acc}, |
| out_dir / 'best_ema.pt') |
| log(f"BEST student acc: {best_acc:.4f} → {out_dir / 'best_ema.pt'}") |
|
|
| if __name__ == '__main__': |
| main() |
|
|