KSTARKX commited on
Commit
bd30e65
·
verified ·
1 Parent(s): af31d52

Release v0.2.0: standalone model package

Browse files
harmonicfoldnet/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ from .harmonic_fold import HarmonicFoldNet
2
+ from .model import reparameterize_model
harmonicfoldnet/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (309 Bytes). View file
 
harmonicfoldnet/__pycache__/harmonic_fold.cpython-311.pyc ADDED
Binary file (83 kB). View file
 
harmonicfoldnet/__pycache__/model.cpython-311.pyc ADDED
Binary file (36 kB). View file
 
harmonicfoldnet/harmonic_fold.py ADDED
@@ -0,0 +1,1311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+
5
+ import torch
6
+ import torch.nn.functional as F
7
+ from torch import nn
8
+
9
+ from .model import AttentionBlock, RepTokenMixer1d
10
+
11
+
12
+ FUSION_MODES = ("full", "no_evidence", "no_attention", "no_local")
13
+ HARMONIC_FOLD_MODES = (
14
+ "full", "no_attention", "no_cross_attention", "no_candidate_attention",
15
+ "no_local", "no_harmonic_bias",
16
+ )
17
+ TEMPORAL_FUSION_ARCHITECTURE_REVISION = "temporal_fusion_v1_3"
18
+ SPECTRAL_FUSION_ARCHITECTURE_REVISION = "spectral_fusion_v3_0"
19
+ HARMONIC_FOLD_V4_ARCHITECTURE_REVISION = "harmonic_fold_v4_0"
20
+ HARMONIC_FOLD_V41_ARCHITECTURE_REVISION = "harmonic_fold_v4_1"
21
+ HARMONIC_FOLD_V42_ARCHITECTURE_REVISION = "harmonic_fold_v4_2"
22
+ HARMONIC_FOLD_V43_ARCHITECTURE_REVISION = "harmonic_fold_v4_3"
23
+ HARMONIC_FOLD_V44_ARCHITECTURE_REVISION = "harmonic_fold_v4_4"
24
+ HARMONIC_FOLD_V45_ARCHITECTURE_REVISION = "harmonic_fold_v4_5_1"
25
+ HARMONIC_FOLD_V46_ARCHITECTURE_REVISION = "harmonic_fold_v4_6"
26
+ HARMONIC_FOLD_V47_ARCHITECTURE_REVISION = "harmonic_fold_v4_7"
27
+
28
+
29
+ class ConvFFN1d(nn.Module):
30
+ def __init__(self, dim: int, expansion: int = 2, dropout: float = 0.0):
31
+ super().__init__()
32
+ hidden = dim * expansion
33
+ self.norm = nn.BatchNorm1d(dim)
34
+ self.layers = nn.Sequential(
35
+ nn.Conv1d(dim, hidden, 1),
36
+ nn.GELU(),
37
+ nn.Dropout(dropout),
38
+ nn.Conv1d(hidden, dim, 1),
39
+ nn.Dropout(dropout),
40
+ )
41
+ # Start as an identity residual path. This avoids destroying the weak
42
+ # periodic input before the local branch has learned a useful update.
43
+ nn.init.zeros_(self.layers[-2].weight)
44
+ nn.init.zeros_(self.layers[-2].bias)
45
+
46
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
47
+ return x + self.layers(self.norm(x))
48
+
49
+
50
+ class RepEEGBlock(nn.Module):
51
+ """Local train-time multi-branch mixer with a foldable inference graph."""
52
+
53
+ def __init__(
54
+ self, dim: int, kernel_size: int, dropout: float, *, gate_logit_init: float = -2.0,
55
+ ):
56
+ super().__init__()
57
+ self.mixer = RepTokenMixer1d(dim, kernel_size=kernel_size)
58
+ nn.init.zeros_(self.mixer.branch_large[1].weight)
59
+ nn.init.zeros_(self.mixer.branch_pointwise[1].weight)
60
+ self.ffn = ConvFFN1d(dim, expansion=2, dropout=dropout)
61
+ self.residual_gate_logit = nn.Parameter(torch.tensor(float(gate_logit_init)))
62
+
63
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
64
+ update = self.ffn(self.mixer(x)) - x
65
+ return x + torch.sigmoid(self.residual_gate_logit) * update
66
+
67
+
68
+ class DepthwiseDownsample1d(nn.Module):
69
+ def __init__(self, cin: int, cout: int, *, stride: int):
70
+ super().__init__()
71
+ self.layers = nn.Sequential(
72
+ nn.Conv1d(cin, cin, 9, stride=stride, padding=4, groups=cin, bias=False),
73
+ nn.BatchNorm1d(cin),
74
+ nn.GELU(),
75
+ nn.Conv1d(cin, cout, 1, bias=False),
76
+ nn.BatchNorm1d(cout),
77
+ nn.GELU(),
78
+ )
79
+
80
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
81
+ return self.layers(x)
82
+
83
+
84
+ class CandidateFeatureExtractor(nn.Module):
85
+ """Shared candidate-aligned phase features, independent of class count."""
86
+
87
+ def __init__(
88
+ self,
89
+ channels: int,
90
+ dim: int,
91
+ sample_rate: int,
92
+ class_frequencies: list[float] | tuple[float, ...],
93
+ class_phases: list[float] | tuple[float, ...] | None,
94
+ *,
95
+ harmonics: int = 4,
96
+ segments: int = 4,
97
+ phase_dynamics: bool = False,
98
+ ):
99
+ super().__init__()
100
+ if not class_frequencies:
101
+ raise ValueError("class_frequencies cannot be empty")
102
+ phases = list(class_phases or [0.0] * len(class_frequencies))
103
+ if len(phases) != len(class_frequencies):
104
+ raise ValueError("class_phases must match class_frequencies")
105
+ self.channels = int(channels)
106
+ self.sample_rate = float(sample_rate)
107
+ self.harmonics = int(harmonics)
108
+ self.segments = int(segments)
109
+ if self.segments <= 0:
110
+ raise ValueError("segments must be positive")
111
+ self.phase_dynamics_enabled = bool(phase_dynamics)
112
+ if self.phase_dynamics_enabled and self.segments < 2:
113
+ raise ValueError("phase dynamics require at least two temporal segments")
114
+ self.register_buffer(
115
+ "class_frequencies", torch.tensor(class_frequencies, dtype=torch.float32),
116
+ persistent=True,
117
+ )
118
+ self.register_buffer(
119
+ "class_phases", torch.tensor(phases, dtype=torch.float32), persistent=True,
120
+ )
121
+ normalized = self.class_frequencies / (0.5 * self.sample_rate)
122
+ self.register_buffer(
123
+ "frequency_encoding_features",
124
+ torch.stack(
125
+ (
126
+ normalized,
127
+ normalized.square(),
128
+ torch.sin(2.0 * torch.pi * normalized),
129
+ torch.cos(2.0 * torch.pi * normalized),
130
+ torch.sin(4.0 * torch.pi * normalized),
131
+ torch.cos(4.0 * torch.pi * normalized),
132
+ torch.sin(self.class_phases),
133
+ torch.cos(self.class_phases),
134
+ ),
135
+ dim=-1,
136
+ ),
137
+ persistent=False,
138
+ )
139
+ self._demodulation_basis_cache: dict[
140
+ tuple[int, str], tuple[torch.Tensor, torch.Tensor]
141
+ ] = {}
142
+ phase_dynamics_features = 5 if self.phase_dynamics_enabled else 0
143
+ feature_dim = channels * harmonics * (
144
+ segments * 3 + phase_dynamics_features
145
+ )
146
+ self.projection = nn.Sequential(
147
+ nn.LayerNorm(feature_dim),
148
+ nn.Linear(feature_dim, dim * 2),
149
+ nn.GELU(),
150
+ nn.Linear(dim * 2, dim),
151
+ )
152
+ self.frequency_projection = nn.Sequential(
153
+ nn.Linear(8, dim), nn.GELU(), nn.Linear(dim, dim),
154
+ )
155
+
156
+ def _frequency_encoding(self, *, device: torch.device, dtype: torch.dtype) -> torch.Tensor:
157
+ return self.frequency_projection(
158
+ self.frequency_encoding_features.to(device=device, dtype=dtype)
159
+ )
160
+
161
+ def _demodulation_basis(
162
+ self, samples: int, device: torch.device,
163
+ ) -> tuple[torch.Tensor, torch.Tensor]:
164
+ key = (samples, str(device))
165
+ cached = self._demodulation_basis_cache.get(key)
166
+ if cached is not None and cached[0].device == device:
167
+ return cached
168
+ # Validation calls run under inference_mode. A cached inference tensor
169
+ # cannot later be saved by autograd when the local EEG front end makes
170
+ # the signal differentiable, so build cache entries in normal tensor
171
+ # mode regardless of the caller's context.
172
+ with torch.inference_mode(False), torch.no_grad():
173
+ time_axis = (
174
+ torch.arange(samples, device=device, dtype=torch.float32)
175
+ / self.sample_rate
176
+ )
177
+ harmonics = torch.arange(
178
+ 1, self.harmonics + 1, device=device, dtype=torch.float32,
179
+ )
180
+ frequency = self.class_frequencies.to(device=device, dtype=torch.float32)
181
+ phase = self.class_phases.to(device=device, dtype=torch.float32)
182
+ angles = (
183
+ 2.0 * torch.pi * frequency[:, None, None]
184
+ * harmonics[None, :, None] * time_axis[None, None, :]
185
+ + phase[:, None, None] * harmonics[None, :, None]
186
+ )
187
+ cosine = torch.cos(angles)
188
+ sine = torch.sin(angles)
189
+ cosine_segments = []
190
+ sine_segments = []
191
+ for segment in range(self.segments):
192
+ begin = (segment * samples) // self.segments
193
+ end = ((segment + 1) * samples) // self.segments
194
+ length = max(end - begin, 1)
195
+ mask = torch.zeros(samples, device=device, dtype=torch.float32)
196
+ mask[begin:end] = 1.0 / length
197
+ cosine_segments.append(cosine * mask)
198
+ sine_segments.append(sine * mask)
199
+ basis = (
200
+ torch.stack(cosine_segments, dim=2),
201
+ torch.stack(sine_segments, dim=2),
202
+ )
203
+ self._demodulation_basis_cache[key] = basis
204
+ return basis
205
+
206
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
207
+ with torch.autocast(device_type=x.device.type, enabled=False):
208
+ signal = x.float()
209
+ samples = signal.shape[-1]
210
+ cosine, sine = self._demodulation_basis(samples, x.device)
211
+ real = torch.einsum("bct,nhst->bnchs", signal, cosine)
212
+ imaginary = torch.einsum("bct,nhst->bnchs", signal, sine)
213
+ amplitude = torch.sqrt(real.square() + imaginary.square() + 1e-8)
214
+ features = torch.stack(
215
+ (real, imaginary, torch.log(amplitude + 1e-8)), dim=-1,
216
+ ).flatten(2)
217
+ if self.phase_dynamics_enabled:
218
+ unit_real = real / amplitude
219
+ unit_imaginary = imaginary / amplitude
220
+ adjacent_real = (
221
+ unit_real[..., 1:] * unit_real[..., :-1]
222
+ + unit_imaginary[..., 1:] * unit_imaginary[..., :-1]
223
+ ).mean(dim=-1)
224
+ adjacent_imaginary = (
225
+ unit_imaginary[..., 1:] * unit_real[..., :-1]
226
+ - unit_real[..., 1:] * unit_imaginary[..., :-1]
227
+ ).mean(dim=-1)
228
+ endpoint_real = (
229
+ unit_real[..., -1] * unit_real[..., 0]
230
+ + unit_imaginary[..., -1] * unit_imaginary[..., 0]
231
+ )
232
+ endpoint_imaginary = (
233
+ unit_imaginary[..., -1] * unit_real[..., 0]
234
+ - unit_real[..., -1] * unit_imaginary[..., 0]
235
+ )
236
+ amplitude_instability = torch.log(amplitude + 1e-8).std(
237
+ dim=-1, correction=0,
238
+ )
239
+ phase_dynamics = torch.stack(
240
+ (
241
+ adjacent_real,
242
+ adjacent_imaginary,
243
+ endpoint_real,
244
+ endpoint_imaginary,
245
+ amplitude_instability,
246
+ ),
247
+ dim=-1,
248
+ ).flatten(2)
249
+ features = torch.cat((features, phase_dynamics), dim=-1)
250
+ features = (features - features.mean(dim=1, keepdim=True)) / features.std(
251
+ dim=1, keepdim=True, correction=0,
252
+ ).clamp_min(1e-5)
253
+ tokens = self.projection(features.to(dtype=x.dtype))
254
+ return tokens + self._frequency_encoding(device=x.device, dtype=x.dtype).unsqueeze(0)
255
+
256
+
257
+ class AnalyticReferencePrior(nn.Module):
258
+ """Parameter-free CCA score used only as a residual prior and ablation."""
259
+
260
+ def __init__(
261
+ self,
262
+ sample_rate: int,
263
+ class_frequencies: list[float] | tuple[float, ...],
264
+ *,
265
+ harmonics: int = 4,
266
+ ):
267
+ super().__init__()
268
+ self.sample_rate = float(sample_rate)
269
+ self.harmonics = int(harmonics)
270
+ self.register_buffer(
271
+ "class_frequencies", torch.tensor(class_frequencies, dtype=torch.float32),
272
+ persistent=True,
273
+ )
274
+ self._reference_q_cache: dict[tuple[int, str], torch.Tensor] = {}
275
+
276
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
277
+ with torch.autocast(device_type=x.device.type, enabled=False):
278
+ signal = x.float()
279
+ _, _, samples = signal.shape
280
+ signal = signal - signal.mean(dim=-1, keepdim=True)
281
+ covariance = signal @ signal.transpose(-1, -2) / max(samples - 1, 1)
282
+ values, vectors = torch.linalg.eigh(covariance)
283
+ scale = values.amax(dim=-1, keepdim=True).clamp_min(1.0)
284
+ values = values.clamp_min(1e-4 * scale)
285
+ inverse_sqrt = (
286
+ vectors * values.rsqrt().unsqueeze(-2)
287
+ ) @ vectors.transpose(-1, -2)
288
+ whitened = (inverse_sqrt @ signal) / math.sqrt(max(samples - 1, 1))
289
+ key = (samples, str(signal.device))
290
+ reference_q = self._reference_q_cache.get(key)
291
+ if reference_q is None or reference_q.device != signal.device:
292
+ time_axis = torch.arange(
293
+ samples, device=signal.device, dtype=torch.float32,
294
+ ) / self.sample_rate
295
+ harmonics = torch.arange(
296
+ 1, self.harmonics + 1, device=signal.device, dtype=torch.float32,
297
+ )
298
+ angles = (
299
+ 2.0 * torch.pi
300
+ * self.class_frequencies.to(signal.device)[:, None, None]
301
+ * harmonics[None, :, None]
302
+ * time_axis[None, None, :]
303
+ )
304
+ references = torch.stack((torch.sin(angles), torch.cos(angles)), dim=-1)
305
+ references = references.permute(0, 2, 1, 3).flatten(2)
306
+ reference_q = torch.linalg.qr(references, mode="reduced").Q
307
+ self._reference_q_cache[key] = reference_q
308
+ cross = torch.einsum("bct,ntk->bnck", whitened, reference_q)
309
+ scores = torch.linalg.svdvals(cross)[..., 0]
310
+ scores = (scores - scores.mean(dim=1, keepdim=True)) / scores.std(
311
+ dim=1, keepdim=True, correction=0,
312
+ ).clamp_min(1e-5)
313
+ return scores.to(dtype=x.dtype)
314
+
315
+
316
+ class ComplexSpectrumFrontEnd(nn.Module):
317
+ """Fixed-grid complex spectrum used by both global and candidate paths."""
318
+
319
+ def __init__(
320
+ self,
321
+ sample_rate: int,
322
+ *,
323
+ resolution_hz: float = 0.25,
324
+ band_hz: tuple[float, float] = (6.0, 45.0),
325
+ ) -> None:
326
+ super().__init__()
327
+ self.sample_rate = float(sample_rate)
328
+ self.resolution_hz = float(resolution_hz)
329
+ self.n_fft = int(round(self.sample_rate / self.resolution_hz))
330
+ self.start = int(round(float(band_hz[0]) / self.resolution_hz))
331
+ self.end = int(round(float(band_hz[1]) / self.resolution_hz)) + 1
332
+ if self.start < 0 or self.end > self.n_fft // 2 + 1 or self.start >= self.end:
333
+ raise ValueError("spectral band must lie inside the Nyquist interval")
334
+
335
+ def spectrum(self, x: torch.Tensor) -> torch.Tensor:
336
+ # Divide by the observed duration rather than n_fft so zero padding only
337
+ # changes spectral resolution, not the scale seen by the network.
338
+ return torch.fft.rfft(x.float(), n=self.n_fft, dim=-1) / x.shape[-1]
339
+
340
+ def forward(self, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
341
+ with torch.autocast(device_type=x.device.type, enabled=False):
342
+ spectrum = self.spectrum(x)
343
+ band = spectrum[..., self.start:self.end]
344
+ magnitude = torch.log1p(10.0 * band.abs())
345
+ features = torch.cat((band.real, band.imag, magnitude), dim=1)
346
+ return spectrum, features.to(dtype=x.dtype)
347
+
348
+
349
+ class SpectralCandidateExtractor(nn.Module):
350
+ """Sample complex harmonic neighborhoods on a fixed physical-Hz grid."""
351
+
352
+ def __init__(
353
+ self,
354
+ channels: int,
355
+ dim: int,
356
+ sample_rate: int,
357
+ class_frequencies: list[float] | tuple[float, ...],
358
+ class_phases: list[float] | tuple[float, ...] | None,
359
+ *,
360
+ resolution_hz: float,
361
+ harmonics: int = 4,
362
+ neighborhood_bins: int = 2,
363
+ ) -> None:
364
+ super().__init__()
365
+ if not class_frequencies:
366
+ raise ValueError("class_frequencies cannot be empty")
367
+ if neighborhood_bins < 0:
368
+ raise ValueError("neighborhood_bins must be non-negative")
369
+ phases = list(class_phases or [0.0] * len(class_frequencies))
370
+ if len(phases) != len(class_frequencies):
371
+ raise ValueError("class_phases must match class_frequencies")
372
+ self.channels = int(channels)
373
+ self.sample_rate = float(sample_rate)
374
+ self.resolution_hz = float(resolution_hz)
375
+ self.harmonics = int(harmonics)
376
+ self.neighborhood_bins = int(neighborhood_bins)
377
+ self.register_buffer(
378
+ "class_frequencies", torch.tensor(class_frequencies, dtype=torch.float32),
379
+ persistent=True,
380
+ )
381
+ self.register_buffer(
382
+ "class_phases", torch.tensor(phases, dtype=torch.float32), persistent=True,
383
+ )
384
+ normalized = self.class_frequencies / (0.5 * self.sample_rate)
385
+ self.register_buffer(
386
+ "frequency_encoding_features",
387
+ torch.stack(
388
+ (
389
+ normalized,
390
+ normalized.square(),
391
+ torch.sin(2.0 * torch.pi * normalized),
392
+ torch.cos(2.0 * torch.pi * normalized),
393
+ torch.sin(4.0 * torch.pi * normalized),
394
+ torch.cos(4.0 * torch.pi * normalized),
395
+ torch.sin(self.class_phases),
396
+ torch.cos(self.class_phases),
397
+ ),
398
+ dim=-1,
399
+ ),
400
+ persistent=False,
401
+ )
402
+ harmonic = torch.arange(1, self.harmonics + 1, dtype=torch.float32)
403
+ offsets = torch.arange(
404
+ -self.neighborhood_bins, self.neighborhood_bins + 1, dtype=torch.float32,
405
+ ) * self.resolution_hz
406
+ target_hz = self.class_frequencies[:, None, None] * harmonic[None, :, None]
407
+ target_hz = target_hz + offsets[None, None, :]
408
+ max_hz = 0.5 * self.sample_rate
409
+ position = (target_hz / self.resolution_hz).clamp(0, int(max_hz / self.resolution_hz))
410
+ lower = position.floor().long()
411
+ self.register_buffer("sample_lower", lower.flatten(), persistent=False)
412
+ self.register_buffer("sample_upper", (lower + 1).clamp_max(int(max_hz / self.resolution_hz)).flatten(), persistent=False)
413
+ self.register_buffer("sample_fraction", (position - lower).flatten(), persistent=False)
414
+ self.register_buffer(
415
+ "sample_valid", ((target_hz >= 0.0) & (target_hz <= max_hz)).flatten(),
416
+ persistent=False,
417
+ )
418
+ neighborhood = 2 * self.neighborhood_bins + 1
419
+ feature_dim = channels * harmonics * neighborhood * 3
420
+ self.projection = nn.Sequential(
421
+ nn.LayerNorm(feature_dim),
422
+ nn.Linear(feature_dim, dim * 2),
423
+ nn.GELU(),
424
+ nn.Linear(dim * 2, dim),
425
+ )
426
+ self.frequency_projection = nn.Sequential(
427
+ nn.Linear(8, dim), nn.GELU(), nn.Linear(dim, dim),
428
+ )
429
+
430
+ def _frequency_encoding(self, *, device: torch.device, dtype: torch.dtype) -> torch.Tensor:
431
+ return self.frequency_projection(
432
+ self.frequency_encoding_features.to(device=device, dtype=dtype)
433
+ )
434
+
435
+ def forward(self, spectrum: torch.Tensor, *, output_dtype: torch.dtype) -> torch.Tensor:
436
+ with torch.autocast(device_type=spectrum.device.type, enabled=False):
437
+ spectrum = spectrum.to(torch.complex64)
438
+ lower_value = spectrum.index_select(-1, self.sample_lower)
439
+ upper_value = spectrum.index_select(-1, self.sample_upper)
440
+ fraction = self.sample_fraction.view(1, 1, -1)
441
+ sampled = lower_value * (1.0 - fraction) + upper_value * fraction
442
+ sampled = sampled * self.sample_valid.view(1, 1, -1)
443
+ batch = sampled.shape[0]
444
+ classes = self.class_frequencies.numel()
445
+ neighborhood = 2 * self.neighborhood_bins + 1
446
+ sampled = sampled.view(
447
+ batch, self.channels, classes, self.harmonics, neighborhood,
448
+ ).permute(0, 2, 1, 3, 4)
449
+ feature = torch.stack(
450
+ (
451
+ sampled.real,
452
+ sampled.imag,
453
+ torch.log1p(10.0 * sampled.abs()),
454
+ ),
455
+ dim=-1,
456
+ ).flatten(2)
457
+ feature = (feature - feature.mean(dim=1, keepdim=True)) / feature.std(
458
+ dim=1, keepdim=True, correction=0,
459
+ ).clamp_min(1e-5)
460
+ tokens = self.projection(feature.to(dtype=output_dtype))
461
+ return tokens + self._frequency_encoding(
462
+ device=spectrum.device, dtype=output_dtype,
463
+ ).unsqueeze(0)
464
+
465
+
466
+ class HarmonicCrossAttention(nn.Module):
467
+ """Late class-query attention with a fixed harmonic locality bias."""
468
+
469
+ def __init__(
470
+ self,
471
+ dim: int,
472
+ heads: int,
473
+ class_frequencies: list[float] | tuple[float, ...],
474
+ spectral_token_hz: torch.Tensor,
475
+ *,
476
+ harmonics: int,
477
+ bias_width_hz: float,
478
+ dropout: float,
479
+ ) -> None:
480
+ super().__init__()
481
+ if dim % heads:
482
+ raise ValueError("attention dimension must be divisible by heads")
483
+ if bias_width_hz <= 0:
484
+ raise ValueError("bias_width_hz must be positive")
485
+ self.dim = int(dim)
486
+ self.heads = int(heads)
487
+ self.head_dim = dim // heads
488
+ self.dropout = float(dropout)
489
+ self.query_norm = nn.LayerNorm(dim)
490
+ self.context_norm = nn.LayerNorm(dim)
491
+ self.query_projection = nn.Linear(dim, dim)
492
+ self.key_projection = nn.Linear(dim, dim)
493
+ self.value_projection = nn.Linear(dim, dim)
494
+ self.output_projection = nn.Linear(dim, dim)
495
+ self.ffn_norm = nn.LayerNorm(dim)
496
+ self.ffn = nn.Sequential(
497
+ nn.Linear(dim, dim * 2), nn.GELU(), nn.Dropout(dropout),
498
+ nn.Linear(dim * 2, dim), nn.Dropout(dropout),
499
+ )
500
+ frequencies = torch.tensor(class_frequencies, dtype=torch.float32)
501
+ harmonic = torch.arange(1, harmonics + 1, dtype=torch.float32)
502
+ targets = frequencies[:, None] * harmonic[None, :]
503
+ distance = spectral_token_hz[None, None, :] - targets[:, :, None]
504
+ harmonic_bias = torch.logsumexp(
505
+ -0.5 * (distance / float(bias_width_hz)).square(), dim=1,
506
+ )
507
+ harmonic_bias = harmonic_bias - harmonic_bias.amax(dim=-1, keepdim=True)
508
+ self.register_buffer("harmonic_bias", harmonic_bias, persistent=False)
509
+ self.register_buffer(
510
+ "spectral_token_hz", spectral_token_hz.detach().clone(), persistent=False,
511
+ )
512
+ self.register_buffer("harmonic_target_hz", targets, persistent=False)
513
+ self.bias_width_hz = float(bias_width_hz)
514
+
515
+ def attention_probabilities(
516
+ self,
517
+ query: torch.Tensor,
518
+ context: torch.Tensor,
519
+ *,
520
+ use_harmonic_bias: bool = True,
521
+ ) -> torch.Tensor:
522
+ """Return analysis-only attention probabilities without dropout."""
523
+ batch, candidates, _ = query.shape
524
+ tokens = context.shape[1]
525
+ q = self.query_projection(self.query_norm(query)).view(
526
+ batch, candidates, self.heads, self.head_dim,
527
+ ).transpose(1, 2)
528
+ normalized_context = self.context_norm(context)
529
+ k = self.key_projection(normalized_context).view(
530
+ batch, tokens, self.heads, self.head_dim,
531
+ ).transpose(1, 2)
532
+ logits = torch.matmul(q, k.transpose(-2, -1)) * (self.head_dim ** -0.5)
533
+ if use_harmonic_bias:
534
+ logits = logits + self.harmonic_bias.to(dtype=logits.dtype)[None, None, :, :]
535
+ return logits.softmax(dim=-1)
536
+
537
+ def forward(
538
+ self,
539
+ query: torch.Tensor,
540
+ context: torch.Tensor,
541
+ *,
542
+ use_harmonic_bias: bool = True,
543
+ ) -> torch.Tensor:
544
+ batch, candidates, _ = query.shape
545
+ tokens = context.shape[1]
546
+ q = self.query_projection(self.query_norm(query)).view(
547
+ batch, candidates, self.heads, self.head_dim,
548
+ ).transpose(1, 2)
549
+ normalized_context = self.context_norm(context)
550
+ k = self.key_projection(normalized_context).view(
551
+ batch, tokens, self.heads, self.head_dim,
552
+ ).transpose(1, 2)
553
+ v = self.value_projection(normalized_context).view(
554
+ batch, tokens, self.heads, self.head_dim,
555
+ ).transpose(1, 2)
556
+ attended = F.scaled_dot_product_attention(
557
+ q,
558
+ k,
559
+ v,
560
+ attn_mask=(
561
+ self.harmonic_bias.to(dtype=q.dtype)[None, None, :, :]
562
+ if use_harmonic_bias else None
563
+ ),
564
+ dropout_p=self.dropout if self.training else 0.0,
565
+ )
566
+ attended = attended.transpose(1, 2).reshape(batch, candidates, self.dim)
567
+ query = query + self.output_projection(attended)
568
+ return query + self.ffn(self.ffn_norm(query))
569
+
570
+
571
+ class HarmonicFoldNet(nn.Module):
572
+ """local-to-global SSVEP decoder with attention as the global fusion path."""
573
+
574
+ def __init__(
575
+ self,
576
+ channels: int,
577
+ sample_rate: int,
578
+ class_frequencies: list[float] | tuple[float, ...],
579
+ class_phases: list[float] | tuple[float, ...] | None = None,
580
+ *,
581
+ width: int = 48,
582
+ local_depths: tuple[int, int] = (1, 1),
583
+ heads: int = 4,
584
+ harmonics: int = 4,
585
+ neighborhood_bins: int = 2,
586
+ spectral_resolution_hz: float = 0.25,
587
+ spectral_band_hz: tuple[float, float] = (6.0, 45.0),
588
+ harmonic_bias_width_hz: float = 0.5,
589
+ align_spectral_grid_to_classes: bool = False,
590
+ duration_conditioning: bool = False,
591
+ candidate_local_mixing: bool = False,
592
+ candidate_local_mixing_placement: str = "pre_cross",
593
+ local_domain: str = "spectral",
594
+ temporal_kernel_sizes: tuple[int, int] = (15, 7),
595
+ temporal_segments: int = 4,
596
+ temporal_phase_dynamics: bool = False,
597
+ spectral_subband_low_hz: tuple[float, ...] | None = None,
598
+ evidence_reliability_gate: bool = False,
599
+ decision_reliability_gate: bool = False,
600
+ dropout: float = 0.1,
601
+ ) -> None:
602
+ super().__init__()
603
+ if width % heads:
604
+ raise ValueError("width must be divisible by heads")
605
+ dim = width * 2
606
+ if dim % heads:
607
+ raise ValueError("2 * width must be divisible by heads")
608
+ if local_domain not in {"spectral", "temporal", "dual"}:
609
+ raise ValueError("local_domain must be spectral, temporal, or dual")
610
+ if len(temporal_kernel_sizes) != 2 or any(
611
+ int(kernel) <= 0 or int(kernel) % 2 == 0
612
+ for kernel in temporal_kernel_sizes
613
+ ):
614
+ raise ValueError("temporal_kernel_sizes must contain two positive odd values")
615
+ if int(temporal_segments) <= 0:
616
+ raise ValueError("temporal_segments must be positive")
617
+ if candidate_local_mixing_placement not in {"pre_cross", "post_cross"}:
618
+ raise ValueError(
619
+ "candidate_local_mixing_placement must be pre_cross or post_cross"
620
+ )
621
+ if align_spectral_grid_to_classes:
622
+ ordered_frequencies = sorted({float(value) for value in class_frequencies})
623
+ if len(ordered_frequencies) < 2:
624
+ raise ValueError("class-grid alignment requires at least two frequencies")
625
+ minimum_spacing = min(
626
+ right - left
627
+ for left, right in zip(
628
+ ordered_frequencies[:-1], ordered_frequencies[1:], strict=True,
629
+ )
630
+ )
631
+ if minimum_spacing <= 0.0:
632
+ raise ValueError("class frequencies must be distinct")
633
+ spectral_resolution_hz = min(
634
+ float(spectral_resolution_hz), 0.5 * minimum_spacing,
635
+ )
636
+ harmonic_bias_width_hz = min(
637
+ float(harmonic_bias_width_hz), minimum_spacing,
638
+ )
639
+ self.spectral_resolution_hz = float(spectral_resolution_hz)
640
+ self.harmonic_bias_width_hz = float(harmonic_bias_width_hz)
641
+ self.sample_rate = float(sample_rate)
642
+ self.duration_conditioning_enabled = bool(duration_conditioning)
643
+ self.candidate_local_mixing_enabled = bool(candidate_local_mixing)
644
+ self.candidate_local_mixing_placement = candidate_local_mixing_placement
645
+ self.register_buffer(
646
+ "candidate_frequency_hz",
647
+ torch.tensor(class_frequencies, dtype=torch.float32),
648
+ persistent=False,
649
+ )
650
+ phases = list(class_phases or [0.0] * len(class_frequencies))
651
+ if len(phases) != len(class_frequencies):
652
+ raise ValueError("class_phases must match class_frequencies")
653
+ candidate_order = sorted(
654
+ range(len(class_frequencies)),
655
+ key=lambda index: (float(class_frequencies[index]), float(phases[index])),
656
+ )
657
+ candidate_inverse_order = [0] * len(candidate_order)
658
+ for ordered_index, original_index in enumerate(candidate_order):
659
+ candidate_inverse_order[original_index] = ordered_index
660
+ self.register_buffer(
661
+ "candidate_frequency_order",
662
+ torch.tensor(candidate_order, dtype=torch.long),
663
+ persistent=False,
664
+ )
665
+ self.register_buffer(
666
+ "candidate_frequency_inverse_order",
667
+ torch.tensor(candidate_inverse_order, dtype=torch.long),
668
+ persistent=False,
669
+ )
670
+ self.local_domain = local_domain
671
+ self.evidence_reliability_gate_enabled = bool(evidence_reliability_gate)
672
+ self.decision_reliability_gate_enabled = bool(decision_reliability_gate)
673
+ self.spectrum = ComplexSpectrumFrontEnd(
674
+ sample_rate,
675
+ resolution_hz=self.spectral_resolution_hz,
676
+ band_hz=spectral_band_hz,
677
+ )
678
+ subband_low_hz = tuple(float(value) for value in (spectral_subband_low_hz or ()))
679
+ if subband_low_hz and any(
680
+ value < float(spectral_band_hz[0]) or value >= float(spectral_band_hz[1])
681
+ for value in subband_low_hz
682
+ ):
683
+ raise ValueError("spectral subband cutoffs must lie inside spectral_band_hz")
684
+ self.spectral_subband_low_hz = subband_low_hz
685
+ self.register_buffer(
686
+ "spectral_bin_hz",
687
+ float(spectral_band_hz[0]) + torch.arange(
688
+ self.spectrum.end - self.spectrum.start, dtype=torch.float32,
689
+ ) * self.spectral_resolution_hz,
690
+ persistent=False,
691
+ )
692
+ spectral_input_groups = len(subband_low_hz) if subband_low_hz else 1
693
+ self.channel_projection = nn.Sequential(
694
+ nn.Conv1d(channels * 3 * spectral_input_groups, width, 1, bias=False),
695
+ nn.BatchNorm1d(width),
696
+ nn.GELU(),
697
+ )
698
+ self.local_stage1 = nn.ModuleList()
699
+ self.local_stage2 = nn.ModuleList()
700
+ self.input_local_stage1 = nn.ModuleList()
701
+ self.input_local_stage2 = nn.ModuleList()
702
+ if local_domain in {"spectral", "dual"}:
703
+ self.local_stage1.extend(
704
+ RepEEGBlock(width, kernel_size=9, dropout=dropout, gate_logit_init=0.0)
705
+ for _ in range(local_depths[0])
706
+ )
707
+ if local_domain in {"temporal", "dual"}:
708
+ self.input_local_stage1.extend(
709
+ RepEEGBlock(
710
+ channels, kernel_size=int(temporal_kernel_sizes[0]),
711
+ dropout=dropout, gate_logit_init=0.0,
712
+ )
713
+ for _ in range(local_depths[0])
714
+ )
715
+ self.downsample = DepthwiseDownsample1d(width, dim, stride=2)
716
+ if local_domain in {"spectral", "dual"}:
717
+ self.local_stage2.extend(
718
+ RepEEGBlock(dim, kernel_size=5, dropout=dropout, gate_logit_init=0.0)
719
+ for _ in range(local_depths[1])
720
+ )
721
+ if local_domain in {"temporal", "dual"}:
722
+ self.input_local_stage2.extend(
723
+ RepEEGBlock(
724
+ channels, kernel_size=int(temporal_kernel_sizes[1]),
725
+ dropout=dropout, gate_logit_init=0.0,
726
+ )
727
+ for _ in range(local_depths[1])
728
+ )
729
+ self.temporal_candidate_features = CandidateFeatureExtractor(
730
+ channels,
731
+ dim,
732
+ sample_rate,
733
+ class_frequencies,
734
+ class_phases,
735
+ harmonics=harmonics,
736
+ segments=int(temporal_segments),
737
+ phase_dynamics=temporal_phase_dynamics,
738
+ )
739
+ self.spectral_candidate_features = SpectralCandidateExtractor(
740
+ channels,
741
+ dim,
742
+ sample_rate,
743
+ class_frequencies,
744
+ class_phases,
745
+ resolution_hz=self.spectral_resolution_hz,
746
+ harmonics=harmonics,
747
+ neighborhood_bins=neighborhood_bins,
748
+ )
749
+ self.candidate_fusion = nn.Sequential(
750
+ nn.LayerNorm(dim * 2), nn.Linear(dim * 2, dim), nn.GELU(),
751
+ )
752
+ if self.duration_conditioning_enabled:
753
+ self.duration_projection = nn.Sequential(
754
+ nn.Linear(4, dim), nn.GELU(), nn.Linear(dim, dim),
755
+ )
756
+ nn.init.zeros_(self.duration_projection[-1].weight)
757
+ nn.init.zeros_(self.duration_projection[-1].bias)
758
+ else:
759
+ self.duration_projection = None
760
+ self.candidate_local_stage = (
761
+ RepEEGBlock(dim, kernel_size=3, dropout=dropout, gate_logit_init=0.0)
762
+ if self.candidate_local_mixing_enabled else None
763
+ )
764
+ if self.evidence_reliability_gate_enabled:
765
+ self.evidence_reliability_gate = nn.Sequential(
766
+ nn.LayerNorm(dim * 3),
767
+ nn.Linear(dim * 3, dim // 2),
768
+ nn.GELU(),
769
+ nn.Linear(dim // 2, 1),
770
+ )
771
+ nn.init.zeros_(self.evidence_reliability_gate[-1].weight)
772
+ nn.init.constant_(self.evidence_reliability_gate[-1].bias, -1.5)
773
+ else:
774
+ self.evidence_reliability_gate = None
775
+ if self.decision_reliability_gate_enabled:
776
+ # Five bounded, sample-level probability statistics control how
777
+ # much of the global/harmonic residual is trusted. The gate is
778
+ # deliberately downstream of candidate scoring: it never builds a
779
+ # second temporal/frequency encoder and adds only 113 parameters.
780
+ self.decision_reliability_gate = nn.Sequential(
781
+ nn.Linear(5, 16),
782
+ nn.GELU(),
783
+ nn.Linear(16, 1),
784
+ )
785
+ nn.init.zeros_(self.decision_reliability_gate[-1].weight)
786
+ nn.init.zeros_(self.decision_reliability_gate[-1].bias)
787
+ else:
788
+ self.decision_reliability_gate = None
789
+ spectral_bins = self.spectrum.end - self.spectrum.start
790
+ reduced_bins = (spectral_bins + 1) // 2
791
+ token_hz = float(spectral_band_hz[0]) + torch.arange(
792
+ reduced_bins, dtype=torch.float32,
793
+ ) * (2.0 * self.spectral_resolution_hz)
794
+ normalized_hz = token_hz / (0.5 * float(sample_rate))
795
+ position_features = torch.stack(
796
+ (
797
+ normalized_hz,
798
+ normalized_hz.square(),
799
+ torch.sin(2.0 * torch.pi * normalized_hz),
800
+ torch.cos(2.0 * torch.pi * normalized_hz),
801
+ torch.sin(4.0 * torch.pi * normalized_hz),
802
+ torch.cos(4.0 * torch.pi * normalized_hz),
803
+ torch.sin(8.0 * torch.pi * normalized_hz),
804
+ torch.cos(8.0 * torch.pi * normalized_hz),
805
+ ),
806
+ dim=-1,
807
+ )
808
+ self.register_buffer("spectral_position_features", position_features, persistent=False)
809
+ self.position_projection = nn.Sequential(
810
+ nn.Linear(8, dim), nn.GELU(), nn.Linear(dim, dim),
811
+ )
812
+ self.fusion_attention = HarmonicCrossAttention(
813
+ dim,
814
+ heads,
815
+ class_frequencies,
816
+ token_hz,
817
+ harmonics=harmonics,
818
+ bias_width_hz=self.harmonic_bias_width_hz,
819
+ dropout=dropout,
820
+ )
821
+ self.candidate_attention = AttentionBlock(dim, heads=heads, dropout=dropout)
822
+ self.score_head = nn.Sequential(
823
+ nn.LayerNorm(dim),
824
+ nn.Linear(dim, dim // 2),
825
+ nn.GELU(),
826
+ nn.Dropout(dropout),
827
+ nn.Linear(dim // 2, 1),
828
+ )
829
+
830
+ def _spectral_tokens(
831
+ self, spectral_features: torch.Tensor, *, use_local: bool,
832
+ ) -> torch.Tensor:
833
+ x = self.channel_projection(spectral_features)
834
+ if use_local:
835
+ for block in self.local_stage1:
836
+ x = block(x)
837
+ x = self.downsample(x)
838
+ if use_local:
839
+ for block in self.local_stage2:
840
+ x = block(x)
841
+ tokens = x.transpose(1, 2)
842
+ position = self.position_projection(
843
+ self.spectral_position_features.to(dtype=tokens.dtype)
844
+ )
845
+ return tokens + position.unsqueeze(0)
846
+
847
+ def _spectral_stem_features(
848
+ self, spectrum: torch.Tensor, spectral_features: torch.Tensor,
849
+ ) -> torch.Tensor:
850
+ """Create nested subband views for one shared local spectral stem.
851
+
852
+ Unlike a filter-bank ensemble, this does not instantiate separate
853
+ fundamental/harmonic convolution branches. Deterministic masks only
854
+ expose progressively higher-frequency evidence to the same 1-D local
855
+ mixer, preserving the compact deployment graph.
856
+ """
857
+ if not self.spectral_subband_low_hz:
858
+ return spectral_features
859
+ with torch.autocast(device_type=spectrum.device.type, enabled=False):
860
+ band = spectrum.to(torch.complex64)[..., self.spectrum.start:self.spectrum.end]
861
+ features = []
862
+ for low_hz in self.spectral_subband_low_hz:
863
+ mask = (self.spectral_bin_hz >= low_hz).to(
864
+ device=band.device, dtype=band.real.dtype,
865
+ ).view(1, 1, -1)
866
+ masked = band * mask
867
+ features.append(torch.cat((
868
+ masked.real,
869
+ masked.imag,
870
+ torch.log1p(10.0 * masked.abs()),
871
+ ), dim=1))
872
+ return torch.cat(features, dim=1).to(dtype=spectral_features.dtype)
873
+
874
+ def _locally_mixed_signal(
875
+ self, x: torch.Tensor, *, use_local: bool,
876
+ ) -> torch.Tensor:
877
+ if not use_local or self.local_domain not in {"temporal", "dual"}:
878
+ return x
879
+ for block in self.input_local_stage1:
880
+ x = block(x)
881
+ for block in self.input_local_stage2:
882
+ x = block(x)
883
+ return x
884
+
885
+ def _duration_conditioning(
886
+ self, *, samples: int, device: torch.device, dtype: torch.dtype,
887
+ ) -> torch.Tensor:
888
+ if self.duration_projection is None:
889
+ raise RuntimeError("duration conditioning is disabled")
890
+ duration = torch.tensor(
891
+ float(samples) / self.sample_rate, device=device, dtype=torch.float32,
892
+ )
893
+ frequencies = self.candidate_frequency_hz.to(device=device)
894
+ cycles = frequencies * duration
895
+ features = torch.stack(
896
+ (
897
+ (duration / (1.0 + duration)).expand_as(cycles),
898
+ torch.log1p(duration).expand_as(cycles),
899
+ cycles / (1.0 + cycles),
900
+ torch.log1p(cycles),
901
+ ),
902
+ dim=-1,
903
+ )
904
+ return self.duration_projection(features.to(dtype=dtype)).unsqueeze(0)
905
+
906
+ def _locally_mix_candidates(
907
+ self, candidates: torch.Tensor, *, use_local: bool,
908
+ ) -> torch.Tensor:
909
+ """Contrast neighboring stimulus candidates in physical frequency order.
910
+
911
+ Dataset class indices need not be frequency-sorted. The local mixer
912
+ therefore operates on a derived frequency/phase order and restores the
913
+ original class order before attention and scoring.
914
+ """
915
+ if not use_local or self.candidate_local_stage is None:
916
+ return candidates
917
+ ordered = candidates.index_select(1, self.candidate_frequency_order)
918
+ ordered = self.candidate_local_stage(ordered.transpose(1, 2)).transpose(1, 2)
919
+ return ordered.index_select(1, self.candidate_frequency_inverse_order)
920
+
921
+ def harmonic_attention_probabilities(
922
+ self,
923
+ x: torch.Tensor,
924
+ *,
925
+ use_harmonic_bias: bool = True,
926
+ ) -> torch.Tensor:
927
+ """Expose the late cross-attention map for mechanism analysis only."""
928
+ local_x = self._locally_mixed_signal(x, use_local=True)
929
+ spectrum, spectral_features = self.spectrum(local_x)
930
+ spectral_features = self._spectral_stem_features(spectrum, spectral_features)
931
+ spectral = self._spectral_tokens(spectral_features, use_local=True)
932
+ temporal = self.temporal_candidate_features(local_x)
933
+ frequency = self.spectral_candidate_features(spectrum, output_dtype=x.dtype)
934
+ candidates = self.candidate_fusion(torch.cat((temporal, frequency), dim=-1))
935
+ if self.duration_projection is not None:
936
+ candidates = candidates + self._duration_conditioning(
937
+ samples=x.shape[-1], device=x.device, dtype=candidates.dtype,
938
+ )
939
+ if self.candidate_local_mixing_placement == "pre_cross":
940
+ candidates = self._locally_mix_candidates(candidates, use_local=True)
941
+ return self.fusion_attention.attention_probabilities(
942
+ candidates,
943
+ spectral,
944
+ use_harmonic_bias=use_harmonic_bias,
945
+ )
946
+
947
+ def forward_mode(self, x: torch.Tensor, mode: str = "full") -> torch.Tensor:
948
+ if mode not in set(HARMONIC_FOLD_MODES):
949
+ raise ValueError(f"unsupported HarmonicFold v4 mode: {mode}")
950
+ local_x = self._locally_mixed_signal(x, use_local=mode != "no_local")
951
+ spectrum, spectral_features = self.spectrum(local_x)
952
+ spectral_features = self._spectral_stem_features(spectrum, spectral_features)
953
+ spectral = self._spectral_tokens(
954
+ spectral_features, use_local=mode != "no_local",
955
+ )
956
+ temporal = self.temporal_candidate_features(local_x)
957
+ frequency = self.spectral_candidate_features(spectrum, output_dtype=x.dtype)
958
+ candidates = self.candidate_fusion(torch.cat((temporal, frequency), dim=-1))
959
+ if self.duration_projection is not None:
960
+ candidates = candidates + self._duration_conditioning(
961
+ samples=x.shape[-1], device=x.device, dtype=candidates.dtype,
962
+ )
963
+ if self.candidate_local_mixing_placement == "pre_cross":
964
+ candidates = self._locally_mix_candidates(
965
+ candidates, use_local=mode != "no_local",
966
+ )
967
+ if mode != "no_attention":
968
+ base_candidates = candidates
969
+ refined = candidates
970
+ if mode != "no_cross_attention":
971
+ refined = self.fusion_attention(
972
+ refined,
973
+ spectral,
974
+ use_harmonic_bias=mode != "no_harmonic_bias",
975
+ )
976
+ if self.candidate_local_mixing_placement == "post_cross":
977
+ refined = self._locally_mix_candidates(
978
+ refined, use_local=mode != "no_local",
979
+ )
980
+ if mode != "no_candidate_attention":
981
+ refined = self.candidate_attention(refined)
982
+ if self.decision_reliability_gate is not None:
983
+ base_logits = self.score_head(base_candidates).squeeze(-1)
984
+ refined_logits = self.score_head(refined).squeeze(-1)
985
+ base_probabilities = torch.softmax(base_logits.float(), dim=-1)
986
+ refined_probabilities = torch.softmax(refined_logits.float(), dim=-1)
987
+ normalizer = math.log(float(base_logits.shape[-1]))
988
+ base_entropy = -(
989
+ base_probabilities * base_probabilities.clamp_min(1e-8).log()
990
+ ).sum(dim=-1) / normalizer
991
+ refined_entropy = -(
992
+ refined_probabilities * refined_probabilities.clamp_min(1e-8).log()
993
+ ).sum(dim=-1) / normalizer
994
+ base_top2 = base_probabilities.topk(k=2, dim=-1).values
995
+ refined_top2 = refined_probabilities.topk(k=2, dim=-1).values
996
+ midpoint = 0.5 * (base_probabilities + refined_probabilities)
997
+ js_divergence = 0.5 * (
998
+ (
999
+ base_probabilities
1000
+ * (
1001
+ base_probabilities.clamp_min(1e-8).log()
1002
+ - midpoint.clamp_min(1e-8).log()
1003
+ )
1004
+ ).sum(dim=-1)
1005
+ + (
1006
+ refined_probabilities
1007
+ * (
1008
+ refined_probabilities.clamp_min(1e-8).log()
1009
+ - midpoint.clamp_min(1e-8).log()
1010
+ )
1011
+ ).sum(dim=-1)
1012
+ )
1013
+ reliability_features = torch.stack(
1014
+ (
1015
+ 1.0 - base_entropy,
1016
+ 1.0 - refined_entropy,
1017
+ base_top2[:, 0] - base_top2[:, 1],
1018
+ refined_top2[:, 0] - refined_top2[:, 1],
1019
+ 1.0 - js_divergence / math.log(2.0),
1020
+ ),
1021
+ dim=-1,
1022
+ ).to(dtype=base_logits.dtype)
1023
+ reliability = torch.sigmoid(
1024
+ self.decision_reliability_gate(reliability_features)
1025
+ )
1026
+ return base_logits + reliability * (refined_logits - base_logits)
1027
+ if self.evidence_reliability_gate is None:
1028
+ candidates = refined
1029
+ else:
1030
+ agreement_features = torch.cat(
1031
+ (temporal, frequency, (temporal - frequency).abs()), dim=-1,
1032
+ )
1033
+ reliability = torch.sigmoid(
1034
+ self.evidence_reliability_gate(agreement_features)
1035
+ )
1036
+ candidates = candidates + reliability * (refined - candidates)
1037
+ elif self.candidate_local_mixing_placement == "post_cross":
1038
+ candidates = self._locally_mix_candidates(
1039
+ candidates, use_local=mode != "no_local",
1040
+ )
1041
+ return self.score_head(candidates).squeeze(-1)
1042
+
1043
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
1044
+ return self.forward_mode(x, "full")
1045
+
1046
+
1047
+ class SpectralFusionDecoder(nn.Module):
1048
+ """local-to-global complex-spectrum hybrid for calibration-free SSVEP.
1049
+
1050
+ Local re-parameterizable mixing operates on the frequency axis before
1051
+ token reduction and late attention. Candidate scoring is shared across
1052
+ datasets, so the architecture is not tied to one fixed label count.
1053
+ """
1054
+
1055
+ def __init__(
1056
+ self,
1057
+ channels: int,
1058
+ sample_rate: int,
1059
+ class_frequencies: list[float] | tuple[float, ...],
1060
+ class_phases: list[float] | tuple[float, ...] | None = None,
1061
+ *,
1062
+ width: int = 48,
1063
+ local_depths: tuple[int, int] = (1, 1),
1064
+ attention_depth: int = 1,
1065
+ heads: int = 4,
1066
+ harmonics: int = 4,
1067
+ neighborhood_bins: int = 2,
1068
+ spectral_resolution_hz: float = 0.25,
1069
+ spectral_band_hz: tuple[float, float] = (6.0, 45.0),
1070
+ dropout: float = 0.1,
1071
+ ) -> None:
1072
+ super().__init__()
1073
+ if width % heads:
1074
+ raise ValueError("width must be divisible by heads")
1075
+ dim = width * 2
1076
+ if dim % heads:
1077
+ raise ValueError("2 * width must be divisible by heads")
1078
+ self.spectrum = ComplexSpectrumFrontEnd(
1079
+ sample_rate,
1080
+ resolution_hz=spectral_resolution_hz,
1081
+ band_hz=spectral_band_hz,
1082
+ )
1083
+ self.channel_projection = nn.Sequential(
1084
+ nn.Conv1d(channels * 3, width, 1, bias=False),
1085
+ nn.BatchNorm1d(width),
1086
+ nn.GELU(),
1087
+ )
1088
+ self.local_stage1 = nn.ModuleList(
1089
+ RepEEGBlock(width, kernel_size=9, dropout=dropout, gate_logit_init=0.0)
1090
+ for _ in range(local_depths[0])
1091
+ )
1092
+ self.downsample = DepthwiseDownsample1d(width, dim, stride=2)
1093
+ self.local_stage2 = nn.ModuleList(
1094
+ RepEEGBlock(dim, kernel_size=5, dropout=dropout, gate_logit_init=0.0)
1095
+ for _ in range(local_depths[1])
1096
+ )
1097
+ self.spectral_attention = nn.ModuleList(
1098
+ AttentionBlock(dim, heads=heads, dropout=dropout)
1099
+ for _ in range(attention_depth)
1100
+ )
1101
+ self.candidate_features = SpectralCandidateExtractor(
1102
+ channels,
1103
+ dim,
1104
+ sample_rate,
1105
+ class_frequencies,
1106
+ class_phases,
1107
+ resolution_hz=spectral_resolution_hz,
1108
+ harmonics=harmonics,
1109
+ neighborhood_bins=neighborhood_bins,
1110
+ )
1111
+ self.temporal_candidate_features = CandidateFeatureExtractor(
1112
+ channels,
1113
+ dim,
1114
+ sample_rate,
1115
+ class_frequencies,
1116
+ class_phases,
1117
+ harmonics=harmonics,
1118
+ segments=4,
1119
+ )
1120
+ self.candidate_fusion = nn.Linear(dim * 2, dim)
1121
+ # Begin from the already validated segmented-demodulation path. The
1122
+ # complex-spectrum candidate path must earn a non-zero contribution
1123
+ # during training rather than perturbing the baseline at initialization.
1124
+ nn.init.zeros_(self.candidate_fusion.weight)
1125
+ nn.init.zeros_(self.candidate_fusion.bias)
1126
+ with torch.no_grad():
1127
+ self.candidate_fusion.weight[:, :dim].copy_(torch.eye(dim))
1128
+ self.cross_norm = nn.LayerNorm(dim)
1129
+ self.cross_attention = nn.MultiheadAttention(
1130
+ dim, heads, dropout=dropout, batch_first=True,
1131
+ )
1132
+ # Likewise, the global spectrum starts as a safe residual branch.
1133
+ nn.init.zeros_(self.cross_attention.out_proj.weight)
1134
+ nn.init.zeros_(self.cross_attention.out_proj.bias)
1135
+ self.candidate_attention = AttentionBlock(dim, heads=heads, dropout=dropout)
1136
+ self.pooled_context = nn.Sequential(nn.LayerNorm(dim), nn.Linear(dim, dim))
1137
+ spectral_bins = self.spectrum.end - self.spectrum.start
1138
+ reduced_bins = (spectral_bins + 1) // 2
1139
+ self.global_score_head = nn.Sequential(
1140
+ nn.LayerNorm(dim),
1141
+ nn.Flatten(),
1142
+ nn.Dropout(dropout),
1143
+ nn.Linear(reduced_bins * dim, len(class_frequencies)),
1144
+ )
1145
+ nn.init.normal_(self.global_score_head[-1].weight, mean=0.0, std=0.01)
1146
+ nn.init.zeros_(self.global_score_head[-1].bias)
1147
+ self.global_score_scale = nn.Parameter(torch.tensor(-2.0))
1148
+ self.score_head = nn.Sequential(
1149
+ nn.LayerNorm(dim),
1150
+ nn.Linear(dim, dim // 2),
1151
+ nn.GELU(),
1152
+ nn.Dropout(dropout),
1153
+ nn.Linear(dim // 2, 1),
1154
+ )
1155
+ self.reference_prior = AnalyticReferencePrior(
1156
+ sample_rate, class_frequencies, harmonics=harmonics,
1157
+ )
1158
+ self.prior_scale = nn.Parameter(torch.tensor(1.0))
1159
+
1160
+ def _spectral_tokens(
1161
+ self, spectral_features: torch.Tensor, *, use_local: bool,
1162
+ ) -> torch.Tensor:
1163
+ x = self.channel_projection(spectral_features)
1164
+ if use_local:
1165
+ for block in self.local_stage1:
1166
+ x = block(x)
1167
+ x = self.downsample(x)
1168
+ if use_local:
1169
+ for block in self.local_stage2:
1170
+ x = block(x)
1171
+ return x.transpose(1, 2)
1172
+
1173
+ def forward_mode(self, x: torch.Tensor, mode: str = "full") -> torch.Tensor:
1174
+ if mode not in FUSION_MODES:
1175
+ raise ValueError(f"unsupported HarmonicFold mode: {mode}")
1176
+ spectrum, spectral_features = self.spectrum(x)
1177
+ spectral = self._spectral_tokens(
1178
+ spectral_features, use_local=mode != "no_local",
1179
+ )
1180
+ temporal_candidates = self.temporal_candidate_features(x)
1181
+ spectral_candidates = self.candidate_features(spectrum, output_dtype=x.dtype)
1182
+ candidates = self.candidate_fusion(
1183
+ torch.cat((temporal_candidates, spectral_candidates), dim=-1)
1184
+ )
1185
+ if mode == "no_attention":
1186
+ candidates = candidates + self.pooled_context(spectral.mean(dim=1)).unsqueeze(1)
1187
+ else:
1188
+ for block in self.spectral_attention:
1189
+ spectral = block(spectral)
1190
+ query = self.cross_norm(candidates)
1191
+ candidates = candidates + self.cross_attention(
1192
+ query, spectral, spectral, need_weights=False,
1193
+ )[0]
1194
+ candidates = self.candidate_attention(candidates)
1195
+ logits = self.score_head(candidates).squeeze(-1)
1196
+ logits = logits + F.softplus(self.global_score_scale) * self.global_score_head(spectral)
1197
+ if mode != "no_evidence":
1198
+ logits = logits + F.softplus(self.prior_scale) * self.reference_prior(x)
1199
+ return logits
1200
+
1201
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
1202
+ return self.forward_mode(x, "full")
1203
+
1204
+
1205
+ class TemporalFusionDecoder(nn.Module):
1206
+ """Original 1-D local-first, attention-late SSVEP hybrid.
1207
+
1208
+ The implementation follows a local-to-global design constraint, but it does
1209
+ not reuse Apple source code, weights, image operators, or model outputs.
1210
+ """
1211
+
1212
+ def __init__(
1213
+ self,
1214
+ channels: int,
1215
+ sample_rate: int,
1216
+ class_frequencies: list[float] | tuple[float, ...],
1217
+ class_phases: list[float] | tuple[float, ...] | None = None,
1218
+ *,
1219
+ width: int = 48,
1220
+ local_depths: tuple[int, int] = (2, 2),
1221
+ attention_depth: int = 1,
1222
+ heads: int = 4,
1223
+ harmonics: int = 4,
1224
+ demod_segments: int = 4,
1225
+ dropout: float = 0.1,
1226
+ ):
1227
+ super().__init__()
1228
+ if width % heads:
1229
+ raise ValueError("width must be divisible by heads")
1230
+ dim = width * 2
1231
+ if dim % heads:
1232
+ raise ValueError("2 * width must be divisible by heads")
1233
+ self.channel_projection = nn.Sequential(
1234
+ nn.Conv1d(channels, width, 1, bias=False),
1235
+ nn.BatchNorm1d(width),
1236
+ nn.GELU(),
1237
+ )
1238
+ self.stem = DepthwiseDownsample1d(width, width, stride=2)
1239
+ self.local_stage1 = nn.ModuleList(
1240
+ RepEEGBlock(width, kernel_size=15, dropout=dropout)
1241
+ for _ in range(local_depths[0])
1242
+ )
1243
+ self.downsample = DepthwiseDownsample1d(width, dim, stride=4)
1244
+ self.local_stage2 = nn.ModuleList(
1245
+ RepEEGBlock(dim, kernel_size=7, dropout=dropout)
1246
+ for _ in range(local_depths[1])
1247
+ )
1248
+ self.temporal_attention = nn.ModuleList(
1249
+ AttentionBlock(dim, heads=heads, dropout=dropout)
1250
+ for _ in range(attention_depth)
1251
+ )
1252
+ self.candidate_features = CandidateFeatureExtractor(
1253
+ channels,
1254
+ dim,
1255
+ sample_rate,
1256
+ class_frequencies,
1257
+ class_phases,
1258
+ harmonics=harmonics,
1259
+ segments=demod_segments,
1260
+ )
1261
+ self.cross_norm = nn.LayerNorm(dim)
1262
+ self.cross_attention = nn.MultiheadAttention(
1263
+ dim, heads, dropout=dropout, batch_first=True,
1264
+ )
1265
+ self.candidate_attention = AttentionBlock(dim, heads=heads, dropout=dropout)
1266
+ self.pooled_context = nn.Sequential(nn.LayerNorm(dim), nn.Linear(dim, dim))
1267
+ self.score_head = nn.Sequential(
1268
+ nn.LayerNorm(dim),
1269
+ nn.Linear(dim, dim // 2),
1270
+ nn.GELU(),
1271
+ nn.Dropout(dropout),
1272
+ nn.Linear(dim // 2, 1),
1273
+ )
1274
+ self.reference_prior = AnalyticReferencePrior(
1275
+ sample_rate, class_frequencies, harmonics=harmonics,
1276
+ )
1277
+ self.prior_scale = nn.Parameter(torch.tensor(1.0))
1278
+
1279
+ def _temporal_tokens(self, x: torch.Tensor, *, use_local: bool) -> torch.Tensor:
1280
+ x = self.stem(self.channel_projection(x))
1281
+ if use_local:
1282
+ for block in self.local_stage1:
1283
+ x = block(x)
1284
+ x = self.downsample(x)
1285
+ if use_local:
1286
+ for block in self.local_stage2:
1287
+ x = block(x)
1288
+ return x.transpose(1, 2)
1289
+
1290
+ def forward_mode(self, x: torch.Tensor, mode: str = "full") -> torch.Tensor:
1291
+ if mode not in FUSION_MODES:
1292
+ raise ValueError(f"unsupported HarmonicFold mode: {mode}")
1293
+ temporal = self._temporal_tokens(x, use_local=mode != "no_local")
1294
+ candidates = self.candidate_features(x)
1295
+ if mode == "no_attention":
1296
+ candidates = candidates + self.pooled_context(temporal.mean(dim=1)).unsqueeze(1)
1297
+ else:
1298
+ for block in self.temporal_attention:
1299
+ temporal = block(temporal)
1300
+ query = self.cross_norm(candidates)
1301
+ candidates = candidates + self.cross_attention(
1302
+ query, temporal, temporal, need_weights=False,
1303
+ )[0]
1304
+ candidates = self.candidate_attention(candidates)
1305
+ logits = self.score_head(candidates).squeeze(-1)
1306
+ if mode != "no_evidence":
1307
+ logits = logits + F.softplus(self.prior_scale) * self.reference_prior(x)
1308
+ return logits
1309
+
1310
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
1311
+ return self.forward_mode(x, "full")
harmonicfoldnet/model.py ADDED
@@ -0,0 +1,414 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+
5
+ import torch
6
+ import torch.nn.functional as F
7
+ from torch import nn
8
+
9
+
10
+ class ConvNormAct(nn.Sequential):
11
+ def __init__(self, cin: int, cout: int, kernel: int, stride: int = 1, groups: int = 1):
12
+ super().__init__(
13
+ nn.Conv1d(cin, cout, kernel, stride=stride, padding=kernel // 2,
14
+ groups=groups, bias=False),
15
+ nn.BatchNorm1d(cout),
16
+ nn.GELU(),
17
+ )
18
+
19
+
20
+ class LocalMixer(nn.Module):
21
+ """Multi-scale depthwise temporal mixing followed by channel mixing."""
22
+ def __init__(self, dim: int, expansion: int = 2, dropout: float = 0.0):
23
+ super().__init__()
24
+ self.dw3 = nn.Conv1d(dim, dim, 3, padding=1, groups=dim, bias=False)
25
+ self.dw7 = nn.Conv1d(dim, dim, 7, padding=3, groups=dim, bias=False)
26
+ self.norm = nn.BatchNorm1d(dim)
27
+ hidden = dim * expansion
28
+ self.ffn = nn.Sequential(
29
+ nn.Conv1d(dim, hidden, 1), nn.GELU(), nn.Dropout(dropout),
30
+ nn.Conv1d(hidden, dim, 1), nn.Dropout(dropout),
31
+ )
32
+
33
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
34
+ x = x + self.norm(self.dw3(x) + self.dw7(x))
35
+ return x + self.ffn(x)
36
+
37
+
38
+ def _fuse_conv_bn(conv: nn.Conv1d, bn: nn.BatchNorm1d) -> tuple[torch.Tensor, torch.Tensor]:
39
+ weight = conv.weight
40
+ bias = conv.bias if conv.bias is not None else torch.zeros(
41
+ weight.shape[0], device=weight.device, dtype=weight.dtype,
42
+ )
43
+ scale = bn.weight / torch.sqrt(bn.running_var + bn.eps)
44
+ return weight * scale.reshape(-1, 1, 1), bn.bias + (bias - bn.running_mean) * scale
45
+
46
+
47
+ class RepTokenMixer1d(nn.Module):
48
+ """Train with three paths and fuse them into one depthwise Conv1d for deployment."""
49
+
50
+ def __init__(self, dim: int, kernel_size: int = 7):
51
+ super().__init__()
52
+ if kernel_size % 2 == 0:
53
+ raise ValueError("kernel_size must be odd")
54
+ self.dim = dim
55
+ self.kernel_size = kernel_size
56
+ self.branch_large = nn.Sequential(
57
+ nn.Conv1d(dim, dim, kernel_size, padding=kernel_size // 2,
58
+ groups=dim, bias=False),
59
+ nn.BatchNorm1d(dim),
60
+ )
61
+ self.branch_pointwise = nn.Sequential(
62
+ nn.Conv1d(dim, dim, 1, groups=dim, bias=False), nn.BatchNorm1d(dim),
63
+ )
64
+ self.branch_identity = nn.BatchNorm1d(dim)
65
+ self.deploy_conv: nn.Conv1d | None = None
66
+
67
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
68
+ if self.deploy_conv is not None:
69
+ return self.deploy_conv(x)
70
+ return self.branch_large(x) + self.branch_pointwise(x) + self.branch_identity(x)
71
+
72
+ def reparameterize(self) -> None:
73
+ if self.deploy_conv is not None:
74
+ return
75
+ base = self.branch_large[0]
76
+ large_w, large_b = _fuse_conv_bn(base, self.branch_large[1])
77
+ point_w, point_b = _fuse_conv_bn(self.branch_pointwise[0], self.branch_pointwise[1])
78
+ point_w = F.pad(point_w, [self.kernel_size // 2] * 2)
79
+ identity = nn.Conv1d(
80
+ self.dim, self.dim, self.kernel_size, padding=self.kernel_size // 2,
81
+ groups=self.dim, bias=False,
82
+ ).to(device=base.weight.device, dtype=base.weight.dtype)
83
+ identity.weight.data.zero_()
84
+ identity.weight.data[:, 0, self.kernel_size // 2] = 1.0
85
+ identity_w, identity_b = _fuse_conv_bn(identity, self.branch_identity)
86
+ deploy = nn.Conv1d(
87
+ self.dim, self.dim, self.kernel_size, padding=self.kernel_size // 2,
88
+ groups=self.dim, bias=True,
89
+ ).to(device=base.weight.device, dtype=base.weight.dtype)
90
+ deploy.weight.data.copy_(large_w + point_w + identity_w)
91
+ deploy.bias.data.copy_(large_b + point_b + identity_b)
92
+ self.deploy_conv = deploy
93
+ del self.branch_large
94
+ del self.branch_pointwise
95
+ del self.branch_identity
96
+
97
+
98
+ class RepLocalMixer(nn.Module):
99
+ def __init__(self, dim: int, expansion: int = 2, dropout: float = 0.0):
100
+ super().__init__()
101
+ self.token_mixer = RepTokenMixer1d(dim, kernel_size=7)
102
+ hidden = dim * expansion
103
+ self.ffn = nn.Sequential(
104
+ nn.Conv1d(dim, hidden, 1), nn.GELU(), nn.Dropout(dropout),
105
+ nn.Conv1d(hidden, dim, 1), nn.Dropout(dropout),
106
+ )
107
+
108
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
109
+ x = F.gelu(self.token_mixer(x))
110
+ return x + self.ffn(x)
111
+
112
+
113
+ class AttentionBlock(nn.Module):
114
+ def __init__(self, dim: int, heads: int, dropout: float):
115
+ super().__init__()
116
+ self.norm1 = nn.LayerNorm(dim)
117
+ self.attn = nn.MultiheadAttention(dim, heads, dropout=dropout, batch_first=True)
118
+ self.norm2 = nn.LayerNorm(dim)
119
+ self.ffn = nn.Sequential(
120
+ nn.Linear(dim, dim * 3), nn.GELU(), nn.Dropout(dropout),
121
+ nn.Linear(dim * 3, dim), nn.Dropout(dropout),
122
+ )
123
+
124
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
125
+ z = self.norm1(x)
126
+ x = x + self.attn(z, z, z, need_weights=False)[0]
127
+ return x + self.ffn(self.norm2(x))
128
+
129
+
130
+ class LegacyLocalAttentionNet(nn.Module):
131
+ """EEG local-first, attention-late network inspired by HarmonicFold's design logic.
132
+
133
+ It is an original 1-D EEG implementation and does not use Apple weights or code.
134
+ """
135
+ def __init__(
136
+ self, channels: int = 8, classes: int = 4, width: int = 64,
137
+ local_depth: int = 3, attention_depth: int = 2,
138
+ heads: int = 4, dropout: float = 0.15,
139
+ ):
140
+ super().__init__()
141
+ self.channel_projection = nn.Sequential(
142
+ nn.Conv1d(channels, width, 1, bias=False), nn.BatchNorm1d(width), nn.GELU(),
143
+ )
144
+ self.stem = ConvNormAct(width, width, 9, stride=2, groups=width)
145
+ self.local = nn.Sequential(*[
146
+ LocalMixer(width, expansion=2, dropout=dropout) for _ in range(local_depth)
147
+ ])
148
+ self.downsample = ConvNormAct(width, width * 2, 5, stride=4)
149
+ dim = width * 2
150
+ self.global_blocks = nn.Sequential(*[
151
+ AttentionBlock(dim, heads=heads, dropout=dropout) for _ in range(attention_depth)
152
+ ])
153
+ self.head = nn.Sequential(nn.LayerNorm(dim), nn.Linear(dim, classes))
154
+
155
+ def forward_features(self, x: torch.Tensor) -> torch.Tensor:
156
+ x = self.channel_projection(x)
157
+ x = self.local(self.stem(x))
158
+ x = self.downsample(x).transpose(1, 2)
159
+ x = self.global_blocks(x)
160
+ return x.mean(dim=1)
161
+
162
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
163
+ return self.head(self.forward_features(x))
164
+
165
+
166
+ def parameter_count(model: nn.Module) -> int:
167
+ return sum(p.numel() for p in model.parameters() if p.requires_grad)
168
+
169
+
170
+ def reparameterize_model(model: nn.Module) -> nn.Module:
171
+ deploy_model = copy.deepcopy(model).eval()
172
+ for module in deploy_model.modules():
173
+ if isinstance(module, RepTokenMixer1d):
174
+ module.reparameterize()
175
+ return deploy_model
176
+
177
+
178
+ class _LocalAttentionEncoder(nn.Module):
179
+ def __init__(
180
+ self, channels: int, width: int, local_depth: int,
181
+ attention_depth: int, heads: int, dropout: float,
182
+ ):
183
+ super().__init__()
184
+ self.projection = nn.Sequential(
185
+ nn.Conv1d(channels, width, 1, bias=False),
186
+ nn.BatchNorm1d(width),
187
+ nn.GELU(),
188
+ )
189
+ self.stem = ConvNormAct(width, width, 9, stride=2, groups=width)
190
+ self.local = nn.Sequential(*[
191
+ RepLocalMixer(width, expansion=2, dropout=dropout)
192
+ for _ in range(local_depth)
193
+ ])
194
+ self.downsample = ConvNormAct(width, width * 2, 5, stride=4)
195
+ self.global_blocks = nn.Sequential(*[
196
+ AttentionBlock(width * 2, heads=heads, dropout=dropout)
197
+ for _ in range(attention_depth)
198
+ ])
199
+
200
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
201
+ x = self.projection(x)
202
+ x = self.local(self.stem(x))
203
+ x = self.downsample(x).transpose(1, 2)
204
+ return self.global_blocks(x).mean(dim=1)
205
+
206
+
207
+ class LegacyFusionNet(nn.Module):
208
+ """Local-first time/frequency EEG encoder with attention applied late.
209
+
210
+ The spectral branch is frequency-agnostic: it consumes a normalized band
211
+ rather than embedding a fixed set of SSVEP labels, so the same architecture
212
+ can be trained on datasets with different target frequencies.
213
+ """
214
+
215
+ def __init__(
216
+ self, channels: int = 8, classes: int = 4, sample_rate: int = 250,
217
+ width: int = 48, local_depth: int = 2, attention_depth: int = 1,
218
+ heads: int = 4, dropout: float = 0.15, spectral_bins: int = 128,
219
+ spectral_band_hz: tuple[float, float] = (0.5, 45.0),
220
+ class_frequencies: list[float] | tuple[float, ...] | None = None,
221
+ class_phases: list[float] | tuple[float, ...] | None = None,
222
+ evidence_harmonics: int = 2,
223
+ evidence_mode: str = "power",
224
+ ):
225
+ super().__init__()
226
+ self.sample_rate = float(sample_rate)
227
+ self.spectral_bins = int(spectral_bins)
228
+ self.spectral_band_hz = tuple(float(v) for v in spectral_band_hz)
229
+ self.evidence_harmonics = int(evidence_harmonics)
230
+ if evidence_mode not in {"power", "cca_phase", "phase_demod"}:
231
+ raise ValueError(f"unsupported evidence mode: {evidence_mode}")
232
+ self.evidence_mode = evidence_mode
233
+ self._reference_q_cache: dict[tuple[int, str], torch.Tensor] = {}
234
+ frequencies = [] if class_frequencies is None else list(class_frequencies)
235
+ if frequencies and len(frequencies) != classes:
236
+ raise ValueError("class_frequencies must contain one frequency per class")
237
+ self.register_buffer(
238
+ "class_frequencies",
239
+ torch.tensor(frequencies, dtype=torch.float32), persistent=True,
240
+ )
241
+ phases = [] if class_phases is None else list(class_phases)
242
+ if phases and len(phases) != classes:
243
+ raise ValueError("class_phases must contain one phase per class")
244
+ if evidence_mode == "phase_demod" and len(phases) != classes:
245
+ raise ValueError("phase_demod requires class phases")
246
+ self.register_buffer(
247
+ "class_phases", torch.tensor(phases, dtype=torch.float32), persistent=True,
248
+ )
249
+ self.time_encoder = _LocalAttentionEncoder(
250
+ channels, width, local_depth, attention_depth, heads, dropout,
251
+ )
252
+ self.frequency_encoder = _LocalAttentionEncoder(
253
+ channels, width, local_depth, attention_depth, heads, dropout,
254
+ )
255
+ dim = width * 2
256
+ self.fusion = nn.Sequential(
257
+ nn.LayerNorm(dim * 2),
258
+ nn.Linear(dim * 2, dim),
259
+ nn.GELU(),
260
+ nn.Dropout(dropout),
261
+ )
262
+ self.head = nn.Sequential(nn.LayerNorm(dim), nn.Linear(dim, classes))
263
+ evidence_features = channels * self.evidence_harmonics * (3 if evidence_mode == "phase_demod" else 1)
264
+ evidence_hidden = max(32 if evidence_mode == "phase_demod" else 16, channels * 2)
265
+ self.evidence_residual = nn.Sequential(
266
+ nn.Linear(evidence_features, evidence_hidden),
267
+ nn.GELU(),
268
+ nn.Linear(evidence_hidden, 1),
269
+ ) if frequencies else None
270
+ self.evidence_scale = nn.Parameter(torch.tensor(1.5)) if frequencies else None
271
+ if self.evidence_residual is not None and self.evidence_mode in {"cca_phase", "phase_demod"}:
272
+ nn.init.zeros_(self.evidence_residual[-1].weight)
273
+ nn.init.zeros_(self.evidence_residual[-1].bias)
274
+
275
+ def _spectrum(self, x: torch.Tensor) -> torch.Tensor:
276
+ spectrum = torch.fft.rfft(x, dim=-1, norm="ortho").abs().square()
277
+ nyquist_bins = spectrum.shape[-1] - 1
278
+ lo = max(0, int(round(self.spectral_band_hz[0] * 2 * nyquist_bins / self.sample_rate)))
279
+ hi = min(spectrum.shape[-1], int(round(self.spectral_band_hz[1] * 2 * nyquist_bins / self.sample_rate)) + 1)
280
+ spectrum = torch.log(spectrum[..., lo:hi].clamp_min(1e-8))
281
+ # Remove subject/session gain and the smooth 1/f spectral envelope.
282
+ smooth = F.avg_pool1d(spectrum, kernel_size=9, stride=1, padding=4)
283
+ spectrum = spectrum - smooth
284
+ spectrum = F.interpolate(
285
+ spectrum, size=self.spectral_bins, mode="linear", align_corners=False,
286
+ )
287
+ return spectrum
288
+
289
+ def forward_features(self, x: torch.Tensor) -> torch.Tensor:
290
+ time_features = self.time_encoder(x)
291
+ frequency_features = self.frequency_encoder(self._spectrum(x))
292
+ return self.fusion(torch.cat([time_features, frequency_features], dim=-1))
293
+
294
+ def _fusion_logits(self, x: torch.Tensor, mode: str) -> torch.Tensor:
295
+ if mode not in {"full", "fusion_only", "time_only", "frequency_only"}:
296
+ raise ValueError(f"unsupported fusion mode: {mode}")
297
+ time_features = self.time_encoder(x) if mode != "frequency_only" else None
298
+ frequency_features = (
299
+ self.frequency_encoder(self._spectrum(x)) if mode != "time_only" else None
300
+ )
301
+ if time_features is None:
302
+ time_features = torch.zeros_like(frequency_features)
303
+ if frequency_features is None:
304
+ frequency_features = torch.zeros_like(time_features)
305
+ fused = self.fusion(torch.cat([time_features, frequency_features], dim=-1))
306
+ return self.head(fused)
307
+
308
+ def candidate_evidence(self, x: torch.Tensor) -> torch.Tensor | None:
309
+ if not self.class_frequencies.numel():
310
+ return None
311
+ if self.evidence_mode == "phase_demod":
312
+ base = self.reference_correlation_evidence(x)
313
+ features = self.phase_aligned_demodulation(x)
314
+ else:
315
+ spectrum = torch.fft.rfft(x, dim=-1, norm="ortho").abs().square()
316
+ max_bin = spectrum.shape[-1] - 1
317
+ harmonic_numbers = torch.arange(
318
+ 1, self.evidence_harmonics + 1, device=x.device, dtype=x.dtype,
319
+ )
320
+ target_hz = self.class_frequencies.to(dtype=x.dtype)[:, None] * harmonic_numbers[None, :]
321
+ indices = torch.round(target_hz * (2 * max_bin / self.sample_rate)).long().clamp_(0, max_bin)
322
+ selected = spectrum[..., indices.reshape(-1)]
323
+ selected = selected.reshape(
324
+ x.shape[0], x.shape[1], len(self.class_frequencies), self.evidence_harmonics,
325
+ )
326
+ if self.evidence_mode == "cca_phase":
327
+ base = self.reference_correlation_evidence(x)
328
+ else:
329
+ base = torch.log(selected.mean(dim=(1, 3)).clamp_min(1e-8))
330
+ features = torch.log(selected.clamp_min(1e-8)).permute(0, 2, 1, 3).flatten(2)
331
+ base = (base - base.mean(dim=1, keepdim=True)) / base.std(dim=1, keepdim=True).clamp_min(1e-5)
332
+ features = (features - features.mean(dim=1, keepdim=True)) / features.std(dim=1, keepdim=True).clamp_min(1e-5)
333
+ return base + self.evidence_residual(features).squeeze(-1)
334
+
335
+ def phase_aligned_demodulation(self, x: torch.Tensor) -> torch.Tensor:
336
+ with torch.autocast(device_type=x.device.type, enabled=False):
337
+ signal = x.float()
338
+ samples = signal.shape[-1]
339
+ time_axis = torch.arange(samples, device=x.device, dtype=torch.float32) / self.sample_rate
340
+ harmonics = torch.arange(
341
+ 1, self.evidence_harmonics + 1, device=x.device, dtype=torch.float32,
342
+ )
343
+ frequencies = self.class_frequencies.to(device=x.device, dtype=torch.float32)
344
+ phases = self.class_phases.to(device=x.device, dtype=torch.float32)
345
+ angles = (
346
+ 2.0 * torch.pi * frequencies[:, None, None]
347
+ * harmonics[None, :, None] * time_axis[None, None, :]
348
+ + phases[:, None, None] * harmonics[None, :, None]
349
+ )
350
+ real = torch.einsum("bct,nht->bnch", signal, torch.cos(angles)) / samples
351
+ imaginary = torch.einsum("bct,nht->bnch", signal, torch.sin(angles)) / samples
352
+ amplitude = torch.sqrt(real.square() + imaginary.square() + 1e-8)
353
+ features = torch.stack((real, imaginary, torch.log(amplitude + 1e-8)), dim=-1)
354
+ return features.flatten(2).to(dtype=x.dtype)
355
+
356
+ def reference_correlation_evidence(self, x: torch.Tensor) -> torch.Tensor:
357
+ """Regularized CCA evidence against phase-complete sinusoid subspaces.
358
+
359
+ This path has no trainable parameters. It retains the classical SSVEP
360
+ prior while the learned residual and time/frequency encoders model
361
+ deviations from that prior.
362
+ """
363
+ if not self.class_frequencies.numel():
364
+ raise RuntimeError("reference evidence requires class frequencies")
365
+ # Keep the whitening decomposition in float32 even under AMP. Dry EEG
366
+ # channels can be nearly collinear, so eigenvalue clipping is safer
367
+ # than assuming every sample covariance admits a Cholesky factor.
368
+ with torch.autocast(device_type=x.device.type, enabled=False):
369
+ signal = x.float()
370
+ _, channels, samples = signal.shape
371
+ signal = signal - signal.mean(dim=-1, keepdim=True)
372
+ covariance = signal @ signal.transpose(-1, -2) / max(samples - 1, 1)
373
+ values, vectors = torch.linalg.eigh(covariance)
374
+ scale = values.amax(dim=-1, keepdim=True).clamp_min(1.0)
375
+ values = values.clamp_min(1e-4 * scale)
376
+ inverse_sqrt = (vectors * values.rsqrt().unsqueeze(-2)) @ vectors.transpose(-1, -2)
377
+ whitened = (inverse_sqrt @ signal) / float(max(samples - 1, 1)) ** 0.5
378
+ key = (samples, str(signal.device))
379
+ reference_q = self._reference_q_cache.get(key)
380
+ if reference_q is None or reference_q.device != signal.device:
381
+ time_axis = torch.arange(
382
+ samples, device=signal.device, dtype=torch.float32,
383
+ ) / self.sample_rate
384
+ harmonics = torch.arange(
385
+ 1, self.evidence_harmonics + 1, device=signal.device, dtype=torch.float32,
386
+ )
387
+ frequencies = self.class_frequencies.to(device=signal.device, dtype=torch.float32)
388
+ angles = (
389
+ 2.0 * torch.pi * frequencies[:, None, None]
390
+ * harmonics[None, :, None] * time_axis[None, None, :]
391
+ )
392
+ references = torch.stack((torch.sin(angles), torch.cos(angles)), dim=-1)
393
+ references = references.permute(0, 2, 1, 3).flatten(2)
394
+ reference_q = torch.linalg.qr(references, mode="reduced").Q
395
+ self._reference_q_cache[key] = reference_q
396
+ cross_covariance = torch.einsum("bct,ntk->bnck", whitened, reference_q)
397
+ evidence = torch.linalg.svdvals(cross_covariance)[..., 0]
398
+ return evidence.to(dtype=x.dtype)
399
+
400
+ def forward_mode(self, x: torch.Tensor, mode: str = "full") -> torch.Tensor:
401
+ if mode == "evidence_only":
402
+ evidence = self.candidate_evidence(x)
403
+ if evidence is None:
404
+ raise RuntimeError("evidence_only requires class frequencies")
405
+ return F.softplus(self.evidence_scale) * evidence
406
+ logits = self._fusion_logits(x, mode)
407
+ if mode == "full":
408
+ evidence = self.candidate_evidence(x)
409
+ if evidence is not None:
410
+ logits = logits + F.softplus(self.evidence_scale) * evidence
411
+ return logits
412
+
413
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
414
+ return self.forward_mode(x, "full")