Zero-shot Quickstart (Multiple Samples)#

This quickstart embeds several spatial samples together — different sections or donors — and shows that TERRA’s embeddings integrate them into a shared space, so cell types and niches line up across samples.

The only differences from the single-sample quickstart are that we concatenate multiple samples into one AnnData and pass sample_key so each section is tokenized with its own spatial graph.

Note

TERRA requires an NVIDIA GPU for the embedding step.

1. Setup#

import logging

import anndata as ad
import numpy as np
import scanpy as sc
import squidpy as sq

from terra import download_pretrained, harmonize_tokenize_embed_pipeline
from terra.datasets import read_xenium_10x

logging.basicConfig(level="INFO")
sc.settings.set_figure_params(dpi=80, frameon=False)

2. Download multiple samples#

We use the two serial-section replicates of the 10x Genomics Xenium Human Breast Cancer dataset as two samples (~10 MB each). Any two sections sharing the same gene panel work the same way (including two Xenium Prime samples).

def load_sample(base_url, out_dir, sample):
    adata = read_xenium_10x(base_url, out_dir)
    # Namespace cell ids by sample so they stay unique after concatenation.
    adata.obs["sample"] = sample
    adata.obs_names = sample + "_" + adata.obs_names.astype(str)
    adata.obs["cell_id"] = adata.obs_names.astype(str)
    # Crop a contiguous window per section, centred on the median cell
    # position (the bounding-box corner is often empty), to keep things fast.
    xy = adata.obsm["spatial"]
    center = np.median(xy, axis=0)
    keep = (np.abs(xy - center) <= 375).all(axis=1)
    return adata[keep].copy()


base = "https://cf.10xgenomics.com/samples/xenium/1.0.1"
rep1 = load_sample(f"{base}/Xenium_FFPE_Human_Breast_Cancer_Rep1/Xenium_FFPE_Human_Breast_Cancer_Rep1",
                   "data/breast_rep1", "rep1")
rep2 = load_sample(f"{base}/Xenium_FFPE_Human_Breast_Cancer_Rep2/Xenium_FFPE_Human_Breast_Cancer_Rep2",
                   "data/breast_rep2", "rep2")

adata = ad.concat([rep1, rep2])
print(adata.obs["sample"].value_counts())
print(adata)
# Total counts per cell, in space, for each section.
adata.obs["total_counts"] = np.asarray(adata.X.sum(axis=1)).ravel()
for sample in adata.obs["sample"].unique():
    sq.pl.spatial_scatter(adata[adata.obs["sample"] == sample],
                          color="total_counts", shape=None, size=4, title=sample)

3. Download a pretrained TERRA model#

# By default the model is cached in the Hugging Face cache; pass
# local_dir="terra_model" to download it into a folder of your choice.
model_dir = download_pretrained("Lotfollahi-lab/TERRA-112M")

4. Embed all samples together (zero-shot)#

Pass sample_key="sample" so each section is tokenized with its own spatial neighborhood graph (neighborhoods never cross sections); batch_key labels the batch variable for downstream comparison.

adata = harmonize_tokenize_embed_pipeline(
    adata=adata,
    sample_key="sample",      # tokenize each section separately
    batch_key="sample",
    model_folder_path=model_dir,
    cache_directory_path="./terra_cache",
    batch_size=128,           # lower this if you run out of GPU memory
)
print(list(adata.obsm))

5. Visualize — do the samples integrate?#

Color the UMAP by sample. Well-integrated embeddings mix the sections (shared biology overlaps) rather than separating by batch.

for emb_key in ["cell_emb", "neighborhood_emb"]:
    sc.pp.neighbors(adata, use_rep=emb_key, key_added=emb_key)
    sc.tl.umap(adata, neighbors_key=emb_key)
    sc.pl.umap(adata, neighbors_key=emb_key, color="sample", title=f"{emb_key} by sample")

6. Niche & cell-type identification across samples#

Cluster each embedding once across all sections: niches from neighborhood_emb and cell types from cell_emb. Because the embeddings are integrated, the same clusters span both sections.

sc.tl.leiden(adata, neighbors_key="neighborhood_emb", key_added="niche",
             resolution=0.6, flavor="igraph", n_iterations=2, directed=False)
sc.tl.leiden(adata, neighbors_key="cell_emb", key_added="cell_type",
             resolution=0.6, flavor="igraph", n_iterations=2, directed=False)

# Niches on the neighborhood-embedding UMAP, cell types on the cell-embedding
# UMAP, each with its own colour palette.
sc.tl.umap(adata, neighbors_key="neighborhood_emb")
sc.pl.umap(adata, neighbors_key="neighborhood_emb", color=["niche", "sample"],
           palette="tab20")

sc.tl.umap(adata, neighbors_key="cell_emb")
sc.pl.umap(adata, neighbors_key="cell_emb", color=["cell_type", "sample"],
           palette="tab20b")
# The shared niches and cell types, shown in space for each section, each with
# its own colour palette.
for sample in adata.obs["sample"].unique():
    sub = adata[adata.obs["sample"] == sample]
    sq.pl.spatial_scatter(sub, color="niche", shape=None, size=6,
                          palette="tab20", title=f"{sample} — niche")
    sq.pl.spatial_scatter(sub, color="cell_type", shape=None, size=6,
                          palette="tab20b", title=f"{sample} — cell type")

Next steps#

Continue with Downstream Analysis for gene-level embeddings, spatial gene-pair scoring, EMD spatial structure, and perturbation.