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 +10 -0
- neuralign/adapt.py +439 -0
- neuralign/callbacks.py +90 -0
- neuralign/configs.py +52 -0
- neuralign/gauges.py +67 -0
- neuralign/initializers.py +106 -0
- neuralign/losses.py +402 -0
- neuralign/model.py +418 -0
- neuralign/neuralign_factors.py +345 -0
- neuralign/ops.py +54 -0
- neuralign/simulation.py +53 -0
- neuralign/trainer.py +81 -0
- neuralign-0.1.0.dist-info/METADATA +74 -0
- neuralign-0.1.0.dist-info/RECORD +16 -0
- neuralign-0.1.0.dist-info/WHEEL +4 -0
- neuralign-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|