Cookbooks

Recipe

Finding spatial niches with Nicheformer

Muhammad AliPublished

Tumours are not uniform. Tumour, immune and stromal cells form local neighbourhoods, or niches, and the same niche can recur across patients. Finding them usually means annotating cell types first, then mapping where they sit. Bio foundation models offer another route: embed every spot, group similar spots into states, then look at which states sit next to each other on the tissue.

Nicheformer is a bio foundation model trained on single-cell and spatial transcriptomics. Running it yourself means getting the weights, a GPU and the tokenisation it expects. The Helical MCP handles that part.

In this cookbook, you compute zero-shot Nicheformer embeddings for a non-small cell lung cancer (NSCLC) Visium study through the Helical MCP. You then use them to define spot states and spatial niches, map both back onto the tissue, and check whether they recur in patients held out from discovery. The data are 88,520 Visium spots from 40 slides: eight cancer patients with tumour and matched background tissue, and two healthy donors.

Nicheformer spatial niches on a discovery tumour slide and two held-out tumour slides

  • Prepare the spatial data and register it with Helical.
  • Run Nicheformer and download the embeddings.
  • Discover spot states and spatial niches on six patients.
  • Map them back onto the tissue.
  • Check whether they recur in two held-out patients, against a QC-only baseline.

You need the Helical MCP connected (see Getting started) and about 2 credits.

1Set up the experiment

  • Discovery patients: P10, P11, P15, P16, P17 and P19. States and niches are fitted on these.
  • Held-out patients: P24 and P25. They are only used to test transfer.
  • Healthy donors: D1 and D2, used as a descriptive comparison.
  • Keep every slide from a patient on the same side of the split. Splitting spots at random would put the same patient on both sides.
  • Fix these roles before looking at the embeddings.
In [1]:
from pathlib import Path
import json
import sys
import warnings
import importlib.metadata

import anndata as ad
import numpy as np
import pandas as pd
import scanpy as sc
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import sparse
from sklearn.preprocessing import normalize
from IPython.display import display, Markdown
from IPython import get_ipython

get_ipython().run_line_magic('matplotlib', 'inline')

DATA_DIR = Path('data')
INPUT = DATA_DIR / 'nicheformer_input.h5ad'
IMAGES = DATA_DIR / 'spatial_images.h5ad'
EMBEDDING = DATA_DIR / 'nicheformer.npy'
OUTPUT = Path('results')
OUTPUT.mkdir(parents=True, exist_ok=True)
SEED = 42
DEVELOPMENT = ['P10', 'P11', 'P15', 'P16', 'P17', 'P19']
HOLDOUT = ['P24', 'P25']
sc.settings.n_jobs = 8
sc.settings.set_figure_params(dpi=100, frameon=False)
sns.set_context('notebook')
print('Python:', sys.version.split()[0])
print({name: importlib.metadata.version(name) for name in
       ['anndata', 'scanpy', 'numpy', 'pandas', 'scikit-learn']})
Python: 3.14.8
{'anndata': '0.13.4', 'scanpy': '1.12.4', 'numpy': '2.5.3', 'pandas': '3.0.6', 'scikit-learn': '1.9.1'}

2Prepare the input

  • The source .X holds raw UMI counts, which is what Nicheformer expects. Don't log-normalise or select genes before inference.
  • Keep the original spot order, IDs and coordinates. Map gene symbols back to the original 10x Ensembl IDs.
  • Leave the tissue images out of the upload. They're kept in a separate file for plotting.
  • Set the metadata to human, spatial, Visium. Helical's Nicheformer adapter has no dedicated Visium token, so the unknown-assay fallback is used.
  • Visium spots mix several cells, while Nicheformer was trained on single cells. Treat this as an exploratory transfer across technologies. Example prompt:
Prepare the E-MTAB-13530 Visium slides for Nicheformer. Keep the raw counts in X, the original spot order, IDs and coordinates, and map gene symbols back to the original Ensembl IDs. Drop the tissue images and set the metadata to human, spatial, Visium. Tell me if anything looks off.
  • The prepared input and the slide images are on our public S3 bucket. Download both into data/ to skip this step:
aws s3 cp s3://helicalpackage/cookbooks/spatial-demo/nicheformer_input.h5ad data/ --no-sign-request
aws s3 cp s3://helicalpackage/cookbooks/spatial-demo/spatial_images.h5ad data/ --no-sign-request
In [2]:
data = ad.read_h5ad(INPUT)
assert data.obs_names.is_unique, 'Repeated spot IDs break the alignment with the embeddings.'
print(f'{data.n_obs:,} spots × {data.n_vars:,} genes; '
      f'{data.obs["sample_id"].nunique()} slides from {data.obs["patient_id"].nunique()} subjects.')
88,520 spots × 36,601 genes; 40 slides from 10 subjects.

3Register, embed and download

3a. Register the dataset

Example prompt:

Register data/nicheformer_input.h5ad with Helical as E-MTAB-13530_Visium_spatial_demo. The observations are Visium spots; use patient_id as the donor column.
  • Check the counts the platform returns: 88,520 spots and 36,601 genes.

3b. Run Nicheformer

Example prompt:

Generate embeddings for E-MTAB-13530_Visium_spatial_demo with the pretrained nicheformer model. Quote me the cost before starting the run.
  • Review and approve the run when prompted. The recorded run was quoted at 1.77 credits.
  • Nicheformer only sees expression. Tumour labels, patient IDs, images and coordinates are not inputs.

3c. Download the embeddings

Example prompt:

When the run succeeds, download the Nicheformer embeddings to data/nicheformer.npy.

4Attach the embeddings to the spots

  • The .npy file has no spot IDs, so the rows line up because Helical keeps the input order. Check the row count before attaching the matrix.
In [3]:
embedding = np.load(EMBEDDING, allow_pickle=False)
assert embedding.ndim == 2 and embedding.shape[0] == data.n_obs, 'One embedding row per input spot is required.'
assert np.isfinite(embedding).all() and (np.linalg.norm(embedding, axis=1) > 0).all()
data.obsm['X_nicheformer'] = embedding.astype(np.float32)
data.obs['patient_id'] = data.obs['sample_id'].astype(str).str.split('_').str[0]
data.obs['tissue_group'] = data.obs['tissue_type'].astype(str).map(
    {'tumour': 'Tumour', 'border': 'Background', 'healthy': 'Healthy'})
assert data.obs['tissue_group'].notna().all()
data.obs['split'] = np.select(
    [data.obs['patient_id'].isin(DEVELOPMENT), data.obs['patient_id'].isin(HOLDOUT)],
    ['Discovery', 'Held-out'], default='Healthy reference')
print(f'Attached {embedding.shape[0]:,} spots × {embedding.shape[1]} embedding dimensions.')
Attached 88,520 spots × 512 embedding dimensions.

4a. Check the study design and technical covariates

  • The source calls background tissue border. In the original study, this is non-involved lung, not a tumour-boundary annotation.
  • Tissue labels are per slide: a tumour slide contains both tumour and its microenvironment.
  • Compute depth, detected genes and mitochondrial fraction, so you can check later whether structure follows technical variation.
In [4]:
symbols = data.var['gene_symbols'].astype(str)
data.var['mt'] = symbols.str.upper().str.startswith('MT-').to_numpy()
sc.pp.calculate_qc_metrics(data, qc_vars=['mt'], percent_top=None, log1p=False, inplace=True)
data.obs['log1p_total_counts'] = np.log1p(data.obs['total_counts'])
slide_table = data.obs.groupby('sample_id', observed=True).agg(
    patient=('patient_id', 'first'), tissue=('tissue_group', 'first'),
    split=('split', 'first'), spots=('patient_id', 'size'),
    median_counts=('total_counts', 'median'), median_genes=('n_genes_by_counts', 'median'))
display(slide_table)
display(pd.crosstab(slide_table['patient'], slide_table['tissue']))
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
sns.boxplot(data=slide_table, x='tissue', y='median_counts', ax=axes[0])
sns.boxplot(data=slide_table, x='tissue', y='median_genes', ax=axes[1])
fig.suptitle('Each observation in these plots is one slide')
plt.tight_layout(); plt.show()
          patient      tissue              split  spots  median_counts  \
sample_id                                                                
D1_1           D1     Healthy  Healthy reference    278         1478.5   
D1_2           D1     Healthy  Healthy reference    333         2391.0   
D2_1           D2     Healthy  Healthy reference   1852         3288.0   
D2_2           D2     Healthy  Healthy reference   2432         3231.5   
P10_B1        P10  Background          Discovery   1755          989.0   
P10_B2        P10  Background          Discovery   1842          597.0   
P10_T1        P10      Tumour          Discovery   2200         4434.0   
P10_T2        P10      Tumour          Discovery   1986         4951.0   
P10_T3        P10      Tumour          Discovery   2610         4182.0   
P10_T4        P10      Tumour          Discovery   2479         2393.0   
P11_B1        P11  Background          Discovery   2873         1442.0   
P11_B2        P11  Background          Discovery   2846          856.0   
P11_T1        P11      Tumour          Discovery   1110         1822.5   
P11_T2        P11      Tumour          Discovery   1176         2053.0   
P11_T3        P11      Tumour          Discovery   1735          953.0   
P11_T4        P11      Tumour          Discovery   1811          868.0   
P15_B1        P15  Background          Discovery   2844         3035.5   
P15_B2        P15  Background          Discovery   2824         3626.5   
P15_T1        P15      Tumour          Discovery   2624         5977.5   
P15_T2        P15      Tumour          Discovery   3131         4833.0   
P16_B1        P16  Background          Discovery   3123          514.0   
P16_B2        P16  Background          Discovery   3351          263.0   
P16_T1        P16      Tumour          Discovery   2262         4333.0   
P16_T2        P16      Tumour          Discovery   2210         4080.5   
P17_B1        P17  Background          Discovery   1522          921.5   
P17_B2        P17  Background          Discovery   1093          986.0   
P17_T1        P17      Tumour          Discovery   2658        11850.0   
P17_T2        P17      Tumour          Discovery   2953         9334.0   
P19_B1        P19  Background          Discovery   2197          651.0   
P19_B2        P19  Background          Discovery   2120          941.5   
P19_T1        P19      Tumour          Discovery   2512         6045.5   
P19_T2        P19      Tumour          Discovery   2369         5055.0   
P24_B1        P24  Background           Held-out   2581         2751.0   
P24_B2        P24  Background           Held-out   3019         2030.0   
P24_T1        P24      Tumour           Held-out   3426         3342.0   
P24_T2        P24      Tumour           Held-out   3491         2561.0   
P25_B1        P25  Background           Held-out   1965         2158.0   
P25_B2        P25  Background           Held-out   2161         2325.0   
P25_T1        P25      Tumour           Held-out   1308         5005.5   
P25_T2        P25      Tumour           Held-out   1458         5390.0   

           median_genes  
sample_id                
D1_1              852.0  
D1_2             1247.0  
D2_1             1554.0  
D2_2             1566.0  
P10_B1            654.0  
P10_B2            438.5  
P10_T1           2192.0  
P10_T2           2350.5  
P10_T3           2047.5  
P10_T4           1312.0  
P11_B1            927.0  
P11_B2            604.0  
P11_T1           1033.5  
P11_T2           1141.0  
P11_T3            608.0  
P11_T4            538.0  
P15_B1           1507.5  
P15_B2           1786.0  
P15_T1           2544.0  
P15_T2           2183.0  
P16_B1            390.0  
P16_B2            218.0  
P16_T1           1959.5  
P16_T2           1914.5  
P17_B1            591.0  
P17_B2            604.0  
P17_T1           3731.0  
P17_T2           3333.0  
P19_B1            486.0  
P19_B2            649.5  
P19_T1           2654.5  
P19_T2           2328.0  
P24_B1           1422.0  
P24_B2           1161.0  
P24_T1           1507.0  
P24_T2           1276.0  
P25_B1           1165.0  
P25_B2           1247.0  
P25_T1           2333.5  
P25_T2           2389.5  
tissue   Background  Healthy  Tumour
patient                             
D1                0        2       0
D2                0        2       0
P10               2        0       4
P11               2        0       4
P15               2        0       2
P16               2        0       2
P17               2        0       2
P19               2        0       2
P24               2        0       2
P25               2        0       2
Output of cell 4

5Discover spot states, then spatial niches

  • Normalise each embedding to unit length.
  • Fit eight spot states on a slide-balanced sample from the discovery patients, then assign every other spot to its nearest state.
  • Describe each spot's neighbourhood by the states of up to six physical neighbours on the same slide, including itself.
  • Fit six niches on these neighbourhood profiles from discovery patients, then transfer them unchanged.
  • Eight states and six niches are fixed demo settings, not estimates of the true number of populations.
  • Spots with no neighbours are flagged as isolated.
In [5]:
from sklearn.cluster import KMeans

N_STATES, N_NICHES, PER_SLIDE = 8, 6, 1000
HEX_OFFSETS = [(0, 2), (0, -2), (1, 1), (1, -1), (-1, 1), (-1, -1)]


def balanced_sample(mask, obs, per_slide, rng):
    '''Up to `per_slide` spots from every slide in `mask`, so large sections don't dominate.'''
    index = np.flatnonzero(mask)
    slides = obs['sample_id'].astype(str).to_numpy()[index]
    return np.concatenate([rng.choice(group, min(per_slide, len(group)), replace=False)
                           for _, group in pd.Series(index).groupby(slides)])


def visium_adjacency(obs):
    '''Physical neighbours on the Visium hexagonal grid, within each slide.'''
    spots = pd.DataFrame({'slide': obs['sample_id'].astype(str).to_numpy(),
                          'row': obs['array_row'].to_numpy(), 'col': obs['array_col'].to_numpy(),
                          'i': np.arange(len(obs))})
    edges = []
    for d_row, d_col in HEX_OFFSETS:
        shifted = spots.assign(row=spots['row'] + d_row, col=spots['col'] + d_col)
        pairs = shifted.merge(spots, on=['slide', 'row', 'col'], suffixes=('', '_neighbour'))
        edges.append(pairs[['i', 'i_neighbour']].to_numpy())
    edges = np.concatenate(edges)
    return sparse.csr_matrix((np.ones(len(edges)), (edges[:, 0], edges[:, 1])),
                             shape=(len(obs), len(obs)))


def fit_spatial_states_and_niches(embedding, obs, development_patients, random_state):
    rng = np.random.default_rng(random_state)
    x = normalize(embedding)
    fit_index = balanced_sample(obs['patient_id'].isin(development_patients).to_numpy(), obs, PER_SLIDE, rng)

    state_model = KMeans(N_STATES, n_init=10, random_state=random_state).fit(x[fit_index])
    state_dist = state_model.transform(x)
    state_codes = state_dist.argmin(axis=1)
    state_distance = state_dist.min(axis=1)
    state_in_reference = state_distance <= np.quantile(state_distance[fit_index], 0.95)

    adjacency = visium_adjacency(obs)
    one_hot = np.eye(N_STATES)[state_codes]
    with_self = adjacency + sparse.identity(len(obs), format='csr')
    proportions = np.asarray(with_self @ one_hot)
    proportions /= proportions.sum(axis=1, keepdims=True)

    niche_model = KMeans(N_NICHES, n_init=10, random_state=random_state).fit(proportions[fit_index])
    niche_dist = niche_model.transform(proportions)
    niche_distance = niche_dist.min(axis=1)
    niche_covered = niche_distance <= np.quantile(niche_distance[fit_index], 0.95)

    state_names = [f'S{k + 1}' for k in range(N_STATES)]
    niche_names = [f'N{k + 1}' for k in range(N_NICHES)]
    assignments = pd.DataFrame({
        'niche_covered': niche_covered,
        'qc_pass': (obs['total_counts'].to_numpy() >= 500) & (obs['n_genes_by_counts'].to_numpy() >= 200),
        'n_neighbors': np.asarray(adjacency.sum(axis=1)).ravel().astype(int),
    }, index=obs.index)
    coverage = pd.DataFrame({'patient_id': obs['patient_id'].to_numpy(), 'split': obs['split'].to_numpy(),
                             'state_in_reference': state_in_reference, 'niche_covered': niche_covered})
    coverage = coverage.groupby(['patient_id', 'split'], observed=True).agg(
        spots=('state_in_reference', 'size'), state_coverage=('state_in_reference', 'mean'),
        niche_coverage=('niche_covered', 'mean')).reset_index()
    return {
        'states': np.array(state_names)[state_codes],
        'niches': np.array(niche_names)[niche_dist.argmin(axis=1)],
        'state_distance': state_distance, 'state_in_reference': state_in_reference,
        'assignments': assignments, 'neighborhood_proportions': proportions, 'adjacency': adjacency,
        'coverage': coverage,
        'niche_centroid_proportions': pd.DataFrame(niche_model.cluster_centers_, index=niche_names, columns=state_names),
        'models': {'states': state_model, 'niches': niche_model},
    }


discovery = fit_spatial_states_and_niches(
    data.obsm['X_nicheformer'], data.obs, development_patients=DEVELOPMENT, random_state=SEED,
)
data.obs['spot_state'] = pd.Categorical(discovery['states'])
data.obs['spatial_niche'] = pd.Categorical(discovery['niches'])
state_palette = dict(zip(data.obs['spot_state'].cat.categories, sns.color_palette('tab10', 8).as_hex()))
niche_palette = dict(zip(data.obs['spatial_niche'].cat.categories, sns.color_palette('Set2', 6).as_hex()))
data.uns['spot_state_colors'] = list(state_palette.values())
data.uns['spatial_niche_colors'] = list(niche_palette.values())
data.obs['state_distance'] = discovery['state_distance']
data.obs['state_in_reference'] = discovery['state_in_reference']
data.obs['niche_in_reference'] = discovery['assignments']['niche_covered'].to_numpy()
data.obs['qc_pass'] = discovery['assignments']['qc_pass'].to_numpy()
data.obs['spatial_degree'] = discovery['assignments']['n_neighbors'].to_numpy()
data.obs['isolated_spot'] = data.obs['spatial_degree'].eq(0)
data.obsm['state_neighborhood_proportions'] = discovery['neighborhood_proportions']
data.obsp['physical_neighbors'] = discovery['adjacency']
display(discovery['coverage'])
display(discovery['niche_centroid_proportions'].round(3))
display(data.obs.groupby('spot_state', observed=True).agg(
    spots=('sample_id', 'size'), qc_pass_fraction=('qc_pass', 'mean'),
    median_counts=('total_counts', 'median'), median_genes=('n_genes_by_counts', 'median')))
print('QC flag for sensitivity analysis: at least 500 UMIs and 200 detected genes; no rows dropped from the embedding.')
print(f'Isolated spots with self-only composition: {data.obs["isolated_spot"].sum():,}')
  patient_id              split  spots  state_coverage  niche_coverage
0         D1  Healthy reference    611        0.900164        0.859247
1         D2  Healthy reference   4284        0.934641        0.984360
2        P10          Discovery  12872        0.952921        0.964108
3        P11          Discovery  11551        0.941304        0.937927
4        P15          Discovery  11423        0.954653        0.982404
5        P16          Discovery  10946        0.967020        0.854193
6        P17          Discovery   8226        0.968271        0.986263
7        P19          Discovery   9198        0.939335        0.958143
8        P24           Held-out  12517        0.863466        0.978429
9        P25           Held-out   6892        0.960389        0.976204
       S1     S2     S3     S4     S5     S6     S7     S8
N1  0.002  0.256  0.122  0.000  0.001  0.030  0.010  0.579
N2  0.715  0.001  0.019  0.108  0.035  0.101  0.017  0.004
N3  0.011  0.001  0.039  0.001  0.137  0.191  0.611  0.009
N4  0.013  0.000  0.004  0.012  0.760  0.027  0.182  0.001
N5  0.021  0.007  0.412  0.004  0.012  0.343  0.083  0.118
N6  0.122  0.001  0.003  0.823  0.035  0.012  0.004  0.001
            spots  qc_pass_fraction  median_counts  median_genes
spot_state                                                      
S1           8386          1.000000         5257.0        2264.0
S2           5239          0.000764          181.0         151.0
S3          12658          0.999842         1070.0         693.0
S4           5958          1.000000        12614.0        4155.0
S5          10370          1.000000         9716.0        3430.0
S6          15895          1.000000         1994.0        1126.0
S7          17492          1.000000         3916.5        1841.0
S8          12522          0.530187          515.0         376.0
QC flag for sensitivity analysis: at least 500 UMIs and 200 detected genes; no rows dropped from the embedding.
Isolated spots with self-only composition: 335

6View the embedding

  • Colour the UMAP by tissue, patient and depth before reading the state colours. Patient-specific islands or depth gradients point to technical structure.
  • The UMAP is only a view. It isn't used to fit states or niches.
In [6]:
sc.pp.neighbors(data, use_rep='X_nicheformer', n_neighbors=15,
                metric='cosine', random_state=SEED, key_added='nicheformer_neighbors')
sc.tl.umap(data, neighbors_key='nicheformer_neighbors', random_state=SEED)
sc.pl.umap(data, color=['tissue_group', 'patient_id', 'log1p_total_counts',
                       'spot_state', 'spatial_niche', 'split'], ncols=3,
           wspace=0.35, size=3, show=False)
plt.savefig(OUTPUT / 'embedding_umap.png', dpi=150, bbox_inches='tight')
plt.show()
Output of cell 6

7Read the states with marker programs

  • Score nine marker programs per spot: mean log1p counts per 10,000 UMIs.
  • The scores come from the same expression data as the embeddings, so they help interpretation but aren't independent validation.
  • Epithelial markers also appear in normal epithelium, so an epithelial or hypoxia-like score alone doesn't mark tumour.
In [7]:
programs = {
    'Epithelial': ['EPCAM', 'KRT8', 'KRT18', 'KRT19'],
    'Alveolar': ['SFTPA1', 'SFTPB', 'SFTPC', 'SFTPD'],
    'T_cell': ['CD3D', 'CD3E', 'TRAC'],
    'B_cell': ['MS4A1', 'CD79A', 'CD79B'],
    'Cytotoxic': ['NKG7', 'GNLY', 'PRF1'],
    'Myeloid': ['LST1', 'TYROBP', 'FCER1G'],
    'Fibroblast': ['COL1A1', 'COL1A2', 'DCN'],
    'Endothelial': ['PECAM1', 'VWF', 'KDR'],
    'Hypoxia_like': ['CA9', 'SLC2A1', 'VEGFA', 'LDHA'],
}
marker_report = []
totals = data.obs['total_counts'].to_numpy()
for name, genes in programs.items():
    mask = symbols.isin(genes).to_numpy()
    present = symbols[mask].tolist()
    assert len(present) >= 2, (name, present)
    x = data.X[:, mask].astype(np.float32)
    x = sparse.diags(10000 / np.maximum(totals, 1)) @ x
    x.data = np.log1p(x.data)
    data.obs[f'score_{name}'] = np.asarray(x.mean(axis=1)).ravel()
    marker_report.append({'program': name, 'present': ', '.join(present),
                          'missing': ', '.join(sorted(set(genes) - set(present)))})
display(pd.DataFrame(marker_report))
score_cols = [f'score_{name}' for name in programs]
scores = data.obs[score_cols]
is_development = data.obs['patient_id'].isin(DEVELOPMENT)
z = (scores - scores.loc[is_development].mean()) / scores.loc[is_development].std().clip(lower=1e-6)
profile_frame = z.join(data.obs[['sample_id', 'patient_id', 'tissue_group', 'spot_state', 'spatial_niche', 'split']])
state_profiles = profile_frame.groupby(['sample_id', 'spot_state'], observed=True)[score_cols].mean()
state_profiles = state_profiles.groupby('spot_state', observed=True).mean()
fig, ax = plt.subplots(figsize=(10, 5))
sns.heatmap(state_profiles.rename(columns=lambda c: c.removeprefix('score_')),
            cmap='vlag', center=0, ax=ax)
ax.set_title('Spot states: marker profiles (equal weight per represented slide)')
plt.tight_layout(); plt.savefig(OUTPUT / 'state_markers.png', dpi=150); plt.show()
        program                      present missing
0    Epithelial    EPCAM, KRT8, KRT18, KRT19        
1      Alveolar  SFTPB, SFTPC, SFTPA1, SFTPD        
2        T_cell             CD3E, CD3D, TRAC        
3        B_cell          MS4A1, CD79B, CD79A        
4     Cytotoxic             GNLY, PRF1, NKG7        
5       Myeloid         FCER1G, LST1, TYROBP        
6    Fibroblast          COL1A2, DCN, COL1A1        
7   Endothelial             KDR, VWF, PECAM1        
8  Hypoxia_like     SLC2A1, VEGFA, CA9, LDHA        
Output of cell 7

8Map states and niches onto the tissue

  • Each slide's histology image is stored under its slide ID in spatial_images.h5ad.
  • Compare a discovery patient (P16) with both held-out patients (P24 and P25). A colour means the same state or niche in every panel.
  • Change the slide list to look at any of the 40 sections.
In [8]:
images = ad.read_h5ad(IMAGES)
assert set(images.uns['spatial']) == set(data.obs['sample_id'].astype(str)), 'Every slide needs its image.'
In [9]:
state_palette = dict(zip(data.obs['spot_state'].cat.categories, sns.color_palette('tab10', 8).as_hex()))
niche_palette = dict(zip(data.obs['spatial_niche'].cat.categories, sns.color_palette('Set2', 6).as_hex()))

def plot_slides(slides, colour, palette=None, vmin=None, vmax=None):
    from matplotlib.collections import PatchCollection
    from matplotlib.patches import Circle, Patch
    from matplotlib.colors import Normalize

    fig, axes = plt.subplots(1, len(slides), figsize=(5 * len(slides), 5), squeeze=False)
    for ax, slide_id in zip(axes.ravel(), slides):
        mask = data.obs['sample_id'].astype(str).eq(slide_id).to_numpy()
        bundle = images.uns['spatial'][slide_id]
        scale = bundle['scalefactors']['tissue_hires_scalef']
        radius = bundle['scalefactors']['spot_diameter_fullres'] * scale / 2
        xy = data.obsm['spatial'][mask] * scale
        patches = [Circle(point, radius) for point in xy]
        ax.imshow(bundle['images']['hires'], origin='upper')
        if palette is not None:
            colours = [palette[str(value)] for value in data.obs.loc[mask, colour]]
            collection = PatchCollection(patches, facecolors=colours, edgecolors='none', alpha=0.8)
        else:
            collection = PatchCollection(patches, cmap='viridis', norm=Normalize(vmin, vmax),
                                         edgecolors='none', alpha=0.8)
            collection.set_array(data.obs.loc[mask, colour].to_numpy(dtype=float))
            fig.colorbar(collection, ax=ax, fraction=0.035, pad=0.01, label=colour.removeprefix('score_'))
        ax.add_collection(collection)
        ax.set_title(f'{slide_id} | {data.obs.loc[mask, "split"].iloc[0]}')
        ax.set_aspect('equal'); ax.axis('off')
    if palette is not None:
        fig.legend(handles=[Patch(color=value, label=key) for key, value in palette.items()],
                   loc='lower center', ncol=len(palette), frameon=False, title=colour)
    fig.tight_layout(rect=(0, 0.12 if palette else 0, 1, 1))
    fig.savefig(OUTPUT / f'spatial_{colour}_{"_".join(slides)}.png', dpi=150, bbox_inches='tight')
    plt.show()

plot_slides(['P16_T1', 'P24_T1', 'P25_T1'], 'spot_state', state_palette)
plot_slides(['P16_T1', 'P24_T1', 'P25_T1'], 'spatial_niche', niche_palette)
plot_slides(['P24_B1', 'P24_T1', 'D2_1'], 'spatial_niche', niche_palette)
program = 'score_Myeloid'  # also try score_Fibroblast, score_T_cell or score_Epithelial
plot_slides(['P16_T1', 'P24_T1', 'P25_T1'], program,
            vmin=0, vmax=float(data.obs[program].quantile(0.98)))
Output of cell 9Output of cell 9Output of cell 9Output of cell 9

9Do the niches recur across patients?

  • Compute niche fractions per patient and tissue, averaging slides so a large section doesn't dominate.
  • Held-out patients didn't contribute to fitting, but every spot is assigned to its nearest niche, so recurrence alone isn't enough. Also compare reference coverage and marker profiles between discovery and held-out patients.
In [10]:
slide_fractions = pd.crosstab(data.obs['sample_id'], data.obs['spatial_niche'], normalize='index')
patient_fractions = slide_fractions.join(slide_table[['patient', 'tissue', 'split']])
patient_fractions = patient_fractions.groupby(['patient', 'tissue', 'split'], observed=True).mean()
display(patient_fractions.round(3))
fig, ax = plt.subplots(figsize=(10, 8))
sns.heatmap(patient_fractions, cmap='Blues', vmin=0, vmax=1, ax=ax)
ax.set_title('Candidate niche fractions; equal weight per slide')
plt.tight_layout(); plt.savefig(OUTPUT / 'niche_patient_fractions.png', dpi=150); plt.show()

niche_profiles = profile_frame.groupby(['split', 'sample_id', 'spatial_niche'], observed=True)[score_cols].mean()
niche_profiles = niche_profiles.groupby(['split', 'spatial_niche'], observed=True).mean()
fig, axes = plt.subplots(1, 2, figsize=(16, 5))
for ax, split in zip(axes, ['Discovery', 'Held-out']):
    sns.heatmap(niche_profiles.loc[split].rename(columns=lambda c: c.removeprefix('score_')),
                cmap='vlag', center=0, vmin=-1, vmax=1, ax=ax)
    ax.set_title(f'{split}: niche marker profiles')
plt.tight_layout(); plt.savefig(OUTPUT / 'transferred_niche_markers.png', dpi=150); plt.show()
                                         N1     N2     N3     N4     N5     N6
patient tissue     split                                                      
D1      Healthy    Healthy reference  0.220  0.000  0.345  0.086  0.349  0.000
D2      Healthy    Healthy reference  0.064  0.003  0.543  0.139  0.251  0.000
P10     Background Discovery          0.515  0.000  0.073  0.003  0.409  0.000
        Tumour     Discovery          0.038  0.000  0.410  0.297  0.254  0.000
P11     Background Discovery          0.373  0.000  0.214  0.029  0.384  0.000
        Tumour     Discovery          0.246  0.142  0.079  0.013  0.494  0.025
P15     Background Discovery          0.032  0.000  0.495  0.188  0.286  0.000
        Tumour     Discovery          0.005  0.000  0.474  0.394  0.127  0.000
P16     Background Discovery          0.826  0.000  0.008  0.000  0.166  0.000
        Tumour     Discovery          0.024  0.132  0.336  0.190  0.207  0.110
P17     Background Discovery          0.481  0.000  0.152  0.018  0.349  0.000
        Tumour     Discovery          0.003  0.203  0.040  0.002  0.062  0.689
P19     Background Discovery          0.523  0.000  0.124  0.008  0.345  0.000
        Tumour     Discovery          0.008  0.803  0.002  0.005  0.120  0.062
P24     Background Held-out           0.034  0.000  0.457  0.046  0.463  0.000
        Tumour     Held-out           0.096  0.452  0.003  0.000  0.347  0.102
P25     Background Held-out           0.051  0.000  0.397  0.036  0.516  0.000
        Tumour     Held-out           0.147  0.058  0.354  0.306  0.117  0.017
Output of cell 10Output of cell 10

9a. Test tumour association in each held-out patient

  • Compute tumour-minus-background niche fractions within each patient.
  • Pick candidate niches on discovery patients only: mean enrichment above 5 percentage points, and positive in at least four of six patients.
  • A candidate passes in a held-out patient when it is tumour-enriched, has at least 50 spots across two slides, a marker-profile correlation of at least 0.5 with discovery, and state coverage of at least 0.8.
  • These are fixed demo cutoffs, not significance tests.
In [11]:
tumour_fraction = patient_fractions.xs('Tumour', level='tissue').droplevel('split')
background_fraction = patient_fractions.xs('Background', level='tissue').droplevel('split')
paired_enrichment = tumour_fraction - background_fraction
candidate_niches = paired_enrichment.columns[
    (paired_enrichment.loc[DEVELOPMENT].mean() > 0.05)
    & (paired_enrichment.loc[DEVELOPMENT].gt(0).sum() >= 4)].tolist()
print('Discovery-selected tumour-associated candidates:', candidate_niches or 'None meet the stated rule')
display(paired_enrichment.round(3))
fig, ax = plt.subplots(figsize=(9, 5))
sns.heatmap(paired_enrichment, cmap='vlag', center=0, vmin=-0.6, vmax=0.6, ax=ax)
ax.set_title('Paired tumour minus background niche fraction')
plt.tight_layout(); plt.savefig(OUTPUT / 'paired_niche_enrichment.png', dpi=150); plt.show()

# First average within slide, then within patient; compare held-out patients separately.
by_patient_profile = profile_frame.groupby(
    ['patient_id', 'sample_id', 'spatial_niche'], observed=True)[score_cols].mean()
by_patient_profile = by_patient_profile.groupby(['patient_id', 'spatial_niche'], observed=True).mean()
discovery_profile = by_patient_profile.loc[DEVELOPMENT].groupby('spatial_niche', observed=True).mean()
support = data.obs.groupby(['patient_id', 'spatial_niche'], observed=True).agg(
    n_spots=('sample_id', 'size'), n_slides=('sample_id', 'nunique'),
    state_coverage=('state_in_reference', 'mean'), niche_coverage=('niche_in_reference', 'mean'))
transfer_rows = []
for patient in HOLDOUT:
    for niche in data.obs['spatial_niche'].cat.categories:
        if (patient, niche) not in support.index:
            transfer_rows.append({'patient_id': patient, 'niche': niche, 'supported': False,
                                  'n_spots': 0, 'n_slides': 0, 'marker_correlation': np.nan})
            continue
        stats = support.loc[(patient, niche)]
        supported = stats['n_spots'] >= 50 and stats['n_slides'] >= 2
        reference = discovery_profile.loc[niche].to_numpy()
        query = by_patient_profile.loc[(patient, niche)].to_numpy()
        correlation = np.corrcoef(reference, query)[0, 1] if supported else np.nan
        transfer_rows.append({'patient_id': patient, 'niche': niche,
                              **stats.to_dict(), 'supported': supported,
                              'marker_correlation': correlation,
                              'tumour_minus_background': paired_enrichment.loc[patient, niche],
                              'discovery_candidate': niche in candidate_niches})
niche_transfer = pd.DataFrame(transfer_rows)
display(niche_transfer.round(3))
Discovery-selected tumour-associated candidates: ['N2', 'N6']
            N1     N2     N3     N4     N5     N6
patient                                          
P10     -0.477  0.000  0.337  0.294 -0.154  0.000
P11     -0.127  0.142 -0.135 -0.016  0.111  0.025
P15     -0.026  0.000 -0.021  0.206 -0.159  0.000
P16     -0.802  0.132  0.328  0.190  0.041  0.110
P17     -0.478  0.203 -0.112 -0.016 -0.287  0.689
P19     -0.515  0.803 -0.122 -0.004 -0.225  0.062
P24      0.062  0.451 -0.453 -0.046 -0.116  0.102
P25      0.097  0.058 -0.042  0.270 -0.399  0.017
Output of cell 11
   patient_id niche  n_spots  n_slides  state_coverage  niche_coverage  \
0         P24    N1    869.0       4.0           0.719           0.969   
1         P24    N2   3124.0       4.0           0.923           0.999   
2         P24    N3   2547.0       4.0           0.982           1.000   
3         P24    N4    251.0       2.0           0.988           1.000   
4         P24    N5   5024.0       4.0           0.771           0.952   
5         P24    N6    702.0       2.0           0.963           0.999   
6         P25    N1    614.0       4.0           0.879           0.907   
7         P25    N2    161.0       2.0           1.000           1.000   
8         P25    N3   2623.0       4.0           0.992           1.000   
9         P25    N4    998.0       4.0           0.999           1.000   
10        P25    N5   2449.0       4.0           0.928           0.956   
11        P25    N6     47.0       2.0           1.000           1.000   

    supported  marker_correlation  tumour_minus_background  \
0        True               0.857                    0.062   
1        True               0.521                    0.451   
2        True               0.276                   -0.453   
3        True               0.176                   -0.046   
4        True               0.256                   -0.116   
5        True               0.929                    0.102   
6        True               0.872                    0.097   
7        True               0.445                    0.058   
8        True               0.934                   -0.042   
9        True               0.298                    0.270   
10       True               0.874                   -0.399   
11      False                 NaN                    0.017   

    discovery_candidate  
0                 False  
1                  True  
2                 False  
3                 False  
4                 False  
5                  True  
6                 False  
7                  True  
8                 False  
9                 False  
10                False  
11                 True  

10Check spatial organisation

  • Use the spot states, not the niches. Niches are built from neighbours, so neighbouring spots agree by construction.
  • For each slide, compare how often neighbouring spots share a state with 100 shuffles of the state labels within that slide.
  • Summarise per patient, because neighbouring spots aren't independent.
In [12]:
def spatial_state_enrichment(obs, adjacency, states, n_permutations, random_state):
    '''Share of neighbouring spots with the same state, against shuffles within each slide.'''
    rng = np.random.default_rng(random_state)
    states = np.asarray(states)
    edges = sparse.triu(adjacency, k=1).tocoo()
    slides = obs['sample_id'].astype(str).to_numpy()
    patients = obs['patient_id'].astype(str).to_numpy()
    rows = []
    for slide in np.unique(slides):
        nodes = np.flatnonzero(slides == slide)
        keep = slides[edges.row] == slide
        i, j = edges.row[keep], edges.col[keep]
        if len(i) == 0:
            continue
        observed = float(np.mean(states[i] == states[j]))
        shuffled = states.copy()
        null = []
        for _ in range(n_permutations):
            shuffled[nodes] = rng.permutation(states[nodes])
            null.append(np.mean(shuffled[i] == shuffled[j]))
        rows.append({'sample_id': slide, 'patient_id': patients[nodes[0]], 'n_edges': len(i),
                     'observed_same_state': observed, 'null_mean': float(np.mean(null)),
                     'null_sd': float(np.std(null)), 'excess': observed - float(np.mean(null))})
    return pd.DataFrame(rows)


spatial_results = spatial_state_enrichment(
    data.obs, discovery['adjacency'], discovery['states'],
    n_permutations=100, random_state=SEED,
)
display(spatial_results)
spatial_patient = spatial_results.groupby('patient_id').agg(
    observed=('observed_same_state', 'mean'),
    expected=('null_mean', 'mean'), excess=('excess', 'mean'))
display(spatial_patient)
   sample_id patient_id  n_edges  observed_same_state  null_mean   null_sd  \
0       D1_1         D1      346             0.456647   0.201994  0.021363   
1       D1_2         D1      434             0.453917   0.232143  0.020641   
2       D2_1         D2     4703             0.438444   0.239377  0.005631   
3       D2_2         D2     6787             0.402240   0.283134  0.005524   
4     P10_B1        P10     5041             0.555247   0.252672  0.006012   
5     P10_B2        P10     5288             0.537254   0.278196  0.006155   
6     P10_T1        P10     6306             0.643356   0.281541  0.005143   
7     P10_T2        P10     5644             0.662651   0.294697  0.005956   
8     P10_T3        P10     7439             0.586369   0.285135  0.004725   
9     P10_T4        P10     7059             0.469613   0.218760  0.005669   
10    P11_B1        P11     8233             0.427912   0.196933  0.004011   
11    P11_B2        P11     8034             0.448345   0.206342  0.004346   
12    P11_T1        P11     3057             0.472359   0.213948  0.006502   
13    P11_T2        P11     3271             0.448487   0.193543  0.006724   
14    P11_T3        P11     4768             0.496225   0.249799  0.005711   
15    P11_T4        P11     4962             0.447400   0.255079  0.005730   
16    P15_B1        P15     7919             0.430736   0.245993  0.004986   
17    P15_B2        P15     7735             0.459341   0.287529  0.005336   
18    P15_T1        P15     7313             0.560509   0.377119  0.005383   
19    P15_T2        P15     8955             0.524176   0.321168  0.004103   
20    P16_B1        P16     8786             0.493057   0.292367  0.004473   
21    P16_B2        P16     9509             0.620360   0.416258  0.004817   
22    P16_T1        P16     6464             0.436108   0.179500  0.004944   
23    P16_T2        P16     6244             0.429212   0.171015  0.004677   
24    P17_B1        P17     4149             0.487105   0.262099  0.006311   
25    P17_B2        P17     2701             0.483525   0.251311  0.008144   
26    P17_T1        P17     7544             0.725080   0.493368  0.004035   
27    P17_T2        P17     8302             0.697422   0.455901  0.004658   
28    P19_B1        P19     6220             0.504823   0.255672  0.005040   
29    P19_B2        P19     6043             0.481549   0.242850  0.005412   
30    P19_T1        P19     7156             0.654556   0.461582  0.004331   
31    P19_T2        P19     6537             0.700780   0.541724  0.004196   
32    P24_B1        P24     7491             0.412495   0.272452  0.005273   
33    P24_B2        P24     8778             0.356573   0.248470  0.004264   
34    P24_T1        P24     9914             0.346278   0.227705  0.003753   
35    P24_T2        P24    10019             0.357820   0.213823  0.003439   
36    P25_B1        P25     5655             0.410080   0.254329  0.004817   
37    P25_B2        P25     6245             0.397278   0.261177  0.004664   
38    P25_T1        P25     3679             0.494428   0.183487  0.005953   
39    P25_T2        P25     4126             0.509210   0.203095  0.005814   

      excess  
0   0.254653  
1   0.221774  
2   0.199067  
3   0.119106  
4   0.302575  
5   0.259058  
6   0.361814  
7   0.367954  
8   0.301234  
9   0.250853  
10  0.230979  
11  0.242003  
12  0.258410  
13  0.254943  
14  0.246426  
15  0.192322  
16  0.184743  
17  0.171811  
18  0.183390  
19  0.203008  
20  0.200690  
21  0.204101  
22  0.256607  
23  0.258197  
24  0.225006  
25  0.232214  
26  0.231711  
27  0.241521  
28  0.249151  
29  0.238699  
30  0.192974  
31  0.159056  
32  0.140043  
33  0.108103  
34  0.118573  
35  0.143997  
36  0.155751  
37  0.136101  
38  0.310940  
39  0.306115  
            observed  expected    excess
patient_id                              
D1          0.455282  0.217069  0.238214
D2          0.420342  0.261255  0.159086
P10         0.575748  0.268500  0.307248
P11         0.456788  0.219274  0.237514
P15         0.493690  0.307952  0.185738
P16         0.494684  0.264785  0.229899
P17         0.598283  0.365670  0.232613
P19         0.585427  0.375457  0.209970
P24         0.368292  0.240613  0.127679
P25         0.452749  0.225522  0.227227

11Predict tissue in held-out patients

  • Leave one patient out across the eight cancer patients. All slides from the held-out patient stay out of training.
  • Fit a nearest-centroid readout and a logistic probe on the frozen Nicheformer embeddings. Compare both with a QC-only baseline: depth, detected genes and mitochondrial fraction.
  • These steps use tissue labels, so they measure how much tissue signal the frozen embeddings carry. They aren't zero-shot predictions.
  • A label-shuffle control swaps tumour and background within training patients. Chance is 0.5.
In [13]:
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import balanced_accuracy_score, roc_auc_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

QC_FEATURES = ['log1p_total_counts', 'n_genes_by_counts', 'pct_counts_mt']
TRAIN_PER_SLIDE = 500


def probe():
    return make_pipeline(StandardScaler(), LogisticRegression(max_iter=2000))


def centroid_scores(x_train, y_train, x_test):
    tumour, background = (normalize(x_train[y_train == k].mean(axis=0, keepdims=True)) for k in (1, 0))
    return (x_test @ (tumour - background).T).ravel()


def leave_one_patient_out(embedding, obs, n_permutations, random_state, include_logistic=True):
    '''Tumour versus background, holding out all slides of one cancer patient at a time.'''
    rng = np.random.default_rng(random_state)
    cancer = obs['tissue_group'].isin(['Tumour', 'Background']).to_numpy()
    x = normalize(embedding)
    qc = obs[QC_FEATURES].to_numpy(dtype=float)
    y = obs['tissue_group'].eq('Tumour').to_numpy().astype(int)
    patients = obs['patient_id'].astype(str).to_numpy()
    metrics, predictions, null = [], [], []
    for patient in sorted(np.unique(patients[cancer])):
        test = cancer & (patients == patient)
        train = balanced_sample(cancer & (patients != patient), obs, TRAIN_PER_SLIDE, rng)
        scores = {'embedding_centroid': centroid_scores(x[train], y[train], x[test]),
                  'qc_logistic': probe().fit(qc[train], y[train]).predict_proba(qc[test])[:, 1]}
        if include_logistic:
            scores['embedding_logistic'] = probe().fit(x[train], y[train]).predict_proba(x[test])[:, 1]
        for model, score in scores.items():
            threshold = 0 if model == 'embedding_centroid' else 0.5
            metrics.append({'patient_id': patient, 'model': model, 'n_spots': int(test.sum()),
                            'balanced_accuracy': balanced_accuracy_score(y[test], score > threshold),
                            'auroc': roc_auc_score(y[test], score)})
            predictions.append(pd.DataFrame({'spot_id': obs.index[test], 'patient_id': patient, 'model': model,
                                             'tumour': y[test], 'score': score}))
        # Control: swap tumour and background for a random half of the training patients.
        null_model = 'embedding_logistic' if include_logistic else 'embedding_centroid'
        for permutation in range(n_permutations):
            flipped = rng.choice(np.unique(patients[train]), size=len(np.unique(patients[train])) // 2, replace=False)
            y_null = np.where(np.isin(patients[train], flipped), 1 - y[train], y[train])
            if include_logistic:
                score = probe().fit(x[train], y_null).predict_proba(x[test])[:, 1]
                prediction = score > 0.5
            else:
                score = centroid_scores(x[train], y_null, x[test])
                prediction = score > 0
            null.append({'patient_id': patient, 'model': null_model, 'permutation': permutation,
                         'balanced_accuracy': balanced_accuracy_score(y[test], prediction),
                         'auroc': roc_auc_score(y[test], score)})
    null = pd.DataFrame(null)
    null_summary = null.groupby('model').agg(
        permutations=('permutation', 'nunique'),
        balanced_accuracy_mean=('balanced_accuracy', 'mean'), balanced_accuracy_sd=('balanced_accuracy', 'std'),
        auroc_mean=('auroc', 'mean'), auroc_sd=('auroc', 'std')).reset_index()
    return {'metrics': pd.DataFrame(metrics), 'predictions': pd.concat(predictions, ignore_index=True),
            'null_summary': null_summary}


evaluation = leave_one_patient_out(
    data.obsm['X_nicheformer'], data.obs,
    n_permutations=20, random_state=SEED,
)
patient_metrics = evaluation['metrics']
display(patient_metrics.round(3))
display(patient_metrics.groupby('model')[['balanced_accuracy', 'auroc']].agg(['mean', 'std']))
display(evaluation['null_summary'].round(3))
fig, axes = plt.subplots(1, 2, figsize=(12, 4))
for ax, metric in zip(axes, ['balanced_accuracy', 'auroc']):
    sns.pointplot(data=patient_metrics, x='patient_id', y=metric, hue='model',
                  dodge=0.3, errorbar=None, linestyles='none', ax=ax)
    ax.axhline(0.5, color='grey', linestyle='--')
    ax.set_ylim(0, 1.02)
    ax.set_title(f'Leave-one-patient-out {metric}')
plt.tight_layout(); plt.savefig(OUTPUT / 'heldout_patient_metrics.png', dpi=150); plt.show()
   patient_id               model  n_spots  balanced_accuracy  auroc
0         P10  embedding_centroid    12872              0.800  0.907
1         P10         qc_logistic    12872              0.796  0.882
2         P10  embedding_logistic    12872              0.754  0.893
3         P11  embedding_centroid    11551              0.528  0.638
4         P11         qc_logistic    11551              0.511  0.541
5         P11  embedding_logistic    11551              0.857  0.952
6         P15  embedding_centroid    11423              0.592  0.738
7         P15         qc_logistic    11423              0.494  0.531
8         P15  embedding_logistic    11423              0.824  0.944
9         P16  embedding_centroid    10946              0.866  0.978
10        P16         qc_logistic    10946              0.866  0.951
11        P16  embedding_logistic    10946              0.764  0.903
12        P17  embedding_centroid     8226              0.880  0.978
13        P17         qc_logistic     8226              0.834  0.946
14        P17  embedding_logistic     8226              0.944  0.999
15        P19  embedding_centroid     9198              0.886  0.969
16        P19         qc_logistic     9198              0.841  0.920
17        P19  embedding_logistic     9198              0.986  1.000
18        P24  embedding_centroid    12517              0.554  0.643
19        P24         qc_logistic    12517              0.513  0.571
20        P24  embedding_logistic    12517              0.848  0.990
21        P25  embedding_centroid     6892              0.631  0.744
22        P25         qc_logistic     6892              0.569  0.649
23        P25  embedding_logistic     6892              0.856  0.950
                   balanced_accuracy               auroc          
                                mean       std      mean       std
model                                                             
embedding_centroid          0.717019  0.155620  0.824412  0.149369
embedding_logistic          0.854106  0.080053  0.953955  0.041009
qc_logistic                 0.677934  0.169392  0.748915  0.192471
                model  permutations  balanced_accuracy_mean  \
0  embedding_logistic            20                   0.556   

   balanced_accuracy_sd  auroc_mean  auroc_sd  
0                 0.188       0.581      0.27  
Output of cell 13

11a. Check sensitivity to low-count spots

  • Repeat the comparison on spots that pass the QC flag (at least 500 UMIs and 200 detected genes).
  • The embeddings are subset after alignment; Nicheformer isn't rerun.
In [14]:
qc_mask = data.obs['qc_pass'].to_numpy()
qc_evaluation = leave_one_patient_out(
    data.obsm['X_nicheformer'][qc_mask], data.obs.loc[qc_mask],
    n_permutations=20, random_state=SEED, include_logistic=True,
)
qc_metrics = qc_evaluation['metrics']
display(qc_metrics.round(3))
display(qc_metrics.groupby('model')[['balanced_accuracy', 'auroc']].mean().round(3))
   patient_id               model  n_spots  balanced_accuracy  auroc
0         P10  embedding_centroid    11536              0.785  0.884
1         P10         qc_logistic    11536              0.775  0.847
2         P10  embedding_logistic    11536              0.773  0.943
3         P11  embedding_centroid     9361              0.506  0.618
4         P11         qc_logistic     9361              0.500  0.476
5         P11  embedding_logistic     9361              0.852  0.954
6         P15  embedding_centroid    11295              0.598  0.759
7         P15         qc_logistic    11295              0.487  0.511
8         P15  embedding_logistic    11295              0.716  0.916
9         P16  embedding_centroid     6809              0.850  0.967
10        P16         qc_logistic     6809              0.833  0.909
11        P16  embedding_logistic     6809              0.758  0.898
12        P17  embedding_centroid     7667              0.861  0.977
13        P17         qc_logistic     7667              0.816  0.932
14        P17  embedding_logistic     7667              0.954  0.998
15        P19  embedding_centroid     7818              0.856  0.964
16        P19         qc_logistic     7818              0.810  0.892
17        P19  embedding_logistic     7818              0.980  1.000
18        P24  embedding_centroid    11711              0.573  0.695
19        P24         qc_logistic    11711              0.534  0.598
20        P24  embedding_logistic    11711              0.835  0.993
21        P25  embedding_centroid     6486              0.686  0.825
22        P25         qc_logistic     6486              0.635  0.716
23        P25  embedding_logistic     6486              0.860  0.958
                    balanced_accuracy  auroc
model                                       
embedding_centroid              0.714  0.836
embedding_logistic              0.841  0.958
qc_logistic                     0.674  0.735

12Summarise the results

  • Read three views together: tissue signal in held-out patients, spatial organisation, and niches that recur with similar markers.
  • Compare each one with the QC-only baseline and the label-shuffle control.
In [15]:
# This compact report is populated only from computed outputs.
means = patient_metrics.groupby('model')[['balanced_accuracy', 'auroc']].mean()
display(Markdown('**Measured evidence:**'))
display(means.round(3))
heldout_coverage = discovery['coverage'].loc[discovery['coverage']['patient_id'].isin(HOLDOUT)]
display(heldout_coverage)
transfer_check = niche_transfer.assign(passes=lambda f:
    f['supported'] & f['tumour_minus_background'].gt(0)
    & f['marker_correlation'].ge(0.5) & f['state_coverage'].ge(0.8))
recurrent_niches = [niche for niche in candidate_niches
                   if transfer_check.loc[transfer_check['niche'].eq(niche), 'passes'].sum() == len(HOLDOUT)]
centroid_ba = means.loc['embedding_centroid', 'balanced_accuracy']
probe_ba = means.loc['embedding_logistic', 'balanced_accuracy']
qc_ba = means.loc['qc_logistic', 'balanced_accuracy']
patient_range = patient_metrics.query("model == 'embedding_logistic'")['balanced_accuracy']
spatial_excess = float(spatial_patient['excess'].mean())
summary = (
    f'**Answer in this cohort.** Across eight patient-held-out folds, mean balanced accuracy '
    f'was **{centroid_ba:.3f}** for the Nicheformer centroid readout and **{probe_ba:.3f}** '
    f'for the supervised linear probe, versus **{qc_ba:.3f}** for QC alone. '
    f'Probe accuracy ranged from {patient_range.min():.3f} to {patient_range.max():.3f} across patients. '
    f'Across all ten subjects (including the two healthy donors), the mean excess of same-state physical neighbours over shuffled labels was '
    f'**{spatial_excess:.3f}**. Of {len(candidate_niches)} discovery-selected tumour-enriched '
    f'candidate niches, **{len(recurrent_niches)}** passed the stated exploratory transfer checks '
    f'in both held-out patients ({", ".join(recurrent_niches) or "none"}).'
)
interpretation = (
    'The combined results provide exploratory support for recurring tumour-associated spatial niches '
    'in frozen Nicheformer representations, with the patient-specific limitations shown above.'
    if recurrent_niches and probe_ba > qc_ba and spatial_excess > 0 else
    'The combined results do not establish reliable recovery of tumour-associated niches across '
    'both held-out patients under the stated checks; examine which component failed in the tables above.'
)
display(Markdown(summary + '\n\n' + interpretation +
    ' These spot mixtures are not validated malignant or immune-cell identities. A counts-derived '
    'baseline and independent spatial annotations are still needed to establish model-specific added value.'))
(OUTPUT / 'findings.md').write_text(summary + '\n\n' + interpretation + '\n')
<IPython.core.display.Markdown object>
                    balanced_accuracy  auroc
model                                       
embedding_centroid              0.717  0.824
embedding_logistic              0.854  0.954
qc_logistic                     0.678  0.749
  patient_id     split  spots  state_coverage  niche_coverage
8        P24  Held-out  12517        0.863466        0.978429
9        P25  Held-out   6892        0.960389        0.976204
<IPython.core.display.Markdown object>
757

What the recorded run shows

  • Tissue signal transfers across patients. The logistic probe on Nicheformer embeddings reaches a mean balanced accuracy of 0.854 across the eight held-out folds, against 0.678 for the QC-only baseline and 0.717 for the nearest-centroid readout. The label-shuffle control stays near chance (0.556).
  • N2 is the strongest candidate niche. It is tumour-enriched in both held-out patients (+45.1 and +5.8 percentage points) and is built mostly from the myeloid- and hypoxia-high state S1. Its marker correlation is 0.521 in P24 but 0.445 in P25, just under the 0.5 cutoff.
  • N6 can't be tested in P25. It passes every check in P24 (+10.2 points, marker correlation 0.929), but P25 has only 47 N6 spots, below the 50-spot cutoff. The same niche label doesn't guarantee the same support in every patient.
  • Some structure is technical. State S2 has a median of 181 UMIs and almost none of its spots pass the QC flag, and niche N1 is dominated by S2 and the low-count state S8.
  • Confirming specific microenvironments needs pathology or cell-composition labels, and a counts-derived baseline to isolate what Nicheformer adds.

13Save the results

  • Save a compact AnnData with annotations, embeddings, UMAP, states, niches and physical neighbours. Counts and images stay in the source files.
  • Save the result tables as CSV and the fitted state and niche models.
  • Keep the downloaded embeddings: repeating the analysis needs no new run.
In [16]:
import joblib

result = ad.AnnData(X=sparse.csr_matrix((data.n_obs, 0)), obs=data.obs.copy())
for key in ['X_nicheformer', 'X_umap', 'spatial', 'state_neighborhood_proportions']:
    result.obsm[key] = data.obsm[key]
result.obsp['physical_neighbors'] = data.obsp['physical_neighbors']
result.uns['source_h5ad'] = str(INPUT)
result.uns['development_patients'] = DEVELOPMENT
result.uns['heldout_patients'] = HOLDOUT
result.write_h5ad(OUTPUT / 'spatial_nicheformer_analysis.h5ad', compression='gzip')
patient_metrics.to_csv(OUTPUT / 'patient_metrics.csv', index=False)
evaluation['null_summary'].to_csv(OUTPUT / 'patient_label_shuffle_control.csv', index=False)
spatial_results.to_csv(OUTPUT / 'spatial_state_enrichment.csv', index=False)
patient_fractions.to_csv(OUTPUT / 'niche_patient_fractions.csv')
state_profiles.to_csv(OUTPUT / 'state_marker_profiles.csv')
niche_profiles.to_csv(OUTPUT / 'niche_marker_profiles.csv')
paired_enrichment.to_csv(OUTPUT / 'paired_niche_enrichment.csv')
niche_transfer.to_csv(OUTPUT / 'heldout_niche_transfer.csv', index=False)
qc_metrics.to_csv(OUTPUT / 'qc_sensitivity_patient_metrics.csv', index=False)
evaluation['predictions'].to_csv(OUTPUT / 'out_of_fold_predictions.csv', index=False)
discovery['coverage'].to_csv(OUTPUT / 'reference_coverage.csv', index=False)
joblib.dump(discovery['models'], OUTPUT / 'frozen_state_niche_models.joblib')
(OUTPUT / 'analysis_settings.json').write_text(json.dumps({
    'seed': SEED, 'development': DEVELOPMENT, 'holdout': HOLDOUT,
    'state_count': 8, 'niche_count': 6, 'programs': programs,
    'source': str(INPUT),
    'versions': {p: importlib.metadata.version(p) for p in
                 ['anndata', 'scanpy', 'numpy', 'pandas', 'scikit-learn']},
}, indent=2))
print(f'Results and figures saved to {OUTPUT}')
Results and figures saved to results

Takeaways

  • The Helical MCP handles registration, the Nicheformer run and the download. You choose the patient split, the analysis and the controls.
  • The same workflow works for other models on the platform: change the model name in the prompt for step 3b.
  • Score a simple baseline next to the model (here, QC covariates), so each number has a reference point.
  • Split by patient, not by spot, when you test whether results transfer to new patients.
  • This analysis is one of many computational experiments you can run in Helical, the virtual AI lab for biology.

References