strictnull 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tsuruta Lab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,177 @@
1
+ Metadata-Version: 2.4
2
+ Name: strictnull
3
+ Version: 0.1.0
4
+ Summary: Build the control before you compare: degree-preserving null models for graphs, with the checks that make them a control.
5
+ Author: Tsuruta Lab
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/tsurutanmen/strictnull
8
+ Project-URL: Issues, https://github.com/tsurutanmen/strictnull/issues
9
+ Keywords: null model,configuration model,degree-preserving,network,connectome,control group,igraph
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: numpy>=1.22
19
+ Requires-Dist: igraph>=0.10
20
+ Provides-Extra: networkx
21
+ Requires-Dist: networkx>=2.6; extra == "networkx"
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest>=7; extra == "test"
24
+ Dynamic: license-file
25
+
26
+ # strictnull
27
+
28
+ Build the control before you compare.
29
+
30
+ A graph only looks special against the right control. Most papers compare a network against an
31
+ Erdős–Rényi graph with the same number of nodes and edges. That control cannot tell wiring from
32
+ degree: a heavy-tailed degree distribution on its own produces clustering, short paths, reciprocity,
33
+ and communities. `strictnull` puts the second control next to the first, verifies both, and tells you
34
+ how much of the "structure" was the degree sequence.
35
+
36
+ ```
37
+ pip install git+https://github.com/tsurutanmen/strictnull
38
+ ```
39
+
40
+ A PyPI release is planned; until then install from GitHub as above. Requires numpy and [igraph](https://python.igraph.org). `networkx` is optional (for `Graph.from_networkx`).
41
+
42
+ ## Thirty seconds
43
+
44
+ ```
45
+ $ strictnull tests/data/toy.csv --n-null 20
46
+
47
+ Graph(directed, weighted, 60 nodes, 400 edges)
48
+ controls: 20 draws each, strict null rewired 73.4% of edges
49
+
50
+ statistic real weak null strict null z(weak) z(strict) by degree
51
+ -------------------------------------------------------------------------------------------------
52
+ reciprocity 0.1250 0.1150+-0.0251 0.1153+-0.0171 +0.4 +0.6 2%
53
+ clustering 0.2835 0.2154+-0.0075 0.2817+-0.0091 +9.0 +0.2 97%
54
+ avg_path 1.8113 1.8391+-0.0098 1.8178+-0.0075 -2.8 -0.9 77%
55
+ assortativity -0.1818 -0.0292+-0.0311 -0.1695+-0.0172 -4.9 -0.7 92%
56
+
57
+ warning: strict null rewired only 73% of edges; raise swaps_per_edge or accept that this graph has little room to rewire
58
+ ```
59
+
60
+ Against the weak control this toy graph is nine standard deviations more clustered than chance.
61
+ Against the strict control it is 0.2. The degree sequence explains 97% of the gap. Nothing about the
62
+ wiring is special here; the paper that reported `z = 9` would have been reporting its degree
63
+ distribution.
64
+
65
+ ## The three numbers
66
+
67
+ For each statistic the report gives:
68
+
69
+ | column | meaning |
70
+ |---|---|
71
+ | `weak null` | mean and sd over draws of an Erdős–Rényi graph with the same node and edge count (weights shuffled). The control most papers use. |
72
+ | `strict null` | mean and sd over draws of a degree-preserving rewiring: every node keeps its exact in-degree and out-degree, everything else is randomised. |
73
+ | `z(weak)`, `z(strict)` | distance of the real value from each control, in control standard deviations. |
74
+ | `by degree` | `(strict − weak) / (real − weak)`: the share of the weak-control gap that the degree sequence alone reproduces. Near 100% means the structure is the degree distribution. |
75
+
76
+ ## Python
77
+
78
+ ```python
79
+ import strictnull as sn
80
+
81
+ g = sn.Graph.from_csv("edges.csv", directed=True) # header: source,target[,weight]
82
+ g = sn.Graph.from_edges(edges, weights, directed=True) # (E, 2) int array
83
+ g = sn.Graph.from_adjacency(A, directed=True)
84
+ g = sn.Graph.from_networkx(G)
85
+
86
+ rep = sn.compare(g, stats=["clustering", "reciprocity", "avg_path", "assortativity"], n_null=20, seed=0)
87
+ print(rep)
88
+ rep.to_json("report.json")
89
+
90
+ for row in rep.rows:
91
+ print(row.stat, row.z_strict, row.fraction_explained_by_degree)
92
+ ```
93
+
94
+ Built-in statistics: `reciprocity`, `clustering`, `avg_clustering`, `avg_path`, `assortativity`,
95
+ `n_triangles`, `max_core`, `modularity_leiden`, `mean_weight`. Add your own:
96
+
97
+ ```python
98
+ @sn.register("rich_club_100")
99
+ def rich_club_100(graph):
100
+ ...
101
+ return value
102
+
103
+ rep = sn.compare(g, stats=["rich_club_100", "clustering"])
104
+ # or pass the callable directly: sn.compare(g, stats=[rich_club_100])
105
+ ```
106
+
107
+ ### Swapping the graph inside a model
108
+
109
+ When the claim is not about a statistic but about a model that uses the graph (a connectome as a
110
+ fixed layer, a knowledge graph as a prior), draw nulls and retrain:
111
+
112
+ ```python
113
+ for i, h in enumerate(sn.ensemble(g, n=5, seed=0)): # independent degree-preserving draws
114
+ sn.verify(g, h, strict=True) # raises if a degree moved
115
+ A_null = h.g.get_adjacency(attribute="weight") # or h.edges(), h.weights()
116
+ acc = train_and_evaluate(A_null)
117
+ ```
118
+
119
+ Compare the real graph's outcome against the mean and spread over draws, not against one draw.
120
+
121
+ ## What the tool refuses or flags, and why
122
+
123
+ - **One draw is not a control.** `compare` raises on `n_null < 2`.
124
+ - **Every null is verified before use.** Exact degree match, no self-loops, no multi-edges, weight
125
+ multiset preserved, and the fraction of edges actually rewired. Below 80% rewired, the report warns.
126
+ - **Invariant statistics are flagged.** If a statistic is identical, to the last digit, on the graph
127
+ and on every null, the control never touched what it measures. That is not "no effect"; it is
128
+ "nothing was tested". The row is marked `INVARIANT` and a warning explains which control it is
129
+ invariant under. Functions of the degree sequence (max degree, degree variance) are invariant under
130
+ the strict null. Functions of the edge count or weight multiset are invariant under both.
131
+
132
+ ## Worked example: a connectome
133
+
134
+ The larval *Drosophila* connectome released by Winding et al. (2023): 2,952 neurons, 110,140
135
+ directed edges. Ten draws per control, 25 seconds.
136
+
137
+ ```
138
+ statistic real weak null strict null z(weak) z(strict) by degree
139
+ reciprocity 0.2569 0.0126+-0.0004 0.0250+-0.0006 +677.1 +380.7 5%
140
+ clustering 0.2350 0.0252+-0.0000 0.0537+-0.0001 +5103.3 +1469.6 14%
141
+ avg_path 2.7466 2.1262+-0.0002 2.2596+-0.0008 +2840.9 +631.7 22%
142
+ assortativity 0.2365 -0.0012+-0.0020 -0.0138+-0.0021 +116.1 +119.7 -5%
143
+ ```
144
+
145
+ Here the wiring is real: the degree sequence explains only 5–22% of each gap and the strict-control
146
+ z stays in the hundreds. That is the case where the strict control changes nothing about the
147
+ conclusion, and it still had to be run to know that.
148
+
149
+ The same connectome, used as a fixed recurrent layer in an MNIST classifier, gave the opposite
150
+ result: swapping the layer for a strict null moved accuracy by +0.002, within seed noise. Structure
151
+ exists, and the task does not use it. The two findings, and the code that produced them, are in the
152
+ [Tsuruta Lab research notes](https://tsurutalab.org/notes/).
153
+
154
+ ## Claude Code skill
155
+
156
+ `skill/strictnull/SKILL.md` teaches Claude Code when to reach for this tool and how to write up the
157
+ result. Install it by copying the folder:
158
+
159
+ ```
160
+ cp -r skill/strictnull ~/.claude/skills/strictnull
161
+ ```
162
+
163
+ After that, a sentence like "this network is more clustered than random" in a session triggers the
164
+ comparison against both controls and a write-up that names the control.
165
+
166
+ ## Method
167
+
168
+ The strict null is the directed configuration model conditioned on simple graphs, sampled by
169
+ double-edge swaps (Maslov & Sneppen 2002), `swaps_per_edge` swaps per edge (default 20). For
170
+ undirected graphs the same procedure preserves each node's degree. The weak null samples uniformly
171
+ among simple graphs with the given edge count. See Fosdick, Larremore, Nishimura & Ugander (2018),
172
+ *Configuring random graph models with fixed degree sequences*, SIAM Review 60(2), for the space of
173
+ choices this tool does not cover (multigraphs, self-loops, stub matching).
174
+
175
+ ## License
176
+
177
+ MIT.
@@ -0,0 +1,152 @@
1
+ # strictnull
2
+
3
+ Build the control before you compare.
4
+
5
+ A graph only looks special against the right control. Most papers compare a network against an
6
+ Erdős–Rényi graph with the same number of nodes and edges. That control cannot tell wiring from
7
+ degree: a heavy-tailed degree distribution on its own produces clustering, short paths, reciprocity,
8
+ and communities. `strictnull` puts the second control next to the first, verifies both, and tells you
9
+ how much of the "structure" was the degree sequence.
10
+
11
+ ```
12
+ pip install git+https://github.com/tsurutanmen/strictnull
13
+ ```
14
+
15
+ A PyPI release is planned; until then install from GitHub as above. Requires numpy and [igraph](https://python.igraph.org). `networkx` is optional (for `Graph.from_networkx`).
16
+
17
+ ## Thirty seconds
18
+
19
+ ```
20
+ $ strictnull tests/data/toy.csv --n-null 20
21
+
22
+ Graph(directed, weighted, 60 nodes, 400 edges)
23
+ controls: 20 draws each, strict null rewired 73.4% of edges
24
+
25
+ statistic real weak null strict null z(weak) z(strict) by degree
26
+ -------------------------------------------------------------------------------------------------
27
+ reciprocity 0.1250 0.1150+-0.0251 0.1153+-0.0171 +0.4 +0.6 2%
28
+ clustering 0.2835 0.2154+-0.0075 0.2817+-0.0091 +9.0 +0.2 97%
29
+ avg_path 1.8113 1.8391+-0.0098 1.8178+-0.0075 -2.8 -0.9 77%
30
+ assortativity -0.1818 -0.0292+-0.0311 -0.1695+-0.0172 -4.9 -0.7 92%
31
+
32
+ warning: strict null rewired only 73% of edges; raise swaps_per_edge or accept that this graph has little room to rewire
33
+ ```
34
+
35
+ Against the weak control this toy graph is nine standard deviations more clustered than chance.
36
+ Against the strict control it is 0.2. The degree sequence explains 97% of the gap. Nothing about the
37
+ wiring is special here; the paper that reported `z = 9` would have been reporting its degree
38
+ distribution.
39
+
40
+ ## The three numbers
41
+
42
+ For each statistic the report gives:
43
+
44
+ | column | meaning |
45
+ |---|---|
46
+ | `weak null` | mean and sd over draws of an Erdős–Rényi graph with the same node and edge count (weights shuffled). The control most papers use. |
47
+ | `strict null` | mean and sd over draws of a degree-preserving rewiring: every node keeps its exact in-degree and out-degree, everything else is randomised. |
48
+ | `z(weak)`, `z(strict)` | distance of the real value from each control, in control standard deviations. |
49
+ | `by degree` | `(strict − weak) / (real − weak)`: the share of the weak-control gap that the degree sequence alone reproduces. Near 100% means the structure is the degree distribution. |
50
+
51
+ ## Python
52
+
53
+ ```python
54
+ import strictnull as sn
55
+
56
+ g = sn.Graph.from_csv("edges.csv", directed=True) # header: source,target[,weight]
57
+ g = sn.Graph.from_edges(edges, weights, directed=True) # (E, 2) int array
58
+ g = sn.Graph.from_adjacency(A, directed=True)
59
+ g = sn.Graph.from_networkx(G)
60
+
61
+ rep = sn.compare(g, stats=["clustering", "reciprocity", "avg_path", "assortativity"], n_null=20, seed=0)
62
+ print(rep)
63
+ rep.to_json("report.json")
64
+
65
+ for row in rep.rows:
66
+ print(row.stat, row.z_strict, row.fraction_explained_by_degree)
67
+ ```
68
+
69
+ Built-in statistics: `reciprocity`, `clustering`, `avg_clustering`, `avg_path`, `assortativity`,
70
+ `n_triangles`, `max_core`, `modularity_leiden`, `mean_weight`. Add your own:
71
+
72
+ ```python
73
+ @sn.register("rich_club_100")
74
+ def rich_club_100(graph):
75
+ ...
76
+ return value
77
+
78
+ rep = sn.compare(g, stats=["rich_club_100", "clustering"])
79
+ # or pass the callable directly: sn.compare(g, stats=[rich_club_100])
80
+ ```
81
+
82
+ ### Swapping the graph inside a model
83
+
84
+ When the claim is not about a statistic but about a model that uses the graph (a connectome as a
85
+ fixed layer, a knowledge graph as a prior), draw nulls and retrain:
86
+
87
+ ```python
88
+ for i, h in enumerate(sn.ensemble(g, n=5, seed=0)): # independent degree-preserving draws
89
+ sn.verify(g, h, strict=True) # raises if a degree moved
90
+ A_null = h.g.get_adjacency(attribute="weight") # or h.edges(), h.weights()
91
+ acc = train_and_evaluate(A_null)
92
+ ```
93
+
94
+ Compare the real graph's outcome against the mean and spread over draws, not against one draw.
95
+
96
+ ## What the tool refuses or flags, and why
97
+
98
+ - **One draw is not a control.** `compare` raises on `n_null < 2`.
99
+ - **Every null is verified before use.** Exact degree match, no self-loops, no multi-edges, weight
100
+ multiset preserved, and the fraction of edges actually rewired. Below 80% rewired, the report warns.
101
+ - **Invariant statistics are flagged.** If a statistic is identical, to the last digit, on the graph
102
+ and on every null, the control never touched what it measures. That is not "no effect"; it is
103
+ "nothing was tested". The row is marked `INVARIANT` and a warning explains which control it is
104
+ invariant under. Functions of the degree sequence (max degree, degree variance) are invariant under
105
+ the strict null. Functions of the edge count or weight multiset are invariant under both.
106
+
107
+ ## Worked example: a connectome
108
+
109
+ The larval *Drosophila* connectome released by Winding et al. (2023): 2,952 neurons, 110,140
110
+ directed edges. Ten draws per control, 25 seconds.
111
+
112
+ ```
113
+ statistic real weak null strict null z(weak) z(strict) by degree
114
+ reciprocity 0.2569 0.0126+-0.0004 0.0250+-0.0006 +677.1 +380.7 5%
115
+ clustering 0.2350 0.0252+-0.0000 0.0537+-0.0001 +5103.3 +1469.6 14%
116
+ avg_path 2.7466 2.1262+-0.0002 2.2596+-0.0008 +2840.9 +631.7 22%
117
+ assortativity 0.2365 -0.0012+-0.0020 -0.0138+-0.0021 +116.1 +119.7 -5%
118
+ ```
119
+
120
+ Here the wiring is real: the degree sequence explains only 5–22% of each gap and the strict-control
121
+ z stays in the hundreds. That is the case where the strict control changes nothing about the
122
+ conclusion, and it still had to be run to know that.
123
+
124
+ The same connectome, used as a fixed recurrent layer in an MNIST classifier, gave the opposite
125
+ result: swapping the layer for a strict null moved accuracy by +0.002, within seed noise. Structure
126
+ exists, and the task does not use it. The two findings, and the code that produced them, are in the
127
+ [Tsuruta Lab research notes](https://tsurutalab.org/notes/).
128
+
129
+ ## Claude Code skill
130
+
131
+ `skill/strictnull/SKILL.md` teaches Claude Code when to reach for this tool and how to write up the
132
+ result. Install it by copying the folder:
133
+
134
+ ```
135
+ cp -r skill/strictnull ~/.claude/skills/strictnull
136
+ ```
137
+
138
+ After that, a sentence like "this network is more clustered than random" in a session triggers the
139
+ comparison against both controls and a write-up that names the control.
140
+
141
+ ## Method
142
+
143
+ The strict null is the directed configuration model conditioned on simple graphs, sampled by
144
+ double-edge swaps (Maslov & Sneppen 2002), `swaps_per_edge` swaps per edge (default 20). For
145
+ undirected graphs the same procedure preserves each node's degree. The weak null samples uniformly
146
+ among simple graphs with the given edge count. See Fosdick, Larremore, Nishimura & Ugander (2018),
147
+ *Configuring random graph models with fixed degree sequences*, SIAM Review 60(2), for the space of
148
+ choices this tool does not cover (multigraphs, self-loops, stub matching).
149
+
150
+ ## License
151
+
152
+ MIT.
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "strictnull"
7
+ version = "0.1.0"
8
+ description = "Build the control before you compare: degree-preserving null models for graphs, with the checks that make them a control."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Tsuruta Lab" }]
13
+ keywords = ["null model", "configuration model", "degree-preserving", "network", "connectome", "control group", "igraph"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Science/Research",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Scientific/Engineering",
20
+ ]
21
+ dependencies = ["numpy>=1.22", "igraph>=0.10"]
22
+
23
+ [project.optional-dependencies]
24
+ networkx = ["networkx>=2.6"]
25
+ test = ["pytest>=7"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/tsurutanmen/strictnull"
29
+ Issues = "https://github.com/tsurutanmen/strictnull/issues"
30
+
31
+ [project.scripts]
32
+ strictnull = "strictnull.cli:main"
33
+
34
+ [tool.setuptools.packages.find]
35
+ include = ["strictnull*"]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,26 @@
1
+ """strictnull: build the control before you compare.
2
+
3
+ A graph only looks special against the right control. Two controls are
4
+ provided, deliberately ordered from weak to strict:
5
+
6
+ weak_null same node count and edge count, nothing else preserved
7
+ strict_null every node keeps its exact degree (in and out, if directed)
8
+
9
+ If an apparent property of a graph survives only against the weak control,
10
+ the property belongs to the degree sequence, not to the wiring.
11
+
12
+ >>> import strictnull as sn
13
+ >>> g = sn.Graph.from_edges(edges, directed=True)
14
+ >>> print(sn.compare(g, n_null=20))
15
+ """
16
+
17
+ from .graph import Graph
18
+ from .nulls import weak_null, strict_null, ensemble, verify
19
+ from .stats import STATS, register
20
+ from .compare import compare, Report
21
+
22
+ __version__ = "0.1.0"
23
+ __all__ = [
24
+ "Graph", "weak_null", "strict_null", "ensemble", "verify",
25
+ "STATS", "register", "compare", "Report",
26
+ ]
@@ -0,0 +1,43 @@
1
+ """strictnull EDGES.csv [--undirected] [--n-null 20] [--stats a,b,c] [--json out.json]"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from .graph import Graph
9
+ from .stats import STATS, DEFAULT
10
+ from .compare import compare
11
+
12
+
13
+ def main(argv=None) -> int:
14
+ ap = argparse.ArgumentParser(
15
+ prog="strictnull",
16
+ description="Compare a graph against a weak (edge-count) and a strict (degree-preserving) null model.")
17
+ ap.add_argument("edges", help="CSV edge list with header: source,target[,weight]")
18
+ ap.add_argument("--undirected", action="store_true", help="treat edges as undirected")
19
+ ap.add_argument("--n-null", type=int, default=20, help="draws per control (default 20)")
20
+ ap.add_argument("--stats", default=",".join(DEFAULT),
21
+ help="comma-separated statistics; known: " + ", ".join(sorted(STATS)))
22
+ ap.add_argument("--swaps-per-edge", type=int, default=20)
23
+ ap.add_argument("--shuffle-weights", action="store_true",
24
+ help="also shuffle weights across edges in the strict null")
25
+ ap.add_argument("--seed", type=int, default=0)
26
+ ap.add_argument("--json", help="write the report as JSON to this path")
27
+ ap.add_argument("--delimiter", default=",")
28
+ ap.add_argument("-v", "--verbose", action="store_true")
29
+ a = ap.parse_args(argv)
30
+
31
+ g = Graph.from_csv(a.edges, directed=not a.undirected, delimiter=a.delimiter)
32
+ stats = [s.strip() for s in a.stats.split(",") if s.strip()]
33
+ rep = compare(g, stats=stats, n_null=a.n_null, seed=a.seed, swaps_per_edge=a.swaps_per_edge,
34
+ keep_weights=not a.shuffle_weights, verbose=a.verbose)
35
+ print(rep)
36
+ if a.json:
37
+ rep.to_json(a.json)
38
+ print(f"\nwrote {a.json}")
39
+ return 0
40
+
41
+
42
+ if __name__ == "__main__":
43
+ sys.exit(main())
@@ -0,0 +1,169 @@
1
+ """Compare a graph against the weak and the strict control, and say what survived."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from dataclasses import dataclass, field
8
+ from typing import Callable, Dict, List, Optional, Sequence, Union
9
+
10
+ import numpy as np
11
+
12
+ from .graph import Graph
13
+ from .nulls import ensemble, verify
14
+ from .stats import STATS, DEFAULT
15
+
16
+ StatSpec = Union[str, Callable[[Graph], float]]
17
+
18
+
19
+ @dataclass
20
+ class Row:
21
+ stat: str
22
+ real: float
23
+ weak_mean: float
24
+ weak_sd: float
25
+ strict_mean: float
26
+ strict_sd: float
27
+ z_weak: float
28
+ z_strict: float
29
+ fraction_explained_by_degree: float
30
+ invariant_under_strict: bool
31
+ invariant_under_weak: bool
32
+
33
+
34
+ @dataclass
35
+ class Report:
36
+ graph: str
37
+ n_null: int
38
+ swaps_per_edge: int
39
+ fraction_rewired: float
40
+ rows: List[Row] = field(default_factory=list)
41
+ warnings: List[str] = field(default_factory=list)
42
+
43
+ def to_dict(self) -> dict:
44
+ return {
45
+ "graph": self.graph, "n_null": self.n_null, "swaps_per_edge": self.swaps_per_edge,
46
+ "fraction_rewired": self.fraction_rewired,
47
+ "rows": [r.__dict__ for r in self.rows], "warnings": list(self.warnings),
48
+ }
49
+
50
+ def to_json(self, path: Optional[str] = None, indent: int = 1) -> str:
51
+ s = json.dumps(self.to_dict(), indent=indent)
52
+ if path:
53
+ with open(path, "w", encoding="utf-8") as f:
54
+ f.write(s)
55
+ return s
56
+
57
+ def __str__(self) -> str:
58
+ lines = [f"{self.graph}", f"controls: {self.n_null} draws each, strict null rewired {self.fraction_rewired:.1%} of edges", ""]
59
+ head = f"{'statistic':16s} {'real':>10s} {'weak null':>18s} {'strict null':>18s} {'z(weak)':>9s} {'z(strict)':>10s} {'by degree':>10s}"
60
+ lines.append(head)
61
+ lines.append("-" * len(head))
62
+ for r in self.rows:
63
+ fe = "-" if math.isnan(r.fraction_explained_by_degree) else f"{r.fraction_explained_by_degree:9.0%}"
64
+ flag = " INVARIANT" if r.invariant_under_strict else ""
65
+ lines.append(
66
+ f"{r.stat:16s} {r.real:10.4f} {r.weak_mean:9.4f}+-{r.weak_sd:<7.4f} "
67
+ f"{r.strict_mean:9.4f}+-{r.strict_sd:<7.4f} {_fz(r.z_weak):>9s} {_fz(r.z_strict):>10s} {fe:>10s}{flag}")
68
+ if self.warnings:
69
+ lines.append("")
70
+ for w in self.warnings:
71
+ lines.append(f"warning: {w}")
72
+ return "\n".join(lines)
73
+
74
+
75
+ def _fz(z: float) -> str:
76
+ if math.isnan(z):
77
+ return "nan"
78
+ if math.isinf(z):
79
+ return "inf" if z > 0 else "-inf"
80
+ return f"{z:+.1f}"
81
+
82
+
83
+ def _z(real: float, mean: float, sd: float) -> float:
84
+ if sd == 0:
85
+ return 0.0 if real == mean else math.copysign(math.inf, real - mean)
86
+ return (real - mean) / sd
87
+
88
+
89
+ def _resolve(stats: Sequence[StatSpec]) -> Dict[str, Callable[[Graph], float]]:
90
+ out = {}
91
+ for s in stats:
92
+ if callable(s):
93
+ out[getattr(s, "__name__", f"stat{len(out)}")] = s
94
+ elif s in STATS:
95
+ out[s] = STATS[s]
96
+ else:
97
+ raise KeyError(f"unknown statistic {s!r}; known: {sorted(STATS)}")
98
+ return out
99
+
100
+
101
+ def compare(graph: Graph, stats: Sequence[StatSpec] = DEFAULT, n_null: int = 20, seed: int = 0,
102
+ swaps_per_edge: int = 20, keep_weights: bool = True, verbose: bool = False) -> Report:
103
+ """Measure ``stats`` on the graph and on ``n_null`` weak and strict nulls.
104
+
105
+ ``fraction_explained_by_degree`` is (strict_mean - weak_mean) / (real - weak_mean):
106
+ the share of the gap between the graph and the weak control that the
107
+ degree sequence alone reproduces. Near 1 means the "structure" is the
108
+ degree distribution. ``invariant_under_strict`` flags a statistic that is
109
+ identical, to the last digit, on the graph and on every strict null: that
110
+ control did not touch what the statistic measures, so the comparison is
111
+ vacuous for it.
112
+ """
113
+ if n_null < 2:
114
+ raise ValueError("n_null must be at least 2; one draw is not a control")
115
+ fns = _resolve(stats)
116
+ real = {k: float(f(graph)) for k, f in fns.items()}
117
+
118
+ weak_vals = {k: [] for k in fns}
119
+ strict_vals = {k: [] for k in fns}
120
+ rewired = []
121
+ for i, h in enumerate(ensemble(graph, n=n_null, seed=seed, kind="weak")):
122
+ rep = verify(graph, h, strict=False)
123
+ if not rep["n_edges_match"]:
124
+ raise AssertionError("weak null has the wrong edge count")
125
+ for k, f in fns.items():
126
+ weak_vals[k].append(float(f(h)))
127
+ if verbose:
128
+ print(f" weak null {i + 1}/{n_null}")
129
+ for i, h in enumerate(ensemble(graph, n=n_null, seed=seed + 7, kind="strict",
130
+ swaps_per_edge=swaps_per_edge, keep_weights=keep_weights)):
131
+ rep = verify(graph, h, strict=True)
132
+ rewired.append(rep["fraction_rewired"])
133
+ for k, f in fns.items():
134
+ strict_vals[k].append(float(f(h)))
135
+ if verbose:
136
+ print(f" strict null {i + 1}/{n_null} rewired {rep['fraction_rewired']:.1%}")
137
+
138
+ report = Report(graph=repr(graph), n_null=n_null, swaps_per_edge=swaps_per_edge,
139
+ fraction_rewired=float(np.mean(rewired)))
140
+ if report.fraction_rewired < 0.8:
141
+ report.warnings.append(
142
+ f"strict null rewired only {report.fraction_rewired:.0%} of edges; raise swaps_per_edge "
143
+ f"or accept that this graph has little room to rewire (very dense or very constrained degrees)")
144
+ if graph.dropped_self_loops or graph.merged_duplicates:
145
+ report.warnings.append(
146
+ f"graph construction dropped {graph.dropped_self_loops} self-loops and merged "
147
+ f"{graph.merged_duplicates} duplicate edges; nulls are defined on the simple graph")
148
+
149
+ for k in fns:
150
+ w = np.asarray(weak_vals[k]); s = np.asarray(strict_vals[k])
151
+ wm, wsd, sm, ssd = float(w.mean()), float(w.std()), float(s.mean()), float(s.std())
152
+ gap = real[k] - wm
153
+ frac = (sm - wm) / gap if gap != 0 else float("nan")
154
+ inv_s = bool(np.all(s == real[k]))
155
+ inv_w = bool(np.all(w == real[k]))
156
+ row = Row(stat=k, real=real[k], weak_mean=wm, weak_sd=wsd, strict_mean=sm, strict_sd=ssd,
157
+ z_weak=_z(real[k], wm, wsd), z_strict=_z(real[k], sm, ssd),
158
+ fraction_explained_by_degree=float(frac),
159
+ invariant_under_strict=inv_s, invariant_under_weak=inv_w)
160
+ report.rows.append(row)
161
+ if inv_s and inv_w:
162
+ report.warnings.append(
163
+ f"{k!r} is identical on the graph and on every null: this statistic is invariant under both "
164
+ f"controls, so the comparison measures nothing for it")
165
+ elif inv_s:
166
+ report.warnings.append(
167
+ f"{k!r} is identical on the graph and on every strict null: the degree-preserving control "
168
+ f"does not touch what it measures (it is a function of the degree sequence)")
169
+ return report