From f76e477125d2508c7082eb47cff835881fd3313d Mon Sep 17 00:00:00 2001 From: Ekin-Kahraman Date: Wed, 8 Apr 2026 02:19:51 +0100 Subject: [PATCH 1/2] fix: handle random_state=0 correctly in leiden/louvain clustering `random_state=0` was not setting the RNG seed because the check used `if random_state:` which evaluates to False when random_state is 0. Changed to `if random_state is not None:` so that any integer value (including 0) correctly calls `optimiser.set_rng_seed()`. Closes #154 Co-Authored-By: Claude Opus 4.6 (1M context) --- muon/_core/tools.py | 2 +- tests/test_leiden_random_state.py | 51 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 tests/test_leiden_random_state.py diff --git a/muon/_core/tools.py b/muon/_core/tools.py index 27792d3..531475d 100644 --- a/muon/_core/tools.py +++ b/muon/_core/tools.py @@ -1002,7 +1002,7 @@ def _cluster( partition_type = alg.RBConfigurationVertexPartition optimiser = alg.Optimiser() - if random_state: + if random_state is not None: optimiser.set_rng_seed(random_state) # The same as leiden.find_partition_multiplex() (louvain.find_partition_multiplex()) diff --git a/tests/test_leiden_random_state.py b/tests/test_leiden_random_state.py new file mode 100644 index 0000000..e81a503 --- /dev/null +++ b/tests/test_leiden_random_state.py @@ -0,0 +1,51 @@ +"""Test that random_state=0 correctly sets the RNG seed in leiden/louvain clustering.""" + +from unittest.mock import patch, MagicMock + +import numpy as np +import scanpy as sc +from anndata import AnnData +from mudata import MuData + +import muon as mu + + +def _make_mudata(): + """Create a minimal MuData with precomputed neighbors.""" + np.random.seed(42) + a = AnnData(np.random.rand(50, 10).astype(np.float32)) + b = AnnData(np.random.rand(50, 10).astype(np.float32)) + sc.pp.neighbors(a) + sc.pp.neighbors(b) + return MuData({"a": a, "b": b}) + + +def test_leiden_random_state_zero_sets_seed(): + """Regression test for https://github.com/scverse/muon/issues/154. + + random_state=0 must call optimiser.set_rng_seed(0), not skip it. + """ + mdata = _make_mudata() + + with patch("leidenalg.Optimiser") as MockOptimiser: + mock_opt = MagicMock() + mock_opt.optimise_partition_multiplex.return_value = 0.0 + MockOptimiser.return_value = mock_opt + + mu.tl.leiden(mdata, random_state=0) + + mock_opt.set_rng_seed.assert_called_once_with(0) + + +def test_leiden_random_state_none_skips_seed(): + """When random_state is None, set_rng_seed should not be called.""" + mdata = _make_mudata() + + with patch("leidenalg.Optimiser") as MockOptimiser: + mock_opt = MagicMock() + mock_opt.optimise_partition_multiplex.return_value = 0.0 + MockOptimiser.return_value = mock_opt + + mu.tl.leiden(mdata, random_state=None) + + mock_opt.set_rng_seed.assert_not_called() From fe9aadbb866d92a7f4a1a8ce40e945fde62ef7da Mon Sep 17 00:00:00 2001 From: Ekin-Kahraman Date: Sat, 11 Apr 2026 13:34:02 +0100 Subject: [PATCH 2/2] fix: mock leidenalg at sys.modules level so tests pass without it installed The CI environment does not have leidenalg. The previous approach used unittest.mock.patch which requires the target module to exist. Now we inject a mock leidenalg module into sys.modules before calling mu.tl.leiden, so the `import leidenalg` inside the function succeeds. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/test_leiden_random_state.py | 70 +++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 13 deletions(-) diff --git a/tests/test_leiden_random_state.py b/tests/test_leiden_random_state.py index e81a503..08478f4 100644 --- a/tests/test_leiden_random_state.py +++ b/tests/test_leiden_random_state.py @@ -1,6 +1,8 @@ """Test that random_state=0 correctly sets the RNG seed in leiden/louvain clustering.""" -from unittest.mock import patch, MagicMock +import sys +from types import ModuleType +from unittest.mock import MagicMock import numpy as np import scanpy as sc @@ -20,32 +22,74 @@ def _make_mudata(): return MuData({"a": a, "b": b}) +def _make_mock_leidenalg(): + """Create a mock leidenalg module with the interfaces the code needs.""" + mock_module = ModuleType("leidenalg") + + mock_optimiser_instance = MagicMock() + mock_optimiser_instance.optimise_partition_multiplex.return_value = 0.0 + + mock_partition = MagicMock() + mock_partition.membership = [0] * 50 + + mock_optimiser_cls = MagicMock(return_value=mock_optimiser_instance) + mock_partition_cls = MagicMock(return_value=mock_partition) + + mock_module.Optimiser = mock_optimiser_cls + mock_module.RBConfigurationVertexPartition = mock_partition_cls + + # Also mock the VertexPartition submodule so module-level imports work. + vp = ModuleType("leidenalg.VertexPartition") + vp.MutableVertexPartition = MagicMock() + mock_module.VertexPartition = vp + + return mock_module, mock_optimiser_instance + + def test_leiden_random_state_zero_sets_seed(): """Regression test for https://github.com/scverse/muon/issues/154. random_state=0 must call optimiser.set_rng_seed(0), not skip it. """ mdata = _make_mudata() + mock_module, mock_opt = _make_mock_leidenalg() - with patch("leidenalg.Optimiser") as MockOptimiser: - mock_opt = MagicMock() - mock_opt.optimise_partition_multiplex.return_value = 0.0 - MockOptimiser.return_value = mock_opt - + saved = sys.modules.get("leidenalg") + saved_vp = sys.modules.get("leidenalg.VertexPartition") + try: + sys.modules["leidenalg"] = mock_module + sys.modules["leidenalg.VertexPartition"] = mock_module.VertexPartition mu.tl.leiden(mdata, random_state=0) - mock_opt.set_rng_seed.assert_called_once_with(0) + finally: + if saved is None: + sys.modules.pop("leidenalg", None) + else: + sys.modules["leidenalg"] = saved + if saved_vp is None: + sys.modules.pop("leidenalg.VertexPartition", None) + else: + sys.modules["leidenalg.VertexPartition"] = saved_vp def test_leiden_random_state_none_skips_seed(): """When random_state is None, set_rng_seed should not be called.""" mdata = _make_mudata() + mock_module, mock_opt = _make_mock_leidenalg() - with patch("leidenalg.Optimiser") as MockOptimiser: - mock_opt = MagicMock() - mock_opt.optimise_partition_multiplex.return_value = 0.0 - MockOptimiser.return_value = mock_opt - + saved = sys.modules.get("leidenalg") + saved_vp = sys.modules.get("leidenalg.VertexPartition") + try: + sys.modules["leidenalg"] = mock_module + sys.modules["leidenalg.VertexPartition"] = mock_module.VertexPartition mu.tl.leiden(mdata, random_state=None) - mock_opt.set_rng_seed.assert_not_called() + finally: + if saved is None: + sys.modules.pop("leidenalg", None) + else: + sys.modules["leidenalg"] = saved + if saved_vp is None: + sys.modules.pop("leidenalg.VertexPartition", None) + else: + sys.modules["leidenalg.VertexPartition"] = saved_vp