SatQuery-api / backend /adaptation /train_lora_vqa.py
Sahil-nith's picture
Upload 47 files
0127c21 verified
Raw History Blame Contribute Delete
10 kB
"""
LoRA fine-tuning: adapt Salesforce/blip-vqa-base to remote-sensing
imagery using the BigEarthNet-derived VQA triplets from
prepare_bigearthnet.py.
Phase 5 (P0). This IS the PS-mandatory "remote-sensing fine-tuning or
domain adaptation" component. It is intentionally lightweight (LoRA,
not full fine-tuning) so it fits the 4-day budget and runs on a single
consumer GPU (or CPU, slowly) β€” full fine-tuning of even a 385M-param
model on limited hackathon compute/time is not a realistic ask.
This is a genuine training script, not a placeholder β€” run it for real
before the demo, even for a small number of steps on a subset of data.
A LoRA adapter trained for 20 minutes on a few hundred examples is an
honest "adapted" claim. Skipping this file entirely and shipping the
base model is also honest, AS LONG AS the app reports adapted=False
(it does, by default β€” see models/vqa.py).
Phase 14 addition β€” "best model" checkpointing:
Earlier version of this script trained for N epochs and saved
whatever the LAST epoch produced, with no validation signal at all β€”
if training overfit or got worse in a later epoch, you'd have no way
to know or recover the better earlier checkpoint. This version:
1. Holds out a validation split (auto-split from --train-jsonl if
--val-jsonl isn't given).
2. Evaluates on that split every epoch.
3. Uses HF Trainer's load_best_model_at_end so the checkpoint that
is actually saved to --output-dir is the one with the LOWEST
validation loss seen during training, not simply the final epoch.
4. Writes adapter_info.json next to the saved adapter recording the
real numbers (train/val example counts, per-epoch eval losses,
best eval loss, which epoch it came from) β€” no invented metrics,
everything in that file is read straight from the Trainer's own
log history.
Usage:
python train_lora_vqa.py \
--train-jsonl data/bigearthnet_vqa_train.jsonl \
--output-dir ../models/checkpoints/vqa_lora_bigearthnet \
--epochs 3 --batch-size 4
# or, with an explicit held-out validation file instead of an
# auto-split of --train-jsonl:
python train_lora_vqa.py \
--train-jsonl data/bigearthnet_vqa_train.jsonl \
--val-jsonl data/bigearthnet_vqa_val.jsonl \
--output-dir ../models/checkpoints/vqa_lora_bigearthnet \
--epochs 3 --batch-size 4
Dependencies (not in the main backend requirements.txt β€” training-only):
pip install peft accelerate datasets
"""
from __future__ import annotations
import argparse
import json
import random
from datetime import datetime, timezone
from pathlib import Path
import torch
from PIL import Image
from torch.utils.data import Dataset
class VQAJsonlDataset(Dataset):
def __init__(self, examples: list[dict], processor):
self.examples = examples
self.processor = processor
def __len__(self) -> int:
return len(self.examples)
def __getitem__(self, idx: int) -> dict:
ex = self.examples[idx]
image = Image.open(ex["image_path"]).convert("RGB")
encoding = self.processor(
image, ex["question"], text_target=ex["answer"],
return_tensors="pt", padding="max_length", truncation=True, max_length=32,
)
return {k: v.squeeze(0) for k, v in encoding.items()}
def _load_jsonl(path: Path) -> list[dict]:
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
def _split_train_val(examples: list[dict], val_split: float, seed: int = 42) -> tuple[list[dict], list[dict]]:
"""Deterministic shuffle + split so re-running with the same
--train-jsonl and --val-split always produces the same split
(reproducible, not re-randomized every run)."""
shuffled = list(examples)
random.Random(seed).shuffle(shuffled)
n_val = max(1, int(len(shuffled) * val_split)) if len(shuffled) > 1 else 0
return shuffled[n_val:], shuffled[:n_val]
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--train-jsonl", required=True, type=Path)
parser.add_argument(
"--val-jsonl", type=Path, default=None,
help="Optional separate validation set. If omitted, a slice of --train-jsonl is held out instead.",
)
parser.add_argument(
"--val-split", type=float, default=0.1,
help="Fraction of --train-jsonl held out for validation when --val-jsonl is not given.",
)
parser.add_argument("--output-dir", required=True, type=Path)
parser.add_argument("--base-model", default="Salesforce/blip-vqa-base")
parser.add_argument("--epochs", type=int, default=3)
parser.add_argument("--batch-size", type=int, default=4)
parser.add_argument("--eval-batch-size", type=int, default=None)
parser.add_argument("--lr", type=float, default=1e-4)
parser.add_argument("--lora-r", type=int, default=8)
args = parser.parse_args()
from peft import LoraConfig, get_peft_model
from transformers import BlipForQuestionAnswering, BlipProcessor, Trainer, TrainingArguments
processor = BlipProcessor.from_pretrained(args.base_model)
model = BlipForQuestionAnswering.from_pretrained(args.base_model)
lora_config = LoraConfig(
r=args.lora_r,
lora_alpha=args.lora_r * 2,
# BLIP's text decoder/vision encoder use standard attention linear
# layers named query/key/value β€” target those. If this errors on
# your installed transformers version, run
# `print(model)` first and adjust target_modules accordingly.
target_modules=["query", "value"],
lora_dropout=0.05,
bias="none",
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
all_train_examples = _load_jsonl(args.train_jsonl)
if args.val_jsonl is not None:
train_examples = all_train_examples
val_examples = _load_jsonl(args.val_jsonl)
else:
train_examples, val_examples = _split_train_val(all_train_examples, args.val_split)
if len(val_examples) == 0:
raise SystemExit(
f"No validation examples available (train set has {len(all_train_examples)} rows, "
f"--val-split={args.val_split}). Provide more training data, a lower --val-split "
"denominator won't help below 1 example, or pass --val-jsonl explicitly. Training "
"without ANY validation signal means there is no way to pick a 'best' checkpoint β€” "
"refusing to silently fall back to 'just save the last epoch'."
)
print(f"Train examples: {len(train_examples)} | Validation examples: {len(val_examples)}")
train_dataset = VQAJsonlDataset(train_examples, processor)
val_dataset = VQAJsonlDataset(val_examples, processor)
eval_batch_size = args.eval_batch_size or args.batch_size
training_args = TrainingArguments(
output_dir=str(args.output_dir / "_trainer_tmp"),
num_train_epochs=args.epochs,
per_device_train_batch_size=args.batch_size,
per_device_eval_batch_size=eval_batch_size,
learning_rate=args.lr,
logging_steps=10,
eval_strategy="epoch",
save_strategy="epoch",
save_total_limit=2, # keep disk usage sane β€” only best + most-recent checkpoint dirs
load_best_model_at_end=True, # <- the actual "best model" mechanism
metric_for_best_model="eval_loss",
greater_is_better=False,
report_to=[],
remove_unused_columns=False,
)
trainer = Trainer(
model=model,
args=training_args,
train_dataset=train_dataset,
eval_dataset=val_dataset,
)
trainer.train()
# trainer.model now holds the BEST checkpoint by eval_loss (thanks to
# load_best_model_at_end=True above), not necessarily the final epoch.
args.output_dir.mkdir(parents=True, exist_ok=True)
model.save_pretrained(str(args.output_dir))
# Pull the real per-epoch eval losses straight out of the Trainer's own
# log history β€” nothing here is invented or estimated.
eval_logs = [entry for entry in trainer.state.log_history if "eval_loss" in entry]
best_entry = min(eval_logs, key=lambda e: e["eval_loss"]) if eval_logs else None
info = {
"base_model": args.base_model,
"adaptation_method": "LoRA (peft)",
"lora_r": args.lora_r,
"epochs_requested": args.epochs,
"train_examples": len(train_examples),
"val_examples": len(val_examples),
"val_source": "explicit --val-jsonl" if args.val_jsonl is not None else f"auto-split ({args.val_split:.0%} of --train-jsonl)",
"per_epoch_eval_loss": [
{"epoch": e.get("epoch"), "eval_loss": e.get("eval_loss")} for e in eval_logs
],
"best_eval_loss": best_entry["eval_loss"] if best_entry else None,
"best_epoch": best_entry.get("epoch") if best_entry else None,
"trained_at_utc": datetime.now(timezone.utc).isoformat(),
"note": (
"best_eval_loss/best_epoch identify which checkpoint load_best_model_at_end "
"selected and saved to this directory β€” it is not necessarily the final epoch. "
"This is a validation-set loss (next-token cross-entropy), not a task-accuracy "
"benchmark figure; do not present it to judges as validated VQA accuracy."
),
}
(args.output_dir / "adapter_info.json").write_text(json.dumps(info, indent=2))
print(f"Saved BEST LoRA adapter (by eval_loss) to {args.output_dir}")
if best_entry:
print(f"Best eval_loss: {best_entry['eval_loss']:.4f} (epoch {best_entry.get('epoch')})")
print(f"Full per-epoch metrics written to {args.output_dir / 'adapter_info.json'}")
print(
"Set VQA_LORA_ADAPTER_PATH to this directory (or leave the default "
"in models/vqa.py, which already points here) so the backend picks "
"it up on next restart."
)
if __name__ == "__main__":
main()