| """
|
| LoRA fine-tuning of the visual encoder for temporal change retrieval.
|
|
|
| Unlike the ProjectionHead adapter (which trains on pre-cached frozen embeddings),
|
| LoRA modifies the visual encoder's **FFN weights** in-place. This requires loading
|
| images on-the-fly during training and re-caching embeddings afterwards.
|
|
|
| Encoder-agnostic
|
| ----------------
|
| The trainer touches no encoder-family internals: each encoder advertises a
|
| ``lora_visual_spec()`` (``src.encoders.base.LoRAVisualSpec``) that supplies the
|
| trainable visual module, its LoRA target-module names, the PIL→tensor preprocess,
|
| and a unified ``forward`` returning shared-space embeddings. Supported families:
|
| open_clip (``georsclip``, ``remoteclip``) and HF-transformers CLIP (``clip_vitl14``).
|
| An encoder without ``lora_visual_spec()`` cannot be LoRA-trained and the trainer
|
| fails with a clear message rather than an obscure ``AttributeError``.
|
|
|
| Architecture
|
| ------------
|
| - LoRA applied to the ViT FFN only: ``c_fc``/``c_proj`` (open_clip ResBlock MLP)
|
| or ``fc1``/``fc2`` (HF-CLIP encoder-layer MLP) — chosen per-encoder by the spec.
|
| The attention projections are NOT adapted: open_clip's attention is an
|
| ``nn.MultiheadAttention`` whose forward calls ``F.multi_head_attention_forward``
|
| and reads ``out_proj.weight`` / ``in_proj_weight`` as raw tensors — it never
|
| invokes ``out_proj.forward``, so a PEFT LoRA wrapper on ``out_proj`` would
|
| receive no gradient and be a silent no-op. Adapting attention would require a
|
| custom MHA wrapper; here we adapt the FFN only.
|
| - Trainable params: ~369K for ViT-B-32 (~0.42% of the visual encoder).
|
| - Text encoder stays fully frozen throughout.
|
| - Loss: masked symmetric InfoNCE, identical to the ProjectionHead trainer
|
| (``src.train._masked_infonce``): every same-caption pair is a mutual positive
|
| (mean log-prob over the positive set), so DEN's heavily-repeated captions do
|
| not fight each other as negatives.
|
| - After training: LoRA weights are merged into the base model via
|
| ``merge_and_unload()``, then embeddings are re-computed and cached.
|
|
|
| CLI
|
| ---
|
| python -m src.lora_train --root data/DynamicEarthNet --encoder georsclip \\
|
| --split train --color-mode nrg --epochs 20
|
|
|
| Use via ``scripts/run_pipeline.py --lora`` for the full cross-split evaluation.
|
| """
|
| from __future__ import annotations
|
|
|
| import argparse
|
| import random
|
| from dataclasses import dataclass
|
| from pathlib import Path
|
| from typing import Any, Dict, List, Optional, Tuple
|
|
|
| import numpy as np
|
| import torch
|
| import torch.nn.functional as F
|
|
|
| from src.datasets.base import TemporalDataset
|
| from src.datasets.registry import build_dataset
|
| from src.encoders import get_encoder
|
|
|
|
|
|
|
|
|
|
|
|
|
| @dataclass
|
| class LoRAConfig:
|
| rank: int = 4
|
| alpha: int = 8
|
| dropout: float = 0.1
|
|
|
|
|
|
|
| target_modules: Optional[List[str]] = None
|
| epochs: int = 20
|
| lr: float = 1e-4
|
| batch_size: int = 8
|
| seed: int = 42
|
|
|
|
|
|
|
|
|
|
|
|
|
| def _build_train_pairs(
|
| dataset: TemporalDataset,
|
| ) -> Tuple[List[Any], List[str]]:
|
| """Return (pairs, captions) lists aligned by index."""
|
| pairs = dataset.list_pairs()
|
| captions = [
|
| getattr(dataset, "text_caption_for_pair", lambda p: "land cover change")(p)
|
| for p in pairs
|
| ]
|
| return pairs, captions
|
|
|
|
|
| def _infonce_loss(
|
| delta: torch.Tensor,
|
| text: torch.Tensor,
|
| pos_mask: torch.Tensor,
|
| temperature: float = 0.07,
|
| ) -> torch.Tensor:
|
| """Masked symmetric InfoNCE — identical formulation to
|
| ``src.train._masked_infonce``.
|
|
|
| Every same-caption pair is a *mutual positive*: the loss is the mean log-prob
|
| over each row's positive set (``pos_mask``), averaged over both directions
|
| (delta->text and text->delta). All columns stay in the softmax denominator, so
|
| same-caption rows are NOT treated as negatives of one another — the bug the
|
| previous single-diagonal-target version had (it left same-caption positives in
|
| the denominator while targeting only the diagonal, making repeated DEN captions
|
| fight each other).
|
| """
|
| a = F.normalize(delta, dim=-1)
|
| t = F.normalize(text, dim=-1)
|
| logits = a @ t.t() / temperature
|
|
|
| def _dir(lg: torch.Tensor) -> torch.Tensor:
|
| log_prob = F.log_softmax(lg, dim=-1)
|
| pos = (log_prob * pos_mask).sum(-1) / pos_mask.sum(-1).clamp_min(1)
|
| return -pos.mean()
|
|
|
| return 0.5 * (_dir(logits) + _dir(logits.t()))
|
|
|
|
|
| def _require_lora_spec(encoder: Any):
|
| """Return the encoder's :class:`~src.encoders.base.LoRAVisualSpec`, or raise a
|
| clear error if the encoder does not support visual LoRA."""
|
| spec_fn = getattr(encoder, "lora_visual_spec", None)
|
| if spec_fn is None:
|
| raise TypeError(
|
| f"Encoder {getattr(encoder, 'name', type(encoder).__name__)!r} does not "
|
| "support visual LoRA (no lora_visual_spec()). Supported encoders: "
|
| "clip_vitl14, georsclip, remoteclip."
|
| )
|
| return spec_fn()
|
|
|
|
|
|
|
|
|
|
|
|
|
| def train_lora(
|
| dataset: TemporalDataset,
|
| encoder: Any,
|
| cfg: LoRAConfig = LoRAConfig(),
|
| device: Optional[torch.device] = None,
|
| verbose: bool = True,
|
| ) -> Tuple[Any, Dict]:
|
| """
|
| Apply LoRA to the encoder's visual tower (via ``encoder.lora_visual_spec()``)
|
| and train on ``dataset`` pairs.
|
|
|
| Returns
|
| -------
|
| visual_lora : peft.PeftModel
|
| Trained LoRA-wrapped visual module (NOT yet merged).
|
| history : dict
|
| Training log with per-epoch loss.
|
| """
|
| try:
|
| from peft import LoraConfig, get_peft_model
|
| except ImportError as exc:
|
| raise ImportError("peft required: pip install peft") from exc
|
|
|
| spec = _require_lora_spec(encoder)
|
|
|
| torch.manual_seed(cfg.seed)
|
| np.random.seed(cfg.seed)
|
| random.seed(cfg.seed)
|
|
|
| device = device or torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
|
|
|
|
|
|
|
|
| encoder.device = device
|
|
|
|
|
| spec.to_device(device)
|
| lora_cfg = LoraConfig(
|
| r=cfg.rank,
|
| lora_alpha=cfg.alpha,
|
| target_modules=cfg.target_modules or spec.target_modules,
|
| lora_dropout=cfg.dropout,
|
| bias="none",
|
| )
|
|
|
|
|
|
|
| visual_lora = get_peft_model(spec.module, lora_cfg)
|
| visual_lora.to(device).train()
|
| if verbose:
|
| visual_lora.print_trainable_parameters()
|
|
|
| opt = torch.optim.Adam(
|
| filter(lambda p: p.requires_grad, visual_lora.parameters()),
|
| lr=cfg.lr,
|
| )
|
| sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, T_max=cfg.epochs)
|
|
|
| pairs, captions = _build_train_pairs(dataset)
|
| if verbose:
|
| print(f"LoRA training: {len(pairs)} pairs, {cfg.epochs} epochs, "
|
| f"batch={cfg.batch_size}, rank={cfg.rank}, alpha={cfg.alpha}")
|
|
|
|
|
| with torch.no_grad():
|
| text_all = torch.from_numpy(
|
| encoder.encode_text(captions).astype(np.float32)
|
| ).to(device)
|
|
|
| caption_ids = {c: i for i, c in enumerate(sorted(set(captions)))}
|
| cid_all = torch.tensor([caption_ids[c] for c in captions], device=device)
|
|
|
| indices = list(range(len(pairs)))
|
| history: Dict[str, List[float]] = {"loss": []}
|
|
|
| for epoch in range(cfg.epochs):
|
| random.shuffle(indices)
|
| epoch_losses: List[float] = []
|
|
|
| for start in range(0, len(indices), cfg.batch_size):
|
| batch_idx = indices[start: start + cfg.batch_size]
|
| if len(batch_idx) < 2:
|
| continue
|
|
|
| px_t1_list, px_t2_list, text_batch, cid_batch = [], [], [], []
|
| for i in batch_idx:
|
| pair = pairs[i]
|
| try:
|
| im1, im2 = dataset.load_pair_images(pair)
|
| except Exception:
|
| continue
|
| px_t1_list.append(spec.preprocess(im1))
|
| px_t2_list.append(spec.preprocess(im2))
|
| text_batch.append(text_all[i])
|
| cid_batch.append(cid_all[i])
|
|
|
| if len(px_t1_list) < 2:
|
| continue
|
|
|
| px_t1 = torch.stack(px_t1_list).to(device)
|
| px_t2 = torch.stack(px_t2_list).to(device)
|
| T = torch.stack(text_batch)
|
| cid = torch.stack(cid_batch)
|
|
|
| f1 = spec.forward(visual_lora, px_t1)
|
| f2 = spec.forward(visual_lora, px_t2)
|
| delta = F.normalize(f2 - f1, dim=-1)
|
|
|
| pos_mask = (cid[:, None] == cid[None, :])
|
| loss = _infonce_loss(delta, T, pos_mask)
|
|
|
| opt.zero_grad()
|
| loss.backward()
|
| opt.step()
|
| epoch_losses.append(loss.item())
|
|
|
| mean_loss = float(np.mean(epoch_losses)) if epoch_losses else float("nan")
|
| history["loss"].append(mean_loss)
|
| sched.step()
|
| if verbose and (epoch % 5 == 0 or epoch == cfg.epochs - 1):
|
| print(f" epoch {epoch+1:3d}/{cfg.epochs} loss={mean_loss:.4f}")
|
|
|
| return visual_lora, history
|
|
|
|
|
|
|
|
|
|
|
|
|
| def save_lora(visual_lora: Any, path: str | Path) -> None:
|
| """Save LoRA adapter weights (not full model — only delta params)."""
|
| Path(path).parent.mkdir(parents=True, exist_ok=True)
|
| visual_lora.save_pretrained(str(path))
|
|
|
|
|
| def merge_lora_into_encoder(encoder: Any, visual_lora: Any) -> None:
|
| """Merge trained LoRA weights into encoder in-place, unload peft wrapper."""
|
| merged = visual_lora.merge_and_unload()
|
| spec = _require_lora_spec(encoder)
|
| spec.set_module(merged)
|
| for p in merged.parameters():
|
| p.requires_grad = False
|
|
|
|
|
| def load_lora_into_encoder(encoder: Any, path: str | Path) -> None:
|
| """Load a saved LoRA adapter from ``path`` and merge into ``encoder``."""
|
| try:
|
| from peft import PeftModel
|
| except ImportError as exc:
|
| raise ImportError("peft required: pip install peft") from exc
|
|
|
| spec = _require_lora_spec(encoder)
|
| visual_lora = PeftModel.from_pretrained(spec.module, str(path))
|
| merge_lora_into_encoder(encoder, visual_lora)
|
|
|
|
|
|
|
|
|
|
|
|
|
| def _main() -> None:
|
| ap = argparse.ArgumentParser(description="Train LoRA adapter on visual encoder")
|
| ap.add_argument("--root", default="data/DynamicEarthNet")
|
| ap.add_argument("--dataset", default="dynamic_earthnet")
|
| ap.add_argument("--split", default="train")
|
| ap.add_argument("--encoder", default="georsclip",
|
| choices=["clip_vitl14", "georsclip", "remoteclip"])
|
| ap.add_argument("--color-mode", default="nrg",
|
| choices=["rgb", "nrg", "ndvi"])
|
| ap.add_argument("--rank", type=int, default=4)
|
| ap.add_argument("--alpha", type=int, default=8)
|
| ap.add_argument("--epochs", type=int, default=20)
|
| ap.add_argument("--lr", type=float, default=1e-4)
|
| ap.add_argument("--batch-size", type=int, default=8)
|
| ap.add_argument("--out", default=None,
|
| help="Output dir for LoRA weights (default: models/<key>__lora/)")
|
| ap.add_argument("--cache-dir", default="data/cache")
|
| args = ap.parse_args()
|
|
|
| from src.embeddings import cache_tag_for, load_or_compute
|
|
|
| enc = get_encoder(args.encoder)
|
|
|
|
|
|
|
| ds = build_dataset(
|
| args.dataset,
|
| root=args.root,
|
| split=args.split,
|
| color_mode=args.color_mode,
|
| )
|
|
|
| cfg = LoRAConfig(
|
| rank=args.rank,
|
| alpha=args.alpha,
|
| epochs=args.epochs,
|
| lr=args.lr,
|
| batch_size=args.batch_size,
|
| )
|
| visual_lora, history = train_lora(ds, enc, cfg, verbose=True)
|
|
|
| color_tag = f"_{args.color_mode}" if args.color_mode != "rgb" else ""
|
| out_dir = args.out or f"models/{ds.name}__{enc.name}{color_tag}__lora"
|
| save_lora(visual_lora, out_dir)
|
| print(f"LoRA weights saved → {out_dir}")
|
|
|
| print("Merging LoRA into encoder and re-computing embeddings ...")
|
| merge_lora_into_encoder(enc, visual_lora)
|
|
|
|
|
|
|
| cache_tag = cache_tag_for(args.split, args.color_mode, lora=True)
|
|
|
|
|
| store = load_or_compute(ds, enc, cache_dir=args.cache_dir, cache_tag=cache_tag, force=True)
|
| print(f"Re-cached {len(store.pairs)} pairs with LoRA-adapted encoder.")
|
| print(f"Cache tag: {cache_tag}")
|
| print(f"Final loss: {history['loss'][-1]:.4f}")
|
|
|
|
|
| if __name__ == "__main__":
|
| _main()
|
|
|