--- title: "Writing a configuration" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Writing a configuration} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>", eval = FALSE, error = FALSE) ``` One YAML file describes a whole run, and `platypus_spec()` reads it. This is the walk through that file; the exhaustive list of fields lives on the engine's site, because the file is the engine's format and keeping a second copy here would mean two of them going out of step: [every configuration field](https://maju116.github.io/pyplatypus/config.html). **The same file works in both packages.** R reads it with `platypus_spec()`, Python with `from_yaml()`, and both hand it to the same validator. Nothing below is R-specific except the lines that run it. Arguments and YAML produce the same object and nothing downstream can tell which was used, so a configuration file and an R script describe the same experiment - and either can be handed to a colleague. ## One file, one run The shortest thing that trains: ```yaml task: semantic_segmentation data: train_path: data/train validation_path: data/validation colormap: [[0, 0, 0], [255, 255, 255]] models: - name: first_try input_shape: [256, 256] ``` ```{r} spec <- platypus_spec("run.yaml") fit <- platypus_fit(spec) evaluate(fit) ``` Four things are required and the rest have defaults: `task`, `data.train_path`, and a `name` and `input_shape` for each model. Everything else - the architecture, the loss, the optimiser, how many epochs - has a default that lives in the engine, so **a short configuration is a complete one** rather than an incomplete one. `?u_net` has a *Where the defaults live* section saying why they are not written into the R signatures. `colormap` says which colour in a mask means which class, darkest first. Masks stored as label maps rather than pictures use `labels: [0, 1]` instead, and giving both is an error: exactly one says how a mask is encoded. ## Several models at once `models:` is a list, and that is the reason this file format exists rather than only the code path. Each entry trains on the same data and lands in the same table, so comparing variants is one run rather than a loop somebody has to write. ```yaml task: semantic_segmentation data: train_path: data/train validation_path: data/validation colormap: [[0, 0, 0], [255, 255, 255]] models: - name: shallow input_shape: [256, 256] blocks: 3 filters: 16 - name: deep input_shape: [256, 256] blocks: 5 filters: 32 architecture: u_net_plus_plus ``` ```{r} fit <- platypus_fit(platypus_spec("run.yaml")) evaluate(fit) # one row per model training_history(fit) # one row per model per epoch ``` `name` has to be unique, because it is how a model is identified in every score table, in every record and in `predict(fit, model = ...)`. Two models called the same thing is refused rather than resolved, by name: `model names must be unique; repeated: shallow`. Two entries differing only in `blocks` is a legitimate configuration and a common one: this is for comparing variants, not only architectures. ## Dividing one folder With no `validation_path`, say how to divide `train_path`: ```yaml task: semantic_segmentation data: train_path: data/all split: fractions: [0.7, 0.15, 0.15] group_by: "^(patient\\d+)_" colormap: [[0, 0, 0], [255, 255, 255]] models: - name: by_patient input_shape: [256, 256] ``` Two fractions give train and validation, three add a test set. Nothing is written to disk - the samples are divided after they are discovered, so there are no new folders and no CSV. `split_dataset()` is still the tool when the CSVs themselves are the point. **`group_by` is required and may be `NULL`.** It is a regular expression whose first group is the thing that must not appear on both sides - a patient, a scan, a slide. The mistake it prevents leaves no trace: slices of one patient in both sets make validation measure memory rather than generalisation, and every number in the output still looks reasonable. An omitted key would be that choice made silently; `null` is the same choice made on purpose, for data where every sample is independent. Exactly one of `split` and `validation_path`. If a run genuinely has no validation set - a final fit on everything - say `validation: false`, which is the third answer rather than an absence, because "I have no validation set" and "I forgot" look identical otherwise. The same from R, where the argument answers the same question three ways: ```{r} segmentation_data("data/all", split = list(fractions = c(0.8, 0.2), group_by = NULL)) segmentation_data("data/train", validation = "data/validation") segmentation_data("data/all", validation = FALSE) ``` ## Augmentation, callbacks, and the rest of a model ```yaml task: semantic_segmentation data: train_path: data/train validation_path: data/validation colormap: [[0, 0, 0], [255, 255, 255]] models: - name: augmented input_shape: [256, 256] epochs: 40 loss: {name: dice} metrics: [{name: iou}, {name: dice}] optimizer: {name: adam, learning_rate: 0.001} callbacks: - {name: early_stopping, monitor: val_loss, patience: 10} - {name: model_checkpoint, monitor: val_iou, path: runs/best.safetensors} augmentation: - {name: HorizontalFlip, params: {p: 0.5}} - {name: RandomBrightnessContrast, params: {p: 0.3}} ``` Every one of those blocks has an R constructor that builds the same thing, and the help pages for them are where the measurements are: `?losses`, `?metrics`, `?optimizers`, `?callbacks`. ```{r} u_net( "augmented", input_shape = c(256, 256), epochs = 40, loss = loss_dice(), metrics = list(metric_iou(), metric_dice()), optimizer = optimizer_adam(learning_rate = 0.001), callbacks = list( callback_early_stopping(monitor = "val_loss", patience = 10), callback_model_checkpoint(monitor = "val_iou", path = "runs/best.safetensors") ), augmentation = list( augmentation_step("HorizontalFlip", p = 0.5), augmentation_step("RandomBrightnessContrast", p = 0.3) ) ) ``` **There is no `mode` on a callback that watches a number.** The direction is taken from the name: anything ending in `loss` is better lower, anything else better higher. So `monitor = "val_iou"` maximises and `monitor = "val_loss"` minimises, and there is nothing to set inconsistently. **Augmentation applies to the training data only** - never to validation, where the point is to measure the same thing every epoch. The names are albumentations' own, and `available_transforms()` lists what the installed version offers; at `rank = 3` the list is shorter, because support for volumes is uneven. Each step is probed at the model's own input size while the pipeline is built, so a transform that cannot do the job says so before the first epoch rather than during it. ## Overlapping classes instead of one per voxel `colormap` and `labels` both say the same thing about a voxel: it is in exactly one class. `regions` is the third way of naming classes and the only one that drops that assumption - classes named as unions of a label map's values, which is how BraTS and the lesion-inside-an-organ family actually store their ground truth. ```yaml task: semantic_segmentation data: train_path: data/train validation_path: data/validation regions: - {name: whole tumour, of: [1, 2, 4]} - {name: tumour core, of: [1, 4]} - {name: enhancing, of: [4]} models: - name: brats input_shape: [240, 240] loss: {name: focal} ``` The same from R. A region is a `list(name = , of = )`, and `of` takes the label-map values that belong to it: ```{r} spec <- platypus_spec( data = segmentation_data( "data/train", "data/validation", regions = list( list(name = "whole tumour", of = c(1, 2, 4)), list(name = "tumour core", of = c(1, 4)), list(name = "enhancing", of = 4) ) ), models = list(u_net("brats", input_shape = c(240, 240), loss = loss_focal())) ) ``` The last region shows that a single value is **ordinary** rather than an edge case: BraTS's enhancing tumour is one label. The task is still `semantic_segmentation`. What changes is what a channel means. **There is no background entry, so the class count is not the one you would write as `labels`.** Those same masks as `labels = c(0, 1, 2, 4)` are **four** classes; as the three regions above they are **three** channels. And a voxel whose value is `4` is in all three of them at once, which is the point rather than a tolerated side effect. **Each channel is an independent binary question** - a sigmoid per channel rather than one softmax across them - so the channels do not compete and do not sum to one. That is what makes the overlap expressible, and it is also why a prediction cannot be written back as a single picture with `save_masks()`: one voxel gets one colour, and a voxel here may belong to three classes. So `save_masks()` refuses such a prediction rather than taking the strongest channel, and `predict()` refuses `type = "class"` for the same reason - there is no one class per voxel to return. Ask for `type = "probability"` and write it with `save_class_masks()`, which gives one binary file per region, named after the region. The channel order is the order the regions were written in, which is also the order `inspect_masks()` names them in and the order every per-class score is reported in. **Two regions are the minimum**, because one region is ordinary binary segmentation and `labels = c(0, 1)` already says that - with a background channel, which `regions` has none of. Disjoint regions are allowed and are not a mistake: they group several values into one class and drop the background channel, which is a useful thing to ask for and is refused nowhere. **The failure this invites is quiet, so it is refused loudly.** A region whose `of` matches nothing in the data - `of = 3` against a label map holding `1`, `2` and `4` - is a channel of zeros. It trains to predict nothing and scores a Dice of 0 for that region while every other number in the run looks ordinary, which is the kind of result that gets read as "that class is hard". `platypus_fit()` refuses it before training, and `inspect_masks()` asks the same question over as much of the data as you want - **naming the region rather than giving its position**, because `missing_classes: 1` would leave you counting entries in the file above: ```{r} inspect_masks(spec) #> split unmatched samples_checked total_samples present_classes missing_classes #> 1 train 0.0000 500 1251 whole tumour, ... enhancing ``` ### When the overlap was drawn separately: `classes_from` `regions` needs the overlap to be a union of one label map's values, which is how BraTS stores it: one integer per voxel, and the scored classes are conventions over those integers. Some overlaps are not like that. A vessel crossing an organ, or two annotators' masks of the same structure, were drawn independently, and no single integer can hold both. Those arrive as **one binary file per class**, and `classes_from` names them - one pattern per class, in class order, matched against a sample's mask files. ```{r} spec <- platypus_spec( data = segmentation_data( "data/train", "data/validation", classes_from = c("_vessel\\.png$", "_organ\\.png$") ), models = list(u_net("crossing", input_shape = c(256, 256))) ) ``` Everything above still holds: independent binary questions, no background entry, a prediction `save_masks()` refuses and `save_class_masks()` writes. The difference between the two namings is the file on disk and nothing else. **Every pattern must match exactly one of a sample's mask files, and every mask file must match a pattern.** Both halves are refused, because each is invisible from the other side: a pattern matching nothing is a class the model is never shown, and a file matching no pattern is a class the data has and nobody asked for. Patterns are Python regular expressions, like `channels_from` and `group_by`, so R's own backslashes double. The order is written rather than inferred, and here it matters more than for channels: a model trained with the vessel in the first channel and used where the first channel is the organ answers plausibly, in range, and nothing further down can notice. ## Instances instead of one mask `task: instance_segmentation` keeps the objects apart rather than uniting them. The data block gets **shorter** rather than longer: no `colormap` and no `labels`, because there are always exactly two classes - background and object - and what names the objects is the directory of per-object mask files itself. ```yaml task: instance_segmentation data: train_path: data/train validation_path: data/validation mode: nested_dirs models: - name: rim input_shape: [256, 256] separation: {name: rim, width: 2} loss: {name: cce_dice} decode: [{name: nearest_seed}, {name: connected_components}] - name: flows input_shape: [256, 256] separation: name: flows loss: {name: mse, where: foreground} ``` The same two models from R. `instance_data()` is what says which task this is - an instance data block declares nothing a segmentation one lacks, so the constructor is the word: ```{r} spec <- platypus_spec( data = instance_data("data/train", "data/validation"), models = list( u_net( "rim", input_shape = c(256, 256), separation = separation_rim(width = 2), loss = loss_cce_dice(), decode = list(decode_nearest_seed(), decode_connected_components()) ), u_net( "flows", input_shape = c(256, 256), separation = separation_flows(loss = loss_mse(where = "foreground")) ) ) ) fit <- platypus_fit(spec) evaluate(fit) ``` Two axes, and they are deliberately different shapes. **`separation` is one block per model and has no default**: it is what the network is trained to predict, so it sets the width of the head - three channels for `rim`, four for `flows` at two classes - and that width is derived rather than written anywhere. **`decode` is a list**, because several decodings share one forward pass: a second decoding of 134 images costs 1.41 s against an hour and a half for a second training run. `evaluate()` then returns a row per *model and decoding*. **Which mask column to read is a decision the table makes for you and it used to make it wrongly.** Every row carries `foreground_dice` - the binary overlap of anything the model called an object against anything that is one, the same arithmetic whatever the separation is - and that is the one to read *across* models. The `dice` beside it is the training metric, a mean over the class channels the head has, so three for `separation_rim()` against two for `separation_flows()`: read down one model's rows, never across two. On the Data Science Bowl's validation cases the class column showed two masks 0.09 apart that are 0.0062 apart, and in the other direction. **No value appears in both lists**, which is what settles which field a new idea belongs in: `separation` is a noun and `decode` is a verb. `separation_rim()` can be decoded by `decode_nearest_seed()` or `decode_watershed()`, `separation_flows()` by `decode_follow_flows()`, and `decode_connected_components()` reads no signal at all and works with every target. Leave `decode` out and it derives to the signal's own decoding plus `connected_components`; give a list and you get the list you gave. A model's own `loss` and `metrics` score the mask at either separation, so they are an overlap or a cross-entropy loss here as everywhere else. `separation_flows()` carries its own `loss` for the two vector channels, which is where `loss_mse()`, `loss_l1()` and `loss_smooth_l1()` belong: `where = "foreground"` averages the error over the voxels the target is defined on, and on these images that is 13.97% of them. **What this task is worth is measured, and it is not flattering.** On the Data Science Bowl the separation signal is worth +0.068 of instance average precision at best while a perfect mask is worth +0.2398, so the mask is the limit by a factor of 3.6 - and a *perfect* semantic mask decoded by `connected_components` still scores only 0.7655. That is what the yardstick row is for: a run that improved the mask and did nothing for the separation improves every row at once, and without the row that ignores the signal there is nothing to notice it against. **One thing is missing from R rather than from the engine, and it is missing on purpose rather than by oversight**: `predict()` on an instance fit refuses. The run and its scores work - `evaluate()` gives the row per model and decoding above - but there is no call yet that brings the objects themselves back into R as a label map, so `predict()` says so instead of returning a mask that is not what you asked for. `platypus#103`. ## Detection instead of segmentation `task: object_detection` changes which fields the file accepts. Boxes rather than masks, so `classes` and an annotation format rather than a colormap: ```yaml task: object_detection data: train_path: data/train validation_path: data/validation annotation_format: pascal_voc classes: [rbc, wbc, platelets] models: - name: cells input_shape: [416, 416] ``` **Leave `anchors` out and they are fitted to your own boxes** by k-means on an IoU distance, `anchors_per_grid` of them per grid. That is worth about 0.23 of mean overlap against COCO's nine on blood cells - measured, 0.65 against 0.88 - so the default is the useful one and naming anchors by hand is for reproducing a published model. `detection_anchors()` reports them and `plot_anchors()` draws them against the boxes they were fitted to. ## One label instead of a mask `task: classification` answers with one label for a whole image rather than one per voxel. The data block loses everything that describes a mask and gains the one thing a label needs - `classes`, in class order: ```yaml task: classification data: train_path: data/train validation_path: data/validation mode: nested_dirs classes: [benign, malignant] models: - name: ours input_shape: [128, 128] blocks: 4 filters: 16 loss: {name: cce} metrics: [{name: auc}, {name: balanced_accuracy}] - name: resnet input_shape: [224, 224] encoder: resnet34 pretrained: true freeze_encoder: 2 pooling: max head_dropout: 0.2 loss: {name: focal, gamma: 2} ``` The same thing from code is `classification_data()` and `classifier()`: ```{r} spec <- platypus_spec( data = classification_data("data/train", "data/validation", classes = c("benign", "malignant")), models = list( classifier("ours", input_shape = c(128, 128), blocks = 4, filters = 16, loss = loss_cce(), metrics = list(metric_auc(), metric_balanced_accuracy())), classifier("resnet", input_shape = c(224, 224), encoder = "resnet34", pretrained = TRUE, freeze_encoder = 2, pooling = "max", head_dropout = 0.2, loss = loss_focal(gamma = 2)) ) ) ``` **Where the labelling is written follows from `mode` rather than being said twice.** In `nested_dirs` the directories under `train_path` are the classes, with the images inside them - the layout nearly every image dataset ships in - and in `config_file` the CSV's `label` column holds the name. `subdirs` is refused here, which is what keeps the two readings of `nested_dirs` apart: a sample is one image rather than a directory with the image in one subdirectory and what labels it in the other. **`classifier()` is the one model constructor in this package not named after an architecture**, and the missing field is the point rather than an omission. The network is a contracting path, pooled, with one dense layer on top, so `encoder` is the whole answer: unset it is ours, sized by `blocks` and `filters`, and named it is any of `available_encoders()`. The two models above are the comparison this task makes easiest - ours against a pretrained backbone on the same split. `blocks`, `filters` and `block_width` are **refused** beside `encoder` rather than ignored, because a backbone's own depth is then the only one there is, and `available_models("classification")` says all of this instead of returning rows. **`metric_auc()` is the default and that is a measurement rather than a preference.** On 40000 images at a prevalence of 3%, with the model's own probabilities as the truth so that only its discrimination varies, accuracy spans 0.0100 where the AUC spans 0.4631 - and accuracy is not monotone in quality: the model with no signal at all scores above two that have one. `metric_balanced_accuracy()` and `metric_f1()` do not rescue it, moving 0.0000 and -0.0005 between a real signal and a collapse, because a calibrated model at that prevalence never crosses the threshold and everything read off the hard prediction sees the same all-negative answer. `class_balance()` is what makes that prevalence visible before a run rather than after it. **A split cut out of the training folder is stratified**, which is the one part of the walk this task overrides. Measured over 400 seeds, a random 70/15/15 of 100 images at a 3% prevalence leaves some part with none of the rare class 91% of the time, 64% at 200 images and 20% at 500 - and a per-class metric cannot average over a class its pass never held, so the run dies at the end of the first epoch with the epoch already paid for. `group_by` beside it is refused when a group's images do not share a label, because keeping a patient on one side and keeping a class on both are then two instructions that cannot both be followed. **What `predict()` gives back is a table rather than an array**, which is the shape the rest of R wants. `type = "class"` is one row per image with `key`, `label`, `predicted` and `probability`, and `type = "probability"` is a matrix of images by classes with the keys as row names - so either one joins to whatever else you hold per image. `space` and the `cases`/`each` pair are refused by name: they describe a grid, and a split of masks too large to hold at once, and neither is a thing one label per image has. `evaluate_cases()` is refused for a different reason - a case here is one image whose score is 0 or 1, so a split's worst case is 0 for any model that gets a single image wrong. ### When the label is a set: `labels_from` A chest film with four findings is **four independent questions** rather than one of four alternatives, and `labels_from` is how that is said: one CSV column per class, in class order, each cell `0` or `1`. ```yaml task: classification data: train_path: data/train.csv validation_path: data/validation.csv mode: config_file classes: [atelectasis, effusion, nodule, mass] labels_from: [Atelectasis, Effusion, Nodule, Mass] models: - name: ours input_shape: [224, 224] blocks: 4 filters: 16 loss: {name: cce} metrics: [{name: auc}, {name: f1}] ``` The same thing from code: ```{r} spec <- platypus_spec( data = classification_data( "data/train.csv", "data/validation.csv", classes = c("atelectasis", "effusion", "nodule", "mass"), labels_from = c("Atelectasis", "Effusion", "Nodule", "Mass"), mode = "config_file" ), models = list( classifier("ours", input_shape = c(224, 224), blocks = 4, filters = 16, loss = loss_cce(), metrics = list(metric_auc(), metric_f1())) ) ) ``` **It is a naming rather than a switch**, for the reason `regions` and `classes_from` are on the mask side: the *target* changes and not only the activation. A `label` column structurally cannot say a film has two findings, so a `multilabel = TRUE` would have trained sigmoid channels against an exclusive target, raised nothing, and reported plausible numbers meaning exactly what softmax meant. **Position is the pairing.** The headers may be called whatever the dataset calls them - above they are capitalised and the classes are not - and the model still learns the classes in the order `classes` names them. `config_file` only, because an image cannot sit in two directories without being two files, and `strict_labels` is refused beside it rather than ignored: the columns *are* the classes, so no cell can hold a class nobody named. A cell that is not `0`, `1`, `0.0` or `1.0` is refused by name, and the case it is written for is `-1`. The datasets shaped this way write `-1` for *uncertain* and leave the cell empty for *not mentioned*; both have a defensible mapping, which is exactly why the reader must not pick one for you. An image with none of the findings is an ordinary labelled row, written as a line of zeros. **`metric_accuracy()` is refused here rather than redefined.** On 4000 images with findings at 10%, 5%, 3% and 2%, a model answering no to everything scores **0.8137** on exact match and **0.9507** per class - above a model holding half the available signal - so neither reading of it can be what a `monitor` watches. Use `metric_balanced_accuracy()`, `metric_f1()` or `metric_auc()`. **`predict()` answers in a different shape on this route**, because the answer is a different kind of thing: one row per image *per class*, with `key`, `class`, and `label` and `predicted` as logicals. An image with none of the findings is a row of `FALSE` per class rather than an empty cell, which could also mean unread - and a split **nobody labelled** is `NA`, which is the other half of that sentence and the reason `FALSE` can be trusted to mean *measured and absent*. There is no `probability` column - that column is the chosen class's own number, and with independent questions nothing was chosen - so `type = "probability"` is the whole of the answer there, one column per class. ### Predicting on images you have no labels for A test set is often just pictures, and that is an ordinary thing to have rather than a half-written configuration. Name them in a CSV with no label column - `key, images` and nothing else - and the split is read as unlabelled: ```yaml task: classification data: train_path: data/train.csv validation_path: data/validation.csv test_path: data/to-predict.csv mode: config_file classes: [benign, malignant] models: - name: ours input_shape: [224, 224] blocks: 4 filters: 16 ``` `predict()` and `evaluate()` then differ in what they can honestly do with it: - **`predict()` works**, and that is what the split is for. `label` comes back as `NA` rather than as a class name or as `FALSE` per class, because nothing said. - **`evaluate()` refuses it by name.** Scoring against labels that are not there would either fail somewhere inside the metric or, worse, report a number - and a zero in a table is read as a result. - **`class_balance()` refuses it too**, for the same reason: counted anyway it says every class has no images, which reads as a finding about the dataset rather than about there being nothing to count. The `key` column is what makes the answer joinable to whatever else you hold - a pixel size, a patient identifier, a date - so it is worth writing even where the row order would do. **One layout cannot do this and the refusal says so.** `nested_dirs` makes the directory the label, so there is no unlabelled form of it: an image has to sit somewhere, and where it sits is the answer. Point `test_path` at a CSV instead, or at the images themselves. ## Not training at all ```yaml task: semantic_segmentation data: train_path: data/images validation_path: data/images colormap: [[0, 0, 0], [255, 255, 255]] models: - name: published input_shape: [256, 256] weights: dsbowl-unet fit: false ``` `weights` takes a registry name, a local path, or `hf://owner/repo/file@commit` - and the `hf://` form requires the commit, because weights that change under a stable name are the worst kind of irreproducibility. A registry name carries one already; `available_weights()` lists them. With `fit: false` nothing trains and `predict(fit)` works immediately. The weights are checked against the model they are being loaded into *before* torch is touched: a shape, a channel count or a class count that does not match is a refusal naming the field, not a tensor-size error from inside a loss. ## What is checked, and when Validation happens in this order, and the first two cost no epochs: 1. **The file, against the schema.** Unknown fields, missing required ones, a `name:` that no block has, a callback watching a number nothing will produce. Before any path is read - and before Python starts, for the checks R can make itself. 2. **The data on disk**, when the run begins: that the folders exist, that images and masks correspond, and that every class in `colormap` or `labels` actually appears in the masks, because a declared class appearing in none has a target channel of zeros, so the model is never shown what it is being asked to find. `inspect_masks()` runs that check on its own, and `platypus_fit(check_masks = FALSE)` skips it. 3. **Everything else**, during the run. A refusal names the field and says what to do. The engine's own messages are translated into R conditions rather than passed through as Python tracebacks, so `tryCatch(platypus_spec("run.yaml"), error = function(e) conditionMessage(e))` gives a sentence rather than a stack. ## The same run from code The file is not a second implementation. These describe the same experiment: ```{r} spec <- platypus_spec("run.yaml") spec <- platypus_spec( data = segmentation_data( "data/train", validation = "data/validation", colormap = list(c(0, 0, 0), c(255, 255, 255)) ), models = list(u_net("first_try", input_shape = c(256, 256))) ) ``` And `as.list(spec)` is the file, so a run built from arguments can be written out and kept: ```{r} yaml::write_yaml(as.list(spec), "run.yaml") ``` Which to use is a question about how many models and how often, not about what is possible. A file is better when there are several models, when the run is going to be repeated, or when somebody else has to see what was done; the arguments are better inside a loop over something the file cannot express, like a vector of datasets. Note that `as.list()` is also how the fields are reached: the object returned by `platypus_spec()` carries the specification on the Python side, so `spec$task` is `NULL` while `as.list(spec)$task` is what you want.