Skip to content

Inconsistency in graphs used by sc.tl.leiden and sc.metrics.modularity leads to modularity mismatch #4099

@jpintar

Description

@jpintar

Please make sure these conditions are met

  • I have checked that this issue has not already been reported.
  • I have confirmed this bug exists on the latest version of scanpy.
  • (optional) I have confirmed this bug exists on the main branch of scanpy.

What happened?

The igraph modularity recorded by sc.tl.leiden and the one computed by sc.metrics.modularity differ, when the reasonable expectation is for them to be equal.

This is because the graphs over which they are computed are not the same. When the graph is constructed during sc.tl.leiden, connectivities are passed to sc._utils.get_igraph_from_adjacency, resulting in a graph in which each pair of connected vertices is connected by two edges (possibly directed when flavor is not igraph). In sc.metrics.modularity, on the other hand, connectivities are passed to ig.Graph.Weighted_Adjacency, and the in resulting graph, each pair of connected vertices is connected by a single edge.

Minimal code sample

# /// script
# requires-python = ">=3.12"
# dependencies = [
#   "scanpy@git+https://github.com/scverse/scanpy.git@main",
#   "igraph",
# ]
# ///
#
# This script automatically imports the development branch of scanpy to check for issues
import igraph as ig
import scanpy as sc


adata = sc.datasets.pbmc3k_processed()
sc.tl.leiden(adata, flavor="igraph", n_iterations=10, directed=False, random_state=1234)

modularity_recorded = adata.uns["leiden"]["modularity"]
modularity_metrics = sc.metrics.modularity(adata, labels="leiden", mode="calculate")

# graph creation equivalent to sc.metrics.modularity
graph_1 = ig.Graph.Weighted_Adjacency(adata.obsp["connectivities"], mode=ig.ADJ_UNDIRECTED)
modularity_1 = graph_1.modularity(adata.obs["leiden"].array.codes, "weight")

# graph creation equivalent to sc.tl.leiden
graph_2 = sc._utils.get_igraph_from_adjacency(adata.obsp["connectivities"], directed=False)
modularity_2 = graph_2.modularity(adata.obs["leiden"].array.codes, "weight")

# creation methods agree for directed graphs
graph_3 = ig.Graph.Weighted_Adjacency(adata.obsp["connectivities"], mode=ig.ADJ_DIRECTED)
graph_4 = sc._utils.get_igraph_from_adjacency(adata.obsp["connectivities"], directed=True)

# tests
assert graph_3.isomorphic(graph_4)

assert not graph_2.isomorphic(graph_1)  # passes, should fail
assert graph_2.subisomorphic_vf2(graph_1)
assert graph_2.ecount() == 2 * graph_1.ecount()  # passes, should fail

assert modularity_1 == modularity_metrics
assert modularity_2 == modularity_recorded
assert modularity_metrics == modularity_recorded, "modularities unequal"  # fails

Error output

AssertionError: modularities unequal

Versions

Details
| Package | Version |
| ------- | ------- |
| igraph  | 1.0.0   |
| scanpy  | 1.12.1  |
| anndata | 0.12.11 |

| Dependency        | Version      |
| ----------------- | ------------ |
| parso             | 0.8.7        |
| numpy             | 2.4.3        |
| asttokens         | 3.0.1        |
| jedi              | 0.19.2       |
| cycler            | 0.12.1       |
| google-crc32c     | 1.8.0        |
| threadpoolctl     | 3.6.0        |
| traitlets         | 5.14.3       |
| fast-array-utils  | 1.4.1        |
| prompt_toolkit    | 3.0.52       |
| jupyter_client    | 8.8.0        |
| python-dateutil   | 2.9.0.post0  |
| Pygments          | 2.20.0       |
| kiwisolver        | 1.5.0        |
| packaging         | 26.2         |
| executing         | 2.2.1        |
| ipykernel         | 7.2.0        |
| donfig            | 0.8.1.post1  |
| debugpy           | 1.8.20       |
| platformdirs      | 4.9.6        |
| h5py              | 3.16.0       |
| six               | 1.17.0       |
| pure_eval         | 0.2.3        |
| PyYAML            | 6.0.3        |
| ipython           | 9.13.0       |
| pyparsing         | 3.3.2        |
| decorator         | 5.2.1        |
| scipy             | 1.17.1       |
| scikit-learn      | 1.8.0        |
| tornado           | 6.5.5        |
| psutil            | 7.2.2        |
| zarr              | 3.2.0        |
| comm              | 0.2.3        |
| numcodecs         | 0.16.5       |
| llvmlite          | 0.47.0       |
| typing_extensions | 4.15.0       |
| pillow            | 12.2.0       |
| natsort           | 8.4.0        |
| stack_data        | 0.6.3        |
| pyzmq             | 27.1.0       |
| setuptools        | 82.0.1       |
| session-info2     | 0.4.1        |
| wcwidth           | 0.7.0        |
| pytz              | 2026.1.post1 |
| msgpack           | 1.1.2        |
| legacy-api-wrap   | 1.5          |
| numba             | 0.65.1       |
| matplotlib        | 3.10.9       |
| scverse-misc      | 0.0.5        |
| appnope           | 0.1.4        |
| joblib            | 1.5.3        |
| pandas            | 2.3.3        |
| texttable         | 1.7.0        |
| jupyter_core      | 5.9.1        |

| Component | Info                                                                              |
| --------- | --------------------------------------------------------------------------------- |
| Python    | 3.13.13 | packaged by conda-forge | (main, Apr  8 2026, 02:29:07) [Clang 19.1.7 ] |
| OS        | macOS-26.4.1-arm64-arm-64bit-Mach-O                                               |
| CPU       | 10/10 logical CPU cores, arm                                                      |
| GPU       | No GPU found                                                                      |
| Updated   | 2026-05-03 15:31                                                                  |

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions