Spaces:
Paused
Paused
Download backend/adaptation/train_lora_vqa.py from Sahil-nith/SatQuery-api: direct link, hf CLI and curl.
- Browser
- Download file 10 kB
-
https://huggingface.co/spaces/Sahil-nith/SatQuery-api/resolve/main/backend/adaptation/train_lora_vqa.py
- Command line
-
hf download hf://spaces/Sahil-nith/SatQuery-api/backend/adaptation/train_lora_vqa.py
-
curl -L -o train_lora_vqa.py https://huggingface.co/spaces/Sahil-nith/SatQuery-api/resolve/main/backend/adaptation/train_lora_vqa.py
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() | |