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.
Files changed (82) hide show
  1. holonomy_explorer/__init__.py +1 -0
  2. holonomy_explorer/__main__.py +79 -0
  3. holonomy_explorer/batching.py +182 -0
  4. holonomy_explorer/common.py +187 -0
  5. holonomy_explorer/curvature.py +154 -0
  6. holonomy_explorer/datasets.py +346 -0
  7. holonomy_explorer/graphs.py +286 -0
  8. holonomy_explorer/server.py +172 -0
  9. holonomy_explorer/static/app.js +435 -0
  10. holonomy_explorer/static/charts.js +114 -0
  11. holonomy_explorer/static/index.html +45 -0
  12. holonomy_explorer/static/layout.js +85 -0
  13. holonomy_explorer/static/style.css +121 -0
  14. holonomy_explorer/static/view.js +195 -0
  15. holonomy_explorer/topology.py +118 -0
  16. holonomy_lib/__init__.py +9 -0
  17. holonomy_lib/_graph_utils.py +52 -0
  18. holonomy_lib/_quadrature.py +74 -0
  19. holonomy_lib/algebra/__init__.py +32 -0
  20. holonomy_lib/algebra/eigsh_sparse.py +251 -0
  21. holonomy_lib/algebra/lanczos.py +361 -0
  22. holonomy_lib/algebra/linear.py +180 -0
  23. holonomy_lib/algebra/solvers.py +547 -0
  24. holonomy_lib/convex/__init__.py +27 -0
  25. holonomy_lib/convex/least_squares.py +184 -0
  26. holonomy_lib/discrete_geometry/__init__.py +38 -0
  27. holonomy_lib/discrete_geometry/forman.py +242 -0
  28. holonomy_lib/discrete_geometry/ricci.py +855 -0
  29. holonomy_lib/experimental/__init__.py +13 -0
  30. holonomy_lib/experimental/heterogeneous_kappa.py +361 -0
  31. holonomy_lib/experimental/laplacian_eigenmaps.py +200 -0
  32. holonomy_lib/hyperbolic/__init__.py +27 -0
  33. holonomy_lib/hyperbolic/frechet_mean.py +381 -0
  34. holonomy_lib/hyperbolic/heat_kernel.py +608 -0
  35. holonomy_lib/hyperbolic/manifold_inner.py +81 -0
  36. holonomy_lib/info_geometry/__init__.py +64 -0
  37. holonomy_lib/info_geometry/divergences.py +224 -0
  38. holonomy_lib/info_geometry/fisher.py +179 -0
  39. holonomy_lib/lie/__init__.py +54 -0
  40. holonomy_lib/lie/so3.py +385 -0
  41. holonomy_lib/lie/spherical_harmonics.py +189 -0
  42. holonomy_lib/manifolds/__init__.py +53 -0
  43. holonomy_lib/manifolds/comparison.py +343 -0
  44. holonomy_lib/manifolds/fixed_rank.py +453 -0
  45. holonomy_lib/manifolds/lorentz.py +699 -0
  46. holonomy_lib/manifolds/lorentzian.py +567 -0
  47. holonomy_lib/manifolds/product.py +425 -0
  48. holonomy_lib/manifolds/spd.py +504 -0
  49. holonomy_lib/manifolds/stereographic.py +680 -0
  50. holonomy_lib/optimization/__init__.py +42 -0
  51. holonomy_lib/optimization/base.py +65 -0
  52. holonomy_lib/optimization/sgd.py +112 -0
  53. holonomy_lib/py.typed +0 -0
  54. holonomy_lib/sheaf/__init__.py +74 -0
  55. holonomy_lib/sheaf/graph_sheaf.py +352 -0
  56. holonomy_lib/sheaf/laplacian.py +529 -0
  57. holonomy_lib/simplicial/__init__.py +55 -0
  58. holonomy_lib/simplicial/_face_lookup.py +99 -0
  59. holonomy_lib/simplicial/complex.py +168 -0
  60. holonomy_lib/simplicial/sparse_complex.py +145 -0
  61. holonomy_lib/simplicial/vietoris_rips.py +277 -0
  62. holonomy_lib/spectral/__init__.py +55 -0
  63. holonomy_lib/spectral/diffusion_map.py +169 -0
  64. holonomy_lib/spectral/effective_resistance.py +199 -0
  65. holonomy_lib/spectral/embedding.py +131 -0
  66. holonomy_lib/spectral/heat_kernel.py +236 -0
  67. holonomy_lib/spectral/laplacian.py +441 -0
  68. holonomy_lib/spectral/magnetic.py +351 -0
  69. holonomy_lib/spectral/spectral_dimension.py +104 -0
  70. holonomy_lib/tensor_calculus/__init__.py +23 -0
  71. holonomy_lib/tensor_calculus/decomposition.py +208 -0
  72. holonomy_lib/topology/__init__.py +28 -0
  73. holonomy_lib/topology/_filtration.py +147 -0
  74. holonomy_lib/topology/_reduction.py +227 -0
  75. holonomy_lib/topology/hodge.py +250 -0
  76. holonomy_lib/topology/persistent_homology.py +301 -0
  77. holonomy_lib-0.8.0.dist-info/METADATA +161 -0
  78. holonomy_lib-0.8.0.dist-info/RECORD +82 -0
  79. holonomy_lib-0.8.0.dist-info/WHEEL +5 -0
  80. holonomy_lib-0.8.0.dist-info/entry_points.txt +2 -0
  81. holonomy_lib-0.8.0.dist-info/licenses/LICENSE.md +105 -0
  82. 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