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/latent_gif.py
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Process-parallel latent-space GIF frame rendering (#278).
|
|
3
|
+
|
|
4
|
+
The latent-GIF stage's cost is almost entirely matplotlib: the old implementation rebuilt a
|
|
5
|
+
figure, re-scattered every category, and saved a PNG per frame on one thread — ~6.6 s/frame,
|
|
6
|
+
~55 min per GIF, ~24-29 h across the 24-GIF sweep. Frames are fully independent (each needs
|
|
7
|
+
only its 2D coords, labels, the shared axis limits and palette), so this module renders them
|
|
8
|
+
across a process pool, mirroring shap_parallel.py's isolation pattern: forkserver context with
|
|
9
|
+
an EMPTY preload so workers never re-import the parent __main__ (aetherscan.main -> TF -> the
|
|
10
|
+
training stack), and this module itself stays off the TF import graph (train.py imports it,
|
|
11
|
+
never the reverse). Unlike SHAP, per-worker memory is tiny (one chunk of frames), so the pool
|
|
12
|
+
is core-bound — size it by cores, not RAM.
|
|
13
|
+
|
|
14
|
+
Within each worker the figure and the per-category scatter artists are created ONCE and updated
|
|
15
|
+
per frame via set_offsets / set_title (the standard animation idiom); the legend is rebuilt per
|
|
16
|
+
frame from the categories actually present, matching the old output exactly. Rendering is
|
|
17
|
+
deterministic: the same frames produce byte-identical PNGs regardless of worker count.
|
|
18
|
+
|
|
19
|
+
GIF assembly (imageio) and Slack upload stay in the caller — only PNG rendering parallelizes.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import multiprocessing as mp
|
|
25
|
+
import os
|
|
26
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
|
|
29
|
+
import numpy as np
|
|
30
|
+
|
|
31
|
+
# Worker-global render state, set up once per worker by _worker_init (module globals survive
|
|
32
|
+
# across _worker calls within one worker process, so the figure is built once, not per chunk)
|
|
33
|
+
_STYLE: dict | None = None
|
|
34
|
+
_FIG = None
|
|
35
|
+
_AX = None
|
|
36
|
+
_ARTISTS: list | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class FrameCategory:
|
|
41
|
+
"""One scatter category: its mask keys (signal_type, optional ON/OFF) and its style."""
|
|
42
|
+
|
|
43
|
+
signal_type: str
|
|
44
|
+
onoff: str | None # None for cadence-level (labels-only masking)
|
|
45
|
+
color: str
|
|
46
|
+
marker: str
|
|
47
|
+
display_name: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def batched_umap_transform(model, coords_list: list[np.ndarray]) -> list[np.ndarray]:
|
|
51
|
+
"""
|
|
52
|
+
Project every snapshot's coords through `model` in ONE stacked .transform() call instead
|
|
53
|
+
of len(coords_list) serial calls. Returns the per-snapshot list split back out, order
|
|
54
|
+
preserved.
|
|
55
|
+
|
|
56
|
+
NOT used by the pipeline: the #278 benchmark measured it ~9% faster but NOT
|
|
57
|
+
output-identical to per-snapshot transforms (UMAP consumes its random_state stream
|
|
58
|
+
differently for the joint batch), which violates #278's outputs-unchanged constraint.
|
|
59
|
+
Kept as benchmark-support so bench_latent_gif.py can keep re-measuring the trade.
|
|
60
|
+
"""
|
|
61
|
+
if not coords_list:
|
|
62
|
+
return []
|
|
63
|
+
stacked = np.vstack(coords_list)
|
|
64
|
+
transformed = model.transform(stacked)
|
|
65
|
+
split_points = np.cumsum([len(coords) for coords in coords_list])[:-1]
|
|
66
|
+
return [np.ascontiguousarray(part) for part in np.split(transformed, split_points)]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _worker_init(style: dict) -> None:
|
|
70
|
+
"""Pin the Agg backend and stash the shared style; the figure is built lazily on the
|
|
71
|
+
first frame so matplotlib initializes inside the worker, never in the forkserver."""
|
|
72
|
+
import matplotlib # noqa: PLC0415
|
|
73
|
+
|
|
74
|
+
matplotlib.use("Agg")
|
|
75
|
+
global _STYLE
|
|
76
|
+
_STYLE = style
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _ensure_figure():
|
|
80
|
+
"""Build the reusable figure + one (initially empty) scatter artist per category."""
|
|
81
|
+
global _FIG, _AX, _ARTISTS
|
|
82
|
+
if _FIG is not None:
|
|
83
|
+
return
|
|
84
|
+
import matplotlib.pyplot as plt # noqa: PLC0415
|
|
85
|
+
|
|
86
|
+
style = _STYLE
|
|
87
|
+
_FIG, _AX = plt.subplots(1, 1, figsize=tuple(style["figsize"]))
|
|
88
|
+
_ARTISTS = []
|
|
89
|
+
for category in style["categories"]:
|
|
90
|
+
artist = _AX.scatter(
|
|
91
|
+
[],
|
|
92
|
+
[],
|
|
93
|
+
c=category.color,
|
|
94
|
+
marker=category.marker,
|
|
95
|
+
s=10,
|
|
96
|
+
alpha=0.75,
|
|
97
|
+
label=category.display_name,
|
|
98
|
+
rasterized=True,
|
|
99
|
+
)
|
|
100
|
+
_ARTISTS.append(artist)
|
|
101
|
+
_AX.set_xlim(tuple(style["xlim"]))
|
|
102
|
+
_AX.set_ylim(tuple(style["ylim"]))
|
|
103
|
+
# Fixed limits + a single-line title => the geometry is frame-independent, so one
|
|
104
|
+
# tight_layout call up front matches the old per-frame call's result
|
|
105
|
+
_AX.set_title(" ", fontsize=11)
|
|
106
|
+
_FIG.tight_layout()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _render_chunk(task: tuple) -> list[str]:
|
|
110
|
+
"""Render one chunk of frames to PNGs; returns the frame paths in order."""
|
|
111
|
+
frames, frame_indices = task
|
|
112
|
+
_ensure_figure()
|
|
113
|
+
style = _STYLE
|
|
114
|
+
categories = style["categories"]
|
|
115
|
+
paths: list[str] = []
|
|
116
|
+
|
|
117
|
+
for frame, frame_idx in zip(frames, frame_indices, strict=True):
|
|
118
|
+
coords_2d = frame["coords"]
|
|
119
|
+
labels_arr = np.asarray(frame["labels"])
|
|
120
|
+
onoff_arr = np.asarray(frame["onoff"]) if frame.get("onoff") is not None else None
|
|
121
|
+
|
|
122
|
+
visible = []
|
|
123
|
+
for category, artist in zip(categories, _ARTISTS, strict=True):
|
|
124
|
+
mask = labels_arr == category.signal_type
|
|
125
|
+
if category.onoff is not None and onoff_arr is not None:
|
|
126
|
+
mask &= onoff_arr == category.onoff
|
|
127
|
+
if mask.any():
|
|
128
|
+
artist.set_offsets(coords_2d[mask])
|
|
129
|
+
visible.append(artist)
|
|
130
|
+
else:
|
|
131
|
+
artist.set_offsets(np.empty((0, 2)))
|
|
132
|
+
# Legend rebuilt from the categories present in THIS frame — same entries the old
|
|
133
|
+
# per-frame implementation produced (it only scattered non-empty categories)
|
|
134
|
+
_AX.legend(handles=visible, **style["legend_kwargs"])
|
|
135
|
+
_AX.set_title(frame["title"], fontsize=11)
|
|
136
|
+
|
|
137
|
+
frame_path = os.path.join(
|
|
138
|
+
style["out_dir"], f"{style['method_name']}_frame_{frame_idx:05d}.png"
|
|
139
|
+
)
|
|
140
|
+
_FIG.savefig(frame_path, dpi=style["dpi"])
|
|
141
|
+
paths.append(frame_path)
|
|
142
|
+
|
|
143
|
+
return paths
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def render_latent_gif_frames(
|
|
147
|
+
frames: list[dict],
|
|
148
|
+
categories: list[FrameCategory],
|
|
149
|
+
xlim: tuple[float, float],
|
|
150
|
+
ylim: tuple[float, float],
|
|
151
|
+
legend_kwargs: dict,
|
|
152
|
+
out_dir: str,
|
|
153
|
+
method_name: str,
|
|
154
|
+
n_workers: int,
|
|
155
|
+
figsize: tuple[float, float] = (10, 8),
|
|
156
|
+
dpi: int = 100,
|
|
157
|
+
) -> list[str]:
|
|
158
|
+
"""
|
|
159
|
+
Render one PNG per frame across a process pool; returns the frame paths in frame order.
|
|
160
|
+
|
|
161
|
+
Each frame dict carries: "coords" (N, 2 float array), "labels" (N str array), optional
|
|
162
|
+
"onoff" (N str array, obs-level only), and "title" (str). n_workers <= 1 renders fully
|
|
163
|
+
in-process (no pool) — byte-identical output either way.
|
|
164
|
+
"""
|
|
165
|
+
if not frames:
|
|
166
|
+
return []
|
|
167
|
+
|
|
168
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
169
|
+
style = {
|
|
170
|
+
"categories": categories,
|
|
171
|
+
"xlim": tuple(xlim),
|
|
172
|
+
"ylim": tuple(ylim),
|
|
173
|
+
"legend_kwargs": legend_kwargs,
|
|
174
|
+
"out_dir": out_dir,
|
|
175
|
+
"method_name": method_name,
|
|
176
|
+
"figsize": figsize,
|
|
177
|
+
"dpi": dpi,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
n_workers = max(1, min(n_workers, len(frames)))
|
|
181
|
+
if n_workers == 1:
|
|
182
|
+
# In-process render — reset worker globals afterwards so repeated calls (the multi-GIF
|
|
183
|
+
# sweep runs in one process) rebuild against the new style
|
|
184
|
+
global _FIG, _AX, _ARTISTS, _STYLE
|
|
185
|
+
try:
|
|
186
|
+
_worker_init(style)
|
|
187
|
+
all_indices = list(range(len(frames)))
|
|
188
|
+
return _render_chunk((frames, all_indices))
|
|
189
|
+
finally:
|
|
190
|
+
if _FIG is not None:
|
|
191
|
+
import matplotlib.pyplot as plt # noqa: PLC0415
|
|
192
|
+
|
|
193
|
+
plt.close(_FIG)
|
|
194
|
+
_FIG = _AX = _ARTISTS = _STYLE = None
|
|
195
|
+
|
|
196
|
+
# Forkserver with an empty preload list, exactly like shap_parallel.py: workers get a
|
|
197
|
+
# clean interpreter that imports only this light module, never the parent's TF stack
|
|
198
|
+
ctx = mp.get_context("forkserver")
|
|
199
|
+
ctx.set_forkserver_preload([])
|
|
200
|
+
|
|
201
|
+
chunk_bounds = np.array_split(np.arange(len(frames)), n_workers)
|
|
202
|
+
tasks = [
|
|
203
|
+
([frames[i] for i in chunk], [int(i) for i in chunk])
|
|
204
|
+
for chunk in chunk_bounds
|
|
205
|
+
if len(chunk)
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
with ProcessPoolExecutor(
|
|
209
|
+
max_workers=len(tasks), mp_context=ctx, initializer=_worker_init, initargs=(style,)
|
|
210
|
+
) as pool:
|
|
211
|
+
chunk_results = list(pool.map(_render_chunk, tasks))
|
|
212
|
+
|
|
213
|
+
return [path for chunk_paths in chunk_results for path in chunk_paths]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# Whole-combo parallelism (the #278 follow-up): one (level, nn, md) UMAP combo —
|
|
218
|
+
# fit + persist + per-snapshot transforms + frame render + GIF assembly — per worker.
|
|
219
|
+
#
|
|
220
|
+
# The 24-combo sweep was strictly serial in train.plot_latent_space_gif at ~95% single-core
|
|
221
|
+
# (~1.7-1.9 h per run) even after frame rendering parallelized: every combo is an independent
|
|
222
|
+
# UMAP fit with its own derived random_state, reads the same shared inputs read-only, and
|
|
223
|
+
# writes distinct files. Farming WHOLE combos to forkserver workers therefore cannot change
|
|
224
|
+
# any output byte — unlike the rejected within-fit ideas (batched_umap_transform above,
|
|
225
|
+
# precomputed-knn reuse), nothing changes how any single fit consumes its RNG stream.
|
|
226
|
+
|
|
227
|
+
# Palettes moved verbatim from train.plot_latent_space_gif so combo workers build their
|
|
228
|
+
# categories without importing train (obs/cadence palettes deliberately distinct — the
|
|
229
|
+
# cadence colors are shared with downstream RF plots).
|
|
230
|
+
OBS_COLORS = {
|
|
231
|
+
("false_no_signal", "ON"): "#1565C0",
|
|
232
|
+
("false_no_signal", "OFF"): "#64B5F6",
|
|
233
|
+
("false_with_rfi", "ON"): "#F9A825",
|
|
234
|
+
("false_with_rfi", "OFF"): "#FFF176",
|
|
235
|
+
("true_only_eti", "ON"): "#2E7D32",
|
|
236
|
+
("true_only_eti", "OFF"): "#81C784",
|
|
237
|
+
("true_eti_rfi", "ON"): "#C62828",
|
|
238
|
+
("true_eti_rfi", "OFF"): "#EF5350",
|
|
239
|
+
}
|
|
240
|
+
OBS_MARKERS = {"ON": "^", "OFF": "x"}
|
|
241
|
+
OBS_DISPLAY_NAMES = {
|
|
242
|
+
("false_no_signal", "ON"): "No Signal (ON)",
|
|
243
|
+
("false_no_signal", "OFF"): "No Signal (OFF)",
|
|
244
|
+
("false_with_rfi", "ON"): "RFI Only (ON)",
|
|
245
|
+
("false_with_rfi", "OFF"): "RFI Only (OFF)",
|
|
246
|
+
("true_only_eti", "ON"): "ETI Only (ON)",
|
|
247
|
+
("true_only_eti", "OFF"): "ETI Only (OFF)",
|
|
248
|
+
("true_eti_rfi", "ON"): "ETI+RFI (ON)",
|
|
249
|
+
("true_eti_rfi", "OFF"): "ETI+RFI (OFF)",
|
|
250
|
+
}
|
|
251
|
+
CADENCE_COLORS = {
|
|
252
|
+
"false_no_signal": "tab:blue",
|
|
253
|
+
"false_with_rfi": "tab:green",
|
|
254
|
+
"true_only_eti": "tab:red",
|
|
255
|
+
"true_eti_rfi": "tab:orange",
|
|
256
|
+
}
|
|
257
|
+
CADENCE_DISPLAY_NAMES = {
|
|
258
|
+
"false_no_signal": "No Signal",
|
|
259
|
+
"false_with_rfi": "RFI Only",
|
|
260
|
+
"true_only_eti": "ETI Only",
|
|
261
|
+
"true_eti_rfi": "ETI+RFI",
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
# Worker-global cache of the shared input bundle: one ProcessPoolExecutor worker handles
|
|
265
|
+
# several combos, and they all read the same bundle file
|
|
266
|
+
_SWEEP_BUNDLE: dict | None = None
|
|
267
|
+
_SWEEP_BUNDLE_PATH: str | None = None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def categories_for_mode(mode: str) -> tuple[list[FrameCategory], dict]:
|
|
271
|
+
"""The scatter categories + legend kwargs for one GIF level (moved verbatim from
|
|
272
|
+
train.plot_latent_space_gif's closure)."""
|
|
273
|
+
if mode == "obs":
|
|
274
|
+
categories = [
|
|
275
|
+
FrameCategory(
|
|
276
|
+
signal_type=stype,
|
|
277
|
+
onoff=status,
|
|
278
|
+
color=color,
|
|
279
|
+
marker=OBS_MARKERS[status],
|
|
280
|
+
display_name=OBS_DISPLAY_NAMES[(stype, status)],
|
|
281
|
+
)
|
|
282
|
+
for (stype, status), color in OBS_COLORS.items()
|
|
283
|
+
]
|
|
284
|
+
legend_kwargs = {
|
|
285
|
+
"loc": "upper right",
|
|
286
|
+
"fontsize": 8,
|
|
287
|
+
"markerscale": 2,
|
|
288
|
+
"ncol": 2,
|
|
289
|
+
"framealpha": 0.8,
|
|
290
|
+
}
|
|
291
|
+
elif mode == "cadence":
|
|
292
|
+
categories = [
|
|
293
|
+
FrameCategory(
|
|
294
|
+
signal_type=stype,
|
|
295
|
+
onoff=None,
|
|
296
|
+
color=color,
|
|
297
|
+
marker="o",
|
|
298
|
+
display_name=CADENCE_DISPLAY_NAMES[stype],
|
|
299
|
+
)
|
|
300
|
+
for stype, color in CADENCE_COLORS.items()
|
|
301
|
+
]
|
|
302
|
+
legend_kwargs = {
|
|
303
|
+
"loc": "upper right",
|
|
304
|
+
"fontsize": 8,
|
|
305
|
+
"markerscale": 2,
|
|
306
|
+
"framealpha": 0.8,
|
|
307
|
+
}
|
|
308
|
+
else:
|
|
309
|
+
raise ValueError(f"mode must be 'obs' or 'cadence', got {mode!r}")
|
|
310
|
+
return categories, legend_kwargs
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def compute_frame_limits(
|
|
314
|
+
transformed_list: list[np.ndarray],
|
|
315
|
+
) -> tuple[tuple[float, float], tuple[float, float]]:
|
|
316
|
+
"""Shared axis limits over every frame's 2D projection, padded 5% (moved verbatim)."""
|
|
317
|
+
x_min = min(t[:, 0].min() for t in transformed_list)
|
|
318
|
+
x_max = max(t[:, 0].max() for t in transformed_list)
|
|
319
|
+
y_min = min(t[:, 1].min() for t in transformed_list)
|
|
320
|
+
y_max = max(t[:, 1].max() for t in transformed_list)
|
|
321
|
+
x_pad = (x_max - x_min) * 0.05
|
|
322
|
+
y_pad = (y_max - y_min) * 0.05
|
|
323
|
+
return (x_min - x_pad, x_max + x_pad), (y_min - y_pad, y_max + y_pad)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def build_snapshot_frames(
|
|
327
|
+
transformed_list: list[np.ndarray],
|
|
328
|
+
snapshot_metadata: list[dict],
|
|
329
|
+
labels_list: list,
|
|
330
|
+
onoff_list: list | None,
|
|
331
|
+
display_method: str,
|
|
332
|
+
) -> list[dict]:
|
|
333
|
+
"""Assemble render_latent_gif_frames' frame dicts (coords/labels/onoff/title) for one
|
|
334
|
+
combo — the title logic moved verbatim from train.plot_latent_space_gif's closure."""
|
|
335
|
+
frames = []
|
|
336
|
+
for frame_idx, meta in enumerate(snapshot_metadata):
|
|
337
|
+
meta_snr_base = meta["snr_base"]
|
|
338
|
+
meta_snr_range = meta["snr_range"]
|
|
339
|
+
meta_snr_floor = meta_snr_base if meta_snr_base is not None else "?"
|
|
340
|
+
meta_snr_ceil = (
|
|
341
|
+
meta_snr_base + meta_snr_range
|
|
342
|
+
if meta_snr_base is not None and meta_snr_range is not None
|
|
343
|
+
else "?"
|
|
344
|
+
)
|
|
345
|
+
frames.append(
|
|
346
|
+
{
|
|
347
|
+
"coords": transformed_list[frame_idx],
|
|
348
|
+
"labels": labels_list[frame_idx],
|
|
349
|
+
"onoff": onoff_list[frame_idx] if onoff_list is not None else None,
|
|
350
|
+
"title": (
|
|
351
|
+
f"Beta-VAE Latent Space: {display_method} — "
|
|
352
|
+
f"Round {meta['round_number']}, "
|
|
353
|
+
f"Epoch {meta['epoch_number']}, "
|
|
354
|
+
f"Step {meta['step_number']} "
|
|
355
|
+
f"(SNR: {meta_snr_floor}–{meta_snr_ceil})"
|
|
356
|
+
),
|
|
357
|
+
}
|
|
358
|
+
)
|
|
359
|
+
return frames
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _sweep_worker_init() -> None:
|
|
363
|
+
"""Pin BLAS-family thread pools before numpy/umap initialize in the fresh forkserver
|
|
364
|
+
interpreter (the shap_parallel pattern) — but deliberately NOT numba's.
|
|
365
|
+
|
|
366
|
+
UMAP calls numba.set_num_threads(cpu_count) on its large-N paths, and numba hard-errors
|
|
367
|
+
when asked to grow past the pool it launched ("Cannot set NUMBA_NUM_THREADS to a
|
|
368
|
+
different value once the threads have been launched") — so capping NUMBA_NUM_THREADS at
|
|
369
|
+
1 breaks production-scale fits outright (found by the first full-scale run; the small-N
|
|
370
|
+
unit fits take the brute-force path and never touch the thread API). OMP_NUM_THREADS is
|
|
371
|
+
left unpinned for the same reason: numba's threading layer may be OpenMP-backed, in
|
|
372
|
+
which case the OMP cap is the same trap under a different name. Shrinking is always
|
|
373
|
+
legal, so an unpinned launch matches the pre-parallel serial behavior exactly (the old
|
|
374
|
+
in-process sweep also ran with an unpinned numba pool); the seeded fits force their
|
|
375
|
+
single-threaded deterministic paths regardless."""
|
|
376
|
+
for var in (
|
|
377
|
+
"OPENBLAS_NUM_THREADS",
|
|
378
|
+
"MKL_NUM_THREADS",
|
|
379
|
+
"NUMEXPR_NUM_THREADS",
|
|
380
|
+
"VECLIB_MAXIMUM_THREADS",
|
|
381
|
+
"BLIS_NUM_THREADS",
|
|
382
|
+
):
|
|
383
|
+
os.environ.setdefault(var, "1")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _load_sweep_bundle(path: str) -> dict:
|
|
387
|
+
global _SWEEP_BUNDLE, _SWEEP_BUNDLE_PATH
|
|
388
|
+
if path != _SWEEP_BUNDLE_PATH:
|
|
389
|
+
import joblib # noqa: PLC0415 (deferred so _sweep_worker_init pins threads first)
|
|
390
|
+
|
|
391
|
+
_SWEEP_BUNDLE = joblib.load(path)
|
|
392
|
+
_SWEEP_BUNDLE_PATH = path
|
|
393
|
+
return _SWEEP_BUNDLE
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def run_umap_gif_combo(task: dict) -> dict:
|
|
397
|
+
"""
|
|
398
|
+
One (mode, nn, md) combo end-to-end: fit the UMAP on the mode's fit pool, persist it
|
|
399
|
+
(warn-not-fail, matching the serial path), transform every snapshot serially (the
|
|
400
|
+
output-identical form — see batched_umap_transform's rejection note), render the frame
|
|
401
|
+
PNGs in-process, and assemble the GIF. Runs in a forkserver worker (or inline when the
|
|
402
|
+
sweep is serial); consumes only the combo's own derived random_state.
|
|
403
|
+
|
|
404
|
+
Returns {method_name, display_method, gif_path, umap_path, n_frames, warnings} —
|
|
405
|
+
`warnings` carries messages for the parent to log (forkserver workers have no handler),
|
|
406
|
+
and gif assembly/Slack stay split: assembly here, upload in the caller.
|
|
407
|
+
"""
|
|
408
|
+
import imageio.v3 as iio # noqa: PLC0415 (deferred so _sweep_worker_init pins threads first)
|
|
409
|
+
import joblib # noqa: PLC0415
|
|
410
|
+
import umap # noqa: PLC0415
|
|
411
|
+
|
|
412
|
+
bundle = _load_sweep_bundle(task["bundle_path"])
|
|
413
|
+
mode = task["mode"]
|
|
414
|
+
warnings: list[str] = []
|
|
415
|
+
|
|
416
|
+
if mode == "obs":
|
|
417
|
+
fit_pool = bundle["fit_pool_obs"]
|
|
418
|
+
coords_list = bundle["coords_obs"]
|
|
419
|
+
labels_list = bundle["labels_obs"]
|
|
420
|
+
onoff_list = bundle["onoff_obs"]
|
|
421
|
+
else:
|
|
422
|
+
fit_pool = bundle["fit_pool_cadence"]
|
|
423
|
+
coords_list = bundle["coords_cadence"]
|
|
424
|
+
labels_list = bundle["labels_cadence"]
|
|
425
|
+
onoff_list = None
|
|
426
|
+
|
|
427
|
+
model = umap.UMAP(
|
|
428
|
+
n_components=2,
|
|
429
|
+
random_state=task["seed"],
|
|
430
|
+
n_neighbors=task["nn"],
|
|
431
|
+
min_dist=task["md"],
|
|
432
|
+
).fit(fit_pool)
|
|
433
|
+
|
|
434
|
+
try:
|
|
435
|
+
os.makedirs(os.path.dirname(task["umap_path"]), exist_ok=True)
|
|
436
|
+
joblib.dump(model, task["umap_path"])
|
|
437
|
+
except Exception as exc:
|
|
438
|
+
warnings.append(f"Failed to persist {mode}-level UMAP model ({task['umap_path']}): {exc}")
|
|
439
|
+
|
|
440
|
+
transformed = [model.transform(coords) for coords in coords_list]
|
|
441
|
+
del model
|
|
442
|
+
|
|
443
|
+
xlim, ylim = compute_frame_limits(transformed)
|
|
444
|
+
categories, legend_kwargs = categories_for_mode(mode)
|
|
445
|
+
frames = build_snapshot_frames(
|
|
446
|
+
transformed, bundle["snapshot_metadata"], labels_list, onoff_list, task["display_method"]
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
frame_paths = render_latent_gif_frames(
|
|
450
|
+
frames,
|
|
451
|
+
categories=categories,
|
|
452
|
+
xlim=xlim,
|
|
453
|
+
ylim=ylim,
|
|
454
|
+
legend_kwargs=legend_kwargs,
|
|
455
|
+
out_dir=task["frames_dir"],
|
|
456
|
+
method_name=task["method_name"],
|
|
457
|
+
n_workers=1, # combos are the parallel unit; frames render inline per combo
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
n_frames = len(frame_paths)
|
|
461
|
+
if n_frames > 0:
|
|
462
|
+
# Assemble the GIF by streaming one frame at a time (moved verbatim from train)
|
|
463
|
+
with iio.imopen(task["gif_path"], "w", plugin="pillow") as gif_writer:
|
|
464
|
+
for frame_path in frame_paths:
|
|
465
|
+
frame = iio.imread(frame_path)
|
|
466
|
+
gif_writer.write(
|
|
467
|
+
frame,
|
|
468
|
+
duration=task["duration_ms"],
|
|
469
|
+
loop=0,
|
|
470
|
+
is_batch=False,
|
|
471
|
+
)
|
|
472
|
+
del frame
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
"method_name": task["method_name"],
|
|
476
|
+
"display_method": task["display_method"],
|
|
477
|
+
"gif_path": task["gif_path"],
|
|
478
|
+
"umap_path": task["umap_path"],
|
|
479
|
+
"n_frames": n_frames,
|
|
480
|
+
"warnings": warnings,
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def run_umap_gif_sweep(tasks: list[dict], n_workers: int) -> list[dict]:
|
|
485
|
+
"""
|
|
486
|
+
Run every (mode, nn, md) combo across a forkserver pool (empty preload, threads pinned —
|
|
487
|
+
the shap_parallel isolation pattern), or serially in-process when n_workers <= 1. Results
|
|
488
|
+
come back in task order. Outputs are byte-identical either way: each combo's fit consumes
|
|
489
|
+
only its own derived random_state, and process isolation cannot change that stream.
|
|
490
|
+
"""
|
|
491
|
+
if not tasks:
|
|
492
|
+
return []
|
|
493
|
+
|
|
494
|
+
n_workers = max(1, min(n_workers, len(tasks)))
|
|
495
|
+
if n_workers == 1:
|
|
496
|
+
# In-process combos cache the shared bundle in THIS process's module globals — clear
|
|
497
|
+
# them afterwards so the caller's pre-sweep memory cleanup (train.py dels its copies
|
|
498
|
+
# of the bundled inputs) isn't quietly undone by a lingering multi-hundred-MB cache.
|
|
499
|
+
# The pooled arm needs no equivalent: its cache lives in short-lived workers.
|
|
500
|
+
global _SWEEP_BUNDLE, _SWEEP_BUNDLE_PATH
|
|
501
|
+
try:
|
|
502
|
+
return [run_umap_gif_combo(task) for task in tasks]
|
|
503
|
+
finally:
|
|
504
|
+
_SWEEP_BUNDLE = None
|
|
505
|
+
_SWEEP_BUNDLE_PATH = None
|
|
506
|
+
|
|
507
|
+
ctx = mp.get_context("forkserver")
|
|
508
|
+
ctx.set_forkserver_preload([])
|
|
509
|
+
with ProcessPoolExecutor(
|
|
510
|
+
max_workers=n_workers, mp_context=ctx, initializer=_sweep_worker_init
|
|
511
|
+
) as pool:
|
|
512
|
+
return list(pool.map(run_umap_gif_combo, tasks))
|