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 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
@@ -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