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.
- aetherscan/__init__.py +45 -0
- aetherscan/benchmark.py +179 -0
- aetherscan/candidate_figures.py +328 -0
- aetherscan/cli.py +3032 -0
- aetherscan/config.py +1002 -0
- aetherscan/dashboard.py +709 -0
- aetherscan/dashboard_cli.py +51 -0
- aetherscan/dashboard_launcher.py +194 -0
- aetherscan/data_generation.py +1448 -0
- aetherscan/db/__init__.py +21 -0
- aetherscan/db/db.py +2789 -0
- aetherscan/hf_hub.py +547 -0
- aetherscan/inference.py +912 -0
- aetherscan/inference_viz.py +1494 -0
- aetherscan/latent_gif.py +512 -0
- aetherscan/latent_variants.py +352 -0
- aetherscan/logger/__init__.py +21 -0
- aetherscan/logger/logger.py +467 -0
- aetherscan/logger/slack_handler.py +760 -0
- aetherscan/main.py +1269 -0
- aetherscan/manager/__init__.py +21 -0
- aetherscan/manager/manager.py +781 -0
- aetherscan/models/__init__.py +21 -0
- aetherscan/models/random_forest.py +171 -0
- aetherscan/models/vae.py +849 -0
- aetherscan/monitor/__init__.py +19 -0
- aetherscan/monitor/monitor.py +935 -0
- aetherscan/pfb.py +161 -0
- aetherscan/preprocessing.py +2718 -0
- aetherscan/rf_metrics.py +100 -0
- aetherscan/round_data.py +932 -0
- aetherscan/run_state.py +277 -0
- aetherscan/seeding.py +160 -0
- aetherscan/shap_parallel.py +238 -0
- aetherscan/tag_guards.py +206 -0
- aetherscan/train.py +7681 -0
- aetherscan-1.0.0.dist-info/METADATA +1187 -0
- aetherscan-1.0.0.dist-info/RECORD +41 -0
- aetherscan-1.0.0.dist-info/WHEEL +4 -0
- aetherscan-1.0.0.dist-info/entry_points.txt +2 -0
- aetherscan-1.0.0.dist-info/licenses/LICENSE +13 -0
aetherscan/inference.py
ADDED
|
@@ -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
|