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
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"""Multi-dimensional GP kernel (Rajpaul+2015 / Barragán+2022) for PyAETNA.
|
|
2
|
+
|
|
3
|
+
This module implements the *joint* cross-output covariance that couples several
|
|
4
|
+
observables to one latent stellar-activity process — as opposed to the
|
|
5
|
+
block-diagonal, one-GP-per-instrument likelihood built by
|
|
6
|
+
:mod:`PyAETNA.kernels.factory`.
|
|
7
|
+
|
|
8
|
+
The model
|
|
9
|
+
---------
|
|
10
|
+
A single latent, **unit-variance** process :math:`G(t)` with covariance
|
|
11
|
+
:math:`\\gamma(t_i, t_j)`. Each observable channel :math:`c` is a linear
|
|
12
|
+
projection of that process and its time derivative:
|
|
13
|
+
|
|
14
|
+
.. math::
|
|
15
|
+
|
|
16
|
+
\\Delta_c(t) = A_c\\, G(t) + B_c\\, \\dot{G}(t)
|
|
17
|
+
|
|
18
|
+
This is Barragán et al. (2022) Eq. 1, the N-channel generalisation of Rajpaul
|
|
19
|
+
et al. (2015) Eqs. 14-16. The paper's three observables are the special case
|
|
20
|
+
``RV -> (V_c, V_r)``, ``BIS -> (B_c, B_r)``, ``log R'HK -> (L_c, 0)``.
|
|
21
|
+
|
|
22
|
+
The covariance between channels :math:`c` and :math:`d` (Rajpaul Eq. 23) is
|
|
23
|
+
|
|
24
|
+
.. math::
|
|
25
|
+
|
|
26
|
+
\\mathrm{cov}(\\Delta_c(t_i), \\Delta_d(t_j)) =
|
|
27
|
+
A_c A_d\\, \\gamma
|
|
28
|
+
+ A_c B_d\\, \\partial\\gamma/\\partial t_j
|
|
29
|
+
+ B_c A_d\\, \\partial\\gamma/\\partial t_i
|
|
30
|
+
+ B_c B_d\\, \\partial^2\\gamma/\\partial t_i \\partial t_j
|
|
31
|
+
|
|
32
|
+
The two middle terms are antisymmetric: they vanish at zero lag but not
|
|
33
|
+
otherwise, and they encode the phase relationship between observables. They
|
|
34
|
+
are exactly what a block-diagonal likelihood discards.
|
|
35
|
+
|
|
36
|
+
Implementation notes
|
|
37
|
+
--------------------
|
|
38
|
+
* The derivative blocks are obtained by **nested** :func:`jax.grad` on the
|
|
39
|
+
scalar latent function, so any twice-differentiable latent kernel works with
|
|
40
|
+
no hand-derived algebra.
|
|
41
|
+
* The kernel operates on ``tinygp`` pytree inputs ``X = (t, channel_index)``.
|
|
42
|
+
* The latent kernel carries **no amplitude**: :math:`\\gamma` is normalised to
|
|
43
|
+
unit variance and all amplitude information lives in :math:`A_c`/:math:`B_c`.
|
|
44
|
+
Otherwise ``(G, A, B) -> (kG, A/k, B/k)`` would be unidentifiable.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
from __future__ import annotations
|
|
48
|
+
|
|
49
|
+
from collections.abc import Callable
|
|
50
|
+
from typing import Any
|
|
51
|
+
|
|
52
|
+
import equinox as eqx
|
|
53
|
+
import jax
|
|
54
|
+
import jax.numpy as jnp
|
|
55
|
+
from tinygp import kernels
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"MULTIGP_PARAM_ORDERS",
|
|
59
|
+
"MULTIGP_SUPPORTED_KERNELS",
|
|
60
|
+
"ChannelAutoKernel",
|
|
61
|
+
"MultiChannelKernel",
|
|
62
|
+
"build_latent_kernel_fn",
|
|
63
|
+
"get_multigp_param_order",
|
|
64
|
+
"multigp_log_probability",
|
|
65
|
+
"multigp_log_probability_blocked",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
# Latent kernels admissible for a multi-GP block.
|
|
69
|
+
#
|
|
70
|
+
# ``Gdot`` only exists if ``gamma`` is twice differentiable in the mean-square
|
|
71
|
+
# sense. Matern32 is deliberately EXCLUDED: the Matern-3/2 process is only
|
|
72
|
+
# *once* mean-square differentiable, so its derivative process is not defined.
|
|
73
|
+
# It cannot be left to fail at runtime — autodiff happily returns a finite,
|
|
74
|
+
# plausible-looking second derivative (JAX takes the subgradient of |tau| at
|
|
75
|
+
# zero) and the sampled matrix stays numerically positive semi-definite, so the
|
|
76
|
+
# misspecification is completely silent. Hence an explicit whitelist.
|
|
77
|
+
#
|
|
78
|
+
# The ``_quasisep`` kernels and SHO are excluded for a different reason: the
|
|
79
|
+
# quasiseparable solver cannot represent the cross-derivative blocks, so they
|
|
80
|
+
# would silently fall back to a dense solve anyway.
|
|
81
|
+
MULTIGP_SUPPORTED_KERNELS = (
|
|
82
|
+
"ExpSquared",
|
|
83
|
+
"RBF",
|
|
84
|
+
"QuasiPeriodic",
|
|
85
|
+
"ExpSineSquared",
|
|
86
|
+
"Cosine",
|
|
87
|
+
"Matern52",
|
|
88
|
+
"RationalQuadratic",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Hyperparameter names per latent kernel. These mirror
|
|
92
|
+
# ``PyAETNA.sampling.utils._get_default_param_order`` but **omit
|
|
93
|
+
# ``log_gp_sigma``**: the latent process is unit-variance by construction (see
|
|
94
|
+
# the module docstring). The naming convention is unchanged — everything is
|
|
95
|
+
# sampled in log scale except ``gp_freq``, and the kernel converts
|
|
96
|
+
# ``period = 1 / freq`` internally.
|
|
97
|
+
MULTIGP_PARAM_ORDERS: dict[str, list[str]] = {
|
|
98
|
+
"ExpSquared": ["log_gp_scale"],
|
|
99
|
+
"RBF": ["log_gp_scale"],
|
|
100
|
+
"Matern52": ["log_gp_scale"],
|
|
101
|
+
"RationalQuadratic": ["log_gp_scale", "log_gp_alpha"],
|
|
102
|
+
"Cosine": ["gp_freq"],
|
|
103
|
+
"ExpSineSquared": ["gp_freq", "log_gp_gamma"],
|
|
104
|
+
"QuasiPeriodic": ["log_gp_se_scale", "gp_freq", "log_gp_gamma"],
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_multigp_param_order(kernel_type: str) -> list[str]:
|
|
109
|
+
"""Return the latent hyperparameter names for a multi-GP kernel type.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
kernel_type: Kernel identifier (case-insensitive).
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Ordered list of hyperparameter names, or ``[]`` if unknown.
|
|
116
|
+
"""
|
|
117
|
+
for name, order in MULTIGP_PARAM_ORDERS.items():
|
|
118
|
+
if name.lower() == kernel_type.lower():
|
|
119
|
+
return list(order)
|
|
120
|
+
return []
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def build_latent_kernel_fn(
|
|
124
|
+
kernel_type: str,
|
|
125
|
+
) -> Callable[[dict[str, Any], Any, Any], Any]:
|
|
126
|
+
"""Build the scalar, unit-variance latent covariance ``gamma(params, t1, t2)``.
|
|
127
|
+
|
|
128
|
+
The hyperparameters are an *argument*, not a closure capture, so that
|
|
129
|
+
:class:`MultiChannelKernel` can hold the function as a **static** equinox
|
|
130
|
+
field while the hyperparameters stay traced — which is what lets gradients
|
|
131
|
+
reach them under NUTS.
|
|
132
|
+
|
|
133
|
+
The returned function is differentiable in ``t1`` and ``t2``, so the kernel
|
|
134
|
+
can form its derivative blocks with :func:`jax.grad`.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
kernel_type: One of :data:`MULTIGP_SUPPORTED_KERNELS`.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
``gamma(params, t1, t2)`` returning a scalar. ``params`` holds
|
|
141
|
+
hyperparameters in **linear** scale (already exponentiated from their
|
|
142
|
+
log-sampled values), keyed by the names in
|
|
143
|
+
:data:`MULTIGP_PARAM_ORDERS` with any ``log_`` prefix stripped — e.g.
|
|
144
|
+
``{"gp_scale": 12.0}``.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
ValueError: If ``kernel_type`` is not admissible for a multi-GP block.
|
|
148
|
+
"""
|
|
149
|
+
kt = kernel_type.lower()
|
|
150
|
+
|
|
151
|
+
if kt not in {k.lower() for k in MULTIGP_SUPPORTED_KERNELS}:
|
|
152
|
+
raise ValueError(
|
|
153
|
+
f"Kernel '{kernel_type}' cannot be used as a multi-GP latent kernel. "
|
|
154
|
+
f"The latent process must be twice differentiable so that its "
|
|
155
|
+
f"derivative Gdot exists. Supported: "
|
|
156
|
+
f"{', '.join(MULTIGP_SUPPORTED_KERNELS)}."
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if kt in ("expsquared", "rbf"):
|
|
160
|
+
|
|
161
|
+
def gamma(p: dict[str, Any], t1: Any, t2: Any) -> Any:
|
|
162
|
+
return jnp.exp(-0.5 * ((t1 - t2) / p["gp_scale"]) ** 2)
|
|
163
|
+
|
|
164
|
+
elif kt == "matern52":
|
|
165
|
+
|
|
166
|
+
def gamma(p: dict[str, Any], t1: Any, t2: Any) -> Any:
|
|
167
|
+
r = jnp.sqrt(5.0) * jnp.abs(t1 - t2) / p["gp_scale"]
|
|
168
|
+
return (1.0 + r + r**2 / 3.0) * jnp.exp(-r)
|
|
169
|
+
|
|
170
|
+
elif kt == "rationalquadratic":
|
|
171
|
+
|
|
172
|
+
def gamma(p: dict[str, Any], t1: Any, t2: Any) -> Any:
|
|
173
|
+
alpha = p["gp_alpha"]
|
|
174
|
+
return (1.0 + 0.5 * ((t1 - t2) / p["gp_scale"]) ** 2 / alpha) ** (-alpha)
|
|
175
|
+
|
|
176
|
+
elif kt == "cosine":
|
|
177
|
+
|
|
178
|
+
def gamma(p: dict[str, Any], t1: Any, t2: Any) -> Any:
|
|
179
|
+
return jnp.cos(2.0 * jnp.pi * (t1 - t2) * p["gp_freq"])
|
|
180
|
+
|
|
181
|
+
elif kt == "expsinesquared":
|
|
182
|
+
|
|
183
|
+
def gamma(p: dict[str, Any], t1: Any, t2: Any) -> Any:
|
|
184
|
+
return jnp.exp(
|
|
185
|
+
-p["gp_gamma"] * jnp.sin(jnp.pi * (t1 - t2) * p["gp_freq"]) ** 2
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
else: # quasiperiodic
|
|
189
|
+
|
|
190
|
+
def gamma(p: dict[str, Any], t1: Any, t2: Any) -> Any:
|
|
191
|
+
tau = t1 - t2
|
|
192
|
+
return jnp.exp(
|
|
193
|
+
-0.5 * (tau / p["gp_se_scale"]) ** 2
|
|
194
|
+
- p["gp_gamma"] * jnp.sin(jnp.pi * tau * p["gp_freq"]) ** 2
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
return gamma
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class MultiChannelKernel(kernels.Kernel):
|
|
201
|
+
"""Rajpaul/Barragán cross-output covariance over ``X = (t, channel_index)``.
|
|
202
|
+
|
|
203
|
+
Attributes:
|
|
204
|
+
latent: Scalar unit-variance covariance ``gamma(params, t1, t2)``
|
|
205
|
+
from :func:`build_latent_kernel_fn`; a **static** field.
|
|
206
|
+
params: Latent hyperparameters in linear scale; a traced field, so
|
|
207
|
+
gradients reach them.
|
|
208
|
+
A: Shape ``(n_channels,)``. Projection onto ``G``; signed.
|
|
209
|
+
B: Shape ``(n_channels,)``. Projection onto ``Gdot``; signed.
|
|
210
|
+
|
|
211
|
+
``A`` and ``B`` are deliberately **linear, signed** quantities rather than
|
|
212
|
+
log-sampled: in Rajpaul et al. the relative signs of ``(V_c, V_r)`` and
|
|
213
|
+
``(B_c, B_r)`` are physically meaningful (BIS typically anti-correlates
|
|
214
|
+
with RV), and the sign structure is normally what the fit should measure.
|
|
215
|
+
|
|
216
|
+
The residual ``(G, A, B) -> (-G, -A, -B)`` sign degeneracy is broken by the
|
|
217
|
+
parser, which requires the reference channel's ``A`` prior to be bounded
|
|
218
|
+
below at zero.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
latent: Callable[[dict[str, Any], Any, Any], Any] = eqx.field(static=True)
|
|
222
|
+
params: dict[str, Any]
|
|
223
|
+
A: jnp.ndarray
|
|
224
|
+
B: jnp.ndarray
|
|
225
|
+
|
|
226
|
+
def evaluate(self, X1: Any, X2: Any) -> Any:
|
|
227
|
+
"""Covariance between one pair of (time, channel) points."""
|
|
228
|
+
t1, c1 = X1
|
|
229
|
+
t2, c2 = X2
|
|
230
|
+
|
|
231
|
+
# `latent` is a static field (a plain function) while `params` is a
|
|
232
|
+
# traced field, so gradients reach the latent hyperparameters.
|
|
233
|
+
def k(a: Any, b: Any) -> Any:
|
|
234
|
+
return self.latent(self.params, a, b)
|
|
235
|
+
|
|
236
|
+
dk_dt1 = jax.grad(k, argnums=0)
|
|
237
|
+
dk_dt2 = jax.grad(k, argnums=1)
|
|
238
|
+
d2k = jax.grad(dk_dt1, argnums=1)
|
|
239
|
+
|
|
240
|
+
a1, b1 = self.A[c1], self.B[c1]
|
|
241
|
+
a2, b2 = self.A[c2], self.B[c2]
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
a1 * a2 * k(t1, t2)
|
|
245
|
+
+ a1 * b2 * dk_dt2(t1, t2)
|
|
246
|
+
+ b1 * a2 * dk_dt1(t1, t2)
|
|
247
|
+
+ b1 * b2 * d2k(t1, t2)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def multigp_log_probability(
|
|
252
|
+
kernel: MultiChannelKernel,
|
|
253
|
+
t: jnp.ndarray,
|
|
254
|
+
channel: jnp.ndarray,
|
|
255
|
+
res: jnp.ndarray,
|
|
256
|
+
diag: jnp.ndarray,
|
|
257
|
+
) -> jnp.ndarray:
|
|
258
|
+
"""Marginal log-probability of the full joint multi-channel covariance.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
kernel: The :class:`MultiChannelKernel`.
|
|
262
|
+
t: Times of every point, all channels concatenated. Shape ``(N,)``.
|
|
263
|
+
channel: Channel index of every point, same order. Shape ``(N,)``.
|
|
264
|
+
res: Residuals (observable minus its mean model). Shape ``(N,)``.
|
|
265
|
+
diag: Per-point variance (measurement error squared plus jitter).
|
|
266
|
+
Shape ``(N,)``.
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Scalar log-probability. Cost is ``O(N^3)`` with ``N`` the total across
|
|
270
|
+
**all** channels.
|
|
271
|
+
"""
|
|
272
|
+
from tinygp import GaussianProcess
|
|
273
|
+
|
|
274
|
+
# tinygp types X as an Array, but a pytree input is the documented way to
|
|
275
|
+
# carry structured inputs (its "Derivative Observations & Pytree Data"
|
|
276
|
+
# pattern). The tuple is what MultiChannelKernel.evaluate unpacks.
|
|
277
|
+
gp = GaussianProcess(kernel, (t, channel), diag=diag) # type: ignore[arg-type]
|
|
278
|
+
return gp.log_probability(res)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def multigp_log_probability_blocked(
|
|
282
|
+
kernel: MultiChannelKernel,
|
|
283
|
+
t: jnp.ndarray,
|
|
284
|
+
channel: jnp.ndarray,
|
|
285
|
+
res: jnp.ndarray,
|
|
286
|
+
diag: jnp.ndarray,
|
|
287
|
+
block_indices: list[Any],
|
|
288
|
+
) -> jnp.ndarray:
|
|
289
|
+
"""Block-diagonal version of :func:`multigp_log_probability`.
|
|
290
|
+
|
|
291
|
+
Each block holds **all channels** for the epochs it covers, so the
|
|
292
|
+
cross-channel information — the whole point of the multi-GP — is preserved
|
|
293
|
+
*within* a block. Only the covariance *between* blocks is dropped.
|
|
294
|
+
|
|
295
|
+
.. math::
|
|
296
|
+
|
|
297
|
+
\\log p(\\mathbf{y} \\mid \\theta) = \\sum_b \\log p(\\mathbf{y}_b \\mid \\theta)
|
|
298
|
+
|
|
299
|
+
Cost drops from ``N^3`` to ``sum_b n_b^3``, i.e. roughly ``N^3 / n_blocks^2``.
|
|
300
|
+
|
|
301
|
+
.. warning::
|
|
302
|
+
|
|
303
|
+
Valid only when the between-block gap greatly exceeds the latent
|
|
304
|
+
kernel's decay scale, so the dropped off-block covariance really is
|
|
305
|
+
negligible. As with
|
|
306
|
+
:func:`PyAETNA.gp.gp_log_probability_per_visit`, this is **never**
|
|
307
|
+
valid for a pure ``ExpSineSquared`` latent kernel, whose covariance
|
|
308
|
+
does not decay at all: ``gamma(nP) == 1`` for any integer ``n``.
|
|
309
|
+
For ``QuasiPeriodic`` it is conditionally valid, when the gap greatly
|
|
310
|
+
exceeds ``exp(log_gp_se_scale)``.
|
|
311
|
+
|
|
312
|
+
Padding follows the convention of
|
|
313
|
+
:func:`PyAETNA.gp.gp_log_probability_per_visit`: padded points get
|
|
314
|
+
``diag = 1e30`` (effectively infinite noise, so they cannot affect the
|
|
315
|
+
real-data contribution), zero residuals, and times pushed far beyond the
|
|
316
|
+
last real point so their covariance with real data vanishes.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
kernel: The :class:`MultiChannelKernel`.
|
|
320
|
+
t: Times of every point, all channels concatenated.
|
|
321
|
+
channel: Channel index of every point, same order.
|
|
322
|
+
res: Residuals.
|
|
323
|
+
diag: Per-point variance.
|
|
324
|
+
block_indices: One index array per block, giving positions within the
|
|
325
|
+
concatenated arrays. Each must already contain every channel's
|
|
326
|
+
points for its epochs.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
Scalar log-probability summed over blocks.
|
|
330
|
+
"""
|
|
331
|
+
n_blocks = len(block_indices)
|
|
332
|
+
if n_blocks == 0:
|
|
333
|
+
return jnp.zeros(())
|
|
334
|
+
if n_blocks == 1:
|
|
335
|
+
idx = block_indices[0]
|
|
336
|
+
return multigp_log_probability(
|
|
337
|
+
kernel, t[idx], channel[idx], res[idx], diag[idx]
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
max_len = max(len(idx) for idx in block_indices)
|
|
341
|
+
|
|
342
|
+
t_pad = jnp.zeros((n_blocks, max_len), dtype=t.dtype)
|
|
343
|
+
c_pad = jnp.zeros((n_blocks, max_len), dtype=channel.dtype)
|
|
344
|
+
r_pad = jnp.zeros((n_blocks, max_len), dtype=res.dtype)
|
|
345
|
+
d_pad = jnp.full((n_blocks, max_len), 1e30, dtype=diag.dtype)
|
|
346
|
+
|
|
347
|
+
for i, idx in enumerate(block_indices):
|
|
348
|
+
n_b = len(idx) # static at trace time
|
|
349
|
+
t_pad = t_pad.at[i, :n_b].set(t[idx])
|
|
350
|
+
c_pad = c_pad.at[i, :n_b].set(channel[idx])
|
|
351
|
+
r_pad = r_pad.at[i, :n_b].set(res[idx])
|
|
352
|
+
d_pad = d_pad.at[i, :n_b].set(diag[idx])
|
|
353
|
+
if n_b < max_len:
|
|
354
|
+
n_extra = max_len - n_b
|
|
355
|
+
last_t = t[idx[-1]]
|
|
356
|
+
pad_start = last_t + jnp.asarray(1e4, dtype=t.dtype)
|
|
357
|
+
t_pad = t_pad.at[i, n_b:].set(
|
|
358
|
+
pad_start + jnp.arange(n_extra, dtype=t.dtype) * 0.001
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
def _block_logprob(
|
|
362
|
+
t_b: jnp.ndarray, c_b: jnp.ndarray, r_b: jnp.ndarray, d_b: jnp.ndarray
|
|
363
|
+
) -> jnp.ndarray:
|
|
364
|
+
return multigp_log_probability(kernel, t_b, c_b, r_b, d_b)
|
|
365
|
+
|
|
366
|
+
return jnp.sum(jax.vmap(_block_logprob)(t_pad, c_pad, r_pad, d_pad))
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class ChannelAutoKernel(kernels.Kernel):
|
|
370
|
+
"""One multi-GP channel's own covariance, as a function of scalar time.
|
|
371
|
+
|
|
372
|
+
:class:`MultiChannelKernel` needs ``(time, channel_index)`` pairs, which
|
|
373
|
+
makes it unusable with the report's PSD helpers — those call
|
|
374
|
+
``kernel(t, t)`` on a plain time array. This wrapper exposes a single
|
|
375
|
+
channel's auto-covariance in that ordinary form:
|
|
376
|
+
|
|
377
|
+
.. math::
|
|
378
|
+
|
|
379
|
+
k(t_1,t_2) = A^2\\gamma
|
|
380
|
+
+ A B\\,\\partial\\gamma/\\partial t_2
|
|
381
|
+
+ B A\\,\\partial\\gamma/\\partial t_1
|
|
382
|
+
+ B^2\\,\\partial^2\\gamma/\\partial t_1\\partial t_2
|
|
383
|
+
|
|
384
|
+
which is the diagonal block of the joint covariance — exactly the kernel
|
|
385
|
+
that channel would have had if it were fitted on its own. It is what a
|
|
386
|
+
periodogram PSD overlay should be compared against, since the periodogram
|
|
387
|
+
is likewise computed from that channel alone.
|
|
388
|
+
|
|
389
|
+
Attributes:
|
|
390
|
+
latent: Scalar unit-variance covariance ``gamma(params, t1, t2)``;
|
|
391
|
+
a **static** field.
|
|
392
|
+
params: Latent hyperparameters in linear scale; a traced field.
|
|
393
|
+
A: Projection onto ``G`` for this channel.
|
|
394
|
+
B: Projection onto ``Gdot`` for this channel.
|
|
395
|
+
"""
|
|
396
|
+
|
|
397
|
+
latent: Callable[[dict[str, Any], Any, Any], Any] = eqx.field(static=True)
|
|
398
|
+
params: dict[str, Any]
|
|
399
|
+
A: Any
|
|
400
|
+
B: Any
|
|
401
|
+
|
|
402
|
+
def evaluate(self, X1: Any, X2: Any) -> Any:
|
|
403
|
+
"""Covariance between two scalar times."""
|
|
404
|
+
|
|
405
|
+
def k(a: Any, b: Any) -> Any:
|
|
406
|
+
return self.latent(self.params, a, b)
|
|
407
|
+
|
|
408
|
+
dk_dt1 = jax.grad(k, argnums=0)
|
|
409
|
+
dk_dt2 = jax.grad(k, argnums=1)
|
|
410
|
+
d2k = jax.grad(dk_dt1, argnums=1)
|
|
411
|
+
|
|
412
|
+
return (
|
|
413
|
+
self.A * self.A * k(X1, X2)
|
|
414
|
+
+ self.A * self.B * dk_dt2(X1, X2)
|
|
415
|
+
+ self.B * self.A * dk_dt1(X1, X2)
|
|
416
|
+
+ self.B * self.B * d2k(X1, X2)
|
|
417
|
+
)
|
PyAETNA/logger.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Canonical repository-wide logging module for PyAETNA.
|
|
3
|
+
|
|
4
|
+
Provides a unified logger `_log` supporting both implicit module-level log file
|
|
5
|
+
destinations (configured via `set_log_file`) and explicit model/file target parameters.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time as ttt
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
_LOG_FILE: str | None = None
|
|
14
|
+
_EIV_TRIGGERED = False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def set_log_file(path: str | None) -> None:
|
|
18
|
+
"""Set the module-wide default log file path.
|
|
19
|
+
|
|
20
|
+
All subsequent calls to `_log` without an explicit target will write to `path`.
|
|
21
|
+
Pass `None` to log to stdout by default.
|
|
22
|
+
"""
|
|
23
|
+
global _LOG_FILE
|
|
24
|
+
_LOG_FILE = path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_log_file() -> str | None:
|
|
28
|
+
"""Return the current default log file path."""
|
|
29
|
+
return _LOG_FILE
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _log(target_or_msg: Any, msg: str | None = None, level: str | None = None) -> None:
|
|
33
|
+
"""Write a timestamped message to a log file or stdout.
|
|
34
|
+
|
|
35
|
+
Signatures supported:
|
|
36
|
+
_log("Message", level="WARNING") # Default target (_LOG_FILE or stdout)
|
|
37
|
+
_log(plModel, "Message") # Explicit model or path target
|
|
38
|
+
_log(log_file_path, "Message") # Explicit string path target
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
target_or_msg: Target object (with `.logFile` attribute), path (str), or message string.
|
|
42
|
+
msg (str, optional): Message string if target_or_msg is a target object/path.
|
|
43
|
+
level (str, optional): Severity level (e.g. 'WARNING', 'ERROR'). Standard 'INFO' level
|
|
44
|
+
is omitted from the output string as messages are informative by nature.
|
|
45
|
+
"""
|
|
46
|
+
if msg is not None:
|
|
47
|
+
target = target_or_msg
|
|
48
|
+
message = msg
|
|
49
|
+
else:
|
|
50
|
+
target = None
|
|
51
|
+
message = target_or_msg
|
|
52
|
+
|
|
53
|
+
# Extract path from target object if present, otherwise use target path or module default
|
|
54
|
+
log_file = target.logFile if hasattr(target, "logFile") else (target or _LOG_FILE)
|
|
55
|
+
|
|
56
|
+
# Omit 'INFO' tag from output line; include non-INFO severity levels (WARNING, ERROR, etc.)
|
|
57
|
+
level_tag = f" {level.upper()}:" if level and level.upper() != "INFO" else ""
|
|
58
|
+
timestamp = ttt.strftime("%Y-%m-%d %H:%M:%S", ttt.gmtime())
|
|
59
|
+
formatted = f"{timestamp}{level_tag} {message}"
|
|
60
|
+
|
|
61
|
+
if not log_file:
|
|
62
|
+
print(formatted)
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
with open(log_file, "a") as f:
|
|
67
|
+
f.write(formatted + "\n")
|
|
68
|
+
except OSError as e:
|
|
69
|
+
print(f"{formatted} (warning: failed to write to log file {log_file!r}: {e})")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Default log file is stdout (None)
|
|
73
|
+
set_log_file(None)
|