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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ nsa_flow