Multimodal Omics Integration logo

Multimodal Omics Integration

Community
jaechang-hits
multimodal-omics-integration

Multimodal and multi-section integration for spatial and single-cell omics: totalVI joint RNA + protein (Visium CytAssist Gene and Protein, CITE-seq), MultiVI joint RNA + ATAC (10x Multiome, spatial ATAC-RNA), BBKNN integration of several tissue sections, scanpy ingest label transfer between sections, and gene signature scores mapped on tissue. Outputs joint latent spaces, denoised protein maps, integrated clusters, transferred labels. For dissociated CITE-seq and scVI/scANVI use scvi-tools-single-cell; for MOFA+ factor analysis use mofaplus-multi-omics or muon-multiomics-singlecell; for Harmony use harmony-batch-correction.

Overview

Publisherjaechang-hits
RepositorySciAgent-Skills
Skill namemultimodal-omics-integration
Stars
381
Forks
45
Bundled files
Instructions only
LicenseCC-BY-4.0
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • Self-contained

    Everything the model needs lives in the instructions — no extra files to sync.

  • Open source

    Published by jaechang-hits on GitHub. Read the source before you install it.

Installation

Install the Multimodal Omics Integration AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/jaechang-hits/SciAgent-Skills/tree/main/skills/genomics-bioinformatics/spatial-transcriptomics/multimodal-omics-integration
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/jaechang-hits/SciAgent-Skills.git /tmp/SciAgent-Skills
mkdir -p .claude/skills
cp -r /tmp/SciAgent-Skills/skills/genomics-bioinformatics/spatial-transcriptomics/multimodal-omics-integration .claude/skills/multimodal-omics-integration
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Multimodal Omics Integration in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Multimodal Omics Integration on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Multimodal Omics Integration is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

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.

QuestionMethodModule
RNA + protein on the same spots / cellstotalVI1-2
RNA + chromatin accessibility on the same cellsMultiVI3
Several sections or samples, one modalityBBKNN4
Labels from an annotated section onto a new onescanpy ingest5
Pathway or cell state activity on tissuesc.tl.score_genes6
Unsupervised factors across 2+ omicsMOFA+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
bash
# 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:

python
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.

python
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.

python
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.

python
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.

python
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.

python
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

python
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:

python
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:

python
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

ParameterDefaultEffect
n_latent (totalVI)20Joint latent dimensions
n_top_genes (totalVI)4000RNA genes modelled with the proteins
max_epochs400 (totalVI); scvi default (MultiVI)Training length; early stopping on validation loss
batch_keyNoneSection/sample column corrected inside the scvi model
neighbors_within_batch3BBKNN neighbours taken from each section
ctrl_size (score_genes)50Control 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:

python
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:

python
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

ProblemCauseFix
totalVI ValueError on protein dataProteins in X, not obsmSplit by feature_types into obsm["protein_expression"] (Module 1)
Denoised protein flat everywhereToo few epochs or very low antibody countsCheck raw counts per antibody; train longer
MultiVI n_genes/n_regions errorConstructor sizes missing or modalities misnamedPass both sizes; check mdata.mod keys
MultiVI training very slowCPU with ~100k peaksUse a GPU, or keep peaks found in > 1-5% of cells
BBKNN clusters still split by sectionStrong batch effectUse scVI with batch_key (scvi-tools-single-cell) or Harmony
ingest labels all one typeDifferent platform / normalisation between sectionsNormalise both the same way; use the Harmony route for cross-platform transfer
score_genes warns about missing genesPanel lacks themReport 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

Frequently asked questions

What does the Multimodal Omics Integration AI skill do?

Multimodal and multi-section integration for spatial and single-cell omics: totalVI joint RNA + protein (Visium CytAssist Gene and Protein, CITE-seq), MultiVI joint RNA + ATAC (10x Multiome, spatial ATAC-RNA), BBKNN integration of several tissue sections, scanpy ingest label transfer between sections, and gene signature scores mapped on tissue. Outputs joint latent spaces, denoised protein maps, integrated clusters, transferred labels. For dissociated CITE-seq and scVI/scANVI use scvi-tools-single-cell; for MOFA+ factor analysis use mofaplus-multi-omics or muon-multiomics-singlecell; for Ha...

Why use Multimodal Omics Integration on TypingMind?

Because you install it once and use it with any model. Multimodal Omics Integration is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Multimodal Omics Integration in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/jaechang-hits/SciAgent-Skills/tree/main/skills/genomics-bioinformatics/spatial-transcriptomics/multimodal-omics-integration. TypingMind reads its SKILL.md and installs it as a skill you can enable per chat.

Which AI models can use Multimodal Omics Integration?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Multimodal Omics Integration?

As many as you like. As long as a model supports skills, you can use Multimodal Omics Integration with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Multimodal Omics Integration AI skill free?

Yes. It is published on GitHub by jaechang-hits under the CC-BY-4.0 license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇