Spatial mapping with Tangram#
This tutorial demonstrates how to use Tangram (original code) to map spatial transcriptomics data. Here, we follow the corresponding tutorial at Squidpy. We do not provide much explanation, and instead refer to the original tutorial.
In scvi-tools, the "cells" and "constrained" modes of Tangram are implemented. In the "cells" mode, the priors will need to be calculated manually. We show this below.
import tempfile
import rapids_singlecell as rsc
import matplotlib.pyplot as plt
import mudata
import numpy as np
import pandas as pd
import scanpy as sc
import scvi
import scviva
import seaborn as sns
import squidpy as sq
from scviva.external import Tangram
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/rapids_singlecell/__init__.py:38: UserWarning:
Multiple rapids_singlecell packages are installed: rapids-singlecell, rapids-singlecell-cu13
Please uninstall all versions and reinstall only one:
pip uninstall rapids-singlecell rapids-singlecell-cu13
_detect_duplicate_installation()
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/spatialdata/_core/query/relational_query.py:531: FutureWarning: functools.partial will be a method descriptor in future Python versions; wrap it in enum.member() if you want to preserve the old behavior
left = partial(_left_join_spatialelement_table)
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/spatialdata/_core/query/relational_query.py:532: FutureWarning: functools.partial will be a method descriptor in future Python versions; wrap it in enum.member() if you want to preserve the old behavior
left_exclusive = partial(_left_exclusive_join_spatialelement_table)
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/spatialdata/_core/query/relational_query.py:533: FutureWarning: functools.partial will be a method descriptor in future Python versions; wrap it in enum.member() if you want to preserve the old behavior
inner = partial(_inner_join_spatialelement_table)
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/spatialdata/_core/query/relational_query.py:534: FutureWarning: functools.partial will be a method descriptor in future Python versions; wrap it in enum.member() if you want to preserve the old behavior
right = partial(_right_join_spatialelement_table)
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/spatialdata/_core/query/relational_query.py:535: FutureWarning: functools.partial will be a method descriptor in future Python versions; wrap it in enum.member() if you want to preserve the old behavior
right_exclusive = partial(_right_exclusive_join_spatialelement_table)
scviva.settings.seed = 0
print("Last run with scVIVA-Tools version:", scviva.__version__)
INFO: Seed set to 0
2026-09-08 14:23:16 | [INFO] Seed set to 0
Last run with scVIVA-Tools version: 0.1.6
scvi.settings.seed = 0
print("Last run with scvi-tools version:", scvi.__version__)
INFO: Seed set to 0
2026-09-08 14:23:16 | [INFO] Seed set to 0
Last run with scvi-tools version: 1.5.0.post1
Note
You can modify save_dir below to change where the data files for this tutorial are saved.
sc.set_figure_params(figsize=(6, 6), frameon=False)
sns.set_theme()
save_dir = tempfile.TemporaryDirectory()
%config InlineBackend.print_figure_kwargs={"facecolor": "w"}
%config InlineBackend.figure_format="retina"
Load and preprocess data with squidpy#
adata_sp = sq.datasets.visium_fluo_adata_crop("data/visium_fluo_adata_crop.h5ad")
adata_sp = adata_sp[adata_sp.obs.cluster.isin([f"Cortex_{i}" for i in np.arange(1, 5)])].copy()
INFO Downloading visium_fluo_adata_crop.h5ad from
https://exampledata.scverse.org/squidpy/figshare/visium_fluo_adata_crop.h5ad
Downloading data from 'https://exampledata.scverse.org/squidpy/figshare/visium_fluo_adata_crop.h5ad' to file '/home/access/PycharmProjects/scviva-tools/docs/tutorials/data/visium_fluo_adata_crop.h5ad'.
img = sq.datasets.visium_fluo_image_crop("data")
INFO Downloading data.tiff from https://exampledata.scverse.org/squidpy/figshare/visium_fluo_image_crop.tiff
Downloading data from 'https://exampledata.scverse.org/squidpy/figshare/visium_fluo_image_crop.tiff' to file '/home/access/PycharmProjects/scviva-tools/docs/tutorials/data.tiff'.
adata_sc = sq.datasets.sc_mouse_cortex("data/sc_mouse_cortex.h5ad")
INFO Downloading sc_mouse_cortex.h5ad from
https://exampledata.scverse.org/squidpy/figshare/sc_mouse_cortex.h5ad
Downloading data from 'https://exampledata.scverse.org/squidpy/figshare/sc_mouse_cortex.h5ad' to file '/home/access/PycharmProjects/scviva-tools/docs/tutorials/data/sc_mouse_cortex.h5ad'.
sc.pp.filter_genes(adata_sp, min_cells=1)
sc.pp.filter_genes(adata_sc, min_cells=1)
fig, axs = plt.subplots(1, 2, figsize=(12, 4))
sq.pl.spatial_scatter(adata_sp, color="cluster", alpha=0.7, frameon=False, ax=axs[0])
sc.pl.umap(adata_sc, color="cell_subclass", size=10, frameon=False, ax=axs[1])
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/squidpy/pl/_spatial_utils.py:468: FutureWarning: The method obs_vector is deprecated and will be removed in the future. Use anndata.acc.A instead of obs_vector. E.g. `vec = adata[A.obs['foo']]` or `vec = adata[A.layers['l']['bar', :]]`
color_source_vector = adata.obs_vector(value_to_plot, layer=layer)
mdata = mudata.MuData(
{
"sp": adata_sp,
"sc": adata_sc,
}
)
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/mudata/_core/mudata.py:1467: FutureWarning: From 0.4 .update() will not pull obs/var columns from individual modalities by default anymore. Set mudata.set_options(pull_on_update=False) to adopt the new behaviour, which will become the default. Use new pull_obs/pull_var and push_obs/push_var methods for more flexibility.
self._update_attr("var", axis=0, join_common=join_common)
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/mudata/_core/mudata.py:609: UserWarning: Cannot join columns with the same name because var_names are intersecting.
self._update_attr_legacy(attr, axis, join_common, **kwargs)
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/mudata/_core/mudata.py:1318: FutureWarning: From 0.4 .update() will not pull obs/var columns from individual modalities by default anymore. Set mudata.set_options(pull_on_update=False) to adopt the new behaviour, which will become the default. Use new pull_obs/pull_var and push_obs/push_var methods for more flexibility.
self._update_attr("obs", axis=1, join_common=join_common)
sq.im.process(img=img, layer="image", method="smooth")
sq.im.segment(
img=img,
layer="image_smooth",
method="watershed",
channel=0,
)
# define image layer to use for segmentation
features_kwargs = {
"segmentation": {
"label_layer": "segmented_watershed",
"props": ["label", "centroid"],
"channels": [1, 2],
}
}
# calculate segmentation features
sq.im.calculate_image_features(
adata_sp,
img,
layer="image",
key_added="image_features",
features_kwargs=features_kwargs,
features="segmentation",
mask_circle=True,
)
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/squidpy/im/_feature_mixin.py:355: RuntimeWarning: invalid value encountered in divide
y = (y - np.min(y)) / (np.max(y) - np.min(y))
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/squidpy/im/_feature_mixin.py:356: RuntimeWarning: invalid value encountered in divide
x = (x - np.min(x)) / (np.max(x) - np.min(x))
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/squidpy/im/_feature_mixin.py:355: RuntimeWarning: invalid value encountered in divide
y = (y - np.min(y)) / (np.max(y) - np.min(y))
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/squidpy/im/_feature_mixin.py:356: RuntimeWarning: invalid value encountered in divide
x = (x - np.min(x)) / (np.max(x) - np.min(x))
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/squidpy/im/_feature_mixin.py:355: RuntimeWarning: invalid value encountered in divide
y = (y - np.min(y)) / (np.max(y) - np.min(y))
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/squidpy/im/_feature_mixin.py:356: RuntimeWarning: invalid value encountered in divide
x = (x - np.min(x)) / (np.max(x) - np.min(x))
adata_sp.obs["cell_count"] = adata_sp.obsm["image_features"]["segmentation_label"]
sq.pl.spatial_scatter(adata_sp, color=["cluster", "cell_count"], frameon=False, wspace=0.01)
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/squidpy/pl/_spatial_utils.py:468: FutureWarning: The method obs_vector is deprecated and will be removed in the future. Use anndata.acc.A instead of obs_vector. E.g. `vec = adata[A.obs['foo']]` or `vec = adata[A.layers['l']['bar', :]]`
color_source_vector = adata.obs_vector(value_to_plot, layer=layer)
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/squidpy/pl/_spatial_utils.py:468: FutureWarning: The method obs_vector is deprecated and will be removed in the future. Use anndata.acc.A instead of obs_vector. E.g. `vec = adata[A.obs['foo']]` or `vec = adata[A.layers['l']['bar', :]]`
color_source_vector = adata.obs_vector(value_to_plot, layer=layer)
sc.tl.rank_genes_groups(adata_sc, groupby="cell_subclass", use_raw=False)
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/scanpy/tools/_rank_genes_groups.py:607: PerformanceWarning: DataFrame is highly fragmented. This is usually the result of calling `frame.insert` many times, which has poor performance. Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`
self.stats[group_name, "names"] = self.var_names[global_indices]
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/scanpy/tools/_rank_genes_groups.py:609: PerformanceWarning: DataFrame is highly fragmented. This is usually the result of calling `frame.insert` many times, which has poor performance. Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`
self.stats[group_name, "scores"] = scores[global_indices]
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/scanpy/tools/_rank_genes_groups.py:612: PerformanceWarning: DataFrame is highly fragmented. This is usually the result of calling `frame.insert` many times, which has poor performance. Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`
self.stats[group_name, "pvals"] = pvals[global_indices]
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/scanpy/tools/_rank_genes_groups.py:622: PerformanceWarning: DataFrame is highly fragmented. This is usually the result of calling `frame.insert` many times, which has poor performance. Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`
self.stats[group_name, "pvals_adj"] = pvals_adj[global_indices]
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/scanpy/tools/_rank_genes_groups.py:633: PerformanceWarning: DataFrame is highly fragmented. This is usually the result of calling `frame.insert` many times, which has poor performance. Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`
self.stats[group_name, "logfoldchanges"] = np.log2(
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/scanpy/tools/_rank_genes_groups.py:607: PerformanceWarning: DataFrame is highly fragmented. This is usually the result of calling `frame.insert` many times, which has poor performance. Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`
self.stats[group_name, "names"] = self.var_names[global_indices]
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/scanpy/tools/_rank_genes_groups.py:609: PerformanceWarning: DataFrame is highly fragmented. This is usually the result of calling `frame.insert` many times, which has poor performance. Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`
self.stats[group_name, "scores"] = scores[global_indices]
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/scanpy/tools/_rank_genes_groups.py:612: PerformanceWarning: DataFrame is highly fragmented. This is usually the result of calling `frame.insert` many times, which has poor performance. Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`
self.stats[group_name, "pvals"] = pvals[global_indices]
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/scanpy/tools/_rank_genes_groups.py:622: PerformanceWarning: DataFrame is highly fragmented. This is usually the result of calling `frame.insert` many times, which has poor performance. Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`
self.stats[group_name, "pvals_adj"] = pvals_adj[global_indices]
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/scanpy/tools/_rank_genes_groups.py:633: PerformanceWarning: DataFrame is highly fragmented. This is usually the result of calling `frame.insert` many times, which has poor performance. Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`
self.stats[group_name, "logfoldchanges"] = np.log2(
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/scanpy/tools/_rank_genes_groups.py:607: PerformanceWarning: DataFrame is highly fragmented. This is usually the result of calling `frame.insert` many times, which has poor performance. Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`
self.stats[group_name, "names"] = self.var_names[global_indices]
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/scanpy/tools/_rank_genes_groups.py:609: PerformanceWarning: DataFrame is highly fragmented. This is usually the result of calling `frame.insert` many times, which has poor performance. Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`
self.stats[group_name, "scores"] = scores[global_indices]
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/scanpy/tools/_rank_genes_groups.py:612: PerformanceWarning: DataFrame is highly fragmented. This is usually the result of calling `frame.insert` many times, which has poor performance. Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`
self.stats[group_name, "pvals"] = pvals[global_indices]
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/scanpy/tools/_rank_genes_groups.py:622: PerformanceWarning: DataFrame is highly fragmented. This is usually the result of calling `frame.insert` many times, which has poor performance. Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`
self.stats[group_name, "pvals_adj"] = pvals_adj[global_indices]
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/scanpy/tools/_rank_genes_groups.py:633: PerformanceWarning: DataFrame is highly fragmented. This is usually the result of calling `frame.insert` many times, which has poor performance. Consider joining all columns at once using pd.concat(axis=1) instead. To get a de-fragmented frame, use `newframe = frame.copy()`
self.stats[group_name, "logfoldchanges"] = np.log2(
Find genes for mapping#
markers_df = pd.DataFrame(adata_sc.uns["rank_genes_groups"]["names"]).iloc[0:100, :]
genes_sc = np.unique(markers_df.melt().value.values)
genes_st = adata_sp.var_names.values
genes = list(set(genes_sc).intersection(set(genes_st)))
len(genes)
1280
Add training objects to mudata#
Here we compute all the density priors we need to run Tangram.
target_count = adata_sp.obs.cell_count.sum()
adata_sp.obs["density_prior"] = np.asarray(adata_sp.obs.cell_count) / target_count
rna_count_per_spot = np.asarray(adata_sp.X.sum(axis=1)).squeeze()
adata_sp.obs["rna_count_based_density"] = rna_count_per_spot / np.sum(rna_count_per_spot)
adata_sp.obs["uniform_density"] = np.ones(adata_sp.X.shape[0]) / adata_sp.X.shape[0]
adata_sp.var_names_make_unique()
mdata.mod["sp"].var_names_make_unique()
mdata.mod["sc"].var_names_make_unique()
mdata.mod["sp_train"] = mdata.mod["sp"][:, genes].copy()
mdata.mod["sc_train"] = mdata.mod["sc"][:, genes].copy()
mdata.update()
# global
mdata.var_names = mdata.var_names.astype(str)
mdata.var_names_make_unique()
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/mudata/_core/mudata.py:1406: FutureWarning: From 0.4 .update() will not pull obs/var columns from individual modalities by default anymore. Set mudata.set_options(pull_on_update=False) to adopt the new behaviour, which will become the default. Use new pull_obs/pull_var and push_obs/push_var methods for more flexibility.
self._update_attr(attr, axis=1 - axis)
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/mudata/_core/mudata.py:1347: UserWarning: Modality names will be prepended to var_names since there are identical var_names in different modalities.
warnings.warn(
# per modality
for ad in mdata.mod.values():
ad.var_names = ad.var_names.astype(str)
ad.var_names_make_unique()
mdata.update()
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/mudata/_core/mudata.py:1467: FutureWarning: From 0.4 .update() will not pull obs/var columns from individual modalities by default anymore. Set mudata.set_options(pull_on_update=False) to adopt the new behaviour, which will become the default. Use new pull_obs/pull_var and push_obs/push_var methods for more flexibility.
self._update_attr("var", axis=0, join_common=join_common)
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/mudata/_core/mudata.py:1318: FutureWarning: From 0.4 .update() will not pull obs/var columns from individual modalities by default anymore. Set mudata.set_options(pull_on_update=False) to adopt the new behaviour, which will become the default. Use new pull_obs/pull_var and push_obs/push_var methods for more flexibility.
self._update_attr("obs", axis=1, join_common=join_common)
Run Tangram#
Here we run the constrained mode; however, we could have set constrained=False, target_count=None, and density_prior_key="rna_count_based_density" to recover Tangram’s "cells" mode.
Tangram.setup_mudata(
mdata,
density_prior_key="density_prior",
modalities={
"density_prior_key": "sp_train",
"sc_layer": "sc_train",
"sp_layer": "sp_train",
},
)
model = Tangram(mdata, constrained=True, target_count=target_count)
model.train()
INFO Tangram torch module moved to cuda:0.
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/torch/cuda/__init__.py:1174: UserWarning: Can't initialize NVML
raw_cnt = _raw_device_count_nvml()
2026-09-08 14:26:31 | [INFO] Tangram torch module moved to cuda:0.
mapper = model.get_mapper_matrix()
mdata.mod["sc"].obsm["tangram_mapper"] = mapper
labels = mdata.mod["sc"].obs.cell_subclass
mdata.mod["sp"].obsm["tangram_ct_pred"] = model.project_cell_annotations(
mdata.mod["sc"], mdata.mod["sp"], mapper, labels
)
mdata.mod["sp_sc_projection"] = model.project_genes(mdata.mod["sc"], mdata.mod["sp"], mapper)
pred = adata_sp.obsm["tangram_ct_pred"]
if not isinstance(pred, pd.DataFrame):
pred = pd.DataFrame(pred, index=adata_sp.obs_names)
adata_sp.obs = pd.concat([adata_sp.obs, pred], axis=1)
sq.pl.spatial_scatter(
adata_sp,
color=["L2/3 IT", "L4", "L5 IT", "L5 PT", "L6 CT", "L6 IT", "L6b"],
wspace=0.01,
ncols=2,
)
/opt/anaconda3/envs/scvi_new/lib/python3.13/site-packages/squidpy/pl/_spatial_utils.py:468: FutureWarning: The method obs_vector is deprecated and will be removed in the future. Use anndata.acc.A instead of obs_vector. E.g. `vec = adata[A.obs['foo']]` or `vec = adata[A.layers['l']['bar', :]]`
color_source_vector = adata.obs_vector(value_to_plot, layer=layer)
import gc
gc.collect()
import torch
torch.cuda.empty_cache()