Multimodal Omics Integration
Overview
Combine modalities measured on the same spots or cells, or several sections of
the same tissue, into one representation. Output: joint latent spaces and
clusters, denoised protein maps, integrated sections, transferred labels and
signature scores on the tissue.
| Question | Method | Module |
|---|
| RNA + protein on the same spots / cells | totalVI | 1-2 |
| RNA + chromatin accessibility on the same cells | MultiVI | 3 |
| Several sections or samples, one modality | BBKNN | 4 |
| Labels from an annotated section onto a new one | scanpy ingest | 5 |
| Pathway or cell state activity on tissue | sc.tl.score_genes | 6 |
| Unsupervised factors across 2+ omics | MOFA+ | mofaplus-multi-omics |
When to Use
- Analysing Visium CytAssist Gene and Protein (or CITE-seq) data with RNA and protein in one model
- Integrating RNA and ATAC measured on the same cells (10x Multiome, spatial ATAC-RNA)
- Clustering several tissue sections together without merging their coordinates
- Copying labels from an annotated section to a new section of the same platform
- Mapping pathway or cell state signatures onto the tissue
- Use
scvi-tools-single-cell instead for dissociated scVI/scANVI workflows without a spatial angle
- For factor analysis across omics layers, use
mofaplus-multi-omics or muon-multiomics-singlecell instead
Prerequisites
- Input: raw counts for every modality (totalVI and MultiVI model counts)
- Compute: scvi-tools pulls torch; GPU recommended for MultiVI, totalVI runs on CPU
for a few thousand spots
- Python packages:
scvi-tools, mudata (Module 3), bbknn (Module 4), scanpy
# Install only what `import` reports missing. `--pypi` keeps the env's scanpy/numba
# stack; a conda add can swap it and bare pip can shadow it.
pixi add --pypi scvi-tools mudata bbknn scanpy
Quick Start
Integrate two sections and cluster them jointly:
import bbknn
import scanpy as sc
adata = sc.concat({"s1": sc.read_h5ad("section1.h5ad"), "s2": sc.read_h5ad("section2.h5ad")},
label="section", index_unique="-")
sc.pp.normalize_total(adata); sc.pp.log1p(adata); sc.pp.pca(adata, n_comps=30)
bbknn.bbknn(adata, batch_key="section", n_pcs=30)
sc.tl.leiden(adata, key_added="joint_cluster", flavor="igraph", n_iterations=2)
Core API
Module 1: Load Spatial RNA + Protein (Visium CytAssist)
Demo: 10x human tonsil, FFPE, CytAssist Gene and Protein (~35 antibodies).
For your own data set OUTS to the Space Ranger outs/ folder.
import tarfile
import urllib.request
from pathlib import Path
import numpy as np
import pandas as pd
import scanpy as sc
CACHE = Path("data_cache/CytAssist_FFPE_Protein_Expression_Human_Tonsil")
BASE = ("https://cf.10xgenomics.com/samples/spatial-exp/2.1.0/CytAssist_FFPE_Protein_Expression_Human_Tonsil/"
"CytAssist_FFPE_Protein_Expression_Human_Tonsil_")
CACHE.mkdir(parents=True, exist_ok=True)
for name in ["filtered_feature_bc_matrix.h5", "spatial.tar.gz"]:
if not (CACHE / name).exists():
urllib.request.urlretrieve(BASE + name, CACHE / name) # ~37 MB + ~34 MB
if not (CACHE / "spatial").exists():
with tarfile.open(CACHE / "spatial.tar.gz") as tar:
tar.extractall(CACHE, filter="data")
OUTS = CACHE
raw = sc.read_10x_h5(OUTS / "filtered_feature_bc_matrix.h5", gex_only=False)
raw.var_names_make_unique()
print(raw.var["feature_types"].value_counts()) # Gene Expression + Antibody Capture
pos = pd.read_csv(OUTS / "spatial/tissue_positions.csv", index_col=0)
raw.obsm["spatial"] = pos.loc[raw.obs_names, ["pxl_col_in_fullres", "pxl_row_in_fullres"]].to_numpy(float)
rna = raw[:, raw.var["feature_types"] == "Gene Expression"].copy()
prot = raw[:, raw.var["feature_types"] == "Antibody Capture"]
rna.obsm["protein_expression"] = pd.DataFrame(prot.X.toarray(), index=prot.obs_names,
columns=prot.var_names)
sc.pp.filter_cells(rna, min_counts=200)
print(rna, f"\nproteins: {rna.obsm['protein_expression'].shape[1]}")
Module 2: Joint RNA + Protein with totalVI
totalVI separates protein foreground from background binding and learns one
latent space from both modalities.
import scvi
rna.layers["counts"] = rna.X.copy()
tmp = rna.copy() # HVGs on log data; model on counts
sc.pp.normalize_total(tmp)
sc.pp.log1p(tmp)
sc.pp.highly_variable_genes(tmp, n_top_genes=4000)
rna = rna[:, tmp.var["highly_variable"]].copy()
scvi.model.TOTALVI.setup_anndata(rna, layer="counts", protein_expression_obsm_key="protein_expression",
batch_key=None) # set to the section/sample column for several slides
model = scvi.model.TOTALVI(rna, n_latent=20)
model.train(max_epochs=400, early_stopping=True)
rna.obsm["X_totalVI"] = model.get_latent_representation()
rna_norm, prot_norm = model.get_normalized_expression(n_samples=25, return_mean=True)
rna.obsm["protein_denoised"] = prot_norm # DataFrame, spots x proteins
sc.pp.neighbors(rna, use_rep="X_totalVI")
sc.tl.umap(rna)
sc.tl.leiden(rna, resolution=0.6, key_added="totalvi_leiden", flavor="igraph", n_iterations=2)
show = list(prot_norm.columns[:4])
for p in show:
rna.obs[f"prot_{p}"] = np.log1p(prot_norm[p].to_numpy())
sc.pl.embedding(rna, basis="spatial", color=["totalvi_leiden"] + [f"prot_{p}" for p in show],
size=1.5, show=False, save="_totalvi.png")
model.save("totalvi_model", overwrite=True)
rna.write_h5ad("totalvi_result.h5ad", compression="gzip")
Compare raw and denoised protein maps for known markers (CD3, CD20 / MS4A1,
CD21 in tonsil follicles) before trusting clusters. Isotype-control antibodies
should come out near zero after denoising.
Module 3: Joint RNA + ATAC with MultiVI
Input is a MuData with an RNA and an ATAC modality of the same cells (10x
Multiome; spatial ATAC-RNA-seq has the same layout plus coordinates). Demo: 10x
PBMC 10k Multiome subset from the scvi-tools tutorial.
import mudata as md
import scipy.sparse as sp
path = Path("data_cache/pbmc_10k_preprocessed.h5mu")
if not path.exists():
path.parent.mkdir(parents=True, exist_ok=True)
urllib.request.urlretrieve("https://exampledata.scverse.org/scvi-tools/pbmc_10k_preprocessed.h5mu", path)
mdata = md.read_h5mu(path)
RNA, ATAC = "rna_subset", "atac_subset" # modality names in your MuData
for m in (RNA, ATAC):
mdata.mod[m].X = sp.csr_matrix(mdata.mod[m].X)
mdata.update()
scvi.model.MULTIVI.setup_mudata(mdata, modalities={"rna_layer": RNA, "atac_layer": ATAC})
mvi = scvi.model.MULTIVI(mdata, n_genes=mdata.mod[RNA].n_vars, n_regions=mdata.mod[ATAC].n_vars)
mvi.train() # GPU strongly recommended (~100k peaks)
joint = mdata.mod[RNA].copy()
joint.obsm["X_multivi"] = mvi.get_latent_representation()
sc.pp.neighbors(joint, use_rep="X_multivi")
sc.tl.umap(joint)
sc.tl.leiden(joint, key_added="multivi_leiden", flavor="igraph", n_iterations=2)
access = mvi.get_normalized_accessibility() # imputed accessibility, cells x peaks
joint.write_h5ad("multivi_result.h5ad", compression="gzip")
print(joint.obs["multivi_leiden"].value_counts())
For a batch, add batch_key="<obs column>" to setup_mudata and
"batch_key": RNA to modalities. On spatial ATAC-RNA data copy the
coordinates into joint.obsm["spatial"] and plot multivi_leiden on them.
Module 4: Integrate Several Sections (BBKNN)
BBKNN builds the neighbour graph from k neighbours per section, so sections
mix in the graph without changing expression values. Spatial coordinates stay
per section.
import bbknn
PATHS = {"s1": "section1.h5ad", "s2": "section2.h5ad"} # raw counts + obsm["spatial"] each
combined = sc.concat({k: sc.read_h5ad(v) for k, v in PATHS.items()}, label="section", index_unique="-")
combined.layers["counts"] = combined.X.copy()
sc.pp.normalize_total(combined)
sc.pp.log1p(combined)
sc.pp.highly_variable_genes(combined, n_top_genes=2000, batch_key="section") # panels: skip
sc.pp.pca(combined, n_comps=30)
bbknn.bbknn(combined, batch_key="section", n_pcs=30, neighbors_within_batch=3)
sc.tl.umap(combined)
sc.tl.leiden(combined, resolution=0.5, key_added="bbknn_leiden", flavor="igraph", n_iterations=2)
mix = pd.crosstab(combined.obs["bbknn_leiden"], combined.obs["section"], normalize="index").round(2)
print(mix) # a cluster from one section only: biology or residual batch
for s in PATHS:
part = combined[combined.obs["section"] == s]
sc.pl.embedding(part, basis="spatial", color="bbknn_leiden", size=1.5, show=False, save=f"_bbknn_{s}.png")
combined.write_h5ad("bbknn_result.h5ad", compression="gzip")
Module 5: Transfer Labels Between Sections (ingest)
sc.tl.ingest projects a query onto the reference PCA/UMAP and copies labels
from nearest neighbours. It does no batch correction, so use it between
sections of the same platform and panel. For an scRNA-seq reference onto
imaging data use the Harmony route in
spatial-transcriptomics-cell-type-annotation; for Visium spots use
deconvolution.
ref = sc.read_h5ad("celltype_annotated.h5ad") # annotated section, log-normalised X
query = sc.read_h5ad("new_section.h5ad") # log-normalised X
LABELS = ["cell_type"]
common = ref.var_names.intersection(query.var_names)
print(f"shared genes: {len(common)}")
ref, query = ref[:, common].copy(), query[:, common].copy()
sc.pp.pca(ref, n_comps=30) # PCA on the shared genes only
sc.pp.neighbors(ref, n_neighbors=15)
sc.tl.umap(ref)
sc.tl.ingest(query, ref, obs=LABELS, embedding_method="umap")
query.obs[LABELS].to_csv("ingest_annotations.csv")
print(query.obs["cell_type"].value_counts())
query.write_h5ad("ingest_result.h5ad", compression="gzip")
Module 6: Score Gene Signatures on the Tissue
adata = sc.read_h5ad("path/to/spatial.h5ad") # all genes, obsm["spatial"]
X_IS_COUNTS = True # False if X is already log-normalised
if X_IS_COUNTS:
sc.pp.normalize_total(adata)
sc.pp.log1p(adata)
SIGNATURES = {"T_cell": ["CD3D", "CD3E", "CD2", "CD8A"], "B_cell": ["MS4A1", "CD79A", "CD19"]}
lookup = {g.upper(): g for g in adata.var_names} # case-insensitive match, names unchanged
for name, genes in SIGNATURES.items():
found = [lookup[g.upper()] for g in genes if g.upper() in lookup]
missing = [g for g in genes if g.upper() not in lookup]
print(f"{name}: {len(found)}/{len(genes)} genes found; missing {missing}")
if found:
sc.tl.score_genes(adata, found, score_name=f"{name}_score", ctrl_size=50, n_bins=25)
cols = [c for c in adata.obs if c.endswith("_score")]
adata.obs[cols].to_csv("signature_scores.csv")
sc.pl.embedding(adata, basis="spatial", color=cols, cmap="viridis", size=1.5, show=False, save="_scores.png")
Scores are relative to random control genes of similar expression; on a
targeted panel of a few hundred genes lower ctrl_size and read scores as
rankings, not absolute activity.
Common Workflows
Workflow: Name totalVI Clusters from Denoised Proteins
Modules 1-2, then a protein evidence table per cluster - antibodies are often
the clearest markers on CytAssist data:
prot = pd.DataFrame(np.log1p(prot_norm.to_numpy()), index=prot_norm.index, columns=prot_norm.columns)
by_cluster = prot.groupby(rna.obs["totalvi_leiden"].to_numpy()).mean()
z = (by_cluster - by_cluster.mean()) / by_cluster.std(ddof=0)
top = z.apply(lambda r: ", ".join(r.nlargest(4).index), axis=1)
rna_log = rna.copy(); sc.pp.normalize_total(rna_log); sc.pp.log1p(rna_log) # rna.X holds counts
sc.tl.rank_genes_groups(rna_log, "totalvi_leiden", method="wilcoxon")
genes = (sc.get.rank_genes_groups_df(rna_log, group=None).groupby("group").head(5)
.groupby("group")["names"].apply(", ".join))
evidence = pd.DataFrame({"n_spots": rna.obs["totalvi_leiden"].value_counts(), "top_proteins": top,
"top_genes": genes})
evidence.to_csv("totalvi_cluster_evidence.csv")
print(evidence)
Name each cluster from both columns; a cluster supported by RNA but not by
protein (or the reverse) deserves a note in the report.
Workflow: Compare Signatures Across Integrated Sections
Modules 4 and 6: integrate sections, score signatures, then compare each
joint cluster's score between sections:
combined = sc.read_h5ad("bbknn_result.h5ad") # Module 4 output, log-normalised X
lookup = {g.upper(): g for g in combined.var_names}
for name, genes in SIGNATURES.items():
found = [lookup[g.upper()] for g in genes if g.upper() in lookup]
if len(found) >= 3:
sc.tl.score_genes(combined, found, score_name=f"{name}_score")
cols = [c for c in combined.obs if c.endswith("_score")]
table = combined.obs.groupby(["bbknn_leiden", "section"], observed=True)[cols].mean().unstack("section")
table.round(3).to_csv("signature_by_cluster_section.csv")
print(table.round(2))
Key Parameters
| Parameter | Default | Effect |
|---|
n_latent (totalVI) | 20 | Joint latent dimensions |
n_top_genes (totalVI) | 4000 | RNA genes modelled with the proteins |
max_epochs | 400 (totalVI); scvi default (MultiVI) | Training length; early stopping on validation loss |
batch_key | None | Section/sample column corrected inside the scvi model |
neighbors_within_batch | 3 | BBKNN neighbours taken from each section |
ctrl_size (score_genes) | 50 | Control genes per expression bin |
Common Recipes
Recipe: Does Protein Agree with RNA?
For markers measured both ways, low correlation flags a weak antibody or
post-transcriptional regulation:
PAIRS = {"CD3": "CD3E", "CD20": "MS4A1", "CD4": "CD4", "CD8": "CD8A"} # protein name -> gene
norm = rna.copy(); sc.pp.normalize_total(norm); sc.pp.log1p(norm)
rows = []
for protein, gene in PAIRS.items():
pcol = [c for c in prot_norm.columns if c.upper().startswith(protein.upper())]
if pcol and gene in norm.var_names:
g = np.asarray(norm[:, gene].X.todense()).ravel() if hasattr(norm.X, "todense") else norm[:, gene].X.ravel()
rows.append((protein, gene, np.corrcoef(np.log1p(prot_norm[pcol[0]]), g)[0, 1]))
print(pd.DataFrame(rows, columns=["protein", "gene", "r"]).round(2))
Recipe: totalVI Across Several Sections
Set batch_key so the model removes section effects inside the latent space:
sections = sc.concat({"s1": rna_s1, "s2": rna_s2}, label="section", index_unique="-") # each prepared as in Module 1
sections.obsm["protein_expression"] = pd.concat([rna_s1.obsm["protein_expression"],
rna_s2.obsm["protein_expression"]]).set_axis(sections.obs_names)
sections.layers["counts"] = sections.X.copy()
scvi.model.TOTALVI.setup_anndata(sections, layer="counts", protein_expression_obsm_key="protein_expression",
batch_key="section")
model = scvi.model.TOTALVI(sections, n_latent=20)
model.train(max_epochs=400, early_stopping=True)
sections.obsm["X_totalVI"] = model.get_latent_representation()
Both sections need the same antibody panel; keep the shared proteins only if
they differ.
Expected Outputs
totalvi_result.h5ad, totalvi_model/ - joint latent space, denoised protein, clusters
multivi_result.h5ad - RNA+ATAC latent space and clusters
bbknn_result.h5ad - integrated sections with clusters
ingest_annotations.csv, ingest_result.h5ad - labels transferred to the new section
signature_scores.csv - per-spot/cell signature scores
figures/ - spatial maps of clusters, proteins and scores
Troubleshooting
| Problem | Cause | Fix |
|---|
totalVI ValueError on protein data | Proteins in X, not obsm | Split by feature_types into obsm["protein_expression"] (Module 1) |
| Denoised protein flat everywhere | Too few epochs or very low antibody counts | Check raw counts per antibody; train longer |
MultiVI n_genes/n_regions error | Constructor sizes missing or modalities misnamed | Pass both sizes; check mdata.mod keys |
| MultiVI training very slow | CPU with ~100k peaks | Use a GPU, or keep peaks found in > 1-5% of cells |
| BBKNN clusters still split by section | Strong batch effect | Use scVI with batch_key (scvi-tools-single-cell) or Harmony |
| ingest labels all one type | Different platform / normalisation between sections | Normalise both the same way; use the Harmony route for cross-platform transfer |
score_genes warns about missing genes | Panel lacks them | Report coverage; do not score with < 3 genes |
Related Skills
scvi-tools-single-cell - scVI/scANVI and totalVI on dissociated cells
muon-multiomics-singlecell - MuData handling, WNN, MOFA through muon
mofaplus-multi-omics - MOFA+ factor analysis across omics layers
harmony-batch-correction - linear batch correction of PCA embeddings
spatial-transcriptomics-cell-type-annotation - reference-based labels for imaging data
spatial-transcriptomics-deconvolution-cell2location - cell type abundance on Visium spots
spatial-domain-detection - tissue domains from the integrated data
References