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.