"""
JAX-accelerated Gaussian Process solver using the starspot analytic kernel.
Uses JAX for JIT-compiled covariance matrix construction, Cholesky
factorization, and GP operations (log-likelihood, prediction, MAP
estimation, and mass matrix computation for MCMC).
"""
import logging
import os
import warnings
import jax
jax.config.update("jax_enable_x64", True)
if "jax_platforms" not in os.environ.get("XLA_FLAGS", "") and "JAX_PLATFORMS" not in os.environ:
jax.config.update("jax_platforms", "cpu")
import jax.numpy as jnp
import jax.scipy.linalg as jla
import numpy as np
from .params import (
resolve_hparam, KERNEL_HPARAM_KEYS, HPARAM_KEYS_WITH_NOISE,
)
from .spot_model import (
SpotEvolutionModel, VisibilityFunction, EdgeOnVisibilityFunction,
_gauss_legendre_grid,
)
from .analytic_kernel import (
AnalyticKernel, _kernel_eval, _kernel_eval_edgeon,
)
from .banded_cholesky import (banded_cholesky, banded_solve,
banded_cholesky_compact, banded_solve_compact)
from .fitting import FittingMixin
from .gp_plots import GPPlotsMixin
from .mass_matrix import MassMatrixMixin
from .validation import (
validate_data, validate_data_vs_model, raise_cholesky_error,
)
__all__ = ["GPSolver"]
logger = logging.getLogger("spotgp")
# KERNEL_HPARAM_KEYS and HPARAM_KEYS_WITH_NOISE are imported from params.
# Re-export as lists for any callers that expect list type.
KERNEL_HPARAM_KEYS = list(KERNEL_HPARAM_KEYS)
HPARAM_KEYS_WITH_NOISE = list(HPARAM_KEYS_WITH_NOISE)
# _kernel_eval and _kernel_eval_edgeon are imported from analytic_kernel.py
# (the single source of truth for kernel math).
def _gp_log_likelihood(theta_full, x, y, yerr, mean_val,
n_harmonics, n_lat, lat_range,
fit_sigma_n, n_kernel=6,
r_gamma_func=None,
quad_nodes=None, quad_weights=None,
edgeon_cn_sq=None,
lat_weight_func=None,
uniform_dt=None,
lag_table=None):
"""
Pure-functional GP marginal log-likelihood.
When ``uniform_dt`` is given (Toeplitz fast path), the stationary
kernel takes only N distinct values over the whole matrix, so it is
evaluated once per distinct lag and gathered: K[i, j] = K1d[|i - j|].
``lag_table`` generalizes this to uniform-cadence data *with gaps*
(the common Kepler/TESS case): the kernel is evaluated once per
distinct integer-cadence lag and gathered through a precomputed
index table. Otherwise the kernel is evaluated on the
upper-triangular lags (N*(N+1)/2 instead of N^2), halving memory and
compute relative to the naive full-matrix evaluation.
Parameters
----------
theta_full : jnp.ndarray, shape (n_kernel,) or (n_kernel+1,)
Kernel params, optionally followed by sigma_n (white noise).
x, y, yerr : jnp.ndarray
Observations.
mean_val : float
Constant mean.
n_harmonics, n_lat, lat_range : kernel config.
fit_sigma_n : bool
If True, last element of theta_full is sigma_n.
n_kernel : int
Number of kernel parameters (default 6 for backward compat).
r_gamma_func : callable or None
JAX-traceable envelope R_Gamma function (see _kernel_eval).
quad_nodes, quad_weights : jnp.ndarray or None
Gauss-Legendre nodes/weights. If None, uses trapezoid rule.
uniform_dt : float or None
Sample spacing when ``x`` is a uniform grid (compile-time
constant). Enables the Toeplitz fast path; None disables it.
lag_table : (jnp.ndarray, jnp.ndarray) or None
``(unique_lags, inv_idx)`` for uniform-cadence data with gaps:
``unique_lags`` holds the distinct pairwise lags and ``inv_idx``
is an (N, N) integer map with ``|x_i - x_j| =
unique_lags[inv_idx[i, j]]``. Ignored when ``uniform_dt`` is
given.
Returns
-------
logL : scalar
"""
N = x.shape[0]
if fit_sigma_n:
theta_kernel = theta_full[:n_kernel]
sigma_n = theta_full[n_kernel]
else:
theta_kernel = theta_full
sigma_n = 0.0
if uniform_dt is not None:
# Toeplitz fast path: N distinct lags instead of N*(N+1)/2.
lag_unique = jnp.arange(N) * uniform_dt
K1d = _kernel_eval(theta_kernel, lag_unique,
n_harmonics, n_lat, lat_range,
quad_nodes=quad_nodes, quad_weights=quad_weights,
r_gamma_func=r_gamma_func,
edgeon_cn_sq=edgeon_cn_sq,
lat_weight_func=lat_weight_func)
idx = jnp.abs(jnp.arange(N)[:, None] - jnp.arange(N)[None, :])
K = K1d[idx]
elif lag_table is not None:
# Gappy-uniform fast path: distinct lags gathered by index table.
lags_unique, inv_idx = lag_table
K1d = _kernel_eval(theta_kernel, lags_unique,
n_harmonics, n_lat, lat_range,
quad_nodes=quad_nodes, quad_weights=quad_weights,
r_gamma_func=r_gamma_func,
edgeon_cn_sq=edgeon_cn_sq,
lat_weight_func=lat_weight_func)
K = K1d[inv_idx]
else:
# Upper-triangular indices (includes diagonal)
row_idx, col_idx = jnp.triu_indices(N)
lag_upper = jnp.abs(x[row_idx] - x[col_idx])
K_upper = _kernel_eval(theta_kernel, lag_upper,
n_harmonics, n_lat, lat_range,
quad_nodes=quad_nodes, quad_weights=quad_weights,
r_gamma_func=r_gamma_func,
edgeon_cn_sq=edgeon_cn_sq,
lat_weight_func=lat_weight_func)
# Reconstruct symmetric matrix from upper triangle
K = jnp.zeros((N, N))
K = K.at[row_idx, col_idx].set(K_upper)
K = K + K.T - jnp.diag(jnp.diag(K))
noise_var = yerr ** 2 + sigma_n ** 2
K_noise = K + jnp.diag(noise_var) + 1e-8 * jnp.eye(N)
L = jla.cholesky(K_noise, lower=True)
resid = y - mean_val
alpha = jla.cho_solve((L, True), resid)
data_fit = resid @ alpha
log_det = 2.0 * jnp.sum(jnp.log(jnp.diag(L)))
return -0.5 * (data_fit + log_det + N * jnp.log(2 * jnp.pi))
def _build_banded_kernel_jax(theta_kernel, x, bandwidth,
n_harmonics, n_lat, lat_range,
r_gamma_func=None,
quad_nodes=None, quad_weights=None,
edgeon_cn_sq=None,
lat_weight_func=None,
uniform_dt=None,
band_lag_table=None):
"""
Build the kernel covariance directly in compact (b+1, N) banded storage.
Evaluates only the O(N*b) within-band lags instead of the full N^2
matrix. When ``uniform_dt`` is given (Toeplitz fast path), every
diagonal d of the band holds the single value K(d*dt), so the kernel
is evaluated at just b+1 distinct lags and broadcast across the band
— O(b) kernel evaluations instead of O(N*b). ``band_lag_table``
generalizes this to uniform-cadence data with gaps: the kernel is
evaluated once per distinct within-band lag and gathered through a
precomputed index table.
Parameters
----------
theta_kernel : jnp.ndarray, shape (n_params,)
Kernel hyperparameters.
x : jnp.ndarray, shape (N,)
Observation times.
bandwidth : int
Number of sub-diagonals.
n_harmonics, n_lat, lat_range : kernel config.
r_gamma_func : callable or None
JAX-traceable envelope R_Gamma function (see _kernel_eval).
quad_nodes, quad_weights : jnp.ndarray or None
uniform_dt : float or None
Sample spacing when ``x`` is a uniform grid (compile-time
constant). Enables the Toeplitz fast path; None disables it.
band_lag_table : (jnp.ndarray, jnp.ndarray) or None
``(unique_lags, inv_idx)`` for uniform-cadence data with gaps:
``unique_lags`` holds the distinct within-band lags and
``inv_idx`` is a (b+1, N) integer map with ``x[i+d] - x[i] =
unique_lags[inv_idx[d, i]]``. Ignored when ``uniform_dt`` is
given.
Returns
-------
cb : jnp.ndarray, shape (b+1, N)
Compact lower-banded storage: ``cb[d, i] = K(x[i+d], x[i])``.
"""
N = x.shape[0]
b = bandwidth
d_idx = jnp.arange(b + 1)[:, None]
i_idx = jnp.arange(N)[None, :]
j_idx = i_idx + d_idx
valid = j_idx < N
if uniform_dt is not None:
# Toeplitz fast path: b+1 distinct lags instead of (b+1)*N.
lag_unique = jnp.arange(b + 1) * uniform_dt
K_unique = _kernel_eval(theta_kernel, lag_unique,
n_harmonics, n_lat, lat_range,
quad_nodes=quad_nodes,
quad_weights=quad_weights,
r_gamma_func=r_gamma_func,
edgeon_cn_sq=edgeon_cn_sq,
lat_weight_func=lat_weight_func)
return jnp.where(valid, K_unique[:, None], 0.0)
if band_lag_table is not None:
# Gappy-uniform fast path: distinct within-band lags, gathered
# through the precomputed index table.
lags_unique, inv_idx = band_lag_table
K_unique = _kernel_eval(theta_kernel, lags_unique,
n_harmonics, n_lat, lat_range,
quad_nodes=quad_nodes,
quad_weights=quad_weights,
r_gamma_func=r_gamma_func,
edgeon_cn_sq=edgeon_cn_sq,
lat_weight_func=lat_weight_func)
return jnp.where(valid, K_unique[inv_idx], 0.0)
j_safe = jnp.minimum(j_idx, N - 1)
lags_flat = jnp.abs(x[j_safe] - x[i_idx]).ravel()
K_flat = _kernel_eval(theta_kernel, lags_flat,
n_harmonics, n_lat, lat_range,
quad_nodes=quad_nodes, quad_weights=quad_weights,
r_gamma_func=r_gamma_func,
edgeon_cn_sq=edgeon_cn_sq,
lat_weight_func=lat_weight_func)
cb = K_flat.reshape(b + 1, N)
cb = jnp.where(valid, cb, 0.0)
return cb
def _gp_log_likelihood_banded(theta_full, x, y, yerr, mean_val,
n_harmonics, n_lat, lat_range,
fit_sigma_n, bandwidth,
n_kernel=6,
r_gamma_func=None,
quad_nodes=None, quad_weights=None,
edgeon_cn_sq=None,
lat_weight_func=None,
uniform_dt=None,
band_lag_table=None):
"""
Pure-functional GP marginal log-likelihood using banded Cholesky.
Builds the covariance directly in compact (b+1, N) banded storage,
evaluating only the O(N*b) within-band lags — or, on a uniform grid
(``uniform_dt`` given), only the b+1 distinct lags of the Toeplitz
band. The Cholesky factorization and solve operate entirely in
compact storage — no (N, N) matrix is ever allocated.
``bandwidth`` must be a Python ``int`` (captured in the JIT closure
at trace time so the inner scan loop can be unrolled statically).
Parameters
----------
bandwidth : int
Number of sub-diagonals to retain (compile-time constant).
n_kernel : int
Number of kernel parameters (default 6 for backward compat).
r_gamma_func : callable or None
JAX-traceable envelope R_Gamma function (see _kernel_eval).
uniform_dt : float or None
Sample spacing when ``x`` is a uniform grid (compile-time
constant). Enables the Toeplitz fast path; None disables it.
All other parameters are the same as ``_gp_log_likelihood``.
"""
N = x.shape[0]
if fit_sigma_n:
theta_kernel = theta_full[:n_kernel]
sigma_n = theta_full[n_kernel]
else:
theta_kernel = theta_full
sigma_n = 0.0
# Build covariance in compact banded storage: O(N*b) instead of O(N^2)
cb = _build_banded_kernel_jax(theta_kernel, x, bandwidth,
n_harmonics, n_lat, lat_range,
r_gamma_func=r_gamma_func,
quad_nodes=quad_nodes,
quad_weights=quad_weights,
edgeon_cn_sq=edgeon_cn_sq,
lat_weight_func=lat_weight_func,
uniform_dt=uniform_dt,
band_lag_table=band_lag_table)
# Add noise to diagonal (row 0 of compact storage)
noise_var = yerr ** 2 + sigma_n ** 2
cb = cb.at[0, :].add(noise_var + 1e-8)
# Factorize and solve in compact storage
Lc = banded_cholesky_compact(cb, bandwidth)
resid = y - mean_val
alpha = banded_solve_compact(Lc, resid, bandwidth)
data_fit = resid @ alpha
log_det = 2.0 * jnp.sum(jnp.log(Lc[0, :]))
return -0.5 * (data_fit + log_det + N * jnp.log(2 * jnp.pi))
def _default_log_prior(theta_arr, bounds):
"""
Default log-prior: soft uniform within bounds.
Uses sigmoid barriers to keep gradients finite near boundaries.
"""
lo = bounds[:, 0]
hi = bounds[:, 1]
k = 100.0 # steepness
log_lo = jnp.sum(jax.nn.log_sigmoid(k * (theta_arr - lo)))
log_hi = jnp.sum(jax.nn.log_sigmoid(k * (hi - theta_arr)))
log_vol = jnp.sum(jnp.log(hi - lo))
return log_lo + log_hi - log_vol
def _validate_hparam(hparam):
"""Validate hparam dict (shared by __init__ and update_hparam).
Delegates to params.resolve_hparam for all validation logic; raises
on the first error encountered.
"""
resolve_hparam(hparam) # raises TypeError / ValueError on bad input
def _detect_uniform_dt(x, rtol=1e-6):
"""
Return the sample spacing if ``x`` is a uniform grid, else None.
A uniform grid enables the Toeplitz fast path: the stationary kernel
takes only one distinct value per diagonal of the covariance matrix,
so it can be evaluated once per distinct lag instead of once per
matrix entry.
Parameters
----------
x : array_like, shape (N,)
Observation times, assumed sorted.
rtol : float
Maximum relative deviation of any spacing from the median
spacing (default 1e-6).
Returns
-------
dt : float or None
The common spacing, or None if the grid is irregular.
Uniform-cadence data *with gaps* also returns None here but is
handled by the second detection tier
(``_detect_cadence_offsets``).
"""
x = np.asarray(x, dtype=np.float64)
if x.shape[0] < 2:
return None
diffs = np.diff(x)
dt = float(np.median(diffs))
if dt <= 0.0:
return None
if np.allclose(diffs, dt, rtol=rtol, atol=0.0):
return dt
return None
def _detect_cadence_offsets(x, rtol=1e-3):
"""
Detect a uniform cadence with gaps and return integer lag offsets.
Handles the common Kepler/TESS case: samples on a regular cadence
``dt`` with missing chunks. Every pairwise lag is then an integer
multiple of ``dt``, so the stationary kernel takes far fewer distinct
values than there are matrix entries and can be evaluated once per
distinct within-band lag (see ``GPSolver._band_lag_table``).
Parameters
----------
x : array_like, shape (N,)
Observation times, assumed sorted.
rtol : float
Maximum deviation of any sample from its cadence grid point,
relative to ``dt`` (default 1e-3).
Returns
-------
dt : float or None
The cadence, or None if ``x`` is not on a uniform cadence grid.
offsets : np.ndarray of int64 or None
Integer grid index of each sample, ``x ≈ x[0] + offsets * dt``.
"""
x = np.asarray(x, dtype=np.float64)
if x.shape[0] < 2:
return None, None
diffs = np.diff(x)
dt = float(np.median(diffs))
if dt <= 0.0:
return None, None
k = np.round((x - x[0]) / dt)
on_grid = np.max(np.abs(x - (x[0] + k * dt))) <= rtol * dt
if on_grid and np.all(np.diff(k) >= 1):
return dt, k.astype(np.int64)
return None, None
def _make_lbfgsb_batch_runner(vg_fn, n_free, maxiter, gtol):
"""
Build a compiled multi-start L-BFGS-B runner.
The returned callable maps a ``(n_starts, n_free)`` batch of starting
points in unit-cube coordinates to ``(u, fun, nit)`` arrays, running
every restart inside a single ``jax.vmap``-ed ``jaxopt.LBFGSB``
program instead of one scipy optimizer per start in a thread pool.
Compilation of the vmapped solver is expensive (it traces the full
while-loop optimizer around the covariance build), so callers cache
the returned runner and reuse it across calls; only the first call
per (objective, maxiter, gtol) pays the compile.
Parameters
----------
vg_fn : callable
JAX-traceable objective mapping a length-``n_free`` vector in
[0, 1] coordinates to ``(value, grad)``.
n_free : int
Number of free parameters.
maxiter : int
Maximum L-BFGS-B iterations per start.
gtol : float
Convergence tolerance passed to the solver.
Raises
------
ImportError
If jaxopt is not installed. Callers fall back to the
thread-pool implementation.
"""
from jaxopt import LBFGSB
solver = LBFGSB(fun=vg_fn, value_and_grad=True,
maxiter=int(maxiter), tol=float(gtol))
lo = jnp.zeros(n_free, dtype=jnp.float64)
hi = jnp.ones(n_free, dtype=jnp.float64)
def _run_one(u0):
res = solver.run(u0, bounds=(lo, hi))
return res.params, res.state.value, res.state.iter_num
vmapped = jax.jit(jax.vmap(_run_one))
def runner(u0_batch):
u, fun, nit = vmapped(jnp.asarray(u0_batch, dtype=jnp.float64))
return np.asarray(u), np.asarray(fun), np.asarray(nit)
return runner
# =====================================================================
# GPSolver class
# =====================================================================
[docs]
class GPSolver(FittingMixin, GPPlotsMixin, MassMatrixMixin):
"""
JAX-accelerated Gaussian Process solver for stellar lightcurves.
Handles covariance matrix construction, Cholesky factorization,
log-likelihood evaluation, prediction, MAP estimation, and mass
matrix computation.
Parameters
----------
data_or_x : TimeSeriesData or array_like, shape (N,)
Either a ``TimeSeriesData`` object, or observation times [days].
When a ``TimeSeriesData`` is passed, ``y`` and ``yerr`` must be
None (they are read from the data object).
y : array_like, shape (N,), optional
Observed flux values. Required when ``data_or_x`` is an array.
yerr : array_like, shape (N,) or float, optional
Measurement uncertainties (1-sigma). Required when ``data_or_x``
is an array.
model_or_hparam : SpotEvolutionModel or dict
Either a SpotEvolutionModel (new API) or a raw hparam dict
(backward-compatible old API).
kernel_type : {"analytic"}
Which kernel to use (default: "analytic").
mean : float or callable or None
Mean function.
fit_sigma_n : bool
If True, include white noise amplitude sigma_n as a free
parameter for optimization/sampling (default False).
bounds : dict or None
Parameter bounds for optimization. If None, uses defaults.
log_prior : callable or None
Custom log-prior function f(theta_arr) -> scalar.
If None, uses soft uniform within bounds.
kernel_kwargs : dict
Extra kwargs forwarded to the kernel constructor.
"""
DEFAULT_BOUNDS = {
"peq": (0.5, 50.0),
"kappa": (0.001, 0.999),
"inc": (0.01, np.pi - 0.01),
"lspot": (0.1, 20.0),
"tau_spot": (0.05, 10.0),
"tau_em": (0.05, 10.0),
"tau_dec": (0.05, 10.0),
"sigma_sn": (0.05, 10.0),
"n_sn": (-10.0, 10.0),
"lat_min": (0.0, np.pi / 2),
"lat_max": (0.0, np.pi / 2),
"sigma_k": (1e-6, 1.0),
"sigma_n": (1e-6, 0.1),
}
def __init__(self, data_or_x, y=None, yerr=None, model_or_hparam=None,
kernel_type="analytic",
mean=None, fit_sigma_n=False, bounds=None,
log_prior=None, matrix_solver="cholesky_banded",
bandwidth=None, save_dir=None, **kernel_kwargs):
# ── Parse data source ────────────────────────────────────────────
from .observations import TimeSeriesData
if isinstance(data_or_x, TimeSeriesData):
# New API: GPSolver(data, model_or_hparam, ...)
# GPSolver(data, model)
# GPSolver(data, model, bounds)
self.data = data_or_x
# The second positional arg is the model, not y
if model_or_hparam is None:
model_or_hparam = y
y = None
# The third positional arg may be bounds (dict/array), not yerr
if isinstance(yerr, (dict, np.ndarray)) and bounds is None:
bounds = yerr
yerr = None
else:
# Legacy API: GPSolver(x, y, yerr, model_or_hparam, ...)
self.data = TimeSeriesData(data_or_x, y, yerr)
self.x = jnp.asarray(self.data.x, dtype=jnp.float64)
self.y = jnp.asarray(self.data.y, dtype=jnp.float64)
self.yerr = jnp.asarray(self.data.yerr, dtype=jnp.float64)
self.N = len(self.x)
# ── Validate data quality ───────────────────────────────────────
validate_data(self.data.x, self.data.y, self.data.yerr)
# Detect uniform sampling. On a uniform grid the covariance is
# Toeplitz, so the kernel needs evaluating only once per distinct
# lag (b+1 values for the banded solver, N for the full solver)
# instead of once per matrix entry. None for irregular grids.
self.uniform_dt = _detect_uniform_dt(self.data.x)
# Second tier: uniform cadence *with gaps* (Kepler/TESS-style).
# Pairwise lags are then integer multiples of the cadence, so the
# kernel is evaluated once per distinct within-band lag and
# gathered through a precomputed index table (see
# _band_lag_table / _full_lag_table).
if self.uniform_dt is None:
self._cadence_dt, self._cadence_offsets = \
_detect_cadence_offsets(self.data.x)
else:
self._cadence_dt, self._cadence_offsets = self.uniform_dt, None
self._band_lag_table_cache = None
self._full_lag_table_cache = None
# Accept SpotEvolutionModel or legacy hparam dict
if isinstance(model_or_hparam, SpotEvolutionModel):
self.spot_model = model_or_hparam
self.hparam = model_or_hparam.to_hparam()
else:
_validate_hparam(model_or_hparam)
self.hparam = dict(model_or_hparam)
self.spot_model = SpotEvolutionModel.from_hparam(self.hparam)
# Mean function
if mean is None:
self.mean_val = float(jnp.mean(self.y))
self.mean_func = lambda t: self.mean_val
elif callable(mean):
self.mean_func = mean
self.mean_val = float(mean(self.x[0]))
else:
self.mean_val = float(mean)
self.mean_func = lambda t: self.mean_val
# Matrix solver
_valid_solvers = ("cholesky_full", "cholesky_banded")
if matrix_solver not in _valid_solvers:
raise ValueError(
f"matrix_solver must be one of {_valid_solvers}, "
f"got '{matrix_solver}'")
self.matrix_solver = matrix_solver
# Build kernel
self.kernel_type = kernel_type
self.kernel_kwargs = kernel_kwargs
self._build_kernel()
# Update hparam with computed sigma_k (if nspot/fspot were given)
self.hparam = dict(self.kernel.hparam)
# Optimization/sampling config — must be set before bounds parsing
# so that the log-param remapping can identify which physical keys
# to replace with their log-space counterparts.
self.fit_sigma_n = fit_sigma_n
# Use envelope-aware param_keys from the SpotEvolutionModel.
# This replaces the old hardcoded KERNEL_HPARAM_KEYS approach.
_model_keys = self.spot_model.param_keys # e.g. (peq, kappa, inc, lspot, tau_spot, sigma_k)
_base_keys = _model_keys + ("sigma_n",) if fit_sigma_n else _model_keys
# Detect log-space parameters: keys prefixed with "log_" in the
# bounds dict indicate sampling in log10 space. The physical key
# is the suffix (e.g. "log_sigma_k" → physical key "sigma_k").
self._log_param_map = {} # {log_key: phys_key}
if isinstance(bounds, dict):
for k in bounds:
if k.startswith("log_"):
self._log_param_map[k] = k[4:]
# Remap param_keys: replace physical keys with their log-space names
# where applicable. All other code uses self.param_keys, so this
# single remap propagates everywhere (MAP dict keys, corner labels, etc.)
_phys_to_log = {v: k for k, v in self._log_param_map.items()}
self.param_keys = tuple(_phys_to_log.get(k, k) for k in _base_keys)
self.n_params = len(self.param_keys)
# Parse bounds — must precede _compute_bandwidth so that the upper
# bounds of lspot and tau_spot are available for the bandwidth calculation.
# For log-space keys the supplied bounds are already in log10 units;
# for physical keys without explicit bounds, fall back to DEFAULT_BOUNDS.
if bounds is None:
self.bounds = jnp.array(
[self.DEFAULT_BOUNDS[k] for k in _base_keys],
dtype=jnp.float64)
elif isinstance(bounds, dict):
self.bounds = jnp.array(
[bounds.get(_pk, self.DEFAULT_BOUNDS[_bk])
for _pk, _bk in zip(self.param_keys, _base_keys)],
dtype=jnp.float64)
else:
self.bounds = jnp.asarray(bounds, dtype=jnp.float64)
# ── Validate data vs model configuration ───────────────────────
validate_data_vs_model(self.data.x, self.bounds, self.param_keys)
# Bandwidth for banded solver (compile-time constant).
# Can be set explicitly via the bandwidth argument; otherwise derived
# from the upper bounds of envelope parameters so that the banded
# approximation is valid for any parameters within the prior.
if self.matrix_solver == "cholesky_banded":
if bandwidth is not None:
self.bandwidth = min(int(bandwidth), self.N - 1)
else:
self.bandwidth = self._compute_bandwidth()
_n_banded = (self.bandwidth + 1) * self.N
_n_full = self.N * self.N
_sparsity = 100.0 * (1.0 - _n_banded / _n_full)
_toep = (", Toeplitz fast path (uniform grid)"
if self.uniform_dt is not None else "")
logger.info("Banded Cholesky: bandwidth=%d, N=%d, sparsity=%.1f%%%s",
self.bandwidth, self.N, _sparsity, _toep)
self._check_bandwidth_efficiency()
# Build covariance and factorize
self._build_covariance()
# Initial theta: physical values from spot_model, converted to log10
# for any log-parameterized keys.
_phys_theta0 = dict(zip(self.spot_model.param_keys,
self.spot_model.theta0))
if fit_sigma_n:
_phys_theta0["sigma_n"] = float(
self.hparam.get("sigma_n", self.DEFAULT_BOUNDS["sigma_n"][0]))
self.theta0 = jnp.array([
np.log10(float(_phys_theta0.get(self._log_param_map[k], 0.0)))
if k in self._log_param_map
else float(_phys_theta0.get(k, 0.0))
for k in self.param_keys
], dtype=jnp.float64)
# Kernel config (extract from kernel object)
self.n_harmonics = self.kernel.n_harmonics
self.n_lat = self.kernel.n_lat
# When latitude params are free, the quadrature grid must cover
# the full hemisphere so the dynamic weights can select any sub-range.
if self.spot_model.latitude_distribution.param_dict:
self.lat_range = (-np.pi / 2, np.pi / 2)
else:
self.lat_range = self.kernel.lat_range
# Quadrature nodes — precomputed at init so they are captured as
# array constants in JIT closures rather than recomputed per trace.
#
# GL weights are pre-normalized (sum = 1.0) so the /norm division
# inside _kernel_eval reduces to /1.0, which XLA eliminates.
#
# Trapezoid weights are left as None: lat_range and n_lat are Python
# scalars captured in the JIT closure, so XLA constant-folds the
# linspace and weight construction at compile time anyway. Pre-
# normalizing trapezoid weights is non-trivial because jnp.sum(dphi*
# ones) = n_lat*dphi != phi_max-phi_min = (n_lat-1)*dphi, which
# would silently change the kernel normalization by n_lat/(n_lat-1).
if self.kernel.quadrature == "gauss-legendre":
if self.spot_model.latitude_distribution.param_dict:
# Latitude params are free: recompute GL nodes/weights
# over the full hemisphere without baked-in lat_dist weights.
gl_nodes, gl_weights = _gauss_legendre_grid(
self.n_lat, -np.pi / 2, np.pi / 2)
_norm = float(jnp.sum(gl_weights))
self._quad_nodes = gl_nodes
self._quad_weights = gl_weights / _norm
else:
raw_w = self.kernel._quad_weights
_norm = float(jnp.sum(raw_w))
self._quad_nodes = self.kernel._quad_nodes
self._quad_weights = raw_w / _norm # sum = 1.0
else: # trapezoid: keep None, XLA constant-folds the else branch
self._quad_nodes = None
self._quad_weights = None
# Prior
self._custom_log_prior = log_prior
# Lag matrix is computed lazily (only needed for Fisher info)
self._lag_flat = None
# Build JAX function that maps sampling theta → physical theta
# (applies 10^x for any log-parameterized indices).
self._build_transform()
# Build JIT-compiled log-posterior
self._build_logposterior()
# Storage for optimization results
self.map_estimate = None
self.inverse_mass_matrix = None
self._hessian = None
self._fisher_matrix = None
self._laplace_hessian = None
self._laplace_mean = None
# Output directory (optional)
if save_dir is not None:
import os as _os
_os.makedirs(save_dir, exist_ok=True)
self.save_dir = save_dir
def _autosave(self, filename, **arrays):
"""Save ``arrays`` to ``save_dir/filename`` if ``save_dir`` is set."""
if self.save_dir is None:
return
import os as _os
path = _os.path.join(self.save_dir, filename)
np.savez(path, **arrays)
logger.debug("Saved %s → %s", filename, path)
def _build_kernel(self):
"""Instantiate the kernel object from the SpotEvolutionModel."""
if self.kernel_type == "analytic":
self.kernel = AnalyticKernel(self.spot_model, **self.kernel_kwargs)
else:
raise ValueError(
f"GPSolver only supports 'analytic' kernel, "
f"got '{self.kernel_type}'")
def _build_transform(self):
"""Build _to_physical: sampling theta → physical theta.
For each log-parameterized key (e.g. ``log_sigma_k``), the
corresponding element of the theta array is stored in log10 space
during sampling/optimization. This function raises 10 to the power
of those elements so that the kernel always receives physical values.
If no log-space parameters are present, ``_to_physical`` is the
identity function (zero overhead).
"""
if not self._log_param_map:
self._to_physical = lambda x: x
return
keys = list(self.param_keys)
log_indices = jnp.array(
[keys.index(k) for k in self._log_param_map], dtype=jnp.int32)
@jax.jit
def to_physical(theta_arr):
return theta_arr.at[log_indices].set(10.0 ** theta_arr[log_indices])
self._to_physical = to_physical
def _eval_kernel(self, tau):
"""Evaluate the kernel at time lags tau."""
tau = jnp.asarray(tau, dtype=float)
return jnp.asarray(self.kernel.kernel(jnp.abs(tau)))
def _compute_bandwidth(self):
"""
Bandwidth in samples for the banded Cholesky solver.
Derived from the **upper bounds** of envelope parameters so that
the banded approximation is valid for any parameters within the
prior, regardless of the current hparam values. This is required
for JIT-compiled sampling: the bandwidth is a compile-time constant
(it determines array shapes), so it must cover the entire prior
support rather than just the initial hyperparameters.
Uses the detected uniform spacing when available, otherwise the
median spacing (robust to gaps, unlike ``x[1] - x[0]``).
"""
if self.N < 2:
return self.N
if self.uniform_dt is not None:
dt = self.uniform_dt
else:
dt = float(np.median(np.diff(np.asarray(self.x))))
support = self.spot_model.bandwidth_support(self.param_keys, self.bounds)
b = int(np.ceil(support / dt))
return min(b, self.N - 1)
def _check_bandwidth_efficiency(self):
"""Warn when the band is so wide that banded loses to dense.
The bandwidth is derived from the prior *upper bounds* of the
envelope parameters, so a generous prior (e.g. ``lspot`` up to
tens of days) combined with a fine cadence can silently push
``b`` toward ``N`` — where the O(N b^2) banded solver costs as
much as dense O(N^3) Cholesky while adding overhead.
"""
if 2 * self.bandwidth >= self.N:
warnings.warn(
f"Banded solver bandwidth b={self.bandwidth} is >= N/2 "
f"(N={self.N}), so the banded Cholesky has (almost) no "
"sparsity to exploit and is likely slower than the dense "
"solver. The bandwidth is set by the prior upper bounds "
"of the envelope parameters (kernel support ~ lspot + "
"2*tau_spot) divided by the cadence; tighten those "
"bounds, downsample the lightcurve, or switch to "
"matrix_solver='cholesky_full'.",
stacklevel=3)
def _band_lag_table(self):
"""(unique_lags, inv_idx) for the banded build on gappy-uniform grids.
Returns None when the grid is strictly uniform (the cheaper
``uniform_dt`` broadcast applies) or genuinely irregular. The
table is cached; ``inv_idx`` has shape (b+1, N) and satisfies
``x[min(i+d, N-1)] - x[i] = unique_lags[inv_idx[d, i]]``.
"""
if self.uniform_dt is not None or self._cadence_offsets is None:
return None
if self._band_lag_table_cache is None:
off = self._cadence_offsets
b, N = self.bandwidth, self.N
d = np.arange(b + 1)[:, None]
i = np.arange(N)[None, :]
j = np.minimum(i + d, N - 1)
lag_int = off[j] - off[i]
uniq, inv = np.unique(lag_int, return_inverse=True)
self._band_lag_table_cache = (
jnp.asarray(uniq * self._cadence_dt, dtype=jnp.float64),
jnp.asarray(inv.reshape(b + 1, N).astype(np.int32)))
return self._band_lag_table_cache
def _full_lag_table(self):
"""(unique_lags, inv_idx) for the dense build on gappy-uniform grids.
Returns None when the grid is strictly uniform or irregular.
``inv_idx`` has shape (N, N) with ``|x_i - x_j| =
unique_lags[inv_idx[i, j]]``; the dense path is O(N^2) anyway,
so the int32 index table does not change its memory scaling.
"""
if self.uniform_dt is not None or self._cadence_offsets is None:
return None
if self._full_lag_table_cache is None:
off = self._cadence_offsets
lag_int = np.abs(off[:, None] - off[None, :])
uniq, inv = np.unique(lag_int, return_inverse=True)
self._full_lag_table_cache = (
jnp.asarray(uniq * self._cadence_dt, dtype=jnp.float64),
jnp.asarray(inv.reshape(self.N, self.N).astype(np.int32)))
return self._full_lag_table_cache
def _build_covariance(self):
"""Build the covariance matrix and Cholesky-factorize it.
For ``cholesky_full``: builds the full N x N matrix (upper-triangle
symmetry trick, N*(N+1)/2 kernel evals).
For ``cholesky_banded``: builds directly in compact (b+1, N) banded
storage, evaluating only the O(N*b) within-band entries. No (N, N)
matrix is ever allocated; ``self.K`` and ``self.K_noise`` are set
to ``None``.
"""
mu = self.mean_func(self.x)
if jnp.isscalar(mu):
self._mu = jnp.full(self.N, mu)
else:
self._mu = jnp.asarray(mu)
self._resid = self.y - self._mu
if self.matrix_solver == "cholesky_banded":
# Build kernel directly in compact (b+1, N) storage
N = self.N
b = self.bandwidth
d_idx = jnp.arange(b + 1)[:, None] # (b+1, 1)
i_idx = jnp.arange(N)[None, :] # (1, N)
j_idx = i_idx + d_idx # (b+1, N)
valid = j_idx < N
band_tab = self._band_lag_table()
if self.uniform_dt is not None:
# Toeplitz fast path: b+1 distinct lags, broadcast per row
K_unique = self._eval_kernel(
jnp.arange(b + 1) * self.uniform_dt)
cb = jnp.where(valid, K_unique[:, None], 0.0)
elif band_tab is not None:
# Gappy-uniform fast path: distinct within-band lags,
# gathered through the precomputed index table
K_unique = self._eval_kernel(band_tab[0])
cb = jnp.where(valid, K_unique[band_tab[1]], 0.0)
else:
j_safe = jnp.minimum(j_idx, N - 1)
lags_flat = jnp.abs(self.x[j_safe] - self.x[i_idx]).ravel()
K_flat = self._eval_kernel(lags_flat)
cb = K_flat.reshape(b + 1, N)
cb = jnp.where(valid, cb, 0.0)
# Add noise to diagonal (row 0)
cb = cb.at[0, :].add(self.yerr ** 2 + 1e-8)
self.K = None
self.K_noise = None
try:
self._Lc = banded_cholesky_compact(cb, self.bandwidth)
except Exception as e:
raise_cholesky_error(
e, theta=self.theta0 if hasattr(self, 'theta0') else None,
param_keys=self.param_keys if hasattr(self, 'param_keys') else None,
bounds=self.bounds if hasattr(self, 'bounds') else None,
context="initial covariance build (banded solver)")
self._L = None
self._alpha = banded_solve_compact(
self._Lc, self._resid, self.bandwidth)
else:
N = self.N
full_tab = self._full_lag_table()
if self.uniform_dt is not None:
# Toeplitz fast path: N distinct lags, gathered by |i - j|
K1d = self._eval_kernel(jnp.arange(N) * self.uniform_dt)
idx = jnp.abs(jnp.arange(N)[:, None] - jnp.arange(N)[None, :])
K = K1d[idx]
elif full_tab is not None:
# Gappy-uniform fast path: distinct lags via index table
K1d = self._eval_kernel(full_tab[0])
K = K1d[full_tab[1]]
else:
row_idx, col_idx = jnp.triu_indices(N)
lag_upper = jnp.abs(self.x[row_idx] - self.x[col_idx])
K_upper = self._eval_kernel(lag_upper)
K = jnp.zeros((N, N))
K = K.at[row_idx, col_idx].set(K_upper)
K = K + K.T - jnp.diag(jnp.diag(K))
self.K = K
self.K_noise = K + jnp.diag(self.yerr ** 2) + 1e-8 * jnp.eye(N)
self._Lc = None
try:
self._L = jla.cholesky(self.K_noise, lower=True)
except Exception as e:
raise_cholesky_error(
e, theta=self.theta0 if hasattr(self, 'theta0') else None,
param_keys=self.param_keys if hasattr(self, 'param_keys') else None,
bounds=self.bounds if hasattr(self, 'bounds') else None,
context="initial covariance build (dense solver)")
self._alpha = jla.cho_solve((self._L, True), self._resid)
def _build_logposterior(self):
"""Build the JIT-compiled log-posterior and its value-and-gradient.
A single raw log-posterior is compiled exactly twice: once
value-only (``log_posterior``, the traceable log-density handed to
samplers) and once wrapped in ``jax.value_and_grad``
(``value_and_grad_log_posterior``, the workhorse for optimizers).
``neg_log_posterior``, ``grad_log_posterior`` and
``grad_neg_log_posterior`` are thin Python wrappers that negate or
slice those two compiled functions outside XLA, so they trigger no
additional compilation (previously each was a separate XLA
compilation, quadrupling warm-up time).
"""
bounds = self.bounds
x, y, yerr = self.x, self.y, self.yerr
mean_val = self.mean_val
n_h, n_l, lr = self.n_harmonics, self.n_lat, self.lat_range
custom_prior = self._custom_log_prior
fit_sn = self.fit_sigma_n
qn, qw = self._quad_nodes, self._quad_weights
to_phys = self._to_physical # sampling theta → physical theta
u_dt = self.uniform_dt # Toeplitz fast path (None = disabled)
# Gappy-uniform lag tables (None on strictly-uniform or irregular
# grids); captured as compile-time constants in the closures.
band_tab = (self._band_lag_table()
if self.matrix_solver == "cholesky_banded" else None)
full_tab = (self._full_lag_table()
if self.matrix_solver != "cholesky_banded" else None)
# Envelope-specific R_Gamma function (JAX-traceable, captured in closure)
r_gamma_fn = self.spot_model.get_r_gamma_func()
# Latitude weight function (JAX-traceable, or None for static weights)
lat_wt_fn = self.spot_model.get_lat_weight_func()
# Number of kernel params (excludes sigma_n)
n_kernel = len(self.spot_model.param_keys)
# Edge-on fast path: pre-compute fixed |c_n|^2 as a JAX array
if isinstance(self.spot_model.visibility, EdgeOnVisibilityFunction):
eo_cn = jnp.array(self.spot_model.visibility.cn_squared(
0.0, self.n_harmonics))
else:
eo_cn = None
if self.matrix_solver == "cholesky_banded":
# Capture bandwidth as a Python int in the closure so that
# jax.lax.scan inside banded_cholesky is unrolled statically.
b = self.bandwidth
def _log_likelihood_raw(theta_arr):
return _gp_log_likelihood_banded(
to_phys(theta_arr), x, y, yerr, mean_val,
n_h, n_l, lr, fit_sn, b,
n_kernel=n_kernel, r_gamma_func=r_gamma_fn,
quad_nodes=qn, quad_weights=qw,
edgeon_cn_sq=eo_cn,
lat_weight_func=lat_wt_fn,
uniform_dt=u_dt,
band_lag_table=band_tab)
else:
def _log_likelihood_raw(theta_arr):
return _gp_log_likelihood(
to_phys(theta_arr), x, y, yerr, mean_val,
n_h, n_l, lr, fit_sn,
n_kernel=n_kernel, r_gamma_func=r_gamma_fn,
quad_nodes=qn, quad_weights=qw,
edgeon_cn_sq=eo_cn,
lat_weight_func=lat_wt_fn,
uniform_dt=u_dt,
lag_table=full_tab)
def _log_prior_raw(theta_arr):
return (custom_prior(theta_arr) if custom_prior is not None
else _default_log_prior(theta_arr, bounds))
def _log_posterior_raw(theta_arr):
return _log_likelihood_raw(theta_arr) + _log_prior_raw(theta_arr)
self.log_posterior = jax.jit(_log_posterior_raw)
self.value_and_grad_log_posterior = jax.jit(
jax.value_and_grad(_log_posterior_raw))
vag = self.value_and_grad_log_posterior
def neg_log_posterior(theta_arr):
return -self.log_posterior(theta_arr)
def grad_log_posterior(theta_arr):
return vag(theta_arr)[1]
def grad_neg_log_posterior(theta_arr):
return -vag(theta_arr)[1]
self.neg_log_posterior = neg_log_posterior
self.grad_log_posterior = grad_log_posterior
self.grad_neg_log_posterior = grad_neg_log_posterior
# Separate log-prior and log-likelihood for SMC tempering.
# Compiled lazily on first call (only run_smc uses them).
self.log_prior_fn = jax.jit(_log_prior_raw)
self.log_likelihood_fn = jax.jit(_log_likelihood_raw)
# =================================================================
# JAX compilation warmup
# =================================================================
[docs]
def build_jax(self, recompute=True):
"""
Pre-compile and warm up the solver's JAX JIT functions.
Exactly two XLA compilations are triggered: the value-only
``log_posterior`` (the log-density handed to samplers) and
``value_and_grad_log_posterior`` (used by ``fit_map`` and the
gradient accessors). The remaining public functions
(``neg_log_posterior``, ``grad_log_posterior``,
``grad_neg_log_posterior``) are Python wrappers around these two
and need no compilation of their own.
Call ``build_jax()`` once after constructing the solver to pay
the compilation cost upfront instead of inside the first fit or
MCMC step.
Returns
-------
self : GPSolver
Returns ``self`` so the call can be chained:
``gp = GPSolver(...).build_jax()``.
"""
import time
theta0 = self.theta0
logger.info("Compiling JAX functions (one-time cost)...")
t0 = time.time()
try:
jax.block_until_ready(self.log_posterior(theta0))
jax.block_until_ready(self.value_and_grad_log_posterior(theta0))
except Exception as e:
raise_cholesky_error(
e, theta=theta0, param_keys=self.param_keys,
bounds=self.bounds,
context="JIT compilation of log-posterior")
elapsed = time.time() - t0
logger.info("JAX GP solver compiled in %.2fs", elapsed)
if recompute:
t0 = time.time()
jax.block_until_ready(self.log_posterior(theta0))
jax.block_until_ready(self.value_and_grad_log_posterior(theta0))
logger.info("JAX GP solver recompute in %.2fs", time.time() - t0)
return self
# =================================================================
# Log-likelihood
# =================================================================
[docs]
def log_likelihood(self):
"""
Marginal log-likelihood of the data under the GP.
Returns
-------
logL : float
log p(y | X, theta)
"""
data_fit = self._resid @ self._alpha
if self._Lc is not None:
log_det = 2.0 * jnp.sum(jnp.log(self._Lc[0, :]))
else:
log_det = 2.0 * jnp.sum(jnp.log(jnp.diag(self._L)))
return float(
-0.5 * (data_fit + log_det + self.N * jnp.log(2 * jnp.pi)))
# =================================================================
# Prediction
# =================================================================
[docs]
def predict(self, xpred, return_cov=False):
"""
Predictive distribution at new input locations.
Parameters
----------
xpred : array_like, shape (M,)
Prediction times.
return_cov : bool
If True, return full predictive covariance.
Returns
-------
mu_pred : ndarray, shape (M,)
var_pred : ndarray, shape (M,) or (M, M)
"""
xpred = jnp.asarray(xpred, dtype=float)
M = len(xpred)
lag_cross = jnp.abs(xpred[:, None] - self.x[None, :])
Ks = self._eval_kernel(lag_cross)
mu_prior = self.mean_func(xpred)
if jnp.isscalar(mu_prior):
mu_prior = jnp.full(M, mu_prior)
mu_pred = mu_prior + Ks @ self._alpha
if self.matrix_solver == "cholesky_banded":
V = banded_solve_compact(self._Lc, Ks.T, self.bandwidth)
else:
V = jla.cho_solve((self._L, True), Ks.T)
if return_cov:
lag_pred = jnp.abs(xpred[:, None] - xpred[None, :])
Kss = self._eval_kernel(lag_pred)
cov_pred = Kss - Ks @ V
return np.asarray(mu_pred), np.asarray(cov_pred)
else:
# Only need diagonal: k(0) - sum_j V_ji * Ks_ij
k0 = self._eval_kernel(jnp.zeros(1))[0]
var_pred = k0 - jnp.einsum('ij,ji->i', Ks, V)
return np.asarray(mu_pred), np.asarray(var_pred)
[docs]
def sample_prior(self, xpred, n_samples=1, rng=None):
"""Draw samples from the GP prior."""
xpred = jnp.asarray(xpred, dtype=float)
if rng is None:
rng = np.random.default_rng()
lag = jnp.abs(xpred[:, None] - xpred[None, :])
K_prior = self._eval_kernel(lag)
K_prior = K_prior + 1e-10 * jnp.eye(len(xpred))
mu = self.mean_func(xpred)
if jnp.isscalar(mu):
mu = jnp.full(len(xpred), mu)
return rng.multivariate_normal(np.asarray(mu), np.asarray(K_prior),
size=n_samples)
[docs]
def sample_posterior(self, xpred, n_samples=1, rng=None):
"""Draw samples from the GP posterior."""
if rng is None:
rng = np.random.default_rng()
mu_pred, cov_pred = self.predict(xpred, return_cov=True)
cov_pred = cov_pred + 1e-10 * np.eye(len(xpred))
return rng.multivariate_normal(mu_pred, cov_pred, size=n_samples)
[docs]
def sample_lightcurves(self, theta=None, xpred=None, n_samples=5,
n_points=2000, source="prior", rng=None):
"""
Sample lightcurves from the GP prior or posterior.
Parameters
----------
theta : dict or array_like, optional
Kernel parameters. Accepts a physical dict with keys from
``param_keys``, a sampling-space dict with ``log_``-prefixed
keys, or a flat array matching ``param_keys``.
If None, uses the current internal hyperparameters.
xpred : array_like, optional
Times at which to evaluate the samples. If None, uses
``n_points`` evenly spaced times spanning the data baseline.
n_samples : int
Number of lightcurve samples to draw (default 5).
n_points : int
Number of prediction points when ``xpred`` is None (default
2000).
source : {'prior', 'posterior'}
Whether to sample from the GP prior or posterior (default
'prior').
rng : numpy.random.Generator, optional
Random number generator for reproducibility.
Returns
-------
xpred : ndarray, shape (M,)
Prediction times.
samples : ndarray, shape (n_samples, M)
Sampled lightcurves.
"""
if theta is not None:
phys = {}
for k, v in (theta.items() if isinstance(theta, dict)
else zip(self.spot_model.param_keys, theta)):
if isinstance(k, str) and k.startswith("log_"):
phys[k[4:]] = 10.0 ** float(v)
else:
phys[k] = float(v)
self.update_hparam(phys)
if xpred is None:
xpred = np.linspace(float(self.x[0]), float(self.x[-1]),
n_points)
else:
xpred = np.asarray(xpred)
if source == "prior":
samples = self.sample_prior(xpred, n_samples=n_samples, rng=rng)
elif source == "posterior":
samples = self.sample_posterior(xpred, n_samples=n_samples,
rng=rng)
else:
raise ValueError(f"source must be 'prior' or 'posterior', "
f"got {source!r}")
return xpred, samples
# =================================================================
# ACF and kernel evaluation
# =================================================================
[docs]
def compute_acf(self, tlags=None, n_bins=50, normalize=True):
"""
Compute the empirical autocorrelation function of the data.
Delegates to ``self.data.compute_acf()``.
Parameters
----------
tlags : array_like, optional
Bin edges for time lags [days]. If provided, ``n_bins`` is
inferred as ``len(tlags) - 1``. If None, ``n_bins`` linearly
spaced bins from 0 to half the baseline are used.
n_bins : int
Number of lag bins (used when ``tlags`` is None, default 50).
normalize : bool
If True (default), normalize so ACF(0) ~ 1.
Returns
-------
lag_centers : ndarray, shape (n_bins,)
Bin centers.
acf : ndarray, shape (n_bins,)
Empirical ACF at each bin center.
"""
if tlags is not None:
tlags = np.asarray(tlags, dtype=np.float64)
max_lag = float(tlags[-1])
n_bins = len(tlags) - 1
else:
max_lag = self.data.baseline / 2.0
lag_centers, acf = self.data.compute_acf(
n_bins=n_bins, max_lag=max_lag)
if not normalize:
y_full = getattr(self.data, '_y_full', self.data.y)
var = np.var(y_full)
acf = acf * var
return lag_centers, acf
[docs]
def compute_kernel(self, tlags):
"""
Evaluate the analytic kernel at the given time lags.
Parameters
----------
tlags : array_like, shape (M,)
Time lags [days].
Returns
-------
K : ndarray, shape (M,)
Kernel values at each lag.
"""
tlags = jnp.asarray(tlags, dtype=jnp.float64)
return np.asarray(self._eval_kernel(jnp.abs(tlags)))
[docs]
def get_theta(self):
"""
Return the current kernel hyperparameters as a dictionary.
Returns
-------
theta : dict
Keys and values for all kernel (and optionally noise)
hyperparameters, e.g. {"peq": 5.0, "kappa": 0.2, ...}.
"""
return {k: float(self.theta0[i]) for i, k in enumerate(self.param_keys)}
def _resolve_keys(self, keys):
"""
Validate a user-supplied list of free-parameter keys and return
the integer indices into self.param_keys plus the complementary
fixed indices and fixed values.
Returns
-------
free_idx : list of int
fixed_idx : list of int
fixed_vals : jnp.ndarray
"""
if keys is None:
return list(range(len(self.param_keys))), [], jnp.array([])
# Normalize user-supplied keys: accept both log_ and physical forms
# by mapping to whichever name is in self.param_keys.
pk_set = set(self.param_keys)
normalized = []
for k in keys:
if k in pk_set:
normalized.append(k)
elif k.startswith("log_") and k[4:] in pk_set:
normalized.append(k[4:])
elif f"log_{k}" in pk_set:
normalized.append(f"log_{k}")
else:
raise ValueError(
f"Unknown key '{k}'. Valid keys: {self.param_keys}")
keys = normalized
free_idx = [i for i, k in enumerate(self.param_keys) if k in keys]
fixed_idx = [i for i, k in enumerate(self.param_keys) if k not in keys]
fixed_vals = self.theta0[jnp.array(fixed_idx)] if fixed_idx else jnp.array([])
return free_idx, fixed_idx, fixed_vals
def _theta_from_free(self, free_vals, free_idx, fixed_idx, fixed_vals):
"""Reconstruct the full theta vector from free and fixed parts."""
n = len(free_idx) + len(fixed_idx)
theta = jnp.zeros(n)
theta = theta.at[jnp.array(free_idx)].set(free_vals)
if len(fixed_idx) > 0:
theta = theta.at[jnp.array(fixed_idx)].set(fixed_vals)
return theta
def _result_dict(self, theta_arr):
"""Convert a full theta array to a labeled dictionary."""
return {k: float(theta_arr[i]) for i, k in enumerate(self.param_keys)}
def _update_model_from_theta(self, theta_dict):
"""Update the SpotEvolutionModel components from a theta-style dict.
The keys in *theta_dict* must be a subset of
``self.spot_model.param_keys`` (physical names like ``tau_em``,
``lat_min``, ``sigma_k``). Each component (visibility, envelope,
latitude distribution) is reconstructed with updated values.
"""
model = self.spot_model
# Visibility
if model.visibility is not None:
for k in model.visibility.param_keys:
if k in theta_dict:
setattr(model.visibility, k, float(theta_dict[k]))
# Envelope: reconstruct from updated param_dict to handle
# varying internal storage (distributions vs plain floats)
if model.envelope is not None:
env_params = dict(model.envelope.param_dict)
env_params.update({k: float(theta_dict[k])
for k in env_params if k in theta_dict})
model.envelope = type(model.envelope)(**env_params)
# Latitude distribution: reconstruct with updated values
lat = model.latitude_distribution
if lat.param_dict:
lat_params = dict(lat.param_dict)
lat_params.update({k: float(theta_dict[k])
for k in lat_params if k in theta_dict})
# Convert from radians to degrees for constructor
init_args = {}
for k, v in lat_params.items():
if "lat" in k:
init_args[k.replace("lat_", "") + "_lat_deg"] = float(np.rad2deg(v))
else:
init_args[k] = v
model.latitude_distribution = type(lat)(**init_args)
if "sigma_k" in theta_dict:
model.sigma_k = float(theta_dict["sigma_k"])
[docs]
def update_hparam(self, hparam):
"""Update hyperparameters and rebuild kernel and covariance.
Accepts a SpotEvolutionModel, an hparam dict (legacy keys like
``lspot``), or a theta-style dict whose keys match
``self.spot_model.param_keys`` (e.g. ``tau_em``, ``lat_min``).
"""
if isinstance(hparam, SpotEvolutionModel):
self.spot_model = hparam
self.hparam = hparam.to_hparam()
elif set(hparam.keys()) <= set(self.spot_model.param_keys):
# Theta-style dict: update the existing model components directly
self._update_model_from_theta(hparam)
self.hparam = self.spot_model.to_hparam()
else:
_validate_hparam(hparam)
self.hparam = dict(hparam)
self.spot_model = SpotEvolutionModel.from_hparam(self.hparam)
self._build_kernel()
self.hparam = dict(self.kernel.hparam)
if self.matrix_solver == "cholesky_banded":
new_bw = self._compute_bandwidth()
if new_bw != self.bandwidth:
self.bandwidth = new_bw
self._band_lag_table_cache = None
self._check_bandwidth_efficiency()
self._build_covariance()
self._build_logposterior()
else:
self._build_covariance()
else:
self._build_covariance()
_phys_theta0 = dict(zip(self.spot_model.param_keys,
self.spot_model.theta0))
self.theta0 = jnp.array([
np.log10(float(_phys_theta0.get(self._log_param_map[k], 0.0)))
if k in self._log_param_map
else float(_phys_theta0.get(k, 0.0))
for k in self.param_keys
], dtype=jnp.float64)
# =================================================================
# HDF5 save / load
# =================================================================
[docs]
def save(self, path):
"""Save solver state to an HDF5 file.
Writes data, model configuration, bounds, and any completed
fit results (MAP, ACF, mass matrix). Can be called repeatedly
— each call overwrites only the groups whose data changed.
Parameters
----------
path : str
File path (should end in ``.h5`` or ``.hdf5``).
"""
from .io import save_gp
save_gp(path, self)
[docs]
@classmethod
def load(cls, path):
"""Reconstruct a GPSolver from an HDF5 file.
The returned solver has fit results restored but is NOT
JIT-compiled. Call ``.build_jax()`` before running fits
or sampling.
Parameters
----------
path : str
Path to an HDF5 file previously written by :meth:`save`.
Returns
-------
GPSolver
"""
from .io import load_gp
return load_gp(path)