One YAML file describes a whole run, and platypus_spec()
reads it. This is the walk through that file; the exhaustive list of
fields lives on the engine’s site, because the file is the engine’s
format and keeping a second copy here would mean two of them going out
of step: every
configuration field.
The same file works in both packages. R reads it
with platypus_spec(), Python with from_yaml(),
and both hand it to the same validator. Nothing below is R-specific
except the lines that run it.
Arguments and YAML produce the same object and nothing downstream can tell which was used, so a configuration file and an R script describe the same experiment - and either can be handed to a colleague.
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]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. ?u_net has a
Where the defaults live section saying why they are not written
into the R signatures.
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.
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 comparing variants 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_plusfit <- platypus_fit(platypus_spec("run.yaml"))
evaluate(fit) # one row per model
training_history(fit) # one row per model per epochname has to be unique, because it is how a model is
identified in every score table, in every record and in
predict(fit, model = ...). Two models called the same thing
is refused rather than resolved, by name:
model names must be unique; repeated: shallow.
Two entries differing only in blocks is a legitimate
configuration and a common one: this is for comparing variants, not only
architectures.
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.
split_dataset() is still the tool when the CSVs themselves
are the point.
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.
The same from R, where the argument answers the same question three ways:
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}}Every one of those blocks has an R constructor that builds the same
thing, and the help pages for them are where the measurements are:
?losses, ?metrics, ?optimizers,
?callbacks.
u_net(
"augmented",
input_shape = c(256, 256),
epochs = 40,
loss = loss_dice(),
metrics = list(metric_iou(), metric_dice()),
optimizer = optimizer_adam(learning_rate = 0.001),
callbacks = list(
callback_early_stopping(monitor = "val_loss", patience = 10),
callback_model_checkpoint(monitor = "val_iou", path = "runs/best.safetensors")
),
augmentation = list(
augmentation_step("HorizontalFlip", p = 0.5),
augmentation_step("RandomBrightnessContrast", p = 0.3)
)
)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.
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 same from R. A region is a list(name = , of = ), and
of takes the label-map values that belong to it:
spec <- platypus_spec(
data = segmentation_data(
"data/train", "data/validation",
regions = list(
list(name = "whole tumour", of = c(1, 2, 4)),
list(name = "tumour core", of = c(1, 4)),
list(name = "enhancing", of = 4)
)
),
models = list(u_net("brats", input_shape = c(240, 240), loss = loss_focal()))
)The last region shows that a single value is ordinary rather than an edge case: BraTS’s enhancing tumour is one label.
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 = c(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 with save_masks(): one voxel gets
one colour, and a voxel here may belong to three classes.
So save_masks() refuses such a prediction rather than
taking the strongest channel, and predict() refuses
type = "class" for the same reason - there is no one class
per voxel to return. Ask for type = "probability" and write
it with save_class_masks(), which gives one binary file per
region, named after the region. The channel order is the order the
regions were written in, which is also the order
inspect_masks() names them in and the order every per-class
score is reported in.
Two regions are the minimum, because one region is
ordinary binary segmentation and labels = c(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”. platypus_fit() refuses it
before training, and inspect_masks() asks the same question
over as much of the data as you want - naming the region rather
than giving its position, because
missing_classes: 1 would leave you counting entries in the
file above:
inspect_masks(spec)
#> split unmatched samples_checked total_samples present_classes missing_classes
#> 1 train 0.0000 500 1251 whole tumour, ... enhancingclasses_fromregions 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 pattern
per class, in class order, matched against a sample’s mask files.
spec <- platypus_spec(
data = segmentation_data(
"data/train", "data/validation",
classes_from = c("_vessel\\.png$", "_organ\\.png$")
),
models = list(u_net("crossing", input_shape = c(256, 256)))
)Everything above still holds: independent binary questions, no
background entry, a prediction save_masks() refuses and
save_class_masks() writes. 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.
Patterns are Python regular expressions, like channels_from
and group_by, so R’s own backslashes double.
The order is written rather than inferred, and here it matters more than for channels: a model trained with the vessel in the first channel and used where the first channel is the organ answers plausibly, in range, and nothing further down can notice.
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}The same two models from R. instance_data() is what says
which task this is - an instance data block declares nothing a
segmentation one lacks, so the constructor is the word:
spec <- platypus_spec(
data = instance_data("data/train", "data/validation"),
models = list(
u_net(
"rim",
input_shape = c(256, 256),
separation = separation_rim(width = 2),
loss = loss_cce_dice(),
decode = list(decode_nearest_seed(), decode_connected_components())
),
u_net(
"flows",
input_shape = c(256, 256),
separation = separation_flows(loss = loss_mse(where = "foreground"))
)
)
)
fit <- platypus_fit(spec)
evaluate(fit)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. evaluate() then returns a row per model and
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
separation_rim() against two for
separation_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. separation_rim()
can be decoded by decode_nearest_seed() or
decode_watershed(), separation_flows() by
decode_follow_flows(), and
decode_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; give a
list and you get the list you gave.
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. separation_flows() carries
its own loss for the two vector channels, which is where
loss_mse(), loss_l1() and
loss_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.
One thing is missing from R rather than from the engine, and
it is missing on purpose rather than by oversight:
predict() on an instance fit refuses. The run and its
scores work - evaluate() gives the row per model and
decoding above - but there is no call yet that brings the objects
themselves back into R as a label map, so predict() says so
instead of returning a mask that is not what you asked for.
platypus#103.
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.
detection_anchors() reports them and
plot_anchors() draws them against the boxes they were
fitted to.
task: classification answers with one label for a whole
image rather than one per voxel. The data block loses everything that
describes a mask and gains the one thing a label needs -
classes, in class order:
task: classification
data:
train_path: data/train
validation_path: data/validation
mode: nested_dirs
classes: [benign, malignant]
models:
- name: ours
input_shape: [128, 128]
blocks: 4
filters: 16
loss: {name: cce}
metrics: [{name: auc}, {name: balanced_accuracy}]
- name: resnet
input_shape: [224, 224]
encoder: resnet34
pretrained: true
freeze_encoder: 2
pooling: max
head_dropout: 0.2
loss: {name: focal, gamma: 2}The same thing from code is classification_data() and
classifier():
spec <- platypus_spec(
data = classification_data("data/train", "data/validation",
classes = c("benign", "malignant")),
models = list(
classifier("ours", input_shape = c(128, 128), blocks = 4, filters = 16,
loss = loss_cce(),
metrics = list(metric_auc(), metric_balanced_accuracy())),
classifier("resnet", input_shape = c(224, 224), encoder = "resnet34",
pretrained = TRUE, freeze_encoder = 2, pooling = "max",
head_dropout = 0.2, loss = loss_focal(gamma = 2))
)
)Where the labelling is written follows from mode
rather than being said twice. In nested_dirs the
directories under train_path are the classes, with the
images inside them - the layout nearly every image dataset ships in -
and in config_file the CSV’s label column
holds the name. subdirs is refused here, which is what
keeps the two readings of nested_dirs apart: a sample is
one image rather than a directory with the image in one subdirectory and
what labels it in the other.
classifier() is the one model constructor in
this package not named after an architecture, and the missing
field is the point rather than an omission. The network is a contracting
path, pooled, with one dense layer on top, so encoder is
the whole answer: unset it is ours, sized by blocks and
filters, and named it is any of
available_encoders(). The two models above are the
comparison this task makes easiest - ours against a pretrained backbone
on the same split. blocks, filters and
block_width are refused beside
encoder rather than ignored, because a backbone’s own depth
is then the only one there is, and
available_models("classification") says all of this instead
of returning rows.
metric_auc() is the default and that is a
measurement rather than a preference. On 40000 images at a
prevalence of 3%, with the model’s own probabilities as the truth so
that only its discrimination varies, accuracy spans 0.0100 where the AUC
spans 0.4631 - and accuracy is not monotone in quality: the model with
no signal at all scores above two that have one.
metric_balanced_accuracy() and metric_f1() do
not rescue it, moving 0.0000 and -0.0005 between a real signal and a
collapse, because a calibrated model at that prevalence never crosses
the threshold and everything read off the hard prediction sees the same
all-negative answer. class_balance() is what makes that
prevalence visible before a run rather than after it.
A split cut out of the training folder is
stratified, which is the one part of the walk this task
overrides. Measured over 400 seeds, a random 70/15/15 of 100 images at a
3% prevalence leaves some part with none of the rare class 91% of the
time, 64% at 200 images and 20% at 500 - and a per-class metric cannot
average over a class its pass never held, so the run dies at the end of
the first epoch with the epoch already paid for. group_by
beside it is refused when a group’s images do not share a label, because
keeping a patient on one side and keeping a class on both are then two
instructions that cannot both be followed.
What predict() gives back is a table rather than
an array, which is the shape the rest of R wants.
type = "class" is one row per image with key,
label, predicted and probability,
and type = "probability" is a matrix of images by classes
with the keys as row names - so either one joins to whatever else you
hold per image. space and the
cases/each pair are refused by name: they
describe a grid, and a split of masks too large to hold at once, and
neither is a thing one label per image has.
evaluate_cases() is refused for a different reason - a case
here is one image whose score is 0 or 1, so a split’s worst case is 0
for any model that gets a single image wrong.
labels_fromA chest film with four findings is four independent
questions rather than one of four alternatives, and
labels_from is how that is said: one CSV column per class,
in class order, each cell 0 or 1.
task: classification
data:
train_path: data/train.csv
validation_path: data/validation.csv
mode: config_file
classes: [atelectasis, effusion, nodule, mass]
labels_from: [Atelectasis, Effusion, Nodule, Mass]
models:
- name: ours
input_shape: [224, 224]
blocks: 4
filters: 16
loss: {name: cce}
metrics: [{name: auc}, {name: f1}]The same thing from code:
spec <- platypus_spec(
data = classification_data(
"data/train.csv", "data/validation.csv",
classes = c("atelectasis", "effusion", "nodule", "mass"),
labels_from = c("Atelectasis", "Effusion", "Nodule", "Mass"),
mode = "config_file"
),
models = list(
classifier("ours", input_shape = c(224, 224), blocks = 4, filters = 16,
loss = loss_cce(), metrics = list(metric_auc(), metric_f1()))
)
)It is a naming rather than a switch, for the reason
regions and classes_from are on the mask side:
the target changes and not only the activation. A
label column structurally cannot say a film has two
findings, so a multilabel = TRUE would have trained sigmoid
channels against an exclusive target, raised nothing, and reported
plausible numbers meaning exactly what softmax meant.
Position is the pairing. The headers may be called
whatever the dataset calls them - above they are capitalised and the
classes are not - and the model still learns the classes in the order
classes names them. config_file only, because
an image cannot sit in two directories without being two files, and
strict_labels is refused beside it rather than ignored: the
columns are the classes, so no cell can hold a class nobody
named.
A cell that is not 0, 1, 0.0
or 1.0 is refused by name, and the case it is written for
is -1. The datasets shaped this way write -1
for uncertain and leave the cell empty for not
mentioned; both have a defensible mapping, which is exactly why the
reader must not pick one for you. An image with none of the findings is
an ordinary labelled row, written as a line of zeros.
metric_accuracy() is refused here rather than
redefined. On 4000 images with findings at 10%, 5%, 3% and 2%,
a model answering no to everything scores 0.8137 on
exact match and 0.9507 per class - above a model
holding half the available signal - so neither reading of it can be what
a monitor watches. Use
metric_balanced_accuracy(), metric_f1() or
metric_auc().
predict() answers in a different shape on this
route, because the answer is a different kind of thing: one row
per image per class, with key, class,
and label and predicted as logicals. An image
with none of the findings is a row of FALSE per class
rather than an empty cell, which could also mean unread - and a split
nobody labelled is NA, which is the other
half of that sentence and the reason FALSE can be trusted
to mean measured and absent. There is no
probability column - that column is the chosen class’s own
number, and with independent questions nothing was chosen - so
type = "probability" is the whole of the answer there, one
column per class.
A test set is often just pictures, and that is an ordinary thing to
have rather than a half-written configuration. Name them in a CSV with
no label column - key, images and nothing else - and the
split is read as unlabelled:
task: classification
data:
train_path: data/train.csv
validation_path: data/validation.csv
test_path: data/to-predict.csv
mode: config_file
classes: [benign, malignant]
models:
- name: ours
input_shape: [224, 224]
blocks: 4
filters: 16predict() and evaluate() then differ in
what they can honestly do with it:
predict() works, and that is what the
split is for. label comes back as NA rather
than as a class name or as FALSE per class, because nothing
said.evaluate() refuses it by name. Scoring
against labels that are not there would either fail somewhere inside the
metric or, worse, report a number - and a zero in a table is read as a
result.class_balance() refuses it too, for
the same reason: counted anyway it says every class has no images, which
reads as a finding about the dataset rather than about there being
nothing to count.The key column is what makes the answer joinable to
whatever else you hold - a pixel size, a patient identifier, a date - so
it is worth writing even where the row order would do.
One layout cannot do this and the refusal says so.
nested_dirs makes the directory the label, so there is no
unlabelled form of it: an image has to sit somewhere, and where it sits
is the answer. Point test_path at a CSV instead, or at the
images themselves.
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: falseweights 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; available_weights() lists them.
With fit: false nothing trains and
predict(fit) 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.
Validation happens in this order, and the first two cost no epochs:
name: that no block has, a
callback watching a number nothing will produce. Before any path is read
- and before Python starts, for the checks R can make itself.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.
inspect_masks() runs that check on its own, and
platypus_fit(check_masks = FALSE) skips it.A refusal names the field and says what to do. The engine’s own
messages are translated into R conditions rather than passed through as
Python tracebacks, so
tryCatch(platypus_spec("run.yaml"), error = function(e) conditionMessage(e))
gives a sentence rather than a stack.
The file is not a second implementation. These describe the same experiment:
spec <- platypus_spec("run.yaml")
spec <- platypus_spec(
data = segmentation_data(
"data/train",
validation = "data/validation",
colormap = list(c(0, 0, 0), c(255, 255, 255))
),
models = list(u_net("first_try", input_shape = c(256, 256)))
)And as.list(spec) is the file, so a run built from
arguments can be written out and kept:
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 arguments are better inside a loop over something the file cannot express, like a vector of datasets.
Note that as.list() is also how the fields are reached:
the object returned by platypus_spec() carries the
specification on the Python side, so spec$task is
NULL while as.list(spec)$task is what you
want.