| Title: | Computer Vision for Medical Imaging |
|---|---|
| Description: | Semantic segmentation and object detection for medical images, from a folder of pictures to a trained model and a figure, in a handful of lines or one configuration file. Architectures, losses and training run on 'PyTorch' through 'pyplatypus', a 'Python' package which is installed and isolated automatically; nothing has to be set up by hand. R keeps the parts that make an analysis fast - reading and combining masks, drawing boxes, plotting, comparing models. |
| Authors: | Michal Maj [aut, cre] |
| Maintainer: | Michal Maj <[email protected]> |
| License: | MIT + file LICENSE |
| Version: | 0.3.0 |
| Built: | 2026-11-06 00:34:39 UTC |
| Source: | https://github.com/maju116/rplatypus |
Useful for looking at what the engine made of your arguments, and for writing it out.
## S3 method for class 'platypus_spec' as.list(x, ...)## S3 method for class 'platypus_spec' as.list(x, ...)
x |
A |
... |
Unused. |
A nested list.
## Not run: str(as.list(spec)) # The same thing a YAML file would hold, which is the point: a configuration file # and this code describe the same run. `yaml::write_yaml(as.list(spec), "run.yaml")` # produces a file the Python engine reads directly. ## End(Not run)## Not run: str(as.list(spec)) # The same thing a YAML file would hold, which is the point: a configuration file # and this code describe the same run. `yaml::write_yaml(as.list(spec), "run.yaml")` # produces a file the Python engine reads directly. ## End(Not run)
Names come from the installed 'albumentations', so whatever it offers is available and a misspelling is caught when the specification is built, with a suggestion.
augmentation_step(name, ...)augmentation_step(name, ...)
name |
The transform, for instance |
... |
Its parameters, for instance |
An augmentation specification.
augmentation_step("HorizontalFlip", p = 0.5)augmentation_step("HorizontalFlip", p = 0.5)
Six listings beside available_transforms() and available_weights(): the losses, the
metrics, the architectures, the optimisers, the callbacks and the tasks. Three columns -
the name you write in a specification, the arguments you may tune, and what it is for.
available_losses(task = "semantic_segmentation") available_metrics(task = "semantic_segmentation") available_models(task = "semantic_segmentation") available_optimizers() available_callbacks() available_tasks() available_separations() available_decodings()available_losses(task = "semantic_segmentation") available_metrics(task = "semantic_segmentation") available_models(task = "semantic_segmentation") available_optimizers() available_callbacks() available_tasks() available_separations() available_decodings()
task |
|
Worth having because the alternative is leaving R for a website. The number of named components grows with every release, and these are read from the engine's own schema, so a loss added there appears here with nothing in this package edited. Nothing is maintained by hand; a list maintained by hand is a list that will be wrong.
description is the component's summary line. The rest of what the engine knows about it -
including the measurements, where there are any - is on the configuration reference of the
engine's own site, which is generated from the same text.
A data frame with name, arguments and description. arguments is a single
string per row, comma-separated, because a column of vectors prints as something nobody
can read; strsplit(x, ", ") takes it apart.
Two listings carry one column more, and it is the column they are asked for:
available_decodings() has needs, the separation a decoding requires - empty where it
requires none, which is connected_components and is why that one is always available -
and available_separations() has head_channels, how wide a head that target makes.
Taken from the engine's own rows rather than named on this side, so a listing that grows
a field arrives here without R changing.
A detection specification chooses no loss component and no metrics at all. Its objective is
fixed, with box_loss on the model picking the box term, and every detection run reports
the same set - map_50, map_50_95, mean_matched_iou - which comes out of the run rather
than going into the specification.
So available_losses("object_detection") and available_metrics("object_detection") raise
a condition whose message says that, rather than returning a table with no rows. An empty
table reads as "there are none", which is a different and wrong answer.
available_models("classification") raises for the same kind of reason and a different
fact. A classifier chooses no architecture: the network is a contracting path, pooled,
with one dense layer on top, and encoder decides whose contracting path it is - ours
when it is unset, any of timm's when it names a backbone. So there is no architecture
field to write and a row here would invite one. encoders is the listing that question
actually has.
available_transforms() and available_weights(), the two that came first,
separations and decodings for the constructors these two listings name, and
platypus_spec(), where all of them are written.
## Not run: available_losses() #> name arguments #> 1 boundary region, alpha #> 2 cce label_smoothing #> 3 cce_dice cce_weight, smooth #> ... # Which metrics take an argument at all, and which do not. available_metrics()[, c("name", "arguments")] # The entry point, for somebody who has read nothing yet. available_tasks() # The two instance axes: what a model can predict, and how those channels become objects. available_separations()[, c("name", "head_channels")] available_decodings()[, c("name", "needs")] # Detection says what it has instead of a loss to choose. try(available_losses("object_detection")) ## End(Not run)## Not run: available_losses() #> name arguments #> 1 boundary region, alpha #> 2 cce label_smoothing #> 3 cce_dice cce_weight, smooth #> ... # Which metrics take an argument at all, and which do not. available_metrics()[, c("name", "arguments")] # The entry point, for somebody who has read nothing yet. available_tasks() # The two instance axes: what a model can predict, and how those channels become objects. available_separations()[, c("name", "head_channels")] available_decodings()[, c("name", "needs")] # Detection says what it has instead of a loss to choose. try(available_losses("object_detection")) ## End(Not run)
The transforms the installed 'albumentations' offers, and - for volumes - the ones it can actually apply to them.
available_transforms(rank = 2, pattern = NULL)available_transforms(rank = 2, pattern = NULL)
rank |
2 for images, 3 for volumes. |
pattern |
Optional regular expression to filter the names, for browsing: |
Worth asking rather than guessing. Support for volumes is uneven: most geometric transforms
work and several intensity ones raise from inside the library, so rank = 3 returns a
shorter list than rank = 2. A transform outside it is refused when the specification is
built, by name, rather than failing an hour into training.
The number of names at rank = 2 is a property of the installed 'albumentations'. The
number at rank = 3 is not, and is deliberately not written down here. The engine finds
that list by offering each transform a probe volume, and some of what it refuses fails for
the probe's reasons rather than the library's: a group of transforms want three channels
where the probe has one, and another group want a target - reference image, bounding box,
metadata of their own - that a probe cannot supply and a specification cannot name either.
A third group used to fail on the probe's size, which engine 0.8.0a10 fixed by retrying a refusal at a larger probe before believing it. That is also where a long-standing superstition went: the count used to vary between machines and was attributed to them, when the cause was one transform randomising its own parameters, so the same machine gave two answers. Escalating removed it. The count is still not promised, because it belongs to the installed library rather than to this package.
The check that decides your run is made when the specification is built, against your own shape and parameters.
A character vector of transform names.
augmentation_step(), which uses one.
## Not run: available_transforms() # everything, for images #> 118 names, with albumentations 2.0.8 available_transforms(rank = 3) # what volumes can take: fewer available_transforms(rank = 3, pattern = "Flip|Crop") #> [1] "AtLeastOneBBoxRandomCrop" "CenterCrop" "CenterCrop3D" #> [4] "CropAndPad" "CropNonEmptyMaskIfExists" "HorizontalFlip" #> [7] "RandomCrop" "RandomCrop3D" "RandomCropFromBorders" #> [10] "RandomResizedCrop" "RandomSizedBBoxSafeCrop" "RandomSizedCrop" #> [13] "VerticalFlip" # Which ones a volume cannot take, by name rather than by count. head(setdiff(available_transforms(), available_transforms(rank = 3)), 3) #> [1] "AdditiveNoise" "BBoxSafeRandomCrop" "ChannelDropout" ## End(Not run)## Not run: available_transforms() # everything, for images #> 118 names, with albumentations 2.0.8 available_transforms(rank = 3) # what volumes can take: fewer available_transforms(rank = 3, pattern = "Flip|Crop") #> [1] "AtLeastOneBBoxRandomCrop" "CenterCrop" "CenterCrop3D" #> [4] "CropAndPad" "CropNonEmptyMaskIfExists" "HorizontalFlip" #> [7] "RandomCrop" "RandomCrop3D" "RandomCropFromBorders" #> [10] "RandomResizedCrop" "RandomSizedBBoxSafeCrop" "RandomSizedCrop" #> [13] "VerticalFlip" # Which ones a volume cannot take, by name rather than by count. head(setdiff(available_transforms(), available_transforms(rank = 3)), 3) #> [1] "AdditiveNoise" "BBoxSafeRandomCrop" "ChannelDropout" ## End(Not run)
Weights are named rather than downloaded by hand, and a name is pinned to one commit of the
repository holding it - so "dsbowl-unet" means one set of numbers today and the same set next
year. This lists what is published, with the commit each name resolves to.
available_weights()available_weights()
Read the descriptions rather than only the names. A model's limitations decide whether it answers your question: the nuclei weights are semantic, so touching nuclei come back as one region and a count taken from them would be wrong.
A data frame with name, repo, filename, revision and description.
u_net() and the other architectures, whose weights argument takes a name from
here, a hf://owner/repo/file.safetensors@commit reference, or a path to a local file.
## Not run: available_weights() spec <- platypus_spec( data = segmentation_data("images/", "images/", colormap = binary_colormap), models = list(u_net("nuclei", input_shape = c(256, 256), blocks = 4, filters = 16, weights = "dsbowl-unet", fit = FALSE)) ) masks <- predict(platypus_fit(spec), split = "validation") ## End(Not run)## Not run: available_weights() spec <- platypus_spec( data = segmentation_data("images/", "images/", colormap = binary_colormap), models = list(u_net("nuclei", input_shape = c(256, 256), blocks = 4, filters = 16, weights = "dsbowl-unet", fit = FALSE)) ) masks <- predict(platypus_fit(spec), split = "validation") ## End(Not run)
Stochastic weight averaging. Past start, every epoch's weights are folded into a running
average and at the end that average becomes the model - the claim being that a point in
the middle of a flat region generalises better than whichever corner the last epoch
stopped in.
callback_swa(start = NULL, learning_rate = NULL, start_epoch = NULL)callback_swa(start = NULL, learning_rate = NULL, start_epoch = NULL)
start, start_epoch
|
When averaging begins - one of the two and never both.
|
learning_rate |
Hold the rate at this value once averaging begins. Unset leaves whatever the run was doing. Worth setting, and the reason is the whole mechanism: averaging is only worth something
while the weights are still moving, and a rate that has decayed towards zero produces a
set of nearly identical snapshots whose average is the last one. For the same reason a
run cannot ask for this and |
What it is worth is not what a table of scores shows. Three seeds, 60 epochs, lesions with ambiguous edges:
| run | Dice | volume bias | volume |error| |
| plain | 0.9639 ±0.0100 | +1.71% ±12.18% | 12.60% ±4.19% |
callback_swa() |
0.9667 ±0.0087 | +2.08% ±2.15% | 12.38% ±4.31% |
Dice does not move and neither does the per-case error - both are inside the seed noise. What moves is reproducibility: the plain runs' volume bias was +4.5%, +12.2% and -11.6% across three seeds, and with averaging -0.1%, +4.2% and +2.1%. A spread 5.7 times tighter, improving on all three seeds when paired, by 7.34% at 4.9 standard errors.
So read it as "the same model twice" rather than "a better model" - which is what
averaging is for, and the reason it belongs beside mask_volume() in any argument about
whether a measured volume can be compared across runs.
Batch-normalisation statistics are recomputed afterwards, by a pass over the training data. They have to be: an averaged weight tensor inherits the statistics of whichever epoch was last rather than averaging them, so without the pass the model is evaluated under the wrong normalisation and scores far worse than it should with nothing to say why. Models here have batch normalisation on by default, so this is nearly every run.
A callback specification, to be passed to a model constructor.
callbacks for the others, mask_volume() for reading a volume in millilitres.
callback_swa() callback_swa(start = 0.5, learning_rate = 1e-3)callback_swa() callback_swa(start = 0.5, learning_rate = 1e-3)
Things that happen between epochs. monitor is checked against the metrics the model
actually asks for, so a callback watching a number that will never arrive is refused
when the specification is built rather than waited on forever.
callback_early_stopping( monitor = "val_loss", patience = 10, min_delta = 0, restore_best = TRUE, start = NULL, start_epoch = NULL ) callback_model_checkpoint( path, monitor = "val_loss", save_best_only = TRUE, when = NULL ) callback_reduce_lr_on_plateau( monitor = "val_loss", factor = 0.1, patience = 5, min_lr = 0 ) callback_cosine_annealing(min_lr = 0, epochs = NULL) callback_linear_annealing(min_lr = 0, epochs = NULL) callback_step_decay(factor = 0.1, every = NULL, milestones = NULL, min_lr = 0) callback_csv_logger(path) callback_ema(decay = 0.999) callback_stop_after(hours) callback_terminate_on_collapse( floor = 1e-04, patience = 3, monitor = "val_foreground" ) callback_terminate_on_nan()callback_early_stopping( monitor = "val_loss", patience = 10, min_delta = 0, restore_best = TRUE, start = NULL, start_epoch = NULL ) callback_model_checkpoint( path, monitor = "val_loss", save_best_only = TRUE, when = NULL ) callback_reduce_lr_on_plateau( monitor = "val_loss", factor = 0.1, patience = 5, min_lr = 0 ) callback_cosine_annealing(min_lr = 0, epochs = NULL) callback_linear_annealing(min_lr = 0, epochs = NULL) callback_step_decay(factor = 0.1, every = NULL, milestones = NULL, min_lr = 0) callback_csv_logger(path) callback_ema(decay = 0.999) callback_stop_after(hours) callback_terminate_on_collapse( floor = 1e-04, patience = 3, monitor = "val_foreground" ) callback_terminate_on_nan()
monitor |
What to watch: That last one is the number Validation only. The training data is shuffled between epochs, so a case has no
identity in that pass and there is nothing honest to pool by - |
patience |
Epochs to wait before acting. For
|
min_delta |
Improvement smaller than this does not count. |
restore_best |
Put the best weights back when training stops, rather than keeping
the last - which are by definition the ones that were not improving. On by default.
Note that An averager beside this one wins. |
start, start_epoch
|
When the callback may begin to act - one of the two and
never both. For For |
path |
Where to write. For The checkpoint's extension decides its format, since the engine's |
save_best_only |
Only write when the watched quantity improves, which is the
default. |
when |
For It is an extra gate beside |
factor, min_lr
|
What the rate is multiplied by, and the floor it stops at.
|
epochs |
For |
every, milestones
|
For |
decay |
For |
hours |
For |
floor |
For |
Whether the watched quantity should rise or fall is worked out from its name: anything
ending in loss is minimised, everything else maximised.
A callback specification.
Every epoch reports foreground as well - for both passes, train_foreground and
val_foreground - which is the share of the prediction that is not background.
Nothing is compared against the truth, so it is not a measure of how good the model is;
it answers whether the model is still producing a segmentation at all.
It is reported whether or not you ask for it, and that is the point. A model that
collapses to predicting background everywhere is invisible in the numbers it already
produced: with include_background = TRUE the Dice is a mean over class channels and
the background channel is nearly perfect, so a collapsed model reports around 0.5 while
its loss falls contentedly. A collapse you have to opt into noticing is one you will not
notice.
callback_terminate_on_collapse() acts on it, evaluate() carries it as a column, and
callback_model_checkpoint()'s when can take a condition on it.
There is a twelfth, documented separately because it needs a page of its own:
callback_swa() averages the weights over the last part of the run. It watches
nothing and changes how reproducible a run is rather than how well it scores.
callback_swa() and callback_ema() both replace the model's weights when training
ends, so a run takes one of them; the engine refuses both. They answer the same
observation - the last epoch's corner is not the middle of the basin - with different
weightings. swa folds the tail uniformly from a declared start; ema weights recent
steps more and needs no start, because an average with a decay forgets the beginning by
itself.
ema is updated after every optimiser step, which is what makes its decay mean what
the literature means by it: 0.999 is 0.999 per step, so over 60 epochs of 200 batches
that is 12,000 updates and a half-life of roughly 700 steps. A run with few steps wants
a smaller number, because an average that forgets more slowly than the run is long never
leaves where it started.
Three of these compute the rate from the epoch rather than from how the run is going -
callback_cosine_annealing(), callback_linear_annealing() and
callback_step_decay() - and at most one of them may be used, because each
recomputes the rate every epoch and two of them would simply overwrite each other.
For the same reason a schedule and callback_reduce_lr_on_plateau() cannot be
combined, and the engine refuses the pair. That refusal replaced a sentence claiming
the opposite: a schedule recomputes the rate from the optimiser's initial value at the
end of every epoch, so it erases whatever the plateau callback cut. Measured on six
epochs of a loss that never improves, the rate with both configured follows the cosine
exactly - 0.0976, 0.0905, 0.0794 from 0.1 - where the plateau alone reaches 1e-6.
A schedule beside callback_swa() is fine and the order is no longer luck: swa pins
its constant rate after averaging begins, whatever position the callbacks sit in.
callback_cosine_annealing() decays the learning rate from its initial value to min_lr
along a cosine. It watches nothing: it is a function of how far through the run you are
rather than of how the run is going, which is the difference from
callback_reduce_lr_on_plateau() - and a run may legitimately want both.
Each parameter group decays from its own initial rate, so an encoder_learning_rate
set on a model is not flattened at the first epoch. That would have been invisible: the
history records one learning_rate, the first group's.
epochs unset means the model's own epochs, which is what you want - a cosine that
ends where the run ends.
callback_early_stopping(monitor = "val_dice", patience = 10)callback_early_stopping(monitor = "val_dice", patience = 10)
The number every decision about this task's metrics turned on, and the person who most needs it is the one who has just written a specification and not yet run anything. A prevalence a reader does not know is one they will discover from a score that looks fine.
class_balance(spec, split = "train")class_balance(spec, split = "train")
spec |
A |
split |
Which split to count: |
The sentence under the table is the engine's own, printed rather than reproduced: the threshold it fires below is the engine's number, and a copy of it here would be a second place for one fact to live.
A data frame of one row per class, in class order, with columns class, n and
share, of class platypus_class_balance so that printing it adds the engine's own
remark about how lopsided the split is. The engine's sentence is carried on the
remark attribute.
classification_data(), split()
## Not run: spec <- platypus_spec( data = classification_data("train/", "valid/", classes = c("benign", "malignant")), models = list(classifier("cnn", input_shape = c(128, 128))) ) class_balance(spec) ## End(Not run)## Not run: spec <- platypus_spec( data = classification_data("train/", "valid/", classes = c("benign", "malignant")), models = list(classifier("cnn", input_shape = c(128, 128))) ) class_balance(spec) ## End(Not run)
Paint a mask with its colormap
classes_to_colours(mask, colormap)classes_to_colours(mask, colormap)
mask |
Class indices, counted from 1: a |
colormap |
A list of RGB triples, one per class; see binary_colormap. |
The same shape with a trailing RGB axis, values 0-255.
mask <- matrix(c(1, 1, 2, 2), nrow = 2) dim(classes_to_colours(mask, binary_colormap))mask <- matrix(c(1, 1, 2, 2), nrow = 2) dim(classes_to_colours(mask, binary_colormap))
The data block for task = "classification". This title said the one label each of them
carries until labels_from arrived, and the field-prose ledger is what asked: the
engine rewrote its own one-line description of this block to name all three labellings,
and re-reading R's prose for it turned up a heading that had become false rather than a
sentence that had.
classification_data( train, validation = NULL, classes, labels_from = NULL, test = NULL, split = NULL, mode = c("nested_dirs", "config_file"), strict_labels = TRUE, window = NULL, subdirs = NULL, column_sep = ";", shuffle = TRUE )classification_data( train, validation = NULL, classes, labels_from = NULL, test = NULL, split = NULL, mode = c("nested_dirs", "config_file"), strict_labels = TRUE, window = NULL, subdirs = NULL, column_sep = ";", shuffle = TRUE )
train |
Path to the training data: a directory of class directories in
|
validation |
Path to the validation data, or |
classes |
The class names, in class order. Position is the class index the model
learns, which is why it is written rather than read off the directory listing: taken
from the filesystem the order would be alphabetical - reproducible, and unrelated to
anything - so a model trained where index 1 meant In There is no background entry. An image is one of these, and a picture of nothing in particular is a class somebody has to name. |
labels_from |
One CSV column per class, in class order, each cell
A cell that is not There is no threshold, and |
test |
Path to a held-out set, scored but never trained on. |
split |
A |
mode |
|
strict_labels |
Refuse an image whose label is not in Refused beside |
window |
How values in real units reach the model, for DICOM and NIfTI - see
|
subdirs |
Refused, and named rather than dropped because every sibling data constructor has it. For segmentation and detection a sample is a directory with the image in one subdirectory and what labels it in the other; a classification sample is one image whose label is the directory it sits in, so there is no second subdirectory. |
column_sep |
For |
shuffle |
Shuffle the training data between epochs. |
What this constructor is for is as much
the fields it has not got as the two it adds: colormap, labels, regions,
classes_from, channels_from and target_spacing all describe a mask or a grid, and
a label is neither.
Where the labelling is written follows from mode rather than being said twice. In
"nested_dirs" mode the directories under train are the classes, with the images
inside them - the layout nearly every image dataset ships in. In "config_file" mode
the CSV's label column holds the class name.
And labels_from is the third way, for a label that is a set rather than one of a
list of alternatives - a chest film with four findings is four independent questions.
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, so there is nothing a
multilabel = TRUE could have meant on a label column that structurally cannot say an
image has two findings.
A data specification, to be passed to platypus_spec().
classifier(), class_balance(), available_metrics()
classification_data("train/", "valid/", classes = c("benign", "malignant"))classification_data("train/", "valid/", classes = c("benign", "malignant"))
What to report beside the loss for task = "classification". These are a different
family from metrics rather than more of it: there is no overlap to measure, no
background class to leave out, and no smooth - a label is right or it is not.
metric_accuracy() metric_balanced_accuracy() metric_f1() metric_auc()metric_accuracy() metric_balanced_accuracy() metric_f1() metric_auc()
They are also scored differently, and that is measured rather than stylistic. An overlap metric is a formula on counts, so averaging a batch's scores is the score; three of these four are not means over images at all. On 4000 images at a prevalence of 3%, against a model whose own probabilities are the truth, pooling the whole pass against averaging batches of 8 moves balanced accuracy by 0.0035, macro F1 by 0.0734, and the AUC by up to 0.0371 in a direction that does not settle with a bigger batch - because at that prevalence 398 of 500 batches hold no positive at all, so a per-class recall is an average over whichever batches happened to contain the rare class. The engine therefore accumulates these over the pass and computes them once at the end.
metric_auc() is the engine's default and the other three are offered rather than
recommended. At a 3% prevalence a calibrated model never crosses the threshold, so
everything read off the hard prediction sees the same all-negative answer: accuracy spans
0.0100 across models of genuinely different quality and is not even monotone - a model
with no signal scores above two that have one - while the AUC spans 0.4631. Balanced
accuracy and F1 do not rescue it; they move by 0.0000 and -0.0005 between a real signal
and a collapse. Being threshold-free is the property that separates them.
So metric_accuracy() is here because it is the number people ask for, and its page says
what it costs.
A metric specification.
classifier(), metrics for masks, class_balance() for the prevalence these
measurements are all about
metric_auc() # The number people ask for, beside the one that can tell a signal from a collapse. classifier("cnn", input_shape = c(128, 128), metrics = list(metric_auc(), metric_accuracy()))metric_auc() # The number people ask for, beside the one that can tell a signal from a collapse. classifier("cnn", input_shape = c(128, 128), metrics = list(metric_auc(), metric_accuracy()))
The model block for task = "classification", and the one model constructor in this
package that is not named after an architecture. u_net(), linknet(),
res_u_net() and u_net_plus_plus() choose between four decoders that read the same
features; a classifier has nothing after the pooling but a dense layer, so the
contracting path is the architecture and encoder is the whole answer. The engine has
no architecture field here for that reason, and the name follows its own
build_classifier.
classifier( name, input_shape, channels = NULL, blocks = NULL, filters = NULL, block_width = NULL, pooling = NULL, head_dropout = NULL, dropout = NULL, batch_normalization = NULL, separable_conv = NULL, spatial_dropout = NULL, activation = NULL, initialiser = NULL, loss = NULL, metrics = NULL, optimizer = NULL, callbacks = NULL, augmentation = NULL, epochs = NULL, batch_size = NULL, weights = NULL, fit = NULL, encoder = NULL, pretrained = NULL, normalise = NULL, freeze_encoder = NULL, encoder_learning_rate = NULL )classifier( name, input_shape, channels = NULL, blocks = NULL, filters = NULL, block_width = NULL, pooling = NULL, head_dropout = NULL, dropout = NULL, batch_normalization = NULL, separable_conv = NULL, spatial_dropout = NULL, activation = NULL, initialiser = NULL, loss = NULL, metrics = NULL, optimizer = NULL, callbacks = NULL, augmentation = NULL, epochs = NULL, batch_size = NULL, weights = NULL, fit = NULL, encoder = NULL, pretrained = NULL, normalise = NULL, freeze_encoder = NULL, encoder_learning_rate = NULL )
name |
A name for the model, used in the score table and in file names. |
input_shape |
The size every image is read at, |
channels |
Input channels: 3 for colour, 1 for greyscale. |
blocks |
How many times the resolution is halved before the features are pooled, so
how much context the answer is made from. Ignored, and refused, when |
filters |
Filters in the first block, doubled at every level, so this and |
block_width |
Convolutions per block, at every level. Raising it adds depth without
adding levels, which is the lever to reach for when the objects are small enough that
halving the resolution again would lose them - a crop of one cell is exactly that
case. Ignored, and refused, with |
pooling |
How the feature map becomes one vector per image. |
head_dropout |
Drop this fraction of the pooled vector before the dense layer.
Separate from |
dropout, batch_normalization, separable_conv, spatial_dropout
|
Regularisation and
shape of the contracting path, as in |
activation, initialiser
|
The non-linearity between convolutions, and how the weights start. |
loss |
The objective. Fewer are admissible here than anywhere else in this package,
and the two that are - |
metrics |
What to report beside the loss. |
optimizer, callbacks, augmentation, epochs, batch_size, weights, fit
|
As in |
encoder |
A pretrained backbone instead of our own contracting path, by name - see
encoders for the table and what it is actually good for. With this, |
pretrained, freeze_encoder, encoder_learning_rate
|
How the backbone arrives and how
it is trained, as in |
normalise |
Normalise the input with the backbone's own ImageNet statistics,
defaulting to |
n_class is not an argument, for the reason it is not one on u_net() either: the
number of classes comes from classes in classification_data(), and a model that also
declared it could only ever disagree with its own data.
A model specification, to be passed to platypus_spec().
classification_data(), metric_auc()
classifier("cnn", input_shape = c(128, 128), blocks = 3, filters = 16) # A pretrained backbone instead of ours, with its early levels held still classifier("resnet", input_shape = c(224, 224), encoder = "resnet34", pretrained = TRUE, freeze_encoder = 2)classifier("cnn", input_shape = c(128, 128), blocks = 3, filters = 16) # A pretrained backbone instead of ours, with its early levels held still classifier("resnet", input_shape = c(224, 224), encoder = "resnet34", pretrained = TRUE, freeze_encoder = 2)
binary_colormap is black background, white foreground: the ordinary two-class
problem. voc_colormap is the twenty-one class palette used by Pascal VOC, kept
because datasets and other people's masks still arrive in it.
binary_colormap binary_labels voc_colormap voc_labelsbinary_colormap binary_labels voc_colormap voc_labels
A list of RGB triples, or a character vector of names.
The labels are the matching class names, for figure legends and mask_coverage().
Position in the list is the class index throughout, counting from 1.
Four exported objects rather than functions. binary_colormap and
voc_colormap are lists of integer vectors of length three - one RGB triple per
class, 2 entries and 21 entries respectively - where position in the list is the class
index, counting from 1. binary_labels and voc_labels are character vectors of the
matching class names, the same length as their colormap and in the same order, so the
nth label names the class the nth triple draws.
mask_coverage(matrix(c(1, 2, 2, 1), 2), labels = binary_labels)mask_coverage(matrix(c(1, 2, 2, 1), 2), labels = binary_labels)
The inverse of classes_to_colours(). Anything matching no colour becomes background, and
coverage in the result says how much of the mask that was - a number near 1 means the
colormap does not describe this dataset, which is the quietest way to train on nothing.
colours_to_classes(mask, colormap, tolerance = 0)colours_to_classes(mask, colormap, tolerance = 0)
mask |
An RGB array: |
colormap |
A list of RGB triples, one per class. |
tolerance |
Allow this much difference per channel. Useful after lossy resizing,
though masks should be resized with |
A list with classes (indices counted from 1) and coverage (the fraction of
pixels that matched a colour).
coloured <- classes_to_colours(matrix(c(1, 2, 2, 1), nrow = 2), binary_colormap) colours_to_classes(coloured, binary_colormap)$classescoloured <- classes_to_colours(matrix(c(1, 2, 2, 1), nrow = 2), binary_colormap) colours_to_classes(coloured, binary_colormap)$classes
The windows radiologists use, as c(centre, width) in Hounsfield units. Pass a name to
segmentation_data() rather than the numbers; this is here for looking them up.
ct_windows()ct_windows()
Held here rather than fetched from the engine so that looking up a constant does not require starting Python. A test checks the two lists against each other whenever the engine is available, so the copy cannot quietly drift.
A named list of c(centre, width) pairs.
ct_windows()$lung ct_windows()[["soft_tissue"]]ct_windows()$lung ct_windows()[["soft_tissue"]]
A model's decode is a list of these, not one: several decodings share one forward
pass, so a second one costs seconds where a second training run costs an hour.
evaluate() then returns a row per model and decoding.
decode_connected_components(minimum_area = NULL) decode_nearest_seed(minimum_area = NULL) decode_watershed(minimum_area = NULL) decode_follow_flows(steps = NULL, step_size = NULL, minimum_area = NULL)decode_connected_components(minimum_area = NULL) decode_nearest_seed(minimum_area = NULL) decode_watershed(minimum_area = NULL) decode_follow_flows(steps = NULL, step_size = NULL, minimum_area = NULL)
minimum_area |
Drop an object smaller than this many pixels. Unset keeps everything, and that is the engine's default on purpose: the right floor is a property of the data rather than of the method. A nucleus at 256x256 in the Data Science Bowl runs from 4 pixels up, so a floor picked here would silently delete the smallest real objects in one dataset while letting speckle through in another. It is applied after the objects are formed, so it removes answers rather than changing how they were reached. |
steps |
For |
step_size |
For |
Each declares which separation it can read, and asking for one that cannot read the
model's signal is refused when the specification is built rather than at the end of a
run. decode_connected_components() reads none of them - it ignores the separation
signal entirely - which is exactly why it belongs in every model's list: it is the row
the signal's own row has to be read against. Left unset, a model decodes with its own
signal's decoding plus that yardstick.
A decoding block, to be passed inside a model's decode list.
list(decode_nearest_seed(), decode_connected_components()) decode_follow_flows(step_size = 0.5)list(decode_nearest_seed(), decode_connected_components()) decode_follow_flows(step_size = 0.5)
A detector cannot be reloaded without its anchors, and when they were fitted rather than
named in the specification this is the only record. export_weights() writes them into the
sidecar for the same reason.
detection_anchors(object, model = NULL)detection_anchors(object, model = NULL)
object |
A fit from |
model |
Which model, when the specification trained several. |
A list: anchors as three groups of pairs, fitted saying whether they were
fitted to your data, and when they were, the mean overlap they achieve and a data
frame with one row per anchor.
## Not run: anchors <- detection_anchors(fit) anchors$mean_iou # ~0.88 fitted on BCCD, against 0.65 for COCO's nine anchors$per_anchor # one row per anchor # `fitted` says whether these were fitted to the data or taken from the # specification. A detector cannot be reloaded without them, so they travel # with the weights. ## End(Not run)## Not run: anchors <- detection_anchors(fit) anchors$mean_iou # ~0.88 fitted on BCCD, against 0.65 for COCO's nine anchors$per_anchor # one row per anchor # `fitted` says whether these were fitted to the data or taken from the # specification. A detector cannot be reloaded without them, so they travel # with the weights. ## End(Not run)
A detector that knows three classes, followed by a classifier that knows eighty: find the
objects, cut them out, hand the pieces to something else. predict() returns boxes in each
image's own pixels precisely so they can be used on the photograph they came from, and this
is what uses them.
detection_crops( object, model = NULL, split = "test", score_threshold = NULL, context = 0, size = NULL, fit = "letterbox", ... )detection_crops( object, model = NULL, split = "test", score_threshold = NULL, context = 0, size = NULL, fit = "letterbox", ... )
object |
A |
model |
Which model, if the fit holds several. |
split |
Which split's images to read. |
score_threshold |
Crop at this confidence instead of the specification's
|
context |
Expand every box by this fraction of its own width and height, per side. |
size |
|
fit |
|
... |
Unused. |
Cut from the image at its native size, not from the square the network sees. Cropping the letterboxed frame would cut a shrunken object and then scale it back up: on a 1024-wide photograph fed to a 416 model, every crop would be two fifths of the size the data allows.
Four more decisions, each of which taken the other way changes what a downstream model sees without changing anything you would notice:
Rounded outward, so the crop contains the whole box. Rounding to nearest loses up to a pixel per side, which on a 26-pixel platelet is a twelfth of the object.
context grows with the box, not by a number of pixels, so one value suits a platelet
and a white cell. Zero by default: a detector's box is tight by construction, and a
classifier trained on whole objects does worse on something cut exactly at the edge - but
expanding silently would put things nobody asked for into every crop.
size letterboxes rather than stretches. Resizing a tall box to a square changes the
aspect of every non-square object, and a classifier then sees a shape that does not occur
in nature. fit = "stretch" is there for models trained that way, and has to be asked for.
Read at operating_point, not at the specification's score_threshold. That one sits
near zero so that average precision integrates the whole ranking - hundreds of boxes an
image, almost all of them the tail it exists to measure over. Cropping those would hand a
classifier mostly noise.
dropped is the number of boxes that clipped to nothing. A model may place a box off the
frame and predict() reports what it said, so at a low threshold some arrive with no
extent at all; those are dropped and counted rather than the arrays quietly coming back
shorter than the detections.
A list with one entry per image, each a list of key, crops (a list of
height x width x channels arrays), boxes, scores, labels, names and dropped.
## Not run: found <- detection_crops(fit, size = c(224, 224)) length(found[[1]]$crops) plot_masks(found[[1]]$crops[[1]]) # look at one # Everything above the operating point, as one flat list ready for another model. pieces <- unlist(lapply(found, `[[`, "crops"), recursive = FALSE) ## End(Not run)## Not run: found <- detection_crops(fit, size = c(224, 224)) length(found[[1]]$crops) plot_masks(found[[1]]$crops[[1]]) # look at one # Everything above the operating point, as one flat list ready for another model. pieces <- unlist(lapply(found, `[[`, "crops"), recursive = FALSE) ## End(Not run)
The detection counterpart of segmentation_data(). Two layouts, the same two as
segmentation: nested_dirs expects one directory per sample holding an images and an
annotations subdirectory, and config_file expects a CSV with images and
annotations columns. A detection dataset in Pascal VOC's own layout - one flat
JPEGImages beside one flat Annotations - is neither, and the way in is to write
three CSV files listing the pairs; examples/detect_blood_cells.py in the engine does
exactly that for BCCD in a dozen lines.
detection_data( train, validation = NULL, classes, test = NULL, split = NULL, mode = c("nested_dirs", "config_file"), annotation_format = c("pascal_voc", "labelme"), coordinates = NULL, strict_labels = TRUE, window = NULL, subdirs = c("images", "annotations"), column_sep = ";", shuffle = TRUE )detection_data( train, validation = NULL, classes, test = NULL, split = NULL, mode = c("nested_dirs", "config_file"), annotation_format = c("pascal_voc", "labelme"), coordinates = NULL, strict_labels = TRUE, window = NULL, subdirs = c("images", "annotations"), column_sep = ";", shuffle = TRUE )
train, validation
|
Paths to the training and validation data. |
classes |
Class names, in class order. Position is the class index the model
learns, and that is why it is written down rather than read from the files: sorted,
BCCD's three come out There is no background entry, unlike a segmentation colormap. A pixel always belongs to some class, so segmentation needs a name for "none of them"; a region of an image holding no object is simply not a box. One class is therefore a legitimate thing to ask for. |
test |
Optional test data. Annotations are read if they are there, and the split is
then scoreable; without them it can be predicted and |
split |
Divide This is the only way to divide one folder for detection. |
mode |
|
annotation_format |
|
coordinates |
How to read a Pascal VOC box. The format's own convention is 1-based
and inclusive of both ends, so a ten-pixel-wide box runs
|
strict_labels |
Refuse an object whose class is not in |
window |
How values in real units reach the model, for DICOM and NIfTI. See
|
subdirs |
For |
column_sep |
For |
shuffle |
Shuffle the training data between epochs. |
A data specification, to be passed to platypus_spec().
detection_data("train/", "valid/", classes = c("RBC", "WBC", "Platelets"))detection_data("train/", "valid/", classes = c("RBC", "WBC", "Platelets"))
Every value plot_masks(), plot_boxes(), overlay_mask() and overlay_agreement()
use when you do not give them one: which colours mean found, missed and invented, one
colour per class for plot_anchors(), how much of the image an overlay lets through, how
a box is labelled, and which boxes are worth drawing at all.
drawing_style()drawing_style()
They belong to the engine - pyplatypus.style - and are mirrored here so that drawing
needs no Python. A test holds the two together, so the same call draws the same picture
from either package.
Reach for this when you want to match a figure rather than restate it: passing
colours = drawing_style()$box_colours to something else keeps one source, and typing
the hex codes starts a second.
A named list: agreement_colours, box_colours, class_colours,
overlay_alpha, box_label_format and box_min_score.
plot_masks(), plot_boxes(), overlay_agreement()
drawing_style()$agreement_colours[["missed"]] # The same list the engine returns, which is what keeps the two drawings the same. str(drawing_style())drawing_style()$agreement_colours[["missed"]] # The same list the engine returns, which is what keeps the two drawings the same. str(drawing_style())
What a sonographer writes down. A fetal head circumference is the perimeter of the ellipse fitted to the skull, and no overlap score stands in for it: two masks with the same Dice can be eight times apart in millimetres, depending on whether they are wrong in a ring all the way round or wrong in a ragged edge.
ellipse_measure(mask, spacing = NULL, class = 2L)ellipse_measure(mask, spacing = NULL, class = 2L)
mask |
A mask of class indices, as |
spacing |
Pixel size in millimetres, length 2, in the array's own order: rows first,
then columns. Unset measures in pixels. Measure on the grid the spacing belongs to:
a prediction resized to the model's input shape has pixels of a size nobody recorded,
so this wants |
class |
Which class is the structure, numbered from 1 as the colormap and |
The fit is not iterative. For a uniform filled ellipse the covariance of the member pixels
has eigenvalues a^2 / 4 and b^2 / 4, so the axes come out of a 2 x 2
eigendecomposition, and the circumference is Ramanujan's second approximation - right to
about 1e-7 relative at the eccentricities a fetal skull has, where pi * (a + b), which
is what one reaches for instead, is wrong by 1.5% at an axis ratio of 0.7.
The largest connected piece is taken and its holes filled before anything is measured. A prediction is not an ellipse: it can arrive in two pieces or with a hole in the middle, and the second moments of either belong to neither - a hole pushes the variance outwards and a stray island drags the centre.
A one-row data frame with major, minor and circumference - the two
semi-axes and the perimeter, in millimetres when spacing was given and in pixels
otherwise. All three are 0 for fewer than three pixels of that class, because a region
that small has no second moment; that is an ordinary outcome for a model that found
nothing, not an error.
mask_volume() for the same argument in three dimensions, and
instance_sizes() for it per object.
# A filled ellipse of 60 x 40 pixels, on half-millimetre pixels: semi-axes of 30 mm and # 20 mm, whose perimeter is 158.8 mm. rows <- matrix(rep(seq_len(201), 301), nrow = 201) columns <- matrix(rep(seq_len(301), each = 201), nrow = 201) mask <- ifelse(((columns - 150) / 60)^2 + ((rows - 100) / 40)^2 <= 1, 2L, 1L) ## Not run: # Wrapped because this one does cross to the engine - unlike [mask_volume()], which is a # count times a product and stays in R so that a reader needs no Python to run its page. ellipse_measure(mask, spacing = c(0.5, 0.5)) ## End(Not run)# A filled ellipse of 60 x 40 pixels, on half-millimetre pixels: semi-axes of 30 mm and # 20 mm, whose perimeter is 158.8 mm. rows <- matrix(rep(seq_len(201), 301), nrow = 201) columns <- matrix(rep(seq_len(301), each = 201), nrow = 201) mask <- ifelse(((columns - 150) / 60)^2 + ((rows - 100) / 40)^2 <= 1, 2L, 1L) ## Not run: # Wrapped because this one does cross to the engine - unlike [mask_volume()], which is a # count times a product and stays in R so that a reader needs no Python to run its page. ellipse_measure(mask, spacing = c(0.5, 0.5)) ## End(Not run)
u_net() and the other model constructors take an encoder, which replaces the
built-in contracting path with a backbone architecture, and a separate pretrained
flag that loads that backbone's ImageNet weights. This page is the measurement behind
them, because the honest summary is narrower than the idea.
u_net("polyps", input_shape = c(256, 256),
encoder = "resnet34", # the architecture
pretrained = TRUE, # and its ImageNet weights
freeze_encoder = 5) # held still while the decoder settles
Two flags rather than one, so naming an architecture never reaches the network on a machine that has none - a hospital network often has none.
Measured on three datasets, one per modality, three seeds each, every configuration compared at its best validation Dice with the best weights restored. Training sets of 40 to 100 images on purpose, because that is the regime transfer learning is for.
| configuration | DS Bowl, microscopy | SIIM-ACR, chest X-ray | Kvasir-SEG, endoscopy |
| built-in encoder, 1.9M parameters | 0.8460 | 0.0531 | 0.5830 |
encoder = "resnet34", 9.0M |
0.7891 | 0.1073 | 0.5670 |
the same, pretrained, freeze_encoder = 5 |
0.8333 | 0.1203 | 0.5932 |
the same, encoder_learning_rate = lr/10 |
0.8326 | 0.1011 | 0.5329 |
Four things come out of it, and each repeats on all three:
freeze_encoder, not encoder_learning_rate aloneFreezing was the better of the two everywhere. On Kvasir a tenth of the rate is the worst row in the table - below the built-in encoder and below the same backbone trained from nothing. Giving one rate to the whole network is worse still: a randomly initialised decoder's gradients undo the transferred weights before the decoder has learned anything worth having.
The best pretrained configuration beats the built-in encoder on one dataset of three, by 0.0102, against a seed-to-seed spread of 0.0170 - inside the noise. And endoscopy is the fairest test available within medical imaging: camera photographs of tissue, three channels, natural lighting. With the backbone frozen for a whole run on DS Bowl the score is 0.6068 against 0.8460, so the features genuinely do not transfer to these images.
On Kvasir the built-in encoder needed 150, 110 and 83 epochs across its three seeds; pretrained and frozen needed 64, 82 and 83. That halving is the one benefit that showed up consistently, and on a dataset that takes a day to train it is the difference that matters.
On SIIM-ACR, where a pneumothorax covers a median 0.59% of the frame, every backbone variant roughly doubles the built-in encoder's score - and the built-in encoder's spread between seeds there (0.0586) is larger than its own mean, with two of three seeds never learning at all. This is a reason to be able to swap the encoder that has nothing to do with ImageNet.
Measure on your own images before trusting any of this: the regime where transfer helps
is narrow, and three public datasets are three public datasets. The engine ships
examples/compare_pretrained_encoders.py, which is this measurement parameterised.
Any convolutional family timm names, as timm names it: "resnet18", "resnet34",
"resnet50", "resnext50_32x4d", "efficientnet_b0" through "b3",
"mobilenetv3_large_100", "densenet121", "regnety_040", "vgg16",
"inception_v3" and others. blocks then says how many of the backbone's stages to
use.
Two kinds are refused rather than approximated:
A 3D input_shape. ImageNet is images, so there is nothing to transfer to a
volume. Refused while the specification is read, before anything is downloaded.
The patch-based families ("convnext_*", "swin_*"). Their features begin at a
quarter of the input, so the half-resolution level a U-shaped decoder recovers fine
detail from does not exist. Inventing it would put a made-up level exactly where the
detail comes from.
The finest level is always platypus's own: every ImageNet stem halves the input, so no
backbone has a full-resolution feature, and the decoder's output head sits on one. That
stage is trained from scratch, which is why freeze_encoder and
encoder_learning_rate leave it alone - it is the one part with everything to learn.
Nothing to do. The environment this package builds always includes the backbones, which
cost 10 MB against PyTorch's own several hundred - not a trade worth making anyone opt
into. On a GTX 10-series card or older see platypus_use_torch().
models for the arguments, platypus_spec() for the specification.
One row per model: what it is, how big it is, how long it trained, and how it scored.
evaluate(object, ...) ## S3 method for class 'platypus_fit' evaluate(object, split = "validation", at = NULL, ...)evaluate(object, ...) ## S3 method for class 'platypus_fit' evaluate(object, split = "validation", at = NULL, ...)
object |
|
... |
Unused. |
split |
Which data to score on: |
at |
Which grid to score on. |
The loss column is only comparable between models trained on the same loss, which is
why loss_function is there beside it. A Focal-Tversky of 0.05 is not better than a
CCE-Dice of 0.14; it is not even the same question.
The metrics have a condition of their own and load_shape is it. Each model is scored on
the grid it reads at, so a model at input_shape = c(512, 512) is measured against a
truth reduced to 512 while one that tiles 2048 is measured against the truth as stored.
Both numbers are a real Dice of a real prediction against a real mask, and they are not
answers to the same question: measured on retinal vessels, that difference reported a
resized model as +0.0187 better where the honest figure was +0.0033, and the bias
runs the other way on clDice because upsampling smooths a prediction. When the rows
disagree about the grid this warns, once; the column is there either way, because a table
outlives the session that printed it.
at = "source" is the answer to that warning rather than a workaround for it: every
prediction goes back onto the grid of the file it came from and is scored against the
truth as stored, so the rows answer one question and load_shape reads source. The
grid is the files' own and you cannot name another, which is deliberate - a grid you
name resamples the truth, so the thing both models are judged against would itself
become an interpolation somebody chose. Here only predictions move, and what that costs
is in predict(): interpolation softens a boundary, and where reading cropped anatomy
away the mask comes back padded with background, which means not examined. For a
comparison that is correct rather than unfair - a model whose field of view missed the
organ did not segment it - but a 0 arriving that way is a result and not a fault. The
loss is not reported there: a loss belongs to the objective a model trained with, on the
grid it trained on.
A data frame, one row per model.
The mean over cases, since the engine's 0.8.0a40. A case's own score is a ratio of
sums - where a case arrived in tiles their overlap counts are added and the formula applied
once, which is reassembly rather than a choice of average - and the split's figure is the
mean of those. So this row is exactly mean(evaluate_cases(fit)$dice), which is what
anybody comparing the two expected and did not previously get: the engine divided an
epoch's totals by the number of batches, so a short last batch weighed more than the
others and the figure moved with batch_size. On a tiled run the difference was larger
still, because tiles were averaged rather than reassembled - worth +0.033 to +0.049 of Dice
on the retinal-vessel runs.
evaluate_cases() pools a group differently again, by adding the counts of the images
in it, which is the better statistic for a patient rather than a slice. maju116/rplatypus#196.
evaluate is also exported by the evaluate package, which knitr depends on, so the
two mask each other depending on which was attached last. It is a generic here, so
evaluate(fit) dispatches on the fit's class whichever one you reach - and
rplatypus::evaluate(fit) says so outright. The name is kept because it is the word for
what this does; platypus_fit() carries a prefix only because fit is a generic in the
tidymodels packages and a plain fit would have been theirs to own.
## Not run: evaluate(fit) # one row per model, on the validation split evaluate(fit, split = "test") # Two models of different shapes compared on one grid - the files' own. evaluate(fit, at = "source") # A split with no masks is refused by name rather than scored against nothing; # `predict()` still works on it. ## End(Not run)## Not run: evaluate(fit) # one row per model, on the validation split evaluate(fit, split = "test") # Two models of different shapes compared on one grid - the files' own. evaluate(fit, at = "source") # A split with no masks is refused by name rather than scored against nothing; # `predict()` still works on it. ## End(Not run)
evaluate() gives one row per model: one number for a whole set. This gives one row per
case - each image, or each patient when group_by is set - because one number hides the
distribution, and the distribution is usually the finding. A model averaging Dice 0.86
that scores 0.1 on three patients has a failure mode, and the mean is where it hides.
evaluate_cases(object, ...) ## S3 method for class 'platypus_fit' evaluate_cases( object, model = NULL, split = "validation", group_by = NULL, at = NULL, ... ) ## S3 method for class 'platypus_cases' summary(object, ...)evaluate_cases(object, ...) ## S3 method for class 'platypus_fit' evaluate_cases( object, model = NULL, split = "validation", group_by = NULL, at = NULL, ... ) ## S3 method for class 'platypus_cases' summary(object, ...)
object |
A |
... |
Unused. |
model |
Which model, by the name in the specification. Defaults to the first. |
split |
Which data to score on. |
group_by |
A pattern picking a group out of each case's name, as in
|
at |
Which grid to score on, as in |
summary() on the result gives the distribution: mean, standard deviation, median,
range, and which case scored worst.
Tiled models are scored on the whole image, not on tiles. Overlaps are accumulated across a case's tiles and the metric applied once, which is that image's score exactly; averaging the tiles' scores would be a different number, and unkind to any case whose object happens to straddle a boundary.
A data frame with one row per case, of class platypus_cases.
split_dataset() for keeping a patient out of two sets in the first place.
## Not run: cases <- evaluate_cases(fit) summary(cases) head(cases[order(cases$dice), ]) # the ones worth looking at evaluate_cases(fit, group_by = "^(patient\\\\d+)_") # On the grid the files are on, which is what makes two models comparable. evaluate_cases(fit, at = "source") ## End(Not run)## Not run: cases <- evaluate_cases(fit) summary(cases) head(cases[order(cases$dice), ]) # the ones worth looking at evaluate_cases(fit, group_by = "^(patient\\\\d+)_") # On the grid the files are on, which is what makes two models comparable. evaluate_cases(fit, at = "source") ## End(Not run)
Detection only. On a segmentation fit this refuses by name and points at
evaluate_cases(). The name carries no detection_ prefix on purpose: this is an S3
generic, so a future task gains a method under the same name rather than a second
spelling of one idea.
evaluate_classes(object, ...) ## S3 method for class 'platypus_fit' evaluate_classes(object, model = NULL, split = "validation", ...)evaluate_classes(object, ...) ## S3 method for class 'platypus_fit' evaluate_classes(object, model = NULL, split = "validation", ...)
object |
A fit from |
... |
Unused. |
model |
Which model, when the specification trained several. |
split |
Which split to score. |
The row that matters on unbalanced data, which is most detection data: BCCD has 4,155
red cells against 372 white and 361 platelets, so a single number is a number about red
cells. evaluate() deliberately carries no overall precision or recall for the same
reason - averaging them over classes needs a weighting, and every choice of weighting is
a different claim.
A data frame, one row per class: average precision at IoU 0.5, the mean overlap
of the boxes that matched, the number of true boxes, and precision and recall at the
model's operating_point.
## Not run: evaluate_classes(fit, "bccd") # The row that matters on unbalanced data, which is most data: BCCD has 4155 red # cells against 372 white and 361 platelets, so a single number is a number about # red cells. ## End(Not run)## Not run: evaluate_classes(fit, "bccd") # The row that matters on unbalanced data, which is most data: BCCD has 4155 red # cells against 372 white and 361 platelets, so a single number is a number about # red cells. ## End(Not run)
Detection only. On a segmentation fit this refuses by name and points at
evaluate_cases(). The name carries no detection_ prefix on purpose: this is an S3
generic, so a future task gains a method under the same name rather than a second
spelling of one idea.
evaluate_images(object, ...) ## S3 method for class 'platypus_fit' evaluate_images( object, model = NULL, split = "validation", score_threshold = NULL, ... )evaluate_images(object, ...) ## S3 method for class 'platypus_fit' evaluate_images( object, model = NULL, split = "validation", score_threshold = NULL, ... )
object |
A |
... |
Unused. |
model |
Which model, by the name in the specification. Defaults to the first. |
split |
Which data to score on. |
score_threshold |
The confidence at which the counts are read. Unset, the
specification's |
evaluate() gives one row per model and evaluate_classes() one row per class. This
gives one row per image, which is the question asked next: not how well on average, but
which pictures it fails on. A mean over a split says 0.86; it does not say the misses
are four frames where the stain is dark, and that difference is usually something about
the data rather than the model.
Sort by missed for the frames it cannot see, and by mean_matched_iou for the ones
where it finds everything and places it badly. Those are different problems - the first
is usually the data, the second usually the anchors.
There is no average precision here, deliberately. Average precision is the area under a precision-recall curve, so it is a property of a ranking over a whole dataset; computed on one image with three boxes it moves on a single box's rank and means nothing. What is meaningful for one picture is counting and overlap, which is what the columns carry.
mean_matched_iou is NA rather than 0 when nothing matched. An image where the model
found nothing and one where it found badly-placed boxes are different failures, and a
zero would merge them.
A data frame of class platypus_images, one row per image: key, n_truth,
n_predicted, matched, missed, ignored, spurious and mean_matched_iou.
ignored counts predictions attributed to a truth marked difficult in a Pascal VOC
annotation, which Pascal VOC's own evaluation sets aside: calling one matched would
claim the model found something the benchmark left out, and calling it spurious
would charge it for the same thing. So the counts are
n_predicted == matched + ignored + spurious, and ignored is zero for a dataset
that marks nothing difficult.
evaluate_classes() for one row per class, evaluate_cases() for the
segmentation counterpart.
## Not run: images <- evaluate_images(fit) head(images[order(-images$missed), ]) # the frames it cannot see head(images[order(images$mean_matched_iou), ]) # the ones it places badly ## End(Not run)## Not run: images <- evaluate_images(fit) head(images[order(-images$missed), ]) # the frames it cannot see head(images[order(images$mean_matched_iou), ]) # the ones it places badly ## End(Not run)
Writes safetensors plus a .json sidecar recording what the weights are for: architecture,
input shape, channels, classes, and whatever else you pass. Loading refuses a model they do not
belong to, which is what the sidecar is for - weights trained on a different colormap with the
same number of classes load cleanly and predict nonsense.
export_weights(object, path, model = NULL, ...)export_weights(object, path, model = NULL, ...)
object |
|
path |
Where to write, with or without the |
model |
Which model, by the name in the specification. Defaults to the first. |
... |
Extra fields for the sidecar, for instance |
Anything in ... joins the sidecar. The data they were trained on and its licence belong there:
weights whose provenance lives in somebody's memory cannot be used by anybody else, including
you in a year.
The path written, invisibly, with the sidecar's contents attached as the recorded
attribute - so you can see what was put beside the weights without opening the file.
## Not run: export_weights(fit, "weights/my-run", data = "our 2026 cohort", licence = "internal use only") ## End(Not run)## Not run: export_weights(fit, "weights/my-run", data = "our 2026 cohort", licence = "internal use only") ## End(Not run)
The convention optical flow has used for twenty years, and a convention rather than a
palette decision - which is why it is not in drawing_style(). Nothing about it is a
choice this project gets to make differently from everyone else's figures, and a reader
who has seen one flow visualisation can read this one.
flow_to_colours(flow)flow_to_colours(flow)
flow |
A two-channel vector field: |
What it is for: debugging a separation_flows() model. A field that is right everywhere
except reversed looks obviously reversed here and identical in any loss. predict() with
type = "channels" is where the field comes from.
A zero vector is black, which is also what the background is, and that is honest - the background has no direction.
A height x width x 3 array of integers, 0-255.
separation_flows() for the model that predicts one.
field <- array(0, dim = c(2, 2, 3)) field[1, 1, 1] <- 1 # pointing down field[2, 1, 2] <- 1 # pointing right shown <- flow_to_colours(field) # Two directions, two hues; and no vector, no colour. !identical(shown[1, 1, ], shown[1, 2, ]) && all(shown[2, 3, ] == 0L)field <- array(0, dim = c(2, 2, 3)) field[1, 1, 1] <- 1 # pointing down field[2, 1, 2] <- 1 # pointing right shown <- flow_to_colours(field) # Two directions, two hues; and no vector, no colour. !identical(shown[1, 1, ], shown[1, 2, ]) && all(shown[2, 3, ] == 0L)
Reads a sample of one split's masks and reports what the specification's colours, or list of labels, actually matched. Trains nothing and loads no model.
inspect_masks(spec, split = c("train", "validation", "test"), limit = 500)inspect_masks(spec, split = c("train", "validation", "test"), limit = 500)
spec |
|
split |
|
limit |
How many masks to read. They are spread across the split rather than taken from its start, because datasets arrive sorted and a prefix would answer about the beginning. |
platypus_fit() does a small version of this before every run and refuses when a
declared class appears in none of the masks it read. This is the same question asked
deliberately, over as much of the data as you want - which is what to reach for when
that refusal looks wrong.
A one-row data frame: unmatched, samples_checked, total_samples, and
present_classes and missing_classes as comma-separated strings so the frame
stays printable - region names where the specification gave regions, and class
numbers otherwise, which is what the engine's own refusal does. The positions are
attached as integer vectors in the attributes present and missing either way, so
anything reading them keeps working.
missing_classes is the one to act on. A class that appears in no mask cannot be
learned: its channel of the target is zero everywhere, so there is no gradient towards
it and the model is never shown the thing it is being asked to find. Either the colours
do not describe these masks, or they name a class the data does not contain.
With regions the classes are named rather than numbered, because a position is the
one thing a reader cannot resolve: missing_classes: 1 means the second region, and
counting entries in a configuration file to find out which is the step nobody takes. The
positions are still there, as the missing and present attributes.
That naming matters most for the one failure regions invites: of = 3 against a label
map holding 4 is a channel of zeros, so the model trains to predict nothing and scores a
Dice of 0 for that region while every other number in the run looks ordinary. The engine
refuses it before training and names the region; this is how to ask the same question over
more of the data than the refusal reads.
unmatched is the fraction of mask pixels matching no entry, which fall back to the
background class. Read it knowing that it scales with the size of the thing being
segmented, so it is loud for a large structure and almost silent for a small lesion.
Measured on masks that are white, with a colormap asking for a colour that is not
there: a foreground covering 20% of the image gives 19.8%, and one covering 0.6% gives
0.61% - which is less than the 0.72% that JPEG compression leaves around the edge of a
perfectly correct mask. That is why the refusal is built on class presence instead.
## Not run: inspect_masks(spec) inspect_masks(spec, split = "validation", limit = 2000) ## End(Not run)## Not run: inspect_masks(spec) inspect_masks(spec, split = "validation", limit = 2000) ## End(Not run)
Masks stored one file per object, read as a label map that keeps each object's identity instead of uniting them into one foreground. The question this answers is how many nuclei, and how big is each one, which a semantic mask cannot answer however good it is: two touching nuclei are one region in it.
instance_data( train, validation = NULL, test = NULL, split = NULL, mode = c("nested_dirs", "config_file"), window = NULL, subdirs = c("images", "masks"), column_sep = ";", shuffle = TRUE )instance_data( train, validation = NULL, test = NULL, split = NULL, mode = c("nested_dirs", "config_file"), window = NULL, subdirs = c("images", "masks"), column_sep = ";", shuffle = TRUE )
train |
Training data: a directory, a configuration file, or a |
validation |
A validation set, or |
test |
A test set to predict on. Scored only if it carries masks. |
split |
Divide |
mode |
|
window |
How values in real units reach the model, for DICOM and NIfTI. See
|
subdirs |
For |
column_sep |
For |
shuffle |
Shuffle the training data between epochs. |
Why this exists rather than an argument on segmentation_data(). Every field
instance_data() takes, that one takes too - what makes it a different constructor is
the five it refuses: colormap, labels, regions, channels_from and
target_spacing do not apply to a directory of per-object masks, and the engine rejects
them by name. A constructor whose contract is "these five do not apply" is a contract
rather than a label, and it gets that refusal from its own argument list rather than from
a table somebody has to keep in step.
The consequence worth knowing before you choose: task is still read off the
constructors rather than typed. Calling this one is how a specification says it is
about instances, so there is no task = "instance_segmentation" to write and nothing to
keep in agreement with it. See platypus_spec().
What the model does with this is the other half, and it is one block: a model's
separation says what the network predicts - rim or flows - and decode says how
those channels become objects. Neither belongs here, because they describe the run and
not the data.
A data specification, to be passed to platypus_spec().
segmentation_data() for one mask per image, platypus_spec()
instance_data("stage1_train/", "stage1_validation/")instance_data("stage1_train/", "stage1_validation/")
One row per object, beside the rows instance_sizes() gives. voxels and area_mm2
answer how big; these answer how stretched - the semi-axes of the ellipse an object's
second moments imply, largest first, and for an image the eccentricity that shape
analysis actually reports.
instance_shapes(labels, spacing = NULL)instance_shapes(labels, spacing = NULL)
labels |
An instance label map from |
spacing |
The size of one voxel in millimetres: two numbers for an image, three for a volume, in the array's own order. Unset measures the sampling grid, and the column names say so. |
The fit is not iterative, and it is the same arithmetic ellipse_measure() applies to a
whole mask: for a uniform solid the variance along an axis is semi_axis^2 / (rank + 2),
so the axes come out of one eigendecomposition per object. What this adds is that it is
per object in one pass: the alternative a caller writes - ellipse_measure() on
labels == k, once per object - rescans the whole array every time, and on a 512 x 512
map of 1939 objects that is 1989 ms against 143.
A volume gets three axes and no eccentricity, because an ellipsoid has no single one; the ratio of two columns is your division rather than a name this package invents.
A data frame with object and the semi-axes largest first - major_mm and
minor_mm for an image, with middle_mm between them for a volume, named _px instead
when spacing was not given. An image also gets eccentricity. One row per object
present, in increasing object number; an object with fewer than rank + 1 voxels has no
second moment and gets zeros, which is an ordinary outcome rather than an error.
eccentricity is unitless and it is not spacing-free. It describes whichever space
it was computed in, so on anisotropic voxels the two disagree completely and in both
directions: a circle of 80 pixels at spacing = c(2, 1) reads 0.0000 on the grid and
0.8660 in millimetres, while an 80 by 40 pixel ellipse at spacing = c(1, 2) reads
0.8667 on the grid and 0.0685 physically - physically almost a circle, which the grid
calls strongly elongated. Pass your voxel size and read the _mm columns unless you
mean the grid; the suffix on the axes is what says which of the two a row is.
instance_sizes() for how big each object is, ellipse_measure() for the same
fit over a whole mask, and overlay_instances() to look at them.
labels <- matrix(0L, nrow = 41, ncol = 81) rows <- matrix(rep(seq_len(41), 81), nrow = 41) columns <- matrix(rep(seq_len(81), each = 41), nrow = 41) labels[((rows - 21) / 10)^2 + ((columns - 21) / 18)^2 <= 1] <- 1L labels[(rows - 21)^2 + (columns - 61)^2 <= 81] <- 2L ## Not run: # Wrapped because this one crosses to the engine - unlike `instance_sizes()`, which is a # count times a product and stays in R so that a reader needs no Python to run its page. # An eigendecomposition per object is not arithmetic a reader verifies by eye, which is # the line `maju116/pyplatypus#270` drew between the two. instance_shapes(labels, spacing = c(0.5, 0.5)) ## End(Not run)labels <- matrix(0L, nrow = 41, ncol = 81) rows <- matrix(rep(seq_len(41), 81), nrow = 41) columns <- matrix(rep(seq_len(81), each = 41), nrow = 41) labels[((rows - 21) / 10)^2 + ((columns - 21) / 18)^2 <= 1] <- 1L labels[(rows - 21)^2 + (columns - 61)^2 <= 81] <- 2L ## Not run: # Wrapped because this one crosses to the engine - unlike `instance_sizes()`, which is a # count times a product and stays in R so that a reader needs no Python to run its page. # An eigendecomposition per object is not arithmetic a reader verifies by eye, which is # the line `maju116/pyplatypus#270` drew between the two. instance_shapes(labels, spacing = c(0.5, 0.5)) ## End(Not run)
One row per object. voxels is the count, which means nothing outside one scanner, and
spacing turns it into a number a reader can compare - the same argument mask_volume()
makes for one class, applied per object. That is the question this whole feature is for:
how many nuclei, and how big is each one.
instance_sizes(labels, spacing = NULL)instance_sizes(labels, spacing = NULL)
labels |
An instance label map from |
spacing |
The size of one voxel in millimetres: two numbers for an image, three for a volume. Unset means the result counts voxels and says nothing about size. |
The unit is decided by the rank, and the column is named after it rather than being called
size with a note somewhere: area_mm2 for an image, volume_ml for a volume. A column
whose unit lives in the documentation is a column that ends up in a paper as a bare number.
Without spacing there is no such column at all, rather than a count pretending to be a
measurement.
A data frame with object, voxels and, when spacing is given, area_mm2 or
volume_ml. One row per object present, in increasing object number; an object number
absent from the map gets no row, because a model that found objects 1, 2 and 4 found
three objects.
overlay_instances() to look at them, mask_volume() for one class of a
semantic mask.
labels <- matrix(0L, nrow = 8, ncol = 8) labels[2:4, 2:4] <- 1L labels[6:7, 6:8] <- 2L instance_sizes(labels) # 0.5 mm pixels: object 1 is 9 pixels, so 9 * 0.25 = 2.25 mm^2. instance_sizes(labels, spacing = c(0.5, 0.5))labels <- matrix(0L, nrow = 8, ncol = 8) labels[2:4, 2:4] <- 1L labels[6:7, 6:8] <- 2L instance_sizes(labels) # 0.5 mm pixels: object 1 is 9 pixels, so 9 * 0.25 = 2.25 mm^2. instance_sizes(labels, spacing = c(0.5, 0.5))
Dice and IoU count a voxel the same wherever it sits, which is why a model can reach 0.88
on Dice while its volumes run a fifth too large: the overshoot is all at the boundary, and
for a small lesion the boundary is most of the object. This adds mean(phi * p), where
phi is the signed distance to the truth's boundary - negative inside, positive outside -
so a voxel predicted far outside the truth costs in proportion to how far.
loss_boundary(region = NULL, alpha = NULL)loss_boundary(region = NULL, alpha = NULL)
region |
The overlap term this is added to - any other loss. Unset uses the engine's
default, which is Dice. |
alpha |
How much of the objective is the region term, Not 0.5, and that was measured. At 0.5 the surface term owns half the objective while the prediction is still random, which is where it is unstable: three seeds gave a volume error spread of ±28.28% against the baseline's ±3.11%, with every number worse. Left unset here on purpose - the number is a measurement and belongs in the one place the measurement lives, so that an engine that learns better cannot be contradicted from this side. |
What it buys and what it costs, measured. Three seeds, 60 epochs, lesions with ambiguous edges, against Dice alone:
| loss | Dice | volume bias | volume |error| |
loss_dice() |
0.9525 ±0.0027 | -5.18% ±3.11% | 16.90% ±0.90% |
loss_boundary() |
0.9411 ±0.0093 | -0.06% ±4.15% | 22.17% ±2.88% |
It trades accuracy per case for being unbiased over a series. "Is there a lesion"
wants the overlap; "has it grown since March" wants a volume that is not systematically
wrong, and no single number answers both - which is the same point mask_volume() makes
about reporting millilitres beside an overlap score. The bias improvement is suggestive on
three seeds, since the baseline's own bias wanders ±3.11%; the costs are established.
It costs time too. The signed distance transform runs once per sample, in the loader's
workers: 5.3 ms for a 256x256 image, 23.4 ms for a 64x64x32 volume and 709.8 ms at 128^3.
On large volumes give platypus_fit() workers, or the transform becomes the training.
A loss specification, to be passed to a model constructor.
losses for the losses that can be the region term - every one of them
except the three that score numbers and this one, held to the engine's own union by
a test rather than counted here - and mask_volume() for reading a volume in
millilitres rather than voxels.
loss_boundary() loss_boundary(region = loss_focal(gamma = 2)) loss_boundary(region = loss_dice(), alpha = 0.95)loss_boundary() loss_boundary(region = loss_focal(gamma = 2)) loss_boundary(region = loss_dice(), alpha = 0.95)
The objective the model is trained against. Every one of these works unchanged on volumes as well as images.
loss_iou(smooth = 1) loss_dice(smooth = 1) loss_generalized_dice(smooth = 1) loss_cce(label_smoothing = 0) loss_cce_dice(cce_weight = 0.5, smooth = 1) loss_focal(gamma = 2, alpha = NULL) loss_tversky(alpha = 0.5, smooth = 1) loss_focal_tversky(alpha = 0.5, gamma = 1, smooth = 1) loss_combo(alpha = 0.5, ce_ratio = 0.5) loss_mse(where = NULL) loss_l1(where = NULL) loss_smooth_l1(beta = NULL, where = NULL) loss_lovasz(per_image = FALSE)loss_iou(smooth = 1) loss_dice(smooth = 1) loss_generalized_dice(smooth = 1) loss_cce(label_smoothing = 0) loss_cce_dice(cce_weight = 0.5, smooth = 1) loss_focal(gamma = 2, alpha = NULL) loss_tversky(alpha = 0.5, smooth = 1) loss_focal_tversky(alpha = 0.5, gamma = 1, smooth = 1) loss_combo(alpha = 0.5, ce_ratio = 0.5) loss_mse(where = NULL) loss_l1(where = NULL) loss_smooth_l1(beta = NULL, where = NULL) loss_lovasz(per_image = FALSE)
smooth |
Added to numerator and denominator to keep an empty class finite. |
label_smoothing |
For cross-entropy, train towards |
cce_weight |
For |
gamma |
For focal losses, how hard to discount what is already classified well - a pixel or voxel for a mask, a whole image for a label - so the gradient comes from what is not. 0 is plain cross-entropy and 2 is the usual value. |
alpha |
For Tversky, the weight on false negatives; raising it buys recall.
Note that |
ce_ratio |
For |
where |
For the three regression losses, where the target means anything and so
where the error is averaged. A flow field is defined on the objects and undefined off
them - there is no direction towards a centre for a pixel in no object - so the builder
writes zero on the background, and |
beta |
For |
per_image |
For |
There is one documented separately because it needs a page of its own: loss_boundary()
adds the signed distance to the truth's boundary on top of any of these, which removes
the systematic volume bias at some cost in per-case accuracy.
Three of these score numbers rather than classes - loss_mse(), loss_l1() and
loss_smooth_l1() - and they are not alternatives to the others. A class target has
nothing for them to measure and a continuous one has nothing for an overlap to measure,
so they belong in exactly one place: the loss of separation_flows(), where the target
is a vector field. Measured against the pinned engine rather than assumed: giving one as
any segmentation model's own loss is refused by name - semantic or instance - and
giving a class loss to separation_flows() is refused by name the other way round.
One of these weights the classes rather than treating them alike -
loss_generalized_dice(), by the inverse square of how much of the picture each class
occupies - and the argument usually made for it is not the argument for it here. It is
introduced in the literature as a fix for Dice being dominated by the background, and
loss_dice() in this package is a mean over the class channels: a lesion in 1% of the
frame already carries the same weight as the 99% around it. Measured on a 1% target, with
one prediction that finds the lesion and leaks a false positive of equal size and one that
misses it and gets the background perfect:
| loss | finds it | misses it | prefers finding by |
loss_dice() |
0.1658 | 0.4731 | 0.3073 |
loss_cce() |
0.1600 | 0.1600 | nothing at all |
loss_cce_dice() |
0.1629 | 0.3166 | 0.1536 |
loss_generalized_dice() |
0.3278 | 0.9511 | 0.6233 |
So what it adds is weight beyond an equal share - on that target the classes come out
weighted 1 to 9801 - and the objective that is genuinely dominated is the per-pixel half
of loss_cce_dice(): to cross-entropy a false positive and a missed lesion of the same
size are the same number of wrong pixels, which is the indifference in that row. Two
things follow. With every class the same size this is exactly loss_dice() pooled over
the classes, so on a balanced problem it costs nothing and buys nothing. And it is not a
third loss_tversky(): Tversky moves the balance between false positives and false
negatives by a number you choose, loss_focal_tversky() reweights the hard pixels, and
this reweights classes by how much of the image they occupy, computed from each batch
rather than set by hand - so a batch holding no lesion at all is scored on flat weights,
which is an argument for a larger batch_size on a sparse dataset rather than against the
loss.
This page said an instance model's own until the pin moved to 0.8.0a22, and it was
true rather than wrong: the refusal lived on the instance class alone, and a semantic
model accepted loss_mse() and trained with it, reporting a validation loss of 0.0014
for a mask that was half missing where loss_cce() reaches a Dice of 1.0000. The default
where = "foreground" is what made it look as though it had worked - it evaluates the
objective only inside the labelled objects and leaves the background unconstrained. The
engine refuses it at either task now (pyplatypus#223).
A loss specification, to be passed to a model constructor.
separation_flows(), whose loss is the one place the regression losses belong
loss_focal(gamma = 2) loss_tversky(alpha = 0.7) loss_smooth_l1(beta = 0.1, where = "foreground")loss_focal(gamma = 2) loss_tversky(alpha = 0.7) loss_smooth_l1(beta = 0.1, where = "foreground")
A quick sanity check before training: a foreground of half a percent is a problem the loss has to be chosen for, and it is better to know before the run than after.
mask_coverage(mask, labels = NULL, n_class = NULL)mask_coverage(mask, labels = NULL, n_class = NULL)
mask |
Class indices, counted from 1. |
labels |
Optional names for the classes. Also what says how many classes there are, when the mask does not hold them all - see the note below. |
n_class |
How many classes the mask is meant to have, when |
A data frame with one row per class, including the classes with no pixels.
A class above the largest value present used to have no row at all, which was the
one case this function is for: tabulate() sizes its table by the largest value it
sees, so a three-class mask holding only 1 and 2 reported two classes and said nothing
about the third. The highest-numbered class is the usual position of a rare foreground,
so the row that went missing was the one worth reading.
maju116/rplatypus#255.
mask <- matrix(1L, 8, 8); mask[2:4, 2:4] <- 2L mask_coverage(mask, labels = c("background", "nucleus")) # A class with 0 pixels is the thing to look for: its target channel is zero # everywhere, so there is no gradient towards it and the model is never shown # what it is being asked to find.mask <- matrix(1L, 8, 8); mask[2:4, 2:4] <- 2L mask_coverage(mask, labels = c("background", "nucleus")) # A class with 0 pixels is the thing to look for: its target channel is zero # everywhere, so there is no gradient towards it and the model is never shown # what it is being asked to find.
A voxel count answers nothing on its own: the same tumour is 4000 voxels on one scanner and 12000 on another, and neither number goes in a report. Multiplied by the volume of a voxel it becomes millilitres, which is comparable between scans, between patients and against whatever the radiologist measured by hand.
mask_volume(mask, spacing, class = 2L)mask_volume(mask, spacing, class = 2L)
mask |
A mask of class indices, as |
spacing |
Voxel size in millimetres, length 3. |
class |
Which class to measure, numbered from 1 as the colormap and |
The volume in millilitres.
mask <- array(1L, dim = c(10, 10, 4)) mask[3:6, 3:6, 2:3] <- 2L # 32 voxels of 2 x 2 x 5 mm: 32 * 20 mm^3 = 640 mm^3 = 0.64 ml mask_volume(mask, spacing = c(2, 2, 5))mask <- array(1L, dim = c(10, 10, 4)) mask[3:6, 3:6, 2:3] <- 2L # 32 voxels of 2 x 2 x 5 mm: 32 * 20 mm^3 = 640 mm^3 = 0.64 ml mask_volume(mask, spacing = c(2, 2, 5))
What gets reported. Unlike the losses these are computed on the hard prediction - the mask you will actually be handed - because a metric taken on probabilities reads higher than the model deserves.
metric_iou(smooth = 1, include_background = TRUE) metric_dice(smooth = 1, include_background = TRUE) metric_tversky(alpha = 0.5, smooth = 1, include_background = TRUE) metric_cldice(iterations = 5, smooth = 1, include_background = FALSE) metric_surface_dice(tau, smooth = 1, include_background = FALSE)metric_iou(smooth = 1, include_background = TRUE) metric_dice(smooth = 1, include_background = TRUE) metric_tversky(alpha = 0.5, smooth = 1, include_background = TRUE) metric_cldice(iterations = 5, smooth = 1, include_background = FALSE) metric_surface_dice(tau, smooth = 1, include_background = FALSE)
smooth |
Smoothing. Zero is allowed and is the honest choice for a reported number: with smoothing, a class absent from an image scores a perfect 1. |
include_background |
Average over the background class as well. In medical images
the background is usually most of the picture, so leaving it in can turn a model that
found nothing into one that looks respectable. Papers report foreground only. It
defaults to |
alpha |
For Tversky, the weight on false negatives. |
iterations |
For clDice, how many times the skeleton is peeled. It has to reach the half-width of the thickest structure, or its core is never reduced to a centreline and the score is refused rather than reported - an empty skeleton otherwise reads as a perfect 1. |
tau |
For |
Three of these ask the same question and two ask different ones. metric_iou(),
metric_dice() and metric_tversky() all ask how much of the mask coincides and differ
in how they weigh the ways of being wrong. metric_cldice() asks whether the structure
coincides, and metric_surface_dice() asks how far wrong the boundary is - which is
the question a contour somebody has to approve is actually judged by, and the one nothing
here reported until the pin moved to 0.8.0a26.
For something thin and connected clDice is the question worth asking, and it and Dice disagree in a way worth knowing. Measured on a nine-pixel vessel: one drawn two pixels thin scores Dice 0.875, and the same vessel severed in the middle scores 0.947 - Dice prefers the severed one. clDice gives them 1.000 and 0.943.
Its limit, also measured: at one pixel wide a displacement of one pixel leaves no overlap at all, and every overlap-based metric reads 0, this one included.
metric_surface_dice() is the other kind of disagreement. loss_boundary()'s own page
reports a model at Dice 0.9525 whose volumes run 5% small and 17% wrong per case, and no
overlap column says that: a displaced boundary and a correct one of the same area score
alike. This is normalised surface Dice (Nikolov et al., 2018), symmetric on purpose - the
truth's surface near the prediction is the boundary's recall, the prediction's surface
near the truth is its precision - and it is the one metric here that reads the voxel
spacing, so tau is in millimetres when target_spacing has put the data on a common
grid and in voxels when it has not. It is also what loss_boundary() optimises, so until
now that loss was being judged by the metric it exists to improve on.
Like clDice it is a property of a whole mask rather than of a batch of counts, so on a
tiled run (splits) it is reported per case by evaluate_cases() and is absent from
the epoch history - a per-batch average of per-tile boundaries would be a number about
nothing. A callback asking to watch val_surface_dice on such a run is refused when the
configuration is read, the way one watching val_cldice is - so the choice to tile and
the choice of what to stop on are settled together, before anything is trained.
include_background defaults to FALSE for those two and TRUE for the three overlap
metrics, and each has its own measured reason. The background's skeleton lies inside the
background by construction, so that class scores 1 for clDice whatever the model did -
averaging it in moved 0.7865 to 0.8933 on a three-pixel vessel. The background's
boundary is the foreground's boundary plus the edge of the picture, and that edge agrees
between any two masks whatever happened in them: measured on four random pairs at
tau = 2, the background scored 0.6110 to 0.7030 against the foreground's 0.5583 to
0.6266 - higher every time. That is agreement on the field of view rather than on the
object.
Metrics for one label per image are a different family and have their own page.
metric_accuracy(), metric_balanced_accuracy(), metric_f1() and metric_auc() score
a classifier, where there is no overlap to measure and no background class to leave out -
see classification_metrics. None of the names on this page appears there and none of
theirs appears here, which is the honest shape: a Dice over one label would be the label
itself.
A metric specification.
metric_dice(include_background = FALSE) # Vessels, airways, neurons, cracks: thin and connected, where Dice asks the wrong thing. metric_cldice() # How far wrong the boundary is, rather than how much of the area coincides. 1.5 mm # because that is this structure's inter-observer variability, not because of anything # here. metric_surface_dice(tau = 1.5)metric_dice(include_background = FALSE) # Vessels, airways, neurons, cracks: thin and connected, where Dice asks the wrong thing. metric_cldice() # How far wrong the boundary is, rather than how much of the area coincides. 1.5 mm # because that is this structure's inter-observer variability, not because of anything # here. metric_surface_dice(tau = 1.5)
Four members of the U-shaped family. They take the same arguments and differ only in
what happens between the contracting and expanding paths: u_net concatenates its skip
connections, linknet adds them, res_u_net makes each block residual, and
u_net_plus_plus fills in the nested skip pathways.
u_net( name, input_shape, channels = NULL, n_class = NULL, blocks = NULL, filters = NULL, block_width = NULL, dropout = NULL, batch_normalization = NULL, separable_conv = NULL, spatial_dropout = NULL, upsample = NULL, deep_supervision = NULL, activation = NULL, initialiser = NULL, loss = NULL, metrics = NULL, optimizer = NULL, callbacks = NULL, augmentation = NULL, epochs = NULL, batch_size = NULL, splits = NULL, weights = NULL, fit = NULL, encoder = NULL, pretrained = NULL, normalise = NULL, freeze_encoder = NULL, encoder_learning_rate = NULL, separation = NULL, decode = NULL ) u_net_plus_plus( name, input_shape, channels = NULL, n_class = NULL, blocks = NULL, filters = NULL, block_width = NULL, dropout = NULL, batch_normalization = NULL, separable_conv = NULL, spatial_dropout = NULL, upsample = NULL, deep_supervision = NULL, activation = NULL, initialiser = NULL, loss = NULL, metrics = NULL, optimizer = NULL, callbacks = NULL, augmentation = NULL, epochs = NULL, batch_size = NULL, splits = NULL, weights = NULL, fit = NULL, encoder = NULL, pretrained = NULL, normalise = NULL, freeze_encoder = NULL, encoder_learning_rate = NULL, separation = NULL, decode = NULL ) res_u_net( name, input_shape, channels = NULL, n_class = NULL, blocks = NULL, filters = NULL, block_width = NULL, dropout = NULL, batch_normalization = NULL, separable_conv = NULL, spatial_dropout = NULL, upsample = NULL, deep_supervision = NULL, activation = NULL, initialiser = NULL, loss = NULL, metrics = NULL, optimizer = NULL, callbacks = NULL, augmentation = NULL, epochs = NULL, batch_size = NULL, splits = NULL, weights = NULL, fit = NULL, encoder = NULL, pretrained = NULL, normalise = NULL, freeze_encoder = NULL, encoder_learning_rate = NULL, separation = NULL, decode = NULL ) linknet( name, input_shape, channels = NULL, n_class = NULL, blocks = NULL, filters = NULL, block_width = NULL, dropout = NULL, batch_normalization = NULL, separable_conv = NULL, spatial_dropout = NULL, upsample = NULL, deep_supervision = NULL, activation = NULL, initialiser = NULL, loss = NULL, metrics = NULL, optimizer = NULL, callbacks = NULL, augmentation = NULL, epochs = NULL, batch_size = NULL, splits = NULL, weights = NULL, fit = NULL, encoder = NULL, pretrained = NULL, normalise = NULL, freeze_encoder = NULL, encoder_learning_rate = NULL, separation = NULL, decode = NULL )u_net( name, input_shape, channels = NULL, n_class = NULL, blocks = NULL, filters = NULL, block_width = NULL, dropout = NULL, batch_normalization = NULL, separable_conv = NULL, spatial_dropout = NULL, upsample = NULL, deep_supervision = NULL, activation = NULL, initialiser = NULL, loss = NULL, metrics = NULL, optimizer = NULL, callbacks = NULL, augmentation = NULL, epochs = NULL, batch_size = NULL, splits = NULL, weights = NULL, fit = NULL, encoder = NULL, pretrained = NULL, normalise = NULL, freeze_encoder = NULL, encoder_learning_rate = NULL, separation = NULL, decode = NULL ) u_net_plus_plus( name, input_shape, channels = NULL, n_class = NULL, blocks = NULL, filters = NULL, block_width = NULL, dropout = NULL, batch_normalization = NULL, separable_conv = NULL, spatial_dropout = NULL, upsample = NULL, deep_supervision = NULL, activation = NULL, initialiser = NULL, loss = NULL, metrics = NULL, optimizer = NULL, callbacks = NULL, augmentation = NULL, epochs = NULL, batch_size = NULL, splits = NULL, weights = NULL, fit = NULL, encoder = NULL, pretrained = NULL, normalise = NULL, freeze_encoder = NULL, encoder_learning_rate = NULL, separation = NULL, decode = NULL ) res_u_net( name, input_shape, channels = NULL, n_class = NULL, blocks = NULL, filters = NULL, block_width = NULL, dropout = NULL, batch_normalization = NULL, separable_conv = NULL, spatial_dropout = NULL, upsample = NULL, deep_supervision = NULL, activation = NULL, initialiser = NULL, loss = NULL, metrics = NULL, optimizer = NULL, callbacks = NULL, augmentation = NULL, epochs = NULL, batch_size = NULL, splits = NULL, weights = NULL, fit = NULL, encoder = NULL, pretrained = NULL, normalise = NULL, freeze_encoder = NULL, encoder_learning_rate = NULL, separation = NULL, decode = NULL ) linknet( name, input_shape, channels = NULL, n_class = NULL, blocks = NULL, filters = NULL, block_width = NULL, dropout = NULL, batch_normalization = NULL, separable_conv = NULL, spatial_dropout = NULL, upsample = NULL, deep_supervision = NULL, activation = NULL, initialiser = NULL, loss = NULL, metrics = NULL, optimizer = NULL, callbacks = NULL, augmentation = NULL, epochs = NULL, batch_size = NULL, splits = NULL, weights = NULL, fit = NULL, encoder = NULL, pretrained = NULL, normalise = NULL, freeze_encoder = NULL, encoder_learning_rate = NULL, separation = NULL, decode = NULL )
name |
Unique within a specification. It names this model's outputs, its rows in every score table, and the directory its run record is written to - so two models called the same thing would overwrite one record and report it as two, which is why the engine refuses a repeat rather than disambiguating it. |
input_shape |
Tile size: |
channels |
Input channels. 3 for colour, 1 for greyscale. Derived from
|
n_class |
Removed. The number of classes comes from the data - |
blocks |
How many times the resolution is halved. Taken from the weights file when
|
filters |
Filters in the first block; doubled at each level. Taken from the weights
file when |
block_width |
Convolutions per block. Two is the usual U-Net block; one trains
faster and holds less, three rarely pays for itself. It composes with every
architecture, including the residual blocks of |
dropout |
Drop this fraction of activations in every block during training, 0 to switch it off. The first thing to reach for when the training loss keeps falling and the validation loss does not. |
batch_normalization |
Normalise each batch's activations between convolutions,
which is what lets a deeper network train at all at a useful learning rate. On by
default; the reason to turn it off is a batch so small that its statistics are noise,
which is the case a 3D run with |
separable_conv |
Depthwise-separable convolutions. Far fewer parameters, a little slower to converge. |
spatial_dropout |
Drop whole feature maps rather than individual activations. On an
image the neighbouring pixels of a dropped activation carry almost the same
information, so ordinary dropout removes less than it appears to; dropping a channel
removes a feature. Does nothing unless |
upsample |
Interpolate and convolve instead of using a transposed convolution. |
deep_supervision |
Train every decoder depth, not only the last. |
activation |
One of |
initialiser |
One of |
loss |
The objective trained against, see losses and |
metrics |
A list of metrics, see metrics. These are reported beside the loss and are computed on the mask rather than on the objective, which is what makes them comparable between models: a Focal-Tversky of 0.05 and a CCE-Dice of 0.14 are not the same question, and their Dice is. A callback watching For a classifier the list is a different family - see classification_metrics, where none of these names appears and the default is measured rather than chosen. |
optimizer |
An optimiser, see optimizers. |
callbacks |
A list of callbacks, see callbacks. |
augmentation |
A list of |
epochs, batch_size
|
Training length and batch size. |
splits |
Cut each image into a grid of tiles instead of shrinking it: one number
per spatial dimension, so |
weights |
A checkpoint to start from, or to use as-is with |
fit |
Set |
encoder |
A pretrained-architecture backbone to use as the contracting path
instead of the built-in encoder, named as timm names it: |
pretrained |
Load the backbone's ImageNet weights. Separate from |
normalise |
Normalise the input with the backbone's own ImageNet statistics.
Defaults to |
freeze_encoder |
Hold the transferred layers still for this many epochs, then
train them. Use this rather than |
encoder_learning_rate |
A separate, smaller rate for the layers that arrived
pretrained. The full-resolution stage in front of them is platypus's own and starts
random, so it keeps the optimiser's rate. Giving this instead of |
separation |
What the network predicts so that touching objects can be told apart -
It may be left out when Left out with no |
decode |
A list of decodings turning those channels into objects, e.g.
|
input_shape is the tile the model sees: two numbers for images, three for volumes.
Its length is what makes a model 2D or 3D - there is no separate switch. Every spatial
dimension must divide by 2^blocks, or the decoder cannot line up with the encoder,
and that is checked when the specification is built rather than part-way through
training.
A model specification, to be passed to platypus_spec().
Every argument below except name and input_shape is NULL unless you set it, and a
NULL is left out of the request entirely - so the engine's own default applies. The
defaults are therefore written down once, in pyplatypus, rather than here as well:
channels 3 blocks 4 filters 16 block_width 2 dropout 0 batch_normalization TRUE separable_conv FALSE spatial_dropout TRUE upsample FALSE deep_supervision FALSE activation "relu" initialiser "he_normal" loss cce metrics iou optimizer adam callbacks none epochs 10 batch_size 8 fit TRUE
The second reason matters more than tidiness. A value sent is indistinguishable from a
value chosen, so defaults travelling from here would silence the engine's own knowledge:
weights naming a published file records its architecture, blocks and filters, and
those are adopted only where the specification stayed silent. Sending blocks = 4
because that is what R happened to say would override a file that knows better.
u_net("unet", input_shape = c(256, 256), blocks = 4, filters = 16) # A large image, tiled rather than shrunk u_net("hd", input_shape = c(256, 256), splits = c(4, 6))u_net("unet", input_shape = c(256, 256), blocks = 4, filters = 16) # A large image, tiled rather than shrunk u_net("hd", input_shape = c(256, 256), splits = c(4, 6))
How the weights are updated. optimizer_adam() unless there is a reason not to: it adapts
the step per parameter, so it usually trains without anything tuned except
learning_rate. optimizer_sgd() with momentum is the recipe most of the
classification literature's best numbers were produced with, in exchange for depending on
a tuned rate and usually a schedule - see callbacks for those.
optimizer_adam( learning_rate = 0.001, beta_1 = 0.9, beta_2 = 0.999, eps = 1e-08, weight_decay = 0, amsgrad = FALSE ) optimizer_adamw( learning_rate = 0.001, beta_1 = 0.9, beta_2 = 0.999, eps = 1e-08, weight_decay = 0.01 ) optimizer_sgd( learning_rate = 0.001, momentum = 0, nesterov = FALSE, weight_decay = 0 ) optimizer_rmsprop( learning_rate = 0.001, alpha = 0.99, momentum = 0, eps = 1e-08, weight_decay = 0 ) optimizer_adagrad( learning_rate = 0.001, lr_decay = 0, eps = 1e-10, weight_decay = 0 ) optimizer_adadelta( learning_rate = 0.001, rho = 0.9, eps = 1e-06, weight_decay = 0 ) optimizer_adamax( learning_rate = 0.001, beta_1 = 0.9, beta_2 = 0.999, eps = 1e-08, weight_decay = 0 ) optimizer_nadam( learning_rate = 0.001, beta_1 = 0.9, beta_2 = 0.999, eps = 1e-08, weight_decay = 0 )optimizer_adam( learning_rate = 0.001, beta_1 = 0.9, beta_2 = 0.999, eps = 1e-08, weight_decay = 0, amsgrad = FALSE ) optimizer_adamw( learning_rate = 0.001, beta_1 = 0.9, beta_2 = 0.999, eps = 1e-08, weight_decay = 0.01 ) optimizer_sgd( learning_rate = 0.001, momentum = 0, nesterov = FALSE, weight_decay = 0 ) optimizer_rmsprop( learning_rate = 0.001, alpha = 0.99, momentum = 0, eps = 1e-08, weight_decay = 0 ) optimizer_adagrad( learning_rate = 0.001, lr_decay = 0, eps = 1e-10, weight_decay = 0 ) optimizer_adadelta( learning_rate = 0.001, rho = 0.9, eps = 1e-06, weight_decay = 0 ) optimizer_adamax( learning_rate = 0.001, beta_1 = 0.9, beta_2 = 0.999, eps = 1e-08, weight_decay = 0 ) optimizer_nadam( learning_rate = 0.001, beta_1 = 0.9, beta_2 = 0.999, eps = 1e-08, weight_decay = 0 )
learning_rate |
How big a step to take. The one setting worth trying first when a run
will not learn: too high and the loss moves without improving, too low and it improves
too slowly to tell from not improving at all. Each parameter group decays from its own
rate, so an |
beta_1 |
Decay for the running average of the gradient. Lower reacts to the last few batches, higher smooths over more of them; 0.9 is the usual value and rarely worth moving. |
beta_2 |
Decay for the running average of the squared gradient, which is what scales
each parameter's step. Below about 0.99 training gets noticeably noisier. In
|
eps |
Added to the denominator so a parameter whose gradient has been zero for a while does not take an enormous step. A guard, not a knob. |
weight_decay |
Pull the weights towards zero at every step, which is L2
regularisation. Off by default because it interacts with the loss and is not free: on a
small dataset it can cost more than the overfitting it prevents. In |
amsgrad |
Keep the largest squared-gradient average seen so far rather than the current one, so the step size never grows back. It fixes a convergence case in Adam's proof and usually changes nothing in practice; worth trying if a run plateaus and then gets worse. |
momentum |
Carry a fraction of the previous step into this one. 0 is plain gradient descent; 0.9 is the usual choice and is what makes SGD competitive with the adaptive methods. |
nesterov |
Look ahead to where momentum is about to carry the weights before
measuring the gradient. Needs |
alpha |
For |
lr_decay |
For |
rho |
For |
No run in this project has compared them on segmentation, so this page says what each
one does differently rather than which is better. available_optimizers() lists them with
the arguments each accepts.
The rest are here because a configuration should be able to name what torch offers.
optimizer_adamw() is Adam with weight decay applied to the weights rather than through
the gradient, which is why its decay defaults to 0.01 rather than 0 - choosing it and
leaving the decay at zero leaves nothing to choose. optimizer_adagrad() accumulates
every squared gradient it has seen, so its step only ever shrinks and a long run can stop
learning; optimizer_adadelta() is the same idea with a window instead of a sum.
optimizer_adamax() tracks the largest gradient rather than the average of their squares,
which is steadier where a few batches carry very large ones. optimizer_rmsprop() is
Adam's denominator without its momentum, and optimizer_nadam() is Adam with the
look-ahead that SGD calls nesterov.
Every argument left unset is decided by the engine, which is where the defaults live.
An optimiser specification.
available_optimizers() for the same list with its arguments, and callbacks()
for moving the rate during a run.
optimizer_adam(learning_rate = 1e-4) optimizer_sgd(learning_rate = 0.01, momentum = 0.9, nesterov = TRUE)optimizer_adam(learning_rate = 1e-4) optimizer_sgd(learning_rate = 0.01, momentum = 0.9, nesterov = TRUE)
Three colours rather than one: what was found, what was missed, and what was invented. A Dice of 0.85 says nothing about which of the three is costing you, and for most clinical questions they are not interchangeable - a missed lesion and a false alarm are different kinds of wrong.
overlay_agreement( image, prediction, truth, alpha = drawing_style()$overlay_alpha, colours = drawing_style()$agreement_colours )overlay_agreement( image, prediction, truth, alpha = drawing_style()$overlay_alpha, colours = drawing_style()$agreement_colours )
image |
A |
prediction, truth
|
Class indices, counted from 1. |
alpha |
How strongly to tint. |
colours |
Named colours for |
A height x width x 3 array of 0-255 integers.
image <- array(runif(8 * 8 * 3, 0.2, 0.6), dim = c(8, 8, 3)) truth <- matrix(1L, 8, 8); truth[2:4, 2:4] <- 2L predicted <- matrix(1L, 8, 8); predicted[3:5, 2:4] <- 2L shown <- overlay_agreement(image, prediction = predicted, truth = truth) dim(shown) # Three colours, not one: a missed lesion and a false alarm cost different things, # and a single overlap score hides which one you have.image <- array(runif(8 * 8 * 3, 0.2, 0.6), dim = c(8, 8, 3)) truth <- matrix(1L, 8, 8); truth[2:4, 2:4] <- 2L predicted <- matrix(1L, 8, 8); predicted[3:5, 2:4] <- 2L shown <- overlay_agreement(image, prediction = predicted, truth = truth) dim(shown) # Three colours, not one: a missed lesion and a false alarm cost different things, # and a single overlap score hides which one you have.
overlay_agreement() answers the same three-way question about pixels, and on an
instance result that is the wrong unit: a merged pair and a split object are both nearly
perfect pixelwise and both wrong, which is exactly what an average precision will not
tell anybody. This matches whole objects at one IoU threshold, the way the metric
matches them, so the picture cannot disagree with the number it illustrates.
overlay_instance_agreement( image, prediction, truth, threshold = 0.5, alpha = drawing_style()$overlay_alpha, colours = drawing_style()$agreement_colours )overlay_instance_agreement( image, prediction, truth, threshold = 0.5, alpha = drawing_style()$overlay_alpha, colours = drawing_style()$agreement_colours )
image |
One image, as |
prediction |
An instance label map from a model. |
truth |
An instance label map from the annotation. |
threshold |
The IoU above which a predicted object counts as a true one, strictly greater. 0.5 is the loosest of the challenge's ten thresholds, so it is the kindest reading, and below 0.5 is refused for the reason the metric refuses it: there one prediction can satisfy two true objects and the match stops being one to one. |
alpha |
How much of the tint shows. |
colours |
Named |
A height x width x 3 array of integers, 0-255.
overlay_instances() for the objects themselves.
image <- array(0.5, dim = c(5, 11, 3)) truth <- matrix(0L, nrow = 5, ncol = 11) truth[2:4, 2:4] <- 1L truth[2:4, 6:8] <- 2L predicted <- matrix(0L, nrow = 5, ncol = 11) predicted[2:4, 2:4] <- 1L # one found predicted[2:4, 9:11] <- 2L # one invented, and object 2 missed shown <- overlay_instance_agreement(image, predicted, truth) # Three different colours: hit, missed, invented. nrow(unique(rbind(shown[3, 2, ], shown[3, 6, ], shown[3, 10, ])))image <- array(0.5, dim = c(5, 11, 3)) truth <- matrix(0L, nrow = 5, ncol = 11) truth[2:4, 2:4] <- 1L truth[2:4, 6:8] <- 2L predicted <- matrix(0L, nrow = 5, ncol = 11) predicted[2:4, 2:4] <- 1L # one found predicted[2:4, 9:11] <- 2L # one invented, and object 2 missed shown <- overlay_instance_agreement(image, predicted, truth) # Three different colours: hit, missed, invented. nrow(unique(rbind(shown[3, 2, ], shown[3, 6, ], shown[3, 10, ])))
One colour per object, chosen so that no two touching objects share one. The rule is adjacency rather than identity: a colour repeats across the picture and never between neighbours, because nobody reads object 214 by its colour and everybody needs to see where one object ends.
overlay_instances( image, labels, alpha = drawing_style()$overlay_alpha, outline = FALSE, colours = drawing_style()$class_colours )overlay_instances( image, labels, alpha = drawing_style()$overlay_alpha, outline = FALSE, colours = drawing_style()$class_colours )
image |
One image: |
labels |
An instance label map - one integer per object, 0 for background. Not a one-hot array: an object identity cannot be carried by a channel. |
alpha |
How much of the tint shows. Ignored when |
outline |
Draw one stroke per object instead of filling it, which leaves the tissue entirely visible and is what a pathologist is used to. |
colours |
The palette to take slots from. Eight by default, mirrored from the engine's own. A picture needing more colours than this has warns, naming how many it needs and how many objects lost the guarantee: the slot wraps onto a colour a neighbour already holds - provably, since it is only past the end once every colour before it was taken by a neighbour - so two touching objects are drawn as one and this figure answers its own question wrongly. The engine warns in the same words, which is checked rather than claimed. Unreachable on anything measured: the hardest of 670 Data Science Bowl cases needs three of the eight. |
That is an exact graph colouring, and it is affordable rather than clever. Measured over
all 670 Data Science Bowl cases: the hardest needs three colours and class_colours has
eight, while the cheap alternative - colour by object number, modulo the palette - draws
one touching pair in ten as a single object.
Eight-connected, so two objects meeting at a corner get different colours. A label-maker goes the other way and joins only along edges, because there the risk is merging two objects into one; here the risk is drawing two as one, which is the opposite direction.
A height x width x 3 array of integers, 0-255.
overlay_instance_agreement() for whether the objects are the right ones,
overlay_mask() for classes rather than objects.
image <- array(0.5, dim = c(5, 9, 3)) labels <- matrix(0L, nrow = 5, ncol = 9) labels[2:4, 2:4] <- 1L labels[2:4, 5:7] <- 2L # touching object 1 along a seam shown <- overlay_instances(image, labels) # Two touching objects, two colours. !identical(shown[3, 2, ], shown[3, 6, ])image <- array(0.5, dim = c(5, 9, 3)) labels <- matrix(0L, nrow = 5, ncol = 9) labels[2:4, 2:4] <- 1L labels[2:4, 5:7] <- 2L # touching object 1 along a seam shown <- overlay_instances(image, labels) # Two touching objects, two colours. !identical(shown[3, 2, ], shown[3, 6, ])
The picture that answers "where is it looking". Background - class 1 - is left alone, so only the classes that were found are tinted.
overlay_mask(image, mask, colormap, alpha = drawing_style()$overlay_alpha)overlay_mask(image, mask, colormap, alpha = drawing_style()$overlay_alpha)
image |
A |
mask |
Class indices, counted from 1, matching the image's height and width. |
colormap |
A list of RGB triples, one per class. |
alpha |
How strongly to tint, 0 to 1. |
A height x width x 3 array of 0-255 integers.
image <- array(runif(8 * 8 * 3, 0.2, 0.6), dim = c(8, 8, 3)) mask <- matrix(1L, 8, 8); mask[2:4, 2:4] <- 2L tinted <- overlay_mask(image, mask, colormap = list(c(0, 0, 0), c(255, 0, 0))) dim(tinted)image <- array(runif(8 * 8 * 3, 0.2, 0.6), dim = c(8, 8, 3)) mask <- matrix(1L, 8, 8); mask[2:4, 2:4] <- 2L tinted <- overlay_mask(image, mask, colormap = list(c(0, 0, 0), c(255, 0, 0))) dim(tinted)
Every failure that comes back from the engine is raised as a classed condition, so a script can tell one kind of problem from another instead of matching on the message. Until now that was true and undocumented, which made it true and useless.
Three or four entries, outermost first:
platypus_config_error the specific kind platypus_error every failure from this package, which is the one to catch error an ordinary R error condition
So tryCatch(..., platypus_error = ) catches everything this package raises and nothing
else, and a specific class narrows it. platypus_error appears once even when the engine
sends no kind at all.
platypus_error, unless there is a reason not to. The specific kinds come from the
engine, so the list below belongs to the version this package pins, and a later engine may
add one. Catching the base class is the thing that keeps working.
Two are worth naming on purpose:
platypus_config_errorA specification that does not describe a runnable
experiment. This one carries data: conditionCall() aside, the condition has a
problems data frame with one row per problem - where, problem and, when it is
short enough to be useful, got - and a source naming the YAML file if it came from
one. The engine reports every problem it found rather than the first, so the frame is
what makes "three things are wrong" readable.
platypus_training_stoppedNot a defect. A callback ended the run early, which is what it was asked to do, and anything scoring the result afterwards should treat it as a finished run rather than a failure.
One class per kind, prefixed. tests/testthat/test-conditions.R holds this list to what
the engine actually defines, so a kind added upstream fails here rather than arriving
undocumented.
platypus_config_error a specification that cannot be run platypus_engine_error the engine could not do what the specification asked platypus_model_error the architecture could not be built as described platypus_data_error the dataset on disk is not what the specification says platypus_image_error an image could not be read, or is not the shape claimed platypus_mask_error a mask does not match its colormap platypus_channel_error one channel per file, and the files do not agree platypus_volume_error a volume, its orientation or its spacing platypus_measure_error a measurement whose spacing does not fit the array platypus_dicom_error a DICOM file or series platypus_augmentation_error a transform that cannot do what was asked of it platypus_detection_error detection metrics platypus_detection_data_error annotations, boxes or classes platypus_instance_error a separation target, or decoding one into objects platypus_instance_data_error the per-object mask files a case has, or has not platypus_label_error an image's label, or the class directories holding it platypus_weights_error weights that do not belong to this model platypus_training_stopped a callback ended the run early platypus_unknown_case a case or model name that is not in this run
platypus_unknown_case is the one with no class behind it in the engine: the R bridge
raises it itself, because asking for a case that does not exist is a question the bridge
can answer without troubling Python.
The engine exports three exception classes - PlatypusError, ConfigError,
WeightsError - and raises the other fifteen internally, so a Python caller catches
PlatypusError and inspects it. R derives its class from the kind the engine sends with
every failure, so all of them arrive as catchable names. That is a wider surface on this
side, and deliberately so: R has no exception hierarchy to lean on, and inherits() is
cheaper than parsing a message.
platypus_status(), for when the problem is that the engine never started.
# A specification with two problems, caught and read as data rather than as text. ## Not run: spec <- platypus_spec( data = segmentation_data("train", "valid", colormap = binary_colormap), models = list(u_net("m", input_shape = c(64, 64), loss = "focaal")) ) tryCatch( platypus_fit(spec), platypus_config_error = function(e) { print(e$problems) # one row per problem: where, problem, got invisible(NULL) }, platypus_error = function(e) { message("something else went wrong: ", conditionMessage(e)) } ) ## End(Not run)# A specification with two problems, caught and read as data rather than as text. ## Not run: spec <- platypus_spec( data = segmentation_data("train", "valid", colormap = binary_colormap), models = list(u_net("m", input_shape = c(64, 64), loss = "focaal")) ) tryCatch( platypus_fit(spec), platypus_config_error = function(e) { print(e$problems) # one row per problem: where, problem, got invisible(NULL) }, platypus_error = function(e) { message("something else went wrong: ", conditionMessage(e)) } ) ## End(Not run)
Reports the PyTorch build in use and the device it can see. Starts the engine if it is not running.
platypus_device()platypus_device()
The answer comes from the engine's own device_report() since 0.8.0a38 - this
package used to compute it, in a file of Python it ships, where no user of the engine
could reach it. What stays here is the advice: the line telling you to call
platypus_use_torch() is about R's session model and would mean nothing to somebody
who installed the engine with pip.
An accelerator that is not CUDA is named, because the published weights were all
trained on CUDA cards and a score from another backend is not directly comparable with
the registry's. Until 0.8.0a38 the engine asked about CUDA alone, so an Apple Silicon
Mac reported cpu while a working mps backend sat in the same installation.
A list, printed readably.
What this prints is a property of the machine it runs on, so an example output here would describe whichever machine built this page rather than yours. Run it and read your own.
## Not run: platypus_device() ## End(Not run)## Not run: platypus_device() ## End(Not run)
The package is named after the animal, and this prints something true about it. It has nothing to do with segmentation.
platypus_fact(number = NULL)platypus_fact(number = NULL)
number |
Which fact, from 1 to the number there are. |
A fact is also printed once when the package is attached, in an interactive session
only. To stop that, set options(rplatypus.facts = FALSE) in ~/.Rprofile, or the
environment variable PLATYPUS_FACTS to 0 or false in ~/.Renviron.
Each fact carries the source it was read from, because several of the best-known claims about platypuses are wrong and four of these exist to say which. The numbering is stable across versions: facts are added to the end and never reordered, so a number can be quoted.
Picking one at random does not disturb the random number stream, which is the only
part of this worth any care: library() must not change what set.seed(1) then
produces, and suppressPackageStartupMessages() hides the message without preventing
the side effect, so avoiding it is the only fix that works.
An object of class platypus_fact, which prints as the fact and its source.
platypus_fact(1) platypus_fact()platypus_fact(1) platypus_fact()
Trains every model the specification asks for, in order, each with its own data pipeline - so two models may differ in input size or tiling and still be compared fairly, on the same data.
platypus_fit( spec, device = NULL, num_workers = "auto", strict_data = TRUE, check_masks = TRUE, verbose = FALSE )platypus_fit( spec, device = NULL, num_workers = "auto", strict_data = TRUE, check_masks = TRUE, verbose = FALSE )
spec |
|
device |
|
num_workers |
Processes used to read and decode images. |
strict_data |
Treat an incomplete sample as an error. Set |
check_masks |
Read a sample of the training masks before training, and refuse when
a class the specification declares appears in none of them. A colormap or set of
labels that matches none of the labelled tissue is the quietest way a run wastes a
day: every mask reads as background, the loss falls because background is most of a
medical image, and the model learns to answer "nothing here". Set |
verbose |
Report each epoch as it finishes. |
Validation data is never augmented. Measuring a model on distorted images measures the distortion.
This blocks until training finishes. With verbose = TRUE each epoch is reported as it
completes, which on anything longer than a few minutes is the difference between
waiting and wondering.
A platypus_fit.
## Not run: fit <- platypus_fit(spec, verbose = TRUE) evaluate(fit) masks <- predict(fit, "unet") ## End(Not run)## Not run: fit <- platypus_fit(spec, verbose = TRUE) evaluate(fit) masks <- predict(fit, "unet") ## End(Not run)
Either from arguments, or from a YAML file by passing its path as the only argument. Both produce the same object, and everything downstream treats them identically.
platypus_spec( data, models = NULL, task = NULL, seed = NULL, deterministic = NULL, output_dir = NULL, check_paths = TRUE )platypus_spec( data, models = NULL, task = NULL, seed = NULL, deterministic = NULL, output_dir = NULL, check_paths = TRUE )
data |
A A plain list of the same fields also works, which is what a caller assembling a
configuration programmatically reaches for, and it is the shape a YAML file already
proves the engine accepts. A plain list names no task, so |
models |
A list of model specifications, see models and |
task |
Which task this specification describes, one of The third task sharpened what this argument is for rather than making it load-bearing.
An instance data block declares nothing a segmentation one lacks, so
|
seed |
Makes a run repeatable as far as the arithmetic is deterministic, which goes further on some hardware than others - so it is worth knowing where it stops. Repeatable: the split ( Not repeatable: a run on a CUDA device. Measured on a GTX 1070, two runs of one
configuration at one seed agreed to twelve decimals on the processor and differed in the
fourth after a single epoch on the card, because cuDNN chooses algorithms neither package
constrains. Nor across versions of the engine: anything that changes the order data is
drawn in changes what one seed draws, and So it is the argument for comparing two of your own runs, and not for publishing a number
somebody else can land on. |
deterministic |
Ask the engine for deterministic algorithms as well, which is the only
thing that makes a run on a card repeatable - The cost is real and arrives later than any other refusal here. Some convolutions become much slower, and an operation with no deterministic implementation raises when it is reached, so a run can stop ten minutes in rather than when the specification is built. Everything else this package refuses, it refuses before reading a single image; this cannot, because whether an operation has a deterministic algorithm is a fact about the installed engine and the device rather than about the specification. Needs |
output_dir |
Where to write a record of the run: the specification, the history,
and anything the run worked out that the specification does not already say - a
detector's fitted anchors, which it cannot be reloaded without. Unset means nothing is written, not "written to a default place". A field with a default cannot be told apart from one somebody set to that default, so sending the default from here would have put files in every user's working directory the moment the engine learned to write them - which it just did. |
check_paths |
Verify that the data paths exist. Turn it off to build a specification on a machine that does not hold the data. |
The engine validates it, so a mistake is caught here rather than part-way through a training run - and reported against the model that has it, by the name you gave it.
A platypus_spec.
## Not run: spec <- platypus_spec( data = segmentation_data("train/", "valid/", colormap = binary_colormap), models = list( u_net("unet", input_shape = c(256, 256)), linknet("linknet", input_shape = c(256, 256), loss = loss_focal_tversky(alpha = 0.7)) ) ) # The same thing, from a file spec <- platypus_spec("experiment.yaml") ## End(Not run)## Not run: spec <- platypus_spec( data = segmentation_data("train/", "valid/", colormap = binary_colormap), models = list( u_net("unet", input_shape = c(256, 256)), linknet("linknet", input_shape = c(256, 256), loss = loss_focal_tversky(alpha = 0.7)) ) ) # The same thing, from a file spec <- platypus_spec("experiment.yaml") ## End(Not run)
Reports whether the Python side has been started, and what it is. Calling this does not start it - use it to check before a long run, or when something is wrong.
platypus_status()platypus_status()
A list with the engine version, the interpreter in use and where its
environment lives, or NULL fields when Python has not been started yet.
Nothing to do. Each release of this package pins one exact engine version, that
pin is what gets declared when the package loads, and the environment is resolved from
it on first use. Install a newer rplatypus and the next session uses the engine it
asks for; the first call after the upgrade fetches the new engine - a few hundred
kilobytes, with PyTorch already cached - and the environment the old version used is
left alone, so going back to it costs nothing either.
That is the opposite of the arrangement most R packages wrapping Python have, where a persistent environment is installed once by hand and updating the R package does not touch it. Here there is no environment anybody maintains: there is a pin, and the environment always matches it. The cost is that the two cannot be moved separately - which is the point, since a release that could drift from its engine is the failure the pin exists to prevent.
One case is worth knowing: updating on the day the engine is published, uv may still be serving a cached index listing and report that the pinned version does not exist. It is a stale read rather than a missing release, it mends itself, and the failure message says so.
If RETICULATE_PYTHON is set, none of this applies - that Python is used whatever the
pin says, and a mismatch is reported as a warning naming both versions.
What this prints is a property of the machine it runs on, so an example output here would describe whichever machine built this page rather than yours. Run it and read your own.
## Not run: platypus_status() ## End(Not run)## Not run: platypus_status() ## End(Not run)
Call this before anything starts the engine - straight after library(rplatypus) - and
before the first call that needs Python.
platypus_use_torch(build = c("pascal", "default"))platypus_use_torch(build = c("pascal", "default"))
build |
|
There is one reason to: a GeForce GTX 10-series card, or anything older. PyTorch 2.8 and later ship CUDA 13 builds, and CUDA 13 dropped the Maxwell, Pascal and Volta generations outright. No driver update brings them back. Without this, such a card is simply not used and everything runs on the processor, perhaps ten times slower, with nothing obviously wrong.
On anything from Turing - the RTX 20-series onwards - the default is correct and this is unnecessary.
The requirement string now in force, invisibly.
py_require() is session state, so this does not survive a restart - and forgetting it
costs about ten times the training time without costing an error, which is the kind of
bill that goes unnoticed. Set it where a session starts instead:
options(rplatypus.torch = "pascal") # ~/.Rprofile
or PLATYPUS_TORCH=pascal in ~/.Renviron, which also reaches runs that never load an
.Rprofile - Rscript, a scheduler, CI. The option wins over the variable, so a
session can override what a machine was configured with. "default" asks for the
ordinary build, which is how to turn the setting off for one session without editing
the file it came from.
Both are read when the package is loaded, which is the last moment anything can change what the environment will be. A value neither of them recognises is reported at load and ignored: a bad line in a startup file should not make the package unloadable.
What this prints is a property of the machine it runs on, so an example output here would describe whichever machine built this page rather than yours. Run it and read your own.
## Not run: library(rplatypus) platypus_use_torch("pascal") # GTX 10-series and older ## End(Not run)## Not run: library(rplatypus) platypus_use_torch("pascal") # GTX 10-series and older ## End(Not run)
The picture the 2020 package drew, and the one no summary statistic replaces. Every annotated box is a point - its width against its height, both as fractions of the model's input - coloured by class, with the anchors on top.
plot_anchors(object, model = NULL, split = "train", log = FALSE, size = 1.1)plot_anchors(object, model = NULL, split = "train", log = FALSE, size = 1.1)
object |
A fit from |
model |
Which model, when the specification trained several. |
split |
Which split's boxes to draw. |
log |
Draw both axes on a log scale. Detection datasets span a wide range of sizes
|
size |
Point size for the boxes. |
What it answers that a mean overlap does not: whether a class has any anchor near it at all. A mean of 0.65 can be one class covered well and another not covered at all, and those two situations want different fixes. On blood cells the three classes occupy three distinct regions - median sides of 133, 69 and 26 pixels at a 416 input - and anchors fitted to all three together cover them at 0.88, 0.85 and 0.83, so fitting to the mixture does not abandon the smallest class. COCO's nine cover the same three at 0.64, 0.70 and 0.70: evenly worse rather than blind to one, which is the useful thing to know. They are not aimed elsewhere - they span 10 to 373 pixels a side because COCO holds objects of every size, and most of that range describes nothing here.
Worth drawing for a split the anchors were not fitted on. Anchors that sit among the training boxes and away from the validation ones say the two halves hold different objects, and no training curve shows that.
A ggplot object.
detection_anchors() for the numbers, yolo3() for choosing them.
## Not run: fit <- platypus_fit(spec) plot_anchors(fit) # the split they were fitted to plot_anchors(fit, split = "validation") # and one they were not ## End(Not run)## Not run: fit <- platypus_fit(spec) plot_anchors(fit) # the split they were fitted to plot_anchors(fit, split = "validation") # and one they were not ## End(Not run)
The detection counterpart of plot_masks(), and the function the 2020 package had under
this name. Boxes arrive from predict() in each image's own pixels, so they land on the
picture without anything being undone first.
plot_boxes( images, boxes, truth = NULL, which = NULL, min_score = drawing_style()$box_min_score, labels = NULL, colours = drawing_style()$box_colours, size = 0.6, text_size = 3 )plot_boxes( images, boxes, truth = NULL, which = NULL, min_score = drawing_style()$box_min_score, labels = NULL, colours = drawing_style()$box_colours, size = 0.6, text_size = 3 )
images |
An image stack from |
boxes |
Boxes to draw: one data frame as |
truth |
Boxes to draw as ground truth, the same shape as |
which |
Which images to draw. Defaults to the first four, like |
min_score |
Drop predicted boxes below this confidence before drawing. The default
follows a detector's own |
labels |
Row labels. Defaults to the names of |
colours |
Named vector giving the colour for |
size |
Line width of a box. |
text_size |
Size of the class label, or |
Predictions and truth can be drawn together, and when they are, the point is the comparison: truth in one colour, predictions in another, both on the same image. A detector that found the right number of objects in the wrong places and one that found the wrong number in the right places score similarly and look nothing alike.
A ggplot object.
predict.platypus_fit(), plot_masks()
## Not run: found <- predict(fit, split = "test") plot_boxes(read_images(files, size = NULL), found, min_score = 0.5) ## End(Not run)## Not run: found <- predict(fit, split = "test") plot_boxes(read_images(files, size = NULL), found, min_score = 0.5) ## End(Not run)
Builds a grid: one row per image, one column per panel. Which panels appear depends on what you pass - the image alone, the truth, the prediction, and where they disagree.
plot_masks( images, prediction = NULL, truth = NULL, colormap = binary_colormap, which = NULL, alpha = drawing_style()$overlay_alpha, labels = NULL, slice = NULL )plot_masks( images, prediction = NULL, truth = NULL, colormap = binary_colormap, which = NULL, alpha = drawing_style()$overlay_alpha, labels = NULL, slice = NULL )
images |
An |
prediction |
Predicted class indices, from |
truth |
True class indices, if you have them. |
colormap |
A list of RGB triples, one per class. |
which |
Which images to show. |
alpha |
How strongly to tint the overlays. |
labels |
Row labels; defaults to the image number. |
slice |
For volumes, which slice to draw. A volume cannot honestly be shown as one
picture, so one plane is chosen and said out loud rather than a projection being
invented. Counted along the last axis, which after the canonical reorientation is the
axial direction - the view a radiologist scrolls through. |
The disagreement panel is usually the one worth looking at. It separates what was found from what was missed and what was invented, and those are not interchangeable: for most clinical questions a missed lesion and a false alarm cost different things, and a single overlap score hides which one you have.
A ggplot.
## Not run: plot_masks(images, prediction = masks, truth = truth, colormap = binary_colormap) ## End(Not run)## Not run: plot_masks(images, prediction = masks, truth = truth, colormap = binary_colormap) ## End(Not run)
One panel per quantity, one line per model. Useful for the thing a final number cannot tell you: whether it had stopped improving, or was still going when it ran out of epochs.
## S3 method for class 'platypus_fit' plot(x, metrics = NULL, ...)## S3 method for class 'platypus_fit' plot(x, metrics = NULL, ...)
x |
|
metrics |
Which columns to plot; defaults to the losses and metrics. |
... |
Unused. |
A ggplot.
## Not run: plot(fit) # every metric that was recorded plot(fit, metrics = "dice") ## End(Not run)## Not run: plot(fit) # every metric that was recorded plot(fit, metrics = "dice") ## End(Not run)
One function per fit rather than one per task, because the question - what does this model say about this split - is the same one. What comes back is not: a mask for segmentation, a table of boxes for detection, a label map of objects for instances, and a row per image for classification. The arguments that describe a grid are refused where there is no grid, by name, rather than accepted and ignored.
## S3 method for class 'platypus_fit' predict( object, model = NULL, split = "test", type = c("class", "probability", "channels"), space = c("model", "source"), cases = NULL, each = NULL, decode = NULL, ... )## S3 method for class 'platypus_fit' predict( object, model = NULL, split = "test", type = c("class", "probability", "channels"), space = c("model", "source"), cases = NULL, each = NULL, decode = NULL, ... )
object |
|
model |
Which model, by the name given in the specification. Defaults to the first one trained. |
split |
Which data to predict on. |
type |
On an instance fit, With This page named On a classification fit the two readings are a table and a matrix rather than two
arrays: |
space |
|
cases |
Case names to predict, as |
each |
A function called as |
decode |
Which decoding turns the predicted channels into objects, by the name used
in the model's |
... |
Unused. |
With space = "model": for "class", an integer array of
image x height x width, classes numbered from 1 to match the colormap; for
"probability", the same with a trailing class axis. Tiled models return images at their
original size, reassembled.
With space = "source": a list of such arrays, one per scan, each on that scan's grid,
named by case. The name and the mask come out of one step of the engine's own
stream, so they cannot be misaligned - which matters because the usual next move is to
join them to something else per case, and a list paired by position is one dropped
batch away from naming the wrong picture.
On an instance fit: a list of integer label maps named by case, objects numbered
from 1 and 0 for background, one entry per image. A list rather than an array for the
reason the detection one is a list, and named rather than ordered because a list paired
against case names by position is one dropped batch away from naming the wrong picture.
Draw them with overlay_instances() and measure them with instance_sizes().
On a classification fit: for "class", a data frame of one row per image with
key (the name the split calls it, which is what joins this to anything else), label
(what it was labelled, or NA on a split that carries no labelling), predicted and
probability - the probability of the class that was chosen, not of the right one. For
"probability", a numeric matrix of images by classes, with the keys as row names and
the class names as columns, in the order classes was written.
With labels_from, where a label is a set, "class" is a longer frame instead:
one row per image per class, with key, class, and label and predicted as
logicals. It is a different answer rather than the same answer with a longer string,
and this is the shape that says so - an image with none of the findings is a row of
FALSE per class rather than an empty cell, which could also mean unread. Summarise
per class with one tapply, or reshape it if you want it wide.
There is no probability column on that route, and it is missing on purpose: that
column is the chosen class's own number, and with independent questions nothing was
chosen. "probability" is the whole of the answer there and comes back as the same
matrix, one column per class.
space = "model" returns everything on the grid the model worked at, stacked into one
array. That is what training saw, and it is the only form that can be a single array,
because a stack needs one shape.
space = "source" maps each prediction back onto the grid of the scan it was computed
from - undoing the resampling and the crop - and therefore returns a list, one mask per
scan. This is what you want for anything that has to meet the original data: laying a mask
over the scan in a viewer, writing it beside the scan with save_volumes(), or measuring it
in millilitres of the scan's own voxels. Scans differ in size, so the list is not a
limitation being worked around - resizing them to a common shape is exactly how a mask ends
up describing anatomy it was not computed from.
Two things are lost mapping back, and neither can be recovered. Interpolation softens a
boundary, so the mask returns slightly smoother than the model drew it. And where reading
cropped anatomy away, the mask comes back padded with background - which means not
examined, not nothing there. Give the model an input_shape that covers the anatomy if
that distinction matters.
The default returns everything together, which for a tiled run is the whole split at full
resolution: 200 fundus photographs at 2048 x 2048 is about 6.7 GB before anything is drawn,
and the engine's own predict was killed by exactly that. Two arguments avoid it.
cases names the ones wanted. each hands them over one at a time and keeps only what the
function gives back. They compose: cases says which, each says what to do with them.
The saving is in memory and not in time. The engine's stream is sequential, so asking for two cases out of two hundred still runs the model over two hundred - it simply never holds more than one answer at once.
## Not run: masks <- predict(fit, "unet") # an array, on the model's grid masks <- predict(fit, "unet", space = "source") # a list, on each scan's own grid # The two worst cases, without holding the other 198. cases <- evaluate_cases(fit, split = "validation") worst <- cases$case[order(cases$dice)][1:2] two <- predict(fit, split = "validation", cases = worst) # Or walk the split, keeping one number per image and none of the masks. coverage <- predict(fit, split = "validation", each = function(case, mask) mean(mask > 1)) # `space = "source"` returns a list and not an array, which is the API being # honest: scans differ in size, a stacked array needs one shape, and resizing # them to match is how a mask ends up describing anatomy it was not computed from. ## End(Not run)## Not run: masks <- predict(fit, "unet") # an array, on the model's grid masks <- predict(fit, "unet", space = "source") # a list, on each scan's own grid # The two worst cases, without holding the other 198. cases <- evaluate_cases(fit, split = "validation") worst <- cases$case[order(cases$dice)][1:2] two <- predict(fit, split = "validation", cases = worst) # Or walk the split, keeping one number per image and none of the masks. coverage <- predict(fit, split = "validation", each = function(case, mask) mean(mask > 1)) # `space = "source"` returns a list and not an array, which is the API being # honest: scans differ in size, a stacked array needs one shape, and resizing # them to match is how a mask ends up describing anatomy it was not computed from. ## End(Not run)
Reads through the engine's own pipeline rather than a separate decoder, so what you plot is what the model was given. A picture drawn from a differently resized copy is a picture of something the model never saw, and the disagreements it appears to show may be nothing but the resizing.
read_images( paths, size = NULL, channels = 3, nearest = FALSE, label_map = FALSE )read_images( paths, size = NULL, channels = 3, nearest = FALSE, label_map = FALSE )
paths |
Image files. |
size |
|
channels |
3 for colour, 1 for greyscale. |
nearest |
Use nearest-neighbour resizing. Required for masks: interpolating one invents colours belonging to no class, which then quietly become background. |
label_map |
The file holds class identities rather than brightness, so read it at full integer width. A 16-bit PNG or a 32-bit TIFF label map is otherwise read through 8-bit greyscale, where every value above 255 arrives as 255 - two classes then merge into one with nothing unmatched to say so. One channel only, because a label map has one, and never for an image: a picture is divided by 255 later. For a volume this argument does nothing and |
An image x height x width x channels array of 0-255 values - or of the file's
own integer values with label_map = TRUE and, for a volume, nearest = TRUE.
A volume read with label_map = TRUE and nearest = FALSE comes back rescaled to
0-1, and this function warns when it is asked for that combination.
## Not run: images <- read_images(list.files("test/", full.names = TRUE), size = c(256, 256)) # A label map stored wide, whose values are classes rather than brightness. values <- read_images("seg.png", channels = 1, nearest = TRUE, label_map = TRUE) ## End(Not run)## Not run: images <- read_images(list.files("test/", full.names = TRUE), size = c(256, 256)) # A label map stored wide, whose values are classes rather than brightness. values <- read_images("seg.png", channels = 1, nearest = TRUE, label_map = TRUE) ## End(Not run)
Masks come two ways, and this reads both. colormap is for masks stored as pictures, one
colour per class. labels is for masks stored as label maps - a vector of the voxel values,
in class order - which is how volume formats store them.
read_masks(paths, colormap = NULL, labels = NULL, size = NULL, tolerance = 0)read_masks(paths, colormap = NULL, labels = NULL, size = NULL, tolerance = 0)
paths |
Image files. |
colormap |
A list of RGB triples, one per class. Give this or |
labels |
The voxel value of each class, in class order: |
size |
|
tolerance |
Passed to |
An array of class indices, counted from 1.
## Not run: # Masks stored as pictures: the colormap says which colour is which class. truth <- read_masks(list.files("masks/", full.names = TRUE), colormap = list(c(0, 0, 0), c(255, 255, 255))) # Masks stored as label maps, which is how volumes do it. Exactly one of the two. truth <- read_masks("case_01/seg.nii.gz", labels = c(0, 1)) # `tolerance` is for masks that have been through a lossy resize, where a colour # that should be (255, 0, 0) arrives as (254, 1, 0) and matches nothing. truth <- read_masks(paths, colormap = voc_colormap, tolerance = 2) ## End(Not run)## Not run: # Masks stored as pictures: the colormap says which colour is which class. truth <- read_masks(list.files("masks/", full.names = TRUE), colormap = list(c(0, 0, 0), c(255, 255, 255))) # Masks stored as label maps, which is how volumes do it. Exactly one of the two. truth <- read_masks("case_01/seg.nii.gz", labels = c(0, 1)) # `tolerance` is for masks that have been through a lossy resize, where a colour # that should be (255, 0, 0) arrives as (254, 1, 0) and matches nothing. truth <- read_masks(paths, colormap = voc_colormap, tolerance = 2) ## End(Not run)
platypus_fit() leaves a record beside the weights when the specification named an
output_dir: the whole specification, whatever the run worked out for itself, the
epoch-by-epoch history, and the version of the engine that produced it. This reads one
back.
read_record(path)read_record(path)
path |
The |
Named to match read_record() in the engine, and read_images() and read_masks() here.
A platypus_record: a list with written, pyplatypus, model,
specification, derived and history. Everything is plain R data, because a record
describes what happened rather than being a live thing - except history, which comes
back as a data frame with one row per epoch.
That frame is not quite what training_history() returns, and the difference is
worth knowing before you rbind() them: a record belongs to one model, whose name is
the top-level model field, so the frame has no model column. training_history()
spans every model in the fit and needs one. Every other column is the same.
The specification in the record is a thing you can run again, not a description of a run.
And for a detector whose anchors were fitted rather than named, derived$anchors is
the only copy outside a weights sidecar - so a record is how a result stays reproducible
after the session that produced it has gone.
Deliberately. Everything else in this package asks the engine; this reads a text file,
and a first call to the engine provisions an environment of several gigabytes. A record
you cannot read without the thing that wrote it is worth less than one you can, so this
needs only jsonlite, and works on a machine that has never had Python.
training_history() for the same numbers from a fit in the current session,
and export_weights(), whose sidecar answers a different question - what the weights
are for, rather than what the run was.
## Not run: spec <- platypus_spec( data = segmentation_data("train", "valid", colormap = binary_colormap), models = list(u_net("vessels", input_shape = c(256, 256))), output_dir = "runs" ) fit <- platypus_fit(spec) record <- read_record("runs/vessels") record$pyplatypus # the engine that produced it head(record$history) # one row per epoch record$specification # enough to run it again ## End(Not run)## Not run: spec <- platypus_spec( data = segmentation_data("train", "valid", colormap = binary_colormap), models = list(u_net("vessels", input_shape = c(256, 256))), output_dir = "runs" ) fit <- platypus_fit(spec) record <- read_record("runs/vessels") record$pyplatypus # the engine that produced it head(record$history) # one row per epoch record$specification # enough to run it again ## End(Not run)
regions gives a channel per named class and a sigmoid per channel, so a pixel may be in
several classes at once - that is the point of it. A colour map cannot hold that: one
pixel gets one colour. save_masks() therefore refuses such a prediction, and this is
what it refuses it in favour of - one binary picture per class, named after the class.
save_class_masks( masks, dir, classes, names = NULL, suffix = "", threshold = 0.5 )save_class_masks( masks, dir, classes, names = NULL, suffix = "", threshold = 0.5 )
masks |
Channel probabilities from Class indices are the wrong input and are refused where a shape or a value can prove
it: any value above 1 is not a probability. What cannot be proved is a stack of
class-index masks whose values are all 1 - |
dir |
Directory to write into; created if it does not exist. |
classes |
One name per channel, in channel order, which is the order the regions were
written in and the order every per-class score is reported in. |
names |
File name stems, one per image, or the source image paths to take them from.
Defaults to |
suffix |
Added before the class, so predictions from different models can sit in one directory without overwriting each other. |
threshold |
A class is written where its channel reaches this. 0.5 is what every metric in the engine thresholds a channel at, so a different number here writes pictures that disagree with the scores printed beside them. |
Each file is <name><suffix>_<class>.png, black and white, so it opens in anything and
thresholds back to exactly what was written. A class name becomes a filename by replacing
anything outside letters, digits, dot, dash and underscore with _, so whole tumour is
whole_tumour on disk; two class names that become the same filename are refused rather
than written, because one file would hold the second class with the first silently gone.
A class nothing reaches is written as an empty picture rather than skipped. A missing file reads as this run did not produce that class and an empty one as it did and the answer was nothing, which are different findings and only one of them is true.
The paths written, invisibly, image by image and within an image class by class.
save_masks() for exclusive classes as one colour picture, save_volumes() for
volumes as NIfTI.
## Not run: spec <- platypus_spec(... ) channels <- predict(fit, "brats", split = "test", type = "probability") save_class_masks(channels, "predictions", classes = c("whole tumour", "core", "enhancing")) ## End(Not run)## Not run: spec <- platypus_spec(... ) channels <- predict(fit, "brats", split = "test", type = "probability") save_class_masks(channels, "predictions", classes = c("whole tumour", "core", "enhancing")) ## End(Not run)
The volume counterpart of save_class_masks(), and the one regions was motivated by:
BraTS names three overlapping regions and stores them as volumes. A label-map NIfTI holds
one integer per voxel, so save_volumes() refuses an overlapping prediction rather than
reducing it, and this is what it refuses it in favour of - one binary volume per class,
each carrying the geometry of the scan it was predicted from.
save_class_volumes( masks, dir, reference, classes, names = NULL, suffix = "", threshold = 0.5 )save_class_volumes( masks, dir, reference, classes, names = NULL, suffix = "", threshold = 0.5 )
masks |
Channel probabilities - a list of |
dir |
Directory to write into; created if it does not exist. |
reference |
The scan each mask was predicted from, as paths. Every class of one scan
is written with that scan's geometry, which is the whole reason this is not
|
classes |
One name per channel, in channel order, which is the order the regions were
written in. |
names |
File name stems, one per volume. Defaults to the reference file names with their extensions removed - and names that collide are refused, because one directory per case usually means the same filename inside each. |
suffix |
Added before the class, so predictions from different models can sit in one directory. |
threshold |
A class is written where its channel reaches this. 0.5 is what every metric in the engine thresholds a channel at. |
Each file is <name><suffix>_<class>.nii.gz. The class name becomes part of a filename the
same way save_class_masks() does, and two class names that become the same filename are
refused rather than written.
Why one file per class rather than one file with several values: a voxel in the tumour core is also in the whole tumour, and no single integer says both. Any viewer can load three binary masks over one scan; none can unpack an overlap out of one label map.
The paths written, invisibly, volume by volume and within a volume class by class.
save_class_masks() for 2D, save_volumes() for exclusive classes.
## Not run: channels <- predict(fit, "brats", split = "test", type = "probability", space = "source") save_class_volumes(channels, "predictions", reference = split_path(split, "test")$image, classes = c("whole tumour", "tumour core", "enhancing")) ## End(Not run)## Not run: channels <- predict(fit, "brats", split = "test", type = "probability", space = "source") save_class_volumes(channels, "predictions", reference = split_path(split, "test")$image, classes = c("whole tumour", "tumour core", "enhancing")) ## End(Not run)
Predictions are only half useful while they are an array in a session. This writes them as ordinary PNGs that open in anything - ImageJ, QuPath, a viewer, a colleague's machine - and returns the paths, so it can sit at the end of a pipeline.
save_masks(masks, dir, colormap, names = NULL, suffix = "")save_masks(masks, dir, colormap, names = NULL, suffix = "")
masks |
Class indices from For volumes use For overlapping classes use |
dir |
Directory to write into; created if it does not exist. |
colormap |
A list of RGB triples, one per class. |
names |
File names, or the source image paths to take names from. Defaults to
|
suffix |
Added before the extension, so predictions from different models can sit in one directory without overwriting each other. |
Files go where you say, not beside the images they came from. Writing into somebody's dataset directory is a surprising thing for a function to do, and a second run against a different model would quietly mix its results in with the first.
The paths written, invisibly.
## Not run: masks <- predict(fit, "unet", split = "test") save_masks(masks, "predictions", binary_colormap, suffix = "_unet") ## End(Not run)## Not run: masks <- predict(fit, "unet", split = "test") save_masks(masks, "predictions", binary_colormap, suffix = "_unet") ## End(Not run)
platypus_spec() reads a YAML file and builds a specification from arguments. This is the
way back, so the two are one pipeline rather than one and a half: a specification assembled
in R becomes a file somebody else can run, or that a script can keep beside its results.
save_spec(spec, path, defaults = FALSE)save_spec(spec, path, defaults = FALSE)
spec |
A |
path |
Where to write it. The parent directory must exist. |
defaults |
Write every field at its resolved value, including the ones you never set. |
It writes what you set, not what the specification holds. A model built from two
arguments holds 29 fields once the engine has filled in its defaults, and writing all of
them is not merely verbose - it changes what the file does. A model given weights and no
architecture takes the architecture from the weights sidecar, and that adoption happens
precisely because the field was never set. Freeze the defaults into the file and the
adoption can never happen again: the file looks like the original and quietly stops doing
what the original did.
defaults = TRUE is the opposite want, and a legitimate one: freeze this run exactly as it
resolved so it reproduces years from now. The cost is the paragraph above, by design.
The path, invisibly.
platypus_spec(), which reads one back, and read_record() for what a run did
as opposed to what it was asked to do.
## Not run: spec <- platypus_spec( data = segmentation_data("train/", "valid/", colormap = binary_colormap), models = list(u_net("unet", input_shape = c(256, 256))) ) save_spec(spec, "experiment.yaml") identical(as.list(platypus_spec("experiment.yaml")), as.list(spec)) # Everything the engine resolved, for a run that has to reproduce in five years. save_spec(spec, "frozen.yaml", defaults = TRUE) ## End(Not run)## Not run: spec <- platypus_spec( data = segmentation_data("train/", "valid/", colormap = binary_colormap), models = list(u_net("unet", input_shape = c(256, 256))) ) save_spec(spec, "experiment.yaml") identical(as.list(platypus_spec("experiment.yaml")), as.list(spec)) # Everything the engine resolved, for a run that has to reproduce in five years. save_spec(spec, "frozen.yaml", defaults = TRUE) ## End(Not run)
Writes each mask as a label-map NIfTI carrying the geometry of the scan it was predicted
from. That is the whole reason this is not save_masks() with a different extension: a
mask array without an affine cannot be laid over its scan by any viewer, any registration
tool or any volume calculation - it either lands in the wrong place or is refused.
save_volumes(masks, dir, reference, names = NULL, suffix = "")save_volumes(masks, dir, reference, names = NULL, suffix = "")
masks |
Masks of class indices, as A list of volumes is what An array is also accepted: one volume as |
dir |
Where to write. Created if it does not exist. |
reference |
The scan each mask was predicted from, in the same order. Its affine is what the mask is written with, so this is required rather than optional. |
names |
File names, without extension. Taken from |
suffix |
Appended to each name, for telling two models' output apart. |
Carrying the affine means the mask has to be on the same grid as the scan, which is what
predict(space = "source") is for: it maps each prediction back out of the model's grid onto
the scan's. Without it, a resampled or resized prediction will be refused here rather than
written somewhere wrong.
The paths written, invisibly.
save_masks() for 2D masks as pictures.
## Not run: validation <- split_path(split, "validation") masks <- predict(fit, split = "validation", space = "source") save_volumes(masks, "predictions", reference = validation$images) ## End(Not run)## Not run: validation <- split_path(split, "validation") masks <- predict(fit, split = "validation", space = "source") save_volumes(masks, "predictions", reference = validation$images) ## End(Not run)
Two layouts. nested_dirs expects one directory per sample containing an images and
a masks subdirectory - the Data Science Bowl arrangement, where a sample may hold
dozens of separate mask files that are combined into one. config_file expects a CSV
with images and masks columns, several paths per cell if needed, resolved relative
to the file itself so a configuration travels with its data.
segmentation_data( train, validation = NULL, colormap = NULL, labels = NULL, regions = NULL, classes_from = NULL, test = NULL, split = NULL, mode = c("nested_dirs", "config_file"), window = NULL, dicom_window = NULL, target_spacing = NULL, channels_from = NULL, subdirs = c("images", "masks"), column_sep = ";", shuffle = TRUE )segmentation_data( train, validation = NULL, colormap = NULL, labels = NULL, regions = NULL, classes_from = NULL, test = NULL, split = NULL, mode = c("nested_dirs", "config_file"), window = NULL, dicom_window = NULL, target_spacing = NULL, channels_from = NULL, subdirs = c("images", "masks"), column_sep = ";", shuffle = TRUE )
train, validation
|
Paths to the training and validation data. A
Leaving |
colormap |
A list of RGB triples, one per class, background first. For masks stored
as pictures. Give this or |
labels |
The voxel value of each class, in class order, for masks stored as label
maps - Include 0 unless the masks really do fill every voxel. A value matching no entry falls back to the first class, so a list without 0 trains the background as whatever that first class is. The engine refuses that combination rather than let it look plausible, which means the error arrives before the run and not in the scores. |
regions |
Overlapping classes, named as unions of a label-map value set: a list of
Two things this field changes that its name does not say. There is no background
entry, so Two regions are the minimum: one region is ordinary binary segmentation, which
|
classes_from |
For masks kept as one binary file per class: one pattern per
class, in class order. The fourth way of saying what a mask's classes are, and the only
one that can express an overlap that was drawn independently - a vessel crossing an
organ, or two annotators' masks of the same structure. Give this, It is Patterns are Python regular expressions matched against a sample's mask files, with
the same two rules 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. |
test |
Optional test data. Only images are read from it. |
split |
Divide
It is required rather than optional because the mistake it prevents leaves no trace: slices of one patient in training and validation at once make validation measure memory rather than generalisation, and the score comes out several points too high with nothing in the output to say so. Nothing is written. |
mode |
|
window |
How values in real units reach the model. A named window - Worth choosing rather than leaving: a fixed window is what makes two scans comparable. Scaling each scan by its own darkest and brightest voxel lets a single metal implant or marker rescale everything else in it.
|
dicom_window |
The former name of |
target_spacing |
For volumes: resample every scan to this many millimetres per voxel,
then centre-crop or pad to the model's
|
channels_from |
For datasets that keep one channel per file: one pattern per channel, in channel order. BraTS ships four MRI sequences per patient - T1, T1 after contrast, T2 and FLAIR - because different tumour structures are visible in different sequences; satellite sets keep their bands apart the same way. The order is stated rather than inferred, and that is the point. Sorted, the BraTS names come out flair, t1, t1ce, t2 - reproducible and anatomically meaningless. A model trained with FLAIR in channel one and used on data whose channel one is T1 returns a plausible answer with the right shape and the right range, and nothing further down can notice. Patterns are Python regular expressions, like |
subdirs |
For |
column_sep |
For |
shuffle |
Shuffle the training data between epochs. |
Classes are named one of two ways, and exactly one of them. colormap is for masks
stored as pictures: a list of RGB triples where position is the class index. labels is
for masks stored as label maps - a vector of the voxel values, in class order - which is
how every volume format stores them, and how a single-channel PNG can be read as well.
A sample that is missing its masks is an error, not a warning - a warning in a loop over five hundred directories is a warning nobody reads.
A data specification, to be passed to platypus_spec().
segmentation_data("train/", "valid/", colormap = binary_colormap)segmentation_data("train/", "valid/", colormap = binary_colormap)
One per model, and it decides the head's width and which losses are admissible - both
derived by the engine rather than stated here, so there is no channel count to get wrong.
Pass the result as a model's separation; see u_net().
separation_rim(width = NULL) separation_flows(mode = NULL, loss = NULL, weight = NULL)separation_rim(width = NULL) separation_flows(mode = NULL, loss = NULL, weight = NULL)
width |
For |
mode |
For |
loss |
For |
weight |
For |
separation_rim() trains three classes - background, object interior, object rim - and
the rim is what separates two touching objects. separation_flows() trains a vector
field pointing at each object's own sink, so every foreground pixel carries a value and
none of them is spent on being a boundary.
What this is worth was measured and it is not flattering. On the Data Science Bowl's
held-out cases the flow route is worth +0.0680 of instance average precision over
decode_connected_components(), which ignores the signal entirely, and the rim route
+0.0640 - while substituting a perfect mask is worth +0.2398. So the mask is the limit
by a factor of 3.6, and a run that beats the published weights' 0.4888 without reaching
0.7655 has improved its mask and said nothing about separation.
There is no hv. Its markers lose to a one-pixel erosion on perfect inputs, 0.9382
against 0.9807, so naming it would promise a mechanism that was measured as absent.
A separation block, to be passed as a model's separation.
separation_rim(width = 2) separation_flows(mode = "diffusion")separation_rim(width = 2) separation_flows(mode = "diffusion")
One row per directory, saying whether it is a usable series and what is wrong with it if it is not. Problems are reported as a column rather than raised, because a hundred cases out of an archive will contain a few with a missing slice, two series in one folder, or duplicated files - and stopping at the first turns an afternoon's work into a week's.
series_report(paths)series_report(paths)
paths |
Directories, each holding the slices of one series. |
Reads no pixels. Every check runs on the DICOM headers, so this is usable on a hundred gigabytes of data and is the first thing worth running on a new dataset.
A data frame with one row per directory: path, ok, slices, sorted_by,
spacing_1/2/3, series_uid and problem.
A missing slice, which does not make a volume shorter - it puts everything past the gap in the wrong place, and no metric would show it. Two series in one folder, which is the normal state of an archive export: a scout, a reconstruction and a phase sitting together, which stacked would interleave two anatomies. Duplicated slices, from a copy or from several echoes mixed together. Slices of different size, angle or spacing.
It also reports how the slices were ordered. "position" means the geometry decided, which
is what you want. "instance_number" means the files carry no positions and the order came
from a number that is only required to be unique - worth knowing before trusting the result.
volume_info() for NIfTI files, split_dataset() for dividing the cases up.
## Not run: cases <- list.dirs("dicom_export", recursive = FALSE) report <- series_report(cases) report[!report$ok, c("path", "problem")] # the ones to deal with first table(report$sorted_by) ## End(Not run)## Not run: cases <- list.dirs("dicom_export", recursive = FALSE) report <- series_report(cases) report[!report$ok, c("path", "problem")] # the ones to deal with first table(report$sorted_by) ## End(Not run)
Divides one directory of samples into three sets and writes each as a CSV the specification can point at. Nothing is copied: the files stay where they are and the CSVs list them, which matters when the data is large or read-only.
split_dataset( root, out_dir, group_by = NULL, fractions = c(0.7, 0.15, 0.15), seed = 0, mode = c("nested_dirs", "config_file"), subdirs = c("images", "masks"), column_sep = ";", strict = TRUE, relative = TRUE )split_dataset( root, out_dir, group_by = NULL, fractions = c(0.7, 0.15, 0.15), seed = 0, mode = c("nested_dirs", "config_file"), subdirs = c("images", "masks"), column_sep = ";", strict = TRUE, relative = TRUE )
root |
The directory holding the samples, laid out as |
out_dir |
Where to write |
group_by |
A pattern picking the group out of a sample's name; see above. |
fractions |
Two or three shares that add up to 1: train, validation and optionally test. Measured in samples, not in groups, so patients with very different numbers of slices do not skew them. |
seed |
Makes the split reproducible. The same inputs and seed give the same split on any machine and in any file order - without that, two runs are not comparable and the reason for a difference is impossible to find. |
mode |
|
subdirs |
The image and mask subdirectory names, for |
column_sep |
Separator between several paths in one CSV cell. |
strict |
Treat an incomplete sample as an error rather than skipping it. |
relative |
Write paths relative to the CSV where possible, so the dataset and its split can be moved together. |
A platypus_split: the three paths, plus how many samples and groups each set
received. Pass it straight to segmentation_data().
group_by existsSplitting per file is the obvious thing and, for medical images, the wrong one. A scan is many slices of one patient, so dividing slices at random puts the same anatomy in training and in validation at once. The model then meets the neighbouring slice of what it is being tested on, validation measures memory rather than generalisation, and Dice comes out several points too high with nothing in the result to say so.
group_by names the unit that must not be split - usually a patient, sometimes a study,
a scanner or a site. Every sample sharing a group lands in exactly one set.
group_by is passed to the engine exactly as written, and read there as a Python
regular expression. In practice the dialects agree on everything you are likely to need
^, $, \\d, [A-Z], +, *, () - and R's own quoting rules still apply, so a
backslash is doubled: "^(patient\\\\d+)_". It is not translated on the way through,
deliberately: a translation that quietly got it wrong would produce a working split with
the wrong groups in it, which is the failure this argument exists to prevent.
A pattern that matches no sample is an error, never a fallback to one-group-per-sample.
evaluate_cases(), which reports a score per case rather than one per set.
## Not run: split <- split_dataset( "scans", "splits", group_by = "^(patient\\\\d+)_", fractions = c(0.7, 0.15, 0.15) ) split spec <- platypus_spec( data = segmentation_data(split, colormap = binary_colormap), models = list(u_net("unet", input_shape = c(256, 256))) ) ## End(Not run)## Not run: split <- split_dataset( "scans", "splits", group_by = "^(patient\\\\d+)_", fractions = c(0.7, 0.15, 0.15) ) split spec <- platypus_spec( data = segmentation_data(split, colormap = binary_colormap), models = list(u_net("unet", input_shape = c(256, 256))) ) ## End(Not run)
Reads the CSV that split_dataset() wrote and hands back the paths ready to use.
split_path(split, which = c("train", "validation", "test"))split_path(split, which = c("train", "validation", "test"))
split |
|
which |
|
The reason this exists rather than read.csv(): the CSVs store paths relative to
themselves, so that a dataset and its split can be moved or mounted elsewhere together.
Read directly, those paths do not open from wherever your session happens to be - which is a
trap worth removing rather than documenting.
A data frame with key, group, images and masks, the paths made absolute.
images and masks hold one path per sample, or several separated by the split's
separator when a sample has several files.
## Not run: split <- split_dataset("scans", "splits", group_by = "^(patient\\\\d+)_") validation <- split_path(split, "validation") images <- read_images(validation$images, size = c(32, 32, 32), channels = 1) ## End(Not run)## Not run: split <- split_dataset("scans", "splits", group_by = "^(patient\\\\d+)_") validation <- split_path(split, "validation") images <- read_images(validation$images, size = c(32, 32, 32), channels = 1) ## End(Not run)
One row per model per epoch, with the loss, whatever metrics were asked for, how long the epoch took and the learning rate it ran at. Two columns arrive whether or not anybody asked:
training_history(object)training_history(object)
object |
foreground - the share of the prediction that is not background. Not a measure
of quality, because nothing is compared against the truth: it answers whether the
model is still producing a segmentation at all. A model collapsing to background
reports a plausible loss and, with include_background = TRUE, a Dice around 0.5.
val_<metric>_worst - the lowest any single case scored. The distribution is the
finding and the mean is where a failure hides, so the number worth reading is in the
table rather than only in summary() after the run.
A data frame with one row per model per epoch.
## Not run: history <- training_history(fit) head(history) # For a detector the loss is broken into four terms on purpose: a run whose # coordinate loss falls while its objectness loss does not is finding the right # places and refusing to commit, and the reverse is confident nonsense. ## End(Not run)## Not run: history <- training_history(fit) head(history) # For a detector the loss is broken into four terms on purpose: a run whose # coordinate loss falls while its objectness loss does not is finding the right # places and refusing to commit, and the reverse is confident nonsense. ## End(Not run)
The Data Science Bowl stores one file per nucleus; datasets like it are common. Later masks win where they overlap, so an explicit class beats the background it sits on.
unite_masks(masks)unite_masks(masks)
masks |
A list of |
One height x width matrix.
# Data Science Bowl stores one file per nucleus, so a sample's mask arrives as several # matrices that have to become one. Later masks win where they overlap. one <- matrix(1L, 8, 8); one[2:4, 2:4] <- 2L two <- matrix(1L, 8, 8); two[6:7, 5:7] <- 2L united <- unite_masks(list(one, two)) table(united)# Data Science Bowl stores one file per nucleus, so a sample's mask arrives as several # matrices that have to become one. Later masks win where they overlap. one <- matrix(1L, 8, 8); one[2:4, 2:4] <- 2L two <- matrix(1L, 8, 8); two[6:7, 5:7] <- 2L united <- unite_masks(list(one, two)) table(united)
Shape and voxel spacing, read through the engine so the numbers are the ones training would use - including the canonical reorientation, which changes what "the first axis" means.
volume_info(paths)volume_info(paths)
paths |
One or more paths to NIfTI files. |
A data frame with one row per file: path, spacing_1/2/3 in millimetres,
shape_1/2/3 in voxels, and voxel_ml, the volume of one voxel in millilitres.
mask_volume(), which turns a segmentation into millilitres.
## Not run: volume_info("scans/case_01/images/ct.nii.gz") ## End(Not run)## Not run: volume_info("scans/case_01/images/ct.nii.gz") ## End(Not run)
One detector, as an entry in platypus_spec()'s models.
yolo3( name, input_shape = c(416, 416), channels = NULL, anchors = NULL, anchors_per_grid = NULL, ignore_threshold = NULL, score_threshold = NULL, nms_threshold = NULL, operating_point = NULL, min_visibility = NULL, box_loss = NULL, optimizer = NULL, callbacks = NULL, augmentation = NULL, epochs = NULL, batch_size = NULL, weights = NULL, fit = NULL )yolo3( name, input_shape = c(416, 416), channels = NULL, anchors = NULL, anchors_per_grid = NULL, ignore_threshold = NULL, score_threshold = NULL, nms_threshold = NULL, operating_point = NULL, min_visibility = NULL, box_loss = NULL, optimizer = NULL, callbacks = NULL, augmentation = NULL, epochs = NULL, batch_size = NULL, weights = NULL, fit = NULL )
name |
Unique within a spec; names the outputs. |
||||||||||||
input_shape |
|
||||||||||||
channels |
Channels the model takes. |
||||||||||||
anchors |
Box shapes to predict offsets from, as fractions of the input size:
a list of three groups, coarsest grid first, each group a list of |
||||||||||||
anchors_per_grid |
How many anchors to fit per grid when |
||||||||||||
ignore_threshold |
A cell that is not responsible for an object but whose box overlaps one this much is left unsupervised rather than trained towards "no object". Without it, a nearly-correct guess beside the responsible cell is punished, which is the one thing a detector should not learn. |
||||||||||||
score_threshold |
Discard a predicted box below this confidence. Low on purpose: average precision is computed over the whole ranking, so a cut here is a ceiling on recall that no threshold chosen later can lift. |
||||||||||||
nms_threshold |
Two boxes of the same class overlapping this much are one object and the lower-scoring one is dropped. Per class, so a platelet on a red cell survives. |
||||||||||||
operating_point |
The confidence at which precision and recall are reported.
Separate from |
||||||||||||
min_visibility |
How much of a box must survive an augmentation that removes part of the frame, as a fraction of its original area. A convention rather than a measurement; what is defensible is the direction. A box keeping two pixels of a cell teaches the model that a two-pixel fragment is a whole cell, which produces false positives everywhere, while dropping a heavily truncated object only fails to teach it about that object. Irrelevant unless a transform can lose part of the frame, which flips and rotations never do. |
||||||||||||
box_loss |
How the box coordinates are scored:
Average precision separates them in neither direction - both gaps are smaller than the
seed spread. The overlap of the boxes it matched does separate them, and The reason is visible in Choose it to read the loss, not to raise the score. The offsets term cannot reach
zero - cross-entropy against a soft target bottoms out at that target's entropy - so a
converged run and a stalled one print the same number, near 5.9. Under |
||||||||||||
optimizer, callbacks, augmentation, epochs, batch_size
|
As for |
||||||||||||
weights |
A registry name such as |
||||||||||||
fit |
Set |
Anchors. Unset, they are fitted to your training boxes with k-means under an IoU distance, which is what you want: COCO's nine anchors borrowed for blood cells cover their boxes at a mean overlap of 0.65 against 0.88 for anchors fitted to them. Fitted anchors are recorded with the run and in the sidecar when weights are exported, because a detector cannot be reloaded without them - the same weights read with other anchors decode every box scaled by a fixed factor, with plausible boxes, plausible scores, wrong places, and nothing in the output to say so.
For the same reason, naming weights and anchors together is an error: loading takes
the anchors recorded beside the file, which would leave the ones here describing nothing.
One model specification.
detection_data(), plot_boxes(), available_weights()
yolo3("cells", input_shape = c(416, 416)) yolo3("cells", input_shape = c(416, 416), weights = "bccd-yolo3", fit = FALSE)yolo3("cells", input_shape = c(416, 416)) yolo3("cells", input_shape = c(416, 416), weights = "bccd-yolo3", fit = FALSE)