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:
- For each ligand-receptor pair in the database, compute the mean expression of ligand in source cluster × mean expression of receptor in target cluster
- Permute cluster labels n times to build a null distribution of that product
- 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
Figure 1. Leiden clusters on tissue. We test signaling between the three largest clusters (0, 1, 2).
Result: Ligand-receptor dot plot
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)