Miroslav Simko <ms@iolabs.ch> 2026-09-02T08:46:42+02:00
Commit #78 ยท 25 snippets
CLAUDE.md | 8 +++++ pyproject.toml | 7 ++-- scripts/train.py | 38 +++++++++++++++----- src/train/config.py | 90 +++++++++++++++++++++++++--------------------- test/test_harness_smoke.py | 62 ++++++++++++++++++++++++++++++-- 5 files changed, 152 insertions(+), 53 deletions(-)
| 1 | """Task-specific config composed over the shared harness package. | 1 | """Task-specific config composed over the shared harness package. |
| 2 | 2 | ||
| 3 | Plain dataclasses + yaml, no config framework. ``DataConfig`` is | 3 | Pydantic models on `iolabs.common.config_loader.ConfigModel` + yaml. ``DataConfig`` |
| 4 | guardrail-specific (dataset/synthetic split); the ``model``/``loss``/``train`` | 4 | is guardrail-specific (dataset/synthetic split); the ``model``/``loss``/``train`` |
| 5 | sections are the generic dataclasses from ``iolabs_ml_harness.config``, built | 5 | sections are the generic models from ``iolabs_ml_harness.config``. Unknown keys |
| 6 | with the same strict-unknown-keys ``build_section`` the shared package uses. | 6 | raise, so config typos fail fast instead of silently training with defaults. |
| 7 | |||
| 8 | Adding a config key = adding one field with its default to the model below. | ||
| 7 | """ | 9 | """ |
| 8 | from dataclasses import dataclass, field | 10 | import logging |
| 9 | from pathlib import Path | 11 | from pathlib import Path |
| 10 | from typing import Any | 12 | from typing import Literal |
| 13 | |||
| 14 | import pydantic | ||
| 15 | from iolabs.common import config_loader | ||
| 16 | from iolabs_ml_harness import config as harness_config | ||
| 17 | |||
| 18 | logger = logging.getLogger(__name__) | ||
| 19 | |||
| 11 | 20 | ||
| 12 | import yaml | 21 | class ConfigError(config_loader.ConfigError): |
| 13 | from iolabs_ml_harness.config import ( | 22 | """Raised when a guardrail harness config holds unknown keys or bad values.""" |
| 14 | LossConfig, ModelConfig, TrainerConfig, build_section) | ||
| 15 | 23 | ||
| 16 | 24 | ||
| 17 | @dataclass | 25 | class PairSpec(config_loader.ConfigModel): |
| 18 | class PairSpec: | ||
| 19 | """One images-dir / masks-dir pair (see src.dataset.rasters.index_raster_pairs).""" | 26 | """One images-dir / masks-dir pair (see src.dataset.rasters.index_raster_pairs).""" |
| 20 | images: str | 27 | images: str |
| 21 | masks: str | 28 | masks: str |
| 22 | 29 | ||
| 23 | 30 | ||
| 24 | @dataclass | 31 | class DataConfig(config_loader.ConfigModel): |
| 25 | class DataConfig: | 32 | """Guardrail dataset selection and crop/loader sizing.""" |
| 26 | source: str = "synthetic" # synthetic | pairs | 33 | source: Literal["synthetic", "pairs"] = "synthetic" |
| 27 | pairs: list = field(default_factory=list) | 34 | pairs: list[PairSpec] = [] |
| 28 | crop_size: int = 256 | 35 | crop_size: int = pydantic.Field(default=256, gt=0) |
| 29 | batch_size: int = 8 | 36 | batch_size: int = pydantic.Field(default=8, gt=0) |
| 30 | num_workers: int = 4 | 37 | num_workers: int = pydantic.Field(default=4, ge=0) |
| 31 | val_fraction: float = 0.15 | 38 | val_fraction: float = pydantic.Field(default=0.15, ge=0.0, le=1.0) |
| 32 | synthetic_tiles: int = 64 | 39 | synthetic_tiles: int = pydantic.Field(default=64, gt=0) |
| 33 | synthetic_seed: int = 20260707 | 40 | synthetic_seed: int = 20260707 |
| 34 | 41 | ||
| 35 | 42 | ||
| 36 | @dataclass | 43 | class HarnessConfig(config_loader.ConfigModel): |
| 37 | class HarnessConfig: | 44 | """Top-level training config: one YAML file, one instance.""" |
| 38 | experiment: str = "experiment" | 45 | experiment: str = "experiment" |
| 39 | seed: int = 1337 | 46 | seed: int = 1337 |
| 40 | data: DataConfig = field(default_factory=DataConfig) | 47 | data: DataConfig = DataConfig() |
| 41 | model: ModelConfig = field(default_factory=ModelConfig) | 48 | model: harness_config.ModelConfig = harness_config.ModelConfig() |
| 42 | loss: LossConfig = field(default_factory=LossConfig) | 49 | loss: harness_config.LossConfig = harness_config.LossConfig() |
| 43 | train: TrainerConfig = field(default_factory=TrainerConfig) | 50 | train: harness_config.TrainerConfig = harness_config.TrainerConfig() |
| 44 | 51 | ||
| 45 | @classmethod | 52 | @classmethod |
| 46 | def from_yaml(cls, path: str | Path) -> "HarnessConfig": | 53 | def from_yaml(cls, path: str | Path) -> "HarnessConfig": |
| 47 | raw: dict[str, Any] = yaml.safe_load(Path(path).read_text()) or {} | 54 | """Loads and validates a harness YAML config. |
| 48 | data = build_section(DataConfig, raw.pop("data", {}), "data") | 55 | |
| 49 | data.pairs = [build_section(PairSpec, p, "data.pairs[]") for p in data.pairs] | 56 | Args: |
| 50 | cfg = cls( | 57 | path: Path of the YAML file, read as UTF-8. |
| 51 | experiment=raw.pop("experiment", cls.experiment), | 58 | |
| 52 | seed=raw.pop("seed", cls.seed), | 59 | Returns: |
| 53 | data=data, | 60 | The validated, frozen config. |
| 54 | model=build_section(ModelConfig, raw.pop("model", {}), "model"), | 61 | |
| 55 | loss=build_section(LossConfig, raw.pop("loss", {}), "loss"), | 62 | Raises: |
| 56 | train=build_section(TrainerConfig, raw.pop("train", {}), "train"), | 63 | FileNotFoundError: If ``path`` does not exist. |
| 57 | ) | 64 | ConfigError: If the document is not a mapping, holds an unknown key, |
| 58 | if raw: | 65 | or holds a value invalid for its field. Derives from |
| 59 | raise KeyError(f"unknown top-level config key(s) {sorted(raw)} in {path}") | 66 | ``ValueError``. |
| 60 | return cfg | 67 | """ |
| 68 | raw = harness_config.load_yaml_mapping(path) | ||
| 69 | return config_loader.validate_config( | ||
| 70 | cls, raw, context=str(path), error_cls=ConfigError) |
| 18 | deeplabv3plus, ...) and model.encoder_name any smp encoder. encoder_weights: | 18 | deeplabv3plus, ...) and model.encoder_name any smp encoder. encoder_weights: |
| 19 | imagenet downloads pretrained encoder weights on first use. | 19 | imagenet downloads pretrained encoder weights on first use. |
| 20 | """ | 20 | """ |
| 21 | import argparse | 21 | import argparse |
| 22 | from typing import Any | ||
| 22 | 23 | ||
| 23 | import lightning.pytorch as pl | 24 | import lightning.pytorch as pl |
| 24 | from iolabs_ml_harness.losses import build_loss | 25 | from iolabs_ml_harness.losses import build_loss |
| 25 | from iolabs_ml_harness.models import build_model | 26 | from iolabs_ml_harness.models import build_model |
| 43 | parser.add_argument("--cpu", action="store_true", help="force CPU training") | 44 | parser.add_argument("--cpu", action="store_true", help="force CPU training") |
| 44 | return parser.parse_args() | 45 | return parser.parse_args() |
| 45 | 46 | ||
| 46 | 47 | ||
| 48 | def apply_cli_overrides(cfg: config.HarnessConfig, | ||
| 49 | args: argparse.Namespace) -> config.HarnessConfig: | ||
| 50 | """Returns a copy of ``cfg`` with the CLI section overrides applied. | ||
| 51 | |||
| 52 | Config models are frozen, so the overrides are applied by copying each | ||
| 53 | touched section instead of assigning to it. | ||
| 54 | |||
| 55 | Args: | ||
| 56 | cfg: The config parsed from the YAML file. | ||
| 57 | args: Parsed CLI arguments; ``None``/empty values override nothing. | ||
| 58 | |||
| 59 | Returns: | ||
| 60 | ``cfg`` itself when no override was given, otherwise an updated copy. | ||
| 61 | """ | ||
| 62 | sections: dict[str, dict[str, Any]] = {"train": {}, "data": {}, "model": {}} | ||
| 63 | if args.max_epochs is not None: | ||
| 64 | sections["train"]["max_epochs"] = args.max_epochs | ||
| 65 | if args.batch_size is not None: | ||
| 66 | sections["data"]["batch_size"] = args.batch_size | ||
| 67 | if args.model: | ||
| 68 | sections["model"]["name"] = args.model | ||
| 69 | if args.encoder: | ||
| 70 | sections["model"]["encoder_name"] = args.encoder | ||
| 71 | updates = {name: getattr(cfg, name).model_copy(update=values) | ||
| 72 | for name, values in sections.items() if values} | ||
| 73 | return cfg.model_copy(update=updates) if updates else cfg | ||
| 74 | |||
| 75 | |||
| 47 | def main() -> None: | 76 | def main() -> None: |
| 48 | args = parse_args() | 77 | args = parse_args() |
| 49 | cfg = config.HarnessConfig.from_yaml(args.config) | 78 | cfg = config.HarnessConfig.from_yaml(args.config) |
| 50 | if cfg.model.num_classes != len(CLASS_NAMES): | 79 | if cfg.model.num_classes != len(CLASS_NAMES): |
| 51 | raise ValueError( | 80 | raise ValueError( |
| 52 | f"model.num_classes={cfg.model.num_classes} but the harness tracks " | 81 | f"model.num_classes={cfg.model.num_classes} but the harness tracks " |
| 53 | f"{len(CLASS_NAMES)} classes {CLASS_NAMES} -- metrics would mis-bin") | 82 | f"{len(CLASS_NAMES)} classes {CLASS_NAMES} -- metrics would mis-bin") |
| 54 | if args.max_epochs is not None: | 83 | cfg = apply_cli_overrides(cfg, args) |
| 55 | cfg.train.max_epochs = args.max_epochs | ||
| 56 | if args.batch_size is not None: | ||
| 57 | cfg.data.batch_size = args.batch_size | ||
| 58 | if args.model: | ||
| 59 | cfg.model.name = args.model | ||
| 60 | if args.encoder: | ||
| 61 | cfg.model.encoder_name = args.encoder | ||
| 62 | 84 | ||
| 63 | pl.seed_everything(cfg.seed, workers=True) | 85 | pl.seed_everything(cfg.seed, workers=True) |
| 64 | 86 | ||
| 65 | dm = datamodule.GuardrailDataModule(cfg.data) | 87 | dm = datamodule.GuardrailDataModule(cfg.data) |
| 2 | 2 | ||
| 3 | Requires the ml extra (uv sync --extra ml --extra dev), which also pulls in | 3 | Requires the ml extra (uv sync --extra ml --extra dev), which also pulls in |
| 4 | iolabs-ml-harness (path dep during development); skips cleanly otherwise. | 4 | iolabs-ml-harness (path dep during development); skips cleanly otherwise. |
| 5 | """ | 5 | """ |
| 6 | import argparse | ||
| 7 | import importlib.util | ||
| 8 | import sys | ||
| 6 | from pathlib import Path | 9 | from pathlib import Path |
| 7 | 10 | ||
| 8 | import pytest | 11 | import pytest |
| 9 | 12 |
| 23 | from src.dataset.synthetic import CLASS_NAMES, SyntheticGuardrailDataset # noqa: E402 | 26 | from src.dataset.synthetic import CLASS_NAMES, SyntheticGuardrailDataset # noqa: E402 |
| 24 | from src.train.config import DataConfig, HarnessConfig # noqa: E402 | 27 | from src.train.config import DataConfig, HarnessConfig # noqa: E402 |
| 25 | from src.train.datamodule import GuardrailDataModule # noqa: E402 | 28 | from src.train.datamodule import GuardrailDataModule # noqa: E402 |
| 26 | 29 | ||
| 30 | _REPO_ROOT = Path(__file__).resolve().parents[1] | ||
| 31 | _CLI_FLAGS = ("max_epochs", "batch_size", "model", "encoder") | ||
| 32 | |||
| 33 | |||
| 34 | def _train_script(): | ||
| 35 | """Imports scripts/train.py as a module, caching it across tests.""" | ||
| 36 | if "train_script" in sys.modules: | ||
| 37 | return sys.modules["train_script"] | ||
| 38 | spec = importlib.util.spec_from_file_location( | ||
| 39 | "train_script", _REPO_ROOT / "scripts" / "train.py") | ||
| 40 | module = importlib.util.module_from_spec(spec) | ||
| 41 | sys.modules["train_script"] = module | ||
| 42 | spec.loader.exec_module(module) | ||
| 43 | return module | ||
| 44 | |||
| 45 | |||
| 46 | def _args(**overrides: object) -> argparse.Namespace: | ||
| 47 | """Builds a parsed-CLI namespace where unset flags are None.""" | ||
| 48 | return argparse.Namespace(**{name: overrides.get(name) for name in _CLI_FLAGS}) | ||
| 49 | |||
| 27 | 50 | ||
| 28 | def test_shipped_baseline_config_parses() -> None: | 51 | def test_shipped_baseline_config_parses() -> None: |
| 29 | cfg = HarnessConfig.from_yaml("configs/unet_baseline.yaml") | 52 | cfg = HarnessConfig.from_yaml("configs/unet_baseline.yaml") |
| 30 | assert cfg.experiment == "guardrail_unet_baseline" | 53 | assert cfg.experiment == "guardrail_unet_baseline" |
| 42 | cfg = HarnessConfig.from_yaml(good) | 65 | cfg = HarnessConfig.from_yaml(good) |
| 43 | assert cfg.experiment == "t" and cfg.model.name == "unet" | 66 | assert cfg.experiment == "t" and cfg.model.name == "unet" |
| 44 | bad = tmp_path / "bad.yaml" | 67 | bad = tmp_path / "bad.yaml" |
| 45 | bad.write_text("data:\n soruce: synthetic\n") # typo'd key | 68 | bad.write_text("data:\n soruce: synthetic\n") # typo'd key |
| 46 | with pytest.raises(KeyError, match="soruce"): | 69 | with pytest.raises(ValueError, match="soruce"): |
| 47 | HarnessConfig.from_yaml(bad) | 70 | HarnessConfig.from_yaml(bad) |
| 48 | 71 | ||
| 49 | 72 | ||
| 50 | def test_synthetic_dataset_is_deterministic() -> None: | 73 | def test_synthetic_dataset_is_deterministic() -> None: |
| 95 | assert len(dm.val_dataset) == 8 # max(8, int(8 * 0.25)) == 8 | 118 | assert len(dm.val_dataset) == 8 # max(8, int(8 * 0.25)) == 8 |
| 96 | batch = next(iter(dm.train_dataloader())) | 119 | batch = next(iter(dm.train_dataloader())) |
| 97 | assert batch["image"].shape == (2, 1, 64, 64) | 120 | assert batch["image"].shape == (2, 1, 64, 64) |
| 98 | assert batch["mask"].shape == (2, 64, 64) | 121 | assert batch["mask"].shape == (2, 64, 64) |
| 99 | with pytest.raises(ValueError, match="unknown data.source"): | 122 | with pytest.raises(ValueError, match="source"): |
| 100 | GuardrailDataModule(DataConfig(source="nope")).setup() | 123 | DataConfig(source="nope") # rejected by the config model itself |
| 101 | with pytest.raises(NotImplementedError): | 124 | with pytest.raises(NotImplementedError): |
| 102 | GuardrailDataModule(DataConfig(source="pairs")).setup() | 125 | GuardrailDataModule(DataConfig(source="pairs")).setup() |
| 103 | 126 | ||
| 104 | 127 |
| 118 | trainer, _checkpoint, _val_loss_ckpt = build_trainer( | 141 | trainer, _checkpoint, _val_loss_ckpt = build_trainer( |
| 119 | train_cfg, experiment="smoke", fast_dev_run=True, cpu=True) | 142 | train_cfg, experiment="smoke", fast_dev_run=True, cpu=True) |
| 120 | trainer.fit(lit, datamodule=dm) | 143 | trainer.fit(lit, datamodule=dm) |
| 121 | assert dm.train_dataset is not None and dm.val_dataset is not None | 144 | assert dm.train_dataset is not None and dm.val_dataset is not None |
| 145 | |||
| 146 | |||
| 147 | def test_apply_cli_overrides_copies_every_touched_section() -> None: | ||
| 148 | cfg = HarnessConfig() | ||
| 149 | args = _args(max_epochs=7, batch_size=3, model="unetplusplus", encoder="resnet34") | ||
| 150 | |||
| 151 | updated = _train_script().apply_cli_overrides(cfg, args) | ||
| 152 | |||
| 153 | assert updated.train.max_epochs == 7 and updated.data.batch_size == 3 | ||
| 154 | assert updated.model.name == "unetplusplus" | ||
| 155 | assert updated.model.encoder_name == "resnet34" | ||
| 156 | # untouched fields of the copied sections survive | ||
| 157 | assert updated.train.lr == cfg.train.lr | ||
| 158 | assert updated.data.crop_size == cfg.data.crop_size | ||
| 159 | assert updated.data.synthetic_seed == cfg.data.synthetic_seed | ||
| 160 | assert updated.model.num_classes == cfg.model.num_classes | ||
| 161 | assert updated.loss == cfg.loss and updated.seed == cfg.seed | ||
| 162 | # the frozen input is never mutated | ||
| 163 | assert cfg.train.max_epochs == -1 and cfg.data.batch_size == 8 | ||
| 164 | |||
| 165 | |||
| 166 | def test_apply_cli_overrides_without_flags_returns_the_input() -> None: | ||
| 167 | cfg = HarnessConfig() | ||
| 168 | |||
| 169 | assert _train_script().apply_cli_overrides(cfg, _args()) is cfg | ||
| 170 | |||
| 171 | |||
| 172 | def test_apply_cli_overrides_honours_zero_valued_flags() -> None: | ||
| 173 | cfg = HarnessConfig() | ||
| 174 | |||
| 175 | updated = _train_script().apply_cli_overrides(cfg, _args(max_epochs=0)) | ||
| 176 | |||
| 177 | assert updated.train.max_epochs == 0 |
| 1 | [project] | 1 | [project] |
| 2 | name = "iolabs-image-analyzer-guardrail-detection" | 2 | name = "iolabs-image-analyzer-guardrail-detection" |
| 3 | version = "0.1.0" | 3 | version = "0.1.1" |
| 4 | description = "Detection of highway guardrails (position + type) from point-cloud-derived rasters" | 4 | description = "Detection of highway guardrails (position + type) from point-cloud-derived 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", |
| 9 | "scipy>=1.11", | 9 | "scipy>=1.11", |
| 10 | "matplotlib>=3.7", | 10 | "matplotlib>=3.7", |
| 11 | "Pillow>=10.0", | 11 | "Pillow>=10.0", |
| 12 | "pyyaml>=6.0", | 12 | "pyyaml>=6.0", |
| 13 | "pydantic>=2.7", | ||
| 14 | "iolabs-common>=0.9.0", | ||
| 13 | ] | 15 | ] |
| 14 | 16 | ||
| 15 | [project.optional-dependencies] | 17 | [project.optional-dependencies] |
| 16 | dev = [ | 18 | dev = [ |
| 23 | "albumentations>=1.4", | 25 | "albumentations>=1.4", |
| 24 | "lightning>=2.2", | 26 | "lightning>=2.2", |
| 25 | "tensorboard>=2.16", | 27 | "tensorboard>=2.16", |
| 26 | "torchmetrics>=1.3", | 28 | "torchmetrics>=1.3", |
| 27 | "iolabs-ml-harness>=0.1.0", | 29 | "iolabs-ml-harness>=0.2.1", |
| 28 | ] | 30 | ] |
| 29 | # Point-cloud cross-section tooling (src/cross_sections/cross_section_pcd.py); | 31 | # Point-cloud cross-section tooling (src/cross_sections/cross_section_pcd.py); |
| 30 | # torch is also required for this path (see the `ml` extra above). | 32 | # torch is also required for this path (see the `ml` extra above). |
| 31 | pointcloud = [ | 33 | pointcloud = [ |
| 42 | url = "https://nexus.iolabs.ch/repository/pypi-private/simple/" | 44 | url = "https://nexus.iolabs.ch/repository/pypi-private/simple/" |
| 43 | authenticate = "always" | 45 | authenticate = "always" |
| 44 | 46 | ||
| 45 | [tool.uv.sources] | 47 | [tool.uv.sources] |
| 48 | iolabs-common = { index = "nexus" } | ||
| 46 | iolabs-ml-harness = { index = "nexus" } | 49 | iolabs-ml-harness = { index = "nexus" } |
| 47 | iolabs-geometry-geometry = { index = "nexus" } | 50 | iolabs-geometry-geometry = { index = "nexus" } |
| 48 | iolabs-point-cloud-las-tools = { index = "nexus" } | 51 | iolabs-point-cloud-las-tools = { index = "nexus" } |
| 49 | 52 |
| 44 | and datamodule in `src/train`, and dataset code in `src/dataset`. Configs and | 44 | and datamodule in `src/train`, and dataset code in `src/dataset`. Configs and |
| 45 | `scripts/train.py` follow the same shape as | 45 | `scripts/train.py` follow the same shape as |
| 46 | `3dai.iolabs.imageanalyzer.linebitmapsegmentation`. | 46 | `3dai.iolabs.imageanalyzer.linebitmapsegmentation`. |
| 47 | 47 | ||
| 48 | Config sections are pydantic models on `iolabs.common.config_loader.ConfigModel` | ||
| 49 | (unknown keys rejected, values coerced by the shared fleet matrix, instances | ||
| 50 | frozen). **Adding a config key = adding one field with its default to the model | ||
| 51 | in `src/train/config.py`** โ nothing else. `HarnessConfig.from_yaml` raises | ||
| 52 | `config.ConfigError`, which derives from `ValueError`. Because the models are | ||
| 53 | frozen, CLI overrides copy sections (`apply_cli_overrides` in | ||
| 54 | `scripts/train.py`) instead of assigning to them. | ||
| 55 | |||
| 48 | ## Environment | 56 | ## Environment |
| 49 | 57 | ||
| 50 | uv-managed: `uv sync`. Extras: | 58 | uv-managed: `uv sync`. Extras: |
| 51 | 59 |
| 1 | [project] | 1 | [project] |
| 2 | name = "iolabs-image-analyzer-guardrail-detection" | 2 | name = "iolabs-image-analyzer-guardrail-detection" |
| 3 | version = "0.1.0" | 3 | version = "0.1.1" |
| 4 | description = "Detection of highway guardrails (position + type) from point-cloud-derived rasters" | 4 | description = "Detection of highway guardrails (position + type) from point-cloud-derived 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", |
| 9 | "scipy>=1.11", | 9 | "scipy>=1.11", |
| 10 | "matplotlib>=3.7", | 10 | "matplotlib>=3.7", |
| 11 | "Pillow>=10.0", | 11 | "Pillow>=10.0", |
| 12 | "pyyaml>=6.0", | 12 | "pyyaml>=6.0", |
| 13 | "pydantic>=2.7", | ||
| 14 | "iolabs-common>=0.9.0", | ||
| 13 | ] | 15 | ] |
| 14 | 16 | ||
| 15 | [project.optional-dependencies] | 17 | [project.optional-dependencies] |
| 16 | dev = [ | 18 | dev = [ |
| 23 | "albumentations>=1.4", | 25 | "albumentations>=1.4", |
| 24 | "lightning>=2.2", | 26 | "lightning>=2.2", |
| 25 | "tensorboard>=2.16", | 27 | "tensorboard>=2.16", |
| 26 | "torchmetrics>=1.3", | 28 | "torchmetrics>=1.3", |
| 27 | "iolabs-ml-harness>=0.1.0", | 29 | "iolabs-ml-harness>=0.2.1", |
| 28 | ] | 30 | ] |
| 29 | # Point-cloud cross-section tooling (src/cross_sections/cross_section_pcd.py); | 31 | # Point-cloud cross-section tooling (src/cross_sections/cross_section_pcd.py); |
| 30 | # torch is also required for this path (see the `ml` extra above). | 32 | # torch is also required for this path (see the `ml` extra above). |
| 31 | pointcloud = [ | 33 | pointcloud = [ |
| 42 | url = "https://nexus.iolabs.ch/repository/pypi-private/simple/" | 44 | url = "https://nexus.iolabs.ch/repository/pypi-private/simple/" |
| 43 | authenticate = "always" | 45 | authenticate = "always" |
| 44 | 46 | ||
| 45 | [tool.uv.sources] | 47 | [tool.uv.sources] |
| 48 | iolabs-common = { index = "nexus" } | ||
| 46 | iolabs-ml-harness = { index = "nexus" } | 49 | iolabs-ml-harness = { index = "nexus" } |
| 47 | iolabs-geometry-geometry = { index = "nexus" } | 50 | iolabs-geometry-geometry = { index = "nexus" } |
| 48 | iolabs-point-cloud-las-tools = { index = "nexus" } | 51 | iolabs-point-cloud-las-tools = { index = "nexus" } |
| 49 | 52 |
| 18 | deeplabv3plus, ...) and model.encoder_name any smp encoder. encoder_weights: | 18 | deeplabv3plus, ...) and model.encoder_name any smp encoder. encoder_weights: |
| 19 | imagenet downloads pretrained encoder weights on first use. | 19 | imagenet downloads pretrained encoder weights on first use. |
| 20 | """ | 20 | """ |
| 21 | import argparse | 21 | import argparse |
| 22 | from typing import Any | ||
| 22 | 23 | ||
| 23 | import lightning.pytorch as pl | 24 | import lightning.pytorch as pl |
| 24 | from iolabs_ml_harness.losses import build_loss | 25 | from iolabs_ml_harness.losses import build_loss |
| 25 | from iolabs_ml_harness.models import build_model | 26 | from iolabs_ml_harness.models import build_model |
| 43 | parser.add_argument("--cpu", action="store_true", help="force CPU training") | 44 | parser.add_argument("--cpu", action="store_true", help="force CPU training") |
| 44 | return parser.parse_args() | 45 | return parser.parse_args() |
| 45 | 46 | ||
| 46 | 47 | ||
| 48 | def apply_cli_overrides(cfg: config.HarnessConfig, | ||
| 49 | args: argparse.Namespace) -> config.HarnessConfig: | ||
| 50 | """Returns a copy of ``cfg`` with the CLI section overrides applied. | ||
| 51 | |||
| 52 | Config models are frozen, so the overrides are applied by copying each | ||
| 53 | touched section instead of assigning to it. | ||
| 54 | |||
| 55 | Args: | ||
| 56 | cfg: The config parsed from the YAML file. | ||
| 57 | args: Parsed CLI arguments; ``None``/empty values override nothing. | ||
| 58 | |||
| 59 | Returns: | ||
| 60 | ``cfg`` itself when no override was given, otherwise an updated copy. | ||
| 61 | """ | ||
| 62 | sections: dict[str, dict[str, Any]] = {"train": {}, "data": {}, "model": {}} | ||
| 63 | if args.max_epochs is not None: | ||
| 64 | sections["train"]["max_epochs"] = args.max_epochs | ||
| 65 | if args.batch_size is not None: | ||
| 66 | sections["data"]["batch_size"] = args.batch_size | ||
| 67 | if args.model: | ||
| 68 | sections["model"]["name"] = args.model | ||
| 69 | if args.encoder: | ||
| 70 | sections["model"]["encoder_name"] = args.encoder | ||
| 71 | updates = {name: getattr(cfg, name).model_copy(update=values) | ||
| 72 | for name, values in sections.items() if values} | ||
| 73 | return cfg.model_copy(update=updates) if updates else cfg | ||
| 74 | |||
| 75 | |||
| 47 | def main() -> None: | 76 | def main() -> None: |
| 48 | args = parse_args() | 77 | args = parse_args() |
| 49 | cfg = config.HarnessConfig.from_yaml(args.config) | 78 | cfg = config.HarnessConfig.from_yaml(args.config) |
| 50 | if cfg.model.num_classes != len(CLASS_NAMES): | 79 | if cfg.model.num_classes != len(CLASS_NAMES): |
| 51 | raise ValueError( | 80 | raise ValueError( |
| 52 | f"model.num_classes={cfg.model.num_classes} but the harness tracks " | 81 | f"model.num_classes={cfg.model.num_classes} but the harness tracks " |
| 53 | f"{len(CLASS_NAMES)} classes {CLASS_NAMES} -- metrics would mis-bin") | 82 | f"{len(CLASS_NAMES)} classes {CLASS_NAMES} -- metrics would mis-bin") |
| 54 | if args.max_epochs is not None: | 83 | cfg = apply_cli_overrides(cfg, args) |
| 55 | cfg.train.max_epochs = args.max_epochs | ||
| 56 | if args.batch_size is not None: | ||
| 57 | cfg.data.batch_size = args.batch_size | ||
| 58 | if args.model: | ||
| 59 | cfg.model.name = args.model | ||
| 60 | if args.encoder: | ||
| 61 | cfg.model.encoder_name = args.encoder | ||
| 62 | 84 | ||
| 63 | pl.seed_everything(cfg.seed, workers=True) | 85 | pl.seed_everything(cfg.seed, workers=True) |
| 64 | 86 | ||
| 65 | dm = datamodule.GuardrailDataModule(cfg.data) | 87 | dm = datamodule.GuardrailDataModule(cfg.data) |
| 1 | """Task-specific config composed over the shared harness package. | 1 | """Task-specific config composed over the shared harness package. |
| 2 | 2 | ||
| 3 | Plain dataclasses + yaml, no config framework. ``DataConfig`` is | 3 | Pydantic models on `iolabs.common.config_loader.ConfigModel` + yaml. ``DataConfig`` |
| 4 | guardrail-specific (dataset/synthetic split); the ``model``/``loss``/``train`` | 4 | is guardrail-specific (dataset/synthetic split); the ``model``/``loss``/``train`` |
| 5 | sections are the generic dataclasses from ``iolabs_ml_harness.config``, built | 5 | sections are the generic models from ``iolabs_ml_harness.config``. Unknown keys |
| 6 | with the same strict-unknown-keys ``build_section`` the shared package uses. | 6 | raise, so config typos fail fast instead of silently training with defaults. |
| 7 | |||
| 8 | Adding a config key = adding one field with its default to the model below. | ||
| 7 | """ | 9 | """ |
| 8 | from dataclasses import dataclass, field | 10 | import logging |
| 9 | from pathlib import Path | 11 | from pathlib import Path |
| 10 | from typing import Any | 12 | from typing import Literal |
| 13 | |||
| 14 | import pydantic | ||
| 15 | from iolabs.common import config_loader | ||
| 16 | from iolabs_ml_harness import config as harness_config | ||
| 17 | |||
| 18 | logger = logging.getLogger(__name__) | ||
| 19 | |||
| 11 | 20 | ||
| 12 | import yaml | 21 | class ConfigError(config_loader.ConfigError): |
| 13 | from iolabs_ml_harness.config import ( | 22 | """Raised when a guardrail harness config holds unknown keys or bad values.""" |
| 14 | LossConfig, ModelConfig, TrainerConfig, build_section) | ||
| 15 | 23 | ||
| 16 | 24 | ||
| 17 | @dataclass | 25 | class PairSpec(config_loader.ConfigModel): |
| 18 | class PairSpec: | ||
| 19 | """One images-dir / masks-dir pair (see src.dataset.rasters.index_raster_pairs).""" | 26 | """One images-dir / masks-dir pair (see src.dataset.rasters.index_raster_pairs).""" |
| 20 | images: str | 27 | images: str |
| 21 | masks: str | 28 | masks: str |
| 22 | 29 | ||
| 23 | 30 | ||
| 24 | @dataclass | 31 | class DataConfig(config_loader.ConfigModel): |
| 25 | class DataConfig: | 32 | """Guardrail dataset selection and crop/loader sizing.""" |
| 26 | source: str = "synthetic" # synthetic | pairs | 33 | source: Literal["synthetic", "pairs"] = "synthetic" |
| 27 | pairs: list = field(default_factory=list) | 34 | pairs: list[PairSpec] = [] |
| 28 | crop_size: int = 256 | 35 | crop_size: int = pydantic.Field(default=256, gt=0) |
| 29 | batch_size: int = 8 | 36 | batch_size: int = pydantic.Field(default=8, gt=0) |
| 30 | num_workers: int = 4 | 37 | num_workers: int = pydantic.Field(default=4, ge=0) |
| 31 | val_fraction: float = 0.15 | 38 | val_fraction: float = pydantic.Field(default=0.15, ge=0.0, le=1.0) |
| 32 | synthetic_tiles: int = 64 | 39 | synthetic_tiles: int = pydantic.Field(default=64, gt=0) |
| 33 | synthetic_seed: int = 20260707 | 40 | synthetic_seed: int = 20260707 |
| 34 | 41 | ||
| 35 | 42 | ||
| 36 | @dataclass | 43 | class HarnessConfig(config_loader.ConfigModel): |
| 37 | class HarnessConfig: | 44 | """Top-level training config: one YAML file, one instance.""" |
| 38 | experiment: str = "experiment" | 45 | experiment: str = "experiment" |
| 39 | seed: int = 1337 | 46 | seed: int = 1337 |
| 40 | data: DataConfig = field(default_factory=DataConfig) | 47 | data: DataConfig = DataConfig() |
| 41 | model: ModelConfig = field(default_factory=ModelConfig) | 48 | model: harness_config.ModelConfig = harness_config.ModelConfig() |
| 42 | loss: LossConfig = field(default_factory=LossConfig) | 49 | loss: harness_config.LossConfig = harness_config.LossConfig() |
| 43 | train: TrainerConfig = field(default_factory=TrainerConfig) | 50 | train: harness_config.TrainerConfig = harness_config.TrainerConfig() |
| 44 | 51 | ||
| 45 | @classmethod | 52 | @classmethod |
| 46 | def from_yaml(cls, path: str | Path) -> "HarnessConfig": | 53 | def from_yaml(cls, path: str | Path) -> "HarnessConfig": |
| 47 | raw: dict[str, Any] = yaml.safe_load(Path(path).read_text()) or {} | 54 | """Loads and validates a harness YAML config. |
| 48 | data = build_section(DataConfig, raw.pop("data", {}), "data") | 55 | |
| 49 | data.pairs = [build_section(PairSpec, p, "data.pairs[]") for p in data.pairs] | 56 | Args: |
| 50 | cfg = cls( | 57 | path: Path of the YAML file, read as UTF-8. |
| 51 | experiment=raw.pop("experiment", cls.experiment), | 58 | |
| 52 | seed=raw.pop("seed", cls.seed), | 59 | Returns: |
| 53 | data=data, | 60 | The validated, frozen config. |
| 54 | model=build_section(ModelConfig, raw.pop("model", {}), "model"), | 61 | |
| 55 | loss=build_section(LossConfig, raw.pop("loss", {}), "loss"), | 62 | Raises: |
| 56 | train=build_section(TrainerConfig, raw.pop("train", {}), "train"), | 63 | FileNotFoundError: If ``path`` does not exist. |
| 57 | ) | 64 | ConfigError: If the document is not a mapping, holds an unknown key, |
| 58 | if raw: | 65 | or holds a value invalid for its field. Derives from |
| 59 | raise KeyError(f"unknown top-level config key(s) {sorted(raw)} in {path}") | 66 | ``ValueError``. |
| 60 | return cfg | 67 | """ |
| 68 | raw = harness_config.load_yaml_mapping(path) | ||
| 69 | return config_loader.validate_config( | ||
| 70 | cls, raw, context=str(path), error_cls=ConfigError) |
| 2 | 2 | ||
| 3 | Requires the ml extra (uv sync --extra ml --extra dev), which also pulls in | 3 | Requires the ml extra (uv sync --extra ml --extra dev), which also pulls in |
| 4 | iolabs-ml-harness (path dep during development); skips cleanly otherwise. | 4 | iolabs-ml-harness (path dep during development); skips cleanly otherwise. |
| 5 | """ | 5 | """ |
| 6 | import argparse | ||
| 7 | import importlib.util | ||
| 8 | import sys | ||
| 6 | from pathlib import Path | 9 | from pathlib import Path |
| 7 | 10 | ||
| 8 | import pytest | 11 | import pytest |
| 9 | 12 |
| 23 | from src.dataset.synthetic import CLASS_NAMES, SyntheticGuardrailDataset # noqa: E402 | 26 | from src.dataset.synthetic import CLASS_NAMES, SyntheticGuardrailDataset # noqa: E402 |
| 24 | from src.train.config import DataConfig, HarnessConfig # noqa: E402 | 27 | from src.train.config import DataConfig, HarnessConfig # noqa: E402 |
| 25 | from src.train.datamodule import GuardrailDataModule # noqa: E402 | 28 | from src.train.datamodule import GuardrailDataModule # noqa: E402 |
| 26 | 29 | ||
| 30 | _REPO_ROOT = Path(__file__).resolve().parents[1] | ||
| 31 | _CLI_FLAGS = ("max_epochs", "batch_size", "model", "encoder") | ||
| 32 | |||
| 33 | |||
| 34 | def _train_script(): | ||
| 35 | """Imports scripts/train.py as a module, caching it across tests.""" | ||
| 36 | if "train_script" in sys.modules: | ||
| 37 | return sys.modules["train_script"] | ||
| 38 | spec = importlib.util.spec_from_file_location( | ||
| 39 | "train_script", _REPO_ROOT / "scripts" / "train.py") | ||
| 40 | module = importlib.util.module_from_spec(spec) | ||
| 41 | sys.modules["train_script"] = module | ||
| 42 | spec.loader.exec_module(module) | ||
| 43 | return module | ||
| 44 | |||
| 45 | |||
| 46 | def _args(**overrides: object) -> argparse.Namespace: | ||
| 47 | """Builds a parsed-CLI namespace where unset flags are None.""" | ||
| 48 | return argparse.Namespace(**{name: overrides.get(name) for name in _CLI_FLAGS}) | ||
| 49 | |||
| 27 | 50 | ||
| 28 | def test_shipped_baseline_config_parses() -> None: | 51 | def test_shipped_baseline_config_parses() -> None: |
| 29 | cfg = HarnessConfig.from_yaml("configs/unet_baseline.yaml") | 52 | cfg = HarnessConfig.from_yaml("configs/unet_baseline.yaml") |
| 30 | assert cfg.experiment == "guardrail_unet_baseline" | 53 | assert cfg.experiment == "guardrail_unet_baseline" |
| 42 | cfg = HarnessConfig.from_yaml(good) | 65 | cfg = HarnessConfig.from_yaml(good) |
| 43 | assert cfg.experiment == "t" and cfg.model.name == "unet" | 66 | assert cfg.experiment == "t" and cfg.model.name == "unet" |
| 44 | bad = tmp_path / "bad.yaml" | 67 | bad = tmp_path / "bad.yaml" |
| 45 | bad.write_text("data:\n soruce: synthetic\n") # typo'd key | 68 | bad.write_text("data:\n soruce: synthetic\n") # typo'd key |
| 46 | with pytest.raises(KeyError, match="soruce"): | 69 | with pytest.raises(ValueError, match="soruce"): |
| 47 | HarnessConfig.from_yaml(bad) | 70 | HarnessConfig.from_yaml(bad) |
| 48 | 71 | ||
| 49 | 72 | ||
| 50 | def test_synthetic_dataset_is_deterministic() -> None: | 73 | def test_synthetic_dataset_is_deterministic() -> None: |
| 95 | assert len(dm.val_dataset) == 8 # max(8, int(8 * 0.25)) == 8 | 118 | assert len(dm.val_dataset) == 8 # max(8, int(8 * 0.25)) == 8 |
| 96 | batch = next(iter(dm.train_dataloader())) | 119 | batch = next(iter(dm.train_dataloader())) |
| 97 | assert batch["image"].shape == (2, 1, 64, 64) | 120 | assert batch["image"].shape == (2, 1, 64, 64) |
| 98 | assert batch["mask"].shape == (2, 64, 64) | 121 | assert batch["mask"].shape == (2, 64, 64) |
| 99 | with pytest.raises(ValueError, match="unknown data.source"): | 122 | with pytest.raises(ValueError, match="source"): |
| 100 | GuardrailDataModule(DataConfig(source="nope")).setup() | 123 | DataConfig(source="nope") # rejected by the config model itself |
| 101 | with pytest.raises(NotImplementedError): | 124 | with pytest.raises(NotImplementedError): |
| 102 | GuardrailDataModule(DataConfig(source="pairs")).setup() | 125 | GuardrailDataModule(DataConfig(source="pairs")).setup() |
| 103 | 126 | ||
| 104 | 127 |
| 118 | trainer, _checkpoint, _val_loss_ckpt = build_trainer( | 141 | trainer, _checkpoint, _val_loss_ckpt = build_trainer( |
| 119 | train_cfg, experiment="smoke", fast_dev_run=True, cpu=True) | 142 | train_cfg, experiment="smoke", fast_dev_run=True, cpu=True) |
| 120 | trainer.fit(lit, datamodule=dm) | 143 | trainer.fit(lit, datamodule=dm) |
| 121 | assert dm.train_dataset is not None and dm.val_dataset is not None | 144 | assert dm.train_dataset is not None and dm.val_dataset is not None |
| 145 | |||
| 146 | |||
| 147 | def test_apply_cli_overrides_copies_every_touched_section() -> None: | ||
| 148 | cfg = HarnessConfig() | ||
| 149 | args = _args(max_epochs=7, batch_size=3, model="unetplusplus", encoder="resnet34") | ||
| 150 | |||
| 151 | updated = _train_script().apply_cli_overrides(cfg, args) | ||
| 152 | |||
| 153 | assert updated.train.max_epochs == 7 and updated.data.batch_size == 3 | ||
| 154 | assert updated.model.name == "unetplusplus" | ||
| 155 | assert updated.model.encoder_name == "resnet34" | ||
| 156 | # untouched fields of the copied sections survive | ||
| 157 | assert updated.train.lr == cfg.train.lr | ||
| 158 | assert updated.data.crop_size == cfg.data.crop_size | ||
| 159 | assert updated.data.synthetic_seed == cfg.data.synthetic_seed | ||
| 160 | assert updated.model.num_classes == cfg.model.num_classes | ||
| 161 | assert updated.loss == cfg.loss and updated.seed == cfg.seed | ||
| 162 | # the frozen input is never mutated | ||
| 163 | assert cfg.train.max_epochs == -1 and cfg.data.batch_size == 8 | ||
| 164 | |||
| 165 | |||
| 166 | def test_apply_cli_overrides_without_flags_returns_the_input() -> None: | ||
| 167 | cfg = HarnessConfig() | ||
| 168 | |||
| 169 | assert _train_script().apply_cli_overrides(cfg, _args()) is cfg | ||
| 170 | |||
| 171 | |||
| 172 | def test_apply_cli_overrides_honours_zero_valued_flags() -> None: | ||
| 173 | cfg = HarnessConfig() | ||
| 174 | |||
| 175 | updated = _train_script().apply_cli_overrides(cfg, _args(max_epochs=0)) | ||
| 176 | |||
| 177 | assert 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.