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 = "mouse-brain-imaging"
release = "2026-11-01"
out_dir = "bundles"
primers_ref = "4d7af340849c00077f9d21c5b54af9e689a4a070"
dev_note = ""

Normal adult mouse brain on imaging platforms¶

Cohort mouse-brain-imaging, 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: mouse-brain-imaging
query: {'organism': 'mouse', 'tissue': 'brain', 'disease': 'normal'}
include_uids: None
exclude_uids: None
families: ['imaging']
marker_set: brain
rationale: Normal mouse brain on Xenium, MERSCOPE and MERFISH (Allen ABC atlas and GEO studies); one family, three platforms with different panels.
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 K8uQ1Ge2i9y8qDPI0000 xenium-in-situ-analysis-of-alzheimers-disease-mouse-model-brain-coronal-sections-from-one-hemisphere-over-a-time-course-1-standard__ffpe-wildtype-13-4-months Xenium imaging_cell 10x Genomics demo datasets 10x_demo CC-BY-4.0 59935 kept
1 Br2jvxFOCzvAx8D10000 xenium-in-situ-analysis-of-alzheimers-disease-mouse-model-brain-coronal-sections-from-one-hemisphere-over-a-time-course-1-standard__ffpe-wildtype-2-5-months Xenium imaging_cell 10x Genomics demo datasets 10x_demo CC-BY-4.0 58231 kept
2 KLfx8NtBjLt1Haos0000 xenium-in-situ-analysis-of-alzheimers-disease-mouse-model-brain-coronal-sections-from-one-hemisphere-over-a-time-course-1-standard__ffpe-wildtype-5-7-months Xenium imaging_cell 10x Genomics demo datasets 10x_demo CC-BY-4.0 58685 kept
3 T9Q8m47Lh79ELPtl0000 GSM9489188 Xenium imaging_cell 10.1016/j.stemcr.2026.102967 doi ncbi-geo-policy 60038 kept
4 A2SdCHwafB0Dcaed0000 GSM9489192 Xenium imaging_cell 10.1016/j.stemcr.2026.102967 doi ncbi-geo-policy 83672 kept
... ... ... ... ... ... ... ... ... ...
363 9cBWCjyWHTOCiVaR0001 C57BL6J-638850.01 Merscope imaging_cell 10.1038/s41586-023-06812-z doi allen-institute-tou 23376 kept
364 hevOqJTzZM9ZpH2U0000 Zhuang-ABCA-2.070 MERFISH imaging_cell 10.1038/s41586-023-06808-9 doi allen-institute-tou 10573 kept
365 u5t35LwOP8WUv0lx0000 Zhuang-ABCA-1.014 MERFISH imaging_cell 10.1038/s41586-023-06808-9 doi allen-institute-tou 11137 kept
366 2DoDAahdNlJDaNph0000 Zhuang-ABCA-1.081 MERFISH imaging_cell 10.1038/s41586-023-06808-9 doi allen-institute-tou 30439 kept
367 ROig0nntlziTqzA60000 Zhuang-ABCA-2.003 MERFISH imaging_cell 10.1038/s41586-023-06808-9 doi allen-institute-tou 4775 kept

368 rows × 9 columns

status members
0 kept 344
1 dropped: no paper (no study_id, series or collection) 24
344 kept, 24 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 0GanEiiPHLy6LyMg0000 MERFISH 20857 20799 86.0 49.0 n/a False kept imaging_cell
1 0dTXlKZ67T6zR6GE0000 Xenium 108738 107607 342.0 89.0 n/a False kept imaging_cell
2 0gqEFXnV3Hcu9xMj0000 MERFISH 29605 29605 254.0 104.0 n/a False kept imaging_cell
3 0secLDAhnLrMdUXL0000 MERFISH 20340 20340 234.0 95.0 n/a False kept imaging_cell
4 1QDC9BC1kJJ5ysPg0000 MERFISH 25178 25178 332.0 125.0 n/a False kept imaging_cell
... ... ... ... ... ... ... ... ... ... ... ...
339 zCshS8HrTT5YzbnI0000 MERFISH 25396 25396 204.0 90.0 n/a False kept imaging_cell
340 zE0nSFflD586tDqD0000 Merscope 51972 51972 727.0 159.0 n/a False kept imaging_cell
341 zFYdf4b6xZiOQbv60000 MERFISH 81366 81366 196.0 84.0 n/a False kept imaging_cell
342 zTJPibCqm7rG0e9I0000 MERFISH 110605 110605 189.0 82.0 n/a False kept imaging_cell
343 zpFfWS0KBmtM8QqY0000 MERFISH 25528 25522 137.0 71.0 n/a False kept imaging_cell

344 rows × 11 columns

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)
imaging_cell: 344 datasets, so the 40 largest are shown.

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 'brain', cited from 10.1016/j.cell.2018.06.021.
markers
compartment
neuron SNAP25, RBFOX3, SYT1
excitatory_neuron SLC17A7, SLC17A6
inhibitory_neuron GAD1, GAD2, SLC32A1
astrocyte AQP4, ALDH1L1, GFAP
oligodendrocyte MBP, PLP1, MOG
opc PDGFRA
microglia CX3CR1, P2RY12, TMEM119
endothelial CLDN5, FLT1, PECAM1
panel compartment genes note
0 Xenium-347-cce0810f neuron Slc17a7, Slc17a6, Gad1, Gad2 no pan-neuronal gene on this panel; excitatory plus inhibitory markers cover neurons
1 Xenium-347-30279cd9 neuron Slc17a7, Slc17a6, Gad1, Gad2 no pan-neuronal gene on this panel; excitatory plus inhibitory markers cover neurons
2 Xenium-348-3974002b neuron Slc17a7, Slc17a6, Gad1, Gad2 no pan-neuronal gene on this panel; excitatory plus inhibitory markers cover neurons
3 Xenium-347-696b1080 neuron Slc17a7, Slc17a6, Gad1, Gad2 no pan-neuronal gene on this panel; excitatory plus inhibitory markers cover neurons
4 MERFISH-1122-5c75344a neuron Slc17a7, Slc17a6, Gad2, Slc32a1 no pan-neuronal gene on this panel; excitatory plus inhibitory markers cover neurons
5 MERFISH-1122-5c75344a opc Vcan, Sox10 OPC genes on this panel; SOX10 is also in oligodendrocytes
6 MERFISH-1122-5c75344a microglia Sall1 the only microglia-specific gene on this panel
7 Merscope-500-5085e6ca neuron Slc17a7, Slc17a6, Gad2, Slc32a1 no pan-neuronal gene on this panel; excitatory plus inhibitory markers cover neurons
8 Merscope-500-5085e6ca opc Vcan, Sox10 OPC genes on this panel; SOX10 is also in oligodendrocytes
9 Merscope-500-5085e6ca microglia not measured no microglia gene on this panel

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

MERFISH, section iMRftc9Kf0Qh7PTq0000 (20407 observations).

Merscope, section tbMoGtLPB5NhCJD60000 (77117 observations).

Xenium, section lkkAcorA6z0tyJ9E0000 (98395 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.

imaging_cell:Xenium: 4130139 observations, 38 sections; all 150 measured, expressed genes of the view; its 8 gene panels have 247 to 5,106 genes each and share 150. Xenium 150 ∩ Merscope 500 ∩ MERFISH 1122 = 57 shared genes (under 200), so one view per technology
imaging_cell:MERFISH: 5819575 observations, 239 sections; all 1122 measured, expressed genes of the view. Xenium 150 ∩ Merscope 500 ∩ MERFISH 1122 = 57 shared genes (under 200), so one view per technology
imaging_cell:Merscope: 3936337 observations, 59 sections; all 500 measured, expressed genes of the view. Xenium 150 ∩ Merscope 500 ∩ MERFISH 1122 = 57 shared genes (under 200), so one view per technology
member NNlamO1zDHJ0DGPD0000 dropped: panel shares 34 genes with the rest of its technology
member nj80VOKyy3fQx1XL0000 dropped: panel shares 34 genes with the rest of its technology
member G1EtQVuEIrCPHZYS0000 dropped: panel shares 34 genes with the rest of its technology
member 9SwM9JOU4EKOzQyQ0000 dropped: panel shares 32 genes with the rest of its technology
member SUB7SlVL6VaddLSn0000 dropped: panel shares 32 genes with the rest of its technology
member pvmdxXS68l8IhBCS0000 dropped: panel would reduce 500 shared genes to 61
member yFNDSwMriEEuVFdF0000 dropped: panel would reduce 500 shared genes to 61
member qJFA6z1xRwNkFBxb0000 dropped: panel would reduce 500 shared genes to 61
Merscope panel of pvmdxXS68l8IhBCS0000, yFNDSwMriEEuVFdF0000, qJFA6z1xRwNkFBxb0000: dropped: panel would reduce 500 shared genes to 61 shared genes
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 imaging_cell:Xenium, before Harmony.

No description has been provided for this image

View imaging_cell:Xenium, after Harmony on the section.

No description has been provided for this image

View imaging_cell:MERFISH, before Harmony.

No description has been provided for this image

View imaging_cell:MERFISH, after Harmony on the section.

No description has been provided for this image

View imaging_cell:Merscope, before Harmony.

No description has been provided for this image

View imaging_cell:Merscope, 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 imaging_cell:Xenium section before Harmony entropy 0.539 0.82 1.016
1 imaging_cell:Xenium section before Harmony other_share 0.73 0.924 0.956
2 imaging_cell:Xenium section after Harmony entropy 0.611 0.82 1.016
3 imaging_cell:Xenium section after Harmony other_share 0.792 0.924 0.956
4 imaging_cell:Xenium paper before Harmony entropy 0.437 0.742 0.867
5 imaging_cell:Xenium paper before Harmony other_share 0.532 0.869 0.898
6 imaging_cell:Xenium paper after Harmony entropy 0.522 0.742 0.867
7 imaging_cell:Xenium paper after Harmony other_share 0.635 0.869 0.898
8 imaging_cell:MERFISH section before Harmony entropy 0.814 0.961 1.54
9 imaging_cell:MERFISH section before Harmony other_share 0.902 0.96 0.993
10 imaging_cell:MERFISH section after Harmony entropy 0.877 0.961 1.54
11 imaging_cell:MERFISH section after Harmony other_share 0.934 0.96 0.993
12 imaging_cell:MERFISH paper before Harmony entropy n/a n/a n/a one paper, so neither statistic is defined
13 imaging_cell:MERFISH paper before Harmony other_share n/a n/a n/a one paper, so neither statistic is defined
14 imaging_cell:MERFISH paper after Harmony entropy n/a n/a n/a one paper, so neither statistic is defined
15 imaging_cell:MERFISH paper after Harmony other_share n/a n/a n/a one paper, so neither statistic is defined
16 imaging_cell:Merscope section before Harmony entropy 0.638 0.896 1.171
17 imaging_cell:Merscope section before Harmony other_share 0.812 0.947 0.98
18 imaging_cell:Merscope section after Harmony entropy 0.693 0.896 1.171
19 imaging_cell:Merscope section after Harmony other_share 0.852 0.947 0.98
20 imaging_cell:Merscope paper before Harmony entropy n/a n/a n/a one paper, so neither statistic is defined
21 imaging_cell:Merscope paper before Harmony other_share n/a n/a n/a one paper, so neither statistic is defined
22 imaging_cell:Merscope paper after Harmony entropy n/a n/a n/a one paper, so neither statistic is defined
23 imaging_cell:Merscope paper after Harmony other_share n/a n/a n/a one paper, so neither statistic is defined
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 imaging_cell:Xenium: 88 clusters on nn_harmony, 9 marker genes.

No description has been provided for this image
observations top compartment top mean runner-up runner-up mean
cluster
0 280331 inhibitory_neuron 0.614 endothelial 0.018
1 408527 excitatory_neuron 0.33 endothelial 0.013
10 141017 opc 1.57 endothelial 0.038
11 191807 excitatory_neuron 0.702 endothelial 0.014
12 139058 inhibitory_neuron 1.496 endothelial 0.018
... ... ... ... ... ...
84 266 neuron 0.0 endothelial 0.0
85 11 neuron 0.0 endothelial 0.0
86 88 neuron 0.0 endothelial 0.0
87 7 neuron 0.0 endothelial 0.0
9 279971 excitatory_neuron 1.047 endothelial 0.022

88 rows × 5 columns

View imaging_cell:MERFISH: 28 clusters on nn_harmony, 12 marker genes.

No description has been provided for this image
observations top compartment top mean runner-up runner-up mean
cluster
0 392564 astrocyte 0.971 endothelial 0.068
1 247141 microglia 1.884 endothelial 0.192
10 222763 inhibitory_neuron 1.289 endothelial 0.022
11 138517 astrocyte 0.519 endothelial 0.364
12 260100 excitatory_neuron 1.197 endothelial 0.027
13 90760 endothelial 2.046 endothelial 2.046
14 29433 excitatory_neuron 0.958 endothelial 0.053
15 29547 astrocyte 0.656 endothelial 0.02
16 474603 excitatory_neuron 0.776 endothelial 0.04
17 75048 inhibitory_neuron 1.807 endothelial 0.038
18 58978 excitatory_neuron 0.952 endothelial 0.022
19 217365 inhibitory_neuron 1.16 endothelial 0.032
2 191238 inhibitory_neuron 1.337 endothelial 0.047
20 211958 excitatory_neuron 1.25 endothelial 0.039
21 187826 excitatory_neuron 1.056 endothelial 0.042
22 143652 inhibitory_neuron 1.553 endothelial 0.053
23 144442 excitatory_neuron 1.283 endothelial 0.024
24 49305 astrocyte 0.775 endothelial 0.052
25 7113 inhibitory_neuron 0.996 endothelial 0.03
26 32103 astrocyte 0.204 endothelial 0.027
27 60193 endothelial 1.139 endothelial 1.139
3 97122 endothelial 0.695 endothelial 0.695
4 198800 opc 1.583 endothelial 0.084
5 378426 inhibitory_neuron 0.883 endothelial 0.044
6 39625 excitatory_neuron 1.097 endothelial 0.044
7 315054 astrocyte 1.181 endothelial 0.082
8 923050 oligodendrocyte 2.455 endothelial 0.056
9 602849 endothelial 2.94 endothelial 2.94

View imaging_cell:Merscope: 29 clusters on nn_harmony, 10 marker genes.

No description has been provided for this image
observations top compartment top mean runner-up runner-up mean
cluster
0 106673 opc 2.647 endothelial 0.332
1 63990 endothelial 2.393 endothelial 2.393
10 144308 inhibitory_neuron 3.23 endothelial 0.248
11 173316 astrocyte 1.76 endothelial 1.674
12 93670 inhibitory_neuron 3.001 endothelial 0.226
13 81685 oligodendrocyte 3.374 endothelial 0.301
14 164836 astrocyte 2.108 endothelial 0.336
15 137266 excitatory_neuron 2.525 endothelial 0.235
16 212979 excitatory_neuron 2.348 endothelial 0.189
17 155043 excitatory_neuron 2.342 endothelial 0.183
18 218748 inhibitory_neuron 2.465 endothelial 0.182
19 209719 excitatory_neuron 1.807 endothelial 0.271
2 267403 endothelial 4.191 endothelial 4.191
20 63494 excitatory_neuron 2.289 endothelial 0.318
21 37073 excitatory_neuron 1.964 endothelial 0.16
22 155972 excitatory_neuron 2.326 endothelial 0.229
23 22407 oligodendrocyte 3.454 endothelial 0.294
24 72975 astrocyte 1.644 endothelial 0.261
25 107971 inhibitory_neuron 2.667 endothelial 0.261
26 125233 excitatory_neuron 2.52 endothelial 0.285
27 29127 astrocyte 0.276 endothelial 0.133
28 1932 astrocyte 0.478 endothelial 0.17
3 298150 excitatory_neuron 1.425 endothelial 0.232
4 414204 oligodendrocyte 3.899 endothelial 0.387
5 283767 astrocyte 2.518 endothelial 0.359
6 81055 excitatory_neuron 1.308 endothelial 0.557
7 59373 inhibitory_neuron 2.67 endothelial 0.241
8 97340 inhibitory_neuron 2.652 endothelial 0.212
9 56628 astrocyte 2.166 endothelial 0.238

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 imaging_cell:Xenium:

  • Cluster 0 (280331 observations) has its highest mean marker expression for inhibitory_neuron (0.61), followed by endothelial (0.02).
  • Cluster 1 (408527 observations) has its highest mean marker expression for excitatory_neuron (0.33), followed by endothelial (0.01).
  • Cluster 10 (141017 observations) has its highest mean marker expression for opc (1.57), followed by endothelial (0.04).
  • Cluster 11 (191807 observations) has its highest mean marker expression for excitatory_neuron (0.70), followed by endothelial (0.01).
  • Cluster 12 (139058 observations) has its highest mean marker expression for inhibitory_neuron (1.50), followed by endothelial (0.02).
  • Cluster 13 (69904 observations) has its highest mean marker expression for inhibitory_neuron (1.05), followed by endothelial (0.02).
  • Cluster 14 (112237 observations) has its highest mean marker expression for excitatory_neuron (1.15), followed by endothelial (0.02).
  • Cluster 15 (109995 observations) has its highest mean marker expression for endothelial (0.65), followed by endothelial (0.65).
  • Cluster 16 (134328 observations) has its highest mean marker expression for astrocyte (0.50), followed by endothelial (0.20).
  • Cluster 17 (114775 observations) has its highest mean marker expression for excitatory_neuron (0.94), followed by endothelial (0.01).
  • Cluster 18 (1157 observations) has its highest mean marker expression for excitatory_neuron (0.10), followed by endothelial (0.00).
  • Cluster 19 (53195 observations) has its highest mean marker expression for inhibitory_neuron (1.64), followed by endothelial (0.02).
  • Cluster 2 (270070 observations) has its highest mean marker expression for endothelial (1.51), followed by endothelial (1.51).
  • Cluster 20 (980 observations) has its highest mean marker expression for astrocyte (1.72), followed by endothelial (0.00).
  • Cluster 21 (1833 observations) has its highest mean marker expression for excitatory_neuron (0.15), followed by endothelial (0.00).
  • Cluster 22 (31029 observations) has its highest mean marker expression for excitatory_neuron (0.77), followed by endothelial (0.02).
  • Cluster 23 (565 observations) has its highest mean marker expression for excitatory_neuron (0.17), followed by endothelial (0.00).
  • Cluster 24 (44571 observations) has its highest mean marker expression for inhibitory_neuron (1.78), followed by endothelial (0.02).
  • Cluster 25 (40959 observations) has its highest mean marker expression for excitatory_neuron (0.96), followed by endothelial (0.02).
  • Cluster 26 (353 observations) has its highest mean marker expression for inhibitory_neuron (0.02), followed by endothelial (0.00).
  • Cluster 27 (7830 observations) has its highest mean marker expression for inhibitory_neuron (0.00), followed by endothelial (0.00).
  • Cluster 28 (438 observations) has its highest mean marker expression for excitatory_neuron (0.02), followed by endothelial (0.00).
  • Cluster 29 (371 observations) has its highest mean marker expression for astrocyte (0.02), followed by endothelial (0.00).
  • Cluster 3 (319120 observations) has its highest mean marker expression for opc (0.99), followed by endothelial (0.05).
  • Cluster 30 (194 observations) has its highest mean marker expression for excitatory_neuron (2.15), followed by endothelial (0.00).
  • Cluster 31 (611 observations) has its highest mean marker expression for inhibitory_neuron (0.01), followed by endothelial (0.00).
  • Cluster 32 (476 observations) has its highest mean marker expression for excitatory_neuron (0.00), followed by endothelial (0.00).
  • Cluster 33 (78 observations) has its highest mean marker expression for astrocyte (0.12), followed by endothelial (0.03).
  • Cluster 34 (757 observations) has its highest mean marker expression for inhibitory_neuron (0.00), followed by endothelial (0.00).
  • Cluster 35 (1099 observations) has its highest mean marker expression for inhibitory_neuron (0.00), followed by endothelial (0.00).
  • Cluster 36 (259 observations) has its highest mean marker expression for opc (0.02), followed by endothelial (0.01).
  • Cluster 37 (770 observations) has its highest mean marker expression for inhibitory_neuron (0.01), followed by endothelial (0.00).
  • Cluster 38 (177 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 39 (46 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 4 (123411 observations) has its highest mean marker expression for inhibitory_neuron (0.35), followed by endothelial (0.18).
  • Cluster 40 (288 observations) has its highest mean marker expression for inhibitory_neuron (0.03), followed by endothelial (0.00).
  • Cluster 41 (105 observations) has its highest mean marker expression for excitatory_neuron (2.18), followed by endothelial (0.00).
  • Cluster 42 (72 observations) has its highest mean marker expression for astrocyte (0.02), followed by endothelial (0.00).
  • Cluster 43 (1372 observations) has its highest mean marker expression for inhibitory_neuron (0.01), followed by endothelial (0.00).
  • Cluster 44 (127 observations) has its highest mean marker expression for opc (0.01), followed by endothelial (0.00).
  • Cluster 45 (105 observations) has its highest mean marker expression for inhibitory_neuron (0.01), followed by endothelial (0.00).
  • Cluster 46 (233 observations) has its highest mean marker expression for excitatory_neuron (0.03), followed by endothelial (0.00).
  • Cluster 47 (66 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 48 (222 observations) has its highest mean marker expression for inhibitory_neuron (0.01), followed by endothelial (0.00).
  • Cluster 49 (114 observations) has its highest mean marker expression for astrocyte (0.03), followed by endothelial (0.00).
  • Cluster 5 (338394 observations) has its highest mean marker expression for astrocyte (1.18), followed by endothelial (0.05).
  • Cluster 50 (214 observations) has its highest mean marker expression for astrocyte (2.17), followed by endothelial (0.00).
  • Cluster 51 (106 observations) has its highest mean marker expression for inhibitory_neuron (0.02), followed by endothelial (0.00).
  • Cluster 52 (91 observations) has its highest mean marker expression for inhibitory_neuron (0.01), followed by endothelial (0.00).
  • Cluster 53 (482 observations) has its highest mean marker expression for astrocyte (2.18), followed by endothelial (0.00).
  • Cluster 54 (280 observations) has its highest mean marker expression for excitatory_neuron (0.00), followed by endothelial (0.00).
  • Cluster 55 (54 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 56 (40 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 57 (304 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 58 (24 observations) has its highest mean marker expression for endothelial (4.27), followed by endothelial (4.27).
  • Cluster 59 (164 observations) has its highest mean marker expression for neuron (0.01), followed by endothelial (0.00).
  • Cluster 6 (186904 observations) has its highest mean marker expression for excitatory_neuron (0.85), followed by endothelial (0.02).
  • Cluster 60 (39 observations) has its highest mean marker expression for endothelial (4.26), followed by endothelial (4.26).
  • Cluster 61 (155 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 62 (32 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 63 (104 observations) has its highest mean marker expression for astrocyte (1.89), followed by endothelial (0.00).
  • Cluster 64 (119 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 65 (99 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 66 (557 observations) has its highest mean marker expression for opc (0.00), followed by endothelial (0.00).
  • Cluster 67 (94 observations) has its highest mean marker expression for opc (2.18), followed by endothelial (0.00).
  • Cluster 68 (148 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 69 (115 observations) has its highest mean marker expression for opc (2.13), followed by endothelial (0.00).
  • Cluster 7 (502006 observations) has its highest mean marker expression for inhibitory_neuron (1.56), followed by endothelial (0.02).
  • Cluster 70 (44 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 71 (79 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 72 (43 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 73 (96 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 74 (75 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 75 (207 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 76 (32 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 77 (57 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 78 (72 observations) has its highest mean marker expression for endothelial (4.37), followed by endothelial (4.37).
  • Cluster 79 (95 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 8 (213055 observations) has its highest mean marker expression for inhibitory_neuron (1.70), followed by endothelial (0.03).
  • Cluster 80 (53 observations) has its highest mean marker expression for astrocyte (1.84), followed by endothelial (0.00).
  • Cluster 81 (56 observations) has its highest mean marker expression for opc (1.85), followed by endothelial (0.00).
  • Cluster 82 (176 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 83 (99 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 84 (266 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 85 (11 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 86 (88 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 87 (7 observations) has its highest mean marker expression for neuron (0.00), followed by endothelial (0.00).
  • Cluster 9 (279971 observations) has its highest mean marker expression for excitatory_neuron (1.05), followed by endothelial (0.02).

View imaging_cell:MERFISH:

  • Cluster 0 (392564 observations) has its highest mean marker expression for astrocyte (0.97), followed by endothelial (0.07).
  • Cluster 1 (247141 observations) has its highest mean marker expression for microglia (1.88), followed by endothelial (0.19).
  • Cluster 10 (222763 observations) has its highest mean marker expression for inhibitory_neuron (1.29), followed by endothelial (0.02).
  • Cluster 11 (138517 observations) has its highest mean marker expression for astrocyte (0.52), followed by endothelial (0.36).
  • Cluster 12 (260100 observations) has its highest mean marker expression for excitatory_neuron (1.20), followed by endothelial (0.03).
  • Cluster 13 (90760 observations) has its highest mean marker expression for endothelial (2.05), followed by endothelial (2.05).
  • Cluster 14 (29433 observations) has its highest mean marker expression for excitatory_neuron (0.96), followed by endothelial (0.05).
  • Cluster 15 (29547 observations) has its highest mean marker expression for astrocyte (0.66), followed by endothelial (0.02).
  • Cluster 16 (474603 observations) has its highest mean marker expression for excitatory_neuron (0.78), followed by endothelial (0.04).
  • Cluster 17 (75048 observations) has its highest mean marker expression for inhibitory_neuron (1.81), followed by endothelial (0.04).
  • Cluster 18 (58978 observations) has its highest mean marker expression for excitatory_neuron (0.95), followed by endothelial (0.02).
  • Cluster 19 (217365 observations) has its highest mean marker expression for inhibitory_neuron (1.16), followed by endothelial (0.03).
  • Cluster 2 (191238 observations) has its highest mean marker expression for inhibitory_neuron (1.34), followed by endothelial (0.05).
  • Cluster 20 (211958 observations) has its highest mean marker expression for excitatory_neuron (1.25), followed by endothelial (0.04).
  • Cluster 21 (187826 observations) has its highest mean marker expression for excitatory_neuron (1.06), followed by endothelial (0.04).
  • Cluster 22 (143652 observations) has its highest mean marker expression for inhibitory_neuron (1.55), followed by endothelial (0.05).
  • Cluster 23 (144442 observations) has its highest mean marker expression for excitatory_neuron (1.28), followed by endothelial (0.02).
  • Cluster 24 (49305 observations) has its highest mean marker expression for astrocyte (0.77), followed by endothelial (0.05).
  • Cluster 25 (7113 observations) has its highest mean marker expression for inhibitory_neuron (1.00), followed by endothelial (0.03).
  • Cluster 26 (32103 observations) has its highest mean marker expression for astrocyte (0.20), followed by endothelial (0.03).
  • Cluster 27 (60193 observations) has its highest mean marker expression for endothelial (1.14), followed by endothelial (1.14).
  • Cluster 3 (97122 observations) has its highest mean marker expression for endothelial (0.69), followed by endothelial (0.69).
  • Cluster 4 (198800 observations) has its highest mean marker expression for opc (1.58), followed by endothelial (0.08).
  • Cluster 5 (378426 observations) has its highest mean marker expression for inhibitory_neuron (0.88), followed by endothelial (0.04).
  • Cluster 6 (39625 observations) has its highest mean marker expression for excitatory_neuron (1.10), followed by endothelial (0.04).
  • Cluster 7 (315054 observations) has its highest mean marker expression for astrocyte (1.18), followed by endothelial (0.08).
  • Cluster 8 (923050 observations) has its highest mean marker expression for oligodendrocyte (2.46), followed by endothelial (0.06).
  • Cluster 9 (602849 observations) has its highest mean marker expression for endothelial (2.94), followed by endothelial (2.94).

View imaging_cell:Merscope:

  • Cluster 0 (106673 observations) has its highest mean marker expression for opc (2.65), followed by endothelial (0.33).
  • Cluster 1 (63990 observations) has its highest mean marker expression for endothelial (2.39), followed by endothelial (2.39).
  • Cluster 10 (144308 observations) has its highest mean marker expression for inhibitory_neuron (3.23), followed by endothelial (0.25).
  • Cluster 11 (173316 observations) has its highest mean marker expression for astrocyte (1.76), followed by endothelial (1.67).
  • Cluster 12 (93670 observations) has its highest mean marker expression for inhibitory_neuron (3.00), followed by endothelial (0.23).
  • Cluster 13 (81685 observations) has its highest mean marker expression for oligodendrocyte (3.37), followed by endothelial (0.30).
  • Cluster 14 (164836 observations) has its highest mean marker expression for astrocyte (2.11), followed by endothelial (0.34).
  • Cluster 15 (137266 observations) has its highest mean marker expression for excitatory_neuron (2.52), followed by endothelial (0.24).
  • Cluster 16 (212979 observations) has its highest mean marker expression for excitatory_neuron (2.35), followed by endothelial (0.19).
  • Cluster 17 (155043 observations) has its highest mean marker expression for excitatory_neuron (2.34), followed by endothelial (0.18).
  • Cluster 18 (218748 observations) has its highest mean marker expression for inhibitory_neuron (2.47), followed by endothelial (0.18).
  • Cluster 19 (209719 observations) has its highest mean marker expression for excitatory_neuron (1.81), followed by endothelial (0.27).
  • Cluster 2 (267403 observations) has its highest mean marker expression for endothelial (4.19), followed by endothelial (4.19).
  • Cluster 20 (63494 observations) has its highest mean marker expression for excitatory_neuron (2.29), followed by endothelial (0.32).
  • Cluster 21 (37073 observations) has its highest mean marker expression for excitatory_neuron (1.96), followed by endothelial (0.16).
  • Cluster 22 (155972 observations) has its highest mean marker expression for excitatory_neuron (2.33), followed by endothelial (0.23).
  • Cluster 23 (22407 observations) has its highest mean marker expression for oligodendrocyte (3.45), followed by endothelial (0.29).
  • Cluster 24 (72975 observations) has its highest mean marker expression for astrocyte (1.64), followed by endothelial (0.26).
  • Cluster 25 (107971 observations) has its highest mean marker expression for inhibitory_neuron (2.67), followed by endothelial (0.26).
  • Cluster 26 (125233 observations) has its highest mean marker expression for excitatory_neuron (2.52), followed by endothelial (0.29).
  • Cluster 27 (29127 observations) has its highest mean marker expression for astrocyte (0.28), followed by endothelial (0.13).
  • Cluster 28 (1932 observations) has its highest mean marker expression for astrocyte (0.48), followed by endothelial (0.17).
  • Cluster 3 (298150 observations) has its highest mean marker expression for excitatory_neuron (1.43), followed by endothelial (0.23).
  • Cluster 4 (414204 observations) has its highest mean marker expression for oligodendrocyte (3.90), followed by endothelial (0.39).
  • Cluster 5 (283767 observations) has its highest mean marker expression for astrocyte (2.52), followed by endothelial (0.36).
  • Cluster 6 (81055 observations) has its highest mean marker expression for excitatory_neuron (1.31), followed by endothelial (0.56).
  • Cluster 7 (59373 observations) has its highest mean marker expression for inhibitory_neuron (2.67), followed by endothelial (0.24).
  • Cluster 8 (97340 observations) has its highest mean marker expression for inhibitory_neuron (2.65), followed by endothelial (0.21).
  • Cluster 9 (56628 observations) has its highest mean marker expression for astrocyte (2.17), followed by endothelial (0.24).

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.
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.
mouse-brain-imaging/cohort.parquet  21339 bytes
mouse-brain-imaging/embedding-1.h5ad  576413772 bytes
mouse-brain-imaging/embedding-2.h5ad  724481714 bytes
mouse-brain-imaging/embedding-3.h5ad  886514284 bytes
mouse-brain-imaging/embedding-4.h5ad  860005362 bytes
mouse-brain-imaging/embedding-5.h5ad  775755786 bytes
mouse-brain-imaging/embedding-6.h5ad  162660962 bytes
mouse-brain-imaging/manifest.json  238503 bytes
mouse-brain-imaging/primer.ipynb  2562462 bytes
mouse-brain-imaging/qc.parquet  19884 bytes
mouse-brain-imaging/umap.parquet  4077417 bytes
slug: mouse-brain-imaging
release: 2026-11-01
primers_ref: 4d7af340849c00077f9d21c5b54af9e689a4a070
n_datasets: 336
n_papers: 24
technologies: ['MERFISH', 'Merscope', 'Xenium']
n_cells_before: 19822705
n_cells_after: 15425572
n_flagged: 6
path: gpu
hardware: NVIDIA B300 SXM6 AC
runtime_s: 3445.2
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 mouse-brain-imaging -p release 2026-11-01 -p primers_ref 4d7af340849c00077f9d21c5b54af9e689a4a070 -p use_gpu True