Showing posts with label cell communication. Show all posts
Showing posts with label cell communication. 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, July 18, 2026

Ligand-Receptor Interactions in Spatial Transcriptomics with Squidpy

We know which clusters are spatially adjacent (post 7) and at what distances they co-occur (post 8). The final question is: are they actually talking to each other? Ligand-receptor analysis tests whether spatially neighboring clusters express complementary signaling pairs at statistically significant levels.

How sq.gr.ligrec works

Squidpy's sq.gr.ligrec uses the CellPhoneDB permutation test framework:

  1. For each ligand-receptor pair in the database, compute the mean expression of ligand in source cluster × mean expression of receptor in target cluster
  2. Permute cluster labels n times to build a null distribution of that product
  3. Report a p-value: how often does the permuted score exceed the observed score?

A significant result (low p-value + high mean) means the two clusters express this pair more than you'd expect if cells were randomly mixed — suggesting active signaling across their spatial interface.

Code

import squidpy as sq
import scanpy as sc

adata = sc.read_h5ad("visium_hne_adata.h5ad")

# NOTE: visium_hne_adata ships .X ALREADY log-normalized (max ~8.4, non-integer);
# the raw integer counts are in adata.raw.X. Normalizing here would log it twice —
# no error, just quietly wrong numbers everywhere below.
sc.pp.highly_variable_genes(adata, n_top_genes=2000, flavor="cell_ranger")
sc.pp.pca(adata)
sc.pp.neighbors(adata)
sc.tl.leiden(adata, key_added="cluster")

sq.gr.spatial_neighbors(adata)

# Run ligand-receptor analysis (downloads CellPhoneDB on first run)
sq.gr.ligrec(
    adata,
    cluster_key="cluster",
    n_perms=1000,       # more perms = more accurate p-values, but slower
)

# Visualize: source clusters 0,1,2 → target clusters 0,1,2
sq.pl.ligrec(
    adata,
    cluster_key="cluster",
    source_groups=["0", "1", "2"],
    target_groups=["0", "1", "2"],
    means_range=(0.3, float("inf")),  # filter out near-zero means
    alpha=0.001,                       # significance threshold
    swap_axes=True,
)

Cluster layout

Leiden clusters on Visium mouse brain

Figure 1. Leiden clusters on tissue. We test signaling between the three largest clusters (0, 1, 2).

Result: Ligand-receptor dot plot

Ligand-receptor interaction dot plot for clusters 0, 1, 2

Figure 2. Ligand-receptor interaction dot plot. Dot size = mean expression of ligand×receptor product. Color = p-value (darker = more significant). Only pairs with mean >0.3 and p<0.001 are shown.

Reading the dot plot

  • Rows: ligand-receptor pairs from CellPhoneDB
  • Columns: source → target cluster pairs (e.g., "0|1" = ligand in cluster 0, receptor in cluster 1)
  • Dot size: mean expression product (larger = stronger signal)
  • Dot color: p-value (darker = more significant)
  • Empty cell: interaction did not pass the mean or significance threshold

The analysis tested 6,320 ligand-receptor pairs across all 225 cluster combinations (15 × 15). The filtered view shows only the most confident interactions between clusters 0, 1, and 2.

Important caveats with Visium data

Ligand-receptor analysis was originally designed for single-cell data. On Visium spots, each "cluster" represents a tissue region, not a pure cell type — a spot can contain 5–20 cells of mixed identities. This means:

  • Interactions detected reflect regional-level expression, not confirmed cell-to-cell contact
  • High-confidence results should be validated with higher-resolution data (Xenium, MERFISH) or orthogonal methods (FISH, IHC)
  • The method is still useful for hypothesis generation: spatially adjacent regions that share significant LR pairs are candidate sites of active signaling

For best results, combine with neighborhood enrichment: interactions between clusters with high nhood enrichment z-scores are more likely to reflect real spatial contact.

Accessing results programmatically

import pandas as pd

means  = adata.uns["cluster_ligrec"]["means"]
pvals  = adata.uns["cluster_ligrec"]["pvalues"]

# Significant interactions between cluster 0 (source) and cluster 1 (target)
sig = means.loc[:, ("0", "1")].to_frame("mean")
sig["pval"] = pvals.loc[:, ("0", "1")]
sig = sig[(sig["mean"] > 0.3) & (sig["pval"] < 0.05)].sort_values("mean", ascending=False)
print(sig.head(10))

Next post: Complete Squidpy workflow — the full pipeline in one place

SVGs → neighborhood enrichment → co-occurrence → ligand-receptor: every step from this series combined into a single reproducible script.

Notebooks: github.com/Lociven/spatiabio-tutorials

ligrec breaks more than most functions

It's one of Squidpy's most-discussed pain points

Pack 1's ligand-receptor notebook plus the debugging notebook cover the errors that actually come up — including ones not in the GitHub issue threads yet.

Get Pack 1 for $19 →

Full notebook: github.com/Lociven/spatiabio-tutorials
Data: squidpy built-in visium_hne_adata (10x Genomics Visium, mouse brain H&E)

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