| """
|
| Step 2: Understanding-only finetuning of Mobile-O's VLM half on Kvasir-VQA.
|
|
|
| =============================================================================
|
| WHY THIS SCRIPT LOOKS THE WAY IT DOES (verified against the real repo source)
|
| =============================================================================
|
|
|
| 1. `mobileoFastSFTForCausalLM` (mobileo/model/language_model/mobileo_sft.py)
|
| is a dead end for pure understanding SFT:
|
| - `prepare_inputs_for_sft()` unconditionally calls
|
| `self.get_model().get_sana_vae()` and encodes `gen_images` through it.
|
| - `forward()` unconditionally hits
|
| `assert latents is not None, "Currently we only support image loss
|
| when latents is None"` right after the LLM forward pass.
|
| There is no `gen_images=None` escape hatch inside this class. Confirmed
|
| by reading the class end-to-end — this matches what the project already
|
| suspected.
|
|
|
| 2. `mobileoFastForCausalLM` (mobileo/model/language_model/mobileo.py), the
|
| post-train class, is architecturally what we want to imitate:
|
| (... , inputs_embeds, labels, latents) = self.prepare_inputs_labels_for_multimodal(
|
| input_ids, position_ids, attention_mask, past_key_values, labels,
|
| gen_image, und_image,
|
| )
|
| output = super().forward(inputs_embeds=inputs_embeds, labels=labels, ...)
|
| ce_loss = output.loss
|
| assert latents is not None # <-- diffusion branch, still unconditional
|
| It calls `prepare_inputs_labels_for_multimodal` from `LlavaMetaForCausalLM`
|
| (mobileo/model/llava_arch.py) and gets a clean `ce_loss` out of
|
| `super().forward()` BEFORE the diffusion assert fires. We just need to
|
| stop before that assert.
|
|
|
| 3. `LlavaMetaForCausalLM.prepare_inputs_labels_for_multimodal` (llava_arch.py,
|
| line ~208) is the actual understanding-image path:
|
| if (gen_images is None and und_images is None) or ... :
|
| return input_ids, position_ids, attention_mask, past_key_values, None, labels, None
|
| if gen_images is not None:
|
| ... encode through self.get_model().get_sana_vae() ...
|
| else:
|
| target_image_embeds = None # <-- this is our branch
|
| images = und_images
|
| image_features = self.visual(images) # vision_tower -> mm_projector
|
| ... splice image_features into the token-embedding stream at
|
| IMAGE_TOKEN_INDEX positions, returns new inputs_embeds/labels ...
|
| Critically: when `gen_images=None`, the SANA VAE encode call is SKIPPED
|
| entirely inside this function. The VAE/DiT objects still exist as frozen
|
| modules on the model (built from the checkpoint's `config.json` at load
|
| time -- see note below), but they are never invoked during our forward
|
| pass.
|
|
|
| 4. `mobileoForInferenceLM` (mobileo/model/language_model/mobileo_inference.py)
|
| already does exactly the routing we need, in its `.generate()` method:
|
| (..., inputs_embeds, _, _) = self.prepare_inputs_labels_for_multimodal(
|
| input_ids, position_ids, attention_mask, None, None,
|
| und_images=images,
|
| )
|
| return super().generate(inputs_embeds=inputs_embeds, ...)
|
| Its `forward()` is NEVER overridden anywhere in the MRO
|
| (Qwen2ForCausalLM, LlavaMetaForCausalLM) -- grep confirms the only
|
| `def forward` in llava_arch.py belongs to the unrelated
|
| `DiffusionConnector` module. So `mobileoForInferenceLM.forward()` is
|
| literally stock `Qwen2ForCausalLM.forward()`: plain CE loss over
|
| `labels`, no diffusion assert, no SANA, no VAE call.
|
|
|
| => Rather than writing a brand-new model class from scratch, this script
|
| subclasses `mobileoForInferenceLM` and overrides ONLY `forward()` to:
|
| a) call `prepare_inputs_labels_for_multimodal(..., gen_images=None,
|
| und_images=und_image)` to get `inputs_embeds`/`labels` with
|
| image features spliced in,
|
| b) hand `inputs_embeds` straight to `super().forward()` (stock
|
| Qwen2 CE loss, ignore_index=-100).
|
| This is a 15-line override sitting on top of code the repo authors
|
| already wrote and use elsewhere, instead of a parallel reimplementation
|
| of vision-tower/projector wiring -- much smaller surface area for bugs.
|
|
|
| 5. IMPORTANT CAVEAT we verified and cannot eliminate from inside this script:
|
| `LlavaMetaModel.__init__` (llava_arch.py) builds `self.dit` / `self.vae`
|
| via `build_sana()` / `build_vae()` (mobileo/model/multimodal_decoder/builder.py)
|
| whenever the loaded config has `diffusion_name_or_path` set -- and it WILL
|
| be set, because it was written into config.json by whichever SFT/post-train
|
| run produced your checkpoint. This happens at `from_pretrained()` time,
|
| driven by the saved config, regardless of which Python wrapper class you
|
| load it with (mobileoForInferenceLM included). It costs you:
|
| - one HF Hub fetch of `Efficient-Large-Model/Sana_600M_512px_diffusers`
|
| (transformer + vae subfolders) the first time, then it's cached
|
| - extra VRAM for the frozen DiT + VAE sitting on the model
|
| It does NOT cost you anything at the loss-computation level: those
|
| modules are simply never called in the forward pass below, and we
|
| explicitly zero their `requires_grad` so they receive no gradient and
|
| are not in the optimizer.
|
|
|
| If you want to avoid the network fetch entirely, the only way is to hand
|
| -edit the checkpoint's config.json to remove the `diffusion_name_or_path`
|
| key before loading -- see the printed instructions at the bottom of this
|
| file under "OPTIONAL: skip SANA/VAE network fetch".
|
|
|
| 6. CONFIG FIELDS double-checked against the real builders:
|
| - `multimodal_llava_encoder/mobileclip_encoder.py` line 20:
|
| self.input_image_size = int(vision_tower.split("_")[-1])
|
| so `vision_tower` MUST end in a numeric resolution suffix, e.g. `_1024`.
|
| - `multimodal_llava_encoder/mobileclip/__init__.py` line 19:
|
| model_name = "_".join(model_name.split("_")[0:2])
|
| which strips a 3-part name like "mobileclip_l_1024" down to
|
| "mobileclip_l" to find the config file. The ONLY config file shipped
|
| in the repo is `mobileclip/configs/mobileclip_l.json`. There is no
|
| "mobileclip_1" or "mobileclip" config. So the correct vision tower
|
| string is `"mobileclip_l_1024"`, NOT `"mobileclip_1024"` as in the
|
| original draft -- `"mobileclip_1024".split("_")[0:2]` -> `["mobileclip",
|
| "1024"]` -> joined as `"mobileclip_1024"` -> no matching config file ->
|
| crash. This only matters if you are building vision modules from
|
| scratch; since we load an already-initialized checkpoint, the tower
|
| and its config come from the checkpoint itself and this string is
|
| never actually constructed by us (see point 7).
|
| - `mobileclip/configs/mobileclip_l.json`: `image_cfg.embed_dim = 3072`,
|
| confirming `mm_hidden_size=3072` is correct.
|
| - Qwen2-0.5B `hidden_size=896` is correct (also confirmed by
|
| `mobile_block.py`'s own docstring example: "input_dim=896, # VLM
|
| output dimension").
|
|
|
| 7. We deliberately do NOT call `initialize_vision_modules()` and do NOT
|
| construct a `FakeModelArgs` namespace. That function is for building
|
| vision/diffusion modules from scratch on top of a base LLM checkpoint
|
| that doesn't have them yet (used by `pre_train.sh`'s initial run). Your
|
| checkpoint (`checkpoints/Mobile-O-0.5B-SFT`) already has a fully
|
| initialized vision tower, projector, DiT, and VAE baked into its
|
| `config.json` + `model.safetensors` -- `from_pretrained()` reconstructs
|
| all of it for you. Calling `initialize_vision_modules()` again on top of
|
| an already-loaded checkpoint is redundant at best, and at worst silently
|
| double-initializes / reloads modules (it has internal "already loaded"
|
| guards, but you saw `[INFO] initialize_vision_modules: ...` warnings
|
| firing in the original smoke test for exactly this reason). Deleting that
|
| call removes a class of bugs without losing anything.
|
|
|
| Run (smoke test, 200 samples, 1 epoch):
|
| cd ~/Mobile-O
|
| python step2_finetune_vlm.py --smoke_test
|
|
|
| Run (full):
|
| cd ~/Mobile-O
|
| python step2_finetune_vlm.py --data data/train.jsonl --epochs 3
|
| """
|
|
|
| import sys
|
| import os
|
| import json
|
| import argparse
|
| import random
|
| import time
|
| import itertools
|
| from pathlib import Path
|
| from typing import List, Optional, Tuple, Union
|
|
|
| import torch
|
| import torch.nn as nn
|
| import torch.nn.functional as F
|
| from torch.utils.data import Dataset, DataLoader
|
| from PIL import Image
|
| from tqdm import tqdm
|
|
|
| from transformers.modeling_outputs import CausalLMOutputWithPast
|
|
|
|
|
| MOBILEO_REPO = os.environ.get("MOBILEO_PATH", ".")
|
| if MOBILEO_REPO not in sys.path:
|
| sys.path.insert(0, MOBILEO_REPO)
|
|
|
|
|
|
|
| from mobileo.model import mobileoForInferenceLM
|
| from mobileo.model.language_model.mobileo_inference import mobileoConfig
|
| from mobileo.constants import IGNORE_INDEX, IMAGE_TOKEN_INDEX
|
| from mobileo.mm_utils import tokenizer_image_token
|
| from transformers import AutoProcessor, AutoTokenizer, get_cosine_schedule_with_warmup
|
|
|
|
|
| DEFAULT_MODEL = "checkpoints/Mobile-O-0.5B-SFT"
|
| DEFAULT_DATA = "data/smoke_test.jsonl"
|
| DEFAULT_OUTDIR = "checkpoints/vlm_kvasir"
|
| BATCH_SIZE = 4
|
| GRAD_ACCUM = 4
|
| LR = 2e-5
|
| WARMUP_RATIO = 0.03
|
| MAX_LEN = 512
|
| UND_IMAGE_SIZE = 1024
|
|
|
|
|
|
|
| class mobileoUnderstandingForTraining(mobileoForInferenceLM):
|
| """
|
| Thin training wrapper around `mobileoForInferenceLM`.
|
|
|
| We override ONLY forward(). Everything else (config class, __init__,
|
| get_model, generate, sample_images, etc.) is inherited unchanged from
|
| `mobileoForInferenceLM`, which already builds the vision tower +
|
| mm_projector + (frozen) DiT/VAE from the checkpoint at load time via
|
| `LlavaMetaModel.__init__` / `from_pretrained()`.
|
|
|
| forward() routes und_image through
|
| `LlavaMetaForCausalLM.prepare_inputs_labels_for_multimodal(...,
|
| gen_images=None, und_images=und_image)`, which:
|
| - skips the SANA VAE encode path entirely (gen_images is None)
|
| - runs the image through `self.visual()` ->
|
| `vision_tower(...)` -> `mm_projector(...)`
|
| - splices the resulting image features into the embedding stream at
|
| IMAGE_TOKEN_INDEX positions
|
| - returns ready-to-use `inputs_embeds` / `labels`
|
| `inputs_embeds` then goes straight into `super().forward()`
|
| (= stock `Qwen2ForCausalLM.forward`), which computes standard
|
| shifted cross-entropy loss with `ignore_index=-100` over `labels`.
|
| No diffusion transformer, scheduler, or VAE is touched.
|
| """
|
|
|
| config_class = mobileoConfig
|
|
|
| def forward(
|
| self,
|
| input_ids: torch.LongTensor = None,
|
| attention_mask: Optional[torch.Tensor] = None,
|
| position_ids: Optional[torch.LongTensor] = None,
|
| past_key_values: Optional[List[torch.FloatTensor]] = None,
|
| inputs_embeds: Optional[torch.FloatTensor] = None,
|
| labels: Optional[torch.LongTensor] = None,
|
| use_cache: Optional[bool] = None,
|
| output_attentions: Optional[bool] = None,
|
| output_hidden_states: Optional[bool] = None,
|
| und_image: Optional[torch.FloatTensor] = None,
|
| return_dict: Optional[bool] = None,
|
| cache_position: Optional[torch.LongTensor] = None,
|
| ) -> Union[Tuple, CausalLMOutputWithPast]:
|
|
|
| if inputs_embeds is None:
|
| (
|
| input_ids,
|
| position_ids,
|
| attention_mask,
|
| past_key_values,
|
| inputs_embeds,
|
| labels,
|
| _target_image_embeds,
|
| ) = self.prepare_inputs_labels_for_multimodal(
|
| input_ids,
|
| position_ids,
|
| attention_mask,
|
| past_key_values,
|
| labels,
|
| gen_images=None,
|
| und_images=und_image,
|
| )
|
|
|
|
|
|
|
| return super(mobileoForInferenceLM, self).forward(
|
| input_ids=input_ids,
|
| attention_mask=attention_mask,
|
| position_ids=position_ids,
|
| past_key_values=past_key_values,
|
| inputs_embeds=inputs_embeds,
|
| labels=labels,
|
| use_cache=use_cache,
|
| output_attentions=output_attentions,
|
| output_hidden_states=output_hidden_states,
|
| return_dict=return_dict,
|
| cache_position=cache_position,
|
| )
|
|
|
|
|
|
|
| class KvasirVQADataset(Dataset):
|
| """
|
| Reads JSONL from step1.
|
| Each record: {"image": "/abs/path.jpg", "conversations": [human, gpt]}
|
| Returns: input_ids, labels, und_image tensor
|
| """
|
|
|
| def __init__(self, records, tokenizer, image_processor):
|
| self.records = records
|
| self.tokenizer = tokenizer
|
| self.image_processor = image_processor
|
|
|
| def __len__(self):
|
| return len(self.records)
|
|
|
| def __getitem__(self, idx):
|
| rec = self.records[idx]
|
| convs = rec["conversations"]
|
|
|
| tokens = []
|
| labels = []
|
| im_end_id = self.tokenizer.convert_tokens_to_ids("<|im_end|>")
|
| eol_id = self.tokenizer.encode("\n", add_special_tokens=False)[0]
|
|
|
| for msg in convs:
|
| role = "user" if msg["from"] == "human" else "assistant"
|
| text = f"<|im_start|>{role}\n{msg['value']}"
|
|
|
|
|
| msg_ids = tokenizer_image_token(text, self.tokenizer)
|
| tokens.extend(msg_ids)
|
|
|
| if msg["from"] == "human":
|
| labels.extend([IGNORE_INDEX] * len(msg_ids))
|
| else:
|
| labels.extend(msg_ids)
|
| tokens.append(im_end_id); tokens.append(eol_id)
|
| labels.append(im_end_id); labels.append(eol_id)
|
|
|
| tokens = tokens[:MAX_LEN]
|
| labels = labels[:MAX_LEN]
|
|
|
| input_ids = torch.tensor(tokens, dtype=torch.long)
|
| label_ids = torch.tensor(labels, dtype=torch.long)
|
|
|
| image = Image.open(rec["image"]).convert("RGB")
|
| und_image = self.image_processor.preprocess(
|
| image,
|
| return_tensors="pt",
|
| size={"height": UND_IMAGE_SIZE, "width": UND_IMAGE_SIZE},
|
| )["pixel_values"].squeeze(0)
|
|
|
| return {
|
| "input_ids": input_ids,
|
| "labels": label_ids,
|
| "und_image": und_image,
|
| }
|
|
|
|
|
| def load_records(jsonl_path):
|
| return [json.loads(l) for l in open(jsonl_path)]
|
|
|
|
|
| def split_train_val(records, val_fraction, seed=42):
|
| if val_fraction <= 0:
|
| return records, []
|
| shuffled = records[:]
|
| random.Random(seed).shuffle(shuffled)
|
| n_val = max(1, int(len(shuffled) * val_fraction))
|
| return shuffled[n_val:], shuffled[:n_val]
|
|
|
|
|
| def evaluate(model, loader, device):
|
| model.eval()
|
| total_loss = 0.0
|
| n_batches = 0
|
| with torch.no_grad():
|
| for batch in loader:
|
| output = model(
|
| input_ids=batch["input_ids"].to(device),
|
| attention_mask=batch["attention_mask"].to(device),
|
| labels=batch["labels"].to(device),
|
| und_image=batch["und_image"].to(device, dtype=torch.bfloat16),
|
| )
|
| total_loss += output.loss.item()
|
| n_batches += 1
|
| model.train()
|
| return total_loss / max(1, n_batches)
|
|
|
|
|
| def make_epoch_loader(dataset, epoch, collate_fn):
|
| """
|
| Fresh DataLoader per epoch with a manually-seeded generator (seed =
|
| 1000 + epoch) so shuffle order is exactly reproducible on resume --
|
| `torch.Generator().manual_seed(...)` instead of relying on global RNG
|
| state, which would NOT reproduce the same order after a process restart.
|
| """
|
| g = torch.Generator()
|
| g.manual_seed(1000 + epoch)
|
| return DataLoader(
|
| dataset, batch_size=BATCH_SIZE, shuffle=True,
|
| num_workers=2, collate_fn=collate_fn, pin_memory=True, generator=g,
|
| )
|
|
|
|
|
| def save_resume_checkpoint(path, model, tokenizer, optimizer, scheduler,
|
| epoch, batches_done, global_step):
|
| """
|
| Full mid-training snapshot: model weights + optimizer/scheduler state +
|
| exact position (epoch, batches_done = absolute batch index already
|
| consumed in that epoch's loader, global_step = optimizer steps so far).
|
| Overwrites `path` in place every time -- this is the "latest" pointer,
|
| not a history of checkpoints (epoch_N/ checkpoints still serve that role).
|
| """
|
| path = Path(path)
|
| path.mkdir(parents=True, exist_ok=True)
|
| model.save_pretrained(str(path))
|
| tokenizer.save_pretrained(str(path))
|
| torch.save({
|
| "optimizer_state": optimizer.state_dict(),
|
| "scheduler_state": scheduler.state_dict(),
|
| "epoch": epoch,
|
| "batches_done": batches_done,
|
| "global_step": global_step,
|
| }, path / "trainer_state.pt")
|
|
|
|
|
| def load_resume_state(path):
|
| state_path = Path(path) / "trainer_state.pt"
|
| if not state_path.exists():
|
| return None
|
| return torch.load(state_path, map_location="cpu")
|
|
|
|
|
| def collate_fn(batch, pad_id):
|
| max_len = max(b["input_ids"].shape[0] for b in batch)
|
| input_ids = torch.stack([
|
| F.pad(b["input_ids"], (0, max_len - b["input_ids"].shape[0]), value=pad_id)
|
| for b in batch
|
| ])
|
| labels = torch.stack([
|
| F.pad(b["labels"], (0, max_len - b["labels"].shape[0]), value=IGNORE_INDEX)
|
| for b in batch
|
| ])
|
| attention_mask = input_ids.ne(pad_id)
|
| und_images = torch.stack([b["und_image"] for b in batch])
|
| return dict(
|
| input_ids=input_ids,
|
| labels=labels,
|
| attention_mask=attention_mask,
|
| und_image=und_images,
|
| )
|
|
|
|
|
|
|
| def freeze_generation_components(model, freeze_vision_tower=False):
|
| """
|
| Freeze DiT, MCP (diffusion_connector), VAE, noise_scheduler.
|
| These are built automatically inside `LlavaMetaModel.__init__` from the
|
| checkpoint's config.json (see module docstring point 5) -- we just make
|
| sure they never receive gradients and never enter the optimizer.
|
| """
|
| freeze_keywords = [
|
| "model.dit",
|
| "model.diffusion_connector",
|
| "model.vae",
|
| "model.noise_scheduler",
|
| ]
|
| if freeze_vision_tower:
|
| freeze_keywords.append("model.vision_tower")
|
|
|
| frozen = trainable = 0
|
| for name, param in model.named_parameters():
|
| if any(kw in name for kw in freeze_keywords):
|
| param.requires_grad = False
|
| frozen += 1
|
| else:
|
| param.requires_grad = True
|
| trainable += 1
|
|
|
| trainable_M = sum(p.numel() for p in model.parameters() if p.requires_grad) / 1e6
|
| total_M = sum(p.numel() for p in model.parameters()) / 1e6
|
| print(f"Frozen param groups: {frozen} | Trainable param groups: {trainable}")
|
| print(f"Trainable: {trainable_M:.1f}M / {total_M:.1f}M params")
|
|
|
|
|
|
|
| def init_wandb(args, run_config):
|
| if args.no_wandb:
|
| return None
|
| import wandb
|
| return wandb.init(
|
| project=args.wandb_project,
|
| name=args.wandb_run_name,
|
| config=run_config,
|
| )
|
|
|
|
|
| def train(args):
|
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| print(f"Device: {device}")
|
|
|
| wandb_run = init_wandb(args, {
|
| "model_path": args.model_path,
|
| "data": args.data,
|
| "epochs": args.epochs,
|
| "batch_size": BATCH_SIZE,
|
| "grad_accum": GRAD_ACCUM,
|
| "lr": LR,
|
| "warmup_ratio": WARMUP_RATIO,
|
| "max_len": MAX_LEN,
|
| "freeze_vision_tower": args.freeze_vision_tower,
|
| "val_fraction": args.val_fraction,
|
| })
|
|
|
| resume_state = load_resume_state(args.resume_from) if args.resume_from else None
|
| load_path = args.resume_from if resume_state is not None else args.model_path
|
|
|
| print(f"\nLoading model from: {load_path}")
|
| print("(this will also build the frozen DiT/VAE from the checkpoint's "
|
| "diffusion_name_or_path the first time -- see module docstring "
|
| "point 5 if you want to skip that network fetch)")
|
| model = mobileoUnderstandingForTraining.from_pretrained(
|
| load_path,
|
| torch_dtype=torch.bfloat16,
|
| )
|
| model = model.to(device)
|
|
|
| freeze_generation_components(model, freeze_vision_tower=args.freeze_vision_tower)
|
|
|
|
|
| tokenizer = AutoTokenizer.from_pretrained(load_path)
|
| image_processor = model.get_model().get_vision_tower().image_processor
|
| if tokenizer.pad_token is None:
|
| tokenizer.pad_token = tokenizer.eos_token
|
|
|
| print(f"\nLoading dataset: {args.data}")
|
| make_collate = lambda b: collate_fn(b, tokenizer.pad_token_id)
|
| train_records, val_records = split_train_val(load_records(args.data), args.val_fraction)
|
|
|
| dataset = KvasirVQADataset(train_records, tokenizer, image_processor)
|
| steps_per_epoch = len(dataset) // BATCH_SIZE + (1 if len(dataset) % BATCH_SIZE else 0)
|
| print(f"Train samples: {len(dataset)} | Batches/epoch: {steps_per_epoch}")
|
|
|
| val_loader = None
|
| if val_records:
|
| val_dataset = KvasirVQADataset(val_records, tokenizer, image_processor)
|
| val_loader = DataLoader(
|
| val_dataset, batch_size=BATCH_SIZE, shuffle=False,
|
| num_workers=2, collate_fn=make_collate, pin_memory=True,
|
| )
|
| print(f"Val samples: {len(val_dataset)} (held out, never trained on)")
|
|
|
| optimizer = torch.optim.AdamW(
|
| [p for p in model.parameters() if p.requires_grad],
|
| lr=LR, weight_decay=0.01,
|
| )
|
| total_steps = max(1, (steps_per_epoch // GRAD_ACCUM) * args.epochs)
|
| warmup_steps = max(1, int(total_steps * WARMUP_RATIO))
|
| scheduler = get_cosine_schedule_with_warmup(optimizer, warmup_steps, total_steps)
|
|
|
| start_epoch = 0
|
| skip_batches = 0
|
| global_step = 0
|
| if resume_state is not None:
|
| optimizer.load_state_dict(resume_state["optimizer_state"])
|
| scheduler.load_state_dict(resume_state["scheduler_state"])
|
| start_epoch = resume_state["epoch"]
|
| skip_batches = resume_state["batches_done"]
|
| global_step = resume_state["global_step"]
|
| print(f"\nResuming from {args.resume_from}: "
|
| f"epoch {start_epoch + 1}, batch {skip_batches} of that epoch, "
|
| f"global_step {global_step}")
|
|
|
| out_dir = Path(args.output_dir)
|
| out_dir.mkdir(parents=True, exist_ok=True)
|
| log_path = out_dir / "train_log.jsonl"
|
| latest_ckpt = out_dir / "latest"
|
|
|
| model.train()
|
| print(f"\nStarting from epoch {start_epoch + 1} of {args.epochs}...")
|
|
|
| for epoch in range(start_epoch, args.epochs):
|
| epoch_loss = 0.0
|
| epoch_opt_steps = 0
|
| this_epoch_skip = skip_batches if epoch == start_epoch else 0
|
| skip_batches = 0
|
|
|
| loader = make_epoch_loader(dataset, epoch, make_collate)
|
| batch_iter = enumerate(loader)
|
| if this_epoch_skip:
|
| print(f"Skipping first {this_epoch_skip} already-trained batches of epoch {epoch+1}...")
|
| batch_iter = itertools.islice(batch_iter, this_epoch_skip, None)
|
|
|
| optimizer.zero_grad()
|
| pbar = tqdm(batch_iter, desc=f"Epoch {epoch+1}/{args.epochs}",
|
| total=steps_per_epoch, initial=this_epoch_skip)
|
| step_start = time.time()
|
|
|
| for step, batch in pbar:
|
| input_ids = batch["input_ids"].to(device)
|
| attention_mask = batch["attention_mask"].to(device)
|
| labels = batch["labels"].to(device)
|
| und_image = batch["und_image"].to(device, dtype=torch.bfloat16)
|
|
|
| output = model(
|
| input_ids=input_ids,
|
| attention_mask=attention_mask,
|
| labels=labels,
|
| und_image=und_image,
|
| )
|
| loss = output.loss / GRAD_ACCUM
|
| loss.backward()
|
|
|
| if (step + 1) % GRAD_ACCUM == 0:
|
| grad_norm = torch.nn.utils.clip_grad_norm_(
|
| [p for p in model.parameters() if p.requires_grad], 1.0
|
| )
|
| optimizer.step()
|
| scheduler.step()
|
| optimizer.zero_grad()
|
| global_step += 1
|
| epoch_opt_steps += 1
|
|
|
| elapsed = time.time() - step_start
|
| step_start = time.time()
|
| samples_per_sec = (BATCH_SIZE * GRAD_ACCUM) / max(elapsed, 1e-6)
|
| gpu_mem_gb = (
|
| torch.cuda.memory_allocated(device) / 1e9
|
| if torch.cuda.is_available() else 0.0
|
| )
|
|
|
| entry = {
|
| "step": global_step,
|
| "epoch": epoch + 1,
|
| "loss": round(loss.item() * GRAD_ACCUM, 4),
|
| "lr": scheduler.get_last_lr()[0],
|
| "grad_norm": float(grad_norm),
|
| "samples_per_sec": round(samples_per_sec, 2),
|
| "gpu_mem_gb": round(gpu_mem_gb, 2),
|
| }
|
| with open(log_path, "a") as f:
|
| f.write(json.dumps(entry) + "\n")
|
| if wandb_run is not None:
|
| wandb_run.log(entry, step=global_step)
|
| epoch_loss += entry["loss"]
|
| pbar.set_postfix(loss=f"{entry['loss']:.4f}")
|
|
|
| if args.save_every_steps > 0 and global_step % args.save_every_steps == 0:
|
| save_resume_checkpoint(
|
| latest_ckpt, model, tokenizer, optimizer, scheduler,
|
| epoch=epoch, batches_done=step + 1, global_step=global_step,
|
| )
|
|
|
| avg = epoch_loss / max(1, epoch_opt_steps)
|
| print(f"Epoch {epoch+1} avg loss: {avg:.4f}")
|
|
|
| val_loss = None
|
| if val_loader is not None:
|
| val_loss = evaluate(model, val_loader, device)
|
| print(f"Epoch {epoch+1} val_loss (held-out, never trained on): {val_loss:.4f}")
|
|
|
| with open(log_path, "a") as f:
|
| f.write(json.dumps({"epoch_summary": epoch + 1, "epoch_avg_loss": avg, "val_loss": val_loss}) + "\n")
|
|
|
| if wandb_run is not None:
|
| log_entry = {"epoch": epoch + 1, "epoch_avg_loss": avg}
|
| if val_loss is not None:
|
| log_entry["val_loss"] = val_loss
|
| wandb_run.log(log_entry, step=global_step)
|
|
|
| epoch_ckpt = out_dir / f"epoch_{epoch+1}"
|
| model.save_pretrained(str(epoch_ckpt))
|
| tokenizer.save_pretrained(str(epoch_ckpt))
|
| print(f"Checkpoint saved -> {epoch_ckpt}")
|
|
|
| save_resume_checkpoint(
|
| latest_ckpt, model, tokenizer, optimizer, scheduler,
|
| epoch=epoch + 1, batches_done=0, global_step=global_step,
|
| )
|
|
|
| ckpt = out_dir / "vlm_kvasir"
|
| model.save_pretrained(str(ckpt))
|
| tokenizer.save_pretrained(str(ckpt))
|
| print(f"\nSaved -> {ckpt}")
|
| print(f"Log -> {log_path}")
|
| print("\nCheck train_log.jsonl -- loss should decrease step by step.")
|
| print("Check epoch_N/val_loss in train_log.jsonl or wandb -- if val_loss stops "
|
| "decreasing (or rises) across epochs, later epochs are likely overfitting "
|
| "rather than generalizing further.")
|
|
|
| if wandb_run is not None:
|
| wandb_run.finish()
|
|
|
|
|
| def parse_args():
|
| p = argparse.ArgumentParser()
|
| p.add_argument("--model_path", default=DEFAULT_MODEL)
|
| p.add_argument("--data", default=DEFAULT_DATA)
|
| p.add_argument("--output_dir", default=DEFAULT_OUTDIR)
|
| p.add_argument("--val_fraction", type=float, default=0.02,
|
| help="Fraction of --data held out (fixed seed, never trained on) for "
|
| "per-epoch val_loss. Set to 0 to disable and train on 100%% of data.")
|
| p.add_argument("--epochs", type=int, default=1)
|
| p.add_argument("--resume_from", default=None,
|
| help="Path to a checkpoint dir saved by this script (e.g. "
|
| "<output_dir>/latest) containing trainer_state.pt. "
|
| "Resumes model weights + optimizer/scheduler state + "
|
| "exact batch position -- continues training as if the "
|
| "process never stopped, mid-epoch included. Logs append "
|
| "to the SAME --output_dir's train_log.jsonl, so use the "
|
| "same --output_dir you used originally.")
|
| p.add_argument("--save_every_steps", type=int, default=200,
|
| help="Save a full resume checkpoint (model+optimizer+scheduler+"
|
| "position) to <output_dir>/latest every N optimizer steps. "
|
| "Set to 0 to disable mid-epoch checkpointing (epoch-boundary "
|
| "checkpoints are still saved).")
|
| p.add_argument("--freeze_vision_tower", action="store_true",
|
| help="Also freeze the MobileCLIP/FastViT vision tower "
|
| "(~600M params) -- trains only the LLM + projector "
|
| "(~500M params), faster/less memory.")
|
| p.add_argument("--smoke_test", action="store_true",
|
| help="200 samples, 1 epoch -- just verify the pipeline works")
|
| p.add_argument("--wandb_project", default="mobile-o-vlm-finetune")
|
| p.add_argument("--wandb_run_name", default=None)
|
| p.add_argument("--no_wandb", action="store_true",
|
| help="Disable wandb logging; only write train_log.jsonl")
|
| return p.parse_args()
|
|
|
|
|
| if __name__ == "__main__":
|
| args = parse_args()
|
| if args.smoke_test:
|
| args.data = "data/smoke_test.jsonl"
|
| args.epochs = 1
|
| train(args)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |