Skip to content

Training API

Trainer(cfg)

Main class for training models in deepvisiontools. Everything configurable can be done through the TrainerConfig class.

Parameters:

Name Type Description Default
cfg deepvisiontools.train.trainconfig.TrainerConfig

Trainer configuration.

required
Usage
from deepvisiontools.config import current, set_current
from deepvisiontools.models import ModelFactory
from deepvisiontools.datasets import DeepVisionDataset, DeepVisionLoader
from deepvisiontools.train import Trainer, launch, TrainerConfig
from deepvisiontools.metrics.factories import (
    DetectionSuiteFactory,
    InstanceSegSuiteFactory,
    SemanticSegSuiteFactory,
)
from torch.optim import Adam
from torchvision.transforms.v2 import (
    RandomHorizontalFlip,
    RandomVerticalFlip,
    RandomGrayscale,
    ColorJitter,
)

# For a 1 class detection dataset, otherwise need to modify current() as explained in DVConfig class

# ── Datsets Paths ───────────────────────
TRAIN_DIR = "datasets_demo/instance_nc=1/train"  # e.g. /data/coco/train2017
VAL_DIR = "datasets_demo/instance_nc=1/valid"  # e.g. /data/coco/val2017

set_current(current().update_device("cuda"))

augs = [
    RandomVerticalFlip(),
    RandomVerticalFlip(),
    ColorJitter(hue=0.15, brightness=0.15, contrast=0.15, saturation=0.15),
    RandomGrayscale(0.05),
]

trainset = DeepVisionDataset(
    TRAIN_DIR,
    "coco",
    data_type_reader="instance_mask",
    augmentation=augs,
)
valset = DeepVisionDataset(
    VAL_DIR, "coco", data_type_reader="instance_mask"
)

trainloader = DeepVisionLoader(trainset, batch_size=4)
validloader = DeepVisionLoader(valset, batch_size=4)

# Best way to use deepvisiontools : build config from registered ones, then modify them if needed
Config = TrainerConfig.build_registered_config(
    model="detector-seg",
    config="detector_convnextv2-base_cascade_maskrcnn",
    train_loader=trainloader,
    valid_loader=validloader,
)
# exemple of modification but everything is configurable (optimizer, scheduler, models directly from ModelFactory etc.)
# Note : you can build TrainerConfig from scratch as well, but there are a bunch of parameters you may want to default to
Config.epochs = 120

# Start Training
def main():
    Trainer(Config).fit(resume=None)

launch(main, devices="auto")    # Can Use DDP wrapper if multiple gpu detected

# If you want to restart from a checkpoint :
Trainer.fit(resume="my/checkpoint/path") # in main
Source code in src/deepvisiontools/train/trainer.py
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
def __init__(self, cfg: TrainerConfig):
    cfg.validate()
    self.cfg = cfg

    # ----------------------------------
    # Device priority
    # ----------------------------------
    # Trainer cfg wins when explicitly set. If cfg.devices.device is None,
    # fall back to current().device at runtime.
    requested_device = _resolve_requested_device(
        getattr(cfg.devices, "device", None)
    )

    # ----------------------------------
    # Strategy priority
    # ----------------------------------
    # Explicit cfg.devices.strategy wins. "auto" means:
    #   - DDP only when launched under torchrun / launch(... nproc > 1)
    #   - otherwise single-device, even if multiple GPUs are visible
    strategy_name = _resolve_strategy_name(cfg.devices.strategy, requested_device)

    self.strategy: _Strategy
    if strategy_name == "ddp":
        self.strategy = DDPStrategy(
            requested_device=requested_device,
            find_unused_parameters=cfg.find_unused_parameters,
        )
    else:
        self.strategy = SingleDeviceStrategy()

    self.device = (
        self.strategy.resolve_device(requested_device)
        if hasattr(self.strategy, "resolve_device")
        else requested_device
    )

    # ----------------------------
    # Model (+ normalization policy BEFORE DDP wrap)
    # ----------------------------
    raw_model = cfg.build_model()
    raw_model, norm_summary = self._prepare_model_norm_policy(raw_model)

    self.model = self.strategy.setup(raw_model, self.device)
    self._norm_policy_summary: dict[str, Any] = dict(norm_summary)
    self._norm_policy_summary_emitted: bool = False

    m = cast(Any, _unwrap(self.model))
    try:
        if (
            self.cfg.checkpoint.model_builder is None
            or self.cfg.checkpoint.model_kwargs is None
        ) and hasattr(m, "export_spec"):
            spec = m.export_spec()
            if isinstance(spec, tuple) and len(spec) == 2:
                builder, model_kwargs = spec
                if self.cfg.checkpoint.model_builder is None:
                    self.cfg.checkpoint.model_builder = builder
                if self.cfg.checkpoint.model_kwargs is None:
                    self.cfg.checkpoint.model_kwargs = dict(model_kwargs)
    except Exception:
        pass

    # ----------------------------
    # Data
    # ----------------------------
    self.train_loader = self.strategy.wrap_loader(cfg.train_loader, shuffle=True)
    self.valid_loader = (
        self.strategy.wrap_loader(cfg.valid_loader, shuffle=False)
        if cfg.valid_loader is not None
        else None
    )
    self.train_loader = self._apply_loader_overrides(self.train_loader)
    self.valid_loader = self._apply_loader_overrides(self.valid_loader)

    # ----------------------------
    # Optimizer & Scheduler
    # ----------------------------
    param_groups = build_param_groups(_unwrap(self.model), cfg)
    self.optimizer = self._instantiate_optimizer(param_groups)
    self.scheduler = self._instantiate_scheduler()

    # ----------------------------
    # Precision
    # ----------------------------
    # Requested AMP follows the required priority:
    #   Trainer config explicit True/False wins.
    #   Trainer config None falls back to current().amp.
    #
    # Effective AMP is then hardened:
    #   - disabled when model caps.supports_amp is False
    #   - disabled when device does not support Trainer/DeepVisionModel autocast
    self.amp_requested = _resolve_requested_amp(cfg.precision.amp)
    self.amp_dtype = _amp_dtype(cfg.precision.amp_dtype, self.device)

    m = cast(Any, _unwrap(self.model))
    supports_amp = _model_supports_amp(m)
    device_supports_amp = _device_supports_trainer_amp(self.device)

    self.amp = bool(self.amp_requested and supports_amp and device_supports_amp)

    if hasattr(m, "use_amp"):
        m.use_amp = bool(self.amp)

    if hasattr(m, "autocast_dtype"):
        m.autocast_dtype = self.amp_dtype

    self.grad_clip = cfg.precision.grad_clip
    self.accumulate_steps = max(int(cfg.precision.accumulate_steps), 1)

    self.scaler = torch.amp.GradScaler(
        enabled=(
            self.amp
            and self.device.type == "cuda"
            and self.amp_dtype == torch.float16
        ),
        init_scale=2.0**16,
    )

    # ----------------------------
    # EMA
    # ----------------------------
    self.ema = None
    if cfg.ema.enabled:
        self.ema = ModelEMA(
            _unwrap(self.model),
            decay=cfg.ema.decay,
            warmup_steps=cfg.ema.warmup_steps,
        )

    # ----------------------------
    # State
    # ----------------------------
    self.global_step = 0
    self.epoch = 0
    self.epochs = int(cfg.epochs)
    self.should_stop = False
    self.metric_store: MutableMapping[str, Any] = {}

    # ----------------------------
    # Decide logging backends early
    # ----------------------------
    backends = self.cfg.logging.backends
    back_list = [backends] if isinstance(backends, str) else list(backends)

    use_mlflow = "mlflow" in back_list
    use_tensorboard = ("tensorboard" in back_list) or ("tb" in back_list)

    # ----------------------------
    # Resolve run / experiment naming
    # ----------------------------
    exp_name = self._resolve_effective_run_name()

    mlflow_args = dict(self.cfg.logging.mlflow or {})
    if getattr(self.cfg.logging, "mlflow_experiment_name", None):
        mlflow_args.setdefault(
            "experiment_name", str(self.cfg.logging.mlflow_experiment_name)
        )
    if getattr(self.cfg.logging, "mlflow_run_name", None):
        mlflow_args.setdefault("run_name", str(self.cfg.logging.mlflow_run_name))

    # ----------------------------
    # Logger(s) first (rank0 only)
    # ----------------------------
    if getattr(self.strategy, "is_rank0", True):
        self.logger = build_logger(
            backends=self.cfg.logging.backends,
            run_dir=Path("."),  # placeholder; actual run_dir decided below
            tb_args=self.cfg.logging.tensorboard,
            mlflow_args=mlflow_args,
            run_name=exp_name,
        )
    else:
        from .loggers import NullLogger

        self.logger = NullLogger()

    # ----------------------------
    # Run dir
    # ----------------------------
    run_dir = self._resolve_run_dir(
        exp_name=exp_name, use_mlflow=use_mlflow, use_tensorboard=use_tensorboard
    )
    run_dir.mkdir(parents=True, exist_ok=True)

    self.run_dir = run_dir
    self.checkpoint_manager = CheckpointManager(
        self.run_dir,
        enabled=bool(getattr(self.cfg.checkpoint, "enable_checkpointing", True)),
    )

    # ----------------------------
    # Export initial config snapshot as an artifact (rank0 only)
    # ----------------------------
    if getattr(self.strategy, "is_rank0", True):
        try:
            try:
                import yaml

                cfg_dict = self.cfg.export_dict(
                    portable=True, include_meta=True, use_initial=True
                )
                yaml_text = yaml.safe_dump(
                    cfg_dict,
                    sort_keys=False,
                    default_flow_style=False,
                    allow_unicode=True,
                )
            except Exception:
                cfg_dict = self.cfg.export_dict(
                    portable=True, include_meta=True, use_initial=True
                )
                yaml_text = str(cfg_dict)

            if hasattr(self.logger, "log_text_artifact"):
                self.logger.log_text_artifact(
                    yaml_text, artifact_file="config/initial_config.yaml"
                )
            else:
                cfg_path = self.run_dir / "initial_config.yaml"
                cfg_path.write_text(yaml_text, encoding="utf-8")
                with contextlib.suppress(Exception):
                    self.logger.log_artifact(cfg_path, artifact_path="config")
        except Exception:
            pass

    # ----------------------------
    # Log params snapshot
    # ----------------------------
    if getattr(self.strategy, "is_rank0", True):
        with contextlib.suppress(Exception):
            cfg_any = cast(Any, self.cfg)
            self.logger.log_params(
                {"config": cfg_any.to_dict(), "describe": cfg_any.describe()}
            )

    # ----------------------------
    # Hooks
    # ----------------------------
    self._hooks = self._build_default_hooks()
    self._metrics_monitor: Any = None

fit(resume=None)

Complete training program.

Parameters:

Name Type Description Default
resume Optional[Union[str, Path]], **optional**

Resume training from checkpoint. Defaults to None.

None
Source code in src/deepvisiontools/train/trainer.py
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
def fit(self, resume: str | Path | None = None) -> None:
    """Complete training program.

    Args:
        resume (Optional[Union[str, Path]], **optional**): Resume training from checkpoint. Defaults to None.
    """
    # Optionally resume
    if resume:
        self._resume_from(resume)

    # Re-apply any runtime normalization constraints before the first epoch
    self._reapply_norm_runtime_policy()

    # Rank-0 startup summary + artifact
    if getattr(self.strategy, "is_rank0", True):
        self._emit_norm_policy_summary()

    hooks: HookList = self._hooks
    hooks.before_train(self)
    try:
        for epoch in range(self.epoch, self.epochs):
            self.epoch = epoch
            self._metrics_monitor = MetricsMonitor(
                access=self.cfg.checkpoint.monitor
            )
            hooks.before_epoch(self, epoch)
            self._train_one_epoch()

            # Validation (EMA-aware)
            self.validate()
            hooks.after_epoch(self, epoch, self._metrics_monitor)

            if self.should_stop:
                break
    except BaseException as exc:
        hooks.on_exception(self, exc)  # allow hooks to react
        raise
    finally:
        hooks.after_train(self)
        self.strategy.cleanup()
        with contextlib.suppress(Exception):
            self.logger.close()

validate()

Epoch-end validation.

DDP policy: - only rank0 runs the full validation pass - other ranks wait at barrier

Safety: - preserves the model's previous train/eval mode - restores BN runtime freeze policy when returning to train mode - stays symmetric with validate_sync()

Source code in src/deepvisiontools/train/trainer.py
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
@torch.no_grad()
def validate(self) -> Mapping[str, float]:
    """
    Epoch-end validation.

    DDP policy:
    - only rank0 runs the full validation pass
    - other ranks wait at barrier

    Safety:
    - preserves the model's previous train/eval mode
    - restores BN runtime freeze policy when returning to train mode
    - stays symmetric with validate_sync()
    """
    wants_train_val = self._should_eval_train_val()
    has_valid = self.valid_loader is not None

    if not has_valid and not wants_train_val:
        self.strategy.barrier()
        return {}

    if not getattr(self.strategy, "is_rank0", True):
        self.strategy.barrier()
        return {}

    valid_loader = self.valid_loader
    train_val_loader = self._build_train_val_loader() if wants_train_val else None

    if valid_loader is None and train_val_loader is None:
        self.strategy.barrier()
        return {}

    ema_hook = cast(Any, self._get_hook(EMAHook))
    cm = ema_hook.swapped(self) if ema_hook is not None else _null_cm()

    was_training = bool(self.model.training)

    try:
        out_dict: dict[str, float] = {}

        with cm:
            self.model.eval()

            if valid_loader is not None:
                out_dict.update(
                    self._run_epoch_eval_loader(
                        loader=valid_loader,
                        split="valid",
                        desc=f"Valid epoch {self.epoch}",
                    )
                )

            if train_val_loader is not None:
                out_dict.update(
                    self._run_epoch_eval_loader(
                        loader=train_val_loader,
                        split="train-val",
                        desc=f"Train-val epoch {self.epoch}",
                    )
                )

        monitor = getattr(self, "_metrics_monitor", None)
        if monitor is not None and isinstance(
            getattr(monitor, "metrics", None), dict
        ):
            for k, v in monitor.metrics.items():
                if isinstance(v, (int, float)):
                    out_dict[k] = float(v)

        return out_dict

    finally:
        if was_training:
            self.model.train()
            self._reapply_norm_runtime_policy()
        self.strategy.barrier()

export_model(path, *, builder, model_kwargs, include_optimizer=False, include_scaler=False, include_ema=True)

Write a portable model artifact (weights + builder/kwargs + pred params).

Source code in src/deepvisiontools/train/trainer.py
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
def export_model(
    self,
    path: str | Path,
    *,
    builder: str,
    model_kwargs: dict[str, Any],
    include_optimizer: bool = False,
    include_scaler: bool = False,
    include_ema: bool = True,
) -> None:
    """Write a portable model artifact (weights + builder/kwargs + pred params)."""
    model = _unwrap(self.model)
    optimizer = self.optimizer if include_optimizer else None
    scaler = self.scaler if (include_scaler and self.scaler is not None) else None
    ema_state = (
        self.ema.state_dict() if (include_ema and self.ema is not None) else None
    )

    save_checkpoint(
        str(path),
        model,
        builder=builder,
        model_kwargs=dict(model_kwargs),  # copy for safety
        epoch=self.epoch,
        optimizer=optimizer,  # optional
        scaler=cast(Any, scaler),  # optional
        ema_state_dict=ema_state,  # optional
        extra={"global_step": self.global_step},
    )

validate_sync()

Synchronized cross-rank in-epoch validation.

Design: - all ranks participate - validation data is sharded across ranks - uses a temporary fresh valid meter from MetricsHook - final metrics are synchronized through suite.finalize_distributed() - rank0 updates the monitor + logger - restores previous train/eval mode afterwards

Source code in src/deepvisiontools/train/trainer.py
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
@torch.no_grad()
def validate_sync(self) -> Mapping[str, float]:
    """
    Synchronized cross-rank in-epoch validation.

    Design:
    - all ranks participate
    - validation data is sharded across ranks
    - uses a temporary fresh valid meter from MetricsHook
    - final metrics are synchronized through suite.finalize_distributed()
    - rank0 updates the monitor + logger
    - restores previous train/eval mode afterwards
    """
    if self.valid_loader is None:
        self.strategy.barrier()
        return {}

    world_size = int(getattr(self.strategy, "world_size", 1))
    if world_size <= 1:
        return self.validate()

    eval_loader = self._build_rank_sharded_valid_loader()
    if eval_loader is None:
        self.strategy.barrier()
        return {}

    rank0 = bool(getattr(self.strategy, "is_rank0", True))
    was_training = bool(self.model.training)

    ema_hook = cast(Any, self._get_hook(EMAHook))
    metrics_hook = cast(Any, self._get_hook(MetricsHook))
    cm = ema_hook.swapped(self) if ema_hook is not None else _null_cm()

    temp_valid_meter = None
    if metrics_hook is not None and hasattr(metrics_hook, "new_valid_meter"):
        try:
            temp_valid_meter = metrics_hook.new_valid_meter()
        except Exception:
            temp_valid_meter = None

    val_meter = LossAverager()

    try:
        with cm:
            self.model.eval()

            iterator = (
                tqdm(
                    enumerate(eval_loader),
                    total=len(eval_loader),
                    desc=f"Valid(sync) epoch {self.epoch}",
                )
                if rank0
                else enumerate(eval_loader)
            )

            for _, batch in iterator:
                with torch.autocast(
                    self.device.type, enabled=self.amp, dtype=self.amp_dtype
                ):
                    out: ModelOutput = self._call_eval_step(batch)
                    loss = out.globalloss()

                if loss is not None:
                    with contextlib.suppress(Exception):
                        val_meter.update(out.lossesdict())

                if temp_valid_meter is not None:
                    with contextlib.suppress(Exception):
                        temp_valid_meter.update(out.preds, batch[1])

        # ---- reduce losses across ranks ----
        local_count = torch.tensor(
            [int(val_meter.count)], dtype=torch.long, device=self.device
        )

        all_keys = sorted(val_meter.total.keys())
        local_sums = (
            torch.tensor(
                [float(val_meter.total[k]) for k in all_keys],
                dtype=torch.float64,
                device=self.device,
            )
            if all_keys
            else torch.zeros((0,), dtype=torch.float64, device=self.device)
        )

        if dist.is_initialized():
            dist.all_reduce(local_count, op=dist.ReduceOp.SUM)
            if local_sums.numel() > 0:
                dist.all_reduce(local_sums, op=dist.ReduceOp.SUM)

        out_dict: dict[str, float] = {}

        total_count = int(local_count.item())
        if total_count > 0 and all_keys:
            avg = {
                k: float(local_sums[i].item()) / float(total_count)
                for i, k in enumerate(all_keys)
            }

            if rank0 and getattr(self, "_metrics_monitor", None) is not None:
                self._metrics_monitor.losses.update({"valid/loss": avg})

            if "loss" in avg:
                out_dict["valid/loss"] = float(avg["loss"])

            for k, v in avg.items():
                out_dict[f"valid_sync/{k}"] = float(v)

        # ---- distributed metrics suite finalize ----
        metric_results: dict[str, float] = {}
        if temp_valid_meter is not None:
            try:
                if hasattr(temp_valid_meter, "finalize_distributed"):
                    metric_results = temp_valid_meter.finalize_distributed()
                else:
                    metric_results = temp_valid_meter.finalize()
            except Exception:
                metric_results = {}

        if (
            rank0
            and metric_results
            and getattr(self, "_metrics_monitor", None) is not None
        ):
            self._metrics_monitor.metrics.update(
                {f"valid/{k}": v for k, v in metric_results.items()}
            )
            out_dict.update(
                {f"valid/{k}": float(v) for k, v in metric_results.items()}
            )

        if rank0 and out_dict:
            with contextlib.suppress(Exception):
                self.logger.log_metrics(
                    out_dict,
                    step=int(self.global_step),
                    split="step",
                )
        return out_dict if rank0 else {}

    finally:
        if was_training:
            self.model.train()
            self._reapply_norm_runtime_policy()
        self.strategy.barrier()

launch(main_fn, *, devices='auto', nproc=None, main_args=None, main_kwargs=None)

Launch a training entrypoint, optionally through torch.distributed.run.

launch is a convenience wrapper around torchrun --standalone. If the current process is already inside a torchrun context (LOCAL_RANK is set), it directly calls main_fn(*main_args, **main_kwargs). Otherwise, it infers the number of processes from devices or nproc.

Parameters:

Name Type Description Default
main_fn `Callable[..., Any]`

User training function to execute.

required
devices `str | int | Sequence[int]`

Device/process selection. Accepted values include: - "auto": use torch.cuda.device_count() processes, or 1 if none. - "cpu" or "single": run one in-process training function. - int: process count, not a physical GPU id. - [0, 1, 3]: expose exactly those physical GPU ids to the child. - "0,1,3" or "cuda:0,1,3": same GPU-subset behavior. - "cuda:2": expose physical GPU 2 as a single visible GPU in a child process.

'auto'
nproc `int | None`

Optional explicit process count override. If set, it takes precedence over the process count inferred from devices.

None
main_args `Sequence[Any] | None`

Positional arguments forwarded to main_fn.

None
main_kwargs `Mapping[str, Any] | None`

Keyword arguments forwarded to main_fn.

None
DDP behavior
  • nproc == 1 and no GPU remapping: main_fn runs in-process.
  • nproc == 1 with GPU remapping, for example devices="cuda:2": a child process is spawned so PyTorch sees the intended CUDA_VISIBLE_DEVICES.
  • nproc > 1: the current Python script is re-executed with python -m torch.distributed.run --standalone --nproc_per_node=<nproc>.
Usage
from deepvisiontools.train import Trainer, launch

def main():
    Trainer(cfg).fit()

# Auto: one process per visible GPU, or CPU/single-process if no GPU.
launch(main, devices="auto")

# Force CPU / single process.
launch(main, devices="cpu")

# Use 2 processes. This is a process count, not GPU id 2.
launch(main, devices=2)

# Use exactly physical GPUs 0 and 1.
launch(main, devices=[0, 1])

# Same as above with string syntax.
launch(main, devices="cuda:0,1")

# Use only physical GPU 2.
launch(main, devices="cuda:2")

# Forward arguments to the main function.
def train_with_name(run_name, *, resume=None):
    cfg.logging.mlflow_run_name = run_name
    Trainer(cfg).fit(resume=resume)

launch(
    train_with_name,
    devices="auto",
    main_args=("experiment_001",),
    main_kwargs={"resume": None},
)
Source code in src/deepvisiontools/train/trainer.py
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
def launch(
    main_fn,
    *,
    devices: str | int | Sequence[int] = "auto",
    nproc: int | None = None,
    main_args: Sequence[Any] | None = None,
    main_kwargs: Mapping[str, Any] | None = None,
) -> None:
    """
    Launch a training entrypoint, optionally through `torch.distributed.run`.

    `launch` is a convenience wrapper around `torchrun --standalone`. If the
    current process is already inside a torchrun context (`LOCAL_RANK` is set),
    it directly calls `main_fn(*main_args, **main_kwargs)`. Otherwise, it infers
    the number of processes from `devices` or `nproc`.

    Args:
        main_fn (`Callable[..., Any]`): User training function to execute.
        devices (`str | int | Sequence[int]`): Device/process selection.
            Accepted values include:
            - `"auto"`: use `torch.cuda.device_count()` processes, or 1 if none.
            - `"cpu"` or `"single"`: run one in-process training function.
            - `int`: process count, not a physical GPU id.
            - `[0, 1, 3]`: expose exactly those physical GPU ids to the child.
            - `"0,1,3"` or `"cuda:0,1,3"`: same GPU-subset behavior.
            - `"cuda:2"`: expose physical GPU 2 as a single visible GPU in a
              child process.
        nproc (`int | None`): Optional explicit process count override. If set,
            it takes precedence over the process count inferred from `devices`.
        main_args (`Sequence[Any] | None`): Positional arguments forwarded to
            `main_fn`.
        main_kwargs (`Mapping[str, Any] | None`): Keyword arguments forwarded to
            `main_fn`.

    ???+ note "DDP behavior"
        - `nproc == 1` and no GPU remapping: `main_fn` runs in-process.
        - `nproc == 1` with GPU remapping, for example `devices="cuda:2"`:
          a child process is spawned so PyTorch sees the intended
          `CUDA_VISIBLE_DEVICES`.
        - `nproc > 1`: the current Python script is re-executed with
          `python -m torch.distributed.run --standalone --nproc_per_node=<nproc>`.

    ???+ example "Usage"
        ```python
        from deepvisiontools.train import Trainer, launch

        def main():
            Trainer(cfg).fit()

        # Auto: one process per visible GPU, or CPU/single-process if no GPU.
        launch(main, devices="auto")

        # Force CPU / single process.
        launch(main, devices="cpu")

        # Use 2 processes. This is a process count, not GPU id 2.
        launch(main, devices=2)

        # Use exactly physical GPUs 0 and 1.
        launch(main, devices=[0, 1])

        # Same as above with string syntax.
        launch(main, devices="cuda:0,1")

        # Use only physical GPU 2.
        launch(main, devices="cuda:2")

        # Forward arguments to the main function.
        def train_with_name(run_name, *, resume=None):
            cfg.logging.mlflow_run_name = run_name
            Trainer(cfg).fit(resume=resume)

        launch(
            train_with_name,
            devices="auto",
            main_args=("experiment_001",),
            main_kwargs={"resume": None},
        )
        ```
    """
    if os.environ.get("LOCAL_RANK") is not None:
        return main_fn(*(main_args or []), **(main_kwargs or {}))

    visible_subset: str | None = None

    def _parse_device_string(s: str) -> tuple[int, str | None]:
        raw = str(s).strip().lower()

        if raw in {"auto", "cuda", "gpu", "gpus"}:
            return max(1, get_visible_gpu_count()), None

        if raw in {"cpu", "single"}:
            return 1, None

        had_cuda_prefix = raw.startswith("cuda:")
        if had_cuda_prefix:
            raw = raw[len("cuda:") :]

        if "," in raw:
            gpu_ids = [tok.strip() for tok in raw.split(",") if tok.strip()]
            if gpu_ids and all(tok.isdigit() for tok in gpu_ids):
                return len(gpu_ids), ",".join(gpu_ids)

        if raw.isdigit():
            if had_cuda_prefix:
                # "cuda:2" means: expose physical GPU 2 as the only visible GPU.
                return 1, raw

            # Historical behavior:
            # "2" means process count, not physical GPU id 2.
            # Use "cuda:2" to select a physical single-GPU subset.
            return int(raw), None

        raise ValueError(
            "launch(devices=...) must be 'auto', 'cpu', an int, a sequence of GPU ids, "
            "'cuda:N', 'N,M', or 'cuda:N,M'. "
            f"Got {s!r}."
        )

    if nproc is None:
        if devices == "auto":
            nproc = max(1, get_visible_gpu_count())

        elif isinstance(devices, int):
            nproc = int(devices)

        elif isinstance(devices, (list, tuple)):
            gpu_ids = [str(int(d)) for d in devices]
            nproc = len(gpu_ids)
            if gpu_ids:
                visible_subset = ",".join(gpu_ids)

        elif isinstance(devices, str):
            nproc, visible_subset = _parse_device_string(devices)

        else:
            nproc = 1

    nproc = max(1, int(nproc))

    env = os.environ.copy()
    if visible_subset is not None:
        env["CUDA_VISIBLE_DEVICES"] = visible_subset

    if nproc == 1:
        # For single-GPU subset selection, run the user function in-process only
        # when no CUDA_VISIBLE_DEVICES remapping is needed. If remapping is needed,
        # re-exec a child process so torch sees the intended visible subset.
        if visible_subset is None:
            return main_fn(*(main_args or []), **(main_kwargs or {}))

        cmd = [sys.executable, sys.argv[0], *sys.argv[1:]]
        subprocess.run(cmd, check=True, env=env)
        return

    cmd = [
        sys.executable,
        "-m",
        "torch.distributed.run",
        "--standalone",
        f"--nproc_per_node={nproc}",
        sys.argv[0],
        *sys.argv[1:],
    ]

    subprocess.run(cmd, check=True, env=env)

TrainerConfig(model=None, train_loader=None, valid_loader=None, model_spec=None, epochs=100, output_dir=None, devices=DevicesConfig(), optim=OptimConfig(), sched=SchedConfig(), precision=PrecisionConfig(), ema=EMAConfig(), freezing=FreezingConfig(), normalization=NormalizationConfig(), checkpoint=CheckpointConfig(), logging=LoggingConfig(), eval=EvalConfig(), find_unused_parameters=False, optuna=None) dataclass

Main configuration object consumed by Trainer.

TrainerConfig gathers runtime objects such as loaders, portable model reconstruction information, training duration, device/DDP policy, optimization, scheduler/warmup, precision, EMA, freezing, normalization, checkpointing, logging, evaluation, and optional Optuna configuration.

Typical usage is to start from a registered preset, then override only what is specific to the experiment.

Parameters:

Name Type Description Default
model `Any | None`

Optional runtime model object. If provided, it is wrapped into ModelSpec when possible, then stored under model_spec. Prefer model_spec for portable YAML/export workflows.

None
train_loader `Any`

Training dataloader. A DeepVisionLoader is recommended.

None
valid_loader `Any`

Validation dataloader. A DeepVisionLoader is recommended. Can be None for train-only workflows.

None
model_spec `ModelSpec | None`

Portable model reconstruction information. It stores the model builder key and kwargs and can also hold the runtime model object.

None
epochs `int`

Number of training epochs. Defaults to 100.

100
output_dir `str | None`

Optional run output directory. If None, deepvisiontools uses its default output/logging setup.

None
devices `DevicesConfig`

Device, strategy, num_workers, and pin_memory policy.

deepvisiontools.train.trainconfig.DevicesConfig()
optim `OptimConfig`

Optimizer class/spec, optimizer kwargs, weight decay, and parameter-group strategy.

deepvisiontools.train.trainconfig.OptimConfig()
sched `SchedConfig`

Scheduler class/spec, scheduler kwargs, step policy, and optional warmup.

deepvisiontools.train.trainconfig.SchedConfig()
precision `PrecisionConfig`

AMP, AMP dtype, gradient clipping, and gradient accumulation.

deepvisiontools.train.trainconfig.PrecisionConfig()
ema `EMAConfig`

Exponential moving average configuration.

deepvisiontools.train.trainconfig.EMAConfig()
freezing `FreezingConfig`

Optional temporary backbone freezing.

deepvisiontools.train.trainconfig.FreezingConfig()
normalization `NormalizationConfig`

BatchNorm / SyncBatchNorm policy.

deepvisiontools.train.trainconfig.NormalizationConfig()
checkpoint `CheckpointConfig`

Training checkpoint and portable model artifact export configuration.

deepvisiontools.train.trainconfig.CheckpointConfig()
logging `LoggingConfig`

Logging backend configuration, including MLflow and TensorBoard settings.

deepvisiontools.train.trainconfig.LoggingConfig()
eval `EvalConfig`

Validation frequency, evaluated split(s), and metric suite configuration.

deepvisiontools.train.trainconfig.EvalConfig()
find_unused_parameters `bool`

DDP option forwarded to DistributedDataParallel. Useful for models with conditionally unused branches, but it can add overhead. Defaults to False.

False
optuna `Any | None`

Optional Optuna configuration. In normal training it can stay None. For HPO, set it to an OptunaConfig and call run_optuna(cfg).

None
Recommended usage from a registered preset
from deepvisiontools.train import TrainerConfig

cfg = TrainerConfig.build_registered_config(
    model="detector-seg",
    config="detector_convnextv2-base_cascade_maskrcnn",
    train_loader=trainloader,
    valid_loader=validloader,
)

cfg.epochs = 50
cfg.devices.device = "cuda"
cfg.devices.strategy = "auto"
cfg.devices.num_workers = 4
cfg.devices.pin_memory = True

cfg.precision.amp = True
cfg.precision.amp_dtype = "bf16"
cfg.precision.grad_clip = 5.0
cfg.precision.accumulate_steps = 2

cfg.ema.enabled = True
cfg.ema.decay = 0.995
cfg.ema.update_every = 1
cfg.ema.warmup_steps = 100
cfg.ema.eval_with_ema = True

cfg.checkpoint.enable_checkpointing = True
cfg.checkpoint.monitor = ("valid/loss", "loss")
cfg.checkpoint.mode = "min"
cfg.checkpoint.filename = "checkpoint"
cfg.checkpoint.save_every_n_epochs = 1
cfg.checkpoint.keep_last_n = 3
cfg.checkpoint.export_model_artifact = False
cfg.checkpoint.export_when = "best"

cfg.logging.backends = ["mlflow"]
cfg.logging.mlflow_experiment_name = "deepvisiontools_experiments"
cfg.logging.mlflow_run_name = "detector_seg_baseline"
cfg.logging.log_every_n_steps = 0

cfg.eval.eval_every_n_steps = 0
cfg.eval.eval_on = "valid"
Optimizer, scheduler, warmup and LLRD
import torch

from deepvisiontools.train import TrainerConfig
from deepvisiontools.train.trainconfig import WarmupConfig

cfg = TrainerConfig.build_registered_config(
    model="detector",
    config="detector_convnextv2-base_vfnet",
    train_loader=trainloader,
    valid_loader=validloader,
)

cfg.optim.optimizer = torch.optim.AdamW
cfg.optim.optim_args = {
    "lr": 2.5e-4,
    "betas": (0.9, 0.999),
}
cfg.optim.weight_decay = 0.05

# Available built-in modes are "flat", "llrd", and "layer_aware_llrd".
cfg.optim.param_groups = "layer_aware_llrd"
cfg.optim.layer_decay = 0.85

cfg.sched.scheduler = torch.optim.lr_scheduler.CosineAnnealingLR
cfg.sched.scheduler_args = {"T_max": "auto", "eta_min": 1e-6}
cfg.sched.step_on = "step"
cfg.sched.warmup = WarmupConfig(
    steps=500,
    steps_unit="step",
    ratio=1.0,
    start_factor=0.001,
)
Evaluation metrics
from deepvisiontools.train.trainconfig import EvalConfig, MetricsSpec

# Detection metrics.
cfg.eval = EvalConfig(
    eval_every_n_steps=0,
    eval_on="valid",
    metrics=MetricsSpec(
        kind="bbox",
        num_classes=3,
    ),
)

# Instance segmentation metrics.
cfg.eval.metrics = MetricsSpec(
    kind="instance_mask",
    num_classes=3,
)

# Semantic segmentation metrics.
cfg.eval.metrics = MetricsSpec(
    kind="semantic_mask",
    num_classes=3,
    kwargs={"keep_per_image": True},
)
Portable custom model with ModelSpec
# my_project/custom_models.py
#
# The custom builder must be imported before loading/building the config,
# so that it is registered in MODEL_REGISTRY.
#
# IMPORTANT:
# - the builder must return a DeepVisionModel;
# - all kwargs stored in ModelSpec must be YAML-safe;
# - avoid storing runtime objects such as instantiated torch modules,
#   datasets, losses or lambdas in model_spec.kwargs.

import torch

from deepvisiontools.models.base.basemodel import (
    MODEL_REGISTRY,
    DeepVisionModel,
)

@MODEL_REGISTRY.register("my_custom_smp")
def build_my_custom_smp(
    *,
    arch: str = "Unet",
    encoder_name: str = "resnet18",
    encoder_weights: str | None = "imagenet",
    in_channels: int = 3,
    num_classes: int = 1,
    device: str | torch.device | None = None,
    use_amp: bool | None = None,
) -> DeepVisionModel:
    from deepvisiontools.models import ModelFactory

    model = ModelFactory(
        name="smp",
        arch=arch,
        encoder_name=encoder_name,
        encoder_weights=encoder_weights,
        in_channels=in_channels,
        num_classes=num_classes,
        device=device,
        use_amp=use_amp,
    )

    # Ensure exported artifacts and TrainerConfig YAML can rebuild this
    # exact model through the registered custom builder.
    model.set_export_spec(
        "my_custom_smp",
        {
            "arch": arch,
            "encoder_name": encoder_name,
            # Usually set to None for artifact reload because weights
            # will be loaded from the saved state_dict.
            "encoder_weights": None,
            "in_channels": in_channels,
            "num_classes": num_classes,
            "device": device,
            "use_amp": use_amp,
        },
    )

    return model
# train.py

# Import the module once so @MODEL_REGISTRY.register(...) is executed.
import my_project.custom_models  # noqa: F401

import torch

from deepvisiontools.train import Trainer, TrainerConfig
from deepvisiontools.train.trainconfig import (
    ModelSpec,
    OptimConfig,
    SchedConfig,
)

cfg = TrainerConfig(
    train_loader=trainloader,
    valid_loader=validloader,
    model_spec=ModelSpec(
        builder="my_custom_smp",
        kwargs={
            "arch": "Unet",
            "encoder_name": "resnet18",
            "encoder_weights": "imagenet",
            "in_channels": 3,
            "num_classes": 1,
            "device": "cuda",
            "use_amp": True,
        },
    ),
    epochs=30,
)

cfg.optim = OptimConfig(
    optimizer=torch.optim.AdamW,
    optim_args={"lr": 1e-4},
    weight_decay=1e-4,
)

cfg.sched = SchedConfig(
    scheduler=None,
    scheduler_args={},
    step_on="epoch",
)

Trainer(cfg).fit()

The exported YAML will contain a portable model block similar to:

trainer:
  model_spec:
    builder: my_custom_smp
    kwargs:
      arch: Unet
      encoder_name: resnet18
      encoder_weights: imagenet
      in_channels: 3
      num_classes: 1
      device: cuda
      use_amp: true

To reload this YAML later, import the custom builder module before calling TrainerConfig.from_yaml(...):

import my_project.custom_models  # noqa: F401

from deepvisiontools.train import TrainerConfig

cfg = TrainerConfig.from_yaml(
    "my_training_config.yaml",
    train_loader=trainloader,
    valid_loader=validloader,
)
Normalization, freezing and DDP
# Device strategy:
# - "auto": single process unless launched with torchrun/launch(...).
# - "single": force single-device training.
# - "ddp": require distributed context.
cfg.devices.device = "cuda"
cfg.devices.strategy = "auto"

# Required by some DDP models with conditional branches.
cfg.find_unused_parameters = False

# BatchNorm policy.
cfg.normalization.policy = "auto"
cfg.normalization.freeze_affine = True
cfg.normalization.freeze_running_stats = True
cfg.normalization.verbose = True

# Temporarily freeze backbone optimizer param groups.
cfg.freezing.freeze_epochs = 2
cfg.freezing.backbone_group_name = "backbone"
Optuna HPO entrypoint
from deepvisiontools.train.optuna_wrapper import (
    OptunaConfig,
    SearchSpaceSpec,
    run_optuna,
)

cfg.optuna = OptunaConfig(
    enabled=True,
    study_name="my_study",
    storage="my_study.db",
    n_trials=20,
    search_space=[
        SearchSpaceSpec(
            name="lr",
            kind="float",
            path="optim.optim_args.lr",
            low=1e-5,
            high=1e-3,
            log=True,
        ),
        SearchSpaceSpec(
            name="accumulate_steps",
            kind="categorical",
            path="precision.accumulate_steps",
            choices=[1, 2, 4],
        ),
    ],
)

study = run_optuna(cfg)

ensure_model_spec_from_model()

If a DeepVisionModel exists in model_spec and has export_spec(), populate model_spec/checkpoint hints.

Source code in src/deepvisiontools/train/trainconfig.py
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
def ensure_model_spec_from_model(self) -> None:
    """If a DeepVisionModel exists in model_spec and has export_spec(), populate model_spec/checkpoint hints."""
    # In current design, runtime model lives under model_spec._model
    if self.model_spec is None:
        return

    m = getattr(self.model_spec, "model_obj", None)
    if m is None:
        m = getattr(self.model_spec, "_model", None)

    if m is None or not isinstance(m, DeepVisionModel):
        return

    try:
        spec = m.export_spec()
    except Exception:
        spec = None

    if not (isinstance(spec, tuple) and len(spec) == 2):
        return

    builder, kwargs = spec
    builder = str(builder)
    kwargs = dict(kwargs)

    # Fill model_spec portable fieldswandb_project': None, 'wandb_run_name': N
    if not self.model_spec.builder:
        self.model_spec.builder = builder
    if not self.model_spec.kwargs:
        self.model_spec.kwargs = dict(kwargs)

    # Mirror into checkpoint hints
    if self.checkpoint.model_builder is None:
        self.checkpoint.model_builder = builder
    if self.checkpoint.model_kwargs is None:
        self.checkpoint.model_kwargs = dict(kwargs)

freeze_initial_snapshot()

Freeze a stable snapshot for reproducible export.

Source code in src/deepvisiontools/train/trainconfig.py
1632
1633
1634
1635
1636
1637
1638
1639
1640
def freeze_initial_snapshot(self) -> None:
    """Freeze a stable snapshot for reproducible export."""
    # Make sure we capture model_spec if possible before freezing
    self.ensure_model_spec_from_model()
    self._initial_snapshot = {
        "portable": self._export_dict(portable=True, include_meta=False),
        "record": self._export_dict(portable=False, include_meta=True),
    }
    self._initial_frozen = True

from_yaml(path, *, train_loader, valid_loader=None, model=None) classmethod

Load portable YAML and rebuild a TrainerConfig.

You MUST pass loaders. You MAY pass model to override; otherwise if model_spec exists in YAML we rebuild from it.

Source code in src/deepvisiontools/train/trainconfig.py
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
@classmethod
def from_yaml(
    cls,
    path: str | Path,
    *,
    train_loader: Any,
    valid_loader: Any = None,
    model: Any = None,
) -> TrainerConfig:
    """Load portable YAML and rebuild a TrainerConfig.

    You MUST pass loaders. You MAY pass model to override; otherwise if model_spec
    exists in YAML we rebuild from it.
    """
    if yaml is None:  # pragma: no cover
        raise RuntimeError("PyYAML is not available; cannot import YAML.")
    raw_text = _read_yaml_source_text(path)
    data = yaml.safe_load(raw_text) or {}
    trainer = data.get("trainer", data)

    # model spec (optional)
    model_spec = None
    ms = trainer.get("model_spec", None)
    if isinstance(ms, dict) and "builder" in ms:
        model_spec = ModelSpec(
            builder=str(ms["builder"]), kwargs=dict(ms.get("kwargs") or {})
        )

    def _dc(dc_cls: Any, payload: Any) -> Any:
        return dc_cls(**payload) if isinstance(payload, dict) else dc_cls()

    # optional optuna config (lazy import to avoid circular import)
    optuna_cfg = None
    opt_payload = trainer.get("optuna", None)
    if isinstance(opt_payload, dict):
        try:
            from .optuna_wrapper import OptunaConfig

            optuna_cfg = OptunaConfig.from_dict(opt_payload)
        except Exception as e:
            raise ValueError(f"Failed to parse trainer.optuna: {e}") from e

    cfg = cls(
        model=model,
        model_spec=model_spec,
        train_loader=train_loader,
        valid_loader=valid_loader,
        epochs=int(trainer.get("epochs", 100)),
        output_dir=trainer.get("output_dir"),
        devices=_dc(DevicesConfig, trainer.get("devices")),
        precision=_dc(PrecisionConfig, trainer.get("precision")),
        ema=_dc(EMAConfig, trainer.get("ema")),
        freezing=_dc(FreezingConfig, trainer.get("freezing")),
        normalization=_dc(NormalizationConfig, trainer.get("normalization")),
        checkpoint=_dc(CheckpointConfig, trainer.get("checkpoint")),
        logging=_dc(LoggingConfig, trainer.get("logging")),
        find_unused_parameters=bool(trainer.get("find_unused_parameters", False)),
        optuna=optuna_cfg,
    )

    # optim
    opt_payload = trainer.get("optim") or {}
    opt_spec = opt_payload.get("optimizer")
    if not isinstance(opt_spec, dict) or "target" not in opt_spec:
        raise ValueError("optim.optimizer must be present with {target, kwargs}.")
    cfg.optim.optimizer = ImportSpec(
        target=str(opt_spec["target"]), kwargs=dict(opt_spec.get("kwargs") or {})
    )
    cfg.optim.optim_args = dict(
        opt_payload.get("optim_args") or cfg.optim.optimizer.kwargs
    )
    cfg.optim.param_groups = opt_payload.get("param_groups", "flat")
    cfg.optim.weight_decay = opt_payload.get("weight_decay")
    cfg.optim.layer_decay = opt_payload.get("layer_decay")

    # sched
    sch_payload = trainer.get("sched") or {}
    sch_spec = sch_payload.get("scheduler")
    if sch_spec is None:
        cfg.sched.scheduler = None
    else:
        if not isinstance(sch_spec, dict) or "target" not in sch_spec:
            raise ValueError("sched.scheduler must be null or {target, kwargs}.")
        cfg.sched.scheduler = ImportSpec(
            target=str(sch_spec["target"]),
            kwargs=dict(sch_spec.get("kwargs") or {}),
        )
    cfg.sched.scheduler_args = dict(
        sch_payload.get("scheduler_args")
        or (cfg.sched.scheduler.kwargs if cfg.sched.scheduler else {})
    )
    cfg.sched.step_on = sch_payload.get("step_on", "epoch")
    wu = sch_payload.get("warmup")
    cfg.sched.warmup = WarmupConfig(**wu) if isinstance(wu, dict) else None

    # eval
    ev_payload = trainer.get("eval") or {}
    metrics_spec = None
    m = ev_payload.get("metrics")
    if m is not None:
        if not isinstance(m, dict):
            raise ValueError("eval.metrics must be null or a dict.")
        metrics_spec = MetricsSpec(
            kind=m.get("kind"),
            num_classes=m.get("num_classes"),
            target=m.get("target"),
            kwargs=dict(m.get("kwargs") or {}),
        )

    cfg.eval = EvalConfig(
        eval_every_n_steps=int(ev_payload.get("eval_every_n_steps", 0)),
        eval_on=ev_payload.get("eval_on", "valid"),
        metrics=metrics_spec,
    )

    # Build model if not provided but spec exists
    if cfg.model is None and cfg.model_spec is not None:
        cfg.build_model()

    return cfg

available_configs() classmethod

List all registered default TrainerConfig presets.

Layout matches ModelFactory configs

dtype: model: - "name": description

Source code in src/deepvisiontools/train/trainconfig.py
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
@classmethod
def available_configs(cls) -> AvailableConfigs:
    """List all registered default TrainerConfig presets.

    Layout matches ModelFactory configs:
      dtype:
        model:
          - "name": description
    """
    _ensure_trainer_config_presets_loaded()

    tree: dict[str, dict[str, list[ConfigEntry]]] = {}
    for (dtype, model, name), preset in _TRAINER_CONFIG_PRESETS.items():
        tree.setdefault(dtype, {})
        tree[dtype].setdefault(model, [])
        tree[dtype][model].append(
            ConfigEntry(name=str(name), description=str(preset.description or ""))
        )
    return AvailableConfigs(tree)

build_registered_config(*, model, config, train_loader, valid_loader=None, dtype=None, model_override=None) classmethod

Build a TrainerConfig from a registered preset.

???+ example "Usage":

# example usage
Config = TrainerConfig.build_registered_config(
        model="detector-seg",
        config="detector_convnextv2-base_cascade_maskrcnn",
        train_loader=trainloader,
        valid_loader=validloader,
    )

Source code in src/deepvisiontools/train/trainconfig.py
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
@classmethod
def build_registered_config(
    cls,
    *,
    model: str,
    config: str,
    train_loader: Any,
    valid_loader: Any = None,
    dtype: str | None = None,
    model_override: Any = None,
) -> TrainerConfig:
    """Build a TrainerConfig from a registered preset.

    ???+ example "Usage":
        ```python
        # example usage
        Config = TrainerConfig.build_registered_config(
                model="detector-seg",
                config="detector_convnextv2-base_cascade_maskrcnn",
                train_loader=trainloader,
                valid_loader=validloader,
            )
        ```
    """
    _ensure_trainer_config_presets_loaded()

    model = str(model)
    config = str(config)

    if dtype is not None:
        dtype = str(dtype)
        preset = _TRAINER_CONFIG_PRESETS.get((dtype, model, config))
        if preset is None:
            raise KeyError(
                f"Unknown TrainerConfig preset: dtype={dtype} model={model} config={config}"
            )
    else:
        hits = [
            p
            for (dt, m, n), p in _TRAINER_CONFIG_PRESETS.items()
            if (m == model and n == config)
        ]
        if len(hits) == 0:
            raise KeyError(
                f"Unknown TrainerConfig preset: model={model} config={config}"
            )
        if len(hits) > 1:
            choices = ", ".join(sorted({p.dtype for p in hits}))
            raise KeyError(
                f"Ambiguous TrainerConfig preset for model={model} config={config}. "
                f"Please specify dtype (choices: {choices})."
            )
        preset = hits[0]

    yaml_text = _read_yaml_source_text(preset.source)
    return cls.from_yaml(
        yaml_text,
        train_loader=train_loader,
        valid_loader=valid_loader,
        model=model_override,
    )

ModelSpec(builder=None, kwargs=None, model=None) dataclass

Portable model reconstruction information + optional cached runtime model.

Note
  • builder/kwargs are the portable representation (what goes to YAML)
  • model is a runtime-only object (never exported in portable=True)
  • model is a property so that setting it auto-refreshes builder/kwargs from DeepVisionModel.export_spec() when possible.
  • If model is None at init: build from builder/kwargs (must be set).
  • If model is provided at init and it's DeepVisionModel: override builder/kwargs from model.export_spec() when available.
Source code in src/deepvisiontools/train/trainconfig.py
306
307
308
309
310
311
312
313
314
315
316
317
318
319
def __init__(
    self,
    builder: str | None = None,
    kwargs: dict[str, Any] | None = None,
    model: Any | None = None,
) -> None:
    self.builder = str(builder) if builder is not None else None
    self.kwargs = dict(kwargs or {})
    self._model = None

    if model is not None:
        self.model = model
    elif self.builder:
        self._ensure_built_from_spec()

model_obj property

Internal getter with a clear name.

ImportSpec(target, kwargs=dict()) dataclass

Main class to make config exportable/readable from yaml.

DevicesConfig(device=None, strategy='auto', num_workers=None, pin_memory=None) dataclass

Device / dataloader runtime hints.

Note
- device=None + strategy="auto": choose "cuda" if available, else "cpu" with warning.
- strategy="auto" matches trainer logic (DDP if >1 GPU).

OptimConfig(optimizer=None, optim_args=dict(), param_groups='flat', weight_decay=None, layer_decay=None) dataclass

Optimizer configuration. This configuration allows to generate torch.optim objects from yaml and/or export them to yaml. Handles LLRD strategies.

???+ note "Note": Allowed values for optimizer: - ImportSpec (portable) - torch optimizer class/type (portable if importable, e.g. torch.optim.AdamW) Disallowed: - optimizer instances (runtime-only, NOT portable) - lambdas / closures / non-importable callables (NOT portable) LLRD : - learning rate layer decay is an excellent optimization strategy when dealing with large backbones. It assigns decreasing learning rate to all blocks in the model.

Parameters:

Name Type Description Default
optimizer `Optional[Any]`

use optim type (python obj like torch.optim.Adamw) or ImportSpec.

None
optim_args `Dict[str, Any]`

all kwargs style dict you want to feed the optimizer with.

dict()
param_groups `str | Callable`

"flat" or "llrd" or user provided callable that build param groups from model. llrd will call deepvisiontools internal llrd strategy to compute param groups.

'flat'
weight_decay `Optional[float]`

optional weight decay (regularization) for optimizer

None
layer_decay Optional[float]

parameter used in llrd strategy for internal llrd strategy.

None

get_optimizer_spec()

Return an ImportSpec (portable), coercing from a class/type when needed. Raises if optimizer is missing or not portable.

Source code in src/deepvisiontools/train/trainconfig.py
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
def get_optimizer_spec(self) -> ImportSpec:
    """
    Return an ImportSpec (portable), coercing from a class/type when needed.
    Raises if optimizer is missing or not portable.
    """
    if self.optimizer is None:
        raise ValueError(
            "OptimConfig.optimizer must be set (ImportSpec or optimizer class)."
        )

    # Case 1: already portable
    if isinstance(self.optimizer, ImportSpec):
        return self.optimizer

    # Reject instances explicitly (not portable)
    try:
        import torch

        if isinstance(self.optimizer, torch.optim.Optimizer):
            raise TypeError(
                "OptimConfig.optimizer must be an optimizer CLASS (e.g. torch.optim.AdamW) "
                "or an ImportSpec. Optimizer INSTANCES are not portable and are rejected."
            )
    except Exception:
        # If torch isn't available, we still enforce class/type below.
        pass

    # Case 2: optimizer class/type (preferred ergonomic API)
    # We require an actual type to avoid lambdas/closures that can't be imported.
    if isinstance(self.optimizer, type):
        target = self._callable_to_target(self.optimizer)
        if target is None:
            raise TypeError(
                "OptimConfig.optimizer is a type but not importable (missing __module__/__qualname__). "
                "Use ImportSpec(target=..., kwargs=...)."
            )

        # Coerce and store back so export/describe become stable
        spec = ImportSpec(target=target, kwargs={})
        self.optimizer = spec
        return spec

    # Anything else is rejected (non-portable)
    raise TypeError(
        "OptimConfig.optimizer must be either an ImportSpec or an optimizer CLASS (type). "
        f"Got: {type(self.optimizer)}"
    )

SchedConfig(scheduler=None, scheduler_args=dict(), step_on='epoch', warmup=None) dataclass

Optim scheduler configuration

Parameters:

Name Type Description Default
scheduler `Optional[ImportSpec]`

siùmilar to optimizer, needs to be an importable object. Defaults to None (lr won't change).

None
scheduler_args `Optional[Dict[str, Any]]`

args used in scheduler. Defaults to None.

dict()
step_on `Literal["epoch", "step", "batch"]`

when lr is updated by scheduler. Defaults to epoch

'epoch'
warmup `Optional[WarmupConfig]`

if warmup is required. Defaults to None.

None

get_scheduler_spec()

Return an ImportSpec (portable), coercing from a class/type when needed.

Raises if the scheduler is not portable or was assigned to the wrong field.

Source code in src/deepvisiontools/train/trainconfig.py
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
def get_scheduler_spec(self) -> ImportSpec | None:
    """
    Return an ImportSpec (portable), coercing from a class/type when needed.

    Raises if the scheduler is not portable or was assigned to the wrong field.
    """
    if self.scheduler is None:
        return None

    # Case 1: already portable
    if isinstance(self.scheduler, ImportSpec):
        return self.scheduler

    # Reject runtime instances explicitly
    if self._is_scheduler_instance(self.scheduler):
        raise TypeError(
            "SchedConfig.scheduler must be a scheduler CLASS "
            "(e.g. torch.optim.lr_scheduler.ExponentialLR) or an ImportSpec. "
            "Scheduler INSTANCES are runtime-only and are rejected."
        )

    # Case 2: scheduler class/type (preferred ergonomic API)
    if isinstance(self.scheduler, type):
        target = self._callable_to_target(self.scheduler)
        if target is None:
            raise TypeError(
                "SchedConfig.scheduler is a type but not importable "
                "(missing __module__/__qualname__). "
                "Use ImportSpec(target=..., kwargs=...)."
            )

        # Coerce and store back so export/describe become stable
        spec = ImportSpec(target=target, kwargs={})
        self.scheduler = spec
        return spec

    # Common user mistake: args dict assigned to `scheduler` instead of `scheduler_args`
    if isinstance(self.scheduler, dict):
        raise TypeError(
            "SchedConfig.scheduler received a dict. "
            "The scheduler itself must be an ImportSpec or scheduler CLASS; "
            "its kwargs belong in `sched.scheduler_args`."
        )

    # Anything else is rejected
    raise TypeError(
        "SchedConfig.scheduler must be either None, an ImportSpec, or a scheduler CLASS (type). "
        f"Got: {type(self.scheduler)}"
    )

build_scheduler(optimizer)

Validate/coerce the scheduler and instantiate it.

Calling get_scheduler_spec() first ensures: - class/type inputs are converted to ImportSpec for stable export - invalid assignments fail early with a helpful message

Source code in src/deepvisiontools/train/trainconfig.py
898
899
900
901
902
903
904
905
906
907
908
909
910
911
def build_scheduler(self, optimizer: Any) -> Any:
    """
    Validate/coerce the scheduler and instantiate it.

    Calling `get_scheduler_spec()` first ensures:
      - class/type inputs are converted to ImportSpec for stable export
      - invalid assignments fail early with a helpful message
    """
    spec = self.get_scheduler_spec()
    if spec is None:
        return None

    # Use the now-coerced portable representation for instantiation
    return self(optimizer)

WarmupConfig(kind='linear', steps=None, epochs=None, steps_unit='step', ratio=1.0, start_factor=0.001) dataclass

Warmup configuration.

Parameters:

Name Type Description Default
kind `Literal["linear", "constant"]`

Default to "linear".

'linear'
steps `Optional[int]`

Default to None

None
epochs `Optional[float]`

Default to None

None
steps_unit `Literal["step", "epoch"]`

Default to"step"

'step'
ratio `float`

linear warmup starts from start_factor * (ratio * base_lr). Defaults to 1.0

1.0
start_factor `float`

Defaults to 0.001

0.001

???+ note Note: Supports 2 ways to specify duration: - steps: number of optimizer steps (batch-based warmup) - epochs: number of epochs (converted to steps by Trainer using len(train_loader)) A "switch" is provided via steps_unit: - steps_unit="step" -> interpret steps as optimizer steps (default) - steps_unit="epoch" -> interpret steps as epochs (converted into epochs) so WarmupConfig(steps=1, steps_unit="epoch") == WarmupConfig(epochs=1) - Trainer will compute warmup_steps as: - if steps is not None: warmup_steps = steps - elif epochs is not None: warmup_steps = epochs * len(train_loader) - So do NOT set both steps and epochs (we normalize to one).

PrecisionConfig(amp=None, amp_dtype='bf16', grad_clip=None, accumulate_steps=1) dataclass

Torch AMP and gradient precision configuration.

Automatic mixed precision is strongly recommended when supported: it is usually faster, uses less CUDA memory, and often has no accuracy trade-off. This config also controls gradient clipping and gradient accumulation.

Parameters:

Name Type Description Default
amp `bool | None`

Enable or disable AMP. If None, the Trainer falls back to the global current() config. Defaults to None.

None
amp_dtype `Literal["fp16", "bf16", "fp32"]`

Precision dtype used by AMP. Defaults to "bf16".

'bf16'
grad_clip `float | None`

Optional gradient clipping value. Typical values are in the 1-10 range for very large models. Defaults to None.

None
accumulate_steps `int`

Number of batches to accumulate before optimizer update, scheduler step, and EMA update. Useful to emulate a larger effective batch size when limited by hardware memory. Defaults to 1.

1

EMAConfig(enabled=True, decay=0.995, update_every=1, warmup_steps=0, eval_with_ema=True) dataclass

Exponential moving average configuration.

EMA keeps a smoothed copy of model weights during training. After each optimizer step, the EMA state is updated from the current model weights. EMA weights are often more stable for validation and deployment, especially for noisy training setups such as detection and instance segmentation.

Parameters:

Name Type Description Default
enabled `bool`

Whether to enable EMA tracking. Defaults to True.

True
decay `float`

EMA decay factor. Typical values range from 0.99 for short trainings to 0.9998 for long trainings. Defaults to 0.995.

0.995
update_every `int`

Update EMA every N optimizer steps. Defaults to 1.

1
warmup_steps `int`

Number of optimizer steps used to ramp EMA decay at the beginning of training. Defaults to 0.

0
eval_with_ema `bool`

Whether to temporarily swap EMA weights during validation/evaluation. Defaults to True.

True

FreezingConfig(freeze_epochs=0, backbone_group_name='backbone', freeze_backbone=False, freeze_bn=False) dataclass

If you need to freeze backbone at begining of training

NormalizationConfig(policy='auto', freeze_affine=True, freeze_running_stats=True, verbose=True) dataclass

BatchNorm / SyncBatchNorm policy. Used in DDP and small batches to freeze batch norm layers / sync BN.

Policies

  • "auto": Safe default.

    • if no BN is present -> do nothing
    • if BN is present and local micro-batch <= 1 -> freeze BN
    • otherwise -> do nothing This intentionally does NOT auto-convert to SyncBN.
  • "none": Never touch normalization layers.

  • "freeze_bn": Freeze BatchNorm / SyncBatchNorm affine params (optional) and keep running stats frozen during train epochs.

  • "sync_bn": Convert BatchNorm{1,2,3}d to SyncBatchNorm, but only when DDP is active on CUDA. Otherwise the request is ignored with a logged summary.

  • "sync_or_freeze_bn": If DDP+CUDA is active -> convert to SyncBN. Otherwise -> freeze BN.

Notes

  • LayerNorm / GroupNorm / InstanceNorm are never modified by this policy.
  • "freeze BN" is implemented by:
    • setting affine params requires_grad=False (if freeze_affine=True)
    • forcing BN modules to eval() whenever the trainer enters train mode (if freeze_running_stats=True)

CheckpointConfig(output_dir=None, enable_checkpointing=True, monitor=('valid/loss', 'loss'), mode='min', filename='checkpoint', save_every_n_epochs=1, keep_last_n=3, export_model_artifact=False, export_when='best', model_builder=None, model_kwargs=None) dataclass

Handles checkpointing and optional model artifact export.

Checkpointing is used to save restartable training states and to keep track of the best model according to a monitored metric.

Parameters:

Name Type Description Default
output_dir `str | None`

Optional checkpoint output directory. If None, the Trainer uses the default deepvisiontools output setup.

None
enable_checkpointing `bool`

Master switch for checkpointing and model artifact export. If False, training and logging still run normally, but checkpoint/model export phases are skipped.

True
monitor `tuple[str, str]`

Metric to monitor as (metric_complete_name, metric_type). Defaults to ("valid/loss", "loss").

('valid/loss', 'loss')
mode `Literal["min", "max"]`

Direction used to decide whether a model is better. Use "min" for losses and "max" for metrics to maximize.

'min'
filename `str`

Prefix used for checkpoint filenames. Defaults to "checkpoint".

'checkpoint'
save_every_n_epochs `int`

Periodic checkpoint interval in epochs. If 0, no periodic checkpoint is written. Defaults to 1.

1
keep_last_n `int`

Number of most recent periodic checkpoints to keep. Older periodic checkpoints are removed. Defaults to 3.

3
export_model_artifact `bool`

Whether to export a portable model artifact in addition to training checkpoints. Defaults to False.

False
export_when `Literal["best", "all"]`

Controls when model artifacts are exported: only for the best model or at every export opportunity.

'best'
model_builder `str | None`

Optional model builder key saved as a restore hint. Usually inferred automatically from model_spec.

None
model_kwargs `dict[str, Any] | None`

Optional model builder kwargs saved as restore hints. Usually inferred automatically from model_spec.

None
Note
  • enable_checkpointing=False is the master switch. When disabled, the training loop still runs normally and MLflow/TensorBoard logging still works, but checkpoint/model export phases are skipped.
  • This is especially useful for large Optuna studies where you only want metrics/losses/logging without writing restart artifacts.

LoggingConfig(backends='mlflow', tensorboard=dict(), mlflow=dict(), log_every_n_steps=0, mlflow_experiment_name=None, mlflow_run_name=None, wandb_project=None, wandb_run_name=None) dataclass

Monitoring configuration.

MLflow can already consume arbitrary keys from mlflow, but exposing the most common ones explicitly makes the Trainer config much cleaner and safer.

Parameters:

Name Type Description Default
backends str | collections.abc.Sequence[str]

Logging backend(s), e.g. "mlflow", "tensorboard", or both.

'mlflow'
tensorboard dict[str, typing.Any]

Extra kwargs forwarded to TensorBoard logger.

dict()
mlflow dict[str, typing.Any]

Extra kwargs forwarded to MLflowLogger.

dict()
log_every_n_steps int

Logging period hint used by hooks.

0
mlflow_experiment_name str | None

Optional explicit MLflow experiment name.

None
mlflow_run_name str | None

Optional explicit MLflow run name.

None
wandb_project str | None

Reserved/legacy field.

None
wandb_run_name str | None

Reserved/legacy field.

None

EvalConfig(eval_every_n_steps=0, eval_on='valid', metrics=None) dataclass

Evaluation configuration.

Parameters:

Name Type Description Default
eval_every_n_steps `int`

Optional step-based evaluation period. If 0, evaluation is performed once per epoch. Defaults to 0.

0
eval_on `Literal["valid", "train", "valid_and_train"]`

Dataset split(s) used for evaluation. Evaluating on train during training is possible but slower. If "valid_and_train" passed, internally clone trainloader, switch off augmentations, and evaluate on it. Defaults to "valid".

'valid'
metrics `MetricsSpec | None`

Portable metric suite configuration. If None, no metrics factory is built. Defaults to None.

None

MetricsSpec(kind=None, num_classes=None, target=None, kwargs=dict()) dataclass

Main metric setup config class used in EvalConfig. Two modes: 1) kind-based: kind in {"bbox","instance_mask","semantic_mask"} + num_classes 2) target-based: import target "module:qualname" + kwargs (if having special callables). Note that this second mode had not been widely tested yet.

Parameters:

Name Type Description Default
kind `Optional[str]`

str val from "detection", "semantic", "instance". Setup standard metric suites in deepvisiontools

None
num_classes `Optional[int]`

if you want to override, but dvt computes class from current() when needed. Defaults to None

None
target Optional[str]

for specifi metrics callables.

None
kwargs Optional[Dict[str, typing.Any]]

args for your specific metric

dict()

build_factory()

Return a zero-arg factory that builds the metric suite object.

Rules: - If target is set: import + kwargs - Else: kind-based factory, with num_classes taken from: - self.num_classes if provided - else current().data_* defaults depending on kind

Source code in src/deepvisiontools/train/trainconfig.py
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
def build_factory(self) -> Callable[[], Any]:
    """Return a zero-arg factory that builds the metric suite object.

    Rules:
    - If target is set: import + kwargs
    - Else: kind-based factory, with num_classes taken from:
        - self.num_classes if provided
        - else current().data_* defaults depending on kind
    """
    # ---- explicit import target wins ----
    if self.target:
        obj = _import_from_target(self.target)

        def _f():
            return obj(**dict(self.kwargs))

        return _f

    # ---- kind-based default mapping ----
    kind = (self.kind or "").strip().lower()
    if not kind:

        def _none():
            raise RuntimeError(
                "MetricsSpec is empty (no target and no kind). "
                "Set eval.metrics to null, or provide target or kind."
            )

        return _none

    # Resolve num_classes:
    # - prefer explicit num_classes
    # - else fallback to global config defaults
    nc_val = self.num_classes
    if nc_val is None:
        cfg = (
            current()
        )  # from ..config import current (already imported in trainconfig.py)
        if kind in {"bbox", "detection", "det"}:
            nc_val = int(cfg.data_box.num_classes)
        elif kind in {"instance_mask", "instance", "inst", "mask"}:
            nc_val = int(cfg.data_instance_mask.num_classes)
        elif kind in {"semantic_mask", "semantic", "semseg"}:
            nc_val = int(cfg.data_semantic_mask.num_classes)
        else:
            raise ValueError(
                f"Unknown MetricsSpec.kind={kind!r}. Expected one of: "
                f"'bbox', 'instance_mask', 'semantic_mask' (or aliases). "
                f"Alternatively provide MetricsSpec(target=..., kwargs=...)."
            )

    nc = int(nc_val)
    if nc <= 0:
        raise ValueError(f"num_classes must be > 0, got {nc}")

    # Local import to keep config module lightweight
    from ..metrics.factories import (
        DetectionSuiteFactory,
        InstanceSegSuiteFactory,
        SemanticSegSuiteFactory,
    )

    # Accept a few aliases
    if kind in {"bbox", "detection", "det"}:

        def _f():
            return DetectionSuiteFactory(nc)()

        return _f

    if kind in {"instance_mask", "instance", "inst", "mask"}:

        def _f():
            return InstanceSegSuiteFactory(nc)()

        return _f

    if kind in {"semantic_mask", "semantic", "semseg"}:
        keep_per_image = bool(self.kwargs.get("keep_per_image", False))

        def _f():
            return SemanticSegSuiteFactory(nc, keep_per_image=keep_per_image)()

        return _f

    raise ValueError(
        f"Unknown MetricsSpec.kind={kind!r}. Expected one of: "
        f"'bbox', 'instance_mask', 'semantic_mask' (or aliases). "
        f"Alternatively provide MetricsSpec(target=..., kwargs=...)."
    )