Showing posts with label tumor microenvironment. Show all posts
Showing posts with label tumor microenvironment. Show all posts

Monday, August 31, 2026

Beyond Ligand-Receptor Scores: A Combined Decision Framework for Spatial Cell Communication

Spatial transcriptomics · ligand-receptor · cell communication · Squidpy

Why Spatial Context Matters for Cell Communication

Single-cell RNA-seq can tell you which ligands and receptors are expressed. It cannot tell you whether the cells expressing them are actually close enough to interact. Spatial transcriptomics closes that gap.

Two cells 500 µm apart in a tumor section are unlikely to exchange short-range signals regardless of what their transcriptomes predict. Squidpy's gr.ligrec and gr.nhood_enrichment functions let you ask both questions at once: what is being expressed, and are the right cells adjacent?

Figure 1. Left: ligand-receptor interaction scores between cell type pairs (* = p<0.05). Right: spatial co-occurrence z-scores — positive values indicate cell types that cluster together in tissue.

Setting Up the Interaction Database

Squidpy ships with a curated ligand-receptor database derived from CellChatDB and CellPhoneDB. You load it once and pass it to gr.ligrec:

import squidpy as sq
import scanpy as sc

# Load preprocessed Visium AnnData with cell type annotation in adata.obs["cell_type"]
adata = sc.read_h5ad("visium_annotated.h5ad")

# Build spatial graph first (required for proximity-aware analysis)
sq.gr.spatial_neighbors(adata, coord_type="grid", n_rings=2)

# Run ligand-receptor permutation test
sq.gr.ligrec(
    adata,
    n_perms=1000,
    cluster_key="cell_type",
    copy=False,
    use_raw=False,
    transmitter_params={"categories": "ligand"},
    receiver_params={"categories": "receptor"},
)
# Results stored in adata.uns["cell_type_ligrec"]

The permutation test shuffles cell type labels 1,000 times and asks: how often would this interaction score arise by chance given the spatial arrangement of cells? This is stricter than expression-only approaches because it accounts for the actual tissue layout.

Visualizing the Results

The raw output is a dictionary with means and pvalues DataFrames. Squidpy provides a dedicated dotplot:

sq.pl.ligrec(
    adata,
    cluster_key="cell_type",
    source_groups="Macrophages",
    target_groups=["T cells", "Cancer"],
    means_range=(0.3, float("inf")),  # filter weak interactions
    alpha=0.05,                        # significance threshold
    swap_axes=False,
    figsize=(10, 6),
)

Two practical filters here: means_range removes interactions with low absolute expression, and alpha applies the permutation p-value cutoff. Without both, the plot becomes unreadable with hundreds of LR pairs.

Neighborhood Enrichment: Are These Cells Actually Neighbors?

Before trusting any LR interaction, check whether the source and target cell types are spatially co-localized. gr.nhood_enrichment runs a permutation test on the spatial graph:

sq.gr.nhood_enrichment(adata, cluster_key="cell_type")
sq.pl.nhood_enrichment(
    adata,
    cluster_key="cell_type",
    method="average",
    cmap="RdBu_r",
    vmin=-3, vmax=3,
    figsize=(7, 6),
)

Z-scores above +2 indicate strong spatial co-occurrence; below −2 indicates mutual exclusion. From the figure: Macrophages and Cancer cells co-occur strongly (z = 3.1) — a known feature of immunosuppressive tumor microenvironments where TAMs cluster around tumor nests. T cells and Fibroblasts are mutually exclusive (z = −0.9), consistent with fibroblast-mediated immune exclusion.

Combining Both Analyses: A Decision Framework

Use neighborhood enrichment as a gate before interpreting LR results:

High enrichment + significant LR → strong candidate interaction worth validating (e.g., SPP1-CD44 between Macrophages and Cancer)

Low enrichment + significant LR → likely expression artifact; cells aren't actually proximal enough to interact

High enrichment + no significant LR → cells co-localize for structural reasons (e.g., Fibroblasts around vessels) rather than active signaling

Key LR Pairs in the Tumor Microenvironment

These consistently appear in Visium TME datasets:

  • SPP1 (OPN) → CD44: Macrophage-to-cancer signaling, promotes invasion and immunosuppression
  • CXCL12 → CXCR4: T cell and cancer cell chemoattraction; CXCR4 inhibition is a clinical target
  • VEGFA → KDR (VEGFR2): Tumor-to-endothelial angiogenic signaling; highest scores near vessel-rich regions
  • TGFB1 → TGFBR2: Fibroblast-mediated immunosuppression and ECM remodeling
  • PDGFB → PDGFRB: Pericyte recruitment by endothelial cells; marks active neovascularization zones

Performance Notes

gr.ligrec with 1,000 permutations on a full Visium slide (~3,500 spots, 10+ cell types) takes 5–15 minutes on a standard laptop. Use n_jobs=-1 to parallelize across CPU cores. For exploratory work, drop to n_perms=200 first to check the output structure, then rerun with 1,000 for publication figures.

# Fast exploratory run
sq.gr.ligrec(adata, n_perms=200, cluster_key="cell_type", n_jobs=-1)

# Extract top interactions programmatically
import pandas as pd
means = adata.uns["cell_type_ligrec"]["means"]
pvals = adata.uns["cell_type_ligrec"]["pvalues"]

# Filter: mean > 0.5, p < 0.05 for Macrophage->Cancer axis
mac_cancer = means.loc[:, ("Macrophages", "Cancer")]
sig = mac_cancer[pvals.loc[:, ("Macrophages", "Cancer")] < 0.05]
print(sig.sort_values(ascending=False).head(10))

What's Next

Ligand-receptor analysis identifies candidate interactions. The next step is spatial trajectory analysis — reconstructing how gene expression changes as you move from one tissue region to another, which reveals which signals are active at each point along the tumor-stroma interface.

Working in tumor microenvironment biology? The companion blog NeoantigenLab covers the immunotherapy side — checkpoint inhibitor mechanisms, neoantigen prediction pipelines, and CAR-T biology for the same cell types you're mapping spatially.

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 →

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