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,912 @@
1
+ """
2
+ Inference orchestration for Aetherscan Pipeline
3
+ Implements distributed encoding of preprocessed cadence snippets and Random Forest candidate
4
+ classification. Supports distributed datasets, per-replica latent generation, and writing
5
+ predictions / latent vectors to the database for downstream analysis.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import gc
11
+ import logging
12
+ import os
13
+ import time
14
+
15
+ import joblib
16
+ import numpy as np
17
+ import tensorflow as tf
18
+
19
+ from aetherscan.benchmark import stage_timer
20
+ from aetherscan.config import get_config
21
+ from aetherscan.db import get_db
22
+ from aetherscan.latent_variants import (
23
+ apply_probability_calibrator,
24
+ build_variant_features,
25
+ sample_z_flat,
26
+ )
27
+ from aetherscan.models import RandomForestModel, prepare_latent_features
28
+ from aetherscan.seeding import (
29
+ STREAM_INFERENCE_MC,
30
+ STREAM_REFERENCE_CLOUD,
31
+ derive_rng,
32
+ seed_tensorflow,
33
+ )
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # Quantile levels stored in each cadence's confidence summary (inference_cadences manifest)
38
+ _CONFIDENCE_QUANTILES = (0.01, 0.05, 0.25, 0.5, 0.75, 0.95, 0.99)
39
+
40
+
41
+ def summarize_confidences(proba_true: np.ndarray, threshold: float) -> dict:
42
+ """
43
+ Aggregate a cadence's P(true) vector into the JSON-serializable confidence summary
44
+ stored on its inference_cadences manifest row.
45
+
46
+ Returns {n, threshold, n_above_threshold, mean, min, max, quantiles} where quantiles maps
47
+ 'p01'/'p05'/.../'p99' to the corresponding quantile of proba_true. Keeping the summary in
48
+ the manifest means run-level artifacts don't depend on the positives-only
49
+ inference_results table. Raises ValueError on an empty vector (a cadence with zero
50
+ snippets never reaches inference).
51
+ """
52
+ proba_true = np.asarray(proba_true, dtype=np.float64)
53
+ if proba_true.size == 0:
54
+ raise ValueError("summarize_confidences requires at least one confidence value")
55
+
56
+ quantiles = np.quantile(proba_true, _CONFIDENCE_QUANTILES)
57
+ return {
58
+ "n": int(proba_true.size),
59
+ "threshold": float(threshold),
60
+ "n_above_threshold": int((proba_true > threshold).sum()),
61
+ "mean": float(proba_true.mean()),
62
+ "min": float(proba_true.min()),
63
+ "max": float(proba_true.max()),
64
+ "quantiles": {
65
+ f"p{int(q * 100):02d}": float(v)
66
+ for q, v in zip(_CONFIDENCE_QUANTILES, quantiles, strict=True)
67
+ },
68
+ }
69
+
70
+
71
+ # Final-partial-step bucket floor for _distributed_encode (#298 I2+I4): per-replica batch
72
+ # sizes are powers of two in [_MIN_ENCODE_BUCKET, inference.per_replica_batch_size], so the
73
+ # number of distinct traced shapes (and cuDNN autotune events) stays bounded across any
74
+ # catalog while tiny cadences never launch degenerate single-digit-row kernels.
75
+ _MIN_ENCODE_BUCKET = 16
76
+
77
+
78
+ def _batched_mc_scores(
79
+ rf_model: RandomForestModel,
80
+ calibrator: dict | None,
81
+ variant: str,
82
+ mean_flat: np.ndarray,
83
+ logvar_flat: np.ndarray,
84
+ num_observations: int,
85
+ latent_dim: int,
86
+ active_dims: list[int] | None,
87
+ mc_draws: int,
88
+ rng: np.random.Generator,
89
+ ) -> np.ndarray:
90
+ """
91
+ Score `mc_draws` reparameterized draws with ONE stacked predict_proba call, returning
92
+ scores of shape (mc_draws, n_rows) — row-for-row what the old per-draw loop produced
93
+ (#298 I8). Correctness rests on three invariants: the draws are generated SEQUENTIALLY
94
+ from `rng` first, so the #279 stream consumption order is byte-identical to the loop;
95
+ sklearn's predict_proba consumes no numpy RNG and scores rows independently, so batch
96
+ composition cannot change any row's probability; and the calibrator is elementwise
97
+ (isotonic interpolation / per-row logistic). What changes is only the fixed per-call
98
+ overhead — check_array + a 1000-tree joblib dispatch — paid once instead of mc_draws
99
+ times (dispatch dominates at typical survivor counts). Bit-identity is pinned by a
100
+ unit test at n_jobs=1 (threaded tree accumulation order is sklearn's own run-to-run
101
+ nondeterminism, present in the old loop too).
102
+ """
103
+ draw_features = [
104
+ build_variant_features(
105
+ variant,
106
+ sample_z_flat(mean_flat, logvar_flat, rng),
107
+ logvar_flat,
108
+ num_observations,
109
+ latent_dim,
110
+ active_dims,
111
+ )
112
+ for _ in range(mc_draws)
113
+ ]
114
+ stacked_features = np.vstack(draw_features)
115
+ # Release the per-draw list before the forest runs: holding it across predict_proba
116
+ # doubled the MC transient (mc_draws x n_rows x n_features, twice) for no reason —
117
+ # the stacked copy is the only thing the predict needs (#301)
118
+ del draw_features
119
+ stacked_scores = apply_probability_calibrator(
120
+ calibrator, rf_model.model.predict_proba(stacked_features)[:, 1]
121
+ )
122
+ return stacked_scores.reshape(mc_draws, -1)
123
+
124
+
125
+ class ReferenceCloudReservoir:
126
+ """
127
+ Seeded uniform reservoir (algorithm R) over the pass-1 rejects' posterior parameters
128
+ (#282): a fixed-size, catalog-representative sample — deliberately NOT near-threshold,
129
+ which would bias the reference cloud toward the boundary and make every candidate look
130
+ ordinary. offer() is O(1) per row; the MC scoring cost at finalize is fixed
131
+ (capacity x mc_draws) regardless of survey size.
132
+ """
133
+
134
+ def __init__(self, capacity: int, rng: np.random.Generator):
135
+ self.capacity = int(capacity)
136
+ self.rng = rng
137
+ self.seen = 0
138
+ self._mean_rows: list[np.ndarray] = []
139
+ self._log_var_rows: list[np.ndarray] = []
140
+ self._screening: list[float] = []
141
+
142
+ def offer(
143
+ self, mean_flat: np.ndarray, log_var_flat: np.ndarray, screening_probas: np.ndarray
144
+ ) -> None:
145
+ if self.capacity <= 0:
146
+ return
147
+ n_rows = len(mean_flat)
148
+ start = 0
149
+ # Fill phase (rare: only until the reservoir reaches capacity)
150
+ while start < n_rows and len(self._mean_rows) < self.capacity:
151
+ self._mean_rows.append(np.array(mean_flat[start]))
152
+ self._log_var_rows.append(np.array(log_var_flat[start]))
153
+ self._screening.append(float(screening_probas[start]))
154
+ self.seen += 1
155
+ start += 1
156
+ if start >= n_rows:
157
+ return
158
+
159
+ # Replacement phase, vectorized (this runs for every reject batch of the survey, so
160
+ # the per-row Python loop it replaces was a real cost): item t (1-indexed global
161
+ # count) is accepted with probability capacity/t and lands in a uniform slot —
162
+ # textbook algorithm R, with one rng call per batch instead of one per row
163
+ remaining = n_rows - start
164
+ t_values = self.seen + 1 + np.arange(remaining)
165
+ accepted = self.rng.random(remaining) < (self.capacity / t_values)
166
+ slots = self.rng.integers(0, self.capacity, size=remaining)
167
+ self.seen += remaining
168
+ for offset in np.nonzero(accepted)[0]:
169
+ row_index = start + int(offset)
170
+ slot = int(slots[offset])
171
+ self._mean_rows[slot] = np.array(mean_flat[row_index])
172
+ self._log_var_rows[slot] = np.array(log_var_flat[row_index])
173
+ self._screening[slot] = float(screening_probas[row_index])
174
+
175
+ def arrays(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
176
+ if not self._mean_rows:
177
+ empty = np.empty((0, 0), dtype=np.float32)
178
+ return empty, empty, np.empty(0, dtype=np.float32)
179
+ return (
180
+ np.stack(self._mean_rows),
181
+ np.stack(self._log_var_rows),
182
+ np.asarray(self._screening, dtype=np.float32),
183
+ )
184
+
185
+
186
+ def _derive_encode_model(encoder):
187
+ """Encode-only view of the VAE encoder (#301): inference consumes z_mean/z_log_var and
188
+ discards the sampled z, but the Sampling layer's tf.random draw is a STATEFUL op that
189
+ graph pruning must retain — it executed per step per replica for nothing. Slicing the
190
+ functional encoder's first two outputs excludes the sampling branch from the traced
191
+ graph (the z_mean/z_log_var tensors are the same graph nodes, so outputs are unchanged).
192
+
193
+ Must be called inside strategy.scope() (its Model wraps the encoder's mirrored
194
+ variables). Returns the full encoder unchanged as a graceful fallback when the submodel
195
+ can't be built.
196
+
197
+ Shape contract the traced encode step relies on: the encoder is
198
+ keras.Model(inputs, [z_mean, z_log_var, z]) (vae.py — 3 heads). The guard checks the
199
+ SOURCE encoder's head COUNT (not the derived model's, which is always <= 2 by
200
+ construction, #305 pass-2 note): a future encoder that adds or drops a head falls back
201
+ loudly instead of the [:2] slice silently taking the wrong two tensors. A head REORDER
202
+ at the same count is still vae.py's construction contract (unguardable by shape alone).
203
+ """
204
+ import tensorflow as tf # noqa: PLC0415 (module already imports it; local keeps the helper self-contained)
205
+
206
+ try:
207
+ if len(encoder.outputs) != 3:
208
+ raise ValueError(
209
+ f"encoder has {len(encoder.outputs)} outputs, expected 3 "
210
+ f"(z_mean, z_log_var, z); refusing to build a potentially mis-sliced "
211
+ f"encode submodel"
212
+ )
213
+ return tf.keras.Model(encoder.inputs, encoder.outputs[:2])
214
+ except Exception as e:
215
+ logger.warning(
216
+ f"Encode-only submodel derivation failed ({e}); using the full encoder "
217
+ f"(the discarded sampling op will run per step)"
218
+ )
219
+ return encoder
220
+
221
+
222
+ class InferencePipeline:
223
+ """Inference pipeline"""
224
+
225
+ def __init__(self, strategy: tf.distribute.Strategy = None):
226
+ """
227
+ Initialize the inference pipeline with an optional tf.distribute strategy (defaults to
228
+ the current strategy, i.e. no-op for single-device).
229
+ """
230
+ self.config = get_config()
231
+ if self.config is None:
232
+ raise ValueError("get_config() returned None")
233
+
234
+ self.db = get_db()
235
+ if self.db is None:
236
+ raise ValueError("get_db() returned None")
237
+ self.start_time = time.time()
238
+
239
+ # Reproducibility (#279): inference used to be entirely unseeded — the encoder's
240
+ # Sampling layer drew fresh entropy every run, so the same encoder + RF + stamps
241
+ # could yield a different candidate set on every run. Seed TF's global RNG from the
242
+ # shared root seed (sub-key 1 = the inference stream; training uses 0) and honor
243
+ # tf_deterministic_ops on this path too. run_inference re-seeds per cadence.
244
+ applied_tf_seed = seed_tensorflow(
245
+ self.config.reproducibility.seed,
246
+ self.config.reproducibility.tf_deterministic_ops,
247
+ 1,
248
+ )
249
+ if applied_tf_seed is not None:
250
+ logger.info(
251
+ f"Seeded TF global RNG from root seed {self.config.reproducibility.seed} "
252
+ f"(derived inference stream seed {applied_tf_seed})"
253
+ )
254
+
255
+ # Set distributed strategy
256
+ self.strategy = strategy or tf.distribute.get_strategy()
257
+ self.num_replicas = self.strategy.num_replicas_in_sync
258
+
259
+ # Initialize models
260
+ self.init_models(
261
+ encoder_path=self.config.inference.encoder_path, rf_path=self.config.inference.rf_path
262
+ )
263
+
264
+ self.latent_dim = self.config.beta_vae.latent_dim
265
+ self.num_observations = self.config.data.num_observations
266
+
267
+ self.per_replica_inf_batch_size = self.config.inference.per_replica_batch_size
268
+ self.threshold = self.config.inference.classification_threshold
269
+ self.screening_threshold = self.config.inference.screening_threshold
270
+ self.mc_draws = self.config.inference.mc_draws
271
+
272
+ # Reference cloud (#282): a seeded uniform reservoir over the pass-1 REJECTS across
273
+ # the whole run — MC-scored once at finalize so the candidate uncertainty plot
274
+ # compares candidates against the survey, not against other candidates
275
+ self._reference_reservoir = ReferenceCloudReservoir(
276
+ capacity=self.config.inference.reference_cloud_size,
277
+ rng=derive_rng(self.config.reproducibility.seed, STREAM_REFERENCE_CLOUD),
278
+ )
279
+
280
+ # Lazily-built tf.function for the distributed encode step, cached so repeated
281
+ # run_inference calls (one per cadence in the streaming loop) reuse one trace
282
+ # instead of accumulating a new concrete function per cadence
283
+ self._encode_step = None
284
+
285
+ # NOTE: HuggingFace Hub model loading is handled upstream: when no local artifact paths
286
+ # are given, hf_hub.resolve_inference_artifacts() (called from main()) downloads the
287
+ # revision-pinned artifacts and routes their cache paths into config.inference, so the
288
+ # paths received here are always local files
289
+ def init_models(self, encoder_path: str, rf_path: str):
290
+ """Load the VAE encoder (inside strategy.scope) and the Random Forest classifier from disk."""
291
+ if not os.path.exists(encoder_path):
292
+ raise FileNotFoundError(f"Encoder not found at {encoder_path}")
293
+ if not os.path.exists(rf_path):
294
+ raise FileNotFoundError(f"Random Forest not found at {rf_path}")
295
+
296
+ try:
297
+ # Load encoder within strategy scope
298
+ # NOTE: per-layer dtype policies are baked into the saved .keras file, so an
299
+ # encoder trained with beta_vae.mixed_precision infers in bf16 automatically
300
+ # (z_mean/z_log_var/Sampling stay fp32 islands) — no global policy call here.
301
+ logger.info(f"Loading encoder from {encoder_path} within strategy scope")
302
+ with self.strategy.scope():
303
+ self.encoder = tf.keras.models.load_model(encoder_path)
304
+ # Encode-only view (#301): inference consumes z_mean/z_log_var and
305
+ # discards the sampled z, but the Sampling layer's tf.random draw is a
306
+ # STATEFUL op that graph pruning must retain — it executed per step per
307
+ # replica for nothing. Slicing the functional model's first two outputs
308
+ # excludes the sampling branch from the traced graph entirely; z_mean /
309
+ # z_log_var tensors are the same graph nodes, so outputs are unchanged.
310
+ self._encode_model = _derive_encode_model(self.encoder)
311
+ logger.info("Encoder loaded successfully")
312
+ except Exception as e:
313
+ logger.error(f"Error loading encoder: {e}")
314
+ raise # Re-raise to propagate error
315
+
316
+ try:
317
+ # Load Random Forest
318
+ logger.info(f"Loading Random Forest from {rf_path}")
319
+ self.rf_model = RandomForestModel()
320
+ self.rf_model.load(rf_path)
321
+ logger.info("Random Forest loaded successfully")
322
+
323
+ except Exception as e:
324
+ logger.error(f"Error loading Random Forest: {e}")
325
+ raise # Re-raise to propagate error
326
+
327
+ # Probability calibrator (#282): the saved training config records whether one is
328
+ # active; applying it identically at inference is mandatory (an unapplied calibrator
329
+ # is a silent train/serve mismatch), so a missing artifact is a hard error
330
+ self.calibrator = None
331
+ if self.config.rf.calibration_active:
332
+ calibrator_path = os.path.join(
333
+ os.path.dirname(rf_path),
334
+ os.path.basename(rf_path).replace("random_forest", "rf_calibrator"),
335
+ )
336
+ if not os.path.exists(calibrator_path):
337
+ raise FileNotFoundError(
338
+ f"config records an active probability calibrator "
339
+ f"({self.config.rf.calibration_method}) but {calibrator_path} does not "
340
+ "exist — refusing to score uncalibrated (train/serve mismatch)"
341
+ )
342
+ self.calibrator = joblib.load(calibrator_path)
343
+ logger.info(
344
+ f"Loaded probability calibrator ({self.calibrator['method']}) from "
345
+ f"{calibrator_path}"
346
+ )
347
+ logger.info(
348
+ f"Inference feature layout: latent_variant='{self.config.rf.latent_variant}', "
349
+ f"active_dims={self.config.rf.active_dims}"
350
+ )
351
+
352
+ def run_inference(
353
+ self,
354
+ data: np.ndarray,
355
+ npy_path: str,
356
+ target: str | None = None,
357
+ session: str | None = None,
358
+ cadence_id: int | None = None,
359
+ band: str | None = None,
360
+ frequency_mhz: float | None = None,
361
+ stamp_frequencies_mhz: list[float] | None = None,
362
+ timestamp_observed: float | None = None,
363
+ h5_path: str | None = None,
364
+ seed_key: int | None = None,
365
+ ) -> dict:
366
+ """
367
+ Run inference on preprocessed cadence snippets (shape (n, 6, 16, 512)) sourced from
368
+ npy_path, and return {n_cadence_snippets, n_processed, n_candidates, proba_true,
369
+ predictions, latents}. proba_true is the per-snippet P(true) vector, predictions the
370
+ thresholded 0/1 array, and latents the truncated per-observation latent array of shape
371
+ (n * num_observations, latent_dim) — callers use them for the per-cadence manifest
372
+ summary and the visualization suite, then drop them (they are per-cadence transients,
373
+ never accumulated catalog-wide).
374
+
375
+ Encodes each snippet through the VAE encoder under the distribution strategy, then runs
376
+ the Random Forest classifier on the latents and writes positive predictions to the
377
+ database (along with the latent vector and observational provenance: target, session,
378
+ cadence_id, band, frequency, timestamp_observed, h5_path — typically derived by
379
+ preprocessing.derive_cadence_provenance from the cadence's metadata JSON).
380
+ stamp_frequencies_mhz, when given, carries one center frequency per snippet row (the
381
+ metadata's stamp_frequencies_mhz) and takes precedence over the scalar frequency_mhz.
382
+
383
+ Safe to call repeatedly on one pipeline instance (the per-cadence streaming loop does):
384
+ models stay loaded, and per-call dataset state is released in the finally block. The
385
+ caller owns tf.keras.backend.clear_session() — see run_inference_pipeline /
386
+ main.inference_command.
387
+ """
388
+ # Sanity check
389
+ if not self.encoder or not self.rf_model:
390
+ raise RuntimeError("Encoder and/or Random Forest not initialized")
391
+
392
+ # Reproducibility (#279): re-seed TF per cadence when the caller provides a stable
393
+ # key (the streaming loop passes the catalog cadence index), so a cadence's sampled
394
+ # latents depend only on (root seed, cadence) — reproducible even when the catalog
395
+ # is subset or a run resumes partway
396
+ if seed_key is not None:
397
+ seed_tensorflow(self.config.reproducibility.seed, False, 1, seed_key)
398
+
399
+ try:
400
+ n_samples = data.shape[0]
401
+ # Sanity check: verify there's at least one sample to run inference on
402
+ if n_samples == 0:
403
+ raise ValueError("Not enough samples (0) to run inference")
404
+ logger.info(f"Running inference on {n_samples} cadence snippets from {npy_path}")
405
+
406
+ if stamp_frequencies_mhz is not None and len(stamp_frequencies_mhz) != n_samples:
407
+ logger.warning(
408
+ f"stamp_frequencies_mhz has {len(stamp_frequencies_mhz)} entries but "
409
+ f"{n_samples} snippets were loaded; ignoring per-stamp frequencies"
410
+ )
411
+ stamp_frequencies_mhz = None
412
+
413
+ # Encode directly from numpy slices (#298 I2+I4): no per-cadence tf.data
414
+ # dataset build / distribute / iter() churn, no full-array pad copy — see
415
+ # _distributed_encode for the bucketed batch geometry and padding semantics.
416
+ # Only the real snippets' latent rows come back (padding never leaves encode).
417
+ with stage_timer("encode"):
418
+ z_mean, z_log_var = self._distributed_encode(data)
419
+
420
+ # NOTE: no gc.collect() here — `data` is still referenced by the caller
421
+ # (main._infer_cadence holds cadence_data until after run_inference returns),
422
+ # so a full collection frees nothing material and each one costs ~0.3 s with
423
+ # TF's object graph loaded while holding the GIL against the prefetch thread
424
+ # (#298 I7). The finally-block collect below is the one per-call collection
425
+ # point.
426
+ del data
427
+
428
+ # Two-pass cascade (#282). Pass 1 scores EVERY snippet deterministically
429
+ # (features per the saved config's winning variant, z_mean in the lead slot)
430
+ # against the permissive screening threshold; pass 2 re-scores the survivors
431
+ # with mc_draws seeded reparameterized draws and REPLACES their score with the
432
+ # MC mean, which carries the science threshold. Not two ANDed criteria — pass 1
433
+ # only exists to say "definitely not a candidate" cheaply.
434
+ logger.info("Running Random Forest classification (two-pass cascade)")
435
+ with stage_timer("rf"):
436
+ variant = self.config.rf.latent_variant
437
+ active_dims = self.config.rf.active_dims
438
+ latent_dim = self.latent_dim
439
+ num_observations = self.num_observations
440
+ # float32 here (not the training default float64): inference never runs the
441
+ # Active-Units .var() gate (active_dims comes from the saved config), and every
442
+ # downstream consumer — build_variant_features, sample_z_flat, the reference
443
+ # reservoir, predict_proba — casts to float32 anyway, so this is byte-identical
444
+ # while skipping two full-matrix float64 widenings per cadence on the RF stage
445
+ # that runs on the main thread while the GPUs are idle.
446
+ mean_flat = prepare_latent_features(z_mean, num_observations, dtype=np.float32)
447
+ logvar_flat = prepare_latent_features(z_log_var, num_observations, dtype=np.float32)
448
+
449
+ pass1_features = build_variant_features(
450
+ variant, mean_flat, logvar_flat, num_observations, latent_dim, active_dims
451
+ )
452
+ screening_probas = apply_probability_calibrator(
453
+ self.calibrator, self.rf_model.model.predict_proba(pass1_features)[:, 1]
454
+ ).astype(np.float32)
455
+ survivors = screening_probas > self.screening_threshold
456
+
457
+ # Pass 2: seeded MC over the survivors. Draws are keyed on (root seed,
458
+ # cadence seed_key), so a cadence's MC statistics are independent of the
459
+ # rest of the catalog. Spread is computed on the SAME (calibrated when
460
+ # active) scale as the plotted probabilities — documented choice.
461
+ mc_mean = np.full(n_samples, np.nan, dtype=np.float32)
462
+ mc_std = np.full(n_samples, np.nan, dtype=np.float32)
463
+ survivor_idx = np.nonzero(survivors)[0]
464
+ if len(survivor_idx):
465
+ # None gets the BARE stream key, a real cadence index its own sub-key:
466
+ # SeedSequence([root, id]) and SeedSequence([root, id, 0]) are distinct,
467
+ # so a keyless legacy call can never collide with catalog cadence 0
468
+ mc_key = (
469
+ (STREAM_INFERENCE_MC,)
470
+ if seed_key is None
471
+ else (STREAM_INFERENCE_MC, seed_key)
472
+ )
473
+ mc_rng = derive_rng(self.config.reproducibility.seed, *mc_key)
474
+ draw_scores = _batched_mc_scores(
475
+ self.rf_model,
476
+ self.calibrator,
477
+ variant,
478
+ mean_flat[survivor_idx],
479
+ logvar_flat[survivor_idx],
480
+ num_observations,
481
+ latent_dim,
482
+ active_dims,
483
+ self.mc_draws,
484
+ mc_rng,
485
+ )
486
+ mc_mean[survivor_idx] = draw_scores.mean(axis=0)
487
+ mc_std[survivor_idx] = draw_scores.std(axis=0)
488
+ del draw_scores
489
+
490
+ # Final score: MC mean for survivors, the pass-1 score otherwise; the
491
+ # science threshold applies to the final score. Confidence of a NEGATIVE is
492
+ # 1 - final score on the DEPLOYED (calibrated when active) scale — a
493
+ # deliberate semantic choice: the old probas[:, 0] equalled 1 - p only on
494
+ # the raw scale, and a calibrator fit on the positive marginal does not
495
+ # commute with the complement (1 - cal(p1) != cal(p0) in general). All
496
+ # persisted/reported probabilities live on one scale this way.
497
+ proba_true = np.where(survivors, np.nan_to_num(mc_mean), screening_probas)
498
+ predictions = (proba_true > self.threshold).astype(int)
499
+ confidence_scores = np.where(predictions, proba_true, 1.0 - proba_true)
500
+
501
+ # Reference cloud: feed the pass-1 rejects to the seeded uniform reservoir
502
+ reject_idx = np.nonzero(~survivors)[0]
503
+ if len(reject_idx):
504
+ self._reference_reservoir.offer(
505
+ mean_flat[reject_idx],
506
+ logvar_flat[reject_idx],
507
+ screening_probas[reject_idx],
508
+ )
509
+
510
+ # The per-observation z_mean rows are the latents consumers see (candidate
511
+ # provenance vectors + the viz collector) — deterministic, reproducible
512
+ latents = z_mean
513
+
514
+ # Write results to database
515
+ with stage_timer("db_write"):
516
+ n_candidates = self._write_inference_results(
517
+ npy_path=npy_path,
518
+ predictions=predictions,
519
+ confidence_scores=confidence_scores,
520
+ latents=latents,
521
+ screening_probas=screening_probas,
522
+ mc_mean=mc_mean,
523
+ mc_std=mc_std,
524
+ target=target,
525
+ session=session,
526
+ cadence_id=cadence_id,
527
+ band=band,
528
+ frequency_mhz=frequency_mhz,
529
+ stamp_frequencies_mhz=stamp_frequencies_mhz,
530
+ timestamp_observed=timestamp_observed,
531
+ h5_path=h5_path,
532
+ )
533
+
534
+ except Exception as e:
535
+ logger.error(f"Error in run_inference(): {e}")
536
+ raise # Re-raise to propagate error
537
+
538
+ finally:
539
+ # One full collection per call (#298 I7 kept exactly this one) — clears any
540
+ # cyclic debris from the encode/cascade path. tf.keras.backend.clear_session()
541
+ # is intentionally NOT called here: the streaming loop reuses this pipeline's
542
+ # loaded models across cadences; the caller clears the session once when the
543
+ # whole run is done.
544
+ gc.collect()
545
+
546
+ return {
547
+ "n_cadence_snippets": n_samples,
548
+ "n_processed": n_samples,
549
+ "n_candidates": n_candidates,
550
+ "proba_true": proba_true,
551
+ "predictions": predictions,
552
+ "latents": latents,
553
+ "screening_probas": screening_probas,
554
+ "mc_mean": mc_mean,
555
+ "mc_std": mc_std,
556
+ }
557
+
558
+ @staticmethod
559
+ def _encode_bucket(remaining: int, num_replicas: int, max_bucket: int) -> int:
560
+ """
561
+ Per-replica batch size for a final partial encode step: the smallest power of two
562
+ (floor _MIN_ENCODE_BUCKET, cap max_bucket) covering ceil(remaining / num_replicas).
563
+
564
+ Bucketing bounds two costs at once: padding waste (always under one bucketed global
565
+ batch per cadence — the old pad-to-full-global policy encoded a 100-stamp cadence
566
+ as 10,240 rows) and the number of distinct traced shapes / cuDNN autotune events
567
+ (at most len({16, 32, ..., max_bucket}) per run, however heterogeneous the catalog).
568
+ """
569
+ need = -(-remaining // num_replicas) # ceil div
570
+ bucket = _MIN_ENCODE_BUCKET
571
+ while bucket < need and bucket < max_bucket:
572
+ bucket *= 2
573
+ return min(bucket, max_bucket)
574
+
575
+ def _distributed_encode(self, data: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
576
+ """
577
+ Encode `data` (n_samples, num_obs, time_bins, width) into pre-allocated
578
+ (n_samples * num_observations, latent_dim) arrays of z_mean and z_log_var — the
579
+ DETERMINISTIC posterior parameters (#282: pass 1 scores on z_mean-based features;
580
+ pass-2 MC draws are reparameterized in numpy from these, seeded per cadence, so no
581
+ stochastic z ever crosses the GPU boundary).
582
+
583
+ Feeds the replicas directly from numpy slices via
584
+ experimental_distribute_values_from_function (#298 I2 option (b)): no tf.data
585
+ dataset, no per-cadence distribute/iter() rebuild (measured ~9.1 s per iter() on a
586
+ 5-GPU distributed dataset), no per-element generator crossings, and no full-array
587
+ pad copy. Full steps run at per_replica_batch_size; the final partial step drops to
588
+ a bucketed size (_encode_bucket) with its tail padded by cycling rows from the
589
+ cadence start — padded outputs never leave this function (only real rows are
590
+ written to the output arrays). Batch-shape changes can flip cuDNN algorithm
591
+ selection, so kept-row latents may differ from the tf.data path in low bits —
592
+ same science, gated on candidate-set + mc_mean/mc_std equality in the on-cluster
593
+ A/B (#298). Per-replica results are gathered via experimental_local_results +
594
+ np.concatenate (cheaper than an NCCL gather for the small latent payload).
595
+ """
596
+ n_samples = len(data)
597
+ num_replicas = self.num_replicas
598
+ max_bucket = self.per_replica_inf_batch_size
599
+
600
+ # Pre-allocate output arrays
601
+ # Use np.empty() instead of np.zeros() so problematic latent values don't fail silently
602
+ z_mean_out = np.empty((n_samples * self.num_observations, self.latent_dim), np.float32)
603
+ z_log_var_out = np.empty_like(z_mean_out)
604
+
605
+ if self._encode_step is None:
606
+ # Cache dimensions for tf.function
607
+ time_bins = self.config.data.time_bins
608
+ width_bin = self.config.data.width_bin // self.config.data.downsample_factor
609
+ # The encode-only submodel when init_models derived one (#301: skips the
610
+ # discarded sampling draw); a bare stub/full encoder still works — the first
611
+ # two outputs are z_mean/z_log_var either way
612
+ encode_model = getattr(self, "_encode_model", None) or self.encoder
613
+
614
+ @tf.function
615
+ def encode_step(batch_data):
616
+ def encode_fn(data):
617
+ """Per-replica encoding step"""
618
+ # Reshape for encoder: (batch, 6, 16, 512) -> (batch * 6, 16, 512, 1)
619
+ reshaped = tf.reshape(data, [-1, time_bins, width_bin, 1])
620
+ outputs = encode_model(reshaped, training=False)
621
+ return outputs[0], outputs[1] # z_mean, z_log_var
622
+
623
+ # Run encoding on all replicas
624
+ return self.strategy.run(encode_fn, args=(batch_data,))
625
+
626
+ self._encode_step = encode_step
627
+
628
+ encode_step = self._encode_step
629
+
630
+ def _harvest(entry) -> None:
631
+ # Extract results from each replica and concatenate (cheaper than an NCCL
632
+ # gather for the small latent payload). The .numpy() calls block until the
633
+ # step's kernels finish — which, in the pipelined loop below, overlaps the
634
+ # NEXT step's device work instead of serializing against it.
635
+ per_replica_mean, per_replica_log_var, out_rows, write_idx = entry
636
+ batch_mean = np.concatenate(
637
+ [r.numpy() for r in self.strategy.experimental_local_results(per_replica_mean)],
638
+ axis=0,
639
+ )
640
+ batch_log_var = np.concatenate(
641
+ [r.numpy() for r in self.strategy.experimental_local_results(per_replica_log_var)],
642
+ axis=0,
643
+ )
644
+ # Only the real rows land in the outputs (obs-major: out_rows snippet rows)
645
+ z_mean_out[write_idx : write_idx + out_rows] = batch_mean[:out_rows]
646
+ z_log_var_out[write_idx : write_idx + out_rows] = batch_log_var[:out_rows]
647
+
648
+ # One-step-deep software pipeline (#301): dispatch step k+1 (h2d + kernels are
649
+ # enqueued asynchronously by eager TF) BEFORE harvesting step k, so the host-side
650
+ # d2h + numpy writes of step k run while step k+1 computes on the GPUs. The old
651
+ # loop harvested immediately, fully serializing convert -> compute -> sync per
652
+ # step (~2% of benched encode throughput). Numerics are untouched: same tensors,
653
+ # same step geometry, same write order — only the harvest timing moves.
654
+ pending = None
655
+ n_steps = 0
656
+ out_idx = 0
657
+ start = 0
658
+ while start < n_samples:
659
+ remaining = n_samples - start
660
+ if remaining >= max_bucket * num_replicas:
661
+ bucket = max_bucket
662
+ else:
663
+ bucket = self._encode_bucket(remaining, num_replicas, max_bucket)
664
+ global_rows = bucket * num_replicas
665
+
666
+ if remaining >= global_rows:
667
+ step_slice = data[start : start + global_rows]
668
+ real_rows = global_rows
669
+ else:
670
+ # Pad the final step with duplicate rows cycled from the cadence start
671
+ # (deterministic, same semantics as the retired dataset padding); their
672
+ # encoded outputs are sliced off in _harvest and never leave this method
673
+ pad_indices = np.arange(global_rows - remaining) % n_samples
674
+ step_slice = np.concatenate([data[start:], data[pad_indices]], axis=0)
675
+ real_rows = remaining
676
+
677
+ def value_fn(ctx, step_slice=step_slice, bucket=bucket):
678
+ replica = ctx.replica_id_in_sync_group
679
+ return tf.convert_to_tensor(step_slice[replica * bucket : (replica + 1) * bucket])
680
+
681
+ per_replica_batch = self.strategy.experimental_distribute_values_from_function(value_fn)
682
+ per_replica_mean, per_replica_log_var = encode_step(per_replica_batch)
683
+
684
+ if pending is not None:
685
+ _harvest(pending)
686
+
687
+ out_rows = real_rows * self.num_observations
688
+ pending = (per_replica_mean, per_replica_log_var, out_rows, out_idx)
689
+
690
+ out_idx += out_rows
691
+ start += real_rows
692
+ n_steps += 1
693
+ if n_steps % 10 == 0 or start >= n_samples:
694
+ logger.info(f"Encoded {start}/{n_samples} snippets ({n_steps} steps)")
695
+
696
+ # Refcount-managed numpy arrays and eager tensors: freed on del/rebind. The
697
+ # full gc.collect() that used to run here EVERY STEP (~0.3 s each with TF
698
+ # loaded, GIL held) reclaimed nothing cyclic (#298 I7). At most one extra
699
+ # step of outputs stays live (the pipeline depth).
700
+ del per_replica_mean, per_replica_log_var
701
+
702
+ if pending is not None:
703
+ _harvest(pending)
704
+
705
+ return z_mean_out, z_log_var_out
706
+
707
+ def _write_inference_results(
708
+ self,
709
+ npy_path: str,
710
+ predictions: np.ndarray,
711
+ confidence_scores: np.ndarray,
712
+ latents: np.ndarray,
713
+ screening_probas: np.ndarray | None = None,
714
+ mc_mean: np.ndarray | None = None,
715
+ mc_std: np.ndarray | None = None,
716
+ target: str | None = None,
717
+ session: str | None = None,
718
+ cadence_id: int | None = None,
719
+ band: str | None = None,
720
+ frequency_mhz: float | None = None,
721
+ stamp_frequencies_mhz: list[float] | None = None,
722
+ timestamp_observed: float | None = None,
723
+ h5_path: str | None = None,
724
+ ) -> int:
725
+ """Write inference results to database.
726
+
727
+ Predictions/confidences are indexed per snippet (dataset order is preserved end to end:
728
+ the .npy rows, the padded dataset, and the truncated latents all share snippet-major
729
+ ordering, so snippet_index == the row index in npy_path). stamp_frequencies_mhz, when
730
+ given, supplies the per-snippet center frequency (falling back to the scalar
731
+ frequency_mhz otherwise); latents rows are per observation, so snippet idx spans
732
+ latents[idx * num_observations : (idx + 1) * num_observations], flattened to one
733
+ (num_observations * latent_dim,) provenance vector.
734
+ """
735
+ if self.db is None:
736
+ raise RuntimeError("No database instance detected - cannot store inference results")
737
+
738
+ tag = self.config.checkpoint.save_tag
739
+
740
+ def _optional_float(values, index):
741
+ if values is None:
742
+ return None
743
+ value = float(values[index])
744
+ return None if np.isnan(value) else value
745
+
746
+ # NOTE: should we just store everything? benchmark storage requirements
747
+ # Only store candidates above threshold (to reduce db size). Iterate the positives
748
+ # directly (rider on #298 I8) — identical rows in identical order, without a Python
749
+ # loop over every negative snippet of a 1e4-1e5-stamp cadence.
750
+ positive_indices = np.nonzero(np.asarray(predictions) == 1)[0]
751
+ n_candidates = len(positive_indices)
752
+ for raw_idx in positive_indices:
753
+ idx = int(raw_idx)
754
+ confidence = float(confidence_scores[idx])
755
+ prediction = int(predictions[idx])
756
+
757
+ snippet_frequency_mhz = frequency_mhz
758
+ if stamp_frequencies_mhz is not None:
759
+ snippet_frequency_mhz = float(stamp_frequencies_mhz[idx])
760
+
761
+ # One latent row per observation -> flatten the snippet's num_observations
762
+ # rows into a single (num_observations * latent_dim,) vector
763
+ latent_rows = latents[idx * self.num_observations : (idx + 1) * self.num_observations]
764
+
765
+ self.db.write_inference_result(
766
+ npy_path=npy_path,
767
+ snippet_index=idx,
768
+ prediction=prediction,
769
+ confidence=confidence,
770
+ latent_vector=latent_rows.reshape(-1),
771
+ target=target,
772
+ session=session,
773
+ cadence_id=cadence_id,
774
+ band=band,
775
+ frequency_mhz=snippet_frequency_mhz,
776
+ timestamp_observed=timestamp_observed,
777
+ h5_path=h5_path,
778
+ tag=tag,
779
+ screening_proba=_optional_float(screening_probas, idx),
780
+ mc_mean=_optional_float(mc_mean, idx),
781
+ mc_std=_optional_float(mc_std, idx),
782
+ )
783
+
784
+ logger.info(f"Wrote {n_candidates} candidates to database")
785
+ return n_candidates
786
+
787
+ def finalize_reference_cloud(self) -> str | None:
788
+ """
789
+ MC-score the reject reservoir and persist the reference cloud (#282): the
790
+ (screening_proba, mc_mean, mc_std) triples for a seeded uniform subsample of the
791
+ survey's pass-1 rejects, written to
792
+ {output_path}/inference_reference_cloud_{tag}.npz together with the subsample
793
+ size, total rejects seen, root seed, and draw count — so the candidate uncertainty
794
+ plot can be regenerated later without re-running inference. Call once, after the
795
+ last run_inference of the run. Returns the npz path (None when the cloud is
796
+ disabled or no rejects were seen).
797
+ """
798
+ mean_flat, logvar_flat, screening = self._reference_reservoir.arrays()
799
+ if len(screening) == 0:
800
+ logger.info("Reference cloud: no pass-1 rejects collected — nothing to persist")
801
+ return None
802
+
803
+ logger.info(
804
+ f"MC-scoring the reference cloud: {len(screening)} reject rows "
805
+ f"(uniform reservoir over {self._reference_reservoir.seen} rejects) x "
806
+ f"{self.mc_draws} draws"
807
+ )
808
+ variant = self.config.rf.latent_variant
809
+ active_dims = self.config.rf.active_dims
810
+ cloud_rng = derive_rng(self.config.reproducibility.seed, STREAM_REFERENCE_CLOUD, 1)
811
+ draw_scores = _batched_mc_scores(
812
+ self.rf_model,
813
+ self.calibrator,
814
+ variant,
815
+ mean_flat,
816
+ logvar_flat,
817
+ self.num_observations,
818
+ self.latent_dim,
819
+ active_dims,
820
+ self.mc_draws,
821
+ cloud_rng,
822
+ )
823
+
824
+ tag = self.config.checkpoint.save_tag
825
+ cloud_path = os.path.join(self.config.output_path, f"inference_reference_cloud_{tag}.npz")
826
+ os.makedirs(os.path.dirname(cloud_path), exist_ok=True)
827
+ np.savez_compressed(
828
+ cloud_path,
829
+ screening_proba=screening,
830
+ mc_mean=draw_scores.mean(axis=0).astype(np.float32),
831
+ mc_std=draw_scores.std(axis=0).astype(np.float32),
832
+ subsample_size=np.int64(len(screening)),
833
+ rejects_seen=np.int64(self._reference_reservoir.seen),
834
+ mc_draws=np.int64(self.mc_draws),
835
+ root_seed=np.int64(
836
+ -1 if self.config.reproducibility.seed is None else self.config.reproducibility.seed
837
+ ),
838
+ # Explicit stream provenance so a reader can reproduce the cloud without
839
+ # reverse-engineering the derivation: the reservoir consumes
840
+ # derive_rng(root, STREAM_REFERENCE_CLOUD) and the MC scoring
841
+ # derive_rng(root, STREAM_REFERENCE_CLOUD, 1)
842
+ reservoir_stream_id=np.int64(STREAM_REFERENCE_CLOUD),
843
+ mc_stream_key=np.array([STREAM_REFERENCE_CLOUD, 1], dtype=np.int64),
844
+ )
845
+ logger.info(f"Reference cloud persisted to {cloud_path}")
846
+ return cloud_path
847
+
848
+ # NOTE: candidate plotting lives in aetherscan.inference_viz (plot_candidate /
849
+ # plot_candidate_gallery), rendered at end of run from the inference_results rows +
850
+ # stamp .npy files so it also covers cadences skipped by the stage-aware resume.
851
+
852
+
853
+ # TODO: add try-except switch statements (see run_training_pipeline())
854
+ def run_inference_pipeline(
855
+ cadence_data: np.ndarray,
856
+ npy_path: str,
857
+ strategy: tf.distribute.Strategy,
858
+ target: str | None = None,
859
+ session: str | None = None,
860
+ cadence_id: int | None = None,
861
+ band: str | None = None,
862
+ frequency_mhz: float | None = None,
863
+ stamp_frequencies_mhz: list[float] | None = None,
864
+ timestamp_observed: float | None = None,
865
+ h5_path: str | None = None,
866
+ ) -> dict:
867
+ """
868
+ Single-shot inference entry point: build an InferencePipeline under `strategy`, run it once
869
+ against `cadence_data` (shape (n, 6, 16, 512)) sourced from `npy_path`, and clear the TF
870
+ session. Used by the legacy --test-files path, which loads one preprocessed array up front;
871
+ the CSV streaming path in main.inference_command instead builds one InferencePipeline and
872
+ calls run_inference per cadence so models load once.
873
+
874
+ The optional provenance arguments (target/session/cadence_id/band/frequency_mhz/
875
+ stamp_frequencies_mhz/timestamp_observed/h5_path) are written to the inference_results
876
+ table for any positive candidates. Returns run_inference's results dict
877
+ ({n_cadence_snippets, n_processed, n_candidates, proba_true, predictions, latents}).
878
+ """
879
+ # Create pipeline
880
+ pipeline = InferencePipeline(strategy=strategy)
881
+
882
+ try:
883
+ # Run inference. The umbrella span makes run_inference's encode/rf/db_write
884
+ # sub-stages record as "inference.infer.*" on this legacy path (the streaming
885
+ # path opens a per-cadence "inference.infer_cadence_NNN" umbrella instead)
886
+ with stage_timer("inference.infer"):
887
+ results = pipeline.run_inference(
888
+ data=cadence_data,
889
+ npy_path=npy_path,
890
+ target=target,
891
+ session=session,
892
+ cadence_id=cadence_id,
893
+ band=band,
894
+ frequency_mhz=frequency_mhz,
895
+ stamp_frequencies_mhz=stamp_frequencies_mhz,
896
+ timestamp_observed=timestamp_observed,
897
+ h5_path=h5_path,
898
+ )
899
+ # Reference cloud (#282), best-effort — the legacy single-shot path builds a fresh
900
+ # pipeline per call, so the cloud covers this one cadence's rejects only
901
+ try:
902
+ pipeline.finalize_reference_cloud()
903
+ except Exception as e:
904
+ logger.error(f"Reference-cloud finalization failed ({e}); continuing")
905
+ finally:
906
+ # Force TensorFlow to release internal references to datasets/iterators. Runs once
907
+ # per pipeline lifetime (after the loaded models are no longer needed) rather than
908
+ # inside run_inference, which may be called repeatedly on live models.
909
+ tf.keras.backend.clear_session()
910
+ logger.info("Cleared TensorFlow session state")
911
+
912
+ return results