timfromhcs's picture
FlyBrain V5.0.0 Space sync (v1 API, backup, watchdog, fixed tabs)
ee1153c verified
Raw
History Blame Contribute Delete
6.61 kB
"""Deterministic topology null models (V5 phase_05).
Controls for testing whether observed effects depend on the empirical
topology rather than on size/density/weight statistics:
EDGE_SHUFFLED - same N/M, targets rewired uniformly (seeded)
DEGREE_PRESERVING_RANDOM - in+out degree sequences preserved via
stub-matching (seeded, no self-loops)
WEIGHT_SHUFFLED - topology fixed, weights permuted (seeded)
Every null graph carries provenance_metadata["null_model"] naming the
transform + the source graph hash, and empirical=False: a null derived
from REAL topology is NEVER labelled VERIFIED biology.
"""
import hashlib
from typing import Dict, List
import numpy as np
from src.connectome.loader import build_population_registry
from src.connectome.types import ConnectomeGraph, GraphMode, ProvenanceStatus
NULL_MODELS = ("EDGE_SHUFFLED", "DEGREE_PRESERVING_RANDOM", "WEIGHT_SHUFFLED")
def _edges_incoming(graph: ConnectomeGraph) -> List[List[int]]:
N = graph.num_neurons
out: List[List[int]] = [[] for _ in range(N)]
for post in range(N):
s, e = int(graph.row_offsets[post]), int(graph.row_offsets[post + 1])
out[post] = [int(c) for c in graph.col_indices[s:e]]
return out
def _build_csr(N: int, incoming: List[List[int]], weights: List[List[float]]):
row = [0]
cols: List[int] = []
wts: List[float] = []
for i in range(N):
order = sorted(range(len(incoming[i])), key=lambda k: incoming[i][k])
for k in order:
cols.append(incoming[i][k])
wts.append(weights[i][k])
row.append(len(cols))
return (np.array(row, dtype=np.int32), np.array(cols, dtype=np.int32),
np.array(wts, dtype=np.float32))
def build_null_graph(graph: ConnectomeGraph, null_model: str, seed: int = 42
) -> ConnectomeGraph:
if null_model not in NULL_MODELS:
raise ValueError(f"unknown null model {null_model!r}; choices {NULL_MODELS}")
rng = np.random.RandomState(seed)
N = graph.num_neurons
incoming = _edges_incoming(graph)
M = sum(len(r) for r in incoming)
w_in = [[float(graph.weights[k]) for k in
range(int(graph.row_offsets[i]), int(graph.row_offsets[i + 1]))]
for i in range(N)]
if null_model == "EDGE_SHUFFLED":
all_w = [w for row in w_in for w in row]
perm = rng.permutation(len(all_w))
flat_w = [all_w[p] for p in perm]
new_in = [[int(rng.randint(N)) for _ in row] for row in incoming]
# drop accidental self-loops deterministically (shift target)
new_in = [[(t + 1) % N if t == i else t for t in row]
for i, row in enumerate(new_in)]
pos = 0
new_w = []
for row in new_in:
new_w.append(flat_w[pos:pos + len(row)])
pos += len(row)
elif null_model == "WEIGHT_SHUFFLED":
all_w = [w for row in w_in for w in row]
perm = rng.permutation(len(all_w))
flat_w = [all_w[p] for p in perm]
new_in = [list(row) for row in incoming]
pos = 0
new_w = []
for row in new_in:
new_w.append(flat_w[pos:pos + len(row)])
pos += len(row)
else: # DEGREE_PRESERVING_RANDOM via directed stub matching
out_stubs: List[int] = []
in_stubs: List[int] = []
out_deg = [0] * N
in_deg = [len(r) for r in incoming]
for post, row in enumerate(incoming):
for pre in row:
out_stubs.append(pre)
in_stubs.append(post)
out_deg[pre] += 1
rng.shuffle(out_stubs)
rng.shuffle(in_stubs)
pairs = []
used = set()
left_o, left_i = list(out_stubs), list(in_stubs)
attempts = 0
while left_o and attempts < 20 * max(1, M):
attempts += 1
o = left_o.pop(0)
# find a non-self, non-duplicate target
pick = None
for j, t in enumerate(left_i):
if t != o and (o, t) not in used:
pick = j
break
if pick is None:
left_o.append(o)
continue
t = left_i.pop(pick)
used.add((o, t))
pairs.append((o, t))
# any leftovers (rare, dense graphs): deterministic fallback edges
for o in left_o:
t = next((c for c in range(N) if c != o and (o, c) not in used), None)
if t is not None:
used.add((o, t))
pairs.append((o, t))
new_in = [[] for _ in range(N)]
new_w_dict: Dict[int, List[float]] = {i: [] for i in range(N)}
# weights: permuted source weights keep the marginal distribution
all_w = [w for row in w_in for w in row]
rng.shuffle(all_w)
wi = 0
for (o, t) in sorted(pairs):
new_in[t].append(o)
new_w_dict[t].append(all_w[wi % len(all_w)] if all_w else 0.05)
wi += 1
new_w = [new_w_dict[i] for i in range(N)]
# verify degree preservation (raises loudly if violated)
got_in = [len(r) for r in new_in]
got_out = [0] * N
for row in new_in:
for pre in row:
got_out[pre] += 1
if got_in != in_deg or got_out != out_deg:
raise ValueError("degree preservation failed; refusing null graph")
row_offsets, col_indices, weights = _build_csr(N, new_in, new_w)
populations = build_population_registry(graph.neuron_ids, graph.coordinates,
graph.tbars, list(graph.sides))
meta = dict(getattr(graph, "provenance_metadata", {}) or {})
meta.update({
"null_model": null_model,
"null_seed": int(seed),
"derived_from_graph_hash": graph.graph_hash,
"empirical": False,
"note": f"{null_model} control derived from {meta.get('graph_identity', graph.mode.value)}; "
"NOT empirical biology regardless of source.",
})
return ConnectomeGraph(
neuron_ids=np.copy(graph.neuron_ids),
coordinates=np.copy(graph.coordinates),
tbars=np.copy(graph.tbars),
sides=list(graph.sides),
row_offsets=row_offsets,
col_indices=col_indices,
weights=weights,
mode=graph.mode,
provenance_status=(ProvenanceStatus.EXPERIMENTAL
if graph.mode == GraphMode.REAL else graph.provenance_status),
populations=populations,
provenance_metadata=meta,
)