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/dashboard.py
ADDED
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Live dashboard for an Aetherscan pipeline run, read straight from its SQLite DB.
|
|
4
|
+
|
|
5
|
+
Renders, in one auto-refreshing page, every run metric that is reconstructable from the DB —
|
|
6
|
+
resource utilization (CPU/RAM/GPU), beta-VAE loss + stability curves, RF eval metrics
|
|
7
|
+
(accuracy/AUC/AP/Brier tiles, confusion heatmaps, ensemble curve, confidence quantiles), the
|
|
8
|
+
signal-injection stats suite, a live latent-space scatter, the stage timeline (PR #134), and
|
|
9
|
+
inference candidate stats — plus a gallery of every saved plot PNG (RF diagnostics, latent
|
|
10
|
+
traversals, inference figures) as it appears on disk.
|
|
11
|
+
|
|
12
|
+
It is STANDALONE — no `aetherscan` imports (like utils/benchmark_report.py), only stdlib sqlite3 +
|
|
13
|
+
numpy + pandas + plotly + streamlit — so streamlit can execute it directly and it runs against a
|
|
14
|
+
live run's DB or one fetched from a cluster with utils/fetch_run_outputs.sh. It ships inside the
|
|
15
|
+
package (src/aetherscan/dashboard.py) so every install method (pip `aetherscan[dashboard]`, the
|
|
16
|
+
container, or a source checkout) can auto-launch it. main.py auto-launches it (--no-dashboard to
|
|
17
|
+
opt out); to run it by hand against a saved DB, point streamlit at the installed module file:
|
|
18
|
+
|
|
19
|
+
streamlit run "$(python -c 'import aetherscan, os; \
|
|
20
|
+
print(os.path.join(os.path.dirname(aetherscan.__file__), "dashboard.py"))')" \
|
|
21
|
+
-- --db-path /path/to/aetherscan.db --tag train_20260101_120000
|
|
22
|
+
|
|
23
|
+
To watch a run on a cluster, SSH-forward the port: ssh -L 8501:localhost:8501 blpc3
|
|
24
|
+
|
|
25
|
+
Read-only (mode=ro); the pipeline's writer-thread journaling makes concurrent reads safe. The data
|
|
26
|
+
layer (load_* / parse / pca helpers) is pure and unit-tested against a synthetic DB; the Streamlit
|
|
27
|
+
UI (render*) is a thin rendering shell that imports plotly/streamlit lazily.
|
|
28
|
+
|
|
29
|
+
Auto-refresh uses a blocking `time.sleep(refresh) + st.rerun()` — deliberately version-robust (works
|
|
30
|
+
on any Streamlit), at the cost of freezing sidebar/tooltip interaction during the sleep window. For
|
|
31
|
+
a smoother non-blocking refresh, `pip install streamlit-autorefresh` (or use `st.fragment(run_every=)`
|
|
32
|
+
on Streamlit >= 1.33) and swap it in.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import argparse
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import sqlite3
|
|
41
|
+
|
|
42
|
+
import numpy as np
|
|
43
|
+
import pandas as pd
|
|
44
|
+
|
|
45
|
+
# --------------------------------------------------------------------------- #
|
|
46
|
+
# Data layer — pure, unit-testable, no Streamlit #
|
|
47
|
+
# --------------------------------------------------------------------------- #
|
|
48
|
+
|
|
49
|
+
# Non-superseded filter reused across the supersede-aware tables
|
|
50
|
+
_ALIVE = "COALESCE(superseded, 0) = 0"
|
|
51
|
+
|
|
52
|
+
# beta-VAE stat_names split into the two training figures (train.py loss curves / stability)
|
|
53
|
+
_LOSS_STATS = [
|
|
54
|
+
"total_loss",
|
|
55
|
+
"reconstruction_loss",
|
|
56
|
+
"kl_loss",
|
|
57
|
+
"true_loss",
|
|
58
|
+
"false_loss",
|
|
59
|
+
"learning_rate",
|
|
60
|
+
]
|
|
61
|
+
_STABILITY_STATS = ["clipping_rate", "gradient_norm_mean", "gradient_norm_std", "gradient_norm_max"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def connect_ro(db_path: str) -> sqlite3.Connection:
|
|
65
|
+
"""Open the DB strictly read-only (won't create/lock a missing file for writing)."""
|
|
66
|
+
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
|
67
|
+
conn.row_factory = sqlite3.Row
|
|
68
|
+
return conn
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _table_exists(conn: sqlite3.Connection, table: str) -> bool:
|
|
72
|
+
return (
|
|
73
|
+
conn.execute(
|
|
74
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)
|
|
75
|
+
).fetchone()
|
|
76
|
+
is not None
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def list_tags(conn: sqlite3.Connection) -> list[str]:
|
|
81
|
+
"""All run tags present anywhere in the DB, sorted."""
|
|
82
|
+
tags: set[str] = set()
|
|
83
|
+
for table in ("pipeline_stages", "system_resources", "training_stats", "inference_cadences"):
|
|
84
|
+
try:
|
|
85
|
+
rows = conn.execute(
|
|
86
|
+
f"SELECT DISTINCT tag FROM {table} WHERE tag IS NOT NULL" # noqa: S608 (fixed names)
|
|
87
|
+
).fetchall()
|
|
88
|
+
tags.update(r[0] for r in rows)
|
|
89
|
+
except sqlite3.OperationalError:
|
|
90
|
+
continue # table absent in an older schema
|
|
91
|
+
return sorted(tags)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_resources(conn: sqlite3.Connection, tag: str) -> pd.DataFrame:
|
|
95
|
+
"""system_resources rows for `tag` (timestamp, resource_type, resource_name, value, unit)."""
|
|
96
|
+
return pd.read_sql_query(
|
|
97
|
+
"SELECT timestamp, resource_type, resource_name, value, unit "
|
|
98
|
+
"FROM system_resources WHERE tag = ? ORDER BY timestamp",
|
|
99
|
+
conn,
|
|
100
|
+
params=(tag,),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def load_training_stats(conn: sqlite3.Connection, tag: str, stats: list[str]) -> pd.DataFrame:
|
|
105
|
+
"""Non-superseded beta_vae training_stats for `tag` limited to `stats` (and their val_
|
|
106
|
+
counterparts), ordered so curves plot in run order (round, epoch, timestamp)."""
|
|
107
|
+
wanted = list(stats) + [f"val_{s}" for s in stats]
|
|
108
|
+
placeholders = ",".join("?" * len(wanted))
|
|
109
|
+
df = pd.read_sql_query(
|
|
110
|
+
"SELECT timestamp, stat_name, value, round_number, epoch_number "
|
|
111
|
+
f"FROM training_stats WHERE tag = ? AND model_name = 'beta_vae' AND {_ALIVE} "
|
|
112
|
+
f"AND stat_name IN ({placeholders}) " # noqa: S608 (placeholders are bound params)
|
|
113
|
+
"ORDER BY round_number, epoch_number, timestamp",
|
|
114
|
+
conn,
|
|
115
|
+
params=(tag, *wanted),
|
|
116
|
+
)
|
|
117
|
+
return df
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def load_rf_stats(conn: sqlite3.Connection, tag: str) -> pd.DataFrame:
|
|
121
|
+
"""Non-superseded model_name='rf' training_stats for `tag` (timestamp, stat_name, value,
|
|
122
|
+
round_number, epoch_number). Duplicate (stat_name, epoch_number) rows — an rf_plots retry
|
|
123
|
+
re-writing the ensemble curve, or a reused tag's stale scalars — collapse last-write-wins
|
|
124
|
+
(rows are ordered by timestamp; scalar rows share a NULL epoch_number, which pandas
|
|
125
|
+
treats as equal when de-duplicating)."""
|
|
126
|
+
df = pd.read_sql_query(
|
|
127
|
+
"SELECT timestamp, stat_name, value, round_number, epoch_number "
|
|
128
|
+
f"FROM training_stats WHERE tag = ? AND model_name = 'rf' AND {_ALIVE} "
|
|
129
|
+
"ORDER BY timestamp",
|
|
130
|
+
conn,
|
|
131
|
+
params=(tag,),
|
|
132
|
+
)
|
|
133
|
+
if not df.empty:
|
|
134
|
+
df = df.drop_duplicates(subset=["stat_name", "epoch_number"], keep="last").reset_index(
|
|
135
|
+
drop=True
|
|
136
|
+
)
|
|
137
|
+
return df
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def rf_confusion_matrices(rf: pd.DataFrame) -> tuple[pd.DataFrame | None, pd.DataFrame | None]:
|
|
141
|
+
"""Shape load_rf_stats rows into the RF tab's two confusion matrices: the binary 2x2
|
|
142
|
+
(actual x predicted, from confusion_{tn,fp,fn,tp}) and the sub-type x predicted-class
|
|
143
|
+
counts (from confusion_{subtype}_pred_{false,true}). Either is None when its cell rows
|
|
144
|
+
are absent."""
|
|
145
|
+
if rf.empty:
|
|
146
|
+
return None, None
|
|
147
|
+
scalars = dict(zip(rf["stat_name"], rf["value"], strict=False))
|
|
148
|
+
|
|
149
|
+
cells = [scalars.get(f"confusion_{c}") for c in ("tn", "fp", "fn", "tp")]
|
|
150
|
+
binary = None
|
|
151
|
+
if None not in cells:
|
|
152
|
+
binary = pd.DataFrame(
|
|
153
|
+
[[cells[0], cells[1]], [cells[2], cells[3]]],
|
|
154
|
+
index=["actual false", "actual true"],
|
|
155
|
+
columns=["pred false", "pred true"],
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
subtype = None
|
|
159
|
+
sub = rf[rf["stat_name"].str.match(r"^confusion_.+_pred_(true|false)$")].copy()
|
|
160
|
+
if not sub.empty:
|
|
161
|
+
parts = sub["stat_name"].str.extract(r"^confusion_(.+)_pred_(true|false)$")
|
|
162
|
+
sub["subtype"] = parts[0]
|
|
163
|
+
sub["pred"] = "pred " + parts[1]
|
|
164
|
+
subtype = (
|
|
165
|
+
sub.pivot_table(index="subtype", columns="pred", values="value", aggfunc="last")
|
|
166
|
+
.reindex(columns=["pred false", "pred true"])
|
|
167
|
+
.sort_index()
|
|
168
|
+
)
|
|
169
|
+
return binary, subtype
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def load_injection_stats(conn: sqlite3.Connection, tag: str) -> pd.DataFrame:
|
|
173
|
+
"""Non-superseded injection_stats for `tag` (stat_name, value, signal_type, injection_stage,
|
|
174
|
+
round_number, is_finite, slope_clamped, timestamp)."""
|
|
175
|
+
return pd.read_sql_query(
|
|
176
|
+
"SELECT timestamp, stat_name, value, signal_type, injection_stage, round_number, "
|
|
177
|
+
"is_finite, slope_clamped "
|
|
178
|
+
f"FROM injection_stats WHERE tag = ? AND {_ALIVE} ORDER BY timestamp",
|
|
179
|
+
conn,
|
|
180
|
+
params=(tag,),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def load_latent_snapshots_latest(conn: sqlite3.Connection, tag: str) -> pd.DataFrame:
|
|
185
|
+
"""The most recent latent_snapshots frame for `tag` (all rows sharing the max
|
|
186
|
+
round/epoch/step), as (signal_type, latent_vector[JSON]). Empty if none."""
|
|
187
|
+
if not _table_exists(conn, "latent_snapshots"):
|
|
188
|
+
return pd.DataFrame(columns=["signal_type", "latent_vector"])
|
|
189
|
+
key = conn.execute(
|
|
190
|
+
"SELECT round_number, epoch_number, step_number FROM latent_snapshots "
|
|
191
|
+
f"WHERE tag = ? AND {_ALIVE} "
|
|
192
|
+
"ORDER BY round_number DESC, epoch_number DESC, step_number DESC LIMIT 1",
|
|
193
|
+
(tag,),
|
|
194
|
+
).fetchone()
|
|
195
|
+
if key is None:
|
|
196
|
+
return pd.DataFrame(columns=["signal_type", "latent_vector"])
|
|
197
|
+
return pd.read_sql_query(
|
|
198
|
+
"SELECT signal_type, latent_vector FROM latent_snapshots "
|
|
199
|
+
f"WHERE tag = ? AND {_ALIVE} AND round_number = ? AND epoch_number = ? AND step_number = ?",
|
|
200
|
+
conn,
|
|
201
|
+
params=(tag, key["round_number"], key["epoch_number"], key["step_number"]),
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def load_inference_results(conn: sqlite3.Connection, tag: str) -> pd.DataFrame:
|
|
206
|
+
"""Non-superseded inference_results for `tag` (prediction, confidence, target, band,
|
|
207
|
+
frequency_mhz). Empty frame if the table is absent."""
|
|
208
|
+
if not _table_exists(conn, "inference_results"):
|
|
209
|
+
return pd.DataFrame(columns=["prediction", "confidence", "target", "band", "frequency_mhz"])
|
|
210
|
+
return pd.read_sql_query(
|
|
211
|
+
"SELECT prediction, confidence, target, band, frequency_mhz "
|
|
212
|
+
f"FROM inference_results WHERE tag = ? AND {_ALIVE}",
|
|
213
|
+
conn,
|
|
214
|
+
params=(tag,),
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def load_inference_cadences(conn: sqlite3.Connection, tag: str) -> pd.DataFrame:
|
|
219
|
+
"""Non-superseded inference_cadences manifest rows for `tag` (status, duration_s, n_stamps,
|
|
220
|
+
n_candidates). Empty frame if the table is absent."""
|
|
221
|
+
if not _table_exists(conn, "inference_cadences"):
|
|
222
|
+
return pd.DataFrame(columns=["status", "duration_s", "n_stamps", "n_candidates"])
|
|
223
|
+
return pd.read_sql_query(
|
|
224
|
+
"SELECT status, duration_s, n_stamps, n_candidates "
|
|
225
|
+
f"FROM inference_cadences WHERE tag = ? AND {_ALIVE}",
|
|
226
|
+
conn,
|
|
227
|
+
params=(tag,),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def load_stages(conn: sqlite3.Connection, tag: str) -> pd.DataFrame:
|
|
232
|
+
"""pipeline_stages spans for `tag` (stage, start_time, end_time, duration_s, metadata) plus a
|
|
233
|
+
derived `depth` (dot-name component count), ordered by start. Empty if the table is absent."""
|
|
234
|
+
if not _table_exists(conn, "pipeline_stages"):
|
|
235
|
+
return pd.DataFrame(columns=["stage", "start_time", "end_time", "duration_s", "metadata"])
|
|
236
|
+
df = pd.read_sql_query(
|
|
237
|
+
"SELECT stage, start_time, end_time, duration_s, metadata "
|
|
238
|
+
"FROM pipeline_stages WHERE tag = ? ORDER BY start_time",
|
|
239
|
+
conn,
|
|
240
|
+
params=(tag,),
|
|
241
|
+
)
|
|
242
|
+
if not df.empty:
|
|
243
|
+
df["depth"] = df["stage"].str.split(".").str.len()
|
|
244
|
+
return df
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def parse_latent_matrix(snapshots: pd.DataFrame) -> tuple[np.ndarray, list[str]]:
|
|
248
|
+
"""Parse a latent_snapshots frame's JSON latent_vector column into an (n, d) float array +
|
|
249
|
+
the matching signal_type labels. Rows whose vector is malformed, ragged, or non-finite
|
|
250
|
+
(NaN/inf — json.loads accepts these and they blow up the downstream SVD) are dropped."""
|
|
251
|
+
vecs, labels = [], []
|
|
252
|
+
for _, row in snapshots.iterrows():
|
|
253
|
+
try:
|
|
254
|
+
v = json.loads(row["latent_vector"])
|
|
255
|
+
except (TypeError, ValueError):
|
|
256
|
+
continue
|
|
257
|
+
if isinstance(v, list) and v:
|
|
258
|
+
vecs.append(v)
|
|
259
|
+
labels.append(row["signal_type"])
|
|
260
|
+
if not vecs:
|
|
261
|
+
return np.empty((0, 0)), []
|
|
262
|
+
width = len(vecs[0])
|
|
263
|
+
keep = [(v, s) for v, s in zip(vecs, labels, strict=False) if len(v) == width]
|
|
264
|
+
mat = np.array([v for v, _ in keep], dtype=float)
|
|
265
|
+
labels_keep = [s for _, s in keep]
|
|
266
|
+
if mat.size:
|
|
267
|
+
finite = np.isfinite(mat).all(axis=1)
|
|
268
|
+
mat = mat[finite]
|
|
269
|
+
labels_keep = [s for s, f in zip(labels_keep, finite, strict=False) if f]
|
|
270
|
+
return mat, labels_keep
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def pca_2d(matrix: np.ndarray) -> np.ndarray:
|
|
274
|
+
"""Project (n, d) -> (n, 2) via mean-centered SVD (numpy-only PCA; no sklearn). Returns a
|
|
275
|
+
(n, 2) array; if d < 2 the missing component(s) are zero-filled."""
|
|
276
|
+
if matrix.size == 0 or matrix.shape[0] == 0:
|
|
277
|
+
return np.empty((0, 2))
|
|
278
|
+
centered = matrix - matrix.mean(axis=0, keepdims=True)
|
|
279
|
+
# economy SVD; right singular vectors are the principal axes
|
|
280
|
+
_, _, vt = np.linalg.svd(centered, full_matrices=False)
|
|
281
|
+
comps = vt[:2] if vt.shape[0] >= 2 else vt
|
|
282
|
+
proj = centered @ comps.T
|
|
283
|
+
if proj.shape[1] == 1: # degenerate d==1
|
|
284
|
+
proj = np.hstack([proj, np.zeros((proj.shape[0], 1))])
|
|
285
|
+
return proj
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def list_png_artifacts(plots_dir: str, limit: int = 60) -> list[dict]:
|
|
289
|
+
"""Every *.png/*.gif under plots_dir (recursive), newest first: {path, name, rel, mtime}.
|
|
290
|
+
|
|
291
|
+
Containment: plots_dir is realpath-resolved and every returned file's realpath is verified to
|
|
292
|
+
stay within that root, so a symlink inside the tree can't make the gallery serve a file from
|
|
293
|
+
outside the run's plots directory. (The dashboard is a single-operator local tool pointed at
|
|
294
|
+
their own run via the launch args, but this keeps the file-serving surface tightly bounded.)
|
|
295
|
+
"""
|
|
296
|
+
if not plots_dir or not os.path.isdir(plots_dir):
|
|
297
|
+
return []
|
|
298
|
+
base = os.path.realpath(plots_dir)
|
|
299
|
+
found = []
|
|
300
|
+
for root, _dirs, files in os.walk(base):
|
|
301
|
+
for f in files:
|
|
302
|
+
if not f.lower().endswith((".png", ".gif")):
|
|
303
|
+
continue
|
|
304
|
+
real = os.path.realpath(os.path.join(root, f))
|
|
305
|
+
if real != base and not real.startswith(base + os.sep):
|
|
306
|
+
continue # symlink escaping the plots tree — never serve it
|
|
307
|
+
try:
|
|
308
|
+
mtime = os.path.getmtime(real)
|
|
309
|
+
except OSError:
|
|
310
|
+
continue
|
|
311
|
+
found.append(
|
|
312
|
+
{"path": real, "name": f, "rel": os.path.relpath(real, base), "mtime": mtime}
|
|
313
|
+
)
|
|
314
|
+
found.sort(key=lambda d: d["mtime"], reverse=True)
|
|
315
|
+
return found[:limit]
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def default_plots_dir(db_path: str) -> str:
|
|
319
|
+
"""Infer {output_path}/plots from the DB path (…/{output_path}/db/aetherscan.db)."""
|
|
320
|
+
db_dir = os.path.dirname(os.path.abspath(db_path)) # …/db
|
|
321
|
+
return os.path.join(os.path.dirname(db_dir), "plots")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def run_summary(resources: pd.DataFrame, stages: pd.DataFrame) -> dict:
|
|
325
|
+
"""Headline dict: wall-clock span, #stages, latest stage, peak system RAM %."""
|
|
326
|
+
t_start: float | None = None
|
|
327
|
+
t_end: float | None = None
|
|
328
|
+
if not stages.empty:
|
|
329
|
+
t_start, t_end = stages["start_time"].min(), stages["end_time"].max()
|
|
330
|
+
elif not resources.empty:
|
|
331
|
+
t_start, t_end = resources["timestamp"].min(), resources["timestamp"].max()
|
|
332
|
+
wall = (t_end - t_start) if t_start is not None else 0.0
|
|
333
|
+
|
|
334
|
+
latest_stage = None
|
|
335
|
+
if not stages.empty:
|
|
336
|
+
latest_stage = stages.sort_values("start_time")["stage"].iloc[-1]
|
|
337
|
+
|
|
338
|
+
peak_ram = None
|
|
339
|
+
if not resources.empty:
|
|
340
|
+
ram = resources[
|
|
341
|
+
(resources["resource_type"] == "ram") & (resources["resource_name"] == "system_total")
|
|
342
|
+
]
|
|
343
|
+
if not ram.empty:
|
|
344
|
+
peak_ram = float(ram["value"].max())
|
|
345
|
+
|
|
346
|
+
return {
|
|
347
|
+
"wall_s": float(wall),
|
|
348
|
+
"n_stages": int(len(stages)),
|
|
349
|
+
"latest_stage": latest_stage,
|
|
350
|
+
"peak_ram_pct": peak_ram,
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
# --------------------------------------------------------------------------- #
|
|
355
|
+
# Streamlit UI — thin shell over the data layer (imports plotly/streamlit lazily)
|
|
356
|
+
# --------------------------------------------------------------------------- #
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _parse_args() -> argparse.Namespace:
|
|
360
|
+
ap = argparse.ArgumentParser(description="Live Aetherscan run dashboard")
|
|
361
|
+
ap.add_argument("--db-path", required=True, help="Path to aetherscan.db")
|
|
362
|
+
ap.add_argument("--tag", default=None, help="Run tag (else pick in the sidebar)")
|
|
363
|
+
ap.add_argument(
|
|
364
|
+
"--plots-dir", default=None, help="Plots dir (default: inferred from --db-path)"
|
|
365
|
+
)
|
|
366
|
+
ap.add_argument("--refresh", type=int, default=10, help="Auto-refresh seconds (0 = off)")
|
|
367
|
+
args, _ = ap.parse_known_args() # streamlit injects extra args after `--`
|
|
368
|
+
return args
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _fmt_duration(seconds: float) -> str:
|
|
372
|
+
seconds = int(seconds)
|
|
373
|
+
h, rem = divmod(seconds, 3600)
|
|
374
|
+
m, s = divmod(rem, 60)
|
|
375
|
+
if h:
|
|
376
|
+
return f"{h}h{m:02d}m"
|
|
377
|
+
if m:
|
|
378
|
+
return f"{m}m{s:02d}s"
|
|
379
|
+
return f"{s}s"
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def render(args: argparse.Namespace) -> None: # pragma: no cover - requires Streamlit runtime
|
|
383
|
+
import plotly.express as px # noqa: PLC0415 (lazy so the data layer imports without plotly)
|
|
384
|
+
import plotly.graph_objects as go # noqa: PLC0415
|
|
385
|
+
import streamlit as st # noqa: PLC0415
|
|
386
|
+
|
|
387
|
+
st.set_page_config(page_title="Aetherscan live", layout="wide")
|
|
388
|
+
|
|
389
|
+
with st.sidebar:
|
|
390
|
+
st.header("Aetherscan run")
|
|
391
|
+
# DB + plots paths come from the launch args only (NOT browser-editable text inputs), so a
|
|
392
|
+
# hosted instance can't be repointed at arbitrary filesystem paths from the browser.
|
|
393
|
+
db_path = args.db_path
|
|
394
|
+
st.caption(f"DB: `{db_path}`")
|
|
395
|
+
try:
|
|
396
|
+
conn = connect_ro(db_path)
|
|
397
|
+
except sqlite3.OperationalError as e:
|
|
398
|
+
st.error(f"Cannot open DB read-only: {e}")
|
|
399
|
+
st.stop()
|
|
400
|
+
tags = list_tags(conn)
|
|
401
|
+
if not tags:
|
|
402
|
+
conn.close()
|
|
403
|
+
st.warning("No run tags found in this DB yet.")
|
|
404
|
+
st.stop()
|
|
405
|
+
default_idx = tags.index(args.tag) if args.tag in tags else len(tags) - 1
|
|
406
|
+
tag = st.selectbox("Tag", tags, index=default_idx)
|
|
407
|
+
refresh = st.number_input("Auto-refresh (s, 0=off)", 0, 600, value=args.refresh)
|
|
408
|
+
plots_dir = args.plots_dir or default_plots_dir(db_path)
|
|
409
|
+
st.caption(f"Plots: `{plots_dir}`")
|
|
410
|
+
|
|
411
|
+
try:
|
|
412
|
+
resources = load_resources(conn, tag)
|
|
413
|
+
stages = load_stages(conn, tag)
|
|
414
|
+
summary = run_summary(resources, stages)
|
|
415
|
+
|
|
416
|
+
st.title(f"Aetherscan — {tag}")
|
|
417
|
+
c1, c2, c3, c4 = st.columns(4)
|
|
418
|
+
c1.metric("Wall clock", _fmt_duration(summary["wall_s"]))
|
|
419
|
+
c2.metric("Stage spans", summary["n_stages"])
|
|
420
|
+
c3.metric("Latest stage", summary["latest_stage"] or "—")
|
|
421
|
+
c4.metric(
|
|
422
|
+
"Peak sys RAM", f"{summary['peak_ram_pct']:.0f}%" if summary["peak_ram_pct"] else "—"
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
tabs = st.tabs(
|
|
426
|
+
[
|
|
427
|
+
"Training",
|
|
428
|
+
"RF",
|
|
429
|
+
"Injection",
|
|
430
|
+
"Latent",
|
|
431
|
+
"Resources",
|
|
432
|
+
"Stages",
|
|
433
|
+
"Inference",
|
|
434
|
+
"All plots (PNG)",
|
|
435
|
+
]
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
# --- Training loss + stability ----------------------------------------
|
|
439
|
+
with tabs[0]:
|
|
440
|
+
loss = load_training_stats(conn, tag, _LOSS_STATS)
|
|
441
|
+
stab = load_training_stats(conn, tag, _STABILITY_STATS)
|
|
442
|
+
if loss.empty and stab.empty:
|
|
443
|
+
st.caption("No beta_vae training_stats yet.")
|
|
444
|
+
for title, df in (("Loss curves", loss), ("Training stability", stab)):
|
|
445
|
+
if df.empty:
|
|
446
|
+
continue
|
|
447
|
+
st.subheader(title)
|
|
448
|
+
d = df.copy()
|
|
449
|
+
d["kind"] = d["stat_name"].str.replace("^val_", "", regex=True)
|
|
450
|
+
d["split"] = np.where(d["stat_name"].str.startswith("val_"), "val", "train")
|
|
451
|
+
for kind in sorted(d["kind"].unique()):
|
|
452
|
+
sub = d[d["kind"] == kind].copy()
|
|
453
|
+
sub["step"] = range(len(sub))
|
|
454
|
+
fig = px.line(sub, x="step", y="value", color="split", title=kind)
|
|
455
|
+
fig.update_layout(height=240, margin={"l": 10, "r": 10, "t": 40, "b": 10})
|
|
456
|
+
st.plotly_chart(fig, use_container_width=True)
|
|
457
|
+
|
|
458
|
+
# --- RF eval metrics ---------------------------------------------------
|
|
459
|
+
with tabs[1]:
|
|
460
|
+
rf = load_rf_stats(conn, tag)
|
|
461
|
+
if rf.empty:
|
|
462
|
+
st.caption("No rf training_stats yet (written when the RF stage completes).")
|
|
463
|
+
else:
|
|
464
|
+
scalars = dict(zip(rf["stat_name"], rf["value"], strict=False))
|
|
465
|
+
tiles = [
|
|
466
|
+
("Val accuracy", "val_accuracy"),
|
|
467
|
+
("ROC-AUC", "val_roc_auc"),
|
|
468
|
+
("Avg precision", "val_average_precision"),
|
|
469
|
+
("Brier score", "val_brier_score"),
|
|
470
|
+
]
|
|
471
|
+
cols = st.columns(len(tiles))
|
|
472
|
+
for col, (label, name) in zip(cols, tiles, strict=False):
|
|
473
|
+
v = scalars.get(name)
|
|
474
|
+
col.metric(label, f"{v:.4f}" if v is not None else "—")
|
|
475
|
+
thr = scalars.get("classification_threshold")
|
|
476
|
+
if thr is not None:
|
|
477
|
+
st.caption(
|
|
478
|
+
f"Accuracy + confusion cells at deployment threshold {thr:g}; "
|
|
479
|
+
"the ensemble curve thresholds at 0.5."
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
binary_cm, subtype_cm = rf_confusion_matrices(rf)
|
|
483
|
+
left, right = st.columns(2)
|
|
484
|
+
for col, cm, title in (
|
|
485
|
+
(left, binary_cm, "Binary confusion (val)"),
|
|
486
|
+
(right, subtype_cm, "Sub-type × prediction (val)"),
|
|
487
|
+
):
|
|
488
|
+
if cm is None:
|
|
489
|
+
continue
|
|
490
|
+
with col:
|
|
491
|
+
st.subheader(title)
|
|
492
|
+
fig = px.imshow(
|
|
493
|
+
cm.to_numpy(),
|
|
494
|
+
x=list(cm.columns),
|
|
495
|
+
y=list(cm.index),
|
|
496
|
+
text_auto=".0f",
|
|
497
|
+
color_continuous_scale="Blues",
|
|
498
|
+
)
|
|
499
|
+
fig.update_layout(
|
|
500
|
+
height=300,
|
|
501
|
+
margin={"l": 10, "r": 10, "t": 10, "b": 10},
|
|
502
|
+
coloraxis_showscale=False,
|
|
503
|
+
)
|
|
504
|
+
st.plotly_chart(fig, use_container_width=True)
|
|
505
|
+
|
|
506
|
+
acc = rf[rf["stat_name"].str.startswith("val_accuracy_")].copy()
|
|
507
|
+
if not acc.empty:
|
|
508
|
+
acc["subtype"] = acc["stat_name"].str.replace("val_accuracy_", "", regex=False)
|
|
509
|
+
fig = px.bar(acc, x="subtype", y="value", title="Per-sub-type val accuracy")
|
|
510
|
+
fig.update_layout(height=260, margin={"l": 10, "r": 10, "t": 40, "b": 10})
|
|
511
|
+
st.plotly_chart(fig, use_container_width=True)
|
|
512
|
+
|
|
513
|
+
curve = rf[rf["stat_name"] == "ensemble_val_accuracy"].sort_values("epoch_number")
|
|
514
|
+
if curve.empty:
|
|
515
|
+
st.caption("No ensemble_val_accuracy series yet (written by rf_plots).")
|
|
516
|
+
else:
|
|
517
|
+
fig = px.line(
|
|
518
|
+
curve, x="epoch_number", y="value", title="Ensemble accuracy vs tree count"
|
|
519
|
+
)
|
|
520
|
+
fig.update_layout(
|
|
521
|
+
height=280,
|
|
522
|
+
margin={"l": 10, "r": 10, "t": 40, "b": 10},
|
|
523
|
+
xaxis_title="number of trees",
|
|
524
|
+
yaxis_title="val accuracy",
|
|
525
|
+
)
|
|
526
|
+
st.plotly_chart(fig, use_container_width=True)
|
|
527
|
+
|
|
528
|
+
quant = rf[rf["stat_name"].str.startswith("val_proba_q")].copy()
|
|
529
|
+
if not quant.empty:
|
|
530
|
+
quant["quantile"] = quant["stat_name"].str.replace(
|
|
531
|
+
"val_proba_q", "p", regex=False
|
|
532
|
+
)
|
|
533
|
+
fig = px.bar(
|
|
534
|
+
quant.sort_values("quantile"),
|
|
535
|
+
x="quantile",
|
|
536
|
+
y="value",
|
|
537
|
+
title="Val P(true) quantiles",
|
|
538
|
+
)
|
|
539
|
+
fig.update_layout(height=260, margin={"l": 10, "r": 10, "t": 40, "b": 10})
|
|
540
|
+
st.plotly_chart(fig, use_container_width=True)
|
|
541
|
+
st.caption(
|
|
542
|
+
"Richer RF figures (SHAP suite, decision boundary, calibration) live under "
|
|
543
|
+
"All plots (PNG)."
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
# --- Injection stats ---------------------------------------------------
|
|
547
|
+
with tabs[2]:
|
|
548
|
+
inj = load_injection_stats(conn, tag)
|
|
549
|
+
if inj.empty:
|
|
550
|
+
st.caption("No injection_stats yet.")
|
|
551
|
+
else:
|
|
552
|
+
st.subheader("Injection stability (per round)")
|
|
553
|
+
stability = (
|
|
554
|
+
inj.assign(nonfinite=1 - inj["is_finite"].fillna(1))
|
|
555
|
+
.groupby("round_number")[["nonfinite", "slope_clamped"]]
|
|
556
|
+
.mean()
|
|
557
|
+
.reset_index()
|
|
558
|
+
)
|
|
559
|
+
fig = px.line(
|
|
560
|
+
stability,
|
|
561
|
+
x="round_number",
|
|
562
|
+
y=["nonfinite", "slope_clamped"],
|
|
563
|
+
title="mean non-finite / slope-clamp rate",
|
|
564
|
+
markers=True,
|
|
565
|
+
)
|
|
566
|
+
fig.update_layout(height=260, margin={"l": 10, "r": 10, "t": 40, "b": 10})
|
|
567
|
+
st.plotly_chart(fig, use_container_width=True)
|
|
568
|
+
|
|
569
|
+
st.subheader("Injected-signal / intensity stat distributions")
|
|
570
|
+
stat = st.selectbox("stat_name", sorted(inj["stat_name"].unique()))
|
|
571
|
+
sub = inj[(inj["stat_name"] == stat) & inj["value"].notna()]
|
|
572
|
+
color = "signal_type" if sub["signal_type"].notna().any() else None
|
|
573
|
+
fig = px.histogram(
|
|
574
|
+
sub, x="value", color=color, barmode="overlay", nbins=60, title=stat
|
|
575
|
+
)
|
|
576
|
+
fig.update_layout(height=300, margin={"l": 10, "r": 10, "t": 40, "b": 10})
|
|
577
|
+
st.plotly_chart(fig, use_container_width=True)
|
|
578
|
+
|
|
579
|
+
# --- Latent scatter (live PCA of the latest snapshot) ------------------
|
|
580
|
+
with tabs[3]:
|
|
581
|
+
snaps = load_latent_snapshots_latest(conn, tag)
|
|
582
|
+
mat, labels = parse_latent_matrix(snaps)
|
|
583
|
+
if mat.shape[0] == 0:
|
|
584
|
+
st.caption("No latent_snapshots yet.")
|
|
585
|
+
else:
|
|
586
|
+
proj = pca_2d(mat)
|
|
587
|
+
df = pd.DataFrame({"pc1": proj[:, 0], "pc2": proj[:, 1], "signal_type": labels})
|
|
588
|
+
st.subheader(
|
|
589
|
+
f"Latent space — latest snapshot (PCA of {mat.shape[0]}×{mat.shape[1]})"
|
|
590
|
+
)
|
|
591
|
+
fig = px.scatter(df, x="pc1", y="pc2", color="signal_type", opacity=0.7)
|
|
592
|
+
fig.update_layout(height=520, margin={"l": 10, "r": 10, "t": 10, "b": 10})
|
|
593
|
+
st.plotly_chart(fig, use_container_width=True)
|
|
594
|
+
st.caption(
|
|
595
|
+
"Cheap PCA projection; the pipeline's saved UMAP animation is under All plots."
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
# --- Resource utilization ---------------------------------------------
|
|
599
|
+
with tabs[4]:
|
|
600
|
+
if resources.empty:
|
|
601
|
+
st.caption("No system_resources yet.")
|
|
602
|
+
else:
|
|
603
|
+
r = resources.copy()
|
|
604
|
+
r["t_min"] = (r["timestamp"] - r["timestamp"].min()) / 60.0
|
|
605
|
+
r["series"] = r["resource_type"] + ":" + r["resource_name"]
|
|
606
|
+
for rtype in ("cpu", "ram", "gpu"):
|
|
607
|
+
sub = r[r["resource_type"] == rtype]
|
|
608
|
+
if sub.empty:
|
|
609
|
+
continue
|
|
610
|
+
fig = px.line(sub, x="t_min", y="value", color="series", title=rtype.upper())
|
|
611
|
+
fig.update_layout(
|
|
612
|
+
height=260,
|
|
613
|
+
margin={"l": 10, "r": 10, "t": 40, "b": 10},
|
|
614
|
+
xaxis_title="minutes",
|
|
615
|
+
yaxis_title="%",
|
|
616
|
+
)
|
|
617
|
+
st.plotly_chart(fig, use_container_width=True)
|
|
618
|
+
|
|
619
|
+
# --- Stage timeline ----------------------------------------------------
|
|
620
|
+
with tabs[5]:
|
|
621
|
+
if stages.empty:
|
|
622
|
+
st.caption("No pipeline_stages yet (needs PR #134's benchmarking layer).")
|
|
623
|
+
else:
|
|
624
|
+
s = stages.copy()
|
|
625
|
+
t0 = s["start_time"].min()
|
|
626
|
+
s["start_min"] = (s["start_time"] - t0) / 60.0
|
|
627
|
+
s["dur_min"] = (s["end_time"] - s["start_time"]) / 60.0
|
|
628
|
+
s["family"] = s["stage"].str.split(".").str[0]
|
|
629
|
+
palette = px.colors.qualitative.Plotly
|
|
630
|
+
fig = go.Figure()
|
|
631
|
+
for i, fam in enumerate(sorted(s["family"].unique())):
|
|
632
|
+
sub = s[s["family"] == fam]
|
|
633
|
+
fig.add_bar(
|
|
634
|
+
x=sub["dur_min"],
|
|
635
|
+
base=sub["start_min"],
|
|
636
|
+
y=sub["stage"],
|
|
637
|
+
orientation="h",
|
|
638
|
+
name=fam,
|
|
639
|
+
marker_color=palette[i % len(palette)],
|
|
640
|
+
customdata=sub[["duration_s", "depth"]],
|
|
641
|
+
hovertemplate="%{y}<br>%{customdata[0]:.1f}s (depth %{customdata[1]})<extra></extra>",
|
|
642
|
+
)
|
|
643
|
+
fig.update_yaxes(autorange="reversed")
|
|
644
|
+
fig.update_xaxes(title="minutes since first stage")
|
|
645
|
+
fig.update_layout(
|
|
646
|
+
height=max(300, 22 * s["stage"].nunique()),
|
|
647
|
+
margin={"l": 10, "r": 10, "t": 20, "b": 10},
|
|
648
|
+
barmode="overlay",
|
|
649
|
+
)
|
|
650
|
+
st.plotly_chart(fig, use_container_width=True)
|
|
651
|
+
|
|
652
|
+
# --- Inference candidates ---------------------------------------------
|
|
653
|
+
with tabs[6]:
|
|
654
|
+
results = load_inference_results(conn, tag)
|
|
655
|
+
cadences = load_inference_cadences(conn, tag)
|
|
656
|
+
if results.empty and cadences.empty:
|
|
657
|
+
st.caption("No inference_results / inference_cadences yet.")
|
|
658
|
+
if not results.empty:
|
|
659
|
+
cands = results[results["prediction"] == 1]
|
|
660
|
+
st.subheader(f"Candidate confidence ({len(cands)} candidates)")
|
|
661
|
+
if not cands.empty:
|
|
662
|
+
fig = px.histogram(cands, x="confidence", nbins=40)
|
|
663
|
+
fig.update_layout(height=240, margin={"l": 10, "r": 10, "t": 10, "b": 10})
|
|
664
|
+
st.plotly_chart(fig, use_container_width=True)
|
|
665
|
+
for dim in ("target", "band"):
|
|
666
|
+
if cands[dim].notna().any():
|
|
667
|
+
counts = cands[dim].value_counts().reset_index()
|
|
668
|
+
counts.columns = [dim, "candidates"]
|
|
669
|
+
fig = px.bar(
|
|
670
|
+
counts, x=dim, y="candidates", title=f"candidates per {dim}"
|
|
671
|
+
)
|
|
672
|
+
fig.update_layout(
|
|
673
|
+
height=240, margin={"l": 10, "r": 10, "t": 40, "b": 10}
|
|
674
|
+
)
|
|
675
|
+
st.plotly_chart(fig, use_container_width=True)
|
|
676
|
+
if not cadences.empty:
|
|
677
|
+
st.subheader("Per-cadence manifest")
|
|
678
|
+
st.dataframe(cadences, use_container_width=True)
|
|
679
|
+
|
|
680
|
+
# --- All plots (PNG gallery) ------------------------------------------
|
|
681
|
+
with tabs[7]:
|
|
682
|
+
pngs = list_png_artifacts(plots_dir)
|
|
683
|
+
if not pngs:
|
|
684
|
+
st.caption(f"No plot PNGs under {plots_dir} yet.")
|
|
685
|
+
else:
|
|
686
|
+
st.caption(f"{len(pngs)} figures under {plots_dir} (newest first)")
|
|
687
|
+
cols = st.columns(2)
|
|
688
|
+
for i, art in enumerate(pngs):
|
|
689
|
+
with cols[i % 2]:
|
|
690
|
+
st.image(art["path"], caption=art["rel"], use_container_width=True)
|
|
691
|
+
|
|
692
|
+
finally:
|
|
693
|
+
conn.close()
|
|
694
|
+
|
|
695
|
+
if refresh and refresh > 0:
|
|
696
|
+
# sleep()+rerun() is the version-robust live refresh (works on any Streamlit); for a
|
|
697
|
+
# smoother non-blocking refresh, `pip install streamlit-autorefresh` and swap it in.
|
|
698
|
+
import time # noqa: PLC0415
|
|
699
|
+
|
|
700
|
+
time.sleep(refresh)
|
|
701
|
+
st.rerun()
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
def main() -> None: # pragma: no cover
|
|
705
|
+
render(_parse_args())
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
if __name__ == "__main__":
|
|
709
|
+
main()
|