Miroslav Simko <ms@iolabs.ch> 2026-09-02T08:47:59+02:00
Commit #81 · 27 snippets
CLAUDE.md | 6 +- pyproject.toml | 12 ++- scripts/train.py | 49 ++++++--- src/train/config.py | 248 +++++++++++++++++++++++-------------------- test/test_ml_harness.py | 2 +- test/test_train_overrides.py | 85 ++++++++++++--- 6 files changed, 257 insertions(+), 145 deletions(-)
| 1 | """YAML-backed configuration for the training harness. | 1 | """YAML-backed configuration for the training harness. |
| 2 | 2 | ||
| 3 | Plain dataclasses + yaml, no config framework. Unknown keys raise, so config | 3 | Pydantic models on `iolabs.common.config_loader.ConfigModel` + yaml. Unknown keys |
| 4 | typos fail fast instead of silently training with defaults. | 4 | raise, so config typos fail fast instead of silently training with defaults, and |
| 5 | values are coerced by the shared fleet matrix. | ||
| 6 | |||
| 7 | Adding a config key = adding one field with its default to the model below. | ||
| 8 | Instances are frozen: derive a changed config with ``model_copy(update=...)``. | ||
| 5 | """ | 9 | """ |
| 6 | from dataclasses import dataclass, field, fields | 10 | import logging |
| 11 | from collections.abc import Mapping | ||
| 7 | from pathlib import Path | 12 | from pathlib import Path |
| 8 | from typing import Any, TypeVar | 13 | from typing import Any, Literal |
| 9 | 14 | ||
| 15 | import pydantic | ||
| 10 | import yaml | 16 | import yaml |
| 17 | from iolabs.common import config_loader | ||
| 18 | |||
| 19 | logger = logging.getLogger(__name__) | ||
| 11 | 20 | ||
| 12 | T = TypeVar("T") | 21 | _STROKE_KINDS = frozenset({"solid", "dashed"}) |
| 22 | _PAIR_LIST_FIELDS = ("pairs", "val_pairs", "test_pairs") | ||
| 23 | _PATH_FIELD_SUFFIXES = ("path", "paths", "dir", "dirs", "root", "roots") | ||
| 13 | 24 | ||
| 14 | 25 | ||
| 15 | def _build(cls: type[T], data: dict[str, Any] | None, where: str) -> T: | 26 | class ConfigError(config_loader.ConfigError): |
| 16 | data = dict(data or {}) | 27 | """Raised when a harness config holds unknown keys or invalid values.""" |
| 17 | known = {f.name for f in fields(cls)} | ||
| 18 | unknown = sorted(set(data) - known) | ||
| 19 | if unknown: | ||
| 20 | raise KeyError(f"unknown key(s) {unknown} in config section {where!r}; " | ||
| 21 | f"known keys: {sorted(known)}") | ||
| 22 | return cls(**data) | ||
| 23 | 28 | ||
| 24 | 29 | ||
| 25 | @dataclass | 30 | class PairSpec(config_loader.ConfigModel): |
| 26 | class PairSpec: | ||
| 27 | """One images-dir / masks-dir pair (see src.dataset.index_tile_pairs).""" | 31 | """One images-dir / masks-dir pair (see src.dataset.index_tile_pairs).""" |
| 28 | images: str | 32 | images: str |
| 29 | masks: str | 33 | masks: str |
| 30 | 34 | ||
| 31 | 35 | ||
| 32 | @dataclass | 36 | class DataConfig(config_loader.ConfigModel): |
| 33 | class DataConfig: | 37 | """Tile corpus, split, crop sampling and label rasterization knobs.""" |
| 34 | pairs: list = field(default_factory=list) | 38 | pairs: list[PairSpec] = [] |
| 35 | # Optional explicit, pre-split directories (e.g. the symlink folders under | 39 | # Optional explicit, pre-split directories (e.g. the symlink folders under |
| 36 | # data/02_processed/<ds>/{train,val,test} built by scripts/build_processed_ | 40 | # data/02_processed/<ds>/{train,val,test} built by scripts/build_processed_ |
| 37 | # splits.py). When val_pairs is set, `pairs` is used in full as the training | 41 | # splits.py). When val_pairs is set, `pairs` is used in full as the training |
| 38 | # set and is NOT re-split — val_fraction is ignored — so a geographic split | 42 | # set and is NOT re-split — val_fraction is ignored — so a geographic split |
| 39 | # materialised on disk is honoured verbatim. test_pairs feeds test_dataloader. | 43 | # materialised on disk is honoured verbatim. test_pairs feeds test_dataloader. |
| 40 | val_pairs: list = field(default_factory=list) | 44 | val_pairs: list[PairSpec] = [] |
| 41 | test_pairs: list = field(default_factory=list) | 45 | test_pairs: list[PairSpec] = [] |
| 42 | crop_size: int = 512 | 46 | crop_size: int = pydantic.Field(default=512, gt=0) |
| 43 | batch_size: int = 16 | 47 | batch_size: int = pydantic.Field(default=16, gt=0) |
| 44 | num_workers: int = 4 | 48 | num_workers: int = pydantic.Field(default=4, ge=0) |
| 45 | val_fraction: float = 0.15 | 49 | val_fraction: float = pydantic.Field(default=0.15, ge=0.0, le=1.0) |
| 46 | crops_per_tile: int = 4 | 50 | crops_per_tile: int = pydantic.Field(default=4, gt=0) |
| 47 | pos_crop_prob: float = 0.7 | 51 | pos_crop_prob: float = pydantic.Field(default=0.7, ge=0.0, le=1.0) |
| 48 | min_valid_fraction: float = 0.10 | 52 | min_valid_fraction: float = pydantic.Field(default=0.10, ge=0.0, le=1.0) |
| 49 | augment: bool = True | 53 | augment: bool = True |
| 50 | label_source: str = "rendered" # rendered *_lines.png | vector *_vectors.json | review | 54 | # rendered *_lines.png | vector *_vectors.json | reviewer-confirmed tiles |
| 51 | label_stroke_px: int | float | dict[str, float] = 4 # scalar or {solid, dashed} | 55 | label_source: Literal["rendered", "vector", "review"] = "rendered" |
| 52 | review_statuses: list = field(default_factory=lambda: ["ok"]) # label_source: review | 56 | # scalar or {solid, dashed}; int stays int so reports render "5", not "5.0" |
| 53 | 57 | label_stroke_px: int | float | dict[str, int | float] = 4 | |
| 54 | 58 | review_statuses: list[str] = ["ok"] # label_source: review | |
| 55 | _STROKE_KINDS = frozenset({"solid", "dashed"}) | ||
| 56 | 59 | ||
| 57 | 60 | @pydantic.field_validator("label_stroke_px", mode="before") | |
| 58 | def _parse_label_stroke_px(value: Any) -> int | float | dict[str, float]: | 61 | @classmethod |
| 59 | """Scalar > 0, or exactly ``{solid, dashed}`` with positive numeric values.""" | 62 | def _check_label_stroke_px(cls, value: Any) -> Any: |
| 60 | if isinstance(value, (int, float)) and not isinstance(value, bool): | 63 | """Scalar > 0, or exactly ``{solid, dashed}`` with positive numeric values.""" |
| 61 | if value <= 0: | 64 | if isinstance(value, (int, float)) and not isinstance(value, bool): |
| 62 | raise ValueError(f"data.label_stroke_px must be > 0, got {value}") | 65 | if value <= 0: |
| 63 | return value | 66 | raise ValueError(f"data.label_stroke_px must be > 0, got {value}") |
| 64 | if isinstance(value, dict): | 67 | return value |
| 65 | keys = set(value) | 68 | if isinstance(value, Mapping): |
| 66 | if keys != _STROKE_KINDS: | 69 | keys = set(value) |
| 67 | raise ValueError( | 70 | if keys != _STROKE_KINDS: |
| 68 | f"data.label_stroke_px mapping must have exactly the keys " | ||
| 69 | f"{sorted(_STROKE_KINDS)}, got {sorted(keys)}") | ||
| 70 | out: dict[str, float] = {} | ||
| 71 | for kind, width in value.items(): | ||
| 72 | if (not isinstance(width, (int, float)) or isinstance(width, bool) | ||
| 73 | or width <= 0): | ||
| 74 | raise ValueError( | 71 | raise ValueError( |
| 75 | f"data.label_stroke_px[{kind!r}] must be a positive number, " | 72 | f"data.label_stroke_px mapping must have exactly the keys " |
| 76 | f"got {width!r}") | 73 | f"{sorted(_STROKE_KINDS)}, got {sorted(keys)}") |
| 77 | out[kind] = width | 74 | for kind, width in value.items(): |
| 78 | return out | 75 | if (not isinstance(width, (int, float)) or isinstance(width, bool) |
| 79 | raise ValueError( | 76 | or width <= 0): |
| 80 | f"data.label_stroke_px must be a positive number or a " | 77 | raise ValueError( |
| 81 | f"{{solid, dashed}} mapping, got {type(value).__name__}: {value!r}") | 78 | f"data.label_stroke_px[{kind!r}] must be a positive number, " |
| 79 | f"got {width!r}") | ||
| 80 | return dict(value) | ||
| 81 | raise ValueError( | ||
| 82 | f"data.label_stroke_px must be a positive number or a " | ||
| 83 | f"{{solid, dashed}} mapping, got {type(value).__name__}: {value!r}") | ||
| 82 | 84 | ||
| 83 | 85 | ||
| 84 | def _reroot_path(value: Any, root: Path) -> Any: | 86 | def _reroot_path(value: Any, root: Path) -> Any: |
| 87 | """Return *value* as an absolute path string, joined onto *root* if relative.""" | ||
| 85 | path = Path(value) | 88 | path = Path(value) |
| 86 | if path.is_absolute(): | 89 | if path.is_absolute(): |
| 87 | return str(path) | 90 | return str(path) |
| 88 | return str(root / path) | 91 | return str(root / path) |
| 89 | 92 | ||
| 90 | 93 | ||
| 91 | def _reroot_path_value(value: Any, root: Path) -> Any: | 94 | def _reroot_path_value(value: Any, root: Path) -> Any: |
| 95 | """Re-root every path-like leaf of a scalar/list/tuple/dict value.""" | ||
| 92 | if isinstance(value, (str, Path)): | 96 | if isinstance(value, (str, Path)): |
| 93 | return _reroot_path(value, root) | 97 | return _reroot_path(value, root) |
| 94 | if isinstance(value, list): | 98 | if isinstance(value, list): |
| 95 | return [_reroot_path_value(item, root) for item in value] | 99 | return [_reroot_path_value(item, root) for item in value] |
| 100 | return value | 104 | return value |
| 101 | 105 | ||
| 102 | 106 | ||
| 103 | def reroot_data_paths(data_cfg: DataConfig, root: str | Path) -> DataConfig: | 107 | def reroot_data_paths(data_cfg: DataConfig, root: str | Path) -> DataConfig: |
| 104 | """Re-root relative dataset paths in a DataConfig onto root.""" | 108 | """Re-root relative dataset paths in a DataConfig onto root. |
| 109 | |||
| 110 | Config models are frozen, so this returns an updated copy instead of | ||
| 111 | mutating ``data_cfg`` in place. | ||
| 112 | |||
| 113 | Args: | ||
| 114 | data_cfg: The data section to re-root; never mutated. | ||
| 115 | root: Directory relative paths are joined onto. Absolute paths are kept. | ||
| 116 | |||
| 117 | Returns: | ||
| 118 | A copy of ``data_cfg`` with the pair lists and every path-like field | ||
| 119 | (name ending in path/paths/dir/dirs/root/roots) made absolute. | ||
| 120 | """ | ||
| 105 | root = Path(root) | 121 | root = Path(root) |
| 106 | for pair_list_name in ("pairs", "val_pairs", "test_pairs"): | 122 | updates: dict[str, Any] = {} |
| 107 | for pair in getattr(data_cfg, pair_list_name): | 123 | for name in _PAIR_LIST_FIELDS: |
| 108 | pair.images = _reroot_path(pair.images, root) | 124 | specs = getattr(data_cfg, name) |
| 109 | pair.masks = _reroot_path(pair.masks, root) | 125 | if specs: |
| 110 | 126 | updates[name] = [ | |
| 111 | path_field_suffixes = ("path", "paths", "dir", "dirs", "root", "roots") | 127 | spec.model_copy(update={ |
| 112 | for field_info in fields(data_cfg): | 128 | "images": _reroot_path(spec.images, root), |
| 113 | name = field_info.name | 129 | "masks": _reroot_path(spec.masks, root)}) |
| 114 | if name in {"pairs", "val_pairs", "test_pairs"}: | 130 | for spec in specs] |
| 131 | for name in type(data_cfg).model_fields: | ||
| 132 | if name in _PAIR_LIST_FIELDS or not name.endswith(_PATH_FIELD_SUFFIXES): | ||
| 115 | continue | 133 | continue |
| 116 | if name.endswith(path_field_suffixes): | 134 | updates[name] = _reroot_path_value(getattr(data_cfg, name), root) |
| 117 | setattr(data_cfg, name, _reroot_path_value(getattr(data_cfg, name), root)) | 135 | return data_cfg.model_copy(update=updates) |
| 118 | return data_cfg | ||
| 119 | 136 | ||
| 120 | 137 | ||
| 121 | @dataclass | 138 | class ModelConfig(config_loader.ConfigModel): |
| 122 | class ModelConfig: | 139 | """Segmentation-models-pytorch architecture/encoder selection.""" |
| 123 | name: str = "unet" | 140 | name: str = "unet" |
| 124 | encoder_name: str = "resnet18" | 141 | encoder_name: str = "resnet18" |
| 125 | encoder_weights: str | None = "imagenet" # None = train from scratch | 142 | encoder_weights: str | None = "imagenet" # None = train from scratch |
| 126 | in_channels: int = 1 | 143 | in_channels: int = pydantic.Field(default=1, gt=0) |
| 127 | num_classes: int = 3 | 144 | num_classes: int = pydantic.Field(default=3, gt=0) |
| 128 | extra: dict = field(default_factory=dict) # passed through to the model factory | 145 | extra: dict[str, Any] = {} # passed through to the model factory |
| 129 | 146 | ||
| 130 | 147 | ||
| 131 | @dataclass | 148 | class LossConfig(config_loader.ConfigModel): |
| 132 | class LossConfig: | 149 | """Loss selection by registry name plus factory keyword arguments.""" |
| 133 | name: str = "dice_focal" | 150 | name: str = "dice_focal" |
| 134 | args: dict = field(default_factory=dict) | 151 | args: dict[str, Any] = {} |
| 135 | 152 | ||
| 136 | 153 | ||
| 137 | @dataclass | 154 | class TrainerConfig(config_loader.ConfigModel): |
| 138 | class TrainerConfig: | 155 | """Lightning trainer, logger, and callback knobs.""" |
| 139 | max_epochs: int = -1 # -1 = no cap; early stopping ends training instead | 156 | max_epochs: int = pydantic.Field(default=-1, ge=-1) # -1 = no cap |
| 140 | lr: float = 3.0e-4 | 157 | lr: float = pydantic.Field(default=3.0e-4, gt=0) |
| 141 | weight_decay: float = 1.0e-4 | 158 | weight_decay: float = pydantic.Field(default=1.0e-4, ge=0) |
| 142 | precision: str = "auto" # auto -> 16-mixed on CUDA, 32-true on CPU | 159 | precision: str = "auto" # auto -> 16-mixed on CUDA, 32-true on CPU |
| 143 | accumulate_grad_batches: int = 1 # match effective batch across experiments | 160 | accumulate_grad_batches: int = pydantic.Field(default=1, ge=1) |
| 144 | accelerator: str = "auto" | 161 | accelerator: str = "auto" |
| 145 | devices: int | str = 1 | 162 | devices: int | str = 1 |
| 146 | viz_every_n_epochs: int = 2 | 163 | viz_every_n_epochs: int = pydantic.Field(default=2, ge=0) |
| 147 | viz_samples: int = 4 | 164 | viz_samples: int = pydantic.Field(default=4, ge=0) |
| 148 | monitor: str = "val/f1_mean_fg" | 165 | monitor: str = "val/f1_mean_fg" |
| 149 | monitor_mode: str = "max" | 166 | monitor_mode: Literal["max", "min"] = "max" |
| 150 | early_stop_monitor: str = "val/loss" # stop when this stops improving | 167 | early_stop_monitor: str = "val/loss" # stop when this stops improving |
| 151 | early_stop_mode: str = "min" | 168 | early_stop_mode: Literal["min", "max"] = "min" |
| 152 | early_stop_patience: int = 4 # epochs without improvement before stopping; 0 disables | 169 | # epochs without improvement before stopping; 0 disables |
| 170 | early_stop_patience: int = pydantic.Field(default=4, ge=0) | ||
| 153 | log_dir: str = "runs" | 171 | log_dir: str = "runs" |
| 154 | log_every_n_steps: int = 10 | 172 | log_every_n_steps: int = pydantic.Field(default=10, ge=1) |
| 155 | 173 | ||
| 156 | 174 | ||
| 157 | @dataclass | 175 | class HarnessConfig(config_loader.ConfigModel): |
| 158 | class HarnessConfig: | 176 | """Top-level training config: one YAML file, one instance.""" |
| 159 | experiment: str = "experiment" | 177 | experiment: str = "experiment" |
| 160 | seed: int = 1337 | 178 | seed: int = 1337 |
| 161 | data: DataConfig = field(default_factory=DataConfig) | 179 | data: DataConfig = DataConfig() |
| 162 | model: ModelConfig = field(default_factory=ModelConfig) | 180 | model: ModelConfig = ModelConfig() |
| 163 | loss: LossConfig = field(default_factory=LossConfig) | 181 | loss: LossConfig = LossConfig() |
| 164 | train: TrainerConfig = field(default_factory=TrainerConfig) | 182 | train: TrainerConfig = TrainerConfig() |
| 165 | 183 | ||
| 166 | @classmethod | 184 | @classmethod |
| 167 | def from_yaml(cls, path: str | Path) -> "HarnessConfig": | 185 | def from_yaml(cls, path: str | Path) -> "HarnessConfig": |
| 168 | raw = yaml.safe_load(Path(path).read_text()) or {} | 186 | """Loads and validates a harness YAML config. |
| 169 | data = _build(DataConfig, raw.pop("data", {}), "data") | 187 | |
| 170 | data.pairs = [_build(PairSpec, p, "data.pairs[]") for p in data.pairs] | 188 | Args: |
| 171 | data.val_pairs = [_build(PairSpec, p, "data.val_pairs[]") for p in data.val_pairs] | 189 | path: Path of the YAML file, read as UTF-8. |
| 172 | data.test_pairs = [_build(PairSpec, p, "data.test_pairs[]") for p in data.test_pairs] | 190 | |
| 173 | data.label_stroke_px = _parse_label_stroke_px(data.label_stroke_px) | 191 | Returns: |
| 174 | cfg = cls( | 192 | The validated, frozen config. |
| 175 | experiment=raw.pop("experiment", cls.experiment), | 193 | |
| 176 | seed=raw.pop("seed", cls.seed), | 194 | Raises: |
| 177 | data=data, | 195 | FileNotFoundError: If ``path`` does not exist. |
| 178 | model=_build(ModelConfig, raw.pop("model", {}), "model"), | 196 | ConfigError: If the document is not a mapping, holds an unknown key, |
| 179 | loss=_build(LossConfig, raw.pop("loss", {}), "loss"), | 197 | or holds a value invalid for its field. Derives from |
| 180 | train=_build(TrainerConfig, raw.pop("train", {}), "train"), | 198 | ``ValueError``. |
| 181 | ) | 199 | """ |
| 182 | if raw: | 200 | raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {} |
| 183 | raise KeyError(f"unknown top-level config key(s) {sorted(raw)} in {path}") | 201 | if not isinstance(raw, Mapping): |
| 184 | return cfg | 202 | raise ConfigError( |
| 203 | f"config {str(path)!r} must contain a top-level mapping, " | ||
| 204 | f"got {type(raw).__name__}") | ||
| 205 | return config_loader.validate_config( | ||
| 206 | cls, raw, context=str(path), error_cls=ConfigError) |
| 22 | segformer, ...) and model.encoder_name any smp encoder. encoder_weights: imagenet | 22 | segformer, ...) and model.encoder_name any smp encoder. encoder_weights: imagenet |
| 23 | downloads pretrained encoder weights on first use. | 23 | downloads pretrained encoder weights on first use. |
| 24 | """ | 24 | """ |
| 25 | import argparse | 25 | import argparse |
| 26 | from typing import Any | ||
| 26 | 27 | ||
| 27 | import lightning.pytorch as pl | 28 | import lightning.pytorch as pl |
| 28 | import torch | 29 | import torch |
| 29 | from lightning.pytorch.callbacks import ( | 30 | from lightning.pytorch.callbacks import ( |
| 53 | parser.add_argument("--cpu", action="store_true", help="force CPU training") | 54 | parser.add_argument("--cpu", action="store_true", help="force CPU training") |
| 54 | return parser.parse_args() | 55 | return parser.parse_args() |
| 55 | 56 | ||
| 56 | 57 | ||
| 58 | def apply_cli_overrides(cfg: config.HarnessConfig, | ||
| 59 | args: argparse.Namespace) -> config.HarnessConfig: | ||
| 60 | """Returns a copy of ``cfg`` with the CLI overrides applied. | ||
| 61 | |||
| 62 | Config models are frozen, so overrides are applied by copying each touched | ||
| 63 | section instead of assigning to it. | ||
| 64 | |||
| 65 | Args: | ||
| 66 | cfg: The config parsed from the YAML file. | ||
| 67 | args: Parsed CLI arguments; ``None``/empty values override nothing. | ||
| 68 | |||
| 69 | Returns: | ||
| 70 | ``cfg`` itself when no override was given, otherwise an updated copy. | ||
| 71 | """ | ||
| 72 | sections: dict[str, dict[str, Any]] = {"train": {}, "data": {}, "model": {}} | ||
| 73 | if args.log_dir: | ||
| 74 | sections["train"]["log_dir"] = args.log_dir | ||
| 75 | if args.num_workers is not None: | ||
| 76 | sections["data"]["num_workers"] = args.num_workers | ||
| 77 | if args.max_epochs is not None: | ||
| 78 | sections["train"]["max_epochs"] = args.max_epochs | ||
| 79 | if args.batch_size is not None: | ||
| 80 | sections["data"]["batch_size"] = args.batch_size | ||
| 81 | if args.model: | ||
| 82 | sections["model"]["name"] = args.model | ||
| 83 | if args.encoder: | ||
| 84 | sections["model"]["encoder_name"] = args.encoder | ||
| 85 | updates = {name: getattr(cfg, name).model_copy(update=values) | ||
| 86 | for name, values in sections.items() if values} | ||
| 87 | return cfg.model_copy(update=updates) if updates else cfg | ||
| 88 | |||
| 89 | |||
| 57 | def main() -> None: | 90 | def main() -> None: |
| 58 | args = parse_args() | 91 | args = parse_args() |
| 59 | cfg = config.HarnessConfig.from_yaml(args.config) | 92 | cfg = config.HarnessConfig.from_yaml(args.config) |
| 60 | if args.data_root: | 93 | if args.data_root: |
| 61 | config.reroot_data_paths(cfg.data, args.data_root) | 94 | cfg = cfg.model_copy(update={ |
| 62 | if args.log_dir: | 95 | "data": config.reroot_data_paths(cfg.data, args.data_root)}) |
| 63 | cfg.train.log_dir = args.log_dir | ||
| 64 | if args.num_workers is not None: | ||
| 65 | cfg.data.num_workers = args.num_workers | ||
| 66 | if cfg.model.num_classes != len(tiles.CLASS_NAMES): | 96 | if cfg.model.num_classes != len(tiles.CLASS_NAMES): |
| 67 | raise ValueError( | 97 | raise ValueError( |
| 68 | f"model.num_classes={cfg.model.num_classes} but the harness tracks " | 98 | f"model.num_classes={cfg.model.num_classes} but the harness tracks " |
| 69 | f"{len(tiles.CLASS_NAMES)} classes {tiles.CLASS_NAMES} — metrics would mis-bin") | 99 | f"{len(tiles.CLASS_NAMES)} classes {tiles.CLASS_NAMES} — metrics would mis-bin") |
| 70 | if args.max_epochs is not None: | 100 | cfg = apply_cli_overrides(cfg, args) |
| 71 | cfg.train.max_epochs = args.max_epochs | ||
| 72 | if args.batch_size is not None: | ||
| 73 | cfg.data.batch_size = args.batch_size | ||
| 74 | if args.model: | ||
| 75 | cfg.model.name = args.model | ||
| 76 | if args.encoder: | ||
| 77 | cfg.model.encoder_name = args.encoder | ||
| 78 | 101 | ||
| 79 | pl.seed_everything(cfg.seed, workers=True) | 102 | pl.seed_everything(cfg.seed, workers=True) |
| 80 | 103 | ||
| 81 | dm = datamodule.TilesDataModule(cfg.data) | 104 | dm = datamodule.TilesDataModule(cfg.data) |
| 185 | cfg = HarnessConfig.from_yaml(good) | 185 | cfg = HarnessConfig.from_yaml(good) |
| 186 | assert cfg.experiment == "t" and cfg.model.name == "unet" | 186 | assert cfg.experiment == "t" and cfg.model.name == "unet" |
| 187 | bad = tmp_path / "bad.yaml" | 187 | bad = tmp_path / "bad.yaml" |
| 188 | bad.write_text("model:\n encoder: oops\n") | 188 | bad.write_text("model:\n encoder: oops\n") |
| 189 | with pytest.raises(KeyError, match="encoder"): | 189 | with pytest.raises(ValueError, match="encoder"): |
| 190 | HarnessConfig.from_yaml(bad) | 190 | HarnessConfig.from_yaml(bad) |
| 191 | 191 | ||
| 192 | 192 | ||
| 193 | def test_shipped_baseline_config_parses() -> None: | 193 | def test_shipped_baseline_config_parses() -> None: |
| 1 | """Unit tests for train.py configuration overrides.""" | 1 | """Unit tests for train.py configuration overrides.""" |
| 2 | from dataclasses import dataclass, field, fields | 2 | import argparse |
| 3 | import importlib.util | ||
| 4 | import sys | ||
| 3 | from pathlib import Path | 5 | from pathlib import Path |
| 4 | 6 | ||
| 5 | from src.train.config import DataConfig, PairSpec, reroot_data_paths | 7 | import pytest |
| 8 | |||
| 9 | from src.train.config import DataConfig, HarnessConfig, PairSpec, reroot_data_paths | ||
| 10 | |||
| 11 | _REPO_ROOT = Path(__file__).resolve().parents[1] | ||
| 12 | _CLI_FLAGS = ("log_dir", "num_workers", "max_epochs", "batch_size", "model", "encoder") | ||
| 13 | |||
| 14 | |||
| 15 | def _train_script(): | ||
| 16 | """Imports scripts/train.py as a module; skips when the ml extra is missing.""" | ||
| 17 | pytest.importorskip("torch") | ||
| 18 | pytest.importorskip("lightning") | ||
| 19 | pytest.importorskip("segmentation_models_pytorch") | ||
| 20 | pytest.importorskip("albumentations") | ||
| 21 | if "train_script" in sys.modules: | ||
| 22 | return sys.modules["train_script"] | ||
| 23 | spec = importlib.util.spec_from_file_location( | ||
| 24 | "train_script", _REPO_ROOT / "scripts" / "train.py") | ||
| 25 | module = importlib.util.module_from_spec(spec) | ||
| 26 | sys.modules["train_script"] = module | ||
| 27 | spec.loader.exec_module(module) | ||
| 28 | return module | ||
| 29 | |||
| 30 | |||
| 31 | def _args(**overrides: object) -> argparse.Namespace: | ||
| 32 | """Builds a parsed-CLI namespace where unset flags are None.""" | ||
| 33 | return argparse.Namespace(**{name: overrides.get(name) for name in _CLI_FLAGS}) | ||
| 6 | 34 | ||
| 7 | 35 | ||
| 8 | def test_reroot_data_paths_rewrites_relative_pair_dirs(tmp_path: Path) -> None: | 36 | def test_reroot_data_paths_rewrites_relative_pair_dirs(tmp_path: Path) -> None: |
| 9 | cfg = DataConfig(pairs=[ | 37 | cfg = DataConfig(pairs=[ |
| 10 | PairSpec(images="data/train/images", masks="data/train/masks"), | 38 | PairSpec(images="data/train/images", masks="data/train/masks"), |
| 11 | ]) | 39 | ]) |
| 12 | 40 | ||
| 13 | reroot_data_paths(cfg, tmp_path) | 41 | cfg = reroot_data_paths(cfg, tmp_path) |
| 14 | 42 | ||
| 15 | assert cfg.pairs[0].images == str(tmp_path / "data/train/images") | 43 | assert cfg.pairs[0].images == str(tmp_path / "data/train/images") |
| 16 | assert cfg.pairs[0].masks == str(tmp_path / "data/train/masks") | 44 | assert cfg.pairs[0].masks == str(tmp_path / "data/train/masks") |
| 17 | 45 |
| 20 | cfg = DataConfig(pairs=[ | 48 | cfg = DataConfig(pairs=[ |
| 21 | PairSpec(images="/mnt/input/images", masks="/mnt/input/masks"), | 49 | PairSpec(images="/mnt/input/images", masks="/mnt/input/masks"), |
| 22 | ]) | 50 | ]) |
| 23 | 51 | ||
| 24 | reroot_data_paths(cfg, tmp_path) | 52 | cfg = reroot_data_paths(cfg, tmp_path) |
| 25 | 53 | ||
| 26 | assert cfg.pairs[0].images == "/mnt/input/images" | 54 | assert cfg.pairs[0].images == "/mnt/input/images" |
| 27 | assert cfg.pairs[0].masks == "/mnt/input/masks" | 55 | assert cfg.pairs[0].masks == "/mnt/input/masks" |
| 28 | 56 |
| 32 | val_pairs=[PairSpec(images="val/images", masks="val/masks")], | 60 | val_pairs=[PairSpec(images="val/images", masks="val/masks")], |
| 33 | test_pairs=[PairSpec(images="test/images", masks="test/masks")], | 61 | test_pairs=[PairSpec(images="test/images", masks="test/masks")], |
| 34 | ) | 62 | ) |
| 35 | 63 | ||
| 36 | reroot_data_paths(cfg, tmp_path) | 64 | cfg = reroot_data_paths(cfg, tmp_path) |
| 37 | 65 | ||
| 38 | assert cfg.val_pairs[0].images == str(tmp_path / "val/images") | 66 | assert cfg.val_pairs[0].images == str(tmp_path / "val/images") |
| 39 | assert cfg.val_pairs[0].masks == str(tmp_path / "val/masks") | 67 | assert cfg.val_pairs[0].masks == str(tmp_path / "val/masks") |
| 40 | assert cfg.test_pairs[0].images == str(tmp_path / "test/images") | 68 | assert cfg.test_pairs[0].images == str(tmp_path / "test/images") |
| 41 | assert cfg.test_pairs[0].masks == str(tmp_path / "test/masks") | 69 | assert cfg.test_pairs[0].masks == str(tmp_path / "test/masks") |
| 42 | 70 | ||
| 43 | 71 | ||
| 44 | def test_reroot_data_paths_covers_extra_path_fields(tmp_path: Path) -> None: | 72 | def test_reroot_data_paths_covers_extra_path_fields(tmp_path: Path) -> None: |
| 45 | @dataclass | ||
| 46 | class ExtendedDataConfig(DataConfig): | 73 | class ExtendedDataConfig(DataConfig): |
| 47 | review_sidecar_path: str = "review/dataset_review.sidecar.json" | 74 | review_sidecar_path: str = "review/dataset_review.sidecar.json" |
| 48 | materialised_split_dirs: list = field(default_factory=lambda: [ | 75 | materialised_split_dirs: list[str] = [ |
| 49 | "splits/train", | 76 | "splits/train", |
| 50 | "/mnt/splits/val", | 77 | "/mnt/splits/val", |
| 51 | ]) | 78 | ] |
| 52 | 79 | ||
| 53 | cfg = ExtendedDataConfig() | 80 | cfg = ExtendedDataConfig() |
| 54 | 81 | ||
| 55 | reroot_data_paths(cfg, tmp_path) | 82 | cfg = reroot_data_paths(cfg, tmp_path) |
| 56 | 83 | ||
| 57 | assert cfg.review_sidecar_path == str(tmp_path / "review/dataset_review.sidecar.json") | 84 | assert cfg.review_sidecar_path == str(tmp_path / "review/dataset_review.sidecar.json") |
| 58 | assert cfg.materialised_split_dirs == [ | 85 | assert cfg.materialised_split_dirs == [ |
| 59 | str(tmp_path / "splits/train"), | 86 | str(tmp_path / "splits/train"), |
| 63 | 90 | ||
| 64 | def test_current_data_path_fields_are_covered_by_pair_lists() -> None: | 91 | def test_current_data_path_fields_are_covered_by_pair_lists() -> None: |
| 65 | pair_fields = {"pairs", "val_pairs", "test_pairs"} | 92 | pair_fields = {"pairs", "val_pairs", "test_pairs"} |
| 66 | path_like_fields = { | 93 | path_like_fields = { |
| 67 | field.name | 94 | name |
| 68 | for field in fields(DataConfig) | 95 | for name in DataConfig.model_fields |
| 69 | if field.name.endswith(("path", "paths", "dir", "dirs", "root", "roots")) | 96 | if name.endswith(("path", "paths", "dir", "dirs", "root", "roots")) |
| 70 | } | 97 | } |
| 71 | 98 | ||
| 72 | assert path_like_fields <= pair_fields | 99 | assert path_like_fields <= pair_fields |
| 73 | 100 |
| 84 | assert cfg.val_pairs[0].images == "val/images" | 111 | assert cfg.val_pairs[0].images == "val/images" |
| 85 | assert cfg.val_pairs[0].masks == "val/masks" | 112 | assert cfg.val_pairs[0].masks == "val/masks" |
| 86 | assert cfg.test_pairs[0].images == "/abs/test/images" | 113 | assert cfg.test_pairs[0].images == "/abs/test/images" |
| 87 | assert cfg.test_pairs[0].masks == "/abs/test/masks" | 114 | assert cfg.test_pairs[0].masks == "/abs/test/masks" |
| 115 | |||
| 116 | |||
| 117 | def test_apply_cli_overrides_copies_every_touched_section() -> None: | ||
| 118 | cfg = HarnessConfig() | ||
| 119 | args = _args(log_dir="tb", num_workers=2, max_epochs=7, batch_size=3, | ||
| 120 | model="unetplusplus", encoder="resnet34") | ||
| 121 | |||
| 122 | updated = _train_script().apply_cli_overrides(cfg, args) | ||
| 123 | |||
| 124 | assert updated.train.log_dir == "tb" and updated.train.max_epochs == 7 | ||
| 125 | assert updated.data.num_workers == 2 and updated.data.batch_size == 3 | ||
| 126 | assert updated.model.name == "unetplusplus" | ||
| 127 | assert updated.model.encoder_name == "resnet34" | ||
| 128 | # untouched fields of the copied sections survive | ||
| 129 | assert updated.train.lr == cfg.train.lr | ||
| 130 | assert updated.data.crop_size == cfg.data.crop_size | ||
| 131 | assert updated.model.num_classes == cfg.model.num_classes | ||
| 132 | assert updated.loss == cfg.loss and updated.seed == cfg.seed | ||
| 133 | # the frozen input is never mutated | ||
| 134 | assert cfg.train.log_dir == "runs" and cfg.data.batch_size == 16 | ||
| 135 | |||
| 136 | |||
| 137 | def test_apply_cli_overrides_without_flags_returns_the_input() -> None: | ||
| 138 | cfg = HarnessConfig() | ||
| 139 | |||
| 140 | assert _train_script().apply_cli_overrides(cfg, _args()) is cfg | ||
| 141 | |||
| 142 | |||
| 143 | def test_apply_cli_overrides_honours_zero_valued_flags() -> None: | ||
| 144 | cfg = HarnessConfig() | ||
| 145 | |||
| 146 | updated = _train_script().apply_cli_overrides(cfg, _args(num_workers=0, max_epochs=0)) | ||
| 147 | |||
| 148 | assert updated.data.num_workers == 0 and updated.train.max_epochs == 0 |
| 1 | [project] | 1 | [project] |
| 2 | name = "iolabs-image-analyzer-line-bitmap-segmentation" | 2 | name = "iolabs-image-analyzer-line-bitmap-segmentation" |
| 3 | version = "0.1.0" | 3 | version = "0.1.1" |
| 4 | description = "Extraction of highway road markings (solid/dashed lane lines) from LiDAR intensity rasters" | 4 | description = "Extraction of highway road markings (solid/dashed lane lines) from LiDAR intensity rasters" |
| 5 | requires-python = ">=3.11,<3.13" | 5 | requires-python = ">=3.11,<3.13" |
| 6 | dependencies = [ | 6 | dependencies = [ |
| 7 | "numpy>=1.26", | 7 | "numpy>=1.26", |
| 10 | "scikit-image>=0.22", | 10 | "scikit-image>=0.22", |
| 11 | "matplotlib>=3.7", | 11 | "matplotlib>=3.7", |
| 12 | "Pillow>=10.0", | 12 | "Pillow>=10.0", |
| 13 | "ezdxf>=1.1", | 13 | "ezdxf>=1.1", |
| 14 | "pydantic>=2.7", | ||
| 15 | "iolabs-common>=0.9.0", | ||
| 14 | ] | 16 | ] |
| 15 | 17 | ||
| 16 | [project.optional-dependencies] | 18 | [project.optional-dependencies] |
| 17 | dev = [ | 19 | dev = [ |
| 39 | [tool.pytest.ini_options] | 41 | [tool.pytest.ini_options] |
| 40 | testpaths = ["test"] | 42 | testpaths = ["test"] |
| 41 | 43 | ||
| 42 | # Inference package is published to the private Nexus index (single source of | 44 | # Inference package is published to the private Nexus index (single source of |
| 43 | # truth); pull it from there, not a local sibling checkout. iolabs-common / | 45 | # truth); pull it from there, not a local sibling checkout. iolabs-common is a |
| 44 | # iolabs-logstash are transitive private deps it pulls in — sources mirror the | 46 | # direct dependency (the config layer) and iolabs-logstash a transitive private |
| 45 | # inference repo's own pyproject so `uv sync --extra ml` resolves them identically. | 47 | # dep of the inference package; both come from the same Nexus index. |
| 46 | [[tool.uv.index]] | 48 | [[tool.uv.index]] |
| 47 | name = "nexus" | 49 | name = "nexus" |
| 48 | url = "https://nexus.iolabs.ch/repository/pypi-private/simple/" | 50 | url = "https://nexus.iolabs.ch/repository/pypi-private/simple/" |
| 49 | authenticate = "always" | 51 | authenticate = "always" |
| 50 | 52 | ||
| 51 | [tool.uv.sources] | 53 | [tool.uv.sources] |
| 52 | iolabs-image-analyzer-line-bitmap-inference = { index = "nexus" } | 54 | iolabs-image-analyzer-line-bitmap-inference = { index = "nexus" } |
| 53 | iolabs-common = { path = "../3dai.common" } | 55 | iolabs-common = { index = "nexus" } |
| 54 | iolabs-logstash = { index = "nexus" } | 56 | iolabs-logstash = { index = "nexus" } |
| 55 | 57 | ||
| 56 | [build-system] | 58 | [build-system] |
| 57 | requires = ["hatchling"] | 59 | requires = ["hatchling"] |
| 39 | - `src/model/` — registry; `model.name` = any smp arch, swap via config | 39 | - `src/model/` — registry; `model.name` = any smp arch, swap via config |
| 40 | - `src/losses/`, `src/metrics/` — dice_focal default; also focal_tversky / | 40 | - `src/losses/`, `src/metrics/` — dice_focal default; also focal_tversky / |
| 41 | soft_cldice / ftl_cldice (handoff recipe); IoU/F1, coverage/tightness, | 41 | soft_cldice / ftl_cldice (handoff recipe); IoU/F1, coverage/tightness, |
| 42 | predicted-vs-label mask area, val clDice (centerline topology) | 42 | predicted-vs-label mask area, val clDice (centerline topology) |
| 43 | - `src/train/` — LightningModule/DataModule, YAML config, `MaskOverlayWriter` | 43 | - `src/train/` — LightningModule/DataModule, YAML config (pydantic models on |
| 44 | `iolabs.common.config_loader.ConfigModel`: unknown keys rejected, values | ||
| 45 | coerced, instances frozen — **adding a config key = adding one field with | ||
| 46 | its default to the model in `src/train/config.py`**; `from_yaml` raises | ||
| 47 | `config.ConfigError`, a `ValueError`), `MaskOverlayWriter` | ||
| 44 | (`intensity | label | prediction` sheets → TensorBoard + `runs/.../overlays/`) | 48 | (`intensity | label | prediction` sheets → TensorBoard + `runs/.../overlays/`) |
| 45 | - `scripts/train.py --config configs/<experiment>.yaml` — train (from repo | 49 | - `scripts/train.py --config configs/<experiment>.yaml` — train (from repo |
| 46 | root, needs `--extra ml`); `tensorboard --logdir runs` to monitor. Configs: | 50 | root, needs `--extra ml`); `tensorboard --logdir runs` to monitor. Configs: |
| 47 | `unet_baseline`, `unet_vector_labels`, `unet_confirmed_good` (reviewer-`ok` | 51 | `unet_baseline`, `unet_vector_labels`, `unet_confirmed_good` (reviewer-`ok` |
| 1 | [project] | 1 | [project] |
| 2 | name = "iolabs-image-analyzer-line-bitmap-segmentation" | 2 | name = "iolabs-image-analyzer-line-bitmap-segmentation" |
| 3 | version = "0.1.0" | 3 | version = "0.1.1" |
| 4 | description = "Extraction of highway road markings (solid/dashed lane lines) from LiDAR intensity rasters" | 4 | description = "Extraction of highway road markings (solid/dashed lane lines) from LiDAR intensity rasters" |
| 5 | requires-python = ">=3.11,<3.13" | 5 | requires-python = ">=3.11,<3.13" |
| 6 | dependencies = [ | 6 | dependencies = [ |
| 7 | "numpy>=1.26", | 7 | "numpy>=1.26", |
| 10 | "scikit-image>=0.22", | 10 | "scikit-image>=0.22", |
| 11 | "matplotlib>=3.7", | 11 | "matplotlib>=3.7", |
| 12 | "Pillow>=10.0", | 12 | "Pillow>=10.0", |
| 13 | "ezdxf>=1.1", | 13 | "ezdxf>=1.1", |
| 14 | "pydantic>=2.7", | ||
| 15 | "iolabs-common>=0.9.0", | ||
| 14 | ] | 16 | ] |
| 15 | 17 | ||
| 16 | [project.optional-dependencies] | 18 | [project.optional-dependencies] |
| 17 | dev = [ | 19 | dev = [ |
| 39 | [tool.pytest.ini_options] | 41 | [tool.pytest.ini_options] |
| 40 | testpaths = ["test"] | 42 | testpaths = ["test"] |
| 41 | 43 | ||
| 42 | # Inference package is published to the private Nexus index (single source of | 44 | # Inference package is published to the private Nexus index (single source of |
| 43 | # truth); pull it from there, not a local sibling checkout. iolabs-common / | 45 | # truth); pull it from there, not a local sibling checkout. iolabs-common is a |
| 44 | # iolabs-logstash are transitive private deps it pulls in — sources mirror the | 46 | # direct dependency (the config layer) and iolabs-logstash a transitive private |
| 45 | # inference repo's own pyproject so `uv sync --extra ml` resolves them identically. | 47 | # dep of the inference package; both come from the same Nexus index. |
| 46 | [[tool.uv.index]] | 48 | [[tool.uv.index]] |
| 47 | name = "nexus" | 49 | name = "nexus" |
| 48 | url = "https://nexus.iolabs.ch/repository/pypi-private/simple/" | 50 | url = "https://nexus.iolabs.ch/repository/pypi-private/simple/" |
| 49 | authenticate = "always" | 51 | authenticate = "always" |
| 50 | 52 | ||
| 51 | [tool.uv.sources] | 53 | [tool.uv.sources] |
| 52 | iolabs-image-analyzer-line-bitmap-inference = { index = "nexus" } | 54 | iolabs-image-analyzer-line-bitmap-inference = { index = "nexus" } |
| 53 | iolabs-common = { path = "../3dai.common" } | 55 | iolabs-common = { index = "nexus" } |
| 54 | iolabs-logstash = { index = "nexus" } | 56 | iolabs-logstash = { index = "nexus" } |
| 55 | 57 | ||
| 56 | [build-system] | 58 | [build-system] |
| 57 | requires = ["hatchling"] | 59 | requires = ["hatchling"] |
| 22 | segformer, ...) and model.encoder_name any smp encoder. encoder_weights: imagenet | 22 | segformer, ...) and model.encoder_name any smp encoder. encoder_weights: imagenet |
| 23 | downloads pretrained encoder weights on first use. | 23 | downloads pretrained encoder weights on first use. |
| 24 | """ | 24 | """ |
| 25 | import argparse | 25 | import argparse |
| 26 | from typing import Any | ||
| 26 | 27 | ||
| 27 | import lightning.pytorch as pl | 28 | import lightning.pytorch as pl |
| 28 | import torch | 29 | import torch |
| 29 | from lightning.pytorch.callbacks import ( | 30 | from lightning.pytorch.callbacks import ( |
| 53 | parser.add_argument("--cpu", action="store_true", help="force CPU training") | 54 | parser.add_argument("--cpu", action="store_true", help="force CPU training") |
| 54 | return parser.parse_args() | 55 | return parser.parse_args() |
| 55 | 56 | ||
| 56 | 57 | ||
| 58 | def apply_cli_overrides(cfg: config.HarnessConfig, | ||
| 59 | args: argparse.Namespace) -> config.HarnessConfig: | ||
| 60 | """Returns a copy of ``cfg`` with the CLI overrides applied. | ||
| 61 | |||
| 62 | Config models are frozen, so overrides are applied by copying each touched | ||
| 63 | section instead of assigning to it. | ||
| 64 | |||
| 65 | Args: | ||
| 66 | cfg: The config parsed from the YAML file. | ||
| 67 | args: Parsed CLI arguments; ``None``/empty values override nothing. | ||
| 68 | |||
| 69 | Returns: | ||
| 70 | ``cfg`` itself when no override was given, otherwise an updated copy. | ||
| 71 | """ | ||
| 72 | sections: dict[str, dict[str, Any]] = {"train": {}, "data": {}, "model": {}} | ||
| 73 | if args.log_dir: | ||
| 74 | sections["train"]["log_dir"] = args.log_dir | ||
| 75 | if args.num_workers is not None: | ||
| 76 | sections["data"]["num_workers"] = args.num_workers | ||
| 77 | if args.max_epochs is not None: | ||
| 78 | sections["train"]["max_epochs"] = args.max_epochs | ||
| 79 | if args.batch_size is not None: | ||
| 80 | sections["data"]["batch_size"] = args.batch_size | ||
| 81 | if args.model: | ||
| 82 | sections["model"]["name"] = args.model | ||
| 83 | if args.encoder: | ||
| 84 | sections["model"]["encoder_name"] = args.encoder | ||
| 85 | updates = {name: getattr(cfg, name).model_copy(update=values) | ||
| 86 | for name, values in sections.items() if values} | ||
| 87 | return cfg.model_copy(update=updates) if updates else cfg | ||
| 88 | |||
| 89 | |||
| 57 | def main() -> None: | 90 | def main() -> None: |
| 58 | args = parse_args() | 91 | args = parse_args() |
| 59 | cfg = config.HarnessConfig.from_yaml(args.config) | 92 | cfg = config.HarnessConfig.from_yaml(args.config) |
| 60 | if args.data_root: | 93 | if args.data_root: |
| 61 | config.reroot_data_paths(cfg.data, args.data_root) | 94 | cfg = cfg.model_copy(update={ |
| 62 | if args.log_dir: | 95 | "data": config.reroot_data_paths(cfg.data, args.data_root)}) |
| 63 | cfg.train.log_dir = args.log_dir | ||
| 64 | if args.num_workers is not None: | ||
| 65 | cfg.data.num_workers = args.num_workers | ||
| 66 | if cfg.model.num_classes != len(tiles.CLASS_NAMES): | 96 | if cfg.model.num_classes != len(tiles.CLASS_NAMES): |
| 67 | raise ValueError( | 97 | raise ValueError( |
| 68 | f"model.num_classes={cfg.model.num_classes} but the harness tracks " | 98 | f"model.num_classes={cfg.model.num_classes} but the harness tracks " |
| 69 | f"{len(tiles.CLASS_NAMES)} classes {tiles.CLASS_NAMES} — metrics would mis-bin") | 99 | f"{len(tiles.CLASS_NAMES)} classes {tiles.CLASS_NAMES} — metrics would mis-bin") |
| 70 | if args.max_epochs is not None: | 100 | cfg = apply_cli_overrides(cfg, args) |
| 71 | cfg.train.max_epochs = args.max_epochs | ||
| 72 | if args.batch_size is not None: | ||
| 73 | cfg.data.batch_size = args.batch_size | ||
| 74 | if args.model: | ||
| 75 | cfg.model.name = args.model | ||
| 76 | if args.encoder: | ||
| 77 | cfg.model.encoder_name = args.encoder | ||
| 78 | 101 | ||
| 79 | pl.seed_everything(cfg.seed, workers=True) | 102 | pl.seed_everything(cfg.seed, workers=True) |
| 80 | 103 | ||
| 81 | dm = datamodule.TilesDataModule(cfg.data) | 104 | dm = datamodule.TilesDataModule(cfg.data) |
| 1 | """YAML-backed configuration for the training harness. | 1 | """YAML-backed configuration for the training harness. |
| 2 | 2 | ||
| 3 | Plain dataclasses + yaml, no config framework. Unknown keys raise, so config | 3 | Pydantic models on `iolabs.common.config_loader.ConfigModel` + yaml. Unknown keys |
| 4 | typos fail fast instead of silently training with defaults. | 4 | raise, so config typos fail fast instead of silently training with defaults, and |
| 5 | values are coerced by the shared fleet matrix. | ||
| 6 | |||
| 7 | Adding a config key = adding one field with its default to the model below. | ||
| 8 | Instances are frozen: derive a changed config with ``model_copy(update=...)``. | ||
| 5 | """ | 9 | """ |
| 6 | from dataclasses import dataclass, field, fields | 10 | import logging |
| 11 | from collections.abc import Mapping | ||
| 7 | from pathlib import Path | 12 | from pathlib import Path |
| 8 | from typing import Any, TypeVar | 13 | from typing import Any, Literal |
| 9 | 14 | ||
| 15 | import pydantic | ||
| 10 | import yaml | 16 | import yaml |
| 17 | from iolabs.common import config_loader | ||
| 18 | |||
| 19 | logger = logging.getLogger(__name__) | ||
| 11 | 20 | ||
| 12 | T = TypeVar("T") | 21 | _STROKE_KINDS = frozenset({"solid", "dashed"}) |
| 22 | _PAIR_LIST_FIELDS = ("pairs", "val_pairs", "test_pairs") | ||
| 23 | _PATH_FIELD_SUFFIXES = ("path", "paths", "dir", "dirs", "root", "roots") | ||
| 13 | 24 | ||
| 14 | 25 | ||
| 15 | def _build(cls: type[T], data: dict[str, Any] | None, where: str) -> T: | 26 | class ConfigError(config_loader.ConfigError): |
| 16 | data = dict(data or {}) | 27 | """Raised when a harness config holds unknown keys or invalid values.""" |
| 17 | known = {f.name for f in fields(cls)} | ||
| 18 | unknown = sorted(set(data) - known) | ||
| 19 | if unknown: | ||
| 20 | raise KeyError(f"unknown key(s) {unknown} in config section {where!r}; " | ||
| 21 | f"known keys: {sorted(known)}") | ||
| 22 | return cls(**data) | ||
| 23 | 28 | ||
| 24 | 29 | ||
| 25 | @dataclass | 30 | class PairSpec(config_loader.ConfigModel): |
| 26 | class PairSpec: | ||
| 27 | """One images-dir / masks-dir pair (see src.dataset.index_tile_pairs).""" | 31 | """One images-dir / masks-dir pair (see src.dataset.index_tile_pairs).""" |
| 28 | images: str | 32 | images: str |
| 29 | masks: str | 33 | masks: str |
| 30 | 34 | ||
| 31 | 35 | ||
| 32 | @dataclass | 36 | class DataConfig(config_loader.ConfigModel): |
| 33 | class DataConfig: | 37 | """Tile corpus, split, crop sampling and label rasterization knobs.""" |
| 34 | pairs: list = field(default_factory=list) | 38 | pairs: list[PairSpec] = [] |
| 35 | # Optional explicit, pre-split directories (e.g. the symlink folders under | 39 | # Optional explicit, pre-split directories (e.g. the symlink folders under |
| 36 | # data/02_processed/<ds>/{train,val,test} built by scripts/build_processed_ | 40 | # data/02_processed/<ds>/{train,val,test} built by scripts/build_processed_ |
| 37 | # splits.py). When val_pairs is set, `pairs` is used in full as the training | 41 | # splits.py). When val_pairs is set, `pairs` is used in full as the training |
| 38 | # set and is NOT re-split — val_fraction is ignored — so a geographic split | 42 | # set and is NOT re-split — val_fraction is ignored — so a geographic split |
| 39 | # materialised on disk is honoured verbatim. test_pairs feeds test_dataloader. | 43 | # materialised on disk is honoured verbatim. test_pairs feeds test_dataloader. |
| 40 | val_pairs: list = field(default_factory=list) | 44 | val_pairs: list[PairSpec] = [] |
| 41 | test_pairs: list = field(default_factory=list) | 45 | test_pairs: list[PairSpec] = [] |
| 42 | crop_size: int = 512 | 46 | crop_size: int = pydantic.Field(default=512, gt=0) |
| 43 | batch_size: int = 16 | 47 | batch_size: int = pydantic.Field(default=16, gt=0) |
| 44 | num_workers: int = 4 | 48 | num_workers: int = pydantic.Field(default=4, ge=0) |
| 45 | val_fraction: float = 0.15 | 49 | val_fraction: float = pydantic.Field(default=0.15, ge=0.0, le=1.0) |
| 46 | crops_per_tile: int = 4 | 50 | crops_per_tile: int = pydantic.Field(default=4, gt=0) |
| 47 | pos_crop_prob: float = 0.7 | 51 | pos_crop_prob: float = pydantic.Field(default=0.7, ge=0.0, le=1.0) |
| 48 | min_valid_fraction: float = 0.10 | 52 | min_valid_fraction: float = pydantic.Field(default=0.10, ge=0.0, le=1.0) |
| 49 | augment: bool = True | 53 | augment: bool = True |
| 50 | label_source: str = "rendered" # rendered *_lines.png | vector *_vectors.json | review | 54 | # rendered *_lines.png | vector *_vectors.json | reviewer-confirmed tiles |
| 51 | label_stroke_px: int | float | dict[str, float] = 4 # scalar or {solid, dashed} | 55 | label_source: Literal["rendered", "vector", "review"] = "rendered" |
| 52 | review_statuses: list = field(default_factory=lambda: ["ok"]) # label_source: review | 56 | # scalar or {solid, dashed}; int stays int so reports render "5", not "5.0" |
| 53 | 57 | label_stroke_px: int | float | dict[str, int | float] = 4 | |
| 54 | 58 | review_statuses: list[str] = ["ok"] # label_source: review | |
| 55 | _STROKE_KINDS = frozenset({"solid", "dashed"}) | ||
| 56 | 59 | ||
| 57 | 60 | @pydantic.field_validator("label_stroke_px", mode="before") | |
| 58 | def _parse_label_stroke_px(value: Any) -> int | float | dict[str, float]: | 61 | @classmethod |
| 59 | """Scalar > 0, or exactly ``{solid, dashed}`` with positive numeric values.""" | 62 | def _check_label_stroke_px(cls, value: Any) -> Any: |
| 60 | if isinstance(value, (int, float)) and not isinstance(value, bool): | 63 | """Scalar > 0, or exactly ``{solid, dashed}`` with positive numeric values.""" |
| 61 | if value <= 0: | 64 | if isinstance(value, (int, float)) and not isinstance(value, bool): |
| 62 | raise ValueError(f"data.label_stroke_px must be > 0, got {value}") | 65 | if value <= 0: |
| 63 | return value | 66 | raise ValueError(f"data.label_stroke_px must be > 0, got {value}") |
| 64 | if isinstance(value, dict): | 67 | return value |
| 65 | keys = set(value) | 68 | if isinstance(value, Mapping): |
| 66 | if keys != _STROKE_KINDS: | 69 | keys = set(value) |
| 67 | raise ValueError( | 70 | if keys != _STROKE_KINDS: |
| 68 | f"data.label_stroke_px mapping must have exactly the keys " | ||
| 69 | f"{sorted(_STROKE_KINDS)}, got {sorted(keys)}") | ||
| 70 | out: dict[str, float] = {} | ||
| 71 | for kind, width in value.items(): | ||
| 72 | if (not isinstance(width, (int, float)) or isinstance(width, bool) | ||
| 73 | or width <= 0): | ||
| 74 | raise ValueError( | 71 | raise ValueError( |
| 75 | f"data.label_stroke_px[{kind!r}] must be a positive number, " | 72 | f"data.label_stroke_px mapping must have exactly the keys " |
| 76 | f"got {width!r}") | 73 | f"{sorted(_STROKE_KINDS)}, got {sorted(keys)}") |
| 77 | out[kind] = width | 74 | for kind, width in value.items(): |
| 78 | return out | 75 | if (not isinstance(width, (int, float)) or isinstance(width, bool) |
| 79 | raise ValueError( | 76 | or width <= 0): |
| 80 | f"data.label_stroke_px must be a positive number or a " | 77 | raise ValueError( |
| 81 | f"{{solid, dashed}} mapping, got {type(value).__name__}: {value!r}") | 78 | f"data.label_stroke_px[{kind!r}] must be a positive number, " |
| 79 | f"got {width!r}") | ||
| 80 | return dict(value) | ||
| 81 | raise ValueError( | ||
| 82 | f"data.label_stroke_px must be a positive number or a " | ||
| 83 | f"{{solid, dashed}} mapping, got {type(value).__name__}: {value!r}") | ||
| 82 | 84 | ||
| 83 | 85 | ||
| 84 | def _reroot_path(value: Any, root: Path) -> Any: | 86 | def _reroot_path(value: Any, root: Path) -> Any: |
| 87 | """Return *value* as an absolute path string, joined onto *root* if relative.""" | ||
| 85 | path = Path(value) | 88 | path = Path(value) |
| 86 | if path.is_absolute(): | 89 | if path.is_absolute(): |
| 87 | return str(path) | 90 | return str(path) |
| 88 | return str(root / path) | 91 | return str(root / path) |
| 89 | 92 | ||
| 90 | 93 | ||
| 91 | def _reroot_path_value(value: Any, root: Path) -> Any: | 94 | def _reroot_path_value(value: Any, root: Path) -> Any: |
| 95 | """Re-root every path-like leaf of a scalar/list/tuple/dict value.""" | ||
| 92 | if isinstance(value, (str, Path)): | 96 | if isinstance(value, (str, Path)): |
| 93 | return _reroot_path(value, root) | 97 | return _reroot_path(value, root) |
| 94 | if isinstance(value, list): | 98 | if isinstance(value, list): |
| 95 | return [_reroot_path_value(item, root) for item in value] | 99 | return [_reroot_path_value(item, root) for item in value] |
| 100 | return value | 104 | return value |
| 101 | 105 | ||
| 102 | 106 | ||
| 103 | def reroot_data_paths(data_cfg: DataConfig, root: str | Path) -> DataConfig: | 107 | def reroot_data_paths(data_cfg: DataConfig, root: str | Path) -> DataConfig: |
| 104 | """Re-root relative dataset paths in a DataConfig onto root.""" | 108 | """Re-root relative dataset paths in a DataConfig onto root. |
| 109 | |||
| 110 | Config models are frozen, so this returns an updated copy instead of | ||
| 111 | mutating ``data_cfg`` in place. | ||
| 112 | |||
| 113 | Args: | ||
| 114 | data_cfg: The data section to re-root; never mutated. | ||
| 115 | root: Directory relative paths are joined onto. Absolute paths are kept. | ||
| 116 | |||
| 117 | Returns: | ||
| 118 | A copy of ``data_cfg`` with the pair lists and every path-like field | ||
| 119 | (name ending in path/paths/dir/dirs/root/roots) made absolute. | ||
| 120 | """ | ||
| 105 | root = Path(root) | 121 | root = Path(root) |
| 106 | for pair_list_name in ("pairs", "val_pairs", "test_pairs"): | 122 | updates: dict[str, Any] = {} |
| 107 | for pair in getattr(data_cfg, pair_list_name): | 123 | for name in _PAIR_LIST_FIELDS: |
| 108 | pair.images = _reroot_path(pair.images, root) | 124 | specs = getattr(data_cfg, name) |
| 109 | pair.masks = _reroot_path(pair.masks, root) | 125 | if specs: |
| 110 | 126 | updates[name] = [ | |
| 111 | path_field_suffixes = ("path", "paths", "dir", "dirs", "root", "roots") | 127 | spec.model_copy(update={ |
| 112 | for field_info in fields(data_cfg): | 128 | "images": _reroot_path(spec.images, root), |
| 113 | name = field_info.name | 129 | "masks": _reroot_path(spec.masks, root)}) |
| 114 | if name in {"pairs", "val_pairs", "test_pairs"}: | 130 | for spec in specs] |
| 131 | for name in type(data_cfg).model_fields: | ||
| 132 | if name in _PAIR_LIST_FIELDS or not name.endswith(_PATH_FIELD_SUFFIXES): | ||
| 115 | continue | 133 | continue |
| 116 | if name.endswith(path_field_suffixes): | 134 | updates[name] = _reroot_path_value(getattr(data_cfg, name), root) |
| 117 | setattr(data_cfg, name, _reroot_path_value(getattr(data_cfg, name), root)) | 135 | return data_cfg.model_copy(update=updates) |
| 118 | return data_cfg | ||
| 119 | 136 | ||
| 120 | 137 | ||
| 121 | @dataclass | 138 | class ModelConfig(config_loader.ConfigModel): |
| 122 | class ModelConfig: | 139 | """Segmentation-models-pytorch architecture/encoder selection.""" |
| 123 | name: str = "unet" | 140 | name: str = "unet" |
| 124 | encoder_name: str = "resnet18" | 141 | encoder_name: str = "resnet18" |
| 125 | encoder_weights: str | None = "imagenet" # None = train from scratch | 142 | encoder_weights: str | None = "imagenet" # None = train from scratch |
| 126 | in_channels: int = 1 | 143 | in_channels: int = pydantic.Field(default=1, gt=0) |
| 127 | num_classes: int = 3 | 144 | num_classes: int = pydantic.Field(default=3, gt=0) |
| 128 | extra: dict = field(default_factory=dict) # passed through to the model factory | 145 | extra: dict[str, Any] = {} # passed through to the model factory |
| 129 | 146 | ||
| 130 | 147 | ||
| 131 | @dataclass | 148 | class LossConfig(config_loader.ConfigModel): |
| 132 | class LossConfig: | 149 | """Loss selection by registry name plus factory keyword arguments.""" |
| 133 | name: str = "dice_focal" | 150 | name: str = "dice_focal" |
| 134 | args: dict = field(default_factory=dict) | 151 | args: dict[str, Any] = {} |
| 135 | 152 | ||
| 136 | 153 | ||
| 137 | @dataclass | 154 | class TrainerConfig(config_loader.ConfigModel): |
| 138 | class TrainerConfig: | 155 | """Lightning trainer, logger, and callback knobs.""" |
| 139 | max_epochs: int = -1 # -1 = no cap; early stopping ends training instead | 156 | max_epochs: int = pydantic.Field(default=-1, ge=-1) # -1 = no cap |
| 140 | lr: float = 3.0e-4 | 157 | lr: float = pydantic.Field(default=3.0e-4, gt=0) |
| 141 | weight_decay: float = 1.0e-4 | 158 | weight_decay: float = pydantic.Field(default=1.0e-4, ge=0) |
| 142 | precision: str = "auto" # auto -> 16-mixed on CUDA, 32-true on CPU | 159 | precision: str = "auto" # auto -> 16-mixed on CUDA, 32-true on CPU |
| 143 | accumulate_grad_batches: int = 1 # match effective batch across experiments | 160 | accumulate_grad_batches: int = pydantic.Field(default=1, ge=1) |
| 144 | accelerator: str = "auto" | 161 | accelerator: str = "auto" |
| 145 | devices: int | str = 1 | 162 | devices: int | str = 1 |
| 146 | viz_every_n_epochs: int = 2 | 163 | viz_every_n_epochs: int = pydantic.Field(default=2, ge=0) |
| 147 | viz_samples: int = 4 | 164 | viz_samples: int = pydantic.Field(default=4, ge=0) |
| 148 | monitor: str = "val/f1_mean_fg" | 165 | monitor: str = "val/f1_mean_fg" |
| 149 | monitor_mode: str = "max" | 166 | monitor_mode: Literal["max", "min"] = "max" |
| 150 | early_stop_monitor: str = "val/loss" # stop when this stops improving | 167 | early_stop_monitor: str = "val/loss" # stop when this stops improving |
| 151 | early_stop_mode: str = "min" | 168 | early_stop_mode: Literal["min", "max"] = "min" |
| 152 | early_stop_patience: int = 4 # epochs without improvement before stopping; 0 disables | 169 | # epochs without improvement before stopping; 0 disables |
| 170 | early_stop_patience: int = pydantic.Field(default=4, ge=0) | ||
| 153 | log_dir: str = "runs" | 171 | log_dir: str = "runs" |
| 154 | log_every_n_steps: int = 10 | 172 | log_every_n_steps: int = pydantic.Field(default=10, ge=1) |
| 155 | 173 | ||
| 156 | 174 | ||
| 157 | @dataclass | 175 | class HarnessConfig(config_loader.ConfigModel): |
| 158 | class HarnessConfig: | 176 | """Top-level training config: one YAML file, one instance.""" |
| 159 | experiment: str = "experiment" | 177 | experiment: str = "experiment" |
| 160 | seed: int = 1337 | 178 | seed: int = 1337 |
| 161 | data: DataConfig = field(default_factory=DataConfig) | 179 | data: DataConfig = DataConfig() |
| 162 | model: ModelConfig = field(default_factory=ModelConfig) | 180 | model: ModelConfig = ModelConfig() |
| 163 | loss: LossConfig = field(default_factory=LossConfig) | 181 | loss: LossConfig = LossConfig() |
| 164 | train: TrainerConfig = field(default_factory=TrainerConfig) | 182 | train: TrainerConfig = TrainerConfig() |
| 165 | 183 | ||
| 166 | @classmethod | 184 | @classmethod |
| 167 | def from_yaml(cls, path: str | Path) -> "HarnessConfig": | 185 | def from_yaml(cls, path: str | Path) -> "HarnessConfig": |
| 168 | raw = yaml.safe_load(Path(path).read_text()) or {} | 186 | """Loads and validates a harness YAML config. |
| 169 | data = _build(DataConfig, raw.pop("data", {}), "data") | 187 | |
| 170 | data.pairs = [_build(PairSpec, p, "data.pairs[]") for p in data.pairs] | 188 | Args: |
| 171 | data.val_pairs = [_build(PairSpec, p, "data.val_pairs[]") for p in data.val_pairs] | 189 | path: Path of the YAML file, read as UTF-8. |
| 172 | data.test_pairs = [_build(PairSpec, p, "data.test_pairs[]") for p in data.test_pairs] | 190 | |
| 173 | data.label_stroke_px = _parse_label_stroke_px(data.label_stroke_px) | 191 | Returns: |
| 174 | cfg = cls( | 192 | The validated, frozen config. |
| 175 | experiment=raw.pop("experiment", cls.experiment), | 193 | |
| 176 | seed=raw.pop("seed", cls.seed), | 194 | Raises: |
| 177 | data=data, | 195 | FileNotFoundError: If ``path`` does not exist. |
| 178 | model=_build(ModelConfig, raw.pop("model", {}), "model"), | 196 | ConfigError: If the document is not a mapping, holds an unknown key, |
| 179 | loss=_build(LossConfig, raw.pop("loss", {}), "loss"), | 197 | or holds a value invalid for its field. Derives from |
| 180 | train=_build(TrainerConfig, raw.pop("train", {}), "train"), | 198 | ``ValueError``. |
| 181 | ) | 199 | """ |
| 182 | if raw: | 200 | raw = yaml.safe_load(Path(path).read_text(encoding="utf-8")) or {} |
| 183 | raise KeyError(f"unknown top-level config key(s) {sorted(raw)} in {path}") | 201 | if not isinstance(raw, Mapping): |
| 184 | return cfg | 202 | raise ConfigError( |
| 203 | f"config {str(path)!r} must contain a top-level mapping, " | ||
| 204 | f"got {type(raw).__name__}") | ||
| 205 | return config_loader.validate_config( | ||
| 206 | cls, raw, context=str(path), error_cls=ConfigError) |
| 185 | cfg = HarnessConfig.from_yaml(good) | 185 | cfg = HarnessConfig.from_yaml(good) |
| 186 | assert cfg.experiment == "t" and cfg.model.name == "unet" | 186 | assert cfg.experiment == "t" and cfg.model.name == "unet" |
| 187 | bad = tmp_path / "bad.yaml" | 187 | bad = tmp_path / "bad.yaml" |
| 188 | bad.write_text("model:\n encoder: oops\n") | 188 | bad.write_text("model:\n encoder: oops\n") |
| 189 | with pytest.raises(KeyError, match="encoder"): | 189 | with pytest.raises(ValueError, match="encoder"): |
| 190 | HarnessConfig.from_yaml(bad) | 190 | HarnessConfig.from_yaml(bad) |
| 191 | 191 | ||
| 192 | 192 | ||
| 193 | def test_shipped_baseline_config_parses() -> None: | 193 | def test_shipped_baseline_config_parses() -> None: |
| 1 | """Unit tests for train.py configuration overrides.""" | 1 | """Unit tests for train.py configuration overrides.""" |
| 2 | from dataclasses import dataclass, field, fields | 2 | import argparse |
| 3 | import importlib.util | ||
| 4 | import sys | ||
| 3 | from pathlib import Path | 5 | from pathlib import Path |
| 4 | 6 | ||
| 5 | from src.train.config import DataConfig, PairSpec, reroot_data_paths | 7 | import pytest |
| 8 | |||
| 9 | from src.train.config import DataConfig, HarnessConfig, PairSpec, reroot_data_paths | ||
| 10 | |||
| 11 | _REPO_ROOT = Path(__file__).resolve().parents[1] | ||
| 12 | _CLI_FLAGS = ("log_dir", "num_workers", "max_epochs", "batch_size", "model", "encoder") | ||
| 13 | |||
| 14 | |||
| 15 | def _train_script(): | ||
| 16 | """Imports scripts/train.py as a module; skips when the ml extra is missing.""" | ||
| 17 | pytest.importorskip("torch") | ||
| 18 | pytest.importorskip("lightning") | ||
| 19 | pytest.importorskip("segmentation_models_pytorch") | ||
| 20 | pytest.importorskip("albumentations") | ||
| 21 | if "train_script" in sys.modules: | ||
| 22 | return sys.modules["train_script"] | ||
| 23 | spec = importlib.util.spec_from_file_location( | ||
| 24 | "train_script", _REPO_ROOT / "scripts" / "train.py") | ||
| 25 | module = importlib.util.module_from_spec(spec) | ||
| 26 | sys.modules["train_script"] = module | ||
| 27 | spec.loader.exec_module(module) | ||
| 28 | return module | ||
| 29 | |||
| 30 | |||
| 31 | def _args(**overrides: object) -> argparse.Namespace: | ||
| 32 | """Builds a parsed-CLI namespace where unset flags are None.""" | ||
| 33 | return argparse.Namespace(**{name: overrides.get(name) for name in _CLI_FLAGS}) | ||
| 6 | 34 | ||
| 7 | 35 | ||
| 8 | def test_reroot_data_paths_rewrites_relative_pair_dirs(tmp_path: Path) -> None: | 36 | def test_reroot_data_paths_rewrites_relative_pair_dirs(tmp_path: Path) -> None: |
| 9 | cfg = DataConfig(pairs=[ | 37 | cfg = DataConfig(pairs=[ |
| 10 | PairSpec(images="data/train/images", masks="data/train/masks"), | 38 | PairSpec(images="data/train/images", masks="data/train/masks"), |
| 11 | ]) | 39 | ]) |
| 12 | 40 | ||
| 13 | reroot_data_paths(cfg, tmp_path) | 41 | cfg = reroot_data_paths(cfg, tmp_path) |
| 14 | 42 | ||
| 15 | assert cfg.pairs[0].images == str(tmp_path / "data/train/images") | 43 | assert cfg.pairs[0].images == str(tmp_path / "data/train/images") |
| 16 | assert cfg.pairs[0].masks == str(tmp_path / "data/train/masks") | 44 | assert cfg.pairs[0].masks == str(tmp_path / "data/train/masks") |
| 17 | 45 |
| 20 | cfg = DataConfig(pairs=[ | 48 | cfg = DataConfig(pairs=[ |
| 21 | PairSpec(images="/mnt/input/images", masks="/mnt/input/masks"), | 49 | PairSpec(images="/mnt/input/images", masks="/mnt/input/masks"), |
| 22 | ]) | 50 | ]) |
| 23 | 51 | ||
| 24 | reroot_data_paths(cfg, tmp_path) | 52 | cfg = reroot_data_paths(cfg, tmp_path) |
| 25 | 53 | ||
| 26 | assert cfg.pairs[0].images == "/mnt/input/images" | 54 | assert cfg.pairs[0].images == "/mnt/input/images" |
| 27 | assert cfg.pairs[0].masks == "/mnt/input/masks" | 55 | assert cfg.pairs[0].masks == "/mnt/input/masks" |
| 28 | 56 |
| 32 | val_pairs=[PairSpec(images="val/images", masks="val/masks")], | 60 | val_pairs=[PairSpec(images="val/images", masks="val/masks")], |
| 33 | test_pairs=[PairSpec(images="test/images", masks="test/masks")], | 61 | test_pairs=[PairSpec(images="test/images", masks="test/masks")], |
| 34 | ) | 62 | ) |
| 35 | 63 | ||
| 36 | reroot_data_paths(cfg, tmp_path) | 64 | cfg = reroot_data_paths(cfg, tmp_path) |
| 37 | 65 | ||
| 38 | assert cfg.val_pairs[0].images == str(tmp_path / "val/images") | 66 | assert cfg.val_pairs[0].images == str(tmp_path / "val/images") |
| 39 | assert cfg.val_pairs[0].masks == str(tmp_path / "val/masks") | 67 | assert cfg.val_pairs[0].masks == str(tmp_path / "val/masks") |
| 40 | assert cfg.test_pairs[0].images == str(tmp_path / "test/images") | 68 | assert cfg.test_pairs[0].images == str(tmp_path / "test/images") |
| 41 | assert cfg.test_pairs[0].masks == str(tmp_path / "test/masks") | 69 | assert cfg.test_pairs[0].masks == str(tmp_path / "test/masks") |
| 42 | 70 | ||
| 43 | 71 | ||
| 44 | def test_reroot_data_paths_covers_extra_path_fields(tmp_path: Path) -> None: | 72 | def test_reroot_data_paths_covers_extra_path_fields(tmp_path: Path) -> None: |
| 45 | @dataclass | ||
| 46 | class ExtendedDataConfig(DataConfig): | 73 | class ExtendedDataConfig(DataConfig): |
| 47 | review_sidecar_path: str = "review/dataset_review.sidecar.json" | 74 | review_sidecar_path: str = "review/dataset_review.sidecar.json" |
| 48 | materialised_split_dirs: list = field(default_factory=lambda: [ | 75 | materialised_split_dirs: list[str] = [ |
| 49 | "splits/train", | 76 | "splits/train", |
| 50 | "/mnt/splits/val", | 77 | "/mnt/splits/val", |
| 51 | ]) | 78 | ] |
| 52 | 79 | ||
| 53 | cfg = ExtendedDataConfig() | 80 | cfg = ExtendedDataConfig() |
| 54 | 81 | ||
| 55 | reroot_data_paths(cfg, tmp_path) | 82 | cfg = reroot_data_paths(cfg, tmp_path) |
| 56 | 83 | ||
| 57 | assert cfg.review_sidecar_path == str(tmp_path / "review/dataset_review.sidecar.json") | 84 | assert cfg.review_sidecar_path == str(tmp_path / "review/dataset_review.sidecar.json") |
| 58 | assert cfg.materialised_split_dirs == [ | 85 | assert cfg.materialised_split_dirs == [ |
| 59 | str(tmp_path / "splits/train"), | 86 | str(tmp_path / "splits/train"), |
| 63 | 90 | ||
| 64 | def test_current_data_path_fields_are_covered_by_pair_lists() -> None: | 91 | def test_current_data_path_fields_are_covered_by_pair_lists() -> None: |
| 65 | pair_fields = {"pairs", "val_pairs", "test_pairs"} | 92 | pair_fields = {"pairs", "val_pairs", "test_pairs"} |
| 66 | path_like_fields = { | 93 | path_like_fields = { |
| 67 | field.name | 94 | name |
| 68 | for field in fields(DataConfig) | 95 | for name in DataConfig.model_fields |
| 69 | if field.name.endswith(("path", "paths", "dir", "dirs", "root", "roots")) | 96 | if name.endswith(("path", "paths", "dir", "dirs", "root", "roots")) |
| 70 | } | 97 | } |
| 71 | 98 | ||
| 72 | assert path_like_fields <= pair_fields | 99 | assert path_like_fields <= pair_fields |
| 73 | 100 |
| 84 | assert cfg.val_pairs[0].images == "val/images" | 111 | assert cfg.val_pairs[0].images == "val/images" |
| 85 | assert cfg.val_pairs[0].masks == "val/masks" | 112 | assert cfg.val_pairs[0].masks == "val/masks" |
| 86 | assert cfg.test_pairs[0].images == "/abs/test/images" | 113 | assert cfg.test_pairs[0].images == "/abs/test/images" |
| 87 | assert cfg.test_pairs[0].masks == "/abs/test/masks" | 114 | assert cfg.test_pairs[0].masks == "/abs/test/masks" |
| 115 | |||
| 116 | |||
| 117 | def test_apply_cli_overrides_copies_every_touched_section() -> None: | ||
| 118 | cfg = HarnessConfig() | ||
| 119 | args = _args(log_dir="tb", num_workers=2, max_epochs=7, batch_size=3, | ||
| 120 | model="unetplusplus", encoder="resnet34") | ||
| 121 | |||
| 122 | updated = _train_script().apply_cli_overrides(cfg, args) | ||
| 123 | |||
| 124 | assert updated.train.log_dir == "tb" and updated.train.max_epochs == 7 | ||
| 125 | assert updated.data.num_workers == 2 and updated.data.batch_size == 3 | ||
| 126 | assert updated.model.name == "unetplusplus" | ||
| 127 | assert updated.model.encoder_name == "resnet34" | ||
| 128 | # untouched fields of the copied sections survive | ||
| 129 | assert updated.train.lr == cfg.train.lr | ||
| 130 | assert updated.data.crop_size == cfg.data.crop_size | ||
| 131 | assert updated.model.num_classes == cfg.model.num_classes | ||
| 132 | assert updated.loss == cfg.loss and updated.seed == cfg.seed | ||
| 133 | # the frozen input is never mutated | ||
| 134 | assert cfg.train.log_dir == "runs" and cfg.data.batch_size == 16 | ||
| 135 | |||
| 136 | |||
| 137 | def test_apply_cli_overrides_without_flags_returns_the_input() -> None: | ||
| 138 | cfg = HarnessConfig() | ||
| 139 | |||
| 140 | assert _train_script().apply_cli_overrides(cfg, _args()) is cfg | ||
| 141 | |||
| 142 | |||
| 143 | def test_apply_cli_overrides_honours_zero_valued_flags() -> None: | ||
| 144 | cfg = HarnessConfig() | ||
| 145 | |||
| 146 | updated = _train_script().apply_cli_overrides(cfg, _args(num_workers=0, max_epochs=0)) | ||
| 147 | |||
| 148 | assert updated.data.num_workers == 0 and updated.train.max_epochs == 0 |
ConfigModel: nested section models mirror the packaged*.default.jsonkey for key; whitelist sets and hand-rolled coercion deleted; loader built onconfig_loader.load_config. Public entry-point names and return types unchanged so lanefinder wrappers keep working.pydantic>=2.7dependency.