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,351 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Magnetic Laplacian for directed and signed-directed graphs.
|
|
5
|
+
|
|
6
|
+
The standard real-valued graph Laplacians (`spectral.laplacian.*`)
|
|
7
|
+
assume a symmetric adjacency. For a directed graph the asymmetry
|
|
8
|
+
encodes information that any symmetrization throws away. The
|
|
9
|
+
**magnetic Laplacian** (Lieb-Loss 1993; Fanuel et al. 2017; Furutani
|
|
10
|
+
et al. 2020) recovers that information by attaching a complex phase
|
|
11
|
+
factor `e^{i · 2π · q · (A_{ij} - A_{ji})}` to each edge, while keeping
|
|
12
|
+
the magnitude structure symmetric. The result is a Hermitian operator
|
|
13
|
+
whose eigenvalues are real and whose eigenvectors are complex; the
|
|
14
|
+
phase of those eigenvectors carries the directional information.
|
|
15
|
+
|
|
16
|
+
Definition (combinatorial form):
|
|
17
|
+
|
|
18
|
+
A_s = (A + A^T) / 2 (symmetrized)
|
|
19
|
+
D_s = diag(Σ_j A_s[i, j]) (symmetric degree)
|
|
20
|
+
H = exp(i · 2π · q · (A - A^T)) (Hermitian phase factor)
|
|
21
|
+
L^(q) = D_s - H ⊙ A_s
|
|
22
|
+
|
|
23
|
+
For symmetric A this reduces to L = D - A regardless of q. For purely
|
|
24
|
+
directed A (entries in {0, 1}, A ≠ A^T) the phase encodes "flow
|
|
25
|
+
direction": q = 1/4 separates directed eigenmodes most clearly
|
|
26
|
+
(Furutani 2020, §5).
|
|
27
|
+
|
|
28
|
+
Four forms exposed:
|
|
29
|
+
combinatorial(A, q) : L^(q) = D_s − H ⊙ A_s
|
|
30
|
+
symmetric_normalized(A, q) : L_sym^(q) = I − D_s^{−1/2} (H ⊙ A_s) D_s^{−1/2}
|
|
31
|
+
sign_magnetic_combinatorial(A, q) : signed-directed analog,
|
|
32
|
+
unifies Kunegis signed + magnetic
|
|
33
|
+
sign_magnetic_symmetric_normalized(A, q) : normalized signed-directed
|
|
34
|
+
|
|
35
|
+
The signed forms implement MSGNN (He, Perlmutter, Reinert, Cucuringu
|
|
36
|
+
2022, equations 1 and 2). They use the signed symmetric adjacency
|
|
37
|
+
A_s = (A + A^T)/2, absolute degree D_abs[i,i] = sum_j
|
|
38
|
+
(|A[i,j]| + |A[j,i]|)/2, and phase exp(i 2π q (A - A^T)).
|
|
39
|
+
Both operators are positive semidefinite; the normalized spectrum
|
|
40
|
+
lies in [0, 2]. Nonnegative input gives the ordinary magnetic
|
|
41
|
+
Laplacian. Symmetric signed input gives the signed Laplacian for
|
|
42
|
+
any charge. At q = 0 the phase disappears, leaving D_abs - A_s.
|
|
43
|
+
For opposite-sign reciprocal edges, D_abs differs from sum_j |A_s|;
|
|
44
|
+
this distinction is part of MSGNN's definition.
|
|
45
|
+
|
|
46
|
+
All four functions return a complex Hermitian tensor of shape
|
|
47
|
+
(B, n, n). Use `torch.linalg.eigh` on the result : its real-eigenvalue
|
|
48
|
+
output is the spectrum and its complex eigenvectors are the
|
|
49
|
+
directed-graph modes.
|
|
50
|
+
|
|
51
|
+
References:
|
|
52
|
+
Lieb, E. H., Loss, M. (1993). Fluxes, Laplacians, and Kasteleyn's
|
|
53
|
+
theorem. Duke Mathematical Journal 71(2):337–363. The original
|
|
54
|
+
magnetic / flux-discrete Laplacian on graphs.
|
|
55
|
+
Fanuel, M., Alaiz, C. M., Suykens, J. A. K. (2017). Magnetic
|
|
56
|
+
eigenmaps for the visualization of directed networks. Applied and
|
|
57
|
+
Computational Harmonic Analysis 44(1):189–199. The modern spectral-
|
|
58
|
+
embedding application.
|
|
59
|
+
Furutani, S., Shibahara, T., Akiyama, M., Hato, K., Aida, M. (2020).
|
|
60
|
+
Graph Signal Processing for Directed Graphs based on the Hermitian
|
|
61
|
+
Laplacian. Journal of Machine Learning Research 21(122):1–37.
|
|
62
|
+
Gives the q = 1/4 default and the normalization conventions used
|
|
63
|
+
here. Section 5 covers the spectral interpretation.
|
|
64
|
+
Kunegis, J., Schmidt, S., Lommatzsch, A., Lerner, J., De Luca, E. W.,
|
|
65
|
+
Albayrak, S. (2010). Spectral analysis of signed graphs for
|
|
66
|
+
clustering, prediction and visualization. SDM 2010. The signed
|
|
67
|
+
Laplacian that the q = 0 signed-magnetic case reduces to.
|
|
68
|
+
He, Y., Perlmutter, M., Reinert, G., Cucuringu, M. (2022). MSGNN:
|
|
69
|
+
A Spectral Graph Neural Network Based on a Novel Magnetic Signed
|
|
70
|
+
Laplacian. LoG 2022, PMLR 198, §3.2, equations 1 and 2.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
from __future__ import annotations
|
|
74
|
+
|
|
75
|
+
import math
|
|
76
|
+
|
|
77
|
+
import torch
|
|
78
|
+
|
|
79
|
+
from holonomy_lib._graph_utils import drop_self_loops
|
|
80
|
+
|
|
81
|
+
# Default magnetic charge q ∈ [0, 1) per Furutani et al. (2020), §5.
|
|
82
|
+
# `q = 1/4` is the value that maximally separates the directed
|
|
83
|
+
# eigenmodes for a cycle and is therefore the standard literature
|
|
84
|
+
# default. **Scale of validity**: dimensionless ratio of magnetic flux
|
|
85
|
+
# units; does not depend on n. Cataloged as `MAGNETIC_CHARGE_DEFAULT`.
|
|
86
|
+
MAGNETIC_CHARGE_DEFAULT: float = 0.25
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def combinatorial(
|
|
90
|
+
A: torch.Tensor, q: float = MAGNETIC_CHARGE_DEFAULT,
|
|
91
|
+
) -> torch.Tensor:
|
|
92
|
+
"""Combinatorial magnetic Laplacian L^(q) = D_s − H ⊙ A_s.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
A: (B, n, n) weighted adjacency. May be non-symmetric. Entries
|
|
96
|
+
may be negative (signed-directed graphs); the symmetrized
|
|
97
|
+
adjacency `A_s = (A + A^T)/2` keeps the "edge presence",
|
|
98
|
+
and the antisymmetric part `A − A^T` enters via the phase.
|
|
99
|
+
q: magnetic charge in [0, 1). Default 1/4 per Furutani 2020 §5.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
L: (B, n, n) complex Hermitian Laplacian. Use `torch.linalg.eigh`
|
|
103
|
+
to get real eigenvalues + complex eigenvectors.
|
|
104
|
+
|
|
105
|
+
References:
|
|
106
|
+
Furutani et al. (2020), eq. 8.
|
|
107
|
+
Lieb-Loss (1993).
|
|
108
|
+
"""
|
|
109
|
+
_validate_real_adjacency(A, q)
|
|
110
|
+
A = drop_self_loops(A)
|
|
111
|
+
A_s = 0.5 * (A + A.mT) # symmetrized magnitude
|
|
112
|
+
D_s = A_s.sum(dim=-1) # (B, n) symmetric degree
|
|
113
|
+
|
|
114
|
+
H_times_As = _magnetic_phase_times_adj(A, A_s, q)
|
|
115
|
+
|
|
116
|
+
# D_s on the diagonal, embedded in the complex dtype.
|
|
117
|
+
D_diag = torch.diag_embed(D_s).to(H_times_As.dtype)
|
|
118
|
+
return D_diag - H_times_As
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def symmetric_normalized(
|
|
122
|
+
A: torch.Tensor, q: float = MAGNETIC_CHARGE_DEFAULT,
|
|
123
|
+
) -> torch.Tensor:
|
|
124
|
+
"""Symmetric-normalized magnetic Laplacian.
|
|
125
|
+
|
|
126
|
+
L_sym^(q) = I − D_s^{−1/2} (H ⊙ A_s) D_s^{−1/2}
|
|
127
|
+
|
|
128
|
+
For nonnegative A, spectrum ⊂ [0, 2] regardless of q (Furutani 2020, Prop. 1). Use
|
|
129
|
+
this form for spectral embeddings of directed graphs; the
|
|
130
|
+
eigenvectors at the bottom-k eigenvalues are the directed analog
|
|
131
|
+
of Laplacian eigenmaps.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
A: (B, n, n) nonnegative weighted adjacency. May be non-symmetric.
|
|
135
|
+
q: magnetic charge in [0, 1).
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
L_sym^(q): (B, n, n) complex Hermitian, spectrum in [0, 2].
|
|
139
|
+
|
|
140
|
+
References:
|
|
141
|
+
Furutani et al. (2020), Definition 4 and Prop. 1.
|
|
142
|
+
"""
|
|
143
|
+
_validate_real_adjacency(A, q)
|
|
144
|
+
A = drop_self_loops(A)
|
|
145
|
+
A_s = 0.5 * (A + A.mT)
|
|
146
|
+
D_s = A_s.sum(dim=-1) # (B, n)
|
|
147
|
+
|
|
148
|
+
# Moore-Penrose pseudoinverse handling for isolated nodes, the same
|
|
149
|
+
# convention as `holonomy_lib.spectral.laplacian.symmetric_normalized`.
|
|
150
|
+
# The clamp keeps rsqrt finite on the branch `where` throws away, so
|
|
151
|
+
# an isolated node never puts an inf into the autograd graph
|
|
152
|
+
floor = torch.finfo(A.dtype).tiny
|
|
153
|
+
safe_D = D_s.clamp(min=floor)
|
|
154
|
+
d_inv_sqrt = torch.where(
|
|
155
|
+
D_s > 0, torch.rsqrt(safe_D), torch.zeros_like(D_s),
|
|
156
|
+
) # (B, n)
|
|
157
|
+
|
|
158
|
+
H_times_As = _magnetic_phase_times_adj(A, A_s, q)
|
|
159
|
+
|
|
160
|
+
# D^{-1/2} (H ⊙ A_s) D^{-1/2} as an outer product of the two scalings.
|
|
161
|
+
# The real scale has to be cast to complex before it multiplies the
|
|
162
|
+
# complex phase matrix
|
|
163
|
+
scale = (
|
|
164
|
+
d_inv_sqrt.unsqueeze(dim=-1) * d_inv_sqrt.unsqueeze(dim=-2)
|
|
165
|
+
).to(H_times_As.dtype)
|
|
166
|
+
normalized = H_times_As * scale
|
|
167
|
+
|
|
168
|
+
n = A.shape[-1]
|
|
169
|
+
eye = torch.eye(n, device=A.device, dtype=H_times_As.dtype).expand_as(
|
|
170
|
+
normalized,
|
|
171
|
+
)
|
|
172
|
+
return eye - normalized
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ============================================================
|
|
176
|
+
# Sign-magnetic Laplacian (signed-directed graphs)
|
|
177
|
+
# ============================================================
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def sign_magnetic_combinatorial(
|
|
181
|
+
A: torch.Tensor, q: float = MAGNETIC_CHARGE_DEFAULT,
|
|
182
|
+
) -> torch.Tensor:
|
|
183
|
+
"""Sign-magnetic combinatorial Laplacian for signed-directed graphs.
|
|
184
|
+
|
|
185
|
+
L^(q,σ) = D_|.| − exp(i · 2π · q · (A − A^T)) ⊙ A_s^signed
|
|
186
|
+
|
|
187
|
+
where:
|
|
188
|
+
A_s^signed = (A + A^T) / 2 (signed symmetric part)
|
|
189
|
+
A_s^|.| = (|A| + |A|^T) / 2 (magnitude symmetric part)
|
|
190
|
+
D_|.| = diag(Σ_j A_s^|.|[i, j]) (signless degree, à la
|
|
191
|
+
Kunegis 2010)
|
|
192
|
+
|
|
193
|
+
Phase uses signed A, as in MSGNN equation 1. At q = 0 the phase
|
|
194
|
+
disappears. The absolute degree is computed before symmetrization,
|
|
195
|
+
so opposite-sign reciprocal edges still contribute to the degree.
|
|
196
|
+
|
|
197
|
+
Reduces to:
|
|
198
|
+
magnetic.combinatorial(A, q) if A ≥ 0 (signs irrelevant)
|
|
199
|
+
laplacian.signed(A) if A = A^T (Θ ≡ 0, signs visible)
|
|
200
|
+
laplacian.combinatorial(A) if A ≥ 0 ∧ A = A^T
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
A: (B, n, n) real-valued adjacency. May be non-symmetric and may
|
|
204
|
+
have negative entries.
|
|
205
|
+
q: magnetic charge in [0, 1). Default 1/4.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
L: (B, n, n) complex Hermitian positive-semidefinite Laplacian.
|
|
209
|
+
|
|
210
|
+
References:
|
|
211
|
+
He, Perlmutter, Reinert, Cucuringu (2022). MSGNN. LoG 2022,
|
|
212
|
+
PMLR 198, §3.2, equation 1 and Theorem 1.
|
|
213
|
+
Kunegis et al. (2010) for the q = 0 (signed-undirected) reduction.
|
|
214
|
+
"""
|
|
215
|
+
_validate_real_adjacency(A, q)
|
|
216
|
+
A = drop_self_loops(A)
|
|
217
|
+
abs_A = A.abs()
|
|
218
|
+
A_s_signed = 0.5 * (A + A.mT)
|
|
219
|
+
A_s_abs = 0.5 * (abs_A + abs_A.mT)
|
|
220
|
+
D_abs = A_s_abs.sum(dim=-1) # (B, n)
|
|
221
|
+
|
|
222
|
+
H_times_As = _magnetic_phase_times_adj(A, A_s_signed, q)
|
|
223
|
+
D_diag = torch.diag_embed(D_abs).to(H_times_As.dtype)
|
|
224
|
+
return D_diag - H_times_As
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def sign_magnetic_symmetric_normalized(
|
|
228
|
+
A: torch.Tensor, q: float = MAGNETIC_CHARGE_DEFAULT,
|
|
229
|
+
) -> torch.Tensor:
|
|
230
|
+
"""Sign-magnetic symmetric-normalized Laplacian.
|
|
231
|
+
|
|
232
|
+
L_sym^(q,σ) = I − D_|.|^{−1/2} (exp(iΘ) ⊙ A_s^signed) D_|.|^{−1/2}
|
|
233
|
+
|
|
234
|
+
where Θ_{ij} = 2π · q · (A_{ij} − A_{ji}) and
|
|
235
|
+
D_|.|[i,i] = sum_j (|A[i,j]| + |A[j,i]|)/2. Use this form for
|
|
236
|
+
spectral embeddings of signed-directed graphs; the eigenvectors at
|
|
237
|
+
the bottom-k eigenvalues are the signed-directed analog of
|
|
238
|
+
Laplacian eigenmaps.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
A: (B, n, n) real-valued adjacency, may be non-symmetric and signed.
|
|
242
|
+
q: magnetic charge in [0, 1).
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
L_sym^(q,σ): (B, n, n) complex Hermitian, spectrum in [0, 2].
|
|
246
|
+
Isolated nodes have diagonal 1 under the pseudoinverse convention.
|
|
247
|
+
|
|
248
|
+
References:
|
|
249
|
+
He, Perlmutter, Reinert, Cucuringu (2022). MSGNN. LoG 2022,
|
|
250
|
+
PMLR 198, §3.2, equation 2 and Theorems 1 and 2.
|
|
251
|
+
"""
|
|
252
|
+
_validate_real_adjacency(A, q)
|
|
253
|
+
A = drop_self_loops(A)
|
|
254
|
+
abs_A = A.abs()
|
|
255
|
+
A_s_signed = 0.5 * (A + A.mT)
|
|
256
|
+
A_s_abs = 0.5 * (abs_A + abs_A.mT)
|
|
257
|
+
D_abs = A_s_abs.sum(dim=-1) # (B, n)
|
|
258
|
+
|
|
259
|
+
# Same pseudoinverse handling as `laplacian.symmetric_normalized` and
|
|
260
|
+
# `magnetic.symmetric_normalized`: isolated nodes get d^{-1/2} = 0.
|
|
261
|
+
floor = torch.finfo(A.dtype).tiny
|
|
262
|
+
safe_D = D_abs.clamp(min=floor)
|
|
263
|
+
d_inv_sqrt = torch.where(
|
|
264
|
+
D_abs > 0, torch.rsqrt(safe_D), torch.zeros_like(D_abs),
|
|
265
|
+
) # (B, n)
|
|
266
|
+
|
|
267
|
+
H_times_As = _magnetic_phase_times_adj(A, A_s_signed, q)
|
|
268
|
+
|
|
269
|
+
scale = (
|
|
270
|
+
d_inv_sqrt.unsqueeze(dim=-1) * d_inv_sqrt.unsqueeze(dim=-2)
|
|
271
|
+
).to(H_times_As.dtype)
|
|
272
|
+
normalized = H_times_As * scale
|
|
273
|
+
|
|
274
|
+
n = A.shape[-1]
|
|
275
|
+
eye = torch.eye(n, device=A.device, dtype=H_times_As.dtype).expand_as(
|
|
276
|
+
normalized,
|
|
277
|
+
)
|
|
278
|
+
return eye - normalized
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ============================================================
|
|
282
|
+
# Internal helpers
|
|
283
|
+
# ============================================================
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _validate_real_adjacency(A: torch.Tensor, q: float) -> None:
|
|
287
|
+
"""Shared input validation for the magnetic Laplacian primitives.
|
|
288
|
+
|
|
289
|
+
Takes the adjacency and charge, returns nothing, raises `ValueError`
|
|
290
|
+
if `A` is not square in its last two dims, is not a real floating
|
|
291
|
+
dtype (complex and integer tensors both fail), or `q` is outside
|
|
292
|
+
[0, 1). Complex `A` is rejected outright: the phase factor
|
|
293
|
+
`H = exp(i · 2π · q · (A − A^T))` is only defined for real
|
|
294
|
+
antisymmetric arguments. Passing a complex `A` would let
|
|
295
|
+
`torch.cos`/`torch.sin` produce complex outputs and
|
|
296
|
+
`torch.complex(cos, sin)` would raise an opaque "expected real
|
|
297
|
+
tensors" error deep inside the helper.
|
|
298
|
+
"""
|
|
299
|
+
if A.ndim < 2 or A.shape[-1] != A.shape[-2]:
|
|
300
|
+
raise ValueError(
|
|
301
|
+
f"A must be (..., n, n); got A.shape={tuple(A.shape)}"
|
|
302
|
+
)
|
|
303
|
+
if not A.is_floating_point():
|
|
304
|
+
raise ValueError(
|
|
305
|
+
f"A must be a real floating-point tensor; got dtype={A.dtype}. "
|
|
306
|
+
f"The magnetic Laplacian's phase factor is defined for real "
|
|
307
|
+
f"adjacencies only."
|
|
308
|
+
)
|
|
309
|
+
if not (0.0 <= q < 1.0):
|
|
310
|
+
raise ValueError(f"magnetic charge q must be in [0, 1), got {q}")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _magnetic_phase_times_adj(
|
|
314
|
+
A: torch.Tensor, A_s: torch.Tensor, q: float,
|
|
315
|
+
) -> torch.Tensor:
|
|
316
|
+
"""Return H ⊙ A_s where H_{ij} = exp(i · 2π · q · (A_{ij} - A_{ji})).
|
|
317
|
+
|
|
318
|
+
Takes the real adjacency `A` (for the phase) and the already
|
|
319
|
+
symmetrized `A_s` (signed or unsigned, the caller picks), both
|
|
320
|
+
`(..., n, n)`, and returns one complex tensor of the same shape. For
|
|
321
|
+
symmetric A the phase is identically 1 and the result reduces to
|
|
322
|
+
A_s + 0j, but we still pay for building the phase. For q = 0 the
|
|
323
|
+
phase is also identically 1, and that case skips the trig entirely.
|
|
324
|
+
"""
|
|
325
|
+
if q == 0.0:
|
|
326
|
+
# no phase at all, so the answer is just A_s cast to complex
|
|
327
|
+
complex_dtype = _matching_complex_dtype(A.dtype)
|
|
328
|
+
return A_s.to(complex_dtype)
|
|
329
|
+
|
|
330
|
+
# the antisymmetric part A - A^T carries the direction of each edge.
|
|
331
|
+
# For a symmetric input it is all zeros, the phase is 1 everywhere,
|
|
332
|
+
# and q has no effect, which is the reduction the module promises
|
|
333
|
+
asym = A - A.mT # (B, n, n)
|
|
334
|
+
angle = (2.0 * math.pi * q) * asym # (B, n, n) real
|
|
335
|
+
phase = torch.complex(torch.cos(angle), torch.sin(angle))
|
|
336
|
+
complex_dtype = phase.dtype
|
|
337
|
+
return phase * A_s.to(complex_dtype)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _matching_complex_dtype(real_dtype: torch.dtype) -> torch.dtype:
|
|
341
|
+
"""Pick a complex dtype matching the precision of a real dtype.
|
|
342
|
+
|
|
343
|
+
float64 maps to complex128 and float32 to complex64. Anything else
|
|
344
|
+
(half, bfloat16) goes up to complex64, since half-precision complex
|
|
345
|
+
support in torch is thin.
|
|
346
|
+
"""
|
|
347
|
+
if real_dtype == torch.float64:
|
|
348
|
+
return torch.complex128
|
|
349
|
+
if real_dtype == torch.float32:
|
|
350
|
+
return torch.complex64
|
|
351
|
+
return torch.complex64
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Spectral dimension from a graph / operator Laplacian spectrum.
|
|
5
|
+
|
|
6
|
+
The spectral dimension `d_s` is the exponent governing the small-time decay of
|
|
7
|
+
the heat-kernel return probability:
|
|
8
|
+
|
|
9
|
+
p(t) = (1/n) Σ_i exp(-t·λ_i) ~ t^(-d_s/2) as t → 0+,
|
|
10
|
+
|
|
11
|
+
equivalently `d_s = -2 · d log p / d log t`. It need not be an integer (fractals,
|
|
12
|
+
multifractal media) and on a finite graph it is read off as the slope of
|
|
13
|
+
`log p` against `log t` over a scaling window: outside that window finite-size
|
|
14
|
+
effects flatten it (`p → (#zero modes)/n` as `t → ∞`, biasing `d_s` toward 0).
|
|
15
|
+
|
|
16
|
+
This estimates `d_s` as the least-squares slope of `log p(t)` against `log t`
|
|
17
|
+
over a caller-supplied set of times `t`; choose them inside the power-law
|
|
18
|
+
window. The input is the Laplacian spectrum (e.g. from
|
|
19
|
+
`holonomy_lib.spectral.laplacian` + an eigensolver), so the same routine serves
|
|
20
|
+
graphs, fractal approximants, and discretized continua.
|
|
21
|
+
|
|
22
|
+
References:
|
|
23
|
+
Rammal, R., Toulouse, G. (1983). Random walks on fractal structures and
|
|
24
|
+
percolation clusters. J. Physique Lett. 44(1):L13-L22 (spectral dimension).
|
|
25
|
+
Durhuus, B., Jonsson, T., Wheater, J. F. (2007). The spectral dimension of
|
|
26
|
+
generic trees. J. Stat. Phys. 128:1237-1260 (return-probability form).
|
|
27
|
+
Hambly, B. M., Kigami, J., Kumagai, T. (2002). Multifractal formalisms for
|
|
28
|
+
the local spectral and walk dimensions. Math. Proc. Camb. Phil. Soc.
|
|
29
|
+
132:555-571 (local / pointwise version).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import math
|
|
35
|
+
|
|
36
|
+
import torch
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def spectral_dimension(
|
|
40
|
+
eigenvalues: torch.Tensor, t: torch.Tensor,
|
|
41
|
+
) -> torch.Tensor:
|
|
42
|
+
"""Spectral dimension `d_s = -2 · slope of log p(t) vs log t`, where
|
|
43
|
+
`p(t) = mean_i exp(-t·λ_i)` is the heat-kernel return probability.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
eigenvalues: `(B, n)` Laplacian eigenvalues, `>= 0` (float-noise negatives
|
|
47
|
+
are clamped to 0). A `(n,)` input is treated as `B = 1`.
|
|
48
|
+
t: `(T,)` strictly positive sample times, `T >= 2`. Choose them inside the
|
|
49
|
+
power-law window: small enough that many modes contribute, large enough
|
|
50
|
+
that the spectral gap has not yet flattened `p`.
|
|
51
|
+
Returns:
|
|
52
|
+
`(B,)` estimated spectral dimension (a `(n,)` input returns shape `()`).
|
|
53
|
+
|
|
54
|
+
Notes:
|
|
55
|
+
`d_s` is a fitted asymptotic exponent, not exact on a finite spectrum: as
|
|
56
|
+
`t → ∞`, `p → (#zero modes)/n`, so an over-large `t` biases `d_s` toward 0.
|
|
57
|
+
See the module docstring for the window caveat.
|
|
58
|
+
|
|
59
|
+
References:
|
|
60
|
+
Rammal-Toulouse (1983); Durhuus-Jonsson-Wheater (2007).
|
|
61
|
+
"""
|
|
62
|
+
if t.ndim != 1 or t.shape[0] < 2:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"t must be a 1-D tensor of at least 2 sample times, got shape "
|
|
65
|
+
f"{tuple(t.shape)}"
|
|
66
|
+
)
|
|
67
|
+
if eigenvalues.ndim < 1:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"eigenvalues must have a mode axis (ndim >= 1), got shape "
|
|
70
|
+
f"{tuple(eigenvalues.shape)}"
|
|
71
|
+
)
|
|
72
|
+
if eigenvalues.shape[-1] == 0:
|
|
73
|
+
raise ValueError("eigenvalues must contain at least one mode")
|
|
74
|
+
if not eigenvalues.is_floating_point() or not t.is_floating_point():
|
|
75
|
+
raise ValueError("eigenvalues and t must have real floating-point dtypes")
|
|
76
|
+
if not torch.isfinite(t).all() or (t <= 0).any():
|
|
77
|
+
raise ValueError("t must be finite and strictly positive")
|
|
78
|
+
if not torch.isfinite(eigenvalues).all():
|
|
79
|
+
raise ValueError("eigenvalues must be finite")
|
|
80
|
+
|
|
81
|
+
# a lone (n,) spectrum gets a batch axis of 1 here and loses it at the
|
|
82
|
+
# end. The clamp removes tiny negative eigenvalues from the eigensolver,
|
|
83
|
+
# which would otherwise make exp(-t·λ) grow with t
|
|
84
|
+
squeeze = eigenvalues.ndim == 1
|
|
85
|
+
lam = (eigenvalues.unsqueeze(0) if squeeze else eigenvalues).clamp(min=0.0)
|
|
86
|
+
|
|
87
|
+
# least-squares slope of log p against log t, per batch element. The
|
|
88
|
+
# slope is Σ (x - x̄)(y - ȳ) / Σ (x - x̄)², with x = log t shared by every
|
|
89
|
+
# batch element, so x is centered and its variance computed once
|
|
90
|
+
log_t = torch.log(t) # (T,)
|
|
91
|
+
lt_centered = log_t - log_t.mean() # (T,)
|
|
92
|
+
variance = lt_centered.square().sum()
|
|
93
|
+
# all t equal (repeated values) means there is no slope to fit
|
|
94
|
+
if variance == 0:
|
|
95
|
+
raise ValueError("t must have nonzero log-time variance")
|
|
96
|
+
# Keep the sum in log space, since valid late times can underflow
|
|
97
|
+
# every exp(-t * lambda) even though the fitted slope is finite.
|
|
98
|
+
log_p = torch.logsumexp(-lam.unsqueeze(-2) * t.unsqueeze(-1), dim=-1)
|
|
99
|
+
log_p = log_p - math.log(lam.shape[-1]) # mean, not sum
|
|
100
|
+
lp_centered = log_p - log_p.mean(dim=-1, keepdim=True) # (B, T)
|
|
101
|
+
slope = (lp_centered * lt_centered).sum(dim=-1) / variance
|
|
102
|
+
d_s = -2.0 * slope
|
|
103
|
+
|
|
104
|
+
return d_s.squeeze(0) if squeeze else d_s
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""holonomy_lib.tensor_calculus: multilinear algebra primitives.
|
|
5
|
+
|
|
6
|
+
Currently implemented:
|
|
7
|
+
mode_product(T, A, axis): n-mode product T ×_axis A.
|
|
8
|
+
mode_unfolding(T, axis): matricize T by moving one axis to position 1,
|
|
9
|
+
after the batch axis.
|
|
10
|
+
hosvd(T, ranks, mode): truncated Higher-Order SVD.
|
|
11
|
+
|
|
12
|
+
Planned:
|
|
13
|
+
CP / Tucker decompositions with ALS, Tensor-Train, manifold-valued
|
|
14
|
+
tensor fields, Einstein notation helpers.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from holonomy_lib.tensor_calculus.decomposition import (
|
|
18
|
+
hosvd,
|
|
19
|
+
mode_product,
|
|
20
|
+
mode_unfolding,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = ["hosvd", "mode_product", "mode_unfolding"]
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# SPDX-License-Identifier: FSL-1.1-ALv2
|
|
2
|
+
# Copyright 2026 Synoros
|
|
3
|
+
|
|
4
|
+
"""Tensor decompositions: HOSVD and the n-mode product machinery.
|
|
5
|
+
|
|
6
|
+
This module assumes the leading dimension of every tensor is a batch
|
|
7
|
+
dimension. So a tensor of order d is stored as a tensor of shape
|
|
8
|
+
`(B, n_1, n_2, ..., n_d)` where B is the batch size. The "tensor modes"
|
|
9
|
+
are axes 1 through d (axis 0 is batch).
|
|
10
|
+
|
|
11
|
+
Operations implemented:
|
|
12
|
+
|
|
13
|
+
mode_product(T, A, axis)
|
|
14
|
+
n-mode product T ×_axis A. Contracts `T`'s `axis`-th dimension with
|
|
15
|
+
the last dimension of `A` (Kolda-Bader 2009 convention).
|
|
16
|
+
|
|
17
|
+
mode_unfolding(T, axis)
|
|
18
|
+
Matricize T by moving `axis` to position 1 and flattening the rest.
|
|
19
|
+
Output shape `(B, n_axis, prod_of_other_modes)`.
|
|
20
|
+
|
|
21
|
+
hosvd(T, ranks, mode)
|
|
22
|
+
Truncated Higher-Order SVD (De Lathauwer-De Moor-Vandewalle 2000).
|
|
23
|
+
Returns (core, factors). Each factor is the top-r_k left singular
|
|
24
|
+
vectors of T's mode-k unfolding.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import math
|
|
30
|
+
from collections.abc import Sequence
|
|
31
|
+
from typing import Literal
|
|
32
|
+
|
|
33
|
+
import torch
|
|
34
|
+
|
|
35
|
+
from holonomy_lib.algebra.linear import truncated_svd
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def mode_product(
|
|
39
|
+
T: torch.Tensor, A: torch.Tensor, axis: int
|
|
40
|
+
) -> torch.Tensor:
|
|
41
|
+
"""n-mode product T ×_axis A.
|
|
42
|
+
|
|
43
|
+
Contracts axis `axis` of T (size n_axis) with the **last** axis of A
|
|
44
|
+
(also size n_axis), producing a tensor where axis `axis` has size
|
|
45
|
+
A.shape[-2] instead. Kolda-Bader (2009) §2.5 convention.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
T: (B, n_1, ..., n_d).
|
|
49
|
+
A: (B, j, n_axis); last axis must match T's `axis`-th axis.
|
|
50
|
+
axis: which axis of T to contract, in [1, T.ndim − 1] (axis 0 is batch).
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Tensor of shape (B, ..., j, ...), where size j appears at position `axis`.
|
|
54
|
+
|
|
55
|
+
References:
|
|
56
|
+
Kolda, T. G., Bader, B. W. (2009). Tensor decompositions and
|
|
57
|
+
applications. SIAM Review 51(3):455–500, §2.5.
|
|
58
|
+
"""
|
|
59
|
+
if axis <= 0 or axis >= T.ndim:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"axis must be in [1, {T.ndim - 1}] (axis 0 is batch), got {axis}"
|
|
62
|
+
)
|
|
63
|
+
if A.ndim != 3:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"A must be 3-D (B, j, n_axis); got A.shape={tuple(A.shape)}"
|
|
66
|
+
)
|
|
67
|
+
if A.shape[0] != T.shape[0]:
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"batch sizes disagree: T has B={T.shape[0]}, A has B={A.shape[0]}"
|
|
70
|
+
)
|
|
71
|
+
if A.shape[-1] != T.shape[axis]:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"size mismatch: A.shape[-1]={A.shape[-1]} must equal "
|
|
74
|
+
f"T.shape[{axis}]={T.shape[axis]}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
B = T.shape[0]
|
|
78
|
+
n_axis = T.shape[axis]
|
|
79
|
+
j = A.shape[-2]
|
|
80
|
+
|
|
81
|
+
# Move the contracted axis of T to the last position, then reshape
|
|
82
|
+
# so that the contracted axis is the inner dim of a 2-D matmul.
|
|
83
|
+
T_perm = T.movedim(axis, -1) # (B, ..., n_axis)
|
|
84
|
+
# Collapse the untouched modes into one K-sized axis.
|
|
85
|
+
other_modes = math.prod(T_perm.shape[1:-1])
|
|
86
|
+
T_flat = T_perm.reshape(B, other_modes, n_axis)
|
|
87
|
+
# Multiply: (B, K, n_axis) @ (B, n_axis, j) → (B, K, j)
|
|
88
|
+
out_flat = torch.bmm(T_flat, A.mT) # uses A.mT = (B, n_axis, j)
|
|
89
|
+
# Undo the collapse. The untouched modes are back in their original order.
|
|
90
|
+
middle = list(T_perm.shape[1:-1])
|
|
91
|
+
out_perm = out_flat.reshape(B, *middle, j)
|
|
92
|
+
# Move the new (size-j) axis from last back to position `axis`:
|
|
93
|
+
return out_perm.movedim(-1, axis)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def mode_unfolding(T: torch.Tensor, axis: int) -> torch.Tensor:
|
|
97
|
+
"""Matricize T along `axis`: move `axis` to position 1, flatten the rest.
|
|
98
|
+
|
|
99
|
+
Other modes retain their original order and the last varies fastest
|
|
100
|
+
across columns (row-major ordering).
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
T: (B, n_1, ..., n_d).
|
|
104
|
+
axis: in [1, T.ndim − 1].
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
(B, n_axis, prod of other tensor modes).
|
|
108
|
+
|
|
109
|
+
References:
|
|
110
|
+
Kolda, T. G., Bader, B. W. (2009), §2.4; mode-n matricization.
|
|
111
|
+
"""
|
|
112
|
+
if axis <= 0 or axis >= T.ndim:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"axis must be in [1, {T.ndim - 1}] (axis 0 is batch), got {axis}"
|
|
115
|
+
)
|
|
116
|
+
B = T.shape[0]
|
|
117
|
+
n_axis = T.shape[axis]
|
|
118
|
+
T_perm = T.movedim(axis, 1) # (B, n_axis, ...)
|
|
119
|
+
return T_perm.reshape(B, n_axis, math.prod(T_perm.shape[2:]))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def hosvd(
|
|
123
|
+
T: torch.Tensor,
|
|
124
|
+
ranks: Sequence[int],
|
|
125
|
+
mode: Literal["exact", "randomized"] = "exact",
|
|
126
|
+
generator: torch.Generator | None = None,
|
|
127
|
+
) -> tuple[torch.Tensor, list[torch.Tensor]]:
|
|
128
|
+
"""Truncated Higher-Order SVD (HOSVD).
|
|
129
|
+
|
|
130
|
+
For a tensor T ∈ R^{B × n_1 × ... × n_d}, returns factors
|
|
131
|
+
{U_k}_{k=1..d} and a core G such that
|
|
132
|
+
|
|
133
|
+
T ≈ G ×_1 U_1 ×_2 U_2 ×_3 ... ×_d U_d
|
|
134
|
+
|
|
135
|
+
where U_k has shape (B, n_k, r_k) with orthonormal columns and G
|
|
136
|
+
has shape (B, r_1, ..., r_d). The factors are taken as the top-r_k
|
|
137
|
+
left singular vectors of T's mode-k unfolding; the core is then
|
|
138
|
+
|
|
139
|
+
G = T ×_1 U_1ᵀ ×_2 U_2ᵀ ×_3 ... ×_d U_dᵀ.
|
|
140
|
+
|
|
141
|
+
Truncated HOSVD is in general **not** the best rank-(r_1,...,r_d)
|
|
142
|
+
Tucker approximation (that requires HOOI / ALS), but it is a
|
|
143
|
+
quasi-optimal initializer and is itself a useful approximation in
|
|
144
|
+
practice; see Vannieuwenhoven et al. (2012) for analysis.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
T: (B, n_1, ..., n_d). Must have ndim ≥ 2 (one batch dim + ≥1 mode).
|
|
148
|
+
ranks: tuple of length d giving the truncation rank per tensor mode.
|
|
149
|
+
Each rank must be in [1, min(n_k, product of the other mode sizes)],
|
|
150
|
+
the available rank of that mode's reduced SVD.
|
|
151
|
+
mode: "exact" or "randomized"; passed through to `truncated_svd`.
|
|
152
|
+
generator: PyTorch RNG, forwarded to randomized SVDs.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
core: (B, r_1, ..., r_d).
|
|
156
|
+
factors: list of d tensors, factors[k-1] of shape (B, n_k, r_k).
|
|
157
|
+
|
|
158
|
+
References:
|
|
159
|
+
De Lathauwer, L., De Moor, B., Vandewalle, J. (2000). A multilinear
|
|
160
|
+
singular value decomposition. SIAM J. Matrix Anal. Appl.
|
|
161
|
+
21(4):1253–1278.
|
|
162
|
+
Vannieuwenhoven, N., Vandebril, R., Meerbergen, K. (2012). A new
|
|
163
|
+
truncation strategy for the higher-order singular value
|
|
164
|
+
decomposition. SIAM J. Sci. Comput. 34(2):A1027–A1052.
|
|
165
|
+
Kolda, T. G., Bader, B. W. (2009), §4.2; Tucker decomposition.
|
|
166
|
+
"""
|
|
167
|
+
if T.ndim < 2:
|
|
168
|
+
raise ValueError(
|
|
169
|
+
f"T must have ≥1 batch dim and ≥1 tensor mode (ndim ≥ 2); "
|
|
170
|
+
f"got T.shape={tuple(T.shape)}"
|
|
171
|
+
)
|
|
172
|
+
d = T.ndim - 1 # number of tensor modes
|
|
173
|
+
if len(ranks) != d:
|
|
174
|
+
raise ValueError(
|
|
175
|
+
f"len(ranks)={len(ranks)} must equal tensor order d={d}"
|
|
176
|
+
)
|
|
177
|
+
for k, r_k in enumerate(ranks):
|
|
178
|
+
n_k = T.shape[k + 1]
|
|
179
|
+
other_modes = math.prod(T.shape[1:k + 1] + T.shape[k + 2:])
|
|
180
|
+
# The mode-k unfolding of each member is (n_k, other_modes), so its
|
|
181
|
+
# rank cannot exceed the smaller of the two.
|
|
182
|
+
max_rank = min(n_k, other_modes)
|
|
183
|
+
if r_k <= 0 or r_k > max_rank:
|
|
184
|
+
raise ValueError(
|
|
185
|
+
f"ranks[{k}]={r_k} must be in [1, {max_rank}]; "
|
|
186
|
+
f"mode {k + 1} unfolding has shape ({n_k}, {other_modes})"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Step 1: factors. For each mode k, take top-r_k left singular vectors
|
|
190
|
+
# of the mode-k unfolding.
|
|
191
|
+
factors: list[torch.Tensor] = []
|
|
192
|
+
for k in range(1, d + 1):
|
|
193
|
+
T_k = mode_unfolding(T, axis=k) # (B, n_k, prod_others)
|
|
194
|
+
U_k, _, _ = truncated_svd(
|
|
195
|
+
T_k, r=ranks[k - 1], mode=mode, generator=generator,
|
|
196
|
+
)
|
|
197
|
+
factors.append(U_k)
|
|
198
|
+
|
|
199
|
+
# Step 2: core G = T ×_1 U_1ᵀ ×_2 U_2ᵀ × ... ×_d U_dᵀ.
|
|
200
|
+
# Our `mode_product(T, A, axis)` contracts T's axis with A's last dim.
|
|
201
|
+
# We want to project axis k from size n_k down to r_k. Pass A =
|
|
202
|
+
# factors[k-1].mT so A has shape (B, r_k, n_k) and the result has r_k at
|
|
203
|
+
# axis k.
|
|
204
|
+
core = T
|
|
205
|
+
for k in range(1, d + 1):
|
|
206
|
+
core = mode_product(core, factors[k - 1].mT, axis=k)
|
|
207
|
+
|
|
208
|
+
return core, factors
|