aetherscan 1.0.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.
@@ -0,0 +1,849 @@
1
+ # TODO: refactor to expose public APIs for creating & destroying BetaVAE instances
2
+ # TODO: replace hard-coded values with config values
3
+ """
4
+ Beta-VAE model implementation for Aetherscan Pipeline
5
+ Uses custom clustering loss components to implicitly differentiate SETI signals from RFI in the
6
+ model's learned latent space
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+
13
+ import tensorflow as tf
14
+ from tensorflow import keras
15
+ from tensorflow.keras import layers
16
+ from tensorflow.keras.initializers import Constant, GlorotNormal, HeNormal, Zeros
17
+ from tensorflow.keras.regularizers import l1, l2
18
+
19
+ from aetherscan.config import get_config
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ # Use keras.utils.* rather than keras.saving.* — the latter is the canonical
25
+ # Keras 3 path, but `from tensorflow import keras` in TF 2.17 + NGC 25.02
26
+ # resolves to the tf-keras compat shim (`keras._tf_keras.keras`), which
27
+ # doesn't re-export the `saving` submodule. keras.utils.register_keras_serializable
28
+ # is the back-compat alias that exists in both tf.keras lineages and standalone
29
+ # Keras 3 — pick the path that works everywhere.
30
+ @keras.utils.register_keras_serializable(package="aetherscan")
31
+ class Sampling(layers.Layer):
32
+ """
33
+ Sampling layer for Beta-VAE using reparameterization trick
34
+
35
+ Since sampling is a non-differentiable operation (can't backprop through random sampling)
36
+ But we need to sample from the Beta-VAE's learned distribution to produce the latent vector (z)
37
+ We isolate the randomness (epsilon) to be independent of the learned params (z_mean, z_log_var)
38
+ Such that gradients can flow through without issue
39
+ """
40
+
41
+ def call(self, inputs):
42
+ # Get the learned mean & log-varience of the latent distribution
43
+ z_mean, z_log_var = inputs
44
+
45
+ batch = tf.shape(z_mean)[0]
46
+ dim = tf.shape(z_mean)[1]
47
+
48
+ # Sample random noise from a standard normal N(0, 1) with same shape as z_mean.
49
+ # tf.random.normal is the stable canonical API; tf.keras.backend.random_normal was
50
+ # removed/deprecated in Keras 3 (shipped in TF 2.16+) and had inconsistent graph/seed semantics.
51
+ epsilon = tf.random.normal(shape=(batch, dim))
52
+
53
+ # Compute latent vector using reparameterization
54
+ # Equivalent to sampling from N(z_mean, exp(z_log_var))
55
+ z = z_mean + tf.exp(0.5 * z_log_var) * epsilon
56
+
57
+ return z
58
+
59
+
60
+ class BetaVAE(keras.Model):
61
+ """
62
+ Beta-VAE for SETI cadence data with a composite loss that combines reconstruction, KL, and a
63
+ custom clustering term that pulls ON/ON & OFF/OFF latents together and pushes ON/OFF apart.
64
+
65
+ alpha weights the clustering term in the total loss; beta weights the KL term (standard
66
+ Beta-VAE knob trading reconstruction quality for latent disentanglement).
67
+ """
68
+
69
+ def __init__(
70
+ self, encoder, decoder, alpha=10.0, beta=1.5, regularization_active=False, **kwargs
71
+ ):
72
+ super().__init__(**kwargs)
73
+ self.encoder = encoder
74
+ self.decoder = decoder
75
+
76
+ # Hyperparameters
77
+ self.alpha = alpha
78
+ self.beta = beta
79
+ # Whether the layer-declared L1/L2 penalties are ADDED to the objective. Default
80
+ # False (v1 decision, 2026-07-29): activating them at the declared, never-calibrated
81
+ # coefficients measurably degraded the model in a 5-seed A/B — recall@0.01FPR
82
+ # median .984 -> .954 with one seed at .72, and 1-4 latent dims per seed pushed
83
+ # below the active-units threshold (one seed lost 4/8). reg_loss is still computed
84
+ # and recorded for observability either way; coefficient calibration is tracked as
85
+ # a follow-up issue.
86
+ self.regularization_active = regularization_active
87
+
88
+ def call(self, inputs, training=None):
89
+ """
90
+ Encode-decode a batch of cadences (shape (batch, 6, 16, 512)) and return
91
+ (reconstruction, z_mean, z_log_var, z).
92
+
93
+ The encoder operates on individual observations, so the call reshapes from cadence form
94
+ (batch, 6, 16, 512) to per-observation form (batch * 6, 16, 512, 1) before encoding, then
95
+ reshapes the reconstruction back to cadence form for loss computation.
96
+ """
97
+ batch_size = tf.shape(inputs)[0]
98
+
99
+ # Reshape inputs for encoder
100
+ encoder_input = tf.reshape(inputs, (batch_size * 6, 16, 512, 1))
101
+
102
+ # Encode: observations -> latents
103
+ z_mean, z_log_var, z = self.encoder(encoder_input, training=training)
104
+
105
+ # Decode: latents -> observations
106
+ reconstruction = self.decoder(z, training=training)
107
+
108
+ # Reshape outputs back to cadence format
109
+ reconstruction = tf.reshape(reconstruction, (batch_size, 6, 16, 512))
110
+
111
+ return reconstruction, z_mean, z_log_var, z
112
+
113
+ @tf.function
114
+ def loss_same(self, a: tf.Tensor, b: tf.Tensor) -> tf.Tensor:
115
+ """Mean squared L2 distance between two same-class latents — minimized to pull
116
+ ON/ON and OFF/OFF pairs together in latent space."""
117
+ return tf.reduce_mean(tf.reduce_sum(tf.square(a - b), axis=1))
118
+
119
+ @tf.function
120
+ def loss_diff(self, a: tf.Tensor, b: tf.Tensor) -> tf.Tensor:
121
+ """Inverse squared L2 distance between ON and OFF latents (with 1e-8 epsilon for
122
+ stability when latents collide) — minimizing this term pushes ON/OFF pairs apart."""
123
+ return tf.reduce_mean(1.0 / (tf.reduce_sum(tf.square(a - b), axis=1) + 1e-8))
124
+
125
+ # Deliberately NOT @tf.function (removed with the L1/L2 activation): this runs only
126
+ # inside compute_total_loss's graph, and giving it its own FuncGraph strands the
127
+ # encoder's activity-regularizer loss tensors out of scope of the reg_loss add_n —
128
+ # InaccessibleTensorError, found by the first regularized run. Inlining into the outer
129
+ # trace keeps every penalty same-graph; standalone (eager) calls just run eagerly.
130
+ # ⚠ REPRODUCIBILITY: this inlining changed float summation order — same-seed results
131
+ # from builds before/after it differ slightly. Determinism WITHIN a build (same seed ⇒
132
+ # byte-identical reruns) is preserved and pinned by the train dataset/accumulation
133
+ # tests; cross-build comparisons must be distribution-level, never same-seed pairing.
134
+ def compute_clustering_loss_true(self, true_data: tf.Tensor) -> tf.Tensor:
135
+ """
136
+ Clustering loss for true-class (ETI-bearing) cadences: sum loss_same across all
137
+ within-class pairs (ON/ON: a1-a2, a1-a3, a2-a3 and OFF/OFF: b-c, b-d, c-d, each direction)
138
+ plus loss_diff across every ON/OFF cross-pair (a{1,2,3} × {b,c,d}). Minimizing this term
139
+ forces ON latents to cluster together, OFF latents to cluster together, and the two
140
+ clusters to separate.
141
+ """
142
+ batch_size = tf.shape(true_data)[0]
143
+
144
+ # Process all observations at once for efficiency
145
+ all_obs = tf.reshape(true_data, (batch_size * 6, 16, 512, 1))
146
+ _, _, all_latents = self.encoder(all_obs, training=True)
147
+
148
+ # Reshape back to (batch, 6, latent_dim)
149
+ latent_dim = tf.shape(all_latents)[1]
150
+ latents_reshaped = tf.reshape(all_latents, (batch_size, 6, latent_dim))
151
+
152
+ # Extract ON and OFF observations
153
+ a1 = latents_reshaped[:, 0, :] # ON
154
+ b = latents_reshaped[:, 1, :] # OFF
155
+ a2 = latents_reshaped[:, 2, :] # ON
156
+ c = latents_reshaped[:, 3, :] # OFF
157
+ a3 = latents_reshaped[:, 4, :] # ON
158
+ d = latents_reshaped[:, 5, :] # OFF
159
+
160
+ # Difference terms (ON-OFF should be maximized, so use loss_diff)
161
+ difference = 0.0
162
+ difference += self.loss_diff(a1, b)
163
+ difference += self.loss_diff(a1, c)
164
+ difference += self.loss_diff(a1, d)
165
+ difference += self.loss_diff(a2, b)
166
+ difference += self.loss_diff(a2, c)
167
+ difference += self.loss_diff(a2, d)
168
+ difference += self.loss_diff(a3, b)
169
+ difference += self.loss_diff(a3, c)
170
+ difference += self.loss_diff(a3, d)
171
+
172
+ # Same terms (ON-ON and OFF-OFF should be minimized, so use loss_same)
173
+ same = 0.0
174
+ same += self.loss_same(a1, a2)
175
+ same += self.loss_same(a1, a3)
176
+ same += self.loss_same(a2, a1)
177
+ same += self.loss_same(a2, a3)
178
+ same += self.loss_same(a3, a1)
179
+ same += self.loss_same(a3, a2)
180
+ same += self.loss_same(b, c)
181
+ same += self.loss_same(b, d)
182
+ same += self.loss_same(c, b)
183
+ same += self.loss_same(c, d)
184
+ same += self.loss_same(d, b)
185
+ same += self.loss_same(d, c)
186
+
187
+ similarity = same + difference
188
+ return similarity
189
+
190
+ # Deliberately NOT @tf.function — same FuncGraph-stranding rationale as
191
+ # compute_clustering_loss_true above.
192
+ def compute_clustering_loss_false(self, false_data: tf.Tensor) -> tf.Tensor:
193
+ """
194
+ Clustering loss for false-class (RFI / noise-only) cadences: sum loss_same across all 15
195
+ pairs of observations since every observation in a false cadence is treated as belonging
196
+ to the same class. Minimizing this term forces all 6 false-class latents to collapse to a
197
+ single cluster (no ON/OFF distinction).
198
+ """
199
+ batch_size = tf.shape(false_data)[0]
200
+
201
+ # Process all observations at once for efficiency
202
+ all_obs = tf.reshape(false_data, (batch_size * 6, 16, 512, 1))
203
+ _, _, all_latents = self.encoder(all_obs, training=True)
204
+
205
+ # Reshape back to (batch, 6, latent_dim)
206
+ latent_dim = tf.shape(all_latents)[1]
207
+ latents_reshaped = tf.reshape(all_latents, (batch_size, 6, latent_dim))
208
+
209
+ # Extract OFF observations
210
+ a1 = latents_reshaped[:, 0, :] # OFF
211
+ b = latents_reshaped[:, 1, :] # OFF
212
+ a2 = latents_reshaped[:, 2, :] # OFF
213
+ c = latents_reshaped[:, 3, :] # OFF
214
+ a3 = latents_reshaped[:, 4, :] # OFF
215
+ d = latents_reshaped[:, 5, :] # OFF
216
+
217
+ # For RFI/false signals, all observations should look similar
218
+ # So we minimize distances between all pairs
219
+ difference = 0.0
220
+ difference += self.loss_same(a1, b)
221
+ difference += self.loss_same(a1, c)
222
+ difference += self.loss_same(a1, d)
223
+ difference += self.loss_same(a2, b)
224
+ difference += self.loss_same(a2, c)
225
+ difference += self.loss_same(a2, d)
226
+ difference += self.loss_same(a3, b)
227
+ difference += self.loss_same(a3, c)
228
+ difference += self.loss_same(a3, d)
229
+
230
+ same = 0.0
231
+ same += self.loss_same(a1, a2)
232
+ same += self.loss_same(a1, a3)
233
+ same += self.loss_same(a2, a1)
234
+ same += self.loss_same(a2, a3)
235
+ same += self.loss_same(a3, a1)
236
+ same += self.loss_same(a3, a2)
237
+ same += self.loss_same(b, c)
238
+ same += self.loss_same(b, d)
239
+ same += self.loss_same(c, b)
240
+ same += self.loss_same(c, d)
241
+ same += self.loss_same(d, b)
242
+ same += self.loss_same(d, c)
243
+
244
+ similarity = same + difference
245
+ return similarity
246
+
247
+ @tf.function
248
+ def compute_total_loss(self, main_data, true_data, false_data, target_data, training=True):
249
+ """
250
+ Forward-pass main_data through the VAE and return a dict with reconstruction, KL, and
251
+ per-class clustering losses plus their weighted sum:
252
+ total = reconstruction + beta * kl + alpha * (true_loss + false_loss) + reg
253
+
254
+ Reconstruction uses binary cross-entropy on the [0, 1]-bounded decoder output (sigmoid).
255
+ true_data and false_data are separate cadences fed through the clustering-loss heads —
256
+ they don't share gradients with reconstruction.
257
+
258
+ `reg` is the sum of the layer-declared regularization penalties (activated 2026-07 by
259
+ maintainer decision — the L1/L2 declarations existed since inception but a custom
260
+ training loop only applies them if it adds model.losses to the objective, so every
261
+ model trained before this change was effectively unregularized). Stock keras
262
+ semantics: kernel/bias L2 penalties appear once each; activity-L1 penalties accrue
263
+ once per regularized-layer forward performed inside this function (the reconstruction
264
+ pass AND both clustering branches), and keras batch-SUM-scales them, so the effective
265
+ activity coefficient scales with the per-replica batch size (128 at defaults).
266
+ """
267
+ # Perform forward pass through Beta-VAE
268
+ reconstruction, z_mean, z_log_var, z = self.call(main_data, training=training)
269
+
270
+ # Ensure reconstruction shape matches target for loss computation
271
+ reconstruction = tf.reshape(reconstruction, tf.shape(target_data))
272
+
273
+ # Compute reconstruction loss
274
+ reconstruction_loss = tf.reduce_mean(
275
+ tf.reduce_sum(
276
+ keras.losses.binary_crossentropy(
277
+ target_data,
278
+ reconstruction,
279
+ from_logits=False, # Use from_logits=False for stability since decoder's final activation is sigmoid (reconstruction is bounded [0,1])
280
+ ),
281
+ axis=(1, 2),
282
+ )
283
+ )
284
+
285
+ # Compute KL loss. The per-(batch, dim) matrix is kept one step longer so the
286
+ # posterior-collapse diagnostics (#282) get the batch-mean KL of EACH latent dim
287
+ # (a collapsing dim's KL goes to ~0) — the scalar loss term is unchanged.
288
+ kl_matrix = -0.5 * (1 + z_log_var - tf.square(z_mean) - tf.exp(z_log_var))
289
+ kl_per_dim = tf.reduce_mean(kl_matrix, axis=0)
290
+ kl_loss = tf.reduce_mean(tf.reduce_sum(kl_matrix, axis=1))
291
+
292
+ # Compute clustering losses
293
+ false_loss = self.compute_clustering_loss_false(false_data)
294
+ true_loss = self.compute_clustering_loss_true(true_data)
295
+
296
+ # Layer-declared regularization penalties (see docstring). Always COMPUTED and
297
+ # returned for observability (keras materializes the activity penalties in the
298
+ # forward pass regardless, so this is one add_n), but only ADDED to the objective
299
+ # when regularization_active — the v1 default is False (see __init__: activation at
300
+ # the declared coefficients measured harmful). Cast fp32 so the sum is exact under
301
+ # mixed_bfloat16 (activity penalties ride bf16 activations); the islands keep the
302
+ # rest of the loss math fp32 already.
303
+ if self.losses:
304
+ reg_loss = tf.add_n([tf.cast(loss, tf.float32) for loss in self.losses])
305
+ else:
306
+ reg_loss = tf.constant(0.0, dtype=tf.float32)
307
+
308
+ # Compute total loss (reg only when active — inactive keeps the objective
309
+ # byte-identical to the pre-activation pipeline)
310
+ total_loss = (
311
+ reconstruction_loss + self.beta * kl_loss + self.alpha * (true_loss + false_loss)
312
+ )
313
+ if self.regularization_active:
314
+ total_loss = total_loss + reg_loss
315
+
316
+ return {
317
+ "total_loss": total_loss,
318
+ "reconstruction_loss": reconstruction_loss,
319
+ "kl_loss": kl_loss,
320
+ "kl_per_dim": kl_per_dim,
321
+ "true_loss": true_loss,
322
+ "false_loss": false_loss,
323
+ "reg_loss": reg_loss,
324
+ }
325
+
326
+
327
+ def build_encoder(
328
+ latent_dim: int = 8, dense_size: int = 512, kernel_size: tuple[int, int] = (3, 3)
329
+ ) -> keras.Model:
330
+ """Build encoder network for Beta-VAE.
331
+
332
+ The encoder compresses input spectrograms into a lower-dimensional latent space.
333
+ It uses a series of convolutional layers followed by dense layers to learn
334
+ the parameters (mean & log-variance) of the Gaussian posterior over the latent space.
335
+
336
+ Architecture Overview:
337
+ ---------------------
338
+ Input: (16, 512, 1) - spectrogram with 16 time bins, 512 frequency bins, 1 polarization channel
339
+
340
+ Convolutional Layers (9 total):
341
+ - 4 downsampling layers (stride=2) reduce spatial dims: 16→8→4→2→1, 512→256→128→64→32
342
+ - 5 feature extraction layers (stride=1) maintain spatial dims
343
+ - Filter progression: 1→16→16→32→32→32→64→64→128→256
344
+
345
+ Dense Layers:
346
+ - Flatten: (1, 32, 256) → (8192,) [1*32*256 = 8192]
347
+ - Dense: (8192,) → (dense_size,) [default 512]
348
+ - z_mean: (dense_size,) → (latent_dim,) [default 8]
349
+ - z_log_var: (dense_size,) → (latent_dim,) [default 8]
350
+
351
+ Output: z_mean, z_log_var, z (sampled latent vector)
352
+
353
+ Layer Details:
354
+ -------------
355
+ | Enc Layer | Filters | Stride | Input Shape | Output Shape |
356
+ |-----------|---------|--------|------------------|------------------|
357
+ | 1 | 16 | 2 | (16, 512, 1) | (8, 256, 16) |
358
+ | 2 | 16 | 1 | (8, 256, 16) | (8, 256, 16) |
359
+ | 3 | 32 | 2 | (8, 256, 16) | (4, 128, 32) |
360
+ | 4 | 32 | 1 | (4, 128, 32) | (4, 128, 32) |
361
+ | 5 | 32 | 1 | (4, 128, 32) | (4, 128, 32) |
362
+ | 6 | 64 | 2 | (4, 128, 32) | (2, 64, 64) |
363
+ | 7 | 64 | 1 | (2, 64, 64) | (2, 64, 64) |
364
+ | 8 | 128 | 1 | (2, 64, 64) | (2, 64, 128) |
365
+ | 9 | 256 | 2 | (2, 64, 128) | (1, 32, 256) |
366
+
367
+ Regularization (all layers):
368
+ - kernel_initializer: HeNormal (conv/dense) or GlorotNormal (latent)
369
+ - bias_initializer: Zeros (except z_log_var uses -3.0 for tighter initial posterior)
370
+ - activity_regularizer: L1(0.001) - encourages sparse activations
371
+ - kernel_regularizer: L2(0.01) - prevents large weights
372
+ - bias_regularizer: L2(0.01) - prevents large biases
373
+
374
+ The returned keras.Model outputs [z_mean, z_log_var, z]. The decoder (build_decoder) must
375
+ remain an exact mirror of this architecture for VAE symmetry — any layer-structure change
376
+ here must be reflected there.
377
+ """
378
+ # Input shape: (batch, 16, 512, 1) - "grayscale" spectrogram
379
+ encoder_inputs = keras.Input(shape=(16, 512, 1), name="encoder_input")
380
+
381
+ # Convolutional layers
382
+ # Layer 1: (16, 512, 1) → (8, 256, 16) - stride 2 downsamples spatial dims by 2x
383
+ x = layers.Conv2D(
384
+ 16,
385
+ kernel_size,
386
+ activation="relu",
387
+ strides=2,
388
+ padding="same",
389
+ kernel_initializer=HeNormal(),
390
+ bias_initializer=Zeros(),
391
+ activity_regularizer=l1(0.001),
392
+ kernel_regularizer=l2(0.01),
393
+ bias_regularizer=l2(0.01),
394
+ )(encoder_inputs)
395
+
396
+ # Layer 2: (8, 256, 16) → (8, 256, 16) - stride 1 maintains spatial dims
397
+ x = layers.Conv2D(
398
+ 16,
399
+ kernel_size,
400
+ activation="relu",
401
+ strides=1,
402
+ padding="same",
403
+ kernel_initializer=HeNormal(),
404
+ bias_initializer=Zeros(),
405
+ activity_regularizer=l1(0.001),
406
+ kernel_regularizer=l2(0.01),
407
+ bias_regularizer=l2(0.01),
408
+ )(x)
409
+
410
+ # Layer 3: (8, 256, 16) → (4, 128, 32) - stride 2 downsamples
411
+ x = layers.Conv2D(
412
+ 32,
413
+ kernel_size,
414
+ activation="relu",
415
+ strides=2,
416
+ padding="same",
417
+ kernel_initializer=HeNormal(),
418
+ bias_initializer=Zeros(),
419
+ activity_regularizer=l1(0.001),
420
+ kernel_regularizer=l2(0.01),
421
+ bias_regularizer=l2(0.01),
422
+ )(x)
423
+
424
+ # Layer 4: (4, 128, 32) → (4, 128, 32) - stride 1 maintains
425
+ x = layers.Conv2D(
426
+ 32,
427
+ kernel_size,
428
+ activation="relu",
429
+ strides=1,
430
+ padding="same",
431
+ kernel_initializer=HeNormal(),
432
+ bias_initializer=Zeros(),
433
+ activity_regularizer=l1(0.001),
434
+ kernel_regularizer=l2(0.01),
435
+ bias_regularizer=l2(0.01),
436
+ )(x)
437
+
438
+ # Layer 5: (4, 128, 32) → (4, 128, 32) - stride 1 maintains
439
+ x = layers.Conv2D(
440
+ 32,
441
+ kernel_size,
442
+ activation="relu",
443
+ strides=1,
444
+ padding="same",
445
+ kernel_initializer=HeNormal(),
446
+ bias_initializer=Zeros(),
447
+ activity_regularizer=l1(0.001),
448
+ kernel_regularizer=l2(0.01),
449
+ bias_regularizer=l2(0.01),
450
+ )(x)
451
+
452
+ # Layer 6: (4, 128, 32) → (2, 64, 64) - stride 2 downsamples
453
+ x = layers.Conv2D(
454
+ 64,
455
+ kernel_size,
456
+ activation="relu",
457
+ strides=2,
458
+ padding="same",
459
+ kernel_initializer=HeNormal(),
460
+ bias_initializer=Zeros(),
461
+ activity_regularizer=l1(0.001),
462
+ kernel_regularizer=l2(0.01),
463
+ bias_regularizer=l2(0.01),
464
+ )(x)
465
+
466
+ # Layer 7: (2, 64, 64) → (2, 64, 64) - stride 1 maintains
467
+ x = layers.Conv2D(
468
+ 64,
469
+ kernel_size,
470
+ activation="relu",
471
+ strides=1,
472
+ padding="same",
473
+ kernel_initializer=HeNormal(),
474
+ bias_initializer=Zeros(),
475
+ activity_regularizer=l1(0.001),
476
+ kernel_regularizer=l2(0.01),
477
+ bias_regularizer=l2(0.01),
478
+ )(x)
479
+
480
+ # Layer 8: (2, 64, 64) → (2, 64, 128) - stride 1, increase filters
481
+ x = layers.Conv2D(
482
+ 128,
483
+ kernel_size,
484
+ activation="relu",
485
+ strides=1,
486
+ padding="same",
487
+ kernel_initializer=HeNormal(),
488
+ bias_initializer=Zeros(),
489
+ activity_regularizer=l1(0.001),
490
+ kernel_regularizer=l2(0.01),
491
+ bias_regularizer=l2(0.01),
492
+ )(x)
493
+
494
+ # Layer 9: (2, 64, 128) → (1, 32, 256) - stride 2 final downsample
495
+ x = layers.Conv2D(
496
+ 256,
497
+ kernel_size,
498
+ activation="relu",
499
+ strides=2,
500
+ padding="same",
501
+ kernel_initializer=HeNormal(),
502
+ bias_initializer=Zeros(),
503
+ activity_regularizer=l1(0.001),
504
+ kernel_regularizer=l2(0.01),
505
+ bias_regularizer=l2(0.01),
506
+ )(x)
507
+
508
+ # Dense layers
509
+ # Flatten: (1, 32, 256) → (8192,)
510
+ # The 32 comes from: 512 / 2^4 = 32 (4 stride-2 layers)
511
+ x = layers.Flatten()(x)
512
+
513
+ # Dense: (8192,) → (dense_size,) - compress to intermediate representation
514
+ x = layers.Dense(
515
+ dense_size,
516
+ activation="relu",
517
+ kernel_initializer=HeNormal(),
518
+ bias_initializer=Zeros(),
519
+ activity_regularizer=l1(0.001),
520
+ kernel_regularizer=l2(0.01),
521
+ bias_regularizer=l2(0.01),
522
+ )(x)
523
+
524
+ # Latent space
525
+ # z_mean: (dense_size,) → (latent_dim,) - mean of latent distribution
526
+ # dtype="float32": fp32 island under beta_vae.mixed_precision — the latent heads feed the
527
+ # KL exp/square math and the Sampling layer, which must not run in bf16. A no-op under
528
+ # the default fp32 policy (identical graph).
529
+ z_mean = layers.Dense(
530
+ latent_dim,
531
+ name="z_mean",
532
+ dtype="float32",
533
+ kernel_initializer=GlorotNormal(),
534
+ bias_initializer=Zeros(),
535
+ activity_regularizer=l1(0.001),
536
+ kernel_regularizer=l2(0.01),
537
+ bias_regularizer=l2(0.01),
538
+ )(x)
539
+
540
+ # z_log_var: (dense_size,) → (latent_dim,) - log-variance of latent distribution
541
+ z_log_var = layers.Dense(
542
+ latent_dim,
543
+ name="z_log_var",
544
+ dtype="float32", # fp32 island under beta_vae.mixed_precision (see z_mean above)
545
+ kernel_initializer=GlorotNormal(),
546
+ bias_initializer=Constant(
547
+ -3.0 # Negative bias initialization tightens initial posterior around prior
548
+ ),
549
+ activity_regularizer=l1(0.001),
550
+ kernel_regularizer=l2(0.01),
551
+ bias_regularizer=l2(0.01),
552
+ )(x)
553
+
554
+ # Sampling: sample z from N(z_mean, exp(z_log_var)) using reparameterization trick
555
+ # dtype="float32": fp32 island under beta_vae.mixed_precision — the exp() and epsilon
556
+ # draw stay fp32 (its z_mean/z_log_var inputs are fp32 via the heads above)
557
+ z = Sampling(dtype="float32")([z_mean, z_log_var])
558
+
559
+ encoder = keras.Model(encoder_inputs, [z_mean, z_log_var, z], name="encoder")
560
+
561
+ return encoder
562
+
563
+
564
+ def build_decoder(
565
+ latent_dim: int = 8, dense_size: int = 512, kernel_size: tuple[int, int] = (3, 3)
566
+ ) -> keras.Model:
567
+ """Build decoder network for Beta-VAE - exact mirror of encoder.
568
+
569
+ The decoder reconstructs spectrograms from latent vectors. It is architecturally
570
+ symmetric to the encoder: each encoder layer has a corresponding decoder layer
571
+ that reverses the transformation.
572
+
573
+ Architecture Overview:
574
+ ---------------------
575
+ Input: (latent_dim,) - sampled latent vector z (default: 8)
576
+
577
+ Dense Layers (mirrors encoder's dense → flatten path in reverse):
578
+ - Dense: (latent_dim,) → (dense_size,) [mirrors z_mean/z_log_var]
579
+ - Dense: (dense_size,) → (8192,) [mirrors Dense before Flatten]
580
+ - Reshape: (8192,) → (1, 32, 256) [mirrors Flatten]
581
+
582
+ Conv Transpose Layers (9 total, mirrors encoder's 9 conv layers in reverse):
583
+ - 4 upsampling layers (stride=2) expand spatial dims: 1→2→4→8→16, 32→64→128→256→512
584
+ - 5 feature transformation layers (stride=1) maintain spatial dims
585
+ - Filter progression: 256→128→64→64→32→32→32→16→16→1
586
+
587
+ Output: (16, 512, 1) - reconstructed spectrogram (matches encoder input)
588
+
589
+ Symmetry Principle:
590
+ ------------------
591
+ For each encoder layer i, decoder layer (9-i+1) reverses it:
592
+ - Decoder layer outputs the same number of filters as encoder layer's INPUT channels
593
+ - Decoder layer uses the same stride as encoder layer (stride-2 upsamples instead of downsamples)
594
+ - Shapes match: decoder layer output shape == encoder layer input shape
595
+
596
+ Layer Details:
597
+ -------------
598
+ | Dec Layer | Filters | Stride | Input Shape | Output Shape | Mirrors Enc |
599
+ |-----------|---------|--------|------------------|------------------|-------------|
600
+ | 1 | 128 | 2 | (1, 32, 256) | (2, 64, 128) | Enc 9 |
601
+ | 2 | 64 | 1 | (2, 64, 128) | (2, 64, 64) | Enc 8 |
602
+ | 3 | 64 | 1 | (2, 64, 64) | (2, 64, 64) | Enc 7 |
603
+ | 4 | 32 | 2 | (2, 64, 64) | (4, 128, 32) | Enc 6 |
604
+ | 5 | 32 | 1 | (4, 128, 32) | (4, 128, 32) | Enc 5 |
605
+ | 6 | 32 | 1 | (4, 128, 32) | (4, 128, 32) | Enc 4 |
606
+ | 7 | 16 | 2 | (4, 128, 32) | (8, 256, 16) | Enc 3 |
607
+ | 8 | 16 | 1 | (8, 256, 16) | (8, 256, 16) | Enc 2 |
608
+ | 9 | 1 | 2 | (8, 256, 16) | (16, 512, 1) | Enc 1 |
609
+
610
+ Regularization (all layers including output):
611
+ - kernel_initializer: HeNormal (hidden layers) or GlorotNormal (output layer)
612
+ - bias_initializer: Zeros
613
+ - activity_regularizer: L1(0.001) - encourages sparse activations
614
+ - kernel_regularizer: L2(0.01) - prevents large weights
615
+ - bias_regularizer: L2(0.01) - prevents large biases
616
+
617
+ Output Layer Notes:
618
+ - Uses sigmoid activation (not relu) to bound output to [0, 1] for BCE loss
619
+ - Uses GlorotNormal initialization (matches encoder's latent layer style)
620
+ - Includes full regularization for symmetry with encoder's first conv layer
621
+
622
+ The returned keras.Model outputs reconstructed spectrograms of shape (16, 512, 1). This
623
+ architecture is the exact mirror of build_encoder — any layer-structure change there must
624
+ be reflected here to maintain symmetry.
625
+ """
626
+ # Input shape: (batch, latent_dim) - sampled latent vector z
627
+ latent_inputs = keras.Input(shape=(latent_dim,), name="decoder_input")
628
+
629
+ # Dense layers: these mirror the encoder's latent → dense → flatten path in reverse
630
+ # Dense: (latent_dim,) → (dense_size,) - mirrors encoder's z_mean/z_log_var layers
631
+ x = layers.Dense(
632
+ dense_size,
633
+ activation="relu",
634
+ kernel_initializer=HeNormal(),
635
+ bias_initializer=Zeros(),
636
+ activity_regularizer=l1(0.001),
637
+ kernel_regularizer=l2(0.01),
638
+ bias_regularizer=l2(0.01),
639
+ )(latent_inputs)
640
+
641
+ # Dense: (dense_size,) → (8192,) - mirrors encoder's Dense(dense_size) layer
642
+ # The 8192 = 1 * 32 * 256 comes from encoder's final conv output shape
643
+ # Where 32 = 512 / 2^4 (4 stride-2 layers reduce width from 512 to 32)
644
+ x = layers.Dense(
645
+ 1 * 32 * 256,
646
+ activation="relu",
647
+ kernel_initializer=HeNormal(),
648
+ bias_initializer=Zeros(),
649
+ activity_regularizer=l1(0.001),
650
+ kernel_regularizer=l2(0.01),
651
+ bias_regularizer=l2(0.01),
652
+ )(x)
653
+
654
+ # Reshape: (8192,) → (1, 32, 256) - mirrors encoder's Flatten layer
655
+ x = layers.Reshape((1, 32, 256))(x)
656
+
657
+ # Convolutional transpose layers
658
+ # These mirror the encoder's conv layers in reverse order
659
+ # Each layer outputs filters matching the INPUT channels of the corresponding encoder layer
660
+ # Layer 1: (1, 32, 256) → (2, 64, 128) - mirrors encoder layer 9 (256 filters, s=2)
661
+ # Output 128 filters to match encoder layer 9's input channels
662
+ x = layers.Conv2DTranspose(
663
+ 128,
664
+ kernel_size,
665
+ activation="relu",
666
+ strides=2,
667
+ padding="same",
668
+ kernel_initializer=HeNormal(),
669
+ bias_initializer=Zeros(),
670
+ activity_regularizer=l1(0.001),
671
+ kernel_regularizer=l2(0.01),
672
+ bias_regularizer=l2(0.01),
673
+ )(x)
674
+
675
+ # Layer 2: (2, 64, 128) → (2, 64, 64) - mirrors encoder layer 8 (128 filters, s=1)
676
+ # Output 64 filters to match encoder layer 8's input channels
677
+ x = layers.Conv2DTranspose(
678
+ 64,
679
+ kernel_size,
680
+ activation="relu",
681
+ strides=1,
682
+ padding="same",
683
+ kernel_initializer=HeNormal(),
684
+ bias_initializer=Zeros(),
685
+ activity_regularizer=l1(0.001),
686
+ kernel_regularizer=l2(0.01),
687
+ bias_regularizer=l2(0.01),
688
+ )(x)
689
+
690
+ # Layer 3: (2, 64, 64) → (2, 64, 64) - mirrors encoder layer 7 (64 filters, s=1)
691
+ # Output 64 filters to match encoder layer 7's input channels
692
+ x = layers.Conv2DTranspose(
693
+ 64,
694
+ kernel_size,
695
+ activation="relu",
696
+ strides=1,
697
+ padding="same",
698
+ kernel_initializer=HeNormal(),
699
+ bias_initializer=Zeros(),
700
+ activity_regularizer=l1(0.001),
701
+ kernel_regularizer=l2(0.01),
702
+ bias_regularizer=l2(0.01),
703
+ )(x)
704
+
705
+ # Layer 4: (2, 64, 64) → (4, 128, 32) - mirrors encoder layer 6 (64 filters, s=2)
706
+ # Output 32 filters to match encoder layer 6's input channels
707
+ x = layers.Conv2DTranspose(
708
+ 32,
709
+ kernel_size,
710
+ activation="relu",
711
+ strides=2,
712
+ padding="same",
713
+ kernel_initializer=HeNormal(),
714
+ bias_initializer=Zeros(),
715
+ activity_regularizer=l1(0.001),
716
+ kernel_regularizer=l2(0.01),
717
+ bias_regularizer=l2(0.01),
718
+ )(x)
719
+
720
+ # Layer 5: (4, 128, 32) → (4, 128, 32) - mirrors encoder layer 5 (32 filters, s=1)
721
+ # Output 32 filters to match encoder layer 5's input channels
722
+ x = layers.Conv2DTranspose(
723
+ 32,
724
+ kernel_size,
725
+ activation="relu",
726
+ strides=1,
727
+ padding="same",
728
+ kernel_initializer=HeNormal(),
729
+ bias_initializer=Zeros(),
730
+ activity_regularizer=l1(0.001),
731
+ kernel_regularizer=l2(0.01),
732
+ bias_regularizer=l2(0.01),
733
+ )(x)
734
+
735
+ # Layer 6: (4, 128, 32) → (4, 128, 32) - mirrors encoder layer 4 (32 filters, s=1)
736
+ # Output 32 filters to match encoder layer 4's input channels
737
+ x = layers.Conv2DTranspose(
738
+ 32,
739
+ kernel_size,
740
+ activation="relu",
741
+ strides=1,
742
+ padding="same",
743
+ kernel_initializer=HeNormal(),
744
+ bias_initializer=Zeros(),
745
+ activity_regularizer=l1(0.001),
746
+ kernel_regularizer=l2(0.01),
747
+ bias_regularizer=l2(0.01),
748
+ )(x)
749
+
750
+ # Layer 7: (4, 128, 32) → (8, 256, 16) - mirrors encoder layer 3 (32 filters, s=2)
751
+ # Output 16 filters to match encoder layer 3's input channels
752
+ x = layers.Conv2DTranspose(
753
+ 16,
754
+ kernel_size,
755
+ activation="relu",
756
+ strides=2,
757
+ padding="same",
758
+ kernel_initializer=HeNormal(),
759
+ bias_initializer=Zeros(),
760
+ activity_regularizer=l1(0.001),
761
+ kernel_regularizer=l2(0.01),
762
+ bias_regularizer=l2(0.01),
763
+ )(x)
764
+
765
+ # Layer 8: (8, 256, 16) → (8, 256, 16) - mirrors encoder layer 2 (16 filters, s=1)
766
+ # Output 16 filters to match encoder layer 2's input channels
767
+ x = layers.Conv2DTranspose(
768
+ 16,
769
+ kernel_size,
770
+ activation="relu",
771
+ strides=1,
772
+ padding="same",
773
+ kernel_initializer=HeNormal(),
774
+ bias_initializer=Zeros(),
775
+ activity_regularizer=l1(0.001),
776
+ kernel_regularizer=l2(0.01),
777
+ bias_regularizer=l2(0.01),
778
+ )(x)
779
+
780
+ # Output layer
781
+ # Layer 9: (8, 256, 16) → (16, 512, 1) - mirrors encoder layer 1 (16 filters, s=2)
782
+ # Output 1 filter to match encoder input channels ("grayscale" spectrogram)
783
+ # Uses sigmoid activation to bound output to [0, 1] for binary cross-entropy loss
784
+ # Uses GlorotNormal (like encoder's latent layers) as this is the "boundary" layer
785
+ # Includes full regularization for symmetry with encoder's first conv layer
786
+ # dtype="float32": fp32 island under beta_vae.mixed_precision — the sigmoid output feeds
787
+ # the BCE reconstruction loss, which must compute on fp32 tensors. A no-op under the
788
+ # default fp32 policy (identical graph).
789
+ decoder_outputs = layers.Conv2DTranspose(
790
+ 1,
791
+ kernel_size,
792
+ activation="sigmoid",
793
+ strides=2,
794
+ padding="same",
795
+ dtype="float32",
796
+ kernel_initializer=GlorotNormal(),
797
+ bias_initializer=Zeros(),
798
+ activity_regularizer=l1(0.001),
799
+ kernel_regularizer=l2(0.01),
800
+ bias_regularizer=l2(0.01),
801
+ )(x)
802
+
803
+ decoder = keras.Model(latent_inputs, decoder_outputs, name="decoder")
804
+
805
+ return decoder
806
+
807
+
808
+ def create_beta_vae_model():
809
+ """Create and compile Beta-VAE model"""
810
+
811
+ logger.info("Creating Beta-VAE model...")
812
+
813
+ config = get_config()
814
+ if config is None:
815
+ raise ValueError("get_config() returned None")
816
+
817
+ encoder = build_encoder(
818
+ latent_dim=config.beta_vae.latent_dim,
819
+ dense_size=config.beta_vae.dense_layer_size,
820
+ kernel_size=config.beta_vae.kernel_size,
821
+ )
822
+
823
+ decoder = build_decoder(
824
+ latent_dim=config.beta_vae.latent_dim,
825
+ dense_size=config.beta_vae.dense_layer_size,
826
+ kernel_size=config.beta_vae.kernel_size,
827
+ )
828
+
829
+ beta_vae = BetaVAE(
830
+ encoder,
831
+ decoder,
832
+ alpha=config.beta_vae.alpha,
833
+ beta=config.beta_vae.beta,
834
+ regularization_active=config.beta_vae.regularization_active,
835
+ )
836
+
837
+ beta_vae.compile(
838
+ optimizer=keras.optimizers.Adam(learning_rate=config.training.base_learning_rate)
839
+ )
840
+
841
+ logger.info(
842
+ f"Created Beta-VAE model: latent_dim={config.beta_vae.latent_dim}, "
843
+ f"beta={config.beta_vae.beta}, alpha={config.beta_vae.alpha}"
844
+ )
845
+
846
+ encoder.summary(print_fn=logger.info)
847
+ decoder.summary(print_fn=logger.info)
848
+
849
+ return beta_vae