Miroslav Simko <ms@iolabs.ch> 2026-09-02T09:19:39+02:00
Commit #76 ยท 20 snippets
README.md | 14 +++++++-- src/iolabs_ml_harness/__init__.py | 2 ++ src/iolabs_ml_harness/config.py | 66 +++++++++++++++++++++++++++++++++------ tests/test_config_registry.py | 37 +++++++++++++++++++++- 4 files changed, 107 insertions(+), 12 deletions(-)
| 10 | ``ModelConfig`` is the legacy image schema and is kept unchanged for the 2D | 10 | ``ModelConfig`` is the legacy image schema and is kept unchanged for the 2D |
| 11 | consumer. Modality-neutral consumers use ``FactoryConfig`` or their own strict | 11 | consumer. Modality-neutral consumers use ``FactoryConfig`` or their own strict |
| 12 | `ConfigModel`. | 12 | `ConfigModel`. |
| 13 | """ | 13 | """ |
| 14 | import logging | ||
| 15 | from collections.abc import Mapping | 14 | from collections.abc import Mapping |
| 16 | from pathlib import Path | 15 | from pathlib import Path |
| 17 | from typing import Any, Literal, TypeVar | 16 | from typing import Any, Literal, TypeVar, get_args |
| 18 | 17 | ||
| 19 | import pydantic | 18 | import pydantic |
| 20 | import yaml | 19 | import yaml |
| 21 | from iolabs.common import config_loader | 20 | from iolabs.common import config_loader |
| 22 | 21 | from pydantic import fields as pydantic_fields | |
| 23 | logger = logging.getLogger(__name__) | ||
| 24 | 22 | ||
| 25 | T = TypeVar("T", bound=config_loader.ConfigModel) | 23 | T = TypeVar("T", bound=config_loader.ConfigModel) |
| 26 | 24 | ||
| 27 | 25 | ||
| 26 | class SectionModel(config_loader.ConfigModel): | ||
| 27 | """`ConfigModel` in which a YAML ``null`` means "use this field's default". | ||
| 28 | |||
| 29 | A bare ``model:`` / ``args:`` line parses to ``None``; the hand-written | ||
| 30 | ``data or {}`` coalescing that predates the pydantic layer treated that as | ||
| 31 | "section omitted". Fields that accept ``None`` (e.g. ``encoder_weights``) | ||
| 32 | keep it as a real value, and required fields still fail. | ||
| 33 | """ | ||
| 34 | |||
| 35 | @pydantic.model_validator(mode="before") | ||
| 36 | @classmethod | ||
| 37 | def _null_means_default(cls, data: Any) -> Any: | ||
| 38 | """Drop ``None`` entries whose field has a default and forbids ``None``.""" | ||
| 39 | if not isinstance(data, Mapping): | ||
| 40 | return data | ||
| 41 | dropped = { | ||
| 42 | name for name, value in data.items() | ||
| 43 | if value is None and _null_means_default_for(cls.model_fields.get(name))} | ||
| 44 | if not dropped: | ||
| 45 | return data | ||
| 46 | return {name: value for name, value in data.items() if name not in dropped} | ||
| 47 | |||
| 48 | |||
| 49 | def _null_means_default_for(field: pydantic_fields.FieldInfo | None) -> bool: | ||
| 50 | """True when *field* has a default and its annotation does not accept None.""" | ||
| 51 | if field is None or field.is_required(): | ||
| 52 | return False | ||
| 53 | return type(None) not in get_args(field.annotation) | ||
| 54 | |||
| 55 | |||
| 56 | def reject_bool(value: Any, name: str) -> Any: | ||
| 57 | """Raise when *value* is a ``bool`` reaching an ``int | str`` field. | ||
| 58 | |||
| 59 | YAML turns ``yes``/``on`` into ``True``, which pydantic would happily narrow | ||
| 60 | to ``1`` on an ``int | str`` union -- a typo would become a silent | ||
| 61 | single-device / precision-1 run. | ||
| 62 | """ | ||
| 63 | if isinstance(value, bool): | ||
| 64 | raise ValueError(f"{name} must be an int or a str, got bool {value!r}") | ||
| 65 | return value | ||
| 66 | |||
| 67 | |||
| 28 | class ConfigError(config_loader.ConfigError): | 68 | class ConfigError(config_loader.ConfigError): |
| 29 | """Raised when a harness config section holds unknown keys or bad values.""" | 69 | """Raised when a harness config section holds unknown keys or bad values.""" |
| 30 | 70 | ||
| 31 | 71 |
| 76 | f"got {type(loaded).__name__}") | 116 | f"got {type(loaded).__name__}") |
| 77 | return dict(loaded) | 117 | return dict(loaded) |
| 78 | 118 | ||
| 79 | 119 | ||
| 80 | class FactoryConfig(config_loader.ConfigModel): | 120 | class FactoryConfig(SectionModel): |
| 81 | """Modality-neutral registry selection: a name plus factory keyword args.""" | 121 | """Modality-neutral registry selection: a name plus factory keyword args.""" |
| 82 | name: str | 122 | name: str |
| 83 | args: dict[str, Any] = {} | 123 | args: dict[str, Any] = {} |
| 84 | 124 | ||
| 85 | 125 | ||
| 86 | class ModelConfig(config_loader.ConfigModel): | 126 | class ModelConfig(SectionModel): |
| 87 | """Legacy image-model schema (segmentation-models-pytorch shaped).""" | 127 | """Legacy image-model schema (segmentation-models-pytorch shaped).""" |
| 88 | name: str = "unet" | 128 | name: str = "unet" |
| 89 | encoder_name: str = "resnet18" | 129 | encoder_name: str = "resnet18" |
| 90 | encoder_weights: str | None = "imagenet" # None = train from scratch | 130 | encoder_weights: str | None = "imagenet" # None = train from scratch |
| 92 | num_classes: int = pydantic.Field(default=3, gt=0) | 132 | num_classes: int = pydantic.Field(default=3, gt=0) |
| 93 | extra: dict[str, Any] = {} # passed to the factory | 133 | extra: dict[str, Any] = {} # passed to the factory |
| 94 | 134 | ||
| 95 | 135 | ||
| 96 | class LossConfig(config_loader.ConfigModel): | 136 | class LossConfig(SectionModel): |
| 97 | """Loss selection by registry name plus factory keyword arguments.""" | 137 | """Loss selection by registry name plus factory keyword arguments.""" |
| 98 | name: str = "dice_focal" | 138 | name: str = "dice_focal" |
| 99 | args: dict[str, Any] = {} | 139 | args: dict[str, Any] = {} |
| 100 | 140 | ||
| 101 | 141 | ||
| 102 | class TrainerConfig(config_loader.ConfigModel): | 142 | class TrainerConfig(SectionModel): |
| 103 | """Harness-visible Lightning trainer, logger, and callback knobs.""" | 143 | """Harness-visible Lightning trainer, logger, and callback knobs.""" |
| 104 | max_epochs: int = pydantic.Field(default=-1, ge=-1) # -1 = no cap | 144 | max_epochs: int = pydantic.Field(default=-1, ge=-1) # -1 = no cap |
| 105 | lr: float = pydantic.Field(default=3.0e-4, gt=0) | 145 | lr: float = pydantic.Field(default=3.0e-4, gt=0) |
| 106 | weight_decay: float = pydantic.Field(default=1.0e-4, ge=0) | 146 | weight_decay: float = pydantic.Field(default=1.0e-4, ge=0) |
| 107 | precision: str = "auto" # auto -> 16-mixed on CUDA, 32-true on CPU | 147 | # "auto" -> 16-mixed on CUDA, 32-true on CPU; else a Lightning precision |
| 148 | # (16, "16-mixed", "bf16-mixed", 32, "32-true", ...) | ||
| 149 | precision: int | str = "auto" | ||
| 108 | accumulate_grad_batches: int = pydantic.Field(default=1, ge=1) | 150 | accumulate_grad_batches: int = pydantic.Field(default=1, ge=1) |
| 109 | accelerator: str = "auto" | 151 | accelerator: str = "auto" |
| 110 | devices: int | str = 1 | 152 | devices: int | str = 1 |
| 111 | viz_every_n_epochs: int = pydantic.Field(default=2, ge=0) | 153 | viz_every_n_epochs: int = pydantic.Field(default=2, ge=0) |
| 117 | # epochs without improvement before stopping; 0 disables | 159 | # epochs without improvement before stopping; 0 disables |
| 118 | early_stop_patience: int = pydantic.Field(default=4, ge=0) | 160 | early_stop_patience: int = pydantic.Field(default=4, ge=0) |
| 119 | log_dir: str = "runs" | 161 | log_dir: str = "runs" |
| 120 | log_every_n_steps: int = pydantic.Field(default=10, ge=1) | 162 | log_every_n_steps: int = pydantic.Field(default=10, ge=1) |
| 163 | |||
| 164 | @pydantic.field_validator("precision", "devices", mode="before") | ||
| 165 | @classmethod | ||
| 166 | def _reject_bool(cls, value: Any, info: pydantic.ValidationInfo) -> Any: | ||
| 167 | """Keep YAML ``yes``/``on`` from silently narrowing to ``1``.""" | ||
| 168 | return reject_bool(value, info.field_name or "value") |
| 11 | ConfigError, | 11 | ConfigError, |
| 12 | FactoryConfig, | 12 | FactoryConfig, |
| 13 | LossConfig, | 13 | LossConfig, |
| 14 | ModelConfig, | 14 | ModelConfig, |
| 15 | SectionModel, | ||
| 15 | TrainerConfig, | 16 | TrainerConfig, |
| 16 | build_section, | 17 | build_section, |
| 17 | load_yaml_mapping, | 18 | load_yaml_mapping, |
| 18 | ) | 19 | ) |
| 72 | "FactoryRegistry", | 73 | "FactoryRegistry", |
| 73 | "LossConfig", | 74 | "LossConfig", |
| 74 | "MaskOverlayWriter", | 75 | "MaskOverlayWriter", |
| 75 | "ModelConfig", | 76 | "ModelConfig", |
| 77 | "SectionModel", | ||
| 76 | "SegmentationModule", | 78 | "SegmentationModule", |
| 77 | "SegmentationStats", | 79 | "SegmentationStats", |
| 78 | "TrainerConfig", | 80 | "TrainerConfig", |
| 79 | "WeightedSum", | 81 | "WeightedSum", |
| 11 | ConfigError, | 11 | ConfigError, |
| 12 | FactoryConfig, | 12 | FactoryConfig, |
| 13 | LossConfig, | 13 | LossConfig, |
| 14 | ModelConfig, | 14 | ModelConfig, |
| 15 | SectionModel, | ||
| 15 | TrainerConfig, | 16 | TrainerConfig, |
| 16 | build_section, | 17 | build_section, |
| 17 | load_yaml_mapping, | 18 | load_yaml_mapping, |
| 18 | ) | 19 | ) |
| 48 | with pytest.raises(ValueError): | 49 | with pytest.raises(ValueError): |
| 49 | build_section(_Section, {"alpha": "not-an-int"}, "train") | 50 | build_section(_Section, {"alpha": "not-an-int"}, "train") |
| 50 | 51 | ||
| 51 | 52 | ||
| 52 | def test_legacy_dataclass_defaults_are_unchanged() -> None: | 53 | def test_shared_section_defaults_are_unchanged() -> None: |
| 53 | model = ModelConfig() | 54 | model = ModelConfig() |
| 54 | assert (model.name, model.encoder_name, model.encoder_weights) == ( | 55 | assert (model.name, model.encoder_name, model.encoder_weights) == ( |
| 55 | "unet", "resnet18", "imagenet") | 56 | "unet", "resnet18", "imagenet") |
| 56 | assert (model.in_channels, model.num_classes, model.extra) == (1, 3, {}) | 57 | assert (model.in_channels, model.num_classes, model.extra) == (1, 3, {}) |
| 67 | assert train.weight_decay == pytest.approx(1.0e-4) | 68 | assert train.weight_decay == pytest.approx(1.0e-4) |
| 68 | assert train.early_stop_mode == "min" | 69 | assert train.early_stop_mode == "min" |
| 69 | 70 | ||
| 70 | 71 | ||
| 72 | def test_null_section_value_falls_back_to_the_default() -> None: | ||
| 73 | """A bare ``args:``/``extra:`` line parses to None and must mean "default".""" | ||
| 74 | assert build_section(LossConfig, {"name": "dice", "args": None}, "loss").args == {} | ||
| 75 | assert build_section(ModelConfig, {"extra": None}, "model").extra == {} | ||
| 76 | # a field that really accepts None keeps it | ||
| 77 | assert build_section( | ||
| 78 | ModelConfig, {"encoder_weights": None}, "model").encoder_weights is None | ||
| 79 | # required fields still fail on None | ||
| 80 | with pytest.raises(ConfigError, match="name"): | ||
| 81 | build_section(FactoryConfig, {"name": None}, "loss") | ||
| 82 | |||
| 83 | |||
| 84 | def test_null_means_default_only_applies_to_section_models() -> None: | ||
| 85 | class _Plain(config_loader.ConfigModel): | ||
| 86 | alpha: int = 1 | ||
| 87 | |||
| 88 | assert issubclass(LossConfig, SectionModel) | ||
| 89 | with pytest.raises(ConfigError): | ||
| 90 | build_section(_Plain, {"alpha": None}, "plain") | ||
| 91 | |||
| 92 | |||
| 93 | def test_precision_accepts_lightning_ints_and_strings() -> None: | ||
| 94 | assert build_section(TrainerConfig, {"precision": 16}, "train").precision == 16 | ||
| 95 | assert build_section( | ||
| 96 | TrainerConfig, {"precision": "bf16-mixed"}, "train").precision == "bf16-mixed" | ||
| 97 | |||
| 98 | |||
| 99 | @pytest.mark.parametrize("field", ["precision", "devices"]) | ||
| 100 | def test_yaml_yes_is_rejected_instead_of_becoming_one(field: str) -> None: | ||
| 101 | """PyYAML turns ``devices: yes`` into True; int|str would narrow it to 1.""" | ||
| 102 | with pytest.raises(ConfigError, match="got bool"): | ||
| 103 | build_section(TrainerConfig, {field: True}, "train") | ||
| 104 | |||
| 105 | |||
| 71 | def test_factory_config_is_name_plus_args() -> None: | 106 | def test_factory_config_is_name_plus_args() -> None: |
| 72 | cfg = build_section(FactoryConfig, {"name": "masked_focal_tversky", | 107 | cfg = build_section(FactoryConfig, {"name": "masked_focal_tversky", |
| 73 | "args": {"alpha": 0.8}}, "loss") | 108 | "args": {"alpha": 0.8}}, "loss") |
| 74 | assert cfg.name == "masked_focal_tversky" and cfg.args == {"alpha": 0.8} | 109 | assert cfg.name == "masked_focal_tversky" and cfg.args == {"alpha": 0.8} |
| 7 | Core is modality neutral and depends only on `numpy`, `pyyaml`, `pydantic`, `iolabs-common` (the shared config layer), `torch`, `lightning`, `tensorboard`, and `torchmetrics`. OpenCV and `segmentation-models-pytorch` moved into the `image` extra in 0.2.0, so a point-cloud consumer never installs an image stack. | 7 | Core is modality neutral and depends only on `numpy`, `pyyaml`, `pydantic`, `iolabs-common` (the shared config layer), `torch`, `lightning`, `tensorboard`, and `torchmetrics`. OpenCV and `segmentation-models-pytorch` moved into the `image` extra in 0.2.0, so a point-cloud consumer never installs an image stack. |
| 8 | 8 | ||
| 9 | | Module | Extra | Purpose | | 9 | | Module | Extra | Purpose | |
| 10 | | --- | --- | --- | | 10 | | --- | --- | --- | |
| 11 | | `config` | core | Strict `build_section`, `load_yaml_mapping`, `ConfigError`, `FactoryConfig`, and the legacy model/loss/trainer `ConfigModel` sections. | | 11 | | `config` | core | Strict `build_section`, `load_yaml_mapping`, `ConfigError`, `SectionModel`, `FactoryConfig`, and the legacy model/loss/trainer sections. | |
| 12 | | `registry` | core | Typed `FactoryRegistry` behind the model and loss registries. | | 12 | | `registry` | core | Typed `FactoryRegistry` behind the model and loss registries. | |
| 13 | | `models` | core | Model registry, `build_registered_model`, and the legacy lazy smp fallback in `build_model`. | | 13 | | `models` | core | Model registry, `build_registered_model`, and the legacy lazy smp fallback in `build_model`. | |
| 14 | | `losses` | core | Task-neutral registry, `WeightedSum`, and rank-agnostic `cross_entropy` / `focal_cross_entropy` / `masked_focal_tversky`. | | 14 | | `losses` | core | Task-neutral registry, `WeightedSum`, and rank-agnostic `cross_entropy` / `focal_cross_entropy` / `masked_focal_tversky`. | |
| 15 | | `metrics` | core | Rank-agnostic, ignore-aware confusion matrix and `SegmentationStats`. | | 15 | | `metrics` | core | Rank-agnostic, ignore-aware confusion matrix and `SegmentationStats`. | |
| 32 | `iolabs.common.config_loader.ConfigModel` (unknown keys rejected, values coerced | 32 | `iolabs.common.config_loader.ConfigModel` (unknown keys rejected, values coerced |
| 33 | by the shared fleet matrix, instances frozen). **Adding a config key = adding one | 33 | by the shared fleet matrix, instances frozen). **Adding a config key = adding one |
| 34 | field with its default to the model in `config.py`** โ there is no separate | 34 | field with its default to the model in `config.py`** โ there is no separate |
| 35 | allow-list, coercion helper, or dataclass to keep in sync. `build_section` | 35 | allow-list, coercion helper, or dataclass to keep in sync. `build_section` |
| 36 | raises `ConfigError`, which derives from `ValueError`. | 36 | raises `ConfigError`, which derives from `ValueError`. Constructing a model |
| 37 | directly (`TrainerConfig(max_epochs=-2)`) raises `pydantic.ValidationError`; | ||
| 38 | only the `build_section` / `validate_config` entry points wrap it in | ||
| 39 | `ConfigError`, so mutate configs through them rather than `model_copy(update=)`, | ||
| 40 | which skips validation entirely. | ||
| 41 | |||
| 42 | Sections derive from `SectionModel`, so a bare `loss:` / `args:` line (YAML | ||
| 43 | `null`) means "use the defaults" โ as the pre-pydantic `data or {}` coalescing | ||
| 44 | did โ while a field that genuinely accepts `None` (`model.encoder_weights`) | ||
| 45 | keeps it. `train.precision` takes Lightning ints (`16`) as well as strings; | ||
| 46 | `precision`/`devices` reject YAML `yes`/`on` instead of narrowing them to `1`. | ||
| 37 | 47 | ||
| 38 | ## Compatibility | 48 | ## Compatibility |
| 39 | 49 | ||
| 40 | 0.2.1 keeps every public name; `build_section` now raises `ConfigError` | 50 | 0.2.1 keeps every public name; `build_section` now raises `ConfigError` |
| 11 | ConfigError, | 11 | ConfigError, |
| 12 | FactoryConfig, | 12 | FactoryConfig, |
| 13 | LossConfig, | 13 | LossConfig, |
| 14 | ModelConfig, | 14 | ModelConfig, |
| 15 | SectionModel, | ||
| 15 | TrainerConfig, | 16 | TrainerConfig, |
| 16 | build_section, | 17 | build_section, |
| 17 | load_yaml_mapping, | 18 | load_yaml_mapping, |
| 18 | ) | 19 | ) |
| 72 | "FactoryRegistry", | 73 | "FactoryRegistry", |
| 73 | "LossConfig", | 74 | "LossConfig", |
| 74 | "MaskOverlayWriter", | 75 | "MaskOverlayWriter", |
| 75 | "ModelConfig", | 76 | "ModelConfig", |
| 77 | "SectionModel", | ||
| 76 | "SegmentationModule", | 78 | "SegmentationModule", |
| 77 | "SegmentationStats", | 79 | "SegmentationStats", |
| 78 | "TrainerConfig", | 80 | "TrainerConfig", |
| 79 | "WeightedSum", | 81 | "WeightedSum", |
| 10 | ``ModelConfig`` is the legacy image schema and is kept unchanged for the 2D | 10 | ``ModelConfig`` is the legacy image schema and is kept unchanged for the 2D |
| 11 | consumer. Modality-neutral consumers use ``FactoryConfig`` or their own strict | 11 | consumer. Modality-neutral consumers use ``FactoryConfig`` or their own strict |
| 12 | `ConfigModel`. | 12 | `ConfigModel`. |
| 13 | """ | 13 | """ |
| 14 | import logging | ||
| 15 | from collections.abc import Mapping | 14 | from collections.abc import Mapping |
| 16 | from pathlib import Path | 15 | from pathlib import Path |
| 17 | from typing import Any, Literal, TypeVar | 16 | from typing import Any, Literal, TypeVar, get_args |
| 18 | 17 | ||
| 19 | import pydantic | 18 | import pydantic |
| 20 | import yaml | 19 | import yaml |
| 21 | from iolabs.common import config_loader | 20 | from iolabs.common import config_loader |
| 22 | 21 | from pydantic import fields as pydantic_fields | |
| 23 | logger = logging.getLogger(__name__) | ||
| 24 | 22 | ||
| 25 | T = TypeVar("T", bound=config_loader.ConfigModel) | 23 | T = TypeVar("T", bound=config_loader.ConfigModel) |
| 26 | 24 | ||
| 27 | 25 | ||
| 26 | class SectionModel(config_loader.ConfigModel): | ||
| 27 | """`ConfigModel` in which a YAML ``null`` means "use this field's default". | ||
| 28 | |||
| 29 | A bare ``model:`` / ``args:`` line parses to ``None``; the hand-written | ||
| 30 | ``data or {}`` coalescing that predates the pydantic layer treated that as | ||
| 31 | "section omitted". Fields that accept ``None`` (e.g. ``encoder_weights``) | ||
| 32 | keep it as a real value, and required fields still fail. | ||
| 33 | """ | ||
| 34 | |||
| 35 | @pydantic.model_validator(mode="before") | ||
| 36 | @classmethod | ||
| 37 | def _null_means_default(cls, data: Any) -> Any: | ||
| 38 | """Drop ``None`` entries whose field has a default and forbids ``None``.""" | ||
| 39 | if not isinstance(data, Mapping): | ||
| 40 | return data | ||
| 41 | dropped = { | ||
| 42 | name for name, value in data.items() | ||
| 43 | if value is None and _null_means_default_for(cls.model_fields.get(name))} | ||
| 44 | if not dropped: | ||
| 45 | return data | ||
| 46 | return {name: value for name, value in data.items() if name not in dropped} | ||
| 47 | |||
| 48 | |||
| 49 | def _null_means_default_for(field: pydantic_fields.FieldInfo | None) -> bool: | ||
| 50 | """True when *field* has a default and its annotation does not accept None.""" | ||
| 51 | if field is None or field.is_required(): | ||
| 52 | return False | ||
| 53 | return type(None) not in get_args(field.annotation) | ||
| 54 | |||
| 55 | |||
| 56 | def reject_bool(value: Any, name: str) -> Any: | ||
| 57 | """Raise when *value* is a ``bool`` reaching an ``int | str`` field. | ||
| 58 | |||
| 59 | YAML turns ``yes``/``on`` into ``True``, which pydantic would happily narrow | ||
| 60 | to ``1`` on an ``int | str`` union -- a typo would become a silent | ||
| 61 | single-device / precision-1 run. | ||
| 62 | """ | ||
| 63 | if isinstance(value, bool): | ||
| 64 | raise ValueError(f"{name} must be an int or a str, got bool {value!r}") | ||
| 65 | return value | ||
| 66 | |||
| 67 | |||
| 28 | class ConfigError(config_loader.ConfigError): | 68 | class ConfigError(config_loader.ConfigError): |
| 29 | """Raised when a harness config section holds unknown keys or bad values.""" | 69 | """Raised when a harness config section holds unknown keys or bad values.""" |
| 30 | 70 | ||
| 31 | 71 |
| 76 | f"got {type(loaded).__name__}") | 116 | f"got {type(loaded).__name__}") |
| 77 | return dict(loaded) | 117 | return dict(loaded) |
| 78 | 118 | ||
| 79 | 119 | ||
| 80 | class FactoryConfig(config_loader.ConfigModel): | 120 | class FactoryConfig(SectionModel): |
| 81 | """Modality-neutral registry selection: a name plus factory keyword args.""" | 121 | """Modality-neutral registry selection: a name plus factory keyword args.""" |
| 82 | name: str | 122 | name: str |
| 83 | args: dict[str, Any] = {} | 123 | args: dict[str, Any] = {} |
| 84 | 124 | ||
| 85 | 125 | ||
| 86 | class ModelConfig(config_loader.ConfigModel): | 126 | class ModelConfig(SectionModel): |
| 87 | """Legacy image-model schema (segmentation-models-pytorch shaped).""" | 127 | """Legacy image-model schema (segmentation-models-pytorch shaped).""" |
| 88 | name: str = "unet" | 128 | name: str = "unet" |
| 89 | encoder_name: str = "resnet18" | 129 | encoder_name: str = "resnet18" |
| 90 | encoder_weights: str | None = "imagenet" # None = train from scratch | 130 | encoder_weights: str | None = "imagenet" # None = train from scratch |
| 92 | num_classes: int = pydantic.Field(default=3, gt=0) | 132 | num_classes: int = pydantic.Field(default=3, gt=0) |
| 93 | extra: dict[str, Any] = {} # passed to the factory | 133 | extra: dict[str, Any] = {} # passed to the factory |
| 94 | 134 | ||
| 95 | 135 | ||
| 96 | class LossConfig(config_loader.ConfigModel): | 136 | class LossConfig(SectionModel): |
| 97 | """Loss selection by registry name plus factory keyword arguments.""" | 137 | """Loss selection by registry name plus factory keyword arguments.""" |
| 98 | name: str = "dice_focal" | 138 | name: str = "dice_focal" |
| 99 | args: dict[str, Any] = {} | 139 | args: dict[str, Any] = {} |
| 100 | 140 | ||
| 101 | 141 | ||
| 102 | class TrainerConfig(config_loader.ConfigModel): | 142 | class TrainerConfig(SectionModel): |
| 103 | """Harness-visible Lightning trainer, logger, and callback knobs.""" | 143 | """Harness-visible Lightning trainer, logger, and callback knobs.""" |
| 104 | max_epochs: int = pydantic.Field(default=-1, ge=-1) # -1 = no cap | 144 | max_epochs: int = pydantic.Field(default=-1, ge=-1) # -1 = no cap |
| 105 | lr: float = pydantic.Field(default=3.0e-4, gt=0) | 145 | lr: float = pydantic.Field(default=3.0e-4, gt=0) |
| 106 | weight_decay: float = pydantic.Field(default=1.0e-4, ge=0) | 146 | weight_decay: float = pydantic.Field(default=1.0e-4, ge=0) |
| 107 | precision: str = "auto" # auto -> 16-mixed on CUDA, 32-true on CPU | 147 | # "auto" -> 16-mixed on CUDA, 32-true on CPU; else a Lightning precision |
| 148 | # (16, "16-mixed", "bf16-mixed", 32, "32-true", ...) | ||
| 149 | precision: int | str = "auto" | ||
| 108 | accumulate_grad_batches: int = pydantic.Field(default=1, ge=1) | 150 | accumulate_grad_batches: int = pydantic.Field(default=1, ge=1) |
| 109 | accelerator: str = "auto" | 151 | accelerator: str = "auto" |
| 110 | devices: int | str = 1 | 152 | devices: int | str = 1 |
| 111 | viz_every_n_epochs: int = pydantic.Field(default=2, ge=0) | 153 | viz_every_n_epochs: int = pydantic.Field(default=2, ge=0) |
| 117 | # epochs without improvement before stopping; 0 disables | 159 | # epochs without improvement before stopping; 0 disables |
| 118 | early_stop_patience: int = pydantic.Field(default=4, ge=0) | 160 | early_stop_patience: int = pydantic.Field(default=4, ge=0) |
| 119 | log_dir: str = "runs" | 161 | log_dir: str = "runs" |
| 120 | log_every_n_steps: int = pydantic.Field(default=10, ge=1) | 162 | log_every_n_steps: int = pydantic.Field(default=10, ge=1) |
| 163 | |||
| 164 | @pydantic.field_validator("precision", "devices", mode="before") | ||
| 165 | @classmethod | ||
| 166 | def _reject_bool(cls, value: Any, info: pydantic.ValidationInfo) -> Any: | ||
| 167 | """Keep YAML ``yes``/``on`` from silently narrowing to ``1``.""" | ||
| 168 | return reject_bool(value, info.field_name or "value") |
| 11 | ConfigError, | 11 | ConfigError, |
| 12 | FactoryConfig, | 12 | FactoryConfig, |
| 13 | LossConfig, | 13 | LossConfig, |
| 14 | ModelConfig, | 14 | ModelConfig, |
| 15 | SectionModel, | ||
| 15 | TrainerConfig, | 16 | TrainerConfig, |
| 16 | build_section, | 17 | build_section, |
| 17 | load_yaml_mapping, | 18 | load_yaml_mapping, |
| 18 | ) | 19 | ) |
| 48 | with pytest.raises(ValueError): | 49 | with pytest.raises(ValueError): |
| 49 | build_section(_Section, {"alpha": "not-an-int"}, "train") | 50 | build_section(_Section, {"alpha": "not-an-int"}, "train") |
| 50 | 51 | ||
| 51 | 52 | ||
| 52 | def test_legacy_dataclass_defaults_are_unchanged() -> None: | 53 | def test_shared_section_defaults_are_unchanged() -> None: |
| 53 | model = ModelConfig() | 54 | model = ModelConfig() |
| 54 | assert (model.name, model.encoder_name, model.encoder_weights) == ( | 55 | assert (model.name, model.encoder_name, model.encoder_weights) == ( |
| 55 | "unet", "resnet18", "imagenet") | 56 | "unet", "resnet18", "imagenet") |
| 56 | assert (model.in_channels, model.num_classes, model.extra) == (1, 3, {}) | 57 | assert (model.in_channels, model.num_classes, model.extra) == (1, 3, {}) |
| 67 | assert train.weight_decay == pytest.approx(1.0e-4) | 68 | assert train.weight_decay == pytest.approx(1.0e-4) |
| 68 | assert train.early_stop_mode == "min" | 69 | assert train.early_stop_mode == "min" |
| 69 | 70 | ||
| 70 | 71 | ||
| 72 | def test_null_section_value_falls_back_to_the_default() -> None: | ||
| 73 | """A bare ``args:``/``extra:`` line parses to None and must mean "default".""" | ||
| 74 | assert build_section(LossConfig, {"name": "dice", "args": None}, "loss").args == {} | ||
| 75 | assert build_section(ModelConfig, {"extra": None}, "model").extra == {} | ||
| 76 | # a field that really accepts None keeps it | ||
| 77 | assert build_section( | ||
| 78 | ModelConfig, {"encoder_weights": None}, "model").encoder_weights is None | ||
| 79 | # required fields still fail on None | ||
| 80 | with pytest.raises(ConfigError, match="name"): | ||
| 81 | build_section(FactoryConfig, {"name": None}, "loss") | ||
| 82 | |||
| 83 | |||
| 84 | def test_null_means_default_only_applies_to_section_models() -> None: | ||
| 85 | class _Plain(config_loader.ConfigModel): | ||
| 86 | alpha: int = 1 | ||
| 87 | |||
| 88 | assert issubclass(LossConfig, SectionModel) | ||
| 89 | with pytest.raises(ConfigError): | ||
| 90 | build_section(_Plain, {"alpha": None}, "plain") | ||
| 91 | |||
| 92 | |||
| 93 | def test_precision_accepts_lightning_ints_and_strings() -> None: | ||
| 94 | assert build_section(TrainerConfig, {"precision": 16}, "train").precision == 16 | ||
| 95 | assert build_section( | ||
| 96 | TrainerConfig, {"precision": "bf16-mixed"}, "train").precision == "bf16-mixed" | ||
| 97 | |||
| 98 | |||
| 99 | @pytest.mark.parametrize("field", ["precision", "devices"]) | ||
| 100 | def test_yaml_yes_is_rejected_instead_of_becoming_one(field: str) -> None: | ||
| 101 | """PyYAML turns ``devices: yes`` into True; int|str would narrow it to 1.""" | ||
| 102 | with pytest.raises(ConfigError, match="got bool"): | ||
| 103 | build_section(TrainerConfig, {field: True}, "train") | ||
| 104 | |||
| 105 | |||
| 71 | def test_factory_config_is_name_plus_args() -> None: | 106 | def test_factory_config_is_name_plus_args() -> None: |
| 72 | cfg = build_section(FactoryConfig, {"name": "masked_focal_tversky", | 107 | cfg = build_section(FactoryConfig, {"name": "masked_focal_tversky", |
| 73 | "args": {"alpha": 0.8}}, "loss") | 108 | "args": {"alpha": 0.8}}, "loss") |
| 74 | assert cfg.name == "masked_focal_tversky" and cfg.args == {"alpha": 0.8} | 109 | assert cfg.name == "masked_focal_tversky" and cfg.args == {"alpha": 0.8} |
SectionModelnull-means-default, int precision preserved, bool guard on precision/devices.