Source code for specsr_roman.config

"""Typed configuration for the three training stages.

Every hyperparameter of the published models lives in ``configs/*.yaml`` and
is loaded into one of the dataclasses below. Two reasons this is not just
argparse:

* a run is reproducible from a file you can diff, rather than from a shell
  history line;
* the defaults here *are* the canonical chain. Constructing ``SR1Config()``
  with no arguments gives the settings that produced the published SR1
  checkpoint, so "what was this trained with" has a readable answer.

Values carried over from the JWST sweep are marked; they were not re-swept on
Roman, and re-sweeping them is open work.
"""

from __future__ import annotations

import dataclasses
from dataclasses import dataclass
from pathlib import Path
from typing import Any, TypeVar

__all__ = ["SR1Config", "ZHeadConfig", "SR2Config", "load_config", "to_dict"]

T = TypeVar("T")


[docs] @dataclass class SR1Config: """SR1: coarse super-resolution. Architecture and optimiser values are the JWST sweep optima, deliberately unchanged so the two instruments' models stay comparable. Everything under "recoverability" is Roman-specific and was tuned here. """ # data data: str = "data/dataset/ou2024_h10307_dataset.npz" augment: bool = False # see specsr_roman.data.augment for why not min_strong_lines: int = 0 # architecture (JWST sweep) hidden_dim: int = 120 num_res_blocks: int = 16 dropout: float = 0.023538492919758583 in_channels: int = 2 # [flux, err] # optimiser (JWST sweep) epochs: int = 200 batch_size: int = 32 lr: float = 8.23706977169561e-05 weight_decay: float = 4.953557427559904e-05 grad_clip: float = 0.5 # predicted-variance handling use_var_clamp: bool = True var_clamp_min: float = 0.1 var_clamp_max: float = 30.0 logvar_reg: float = 1.5590946894260903e-06 # line mask. mask_min_width 7 is the published value; Diffsky lines are # narrow (~5 px above threshold) so 3 is worth trying, but 7 is what the # released checkpoint used. mask_smooth_k: int = 161 mask_thresh_mad: float = 8.0 # Roman: noiseless targets need a higher # threshold than the JWST sweep value 2.946, # which flagged 18% of all pixels mask_dilate: int = 11 mask_min_width: int = 7 # gated sharpness term lam_d1: float = 0.11 lam_d2: float = 0.01 gate_min_frac: float = 0.01282614837763313 gate_temp: float = 0.2962018702777486 score_w_line: float = 0.50334848260373 score_w_recon: float = 0.2 # sharpness warm-down: the term is most useful early, and left at full # strength it eventually trades calibration for contrast. sharp_wd_start_epoch: int = 25 sharp_wd_rate: float = 0.008 sharp_wd_floor: float = 0.15 # recoverability (Roman-specific; see specsr_roman.training.losses) lam_lineflux: float = 1.0 lineflux_snr0: float = 2.0 lam_hallu: float = 0.5 hallu_snr0: float = 1.0 # checkpoint monitor smoothing ema_alpha: float = 0.9 # run plumbing init_checkpoint: str | None = None out_prefix: str = "sr1_roman" out_dir: str = "runs/sr1" run_name: str | None = None seed: int = 42 num_workers: int = 4 progress: bool = False wandb_project: str = "roman-spectral-superresolution" wandb_mode: str | None = None push_to_hub: bool = False hub_repo: str | None = None export_predictions: bool = True
[docs] @dataclass class ZHeadConfig: """ZHead: P(z) over a redshift grid, conditioned on grism + photometry.""" # data data: str = "data/dataset/ou2024_h10307_dataset.npz" sr1_ckpt: str = "sr1_ou2024_v6" min_strong_lines: int = 0 # architecture arch: str = "clf" # clf | attn | conv (clf is canonical) hidden_dim: int = 64 num_blocks: int = 4 dropout: float = 0.1 n_heads: int = 8 # P(z) grid n_bins: int = 310 z_lo: float = 0.0 z_hi: float = 3.1 label_sigma: float = 0.03 refine_window: int = 8 # photometry. `medium` = Roman F106/F129/F158, the bands that actually # ship with the HLWAS grism. `all` is a complete noiseless SED: diagnostic # only, never a model to deploy. use_phot: bool = True phot_tier: str | None = "medium" phot_mag_err: float = 0.05 phot_eval_mag_err: float = 0.05 # never score against noiseless truth # optimiser epochs: int = 150 batch_size: int = 32 lr: float = 3e-4 weight_decay: float = 1e-5 z_var_floor: float = 1e-6 # run plumbing out_prefix: str = "zhead_roman" out_dir: str = "runs/zhead" run_name: str | None = None seed: int = 42 num_workers: int = 4 wandb_project: str = "roman-spectral-superresolution" wandb_mode: str | None = None push_to_hub: bool = False hub_repo: str | None = None
[docs] @dataclass class SR2Config: """SR2: line-token attention refinement on top of frozen SR1 + ZHead.""" # data + upstream stages data: str = "data/dataset/ou2024_h10307_dataset.npz" sr1_ckpt: str = "sr1_ou2024_v6" zhead_ckpt: str = "zhead_ou2024_roman_med3_noisy" phot_tier: str | None = "medium" # must match how the ZHead was trained min_strong_lines: int = 0 augment: bool = False # optimiser epochs: int = 150 batch_size: int = 32 lr: float = 1e-4 weight_decay: float = 2e-5 grad_clip: float = 0.5 # delta shaping delta_cap: float = 40.0 # normalised SED lines peak above 30 sigma_base_um: float = 0.005 z_topk: int = 3 # reconstruction terms lam_hp_in: float = 3.0 lam_hp_out: float = 0.3 hp_k: int = 51 lam_sparse: float = 0.0 # superseded by supervised presence lam_lineflux: float = 1.0 lam_hallu: float = 1.0 hallu_snr0: float = 1.0 lam_presence: float = 0.3 presence_thresh: float = 3.0 label_sigma: float = 0.03 # coupled z-loss. OFF in the canonical chain: it rewards drawing every # line at the predicted redshift, and with a photometry-fed ZHead already # supplying z it bought nothing while inflating hallucination amplitude to # 2.6x truth during warm-up. lam_z: float = 0.0 lam_z_warmup: int = 5 zhead_finetune: bool = True zhead_lr_mult: float = 0.1 # run plumbing out_prefix: str = "sr2_roman" out_dir: str = "runs/sr2" run_name: str | None = None seed: int = 42 num_workers: int = 4 wandb_project: str = "roman-spectral-superresolution" wandb_mode: str | None = None push_to_hub: bool = False hub_repo: str | None = None smoke: bool = False
[docs] def to_dict(cfg) -> dict[str, Any]: """Dataclass -> plain dict, for W&B config and checkpoint cards.""" return dataclasses.asdict(cfg)
[docs] def load_config(cls: type[T], path: str | Path | None = None, overrides: dict[str, Any] | None = None) -> T: """Build a config from an optional YAML file plus optional overrides. Unknown keys are an error rather than a shrug: a typo'd hyperparameter that silently does nothing is the worst possible failure mode for a training run you will not look at again for six hours. """ values: dict[str, Any] = {} if path is not None: import yaml with open(path) as fh: values.update(yaml.safe_load(fh) or {}) if overrides: values.update({k: v for k, v in overrides.items() if v is not None}) known = {f.name for f in dataclasses.fields(cls)} unknown = set(values) - known if unknown: raise ValueError( f"{cls.__name__}: unknown config key(s) {sorted(unknown)}; " f"valid keys are {sorted(known)}") return cls(**values)