neuralign 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.
neuralign/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ from neuralign.model import NeuralignModel
2
+ from neuralign.configs import NeuralignConfig
3
+ from neuralign.neuralign_factors import TensorFactors, NeuralignFactors
4
+
5
+ __all__ = [
6
+ "NeuralignModel",
7
+ "NeuralignConfig",
8
+ "TensorFactors",
9
+ "NeuralignFactors",
10
+ ]
neuralign/adapt.py ADDED
@@ -0,0 +1,439 @@
1
+ """Fit new sessions into an existing Neuralign decomposition."""
2
+
3
+ import copy
4
+ import warnings
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+ import torch
9
+ import torch.nn.functional as F
10
+
11
+ import neuralign.ops as ops
12
+ from neuralign.callbacks import EarlyStopping, ProgressBar
13
+ from neuralign.configs import NeuralignConfig, from_dict, override_config, to_dict
14
+ from neuralign.initializers import initialize_trial_factors
15
+ from neuralign.losses import (
16
+ AdaptationConditionCentroidLoss,
17
+ AdaptationProcrustesLoss,
18
+ GlobalGramLoss,
19
+ NeuronSparsityLoss,
20
+ TensorReconstructionLoss,
21
+ )
22
+ from neuralign.neuralign_factors import NeuralignFactors, TensorFactors
23
+ from neuralign.trainer import Trainer
24
+
25
+ DEFAULT_CONDITION_CENTROID_WEIGHT = 1e-2
26
+
27
+ @dataclass
28
+ class _AdaptationResult:
29
+ factors: list[TensorFactors]
30
+ history: list[float]
31
+ loss_history: dict[str, list[float]]
32
+
33
+
34
+ class _AdaptationModel(torch.nn.Module):
35
+ """Neuralign model with fixed global factors and new local factors."""
36
+
37
+ def __init__(
38
+ self, source, tensors, config, session_label_info, trial_masks
39
+ ):
40
+ super().__init__()
41
+ self.cfg = config
42
+ self._tensor_names = []
43
+ for n, tensor in enumerate(tensors):
44
+ name = f"_tensor_{n}"
45
+ self.register_buffer(name, tensor, persistent=False)
46
+ self._tensor_names.append(name)
47
+ self.tensors = [getattr(self, name) for name in self._tensor_names]
48
+
49
+ self._trial_mask_names = []
50
+ self._alignment_index_names = []
51
+ for n, mask in enumerate(trial_masks):
52
+ mask_name = f"_trial_mask_{n}"
53
+ index_name = f"_alignment_indices_{n}"
54
+ self.register_buffer(mask_name, mask, persistent=False)
55
+ self.register_buffer(
56
+ index_name,
57
+ torch.nonzero(~mask, as_tuple=True)[0],
58
+ persistent=False,
59
+ )
60
+ self._trial_mask_names.append(mask_name)
61
+ self._alignment_index_names.append(index_name)
62
+ self.trial_masks = [
63
+ getattr(self, name) for name in self._trial_mask_names
64
+ ]
65
+ self.alignment_indices = [
66
+ getattr(self, name) for name in self._alignment_index_names
67
+ ]
68
+
69
+ for name in ("w", "V", "H"):
70
+ value = getattr(source, name).detach().clone().to(tensors[0].device)
71
+ self.register_buffer(name, value)
72
+ if source.C is not None:
73
+ self.register_buffer(
74
+ "C", source.C.detach().clone().to(tensors[0].device)
75
+ )
76
+
77
+ initial = [
78
+ _initialize_tensor(
79
+ tensor,
80
+ self.w,
81
+ self.V,
82
+ self.H,
83
+ alignment_indices=self.alignment_indices[n],
84
+ init=self.cfg.init,
85
+ )
86
+ for n, tensor in enumerate(tensors)
87
+ ]
88
+ self._A = torch.nn.ParameterList([
89
+ torch.nn.Parameter(_parameter_value(factors.A, self.cfg.non_negative))
90
+ for factors in initial
91
+ ])
92
+ self._U = torch.nn.ParameterList([
93
+ torch.nn.Parameter(_parameter_value(factors.U, self.cfg.non_negative))
94
+ for factors in initial
95
+ ])
96
+ self._S_n = torch.nn.ParameterList([
97
+ torch.nn.Parameter(_parameter_value(factors.S, self.cfg.non_negative))
98
+ for factors in initial
99
+ ])
100
+ self._Q_names = []
101
+ for n, factors in enumerate(initial):
102
+ name = f"_Q_{n}"
103
+ self.register_buffer(name, factors.Q.detach().clone())
104
+ self._Q_names.append(name)
105
+ self.Q_n = [getattr(self, name) for name in self._Q_names]
106
+ self.coordinate_loss = AdaptationProcrustesLoss(
107
+ self, session_label_info, k=self.cfg.procrustes_every_k
108
+ )
109
+
110
+ @property
111
+ def A(self):
112
+ return _FactorAccessor(
113
+ self._A, transform=_positive if self.cfg.non_negative else None
114
+ )
115
+
116
+ @property
117
+ def U(self):
118
+ return _FactorAccessor(
119
+ self._U, transform=_positive if self.cfg.non_negative else None
120
+ )
121
+
122
+ @property
123
+ def S_n(self):
124
+ return _FactorAccessor(
125
+ self._S_n, transform=_positive if self.cfg.non_negative else None
126
+ )
127
+
128
+ @torch.no_grad()
129
+ def finalize(self, trainer):
130
+ del trainer
131
+ self.coordinate_loss.update_projections(self)
132
+
133
+ def export_factors(self, history=None, loss_history=None):
134
+ factors = [
135
+ TensorFactors(
136
+ A=self.A[n].detach().clone(),
137
+ U=self.U[n].detach().clone(),
138
+ Q=self.Q_n[n].detach().clone(),
139
+ S=self.S_n[n].detach().clone(),
140
+ )
141
+ for n in range(len(self.tensors))
142
+ ]
143
+ return _AdaptationResult(
144
+ factors,
145
+ list(history or []),
146
+ {name: list(values) for name, values in (loss_history or {}).items()},
147
+ )
148
+
149
+
150
+ def partial_fit_factors(
151
+ source: NeuralignFactors,
152
+ tensors,
153
+ trial_labels=None,
154
+ trial_mask=None,
155
+ *,
156
+ device=None,
157
+ config=None,
158
+ **overrides,
159
+ ) -> NeuralignFactors:
160
+ """Return a copy of source with one or more fitted sessions appended."""
161
+ cfg = _adaptation_config(source, config, overrides)
162
+ source_device = source.w.device
163
+ device = source_device if device is None else torch.device(device)
164
+
165
+ tensors = [
166
+ torch.as_tensor(value, dtype=source.w.dtype, device=device).detach()
167
+ for value in _as_sequence(tensors)
168
+ ]
169
+ if not tensors:
170
+ raise ValueError("Expected one or more (neurons, time, trials) tensors.")
171
+ if any(tensor.ndim != 3 for tensor in tensors):
172
+ raise ValueError("Tensors must have shape (neurons, time, trials).")
173
+ if any(tensor.shape[1] != source.V.shape[0] for tensor in tensors):
174
+ raise ValueError("New tensors must use the learned time axis.")
175
+
176
+ labels = _session_sequence(trial_labels, len(tensors), "label")
177
+ masks = _trial_masks(tensors, trial_mask)
178
+ vocabulary = _condition_vocabulary(source)
179
+ session_label_info, ignored = _label_info(
180
+ tensors, labels, masks, vocabulary
181
+ )
182
+ if cfg.lambda_condition_centroid is None:
183
+ cfg.lambda_condition_centroid = (
184
+ DEFAULT_CONDITION_CENTROID_WEIGHT
185
+ if any(session_label_info)
186
+ else 0.0
187
+ )
188
+ if ignored:
189
+ warnings.warn(
190
+ f"Ignoring labels without frozen condition prototypes: {ignored!r}",
191
+ UserWarning,
192
+ stacklevel=2,
193
+ )
194
+
195
+ if cfg.seed is not None:
196
+ torch.manual_seed(cfg.seed)
197
+ model = _AdaptationModel(
198
+ source, tensors, cfg, session_label_info, masks
199
+ )
200
+ callbacks = [EarlyStopping(cfg.patience, cfg.tol)]
201
+ if cfg.progress:
202
+ callbacks.append(ProgressBar())
203
+ trainer = Trainer(
204
+ model,
205
+ _adaptation_losses(model, cfg),
206
+ lr=cfg.lr,
207
+ max_steps=cfg.max_iter,
208
+ callbacks=callbacks,
209
+ )
210
+ fitted = trainer.fit()
211
+
212
+ result = copy.deepcopy(source)
213
+ new_factors = [factors.to(source_device) for factors in fitted.factors]
214
+ result.tensor_factors = list(result.tensor_factors)
215
+ result.tensor_factors.extend(new_factors)
216
+ if result.condition_labels is None and source.C is not None:
217
+ result.condition_labels = vocabulary
218
+ result.metadata = result.metadata or {}
219
+ result.metadata.setdefault("adaptations", []).append({
220
+ "session_indices": list(range(len(source), len(result))),
221
+ "loss": fitted.history,
222
+ "losses": fitted.loss_history,
223
+ "ignored_labels": ignored,
224
+ "config": to_dict(cfg),
225
+ })
226
+ return result
227
+
228
+
229
+ def _initialize_tensor(
230
+ tensor, w, V, H, alignment_indices, init="svd"
231
+ ):
232
+ S = torch.ones_like(w)
233
+ if init == "svd":
234
+ template = H @ torch.diag(w) @ V.T
235
+ tensor_slice = tensor.index_select(2, alignment_indices).mean(dim=2)
236
+ Q = ops.solve_procrustes(tensor_slice, template)
237
+ elif init == "random":
238
+ random = torch.randn(
239
+ tensor.shape[0], len(w), dtype=tensor.dtype, device=tensor.device
240
+ )
241
+ Q = torch.linalg.qr(random, mode="reduced").Q
242
+ else:
243
+ raise ValueError(f"Invalid initialization method: {init}")
244
+ A = ops.reconstruct_A(Q, H, S)
245
+ if init == "random":
246
+ U = torch.randn(
247
+ tensor.shape[2], len(w), dtype=tensor.dtype, device=tensor.device
248
+ )
249
+ else:
250
+ U = initialize_trial_factors(tensor, w, A, V)
251
+ return TensorFactors(A=A, U=U, Q=Q, S=S)
252
+
253
+
254
+ class _FactorAccessor:
255
+ def __init__(self, raw, transform=None):
256
+ self.raw = raw
257
+ self.transform = transform
258
+
259
+ def __getitem__(self, idx):
260
+ value = self.raw[idx]
261
+ return self.transform(value) if self.transform is not None else value
262
+
263
+ def __len__(self):
264
+ return len(self.raw)
265
+
266
+ def __iter__(self):
267
+ for i in range(len(self)):
268
+ yield self[i]
269
+
270
+
271
+ def _positive(x):
272
+ return F.softplus(x) + 1e-12
273
+
274
+
275
+ def _inverse_positive(value):
276
+ target = value.abs().clamp_min(torch.finfo(value.dtype).eps)
277
+ shifted = (target - 1e-12).clamp_min(torch.finfo(value.dtype).tiny)
278
+ return shifted + torch.log(-torch.expm1(-shifted))
279
+
280
+
281
+ def _parameter_value(value, non_negative):
282
+ value = value.detach().clone()
283
+ return _inverse_positive(value) if non_negative else value
284
+
285
+
286
+ def _adaptation_config(source, config, overrides):
287
+ base_config = source.config if config is None else config
288
+ if base_config is None:
289
+ base_config = NeuralignConfig(rank=len(source.w))
290
+ if isinstance(base_config, dict):
291
+ cfg = from_dict(base_config)
292
+ elif isinstance(base_config, NeuralignConfig):
293
+ cfg = copy.deepcopy(base_config)
294
+ else:
295
+ raise TypeError("config must be a NeuralignConfig, a dictionary, or None.")
296
+ cfg = override_config(cfg, overrides)
297
+ cfg.rank = len(source.w)
298
+ cfg.gauge_fix = False
299
+ return cfg
300
+
301
+
302
+ def _adaptation_losses(model, cfg):
303
+ losses = {}
304
+ if cfg.lambda_tensor_reconstruction > 0:
305
+ floor = torch.finfo(model.tensors[0].dtype).tiny
306
+ norms = [
307
+ (torch.linalg.norm(tensor) ** 2).clamp_min(floor)
308
+ for tensor in model.tensors
309
+ ]
310
+ losses["Tensor"] = (
311
+ TensorReconstructionLoss(norms, cfg.loss_type),
312
+ cfg.lambda_tensor_reconstruction,
313
+ )
314
+ if cfg.lambda_global_gram > 0:
315
+ losses["Geometry"] = (GlobalGramLoss(), cfg.lambda_global_gram)
316
+ if cfg.lambda_procrustes > 0:
317
+ losses["Coordinates"] = (
318
+ model.coordinate_loss, cfg.lambda_procrustes
319
+ )
320
+ if cfg.lambda_condition_centroid > 0:
321
+ condition_loss = AdaptationConditionCentroidLoss(
322
+ model.coordinate_loss.session_label_info
323
+ )
324
+ if not hasattr(model, "C") or not condition_loss.entries:
325
+ raise ValueError(
326
+ "lambda_condition_centroid requires frozen source condition "
327
+ "prototypes and at least one recognized, unmasked trial label."
328
+ )
329
+ losses["Conditions"] = (
330
+ condition_loss, cfg.lambda_condition_centroid
331
+ )
332
+ if cfg.lambda_neuron_sparsity > 0:
333
+ losses["Sparsity"] = (
334
+ NeuronSparsityLoss(), cfg.lambda_neuron_sparsity
335
+ )
336
+ return losses
337
+
338
+
339
+ def _as_sequence(value):
340
+ if isinstance(value, (np.ndarray, torch.Tensor)):
341
+ return [value]
342
+ try:
343
+ array = np.asarray(value)
344
+ except ValueError:
345
+ return list(value)
346
+ if array.ndim == 3:
347
+ return [value]
348
+ return list(value)
349
+
350
+
351
+ def _session_sequence(values, n_tensors, name):
352
+ if values is None:
353
+ return [None] * n_tensors
354
+ if n_tensors == 1:
355
+ if len(values) == 1 and isinstance(
356
+ values[0], (list, tuple, np.ndarray, torch.Tensor)
357
+ ):
358
+ return [values[0]]
359
+ return [values]
360
+ if len(values) != n_tensors:
361
+ raise ValueError(f"Provide one {name} sequence for each new tensor.")
362
+ return list(values)
363
+
364
+
365
+ def _trial_masks(tensors, trial_mask):
366
+ values = _session_sequence(trial_mask, len(tensors), "trial-mask")
367
+ masks = []
368
+ for tensor, value in zip(tensors, values):
369
+ if value is None:
370
+ mask = torch.zeros(
371
+ tensor.shape[2], dtype=torch.bool, device=tensor.device
372
+ )
373
+ else:
374
+ mask = torch.as_tensor(
375
+ value, dtype=torch.bool, device=tensor.device
376
+ )
377
+ if mask.ndim != 1 or len(mask) != tensor.shape[2]:
378
+ raise ValueError(
379
+ "Trial masks must match the number of trials in each tensor."
380
+ )
381
+ if mask.all():
382
+ raise ValueError("Each tensor must contain an unmasked trial.")
383
+ masks.append(mask)
384
+ return masks
385
+
386
+
387
+ def _condition_vocabulary(source):
388
+ if source.C is None:
389
+ return []
390
+ if source.condition_labels is None:
391
+ return list(range(source.C.shape[0]))
392
+ return [_python_scalar(label) for label in source.condition_labels]
393
+
394
+
395
+ def _label_info(tensors, labels, masks, vocabulary):
396
+ label_to_index = {label: n for n, label in enumerate(vocabulary)}
397
+ session_label_info = []
398
+ ignored = []
399
+ ignored_set = set()
400
+ for n, (tensor, tensor_labels, mask) in enumerate(
401
+ zip(tensors, labels, masks)
402
+ ):
403
+ if tensor_labels is None:
404
+ session_label_info.append([])
405
+ continue
406
+ values = (
407
+ tensor_labels.detach().cpu().numpy()
408
+ if isinstance(tensor_labels, torch.Tensor)
409
+ else np.asarray(tensor_labels)
410
+ )
411
+ if values.ndim != 1 or len(values) != tensor.shape[2]:
412
+ raise ValueError(
413
+ f"Trial labels for tensor {n} must match its number of trials."
414
+ )
415
+ positions = {}
416
+ alignment_indices = torch.nonzero(~mask, as_tuple=True)[0].tolist()
417
+ for index in alignment_indices:
418
+ label = values[index]
419
+ label = _python_scalar(label)
420
+ condition = label_to_index.get(label)
421
+ if condition is None:
422
+ if label not in ignored_set:
423
+ ignored_set.add(label)
424
+ ignored.append(label)
425
+ continue
426
+ positions.setdefault(condition, []).append(index)
427
+ condition_info = [
428
+ (
429
+ condition,
430
+ torch.tensor(indices, dtype=torch.long, device=tensor.device),
431
+ )
432
+ for condition, indices in positions.items()
433
+ ]
434
+ session_label_info.append(condition_info)
435
+ return session_label_info, ignored
436
+
437
+
438
+ def _python_scalar(value):
439
+ return value.item() if isinstance(value, np.generic) else value
neuralign/callbacks.py ADDED
@@ -0,0 +1,90 @@
1
+ """Minimal callbacks for the training loop."""
2
+
3
+ import copy
4
+ import math
5
+
6
+ from tqdm.auto import tqdm
7
+
8
+ from neuralign.gauges import gauge_fix_model
9
+
10
+
11
+ class Callback:
12
+ def on_train_start(self, trainer):
13
+ pass
14
+
15
+ def on_step_end(self, trainer):
16
+ pass
17
+
18
+ def on_after_optimizer_step(self, trainer):
19
+ pass
20
+
21
+ def on_train_end(self, trainer):
22
+ pass
23
+
24
+
25
+ class EarlyStopping(Callback):
26
+ def __init__(self, patience=30, tol=1e-6):
27
+ self.patience = patience
28
+ self.tol = tol
29
+
30
+ def on_train_start(self, trainer):
31
+ self.best = float("inf")
32
+ self.best_loss = float("inf")
33
+ self.best_state = None
34
+ self.best_history_length = 0
35
+ self.bad_steps = 0
36
+
37
+ def on_step_end(self, trainer):
38
+ if not math.isfinite(trainer.current_loss):
39
+ trainer.should_stop = True
40
+ else:
41
+ if trainer.current_loss < self.best_loss:
42
+ self.best_loss = trainer.current_loss
43
+ self.best_state = copy.deepcopy(trainer.model.state_dict())
44
+ self.best_history_length = len(trainer.history)
45
+
46
+ if trainer.current_loss < self.best - self.tol:
47
+ self.best = trainer.current_loss
48
+ self.bad_steps = 0
49
+ else:
50
+ self.bad_steps += 1
51
+ if self.bad_steps >= self.patience:
52
+ trainer.should_stop = True
53
+
54
+ def on_train_end(self, trainer):
55
+ if self.best_state is not None:
56
+ trainer.model.load_state_dict(self.best_state)
57
+ del trainer.history[self.best_history_length:]
58
+ for history in trainer.loss_history.values():
59
+ del history[self.best_history_length:]
60
+
61
+
62
+ class ProgressBar(Callback):
63
+ def on_train_start(self, trainer):
64
+ self.bar = tqdm(total=trainer.max_steps)
65
+
66
+ def on_step_end(self, trainer):
67
+ self.bar.update(1)
68
+ postfix = {"loss": f"{trainer.current_loss:.6f}"}
69
+ postfix.update(
70
+ (f"{name}", f"{value:.6f}")
71
+ for name, value in trainer.current_losses.items()
72
+ )
73
+ self.bar.set_postfix(postfix)
74
+
75
+ def on_train_end(self, trainer):
76
+ self.bar.close()
77
+
78
+
79
+ class GaugeFixing(Callback):
80
+ def on_train_start(self, trainer):
81
+ # Fix the gauge of the decomposition into the global weights at the start of training
82
+ gauge_fix_model(trainer.model)
83
+
84
+ def on_after_optimizer_step(self, trainer):
85
+ # Fix the gauge of the decomposition into the global weights after each step
86
+ gauge_fix_model(trainer.model)
87
+
88
+ def on_train_end(self, trainer):
89
+ # Also resolve sign ambiguities when the decomposition is finalized.
90
+ gauge_fix_model(trainer.model, sign_fix=True)
neuralign/configs.py ADDED
@@ -0,0 +1,52 @@
1
+ """Neuralign fitting options."""
2
+
3
+ from dataclasses import asdict, dataclass, fields
4
+ from typing import Any, Dict
5
+
6
+
7
+ @dataclass
8
+ class NeuralignConfig:
9
+ """Configuration for fitting and adapting session tensors."""
10
+
11
+ rank: int = 4
12
+ init: str = "svd"
13
+ gauge_fix: bool = True
14
+ non_negative: bool = False
15
+ loss_type: str = "frobenius" # "frobenius" or "poisson" or "poisson_log"
16
+ lr: float = 1e-2
17
+ max_iter: int = 2000
18
+ tol: float = 1e-6
19
+ patience: int = 100
20
+ progress: bool = True
21
+ seed: int | None = None
22
+
23
+ lambda_tensor_reconstruction: float = 1.0
24
+ lambda_procrustes: float = 1.0 # coordinate alignment loss (can be condition-aware)
25
+ # Adaptation penalty toward frozen condition means: None uses the default;
26
+ # 0.0 disables it. Only recognized, unmasked conditions contribute.
27
+ lambda_condition_centroid: float | None = None
28
+ procrustes_every_k: int = 3 # how often to re-compute aligned coordinates (expensive)
29
+ lambda_global_gram: float = 0.0 # global geometry loss (regularizer)
30
+ lambda_neuron_sparsity: float = 0.0 # weight for the neuron sparsity loss (regularizer)
31
+
32
+
33
+ def to_dict(cfg: NeuralignConfig) -> Dict[str, Any]:
34
+ return asdict(cfg)
35
+
36
+
37
+ def from_dict(d: Dict[str, Any]) -> NeuralignConfig:
38
+ valid = {f.name for f in fields(NeuralignConfig)}
39
+ filtered = {k: v for k, v in d.items() if k in valid}
40
+ return NeuralignConfig(**filtered)
41
+
42
+
43
+ def override_config(cfg: NeuralignConfig, overrides: Dict[str, Any]) -> NeuralignConfig:
44
+ d = to_dict(cfg)
45
+ for k, v in overrides.items():
46
+ if v is None:
47
+ continue
48
+ if k in d:
49
+ d[k] = v
50
+ else:
51
+ raise ValueError(f"Invalid config key: {k}")
52
+ return from_dict(d)
neuralign/gauges.py ADDED
@@ -0,0 +1,67 @@
1
+ """
2
+ Gauge normalization/fixing functions to remove the gauge freedom in the neuralign model.
3
+ These functions help with scaling and sign ambiguities in the factors, making them more interpretable
4
+ """
5
+
6
+ import torch
7
+
8
+ @torch.no_grad()
9
+ def gauge_fix_model(model, eps=1e-12, sign_fix=False):
10
+
11
+ # Normalize V and absorb its component scales into the CP weights.
12
+ scale = torch.linalg.norm(model.V, dim=0)
13
+ scale = torch.where(scale > eps, scale, torch.ones_like(scale))
14
+ model.V.div_(scale)
15
+ model.w.mul_(scale)
16
+
17
+ # Normalize H while preserving H @ diag(S_n).
18
+ scale = torch.linalg.norm(model.H, dim=0)
19
+ scale = torch.where(scale > eps, scale, torch.ones_like(scale))
20
+ model.H.div_(scale)
21
+ for S in model.S_n:
22
+ S.mul_(scale)
23
+
24
+ # Normalize U with one component scale shared equally across sessions and
25
+ # absorb that scale into the CP weights.
26
+ scale = torch.sqrt(
27
+ torch.stack([torch.mean(U.square(), dim=0) for U in model.U]).mean(dim=0)
28
+ )
29
+ scale = torch.where(scale > eps, scale, torch.ones_like(scale))
30
+ for U in model.U:
31
+ U.div_(scale)
32
+
33
+ # normalize condition centroids if they exist
34
+ if hasattr(model, "C") and model.C is not None:
35
+ model.C.div_(scale)
36
+
37
+ model.w.mul_(scale)
38
+
39
+
40
+ if sign_fix:
41
+ # Make the largest-magnitude entry of each V column positive. Flipping
42
+ # every corresponding U column preserves the CP reconstruction.
43
+ indices = torch.argmax(torch.abs(model.V), dim=0, keepdim=True)
44
+ anchors = torch.gather(model.V, 0, indices).squeeze(0)
45
+ signs = torch.where(
46
+ torch.abs(anchors) > eps,
47
+ torch.sign(anchors),
48
+ torch.ones_like(anchors),
49
+ )
50
+ model.V.mul_(signs)
51
+ for U in model.U:
52
+ U.mul_(signs)
53
+ if hasattr(model, "C") and model.C is not None:
54
+ model.C.mul_(signs)
55
+
56
+ # Apply the same convention to H and compensate in every S_n so that
57
+ # H @ diag(S_n) is unchanged.
58
+ indices = torch.argmax(torch.abs(model.H), dim=0, keepdim=True)
59
+ anchors = torch.gather(model.H, 0, indices).squeeze(0)
60
+ signs = torch.where(
61
+ torch.abs(anchors) > eps,
62
+ torch.sign(anchors),
63
+ torch.ones_like(anchors),
64
+ )
65
+ model.H.mul_(signs)
66
+ for S in model.S_n:
67
+ S.mul_(signs)