Skip to content

Commit 707fe78

Browse files
feat: support for directed graphs with igraph Leiden clustering (#4490)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent e099d9b commit 707fe78

5 files changed

Lines changed: 68 additions & 25 deletions

File tree

‎docs/release-notes/4490.feat.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Allow `directed=True` with `flavor="igraph"` in {func}`~scanpy.tl.leiden`, and default to it with {attr}`~scanpy.Preset.ScanpyV2Preview`; the `leiden` extra now requires `igraph>=1` and `leidenalg>=0.11` {smaller}`M Kuehl`

‎pyproject.toml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ bbknn = [ "bbknn" ]
8787
dask = [ "anndata[dask]", "dask[array]>=2024.10" ]
8888
# PCA acceleration
8989
dask-ml = [ "dask-ml", "scanpy[dask]" ]
90-
leiden = [ "igraph>=0.10.8", "leidenalg>=0.10.1" ]
90+
leiden = [ "igraph>=1", "leidenalg>=0.11" ]
9191
louvain = [ "igraph", "louvain>=0.8.2", "setuptools" ]
9292
magic = [ "magic-impute>=2.0.4" ]
9393
paga = [ "igraph" ]
@@ -101,7 +101,7 @@ scanpy2 = [
101101
"anndata>=0.13.3",
102102
"holoviews>=1.23.2",
103103
"hv-anndata>=0.0.3a5",
104-
"igraph>=0.10.8",
104+
"igraph>=1",
105105
"matplotlib>=3.11",
106106
"scanpy[illico]",
107107
"scikit-misc>=0.5.1",

‎src/scanpy/_settings/presets.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ class ScoreGenesPreset(NamedTuple):
113113

114114
class LeidenPreset(NamedTuple):
115115
flavor: LeidenFlavor
116+
directed: bool | None
116117

117118

118119
preset_postprocessors: list[Callable[[], None]] = []
@@ -289,10 +290,10 @@ def score_genes() -> Mapping[Preset, ScoreGenesPreset]:
289290

290291
@preset_property
291292
def leiden() -> Mapping[Preset, LeidenPreset]:
292-
"""Flavor for :func:`~scanpy.tl.leiden`."""
293+
"""Settings for :func:`~scanpy.tl.leiden`.""" # noqa: D401
293294
return {
294-
Preset.ScanpyV1: LeidenPreset(flavor="leidenalg"),
295-
Preset.ScanpyV2Preview: LeidenPreset(flavor="igraph"),
295+
Preset.ScanpyV1: LeidenPreset(flavor="leidenalg", directed=None),
296+
Preset.ScanpyV2Preview: LeidenPreset(flavor="igraph", directed=True),
296297
}
297298

298299
@contextmanager

‎src/scanpy/tools/_leiden.py‎

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
doc_rng,
2020
)
2121
from .._settings import Default
22+
from .._settings.presets import LeidenPreset
2223
from .._utils import _doc_params
2324
from .._utils.random import _accepts_legacy_random_state, _LegacyRng, _set_igraph_rng
2425
from ..get._out import _out_key, deprecated_copy
@@ -35,9 +36,9 @@
3536

3637
if TYPE_CHECKING:
3738
from collections.abc import Sequence
38-
from typing import Literal
3939

4040
from anndata import AnnData
41+
from igraph import Graph
4142

4243
from .._compat import CSBase
4344
from .._settings.presets import LeidenFlavor
@@ -85,7 +86,7 @@ def leiden( # noqa: PLR0913
8586
*,
8687
restrict_to: tuple[str, Sequence[str]] | None = None,
8788
rng: SeedLike | RNGLike | None = None,
88-
directed: bool | None = None,
89+
directed: bool | Default | None = Default(preset=("leiden", "directed")),
8990
use_weights: bool = True,
9091
n_iterations: int = -1,
9192
partition_type: type[MutableVertexPartition] | None = None,
@@ -123,6 +124,7 @@ def leiden( # noqa: PLR0913
123124
{restrict_to}
124125
directed
125126
Whether to treat the graph as directed or undirected.
127+
`None` means directed for `flavor='leidenalg'` and undirected for `flavor='igraph'`.
126128
use_weights
127129
If `True`, edge weights from the graph are used in the computation
128130
(placing more emphasis on stronger edges).
@@ -169,7 +171,9 @@ def leiden( # noqa: PLR0913
169171
to calculate a score independent of `flavor`.
170172
171173
"""
172-
flavor = _validate_flavor(flavor, partition_type=partition_type, directed=directed)
174+
flavor, directed = _validate_flavor(
175+
flavor, partition_type=partition_type, directed=directed
176+
)
173177
_utils.ensure_igraph()
174178
clustering_args = dict(clustering_args)
175179
rng = np.random.default_rng(rng)
@@ -205,7 +209,6 @@ def leiden( # noqa: PLR0913
205209

206210
if resolution is not None:
207211
clustering_args["resolution_parameter"] = resolution
208-
directed = True if directed is None else directed
209212
g = _utils.get_igraph_from_adjacency(adjacency, directed=directed)
210213
if partition_type is None:
211214
partition_type = leidenalg.RBConfigurationVertexPartition
@@ -222,12 +225,20 @@ def leiden( # noqa: PLR0913
222225
leidenalg.find_partition(g, partition_type, seed=seed, **clustering_args),
223226
)
224227
else:
225-
g = _utils.get_igraph_from_adjacency(adjacency, directed=False)
228+
g = _utils.get_igraph_from_adjacency(adjacency, directed=directed)
226229
if use_weights:
227230
clustering_args["weights"] = "weight"
228231
if resolution is not None:
229232
clustering_args["resolution"] = resolution
230233
clustering_args.setdefault("objective_function", "modularity")
234+
if (
235+
directed
236+
and clustering_args["objective_function"] == "modularity"
237+
and clustering_args.keys().isdisjoint({"node_weights", "node_in_weights"})
238+
):
239+
clustering_args |= _directed_node_weights(
240+
g, weights=clustering_args.get("weights")
241+
)
231242
with _set_igraph_rng(rng):
232243
part = g.community_leiden(**clustering_args)
233244
# store output into adata.obs
@@ -265,18 +276,30 @@ def leiden( # noqa: PLR0913
265276
return None
266277

267278

279+
def _directed_node_weights(
280+
g: Graph, *, weights: str | Sequence[float] | None
281+
) -> dict[str, list[float]]:
282+
"""Get in- and out-degrees as node weights; python-igraph otherwise only counts out-degrees."""
283+
return dict(
284+
node_weights=g.strength(mode="out", weights=weights),
285+
node_in_weights=g.strength(mode="in", weights=weights),
286+
)
287+
288+
268289
def _validate_flavor(
269-
flavor: str | None, *, partition_type: object | None, directed: bool | None
270-
) -> Literal["igraph", "leidenalg"]:
271-
if was_default := (flavor is None or isinstance(flavor, Default)):
272-
from scanpy import settings
290+
flavor: str | None,
291+
*,
292+
partition_type: object | None,
293+
directed: bool | Default | None,
294+
) -> LeidenPreset:
295+
from scanpy import settings
273296

297+
if was_default := (flavor is None or isinstance(flavor, Default)):
274298
flavor = settings.preset.leiden.flavor
299+
if isinstance(directed, Default):
300+
directed = settings.preset.leiden.directed
275301
match flavor:
276302
case "igraph":
277-
if directed:
278-
msg = "Cannot use igraph’s leiden implementation with a directed graph."
279-
raise ValueError(msg)
280303
if partition_type is not None:
281304
msg = "Do not pass in partition_type argument when using igraph."
282305
raise ValueError(msg)
@@ -288,8 +311,7 @@ def _validate_flavor(
288311
if was_default:
289312
msg += (
290313
"\nIn the future, the default backend for leiden will be igraph instead of leidenalg. "
291-
"To achieve the future defaults please pass: `flavor='igraph'` and `n_iterations=2`. "
292-
"`directed` must also be `False` to work with igraph’s implementation."
314+
"To achieve the future defaults please pass: `flavor='igraph'` and `n_iterations=2`."
293315
)
294316
warn(msg, FutureWarning if was_default else UserWarning)
295317
try:
@@ -302,4 +324,6 @@ def _validate_flavor(
302324
case _:
303325
msg = f"flavor must be either 'igraph' or 'leidenalg', but {flavor!r} was passed."
304326
raise ValueError(msg)
305-
return flavor
327+
if directed is None:
328+
directed = flavor == "leidenalg"
329+
return LeidenPreset(flavor, directed)

‎tests/test_clustering.py‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
from functools import partial
55
from typing import TYPE_CHECKING
66

7+
import numpy as np
78
import pandas as pd
89
import pytest
10+
from anndata import AnnData
911
from sklearn.metrics.cluster import normalized_mutual_info_score
1012

1113
import scanpy as sc
@@ -15,8 +17,6 @@
1517
if TYPE_CHECKING:
1618
from typing import Literal
1719

18-
from anndata import AnnData
19-
2020

2121
@pytest.fixture
2222
def adata_neighbors() -> AnnData:
@@ -105,10 +105,27 @@ def test_leiden_random_state(
105105
)
106106

107107

108+
@needs.anndata_acc
108109
@needs.igraph
109-
def test_leiden_igraph_directed(adata_neighbors):
110-
with pytest.raises(ValueError, match=r"Cannot use igraph’s leiden.*directed"):
111-
sc.tl.leiden(adata_neighbors, flavor="igraph", directed=True)
110+
@needs.leidenalg
111+
def test_leiden_igraph_directed():
112+
from anndata.acc import A
113+
114+
graph = np.array([
115+
[0, 0, 0, 3, 0, 0, 0, 2],
116+
[0, 0, 1, 0, 0, 0, 3, 0],
117+
[2, 0, 0, 1, 2, 0, 1, 2],
118+
[0, 0, 0, 0, 0, 0, 0, 0],
119+
[0, 0, 0, 1, 0, 0, 0, 0],
120+
[0, 0, 0, 1, 0, 0, 0, 0],
121+
[0, 0, 1, 1, 0, 1, 0, 0],
122+
[1, 0, 0, 0, 0, 0, 1, 0],
123+
])
124+
adata = AnnData(np.zeros((8, 1)), obsp=dict(graph=graph))
125+
kw = dict(use=A.obsp["graph"], directed=True, out=True)
126+
igraph = sc.tl.leiden(adata, flavor="igraph", **kw)
127+
leidenalg = sc.tl.leiden(adata, flavor="leidenalg", **kw)
128+
assert normalized_mutual_info_score(igraph, leidenalg) == 1
112129

113130

114131
@needs.igraph

0 commit comments

Comments
 (0)