nsa-flow 0.1.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.
- nsa_flow/__init__.py +3 -0
- nsa_flow/core.py +1229 -0
- nsa_flow-0.1.0.dist-info/METADATA +145 -0
- nsa_flow-0.1.0.dist-info/RECORD +6 -0
- nsa_flow-0.1.0.dist-info/WHEEL +5 -0
- nsa_flow-0.1.0.dist-info/top_level.txt +1 -0
nsa_flow/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
from .core import nsa_flow, nsa_flow_retract_auto, inv_sqrt_sym_adaptive, invariant_orthogonality_defect, energy_fidelity, nsa_flow_autograd, get_torch_optimizer, defect_fast
|
|
2
|
+
__all__ = ["nsa_flow", "nsa_flow_retract_auto", "inv_sqrt_sym_adaptive", "invariant_orthogonality_defect", "energy_fidelity","nsa_flow_autograd","get_torch_optimizer","defect_fast"]
|
|
3
|
+
|
nsa_flow/core.py
ADDED
|
@@ -0,0 +1,1229 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
import torch_optimizer
|
|
3
|
+
import time
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import torch
|
|
7
|
+
import warnings
|
|
8
|
+
|
|
9
|
+
def get_torch_optimizer(opt_name: str, params, lr: float, **kwargs):
|
|
10
|
+
"""
|
|
11
|
+
Returns a PyTorch optimizer instance based on the provided name.
|
|
12
|
+
Automatically includes supported optimizers from both torch.optim
|
|
13
|
+
and (if installed) torch_optimizer.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
opt_name (str): Name of the optimizer (case-insensitive).
|
|
17
|
+
params: Model parameters to optimize.
|
|
18
|
+
lr (float): Learning rate.
|
|
19
|
+
**kwargs: Additional arguments to pass to the optimizer.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
optimizer (torch.optim.Optimizer): A PyTorch optimizer instance.
|
|
23
|
+
"""
|
|
24
|
+
name = opt_name.lower()
|
|
25
|
+
|
|
26
|
+
# --- Base PyTorch optimizers ---
|
|
27
|
+
optimizers = {
|
|
28
|
+
"adam": lambda p, lr: torch.optim.Adam(p, lr=lr, **kwargs),
|
|
29
|
+
"adamw": lambda p, lr: torch.optim.AdamW(p, lr=lr, **kwargs),
|
|
30
|
+
"sgd": lambda p, lr: torch.optim.SGD(p, lr=lr, momentum=0.9, **kwargs),
|
|
31
|
+
"sgd_nesterov": lambda p, lr: torch.optim.SGD(p, lr=lr, momentum=0.9, nesterov=True, **kwargs),
|
|
32
|
+
"rmsprop": lambda p, lr: torch.optim.RMSprop(p, lr=lr, **kwargs),
|
|
33
|
+
"adagrad": lambda p, lr: torch.optim.Adagrad(p, lr=lr, **kwargs),
|
|
34
|
+
"lbfgs": lambda p, lr: torch.optim.LBFGS(p, lr=lr, max_iter=10, **kwargs),
|
|
35
|
+
"adamax": lambda p, lr: torch.optim.Adamax(p, lr=lr, **kwargs),
|
|
36
|
+
"asgd": lambda p, lr: torch.optim.ASGD(p, lr=lr, **kwargs),
|
|
37
|
+
"nadam": lambda p, lr: torch.optim.NAdam(p, lr=lr, **kwargs),
|
|
38
|
+
"radam": lambda p, lr: torch.optim.RAdam(p, lr=lr, **kwargs),
|
|
39
|
+
"rprop": lambda p, lr: torch.optim.Rprop(p, lr=lr, **kwargs),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# --- Optionally extend with torch_optimizer if available ---
|
|
43
|
+
try:
|
|
44
|
+
import torch_optimizer as optimx
|
|
45
|
+
|
|
46
|
+
extra_opts = {
|
|
47
|
+
"lars": lambda p, lr: optimx.LARS(p, lr=lr, **kwargs),
|
|
48
|
+
"yogi": lambda p, lr: optimx.Yogi(p, lr=lr, **kwargs),
|
|
49
|
+
"adabound": lambda p, lr: optimx.AdaBound(p, lr=lr, **kwargs),
|
|
50
|
+
"qhadam": lambda p, lr: optimx.QHAdam(p, lr=lr, **kwargs),
|
|
51
|
+
"pid": lambda p, lr: optimx.PID(p, lr=lr, **kwargs),
|
|
52
|
+
"qhm": lambda p, lr: optimx.QHM(p, lr=lr, **kwargs),
|
|
53
|
+
"adamp": lambda p, lr: optimx.AdamP(p, lr=lr, **kwargs),
|
|
54
|
+
"sgdp": lambda p, lr: optimx.SGDP(p, lr=lr, **kwargs),
|
|
55
|
+
"padam": lambda p, lr: optimx.PAdam(p, lr=lr, **kwargs),
|
|
56
|
+
"lookahead": lambda p, lr: optimx.Lookahead(torch.optim.Adam(p, lr=lr, **kwargs)),
|
|
57
|
+
}
|
|
58
|
+
optimizers.update(extra_opts)
|
|
59
|
+
|
|
60
|
+
except ImportError:
|
|
61
|
+
warnings.warn("torch_optimizer not installed. Skipping extra optimizers.", UserWarning)
|
|
62
|
+
|
|
63
|
+
if name not in optimizers:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"Unsupported optimizer: '{opt_name}'. "
|
|
66
|
+
f"Supported options: {sorted(optimizers.keys())}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return optimizers[name](params, lr)
|
|
70
|
+
|
|
71
|
+
def invariant_orthogonality_defect(V):
|
|
72
|
+
norm2 = torch.sum(V ** 2)
|
|
73
|
+
if norm2 <= 1e-12:
|
|
74
|
+
return torch.tensor(0.0, device=V.device)
|
|
75
|
+
S = V.T @ V
|
|
76
|
+
diagS = torch.diag(S)
|
|
77
|
+
off_f2 = torch.sum(S * S) - torch.sum(diagS ** 2)
|
|
78
|
+
return off_f2 / (norm2 ** 2)
|
|
79
|
+
|
|
80
|
+
def defect_fast(V):
|
|
81
|
+
return invariant_orthogonality_defect(V)
|
|
82
|
+
|
|
83
|
+
def _inv_sqrt_eig(A: torch.Tensor, eps: float = 1e-8, verbose: bool = False) -> torch.Tensor:
|
|
84
|
+
"""
|
|
85
|
+
Compute (A + eps I)^{-1/2} using symmetric eigendecomposition.
|
|
86
|
+
A must be symmetric (k x k).
|
|
87
|
+
"""
|
|
88
|
+
# eigh returns eigenvalues in ascending order
|
|
89
|
+
w, V = torch.linalg.eigh(A)
|
|
90
|
+
# regularize eigenvalues
|
|
91
|
+
w_reg = w.clamp_min(eps)
|
|
92
|
+
inv_sqrt_w = (w_reg**-0.5)
|
|
93
|
+
if verbose:
|
|
94
|
+
small = (w_reg <= eps).sum().item()
|
|
95
|
+
print(f"_inv_sqrt_eig: min_eig={w_reg.min().item():.3e}, #clamped={small}")
|
|
96
|
+
return (V * inv_sqrt_w.unsqueeze(0)) @ V.transpose(-2, -1)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _inv_sqrt_newton_schulz(A: torch.Tensor, eps: float = 1e-8,
|
|
100
|
+
ns_iter: int = 10, verbose: bool = False) -> torch.Tensor:
|
|
101
|
+
"""
|
|
102
|
+
Newton-Schulz iteration for matrix inverse square root of (A + eps I).
|
|
103
|
+
This expects A to be positive definite and reasonably well-conditioned after eps.
|
|
104
|
+
Works best when ||I - A|| < 1 (so we scale).
|
|
105
|
+
"""
|
|
106
|
+
k = A.shape[-1]
|
|
107
|
+
I = torch.eye(k, device=A.device, dtype=A.dtype)
|
|
108
|
+
A_reg = A + eps * I
|
|
109
|
+
|
|
110
|
+
# compute norm for scaling
|
|
111
|
+
normA = torch.linalg.norm(A_reg)
|
|
112
|
+
if normA == 0:
|
|
113
|
+
return I # fallback
|
|
114
|
+
Y = A_reg / normA
|
|
115
|
+
Z = I.clone()
|
|
116
|
+
|
|
117
|
+
if verbose:
|
|
118
|
+
print(f"_inv_sqrt_newton_schulz: normA={normA.item():.3e}, ns_iter={ns_iter}")
|
|
119
|
+
|
|
120
|
+
for i in range(ns_iter):
|
|
121
|
+
T = 0.5 * (3.0 * I - Z @ Y)
|
|
122
|
+
Y = Y @ T
|
|
123
|
+
Z = T @ Z
|
|
124
|
+
# Z approximates A^{-1/2} * sqrt(normA)
|
|
125
|
+
return Z / torch.sqrt(normA)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _inv_sqrt_diag(A: torch.Tensor, eps: float = 1e-8, verbose: bool = False) -> torch.Tensor:
|
|
129
|
+
"""
|
|
130
|
+
Diagonal approximation: invert sqrt of diagonal entries only.
|
|
131
|
+
Useful when matrix is approximately diagonal or to cheaply approximate.
|
|
132
|
+
"""
|
|
133
|
+
diag = torch.diagonal(A, 0)
|
|
134
|
+
diag_reg = diag.clamp_min(eps)
|
|
135
|
+
inv_sqrt = (diag_reg**-0.5)
|
|
136
|
+
if verbose:
|
|
137
|
+
print(f"_inv_sqrt_diag: min_diag={diag_reg.min().item():.3e}")
|
|
138
|
+
return torch.diag(inv_sqrt)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def inv_sqrt_sym_adaptive(YtY: torch.Tensor,
|
|
142
|
+
epsilon: float = 1e-8,
|
|
143
|
+
method: str = "eig",
|
|
144
|
+
ns_iter: int = 4,
|
|
145
|
+
eig_thresh: int = 128,
|
|
146
|
+
verbose: bool = False) -> torch.Tensor:
|
|
147
|
+
"""
|
|
148
|
+
Adaptive inverse-square-root for symmetric positive-definite matrix YtY.
|
|
149
|
+
method: "eig" | "ns" | "diag" | "auto"
|
|
150
|
+
- "eig": eigendecomposition
|
|
151
|
+
- "ns": Newton-Schulz
|
|
152
|
+
- "diag": diagonal fallback
|
|
153
|
+
- "auto": choose based on dimension heuristics (k <= eig_thresh => eig, else ns)
|
|
154
|
+
"""
|
|
155
|
+
k = YtY.shape[-1]
|
|
156
|
+
if method == "auto":
|
|
157
|
+
if k <= eig_thresh:
|
|
158
|
+
method_use = "eig"
|
|
159
|
+
else:
|
|
160
|
+
method_use = "ns"
|
|
161
|
+
else:
|
|
162
|
+
method_use = method
|
|
163
|
+
|
|
164
|
+
if method_use == "eig":
|
|
165
|
+
return _inv_sqrt_eig(YtY, eps=epsilon, verbose=verbose)
|
|
166
|
+
elif method_use == "ns":
|
|
167
|
+
# try Newton-Schulz; if it fails (e.g. nan/inf), fallback to eig
|
|
168
|
+
try:
|
|
169
|
+
T = _inv_sqrt_newton_schulz(YtY, eps=epsilon, ns_iter=ns_iter, verbose=verbose)
|
|
170
|
+
if torch.isfinite(T).all():
|
|
171
|
+
return T
|
|
172
|
+
else:
|
|
173
|
+
if verbose:
|
|
174
|
+
print("Newton-Schulz produced non-finite entries; falling back to eig.")
|
|
175
|
+
return _inv_sqrt_eig(YtY, eps=epsilon, verbose=verbose)
|
|
176
|
+
except Exception as e:
|
|
177
|
+
if verbose:
|
|
178
|
+
print(f"Newton-Schulz error: {e}; falling back to eig.")
|
|
179
|
+
return _inv_sqrt_eig(YtY, eps=epsilon, verbose=verbose)
|
|
180
|
+
elif method_use == "diag":
|
|
181
|
+
return _inv_sqrt_diag(YtY, eps=epsilon, verbose=verbose)
|
|
182
|
+
else:
|
|
183
|
+
raise ValueError(f"Unknown inv sqrt method: {method}")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def nsa_flow_retract_auto(Y_cand: torch.Tensor,
|
|
187
|
+
w_retract: float = 1.0,
|
|
188
|
+
retraction_type: str = "soft_polar",
|
|
189
|
+
eps_rf: float = 1e-8,
|
|
190
|
+
inv_method: str = "auto",
|
|
191
|
+
ns_iter: int = 4,
|
|
192
|
+
eig_thresh: int = 128,
|
|
193
|
+
diag_thresh: int = 8192,
|
|
194
|
+
verbose: bool = False) -> torch.Tensor:
|
|
195
|
+
"""
|
|
196
|
+
Retraction onto Stiefel manifold (matches R nsa_flow_retract_auto behaviour).
|
|
197
|
+
Y_cand: (p, k) torch tensor
|
|
198
|
+
retraction_type: "polar" | "soft_polar" | "none"
|
|
199
|
+
w_retract: blend weight in [0,1] where result = (1-w)*Y + w * retraction_operator(Y)
|
|
200
|
+
inv_method: "eig" | "ns" | "diag" | "auto"
|
|
201
|
+
ns_iter, eig_thresh, diag_thresh: heuristics from R version
|
|
202
|
+
"""
|
|
203
|
+
if not torch.is_tensor(Y_cand):
|
|
204
|
+
raise TypeError("Y_cand must be a torch.Tensor")
|
|
205
|
+
if retraction_type is None or retraction_type == "none" or w_retract <= 0.0:
|
|
206
|
+
return Y_cand.clone()
|
|
207
|
+
|
|
208
|
+
p, k = Y_cand.shape
|
|
209
|
+
device = Y_cand.device
|
|
210
|
+
dtype = Y_cand.dtype
|
|
211
|
+
|
|
212
|
+
normY = torch.norm(Y_cand)
|
|
213
|
+
if normY < 1e-12:
|
|
214
|
+
# trivial zero input -> just return copy (no retraction meaningful)
|
|
215
|
+
return Y_cand.clone()
|
|
216
|
+
|
|
217
|
+
retraction_type = retraction_type.lower()
|
|
218
|
+
if retraction_type == "polar":
|
|
219
|
+
# economy SVD
|
|
220
|
+
U, S, Vh = torch.linalg.svd(Y_cand, full_matrices=False)
|
|
221
|
+
Q = U @ Vh
|
|
222
|
+
# preserve Frobenius norm optionally
|
|
223
|
+
cur_norm = torch.norm(Q)
|
|
224
|
+
if cur_norm > 1e-12:
|
|
225
|
+
Q = Q / cur_norm * normY
|
|
226
|
+
return Q.to(device=device, dtype=dtype).clone()
|
|
227
|
+
|
|
228
|
+
if retraction_type == "soft_polar":
|
|
229
|
+
# Decide whether to use additive SVD fallback (R's heuristic)
|
|
230
|
+
use_additive_svd_fallback = False
|
|
231
|
+
if (k > diag_thresh) and (p < k):
|
|
232
|
+
use_additive_svd_fallback = True
|
|
233
|
+
|
|
234
|
+
# (R code had an override that forced fallback; default behaviour should be heuristic-driven)
|
|
235
|
+
if not use_additive_svd_fallback:
|
|
236
|
+
# compute small k x k Gram matrix Y^T Y
|
|
237
|
+
# crossprod in R is t(Y) %*% Y
|
|
238
|
+
YtY = Y_cand.T @ Y_cand
|
|
239
|
+
# compute inverse sqrt of (YtY + eps I)
|
|
240
|
+
I_k = torch.eye(k, device=device, dtype=dtype)
|
|
241
|
+
YtY = YtY + eps_rf * I_k
|
|
242
|
+
|
|
243
|
+
T = inv_sqrt_sym_adaptive(YtY, epsilon=eps_rf, method=inv_method,
|
|
244
|
+
ns_iter=ns_iter, eig_thresh=eig_thresh, verbose=verbose)
|
|
245
|
+
|
|
246
|
+
# multiplicative soft-polar: T_w = (1-w) I + w * T
|
|
247
|
+
T_w = (1.0 - w_retract) * I_k + w_retract * T
|
|
248
|
+
Ytilde = Y_cand @ T_w
|
|
249
|
+
|
|
250
|
+
# preserve Frobenius norm
|
|
251
|
+
cur_norm = torch.norm(Ytilde)
|
|
252
|
+
if cur_norm > 1e-12:
|
|
253
|
+
Ytilde = Ytilde / cur_norm * normY
|
|
254
|
+
|
|
255
|
+
return Ytilde.clone()
|
|
256
|
+
else:
|
|
257
|
+
# fallback: economy SVD additive blend
|
|
258
|
+
U, S, Vh = torch.linalg.svd(Y_cand, full_matrices=False)
|
|
259
|
+
Q = U @ Vh
|
|
260
|
+
Ytilde = (1.0 - w_retract) * Y_cand + w_retract * Q
|
|
261
|
+
cur_norm = torch.norm(Ytilde)
|
|
262
|
+
if cur_norm > 1e-12:
|
|
263
|
+
Ytilde = Ytilde / cur_norm * normY
|
|
264
|
+
return Ytilde.clone()
|
|
265
|
+
|
|
266
|
+
raise ValueError(f"unsupported retraction_type: {retraction_type}")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def energy_fidelity(M, Xc, w):
|
|
271
|
+
"""Smooth fidelity energy used for autograd (no prox)."""
|
|
272
|
+
n = Xc.shape[0]
|
|
273
|
+
# negative sign to match original formulation (we minimize energy = -fid + prox)
|
|
274
|
+
return -0.5 * w * torch.sum((Xc @ M) ** 2) / n
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def nsa_flow_old(Y0, X0=None, w=0.5,
|
|
280
|
+
retraction="soft_polar",
|
|
281
|
+
max_iter=500, tol=1e-5, verbose=False, seed=42,
|
|
282
|
+
apply_nonneg=True, optimizer="fast",
|
|
283
|
+
initial_learning_rate="default",
|
|
284
|
+
record_every=1, window_size=5, c1_armijo=1e-6,
|
|
285
|
+
simplified=False,
|
|
286
|
+
device=None):
|
|
287
|
+
"""
|
|
288
|
+
NSA-Flow optimization (PyTorch version)
|
|
289
|
+
|
|
290
|
+
Parameters
|
|
291
|
+
----------
|
|
292
|
+
Y0 : torch.Tensor
|
|
293
|
+
Initial matrix (p x k).
|
|
294
|
+
X0 : torch.Tensor or None
|
|
295
|
+
Target matrix for fidelity.
|
|
296
|
+
w : float
|
|
297
|
+
Weight for orthogonality penalty (0..1).
|
|
298
|
+
retraction : str
|
|
299
|
+
Retraction method ('soft_polar', 'polar', 'none').
|
|
300
|
+
max_iter : int
|
|
301
|
+
Maximum iterations.
|
|
302
|
+
tol : float
|
|
303
|
+
Convergence tolerance.
|
|
304
|
+
verbose : bool
|
|
305
|
+
Print progress if True.
|
|
306
|
+
seed : int
|
|
307
|
+
Random seed.
|
|
308
|
+
apply_nonneg : bool
|
|
309
|
+
Enforce nonnegativity after each retraction if True.
|
|
310
|
+
optimizer : str
|
|
311
|
+
Only 'fast' is supported currently (simple gradient step).
|
|
312
|
+
initial_learning_rate : float or str
|
|
313
|
+
Learning rate or 'default' for auto-estimate.
|
|
314
|
+
record_every : int
|
|
315
|
+
Iteration interval for recording traces.
|
|
316
|
+
window_size : int
|
|
317
|
+
Energy stability window for convergence check.
|
|
318
|
+
simplified : bool
|
|
319
|
+
Use simplified orthogonality objective.
|
|
320
|
+
device : torch.device or str or None
|
|
321
|
+
Device to run on.
|
|
322
|
+
|
|
323
|
+
Returns
|
|
324
|
+
-------
|
|
325
|
+
dict with keys:
|
|
326
|
+
'Y', 'traces', 'final_iter', 'best_total_energy', 'best_Y_iteration'
|
|
327
|
+
"""
|
|
328
|
+
|
|
329
|
+
torch.manual_seed(seed)
|
|
330
|
+
if device is None:
|
|
331
|
+
device = Y0.device
|
|
332
|
+
|
|
333
|
+
Y = Y0.clone().detach().to(device)
|
|
334
|
+
p, k = Y.shape
|
|
335
|
+
|
|
336
|
+
if X0 is None:
|
|
337
|
+
if apply_nonneg:
|
|
338
|
+
X0 = torch.clamp(Y, min=0)
|
|
339
|
+
else:
|
|
340
|
+
X0 = Y.clone()
|
|
341
|
+
perturb_scale = torch.norm(Y) / (Y.numel() ** 0.5) * 0.05
|
|
342
|
+
Y = Y + perturb_scale * torch.randn_like(Y)
|
|
343
|
+
if verbose:
|
|
344
|
+
print("Added perturbation to Y0.")
|
|
345
|
+
else:
|
|
346
|
+
X0 = X0.to(device)
|
|
347
|
+
if apply_nonneg:
|
|
348
|
+
X0 = torch.clamp(X0, min=0)
|
|
349
|
+
assert X0.shape == Y.shape, "X0 must match Y0 shape."
|
|
350
|
+
|
|
351
|
+
def compute_ortho_terms(Y, c_orth=1.0):
|
|
352
|
+
norm2 = torch.sum(Y ** 2)
|
|
353
|
+
if norm2 <= 1e-12 or c_orth <= 0:
|
|
354
|
+
grad_orth = torch.zeros_like(Y)
|
|
355
|
+
return grad_orth, torch.tensor(0.0, device=Y.device), norm2
|
|
356
|
+
|
|
357
|
+
S = Y.T @ Y
|
|
358
|
+
diagS = torch.diag(S)
|
|
359
|
+
off_f2 = torch.sum(S * S) - torch.sum(diagS ** 2)
|
|
360
|
+
defect = off_f2 / (norm2 ** 2)
|
|
361
|
+
|
|
362
|
+
if simplified:
|
|
363
|
+
grad_orth = -2 * c_orth * (Y @ (S - torch.diag(torch.diag(S))))
|
|
364
|
+
else:
|
|
365
|
+
Y_S = Y @ S
|
|
366
|
+
Y_diag_scale = Y * diagS
|
|
367
|
+
term1 = (Y_S - Y_diag_scale) / (norm2 ** 2)
|
|
368
|
+
term2 = (defect / norm2) * Y
|
|
369
|
+
grad_orth = c_orth * (term1 - term2)
|
|
370
|
+
return grad_orth, defect, norm2
|
|
371
|
+
|
|
372
|
+
def symm(A):
|
|
373
|
+
return 0.5 * (A + A.T)
|
|
374
|
+
|
|
375
|
+
# --- Scaling constants ---
|
|
376
|
+
g0 = 0.5 * torch.sum((Y - X0) ** 2) / (p * k)
|
|
377
|
+
g0 = torch.clamp(g0, min=1e-8)
|
|
378
|
+
d0 = torch.clamp(defect_fast(Y), min=1e-8)
|
|
379
|
+
fid_eta = (1 - w) / (g0 * p * k)
|
|
380
|
+
c_orth = 4 * w / d0
|
|
381
|
+
|
|
382
|
+
# --- Learning rate ---
|
|
383
|
+
if initial_learning_rate == "default":
|
|
384
|
+
lr = 1e-3 if apply_nonneg else 1.0
|
|
385
|
+
else:
|
|
386
|
+
lr = float(initial_learning_rate)
|
|
387
|
+
|
|
388
|
+
traces = []
|
|
389
|
+
recent_energies = []
|
|
390
|
+
t0 = time.time()
|
|
391
|
+
|
|
392
|
+
best_Y = Y.clone()
|
|
393
|
+
best_total_energy = float("inf")
|
|
394
|
+
best_Y_iteration = 0
|
|
395
|
+
|
|
396
|
+
# --- Initial gradient for relative norm ---
|
|
397
|
+
grad_fid_init = -fid_eta * (Y - X0)
|
|
398
|
+
grad_orth_init, _, _ = compute_ortho_terms(Y, c_orth)
|
|
399
|
+
sym_term_orth_init = symm(Y.T @ grad_orth_init)
|
|
400
|
+
rgrad_orth_init = grad_orth_init - Y @ sym_term_orth_init
|
|
401
|
+
rgrad_init = grad_fid_init + rgrad_orth_init
|
|
402
|
+
init_grad_norm = torch.norm(rgrad_init) + 1e-8
|
|
403
|
+
|
|
404
|
+
# --- Energy ---
|
|
405
|
+
def nsa_energy(Vp):
|
|
406
|
+
Vp = nsa_flow_retract_auto(Vp, w, retraction)
|
|
407
|
+
if apply_nonneg:
|
|
408
|
+
Vp = torch.clamp(Vp, min=0)
|
|
409
|
+
e = 0.5 * fid_eta * torch.sum((Vp - X0) ** 2)
|
|
410
|
+
norm2_V = torch.sum(Vp ** 2)
|
|
411
|
+
if c_orth > 0 and norm2_V > 0:
|
|
412
|
+
defect = defect_fast(Vp)
|
|
413
|
+
e = e + 0.25 * c_orth * defect
|
|
414
|
+
return e
|
|
415
|
+
|
|
416
|
+
# --- optimizer setup ---
|
|
417
|
+
if optimizer == "fast":
|
|
418
|
+
opt = None # use manual gradient step
|
|
419
|
+
else:
|
|
420
|
+
Y.requires_grad_(True)
|
|
421
|
+
opt = get_torch_optimizer(optimizer, [Y], lr)
|
|
422
|
+
|
|
423
|
+
# --- Main loop ---
|
|
424
|
+
for it in range(1, max_iter + 1):
|
|
425
|
+
grad_fid = -fid_eta * (Y - X0)
|
|
426
|
+
grad_orth, defect_val, _ = compute_ortho_terms(Y, c_orth)
|
|
427
|
+
if c_orth > 0:
|
|
428
|
+
sym_term_orth = symm(Y.T @ grad_orth)
|
|
429
|
+
rgrad_orth = grad_orth - Y @ sym_term_orth
|
|
430
|
+
else:
|
|
431
|
+
rgrad_orth = grad_orth
|
|
432
|
+
rgrad = grad_fid + rgrad_orth
|
|
433
|
+
|
|
434
|
+
if torch.isnan(rgrad).any() or torch.isinf(rgrad).any():
|
|
435
|
+
if verbose:
|
|
436
|
+
print("NaN or Inf in gradient; stopping.")
|
|
437
|
+
break
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# Simple gradient step (we can add optimizer class later)
|
|
442
|
+
Y_new = Y - lr * rgrad
|
|
443
|
+
Y_new = nsa_flow_retract_auto(Y_new, w, retraction)
|
|
444
|
+
if apply_nonneg:
|
|
445
|
+
Y_new = torch.clamp(Y_new, min=0)
|
|
446
|
+
|
|
447
|
+
current_energy = nsa_energy(Y_new)
|
|
448
|
+
|
|
449
|
+
# --- Backtracking line search ---
|
|
450
|
+
alpha = 1.0
|
|
451
|
+
count = 0
|
|
452
|
+
max_bt = 20
|
|
453
|
+
while current_energy > best_total_energy and count < max_bt:
|
|
454
|
+
alpha *= 0.5
|
|
455
|
+
Y_try = best_Y + alpha * (Y_new - best_Y)
|
|
456
|
+
current_energy = nsa_energy(Y_try)
|
|
457
|
+
count += 1
|
|
458
|
+
if current_energy < best_total_energy:
|
|
459
|
+
Y_new = nsa_flow_retract_auto(Y_try, w, retraction)
|
|
460
|
+
break
|
|
461
|
+
|
|
462
|
+
if count == max_bt:
|
|
463
|
+
if verbose:
|
|
464
|
+
print("Backtracking failed; reverting to best Y.")
|
|
465
|
+
Y_new = best_Y.clone()
|
|
466
|
+
|
|
467
|
+
if count > 2:
|
|
468
|
+
lr *= 0.95
|
|
469
|
+
elif count == 0 and it % 5 == 0:
|
|
470
|
+
lr *= 1.01
|
|
471
|
+
|
|
472
|
+
Y = Y_new
|
|
473
|
+
|
|
474
|
+
fidelity = 0.5 * fid_eta * torch.sum((Y - X0) ** 2)
|
|
475
|
+
orthogonality = defect_fast(Y)
|
|
476
|
+
total_energy = fidelity + 0.25 * c_orth * orthogonality
|
|
477
|
+
dt = time.time() - t0
|
|
478
|
+
|
|
479
|
+
if total_energy < best_total_energy:
|
|
480
|
+
best_total_energy = total_energy
|
|
481
|
+
best_Y = Y.clone()
|
|
482
|
+
best_Y_iteration = it
|
|
483
|
+
|
|
484
|
+
recent_energies.append(total_energy.item())
|
|
485
|
+
if len(recent_energies) > window_size:
|
|
486
|
+
recent_energies.pop(0)
|
|
487
|
+
|
|
488
|
+
if it % record_every == 0:
|
|
489
|
+
traces.append({
|
|
490
|
+
"iter": it,
|
|
491
|
+
"time": dt,
|
|
492
|
+
"fidelity": fidelity.item(),
|
|
493
|
+
"orthogonality": orthogonality.item(),
|
|
494
|
+
"total_energy": total_energy.item()
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
if verbose:
|
|
498
|
+
print(f"[Iter {it:3d}] Total: {total_energy.item():.6e} | "
|
|
499
|
+
f"Fid: {fidelity.item():.6e} | Orth: {orthogonality.item():.6e}")
|
|
500
|
+
|
|
501
|
+
# --- Convergence ---
|
|
502
|
+
grad_norm = torch.norm(rgrad)
|
|
503
|
+
rel_grad_norm = grad_norm / init_grad_norm
|
|
504
|
+
if rel_grad_norm < tol:
|
|
505
|
+
if verbose:
|
|
506
|
+
print(f"Converged at iter {it} (grad norm < {tol:.2e})")
|
|
507
|
+
break
|
|
508
|
+
if len(recent_energies) == window_size:
|
|
509
|
+
e_max, e_min = max(recent_energies), min(recent_energies)
|
|
510
|
+
e_avg = sum(recent_energies) / len(recent_energies)
|
|
511
|
+
rel_var = (e_max - e_min) / (abs(e_avg) + 1e-12)
|
|
512
|
+
if rel_var < tol:
|
|
513
|
+
if verbose:
|
|
514
|
+
print(f"Converged at iter {it} (energy stable < {tol:.2e})")
|
|
515
|
+
break
|
|
516
|
+
|
|
517
|
+
return {
|
|
518
|
+
"Y": best_Y,
|
|
519
|
+
"traces": traces,
|
|
520
|
+
"final_iter": len(traces),
|
|
521
|
+
"best_total_energy": best_total_energy.item(),
|
|
522
|
+
"best_Y_iteration": best_Y_iteration,
|
|
523
|
+
"target": X0
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def nsa_flow_autograd(
|
|
528
|
+
Y0, X0=None, w=0.5,
|
|
529
|
+
retraction="soft_polar",
|
|
530
|
+
max_iter=500, tol=1e-5, verbose=False, seed=42,
|
|
531
|
+
apply_nonneg=True, optimizer="adam",
|
|
532
|
+
initial_learning_rate="default",
|
|
533
|
+
record_every=1, window_size=5, c1_armijo=1e-6,
|
|
534
|
+
simplified=False,
|
|
535
|
+
device=None
|
|
536
|
+
):
|
|
537
|
+
"""
|
|
538
|
+
NSA-Flow (autograd-based energy minimization).
|
|
539
|
+
|
|
540
|
+
Supports standard torch.optim optimizers (Adam, SGD, LBFGS)
|
|
541
|
+
and built-in Armijo line search for energy descent.
|
|
542
|
+
"""
|
|
543
|
+
|
|
544
|
+
torch.manual_seed(seed)
|
|
545
|
+
if device is None:
|
|
546
|
+
device = Y0.device
|
|
547
|
+
Y = Y0.clone().detach().to(device).requires_grad_(True)
|
|
548
|
+
p, k = Y.shape
|
|
549
|
+
|
|
550
|
+
# --- target ---
|
|
551
|
+
if X0 is None:
|
|
552
|
+
X0 = Y.clone().detach()
|
|
553
|
+
X0 = X0.to(device)
|
|
554
|
+
if apply_nonneg:
|
|
555
|
+
X0 = torch.clamp(X0, min=0.0)
|
|
556
|
+
|
|
557
|
+
# --- learning rate ---
|
|
558
|
+
if initial_learning_rate == "default":
|
|
559
|
+
lr = 5e-4 if apply_nonneg else 5e-4
|
|
560
|
+
else:
|
|
561
|
+
lr = float(initial_learning_rate)
|
|
562
|
+
|
|
563
|
+
# --- define energy ---
|
|
564
|
+
def nsa_energy(V, w=w, retraction=retraction):
|
|
565
|
+
Vp = nsa_flow_retract_auto(V, w, retraction)
|
|
566
|
+
if apply_nonneg:
|
|
567
|
+
Vp = torch.clamp(Vp, min=0.0)
|
|
568
|
+
fid = 0.5 * torch.sum((Vp - X0) ** 2)
|
|
569
|
+
orth_term = 0.25 * w * defect_fast(Vp) if w > 0 else 0.0
|
|
570
|
+
return ( fid + orth_term ) * 1.0
|
|
571
|
+
|
|
572
|
+
# --- optimizer setup ---
|
|
573
|
+
opt_name = str(optimizer).lower()
|
|
574
|
+
opt = get_torch_optimizer(opt_name, [Y], lr=lr)
|
|
575
|
+
|
|
576
|
+
# --- tracking ---
|
|
577
|
+
traces, recent_energies = [], []
|
|
578
|
+
best_Y, best_total_energy, best_Y_iteration = Y.clone(), float("inf"), 0
|
|
579
|
+
t0 = time.time()
|
|
580
|
+
|
|
581
|
+
for it in range(1, max_iter + 1):
|
|
582
|
+
if opt_name == "armijo":
|
|
583
|
+
# Manual Armijo line search
|
|
584
|
+
opt_lr = lr
|
|
585
|
+
Y.grad = None
|
|
586
|
+
E0 = nsa_energy(Y)
|
|
587
|
+
E0.backward()
|
|
588
|
+
grad = Y.grad.clone()
|
|
589
|
+
gnorm = grad.norm()
|
|
590
|
+
|
|
591
|
+
if gnorm < tol:
|
|
592
|
+
if verbose:
|
|
593
|
+
print(f"Converged: grad norm < {tol:.2e}")
|
|
594
|
+
break
|
|
595
|
+
|
|
596
|
+
descent_dir = -grad
|
|
597
|
+
# Normalize direction for stable step size
|
|
598
|
+
descent_dir = descent_dir / (descent_dir.norm() + 1e-12)
|
|
599
|
+
|
|
600
|
+
# --- Armijo condition ---
|
|
601
|
+
with torch.no_grad():
|
|
602
|
+
step = 1.0
|
|
603
|
+
while step > 1e-8:
|
|
604
|
+
Y_trial = Y + step * descent_dir
|
|
605
|
+
Y_trial = nsa_flow_retract_auto(Y_trial, w, retraction)
|
|
606
|
+
if apply_nonneg:
|
|
607
|
+
Y_trial = torch.clamp(Y_trial, min=0.0)
|
|
608
|
+
E_trial = nsa_energy(Y_trial)
|
|
609
|
+
if E_trial <= E0 + c1_armijo * step * torch.sum(grad * descent_dir):
|
|
610
|
+
Y.data.copy_(Y_trial)
|
|
611
|
+
break
|
|
612
|
+
step *= 0.5
|
|
613
|
+
else:
|
|
614
|
+
if verbose:
|
|
615
|
+
print(f"[Iter {it}] Armijo failed to find descent — stopping.")
|
|
616
|
+
break
|
|
617
|
+
|
|
618
|
+
else:
|
|
619
|
+
# --- standard autograd optimizer ---
|
|
620
|
+
def closure():
|
|
621
|
+
opt.zero_grad(set_to_none=True)
|
|
622
|
+
E = nsa_energy(Y)
|
|
623
|
+
E.backward()
|
|
624
|
+
return E
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
torch.nn.utils.clip_grad_norm_([Y], max_norm=10.0) # FIXME
|
|
628
|
+
if isinstance(opt, torch.optim.LBFGS):
|
|
629
|
+
E0 = opt.step(closure)
|
|
630
|
+
else:
|
|
631
|
+
E0 = closure()
|
|
632
|
+
opt.step()
|
|
633
|
+
|
|
634
|
+
with torch.no_grad():
|
|
635
|
+
Y.data = nsa_flow_retract_auto(Y.data, w, retraction)
|
|
636
|
+
if apply_nonneg:
|
|
637
|
+
Y.data = torch.clamp(Y.data, min=0.0)
|
|
638
|
+
|
|
639
|
+
# --- energy tracking ---
|
|
640
|
+
with torch.no_grad():
|
|
641
|
+
total_energy = nsa_energy(Y).item()
|
|
642
|
+
if total_energy < best_total_energy:
|
|
643
|
+
best_total_energy = total_energy
|
|
644
|
+
best_Y = Y.clone().detach()
|
|
645
|
+
best_Y_iteration = it
|
|
646
|
+
|
|
647
|
+
recent_energies.append(total_energy)
|
|
648
|
+
if len(recent_energies) > window_size:
|
|
649
|
+
recent_energies.pop(0)
|
|
650
|
+
|
|
651
|
+
dt = time.time() - t0
|
|
652
|
+
if it % record_every == 0:
|
|
653
|
+
traces.append({
|
|
654
|
+
"iter": it,
|
|
655
|
+
"time": dt,
|
|
656
|
+
"total_energy": total_energy,
|
|
657
|
+
})
|
|
658
|
+
|
|
659
|
+
if verbose and (it % 10 == 0 or it == max_iter):
|
|
660
|
+
print(f"[Iter {it:4d}] Energy={total_energy:.6e} | lr={lr:.2e}")
|
|
661
|
+
|
|
662
|
+
if len(recent_energies) == window_size:
|
|
663
|
+
e_max, e_min = max(recent_energies), min(recent_energies)
|
|
664
|
+
e_avg = sum(recent_energies) / len(recent_energies)
|
|
665
|
+
rel_var = (e_max - e_min) / (abs(e_avg) + 1e-12)
|
|
666
|
+
if rel_var < tol:
|
|
667
|
+
if verbose:
|
|
668
|
+
print(f"Converged (energy stable < {tol:.2e}) at iter {it}")
|
|
669
|
+
break
|
|
670
|
+
|
|
671
|
+
return {
|
|
672
|
+
"Y": best_Y.detach(),
|
|
673
|
+
"traces": traces,
|
|
674
|
+
"final_iter": len(traces),
|
|
675
|
+
"best_total_energy": best_total_energy,
|
|
676
|
+
"best_Y_iteration": best_Y_iteration,
|
|
677
|
+
"target": X0,
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def nsa_flow(Y0, X0=None, w=0.5,
|
|
686
|
+
retraction="soft_polar",
|
|
687
|
+
max_iter=500, tol=1e-5, verbose=False, seed=42,
|
|
688
|
+
apply_nonneg=True, optimizer="fast",
|
|
689
|
+
initial_learning_rate="default",
|
|
690
|
+
record_every=1, window_size=5, c1_armijo=1e-6,
|
|
691
|
+
simplified=False,
|
|
692
|
+
device=None):
|
|
693
|
+
"""
|
|
694
|
+
NSA-Flow optimization (PyTorch version)
|
|
695
|
+
|
|
696
|
+
Parameters
|
|
697
|
+
----------
|
|
698
|
+
Y0 : torch.Tensor
|
|
699
|
+
Initial matrix (p x k).
|
|
700
|
+
X0 : torch.Tensor or None
|
|
701
|
+
Target matrix for fidelity.
|
|
702
|
+
w : float
|
|
703
|
+
Weight for orthogonality penalty (0..1).
|
|
704
|
+
retraction : str
|
|
705
|
+
Retraction method ('soft_polar', 'polar', 'none').
|
|
706
|
+
max_iter : int
|
|
707
|
+
Maximum iterations.
|
|
708
|
+
tol : float
|
|
709
|
+
Convergence tolerance.
|
|
710
|
+
verbose : bool
|
|
711
|
+
Print progress if True.
|
|
712
|
+
seed : int
|
|
713
|
+
Random seed.
|
|
714
|
+
apply_nonneg : bool
|
|
715
|
+
Enforce nonnegativity after each retraction if True.
|
|
716
|
+
optimizer : str
|
|
717
|
+
Optimizer to use ('fast' for simple gradient step, or PyTorch optimizers like 'Adam').
|
|
718
|
+
initial_learning_rate : float or str
|
|
719
|
+
Learning rate or 'default' for auto-estimate.
|
|
720
|
+
record_every : int
|
|
721
|
+
Iteration interval for recording traces.
|
|
722
|
+
window_size : int
|
|
723
|
+
Energy stability window for convergence check.
|
|
724
|
+
simplified : bool
|
|
725
|
+
Use simplified orthogonality objective.
|
|
726
|
+
device : torch.device or str or None
|
|
727
|
+
Device to run on.
|
|
728
|
+
|
|
729
|
+
Returns
|
|
730
|
+
-------
|
|
731
|
+
dict with keys:
|
|
732
|
+
'Y', 'traces', 'final_iter', 'best_total_energy', 'best_Y_iteration'
|
|
733
|
+
"""
|
|
734
|
+
|
|
735
|
+
torch.manual_seed(seed)
|
|
736
|
+
if device is None:
|
|
737
|
+
device = Y0.device
|
|
738
|
+
|
|
739
|
+
Y = Y0.clone().detach().to(device)
|
|
740
|
+
p, k = Y.shape
|
|
741
|
+
|
|
742
|
+
if X0 is None:
|
|
743
|
+
if apply_nonneg:
|
|
744
|
+
X0 = torch.clamp(Y, min=0)
|
|
745
|
+
else:
|
|
746
|
+
X0 = Y.clone()
|
|
747
|
+
perturb_scale = torch.norm(Y) / (Y.numel() ** 0.5) * 0.05
|
|
748
|
+
Y = Y + perturb_scale * torch.randn_like(Y)
|
|
749
|
+
if verbose:
|
|
750
|
+
print("Added perturbation to Y0.")
|
|
751
|
+
else:
|
|
752
|
+
X0 = X0.to(device)
|
|
753
|
+
if apply_nonneg:
|
|
754
|
+
X0 = torch.clamp(X0, min=0)
|
|
755
|
+
assert X0.shape == Y.shape, "X0 must match Y0 shape."
|
|
756
|
+
|
|
757
|
+
def compute_ortho_terms(Y, c_orth=1.0):
|
|
758
|
+
norm2 = torch.sum(Y ** 2)
|
|
759
|
+
if norm2 <= 1e-12 or c_orth <= 0:
|
|
760
|
+
grad_orth = torch.zeros_like(Y)
|
|
761
|
+
return grad_orth, torch.tensor(0.0, device=Y.device), norm2
|
|
762
|
+
|
|
763
|
+
S = Y.T @ Y
|
|
764
|
+
diagS = torch.diag(S)
|
|
765
|
+
off_f2 = torch.sum(S * S) - torch.sum(diagS ** 2)
|
|
766
|
+
defect = off_f2 / (norm2 ** 2)
|
|
767
|
+
|
|
768
|
+
if simplified:
|
|
769
|
+
grad_orth = -2 * c_orth * (Y @ (S - torch.diag(torch.diag(S))))
|
|
770
|
+
else:
|
|
771
|
+
Y_S = Y @ S
|
|
772
|
+
Y_diag_scale = Y * diagS
|
|
773
|
+
term1 = (Y_S - Y_diag_scale) / (norm2 ** 2)
|
|
774
|
+
term2 = (defect / norm2) * Y
|
|
775
|
+
grad_orth = c_orth * (term1 - term2)
|
|
776
|
+
return grad_orth, defect, norm2
|
|
777
|
+
|
|
778
|
+
def symm(A):
|
|
779
|
+
return 0.5 * (A + A.T)
|
|
780
|
+
|
|
781
|
+
# --- Scaling constants ---
|
|
782
|
+
g0 = 0.5 * torch.sum((Y - X0) ** 2) / (p * k)
|
|
783
|
+
g0 = torch.clamp(g0, min=1e-8)
|
|
784
|
+
d0 = torch.clamp(defect_fast(Y), min=1e-8)
|
|
785
|
+
fid_eta = (1 - w) / (g0 * p * k)
|
|
786
|
+
c_orth = 4 * w / d0
|
|
787
|
+
|
|
788
|
+
# --- Learning rate ---
|
|
789
|
+
if initial_learning_rate == "default":
|
|
790
|
+
lr = 1e-3 if apply_nonneg else 1.0
|
|
791
|
+
else:
|
|
792
|
+
lr = float(initial_learning_rate)
|
|
793
|
+
|
|
794
|
+
traces = []
|
|
795
|
+
recent_energies = []
|
|
796
|
+
t0 = time.time()
|
|
797
|
+
|
|
798
|
+
best_Y = Y.clone()
|
|
799
|
+
best_total_energy = float("inf")
|
|
800
|
+
best_Y_iteration = 0
|
|
801
|
+
|
|
802
|
+
# --- Initial gradient for relative norm ---
|
|
803
|
+
grad_fid_init = -fid_eta * (Y - X0)
|
|
804
|
+
grad_orth_init, _, _ = compute_ortho_terms(Y, c_orth)
|
|
805
|
+
sym_term_orth_init = symm(Y.T @ grad_orth_init)
|
|
806
|
+
rgrad_orth_init = grad_orth_init - Y @ sym_term_orth_init
|
|
807
|
+
rgrad_init = grad_fid_init + rgrad_orth_init
|
|
808
|
+
init_grad_norm = torch.norm(rgrad_init) + 1e-8
|
|
809
|
+
|
|
810
|
+
# --- Energy ---
|
|
811
|
+
def nsa_energy(Vp):
|
|
812
|
+
Vp = nsa_flow_retract_auto(Vp, w, retraction)
|
|
813
|
+
if apply_nonneg:
|
|
814
|
+
Vp = torch.clamp(Vp, min=0)
|
|
815
|
+
e = 0.5 * fid_eta * torch.sum((Vp - X0) ** 2)
|
|
816
|
+
norm2_V = torch.sum(Vp ** 2)
|
|
817
|
+
if c_orth > 0 and norm2_V > 0:
|
|
818
|
+
defect = defect_fast(Vp)
|
|
819
|
+
e = e + 0.25 * c_orth * defect
|
|
820
|
+
return e
|
|
821
|
+
|
|
822
|
+
# --- optimizer setup ---
|
|
823
|
+
if optimizer == "fast":
|
|
824
|
+
opt = None # use manual gradient step
|
|
825
|
+
else:
|
|
826
|
+
Y.requires_grad_(True)
|
|
827
|
+
opt = get_torch_optimizer(optimizer, [Y], lr)
|
|
828
|
+
|
|
829
|
+
# --- Main loop ---
|
|
830
|
+
for it in range(1, max_iter + 1):
|
|
831
|
+
grad_fid = -fid_eta * (Y - X0)
|
|
832
|
+
grad_orth, defect_val, _ = compute_ortho_terms(Y, c_orth)
|
|
833
|
+
if c_orth > 0:
|
|
834
|
+
sym_term_orth = symm(Y.T @ grad_orth)
|
|
835
|
+
rgrad_orth = grad_orth - Y @ sym_term_orth
|
|
836
|
+
else:
|
|
837
|
+
rgrad_orth = grad_orth
|
|
838
|
+
rgrad = grad_fid + rgrad_orth
|
|
839
|
+
|
|
840
|
+
if torch.isnan(rgrad).any() or torch.isinf(rgrad).any():
|
|
841
|
+
if verbose:
|
|
842
|
+
print("NaN or Inf in gradient; stopping.")
|
|
843
|
+
break
|
|
844
|
+
|
|
845
|
+
# Compute proposed update before retraction
|
|
846
|
+
if optimizer == "fast":
|
|
847
|
+
Y_proposed = Y + lr * rgrad # Corrected sign assuming rgrad is descent direction
|
|
848
|
+
else:
|
|
849
|
+
Y_old = Y.clone()
|
|
850
|
+
opt.zero_grad()
|
|
851
|
+
Y.grad = -rgrad # Set to ascent gradient for optimizer
|
|
852
|
+
opt.step()
|
|
853
|
+
Y_proposed = Y.clone()
|
|
854
|
+
Y.data = Y_old.data # Reset Y to old value (optimizer state remains updated)
|
|
855
|
+
|
|
856
|
+
Y_new = nsa_flow_retract_auto(Y_proposed, w, retraction)
|
|
857
|
+
|
|
858
|
+
current_energy = nsa_energy(Y_new)
|
|
859
|
+
|
|
860
|
+
# --- Backtracking line search ---
|
|
861
|
+
alpha = 1.0
|
|
862
|
+
count = 0
|
|
863
|
+
max_bt = 20
|
|
864
|
+
while current_energy > best_total_energy and count < max_bt:
|
|
865
|
+
alpha *= 0.5
|
|
866
|
+
Y_try = best_Y + alpha * (Y_new - best_Y)
|
|
867
|
+
current_energy = nsa_energy(Y_try)
|
|
868
|
+
count += 1
|
|
869
|
+
if current_energy < best_total_energy:
|
|
870
|
+
Y_new = nsa_flow_retract_auto(Y_try, w, retraction)
|
|
871
|
+
break
|
|
872
|
+
|
|
873
|
+
if count == max_bt:
|
|
874
|
+
if verbose:
|
|
875
|
+
print("Backtracking failed; reverting to best Y.")
|
|
876
|
+
Y_new = best_Y.clone()
|
|
877
|
+
|
|
878
|
+
# Apply nonnegativity after backtracking (to ensure consistency)
|
|
879
|
+
if apply_nonneg:
|
|
880
|
+
Y_new = torch.clamp(Y_new, min=0)
|
|
881
|
+
|
|
882
|
+
if count > 2:
|
|
883
|
+
if optimizer == "fast":
|
|
884
|
+
lr *= 0.95
|
|
885
|
+
else:
|
|
886
|
+
opt.param_groups[0]['lr'] *= 0.95
|
|
887
|
+
elif count == 0 and it % 5 == 0:
|
|
888
|
+
if optimizer == "fast":
|
|
889
|
+
lr *= 1.01
|
|
890
|
+
else:
|
|
891
|
+
opt.param_groups[0]['lr'] *= 1.01
|
|
892
|
+
|
|
893
|
+
Y = Y_new
|
|
894
|
+
|
|
895
|
+
fidelity = 0.5 * fid_eta * torch.sum((Y - X0) ** 2)
|
|
896
|
+
orthogonality = defect_fast(Y)
|
|
897
|
+
total_energy = fidelity + 0.25 * c_orth * orthogonality
|
|
898
|
+
dt = time.time() - t0
|
|
899
|
+
|
|
900
|
+
if total_energy < best_total_energy:
|
|
901
|
+
best_total_energy = total_energy
|
|
902
|
+
best_Y = Y.clone()
|
|
903
|
+
best_Y_iteration = it
|
|
904
|
+
|
|
905
|
+
recent_energies.append(total_energy.item())
|
|
906
|
+
if len(recent_energies) > window_size:
|
|
907
|
+
recent_energies.pop(0)
|
|
908
|
+
|
|
909
|
+
if it % record_every == 0:
|
|
910
|
+
traces.append({
|
|
911
|
+
"iter": it,
|
|
912
|
+
"time": dt,
|
|
913
|
+
"fidelity": fidelity.item(),
|
|
914
|
+
"orthogonality": orthogonality.item(),
|
|
915
|
+
"total_energy": total_energy.item()
|
|
916
|
+
})
|
|
917
|
+
|
|
918
|
+
if verbose:
|
|
919
|
+
print(f"[Iter {it:3d}] Total: {total_energy.item():.6e} | "
|
|
920
|
+
f"Fid: {fidelity.item():.6e} | Orth: {orthogonality.item():.6e}")
|
|
921
|
+
|
|
922
|
+
# --- Convergence ---
|
|
923
|
+
grad_norm = torch.norm(rgrad)
|
|
924
|
+
rel_grad_norm = grad_norm / init_grad_norm
|
|
925
|
+
if rel_grad_norm < tol:
|
|
926
|
+
if verbose:
|
|
927
|
+
print(f"Converged at iter {it} (grad norm < {tol:.2e})")
|
|
928
|
+
break
|
|
929
|
+
if len(recent_energies) == window_size:
|
|
930
|
+
e_max, e_min = max(recent_energies), min(recent_energies)
|
|
931
|
+
e_avg = sum(recent_energies) / len(recent_energies)
|
|
932
|
+
rel_var = (e_max - e_min) / (abs(e_avg) + 1e-12)
|
|
933
|
+
if rel_var < tol:
|
|
934
|
+
if verbose:
|
|
935
|
+
print(f"Converged at iter {it} (energy stable < {tol:.2e})")
|
|
936
|
+
break
|
|
937
|
+
|
|
938
|
+
return {
|
|
939
|
+
"Y": best_Y,
|
|
940
|
+
"traces": traces,
|
|
941
|
+
"final_iter": len(traces),
|
|
942
|
+
"best_total_energy": best_total_energy.item(),
|
|
943
|
+
"best_Y_iteration": best_Y_iteration,
|
|
944
|
+
"target": X0
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def nsa_flow(Y0, X0=None, w=0.5,
|
|
951
|
+
retraction="soft_polar",
|
|
952
|
+
max_iter=500, tol=1e-5, verbose=False, seed=42,
|
|
953
|
+
apply_nonneg=True, optimizer="fast",
|
|
954
|
+
initial_learning_rate="default",
|
|
955
|
+
record_every=1, window_size=5, c1_armijo=1e-6,
|
|
956
|
+
simplified=False,
|
|
957
|
+
project_full_gradient=False,
|
|
958
|
+
device=None):
|
|
959
|
+
"""
|
|
960
|
+
NSA-Flow optimization (PyTorch version)
|
|
961
|
+
|
|
962
|
+
Parameters
|
|
963
|
+
----------
|
|
964
|
+
Y0 : torch.Tensor
|
|
965
|
+
Initial matrix (p x k).
|
|
966
|
+
X0 : torch.Tensor or None
|
|
967
|
+
Target matrix for fidelity.
|
|
968
|
+
w : float
|
|
969
|
+
Weight for orthogonality penalty (0..1).
|
|
970
|
+
retraction : str
|
|
971
|
+
Retraction method ('soft_polar', 'polar', 'none').
|
|
972
|
+
max_iter : int
|
|
973
|
+
Maximum iterations.
|
|
974
|
+
tol : float
|
|
975
|
+
Convergence tolerance.
|
|
976
|
+
verbose : bool
|
|
977
|
+
Print progress if True.
|
|
978
|
+
seed : int
|
|
979
|
+
Random seed.
|
|
980
|
+
apply_nonneg : bool
|
|
981
|
+
Enforce nonnegativity after each retraction if True.
|
|
982
|
+
optimizer : str
|
|
983
|
+
Optimizer to use ('fast' for simple gradient step, or PyTorch optimizers like 'Adam').
|
|
984
|
+
initial_learning_rate : float or str
|
|
985
|
+
Learning rate or 'default' for auto-estimate.
|
|
986
|
+
record_every : int
|
|
987
|
+
Iteration interval for recording traces.
|
|
988
|
+
window_size : int
|
|
989
|
+
Energy stability window for convergence check.
|
|
990
|
+
simplified : bool
|
|
991
|
+
Use simplified orthogonality objective.
|
|
992
|
+
project_full_gradient : bool
|
|
993
|
+
If True, project the full gradient (fidelity + orthogonality) onto the tangent space.
|
|
994
|
+
If False (default), only project the orthogonality gradient, keeping fidelity as Euclidean.
|
|
995
|
+
device : torch.device or str or None
|
|
996
|
+
Device to run on.
|
|
997
|
+
|
|
998
|
+
Returns
|
|
999
|
+
-------
|
|
1000
|
+
dict with keys:
|
|
1001
|
+
'Y', 'traces', 'final_iter', 'best_total_energy', 'best_Y_iteration'
|
|
1002
|
+
"""
|
|
1003
|
+
|
|
1004
|
+
torch.manual_seed(seed)
|
|
1005
|
+
if device is None:
|
|
1006
|
+
device = Y0.device
|
|
1007
|
+
|
|
1008
|
+
Y = Y0.clone().detach().to(device)
|
|
1009
|
+
p, k = Y.shape
|
|
1010
|
+
|
|
1011
|
+
if X0 is None:
|
|
1012
|
+
if apply_nonneg:
|
|
1013
|
+
X0 = torch.clamp(Y, min=0)
|
|
1014
|
+
else:
|
|
1015
|
+
X0 = Y.clone()
|
|
1016
|
+
perturb_scale = torch.norm(Y) / (Y.numel() ** 0.5) * 0.05
|
|
1017
|
+
Y = Y + perturb_scale * torch.randn_like(Y)
|
|
1018
|
+
if verbose:
|
|
1019
|
+
print("Added perturbation to Y0.")
|
|
1020
|
+
else:
|
|
1021
|
+
X0 = X0.to(device)
|
|
1022
|
+
if apply_nonneg:
|
|
1023
|
+
X0 = torch.clamp(X0, min=0)
|
|
1024
|
+
assert X0.shape == Y.shape, "X0 must match Y0 shape."
|
|
1025
|
+
|
|
1026
|
+
def compute_ortho_terms(Y, c_orth=1.0):
|
|
1027
|
+
norm2 = torch.sum(Y ** 2)
|
|
1028
|
+
if norm2 <= 1e-12 or c_orth <= 0:
|
|
1029
|
+
grad_orth = torch.zeros_like(Y)
|
|
1030
|
+
return grad_orth, torch.tensor(0.0, device=Y.device), norm2
|
|
1031
|
+
|
|
1032
|
+
S = Y.T @ Y
|
|
1033
|
+
diagS = torch.diag(S)
|
|
1034
|
+
off_f2 = torch.sum(S * S) - torch.sum(diagS ** 2)
|
|
1035
|
+
defect = off_f2 / (norm2 ** 2)
|
|
1036
|
+
|
|
1037
|
+
if simplified:
|
|
1038
|
+
grad_orth = -2 * c_orth * (Y @ (S - torch.diag(diagS)))
|
|
1039
|
+
else:
|
|
1040
|
+
Y_S = Y @ S
|
|
1041
|
+
Y_diag_scale = Y * diagS
|
|
1042
|
+
term1 = (Y_S - Y_diag_scale) / (norm2 ** 2)
|
|
1043
|
+
term2 = (defect / norm2) * Y
|
|
1044
|
+
grad_orth = c_orth * (term1 - term2)
|
|
1045
|
+
return grad_orth, defect, norm2
|
|
1046
|
+
|
|
1047
|
+
def symm(A):
|
|
1048
|
+
return 0.5 * (A + A.T)
|
|
1049
|
+
|
|
1050
|
+
# --- Scaling constants ---
|
|
1051
|
+
g0 = 0.5 * torch.sum((Y - X0) ** 2) / (p * k)
|
|
1052
|
+
g0 = torch.clamp(g0, min=1e-8)
|
|
1053
|
+
d0 = torch.clamp(defect_fast(Y), min=1e-8)
|
|
1054
|
+
fid_eta = (1 - w) / (g0 * p * k)
|
|
1055
|
+
c_orth = 4 * w / d0
|
|
1056
|
+
|
|
1057
|
+
# --- Learning rate ---
|
|
1058
|
+
if initial_learning_rate == "default":
|
|
1059
|
+
lr = 1e-3 if apply_nonneg else 1.0
|
|
1060
|
+
else:
|
|
1061
|
+
lr = float(initial_learning_rate)
|
|
1062
|
+
|
|
1063
|
+
traces = []
|
|
1064
|
+
recent_energies = []
|
|
1065
|
+
t0 = time.time()
|
|
1066
|
+
|
|
1067
|
+
best_Y = Y.clone()
|
|
1068
|
+
best_total_energy = float("inf")
|
|
1069
|
+
best_Y_iteration = 0
|
|
1070
|
+
|
|
1071
|
+
# --- Initial gradient for relative norm ---
|
|
1072
|
+
grad_fid_init = -fid_eta * (Y - X0)
|
|
1073
|
+
grad_orth_init, _, _ = compute_ortho_terms(Y, c_orth)
|
|
1074
|
+
if project_full_gradient:
|
|
1075
|
+
full_grad_init = grad_fid_init + grad_orth_init
|
|
1076
|
+
sym_term_init = symm(Y.T @ full_grad_init)
|
|
1077
|
+
rgrad_init = full_grad_init - Y @ sym_term_init
|
|
1078
|
+
else:
|
|
1079
|
+
rgrad_fid_init = grad_fid_init
|
|
1080
|
+
if c_orth > 0:
|
|
1081
|
+
sym_term_orth_init = symm(Y.T @ grad_orth_init)
|
|
1082
|
+
rgrad_orth_init = grad_orth_init - Y @ sym_term_orth_init
|
|
1083
|
+
else:
|
|
1084
|
+
rgrad_orth_init = grad_orth_init
|
|
1085
|
+
rgrad_init = rgrad_fid_init + rgrad_orth_init
|
|
1086
|
+
init_grad_norm = torch.norm(rgrad_init) + 1e-8
|
|
1087
|
+
|
|
1088
|
+
# --- Energy ---
|
|
1089
|
+
def nsa_energy(Vp):
|
|
1090
|
+
Vp = nsa_flow_retract_auto(Vp, w, retraction)
|
|
1091
|
+
if apply_nonneg:
|
|
1092
|
+
Vp = torch.clamp(Vp, min=0)
|
|
1093
|
+
e = 0.5 * fid_eta * torch.sum((Vp - X0) ** 2)
|
|
1094
|
+
norm2_V = torch.sum(Vp ** 2)
|
|
1095
|
+
if c_orth > 0 and norm2_V > 0:
|
|
1096
|
+
defect = defect_fast(Vp)
|
|
1097
|
+
e = e + 0.25 * c_orth * defect
|
|
1098
|
+
return e
|
|
1099
|
+
|
|
1100
|
+
# --- optimizer setup ---
|
|
1101
|
+
if optimizer == "fast":
|
|
1102
|
+
opt = None # use manual gradient step
|
|
1103
|
+
else:
|
|
1104
|
+
Y.requires_grad_(True)
|
|
1105
|
+
opt = get_torch_optimizer(optimizer, [Y], lr)
|
|
1106
|
+
|
|
1107
|
+
# --- Main loop ---
|
|
1108
|
+
for it in range(1, max_iter + 1):
|
|
1109
|
+
grad_fid = -fid_eta * (Y - X0)
|
|
1110
|
+
grad_orth, defect_val, _ = compute_ortho_terms(Y, c_orth)
|
|
1111
|
+
if project_full_gradient:
|
|
1112
|
+
full_grad = grad_fid + grad_orth
|
|
1113
|
+
sym_term = symm(Y.T @ full_grad)
|
|
1114
|
+
rgrad = full_grad - Y @ sym_term
|
|
1115
|
+
else:
|
|
1116
|
+
rgrad_fid = grad_fid
|
|
1117
|
+
if c_orth > 0:
|
|
1118
|
+
sym_term_orth = symm(Y.T @ grad_orth)
|
|
1119
|
+
rgrad_orth = grad_orth - Y @ sym_term_orth
|
|
1120
|
+
else:
|
|
1121
|
+
rgrad_orth = grad_orth
|
|
1122
|
+
rgrad = rgrad_fid + rgrad_orth
|
|
1123
|
+
|
|
1124
|
+
if torch.isnan(rgrad).any() or torch.isinf(rgrad).any():
|
|
1125
|
+
if verbose:
|
|
1126
|
+
print("NaN or Inf in gradient; stopping.")
|
|
1127
|
+
break
|
|
1128
|
+
|
|
1129
|
+
# Compute proposed update before retraction
|
|
1130
|
+
if optimizer == "fast":
|
|
1131
|
+
Y_proposed = Y - lr * rgrad # Reverted to original sign convention
|
|
1132
|
+
else:
|
|
1133
|
+
Y_old = Y.clone().detach()
|
|
1134
|
+
opt.zero_grad()
|
|
1135
|
+
Y.grad = rgrad # Set to positive gradient for optimizer to subtract
|
|
1136
|
+
opt.step()
|
|
1137
|
+
Y_proposed = Y.clone().detach()
|
|
1138
|
+
Y.data = Y_old.data # Reset Y to old value (optimizer state remains updated)
|
|
1139
|
+
|
|
1140
|
+
Y_new = nsa_flow_retract_auto(Y_proposed, w, retraction)
|
|
1141
|
+
|
|
1142
|
+
current_energy = nsa_energy(Y_new)
|
|
1143
|
+
|
|
1144
|
+
# --- Backtracking line search ---
|
|
1145
|
+
alpha = 1.0
|
|
1146
|
+
count = 0
|
|
1147
|
+
max_bt = 20
|
|
1148
|
+
while current_energy > best_total_energy and count < max_bt:
|
|
1149
|
+
alpha *= 0.5
|
|
1150
|
+
Y_try = best_Y + alpha * (Y_new - best_Y)
|
|
1151
|
+
current_energy = nsa_energy(Y_try)
|
|
1152
|
+
count += 1
|
|
1153
|
+
if current_energy < best_total_energy:
|
|
1154
|
+
Y_new = nsa_flow_retract_auto(Y_try, w, retraction)
|
|
1155
|
+
break
|
|
1156
|
+
|
|
1157
|
+
if count == max_bt:
|
|
1158
|
+
if verbose:
|
|
1159
|
+
print("Backtracking failed; reverting to best Y.")
|
|
1160
|
+
Y_new = best_Y.clone()
|
|
1161
|
+
|
|
1162
|
+
# Apply nonnegativity after backtracking (to ensure consistency)
|
|
1163
|
+
if apply_nonneg:
|
|
1164
|
+
Y_new = torch.clamp(Y_new, min=0)
|
|
1165
|
+
|
|
1166
|
+
if count > 2:
|
|
1167
|
+
if optimizer == "fast":
|
|
1168
|
+
lr *= 0.95
|
|
1169
|
+
else:
|
|
1170
|
+
opt.param_groups[0]['lr'] *= 0.95
|
|
1171
|
+
elif count == 0 and it % 5 == 0:
|
|
1172
|
+
if optimizer == "fast":
|
|
1173
|
+
lr *= 1.01
|
|
1174
|
+
else:
|
|
1175
|
+
opt.param_groups[0]['lr'] *= 1.01
|
|
1176
|
+
|
|
1177
|
+
Y = Y_new
|
|
1178
|
+
|
|
1179
|
+
fidelity = 0.5 * fid_eta * torch.sum((Y - X0) ** 2)
|
|
1180
|
+
orthogonality = defect_fast(Y)
|
|
1181
|
+
total_energy = fidelity + 0.25 * c_orth * orthogonality
|
|
1182
|
+
dt = time.time() - t0
|
|
1183
|
+
|
|
1184
|
+
if total_energy < best_total_energy:
|
|
1185
|
+
best_total_energy = total_energy
|
|
1186
|
+
best_Y = Y.clone()
|
|
1187
|
+
best_Y_iteration = it
|
|
1188
|
+
|
|
1189
|
+
recent_energies.append(total_energy.item())
|
|
1190
|
+
if len(recent_energies) > window_size:
|
|
1191
|
+
recent_energies.pop(0)
|
|
1192
|
+
|
|
1193
|
+
if it % record_every == 0:
|
|
1194
|
+
traces.append({
|
|
1195
|
+
"iter": it,
|
|
1196
|
+
"time": dt,
|
|
1197
|
+
"fidelity": fidelity.item(),
|
|
1198
|
+
"orthogonality": orthogonality.item(),
|
|
1199
|
+
"total_energy": total_energy.item()
|
|
1200
|
+
})
|
|
1201
|
+
|
|
1202
|
+
if verbose:
|
|
1203
|
+
print(f"[Iter {it:3d}] Total: {total_energy.item():.6e} | "
|
|
1204
|
+
f"Fid: {fidelity.item():.6e} | Orth: {orthogonality.item():.6e}")
|
|
1205
|
+
|
|
1206
|
+
# --- Convergence ---
|
|
1207
|
+
grad_norm = torch.norm(rgrad)
|
|
1208
|
+
rel_grad_norm = grad_norm / init_grad_norm
|
|
1209
|
+
if rel_grad_norm < tol:
|
|
1210
|
+
if verbose:
|
|
1211
|
+
print(f"Converged at iter {it} (grad norm < {tol:.2e})")
|
|
1212
|
+
break
|
|
1213
|
+
if len(recent_energies) == window_size:
|
|
1214
|
+
e_max, e_min = max(recent_energies), min(recent_energies)
|
|
1215
|
+
e_avg = sum(recent_energies) / len(recent_energies)
|
|
1216
|
+
rel_var = (e_max - e_min) / (abs(e_avg) + 1e-12)
|
|
1217
|
+
if rel_var < tol:
|
|
1218
|
+
if verbose:
|
|
1219
|
+
print(f"Converged at iter {it} (energy stable < {tol:.2e})")
|
|
1220
|
+
break
|
|
1221
|
+
|
|
1222
|
+
return {
|
|
1223
|
+
"Y": best_Y,
|
|
1224
|
+
"traces": traces,
|
|
1225
|
+
"final_iter": len(traces),
|
|
1226
|
+
"best_total_energy": best_total_energy.item(),
|
|
1227
|
+
"best_Y_iteration": best_Y_iteration,
|
|
1228
|
+
"target": X0
|
|
1229
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nsa_flow
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Non-negative Stiefel Approximating Flow for interpretable representation learning
|
|
5
|
+
Author-email: "B. Avants" <stnava@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/stnava/nsa_flow
|
|
8
|
+
Project-URL: Repository, https://github.com/stnava/nsa_flow
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: torch>=2.0
|
|
12
|
+
Requires-Dist: numpy>=1.23
|
|
13
|
+
Requires-Dist: matplotlib>=3.7
|
|
14
|
+
|
|
15
|
+
# 🧠 NSA-Flow: Non-negative Stiefel Approximating Flow
|
|
16
|
+
|
|
17
|
+
**NSA-Flow** is a general-purpose optimization framework for interpretable representation learning.
|
|
18
|
+
It unifies sparse matrix factorization, orthogonalization, and manifold constraints into a single, differentiable algorithm that operates near the Stiefel manifold.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## ✨ Overview
|
|
23
|
+
|
|
24
|
+
Interpretable representation learning remains a core challenge in high-dimensional domains such as neuroimaging, genomics, and text analysis.
|
|
25
|
+
**NSA-Flow** provides a smooth geometric mechanism for balancing **reconstruction fidelity** and **column-wise decorrelation**, producing sparse, stable, and interpretable representations.
|
|
26
|
+
|
|
27
|
+
NSA-Flow enforces structured sparsity via a single tunable weight parameter, combining:
|
|
28
|
+
- Continuous orthogonality control via manifold retraction (e.g., soft-polar, polar)
|
|
29
|
+
- Non-negativity via proximal updates
|
|
30
|
+
- Adaptive gradient scaling and learning-rate control
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## 🧩 Key Features
|
|
35
|
+
|
|
36
|
+
- ⚙️ **Continuous flow near the Stiefel manifold**
|
|
37
|
+
- 🧮 **Non-negative and orthogonal constraints**
|
|
38
|
+
- 🧠 **Interpretable latent representations**
|
|
39
|
+
- 🚀 **Compatible with PyTorch optimization routines**
|
|
40
|
+
- 🧬 **Validated on neuroimaging and genomics datasets**
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 📦 Installation
|
|
45
|
+
|
|
46
|
+
Install from PyPI (once published):
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install nsa_flow
|
|
50
|
+
|
|
51
|
+
Or install the latest development version directly from GitHub:
|
|
52
|
+
|
|
53
|
+
pip install git+https://github.com/stnava/nsa_flow.git
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
⸻
|
|
57
|
+
|
|
58
|
+
🧰 Dependencies
|
|
59
|
+
• Python ≥ 3.9
|
|
60
|
+
• PyTorch ≥ 2.0
|
|
61
|
+
• NumPy ≥ 1.23
|
|
62
|
+
• Matplotlib (for optional visualization)
|
|
63
|
+
|
|
64
|
+
⸻
|
|
65
|
+
|
|
66
|
+
🚀 Quick Start
|
|
67
|
+
|
|
68
|
+
import torch
|
|
69
|
+
import nsa_flow
|
|
70
|
+
from nsa_flow import nsa_flow, invariant_orthogonality_defect
|
|
71
|
+
|
|
72
|
+
torch.manual_seed(42)
|
|
73
|
+
|
|
74
|
+
# Random initialization
|
|
75
|
+
Y = torch.randn(50, 10)
|
|
76
|
+
X0 = torch.randn_like(Y)
|
|
77
|
+
|
|
78
|
+
# Run NSA-Flow optimization
|
|
79
|
+
result = nsa_flow(
|
|
80
|
+
Y,
|
|
81
|
+
X0=X0,
|
|
82
|
+
w=0.8,
|
|
83
|
+
retraction="soft_polar",
|
|
84
|
+
optimizer="sgdp",
|
|
85
|
+
max_iter=50,
|
|
86
|
+
record_every=1,
|
|
87
|
+
tol=1e-8,
|
|
88
|
+
initial_learning_rate=1e-2,
|
|
89
|
+
verbose=True,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
print("Initial orthogonality defect:", invariant_orthogonality_defect(Y))
|
|
93
|
+
print("Final orthogonality defect:", invariant_orthogonality_defect(result["Y"]))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
⸻
|
|
97
|
+
|
|
98
|
+
📖 Documentation
|
|
99
|
+
|
|
100
|
+
NSA-Flow exposes a small set of high-level functions:
|
|
101
|
+
|
|
102
|
+
Function Description
|
|
103
|
+
nsa_flow() Main optimization loop balancing fidelity and orthogonality
|
|
104
|
+
nsa_flow_retract_auto() Retraction operator enforcing manifold constraints
|
|
105
|
+
invariant_orthogonality_defect() Computes orthogonality defect measure
|
|
106
|
+
defect_fast() Fast approximate defect metric
|
|
107
|
+
nsa_flow_autograd() Autograd-compatible variant for joint optimization
|
|
108
|
+
get_torch_optimizer() Returns a configured PyTorch optimizer
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
⸻
|
|
112
|
+
|
|
113
|
+
🧪 Validation
|
|
114
|
+
|
|
115
|
+
NSA-Flow has been validated in:
|
|
116
|
+
• Golub leukemia gene expression dataset
|
|
117
|
+
• Alzheimer’s Disease Neuroimaging Initiative (ADNI) dataset
|
|
118
|
+
|
|
119
|
+
NSA-Flow constraints maintain or improve performance while simplifying latent representations and improving interpretability.
|
|
120
|
+
|
|
121
|
+
⸻
|
|
122
|
+
|
|
123
|
+
🧑💻 Citation
|
|
124
|
+
|
|
125
|
+
If you use NSA-Flow in research, please cite:
|
|
126
|
+
|
|
127
|
+
Stnava et al. (2025). NSA-Flow: Non-negative Stiefel Approximating Flow for Interpretable Representation Learning.
|
|
128
|
+
|
|
129
|
+
⸻
|
|
130
|
+
|
|
131
|
+
⚖️ License
|
|
132
|
+
|
|
133
|
+
MIT License © 2025
|
|
134
|
+
|
|
135
|
+
⸻
|
|
136
|
+
|
|
137
|
+
📫 Contact
|
|
138
|
+
|
|
139
|
+
For issues, feature requests, or contributions, open an issue on
|
|
140
|
+
GitHub.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
nsa_flow/__init__.py,sha256=qnlPMOR8nkYywahWFDANtHB8TPMOa2bxpn74seWjjk4,358
|
|
2
|
+
nsa_flow/core.py,sha256=S2KGX-xHjZsu1dTod7ejOQn4pxZm8a4eQ9VagW2UyCE,41594
|
|
3
|
+
nsa_flow-0.1.0.dist-info/METADATA,sha256=V181erJj7BkTt95n_MEo01DPR2REVI76Gyy4ruhH25s,3784
|
|
4
|
+
nsa_flow-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
5
|
+
nsa_flow-0.1.0.dist-info/top_level.txt,sha256=T3fBKMgONwj0xE4usWBSieYiZZSH3CLRI4Ti9cvvVEY,9
|
|
6
|
+
nsa_flow-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nsa_flow
|