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()