| |
| """Image → Transliteration end-to-end seq2seq fine-tune. |
| |
| ViT encoder + ByT5 decoder (cross-attention). |
| Data: 83 Hitit tablet with full transliteration sequences. |
| """ |
| import os, sys, json, argparse, time |
| from pathlib import Path |
| import yaml |
|
|
| ROOT = Path("/arf/scratch/stakan/hitit-proje") |
|
|
| def log(msg): print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True) |
|
|
| class ImageTransliterationDataset: |
| def __init__(self, jsonl_path, processor, tokenizer, max_tgt=512): |
| import torch |
| self.records = [] |
| with open(jsonl_path) as f: |
| for line in f: |
| r = json.loads(line) |
| if r.get('image_path') and Path(r['image_path']).exists(): |
| self.records.append(r) |
| self.processor = processor |
| self.tokenizer = tokenizer |
| self.max_tgt = max_tgt |
|
|
| def __len__(self): return len(self.records) |
|
|
| def __getitem__(self, idx): |
| from PIL import Image |
| r = self.records[idx] |
| img = Image.open(r['image_path']).convert('RGB').resize((384, 384)) |
| pixel_values = self.processor(img, return_tensors='pt')['pixel_values'][0] |
| target = r.get('sequence_plain', '')[:1000] |
| labels = self.tokenizer(target, max_length=self.max_tgt, |
| truncation=True, return_tensors='pt')['input_ids'][0] |
| return {'pixel_values': pixel_values, 'labels': labels} |
|
|
| def _remap_timm_to_hf_vit(state): |
| """Map timm/DINO ViT state_dict keys to HuggingFace ViTModel keys.""" |
| import torch, re |
| out = {} |
| for k, v in state.items(): |
| if k == 'cls_token': |
| out['embeddings.cls_token'] = v |
| elif k == 'pos_embed': |
| out['embeddings.position_embeddings'] = v |
| elif k == 'patch_embed.proj.weight': |
| out['embeddings.patch_embeddings.projection.weight'] = v |
| elif k == 'patch_embed.proj.bias': |
| out['embeddings.patch_embeddings.projection.bias'] = v |
| elif k == 'norm.weight': |
| out['layernorm.weight'] = v |
| elif k == 'norm.bias': |
| out['layernorm.bias'] = v |
| else: |
| m = re.match(r'blocks\.(\d+)\.(.+)', k) |
| if not m: |
| continue |
| i, sub = m.group(1), m.group(2) |
| base = f'encoder.layer.{i}' |
| if sub == 'norm1.weight': out[f'{base}.layernorm_before.weight'] = v |
| elif sub == 'norm1.bias': out[f'{base}.layernorm_before.bias'] = v |
| elif sub == 'norm2.weight': out[f'{base}.layernorm_after.weight'] = v |
| elif sub == 'norm2.bias': out[f'{base}.layernorm_after.bias'] = v |
| elif sub == 'attn.qkv.weight': |
| d = v.size(0) // 3 |
| out[f'{base}.attention.attention.query.weight'] = v[:d] |
| out[f'{base}.attention.attention.key.weight'] = v[d:2*d] |
| out[f'{base}.attention.attention.value.weight'] = v[2*d:] |
| elif sub == 'attn.qkv.bias': |
| d = v.size(0) // 3 |
| out[f'{base}.attention.attention.query.bias'] = v[:d] |
| out[f'{base}.attention.attention.key.bias'] = v[d:2*d] |
| out[f'{base}.attention.attention.value.bias'] = v[2*d:] |
| elif sub == 'attn.proj.weight': out[f'{base}.attention.output.dense.weight'] = v |
| elif sub == 'attn.proj.bias': out[f'{base}.attention.output.dense.bias'] = v |
| elif sub == 'mlp.fc1.weight': out[f'{base}.intermediate.dense.weight'] = v |
| elif sub == 'mlp.fc1.bias': out[f'{base}.intermediate.dense.bias'] = v |
| elif sub == 'mlp.fc2.weight': out[f'{base}.output.dense.weight'] = v |
| elif sub == 'mlp.fc2.bias': out[f'{base}.output.dense.bias'] = v |
| return out |
|
|
| def collate_seq2seq(batch, tokenizer): |
| import torch |
| pixel_values = torch.stack([b['pixel_values'] for b in batch]) |
| labels = [b['labels'] for b in batch] |
| max_len = max(l.size(0) for l in labels) |
| padded = torch.full((len(labels), max_len), -100, dtype=torch.long) |
| for i, l in enumerate(labels): |
| padded[i, :l.size(0)] = l |
| return {'pixel_values': pixel_values, 'labels': padded} |
|
|
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument('--config', default=str(ROOT / 'hitit_ocr/configs/seq2seq_hitit.yaml')) |
| ap.add_argument('--phase', default='finetune') |
| ap.add_argument('--encoder', help='SSL DINOv3 checkpoint') |
| ap.add_argument('--decoder', help='ByT5 pretrained checkpoint') |
| ap.add_argument('--data', default=str(ROOT / 'datasets/processed/seq2seq_pairs.jsonl')) |
| ap.add_argument('--output', default=str(ROOT / 'hitit_ocr/runs/seq2seq_image_ft/')) |
| args = ap.parse_args() |
|
|
| cfg = yaml.safe_load(open(args.config)) |
| output = Path(args.output) |
| output.mkdir(parents=True, exist_ok=True) |
|
|
| try: |
| import torch |
| import torch.nn as nn |
| from transformers import (AutoTokenizer, |
| ViTImageProcessor, ViTModel, |
| T5ForConditionalGeneration, |
| Seq2SeqTrainer, |
| Seq2SeqTrainingArguments) |
| from transformers.modeling_outputs import BaseModelOutput |
| except ImportError as e: |
| log(f"Missing: {e}"); sys.exit(1) |
|
|
| |
| enc_name = 'google/vit-base-patch16-384' |
| dec_name = args.decoder if args.decoder and Path(args.decoder).exists() else 'google/byt5-small' |
|
|
| log(f"Encoder: {enc_name}, Decoder: {dec_name}") |
|
|
| encoder = ViTModel.from_pretrained(enc_name, add_pooling_layer=False) |
| t5 = T5ForConditionalGeneration.from_pretrained(dec_name) |
| image_processor = ViTImageProcessor.from_pretrained(enc_name) |
| tokenizer = AutoTokenizer.from_pretrained(dec_name) |
|
|
| |
| enc_dim = encoder.config.hidden_size |
| dec_dim = t5.config.d_model |
| proj = nn.Linear(enc_dim, dec_dim) if enc_dim != dec_dim else nn.Identity() |
|
|
| class ViTT5Seq2Seq(nn.Module): |
| """ViT encoder + T5 decoder; T5'in kendi encoder'ı atlanır, encoder_outputs= verilir.""" |
| def __init__(self, encoder, t5, proj, decoder_start_token_id, pad_token_id): |
| super().__init__() |
| self.encoder = encoder |
| self.t5 = t5 |
| self.proj = proj |
| self.config = t5.config |
| self.config.decoder_start_token_id = decoder_start_token_id |
| self.config.pad_token_id = pad_token_id |
|
|
| def _shift_right(self, input_ids): |
| shifted = input_ids.new_full(input_ids.shape, self.config.pad_token_id) |
| shifted[:, 1:] = input_ids[:, :-1].clone() |
| shifted[:, 0] = self.config.decoder_start_token_id |
| shifted.masked_fill_(shifted == -100, self.config.pad_token_id) |
| return shifted |
|
|
| def forward(self, pixel_values=None, labels=None, **kw): |
| enc_out = self.encoder(pixel_values=pixel_values).last_hidden_state |
| enc_out = self.proj(enc_out) |
| encoder_outputs = BaseModelOutput(last_hidden_state=enc_out) |
| decoder_input_ids = self._shift_right(labels) if labels is not None else None |
| out = self.t5(encoder_outputs=encoder_outputs, |
| decoder_input_ids=decoder_input_ids, |
| labels=labels, return_dict=True) |
| return out |
|
|
| def gradient_checkpointing_enable(self, **kw): |
| self.encoder.gradient_checkpointing_enable(**kw) |
| self.t5.gradient_checkpointing_enable(**kw) |
|
|
| model = ViTT5Seq2Seq(encoder, t5, proj, |
| decoder_start_token_id=tokenizer.pad_token_id or 0, |
| pad_token_id=tokenizer.pad_token_id or 0) |
|
|
| |
| if args.encoder and Path(args.encoder).exists(): |
| log(f"Loading SSL encoder from {args.encoder}") |
| ck = torch.load(args.encoder, map_location='cpu') |
| bb_state = ck.get('backbone', ck) |
| remapped = _remap_timm_to_hf_vit(bb_state) |
| own = model.encoder.state_dict() |
| |
| pe_key = 'embeddings.position_embeddings' |
| if pe_key in remapped and pe_key in own and remapped[pe_key].shape != own[pe_key].shape: |
| src, tgt = remapped[pe_key], own[pe_key] |
| cls_pe, grid_pe = src[:, :1], src[:, 1:] |
| n_src, n_tgt = grid_pe.size(1), tgt.size(1) - 1 |
| gs_src, gs_tgt = int(n_src ** 0.5), int(n_tgt ** 0.5) |
| grid_pe = grid_pe.reshape(1, gs_src, gs_src, -1).permute(0, 3, 1, 2) |
| grid_pe = torch.nn.functional.interpolate( |
| grid_pe, size=(gs_tgt, gs_tgt), mode='bicubic', align_corners=False) |
| grid_pe = grid_pe.permute(0, 2, 3, 1).reshape(1, n_tgt, -1) |
| remapped[pe_key] = torch.cat([cls_pe, grid_pe], dim=1) |
| log(f" Interpolated pos_embed {gs_src}x{gs_src} -> {gs_tgt}x{gs_tgt}") |
| matched = {k: v for k, v in remapped.items() if k in own and own[k].shape == v.shape} |
| missing = [k for k in own if k not in matched] |
| model.encoder.load_state_dict(matched, strict=False) |
| log(f"Matched {len(matched)}/{len(own)} tensors, missing {len(missing)}") |
| if missing and len(missing) < 10: |
| log(f" Missing: {missing}") |
|
|
| |
| ds = ImageTransliterationDataset(args.data, image_processor, tokenizer) |
| log(f"Data: {len(ds)} tablet") |
| n_val = max(1, len(ds) // 10) |
| train_ds = [ds[i] for i in range(n_val, len(ds))] |
| val_ds = [ds[i] for i in range(n_val)] |
|
|
| def collate(batch): |
| return collate_seq2seq(batch, tokenizer) |
|
|
| training = cfg.get('training', {}) |
| targs = Seq2SeqTrainingArguments( |
| output_dir=str(output), |
| num_train_epochs=training.get('epochs', 60), |
| per_device_train_batch_size=training.get('batch_size', 8), |
| per_device_eval_batch_size=4, |
| learning_rate=training.get('lr_decoder', 1e-4), |
| weight_decay=0.01, |
| warmup_steps=training.get('warmup_steps', 200), |
| bf16=True, |
| fp16=False, |
| gradient_accumulation_steps=training.get('gradient_accumulation', 4), |
| gradient_checkpointing=False, |
| logging_steps=20, |
| eval_strategy='epoch', |
| save_strategy='epoch', |
| save_total_limit=2, |
| load_best_model_at_end=True, |
| metric_for_best_model='eval_loss', |
| greater_is_better=False, |
| dataloader_num_workers=2, |
| predict_with_generate=False, |
| report_to='none', |
| ddp_find_unused_parameters=True, |
| ddp_broadcast_buffers=False, |
| save_safetensors=False, |
| ) |
|
|
| trainer = Seq2SeqTrainer( |
| model=model, args=targs, |
| train_dataset=train_ds, eval_dataset=val_ds, |
| data_collator=collate, |
| ) |
|
|
| log("Training image→transliteration seq2seq...") |
| trainer.train() |
|
|
| best_dir = output / 'best' |
| best_dir.mkdir(parents=True, exist_ok=True) |
| torch.save(model.state_dict(), best_dir / 'pytorch_model.bin') |
| tokenizer.save_pretrained(str(best_dir)) |
| image_processor.save_pretrained(str(best_dir)) |
| |
| torch.save({'model_dir': str(best_dir)}, output / 'best.pt') |
| log(f"DONE: {output}") |
|
|
| if __name__ == '__main__': |
| main() |
|
|