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/manifold.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Riemannian manifold operations for GROUPOID."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import numpy.typing as npt
|
|
9
|
+
from loguru import logger
|
|
10
|
+
|
|
11
|
+
# TYPE_CHECKING is always False at runtime; import is for static type checkers.
|
|
12
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
13
|
+
from geomstats.geometry.base import LevelSet
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def karcher_mean(
|
|
17
|
+
manifold: LevelSet,
|
|
18
|
+
points: npt.NDArray[np.float64],
|
|
19
|
+
weights: npt.NDArray[np.float64] | None = None,
|
|
20
|
+
max_iter: int = 100,
|
|
21
|
+
tol: float = 1e-6,
|
|
22
|
+
) -> npt.NDArray[np.float64]:
|
|
23
|
+
"""Compute the Karcher (Frechet) mean on a Riemannian manifold.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
manifold : geomstats manifold
|
|
28
|
+
A geomstats manifold instance with a metric.
|
|
29
|
+
points : np.ndarray
|
|
30
|
+
Array of shape (n_points, *point_shape) on the manifold.
|
|
31
|
+
weights : np.ndarray or None
|
|
32
|
+
Optional weights for the mean computation.
|
|
33
|
+
max_iter : int
|
|
34
|
+
Maximum iterations for gradient descent.
|
|
35
|
+
tol : float
|
|
36
|
+
Convergence tolerance.
|
|
37
|
+
|
|
38
|
+
Returns
|
|
39
|
+
-------
|
|
40
|
+
np.ndarray
|
|
41
|
+
The Karcher mean point on the manifold.
|
|
42
|
+
"""
|
|
43
|
+
from geomstats.learning.frechet_mean import FrechetMean
|
|
44
|
+
|
|
45
|
+
n_points = points.shape[0]
|
|
46
|
+
logger.debug(
|
|
47
|
+
"Computing Karcher mean of {} points on {}",
|
|
48
|
+
n_points,
|
|
49
|
+
type(manifold).__name__,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
estimator = FrechetMean(manifold)
|
|
53
|
+
|
|
54
|
+
# Forward convergence controls to the underlying gradient-descent
|
|
55
|
+
# optimizer when the installed geomstats version exposes it. Older
|
|
56
|
+
# versions without an `optimizer` attribute fall back to their defaults.
|
|
57
|
+
optimizer = getattr(estimator, "optimizer", None)
|
|
58
|
+
if optimizer is not None:
|
|
59
|
+
if hasattr(optimizer, "max_iter"):
|
|
60
|
+
optimizer.max_iter = max_iter
|
|
61
|
+
if hasattr(optimizer, "epsilon"):
|
|
62
|
+
optimizer.epsilon = tol
|
|
63
|
+
|
|
64
|
+
if weights is not None:
|
|
65
|
+
estimator.fit(points, weights=weights)
|
|
66
|
+
else:
|
|
67
|
+
estimator.fit(points)
|
|
68
|
+
|
|
69
|
+
mean: npt.NDArray[np.float64] = estimator.estimate_
|
|
70
|
+
logger.debug("Karcher mean converged")
|
|
71
|
+
return mean
|
groupoid/optimizer.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Riemannian optimizers for federated learning on manifolds.
|
|
2
|
+
|
|
3
|
+
Standard SGD and Adam assume Euclidean parameter spaces. When model
|
|
4
|
+
parameters live on a Riemannian manifold (e.g., Stiefel manifold for
|
|
5
|
+
orthogonal layers, SPD manifold for covariance matrices), we need
|
|
6
|
+
Riemannian counterparts that respect the geometry.
|
|
7
|
+
|
|
8
|
+
This module provides:
|
|
9
|
+
- Riemannian SGD with retraction
|
|
10
|
+
- Riemannian Adam with exponential map updates
|
|
11
|
+
- Adaptive learning rate based on sectional curvature
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
import numpy.typing as npt
|
|
21
|
+
from loguru import logger
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class RiemannianSGD:
|
|
26
|
+
"""Riemannian stochastic gradient descent.
|
|
27
|
+
|
|
28
|
+
Updates parameters by computing the Riemannian gradient (projection
|
|
29
|
+
of Euclidean gradient onto tangent space) and retracting back to
|
|
30
|
+
the manifold via the exponential map.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
manifold
|
|
35
|
+
A geomstats manifold instance.
|
|
36
|
+
lr
|
|
37
|
+
Learning rate.
|
|
38
|
+
momentum
|
|
39
|
+
Momentum coefficient (0 = no momentum).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
manifold: Any
|
|
43
|
+
lr: float = 0.01
|
|
44
|
+
momentum: float = 0.0
|
|
45
|
+
_velocity: npt.NDArray[np.float64] | None = field(default=None, init=False, repr=False)
|
|
46
|
+
|
|
47
|
+
def step(
|
|
48
|
+
self, point: npt.NDArray[np.float64], euclidean_grad: npt.NDArray[np.float64]
|
|
49
|
+
) -> npt.NDArray[np.float64]:
|
|
50
|
+
"""Perform one optimization step.
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
point
|
|
55
|
+
Current point on the manifold.
|
|
56
|
+
euclidean_grad
|
|
57
|
+
Euclidean gradient (will be projected to tangent space).
|
|
58
|
+
|
|
59
|
+
Returns
|
|
60
|
+
-------
|
|
61
|
+
np.ndarray
|
|
62
|
+
Updated point on the manifold.
|
|
63
|
+
"""
|
|
64
|
+
# Project gradient onto tangent space
|
|
65
|
+
riemannian_grad = self.manifold.to_tangent(euclidean_grad, point)
|
|
66
|
+
|
|
67
|
+
# Apply momentum
|
|
68
|
+
vel: npt.NDArray[np.float64]
|
|
69
|
+
if self.momentum > 0:
|
|
70
|
+
if self._velocity is None:
|
|
71
|
+
vel = riemannian_grad
|
|
72
|
+
else:
|
|
73
|
+
vel = self.manifold.to_tangent(
|
|
74
|
+
self.momentum * self._velocity + riemannian_grad, point
|
|
75
|
+
)
|
|
76
|
+
self._velocity = vel
|
|
77
|
+
update = -self.lr * vel
|
|
78
|
+
else:
|
|
79
|
+
update = -self.lr * riemannian_grad
|
|
80
|
+
|
|
81
|
+
# Retract to manifold via exponential map
|
|
82
|
+
new_point: npt.NDArray[np.float64] = self.manifold.metric.exp(update, point)
|
|
83
|
+
|
|
84
|
+
return new_point
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class RiemannianAdam:
|
|
89
|
+
"""Riemannian Adam optimizer.
|
|
90
|
+
|
|
91
|
+
Adapts the Adam optimizer to Riemannian manifolds by maintaining
|
|
92
|
+
exponential moving averages of the Riemannian gradient and its
|
|
93
|
+
squared norm, with updates via the exponential map.
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
manifold
|
|
98
|
+
A geomstats manifold instance.
|
|
99
|
+
lr
|
|
100
|
+
Learning rate.
|
|
101
|
+
beta1
|
|
102
|
+
Exponential decay rate for first moment.
|
|
103
|
+
beta2
|
|
104
|
+
Exponential decay rate for second moment.
|
|
105
|
+
eps
|
|
106
|
+
Small constant for numerical stability.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
manifold: Any
|
|
110
|
+
lr: float = 0.001
|
|
111
|
+
beta1: float = 0.9
|
|
112
|
+
beta2: float = 0.999
|
|
113
|
+
eps: float = 1e-8
|
|
114
|
+
_m: npt.NDArray[np.float64] | None = field(default=None, init=False, repr=False)
|
|
115
|
+
_v: float = field(default=0.0, init=False, repr=False)
|
|
116
|
+
_t: int = field(default=0, init=False, repr=False)
|
|
117
|
+
|
|
118
|
+
def step(
|
|
119
|
+
self, point: npt.NDArray[np.float64], euclidean_grad: npt.NDArray[np.float64]
|
|
120
|
+
) -> npt.NDArray[np.float64]:
|
|
121
|
+
"""Perform one optimization step.
|
|
122
|
+
|
|
123
|
+
Parameters
|
|
124
|
+
----------
|
|
125
|
+
point
|
|
126
|
+
Current point on the manifold.
|
|
127
|
+
euclidean_grad
|
|
128
|
+
Euclidean gradient.
|
|
129
|
+
|
|
130
|
+
Returns
|
|
131
|
+
-------
|
|
132
|
+
np.ndarray
|
|
133
|
+
Updated point on the manifold.
|
|
134
|
+
"""
|
|
135
|
+
self._t += 1
|
|
136
|
+
|
|
137
|
+
# Riemannian gradient
|
|
138
|
+
grad = self.manifold.to_tangent(euclidean_grad, point)
|
|
139
|
+
grad_norm_sq = float(np.sum(grad**2))
|
|
140
|
+
|
|
141
|
+
# Update biased first moment (tangent vector)
|
|
142
|
+
first_moment: npt.NDArray[np.float64]
|
|
143
|
+
if self._m is None:
|
|
144
|
+
first_moment = grad.copy()
|
|
145
|
+
else:
|
|
146
|
+
first_moment = self.manifold.to_tangent(
|
|
147
|
+
self.beta1 * self._m + (1 - self.beta1) * grad, point
|
|
148
|
+
)
|
|
149
|
+
self._m = first_moment
|
|
150
|
+
|
|
151
|
+
# Update biased second moment (scalar, norm-based)
|
|
152
|
+
self._v = self.beta2 * self._v + (1 - self.beta2) * grad_norm_sq
|
|
153
|
+
|
|
154
|
+
# Bias correction
|
|
155
|
+
m_hat = first_moment / (1 - self.beta1**self._t)
|
|
156
|
+
v_hat = self._v / (1 - self.beta2**self._t)
|
|
157
|
+
|
|
158
|
+
# Adaptive update
|
|
159
|
+
update = -self.lr * m_hat / (np.sqrt(v_hat) + self.eps)
|
|
160
|
+
update = self.manifold.to_tangent(update, point)
|
|
161
|
+
|
|
162
|
+
new_point: npt.NDArray[np.float64] = self.manifold.metric.exp(update, point)
|
|
163
|
+
return new_point
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def curvature_adaptive_lr(
|
|
167
|
+
manifold: Any, # geomstats manifold; no upstream type stubs
|
|
168
|
+
point: npt.NDArray[np.float64],
|
|
169
|
+
base_lr: float,
|
|
170
|
+
tangent_vec: npt.NDArray[np.float64],
|
|
171
|
+
) -> float:
|
|
172
|
+
"""Adapt learning rate based on local sectional curvature.
|
|
173
|
+
|
|
174
|
+
In regions of high positive curvature, geodesics converge and
|
|
175
|
+
we should take smaller steps. In regions of negative curvature,
|
|
176
|
+
geodesics diverge and we can take larger steps.
|
|
177
|
+
|
|
178
|
+
Parameters
|
|
179
|
+
----------
|
|
180
|
+
manifold
|
|
181
|
+
A geomstats manifold with a curvature method.
|
|
182
|
+
point
|
|
183
|
+
Current point on the manifold.
|
|
184
|
+
base_lr
|
|
185
|
+
Base learning rate to adapt.
|
|
186
|
+
tangent_vec
|
|
187
|
+
Direction of the update.
|
|
188
|
+
|
|
189
|
+
Returns
|
|
190
|
+
-------
|
|
191
|
+
float
|
|
192
|
+
Adapted learning rate.
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
if hasattr(manifold.metric, "sectional_curvature"):
|
|
196
|
+
# Use a random orthogonal vector for the plane
|
|
197
|
+
random_vec = manifold.to_tangent(np.random.randn(*tangent_vec.shape), point)
|
|
198
|
+
kappa = manifold.metric.sectional_curvature(tangent_vec, random_vec, point)
|
|
199
|
+
kappa = float(np.mean(kappa)) if hasattr(kappa, "__len__") else float(kappa)
|
|
200
|
+
|
|
201
|
+
# Scale: lr / (1 + max(kappa, 0)) damps in positive curvature
|
|
202
|
+
adapted: float = base_lr / (1.0 + max(kappa, 0.0))
|
|
203
|
+
logger.debug("Curvature-adapted LR: {:.6f} (kappa={:.4f})", adapted, kappa)
|
|
204
|
+
return adapted
|
|
205
|
+
except (AttributeError, NotImplementedError):
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
return base_lr
|
groupoid/persistence.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Persistent homology for federated model divergence detection.
|
|
2
|
+
|
|
3
|
+
Applies topological data analysis to track how the distribution of
|
|
4
|
+
client model parameters evolves across federated rounds. The key
|
|
5
|
+
invariant: if the persistence diagram is stable across rounds, the
|
|
6
|
+
federation is converging; if new topological features appear, clients
|
|
7
|
+
are diverging into distinct clusters.
|
|
8
|
+
|
|
9
|
+
Uses Vietoris-Rips filtration on the space of model parameters to
|
|
10
|
+
compute persistence diagrams, then measures stability via bottleneck
|
|
11
|
+
and Wasserstein distances.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
import numpy.typing as npt
|
|
20
|
+
from loguru import logger
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class PersistenceSummary:
|
|
25
|
+
"""Summary of persistent homology computation.
|
|
26
|
+
|
|
27
|
+
The ``diagram`` array is dimension-labelled: it has shape ``(k, 3)``
|
|
28
|
+
where column 0 is birth, column 1 is death, and column 2 is the
|
|
29
|
+
homology dimension (0 for H0/connected components, 1 for H1/loops,
|
|
30
|
+
...). Retaining the dimension label is what lets ``track_divergence``
|
|
31
|
+
compare like-with-like (H0 against H0) instead of pooling features
|
|
32
|
+
from different homology dimensions into one bottleneck computation.
|
|
33
|
+
Use :meth:`diagram_for_dim` to extract a single dimension's bars.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
betti_0: int
|
|
37
|
+
betti_1: int
|
|
38
|
+
total_persistence: float
|
|
39
|
+
max_persistence: float
|
|
40
|
+
diagram: npt.NDArray[np.float64]
|
|
41
|
+
bottleneck_to_previous: float | None = None
|
|
42
|
+
|
|
43
|
+
def diagram_for_dim(self, dim: int, *, finite_only: bool = False) -> npt.NDArray[np.float64]:
|
|
44
|
+
"""Return the ``(birth, death)`` bars of a single homology dimension.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
dim
|
|
49
|
+
Homology dimension to select (0 = H0, 1 = H1, ...).
|
|
50
|
+
finite_only
|
|
51
|
+
If True, drop bars that die at infinity (the essential
|
|
52
|
+
classes). H0 always carries exactly one infinite bar (the
|
|
53
|
+
whole space's single surviving component under ``thresh=inf``);
|
|
54
|
+
excluding it leaves the finite bars, which for H0 are exactly
|
|
55
|
+
the minimum-spanning-tree edge weights of the point cloud.
|
|
56
|
+
|
|
57
|
+
Returns
|
|
58
|
+
-------
|
|
59
|
+
np.ndarray
|
|
60
|
+
Array of shape ``(m, 2)`` with columns ``(birth, death)``.
|
|
61
|
+
Empty diagrams return shape ``(0, 2)``.
|
|
62
|
+
"""
|
|
63
|
+
if self.diagram.shape[0] == 0:
|
|
64
|
+
return np.empty((0, 2))
|
|
65
|
+
mask = self.diagram[:, 2] == dim
|
|
66
|
+
if finite_only:
|
|
67
|
+
mask = mask & (self.diagram[:, 1] < np.inf)
|
|
68
|
+
# np.asarray pins the return type (numpy fancy-indexing is typed as
|
|
69
|
+
# Any, which would trip mypy's warn_return_any); it is a no-copy view
|
|
70
|
+
# here since the slice is already an ndarray.
|
|
71
|
+
return np.asarray(self.diagram[mask][:, :2])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def compute_persistence(
|
|
75
|
+
points: npt.NDArray[np.float64],
|
|
76
|
+
max_dim: int = 1,
|
|
77
|
+
max_edge_length: float = np.inf,
|
|
78
|
+
) -> PersistenceSummary:
|
|
79
|
+
"""Compute persistent homology of a point cloud.
|
|
80
|
+
|
|
81
|
+
Parameters
|
|
82
|
+
----------
|
|
83
|
+
points
|
|
84
|
+
Array of shape (n_points, n_features) representing model
|
|
85
|
+
parameters or their embeddings.
|
|
86
|
+
max_dim
|
|
87
|
+
Maximum homological dimension to compute.
|
|
88
|
+
max_edge_length
|
|
89
|
+
Maximum edge length for the Rips filtration.
|
|
90
|
+
|
|
91
|
+
Returns
|
|
92
|
+
-------
|
|
93
|
+
PersistenceSummary
|
|
94
|
+
Topological summary including Betti numbers and persistence.
|
|
95
|
+
"""
|
|
96
|
+
from ripser import ripser
|
|
97
|
+
|
|
98
|
+
logger.debug("Computing persistence: {} points, max_dim={}", points.shape[0], max_dim)
|
|
99
|
+
|
|
100
|
+
result = ripser(points, maxdim=max_dim, thresh=max_edge_length)
|
|
101
|
+
diagrams = result["dgms"]
|
|
102
|
+
|
|
103
|
+
# Compute Betti numbers (count features that persist at infinity)
|
|
104
|
+
betti_0 = int(np.sum(diagrams[0][:, 1] == np.inf)) if len(diagrams) > 0 else 0
|
|
105
|
+
betti_1 = int(np.sum(diagrams[1][:, 1] == np.inf)) if len(diagrams) > 1 else 0
|
|
106
|
+
|
|
107
|
+
# Compute total and max persistence (excluding infinite features)
|
|
108
|
+
all_finite = []
|
|
109
|
+
for dgm in diagrams:
|
|
110
|
+
finite_mask = dgm[:, 1] < np.inf
|
|
111
|
+
if np.any(finite_mask):
|
|
112
|
+
lifetimes = dgm[finite_mask, 1] - dgm[finite_mask, 0]
|
|
113
|
+
all_finite.extend(lifetimes.tolist())
|
|
114
|
+
|
|
115
|
+
total_persistence = float(sum(all_finite)) if all_finite else 0.0
|
|
116
|
+
max_persistence = float(max(all_finite)) if all_finite else 0.0
|
|
117
|
+
|
|
118
|
+
# Concatenate all diagrams for storage, RETAINING the homology
|
|
119
|
+
# dimension as a third column. ripser returns a per-dimension list
|
|
120
|
+
# (diagrams[d] holds dimension d); a bare np.vstack would discard
|
|
121
|
+
# that label and make H0 and H1 bars indistinguishable. We append a
|
|
122
|
+
# dim column so downstream consumers (e.g. track_divergence) can
|
|
123
|
+
# compare like dimensions instead of pooling them.
|
|
124
|
+
labelled = []
|
|
125
|
+
for dim, dgm in enumerate(diagrams):
|
|
126
|
+
if dgm.shape[0] == 0:
|
|
127
|
+
continue
|
|
128
|
+
dim_col = np.full((dgm.shape[0], 1), float(dim))
|
|
129
|
+
labelled.append(np.hstack([dgm, dim_col]))
|
|
130
|
+
full_diagram = np.vstack(labelled) if labelled else np.empty((0, 3))
|
|
131
|
+
|
|
132
|
+
logger.debug(
|
|
133
|
+
"Persistence: beta_0={}, beta_1={}, total={:.4f}",
|
|
134
|
+
betti_0,
|
|
135
|
+
betti_1,
|
|
136
|
+
total_persistence,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return PersistenceSummary(
|
|
140
|
+
betti_0=betti_0,
|
|
141
|
+
betti_1=betti_1,
|
|
142
|
+
total_persistence=total_persistence,
|
|
143
|
+
max_persistence=max_persistence,
|
|
144
|
+
diagram=full_diagram,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def track_divergence(
|
|
149
|
+
current_params: npt.NDArray[np.float64],
|
|
150
|
+
previous_summary: PersistenceSummary | None = None,
|
|
151
|
+
max_dim: int = 1,
|
|
152
|
+
) -> PersistenceSummary:
|
|
153
|
+
"""Track federation divergence across rounds.
|
|
154
|
+
|
|
155
|
+
Computes persistence of current parameter distribution and, if a
|
|
156
|
+
previous summary exists, computes the bottleneck distance to
|
|
157
|
+
measure how much the topological structure has changed.
|
|
158
|
+
|
|
159
|
+
Parameters
|
|
160
|
+
----------
|
|
161
|
+
current_params
|
|
162
|
+
Array of shape (n_clients, n_features).
|
|
163
|
+
previous_summary
|
|
164
|
+
PersistenceSummary from the previous round, if available.
|
|
165
|
+
max_dim
|
|
166
|
+
Maximum homological dimension.
|
|
167
|
+
|
|
168
|
+
Returns
|
|
169
|
+
-------
|
|
170
|
+
PersistenceSummary
|
|
171
|
+
Updated summary with bottleneck distance to previous round.
|
|
172
|
+
"""
|
|
173
|
+
summary = compute_persistence(current_params, max_dim=max_dim)
|
|
174
|
+
|
|
175
|
+
if previous_summary is not None:
|
|
176
|
+
from persim import bottleneck
|
|
177
|
+
|
|
178
|
+
# Compare H0 diagrams (connected components) ONLY. We select bars
|
|
179
|
+
# whose homology dimension is 0 and which are finite (the single
|
|
180
|
+
# infinite H0 bar -- the whole space's surviving component -- is
|
|
181
|
+
# excluded so persim never has to match infinities). Selecting on
|
|
182
|
+
# the dimension label is essential: without it, H1 (loop) bars
|
|
183
|
+
# leak into this pool and silently contaminate the "H0 divergence"
|
|
184
|
+
# with loop-structure changes. See PersistenceSummary.diagram_for_dim.
|
|
185
|
+
current_h0 = summary.diagram_for_dim(0, finite_only=True)
|
|
186
|
+
prev_h0 = previous_summary.diagram_for_dim(0, finite_only=True)
|
|
187
|
+
|
|
188
|
+
if len(current_h0) > 0 and len(prev_h0) > 0:
|
|
189
|
+
dist = bottleneck(current_h0, prev_h0)
|
|
190
|
+
summary.bottleneck_to_previous = float(dist)
|
|
191
|
+
logger.info("Bottleneck distance to previous round: {:.4f}", dist)
|
|
192
|
+
else:
|
|
193
|
+
summary.bottleneck_to_previous = 0.0
|
|
194
|
+
|
|
195
|
+
return summary
|
groupoid/sheaf.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Sheaf-theoretic data structures for federated learning."""
|
|
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 Sheaf:
|
|
12
|
+
"""A cellular sheaf on a graph.
|
|
13
|
+
|
|
14
|
+
Assigns vector spaces to nodes and linear restriction maps to edges.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, graph: nx.DiGraph) -> None:
|
|
18
|
+
self.graph = graph
|
|
19
|
+
self._restriction_maps: dict[tuple[str, str], npt.NDArray[np.float64]] = {}
|
|
20
|
+
self._sections: dict[str, npt.NDArray[np.float64]] = {}
|
|
21
|
+
|
|
22
|
+
def set_restriction_map(
|
|
23
|
+
self, source: str, target: str, matrix: npt.NDArray[np.float64]
|
|
24
|
+
) -> None:
|
|
25
|
+
"""Set the restriction map for an edge."""
|
|
26
|
+
self._restriction_maps[(source, target)] = matrix
|
|
27
|
+
logger.debug("Set restriction map {} -> {}", source, target)
|
|
28
|
+
|
|
29
|
+
def get_restriction_map(self, source: str, target: str) -> npt.NDArray[np.float64]:
|
|
30
|
+
"""Get the restriction map for an edge."""
|
|
31
|
+
return self._restriction_maps[(source, target)]
|
|
32
|
+
|
|
33
|
+
def set_section(self, node: str, value: npt.NDArray[np.float64]) -> None:
|
|
34
|
+
"""Set a section value at a node."""
|
|
35
|
+
self._sections[node] = value
|
|
36
|
+
|
|
37
|
+
def get_section(self, node: str) -> npt.NDArray[np.float64]:
|
|
38
|
+
"""Get the section value at a node."""
|
|
39
|
+
return self._sections[node]
|
|
40
|
+
|
|
41
|
+
def restrict(
|
|
42
|
+
self, section: npt.NDArray[np.float64], source: str, target: str
|
|
43
|
+
) -> npt.NDArray[np.float64]:
|
|
44
|
+
"""Apply the restriction map to a section.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
section : np.ndarray
|
|
49
|
+
The section value at the source node.
|
|
50
|
+
source : str
|
|
51
|
+
Source node identifier.
|
|
52
|
+
target : str
|
|
53
|
+
Target node identifier.
|
|
54
|
+
|
|
55
|
+
Returns
|
|
56
|
+
-------
|
|
57
|
+
np.ndarray
|
|
58
|
+
The restricted section at the target node.
|
|
59
|
+
"""
|
|
60
|
+
R = self._restriction_maps[(source, target)]
|
|
61
|
+
result: npt.NDArray[np.float64] = R @ section
|
|
62
|
+
return result
|
|
63
|
+
|
|
64
|
+
def restrict_along_path(
|
|
65
|
+
self, section: npt.NDArray[np.float64], path: list[str]
|
|
66
|
+
) -> npt.NDArray[np.float64]:
|
|
67
|
+
"""Restrict a section along a path of nodes.
|
|
68
|
+
|
|
69
|
+
Parameters
|
|
70
|
+
----------
|
|
71
|
+
section : np.ndarray
|
|
72
|
+
The section value at path[0].
|
|
73
|
+
path : list[str]
|
|
74
|
+
Ordered list of nodes forming a path in the graph.
|
|
75
|
+
|
|
76
|
+
Returns
|
|
77
|
+
-------
|
|
78
|
+
np.ndarray
|
|
79
|
+
The section restricted to path[-1].
|
|
80
|
+
"""
|
|
81
|
+
result = section
|
|
82
|
+
for i in range(len(path) - 1):
|
|
83
|
+
result = self.restrict(result, path[i], path[i + 1])
|
|
84
|
+
return result
|