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/hf_hub.py
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HuggingFace Hub integration for Aetherscan model artifacts.
|
|
3
|
+
|
|
4
|
+
One public model repo (config.hf.repo_id, default zachtheyek/aetherscan) carries the released
|
|
5
|
+
weights at stable filenames in the repo root — vae_encoder.keras, vae_decoder.keras,
|
|
6
|
+
random_forest.joblib, config.json, plus an auto-generated model card README.md — with HF git
|
|
7
|
+
tags carrying the versioning. Two tag families exist on the Hub: training tags (= a run's
|
|
8
|
+
save_tag, e.g. train_20260101_120000, created by upload_run_to_hf after training) and release
|
|
9
|
+
tags (vX.Y.Z, added by the release runbook pointing at the blessed weights commit).
|
|
10
|
+
|
|
11
|
+
Upload (train, opt-in via --hf-upload) stages the four run artifacts under the stable names,
|
|
12
|
+
commits them with the save_tag as the commit message, and tags the commit. Download
|
|
13
|
+
(inference, the default when no local artifact paths are given) resolves a revision —
|
|
14
|
+
explicit --hf-revision, else v{__version__} when running as an installed release (see
|
|
15
|
+
version_default_revision — this is what makes `pip install aetherscan==1.0.0` + bare
|
|
16
|
+
inference pull exactly the v1.0.0 weights), else the highest semver vX.Y.Z release tag — and
|
|
17
|
+
pulls the artifact trio revision-pinned via hf_hub_download. A no-artifact download requires a
|
|
18
|
+
release tag; training tags never name the default revision.
|
|
19
|
+
|
|
20
|
+
Auth: uploads require HF_TOKEN in the environment (loaded from the gitignored .env);
|
|
21
|
+
huggingface_hub reads it implicitly. The repo is public, so downloads need no token. The
|
|
22
|
+
token value is never logged.
|
|
23
|
+
|
|
24
|
+
huggingface_hub is imported lazily inside the thin _hf_api()/_hf_hub_download() seams so this
|
|
25
|
+
module stays importable (and unit-testable with those seams monkeypatched) without the
|
|
26
|
+
dependency installed — e.g. in the pre-rebuild NGC container, where only actual Hub calls
|
|
27
|
+
should fail.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import contextlib
|
|
34
|
+
import copy
|
|
35
|
+
import json
|
|
36
|
+
import logging
|
|
37
|
+
import os
|
|
38
|
+
import platform
|
|
39
|
+
import re
|
|
40
|
+
import shutil
|
|
41
|
+
import tempfile
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
from aetherscan.config import get_config
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
# Stable artifact filenames at the HF repo root (versioning lives in git tags, not names).
|
|
49
|
+
HF_ENCODER_FILENAME = "vae_encoder.keras"
|
|
50
|
+
HF_DECODER_FILENAME = "vae_decoder.keras"
|
|
51
|
+
HF_RF_FILENAME = "random_forest.joblib"
|
|
52
|
+
HF_CONFIG_FILENAME = "config.json"
|
|
53
|
+
HF_CALIBRATOR_FILENAME = "rf_calibrator.joblib" # optional: only calibrated runs (#282)
|
|
54
|
+
HF_CARD_FILENAME = "README.md"
|
|
55
|
+
|
|
56
|
+
GITHUB_URL = "https://github.com/zachtheyek/Aetherscan"
|
|
57
|
+
|
|
58
|
+
# Release tags (vX.Y.Z, from the release runbook) name the default download revision. Training
|
|
59
|
+
# tags (the {command}_{datetime} run tags) never name it — a no-artifact inference download
|
|
60
|
+
# requires a blessed release tag.
|
|
61
|
+
_SEMVER_TAG_PATTERN = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _hf_api():
|
|
65
|
+
"""Construct an HfApi client (lazy import — see module docstring). HF_TOKEN is picked up
|
|
66
|
+
from the environment implicitly for authenticated calls."""
|
|
67
|
+
from huggingface_hub import HfApi # noqa: PLC0415
|
|
68
|
+
|
|
69
|
+
return HfApi()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _hf_hub_download(**kwargs) -> str:
|
|
73
|
+
"""Thin seam over huggingface_hub.hf_hub_download (lazy import — see module docstring)."""
|
|
74
|
+
from huggingface_hub import hf_hub_download # noqa: PLC0415
|
|
75
|
+
|
|
76
|
+
return hf_hub_download(**kwargs)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _disable_hub_progress_bars() -> None:
|
|
80
|
+
"""Disable huggingface_hub's tqdm progress bars for this process. The pipeline redirects
|
|
81
|
+
sys.stdout/sys.stderr to loggers (StreamToLogger), so interactive progress rendering is
|
|
82
|
+
line-spam at best — and tty/capability probing by the progress machinery must never be a
|
|
83
|
+
failure surface for uploads/downloads (hf_upload once died on a stdout isatty() probe).
|
|
84
|
+
Tolerant of a missing huggingface_hub: the subsequent Hub call raises its own error."""
|
|
85
|
+
try:
|
|
86
|
+
from huggingface_hub.utils import disable_progress_bars # noqa: PLC0415
|
|
87
|
+
except Exception:
|
|
88
|
+
return
|
|
89
|
+
disable_progress_bars()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _is_tag_conflict(exc: Exception) -> bool:
|
|
93
|
+
"""True when `exc` is the Hub's tag-already-exists HTTP 409 conflict."""
|
|
94
|
+
try:
|
|
95
|
+
from huggingface_hub.errors import HfHubHTTPError # noqa: PLC0415
|
|
96
|
+
except Exception:
|
|
97
|
+
return False
|
|
98
|
+
if not isinstance(exc, HfHubHTTPError):
|
|
99
|
+
return False
|
|
100
|
+
return getattr(getattr(exc, "response", None), "status_code", None) == 409
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def list_hf_tags(repo_id: str) -> list[str]:
|
|
104
|
+
"""Return the names of every git tag on the HF model repo; [] when the repo doesn't exist
|
|
105
|
+
yet (first upload creates it). Network/auth errors propagate to the caller."""
|
|
106
|
+
from huggingface_hub.errors import RepositoryNotFoundError # noqa: PLC0415
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
refs = _hf_api().list_repo_refs(repo_id, repo_type="model")
|
|
110
|
+
except RepositoryNotFoundError:
|
|
111
|
+
return []
|
|
112
|
+
return [ref.name for ref in refs.tags]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def hf_tag_exists(repo_id: str, tag: str) -> bool:
|
|
116
|
+
"""True when `tag` already exists on the HF repo (used by the fail-early dedup guard)."""
|
|
117
|
+
return tag in list_hf_tags(repo_id)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def select_default_revision(tags: list[str]) -> str | None:
|
|
121
|
+
"""
|
|
122
|
+
Pick the default download revision from a repo's tag list: the highest semver vX.Y.Z
|
|
123
|
+
release tag, or None if the repo carries no release tag. Comparison is numeric
|
|
124
|
+
(v1.10.0 > v1.9.9); training tags (the {command}_{datetime} run tags) never win — a
|
|
125
|
+
no-artifact inference download requires a blessed release tag.
|
|
126
|
+
"""
|
|
127
|
+
semver = [
|
|
128
|
+
(tuple(int(g) for g in m.groups()), t) for t in tags if (m := _SEMVER_TAG_PATTERN.match(t))
|
|
129
|
+
]
|
|
130
|
+
if semver:
|
|
131
|
+
return max(semver)[1]
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def version_default_revision() -> str | None:
|
|
136
|
+
"""
|
|
137
|
+
The version-coupled default HF revision — f"v{__version__}" — or None when this run's
|
|
138
|
+
version can't name a release tag. This is the runtime half of the release contract
|
|
139
|
+
(docs/RELEASE.md): `pip install aetherscan==1.0.0` + inference with no model-path flags
|
|
140
|
+
downloads exactly the v1.0.0-blessed weights, because the installed package version is
|
|
141
|
+
the default revision.
|
|
142
|
+
|
|
143
|
+
Guard: the default only activates when v{__version__} lands in the release-tag family
|
|
144
|
+
(_SEMVER_TAG_PATTERN, strict vX.Y.Z). The importlib.metadata fallback ("0.0.0.dev0" —
|
|
145
|
+
source-tree/container runs), .dev pre-releases (e.g. "0.9.0.dev0" — a pip install of a
|
|
146
|
+
between-releases tree), and rc/post/local versions all fail the match and fall through
|
|
147
|
+
to latest-release resolution instead. Deliberately NOT existence-checked: an installed
|
|
148
|
+
release whose weights tag is missing must fail the download loudly (the release
|
|
149
|
+
runbook's blessing step was skipped) rather than silently pull some other version's
|
|
150
|
+
weights.
|
|
151
|
+
"""
|
|
152
|
+
import aetherscan # noqa: PLC0415 (late import so tests can monkeypatch __version__)
|
|
153
|
+
|
|
154
|
+
candidate = f"v{aetherscan.__version__}"
|
|
155
|
+
if not _SEMVER_TAG_PATTERN.match(candidate):
|
|
156
|
+
return None
|
|
157
|
+
return candidate
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def resolve_hf_revision(repo_id: str, revision: str | None) -> str:
|
|
161
|
+
"""
|
|
162
|
+
Resolve the HF revision inference should download from: an explicitly requested revision
|
|
163
|
+
is returned as-is (existence is checked by the download itself); otherwise an installed
|
|
164
|
+
release pins its own version's tag (version_default_revision — also download-checked);
|
|
165
|
+
otherwise the repo's tags are listed and select_default_revision picks the latest
|
|
166
|
+
release. Raises RuntimeError with guidance when nothing is resolvable.
|
|
167
|
+
"""
|
|
168
|
+
if revision is not None:
|
|
169
|
+
return revision
|
|
170
|
+
version_pinned = version_default_revision()
|
|
171
|
+
if version_pinned is not None:
|
|
172
|
+
logger.info(
|
|
173
|
+
f"Resolved HF revision '{version_pinned}' (pinned to the installed aetherscan "
|
|
174
|
+
f"release; override with --hf-revision)"
|
|
175
|
+
)
|
|
176
|
+
return version_pinned
|
|
177
|
+
try:
|
|
178
|
+
tags = list_hf_tags(repo_id)
|
|
179
|
+
except Exception as e:
|
|
180
|
+
raise RuntimeError(
|
|
181
|
+
f"Could not list tags on HF repo '{repo_id}' to resolve the latest release: {e}. "
|
|
182
|
+
f"Check that --hf-repo-id is correct, the repo is public (or HF_TOKEN grants "
|
|
183
|
+
f"access), and this host can reach huggingface.co — or pin a revision with "
|
|
184
|
+
f"--hf-revision / pass all three local artifact paths."
|
|
185
|
+
) from e
|
|
186
|
+
selected = select_default_revision(tags)
|
|
187
|
+
if selected is None:
|
|
188
|
+
raise RuntimeError(
|
|
189
|
+
f"No release tag (vX.Y.Z) found on HF repo "
|
|
190
|
+
f"'{repo_id}' to download model artifacts from. Either pin a revision with "
|
|
191
|
+
f"--hf-revision, point --hf-repo-id at a repo with released weights, or pass "
|
|
192
|
+
f"all three local artifact paths (--encoder-path/--rf-path/--config-path)."
|
|
193
|
+
)
|
|
194
|
+
logger.info(f"Resolved HF revision '{selected}' (latest release tag on {repo_id})")
|
|
195
|
+
return selected
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def download_inference_artifacts(repo_id: str, revision: str) -> tuple[str, str, str]:
|
|
199
|
+
"""
|
|
200
|
+
Download the (encoder, random forest, config) artifact trio from the HF repo at the
|
|
201
|
+
pinned revision. Returns the local cache paths (under HF_HOME / ~/.cache/huggingface;
|
|
202
|
+
repeated runs hit the cache). Public repo — no token required.
|
|
203
|
+
"""
|
|
204
|
+
_disable_hub_progress_bars()
|
|
205
|
+
try:
|
|
206
|
+
paths = tuple(
|
|
207
|
+
_hf_hub_download(repo_id=repo_id, filename=filename, revision=revision)
|
|
208
|
+
for filename in (HF_ENCODER_FILENAME, HF_RF_FILENAME, HF_CONFIG_FILENAME)
|
|
209
|
+
)
|
|
210
|
+
# Optional calibrator (#282): lands in the same snapshot dir as the RF, where
|
|
211
|
+
# InferencePipeline.init_models derives its path from rf_path. Absent for
|
|
212
|
+
# uncalibrated runs and pre-#282 uploads — the downloaded config's
|
|
213
|
+
# rf.calibration_active decides whether inference requires it.
|
|
214
|
+
try:
|
|
215
|
+
_hf_hub_download(repo_id=repo_id, filename=HF_CALIBRATOR_FILENAME, revision=revision)
|
|
216
|
+
except Exception:
|
|
217
|
+
logger.info(
|
|
218
|
+
f"No {HF_CALIBRATOR_FILENAME} at {repo_id}@{revision} (expected for "
|
|
219
|
+
"uncalibrated or pre-#282 runs)"
|
|
220
|
+
)
|
|
221
|
+
except Exception as e:
|
|
222
|
+
# Wrap raw huggingface_hub errors (404s, network failures, auth) with operator
|
|
223
|
+
# guidance — this surfaces via main.py's resolution path at startup.
|
|
224
|
+
raise RuntimeError(
|
|
225
|
+
f"Failed to download model artifacts from {repo_id}@{revision}: {e}. Check "
|
|
226
|
+
f"that the revision exists (--hf-revision), the repo id is correct "
|
|
227
|
+
f"(--hf-repo-id), the repo is public (or HF_TOKEN grants access), and this "
|
|
228
|
+
f"host can reach huggingface.co."
|
|
229
|
+
) from e
|
|
230
|
+
logger.info(f"Downloaded model artifacts from {repo_id}@{revision}")
|
|
231
|
+
return paths
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def resolve_inference_artifacts(args: argparse.Namespace) -> None:
|
|
235
|
+
"""
|
|
236
|
+
Ensure the inference artifact trio (encoder/rf/config paths) is populated on `args`
|
|
237
|
+
before validation runs, in resolution order: explicit local paths (highest) >
|
|
238
|
+
--hf-revision > v{__version__} when running as an installed release (see
|
|
239
|
+
version_default_revision) > latest release tag on the HF repo.
|
|
240
|
+
|
|
241
|
+
When none of the three paths were given, the resolved revision's artifacts are
|
|
242
|
+
downloaded and their cache paths written onto `args` — exactly as if the user had passed
|
|
243
|
+
them on the CLI, so the existing validate_args / apply_saved_config / model-load path is
|
|
244
|
+
reused unchanged. The resolved revision is also written to args.hf_revision so it lands
|
|
245
|
+
in config.hf.revision (and thus the saved inference config) for provenance.
|
|
246
|
+
|
|
247
|
+
A partial set of local paths is left untouched: collect_validation_errors reports the
|
|
248
|
+
missing ones (mixing local and Hub-sourced artifacts would silently pair mismatched
|
|
249
|
+
models). Raises RuntimeError (via resolve_hf_revision) or huggingface_hub errors when HF
|
|
250
|
+
resolution fails — the caller exits with the message as guidance.
|
|
251
|
+
"""
|
|
252
|
+
provided = [
|
|
253
|
+
getattr(args, name, None) is not None for name in ("encoder_path", "rf_path", "config_path")
|
|
254
|
+
]
|
|
255
|
+
if all(provided):
|
|
256
|
+
if getattr(args, "hf_revision", None) is not None:
|
|
257
|
+
logger.info("Explicit local artifact paths take precedence — ignoring --hf-revision")
|
|
258
|
+
return
|
|
259
|
+
if any(provided):
|
|
260
|
+
# Partial trio: leave args as-is; validation reports the missing paths.
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
config = get_config()
|
|
264
|
+
if config is None:
|
|
265
|
+
raise ValueError("get_config() returned None")
|
|
266
|
+
repo_id = getattr(args, "hf_repo_id", None) or config.hf.repo_id
|
|
267
|
+
revision = resolve_hf_revision(repo_id, getattr(args, "hf_revision", None))
|
|
268
|
+
logger.info(
|
|
269
|
+
f"No local artifact paths given — downloading model artifacts from HF repo "
|
|
270
|
+
f"'{repo_id}' at revision '{revision}'"
|
|
271
|
+
)
|
|
272
|
+
args.encoder_path, args.rf_path, args.config_path = download_inference_artifacts(
|
|
273
|
+
repo_id, revision
|
|
274
|
+
)
|
|
275
|
+
args.hf_revision = revision
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _collect_library_versions() -> dict[str, str]:
|
|
279
|
+
"""Best-effort version stamps for the model card; import failures record 'unknown'."""
|
|
280
|
+
versions: dict[str, str] = {"python": platform.python_version()}
|
|
281
|
+
for card_name, module_name in (
|
|
282
|
+
("tensorflow", "tensorflow"),
|
|
283
|
+
("numpy", "numpy"),
|
|
284
|
+
("scikit-learn", "sklearn"),
|
|
285
|
+
("huggingface_hub", "huggingface_hub"),
|
|
286
|
+
):
|
|
287
|
+
try:
|
|
288
|
+
module = __import__(module_name)
|
|
289
|
+
versions[card_name] = str(module.__version__)
|
|
290
|
+
except Exception:
|
|
291
|
+
versions[card_name] = "unknown"
|
|
292
|
+
return versions
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def compute_rf_metrics(model_path: str, tag: str) -> dict[str, Any] | None:
|
|
296
|
+
"""
|
|
297
|
+
Derive validation metrics for the model card from the run's persisted RF eval artifacts
|
|
298
|
+
(rf_eval_artifacts_{tag}.joblib, written by train_random_forest). Returns None when the
|
|
299
|
+
artifact is missing or unreadable — the card simply omits its metrics section.
|
|
300
|
+
"""
|
|
301
|
+
artifact_path = os.path.join(model_path, f"rf_eval_artifacts_{tag}.joblib")
|
|
302
|
+
if not os.path.exists(artifact_path):
|
|
303
|
+
logger.info(f"No RF eval artifacts at {artifact_path} — model card omits metrics")
|
|
304
|
+
return None
|
|
305
|
+
try:
|
|
306
|
+
import joblib # noqa: PLC0415
|
|
307
|
+
from sklearn.metrics import average_precision_score, roc_auc_score # noqa: PLC0415
|
|
308
|
+
|
|
309
|
+
artifacts = joblib.load(artifact_path)
|
|
310
|
+
labels = artifacts["val_binary_labels"]
|
|
311
|
+
probas = artifacts["val_probas"]
|
|
312
|
+
return {
|
|
313
|
+
"val_roc_auc": float(roc_auc_score(labels, probas)),
|
|
314
|
+
"val_average_precision": float(average_precision_score(labels, probas)),
|
|
315
|
+
"classification_threshold": float(artifacts["classification_threshold"]),
|
|
316
|
+
"n_val": int(len(labels)),
|
|
317
|
+
}
|
|
318
|
+
except Exception as e:
|
|
319
|
+
logger.warning(f"Failed to compute RF metrics from {artifact_path}: {e}")
|
|
320
|
+
return None
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def generate_model_card(
|
|
324
|
+
*,
|
|
325
|
+
tag: str,
|
|
326
|
+
config_dict: dict[str, Any],
|
|
327
|
+
metrics: dict[str, Any] | None,
|
|
328
|
+
versions: dict[str, str],
|
|
329
|
+
) -> str:
|
|
330
|
+
"""
|
|
331
|
+
Render the repo card (README.md) uploaded alongside the weights: pipeline description,
|
|
332
|
+
training tag, config summary (rounds / samples / SNR schedule), validation metrics when
|
|
333
|
+
available, library versions, and the GitHub link + citation pointer.
|
|
334
|
+
"""
|
|
335
|
+
training = config_dict.get("training", {})
|
|
336
|
+
beta_vae = config_dict.get("beta_vae", {})
|
|
337
|
+
rf = config_dict.get("rf", {})
|
|
338
|
+
|
|
339
|
+
def row(name: str, section: dict[str, Any], key: str) -> str:
|
|
340
|
+
return f"| {name} | `{section.get(key, 'n/a')}` |"
|
|
341
|
+
|
|
342
|
+
config_rows = "\n".join(
|
|
343
|
+
[
|
|
344
|
+
row("Training rounds", training, "num_training_rounds"),
|
|
345
|
+
row("Epochs per round", training, "epochs_per_round"),
|
|
346
|
+
row("Beta-VAE samples per round", training, "num_samples_beta_vae"),
|
|
347
|
+
row("Random Forest samples", training, "num_samples_rf"),
|
|
348
|
+
row("Curriculum schedule", training, "curriculum_schedule"),
|
|
349
|
+
row("SNR base", training, "snr_base"),
|
|
350
|
+
row("Initial SNR range", training, "initial_snr_range"),
|
|
351
|
+
row("Final SNR range", training, "final_snr_range"),
|
|
352
|
+
row("Latent dimensions", beta_vae, "latent_dim"),
|
|
353
|
+
row("Beta (KL weight)", beta_vae, "beta"),
|
|
354
|
+
row("Alpha (clustering weight)", beta_vae, "alpha"),
|
|
355
|
+
row("RF estimators", rf, "n_estimators"),
|
|
356
|
+
]
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
if metrics is not None:
|
|
360
|
+
metrics_section = (
|
|
361
|
+
"## Evaluation (validation split)\n\n"
|
|
362
|
+
"| Metric | Value |\n|---|---|\n"
|
|
363
|
+
f"| ROC AUC | {metrics['val_roc_auc']:.4f} |\n"
|
|
364
|
+
f"| Average precision | {metrics['val_average_precision']:.4f} |\n"
|
|
365
|
+
f"| Classification threshold | {metrics['classification_threshold']} |\n"
|
|
366
|
+
f"| Validation samples | {metrics['n_val']} |\n"
|
|
367
|
+
)
|
|
368
|
+
else:
|
|
369
|
+
metrics_section = "## Evaluation\n\nNo evaluation artifacts were available for this run.\n"
|
|
370
|
+
|
|
371
|
+
versions_rows = "\n".join(f"| {name} | `{ver}` |" for name, ver in versions.items())
|
|
372
|
+
|
|
373
|
+
return f"""---
|
|
374
|
+
license: bsd-3-clause
|
|
375
|
+
library_name: keras
|
|
376
|
+
tags:
|
|
377
|
+
- seti
|
|
378
|
+
- radio-astronomy
|
|
379
|
+
- anomaly-detection
|
|
380
|
+
- beta-vae
|
|
381
|
+
- random-forest
|
|
382
|
+
---
|
|
383
|
+
|
|
384
|
+
# Aetherscan
|
|
385
|
+
|
|
386
|
+
[Breakthrough Listen](https://breakthroughinitiatives.org/initiative/1)'s deep-learning SETI
|
|
387
|
+
pipeline: a two-stage architecture where a **Beta-VAE encoder** compresses each observation of
|
|
388
|
+
a 6-observation cadence (3 ON / 3 OFF, ABACAD) into an 8-dimensional latent, and a **Random
|
|
389
|
+
Forest** classifies the cadence's concatenated latents as a technosignature candidate or not.
|
|
390
|
+
|
|
391
|
+
This repository carries the released model weights at stable filenames, versioned via git
|
|
392
|
+
tags: training tags match the pipeline run's save tag (e.g. `train_20260101_120000`), and
|
|
393
|
+
release tags (`vX.Y.Z`) mark blessed weights.
|
|
394
|
+
|
|
395
|
+
**Training tag**: `{tag}`
|
|
396
|
+
|
|
397
|
+
## Files
|
|
398
|
+
|
|
399
|
+
| File | Description |
|
|
400
|
+
|---|---|
|
|
401
|
+
| `{HF_ENCODER_FILENAME}` | Beta-VAE encoder (Keras) — the inference feature extractor |
|
|
402
|
+
| `{HF_DECODER_FILENAME}` | Beta-VAE decoder (Keras) — for reconstruction/traversal analysis |
|
|
403
|
+
| `{HF_RF_FILENAME}` | Random Forest cadence classifier (joblib) |
|
|
404
|
+
| `{HF_CONFIG_FILENAME}` | Full resolved training configuration for this run |
|
|
405
|
+
|
|
406
|
+
## Training configuration
|
|
407
|
+
|
|
408
|
+
| Parameter | Value |
|
|
409
|
+
|---|---|
|
|
410
|
+
{config_rows}
|
|
411
|
+
|
|
412
|
+
The complete configuration is in `{HF_CONFIG_FILENAME}`.
|
|
413
|
+
|
|
414
|
+
{metrics_section}
|
|
415
|
+
## Library versions
|
|
416
|
+
|
|
417
|
+
| Library | Version |
|
|
418
|
+
|---|---|
|
|
419
|
+
{versions_rows}
|
|
420
|
+
|
|
421
|
+
## Usage
|
|
422
|
+
|
|
423
|
+
Pin this training tag with `--hf-revision` to download exactly these weights (a bare no-artifact
|
|
424
|
+
inference download resolves to the latest `vX.Y.Z` release tag instead, never a training tag):
|
|
425
|
+
|
|
426
|
+
```bash
|
|
427
|
+
python -m aetherscan.main inference --hf-revision {tag} --inference-files <catalog.csv>
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
## Links & citation
|
|
431
|
+
|
|
432
|
+
Source code, documentation, and issue tracker: [{GITHUB_URL}]({GITHUB_URL}).
|
|
433
|
+
If you use Aetherscan in your research, please cite it via the repository's `CITATION.cff`.
|
|
434
|
+
"""
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# Environment-specific config fields stripped before config.json is published to the PUBLIC Hub
|
|
438
|
+
# repo (HFSEC-1): whole sections that are pure host layout, and per-field absolute paths / real
|
|
439
|
+
# observation filenames. None are reproducible off-host, and they would otherwise disclose the
|
|
440
|
+
# operator's username, directory layout, and dataset file names on huggingface.co.
|
|
441
|
+
_UPLOAD_REDACT_SECTIONS = ("paths",)
|
|
442
|
+
_UPLOAD_REDACT_FIELDS = {
|
|
443
|
+
"data": ("train_files", "test_files", "inference_files"),
|
|
444
|
+
"inference": ("encoder_path", "rf_path", "config_path", "preprocess_output_dir"),
|
|
445
|
+
"checkpoint": ("load_dir",),
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _sanitize_config_for_upload(config_dict: dict) -> dict:
|
|
450
|
+
"""Return a copy of the run config with host-specific fields (absolute paths, real dataset
|
|
451
|
+
filenames) stripped, so the config.json published to the public Hub repo carries only
|
|
452
|
+
reproducibility-relevant hyperparameters. See HFSEC-1."""
|
|
453
|
+
sanitized = copy.deepcopy(config_dict)
|
|
454
|
+
for section in _UPLOAD_REDACT_SECTIONS:
|
|
455
|
+
sanitized.pop(section, None)
|
|
456
|
+
for section, fields in _UPLOAD_REDACT_FIELDS.items():
|
|
457
|
+
block = sanitized.get(section)
|
|
458
|
+
if isinstance(block, dict):
|
|
459
|
+
for field in fields:
|
|
460
|
+
block.pop(field, None)
|
|
461
|
+
return sanitized
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def upload_run_to_hf(
|
|
465
|
+
*, repo_id: str, tag: str, model_path: str, output_path: str, force: bool = False
|
|
466
|
+
) -> None:
|
|
467
|
+
"""
|
|
468
|
+
Publish one training run's final artifacts to the HF model repo: stage the four artifacts
|
|
469
|
+
under their stable names plus a generated model card, commit them (commit message = the
|
|
470
|
+
run's save_tag), and tag the commit with the save_tag. Creates the (public) repo when it
|
|
471
|
+
doesn't exist yet. With force=True a pre-existing identical tag is moved to the new
|
|
472
|
+
commit (the startup dedup guard was consciously overridden via --force-tag).
|
|
473
|
+
|
|
474
|
+
Raises on any failure — the caller (the hf_upload training stage) records it in the run
|
|
475
|
+
manifest without failing the run.
|
|
476
|
+
"""
|
|
477
|
+
sources = {
|
|
478
|
+
HF_ENCODER_FILENAME: os.path.join(model_path, f"vae_encoder_{tag}.keras"),
|
|
479
|
+
HF_DECODER_FILENAME: os.path.join(model_path, f"vae_decoder_{tag}.keras"),
|
|
480
|
+
HF_RF_FILENAME: os.path.join(model_path, f"random_forest_{tag}.joblib"),
|
|
481
|
+
HF_CONFIG_FILENAME: os.path.join(output_path, f"config_{tag}.json"),
|
|
482
|
+
}
|
|
483
|
+
missing = [path for path in sources.values() if not os.path.exists(path)]
|
|
484
|
+
if missing:
|
|
485
|
+
raise FileNotFoundError(
|
|
486
|
+
f"Cannot upload run '{tag}' to HF: missing artifact(s): {', '.join(missing)}"
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
# Optional fifth artifact (#282): the probability calibrator, present only when training
|
|
490
|
+
# fitted-and-kept one (config.json's rf.calibration_active records which)
|
|
491
|
+
calibrator_source = os.path.join(model_path, f"rf_calibrator_{tag}.joblib")
|
|
492
|
+
if os.path.exists(calibrator_source):
|
|
493
|
+
sources[HF_CALIBRATOR_FILENAME] = calibrator_source
|
|
494
|
+
|
|
495
|
+
with open(sources[HF_CONFIG_FILENAME]) as f:
|
|
496
|
+
config_dict = json.load(f)
|
|
497
|
+
# The repo is PUBLIC, so strip environment-specific fields before publishing config.json:
|
|
498
|
+
# absolute host paths (username + internal layout) and real observation filenames, none of
|
|
499
|
+
# which are reproducible off-host (HFSEC-1). The model card renders only hyperparameters, so
|
|
500
|
+
# it is safe to build from the sanitized config too.
|
|
501
|
+
public_config = _sanitize_config_for_upload(config_dict)
|
|
502
|
+
card = generate_model_card(
|
|
503
|
+
tag=tag,
|
|
504
|
+
config_dict=public_config,
|
|
505
|
+
metrics=compute_rf_metrics(model_path, tag),
|
|
506
|
+
versions=_collect_library_versions(),
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
_disable_hub_progress_bars()
|
|
510
|
+
api = _hf_api()
|
|
511
|
+
with tempfile.TemporaryDirectory(prefix="aetherscan_hf_upload_") as staging:
|
|
512
|
+
for stable_name, source in sources.items():
|
|
513
|
+
if stable_name == HF_CONFIG_FILENAME:
|
|
514
|
+
with open(os.path.join(staging, stable_name), "w") as f:
|
|
515
|
+
json.dump(public_config, f, indent=2)
|
|
516
|
+
else:
|
|
517
|
+
shutil.copy2(source, os.path.join(staging, stable_name))
|
|
518
|
+
with open(os.path.join(staging, HF_CARD_FILENAME), "w") as f:
|
|
519
|
+
f.write(card)
|
|
520
|
+
|
|
521
|
+
api.create_repo(repo_id, repo_type="model", private=False, exist_ok=True)
|
|
522
|
+
api.upload_folder(repo_id=repo_id, folder_path=staging, commit_message=tag)
|
|
523
|
+
|
|
524
|
+
if force:
|
|
525
|
+
# --force-tag semantics: repoint the existing tag at the fresh commit rather than
|
|
526
|
+
# failing (delete errors are ignored — the tag may simply not exist yet).
|
|
527
|
+
# NOTE: delete_tag -> create_tag is not atomic (the Hub has no tag-move primitive):
|
|
528
|
+
# a crash between the two calls leaves the tag missing until the stage is retried —
|
|
529
|
+
# hf_upload stays un-done in the run manifest, so re-running the identical command
|
|
530
|
+
# recreates it. Acceptable for a consciously-forced override.
|
|
531
|
+
with contextlib.suppress(Exception):
|
|
532
|
+
api.delete_tag(repo_id, tag=tag)
|
|
533
|
+
try:
|
|
534
|
+
api.create_tag(repo_id, tag=tag)
|
|
535
|
+
except Exception as e:
|
|
536
|
+
if _is_tag_conflict(e):
|
|
537
|
+
# TOCTOU: the startup dedup guard checked this tag hours ago (a full-scale run
|
|
538
|
+
# trains for ~30 h) — a concurrent run may have created it since. The artifacts
|
|
539
|
+
# are safely uploaded on the main branch; only the tag is missing.
|
|
540
|
+
raise RuntimeError(
|
|
541
|
+
f"Tag '{tag}' already exists on {repo_id} (created after this run's "
|
|
542
|
+
f"startup check, e.g. by a concurrent run). The artifacts were uploaded "
|
|
543
|
+
f"but left untagged — re-run the identical command with --force-tag to "
|
|
544
|
+
f"move the tag to this run's upload."
|
|
545
|
+
) from e
|
|
546
|
+
raise
|
|
547
|
+
logger.info(f"Uploaded run '{tag}' to https://huggingface.co/{repo_id} and tagged the commit")
|