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 [1;31mmiss[0m: /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 [1;32mpopulated[0m: /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 [1;32mhit[0m: /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 [1;32mhit[0m: /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 [1;32mhit[0m: /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.