Add ML diagnostics tooling (W&B, TensorBoard, Grad-CAM, profiler) and gin configs
- Add unified experiment tracker (W&B + TensorBoard) with graceful fallback - Add gradient norm monitoring per param group (MONA, LoRA, MLP, gates, tau) - Add Grad-CAM visualization for DINOv3 drone/satellite encoders - Add PyTorch Profiler wrapper + torchinfo model summary - Add gin-config support to train_gtauav.py with CLI overrides - Add v3 gin configs: gtauav_balanced, gtauav_baseline, gtauav_text_heavy, gtauav_image_heavy - Generate metric plots every epoch (not just on eval) - Set default epochs to 10 - Update README and CLAUDE.md with new tooling and usage docs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
43
CLAUDE.md
43
CLAUDE.md
@@ -97,7 +97,16 @@ Eval: Resize(256) + CenterCrop(256) + ImageNet normalization.
|
|||||||
| `src/models/asymmetric_encoder.py` | DINOv3ViT + TextFusionMLP + AsymmetricEncoder + GatedFusion |
|
| `src/models/asymmetric_encoder.py` | DINOv3ViT + TextFusionMLP + AsymmetricEncoder + GatedFusion |
|
||||||
| `src/datasets/gtauav_dataset.py` | GTA-UAV-LR loader + L1/L2/L3 caption parsing из VLM JSON |
|
| `src/datasets/gtauav_dataset.py` | GTA-UAV-LR loader + L1/L2/L3 caption parsing из VLM JSON |
|
||||||
| `src/losses/multi_infonce.py` | InfoNCE с learnable temperature (fp32), clamp [0.01, 0.5] |
|
| `src/losses/multi_infonce.py` | InfoNCE с learnable temperature (fp32), clamp [0.01, 0.5] |
|
||||||
| `src/training/train_gtauav.py` | Training loop с eval, AMP, per-group LR, warmup, --resume |
|
| `src/training/train_gtauav.py` | Training loop с gin, W&B/TB, AMP, per-group LR, warmup, --resume |
|
||||||
|
| `src/training/trackers.py` | Unified experiment tracker: W&B + TensorBoard + CSV |
|
||||||
|
| `src/training/grad_monitor.py` | Gradient norm monitoring per param group |
|
||||||
|
| `src/training/gradcam.py` | Grad-CAM visualization для DINOv3 encoders |
|
||||||
|
| `src/training/profiling.py` | PyTorch Profiler wrapper + torchinfo model summary |
|
||||||
|
| `src/training/plot_metrics.py` | Seaborn/matplotlib plots (каждую эпоху) |
|
||||||
|
| `conf/gtauav_balanced.gin` | With text, gate=0.7, 10 epochs |
|
||||||
|
| `conf/gtauav_baseline.gin` | No text, gate=1.0 |
|
||||||
|
| `conf/gtauav_text_heavy.gin` | Text-heavy, gate=0.3 |
|
||||||
|
| `conf/gtauav_image_heavy.gin` | Image-heavy, gate=0.9 |
|
||||||
| `scripts/make_split.py` | 80/20 random split из всех пар |
|
| `scripts/make_split.py` | 80/20 random split из всех пар |
|
||||||
| `scripts/filter_segmentation.py` | Scan segm masks, output meta JSON (exclude >=90% bg+water) |
|
| `scripts/filter_segmentation.py` | Scan segm masks, output meta JSON (exclude >=90% bg+water) |
|
||||||
|
|
||||||
@@ -204,6 +213,14 @@ Meta-файл `meta/seg_filter.json`: исключение изображени
|
|||||||
- Test: 6,742 → 6,252 after seg filter
|
- Test: 6,742 → 6,252 after seg filter
|
||||||
- Скрипт: `python -m scripts.make_split --ratio 0.8 --seed 42`
|
- Скрипт: `python -m scripts.make_split --ratio 0.8 --seed 42`
|
||||||
|
|
||||||
|
### V3 (GTA-UAV, gin)
|
||||||
|
| Конфиг | Gate init | Описание |
|
||||||
|
|--------|-----------|----------|
|
||||||
|
| `conf/gtauav_balanced.gin` | 0.7 (30% text) | **Primary test** |
|
||||||
|
| `conf/gtauav_baseline.gin` | 1.0 (no text) | Reference baseline |
|
||||||
|
| `conf/gtauav_text_heavy.gin` | 0.3 (70% text) | Stress test |
|
||||||
|
| `conf/gtauav_image_heavy.gin` | 0.9 (10% text) | Image-dominant |
|
||||||
|
|
||||||
### V2 (UAV-GeoLoc, gin)
|
### V2 (UAV-GeoLoc, gin)
|
||||||
| Конфиг | Gate init | Описание |
|
| Конфиг | Gate init | Описание |
|
||||||
|--------|-----------|----------|
|
|--------|-----------|----------|
|
||||||
@@ -218,17 +235,31 @@ Meta-файл `meta/seg_filter.json`: исключение изображени
|
|||||||
# 1. Filter segmentation (exclude 90%+ background/water)
|
# 1. Filter segmentation (exclude 90%+ background/water)
|
||||||
python -m scripts.filter_segmentation --output meta/seg_filter.json
|
python -m scripts.filter_segmentation --output meta/seg_filter.json
|
||||||
|
|
||||||
# 2. Baseline (no text)
|
# 2. Train with gin config (recommended)
|
||||||
python -m src.training.train_gtauav --baseline --filter-meta meta/seg_filter.json
|
python -m src.training.train_gtauav --config conf/gtauav_balanced.gin \
|
||||||
|
--filter-meta meta/seg_filter.json
|
||||||
|
|
||||||
# 3. With captions (L1/L2/L3)
|
# 3. Baseline (no text)
|
||||||
python -m src.training.train_gtauav --filter-meta meta/seg_filter.json
|
python -m src.training.train_gtauav --config conf/gtauav_baseline.gin \
|
||||||
|
--filter-meta meta/seg_filter.json
|
||||||
|
|
||||||
# 4. Compare
|
# 4. With diagnostics (W&B + Grad-CAM + Profiler)
|
||||||
|
python -m src.training.train_gtauav --config conf/gtauav_balanced.gin \
|
||||||
|
--filter-meta meta/seg_filter.json --wandb --gradcam --profile
|
||||||
|
|
||||||
|
# 5. CLI overrides (gin params take priority)
|
||||||
|
python -m src.training.train_gtauav --config conf/gtauav_balanced.gin \
|
||||||
|
--filter-meta meta/seg_filter.json \
|
||||||
|
--gin-param 'TrainConfigGTAUAV.batch_size=16'
|
||||||
|
|
||||||
|
# 6. Compare
|
||||||
python -m scripts.compare_runs \
|
python -m scripts.compare_runs \
|
||||||
--baseline_report out/gtauav/baseline/eval_report.json \
|
--baseline_report out/gtauav/baseline/eval_report.json \
|
||||||
--full_report out/gtauav/with_text/eval_report.json \
|
--full_report out/gtauav/with_text/eval_report.json \
|
||||||
--output out/gtauav/comparison.md
|
--output out/gtauav/comparison.md
|
||||||
|
|
||||||
|
# 7. TensorBoard
|
||||||
|
tensorboard --logdir out/gtauav/with_text/tb_logs
|
||||||
```
|
```
|
||||||
|
|
||||||
### V2 (UAV-GeoLoc)
|
### V2 (UAV-GeoLoc)
|
||||||
|
|||||||
89
README.md
89
README.md
@@ -218,10 +218,13 @@ Gallery: sat_img -> GeoRSCLIP -> gallery
|
|||||||
|
|
||||||
```
|
```
|
||||||
caption-test/
|
caption-test/
|
||||||
├── conf/ # Gin configs (v2)
|
├── conf/ # Gin configs
|
||||||
│ ├── balanced.gin
|
│ ├── gtauav_balanced.gin # GTA-UAV with text (10 epochs, v3)
|
||||||
│ ├── baseline_no_text.gin
|
│ ├── gtauav_baseline.gin # GTA-UAV baseline, no text (v3)
|
||||||
│ └── text_heavy.gin
|
│ ├── gtauav_text_heavy.gin # GTA-UAV text-heavy gate=0.3 (v3)
|
||||||
|
│ ├── balanced.gin # UAV-GeoLoc with text (v2)
|
||||||
|
│ ├── baseline_no_text.gin # UAV-GeoLoc baseline (v2)
|
||||||
|
│ └── text_heavy.gin # UAV-GeoLoc text-heavy (v2)
|
||||||
├── nn_models/ # Pre-trained checkpoints (v3, gitignored)
|
├── nn_models/ # Pre-trained checkpoints (v3, gitignored)
|
||||||
│ ├── DINO_WEB/ # DINOv3 ViT-L/16 LVD-1689M (.pth)
|
│ ├── DINO_WEB/ # DINOv3 ViT-L/16 LVD-1689M (.pth)
|
||||||
│ ├── DINO_SAT/ # DINOv3 ViT-L/16 SAT-493M (.safetensors)
|
│ ├── DINO_SAT/ # DINOv3 ViT-L/16 SAT-493M (.safetensors)
|
||||||
@@ -250,7 +253,12 @@ caption-test/
|
|||||||
│ │ └── multi_infonce.py # InfoNCE with learnable temperature
|
│ │ └── multi_infonce.py # InfoNCE with learnable temperature
|
||||||
│ ├── training/
|
│ ├── training/
|
||||||
│ │ ├── train_gtauav.py # Training loop GTA-UAV (v3)
|
│ │ ├── train_gtauav.py # Training loop GTA-UAV (v3)
|
||||||
│ │ └── train.py # Training loop UAV-GeoLoc (v2)
|
│ │ ├── train.py # Training loop UAV-GeoLoc (v2)
|
||||||
|
│ │ ├── trackers.py # Unified tracker: W&B + TensorBoard
|
||||||
|
│ │ ├── grad_monitor.py # Gradient norm monitoring per group
|
||||||
|
│ │ ├── gradcam.py # Grad-CAM visualization for DINOv3
|
||||||
|
│ │ ├── profiling.py # PyTorch Profiler + torchinfo summary
|
||||||
|
│ │ └── plot_metrics.py # Seaborn/matplotlib metric plots
|
||||||
│ └── eval/
|
│ └── eval/
|
||||||
│ └── evaluate.py # R@K metrics, Delta R@1
|
│ └── evaluate.py # R@K metrics, Delta R@1
|
||||||
└── checkpoints/ # GeoRSCLIP RS5M_ViT-B-32.pt (v2)
|
└── checkpoints/ # GeoRSCLIP RS5M_ViT-B-32.pt (v2)
|
||||||
@@ -268,6 +276,17 @@ regex
|
|||||||
gin-config
|
gin-config
|
||||||
Pillow
|
Pillow
|
||||||
numpy
|
numpy
|
||||||
|
pandas
|
||||||
|
matplotlib
|
||||||
|
seaborn
|
||||||
|
```
|
||||||
|
|
||||||
|
### Optional (for extended diagnostics)
|
||||||
|
|
||||||
|
```
|
||||||
|
wandb # Weights & Biases experiment tracking
|
||||||
|
torchinfo # Model summary tables
|
||||||
|
tensorboard # TensorBoard logging (included with torch)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Workflow (V3 — GTA-UAV)
|
## Workflow (V3 — GTA-UAV)
|
||||||
@@ -279,26 +298,52 @@ python -m scripts.make_split --output-dir meta
|
|||||||
python -m scripts.filter_segmentation --output meta/seg_filter.json
|
python -m scripts.filter_segmentation --output meta/seg_filter.json
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Train baseline (no text)
|
### 2. Train with gin configs (recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Baseline (no text, 10 epochs)
|
||||||
|
python -m src.training.train_gtauav --config conf/gtauav_baseline.gin \
|
||||||
|
--filter-meta meta/seg_filter.json
|
||||||
|
|
||||||
|
# With captions (L1/L2/L3, 10 epochs)
|
||||||
|
python -m src.training.train_gtauav --config conf/gtauav_balanced.gin \
|
||||||
|
--filter-meta meta/seg_filter.json
|
||||||
|
|
||||||
|
# Text-heavy (gate=0.3, 70% text weight)
|
||||||
|
python -m src.training.train_gtauav --config conf/gtauav_text_heavy.gin \
|
||||||
|
--filter-meta meta/seg_filter.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Train without gin (CLI-only)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m src.training.train_gtauav --baseline --filter-meta meta/seg_filter.json
|
python -m src.training.train_gtauav --baseline --filter-meta meta/seg_filter.json
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Train with captions (L1/L2/L3)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m src.training.train_gtauav --filter-meta meta/seg_filter.json
|
python -m src.training.train_gtauav --filter-meta meta/seg_filter.json
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Resume from checkpoint
|
### 4. Enable diagnostics
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# W&B + Grad-CAM + PyTorch Profiler
|
||||||
|
python -m src.training.train_gtauav --config conf/gtauav_balanced.gin \
|
||||||
|
--filter-meta meta/seg_filter.json --wandb --gradcam --profile
|
||||||
|
|
||||||
|
# Gin parameter overrides from CLI
|
||||||
|
python -m src.training.train_gtauav --config conf/gtauav_balanced.gin \
|
||||||
|
--filter-meta meta/seg_filter.json \
|
||||||
|
--gin-param 'TrainConfigGTAUAV.batch_size=16' 'TrainConfigGTAUAV.epochs=20'
|
||||||
|
```
|
||||||
|
|
||||||
|
CLI flags (`--wandb`, `--gradcam`, `--profile`, `--epochs`, etc.) take priority over gin config.
|
||||||
|
|
||||||
|
### 5. Resume from checkpoint
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m src.training.train_gtauav --resume out/gtauav/with_text/ckpt_epoch004.pt \
|
python -m src.training.train_gtauav --resume out/gtauav/with_text/ckpt_epoch004.pt \
|
||||||
--filter-meta meta/seg_filter.json
|
--filter-meta meta/seg_filter.json
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Compare and get verdict
|
### 6. Compare and get verdict
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m scripts.compare_runs \
|
python -m scripts.compare_runs \
|
||||||
@@ -307,6 +352,24 @@ python -m scripts.compare_runs \
|
|||||||
--output out/gtauav/comparison.md
|
--output out/gtauav/comparison.md
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 7. View TensorBoard
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tensorboard --logdir out/gtauav/with_text/tb_logs
|
||||||
|
```
|
||||||
|
|
||||||
|
## Diagnostics & Visualization
|
||||||
|
|
||||||
|
| Tool | Flag | Output | Description |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| **TensorBoard** | `--use-tb` (default on) | `{out}/tb_logs/` | Scalars, histograms, images |
|
||||||
|
| **W&B** | `--wandb` | cloud | Full experiment tracking, Grad-CAM images |
|
||||||
|
| **Grad-CAM** | `--gradcam` | `{out}/gradcam/` | DINOv3 attention heatmaps (drone + satellite) |
|
||||||
|
| **PyTorch Profiler** | `--profile` | `{out}/profiler/` | Chrome trace, CUDA timeline, memory |
|
||||||
|
| **torchinfo** | auto | `{out}/model_summary.txt` | Layer-by-layer parameter table |
|
||||||
|
| **Gradient norms** | `--log-grad-norms` (default on) | TB/W&B | Per-group: MONA, LoRA, MLP, gates, tau |
|
||||||
|
| **CSV + plots** | auto | `{out}/logs/` | train.csv, val.csv, PNG plots every epoch |
|
||||||
|
|
||||||
## Decision rule
|
## Decision rule
|
||||||
|
|
||||||
| Delta R@1 (drone→satellite) | Verdict |
|
| Delta R@1 (drone→satellite) | Verdict |
|
||||||
|
|||||||
50
conf/gtauav_balanced.gin
Normal file
50
conf/gtauav_balanced.gin
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# GTA-UAV Balanced: GatedFusion with L1/L2/L3 captions on both branches.
|
||||||
|
# query = sigma(alpha) * drone + (1-sigma(alpha)) * text -> InfoNCE vs gallery
|
||||||
|
# 20 epochs, DINOv3 + DGTRS-CLIP, MONA + LoRA adapters.
|
||||||
|
|
||||||
|
import src.losses.multi_infonce
|
||||||
|
import src.training.train_gtauav
|
||||||
|
|
||||||
|
# ---- Training ----
|
||||||
|
TrainConfigGTAUAV.epochs = 10
|
||||||
|
TrainConfigGTAUAV.batch_size = 8
|
||||||
|
TrainConfigGTAUAV.num_workers = 4
|
||||||
|
TrainConfigGTAUAV.learning_rate = 1e-4
|
||||||
|
TrainConfigGTAUAV.text_lr_factor = 0.1
|
||||||
|
TrainConfigGTAUAV.weight_decay = 1e-4
|
||||||
|
TrainConfigGTAUAV.grad_clip = 1.0
|
||||||
|
TrainConfigGTAUAV.use_amp = True
|
||||||
|
TrainConfigGTAUAV.eval_every = 2
|
||||||
|
TrainConfigGTAUAV.warmup_epochs = 2
|
||||||
|
TrainConfigGTAUAV.seed = 42
|
||||||
|
TrainConfigGTAUAV.device = "cuda"
|
||||||
|
|
||||||
|
# ---- Model ----
|
||||||
|
TrainConfigGTAUAV.init_gate = 0.7
|
||||||
|
TrainConfigGTAUAV.baseline_mode = False
|
||||||
|
|
||||||
|
# ---- Loss ----
|
||||||
|
TrainConfigGTAUAV.tau_init = 0.07
|
||||||
|
TrainConfigGTAUAV.label_smoothing = 0.1
|
||||||
|
TrainConfigGTAUAV.weight_q2g = 0.6
|
||||||
|
TrainConfigGTAUAV.weight_g2q = 0.4
|
||||||
|
TrainConfigGTAUAV.learnable_temperature = True
|
||||||
|
|
||||||
|
# ---- Output ----
|
||||||
|
TrainConfigGTAUAV.output_dir = "out/gtauav/with_text"
|
||||||
|
|
||||||
|
# ---- Tracking ----
|
||||||
|
TrainConfigGTAUAV.use_wandb = False
|
||||||
|
TrainConfigGTAUAV.use_tb = True
|
||||||
|
TrainConfigGTAUAV.use_gradcam = True
|
||||||
|
TrainConfigGTAUAV.gradcam_every = 5
|
||||||
|
TrainConfigGTAUAV.use_profiler = False
|
||||||
|
TrainConfigGTAUAV.log_grad_norms = True
|
||||||
|
|
||||||
|
# ---- InfoNCE Loss (gin-configurable) ----
|
||||||
|
InfoNCELoss.temperature_init = 0.07
|
||||||
|
InfoNCELoss.temperature_final = 0.01
|
||||||
|
InfoNCELoss.label_smoothing = 0.1
|
||||||
|
InfoNCELoss.weight_q2g = 0.6
|
||||||
|
InfoNCELoss.weight_g2q = 0.4
|
||||||
|
InfoNCELoss.learnable_temperature = True
|
||||||
9
conf/gtauav_baseline.gin
Normal file
9
conf/gtauav_baseline.gin
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# GTA-UAV Baseline: no text fusion (gate forced to 1.0).
|
||||||
|
# query = drone_only -> InfoNCE vs satellite
|
||||||
|
# Reference R@1 for delta computation.
|
||||||
|
|
||||||
|
include 'conf/gtauav_balanced.gin'
|
||||||
|
|
||||||
|
TrainConfigGTAUAV.baseline_mode = True
|
||||||
|
TrainConfigGTAUAV.output_dir = "out/gtauav/baseline"
|
||||||
|
TrainConfigGTAUAV.use_gradcam = False
|
||||||
8
conf/gtauav_image_heavy.gin
Normal file
8
conf/gtauav_image_heavy.gin
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# GTA-UAV Image-heavy: gate initialized high (more image weight).
|
||||||
|
# query = sigma(0.9) * drone + 0.1 * text
|
||||||
|
# Minimal text contribution test.
|
||||||
|
|
||||||
|
include 'conf/gtauav_balanced.gin'
|
||||||
|
|
||||||
|
TrainConfigGTAUAV.init_gate = 0.9
|
||||||
|
TrainConfigGTAUAV.output_dir = "out/gtauav/image_heavy"
|
||||||
8
conf/gtauav_text_heavy.gin
Normal file
8
conf/gtauav_text_heavy.gin
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# GTA-UAV Text-heavy: gate initialized low (more text weight).
|
||||||
|
# query = sigma(0.3) * drone + 0.7 * text
|
||||||
|
# Stress test for text contribution.
|
||||||
|
|
||||||
|
include 'conf/gtauav_balanced.gin'
|
||||||
|
|
||||||
|
TrainConfigGTAUAV.init_gate = 0.3
|
||||||
|
TrainConfigGTAUAV.output_dir = "out/gtauav/text_heavy"
|
||||||
85
src/training/grad_monitor.py
Normal file
85
src/training/grad_monitor.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""Gradient monitoring for training diagnostics.
|
||||||
|
|
||||||
|
Computes per-group gradient norms after backward pass to detect
|
||||||
|
vanishing/exploding gradients in different components:
|
||||||
|
- MONA adapters (drone + satellite)
|
||||||
|
- LoRA (DGTRS-CLIP text encoder)
|
||||||
|
- TextFusionMLP
|
||||||
|
- GatedFusion gates (alpha_q, alpha_g)
|
||||||
|
- Learnable temperature (logit_scale)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import torch.nn as nn
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("caption_test.grad_monitor")
|
||||||
|
|
||||||
|
# Parameter group patterns for classification.
|
||||||
|
_GROUP_PATTERNS = {
|
||||||
|
"mona_drone": "drone_encoder",
|
||||||
|
"mona_sat": "sat_encoder",
|
||||||
|
"lora_text": "text_encoder",
|
||||||
|
"text_fusion_mlp": "text_fusion",
|
||||||
|
"gate_q": "fusion_query",
|
||||||
|
"gate_g": "fusion_gallery",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_gradient_norms(
|
||||||
|
model: nn.Module,
|
||||||
|
loss_fn: nn.Module | None = None,
|
||||||
|
) -> dict[str, float]:
|
||||||
|
"""Compute L2 gradient norms per parameter group.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model: The model after backward().
|
||||||
|
loss_fn: Loss module (for logit_scale gradient).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping group name to gradient L2 norm.
|
||||||
|
"""
|
||||||
|
group_grads: dict[str, list[torch.Tensor]] = {k: [] for k in _GROUP_PATTERNS}
|
||||||
|
group_grads["other"] = []
|
||||||
|
|
||||||
|
for name, param in model.named_parameters():
|
||||||
|
if not param.requires_grad or param.grad is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
matched = False
|
||||||
|
for group_name, pattern in _GROUP_PATTERNS.items():
|
||||||
|
if pattern in name:
|
||||||
|
group_grads[group_name].append(param.grad.detach().flatten())
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
if not matched:
|
||||||
|
group_grads["other"].append(param.grad.detach().flatten())
|
||||||
|
|
||||||
|
norms: dict[str, float] = {}
|
||||||
|
for group_name, grads in group_grads.items():
|
||||||
|
if grads:
|
||||||
|
all_grads = torch.cat(grads)
|
||||||
|
norms[f"grad_norm/{group_name}"] = float(all_grads.norm(2).item())
|
||||||
|
norms[f"grad_max/{group_name}"] = float(all_grads.abs().max().item())
|
||||||
|
|
||||||
|
# Logit scale gradient (from loss module).
|
||||||
|
if loss_fn is not None and hasattr(loss_fn, "logit_scale"):
|
||||||
|
ls = loss_fn.logit_scale
|
||||||
|
if ls is not None and ls.grad is not None:
|
||||||
|
norms["grad_norm/logit_scale"] = float(ls.grad.detach().abs().item())
|
||||||
|
|
||||||
|
return norms
|
||||||
|
|
||||||
|
|
||||||
|
def log_gradient_summary(norms: dict[str, float]) -> None:
|
||||||
|
"""Log gradient norms summary to console."""
|
||||||
|
norm_parts = []
|
||||||
|
for k, v in sorted(norms.items()):
|
||||||
|
if k.startswith("grad_norm/"):
|
||||||
|
name = k.replace("grad_norm/", "")
|
||||||
|
norm_parts.append(f"{name}={v:.4f}")
|
||||||
|
if norm_parts:
|
||||||
|
LOGGER.info("grad norms: %s", " ".join(norm_parts))
|
||||||
241
src/training/gradcam.py
Normal file
241
src/training/gradcam.py
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""Grad-CAM visualization for DINOv3 ViT encoders.
|
||||||
|
|
||||||
|
Generates attention heatmaps overlaid on input images to show
|
||||||
|
which regions the model focuses on for drone/satellite matching.
|
||||||
|
|
||||||
|
Hook target: last DINOv3Block output (before final LayerNorm).
|
||||||
|
Uses patch tokens (excluding CLS + registers) to build spatial map.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
cam = DINOv3GradCAM(model.drone_encoder, target_layer_idx=-1)
|
||||||
|
heatmap = cam.generate(image_tensor, class_idx=None) # [H, W] numpy
|
||||||
|
overlay = cam.overlay(image_tensor, heatmap) # [3, H, W] torch
|
||||||
|
cam.remove_hooks()
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
import torch.nn as nn
|
||||||
|
import torch.nn.functional as F
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("caption_test.gradcam")
|
||||||
|
|
||||||
|
|
||||||
|
class DINOv3GradCAM:
|
||||||
|
"""Grad-CAM for DINOv3 ViT encoder.
|
||||||
|
|
||||||
|
Hooks into a specific transformer block to capture activations
|
||||||
|
and gradients, then produces spatial attention heatmaps.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encoder: DINOv3ViT model instance.
|
||||||
|
target_layer_idx: Which block to hook (-1 = last block).
|
||||||
|
num_registers: Number of register tokens (skipped in spatial map).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
encoder: nn.Module,
|
||||||
|
target_layer_idx: int = -1,
|
||||||
|
num_registers: int = 4,
|
||||||
|
) -> None:
|
||||||
|
self.encoder = encoder
|
||||||
|
self.num_registers = num_registers
|
||||||
|
self._activations: torch.Tensor | None = None
|
||||||
|
self._gradients: torch.Tensor | None = None
|
||||||
|
|
||||||
|
# Hook into the target block.
|
||||||
|
target_block = encoder.layer[target_layer_idx]
|
||||||
|
self._fwd_hook = target_block.register_forward_hook(self._save_activation)
|
||||||
|
self._bwd_hook = target_block.register_full_backward_hook(self._save_gradient)
|
||||||
|
|
||||||
|
def _save_activation(self, module: nn.Module, input: Any, output: torch.Tensor) -> None:
|
||||||
|
self._activations = output.detach()
|
||||||
|
|
||||||
|
def _save_gradient(self, module: nn.Module, grad_input: Any, grad_output: tuple) -> None:
|
||||||
|
self._gradients = grad_output[0].detach()
|
||||||
|
|
||||||
|
@torch.enable_grad()
|
||||||
|
def generate(
|
||||||
|
self,
|
||||||
|
image: torch.Tensor,
|
||||||
|
target_embedding: torch.Tensor | None = None,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Generate Grad-CAM heatmap for a single image.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image: Input image [1, 3, H, W].
|
||||||
|
target_embedding: Optional target to maximize similarity against.
|
||||||
|
If None, uses the CLS token norm as the scalar output.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Heatmap [H_patches, W_patches] as numpy array, values in [0, 1].
|
||||||
|
"""
|
||||||
|
self.encoder.zero_grad()
|
||||||
|
image = image.requires_grad_(True)
|
||||||
|
|
||||||
|
# Forward through the full encoder.
|
||||||
|
cls_token = self.encoder(image) # [1, D]
|
||||||
|
|
||||||
|
# Scalar to backprop from.
|
||||||
|
if target_embedding is not None:
|
||||||
|
score = (cls_token * target_embedding).sum()
|
||||||
|
else:
|
||||||
|
score = cls_token.norm(dim=-1).sum()
|
||||||
|
|
||||||
|
score.backward(retain_graph=True)
|
||||||
|
|
||||||
|
if self._activations is None or self._gradients is None:
|
||||||
|
LOGGER.warning("No activations/gradients captured")
|
||||||
|
return np.zeros((16, 16), dtype=np.float32)
|
||||||
|
|
||||||
|
# Gradients: [1, N_tokens, D] — average over token dim for weights.
|
||||||
|
weights = self._gradients.mean(dim=1, keepdim=True) # [1, 1, D]
|
||||||
|
|
||||||
|
# Weighted activation map.
|
||||||
|
# Skip CLS (idx 0) + registers (idx 1..num_registers).
|
||||||
|
n_special = 1 + self.num_registers
|
||||||
|
patch_activations = self._activations[:, n_special:, :] # [1, N_patches, D]
|
||||||
|
|
||||||
|
cam = (patch_activations * weights).sum(dim=-1) # [1, N_patches]
|
||||||
|
cam = F.relu(cam) # Only positive contributions.
|
||||||
|
|
||||||
|
# Reshape to spatial grid.
|
||||||
|
n_patches = cam.shape[1]
|
||||||
|
h = w = int(n_patches ** 0.5)
|
||||||
|
cam = cam.reshape(1, 1, h, w)
|
||||||
|
|
||||||
|
# Normalize to [0, 1].
|
||||||
|
cam = cam - cam.min()
|
||||||
|
cam_max = cam.max()
|
||||||
|
if cam_max > 0:
|
||||||
|
cam = cam / cam_max
|
||||||
|
|
||||||
|
return cam.squeeze().cpu().numpy()
|
||||||
|
|
||||||
|
def overlay(
|
||||||
|
self,
|
||||||
|
image: torch.Tensor,
|
||||||
|
heatmap: np.ndarray,
|
||||||
|
alpha: float = 0.5,
|
||||||
|
colormap: str = "jet",
|
||||||
|
) -> torch.Tensor:
|
||||||
|
"""Overlay heatmap on image.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
image: Original image [1, 3, H, W] or [3, H, W] (ImageNet normalized).
|
||||||
|
heatmap: Grad-CAM heatmap [h, w] in [0, 1].
|
||||||
|
alpha: Overlay transparency.
|
||||||
|
colormap: Matplotlib colormap name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Overlaid image [3, H, W] as torch tensor, values in [0, 1].
|
||||||
|
"""
|
||||||
|
import matplotlib.cm as cm
|
||||||
|
|
||||||
|
if image.dim() == 4:
|
||||||
|
image = image.squeeze(0)
|
||||||
|
|
||||||
|
_, H, W = image.shape
|
||||||
|
|
||||||
|
# Denormalize from ImageNet stats.
|
||||||
|
mean = torch.tensor([0.485, 0.456, 0.406]).view(3, 1, 1).to(image.device)
|
||||||
|
std = torch.tensor([0.229, 0.224, 0.225]).view(3, 1, 1).to(image.device)
|
||||||
|
img = (image * std + mean).clamp(0, 1).cpu().numpy().transpose(1, 2, 0) # [H, W, 3]
|
||||||
|
|
||||||
|
# Resize heatmap to image size.
|
||||||
|
heatmap_resized = torch.tensor(heatmap).unsqueeze(0).unsqueeze(0).float()
|
||||||
|
heatmap_resized = F.interpolate(heatmap_resized, size=(H, W), mode="bilinear", align_corners=False)
|
||||||
|
heatmap_np = heatmap_resized.squeeze().numpy()
|
||||||
|
|
||||||
|
# Apply colormap.
|
||||||
|
cmap = cm.get_cmap(colormap)
|
||||||
|
heatmap_colored = cmap(heatmap_np)[:, :, :3] # [H, W, 3]
|
||||||
|
|
||||||
|
# Blend.
|
||||||
|
overlay = alpha * heatmap_colored + (1 - alpha) * img
|
||||||
|
overlay = np.clip(overlay, 0, 1)
|
||||||
|
|
||||||
|
return torch.from_numpy(overlay.transpose(2, 0, 1)).float()
|
||||||
|
|
||||||
|
def remove_hooks(self) -> None:
|
||||||
|
"""Remove forward/backward hooks."""
|
||||||
|
self._fwd_hook.remove()
|
||||||
|
self._bwd_hook.remove()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_gradcam_samples(
|
||||||
|
model: nn.Module,
|
||||||
|
dataloader: torch.utils.data.DataLoader,
|
||||||
|
device: str,
|
||||||
|
output_dir: str,
|
||||||
|
n_samples: int = 8,
|
||||||
|
epoch: int = 0,
|
||||||
|
) -> list[torch.Tensor]:
|
||||||
|
"""Generate Grad-CAM visualizations for a batch of samples.
|
||||||
|
|
||||||
|
Creates overlaid heatmaps for both drone and satellite encoders.
|
||||||
|
Saves to output_dir/gradcam/epoch_{N}/.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model: AsymmetricEncoder instance.
|
||||||
|
dataloader: Validation dataloader.
|
||||||
|
device: Torch device.
|
||||||
|
output_dir: Base output directory.
|
||||||
|
n_samples: Number of samples to visualize.
|
||||||
|
epoch: Current epoch (for naming).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of overlay tensors [3, H, W] for logging.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
|
||||||
|
save_dir = Path(output_dir) / "gradcam" / f"epoch_{epoch:03d}"
|
||||||
|
save_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
model.eval()
|
||||||
|
cam_drone = DINOv3GradCAM(model.drone_encoder, target_layer_idx=-1)
|
||||||
|
cam_sat = DINOv3GradCAM(model.sat_encoder, target_layer_idx=-1)
|
||||||
|
|
||||||
|
overlays = []
|
||||||
|
batch = next(iter(dataloader))
|
||||||
|
drone_imgs = batch["drone_img"][:n_samples].to(device)
|
||||||
|
sat_imgs = batch["sat_img"][:n_samples].to(device)
|
||||||
|
|
||||||
|
for i in range(min(n_samples, drone_imgs.shape[0])):
|
||||||
|
drone_img = drone_imgs[i:i+1]
|
||||||
|
sat_img = sat_imgs[i:i+1]
|
||||||
|
|
||||||
|
# Drone Grad-CAM.
|
||||||
|
heatmap_d = cam_drone.generate(drone_img)
|
||||||
|
overlay_d = cam_drone.overlay(drone_img, heatmap_d)
|
||||||
|
overlays.append(overlay_d)
|
||||||
|
|
||||||
|
# Satellite Grad-CAM.
|
||||||
|
heatmap_s = cam_sat.generate(sat_img)
|
||||||
|
overlay_s = cam_sat.overlay(sat_img, heatmap_s)
|
||||||
|
overlays.append(overlay_s)
|
||||||
|
|
||||||
|
# Save as side-by-side figure.
|
||||||
|
fig, axes = plt.subplots(1, 2, figsize=(10, 5))
|
||||||
|
axes[0].imshow(overlay_d.permute(1, 2, 0).numpy())
|
||||||
|
axes[0].set_title(f"Drone #{i}")
|
||||||
|
axes[0].axis("off")
|
||||||
|
axes[1].imshow(overlay_s.permute(1, 2, 0).numpy())
|
||||||
|
axes[1].set_title(f"Satellite #{i}")
|
||||||
|
axes[1].axis("off")
|
||||||
|
plt.tight_layout()
|
||||||
|
fig.savefig(save_dir / f"sample_{i:03d}.png", dpi=150, bbox_inches="tight")
|
||||||
|
plt.close(fig)
|
||||||
|
|
||||||
|
cam_drone.remove_hooks()
|
||||||
|
cam_sat.remove_hooks()
|
||||||
|
|
||||||
|
LOGGER.info("Grad-CAM: saved %d samples to %s", len(overlays) // 2, save_dir)
|
||||||
|
return overlays
|
||||||
166
src/training/profiling.py
Normal file
166
src/training/profiling.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""PyTorch Profiler wrapper for training performance analysis.
|
||||||
|
|
||||||
|
Profiles the first N batches of training to identify bottlenecks
|
||||||
|
in CUDA/CPU execution, memory allocation, and data loading.
|
||||||
|
|
||||||
|
Exports:
|
||||||
|
- Chrome trace (viewable in chrome://tracing)
|
||||||
|
- TensorBoard plugin data (if TB available)
|
||||||
|
- Summary table to console
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
profiler = TrainingProfiler(output_dir, n_warmup=3, n_active=5)
|
||||||
|
for batch_idx, batch in enumerate(loader):
|
||||||
|
with profiler.step_context(batch_idx):
|
||||||
|
# ... training step ...
|
||||||
|
if profiler.is_done(batch_idx):
|
||||||
|
break
|
||||||
|
profiler.export()
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import torch
|
||||||
|
from torch.profiler import ProfilerActivity, profile, schedule, tensorboard_trace_handler
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("caption_test.profiler")
|
||||||
|
|
||||||
|
|
||||||
|
class TrainingProfiler:
|
||||||
|
"""PyTorch profiler for first N training batches.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_dir: Directory for profiler output.
|
||||||
|
n_warmup: Number of warmup steps (not profiled).
|
||||||
|
n_active: Number of steps to actively profile.
|
||||||
|
n_repeat: Number of profiling cycles.
|
||||||
|
record_shapes: Record tensor shapes.
|
||||||
|
profile_memory: Track memory allocation.
|
||||||
|
with_stack: Record Python call stacks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
output_dir: str | Path,
|
||||||
|
n_warmup: int = 3,
|
||||||
|
n_active: int = 5,
|
||||||
|
n_repeat: int = 1,
|
||||||
|
record_shapes: bool = True,
|
||||||
|
profile_memory: bool = True,
|
||||||
|
with_stack: bool = False,
|
||||||
|
) -> None:
|
||||||
|
self.output_dir = Path(output_dir) / "profiler"
|
||||||
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.n_warmup = n_warmup
|
||||||
|
self.n_active = n_active
|
||||||
|
self.n_repeat = n_repeat
|
||||||
|
self.total_steps = (n_warmup + n_active) * n_repeat
|
||||||
|
|
||||||
|
self._profiler = profile(
|
||||||
|
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
|
||||||
|
schedule=schedule(
|
||||||
|
wait=0,
|
||||||
|
warmup=n_warmup,
|
||||||
|
active=n_active,
|
||||||
|
repeat=n_repeat,
|
||||||
|
),
|
||||||
|
on_trace_ready=tensorboard_trace_handler(str(self.output_dir)),
|
||||||
|
record_shapes=record_shapes,
|
||||||
|
profile_memory=profile_memory,
|
||||||
|
with_stack=with_stack,
|
||||||
|
)
|
||||||
|
self._started = False
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Start the profiler."""
|
||||||
|
self._profiler.__enter__()
|
||||||
|
self._started = True
|
||||||
|
LOGGER.info(
|
||||||
|
"Profiler started: %d warmup + %d active steps, output: %s",
|
||||||
|
self.n_warmup, self.n_active, self.output_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
def step(self) -> None:
|
||||||
|
"""Signal end of a profiling step."""
|
||||||
|
if self._started:
|
||||||
|
self._profiler.step()
|
||||||
|
|
||||||
|
def is_done(self, batch_idx: int) -> bool:
|
||||||
|
"""Check if profiling is complete."""
|
||||||
|
return batch_idx >= self.total_steps
|
||||||
|
|
||||||
|
def export(self) -> None:
|
||||||
|
"""Export profiling results and print summary."""
|
||||||
|
if not self._started:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._profiler.__exit__(None, None, None)
|
||||||
|
self._started = False
|
||||||
|
|
||||||
|
# Print key averages summary.
|
||||||
|
summary = self._profiler.key_averages().table(
|
||||||
|
sort_by="cuda_time_total", row_limit=20,
|
||||||
|
)
|
||||||
|
LOGGER.info("Profiler summary (top 20 by CUDA time):\n%s", summary)
|
||||||
|
|
||||||
|
# Export Chrome trace.
|
||||||
|
trace_path = self.output_dir / "chrome_trace.json"
|
||||||
|
self._profiler.export_chrome_trace(str(trace_path))
|
||||||
|
LOGGER.info("Chrome trace exported: %s", trace_path)
|
||||||
|
|
||||||
|
# Memory summary if available.
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
mem_summary = torch.cuda.memory_summary(abbreviated=True)
|
||||||
|
summary_path = self.output_dir / "memory_summary.txt"
|
||||||
|
summary_path.write_text(mem_summary)
|
||||||
|
LOGGER.info("CUDA memory summary: %s", summary_path)
|
||||||
|
|
||||||
|
|
||||||
|
def print_model_summary(model: torch.nn.Module, device: str = "cuda") -> str:
|
||||||
|
"""Print model summary using torchinfo (if available).
|
||||||
|
|
||||||
|
Falls back to a simple parameter count if torchinfo is not installed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Summary string.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from torchinfo import summary as torchinfo_summary
|
||||||
|
info = torchinfo_summary(
|
||||||
|
model,
|
||||||
|
input_data={
|
||||||
|
"drone_img": torch.randn(1, 3, 256, 256, device=device),
|
||||||
|
"sat_img": torch.randn(1, 3, 256, 256, device=device),
|
||||||
|
},
|
||||||
|
col_names=["input_size", "output_size", "num_params", "trainable"],
|
||||||
|
verbose=0,
|
||||||
|
depth=3,
|
||||||
|
)
|
||||||
|
summary_str = str(info)
|
||||||
|
LOGGER.info("Model summary (torchinfo):\n%s", summary_str)
|
||||||
|
return summary_str
|
||||||
|
except ImportError:
|
||||||
|
LOGGER.info("torchinfo not installed, using basic parameter count")
|
||||||
|
except Exception as e:
|
||||||
|
LOGGER.warning("torchinfo failed (%s), using basic parameter count", e)
|
||||||
|
|
||||||
|
# Fallback: simple param count.
|
||||||
|
lines = []
|
||||||
|
total = 0
|
||||||
|
trainable = 0
|
||||||
|
for name, param in model.named_parameters():
|
||||||
|
total += param.numel()
|
||||||
|
if param.requires_grad:
|
||||||
|
trainable += param.numel()
|
||||||
|
lines.append(f" [trainable] {name}: {list(param.shape)} ({param.numel():,})")
|
||||||
|
|
||||||
|
summary_str = (
|
||||||
|
f"Total parameters: {total:,}\n"
|
||||||
|
f"Trainable parameters: {trainable:,} ({100*trainable/max(total,1):.2f}%)\n"
|
||||||
|
+ "\n".join(lines[:30])
|
||||||
|
)
|
||||||
|
LOGGER.info("Model summary:\n%s", summary_str)
|
||||||
|
return summary_str
|
||||||
206
src/training/trackers.py
Normal file
206
src/training/trackers.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""Unified experiment tracking: W&B + TensorBoard + CSV.
|
||||||
|
|
||||||
|
Auto-detects available backends. Falls back gracefully if wandb/tensorboard
|
||||||
|
are not installed.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
tracker = ExperimentTracker(output_dir, config_dict, use_wandb=True, use_tb=True)
|
||||||
|
tracker.log_train(epoch, {"loss": 0.5, "lr": 1e-4})
|
||||||
|
tracker.log_val(epoch, {"r@1_q2g": 0.3})
|
||||||
|
tracker.log_gradients(epoch, grad_norms_dict)
|
||||||
|
tracker.log_image(epoch, "gradcam/drone", image_tensor)
|
||||||
|
tracker.close()
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import torch
|
||||||
|
|
||||||
|
LOGGER = logging.getLogger("caption_test.trackers")
|
||||||
|
|
||||||
|
|
||||||
|
def _try_import_wandb():
|
||||||
|
try:
|
||||||
|
import wandb
|
||||||
|
return wandb
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _try_import_tb():
|
||||||
|
try:
|
||||||
|
from torch.utils.tensorboard import SummaryWriter
|
||||||
|
return SummaryWriter
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ExperimentTracker:
|
||||||
|
"""Unified tracker dispatching to W&B, TensorBoard, and CSV.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
output_dir: Base output directory.
|
||||||
|
config: Dict of hyperparameters to log.
|
||||||
|
use_wandb: Enable Weights & Biases tracking.
|
||||||
|
use_tb: Enable TensorBoard tracking.
|
||||||
|
wandb_project: W&B project name.
|
||||||
|
wandb_run_name: W&B run name (auto-generated if None).
|
||||||
|
wandb_entity: W&B entity (team/user).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
output_dir: str | Path,
|
||||||
|
config: dict[str, Any] | None = None,
|
||||||
|
use_wandb: bool = False,
|
||||||
|
use_tb: bool = True,
|
||||||
|
wandb_project: str = "caption-test-gtauav",
|
||||||
|
wandb_run_name: str | None = None,
|
||||||
|
wandb_entity: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.output_dir = Path(output_dir)
|
||||||
|
self._wandb_run = None
|
||||||
|
self._tb_writer = None
|
||||||
|
|
||||||
|
# W&B init.
|
||||||
|
if use_wandb:
|
||||||
|
wandb = _try_import_wandb()
|
||||||
|
if wandb is not None:
|
||||||
|
self._wandb_run = wandb.init(
|
||||||
|
project=wandb_project,
|
||||||
|
name=wandb_run_name,
|
||||||
|
entity=wandb_entity,
|
||||||
|
config=config or {},
|
||||||
|
dir=str(self.output_dir),
|
||||||
|
reinit=True,
|
||||||
|
)
|
||||||
|
LOGGER.info("W&B initialized: %s", self._wandb_run.url)
|
||||||
|
else:
|
||||||
|
LOGGER.warning("wandb not installed, skipping W&B tracking")
|
||||||
|
|
||||||
|
# TensorBoard init.
|
||||||
|
if use_tb:
|
||||||
|
SummaryWriter = _try_import_tb()
|
||||||
|
if SummaryWriter is not None:
|
||||||
|
tb_dir = self.output_dir / "tb_logs"
|
||||||
|
tb_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._tb_writer = SummaryWriter(log_dir=str(tb_dir))
|
||||||
|
LOGGER.info("TensorBoard initialized: %s", tb_dir)
|
||||||
|
else:
|
||||||
|
LOGGER.warning("tensorboard not installed, skipping TB tracking")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_wandb(self) -> bool:
|
||||||
|
return self._wandb_run is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_tb(self) -> bool:
|
||||||
|
return self._tb_writer is not None
|
||||||
|
|
||||||
|
def log_train(self, epoch: int, metrics: dict[str, float], step: int | None = None) -> None:
|
||||||
|
"""Log training metrics for an epoch."""
|
||||||
|
if self._wandb_run is not None:
|
||||||
|
self._wandb_run.log(
|
||||||
|
{f"train/{k}": v for k, v in metrics.items()},
|
||||||
|
step=step or epoch,
|
||||||
|
)
|
||||||
|
if self._tb_writer is not None:
|
||||||
|
for k, v in metrics.items():
|
||||||
|
self._tb_writer.add_scalar(f"train/{k}", v, global_step=step or epoch)
|
||||||
|
|
||||||
|
def log_val(self, epoch: int, metrics: dict[str, float], step: int | None = None) -> None:
|
||||||
|
"""Log validation metrics."""
|
||||||
|
if self._wandb_run is not None:
|
||||||
|
self._wandb_run.log(
|
||||||
|
{f"val/{k}": v for k, v in metrics.items()},
|
||||||
|
step=step or epoch,
|
||||||
|
)
|
||||||
|
if self._tb_writer is not None:
|
||||||
|
for k, v in metrics.items():
|
||||||
|
self._tb_writer.add_scalar(f"val/{k}", v, global_step=step or epoch)
|
||||||
|
|
||||||
|
def log_gradients(self, epoch: int, grad_norms: dict[str, float], step: int | None = None) -> None:
|
||||||
|
"""Log gradient norms per parameter group."""
|
||||||
|
if self._wandb_run is not None:
|
||||||
|
self._wandb_run.log(
|
||||||
|
{f"gradients/{k}": v for k, v in grad_norms.items()},
|
||||||
|
step=step or epoch,
|
||||||
|
)
|
||||||
|
if self._tb_writer is not None:
|
||||||
|
for k, v in grad_norms.items():
|
||||||
|
self._tb_writer.add_scalar(f"gradients/{k}", v, global_step=step or epoch)
|
||||||
|
|
||||||
|
def log_scalar(self, tag: str, value: float, step: int) -> None:
|
||||||
|
"""Log a single scalar."""
|
||||||
|
if self._wandb_run is not None:
|
||||||
|
self._wandb_run.log({tag: value}, step=step)
|
||||||
|
if self._tb_writer is not None:
|
||||||
|
self._tb_writer.add_scalar(tag, value, global_step=step)
|
||||||
|
|
||||||
|
def log_image(self, tag: str, image: Any, step: int, caption: str | None = None) -> None:
|
||||||
|
"""Log an image (numpy HWC or torch CHW).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tag: Image tag/name.
|
||||||
|
image: numpy array [H,W,C] or torch tensor [C,H,W].
|
||||||
|
step: Global step.
|
||||||
|
caption: Optional caption for W&B.
|
||||||
|
"""
|
||||||
|
if self._wandb_run is not None:
|
||||||
|
wandb = _try_import_wandb()
|
||||||
|
if isinstance(image, torch.Tensor):
|
||||||
|
image_np = image.detach().cpu().permute(1, 2, 0).numpy()
|
||||||
|
else:
|
||||||
|
image_np = image
|
||||||
|
self._wandb_run.log(
|
||||||
|
{tag: wandb.Image(image_np, caption=caption)},
|
||||||
|
step=step,
|
||||||
|
)
|
||||||
|
if self._tb_writer is not None:
|
||||||
|
if isinstance(image, torch.Tensor):
|
||||||
|
self._tb_writer.add_image(tag, image.detach().cpu(), global_step=step)
|
||||||
|
else:
|
||||||
|
self._tb_writer.add_image(tag, image, global_step=step, dataformats="HWC")
|
||||||
|
|
||||||
|
def log_histogram(self, tag: str, values: torch.Tensor, step: int) -> None:
|
||||||
|
"""Log a histogram of values (weights, activations, etc.)."""
|
||||||
|
if self._wandb_run is not None:
|
||||||
|
wandb = _try_import_wandb()
|
||||||
|
self._wandb_run.log(
|
||||||
|
{tag: wandb.Histogram(values.detach().cpu().numpy())},
|
||||||
|
step=step,
|
||||||
|
)
|
||||||
|
if self._tb_writer is not None:
|
||||||
|
self._tb_writer.add_histogram(tag, values.detach().cpu(), global_step=step)
|
||||||
|
|
||||||
|
def log_model_graph(self, model: torch.nn.Module, input_example: Any = None) -> None:
|
||||||
|
"""Log model graph to TensorBoard (if available)."""
|
||||||
|
if self._tb_writer is not None and input_example is not None:
|
||||||
|
try:
|
||||||
|
self._tb_writer.add_graph(model, input_example)
|
||||||
|
except Exception as e:
|
||||||
|
LOGGER.warning("Failed to log model graph: %s", e)
|
||||||
|
|
||||||
|
def watch_model(self, model: torch.nn.Module, log_freq: int = 100) -> None:
|
||||||
|
"""Enable W&B gradient/weight watching."""
|
||||||
|
if self._wandb_run is not None:
|
||||||
|
wandb = _try_import_wandb()
|
||||||
|
wandb.watch(model, log="all", log_freq=log_freq)
|
||||||
|
|
||||||
|
def log_summary(self, summary: dict[str, Any]) -> None:
|
||||||
|
"""Log final summary metrics (best R@1, etc.)."""
|
||||||
|
if self._wandb_run is not None:
|
||||||
|
for k, v in summary.items():
|
||||||
|
self._wandb_run.summary[k] = v
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Flush and close all backends."""
|
||||||
|
if self._tb_writer is not None:
|
||||||
|
self._tb_writer.flush()
|
||||||
|
self._tb_writer.close()
|
||||||
|
if self._wandb_run is not None:
|
||||||
|
self._wandb_run.finish()
|
||||||
@@ -4,6 +4,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
Asymmetric DINOv3 encoders (drone LVD + satellite SAT) with LRSCLIP text fusion.
|
Asymmetric DINOv3 encoders (drone LVD + satellite SAT) with LRSCLIP text fusion.
|
||||||
Single InfoNCE loss: query(drone+text) vs gallery(satellite).
|
Single InfoNCE loss: query(drone+text) vs gallery(satellite).
|
||||||
|
|
||||||
|
Supports gin-config, W&B, TensorBoard, Grad-CAM, gradient monitoring,
|
||||||
|
PyTorch Profiler, and torchinfo model summary.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -16,6 +19,7 @@ from dataclasses import dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import coloredlogs
|
import coloredlogs
|
||||||
|
import gin
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import torch
|
import torch
|
||||||
import torch.nn as nn
|
import torch.nn as nn
|
||||||
@@ -28,6 +32,9 @@ 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.training.plot_metrics import generate_plots
|
from src.training.plot_metrics import generate_plots
|
||||||
|
from src.training.trackers import ExperimentTracker
|
||||||
|
from src.training.grad_monitor import compute_gradient_norms, log_gradient_summary
|
||||||
|
from src.training.profiling import TrainingProfiler, print_model_summary
|
||||||
from src.models.asymmetric_encoder import (
|
from src.models.asymmetric_encoder import (
|
||||||
AsymmetricEncoder,
|
AsymmetricEncoder,
|
||||||
get_dino_transform,
|
get_dino_transform,
|
||||||
@@ -48,6 +55,7 @@ _DINO_SAT = "nn_models/DINO_SAT/model.safetensors"
|
|||||||
_LRSCLIP = "nn_models/LRSCLIP/DGTRS-CLIP-ViT-L-14.pt"
|
_LRSCLIP = "nn_models/LRSCLIP/DGTRS-CLIP-ViT-L-14.pt"
|
||||||
|
|
||||||
|
|
||||||
|
@gin.configurable
|
||||||
@dataclass
|
@dataclass
|
||||||
class TrainConfigGTAUAV:
|
class TrainConfigGTAUAV:
|
||||||
"""Training configuration for GTA-UAV experiment."""
|
"""Training configuration for GTA-UAV experiment."""
|
||||||
@@ -69,7 +77,7 @@ class TrainConfigGTAUAV:
|
|||||||
# Training.
|
# Training.
|
||||||
resume_from: str | None = None # path to checkpoint for resuming
|
resume_from: str | None = None # path to checkpoint for resuming
|
||||||
output_dir: str = "out/gtauav/with_text"
|
output_dir: str = "out/gtauav/with_text"
|
||||||
epochs: int = 20
|
epochs: int = 10
|
||||||
batch_size: int = 8
|
batch_size: int = 8
|
||||||
num_workers: int = 4
|
num_workers: int = 4
|
||||||
learning_rate: float = 1e-4
|
learning_rate: float = 1e-4
|
||||||
@@ -89,6 +97,20 @@ class TrainConfigGTAUAV:
|
|||||||
weight_g2q: float = 0.4
|
weight_g2q: float = 0.4
|
||||||
learnable_temperature: bool = True
|
learnable_temperature: bool = True
|
||||||
|
|
||||||
|
# Tracking & diagnostics.
|
||||||
|
use_wandb: bool = False
|
||||||
|
use_tb: bool = True
|
||||||
|
wandb_project: str = "caption-test-gtauav"
|
||||||
|
wandb_run_name: str | None = None
|
||||||
|
wandb_entity: str | None = None
|
||||||
|
log_grad_norms: bool = True
|
||||||
|
use_gradcam: bool = False
|
||||||
|
gradcam_every: int = 5 # Grad-CAM every N epochs
|
||||||
|
gradcam_samples: int = 8
|
||||||
|
use_profiler: bool = False
|
||||||
|
profiler_warmup: int = 3
|
||||||
|
profiler_active: int = 5
|
||||||
|
|
||||||
|
|
||||||
def _set_seed(seed: int) -> None:
|
def _set_seed(seed: int) -> None:
|
||||||
import random as _random
|
import random as _random
|
||||||
@@ -157,7 +179,7 @@ def _evaluate(
|
|||||||
all_query: list[torch.Tensor] = []
|
all_query: list[torch.Tensor] = []
|
||||||
all_gallery: list[torch.Tensor] = []
|
all_gallery: list[torch.Tensor] = []
|
||||||
|
|
||||||
for batch in tqdm(loader, desc=" 🔎 Evaluating", unit="batch", leave=False):
|
for batch in tqdm(loader, desc=" eval", unit="batch", leave=False):
|
||||||
drone_img = batch["drone_img"].to(device, non_blocking=True)
|
drone_img = batch["drone_img"].to(device, non_blocking=True)
|
||||||
sat_img = batch["sat_img"].to(device, non_blocking=True)
|
sat_img = batch["sat_img"].to(device, non_blocking=True)
|
||||||
|
|
||||||
@@ -240,7 +262,7 @@ def _clear_vram() -> None:
|
|||||||
torch.cuda.empty_cache()
|
torch.cuda.empty_cache()
|
||||||
torch.cuda.reset_peak_memory_stats()
|
torch.cuda.reset_peak_memory_stats()
|
||||||
allocated = torch.cuda.memory_allocated() / 1e9
|
allocated = torch.cuda.memory_allocated() / 1e9
|
||||||
LOGGER.info("🧹 VRAM cleared. Current usage: %.2f GB", allocated)
|
LOGGER.info("VRAM cleared. Current usage: %.2f GB", allocated)
|
||||||
|
|
||||||
|
|
||||||
def train(cfg: TrainConfigGTAUAV) -> None:
|
def train(cfg: TrainConfigGTAUAV) -> None:
|
||||||
@@ -259,12 +281,23 @@ def train(cfg: TrainConfigGTAUAV) -> None:
|
|||||||
with (output_dir / "config.json").open("w") as f:
|
with (output_dir / "config.json").open("w") as f:
|
||||||
json.dump(vars(cfg), f, indent=2)
|
json.dump(vars(cfg), f, indent=2)
|
||||||
|
|
||||||
|
# --- Experiment tracker (W&B + TensorBoard) ---
|
||||||
|
tracker = ExperimentTracker(
|
||||||
|
output_dir=output_dir,
|
||||||
|
config=vars(cfg),
|
||||||
|
use_wandb=cfg.use_wandb,
|
||||||
|
use_tb=cfg.use_tb,
|
||||||
|
wandb_project=cfg.wandb_project,
|
||||||
|
wandb_run_name=cfg.wandb_run_name,
|
||||||
|
wandb_entity=cfg.wandb_entity,
|
||||||
|
)
|
||||||
|
|
||||||
# Model.
|
# Model.
|
||||||
start_epoch = 0
|
start_epoch = 0
|
||||||
resume_ckpt = None
|
resume_ckpt = None
|
||||||
|
|
||||||
if cfg.resume_from is not None:
|
if cfg.resume_from is not None:
|
||||||
LOGGER.info("🔄 Resuming from %s", cfg.resume_from)
|
LOGGER.info("Resuming from %s", cfg.resume_from)
|
||||||
model, resume_ckpt = AsymmetricEncoder.load_checkpoint(
|
model, resume_ckpt = AsymmetricEncoder.load_checkpoint(
|
||||||
cfg.resume_from,
|
cfg.resume_from,
|
||||||
dino_web_path=cfg.dino_web_path,
|
dino_web_path=cfg.dino_web_path,
|
||||||
@@ -274,8 +307,8 @@ def train(cfg: TrainConfigGTAUAV) -> None:
|
|||||||
)
|
)
|
||||||
start_epoch = resume_ckpt.get("epoch", -1) + 1
|
start_epoch = resume_ckpt.get("epoch", -1) + 1
|
||||||
else:
|
else:
|
||||||
mode_str = "🚫 baseline (no text)" if cfg.baseline_mode else "📝 with text (L1/L2/L3)"
|
mode_str = "baseline (no text)" if cfg.baseline_mode else "with text (L1/L2/L3)"
|
||||||
LOGGER.info("🏗️ Building model — %s", mode_str)
|
LOGGER.info("Building model — %s", mode_str)
|
||||||
model = AsymmetricEncoder(
|
model = AsymmetricEncoder(
|
||||||
dino_web_path=cfg.dino_web_path,
|
dino_web_path=cfg.dino_web_path,
|
||||||
dino_sat_path=cfg.dino_sat_path,
|
dino_sat_path=cfg.dino_sat_path,
|
||||||
@@ -288,10 +321,18 @@ def train(cfg: TrainConfigGTAUAV) -> None:
|
|||||||
n_trainable = sum(p.numel() for p in model.trainable_parameters())
|
n_trainable = sum(p.numel() for p in model.trainable_parameters())
|
||||||
n_total = sum(p.numel() for p in model.parameters())
|
n_total = sum(p.numel() for p in model.parameters())
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
"🧮 trainable=%s (%.2f%%) total=%s",
|
"trainable=%s (%.2f%%) total=%s",
|
||||||
f"{n_trainable:,}", 100.0 * n_trainable / max(n_total, 1), f"{n_total:,}",
|
f"{n_trainable:,}", 100.0 * n_trainable / max(n_total, 1), f"{n_total:,}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Model summary (torchinfo) ---
|
||||||
|
model_summary = print_model_summary(model, device=cfg.device)
|
||||||
|
(output_dir / "model_summary.txt").write_text(model_summary)
|
||||||
|
|
||||||
|
# --- W&B model watching (gradient + weight histograms) ---
|
||||||
|
if tracker.has_wandb:
|
||||||
|
tracker.watch_model(model, log_freq=50)
|
||||||
|
|
||||||
# Loss.
|
# Loss.
|
||||||
loss_fn = InfoNCELoss(
|
loss_fn = InfoNCELoss(
|
||||||
temperature_init=cfg.tau_init,
|
temperature_init=cfg.tau_init,
|
||||||
@@ -301,7 +342,7 @@ def train(cfg: TrainConfigGTAUAV) -> None:
|
|||||||
learnable_temperature=cfg.learnable_temperature,
|
learnable_temperature=cfg.learnable_temperature,
|
||||||
)
|
)
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
"🌡️ Temperature: %s (init=%.3f)",
|
"Temperature: %s (init=%.3f)",
|
||||||
"learnable" if cfg.learnable_temperature else "cosine schedule",
|
"learnable" if cfg.learnable_temperature else "cosine schedule",
|
||||||
cfg.tau_init,
|
cfg.tau_init,
|
||||||
)
|
)
|
||||||
@@ -345,7 +386,7 @@ def train(cfg: TrainConfigGTAUAV) -> None:
|
|||||||
pin_memory=True,
|
pin_memory=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
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 — per-group LR (text encoder gets lower LR).
|
# Optimizer — per-group LR (text encoder gets lower LR).
|
||||||
param_groups = _build_param_groups(model, cfg.learning_rate, cfg.text_lr_factor)
|
param_groups = _build_param_groups(model, cfg.learning_rate, cfg.text_lr_factor)
|
||||||
@@ -358,7 +399,7 @@ def train(cfg: TrainConfigGTAUAV) -> None:
|
|||||||
lr_info = f"proj={cfg.learning_rate:.0e}"
|
lr_info = f"proj={cfg.learning_rate:.0e}"
|
||||||
if not cfg.baseline_mode:
|
if not cfg.baseline_mode:
|
||||||
lr_info += f" text={cfg.learning_rate * cfg.text_lr_factor:.0e}"
|
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)
|
LOGGER.info("Optimizer: AdamW LR: %s warmup=%d epochs", lr_info, cfg.warmup_epochs)
|
||||||
|
|
||||||
# Scheduler — cosine with linear warmup.
|
# Scheduler — cosine with linear warmup.
|
||||||
steps_per_epoch = len(train_loader)
|
steps_per_epoch = len(train_loader)
|
||||||
@@ -377,18 +418,31 @@ def train(cfg: TrainConfigGTAUAV) -> None:
|
|||||||
if resume_ckpt is not None:
|
if resume_ckpt is not None:
|
||||||
if "optimizer_state" in resume_ckpt:
|
if "optimizer_state" in resume_ckpt:
|
||||||
optimizer.load_state_dict(resume_ckpt["optimizer_state"])
|
optimizer.load_state_dict(resume_ckpt["optimizer_state"])
|
||||||
LOGGER.info("🔄 Optimizer state restored")
|
LOGGER.info("Optimizer state restored")
|
||||||
if "loss_state" in resume_ckpt:
|
if "loss_state" in resume_ckpt:
|
||||||
loss_fn.load_state_dict(resume_ckpt["loss_state"])
|
loss_fn.load_state_dict(resume_ckpt["loss_state"])
|
||||||
LOGGER.info("🔄 Loss state restored (tau=%.4f)", loss_fn.current_temperature)
|
LOGGER.info("Loss state restored (tau=%.4f)", loss_fn.current_temperature)
|
||||||
# Set scheduler last_epoch so it resumes at the correct LR.
|
# Set scheduler last_epoch so it resumes at the correct LR.
|
||||||
scheduler.last_epoch = start_epoch * steps_per_epoch
|
scheduler.last_epoch = start_epoch * steps_per_epoch
|
||||||
LOGGER.info("🔄 Resuming from epoch %d", start_epoch)
|
LOGGER.info("Resuming from epoch %d", start_epoch)
|
||||||
|
|
||||||
history: list[dict] = []
|
history: list[dict] = []
|
||||||
csv_logger = CSVLogger(output_dir)
|
csv_logger = CSVLogger(output_dir)
|
||||||
|
|
||||||
LOGGER.info("🚀 Starting training for %d epochs (from epoch %d)", cfg.epochs, start_epoch)
|
# --- Optional profiler (first epoch only) ---
|
||||||
|
profiler = None
|
||||||
|
if cfg.use_profiler and start_epoch == 0:
|
||||||
|
profiler = TrainingProfiler(
|
||||||
|
output_dir=output_dir,
|
||||||
|
n_warmup=cfg.profiler_warmup,
|
||||||
|
n_active=cfg.profiler_active,
|
||||||
|
)
|
||||||
|
profiler.start()
|
||||||
|
|
||||||
|
LOGGER.info("Starting training for %d epochs (from epoch %d)", cfg.epochs, start_epoch)
|
||||||
|
|
||||||
|
global_step = start_epoch * steps_per_epoch
|
||||||
|
best_r1 = 0.0
|
||||||
|
|
||||||
for epoch in range(start_epoch, cfg.epochs):
|
for epoch in range(start_epoch, cfg.epochs):
|
||||||
model.train()
|
model.train()
|
||||||
@@ -398,7 +452,7 @@ def train(cfg: TrainConfigGTAUAV) -> None:
|
|||||||
|
|
||||||
pbar = tqdm(
|
pbar = tqdm(
|
||||||
train_loader,
|
train_loader,
|
||||||
desc=f" 🏋️ Epoch {epoch + 1}/{cfg.epochs}",
|
desc=f" Epoch {epoch + 1}/{cfg.epochs}",
|
||||||
unit="batch",
|
unit="batch",
|
||||||
leave=False,
|
leave=False,
|
||||||
)
|
)
|
||||||
@@ -439,15 +493,34 @@ def train(cfg: TrainConfigGTAUAV) -> None:
|
|||||||
model.trainable_parameters(),
|
model.trainable_parameters(),
|
||||||
max_norm=cfg.grad_clip,
|
max_norm=cfg.grad_clip,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Gradient monitoring (after unscale, before step) ---
|
||||||
|
if cfg.log_grad_norms and n_batches % 50 == 0:
|
||||||
|
grad_norms = compute_gradient_norms(model, loss_fn)
|
||||||
|
tracker.log_gradients(epoch, grad_norms, step=global_step)
|
||||||
|
if n_batches == 0:
|
||||||
|
log_gradient_summary(grad_norms)
|
||||||
|
|
||||||
scaler.step(optimizer)
|
scaler.step(optimizer)
|
||||||
scaler.update()
|
scaler.update()
|
||||||
with warnings.catch_warnings():
|
with warnings.catch_warnings():
|
||||||
warnings.filterwarnings("ignore", message=".*lr_scheduler.step.*optimizer.step.*")
|
warnings.filterwarnings("ignore", message=".*lr_scheduler.step.*optimizer.step.*")
|
||||||
scheduler.step()
|
scheduler.step()
|
||||||
|
|
||||||
|
# --- Per-step tracking ---
|
||||||
|
step_metrics = {
|
||||||
|
"loss": float(total_loss.item()),
|
||||||
|
"temperature": float(loss_dict["temperature"].item()),
|
||||||
|
"gate_q": float(loss_dict["gate_q"].item()),
|
||||||
|
"gate_g": float(loss_dict["gate_g"].item()),
|
||||||
|
"lr": optimizer.param_groups[0]["lr"],
|
||||||
|
}
|
||||||
|
tracker.log_train(epoch, step_metrics, step=global_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())
|
||||||
n_batches += 1
|
n_batches += 1
|
||||||
|
global_step += 1
|
||||||
|
|
||||||
pbar.set_postfix(
|
pbar.set_postfix(
|
||||||
loss=f"{total_loss.item():.3f}",
|
loss=f"{total_loss.item():.3f}",
|
||||||
@@ -456,11 +529,18 @@ def train(cfg: TrainConfigGTAUAV) -> None:
|
|||||||
gg=f"{loss_dict['gate_g'].item():.3f}",
|
gg=f"{loss_dict['gate_g'].item():.3f}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Profiler step ---
|
||||||
|
if profiler is not None:
|
||||||
|
profiler.step()
|
||||||
|
if profiler.is_done(n_batches):
|
||||||
|
profiler.export()
|
||||||
|
profiler = None
|
||||||
|
|
||||||
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()}
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
"📈 epoch=%d time=%.1fs lr=%.2e loss=%.4f tau=%.4f gate_q=%.4f gate_g=%.4f",
|
"epoch=%d time=%.1fs lr=%.2e loss=%.4f tau=%.4f gate_q=%.4f gate_g=%.4f",
|
||||||
epoch, elapsed,
|
epoch, elapsed,
|
||||||
optimizer.param_groups[0]["lr"],
|
optimizer.param_groups[0]["lr"],
|
||||||
means.get("total", 0.0),
|
means.get("total", 0.0),
|
||||||
@@ -475,8 +555,14 @@ def train(cfg: TrainConfigGTAUAV) -> None:
|
|||||||
"train": means,
|
"train": means,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Log train metrics to CSV.
|
# Log train metrics to CSV + generate plots every epoch.
|
||||||
csv_logger.log_train(epoch, means, optimizer.param_groups[0]["lr"], elapsed)
|
csv_logger.log_train(epoch, means, optimizer.param_groups[0]["lr"], elapsed)
|
||||||
|
generate_plots(csv_logger.log_dir)
|
||||||
|
|
||||||
|
# --- Log VRAM usage ---
|
||||||
|
if torch.cuda.is_available():
|
||||||
|
vram_gb = torch.cuda.max_memory_allocated() / 1e9
|
||||||
|
tracker.log_scalar("system/vram_peak_gb", vram_gb, step=global_step)
|
||||||
|
|
||||||
# Evaluation.
|
# Evaluation.
|
||||||
if (epoch + 1) % cfg.eval_every == 0 or epoch == cfg.epochs - 1:
|
if (epoch + 1) % cfg.eval_every == 0 or epoch == cfg.epochs - 1:
|
||||||
@@ -484,8 +570,16 @@ def train(cfg: TrainConfigGTAUAV) -> None:
|
|||||||
epoch_record["val"] = val_metrics
|
epoch_record["val"] = val_metrics
|
||||||
csv_logger.log_val(epoch, val_metrics)
|
csv_logger.log_val(epoch, val_metrics)
|
||||||
generate_plots(csv_logger.log_dir)
|
generate_plots(csv_logger.log_dir)
|
||||||
|
tracker.log_val(epoch, val_metrics, step=global_step)
|
||||||
|
|
||||||
|
# Track best R@1.
|
||||||
|
r1 = val_metrics.get("r@1_q2g", 0.0)
|
||||||
|
if r1 > best_r1:
|
||||||
|
best_r1 = r1
|
||||||
|
tracker.log_scalar("val/best_r@1_q2g", best_r1, step=global_step)
|
||||||
|
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
"🎯 val epoch=%d R@1=%.4f R@5=%.4f R@10=%.4f gate_q=%.4f gate_g=%.4f",
|
"val epoch=%d R@1=%.4f R@5=%.4f R@10=%.4f gate_q=%.4f gate_g=%.4f",
|
||||||
epoch,
|
epoch,
|
||||||
val_metrics.get("r@1_q2g", 0.0),
|
val_metrics.get("r@1_q2g", 0.0),
|
||||||
val_metrics.get("r@5_q2g", 0.0),
|
val_metrics.get("r@5_q2g", 0.0),
|
||||||
@@ -494,6 +588,27 @@ def train(cfg: TrainConfigGTAUAV) -> None:
|
|||||||
val_metrics.get("gate_g", 1.0),
|
val_metrics.get("gate_g", 1.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Grad-CAM visualization ---
|
||||||
|
if cfg.use_gradcam and (epoch + 1) % cfg.gradcam_every == 0:
|
||||||
|
from src.training.gradcam import generate_gradcam_samples
|
||||||
|
overlays = generate_gradcam_samples(
|
||||||
|
model=model,
|
||||||
|
dataloader=test_loader,
|
||||||
|
device=cfg.device,
|
||||||
|
output_dir=str(output_dir),
|
||||||
|
n_samples=cfg.gradcam_samples,
|
||||||
|
epoch=epoch,
|
||||||
|
)
|
||||||
|
# Log first few overlays to tracker.
|
||||||
|
for i, overlay in enumerate(overlays[:4]):
|
||||||
|
kind = "drone" if i % 2 == 0 else "sat"
|
||||||
|
tracker.log_image(
|
||||||
|
f"gradcam/{kind}_{i//2}",
|
||||||
|
overlay,
|
||||||
|
step=global_step,
|
||||||
|
caption=f"Epoch {epoch} {kind} Grad-CAM",
|
||||||
|
)
|
||||||
|
|
||||||
history.append(epoch_record)
|
history.append(epoch_record)
|
||||||
|
|
||||||
# Save checkpoint.
|
# Save checkpoint.
|
||||||
@@ -506,7 +621,7 @@ def train(cfg: TrainConfigGTAUAV) -> None:
|
|||||||
},
|
},
|
||||||
path=output_dir / f"ckpt_epoch{epoch:03d}.pt",
|
path=output_dir / f"ckpt_epoch{epoch:03d}.pt",
|
||||||
)
|
)
|
||||||
LOGGER.info("💾 Checkpoint saved: ckpt_epoch%03d.pt", epoch)
|
LOGGER.info("Checkpoint saved: ckpt_epoch%03d.pt", epoch)
|
||||||
|
|
||||||
# Save history.
|
# Save history.
|
||||||
history_path = output_dir / "history.json"
|
history_path = output_dir / "history.json"
|
||||||
@@ -514,7 +629,7 @@ def train(cfg: TrainConfigGTAUAV) -> None:
|
|||||||
json.dump(history, f, indent=2)
|
json.dump(history, f, indent=2)
|
||||||
|
|
||||||
# Save final eval report.
|
# Save final eval report.
|
||||||
LOGGER.info("🔎 Running final evaluation...")
|
LOGGER.info("Running final evaluation...")
|
||||||
final_metrics = _evaluate(model, test_loader, cfg.device)
|
final_metrics = _evaluate(model, test_loader, cfg.device)
|
||||||
report = {
|
report = {
|
||||||
"config": vars(cfg),
|
"config": vars(cfg),
|
||||||
@@ -525,9 +640,25 @@ def train(cfg: TrainConfigGTAUAV) -> None:
|
|||||||
with report_path.open("w", encoding="utf-8") as f:
|
with report_path.open("w", encoding="utf-8") as f:
|
||||||
json.dump(report, f, indent=2)
|
json.dump(report, f, indent=2)
|
||||||
|
|
||||||
LOGGER.info("✅ Training complete. Report: %s", report_path)
|
# --- Log final summary to W&B ---
|
||||||
|
tracker.log_summary({
|
||||||
|
"best_r@1_q2g": best_r1,
|
||||||
|
"final_r@1_q2g": final_metrics.get("r@1_q2g", 0.0),
|
||||||
|
"final_r@5_q2g": final_metrics.get("r@5_q2g", 0.0),
|
||||||
|
"final_r@10_q2g": final_metrics.get("r@10_q2g", 0.0),
|
||||||
|
"final_gate_q": final_metrics.get("gate_q", 1.0),
|
||||||
|
"final_gate_g": final_metrics.get("gate_g", 1.0),
|
||||||
|
})
|
||||||
|
|
||||||
|
# --- Cleanup profiler if still running ---
|
||||||
|
if profiler is not None:
|
||||||
|
profiler.export()
|
||||||
|
|
||||||
|
tracker.close()
|
||||||
|
|
||||||
|
LOGGER.info("Training complete. Report: %s", report_path)
|
||||||
LOGGER.info(
|
LOGGER.info(
|
||||||
"📊 Final — R@1=%.4f R@5=%.4f R@10=%.4f gate_q=%.4f gate_g=%.4f",
|
"Final — R@1=%.4f R@5=%.4f R@10=%.4f gate_q=%.4f gate_g=%.4f",
|
||||||
final_metrics.get("r@1_q2g", 0.0),
|
final_metrics.get("r@1_q2g", 0.0),
|
||||||
final_metrics.get("r@5_q2g", 0.0),
|
final_metrics.get("r@5_q2g", 0.0),
|
||||||
final_metrics.get("r@10_q2g", 0.0),
|
final_metrics.get("r@10_q2g", 0.0),
|
||||||
@@ -538,6 +669,10 @@ def train(cfg: TrainConfigGTAUAV) -> None:
|
|||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(description="GTA-UAV caption test training.")
|
parser = argparse.ArgumentParser(description="GTA-UAV caption test training.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--config", type=str, default=None,
|
||||||
|
help="Path to gin config file (e.g. conf/gtauav_balanced.gin).",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--baseline", action="store_true",
|
"--baseline", action="store_true",
|
||||||
help="Run baseline mode (no text).",
|
help="Run baseline mode (no text).",
|
||||||
@@ -555,47 +690,86 @@ def main() -> None:
|
|||||||
help="Path to seg_filter.json for excluding bad images.",
|
help="Path to seg_filter.json for excluding bad images.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--batch-size", type=int, default=8,
|
"--batch-size", type=int, default=None,
|
||||||
help="Batch size.",
|
help="Batch size.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--epochs", type=int, default=20,
|
"--epochs", type=int, default=None,
|
||||||
help="Number of epochs.",
|
help="Number of epochs.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--lr", type=float, default=1e-4,
|
"--lr", type=float, default=None,
|
||||||
help="Learning rate for projections.",
|
help="Learning rate for projections.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--text-lr-factor", type=float, default=0.1,
|
"--text-lr-factor", type=float, default=None,
|
||||||
help="Text encoder LR = lr * factor (default 0.1 = 10x lower).",
|
help="Text encoder LR = lr * factor (default 0.1 = 10x lower).",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--warmup-epochs", type=int, default=2,
|
"--warmup-epochs", type=int, default=None,
|
||||||
help="Linear warmup epochs.",
|
help="Linear warmup epochs.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--init-gate", type=float, default=0.7,
|
"--init-gate", type=float, default=None,
|
||||||
help="Initial gate value (image weight).",
|
help="Initial gate value (image weight).",
|
||||||
)
|
)
|
||||||
|
# Tracking flags.
|
||||||
|
parser.add_argument("--wandb", action="store_true", help="Enable W&B tracking.")
|
||||||
|
parser.add_argument("--no-tb", action="store_true", help="Disable TensorBoard.")
|
||||||
|
parser.add_argument("--gradcam", action="store_true", help="Enable Grad-CAM visualization.")
|
||||||
|
parser.add_argument("--profile", action="store_true", help="Enable PyTorch profiler (first epoch).")
|
||||||
|
parser.add_argument("--no-grad-norms", action="store_true", help="Disable gradient norm logging.")
|
||||||
|
# Gin overrides.
|
||||||
|
parser.add_argument(
|
||||||
|
"--gin-param", type=str, nargs="*", default=[],
|
||||||
|
help="Gin parameter overrides (e.g. 'TrainConfigGTAUAV.epochs=30').",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
cfg = TrainConfigGTAUAV()
|
# Parse gin config if provided.
|
||||||
cfg.baseline_mode = args.baseline
|
if args.config is not None:
|
||||||
cfg.resume_from = args.resume
|
gin.parse_config_file(args.config)
|
||||||
cfg.batch_size = args.batch_size
|
if args.gin_param:
|
||||||
cfg.epochs = args.epochs
|
gin.parse_config(args.gin_param)
|
||||||
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
|
|
||||||
|
|
||||||
|
# Create config (gin bindings apply via @gin.configurable).
|
||||||
|
cfg = TrainConfigGTAUAV()
|
||||||
|
|
||||||
|
# CLI overrides take priority over gin.
|
||||||
|
if args.baseline:
|
||||||
|
cfg.baseline_mode = True
|
||||||
|
if args.resume is not None:
|
||||||
|
cfg.resume_from = args.resume
|
||||||
|
if args.batch_size is not None:
|
||||||
|
cfg.batch_size = args.batch_size
|
||||||
|
if args.epochs is not None:
|
||||||
|
cfg.epochs = args.epochs
|
||||||
|
if args.lr is not None:
|
||||||
|
cfg.learning_rate = args.lr
|
||||||
|
if args.text_lr_factor is not None:
|
||||||
|
cfg.text_lr_factor = args.text_lr_factor
|
||||||
|
if args.warmup_epochs is not None:
|
||||||
|
cfg.warmup_epochs = args.warmup_epochs
|
||||||
|
if args.init_gate is not None:
|
||||||
|
cfg.init_gate = args.init_gate
|
||||||
if args.filter_meta is not None:
|
if args.filter_meta is not None:
|
||||||
cfg.filter_meta = args.filter_meta
|
cfg.filter_meta = args.filter_meta
|
||||||
|
|
||||||
|
# Tracking overrides.
|
||||||
|
if args.wandb:
|
||||||
|
cfg.use_wandb = True
|
||||||
|
if args.no_tb:
|
||||||
|
cfg.use_tb = False
|
||||||
|
if args.gradcam:
|
||||||
|
cfg.use_gradcam = True
|
||||||
|
if args.profile:
|
||||||
|
cfg.use_profiler = True
|
||||||
|
if args.no_grad_norms:
|
||||||
|
cfg.log_grad_norms = False
|
||||||
|
|
||||||
if args.output_dir is not None:
|
if args.output_dir is not None:
|
||||||
cfg.output_dir = args.output_dir
|
cfg.output_dir = args.output_dir
|
||||||
elif args.baseline:
|
elif args.baseline and args.output_dir is None:
|
||||||
cfg.output_dir = "out/gtauav/baseline"
|
cfg.output_dir = "out/gtauav/baseline"
|
||||||
|
|
||||||
train(cfg)
|
train(cfg)
|
||||||
|
|||||||
Reference in New Issue
Block a user