Showing posts with label neighborhood enrichment. Show all posts
Showing posts with label neighborhood enrichment. 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 4, 2026

Neighborhood Enrichment Analysis with Squidpy: Which Clusters Are Actually Neighbors?

After identifying spatially variable genes (posts 5 and 6), a natural next question is: which cell populations actually live next to each other on the tissue? That's exactly what neighborhood enrichment analysis answers.

In this post we'll use sq.gr.nhood_enrichment from Squidpy to measure which Leiden clusters co-locate more — or less — than you'd expect by chance, on the same 10x Genomics Visium mouse brain section used throughout this series.

What is neighborhood enrichment?

Neighborhood enrichment computes a z-score for every pair of clusters (i, j). It asks: if I stand on a spot in cluster i, how often do I see cluster j as a neighbor, compared to random expectation?

  • Positive z-score → clusters co-localize more than random (spatially adjacent)
  • Negative z-score → clusters are spatially segregated (avoid each other)
  • Significance is assessed by a permutation test (1,000 iterations by default)

The permutation shuffles cluster labels and recomputes adjacency counts each time, building a null distribution. Your observed count is then standardized against that distribution.

Pipeline

The analysis requires spatial coordinates (for the neighbor graph) and cluster labels. We run the standard Scanpy preprocessing first:

import squidpy as sq
import scanpy as sc

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

# Preprocessing
# 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")   # requires: pip install leidenalg

# Spatial graph + neighborhood enrichment
sq.gr.spatial_neighbors(adata)
sq.gr.nhood_enrichment(adata, cluster_key="cluster")

# Visualize
sq.pl.nhood_enrichment(
    adata,
    cluster_key="cluster",
    title="Neighborhood enrichment (Leiden clusters)"
)
sq.pl.spatial_scatter(adata, color="cluster", title="Leiden clusters on tissue")

Note: sq.gr.spatial_neighbors builds a hexagonal neighbor graph from spot coordinates. sq.gr.nhood_enrichment stores results in adata.uns["cluster_nhood_enrichment"] as both z-scores and raw counts.

Result: Leiden cluster layout on tissue

We get 15 Leiden clusters (0–14) across 2,688 spots. The spatial scatter plot shows how they map onto the tissue section:

Leiden clusters mapped onto mouse brain Visium tissue

Figure 1. Leiden clusters on the Visium mouse brain section. Distinct anatomical regions (cortex, hippocampus, thalamus, white matter) form spatially coherent cluster zones.

The clustering captures visible tissue architecture — cortical layers form a continuous band, hippocampal subfields cluster together, and smaller subcortical nuclei appear as isolated patches. This spatial coherence is a good sign before running neighborhood enrichment.

Result: Neighborhood enrichment heatmap

Neighborhood enrichment z-score heatmap for 15 Leiden clusters

Figure 2. Neighborhood enrichment heatmap. The diagonal (self-enrichment) dominates, but off-diagonal hot spots reveal which distinct clusters spatially border each other.

Reading the heatmap

The diagonal is always strongly positive — every cluster borders itself most. The interesting biology is in the off-diagonal:

Cluster pair z-score Interpretation
12 ↔ 14 +7.59 Strongest co-localization — likely adjacent subcortical zones
5 ↔ 14 +4.62 Cluster 14 (smallest, 31 spots) is a boundary cluster between 5 and 12
10 ↔ 11 +2.49 Moderate co-localization
7 ↔ 13 +2.38 Moderate co-localization
Most other pairs −3 to −15 Spatial segregation — distinct anatomical compartments

Cluster 14 is the smallest cluster (only 31 spots) and shows strong enrichment with both 12 and 5 — this pattern is typical of a narrow interface or transitional zone sitting at the boundary between two larger tissue compartments.

The widespread negative z-scores reflect the highly structured anatomy of the mouse brain: cortical, hippocampal, and subcortical regions are spatially segregated at this resolution.

Accessing the raw z-scores

import numpy as np
import pandas as pd

zscore = adata.uns["cluster_nhood_enrichment"]["zscore"]
n = zscore.shape[0]
rows = []
for i in range(n):
    for j in range(i+1, n):
        rows.append(dict(cluster_i=i, cluster_j=j, zscore=zscore[i, j]))

df = pd.DataFrame(rows).sort_values("zscore", ascending=False)
print(df.head(10))

This extracts every off-diagonal pair ranked by enrichment, which is useful for downstream annotation when you want to know which clusters to examine as boundary zones.

Key takeaways

  • sq.gr.nhood_enrichment requires both spatial neighbors (from sq.gr.spatial_neighbors) and cluster labels — run them in that order.
  • The permutation z-score is robust: values above +2 or below −2 are generally meaningful.
  • Small clusters with high off-diagonal enrichment often mark tissue boundaries or transitional zones.
  • Combining the heatmap with sq.pl.spatial_scatter is essential — the numbers only make sense once you see where clusters sit on the tissue.

In the next post, we'll look at co-occurrence analysis (sq.gr.co_occurrence), which extends this idea to continuous distance scales rather than immediate neighbors.

Next post: Co-occurrence analysis (sq.gr.co_occurrence)

This series covers spatial transcriptomics analysis step by step — from SVGs to cell-cell interactions. New posts go up roughly once a week.

Follow on Blogger to get notified, or bookmark github.com/Lociven/spatiabio-tutorials for the notebooks.

z-score interpretation, in depth

n_perms and other things this post glossed over

Pack 1's neighborhood enrichment notebook goes deeper on permutation count, z-score interpretation, and when the test misleads you.

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