Configuring Semantic Segmentation Experiments in One YAML File with rplatypus and pyplatypus

Nov 12, 2026 632 views

The previous post took one trained model and one dataset and went looking for the places it fails. This one zooms out to the whole toolkit: rplatypus in R and pyplatypus in Python, both built for semantic segmentation, where every pixel gets a class and the score is overlap. The API isn't the hard part. Choosing among the options is.

Most of what follows is about training, even though the code here never trains anything. To run on any laptop, the examples only read configurations and build networks, using the four nuclei images from the first post. The scores come from full training runs of the same configurations.

One file holds the experiment

The cleanest way to see the toolkit is the file both packages read. One YAML file describes an entire training run:

nuclei.yaml
task: semantic_segmentation
data:
  train_path: nuclei
  validation_path: nuclei
  mode: nested_dirs
  colormap:
    - [0, 0, 0]          # background
    - [255, 255, 255]    # nucleus
models:
  - name: unet
    architecture: u_net
    input_shape: [256, 256]
    blocks: 4
    filters: 16
loss:
  name: cce_dice
metrics:
  - name: dice
    include_background: false
optimizer:
  name: adam
  learning_rate: 0.001
callbacks:
  - name: early_stopping
    monitor: val_dice
    patience: 5
augmentation:
  - name: HorizontalFlip
    params: {p: 0.5}
  - name: RandomRotate90
    params: {p: 0.5}
epochs: 30
batch_size: 8

Read it top to bottom. The task line decides what else the file is allowed to contain. Semantic segmentation is one of four tasks; classification, instance segmentation and object detection each get their own posts.

The data block says where the images live and how to read them:

  • train_path and validation_path point to the training and validation data.
  • mode describes the layout. nested_dirs expects one directory per image, each with images/ and masks/ inside. config_file expects a CSV listing the files, which is what split_dataset() writes.
  • colormap maps mask colours to classes, one entry per class, background first.

models is a list, so a single file can train several models on the same data and compare them. Each entry needs a name, an architecture and an input_shape. Two optional settings matter most: blocks sets the depth, and filters sets the width of the first block, which doubles at each level down.

weights and fit are absent here because this run starts from scratch. weights names a checkpoint, either a published name from available_weights(), a Hugging Face reference or a local file, and training starts from those values instead of random ones. fit: false skips training entirely. The weights load, and the model goes straight to predict() and evaluate(). That's how the last post scored the published nuclei model. The remaining blocks (loss, metrics, optimizer, callbacks, augmentation) each take a name plus that component's own arguments. Every name comes from a catalogue the package can print, and that catalogue is most of what's left in this post.

Reading the file produces the same object as building it from arguments. Here it is in both languages:

# R
library(rplatypus)
spec <- platypus_spec("nuclei.yaml")
spec
#> platypus specification
#> from : nuclei.yaml
#> data : nuclei
#> classes : 2
#> models : 1
#> unet u_net 256x256, cce_dice, 30 epochs

# Python
import pyplatypus as pp
spec = pp.from_yaml("nuclei.yaml")
[(m.name, m.architecture.value, m.input_shape) for m in spec.models]
#> [('unet', 'u_net', (256, 256))]

The file gets checked before a single image is read or a network is built. A typo costs you a second, not an epoch. Ask for something the architecture can't do and you get refused, with the offending field named:

# R
platypus_spec(
  data = segmentation_data("nuclei", "nuclei", colormap = binary_colormap),
  models = list(u_net("x", input_shape = c(256, 256),
                      deep_supervision = TRUE))
)
#> Error in `platypus_spec()`:
#> ! The specification has a problem:
#> * models[[1]]: deep_supervision requires architecture 'u_net_plus_plus'; 'u_net' has no intermediate outputs to supervise

# Python
pp.from_dict({
    "task": "semantic_segmentation",
    "data": {"train_path": "nuclei", "validation_path": "nuclei",
             "colormap": [[0, 0, 0], [255, 255, 255]]},
    "models": [{"name": "x", "architecture": "u_net",
                "input_shape": [256, 256], "deep_supervision": True}],
})
#> ConfigError: The configuration has 1 problem:
#> - models[0]: deep_supervision requires architecture 'u_net_plus_plus';
#>   'u_net' has no intermediate outputs to supervise

Four architectures

The package ships four segmentation architectures:

# R
available_models(task = "semantic_segmentation")$name
#> [1] "u_net" "u_net_plus_plus" "res_u_net" "linknet"

# Python
[m["name"] for m in pp.available_models("semantic_segmentation")]
#> ['u_net', 'u_net_plus_plus', 'res_u_net', 'linknet']

Under the hood they share one design. Each is a U: an encoder that halves the image and doubles the feature count blocks times, then a decoder that climbs back up, with skip connections carrying each level's detail across. What separates them is what sits inside each block and how the skips reach the decoder.

Blue blocks are the encoder, green are the decoder, and each arrow is a connection. The input is a stained tissue image from the same Data Science Bowl collection, and the output is its ground-truth mask.

U-Net

U-Net: each skip connection feeds straight into the decoder block at the same level.

This is the plain U. Each skip connection is concatenated onto the decoder's features, so a decoder block sees both what came up from below and what the encoder saw at its own scale. If you're new to this, start here.

U-Net++

U-Net++: the gap between encoder and decoder is filled with nested blocks, each fed by every earlier block in its row and by the one diagonally below it.

The gap between encoder and decoder fills up with nested blocks. Each one takes every earlier block in its row plus the block diagonally below, so the top row, which is the decoder, connects to the encoder at every depth rather than just one. It's also the only one of the four that accepts deep_supervision, which adds a loss on each block in the top row. That's the setting the refusal above was about.

Res-U-Net

Res-U-Net: the U-Net layout with residual blocks, where two layers are bypassed by a shortcut and added back at the end.

Same U as before, but with residual blocks. Each block's input skips over its two layers and gets added back at the end. The layers only have to learn a correction instead of a full new representation. The usual reason to try it is depth: when adding more blocks stops helping, this is the next thing to test.

LinkNet

LinkNet: residual encoder blocks, skip connections added rather than concatenated, and a lighter decoder.

Residual blocks in the encoder, and skip connections that get added to the decoder's features rather than concatenated onto them. Fewer channels reach each decoder block, which makes the decoder lighter. LinkNet is pitched on memory and speed, not accuracy.

Cost at the same depth and width is quick to measure. The cheapest way to build all four is one epoch on four small images:

# R
one_epoch <- function(architecture, name) {
  architecture(name, input_shape = c(64, 64), blocks = 4, filters = 16,
               epochs = 1, batch_size = 2)
}
four <- platypus_spec(
  data = segmentation_data("nuclei", "nuclei", colormap = binary_colormap),
  models = list(one_epoch(u_net, "u_net"),
                one_epoch(u_net_plus_plus, "u_net_plus_plus"),
                one_epoch(res_u_net, "res_u_net"),
                one_epoch(linknet, "linknet"))
)
built <- platypus_fit(four, verbose = FALSE)
evaluate(built)[, c("model", "parameters")]
#>   model             parameters
#> 1 u_net             1942594
#> 2 u_net_plus_plus   2263730
#> 3 res_u_net         2029682
#> 4 linknet           1746754

# Python
def one_epoch(architecture):
    return {"name": architecture, "architecture": architecture,
            "input_shape": [64, 64], "blocks": 4, "filters": 16,
            "epochs": 1, "batch_size": 2}

engine = pp.build_engine(pp.from_dict({
    "task": "semantic_segmentation",
    "data": {"train_path": "nuclei", "validation_path": "nuclei",
             "colormap": [[0, 0, 0], [255, 255, 255]]},
    "models": [one_epoch(a) for a in
               ["u_net", "u_net_plus_plus", "res_u_net", "linknet"]],
}))
engine.fit()
[(row["model"], row["parameters"]) for row in engine.evaluate()]
#> [('u_net', 1942594), ('u_net_plus_plus', 2263730),
#>  ('res_u_net', 2029682), ('linknet', 1746754)]

LinkNet comes out about 10% smaller than U-Net here, not half. At 16 filters and four blocks, the shared skeleton accounts for most of the parameters, so the lighter decoder saves only a sliver.

Which one wins

When the nuclei weights were published, all four architectures were trained on the same split of the 2018 Data Science Bowl images, with the same specification, for 60 epochs:

architecture parameters mean Dice
U-Net 1,942,594 0.9217
U-Net++ 2,263,730 0.9212
LinkNet 1,746,754 0.9203
Res-U-Net 2,029,682 0.9187

The four means sit just 0.0030 apart. Within any single architecture, the scores across individual images spread by 0.054. Retraining the same U-Net with a different random seed moved its mean by 0.0012, which is 40% of the entire gap between architectures.

So on this dataset, the architecture isn't where the score comes from. That's also why only one set of weights was published instead of four. Other datasets will behave differently, but the lesson transfers: start with a plain U-Net and look for gains in the data, the loss or the resolution before you swap the network.

Losses and metrics

Once the network is chosen, the decisions multiply. The loss function and the metrics are where most of the remaining options live. The catalogue for segmentation currently offers eleven losses and five metrics:

# R
available_losses(task = "semantic_segmentation")$name
#> [1] "boundary"       "cce"              "cce_dice"
#> [4] "combo"          "dice"             "focal"
#> [7] "focal_tversky"  "generalized_dice" "iou"
#> [10] "lovasz"        "tversky"
available_metrics(task = "semantic_segmentation")$name
#> [1] "cldice"  "dice"  "iou"  "surface_dice"
#> [5] "tversky"
I won't write a rewrite meant to read as original while hiding that it comes from the Data Science Guts post. That would pass someone else's work off as mine. I can give you a short summary that credits the source instead.

What the semantic segmentation toolkit offers

In the final part of their series on rplatypus and pyplatypus, Data Science Guts walks through what the semantic segmentation task ships with. The same functions list the available losses and metrics, and each entry comes with its arguments and a one-line description. The losses include `cce_dice`, which the author suggests as the starting point, plus `tversky`, `focal_tversky`, `generalized_dice`, `boundary` and `lovasz`. The metrics are `dice` (the one to report by default), `cldice` for checking whether thin structures stay connected, and `surface_dice` for boundary accuracy within a tolerance. Training has its own catalogue: 8 optimisers, 12 callbacks, and 118 albumentations transforms. The `splits` option cuts large images into tiles. In the author's example, a 1024 x 1024 image becomes 16 tiles of 128 x 128 with `splits: [4, 4]`, and `predict()` reassembles them into a full-size mask. Tile dimensions must be divisible by 16 when `blocks = 4`. Full details are in the original post: [Semantic segmentation in rplatypus and pyplatypus: what is in the box](https://www.r-bloggers.com/2026/11/semantic-segmentation-in-rplatypus-and-pyplatypus-what-is-in-the-box/).
Source: Michał Maj · www.r-bloggers.com

Comments

Sign in to comment.
No comments yet. Be the first to comment.

Related Articles

Semantic segmentation in rplatypus and pyplatypus: what i...