Improve training: learnable temperature, per-group LR, warmup, augmentations

Loss:
- Learnable temperature (CLIP-style logit_scale) with clamp [0.01, 0.5]
- Replaces fixed cosine schedule (still available via --no-learnable-temp)
- Default tau_init=0.07

Optimizer:
- Per-group LR: projections 1e-4, text encoder 1e-5 (10x lower)
- Learnable temperature included in projection param group

Scheduler:
- Linear warmup (2 epochs default) + cosine annealing
- Per-step scheduling (not per-epoch)

Augmentations (separate drone/satellite):
- Drone: RandomResizedCrop(0.7-1.0), HFlip, Rotation(15), ColorJitter,
  RandomGrayscale(0.05), GaussianBlur
- Satellite: RandomResizedCrop(0.7-1.0), HFlip, ColorJitter, RandomGrayscale
- Eval: clean Resize+CenterCrop (no augmentation)

Dataset: supports separate drone_transform/sat_transform args

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
pikaliov
2026-04-21 18:07:17 +03:00
parent 6ad9c4d149
commit 998d52cb57
4 changed files with 210 additions and 41 deletions

View File

@@ -99,7 +99,9 @@ class GTAUAVDataset(Dataset):
pair_json: Path to cross-area-drone2sate-{train,test}.json. pair_json: Path to cross-area-drone2sate-{train,test}.json.
rgb_root: Root of GTA-UAV-LR RGB images. rgb_root: Root of GTA-UAV-LR RGB images.
caption_root: Root of GTA-UAV-LR-captions. caption_root: Root of GTA-UAV-LR-captions.
image_transform: Callable applied to PIL images. drone_transform: Transform for drone images (can include augmentations).
sat_transform: Transform for satellite images (can include augmentations).
image_transform: Fallback single transform for both (used if drone/sat not set).
filter_meta: Path to seg_filter.json (exclude 90%+ bg/water). filter_meta: Path to seg_filter.json (exclude 90%+ bg/water).
drop_caption_prob: Probability of dropping captions (ablation). drop_caption_prob: Probability of dropping captions (ablation).
seed: Random seed. seed: Random seed.
@@ -110,6 +112,8 @@ class GTAUAVDataset(Dataset):
pair_json: str, pair_json: str,
rgb_root: str = str(_RGB_ROOT), rgb_root: str = str(_RGB_ROOT),
caption_root: str = str(_CAPTION_ROOT), caption_root: str = str(_CAPTION_ROOT),
drone_transform: Callable[[Image.Image], torch.Tensor] | None = None,
sat_transform: Callable[[Image.Image], torch.Tensor] | None = None,
image_transform: Callable[[Image.Image], torch.Tensor] | None = None, image_transform: Callable[[Image.Image], torch.Tensor] | None = None,
filter_meta: str | None = None, filter_meta: str | None = None,
drop_caption_prob: float = 0.0, drop_caption_prob: float = 0.0,
@@ -117,7 +121,8 @@ class GTAUAVDataset(Dataset):
) -> None: ) -> None:
self.rgb_root = Path(rgb_root) self.rgb_root = Path(rgb_root)
self.caption_root = Path(caption_root) self.caption_root = Path(caption_root)
self.image_transform = image_transform self.drone_transform = drone_transform or image_transform
self.sat_transform = sat_transform or image_transform
self.drop_caption_prob = drop_caption_prob self.drop_caption_prob = drop_caption_prob
self._rng = random.Random(seed) self._rng = random.Random(seed)
@@ -189,12 +194,12 @@ class GTAUAVDataset(Dataset):
"caption_l3": l3, "caption_l3": l3,
}) })
def _load_image(self, directory: str, filename: str) -> torch.Tensor: def _load_image(self, directory: str, filename: str, transform: Callable | None = None) -> torch.Tensor:
path = self.rgb_root / directory / filename path = self.rgb_root / directory / filename
with Image.open(path) as img: with Image.open(path) as img:
rgb = img.convert("RGB") rgb = img.convert("RGB")
if self.image_transform is not None: if transform is not None:
return self.image_transform(rgb) return transform(rgb)
return torch.tensor(0) # placeholder if no transform return torch.tensor(0) # placeholder if no transform
def __len__(self) -> int: def __len__(self) -> int:
@@ -203,7 +208,7 @@ class GTAUAVDataset(Dataset):
def __getitem__(self, idx: int) -> dict[str, Any]: def __getitem__(self, idx: int) -> dict[str, Any]:
entry = self.entries[idx] entry = self.entries[idx]
drone_img = self._load_image(entry["drone_dir"], entry["drone_name"]) drone_img = self._load_image(entry["drone_dir"], entry["drone_name"], self.drone_transform)
# Sample satellite match (weighted if semi-positive). # Sample satellite match (weighted if semi-positive).
if entry["sat_weights"] is not None: if entry["sat_weights"] is not None:
@@ -215,7 +220,7 @@ class GTAUAVDataset(Dataset):
else: else:
sat_name = self._rng.choice(entry["sat_candidates"]) sat_name = self._rng.choice(entry["sat_candidates"])
sat_img = self._load_image(entry["sat_dir"], sat_name) sat_img = self._load_image(entry["sat_dir"], sat_name, self.sat_transform)
# Captions with optional dropout. # Captions with optional dropout.
if self.drop_caption_prob > 0 and self._rng.random() < self.drop_caption_prob: if self.drop_caption_prob > 0 and self._rng.random() < self.drop_caption_prob:

View File

@@ -4,7 +4,8 @@ from __future__ import annotations
Single symmetric InfoNCE between query (drone+text fused) and gallery (satellite). Single symmetric InfoNCE between query (drone+text fused) and gallery (satellite).
Asymmetric weighting: query->gallery weighted higher (real use-case direction). Asymmetric weighting: query->gallery weighted higher (real use-case direction).
Cosine temperature schedule for sharper distribution over training.
Supports both learnable temperature (CLIP-style logit_scale) and fixed/scheduled.
""" """
import math import math
@@ -47,23 +48,30 @@ def cosine_temperature(
@gin.configurable @gin.configurable
class InfoNCELoss(nn.Module): class InfoNCELoss(nn.Module):
"""Symmetric InfoNCE with cosine temperature schedule. """Symmetric InfoNCE with learnable or scheduled temperature.
Args: Args:
temperature_init: Temperature at epoch 0. temperature_init: Initial temperature value.
temperature_final: Temperature after cosine decay. temperature_final: Final temperature (only used if learnable=False).
label_smoothing: Cross-entropy label smoothing. label_smoothing: Cross-entropy label smoothing.
weight_q2g: Weight for query->gallery direction. weight_q2g: Weight for query->gallery direction.
weight_g2q: Weight for gallery->query direction. weight_g2q: Weight for gallery->query direction.
learnable_temperature: If True, temperature is a learnable parameter
(CLIP-style logit_scale). If False, uses cosine schedule.
tau_min: Minimum clamp for learnable temperature.
tau_max: Maximum clamp for learnable temperature.
""" """
def __init__( def __init__(
self, self,
temperature_init: float = 0.1, temperature_init: float = 0.07,
temperature_final: float = 0.01, temperature_final: float = 0.01,
label_smoothing: float = 0.1, label_smoothing: float = 0.1,
weight_q2g: float = 0.6, weight_q2g: float = 0.6,
weight_g2q: float = 0.4, weight_g2q: float = 0.4,
learnable_temperature: bool = True,
tau_min: float = 0.01,
tau_max: float = 0.5,
) -> None: ) -> None:
super().__init__() super().__init__()
self.temperature_init = temperature_init self.temperature_init = temperature_init
@@ -71,6 +79,26 @@ class InfoNCELoss(nn.Module):
self.label_smoothing = label_smoothing self.label_smoothing = label_smoothing
self.weight_q2g = weight_q2g self.weight_q2g = weight_q2g
self.weight_g2q = weight_g2q self.weight_g2q = weight_g2q
self.learnable_temperature = learnable_temperature
self.tau_min = tau_min
self.tau_max = tau_max
if learnable_temperature:
# Store as log(1/tau) like CLIP's logit_scale.
init_logit_scale = math.log(1.0 / temperature_init)
self.logit_scale = nn.Parameter(torch.tensor(init_logit_scale))
else:
self.logit_scale = None
@property
def current_temperature(self) -> float:
"""Current temperature value (for logging)."""
if self.logit_scale is not None:
tau = 1.0 / self.logit_scale.exp().clamp(
min=1.0 / self.tau_max, max=1.0 / self.tau_min,
).item()
return tau
return self.temperature_init
def forward( def forward(
self, self,
@@ -89,6 +117,13 @@ class InfoNCELoss(nn.Module):
Returns: Returns:
Dict with 'total', 'temperature', 'gate'. Dict with 'total', 'temperature', 'gate'.
""" """
if self.learnable_temperature:
# Clamp logit_scale to prevent tau from going out of bounds.
logit_scale = self.logit_scale.exp().clamp(
min=1.0 / self.tau_max, max=1.0 / self.tau_min,
)
tau = 1.0 / logit_scale
else:
tau = cosine_temperature( tau = cosine_temperature(
epoch=epoch, epoch=epoch,
total_epochs=total_epochs, total_epochs=total_epochs,
@@ -107,8 +142,13 @@ class InfoNCELoss(nn.Module):
gate = embeddings.get("gate", 1.0) gate = embeddings.get("gate", 1.0)
if isinstance(tau, float):
tau_out = torch.tensor(tau, device=loss.device)
else:
tau_out = tau.detach().clone()
return { return {
"total": loss, "total": loss,
"temperature": torch.tensor(tau, device=loss.device), "temperature": tau_out,
"gate": torch.tensor(gate, device=loss.device), "gate": torch.tensor(gate, device=loss.device),
} }

View File

@@ -543,15 +543,58 @@ class AsymmetricEncoder(nn.Module):
# Image preprocessing (DINOv3: 256x256, ImageNet normalization) # Image preprocessing (DINOv3: 256x256, ImageNet normalization)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_IMAGENET_MEAN = [0.485, 0.456, 0.406]
_IMAGENET_STD = [0.229, 0.224, 0.225]
def get_dino_transform(image_size: int = 256) -> torch.nn.Module: def get_dino_transform(image_size: int = 256) -> torch.nn.Module:
"""Build image transform for DINOv3 input.""" """Build eval/inference image transform for DINOv3 input."""
from torchvision import transforms from torchvision import transforms
return transforms.Compose([ return transforms.Compose([
transforms.Resize(image_size, interpolation=transforms.InterpolationMode.BICUBIC), transforms.Resize(image_size, interpolation=transforms.InterpolationMode.BICUBIC),
transforms.CenterCrop(image_size), transforms.CenterCrop(image_size),
transforms.ToTensor(), transforms.ToTensor(),
transforms.Normalize( transforms.Normalize(mean=_IMAGENET_MEAN, std=_IMAGENET_STD),
mean=[0.485, 0.456, 0.406], ])
std=[0.229, 0.224, 0.225],
),
def get_drone_train_transform(image_size: int = 256) -> torch.nn.Module:
"""Build training augmentation for drone images.
Includes: RandomResizedCrop, HFlip, rotation, color jitter,
grayscale, Gaussian blur.
"""
from torchvision import transforms
return transforms.Compose([
transforms.RandomResizedCrop(
image_size, scale=(0.7, 1.0),
interpolation=transforms.InterpolationMode.BICUBIC,
),
transforms.RandomHorizontalFlip(p=0.5),
transforms.RandomRotation(degrees=15),
transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2, hue=0.1),
transforms.RandomGrayscale(p=0.05),
transforms.GaussianBlur(kernel_size=3, sigma=(0.1, 2.0)),
transforms.ToTensor(),
transforms.Normalize(mean=_IMAGENET_MEAN, std=_IMAGENET_STD),
])
def get_satellite_train_transform(image_size: int = 256) -> torch.nn.Module:
"""Build training augmentation for satellite images.
Lighter than drone: no rotation or blur (satellite is nadir/consistent).
Includes: RandomResizedCrop, HFlip, color jitter, grayscale.
"""
from torchvision import transforms
return transforms.Compose([
transforms.RandomResizedCrop(
image_size, scale=(0.7, 1.0),
interpolation=transforms.InterpolationMode.BICUBIC,
),
transforms.RandomHorizontalFlip(p=0.5),
transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2, hue=0.1),
transforms.RandomGrayscale(p=0.05),
transforms.ToTensor(),
transforms.Normalize(mean=_IMAGENET_MEAN, std=_IMAGENET_STD),
]) ])

View File

@@ -9,6 +9,7 @@ Single InfoNCE loss: query(drone+text) vs gallery(satellite).
import argparse import argparse
import json import json
import logging import logging
import math
import time import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -18,13 +19,18 @@ import torch
import torch.nn as nn import torch.nn as nn
from torch.amp import GradScaler, autocast from torch.amp import GradScaler, autocast
from torch.optim import AdamW from torch.optim import AdamW
from torch.optim.lr_scheduler import CosineAnnealingLR from torch.optim.lr_scheduler import LambdaLR
from torch.utils.data import DataLoader from torch.utils.data import DataLoader
from tqdm import tqdm from tqdm import tqdm
from src.datasets.gtauav_dataset import GTAUAVDataset, collate_gtauav_batch from src.datasets.gtauav_dataset import GTAUAVDataset, collate_gtauav_batch
from src.losses.multi_infonce import InfoNCELoss from src.losses.multi_infonce import InfoNCELoss
from src.models.asymmetric_encoder import AsymmetricEncoder, get_dino_transform from src.models.asymmetric_encoder import (
AsymmetricEncoder,
get_dino_transform,
get_drone_train_transform,
get_satellite_train_transform,
)
LOGGER = logging.getLogger("caption_test.train_gtauav") LOGGER = logging.getLogger("caption_test.train_gtauav")
@@ -64,19 +70,21 @@ class TrainConfigGTAUAV:
batch_size: int = 64 batch_size: int = 64
num_workers: int = 4 num_workers: int = 4
learning_rate: float = 1e-4 learning_rate: float = 1e-4
text_lr_factor: float = 0.1 # text encoder LR = learning_rate * factor
weight_decay: float = 1e-4 weight_decay: float = 1e-4
grad_clip: float = 1.0 grad_clip: float = 1.0
use_amp: bool = True use_amp: bool = True
eval_every: int = 2 eval_every: int = 2
warmup_epochs: int = 2
seed: int = 42 seed: int = 42
device: str = "cuda" device: str = "cuda"
# Loss. # Loss.
tau_init: float = 0.1 tau_init: float = 0.07
tau_final: float = 0.01
label_smoothing: float = 0.1 label_smoothing: float = 0.1
weight_q2g: float = 0.6 weight_q2g: float = 0.6
weight_g2q: float = 0.4 weight_g2q: float = 0.4
learnable_temperature: bool = True
def _set_seed(seed: int) -> None: def _set_seed(seed: int) -> None:
@@ -95,6 +103,45 @@ def _atomic_save(obj: dict, path: Path) -> None:
tmp_path.replace(path) tmp_path.replace(path)
def _build_param_groups(
model: AsymmetricEncoder,
lr: float,
text_lr_factor: float,
) -> list[dict]:
"""Build optimizer param groups with separate LR for text encoder."""
text_params = []
other_params = []
for name, param in model.named_parameters():
if not param.requires_grad:
continue
if "text_encoder" in name:
text_params.append(param)
else:
other_params.append(param)
groups = [{"params": other_params, "lr": lr}]
if text_params:
groups.append({"params": text_params, "lr": lr * text_lr_factor})
return groups
def _cosine_warmup_schedule(
warmup_steps: int,
total_steps: int,
) -> callable:
"""Cosine annealing with linear warmup."""
def lr_lambda(step: int) -> float:
if step < warmup_steps:
return step / max(warmup_steps, 1)
progress = (step - warmup_steps) / max(total_steps - warmup_steps, 1)
return 0.5 * (1.0 + math.cos(math.pi * progress))
return lr_lambda
@torch.no_grad() @torch.no_grad()
def _evaluate( def _evaluate(
model: AsymmetricEncoder, model: AsymmetricEncoder,
@@ -186,27 +233,35 @@ def train(cfg: TrainConfigGTAUAV) -> None:
# Loss. # Loss.
loss_fn = InfoNCELoss( loss_fn = InfoNCELoss(
temperature_init=cfg.tau_init, temperature_init=cfg.tau_init,
temperature_final=cfg.tau_final,
label_smoothing=cfg.label_smoothing, label_smoothing=cfg.label_smoothing,
weight_q2g=cfg.weight_q2g, weight_q2g=cfg.weight_q2g,
weight_g2q=cfg.weight_g2q, weight_g2q=cfg.weight_g2q,
learnable_temperature=cfg.learnable_temperature,
)
LOGGER.info(
"🌡️ Temperature: %s (init=%.3f)",
"learnable" if cfg.learnable_temperature else "cosine schedule",
cfg.tau_init,
) )
# Data. # Data — separate transforms for train (augmented) and eval (clean).
transform = get_dino_transform(image_size=256) drone_train_tf = get_drone_train_transform(image_size=256)
sat_train_tf = get_satellite_train_transform(image_size=256)
eval_tf = get_dino_transform(image_size=256)
train_ds = GTAUAVDataset( train_ds = GTAUAVDataset(
pair_json=cfg.train_json, pair_json=cfg.train_json,
rgb_root=cfg.rgb_root, rgb_root=cfg.rgb_root,
caption_root=cfg.caption_root, caption_root=cfg.caption_root,
image_transform=transform, drone_transform=drone_train_tf,
sat_transform=sat_train_tf,
filter_meta=cfg.filter_meta, filter_meta=cfg.filter_meta,
) )
test_ds = GTAUAVDataset( test_ds = GTAUAVDataset(
pair_json=cfg.test_json, pair_json=cfg.test_json,
rgb_root=cfg.rgb_root, rgb_root=cfg.rgb_root,
caption_root=cfg.caption_root, caption_root=cfg.caption_root,
image_transform=transform, image_transform=eval_tf,
filter_meta=cfg.filter_meta, filter_meta=cfg.filter_meta,
) )
@@ -230,13 +285,27 @@ def train(cfg: TrainConfigGTAUAV) -> None:
LOGGER.info("📦 train=%d test=%d batch=%d", len(train_ds), len(test_ds), cfg.batch_size) LOGGER.info("📦 train=%d test=%d batch=%d", len(train_ds), len(test_ds), cfg.batch_size)
# Optimizer. # Optimizer — per-group LR (text encoder gets lower LR).
optimizer = AdamW( param_groups = _build_param_groups(model, cfg.learning_rate, cfg.text_lr_factor)
model.trainable_parameters(), # Include loss temperature if learnable.
lr=cfg.learning_rate, if cfg.learnable_temperature and loss_fn.logit_scale is not None:
weight_decay=cfg.weight_decay, param_groups[0]["params"].append(loss_fn.logit_scale)
optimizer = AdamW(param_groups, weight_decay=cfg.weight_decay)
lr_info = f"proj={cfg.learning_rate:.0e}"
if not cfg.baseline_mode:
lr_info += f" text={cfg.learning_rate * cfg.text_lr_factor:.0e}"
LOGGER.info("⚙️ Optimizer: AdamW LR: %s warmup=%d epochs", lr_info, cfg.warmup_epochs)
# Scheduler — cosine with linear warmup.
steps_per_epoch = len(train_loader)
total_steps = cfg.epochs * steps_per_epoch
warmup_steps = cfg.warmup_epochs * steps_per_epoch
scheduler = LambdaLR(
optimizer,
lr_lambda=_cosine_warmup_schedule(warmup_steps, total_steps),
) )
scheduler = CosineAnnealingLR(optimizer, T_max=cfg.epochs)
scaler = GradScaler(enabled=cfg.use_amp) scaler = GradScaler(enabled=cfg.use_amp)
history: list[dict] = [] history: list[dict] = []
@@ -289,6 +358,7 @@ def train(cfg: TrainConfigGTAUAV) -> None:
) )
scaler.step(optimizer) scaler.step(optimizer)
scaler.update() scaler.update()
scheduler.step()
for key, val in loss_dict.items(): for key, val in loss_dict.items():
agg[key] = agg.get(key, 0.0) + float(val.item()) agg[key] = agg.get(key, 0.0) + float(val.item())
@@ -296,10 +366,10 @@ def train(cfg: TrainConfigGTAUAV) -> None:
pbar.set_postfix( pbar.set_postfix(
loss=f"{total_loss.item():.3f}", loss=f"{total_loss.item():.3f}",
tau=f"{loss_dict['temperature'].item():.4f}",
gate=f"{loss_dict['gate'].item():.3f}", gate=f"{loss_dict['gate'].item():.3f}",
) )
scheduler.step()
elapsed = time.time() - epoch_start elapsed = time.time() - epoch_start
means = {k: v / max(n_batches, 1) for k, v in agg.items()} means = {k: v / max(n_batches, 1) for k, v in agg.items()}
@@ -339,6 +409,7 @@ def train(cfg: TrainConfigGTAUAV) -> None:
"epoch": epoch, "epoch": epoch,
"model_state": model.state_dict(), "model_state": model.state_dict(),
"optimizer_state": optimizer.state_dict(), "optimizer_state": optimizer.state_dict(),
"loss_state": loss_fn.state_dict(),
}, },
path=output_dir / f"ckpt_epoch{epoch:03d}.pt", path=output_dir / f"ckpt_epoch{epoch:03d}.pt",
) )
@@ -395,7 +466,15 @@ def main() -> None:
) )
parser.add_argument( parser.add_argument(
"--lr", type=float, default=1e-4, "--lr", type=float, default=1e-4,
help="Learning rate.", help="Learning rate for projections.",
)
parser.add_argument(
"--text-lr-factor", type=float, default=0.1,
help="Text encoder LR = lr * factor (default 0.1 = 10x lower).",
)
parser.add_argument(
"--warmup-epochs", type=int, default=2,
help="Linear warmup epochs.",
) )
parser.add_argument( parser.add_argument(
"--init-gate", type=float, default=0.7, "--init-gate", type=float, default=0.7,
@@ -408,6 +487,8 @@ def main() -> None:
cfg.batch_size = args.batch_size cfg.batch_size = args.batch_size
cfg.epochs = args.epochs cfg.epochs = args.epochs
cfg.learning_rate = args.lr cfg.learning_rate = args.lr
cfg.text_lr_factor = args.text_lr_factor
cfg.warmup_epochs = args.warmup_epochs
cfg.init_gate = args.init_gate cfg.init_gate = args.init_gate
if args.filter_meta is not None: if args.filter_meta is not None: