pygspice 2.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.
- gspice/__init__.py +80 -0
- gspice/_blocks.py +308 -0
- gspice/_datasets.py +237 -0
- gspice/_diagnostics.py +708 -0
- gspice/_linalg.py +549 -0
- gspice/_model.py +1690 -0
- gspice/_predict.py +1157 -0
- gspice/_repair.py +460 -0
- gspice/_spectra.py +511 -0
- gspice/legacy/__init__.py +30 -0
- gspice/legacy/_errors.py +2 -0
- gspice/legacy/djs_maskinterp.py +152 -0
- gspice/legacy/matrixUtils.py +204 -0
- gspice/legacy/specUtils.py +464 -0
- gspice/py.typed +0 -0
- pygspice-2.0.0.dist-info/METADATA +72 -0
- pygspice-2.0.0.dist-info/RECORD +19 -0
- pygspice-2.0.0.dist-info/WHEEL +4 -0
- pygspice-2.0.0.dist-info/licenses/LICENSE +674 -0
gspice/__init__.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Gaussian pixelwise conditional estimation for spectra."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import warnings
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from . import legacy
|
|
8
|
+
from ._blocks import fit_blocks
|
|
9
|
+
from ._datasets import Simulation, simulate
|
|
10
|
+
from ._diagnostics import Calibration, calibration, finite_sample_factor, noise_audit
|
|
11
|
+
from ._model import Blocks, LowRank, Model, fit, load, save
|
|
12
|
+
from ._predict import Prediction, Repaired, infill, pixelwise
|
|
13
|
+
from ._repair import repair
|
|
14
|
+
from ._spectra import scale, snr, unscale
|
|
15
|
+
|
|
16
|
+
# Without a handler, Python prints the records of level WARNING and above to stderr.
|
|
17
|
+
# With one, the application decides where they go.
|
|
18
|
+
logging.getLogger("gspice").addHandler(logging.NullHandler())
|
|
19
|
+
|
|
20
|
+
__version__ = "2.0.0"
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"Blocks",
|
|
24
|
+
"Calibration",
|
|
25
|
+
"LowRank",
|
|
26
|
+
"Model",
|
|
27
|
+
"Prediction",
|
|
28
|
+
"Repaired",
|
|
29
|
+
"Simulation",
|
|
30
|
+
"calibration",
|
|
31
|
+
"finite_sample_factor",
|
|
32
|
+
"fit",
|
|
33
|
+
"fit_blocks",
|
|
34
|
+
"infill",
|
|
35
|
+
"legacy",
|
|
36
|
+
"load",
|
|
37
|
+
"noise_audit",
|
|
38
|
+
"pixelwise",
|
|
39
|
+
"repair",
|
|
40
|
+
"save",
|
|
41
|
+
"scale",
|
|
42
|
+
"simulate",
|
|
43
|
+
"snr",
|
|
44
|
+
"unscale",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
# The 2022 names that gspice no longer defines, with their replacements in the new API
|
|
48
|
+
# (design section 7.2). None: the function has no replacement outside gspice.legacy.
|
|
49
|
+
_REPLACEMENT = {
|
|
50
|
+
"standard_scale": "scale",
|
|
51
|
+
"covar": "fit",
|
|
52
|
+
"covar_iter_mask": "fit",
|
|
53
|
+
"get_chimask": "pixelwise",
|
|
54
|
+
"gp_interp": "pixelwise",
|
|
55
|
+
"gaussian_estimate": "infill",
|
|
56
|
+
"cholesky_inv": None,
|
|
57
|
+
"submatrix_inv": None,
|
|
58
|
+
"submatrix_inv_mult": None,
|
|
59
|
+
"maskinterp1": None,
|
|
60
|
+
"maskinterp": None,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# PEP 562: gspice.<name> of a 2022 name returns gspice.legacy.<name> with a
|
|
65
|
+
# DeprecationWarning. Type checkers do not see the hook, so they report a misspelled or
|
|
66
|
+
# deprecated name instead of typing every attribute of gspice as Any.
|
|
67
|
+
if not TYPE_CHECKING:
|
|
68
|
+
|
|
69
|
+
def __getattr__(name: str) -> Any:
|
|
70
|
+
if name not in _REPLACEMENT:
|
|
71
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
72
|
+
use = f"gspice.legacy.{name}"
|
|
73
|
+
if replacement := _REPLACEMENT[name]:
|
|
74
|
+
use = f"gspice.{replacement}, or {use} to keep the 2022 results"
|
|
75
|
+
warnings.warn(
|
|
76
|
+
f"gspice.{name} is deprecated and will be removed in 3.0.0. Use {use}.",
|
|
77
|
+
DeprecationWarning,
|
|
78
|
+
stacklevel=2,
|
|
79
|
+
)
|
|
80
|
+
return getattr(legacy, name)
|
gspice/_blocks.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import math
|
|
3
|
+
import time
|
|
4
|
+
from fractions import Fraction
|
|
5
|
+
from typing import Any, NamedTuple
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from numpy.linalg import LinAlgError
|
|
9
|
+
from numpy.typing import ArrayLike, NDArray
|
|
10
|
+
from scipy.linalg import lapack
|
|
11
|
+
|
|
12
|
+
from ._linalg import factor_pattern, factor_prior
|
|
13
|
+
from ._model import NGUARD_MAX, Blocks, Model, submatrix_of
|
|
14
|
+
from ._predict import filled, noisy, spans, validated, working
|
|
15
|
+
from ._spectra import (
|
|
16
|
+
Integer,
|
|
17
|
+
Progress,
|
|
18
|
+
Real,
|
|
19
|
+
check_progress,
|
|
20
|
+
integer,
|
|
21
|
+
real,
|
|
22
|
+
to_flux,
|
|
23
|
+
to_working,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_logger = logging.getLogger("gspice")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def fit_blocks(
|
|
30
|
+
model: Model,
|
|
31
|
+
flux: ArrayLike,
|
|
32
|
+
ivar: ArrayLike,
|
|
33
|
+
mask: ArrayLike | None = None,
|
|
34
|
+
*,
|
|
35
|
+
width: Integer = 150,
|
|
36
|
+
stride: Integer = 75,
|
|
37
|
+
nguard: Integer = 20,
|
|
38
|
+
percentile: Real = 99.9,
|
|
39
|
+
progress: Progress | None = None,
|
|
40
|
+
) -> Model:
|
|
41
|
+
r"""Return the model with block thresholds from clean spectra.
|
|
42
|
+
|
|
43
|
+
Block :math:`j` holds the ``width`` pixels from ``starts[j]``. The starts are 0,
|
|
44
|
+
``stride``, 2 ``stride`` and so on, up to npix − ``width``. If the last of them is
|
|
45
|
+
smaller than npix − ``width``, the start npix − ``width`` follows. The defaults
|
|
46
|
+
give 31 blocks at npix 2400. fit_blocks first replaces the bad pixels by their
|
|
47
|
+
conditional mean, as `infill` does. It does not detect outliers by z.
|
|
48
|
+
|
|
49
|
+
Take a spectrum in S/N bin :math:`b`, with :math:`x` the working vector of its
|
|
50
|
+
infilled flux (see `Model`) and :math:`P` the inverse of ``cov[b]``. Let :math:`A`
|
|
51
|
+
be a block, :math:`G` the pixels within ``nguard`` of :math:`A`, clipped at the
|
|
52
|
+
ends, and :math:`O` the pixels outside :math:`G`. Then
|
|
53
|
+
|
|
54
|
+
.. math::
|
|
55
|
+
|
|
56
|
+
\chi^2_A = \frac{r_A^\mathsf{T} C_{AA}^{-1} r_A}{|A|}, \qquad r_A = x_A - \mu_A,
|
|
57
|
+
|
|
58
|
+
where :math:`\mu_A` and :math:`C_{AA} = (P_{GG}^{-1})_{AA}` are the conditional mean
|
|
59
|
+
and covariance of :math:`x_A` given :math:`x_O`. The guard pixels
|
|
60
|
+
:math:`G \setminus A` are thus marginalized. Where :math:`G` covers all pixels,
|
|
61
|
+
:math:`\mu_A` and :math:`C_{AA}` are those of the prior, 0 and ``cov[b]`` at
|
|
62
|
+
:math:`A`, computed without :math:`P`.
|
|
63
|
+
|
|
64
|
+
``thresholds[b, j]`` is ``np.percentile`` (linear) of :math:`\chi^2_A` of block
|
|
65
|
+
:math:`j`, at :math:`p` = ``percentile``, over the :math:`m` spectra of bin
|
|
66
|
+
:math:`b`. A block without an artifact then exceeds its threshold with the
|
|
67
|
+
probability
|
|
68
|
+
|
|
69
|
+
.. math::
|
|
70
|
+
|
|
71
|
+
\frac{m (1 - p/100) + p/100}{m + 1}
|
|
72
|
+
|
|
73
|
+
if :math:`(m - 1) p/100` is an integer. Otherwise the probability is at most this
|
|
74
|
+
value, for a density that falls beyond the threshold. For :math:`p = 99.9`, it is
|
|
75
|
+
0.20% at :math:`m = 1000` and 0.12% at :math:`m = 5000`. `repair` also infills the
|
|
76
|
+
pixels that z flags, which lowers this rate. Use clean held-out spectra.
|
|
77
|
+
|
|
78
|
+
For a noise-aware model, :math:`x` is in flux units. The covariance :math:`C_s` of
|
|
79
|
+
each spectrum (see `Model`) then replaces ``cov[b]`` in its infill and its blocks.
|
|
80
|
+
|
|
81
|
+
Parameters
|
|
82
|
+
----------
|
|
83
|
+
model : Model
|
|
84
|
+
The model.
|
|
85
|
+
flux, ivar, mask : array_like, shape (nspec, npix)
|
|
86
|
+
As in `scale`, with the npix of ``model``. Clean spectra, with at least
|
|
87
|
+
:math:`m` spectra in each bin such that :math:`m (100 - p) \ge 100`. The check
|
|
88
|
+
reads :math:`p` as the decimal ``Fraction(str(p))``: 1000 spectra at
|
|
89
|
+
:math:`p = 99.9`.
|
|
90
|
+
width : int, optional
|
|
91
|
+
The number of pixels per block, :math:`|A|`; from 1 to npix.
|
|
92
|
+
stride : int, optional
|
|
93
|
+
The distance between the starts of consecutive blocks (pixels); 1 or more.
|
|
94
|
+
nguard : int, optional
|
|
95
|
+
The half-width of the guard window around each block (pixels); from 0 to
|
|
96
|
+
:math:`2^{63} - 1`, as for `Blocks`.
|
|
97
|
+
percentile : float, optional
|
|
98
|
+
The percentile :math:`p` of the thresholds; :math:`0 < p < 100`.
|
|
99
|
+
progress : callable, optional
|
|
100
|
+
``progress(stage, done, total)``, called after each chunk of spectra, with the
|
|
101
|
+
stage ``"fit_blocks"``, the number of spectra done and their total. For a
|
|
102
|
+
noise-aware model, a chunk is one spectrum.
|
|
103
|
+
|
|
104
|
+
Returns
|
|
105
|
+
-------
|
|
106
|
+
Model
|
|
107
|
+
A new model, equal to ``model`` except for ``blocks``: a `Blocks` that holds the
|
|
108
|
+
starts, width, nguard and thresholds.
|
|
109
|
+
|
|
110
|
+
Raises
|
|
111
|
+
------
|
|
112
|
+
TypeError
|
|
113
|
+
If ``model`` is not a Model, or ``width``, ``stride`` or ``nguard`` is not an
|
|
114
|
+
integer. If ``percentile`` is not a real number, or ``progress`` is not callable
|
|
115
|
+
or None. If ``flux``, ``ivar`` or ``mask`` has a dtype that `scale` does not
|
|
116
|
+
allow.
|
|
117
|
+
ValueError
|
|
118
|
+
If ``flux``, ``ivar`` or ``mask`` has a shape or a value that `scale` does not
|
|
119
|
+
allow. If ``flux`` has another npix than ``model``, or a spectrum has no good
|
|
120
|
+
pixel. If ``width``, ``stride``, ``nguard`` or ``percentile`` is outside its
|
|
121
|
+
range. If a bin has fewer spectra than :math:`p` needs; the message names the
|
|
122
|
+
bin and its count. If a threshold is not finite or is 0, as for spectra equal to
|
|
123
|
+
their conditional mean.
|
|
124
|
+
numpy.linalg.LinAlgError
|
|
125
|
+
If the covariance of a bin fails the check of its precision matrix, as in
|
|
126
|
+
`pixelwise`, and the :math:`G` of a block does not cover all pixels.
|
|
127
|
+
If :math:`P_{GG}` of a block is not positive definite. If ``cov[b]`` at
|
|
128
|
+
:math:`T = A` fails that check, for a block whose :math:`G` covers all pixels.
|
|
129
|
+
Also as in `infill`, for the bad pixels. The message names the bin, and the
|
|
130
|
+
block or the row of a pattern. For a noise-aware model, as in `infill`, with
|
|
131
|
+
:math:`C_s` in place of ``cov[b]``.
|
|
132
|
+
|
|
133
|
+
Notes
|
|
134
|
+
-----
|
|
135
|
+
Cost:
|
|
136
|
+
|
|
137
|
+
- :math:`\mathrm{npix}^3` flops for :math:`P` at the first use of a bin, then
|
|
138
|
+
:math:`|G|^3/3` flops per bin and block.
|
|
139
|
+
- About :math:`2\,\mathrm{npix}^2 + \sum_A |G|^2` flops per spectrum, plus the
|
|
140
|
+
`infill` of the bad pixels, of the mean only.
|
|
141
|
+
- The factors of all bins take :math:`\mathrm{nbin} \sum_A |G|^2 \times 8` bytes
|
|
142
|
+
during the scan: 8.8 MB per bin at npix 2400 with the defaults, and 649 MB at
|
|
143
|
+
``stride=1``.
|
|
144
|
+
- Beyond the inputs, and their float64 copies if they are long double, fit_blocks
|
|
145
|
+
holds a bool array of the input shape. It also holds the :math:`\chi^2_A` of the
|
|
146
|
+
spectra of one bin (nblock float64 per spectrum), and a few float64 arrays of one
|
|
147
|
+
chunk of ``max(1, 2**24 // npix)`` spectra.
|
|
148
|
+
- The new model shares the arrays of ``model``, its precision matrices and its guard
|
|
149
|
+
weights (see `Model`).
|
|
150
|
+
- Unless every block covers all pixels with its guard, a noise-aware model forms
|
|
151
|
+
:math:`P_s` and the factors of the blocks for each spectrum. :math:`P_s` takes
|
|
152
|
+
:math:`\mathrm{npix}^3` flops for a dense ``cov[0]``, or :math:`\mathrm{npix}^2 K`
|
|
153
|
+
for a `LowRank` of rank :math:`K`. Otherwise the infill of the bad pixels forms no
|
|
154
|
+
:math:`P_s`, and costs as in `infill`.
|
|
155
|
+
"""
|
|
156
|
+
start = time.perf_counter()
|
|
157
|
+
width = integer(width, "width", 1)
|
|
158
|
+
stride = integer(stride, "stride", 1)
|
|
159
|
+
nguard = integer(nguard, "nguard", 0, NGUARD_MAX)
|
|
160
|
+
percentile = real(percentile, "percentile")
|
|
161
|
+
if not 0 < percentile < 100:
|
|
162
|
+
raise ValueError(
|
|
163
|
+
f"percentile: expected a number > 0 and < 100, got {percentile}"
|
|
164
|
+
)
|
|
165
|
+
check_progress(progress)
|
|
166
|
+
flux, ivar, bad = validated(model, flux, ivar, mask, inflate=False)
|
|
167
|
+
nspec, npix = flux.shape
|
|
168
|
+
if width > npix:
|
|
169
|
+
raise ValueError(
|
|
170
|
+
f"width: expected an integer <= {npix}, the pixels of model, got {width}"
|
|
171
|
+
)
|
|
172
|
+
nbin = len(model.cov)
|
|
173
|
+
bins = model._bins(flux, ivar, bad)
|
|
174
|
+
# Decision A7, in exact arithmetic with the decimal that prints as percentile: in
|
|
175
|
+
# float arithmetic, 99.9 would need 1001 spectra.
|
|
176
|
+
p = Fraction(str(percentile))
|
|
177
|
+
for b, m in enumerate(np.bincount(bins, minlength=nbin).tolist()):
|
|
178
|
+
if m * (100 - p) < 100:
|
|
179
|
+
raise ValueError(
|
|
180
|
+
f"fit_blocks: percentile {percentile} needs at least "
|
|
181
|
+
f"{math.ceil(100 / (100 - p))} spectra in each S/N bin, got {m} in "
|
|
182
|
+
f"bin {b}"
|
|
183
|
+
)
|
|
184
|
+
# A stride above npix gives the starts of npix, and keeps arange in int64.
|
|
185
|
+
starts = np.append(np.arange(0, npix - width, min(stride, npix)), npix - width)
|
|
186
|
+
# The factors of every bin, and P of the bins with bad pixels for their infill,
|
|
187
|
+
# before any spectrum, so that a covariance that fails a check raises at once. A
|
|
188
|
+
# noise model forms them for each spectrum.
|
|
189
|
+
factors: list[list[BlockFactor]] = []
|
|
190
|
+
if not model.noise:
|
|
191
|
+
factors = [block_factors(model, b, starts, width, nguard) for b in range(nbin)]
|
|
192
|
+
for b in np.unique(bins[bad.any(axis=1)]).tolist():
|
|
193
|
+
model._precision(b)
|
|
194
|
+
thresholds = np.empty((nbin, starts.size))
|
|
195
|
+
done = 0
|
|
196
|
+
for b in range(nbin):
|
|
197
|
+
rows = np.flatnonzero(bins == b)
|
|
198
|
+
chi2 = np.empty((rows.size, starts.size))
|
|
199
|
+
for c in spans(model, rows.size, npix):
|
|
200
|
+
spectra = rows[c]
|
|
201
|
+
cover = (
|
|
202
|
+
noisy(model, ivar[spectra], bad[spectra], int(spectra[0]))
|
|
203
|
+
if model.noise
|
|
204
|
+
else model
|
|
205
|
+
)
|
|
206
|
+
F = (
|
|
207
|
+
block_factors(cover, 0, starts, width, nguard)
|
|
208
|
+
if model.noise
|
|
209
|
+
else factors[b]
|
|
210
|
+
)
|
|
211
|
+
X = _scan_vectors(model, cover, b, flux, ivar, bad, spectra)
|
|
212
|
+
chi2[c] = block_chi2(F, X)
|
|
213
|
+
del cover, F
|
|
214
|
+
done += spectra.size
|
|
215
|
+
if progress is not None:
|
|
216
|
+
progress("fit_blocks", done, nspec)
|
|
217
|
+
thresholds[b] = np.percentile(chi2, percentile, axis=0)
|
|
218
|
+
_logger.debug(
|
|
219
|
+
"fit_blocks: %d spectra, %d blocks in %.3f s",
|
|
220
|
+
nspec,
|
|
221
|
+
starts.size,
|
|
222
|
+
time.perf_counter() - start,
|
|
223
|
+
)
|
|
224
|
+
return model._with_blocks(Blocks(starts, width, nguard, thresholds))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _scan_vectors(
|
|
228
|
+
model: Model,
|
|
229
|
+
cover: Model,
|
|
230
|
+
b: int,
|
|
231
|
+
flux: NDArray[Any],
|
|
232
|
+
ivar: NDArray[Any],
|
|
233
|
+
bad: NDArray[np.bool_],
|
|
234
|
+
spectra: NDArray[np.intp],
|
|
235
|
+
) -> NDArray[np.float64]:
|
|
236
|
+
# The working vectors x of the infilled flux of the spectra of bin b, under the
|
|
237
|
+
# covariance of cover.
|
|
238
|
+
bins = np.full(spectra.size, b)
|
|
239
|
+
G = bad[spectra]
|
|
240
|
+
X, a = working(model, flux[spectra], ivar[spectra], G, bins)
|
|
241
|
+
X = filled(cover, X, bins, spectra, G, G)
|
|
242
|
+
# Through flux units, as the docstring of fit_blocks defines x.
|
|
243
|
+
center = 0.0 if model.center is None else model.center
|
|
244
|
+
x = np.array(flux[spectra], dtype=np.float64)
|
|
245
|
+
to_flux(X, a, model.mean[b], center, out=X)
|
|
246
|
+
x[G] = X[G]
|
|
247
|
+
to_working(x, a, center, model.mean[b])
|
|
248
|
+
return x
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
class BlockFactor(NamedTuple):
|
|
252
|
+
# Block A and G, the pixels within nguard of A clipped at the ends; order lists the
|
|
253
|
+
# pixels of G, counted from G.start, as (G \ A, A). R is the lower Cholesky factor
|
|
254
|
+
# of P_GG in that order, with P the precision matrix of the bin. Where G covers all
|
|
255
|
+
# pixels, P is None and R is the factor of C_AA, the prior (design section 4.2).
|
|
256
|
+
A: slice
|
|
257
|
+
G: slice
|
|
258
|
+
order: NDArray[np.intp]
|
|
259
|
+
R: NDArray[np.float64]
|
|
260
|
+
P: NDArray[np.float64] | None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def block_factors(
|
|
264
|
+
model: Model, b: int, starts: NDArray[np.int64], width: int, nguard: int
|
|
265
|
+
) -> list[BlockFactor]:
|
|
266
|
+
npix = model.mean.shape[1]
|
|
267
|
+
factors = []
|
|
268
|
+
# Python integers, so that a large nguard cannot overflow.
|
|
269
|
+
for k, start in enumerate(starts.tolist()):
|
|
270
|
+
A = slice(start, start + width)
|
|
271
|
+
G = slice(max(start - nguard, 0), min(start + width + nguard, npix))
|
|
272
|
+
order = np.r_[G.start : A.start, A.stop : G.stop, A] - G.start
|
|
273
|
+
P = None if G.stop - G.start == npix else model._precision(b)
|
|
274
|
+
try:
|
|
275
|
+
if P is None:
|
|
276
|
+
R = factor_prior(submatrix_of(model.cov[b], np.arange(A.start, A.stop)))
|
|
277
|
+
else:
|
|
278
|
+
R = factor_pattern(P[G, G][np.ix_(order, order)])
|
|
279
|
+
except LinAlgError as error:
|
|
280
|
+
raise LinAlgError(f"{model._name(b)} is {error}, for block {k}") from None
|
|
281
|
+
factors.append(BlockFactor(A, G, order, R, P))
|
|
282
|
+
return factors
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def block_chi2(
|
|
286
|
+
factors: list[BlockFactor], X: NDArray[np.float64]
|
|
287
|
+
) -> NDArray[np.float64]:
|
|
288
|
+
# chi2_A (nspec, nblock) of the working vectors X of spectra of the bin of factors.
|
|
289
|
+
chi2 = np.empty((len(X), len(factors)))
|
|
290
|
+
Y = None
|
|
291
|
+
for k, (A, G, order, R, P) in enumerate(factors):
|
|
292
|
+
width = A.stop - A.start
|
|
293
|
+
if P is None:
|
|
294
|
+
# ||L^-1 x_A||^2 with L L^T = C_AA, the prior covariance of x_A.
|
|
295
|
+
w, _ = lapack.dtrtrs(R, X[:, A].T, lower=1)
|
|
296
|
+
else:
|
|
297
|
+
if Y is None:
|
|
298
|
+
# (P x)^T of every spectrum, once for all blocks: P is symmetric.
|
|
299
|
+
Y = X @ P
|
|
300
|
+
# y = R^-1 (P x)_G in the order of R. As R R^T = P_GG and r_G = x_G - mu_G
|
|
301
|
+
# = P_GG^-1 (P x)_G, R^T r_G = y, so R_AA^T r_A = y_A (R^T is upper
|
|
302
|
+
# triangular) and chi2_A |A| = ||y_A||^2. The guard pixels enter y as R^T
|
|
303
|
+
# x_(G \ A), which is 0 in the rows of A, so an outlier there cancels to
|
|
304
|
+
# rounding (test_blocks bounds it).
|
|
305
|
+
w, _ = lapack.dtrtrs(R, np.take(Y, G.start + order, axis=1).T, lower=1)
|
|
306
|
+
w = w[-width:]
|
|
307
|
+
chi2[:, k] = np.sum(w**2, axis=0) / width
|
|
308
|
+
return chi2
|
gspice/_datasets.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
from numpy.typing import NDArray
|
|
6
|
+
from scipy.special import expit, logit
|
|
7
|
+
|
|
8
|
+
from ._spectra import Boolean, Integer, Real, boolean, check_rng, integer, real
|
|
9
|
+
|
|
10
|
+
MAX_DEPTH = 0.95
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, eq=False)
|
|
14
|
+
class Simulation:
|
|
15
|
+
r"""Synthetic spectra from `simulate`, with their noise-free flux and artifacts.
|
|
16
|
+
|
|
17
|
+
The arrays that `simulate` returns are read-only.
|
|
18
|
+
|
|
19
|
+
Attributes
|
|
20
|
+
----------
|
|
21
|
+
flux : ndarray, shape (nspec, npix)
|
|
22
|
+
The flux, with noise and artifacts (flux).
|
|
23
|
+
ivar : ndarray, shape (nspec, npix)
|
|
24
|
+
The inverse variance of the noise, :math:`1/\sigma^2` (flux⁻²).
|
|
25
|
+
truth : ndarray, shape (nspec, npix)
|
|
26
|
+
The noise-free flux without artifacts (flux).
|
|
27
|
+
artifacts : ndarray of bool, shape (nspec, npix)
|
|
28
|
+
The pixels that an injected artifact covers.
|
|
29
|
+
snr : ndarray, shape (nspec,)
|
|
30
|
+
The continuum signal-to-noise ratio that each spectrum was drawn with
|
|
31
|
+
(unit-free).
|
|
32
|
+
labels : ndarray of int64, shape (nspec,)
|
|
33
|
+
The population of each spectrum: 0, or 1 for the giants.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
flux: NDArray[np.float64]
|
|
37
|
+
ivar: NDArray[np.float64]
|
|
38
|
+
truth: NDArray[np.float64]
|
|
39
|
+
artifacts: NDArray[np.bool_]
|
|
40
|
+
snr: NDArray[np.float64]
|
|
41
|
+
labels: NDArray[np.int64]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def simulate(
|
|
45
|
+
nspec: Integer,
|
|
46
|
+
npix: Integer = 500,
|
|
47
|
+
*,
|
|
48
|
+
rng: np.random.Generator,
|
|
49
|
+
artifacts: Real = 0.0,
|
|
50
|
+
gaussian: Boolean = False,
|
|
51
|
+
) -> Simulation:
|
|
52
|
+
r"""Synthetic continuum-normalized spectra for tests and examples (no network).
|
|
53
|
+
|
|
54
|
+
Each spectrum has the continuum 1 and
|
|
55
|
+
:math:`\mathrm{nline} = \lceil \mathrm{npix}/25 \rceil` absorption lines
|
|
56
|
+
:math:`j = 1, \dots, \mathrm{nline}` at evenly spaced centres. Line :math:`j` has a
|
|
57
|
+
Gaussian profile :math:`p_j`, with a standard deviation from 1.5 to 4 pixels.
|
|
58
|
+
The lines depend only on npix. Two latent parameters :math:`\theta` set the depths.
|
|
59
|
+
A spectrum is in population 1, the giants, with the probability 0.3, and then has
|
|
60
|
+
:math:`\theta \sim N((0, 2), I)`. Otherwise :math:`\theta \sim N(0, I)`.
|
|
61
|
+
Line :math:`j` has the depth
|
|
62
|
+
|
|
63
|
+
.. math::
|
|
64
|
+
|
|
65
|
+
\mathrm{depth}_j = 0.95\,\mathrm{expit}\bigl(\mathrm{logit}(d_j / 0.95)
|
|
66
|
+
+ r_j \cdot \theta\bigr),
|
|
67
|
+
|
|
68
|
+
with :math:`d_j` from 0.1 to 0.6 its depth at :math:`\theta = 0`, and
|
|
69
|
+
:math:`r_j = 0.5 (\cos j, \sin j)`. The depths are thus correlated, in (0, 0.95),
|
|
70
|
+
and not Gaussian. Then :math:`\mathrm{truth} = 1 - \sum_j \mathrm{depth}_j\, p_j`,
|
|
71
|
+
in (0, 1]. The continuum S/N has the density
|
|
72
|
+
:math:`p(\mathrm{S/N}) \propto (\mathrm{S/N})^{-2.5}` on [30, 400]. The noise is
|
|
73
|
+
Gaussian with :math:`\sigma = \sqrt{\mathrm{truth}} / \mathrm{snr}`.
|
|
74
|
+
|
|
75
|
+
With ``gaussian=True``, :math:`\mathrm{truth} = \mu + J \theta` with
|
|
76
|
+
:math:`\theta \sim N(0, I)`. Here :math:`\mu` is the truth of population 0 at
|
|
77
|
+
:math:`\theta = 0`, and the columns of :math:`J` are its derivatives there.
|
|
78
|
+
Every spectrum has the label 0 and the S/N 50, and :math:`\sigma = \sqrt{\mu} / 50`.
|
|
79
|
+
So ``ivar`` is equal for all spectra. Without artifacts,
|
|
80
|
+
:math:`\mathrm{flux} \sim N(\mu, J J^\mathsf{T} + \mathrm{diag}(\sigma^2))`, and the
|
|
81
|
+
scaled spectra (see `scale`) are exactly Gaussian. truth can then leave (0, 1].
|
|
82
|
+
|
|
83
|
+
``round(artifacts * nspec)`` spectra (half to even), chosen at random, get one
|
|
84
|
+
artifact each. Its type is drawn uniformly from three:
|
|
85
|
+
|
|
86
|
+
- A cosmic ray adds :math:`h \sigma` to each of 1 to 3 adjacent pixels, with
|
|
87
|
+
:math:`h` log-uniform on [5, 50].
|
|
88
|
+
- A bad column sets the flux of 5 to 20 adjacent pixels to 2.
|
|
89
|
+
- A shifted segment of 200 to 600 adjacent pixels :math:`i` has the flux
|
|
90
|
+
:math:`\mathrm{truth}(i - d)` plus the noise. The shift :math:`d` is from 0.5 to 3
|
|
91
|
+
pixels, of either sign. truth is interpolated linearly, and held constant beyond
|
|
92
|
+
its ends.
|
|
93
|
+
|
|
94
|
+
An artifact covers at most npix − 1 pixels, or 1 if npix is 1. Its start is drawn
|
|
95
|
+
uniformly from the starts where it fits. For one state of ``rng``, every value of
|
|
96
|
+
``artifacts`` gives the same ``ivar``, ``truth``, ``snr`` and ``labels``. It also
|
|
97
|
+
gives the same ``flux`` outside the pixels in ``Simulation.artifacts``.
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
nspec : int
|
|
102
|
+
The number of spectra; 0 or more.
|
|
103
|
+
npix : int, optional
|
|
104
|
+
The number of pixels; 1 or more.
|
|
105
|
+
rng : numpy.random.Generator
|
|
106
|
+
The source of all randomness. With the same libraries and thread settings, the
|
|
107
|
+
same state gives the same result bit for bit.
|
|
108
|
+
artifacts : float, optional
|
|
109
|
+
The fraction of spectra with an artifact; from 0 to 1.
|
|
110
|
+
gaussian : bool, optional
|
|
111
|
+
Draw exactly Gaussian spectra, as above.
|
|
112
|
+
|
|
113
|
+
Returns
|
|
114
|
+
-------
|
|
115
|
+
Simulation
|
|
116
|
+
The spectra.
|
|
117
|
+
|
|
118
|
+
Raises
|
|
119
|
+
------
|
|
120
|
+
TypeError
|
|
121
|
+
If an argument has a type that is not allowed above.
|
|
122
|
+
ValueError
|
|
123
|
+
If an argument has a value that is not allowed above.
|
|
124
|
+
|
|
125
|
+
Notes
|
|
126
|
+
-----
|
|
127
|
+
Cost: :math:`O(\mathrm{nspec}\,\mathrm{npix})` time and memory, plus the nline
|
|
128
|
+
profiles of npix values each. With ``gaussian=False``, the line sum adds
|
|
129
|
+
:math:`2\,\mathrm{nspec}\,\mathrm{nline}\,\mathrm{npix}` flops.
|
|
130
|
+
"""
|
|
131
|
+
nspec = integer(nspec, "nspec", 0)
|
|
132
|
+
npix = integer(npix, "npix", 1)
|
|
133
|
+
check_rng(rng)
|
|
134
|
+
artifacts = real(artifacts, "artifacts")
|
|
135
|
+
if not 0 <= artifacts <= 1:
|
|
136
|
+
raise ValueError(f"artifacts: expected a number from 0 to 1, got {artifacts}")
|
|
137
|
+
gaussian = boolean(gaussian, "gaussian")
|
|
138
|
+
profiles, depth, response = _lines(npix)
|
|
139
|
+
if gaussian:
|
|
140
|
+
mu, J = _linearization(profiles, depth, response)
|
|
141
|
+
truth = mu + rng.standard_normal((nspec, 2)) @ J.T
|
|
142
|
+
labels = np.zeros(nspec, dtype=np.int64)
|
|
143
|
+
# Near 47.0, the median of the S/N law below.
|
|
144
|
+
snr = np.full(nspec, 50.0)
|
|
145
|
+
ivar = np.square(snr)[:, np.newaxis] / mu
|
|
146
|
+
else:
|
|
147
|
+
labels = (rng.random(nspec) < 0.3).astype(np.int64)
|
|
148
|
+
theta = rng.standard_normal((nspec, 2)) + np.outer(labels, (0.0, 2.0))
|
|
149
|
+
depths = MAX_DEPTH * expit(logit(depth / MAX_DEPTH) + theta @ response.T)
|
|
150
|
+
truth = 1 - depths @ profiles
|
|
151
|
+
# The inverse of the distribution function of the S/N, in a form where u = 0
|
|
152
|
+
# gives 30 exactly and u < 1 stays below 400 by about 3.5e-15 relative.
|
|
153
|
+
u = rng.random(nspec)
|
|
154
|
+
snr = 30 * (1 - u * (1 - (30 / 400) ** 1.5)) ** (-1 / 1.5)
|
|
155
|
+
ivar = np.square(snr)[:, np.newaxis] / truth
|
|
156
|
+
# In place, so that the noise holds one temporary of the size of flux.
|
|
157
|
+
flux = rng.standard_normal((nspec, npix))
|
|
158
|
+
flux /= np.sqrt(ivar)
|
|
159
|
+
flux += truth
|
|
160
|
+
flagged = _inject(flux, ivar, truth, rng, round(artifacts * nspec))
|
|
161
|
+
for array in (flux, ivar, truth, flagged, snr, labels):
|
|
162
|
+
array.flags.writeable = False
|
|
163
|
+
return Simulation(flux, ivar, truth, flagged, snr, labels)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _lines(
|
|
167
|
+
npix: int,
|
|
168
|
+
) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
|
|
169
|
+
# The profiles (nline, npix), the depths at theta = 0 (nline,) and the responses
|
|
170
|
+
# (nline, 2). The fractional parts of k sqrt(2) and k sqrt(3) spread the widths and
|
|
171
|
+
# the depths evenly over their ranges, and the response turns by 1 radian per line.
|
|
172
|
+
# From k = 1, every line, also a single one, depends on both latent parameters.
|
|
173
|
+
nline = math.ceil(npix / 25)
|
|
174
|
+
k = np.arange(1, nline + 1, dtype=np.float64)
|
|
175
|
+
width = 1.5 + 2.5 * (k * math.sqrt(2) % 1)
|
|
176
|
+
depth = 0.1 + 0.5 * (k * math.sqrt(3) % 1)
|
|
177
|
+
# The pixels span [-0.5, npix - 0.5], and the centres divide it evenly.
|
|
178
|
+
centre = (k - 0.5) * npix / nline - 0.5
|
|
179
|
+
offset = (np.arange(npix) - centre[:, np.newaxis]) / width[:, np.newaxis]
|
|
180
|
+
# 0 beyond 30 widths, where a profile is below 1e-195. No sum changes, because every
|
|
181
|
+
# pixel lies within 8.4 widths of a line, and nothing underflows: subnormal tails
|
|
182
|
+
# would raise under a caller's errstate and slow the line sum twofold.
|
|
183
|
+
profiles = np.exp(
|
|
184
|
+
-0.5 * np.square(offset), out=np.zeros_like(offset), where=np.abs(offset) < 30
|
|
185
|
+
)
|
|
186
|
+
response = 0.5 * np.column_stack((np.cos(k), np.sin(k)))
|
|
187
|
+
return profiles, depth, response
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _linearization(
|
|
191
|
+
profiles: NDArray[np.float64],
|
|
192
|
+
depth: NDArray[np.float64],
|
|
193
|
+
response: NDArray[np.float64],
|
|
194
|
+
) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
195
|
+
# mu (npix,) and J (npix, 2). At theta = 0, the depth of line k is d_k, and its
|
|
196
|
+
# derivative is d_k (1 - d_k / MAX_DEPTH) r_k.
|
|
197
|
+
slope = depth * (1 - depth / MAX_DEPTH)
|
|
198
|
+
return 1 - depth @ profiles, -profiles.T @ (slope[:, np.newaxis] * response)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _inject(
|
|
202
|
+
flux: NDArray[np.float64],
|
|
203
|
+
ivar: NDArray[np.float64],
|
|
204
|
+
truth: NDArray[np.float64],
|
|
205
|
+
rng: np.random.Generator,
|
|
206
|
+
count: int,
|
|
207
|
+
) -> NDArray[np.bool_]:
|
|
208
|
+
# Adds the artifacts to flux in place, and returns the pixels that they cover.
|
|
209
|
+
nspec, npix = flux.shape
|
|
210
|
+
rows = rng.choice(nspec, count, replace=False)
|
|
211
|
+
# 0, 1 and 2: a cosmic ray, a bad column and a shifted block.
|
|
212
|
+
kinds = rng.integers(3, size=count)
|
|
213
|
+
shortest, longest = np.array([(1, 3), (5, 20), (200, 600)])[kinds].T
|
|
214
|
+
widths = np.minimum(
|
|
215
|
+
rng.integers(shortest, longest, endpoint=True), max(npix - 1, 1)
|
|
216
|
+
)
|
|
217
|
+
starts = rng.integers(npix - widths, endpoint=True)
|
|
218
|
+
# u sets the height of a cosmic ray and the size of the shift of a block.
|
|
219
|
+
u = rng.random(count)
|
|
220
|
+
heights = 5 * 10**u
|
|
221
|
+
shifts = rng.choice((-1.0, 1.0), count) * (0.5 + 2.5 * u)
|
|
222
|
+
pixels = np.arange(npix)
|
|
223
|
+
flagged = np.zeros((nspec, npix), dtype=bool)
|
|
224
|
+
for s, kind, start, width, height, shift in zip(
|
|
225
|
+
rows, kinds, starts, widths, heights, shifts, strict=True
|
|
226
|
+
):
|
|
227
|
+
run = slice(start, start + width)
|
|
228
|
+
flagged[s, run] = True
|
|
229
|
+
if kind == 0:
|
|
230
|
+
flux[s, run] += height / np.sqrt(ivar[s, run])
|
|
231
|
+
elif kind == 1:
|
|
232
|
+
flux[s, run] = 2.0
|
|
233
|
+
else:
|
|
234
|
+
# The noise stays; the truth under it moves.
|
|
235
|
+
shifted = np.interp(pixels[run] - shift, pixels, truth[s])
|
|
236
|
+
flux[s, run] += shifted - truth[s, run]
|
|
237
|
+
return flagged
|