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,21 @@
1
+ """
2
+ Models package for Aetherscan pipeline
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from .random_forest import (
8
+ RandomForestModel,
9
+ prepare_latent_features,
10
+ )
11
+ from .vae import (
12
+ Sampling,
13
+ create_beta_vae_model,
14
+ )
15
+
16
+ __all__ = [
17
+ "RandomForestModel",
18
+ "Sampling",
19
+ "create_beta_vae_model",
20
+ "prepare_latent_features",
21
+ ]
@@ -0,0 +1,171 @@
1
+ # TODO: refactor to expose public APIs for creating & destroying RandomForestModel instances
2
+ """
3
+ Random Forest classifier implementation for Aetherscan Pipeline
4
+ Receives concatenated latents grouped by their original 6-observation cadence pattern
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+
11
+ import joblib
12
+ import numpy as np
13
+ import numpy.typing as npt
14
+ from sklearn.ensemble import RandomForestClassifier
15
+ from sklearn.utils import shuffle
16
+
17
+ from aetherscan.config import get_config
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def prepare_latent_features(
23
+ latent_vectors: np.ndarray,
24
+ num_observations: int = 6,
25
+ dtype: npt.DTypeLike = np.float64,
26
+ ) -> np.ndarray:
27
+ """
28
+ Reshape per-observation latent vectors of shape (num_cadences * num_observations, latent_dim)
29
+ into per-cadence features of shape (num_cadences, num_observations * latent_dim), so each
30
+ cadence's 6 latents are concatenated into a single feature row for the RF.
31
+
32
+ Caller must keep row i..i+num_observations-1 grouped as cadence i. Raises ValueError if the
33
+ row count isn't divisible by num_observations. `dtype` defaults to float64 (see the note at
34
+ the return) — training callers MUST keep it; inference passes float32.
35
+ """
36
+ # Expected shape: (num_cadences * num_observations, latent_dim)
37
+ num_latents = latent_vectors.shape[0]
38
+
39
+ if num_latents % num_observations != 0:
40
+ raise ValueError(
41
+ f"Received {num_latents} latent vectors. Not divisible by num_observations ({num_observations})"
42
+ )
43
+
44
+ num_cadences = num_latents // num_observations
45
+ latent_dim = latent_vectors.shape[1]
46
+
47
+ # Target shape: (num_cadences, num_observations * latent_dim)
48
+ # Where each element in the latent vector is treated as a feature by the Random Forest
49
+ # We flatten the observations so all 6 latents in a cadence are grouped together.
50
+ # A row-major reshape IS that flatten (row i = rows i*num_obs..(i+1)*num_obs-1
51
+ # raveled) — the per-row Python loop this replaces cost ~0.1-0.5 s per RFI-dense
52
+ # cadence at inference (#301). `dtype` defaults to float64 and MUST stay float64 for
53
+ # the TRAINING callers: the Active-Units gate computes .var() on this output, whose
54
+ # float32 accumulation differs in exactly the low bits that decide a hovering-at-
55
+ # threshold dim (#288's margin lesson). Inference passes dtype=float32 — it never runs
56
+ # that .var() gate (active_dims is loaded from the saved config, not recomputed), and
57
+ # every inference consumer (build_variant_features, sample_z_flat, predict_proba) casts
58
+ # to float32 anyway, so float32 here is byte-identical downstream (float32->float64->
59
+ # float32 round-trips exactly) while skipping two full-matrix float64 widenings per
60
+ # cadence on the GPU-idle RF stage.
61
+ return np.asarray(latent_vectors, dtype=dtype).reshape(
62
+ num_cadences, num_observations * latent_dim
63
+ )
64
+
65
+
66
+ class RandomForestModel:
67
+ """Random Forest classifier for SETI signal detection"""
68
+
69
+ def __init__(self):
70
+ self.config = get_config()
71
+ if self.config is None:
72
+ raise ValueError("get_config() returned None")
73
+
74
+ self.model = RandomForestClassifier(
75
+ n_estimators=self.config.rf.n_estimators,
76
+ bootstrap=self.config.rf.bootstrap,
77
+ max_features=self.config.rf.max_features,
78
+ n_jobs=self.config.rf.n_jobs,
79
+ # Derived from the pipeline root seed unless --rf-seed overrides (#279)
80
+ random_state=self.config.resolved_rf_seed(),
81
+ )
82
+
83
+ self.is_trained = False
84
+
85
+ def train(self, latent_vectors: np.ndarray, binary_labels: np.ndarray):
86
+ """
87
+ Fit the Random Forest on `latent_vectors` of shape
88
+ (n_cadences * num_observations, latent_dim) and `binary_labels` of shape (n_cadences,)
89
+ (0 = false, 1 = true signal). latent_vectors must be grouped so row
90
+ i..i+num_observations-1 corresponds to cadence i — this is what
91
+ prepare_latent_features expects.
92
+ """
93
+ # Prepare features
94
+ features = prepare_latent_features(latent_vectors, self.config.data.num_observations)
95
+
96
+ # Sanity check: make sure length of feature & label arrays are aligned
97
+ if features.shape[0] != binary_labels.shape[0]:
98
+ raise ValueError(
99
+ f"Feature/label count mismatch: {features.shape[0]} vs {binary_labels.shape[0]}"
100
+ )
101
+
102
+ # Shuffle data
103
+ features, binary_labels = shuffle(
104
+ features, binary_labels, random_state=self.config.resolved_rf_seed()
105
+ )
106
+ logger.info(f"Prepared {features.shape[0]} training samples")
107
+
108
+ # Start training
109
+ logger.info("Training Random Forest classifier...")
110
+ self.model.fit(features, binary_labels)
111
+ self.is_trained = True
112
+
113
+ def predict_proba(self, latent_vectors: np.ndarray) -> np.ndarray:
114
+ """
115
+ Predict per-class probabilities for each cadence; returns shape (n_cadences, 2) with
116
+ columns [P(class=0), P(class=1)]. latent_vectors follows the same grouped layout as
117
+ train().
118
+ """
119
+ if not self.is_trained:
120
+ logger.warning("Making predictions with untrained model")
121
+
122
+ features = prepare_latent_features(latent_vectors, self.config.data.num_observations)
123
+ return self.model.predict_proba(features)
124
+
125
+ def predict(self, latent_vectors: np.ndarray, threshold: float = 0.5) -> np.ndarray:
126
+ """
127
+ Binary class predictions: 1 where P(class=1) > threshold, else 0. Returns shape
128
+ (n_cadences,) int array.
129
+ """
130
+ probas = self.predict_proba(latent_vectors)
131
+ return (probas[:, 1] > threshold).astype(int)
132
+
133
+ def predict_verbose(
134
+ self, latent_vectors: np.ndarray, threshold: float = 0.5
135
+ ) -> tuple[np.ndarray, np.ndarray]:
136
+ """
137
+ Like predict(), but also returns per-cadence confidence (the probability of the predicted
138
+ class — so high for confident negatives too, not just confident positives). Returns
139
+ (predictions, confidences), each of shape (n_cadences,).
140
+ """
141
+ probas = self.predict_proba(latent_vectors)
142
+ predictions = (probas[:, 1] > threshold).astype(int)
143
+
144
+ # Confidence score = the probability of the predicted class
145
+ confidences = np.where(predictions, probas[:, 1], probas[:, 0])
146
+
147
+ return predictions, confidences
148
+
149
+ def save(self, filepath: str):
150
+ """Save RF model weights"""
151
+ if not self.is_trained:
152
+ logger.warning("Saving untrained model")
153
+
154
+ joblib.dump(self.model, filepath)
155
+ logger.info(f"Saved Random Forest model to {filepath}")
156
+
157
+ def load(self, filepath: str):
158
+ """Load RF model weights"""
159
+ if self.is_trained:
160
+ logger.warning("Overriding trained model")
161
+
162
+ self.model = joblib.load(filepath)
163
+ # Predict-time parallelism must come from the RUNTIME config, not the training
164
+ # host's pickled value (#301): joblib.load replaces the estimator wholesale, so
165
+ # the constructor's rf.n_jobs was silently dead on every loaded model. n_jobs is
166
+ # a predict-time execution knob on a fitted forest — reassigning it cannot touch
167
+ # the trees. Default (-1) matches the deployed artifacts, so behavior only
168
+ # changes when an operator asks for it.
169
+ self.model.n_jobs = self.config.rf.n_jobs
170
+ self.is_trained = True
171
+ logger.info(f"Loaded Random Forest model from {filepath}")