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/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
@@ -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