holonomy-lib 0.8.0__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.
- holonomy_explorer/__init__.py +1 -0
- holonomy_explorer/__main__.py +79 -0
- holonomy_explorer/batching.py +182 -0
- holonomy_explorer/common.py +187 -0
- holonomy_explorer/curvature.py +154 -0
- holonomy_explorer/datasets.py +346 -0
- holonomy_explorer/graphs.py +286 -0
- holonomy_explorer/server.py +172 -0
- holonomy_explorer/static/app.js +435 -0
- holonomy_explorer/static/charts.js +114 -0
- holonomy_explorer/static/index.html +45 -0
- holonomy_explorer/static/layout.js +85 -0
- holonomy_explorer/static/style.css +121 -0
- holonomy_explorer/static/view.js +195 -0
- holonomy_explorer/topology.py +118 -0
- holonomy_lib/__init__.py +9 -0
- holonomy_lib/_graph_utils.py +52 -0
- holonomy_lib/_quadrature.py +74 -0
- holonomy_lib/algebra/__init__.py +32 -0
- holonomy_lib/algebra/eigsh_sparse.py +251 -0
- holonomy_lib/algebra/lanczos.py +361 -0
- holonomy_lib/algebra/linear.py +180 -0
- holonomy_lib/algebra/solvers.py +547 -0
- holonomy_lib/convex/__init__.py +27 -0
- holonomy_lib/convex/least_squares.py +184 -0
- holonomy_lib/discrete_geometry/__init__.py +38 -0
- holonomy_lib/discrete_geometry/forman.py +242 -0
- holonomy_lib/discrete_geometry/ricci.py +855 -0
- holonomy_lib/experimental/__init__.py +13 -0
- holonomy_lib/experimental/heterogeneous_kappa.py +361 -0
- holonomy_lib/experimental/laplacian_eigenmaps.py +200 -0
- holonomy_lib/hyperbolic/__init__.py +27 -0
- holonomy_lib/hyperbolic/frechet_mean.py +381 -0
- holonomy_lib/hyperbolic/heat_kernel.py +608 -0
- holonomy_lib/hyperbolic/manifold_inner.py +81 -0
- holonomy_lib/info_geometry/__init__.py +64 -0
- holonomy_lib/info_geometry/divergences.py +224 -0
- holonomy_lib/info_geometry/fisher.py +179 -0
- holonomy_lib/lie/__init__.py +54 -0
- holonomy_lib/lie/so3.py +385 -0
- holonomy_lib/lie/spherical_harmonics.py +189 -0
- holonomy_lib/manifolds/__init__.py +53 -0
- holonomy_lib/manifolds/comparison.py +343 -0
- holonomy_lib/manifolds/fixed_rank.py +453 -0
- holonomy_lib/manifolds/lorentz.py +699 -0
- holonomy_lib/manifolds/lorentzian.py +567 -0
- holonomy_lib/manifolds/product.py +425 -0
- holonomy_lib/manifolds/spd.py +504 -0
- holonomy_lib/manifolds/stereographic.py +680 -0
- holonomy_lib/optimization/__init__.py +42 -0
- holonomy_lib/optimization/base.py +65 -0
- holonomy_lib/optimization/sgd.py +112 -0
- holonomy_lib/py.typed +0 -0
- holonomy_lib/sheaf/__init__.py +74 -0
- holonomy_lib/sheaf/graph_sheaf.py +352 -0
- holonomy_lib/sheaf/laplacian.py +529 -0
- holonomy_lib/simplicial/__init__.py +55 -0
- holonomy_lib/simplicial/_face_lookup.py +99 -0
- holonomy_lib/simplicial/complex.py +168 -0
- holonomy_lib/simplicial/sparse_complex.py +145 -0
- holonomy_lib/simplicial/vietoris_rips.py +277 -0
- holonomy_lib/spectral/__init__.py +55 -0
- holonomy_lib/spectral/diffusion_map.py +169 -0
- holonomy_lib/spectral/effective_resistance.py +199 -0
- holonomy_lib/spectral/embedding.py +131 -0
- holonomy_lib/spectral/heat_kernel.py +236 -0
- holonomy_lib/spectral/laplacian.py +441 -0
- holonomy_lib/spectral/magnetic.py +351 -0
- holonomy_lib/spectral/spectral_dimension.py +104 -0
- holonomy_lib/tensor_calculus/__init__.py +23 -0
- holonomy_lib/tensor_calculus/decomposition.py +208 -0
- holonomy_lib/topology/__init__.py +28 -0
- holonomy_lib/topology/_filtration.py +147 -0
- holonomy_lib/topology/_reduction.py +227 -0
- holonomy_lib/topology/hodge.py +250 -0
- holonomy_lib/topology/persistent_homology.py +301 -0
- holonomy_lib-0.8.0.dist-info/METADATA +161 -0
- holonomy_lib-0.8.0.dist-info/RECORD +82 -0
- holonomy_lib-0.8.0.dist-info/WHEEL +5 -0
- holonomy_lib-0.8.0.dist-info/entry_points.txt +2 -0
- holonomy_lib-0.8.0.dist-info/licenses/LICENSE.md +105 -0
- holonomy_lib-0.8.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Fréchet means of vector-coordinate points on Riemannian manifolds.
|
|
5
|
+
|
|
6
|
+
The objective is the weighted sum of squared geodesic distances. On a
|
|
7
|
+
Hadamard manifold it has a unique minimizer, but that fact alone does not
|
|
8
|
+
make unit Karcher steps converge. This implementation controls each batch's
|
|
9
|
+
step with backtracking on the objective. Each batch member picks its own
|
|
10
|
+
step size.
|
|
11
|
+
|
|
12
|
+
References:
|
|
13
|
+
Karcher, H. (1977). Riemannian center of mass and mollifier smoothing.
|
|
14
|
+
Comm. Pure Appl. Math. 30(5):509–541.
|
|
15
|
+
Afsari, B., Tron, R., Vidal, R. (2013). On the convergence of gradient
|
|
16
|
+
descent for finding the Riemannian center of mass. SIAM J. Control
|
|
17
|
+
Optim. 51(3):2230–2260.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import math
|
|
23
|
+
import warnings
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import torch
|
|
27
|
+
from torch.autograd.function import once_differentiable
|
|
28
|
+
|
|
29
|
+
# Iteration cap. The loop breaks early once every residual is at most tol, so
|
|
30
|
+
# this only limits the case where some batch member never gets there.
|
|
31
|
+
FRECHET_MAX_ITER_DEFAULT = 100
|
|
32
|
+
# Convergence is checked on the host every this many iterations. Each check is
|
|
33
|
+
# a .item() sync, which CONVENTIONS §4.1 allows for convergence checks.
|
|
34
|
+
FRECHET_SYNC_EVERY_DEFAULT = 8
|
|
35
|
+
# Trial steps halve from the last accepted step, so 16 trials starting at 1
|
|
36
|
+
# reach 2**-15. If none passes, that batch member stays put and the final
|
|
37
|
+
# residual warning reports it.
|
|
38
|
+
FRECHET_MAX_BACKTRACKS_DEFAULT = 16
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def frechet_mean(
|
|
42
|
+
points: torch.Tensor,
|
|
43
|
+
manifold: Any,
|
|
44
|
+
weights: torch.Tensor | None = None,
|
|
45
|
+
max_iter: int = FRECHET_MAX_ITER_DEFAULT,
|
|
46
|
+
tol: float = 1e-9,
|
|
47
|
+
*,
|
|
48
|
+
sync_every: int = FRECHET_SYNC_EVERY_DEFAULT,
|
|
49
|
+
max_backtracks: int = FRECHET_MAX_BACKTRACKS_DEFAULT,
|
|
50
|
+
) -> torch.Tensor:
|
|
51
|
+
"""Compute a Fréchet mean with backtracking Karcher descent.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
points: `(B, N, ambient_dim)` vector-coordinate points. Matrix-valued
|
|
55
|
+
manifolds such as SPD are not supported by this shape contract.
|
|
56
|
+
manifold: object exposing `log`, `exp`, `norm`, and `distance` on
|
|
57
|
+
vector-coordinate batches, for example `LorentzManifold`. Backward
|
|
58
|
+
also requires `dim`, `projection`, and `inner`.
|
|
59
|
+
weights: optional `(B, N)` finite non-negative weights, with positive
|
|
60
|
+
total in every batch. Normalized internally; omitted means uniform.
|
|
61
|
+
max_iter: maximum number of descent steps, default 100.
|
|
62
|
+
tol: requested residual norm of the weighted average logarithm.
|
|
63
|
+
sync_every: check all-batch convergence on the host every this many
|
|
64
|
+
iterations. At most `sync_every - 1` extra small updates occur
|
|
65
|
+
after all residuals first meet the tolerance.
|
|
66
|
+
max_backtracks: number of trial step sizes, starting at the last
|
|
67
|
+
accepted step and halving (initially one). A batch still above
|
|
68
|
+
`tol` with no acceptable trial stays where it is.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
`(B, ambient_dim)` mean estimate. `B=0` returns an empty tensor.
|
|
72
|
+
Warns if its residual exceeds `tol` after the iteration budget.
|
|
73
|
+
|
|
74
|
+
Notes:
|
|
75
|
+
Iterations and backtracking trials are sequential because each step
|
|
76
|
+
depends on the preceding objective value. Points and batch elements
|
|
77
|
+
are processed together by tensor operations. The line search is
|
|
78
|
+
evaluated without autograd. First derivatives with respect to points
|
|
79
|
+
and weights use the implicit function theorem at the returned mean,
|
|
80
|
+
with the manifold held fixed. They do not differentiate the iterations.
|
|
81
|
+
Accuracy therefore requires convergence of the stationarity residual;
|
|
82
|
+
a warning about nonconvergence also applies to the gradients.
|
|
83
|
+
|
|
84
|
+
The backward system uses an orthonormal tangent frame and has size
|
|
85
|
+
`dim x dim`, including for embedded manifolds such as the Lorentz
|
|
86
|
+
hyperboloid. A singular stationarity Jacobian (to working precision)
|
|
87
|
+
returns NaN gradients for that batch member only. Higher derivatives
|
|
88
|
+
and derivatives with respect to manifold parameters are unsupported.
|
|
89
|
+
|
|
90
|
+
On positive-curvature manifolds, spread-out inputs may have several
|
|
91
|
+
local minima or cross a logarithm's cut locus. Backtracking does not
|
|
92
|
+
establish global optimality there. The caller must choose inputs in
|
|
93
|
+
a convex normal neighborhood to use local convergence guarantees.
|
|
94
|
+
|
|
95
|
+
References:
|
|
96
|
+
Karcher (1977), Riemannian center of mass and mollifier smoothing.
|
|
97
|
+
Afsari, Tron, Vidal (2013), On the convergence of gradient descent
|
|
98
|
+
for finding the Riemannian center of mass, §§2–3.
|
|
99
|
+
Absil, Mahony, Sepulchre (2008), Optimization Algorithms on Matrix
|
|
100
|
+
Manifolds, §4.2, line-search methods.
|
|
101
|
+
Lou et al. (2020), Differentiating through the Frechet Mean,
|
|
102
|
+
ICML, PMLR 119:6393-6403, implicit differentiation.
|
|
103
|
+
"""
|
|
104
|
+
if points.ndim != 3:
|
|
105
|
+
raise ValueError(
|
|
106
|
+
f"points must have shape (B, N, ambient_dim), got {tuple(points.shape)}"
|
|
107
|
+
)
|
|
108
|
+
batch, count, dimension = points.shape
|
|
109
|
+
if count == 0:
|
|
110
|
+
raise ValueError("Fréchet mean of an empty set is undefined")
|
|
111
|
+
if weights is not None and weights.shape != (batch, count):
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"weights must have shape ({batch}, {count}), got {tuple(weights.shape)}"
|
|
114
|
+
)
|
|
115
|
+
if max_iter < 0 or sync_every < 1 or max_backtracks < 1:
|
|
116
|
+
raise ValueError("max_iter must be >= 0; sync_every and max_backtracks >= 1")
|
|
117
|
+
if not torch.is_floating_point(points):
|
|
118
|
+
raise ValueError("points must have floating dtype")
|
|
119
|
+
if not math.isfinite(tol) or tol < 0:
|
|
120
|
+
raise ValueError("tol must be finite and non-negative")
|
|
121
|
+
if weights is None:
|
|
122
|
+
normalized_weights = points.new_full((batch, count), 1.0 / count)
|
|
123
|
+
else:
|
|
124
|
+
if not torch.isfinite(weights).all() or (weights < 0).any():
|
|
125
|
+
raise ValueError("weights must be finite and non-negative")
|
|
126
|
+
# Match the points' device and dtype so the weighted sums do not mix types.
|
|
127
|
+
weights = weights.to(device=points.device, dtype=points.dtype)
|
|
128
|
+
total = weights.sum(dim=-1, keepdim=True)
|
|
129
|
+
if not torch.isfinite(total).all() or (total <= 0).any():
|
|
130
|
+
raise ValueError(
|
|
131
|
+
"weights must be finite, non-negative, and have positive total"
|
|
132
|
+
)
|
|
133
|
+
normalized_weights = weights / total
|
|
134
|
+
|
|
135
|
+
if batch == 0:
|
|
136
|
+
# Keep both differentiable inputs connected even for an empty batch.
|
|
137
|
+
return (normalized_weights.unsqueeze(-1) * points).sum(dim=1)
|
|
138
|
+
# The implicit-gradient Function is only entered for nonempty batches.
|
|
139
|
+
return _ImplicitFrechetMean.apply(
|
|
140
|
+
points, normalized_weights, manifold, max_iter, tol,
|
|
141
|
+
sync_every, max_backtracks,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _frechet_mean_forward(
|
|
146
|
+
points: torch.Tensor, normalized_weights: torch.Tensor, manifold: Any,
|
|
147
|
+
max_iter: int, tol: float, sync_every: int, max_backtracks: int,
|
|
148
|
+
) -> torch.Tensor:
|
|
149
|
+
"""Karcher descent for validated inputs; called without an autograd graph."""
|
|
150
|
+
batch, count, dimension = points.shape
|
|
151
|
+
# Descent starts at each batch member's first input point.
|
|
152
|
+
mean = points[:, 0].clone()
|
|
153
|
+
# One flat (batch * count, dim) call per manifold op, so the work is vectorized.
|
|
154
|
+
flat_points = points.reshape(batch * count, dimension)
|
|
155
|
+
|
|
156
|
+
def average_log(base: torch.Tensor) -> torch.Tensor:
|
|
157
|
+
# Log map from each base to every point, then the weighted sum over N.
|
|
158
|
+
expanded = base.unsqueeze(1).expand(batch, count, dimension)
|
|
159
|
+
logarithms = manifold.log(
|
|
160
|
+
expanded.reshape(batch * count, dimension), flat_points,
|
|
161
|
+
).reshape(batch, count, dimension)
|
|
162
|
+
return (normalized_weights.unsqueeze(-1) * logarithms).sum(dim=1)
|
|
163
|
+
|
|
164
|
+
def objective(base: torch.Tensor) -> torch.Tensor:
|
|
165
|
+
expanded = base.unsqueeze(1).expand(batch, count, dimension)
|
|
166
|
+
distances = manifold.distance(
|
|
167
|
+
expanded.reshape(batch * count, dimension), flat_points,
|
|
168
|
+
).reshape(batch, count)
|
|
169
|
+
return (normalized_weights * distances.square()).sum(dim=-1)
|
|
170
|
+
|
|
171
|
+
# Every batch member's first sweep starts at unit step.
|
|
172
|
+
previous_step = points.new_ones(batch)
|
|
173
|
+
for iteration in range(max_iter):
|
|
174
|
+
tangent = average_log(mean)
|
|
175
|
+
# The tangent belongs to the old mean; its norm must use that base.
|
|
176
|
+
residual = manifold.norm(mean, tangent)
|
|
177
|
+
active = residual > tol
|
|
178
|
+
with torch.no_grad():
|
|
179
|
+
cost = objective(mean)
|
|
180
|
+
# Start from the last accepted step instead of 1. Near the minimum
|
|
181
|
+
# the objective change is below rounding, so a restart at a large
|
|
182
|
+
# step would be judged on noise.
|
|
183
|
+
step = previous_step
|
|
184
|
+
# Converged batches still move by their last step until the next
|
|
185
|
+
# host check. That move is at most tol, and backward only sees the
|
|
186
|
+
# final mean, so it does not change the gradient.
|
|
187
|
+
accepted_step = torch.where(active, torch.zeros_like(step), step)
|
|
188
|
+
# Converged batches count as accepted, so the trial loop skips them.
|
|
189
|
+
accepted = ~active
|
|
190
|
+
# The derivative of the normalized squared-distance objective
|
|
191
|
+
# along average_log is -2*residual**2. Armijo with coefficient
|
|
192
|
+
# one half therefore asks for a decrease of step*residual**2.
|
|
193
|
+
# Allow one relative eps of objective rounding near a minimum.
|
|
194
|
+
rounding = torch.finfo(points.dtype).eps * cost.abs()
|
|
195
|
+
for _ in range(max_backtracks):
|
|
196
|
+
trial = manifold.exp(mean, step.unsqueeze(-1) * tangent)
|
|
197
|
+
trial_cost = objective(trial)
|
|
198
|
+
decrease = step * residual.square()
|
|
199
|
+
# Near the minimizer, subtraction of two squared-distance
|
|
200
|
+
# objectives loses the decrease. The gradient residual still
|
|
201
|
+
# resolves it, so demand residual reduction in that regime.
|
|
202
|
+
trial_residual = manifold.norm(trial, average_log(trial))
|
|
203
|
+
improved = torch.where(
|
|
204
|
+
decrease <= rounding,
|
|
205
|
+
trial_residual < residual,
|
|
206
|
+
trial_cost <= cost - decrease + rounding,
|
|
207
|
+
)
|
|
208
|
+
# Keep the first passing trial. Non-finite trial costs never pass.
|
|
209
|
+
take = ~accepted & torch.isfinite(trial_cost) & improved
|
|
210
|
+
accepted_step = torch.where(take, step, accepted_step)
|
|
211
|
+
accepted = accepted | take
|
|
212
|
+
step = step * 0.5
|
|
213
|
+
# Only active batches move their starting step. A rejected active batch
|
|
214
|
+
# has accepted_step 0, so it stays put and its starting step becomes 0.
|
|
215
|
+
previous_step = torch.where(active, accepted_step, previous_step)
|
|
216
|
+
mean = manifold.exp(mean, accepted_step.unsqueeze(-1) * tangent)
|
|
217
|
+
# The loop's one host sync: an early exit, checked every sync_every steps.
|
|
218
|
+
if (iteration + 1) % sync_every == 0 and not active.any().item():
|
|
219
|
+
break
|
|
220
|
+
|
|
221
|
+
# A non-finite or off-manifold mean fails loudly rather than being returned.
|
|
222
|
+
if not torch.isfinite(mean).all():
|
|
223
|
+
raise RuntimeError("frechet_mean produced non-finite output")
|
|
224
|
+
if hasattr(manifold, "is_on_manifold") and not manifold.is_on_manifold(mean).all():
|
|
225
|
+
raise RuntimeError("frechet_mean produced a point outside the manifold")
|
|
226
|
+
with torch.no_grad():
|
|
227
|
+
final_residual = manifold.norm(mean, average_log(mean)).max().item()
|
|
228
|
+
# NaN compares false with everything, so the isfinite test makes it a warning too.
|
|
229
|
+
if not math.isfinite(final_residual) or final_residual > tol:
|
|
230
|
+
warnings.warn(
|
|
231
|
+
f"frechet_mean did not converge: residual {final_residual:.6g} "
|
|
232
|
+
f"exceeds tol={tol:.6g} after at most {max_iter} iterations",
|
|
233
|
+
RuntimeWarning, stacklevel=2,
|
|
234
|
+
)
|
|
235
|
+
return mean
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _tangent_frame(mean: torch.Tensor, manifold: Any) -> torch.Tensor:
|
|
239
|
+
"""Tangent frame at each mean, orthonormal under `manifold.inner`.
|
|
240
|
+
|
|
241
|
+
Input: `mean` `(B, ambient)` on the manifold. The manifold must expose
|
|
242
|
+
`dim`, `projection`, and `inner`. The base point is treated as a constant.
|
|
243
|
+
Output: `(B, ambient, dim)` columns, orthonormal under `manifold.inner`
|
|
244
|
+
at each mean.
|
|
245
|
+
Assumes the projected ambient axes span the tangent space, which has
|
|
246
|
+
dimension `manifold.dim`.
|
|
247
|
+
"""
|
|
248
|
+
batch, ambient = mean.shape
|
|
249
|
+
dimension = manifold.dim
|
|
250
|
+
axes = torch.eye(ambient, dtype=mean.dtype, device=mean.device)
|
|
251
|
+
axes = axes.expand(batch, ambient, ambient)
|
|
252
|
+
# Full-dimensional manifold: every ambient axis is already a tangent direction.
|
|
253
|
+
if dimension == ambient:
|
|
254
|
+
frame = axes
|
|
255
|
+
else:
|
|
256
|
+
# Projection may be oblique in Euclidean coordinates (Lorentz), but
|
|
257
|
+
# its column space is still the tangent space. SVD extracts just the
|
|
258
|
+
# intrinsic number of directions; no normal direction enters the solve.
|
|
259
|
+
bases = mean[:, None, :].expand_as(axes).reshape(-1, ambient)
|
|
260
|
+
projected = manifold.projection(bases, axes.reshape(-1, ambient))
|
|
261
|
+
projected = projected.reshape(batch, ambient, ambient).mT
|
|
262
|
+
frame = torch.linalg.svd(projected, full_matrices=False).U[..., :dimension]
|
|
263
|
+
|
|
264
|
+
rows = frame.mT
|
|
265
|
+
# Gram matrix G_ij = <f_i, f_j> at each mean, from one batched inner call.
|
|
266
|
+
bases = mean[:, None, None, :].expand(batch, dimension, dimension, ambient)
|
|
267
|
+
left = rows[:, :, None, :].expand_as(bases)
|
|
268
|
+
right = rows[:, None, :, :].expand_as(bases)
|
|
269
|
+
gram = manifold.inner(
|
|
270
|
+
bases.reshape(-1, ambient), left.reshape(-1, ambient),
|
|
271
|
+
right.reshape(-1, ambient),
|
|
272
|
+
).reshape(batch, dimension, dimension)
|
|
273
|
+
# G = L L^T, so applying L^{-1} to the frame gives metric-orthonormal vectors.
|
|
274
|
+
factor = torch.linalg.cholesky(gram)
|
|
275
|
+
return torch.linalg.solve_triangular(factor, rows, upper=False).mT
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class _ImplicitFrechetMean(torch.autograd.Function):
|
|
279
|
+
"""Differentiate the converged tangent stationarity equation, per batch."""
|
|
280
|
+
|
|
281
|
+
@staticmethod
|
|
282
|
+
def forward(
|
|
283
|
+
ctx: Any, points: torch.Tensor, weights: torch.Tensor, manifold: Any,
|
|
284
|
+
max_iter: int, tol: float, sync_every: int, max_backtracks: int,
|
|
285
|
+
) -> torch.Tensor:
|
|
286
|
+
mean = _frechet_mean_forward(
|
|
287
|
+
points, weights, manifold, max_iter, tol, sync_every, max_backtracks,
|
|
288
|
+
)
|
|
289
|
+
ctx.manifold = manifold
|
|
290
|
+
# Only the inputs and the converged mean are saved. The iterates are not needed.
|
|
291
|
+
ctx.save_for_backward(points, weights, mean)
|
|
292
|
+
return mean
|
|
293
|
+
|
|
294
|
+
@staticmethod
|
|
295
|
+
@once_differentiable
|
|
296
|
+
def backward(
|
|
297
|
+
ctx: Any, grad_mean: torch.Tensor,
|
|
298
|
+
) -> tuple[torch.Tensor, torch.Tensor, None, None, None, None, None]:
|
|
299
|
+
"""Gradients of the Fréchet mean with respect to points and weights.
|
|
300
|
+
|
|
301
|
+
Input: `grad_mean` `(B, ambient)`, the cotangent of the mean.
|
|
302
|
+
Output: `grad_points` `(B, N, ambient)` and `grad_weights` `(B, N)`.
|
|
303
|
+
A batch member with a singular or non-finite stationarity Jacobian
|
|
304
|
+
gets NaN in both.
|
|
305
|
+
Assumes `mean` is a converged Fréchet mean, since the implicit
|
|
306
|
+
equation holds only there. The manifold is treated as fixed.
|
|
307
|
+
"""
|
|
308
|
+
points, weights, mean = ctx.saved_tensors
|
|
309
|
+
manifold = ctx.manifold
|
|
310
|
+
mean = mean.detach()
|
|
311
|
+
batch, count, ambient = points.shape
|
|
312
|
+
frame = _tangent_frame(mean, manifold)
|
|
313
|
+
dimension = frame.shape[-1]
|
|
314
|
+
|
|
315
|
+
with torch.enable_grad():
|
|
316
|
+
points = points.detach().requires_grad_(True)
|
|
317
|
+
weights = weights.detach().requires_grad_(True)
|
|
318
|
+
# Tangent coordinates at the mean. Zero coordinates give base = mean.
|
|
319
|
+
coordinates = mean.new_zeros(batch, dimension, requires_grad=True)
|
|
320
|
+
tangent = (frame @ coordinates.unsqueeze(-1)).squeeze(-1)
|
|
321
|
+
# F(c): the stationarity residual with the base at exp(mean, frame c).
|
|
322
|
+
base = manifold.exp(mean, tangent)
|
|
323
|
+
expanded = base[:, None, :].expand(batch, count, ambient)
|
|
324
|
+
logarithms = manifold.log(
|
|
325
|
+
expanded.reshape(-1, ambient), points.reshape(-1, ambient),
|
|
326
|
+
).reshape(batch, count, ambient)
|
|
327
|
+
average = (weights.unsqueeze(-1) * logarithms).sum(dim=1)
|
|
328
|
+
# Project the averaged log back onto the tangent space at the mean.
|
|
329
|
+
projected = manifold.projection(mean, average)
|
|
330
|
+
bases = mean[:, None, :].expand(batch, dimension, ambient)
|
|
331
|
+
vectors = projected[:, None, :].expand_as(bases)
|
|
332
|
+
# Residual component along each frame vector, in the metric at the mean.
|
|
333
|
+
stationarity = manifold.inner(
|
|
334
|
+
bases.reshape(-1, ambient), frame.mT.reshape(-1, ambient),
|
|
335
|
+
vectors.reshape(-1, ambient),
|
|
336
|
+
).reshape(batch, dimension)
|
|
337
|
+
|
|
338
|
+
# Summing over the batch lets one backward pass give every member's
|
|
339
|
+
# Jacobian row, since no manifold operation mixes batch members.
|
|
340
|
+
jacobian = torch.stack([
|
|
341
|
+
torch.autograd.grad(
|
|
342
|
+
stationarity[:, direction].sum(), coordinates,
|
|
343
|
+
retain_graph=True,
|
|
344
|
+
)[0]
|
|
345
|
+
for direction in range(dimension)
|
|
346
|
+
], dim=-2)
|
|
347
|
+
# base equals mean at c = 0, so this cotangent is frame^T grad_mean.
|
|
348
|
+
cotangent = torch.autograd.grad(
|
|
349
|
+
base, coordinates, grad_mean, retain_graph=True,
|
|
350
|
+
)[0]
|
|
351
|
+
# A non-finite Jacobian is zeroed so the SVD and solve stay finite.
|
|
352
|
+
# The member is flagged invalid below.
|
|
353
|
+
finite = torch.isfinite(jacobian).all(dim=(-2, -1))
|
|
354
|
+
safe_jacobian = torch.where(
|
|
355
|
+
finite[:, None, None], jacobian, torch.zeros_like(jacobian),
|
|
356
|
+
)
|
|
357
|
+
singular_values = torch.linalg.svdvals(safe_jacobian)
|
|
358
|
+
# The usual numerical-rank criterion scales with dimension and
|
|
359
|
+
# machine precision, not an absolute Hessian cutoff.
|
|
360
|
+
rank_tolerance = (
|
|
361
|
+
dimension * torch.finfo(mean.dtype).eps * singular_values[:, 0]
|
|
362
|
+
)
|
|
363
|
+
nonsingular = finite & (singular_values[:, -1] > rank_tolerance)
|
|
364
|
+
# Singular members get the identity so solve_ex stays finite. Their
|
|
365
|
+
# multipliers are zeroed and their gradients become NaN below.
|
|
366
|
+
identity = torch.eye(dimension, dtype=mean.dtype, device=mean.device)
|
|
367
|
+
system = torch.where(
|
|
368
|
+
nonsingular[:, None, None], safe_jacobian.mT, identity,
|
|
369
|
+
)
|
|
370
|
+
multiplier, info = torch.linalg.solve_ex(system, cotangent)
|
|
371
|
+
valid = nonsingular & (info == 0)
|
|
372
|
+
multiplier = torch.where(valid[:, None], multiplier, 0.0)
|
|
373
|
+
# Implicit function theorem: dm/dtheta = -J^{-1} dF/dtheta, so the
|
|
374
|
+
# gradient is -multiplier . dF/dtheta with J^T multiplier = cotangent.
|
|
375
|
+
grad_points, grad_weights = torch.autograd.grad(
|
|
376
|
+
stationarity, (points, weights), -multiplier,
|
|
377
|
+
)
|
|
378
|
+
# NaN marks invalid members, so a bad solve is never returned as a gradient.
|
|
379
|
+
grad_points = torch.where(valid[:, None, None], grad_points, float("nan"))
|
|
380
|
+
grad_weights = torch.where(valid[:, None], grad_weights, float("nan"))
|
|
381
|
+
return grad_points, grad_weights, None, None, None, None, None
|