import json import random from pathlib import Path from typing import Dict, Optional import pandas as pd import sacrebleu import torch import torch.nn as nn from torch.optim import AdamW from tqdm.auto import tqdm from .data import BBoxAwareImageCaptioningCollator from .utils import cleanup_memory def move_batch_to_device(batch, device): output = {} for key, value in batch.items(): if isinstance(value, torch.Tensor): output[key] = value.to(device, non_blocking=True) else: output[key] = value return output def compute_loss_from_logits(logits, labels, label_smoothing=0.0): vocab_size = logits.size(-1) loss_fct = nn.CrossEntropyLoss( ignore_index=-100, label_smoothing=label_smoothing, ) return loss_fct( logits.reshape(-1, vocab_size), labels.reshape(-1), ) def make_optimizer(model, learning_rate=3e-5, weight_decay=0.01): decay_params = [] no_decay_params = [] for name, param in model.named_parameters(): if not param.requires_grad: continue if ( name.endswith(".bias") or "layernorm" in name.lower() or "layer_norm" in name.lower() or "norm" in name.lower() ): no_decay_params.append(param) else: decay_params.append(param) optimizer_grouped_parameters = [ {"params": decay_params, "weight_decay": weight_decay}, {"params": no_decay_params, "weight_decay": 0.0}, ] use_fused = torch.cuda.is_available() try: optimizer = AdamW( optimizer_grouped_parameters, lr=learning_rate, betas=(0.9, 0.999), eps=1e-8, fused=use_fused, ) except TypeError: optimizer = AdamW( optimizer_grouped_parameters, lr=learning_rate, betas=(0.9, 0.999), eps=1e-8, ) return optimizer def select_eval_indices(dataset_size, max_samples=None, seed=42): if max_samples is None or int(max_samples) >= dataset_size: return list(range(dataset_size)) rng = random.Random(int(seed)) return rng.sample(range(dataset_size), int(max_samples)) def pick_diverse_examples(rows, num_examples=5, seed=42): rng = random.Random(seed) shuffled = list(rows) rng.shuffle(shuffled) selected = [] seen_images = set() for row in shuffled: if row["image_id"] in seen_images: continue selected.append(row) seen_images.add(row["image_id"]) if len(selected) >= num_examples: break if len(selected) < num_examples: for row in shuffled: if row not in selected: selected.append(row) if len(selected) >= num_examples: break return selected @torch.no_grad() def evaluate_loss( model, dataloader, device, label_smoothing=0.0, use_bf16=False, use_fp16=False, ): model.eval() autocast_dtype = torch.bfloat16 if use_bf16 else torch.float16 total_loss = 0.0 total_batches = 0 for batch in tqdm(dataloader, desc="eval loss", leave=False): batch = move_batch_to_device(batch, device) with torch.amp.autocast( device_type="cuda", dtype=autocast_dtype, enabled=(use_bf16 or use_fp16), ): outputs = model( pixel_values=batch["pixel_values"], bbox_features=batch["bbox_features"], labels=batch["labels"], ) loss = compute_loss_from_logits( logits=outputs.logits, labels=batch["labels"], label_smoothing=label_smoothing, ) total_loss += float(loss.detach().cpu()) total_batches += 1 cleanup_memory() return total_loss / max(1, total_batches) @torch.no_grad() def evaluate_generation( model, dataset, tokenizer, image_processor, device, tgt_lang_code="en_XX", max_target_length=64, eval_batch_size=128, num_workers=4, pin_memory=True, max_samples=None, num_examples=5, sample_seed=42, output_path=None, split_name="val", num_beams=4, max_new_tokens=30, repetition_penalty=1.1, no_repeat_ngram_size=3, length_penalty=1.0, use_bf16=False, use_fp16=False, ): model.eval() autocast_dtype = torch.bfloat16 if use_bf16 else torch.float16 indices = select_eval_indices( dataset_size=len(dataset), max_samples=max_samples, seed=sample_seed, ) eval_subset = torch.utils.data.Subset(dataset, indices) eval_collator = BBoxAwareImageCaptioningCollator( image_processor=image_processor, tokenizer=tokenizer, tgt_lang_code=tgt_lang_code, max_target_length=max_target_length, ) eval_loader = torch.utils.data.DataLoader( eval_subset, batch_size=eval_batch_size, shuffle=False, collate_fn=eval_collator, num_workers=num_workers, pin_memory=pin_memory, ) predictions = [] references = [] rows = [] forced_bos_token_id = tokenizer.convert_tokens_to_ids(tgt_lang_code) for batch in tqdm(eval_loader, desc=f"generation eval ({split_name})", leave=False): batch = move_batch_to_device(batch, device) with torch.amp.autocast( device_type="cuda", dtype=autocast_dtype, enabled=(use_bf16 or use_fp16), ): generated_ids = model.generate( pixel_values=batch["pixel_values"], bbox_features=batch["bbox_features"], tokenizer=tokenizer, num_beams=num_beams, max_new_tokens=max_new_tokens, forced_bos_token_id=forced_bos_token_id, repetition_penalty=repetition_penalty, no_repeat_ngram_size=no_repeat_ngram_size, length_penalty=length_penalty, ) batch_predictions = tokenizer.batch_decode( generated_ids, skip_special_tokens=True, ) for image_id, region_id, raw_bbox, reference, prediction in zip( batch["image_id"], batch["region_id"], batch["raw_bbox"], batch["caption"], batch_predictions, ): pred = str(prediction).strip() ref = str(reference).strip() predictions.append(pred) references.append(ref) rows.append({ "split": split_name, "image_id": image_id, "region_id": region_id, "raw_bbox": raw_bbox, "ground_truth": ref, "prediction": pred, "is_empty": pred == "", "same_as_ground_truth": pred.lower() == ref.lower(), }) bleu = sacrebleu.corpus_bleu(predictions, [references]).score if predictions else float("nan") chrf = sacrebleu.corpus_chrf(predictions, [references]).score if predictions else float("nan") predictions_df = pd.DataFrame(rows) if output_path is not None: output_path = Path(output_path) output_path.parent.mkdir(parents=True, exist_ok=True) predictions_df.to_csv(output_path, index=False, encoding="utf-8-sig") print(f"Full {split_name} predictions saved to: {output_path}") print(f"Saved rows: {len(predictions_df):,}") example_rows = pick_diverse_examples( rows, num_examples=num_examples, seed=sample_seed, ) cleanup_memory() return { "bleu": float(bleu), "chrf": float(chrf), "num_samples": len(predictions), "examples": rows, "qualitative_examples": example_rows, "predictions_path": str(output_path) if output_path is not None else None, } def show_and_save_qualitative_examples(gen_metrics, epoch, output_dir): output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) qualitative_rows = gen_metrics.get("qualitative_examples", []) qualitative_df = pd.DataFrame(qualitative_rows) qualitative_path = output_dir / f"epoch_{epoch + 1:03d}_qualitative_predictions.csv" qualitative_df.to_csv(qualitative_path, index=False, encoding="utf-8-sig") print("Qualitative predictions saved to:", qualitative_path) display_cols = [ "image_id", "region_id", "raw_bbox", "ground_truth", "prediction", "is_empty", ] existing_cols = [col for col in display_cols if col in qualitative_df.columns] if len(qualitative_df) > 0: return qualitative_df[existing_cols] return qualitative_df def summarize_prediction_diversity(predictions_df): return ( predictions_df .groupby("image_id") .agg( num_regions=("region_id", "count"), unique_ground_truth=("ground_truth", "nunique"), unique_predictions=("prediction", "nunique"), first_prediction=("prediction", "first"), ) .reset_index() .sort_values(["num_regions", "unique_ground_truth"], ascending=False) ) def train_one_epoch( model, train_loader, optimizer, scheduler, scaler, device, epoch, global_step, grad_accum_steps=1, max_grad_norm=1.0, label_smoothing=0.0, use_bf16=False, use_fp16=False, start_batch_idx=0, ): model.train() autocast_dtype = torch.bfloat16 if use_bf16 else torch.float16 running_loss = 0.0 processed_batches = 0 progress_bar = tqdm( enumerate(train_loader), total=len(train_loader), desc=f"epoch {epoch + 1}", ) optimizer.zero_grad(set_to_none=True) for batch_idx, batch in progress_bar: if batch_idx < start_batch_idx: continue batch = move_batch_to_device(batch, device) with torch.amp.autocast( device_type="cuda", dtype=autocast_dtype, enabled=(use_bf16 or use_fp16), ): outputs = model( pixel_values=batch["pixel_values"], bbox_features=batch["bbox_features"], labels=batch["labels"], ) loss = compute_loss_from_logits( logits=outputs.logits, labels=batch["labels"], label_smoothing=label_smoothing, ) loss_for_backward = loss / grad_accum_steps if use_fp16: scaler.scale(loss_for_backward).backward() else: loss_for_backward.backward() running_loss += float(loss.detach().cpu()) processed_batches += 1 should_step = ( ((batch_idx + 1) % grad_accum_steps == 0) or ((batch_idx + 1) == len(train_loader)) ) if should_step: if use_fp16: scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_( [p for p in model.parameters() if p.requires_grad], max_grad_norm, ) if use_fp16: scaler.step(optimizer) scaler.update() else: optimizer.step() scheduler.step() optimizer.zero_grad(set_to_none=True) global_step += 1 progress_bar.set_postfix( { "loss": f"{running_loss / max(1, processed_batches):.4f}", "lr": f"{optimizer.param_groups[0]['lr']:.2e}", "step": global_step, } ) avg_loss = running_loss / max(1, processed_batches) return avg_loss, global_step