strictnull 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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
+ ]
strictnull/cli.py ADDED
@@ -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())
strictnull/compare.py ADDED
@@ -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
strictnull/graph.py ADDED
@@ -0,0 +1,151 @@
1
+ """A thin wrapper around igraph.Graph that remembers what it was built from."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import csv
6
+ from typing import Iterable, Optional
7
+
8
+ import numpy as np
9
+
10
+ try:
11
+ import igraph as ig
12
+ except ImportError as exc: # pragma: no cover
13
+ raise ImportError("strictnull needs python-igraph: pip install igraph") from exc
14
+
15
+
16
+ class Graph:
17
+ """Directed or undirected simple graph with optional edge weights.
18
+
19
+ Self-loops are dropped on construction and duplicate edges are merged
20
+ (weights summed), because the null models are defined on simple graphs.
21
+ """
22
+
23
+ def __init__(self, g: "ig.Graph", dropped_self_loops: int = 0, merged_duplicates: int = 0):
24
+ self.g = g
25
+ self.directed = g.is_directed()
26
+ self.dropped_self_loops = dropped_self_loops
27
+ self.merged_duplicates = merged_duplicates
28
+
29
+ # ------------------------------------------------------------ builders
30
+ @classmethod
31
+ def from_edges(cls, edges, weights=None, directed: bool = True, n_nodes: Optional[int] = None) -> "Graph":
32
+ edges = np.asarray(edges, dtype=np.int64)
33
+ if edges.size == 0:
34
+ edges = edges.reshape(0, 2)
35
+ if edges.ndim != 2 or edges.shape[1] != 2:
36
+ raise ValueError(f"edges must have shape (E, 2), got {edges.shape}")
37
+ if weights is not None:
38
+ weights = np.asarray(weights, dtype=np.float64)
39
+ if len(weights) != len(edges):
40
+ raise ValueError("weights length must match edges length")
41
+ if n_nodes is None:
42
+ n_nodes = int(edges.max()) + 1 if len(edges) else 0
43
+
44
+ keep = edges[:, 0] != edges[:, 1]
45
+ dropped = int((~keep).sum())
46
+ edges = edges[keep]
47
+ if weights is not None:
48
+ weights = weights[keep]
49
+
50
+ if not directed:
51
+ edges = np.sort(edges, axis=1)
52
+ merged = {}
53
+ for i, (u, v) in enumerate(map(tuple, edges.tolist())):
54
+ if (u, v) in merged:
55
+ if weights is not None:
56
+ merged[(u, v)] += float(weights[i])
57
+ else:
58
+ merged[(u, v)] = float(weights[i]) if weights is not None else None
59
+ n_dup = len(edges) - len(merged)
60
+
61
+ g = ig.Graph(n=n_nodes, edges=list(merged.keys()), directed=directed)
62
+ if weights is not None:
63
+ g.es["weight"] = list(merged.values())
64
+ return cls(g, dropped_self_loops=dropped, merged_duplicates=n_dup)
65
+
66
+ @classmethod
67
+ def from_adjacency(cls, A, directed: bool = True) -> "Graph":
68
+ A = np.asarray(A)
69
+ if A.ndim != 2 or A.shape[0] != A.shape[1]:
70
+ raise ValueError("adjacency must be square")
71
+ src, dst = np.nonzero(A)
72
+ if not directed:
73
+ keep = src <= dst
74
+ src, dst = src[keep], dst[keep]
75
+ w = A[src, dst].astype(np.float64)
76
+ return cls.from_edges(np.stack([src, dst], axis=1), w, directed=directed, n_nodes=A.shape[0])
77
+
78
+ @classmethod
79
+ def from_networkx(cls, G) -> "Graph":
80
+ nodes = list(G.nodes())
81
+ index = {n: i for i, n in enumerate(nodes)}
82
+ edges, weights, has_w = [], [], False
83
+ for u, v, d in G.edges(data=True):
84
+ edges.append((index[u], index[v]))
85
+ if "weight" in d:
86
+ has_w = True
87
+ weights.append(float(d.get("weight", 1.0)))
88
+ out = cls.from_edges(edges, weights if has_w else None, directed=G.is_directed(), n_nodes=len(nodes))
89
+ out.labels = nodes
90
+ return out
91
+
92
+ @classmethod
93
+ def from_csv(cls, path, directed: bool = True, delimiter: str = ",") -> "Graph":
94
+ """Edge list with a header. Columns: source, target[, weight]. Node ids may be any strings."""
95
+ rows = []
96
+ with open(path, newline="", encoding="utf-8") as f:
97
+ reader = csv.reader(f, delimiter=delimiter)
98
+ header = next(reader)
99
+ if len(header) < 2:
100
+ raise ValueError("need at least two columns: source, target")
101
+ has_w = len(header) >= 3
102
+ for r in reader:
103
+ if not r or not r[0].strip():
104
+ continue
105
+ rows.append((r[0].strip(), r[1].strip(), float(r[2]) if has_w and len(r) > 2 and r[2].strip() else 1.0))
106
+ labels = sorted({r[0] for r in rows} | {r[1] for r in rows})
107
+ index = {n: i for i, n in enumerate(labels)}
108
+ edges = [(index[a], index[b]) for a, b, _ in rows]
109
+ weights = [w for _, _, w in rows] if has_w else None
110
+ out = cls.from_edges(edges, weights, directed=directed, n_nodes=len(labels))
111
+ out.labels = labels
112
+ return out
113
+
114
+ # ------------------------------------------------------------ accessors
115
+ @property
116
+ def n_nodes(self) -> int:
117
+ return self.g.vcount()
118
+
119
+ @property
120
+ def n_edges(self) -> int:
121
+ return self.g.ecount()
122
+
123
+ @property
124
+ def weighted(self) -> bool:
125
+ return "weight" in self.g.edge_attributes()
126
+
127
+ def edges(self) -> np.ndarray:
128
+ e = np.array(self.g.get_edgelist(), dtype=np.int64)
129
+ return e.reshape(-1, 2)
130
+
131
+ def weights(self) -> Optional[np.ndarray]:
132
+ return np.asarray(self.g.es["weight"], dtype=np.float64) if self.weighted else None
133
+
134
+ def degrees(self):
135
+ """(in, out) for directed graphs; (deg, deg) for undirected."""
136
+ if self.directed:
137
+ return (np.asarray(self.g.degree(mode="in"), dtype=np.int64),
138
+ np.asarray(self.g.degree(mode="out"), dtype=np.int64))
139
+ d = np.asarray(self.g.degree(), dtype=np.int64)
140
+ return d, d
141
+
142
+ def copy(self) -> "Graph":
143
+ out = Graph(self.g.copy(), self.dropped_self_loops, self.merged_duplicates)
144
+ if hasattr(self, "labels"):
145
+ out.labels = self.labels
146
+ return out
147
+
148
+ def __repr__(self) -> str:
149
+ kind = "directed" if self.directed else "undirected"
150
+ w = ", weighted" if self.weighted else ""
151
+ return f"Graph({kind}{w}, {self.n_nodes} nodes, {self.n_edges} edges)"
strictnull/nulls.py ADDED
@@ -0,0 +1,116 @@
1
+ """The two controls, and the check that makes them controls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from typing import Iterator
7
+
8
+ import numpy as np
9
+ import igraph as ig
10
+
11
+ from .graph import Graph
12
+
13
+
14
+ def weak_null(graph: Graph, seed: int = 0) -> Graph:
15
+ """Erdős–Rényi control: same node count and edge count, nothing else.
16
+
17
+ Weights, if present, are shuffled onto the new edges, so the weight
18
+ distribution is preserved but its placement is random. This is the
19
+ control most papers use. On its own it cannot tell wiring from degree.
20
+ """
21
+ rng = np.random.default_rng(seed)
22
+ n, m = graph.n_nodes, graph.n_edges
23
+ directed = graph.directed
24
+ max_edges = n * (n - 1) if directed else n * (n - 1) // 2
25
+ if m > max_edges:
26
+ raise ValueError("more edges than a simple graph can hold")
27
+
28
+ seen, out = set(), []
29
+ while len(out) < m:
30
+ need = m - len(out)
31
+ a = rng.integers(0, n, size=need * 2 + 8)
32
+ b = rng.integers(0, n, size=need * 2 + 8)
33
+ for u, v in zip(a.tolist(), b.tolist()):
34
+ if u == v:
35
+ continue
36
+ key = (u, v) if directed else (min(u, v), max(u, v))
37
+ if key in seen:
38
+ continue
39
+ seen.add(key)
40
+ out.append(key)
41
+ if len(out) == m:
42
+ break
43
+
44
+ g = ig.Graph(n=n, edges=out, directed=directed)
45
+ if graph.weighted:
46
+ g.es["weight"] = rng.permutation(graph.weights()).tolist()
47
+ return Graph(g)
48
+
49
+
50
+ def strict_null(graph: Graph, seed: int = 0, swaps_per_edge: int = 20, keep_weights: bool = True) -> Graph:
51
+ """Degree-preserving control: every node keeps its exact degree.
52
+
53
+ Directed graphs keep each node's in-degree and out-degree separately.
54
+ Implemented with repeated double-edge swaps restricted to simple graphs
55
+ (the configuration model conditioned on no self-loops or multi-edges).
56
+ ``swaps_per_edge`` of 10-20 is enough to forget the original wiring; the
57
+ report from ``verify`` says how much was actually rewired.
58
+
59
+ ``keep_weights`` carries each weight with the edge slot it sat in, so the
60
+ weight multiset is preserved. ``keep_weights=False`` additionally
61
+ shuffles weights across edges.
62
+ """
63
+ if swaps_per_edge <= 0:
64
+ raise ValueError("swaps_per_edge must be positive")
65
+ g = graph.g.copy()
66
+ ig.set_random_number_generator(random.Random(seed))
67
+ n_swaps = swaps_per_edge * max(graph.n_edges, 1)
68
+ try:
69
+ try:
70
+ g.rewire(n=n_swaps, allowed_edge_types="simple") # igraph >= 1.0
71
+ except TypeError:
72
+ g.rewire(n=n_swaps, mode="simple") # igraph 0.10 / 0.11
73
+ finally:
74
+ ig.set_random_number_generator(random)
75
+ if graph.weighted:
76
+ w = graph.weights()
77
+ g.es["weight"] = w.tolist() if keep_weights else np.random.default_rng(seed + 1).permutation(w).tolist()
78
+ return Graph(g)
79
+
80
+
81
+ def ensemble(graph: Graph, n: int = 20, seed: int = 0, kind: str = "strict", **kwargs) -> Iterator[Graph]:
82
+ """Yield ``n`` independent nulls. One draw is not a control; it is one draw."""
83
+ fn = {"strict": strict_null, "weak": weak_null}[kind]
84
+ for i in range(n):
85
+ yield fn(graph, seed=seed + 1000 * i, **kwargs)
86
+
87
+
88
+ def verify(original: Graph, null: Graph, strict: bool = True) -> dict:
89
+ """Check that a null is what it claims to be. Raises if ``strict`` and degrees moved."""
90
+ oi, oo = original.degrees()
91
+ ni, no = null.degrees()
92
+ el = null.g.get_edgelist()
93
+ report = {
94
+ "n_nodes_match": original.n_nodes == null.n_nodes,
95
+ "n_edges_match": original.n_edges == null.n_edges,
96
+ "in_degree_exact": bool(np.array_equal(oi, ni)),
97
+ "out_degree_exact": bool(np.array_equal(oo, no)),
98
+ "self_loops": int(sum(1 for u, v in el if u == v)),
99
+ "multi_edges": null.n_edges - len(set(el)),
100
+ }
101
+ if not original.directed:
102
+ report["degree_exact"] = report.pop("in_degree_exact")
103
+ report.pop("out_degree_exact")
104
+ orig_set = set(original.g.get_edgelist())
105
+ shared = len(orig_set & set(el))
106
+ report["edges_retained"] = shared
107
+ report["fraction_rewired"] = 1.0 - shared / max(len(orig_set), 1)
108
+ if original.weighted and null.weighted:
109
+ report["weight_multiset_preserved"] = bool(np.allclose(np.sort(original.weights()), np.sort(null.weights())))
110
+ if strict:
111
+ deg_ok = report.get("degree_exact", report.get("in_degree_exact", False) and report.get("out_degree_exact", False))
112
+ if not deg_ok:
113
+ raise AssertionError("degree sequence was not preserved")
114
+ if report["self_loops"] or report["multi_edges"]:
115
+ raise AssertionError("null contains self-loops or multi-edges")
116
+ return report
strictnull/stats.py ADDED
@@ -0,0 +1,83 @@
1
+ """Graph statistics. Each takes a Graph and returns a float.
2
+
3
+ Register your own with ``@register("name")`` or pass callables to ``compare``.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Callable, Dict
9
+
10
+ from .graph import Graph
11
+
12
+ STATS: Dict[str, Callable[[Graph], float]] = {}
13
+
14
+
15
+ def register(name: str):
16
+ def deco(fn):
17
+ STATS[name] = fn
18
+ return fn
19
+ return deco
20
+
21
+
22
+ def _undirected(graph: Graph):
23
+ if not graph.directed:
24
+ return graph.g
25
+ u = graph.g.copy()
26
+ u.to_undirected(combine_edges="first")
27
+ return u
28
+
29
+
30
+ @register("reciprocity")
31
+ def reciprocity(graph: Graph) -> float:
32
+ """Fraction of edges whose reverse edge also exists. Undirected graphs: 1.0."""
33
+ return float(graph.g.reciprocity()) if graph.directed else 1.0
34
+
35
+
36
+ @register("clustering")
37
+ def clustering(graph: Graph) -> float:
38
+ """Global transitivity of the underlying undirected graph."""
39
+ return float(_undirected(graph).transitivity_undirected(mode="zero"))
40
+
41
+
42
+ @register("avg_clustering")
43
+ def avg_clustering(graph: Graph) -> float:
44
+ return float(_undirected(graph).transitivity_avglocal_undirected(mode="zero"))
45
+
46
+
47
+ @register("avg_path")
48
+ def avg_path(graph: Graph) -> float:
49
+ """Mean shortest path over connected pairs, ignoring direction."""
50
+ return float(_undirected(graph).average_path_length(directed=False))
51
+
52
+
53
+ @register("assortativity")
54
+ def assortativity(graph: Graph) -> float:
55
+ return float(_undirected(graph).assortativity_degree(directed=False))
56
+
57
+
58
+ @register("n_triangles")
59
+ def n_triangles(graph: Graph) -> float:
60
+ return float(len(_undirected(graph).cliques(min=3, max=3)))
61
+
62
+
63
+ @register("max_core")
64
+ def max_core(graph: Graph) -> float:
65
+ return float(max(_undirected(graph).coreness()) if graph.n_nodes else 0.0)
66
+
67
+
68
+ @register("modularity_leiden")
69
+ def modularity_leiden(graph: Graph) -> float:
70
+ """Modularity of the best Leiden partition (undirected). Seeded for reproducibility."""
71
+ u = _undirected(graph)
72
+ part = u.community_leiden(objective_function="modularity", n_iterations=-1)
73
+ return float(part.modularity)
74
+
75
+
76
+ @register("mean_weight")
77
+ def mean_weight(graph: Graph) -> float:
78
+ """Present so an invariant statistic is on hand: weights are carried by every null."""
79
+ w = graph.weights()
80
+ return float(w.mean()) if w is not None and len(w) else float("nan")
81
+
82
+
83
+ DEFAULT = ["reciprocity", "clustering", "avg_path", "assortativity"]
@@ -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,12 @@
1
+ strictnull/__init__.py,sha256=mE2RnCFOO5-5sBrmRgfUU-_Zad5QcT_V4H6LfVJGyVQ,903
2
+ strictnull/cli.py,sha256=542jlksQzPAcpLQmGcnOVA_dnswMhzeMT1kSuoTDc-M,1808
3
+ strictnull/compare.py,sha256=Nzu7rzmQe3VCXL2vIhbCIPjlCicTEdpqZ9KOVk4P2wc,6967
4
+ strictnull/graph.py,sha256=_NYXa37HuWqb4O1BtgBwO4HZUYNtJgNx03At2vMUDRI,5988
5
+ strictnull/nulls.py,sha256=W28vwFr4_DJRmmTVnVqRu9NPePclUSUyqGx5Y8eJfr4,4737
6
+ strictnull/stats.py,sha256=8oEj8k6_gCSppYbMsBt-eHA6_gFc9ZapyBPZcGDAz2Y,2398
7
+ strictnull-0.1.0.dist-info/licenses/LICENSE,sha256=LsE50bq1amk_QUYNjCk785wcz3x-j6KitwyCim2U1ko,1068
8
+ strictnull-0.1.0.dist-info/METADATA,sha256=ZnrsZ7XwT7Tc36v_fB0yRDWPcg1nZZE83Aqri8JUlXE,8272
9
+ strictnull-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ strictnull-0.1.0.dist-info/entry_points.txt,sha256=eHAfcX8iZg05wwYTkmtgdvQB1a4Z2r2qxCV2NoygMB0,51
11
+ strictnull-0.1.0.dist-info/top_level.txt,sha256=MIWwYfOZtkEmyVWwEyiw9dP2h1nFh8XZPNZroDTTq74,11
12
+ strictnull-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ strictnull = strictnull.cli:main
@@ -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 @@
1
+ strictnull