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.
- strictnull-0.1.0/LICENSE +21 -0
- strictnull-0.1.0/PKG-INFO +177 -0
- strictnull-0.1.0/README.md +152 -0
- strictnull-0.1.0/pyproject.toml +38 -0
- strictnull-0.1.0/setup.cfg +4 -0
- strictnull-0.1.0/strictnull/__init__.py +26 -0
- strictnull-0.1.0/strictnull/cli.py +43 -0
- strictnull-0.1.0/strictnull/compare.py +169 -0
- strictnull-0.1.0/strictnull/graph.py +151 -0
- strictnull-0.1.0/strictnull/nulls.py +116 -0
- strictnull-0.1.0/strictnull/stats.py +83 -0
- strictnull-0.1.0/strictnull.egg-info/PKG-INFO +177 -0
- strictnull-0.1.0/strictnull.egg-info/SOURCES.txt +17 -0
- strictnull-0.1.0/strictnull.egg-info/dependency_links.txt +1 -0
- strictnull-0.1.0/strictnull.egg-info/entry_points.txt +2 -0
- strictnull-0.1.0/strictnull.egg-info/requires.txt +8 -0
- strictnull-0.1.0/strictnull.egg-info/top_level.txt +1 -0
- strictnull-0.1.0/tests/test_compare.py +86 -0
- strictnull-0.1.0/tests/test_nulls.py +103 -0
strictnull-0.1.0/LICENSE
ADDED
|
@@ -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,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
|