Skip to content

Leiden 1.0.0 - #9

Open
ianfd wants to merge 13 commits into
mainfrom
leiden-0.7.0
Open

Leiden 1.0.0#9
ianfd wants to merge 13 commits into
mainfrom
leiden-0.7.0

Conversation

@ianfd

@ianfd ianfd commented Aug 2, 2026

Copy link
Copy Markdown
Member

No description provided.

Ian added 13 commits August 1, 2026 15:56
Spots and bins sit on a known grid, so neighbours are integer arithmetic — no
spatial index, no distances, nothing approximate. Sort by (row, col), build a
row directory, and each neighbour is a binary search inside one row.

Offsets verified against 10x's Space Ranger docs and by replicating squidpy's
algorithm: Visium is doubled coordinates, (row + col) constant parity, six
neighbours at (row, col±2) and (row±1, col±1). Documented capture area (78 rows
x 64 spots = 4992) is a test case.

Two traps this defends against:

- squidpy ignores the lattice and runs Euclidean kNN on pixel coordinates with a
  median*1.3 cutoff. Exact on contiguous tissue, but the cutoff is global, so at
  50% lattice occupancy about half its edges are not lattice-adjacent. Working
  from the integers has no such failure mode.
- raw doubled coordinates are anisotropic (in-row 2 units, diagonal sqrt 2), so
  metric methods silently drop the in-row neighbours and return a diagonal-only
  graph. visium_isometric_coords() rescales so all six sit at the pitch, and
  passing offset coordinates instead of doubled is now a hard error rather than
  a wrong graph.

Builds CSR directly in two passes; an edge list first would cost ~700 MB at HD
scale. 10.5M bins / 21.1M edges in 954ms, 591 MB.

18 tests: exact adjacency on small grids, closed-form edge counts, interior and
hand-computed boundary degrees, parity, tissue holes, permutation invariance,
symmetry, u32::MAX coordinates. Plus the load-bearing one — Leiden on a lattice
produces zero disconnected communities, i.e. spatially contiguous domains.
Point-based assays (Xenium, MERFISH, CosMx, Slide-seq) position cells rather
than slotting them into a grid, so neighbours have to be searched for.

Both builders run over a uniform-grid index rather than a kd-tree. That started
as a performance choice — tissue is close to uniformly dense, so bucketing beats
traversal — but became a correctness one. kiddo 5.2.2 is unusable here on two
counts, both found by tests in this commit:

- KdTree panics outright past 32 points sharing a coordinate on one axis. Every
  Visium row is 64 spots at one y; every HD row is thousands.
- ImmutableKdTree returns neighbour ids that do not match the coordinates it
  measured. Querying a collinear run, it reported distance 0 for a point five
  units away: the distances are right for the true neighbours, the ids are not.
  A graph built from it joins each cell to someone else's neighbours — wrong,
  and wrong in a way that still looks like a plausible clustering.

The grid has no such failure modes. Duplicates and collinear runs are ordinary
inputs, everything is exact, and it needs no dependency at all, so the spatial
builders are unconditional rather than feature-gated.

Radius is symmetric by construction. kNN is not, so Symmetry::Union (scanpy's
convention, degree >= k) or Intersection (mutual only, degree <= k) reconciles
it explicitly — emitting both directions and letting construction merge them
would silently give mutual pairs twice the weight. Ties break on the lower
index, so pixel-rounded coordinates still give a reproducible graph.

Verified against O(n^2) brute force across seven degenerate point sets
(collinear, all-identical, duplicated pairs, dense spike, far clumps) x three
radii and three k. 500k cells: knn(6) 299ms, radius(30) 80ms.
Parameter-free neighbourhoods: no radius, no k, and the triangulation adapts to
local density on its own. Verified exactly via Euler — a triangulation of n
points with h on the convex hull has precisely 3n - 3 - h edges.

A triangulation covers the convex hull, so it invents edges across ventricles,
concave boundaries and between detached fragments. Joining two sides of a hole
is exactly the error that yields a plausible-looking meaningless domain, so
pruning is not optional.

HullPruning::Adaptive drops an edge longer than `factor` times the local edge
scale at *both* endpoints. Three details, each measured rather than assumed:

- local scale is the 25th percentile of incident edge lengths, not the median.
  A node on a hole rim has several edges spanning the void, and those inflate
  its median until the artefact looks normal — the statistic hides what it is
  meant to expose. At the median, 6 of 42 fragment bridges survived at any
  factor.
- compares against max of the two scales, not min. With min, a genuine boundary
  between a dense nest and loose stroma keeps only 19-40% of its edges; with
  max, 92-95%.
- factor defaults to 2.0: the largest value that fully separates two detached
  fragments, the one unambiguous requirement. Costs ~5% of edges on uniform
  points, less on real tissue. It will not clear a hole 2-3 cells across; that
  needs ~1.5 and costs 16% everywhere, and at that width it is arguable whether
  the cells either side are neighbours anyway.

Gabriel and relative-neighbourhood graphs are not the answer despite being
parameter-free: their emptiness tests pass for a hole, so Gabriel leaves 19 of
42 fragment bridges and drops 21% of real edges, RNG drops 52%.

Degenerate input (collinear, all-coincident) is a hard error rather than an
edgeless graph, which would cluster into singletons and look like a result.

11 tests including Euler exactness, hole clearance, fragment separation,
density-boundary preservation, permutation invariance, and contiguity of Leiden
domains on the result.
The adaptive rule turns out to be Zahn's 1971 inconsistent-edge criterion:
delete an edge "significantly larger than the average of nearby edge weights on
both sides", which is the same both-endpoints shape used here. He reports "a
factor of 2 usually means the separation is quite apparent", with worked
examples from 1.3 to 2.6 — so the default of 2.0, arrived at by measurement, is
also the canonical value.

Records his limitation too, since it carries over unchanged: the criterion
"doesn't detect one-way gradients however steep", so a smoothly ramping density
gives it no discontinuity to find.

Also notes what it would take to add Gabriel/RNG later: Lingas (1994) extracts
either from an existing triangulation in O(n), and the exclusion region must be
open, since the closed variant disconnects on ties and gridded coordinates are
made of ties.
The existing tests prove each builder produces the graph it claims to. These ask
the different question: does what comes out survive what real data does to you —
cells missed by segmentation, centroids off by a fraction of a cell, a parameter
picked slightly differently.

Writing them turned up a limitation worth stating plainly. A bare spatial graph
carries no expression, so the only thing it can recover is spatial structure.
Physical separation it gets exactly: detached fragments and masked-apart tissue
come back as connected components, with no resolution to choose, unchanged by
30% subsampling, by coordinate jitter, and across every parameter tried.

Boundaries *inside* continuous tissue it does not get. There is nothing for the
objective to anchor to, so it returns a tiling whose blob size the resolution
sets. The tiling is spatially contiguous and looks exactly like a result, and it
is arbitrary — resample and the boundaries move. My first attempt at these tests
assumed otherwise and asserted domain recovery on planted density nests; it came
back at ARI 0.09, which is the correct answer to the wrong question.

So the file now asserts the limitation too: on homogeneous tissue the tiling must
*fail* to reproduce under resampling. If that ever starts holding up, the
assertion fires and says the docs need revisiting.

9 tests. The meaningful guarantee across resolutions is that a domain may
subdivide an island but never spans two.
Written before implementing spatial domain detection so the target cannot drift
to meet the result.

Target: median ARI 0.46-0.52 across the 12 sections, against a non-spatial
baseline of 0.38-0.43. The range is a real disagreement, not imprecision —
BANKSY self-reports 0.518 from its own deposited artifacts, while the Genome
Biology benchmark puts it near 0.46 running the same method on the same
sections. Take the lower one.

Records the independent medians for ten methods, the protocol rule that decides
comparability (median ARI over resolutions yielding the correct cluster count,
not best-of-sweep), and two traps: BANKSY's published ARIs are on smoothed
labels via a SmoothLabels call visible only in its deposited code and never
mentioned in the paper, and configuration can swamp method differences — DeepST
scores 0.538 and 0.229 on the same section in two independent benchmarks.

Also pins BANKSY's Visium parameters, including that lambda is 0.2 for Visium
domain segmentation rather than the 0.8 used elsewhere, and that use_agf=TRUE
means mean + gradient, so a mean-only implementation is not a faithful
replication.
Fills in the self-reported vs independent comparison, which is the part that
decides what to trust:

  GraphST    151673   self 0.635  indep 0.633/0.638   gap ~0.00
  BayesSpace 151673   self 0.55   indep 0.550         gap  0.00
  STAGATE    151676   self 0.60   indep 0.493         gap  0.11
  BANKSY     12-slice self 0.518  indep 0.469         gap  0.05

Two of four survive independent replication. BayesSpace also disagrees between
two independent benchmarks (0.550 vs ~0.40 on the same section), so independence
alone does not settle it either.

Verifies from code what no paper states: sample 2 (151669-151672) is annotated
L3-L6 + WM, so 5 clusters, the other eight sections 7. Also that the benchmark
drops unannotated spots before clustering rather than before scoring — GraphST's
own tutorial does the opposite, letting them train the model and vote in spatial
refinement, and hardcodes 7 clusters for every section including the 5-cluster
ones.

Expands the independent table to 15 methods and marks which are machine-readable
from the benchmark's raw per-run files versus recovered from the figure. BANKSY
is figure-derived and new in the published version, so 0.469 carries +/-0.01 and
is not an exact published value.
…ence

Augments each cell's expression with a summary of its neighbourhood so ordinary
Leiden finds spatial domains. Verified differentially against banksy-py's own
functions via committed fixtures, not against a reading of the paper — which
matters, because four details are wrong if you transcribe from the paper, and
three of them I got wrong first time.

- The lambda budget is not split evenly across harmonics. Each successive
  harmonic gets half the weight of the one before, so at max_m=1 the mean takes
  2/3 of lambda and the gradient 1/3, not half each.
- Every block is z-scored per column before scaling, so lambda mixes
  standardised blocks. Without it the block with larger variance dominates
  regardless of lambda.
- The gradient term subtracts its own neighbourhood mean before the phase sum.
  This lives in the reference's matrix builder, not its weight construction, so
  a matmul against the weights silently omits it — my first fixtures did exactly
  that and would have passed against a wrong reference.
- The neighbourhood size differs per harmonic, and the two references disagree
  about how: R uses k_geom[m] (18 for both on DLPFC), Python multiplies
  internally as k*(m+1) (so 18 and 36). R produced the published numbers, so k
  is explicit per harmonic here rather than derived.

Also documents, from source: the scaled_gaussian kernel has no factor of 2 in
its exponent; R and Python disagree on the ranked kernel, which additionally
crashes for m>0 in Python; and the reference stores azimuths as float32, which
is why the fixtures agree to 1e-6 rather than tighter.

5 differential tests over 5 decay kernels, harmonics 0-2, lambda 0 to 1.
Completes the spatial layer.

fuse() blends an expression graph with a spatial one over the same cells,
alpha*a + (1-alpha)*b, as the union of both edge sets. Normalisation::TotalWeight
scales each to unit total weight first, and is the default because without it the
graph with heavier weights wins at every alpha and the parameter stops meaning
anything — the same failure the BANKSY blocks avoid by z-scoring. There is a test
that fusing a graph with a 1000x-scaled copy of itself gives back uniform weights
when normalised, and is dominated when not.

per_sample() runs a builder separately within each slice. Building globally and
deleting cross-sample edges afterwards is not equivalent for kNN: a cell at a
section's edge loses neighbours instead of taking k from its own section. There
is a test asserting both halves of that — every cell keeps full degree under
per_sample, and the filter-afterwards approach demonstrably starves some. It
matters because sections are routinely stored overlapping in one coordinate
frame, so cells from different slices can sit arbitrarily close.

8 tests including endpoint recovery, edge-set union, symmetry, sample labels
being arbitrary, and single-cell samples.
Adds what the crate has actually been measured against, with the comparison
named in each case rather than left implicit.

Accuracy: 160 committed leidenalg fixtures (-0.09% mean modularity single-seed,
+0.42% best-of-2), igraph (+0.03%), exhaustive brute force (93.5% exact optimum
against igraph's 94.4-96.3%), banksy-py (elementwise to 1e-6), and PBMC3k where
cluster counts track scanpy within one at every resolution and agreement with
the authors' cell types is 0.8599 against scanpy's 0.8609.

States plainly that the leidenalg mean is noise — both are stochastic heuristics
and leidenalg's own two fixture seeds differ by up to 3.5% on hard instances —
and that what the fixtures pin exactly is the quality function's definition, not
the optimiser's luck.

Performance is framed against scanpy/igraph as a C implementation called from
Python, not as an interpreter-overhead comparison: 2-3x on PBMC3k, 1.33x
geometric mean on synthetic graphs.

Also records what is *not* measured: spatial domain detection has no comparative
result yet, so nothing in the README should be read as a claim about it, and
points at the pre-registered benchmark target.
The #[ignore]d tests were running nowhere. Three of the four are real coverage,
not diagnostics: the Visium HD scale run is the only thing exercising the
10.5M-bin path, the Xenium run the only 500k-cell one, and the exhaustive
optimality sweep over Bell(12) is the strongest evidence the optimiser actually
reaches optima.

They cost 11 seconds together, so there was no reason for the gap beyond nobody
having added the job.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant