Writing a configuration

From one model to several, by hand, in YAML.

One YAML file describes a whole run. This page is the walk through it; every field it accepts is the reference beside it, generated from the same models that read the file.

The same file works in both packages. Python reads it with from_yaml(), R with platypus_spec(yaml = ...), and both hand it to the same validator. Nothing on this page is Python-specific except the two lines that run it.

One file, one run

The shortest thing that trains:

task: semantic_segmentation
data:
  train_path: data/train
  validation_path: data/validation
  colormap: [[0, 0, 0], [255, 255, 255]]
models:
  - name: first_try
    input_shape: [256, 256]
from pyplatypus import build_engine, from_yaml

engine = build_engine(from_yaml("run.yaml"))
engine.fit()
print(engine.evaluate())

Four things are required and the rest have defaults: task, data.train_path, and a name and input_shape for each model. Everything else - the architecture, the loss, the optimiser, how many epochs - has a default that lives in the engine, so a short configuration is a complete one rather than an incomplete one.

colormap says which colour in a mask means which class, darkest first. Masks stored as label maps rather than pictures use labels: [0, 1] instead, and giving both is an error: exactly one says how a mask is encoded.

Several models at once

models: is a list, and that is the reason this file format exists rather than only the code path. Each entry trains on the same data and lands in the same table, so the comparison is one run rather than a loop somebody has to write.

task: semantic_segmentation
data:
  train_path: data/train
  validation_path: data/validation
  colormap: [[0, 0, 0], [255, 255, 255]]
models:
  - name: shallow
    input_shape: [256, 256]
    blocks: 3
    filters: 16
  - name: deep
    input_shape: [256, 256]
    blocks: 5
    filters: 32
    architecture: u_net_plus_plus

name has to be unique, because it is how a model is identified in every score table, in every record and in engine.predict(model_name=...). Two models called the same thing is refused rather than resolved.

Two entries differing only in blocks is a legitimate configuration and a common one: this is for comparing variants, not only architectures.

Dividing one folder

With no validation_path, say how to divide train_path:

task: semantic_segmentation
data:
  train_path: data/all
  split:
    fractions: [0.7, 0.15, 0.15]
    group_by: "^(patient\\d+)_"
  colormap: [[0, 0, 0], [255, 255, 255]]
models:
  - name: by_patient
    input_shape: [256, 256]

Two fractions give train and validation, three add a test set. Nothing is written to disk - the samples are divided after they are discovered, so there are no new folders and no CSV.

group_by is required and may be null. It is a regular expression whose first group is the thing that must not appear on both sides - a patient, a scan, a slide. The mistake it prevents leaves no trace: slices of one patient in both sets make validation measure memory rather than generalisation, and every number in the output still looks reasonable. An omitted key would be that choice made silently; null is the same choice made on purpose, for data where every sample is independent.

Exactly one of split and validation_path. If a run genuinely has no validation set - a final fit on everything - say validation: false, which is the third answer rather than an absence, because “I have no validation set” and “I forgot” look identical otherwise.

Augmentation, callbacks, and the rest of a model

task: semantic_segmentation
data:
  train_path: data/train
  validation_path: data/validation
  colormap: [[0, 0, 0], [255, 255, 255]]
models:
  - name: augmented
    input_shape: [256, 256]
    epochs: 40
    loss: {name: dice}
    metrics: [{name: iou}, {name: dice}]
    optimizer: {name: adam, learning_rate: 0.001}
    callbacks:
      - {name: early_stopping, monitor: val_loss, patience: 10}
      - {name: model_checkpoint, monitor: val_iou, path: runs/best.safetensors}
    augmentation:
      - {name: HorizontalFlip, params: {p: 0.5}}
      - {name: RandomBrightnessContrast, params: {p: 0.3}}

loss, optimizer, each metric and each callback are blocks chosen by name:, and the name decides which other keys that block accepts. A key the chosen one does not have is refused by name rather than ignored.

There is no mode on a callback that watches a number. The direction is taken from the name: anything ending in loss is better lower, anything else better higher. So monitor: val_iou maximises and monitor: val_loss minimises, and there is nothing to set inconsistently.

Augmentation applies to the training data only - never to validation, where the point is to measure the same thing every epoch. The names are albumentations’ own, and available_transforms() lists what the installed version offers; at rank=3 the list is shorter, because support for volumes is uneven. Each step is probed at the model’s own input size while the pipeline is built, so a transform that cannot do the job says so before the first epoch rather than during it.

Overlapping classes instead of one per voxel

colormap and labels both say the same thing about a voxel: it is in exactly one class. regions is the third way of naming classes and the only one that drops that assumption - classes named as unions of a label map’s values, which is how BraTS and the lesion-inside-an-organ family actually store their ground truth.

task: semantic_segmentation
data:
  train_path: data/train
  validation_path: data/validation
  regions:
    - {name: whole tumour, of: [1, 2, 4]}
    - {name: tumour core, of: [1, 4]}
    - {name: enhancing, of: [4]}
models:
  - name: brats
    input_shape: [240, 240]
    loss: {name: focal}

The task is still semantic_segmentation. What changes is what a channel means.

There is no background entry, so the class count is not the one you would write as labels. Those same masks as labels: [0, 1, 2, 4] are four classes; as the three regions above they are three channels. And a voxel whose value is 4 is in all three of them at once, which is the point rather than a tolerated side effect.

Each channel is an independent binary question - a sigmoid per channel rather than one softmax across them - so the channels do not compete and do not sum to one. That is what makes the overlap expressible, and it is also why a prediction cannot be written back as a single picture: one voxel gets one colour, and a voxel here may belong to three classes. A colour map refuses such a prediction rather than taking the strongest channel, and save_class_masks() is what it points at - one binary picture per region, named after the region, which is the only form a picture can hold without losing the overlap.

Two regions are the minimum, because one region is ordinary binary segmentation and labels: [0, 1] already says that - with a background channel, which regions has none of. Disjoint regions are allowed and are not a mistake: they group several values into one class and drop the background channel, which is a useful thing to ask for and is refused nowhere.

The failure this invites is quiet, so it is refused loudly. A region whose of matches nothing in the data - of: [3] against a label map holding 1, 2 and 4 - is a channel of zeros. It trains to predict nothing and scores a Dice of 0 for that region while every other number in the run looks ordinary, which is the kind of result that gets read as “that class is hard”. The mask check refuses it before training and names the region rather than its position, and inspect_masks answers the same question over as much of the data as you want, naming it the same way.

When the overlap was drawn separately: classes_from

regions needs the overlap to be a union of one label map’s values, which is how BraTS stores it - one integer per voxel, and the scored classes are conventions over those integers. Some overlaps are not like that. A vessel crossing an organ, or two annotators’ masks of the same structure, were drawn independently and no single integer can hold both. Those arrive as one binary file per class, and classes_from names them: one regular expression per class, in class order, matched against a sample’s mask files.

task: semantic_segmentation
data:
  train_path: data/train
  validation_path: data/validation
  classes_from:
    - _vessel\.png$
    - _organ\.png$
models:
  - name: crossing
    input_shape: [256, 256]
    loss: {name: cce_dice}

Everything the section above says about a channel still holds: independent binary questions, no background entry, a prediction that cannot be written back as one picture, save_class_masks() where a colour map refuses. The difference between the two namings is the file on disk and nothing else.

Every pattern must match exactly one of a sample’s mask files, and every mask file must match a pattern. Both halves are refused, because each is invisible from the other side: a pattern matching nothing is a class the model is never shown, and a file matching no pattern is a class the data has and nobody asked for. The order is written rather than inferred for the reason channels_from gives, with worse consequences here - sorting the names is reproducible and anatomically meaningless, and a model trained with the vessel in channel 0 and used where channel 0 is the organ answers plausibly, in range, with nothing downstream able to tell.

#91 asked which of the two shapes this package needed; the answer, in 2026-10-12’s decision, was nested regions first. This is the other half.

Instances instead of one mask

task: instance_segmentation keeps the objects apart rather than uniting them. The data block gets shorter rather than longer: no colormap and no labels, because there are always exactly two classes - background and object - and what names the objects is the directory of per-object mask files itself.

task: instance_segmentation
data:
  train_path: data/train
  validation_path: data/validation
  mode: nested_dirs
models:
  - name: rim
    input_shape: [256, 256]
    separation: {name: rim, width: 2}
    loss: {name: cce_dice}
    decode: [{name: nearest_seed}, {name: connected_components}]
  - name: flows
    input_shape: [256, 256]
    separation:
      name: flows
      loss: {name: mse, where: foreground}

Two axes, and they are deliberately different shapes. separation is one block per model and has no default: it is what the network is trained to predict, so it sets the width of the head - three channels for rim, four for flows at two classes - and that width is derived rather than written anywhere. decode is a list, because several decodings share one forward pass: a second decoding of 134 images costs 1.41 s against an hour and a half for a second training run, so it belongs beside metrics: rather than beside callbacks:. evaluate() then returns a row per (model, decoding).

Which mask column to read is a decision the table makes for you and it used to make it wrongly. Every row carries foreground_dice - the binary overlap of anything the model called an object against anything that is one, the same arithmetic whatever the separation is - and that is the one to read across models. The dice beside it is the training metric, a mean over the class channels the head has, so three for rim against two for flows: read down one model’s rows, never across two. On the Data Science Bowl’s validation cases the class column showed two masks 0.09 apart that are 0.0062 apart, and in the other direction.

No value appears in both lists, which is what settles which field a new idea belongs in: separation is a noun and decode is a verb. rim can be decoded by nearest_seed or watershed, flows by follow_flows, and connected_components reads no signal at all and works with every target. Leave decode out and it derives to the signal’s own decoding plus connected_components; write a list and you get the list you wrote.

A model’s own loss and metrics score the mask at either separation, so they are an overlap or a cross-entropy loss here as everywhere else. A flows block carries its own loss for the two vector channels, which is where mse, l1 and smooth_l1 belong: where: foreground averages the error over the voxels the target is defined on, and on these images that is 13.97% of them.

What this task is worth is measured, and it is not flattering. On the Data Science Bowl the separation signal is worth +0.068 of instance average precision at best while a perfect mask is worth +0.2398, so the mask is the limit by a factor of 3.6 - and a perfect semantic mask decoded by connected_components still scores only 0.7655. That is what the yardstick row is for: a run that improved the mask and did nothing for the separation improves every row at once, and without the row that ignores the signal there is nothing to notice it against.

Detection instead of segmentation

task: object_detection changes which fields the file accepts. Boxes rather than masks, so classes and an annotation format rather than a colormap:

task: object_detection
data:
  train_path: data/train
  validation_path: data/validation
  annotation_format: pascal_voc
  classes: [rbc, wbc, platelets]
models:
  - name: cells
    input_shape: [416, 416]

Leave anchors out and they are fitted to your own boxes by k-means on an IoU distance, anchors_per_grid of them per grid. That is worth about 0.23 of mean overlap against COCO’s nine on blood cells - measured, 0.65 against 0.88 - so the default is the useful one and naming anchors by hand is for reproducing a published model.

Not training at all

task: semantic_segmentation
data:
  train_path: data/images
  validation_path: data/images
  colormap: [[0, 0, 0], [255, 255, 255]]
models:
  - name: published
    input_shape: [256, 256]
    weights: dsbowl-unet
    fit: false

weights takes a registry name, a local path, or hf://owner/repo/file@commit - and the hf:// form requires the commit, because weights that change under a stable name are the worst kind of irreproducibility. A registry name carries one already.

With fit: false nothing trains and engine.predict() works immediately. The weights are checked against the model they are being loaded into before torch is touched: a shape, a channel count or a class count that does not match is a refusal naming the field, not a tensor-size error from inside a loss.

What is checked, and when

Validation happens in this order, and the first two cost no epochs:

  1. The file, against the schema. Unknown fields, missing required ones, a name: that no block has, a callback watching a number nothing will produce. Before any path is read.
  2. The data on disk, when the engine is built: that the folders exist, that images and masks correspond, and that every class in colormap or labels actually appears in the masks - because a declared class appearing in none has a target channel of zeros, so the model is never shown what it is being asked to find.
  3. Everything else, during the run.

A refusal names the field and says what to do. models[0].callbacks[1].model_checkpoint.path: Field required is the whole message - the path through the file, then the problem.

The same run from code

The file is not a second implementation. from_yaml() and from_dict() produce the same object, and so does building the models by hand:

from pyplatypus import build_engine, from_dict

spec = from_dict(
    {
        "task": "semantic_segmentation",
        "data": {
            "train_path": "data/train",
            "validation_path": "data/validation",
            "colormap": [[0, 0, 0], [255, 255, 255]],
        },
        "models": [{"name": "first_try", "input_shape": [256, 256]}],
    }
)
engine = build_engine(spec)

Which to use is a question about how many models and how often, not about what is possible. A file is better when there are several models, when the run is going to be repeated, or when somebody else has to see what was done; the dict is better inside a loop over something the file cannot express, like a list of datasets.