Slide-level¶
This notebook walks through soma’s modular building-block API for a slide-level prediction task, end to end:
Dataset -> FeatureExtractor -> train (aggregator + head) -> evaluate
We build a slide-level representation two ways — a tile encoder + MIL aggregator (Path A) and a slide-level encoder with no aggregator (Path B) — then show that switching the task to multiclass, regression, or survival is just a different TaskConfig on the same extracted features, the property soma is built around. The last cell shows how the whole thing collapses into a single Pipeline call.
This uses a tiny synthetic dataset so it runs anywhere on CPU with no gated model and no real slides. The numbers are therefore meaningless — the point is the API, not the result.
⚠️ Scaffolding (not soma API)¶
The cell below fabricates a toy dataset: a handful of small tissue-like .tif slides plus the two CSVs soma expects. You would replace this with your own slides and labels — the only thing that matters downstream is the on-disk contract:
dataset.csv— one row per slide withsample_id,image_path,label(extra label columns are fine; you pick which one islabel).splits.csv—sample_id,split(train/tune/test*), optionalfold.
[1]:
import logging, warnings
warnings.filterwarnings('ignore')
logging.getLogger().setLevel(logging.ERROR)
import tempfile
from pathlib import Path
import numpy as np
import pandas as pd
import tifffile
WORK = Path(tempfile.mkdtemp(prefix='soma-slide-tutorial-'))
SLIDES = WORK / 'slides'; SLIDES.mkdir()
rng = np.random.default_rng(0)
def make_toy_slide(path, size=640):
"""A white background with a central H&E-ish blob, saved as a tiled
TIFF whose resolution tags make OpenSlide report 0.5 microns/pixel."""
img = np.full((size, size, 3), 240, np.uint8)
yy, xx = np.mgrid[0:size, 0:size]
blob = ((xx - size // 2) ** 2 + (yy - size // 2) ** 2) < (size * 0.35) ** 2
tissue = np.stack([np.full((size, size), 150),
np.full((size, size), 70),
np.full((size, size), 160)], -1).astype(np.int16)
tissue += rng.integers(-30, 30, (size, size, 3))
img[blob] = np.clip(tissue, 0, 255).astype(np.uint8)[blob]
tifffile.imwrite(path, img, photometric='rgb', tile=(256, 256),
resolution=(20000, 20000), resolutionunit='CENTIMETER')
N = 8
sample_ids = [f's{i:02d}' for i in range(N)]
for sid in sample_ids:
make_toy_slide(SLIDES / f'{sid}.tif')
# train/tune/test assignment (single fold) with both classes in every split
split = (['train'] * 4) + (['tune'] * 2) + (['test'] * 2)
binary = [0, 1, 0, 1, 0, 1, 0, 1]
dataset_csv = WORK / 'dataset.csv'
splits_csv = WORK / 'splits.csv'
pd.DataFrame({'sample_id': sample_ids,
'image_path': [str(SLIDES / f'{s}.tif') for s in sample_ids],
'label': binary}).to_csv(dataset_csv, index=False)
pd.DataFrame({'sample_id': sample_ids, 'split': split}).to_csv(splits_csv, index=False)
print('dataset.csv'); print(pd.read_csv(dataset_csv).head().to_string(index=False))
print('\nsplits.csv'); print(pd.read_csv(splits_csv)['split'].value_counts().to_string())
dataset.csv
sample_id image_path label
s00 /tmp/soma-slide-tutorial-lh578cej/slides/s00.tif 0
s01 /tmp/soma-slide-tutorial-lh578cej/slides/s01.tif 1
s02 /tmp/soma-slide-tutorial-lh578cej/slides/s02.tif 0
s03 /tmp/soma-slide-tutorial-lh578cej/slides/s03.tif 1
s04 /tmp/soma-slide-tutorial-lh578cej/slides/s04.tif 0
splits.csv
split
train 4
tune 2
test 2
1. Load the dataset and splits¶
Dataset reads dataset.csv and infers the label space; Splits reads splits.csv and pairs it with the dataset. soma never partitions your data — the splits you provide are the splits it uses, which is what keeps evaluation reproducible and leakage-free.
[2]:
from soma import Dataset, Splits
dataset = Dataset(dataset_csv)
splits = Splits(splits_csv, dataset)
print('slides: ', len(dataset.sample_ids))
print('classes: ', sorted(pd.read_csv(dataset_csv)['label'].unique()))
print('folds: ', splits.num_folds)
slides: 8
classes: [0, 1]
folds: 1
2. Two ways to a slide-level representation¶
A prediction needs one vector per slide. soma supports two routes, and you choose by the encoder you name:
Path A — a tile encoder + an aggregator. A tile-level foundation model embeds each tile into a bag of vectors, then a trainable MIL aggregator (e.g. attention-MIL) pools the bag into a slide vector.
Path B — a slide-level encoder. A slide-level model emits the slide vector directly, so there is no aggregator (
aggregator=None).
We run both below on the same dataset and splits.
Path A — tile encoder (phikon) + MIL aggregator¶
FeatureExtractor runs preprocessing (tissue masking + tiling) and then a frozen tile encoder over each tile. We use `phikon <https://huggingface.co/owkin/phikon>`__ because it is ungated (no HF token) and small enough to run on CPU. extract() writes a bag of tile embeddings per slide; with CacheConfig(enabled=True) they are cached so every experiment below reuses this one extraction.
[3]:
from soma import FeatureExtractor, FeatureStore, EncoderConfig, PreprocessingConfig, CacheConfig
feature_dir = WORK / 'output' / 'features'
extractor = FeatureExtractor(
dataset,
EncoderConfig(name='phikon'),
preprocessing=PreprocessingConfig(
backend='openslide',
requested_tile_size_px=224,
requested_spacing_um=0.5,
tissue_method='otsu', # otsu | hsv | threshold | sam2
tolerance=0.07,
# Our toy slides are small; a fine segmentation downsample + low
# area threshold keep the tissue mask usable (defaults assume WSIs).
seg_downsample=16,
a_t=1,
),
cache=CacheConfig(enabled=True, root_dir=str(WORK / 'cache')),
output_root=str(WORK / 'output'),
)
extractor.extract(feature_dir=str(feature_dir))
store = FeatureStore(str(feature_dir)) # the embeddings written above
print('extracted feature bags for', len(store.available_samples), 'slides')
… resolving tiling cache (samples=8, key=4e8d1e616e10d836): /tmp/soma-slide-tutorial-lh578cej/tiling_cache/4e8d1e616e10d836
✗ tiling cache miss: /tmp/soma-slide-tutorial-lh578cej/tiling_cache/4e8d1e616e10d836 (initializing)
slide2vec phikon (tile) on gpu for 8 slide(s)
Tiling slides (8 total)...
Resolving tissue masks (8 total)...
Tissue resolution finished: 8/8 complete, 0 failed
╭─ Tiling Summary ─╮ │ Slides 8 │ │ Completed 8 │ │ Failed 0 │ │ Tiles 32 │ ╰──────────────────╯
╭──────────────────────────── Run Complete ─────────────────────────────╮ │ Output /tmp/soma-slide-tutorial-lh578cej/output/features/tiling │ │ Logs /tmp/soma-slide-tutorial-lh578cej/output/features/tiling/logs │ ╰───────────────────────────────────────────────────────────────────────╯
… resolving tiling cache (samples=8, key=4e8d1e616e10d836): /tmp/soma-slide-tutorial-lh578cej/tiling_cache/4e8d1e616e10d836
✓ tiling cache populated: /tmp/soma-slide-tutorial-lh578cej/tiling_cache/4e8d1e616e10d836
… loading cached tilings: 0/8
… loading cached tilings: 8/8
✓ loaded cached tilings: 8/8
… resolving feature cache (artifacts=8, key=f5822bf7f01840dc): /tmp/soma-slide-tutorial-lh578cej/cache/tile/f5822bf7f01840dc
✗ feature cache [1;31mmiss[0m: /tmp/soma-slide-tutorial-lh578cej/cache/tile/f5822bf7f01840dc (initializing)
slide2vec phikon (tile) on gpu for 8 slide(s)
Note: Environment variable`HF_TOKEN` is set and is the current active token independently from the token you've just configured.
Model phikon ready on cpu
✓ feature cache [1;32mpopulated[0m: /tmp/soma-slide-tutorial-lh578cej/cache/tile/f5822bf7f01840dc
extracted feature bags for 8 slides
Now train() consumes the bag FeatureStore and trains a MIL aggregator (here attention-MIL, abmil) plus a task head. The aggregator turns each slide’s bag of tile features into one slide-level vector; the head maps that to a prediction. TaskConfig(name='binary_classification') picks the head, loss, and default metrics.
[4]:
from soma import AggregatorConfig, TaskConfig, TrainingConfig, EvalConfig, train
training = TrainingConfig(epochs=3, learning_rate=1e-3, batch_size=4, seed=0)
result = train(
feature_store=store,
dataset=dataset,
splits=splits,
aggregator=AggregatorConfig(name='abmil'),
task=TaskConfig(name='binary_classification'),
training=training,
evaluation=EvalConfig(metrics=['balanced_accuracy', 'auroc']),
run_dir=str(WORK / 'runs' / 'binary_tile_abmil'),
)
print('run dir:', result.run_dir)
run dir: /tmp/soma-slide-tutorial-lh578cej/runs/binary_tile_abmil
3. Path B — a slide-level encoder (no aggregator)¶
Some foundation models are slide-level: they run their own tile encoder internally and return one vector per slide, so you skip the aggregator entirely. We use `moozy-slide <https://huggingface.co/AtlasAnalyticsLab/MOOZY>`__ (built on the ungated lunit tile encoder) so this runs on CPU with no token; swap in titan, prism, or gigapath-slide if you have access.
The only API differences from Path A: the encoder name, and aggregator=None in train() (there is no bag to pool — the store already holds one vector per slide).
[5]:
slide_feature_dir = WORK / 'output' / 'features_slide'
slide_extractor = FeatureExtractor(
dataset,
EncoderConfig(name='moozy-slide', allow_non_recommended_settings=True),
preprocessing=PreprocessingConfig(
backend='openslide', requested_tile_size_px=224, requested_spacing_um=0.5,
tissue_method='otsu', tolerance=0.07, seg_downsample=16, a_t=1,
),
cache=CacheConfig(enabled=True, root_dir=str(WORK / 'cache')),
output_root=str(WORK / 'output'),
)
slide_extractor.extract(feature_dir=str(slide_feature_dir))
slide_store = FeatureStore(str(slide_feature_dir))
print('slide-level features:', slide_store.is_slide_level,
'| one vector per slide of dim',
tuple(slide_store.load(slide_store.available_samples[0]).shape))
slide_result = train(
feature_store=slide_store,
dataset=dataset,
splits=splits,
aggregator=None, # <- slide-level features need no aggregator
task=TaskConfig(name='binary_classification'),
training=training,
evaluation=EvalConfig(metrics=['balanced_accuracy', 'auroc']),
run_dir=str(WORK / 'runs' / 'binary_slide_noagg'),
)
print('run dir:', slide_result.run_dir)
… resolving tiling cache (samples=8, key=4e8d1e616e10d836): /tmp/soma-slide-tutorial-lh578cej/tiling_cache/4e8d1e616e10d836
✓ tiling cache hit: /tmp/soma-slide-tutorial-lh578cej/tiling_cache/4e8d1e616e10d836
… loading cached tilings: 0/8
… loading cached tilings: 8/8
✓ loaded cached tilings: 8/8
… resolving feature cache (artifacts=8, key=b590ab1a0920d274): /tmp/soma-slide-tutorial-lh578cej/cache/tile/b590ab1a0920d274
✗ feature cache [1;31mmiss[0m: /tmp/soma-slide-tutorial-lh578cej/cache/tile/b590ab1a0920d274 (initializing)
… resolving feature cache (artifacts=8, key=970ed2890fd2d7e3): /tmp/soma-slide-tutorial-lh578cej/cache/slide/970ed2890fd2d7e3
✗ feature cache [1;31mmiss[0m: /tmp/soma-slide-tutorial-lh578cej/cache/slide/970ed2890fd2d7e3 (initializing)
slide2vec lunit (tile) on gpu for 8 slide(s)
Note: Environment variable`HF_TOKEN` is set and is the current active token independently from the token you've just configured.
Model lunit ready on cpu
✓ feature cache [1;32mpopulated[0m: /tmp/soma-slide-tutorial-lh578cej/cache/tile/b590ab1a0920d274
Note: Environment variable`HF_TOKEN` is set and is the current active token independently from the token you've just configured.
Model moozy-slide ready on cpu
✓ feature cache [1;32mpopulated[0m: /tmp/soma-slide-tutorial-lh578cej/cache/slide/970ed2890fd2d7e3
slide-level features: True | one vector per slide of dim (768,)
run dir: /tmp/soma-slide-tutorial-lh578cej/runs/binary_slide_noagg
4. Swap the task — same features, new head¶
This is the payoff of the modular design. Features don’t depend on the labels, so we reuse Path A’s tile ``FeatureStore`` (store) and only change the Dataset labels + TaskConfig + metrics. No re-extraction. (Each of these works identically on Path B’s slide_store with aggregator=None.)
Multiclass classification¶
[6]:
def relabel(values, **extra_cols):
df = pd.DataFrame({'sample_id': sample_ids,
'image_path': [str(SLIDES / f'{s}.tif') for s in sample_ids],
'label': values, **extra_cols})
path = WORK / f'dataset_{abs(hash(tuple(map(str, values)))) % 10**6}.csv'
df.to_csv(path, index=False)
return Dataset(path)
multiclass = [0, 1, 2, 3, 0, 1, 2, 3]
ds_mc = relabel(multiclass)
result_mc = train(
feature_store=store, dataset=ds_mc, splits=Splits(splits_csv, ds_mc),
aggregator=AggregatorConfig(name='abmil'),
task=TaskConfig(name='multiclass_classification'),
training=training, evaluation=EvalConfig(metrics=['balanced_accuracy']),
run_dir=str(WORK / 'runs' / 'multiclass'),
)
print('multiclass run dir:', result_mc.run_dir)
multiclass run dir: /tmp/soma-slide-tutorial-lh578cej/runs/multiclass
Regression¶
[7]:
targets = rng.uniform(0, 10, size=N).round(2)
ds_reg = relabel(targets)
result_reg = train(
feature_store=store, dataset=ds_reg, splits=Splits(splits_csv, ds_reg),
aggregator=AggregatorConfig(name='abmil'),
task=TaskConfig(name='regression'),
training=training, evaluation=EvalConfig(metrics=['mae', 'r2']),
run_dir=str(WORK / 'runs' / 'regression'),
)
print('regression run dir:', result_reg.run_dir)
regression run dir: /tmp/soma-slide-tutorial-lh578cej/runs/regression
Survival (Cox)¶
Time-to-event reuses label as the event/censoring time and adds an event column (1 = event observed, 0 = censored). TaskConfig('survival') with loss='cox' selects continuous-time CoxPH, where the risk set is the batch — so batch_size >= 2.
[8]:
times = rng.uniform(1, 100, size=N).round(1)
events = [1, 0, 1, 1, 1, 0, 1, 1]
ds_surv = relabel(times, event=events)
result_surv = train(
feature_store=store, dataset=ds_surv, splits=Splits(splits_csv, ds_surv),
aggregator=AggregatorConfig(name='abmil'),
task=TaskConfig(name='survival', params={'loss': 'cox', 'min_events_per_window': 1}),
training=TrainingConfig(epochs=3, learning_rate=1e-3, batch_size=4, seed=0),
evaluation=EvalConfig(metrics=['c_index']),
run_dir=str(WORK / 'runs' / 'survival'),
)
print('survival run dir:', result_surv.run_dir)
survival run dir: /tmp/soma-slide-tutorial-lh578cej/runs/survival
5. The one-shot Pipeline equivalent¶
Everything above — preprocess, extract, train, evaluate — is what Pipeline does in a single call from one config. The building blocks are for when you want to reuse features across many experiments (as we just did); the Pipeline is for when you just want the result.
(Shown for reference, not executed — it would repeat all the work above.)
from soma import (
Pipeline, PipelineConfig, PreprocessingConfig, EncoderConfig,
AggregatorConfig, TaskConfig, TrainingConfig, EvalConfig, CacheConfig,
)
config = PipelineConfig(
dataset_csv=str(dataset_csv),
splits_csv=str(splits_csv),
output_root='output/binary',
dataset_type='slide',
preprocessing=PreprocessingConfig(
backend='openslide', requested_tile_size_px=224,
requested_spacing_um=0.5, tissue_method='otsu', tolerance=0.07,
),
encoder=EncoderConfig(name='phikon'),
aggregator=AggregatorConfig(name='abmil'),
task=TaskConfig(name='binary_classification'),
training=TrainingConfig(epochs=3, learning_rate=1e-3, batch_size=4),
evaluation=EvalConfig(metrics=['balanced_accuracy', 'auroc']),
cache=CacheConfig(enabled=True),
)
results = Pipeline(config).run()