Spaces:
Paused
Paused
File size: 10,045 Bytes
0127c21 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 | """
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()
|