specsr 1.0.2__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.
- specsr/__init__.py +34 -0
- specsr/checkpoints.py +125 -0
- specsr/cli.py +158 -0
- specsr/config.py +71 -0
- specsr/data/__init__.py +37 -0
- specsr/data/augment.py +195 -0
- specsr/data/build.py +345 -0
- specsr/data/datasets.py +254 -0
- specsr/data/grid.py +257 -0
- specsr/data/ingest.py +204 -0
- specsr/data/splits.py +354 -0
- specsr/data/stitch.py +213 -0
- specsr/evaluation/__init__.py +268 -0
- specsr/evaluation/figures.py +376 -0
- specsr/evaluation/runner.py +74 -0
- specsr/inference/__init__.py +24 -0
- specsr/inference/pipeline.py +421 -0
- specsr/linefit.py +170 -0
- specsr/metrics.py +354 -0
- specsr/models/__init__.py +36 -0
- specsr/models/blocks.py +142 -0
- specsr/models/lines.py +142 -0
- specsr/models/loaders.py +205 -0
- specsr/models/sr1.py +94 -0
- specsr/models/sr2.py +464 -0
- specsr/models/zhead.py +327 -0
- specsr/paths.py +118 -0
- specsr/plotting.py +1402 -0
- specsr/runtime.py +191 -0
- specsr/training/__init__.py +28 -0
- specsr/training/common.py +272 -0
- specsr/training/losses.py +756 -0
- specsr/training/runner.py +114 -0
- specsr/training/sr1.py +307 -0
- specsr/training/sr2.py +643 -0
- specsr/training/zhead.py +418 -0
- specsr/training/zhead_sources.py +210 -0
- specsr/training/ztransform.py +127 -0
- specsr/wandb_plots.py +136 -0
- specsr-1.0.2.dist-info/METADATA +293 -0
- specsr-1.0.2.dist-info/RECORD +44 -0
- specsr-1.0.2.dist-info/WHEEL +4 -0
- specsr-1.0.2.dist-info/entry_points.txt +2 -0
- specsr-1.0.2.dist-info/licenses/LICENSE +21 -0
specsr/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""specsr — physics-informed deep learning for super-resolving galaxy spectra.
|
|
2
|
+
|
|
3
|
+
Enhances low-resolution galaxy spectra by ~10x in resolving power (R~100 to
|
|
4
|
+
R~1000), recovering narrow emission-line features — including blended doublets
|
|
5
|
+
such as [O III] 4959,5007 and Hbeta — that are unresolvable at prism resolution.
|
|
6
|
+
|
|
7
|
+
Trained on paired JWST/NIRSpec prism + medium-grating observations from JADES.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
# Read from the installed metadata rather than restated here. A second copy of
|
|
13
|
+
# the version drifts from pyproject.toml the first time one of them is bumped
|
|
14
|
+
# alone -- which had already happened -- and the copy that is wrong is the one
|
|
15
|
+
# users see.
|
|
16
|
+
try:
|
|
17
|
+
from importlib.metadata import PackageNotFoundError
|
|
18
|
+
from importlib.metadata import version as _version
|
|
19
|
+
|
|
20
|
+
__version__ = _version("specsr")
|
|
21
|
+
except PackageNotFoundError: # running from a source tree that was never installed
|
|
22
|
+
__version__ = "0.0.0.dev0"
|
|
23
|
+
|
|
24
|
+
__all__ = ["__version__", "get_checkpoint"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def __getattr__(name: str):
|
|
28
|
+
# Lazy re-export keeps `import specsr` cheap: torch and huggingface_hub are
|
|
29
|
+
# only imported when a model or checkpoint is actually requested.
|
|
30
|
+
if name == "get_checkpoint":
|
|
31
|
+
from .checkpoints import get_checkpoint
|
|
32
|
+
|
|
33
|
+
return get_checkpoint
|
|
34
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
specsr/checkpoints.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""On-demand model weight fetching from the Hugging Face Hub.
|
|
2
|
+
|
|
3
|
+
Weights are not stored in the git repository. They live in a Hub model repo and
|
|
4
|
+
are downloaded (and cached) the first time they are requested::
|
|
5
|
+
|
|
6
|
+
from specsr.checkpoints import get_checkpoint
|
|
7
|
+
path = get_checkpoint("sr1")
|
|
8
|
+
|
|
9
|
+
Set ``SPECSR_CHECKPOINT_DIR`` to load from a local directory instead — useful
|
|
10
|
+
during training, offline runs, or CI.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .paths import cache_dir
|
|
19
|
+
|
|
20
|
+
__all__ = ["get_checkpoint", "available_checkpoints", "DEFAULT_REPO", "DEFAULT_REVISION"]
|
|
21
|
+
|
|
22
|
+
DEFAULT_REPO = "aryana-haghjoo/specsr"
|
|
23
|
+
|
|
24
|
+
# Pin the default revision so a fresh install reproduces published results even
|
|
25
|
+
# if the Hub repo gains newer weights later. `v1-submission` is deliberately NOT
|
|
26
|
+
# the default: it was trained on a leaky split (see the model card).
|
|
27
|
+
DEFAULT_REVISION = "main"
|
|
28
|
+
|
|
29
|
+
# Logical name -> path within the Hub repo.
|
|
30
|
+
_REGISTRY: dict[str, str] = {
|
|
31
|
+
"sr1": "sr1/best_sr1.pth",
|
|
32
|
+
# SR1's architecture config. Registered rather than inferred: a Hub download
|
|
33
|
+
# places only the files actually requested into the snapshot directory, so
|
|
34
|
+
# looking for this one *beside* the downloaded weights finds nothing. That
|
|
35
|
+
# broke `from_pretrained()` for every public user while passing locally,
|
|
36
|
+
# where the archive directories do hold both files side by side.
|
|
37
|
+
"sr1_config": "sr1/config_logR.yaml",
|
|
38
|
+
"zhead": "zhead/best_zhead.pth",
|
|
39
|
+
"sr2": "sr2/best_sr2.pth",
|
|
40
|
+
# Redshift-comparison heads (LR / HR / SR2 inputs) used for the
|
|
41
|
+
# information-content diagnostic.
|
|
42
|
+
"zhead_lowres": "zhead/best_zhead_lowres.pth",
|
|
43
|
+
"zhead_hires": "zhead/best_zhead_hires.pth",
|
|
44
|
+
"zhead_sr2": "zhead/best_zhead_sr2.pth",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# What the training loops actually name their outputs. The Hub layout uses
|
|
49
|
+
# tidier names, so a directory written by `specsr train` does not match the
|
|
50
|
+
# registry path -- and `SPECSR_CHECKPOINT_DIR` is pointed at exactly such a
|
|
51
|
+
# directory during training and offline work. Accept both.
|
|
52
|
+
_LOCAL_ALIASES: dict[str, tuple[str, ...]] = {
|
|
53
|
+
"sr1": ("best_superres_model.pth", "final_model.pth"),
|
|
54
|
+
"sr1_config": ("config_logR.yaml",),
|
|
55
|
+
# `best_zhead.pth` is what the trainer writes since 2026-08-14; the
|
|
56
|
+
# suffixed name is kept so run directories written before that still load.
|
|
57
|
+
"zhead": ("best_zhead.pth", "best_zhead_sr1.pth"),
|
|
58
|
+
"sr2": ("best_sr2.pth",),
|
|
59
|
+
"zhead_lowres": ("best_zhead_lowres.pth",),
|
|
60
|
+
"zhead_hires": ("best_zhead_hires.pth",),
|
|
61
|
+
"zhead_sr2": ("best_zhead_sr2.pth",),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def available_checkpoints() -> list[str]:
|
|
66
|
+
"""Logical checkpoint names understood by :func:`get_checkpoint`."""
|
|
67
|
+
return sorted(_REGISTRY)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_checkpoint(
|
|
71
|
+
name: str,
|
|
72
|
+
repo_id: str | None = None,
|
|
73
|
+
revision: str | None = None,
|
|
74
|
+
) -> Path:
|
|
75
|
+
"""Return a local path to the requested checkpoint, downloading if needed.
|
|
76
|
+
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
name
|
|
80
|
+
Logical name, e.g. ``"sr1"``. See :func:`available_checkpoints`.
|
|
81
|
+
repo_id
|
|
82
|
+
Override the Hub repo. Defaults to ``SPECSR_CHECKPOINT_REPO`` or
|
|
83
|
+
:data:`DEFAULT_REPO`.
|
|
84
|
+
revision
|
|
85
|
+
Branch, tag or commit. Defaults to ``SPECSR_CHECKPOINT_REVISION`` or
|
|
86
|
+
:data:`DEFAULT_REVISION`.
|
|
87
|
+
"""
|
|
88
|
+
if name not in _REGISTRY:
|
|
89
|
+
raise KeyError(
|
|
90
|
+
f"Unknown checkpoint {name!r}. Available: {available_checkpoints()}"
|
|
91
|
+
)
|
|
92
|
+
filename = _REGISTRY[name]
|
|
93
|
+
|
|
94
|
+
# Local override wins: no network, no Hub account needed.
|
|
95
|
+
local_root = os.environ.get("SPECSR_CHECKPOINT_DIR")
|
|
96
|
+
if local_root:
|
|
97
|
+
root = Path(local_root).expanduser()
|
|
98
|
+
tried = [root / filename, root / Path(filename).name]
|
|
99
|
+
tried += [root / alias for alias in _LOCAL_ALIASES.get(name, ())]
|
|
100
|
+
for candidate in tried:
|
|
101
|
+
if candidate.exists():
|
|
102
|
+
return candidate
|
|
103
|
+
raise FileNotFoundError(
|
|
104
|
+
f"SPECSR_CHECKPOINT_DIR is set to {local_root!r} but none of these "
|
|
105
|
+
f"exist: {', '.join(str(t) for t in tried)}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
from huggingface_hub import hf_hub_download
|
|
110
|
+
except ImportError as exc: # pragma: no cover - depends on optional extra
|
|
111
|
+
raise ImportError(
|
|
112
|
+
"Downloading checkpoints requires huggingface_hub. Install it with "
|
|
113
|
+
"`pip install specsr[hub]`, or set SPECSR_CHECKPOINT_DIR to a local "
|
|
114
|
+
"directory of weights."
|
|
115
|
+
) from exc
|
|
116
|
+
|
|
117
|
+
return Path(
|
|
118
|
+
hf_hub_download(
|
|
119
|
+
repo_id=repo_id or os.environ.get("SPECSR_CHECKPOINT_REPO", DEFAULT_REPO),
|
|
120
|
+
filename=filename,
|
|
121
|
+
revision=revision
|
|
122
|
+
or os.environ.get("SPECSR_CHECKPOINT_REVISION", DEFAULT_REVISION),
|
|
123
|
+
cache_dir=str(cache_dir() / "hub"),
|
|
124
|
+
)
|
|
125
|
+
)
|
specsr/cli.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Console entry point for specsr.
|
|
2
|
+
|
|
3
|
+
Subcommands are dispatched lazily so that `specsr --help` stays fast and does
|
|
4
|
+
not require the optional training or Hub dependencies to be installed.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="specsr",
|
|
18
|
+
description="Super-resolve galaxy spectra with a physics-informed model.",
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument("--version", action="version", version=f"specsr {__version__}")
|
|
21
|
+
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
22
|
+
|
|
23
|
+
p_build = sub.add_parser(
|
|
24
|
+
"build-dataset",
|
|
25
|
+
help="Build paired/augmented datasets from the raw JADES release tree.",
|
|
26
|
+
)
|
|
27
|
+
p_build.add_argument("--release", default="DR4", help="JADES release (default: DR4)")
|
|
28
|
+
p_build.add_argument("--out", default=None, help="Output .npz path")
|
|
29
|
+
p_build.add_argument(
|
|
30
|
+
"--n-aug", type=int, default=20, help="Augmented realizations per galaxy"
|
|
31
|
+
)
|
|
32
|
+
p_build.add_argument(
|
|
33
|
+
"--no-augment", action="store_true", help="Emit the unaugmented paired set only"
|
|
34
|
+
)
|
|
35
|
+
p_build.add_argument(
|
|
36
|
+
"--limit", type=int, default=None, help="Cap the number of galaxies (smoke tests)"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
p_train = sub.add_parser("train", help="Train a pipeline stage.")
|
|
40
|
+
p_train.add_argument("stage", choices=["sr1", "zhead", "sr2"])
|
|
41
|
+
p_train.add_argument("--config", required=True, help="YAML config path")
|
|
42
|
+
p_train.add_argument("--dataset", default=None, help="Dataset .npz path")
|
|
43
|
+
p_train.add_argument("--out-dir", default=None, help="Where to write checkpoints")
|
|
44
|
+
p_train.add_argument(
|
|
45
|
+
"--source", choices=["lowres", "hires", "sr1", "sr2"], default=None,
|
|
46
|
+
help="zhead only: which spectrum representation the head sees. The "
|
|
47
|
+
"redshift comparison in the paper is this flag, four times.",
|
|
48
|
+
)
|
|
49
|
+
# Upstream stages are named explicitly rather than discovered, so a
|
|
50
|
+
# checkpoint always records the models it was trained on top of.
|
|
51
|
+
p_train.add_argument("--sr1-ckpt", default=None)
|
|
52
|
+
p_train.add_argument("--sr1-config", default=None)
|
|
53
|
+
p_train.add_argument("--zhead-ckpt", default=None)
|
|
54
|
+
p_train.add_argument("--sr2-ckpt", default=None)
|
|
55
|
+
p_train.add_argument("--zhead-bootstrap-ckpt", default=None,
|
|
56
|
+
help="zhead --source sr2: the frozen head that conditions SR2")
|
|
57
|
+
p_train.add_argument("--init-ckpt", default=None,
|
|
58
|
+
help="sr1/sr2: warm-start the stage's weights from this "
|
|
59
|
+
"checkpoint for fine-tuning (architecture must match)")
|
|
60
|
+
|
|
61
|
+
p_infer = sub.add_parser("infer", help="Run the full pipeline on spectra.")
|
|
62
|
+
p_infer.add_argument("--dataset", required=True, help="Dataset .npz path")
|
|
63
|
+
p_infer.add_argument("--checkpoints", default=None,
|
|
64
|
+
help="Directory holding best_superres_model.pth, "
|
|
65
|
+
"config_logR.yaml, best_zhead.pth and best_sr2.pth")
|
|
66
|
+
p_infer.add_argument("--idx", type=int, nargs="+", help="Row indices")
|
|
67
|
+
p_infer.add_argument("--range", type=int, nargs=2, metavar=("START", "END"))
|
|
68
|
+
p_infer.add_argument("--save", default=None, help="Write outputs to this .npz")
|
|
69
|
+
|
|
70
|
+
p_eval = sub.add_parser("evaluate", help="Reproduce paper evaluations and figures.")
|
|
71
|
+
p_eval.add_argument(
|
|
72
|
+
"analysis",
|
|
73
|
+
choices=["all", "sample", "residuals", "line-flux", "line-snr",
|
|
74
|
+
"augmentation", "coverage", "redshift"],
|
|
75
|
+
)
|
|
76
|
+
p_eval.add_argument("--dataset", default=None)
|
|
77
|
+
p_eval.add_argument("--cache", default=None,
|
|
78
|
+
help="Prediction cache from scripts/make_predictions.py")
|
|
79
|
+
p_eval.add_argument("--outdir", default=None)
|
|
80
|
+
# The redshift figure's three arms are separate training runs.
|
|
81
|
+
p_eval.add_argument("--z-lowres", dest="z_lowres", default=None)
|
|
82
|
+
p_eval.add_argument("--z-hires", dest="z_hires", default=None)
|
|
83
|
+
p_eval.add_argument("--z-sr2", dest="z_sr2", default=None)
|
|
84
|
+
|
|
85
|
+
return parser
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parse_sweep_overrides(extras: list[str], parser: argparse.ArgumentParser) -> dict:
|
|
89
|
+
"""Turn leftover ``--name=value`` arguments into config overrides.
|
|
90
|
+
|
|
91
|
+
A W&B sweep agent appends every searched parameter to the command this way
|
|
92
|
+
(the ``${args}`` macro), and the set of names is whatever that sweep
|
|
93
|
+
searches -- so they cannot be declared on the parser in advance. Before this
|
|
94
|
+
existed, ``specsr train`` rejected them outright and **every trial of every
|
|
95
|
+
sweep failed at argument parsing**, which is why no sweep in this repo had
|
|
96
|
+
ever run against the package CLI.
|
|
97
|
+
|
|
98
|
+
Values are parsed as YAML scalars so ``--lr=1e-4`` arrives as a float and
|
|
99
|
+
``--use_var_clamp=true`` as a bool, rather than as strings that would then
|
|
100
|
+
be silently truthy.
|
|
101
|
+
"""
|
|
102
|
+
import yaml
|
|
103
|
+
|
|
104
|
+
overrides: dict = {}
|
|
105
|
+
i = 0
|
|
106
|
+
while i < len(extras):
|
|
107
|
+
token = extras[i]
|
|
108
|
+
if not token.startswith("--"):
|
|
109
|
+
parser.error(f"unrecognized arguments: {token}")
|
|
110
|
+
name, sep, raw = token[2:].partition("=")
|
|
111
|
+
if not sep:
|
|
112
|
+
if i + 1 >= len(extras) or extras[i + 1].startswith("--"):
|
|
113
|
+
parser.error(f"override --{name} was given no value")
|
|
114
|
+
raw = extras[i + 1]
|
|
115
|
+
i += 1
|
|
116
|
+
overrides[name.replace("-", "_")] = yaml.safe_load(raw)
|
|
117
|
+
i += 1
|
|
118
|
+
return overrides
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def main(argv: list[str] | None = None) -> int:
|
|
122
|
+
parser = _build_parser()
|
|
123
|
+
args, extras = parser.parse_known_args(argv)
|
|
124
|
+
|
|
125
|
+
if args.command is None:
|
|
126
|
+
parser.print_help()
|
|
127
|
+
return 1
|
|
128
|
+
|
|
129
|
+
# Only `train` tolerates undeclared flags, and only because a sweep agent
|
|
130
|
+
# supplies them. Anywhere else an unrecognised flag is a typo and must stay
|
|
131
|
+
# an error rather than being quietly dropped.
|
|
132
|
+
if extras and args.command != "train":
|
|
133
|
+
parser.error(f"unrecognized arguments: {' '.join(extras)}")
|
|
134
|
+
args.overrides = _parse_sweep_overrides(extras, parser) if extras else {}
|
|
135
|
+
|
|
136
|
+
if args.command == "build-dataset":
|
|
137
|
+
from .data.build import run_build
|
|
138
|
+
|
|
139
|
+
return run_build(args)
|
|
140
|
+
if args.command == "train":
|
|
141
|
+
from .training.runner import run_train
|
|
142
|
+
|
|
143
|
+
return run_train(args)
|
|
144
|
+
if args.command == "infer":
|
|
145
|
+
from .inference.pipeline import run_infer
|
|
146
|
+
|
|
147
|
+
return run_infer(args)
|
|
148
|
+
if args.command == "evaluate":
|
|
149
|
+
from .evaluation.runner import run_evaluate
|
|
150
|
+
|
|
151
|
+
return run_evaluate(args)
|
|
152
|
+
|
|
153
|
+
parser.error(f"unhandled command {args.command!r}")
|
|
154
|
+
return 2
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__": # pragma: no cover
|
|
158
|
+
sys.exit(main())
|
specsr/config.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Configuration loading for the training stages.
|
|
2
|
+
|
|
3
|
+
Configs come from two places and they do not look the same:
|
|
4
|
+
|
|
5
|
+
- **Hand-written YAML**, a flat mapping of ``name: value``.
|
|
6
|
+
- **Weights & Biases run dumps**, where every entry is wrapped as
|
|
7
|
+
``name: {value: ...}`` and a ``_wandb`` key carries run metadata (host, GPU,
|
|
8
|
+
git commit, ...) that is not configuration at all.
|
|
9
|
+
|
|
10
|
+
The shipped ``config.yaml`` files in this project are the second kind. Loading
|
|
11
|
+
one naively gives every hyperparameter as the dict ``{"value": ...}``, so
|
|
12
|
+
``float(cfg["lr"])`` raises and, worse, ``cfg.get("dropout", 0.1)`` silently
|
|
13
|
+
returns a dict that some call sites will happily pass into a layer constructor.
|
|
14
|
+
|
|
15
|
+
:func:`load_config` normalises both forms.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
__all__ = ["load_config", "unwrap_wandb_config", "merge_overrides"]
|
|
26
|
+
|
|
27
|
+
#: Keys written by W&B run dumps that are metadata, not configuration.
|
|
28
|
+
_WANDB_META_KEYS = {"_wandb", "_runtime", "_step", "_timestamp"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def unwrap_wandb_config(raw: dict[str, Any]) -> dict[str, Any]:
|
|
32
|
+
"""Flatten a W&B-style ``{name: {value: ...}}`` mapping.
|
|
33
|
+
|
|
34
|
+
Entries that are not wrapped are passed through unchanged, so this is safe
|
|
35
|
+
to apply to a hand-written config too. W&B metadata keys are dropped.
|
|
36
|
+
"""
|
|
37
|
+
out: dict[str, Any] = {}
|
|
38
|
+
for key, val in raw.items():
|
|
39
|
+
if key in _WANDB_META_KEYS:
|
|
40
|
+
continue
|
|
41
|
+
if isinstance(val, dict) and set(val.keys()) == {"value"}:
|
|
42
|
+
out[key] = val["value"]
|
|
43
|
+
else:
|
|
44
|
+
out[key] = val
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_config(path: str | Path, **overrides: Any) -> dict[str, Any]:
|
|
49
|
+
"""Load a YAML config, normalising W&B run dumps, then apply ``overrides``.
|
|
50
|
+
|
|
51
|
+
Overrides whose value is ``None`` are ignored, so argparse defaults can be
|
|
52
|
+
passed straight through without clobbering the file.
|
|
53
|
+
"""
|
|
54
|
+
path = Path(path)
|
|
55
|
+
if not path.exists():
|
|
56
|
+
raise FileNotFoundError(f"Config not found: {path}")
|
|
57
|
+
|
|
58
|
+
with path.open() as fh:
|
|
59
|
+
raw = yaml.safe_load(fh) or {}
|
|
60
|
+
if not isinstance(raw, dict):
|
|
61
|
+
raise TypeError(f"{path} must contain a mapping at the top level, got {type(raw).__name__}")
|
|
62
|
+
|
|
63
|
+
cfg = unwrap_wandb_config(raw)
|
|
64
|
+
return merge_overrides(cfg, **overrides)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def merge_overrides(cfg: dict[str, Any], **overrides: Any) -> dict[str, Any]:
|
|
68
|
+
"""Return ``cfg`` updated with the non-``None`` entries of ``overrides``."""
|
|
69
|
+
merged = dict(cfg)
|
|
70
|
+
merged.update({k: v for k, v in overrides.items() if v is not None})
|
|
71
|
+
return merged
|
specsr/data/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Dataset construction, loading and leak-free splitting.
|
|
2
|
+
|
|
3
|
+
The preprocessing pipeline turns raw JADES products into paired ``.npz``
|
|
4
|
+
datasets; :mod:`~specsr.data.datasets` loads them, and
|
|
5
|
+
:mod:`~specsr.data.splits` splits them over parent galaxies rather than rows.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .augment import AugmentationConfig, augment_pair
|
|
11
|
+
from .build import BuildConfig, build_dataset
|
|
12
|
+
from .datasets import FixedGridSpectraDataset, PairedSpectra, normalize_spectrum
|
|
13
|
+
from .grid import DEFAULT_GRID, LogWavelengthGrid, resample_flux_conserving
|
|
14
|
+
from .ingest import discover_spectra, group_by_target, read_spectrum
|
|
15
|
+
from .splits import get_or_make_split, make_group_split, parent_group_ids
|
|
16
|
+
from .stitch import StitchedSpectrum, stitch_gratings
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"BuildConfig",
|
|
20
|
+
"build_dataset",
|
|
21
|
+
"AugmentationConfig",
|
|
22
|
+
"augment_pair",
|
|
23
|
+
"DEFAULT_GRID",
|
|
24
|
+
"LogWavelengthGrid",
|
|
25
|
+
"resample_flux_conserving",
|
|
26
|
+
"discover_spectra",
|
|
27
|
+
"group_by_target",
|
|
28
|
+
"read_spectrum",
|
|
29
|
+
"StitchedSpectrum",
|
|
30
|
+
"stitch_gratings",
|
|
31
|
+
"FixedGridSpectraDataset",
|
|
32
|
+
"PairedSpectra",
|
|
33
|
+
"normalize_spectrum",
|
|
34
|
+
"get_or_make_split",
|
|
35
|
+
"make_group_split",
|
|
36
|
+
"parent_group_ids",
|
|
37
|
+
]
|
specsr/data/augment.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Augmentation of paired spectra, with explicit provenance.
|
|
2
|
+
|
|
3
|
+
Each galaxy is expanded into one original plus ``n_aug`` stochastic realizations:
|
|
4
|
+
a small Gaussian redshift offset, and flux perturbations scaled to the local
|
|
5
|
+
flux. The intent is to teach robustness to line-position variability and to
|
|
6
|
+
noise, rather than to add information.
|
|
7
|
+
|
|
8
|
+
Provenance is not optional
|
|
9
|
+
--------------------------
|
|
10
|
+
Every emitted row records ``parent_id``, the index of the galaxy it came from.
|
|
11
|
+
The original products did not: their ``id`` column was a running row index, so
|
|
12
|
+
after augmentation there was no way to tell which rows shared a parent. Splitting
|
|
13
|
+
then had to be reconstructed from ``(ra, dec, field)``, and any split drawn over
|
|
14
|
+
rows put ~16 near-duplicate siblings of every held-out galaxy into training.
|
|
15
|
+
|
|
16
|
+
Carrying ``parent_id`` makes the grouping explicit rather than inferred, so a
|
|
17
|
+
leak-free split is the obvious thing to write rather than something that has to
|
|
18
|
+
be recovered after the fact.
|
|
19
|
+
|
|
20
|
+
Redshift shifts are translations on a log grid
|
|
21
|
+
----------------------------------------------
|
|
22
|
+
Because ``log(lambda * (1 + z)) = log(lambda) + log(1 + z)``, applying a redshift
|
|
23
|
+
offset on a logarithmic wavelength grid is a *uniform shift* along the axis, the
|
|
24
|
+
same number of samples at every wavelength. On the old linear grid the same
|
|
25
|
+
operation stretched the spectrum, moving red features further than blue ones and
|
|
26
|
+
changing the sampling of a line depending on where it sat.
|
|
27
|
+
|
|
28
|
+
This also matches the model: the convolutional stages are translation
|
|
29
|
+
equivariant, so an augmentation that is a pure translation exercises exactly the
|
|
30
|
+
symmetry the architecture already has.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
|
|
37
|
+
import numpy as np
|
|
38
|
+
|
|
39
|
+
from .grid import LogWavelengthGrid
|
|
40
|
+
|
|
41
|
+
__all__ = ["AugmentationConfig", "shift_redshift_on_log_grid", "augment_pair"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class AugmentationConfig:
|
|
46
|
+
"""Parameters of the augmentation.
|
|
47
|
+
|
|
48
|
+
Attributes
|
|
49
|
+
----------
|
|
50
|
+
n_aug
|
|
51
|
+
Realizations generated per galaxy, in addition to the original.
|
|
52
|
+
sigma_z
|
|
53
|
+
Standard deviation of the Gaussian redshift offset.
|
|
54
|
+
noise_frac
|
|
55
|
+
Flux perturbation as a fraction of the local absolute flux.
|
|
56
|
+
seed
|
|
57
|
+
Base seed. Each galaxy derives its own generator from this and its
|
|
58
|
+
parent index, so a build is reproducible and adding galaxies does not
|
|
59
|
+
change the realizations of existing ones.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
n_aug: int = 20
|
|
63
|
+
sigma_z: float = 0.3
|
|
64
|
+
noise_frac: float = 0.10
|
|
65
|
+
seed: int = 42
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def shift_redshift_on_log_grid(
|
|
69
|
+
flux: np.ndarray,
|
|
70
|
+
valid: np.ndarray,
|
|
71
|
+
z_from: float,
|
|
72
|
+
z_to: float,
|
|
73
|
+
grid: LogWavelengthGrid,
|
|
74
|
+
err: np.ndarray | None = None,
|
|
75
|
+
):
|
|
76
|
+
"""Move a spectrum from redshift ``z_from`` to ``z_to``.
|
|
77
|
+
|
|
78
|
+
On a log grid this is a shift of ``log((1 + z_to) / (1 + z_from))`` in
|
|
79
|
+
``log lambda``, i.e. a constant number of samples. The shift is generally
|
|
80
|
+
fractional, so neighbouring samples are linearly combined; the validity mask
|
|
81
|
+
is shifted with the same weights and a sample is kept only if **both**
|
|
82
|
+
contributing samples were valid, so a shift cannot manufacture data at the
|
|
83
|
+
edge of a gap.
|
|
84
|
+
|
|
85
|
+
Samples shifted in from outside the grid are marked invalid rather than
|
|
86
|
+
wrapped or extrapolated: there is no measurement there.
|
|
87
|
+
"""
|
|
88
|
+
n = flux.size
|
|
89
|
+
step = np.log(grid.lambda_max / grid.lambda_min) / (n - 1)
|
|
90
|
+
delta = np.log((1.0 + z_to) / (1.0 + z_from)) / step
|
|
91
|
+
|
|
92
|
+
lo = int(np.floor(delta))
|
|
93
|
+
frac = float(delta - lo)
|
|
94
|
+
|
|
95
|
+
def _shift(arr, fill):
|
|
96
|
+
out = np.full(n, fill, dtype=arr.dtype if arr.dtype != bool else arr.dtype)
|
|
97
|
+
src = np.arange(n) - lo
|
|
98
|
+
ok = (src >= 0) & (src < n)
|
|
99
|
+
out[ok] = arr[src[ok]]
|
|
100
|
+
return out, ok
|
|
101
|
+
|
|
102
|
+
a, ok_a = _shift(flux.astype(float), np.nan)
|
|
103
|
+
b, ok_b = _shift(np.roll(flux.astype(float), -1), np.nan)
|
|
104
|
+
va, _ = _shift(valid, False)
|
|
105
|
+
vb, _ = _shift(np.roll(valid, -1), False)
|
|
106
|
+
|
|
107
|
+
shifted = (1.0 - frac) * a + frac * b
|
|
108
|
+
# Both contributors must be real, or the interpolation invents flux.
|
|
109
|
+
shifted_valid = va & vb & ok_a & ok_b & np.isfinite(shifted)
|
|
110
|
+
shifted = np.where(shifted_valid, shifted, np.nan)
|
|
111
|
+
|
|
112
|
+
if err is None:
|
|
113
|
+
return shifted, shifted_valid
|
|
114
|
+
|
|
115
|
+
ea, _ = _shift(err.astype(float), np.nan)
|
|
116
|
+
eb, _ = _shift(np.roll(err.astype(float), -1), np.nan)
|
|
117
|
+
# Linear combination of independent samples -> quadrature with the weights.
|
|
118
|
+
shifted_err = np.sqrt(((1.0 - frac) * ea) ** 2 + (frac * eb) ** 2)
|
|
119
|
+
shifted_err = np.where(shifted_valid, shifted_err, np.nan)
|
|
120
|
+
return shifted, shifted_valid, shifted_err
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def augment_pair(
|
|
124
|
+
flux_low: np.ndarray,
|
|
125
|
+
flux_low_err: np.ndarray,
|
|
126
|
+
valid_low: np.ndarray,
|
|
127
|
+
flux_high: np.ndarray,
|
|
128
|
+
flux_high_err: np.ndarray,
|
|
129
|
+
valid_high: np.ndarray,
|
|
130
|
+
z: float,
|
|
131
|
+
parent_id: int,
|
|
132
|
+
grid: LogWavelengthGrid,
|
|
133
|
+
config: AugmentationConfig | None = None,
|
|
134
|
+
) -> list[dict]:
|
|
135
|
+
"""Expand one galaxy into ``1 + n_aug`` rows.
|
|
136
|
+
|
|
137
|
+
The first row is the unmodified original. Each subsequent row applies the
|
|
138
|
+
*same* redshift offset to both members of the pair — they are the same
|
|
139
|
+
galaxy, so a shift that moved them differently would teach the model a
|
|
140
|
+
wavelength mapping that does not exist — and independent noise to each, since
|
|
141
|
+
their measurement noise is genuinely independent.
|
|
142
|
+
|
|
143
|
+
Returns a list of dicts, each carrying ``parent_id`` and the realized ``z``.
|
|
144
|
+
"""
|
|
145
|
+
config = config or AugmentationConfig()
|
|
146
|
+
rng = np.random.default_rng([config.seed, parent_id])
|
|
147
|
+
|
|
148
|
+
rows = [
|
|
149
|
+
{
|
|
150
|
+
"flux_low": np.asarray(flux_low, dtype=float),
|
|
151
|
+
"flux_low_err": np.asarray(flux_low_err, dtype=float),
|
|
152
|
+
"valid_low": np.asarray(valid_low, dtype=bool),
|
|
153
|
+
"flux_high": np.asarray(flux_high, dtype=float),
|
|
154
|
+
"flux_high_err": np.asarray(flux_high_err, dtype=float),
|
|
155
|
+
"valid_high": np.asarray(valid_high, dtype=bool),
|
|
156
|
+
"z": float(z),
|
|
157
|
+
"parent_id": int(parent_id),
|
|
158
|
+
"is_original": True,
|
|
159
|
+
}
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
for _ in range(config.n_aug):
|
|
163
|
+
z_new = float(z + rng.normal(0.0, config.sigma_z))
|
|
164
|
+
# Negative redshift is unphysical and would invert the shift direction.
|
|
165
|
+
z_new = max(z_new, 0.0)
|
|
166
|
+
|
|
167
|
+
lo_f, lo_v, lo_e = shift_redshift_on_log_grid(
|
|
168
|
+
flux_low, valid_low, z, z_new, grid, err=flux_low_err
|
|
169
|
+
)
|
|
170
|
+
hi_f, hi_v, hi_e = shift_redshift_on_log_grid(
|
|
171
|
+
flux_high, valid_high, z, z_new, grid, err=flux_high_err
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
for f, e, v in ((lo_f, lo_e, lo_v), (hi_f, hi_e, hi_v)):
|
|
175
|
+
sigma = config.noise_frac * np.abs(np.nan_to_num(f))
|
|
176
|
+
noise = rng.normal(0.0, np.where(sigma > 0, sigma, 1e-30))
|
|
177
|
+
f[v] += noise[v]
|
|
178
|
+
# The added noise is part of the uncertainty budget from here on.
|
|
179
|
+
e[v] = np.sqrt(np.nan_to_num(e[v]) ** 2 + sigma[v] ** 2)
|
|
180
|
+
|
|
181
|
+
rows.append(
|
|
182
|
+
{
|
|
183
|
+
"flux_low": lo_f,
|
|
184
|
+
"flux_low_err": lo_e,
|
|
185
|
+
"valid_low": lo_v,
|
|
186
|
+
"flux_high": hi_f,
|
|
187
|
+
"flux_high_err": hi_e,
|
|
188
|
+
"valid_high": hi_v,
|
|
189
|
+
"z": z_new,
|
|
190
|
+
"parent_id": int(parent_id),
|
|
191
|
+
"is_original": False,
|
|
192
|
+
}
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
return rows
|