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/run_state.py
ADDED
|
@@ -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)
|