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,277 @@
1
+ """
2
+ Persisted training-run state for fault-tolerant resume.
3
+
4
+ A TrainingRunState manifest lives at {output_path}/run_state_{save_tag}.json and carries
5
+ everything a retry (in-process or a full relaunch of the identical command) needs to resume
6
+ where the previous attempt died:
7
+
8
+ - run_start_time: wall clock of attempt 1. TrainingPipeline.__init__ seeds self.start_time
9
+ from it, so every DB query/plot spans the whole run rather than just the current attempt.
10
+ - completed_rounds: beta-VAE rounds whose checkpoint was saved; resume starts at max + 1.
11
+ - stages_done / stages_failed: drive run_training_pipeline's stage machine — done stages are
12
+ skipped, failed non-critical stages (plots, hf_upload) are retried on the next run and force a nonzero
13
+ exit if they never succeed.
14
+
15
+ Writes are atomic (tmp -> os.replace, mirroring round_data's .done manifest protocol) so a
16
+ crash mid-write can never leave a truncated manifest — the file is either the previous state
17
+ or the new one.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import contextlib
23
+ import hashlib
24
+ import json
25
+ import logging
26
+ import os
27
+ from dataclasses import asdict, dataclass, field
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Ordered stage names for run_training_pipeline's stage machine.
32
+ STAGE_VAE_ROUNDS = "vae_rounds"
33
+ STAGE_VAE_PLOTS = "vae_plots"
34
+ STAGE_RF_TRAIN = "rf_train"
35
+ STAGE_RF_PLOTS = "rf_plots"
36
+ STAGE_FINAL_SAVE = "final_save"
37
+ STAGE_HF_UPLOAD = "hf_upload" # Opt-in (config.hf.upload_after_training); non-critical
38
+ TRAINING_STAGES = (
39
+ STAGE_VAE_ROUNDS,
40
+ STAGE_VAE_PLOTS,
41
+ STAGE_RF_TRAIN,
42
+ STAGE_RF_PLOTS,
43
+ STAGE_FINAL_SAVE,
44
+ STAGE_HF_UPLOAD,
45
+ )
46
+
47
+
48
+ def run_state_path(output_path: str, tag: str) -> str:
49
+ """Manifest location for one training run: {output_path}/run_state_{save_tag}.json."""
50
+ return os.path.join(output_path, f"run_state_{tag}.json")
51
+
52
+
53
+ # Config sections excluded from a run's fingerprint: pure infra/runtime (db, manager, monitor,
54
+ # logger), resume-control (checkpoint), environment paths, the inference config, and the HF
55
+ # upload config — none of which change the training result, so a change to any of them must NOT
56
+ # force a fresh run. In particular, toggling --hf-upload or changing its repo/revision must never
57
+ # be read as training drift (that would discard the manifest and overwrite a completed model).
58
+ _FINGERPRINT_EXCLUDE_SECTIONS = frozenset(
59
+ {"db", "manager", "monitor", "logger", "inference", "paths", "checkpoint", "hf"}
60
+ )
61
+ # Retry knobs live in the training section but only control the retry loop, not the result.
62
+ _FINGERPRINT_EXCLUDE_TRAINING_KEYS = frozenset({"max_retries", "retry_delay"})
63
+
64
+
65
+ def config_fingerprint(config_dict: dict) -> str:
66
+ """
67
+ Stable hash of the training-result-affecting config (from Config.to_dict()), used to detect
68
+ that a run's config changed under a reused --save-tag. Excludes infra/runtime, resume-control,
69
+ environment paths, the inference config, and the retry knobs (see the constants above) so a
70
+ change to any of those does not spuriously force a fresh run.
71
+ """
72
+ relevant = {
73
+ section: values
74
+ for section, values in config_dict.items()
75
+ if section not in _FINGERPRINT_EXCLUDE_SECTIONS
76
+ }
77
+ training = relevant.get("training")
78
+ if isinstance(training, dict):
79
+ relevant["training"] = {
80
+ k: v for k, v in training.items() if k not in _FINGERPRINT_EXCLUDE_TRAINING_KEYS
81
+ }
82
+ canonical = json.dumps(relevant, sort_keys=True, default=str)
83
+ return hashlib.sha256(canonical.encode()).hexdigest()
84
+
85
+
86
+ def config_changed(state: TrainingRunState | None, current_fingerprint: str) -> bool:
87
+ """
88
+ True when a persisted manifest exists but was written under a different config fingerprint —
89
+ i.e. the resolved training config changed under a reused save tag. The caller downgrades to a
90
+ fresh run so a stale model is never silently reused/reported as success (a manifest predating
91
+ fingerprinting carries an empty string, which mismatches any real fingerprint -> fresh run).
92
+ """
93
+ return state is not None and state.config_fingerprint != current_fingerprint
94
+
95
+
96
+ # Inference-result-affecting config for inference_config_fingerprint(). We DENYLIST the inference
97
+ # keys that don't change per-cadence results (I/O paths, batching, retry knobs, and viz/debug
98
+ # toggles) rather than allowlisting the ones that do — a denylist can only over-invalidate (force a
99
+ # harmless re-inference), never under-invalidate (the actual stale-reuse footgun). The data section
100
+ # is the reverse: it is mostly training params, so we ALLOWLIST only the geometry fields that drive
101
+ # stamp extraction and the encoder input.
102
+ _INFERENCE_FINGERPRINT_EXCLUDE_INFERENCE_KEYS = frozenset(
103
+ {
104
+ "config_path", # source-config file path, not a result input
105
+ "per_replica_batch_size", # batching only; #120's pad+truncate makes results batch-invariant
106
+ "coarse_channel_log_interval", # progress-logging chunk size only (inert)
107
+ "bandpass_debug_plot", # opt-in debug artifact
108
+ "preprocess_output_dir", # where stamps are written (folded into npy_path, not values)
109
+ "inference_viz_enabled", # viz toggle
110
+ "inference_viz_scope", # viz only (#301)
111
+ "stamp_gallery_top_k", # viz only
112
+ "max_candidate_plots", # viz only
113
+ "max_retries", # retry loop only
114
+ "retry_delay", # retry loop only
115
+ "prefetch_depth", # scheduling only; per-cadence results are depth-invariant (#298 N2)
116
+ # Stamp-cache retention only (#302): deletes already-scored artifacts, never
117
+ # changes what a cadence scores. MUST stay excluded — as a new inference key it
118
+ # would otherwise enter BOTH fingerprints, staling every 'inferred' resume row
119
+ # and renaming every ED cache directory on upgrade.
120
+ "prune_stamps",
121
+ }
122
+ )
123
+ _INFERENCE_FINGERPRINT_DATA_KEYS = frozenset(
124
+ {"downsample_factor", "width_bin", "num_observations", "time_bins"}
125
+ )
126
+ # Scoring/model keys excluded ON TOP of the inference denylist for the PREPROCESSING
127
+ # fingerprint (#298 I3): energy detection is deterministic given (csv files, h5 files, ED
128
+ # config), so a changed encoder/RF/threshold must REUSE stamps — that is the whole point of
129
+ # the fingerprint-scoped stamp cache. Everything else in the inference section (cadence
130
+ # grouping columns, channelization, bandpass method/taps, detection windows/threshold, stamp
131
+ # geometry, overlap, the downsample toggle) stays IN the hash. This too is a DENYLIST, never
132
+ # an ED-key allowlist: an allowlist that missed a key (say coarse_channel_width) would
133
+ # silently REUSE WRONG STAMPS when that key changes; a denylist can only over-invalidate.
134
+ _PREPROCESSING_FINGERPRINT_EXTRA_EXCLUDE_KEYS = frozenset(
135
+ {
136
+ "encoder_path",
137
+ "rf_path",
138
+ "classification_threshold",
139
+ "screening_threshold",
140
+ "mc_draws",
141
+ "reference_cloud_size",
142
+ }
143
+ )
144
+
145
+
146
+ def preprocessing_config_fingerprint(config_dict: dict) -> str:
147
+ """
148
+ Stable hash of the PREPROCESSING-result-affecting config — everything that changes what
149
+ energy detection writes into a stamp .npy (#298 I3). Keys the default stamp cache
150
+ directory ({data_path}/inference/preprocessed/<csv_stem>_ed<hash12>/), is persisted into
151
+ each cadence's metadata JSON as ed_config_fingerprint, and is verified by the resume
152
+ guard — so runs sharing an ED config share stamps, and any ED-config change lands in a
153
+ different directory by construction.
154
+ """
155
+ inference = config_dict.get("inference") or {}
156
+ data = config_dict.get("data") or {}
157
+ excluded = (
158
+ _INFERENCE_FINGERPRINT_EXCLUDE_INFERENCE_KEYS
159
+ | _PREPROCESSING_FINGERPRINT_EXTRA_EXCLUDE_KEYS
160
+ )
161
+ relevant = {
162
+ "inference": {k: v for k, v in inference.items() if k not in excluded},
163
+ "data": {k: data[k] for k in _INFERENCE_FINGERPRINT_DATA_KEYS if k in data},
164
+ }
165
+ canonical = json.dumps(relevant, sort_keys=True, default=str)
166
+ return hashlib.sha256(canonical.encode()).hexdigest()
167
+
168
+
169
+ def inference_config_fingerprint(config_dict: dict) -> str:
170
+ """
171
+ Stable hash of the inference-result-affecting config (from Config.to_dict()) — the inference
172
+ counterpart of config_fingerprint. Used to detect that inference was re-run under a reused
173
+ --save-tag with a changed config so a stale 'inferred' manifest row is not silently reused as
174
+ a skip. Covers the inference section minus non-result knobs (see the denylist above) plus the
175
+ data-section geometry that drives stamp extraction and the encoder input.
176
+ """
177
+ inference = config_dict.get("inference") or {}
178
+ data = config_dict.get("data") or {}
179
+ relevant = {
180
+ "inference": {
181
+ k: v
182
+ for k, v in inference.items()
183
+ if k not in _INFERENCE_FINGERPRINT_EXCLUDE_INFERENCE_KEYS
184
+ },
185
+ "data": {k: data[k] for k in _INFERENCE_FINGERPRINT_DATA_KEYS if k in data},
186
+ }
187
+ canonical = json.dumps(relevant, sort_keys=True, default=str)
188
+ return hashlib.sha256(canonical.encode()).hexdigest()
189
+
190
+
191
+ @dataclass
192
+ class TrainingRunState:
193
+ """Persisted state of one training run (identified by its save tag) across attempts."""
194
+
195
+ tag: str
196
+ run_start_time: float # Wall clock of attempt 1 — used by ALL DB queries/plots
197
+ attempt: int = 1 # Incremented per retry (each TrainingPipeline rebuild)
198
+ config_fingerprint: str = "" # Hash of the result-affecting config (config_fingerprint())
199
+ completed_rounds: list[int] = field(default_factory=list)
200
+ stages_done: list[str] = field(default_factory=list)
201
+ stages_failed: list[str] = field(default_factory=list)
202
+
203
+ def is_stage_done(self, stage: str) -> bool:
204
+ return stage in self.stages_done
205
+
206
+ def mark_stage_done(self, stage: str) -> None:
207
+ """Record a stage success; a previously recorded failure of the same stage is cleared."""
208
+ if stage not in self.stages_done:
209
+ self.stages_done.append(stage)
210
+ if stage in self.stages_failed:
211
+ self.stages_failed.remove(stage)
212
+
213
+ def record_stage_failure(self, stage: str) -> None:
214
+ """Record a non-critical stage failure; not in stages_done, so relaunches retry it."""
215
+ if stage not in self.stages_failed:
216
+ self.stages_failed.append(stage)
217
+
218
+ def clear_stage_failure(self, stage: str) -> None:
219
+ """Drop a recorded failure without marking the stage done — used when the user opts
220
+ out of an optional stage (e.g. re-running without --hf-upload) so a stale failure
221
+ can't force a nonzero exit forever."""
222
+ if stage in self.stages_failed:
223
+ self.stages_failed.remove(stage)
224
+
225
+ def mark_round_completed(self, round_number: int) -> None:
226
+ if round_number not in self.completed_rounds:
227
+ self.completed_rounds.append(round_number)
228
+ self.completed_rounds.sort()
229
+
230
+ @property
231
+ def resume_round(self) -> int:
232
+ """1-based round the beta-VAE loop should (re)start from."""
233
+ return max(self.completed_rounds) + 1 if self.completed_rounds else 1
234
+
235
+
236
+ def save_run_state(state: TrainingRunState, path: str) -> None:
237
+ """Persist the manifest atomically (tmp -> os.replace): a crash mid-write leaves either
238
+ the previous manifest or the new one, never a truncated file."""
239
+ parent = os.path.dirname(path)
240
+ if parent:
241
+ os.makedirs(parent, exist_ok=True)
242
+ tmp_path = path + ".tmp"
243
+ try:
244
+ with open(tmp_path, "w") as f:
245
+ json.dump(asdict(state), f, indent=2)
246
+ os.replace(tmp_path, path)
247
+ except Exception:
248
+ with contextlib.suppress(OSError):
249
+ os.remove(tmp_path)
250
+ raise
251
+
252
+
253
+ def load_run_state(path: str) -> TrainingRunState | None:
254
+ """Load a persisted manifest; returns None when the file is missing or unreadable
255
+ (a corrupt manifest downgrades to a fresh run rather than blocking training)."""
256
+ if not os.path.isfile(path):
257
+ return None
258
+ try:
259
+ with open(path) as f:
260
+ payload = json.load(f)
261
+ return TrainingRunState(
262
+ tag=str(payload["tag"]),
263
+ run_start_time=float(payload["run_start_time"]),
264
+ attempt=int(payload.get("attempt", 1)),
265
+ config_fingerprint=str(payload.get("config_fingerprint", "")),
266
+ completed_rounds=sorted(int(r) for r in payload.get("completed_rounds", [])),
267
+ stages_done=[str(s) for s in payload.get("stages_done", [])],
268
+ stages_failed=[str(s) for s in payload.get("stages_failed", [])],
269
+ )
270
+ except KeyError as e:
271
+ logger.warning(
272
+ f"Run state at {path} is missing required field {e} — treating as a fresh run"
273
+ )
274
+ return None
275
+ except Exception as e:
276
+ logger.warning(f"Failed to load run state from {path}: {e} — treating as a fresh run")
277
+ return None
aetherscan/seeding.py ADDED
@@ -0,0 +1,160 @@
1
+ """
2
+ Root-seed stream derivation for reproducible pipeline runs (issues #49, #279).
3
+
4
+ A single root seed (config.reproducibility.seed, --seed on both subcommands) makes every
5
+ random stream in the pipeline deterministic. Rather than seeding numpy's global RNG once
6
+ (streams would collide and any consumer could perturb every other), each consumer derives its
7
+ own independent stream here via np.random.SeedSequence keyed on (root_seed, stream id,
8
+ ...extras) — the same key always reproduces the same stream, and distinct keys are
9
+ statistically independent. Consumers that need a numpy Generator use derive_rng; APIs that
10
+ take an int random_state (sklearn, umap) use derive_seed; TensorFlow's global RNG (weight
11
+ init + the VAE Sampling layer) is seeded via seed_tensorflow, called by BOTH pipeline
12
+ constructors so training and inference cannot drift (#279 — inference used to be entirely
13
+ unseeded, making candidate sets unreproducible).
14
+
15
+ The Random Forest seed is likewise derived from the root (STREAM_RF); config.rf.seed remains
16
+ only as an explicit override for the deprecated --rf-seed flag.
17
+
18
+ Deliberately NOT seeded (do not "fix" these): uuid4 temp-file names (must stay real entropy),
19
+ round_data._array_checksum's content-keyed default_rng (keyed on the array, never the root,
20
+ or checksums stop being comparable), and data_generation's per-PID worker init seeding (always
21
+ overwritten by the per-task root-derived reseed before any draw).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import random
28
+
29
+ import numpy as np
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+ # Stream ids: the leading component of every derived stream key. Each consumer of the root seed
34
+ # owns one id (extra key components — e.g. the round index — subdivide it further), so no two
35
+ # consumers can ever end up sharing a stream. Add a new id here for any new consumer.
36
+ STREAM_DATA_GEN = 0 # per-round worker-task seeds (data_generation.generate_round_to_memmap)
37
+ STREAM_DATASET = 1 # stratified split / trim / epoch shuffles (prepare_distributed_train_dataset)
38
+ STREAM_VIZ = 2 # latent-viz batch selection + padding (train.TrainingPipeline)
39
+ STREAM_PLOT = 3 # plot subsampling (train.TrainingPipeline plot helpers)
40
+ STREAM_RF = 4 # RandomForest estimator random_state + pre-fit shuffle (models/random_forest)
41
+ STREAM_UMAP = 5 # every umap.UMAP random_state + fit-pool subsampling (sub-keyed per site)
42
+ STREAM_KMEANS = 6 # SHAP-space KMeans random_state (train.plot_rf_shap_explanation_clustering)
43
+ STREAM_TF = 7 # tf.random.set_seed roots — sub-keyed 0=training (then per round), 1=inference
44
+ STREAM_INFERENCE_VIZ = 8 # inference_viz latent budget-fill subsample
45
+ STREAM_SHAP_SAMPLES = 9 # SHAP summary/interaction row subsampling (feeds cached artifacts)
46
+ STREAM_RF_PLOTS = 10 # RF plot subsamples (sub-keyed: 0=learning curve, 1=decision boundary)
47
+ STREAM_INFERENCE_MC = 11 # pass-2 MC latent draws (sub-keyed by the cadence's catalog index)
48
+ STREAM_REFERENCE_CLOUD = 12 # reservoir subsample of pass-1 rejects for the reference cloud
49
+ STREAM_KERAS_INIT = 13 # Python's global random, which tf_keras initializers seed from (see below)
50
+
51
+ # One-shot flag so an unseeded run warns exactly once instead of per derived stream.
52
+ # Module-level, so the "once" is PER PROCESS: forkserver/spawn workers each carry their own
53
+ # copy and may repeat the warning in their own logs — intentional (each worker's log is
54
+ # self-contained), not a bug.
55
+ _UNSEEDED_WARNED = False
56
+
57
+
58
+ def _warn_if_unseeded(root_seed: int | None) -> None:
59
+ global _UNSEEDED_WARNED
60
+ if root_seed is None and not _UNSEEDED_WARNED:
61
+ logger.warning(
62
+ "reproducibility.seed is None: every derived random stream falls back to OS "
63
+ "entropy, so this run is NOT reproducible. Set --seed (or leave the default) "
64
+ "for reproducible artifacts."
65
+ )
66
+ _UNSEEDED_WARNED = True
67
+
68
+
69
+ def derive_rng(root_seed: int | None, *stream_key: int) -> np.random.Generator:
70
+ """
71
+ Build the numpy Generator for one consumer stream of the pipeline root seed.
72
+
73
+ With root_seed None this returns a fresh OS-entropy Generator (the historical
74
+ non-reproducible behavior), warning once per process. Otherwise the Generator is seeded
75
+ from SeedSequence([root_seed, *stream_key]), so the same (root_seed, stream_key) always
76
+ yields an identical stream and different keys yield independent ones. `stream_key` must
77
+ start with one of the STREAM_* ids above.
78
+ """
79
+ _warn_if_unseeded(root_seed)
80
+ if root_seed is None:
81
+ return np.random.default_rng()
82
+ return np.random.default_rng(np.random.SeedSequence([root_seed, *stream_key]))
83
+
84
+
85
+ def derive_seed(root_seed: int | None, *stream_key: int) -> int:
86
+ """
87
+ The int-valued sibling of derive_rng, for APIs that take an integer random_state
88
+ (sklearn RandomForestClassifier/KMeans/shuffle, umap.UMAP, tf.random.set_seed).
89
+
90
+ Deterministic given (root_seed, stream_key); with root_seed None it returns a fresh
91
+ OS-entropy int (warning once) so call sites can keep passing a CONCRETE random_state —
92
+ per #279's constraint, determinism knobs stay set at every site that sets one today,
93
+ only the value's provenance changes. Returns a value in [0, 2**32).
94
+ """
95
+ _warn_if_unseeded(root_seed)
96
+ if root_seed is None:
97
+ return int(np.random.SeedSequence().generate_state(1)[0])
98
+ return int(np.random.SeedSequence([root_seed, *stream_key]).generate_state(1)[0])
99
+
100
+
101
+ def seed_tensorflow(root_seed: int | None, deterministic_ops: bool, *stream_key: int) -> int | None:
102
+ """
103
+ Seed TensorFlow's global RNG from the root and (optionally) force deterministic op
104
+ implementations. Called by BOTH TrainingPipeline and InferencePipeline constructors —
105
+ and again at round/cadence boundaries with extended stream keys, so a resumed or
106
+ partially-rerun pipeline reproduces an uninterrupted one (#279: a single __init__-time
107
+ set_seed is not resume-safe, because skipping rounds shifts the stream position).
108
+
109
+ ALSO seeds Python's global `random` module, which is NOT incidental: tf_keras's weight
110
+ initializers (HeNormal/GlorotNormal in models/vae.py) build a
111
+ `backend.RandomGenerator(seed=None, rng_type="stateless")`, whose `_create_seed()` falls
112
+ back to `random.randint(1, int(1e9))` when no Keras seed generator is set. So
113
+ `tf.random.set_seed()` alone does NOT pin weight initialization — measured on the NGC
114
+ 2.17 image: two same-`tf.random.set_seed` builds gave 100% different weights. Seeding
115
+ Python's `random` closes that hole (verified: identical weights same seed, different
116
+ weights different seed).
117
+
118
+ Do NOT "simplify" this to `tf_keras.utils.set_random_seed()`, the canonical Keras API:
119
+ it populates the thread-local `_SEED_GENERATOR`, after which `_create_seed()` calls
120
+ `generator.randint(1, 1e9)` with a FLOAT bound, which Python 3.12's `random.randint`
121
+ rejects — every subsequent initializer raises `TypeError: 'float' object cannot be
122
+ interpreted as an integer`. That path is broken on this stack; this one is not.
123
+
124
+ The VAE `Sampling` layer calls `tf.random.normal` directly and IS covered by
125
+ `tf.random.set_seed` alone — only the initializers needed the extra seeding.
126
+
127
+ Returns the seed applied, or None when root_seed is None (TF keeps its own entropy;
128
+ warned once). TF is imported lazily so this module stays importable without the
129
+ scientific stack.
130
+ """
131
+ import tensorflow as tf # noqa: PLC0415
132
+
133
+ _warn_if_unseeded(root_seed)
134
+ applied: int | None = None
135
+ if root_seed is not None:
136
+ applied = derive_seed(root_seed, STREAM_TF, *stream_key)
137
+ tf.random.set_seed(applied)
138
+ # NOTE: random.seed() is PROCESS-WIDE, with two consequences worth knowing:
139
+ # (1) it pins any transitive consumer of Python's `random` in the imported dependency
140
+ # tree, not just tf_keras — a feature for a reproducibility fix, but it means a
141
+ # library whose behavior varies with RNG state now behaves differently than it did
142
+ # before this call existed. Nothing in the current dep set is known to care.
143
+ # (2) it is not thread-local. data_generation reseeds per task before every draw
144
+ # (data_generation.py:795), so today's single-threaded-per-task usage is safe; but
145
+ # if a CONCURRENT main-process consumer of `random` is ever added (a monitor
146
+ # thread, a plot renderer, a callback), the seed pins the aggregate stream while
147
+ # the per-thread interleaving stays nondeterministic.
148
+ random.seed(derive_seed(root_seed, STREAM_KERAS_INIT, *stream_key))
149
+
150
+ if deterministic_ops:
151
+ # Deterministic kernels only pin op results; without a seed the draws still differ
152
+ # run to run, so flag the (legal but pointless) combination loudly
153
+ tf.config.experimental.enable_op_determinism()
154
+ logger.info("Enabled deterministic TF op implementations (tf_deterministic_ops)")
155
+ if root_seed is None:
156
+ logger.warning(
157
+ "tf_deterministic_ops is enabled but reproducibility.seed is None: "
158
+ "deterministic kernels alone do not make runs reproducible"
159
+ )
160
+ return applied
@@ -0,0 +1,238 @@
1
+ """
2
+ Process-pool wrapper for the RF SHAP passes.
3
+
4
+ shap's TreeSHAP C extension is single-threaded (no OpenMP, no ``n_jobs``), so explaining a
5
+ 1000-tree forest at production sample counts runs for hours-to-days — dominated by the interaction
6
+ pass. SHAP values are per-sample independent, though, so we chunk the samples across processes and
7
+ call a **stock** ``shap.TreeExplainer`` in each worker (not a fork of the algorithm — just
8
+ parallelism). Measured ~40-45x on a 96-core node, byte-identical to the single-threaded result.
9
+
10
+ This module is deliberately kept small and off the TF/training import graph. ``train.py`` imports
11
+ TensorFlow at module level (and pulls in matplotlib/umap/…), and multiprocessing's ``spawn`` re-imports
12
+ the parent's ``__main__`` (``aetherscan.main`` → TF → the whole training stack) into every worker. So
13
+ the pool runs under the **forkserver** start method with an **empty preload**: the fork server is
14
+ spawned once, clean (no ``__main__`` re-import), and workers fork from it — importing only this module
15
+ + shap, never TF. See ``_compute_or_load_shap_values`` in ``train.py`` for the caller and
16
+ ``docs/TRAINING_PIPELINE.md`` for the CPU-vs-GPU comparison behind this choice.
17
+
18
+ Design notes:
19
+ * **Rebuild the explainer inside each worker** from the picklable sklearn RF — never pickle a
20
+ pre-built ``TreeExplainer`` into workers (segfaults large-model workers; shap #1204). Workers
21
+ load the RF from its persisted joblib path so the model isn't re-serialised through the pool.
22
+ * **One pool per session** (``shap_pool``): all three passes map onto the same worker set, so
23
+ workers start, load the RF, and build their explainers once per session rather than once per
24
+ pass. Each worker caches one explainer per pass family (plain vs log-loss) — see
25
+ ``_get_explainer``.
26
+ * **forkserver + empty preload** keeps workers off the TF/training stack (see above); the fork
27
+ server is spawned once and forks lightweight workers.
28
+ * **Pin BLAS/OpenMP threads to 1 per worker** so N workers don't oversubscribe the CPU (best-effort:
29
+ shap's TreeSHAP is single-threaded, so this only tames incidental numpy/BLAS threads).
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import contextlib
35
+ import multiprocessing as mp
36
+ import os
37
+ from concurrent.futures import ProcessPoolExecutor
38
+
39
+ import numpy as np
40
+
41
+ # Kinds of SHAP pass a worker can run.
42
+ _RAW_KINDS = ("summary", "interaction")
43
+ _LOGLOSS_KIND = "logloss"
44
+
45
+ # Per-worker globals, populated once by the pool initializer (or in-process for the n_workers==1
46
+ # fallback). Module-level so the forkserver workers can resolve them. _EXPLAINERS caches one built
47
+ # explainer per pass family ("raw" vs "logloss") so a worker serving several chunks — and several
48
+ # passes, now that one pool spans all of them — parses the forest once per family, not per chunk.
49
+ _RF = None
50
+ _BACKGROUND = None
51
+ _EXPLAINERS: dict[str, object] = {}
52
+
53
+ # Chunks submitted per worker per pass: >1 smooths stragglers (TreeSHAP cost varies per sample, so
54
+ # a worker stuck on a slow chunk no longer idles the rest for the whole tail of the pass).
55
+ _CHUNKS_PER_WORKER = 4
56
+
57
+
58
+ def select_positive_class_shap(values, log_loss: bool = False) -> np.ndarray:
59
+ """
60
+ Normalize SHAP output across shap versions into a single positive-class ndarray.
61
+
62
+ TreeExplainer returns results in several shapes depending on shap version & task:
63
+ - classic binary classification: list ``[neg, pos]`` of ``(n, F)`` arrays
64
+ - newer shap, last axis is class: ``(n, F, 2)`` for values, ``(n, F, F, 2)`` for interactions
65
+ - log-loss (single scalar output): ``(n, F)`` or ``(n, F, F)``
66
+
67
+ Picks the positive-class slice in all cases. For log-loss there is only one output, so it is
68
+ returned as-is (modulo a preserved class axis). Kept here (not in train.py) so both the pipeline
69
+ and the TF-free workers share one definition.
70
+ """
71
+ if log_loss:
72
+ if isinstance(values, list):
73
+ return np.asarray(values[0])
74
+ values = np.asarray(values)
75
+ # newer shap preserves the class axis even for model_output="log_loss":
76
+ # (n, F, 2) -> (n, F); (n, F, F, 2) -> (n, F, F)
77
+ if values.ndim >= 3 and values.shape[-1] == 2:
78
+ return values[..., 1]
79
+ return values
80
+
81
+ if isinstance(values, list):
82
+ return np.asarray(values[1])
83
+
84
+ values = np.asarray(values)
85
+ # (n, F, 2) -> (n, F); (n, F, F, 2) -> (n, F, F)
86
+ if values.ndim >= 3 and values.shape[-1] == 2:
87
+ return values[..., 1]
88
+ return values
89
+
90
+
91
+ def _pin_worker_threads() -> None:
92
+ # Must run before numpy/shap import their native thread pools. shap's TreeSHAP is single-threaded
93
+ # anyway, so this only tames incidental numpy/BLAS threads — but with all cores as workers that
94
+ # still matters. setdefault so an explicitly-set env is respected.
95
+ for var in (
96
+ "OMP_NUM_THREADS",
97
+ "OPENBLAS_NUM_THREADS",
98
+ "MKL_NUM_THREADS",
99
+ "NUMEXPR_NUM_THREADS",
100
+ "VECLIB_MAXIMUM_THREADS",
101
+ "BLIS_NUM_THREADS",
102
+ ):
103
+ os.environ.setdefault(var, "1")
104
+
105
+
106
+ def _worker_init(rf_path: str, background) -> None:
107
+ """Pool initializer: pin threads, then load the RF once per worker (rebuild-per-worker source)."""
108
+ _pin_worker_threads()
109
+ import joblib # noqa: PLC0415 (deferred so _pin_worker_threads() sets env before numpy/BLAS init)
110
+
111
+ global _RF, _BACKGROUND
112
+ _RF = joblib.load(rf_path)
113
+ _BACKGROUND = background
114
+ # In-process (n_workers==1) sessions reuse this module's globals across calls — drop any
115
+ # explainers built for a previous session's RF.
116
+ _EXPLAINERS.clear()
117
+
118
+
119
+ def _get_explainer(kind: str):
120
+ """
121
+ Return the cached per-worker explainer for ``kind``, building it on first use.
122
+
123
+ The log-loss pass needs a structurally different explainer (interventional:
124
+ ``model_output="log_loss"`` + background data), so it gets its own cache slot; the summary
125
+ and interaction passes share the one plain ``TreeExplainer``.
126
+ """
127
+ import shap # noqa: PLC0415 (deferred so the worker pins threads before shap/BLAS init)
128
+
129
+ # Validate here, not just at the top-level API: the in-process (n_workers <= 1) path
130
+ # reaches _worker without _validate_pass, and silently collapsing an unknown kind into
131
+ # the "raw" slot would hand a future pass the wrong explainer.
132
+ if kind != _LOGLOSS_KIND and kind not in _RAW_KINDS:
133
+ raise ValueError(f"unknown SHAP kind {kind!r}")
134
+ key = _LOGLOSS_KIND if kind == _LOGLOSS_KIND else "raw"
135
+ explainer = _EXPLAINERS.get(key)
136
+ if explainer is None:
137
+ if key == _LOGLOSS_KIND:
138
+ explainer = shap.TreeExplainer(_RF, data=_BACKGROUND, model_output="log_loss")
139
+ else:
140
+ explainer = shap.TreeExplainer(_RF)
141
+ _EXPLAINERS[key] = explainer
142
+ return explainer
143
+
144
+
145
+ def _worker(task):
146
+ """Compute one SHAP pass on one chunk of samples using the module-global RF. Returns the
147
+ positive-class array for the chunk; ProcessPoolExecutor.map preserves input order."""
148
+ kind, x_chunk, y_chunk = task
149
+ explainer = _get_explainer(kind)
150
+
151
+ # Suppress shap's tqdm (it writes to stderr, which the pipeline logger forwards to Slack).
152
+ with open(os.devnull, "w") as devnull, contextlib.redirect_stderr(devnull):
153
+ if kind == _LOGLOSS_KIND:
154
+ raw = explainer.shap_values(x_chunk, y=y_chunk)
155
+ elif kind == "interaction":
156
+ raw = explainer.shap_interaction_values(x_chunk)
157
+ else:
158
+ raw = explainer.shap_values(x_chunk)
159
+ return select_positive_class_shap(raw, log_loss=(kind == _LOGLOSS_KIND))
160
+
161
+
162
+ def _validate_pass(kind: str, n: int) -> None:
163
+ if kind != _LOGLOSS_KIND and kind not in _RAW_KINDS:
164
+ raise ValueError(f"unknown SHAP kind {kind!r}")
165
+ if n == 0:
166
+ raise ValueError("cannot explain zero samples")
167
+
168
+
169
+ @contextlib.contextmanager
170
+ def shap_pool(rf_path: str, n_workers: int, *, background: np.ndarray | None = None):
171
+ """
172
+ One worker pool shared by every SHAP pass computed inside the block.
173
+
174
+ Yields ``run(kind, x, y=None) -> np.ndarray`` computing the positive-class SHAP values for
175
+ one pass ``kind`` in ``{"summary", "interaction", "logloss"}``. Each pass historically spun
176
+ up (and tore down) its own pool; sharing one pool starts the workers — and parses the RF
177
+ into per-worker explainers (see ``_get_explainer``) — once per session instead of once per
178
+ pass. ``background`` must be supplied at pool creation when a ``"logloss"`` pass will run
179
+ (workers receive it through the initializer, not per task). ``n_workers <= 1`` runs every
180
+ pass in-process (no pool) — single-core hosts or tiny inputs.
181
+ """
182
+ if n_workers <= 1:
183
+ _worker_init(rf_path, background)
184
+
185
+ def run(kind: str, x: np.ndarray, y: np.ndarray | None = None) -> np.ndarray:
186
+ _validate_pass(kind, len(x))
187
+ return _worker((kind, x, y))
188
+
189
+ yield run
190
+ return
191
+
192
+ # forkserver with an EMPTY preload: the fork server is spawned clean and does NOT re-import the
193
+ # parent's __main__ (aetherscan.main -> TF -> the whole training stack), so workers stay light.
194
+ ctx = mp.get_context("forkserver")
195
+ ctx.set_forkserver_preload([])
196
+ with ProcessPoolExecutor(
197
+ max_workers=n_workers,
198
+ mp_context=ctx,
199
+ initializer=_worker_init,
200
+ initargs=(rf_path, background),
201
+ ) as pool:
202
+
203
+ def run(kind: str, x: np.ndarray, y: np.ndarray | None = None) -> np.ndarray:
204
+ _validate_pass(kind, len(x))
205
+ # ~_CHUNKS_PER_WORKER chunks per worker smooth stragglers. The chunk count cannot
206
+ # change the numbers: TreeSHAP is per-sample exact and per-sample independent, and
207
+ # pool.map yields chunk results in submission order, so concatenating them is
208
+ # bitwise-identical to the serial result for ANY chunking.
209
+ n_chunks = min(len(x), n_workers * _CHUNKS_PER_WORKER)
210
+ chunks = np.array_split(np.arange(len(x)), n_chunks)
211
+ tasks = [(kind, x[c], (None if y is None else y[c])) for c in chunks if len(c)]
212
+ return np.concatenate(list(pool.map(_worker, tasks)), axis=0)
213
+
214
+ yield run
215
+
216
+
217
+ def parallel_shap(
218
+ rf_path: str,
219
+ x: np.ndarray,
220
+ kind: str,
221
+ n_workers: int,
222
+ *,
223
+ background: np.ndarray | None = None,
224
+ y: np.ndarray | None = None,
225
+ ) -> np.ndarray:
226
+ """
227
+ Compute the positive-class SHAP values for ``x`` under one pass ``kind`` in
228
+ ``{"summary", "interaction", "logloss"}``, chunked across up to ``n_workers`` worker processes
229
+ (a dedicated single-pass pool; callers running several passes should hold one ``shap_pool``
230
+ open instead — ``_compute_or_load_shap_values`` in ``train.py`` does).
231
+
232
+ ``rf_path`` is the persisted sklearn RF joblib (each worker loads it). ``background`` and ``y`` are
233
+ required only for the ``"logloss"`` (interventional) pass. TreeSHAP is per-sample deterministic, so
234
+ the concatenated result is bitwise-identical to the single-process computation.
235
+ """
236
+ _validate_pass(kind, len(x))
237
+ with shap_pool(rf_path, max(1, min(n_workers, len(x))), background=background) as run:
238
+ return run(kind, x, y)