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)

No comments:

Post a Comment

Tangram: Mapping Single-Cell Annotations onto Spatial Coordinates

SPATIAL TRANSCRIPTOMICS · TUTORIAL Tangram: Mapping Single-Cell Annotations onto Spatial Coordinates Transfer cell type labels,...