1090 lines
45 KiB
Markdown
1090 lines
45 KiB
Markdown
# Рефакторинг `caption-test` (`belka_refactor`) — обновлённое предложение
|
||
|
||
> **Версия 2.** Обновлена под полный набор требований: `REQUIREMENTS_GIN_STYLE.md` (включая центральный `load_all_configs`), стандарт `code-style`, рекомендации «5 конфигов — оптимум» с принципом разделения «меняются вместе → один конфиг».
|
||
|
||
---
|
||
|
||
## 0. Что изменилось относительно версии 1
|
||
|
||
| # | Было в v1 | Стало в v2 | Почему |
|
||
|---|---|---|---|
|
||
| 1 | 6 конфиг-классов | **5 конфиг-классов** (Pipeline, Hardware, Models, Training, Tracking) | «5 — оптимальное число»; принцип «оси изменчивости»: Loss+Optimizer+Sampling меняются вместе при экспериментах с обучением → один `TrainingConfig` |
|
||
| 2 | 6 индивидуальных `get_*_cfg()` в `main()` | **Один** `load_all_configs()` + `gin.clear_config()` внутри | Прямое требование `REQUIREMENTS_GIN_STYLE.md §3.1`: индивидуальные loader'ы — **только для тестов** |
|
||
| 3 | `os.environ.get("CAPTION_TEST_PRESET")` для выбора пресета | `path2cfg` фиксирован, разные пресеты = разные директории | env vars — это скрытый CLI; стандарт требует «всё в `.gin`» |
|
||
| 4 | Подкаталог `presets/` внутри `in/config_files/` | `in/config_files/` напрямую (один пресет = один запуск; копировать дир для другого) | Соответствие референсной структуре `test_bb_uav` |
|
||
| 5 | Не подсвечен антипаттерн `@gin.configurable` на `InfoNCELoss` + дубль биндингов | **Явно подсвечен**: один источник правды | Двойная gin-регистрация → тихие баги |
|
||
| 6 | `get_proj_dir()` упомянут абстрактно | Конкретная реализация через MARKERS (`pyproject.toml`, `.git`, `in`) | Из `REQUIREMENTS_GIN_STYLE.md §5` |
|
||
|
||
---
|
||
|
||
## 1. Главные нарушения стандарта в текущем коде
|
||
|
||
### 1.1 Критические (запрещены прямо)
|
||
|
||
```python
|
||
# src/training/train_gtauav.py, строки ~80
|
||
@gin.configurable(module="src.training.train_gtauav")
|
||
@dataclass # ← FORBIDDEN
|
||
class TrainConfigGTAUAV:
|
||
train_json: str = _TRAIN_JSON
|
||
# ... 50+ полей
|
||
```
|
||
|
||
> «Запрещено использовать `dataclass` совместно с gin» — стандарт §3.1 и `Reference Examples → Anti-patterns`.
|
||
|
||
```python
|
||
# src/training/train_gtauav.py, ~main()
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser(...) # ← FORBIDDEN
|
||
parser.add_argument("--config", ...)
|
||
parser.add_argument("--baseline", ...)
|
||
# ... 15+ CLI флагов
|
||
```
|
||
|
||
> «Запрещён argparse — все параметры из .gin файлов» — стандарт §3.4.
|
||
|
||
```python
|
||
# src/training/train_gtauav.py, module level
|
||
_RGB_ROOT = "/home/servml/Документы/datasets/GTA-UAV-LR" # hardcoded
|
||
_DINO_WEB = "nn_models/DINO_WEB/dinov3-vitl16-pretrain-lvd1689m.pth"
|
||
# ... ещё 5 hardcoded путей
|
||
```
|
||
|
||
> «Нет захардкоженных model ID / промптов / размеров» — чеклист §6.
|
||
|
||
### 1.2 Серьёзные структурные
|
||
|
||
- **Один мега-конфиг на 50+ полей** — нарушает «1 .gin = 1 конфиг-класс».
|
||
- **Дубль биндингов в `gtauav_balanced.gin`**: одни и те же значения прописаны и для `TrainConfigGTAUAV.tau_init`, и для `InfoNCELoss.temperature_init`. Если кто-то поменяет одно и забудет другое — тихий рассинхрон.
|
||
- **`InfoNCELoss` с `@gin.configurable`** + одновременно создаётся через явные kwargs из `cfg.tau_init`. Двойной источник правды.
|
||
- **`@torch.no_grad()` вместо `@torch.inference_mode()`** в `_evaluate`, `_embed_drone_queries`.
|
||
- **`train_gtauav.py` — 1296 строк**: dataclass-конфиг + утилиты + CSVLogger + `_evaluate` (150 строк) + `train()` (700 строк) + `main()` с argparse.
|
||
|
||
---
|
||
|
||
## 2. Целевая архитектура — 5 конфигов
|
||
|
||
### Принцип разделения «оси изменчивости»
|
||
|
||
| Что меняется вместе | Конфиг |
|
||
|---|---|
|
||
| Запуск пайплайна (что обрабатываем, куда сохраняем, сколько эпох, расписание eval) | **PipelineConfig** |
|
||
| GPU / память / производительность (`batch_size`, `num_workers`, `use_amp`, `grad_checkpointing`) | **HardwareConfig** |
|
||
| Архитектура (бэкбоны, пути к чекпоинтам, MONA, gate, baseline_mode, asym/shared) | **ModelsConfig** |
|
||
| Обучение (loss + optimizer + sampler — экспериментируем с этим вместе) | **TrainingConfig** |
|
||
| Наблюдаемость (W&B, TB, Grad-CAM, profiler) — независимый рубильник | **TrackingConfig** |
|
||
|
||
> **Почему Loss + Optimizer + Sampling в одном `TrainingConfig`?** Они меняются вместе. Когда исследуешь «что улучшит R@1» — ты крутишь `tau`, `label_smoothing`, `lr`, `text_lr_factor`, `sampler_type`, `dss_warmup`. Они образуют единый «recipe обучения». Разделение их на 3 конфига создавало бы координационную нагрузку без выгоды.
|
||
>
|
||
> **Почему Tracking отдельно?** Это диагностика. Можно запустить один и тот же эксперимент с `use_wandb=True` и без — результат обучения идентичный, меняется только что мы записываем. Это **независимая ось**, поэтому отдельный конфиг.
|
||
|
||
### Целевая структура каталогов
|
||
|
||
```
|
||
caption-test/
|
||
├── in/
|
||
│ └── config_files/ # Один активный пресет
|
||
│ ├── pipeline.gin # 1 файл = 1 конфиг-класс
|
||
│ ├── hardware.gin
|
||
│ ├── models.gin
|
||
│ ├── training.gin
|
||
│ └── tracking.gin
|
||
├── presets/ # Готовые пресеты (копируются в in/config_files/)
|
||
│ ├── gtauav_balanced/
|
||
│ │ ├── pipeline.gin
|
||
│ │ ├── hardware.gin
|
||
│ │ ├── models.gin
|
||
│ │ ├── training.gin
|
||
│ │ └── tracking.gin
|
||
│ ├── gtauav_baseline/
|
||
│ ├── gtauav_balanced_asym/
|
||
│ ├── gtauav_balanced_stripnet/
|
||
│ ├── gtauav_balanced_stripnet_unfrozen/
|
||
│ ├── gtauav_text_heavy/
|
||
│ └── gtauav_image_heavy/
|
||
├── src/
|
||
│ ├── conf/
|
||
│ │ ├── __init__.py
|
||
│ │ ├── pipeline_conf.py # PipelineConfig + get_pipeline_cfg
|
||
│ │ ├── hardware_conf.py # HardwareConfig + get_hardware_cfg
|
||
│ │ ├── models_conf.py # ModelsConfig + get_models_cfg
|
||
│ │ ├── training_conf.py # TrainingConfig + get_training_cfg
|
||
│ │ ├── tracking_conf.py # TrackingConfig + get_tracking_cfg
|
||
│ │ └── config_loader.py # load_all_configs() — единый продакшен-вход
|
||
│ ├── datasets/ # БЕЗ ИЗМЕНЕНИЙ (логика самплеров корректна)
|
||
│ ├── models/ # БЕЗ ИЗМЕНЕНИЙ (модельная архитектура работает)
|
||
│ ├── losses/ # минимальные правки (см. §4)
|
||
│ ├── eval/
|
||
│ │ ├── __init__.py
|
||
│ │ └── evaluator.py # NEW — _evaluate() сюда, @torch.inference_mode()
|
||
│ ├── training/
|
||
│ │ ├── __init__.py
|
||
│ │ ├── trainer.py # NEW — class Trainer: основной цикл
|
||
│ │ ├── csv_logger.py # NEW — CSVLogger вытащен сюда
|
||
│ │ ├── trackers.py # без изменений
|
||
│ │ ├── grad_monitor.py # без изменений
|
||
│ │ ├── gradcam.py # без изменений
|
||
│ │ ├── profiling.py # без изменений
|
||
│ │ └── plot_metrics.py # без изменений
|
||
│ ├── utils/
|
||
│ │ ├── __init__.py
|
||
│ │ ├── io_utils.py # atomic_save_torch, clear_vram
|
||
│ │ ├── seed_utils.py # set_seed
|
||
│ │ └── path_utils.py # get_proj_dir
|
||
│ └── main.py # NEW — единственная точка входа
|
||
├── scripts/ # без изменений (это уже отдельные tools)
|
||
├── meta/ # без изменений (data artifacts)
|
||
└── nn_models/ # без изменений (checkpoints, gitignored)
|
||
```
|
||
|
||
**Workflow смены пресета:**
|
||
|
||
```bash
|
||
# Вместо CLI флагов — копируем нужный пресет:
|
||
cp -r presets/gtauav_baseline/* in/config_files/
|
||
python -m src.main
|
||
|
||
# Или для эксперимента: создать новый пресет
|
||
cp -r presets/gtauav_balanced presets/gtauav_balanced_lr5e5
|
||
# отредактировать presets/gtauav_balanced_lr5e5/training.gin
|
||
cp -r presets/gtauav_balanced_lr5e5/* in/config_files/
|
||
python -m src.main
|
||
```
|
||
|
||
Каждый запуск однозначно описан содержимым `in/config_files/`. Воспроизводимость = снэпшот этой директории.
|
||
|
||
---
|
||
|
||
## 3. Конфиг-классы — полный код
|
||
|
||
### 3.1 `src/conf/pipeline_conf.py`
|
||
|
||
```python
|
||
from __future__ import annotations
|
||
|
||
import gin
|
||
|
||
|
||
@gin.configurable
|
||
class PipelineConfig:
|
||
"""Pipeline orchestration: data IO, training schedule, output, resume."""
|
||
|
||
def __init__(
|
||
self,
|
||
# Data paths.
|
||
train_json: str = "meta/train_80.json",
|
||
test_json: str = "meta/test_20.json",
|
||
rgb_root: str = "/data/GTA-UAV-LR",
|
||
caption_root: str = "/data/GTA-UAV-LR-captions",
|
||
filter_meta: str | None = None,
|
||
# Training schedule.
|
||
epochs: int = 10,
|
||
warmup_epochs: int = 2,
|
||
eval_every: int = 1,
|
||
# Reproducibility & output.
|
||
seed: int = 42,
|
||
output_dir: str = "out/gtauav/with_text",
|
||
resume_from: str | None = None,
|
||
) -> None:
|
||
self.train_json = train_json
|
||
self.test_json = test_json
|
||
self.rgb_root = rgb_root
|
||
self.caption_root = caption_root
|
||
self.filter_meta = filter_meta
|
||
self.epochs = epochs
|
||
self.warmup_epochs = warmup_epochs
|
||
self.eval_every = eval_every
|
||
self.seed = seed
|
||
self.output_dir = output_dir
|
||
self.resume_from = resume_from
|
||
|
||
|
||
def get_pipeline_cfg(path2cfg: str) -> PipelineConfig:
|
||
"""Load ONLY pipeline config (TESTING ONLY — use load_all_configs in production).
|
||
|
||
Args:
|
||
path2cfg: Path to config directory (with trailing slash).
|
||
|
||
Returns:
|
||
Instantiated PipelineConfig.
|
||
"""
|
||
gin.clear_config()
|
||
gin.parse_config_file(f"{path2cfg}pipeline.gin")
|
||
return PipelineConfig()
|
||
```
|
||
|
||
### 3.2 `src/conf/hardware_conf.py`
|
||
|
||
```python
|
||
from __future__ import annotations
|
||
|
||
import gin
|
||
|
||
|
||
@gin.configurable
|
||
class HardwareConfig:
|
||
"""GPU profile + memory/compute optimisation flags.
|
||
|
||
Everything that changes when you switch hardware (4090 → A100 → Jetson)
|
||
lives here. batch_size and grad_accum_steps are hardware-bound: they
|
||
determine VRAM footprint, not the training recipe.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
device: str = "cuda",
|
||
batch_size: int = 8,
|
||
grad_accum_steps: int = 1,
|
||
num_workers: int = 4,
|
||
use_amp: bool = True,
|
||
gradient_checkpointing: bool = True,
|
||
reserve_gb: float = 2.0,
|
||
) -> None:
|
||
self.device = device
|
||
self.batch_size = batch_size
|
||
self.grad_accum_steps = grad_accum_steps
|
||
self.num_workers = num_workers
|
||
self.use_amp = use_amp
|
||
self.gradient_checkpointing = gradient_checkpointing
|
||
self.reserve_gb = reserve_gb
|
||
# Derived (RTX 4090 default; override per profile):
|
||
self.total_vram_gb = 24.0
|
||
self.available_vram_gb = self.total_vram_gb - self.reserve_gb
|
||
self.effective_batch_size = self.batch_size * self.grad_accum_steps
|
||
|
||
|
||
def get_hardware_cfg(path2cfg: str) -> HardwareConfig:
|
||
"""Load ONLY hardware config (TESTING ONLY — use load_all_configs in production)."""
|
||
gin.clear_config()
|
||
gin.parse_config_file(f"{path2cfg}hardware.gin")
|
||
return HardwareConfig()
|
||
```
|
||
|
||
### 3.3 `src/conf/models_conf.py`
|
||
|
||
```python
|
||
from __future__ import annotations
|
||
|
||
import gin
|
||
|
||
|
||
@gin.configurable
|
||
class ModelsConfig:
|
||
"""Model checkpoints + architecture switches.
|
||
|
||
Everything that defines WHAT model is built: backbone choice, paths to
|
||
pretrained weights, MONA placement, gate init, asym vs shared encoder.
|
||
Changes here mean a new architecture experiment.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
# Checkpoints.
|
||
dino_web_path: str = "nn_models/DINO_WEB/dinov3-vitl16-pretrain-lvd1689m.pth",
|
||
dino_sat_path: str = "nn_models/DINO_SAT/model.safetensors",
|
||
lrsclip_path: str = "nn_models/LRSCLIP/DGTRS-CLIP-ViT-L-14.pt",
|
||
stripnet_path: str = "nn_models/STRIPNET/stripnet_s.pth",
|
||
# Backbone selection.
|
||
backbone: str = "dinov3",
|
||
shared_encoder: bool = True,
|
||
baseline_mode: bool = False,
|
||
# Fusion.
|
||
init_gate: float = 0.7,
|
||
# MONA (DINOv3).
|
||
mona_bottleneck: int = 64,
|
||
mona_last_n_blocks: int = 12,
|
||
# StripNet-specific.
|
||
stripnet_freeze: bool = True,
|
||
stripnet_mona_last_n_stages: int = 2,
|
||
) -> None:
|
||
self.dino_web_path = dino_web_path
|
||
self.dino_sat_path = dino_sat_path
|
||
self.lrsclip_path = lrsclip_path
|
||
self.stripnet_path = stripnet_path
|
||
self.backbone = backbone
|
||
self.shared_encoder = shared_encoder
|
||
self.baseline_mode = baseline_mode
|
||
self.init_gate = init_gate
|
||
self.mona_bottleneck = mona_bottleneck
|
||
self.mona_last_n_blocks = mona_last_n_blocks
|
||
self.stripnet_freeze = stripnet_freeze
|
||
self.stripnet_mona_last_n_stages = stripnet_mona_last_n_stages
|
||
|
||
|
||
def get_models_cfg(path2cfg: str) -> ModelsConfig:
|
||
"""Load ONLY models config (TESTING ONLY — use load_all_configs in production)."""
|
||
gin.clear_config()
|
||
gin.parse_config_file(f"{path2cfg}models.gin")
|
||
return ModelsConfig()
|
||
```
|
||
|
||
### 3.4 `src/conf/training_conf.py`
|
||
|
||
```python
|
||
from __future__ import annotations
|
||
|
||
import gin
|
||
|
||
|
||
@gin.configurable
|
||
class TrainingConfig:
|
||
"""Training recipe: loss + optimizer + sampler.
|
||
|
||
These three move together when you tune learning. Changing tau usually
|
||
pairs with changing lr; switching sampler_type usually pairs with
|
||
re-tuning loss weights. Keeping them in one config matches the actual
|
||
workflow of running ablations.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
# --- Loss ---
|
||
loss_type: str = "symmetric",
|
||
tau_init: float = 0.07,
|
||
tau_min: float = 0.01,
|
||
tau_max: float = 0.1,
|
||
learnable_temperature: bool = True,
|
||
label_smoothing: float = 0.1,
|
||
weight_q2g: float = 0.6,
|
||
weight_g2q: float = 0.4,
|
||
hard_mining_k: int = 0,
|
||
neg_bank_size: int = 0,
|
||
# --- Optimizer ---
|
||
learning_rate: float = 1e-4,
|
||
text_lr_factor: float = 0.1,
|
||
stripnet_backbone_lr_factor: float = 0.1,
|
||
weight_decay: float = 1e-4,
|
||
grad_clip: float = 1.0,
|
||
# --- Sampler ---
|
||
sampler_type: str = "mutex",
|
||
dss_warmup_epochs: int = 1,
|
||
dss_reembed_every: int = 1,
|
||
dss_knn_device: str = "cuda",
|
||
dss_use_lsh: bool = False,
|
||
dss_lsh_num_tables: int = 8,
|
||
dss_lsh_num_bits: int = 14,
|
||
dss_cache_dir: str | None = None,
|
||
) -> None:
|
||
# Loss.
|
||
self.loss_type = loss_type
|
||
self.tau_init = tau_init
|
||
self.tau_min = tau_min
|
||
self.tau_max = tau_max
|
||
self.learnable_temperature = learnable_temperature
|
||
self.label_smoothing = label_smoothing
|
||
self.weight_q2g = weight_q2g
|
||
self.weight_g2q = weight_g2q
|
||
self.hard_mining_k = hard_mining_k
|
||
self.neg_bank_size = neg_bank_size
|
||
# Optimizer.
|
||
self.learning_rate = learning_rate
|
||
self.text_lr_factor = text_lr_factor
|
||
self.stripnet_backbone_lr_factor = stripnet_backbone_lr_factor
|
||
self.weight_decay = weight_decay
|
||
self.grad_clip = grad_clip
|
||
# Sampler.
|
||
self.sampler_type = sampler_type
|
||
self.dss_warmup_epochs = dss_warmup_epochs
|
||
self.dss_reembed_every = dss_reembed_every
|
||
self.dss_knn_device = dss_knn_device
|
||
self.dss_use_lsh = dss_use_lsh
|
||
self.dss_lsh_num_tables = dss_lsh_num_tables
|
||
self.dss_lsh_num_bits = dss_lsh_num_bits
|
||
self.dss_cache_dir = dss_cache_dir
|
||
|
||
|
||
def get_training_cfg(path2cfg: str) -> TrainingConfig:
|
||
"""Load ONLY training config (TESTING ONLY — use load_all_configs in production)."""
|
||
gin.clear_config()
|
||
gin.parse_config_file(f"{path2cfg}training.gin")
|
||
return TrainingConfig()
|
||
```
|
||
|
||
### 3.5 `src/conf/tracking_conf.py`
|
||
|
||
```python
|
||
from __future__ import annotations
|
||
|
||
import gin
|
||
|
||
|
||
@gin.configurable
|
||
class TrackingConfig:
|
||
"""Experiment tracking + diagnostics.
|
||
|
||
Independent axis: changing these flags does not affect training results,
|
||
only what is observed/recorded.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
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,
|
||
gradcam_samples: int = 8,
|
||
use_profiler: bool = False,
|
||
profiler_warmup: int = 3,
|
||
profiler_active: int = 5,
|
||
) -> None:
|
||
self.use_wandb = use_wandb
|
||
self.use_tb = use_tb
|
||
self.wandb_project = wandb_project
|
||
self.wandb_run_name = wandb_run_name
|
||
self.wandb_entity = wandb_entity
|
||
self.log_grad_norms = log_grad_norms
|
||
self.use_gradcam = use_gradcam
|
||
self.gradcam_every = gradcam_every
|
||
self.gradcam_samples = gradcam_samples
|
||
self.use_profiler = use_profiler
|
||
self.profiler_warmup = profiler_warmup
|
||
self.profiler_active = profiler_active
|
||
|
||
|
||
def get_tracking_cfg(path2cfg: str) -> TrackingConfig:
|
||
"""Load ONLY tracking config (TESTING ONLY — use load_all_configs in production)."""
|
||
gin.clear_config()
|
||
gin.parse_config_file(f"{path2cfg}tracking.gin")
|
||
return TrackingConfig()
|
||
```
|
||
|
||
### 3.6 `src/conf/config_loader.py` — продакшен-вход
|
||
|
||
```python
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import gin
|
||
|
||
from src.conf.hardware_conf import HardwareConfig
|
||
from src.conf.models_conf import ModelsConfig
|
||
from src.conf.pipeline_conf import PipelineConfig
|
||
from src.conf.tracking_conf import TrackingConfig
|
||
from src.conf.training_conf import TrainingConfig
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def load_all_configs(path2cfg: str) -> dict[str, Any]:
|
||
"""Parse ALL .gin files in path2cfg and return all config objects.
|
||
|
||
This is the PRODUCTION entry point — main() calls this once. Individual
|
||
get_*_cfg() loaders exist only for unit tests / notebooks.
|
||
|
||
Args:
|
||
path2cfg: Path to config directory (WITH trailing slash).
|
||
|
||
Returns:
|
||
Dict with config objects keyed by name:
|
||
{
|
||
"pipeline": PipelineConfig,
|
||
"hardware": HardwareConfig,
|
||
"models": ModelsConfig,
|
||
"training": TrainingConfig,
|
||
"tracking": TrackingConfig,
|
||
}
|
||
|
||
Raises:
|
||
FileNotFoundError: If path2cfg contains no .gin files.
|
||
"""
|
||
cfg_dir = Path(path2cfg)
|
||
gin_files = sorted(cfg_dir.glob("*.gin"))
|
||
if not gin_files:
|
||
raise FileNotFoundError(f"No .gin files found in {cfg_dir}")
|
||
|
||
# MANDATORY: reset gin global state before parsing — without clear_config(),
|
||
# parameters from previous parses accumulate (gin holds global bindings).
|
||
gin.clear_config()
|
||
gin.parse_config_files_and_bindings(
|
||
config_files=[str(f) for f in gin_files],
|
||
bindings=[],
|
||
)
|
||
logger.info("Loaded %d gin files from %s", len(gin_files), cfg_dir)
|
||
|
||
# Instantiate AFTER all bindings are parsed.
|
||
return {
|
||
"pipeline": PipelineConfig(),
|
||
"hardware": HardwareConfig(),
|
||
"models": ModelsConfig(),
|
||
"training": TrainingConfig(),
|
||
"tracking": TrackingConfig(),
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. `InfoNCELoss`: убрать двойную gin-регистрацию
|
||
|
||
**Текущая проблема.** В `src/losses/multi_infonce.py` `InfoNCELoss` декорирован `@gin.configurable`, и в `gtauav_balanced.gin` есть **обе** группы биндингов:
|
||
|
||
```gin
|
||
TrainConfigGTAUAV.tau_init = 0.07 # <-- читается мега-конфигом
|
||
TrainConfigGTAUAV.weight_q2g = 0.6
|
||
# ...
|
||
InfoNCELoss.temperature_init = 0.07 # <-- читается самой InfoNCELoss
|
||
InfoNCELoss.weight_q2g = 0.6
|
||
# ...
|
||
```
|
||
|
||
Это два источника правды на одни и те же значения. Если кто-то поменяет одну строку и забудет другую — обучение пойдёт с рассинхроном между логированием и реальным `tau`. Тихая ошибка, которую очень тяжело отладить.
|
||
|
||
**Решение: один источник правды — `TrainingConfig`.** `InfoNCELoss` теряет `@gin.configurable` и принимает параметры обычными аргументами:
|
||
|
||
```python
|
||
# src/losses/multi_infonce.py
|
||
class InfoNCELoss(nn.Module):
|
||
"""Symmetric InfoNCE with learnable temperature.
|
||
|
||
Note: NOT @gin.configurable. All parameters come from TrainingConfig
|
||
via explicit kwargs, which keeps a single source of truth.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
temperature_init: float = 0.07,
|
||
temperature_min: float = 0.01,
|
||
temperature_max: float = 0.1,
|
||
learnable_temperature: bool = True,
|
||
label_smoothing: float = 0.1,
|
||
weight_q2g: float = 0.6,
|
||
weight_g2q: float = 0.4,
|
||
hard_mining_k: int = 0,
|
||
) -> None:
|
||
super().__init__()
|
||
# ... (existing implementation)
|
||
```
|
||
|
||
В `Trainer`:
|
||
|
||
```python
|
||
loss_fn = InfoNCELoss(
|
||
temperature_init=training_cfg.tau_init,
|
||
temperature_min=training_cfg.tau_min,
|
||
temperature_max=training_cfg.tau_max,
|
||
learnable_temperature=training_cfg.learnable_temperature,
|
||
label_smoothing=training_cfg.label_smoothing,
|
||
weight_q2g=training_cfg.weight_q2g,
|
||
weight_g2q=training_cfg.weight_g2q,
|
||
hard_mining_k=training_cfg.hard_mining_k,
|
||
)
|
||
```
|
||
|
||
В `training.gin` остаются **только** `TrainingConfig.*` биндинги. Все `InfoNCELoss.*` строки **удаляются** из всех gin-файлов.
|
||
|
||
**Принцип общий:** `@gin.configurable` идёт **только** на классы из `src/conf/`. Никакие классы из `src/models/`, `src/losses/`, `src/datasets/` не должны быть gin-configurable. Они принимают параметры явно через `__init__` и собираются `Trainer`-ом из объектов конфига.
|
||
|
||
---
|
||
|
||
## 5. Точка входа — `src/main.py`
|
||
|
||
```python
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
|
||
import coloredlogs
|
||
|
||
from src.conf.config_loader import load_all_configs
|
||
from src.training.trainer import Trainer
|
||
from src.utils.path_utils import get_proj_dir
|
||
from src.utils.seed_utils import set_seed
|
||
|
||
logger = logging.getLogger("caption_test")
|
||
|
||
|
||
def main() -> None:
|
||
"""Entry point: load all configs from in/config_files/ and run training.
|
||
|
||
No argparse. No CLI flags. No env vars. Every parameter lives in .gin
|
||
files under in/config_files/. To switch experiments, copy a different
|
||
preset over in/config_files/ before running.
|
||
"""
|
||
coloredlogs.install(
|
||
level="INFO",
|
||
logger=logger,
|
||
fmt="%(asctime)s %(name)s %(levelname)s %(message)s",
|
||
)
|
||
|
||
proj_dir = get_proj_dir()
|
||
path2cfg = f"{proj_dir}in/config_files/"
|
||
|
||
# ONE call loads everything; gin.clear_config() runs inside.
|
||
configs = load_all_configs(path2cfg)
|
||
|
||
# Reproducibility — single point of seed setting.
|
||
set_seed(configs["pipeline"].seed)
|
||
|
||
# Pass configs explicitly — Trainer never queries gin global state.
|
||
trainer = Trainer(
|
||
pipeline_cfg=configs["pipeline"],
|
||
hardware_cfg=configs["hardware"],
|
||
models_cfg=configs["models"],
|
||
training_cfg=configs["training"],
|
||
tracking_cfg=configs["tracking"],
|
||
)
|
||
trainer.run()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|
||
```
|
||
|
||
### `src/utils/path_utils.py`
|
||
|
||
```python
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
# Markers that identify the project root.
|
||
_MARKERS: tuple[str, ...] = ("pyproject.toml", ".git", "in")
|
||
|
||
|
||
def get_proj_dir() -> str:
|
||
"""Return absolute project root directory with trailing slash.
|
||
|
||
Walks up from this file's directory until it finds one of the markers
|
||
(pyproject.toml, .git, or `in/`). Falls back with a clear error if not
|
||
found within 10 levels.
|
||
|
||
Returns:
|
||
Project root path as string with trailing slash, e.g. '/home/user/caption-test/'.
|
||
|
||
Raises:
|
||
RuntimeError: If no marker found within 10 parent directories.
|
||
"""
|
||
current = Path(__file__).resolve().parent
|
||
for _ in range(10):
|
||
if any((current / m).exists() for m in _MARKERS):
|
||
return str(current) + "/"
|
||
current = current.parent
|
||
raise RuntimeError(
|
||
f"Project root not found. Looked for {_MARKERS} starting at "
|
||
f"{Path(__file__).resolve().parent}",
|
||
)
|
||
```
|
||
|
||
### `src/utils/seed_utils.py`
|
||
|
||
```python
|
||
from __future__ import annotations
|
||
|
||
import random
|
||
|
||
import numpy as np
|
||
import torch
|
||
|
||
|
||
def set_seed(seed: int = 42) -> None:
|
||
"""Fix all RNG seeds for reproducibility.
|
||
|
||
Args:
|
||
seed: Integer seed applied to Python random, NumPy, and PyTorch
|
||
(CPU + all CUDA devices).
|
||
"""
|
||
random.seed(seed)
|
||
np.random.seed(seed)
|
||
torch.manual_seed(seed)
|
||
torch.cuda.manual_seed_all(seed)
|
||
```
|
||
|
||
### `src/utils/io_utils.py`
|
||
|
||
```python
|
||
from __future__ import annotations
|
||
|
||
import gc
|
||
import logging
|
||
import os
|
||
import tempfile
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import torch
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def atomic_save_torch(obj: Any, path: Path) -> None:
|
||
"""Save a PyTorch object atomically via temp file + os.replace.
|
||
|
||
On any failure (including KeyboardInterrupt / SIGTERM), the temp file
|
||
is removed. This makes --resume safe: a partial checkpoint never ends
|
||
up at the destination path.
|
||
|
||
Args:
|
||
obj: Anything torch.save can handle (state dict, full model, etc.).
|
||
path: Destination path. Parent directory is created if missing.
|
||
|
||
Raises:
|
||
Re-raises any error from torch.save after cleaning up the temp file.
|
||
"""
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
fd, tmp = tempfile.mkstemp(suffix=".pt.tmp", dir=path.parent)
|
||
os.close(fd)
|
||
try:
|
||
torch.save(obj, tmp)
|
||
os.replace(tmp, path)
|
||
except BaseException:
|
||
if os.path.exists(tmp):
|
||
os.remove(tmp)
|
||
raise
|
||
|
||
|
||
def clear_vram() -> None:
|
||
"""Free VRAM and reset peak memory stats.
|
||
|
||
Call before starting a new training stage or when switching models.
|
||
"""
|
||
gc.collect()
|
||
if torch.cuda.is_available():
|
||
torch.cuda.empty_cache()
|
||
torch.cuda.reset_peak_memory_stats()
|
||
allocated_gb = torch.cuda.memory_allocated() / 1e9
|
||
logger.info("VRAM cleared. Current usage: %.2f GB", allocated_gb)
|
||
```
|
||
|
||
---
|
||
|
||
## 6. Пример gin-пресета `gtauav_balanced/`
|
||
|
||
### `presets/gtauav_balanced/pipeline.gin`
|
||
|
||
```gin
|
||
# What to train on, where to save, how long.
|
||
PipelineConfig.train_json = 'meta/train_80.json'
|
||
PipelineConfig.test_json = 'meta/test_20.json'
|
||
PipelineConfig.rgb_root = '/data/GTA-UAV-LR'
|
||
PipelineConfig.caption_root = '/data/GTA-UAV-LR-captions'
|
||
PipelineConfig.filter_meta = 'meta/seg_filter.json'
|
||
PipelineConfig.epochs = 10
|
||
PipelineConfig.warmup_epochs = 2
|
||
PipelineConfig.eval_every = 1
|
||
PipelineConfig.seed = 42
|
||
PipelineConfig.output_dir = 'out/gtauav/with_text'
|
||
PipelineConfig.resume_from = None
|
||
```
|
||
|
||
### `presets/gtauav_balanced/hardware.gin`
|
||
|
||
```gin
|
||
# RTX 4090 profile.
|
||
HardwareConfig.device = 'cuda'
|
||
HardwareConfig.batch_size = 8
|
||
HardwareConfig.grad_accum_steps = 8
|
||
HardwareConfig.num_workers = 4
|
||
HardwareConfig.use_amp = True
|
||
HardwareConfig.gradient_checkpointing = True
|
||
HardwareConfig.reserve_gb = 2.0
|
||
```
|
||
|
||
### `presets/gtauav_balanced/models.gin`
|
||
|
||
```gin
|
||
# Architecture: shared DINOv3 WEB encoder, MONA in last 12 of 24 blocks, with text.
|
||
ModelsConfig.dino_web_path = 'nn_models/DINO_WEB/dinov3-vitl16-pretrain-lvd1689m.pth'
|
||
ModelsConfig.dino_sat_path = 'nn_models/DINO_SAT/model.safetensors'
|
||
ModelsConfig.lrsclip_path = 'nn_models/LRSCLIP/DGTRS-CLIP-ViT-L-14.pt'
|
||
ModelsConfig.backbone = 'dinov3'
|
||
ModelsConfig.shared_encoder = True
|
||
ModelsConfig.baseline_mode = False
|
||
ModelsConfig.init_gate = 0.7
|
||
ModelsConfig.mona_bottleneck = 64
|
||
ModelsConfig.mona_last_n_blocks = 12
|
||
```
|
||
|
||
### `presets/gtauav_balanced/training.gin`
|
||
|
||
```gin
|
||
# Loss + optimizer + sampler — the training recipe.
|
||
TrainingConfig.loss_type = 'symmetric'
|
||
TrainingConfig.tau_init = 0.07
|
||
TrainingConfig.tau_min = 0.01
|
||
TrainingConfig.tau_max = 0.1
|
||
TrainingConfig.learnable_temperature = True
|
||
TrainingConfig.label_smoothing = 0.1
|
||
TrainingConfig.weight_q2g = 0.6
|
||
TrainingConfig.weight_g2q = 0.4
|
||
TrainingConfig.hard_mining_k = 0
|
||
TrainingConfig.neg_bank_size = 0
|
||
|
||
TrainingConfig.learning_rate = 1e-4
|
||
TrainingConfig.text_lr_factor = 0.1
|
||
TrainingConfig.weight_decay = 1e-4
|
||
TrainingConfig.grad_clip = 1.0
|
||
|
||
TrainingConfig.sampler_type = 'mutex'
|
||
TrainingConfig.dss_warmup_epochs = 1
|
||
TrainingConfig.dss_reembed_every = 1
|
||
TrainingConfig.dss_knn_device = 'cuda'
|
||
TrainingConfig.dss_use_lsh = False
|
||
```
|
||
|
||
### `presets/gtauav_balanced/tracking.gin`
|
||
|
||
```gin
|
||
# Diagnostics off by default; flip on per experiment.
|
||
TrackingConfig.use_wandb = False
|
||
TrackingConfig.use_tb = True
|
||
TrackingConfig.log_grad_norms = True
|
||
TrackingConfig.use_gradcam = False
|
||
TrackingConfig.gradcam_every = 5
|
||
TrackingConfig.use_profiler = False
|
||
```
|
||
|
||
> **Важно:** ни в одном `.gin` файле нет ни `TrainConfigGTAUAV.*`, ни `InfoNCELoss.*` биндингов. Только 5 классов из `src/conf/`. Каждый параметр имеет ровно одно место, где он определён.
|
||
|
||
---
|
||
|
||
## 7. Декомпозиция `train_gtauav.py` (1296 → ~5 файлов по 100–250 строк)
|
||
|
||
| Сейчас в `train_gtauav.py` | Куда переносится | Размер |
|
||
|---|---|---|
|
||
| `TrainConfigGTAUAV` (`@dataclass + @gin.configurable`) | **Удаляется**, заменяется 5 классами в `src/conf/` | — |
|
||
| Module-level constants `_RGB_ROOT`, `_DINO_WEB`, и т.д. | **Удаляются**, дефолты → `__init__` конфиг-классов | — |
|
||
| `_set_seed()` | `src/utils/seed_utils.py::set_seed` | ~10 строк |
|
||
| `_atomic_save()` | `src/utils/io_utils.py::atomic_save_torch` | ~15 строк |
|
||
| `_clear_vram()` | `src/utils/io_utils.py::clear_vram` | ~10 строк |
|
||
| `_build_param_groups()` | `src/training/trainer.py::Trainer._build_param_groups` (метод) | ~30 строк |
|
||
| `_cosine_warmup_schedule()` | `src/training/trainer.py` (модульная функция) | ~10 строк |
|
||
| `_embed_drone_queries()` (`@torch.no_grad`) | `src/training/trainer.py::Trainer._embed_drone_queries` (`@torch.inference_mode`) | ~30 строк |
|
||
| `_evaluate()` (`@torch.no_grad`) | `src/eval/evaluator.py::evaluate` (`@torch.inference_mode`) | ~150 строк |
|
||
| `CSVLogger` | `src/training/csv_logger.py` | ~80 строк |
|
||
| `train()` (700+ строк) | `src/training/trainer.py::Trainer.run` + приватные методы | ~250 строк |
|
||
| `main()` (с argparse) | `src/main.py` (без argparse) | ~30 строк |
|
||
|
||
### Скелет `Trainer`
|
||
|
||
```python
|
||
# src/training/trainer.py
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from pathlib import Path
|
||
|
||
# ... imports
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class Trainer:
|
||
"""Orchestrates the full GTA-UAV training pipeline.
|
||
|
||
Not @gin.configurable — all parameters arrive as config objects.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
pipeline_cfg: PipelineConfig,
|
||
hardware_cfg: HardwareConfig,
|
||
models_cfg: ModelsConfig,
|
||
training_cfg: TrainingConfig,
|
||
tracking_cfg: TrackingConfig,
|
||
) -> None:
|
||
self.pipeline_cfg = pipeline_cfg
|
||
self.hardware_cfg = hardware_cfg
|
||
self.models_cfg = models_cfg
|
||
self.training_cfg = training_cfg
|
||
self.tracking_cfg = tracking_cfg
|
||
|
||
# Populated lazily in _setup / _build_*.
|
||
self.output_dir: Path | None = None
|
||
self.model: AsymmetricEncoder | None = None
|
||
self.loss_fn: nn.Module | None = None
|
||
self.optimizer: torch.optim.Optimizer | None = None
|
||
self.scheduler: torch.optim.lr_scheduler.LambdaLR | None = None
|
||
self.train_loader: DataLoader | None = None
|
||
self.test_loader: DataLoader | None = None
|
||
self.tracker: ExperimentTracker | None = None
|
||
self.csv_logger: CSVLogger | None = None
|
||
|
||
def run(self) -> None:
|
||
"""Full pipeline: setup → train → eval → save."""
|
||
clear_vram()
|
||
self._setup_output_dir()
|
||
self._build_tracker()
|
||
self._build_model()
|
||
self._build_loss()
|
||
self._build_data_loaders()
|
||
self._build_optimizer_and_scheduler()
|
||
try:
|
||
self._train_loop()
|
||
self._final_evaluation()
|
||
finally:
|
||
self._cleanup()
|
||
|
||
# --- Private helpers below (one method per concern) ---
|
||
|
||
def _setup_output_dir(self) -> None: ...
|
||
def _build_tracker(self) -> None: ...
|
||
def _build_model(self) -> None: ...
|
||
def _build_loss(self) -> None: ...
|
||
def _build_data_loaders(self) -> None: ...
|
||
def _build_optimizer_and_scheduler(self) -> None: ...
|
||
def _build_param_groups(self) -> list[dict]: ...
|
||
def _train_loop(self) -> None: ...
|
||
def _train_one_epoch(self, epoch: int) -> dict[str, float]: ...
|
||
def _evaluate_epoch(self, epoch: int) -> None: ...
|
||
def _save_checkpoint(self, epoch: int) -> None: ...
|
||
def _final_evaluation(self) -> None: ...
|
||
def _cleanup(self) -> None: ...
|
||
```
|
||
|
||
Каждый приватный метод — 20–60 строк, одна ответственность. Читать и тестировать на порядок легче, чем 700-строчную функцию.
|
||
|
||
---
|
||
|
||
## 8. `@torch.inference_mode()` вместо `@torch.no_grad()`
|
||
|
||
В текущем коде:
|
||
|
||
```python
|
||
# src/training/train_gtauav.py
|
||
@torch.no_grad()
|
||
def _embed_drone_queries(...): ...
|
||
|
||
@torch.no_grad()
|
||
def _evaluate(...): ...
|
||
```
|
||
|
||
Стандарт §4.4 требует `@torch.inference_mode()`. Это не просто «нагляднее»: `inference_mode()` дополнительно отключает version counter на тензорах, что даёт небольшой speedup и блокирует случайные in-place правки, которые при `no_grad()` тихо пройдут.
|
||
|
||
При переносе в `src/eval/evaluator.py` и `src/training/trainer.py`:
|
||
|
||
```python
|
||
# src/eval/evaluator.py
|
||
@torch.inference_mode()
|
||
def evaluate(
|
||
model: nn.Module,
|
||
loader: DataLoader,
|
||
device: str,
|
||
loss_fn: nn.Module | None = None,
|
||
epoch: int = 0,
|
||
total_epochs: int = 1,
|
||
k_values: tuple[int, ...] = (1, 5, 10),
|
||
max_batches: int | None = None,
|
||
desc: str = "eval",
|
||
) -> dict[str, float]:
|
||
"""Compute R@K and MRR on the full satellite gallery."""
|
||
# ... rest unchanged ...
|
||
```
|
||
|
||
---
|
||
|
||
## 9. Чеклист по review (полный, по стандарту §6)
|
||
|
||
| Пункт | Текущее состояние | После рефакторинга |
|
||
|---|---|---|
|
||
| `from __future__ import annotations` первой строкой | ⚠️ `train_gtauav.py` ✓; остальные требуют аудита | ✅ во всех новых файлах |
|
||
| Все функции/методы имеют type hints | ⚠️ частично (`_atomic_save(obj: dict, ...)` слабо типизировано) | ✅ строгие type hints |
|
||
| Google-style docstrings на публичных классах/функциях | ✅ в основном есть | ✅ + покрытие 100% |
|
||
| `@gin.configurable` только на классах | ❌ на `dataclass` (запрещено) | ✅ только на классах из `src/conf/` |
|
||
| Нет `dataclass` + gin | ❌ `TrainConfigGTAUAV` нарушает | ✅ удалён |
|
||
| Нет `argparse` | ❌ 15+ CLI флагов в `main()` | ✅ убран полностью |
|
||
| Нет захардкоженных model ID / промптов / размеров | ❌ `_DINO_WEB`, `_DINO_SAT`, `_LRSCLIP`, `_RGB_ROOT` на module-level | ✅ только дефолты в `__init__` конфигов |
|
||
| `gin.clear_config()` перед каждой загрузкой | ❌ не вызывается нигде | ✅ внутри `load_all_configs()` |
|
||
| Один источник правды для каждого параметра | ❌ дубль `TrainConfigGTAUAV.tau_init` ↔ `InfoNCELoss.temperature_init` | ✅ только `TrainingConfig.*` |
|
||
| Модели выгружаются после использования | ⚠️ `_clear_vram` в начале, нет `del model` в конце | ✅ `_cleanup()` в `Trainer` |
|
||
| Файлы сохраняются атомарно (temp + replace + cleanup на ошибке) | ⚠️ нет `try/except` для очистки `.tmp` | ✅ `atomic_save_torch` с `tempfile.mkstemp` |
|
||
| Seed установлен | ✅ `_set_seed(42)` | ✅ через `src/utils/seed_utils.py::set_seed` |
|
||
| `@torch.inference_mode()` на inference-функциях | ❌ используется `@torch.no_grad()` | ✅ заменено везде |
|
||
| Английский язык кода/комментариев | ✅ хорошо | ✅ |
|
||
| Импорты: stdlib → third-party → local, разделены пустыми строками | ⚠️ требует аудита | ✅ во всех новых файлах |
|
||
|
||
---
|
||
|
||
## 10. План миграции — 7 коммитов
|
||
|
||
> Каждый коммит самодостаточен и оставляет код рабочим. После каждого коммита прогон 1 эпохи на маленьком сабсете и сверка `r@1_q2g`/`loss` до 4-го знака.
|
||
|
||
| # | Коммит | Что делается | Что не ломается |
|
||
|---|---|---|---|
|
||
| 1 | **utils** | Создать `src/utils/{io_utils,seed_utils,path_utils}.py`. Переключить `train_gtauav.py` на новые имена | Старый код продолжает работать |
|
||
| 2 | **conf infrastructure** | Создать 5 классов в `src/conf/` + `config_loader.py`. **Не использовать.** | Существующий `TrainConfigGTAUAV` остаётся |
|
||
| 3 | **evaluator** | Вынести `_evaluate` → `src/eval/evaluator.py`, `@torch.inference_mode()` | `train_gtauav.py` импортирует оттуда |
|
||
| 4 | **csv logger + trainer skeleton** | Вынести `CSVLogger` → `src/training/csv_logger.py`. Создать `Trainer` (пока пустой), но не использовать | Параллельно работают оба пути |
|
||
| 5 | **Trainer.run() реализация** | Перенести логику `train()` в `Trainer.run()` методы. Создать первый пресет `presets/gtauav_balanced/`. Создать `src/main.py` | Обе точки входа работают; сравнить метрики |
|
||
| 6 | **InfoNCELoss decouple** | Убрать `@gin.configurable` с `InfoNCELoss`. Удалить `InfoNCELoss.*` биндинги из всех `.gin` | `TrainingConfig` единственный источник |
|
||
| 7 | **cleanup** | Удалить `TrainConfigGTAUAV`, `argparse`, `train_gtauav.py::main`, старую `conf/` | Только новый путь |
|
||
|
||
**Контрольная точка**: после коммита 5 запустить **обе** точки входа (`python -m src.training.train_gtauav --config conf/gtauav_balanced.gin` и `python -m src.main` с пресетом `gtauav_balanced`) на 1 эпохе на 16 батчах. Метрики `r@1_q2g`, `r@5_q2g`, `loss`, `tau`, `gate_q`, `gate_g` должны совпадать до 4-го знака. Любое расхождение — баг рефакторинга, не логики.
|
||
|
||
---
|
||
|
||
## 11. Что **не** трогать
|
||
|
||
Эти модули содержат рабочую ML-логику и не подлежат рефакторингу в рамках этой задачи:
|
||
|
||
- `src/models/asymmetric_encoder.py`
|
||
- `src/models/dgtrs/`
|
||
- `src/models/adapters.py` (MONA, LoRA)
|
||
- `src/datasets/gtauav_dataset.py`
|
||
- `src/datasets/dynamic_similarity_sampler.py`
|
||
- `src/datasets/mutually_exclusive_sampler.py`
|
||
- `src/datasets/embedding_cache.py`
|
||
- `src/losses/multi_infonce.py` — **только** убрать `@gin.configurable`, остальное не трогать
|
||
- `src/losses/weighted_infonce.py`, `src/losses/hard_negatives.py`
|
||
- `src/training/grad_monitor.py`, `gradcam.py`, `profiling.py`, `plot_metrics.py`, `trackers.py`
|
||
|
||
Цель этого рефакторинга — **только конфиг и точка входа**. Никаких изменений в формуле обучения, в модельной архитектуре, в семплерах или в формуле loss. Метрики после рефакторинга должны быть идентичны метрикам до — иначе это регрессия, а не рефакторинг.
|
||
|
||
---
|
||
|
||
## 12. Опционально, но окупится: smoke-test
|
||
|
||
```python
|
||
# tests/test_smoke.py
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
|
||
from src.conf.config_loader import load_all_configs
|
||
|
||
|
||
def test_load_all_configs_returns_5_keys(tmp_path: Path) -> None:
|
||
"""load_all_configs returns exactly the 5 expected keys."""
|
||
# Write minimal .gin files into tmp_path/.
|
||
(tmp_path / "pipeline.gin").write_text("PipelineConfig.epochs = 1\n")
|
||
(tmp_path / "hardware.gin").write_text("HardwareConfig.batch_size = 2\n")
|
||
(tmp_path / "models.gin").write_text("ModelsConfig.backbone = 'dinov3'\n")
|
||
(tmp_path / "training.gin").write_text("TrainingConfig.tau_init = 0.07\n")
|
||
(tmp_path / "tracking.gin").write_text("TrackingConfig.use_tb = False\n")
|
||
|
||
cfgs = load_all_configs(str(tmp_path) + "/")
|
||
assert set(cfgs.keys()) == {"pipeline", "hardware", "models", "training", "tracking"}
|
||
assert cfgs["pipeline"].epochs == 1
|
||
assert cfgs["hardware"].batch_size == 2
|
||
|
||
|
||
def test_load_all_configs_clears_state_between_calls(tmp_path: Path) -> None:
|
||
"""Two calls with different .gin do not leak state."""
|
||
# Call 1: epochs=10
|
||
(tmp_path / "pipeline.gin").write_text("PipelineConfig.epochs = 10\n")
|
||
for name in ("hardware", "models", "training", "tracking"):
|
||
(tmp_path / f"{name}.gin").write_text("") # empty is fine
|
||
cfgs1 = load_all_configs(str(tmp_path) + "/")
|
||
assert cfgs1["pipeline"].epochs == 10
|
||
|
||
# Call 2: epochs=20 — must NOT inherit 10 from prior call.
|
||
(tmp_path / "pipeline.gin").write_text("PipelineConfig.epochs = 20\n")
|
||
cfgs2 = load_all_configs(str(tmp_path) + "/")
|
||
assert cfgs2["pipeline"].epochs == 20 # would fail if clear_config absent
|
||
```
|
||
|
||
Второй тест ловит самый коварный баг при работе с gin — пропущенный `clear_config()`. Один раз отладишь — ловится за полсекунды на каждом запуске CI.
|
||
|
||
---
|
||
|
||
## Резюме одной строкой
|
||
|
||
**Удалить `TrainConfigGTAUAV` (`@dataclass + @gin.configurable`) и argparse → разделить на 5 узких конфиг-классов в `src/conf/` → грузить через единственный `load_all_configs()` с `gin.clear_config()` → разрезать `train_gtauav.py` (1296 строк) на `Trainer` + `evaluator` + `csv_logger` + `utils` + `main` → убрать дубль gin-биндингов между `TrainConfigGTAUAV` и `InfoNCELoss` (один источник правды — `TrainingConfig`) → `@torch.no_grad` → `@torch.inference_mode`.**
|