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,352 @@
1
+ """
2
+ Latent-representation variants, selection metrics, and probability calibration (#282).
3
+
4
+ The RF historically consumed the stochastic sampled `z` — nobody ever tested whether that is
5
+ the best representation, and the encoder's `z_mean`/`z_log_var` were computed and thrown away
6
+ at the RF stage. This module defines the 8-variant catalogue trained in one sweep on the same
7
+ generated data and split, the feature builders that inference reuses (driven by the saved
8
+ config's rf.latent_variant — never hardcoded), the selection metrics (recall at a fixed low
9
+ FPR is primary; AUC averages over operating points the pipeline never uses), the bootstrap
10
+ minimum-margin tie-break toward simpler variants, and the ECE-gated probability calibrator.
11
+
12
+ Everything operates on FLATTENED per-cadence feature blocks of shape
13
+ (n_cadences, num_observations * latent_dim) — the exact output of
14
+ models.random_forest.prepare_latent_features — so builders compose by hstack and the RF
15
+ feature layout stays `[z_mean block | extras]`, documented for SHAP readability.
16
+
17
+ Deliberately TF-free (mirrors rf_metrics.py): imported by both train.py and inference.py, and
18
+ unit-testable without the scientific stack.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+
25
+ import numpy as np
26
+ from sklearn.isotonic import IsotonicRegression
27
+ from sklearn.linear_model import LogisticRegression
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Variant catalogue, ordered SIMPLE -> COMPLEX (by feature count, deterministic-first among
32
+ # equals). The minimum-margin selection walks this order, so ties break toward the earlier
33
+ # (simpler / cheaper-SHAP) variant. Names are persisted in config_{tag}.json (rf.latent_variant)
34
+ # and in artifact filenames — treat them as a stable contract.
35
+ VARIANT_ORDER = [
36
+ "z_mean", # deterministic baseline: per-obs posterior means (F = 48 at defaults)
37
+ "z", # legacy baseline: one stochastic sample (F = 48)
38
+ "z_aug", # trained on z_mean + K sampled draws as extra ROWS, evaluated on z_mean (F = 48)
39
+ "z_mean_total_kl", # z_mean + total KL per cadence (F = 49)
40
+ "z_mean_obs_logvar", # z_mean + per-observation mean log_var (F = 48 + num_obs = 54)
41
+ "z_mean_dim_logvar", # z_mean + per-dim mean log_var over obs (F = 48 + latent_dim = 56)
42
+ "z_mean_logvar_active", # z_mean + log_var restricted to ACTIVE dims (F = 48..96)
43
+ "z_mean_logvar", # full per-dimension uncertainty (F = 96)
44
+ ]
45
+
46
+
47
+ def _reshape_blocks(flat: np.ndarray, num_observations: int, latent_dim: int) -> np.ndarray:
48
+ return np.asarray(flat).reshape(-1, num_observations, latent_dim)
49
+
50
+
51
+ def per_cadence_kl(z_mean_flat: np.ndarray, z_log_var_flat: np.ndarray) -> np.ndarray:
52
+ """Total KL divergence per cadence: -0.5*(1 + lv - mu^2 - e^lv) summed over every
53
+ (observation, dim) — the same closed form as models/vae.py's loss term."""
54
+ z_mean = np.asarray(z_mean_flat, dtype=np.float64)
55
+ z_log_var = np.asarray(z_log_var_flat, dtype=np.float64)
56
+ kl = -0.5 * (1.0 + z_log_var - np.square(z_mean) - np.exp(z_log_var))
57
+ return kl.sum(axis=1).astype(np.float32)
58
+
59
+
60
+ def latent_dim_variances(
61
+ z_mean_flat: np.ndarray, num_observations: int, latent_dim: int
62
+ ) -> np.ndarray:
63
+ """Per-dim z_mean variance across samples (pooling all observations) — the raw quantity
64
+ the Active Units threshold cuts on. Exposed so callers can log the margins: a dim a hair
65
+ below the cutoff and a dim that went dark read identically in the AU count but mean
66
+ opposite things for an A/B parity decision (#288's fp16 verdict hinged on exactly this)."""
67
+ per_obs = _reshape_blocks(z_mean_flat, num_observations, latent_dim)
68
+ pooled = per_obs.reshape(-1, latent_dim)
69
+ return pooled.var(axis=0)
70
+
71
+
72
+ def active_latent_dims(
73
+ z_mean_flat: np.ndarray, num_observations: int, latent_dim: int, threshold: float
74
+ ) -> list[int]:
75
+ """
76
+ Active Units (Burda et al.): dims whose z_mean variance across samples (pooling all
77
+ observations) exceeds `threshold`. Collapsed dims carry near-constant posteriors and
78
+ contribute dead-weight log_var features — this gates the z_mean_logvar_active variant
79
+ and feeds check_posterior_collapse.
80
+ """
81
+ variances = latent_dim_variances(z_mean_flat, num_observations, latent_dim)
82
+ return [int(d) for d in np.nonzero(variances > threshold)[0]]
83
+
84
+
85
+ def _active_logvar_columns(
86
+ active_dims: list[int], num_observations: int, latent_dim: int
87
+ ) -> list[int]:
88
+ """Column indices of the active dims inside a flattened per-cadence block (obs-major)."""
89
+ return [
90
+ obs * latent_dim + dim for obs in range(num_observations) for dim in sorted(active_dims)
91
+ ]
92
+
93
+
94
+ def variant_feature_count(
95
+ variant: str, num_observations: int, latent_dim: int, active_dims: list[int] | None = None
96
+ ) -> int:
97
+ base = num_observations * latent_dim
98
+ if variant in ("z", "z_mean", "z_aug"):
99
+ return base
100
+ if variant == "z_mean_total_kl":
101
+ return base + 1
102
+ if variant == "z_mean_obs_logvar":
103
+ return base + num_observations
104
+ if variant == "z_mean_dim_logvar":
105
+ return base + latent_dim
106
+ if variant == "z_mean_logvar_active":
107
+ return base + num_observations * len(active_dims or [])
108
+ if variant == "z_mean_logvar":
109
+ return 2 * base
110
+ raise ValueError(f"Unknown latent variant {variant!r}; expected one of {VARIANT_ORDER}")
111
+
112
+
113
+ def build_variant_features(
114
+ variant: str,
115
+ lead_flat: np.ndarray,
116
+ z_log_var_flat: np.ndarray,
117
+ num_observations: int,
118
+ latent_dim: int,
119
+ active_dims: list[int] | None = None,
120
+ ) -> np.ndarray:
121
+ """
122
+ Assemble one variant's feature matrix from a LEAD block plus the log_var block.
123
+
124
+ `lead_flat` fills the first num_observations*latent_dim columns: z_mean for the
125
+ deterministic (pass-1 / evaluation) form, a sampled draw for MC scoring, or the sampled
126
+ z for the legacy "z" variant's training rows. The uncertainty extras always come from
127
+ the DETERMINISTIC z_log_var block. Layout is [lead | extras], obs-major within blocks —
128
+ the RF is permutation-invariant across features, so ordering only affects SHAP/feature
129
+ naming readability.
130
+ """
131
+ lead = np.asarray(lead_flat, dtype=np.float32)
132
+ if variant in ("z", "z_mean", "z_aug"):
133
+ return lead
134
+
135
+ z_log_var = np.asarray(z_log_var_flat, dtype=np.float32)
136
+ if variant == "z_mean_logvar":
137
+ return np.hstack([lead, z_log_var])
138
+ if variant == "z_mean_total_kl":
139
+ kl = per_cadence_kl(lead, z_log_var)
140
+ return np.hstack([lead, kl[:, None]])
141
+ if variant == "z_mean_obs_logvar":
142
+ per_obs_mean = _reshape_blocks(z_log_var, num_observations, latent_dim).mean(axis=2)
143
+ return np.hstack([lead, per_obs_mean.astype(np.float32)])
144
+ if variant == "z_mean_dim_logvar":
145
+ per_dim_mean = _reshape_blocks(z_log_var, num_observations, latent_dim).mean(axis=1)
146
+ return np.hstack([lead, per_dim_mean.astype(np.float32)])
147
+ if variant == "z_mean_logvar_active":
148
+ columns = _active_logvar_columns(active_dims or [], num_observations, latent_dim)
149
+ if not columns:
150
+ # Every dim collapsed (or none measured active) — degenerate to plain z_mean
151
+ return lead
152
+ return np.hstack([lead, z_log_var[:, columns]])
153
+ raise ValueError(f"Unknown latent variant {variant!r}; expected one of {VARIANT_ORDER}")
154
+
155
+
156
+ def variant_feature_names(
157
+ variant: str,
158
+ num_observations: int,
159
+ latent_dim: int,
160
+ active_dims: list[int] | None = None,
161
+ ) -> list[str]:
162
+ """
163
+ Human-readable column names mirroring build_variant_features' exact layout —
164
+ [lead | extras], obs-major within blocks. Even-indexed observations are ON, odd are OFF,
165
+ pairs numbered 1..3 (the data_generation cadence convention). MUST stay column-for-column
166
+ in lockstep with build_variant_features: the SHAP plots pair these names with the feature
167
+ matrix, and a length mismatch is an IndexError inside shap (a 54-feature
168
+ z_mean_obs_logvar winner against the old hardcoded 48-name list is exactly how this
169
+ function came to exist).
170
+ """
171
+
172
+ def obs_label(o: int) -> str:
173
+ return f"{'ON' if o % 2 == 0 else 'OFF'}-{o // 2 + 1}"
174
+
175
+ lead = [f"{obs_label(o)}_dim-{d}" for o in range(num_observations) for d in range(latent_dim)]
176
+ if variant in ("z", "z_mean", "z_aug"):
177
+ return lead
178
+ if variant == "z_mean_logvar":
179
+ return lead + [
180
+ f"logvar_{obs_label(o)}_dim-{d}"
181
+ for o in range(num_observations)
182
+ for d in range(latent_dim)
183
+ ]
184
+ if variant == "z_mean_total_kl":
185
+ return lead + ["total_kl"]
186
+ if variant == "z_mean_obs_logvar":
187
+ return lead + [f"logvar_mean_{obs_label(o)}" for o in range(num_observations)]
188
+ if variant == "z_mean_dim_logvar":
189
+ return lead + [f"logvar_mean_dim-{d}" for d in range(latent_dim)]
190
+ if variant == "z_mean_logvar_active":
191
+ columns = _active_logvar_columns(active_dims or [], num_observations, latent_dim)
192
+ return lead + [f"logvar_{obs_label(c // latent_dim)}_dim-{c % latent_dim}" for c in columns]
193
+ raise ValueError(f"Unknown latent variant {variant!r}; expected one of {VARIANT_ORDER}")
194
+
195
+
196
+ def sample_z_flat(
197
+ z_mean_flat: np.ndarray, z_log_var_flat: np.ndarray, rng: np.random.Generator
198
+ ) -> np.ndarray:
199
+ """One reparameterized draw z = mu + exp(lv/2) * eps on the flattened blocks (numpy —
200
+ seeded via aetherscan.seeding, independent of TF's RNG)."""
201
+ z_mean = np.asarray(z_mean_flat, dtype=np.float32)
202
+ z_log_var = np.asarray(z_log_var_flat, dtype=np.float32)
203
+ epsilon = rng.standard_normal(size=z_mean.shape).astype(np.float32)
204
+ return z_mean + np.exp(0.5 * z_log_var) * epsilon
205
+
206
+
207
+ def build_z_aug_training_set(
208
+ z_mean_flat: np.ndarray,
209
+ z_log_var_flat: np.ndarray,
210
+ labels: np.ndarray,
211
+ draws: int,
212
+ rng: np.random.Generator,
213
+ ) -> tuple[np.ndarray, np.ndarray]:
214
+ """The z_aug variant's training rows: the deterministic z_mean row plus `draws` sampled
215
+ rows per cadence (sampling-as-augmentation), labels replicated to match. Evaluation
216
+ stays on plain z_mean — that is the whole point of the variant."""
217
+ blocks = [np.asarray(z_mean_flat, dtype=np.float32)]
218
+ blocks += [sample_z_flat(z_mean_flat, z_log_var_flat, rng) for _ in range(draws)]
219
+ features = np.vstack(blocks)
220
+ stacked_labels = np.concatenate([np.asarray(labels)] * (draws + 1))
221
+ return features, stacked_labels
222
+
223
+
224
+ # ------------------------------------------------------------------ selection metrics
225
+
226
+
227
+ def recall_at_fpr(labels: np.ndarray, scores: np.ndarray, max_fpr: float) -> float:
228
+ """
229
+ Recall of the positive class at the score threshold whose false-positive rate is
230
+ `max_fpr`: the primary #282 selection metric (don't miss signals while keeping the
231
+ candidate list reviewable). Returns NaN for single-class labels.
232
+ """
233
+ labels = np.asarray(labels).astype(bool)
234
+ scores = np.asarray(scores)
235
+ negatives = scores[~labels]
236
+ positives = scores[labels]
237
+ if len(negatives) == 0 or len(positives) == 0:
238
+ return float("nan")
239
+ threshold = np.quantile(negatives, 1.0 - max_fpr)
240
+ return float(np.mean(positives > threshold))
241
+
242
+
243
+ def expected_calibration_error(labels: np.ndarray, probas: np.ndarray, n_bins: int = 10) -> float:
244
+ """ECE over quantile bins — the same binning strategy as plot_rf_calibration_curve, so
245
+ the gate and the plotted number agree."""
246
+ labels = np.asarray(labels, dtype=np.float64)
247
+ probas = np.asarray(probas, dtype=np.float64)
248
+ edges = np.quantile(probas, np.linspace(0.0, 1.0, n_bins + 1))
249
+ edges[0], edges[-1] = -np.inf, np.inf
250
+ ece = 0.0
251
+ for low, high in zip(edges[:-1], edges[1:], strict=False):
252
+ mask = (probas > low) & (probas <= high)
253
+ if not mask.any():
254
+ continue
255
+ ece += (mask.mean()) * abs(labels[mask].mean() - probas[mask].mean())
256
+ return float(ece)
257
+
258
+
259
+ def recall_margin_lower_bound(
260
+ labels: np.ndarray,
261
+ scores_best: np.ndarray,
262
+ scores_simpler: np.ndarray,
263
+ max_fpr: float,
264
+ rounds: int,
265
+ rng: np.random.Generator,
266
+ alpha: float = 0.05,
267
+ ) -> float:
268
+ """Lower bound of the bootstrap CI on recall@FPR(best) - recall@FPR(simpler). A bound
269
+ <= 0 means the best variant does not beat the simpler one by more than noise."""
270
+ labels = np.asarray(labels)
271
+ n = len(labels)
272
+ diffs = np.empty(rounds)
273
+ for i in range(rounds):
274
+ idx = rng.integers(0, n, size=n)
275
+ diffs[i] = recall_at_fpr(labels[idx], scores_best[idx], max_fpr) - recall_at_fpr(
276
+ labels[idx], scores_simpler[idx], max_fpr
277
+ )
278
+ return float(np.nanquantile(diffs, alpha))
279
+
280
+
281
+ def select_winner(
282
+ labels: np.ndarray,
283
+ scores_by_variant: dict[str, np.ndarray],
284
+ max_fpr: float,
285
+ bootstrap_rounds: int,
286
+ rng: np.random.Generator,
287
+ ) -> tuple[str, dict[str, float]]:
288
+ """
289
+ Minimum-margin selection (#282): compute recall@FPR for every variant, find the best,
290
+ then walk VARIANT_ORDER (simple -> complex) and return the FIRST variant that is
291
+ statistically tied with the best (bootstrap lower bound of the best-minus-variant
292
+ margin <= 0). Only a variant the best beats beyond noise is passed over.
293
+ """
294
+ recalls = {
295
+ name: recall_at_fpr(labels, scores, max_fpr) for name, scores in scores_by_variant.items()
296
+ }
297
+ ordered = [name for name in VARIANT_ORDER if name in scores_by_variant]
298
+ best = max(ordered, key=lambda name: (recalls[name], -ordered.index(name)))
299
+ for name in ordered:
300
+ if name == best:
301
+ break
302
+ margin_bound = recall_margin_lower_bound(
303
+ labels,
304
+ scores_by_variant[best],
305
+ scores_by_variant[name],
306
+ max_fpr,
307
+ bootstrap_rounds,
308
+ rng,
309
+ )
310
+ if margin_bound <= 0:
311
+ logger.info(
312
+ f"Variant selection: '{best}' (recall {recalls[best]:.4f}) does not beat "
313
+ f"simpler '{name}' (recall {recalls[name]:.4f}) beyond noise "
314
+ f"(bootstrap lower margin {margin_bound:+.4f}) — tie broken toward '{name}'"
315
+ )
316
+ return name, recalls
317
+ return best, recalls
318
+
319
+
320
+ # ------------------------------------------------------------------ probability calibration
321
+
322
+
323
+ def fit_probability_calibrator(probas: np.ndarray, labels: np.ndarray, min_isotonic: int) -> dict:
324
+ """
325
+ Fit a probability calibrator on HELD-OUT calibration rows (#282): isotonic when the set
326
+ is large enough (>= min_isotonic rows), else sigmoid/Platt (isotonic overfits small
327
+ sets). Returned dict {method, model} is joblib-persistable as rf_calibrator_{tag}.joblib
328
+ and applied identically at inference — an unapplied calibrator would be a silent
329
+ train/serve mismatch.
330
+ """
331
+ probas = np.asarray(probas, dtype=np.float64)
332
+ labels = np.asarray(labels, dtype=np.int64)
333
+ if len(probas) >= min_isotonic:
334
+ model = IsotonicRegression(y_min=0.0, y_max=1.0, out_of_bounds="clip")
335
+ model.fit(probas, labels)
336
+ method = "isotonic"
337
+ else:
338
+ model = LogisticRegression(solver="lbfgs")
339
+ model.fit(probas.reshape(-1, 1), labels)
340
+ method = "sigmoid"
341
+ return {"method": method, "model": model}
342
+
343
+
344
+ def apply_probability_calibrator(calibrator: dict | None, probas: np.ndarray) -> np.ndarray:
345
+ """Map raw RF probabilities through the calibrator (identity when None). Monotonic, so
346
+ rank metrics (AUC, recall@FPR) are unchanged — only probability VALUES move."""
347
+ if calibrator is None:
348
+ return np.asarray(probas)
349
+ probas = np.asarray(probas, dtype=np.float64)
350
+ if calibrator["method"] == "isotonic":
351
+ return calibrator["model"].predict(probas)
352
+ return calibrator["model"].predict_proba(probas.reshape(-1, 1))[:, 1]
@@ -0,0 +1,21 @@
1
+ """
2
+ Logger package for Aetherscan pipeline
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from .logger import (
8
+ get_logger,
9
+ init_logger,
10
+ init_worker_logging,
11
+ shutdown_logger,
12
+ )
13
+ from .slack_handler import SlackHandler
14
+
15
+ __all__ = [
16
+ "get_logger",
17
+ "init_logger",
18
+ "init_worker_logging",
19
+ "shutdown_logger",
20
+ "SlackHandler",
21
+ ]