Repository navigation
Expand file tree
/
Copy pathinterface_baselines.py
More file actions
385 lines (323 loc) · 13.9 KB
/
Copy pathinterface_baselines.py
File metadata and controls
385 lines (323 loc) · 13.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
"""Classical graph-local baselines for structural-interface benchmarks.
This module is the *fair-comparison* layer for TNFR Structural Interface Theory.
It provides classical, well-understood, graph-local node scores that any TNFR
interface claim must be compared against. The closest classical analogue to the
TNFR phase-gate stress is :func:`local_disagreement`; the remaining baselines
(graph total variation, local class entropy, label-propagation residual, graph
cut contribution, neighbour distance, degree, feature deviation, and
constant/random controls) widen the comparison so that any reported TNFR
advantage is measured against strong, not only weak, references.
Design rules
------------
- Pure-Python and deterministic. ``random_baseline`` uses a seeded
:class:`random.Random` over a stable node order; everything else is exact.
- Read-only: no graph attribute is mutated.
- Each baseline returns a ``dict[node, float]`` so it can be ranked uniformly by
:func:`tnfr.validation.structural_interface.evaluate_interface_scores`.
- Formulas are documented per function (Milestone 2 acceptance criterion).
Honest note
-----------
For a *binary* label encoded as a phase (0 vs ``π``), graph total variation on
the phase signal is proportional to :func:`local_disagreement`. This is stated
explicitly rather than hidden: the two baselines coincide up to a scale factor
when the only per-node signal is the label itself.
References
----------
- ``docs/STRUCTURAL_INTERFACE_THEORY.md`` §"Fair benchmark design"
"""
from __future__ import annotations
import math
import random
from typing import Any, Mapping
try:
import networkx as nx
except ImportError: # pragma: no cover - optional dependency guard
nx = None # type: ignore[assignment]
__all__ = [
"local_disagreement",
"graph_total_variation",
"local_class_entropy",
"label_propagation_residual",
"graph_cut_contribution",
"mean_neighbour_distance",
"degree_score",
"feature_deviation",
"constant_baseline",
"random_baseline",
"compute_all_baselines",
"BASELINE_FORMULAS",
]
_DEFAULT_DISTANCE_KEY = "distance"
#: Short human-readable formula notes, surfaced in reports for transparency.
BASELINE_FORMULAS: Mapping[str, str] = {
"local_disagreement": "count of neighbours whose state differs from the node",
"graph_total_variation": "sum_j |v_i - v_j| over incident edges (v = numeric signal or label code)",
"local_class_entropy": "Shannon entropy of class counts over the closed neighbourhood, normalized by log(#classes)",
"label_propagation_residual": "1 - f_i[own_class] after clamped label propagation (alpha, iterations)",
"graph_cut_contribution": "sum over cross-class incident edges of similarity weight 1/(1+distance)",
"mean_neighbour_distance": "mean feature-space distance to neighbours (edge distance attribute)",
"degree": "node degree",
"feature_deviation": "|x_i - mean(x)| / std(x) for a chosen numeric feature",
"constant": "constant 1.0 for every node (control)",
"random": "uniform[0,1) per node from a seeded RNG (control)",
}
def _require_networkx() -> None:
if nx is None: # pragma: no cover - optional dependency guard
raise RuntimeError("networkx is required for interface baselines")
def _stable_nodes(G: Any) -> list[Any]:
"""Return graph nodes in a deterministic order (by ``repr``)."""
return sorted(G.nodes(), key=lambda node: repr(node))
def _has_numeric_attr(G: Any, key: str) -> bool:
for node in G.nodes():
value = G.nodes[node].get(key)
if not isinstance(value, (int, float)) or isinstance(value, bool):
return False
return G.number_of_nodes() > 0
# ---------------------------------------------------------------------------
# Boundary-sensitive baselines
# ---------------------------------------------------------------------------
def local_disagreement(G: Any, *, state_key: str) -> dict[Any, float]:
"""Count, per node, the neighbours whose ``state_key`` differs.
Formula: ``score_i = |{ j ~ i : state_j != state_i }|``.
This is the closest classical analogue to TNFR phase-gate violations and is
the primary reference baseline for any interface claim.
"""
_require_networkx()
scores: dict[Any, float] = {}
for node in G.nodes():
own = G.nodes[node].get(state_key)
scores[node] = float(
sum(
1
for neighbour in G.neighbors(node)
if G.nodes[neighbour].get(state_key) != own
)
)
return scores
def _state_codes(G: Any, state_key: str) -> dict[Any, int]:
classes = sorted(
{G.nodes[node].get(state_key) for node in G.nodes()}, key=lambda c: repr(c)
)
return {cls: code for code, cls in enumerate(classes)}
def graph_total_variation(
G: Any,
*,
value_key: str | None = None,
state_key: str | None = None,
) -> dict[Any, float]:
"""Per-node graph total variation of a numeric signal.
Formula: ``score_i = sum_{j ~ i} |v_i - v_j|``.
When ``value_key`` is given, ``v`` is that numeric node attribute. Otherwise
``state_key`` must be given and categories are mapped to integer codes
(``v`` = code). For a binary label this is proportional to
:func:`local_disagreement`.
"""
_require_networkx()
if value_key is None and state_key is None:
raise ValueError("provide value_key or state_key")
if value_key is not None:
value = {node: float(G.nodes[node].get(value_key, 0.0)) for node in G.nodes()}
else:
codes = _state_codes(G, state_key) # type: ignore[arg-type]
value = {
node: float(codes.get(G.nodes[node].get(state_key), 0))
for node in G.nodes()
}
scores: dict[Any, float] = {}
for node in G.nodes():
own = value[node]
scores[node] = float(
sum(abs(own - value[neighbour]) for neighbour in G.neighbors(node))
)
return scores
def local_class_entropy(
G: Any, *, state_key: str, normalize: bool = True
) -> dict[Any, float]:
"""Shannon entropy of class counts over each closed neighbourhood.
The closed neighbourhood of ``i`` is ``{i} ∪ N(i)``. Higher entropy means a
more mixed neighbourhood, i.e. a stronger interface. When ``normalize`` is
True the entropy is divided by ``log(#global_classes)`` so scores lie in
``[0, 1]``.
"""
_require_networkx()
global_classes = {G.nodes[node].get(state_key) for node in G.nodes()}
norm = math.log(len(global_classes)) if len(global_classes) > 1 else 0.0
scores: dict[Any, float] = {}
for node in G.nodes():
counts: dict[Any, int] = {}
members = [node, *G.neighbors(node)]
for member in members:
cls = G.nodes[member].get(state_key)
counts[cls] = counts.get(cls, 0) + 1
total = sum(counts.values())
entropy = 0.0
if total > 0:
for count in counts.values():
p = count / total
entropy -= p * math.log(p)
if normalize and norm > 0:
scores[node] = float(entropy / norm)
else:
scores[node] = float(entropy)
return scores
def label_propagation_residual(
G: Any,
*,
state_key: str,
alpha: float = 0.85,
iterations: int = 30,
) -> dict[Any, float]:
"""Clamped label-propagation disagreement residual.
Each node starts with a one-hot class vector. At every iteration the soft
label is updated as ``f_i <- (1 - alpha) * seed_i + alpha * mean_j f_j``.
After ``iterations`` steps the residual is ``1 - f_i[own_class]`` (the soft
probability mass that propagation moved away from the node's own class).
Boundary nodes accumulate larger residuals. Deterministic.
"""
_require_networkx()
nodes = list(G.nodes())
classes = sorted(
{G.nodes[node].get(state_key) for node in nodes}, key=lambda c: repr(c)
)
if len(classes) <= 1:
return {node: 0.0 for node in nodes}
index = {cls: i for i, cls in enumerate(classes)}
k = len(classes)
seed = {node: [0.0] * k for node in nodes}
for node in nodes:
seed[node][index[G.nodes[node].get(state_key)]] = 1.0
f = {node: list(seed[node]) for node in nodes}
for _ in range(max(1, int(iterations))):
updated: dict[Any, list[float]] = {}
for node in nodes:
neighbours = list(G.neighbors(node))
if neighbours:
acc = [0.0] * k
for neighbour in neighbours:
fj = f[neighbour]
for c in range(k):
acc[c] += fj[c]
inv = 1.0 / len(neighbours)
propagated = [value * inv for value in acc]
else:
propagated = list(seed[node])
updated[node] = [
(1.0 - alpha) * seed[node][c] + alpha * propagated[c] for c in range(k)
]
f = updated
residual: dict[Any, float] = {}
for node in nodes:
own = index[G.nodes[node].get(state_key)]
total = sum(f[node]) or 1.0
residual[node] = float(1.0 - f[node][own] / total)
return residual
def graph_cut_contribution(
G: Any,
*,
state_key: str,
distance_key: str = _DEFAULT_DISTANCE_KEY,
) -> dict[Any, float]:
"""Per-node contribution to a similarity-weighted class cut.
Formula: ``score_i = sum_{j ~ i, state_j != state_i} 1 / (1 + distance_ij)``.
Unlike :func:`local_disagreement` (a raw count), this weights cross-class
edges by feature-space proximity, so a node that is *close* to a
differently-labelled neighbour scores higher than one whose cross-class
neighbour is far.
"""
_require_networkx()
scores: dict[Any, float] = {}
for node in G.nodes():
own = G.nodes[node].get(state_key)
total = 0.0
for neighbour in G.neighbors(node):
if G.nodes[neighbour].get(state_key) != own:
distance = float(G.edges[node, neighbour].get(distance_key, 0.0))
total += 1.0 / (1.0 + distance)
scores[node] = float(total)
return scores
# ---------------------------------------------------------------------------
# Topology / feature / control baselines
# ---------------------------------------------------------------------------
def mean_neighbour_distance(
G: Any, *, distance_key: str = _DEFAULT_DISTANCE_KEY
) -> dict[Any, float]:
"""Mean feature-space distance to neighbours (edge ``distance_key``)."""
_require_networkx()
scores: dict[Any, float] = {}
for node in G.nodes():
distances = [
float(G.edges[node, neighbour].get(distance_key, 0.0))
for neighbour in G.neighbors(node)
]
scores[node] = sum(distances) / len(distances) if distances else 0.0
return scores
def degree_score(G: Any) -> dict[Any, float]:
"""Node degree (topology-only control)."""
_require_networkx()
return {node: float(G.degree[node]) for node in G.nodes()}
def feature_deviation(G: Any, *, value_key: str) -> dict[Any, float]:
"""Standardized absolute deviation of a numeric feature from its mean.
Formula: ``score_i = |x_i - mean(x)| / std(x)`` (std clamped to 1 if zero).
A simple, domain-agnostic feature baseline.
"""
_require_networkx()
values = {node: float(G.nodes[node].get(value_key, 0.0)) for node in G.nodes()}
if not values:
return {}
mean = sum(values.values()) / len(values)
variance = sum((v - mean) ** 2 for v in values.values()) / len(values)
std = math.sqrt(variance) or 1.0
return {node: abs(value - mean) / std for node, value in values.items()}
def constant_baseline(G: Any, *, value: float = 1.0) -> dict[Any, float]:
"""Constant score for every node (trivial control)."""
_require_networkx()
return {node: float(value) for node in G.nodes()}
def random_baseline(G: Any, *, seed: int = 0) -> dict[Any, float]:
"""Deterministic uniform[0,1) score per node from a seeded RNG."""
_require_networkx()
rng = random.Random(int(seed))
return {node: rng.random() for node in _stable_nodes(G)}
# ---------------------------------------------------------------------------
# Aggregator
# ---------------------------------------------------------------------------
def compute_all_baselines(
G: Any,
*,
state_key: str,
distance_key: str = _DEFAULT_DISTANCE_KEY,
phase_key: str = "phase",
feature_key: str | None = None,
seed: int = 0,
) -> dict[str, dict[Any, float]]:
"""Compute the full classical baseline suite as named node-score maps.
The graph-total-variation baseline uses the numeric ``phase_key`` signal when
every node carries it; otherwise it falls back to state-code total variation
(documented to coincide with :func:`local_disagreement` up to scaling for
binary labels). ``feature_key`` adds :func:`feature_deviation` when supplied.
"""
_require_networkx()
use_phase = _has_numeric_attr(G, phase_key)
maps: dict[str, dict[Any, float]] = {
"local_disagreement": local_disagreement(G, state_key=state_key),
"graph_total_variation": graph_total_variation(
G,
value_key=phase_key if use_phase else None,
state_key=None if use_phase else state_key,
),
"local_class_entropy": local_class_entropy(G, state_key=state_key),
"label_propagation_residual": label_propagation_residual(
G, state_key=state_key
),
"graph_cut_contribution": graph_cut_contribution(
G, state_key=state_key, distance_key=distance_key
),
"mean_neighbour_distance": mean_neighbour_distance(
G, distance_key=distance_key
),
"degree": degree_score(G),
"constant": constant_baseline(G),
"random": random_baseline(G, seed=seed),
}
if feature_key is not None:
maps["feature_deviation"] = feature_deviation(G, value_key=feature_key)
return maps