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 with sample_id, image_path, label (extra label columns are fine; you pick which one is label).

  • splits.csvsample_id, split (train / tune / test*), optional fold.

[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 miss: /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 populated: /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 miss: /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 miss: /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 populated: /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 populated: /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()