groupoid 0.1.0.dev1__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.
- groupoid/__init__.py +31 -0
- groupoid/aggregation.py +234 -0
- groupoid/cohomology.py +99 -0
- groupoid/groupoid.py +85 -0
- groupoid/laplacian.py +201 -0
- groupoid/manifold.py +71 -0
- groupoid/optimizer.py +208 -0
- groupoid/persistence.py +195 -0
- groupoid/sheaf.py +84 -0
- groupoid/transport.py +188 -0
- groupoid-0.1.0.dev1.dist-info/METADATA +258 -0
- groupoid-0.1.0.dev1.dist-info/RECORD +15 -0
- groupoid-0.1.0.dev1.dist-info/WHEEL +5 -0
- groupoid-0.1.0.dev1.dist-info/licenses/LICENSE +191 -0
- groupoid-0.1.0.dev1.dist-info/top_level.txt +1 -0
groupoid/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""GROUPOID: Groupoid-based federated learning with Riemannian geometry."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0.dev1"
|
|
4
|
+
|
|
5
|
+
from groupoid.aggregation import (
|
|
6
|
+
DisconnectedClientGraphError,
|
|
7
|
+
FederatedRound,
|
|
8
|
+
TransportGroupoidAggregator,
|
|
9
|
+
)
|
|
10
|
+
from groupoid.cohomology import IncompleteCocycleError, compute_h1
|
|
11
|
+
from groupoid.groupoid import CompositionError, Morphism, compose, inverse
|
|
12
|
+
from groupoid.laplacian import SpectralSummary, spectral_analysis
|
|
13
|
+
from groupoid.manifold import karcher_mean
|
|
14
|
+
from groupoid.sheaf import Sheaf
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"__version__",
|
|
18
|
+
"CompositionError",
|
|
19
|
+
"DisconnectedClientGraphError",
|
|
20
|
+
"FederatedRound",
|
|
21
|
+
"IncompleteCocycleError",
|
|
22
|
+
"Morphism",
|
|
23
|
+
"Sheaf",
|
|
24
|
+
"SpectralSummary",
|
|
25
|
+
"TransportGroupoidAggregator",
|
|
26
|
+
"compose",
|
|
27
|
+
"compute_h1",
|
|
28
|
+
"inverse",
|
|
29
|
+
"karcher_mean",
|
|
30
|
+
"spectral_analysis",
|
|
31
|
+
]
|
groupoid/aggregation.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Federated aggregation via groupoid transport and Karcher mean.
|
|
2
|
+
|
|
3
|
+
This module ties together the transport groupoid, sheaf consistency,
|
|
4
|
+
and Riemannian aggregation into a complete federated learning round.
|
|
5
|
+
The key insight is that naive parameter averaging fails when client
|
|
6
|
+
models live on different tangent spaces. Instead, we:
|
|
7
|
+
|
|
8
|
+
1. Transport all client parameters to a common base point via the
|
|
9
|
+
groupoid morphisms.
|
|
10
|
+
2. Check cohomological consistency (H^1) to detect irreconcilable
|
|
11
|
+
disagreements before aggregation.
|
|
12
|
+
3. Compute the Karcher mean on the manifold of the transported
|
|
13
|
+
parameters.
|
|
14
|
+
4. Transport the aggregated model back to each client's local frame.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
|
|
21
|
+
import networkx as nx
|
|
22
|
+
import numpy as np
|
|
23
|
+
import numpy.typing as npt
|
|
24
|
+
from loguru import logger
|
|
25
|
+
|
|
26
|
+
from groupoid.cohomology import compute_h1
|
|
27
|
+
from groupoid.groupoid import Morphism, compose, inverse
|
|
28
|
+
from groupoid.manifold import karcher_mean
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DisconnectedClientGraphError(Exception):
|
|
32
|
+
"""Raised when the client graph has no transport path to the base node.
|
|
33
|
+
|
|
34
|
+
Aggregation transports every client's parameters to the base node along
|
|
35
|
+
a path in the client graph. If a client lies in a different connected
|
|
36
|
+
component from the base node, no such path exists and the parameters
|
|
37
|
+
cannot be aligned. This error replaces the raw networkx
|
|
38
|
+
``NetworkXNoPath`` with a domain-meaningful message naming the
|
|
39
|
+
unreachable node and the base.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class FederatedRound:
|
|
45
|
+
"""Result of a single federated aggregation round."""
|
|
46
|
+
|
|
47
|
+
global_params: npt.NDArray[np.float64]
|
|
48
|
+
local_updates: dict[str, npt.NDArray[np.float64]]
|
|
49
|
+
h1_norm: float
|
|
50
|
+
is_consistent: bool
|
|
51
|
+
transport_residuals: dict[str, float]
|
|
52
|
+
round_idx: int = 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class TransportGroupoidAggregator:
|
|
57
|
+
"""Federated aggregator using groupoid transport and Riemannian geometry.
|
|
58
|
+
|
|
59
|
+
Given a network of clients with local model parameters on a Riemannian
|
|
60
|
+
manifold, this aggregator:
|
|
61
|
+
- Uses parallel transport (groupoid morphisms) to align parameters
|
|
62
|
+
- Checks cohomological consistency before aggregation
|
|
63
|
+
- Computes intrinsic Karcher mean on the parameter manifold
|
|
64
|
+
- Distributes the result back via inverse transport
|
|
65
|
+
|
|
66
|
+
Parameters
|
|
67
|
+
----------
|
|
68
|
+
manifold
|
|
69
|
+
A geomstats manifold instance for the parameter space.
|
|
70
|
+
graph
|
|
71
|
+
Directed graph of the client network.
|
|
72
|
+
base_node
|
|
73
|
+
The node to which all parameters are transported for aggregation.
|
|
74
|
+
consistency_threshold
|
|
75
|
+
Maximum H^1 norm before raising a consistency warning.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
manifold: object
|
|
79
|
+
graph: nx.DiGraph
|
|
80
|
+
base_node: str
|
|
81
|
+
consistency_threshold: float = 1e-6
|
|
82
|
+
morphisms: dict[tuple[str, str], Morphism] = field(default_factory=dict)
|
|
83
|
+
_round_idx: int = field(default=0, init=False)
|
|
84
|
+
|
|
85
|
+
def register_transport(
|
|
86
|
+
self, source: str, target: str, matrix: npt.NDArray[np.float64]
|
|
87
|
+
) -> None:
|
|
88
|
+
"""Register a transport map between two clients."""
|
|
89
|
+
self.morphisms[(source, target)] = Morphism(
|
|
90
|
+
source=source,
|
|
91
|
+
target=target,
|
|
92
|
+
transport_map=matrix,
|
|
93
|
+
)
|
|
94
|
+
logger.debug("Registered transport {} -> {}", source, target)
|
|
95
|
+
|
|
96
|
+
def _get_transport_to_base(self, node: str) -> npt.NDArray[np.float64] | None:
|
|
97
|
+
"""Compute the composite transport map from node to base_node.
|
|
98
|
+
|
|
99
|
+
Uses shortest path in the graph and composes morphisms along it.
|
|
100
|
+
Returns None if node == base_node (identity transport).
|
|
101
|
+
"""
|
|
102
|
+
if node == self.base_node:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
path = nx.shortest_path(self.graph.to_undirected(), node, self.base_node)
|
|
107
|
+
except nx.NetworkXNoPath as e:
|
|
108
|
+
raise DisconnectedClientGraphError(
|
|
109
|
+
f"client graph is disconnected: no transport path from "
|
|
110
|
+
f"{node} to base {self.base_node}"
|
|
111
|
+
) from e
|
|
112
|
+
composite: Morphism | None = None
|
|
113
|
+
|
|
114
|
+
for i in range(len(path) - 1):
|
|
115
|
+
src, tgt = path[i], path[i + 1]
|
|
116
|
+
if (src, tgt) in self.morphisms:
|
|
117
|
+
m = self.morphisms[(src, tgt)]
|
|
118
|
+
elif (tgt, src) in self.morphisms:
|
|
119
|
+
m = inverse(self.morphisms[(tgt, src)])
|
|
120
|
+
else:
|
|
121
|
+
raise ValueError(f"No transport map for edge ({src}, {tgt})")
|
|
122
|
+
|
|
123
|
+
composite = m if composite is None else compose(composite, m)
|
|
124
|
+
|
|
125
|
+
return composite.transport_map if composite is not None else None
|
|
126
|
+
|
|
127
|
+
def check_consistency(self, client_params: dict[str, npt.NDArray[np.float64]]) -> float:
|
|
128
|
+
"""Check cohomological consistency of current transport maps.
|
|
129
|
+
|
|
130
|
+
Returns the H^1 norm. A value near zero indicates that the
|
|
131
|
+
local models can be consistently aggregated.
|
|
132
|
+
"""
|
|
133
|
+
transport_maps = {(m.source, m.target): m.transport_map for m in self.morphisms.values()}
|
|
134
|
+
h1 = compute_h1(self.graph, transport_maps)
|
|
135
|
+
logger.info("Cohomological consistency check: H^1 = {:.2e}", h1)
|
|
136
|
+
return h1
|
|
137
|
+
|
|
138
|
+
def aggregate(
|
|
139
|
+
self,
|
|
140
|
+
client_params: dict[str, npt.NDArray[np.float64]],
|
|
141
|
+
weights: dict[str, float] | None = None,
|
|
142
|
+
) -> FederatedRound:
|
|
143
|
+
"""Run one round of groupoid-aware federated aggregation.
|
|
144
|
+
|
|
145
|
+
Parameters
|
|
146
|
+
----------
|
|
147
|
+
client_params
|
|
148
|
+
Map from client node ID to local model parameters.
|
|
149
|
+
weights
|
|
150
|
+
Optional per-client weights for the Karcher mean.
|
|
151
|
+
|
|
152
|
+
Returns
|
|
153
|
+
-------
|
|
154
|
+
FederatedRound
|
|
155
|
+
The aggregation result including global params, consistency
|
|
156
|
+
metrics, and per-client local updates.
|
|
157
|
+
"""
|
|
158
|
+
self._round_idx += 1
|
|
159
|
+
logger.info("Starting aggregation round {}", self._round_idx)
|
|
160
|
+
|
|
161
|
+
h1 = self.check_consistency(client_params)
|
|
162
|
+
is_consistent = h1 < self.consistency_threshold
|
|
163
|
+
|
|
164
|
+
if not is_consistent:
|
|
165
|
+
logger.warning(
|
|
166
|
+
"H^1 = {:.2e} exceeds threshold {:.2e}, "
|
|
167
|
+
"aggregation may produce inconsistent results",
|
|
168
|
+
h1,
|
|
169
|
+
self.consistency_threshold,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Transport all client params to base node frame
|
|
173
|
+
transported = {}
|
|
174
|
+
transport_residuals = {}
|
|
175
|
+
for node, params in client_params.items():
|
|
176
|
+
if node == self.base_node:
|
|
177
|
+
transported[node] = params
|
|
178
|
+
transport_residuals[node] = 0.0
|
|
179
|
+
else:
|
|
180
|
+
# _get_transport_to_base returns None only for node == base_node
|
|
181
|
+
# (excluded by this else); a disconnected graph raises
|
|
182
|
+
# NetworkXNoPath. This guard is therefore defensively
|
|
183
|
+
# unreachable.
|
|
184
|
+
T = self._get_transport_to_base(node)
|
|
185
|
+
if T is None: # pragma: no cover - unreachable defensive guard (see above)
|
|
186
|
+
raise ValueError(f"No transport path from {node} to {self.base_node}")
|
|
187
|
+
transported_params = T @ params
|
|
188
|
+
transported[node] = transported_params
|
|
189
|
+
transport_residuals[node] = float(
|
|
190
|
+
np.linalg.norm(T @ T.T - np.eye(T.shape[0]), "fro")
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Stack transported params and compute Karcher mean
|
|
194
|
+
nodes = sorted(transported.keys())
|
|
195
|
+
param_stack = np.stack([transported[n] for n in nodes])
|
|
196
|
+
|
|
197
|
+
if weights is not None:
|
|
198
|
+
w = np.array([weights.get(n, 1.0) for n in nodes])
|
|
199
|
+
w = w / w.sum()
|
|
200
|
+
else:
|
|
201
|
+
w = None
|
|
202
|
+
|
|
203
|
+
global_params = karcher_mean(self.manifold, param_stack, weights=w)
|
|
204
|
+
|
|
205
|
+
# Transport global params back to each client's local frame
|
|
206
|
+
local_updates = {}
|
|
207
|
+
for node in client_params:
|
|
208
|
+
if node == self.base_node:
|
|
209
|
+
local_updates[node] = global_params
|
|
210
|
+
else:
|
|
211
|
+
# Same defensively-unreachable guard as in the forward
|
|
212
|
+
# transport loop above.
|
|
213
|
+
T = self._get_transport_to_base(node)
|
|
214
|
+
if T is None: # pragma: no cover - unreachable defensive guard (see above)
|
|
215
|
+
raise ValueError(f"No transport path from {node} to {self.base_node}")
|
|
216
|
+
T_inv = np.linalg.inv(T)
|
|
217
|
+
local_updates[node] = T_inv @ global_params
|
|
218
|
+
|
|
219
|
+
result = FederatedRound(
|
|
220
|
+
global_params=global_params,
|
|
221
|
+
local_updates=local_updates,
|
|
222
|
+
h1_norm=h1,
|
|
223
|
+
is_consistent=is_consistent,
|
|
224
|
+
transport_residuals=transport_residuals,
|
|
225
|
+
round_idx=self._round_idx,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
logger.info(
|
|
229
|
+
"Round {} complete: H^1={:.2e}, consistent={}",
|
|
230
|
+
self._round_idx,
|
|
231
|
+
h1,
|
|
232
|
+
is_consistent,
|
|
233
|
+
)
|
|
234
|
+
return result
|
groupoid/cohomology.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""First cohomology of transport groupoids."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import networkx as nx
|
|
6
|
+
import numpy as np
|
|
7
|
+
import numpy.typing as npt
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class IncompleteCocycleError(Exception):
|
|
12
|
+
"""Raised when a cocycle is incompletely specified.
|
|
13
|
+
|
|
14
|
+
The holonomy around a cycle is the ordered product of the transport
|
|
15
|
+
maps on *every* edge of that cycle. If any edge on a cycle has no
|
|
16
|
+
transport map (in either direction), the holonomy is undefined: a
|
|
17
|
+
product over a strict subset of the cycle's edges is mathematically
|
|
18
|
+
meaningless and must not be reported as an H^1 (in)consistency. This
|
|
19
|
+
error names the missing edge so the caller can supply it.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def compute_h1(
|
|
24
|
+
graph: nx.DiGraph,
|
|
25
|
+
transport_maps: dict[tuple[str, str], npt.NDArray[np.float64]],
|
|
26
|
+
) -> float:
|
|
27
|
+
"""Compute the H^1 cohomology norm of a transport cocycle.
|
|
28
|
+
|
|
29
|
+
Returns 0.0 (up to numerical tolerance) when the cocycle is a
|
|
30
|
+
coboundary, meaning the local data is globally consistent.
|
|
31
|
+
|
|
32
|
+
The H^1 obstruction on a cycle is the deviation from the identity of
|
|
33
|
+
the holonomy, i.e. the ordered product of the restriction/transport
|
|
34
|
+
maps around the *entire* cycle. The cocycle must therefore be fully
|
|
35
|
+
specified: every edge of every basis cycle needs a transport map in
|
|
36
|
+
one direction or the other (the reverse direction is inverted). A
|
|
37
|
+
missing edge map leaves the holonomy undefined, so this function
|
|
38
|
+
raises :class:`IncompleteCocycleError` rather than silently forming a
|
|
39
|
+
partial product.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
graph : nx.DiGraph
|
|
44
|
+
The groupoid nerve (directed graph of nodes/clients).
|
|
45
|
+
transport_maps : dict
|
|
46
|
+
Maps (source, target) edge tuples to transport matrices.
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
float
|
|
51
|
+
The cohomology norm. Zero indicates global consistency.
|
|
52
|
+
|
|
53
|
+
Raises
|
|
54
|
+
------
|
|
55
|
+
IncompleteCocycleError
|
|
56
|
+
If any edge on a basis cycle has no transport map in either
|
|
57
|
+
direction, so the cycle's holonomy is undefined.
|
|
58
|
+
"""
|
|
59
|
+
undirected = graph.to_undirected()
|
|
60
|
+
cycles = nx.cycle_basis(undirected)
|
|
61
|
+
|
|
62
|
+
if not cycles:
|
|
63
|
+
logger.debug("No cycles in graph, H^1 = 0 trivially")
|
|
64
|
+
return 0.0
|
|
65
|
+
|
|
66
|
+
max_holonomy_norm = 0.0
|
|
67
|
+
|
|
68
|
+
for cycle in cycles:
|
|
69
|
+
n = len(cycle)
|
|
70
|
+
# Resolve every edge's transport map first, raising on the first
|
|
71
|
+
# missing one so a partial product is never formed. The reverse
|
|
72
|
+
# orientation is inverted. Every cycle-basis cycle has length >= 3, so
|
|
73
|
+
# the resulting product is always fully formed.
|
|
74
|
+
edge_maps = []
|
|
75
|
+
for i in range(n):
|
|
76
|
+
u = cycle[i]
|
|
77
|
+
v = cycle[(i + 1) % n]
|
|
78
|
+
|
|
79
|
+
if (u, v) in transport_maps:
|
|
80
|
+
edge_maps.append(transport_maps[(u, v)])
|
|
81
|
+
elif (v, u) in transport_maps:
|
|
82
|
+
edge_maps.append(np.linalg.inv(transport_maps[(v, u)]))
|
|
83
|
+
else:
|
|
84
|
+
raise IncompleteCocycleError(
|
|
85
|
+
f"Incomplete cocycle: no transport map for edge ({u}, {v}) "
|
|
86
|
+
f"on cycle {cycle}; holonomy is undefined. Supply the edge "
|
|
87
|
+
f"map in either direction before computing H^1."
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
holonomy = edge_maps[0]
|
|
91
|
+
for T in edge_maps[1:]:
|
|
92
|
+
holonomy = T @ holonomy
|
|
93
|
+
|
|
94
|
+
dim = holonomy.shape[0]
|
|
95
|
+
deviation = float(np.linalg.norm(holonomy - np.eye(dim), ord="fro"))
|
|
96
|
+
max_holonomy_norm = max(max_holonomy_norm, deviation)
|
|
97
|
+
|
|
98
|
+
logger.debug("H^1 norm = {:.6e}", max_holonomy_norm)
|
|
99
|
+
return max_holonomy_norm
|
groupoid/groupoid.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Groupoid structure for federated learning transport maps."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import numpy.typing as npt
|
|
7
|
+
from loguru import logger
|
|
8
|
+
from pydantic import BaseModel, ConfigDict
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Morphism(BaseModel):
|
|
12
|
+
"""A morphism in the transport groupoid.
|
|
13
|
+
|
|
14
|
+
Represents a transport map between two nodes (clients) in the
|
|
15
|
+
federated learning network.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
19
|
+
|
|
20
|
+
source: str
|
|
21
|
+
target: str
|
|
22
|
+
transport_map: npt.NDArray[np.float64]
|
|
23
|
+
|
|
24
|
+
def __repr__(self) -> str:
|
|
25
|
+
return f"Morphism({self.source} -> {self.target})"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CompositionError(Exception):
|
|
29
|
+
"""Raised when morphism composition is not defined."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def compose(f: Morphism, g: Morphism) -> Morphism:
|
|
33
|
+
"""Compose two morphisms f and g (f followed by g).
|
|
34
|
+
|
|
35
|
+
Requires f.target == g.source (category composition law).
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
f : Morphism
|
|
40
|
+
First morphism (applied first).
|
|
41
|
+
g : Morphism
|
|
42
|
+
Second morphism (applied second).
|
|
43
|
+
|
|
44
|
+
Returns
|
|
45
|
+
-------
|
|
46
|
+
Morphism
|
|
47
|
+
The composed morphism from f.source to g.target.
|
|
48
|
+
|
|
49
|
+
Raises
|
|
50
|
+
------
|
|
51
|
+
CompositionError
|
|
52
|
+
If f.target != g.source.
|
|
53
|
+
"""
|
|
54
|
+
if f.target != g.source:
|
|
55
|
+
raise CompositionError(f"Cannot compose: {f.target} != {g.source}")
|
|
56
|
+
|
|
57
|
+
logger.debug("Composing {} with {}", f, g)
|
|
58
|
+
composed_map = g.transport_map @ f.transport_map
|
|
59
|
+
return Morphism(
|
|
60
|
+
source=f.source,
|
|
61
|
+
target=g.target,
|
|
62
|
+
transport_map=composed_map,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def inverse(f: Morphism) -> Morphism:
|
|
67
|
+
"""Compute the inverse of a morphism.
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
f : Morphism
|
|
72
|
+
The morphism to invert.
|
|
73
|
+
|
|
74
|
+
Returns
|
|
75
|
+
-------
|
|
76
|
+
Morphism
|
|
77
|
+
The inverse morphism from f.target to f.source.
|
|
78
|
+
"""
|
|
79
|
+
logger.debug("Inverting {}", f)
|
|
80
|
+
inv_map = np.linalg.inv(f.transport_map)
|
|
81
|
+
return Morphism(
|
|
82
|
+
source=f.target,
|
|
83
|
+
target=f.source,
|
|
84
|
+
transport_map=inv_map,
|
|
85
|
+
)
|
groupoid/laplacian.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Sheaf Laplacian for federated consensus and spectral analysis.
|
|
2
|
+
|
|
3
|
+
The sheaf Laplacian generalizes the graph Laplacian by incorporating
|
|
4
|
+
the restriction maps of a cellular sheaf. Its spectrum reveals the
|
|
5
|
+
structure of the agreement space:
|
|
6
|
+
|
|
7
|
+
- Kernel of L = space of global sections (consistent models)
|
|
8
|
+
- Smallest nonzero eigenvalue = algebraic connectivity of the sheaf
|
|
9
|
+
(how fast consensus can be reached)
|
|
10
|
+
- Spectral gap = robustness of the consensus to perturbation
|
|
11
|
+
|
|
12
|
+
In federated learning, the sheaf Laplacian governs the diffusion
|
|
13
|
+
process that drives local models toward global consistency.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
import numpy.typing as npt
|
|
22
|
+
from loguru import logger
|
|
23
|
+
|
|
24
|
+
from groupoid.sheaf import Sheaf
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class SpectralSummary:
|
|
29
|
+
"""Spectral decomposition of the sheaf Laplacian."""
|
|
30
|
+
|
|
31
|
+
eigenvalues: npt.NDArray[np.float64]
|
|
32
|
+
eigenvectors: npt.NDArray[np.float64]
|
|
33
|
+
spectral_gap: float
|
|
34
|
+
algebraic_connectivity: float
|
|
35
|
+
kernel_dimension: int
|
|
36
|
+
consensus_rate: float
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_sheaf_laplacian(sheaf: Sheaf, stalk_dim: int) -> npt.NDArray[np.float64]:
|
|
40
|
+
"""Build the sheaf Laplacian matrix.
|
|
41
|
+
|
|
42
|
+
For a sheaf F on graph G with n nodes and stalk dimension d,
|
|
43
|
+
the sheaf Laplacian is a (n*d) x (n*d) block matrix defined as:
|
|
44
|
+
|
|
45
|
+
L_F = delta^T @ delta
|
|
46
|
+
|
|
47
|
+
where delta is the connection coboundary. For each edge (u, v) with
|
|
48
|
+
restriction (transport) map R = R_{uv}: stalk(u) -> stalk(v), the
|
|
49
|
+
coboundary acts as (delta x)_{(u,v)} = x_v - R_{uv} x_u, so
|
|
50
|
+
L = delta^T @ delta has blocks (summed over incident edges):
|
|
51
|
+
|
|
52
|
+
L[u,u] += R_{uv}^T @ R_{uv} (source diagonal)
|
|
53
|
+
L[v,v] += I (target diagonal)
|
|
54
|
+
L[u,v] += -R_{uv}^T (off-diagonal)
|
|
55
|
+
L[v,u] += -R_{uv} (off-diagonal)
|
|
56
|
+
|
|
57
|
+
L is symmetric positive semi-definite for ANY restriction maps (it is
|
|
58
|
+
delta^T delta); its kernel is the space of transport-consistent global
|
|
59
|
+
sections (x_v = R_{uv} x_u on every edge).
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
sheaf
|
|
64
|
+
A Sheaf instance with restriction maps set.
|
|
65
|
+
stalk_dim
|
|
66
|
+
Dimension of each stalk (vector space at each node).
|
|
67
|
+
|
|
68
|
+
Returns
|
|
69
|
+
-------
|
|
70
|
+
np.ndarray
|
|
71
|
+
The sheaf Laplacian matrix of shape (n*d, n*d).
|
|
72
|
+
"""
|
|
73
|
+
nodes = sorted(sheaf.graph.nodes())
|
|
74
|
+
n = len(nodes)
|
|
75
|
+
node_idx = {node: i for i, node in enumerate(nodes)}
|
|
76
|
+
N = n * stalk_dim
|
|
77
|
+
|
|
78
|
+
L = np.zeros((N, N))
|
|
79
|
+
|
|
80
|
+
for u, v in sheaf.graph.edges():
|
|
81
|
+
i, j = node_idx[u], node_idx[v]
|
|
82
|
+
R = sheaf.get_restriction_map(u, v)
|
|
83
|
+
i_slice = slice(i * stalk_dim, (i + 1) * stalk_dim)
|
|
84
|
+
j_slice = slice(j * stalk_dim, (j + 1) * stalk_dim)
|
|
85
|
+
|
|
86
|
+
# L = delta^T delta for coboundary (delta x)_(u,v) = x_v - R_uv x_u:
|
|
87
|
+
L[i_slice, i_slice] += R.T @ R # source diagonal: R^T R
|
|
88
|
+
L[j_slice, j_slice] += np.eye(stalk_dim) # target diagonal: I
|
|
89
|
+
L[i_slice, j_slice] += -R.T # off-diagonal: -R^T
|
|
90
|
+
L[j_slice, i_slice] += -R # off-diagonal: -R
|
|
91
|
+
|
|
92
|
+
logger.debug("Built sheaf Laplacian: {}x{} ({} nodes, stalk_dim={})", N, N, n, stalk_dim)
|
|
93
|
+
return L
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def spectral_analysis(
|
|
97
|
+
sheaf: Sheaf,
|
|
98
|
+
stalk_dim: int,
|
|
99
|
+
tol: float = 1e-10,
|
|
100
|
+
) -> SpectralSummary:
|
|
101
|
+
"""Compute spectral decomposition of the sheaf Laplacian.
|
|
102
|
+
|
|
103
|
+
Parameters
|
|
104
|
+
----------
|
|
105
|
+
sheaf
|
|
106
|
+
A Sheaf instance with restriction maps.
|
|
107
|
+
stalk_dim
|
|
108
|
+
Dimension of each stalk.
|
|
109
|
+
tol
|
|
110
|
+
Tolerance for identifying zero eigenvalues.
|
|
111
|
+
|
|
112
|
+
Returns
|
|
113
|
+
-------
|
|
114
|
+
SpectralSummary
|
|
115
|
+
Full spectral summary including connectivity and consensus rate.
|
|
116
|
+
"""
|
|
117
|
+
L = build_sheaf_laplacian(sheaf, stalk_dim)
|
|
118
|
+
eigenvalues, eigenvectors = np.linalg.eigh(L)
|
|
119
|
+
|
|
120
|
+
# Sort by magnitude
|
|
121
|
+
idx = np.argsort(eigenvalues)
|
|
122
|
+
eigenvalues = eigenvalues[idx]
|
|
123
|
+
eigenvectors = eigenvectors[:, idx]
|
|
124
|
+
|
|
125
|
+
# Kernel dimension (number of zero eigenvalues)
|
|
126
|
+
kernel_dim = int(np.sum(np.abs(eigenvalues) < tol))
|
|
127
|
+
|
|
128
|
+
# Spectral gap and algebraic connectivity
|
|
129
|
+
nonzero_eigs = eigenvalues[np.abs(eigenvalues) >= tol]
|
|
130
|
+
if len(nonzero_eigs) > 0:
|
|
131
|
+
algebraic_connectivity = float(nonzero_eigs[0])
|
|
132
|
+
spectral_gap = float(nonzero_eigs[0])
|
|
133
|
+
else:
|
|
134
|
+
algebraic_connectivity = 0.0
|
|
135
|
+
spectral_gap = 0.0
|
|
136
|
+
|
|
137
|
+
# Consensus rate: exponential convergence rate of sheaf diffusion
|
|
138
|
+
# x(t+1) = (I - epsilon * L) @ x(t), converges as exp(-lambda_1 * t)
|
|
139
|
+
consensus_rate = algebraic_connectivity
|
|
140
|
+
|
|
141
|
+
logger.info(
|
|
142
|
+
"Spectral analysis: kernel_dim={}, spectral_gap={:.4f}, connectivity={:.4f}",
|
|
143
|
+
kernel_dim,
|
|
144
|
+
spectral_gap,
|
|
145
|
+
algebraic_connectivity,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
return SpectralSummary(
|
|
149
|
+
eigenvalues=eigenvalues,
|
|
150
|
+
eigenvectors=eigenvectors,
|
|
151
|
+
spectral_gap=spectral_gap,
|
|
152
|
+
algebraic_connectivity=algebraic_connectivity,
|
|
153
|
+
kernel_dimension=kernel_dim,
|
|
154
|
+
consensus_rate=consensus_rate,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def sheaf_diffusion_step(
|
|
159
|
+
sheaf: Sheaf,
|
|
160
|
+
sections: dict[str, npt.NDArray[np.float64]],
|
|
161
|
+
stalk_dim: int,
|
|
162
|
+
step_size: float = 0.1,
|
|
163
|
+
) -> dict[str, npt.NDArray[np.float64]]:
|
|
164
|
+
"""One step of sheaf diffusion (Laplacian smoothing).
|
|
165
|
+
|
|
166
|
+
Drives local sections toward global consistency by flowing along
|
|
167
|
+
the negative gradient of the sheaf Laplacian energy:
|
|
168
|
+
|
|
169
|
+
E(x) = x^T L_F x = sum_{(i,j)} ||R_{ij} x_i - x_j||^2
|
|
170
|
+
|
|
171
|
+
Parameters
|
|
172
|
+
----------
|
|
173
|
+
sheaf
|
|
174
|
+
Sheaf with restriction maps.
|
|
175
|
+
sections
|
|
176
|
+
Current section values at each node.
|
|
177
|
+
stalk_dim
|
|
178
|
+
Dimension of each stalk.
|
|
179
|
+
step_size
|
|
180
|
+
Diffusion step size (must be < 1/lambda_max for stability).
|
|
181
|
+
|
|
182
|
+
Returns
|
|
183
|
+
-------
|
|
184
|
+
dict[str, npt.NDArray[np.float64]]
|
|
185
|
+
Updated section values after one diffusion step.
|
|
186
|
+
"""
|
|
187
|
+
L = build_sheaf_laplacian(sheaf, stalk_dim)
|
|
188
|
+
nodes = sorted(sheaf.graph.nodes())
|
|
189
|
+
|
|
190
|
+
# Stack sections into vector
|
|
191
|
+
x = np.concatenate([sections[n] for n in nodes])
|
|
192
|
+
|
|
193
|
+
# Diffusion step: x' = x - step_size * L @ x
|
|
194
|
+
x_new = x - step_size * L @ x
|
|
195
|
+
|
|
196
|
+
# Unstack
|
|
197
|
+
result = {}
|
|
198
|
+
for idx, node in enumerate(nodes):
|
|
199
|
+
result[node] = x_new[idx * stalk_dim : (idx + 1) * stalk_dim]
|
|
200
|
+
|
|
201
|
+
return result
|