groupoid 0.1.0.dev2__py3-none-any.whl → 0.1.0.dev4__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 CHANGED
@@ -1,6 +1,6 @@
1
1
  """GROUPOID: Groupoid-based federated learning with Riemannian geometry."""
2
2
 
3
- __version__ = "0.1.0.dev2"
3
+ __version__ = "0.1.0.dev4"
4
4
 
5
5
  from groupoid.aggregation import (
6
6
  DisconnectedClientGraphError,
groupoid/aggregation.py CHANGED
@@ -23,9 +23,11 @@ import numpy as np
23
23
  import numpy.typing as npt
24
24
  from loguru import logger
25
25
 
26
+ from groupoid import persistence as _persistence
26
27
  from groupoid.cohomology import compute_h1
27
28
  from groupoid.groupoid import Morphism, compose, inverse
28
29
  from groupoid.manifold import karcher_mean
30
+ from groupoid.transport import compute_transport_matrix
29
31
 
30
32
 
31
33
  class DisconnectedClientGraphError(Exception):
@@ -42,7 +44,14 @@ class DisconnectedClientGraphError(Exception):
42
44
 
43
45
  @dataclass
44
46
  class FederatedRound:
45
- """Result of a single federated aggregation round."""
47
+ """Result of a single federated aggregation round.
48
+
49
+ ``divergence`` is populated only when the aggregator was constructed
50
+ with ``track_divergence=True``: it is the persistent-homology summary
51
+ of the transported client parameters, with the bottleneck distance to
52
+ the previous round's summary (None on the first round with tracking
53
+ off; see :func:`groupoid.persistence.track_divergence`).
54
+ """
46
55
 
47
56
  global_params: npt.NDArray[np.float64]
48
57
  local_updates: dict[str, npt.NDArray[np.float64]]
@@ -50,6 +59,7 @@ class FederatedRound:
50
59
  is_consistent: bool
51
60
  transport_residuals: dict[str, float]
52
61
  round_idx: int = 0
62
+ divergence: _persistence.PersistenceSummary | None = None
53
63
 
54
64
 
55
65
  @dataclass
@@ -73,14 +83,23 @@ class TransportGroupoidAggregator:
73
83
  The node to which all parameters are transported for aggregation.
74
84
  consistency_threshold
75
85
  Maximum H^1 norm before raising a consistency warning.
86
+ track_divergence
87
+ When True, each :meth:`aggregate` round computes the persistent
88
+ homology of the transported client parameters and the bottleneck
89
+ distance to the previous round (H0 against H0), populating
90
+ ``FederatedRound.divergence``.
76
91
  """
77
92
 
78
93
  manifold: object
79
94
  graph: nx.DiGraph
80
95
  base_node: str
81
96
  consistency_threshold: float = 1e-6
97
+ track_divergence: bool = False
82
98
  morphisms: dict[tuple[str, str], Morphism] = field(default_factory=dict)
83
99
  _round_idx: int = field(default=0, init=False)
100
+ _prev_divergence: _persistence.PersistenceSummary | None = field(
101
+ default=None, init=False, repr=False
102
+ )
84
103
 
85
104
  def register_transport(
86
105
  self, source: str, target: str, matrix: npt.NDArray[np.float64]
@@ -93,6 +112,42 @@ class TransportGroupoidAggregator:
93
112
  )
94
113
  logger.debug("Registered transport {} -> {}", source, target)
95
114
 
115
+ def register_transport_from_points(
116
+ self,
117
+ source: str,
118
+ target: str,
119
+ source_point: npt.NDArray[np.float64],
120
+ target_point: npt.NDArray[np.float64],
121
+ method: str = "pole",
122
+ n_rungs: int = 2,
123
+ ) -> npt.NDArray[np.float64]:
124
+ """Compute and register a transport map from two manifold points.
125
+
126
+ Builds the parallel-transport matrix along the geodesic from
127
+ ``source_point`` to ``target_point`` via
128
+ :func:`groupoid.transport.compute_transport_matrix` (pole or
129
+ Schild's ladder) and registers it for the ``(source, target)``
130
+ edge. This wires the ladder approximations into the aggregation
131
+ pipeline for the case where per-client base points are known but
132
+ explicit transport matrices are not. The ladders are
133
+ approximations with a small residual; see LIMITATIONS.md for the
134
+ measured behavior.
135
+
136
+ Returns
137
+ -------
138
+ np.ndarray
139
+ The registered transport matrix.
140
+ """
141
+ matrix = compute_transport_matrix(
142
+ self.manifold,
143
+ source_point,
144
+ target_point,
145
+ method=method,
146
+ n_rungs=n_rungs,
147
+ )
148
+ self.register_transport(source, target, matrix)
149
+ return matrix
150
+
96
151
  def _get_transport_to_base(self, node: str) -> npt.NDArray[np.float64] | None:
97
152
  """Compute the composite transport map from node to base_node.
98
153
 
@@ -194,6 +249,15 @@ class TransportGroupoidAggregator:
194
249
  nodes = sorted(transported.keys())
195
250
  param_stack = np.stack([transported[n] for n in nodes])
196
251
 
252
+ # Optional topological divergence tracking on the transported
253
+ # parameters (all in the base frame, so rounds are comparable).
254
+ divergence: _persistence.PersistenceSummary | None = None
255
+ if self.track_divergence:
256
+ divergence = _persistence.track_divergence(
257
+ param_stack, previous_summary=self._prev_divergence
258
+ )
259
+ self._prev_divergence = divergence
260
+
197
261
  if weights is not None:
198
262
  w = np.array([weights.get(n, 1.0) for n in nodes])
199
263
  w = w / w.sum()
@@ -223,6 +287,7 @@ class TransportGroupoidAggregator:
223
287
  is_consistent=is_consistent,
224
288
  transport_residuals=transport_residuals,
225
289
  round_idx=self._round_idx,
290
+ divergence=divergence,
226
291
  )
227
292
 
228
293
  logger.info(
groupoid/groupoid.py CHANGED
@@ -24,6 +24,11 @@ class Morphism(BaseModel):
24
24
  def __repr__(self) -> str:
25
25
  return f"Morphism({self.source} -> {self.target})"
26
26
 
27
+ # pydantic's BaseModel defines __str__ separately (a field dump including
28
+ # the full transport matrix), which would bypass the compact __repr__ in
29
+ # print(), f-strings, and loguru's {} formatting. Alias it explicitly.
30
+ __str__ = __repr__
31
+
27
32
 
28
33
  class CompositionError(Exception):
29
34
  """Raised when morphism composition is not defined."""
groupoid/optimizer.py CHANGED
@@ -9,6 +9,15 @@ This module provides:
9
9
  - Riemannian SGD with retraction
10
10
  - Riemannian Adam with exponential map updates
11
11
  - Adaptive learning rate based on sectional curvature
12
+
13
+ Accumulated moments (the SGD momentum velocity and Adam's first moment)
14
+ live in the tangent space of the iterate that produced them. After each
15
+ step they are parallel-transported into the new iterate's tangent space
16
+ when the metric provides parallel transport (the construction of
17
+ Becigneul & Ganea, "Riemannian Adaptive Optimization Methods", ICLR 2019),
18
+ and projected onto it otherwise. Transport is an isometry, so it moves the
19
+ moment without shrinking it; projection is a coarser fallback that can
20
+ lose the component normal to the new tangent space.
12
21
  """
13
22
 
14
23
  from __future__ import annotations
@@ -21,13 +30,42 @@ import numpy.typing as npt
21
30
  from loguru import logger
22
31
 
23
32
 
33
+ def _transport_moment(
34
+ manifold: Any, # geomstats manifold; no upstream type stubs
35
+ moment: npt.NDArray[np.float64],
36
+ base_point: npt.NDArray[np.float64],
37
+ new_point: npt.NDArray[np.float64],
38
+ ) -> npt.NDArray[np.float64]:
39
+ """Move an accumulated moment from base_point's tangent space to new_point's.
40
+
41
+ Uses the metric's parallel transport when available (isometric: the
42
+ moment's norm is preserved exactly). Falls back to projection onto the
43
+ new tangent space for metrics that do not implement parallel transport,
44
+ mirroring the compatibility shims elsewhere in this package.
45
+ """
46
+ metric = manifold.metric
47
+ if hasattr(metric, "parallel_transport"):
48
+ try:
49
+ transported: npt.NDArray[np.float64] = metric.parallel_transport(
50
+ moment, base_point, end_point=new_point
51
+ )
52
+ return transported
53
+ except NotImplementedError:
54
+ pass
55
+ projected: npt.NDArray[np.float64] = manifold.to_tangent(moment, new_point)
56
+ return projected
57
+
58
+
24
59
  @dataclass
25
60
  class RiemannianSGD:
26
61
  """Riemannian stochastic gradient descent.
27
62
 
28
63
  Updates parameters by computing the Riemannian gradient (projection
29
64
  of Euclidean gradient onto tangent space) and retracting back to
30
- the manifold via the exponential map.
65
+ the manifold via the exponential map. With momentum, the velocity is
66
+ parallel-transported into each new iterate's tangent space (see the
67
+ module docstring), so it accumulates geometry-consistently across
68
+ steps.
31
69
 
32
70
  Parameters
33
71
  ----------
@@ -64,7 +102,10 @@ class RiemannianSGD:
64
102
  # Project gradient onto tangent space
65
103
  riemannian_grad = self.manifold.to_tangent(euclidean_grad, point)
66
104
 
67
- # Apply momentum
105
+ # Apply momentum. _velocity was parallel-transported into `point`'s
106
+ # tangent space at the end of the previous step; the to_tangent wrap
107
+ # keeps the combination numerically tangent (and tolerates callers
108
+ # stepping from a point other than the previous iterate).
68
109
  vel: npt.NDArray[np.float64]
69
110
  if self.momentum > 0:
70
111
  if self._velocity is None:
@@ -73,7 +114,6 @@ class RiemannianSGD:
73
114
  vel = self.manifold.to_tangent(
74
115
  self.momentum * self._velocity + riemannian_grad, point
75
116
  )
76
- self._velocity = vel
77
117
  update = -self.lr * vel
78
118
  else:
79
119
  update = -self.lr * riemannian_grad
@@ -81,6 +121,10 @@ class RiemannianSGD:
81
121
  # Retract to manifold via exponential map
82
122
  new_point: npt.NDArray[np.float64] = self.manifold.metric.exp(update, point)
83
123
 
124
+ # Carry the velocity to the new iterate's tangent space.
125
+ if self.momentum > 0:
126
+ self._velocity = _transport_moment(self.manifold, vel, point, new_point)
127
+
84
128
  return new_point
85
129
 
86
130
 
@@ -90,7 +134,11 @@ class RiemannianAdam:
90
134
 
91
135
  Adapts the Adam optimizer to Riemannian manifolds by maintaining
92
136
  exponential moving averages of the Riemannian gradient and its
93
- squared norm, with updates via the exponential map.
137
+ squared norm, with updates via the exponential map. The first moment
138
+ is parallel-transported into each new iterate's tangent space (see
139
+ the module docstring); the second moment is a scalar gradient-norm
140
+ average and needs no transport because parallel transport is an
141
+ isometry (norms are invariant).
94
142
 
95
143
  Parameters
96
144
  ----------
@@ -143,6 +191,10 @@ class RiemannianAdam:
143
191
  # m_hat = m_1 / (1 - beta1) recovers `grad` on the first step. Seeding
144
192
  # m_1 = grad directly would leave the 1/(1-beta1) factor uncancelled and
145
193
  # inflate the first update by ~10x (beta1=0.9).
194
+ # _m was parallel-transported into `point`'s tangent space at the end
195
+ # of the previous step; the to_tangent wrap keeps the combination
196
+ # numerically tangent (and tolerates callers stepping from a point
197
+ # other than the previous iterate).
146
198
  first_moment: npt.NDArray[np.float64]
147
199
  if self._m is None:
148
200
  first_moment = (1 - self.beta1) * grad
@@ -150,7 +202,6 @@ class RiemannianAdam:
150
202
  first_moment = self.manifold.to_tangent(
151
203
  self.beta1 * self._m + (1 - self.beta1) * grad, point
152
204
  )
153
- self._m = first_moment
154
205
 
155
206
  # Update biased second moment (scalar, norm-based)
156
207
  self._v = self.beta2 * self._v + (1 - self.beta2) * grad_norm_sq
@@ -164,6 +215,10 @@ class RiemannianAdam:
164
215
  update = self.manifold.to_tangent(update, point)
165
216
 
166
217
  new_point: npt.NDArray[np.float64] = self.manifold.metric.exp(update, point)
218
+
219
+ # Carry the first moment to the new iterate's tangent space.
220
+ self._m = _transport_moment(self.manifold, first_moment, point, new_point)
221
+
167
222
  return new_point
168
223
 
169
224
 
@@ -175,9 +230,10 @@ def curvature_adaptive_lr(
175
230
  ) -> float:
176
231
  """Adapt learning rate based on local sectional curvature.
177
232
 
178
- In regions of high positive curvature, geodesics converge and
179
- we should take smaller steps. In regions of negative curvature,
180
- geodesics diverge and we can take larger steps.
233
+ In regions of high positive curvature, geodesics converge and we
234
+ should take smaller steps, so the base rate is damped by
235
+ ``1 / (1 + kappa)``. In flat or negatively curved regions the base
236
+ rate is returned unchanged; no enlargement is applied.
181
237
 
182
238
  Parameters
183
239
  ----------
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: groupoid
3
- Version: 0.1.0.dev2
3
+ Version: 0.1.0.dev4
4
4
  Summary: Research prototype: groupoid-based aggregation for federated learning on Riemannian manifolds
5
5
  Author-email: Santiago Maniches <santiago.maniches@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -109,21 +109,20 @@ integration tests:
109
109
  - **Federated aggregation pipeline**: transport-aware aggregation with
110
110
  H^1 consistency checking, multi-round convergence tested
111
111
  (`groupoid.aggregation`)
112
-
113
- ## Implemented, validated against ground truth, not yet integrated
114
-
115
- These modules have unit tests that validate behavior against a
116
- known-correct reference, but are not yet wired into the main aggregation
117
- pipeline:
118
-
119
112
  - **Parallel transport**: Schild's ladder and pole ladder
120
- (`groupoid.transport`). The pole ladder is validated against geomstats'
121
- analytic parallel transport on S^2 -- it matches in direction
122
- (cosine > 0.999) and magnitude. Schild's ladder is a coarser
123
- first-order approximation and is asserted as such. See
124
- [LIMITATIONS.md](LIMITATIONS.md) for the convergence caveat.
113
+ (`groupoid.transport`), wired into the pipeline via
114
+ `TransportGroupoidAggregator.register_transport_from_points` (computes
115
+ and registers the transport matrix from two client base points). The
116
+ pole ladder is validated against geomstats' analytic parallel transport
117
+ on S^2 -- it matches in direction (cosine > 0.999) and magnitude.
118
+ Schild's ladder is a coarser first-order approximation and is asserted
119
+ as such. See [LIMITATIONS.md](LIMITATIONS.md) for the convergence
120
+ caveat.
125
121
  - **Persistent homology**: Vietoris-Rips filtration for divergence
126
- tracking (`groupoid.persistence`). Unit-tested against point clouds of
122
+ tracking (`groupoid.persistence`), wired into the pipeline via the
123
+ aggregator's opt-in `track_divergence` flag (per-round H0-vs-H0
124
+ bottleneck distance on the transported parameters, exposed as
125
+ `FederatedRound.divergence`). Unit-tested against point clouds of
127
126
  known topology: a circle's dominant 1-cycle (via maximum persistence),
128
127
  two-cluster component counting (`betti_0 == 2` at a finite filtration),
129
128
  and a translation-invariant bottleneck distance. The persistence diagram
@@ -133,17 +132,20 @@ pipeline:
133
132
  the H0 diagram. The Betti numbers are degenerate under the default
134
133
  `thresh=inf` filtration; see [LIMITATIONS.md](LIMITATIONS.md).
135
134
 
136
- ## Implemented, smoke-tested, not yet integrated
135
+ ## Implemented and validated, not yet integrated
137
136
 
138
- This module has implementation with smoke-test coverage only -- the tests
139
- check coarse sanity (steps stay on the manifold), not core correctness --
140
- and it is not yet wired into the main aggregation pipeline:
137
+ This module is validated against known-correct references but not yet
138
+ wired into the main aggregation pipeline:
141
139
 
142
140
  - **Riemannian optimizers**: SGD and Adam with exponential map
143
- retraction; smoke-tested to stay on the manifold after a step, with the
144
- curvature-adaptive learning rate covered for both its damping and
145
- fallback branches. Core descent/convergence behavior is not validated
146
- (`groupoid.optimizer`)
141
+ retraction; the momentum velocity and Adam first moment are
142
+ parallel-transported between iterates (with a projection fallback for
143
+ metrics without parallel transport). Validated: descent to a known
144
+ target on S^2 (geodesic-distance objective) for SGD, momentum SGD, and
145
+ Adam; transported moments preserve norm exactly where projection would
146
+ annihilate them; the curvature-adaptive learning rate is covered for
147
+ both its damping and fallback branches. No general convergence-rate
148
+ guarantees are established (`groupoid.optimizer`)
147
149
 
148
150
  ## Status
149
151
 
@@ -178,8 +180,13 @@ idea -- transport-groupoid aggregation with cohomological consistency checking
178
180
  In short: use Flower/FedML/TFF for the FL system, use geomstats/pymanopt for
179
181
  manifold math; GROUPOID is a research prototype testing whether combining a
180
182
  transport groupoid with sheaf-cohomological consistency yields a better
181
- aggregation operator than Euclidean FedAvg. That hypothesis is **not yet
182
- validated** (see [STATUS.md](STATUS.md)).
183
+ aggregation operator than Euclidean FedAvg. A preregistered synthetic
184
+ benchmark ([experiments/](experiments/)) supports the transport benefit
185
+ under frame misalignment -- an effect largely built into the synthetic
186
+ setup -- and shows the pooled H^1 norm tracks corruption-induced error
187
+ across corruption levels, though it does not rank runs within a level.
188
+ The hypothesis remains **unvalidated on real federated learning tasks**
189
+ (see [STATUS.md](STATUS.md)).
183
190
 
184
191
  ## Installation
185
192
 
@@ -0,0 +1,15 @@
1
+ groupoid/__init__.py,sha256=k4p0zjEcIY-NV9ZTo33Qu2u95M3TBAXMxo-qg25YK2k,839
2
+ groupoid/aggregation.py,sha256=_HCkjvL6iYUYy_F5TAE-o2_WHTRDhCkTBelee92EfA0,11481
3
+ groupoid/cohomology.py,sha256=CHZDSGjPPuDNXQG8f3xnx3sDlxpEmlYeWqRiCpfRNpU,3567
4
+ groupoid/groupoid.py,sha256=VsZThPAamxXnNWZp3YYJtSv_AiHnw9PYajCAI2Kmkyc,2246
5
+ groupoid/laplacian.py,sha256=jqZEU6yvBocUqKg30XUojIUlDoGZVfyMEBKNRWhdG6w,6189
6
+ groupoid/manifold.py,sha256=2jP68SwLhbpl4EItvLBRnV6_k6l2thTFzchudIzjGJw,2100
7
+ groupoid/optimizer.py,sha256=RmleLbxpIiUIVihYFHKsN49YAbuslV6Ip5xG2s_-QrI,9575
8
+ groupoid/persistence.py,sha256=BuiBr00qRAXMPjdlZKuBRFhBUwFge7v2a-rn5bTF3LU,7229
9
+ groupoid/sheaf.py,sha256=hlhvbP_rfQPy7t-EbvD8jTkjsRYv3eyk49Th2ehRNL0,2653
10
+ groupoid/transport.py,sha256=5ItpmiIEqjEzEcFY8H1EAWiJpWHg-9h2d-UVkLAnR9A,5816
11
+ groupoid-0.1.0.dev4.dist-info/licenses/LICENSE,sha256=qd0jMtXL8CN6XNdjjC4r2RX5u2FS6Y0TG1FtWTH4TTM,10766
12
+ groupoid-0.1.0.dev4.dist-info/METADATA,sha256=TeyF3BIGLTWnNOTn9uIXDGQCSR0MoWnx2cCKW2AU_x4,11914
13
+ groupoid-0.1.0.dev4.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
14
+ groupoid-0.1.0.dev4.dist-info/top_level.txt,sha256=avPz-dSDwo-4zKd7EoxNoLEEwEOYTAtByUD6BTFV9bY,9
15
+ groupoid-0.1.0.dev4.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (83.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,15 +0,0 @@
1
- groupoid/__init__.py,sha256=Ygp2CQP485aQAdyO20G5BchVyM8TZPPgeLiWEYrDL4A,839
2
- groupoid/aggregation.py,sha256=5SbaiZkfA4HJGVR09n9oyzlEAGFyTN1eUZDVAcHoBYU,8768
3
- groupoid/cohomology.py,sha256=CHZDSGjPPuDNXQG8f3xnx3sDlxpEmlYeWqRiCpfRNpU,3567
4
- groupoid/groupoid.py,sha256=L2WmTG8-Sh_r6TsgmIJ077zRG-nz1OyYEqsorAJf1w0,1992
5
- groupoid/laplacian.py,sha256=jqZEU6yvBocUqKg30XUojIUlDoGZVfyMEBKNRWhdG6w,6189
6
- groupoid/manifold.py,sha256=2jP68SwLhbpl4EItvLBRnV6_k6l2thTFzchudIzjGJw,2100
7
- groupoid/optimizer.py,sha256=DPFVT93aPkygFkVrRVE0_H2I9dCkbX_qRo2-YTpZB6E,6650
8
- groupoid/persistence.py,sha256=BuiBr00qRAXMPjdlZKuBRFhBUwFge7v2a-rn5bTF3LU,7229
9
- groupoid/sheaf.py,sha256=hlhvbP_rfQPy7t-EbvD8jTkjsRYv3eyk49Th2ehRNL0,2653
10
- groupoid/transport.py,sha256=5ItpmiIEqjEzEcFY8H1EAWiJpWHg-9h2d-UVkLAnR9A,5816
11
- groupoid-0.1.0.dev2.dist-info/licenses/LICENSE,sha256=qd0jMtXL8CN6XNdjjC4r2RX5u2FS6Y0TG1FtWTH4TTM,10766
12
- groupoid-0.1.0.dev2.dist-info/METADATA,sha256=EmZ_ljhNpC0ffCfVBxU9r7IOi7RV3I0zdiIPdgmdUk0,11217
13
- groupoid-0.1.0.dev2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
14
- groupoid-0.1.0.dev2.dist-info/top_level.txt,sha256=avPz-dSDwo-4zKd7EoxNoLEEwEOYTAtByUD6BTFV9bY,9
15
- groupoid-0.1.0.dev2.dist-info/RECORD,,