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/cli.py
ADDED
|
@@ -0,0 +1,3032 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI argument parsing for Aetherscan Pipeline
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
from dataclasses import dataclass, field, is_dataclass
|
|
14
|
+
from dataclasses import fields as dataclass_fields
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from math import gcd, lcm
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from aetherscan.config import InferenceConfig, get_config
|
|
20
|
+
from aetherscan.run_state import (
|
|
21
|
+
_INFERENCE_FINGERPRINT_DATA_KEYS,
|
|
22
|
+
_INFERENCE_FINGERPRINT_EXCLUDE_INFERENCE_KEYS,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Validation primitives are expressed as global variables and shared by
|
|
29
|
+
# validate_args() and utility scripts (e.g. utils/find_optimal_configs.py)
|
|
30
|
+
|
|
31
|
+
# Tag scheme (see resolve_save_tag): a run's save-tag is `{command}_{YYYYMMDD_HHMMSS}`. The user
|
|
32
|
+
# supplies only the command prefix on --save-tag; the datetime is appended at runtime. --load-tag
|
|
33
|
+
# takes a full resolved tag (resume that run in place) or a per-round checkpoint (round_XX).
|
|
34
|
+
_SAVE_TAG_PREFIXES = ("test", "train", "inf", "bench")
|
|
35
|
+
# --save-tag accepts one of the prefixes above (bare); the datetime is auto-appended.
|
|
36
|
+
_SAVE_TAG_PATTERN = re.compile(r"^(?:test|train|inf|bench)$")
|
|
37
|
+
# A fully-resolved run tag `{prefix}_{YYYYMMDD_HHMMSS}` — used to detect a resume-in-place --load-tag.
|
|
38
|
+
_FULL_TAG_PATTERN = re.compile(r"^(?:test|train|inf|bench)_\d{8}_\d{6}$")
|
|
39
|
+
# --load-tag accepts a full resolved tag (resume that run in place) or a per-round checkpoint.
|
|
40
|
+
_LOAD_TAG_PATTERN = re.compile(r"^(?:(?:test|train|inf|bench)_\d{8}_\d{6}|round_\d+)$")
|
|
41
|
+
|
|
42
|
+
# The round_XX subset of _LOAD_TAG_PATTERN: per-round checkpoints are saved under the checkpoints/
|
|
43
|
+
# subdirectory, so loading one requires --load-dir checkpoints (issue #142)
|
|
44
|
+
_ROUND_TAG_PATTERN = re.compile(r"^round_\d+$")
|
|
45
|
+
|
|
46
|
+
# Default --save-tag prefix per subcommand when --save-tag is omitted.
|
|
47
|
+
_COMMAND_TAG_PREFIX = {"train": "train", "inference": "inf"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def resolve_save_tag(command: str | None, save_tag: str | None, load_tag: str | None) -> str:
|
|
51
|
+
"""Resolve the run's full `{prefix}_{YYYYMMDD_HHMMSS}` save-tag at runtime.
|
|
52
|
+
|
|
53
|
+
- A full `{prefix}_{datetime}` ``--load-tag`` resumes that run **in place**: its tag is
|
|
54
|
+
adopted so the resumed attempt writes under the same ``run_state`` / DB rows / artifacts.
|
|
55
|
+
(A ``round_XX`` ``--load-tag`` does NOT adopt — it seeds a fresh run from that checkpoint.)
|
|
56
|
+
- Otherwise the tag is ``{prefix}_{datetime}``, where the prefix is ``--save-tag``
|
|
57
|
+
(``test|train|inf|bench``) or, when omitted, the subcommand default (``train`` → ``train``,
|
|
58
|
+
``inference`` → ``inf``). The datetime is stamped once, here, at run time.
|
|
59
|
+
"""
|
|
60
|
+
if load_tag is not None and _FULL_TAG_PATTERN.match(load_tag):
|
|
61
|
+
return load_tag
|
|
62
|
+
if save_tag is not None:
|
|
63
|
+
prefix = save_tag
|
|
64
|
+
elif command in _COMMAND_TAG_PREFIX:
|
|
65
|
+
prefix = _COMMAND_TAG_PREFIX[command]
|
|
66
|
+
else:
|
|
67
|
+
# Fail loudly rather than emit an unloadable tag (a prefix outside {test,train,inf,bench}
|
|
68
|
+
# would not match _LOAD_TAG_PATTERN, so its artifacts could never be --load-tag'd).
|
|
69
|
+
raise ValueError(
|
|
70
|
+
f"Cannot derive a save-tag prefix for command {command!r}; pass --save-tag explicitly."
|
|
71
|
+
)
|
|
72
|
+
return f"{prefix}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# sklearn's RandomForestClassifier accepts these string values for max_features
|
|
76
|
+
_RF_MAX_FEATURES_STR_VALUES = {"sqrt", "log2"}
|
|
77
|
+
|
|
78
|
+
# Allowed values for curriculum_schedule
|
|
79
|
+
_CURRICULUM_SCHEDULES = {"linear", "exponential", "step"}
|
|
80
|
+
|
|
81
|
+
# Allowed values for bandpass_method (energy-detection bandpass flattening)
|
|
82
|
+
_BANDPASS_METHODS = {"pfb", "spline"}
|
|
83
|
+
|
|
84
|
+
# HuggingFace Hub repo ids are "namespace/name" (user or org namespace)
|
|
85
|
+
_HF_REPO_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9._-]+$")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class ValidationError:
|
|
90
|
+
"""A single validation failure with enough structure for utility scripts to propose fixes."""
|
|
91
|
+
|
|
92
|
+
field: str # e.g. "training.effective_batch_size"
|
|
93
|
+
current: Any
|
|
94
|
+
message: str
|
|
95
|
+
fix_kind: str # one of: clamp_low, clamp_high, range, enum, divisibility, file_exists, cross_param, format
|
|
96
|
+
min_val: Any = None
|
|
97
|
+
max_val: Any = None
|
|
98
|
+
allowed: Any = None
|
|
99
|
+
divisor: Any = None
|
|
100
|
+
extra: dict = field(default_factory=dict)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _resolve(args: argparse.Namespace, arg_name: str, default: Any) -> Any:
|
|
104
|
+
"""Return `args.<arg_name>` if present and not None, otherwise `default` (typically the config value at startup time)."""
|
|
105
|
+
val = getattr(args, arg_name, None)
|
|
106
|
+
return val if val is not None else default
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _estimate_round_data_nbytes(
|
|
110
|
+
n_samples: int,
|
|
111
|
+
num_observations: int,
|
|
112
|
+
time_bins: int,
|
|
113
|
+
width_bin_downsampled: int,
|
|
114
|
+
bytes_per_element: int = 4,
|
|
115
|
+
) -> int:
|
|
116
|
+
"""
|
|
117
|
+
Estimate the on-disk size of one round's disk-backed dataset (see round_data.py): three
|
|
118
|
+
cadence arrays of shape (n_samples, num_observations, time_bins, width_bin_downsampled)
|
|
119
|
+
at `bytes_per_element` (4 for the float32 default, 2 under
|
|
120
|
+
training.round_array_dtype="float16") plus a tiny U20 labels array. Kept stdlib-only
|
|
121
|
+
(no numpy) so utils/print_cli_help.py can keep importing cli.py without the scientific
|
|
122
|
+
stack.
|
|
123
|
+
"""
|
|
124
|
+
per_sample_bytes = num_observations * time_bins * width_bin_downsampled * bytes_per_element
|
|
125
|
+
labels_bytes = n_samples * 20 * 4 # numpy "U20" = 20 UCS-4 code points per label
|
|
126
|
+
return 3 * n_samples * per_sample_bytes + labels_bytes
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _nearest_existing_ancestor(path: str) -> str:
|
|
130
|
+
"""Walk up from `path` to the closest directory that exists (for shutil.disk_usage on a
|
|
131
|
+
round-data dir that hasn't been created yet)."""
|
|
132
|
+
probe = os.path.abspath(path)
|
|
133
|
+
while probe and not os.path.exists(probe):
|
|
134
|
+
parent = os.path.dirname(probe)
|
|
135
|
+
if parent == probe:
|
|
136
|
+
break
|
|
137
|
+
probe = parent
|
|
138
|
+
return probe
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _resolve_num_replicas(args: argparse.Namespace) -> int | None:
|
|
142
|
+
"""Resolve the replica count to validate cross-replica constraints against, and
|
|
143
|
+
fail fast if an explicitly-requested count is unusable on this host.
|
|
144
|
+
|
|
145
|
+
Returns the count `collect_validation_errors` should check batch/sample divisibility
|
|
146
|
+
against, or None to skip those checks.
|
|
147
|
+
|
|
148
|
+
Resolution:
|
|
149
|
+
- If the user passed --num-replicas, or `config.gpu.num_replicas` is set on the
|
|
150
|
+
singleton, e.g. loaded from a saved JSON config via `apply_saved_config` (flag has
|
|
151
|
+
priority), that value must be >= 1 and <= the number of GPUs TF reports; either
|
|
152
|
+
violation raises ValueError so a bad count hard-stops *before* the divisibility
|
|
153
|
+
checks consume it as a divisor. Otherwise it is returned, so those checks run
|
|
154
|
+
against the same count the strategy will actually use.
|
|
155
|
+
- With no explicit request, the returned count is the number of GPUs TF reports.
|
|
156
|
+
- If TF is unavailable (e.g. a dev box) or reports zero GPUs, None is returned
|
|
157
|
+
regardless of whether a count was requested: nothing can be confirmed against the
|
|
158
|
+
hardware, so the divisibility checks are skipped and handling is deferred to
|
|
159
|
+
`setup_gpu_strategy` (which determines whether to fail on GPU-required runs, or to
|
|
160
|
+
proceed with a CPU-only runtime). Note, the >= 1 check still runs first, so a
|
|
161
|
+
nonsensical explicit request is rejected even here.
|
|
162
|
+
|
|
163
|
+
TF is imported lazily so utility scripts (which call `collect_validation_errors`
|
|
164
|
+
directly and never reach this) don't pay the init cost.
|
|
165
|
+
"""
|
|
166
|
+
config = get_config()
|
|
167
|
+
if config is None:
|
|
168
|
+
raise ValueError("get_config() returned None")
|
|
169
|
+
|
|
170
|
+
# Resolve the requested count and remember which source supplied it, so any failure
|
|
171
|
+
# below names the right one (the flag vs. the saved/programmatic config value).
|
|
172
|
+
if getattr(args, "num_replicas", None) is not None:
|
|
173
|
+
requested: int | None = int(args.num_replicas)
|
|
174
|
+
source = "--num-replicas"
|
|
175
|
+
elif config.gpu.num_replicas is not None:
|
|
176
|
+
requested = int(config.gpu.num_replicas)
|
|
177
|
+
source = "config.gpu.num_replicas"
|
|
178
|
+
else:
|
|
179
|
+
requested = None
|
|
180
|
+
source = None
|
|
181
|
+
|
|
182
|
+
# Positivity is checked first and without TF, so a nonsensical explicit request fails
|
|
183
|
+
# fast even on a no-GPU box.
|
|
184
|
+
if requested is not None and requested < 1:
|
|
185
|
+
raise ValueError(
|
|
186
|
+
f"{source} must be >= 1 (or unset to use all available GPUs), got {requested}"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Everything below needs the host's GPU count. If TF is unavailable or reports zero
|
|
190
|
+
# GPUs we can neither detect nor verify a count, so skip the cross-replica checks and
|
|
191
|
+
# defer to setup_gpu_strategy rather than guessing.
|
|
192
|
+
try:
|
|
193
|
+
import tensorflow as tf # noqa: PLC0415
|
|
194
|
+
|
|
195
|
+
gpus = tf.config.list_physical_devices("GPU")
|
|
196
|
+
except Exception:
|
|
197
|
+
return None
|
|
198
|
+
if not gpus:
|
|
199
|
+
return None
|
|
200
|
+
total = len(gpus)
|
|
201
|
+
|
|
202
|
+
# No explicit request: use every GPU TF reports.
|
|
203
|
+
if requested is None:
|
|
204
|
+
return total
|
|
205
|
+
|
|
206
|
+
# Explicit request: never allow more replicas than the host has GPUs — propagating
|
|
207
|
+
# batch/sample sizes validated against the wrong replica count would silently corrupt
|
|
208
|
+
# the run.
|
|
209
|
+
if requested > total:
|
|
210
|
+
raise ValueError(
|
|
211
|
+
f"{source}={requested} exceeds the number of GPUs available on this node "
|
|
212
|
+
f"({total}). Set it to <= {total}, or unset it to use all available GPUs."
|
|
213
|
+
)
|
|
214
|
+
return requested
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def setup_argument_parser() -> argparse.ArgumentParser:
|
|
218
|
+
"""Build the top-level Aetherscan argparse parser with `train` and `inference` subcommands."""
|
|
219
|
+
parser = argparse.ArgumentParser(
|
|
220
|
+
description="Aetherscan Pipeline -- Breakthrough Listen's first end-to-end production-grade DL pipeline for SETI @ scale"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Add commands. required=True: a subcommand is mandatory. Without it a bare
|
|
224
|
+
# `python -m aetherscan.main` leaves command=None, and main()'s resolve_save_tag(None, ...)
|
|
225
|
+
# would raise a ValueError traceback before the logger exists; argparse instead emits a clean
|
|
226
|
+
# usage error (caught by main()'s SystemExit handler, which prints full help).
|
|
227
|
+
subparsers = parser.add_subparsers(dest="command", required=True, help="Command to execute")
|
|
228
|
+
|
|
229
|
+
# Train command
|
|
230
|
+
_add_train_arguments(subparsers)
|
|
231
|
+
# Inference command
|
|
232
|
+
_add_inference_arguments(subparsers)
|
|
233
|
+
|
|
234
|
+
return parser
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _add_train_arguments(subparsers):
|
|
238
|
+
"""Register the `train` subcommand and populate it with all training-mode flags."""
|
|
239
|
+
train_parser = subparsers.add_parser("train", help="Execute training pipeline")
|
|
240
|
+
_add_train_flags_to(train_parser)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# TODO: improve flag help descriptions
|
|
244
|
+
def _add_reproducibility_flags_to(parser):
|
|
245
|
+
"""
|
|
246
|
+
Add the shared reproducibility flags (#279) — registered on BOTH subparsers via one
|
|
247
|
+
helper so the train and inference surfaces cannot drift.
|
|
248
|
+
"""
|
|
249
|
+
parser.add_argument(
|
|
250
|
+
"--seed",
|
|
251
|
+
type=int,
|
|
252
|
+
default=None,
|
|
253
|
+
help="Root random seed for reproducible runs: every random stream derives from it — data generation, dataset split/shuffles, TF weight init, the VAE sampling layer (training AND inference), the random forest, UMAP/KMeans plot fits, and plot subsampling. Defaults to a concrete value (reproducible out of the box); must be >= 0. To run unseeded, pass --unseeded",
|
|
254
|
+
)
|
|
255
|
+
parser.add_argument(
|
|
256
|
+
"--unseeded",
|
|
257
|
+
action="store_true",
|
|
258
|
+
default=False,
|
|
259
|
+
help="Opt OUT of the seeded default: draw every random stream from OS entropy (non-reproducible). Mutually exclusive with --seed",
|
|
260
|
+
)
|
|
261
|
+
parser.add_argument(
|
|
262
|
+
"--tf-deterministic-ops",
|
|
263
|
+
action=argparse.BooleanOptionalAction,
|
|
264
|
+
default=None,
|
|
265
|
+
help="Force deterministic TensorFlow/cuDNN op implementations (tf.config.experimental.enable_op_determinism) for bit-exact GPU reproducibility at some speed cost. Default: enabled — without it, cuDNN autotune noise can flip near-threshold candidates between identical runs; opt out with --no-tf-deterministic-ops",
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _add_runtime_flags_to(parser):
|
|
270
|
+
"""
|
|
271
|
+
Add the shared host-runtime flags — registered on BOTH subparsers via one helper so
|
|
272
|
+
the train and inference surfaces cannot drift (#303).
|
|
273
|
+
"""
|
|
274
|
+
parser.add_argument(
|
|
275
|
+
"--n-processes",
|
|
276
|
+
type=int,
|
|
277
|
+
default=None,
|
|
278
|
+
help="Worker-process count for the multiprocessing pools (energy detection + stamp extraction at inference; data generation at training). Default: all cores. Host tuning: never layered from a saved --config-path, so a config recorded on a bigger host cannot oversubscribe this one (must be >= 1)",
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _add_train_flags_to(parser):
|
|
283
|
+
"""
|
|
284
|
+
Add all training-mode CLI flags to `parser`. Defined separately from the subparser wrapper
|
|
285
|
+
so that utility scripts (e.g. utils/find_optimal_configs.py) can expose the same flag
|
|
286
|
+
surface without re-declaring every argument.
|
|
287
|
+
"""
|
|
288
|
+
# Shared reproducibility + host-runtime flags — one helper each, both subparsers
|
|
289
|
+
_add_reproducibility_flags_to(parser)
|
|
290
|
+
_add_runtime_flags_to(parser)
|
|
291
|
+
|
|
292
|
+
# Path arguments (overrides environment variables)
|
|
293
|
+
parser.add_argument(
|
|
294
|
+
"--data-path",
|
|
295
|
+
type=str,
|
|
296
|
+
default=None,
|
|
297
|
+
help="Path to data directory (overrides AETHERSCAN_DATA_PATH environment variable)",
|
|
298
|
+
)
|
|
299
|
+
parser.add_argument(
|
|
300
|
+
"--model-path",
|
|
301
|
+
type=str,
|
|
302
|
+
default=None,
|
|
303
|
+
help="Path to model directory (overrides AETHERSCAN_MODEL_PATH environment variable)",
|
|
304
|
+
)
|
|
305
|
+
parser.add_argument(
|
|
306
|
+
"--output-path",
|
|
307
|
+
type=str,
|
|
308
|
+
default=None,
|
|
309
|
+
help="Path to output directory (overrides AETHERSCAN_OUTPUT_PATH environment variable)",
|
|
310
|
+
)
|
|
311
|
+
parser.add_argument(
|
|
312
|
+
"--dashboard",
|
|
313
|
+
action=argparse.BooleanOptionalAction,
|
|
314
|
+
default=None,
|
|
315
|
+
help="Auto-launch the live monitoring Streamlit dashboard for this run; SSH-forward the port to view it (default: on). Use --no-dashboard to disable",
|
|
316
|
+
)
|
|
317
|
+
parser.add_argument(
|
|
318
|
+
"--dashboard-port",
|
|
319
|
+
type=int,
|
|
320
|
+
default=None,
|
|
321
|
+
help="Port for the auto-launched live dashboard (default: 8501)",
|
|
322
|
+
)
|
|
323
|
+
parser.add_argument(
|
|
324
|
+
"--benchmark-report",
|
|
325
|
+
action=argparse.BooleanOptionalAction,
|
|
326
|
+
default=None,
|
|
327
|
+
help="Render the end-of-run benchmark report (stage timeline + bottleneck suggestions) and post it to Slack (default: on). Use --no-benchmark-report to disable",
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# BetaVAE model configuration
|
|
331
|
+
parser.add_argument(
|
|
332
|
+
"--vae-latent-dim",
|
|
333
|
+
type=int,
|
|
334
|
+
default=None,
|
|
335
|
+
help="Dimensionality of the VAE latent space (bottleneck size)",
|
|
336
|
+
)
|
|
337
|
+
parser.add_argument(
|
|
338
|
+
"--vae-dense-layer-size",
|
|
339
|
+
type=int,
|
|
340
|
+
default=None,
|
|
341
|
+
help="Size of dense layer in VAE architecture (should match frequency bins after downsampling)",
|
|
342
|
+
)
|
|
343
|
+
parser.add_argument(
|
|
344
|
+
"--vae-kernel-size",
|
|
345
|
+
type=int,
|
|
346
|
+
nargs=2,
|
|
347
|
+
default=None,
|
|
348
|
+
help="Kernel size for Conv2D layers as two integers (e.g., --vae-kernel-size 3 3)",
|
|
349
|
+
)
|
|
350
|
+
parser.add_argument(
|
|
351
|
+
"--vae-beta",
|
|
352
|
+
type=float,
|
|
353
|
+
default=None,
|
|
354
|
+
help="Beta coefficient for KL divergence loss term in beta-VAE (controls disentanglement)",
|
|
355
|
+
)
|
|
356
|
+
parser.add_argument(
|
|
357
|
+
"--vae-alpha",
|
|
358
|
+
type=float,
|
|
359
|
+
default=None,
|
|
360
|
+
help="Alpha coefficient for clustering loss term in VAE (controls cluster separation)",
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# Random Forest configuration
|
|
364
|
+
parser.add_argument(
|
|
365
|
+
"--rf-n-estimators",
|
|
366
|
+
type=int,
|
|
367
|
+
default=None,
|
|
368
|
+
help="Number of decision trees in the random forest ensemble",
|
|
369
|
+
)
|
|
370
|
+
parser.add_argument(
|
|
371
|
+
"--rf-bootstrap",
|
|
372
|
+
type=lambda x: x.lower() in ("true", "1", "yes"),
|
|
373
|
+
default=None,
|
|
374
|
+
help="Whether to use bootstrap sampling when building trees (enables bagging)",
|
|
375
|
+
)
|
|
376
|
+
parser.add_argument(
|
|
377
|
+
"--rf-max-features",
|
|
378
|
+
type=str,
|
|
379
|
+
default=None,
|
|
380
|
+
help="Number of features to consider for splits: 'sqrt', 'log2', or a float (fraction of features)",
|
|
381
|
+
)
|
|
382
|
+
parser.add_argument(
|
|
383
|
+
"--rf-n-jobs",
|
|
384
|
+
type=int,
|
|
385
|
+
default=None,
|
|
386
|
+
help="Number of parallel jobs for random forest training (-1 uses all CPU cores)",
|
|
387
|
+
)
|
|
388
|
+
parser.add_argument(
|
|
389
|
+
"--rf-seed",
|
|
390
|
+
type=int,
|
|
391
|
+
default=None,
|
|
392
|
+
help="DEPRECATED: explicit random forest seed override. The RF seed now derives from the root --seed (#279); this alias remains for existing scripts and logs a deprecation warning when used.",
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
# GPU configuration
|
|
396
|
+
parser.add_argument(
|
|
397
|
+
"--num-replicas",
|
|
398
|
+
type=int,
|
|
399
|
+
default=None,
|
|
400
|
+
help="Number of GPUs to use for the distributed strategy. If omitted, the strategy uses every GPU visible to TF; otherwise it is restricted to the first N physical GPUs and the rest are left untouched. Must be >= 1 and <= the number of physical GPUs on your machine.",
|
|
401
|
+
)
|
|
402
|
+
parser.add_argument(
|
|
403
|
+
"--gpu-memory-limit-mb",
|
|
404
|
+
type=int,
|
|
405
|
+
default=None,
|
|
406
|
+
help="Per-GPU memory cap in MiB. Omit to use memory-growth-only (recommended on Blackwell). Set for TF to allocate a fixed logical device of a given size per physical GPU (e.g. 14000)",
|
|
407
|
+
)
|
|
408
|
+
parser.add_argument(
|
|
409
|
+
"--nccl-num-packs",
|
|
410
|
+
type=int,
|
|
411
|
+
default=None,
|
|
412
|
+
help="num_packs for NCCL/HierarchicalCopy all-reduce. Lower values (e.g. 1) reduces tiny-tensor latency; higher values (e.g. >=4) can help bandwidth on >4-GPU topologies.",
|
|
413
|
+
)
|
|
414
|
+
parser.add_argument(
|
|
415
|
+
"--async-allocator",
|
|
416
|
+
action=argparse.BooleanOptionalAction,
|
|
417
|
+
default=None,
|
|
418
|
+
help="Toggle TF_GPU_ALLOCATOR=cuda_malloc_async (default: enabled). Pass --no-async-allocator as a workaround for NGC 25.02 multi-GPU OOM bugs.",
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
# Data configuration
|
|
422
|
+
parser.add_argument(
|
|
423
|
+
"--num-observations",
|
|
424
|
+
type=int,
|
|
425
|
+
default=None,
|
|
426
|
+
help="Number of observations per cadence snippet (e.g., 6 for 3 ON + 3 OFF)",
|
|
427
|
+
)
|
|
428
|
+
parser.add_argument(
|
|
429
|
+
"--width-bin",
|
|
430
|
+
type=int,
|
|
431
|
+
default=None,
|
|
432
|
+
help="Number of frequency bins per observation (spectral resolution)",
|
|
433
|
+
)
|
|
434
|
+
parser.add_argument(
|
|
435
|
+
"--downsample-factor",
|
|
436
|
+
type=int,
|
|
437
|
+
default=None,
|
|
438
|
+
help="Downsampling factor for frequency bins (reduces spectral dimension)",
|
|
439
|
+
)
|
|
440
|
+
parser.add_argument(
|
|
441
|
+
"--time-bins",
|
|
442
|
+
type=int,
|
|
443
|
+
default=None,
|
|
444
|
+
help="Number of time bins per observation (temporal resolution)",
|
|
445
|
+
)
|
|
446
|
+
parser.add_argument(
|
|
447
|
+
"--freq-resolution",
|
|
448
|
+
type=float,
|
|
449
|
+
default=None,
|
|
450
|
+
help="Frequency resolution in Hz (determined by instrument)",
|
|
451
|
+
)
|
|
452
|
+
parser.add_argument(
|
|
453
|
+
"--time-resolution",
|
|
454
|
+
type=float,
|
|
455
|
+
default=None,
|
|
456
|
+
help="Time resolution in seconds (determined by instrument)",
|
|
457
|
+
)
|
|
458
|
+
parser.add_argument(
|
|
459
|
+
"--num-target-backgrounds",
|
|
460
|
+
type=int,
|
|
461
|
+
default=None,
|
|
462
|
+
help="Number of background (noise-only) cadences to load for training data generation",
|
|
463
|
+
)
|
|
464
|
+
parser.add_argument(
|
|
465
|
+
"--background-load-chunk-size",
|
|
466
|
+
type=int,
|
|
467
|
+
default=None,
|
|
468
|
+
help="Maximum number of background cadences to process at once during loading (memory management)",
|
|
469
|
+
)
|
|
470
|
+
parser.add_argument(
|
|
471
|
+
"--max-chunks-per-file",
|
|
472
|
+
type=int,
|
|
473
|
+
default=None,
|
|
474
|
+
help="Maximum number of chunks to load from a single data file (limits per-file contribution)",
|
|
475
|
+
)
|
|
476
|
+
parser.add_argument(
|
|
477
|
+
"--train-files",
|
|
478
|
+
type=str,
|
|
479
|
+
nargs="+",
|
|
480
|
+
default=None,
|
|
481
|
+
help="Space-separated list of training data file names (e.g., real_filtered_LARGE_HIP110750.npy)",
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
# Training configuration
|
|
485
|
+
parser.add_argument(
|
|
486
|
+
"--num-training-rounds",
|
|
487
|
+
type=int,
|
|
488
|
+
default=None,
|
|
489
|
+
help="Total number of training rounds in curriculum learning schedule",
|
|
490
|
+
)
|
|
491
|
+
parser.add_argument(
|
|
492
|
+
"--epochs-per-round",
|
|
493
|
+
type=int,
|
|
494
|
+
default=None,
|
|
495
|
+
help="Number of epochs to train the VAE per curriculum learning round",
|
|
496
|
+
)
|
|
497
|
+
parser.add_argument(
|
|
498
|
+
"--num-samples-beta-vae",
|
|
499
|
+
type=int,
|
|
500
|
+
default=None,
|
|
501
|
+
# NOTE: divisible by 4 or num_replicas?
|
|
502
|
+
help="Number of training samples to generate for beta-VAE per round (must be divisible by 4)",
|
|
503
|
+
)
|
|
504
|
+
parser.add_argument(
|
|
505
|
+
"--num-samples-rf",
|
|
506
|
+
type=int,
|
|
507
|
+
default=None,
|
|
508
|
+
# NOTE: divisible by 4 or num_replicas?
|
|
509
|
+
help="Number of training samples to generate for random forest (must be divisible by 4)",
|
|
510
|
+
)
|
|
511
|
+
parser.add_argument(
|
|
512
|
+
"--train-val-split",
|
|
513
|
+
type=float,
|
|
514
|
+
default=None,
|
|
515
|
+
help="Fraction of data to use for training vs validation (e.g., 0.8 = 80%% train, 20%% val)",
|
|
516
|
+
)
|
|
517
|
+
parser.add_argument(
|
|
518
|
+
"--per-replica-batch-size",
|
|
519
|
+
type=int,
|
|
520
|
+
default=None,
|
|
521
|
+
help="Batch size per GPU/device replica during training",
|
|
522
|
+
)
|
|
523
|
+
parser.add_argument(
|
|
524
|
+
"--effective-batch-size",
|
|
525
|
+
type=int,
|
|
526
|
+
default=None,
|
|
527
|
+
help="Effective batch size for gradient accumulation across all replicas",
|
|
528
|
+
)
|
|
529
|
+
parser.add_argument(
|
|
530
|
+
"--per-replica-val-batch-size",
|
|
531
|
+
type=int,
|
|
532
|
+
default=None,
|
|
533
|
+
help="Batch size per GPU/device replica during validation",
|
|
534
|
+
)
|
|
535
|
+
parser.add_argument(
|
|
536
|
+
"--signal-injection-chunk-size",
|
|
537
|
+
type=int,
|
|
538
|
+
default=None,
|
|
539
|
+
# NOTE: divisible by 4 or num_replicas?
|
|
540
|
+
help="Maximum cadences to process at once during synthetic signal injection (must be divisible by 4)",
|
|
541
|
+
)
|
|
542
|
+
parser.add_argument(
|
|
543
|
+
"--data-gen-task-size",
|
|
544
|
+
type=int,
|
|
545
|
+
default=None,
|
|
546
|
+
help="Cadences per batched signal-injection worker task (workers write results straight into the round's on-disk memmap; must be >= 1)",
|
|
547
|
+
)
|
|
548
|
+
parser.add_argument(
|
|
549
|
+
"--round-data-dir",
|
|
550
|
+
type=str,
|
|
551
|
+
default=None,
|
|
552
|
+
help="Directory for disk-backed per-round training datasets (defaults to <data-path>/training/round_data; needs ~2.2x one round's size free when data-generation overlap is enabled, ~1.1x otherwise)",
|
|
553
|
+
)
|
|
554
|
+
parser.add_argument(
|
|
555
|
+
"--overlap-data-generation",
|
|
556
|
+
action=argparse.BooleanOptionalAction,
|
|
557
|
+
default=None,
|
|
558
|
+
help="Generate round k+1's training data in a background producer process while round k trains (default: enabled). Pass --no-overlap-data-generation to fall back to sequential in-process generation for debugging",
|
|
559
|
+
)
|
|
560
|
+
parser.add_argument(
|
|
561
|
+
"--keep-round-data",
|
|
562
|
+
action=argparse.BooleanOptionalAction,
|
|
563
|
+
default=None,
|
|
564
|
+
help="Retain each round's on-disk training data after that round finishes (default: disabled — round k's data directory is deleted as soon as round k's training completes). Enable for debugging",
|
|
565
|
+
)
|
|
566
|
+
parser.add_argument(
|
|
567
|
+
"--plot-injection-subsampling-count",
|
|
568
|
+
type=int,
|
|
569
|
+
default=None,
|
|
570
|
+
help="Max points per stat name, per signal type, for A→B intensity bias scatter plots. Outliers are prioritized, with the difference made up from randomly sampling without replacement the remaining points",
|
|
571
|
+
)
|
|
572
|
+
parser.add_argument(
|
|
573
|
+
"--plot-injection-outlier-percentile",
|
|
574
|
+
type=float,
|
|
575
|
+
default=None,
|
|
576
|
+
help="Threshold for points to always be included in A→B intensity bias scatter plots",
|
|
577
|
+
)
|
|
578
|
+
parser.add_argument(
|
|
579
|
+
"--latent-viz-num-cadences-per-type",
|
|
580
|
+
type=int,
|
|
581
|
+
default=None,
|
|
582
|
+
help="Number of cadences per signal type for latent space visualization batch (total points = 4× this value × 6 observations per cadence)",
|
|
583
|
+
)
|
|
584
|
+
parser.add_argument(
|
|
585
|
+
"--latent-viz-step-interval",
|
|
586
|
+
type=int,
|
|
587
|
+
default=None,
|
|
588
|
+
help="Capture a latent space snapshot every N training steps (lower = more snapshots, more DB writes, and larger storage costs)",
|
|
589
|
+
)
|
|
590
|
+
parser.add_argument(
|
|
591
|
+
"--latent-viz-umap-fit-max-samples",
|
|
592
|
+
type=int,
|
|
593
|
+
default=None,
|
|
594
|
+
help="Maximum number of pooled latent vectors used to fit the UMAP model (remaining vectors are projected via transform; lower = faster, higher = more faithful embedding)",
|
|
595
|
+
)
|
|
596
|
+
parser.add_argument(
|
|
597
|
+
"--latent-viz-umap-n-neighbors",
|
|
598
|
+
type=int,
|
|
599
|
+
nargs="+",
|
|
600
|
+
default=None,
|
|
601
|
+
help="UMAP n_neighbors values to sweep for latent space visualization (e.g., --latent-viz-umap-n-neighbors 5 15 30 50)",
|
|
602
|
+
)
|
|
603
|
+
parser.add_argument(
|
|
604
|
+
"--latent-viz-umap-min-dist",
|
|
605
|
+
type=float,
|
|
606
|
+
nargs="+",
|
|
607
|
+
default=None,
|
|
608
|
+
help="UMAP min_dist values to sweep for latent space visualization (e.g., --latent-viz-umap-min-dist 0.0 0.1 0.5)",
|
|
609
|
+
)
|
|
610
|
+
parser.add_argument(
|
|
611
|
+
"--latent-viz-gif-max-frames",
|
|
612
|
+
type=int,
|
|
613
|
+
default=None,
|
|
614
|
+
help="Maximum number of frames in latent space GIF output (snapshots beyond this limit are log-subsampled, prioritizing earlier training steps)",
|
|
615
|
+
)
|
|
616
|
+
parser.add_argument(
|
|
617
|
+
"--latent-viz-gif-duration-ms",
|
|
618
|
+
type=int,
|
|
619
|
+
default=None,
|
|
620
|
+
help="Milliseconds per frame in latent space GIF output",
|
|
621
|
+
)
|
|
622
|
+
parser.add_argument(
|
|
623
|
+
"--latent-traversal-every-round",
|
|
624
|
+
action=argparse.BooleanOptionalAction,
|
|
625
|
+
default=None,
|
|
626
|
+
help="Render latent-dimension traversal figures at the end of every training round, in addition to the end-of-training set (default: disabled)",
|
|
627
|
+
)
|
|
628
|
+
parser.add_argument(
|
|
629
|
+
"--latent-traversal-num-steps",
|
|
630
|
+
type=int,
|
|
631
|
+
default=None,
|
|
632
|
+
help="Number of traversal steps per latent dimension (must be odd and >= 3 so the center column is the unperturbed class-mean decode)",
|
|
633
|
+
)
|
|
634
|
+
parser.add_argument(
|
|
635
|
+
"--latent-traversal-max-sigma",
|
|
636
|
+
type=float,
|
|
637
|
+
default=None,
|
|
638
|
+
help="Latent traversal range in per-dimension standard deviations: steps span [-max_sigma, +max_sigma] (must be > 0)",
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
parser.add_argument(
|
|
642
|
+
"--snr-base",
|
|
643
|
+
type=int,
|
|
644
|
+
default=None,
|
|
645
|
+
help="Base signal-to-noise ratio for curriculum learning (minimum SNR difficulty level)",
|
|
646
|
+
)
|
|
647
|
+
parser.add_argument(
|
|
648
|
+
"--initial-snr-range",
|
|
649
|
+
type=int,
|
|
650
|
+
default=None,
|
|
651
|
+
help="SNR range for initial (easiest) training rounds (signals sampled from snr_base to snr_base + initial_snr_range)",
|
|
652
|
+
)
|
|
653
|
+
parser.add_argument(
|
|
654
|
+
"--final-snr-range",
|
|
655
|
+
type=int,
|
|
656
|
+
default=None,
|
|
657
|
+
help="SNR range for final (hardest) training rounds (signals sampled from snr_base to snr_base + final_snr_range). Ignored if only training for 1 round",
|
|
658
|
+
)
|
|
659
|
+
parser.add_argument(
|
|
660
|
+
"--curriculum-schedule",
|
|
661
|
+
type=str,
|
|
662
|
+
default=None,
|
|
663
|
+
help="Curriculum difficulty progression schedule: 'linear', 'exponential', or 'step'",
|
|
664
|
+
)
|
|
665
|
+
parser.add_argument(
|
|
666
|
+
"--exponential-decay-rate",
|
|
667
|
+
type=float,
|
|
668
|
+
default=None,
|
|
669
|
+
help="Decay rate for exponential curriculum schedule (must be negative; more negative = faster difficulty increase)",
|
|
670
|
+
)
|
|
671
|
+
parser.add_argument(
|
|
672
|
+
"--step-easy-rounds",
|
|
673
|
+
type=int,
|
|
674
|
+
default=None,
|
|
675
|
+
help="Number of rounds with easy signals when using step curriculum schedule",
|
|
676
|
+
)
|
|
677
|
+
parser.add_argument(
|
|
678
|
+
"--step-hard-rounds",
|
|
679
|
+
type=int,
|
|
680
|
+
default=None,
|
|
681
|
+
help="Number of rounds with hard signals when using step curriculum schedule",
|
|
682
|
+
)
|
|
683
|
+
parser.add_argument(
|
|
684
|
+
"--base-learning-rate",
|
|
685
|
+
type=float,
|
|
686
|
+
default=None,
|
|
687
|
+
help="Initial learning rate for Adam optimizer",
|
|
688
|
+
)
|
|
689
|
+
parser.add_argument(
|
|
690
|
+
"--min-learning-rate",
|
|
691
|
+
type=float,
|
|
692
|
+
default=None,
|
|
693
|
+
help="Learning rate floor for adaptive learning rate reduction",
|
|
694
|
+
)
|
|
695
|
+
parser.add_argument(
|
|
696
|
+
"--min-pct-improvement",
|
|
697
|
+
type=float,
|
|
698
|
+
default=None,
|
|
699
|
+
help="Minimum fractional validation loss improvement to avoid LR reduction (e.g., 0.001 = 0.1%%)",
|
|
700
|
+
)
|
|
701
|
+
parser.add_argument(
|
|
702
|
+
"--patience-threshold",
|
|
703
|
+
type=int,
|
|
704
|
+
default=None,
|
|
705
|
+
help="Number of consecutive epochs without minimum improvement before reducing learning rate",
|
|
706
|
+
)
|
|
707
|
+
parser.add_argument(
|
|
708
|
+
"--lr-reduction-factor",
|
|
709
|
+
type=float,
|
|
710
|
+
default=None,
|
|
711
|
+
help="Multiplicative factor for learning rate reduction (e.g., 0.2 reduces LR by 20%%)",
|
|
712
|
+
)
|
|
713
|
+
parser.add_argument(
|
|
714
|
+
"--max-retries",
|
|
715
|
+
type=int,
|
|
716
|
+
default=None,
|
|
717
|
+
help="Maximum number of retry attempts when training fails due to errors",
|
|
718
|
+
)
|
|
719
|
+
parser.add_argument(
|
|
720
|
+
"--retry-delay",
|
|
721
|
+
type=int,
|
|
722
|
+
default=None,
|
|
723
|
+
help="Delay in seconds between retry attempts after training failure",
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
# HuggingFace Hub configuration
|
|
727
|
+
parser.add_argument(
|
|
728
|
+
"--hf-upload",
|
|
729
|
+
action=argparse.BooleanOptionalAction,
|
|
730
|
+
default=None,
|
|
731
|
+
help="Upload the final model artifacts (encoder, decoder, random forest, config) plus a generated model card to the HuggingFace Hub after training completes, tagging the commit with --save-tag (default: disabled = local-only). Requires HF_TOKEN in the environment (via .env)",
|
|
732
|
+
)
|
|
733
|
+
parser.add_argument(
|
|
734
|
+
"--hf-repo-id",
|
|
735
|
+
type=str,
|
|
736
|
+
default=None,
|
|
737
|
+
help="HuggingFace model repo id (namespace/name) for weight upload/download (default: zachtheyek/aetherscan)",
|
|
738
|
+
)
|
|
739
|
+
|
|
740
|
+
# Checkpoint configuration
|
|
741
|
+
parser.add_argument(
|
|
742
|
+
"--load-dir",
|
|
743
|
+
type=str,
|
|
744
|
+
default=None,
|
|
745
|
+
help="Subdirectory for checkpoint loading (relative to --model-path)",
|
|
746
|
+
)
|
|
747
|
+
parser.add_argument(
|
|
748
|
+
"--load-tag",
|
|
749
|
+
type=str,
|
|
750
|
+
default=None,
|
|
751
|
+
help="Checkpoint to load. A full run tag ({command}_YYYYMMDD_HHMMSS) resumes that run in place (its tag is adopted, so the resumed attempt writes under the same run). round_XX (requires --load-dir checkpoints) seeds a fresh run from that per-round checkpoint, resuming from round XX+1 unless --start-round is given.",
|
|
752
|
+
)
|
|
753
|
+
parser.add_argument(
|
|
754
|
+
"--start-round",
|
|
755
|
+
type=int,
|
|
756
|
+
default=None,
|
|
757
|
+
help="Round to begin/resume training from",
|
|
758
|
+
)
|
|
759
|
+
parser.add_argument(
|
|
760
|
+
"--save-tag",
|
|
761
|
+
type=str,
|
|
762
|
+
default=None,
|
|
763
|
+
help="Run label prefix: one of test, train, inf, bench. The datetime is appended automatically at runtime (e.g. train_20260101_120000). Defaults to the subcommand (train->train, inference->inf) if omitted.",
|
|
764
|
+
)
|
|
765
|
+
parser.add_argument(
|
|
766
|
+
"--force-tag",
|
|
767
|
+
action=argparse.BooleanOptionalAction,
|
|
768
|
+
default=None,
|
|
769
|
+
help="Override the fail-early save-tag collision guard: proceed even when an explicitly-provided --save-tag matches existing artifacts, DB rows, or (with --hf-upload) an existing HuggingFace tag (default: disabled)",
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _add_inference_arguments(subparsers):
|
|
774
|
+
"""Register the `inference` subcommand and populate it with all inference-mode flags."""
|
|
775
|
+
inf_parser = subparsers.add_parser("inference", help="Execute inference pipeline")
|
|
776
|
+
_add_inference_flags_to(inf_parser)
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
# TODO: improve flag help descriptions
|
|
780
|
+
def _add_inference_flags_to(parser):
|
|
781
|
+
"""
|
|
782
|
+
Add all inference-mode CLI flags to `parser`. Defined separately from the subparser wrapper
|
|
783
|
+
so that utility scripts (e.g. utils/find_optimal_configs.py) can expose the same flag
|
|
784
|
+
surface without re-declaring every argument.
|
|
785
|
+
"""
|
|
786
|
+
# Shared reproducibility + host-runtime flags — one helper each, both subparsers
|
|
787
|
+
_add_reproducibility_flags_to(parser)
|
|
788
|
+
_add_runtime_flags_to(parser)
|
|
789
|
+
|
|
790
|
+
# Path arguments
|
|
791
|
+
parser.add_argument(
|
|
792
|
+
"--data-path",
|
|
793
|
+
type=str,
|
|
794
|
+
default=None,
|
|
795
|
+
help="Path to data directory (overrides AETHERSCAN_DATA_PATH environment variable)",
|
|
796
|
+
)
|
|
797
|
+
parser.add_argument(
|
|
798
|
+
"--model-path",
|
|
799
|
+
type=str,
|
|
800
|
+
default=None,
|
|
801
|
+
help="Path to model directory (overrides AETHERSCAN_MODEL_PATH environment variable)",
|
|
802
|
+
)
|
|
803
|
+
parser.add_argument(
|
|
804
|
+
"--output-path",
|
|
805
|
+
type=str,
|
|
806
|
+
default=None,
|
|
807
|
+
help="Path to output directory (overrides AETHERSCAN_OUTPUT_PATH environment variable)",
|
|
808
|
+
)
|
|
809
|
+
parser.add_argument(
|
|
810
|
+
"--dashboard",
|
|
811
|
+
action=argparse.BooleanOptionalAction,
|
|
812
|
+
default=None,
|
|
813
|
+
help="Auto-launch the live monitoring Streamlit dashboard for this run; SSH-forward the port to view it (default: on). Use --no-dashboard to disable",
|
|
814
|
+
)
|
|
815
|
+
parser.add_argument(
|
|
816
|
+
"--dashboard-port",
|
|
817
|
+
type=int,
|
|
818
|
+
default=None,
|
|
819
|
+
help="Port for the auto-launched live dashboard (default: 8501)",
|
|
820
|
+
)
|
|
821
|
+
parser.add_argument(
|
|
822
|
+
"--benchmark-report",
|
|
823
|
+
action=argparse.BooleanOptionalAction,
|
|
824
|
+
default=None,
|
|
825
|
+
help="Render the end-of-run benchmark report (stage timeline + bottleneck suggestions) and post it to Slack (default: on). Use --no-benchmark-report to disable",
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
# GPU configuration
|
|
829
|
+
parser.add_argument(
|
|
830
|
+
"--num-replicas",
|
|
831
|
+
type=int,
|
|
832
|
+
default=None,
|
|
833
|
+
help="Number of GPUs to use for the distributed strategy. If omitted, the strategy uses every GPU visible to TF; otherwise it is restricted to the first N physical GPUs and the rest are left untouched. Must be >= 1 and <= the number of physical GPUs on your machine.",
|
|
834
|
+
)
|
|
835
|
+
parser.add_argument(
|
|
836
|
+
"--gpu-memory-limit-mb",
|
|
837
|
+
type=int,
|
|
838
|
+
default=None,
|
|
839
|
+
help="Per-GPU memory cap in MiB. Omit to use memory-growth-only (recommended on Blackwell). Set for TF to allocate a fixed logical device of a given size per physical GPU (e.g. 14000)",
|
|
840
|
+
)
|
|
841
|
+
parser.add_argument(
|
|
842
|
+
"--async-allocator",
|
|
843
|
+
action=argparse.BooleanOptionalAction,
|
|
844
|
+
default=None,
|
|
845
|
+
help="Toggle TF_GPU_ALLOCATOR=cuda_malloc_async (default: enabled). Pass --no-async-allocator as a workaround for NGC 25.02 multi-GPU OOM bugs.",
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
# Data configuration
|
|
849
|
+
parser.add_argument(
|
|
850
|
+
"--test-files",
|
|
851
|
+
type=str,
|
|
852
|
+
nargs="+",
|
|
853
|
+
default=None,
|
|
854
|
+
help="Space-separated list of testing data file names (e.g., real_filtered_LARGE_test_HIP15638.npy)",
|
|
855
|
+
)
|
|
856
|
+
parser.add_argument(
|
|
857
|
+
"--inference-files",
|
|
858
|
+
type=str,
|
|
859
|
+
nargs="+",
|
|
860
|
+
default=None,
|
|
861
|
+
help="Space-separated list of inference catalog file names (e.g. complete_cadences_catalog.csv). Expects .h5 filepaths to individual observations, and sufficient metadata for recovering cadence groupings. If provided, triggers the energy detection preprocessing pipeline and takes precedence over --test-files",
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
# Inference configuration
|
|
865
|
+
parser.add_argument(
|
|
866
|
+
"--encoder-path",
|
|
867
|
+
type=str,
|
|
868
|
+
default=None,
|
|
869
|
+
help="Path to trained VAE encoder model file (.keras). Optional: when none of --encoder-path/--rf-path/--config-path are given, the artifacts are downloaded from the HuggingFace Hub (see --hf-repo-id/--hf-revision); provide either all three local paths or none",
|
|
870
|
+
)
|
|
871
|
+
parser.add_argument(
|
|
872
|
+
"--rf-path",
|
|
873
|
+
type=str,
|
|
874
|
+
default=None,
|
|
875
|
+
help="Path to trained Random Forest model file (.joblib). Optional: see --encoder-path for the all-three-or-none rule",
|
|
876
|
+
)
|
|
877
|
+
parser.add_argument(
|
|
878
|
+
"--config-path",
|
|
879
|
+
type=str,
|
|
880
|
+
default=None,
|
|
881
|
+
help="Path to config file from corresponding training run (.json). Optional: see --encoder-path for the all-three-or-none rule",
|
|
882
|
+
)
|
|
883
|
+
parser.add_argument(
|
|
884
|
+
"--per-replica-batch-size",
|
|
885
|
+
type=int,
|
|
886
|
+
default=None,
|
|
887
|
+
help="Batch size per GPU/device replica during inference",
|
|
888
|
+
)
|
|
889
|
+
parser.add_argument(
|
|
890
|
+
"--classification-threshold",
|
|
891
|
+
type=float,
|
|
892
|
+
default=None,
|
|
893
|
+
help="Science threshold for candidate detection, applied to the pass-2 MC mean probability (the two-pass cascade's final score)",
|
|
894
|
+
)
|
|
895
|
+
parser.add_argument(
|
|
896
|
+
"--screening-threshold",
|
|
897
|
+
type=float,
|
|
898
|
+
default=None,
|
|
899
|
+
help="Permissive pass-1 screening threshold of the two-pass cascade (tuned for recall; must not exceed --classification-threshold). Snippets below it are rejected without MC scoring",
|
|
900
|
+
)
|
|
901
|
+
parser.add_argument(
|
|
902
|
+
"--mc-draws",
|
|
903
|
+
type=int,
|
|
904
|
+
default=None,
|
|
905
|
+
help="Seeded Monte-Carlo latent draws per pass-2 survivor (mean carries the science threshold; std is the reported uncertainty spread)",
|
|
906
|
+
)
|
|
907
|
+
parser.add_argument(
|
|
908
|
+
"--reference-cloud-size",
|
|
909
|
+
type=int,
|
|
910
|
+
default=None,
|
|
911
|
+
help="Size of the seeded uniform reservoir of pass-1 rejects MC-scored as the candidate uncertainty plot's survey background (0 disables)",
|
|
912
|
+
)
|
|
913
|
+
parser.add_argument(
|
|
914
|
+
"--prefetch-depth",
|
|
915
|
+
type=int,
|
|
916
|
+
default=None,
|
|
917
|
+
help="Cadences preprocessed+loaded ahead of the GPU stage in the streaming loop (>= 1). Each unit of depth overlaps energy-detection reads with stamp extraction and the serial per-cadence sections, costing one in-flight cadence of RAM (up to ~65 GB for RFI-dense C-band cadences); outputs are identical at any depth (default: 3 per the on-cluster A/B)",
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
# Energy detection preprocessing
|
|
921
|
+
parser.add_argument(
|
|
922
|
+
"--cadence-group-by-cols",
|
|
923
|
+
type=str,
|
|
924
|
+
nargs="+",
|
|
925
|
+
default=None,
|
|
926
|
+
help="Space-separated list of CSV column names whose joint value defines cadence membership (e.g., Target Session Band 'Cadence ID' Frequency)",
|
|
927
|
+
)
|
|
928
|
+
parser.add_argument(
|
|
929
|
+
"--cadence-h5-path-col",
|
|
930
|
+
type=str,
|
|
931
|
+
default=None,
|
|
932
|
+
help="CSV column containing the .h5 file path for each observation (default: '.h5 path')",
|
|
933
|
+
)
|
|
934
|
+
parser.add_argument(
|
|
935
|
+
"--cadence-expected-obs",
|
|
936
|
+
type=int,
|
|
937
|
+
default=None,
|
|
938
|
+
help="Required number of observations per cadence (default: 6 for ABACAD)",
|
|
939
|
+
)
|
|
940
|
+
parser.add_argument(
|
|
941
|
+
"--coarse-channel-width",
|
|
942
|
+
type=int,
|
|
943
|
+
default=None,
|
|
944
|
+
help="Number of fine channels per coarse channel (default: 1048576)",
|
|
945
|
+
)
|
|
946
|
+
parser.add_argument(
|
|
947
|
+
"--coarse-channel-log-interval",
|
|
948
|
+
type=int,
|
|
949
|
+
default=None,
|
|
950
|
+
help="Progress-logging cadence for energy detection, in coarse channels per log line. Default: ~25%% milestone lines per ON file (the per-channel lines were 62%% of a run's Slack-bound log volume); pass an explicit N to restore every-N-channels lines. Parallelism itself comes from the persistent worker pool, not this knob.",
|
|
951
|
+
)
|
|
952
|
+
parser.add_argument(
|
|
953
|
+
"--bandpass-method",
|
|
954
|
+
type=str,
|
|
955
|
+
default=None,
|
|
956
|
+
help="Bandpass flattening method for energy detection: 'pfb' (default) divides each coarse channel by the instrument's static polyphase-filterbank response; 'spline' fits and subtracts a per-channel spline",
|
|
957
|
+
)
|
|
958
|
+
parser.add_argument(
|
|
959
|
+
"--pfb-taps-per-channel",
|
|
960
|
+
type=int,
|
|
961
|
+
default=None,
|
|
962
|
+
help="PFB prototype-filter taps per coarse channel for --bandpass-method pfb (default: 12, the GBT/Breakthrough Listen backend value). INSTRUMENT-DEPENDENT: must match the backend that produced the .h5 files",
|
|
963
|
+
)
|
|
964
|
+
parser.add_argument(
|
|
965
|
+
"--bandpass-debug-plot",
|
|
966
|
+
action=argparse.BooleanOptionalAction,
|
|
967
|
+
default=None,
|
|
968
|
+
help="Save a per-cadence bandpass-flattening overlay debug plot (raw vs flattened integrated spectrum for a few sampled coarse channels) under plots/inference/{save-tag}/ (default: off)",
|
|
969
|
+
)
|
|
970
|
+
parser.add_argument(
|
|
971
|
+
"--spline-order",
|
|
972
|
+
type=int,
|
|
973
|
+
default=None,
|
|
974
|
+
help="Spline order for bandpass fitting with --bandpass-method spline (default: 16)",
|
|
975
|
+
)
|
|
976
|
+
parser.add_argument(
|
|
977
|
+
"--detection-window-size",
|
|
978
|
+
type=int,
|
|
979
|
+
default=None,
|
|
980
|
+
help="Sliding window size in fine channels for normality test (default: 256)",
|
|
981
|
+
)
|
|
982
|
+
parser.add_argument(
|
|
983
|
+
"--detection-step-size",
|
|
984
|
+
type=int,
|
|
985
|
+
default=None,
|
|
986
|
+
help="Step size in fine channels for sliding window (default: 128)",
|
|
987
|
+
)
|
|
988
|
+
parser.add_argument(
|
|
989
|
+
"--stat-threshold",
|
|
990
|
+
type=float,
|
|
991
|
+
default=None,
|
|
992
|
+
help="D'Agostino-Pearson statistic threshold for hit detection (default: 2048.0)",
|
|
993
|
+
)
|
|
994
|
+
parser.add_argument(
|
|
995
|
+
"--stamp-width",
|
|
996
|
+
type=int,
|
|
997
|
+
default=None,
|
|
998
|
+
help="Width in fine channels of the extracted stamp around each hit (default: 4096; must equal --width-bin)",
|
|
999
|
+
)
|
|
1000
|
+
parser.add_argument(
|
|
1001
|
+
"--store-downsampled-stamps",
|
|
1002
|
+
action=argparse.BooleanOptionalAction,
|
|
1003
|
+
default=None,
|
|
1004
|
+
help="Downsample stamps along frequency (by --downsample-factor) at extraction time, storing stamp_width // downsample_factor bins per stamp (~8x smaller at defaults; default: enabled). Pass --no-store-downsampled-stamps to archive raw-resolution stamps; loading handles both layouts.",
|
|
1005
|
+
)
|
|
1006
|
+
parser.add_argument(
|
|
1007
|
+
"--overlap-search",
|
|
1008
|
+
action=argparse.BooleanOptionalAction,
|
|
1009
|
+
default=None,
|
|
1010
|
+
help="Additionally extract stamps offset by ±overlap_fraction*stamp_width around each hit. Pass --no-overlap-search to disable when the config default is True.",
|
|
1011
|
+
)
|
|
1012
|
+
parser.add_argument(
|
|
1013
|
+
"--overlap-fraction",
|
|
1014
|
+
type=float,
|
|
1015
|
+
default=None,
|
|
1016
|
+
help="Fractional offset (relative to stamp_width) for overlap-search stamps (default: 0.5)",
|
|
1017
|
+
)
|
|
1018
|
+
parser.add_argument(
|
|
1019
|
+
"--preprocess-output-dir",
|
|
1020
|
+
type=str,
|
|
1021
|
+
default=None,
|
|
1022
|
+
help="Directory for per-cadence .npy outputs from preprocessing. Default: a per-CSV directory {data_path}/inference/preprocessed/<csv_stem>_ed<hash>/ keyed on the energy-detection config fingerprint — runs sharing an ED config reuse each other's stamps automatically, and any ED-config change resolves to a fresh directory. Pass a directory explicitly to pin/share one location (reuse is still guarded by the sidecar's recorded h5 paths and ED fingerprint)",
|
|
1023
|
+
)
|
|
1024
|
+
|
|
1025
|
+
parser.add_argument(
|
|
1026
|
+
"--prune-stamps",
|
|
1027
|
+
action=argparse.BooleanOptionalAction,
|
|
1028
|
+
default=None,
|
|
1029
|
+
help="Delete each cadence's stamp .npy right after its 'inferred' manifest row lands, keeping the metadata .json plus a ~196 KB snippet sidecar per candidate — resume rides the DB row, and only stamps this run freshly extracted are ever pruned. Without pruning a full catalog writes ~30-90 TB of stamps. Default: AUTO — enabled for the fingerprint-scoped default cache directory, disabled when --preprocess-output-dir is set explicitly. Pass --no-prune-stamps to keep every stamp (slice-scale runs wanting the cross-run rerun cache).",
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
# Visualization suite
|
|
1033
|
+
parser.add_argument(
|
|
1034
|
+
"--inference-viz",
|
|
1035
|
+
action=argparse.BooleanOptionalAction,
|
|
1036
|
+
default=None,
|
|
1037
|
+
help="Render the inference visualization suite (energy detection distributions, hit spectrum, bandpass overlay, stamp/candidate galleries, confidence distribution, latent projection, summary card) at the end of a CSV inference run, saved under plots/inference/{save_tag}/ and uploaded to Slack (default: enabled). Pass --no-inference-viz to disable.",
|
|
1038
|
+
)
|
|
1039
|
+
parser.add_argument(
|
|
1040
|
+
"--inference-viz-scope",
|
|
1041
|
+
type=str,
|
|
1042
|
+
choices=["full", "new"],
|
|
1043
|
+
default=None,
|
|
1044
|
+
help="Which cadences the metadata-driven viz figures cover: 'full' (default) renders the whole accumulated tag every successful pass; 'new' renders only cadences inferred this pass — recommended for resumed multi-pass catalog campaigns, where 'full' re-pays the entire catalog's viz tail on every pass. DB-sourced candidate figures always cover the full tag either way.",
|
|
1045
|
+
)
|
|
1046
|
+
parser.add_argument(
|
|
1047
|
+
"--stamp-gallery-top-k",
|
|
1048
|
+
type=int,
|
|
1049
|
+
default=None,
|
|
1050
|
+
help="Number of top-statistic stamps shown in the stamp gallery figure, each as a 6-observation waterfall grid (default: 12)",
|
|
1051
|
+
)
|
|
1052
|
+
parser.add_argument(
|
|
1053
|
+
"--max-candidate-plots",
|
|
1054
|
+
type=int,
|
|
1055
|
+
default=None,
|
|
1056
|
+
help="Maximum number of per-candidate figures rendered per run, highest confidence first (default: 50; the candidate gallery is unaffected)",
|
|
1057
|
+
)
|
|
1058
|
+
parser.add_argument(
|
|
1059
|
+
"--max-retries",
|
|
1060
|
+
type=int,
|
|
1061
|
+
default=None,
|
|
1062
|
+
help="Maximum number of retry attempts for inference (including preprocessing) on failure",
|
|
1063
|
+
)
|
|
1064
|
+
parser.add_argument(
|
|
1065
|
+
"--retry-delay",
|
|
1066
|
+
type=int,
|
|
1067
|
+
default=None,
|
|
1068
|
+
help="Delay in seconds between inference retry attempts",
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
# HuggingFace Hub configuration
|
|
1072
|
+
parser.add_argument(
|
|
1073
|
+
"--hf-repo-id",
|
|
1074
|
+
type=str,
|
|
1075
|
+
default=None,
|
|
1076
|
+
help="HuggingFace model repo id (namespace/name) for weight upload/download (default: zachtheyek/aetherscan)",
|
|
1077
|
+
)
|
|
1078
|
+
parser.add_argument(
|
|
1079
|
+
"--hf-revision",
|
|
1080
|
+
type=str,
|
|
1081
|
+
default=None,
|
|
1082
|
+
help="HuggingFace revision (tag, branch, or commit hash) to pin the model download to when no local artifact paths are given (default: v{package version} when running as an installed release, else the repo's latest release tag — highest semver vX.Y.Z tag; a release tag is required for a no-artifact download)",
|
|
1083
|
+
)
|
|
1084
|
+
|
|
1085
|
+
# Checkpoint configuration
|
|
1086
|
+
parser.add_argument(
|
|
1087
|
+
"--save-tag",
|
|
1088
|
+
type=str,
|
|
1089
|
+
default=None,
|
|
1090
|
+
help="Run label prefix: one of test, train, inf, bench. The datetime is appended automatically at runtime (e.g. inf_20260101_120000). Defaults to the subcommand (train->train, inference->inf) if omitted.",
|
|
1091
|
+
)
|
|
1092
|
+
parser.add_argument(
|
|
1093
|
+
"--force-tag",
|
|
1094
|
+
action=argparse.BooleanOptionalAction,
|
|
1095
|
+
default=None,
|
|
1096
|
+
help="Override the fail-early save-tag collision guard: proceed even when an explicitly-provided --save-tag matches a previous run's saved config or DB rows (default: disabled)",
|
|
1097
|
+
)
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
# Saved-config ALLOWLIST (#303): apply_saved_config layers ONLY these fields from a saved
|
|
1101
|
+
# training config; everything else always comes from env + defaults + CLI. The old design
|
|
1102
|
+
# was the inverse — apply everything minus a skip-list — which left every host-scoped
|
|
1103
|
+
# field unsafe by default and needed a new amendment each time one bit (checkpoint, then
|
|
1104
|
+
# inference batching/prefetch, then reproducibility). The live footgun: a config saved on
|
|
1105
|
+
# 96-core bla0 carries manager.n_processes=96 and silently 3x-oversubscribed 32-core
|
|
1106
|
+
# blpc3's ED pool, with no CLI rescue before --n-processes existed.
|
|
1107
|
+
#
|
|
1108
|
+
# The allowlist is DERIVED from run_state.py's result-fingerprint key sets so the two can
|
|
1109
|
+
# never drift (test-pinned): every field the fingerprints treat as result-affecting keeps
|
|
1110
|
+
# layering — de-layering one would silently change science relative to the artifact's
|
|
1111
|
+
# validation, and would also stale the resume fingerprints — while host/runtime fields
|
|
1112
|
+
# (gpu, manager, db, logger, monitor, paths, hf, training, checkpoint, reproducibility)
|
|
1113
|
+
# never layer. An allowlist can only under-layer (visible in the startup diff log below,
|
|
1114
|
+
# fixable via CLI); the old shape could silently import a foreign host's tuning.
|
|
1115
|
+
# - beta_vae: whole section — model contract (latent_dim drives the scoring feature
|
|
1116
|
+
# layout; dense_layer_size/kernel_size feed shape validation; loss/precision fields are
|
|
1117
|
+
# inert at inference because the encoder is loaded whole from the .keras file).
|
|
1118
|
+
# - rf: the deployed-representation contract only — NOT n_jobs (host tuning, re-pinned
|
|
1119
|
+
# from the runtime config at load) and NOT seeds (run-scoped, #279).
|
|
1120
|
+
# - inference: exactly the fields inference_config_fingerprint hashes (thresholds,
|
|
1121
|
+
# mc_draws, ED params — training certifies the threshold cascade against this specific
|
|
1122
|
+
# model, so the artifact carrying its own science params is correct provenance) minus
|
|
1123
|
+
# the artifact paths (host-local; CLI/HF-resolved).
|
|
1124
|
+
# - data: the stamp/encoder geometry keys the fingerprints use.
|
|
1125
|
+
_SAVED_CONFIG_SECTION_ALLOWLIST = frozenset({"beta_vae"})
|
|
1126
|
+
_SAVED_CONFIG_FIELD_ALLOWLIST: dict[str, frozenset[str]] = {
|
|
1127
|
+
"data": frozenset(_INFERENCE_FINGERPRINT_DATA_KEYS),
|
|
1128
|
+
"inference": frozenset(f.name for f in dataclass_fields(InferenceConfig))
|
|
1129
|
+
- _INFERENCE_FINGERPRINT_EXCLUDE_INFERENCE_KEYS
|
|
1130
|
+
- frozenset({"encoder_path", "rf_path", "config_path"}),
|
|
1131
|
+
"rf": frozenset({"latent_variant", "active_dims", "calibration_active", "calibration_method"}),
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def apply_saved_config(config_path: str) -> None:
|
|
1136
|
+
"""Layer a saved JSON config (e.g. from a prior training run) onto the singleton.
|
|
1137
|
+
|
|
1138
|
+
Used in inference mode to make `--config-path my_run.json` actually take effect:
|
|
1139
|
+
the saved file's values become the new "defaults" that `validate_args` sees via
|
|
1140
|
+
`_resolve`, and any CLI flags the user also passes will override them in the
|
|
1141
|
+
subsequent `apply_args_to_config` call.
|
|
1142
|
+
|
|
1143
|
+
Priority order for ALLOWLISTED fields ends up as:
|
|
1144
|
+
|
|
1145
|
+
defaults < saved config < CLI args
|
|
1146
|
+
|
|
1147
|
+
Only the model-contract / result-affecting fields in _SAVED_CONFIG_FIELD_ALLOWLIST /
|
|
1148
|
+
_SAVED_CONFIG_SECTION_ALLOWLIST layer (#303); every other saved field — paths, gpu,
|
|
1149
|
+
manager, db/logger/monitor, hf, training, checkpoint (most damagingly save_tag, which
|
|
1150
|
+
would make this run masquerade under the training run's tag), reproducibility (the
|
|
1151
|
+
run's own contract: opting out of determinism is an explicit CLI act) — is ignored,
|
|
1152
|
+
with every ignored field whose saved value differs from the resolved one logged as a
|
|
1153
|
+
startup diff line so nothing disappears silently. Unknown keys/fields are skipped to
|
|
1154
|
+
stay forward-compat with newer/older saved configs.
|
|
1155
|
+
|
|
1156
|
+
Raises `ValueError` if the file is missing or malformed — caught by main.py's
|
|
1157
|
+
wrapper alongside `validate_args` failures.
|
|
1158
|
+
"""
|
|
1159
|
+
if not os.path.exists(config_path):
|
|
1160
|
+
raise ValueError(f"--config-path: file does not exist on disk: {config_path}")
|
|
1161
|
+
try:
|
|
1162
|
+
with open(config_path) as f:
|
|
1163
|
+
saved = json.load(f)
|
|
1164
|
+
except json.JSONDecodeError as exc:
|
|
1165
|
+
raise ValueError(f"--config-path: {config_path} is not valid JSON: {exc}") from exc
|
|
1166
|
+
|
|
1167
|
+
config = get_config()
|
|
1168
|
+
if config is None:
|
|
1169
|
+
raise ValueError("get_config() returned None")
|
|
1170
|
+
|
|
1171
|
+
ignored_diffs: list[str] = []
|
|
1172
|
+
for key, value in saved.items():
|
|
1173
|
+
target = getattr(config, key, None)
|
|
1174
|
+
if not (is_dataclass(target) and isinstance(value, dict)):
|
|
1175
|
+
# Top-level scalars (a legacy flat config's data_path/model_path/...) and
|
|
1176
|
+
# unknown sections never layer: paths and host layout always come from
|
|
1177
|
+
# env + defaults + CLI (#303; current-format configs nest paths under a
|
|
1178
|
+
# "paths" key that was never applied anyway — this formalizes that).
|
|
1179
|
+
if not isinstance(value, dict) and value != target:
|
|
1180
|
+
ignored_diffs.append(f"{key}: saved={value!r} ignored")
|
|
1181
|
+
continue
|
|
1182
|
+
if key in _SAVED_CONFIG_SECTION_ALLOWLIST:
|
|
1183
|
+
allowed = {f.name for f in dataclass_fields(target)}
|
|
1184
|
+
else:
|
|
1185
|
+
allowed = _SAVED_CONFIG_FIELD_ALLOWLIST.get(key, frozenset())
|
|
1186
|
+
for sub_key, sub_val in value.items():
|
|
1187
|
+
if not hasattr(target, sub_key):
|
|
1188
|
+
continue
|
|
1189
|
+
if sub_key in allowed:
|
|
1190
|
+
setattr(target, sub_key, sub_val)
|
|
1191
|
+
else:
|
|
1192
|
+
current = getattr(target, sub_key)
|
|
1193
|
+
if current != sub_val:
|
|
1194
|
+
ignored_diffs.append(
|
|
1195
|
+
f"{key}.{sub_key}: saved={sub_val!r} ignored, using {current!r}"
|
|
1196
|
+
)
|
|
1197
|
+
if ignored_diffs:
|
|
1198
|
+
logger.info(
|
|
1199
|
+
f"Saved-config fields NOT layered ({len(ignored_diffs)} host/run-scoped "
|
|
1200
|
+
f"difference(s); the #303 allowlist keeps paths and host tuning with "
|
|
1201
|
+
f"env+defaults+CLI): {'; '.join(ignored_diffs)}"
|
|
1202
|
+
)
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def apply_args_to_config(args: argparse.Namespace) -> None:
|
|
1206
|
+
"""Mutate the singleton config in place with any non-None overrides from the parsed CLI
|
|
1207
|
+
namespace. Only attributes actually present on `args` are considered; missing ones fall back
|
|
1208
|
+
to the config defaults."""
|
|
1209
|
+
config = get_config()
|
|
1210
|
+
if config is None:
|
|
1211
|
+
raise ValueError("get_config() returned None")
|
|
1212
|
+
|
|
1213
|
+
# Host-runtime overrides (#303): the only way to size the worker pools besides the
|
|
1214
|
+
# cpu_count() default — never layered from a saved config
|
|
1215
|
+
if hasattr(args, "n_processes") and args.n_processes is not None:
|
|
1216
|
+
config.manager.n_processes = args.n_processes
|
|
1217
|
+
|
|
1218
|
+
# Path overrides (must be done first as they affect file loading)
|
|
1219
|
+
if hasattr(args, "data_path") and args.data_path is not None:
|
|
1220
|
+
config.data_path = args.data_path
|
|
1221
|
+
if hasattr(args, "model_path") and args.model_path is not None:
|
|
1222
|
+
config.model_path = args.model_path
|
|
1223
|
+
if hasattr(args, "output_path") and args.output_path is not None:
|
|
1224
|
+
config.output_path = args.output_path
|
|
1225
|
+
if hasattr(args, "dashboard") and args.dashboard is not None:
|
|
1226
|
+
config.monitor.dashboard_enabled = args.dashboard
|
|
1227
|
+
if hasattr(args, "dashboard_port") and args.dashboard_port is not None:
|
|
1228
|
+
config.monitor.dashboard_port = args.dashboard_port
|
|
1229
|
+
if hasattr(args, "benchmark_report") and args.benchmark_report is not None:
|
|
1230
|
+
config.monitor.benchmark_report_enabled = args.benchmark_report
|
|
1231
|
+
|
|
1232
|
+
# BetaVAE configuration
|
|
1233
|
+
if hasattr(args, "vae_latent_dim") and args.vae_latent_dim is not None:
|
|
1234
|
+
config.beta_vae.latent_dim = args.vae_latent_dim
|
|
1235
|
+
if hasattr(args, "vae_dense_layer_size") and args.vae_dense_layer_size is not None:
|
|
1236
|
+
config.beta_vae.dense_layer_size = args.vae_dense_layer_size
|
|
1237
|
+
if hasattr(args, "vae_kernel_size") and args.vae_kernel_size is not None:
|
|
1238
|
+
config.beta_vae.kernel_size = tuple(args.vae_kernel_size)
|
|
1239
|
+
if hasattr(args, "vae_beta") and args.vae_beta is not None:
|
|
1240
|
+
config.beta_vae.beta = args.vae_beta
|
|
1241
|
+
if hasattr(args, "vae_alpha") and args.vae_alpha is not None:
|
|
1242
|
+
config.beta_vae.alpha = args.vae_alpha
|
|
1243
|
+
|
|
1244
|
+
# Random Forest configuration
|
|
1245
|
+
if hasattr(args, "rf_n_estimators") and args.rf_n_estimators is not None:
|
|
1246
|
+
config.rf.n_estimators = args.rf_n_estimators
|
|
1247
|
+
if hasattr(args, "rf_bootstrap") and args.rf_bootstrap is not None:
|
|
1248
|
+
config.rf.bootstrap = args.rf_bootstrap
|
|
1249
|
+
if hasattr(args, "rf_max_features") and args.rf_max_features is not None:
|
|
1250
|
+
config.rf.max_features = args.rf_max_features
|
|
1251
|
+
if hasattr(args, "rf_n_jobs") and args.rf_n_jobs is not None:
|
|
1252
|
+
config.rf.n_jobs = args.rf_n_jobs
|
|
1253
|
+
if hasattr(args, "rf_seed") and args.rf_seed is not None:
|
|
1254
|
+
# Deprecated alias (#279): the RF seed normally derives from the root --seed via
|
|
1255
|
+
# STREAM_RF; an explicit override still works but is discouraged
|
|
1256
|
+
logger.warning(
|
|
1257
|
+
"--rf-seed is DEPRECATED: the random forest seed now derives from the root "
|
|
1258
|
+
"--seed. The explicit override will be honored this run."
|
|
1259
|
+
)
|
|
1260
|
+
config.rf.seed = args.rf_seed
|
|
1261
|
+
|
|
1262
|
+
# GPU configuration
|
|
1263
|
+
if hasattr(args, "num_replicas") and args.num_replicas is not None:
|
|
1264
|
+
config.gpu.num_replicas = args.num_replicas
|
|
1265
|
+
if hasattr(args, "gpu_memory_limit_mb") and args.gpu_memory_limit_mb is not None:
|
|
1266
|
+
config.gpu.per_gpu_memory_limit_mb = args.gpu_memory_limit_mb
|
|
1267
|
+
if hasattr(args, "nccl_num_packs") and args.nccl_num_packs is not None:
|
|
1268
|
+
config.gpu.nccl_num_packs = args.nccl_num_packs
|
|
1269
|
+
# async_allocator uses argparse.BooleanOptionalAction with default=None so that
|
|
1270
|
+
# the CLI can express "leave the config default" (omit), "force on"
|
|
1271
|
+
# (--async-allocator), and "force off" (--no-async-allocator). The `is not None`
|
|
1272
|
+
# guard preserves the config default when the user passes neither
|
|
1273
|
+
if hasattr(args, "async_allocator") and args.async_allocator is not None:
|
|
1274
|
+
config.gpu.use_async_allocator = args.async_allocator
|
|
1275
|
+
|
|
1276
|
+
# Data configuration
|
|
1277
|
+
if hasattr(args, "num_observations") and args.num_observations is not None:
|
|
1278
|
+
config.data.num_observations = args.num_observations
|
|
1279
|
+
if hasattr(args, "width_bin") and args.width_bin is not None:
|
|
1280
|
+
config.data.width_bin = args.width_bin
|
|
1281
|
+
if hasattr(args, "downsample_factor") and args.downsample_factor is not None:
|
|
1282
|
+
config.data.downsample_factor = args.downsample_factor
|
|
1283
|
+
if hasattr(args, "time_bins") and args.time_bins is not None:
|
|
1284
|
+
config.data.time_bins = args.time_bins
|
|
1285
|
+
if hasattr(args, "freq_resolution") and args.freq_resolution is not None:
|
|
1286
|
+
config.data.freq_resolution = args.freq_resolution
|
|
1287
|
+
if hasattr(args, "time_resolution") and args.time_resolution is not None:
|
|
1288
|
+
config.data.time_resolution = args.time_resolution
|
|
1289
|
+
if hasattr(args, "num_target_backgrounds") and args.num_target_backgrounds is not None:
|
|
1290
|
+
config.data.num_target_backgrounds = args.num_target_backgrounds
|
|
1291
|
+
if hasattr(args, "background_load_chunk_size") and args.background_load_chunk_size is not None:
|
|
1292
|
+
config.data.background_load_chunk_size = args.background_load_chunk_size
|
|
1293
|
+
if hasattr(args, "max_chunks_per_file") and args.max_chunks_per_file is not None:
|
|
1294
|
+
config.data.max_chunks_per_file = args.max_chunks_per_file
|
|
1295
|
+
if hasattr(args, "train_files") and args.train_files is not None:
|
|
1296
|
+
config.data.train_files = args.train_files
|
|
1297
|
+
if hasattr(args, "test_files") and args.test_files is not None:
|
|
1298
|
+
config.data.test_files = args.test_files
|
|
1299
|
+
if hasattr(args, "inference_files") and args.inference_files is not None:
|
|
1300
|
+
config.data.inference_files = args.inference_files
|
|
1301
|
+
|
|
1302
|
+
# Reproducibility (#279): shared by both subparsers. --unseeded is the explicit opt-out
|
|
1303
|
+
# of the seeded default (#298) — validate_args rejects passing it together with --seed.
|
|
1304
|
+
if hasattr(args, "unseeded") and args.unseeded:
|
|
1305
|
+
config.reproducibility.seed = None
|
|
1306
|
+
elif hasattr(args, "seed") and args.seed is not None:
|
|
1307
|
+
config.reproducibility.seed = args.seed
|
|
1308
|
+
# tf_deterministic_ops uses argparse.BooleanOptionalAction with default=None so the CLI
|
|
1309
|
+
# can express "leave the config default" (omit), "force on", and "force off" — same
|
|
1310
|
+
# pattern as async_allocator above
|
|
1311
|
+
if hasattr(args, "tf_deterministic_ops") and args.tf_deterministic_ops is not None:
|
|
1312
|
+
config.reproducibility.tf_deterministic_ops = args.tf_deterministic_ops
|
|
1313
|
+
|
|
1314
|
+
# Training configuration
|
|
1315
|
+
if hasattr(args, "num_training_rounds") and args.num_training_rounds is not None:
|
|
1316
|
+
config.training.num_training_rounds = args.num_training_rounds
|
|
1317
|
+
if hasattr(args, "epochs_per_round") and args.epochs_per_round is not None:
|
|
1318
|
+
config.training.epochs_per_round = args.epochs_per_round
|
|
1319
|
+
if hasattr(args, "num_samples_beta_vae") and args.num_samples_beta_vae is not None:
|
|
1320
|
+
config.training.num_samples_beta_vae = args.num_samples_beta_vae
|
|
1321
|
+
if hasattr(args, "num_samples_rf") and args.num_samples_rf is not None:
|
|
1322
|
+
config.training.num_samples_rf = args.num_samples_rf
|
|
1323
|
+
if hasattr(args, "train_val_split") and args.train_val_split is not None:
|
|
1324
|
+
config.training.train_val_split = args.train_val_split
|
|
1325
|
+
if (
|
|
1326
|
+
hasattr(args, "per_replica_batch_size")
|
|
1327
|
+
and args.per_replica_batch_size is not None
|
|
1328
|
+
and getattr(args, "command", None) == "train"
|
|
1329
|
+
):
|
|
1330
|
+
config.training.per_replica_batch_size = args.per_replica_batch_size
|
|
1331
|
+
if hasattr(args, "effective_batch_size") and args.effective_batch_size is not None:
|
|
1332
|
+
config.training.effective_batch_size = args.effective_batch_size
|
|
1333
|
+
if hasattr(args, "per_replica_val_batch_size") and args.per_replica_val_batch_size is not None:
|
|
1334
|
+
config.training.per_replica_val_batch_size = args.per_replica_val_batch_size
|
|
1335
|
+
if (
|
|
1336
|
+
hasattr(args, "signal_injection_chunk_size")
|
|
1337
|
+
and args.signal_injection_chunk_size is not None
|
|
1338
|
+
):
|
|
1339
|
+
config.training.signal_injection_chunk_size = args.signal_injection_chunk_size
|
|
1340
|
+
if hasattr(args, "data_gen_task_size") and args.data_gen_task_size is not None:
|
|
1341
|
+
config.training.data_gen_task_size = args.data_gen_task_size
|
|
1342
|
+
if hasattr(args, "round_data_dir") and args.round_data_dir is not None:
|
|
1343
|
+
config.training.round_data_dir = args.round_data_dir
|
|
1344
|
+
# overlap_data_generation / keep_round_data use argparse.BooleanOptionalAction with
|
|
1345
|
+
# default=None so that the CLI can express "leave the config default" (omit), "force on"
|
|
1346
|
+
# (--overlap-data-generation), and "force off" (--no-overlap-data-generation). The
|
|
1347
|
+
# `is not None` guard preserves the config default when the user passes neither
|
|
1348
|
+
if hasattr(args, "overlap_data_generation") and args.overlap_data_generation is not None:
|
|
1349
|
+
config.training.overlap_data_generation = args.overlap_data_generation
|
|
1350
|
+
if hasattr(args, "keep_round_data") and args.keep_round_data is not None:
|
|
1351
|
+
config.training.keep_round_data = args.keep_round_data
|
|
1352
|
+
if (
|
|
1353
|
+
hasattr(args, "plot_injection_subsampling_count")
|
|
1354
|
+
and args.plot_injection_subsampling_count is not None
|
|
1355
|
+
):
|
|
1356
|
+
config.training.plot_injection_subsampling_count = args.plot_injection_subsampling_count
|
|
1357
|
+
if (
|
|
1358
|
+
hasattr(args, "plot_injection_outlier_percentile")
|
|
1359
|
+
and args.plot_injection_outlier_percentile is not None
|
|
1360
|
+
):
|
|
1361
|
+
config.training.plot_injection_outlier_percentile = args.plot_injection_outlier_percentile
|
|
1362
|
+
if (
|
|
1363
|
+
hasattr(args, "latent_viz_num_cadences_per_type")
|
|
1364
|
+
and args.latent_viz_num_cadences_per_type is not None
|
|
1365
|
+
):
|
|
1366
|
+
config.training.latent_viz_num_cadences_per_type = args.latent_viz_num_cadences_per_type
|
|
1367
|
+
if hasattr(args, "latent_viz_step_interval") and args.latent_viz_step_interval is not None:
|
|
1368
|
+
config.training.latent_viz_step_interval = args.latent_viz_step_interval
|
|
1369
|
+
if (
|
|
1370
|
+
hasattr(args, "latent_viz_umap_fit_max_samples")
|
|
1371
|
+
and args.latent_viz_umap_fit_max_samples is not None
|
|
1372
|
+
):
|
|
1373
|
+
config.training.latent_viz_umap_fit_max_samples = args.latent_viz_umap_fit_max_samples
|
|
1374
|
+
if (
|
|
1375
|
+
hasattr(args, "latent_viz_umap_n_neighbors")
|
|
1376
|
+
and args.latent_viz_umap_n_neighbors is not None
|
|
1377
|
+
):
|
|
1378
|
+
config.training.latent_viz_umap_n_neighbors = args.latent_viz_umap_n_neighbors
|
|
1379
|
+
if hasattr(args, "latent_viz_umap_min_dist") and args.latent_viz_umap_min_dist is not None:
|
|
1380
|
+
config.training.latent_viz_umap_min_dist = args.latent_viz_umap_min_dist
|
|
1381
|
+
if hasattr(args, "latent_viz_gif_max_frames") and args.latent_viz_gif_max_frames is not None:
|
|
1382
|
+
config.training.latent_viz_gif_max_frames = args.latent_viz_gif_max_frames
|
|
1383
|
+
if hasattr(args, "latent_viz_gif_duration_ms") and args.latent_viz_gif_duration_ms is not None:
|
|
1384
|
+
config.training.latent_viz_gif_duration_ms = args.latent_viz_gif_duration_ms
|
|
1385
|
+
# latent_traversal_every_round uses argparse.BooleanOptionalAction with default=None
|
|
1386
|
+
# (same tri-state as overlap_data_generation / keep_round_data above)
|
|
1387
|
+
if (
|
|
1388
|
+
hasattr(args, "latent_traversal_every_round")
|
|
1389
|
+
and args.latent_traversal_every_round is not None
|
|
1390
|
+
):
|
|
1391
|
+
config.training.latent_traversal_every_round = args.latent_traversal_every_round
|
|
1392
|
+
if hasattr(args, "latent_traversal_num_steps") and args.latent_traversal_num_steps is not None:
|
|
1393
|
+
config.training.latent_traversal_num_steps = args.latent_traversal_num_steps
|
|
1394
|
+
if hasattr(args, "latent_traversal_max_sigma") and args.latent_traversal_max_sigma is not None:
|
|
1395
|
+
config.training.latent_traversal_max_sigma = args.latent_traversal_max_sigma
|
|
1396
|
+
if hasattr(args, "snr_base") and args.snr_base is not None:
|
|
1397
|
+
config.training.snr_base = args.snr_base
|
|
1398
|
+
if hasattr(args, "initial_snr_range") and args.initial_snr_range is not None:
|
|
1399
|
+
config.training.initial_snr_range = args.initial_snr_range
|
|
1400
|
+
if hasattr(args, "final_snr_range") and args.final_snr_range is not None:
|
|
1401
|
+
config.training.final_snr_range = args.final_snr_range
|
|
1402
|
+
if hasattr(args, "curriculum_schedule") and args.curriculum_schedule is not None:
|
|
1403
|
+
config.training.curriculum_schedule = args.curriculum_schedule
|
|
1404
|
+
if hasattr(args, "exponential_decay_rate") and args.exponential_decay_rate is not None:
|
|
1405
|
+
config.training.exponential_decay_rate = args.exponential_decay_rate
|
|
1406
|
+
if hasattr(args, "step_easy_rounds") and args.step_easy_rounds is not None:
|
|
1407
|
+
config.training.step_easy_rounds = args.step_easy_rounds
|
|
1408
|
+
if hasattr(args, "step_hard_rounds") and args.step_hard_rounds is not None:
|
|
1409
|
+
config.training.step_hard_rounds = args.step_hard_rounds
|
|
1410
|
+
if hasattr(args, "base_learning_rate") and args.base_learning_rate is not None:
|
|
1411
|
+
config.training.base_learning_rate = args.base_learning_rate
|
|
1412
|
+
if hasattr(args, "min_learning_rate") and args.min_learning_rate is not None:
|
|
1413
|
+
config.training.min_learning_rate = args.min_learning_rate
|
|
1414
|
+
if hasattr(args, "min_pct_improvement") and args.min_pct_improvement is not None:
|
|
1415
|
+
config.training.min_pct_improvement = args.min_pct_improvement
|
|
1416
|
+
if hasattr(args, "patience_threshold") and args.patience_threshold is not None:
|
|
1417
|
+
config.training.patience_threshold = args.patience_threshold
|
|
1418
|
+
if hasattr(args, "lr_reduction_factor") and args.lr_reduction_factor is not None:
|
|
1419
|
+
config.training.reduction_factor = args.lr_reduction_factor
|
|
1420
|
+
if (
|
|
1421
|
+
hasattr(args, "max_retries")
|
|
1422
|
+
and args.max_retries is not None
|
|
1423
|
+
and getattr(args, "command", None) == "train"
|
|
1424
|
+
):
|
|
1425
|
+
config.training.max_retries = args.max_retries
|
|
1426
|
+
if (
|
|
1427
|
+
hasattr(args, "retry_delay")
|
|
1428
|
+
and args.retry_delay is not None
|
|
1429
|
+
and getattr(args, "command", None) == "train"
|
|
1430
|
+
):
|
|
1431
|
+
config.training.retry_delay = args.retry_delay
|
|
1432
|
+
|
|
1433
|
+
# HuggingFace Hub configuration
|
|
1434
|
+
# hf_upload uses argparse.BooleanOptionalAction with default=None so that the CLI can
|
|
1435
|
+
# express "leave the config default" (omit), "force on" (--hf-upload), and "force off"
|
|
1436
|
+
# (--no-hf-upload). The `is not None` guard preserves the config default when the user
|
|
1437
|
+
# passes neither
|
|
1438
|
+
if hasattr(args, "hf_upload") and args.hf_upload is not None:
|
|
1439
|
+
config.hf.upload_after_training = args.hf_upload
|
|
1440
|
+
if hasattr(args, "hf_repo_id") and args.hf_repo_id is not None:
|
|
1441
|
+
config.hf.repo_id = args.hf_repo_id
|
|
1442
|
+
if hasattr(args, "hf_revision") and args.hf_revision is not None:
|
|
1443
|
+
config.hf.revision = args.hf_revision
|
|
1444
|
+
|
|
1445
|
+
# Checkpoint configuration
|
|
1446
|
+
if hasattr(args, "load_dir") and args.load_dir is not None:
|
|
1447
|
+
config.checkpoint.load_dir = args.load_dir
|
|
1448
|
+
if hasattr(args, "load_tag") and args.load_tag is not None:
|
|
1449
|
+
config.checkpoint.load_tag = args.load_tag
|
|
1450
|
+
config.checkpoint.infer_start_round() # Try inferring start_round from load_tag first
|
|
1451
|
+
if hasattr(args, "start_round") and args.start_round is not None:
|
|
1452
|
+
config.checkpoint.start_round = args.start_round # Override start_round if provided
|
|
1453
|
+
# NOTE: config.checkpoint.save_tag is resolved once in main() (before init_logger, so the log
|
|
1454
|
+
# file / config / artifacts share one datetime) via resolve_save_tag — not applied from args here.
|
|
1455
|
+
# force_tag uses argparse.BooleanOptionalAction with default=None (same tri-state as
|
|
1456
|
+
# hf_upload above)
|
|
1457
|
+
if hasattr(args, "force_tag") and args.force_tag is not None:
|
|
1458
|
+
config.checkpoint.force_tag = args.force_tag
|
|
1459
|
+
|
|
1460
|
+
# Inference configuration
|
|
1461
|
+
if hasattr(args, "encoder_path") and args.encoder_path is not None:
|
|
1462
|
+
config.inference.encoder_path = args.encoder_path
|
|
1463
|
+
if hasattr(args, "rf_path") and args.rf_path is not None:
|
|
1464
|
+
config.inference.rf_path = args.rf_path
|
|
1465
|
+
if hasattr(args, "config_path") and args.config_path is not None:
|
|
1466
|
+
config.inference.config_path = args.config_path
|
|
1467
|
+
if hasattr(args, "classification_threshold") and args.classification_threshold is not None:
|
|
1468
|
+
config.inference.classification_threshold = args.classification_threshold
|
|
1469
|
+
if hasattr(args, "screening_threshold") and args.screening_threshold is not None:
|
|
1470
|
+
config.inference.screening_threshold = args.screening_threshold
|
|
1471
|
+
if hasattr(args, "mc_draws") and args.mc_draws is not None:
|
|
1472
|
+
config.inference.mc_draws = args.mc_draws
|
|
1473
|
+
if hasattr(args, "reference_cloud_size") and args.reference_cloud_size is not None:
|
|
1474
|
+
config.inference.reference_cloud_size = args.reference_cloud_size
|
|
1475
|
+
if hasattr(args, "prefetch_depth") and args.prefetch_depth is not None:
|
|
1476
|
+
config.inference.prefetch_depth = args.prefetch_depth
|
|
1477
|
+
if (
|
|
1478
|
+
hasattr(args, "per_replica_batch_size")
|
|
1479
|
+
and args.per_replica_batch_size is not None
|
|
1480
|
+
and getattr(args, "command", None) == "inference"
|
|
1481
|
+
):
|
|
1482
|
+
config.inference.per_replica_batch_size = args.per_replica_batch_size
|
|
1483
|
+
|
|
1484
|
+
# Energy detection preprocessing
|
|
1485
|
+
if hasattr(args, "cadence_group_by_cols") and args.cadence_group_by_cols is not None:
|
|
1486
|
+
config.inference.cadence_group_by_cols = args.cadence_group_by_cols
|
|
1487
|
+
if hasattr(args, "cadence_h5_path_col") and args.cadence_h5_path_col is not None:
|
|
1488
|
+
config.inference.cadence_h5_path_col = args.cadence_h5_path_col
|
|
1489
|
+
if hasattr(args, "cadence_expected_obs") and args.cadence_expected_obs is not None:
|
|
1490
|
+
config.inference.cadence_expected_obs = args.cadence_expected_obs
|
|
1491
|
+
if hasattr(args, "coarse_channel_width") and args.coarse_channel_width is not None:
|
|
1492
|
+
config.inference.coarse_channel_width = args.coarse_channel_width
|
|
1493
|
+
if (
|
|
1494
|
+
hasattr(args, "coarse_channel_log_interval")
|
|
1495
|
+
and args.coarse_channel_log_interval is not None
|
|
1496
|
+
):
|
|
1497
|
+
config.inference.coarse_channel_log_interval = args.coarse_channel_log_interval
|
|
1498
|
+
if hasattr(args, "bandpass_method") and args.bandpass_method is not None:
|
|
1499
|
+
config.inference.bandpass_method = args.bandpass_method
|
|
1500
|
+
if hasattr(args, "pfb_taps_per_channel") and args.pfb_taps_per_channel is not None:
|
|
1501
|
+
config.inference.pfb_taps_per_channel = args.pfb_taps_per_channel
|
|
1502
|
+
# bandpass_debug_plot uses argparse.BooleanOptionalAction with default=None so the CLI
|
|
1503
|
+
# can express "leave the config default" (omit), "force on" (--bandpass-debug-plot),
|
|
1504
|
+
# and "force off" (--no-bandpass-debug-plot).
|
|
1505
|
+
if hasattr(args, "bandpass_debug_plot") and args.bandpass_debug_plot is not None:
|
|
1506
|
+
config.inference.bandpass_debug_plot = args.bandpass_debug_plot
|
|
1507
|
+
if hasattr(args, "spline_order") and args.spline_order is not None:
|
|
1508
|
+
config.inference.spline_order = args.spline_order
|
|
1509
|
+
if hasattr(args, "detection_window_size") and args.detection_window_size is not None:
|
|
1510
|
+
config.inference.detection_window_size = args.detection_window_size
|
|
1511
|
+
if hasattr(args, "detection_step_size") and args.detection_step_size is not None:
|
|
1512
|
+
config.inference.detection_step_size = args.detection_step_size
|
|
1513
|
+
if hasattr(args, "stat_threshold") and args.stat_threshold is not None:
|
|
1514
|
+
config.inference.stat_threshold = args.stat_threshold
|
|
1515
|
+
if hasattr(args, "stamp_width") and args.stamp_width is not None:
|
|
1516
|
+
config.inference.stamp_width = args.stamp_width
|
|
1517
|
+
# store_downsampled_stamps uses argparse.BooleanOptionalAction with default=None so that
|
|
1518
|
+
# the CLI can express "leave the config default" (omit), "force on"
|
|
1519
|
+
# (--store-downsampled-stamps), and "force off" (--no-store-downsampled-stamps)
|
|
1520
|
+
if hasattr(args, "store_downsampled_stamps") and args.store_downsampled_stamps is not None:
|
|
1521
|
+
config.inference.store_downsampled_stamps = args.store_downsampled_stamps
|
|
1522
|
+
# overlap_search uses argparse.BooleanOptionalAction with default=None so that
|
|
1523
|
+
# the CLI can express "leave the config default" (omit), "force on"
|
|
1524
|
+
# (--overlap-search), and "force off" (--no-overlap-search). The `is not None`
|
|
1525
|
+
# guard preserves the config default when the user passes neither.
|
|
1526
|
+
if hasattr(args, "overlap_search") and args.overlap_search is not None:
|
|
1527
|
+
config.inference.overlap_search = args.overlap_search
|
|
1528
|
+
if hasattr(args, "overlap_fraction") and args.overlap_fraction is not None:
|
|
1529
|
+
config.inference.overlap_fraction = args.overlap_fraction
|
|
1530
|
+
if hasattr(args, "preprocess_output_dir") and args.preprocess_output_dir is not None:
|
|
1531
|
+
config.inference.preprocess_output_dir = args.preprocess_output_dir
|
|
1532
|
+
|
|
1533
|
+
# Visualization suite
|
|
1534
|
+
# inference_viz uses argparse.BooleanOptionalAction with default=None so that the CLI
|
|
1535
|
+
# can express "leave the config default" (omit), "force on" (--inference-viz), and
|
|
1536
|
+
# "force off" (--no-inference-viz)
|
|
1537
|
+
# prune_stamps uses BooleanOptionalAction with default=None, where None means AUTO
|
|
1538
|
+
# (on for the fingerprint-default cache dir, off for an explicit one) — resolved at
|
|
1539
|
+
# run time by main._resolve_prune_stamps, so only explicit flags land here
|
|
1540
|
+
if hasattr(args, "prune_stamps") and args.prune_stamps is not None:
|
|
1541
|
+
config.inference.prune_stamps = args.prune_stamps
|
|
1542
|
+
if hasattr(args, "inference_viz") and args.inference_viz is not None:
|
|
1543
|
+
config.inference.inference_viz_enabled = args.inference_viz
|
|
1544
|
+
if hasattr(args, "inference_viz_scope") and args.inference_viz_scope is not None:
|
|
1545
|
+
config.inference.inference_viz_scope = args.inference_viz_scope
|
|
1546
|
+
if hasattr(args, "stamp_gallery_top_k") and args.stamp_gallery_top_k is not None:
|
|
1547
|
+
config.inference.stamp_gallery_top_k = args.stamp_gallery_top_k
|
|
1548
|
+
if hasattr(args, "max_candidate_plots") and args.max_candidate_plots is not None:
|
|
1549
|
+
config.inference.max_candidate_plots = args.max_candidate_plots
|
|
1550
|
+
if (
|
|
1551
|
+
hasattr(args, "max_retries")
|
|
1552
|
+
and args.max_retries is not None
|
|
1553
|
+
and getattr(args, "command", None) == "inference"
|
|
1554
|
+
):
|
|
1555
|
+
config.inference.max_retries = args.max_retries
|
|
1556
|
+
if (
|
|
1557
|
+
hasattr(args, "retry_delay")
|
|
1558
|
+
and args.retry_delay is not None
|
|
1559
|
+
and getattr(args, "command", None) == "inference"
|
|
1560
|
+
):
|
|
1561
|
+
config.inference.retry_delay = args.retry_delay
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
# NOTE: come back to this later (make sure these checks are both comprehensive and implemented correctly)
|
|
1565
|
+
def collect_validation_errors(
|
|
1566
|
+
args: argparse.Namespace, num_replicas: int | None
|
|
1567
|
+
) -> list[ValidationError]:
|
|
1568
|
+
"""
|
|
1569
|
+
Collect semantic and cross-param validation failures for the parsed CLI namespace, merging
|
|
1570
|
+
args with config defaults via _resolve(). Returns a list rather than raising so utility
|
|
1571
|
+
scripts (e.g. utils/find_optimal_configs.py) can post-process violations and propose fixes;
|
|
1572
|
+
validate_args() is the thin wrapper that turns this list into a ValueError.
|
|
1573
|
+
"""
|
|
1574
|
+
|
|
1575
|
+
config = get_config()
|
|
1576
|
+
if config is None:
|
|
1577
|
+
raise ValueError("get_config() returned None")
|
|
1578
|
+
|
|
1579
|
+
cmd = getattr(args, "command", None)
|
|
1580
|
+
errors: list[ValidationError] = []
|
|
1581
|
+
|
|
1582
|
+
# Shared host-runtime flags (#303) — both subcommands
|
|
1583
|
+
nproc = _resolve(args, "n_processes", config.manager.n_processes)
|
|
1584
|
+
if nproc is not None and nproc < 1:
|
|
1585
|
+
errors.append(
|
|
1586
|
+
ValidationError(
|
|
1587
|
+
field="manager.n_processes",
|
|
1588
|
+
current=nproc,
|
|
1589
|
+
message=f"--n-processes must be >= 1, got {nproc}",
|
|
1590
|
+
fix_kind="clamp_low",
|
|
1591
|
+
min_val=1,
|
|
1592
|
+
)
|
|
1593
|
+
)
|
|
1594
|
+
|
|
1595
|
+
# --num-replicas validation (>= 1 and <= available GPUs) is performed upstream
|
|
1596
|
+
# by _resolve_num_replicas(), which validate_args() calls before
|
|
1597
|
+
# collect_validation_errors(). By the time we get here, num_replicas is known to
|
|
1598
|
+
# be a sane positive int (or None on a no-GPU dev box, in which case cross-
|
|
1599
|
+
# replica divisibility checks are skipped further down).
|
|
1600
|
+
|
|
1601
|
+
# Checks below are ordered to follow the parser sections in _add_train_flags_to /
|
|
1602
|
+
# _add_inference_flags_to, which themselves mirror the sub-dataclass order in
|
|
1603
|
+
# config.py. Within each section, fields appear in the same order they are
|
|
1604
|
+
# registered on the parser. Cross-parameter checks live in the section of the
|
|
1605
|
+
# primary constrained field.
|
|
1606
|
+
|
|
1607
|
+
# ============================================================================
|
|
1608
|
+
# COMMON CHECKS — flags shared by both subcommands
|
|
1609
|
+
# ============================================================================
|
|
1610
|
+
# --hf-repo-id must look like an HF "namespace/name" repo id (shared Pattern B flag).
|
|
1611
|
+
hf_repo_id = _resolve(args, "hf_repo_id", config.hf.repo_id)
|
|
1612
|
+
if hf_repo_id is not None and not _HF_REPO_ID_PATTERN.match(hf_repo_id):
|
|
1613
|
+
errors.append(
|
|
1614
|
+
ValidationError(
|
|
1615
|
+
field="hf.repo_id",
|
|
1616
|
+
current=hf_repo_id,
|
|
1617
|
+
message=f"--hf-repo-id must be a HuggingFace repo id of the form namespace/name, got {hf_repo_id!r}",
|
|
1618
|
+
fix_kind="format",
|
|
1619
|
+
)
|
|
1620
|
+
)
|
|
1621
|
+
|
|
1622
|
+
# Reproducibility (#279, shared flags): the root seed must be non-negative
|
|
1623
|
+
# (np.random.SeedSequence and tf.random.set_seed both reject negatives at runtime)
|
|
1624
|
+
seed = _resolve(args, "seed", config.reproducibility.seed)
|
|
1625
|
+
if seed is not None and seed < 0:
|
|
1626
|
+
errors.append(
|
|
1627
|
+
ValidationError(
|
|
1628
|
+
field="reproducibility.seed",
|
|
1629
|
+
current=seed,
|
|
1630
|
+
message=f"--seed must be a non-negative integer, got {seed}",
|
|
1631
|
+
fix_kind="clamp_low",
|
|
1632
|
+
min_val=0,
|
|
1633
|
+
)
|
|
1634
|
+
)
|
|
1635
|
+
|
|
1636
|
+
# --unseeded is the explicit opt-out of the seeded default (#298); combining it with an
|
|
1637
|
+
# explicit --seed is contradictory — refuse rather than silently picking one
|
|
1638
|
+
if getattr(args, "unseeded", False) and getattr(args, "seed", None) is not None:
|
|
1639
|
+
errors.append(
|
|
1640
|
+
ValidationError(
|
|
1641
|
+
field="reproducibility.seed",
|
|
1642
|
+
current=args.seed,
|
|
1643
|
+
message="--unseeded and --seed are mutually exclusive: pass one or the other",
|
|
1644
|
+
fix_kind="cross_param",
|
|
1645
|
+
)
|
|
1646
|
+
)
|
|
1647
|
+
|
|
1648
|
+
# Same bound for the deprecated explicit RF override (train-only flag; _resolve falls
|
|
1649
|
+
# back to the config default on the inference namespace)
|
|
1650
|
+
rf_seed = _resolve(args, "rf_seed", config.rf.seed)
|
|
1651
|
+
if rf_seed is not None and rf_seed < 0:
|
|
1652
|
+
errors.append(
|
|
1653
|
+
ValidationError(
|
|
1654
|
+
field="rf.seed",
|
|
1655
|
+
current=rf_seed,
|
|
1656
|
+
message=f"--rf-seed must be a non-negative integer, got {rf_seed}",
|
|
1657
|
+
fix_kind="clamp_low",
|
|
1658
|
+
min_val=0,
|
|
1659
|
+
)
|
|
1660
|
+
)
|
|
1661
|
+
|
|
1662
|
+
# ============================================================================
|
|
1663
|
+
# TRAINING-MODE CHECKS — match _add_train_flags_to layout
|
|
1664
|
+
# ============================================================================
|
|
1665
|
+
if cmd == "train":
|
|
1666
|
+
# ---------------------------------------------------------------- BetaVAE
|
|
1667
|
+
# --vae-dense-layer-size must match width_bin // downsample_factor (a Data
|
|
1668
|
+
# constraint that lands on a BetaVAE field — gate on the BetaVAE field).
|
|
1669
|
+
wb = _resolve(args, "width_bin", config.data.width_bin)
|
|
1670
|
+
df = _resolve(args, "downsample_factor", config.data.downsample_factor)
|
|
1671
|
+
vds = _resolve(args, "vae_dense_layer_size", config.beta_vae.dense_layer_size)
|
|
1672
|
+
if wb and df and vds is not None:
|
|
1673
|
+
expected = wb // df
|
|
1674
|
+
if vds != expected:
|
|
1675
|
+
errors.append(
|
|
1676
|
+
ValidationError(
|
|
1677
|
+
field="beta_vae.dense_layer_size",
|
|
1678
|
+
current=vds,
|
|
1679
|
+
message=f"--vae-dense-layer-size ({vds}) must equal width_bin // downsample_factor ({wb} // {df} = {expected})",
|
|
1680
|
+
fix_kind="range",
|
|
1681
|
+
min_val=expected,
|
|
1682
|
+
max_val=expected,
|
|
1683
|
+
)
|
|
1684
|
+
)
|
|
1685
|
+
|
|
1686
|
+
# ---------------------------------------------------------- Random Forest
|
|
1687
|
+
rfmf = _resolve(args, "rf_max_features", config.rf.max_features)
|
|
1688
|
+
if rfmf is not None and not (
|
|
1689
|
+
rfmf in _RF_MAX_FEATURES_STR_VALUES or isinstance(rfmf, (int, float))
|
|
1690
|
+
):
|
|
1691
|
+
errors.append(
|
|
1692
|
+
ValidationError(
|
|
1693
|
+
field="rf.max_features",
|
|
1694
|
+
current=rfmf,
|
|
1695
|
+
message=f"--rf-max-features must be one of {sorted(_RF_MAX_FEATURES_STR_VALUES)} or a number, got {rfmf!r}",
|
|
1696
|
+
fix_kind="enum",
|
|
1697
|
+
allowed=sorted(_RF_MAX_FEATURES_STR_VALUES),
|
|
1698
|
+
)
|
|
1699
|
+
)
|
|
1700
|
+
|
|
1701
|
+
rfj = _resolve(args, "rf_n_jobs", config.rf.n_jobs)
|
|
1702
|
+
n_cores = os.cpu_count() or 1
|
|
1703
|
+
if rfj is not None and not (-1 <= rfj <= n_cores):
|
|
1704
|
+
errors.append(
|
|
1705
|
+
ValidationError(
|
|
1706
|
+
field="rf.n_jobs",
|
|
1707
|
+
current=rfj,
|
|
1708
|
+
message=f"--rf-n-jobs must satisfy -1 <= n_jobs <= cpu_count ({n_cores}), got {rfj}",
|
|
1709
|
+
fix_kind="range",
|
|
1710
|
+
min_val=-1,
|
|
1711
|
+
max_val=n_cores,
|
|
1712
|
+
)
|
|
1713
|
+
)
|
|
1714
|
+
|
|
1715
|
+
# --------------------------------------------------------------------- GPU
|
|
1716
|
+
# (--num-replicas <= 0 lives in the COMMON checks block above; nothing else
|
|
1717
|
+
# in GPUConfig requires train-side validation.)
|
|
1718
|
+
|
|
1719
|
+
# -------------------------------------------------------------------- Data
|
|
1720
|
+
# TODO: Deferred -- time_bin & width_bin must match data shape — requires
|
|
1721
|
+
# loading actual data files; validated at runtime by the data loader.
|
|
1722
|
+
data_path = _resolve(args, "data_path", config.data_path)
|
|
1723
|
+
train_files = _resolve(args, "train_files", config.data.train_files) or []
|
|
1724
|
+
for f in train_files:
|
|
1725
|
+
full = config.get_training_file_path(f, data_path)
|
|
1726
|
+
if not os.path.exists(full):
|
|
1727
|
+
errors.append(
|
|
1728
|
+
ValidationError(
|
|
1729
|
+
field="data.train_files",
|
|
1730
|
+
current=f,
|
|
1731
|
+
message=f"--train-files: file does not exist on disk: {full}",
|
|
1732
|
+
fix_kind="file_exists",
|
|
1733
|
+
)
|
|
1734
|
+
)
|
|
1735
|
+
|
|
1736
|
+
# ---------------------------------------------------------------- Training
|
|
1737
|
+
# Resolve the fields needed by multiple checks up front so the cross-field
|
|
1738
|
+
# validations below can refer to them without re-resolving.
|
|
1739
|
+
ntr = _resolve(args, "num_training_rounds", config.training.num_training_rounds)
|
|
1740
|
+
nsb = _resolve(args, "num_samples_beta_vae", config.training.num_samples_beta_vae)
|
|
1741
|
+
nsr = _resolve(args, "num_samples_rf", config.training.num_samples_rf)
|
|
1742
|
+
tvs = _resolve(args, "train_val_split", config.training.train_val_split)
|
|
1743
|
+
prb = _resolve(args, "per_replica_batch_size", config.training.per_replica_batch_size)
|
|
1744
|
+
eb = _resolve(args, "effective_batch_size", config.training.effective_batch_size)
|
|
1745
|
+
prvb = _resolve(
|
|
1746
|
+
args, "per_replica_val_batch_size", config.training.per_replica_val_batch_size
|
|
1747
|
+
)
|
|
1748
|
+
sic = _resolve(
|
|
1749
|
+
args, "signal_injection_chunk_size", config.training.signal_injection_chunk_size
|
|
1750
|
+
)
|
|
1751
|
+
lvc = _resolve(
|
|
1752
|
+
args,
|
|
1753
|
+
"latent_viz_num_cadences_per_type",
|
|
1754
|
+
config.training.latent_viz_num_cadences_per_type,
|
|
1755
|
+
)
|
|
1756
|
+
cs = _resolve(args, "curriculum_schedule", config.training.curriculum_schedule)
|
|
1757
|
+
|
|
1758
|
+
# NOTE: come back to this later (should we parametrize num_signal_types = 4 in config.py?)
|
|
1759
|
+
# num_samples_beta_vae divisible by 4 (balanced class generation)
|
|
1760
|
+
if nsb is not None and nsb % 4 != 0:
|
|
1761
|
+
errors.append(
|
|
1762
|
+
ValidationError(
|
|
1763
|
+
field="training.num_samples_beta_vae",
|
|
1764
|
+
current=nsb,
|
|
1765
|
+
message=f"--num-samples-beta-vae must be divisible by 4 for balanced class generation, got {nsb}",
|
|
1766
|
+
fix_kind="divisibility",
|
|
1767
|
+
divisor=4,
|
|
1768
|
+
)
|
|
1769
|
+
)
|
|
1770
|
+
# num_samples_rf must be divisible by 4 (class balance) AND by 2 (triplet batches).
|
|
1771
|
+
if nsr is not None and nsr % 4 != 0:
|
|
1772
|
+
errors.append(
|
|
1773
|
+
ValidationError(
|
|
1774
|
+
field="training.num_samples_rf",
|
|
1775
|
+
current=nsr,
|
|
1776
|
+
message=f"--num-samples-rf must be divisible by 4 for balanced class generation, got {nsr}",
|
|
1777
|
+
fix_kind="divisibility",
|
|
1778
|
+
divisor=4,
|
|
1779
|
+
)
|
|
1780
|
+
)
|
|
1781
|
+
if nsr is not None and nsr % 2 != 0:
|
|
1782
|
+
errors.append(
|
|
1783
|
+
ValidationError(
|
|
1784
|
+
field="training.num_samples_rf",
|
|
1785
|
+
current=nsr,
|
|
1786
|
+
message=f"--num-samples-rf must be divisible by 2 for the balanced true/false halves in data generation, got {nsr}",
|
|
1787
|
+
fix_kind="divisibility",
|
|
1788
|
+
divisor=2,
|
|
1789
|
+
)
|
|
1790
|
+
)
|
|
1791
|
+
|
|
1792
|
+
# train_val_split bounds
|
|
1793
|
+
if tvs is not None and not (0 <= tvs <= 1):
|
|
1794
|
+
errors.append(
|
|
1795
|
+
ValidationError(
|
|
1796
|
+
field="training.train_val_split",
|
|
1797
|
+
current=tvs,
|
|
1798
|
+
message=f"--train-val-split must satisfy 0 <= split <= 1, got {tvs}",
|
|
1799
|
+
fix_kind="range",
|
|
1800
|
+
min_val=0.0,
|
|
1801
|
+
max_val=1.0,
|
|
1802
|
+
)
|
|
1803
|
+
)
|
|
1804
|
+
|
|
1805
|
+
# signal_injection_chunk_size divisible by 4 (class balance)
|
|
1806
|
+
if sic is not None and sic % 4 != 0:
|
|
1807
|
+
errors.append(
|
|
1808
|
+
ValidationError(
|
|
1809
|
+
field="training.signal_injection_chunk_size",
|
|
1810
|
+
current=sic,
|
|
1811
|
+
message=f"--signal-injection-chunk-size must be divisible by 4 for balanced class generation, got {sic}",
|
|
1812
|
+
fix_kind="divisibility",
|
|
1813
|
+
divisor=4,
|
|
1814
|
+
)
|
|
1815
|
+
)
|
|
1816
|
+
|
|
1817
|
+
# data_gen_task_size >= 1 (batched memmap worker tasks)
|
|
1818
|
+
dgts = _resolve(args, "data_gen_task_size", config.training.data_gen_task_size)
|
|
1819
|
+
if dgts is not None and dgts < 1:
|
|
1820
|
+
errors.append(
|
|
1821
|
+
ValidationError(
|
|
1822
|
+
field="training.data_gen_task_size",
|
|
1823
|
+
current=dgts,
|
|
1824
|
+
message=f"--data-gen-task-size must be >= 1, got {dgts}",
|
|
1825
|
+
fix_kind="clamp_low",
|
|
1826
|
+
min_val=1,
|
|
1827
|
+
)
|
|
1828
|
+
)
|
|
1829
|
+
|
|
1830
|
+
# Latent traversal: odd step count >= 3 (so the center column is the unperturbed
|
|
1831
|
+
# class-mean decode) and a strictly positive sigma range. No upper step bound:
|
|
1832
|
+
# decode cost scales linearly and stays trivial at any plausible value (99 steps
|
|
1833
|
+
# ~= 800 decoder calls), and an oversized figure is immediately self-evident
|
|
1834
|
+
lts = _resolve(
|
|
1835
|
+
args, "latent_traversal_num_steps", config.training.latent_traversal_num_steps
|
|
1836
|
+
)
|
|
1837
|
+
if lts is not None and (lts < 3 or lts % 2 == 0):
|
|
1838
|
+
errors.append(
|
|
1839
|
+
ValidationError(
|
|
1840
|
+
field="training.latent_traversal_num_steps",
|
|
1841
|
+
current=lts,
|
|
1842
|
+
message=f"--latent-traversal-num-steps must be odd and >= 3 so the center column is the unperturbed decode, got {lts}",
|
|
1843
|
+
# No fix_kind captures "odd and >= 3"; clamp_low is closest and the
|
|
1844
|
+
# proposal seed is the field's default (7 — odd and valid), matching the
|
|
1845
|
+
# max_sigma block below. propose_simple_fix only ever suggests this value.
|
|
1846
|
+
fix_kind="clamp_low",
|
|
1847
|
+
min_val=7,
|
|
1848
|
+
)
|
|
1849
|
+
)
|
|
1850
|
+
ltms = _resolve(
|
|
1851
|
+
args, "latent_traversal_max_sigma", config.training.latent_traversal_max_sigma
|
|
1852
|
+
)
|
|
1853
|
+
if ltms is not None and ltms <= 0:
|
|
1854
|
+
errors.append(
|
|
1855
|
+
ValidationError(
|
|
1856
|
+
field="training.latent_traversal_max_sigma",
|
|
1857
|
+
current=ltms,
|
|
1858
|
+
message=f"--latent-traversal-max-sigma must be > 0, got {ltms}",
|
|
1859
|
+
fix_kind="clamp_low",
|
|
1860
|
+
min_val=3.0, # Proposal seed: the field's default (matches num_steps above)
|
|
1861
|
+
)
|
|
1862
|
+
)
|
|
1863
|
+
|
|
1864
|
+
# Disk budget for the disk-backed round datasets (round_data.py): with overlap enabled
|
|
1865
|
+
# two rounds coexist on disk (round k trains while round k+1 generates), so require
|
|
1866
|
+
# 2.2x one round's estimated size free; 1.1x when overlap is disabled. Estimated from
|
|
1867
|
+
# sample counts only — actual usage tracks the estimate closely since the arrays are
|
|
1868
|
+
# fixed-shape (float32; halved under training.round_array_dtype="float16").
|
|
1869
|
+
nob = _resolve(args, "num_observations", config.data.num_observations)
|
|
1870
|
+
tb = _resolve(args, "time_bins", config.data.time_bins)
|
|
1871
|
+
overlap = _resolve(args, "overlap_data_generation", config.training.overlap_data_generation)
|
|
1872
|
+
round_data_dir = _resolve(args, "round_data_dir", config.training.round_data_dir)
|
|
1873
|
+
if round_data_dir is None:
|
|
1874
|
+
# data_path was resolved in the Data block above; round data are generated
|
|
1875
|
+
# training data, so they live under the training-data root
|
|
1876
|
+
round_data_dir = config.get_training_file_path("round_data", data_path)
|
|
1877
|
+
if all(v is not None for v in (nsb, nob, tb, wb)) and df:
|
|
1878
|
+
element_bytes = 2 if config.training.round_array_dtype == "float16" else 4
|
|
1879
|
+
round_nbytes = _estimate_round_data_nbytes(
|
|
1880
|
+
nsb, nob, tb, wb // df, bytes_per_element=element_bytes
|
|
1881
|
+
)
|
|
1882
|
+
required_factor = 2.2 if overlap else 1.1
|
|
1883
|
+
required_bytes = required_factor * round_nbytes
|
|
1884
|
+
try:
|
|
1885
|
+
free_bytes = shutil.disk_usage(_nearest_existing_ancestor(round_data_dir)).free
|
|
1886
|
+
except OSError as e:
|
|
1887
|
+
logger.warning(
|
|
1888
|
+
f"Could not check free disk space for --round-data-dir "
|
|
1889
|
+
f"({round_data_dir}): {e} — skipping the disk-budget check"
|
|
1890
|
+
)
|
|
1891
|
+
else:
|
|
1892
|
+
if free_bytes < required_bytes:
|
|
1893
|
+
errors.append(
|
|
1894
|
+
ValidationError(
|
|
1895
|
+
field="training.round_data_dir",
|
|
1896
|
+
current=round_data_dir,
|
|
1897
|
+
message=(
|
|
1898
|
+
f"--round-data-dir ({round_data_dir}) needs >= "
|
|
1899
|
+
f"{required_bytes / 1e9:.1f} GB free ({required_factor}x one "
|
|
1900
|
+
f"round's ~{round_nbytes / 1e9:.1f} GB"
|
|
1901
|
+
f"{' with data-generation overlap enabled' if overlap else ''}), "
|
|
1902
|
+
f"but only {free_bytes / 1e9:.1f} GB is available. Free up disk "
|
|
1903
|
+
f"space, point --round-data-dir at a larger volume, reduce "
|
|
1904
|
+
f"--num-samples-beta-vae, or pass --no-overlap-data-generation "
|
|
1905
|
+
f"to halve the requirement"
|
|
1906
|
+
),
|
|
1907
|
+
fix_kind="file_exists",
|
|
1908
|
+
)
|
|
1909
|
+
)
|
|
1910
|
+
|
|
1911
|
+
# SNR sanity (positivity + curriculum ordering)
|
|
1912
|
+
snr_base = _resolve(args, "snr_base", config.training.snr_base)
|
|
1913
|
+
snr_init = _resolve(args, "initial_snr_range", config.training.initial_snr_range)
|
|
1914
|
+
snr_fin = _resolve(args, "final_snr_range", config.training.final_snr_range)
|
|
1915
|
+
if snr_base is not None and snr_base <= 0:
|
|
1916
|
+
errors.append(
|
|
1917
|
+
ValidationError(
|
|
1918
|
+
field="training.snr_base",
|
|
1919
|
+
current=snr_base,
|
|
1920
|
+
message=f"--snr-base must be > 0, got {snr_base}",
|
|
1921
|
+
fix_kind="clamp_low",
|
|
1922
|
+
min_val=1,
|
|
1923
|
+
)
|
|
1924
|
+
)
|
|
1925
|
+
if snr_init is not None and snr_init <= 0:
|
|
1926
|
+
errors.append(
|
|
1927
|
+
ValidationError(
|
|
1928
|
+
field="training.initial_snr_range",
|
|
1929
|
+
current=snr_init,
|
|
1930
|
+
message=f"--initial-snr-range must be > 0, got {snr_init}",
|
|
1931
|
+
fix_kind="clamp_low",
|
|
1932
|
+
min_val=1,
|
|
1933
|
+
)
|
|
1934
|
+
)
|
|
1935
|
+
if snr_fin is not None and snr_fin <= 0:
|
|
1936
|
+
errors.append(
|
|
1937
|
+
ValidationError(
|
|
1938
|
+
field="training.final_snr_range",
|
|
1939
|
+
current=snr_fin,
|
|
1940
|
+
message=f"--final-snr-range must be > 0, got {snr_fin}",
|
|
1941
|
+
fix_kind="clamp_low",
|
|
1942
|
+
min_val=1,
|
|
1943
|
+
)
|
|
1944
|
+
)
|
|
1945
|
+
if snr_init is not None and snr_fin is not None and snr_init < snr_fin:
|
|
1946
|
+
errors.append(
|
|
1947
|
+
ValidationError(
|
|
1948
|
+
field="training.initial_snr_range",
|
|
1949
|
+
current=snr_init,
|
|
1950
|
+
message=f"--initial-snr-range ({snr_init}) must be >= --final-snr-range ({snr_fin}) — curriculum schedules from easy to hard",
|
|
1951
|
+
fix_kind="clamp_low",
|
|
1952
|
+
min_val=snr_fin,
|
|
1953
|
+
)
|
|
1954
|
+
)
|
|
1955
|
+
|
|
1956
|
+
# Curriculum schedule enum + exponential / step parameters
|
|
1957
|
+
if cs is not None and cs not in _CURRICULUM_SCHEDULES:
|
|
1958
|
+
errors.append(
|
|
1959
|
+
ValidationError(
|
|
1960
|
+
field="training.curriculum_schedule",
|
|
1961
|
+
current=cs,
|
|
1962
|
+
message=f"--curriculum-schedule must be one of {sorted(_CURRICULUM_SCHEDULES)}, got {cs!r}",
|
|
1963
|
+
fix_kind="enum",
|
|
1964
|
+
allowed=sorted(_CURRICULUM_SCHEDULES),
|
|
1965
|
+
)
|
|
1966
|
+
)
|
|
1967
|
+
|
|
1968
|
+
edr = _resolve(args, "exponential_decay_rate", config.training.exponential_decay_rate)
|
|
1969
|
+
# exponential_decay_rate only governs the exponential schedule; skip it otherwise.
|
|
1970
|
+
if cs == "exponential" and edr is not None and edr >= 0:
|
|
1971
|
+
errors.append(
|
|
1972
|
+
ValidationError(
|
|
1973
|
+
field="training.exponential_decay_rate",
|
|
1974
|
+
current=edr,
|
|
1975
|
+
message=f"--exponential-decay-rate must be < 0 (more negative = faster difficulty ramp), got {edr}",
|
|
1976
|
+
fix_kind="clamp_high",
|
|
1977
|
+
max_val=-0.01,
|
|
1978
|
+
)
|
|
1979
|
+
)
|
|
1980
|
+
|
|
1981
|
+
ser = _resolve(args, "step_easy_rounds", config.training.step_easy_rounds)
|
|
1982
|
+
shr = _resolve(args, "step_hard_rounds", config.training.step_hard_rounds)
|
|
1983
|
+
# step_easy_rounds / step_hard_rounds only apply to the step schedule; skip their
|
|
1984
|
+
# range checks otherwise.
|
|
1985
|
+
if cs == "step" and ntr is not None and ser is not None and not (0 <= ser <= ntr):
|
|
1986
|
+
errors.append(
|
|
1987
|
+
ValidationError(
|
|
1988
|
+
field="training.step_easy_rounds",
|
|
1989
|
+
current=ser,
|
|
1990
|
+
message=f"--step-easy-rounds must satisfy 0 <= rounds <= num_training_rounds ({ntr}), got {ser}",
|
|
1991
|
+
fix_kind="range",
|
|
1992
|
+
min_val=0,
|
|
1993
|
+
max_val=ntr,
|
|
1994
|
+
)
|
|
1995
|
+
)
|
|
1996
|
+
if cs == "step" and ntr is not None and shr is not None and not (0 <= shr <= ntr):
|
|
1997
|
+
errors.append(
|
|
1998
|
+
ValidationError(
|
|
1999
|
+
field="training.step_hard_rounds",
|
|
2000
|
+
current=shr,
|
|
2001
|
+
message=f"--step-hard-rounds must satisfy 0 <= rounds <= num_training_rounds ({ntr}), got {shr}",
|
|
2002
|
+
fix_kind="range",
|
|
2003
|
+
min_val=0,
|
|
2004
|
+
max_val=ntr,
|
|
2005
|
+
)
|
|
2006
|
+
)
|
|
2007
|
+
# step_easy + step_hard only need to sum to num_training_rounds when step
|
|
2008
|
+
# schedule is selected.
|
|
2009
|
+
if (
|
|
2010
|
+
cs == "step"
|
|
2011
|
+
and ntr is not None
|
|
2012
|
+
and ser is not None
|
|
2013
|
+
and shr is not None
|
|
2014
|
+
and ser + shr != ntr
|
|
2015
|
+
):
|
|
2016
|
+
errors.append(
|
|
2017
|
+
ValidationError(
|
|
2018
|
+
field="training.step_easy_rounds",
|
|
2019
|
+
current=ser,
|
|
2020
|
+
message=f"--step-easy-rounds + --step-hard-rounds ({ser} + {shr} = {ser + shr}) must equal --num-training-rounds ({ntr}) when curriculum_schedule=step",
|
|
2021
|
+
fix_kind="cross_param",
|
|
2022
|
+
extra={"step_hard_rounds": shr, "num_training_rounds": ntr},
|
|
2023
|
+
)
|
|
2024
|
+
)
|
|
2025
|
+
|
|
2026
|
+
# Learning rate / patience
|
|
2027
|
+
blr = _resolve(args, "base_learning_rate", config.training.base_learning_rate)
|
|
2028
|
+
mlr = _resolve(args, "min_learning_rate", config.training.min_learning_rate)
|
|
2029
|
+
if blr is not None and mlr is not None and blr < mlr:
|
|
2030
|
+
errors.append(
|
|
2031
|
+
ValidationError(
|
|
2032
|
+
field="training.base_learning_rate",
|
|
2033
|
+
current=blr,
|
|
2034
|
+
message=f"--base-learning-rate ({blr}) must be >= --min-learning-rate ({mlr})",
|
|
2035
|
+
fix_kind="clamp_low",
|
|
2036
|
+
min_val=mlr,
|
|
2037
|
+
)
|
|
2038
|
+
)
|
|
2039
|
+
mpi = _resolve(args, "min_pct_improvement", config.training.min_pct_improvement)
|
|
2040
|
+
if mpi is not None and mpi < 0:
|
|
2041
|
+
errors.append(
|
|
2042
|
+
ValidationError(
|
|
2043
|
+
field="training.min_pct_improvement",
|
|
2044
|
+
current=mpi,
|
|
2045
|
+
message=f"--min-pct-improvement must be >= 0, got {mpi}",
|
|
2046
|
+
fix_kind="clamp_low",
|
|
2047
|
+
min_val=0.0,
|
|
2048
|
+
)
|
|
2049
|
+
)
|
|
2050
|
+
pt = _resolve(args, "patience_threshold", config.training.patience_threshold)
|
|
2051
|
+
if pt is not None and pt < 1:
|
|
2052
|
+
errors.append(
|
|
2053
|
+
ValidationError(
|
|
2054
|
+
field="training.patience_threshold",
|
|
2055
|
+
current=pt,
|
|
2056
|
+
message=f"--patience-threshold must be >= 1 (must wait at least one epoch before reducing LR), got {pt}",
|
|
2057
|
+
fix_kind="clamp_low",
|
|
2058
|
+
min_val=1,
|
|
2059
|
+
)
|
|
2060
|
+
)
|
|
2061
|
+
lrf = _resolve(args, "lr_reduction_factor", config.training.reduction_factor)
|
|
2062
|
+
if lrf is not None and not (0 < lrf < 1):
|
|
2063
|
+
errors.append(
|
|
2064
|
+
ValidationError(
|
|
2065
|
+
field="training.reduction_factor",
|
|
2066
|
+
current=lrf,
|
|
2067
|
+
message=f"--lr-reduction-factor must satisfy 0 < factor < 1 (it's a reduction multiplier), got {lrf}",
|
|
2068
|
+
fix_kind="range",
|
|
2069
|
+
min_val=0.01,
|
|
2070
|
+
max_val=0.99,
|
|
2071
|
+
)
|
|
2072
|
+
)
|
|
2073
|
+
|
|
2074
|
+
# Retries (training-scoped — args.max_retries / args.retry_delay also exist
|
|
2075
|
+
# in the inference subparser, so the Pattern C gate on cmd kept this clean).
|
|
2076
|
+
mr = _resolve(args, "max_retries", config.training.max_retries)
|
|
2077
|
+
if mr is not None and mr < 1:
|
|
2078
|
+
errors.append(
|
|
2079
|
+
ValidationError(
|
|
2080
|
+
field="training.max_retries",
|
|
2081
|
+
current=mr,
|
|
2082
|
+
message=f"--max-retries must be >= 1 (the run needs at least one attempt), got {mr}",
|
|
2083
|
+
fix_kind="clamp_low",
|
|
2084
|
+
min_val=1,
|
|
2085
|
+
)
|
|
2086
|
+
)
|
|
2087
|
+
rd = _resolve(args, "retry_delay", config.training.retry_delay)
|
|
2088
|
+
if rd is not None and rd < 0:
|
|
2089
|
+
errors.append(
|
|
2090
|
+
ValidationError(
|
|
2091
|
+
field="training.retry_delay",
|
|
2092
|
+
current=rd,
|
|
2093
|
+
message=f"--retry-delay must be >= 0, got {rd}",
|
|
2094
|
+
fix_kind="clamp_low",
|
|
2095
|
+
min_val=0,
|
|
2096
|
+
)
|
|
2097
|
+
)
|
|
2098
|
+
|
|
2099
|
+
# Cross-replica batch / sample constraints. Tied to multiple Training fields
|
|
2100
|
+
# (effective_batch_size, per_replica_*_batch_size, num_samples_*, latent_viz_*)
|
|
2101
|
+
# plus num_replicas, so they live at the end of the Training section.
|
|
2102
|
+
if all(v is not None for v in (prb, eb, prvb, lvc, nsb, nsr, tvs)):
|
|
2103
|
+
if num_replicas is None:
|
|
2104
|
+
logger.warning(
|
|
2105
|
+
"GPU count unknown (TF reports 0 GPUs or is unavailable) — skipping "
|
|
2106
|
+
"cross-replica divisibility checks; they need a TF-visible GPU count "
|
|
2107
|
+
"even when --num-replicas is passed. The effective-batch-size check "
|
|
2108
|
+
"re-runs at training time once the real replica count is known."
|
|
2109
|
+
)
|
|
2110
|
+
elif num_replicas < 1:
|
|
2111
|
+
# _resolve_num_replicas would have raised before we got here for a real
|
|
2112
|
+
# pipeline invocation. Reachable only via direct callers (e.g.
|
|
2113
|
+
# utils/find_optimal_configs.py) that bypass validate_args; skip the
|
|
2114
|
+
# cross-replica section to avoid div-by-zero.
|
|
2115
|
+
pass
|
|
2116
|
+
else:
|
|
2117
|
+
train_samples = nsb * tvs
|
|
2118
|
+
val_samples = nsb * (1 - tvs)
|
|
2119
|
+
global_train_batch = prb * num_replicas
|
|
2120
|
+
global_val_batch = prvb * num_replicas
|
|
2121
|
+
latent_total = lvc * 4
|
|
2122
|
+
|
|
2123
|
+
if not (global_train_batch <= eb <= train_samples):
|
|
2124
|
+
errors.append(
|
|
2125
|
+
ValidationError(
|
|
2126
|
+
field="training.effective_batch_size",
|
|
2127
|
+
current=eb,
|
|
2128
|
+
message=f"--effective-batch-size ({eb}) must satisfy per_replica_batch_size * num_replicas ({prb} * {num_replicas} = {global_train_batch}) <= effective_batch_size <= num_samples_beta_vae * train_val_split ({nsb} * {tvs} = {train_samples})",
|
|
2129
|
+
fix_kind="cross_param",
|
|
2130
|
+
extra={
|
|
2131
|
+
"per_replica_batch_size": prb,
|
|
2132
|
+
"num_replicas": num_replicas,
|
|
2133
|
+
"num_samples_beta_vae": nsb,
|
|
2134
|
+
"train_val_split": tvs,
|
|
2135
|
+
},
|
|
2136
|
+
)
|
|
2137
|
+
)
|
|
2138
|
+
if global_val_batch > val_samples:
|
|
2139
|
+
errors.append(
|
|
2140
|
+
ValidationError(
|
|
2141
|
+
field="training.per_replica_val_batch_size",
|
|
2142
|
+
current=prvb,
|
|
2143
|
+
message=f"--per-replica-val-batch-size * num_replicas ({prvb} * {num_replicas} = {global_val_batch}) must be <= num_samples_beta_vae * (1 - train_val_split) ({nsb} * {1 - tvs:.4f} = {val_samples})",
|
|
2144
|
+
fix_kind="cross_param",
|
|
2145
|
+
)
|
|
2146
|
+
)
|
|
2147
|
+
# The RF splits num_samples_rf by train_val_split (like the beta-VAE), so the
|
|
2148
|
+
# val set the runtime actually builds is nsr * (1 - train_val_split), then
|
|
2149
|
+
# trims it to a multiple of the global val batch. Validate that val portion,
|
|
2150
|
+
# not raw nsr — otherwise train_random_forest fails at runtime with
|
|
2151
|
+
# "val_steps < 1: n_val_trimmed (0) ..." when the val split is too small.
|
|
2152
|
+
rf_val_samples = round(nsr * (1 - tvs))
|
|
2153
|
+
if global_val_batch > rf_val_samples:
|
|
2154
|
+
errors.append(
|
|
2155
|
+
ValidationError(
|
|
2156
|
+
field="training.num_samples_rf",
|
|
2157
|
+
current=nsr,
|
|
2158
|
+
message=f"num_samples_rf * (1 - train_val_split) ({nsr} * {1 - tvs:.4f} = {rf_val_samples}) must be >= per_replica_val_batch_size * num_replicas ({prvb} * {num_replicas} = {global_val_batch})",
|
|
2159
|
+
fix_kind="cross_param",
|
|
2160
|
+
)
|
|
2161
|
+
)
|
|
2162
|
+
# The RF's TRAIN split is trimmed to a multiple of effective_batch_size at
|
|
2163
|
+
# runtime (create_train_val_split); below one full batch it trims to ZERO
|
|
2164
|
+
# and the run dies at rf_train with "train_steps < 1" — after the beta-VAE
|
|
2165
|
+
# rounds already trained (#297). Divisibility is deliberately NOT required:
|
|
2166
|
+
# the defaults themselves rely on the trim (79,872 % 7,680 != 0); only the
|
|
2167
|
+
# >= one-effective-batch floor is a hard constraint. int(), not round():
|
|
2168
|
+
# the runtime split truncates PER LABEL (sum of int(count_l * tvs)), so
|
|
2169
|
+
# round()'s upward half could pass a config the runtime kills. Truncating
|
|
2170
|
+
# the total still over-estimates the per-label sum by at most
|
|
2171
|
+
# (n_labels - 1) samples — negligible against a multi-thousand-row batch
|
|
2172
|
+
# and covered by the runtime backstop for exact-floor pathologies.
|
|
2173
|
+
rf_train_samples = int(nsr * tvs)
|
|
2174
|
+
if eb > rf_train_samples:
|
|
2175
|
+
errors.append(
|
|
2176
|
+
ValidationError(
|
|
2177
|
+
field="training.num_samples_rf",
|
|
2178
|
+
current=nsr,
|
|
2179
|
+
message=f"num_samples_rf * train_val_split ({nsr} * {tvs:.4f} = {rf_train_samples}) must be >= --effective-batch-size ({eb}) — the RF train split trims to a multiple of the effective batch, so below one batch it trims to zero and rf_train fails at runtime",
|
|
2180
|
+
fix_kind="cross_param",
|
|
2181
|
+
)
|
|
2182
|
+
)
|
|
2183
|
+
if latent_total > val_samples:
|
|
2184
|
+
errors.append(
|
|
2185
|
+
ValidationError(
|
|
2186
|
+
field="training.latent_viz_num_cadences_per_type",
|
|
2187
|
+
current=lvc,
|
|
2188
|
+
message=f"--latent-viz-num-cadences-per-type * 4 ({lvc} * 4 = {latent_total}) must be <= num_samples_beta_vae * (1 - train_val_split) ({val_samples})",
|
|
2189
|
+
fix_kind="cross_param",
|
|
2190
|
+
)
|
|
2191
|
+
)
|
|
2192
|
+
if eb % global_train_batch != 0:
|
|
2193
|
+
errors.append(
|
|
2194
|
+
ValidationError(
|
|
2195
|
+
field="training.effective_batch_size",
|
|
2196
|
+
current=eb,
|
|
2197
|
+
message=f"--effective-batch-size ({eb}) must be divisible by per_replica_batch_size * num_replicas ({global_train_batch})",
|
|
2198
|
+
fix_kind="cross_param",
|
|
2199
|
+
divisor=global_train_batch,
|
|
2200
|
+
)
|
|
2201
|
+
)
|
|
2202
|
+
# Round to int — train_samples/val_samples come from `nsb * tvs` and
|
|
2203
|
+
# `nsb * (1 - tvs)` which suffer IEEE-754 noise (e.g.
|
|
2204
|
+
# 499200 * 0.2 = 99839.999...). Use round() so the intended sample
|
|
2205
|
+
# counts survive the cast.
|
|
2206
|
+
train_samples_i = round(train_samples)
|
|
2207
|
+
val_samples_i = round(val_samples)
|
|
2208
|
+
if train_samples_i % eb != 0:
|
|
2209
|
+
errors.append(
|
|
2210
|
+
ValidationError(
|
|
2211
|
+
field="training.num_samples_beta_vae",
|
|
2212
|
+
current=nsb,
|
|
2213
|
+
message=f"num_samples_beta_vae * train_val_split ({train_samples_i}) must be divisible by --effective-batch-size ({eb})",
|
|
2214
|
+
fix_kind="cross_param",
|
|
2215
|
+
divisor=eb,
|
|
2216
|
+
)
|
|
2217
|
+
)
|
|
2218
|
+
if val_samples_i % global_val_batch != 0:
|
|
2219
|
+
errors.append(
|
|
2220
|
+
ValidationError(
|
|
2221
|
+
field="training.num_samples_beta_vae",
|
|
2222
|
+
current=nsb,
|
|
2223
|
+
message=f"num_samples_beta_vae * (1 - train_val_split) ({val_samples_i}) must be divisible by per_replica_val_batch_size * num_replicas ({global_val_batch})",
|
|
2224
|
+
fix_kind="cross_param",
|
|
2225
|
+
divisor=global_val_batch,
|
|
2226
|
+
)
|
|
2227
|
+
)
|
|
2228
|
+
if nsr % global_val_batch != 0:
|
|
2229
|
+
errors.append(
|
|
2230
|
+
ValidationError(
|
|
2231
|
+
field="training.num_samples_rf",
|
|
2232
|
+
current=nsr,
|
|
2233
|
+
message=f"--num-samples-rf ({nsr}) must be divisible by per_replica_val_batch_size * num_replicas ({global_val_batch})",
|
|
2234
|
+
fix_kind="cross_param",
|
|
2235
|
+
divisor=global_val_batch,
|
|
2236
|
+
)
|
|
2237
|
+
)
|
|
2238
|
+
if latent_total % global_val_batch != 0:
|
|
2239
|
+
errors.append(
|
|
2240
|
+
ValidationError(
|
|
2241
|
+
field="training.latent_viz_num_cadences_per_type",
|
|
2242
|
+
current=lvc,
|
|
2243
|
+
message=f"--latent-viz-num-cadences-per-type * 4 ({latent_total}) must be divisible by per_replica_val_batch_size * num_replicas ({global_val_batch})",
|
|
2244
|
+
fix_kind="cross_param",
|
|
2245
|
+
divisor=global_val_batch,
|
|
2246
|
+
)
|
|
2247
|
+
)
|
|
2248
|
+
|
|
2249
|
+
# -------------------------------------------------------------- Checkpoint
|
|
2250
|
+
# TODO: Deferred -- save_tag uniqueness check requires the DB (init_db runs
|
|
2251
|
+
# after validate_args).
|
|
2252
|
+
load_tag = _resolve(args, "load_tag", config.checkpoint.load_tag)
|
|
2253
|
+
if load_tag is not None and not _LOAD_TAG_PATTERN.match(load_tag):
|
|
2254
|
+
errors.append(
|
|
2255
|
+
ValidationError(
|
|
2256
|
+
field="checkpoint.load_tag",
|
|
2257
|
+
current=load_tag,
|
|
2258
|
+
message=f"--load-tag must be a full run tag ({{test|train|inf|bench}}_YYYYMMDD_HHMMSS) or round_XX; got {load_tag!r}",
|
|
2259
|
+
fix_kind="format",
|
|
2260
|
+
)
|
|
2261
|
+
)
|
|
2262
|
+
|
|
2263
|
+
# Resume footgun (issue #142): per-round checkpoints are saved under the checkpoints/
|
|
2264
|
+
# subdirectory, so `--load-tag round_XX` without `--load-dir checkpoints` searches the
|
|
2265
|
+
# models root instead and used to silently resume from a stale, unrelated model.
|
|
2266
|
+
load_dir = _resolve(args, "load_dir", config.checkpoint.load_dir)
|
|
2267
|
+
if (
|
|
2268
|
+
load_tag is not None
|
|
2269
|
+
and _ROUND_TAG_PATTERN.match(load_tag)
|
|
2270
|
+
and load_dir != "checkpoints"
|
|
2271
|
+
):
|
|
2272
|
+
errors.append(
|
|
2273
|
+
ValidationError(
|
|
2274
|
+
field="checkpoint.load_dir",
|
|
2275
|
+
current=load_dir,
|
|
2276
|
+
message=(
|
|
2277
|
+
f"--load-tag {load_tag!r} names a per-round checkpoint, and those are "
|
|
2278
|
+
f"saved under the 'checkpoints/' subdirectory — pass --load-dir "
|
|
2279
|
+
f"checkpoints to resume from it. (If you genuinely keep a final model "
|
|
2280
|
+
f"tagged {load_tag!r} in the models root, re-save it under a full run "
|
|
2281
|
+
f"tag ({{test|train|inf|bench}}_YYYYMMDD_HHMMSS) and load that instead.)"
|
|
2282
|
+
),
|
|
2283
|
+
fix_kind="cross_param",
|
|
2284
|
+
)
|
|
2285
|
+
)
|
|
2286
|
+
|
|
2287
|
+
sr = _resolve(args, "start_round", config.checkpoint.start_round)
|
|
2288
|
+
if sr is not None and ntr is not None and not (1 <= sr <= ntr):
|
|
2289
|
+
errors.append(
|
|
2290
|
+
ValidationError(
|
|
2291
|
+
field="checkpoint.start_round",
|
|
2292
|
+
current=sr,
|
|
2293
|
+
message=f"--start-round must satisfy 1 <= round <= num_training_rounds ({ntr}), got {sr}",
|
|
2294
|
+
fix_kind="range",
|
|
2295
|
+
min_val=1,
|
|
2296
|
+
max_val=ntr,
|
|
2297
|
+
)
|
|
2298
|
+
)
|
|
2299
|
+
|
|
2300
|
+
save_tag = getattr(args, "save_tag", None)
|
|
2301
|
+
if save_tag is not None and not _SAVE_TAG_PATTERN.match(save_tag):
|
|
2302
|
+
errors.append(
|
|
2303
|
+
ValidationError(
|
|
2304
|
+
field="checkpoint.save_tag",
|
|
2305
|
+
current=save_tag,
|
|
2306
|
+
message=f"--save-tag must be one of: test, train, inf, bench (the datetime is appended automatically); got {save_tag!r}",
|
|
2307
|
+
fix_kind="format",
|
|
2308
|
+
)
|
|
2309
|
+
)
|
|
2310
|
+
|
|
2311
|
+
# ============================================================================
|
|
2312
|
+
# INFERENCE-MODE CHECKS — match _add_inference_flags_to layout
|
|
2313
|
+
# ============================================================================
|
|
2314
|
+
if cmd == "inference":
|
|
2315
|
+
# ----------------------------------------------------- Inference model artifacts
|
|
2316
|
+
# The encoder/RF/config artifact trio is all-or-none: with all three paths given
|
|
2317
|
+
# they must each exist on disk; with none given, main.py's
|
|
2318
|
+
# resolve_inference_artifacts() has already filled `args` with HuggingFace-
|
|
2319
|
+
# downloaded cache paths before validation runs (utility scripts that skip that
|
|
2320
|
+
# step simply defer to the Hub default). A partial trio is an error — mixing local
|
|
2321
|
+
# and Hub-sourced artifacts would silently pair mismatched models.
|
|
2322
|
+
artifact_specs = (
|
|
2323
|
+
("encoder_path", "encoder_path", "--encoder-path"),
|
|
2324
|
+
("rf_path", "rf_path", "--rf-path"),
|
|
2325
|
+
("config_path", "config_path", "--config-path"),
|
|
2326
|
+
)
|
|
2327
|
+
artifact_paths = {
|
|
2328
|
+
arg_name: _resolve(args, arg_name, getattr(config.inference, cfg_attr))
|
|
2329
|
+
for arg_name, cfg_attr, _flag in artifact_specs
|
|
2330
|
+
}
|
|
2331
|
+
n_provided = sum(path is not None for path in artifact_paths.values())
|
|
2332
|
+
for arg_name, cfg_attr, flag in artifact_specs:
|
|
2333
|
+
path = artifact_paths[arg_name]
|
|
2334
|
+
if path is None and 0 < n_provided < len(artifact_specs):
|
|
2335
|
+
errors.append(
|
|
2336
|
+
ValidationError(
|
|
2337
|
+
field=f"inference.{cfg_attr}",
|
|
2338
|
+
current=None,
|
|
2339
|
+
message=f"{flag} is missing: provide all three of --encoder-path/--rf-path/--config-path, or omit all three to download the artifacts from the HuggingFace Hub",
|
|
2340
|
+
fix_kind="file_exists",
|
|
2341
|
+
)
|
|
2342
|
+
)
|
|
2343
|
+
elif path is not None and not os.path.exists(path):
|
|
2344
|
+
errors.append(
|
|
2345
|
+
ValidationError(
|
|
2346
|
+
field=f"inference.{cfg_attr}",
|
|
2347
|
+
current=path,
|
|
2348
|
+
message=f"{flag}: file does not exist on disk: {path}",
|
|
2349
|
+
fix_kind="file_exists",
|
|
2350
|
+
)
|
|
2351
|
+
)
|
|
2352
|
+
|
|
2353
|
+
# -------------------------------------------------------------------- Data
|
|
2354
|
+
data_path = _resolve(args, "data_path", config.data_path)
|
|
2355
|
+
test_files = _resolve(args, "test_files", config.data.test_files) or []
|
|
2356
|
+
for f in test_files:
|
|
2357
|
+
full = config.get_test_file_path(f, data_path)
|
|
2358
|
+
if not os.path.exists(full):
|
|
2359
|
+
errors.append(
|
|
2360
|
+
ValidationError(
|
|
2361
|
+
field="data.test_files",
|
|
2362
|
+
current=f,
|
|
2363
|
+
message=f"--test-files: file does not exist on disk: {full}",
|
|
2364
|
+
fix_kind="file_exists",
|
|
2365
|
+
)
|
|
2366
|
+
)
|
|
2367
|
+
inf_files = _resolve(args, "inference_files", config.data.inference_files)
|
|
2368
|
+
for f in inf_files or []:
|
|
2369
|
+
full = config.get_inference_file_path(f, data_path)
|
|
2370
|
+
if not os.path.exists(full):
|
|
2371
|
+
errors.append(
|
|
2372
|
+
ValidationError(
|
|
2373
|
+
field="data.inference_files",
|
|
2374
|
+
current=f,
|
|
2375
|
+
message=f"--inference-files: file does not exist on disk: {full}",
|
|
2376
|
+
fix_kind="file_exists",
|
|
2377
|
+
)
|
|
2378
|
+
)
|
|
2379
|
+
|
|
2380
|
+
# --------------------------------------------------------------- Inference
|
|
2381
|
+
ct = _resolve(args, "classification_threshold", config.inference.classification_threshold)
|
|
2382
|
+
if ct is not None and not (0 <= ct <= 1):
|
|
2383
|
+
errors.append(
|
|
2384
|
+
ValidationError(
|
|
2385
|
+
field="inference.classification_threshold",
|
|
2386
|
+
current=ct,
|
|
2387
|
+
message=f"--classification-threshold must satisfy 0 <= threshold <= 1, got {ct}",
|
|
2388
|
+
fix_kind="range",
|
|
2389
|
+
min_val=0.0,
|
|
2390
|
+
max_val=1.0,
|
|
2391
|
+
)
|
|
2392
|
+
)
|
|
2393
|
+
|
|
2394
|
+
# Two-pass cascade (#282): the screen must be MORE permissive than the science
|
|
2395
|
+
# threshold — pass 1 only exists to reject cheap certain-negatives
|
|
2396
|
+
st = _resolve(args, "screening_threshold", config.inference.screening_threshold)
|
|
2397
|
+
if st is not None and not (0 <= st <= 1):
|
|
2398
|
+
errors.append(
|
|
2399
|
+
ValidationError(
|
|
2400
|
+
field="inference.screening_threshold",
|
|
2401
|
+
current=st,
|
|
2402
|
+
message=f"--screening-threshold must satisfy 0 <= threshold <= 1, got {st}",
|
|
2403
|
+
fix_kind="range",
|
|
2404
|
+
min_val=0.0,
|
|
2405
|
+
max_val=1.0,
|
|
2406
|
+
)
|
|
2407
|
+
)
|
|
2408
|
+
elif st is not None and ct is not None and st > ct:
|
|
2409
|
+
errors.append(
|
|
2410
|
+
ValidationError(
|
|
2411
|
+
field="inference.screening_threshold",
|
|
2412
|
+
current=st,
|
|
2413
|
+
message=(
|
|
2414
|
+
f"--screening-threshold ({st}) must not exceed "
|
|
2415
|
+
f"--classification-threshold ({ct}): the pass-1 screen must be the "
|
|
2416
|
+
"more permissive of the two (it can only ever REMOVE candidates)"
|
|
2417
|
+
),
|
|
2418
|
+
fix_kind="cross_param",
|
|
2419
|
+
)
|
|
2420
|
+
)
|
|
2421
|
+
|
|
2422
|
+
mc_draws = _resolve(args, "mc_draws", config.inference.mc_draws)
|
|
2423
|
+
if mc_draws is not None and mc_draws < 1:
|
|
2424
|
+
errors.append(
|
|
2425
|
+
ValidationError(
|
|
2426
|
+
field="inference.mc_draws",
|
|
2427
|
+
current=mc_draws,
|
|
2428
|
+
message=f"--mc-draws must be a positive integer, got {mc_draws}",
|
|
2429
|
+
fix_kind="clamp_low",
|
|
2430
|
+
min_val=1,
|
|
2431
|
+
)
|
|
2432
|
+
)
|
|
2433
|
+
|
|
2434
|
+
cloud_size = _resolve(args, "reference_cloud_size", config.inference.reference_cloud_size)
|
|
2435
|
+
if cloud_size is not None and cloud_size < 0:
|
|
2436
|
+
errors.append(
|
|
2437
|
+
ValidationError(
|
|
2438
|
+
field="inference.reference_cloud_size",
|
|
2439
|
+
current=cloud_size,
|
|
2440
|
+
message=f"--reference-cloud-size must be >= 0, got {cloud_size}",
|
|
2441
|
+
fix_kind="clamp_low",
|
|
2442
|
+
min_val=0,
|
|
2443
|
+
)
|
|
2444
|
+
)
|
|
2445
|
+
|
|
2446
|
+
prefetch_depth = _resolve(args, "prefetch_depth", config.inference.prefetch_depth)
|
|
2447
|
+
if prefetch_depth is not None and prefetch_depth < 1:
|
|
2448
|
+
errors.append(
|
|
2449
|
+
ValidationError(
|
|
2450
|
+
field="inference.prefetch_depth",
|
|
2451
|
+
current=prefetch_depth,
|
|
2452
|
+
message=f"--prefetch-depth must be a positive integer, got {prefetch_depth}",
|
|
2453
|
+
fix_kind="clamp_low",
|
|
2454
|
+
min_val=1,
|
|
2455
|
+
)
|
|
2456
|
+
)
|
|
2457
|
+
|
|
2458
|
+
# The CLI enforces choices=["full", "new"], but the config field can be set
|
|
2459
|
+
# programmatically (or by a hand-edited config file passed through a future
|
|
2460
|
+
# surface) — a typo would otherwise silently render full-scope (#301 review note)
|
|
2461
|
+
viz_scope = _resolve(args, "inference_viz_scope", config.inference.inference_viz_scope)
|
|
2462
|
+
if viz_scope not in (None, "full", "new"):
|
|
2463
|
+
errors.append(
|
|
2464
|
+
ValidationError(
|
|
2465
|
+
field="inference.inference_viz_scope",
|
|
2466
|
+
current=viz_scope,
|
|
2467
|
+
message=f"--inference-viz-scope must be 'full' or 'new', got {viz_scope!r}",
|
|
2468
|
+
fix_kind="format",
|
|
2469
|
+
)
|
|
2470
|
+
)
|
|
2471
|
+
|
|
2472
|
+
# -------------------------------------------- Energy detection preprocessing
|
|
2473
|
+
# When inference_files is set the energy-detection pipeline runs before
|
|
2474
|
+
# inference; stamp_width must equal data.width_bin so the extracted
|
|
2475
|
+
# (n_hits, 6, 16, stamp_width) tensor matches the downstream downsample +
|
|
2476
|
+
# log-norm path.
|
|
2477
|
+
if inf_files is not None:
|
|
2478
|
+
sw_inf = _resolve(args, "stamp_width", config.inference.stamp_width)
|
|
2479
|
+
wb_inf = _resolve(args, "width_bin", config.data.width_bin)
|
|
2480
|
+
if sw_inf is not None and wb_inf is not None and sw_inf != wb_inf:
|
|
2481
|
+
errors.append(
|
|
2482
|
+
ValidationError(
|
|
2483
|
+
field="inference.stamp_width",
|
|
2484
|
+
current=sw_inf,
|
|
2485
|
+
message=(
|
|
2486
|
+
f"--stamp-width ({sw_inf}) must equal --width-bin ({wb_inf}) "
|
|
2487
|
+
"when --inference-files is set"
|
|
2488
|
+
),
|
|
2489
|
+
fix_kind="range",
|
|
2490
|
+
min_val=wb_inf,
|
|
2491
|
+
max_val=wb_inf,
|
|
2492
|
+
)
|
|
2493
|
+
)
|
|
2494
|
+
group_cols = _resolve(
|
|
2495
|
+
args, "cadence_group_by_cols", config.inference.cadence_group_by_cols
|
|
2496
|
+
)
|
|
2497
|
+
if group_cols is not None and len(group_cols) == 0:
|
|
2498
|
+
errors.append(
|
|
2499
|
+
ValidationError(
|
|
2500
|
+
field="inference.cadence_group_by_cols",
|
|
2501
|
+
current=group_cols,
|
|
2502
|
+
message="--cadence-group-by-cols must be non-empty when --inference-files is provided",
|
|
2503
|
+
fix_kind="clamp_low",
|
|
2504
|
+
)
|
|
2505
|
+
)
|
|
2506
|
+
# Downsample-at-extraction stores stamp_width // downsample_factor bins per
|
|
2507
|
+
# stamp, so the division must be exact for the stored width to be well-defined
|
|
2508
|
+
store_ds = _resolve(
|
|
2509
|
+
args, "store_downsampled_stamps", config.inference.store_downsampled_stamps
|
|
2510
|
+
)
|
|
2511
|
+
df_inf = _resolve(args, "downsample_factor", config.data.downsample_factor)
|
|
2512
|
+
if (
|
|
2513
|
+
store_ds
|
|
2514
|
+
and sw_inf is not None
|
|
2515
|
+
and df_inf is not None
|
|
2516
|
+
and df_inf > 0
|
|
2517
|
+
and sw_inf % df_inf != 0
|
|
2518
|
+
):
|
|
2519
|
+
errors.append(
|
|
2520
|
+
ValidationError(
|
|
2521
|
+
field="inference.stamp_width",
|
|
2522
|
+
current=sw_inf,
|
|
2523
|
+
message=(
|
|
2524
|
+
f"--stamp-width ({sw_inf}) must be divisible by "
|
|
2525
|
+
f"--downsample-factor ({df_inf}) when stamps are downsampled at "
|
|
2526
|
+
"extraction (--store-downsampled-stamps)"
|
|
2527
|
+
),
|
|
2528
|
+
fix_kind="divisibility",
|
|
2529
|
+
divisor=df_inf,
|
|
2530
|
+
)
|
|
2531
|
+
)
|
|
2532
|
+
|
|
2533
|
+
coarse_channel_width = _resolve(
|
|
2534
|
+
args, "coarse_channel_width", config.inference.coarse_channel_width
|
|
2535
|
+
)
|
|
2536
|
+
coarse_channel_log_interval = _resolve(
|
|
2537
|
+
args, "coarse_channel_log_interval", config.inference.coarse_channel_log_interval
|
|
2538
|
+
)
|
|
2539
|
+
spline_order = _resolve(args, "spline_order", config.inference.spline_order)
|
|
2540
|
+
detection_window_size = _resolve(
|
|
2541
|
+
args, "detection_window_size", config.inference.detection_window_size
|
|
2542
|
+
)
|
|
2543
|
+
detection_step_size = _resolve(
|
|
2544
|
+
args, "detection_step_size", config.inference.detection_step_size
|
|
2545
|
+
)
|
|
2546
|
+
stat_threshold = _resolve(args, "stat_threshold", config.inference.stat_threshold)
|
|
2547
|
+
stamp_width = _resolve(args, "stamp_width", config.inference.stamp_width)
|
|
2548
|
+
overlap_fraction = _resolve(args, "overlap_fraction", config.inference.overlap_fraction)
|
|
2549
|
+
|
|
2550
|
+
# Positivity checks for the bare-int / bare-float fields, in parser order.
|
|
2551
|
+
for name, val in (
|
|
2552
|
+
("coarse_channel_width", coarse_channel_width),
|
|
2553
|
+
("coarse_channel_log_interval", coarse_channel_log_interval),
|
|
2554
|
+
("stat_threshold", stat_threshold),
|
|
2555
|
+
("stamp_width", stamp_width),
|
|
2556
|
+
):
|
|
2557
|
+
if val is not None and val <= 0:
|
|
2558
|
+
errors.append(
|
|
2559
|
+
ValidationError(
|
|
2560
|
+
field=f"inference.{name}",
|
|
2561
|
+
current=val,
|
|
2562
|
+
message=f"--{name.replace('_', '-')} must be > 0, got {val}",
|
|
2563
|
+
fix_kind="clamp_low",
|
|
2564
|
+
min_val=1,
|
|
2565
|
+
)
|
|
2566
|
+
)
|
|
2567
|
+
if spline_order is not None and spline_order < 1:
|
|
2568
|
+
errors.append(
|
|
2569
|
+
ValidationError(
|
|
2570
|
+
field="inference.spline_order",
|
|
2571
|
+
current=spline_order,
|
|
2572
|
+
message=f"--spline-order must be >= 1, got {spline_order}",
|
|
2573
|
+
fix_kind="clamp_low",
|
|
2574
|
+
min_val=1,
|
|
2575
|
+
)
|
|
2576
|
+
)
|
|
2577
|
+
bandpass_method = _resolve(args, "bandpass_method", config.inference.bandpass_method)
|
|
2578
|
+
if bandpass_method is not None and bandpass_method not in _BANDPASS_METHODS:
|
|
2579
|
+
errors.append(
|
|
2580
|
+
ValidationError(
|
|
2581
|
+
field="inference.bandpass_method",
|
|
2582
|
+
current=bandpass_method,
|
|
2583
|
+
message=f"--bandpass-method must be one of {sorted(_BANDPASS_METHODS)}, got {bandpass_method!r}",
|
|
2584
|
+
fix_kind="enum",
|
|
2585
|
+
allowed=sorted(_BANDPASS_METHODS),
|
|
2586
|
+
)
|
|
2587
|
+
)
|
|
2588
|
+
pfb_taps = _resolve(args, "pfb_taps_per_channel", config.inference.pfb_taps_per_channel)
|
|
2589
|
+
if pfb_taps is not None and pfb_taps < 1:
|
|
2590
|
+
errors.append(
|
|
2591
|
+
ValidationError(
|
|
2592
|
+
field="inference.pfb_taps_per_channel",
|
|
2593
|
+
current=pfb_taps,
|
|
2594
|
+
message=f"--pfb-taps-per-channel must be >= 1, got {pfb_taps}",
|
|
2595
|
+
fix_kind="clamp_low",
|
|
2596
|
+
min_val=1,
|
|
2597
|
+
)
|
|
2598
|
+
)
|
|
2599
|
+
# detection_window_size <= stamp_width
|
|
2600
|
+
if (
|
|
2601
|
+
detection_window_size is not None
|
|
2602
|
+
and stamp_width is not None
|
|
2603
|
+
and detection_window_size > stamp_width
|
|
2604
|
+
):
|
|
2605
|
+
errors.append(
|
|
2606
|
+
ValidationError(
|
|
2607
|
+
field="inference.detection_window_size",
|
|
2608
|
+
current=detection_window_size,
|
|
2609
|
+
message=(
|
|
2610
|
+
f"--detection-window-size ({detection_window_size}) must be"
|
|
2611
|
+
f" <= --stamp-width ({stamp_width})"
|
|
2612
|
+
),
|
|
2613
|
+
fix_kind="clamp_high",
|
|
2614
|
+
max_val=stamp_width,
|
|
2615
|
+
)
|
|
2616
|
+
)
|
|
2617
|
+
# detection_step_size > 0 and <= detection_window_size
|
|
2618
|
+
if detection_step_size is not None and detection_step_size <= 0:
|
|
2619
|
+
errors.append(
|
|
2620
|
+
ValidationError(
|
|
2621
|
+
field="inference.detection_step_size",
|
|
2622
|
+
current=detection_step_size,
|
|
2623
|
+
message=f"--detection-step-size must be > 0, got {detection_step_size}",
|
|
2624
|
+
fix_kind="clamp_low",
|
|
2625
|
+
min_val=1,
|
|
2626
|
+
)
|
|
2627
|
+
)
|
|
2628
|
+
if (
|
|
2629
|
+
detection_step_size is not None
|
|
2630
|
+
and detection_window_size is not None
|
|
2631
|
+
and detection_step_size > detection_window_size
|
|
2632
|
+
):
|
|
2633
|
+
errors.append(
|
|
2634
|
+
ValidationError(
|
|
2635
|
+
field="inference.detection_step_size",
|
|
2636
|
+
current=detection_step_size,
|
|
2637
|
+
message=(
|
|
2638
|
+
f"--detection-step-size ({detection_step_size}) must be"
|
|
2639
|
+
f" <= --detection-window-size ({detection_window_size})"
|
|
2640
|
+
),
|
|
2641
|
+
fix_kind="clamp_high",
|
|
2642
|
+
max_val=detection_window_size,
|
|
2643
|
+
)
|
|
2644
|
+
)
|
|
2645
|
+
if overlap_fraction is not None and not (0 <= overlap_fraction <= 1):
|
|
2646
|
+
errors.append(
|
|
2647
|
+
ValidationError(
|
|
2648
|
+
field="inference.overlap_fraction",
|
|
2649
|
+
current=overlap_fraction,
|
|
2650
|
+
message=f"--overlap-fraction must satisfy 0 <= fraction <= 1, got {overlap_fraction}",
|
|
2651
|
+
fix_kind="range",
|
|
2652
|
+
min_val=0.0,
|
|
2653
|
+
max_val=1.0,
|
|
2654
|
+
)
|
|
2655
|
+
)
|
|
2656
|
+
|
|
2657
|
+
# Visualization suite
|
|
2658
|
+
stamp_gallery_top_k = _resolve(
|
|
2659
|
+
args, "stamp_gallery_top_k", config.inference.stamp_gallery_top_k
|
|
2660
|
+
)
|
|
2661
|
+
if stamp_gallery_top_k is not None and stamp_gallery_top_k < 1:
|
|
2662
|
+
errors.append(
|
|
2663
|
+
ValidationError(
|
|
2664
|
+
field="inference.stamp_gallery_top_k",
|
|
2665
|
+
current=stamp_gallery_top_k,
|
|
2666
|
+
message=f"--stamp-gallery-top-k must be >= 1, got {stamp_gallery_top_k}",
|
|
2667
|
+
fix_kind="clamp_low",
|
|
2668
|
+
min_val=1,
|
|
2669
|
+
)
|
|
2670
|
+
)
|
|
2671
|
+
max_candidate_plots = _resolve(
|
|
2672
|
+
args, "max_candidate_plots", config.inference.max_candidate_plots
|
|
2673
|
+
)
|
|
2674
|
+
if max_candidate_plots is not None and max_candidate_plots < 0:
|
|
2675
|
+
errors.append(
|
|
2676
|
+
ValidationError(
|
|
2677
|
+
field="inference.max_candidate_plots",
|
|
2678
|
+
current=max_candidate_plots,
|
|
2679
|
+
message=f"--max-candidate-plots must be >= 0, got {max_candidate_plots}",
|
|
2680
|
+
fix_kind="clamp_low",
|
|
2681
|
+
min_val=0,
|
|
2682
|
+
)
|
|
2683
|
+
)
|
|
2684
|
+
|
|
2685
|
+
# Retries (inference-scoped)
|
|
2686
|
+
mr = _resolve(args, "max_retries", config.inference.max_retries)
|
|
2687
|
+
if mr is not None and mr < 0:
|
|
2688
|
+
errors.append(
|
|
2689
|
+
ValidationError(
|
|
2690
|
+
field="inference.max_retries",
|
|
2691
|
+
current=mr,
|
|
2692
|
+
message=f"--max-retries must be >= 0, got {mr}",
|
|
2693
|
+
fix_kind="clamp_low",
|
|
2694
|
+
min_val=0,
|
|
2695
|
+
)
|
|
2696
|
+
)
|
|
2697
|
+
rd = _resolve(args, "retry_delay", config.inference.retry_delay)
|
|
2698
|
+
if rd is not None and rd < 0:
|
|
2699
|
+
errors.append(
|
|
2700
|
+
ValidationError(
|
|
2701
|
+
field="inference.retry_delay",
|
|
2702
|
+
current=rd,
|
|
2703
|
+
message=f"--retry-delay must be >= 0, got {rd}",
|
|
2704
|
+
fix_kind="clamp_low",
|
|
2705
|
+
min_val=0,
|
|
2706
|
+
)
|
|
2707
|
+
)
|
|
2708
|
+
|
|
2709
|
+
# -------------------------------------------------------------- Checkpoint
|
|
2710
|
+
save_tag = getattr(args, "save_tag", None)
|
|
2711
|
+
if save_tag is not None and not _SAVE_TAG_PATTERN.match(save_tag):
|
|
2712
|
+
errors.append(
|
|
2713
|
+
ValidationError(
|
|
2714
|
+
field="checkpoint.save_tag",
|
|
2715
|
+
current=save_tag,
|
|
2716
|
+
message=f"--save-tag must be one of: test, train, inf, bench (the datetime is appended automatically); got {save_tag!r}",
|
|
2717
|
+
fix_kind="format",
|
|
2718
|
+
)
|
|
2719
|
+
)
|
|
2720
|
+
|
|
2721
|
+
return errors
|
|
2722
|
+
|
|
2723
|
+
|
|
2724
|
+
# NOTE: come back to this later (everything in this section)
|
|
2725
|
+
# ---------------------------------------------------------------------------
|
|
2726
|
+
# Fix proposal surface — used both by validate_args (to include suggestions in
|
|
2727
|
+
# the ValueError message on failure) and by utils/find_optimal_configs.py (to
|
|
2728
|
+
# print suggestions to stdout when run directly). Kept here in cli.py rather
|
|
2729
|
+
# than utils/ so cli.py can call them without a circular import or sys.path
|
|
2730
|
+
# gymnastics, and so the validation + proposal logic stays colocated.
|
|
2731
|
+
# ---------------------------------------------------------------------------
|
|
2732
|
+
|
|
2733
|
+
# Neighborhoods for the fields the solver prefers to keep near their base value (data sizes +
|
|
2734
|
+
# the throughput-optimal per-replica batch). effective_batch_size and per_replica_val_batch_size
|
|
2735
|
+
# are deliberately NOT here: they are generated exactly from the divisibility structure (divisors
|
|
2736
|
+
# of the train split / of the gcd of the val-side counts), so the search is small and complete
|
|
2737
|
+
# rather than a coarse grid that either explodes or steps over the only valid values.
|
|
2738
|
+
_SEARCH_RANGES: dict[str, tuple[int, int, int]] = {
|
|
2739
|
+
# (min, max, step)
|
|
2740
|
+
"num_samples_beta_vae": (400_000, 600_000, 10_240),
|
|
2741
|
+
"num_samples_rf": (80_000, 120_000, 2_048),
|
|
2742
|
+
"per_replica_batch_size": (64, 256, 64),
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
_CROSS_PARAM_FIELDS = (
|
|
2746
|
+
"num_samples_beta_vae",
|
|
2747
|
+
"num_samples_rf",
|
|
2748
|
+
"per_replica_batch_size",
|
|
2749
|
+
"effective_batch_size",
|
|
2750
|
+
"per_replica_val_batch_size",
|
|
2751
|
+
)
|
|
2752
|
+
|
|
2753
|
+
|
|
2754
|
+
def _divisors(n: int) -> list[int]:
|
|
2755
|
+
"""Sorted positive divisors of ``n`` (empty for n < 1)."""
|
|
2756
|
+
if n < 1:
|
|
2757
|
+
return []
|
|
2758
|
+
small: list[int] = []
|
|
2759
|
+
large: list[int] = []
|
|
2760
|
+
i = 1
|
|
2761
|
+
while i * i <= n:
|
|
2762
|
+
if n % i == 0:
|
|
2763
|
+
small.append(i)
|
|
2764
|
+
if i != n // i:
|
|
2765
|
+
large.append(n // i)
|
|
2766
|
+
i += 1
|
|
2767
|
+
return small + large[::-1]
|
|
2768
|
+
|
|
2769
|
+
|
|
2770
|
+
def _check_cross_constraints(
|
|
2771
|
+
num_samples_beta_vae: int,
|
|
2772
|
+
num_samples_rf: int,
|
|
2773
|
+
train_val_split: float,
|
|
2774
|
+
per_replica_batch_size: int,
|
|
2775
|
+
effective_batch_size: int,
|
|
2776
|
+
per_replica_val_batch_size: int,
|
|
2777
|
+
num_replicas_list: list[int],
|
|
2778
|
+
latent_total: int | None = None,
|
|
2779
|
+
) -> bool:
|
|
2780
|
+
"""Return True iff the six-tuple satisfies all cross-replica constraints for every
|
|
2781
|
+
num_replicas in the list. Mirrors the cross-replica checks in
|
|
2782
|
+
:func:`collect_validation_errors`. When ``latent_total`` (= latent_viz_num_cadences_per_type
|
|
2783
|
+
* 4) is supplied, the latent-viz divisibility check is included too, so a returned solution is
|
|
2784
|
+
valid against every cross-replica constraint the pipeline enforces — pass None to skip it (e.g.
|
|
2785
|
+
when the caller doesn't vary anything the latent batch depends on)."""
|
|
2786
|
+
if not (0 <= train_val_split <= 1):
|
|
2787
|
+
return False
|
|
2788
|
+
if num_samples_rf % 2 != 0:
|
|
2789
|
+
return False
|
|
2790
|
+
for nr in num_replicas_list:
|
|
2791
|
+
gtrain = per_replica_batch_size * nr
|
|
2792
|
+
gval = per_replica_val_batch_size * nr
|
|
2793
|
+
# round() rather than int() — `num_samples_beta_vae * (1 - train_val_split)`
|
|
2794
|
+
# suffers IEEE-754 noise (e.g. 499200 * 0.2 = 99839.999...) and int() would
|
|
2795
|
+
# truncate to 99839.
|
|
2796
|
+
train_samples = round(num_samples_beta_vae * train_val_split)
|
|
2797
|
+
val_samples = round(num_samples_beta_vae * (1 - train_val_split))
|
|
2798
|
+
if not (gtrain <= effective_batch_size <= num_samples_beta_vae * train_val_split):
|
|
2799
|
+
return False
|
|
2800
|
+
if gval > num_samples_beta_vae * (1 - train_val_split):
|
|
2801
|
+
return False
|
|
2802
|
+
if gval > num_samples_rf:
|
|
2803
|
+
return False
|
|
2804
|
+
# RF train-split floor (#297): mirrors collect_validation_errors — the RF stage
|
|
2805
|
+
# trims its train split to a multiple of the effective batch, so it needs at
|
|
2806
|
+
# least one full effective batch after the split. int() matches the validator
|
|
2807
|
+
# (the runtime truncates per label; see the comment there).
|
|
2808
|
+
if effective_batch_size > int(num_samples_rf * train_val_split):
|
|
2809
|
+
return False
|
|
2810
|
+
if effective_batch_size % gtrain != 0:
|
|
2811
|
+
return False
|
|
2812
|
+
if train_samples % effective_batch_size != 0:
|
|
2813
|
+
return False
|
|
2814
|
+
if val_samples % gval != 0:
|
|
2815
|
+
return False
|
|
2816
|
+
if num_samples_rf % gval != 0:
|
|
2817
|
+
return False
|
|
2818
|
+
if latent_total is not None and latent_total % gval != 0:
|
|
2819
|
+
return False
|
|
2820
|
+
return True
|
|
2821
|
+
|
|
2822
|
+
|
|
2823
|
+
def _solve_cross_param_constraints(
|
|
2824
|
+
base: dict[str, int | float],
|
|
2825
|
+
num_replicas_list: list[int],
|
|
2826
|
+
max_candidates: int = 5_000_000,
|
|
2827
|
+
) -> dict[str, int | float] | None:
|
|
2828
|
+
"""Find the batch/sample configuration nearest (L1) to ``base`` that satisfies every
|
|
2829
|
+
cross-replica constraint for all replica counts in ``num_replicas_list``, or None if none
|
|
2830
|
+
exists. ``base`` may carry an optional ``latent_total`` key (= latent_viz_num_cadences_per_type
|
|
2831
|
+
* 4) so the latent-viz constraint is honored.
|
|
2832
|
+
|
|
2833
|
+
Rather than a blind grid (which both explodes past the candidate budget and steps over the only
|
|
2834
|
+
valid values), the effective batch and per-replica val batch — the two purely divisibility-bound
|
|
2835
|
+
fields — are enumerated *exactly* from the structure: the effective batch must be a divisor of
|
|
2836
|
+
the train split that is a multiple of ``per_replica_batch_size * num_replicas`` for every replica
|
|
2837
|
+
count; the global val batch must divide the gcd of the val split, num_samples_rf, and (if given)
|
|
2838
|
+
latent_total. The data sizes and per-replica batch are only searched over small neighborhoods
|
|
2839
|
+
(seeded with the base value, so they're preferred when a solution keeps them) — but because the
|
|
2840
|
+
result is chosen purely by L1 distance, a multi-count solve can still move per_replica_batch when
|
|
2841
|
+
doing so is closer than raising the effective batch to the larger multiple all counts share (e.g.
|
|
2842
|
+
a 4/5/6-GPU solve drops it to 64 rather than lifting the effective batch to lcm(128*{4,5,6})).
|
|
2843
|
+
Every candidate is confirmed with the shared :func:`_check_cross_constraints`, which stays the
|
|
2844
|
+
single source of truth."""
|
|
2845
|
+
latent_total = base.get("latent_total")
|
|
2846
|
+
tvs = base["train_val_split"]
|
|
2847
|
+
b_nsb = base["num_samples_beta_vae"]
|
|
2848
|
+
b_nsr = base["num_samples_rf"]
|
|
2849
|
+
b_p = base["per_replica_batch_size"]
|
|
2850
|
+
b_e = base["effective_batch_size"]
|
|
2851
|
+
b_v = base["per_replica_val_batch_size"]
|
|
2852
|
+
|
|
2853
|
+
def _ok(nsb, nsr, p, e, v):
|
|
2854
|
+
return _check_cross_constraints(
|
|
2855
|
+
nsb, nsr, tvs, p, e, v, num_replicas_list, latent_total=latent_total
|
|
2856
|
+
)
|
|
2857
|
+
|
|
2858
|
+
if _ok(b_nsb, b_nsr, b_p, b_e, b_v):
|
|
2859
|
+
return {
|
|
2860
|
+
"num_samples_beta_vae": b_nsb,
|
|
2861
|
+
"num_samples_rf": b_nsr,
|
|
2862
|
+
"train_val_split": tvs,
|
|
2863
|
+
"per_replica_batch_size": b_p,
|
|
2864
|
+
"effective_batch_size": b_e,
|
|
2865
|
+
"per_replica_val_batch_size": b_v,
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
def _neighborhood(field: str) -> list[int]:
|
|
2869
|
+
lo, hi, step = _SEARCH_RANGES[field]
|
|
2870
|
+
return sorted({base[field]} | set(range(lo, hi + 1, step)))
|
|
2871
|
+
|
|
2872
|
+
nsb_cands = _neighborhood("num_samples_beta_vae")
|
|
2873
|
+
nsr_cands = _neighborhood("num_samples_rf")
|
|
2874
|
+
p_cands = _neighborhood("per_replica_batch_size")
|
|
2875
|
+
|
|
2876
|
+
best: dict[str, int | float] | None = None
|
|
2877
|
+
best_dist = float("inf")
|
|
2878
|
+
checked = 0
|
|
2879
|
+
for nsb in nsb_cands:
|
|
2880
|
+
train_samples = round(nsb * tvs)
|
|
2881
|
+
val_samples = round(nsb * (1 - tvs))
|
|
2882
|
+
train_divisors = _divisors(train_samples)
|
|
2883
|
+
for p in p_cands:
|
|
2884
|
+
gtrains = [p * nr for nr in num_replicas_list]
|
|
2885
|
+
lcm_gtrain = 1
|
|
2886
|
+
for gt in gtrains:
|
|
2887
|
+
lcm_gtrain = lcm(lcm_gtrain, gt)
|
|
2888
|
+
min_e = max(gtrains)
|
|
2889
|
+
e_cands = [d for d in train_divisors if d >= min_e and d % lcm_gtrain == 0]
|
|
2890
|
+
if not e_cands:
|
|
2891
|
+
continue
|
|
2892
|
+
for nsr in nsr_cands:
|
|
2893
|
+
if nsr % 2 != 0:
|
|
2894
|
+
continue
|
|
2895
|
+
g = gcd(val_samples, nsr)
|
|
2896
|
+
if latent_total is not None:
|
|
2897
|
+
g = gcd(g, latent_total)
|
|
2898
|
+
v_cands = [
|
|
2899
|
+
d for d in _divisors(g) if all(g % (d * nr) == 0 for nr in num_replicas_list)
|
|
2900
|
+
]
|
|
2901
|
+
for e in e_cands:
|
|
2902
|
+
for v in v_cands:
|
|
2903
|
+
if checked >= max_candidates:
|
|
2904
|
+
return best
|
|
2905
|
+
checked += 1
|
|
2906
|
+
if not _ok(nsb, nsr, p, e, v):
|
|
2907
|
+
continue
|
|
2908
|
+
dist = (
|
|
2909
|
+
abs(nsb - b_nsb)
|
|
2910
|
+
+ abs(nsr - b_nsr)
|
|
2911
|
+
+ abs(p - b_p)
|
|
2912
|
+
+ abs(e - b_e)
|
|
2913
|
+
+ abs(v - b_v)
|
|
2914
|
+
)
|
|
2915
|
+
if dist < best_dist:
|
|
2916
|
+
best_dist = dist
|
|
2917
|
+
best = {
|
|
2918
|
+
"num_samples_beta_vae": nsb,
|
|
2919
|
+
"num_samples_rf": nsr,
|
|
2920
|
+
"train_val_split": tvs,
|
|
2921
|
+
"per_replica_batch_size": p,
|
|
2922
|
+
"effective_batch_size": e,
|
|
2923
|
+
"per_replica_val_batch_size": v,
|
|
2924
|
+
}
|
|
2925
|
+
return best
|
|
2926
|
+
|
|
2927
|
+
|
|
2928
|
+
def _round_to_multiple(val: int, divisor: int) -> int:
|
|
2929
|
+
"""Round ``val`` to the nearest multiple of ``divisor`` (ties round up)."""
|
|
2930
|
+
q, r = divmod(val, divisor)
|
|
2931
|
+
return divisor * (q if r * 2 < divisor else q + 1)
|
|
2932
|
+
|
|
2933
|
+
|
|
2934
|
+
def propose_simple_fix(err: ValidationError) -> object | None:
|
|
2935
|
+
"""Suggest a single replacement value for a non-cross-param violation. Returns None when
|
|
2936
|
+
a violation cannot be auto-fixed (e.g. a missing file)."""
|
|
2937
|
+
if err.fix_kind == "clamp_low" and err.min_val is not None:
|
|
2938
|
+
return err.min_val
|
|
2939
|
+
if err.fix_kind == "clamp_high" and err.max_val is not None:
|
|
2940
|
+
return err.max_val
|
|
2941
|
+
if err.fix_kind == "range" and err.min_val is not None and err.max_val is not None:
|
|
2942
|
+
if isinstance(err.current, (int, float)):
|
|
2943
|
+
return max(err.min_val, min(err.current, err.max_val))
|
|
2944
|
+
return err.min_val
|
|
2945
|
+
if err.fix_kind == "enum" and err.allowed:
|
|
2946
|
+
return err.allowed[0]
|
|
2947
|
+
if err.fix_kind == "divisibility" and err.divisor and isinstance(err.current, int):
|
|
2948
|
+
return _round_to_multiple(err.current, err.divisor)
|
|
2949
|
+
return None # format / file_exists / cross_param handled separately
|
|
2950
|
+
|
|
2951
|
+
|
|
2952
|
+
def _format_proposal_value(val: object) -> str:
|
|
2953
|
+
"""Format a proposed value for the CLI flag hint shown to the user."""
|
|
2954
|
+
if isinstance(val, float):
|
|
2955
|
+
return f"{val:g}"
|
|
2956
|
+
if isinstance(val, list):
|
|
2957
|
+
return "[" + ", ".join(str(x) for x in val) + "]"
|
|
2958
|
+
return str(val)
|
|
2959
|
+
|
|
2960
|
+
|
|
2961
|
+
def _build_suggestion_block(errors: list[ValidationError], num_replicas: int | None) -> str:
|
|
2962
|
+
"""Compose the 'Suggested fixes:' block appended to the ValueError message.
|
|
2963
|
+
|
|
2964
|
+
Mirrors the proposer logic in utils/find_optimal_configs.py: simple violations get
|
|
2965
|
+
a per-field replacement value via :func:`propose_simple_fix`; cross-replica
|
|
2966
|
+
violations trigger a single grid search via :func:`_solve_cross_param_constraints`.
|
|
2967
|
+
Returns an empty string if nothing actionable was found.
|
|
2968
|
+
"""
|
|
2969
|
+
config = get_config()
|
|
2970
|
+
simple = [e for e in errors if e.fix_kind != "cross_param"]
|
|
2971
|
+
cross = [e for e in errors if e.fix_kind == "cross_param"]
|
|
2972
|
+
# Keyed on (field, fix_kind): distinct violations of the SAME field (e.g. the
|
|
2973
|
+
# stamp_width equality and divisibility checks both firing) must not silently
|
|
2974
|
+
# overwrite each other's proposal — one flag line per violation is honest, even
|
|
2975
|
+
# when that repeats a flag with different candidate values.
|
|
2976
|
+
proposals: dict[tuple[str, str], object] = {}
|
|
2977
|
+
|
|
2978
|
+
for err in simple:
|
|
2979
|
+
fix = propose_simple_fix(err)
|
|
2980
|
+
if fix is not None:
|
|
2981
|
+
proposals[(err.field, err.fix_kind)] = fix
|
|
2982
|
+
|
|
2983
|
+
if cross and num_replicas is not None and config is not None:
|
|
2984
|
+
base = {
|
|
2985
|
+
"num_samples_beta_vae": config.training.num_samples_beta_vae,
|
|
2986
|
+
"num_samples_rf": config.training.num_samples_rf,
|
|
2987
|
+
"train_val_split": config.training.train_val_split,
|
|
2988
|
+
"per_replica_batch_size": config.training.per_replica_batch_size,
|
|
2989
|
+
"effective_batch_size": config.training.effective_batch_size,
|
|
2990
|
+
"per_replica_val_batch_size": config.training.per_replica_val_batch_size,
|
|
2991
|
+
"latent_total": config.training.latent_viz_num_cadences_per_type * 4,
|
|
2992
|
+
}
|
|
2993
|
+
solution = _solve_cross_param_constraints(base, [num_replicas])
|
|
2994
|
+
if solution is not None:
|
|
2995
|
+
for f_name in _CROSS_PARAM_FIELDS + ("train_val_split",):
|
|
2996
|
+
if solution[f_name] != base[f_name]:
|
|
2997
|
+
proposals[(f"training.{f_name}", "cross_param")] = solution[f_name]
|
|
2998
|
+
|
|
2999
|
+
if not proposals:
|
|
3000
|
+
return ""
|
|
3001
|
+
|
|
3002
|
+
flag_lines: list[str] = []
|
|
3003
|
+
for (f_name, _fix_kind), value in proposals.items():
|
|
3004
|
+
# Drop the config-section prefix and switch underscores to hyphens.
|
|
3005
|
+
short = f_name.split(".", 1)[-1]
|
|
3006
|
+
flag = "--" + short.replace("_", "-")
|
|
3007
|
+
flag_lines.append(f" {flag} {_format_proposal_value(value)}")
|
|
3008
|
+
|
|
3009
|
+
body = " \\\n".join(flag_lines)
|
|
3010
|
+
return (
|
|
3011
|
+
"\n\nSuggested fixes (run `python utils/find_optimal_configs.py` for the full "
|
|
3012
|
+
"report):\n" + body
|
|
3013
|
+
)
|
|
3014
|
+
|
|
3015
|
+
|
|
3016
|
+
def validate_args(args: argparse.Namespace) -> None:
|
|
3017
|
+
"""
|
|
3018
|
+
Pre-flight semantic and cross-parameter validation for the parsed CLI namespace, run before
|
|
3019
|
+
apply_args_to_config(). Delegates to _resolve_num_replicas() (which also fails fast on a
|
|
3020
|
+
replica count that's unusable on this host) and collect_validation_errors(), and raises
|
|
3021
|
+
ValueError if any failures came back. On failure the ValueError message includes a list of
|
|
3022
|
+
suggested CLI flags from the same proposer surface that powers utils/find_optimal_configs.py.
|
|
3023
|
+
Syntax and type checks are handled earlier by argparse itself in ArgumentParser.parse_args
|
|
3024
|
+
(called from main.py:main).
|
|
3025
|
+
"""
|
|
3026
|
+
num_replicas = _resolve_num_replicas(args)
|
|
3027
|
+
# NOTE: come back to this later (should we log a help message to use utils/find_optimal_configs.py if the inline `Suggested fixes: ` block isn't enough? alternatively, why don't we make it such that validate_args and the utility script share the exact same proposer surface -- e.g. bounded grid search on both, not just utility script. then, the purpose of the utility script would simply be to test hypothetical configurations? update docs/CONFIG_AND_CLI.md if any changes.)
|
|
3028
|
+
errors = collect_validation_errors(args, num_replicas)
|
|
3029
|
+
if errors:
|
|
3030
|
+
violation_block = "\n".join(f" - {e.message}" for e in errors)
|
|
3031
|
+
suggestions = _build_suggestion_block(errors, num_replicas)
|
|
3032
|
+
raise ValueError(f"Invalid arguments detected:\n{violation_block}{suggestions}")
|