PyAETNA 4.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.
- PyAETNA/__init__.py +9 -0
- PyAETNA/__main__.py +5 -0
- PyAETNA/_device_config.py +57 -0
- PyAETNA/_xla_flags.py +50 -0
- PyAETNA/body_models.py +2336 -0
- PyAETNA/cli.py +52 -0
- PyAETNA/gp.py +791 -0
- PyAETNA/gp_parallel.py +172 -0
- PyAETNA/kernels/factory.py +823 -0
- PyAETNA/kernels/multichannel.py +417 -0
- PyAETNA/logger.py +73 -0
- PyAETNA/model_utils.py +282 -0
- PyAETNA/outputs.py +260 -0
- PyAETNA/physics/__init__.py +6 -0
- PyAETNA/physics/orbital.py +479 -0
- PyAETNA/physics/plotting.py +767 -0
- PyAETNA/physics/priors.py +89 -0
- PyAETNA/physics/spectra.py +483 -0
- PyAETNA/physics/stellar.py +369 -0
- PyAETNA/planet_phys.py +1414 -0
- PyAETNA/provenance.py +163 -0
- PyAETNA/py.typed +0 -0
- PyAETNA/reporting/__init__.py +64 -0
- PyAETNA/reporting/confidence_bands.py +1238 -0
- PyAETNA/reporting/corner_plot.py +1204 -0
- PyAETNA/reporting/data_validation.py +116 -0
- PyAETNA/reporting/diagnostics.py +580 -0
- PyAETNA/reporting/gp_plotting.py +942 -0
- PyAETNA/reporting/grid_builder.py +1257 -0
- PyAETNA/reporting/model_components.py +894 -0
- PyAETNA/reporting/model_math.py +1096 -0
- PyAETNA/reporting/multigp_plots.py +1681 -0
- PyAETNA/reporting/noise_plots.py +406 -0
- PyAETNA/reporting/panel_detrended.py +188 -0
- PyAETNA/reporting/panel_nontime_residuals.py +467 -0
- PyAETNA/reporting/panel_overlay.py +745 -0
- PyAETNA/reporting/panel_residuals.py +109 -0
- PyAETNA/reporting/param_sorting.py +155 -0
- PyAETNA/reporting/pdf_report.py +2119 -0
- PyAETNA/reporting/periodogram_plots.py +1467 -0
- PyAETNA/reporting/phase_plots.py +1529 -0
- PyAETNA/reporting/plot_data_prep.py +1123 -0
- PyAETNA/reporting/plot_models.py +177 -0
- PyAETNA/reporting/plot_styling.py +468 -0
- PyAETNA/reporting/residual_plotting.py +419 -0
- PyAETNA/reporting/trend_detection.py +128 -0
- PyAETNA/reporting/ttv_plots.py +1226 -0
- PyAETNA/run.py +813 -0
- PyAETNA/sampling/__init__.py +19 -0
- PyAETNA/sampling/centering.py +331 -0
- PyAETNA/sampling/config/parser/__init__.py +602 -0
- PyAETNA/sampling/config/parser/_constants.py +14 -0
- PyAETNA/sampling/config/parser/detrending.py +825 -0
- PyAETNA/sampling/config/parser/gp.py +386 -0
- PyAETNA/sampling/config/parser/multigp.py +697 -0
- PyAETNA/sampling/config/parser/ordering.py +154 -0
- PyAETNA/sampling/config/parser/planets.py +699 -0
- PyAETNA/sampling/config/parser/prior_refs.py +157 -0
- PyAETNA/sampling/config/parser/scoping.py +465 -0
- PyAETNA/sampling/config/parser/star.py +647 -0
- PyAETNA/sampling/config/priors.py +266 -0
- PyAETNA/sampling/config/section.py +51 -0
- PyAETNA/sampling/config/validation.py +231 -0
- PyAETNA/sampling/data.py +243 -0
- PyAETNA/sampling/eccentricity.py +65 -0
- PyAETNA/sampling/energy.py +431 -0
- PyAETNA/sampling/engine/checkpoint.py +461 -0
- PyAETNA/sampling/engine/context.py +24 -0
- PyAETNA/sampling/engine/convergence.py +126 -0
- PyAETNA/sampling/engine/data_loader.py +335 -0
- PyAETNA/sampling/engine/eiv.py +458 -0
- PyAETNA/sampling/engine/ensemble_kernels.py +238 -0
- PyAETNA/sampling/engine/exceptions.py +47 -0
- PyAETNA/sampling/engine/execution.py +500 -0
- PyAETNA/sampling/engine/numpyro_model.py +1031 -0
- PyAETNA/sampling/engine/runner.py +150 -0
- PyAETNA/sampling/engine/sampler_settings.py +192 -0
- PyAETNA/sampling/engine/sampler_utils.py +404 -0
- PyAETNA/sampling/engine/sampling_logging.py +292 -0
- PyAETNA/sampling/engine/sampling_utils.py +707 -0
- PyAETNA/sampling/model/core.py +774 -0
- PyAETNA/sampling/model/detrending.py +315 -0
- PyAETNA/sampling/nested_sampling.py +1457 -0
- PyAETNA/sampling/peaks.py +1225 -0
- PyAETNA/sampling/precision.py +393 -0
- PyAETNA/sampling/protocols.py +140 -0
- PyAETNA/sampling/utils.py +299 -0
- PyAETNA/utils/__init__.py +4 -0
- PyAETNA/utils/constants.py +26 -0
- PyAETNA/version.py +21 -0
- pyaetna-4.0.0.dist-info/METADATA +269 -0
- pyaetna-4.0.0.dist-info/RECORD +96 -0
- pyaetna-4.0.0.dist-info/WHEEL +5 -0
- pyaetna-4.0.0.dist-info/entry_points.txt +2 -0
- pyaetna-4.0.0.dist-info/licenses/LICENSE +21 -0
- pyaetna-4.0.0.dist-info/top_level.txt +1 -0
PyAETNA/gp.py
ADDED
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyAETNA GP Module - Shared Gaussian Process utilities
|
|
3
|
+
|
|
4
|
+
This module provides GP kernel building and prediction functions used by both
|
|
5
|
+
the sampler (PyAETNA/sampling/engine/runner.py) and reporting modules.
|
|
6
|
+
|
|
7
|
+
Centralizing GP code here ensures consistency between sampling and visualization.
|
|
8
|
+
|
|
9
|
+
Per-visit block-diagonal GP
|
|
10
|
+
----------------------------
|
|
11
|
+
When ``per_visit: true`` is set in a GP section, the covariance matrix is
|
|
12
|
+
factorised into independent blocks—one per visit—while **sharing** the same
|
|
13
|
+
kernel hyperparameters across all visits. This is a *computational strategy*,
|
|
14
|
+
not a parameter-scope expansion:
|
|
15
|
+
|
|
16
|
+
.. math::
|
|
17
|
+
|
|
18
|
+
\\log p(\\mathbf{y} \\mid \\theta)
|
|
19
|
+
= \\sum_v \\log p(\\mathbf{y}_v \\mid \\theta)
|
|
20
|
+
|
|
21
|
+
This factorisation is valid **only when the off-diagonal inter-visit blocks of
|
|
22
|
+
the covariance matrix are negligible**, i.e. when the kernel decays to zero over
|
|
23
|
+
the inter-visit gap. Kernels that violate this condition make the factorisation
|
|
24
|
+
mathematically incorrect:
|
|
25
|
+
|
|
26
|
+
* **``ExpSineSquared`` (strictly periodic kernel)** — covariance never decays;
|
|
27
|
+
$k(nP) = \\sigma^2$ at any integer multiple of the period. Per-visit is
|
|
28
|
+
**contraindicated** for this kernel.
|
|
29
|
+
* **``SHO_Quality`` / ``SHO`` with very high quality factor** — damping is slow;
|
|
30
|
+
cross-visit covariances remain significant when the inter-visit gap is much
|
|
31
|
+
shorter than the damping time $Q / \\omega_0$. Use with caution and verify
|
|
32
|
+
that the fitted Q implies fast damping relative to the gap.
|
|
33
|
+
* **``QuasiPeriodic``** — the SE envelope makes it conditionally valid when the
|
|
34
|
+
inter-visit gap $\\gg \\ell_\\text{SE}$. Safe for typical multi-visit
|
|
35
|
+
photometry (gaps of days, SE scales of hours).
|
|
36
|
+
|
|
37
|
+
For all monotonically decaying kernels (Matérn32, Matérn52, ExpSquared, SHO
|
|
38
|
+
with moderate Q) the benefits are:
|
|
39
|
+
|
|
40
|
+
* **O(N³) dense kernels** — wall-clock speedup proportional to V² (number of
|
|
41
|
+
visits squared), since each block is ``(N/V)³`` instead of ``N³``.
|
|
42
|
+
* **SHO quasisep kernels** — prevents the ``-inf`` numerical catastrophe that
|
|
43
|
+
occurs when Kalman transition-matrix elements ``exp(-α Δt)`` underflow at
|
|
44
|
+
large inter-visit gaps.
|
|
45
|
+
* **O(N) Matérn quasisep** — no benefit (already numerically robust and
|
|
46
|
+
linear); a small overhead from the Python loop is incurred.
|
|
47
|
+
|
|
48
|
+
See ``docs/gp_per_visit_analysis/GP_PER_VISIT_ANALYSIS.md`` for benchmarks.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
from collections.abc import Callable
|
|
54
|
+
from typing import Any
|
|
55
|
+
|
|
56
|
+
import jax
|
|
57
|
+
import jax.numpy as jnp
|
|
58
|
+
import numpy as np
|
|
59
|
+
from tinygp import GaussianProcess
|
|
60
|
+
|
|
61
|
+
# Import kernel functions from the new dedicated kernels/factory.py module
|
|
62
|
+
from PyAETNA.kernels.factory import (
|
|
63
|
+
build_gp_kernel_for_plotting,
|
|
64
|
+
build_gp_kernel_for_sampling,
|
|
65
|
+
build_gp_kernel_from_specs,
|
|
66
|
+
)
|
|
67
|
+
from PyAETNA.logger import _log
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
"build_gp_kernel_for_plotting",
|
|
71
|
+
"build_gp_kernel_for_sampling",
|
|
72
|
+
"build_gp_kernel_from_specs",
|
|
73
|
+
"compute_kernel_theoretical_psd",
|
|
74
|
+
"compute_windowed_psd",
|
|
75
|
+
"get_gp_x_variable",
|
|
76
|
+
"gp_log_probability_per_visit",
|
|
77
|
+
"gp_predict",
|
|
78
|
+
"gp_predict_per_visit",
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
# =============================================================================
|
|
82
|
+
# GP Prediction (JIT-compiled for speed)
|
|
83
|
+
# =============================================================================
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def gp_predict(
|
|
87
|
+
kernel: Any,
|
|
88
|
+
x: Any,
|
|
89
|
+
y: Any,
|
|
90
|
+
dy_sq: Any,
|
|
91
|
+
x_test: Any,
|
|
92
|
+
log_func: Callable[..., None] | None = None,
|
|
93
|
+
log_target: Any = None,
|
|
94
|
+
) -> tuple[jax.Array, jax.Array]:
|
|
95
|
+
"""
|
|
96
|
+
GP prediction wrapper. Handles shape validation and logging before
|
|
97
|
+
calling the JIT-compiled core.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
kernel: tinygp kernel object
|
|
101
|
+
x: Training inputs (1D or 2D array)
|
|
102
|
+
y: Training outputs (residuals)
|
|
103
|
+
dy_sq: Squared uncertainties (diagonal noise)
|
|
104
|
+
x_test: Test inputs (usually same as x for interpolation)
|
|
105
|
+
log_func: Logging callable (expected signature: log_func(target, msg))
|
|
106
|
+
log_target: Target for logging (e.g. log file path or PlModel object)
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
mean, std: Predicted mean and standard deviation at x_test
|
|
110
|
+
"""
|
|
111
|
+
# Convert to arrays for shape checking
|
|
112
|
+
x = jnp.asarray(x)
|
|
113
|
+
y = jnp.asarray(y)
|
|
114
|
+
dy_sq = jnp.asarray(dy_sq)
|
|
115
|
+
x_test = jnp.asarray(x_test)
|
|
116
|
+
|
|
117
|
+
# Shape repair and validation
|
|
118
|
+
if dy_sq.ndim > 1:
|
|
119
|
+
flat = dy_sq.reshape(-1)
|
|
120
|
+
if flat.shape[0] == y.shape[0]:
|
|
121
|
+
dy_sq = flat
|
|
122
|
+
else:
|
|
123
|
+
msg = f"ERROR: dy_sq shape {dy_sq.shape} cannot be flattened to match y length {y.shape[0]}"
|
|
124
|
+
if log_func:
|
|
125
|
+
log_func(log_target, msg)
|
|
126
|
+
raise ValueError(msg)
|
|
127
|
+
|
|
128
|
+
if dy_sq.shape[0] != y.shape[0]:
|
|
129
|
+
msg = f"ERROR: dy_sq length {dy_sq.shape[0]} must match y length {y.shape[0]}"
|
|
130
|
+
if log_func:
|
|
131
|
+
log_func(log_target, msg)
|
|
132
|
+
raise ValueError(msg)
|
|
133
|
+
|
|
134
|
+
return _gp_predict_jit(kernel, x, y, dy_sq, x_test)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@jax.jit
|
|
138
|
+
def _gp_predict_jit(
|
|
139
|
+
kernel: Any, x: jax.Array, y: jax.Array, dy_sq: jax.Array, x_test: jax.Array
|
|
140
|
+
) -> tuple[jax.Array, jax.Array]:
|
|
141
|
+
"""Core JIT-compiled GP prediction logic."""
|
|
142
|
+
gp = GaussianProcess(kernel, x, diag=dy_sq)
|
|
143
|
+
gp_cond = gp.condition(y, x_test)
|
|
144
|
+
return gp_cond.gp.loc, jnp.sqrt(gp_cond.gp.variance)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# =============================================================================
|
|
148
|
+
# GP x-variable extraction helper
|
|
149
|
+
# =============================================================================
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_gp_x_variable(
|
|
153
|
+
plData: Any, gp_cfg: dict[str, Any], inst_mask: Any, use_jax: bool = True
|
|
154
|
+
) -> jax.Array | np.ndarray:
|
|
155
|
+
"""
|
|
156
|
+
Extract GP input variable(s) for an instrument.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
plData: PlData object with time and dBasis
|
|
160
|
+
gp_cfg: GP configuration dict with 'x_variable' or 'x_variables'
|
|
161
|
+
inst_mask: Boolean mask or index array for the instrument
|
|
162
|
+
use_jax: If True, return jax arrays. If False, return numpy arrays.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
x_inst: Input array for GP (1D or 2D depending on number of variables)
|
|
166
|
+
"""
|
|
167
|
+
xp = jnp if use_jax else np
|
|
168
|
+
|
|
169
|
+
# Support both x_variable (string) and x_variables (list)
|
|
170
|
+
x_variables = gp_cfg.get("x_variables", gp_cfg.get("x_variable", ["TIME"]))
|
|
171
|
+
if isinstance(x_variables, str):
|
|
172
|
+
x_variables = [x_variables]
|
|
173
|
+
|
|
174
|
+
if len(x_variables) == 1:
|
|
175
|
+
# Single variable - 1D input
|
|
176
|
+
xv = x_variables[0]
|
|
177
|
+
if xv == "TIME":
|
|
178
|
+
x_var = xp.array(plData.time) if use_jax else np.array(plData.time)
|
|
179
|
+
else:
|
|
180
|
+
x_var = (
|
|
181
|
+
xp.array(plData.dBasis[xv]) if use_jax else np.array(plData.dBasis[xv])
|
|
182
|
+
)
|
|
183
|
+
x_inst = x_var[inst_mask]
|
|
184
|
+
else:
|
|
185
|
+
# Multiple variables - stack into 2D input
|
|
186
|
+
x_arrays = []
|
|
187
|
+
for xv in x_variables:
|
|
188
|
+
if xv == "TIME":
|
|
189
|
+
x_arrays.append(np.array(plData.time))
|
|
190
|
+
else:
|
|
191
|
+
x_arrays.append(np.array(plData.dBasis[xv]))
|
|
192
|
+
x_var = np.column_stack(x_arrays)
|
|
193
|
+
x_inst = x_var[inst_mask]
|
|
194
|
+
if use_jax:
|
|
195
|
+
x_inst = jnp.array(x_inst)
|
|
196
|
+
|
|
197
|
+
return x_inst
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# =============================================================================
|
|
201
|
+
# Per-visit block-diagonal GP helpers
|
|
202
|
+
# =============================================================================
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# Variance of the fake points that pad per-visit GP blocks to a common length,
|
|
206
|
+
# and the log-probability each of them contributes (see
|
|
207
|
+
# gp_log_probability_per_visit).
|
|
208
|
+
_PAD_VARIANCE = 1e30
|
|
209
|
+
_PAD_LOGPROB_CORRECTION = 0.5 * float(np.log(2.0 * np.pi * _PAD_VARIANCE))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def make_gp(
|
|
213
|
+
kernel: Any, x: Any, diag: Any, *, assume_sorted: bool = False
|
|
214
|
+
) -> GaussianProcess:
|
|
215
|
+
"""Build a tinygp ``GaussianProcess``, optionally skipping the sortedness check.
|
|
216
|
+
|
|
217
|
+
For quasiseparable kernels tinygp (0.3.1) checks, on every evaluation,
|
|
218
|
+
that the inputs are sorted, through a ``jax.debug.callback``: each likelihood call then
|
|
219
|
+
waits for a device-to-host transfer, which is costly on a GPU (and fails
|
|
220
|
+
when no CPU device is visible). PyAETNA's inputs are fixed data, so the
|
|
221
|
+
caller verifies the ordering once, before tracing, and passes
|
|
222
|
+
``assume_sorted=True``. Dense kernels have no such check.
|
|
223
|
+
"""
|
|
224
|
+
from tinygp.kernels.quasisep import Quasisep
|
|
225
|
+
|
|
226
|
+
if assume_sorted and isinstance(kernel, Quasisep):
|
|
227
|
+
return GaussianProcess(kernel, x, diag=diag, assume_sorted=True)
|
|
228
|
+
return GaussianProcess(kernel, x, diag=diag)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def is_quasiseparable(kernel: Any) -> bool:
|
|
232
|
+
from tinygp.kernels.quasisep import Quasisep
|
|
233
|
+
|
|
234
|
+
return isinstance(kernel, Quasisep)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def gp_log_probability(
|
|
238
|
+
kernel: Any,
|
|
239
|
+
x: Any,
|
|
240
|
+
diag: Any,
|
|
241
|
+
y: Any,
|
|
242
|
+
*,
|
|
243
|
+
assume_sorted: bool = False,
|
|
244
|
+
parallel: bool = False,
|
|
245
|
+
) -> Any:
|
|
246
|
+
"""GP marginal log-likelihood of residuals ``y``.
|
|
247
|
+
|
|
248
|
+
With ``parallel=True``, a quasiseparable kernel on sorted 1-D inputs is
|
|
249
|
+
evaluated with the parallel-in-time Kalman filter
|
|
250
|
+
(:mod:`PyAETNA.gp_parallel`), which gives the same value as tinygp but in
|
|
251
|
+
~2 log2 N sequential steps instead of N: much faster on GPUs, slower on
|
|
252
|
+
CPUs. Any other case uses tinygp.
|
|
253
|
+
"""
|
|
254
|
+
if parallel and assume_sorted and is_quasiseparable(kernel) and jnp.ndim(x) == 1:
|
|
255
|
+
from PyAETNA.gp_parallel import log_probability_parallel
|
|
256
|
+
|
|
257
|
+
return log_probability_parallel(kernel, x, diag, y)
|
|
258
|
+
return make_gp(kernel, x, diag, assume_sorted=assume_sorted).log_probability(y)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def resolve_gp_solver(solver: str) -> bool:
|
|
262
|
+
"""True if the parallel quasiseparable solver should be used.
|
|
263
|
+
|
|
264
|
+
``auto`` selects it on GPUs, where it is 40-150x faster than tinygp's
|
|
265
|
+
sequential scan, and not on CPUs, where it is 2-4x slower
|
|
266
|
+
(``paper_benchmarks/gp_solver``).
|
|
267
|
+
"""
|
|
268
|
+
solver = str(solver).lower()
|
|
269
|
+
if solver not in ("auto", "sequential", "parallel"):
|
|
270
|
+
raise ValueError(
|
|
271
|
+
f"GP solver must be 'auto', 'sequential' or 'parallel', got {solver!r}"
|
|
272
|
+
)
|
|
273
|
+
if solver == "auto":
|
|
274
|
+
return jax.default_backend() == "gpu"
|
|
275
|
+
return solver == "parallel"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def inputs_sorted(x: Any) -> bool:
|
|
279
|
+
"""True if a 1-D input array is non-decreasing (checked on concrete data)."""
|
|
280
|
+
arr = np.asarray(x)
|
|
281
|
+
return arr.ndim == 1 and bool(np.all(np.diff(arr) >= 0))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def gp_log_probability_per_visit(
|
|
285
|
+
kernel: Any,
|
|
286
|
+
x_inst: jax.Array,
|
|
287
|
+
res_inst: jax.Array,
|
|
288
|
+
diag_inst: jax.Array,
|
|
289
|
+
visit_indices: list[Any],
|
|
290
|
+
assume_sorted: bool = False,
|
|
291
|
+
parallel: bool = False,
|
|
292
|
+
) -> jax.Array:
|
|
293
|
+
"""
|
|
294
|
+
Compute the GP marginal log-probability using a per-visit block-diagonal
|
|
295
|
+
factorisation. Hyperparameters are **shared** across all visits.
|
|
296
|
+
|
|
297
|
+
This is mathematically equivalent to the full-matrix computation when
|
|
298
|
+
inter-visit covariance is negligible (gap >> kernel length scale), which
|
|
299
|
+
is the expected regime for space-based photometry (e.g. CHEOPS visits
|
|
300
|
+
separated by days, kernel length scales of minutes).
|
|
301
|
+
|
|
302
|
+
.. math::
|
|
303
|
+
|
|
304
|
+
\\log p(\\mathbf{y} \\mid \\theta)
|
|
305
|
+
= \\sum_v \\log p(\\mathbf{y}_v \\mid \\theta)
|
|
306
|
+
|
|
307
|
+
Each visit contributes an independent block of size ``n_v``, so the
|
|
308
|
+
total cost for O(N³) dense kernels drops from ``N³`` to
|
|
309
|
+
``\\sum_v n_v^3 \\approx V \\cdot (N/V)^3 = N^3 / V^2``.
|
|
310
|
+
|
|
311
|
+
For SHO quasisep kernels this factorisation is a **numerical necessity**:
|
|
312
|
+
the Kalman state-transition matrix contains ``exp(-\\alpha \\Delta t)``
|
|
313
|
+
terms that underflow for large inter-visit gaps, producing ``-inf``.
|
|
314
|
+
|
|
315
|
+
**Compilation optimization** — All visits are padded to the same length
|
|
316
|
+
and evaluated via a single ``jax.vmap``-ed GP call. This replaces the
|
|
317
|
+
previous Python for-loop (which JAX unrolled into *n_visits* separate GP
|
|
318
|
+
subgraphs) with one compiled GP kernel batched across visits,
|
|
319
|
+
dramatically reducing XLA graph size and compilation time. Padded
|
|
320
|
+
positions use ``diag = 1e30`` (effectively infinite observation noise):
|
|
321
|
+
the Kalman gain is driven to zero, so padding cannot corrupt the
|
|
322
|
+
real-data likelihood contribution—it only adds a constant independent
|
|
323
|
+
of the kernel hyperparameters.
|
|
324
|
+
|
|
325
|
+
.. warning::
|
|
326
|
+
|
|
327
|
+
This factorisation is only valid when the kernel decays sufficiently
|
|
328
|
+
over the inter-visit gap, i.e. the off-diagonal cross-visit covariance
|
|
329
|
+
blocks must be negligible. Three specific cases break this assumption:
|
|
330
|
+
|
|
331
|
+
* **``ExpSineSquared``** (strictly periodic): covariance never decays;
|
|
332
|
+
``k(n*P) == sigma^2`` for any integer ``n``. Using per-visit with
|
|
333
|
+
this kernel is **mathematically incorrect** and will produce biased
|
|
334
|
+
hyperparameter estimates. Do not combine ``ExpSineSquared`` with
|
|
335
|
+
``per_visit: true``.
|
|
336
|
+
* **``SHO_Quality`` / ``SHO`` with high Q factor**: decay is slow;
|
|
337
|
+
cross-visit covariances are significant when the inter-visit gap is
|
|
338
|
+
much less than the damping time ``Q / omega_0``. Verify that the
|
|
339
|
+
posterior Q implies fast damping relative to the gap.
|
|
340
|
+
* **``QuasiPeriodic``**: conditionally valid when the inter-visit gap
|
|
341
|
+
is much larger than ``exp(log_gp_se_scale)`` (the SE length scale
|
|
342
|
+
that provides the decaying envelope).
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
kernel: tinygp kernel object (built from shared hyperparameters).
|
|
346
|
+
x_inst: GP input array for the instrument (1-D, sorted within visits).
|
|
347
|
+
res_inst: Residuals for the instrument.
|
|
348
|
+
diag_inst: Diagonal variance (data error² + jitter²) for the instrument.
|
|
349
|
+
visit_indices: List of JAX index arrays, one per visit, giving the
|
|
350
|
+
positions within the *instrument* arrays (not the global data array).
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
Scalar GP marginal log-probability (sum over visits).
|
|
354
|
+
"""
|
|
355
|
+
n_visits = len(visit_indices)
|
|
356
|
+
|
|
357
|
+
if n_visits == 0:
|
|
358
|
+
return jnp.zeros(())
|
|
359
|
+
|
|
360
|
+
# Single visit: no padding or vmap overhead needed.
|
|
361
|
+
if n_visits == 1:
|
|
362
|
+
v_idx = visit_indices[0]
|
|
363
|
+
return gp_log_probability(
|
|
364
|
+
kernel,
|
|
365
|
+
x_inst[v_idx],
|
|
366
|
+
diag_inst[v_idx],
|
|
367
|
+
res_inst[v_idx],
|
|
368
|
+
assume_sorted=assume_sorted,
|
|
369
|
+
parallel=parallel,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
# --- Pad all visits to uniform length for a single vmap-ed GP call ------
|
|
373
|
+
# Instead of a Python for-loop (which JAX unrolls into n_visits separate
|
|
374
|
+
# GP subgraphs), we build (n_visits, max_len) arrays and vmap a single
|
|
375
|
+
# GP evaluation. This compiles ONE GP solve and batches it, reducing
|
|
376
|
+
# XLA graph size by ~n_visits×.
|
|
377
|
+
#
|
|
378
|
+
# Padding conventions:
|
|
379
|
+
# x: sorted values placed far after the last real point (gap = 1e4)
|
|
380
|
+
# so that exp(-α*Δt) → 0 and the Kalman state resets.
|
|
381
|
+
# r: zero residuals (no signal contribution).
|
|
382
|
+
# diag: 1e30 (effectively infinite noise → Kalman gain ≈ 0 → no
|
|
383
|
+
# state update; log-probability contribution is a constant
|
|
384
|
+
# that does not depend on kernel hyperparameters).
|
|
385
|
+
max_len = max(len(v_idx) for v_idx in visit_indices)
|
|
386
|
+
|
|
387
|
+
x_padded = jnp.zeros((n_visits, max_len), dtype=x_inst.dtype)
|
|
388
|
+
r_padded = jnp.zeros((n_visits, max_len), dtype=res_inst.dtype)
|
|
389
|
+
d_padded = jnp.full((n_visits, max_len), _PAD_VARIANCE, dtype=diag_inst.dtype)
|
|
390
|
+
|
|
391
|
+
for i, v_idx in enumerate(visit_indices):
|
|
392
|
+
n_v = len(v_idx) # static (concrete) at trace time
|
|
393
|
+
x_padded = x_padded.at[i, :n_v].set(x_inst[v_idx])
|
|
394
|
+
r_padded = r_padded.at[i, :n_v].set(res_inst[v_idx])
|
|
395
|
+
d_padded = d_padded.at[i, :n_v].set(diag_inst[v_idx])
|
|
396
|
+
# Padding x-values: sorted, placed far after the last real point.
|
|
397
|
+
# The large gap (1e4) ensures exp(-α*Δt) → 0, resetting the
|
|
398
|
+
# Kalman state so padding cannot corrupt the real-data block.
|
|
399
|
+
if n_v < max_len:
|
|
400
|
+
n_pad = max_len - n_v
|
|
401
|
+
last_x = x_inst[v_idx[-1]]
|
|
402
|
+
pad_start = last_x + jnp.asarray(1e4, dtype=x_inst.dtype)
|
|
403
|
+
pad_x = pad_start + jnp.arange(n_pad, dtype=x_inst.dtype) * 0.001
|
|
404
|
+
x_padded = x_padded.at[i, n_v:].set(pad_x)
|
|
405
|
+
|
|
406
|
+
def _visit_logprob(
|
|
407
|
+
x_v: jnp.ndarray, r_v: jnp.ndarray, d_v: jnp.ndarray
|
|
408
|
+
) -> jnp.ndarray:
|
|
409
|
+
# Padding appends points after the last real one, so a sorted visit
|
|
410
|
+
# stays sorted.
|
|
411
|
+
return gp_log_probability(
|
|
412
|
+
kernel, x_v, d_v, r_v, assume_sorted=assume_sorted, parallel=parallel
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# Single compiled GP kernel, vmapped over the visit dimension.
|
|
416
|
+
# The kernel object (captured from outer scope) is shared—hyperparameters
|
|
417
|
+
# are NOT batched, only x/r/d are.
|
|
418
|
+
logprobs = jax.vmap(_visit_logprob)(x_padded, r_padded, d_padded)
|
|
419
|
+
# Each padded point (zero residual, variance _PAD_VARIANCE, uncorrelated
|
|
420
|
+
# with the data) adds -0.5 ln(2 pi _PAD_VARIANCE) to the log-probability.
|
|
421
|
+
# The term is constant, so posteriors are unaffected, but it would shift
|
|
422
|
+
# the Bayesian evidence (by ~35.5 nats per padded point): remove it.
|
|
423
|
+
n_padded = sum(max_len - len(v_idx) for v_idx in visit_indices)
|
|
424
|
+
return jnp.sum(logprobs) + n_padded * _PAD_LOGPROB_CORRECTION
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def gp_predict_per_visit(
|
|
428
|
+
kernel: Any,
|
|
429
|
+
x_inst: Any,
|
|
430
|
+
res_inst: Any,
|
|
431
|
+
diag_inst: Any,
|
|
432
|
+
visit_indices: list[Any],
|
|
433
|
+
x_pred_inst: Any = None,
|
|
434
|
+
) -> tuple[jax.Array, jax.Array]:
|
|
435
|
+
"""
|
|
436
|
+
Compute GP posterior predictions using per-visit block-diagonal
|
|
437
|
+
conditioning, with **shared** kernel hyperparameters.
|
|
438
|
+
|
|
439
|
+
Each visit is conditioned independently (no cross-visit covariance is used
|
|
440
|
+
for the conditioning), then results are concatenated back into the
|
|
441
|
+
instrument-level arrays. The same validity conditions as
|
|
442
|
+
:func:`gp_log_probability_per_visit` apply: the kernel must decay over
|
|
443
|
+
the inter-visit gap. In particular, ``ExpSineSquared`` (strictly
|
|
444
|
+
periodic, non-decaying) must **not** be combined with this function, as
|
|
445
|
+
ignoring cross-visit covariance will corrupt the posterior mean and
|
|
446
|
+
variance estimates.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
kernel: tinygp kernel object.
|
|
450
|
+
x_inst: Training inputs for the instrument.
|
|
451
|
+
res_inst: Training residuals.
|
|
452
|
+
diag_inst: Diagonal variance (data error² + jitter²).
|
|
453
|
+
visit_indices: List of JAX index arrays (per-visit positions within
|
|
454
|
+
the instrument arrays).
|
|
455
|
+
x_pred_inst: (optional) Prediction inputs. If ``None``, predicts at
|
|
456
|
+
the training locations.
|
|
457
|
+
|
|
458
|
+
Returns:
|
|
459
|
+
(mean, std) — predicted GP posterior mean and standard deviation,
|
|
460
|
+
same length as ``x_inst`` (or ``x_pred_inst`` if provided).
|
|
461
|
+
"""
|
|
462
|
+
n_out = len(x_inst) if x_pred_inst is None else len(x_pred_inst)
|
|
463
|
+
mean_out = jnp.zeros(n_out)
|
|
464
|
+
std_out = jnp.zeros(n_out)
|
|
465
|
+
|
|
466
|
+
for v_idx in visit_indices:
|
|
467
|
+
# Convert to JAX arrays — dtype follows the global x64 setting,
|
|
468
|
+
# keeping data and kernel params consistently float32 or float64.
|
|
469
|
+
x_v = jnp.asarray(x_inst[v_idx])
|
|
470
|
+
r_v = jnp.asarray(res_inst[v_idx])
|
|
471
|
+
d_v = jnp.asarray(diag_inst[v_idx])
|
|
472
|
+
gp_v = GaussianProcess(kernel, x_v, diag=d_v)
|
|
473
|
+
|
|
474
|
+
if x_pred_inst is None:
|
|
475
|
+
cond = gp_v.condition(r_v, x_v)
|
|
476
|
+
else:
|
|
477
|
+
x_p_v = jnp.asarray(x_pred_inst[v_idx])
|
|
478
|
+
cond = gp_v.condition(r_v, x_p_v)
|
|
479
|
+
|
|
480
|
+
mean_out = mean_out.at[v_idx].set(cond.gp.loc)
|
|
481
|
+
std_out = std_out.at[v_idx].set(jnp.sqrt(cond.gp.variance))
|
|
482
|
+
|
|
483
|
+
return mean_out, std_out
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# =============================================================================
|
|
487
|
+
# Kernel theoretical PSD computation (for periodogram comparison)
|
|
488
|
+
# =============================================================================
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def compute_kernel_theoretical_psd(
|
|
492
|
+
kernel: Any,
|
|
493
|
+
freqs: np.ndarray,
|
|
494
|
+
n_acf_lags: int = 8192,
|
|
495
|
+
oversample: float = 20.0,
|
|
496
|
+
max_refine: int = 6,
|
|
497
|
+
rtol: float = 1e-3,
|
|
498
|
+
max_n_lags: int = 2**20,
|
|
499
|
+
alias_rtol: float = 1e-4,
|
|
500
|
+
) -> np.ndarray:
|
|
501
|
+
"""
|
|
502
|
+
Compute the one-sided power spectral density (PSD) of a tinygp kernel
|
|
503
|
+
at a set of requested cyclic frequencies via numerical FFT of the
|
|
504
|
+
autocovariance function.
|
|
505
|
+
|
|
506
|
+
The approach is kernel-agnostic: it builds a regularly-spaced lag grid,
|
|
507
|
+
evaluates the autocovariance k(0, tau) at each lag via ``jax.vmap``, then
|
|
508
|
+
computes the PSD via a real FFT. Works for dense kernels (ExpSquared,
|
|
509
|
+
ExpSineSquared, Matern, ...), quasisep kernels, and arbitrary sums/
|
|
510
|
+
products thereof.
|
|
511
|
+
|
|
512
|
+
**Aliasing safeguard**: the ACF lag spacing ``dt_lag`` must be fine
|
|
513
|
+
enough to resolve the *kernel's own* frequency content, which for
|
|
514
|
+
periodic/quasi-periodic kernels (ExpSineSquared and friends) can extend
|
|
515
|
+
to harmonics far above the highest frequency you actually care about in
|
|
516
|
+
``freqs``. Coupling ``dt_lag`` directly to ``max(freqs)`` (as a naive
|
|
517
|
+
Nyquist argument would suggest) undersamples that structure and folds
|
|
518
|
+
high harmonics back into the requested band, producing spurious/
|
|
519
|
+
intermittent extra peaks. Instead, this function starts from a lag
|
|
520
|
+
spacing oversampled by ``oversample`` beyond ``max(freqs)`` and
|
|
521
|
+
adaptively halves it (doubling the number of lags to keep the total lag
|
|
522
|
+
span, and hence frequency resolution, fixed) until **both** of the
|
|
523
|
+
following hold:
|
|
524
|
+
|
|
525
|
+
1. **There is no power left to fold**: the PSD in the top octave of the
|
|
526
|
+
lag grid's own Nyquist band,
|
|
527
|
+
:math:`[f_{\\text{nyq}}/2,\\, f_{\\text{nyq}}]` with
|
|
528
|
+
:math:`f_{\\text{nyq}} = 1/(2\\,\\Delta\\tau)`, has dropped below
|
|
529
|
+
``alias_rtol`` times the peak PSD *in the requested band*. Whatever
|
|
530
|
+
lies above :math:`f_{\\text{nyq}}` and folds back down is bounded by
|
|
531
|
+
that level, so it cannot contaminate the result. Being measured on
|
|
532
|
+
the spectrum actually computed, this detects kernel-specific
|
|
533
|
+
high-frequency content empirically rather than assuming it is
|
|
534
|
+
bounded by ``max(freqs)``.
|
|
535
|
+
2. **Refining no longer changes the answer**: the in-band PSD moved by
|
|
536
|
+
less than ``rtol`` of its own peak between successive refinements.
|
|
537
|
+
This catches the *other* dt-dependent error, the quadrature error of
|
|
538
|
+
the discrete transform, which shows up on the steep flanks of sharp
|
|
539
|
+
kernel peaks and is not aliasing at all — criterion 1 is blind to it.
|
|
540
|
+
|
|
541
|
+
Both criteria are scaled by the in-band **peak** PSD rather than applied
|
|
542
|
+
pointwise-relative. A pointwise-relative test is meaningless in the far
|
|
543
|
+
wings, where the PSD of a smooth kernel sits 10-12 orders of magnitude
|
|
544
|
+
below the peak, on the FFT's own round-off floor: that floor reshuffles
|
|
545
|
+
at O(1) relative level on every refinement and never settles, so a
|
|
546
|
+
pointwise test reports "did not converge" for even the most benign
|
|
547
|
+
kernel while the physically meaningful part of the spectrum is already
|
|
548
|
+
accurate to far better than ``rtol``.
|
|
549
|
+
|
|
550
|
+
A pure ``Constant`` kernel is the one genuinely non-convergent case: all
|
|
551
|
+
its power sits at DC, so it has no meaningful in-band PSD and the
|
|
552
|
+
warning below is the honest answer. In any sum with a real kernel its
|
|
553
|
+
contribution is stable and the warning does not fire.
|
|
554
|
+
|
|
555
|
+
See `localDocs/2026_08_20_ExpSineSquared_PSD_Aliasing_Analysis.pdf` for
|
|
556
|
+
a detailed analysis of the underlying aliasing issue.
|
|
557
|
+
|
|
558
|
+
**Normalisation convention** (matches Lomb-Scargle PSD in reports.py):
|
|
559
|
+
|
|
560
|
+
.. math::
|
|
561
|
+
|
|
562
|
+
S_{\\text{1-sided}}(f) = 2 \\,|\\tilde{k}(f)|\\,\\Delta\\tau
|
|
563
|
+
\\qquad \\text{for } f > 0
|
|
564
|
+
|
|
565
|
+
so that :math:`\\int_0^\\infty S(f)\\,df \\approx k(0)` (the kernel
|
|
566
|
+
variance).
|
|
567
|
+
|
|
568
|
+
Parameters
|
|
569
|
+
----------
|
|
570
|
+
kernel : tinygp kernel object
|
|
571
|
+
Any tinygp kernel (dense or quasisep) exposing ``.evaluate(x1, x2)``.
|
|
572
|
+
freqs : 1-D array of float
|
|
573
|
+
Cyclic frequencies [d⁻¹] at which to evaluate the PSD. Must be
|
|
574
|
+
positive.
|
|
575
|
+
n_acf_lags : int, optional
|
|
576
|
+
Minimum number of lags used for the FFT at the *starting*
|
|
577
|
+
resolution (before adaptive refinement). Default 8192.
|
|
578
|
+
oversample : float, optional
|
|
579
|
+
Factor by which the initial lag-spacing Nyquist frequency exceeds
|
|
580
|
+
``max(freqs)``. Larger values are safer for sharply peaked/high-Γ
|
|
581
|
+
periodic kernels but cost more compute on the first pass. Default
|
|
582
|
+
20.
|
|
583
|
+
max_refine : int, optional
|
|
584
|
+
Maximum number of dt_lag-halving refinement iterations. Default 6
|
|
585
|
+
(i.e. up to 64x finer than the initial spacing).
|
|
586
|
+
rtol : float, optional
|
|
587
|
+
Stability tolerance: the PSD at the requested ``freqs`` counts as
|
|
588
|
+
converged once successive refinements move it by less than ``rtol``
|
|
589
|
+
times its own in-band peak. Default 1e-3.
|
|
590
|
+
max_n_lags : int, optional
|
|
591
|
+
Hard cap on the number of lags, to bound memory/compute if
|
|
592
|
+
convergence is never reached. A warning is emitted if this cap is
|
|
593
|
+
hit without convergence. Default 2**20.
|
|
594
|
+
alias_rtol : float, optional
|
|
595
|
+
Aliasing tolerance: the PSD near the lag grid's Nyquist frequency
|
|
596
|
+
must fall below this fraction of the in-band peak for the lag
|
|
597
|
+
spacing to be declared fine enough. Default 1e-4, i.e. an order of
|
|
598
|
+
magnitude stricter than ``rtol``, so residual folded power stays
|
|
599
|
+
well below the level at which the result is declared converged —
|
|
600
|
+
while remaining orders of magnitude above the FFT's own round-off
|
|
601
|
+
floor (~1e-12 of the peak), so the test responds to real spectral
|
|
602
|
+
content rather than to numerical noise.
|
|
603
|
+
|
|
604
|
+
Returns
|
|
605
|
+
-------
|
|
606
|
+
psd : 1-D array of float
|
|
607
|
+
One-sided PSD evaluated at ``freqs``, in units of
|
|
608
|
+
``[data_units]² · [x_units]`` (e.g. flux² · day when x is TIME).
|
|
609
|
+
"""
|
|
610
|
+
_prev_x64 = jax.config.jax_enable_x64
|
|
611
|
+
jax.config.update("jax_enable_x64", True)
|
|
612
|
+
try:
|
|
613
|
+
freqs = np.asarray(freqs, dtype=np.float64)
|
|
614
|
+
f_max = float(np.max(freqs))
|
|
615
|
+
f_min = float(np.min(freqs[freqs > 0]))
|
|
616
|
+
|
|
617
|
+
def _eval_psd(dt_lag: float, n_lags: int) -> tuple[np.ndarray, np.ndarray]:
|
|
618
|
+
"""FFT-based one-sided PSD at a given lag resolution."""
|
|
619
|
+
x_grid = jnp.arange(n_lags, dtype=jnp.float64) * dt_lag
|
|
620
|
+
origin = x_grid[0] # = 0.0
|
|
621
|
+
k_tau = np.asarray(
|
|
622
|
+
jax.vmap(lambda tau: kernel.evaluate(origin, tau))(x_grid)
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
# Cosine taper to suppress Gibbs ringing from truncating the ACF.
|
|
626
|
+
window = 0.5 * (1.0 + np.cos(np.pi * np.arange(n_lags) / n_lags))
|
|
627
|
+
k_tau_w = k_tau * window
|
|
628
|
+
|
|
629
|
+
# Two-sided ACF: k(-tau) = k(tau)
|
|
630
|
+
k_sym = np.concatenate([k_tau_w[::-1][:-1], k_tau_w])
|
|
631
|
+
fft_vals = np.fft.fft(k_sym)
|
|
632
|
+
psd_twosided = np.abs(fft_vals) * dt_lag
|
|
633
|
+
fft_freqs = np.fft.fftfreq(len(k_sym), d=dt_lag)
|
|
634
|
+
|
|
635
|
+
pos_mask = fft_freqs > 0
|
|
636
|
+
f_pos = fft_freqs[pos_mask]
|
|
637
|
+
psd_onesided = 2.0 * psd_twosided[pos_mask]
|
|
638
|
+
|
|
639
|
+
sort_idx = np.argsort(f_pos)
|
|
640
|
+
return f_pos[sort_idx], psd_onesided[sort_idx]
|
|
641
|
+
|
|
642
|
+
def _alias_level(
|
|
643
|
+
f_grid: np.ndarray, psd_grid: np.ndarray, peak: float
|
|
644
|
+
) -> float:
|
|
645
|
+
"""Power near the lag grid's Nyquist, as a fraction of the
|
|
646
|
+
in-band peak.
|
|
647
|
+
|
|
648
|
+
Aliasing folds the spectrum above ``f_nyq = 1/(2 dt_lag)`` back
|
|
649
|
+
down into the computed band. That folded-in power is bounded by
|
|
650
|
+
the level the PSD has already reached as it approaches
|
|
651
|
+
``f_nyq``, so probing the top octave of the computed grid gives
|
|
652
|
+
a direct, single-pass measure of how much contamination is still
|
|
653
|
+
possible — no iteration-to-iteration differencing needed.
|
|
654
|
+
"""
|
|
655
|
+
if peak <= 0.0:
|
|
656
|
+
return 0.0
|
|
657
|
+
f_nyq = float(f_grid[-1])
|
|
658
|
+
# Keep the probe band clear of the requested band itself, which
|
|
659
|
+
# it would otherwise overlap for ``oversample`` near 1.
|
|
660
|
+
tail = psd_grid[f_grid >= max(0.5 * f_nyq, f_max)]
|
|
661
|
+
if tail.size == 0:
|
|
662
|
+
return 0.0
|
|
663
|
+
return float(np.max(tail)) / peak
|
|
664
|
+
|
|
665
|
+
# ── Initial resolution: oversampled well beyond max(freqs) ──────────
|
|
666
|
+
f_nyq_resolve = oversample * f_max
|
|
667
|
+
dt_lag = 1.0 / (2.0 * f_nyq_resolve)
|
|
668
|
+
# Ensure total lag span covers at least one cycle of the lowest
|
|
669
|
+
# requested frequency (needed for low-frequency resolution).
|
|
670
|
+
min_lags_needed = int(np.ceil(1.0 / (f_min * dt_lag))) + 1
|
|
671
|
+
n_lags = max(n_acf_lags, min_lags_needed)
|
|
672
|
+
|
|
673
|
+
# Initial evaluation
|
|
674
|
+
f_grid, psd_grid = _eval_psd(dt_lag, n_lags)
|
|
675
|
+
psd_prev = np.interp(freqs, f_grid, psd_grid, left=0.0, right=0.0)
|
|
676
|
+
peak = float(np.max(psd_prev))
|
|
677
|
+
|
|
678
|
+
alias_level = _alias_level(f_grid, psd_grid, peak)
|
|
679
|
+
stab_level = np.inf
|
|
680
|
+
|
|
681
|
+
# ``peak <= 0`` means the kernel has no power anywhere in the
|
|
682
|
+
# requested band (e.g. a multi-GP channel whose projections are both
|
|
683
|
+
# zero): trivially converged, since there is no scale to test
|
|
684
|
+
# anything against.
|
|
685
|
+
converged = peak <= 0.0
|
|
686
|
+
|
|
687
|
+
# ── Adaptive refinement: halve dt_lag, double n_lags (span fixed) ───
|
|
688
|
+
# At least one refinement has to run before the result can be called
|
|
689
|
+
# converged: ``stab_level`` is a difference between successive
|
|
690
|
+
# resolutions, so the first pass alone cannot establish it.
|
|
691
|
+
n_refined = 0
|
|
692
|
+
if not converged:
|
|
693
|
+
for _ in range(max_refine):
|
|
694
|
+
if n_lags * 2 > max_n_lags:
|
|
695
|
+
break
|
|
696
|
+
dt_lag /= 2.0
|
|
697
|
+
n_lags *= 2
|
|
698
|
+
n_refined += 1
|
|
699
|
+
|
|
700
|
+
f_grid, psd_grid = _eval_psd(dt_lag, n_lags)
|
|
701
|
+
psd_cur = np.interp(freqs, f_grid, psd_grid, left=0.0, right=0.0)
|
|
702
|
+
peak = float(np.max(psd_cur))
|
|
703
|
+
|
|
704
|
+
if peak > 0.0:
|
|
705
|
+
alias_level = _alias_level(f_grid, psd_grid, peak)
|
|
706
|
+
stab_level = float(np.max(np.abs(psd_cur - psd_prev))) / peak
|
|
707
|
+
psd_prev = psd_cur
|
|
708
|
+
|
|
709
|
+
if peak <= 0.0 or (alias_level <= alias_rtol and stab_level <= rtol):
|
|
710
|
+
converged = True
|
|
711
|
+
break
|
|
712
|
+
|
|
713
|
+
if n_refined == 0:
|
|
714
|
+
# Refinement was disabled (``max_refine <= 0``) or blocked
|
|
715
|
+
# outright by ``max_n_lags``, so stability could never be
|
|
716
|
+
# measured; the aliasing test is then all there is to go on.
|
|
717
|
+
converged = alias_level <= alias_rtol
|
|
718
|
+
|
|
719
|
+
if not converged:
|
|
720
|
+
_log(
|
|
721
|
+
f"compute_kernel_theoretical_psd: PSD not converged after "
|
|
722
|
+
f"{n_refined} of max_refine={max_refine} refinements (final "
|
|
723
|
+
f"dt_lag={dt_lag:.3g}, n_lags={n_lags}): power near the "
|
|
724
|
+
f"lag-grid Nyquist is still {alias_level:.2e} of the in-band "
|
|
725
|
+
f"peak (target {alias_rtol:.0e}), and the last refinement "
|
|
726
|
+
f"still moved the in-band PSD by {stab_level:.2e} of that "
|
|
727
|
+
f"peak (target {rtol:.0e}). Result may still contain aliasing "
|
|
728
|
+
f"from unresolved kernel structure (e.g. very high Γ in a "
|
|
729
|
+
f"quasi-periodic term). Consider raising oversample, "
|
|
730
|
+
f"max_refine, or max_n_lags.",
|
|
731
|
+
level="WARNING",
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
return psd_prev
|
|
735
|
+
finally:
|
|
736
|
+
jax.config.update("jax_enable_x64", _prev_x64)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
# =============================================================================
|
|
740
|
+
# Kernel realistic PSD computation taking into account the observational time window (for periodogram comparison)
|
|
741
|
+
# =============================================================================
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def compute_windowed_psd(kernel: Any, freqs: Any, t: Any) -> jax.Array:
|
|
745
|
+
"""
|
|
746
|
+
Compute the discrete one-sided PSD approximation of a tinygp kernel,
|
|
747
|
+
fully JAX-compatible and differentiable.
|
|
748
|
+
"""
|
|
749
|
+
_prev_x64 = jax.config.jax_enable_x64
|
|
750
|
+
jax.config.update("jax_enable_x64", True)
|
|
751
|
+
|
|
752
|
+
try:
|
|
753
|
+
t = jnp.asarray(t, dtype=jnp.float64).reshape(-1)
|
|
754
|
+
freqs = jnp.asarray(freqs, dtype=jnp.float64)
|
|
755
|
+
|
|
756
|
+
# 1. Build the Covariance Matrix
|
|
757
|
+
K = kernel(t, t)
|
|
758
|
+
|
|
759
|
+
# # Safe JAX-compatible dimensionality check
|
|
760
|
+
# K = jnp.atleast_2d(K)
|
|
761
|
+
# if K.shape == (1, 1) and t.shape[0] > 1:
|
|
762
|
+
# K = jnp.full((len(t), len(t)), K[0, 0])
|
|
763
|
+
|
|
764
|
+
# 2. Get Intrinsic Kernel Variance
|
|
765
|
+
kernel_variance = jnp.mean(jnp.diagonal(K))
|
|
766
|
+
|
|
767
|
+
# 3. Project into frequency space
|
|
768
|
+
# Using a more memory-efficient/readable outer product for the exponent
|
|
769
|
+
exponent = -2j * jnp.pi * jnp.outer(t, freqs)
|
|
770
|
+
projection_matrix = jnp.exp(exponent)
|
|
771
|
+
|
|
772
|
+
# Matrix quadratic form: raw_power = diag(P^H @ K @ P)
|
|
773
|
+
raw_power = jnp.real(
|
|
774
|
+
jnp.sum(jnp.conj(projection_matrix) * (K @ projection_matrix), axis=0)
|
|
775
|
+
)
|
|
776
|
+
|
|
777
|
+
# 4. Pure JAX Trapezoidal Integration
|
|
778
|
+
# ∫ P(f) df ≈ ∑ 0.5 * (P_i + P_{i+1}) * Δf
|
|
779
|
+
df = jnp.diff(freqs)
|
|
780
|
+
zero_padded_power = 0.5 * (raw_power[:-1] + raw_power[1:])
|
|
781
|
+
raw_integral = jnp.sum(zero_padded_power * df)
|
|
782
|
+
|
|
783
|
+
# 5. Normalize
|
|
784
|
+
psd_normalized = jnp.where(
|
|
785
|
+
raw_integral > 0, raw_power * (kernel_variance / raw_integral), raw_power
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
return psd_normalized
|
|
789
|
+
|
|
790
|
+
finally:
|
|
791
|
+
jax.config.update("jax_enable_x64", _prev_x64)
|