Note

This page is an executed notebook. Download it to run it yourself.

Getting started

This notebook runs one experiment five ways, on one synthetic cohort: with a preset and the pipeline, as the plan the preset resolves to, as the public verbs one cell at a time, with one step changed, and back through the pipeline with that change. Every run ends on soma’s own nouns: the run directory, Predictions, Evaluation and compare.

It needs no GPU, no model weights and no network: the slides are written by soma.testing.synthetic_cohort and embedded by fake, a weight-free encoder. On a laptop CPU it runs in a few minutes. Install soma first.

[1]:
import os

os.environ["CUDA_VISIBLE_DEVICES"] = ""  # run on the CPU, so the numbers are the same on every machine

import sys
import tempfile
from dataclasses import replace
from pathlib import Path

import pandas as pd
import yaml

import soma
from soma.config import Component

root = Path(tempfile.mkdtemp(prefix="soma-getting-started-"))

1. Run an experiment

soma.testing.synthetic_cohort writes 32 small whole-slide images and the two tables soma reads. Your data takes the same shape: cohort.csv holds one row per slide (sample_id, image_path, the target label, an optional patient_id and any metadata column), and splits.csv gives each slide its split.

[2]:
records, splits = soma.testing.synthetic_cohort(root / "data", n=32, seed=0)
pd.read_csv(records).head()
[2]:
sample_id image_path patient_id label severity
0 slide00 /tmp/soma-getting-started-vjk9lu9i/data/slides... patient08 0 0.081935
1 slide01 /tmp/soma-getting-started-vjk9lu9i/data/slides... patient13 0 0.360645
2 slide02 /tmp/soma-getting-started-vjk9lu9i/data/slides... patient06 1 0.794194
3 slide03 /tmp/soma-getting-started-vjk9lu9i/data/slides... patient05 1 0.670323
4 slide04 /tmp/soma-getting-started-vjk9lu9i/data/slides... patient09 0 0.329677
[3]:
pd.read_csv(splits).head()
[3]:
sample_id split
0 slide00 tune
1 slide01 test
2 slide02 train
3 slide03 train
4 slide04 tune

Each slide holds a tissue disc whose colour darkens with severity, and label is severity > 0.5: a model scores well only if the right pixels, features and labels reach it. Two slides of one patient always share a split.

Every swappable part of soma is a component of some kind. soma.components() lists the kinds, and soma.components(kind) the registered entries of one kind, with their params:

[4]:
soma.components()
[4]:
['aggregators',
 'augmentations',
 'callbacks',
 'decoders',
 'encoders',
 'heads',
 'learners',
 'optimizers',
 'presets',
 'samplers',
 'schedulers']
[5]:
[entry["name"] for entry in soma.components("aggregators")]
[5]:
['abmil',
 'clam_mb',
 'clam_sb',
 'dsmil',
 'dtfdmil',
 'hipt',
 'max_pool',
 'mean_pool',
 'transmil']

A preset returns a complete experiment, an ExperimentConfig, from a few arguments. mil_classification tiles each slide, embeds the tiles with a frozen encoder, pools each slide’s bag of tile features with a MIL aggregator (AB-MIL by default) and classifies it.

The config is a dataclass: change it by assigning fields. The function below points a preset’s config at the cohort and makes the run fit this toy cohort: all slides are tiled and loaded in this process, the tile features are z-scored on the train split, and the model trains 20 epochs at a higher learning rate. Every run below goes through it.

[6]:
def on_this_cohort(cfg):
    cfg.cohort.records, cfg.cohort.splits = records, splits
    cfg.run.output_dir = root / "runs"
    cfg.features.cache.root_dir = root / "cache"  # one feature cache for every run below
    cfg.features.execution = replace(cfg.features.execution, num_workers_per_gpu=0, num_preprocessing_workers=0)
    cfg.learner.params["normalization"] = {"method": "zscore"}
    cfg.learner.params["optimizer"] = Component("adamw", {"lr": 3e-3})
    cfg.trainer.max_epochs = 20
    return cfg


cfg = on_this_cohort(soma.presets.mil_classification(encoder="fake"))
result = soma.Pipeline(cfg).run()
result.summary
dlopen error libcuda.so.1: cannot open shared object file: No such file or directory
 missing cuda symbols while dynamic loading
 cuFile initialization failed
dlopen error libcuda.so.1: cannot open shared object file: No such file or directory
 missing cuda symbols while dynamic loading
 cuFile initialization failed
… resolving tiling cache (samples=32, key=7e81ade8bff7772c): /tmp/soma-getting-started-vjk9lu9i/tiling_cache/7e81ade8bff7772c
✗ tiling cache miss: /tmp/soma-getting-started-vjk9lu9i/tiling_cache/7e81ade8bff7772c (initializing)
slide2vec fake (tile) on gpu for 32 slide(s)
Tiling slides (32 total)...
 slide00: selected cuCIM for auto backend
 slide01: selected cuCIM for auto backend
 slide02: selected cuCIM for auto backend
 slide03: selected cuCIM for auto backend
 slide04: selected cuCIM for auto backend
 slide05: selected cuCIM for auto backend
 slide06: selected cuCIM for auto backend
 slide07: selected cuCIM for auto backend
 slide08: selected cuCIM for auto backend
 slide09: selected cuCIM for auto backend
 slide10: selected cuCIM for auto backend
 slide11: selected cuCIM for auto backend
 slide12: selected cuCIM for auto backend
 slide13: selected cuCIM for auto backend
 slide14: selected cuCIM for auto backend
 slide15: selected cuCIM for auto backend
 slide16: selected cuCIM for auto backend
 slide17: selected cuCIM for auto backend
 slide18: selected cuCIM for auto backend
 slide19: selected cuCIM for auto backend
 slide20: selected cuCIM for auto backend
 slide21: selected cuCIM for auto backend
 slide22: selected cuCIM for auto backend
 slide23: selected cuCIM for auto backend
 slide24: selected cuCIM for auto backend
 slide25: selected cuCIM for auto backend
 slide26: selected cuCIM for auto backend
 slide27: selected cuCIM for auto backend
 slide28: selected cuCIM for auto backend
 slide29: selected cuCIM for auto backend
 slide30: selected cuCIM for auto backend
 slide31: selected cuCIM for auto backend
Resolving tissue masks (32 total)...
Tissue resolution finished: 32/32 complete, 0 failed
╭─ Tiling Summary ─╮
│ Slides     32    │
│ Completed  32    │
│ Failed     0     │
│ Tiles      4608  │
╰──────────────────╯
╭───────────────────────────────────────────────── Run Complete ──────────────────────────────────────────────────╮
│ Output  /tmp/soma-getting-started-vjk9lu9i/runs/experiments/cohort-fake-abmil-multiclass-classification_88bcf2… │
│ Logs    /tmp/soma-getting-started-vjk9lu9i/runs/experiments/cohort-fake-abmil-multiclass-classification_88bcf2… │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
… resolving tiling cache (samples=32, key=7e81ade8bff7772c): /tmp/soma-getting-started-vjk9lu9i/tiling_cache/7e81ade8bff7772c
✓ tiling cache populated: /tmp/soma-getting-started-vjk9lu9i/tiling_cache/7e81ade8bff7772c
… loading cached tilings: 0/32
… loading cached tilings: 32/32
✓ loaded cached tilings: 32/32
… resolving feature cache (artifacts=32, key=4063bfe5519a01ff): /tmp/soma-getting-started-vjk9lu9i/cache/tile/4063bfe5519a01ff
✗ feature cache miss: /tmp/soma-getting-started-vjk9lu9i/cache/tile/4063bfe5519a01ff (initializing)
slide2vec fake (tile) on gpu for 32 slide(s)
Model fake ready on cpu
✓ feature cache populated: /tmp/soma-getting-started-vjk9lu9i/cache/tile/4063bfe5519a01ff
╭───────────── run summary ─────────────╮
│    encoder  fake                      │
│    spacing  0.5 µm                    │
│ aggregator  abmil                     │
│       task  multiclass classification │
│   features  tile  ·  dim=3            │
╰───────────────────────────────────────╯
14:35:38 INFO soma.pipeline.build: Fold 0: train=16 tune=8 test=8
14:35:38 INFO soma.pipeline.build: Fitted feature adaptor on 16 Support sample(s): normalization=zscore (0 channel(s) at the eps floor), projection=none (3 → 3 dims)
╭─────────────────────────── completed run ────────────────────────────╮
│ test coverage  auroc_macro=1.0000  ·  100.0% (8/8)  ·  placeholder=0 │
╰──────────────────────────────────────────────────────────────────────╯
[6]:
{'test/auroc_macro': 1.0,
 'test/balanced_accuracy': 0.875,
 'test/f1_macro': 0.873015873015873,
 'test/coverage': 1.0,
 'test/num_samples': 8.0,
 'test/num_real_samples': 8.0,
 'test/num_placeholder_samples': 0.0}

The run directory holds everything the run decided and produced: config.yaml (the config as given), plan.yaml (the config with every default filled in), the test predictions, the metrics, the training history and the trained model as a bundle/ that soma.load reopens.

[7]:
sorted(path.name for path in result.run_dir.iterdir())
[7]:
['bundle',
 'config.yaml',
 'extraction_provenance.json',
 'feature_adapter.json',
 'features',
 'metrics.json',
 'plan.yaml',
 'predictions_test.parquet',
 'run.yaml',
 'summary.json',
 'test_results.json',
 'tiling',
 'training_history.json']

The predictions are a file of their own. soma.evaluate scores them against the cohort’s targets with any metric list, without the model:

[8]:
cohort = soma.Cohort.from_csv(records, splits, targets=["label"])
from_run = soma.Predictions.from_parquet(result.run_dir / "predictions_test.parquet")
soma.evaluate(from_run, cohort.targets("test"), metrics=["accuracy", "mcc"]).metrics
[8]:
{'accuracy': 0.875,
 'mcc': 0.7745966692414834,
 'coverage': 1.0,
 'num_samples': 8,
 'num_real_samples': 8,
 'num_placeholder_samples': 0}

Swapping a component is one argument. The same experiment with mean pooling in place of AB-MIL (the tile features come from the cache this time, nothing is encoded again):

[9]:
mean_pool = soma.Pipeline(on_this_cohort(soma.presets.mil_classification(encoder="fake", aggregator="mean_pool"))).run()
mean_pool.summary
… resolving tiling cache (samples=32, key=7e81ade8bff7772c): /tmp/soma-getting-started-vjk9lu9i/tiling_cache/7e81ade8bff7772c
✓ tiling cache hit: /tmp/soma-getting-started-vjk9lu9i/tiling_cache/7e81ade8bff7772c
… loading cached tilings: 0/32
… loading cached tilings: 32/32
✓ loaded cached tilings: 32/32
… resolving feature cache (artifacts=32, key=4063bfe5519a01ff): /tmp/soma-getting-started-vjk9lu9i/cache/tile/4063bfe5519a01ff
✓ feature cache hit: /tmp/soma-getting-started-vjk9lu9i/cache/tile/4063bfe5519a01ff
╭───────────── run summary ─────────────╮
│    encoder  fake                      │
│    spacing  0.5 µm                    │
│ aggregator  mean_pool                 │
│       task  multiclass classification │
│   features  tile  ·  dim=3            │
╰───────────────────────────────────────╯
14:35:42 INFO soma.pipeline.build: Fold 0: train=16 tune=8 test=8
14:35:42 INFO soma.pipeline.build: Fitted feature adaptor on 16 Support sample(s): normalization=zscore (0 channel(s) at the eps floor), projection=none (3 → 3 dims)
╭─────────────────────────── completed run ────────────────────────────╮
│ test coverage  auroc_macro=1.0000  ·  100.0% (8/8)  ·  placeholder=0 │
╰──────────────────────────────────────────────────────────────────────╯
[9]:
{'test/auroc_macro': 1.0,
 'test/balanced_accuracy': 0.875,
 'test/f1_macro': 0.873015873015873,
 'test/coverage': 1.0,
 'test/num_samples': 8.0,
 'test/num_real_samples': 8.0,
 'test/num_placeholder_samples': 0.0}

On the command line, soma init prints a preset as an editable YAML file with every default spelled out. Its first sections:

[10]:
!{sys.executable} -m soma init mil_classification --encoder fake > {root}/experiment.yaml
text = (root / "experiment.yaml").read_text()
print(text[: text.index("features:")] + "...")
# soma init mil_classification --encoder fake
# Every field at the preset's value or its default. A null field is unset: the run
# fills in what the cohort or the machine decides. Point cohort.records and
# cohort.splits at your files, edit what you need, then: soma run <this file>
run:
  output_dir: runs
  seed: 0
  folds: null
  run_id: null
  resume: false
  mirror_root: null
cohort:
  records: dataset.csv
  splits: splits.csv
  collapse: null
  tune_is_test: false
  allow_missing_tune: false
...

The file and the Python object are one config: with the same edits, the file resolves to the same plan as cfg.

[11]:
from_file = on_this_cohort(soma.ExperimentConfig.from_yaml(root / "experiment.yaml"))
soma.resolve(from_file, cohort) == soma.resolve(cfg, cohort)
[11]:
True

After the same edits in the file, soma run experiment.yaml runs this experiment. A preset also runs as is in one line:

soma run --preset mil_classification --encoder fake --cohort cohort.csv --splits splits.csv --out runs

On your own slides, name a foundation model instead of fake and point the cohort at your tables; nothing else changes:

cfg = soma.presets.mil_classification(encoder="uni2")
cfg.cohort.records, cfg.cohort.splits = "cohort.csv", "splits.csv"  # sample_id, image_path, label[, patient_id]
cfg.run.output_dir = "runs"
soma.Pipeline(cfg).run()

The first run downloads the encoder’s weights (uni2 is gated on Hugging Face: accept its terms and set HF_TOKEN); feature extraction is much faster on a GPU, and is cached for every later run.

2. What the preset resolved to

soma.resolve expands a config against its cohort into a Plan: every {name, params} leaf with all its params, and what the cohort decides (here the class count and the label map) filled in. The run wrote the same plan to plan.yaml before it trained.

[12]:
plan = soma.resolve(cfg, cohort)
plan.shape, plan.task_family, plan.unit
[12]:
('set', 'multiclass_classification', 'slide')
[13]:
resolved = plan.to_dict()
print(yaml.safe_dump(
    {"features.encoder": resolved["features"]["encoder"], "learner": resolved["learner"], "trainer": resolved["trainer"]},
    sort_keys=False,
))
features.encoder:
  name: fake
  params:
    output_variant: null
    allow_non_recommended_settings: false
learner:
  name: mil
  params:
    aggregator:
      name: abmil
      params:
        hidden_dim: 128
        activation: tanh
        gated: true
        dropout: 0.25
    head:
      name: multiclass_classification
      params:
        num_classes: 2
        label_map:
          0: 0
          1: 1
        dropout: 0.0
        targets:
          label: label
    normalization:
      method: zscore
      eps: 1.0e-06
    projection:
      method: none
      target_dim: null
      seed: 0
    optimizer:
      name: adamw
      params:
        lr: 0.003
        betas:
        - 0.9
        - 0.999
        eps: 1.0e-08
        weight_decay: 0.01
        amsgrad: false
        maximize: false
        foreach: null
        capturable: false
        differentiable: false
        fused: null
    scheduler:
    - name: cosine
      params:
        T_max: null
        eta_min: 0.0
        last_epoch: -1
    loss: null
trainer:
  max_epochs: 20
  max_steps: null
  accumulate_grad_batches: 1
  val_every_n_epochs: 1
  device: auto
  precision: fp32
  callbacks:
  - name: checkpoint
    params:
      monitor: loss
      mode: min
      selection: best
      dirpath: null
  - name: early_stopping
    params:
      monitor: loss
      mode: min
      patience: 10
      hold_until_moves: true

[14]:
soma.Plan.from_yaml(result.run_dir / "plan.yaml").comparable() == plan.comparable()
[14]:
True

3. The same run from the verbs

Pipeline composes the public verbs in one fixed order. Below, each step is one cell, built with plain constructors from the plan’s values; the result is the pipeline’s run, byte for byte.

Seed. Seeds Python, NumPy and torch for fold 0: every random draw below follows from it.

[15]:
soma.seed_everything(plan.run.seed, fold=0)

Source. soma.extract tiles the slides and embeds the tiles, once: the cache serves the pipeline’s features again, so nothing is encoded here. This is where another encoder goes.

[16]:
source = soma.extract(
    soma.ImageManifest.from_csv(records),
    encoder="fake",
    extraction=cfg.features.extraction,
    execution=cfg.features.execution,
    cache=cfg.features.cache,
    output_dir=root / "by-hand",
)
source.feature_dim, source.load("slide00").shape
… resolving tiling cache (samples=32, key=7e81ade8bff7772c): /tmp/soma-getting-started-vjk9lu9i/tiling_cache/7e81ade8bff7772c
✓ tiling cache hit: /tmp/soma-getting-started-vjk9lu9i/tiling_cache/7e81ade8bff7772c
… loading cached tilings: 0/32
… loading cached tilings: 32/32
✓ loaded cached tilings: 32/32
… resolving feature cache (artifacts=32, key=4063bfe5519a01ff): /tmp/soma-getting-started-vjk9lu9i/cache/tile/4063bfe5519a01ff
✓ feature cache hit: /tmp/soma-getting-started-vjk9lu9i/cache/tile/4063bfe5519a01ff
[16]:
(3, torch.Size([144, 3]))

Features you computed yourself enter through a source too, for example soma.data.from_arrays over one (tiles, features) tensor per slide; soma.testing.check_set_source checks any source:

[17]:
own = soma.data.from_arrays({sample_id: source.load(sample_id) for sample_id in source.sample_ids})
soma.testing.check_set_source(own)

Cohort. The cohort holds the targets and the folds, and refuses a patient whose slides fall in two splits. The other cohorts you would build here: patient-level bags (cohort.collapse("patient_id")) or a cross-validation splits.csv with a fold column.

[18]:
fold = cohort.folds[0]
len(fold.train), len(fold.tune), len(fold.tests["test"])
[18]:
(16, 8, 8)

Feature adaptor. The z-score statistics are fitted on the train split only, so no tune or test slide leaks into them. A projection (PCA, random) would be fitted here as well.

[19]:
from soma.learn.feature_adaptor import FeatureAdaptor

adaptor = FeatureAdaptor(method="zscore", num_features=source.feature_dim)
adaptor.fit([source.load(sample_id) for sample_id in fold.train])
[19]:
FeatureAdaptor(method='zscore', eps=1e-06, num_features=3, projection='none', output_dim=3)

Model. The aggregator, then the head, behind the adaptor; record_builder notes how to rebuild the model, so that soma.load can reopen it later. The learner owns the optimization. This is where another aggregator, head or optimizer goes.

[20]:
def build_model(feature_dim, adaptor=None):
    aggregator = soma.models.ABMIL(input_dim=feature_dim)
    head = soma.models.MulticlassClassificationHead(input_dim=aggregator.output_dim, num_classes=2, label_map={0: 0, 1: 1})
    adaptor = adaptor or FeatureAdaptor(method="zscore", num_features=feature_dim)
    model = soma.models.MILModel(aggregator, head, feature_adaptor=adaptor)
    return soma.learn.record_builder(model, build_model, feature_dim=feature_dim)


learner = soma.learn.MILLearner(
    build_model(source.feature_dim, adaptor), optimizer={"name": "adamw", "params": {"lr": 3e-3}}, schedulers=["cosine"]
)

Loaders. One bag per batch, shuffled for training. A sampler or an augmentation goes here.

[21]:
loaders = soma.learn.loaders(source, cohort, head=learner.head, batch_size=1)

Baseline. Slides where tiling finds no tissue have no features; they are scored with the train split’s class prior so that every test slide is counted. Here every slide has tissue.

[22]:
head = learner.head
baseline = soma.evaluation.Baseline.fit(
    cohort.targets("train"), task_family=head.task_family, classes=head.classes, target_keys=head.target_keys
)
no_features = [sample_id for sample_id in fold.tests["test"] if sample_id not in source.sample_ids]

Fit. Trains with the plan’s callbacks: keep the epoch with the best tune loss, stop after 10 epochs without improvement.

[23]:
fit = soma.fit(learner, loaders.train, loaders.tune, max_epochs=20)

Tune step. Classification has none. Detection chooses its peak threshold on the tune predictions here (soma.evaluation.sweep_threshold).

Predict and evaluate. The test predictions, plus baseline rows for the slides without features, scored against the test targets:

[24]:
by_hand = soma.Predictions.concat([soma.predict(fit.learner, loaders.tests["test"]), baseline.predict(no_features)])
soma.evaluate(by_hand, cohort.targets("test")).metrics
[24]:
{'auroc_macro': 1.0,
 'balanced_accuracy': 0.875,
 'f1_macro': 0.873015873015873,
 'coverage': 1.0,
 'num_samples': 8,
 'num_real_samples': 8,
 'num_placeholder_samples': 0}

Save. The bundle holds the weights and the recipe that rebuilds the model; soma.load reopens it, and the reopened model scores the same:

[25]:
bundle = soma.save(fit, root / "by-hand" / "bundle")
reopened = soma.load(bundle)
soma.evaluate(soma.predict(reopened.model, loaders.tests["test"]), cohort.targets("test")).metrics
[25]:
{'auroc_macro': 1.0, 'balanced_accuracy': 0.875, 'f1_macro': 0.873015873015873}

The hand-built run is the pipeline’s run: the same predictions (compare’s paired permutation test finds no difference) and the same weights. soma’s test suite holds every pipeline run to this.

[26]:
(
    soma.evaluation.compare([from_run, by_hand], cohort.targets("test"), "auroc_macro"),
    from_run.frame.equals(by_hand.frame),
    (result.run_dir / "bundle" / "weights.safetensors").read_bytes() == (bundle / "weights.safetensors").read_bytes(),
)
[26]:
([1.0, 1.0], True, True)

4. Change a step

A learner is a class whose methods are the steps of training. This one adds a penalty on the entropy of the attention, so each slide’s prediction rests on fewer tiles: one training_step override.

[27]:
class EntropyMIL(soma.learn.MILLearner):
    """MIL plus 0.01 x the entropy of each bag's attention over its tiles."""

    def training_step(self, batch):
        output = self.forward_batch(batch)
        attention = output.tile_attention.masked_fill(~batch.mask, float("-inf")).softmax(-1)
        entropy = -(attention * attention.clamp_min(1e-8).log()).sum(-1).mean()
        return self.compute_loss(output, batch) + 0.01 * entropy

soma.testing.check_learner runs one batch through every method soma calls and names the one that breaks the contract, if any. The batch here is the eight tune slides, so that it holds both classes:

[28]:
probe = EntropyMIL(build_model(source.feature_dim, adaptor))
batch = next(iter(soma.learn.loaders(source, cohort, head=probe.head, batch_size=8).tune))
soma.testing.check_learner(probe, batch, budget=soma.learn.Budget(max_epochs=1, max_steps=None, updates_per_epoch=1))
/home/runner/work/soma/soma/soma/testing/_learner.py:70: UserWarning: Converting a tensor with requires_grad=True to a scalar may lead to unexpected behavior.
Consider using tensor.detach() first. (Triggered internally at /__w/pytorch/pytorch/torch/csrc/autograd/generated/python_variable_methods.cpp:820.)
  if not math.isfinite(float(loss)):

The same cells as above, with EntropyMIL in place of MILLearner:

[29]:
soma.seed_everything(plan.run.seed, fold=0)
adaptor = FeatureAdaptor(method="zscore", num_features=source.feature_dim)
adaptor.fit([source.load(sample_id) for sample_id in fold.train])
learner = EntropyMIL(
    build_model(source.feature_dim, adaptor), optimizer={"name": "adamw", "params": {"lr": 3e-3}}, schedulers=["cosine"]
)
loaders = soma.learn.loaders(source, cohort, head=learner.head, batch_size=1)
fit = soma.fit(learner, loaders.train, loaders.tune, max_epochs=20)
entropy_by_hand = soma.Predictions.concat([soma.predict(fit.learner, loaders.tests["test"]), baseline.predict(no_features)])
soma.evaluate(entropy_by_hand, cohort.targets("test")).metrics
[29]:
{'auroc_macro': 1.0,
 'balanced_accuracy': 0.875,
 'f1_macro': 0.873015873015873,
 'coverage': 1.0,
 'num_samples': 8,
 'num_real_samples': 8,
 'num_placeholder_samples': 0}

5. Hand it back to the pipeline

A config names a class the registry does not know by its dotted import path: a class defined in this notebook is __main__.EntropyMIL. soma warns that it cannot validate the class’s params, then runs it like any other learner, and the run is again the hand-built one:

[30]:
cfg.learner = replace(cfg.learner, name="__main__.EntropyMIL")
entropy_run = soma.Pipeline(cfg).run()
from_entropy_run = soma.Predictions.from_parquet(entropy_run.run_dir / "predictions_test.parquet")
soma.evaluation.compare([from_entropy_run, entropy_by_hand], cohort.targets("test"), "auroc_macro"), from_entropy_run.frame.equals(entropy_by_hand.frame)
… resolving tiling cache (samples=32, key=7e81ade8bff7772c): /tmp/soma-getting-started-vjk9lu9i/tiling_cache/7e81ade8bff7772c
✓ tiling cache hit: /tmp/soma-getting-started-vjk9lu9i/tiling_cache/7e81ade8bff7772c
… loading cached tilings: 0/32
… loading cached tilings: 32/32
✓ loaded cached tilings: 32/32
/home/runner/work/soma/soma/soma/plan.py:185: UserWarning: learners '__main__.EntropyMIL' is not registered: its params pass through unvalidated (register it to validate them and list it).
  return registry.resolve(name)
… resolving feature cache (artifacts=32, key=4063bfe5519a01ff): /tmp/soma-getting-started-vjk9lu9i/cache/tile/4063bfe5519a01ff
✓ feature cache hit: /tmp/soma-getting-started-vjk9lu9i/cache/tile/4063bfe5519a01ff
╭───────────── run summary ─────────────╮
│    encoder  fake                      │
│    spacing  0.5 µm                    │
│ aggregator  abmil                     │
│       task  multiclass classification │
│   features  tile  ·  dim=3            │
╰───────────────────────────────────────╯
14:36:03 INFO soma.pipeline.build: Fold 0: train=16 tune=8 test=8
14:36:03 INFO soma.pipeline.build: Fitted feature adaptor on 16 Support sample(s): normalization=zscore (0 channel(s) at the eps floor), projection=none (3 → 3 dims)
╭─────────────────────────── completed run ────────────────────────────╮
│ test coverage  auroc_macro=1.0000  ·  100.0% (8/8)  ·  placeholder=0 │
╰──────────────────────────────────────────────────────────────────────╯
[30]:
([1.0, 1.0], True)

To share the learner, put it in a package and register it, so its params are checked and it is listed by soma.components("learners"): see Extending soma for this and the other extension points, each with its conformance check.