Showing posts with label deconvolution. Show all posts
Showing posts with label deconvolution. Show all posts

Monday, September 7, 2026

Visium HD: What Changes When Resolution Goes Subcellular

Spatial transcriptomics · Visium HD · Xenium · CosMx · platform comparison

Why Resolution Matters (and Why It Is Not Everything)

Visium HD delivers 2 µm bins — roughly single-cell resolution — while the original Visium captures 55 µm spots that each contain 5–20 cells. That 27x improvement in resolution sounds decisive. In practice, the right platform depends on your biological question, not just the spec sheet.

This post covers what Visium HD actually changes analytically, where it outperforms its predecessor, and when Xenium or CosMx is the better choice despite having a fixed gene panel.

Figure 1. Left: spatial resolution comparison — Visium HD achieves 2 µm bins vs. 55 µm spots for Visium v1. Right: capability trade-offs across platforms normalized to maximum observed values.

What Visium HD Changes: The Bin Model

Visium HD does not capture individual cells — it captures 2 µm square bins that tile the tissue continuously. The key workflow difference is binning strategy: you choose a bin size at analysis time, not at capture time.

import squidpy as sq
import scanpy as sc

# Load Visium HD at 8 µm bins (good starting point — balances resolution and sparsity)
adata = sq.read.visium(
    path="visium_hd_output/",
    counts_file="filtered_feature_bc_matrix.h5",
    library_id="your_sample_id",
    bin_size=8,  # options: 2, 8, 16 micrometers
)

print(adata)
# AnnData: n_obs (bins) depends heavily on bin_size
# bin_size=2:  ~500,000 bins (very sparse, ~2-5 UMI/bin)
# bin_size=8:  ~30,000 bins (workable, ~50-100 UMI/bin)
# bin_size=16: ~8,000 bins (Visium-like density)

Start with 8 µm bins for exploratory analysis. At 2 µm, most bins will have fewer than 5 UMI — too sparse for reliable clustering without heavy smoothing. Only use 2 µm bins if you need subcellular resolution for a specific question (e.g., resolving nuclear vs. cytoplasmic transcript localization).

The Critical Difference: Deconvolution Is Optional

With Visium v1, cell2location or RCTD is mandatory to get cell type proportions per spot. With Visium HD at 8 µm bins, many bins will contain a single dominant cell type and can be annotated directly by marker expression — though mixed bins still exist at tissue interfaces.

# Standard clustering works directly on HD bins
sc.pp.filter_cells(adata, min_counts=10)
sc.pp.normalize_total(adata, target_sum=1e4)
sc.pp.log1p(adata)
sc.pp.highly_variable_genes(adata, n_top_genes=3000)
sc.tl.pca(adata)
sc.pp.neighbors(adata, n_neighbors=15)
sc.tl.leiden(adata, resolution=0.4)

# Annotate by marker expression (same workflow as scRNA-seq)
sc.pl.dotplot(adata, markers_dict, groupby="leiden")

# Optional: if you want single-cell precision at 2µm bins,
# still use cell2location with matched scRNA-seq reference

Visium HD vs. Xenium: Choosing the Right Tool

Choose Visium HD when:

  • You need whole-transcriptome coverage (not a fixed panel)
  • Your question involves discovery — finding unexpected cell states or pathways
  • You have FFPE tissue (Visium HD supports FFPE; Xenium also does)
  • You want spatial multi-omics (Visium HD + CUT&TAG or protein co-detection)

Choose Xenium/CosMx when:

  • You need subcellular resolution (mRNA localization within a cell)
  • Your target genes are already known and a panel of 100–500 genes is sufficient
  • You need higher sensitivity per transcript (in-situ amplification vs. sequencing)
  • Your tissue is thick or 3D (Xenium handles z-stacking)

Squidpy Compatibility

Visium HD AnnData loads exactly like Visium v1 from Squidpy's perspective. All tools covered in this series work without modification:

# Everything from earlier posts works identically on Visium HD
sq.gr.spatial_neighbors(adata, coord_type="generic", n_neighs=6)
sq.gr.nhood_enrichment(adata, cluster_key="leiden")
sq.gr.ligrec(adata, n_perms=500, cluster_key="leiden")
sq.gr.spatial_autocorr(adata, mode="moran")

# The only practical difference: more bins = longer runtime
# nhood_enrichment on 30,000 bins takes ~10 min vs ~2 min for Visium v1

One Practical Warning: Tissue Coverage

Visium HD captures a 6.5 mm × 6.5 mm area — identical to Visium v1. This is enough for most tissue sections but smaller than a typical whole-slide image. If your question requires comparing multiple tumor regions across a large section, you will need multiple capture areas or switch to a platform with larger footprint (some CosMx runs cover full slides).

Interested in what the immune cell types you are mapping at single-cell resolution are actually doing in the tumor? NeoantigenLab covers T cell exhaustion, checkpoint inhibitor mechanisms, and CAR-T biology at the molecular level.

Get the complete pack

SpatiaBio Squidpy Pack 1 — 16 Notebooks ($19)

All 16 notebooks — neighborhood enrichment, co-occurrence, ligand-receptor, spatial autocorrelation, publication figures, and the complete workflow. Includes memory tips for Visium HD. Ready to run on your own data.

Get it for $19 →

From the NeoantigenLab sister blog

Working with tumor transcriptomics for neoantigen research?

NeoantigenLab covers neoantigen biology, WES pipelines, pVACseq, and HLA typing for experimental researchers — the biology behind what you're sequencing.

Visit NeoantigenLab →

Monday, August 24, 2026

Tangram: Mapping Single-Cell Annotations onto Spatial Coordinates

SPATIAL TRANSCRIPTOMICS · TUTORIAL

Tangram: Mapping Single-Cell Annotations onto Spatial Coordinates

Transfer cell type labels, gene programs, and custom scores from scRNA-seq directly onto your Visium or Xenium data

July 2026  ·  10 min read  ·  SpatiaBio

Cell2location (covered in Post 12) infers cell type abundances per spot using a Bayesian model. Tangram takes a complementary approach: it learns a mapping from single-cell space to spatial space by aligning gene expression profiles, then transfers any annotation — cell types, transcription factor activity, pseudotime, custom gene scores — onto spatial coordinates. If you have a well-annotated scRNA-seq atlas and want to ask "where do these cells sit in tissue?", Tangram is your tool.

Figure 1. Tangram-mapped dominant cell type per spatial location (left) and deconvolution method comparison by accuracy and compute speed (right).

How Tangram Works

Tangram frames cell-to-space mapping as an optimal transport problem. Given a scRNA-seq dataset with C cells and a spatial dataset with S spots, it learns a mapping matrix M (C × S) that minimizes the difference between:

  • The spatially reconstructed gene expression (MT × scRNA matrix)
  • The observed spatial gene expression

The key insight: you only need a shared set of marker genes to align the two modalities. Tangram then uses the learned mapping to project everything else — including genes not measured in the spatial assay.

import tangram as tg
import scanpy as sc
import squidpy as sq

# Load data
sc_adata  = sc.read_h5ad('sc_annotated.h5ad')   # scRNA-seq with cell_type labels
sp_adata  = sc.read_h5ad('visium_filtered.h5ad') # Visium spatial data

# Identify shared marker genes (top 100 per cell type works well)
sc.tl.rank_genes_groups(sc_adata, groupby='cell_type', method='wilcoxon')
markers = tg.pp.select_genes(sc_adata, n_genes=100)

tg.pp.filter_gene_list(sc_adata, sp_adata, gene_list=markers)

# Train mapping (GPU recommended for large datasets)
tg.mapping.map_cells_to_space(
    sc_adata,
    sp_adata,
    mode='cells',        # or 'clusters' for memory efficiency
    target_count=sp_adata.obs.cell_count.sum(),
    density_prior='rna_count_based',
    num_epochs=500,
    device='cpu',
)

Three Mapping Modes

cells mode

Maps each individual cell to a spot. Best for datasets <50k cells. Gives highest resolution but memory-intensive.

clusters mode

Maps cluster centroids instead of individual cells. Scalable to large atlases. Recommended starting point.

constrained mode

Uses prior cell density estimates (e.g. from DAPI) to constrain the mapping. More accurate when cell counts per spot are known.

Projecting Cell Types and Custom Scores

After mapping, Tangram stores results in sp_adata.obsm['tangram_ct_pred']. You can then project back to the spatial grid:

# Project cell type probabilities onto spatial coords
tg.pl.plot_cell_annotation_sc(sp_adata, annotation='cell_type', perc=0.02)

# Project any custom score from scRNA-seq (e.g. exhaustion score)
sc_adata.obs['exhaustion_score'] = (
    sc_adata[:, ['PDCD1','HAVCR2','LAG3','TIGIT','TOX']].X.mean(axis=1)
)
tg.pp.project_genes(adata_map=sp_adata, adata_sc=sc_adata)

# Now sp_adata has exhaustion_score — plot spatially
import squidpy as sq
sq.pl.spatial_scatter(sp_adata, color='exhaustion_score', cmap='Reds')
Pro tip: Project T cell exhaustion scores, EMT signatures, or pVACseq neoantigen burden per cell — then map them spatially. This links neoantigen-rich tumor zones to immune infiltration patterns.

Tangram vs cell2location: When to Use Which

Tangram cell2location
Approach Optimal transport mapping Bayesian deconvolution
Output Cell-to-spot probability matrix Cell type abundance per spot
Custom score transfer ✓ Any obs/obsm column ✗ Cell types only
Gene imputation ✓ Projects unmeasured genes ✗ No
Scalability Moderate (<200k cells) Large atlases OK
Best for Transferring rich annotations Quantifying cell composition

Common Pitfalls

  • Too few marker genes — use at least 50-100 per cell type. With <20, mapping is unreliable.
  • Batch effects between sc and spatial — if both datasets are from different labs or platforms, normalize carefully before running Tangram. Harmony or scVI integration first helps.
  • Overinterpretation of imputed genes — genes projected via Tangram are predicted, not measured. Don't use imputed values for differential expression.
  • Using cells mode on >100k cells — switch to clusters mode. The mapping matrix becomes O(C × S) and will OOM on most machines.

Key Takeaways

  • Tangram maps any scRNA-seq annotation onto spatial coordinates via optimal transport
  • Use clusters mode for large atlases, cells mode for maximum resolution
  • Combine with Squidpy for spatial statistics on projected scores
  • Complements cell2location — use both for a complete picture

Working on neoantigen biology or cancer immunotherapy?

The spatial exhaustion score workflow above connects directly to neoantigen research — see NeoantigenLab for the immunology side.

Explore NeoantigenLab →

From the NeoantigenLab sister blog

Working with tumor transcriptomics for neoantigen research?

NeoantigenLab covers neoantigen biology, WES pipelines, pVACseq, and HLA typing for experimental researchers — the biology behind what you're sequencing.

Visit NeoantigenLab →

Saturday, August 8, 2026

Cell2location: Deconvolving Visium Spots to Single-Cell Resolution

By Lociven · SpatiaBio · August 2026

Visium spots are not single cells. Each spot captures RNA from multiple cells, and without knowing which cell types contributed what, your spatial analysis is blurry. Cell2location solves this by deconvolving Visium spots using a scRNA-seq reference. This post explains how it works and how to run it.


The problem: spots contain multiple cells

A standard Visium v2 spot is 55 µm in diameter. In most human tissues, that area contains 2–15 cells depending on cell size and packing density. When you cluster spots and call marker genes, you're clustering mixtures — and the "spatial domains" you find may reflect the composition of those mixtures rather than true biological boundaries.

Cell type deconvolution maps each spot to a probability distribution over cell types: "this spot is 60% cancer epithelial, 25% macrophage, 15% fibroblast." That's what cell2location produces.

Why cell2location over other methods? RCTD and NNLS are faster but assume cell type proportions sum to 1 (full tissue coverage). Cell2location uses a hierarchical Bayesian model that accounts for differences in RNA capture efficiency between scRNA-seq and Visium — which matters a lot in practice.


What you need

  • Visium AnnData — raw counts (not normalized), spatial coordinates
  • scRNA-seq reference — same tissue type, annotated cell types. Does not need to be the same sample.
  • GPU strongly recommended (CPU works but is slow for large datasets)

The scRNA-seq reference is the critical input. If your reference has poorly annotated cell types, or doesn't cover all cell types present in the tissue, the deconvolution will be wrong. More cell types in the reference is generally better, as long as they're well-annotated.


Step 1: Prepare the scRNA-seq reference

import cell2location
import scanpy as sc
import numpy as np

# Load scRNA-seq reference
adata_ref = sc.read_h5ad("scrna_reference.h5ad")

# Keep raw counts
adata_ref.X = adata_ref.layers["counts"].copy()

# Filter to cell types with enough cells
cell_type_counts = adata_ref.obs["cell_type"].value_counts()
keep = cell_type_counts[cell_type_counts >= 5].index
adata_ref = adata_ref[adata_ref.obs["cell_type"].isin(keep)].copy()

# Filter genes
sc.pp.filter_genes(adata_ref, min_cells=1)

print(f"Reference: {adata_ref.n_obs} cells, {adata_ref.n_vars} genes")
print(f"Cell types: {adata_ref.obs['cell_type'].nunique()}")

Step 2: Train the reference model

from cell2location.models import RegressionModel

# Set up the regression model on the reference
RegressionModel.setup_anndata(
    adata=adata_ref,
    batch_key="sample",        # batch correction if multiple samples
    labels_key="cell_type",
    layer="counts"
)

mod = RegressionModel(adata_ref)
mod.train(max_epochs=250, use_gpu=True)

# Export the estimated expression signatures
adata_ref = mod.export_posterior(
    adata_ref,
    sample_kwargs={"num_samples": 1000, "batch_size": 2500, "use_gpu": True}
)

# Save the cell type signatures
inf_aver = adata_ref.varm["means_per_cluster_mu_fg"][
    [f"means_per_cluster_mu_fg_{i}" for i in adata_ref.uns["mod"]["factor_names"]]
].copy()
inf_aver.columns = adata_ref.uns["mod"]["factor_names"]
inf_aver.iloc[0:5, 0:5]

Step 3: Run deconvolution on Visium

import squidpy as sq

# Load Visium data
adata_vis = sq.read.visium("path/to/visium/")
adata_vis.var_names_make_unique()

# Keep only genes present in both datasets
intersect = np.intersect1d(adata_vis.var_names, inf_aver.index)
adata_vis = adata_vis[:, intersect].copy()
inf_aver = inf_aver.loc[intersect, :]

# Set up cell2location model on Visium
cell2location.models.Cell2location.setup_anndata(
    adata=adata_vis, layer="counts"
)

mod_vis = cell2location.models.Cell2location(
    adata_vis,
    cell_state_df=inf_aver,
    N_cells_per_location=10,    # expected cells per spot
    detection_alpha=20
)

mod_vis.train(
    max_epochs=30000,
    batch_size=None,
    train_size=1,
    use_gpu=True
)

Step 4: Extract and visualize cell type abundances

# Export posterior
adata_vis = mod_vis.export_posterior(
    adata_vis,
    sample_kwargs={"num_samples": 1000, "batch_size": 10, "use_gpu": True}
)

# Add 5% quantile cell type abundances to obsm
adata_vis.obs[adata_vis.uns["mod"]["factor_names"]] = adata_vis.obsm["q05_cell_abundance_w_sf"]

# Plot spatial distribution of each cell type
import matplotlib.pyplot as plt

cell_types_to_plot = ["Cancer_epithelial", "CD8_T_cell", "Macrophage", "Fibroblast"]

fig, axes = plt.subplots(1, len(cell_types_to_plot), figsize=(16, 4))
for ax, ct in zip(axes, cell_types_to_plot):
    sq.pl.spatial_scatter(
        adata_vis, color=ct, ax=ax,
        cmap="magma", size=1.5, title=ct
    )
plt.tight_layout()
plt.savefig("cell2location_output.png", dpi=150, bbox_inches="tight")

How deconvolution changes what a spot means. Left: a 55 µm Visium spot physically contains several cells of different types, and sequencing returns one averaged profile for all of them. Right: cell2location returns a composition instead — the worked example from this post. Read the q05 column, the conservative 5th-percentile estimate, rather than the mean.

Interpreting the output

The key output column is q05_cell_abundance_w_sf — the 5th percentile of the posterior distribution of cell type abundance per spot. Using the 5th percentile (rather than the mean) gives conservative, high-confidence estimates.

Common patterns to look for:

  • Cancer cells concentrated in the tumor core, stromal cells at the margin
  • CD8+ T cells enriched at the invasive margin vs excluded from the tumor core (a key immunotherapy biomarker)
  • Macrophage subtypes (M1/M2) in different spatial zones
  • Spots with very low total abundance — these may be necrotic areas or technical artifacts

Common mistake: Setting N_cells_per_location too high or too low drastically affects results. Use histology or DAPI staining to estimate the actual cell density in your tissue before running the model.


Using cell2location output with Squidpy

Once you have cell type abundances per spot, you can feed them directly into Squidpy's neighborhood enrichment and co-occurrence functions — treating cell types as categorical annotations rather than gene expression clusters.

import squidpy as sq

# Assign dominant cell type per spot
adata_vis.obs["dominant_cell_type"] = (
    adata_vis.obs[adata_vis.uns["mod"]["factor_names"]]
    .idxmax(axis=1)
)

# Now run neighborhood enrichment on deconvolved cell types
sq.gr.spatial_neighbors(adata_vis, coord_type="grid", n_rings=2)
sq.gr.nhood_enrichment(adata_vis, cluster_key="dominant_cell_type")
sq.pl.nhood_enrichment(adata_vis, cluster_key="dominant_cell_type", method="ward")

Get the complete pack

SpatiaBio Pack 2 — Deconvolution & Spatial Pattern Detection

The full cell2location deconvolution notebook — real scRNA-seq reference mapping, tissue zone clustering, and spatial neighborhood enrichment between zones. Plus a SpatialDE2 SVG detection notebook.

Get it for $19 →

From the NeoantigenLab sister blog

Applying spatial transcriptomics to tumor immunology?

NeoantigenLab covers the biology behind what you're profiling — neoantigen pipelines, TIL therapy, checkpoint inhibitors, and cancer immunotherapy for experimental researchers.

Visit NeoantigenLab →

Tags: cell2location, cell type deconvolution, Visium, spatial transcriptomics, scRNA-seq, RCTD, Bayesian deconvolution, tumor microenvironment, Squidpy

Squidpy's Delaunay Radius Pruning Builds a Graph You Can't Save — Here's the Workaround

Squidpy · spatial_neighbors_delaunay · h5ad · IORegistryError · still in latest release Squidpy's Delaunay Radius Pruning Builds a G...