Cohort primer¶

In [1]:
slug = "human-colon"
release = "2026-10-28"
use_gpu = True
out_dir = "bundles"
smoke = 0
primers_ref = "feat-primers"
cohorts_path = None
catalog_path = None
representatives = 1
dev_note = ""
In [2]:
# Parameters
slug = "human-colon"
release = "2026-11-01"
out_dir = "bundles"
primers_ref = "4d7af340849c00077f9d21c5b54af9e689a4a070"
dev_note = ""

Human colon and colorectal cancer¶

Cohort human-colon, catalog release 2026-11-01.

This primer describes a cohort; it is not a benchmark. It does not rank papers, technologies or methods. The batch correction (Harmony on the section) is shown for orientation only: well-mixed embeddings are not proof of a correct biology. This cohort's technologies may also differ by paper and lab. Where one paper used one technology, a technology effect can't be separated from a paper effect.

The analysis below runs the primers package at a fixed reference. To install the same code:

pip install "sddb-primers @ git+https://github.com/theislab/spatialdata-db-client@4d7af340849c00077f9d21c5b54af9e689a4a070#subdirectory=primers"

Setup¶

The imports, and a helper that links each primers function to its source at the reference that ran.

In [5]:
import inspect
import json
import platform
import time

import anndata as ad
import numpy as np
import pandas as pd
import scanpy as sc
import spatialdata_plot  # noqa: F401  (registers the .pl accessor)
import zarr
from primers.cohort import hd_table, load_cohort, smoke_spec
from primers.embed import CAVEAT, MAX_FLOOR_LOSS, MIN_SHARED_GENES, N_HVG, N_NEIGHBORS, N_PCS, SEED, embed_views
from primers.export import export_bundle, mixing_by_view, with_qc_status
from primers.markers import load_markers, panel_id
from primers.qc import MIN_GENES_FOR_QC, apply_qc, load_thresholds, qc_floors
from spatialdata.transformations import get_transformation

import sddb
from sddb.remote import storage_options

start = time.time()
out_dir = Path(out_dir)
if use_gpu:
    import cupy as cp
    import rapids_singlecell as rsc
    import rmm
    from rmm.allocators.cupy import rmm_cupy_allocator

    rmm.reinitialize(managed_memory=True)
    cp.cuda.set_allocator(rmm_cupy_allocator)


def show_call(func, what):
    """Show what a primers function does and link its source at ``primers_ref``."""
    _, line = inspect.getsourcelines(func)
    module = func.__module__.split(".")[-1]
    url = f"{REPO}/blob/{primers_ref}/primers/primers/{module}.py#L{line}"
    display(Markdown(f"`{func.__module__}.{func.__name__}`: {what} [Source at `{primers_ref}`]({url})."))


def show_table(df, digits=3):
    """Display ``df`` rounded, with missing values written as n/a, and long text columns in full."""
    shown = df.round(digits).astype(object)
    with pd.option_context("display.max_colwidth", None):
        display(shown.where(df.notna(), "n/a"))


catalog = sddb.Catalog.from_file(catalog_path) if catalog_path else sddb.Catalog(version=release)

Query and cohort¶

The cohort is a stated catalog query, plus the uids it includes and minus the uids it excludes. The spec below is the one in cohorts.yaml.

In [6]:
if smoke:
    show_call(
        smoke_spec,
        "Keeps only the smallest kept member of each of a few papers, taken in turn from each family, for a "
        "quick end-to-end check.",
    )
    n_spec_excluded = len(spec.get("exclude_uids") or [])
    spec = smoke_spec(spec, catalog, smoke)
    print(
        f"Smoke run on {smoke} papers: {len(spec['exclude_uids']) - n_spec_excluded} more uids are excluded "
        f"({len(spec['exclude_uids'])} in all)."
    )
for key in ("slug", "query", "include_uids", "exclude_uids", "families", "marker_set", "rationale"):
    value = spec.get(key)
    if smoke and key == "exclude_uids":
        value = f"{len(value)} uids"
    print(f"{key}: {value}")
slug: human-colon
query: {'organism': 'human', 'tissue': ['colon', 'colorectum'], 'n_obs__lte': 400000}
include_uids: ['byYhE703lPTspj8i0000']
exclude_uids: None
families: ['spot', 'imaging']
marker_set: colon
rationale: Normal colon and colorectal cancer on Visium, Visium HD and Xenium; Oliveira 2025 was the mixing spike, here joined by GEO and 10x sections.
In [7]:
show_call(
    load_cohort,
    "Runs the query on the catalog, adds the included uids, removes the excluded ones, and loads each kept "
    "member's table keyed by gene symbol, joined within its technology family.",
)
adatas, cohort = load_cohort(spec, catalog=catalog)

primers.cohort.load_cohort: Runs the query on the catalog, adds the included uids, removes the excluded ones, and loads each kept member's table keyed by gene symbol, joined within its technology family. Source at 4d7af340849c00077f9d21c5b54af9e689a4a070.

In [8]:
columns = ["uid", "title", "technology", "family", "paper", "paper_source", "license", "n_obs", "status"]
show_table(cohort[columns].rename(columns={"license": "licence"}))
show_table(cohort["status"].value_counts().rename_axis("status").reset_index(name="members"))
print(f"{int((cohort['status'] == 'kept').sum())} kept, {int((cohort['status'] != 'kept').sum())} dropped.")
uid title technology family paper paper_source licence n_obs status
0 r0hI7AaM1crJtqq60001 visium-cytassist-gene-expression-libraries-of-post-xenium-human-colon-cancer-ffpe-using-the-human-whole-transcriptome-probe-set-2-standard Visium spot 10x Genomics demo datasets 10x_demo CC-BY-4.0 6518 kept
1 4LGaWiMGNdmI79Si0002 V53M06-039 Visium spot visium-cytassist-gene-expression-of-post-xenium-vs-control collection CC-BY-4.0 6352 kept
2 2Z4uTqeIZtrYw0YY0000 GSM9322957 Visium spot geo-gse311294 geo_series ncbi-geo-policy 3742 kept
3 LEmjbIthI5Ge9P640002 V53M06-039 Visium spot visium-cytassist-gene-expression-of-post-xenium-vs-control collection CC-BY-4.0 6487 kept
4 4gQynuR0sN7FY8zx0002 V53M06-039 Visium spot visium-cytassist-gene-expression-of-post-xenium-vs-control collection CC-BY-4.0 6414 kept
5 1Fn1jXIrYRqetnxm0000 human-colon-preview-data-xenium-human-colon-gene-expression-panel-1-standard__hcolon-non-diseased-add-on-ffpe Xenium imaging_cell 10x Genomics demo datasets 10x_demo CC-BY-4.0 275822 kept
6 VAHI5ZX2Kd4z4xo70000 GSM9340364 Visium spot 10.1038/s41467-026-71944-5 doi CC-BY-4.0 11346 kept
7 nlnQtxRJaHzD4QGg0000 GSM9340372 Visium spot 10.1038/s41467-026-71944-5 doi CC-BY-4.0 11743 kept
8 3Kv4Urjh9wOvpWxx0000 GSM9340378 Visium spot 10.1038/s41467-026-71944-5 doi CC-BY-4.0 10493 kept
9 apO7dpGaSHFeGqV90000 GSM9340365 Visium spot 10.1038/s41467-026-71944-5 doi CC-BY-4.0 9022 kept
10 5k9x6zgPxGCqcVm90000 GSM9340371 Visium spot 10.1038/s41467-026-71944-5 doi CC-BY-4.0 8620 kept
11 bcFco58JPI1KGPxv0000 GSM9340381 Visium spot 10.1038/s41467-026-71944-5 doi CC-BY-4.0 11709 kept
12 hXUgLFFUrOsFU2cC0000 xenium-human-colon-cancer-p1-crc-addon Xenium imaging_cell 10.1038/s41588-025-02193-3 doi_inferred CC-BY-4.0 307762 kept
13 oXs8e80rkMTec58P0000 GSM9340367 Visium spot 10.1038/s41467-026-71944-5 doi CC-BY-4.0 13362 kept
14 JghmzMkVMneMc4fc0000 GSM9340379 Visium spot 10.1038/s41467-026-71944-5 doi CC-BY-4.0 13643 kept
15 vQ7Ea8u6egcE5Yl80000 GSM9340377 Visium spot 10.1038/s41467-026-71944-5 doi CC-BY-4.0 11174 kept
16 K3y4WdsggesEJx9t0000 visium-cytassist-human-colon-normal-p5 Visium spot 10.1038/s41588-025-02193-3 doi CC-BY-4.0 2638 kept
17 IuKLRdW4rbmKcGaf0000 GSM9340370 Visium spot 10.1038/s41467-026-71944-5 doi CC-BY-4.0 9421 kept
18 V61FMJpIJmFzPcRh0000 GSM9340366 Visium spot 10.1038/s41467-026-71944-5 doi CC-BY-4.0 8851 kept
19 ETm3REX6qjG180pe0000 visium-cytassist-human-colon-normal-p3 Visium spot 10.1038/s41588-025-02193-3 doi CC-BY-4.0 3887 kept
20 AbWx6fWqvaHdq2Ng0000 GSM9340380 Visium spot 10.1038/s41467-026-71944-5 doi CC-BY-4.0 13022 kept
21 Hryzjbyxm1LVVuxm0000 GSM6505135 Visium spot 10.1016/j.crmeth.2022.100340 doi ncbi-geo-policy 2261 kept
22 usUHxzCZfpKtlsGe0000 GSM9340363 Visium spot 10.1038/s41467-026-71944-5 doi CC-BY-4.0 7269 kept
23 Ewx754a3yUZfDGAe0000 GSM9340375 Visium spot 10.1038/s41467-026-71944-5 doi CC-BY-4.0 14336 kept
24 qMnnMcP2B2W5ddYN0000 visium-cytassist-human-colon-cancer-p2 Visium spot 10.1038/s41588-025-02193-3 doi CC-BY-4.0 4269 kept
25 cvzmUpPy4joWqoLn0000 GSM9340373 Visium spot 10.1038/s41467-026-71944-5 doi CC-BY-4.0 8076 kept
26 OANiWcfU8xRy95iN0000 GSM9340368 Visium spot 10.1038/s41467-026-71944-5 doi CC-BY-4.0 7431 kept
27 bZvfnosh4QOGK1WJ0000 GSM9340376 Visium spot 10.1038/s41467-026-71944-5 doi CC-BY-4.0 7139 kept
28 Q8iE0HqeTmMZDAPU0000 GSM9340369 Visium spot 10.1038/s41467-026-71944-5 doi CC-BY-4.0 11842 kept
29 p2dczaEpp7jiUMaC0000 GSM9340374 Visium spot 10.1038/s41467-026-71944-5 doi CC-BY-4.0 9890 kept
30 ODlV9hhd8502waGR0000 xenium-human-colon-cancer-p2-crc-addon Xenium imaging_cell 10.1038/s41588-025-02193-3 doi_inferred CC-BY-4.0 340837 kept
31 HOoZeyrVaBDC3qRR0000 xenium-human-colon-cancer-p5-crc-addon Xenium imaging_cell 10.1038/s41588-025-02193-3 doi_inferred CC-BY-4.0 275998 kept
32 fsVBVE8Vt4tYAbnL0000 GSM6505133 Visium spot 10.1016/j.crmeth.2022.100340 doi ncbi-geo-policy 2204 kept
33 FkH5OwJmiFgtKYsu0000 GSM6505134 Visium spot 10.1016/j.crmeth.2022.100340 doi ncbi-geo-policy 3232 kept
34 qcge4iApT3jyYAmz0000 GSM9785433 Visium spot geo-gse334323 geo_series ncbi-geo-policy 4898 kept
35 rDBWjvddZYTELxem0000 GSM9322960 Visium spot geo-gse311294 geo_series ncbi-geo-policy 4362 kept
36 xHInB8UtsIi4YsFm0000 human-colorectal-cancer-11-mm-capture-area-ffpe-2-standard Visium spot 10x Genomics demo datasets 10x_demo CC-BY-4.0 9080 kept
37 FUV4muFggCuWi4US0000 GSM9554207 Visium spot 10.1126/science.ady1678 doi ncbi-geo-policy 3579 kept
38 9Q3xbJk09P0dcb5S0000 GSM9322959 Visium spot geo-gse311294 geo_series ncbi-geo-policy 3673 kept
39 ASZTbVCdOyoFEfXH0000 GSM8703563 Visium spot 10.1038/s41467-025-66485-2 doi ncbi-geo-policy 3493 kept
40 WIs54w6nWccKpx310000 GSM8703566 Visium spot 10.1038/s41467-025-66485-2 doi ncbi-geo-policy 3415 kept
41 50NMBRnIt2dhttPu0000 GSM8703565 Visium spot 10.1038/s41467-025-66485-2 doi ncbi-geo-policy 3563 kept
42 rmy1EEWRM1RZvdyX0000 GSM9785428 Visium spot geo-gse334323 geo_series ncbi-geo-policy 4992 kept
43 ekNMyKACI3IvML1s0000 GSM9554205 Visium spot 10.1126/science.ady1678 doi ncbi-geo-policy 2946 kept
44 XomWzX9g1eBUKuFm0000 GSM9322958 Visium spot geo-gse311294 geo_series ncbi-geo-policy 4732 kept
45 ETWGEM211qDYxYxR0000 GSM7089855 Visium spot geo-gse226997 geo_series ncbi-geo-policy 4148 kept
46 GEyvRPhzNhAuMQ1m0000 GSM9785429 Visium spot geo-gse334323 geo_series ncbi-geo-policy 4682 kept
47 XObX8m20iIl0l2A30000 GSM9554206 Visium spot 10.1126/science.ady1678 doi ncbi-geo-policy 2599 kept
48 afvnxtewYmMuUsLE0000 GSM9785430 Visium spot geo-gse334323 geo_series ncbi-geo-policy 3689 kept
49 0YPANyxcDKJmaAQM0000 GSM7089857 Visium spot geo-gse226997 geo_series ncbi-geo-policy 4676 kept
50 JQBMUJ2pwE4VrkcE0000 GSM7089858 Visium spot geo-gse226997 geo_series ncbi-geo-policy 3535 kept
51 ClgKsG6HK1dBEMby0000 GSM7089856 Visium spot geo-gse226997 geo_series ncbi-geo-policy 3737 kept
52 HGIUBMBQQA0YI0ap0000 GSM8703564 Visium spot 10.1038/s41467-025-66485-2 doi ncbi-geo-policy 2473 kept
53 iidMPcbVlfusWRbP0001 ffpe-human-colorectal-cancer-data-with-human-immuno-oncology-profiling-panel-and-custom-add-on-1-standard Xenium imaging_cell n/a none CC-BY-4.0 388175 dropped: no paper (no study_id, series or collection)
54 mxS1cBFjzkguZFvq0000 GSM9785432 Visium spot geo-gse334323 geo_series ncbi-geo-policy 3565 kept
55 tBcLc5Z5NkufJCMS0000 GSM9785431 Visium spot geo-gse334323 geo_series ncbi-geo-policy 3909 kept
56 ZcUlrf9ps0SifYJc0001 human-colon-preview-data-xenium-human-colon-gene-expression-panel-1-standard__hcolon-non-diseased-base-ffpe Xenium imaging_cell 10x Genomics demo datasets 10x_demo CC-BY-4.0 270984 kept
57 l4gygKGF3CVWnkN60000 GSM9322961 Visium spot geo-gse311294 geo_series ncbi-geo-policy 4564 kept
58 PoO79PXNXVvmgaQ10000 GSM9554204 Visium spot 10.1126/science.ady1678 doi ncbi-geo-policy 2340 kept
59 byYhE703lPTspj8i0000 visium-hd-human-colon-cancer-p2 VisiumHD spot 10.1038/s41588-025-02193-3 doi_inferred CC-BY-4.0 545913 kept
status members
0 kept 59
1 dropped: no paper (no study_id, series or collection) 1
59 kept, 1 dropped.

The batch variable is the paper. A member's paper is its DOI. Without a DOI it is its GEO series, else its first collection, else its study id. A DOI that one member of a series or collection carries is given to the other members of that group. All 10x Genomics demo datasets count as one paper, because they come from one company and not from independent labs. Harmony runs on the section (dataset_uid), not on the paper. 10x often publishes one section in several Space Ranger releases, and only the newest release is kept, and the others are dropped as re-releases. Serial sections of one 10x block count as one section in the mixing statistics.

Quality control¶

Each technology group has its own filters. The table gives the values and the reason for each group.

In [9]:
show_call(load_thresholds, "Reads the QC threshold groups from thresholds.yaml.")
thresholds = load_thresholds()
show_table(
    pd.DataFrame(
        [
            {
                "group": name,
                "technologies": ", ".join(g["technologies"]),
                "min_counts": g["min_counts"],
                "min_genes": g["min_genes"],
                "max_pct_mt": g.get("max_pct_mt"),
                "flag: min median counts": g["flag"]["min_median_counts"],
                "flag: min share kept": g["flag"]["min_share_kept"],
                "reason": g["reason"],
            }
            for name, g in thresholds.items()
        ]
    ).set_index("group")
)
print(f"MIN_GENES_FOR_QC = {MIN_GENES_FOR_QC}: QC, and every view, needs at least this many genes.")

primers.qc.load_thresholds: Reads the QC threshold groups from thresholds.yaml. Source at 4d7af340849c00077f9d21c5b54af9e689a4a070.

technologies min_counts min_genes max_pct_mt flag: min median counts flag: min share kept reason
group
spot Visium, SpatialTranscriptomics 500 250 30.0 1000 0.5 A Visium or ST spot holds about 1 to 10 cells, so a spot with under 500 UMIs or 250 genes is mostly background or tissue edge; solid tissues often reach 20 to 30% mitochondrial reads, so the cap is 30%.
binned_spot VisiumHD 200 50 30.0 200 0.5 On the 5 Visium HD sections of oliveira-2024-colorectal, 16 um bins at 200 counts kept 74% of bins with the same marker signal as 8 um bins at Oliveira et al.'s 100 (Nat Genet 57:1512-1523), which kept 60%, and found T cells in all 5 sections against 3 (web tasks/2026-11-01-t094-hd-bin-size.md).
imaging_cell Xenium, Merscope, MERFISH 10 3 n/a 30 0.5 Targeted panels give tens to hundreds of decoded transcripts per segmented cell, and a cell with under 10 transcripts or 3 genes cannot be given a type; the panels have no mitochondrial genes.
MIN_GENES_FOR_QC = 50: QC, and every view, needs at least this many genes.
In [10]:
show_call(
    apply_qc,
    "Leaves out control probes, computes counts, genes and the mitochondrial share per observation, keeps "
    "the observations that pass their technology's filters, and describes each dataset.",
)
qcs = []
for fam in list(adatas):
    adatas[fam], q = apply_qc(adatas[fam], thresholds)
    qcs.append(q.assign(family=fam))
qc = pd.concat(qcs, ignore_index=True)
show_table(qc)

primers.qc.apply_qc: Leaves out control probes, computes counts, genes and the mitochondrial share per observation, keeps the observations that pass their technology's filters, and describes each dataset. Source at 4d7af340849c00077f9d21c5b54af9e689a4a070.

dataset_uid technology n_before n_after median_counts median_genes pct_mt_median flagged flag_reason status family
0 0YPANyxcDKJmaAQM0000 Visium 4676 4508 7569.0 3475.5 0.0 False kept spot
1 2Z4uTqeIZtrYw0YY0000 Visium 3742 3582 3885.5 2541.5 14.808 False kept spot
2 3Kv4Urjh9wOvpWxx0000 Visium 10493 10490 4596.0 2998.0 3.205 False kept spot
3 4LGaWiMGNdmI79Si0002 Visium 6352 5009 6810.5 3015.5 1.97 False kept spot
4 4gQynuR0sN7FY8zx0002 Visium 6414 5325 4009.5 2403.5 4.427 False kept spot
5 50NMBRnIt2dhttPu0000 Visium 3563 3555 4687.0 2505.0 0.0 False kept spot
6 5k9x6zgPxGCqcVm90000 Visium 8620 8086 4980.0 2866.0 9.59 False kept spot
7 9Q3xbJk09P0dcb5S0000 Visium 3673 3453 2020.0 1548.0 5.198 False kept spot
8 ASZTbVCdOyoFEfXH0000 Visium 3493 3491 4129.0 2376.0 0.0 False kept spot
9 AbWx6fWqvaHdq2Ng0000 Visium 13022 13018 14744.5 5654.0 2.778 False kept spot
10 ClgKsG6HK1dBEMby0000 Visium 3737 3588 4436.0 2474.0 0.0 False kept spot
11 ETWGEM211qDYxYxR0000 Visium 4148 4142 15059.0 5543.5 0.0 False kept spot
12 ETm3REX6qjG180pe0000 Visium 3887 3186 1920.0 1414.0 3.793 False kept spot
13 Ewx754a3yUZfDGAe0000 Visium 14336 14066 24268.0 7352.5 4.235 False kept spot
14 FUV4muFggCuWi4US0000 Visium 3579 3558 8213.0 3787.0 0.0 False kept spot
15 FkH5OwJmiFgtKYsu0000 Visium 3232 3010 2281.5 1254.5 0.0 False kept spot
16 GEyvRPhzNhAuMQ1m0000 Visium 4682 2131 357.5 295.0 8.174 True median counts 357.5 < 1000; 46% kept < 50% kept spot
17 HGIUBMBQQA0YI0ap0000 Visium 2473 2442 1547.0 1114.0 0.0 False kept spot
18 Hryzjbyxm1LVVuxm0000 Visium 2261 2137 2239.0 1243.0 0.0 False kept spot
19 IuKLRdW4rbmKcGaf0000 Visium 9421 9415 11141.0 4945.0 3.151 False kept spot
20 JQBMUJ2pwE4VrkcE0000 Visium 3535 3264 2466.0 1675.0 0.0 False kept spot
21 JghmzMkVMneMc4fc0000 Visium 13643 13638 20542.0 6851.0 3.136 False kept spot
22 K3y4WdsggesEJx9t0000 Visium 2638 2623 10514.5 4689.5 6.75 False kept spot
23 LEmjbIthI5Ge9P640002 Visium 6487 5926 5464.0 3018.0 4.815 False kept spot
24 OANiWcfU8xRy95iN0000 Visium 7431 7402 6662.0 3510.0 4.129 False kept spot
25 PoO79PXNXVvmgaQ10000 Visium 2340 2338 6743.5 3155.0 0.0 False kept spot
26 Q8iE0HqeTmMZDAPU0000 Visium 11842 11206 2462.5 1800.0 7.762 False kept spot
27 V61FMJpIJmFzPcRh0000 Visium 8851 8378 21274.0 6302.0 2.922 False kept spot
28 VAHI5ZX2Kd4z4xo70000 Visium 11346 11340 6960.0 3674.5 4.331 False kept spot
29 WIs54w6nWccKpx310000 Visium 3415 3405 3502.0 1953.0 0.0 False kept spot
30 XObX8m20iIl0l2A30000 Visium 2599 2563 3550.0 2214.0 0.0 False kept spot
31 XomWzX9g1eBUKuFm0000 Visium 4732 4623 4920.0 3066.5 12.603 False kept spot
32 afvnxtewYmMuUsLE0000 Visium 3689 3653 4457.0 2609.0 3.603 False kept spot
33 apO7dpGaSHFeGqV90000 Visium 9022 9012 4021.5 2540.0 6.495 False kept spot
34 bZvfnosh4QOGK1WJ0000 Visium 7139 6825 12746.0 5453.0 3.372 False kept spot
35 bcFco58JPI1KGPxv0000 Visium 11709 11707 12136.0 5739.0 4.83 False kept spot
36 byYhE703lPTspj8i0000 VisiumHD 137051 121249 1598.0 1071.0 4.049 False kept spot
37 cvzmUpPy4joWqoLn0000 Visium 8076 8075 11518.5 4829.5 3.021 False kept spot
38 ekNMyKACI3IvML1s0000 Visium 2946 2941 14731.0 5172.5 0.0 False kept spot
39 fsVBVE8Vt4tYAbnL0000 Visium 2204 2204 30740.0 7510.5 0.0 False kept spot
40 l4gygKGF3CVWnkN60000 Visium 4564 4120 1256.0 969.5 14.643 False kept spot
41 mxS1cBFjzkguZFvq0000 Visium 3565 3165 3687.0 2260.0 4.819 False kept spot
42 nlnQtxRJaHzD4QGg0000 Visium 11743 11740 26371.0 7512.0 5.782 False kept spot
43 oXs8e80rkMTec58P0000 Visium 13362 13362 30463.0 7685.5 3.254 False kept spot
44 p2dczaEpp7jiUMaC0000 Visium 9890 9878 6312.5 3586.5 3.659 False kept spot
45 qMnnMcP2B2W5ddYN0000 Visium 4269 4269 75589.0 11383.0 3.156 False kept spot
46 qcge4iApT3jyYAmz0000 Visium 4898 4578 1986.5 1140.5 3.738 False kept spot
47 r0hI7AaM1crJtqq60001 Visium 6518 4930 3374.0 1974.5 2.267 False kept spot
48 rDBWjvddZYTELxem0000 Visium 4362 4267 1541.5 1218.0 6.546 False kept spot
49 rmy1EEWRM1RZvdyX0000 Visium 4992 674 142.0 125.0 7.273 True median counts 142 < 1000; 14% kept < 50% kept spot
50 tBcLc5Z5NkufJCMS0000 Visium 3909 2776 1181.0 850.0 10.836 False kept spot
51 usUHxzCZfpKtlsGe0000 Visium 7269 7009 3837.0 2347.0 6.58 False kept spot
52 vQ7Ea8u6egcE5Yl80000 Visium 11174 11171 10762.0 5502.5 4.963 False kept spot
53 xHInB8UtsIi4YsFm0000 Visium 9080 9077 49281.5 9559.5 4.387 False kept spot
54 1Fn1jXIrYRqetnxm0000 Xenium 275822 274239 84.0 41.0 n/a False kept imaging_cell
55 HOoZeyrVaBDC3qRR0000 Xenium 275998 270955 80.0 41.0 n/a False kept imaging_cell
56 ODlV9hhd8502waGR0000 Xenium 340837 331376 94.0 50.0 n/a False kept imaging_cell
57 ZcUlrf9ps0SifYJc0001 Xenium 270984 263505 53.0 31.0 n/a False kept imaging_cell
58 hXUgLFFUrOsFU2cC0000 Xenium 307762 289352 53.0 32.0 n/a False kept imaging_cell
In [11]:
MAX_VIOLIN_DATASETS = 40
for fam, adata in adatas.items():
    obs = adata.obs[["dataset_uid", "total_counts", "n_genes"]].copy()
    sizes = obs["dataset_uid"].value_counts()
    if len(sizes) > MAX_VIOLIN_DATASETS:
        print(f"{fam}: {len(sizes)} datasets, so the {MAX_VIOLIN_DATASETS} largest are shown.")
        obs = obs[obs["dataset_uid"].isin(sizes.index[:MAX_VIOLIN_DATASETS])]
    obs["dataset_uid"] = obs["dataset_uid"].astype(str).astype("category")
    display(Markdown(f"Counts and genes per observation after QC, family `{fam}`."))
    sc.pl.violin(ad.AnnData(obs=obs), ["total_counts", "n_genes"], groupby="dataset_uid", rotation=90, stripplot=False)
spot: 54 datasets, so the 40 largest are shown.

Counts and genes per observation after QC, family spot.

No description has been provided for this image

Counts and genes per observation after QC, family imaging_cell.

No description has been provided for this image

Marker set¶

Compartments are named only from this marker set, which was fixed before any embedding was computed. A compartment that a panel misses can have a fallback chosen from that panel's genes.

In [12]:
show_call(load_markers, "Reads the marker sets from markers.yaml, each with its citation and fallbacks.")
marker_set = load_markers()[spec["marker_set"]]
print(f"Marker set {spec['marker_set']!r}, cited from {', '.join(marker_set['citation'])}.")
show_table(
    pd.DataFrame({"markers": {c: ", ".join(g) for c, g in marker_set["compartments"].items()}}).rename_axis(
        "compartment"
    )
)
kept_after_qc = set(qc.loc[qc["status"] == "kept", "dataset_uid"].astype(str))
technology_of = cohort.set_index("uid")["technology"].astype(str)
panel_of = {
    uid: panel_id(technology_of[uid], genes)
    for adata in adatas.values()
    for uid, genes in adata.uns.get("member_genes", {}).items()
    if uid in kept_after_qc
}
used = [
    {
        "panel": pid,
        "compartment": comp,
        "genes": "not measured" if fb.get("not_measured") else ", ".join(fb.get("genes") or []),
        "note": fb.get("note", ""),
    }
    for pid, entries in (marker_set.get("fallback") or {}).items()
    if pid in set(panel_of.values())
    for comp, fb in entries.items()
]
if used:
    show_table(pd.DataFrame(used))
else:
    print("No fallback of this marker set applies to the panels of this cohort.")

primers.markers.load_markers: Reads the marker sets from markers.yaml, each with its citation and fallbacks. Source at 4d7af340849c00077f9d21c5b54af9e689a4a070.

Marker set 'colon', cited from 10.1016/j.cell.2019.06.029.
markers
compartment
epithelial EPCAM, KRT20, CDH17, CEACAM5
goblet MUC2, TFF3, SPINK4
stem LGR5, OLFM4, ASCL2
fibroblast COL1A1, COL3A1, DCN, PDGFRA
smooth_muscle ACTA2, DES, MYH11
endothelial PECAM1, VWF, PLVAP
t_cell CD3E, CD3D, CD2
b_plasma MS4A1, CD79A, MZB1, JCHAIN
myeloid CD68, CD14, CSF1R, C1QA
panel compartment genes note
0 Xenium-425-4d5186a6 goblet CLCA1, SPDEF, ITLN1 goblet-cell genes on this panel
1 Xenium-422-21ae9d3d goblet CLCA1, SPDEF, ITLN1 goblet-cell genes on this panel
2 Xenium-325-2fc53225 goblet CLCA1, SPDEF, ITLN1 goblet-cell genes on this panel
3 Xenium-325-2fc53225 fibroblast not measured no fibroblast gene on this panel
4 Xenium-325-2fc53225 smooth_muscle not measured no smooth-muscle gene on this panel
5 Xenium-325-2fc53225 endothelial AQP1 the only endothelial gene on this panel; AQP1 is also in some epithelia, so weak alone

Spatial overview¶

For each technology, the kept section with the median number of observations is drawn, coloured by one marker gene of the first compartment whose markers (or its panel's fallbacks) the table measures. Only the table, the shapes it annotates and, for spot technologies, the low-resolution image are loaded.

In [13]:
show_call(with_qc_status, "Marks the members that have no cells left after QC as dropped.")
kept_members = with_qc_status(cohort, qc).query("status == 'kept'")
ENSEMBL = r"ENS[A-Z]*G\d{6}"
SYMBOL_COLUMNS = ("symbol", "gene_symbols", "gene_symbol", "gene_name", "feature_name")


def colour_gene(table, panel):
    """(var name, symbol) of the first gene of the first compartment the table measures.

    A compartment's genes are its markers, then the fallback genes for this section's own panel.
    """
    names = pd.Index(table.var_names.astype(str))
    symbols = names
    if names.str.match(ENSEMBL).mean() >= 0.5:
        column = next((c for c in SYMBOL_COLUMNS if c in table.var), None)
        if column is None:
            return None, None
        symbols = pd.Index(table.var[column].astype(str))
    by_symbol = {s.upper(): (n, s) for n, s in zip(names, symbols, strict=True)}
    fallback = (marker_set.get("fallback") or {}).get(panel) or {}
    for comp, genes in marker_set["compartments"].items():
        for gene in [*genes, *((fallback.get(comp) or {}).get("genes") or [])]:
            if gene.upper() in by_symbol:
                return by_symbol[gene.upper()]
    return None, None


def table_region(zarr_url, table_name):
    """The shapes element a table annotates, read from the table's zarr attributes without loading it."""
    group = zarr.open_group(zarr_url, mode="r", storage_options=storage_options(zarr_url) or None)
    attrs = dict(group[f"tables/{table_name}"].attrs)
    if "region" not in attrs and f"tables/{table_name}/uns/spatialdata_attrs" in group:
        attrs = ad.io.read_elem(group[f"tables/{table_name}/uns/spatialdata_attrs"])
    region = attrs.get("region")
    return region if isinstance(region, str) or region is None else (list(region) or [None])[0]


def representative_uids(members):
    """The ``representatives`` kept sections closest to the median number of observations."""
    n_obs = members.set_index("uid")["n_obs"].astype(float)
    return list((n_obs - n_obs.median()).abs().sort_values(kind="stable").index[:representatives])

primers.export.with_qc_status: Marks the members that have no cells left after QC as dropped. Source at 4d7af340849c00077f9d21c5b54af9e689a4a070.

In [14]:
for tech, members in kept_members.groupby("technology"):
    for uid in representative_uids(members):
        ds = catalog.dataset(uid)
        stored = ds.elements()
        table_name = hd_table(stored) if tech == "VisiumHD" else (getattr(ds, "default_table", None) or "table")
        region = table_region(ds.zarr_url, table_name)
        if region is None or f"shapes/{region}" not in stored:
            display(Markdown(f"{tech}, section `{uid}`: the table annotates no shapes element, so no plot."))
            continue
        # a small tissue image when there is one: lowres, else hires (never the full-resolution image)
        images = [k.split("/", 1)[1] for k in stored if k.startswith("images/")]
        lowres = next((k for k in images if "lowres" in k), None) or next((k for k in images if "hires" in k), None)
        wanted = [f"tables/{table_name}", f"shapes/{region}"] + ([f"images/{lowres}"] if lowres else [])
        sdata = ds.to_sdata(elements=wanted)
        gene, symbol = colour_gene(sdata[table_name], panel_of.get(uid))
        section = members.set_index("uid").loc[uid, "title"]
        title = f"{tech}, {section}: {symbol}" if gene else f"{tech}, {section}"
        display(Markdown(f"{tech}, section `{uid}` ({int(members.set_index('uid').loc[uid, 'n_obs'])} observations)."))
        systems = list(get_transformation(sdata.shapes[region], get_all=True))
        system = "global" if "global" in systems else systems[0]
        # spatialdata-plot 0.4.2 rasterises shapes with datashader in "global" only; elsewhere use matplotlib
        method = None if system == "global" else "matplotlib"
        # fmt: off
        if lowres:
            (
                sdata
                .pl.render_images(lowres)
                .pl.render_shapes(region, color=gene, table_name=table_name, method=method, colorbar_params={"label": symbol})
                .pl.show(
                    coordinate_systems=system,
                    title=title,
                )
            )
        else:
            (
                sdata
                .pl.render_shapes(region, color=gene, table_name=table_name, method=method, colorbar_params={"label": symbol})
                .pl.show(
                    coordinate_systems=system,
                    title=title,
                )
            )
        # fmt: on
2026-11-04 05:11:05 | [INFO] root_attr: version
2026-11-04 05:11:05 | [INFO] root_attr: multiscales
2026-11-04 05:11:05 | [INFO] root_attr: omero
2026-11-04 05:11:05 | [INFO] datasets [{'path': 's0', 'coordinateTransformations': [{'type': 'scale', 'scale': [1.0, 1.0, 1.0]}, {'type': 'translation', 'translation': [0.0, 0.0, 0.0]}]}]
2026-11-04 05:11:05 | [INFO] resolution: s0
2026-11-04 05:11:05 | [INFO]  - shape ('c', 'y', 'x') = (3, 600, 600)
2026-11-04 05:11:05 | [INFO]  - chunks =  ['3', '600', '600']
2026-11-04 05:11:05 | [INFO]  - dtype = uint8
2026-11-04 05:11:05 | [INFO] root_attr: version
2026-11-04 05:11:05 | [INFO] root_attr: multiscales
2026-11-04 05:11:05 | [INFO] root_attr: omero

Visium, section XomWzX9g1eBUKuFm0000 (4732 observations).

2026-11-04 05:11:21 | [INFO] root_attr: version
2026-11-04 05:11:21 | [INFO] root_attr: multiscales
2026-11-04 05:11:21 | [INFO] root_attr: omero
2026-11-04 05:11:21 | [INFO] datasets [{'path': 's0', 'coordinateTransformations': [{'type': 'scale', 'scale': [1.0, 1.0, 1.0]}, {'type': 'translation', 'translation': [0.0, 0.0, 0.0]}]}]
2026-11-04 05:11:21 | [INFO] resolution: s0
2026-11-04 05:11:21 | [INFO]  - shape ('c', 'y', 'x') = (3, 389, 600)
2026-11-04 05:11:21 | [INFO]  - chunks =  ['3', '389', '600']
2026-11-04 05:11:21 | [INFO]  - dtype = uint8
2026-11-04 05:11:21 | [INFO] root_attr: version
2026-11-04 05:11:21 | [INFO] root_attr: multiscales
2026-11-04 05:11:21 | [INFO] root_attr: omero

VisiumHD, section byYhE703lPTspj8i0000 (545913 observations).

Xenium, section HOoZeyrVaBDC3qRR0000 (275998 observations).

No description has been provided for this image
No description has been provided for this image
No description has been provided for this image

Embedding¶

Each technology family is embedded on the genes every member measured. A joint view across families is added only when the gates pass. The parameters of the embedding come first.

In [15]:
show_call(
    qc_floors, "Gives each technology's count floor, the min_counts of its QC group, for gate 3 and the joint view."
)
floors = qc_floors(thresholds)
show_table(
    pd.DataFrame(
        {
            "value": [str(x) for x in (MIN_SHARED_GENES, MAX_FLOOR_LOSS, N_HVG, N_PCS, N_NEIGHBORS, SEED)],
            "meaning": [
                "genes a view needs before a family is split per technology, and the joint view's gate 1",
                "largest share of a technology's observations that may fall below its floor on the shared genes (gate 3)",
                "highly variable genes kept when a view has more genes",
                "principal components",
                "neighbours per observation",
                "random seed",
            ],
        },
        index=["MIN_SHARED_GENES", "MAX_FLOOR_LOSS", "N_HVG", "N_PCS", "N_NEIGHBORS", "SEED"],
    )
)
show_table(pd.Series(floors, name="count floor").rename_axis("technology").to_frame())

primers.qc.qc_floors: Gives each technology's count floor, the min_counts of its QC group, for gate 3 and the joint view. Source at 4d7af340849c00077f9d21c5b54af9e689a4a070.

value meaning
MIN_SHARED_GENES 200 genes a view needs before a family is split per technology, and the joint view's gate 1
MAX_FLOOR_LOSS 0.5 largest share of a technology's observations that may fall below its floor on the shared genes (gate 3)
N_HVG 2000 highly variable genes kept when a view has more genes
N_PCS 30 principal components
N_NEIGHBORS 30 neighbours per observation
SEED 0 random seed
count floor
technology
Visium 500.0
SpatialTranscriptomics 500.0
VisiumHD 200.0
Xenium 10.0
Merscope 10.0
MERFISH 10.0
In [16]:
show_call(
    embed_views,
    "Builds one view per family (or per technology when a family shares too few genes), plus the joint view "
    "when the gates pass, and embeds each with PCA, UMAP and Harmony on the section.",
)
views = embed_views(adatas, marker_set, floor=floors, use_gpu=use_gpu)
for name, v in views.items():
    p = v.uns["primer"]
    print(f"{name}: {p['n_obs']} observations, {p['n_sections']} sections; {p['genes']['rule']}. {p['note']}".strip())
first = next(iter(views.values())).uns["primer"]
for uid, reason in first["members_dropped_panel"].items():
    print(f"member {uid} {reason}")
for c in {tuple(c["uids"]): c for v in views.values() for c in v.uns["primer"]["panel_costs"]}.values():
    print(f"{c['technology']} panel of {', '.join(c['uids'])}: {c['reason']} shared genes")
for name, reason in first["views_skipped"].items():
    print(f"no view {name}: {reason}")
gates = next((v.uns["primer"]["gates"] for v in views.values() if "gates" in v.uns["primer"]), None)
if gates is not None:
    print(f"Joint view: {'yes' if gates['joint'] else 'no'}. {gates['reason']}".strip())
    for drop in gates["drops"]:
        print(f"  left out {drop['technology']} ({drop['step']}): {drop['reason']}")

primers.embed.embed_views: Builds one view per family (or per technology when a family shares too few genes), plus the joint view when the gates pass, and embeds each with PCA, UMAP and Harmony on the section. Source at 4d7af340849c00077f9d21c5b54af9e689a4a070.

spot: 441580 observations, 54 sections; 2000 highly variable genes of the 17367 measured, expressed genes (seurat_v3 on raw counts, section as batch); its 6 gene panels have 17,943 to 19,144 genes each and share 17,368.
imaging_cell: 1429427 observations, 5 sections; all 322 measured, expressed genes of the view; its 3 gene panels have 325 to 425 genes each and share 322.
Joint view: no. fewer than two families keep a technology
  left out Visium (gate3): loses 71% of its observations below 500 counts on the 299 shared genes (limit 50%)
  left out VisiumHD (gate3): loses 99% of its observations below 200 counts on the 299 shared genes (limit 50%)
In [17]:
for name, v in views.items():
    colours = [c for c in ("paper", "technology", "tissue") if c in v.obs and v.obs[c].nunique() > 1]
    display(Markdown(f"View `{name}`, before Harmony."))
    sc.pl.umap(v, color=colours or None, ncols=1)
    if "X_umap_harmony" in v.obsm:
        display(Markdown(f"View `{name}`, after Harmony on the section."))
        sc.pl.embedding(v, basis="X_umap_harmony", color=colours or None, ncols=1)
    else:
        display(Markdown(f"View `{name}` has one section, so it has no Harmony embedding."))

View spot, before Harmony.

No description has been provided for this image

View spot, after Harmony on the section.

No description has been provided for this image

View imaging_cell, before Harmony.

No description has been provided for this image

View imaging_cell, after Harmony on the section.

No description has been provided for this image
In [18]:
show_call(
    mixing_by_view,
    "Measures how evenly the batches mix among each observation's nearest neighbours, per view, before and "
    "after Harmony; the bundle's manifest records the same numbers.",
)
mixing = mixing_by_view(views)
rows, definitions = [], {}
for name, by_batch in mixing.items():
    for label, by_basis in by_batch.items():
        for basis, m in by_basis.items():
            for stat in ("entropy", "other_share"):
                definitions.setdefault(stat, m[stat]["definition"].replace(m["batch_key"], "batch"))
                rows.append(
                    {
                        "view": name,
                        "batch": label,
                        "embedding": "after Harmony" if basis == "X_pca_harmony" else "before Harmony",
                        "statistic": stat,
                        "mean": m[stat]["mean"],
                        "random reference": m[stat]["random"],
                        "limit from proportions": m[stat]["random_limit"],
                        "note": m["note"],
                    }
                )
show_table(pd.DataFrame(rows))
for stat, text in definitions.items():
    print(f"{stat}: {text}")
print(CAVEAT)

primers.export.mixing_by_view: Measures how evenly the batches mix among each observation's nearest neighbours, per view, before and after Harmony; the bundle's manifest records the same numbers. Source at 4d7af340849c00077f9d21c5b54af9e689a4a070.

view batch embedding statistic mean random reference limit from proportions note
0 spot section before Harmony entropy 0.09 0.756 0.983
1 spot section before Harmony other_share 0.146 0.881 0.911
2 spot section after Harmony entropy 0.386 0.755 0.983
3 spot section after Harmony other_share 0.565 0.881 0.911
4 spot paper before Harmony entropy 0.033 0.621 0.695
5 spot paper before Harmony other_share 0.041 0.682 0.706
6 spot paper after Harmony entropy 0.188 0.621 0.695
7 spot paper after Harmony other_share 0.224 0.682 0.706
8 imaging_cell section before Harmony entropy 0.467 0.955 0.998
9 imaging_cell section before Harmony other_share 0.415 0.772 0.799
10 imaging_cell section after Harmony entropy 0.658 0.955 0.998
11 imaging_cell section after Harmony other_share 0.579 0.772 0.799
12 imaging_cell paper before Harmony entropy 0.291 0.93 0.955
13 imaging_cell paper before Harmony other_share 0.119 0.454 0.469
14 imaging_cell paper after Harmony entropy 0.467 0.93 0.955
15 imaging_cell paper after Harmony other_share 0.213 0.454 0.469
entropy: How evenly the batch labels are spread among each observation's 30 nearest neighbours, divided by log(min(30, number of labels)): 0 when all neighbours share one label, 1 when the labels are as evenly spread as the neighbours allow.
other_share: The share of each observation's 30 nearest neighbours that carry another batch.
Well mixed is not the same as biologically right: a correction can also mix cells that differ for a real reason, such as tissue or disease.

Biology¶

Each view is clustered with Leiden (resolution 0.5, 2 iterations) on its Harmony graph, or on its PCA graph when it is uncorrected. The clusters are then described by the marker genes of the marker set only.

In [19]:
fallback_genes = {}
for entries in (marker_set.get("fallback") or {}).values():
    for comp, fb in (entries or {}).items():
        fallback_genes.setdefault(comp, set()).update(str(g).upper() for g in (fb or {}).get("genes") or [])
compartment_genes = {
    comp: {str(g).upper() for g in genes} | fallback_genes.get(comp, set())
    for comp, genes in marker_set["compartments"].items()
}
readings = {}
for name, v in views.items():
    key = "nn_harmony" if "nn_harmony_distances" in v.obsp else "nn_pca"
    leiden = {"resolution": 0.5, "random_state": SEED, "neighbors_key": key, "key_added": "leiden"}
    if use_gpu:  # rapids-singlecell's n_iterations counts aggregation levels, so its default is kept
        rsc.tl.leiden(v, **leiden)
    else:
        sc.tl.leiden(v, flavor="igraph", n_iterations=2, **leiden)
    expr = v.obsm["marker_expr"]
    m = ad.AnnData(
        X=expr.to_numpy(dtype=np.float32),
        obs=v.obs[["leiden"]].copy(),
        var=pd.DataFrame(index=expr.columns.astype(str)),
    )
    display(Markdown(f"View `{name}`: {m.obs['leiden'].nunique()} clusters on `{key}`, {m.n_vars} marker genes."))
    if m.n_vars == 0 or m.obs["leiden"].nunique() < 2:
        display(Markdown("This view has fewer than two clusters or no marker gene, so there is no dotplot."))
        continue
    by_compartment = {
        comp: [g for g in expr.columns if g.upper() in genes] for comp, genes in compartment_genes.items()
    }
    sc.pl.dotplot(m, {k: g for k, g in by_compartment.items() if g}, groupby="leiden", standard_scale="var")
    scores = (
        pd.DataFrame(
            {
                comp: expr[[g for g in expr.columns if g.upper() in genes]].mean(axis=1)
                for comp, genes in compartment_genes.items()
                if any(g.upper() in genes for g in expr.columns)
            }
        )
        .groupby(m.obs["leiden"].to_numpy())
        .mean()
    )
    ranked = scores.apply(lambda r: r.sort_values(ascending=False), axis=1)
    table = pd.DataFrame(
        {
            "observations": m.obs["leiden"].value_counts().reindex(scores.index),
            "top compartment": scores.idxmax(axis=1),
            "top mean": scores.max(axis=1),
            "runner-up": [r.dropna().index[1] if r.notna().sum() > 1 else "" for _, r in ranked.iterrows()],
            "runner-up mean": [r.dropna().iloc[1] if r.notna().sum() > 1 else np.nan for _, r in ranked.iterrows()],
        }
    ).rename_axis("cluster")
    readings[name] = table
    show_table(table)

View spot: 13 clusters on nn_harmony, 34 marker genes.

No description has been provided for this image
observations top compartment top mean runner-up runner-up mean
cluster
0 22820 fibroblast 1.654 endothelial 0.323
1 12974 epithelial 1.601 endothelial 0.095
10 71447 epithelial 1.248 endothelial 0.122
11 924 fibroblast 1.946 endothelial 0.558
12 45416 goblet 0.938 endothelial 0.225
2 8631 fibroblast 0.938 endothelial 0.155
3 79383 epithelial 1.353 endothelial 0.07
4 46230 epithelial 2.167 endothelial 0.095
5 27127 fibroblast 1.638 endothelial 0.437
6 62358 fibroblast 1.935 endothelial 0.24
7 35779 smooth_muscle 2.916 endothelial 0.286
8 25250 fibroblast 1.944 endothelial 0.72
9 3241 fibroblast 0.679 endothelial 0.098

View imaging_cell: 16 clusters on nn_harmony, 15 marker genes.

No description has been provided for this image
observations top compartment top mean runner-up runner-up mean
cluster
0 114318 myeloid 0.457 endothelial 0.056
1 33954 epithelial 0.139 endothelial 0.073
10 39317 b_plasma 1.218 endothelial 0.029
11 116323 epithelial 0.955 endothelial 0.033
12 79188 epithelial 1.359 endothelial 0.002
13 1897 endothelial 0.192 endothelial 0.192
14 61 goblet 0.029 endothelial 0.0
15 43 epithelial 0.167 endothelial 0.0
2 77661 b_plasma 0.478 endothelial 0.043
3 248111 endothelial 0.15 endothelial 0.15
4 160326 t_cell 0.458 endothelial 0.043
5 22846 epithelial 0.381 endothelial 0.036
6 248936 epithelial 1.098 endothelial 0.043
7 118499 epithelial 0.62 endothelial 0.045
8 112140 epithelial 0.575 endothelial 0.133
9 55807 endothelial 1.117 endothelial 1.117

Draft reading, to be reviewed by the Lead¶

Each statement below is generated from the reading table of its view and cites the numbers in it. The score of a compartment is the mean, over a cluster's observations, of the mean log-normalised expression of the compartment's marker and fallback genes. Compartments can sit inside others (goblet cells are epithelial), so the top compartment of a cluster does not exclude the runner-up.

In [20]:
for name, table in readings.items():
    lines = [f"View `{name}`:"]
    for cluster, r in table.iterrows():
        text = (
            f"- Cluster {cluster} ({int(r['observations'])} observations) has its highest mean marker expression "
            f"for {r['top compartment']} ({r['top mean']:.2f})"
        )
        if r["runner-up"]:
            text += f", followed by {r['runner-up']} ({r['runner-up mean']:.2f})"
        lines.append(text + ".")
    display(Markdown("\n".join(lines)))

View spot:

  • Cluster 0 (22820 observations) has its highest mean marker expression for fibroblast (1.65), followed by endothelial (0.32).
  • Cluster 1 (12974 observations) has its highest mean marker expression for epithelial (1.60), followed by endothelial (0.10).
  • Cluster 10 (71447 observations) has its highest mean marker expression for epithelial (1.25), followed by endothelial (0.12).
  • Cluster 11 (924 observations) has its highest mean marker expression for fibroblast (1.95), followed by endothelial (0.56).
  • Cluster 12 (45416 observations) has its highest mean marker expression for goblet (0.94), followed by endothelial (0.22).
  • Cluster 2 (8631 observations) has its highest mean marker expression for fibroblast (0.94), followed by endothelial (0.16).
  • Cluster 3 (79383 observations) has its highest mean marker expression for epithelial (1.35), followed by endothelial (0.07).
  • Cluster 4 (46230 observations) has its highest mean marker expression for epithelial (2.17), followed by endothelial (0.09).
  • Cluster 5 (27127 observations) has its highest mean marker expression for fibroblast (1.64), followed by endothelial (0.44).
  • Cluster 6 (62358 observations) has its highest mean marker expression for fibroblast (1.93), followed by endothelial (0.24).
  • Cluster 7 (35779 observations) has its highest mean marker expression for smooth_muscle (2.92), followed by endothelial (0.29).
  • Cluster 8 (25250 observations) has its highest mean marker expression for fibroblast (1.94), followed by endothelial (0.72).
  • Cluster 9 (3241 observations) has its highest mean marker expression for fibroblast (0.68), followed by endothelial (0.10).

View imaging_cell:

  • Cluster 0 (114318 observations) has its highest mean marker expression for myeloid (0.46), followed by endothelial (0.06).
  • Cluster 1 (33954 observations) has its highest mean marker expression for epithelial (0.14), followed by endothelial (0.07).
  • Cluster 10 (39317 observations) has its highest mean marker expression for b_plasma (1.22), followed by endothelial (0.03).
  • Cluster 11 (116323 observations) has its highest mean marker expression for epithelial (0.95), followed by endothelial (0.03).
  • Cluster 12 (79188 observations) has its highest mean marker expression for epithelial (1.36), followed by endothelial (0.00).
  • Cluster 13 (1897 observations) has its highest mean marker expression for endothelial (0.19), followed by endothelial (0.19).
  • Cluster 14 (61 observations) has its highest mean marker expression for goblet (0.03), followed by endothelial (0.00).
  • Cluster 15 (43 observations) has its highest mean marker expression for epithelial (0.17), followed by endothelial (0.00).
  • Cluster 2 (77661 observations) has its highest mean marker expression for b_plasma (0.48), followed by endothelial (0.04).
  • Cluster 3 (248111 observations) has its highest mean marker expression for endothelial (0.15), followed by endothelial (0.15).
  • Cluster 4 (160326 observations) has its highest mean marker expression for t_cell (0.46), followed by endothelial (0.04).
  • Cluster 5 (22846 observations) has its highest mean marker expression for epithelial (0.38), followed by endothelial (0.04).
  • Cluster 6 (248936 observations) has its highest mean marker expression for epithelial (1.10), followed by endothelial (0.04).
  • Cluster 7 (118499 observations) has its highest mean marker expression for epithelial (0.62), followed by endothelial (0.05).
  • Cluster 8 (112140 observations) has its highest mean marker expression for epithelial (0.57), followed by endothelial (0.13).
  • Cluster 9 (55807 observations) has its highest mean marker expression for endothelial (1.12), followed by endothelial (1.12).

Export and reproduce¶

The bundle holds the manifest, the web subsample of each view, the QC and cohort tables and the embeddings.

In [21]:
show_call(
    export_bundle,
    "Writes the manifest, umap.parquet, qc.parquet, cohort.parquet and embedding.h5ad for this cohort. A cohort whose embedding is over 1 GiB gets parts of whole datasets instead (embedding-1.h5ad, embedding-2.h5ad, ...), and anndata.concat over the parts gives the whole cohort.",
)
bundle = export_bundle(
    views,
    cohort,
    qc,
    spec,
    out_dir,
    release,
    runtime_s=time.time() - start,
    mixing=mixing,
    primers_ref=primers_ref,
)
for f in sorted(bundle.iterdir()):
    print(f"{f.relative_to(out_dir)}  {f.stat().st_size} bytes")
manifest = json.loads((bundle / "manifest.json").read_text())
for key in (
    "slug",
    "release",
    "primers_ref",
    "n_datasets",
    "n_papers",
    "technologies",
    "n_cells_before",
    "n_cells_after",
    "n_flagged",
    "path",
    "hardware",
    "runtime_s",
):
    print(f"{key}: {manifest[key]}")

primers.export.export_bundle: Writes the manifest, umap.parquet, qc.parquet, cohort.parquet and embedding.h5ad for this cohort. A cohort whose embedding is over 1 GiB gets parts of whole datasets instead (embedding-1.h5ad, embedding-2.h5ad, ...), and anndata.concat over the parts gives the whole cohort. Source at 4d7af340849c00077f9d21c5b54af9e689a4a070.

ImplicitModificationWarning: Trying to modify attribute `.obs` of view, initializing view as actual.
ImplicitModificationWarning: Trying to modify attribute `.obs` of view, initializing view as actual.
ImplicitModificationWarning: Trying to modify attribute `.obs` of view, initializing view as actual.
ImplicitModificationWarning: Trying to modify attribute `.obs` of view, initializing view as actual.
ImplicitModificationWarning: Trying to modify attribute `.obs` of view, initializing view as actual.
ImplicitModificationWarning: Trying to modify attribute `.obs` of view, initializing view as actual.
ImplicitModificationWarning: Trying to modify attribute `.obs` of view, initializing view as actual.
ImplicitModificationWarning: Trying to modify attribute `.obs` of view, initializing view as actual.
ImplicitModificationWarning: Trying to modify attribute `.obs` of view, initializing view as actual.
ImplicitModificationWarning: Trying to modify attribute `.obs` of view, initializing view as actual.
ImplicitModificationWarning: Trying to modify attribute `.obs` of view, initializing view as actual.
ImplicitModificationWarning: Trying to modify attribute `.obs` of view, initializing view as actual.
ImplicitModificationWarning: Trying to modify attribute `.obs` of view, initializing view as actual.
human-colon/cohort.parquet  10888 bytes
human-colon/embedding.h5ad  400920867 bytes
human-colon/manifest.json  59751 bytes
human-colon/primer.ipynb  4142986 bytes
human-colon/qc.parquet  9659 bytes
human-colon/umap.parquet  2916160 bytes
slug: human-colon
release: 2026-11-01
primers_ref: 4d7af340849c00077f9d21c5b54af9e689a4a070
n_datasets: 59
n_papers: 10
technologies: ['Visium', 'VisiumHD', 'Xenium']
n_cells_before: 1947497
n_cells_after: 1871007
n_flagged: 2
path: gpu
hardware: NVIDIA B300 SXM6 AC
runtime_s: 770.1
In [22]:
command = (
    f"papermill primers/notebooks/primer.ipynb primer.ipynb -p slug {slug} -p release {release} "
    f"-p primers_ref {primers_ref} -p use_gpu {use_gpu}" + (f" -p smoke {smoke}" if smoke else "")
)
versions = "\n".join(f"  - {k} {v}" for k, v in manifest["versions"].items())
display(
    Markdown(
        f"- Catalog release: `{release}`\n- primers reference: `{primers_ref}`\n- Versions:\n{versions}\n"
        f"- Hardware: {manifest['hardware']} ({platform.system()})\n- Path: {manifest['path']}\n\n"
        "To run this primer again:"
    )
)
show_command(command)
  • Catalog release: 2026-11-01
  • primers reference: 4d7af340849c00077f9d21c5b54af9e689a4a070
  • Versions:
    • spatialdata-db 0.1.3
    • scanpy 1.12.5
    • anndata 0.13.4
    • spatialdata 0.8.0
    • spatialdata-plot 0.4.2
    • rapids-singlecell-cu13 0.18.1
  • Hardware: NVIDIA B300 SXM6 AC (Linux)
  • Path: gpu

To run this primer again:

papermill primers/notebooks/primer.ipynb primer.ipynb -p slug human-colon -p release 2026-11-01 -p primers_ref 4d7af340849c00077f9d21c5b54af9e689a4a070 -p use_gpu True