spatial-transcriptomics/spatial-neighbors/SKILL.md
Build the spatial neighbor graph that every downstream spatial statistic (Moran's I, neighborhood enrichment, co-occurrence, spatial domains) inherits, using Squidpy. Use when choosing the graph type (kNN vs Delaunay vs fixed-radius vs Visium hex grid) and understanding why it silently changes every downstream result; handling variable cell density (kNN fixes neighbor COUNT, fixed-radius fixes physical DISTANCE -- each distorts the other); getting coordinate units right (pixels vs microns; Visium array coords are not distance); pruning Delaunay long edges across tissue gaps; running the graph sensitivity analysis almost nobody runs; and knowing when planar section neighbors misrepresent a 3D tissue.
npx skillsauth add GPTomics/bioSkills bio-spatial-transcriptomics-spatial-neighborsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Reference examples tested with: squidpy 1.4+, scanpy 1.10+, anndata 0.10+, numpy 1.26+
Before using code patterns, verify installed versions match. If versions differ:
pip show <package> then help(module.function) to check signaturesIf code throws ImportError, AttributeError, or TypeError, introspect the installed package and adapt the example to match the actual API rather than retrying.
"Build a spatial neighbor graph for my tissue" -> Define which cells or spots count as spatial neighbors, encoded as a sparse weights matrix W over adata.obsm['spatial'].
squidpy.gr.spatial_neighbors() -> writes adata.obsp['spatial_connectivities'] and adata.obsp['spatial_distances']The platform-class fork sets the default geometry. Sequencing/capture data on a fixed lattice (Visium hex, Visium HD grid) has a KNOWN adjacency -> use coord_type='grid'. Imaging/in-situ point clouds (Xenium, MERFISH, CosMx) have irregular cell positions -> use coord_type='generic' with Delaunay or kNN. The first question is always which side of the fork the data is on, because it decides whether "neighbor" is a lattice fact or a modeling choice.
The graph is the model that all spatial statistics inherit. Moran's I, Geary's C, neighborhood enrichment, co-occurrence, and every graph-neural-network spatial-domain method are functions f(expression, W) of the spatial weights matrix W -- the graph is not preprocessing, it is literally an argument to the statistic. Change k from 6 to 30, switch Delaunay to kNN, or row-standardize W instead of leaving it binary, and the Moran's I value, the enrichment z-scores, the SVG ranking, and the domain boundaries all move. The analyst is never measuring "spatial structure"; they are measuring spatial structure as seen through one particular definition of adjacency.
This is the most under-reported researcher degree of freedom in the field, and it has a name geographers settled decades ago: the modifiable areal unit problem -- aggregate or re-adjacency the units and the answer changes. There is no canonical W. The honest workflow therefore does what almost no paper does: build the graph under at least two definitions, rerun the downstream statistic, and report which genes/pairs/domains are graph-robust versus graph-fragile. A result that survives only one graph choice is a result about that graph, not about the tissue.
Two failure directions bound the choice. Too dense a graph (large k, large radius, many rings) over-smooths -- it inflates apparent autocorrelation, washes out local detail, and merges distinct domains into blobs. Too sparse a graph fragments the tissue into disconnected components, flags spurious local outliers, and misses real medium-scale structure. The right density is the one whose downstream conclusion is stable; the specific k is not the deliverable, the stability across k is.
Each family silently assumes something different about the tissue, and that assumption -- not the algorithm -- is what fails.
| Graph type | Degree behavior | Density bias | Best when | Fails when |
|---|---|---|---|---|
| Visium hex grid (coord_type='grid', n_rings) | Fixed 6 per ring; known lattice | None (regular lattice) | Visium / capture grids where geometry is fixed and exact | A spot is treated as a cell -- it is a 1-10-cell mixture, so spot adjacency mixes deconvolution error with real contact |
| kNN (coord_type='generic', n_neighs) | Constant COUNT k | Radius implicitly stretches in sparse regions, connecting distant cells | Single-cell platforms (Xenium/MERFISH/CosMx) where fixed degree is wanted | Density varies sharply; asymmetric by default (A is B's neighbor but not vice versa) |
| Fixed-radius (radius=r) | Variable -- more neighbors where dense | STRONG: dense regions get more neighbors of EVERYTHING -> inflated enrichment that is pure density artifact | A real physical interaction range exists (ligand diffusion ~tens of microns) AND density is ~uniform | Density gradients; radius set in the wrong coordinate unit |
| Delaunay (delaunay=True) | Variable; parameter-free "who touches whom" | Mild | Single-cell data wanting a parameter-free contact graph | Tissue has gaps/holes/folds -> long spurious edges leap across empty space; needs distance pruning |
Three points the naive analyst misses. squidpy.gr.nhood_enrichment builds NO graph of its own -- it consumes whatever graph spatial_neighbors already stored in obsp (and errors if none exists); the Squidpy non-grid default is kNN with n_neighs=6 (delaunay=False), so a published z-score is specific to whichever graph produced it and would change under a different graph -- always know which graph produced the number. kNN is asymmetric; "mutual kNN" (edge only if both cells are in each other's k-set) is more conservative and stops hub cells in dense regions from dominating. Unpruned Delaunay over tissue with necrotic holes or folds connects cells micrometers apart on the slide but biologically unrelated -- pruning by a max edge length is the standard fix.
A fixed-radius neighborhood gives dense regions more neighbors and sparse regions fewer. Because neighborhood enrichment, co-occurrence, and local statistics all depend on neighbor COUNTS, a pure density gradient masquerades as biological signal: a dense lymphoid follicle gets inflated "enrichment" of everything simply because every cell there has more neighbors. kNN fixes the count (it adapts the radius to local density) but then distorts physical distance -- a "neighbor" in sparse stroma may sit far away. The density structure of the tissue dictates which distortion is tolerable: use kNN/Delaunay when density varies (the common case in real tissue); reserve fixed-radius for roughly uniform tissue where an absolute physical interaction range is the actual biological question.
A radius, a co-occurrence interval, and a Delaunay pruning cutoff are all in PHYSICAL distance units. If adata.obsm['spatial'] holds pixels, array row/col indices, or arbitrary units, a "50-unit radius" is silently meaningless. Visium array row/col is a lattice index, not microns; full-resolution Visium pixel coordinates need the Space Ranger scale factor (spot_diameter_fullres, tissue_hires_scalef) to convert to physical distance. Imaging platforms store microns or pixels depending on the reader. Confirm the unit BEFORE setting any distance parameter -- the single cheapest check is to measure nearest-neighbor spacing and compare it to the known platform pitch (Visium ~100 microns center-to-center).
Goal: Confirm the coordinate unit so that any radius is physically meaningful.
Approach: Measure median nearest-neighbor distance from a temporary kNN graph and compare it to the known platform geometry; a Visium grid in microns should read ~100, in pixels it reads hundreds-to-thousands.
import squidpy as sq
import scanpy as sc
import numpy as np
sq.gr.spatial_neighbors(adata, coord_type='generic', n_neighs=1) # nearest neighbor only, just to read spacing
nn = adata.obsp['spatial_distances'].data
print(f'median nearest-neighbor spacing: {np.median(nn):.1f} units')
# Visium pitch is ~100 microns; a value of hundreds-to-thousands means coords are in PIXELS -> rescale or use grid mode
Goal: Construct the adjacency that matches the platform geometry rather than a one-size default.
Approach: Use grid mode for Visium hex (the lattice is exact and known); use generic Delaunay or kNN for imaging point clouds; store under named keys so multiple graphs coexist for the sensitivity check below.
# Visium hex lattice: 6 immediate neighbors per ring; n_rings=2 widens the neighborhood deliberately
sq.gr.spatial_neighbors(adata, coord_type='grid', n_neighs=6, n_rings=1, key_added='visium_hex')
# Imaging point cloud, fixed-degree: constant k, radius adapts to local density
sq.gr.spatial_neighbors(adata, coord_type='generic', n_neighs=10, key_added='knn10')
# Imaging point cloud, parameter-free contact graph (delaunay=True is opt-in; the
# generic default is kNN with n_neighs=6)
sq.gr.spatial_neighbors(adata, coord_type='generic', delaunay=True, key_added='delaunay')
Goal: Stop Delaunay from inventing long-range "neighbors" that leap across necrotic holes, folds, or slide background.
Approach: Build Delaunay, then prune to a physically sensible maximum edge length using radius as a (min, max) interval -- edges longer than max (in microns) are dropped.
# radius as a (min, max) tuple prunes the graph to edges within that physical-distance interval;
# choose max from the tissue: a few cell diameters (e.g. 50 microns) kills cross-gap edges, keeps true contacts
sq.gr.spatial_neighbors(adata, coord_type='generic', delaunay=True, radius=(0.0, 50.0), key_added='delaunay_pruned')
pruned = adata.obsp['delaunay_pruned_connectivities']
print(f'edges after pruning: {pruned.nnz}; mean degree: {pruned.nnz / adata.n_obs:.1f}')
Goal: Decide whether a downstream conclusion is a property of the tissue or an artifact of the graph choice.
Approach: Build the graph under several adjacency definitions, store each under its own key, then recompute the downstream statistic on each and flag results that are not stable across graphs.
graphs = {}
for k in (6, 15, 30):
sq.gr.spatial_neighbors(adata, coord_type='generic', n_neighs=k, key_added=f'knn{k}')
graphs[f'knn{k}'] = adata.obsp[f'knn{k}_connectivities']
sq.gr.spatial_neighbors(adata, coord_type='generic', delaunay=True, key_added='delaunay')
graphs['delaunay'] = adata.obsp['delaunay_connectivities']
# Recompute the downstream statistic per graph (Moran's I shown); compare rankings, not single values.
# A gene/pair/domain that only appears under one graph is graph-fragile -- report it as such.
import scanpy as sc
for name, W in graphs.items():
adata.obsp['spatial_connectivities'] = W # spatial_autocorr reads the active 'spatial' graph
adata.obsp['spatial_distances'] = adata.obsp[f'{name}_distances'] if f'{name}_distances' in adata.obsp else adata.obsp['spatial_distances']
sq.gr.spatial_autocorr(adata, mode='moran', genes=adata.var_names[:50].tolist())
adata.uns[f'moranI_{name}'] = adata.uns['moranI'].copy()
Goal: Catch fragmentation (too sparse) and over-connection (too dense) before they corrupt every downstream number.
Approach: Summarize degree distribution and connected components; a healthy graph is one connected component with a tight degree distribution, not many islands or a few hub cells.
import numpy as np
conn = adata.obsp['spatial_connectivities']
degree = np.asarray((conn > 0).sum(axis=1)).ravel()
print(f'mean degree {degree.mean():.1f}; min {degree.min()}; max {degree.max()}')
# isolated cells (degree 0) signal fragmentation; a heavy max-degree tail signals density-driven hubs
print(f'isolated cells: {(degree == 0).sum()}')
A tissue section is one ~5-10 micron optical/physical plane of a three-dimensional organ. Two cells that are planar neighbors in the section may be far apart in the intact tissue, and two true 3D neighbors may sit in different sections and never appear adjacent in the graph. Cells truncated at the section's top or bottom surface carry partial transcript profiles (only the captured fraction of the cell), which depresses their counts and distorts their degree. Any neighbor graph built from a single section is a planar slice of the real 3D adjacency -- adequate for in-plane analysis, but it does not license 3D-contact claims. Reconstructing true 3D neighbors from serial sections (registration, z-stacking, alignment across planes) is a different problem that this graph does not solve. Layered, ducted, or crypted tissue is also anisotropic (covariance is direction-dependent), so an isotropic graph that ignores orientation underpowers directional structure -- a caveat to keep when neighbor counts feed directional or layer-aware statistics.
| Symptom | Cause | Fix |
|---|---|---|
| Every cell has wildly different neighbor counts; dense regions show "enrichment" of everything | Fixed-radius graph on density-varying tissue -- pure density artifact | Use kNN or Delaunay (constant or contact-based degree); reserve radius for ~uniform tissue with a real physical range |
| A radius of 50 captures all cells or none | adata.obsm['spatial'] is in pixels or array units, not microns | Confirm the unit (measure nearest-neighbor spacing vs platform pitch); apply the Visium scale factor or use coord_type='grid' |
| Long edges cross empty space / necrotic holes; spurious long-range neighbors | Unpruned Delaunay over tissue with gaps or folds | Prune with radius=(0, max) at a few cell diameters; inspect the overlaid graph |
| Visium neighbors look irregular instead of a clean hex lattice | coord_type='generic' used on a Visium grid | Use coord_type='grid' with n_rings; the lattice adjacency is exact and known |
| Downstream Moran's I / enrichment z-scores change when k is changed | Expected -- the statistic is f(expression, W); the graph is the model | Run the sensitivity analysis across k and Delaunay; report only graph-robust results |
| Graph splits into many connected components | Graph too sparse (k too small, radius too short) | Increase k or radius, or switch to Delaunay; check isolated-cell count |
| Spot-level neighborhood enrichment over-interpreted as cell-cell contact | A Visium spot is a 1-10-cell mixture, not a cell | Treat spot adjacency as spot-level; deconvolve (spatial-transcriptomics/spatial-deconvolution) before cell-level claims |
| 3D-contact conclusion drawn from one section | Planar neighbors are a slice of 3D adjacency; truncated cells have partial profiles | Restrict claims to in-plane; use serial-section reconstruction for true 3D neighbors |
tools
End-to-end CLIP-seq pipeline from FASTQ to ENCODE-compliant binding sites, single-nucleotide crosslink maps, annotation, motifs, and (optionally) differential binding. Use when running the full Yeo lab eCLIP / iCLIP / iCLIP2 / iCLIP3 / irCLIP / PAR-CLIP analysis with SMInput control, protocol-specific UMI extraction, ENCODE STAR parameters, CLIPper or Skipper peak calling with stringent log2 FC and -log10 p thresholds, IDR rescue and self-consistency QC, and downstream motif registration with mCross or PEKA.
development
Detect, date, and contextualize whole-genome duplication (WGD / paleopolyploidy) events using wgd v2 (Chen et al 2024), KsRates (Sensalari 2022 substitution-rate-corrected Ks dating), DupGen_finder (Qiao 2019), MAPS (Li 2018 phylogenomic), POInT (Conant 2008 ordered-block), SLEDGe (2024 ML-based), Whale.jl (Bayesian DL+WGD), and synteny-anchored paranome construction. Use when identifying ancient polyploidy from Ks distributions and synteny block analysis, positioning WGD events relative to speciation, distinguishing tandem from segmental from WGD duplications, dating the 2R/3R vertebrate / fish / salmonid WGDs, building paranome and Ks-age mixture models, applying KsRates substitution-rate correction across lineages, or testing alternative biased-fractionation / dosage-balance models post-WGD.
tools
Build whole-genome alignments using Progressive Cactus (Armstrong 2020 reference-free clade-level WGA), Minigraph-Cactus (Hickey 2024 pangenome-aware), LASTZ chain/net (UCSC pipeline), MUMmer4 (Marçais 2018 pairwise), minimap2 -x asm5/10/20 (Li 2018 fast pairwise), AnchorWave (Song 2022 WGD-aware), and Mauve / progressiveMauve (bacterial). Operates the HAL toolkit (Hickey 2013) for downstream extraction including halSynteny, halLiftover, halBranchMutations, and hal2maf. Use when constructing multi-species alignments for comparative-annotation projection (TOGA), synteny detection, conservation analyses (phyloP / PhastCons), or pangenome graph construction; selecting between reference-free (Cactus) and reference-anchored (LASTZ chains/nets) approaches; tuning sensitivity for closely vs distantly related genomes; or producing HAL files for genome-wide downstream tools.
development
Detect syntenic blocks and structural rearrangements between genomes using MCScanX (Wang 2012), JCVI/MCScan (Tang 2008 Python), GENESPACE (Lovell 2022) for orthology-anchored riparian visualization, SyRI for structural variation, AnchorWave for sequence-level synteny, i-ADHoRe 3.0 for highly diverged species, SynNet for synteny networks, and ntSynt for multi-genome macrosynteny. Use when identifying collinear gene blocks across species, distinguishing macrosynteny from microsynteny, detecting inversions/translocations/duplications, anchoring orthology in WGD lineages, producing publication riparian plots, computing synteny block age via Ks (cross-references whole-genome-duplication), or running synteny-aware ortholog inference in polyploids.