diff --git a/delaynet/network_analysis/_normalisation.py b/delaynet/network_analysis/_normalisation.py index 5a0dc44..16e7aaa 100644 --- a/delaynet/network_analysis/_normalisation.py +++ b/delaynet/network_analysis/_normalisation.py @@ -152,11 +152,25 @@ def wrapper( x_true = metric_fn(weight_matrix, *args, **kwargs) # Sample ensemble and compute metric values + # Retry if the metric raises ValueError on a random graph (e.g., reciprocity + # rejects accidentally symmetric matrices). Cap attempts to avoid infinite loops. samples = [] - for _ in range(n_rand_val): + max_attempts = n_rand_val * 10 + for _ in range(max_attempts): + if len(samples) >= n_rand_val: + break R = _random_directed_gnm_igraph(n, m) - x_r = metric_fn(R, *args, **kwargs) + try: + x_r = metric_fn(R, *args, **kwargs) + except ValueError: + continue samples.append(np.asarray(x_r)) + else: + raise RuntimeError( + f"Could not collect {n_rand_val} valid null-distribution samples " + f"after {max_attempts} attempts. The metric {metric_fn.__name__!r} " + f"raised ValueError on every random graph generated." + ) samples_arr = np.stack(samples, axis=0) # shape: (n_random, ...) mu = samples_arr.mean(axis=0) diff --git a/tests/network_analysis/test_normalisation.py b/tests/network_analysis/test_normalisation.py index f3806dc..30f54a7 100644 --- a/tests/network_analysis/test_normalisation.py +++ b/tests/network_analysis/test_normalisation.py @@ -8,6 +8,8 @@ - Seed reproducibility for z-scores. - Sigma==0 branch returns 0.0 without NaN/inf. - Output shape/type parity when normalising. +- Retry when a random graph raises ValueError (e.g. symmetric for reciprocity). +- RuntimeError when no valid null-distribution samples can be collected. """ from __future__ import annotations @@ -18,6 +20,8 @@ import pytest from igraph import Graph +import delaynet.network_analysis._normalisation as _normalisation + from delaynet.network_analysis.metrics import ( link_density, reciprocity, @@ -302,3 +306,44 @@ def test_example_matrices(network_metric_and_kind, n, m): assert not np.isnan(val) assert not np.isnan(z) assert np.isfinite(z) + + +@pytest.mark.parametrize("n_rejected", [1, 3]) +def test_normalisation_retries_when_random_graph_rejected(monkeypatch, n_rejected): + """Samples that raise ValueError (e.g. accidentally symmetric for reciprocity) + are resampled until enough valid samples are collected.""" + # Arrange + A = np.array([[0, 1, 0], [0, 0, 1], [1, 0, 0]], dtype=int) + symmetric = np.array([[0, 1, 1], [1, 0, 0], [1, 0, 0]], dtype=int) + partial = np.array([[0, 1, 1], [1, 0, 0], [0, 0, 0]], dtype=int) + independent = np.array([[0, 1, 0], [0, 0, 1], [1, 0, 0]], dtype=int) + generated = iter([symmetric] * n_rejected + [partial, independent]) + monkeypatch.setattr( + _normalisation, "_random_directed_gnm_igraph", lambda n, m: next(generated) + ) + + # Act + z = reciprocity(A, normalise=True, n_random=2) + + # Assert + assert isinstance(z, float) + assert np.isfinite(z) + + +@pytest.mark.parametrize("n_random", [1, 2]) +def test_normalisation_raises_when_all_random_graphs_rejected(monkeypatch, n_random): + """If every sampled graph raises ValueError, a RuntimeError is raised instead of + building an incomplete null distribution.""" + # Arrange + A = np.array([[0, 1, 0], [0, 0, 1], [1, 0, 0]], dtype=int) + symmetric = np.array([[0, 1, 1], [1, 0, 0], [1, 0, 0]], dtype=int) + monkeypatch.setattr( + _normalisation, "_random_directed_gnm_igraph", lambda n, m: symmetric + ) + + # Act / Assert + with pytest.raises( + RuntimeError, + match=f"Could not collect {n_random} valid null-distribution samples", + ): + reciprocity(A, normalise=True, n_random=n_random)