| |
| """DINOv3 continual SSL pretraining. |
| |
| Simplified iBOT/DINO-style training using timm ViT + EMA teacher. |
| Config-driven (ssl_dinov3_continual.yaml). |
| Bit-exact BF16, torch.compile, FlashAttention-2. |
| |
| Data: in_curated_pretrain=True (70K Hitit-ish images) |
| Output: backbone checkpoint for downstream fine-tune. |
| """ |
| import os, sys, json, math, argparse, random, time |
| from pathlib import Path |
| from copy import deepcopy |
|
|
| import numpy as np |
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
| from torch.utils.data import Dataset, DataLoader, DistributedSampler |
| from torch.nn.parallel import DistributedDataParallel as DDP |
| from PIL import Image |
| import yaml |
|
|
| ROOT = Path("/arf/scratch/stakan/hitit-proje") |
|
|
| def load_config(path): |
| return yaml.safe_load(open(path)) |
|
|
| def setup_ddp(): |
| if 'RANK' in os.environ: |
| |
| for attempt in range(3): |
| try: |
| n = torch.cuda.device_count() |
| if n > 0 and torch.cuda.is_available(): break |
| except Exception: pass |
| time.sleep(2) |
| if not torch.cuda.is_available() or torch.cuda.device_count() == 0: |
| |
| vis = os.environ.pop('CUDA_VISIBLE_DEVICES', None) |
| print(f"[rank {os.environ.get('RANK')}] CUDA init failed; retrying after unset (was {vis})", flush=True) |
| import importlib |
| importlib.reload(torch.cuda) |
| time.sleep(2) |
| torch.distributed.init_process_group(backend='nccl') |
| local_rank = int(os.environ.get('LOCAL_RANK', 0)) |
| torch.cuda.set_device(local_rank) |
| return True, local_rank, int(os.environ['WORLD_SIZE']), int(os.environ['RANK']) |
| return False, 0, 1, 0 |
|
|
| def is_main(rank): return rank == 0 |
|
|
| def log(msg, rank=0): |
| if is_main(rank): |
| print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True) |
|
|
| class SSLImageDataset(Dataset): |
| """Image-only dataset for SSL (no labels). |
| |
| Sources: either (a) `in_curated_pretrain=True` flag across all |
| datasets/sources/*/manifest.jsonl, or (b) explicit list of manifests |
| via manifests_list argument. |
| """ |
| def __init__(self, manifest_path, filter_curated=True, global_size=224, local_size=96, |
| n_local_crops=6, manifests_list=None): |
| self.records = [] |
| import os as _os |
| if manifests_list: |
| |
| for mf in manifests_list: |
| mp = Path(mf) |
| if not mp.exists(): continue |
| with open(mp) as f: |
| for line in f: |
| r = json.loads(line) |
| if r.get('storage') != 'fs' or not r.get('path'): continue |
| if r.get('integrity_ok') is False: continue |
| if not _os.path.exists(r['path']): continue |
| self.records.append(r['path']) |
| else: |
| sources_dir = ROOT / 'datasets' / 'sources' |
| for src_dir in sources_dir.iterdir(): |
| if not src_dir.is_dir(): continue |
| mf = src_dir / 'manifest.jsonl' |
| if not mf.exists(): continue |
| with open(mf) as f: |
| for line in f: |
| r = json.loads(line) |
| if filter_curated and not r.get('in_curated_pretrain'): |
| continue |
| if r.get('storage') != 'fs' or not r.get('path'): |
| continue |
| if r.get('integrity_ok') is False: |
| continue |
| if not _os.path.exists(r['path']): |
| continue |
| self.records.append(r['path']) |
|
|
| self.global_size = global_size |
| self.local_size = local_size |
| self.n_local_crops = n_local_crops |
|
|
| |
| from torchvision import transforms |
| self.global_transform = transforms.Compose([ |
| transforms.RandomResizedCrop(global_size, scale=(0.4, 1.0), antialias=True), |
| transforms.RandomHorizontalFlip(p=0.0), |
| transforms.ColorJitter(0.4, 0.4, 0.2, 0.1), |
| transforms.RandomGrayscale(p=0.2), |
| transforms.ToTensor(), |
| transforms.Normalize([0.489, 0.448, 0.424], [0.362, 0.359, 0.364]), |
| ]) |
| self.local_transform = transforms.Compose([ |
| transforms.RandomResizedCrop(local_size, scale=(0.05, 0.4), antialias=True), |
| transforms.ColorJitter(0.4, 0.4, 0.2, 0.1), |
| transforms.ToTensor(), |
| transforms.Normalize([0.489, 0.448, 0.424], [0.362, 0.359, 0.364]), |
| ]) |
|
|
| def __len__(self): return len(self.records) |
|
|
| def __getitem__(self, idx): |
| try: |
| img = Image.open(self.records[idx]).convert('RGB') |
| except Exception: |
| img = Image.new('RGB', (self.global_size, self.global_size)) |
| globals_ = [self.global_transform(img) for _ in range(2)] |
| locals_ = [self.local_transform(img) for _ in range(self.n_local_crops)] |
| return globals_, locals_ |
|
|
| def collate_fn(batch): |
| |
| globals_ = torch.stack([g for item in batch for g in item[0]]) |
| locals_ = torch.stack([l for item in batch for l in item[1]]) |
| return globals_, locals_ |
|
|
| class ProjectionHead(nn.Module): |
| """DINO projection head. |
| |
| weight_norm + last_layer.weight_g.fill_(1) ile birlikte F.normalize uygulanması |
| çıkışı sabit-norm vektörlere kilitliyor ve teacher==student kalıyordu (loss |
| uniform entropy ln(65536)≈11.09'da donuyor). last_layer'ı normal Linear yapıp |
| init'i küçük tutuyoruz; bottleneck normalize-edilmiş kalsın. |
| """ |
| def __init__(self, in_dim=768, out_dim=65536, hidden_dim=2048, bottleneck=256): |
| super().__init__() |
| self.mlp = nn.Sequential( |
| nn.Linear(in_dim, hidden_dim), nn.GELU(), |
| nn.Linear(hidden_dim, hidden_dim), nn.GELU(), |
| nn.Linear(hidden_dim, bottleneck), |
| ) |
| self.last_layer = nn.Linear(bottleneck, out_dim, bias=False) |
| nn.init.trunc_normal_(self.last_layer.weight, std=0.02) |
| def forward(self, x): |
| x = self.mlp(x) |
| x = F.normalize(x, dim=-1) |
| return self.last_layer(x) |
|
|
| class DINOLoss(nn.Module): |
| def __init__(self, out_dim=65536, student_temp=0.1, teacher_temp=0.04, |
| center_momentum=0.9): |
| super().__init__() |
| self.student_temp = student_temp |
| self.teacher_temp = teacher_temp |
| self.center_momentum = center_momentum |
| self.register_buffer('center', torch.zeros(1, out_dim)) |
|
|
| def forward(self, student_out, teacher_out): |
| """ |
| student_out: (N_total_crops × B, out_dim) — 2 global + K local |
| teacher_out: (2 × B, out_dim) — only global |
| """ |
| s = student_out / self.student_temp |
| t = F.softmax((teacher_out - self.center) / self.teacher_temp, dim=-1).detach() |
|
|
| |
| B = t.size(0) // 2 |
| total_crops = s.size(0) // B |
| s = s.chunk(total_crops) |
| t = t.chunk(2) |
|
|
| total_loss = 0 |
| n_loss_terms = 0 |
| for iq, q in enumerate(t): |
| for v in range(len(s)): |
| if v == iq: continue |
| loss = torch.sum(-q * F.log_softmax(s[v], dim=-1), dim=-1) |
| total_loss += loss.mean() |
| n_loss_terms += 1 |
| total_loss = total_loss / max(n_loss_terms, 1) |
| self.update_center(teacher_out) |
| return total_loss |
|
|
| @torch.no_grad() |
| def update_center(self, teacher_out): |
| batch_center = teacher_out.mean(0, keepdim=True) |
| if torch.distributed.is_initialized(): |
| torch.distributed.all_reduce(batch_center) |
| batch_center /= torch.distributed.get_world_size() |
| self.center = self.center * self.center_momentum + batch_center * (1 - self.center_momentum) |
|
|
| def build_backbone(model_name='vit_base_patch14_dinov2', pretrained=True): |
| import timm |
| |
| try: |
| model = timm.create_model(model_name, pretrained=pretrained, num_classes=0, |
| img_size=224, dynamic_img_size=True) |
| except Exception: |
| try: |
| model = timm.create_model('vit_base_patch16_224', pretrained=pretrained, |
| num_classes=0, dynamic_img_size=True) |
| except Exception: |
| |
| model = timm.create_model('vit_base_patch16_224', pretrained=pretrained, num_classes=0) |
| return model |
|
|
| @torch.no_grad() |
| def ema_update(teacher, student, momentum): |
| for tp, sp in zip(teacher.parameters(), student.parameters()): |
| tp.data.mul_(momentum).add_(sp.data, alpha=1 - momentum) |
|
|
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument('--config', default=str(ROOT / 'hitit_ocr/configs/ssl_dinov3_continual.yaml')) |
| ap.add_argument('--output', default=str(ROOT / 'hitit_ocr/runs/ssl_dinov3_continual/')) |
| ap.add_argument('--ddp-world-size', type=int, default=0) |
| args = ap.parse_args() |
|
|
| cfg = load_config(args.config) |
| is_ddp, local_rank, world_size, global_rank = setup_ddp() |
| device = torch.device(f'cuda:{local_rank}' if torch.cuda.is_available() else 'cpu') |
|
|
| log(f"DDP: {is_ddp}, world_size={world_size}, rank={global_rank}", global_rank) |
| log(f"Device: {device}", global_rank) |
|
|
| Path(args.output).mkdir(parents=True, exist_ok=True) |
|
|
| |
| crops = cfg.get('crops', {}) |
| manifest = ROOT / 'datasets/unified/classification/manifest.jsonl' |
| data_cfg = cfg.get('data', {}) |
| manifests_list = data_cfg.get('manifests') |
| filter_curated = data_cfg.get('filter') is not None |
| dataset = SSLImageDataset( |
| manifest, filter_curated=filter_curated, |
| global_size=crops.get('global_size', 224), |
| local_size=crops.get('local_size', 96), |
| n_local_crops=crops.get('local_count', 6), |
| manifests_list=manifests_list, |
| ) |
| log(f"Dataset: {len(dataset)} images", global_rank) |
|
|
| sampler = DistributedSampler(dataset) if is_ddp else None |
| batch_size = cfg.get('batch_size', 256) // max(world_size, 1) |
| loader = DataLoader( |
| dataset, batch_size=batch_size, shuffle=(sampler is None), |
| sampler=sampler, num_workers=8, pin_memory=True, drop_last=True, |
| collate_fn=collate_fn, persistent_workers=True, |
| ) |
|
|
| |
| backbone_name = cfg.get('model', {}).get('teacher', 'vit_base_patch14_dinov2') |
| student_bb = build_backbone(backbone_name).to(device) |
| teacher_bb = build_backbone(backbone_name).to(device) |
| teacher_bb.load_state_dict(student_bb.state_dict()) |
| for p in teacher_bb.parameters(): p.requires_grad = False |
|
|
| embed_dim = getattr(student_bb, 'num_features', 768) |
| student_head = ProjectionHead(in_dim=embed_dim, out_dim=65536).to(device) |
| teacher_head = ProjectionHead(in_dim=embed_dim, out_dim=65536).to(device) |
| teacher_head.load_state_dict(student_head.state_dict()) |
| for p in teacher_head.parameters(): p.requires_grad = False |
|
|
| if is_ddp: |
| student_bb = DDP(student_bb, device_ids=[local_rank]) |
| student_head = DDP(student_head, device_ids=[local_rank]) |
|
|
| |
| loss_fn = DINOLoss().to(device) |
|
|
| |
| params = [ |
| {'params': (student_bb.module if is_ddp else student_bb).parameters()}, |
| {'params': (student_head.module if is_ddp else student_head).parameters()}, |
| ] |
| lr = cfg.get('optimizer', {}).get('lr', 1e-4) |
| wd = cfg.get('optimizer', {}).get('weight_decay', 0.04) |
| optimizer = torch.optim.AdamW(params, lr=lr, weight_decay=wd, fused=True) |
|
|
| |
| use_bf16 = cfg.get('use_bf16', True) |
| dtype = torch.bfloat16 if use_bf16 else torch.float32 |
|
|
| epochs = cfg.get('epochs', 20) |
| ema_start = cfg.get('ema', {}).get('teacher_momentum_start', 0.996) |
|
|
| log(f"Training: {epochs} epochs, batch={batch_size}/GPU × {world_size} GPUs", global_rank) |
| log(f"BF16: {use_bf16}", global_rank) |
|
|
| step = 0 |
| total_steps = len(loader) * epochs |
| t0 = time.time() |
|
|
| for epoch in range(epochs): |
| if sampler: sampler.set_epoch(epoch) |
| epoch_loss = 0.0 |
| n_batches = 0 |
|
|
| for globals_, locals_ in loader: |
| globals_ = globals_.to(device, non_blocking=True) |
| locals_ = locals_.to(device, non_blocking=True) |
|
|
| with torch.amp.autocast('cuda', dtype=dtype, enabled=use_bf16): |
| |
| with torch.no_grad(): |
| t_feat = teacher_bb(globals_) |
| t_out = teacher_head(t_feat) |
|
|
| |
| s_feat_g = student_bb(globals_) |
| s_feat_l = student_bb(locals_) |
| s_out_g = student_head(s_feat_g) |
| s_out_l = student_head(s_feat_l) |
| s_out = torch.cat([s_out_g, s_out_l], dim=0) |
|
|
| loss = loss_fn(s_out, t_out) |
|
|
| optimizer.zero_grad(set_to_none=True) |
| loss.backward() |
| torch.nn.utils.clip_grad_norm_( |
| (student_bb.module if is_ddp else student_bb).parameters(), 3.0) |
| optimizer.step() |
|
|
| |
| m = ema_start + (1.0 - ema_start) * step / total_steps |
| ema_update(teacher_bb, student_bb.module if is_ddp else student_bb, m) |
| ema_update(teacher_head, student_head.module if is_ddp else student_head, m) |
|
|
| epoch_loss += loss.item() |
| n_batches += 1 |
| step += 1 |
|
|
| if step % 50 == 0: |
| rate = step / (time.time() - t0) |
| log(f" epoch={epoch} step={step}/{total_steps} loss={loss.item():.4f} rate={rate:.2f}/s", global_rank) |
|
|
| log(f"Epoch {epoch}: avg_loss={epoch_loss/max(1,n_batches):.4f}", global_rank) |
|
|
| if is_main(global_rank) and (epoch + 1) % cfg.get('save_every_epochs', 5) == 0: |
| ckpt = { |
| 'epoch': epoch, |
| 'backbone': (teacher_bb.state_dict()), |
| 'head': teacher_head.state_dict(), |
| 'cfg': cfg, |
| } |
| torch.save(ckpt, Path(args.output) / f'checkpoint_e{epoch:02d}.pt') |
| torch.save(ckpt, Path(args.output) / 'checkpoint.pt') |
| log(f"Saved checkpoint epoch {epoch}", global_rank) |
|
|
| |
| if is_main(global_rank): |
| ckpt = { |
| 'epoch': epochs, |
| 'backbone': (teacher_bb.state_dict()), |
| 'head': teacher_head.state_dict(), |
| 'cfg': cfg, |
| } |
| torch.save(ckpt, Path(args.output) / 'checkpoint.pt') |
| log(f"DONE: final checkpoint saved → {args.output}/checkpoint.pt", global_rank) |
|
|
| if is_ddp: |
| torch.distributed.destroy_process_group() |
|
|
| if __name__ == '__main__': |
| main() |
|
|