Skip to content

spatial_neigbours extensibility features and clarification#1147

Open
selmanozleyen wants to merge 63 commits intoscverse:mainfrom
selmanozleyen:feat/spatial_neighbours
Open

spatial_neigbours extensibility features and clarification#1147
selmanozleyen wants to merge 63 commits intoscverse:mainfrom
selmanozleyen:feat/spatial_neighbours

Conversation

@selmanozleyen
Copy link
Copy Markdown
Member

@selmanozleyen selmanozleyen commented Apr 2, 2026

fixes: #1102 and #1047

It's backward compatible and I am curious what the community might bring to this!

  • I clarified the docs on argument precedence
  • Added extensibility features
  • Added tests to ensure the function behaves as written in the docs
  • No breaking changes

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 2, 2026

Codecov Report

❌ Patch coverage is 87.46518% with 45 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.82%. Comparing base (373228d) to head (13768e2).

Files with missing lines Patch % Lines
src/squidpy/gr/_build.py 80.41% 24 Missing and 4 partials ⚠️
src/squidpy/gr/neighbors.py 92.01% 11 Missing and 6 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1147      +/-   ##
==========================================
+ Coverage   73.56%   73.82%   +0.26%     
==========================================
  Files          44       45       +1     
  Lines        6929     7098     +169     
  Branches     1174     1178       +4     
==========================================
+ Hits         5097     5240     +143     
- Misses       1347     1369      +22     
- Partials      485      489       +4     
Files with missing lines Coverage Δ
src/squidpy/_docs.py 94.44% <100.00%> (+0.24%) ⬆️
src/squidpy/gr/neighbors.py 92.01% <92.01%> (ø)
src/squidpy/gr/_build.py 81.21% <80.41%> (-7.31%) ⬇️
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@selmanozleyen selmanozleyen requested a review from grst April 2, 2026 12:30
@selmanozleyen selmanozleyen requested a review from timtreis April 2, 2026 12:53
@selmanozleyen selmanozleyen self-assigned this Apr 2, 2026
Copy link
Copy Markdown
Contributor

@grst grst left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @selmanozleyen, that goes in the right direction. I put up some design questions up for discussion!

Comment thread src/squidpy/gr/_build.py Outdated
Comment thread src/squidpy/gr/_build.py
Comment thread src/squidpy/gr/neighbors.py Outdated
Comment thread src/squidpy/gr/neighbors.py Outdated
Comment thread src/squidpy/gr/neighbors.py Outdated
Comment thread src/squidpy/gr/neighbors.py Outdated
Comment thread src/squidpy/gr/neighbors.py Outdated
Comment thread src/squidpy/gr/neighbors.py Outdated
Comment thread src/squidpy/gr/_build.py Outdated
Comment thread src/squidpy/gr/_build.py Outdated
Comment thread src/squidpy/gr/_build.py Outdated
Comment thread src/squidpy/gr/_build.py

Notes
-----
``spatial_neighbors`` has 4 graph-construction modes:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have a good explanation of these somewhere?

Comment thread src/squidpy/gr/_build.py
"`spatial_neighbors_delaunay`, `spatial_neighbors_grid`, or "
"`spatial_neighbors_from_builder` instead.",
FutureWarning,
stacklevel=2,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think your other future warning was stacklevel=3, how do you decide this?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stacklevel 2: user code -> spatial_neighbors() -> warnings.warn(...)

vs

stacklevel 3: user code -> spatial_neighbors() -> _resolve_graph_builder() -> warnings.warn(...)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have this written down somewhere? I haven't really thought about this yet so I wonder if we should add that to some "how to Squidpy" doc somewhere?

Comment thread src/squidpy/gr/_build.py Outdated
}
@d.dedent
def spatial_neighbors_from_builder(
adata: AnnData | SpatialData,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see other comment

Comment thread src/squidpy/gr/_build.py Outdated


def _prepare_spatial_neighbors_input(
adata: AnnData | SpatialData,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see other comment

Comment thread src/squidpy/gr/_build.py
def _prepare_spatial_neighbors_input(
adata: AnnData | SpatialData,
*,
spatial_key: str,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Align annotations across functions

Comment thread src/squidpy/gr/_build.py Outdated
Return D^{-1/2} * A * D^{-1/2}, where D = diag(degrees(A)) and A = adj.
@d.dedent
def spatial_neighbors_grid(
adata: AnnData | SpatialData,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see other comment


def __init__(
self,
radius: float | tuple[float, float] | None = None,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this used?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes by _filter_by_radius_interval. If I did this from scratch maybe I'd make it more complicated by having this filtering as a separate transform but this feels more appropiate for old implementation

@@ -0,0 +1,440 @@
"""Graph construction strategies for spatial neighbor graphs.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Quite some duplicates code, can you use a factory or prototcol instead?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if I agree. What exactly do you think needs deduplication here?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. The two typevars here aren't modified anywhere. It could just be an ABC.

  2. apply_percentile is the only function using the coord_type. Since at the point of _resolve_graph_builder we decide on the downstream path, we don't need that guard. With that guard removed, the coord_type becomes superflous and would further simplify the class.

  3. Both apply_filters are identical, could be deduplicated by moving it into build of the ABC.

  4. KNNBuilder.build_graph and RadiusBuilder.build_graph share quite some overlap in building their nn-graphs. Just the delauny stuff would need to be routed differently.

but I guess the biggest win would be removing the non-CSR build option? Is this sth we'll realistically ever encounter?

Copy link
Copy Markdown
Member

@timtreis timtreis Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The factory/protocol comment doesn't make much sense in hind-sight after having gone through it in detail - still, quite some opportunity to remove LOCs

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but I guess the biggest win would be removing the non-CSR build option?

I'd like to also give possibility to support cupy sparse or whatever sparse types jax might have for example

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But I noticed I use scipy operations also in the builder path so let me just have a second look.

selmanozleyen and others added 16 commits April 13, 2026 07:46
* [pre-commit.ci] pre-commit autoupdate

updates:
- [github.com/biomejs/pre-commit: v2.4.9 → v2.4.10](biomejs/pre-commit@v2.4.9...v2.4.10)
- [github.com/tox-dev/pyproject-fmt: v2.20.0 → v2.21.0](tox-dev/pyproject-fmt@v2.20.0...v2.21.0)
- [github.com/astral-sh/ruff-pre-commit: v0.15.8 → v0.15.9](astral-sh/ruff-pre-commit@v0.15.8...v0.15.9)

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
@selmanozleyen selmanozleyen requested a review from timtreis April 13, 2026 22:33
Copy link
Copy Markdown

@shashkat shashkat left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks amazing to me now! Just a few more minor suggestions. I really liked the idea of using specialized Postprocessor classes!

Comment thread src/squidpy/gr/_build.py
Comment on lines 709 to +713
n_neighs
Number of neighboring tiles. Defaults to ``6``.
Number of neighboring tiles used to form the base grid connectivity.
Defaults to ``6``. On a Visium-like hexagonal grid, ``6`` corresponds to
the immediate surrounding spots, while smaller values such as ``3`` make
the first-ring graph deliberately sparser.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it would be useful to mention that there is no guarantee of what subset of neighbors would be chosen from a given ring, if n_neighs is less than the actual number of neighbors of a cell in the grid.
Just like this snippet from the latest-commit documentation of spatial_neighbors function:

        - values such as ``n_neighs=4`` and ``n_neighs=6`` are the
          intended square-grid and hex-grid choices, respectively;
        - other values are accepted for backward compatibility, but
          their geometric interpretation is not guaranteed to match a
          continuous ring on the grid;
        - no clockwise or other within-ring ordering is part of the
          public API.

Comment on lines +85 to +87
def postprocessors(self) -> Sequence[GraphPostprocessor[GraphMatrixT]]:
"""Return post-build processing steps for ``(adj, dst)``."""
return self._postprocessors
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure if this method is offering any functionality.
We can possibly call the attribute _postprocessors as postprocessors and replace line 77 from for postprocessor in self.postprocessors(): to for postprocessor in self.postprocessors:.
What I understand is that this method can allow one to reorder or filter out the entities in _postprocessors attribute. But that can anyways be done when the postprocessors entities are created in the init method of a subclass of GraphBuilder.

Comment on lines 63 to +73
def __init__(
self,
transform: str | Transform | None = None,
set_diag: bool = False,
percentile: float | None = None,
postprocessors: Sequence[GraphPostprocessor[GraphMatrixT]] = (),
) -> None:
self.transform = Transform.NONE if transform is None else Transform(transform)
self.set_diag = set_diag
self.percentile = percentile
self._postprocessors: list[GraphPostprocessor[GraphMatrixT]] = list(postprocessors)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the transform argument used here? I think the transformation requirements would be encoded in postprocessors anyways, so we can remove it.

Comment on lines +261 to +262
if isinstance(radius, tuple):
postprocessors.append(DistanceIntervalPostprocessor(tuple(sorted(radius))))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If someone passes a single float or int to radius, DelaunayBuilder doesn't do anything with it I believe. Only if radius is a tuple[float, float], does it add the filter. I think we can fix this by doing something like the following.
change this:

if isinstance(radius, tuple):
    postprocessors.append(DistanceIntervalPostprocessor(tuple(sorted(radius))))

to this:

if isinstance(radius, (int, float)):
    radius = (0, radius)
postprocessors.append(DistanceIntervalPostprocessor(tuple(sorted(radius))))

Then, we can also change the type suggestion for radius in spatial_neighbors_delaunay to float | tuple[float, float].

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.

Documentation for sq.gr.spatial_neighbors() likely needs more clarification about the interplay of arguments

4 participants