Training¶
Set up training¶
All settings are expected to be specified in
main_config.yaml.Training settings live under the
trainconfigsection. Defaults, validation, and auto-derived values are managed throughSkiNet.ML.configs.train_configs.train_config.TrainConfig.precisionis auto-detected fromaccelerator: GPU/CUDA/MPS →"16-mixed", CPU →"32-true". Override explicitly if needed.For an Optuna sweep on GPU keep
precision: "16-mixed"; for CPU sweeps either omitprecision(auto-detected) or set a CPU-supported value.
Key TrainConfig fields¶
Field |
Default |
Description |
|---|---|---|
|
|
Samples per training batch |
|
auto (CPU count / DDP devices) |
DataLoader workers; auto-divided among DDP processes |
|
|
Auto-set from accelerator |
|
|
Batches pre-loaded per worker; ignored when |
|
|
Cache dataset in RAM before training; set |
|
|
Wrap model with |
|
|
|
|
|
|
|
|
Base learning rate (linearly scaled by batch size in Optuna sweeps) |
|
|
L2 regularisation strength |
|
|
|
|
|
Maximum training epochs |
|
|
Resolves to |
|
auto |
Derived from accelerator; override if needed |
|
|
See Reproducibility |
|
|
Global RNG seed passed to |
|
|
Enable/disable the LR scheduler |
|
|
|
|
|
Period of cosine annealing; auto-set to |
|
|
Minimum learning rate at the end of each cosine cycle |
Lightning model¶
The core training logic lives in SkiNet.ML.model.lightning_model.LightningModel (a L.LightningModule subclass).
Metrics¶
Metric logged |
Description |
|---|---|
|
Epoch-mean loss for the configured |
|
|
|
|
|
Best dataset-aggregated Dice found across the threshold sweep |
|
Mean of per-image Dice at the best threshold; schema default monitor ( |
|
Threshold that achieved |
|
Dice gain from using the optimal threshold vs. 0.5 |
|
Threshold actually applied during validation |
Optimizer and scheduler¶
Optimizer: Adam or AdamW (set via
optimizer_name), with configurablelrandweight_decay.Scheduler: controlled by
scheduler_type("reduce_on_plateau"or"cosine_annealing"); toggled viause_lr_scheduler."reduce_on_plateau"(schema default):ReduceLROnPlateau, mode"max", schema defaults patience5/ factor0.5(the shippedmain_config.yamlsets patience3). It monitors the propagatedSWEEP_CONFIG.monitor(schema defaultval_mean_dice_per_image;val_best_dice_at_thresholdin the shipped config) — do not setmonitorunderlr_scheduler_config. Configured vialr_scheduler_config."cosine_annealing":CosineAnnealingLRwithT_max(defaults tomax_epochswhenNone) andeta_min(default1e-6). Configured viacosine_annealing_config.
Mixed precision and gradient scale monitoring¶
on_before_optimizer_step() reads trainer.precision_plugin.scaler and logs grad_scale each step. A sudden drop or collapse in grad_scale is the first diagnostic signal for mixed-precision instability.
Non-finite detection¶
Every training, validation, and test step validates inputs, logits, masks, and loss for NaN/Inf. On detection a detailed _tensor_debug_summary() is raised with the batch index and per-tensor statistics.
Model architecture¶
SkiNet uses a UNet2D architecture configured via SkiNet.ML.configs.model_configs.unet2d_config.UNet2DModelConfig (under MODEL_CONFIG in the YAML).
Key UNet2DModelConfig fields¶
Field |
Default |
Description |
|---|---|---|
|
|
Input image channels |
|
|
Output channels of the first encoder layer |
|
|
Total encoder layers (decoder has |
|
|
Segmentation output classes |
|
|
Convolution kernel size |
|
|
Downsampling stride in encoder; upsampling factor in decoder |
|
|
Residual block type for encoder layers |
|
|
Residual block type for decoder merge layers |
|
|
Channel reduction ratio for SE blocks (only used when |
|
|
Structural validation (skip key count) during forward pass |
|
|
Log warnings for near-zero skip connections (GPU-expensive; keep off in production) |
Encoder residual modes¶
Configured via encoder_residual_mode in MODEL_CONFIG:
Mode |
Description |
|---|---|
|
Standard UNet encoder: Conv-BN-Act without any residual connection |
|
Post-activation: downsample → refine with residual from downsampled intermediate (Oktay et al. 2020) |
|
Pre-activation with 1×1 projection shortcut: BN-Act-Conv → BN-Act-Conv + P(x) (He et al. ECCV 2016) |
|
Pre-activation He2 with Squeeze-and-Excitation channel attention applied before the shortcut addition (Hu et al. CVPR 2018) |
Merge (decoder) residual modes¶
Configured via merge_residual_mode in MODEL_CONFIG:
Mode |
Description |
|---|---|
|
Standard UNet decoder: upsample → concatenate skip → Conv-BN-Act without any residual connection |
|
Post-activation: project-and-sum → BN-Act → Conv-BN-Act + residual (Oktay et al. 2020) |
|
Pre-activation with one refinement conv + identity shortcut (He et al. ECCV 2016) |
|
Pre-activation with two refinement convs + identity shortcut (He et al. ECCV 2016) |
|
Additive attention gate (Oktay et al. MIDL 2018) gates the skip connection before merge; post-merge he2 refinement |
Example YAML (the production configuration):
MODEL_CONFIG:
encoder_residual_mode: "classical"
merge_residual_mode: "attention_gate"
Best threshold selection¶
At the end of each validation epoch, when optimal_threshold is null, SkiNet.ML.training.training_utils.find_best_threshold() sweeps 51 evenly-spaced candidate values from 1.0 down to 0.0 using torch.linspace. For every threshold it computes true positives, false positives, and false negatives in a single vectorised broadcast across the entire validation set, then derives Dice (F1) as 2·tp / (2·tp + fp + fn). The threshold with the highest Dice is selected; when multiple thresholds tie, the highest one wins because the sweep is descending and argmax returns the first occurrence.
When optimal_threshold is set to a fixed float (as in the shipped main_config.yaml, 0.5), the sweep is skipped: val_best_dice_at_threshold and val_mean_dice_per_image are computed directly at that fixed threshold and val_optimal_threshold echoes it. In both cases val_dice_threshold_gain reports the Dice difference relative to a plain 0.5 cutoff. val_best_dice_at_threshold is the metric monitored by early stopping and Optuna.
Callbacks¶
All callbacks are opt-in via boolean flags in TrainConfig. They are wired together in
SkiNet.Utils.logging.logging_callbacks_setup.setup_logging_and_callbacks().
Callback |
Flag |
Description |
|---|---|---|
|
always on |
Background thread logs CPU%, RAM%, GPU memory (allocated/reserved), and GPU utilisation every |
always on |
Logs |
|
|
|
Monitors the propagated |
|
|
Saves the best checkpoint by the propagated |
|
|
Logs params, metrics, model summary, and artifacts; supports nested Optuna child runs; config via |
|
any logger enabled |
Logs LR each epoch |
|
|
Logs model summary at fit start; logs early-stopping state and best checkpoint as artifacts at fit end |
|
|
Lightning Studio native logger; config via |
Ways to start training inside a configured environment (Docker container)¶
The options below assume you are inside a configured environment (as per SkiNet’s Docker container)
Optuna hyperparameter optimisation (HPO) sweep¶
When running optuna_sweep.py, a single MLflow parent run wraps the whole study. Each trial is a nested MLflow child run. The sampler is GridSampler (exhaustive grid, not random).
The search space is declared in
SkiNet.ML.configs.train_configs.sweep_config.SweepConfigand keyed bySkiNet.Utils.experiment_keys.HyperparamKey.HyperparamKeyis the single source of truth: adding a new member there automatically makesSkiNet.Utils.mlops.optuna_utils.validate_search_space()require it and allowsbuild_objectiveto read it.Current members:
Member
String key
Description
HyperparamKey.LR"lr"Learning rate (scaled linearly by batch size:
lr * batch_size / min_batch_size). Fixed to a single value inmain_config.yamlafter E1 LR search; set multiple values inSWEEP_CONFIG.lronly when searching LR for a new architecture.HyperparamKey.WEIGHT_DECAY"weight_decay"L2 regularisation strength
HyperparamKey.BATCH_SIZE"batch_size"Samples per training batch (also rescales LR — see below)
HyperparamKey.NUM_WORKERS"num_workers"DataLoader worker count
HyperparamKey.PREFETCH_FACTOR"prefetch_factor"Batches pre-loaded per worker
HyperparamKey.SCHEDULER_TYPE"scheduler_type"LR scheduler per trial:
"none"(setsuse_lr_scheduler=False),"cosine_annealing", or"reduce_on_plateau"Each field is a list of GridSampler candidates for one dimension; the effective search space is the Cartesian product of the lists, and
optuna_sweep.pyruns the full product (n_combos = ∏ len(list)) unless--trials Ncaps it. Single-element lists are held constant.Every
SweepConfigfield defaults to a single value, kept consistent with theSWEEP_CONFIGblock inmain_config.yaml(lr=[3e-4],weight_decay=[0.0],batch_size=[8],num_workers=[2],prefetch_factor=[4],scheduler_type=["none"]), so the default grid is a 1-combo no-op sweep. To search a dimension, widen its list in the YAML — in practice vary one at a time. Notenum_workersandprefetch_factorare throughput knobs that do not affect model quality, andbatch_sizeis usually swept to find the largest size that fits GPU memory rather than as a generalisation target. Whenbatch_sizeis varied,lris rescaled per trial byscale_lr(anchored to the smallest batch in the sweep), so a sampledlralways denotes the rate at the reference batch size.SWEEP_CONFIG.monitorandSWEEP_CONFIG.directionin the YAML are the single source of truth for the optimisation objective.SkiNet.ML.configs.experiment_config.ExperimentConfigautomatically propagatesmonitorintoearly_stopping_config,checkpoint_config, andlr_scheduler_configat config-load time — do not setmonitorin those sub-sections. If they disagree, aValueErroris raised at startup so the mismatch is caught before any training runs. The schema default (whenSWEEP_CONFIG.monitoris unset) isval_mean_dice_per_image; the shippedmain_config.yamloverrides it toval_best_dice_at_threshold, which is what the callbacks below then monitor.SWEEP_CONFIG: monitor: "val_best_dice_at_threshold" # <-- only place to set the metric direction: "maximize" # early_stopping_config, checkpoint_config, lr_scheduler_config: omit 'monitor' — propagated automatically
--monitor/--directionCLI flags are optional overrides — use them only for a one-off run without editing the YAML. When omitted,optuna_sweep.pyreads directly fromSWEEP_CONFIG.Similarly, the shell env vars
SWEEP_MONITOR/SWEEP_DIRECTIONinon_start_gpu.share optional overrides; leaving them unset means the YAML values are used.MLflow run naming:
Parent:
optuna_study_{TRAIN_CONFIG.experiment_name}_{monitor}Child:
trial_{n}_lr{lr}_wd{weight_decay}_bs{batch_size}_nw{num_workers}_pf{prefetch_factor}_sched{scheduler_type}
Example — monitor and direction taken from SWEEP_CONFIG in the YAML (recommended):
python optuna_sweep.py --config main_config.yaml
Example — one-off override without editing the YAML:
python optuna_sweep.py --config main_config.yaml --monitor val_best_dice_at_threshold --trials 10
Regular training¶
python main_run.py --config main_config.yaml
MLflow run name: {experiment_name}_seed{seed}_{timestamp}.
Multi-seed training¶
Launch a training experiment as specified in TRAIN_CONFIG, using multiple seeds. All seeds will run under one MLflow experiment
python run_seeds.py --config main_config.yaml --seeds 42 200 300
Each seed produces an independent MLflow run.
Launching training from Lightning Studio¶
The startup scripts (on_start_gpu.sh, on_start_cpu.sh) handle Docker bootstrap on Lightning Studio and dispatch to the commands above. See development.md for the full reference: available MODE values, DATASET/ENCODER_MODES/MERGE_MODES/RELEASE_GPU env vars, dry-run, and example invocations.
Training monitoring¶
MLFlow¶
When using on_start_gpu.sh / on_start_cpu.sh: MLflow is started automatically by the script — no manual step needed.
When running Python entry points directly inside the container: start the MLflow server first.
Via the setup script (recommended):
chmod +x start_mlflow.sh
./start_mlflow.sh
Or manually (SQLite backend + local artifact store):
mlflow server \
--backend-store-uri sqlite:////workplace/SkiNet/mlflow.db \
--default-artifact-root file:///workplace/SkiNet/mlruns \
--host 0.0.0.0 \
--port 5000
Open the MLflow UI in a browser on port 5000. If using Lightning Studio, tunnel via SSH:
ssh -N -L 5000:localhost:5000 ssh_connection_string_from_your_studio@ssh.lightning.ai
GPU utilisation¶
nvidia-smi dmon -s u
Reproducibility¶
How the same seed value is guaranteed¶
Three independent seed fields are read from the YAML and, in the shipped main_config.yaml, set to the
same integer 100 (Pydantic schema defaults differ: split_random_seed=42, seed_value=None, seed=42):
DATA_CONFIG.split_random_seed # used once when datasets are split
TRANSFORM_CONFIG.seed_value # passed into the augmentation pipeline constructor
TRAIN_CONFIG.seed # applied globally
Then in configure_reproducibility():
L.seed_everything(train_cfg.seed, workers=True) # seeds Python, NumPy, PyTorch, and DataLoader workers
# fallback: if TRANSFORM_CONFIG.seed_value is None (and compose_kwargs has no "seed"), set it to train_cfg.seed
All three fields are set to a constant in the YAML — nothing overrides them at runtime.
Note: Toggling
train_and_evaluate(..., visualize=...)(defaultTrue, which callsvisualize_augmented_dataon the train split) does not change model weight initialisation:visualize_augmented_datasnapshots torch/NumPy/Python/CUDA RNG state and restores it in afinallyblock, so the RNG seen by downstream model init and training is identical with or without visualisation. No manual re-seeding is needed.
Platform notes — Deterministic mode (Apple Silicon / MPS)¶
Lightning raises MisconfigurationException if deterministic: true is used on Apple Silicon (MPS) because the MPS backend does not support deterministic mode. This is handled automatically:
# On MPS, fall back to "warn" so Lightning logs a warning but continues.
# True determinism requires CUDA/cuDNN.
if train_cfg.deterministic and torch.backends.mps.is_available():
deterministic = "warn"
else:
deterministic = train_cfg.deterministic
if deterministic is True and torch.cuda.is_available():
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
trainconfig:
deterministic: true
On Ubuntu/CUDA (the primary SkiNet target) this fully enables cuDNN deterministic mode.