ExoIris 0.23.2__py3-none-any.whl → 1.1.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.
exoiris/bin1d.py ADDED
@@ -0,0 +1,97 @@
1
+ # ExoIris: fast, flexible, and easy exoplanet transmission spectroscopy in Python.
2
+ # Copyright (C) 2026 Hannu Parviainen
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ from numba import njit
18
+ from numpy import zeros, isfinite, where, nan, sqrt, ndarray, sum
19
+
20
+ @njit
21
+ def bin1d(v, e, el, er, bins, estimate_errors: bool = False) -> tuple[ndarray, ndarray]:
22
+ """Bin 2D spectrophotometry data with its uncertainties into predefined bins along the first axis.
23
+
24
+ Parameters
25
+ ----------
26
+ v : ndarray
27
+ A 2D spectrophotometry array.
28
+ e : ndarray
29
+ A 2D array of uncertainties associated with the spectrophotometry in `v`, matching the shape of `v`.
30
+ el : ndarray
31
+ A 1D array containing the left edges of the integration ranges for each spectral data point.
32
+ er : ndarray
33
+ A 1D array containing the right edges of the integration ranges for each spectral data point.
34
+ bins : ndarray
35
+ A 2D array containing the edges of the bins. These should be sorted in ascending order.
36
+ estimate_errors: bool, optional.
37
+ Should the uncertainties be estimated from the data? Default value is False.
38
+
39
+ Returns
40
+ -------
41
+ tuple of ndarrays
42
+ A tuple containing two 2D arrays:
43
+ - The first array (`bv`) contains the binned values of the transmission spectrum.
44
+ - The second array (`be`) contains the binned uncertainties.
45
+ """
46
+ nbins = len(bins)
47
+ ndata = v.shape[0]
48
+ bv = zeros((nbins, v.shape[1]))
49
+ be = zeros((nbins, v.shape[1]))
50
+ nonfin_weights = isfinite(v).astype('d')
51
+ v = where(nonfin_weights, v, 0.0)
52
+ e2 = where(nonfin_weights, e**2, 0.0)
53
+ weights = zeros(v.shape)
54
+ npt = zeros(v.shape[1])
55
+
56
+ i = 0
57
+ for ibin in range(nbins):
58
+ npt[:] = 0
59
+ bel, ber = bins[ibin]
60
+ for i in range(i, ndata - 1):
61
+ if el[i + 1] > bel:
62
+ break
63
+ il = i
64
+ if er[i] > ber:
65
+ weights[i, :] = ber - bel
66
+ npt += 1
67
+ else:
68
+ weights[i, :] = er[i] - max(el[i], bel)
69
+ npt += 1
70
+ for i in range(i + 1, ndata):
71
+ if er[i] < ber:
72
+ weights[i, :] = er[i] - el[i]
73
+ npt += 1
74
+
75
+ else:
76
+ weights[i, :] = ber - el[i]
77
+ npt += 1
78
+ break
79
+ ir = i
80
+
81
+ weights[il:ir+1, :] *= nonfin_weights[il:ir+1, :]
82
+ weights[il:ir+1, :] /= weights[il:ir+1, :].sum(0)
83
+ npt += (nonfin_weights[il:ir+1, :]-1.0).sum(0)
84
+ ws = sum(weights[il:ir+1, :], 0)
85
+ ws2 = sum(weights[il:ir+1, :]**2, 0)
86
+ ws = where(ws > 0, ws, nan)
87
+ bv[ibin] = vmean = sum(weights[il:ir+1, :] * v[il:ir+1,:], 0) / ws
88
+
89
+ if estimate_errors:
90
+ var_sum = sum(weights[il:ir+1, :] * (v[il:ir+1, :] - vmean)**2, 0)
91
+ denominator = ws - ws2 / ws
92
+ sample_variance = var_sum / denominator
93
+ be[ibin, :] = where((npt > 1) & (ws**2 > ws2),
94
+ sqrt(sample_variance * ws2 / (ws * ws)), nan)
95
+ else:
96
+ be[ibin] = sqrt(sum(weights[il:ir+1, :]**2 * e2[il:ir+1,:], 0)) / ws
97
+ return bv, be
exoiris/bin2d.py ADDED
@@ -0,0 +1,218 @@
1
+ # ExoIris: fast, flexible, and easy exoplanet transmission spectroscopy in Python.
2
+ # Copyright (C) 2026 Hannu Parviainen
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ from numba import njit
18
+ from numpy import zeros, isfinite, where, nan, sqrt, ndarray, int32, float64
19
+
20
+ @njit
21
+ def bin2d(v: ndarray, e: ndarray,
22
+ wl_l: ndarray, wl_r: ndarray,
23
+ tm_l: ndarray, tm_r: ndarray,
24
+ wl_bins: ndarray, tm_bins: ndarray,
25
+ estimate_errors: bool = False) -> tuple[ndarray, ndarray, ndarray]:
26
+ """Bin 2D spectrophotometry data in both wavelength and time dimensions.
27
+
28
+ Parameters
29
+ ----------
30
+ v : ndarray
31
+ A 2D spectrophotometry array with shape (n_wavelength, n_exposure).
32
+ e : ndarray
33
+ A 2D array of uncertainties matching the shape of `v`.
34
+ wl_l : ndarray
35
+ A 1D array of left wavelength edges for each spectral data point.
36
+ wl_r : ndarray
37
+ A 1D array of right wavelength edges for each spectral data point.
38
+ tm_l : ndarray
39
+ A 1D array of left time edges for each exposure.
40
+ tm_r : ndarray
41
+ A 1D array of right time edges for each exposure.
42
+ wl_bins : ndarray
43
+ A 2D array (n_wl_bins, 2) of wavelength bin edges [left, right].
44
+ tm_bins : ndarray
45
+ A 2D array (n_tm_bins, 2) of time bin edges [left, right].
46
+ estimate_errors : bool, optional
47
+ If True, estimate uncertainties from data scatter. Default is False.
48
+
49
+ Returns
50
+ -------
51
+ tuple of ndarrays
52
+ - bv: Binned values with shape (n_wl_bins, n_tm_bins).
53
+ - be: Binned uncertainties with shape (n_wl_bins, n_tm_bins).
54
+ - bn: Number of original finite pixels in each bin with shape (n_wl_bins, n_tm_bins).
55
+ """
56
+ n_wl_bins = len(wl_bins)
57
+ n_tm_bins = len(tm_bins)
58
+ n_wl = v.shape[0]
59
+ n_tm = v.shape[1]
60
+
61
+ bv = zeros((n_wl_bins, n_tm_bins))
62
+ be = zeros((n_wl_bins, n_tm_bins))
63
+ bn = zeros((n_wl_bins, n_tm_bins), dtype=int32)
64
+
65
+ # Pre-compute masks and cleaned data
66
+ nonfin_mask = isfinite(v)
67
+ v_clean = where(nonfin_mask, v, 0.0)
68
+ e2_clean = where(nonfin_mask, e**2, 0.0)
69
+ nonfin_weights = nonfin_mask.astype(float64)
70
+
71
+ # Pre-compute time bin indices and weights
72
+ tm_il_arr = zeros(n_tm_bins, dtype=int32)
73
+ tm_ir_arr = zeros(n_tm_bins, dtype=int32)
74
+ tm_weights_all = zeros((n_tm_bins, n_tm))
75
+
76
+ for itm_bin in range(n_tm_bins):
77
+ tm_bel, tm_ber = tm_bins[itm_bin]
78
+
79
+ # Find first time index
80
+ tm_il = 0
81
+ for j in range(n_tm - 1):
82
+ if tm_l[j + 1] > tm_bel:
83
+ tm_il = j
84
+ break
85
+ else:
86
+ tm_il = n_tm - 1
87
+
88
+ tm_il_arr[itm_bin] = tm_il
89
+
90
+ # Calculate time weights with ROBUST overlap logic
91
+ # Overlap = max(0, min(pixel_right, bin_right) - max(pixel_left, bin_left))
92
+ # We perform the loop starting from the found index until pixels no longer overlap
93
+
94
+ tm_idx = tm_il
95
+ # Iterate until pixel starts after bin ends or we run out of pixels
96
+ while tm_idx < n_tm:
97
+ # Optimization: If pixel starts after bin ends, stop.
98
+ if tm_l[tm_idx] >= tm_ber:
99
+ # But careful: tm_il search might land us on a pixel that starts after bin
100
+ # if the bin is in a gap. If so, we just want to record the index and break.
101
+ break
102
+
103
+ # Calculate overlap
104
+ # min(pixel_r, bin_r)
105
+ r_bound = tm_r[tm_idx] if tm_r[tm_idx] < tm_ber else tm_ber
106
+ # max(pixel_l, bin_l)
107
+ l_bound = tm_l[tm_idx] if tm_l[tm_idx] > tm_bel else tm_bel
108
+
109
+ if r_bound > l_bound:
110
+ tm_weights_all[itm_bin, tm_idx] = r_bound - l_bound
111
+
112
+ # If this pixel goes past the bin, we are done with this bin
113
+ if tm_r[tm_idx] >= tm_ber:
114
+ break
115
+
116
+ tm_idx += 1
117
+
118
+ tm_ir_arr[itm_bin] = min(tm_idx, n_tm - 1)
119
+
120
+ # Allocation moved outside loop
121
+ wl_weights = zeros(n_wl)
122
+
123
+ # Process wavelength bins
124
+ wl_start = 0
125
+ for iwl_bin in range(n_wl_bins):
126
+ wl_bel, wl_ber = wl_bins[iwl_bin]
127
+
128
+ # Reset weights for this iteration (faster than re-allocating)
129
+ # We only need to zero out the range we are about to use or used previously?
130
+ # Actually, since we calculate specific indices [wl_il, wl_ir],
131
+ # we can just zero them out at the end of the loop, or re-zero the whole array.
132
+ # Given n_wl is usually small (spectroscopy), zeroing whole array is fine.
133
+ wl_weights[:] = 0.0
134
+
135
+ # Find first wavelength index
136
+ wl_il = wl_start
137
+ for wl_il in range(wl_start, n_wl - 1):
138
+ if wl_l[wl_il + 1] > wl_bel:
139
+ break
140
+
141
+ # Calculate wavelength weights (Same robust logic as time)
142
+ wl_idx = wl_il
143
+ while wl_idx < n_wl:
144
+ if wl_l[wl_idx] >= wl_ber:
145
+ break
146
+
147
+ r_bound = wl_r[wl_idx] if wl_r[wl_idx] < wl_ber else wl_ber
148
+ l_bound = wl_l[wl_idx] if wl_l[wl_idx] > wl_bel else wl_bel
149
+
150
+ if r_bound > l_bound:
151
+ wl_weights[wl_idx] = r_bound - l_bound
152
+
153
+ if wl_r[wl_idx] >= wl_ber:
154
+ break
155
+ wl_idx += 1
156
+
157
+ wl_ir = min(wl_idx, n_wl - 1)
158
+
159
+ # Optimization for next bin search
160
+ wl_start = wl_il
161
+
162
+ # Process all time bins for this wavelength bin
163
+ for itm_bin in range(n_tm_bins):
164
+ tm_il = tm_il_arr[itm_bin]
165
+ tm_ir = tm_ir_arr[itm_bin]
166
+
167
+ total_weight = 0.0
168
+ sum_w2 = 0.0
169
+ weighted_sum = 0.0
170
+ weighted_e2_sum = 0.0
171
+ npt = 0
172
+
173
+ for i in range(wl_il, wl_ir + 1):
174
+ w_wl = wl_weights[i]
175
+ if w_wl <= 0: continue # Skip if no overlap
176
+
177
+ for j in range(tm_il, tm_ir + 1):
178
+ # Combine weights
179
+ w = w_wl * tm_weights_all[itm_bin, j] * nonfin_weights[i, j]
180
+
181
+ if w > 0:
182
+ total_weight += w
183
+ sum_w2 += w * w
184
+ weighted_sum += w * v_clean[i, j]
185
+ weighted_e2_sum += w * w * e2_clean[i, j]
186
+ npt += 1
187
+
188
+ bn[iwl_bin, itm_bin] = npt
189
+
190
+ if total_weight > 0:
191
+ bv[iwl_bin, itm_bin] = vmean = weighted_sum / total_weight
192
+
193
+ if estimate_errors:
194
+ # Need at least 2 points (or effective degrees of freedom) to estimate variance
195
+ if npt > 1 and total_weight**2 > sum_w2:
196
+ var_sum = 0.0
197
+ for i in range(wl_il, wl_ir + 1):
198
+ w_wl = wl_weights[i]
199
+ if w_wl <= 0: continue
200
+
201
+ for j in range(tm_il, tm_ir + 1):
202
+ w = w_wl * tm_weights_all[itm_bin, j] * nonfin_weights[i, j]
203
+ if w > 0:
204
+ var_sum += w * (v_clean[i, j] - vmean) ** 2
205
+
206
+ denominator = total_weight - (sum_w2 / total_weight)
207
+ sample_variance = var_sum / denominator
208
+ be[iwl_bin, itm_bin] = sqrt(sample_variance * (sum_w2 / (total_weight * total_weight)))
209
+ else:
210
+ be[iwl_bin, itm_bin] = nan
211
+ else:
212
+ # Propagate input errors: SE = sqrt(sum(w² * σ²)) / sum(w)
213
+ be[iwl_bin, itm_bin] = sqrt(weighted_e2_sum) / total_weight
214
+ else:
215
+ bv[iwl_bin, itm_bin] = nan
216
+ be[iwl_bin, itm_bin] = nan
217
+
218
+ return bv, be, bn
exoiris/exoiris.py CHANGED
@@ -43,7 +43,7 @@ from uncertainties import UFloat
43
43
 
44
44
  from .ldtkld import LDTkLD
45
45
  from .tsdata import TSData, TSDataGroup
46
- from .tslpf import TSLPF
46
+ from .tslpf import TSLPF, interpolators
47
47
  from .wlpf import WhiteLPF
48
48
  from .loglikelihood import LogLikelihood
49
49
 
@@ -88,7 +88,14 @@ def load_model(fname: Path | str, name: str | None = None):
88
88
  try:
89
89
  ip = hdr['INTERP']
90
90
  except KeyError:
91
- ip = 'bspline'
91
+ ip = 'linear'
92
+
93
+ # Read the interpolation model.
94
+ # =============================
95
+ try:
96
+ ip_ld = hdr['INTERP_LD']
97
+ except KeyError:
98
+ ip_ld = 'bspline-quadratic'
92
99
 
93
100
  # Read the noise model.
94
101
  # =====================
@@ -100,6 +107,7 @@ def load_model(fname: Path | str, name: str | None = None):
100
107
  # Setup the analysis.
101
108
  # ===================
102
109
  a = ExoIris(name or hdr['NAME'], ldmodel=ldm, data=data, noise_model=noise_model, interpolation=ip)
110
+ a.set_limb_darkening_interpolator(ip_ld)
103
111
  a.set_radius_ratio_knots(hdul['K_KNOTS'].data.astype('d'))
104
112
  a.set_limb_darkening_knots(hdul['LD_KNOTS'].data.astype('d'))
105
113
 
@@ -162,7 +170,7 @@ class ExoIris:
162
170
  """
163
171
 
164
172
  def __init__(self, name: str, ldmodel, data: TSDataGroup | TSData, nk: int = 50, nldc: int = 10, nthreads: int = 1,
165
- tmpars: dict | None = None, noise_model: Literal["white", "fixed_gp", "free_gp"] = 'white',
173
+ tmpars: dict | None = None, noise_model: Literal["white_profiled", "white_marginalized", "fixed_gp", "free_gp"] = 'white_profiled',
166
174
  interpolation: Literal['nearest', 'linear', 'pchip', 'makima', 'bspline', 'bspline-quadratic', 'bspline-cubic'] = 'linear'):
167
175
  """
168
176
  Parameters
@@ -263,15 +271,15 @@ class ExoIris:
263
271
  data = TSDataGroup([data]) if isinstance(data, TSData) else data
264
272
  self._tsa.set_data(data)
265
273
 
266
- def set_prior(self, parameter: Literal['radius ratios', 'baselines', 'wn multipliers'] | str,
274
+ def set_prior(self, parameter: Literal['radius ratios', 'offsets', 'wn multipliers'] | str,
267
275
  prior: str | Any, *nargs) -> None:
268
276
  """Set a prior on a model parameter.
269
277
 
270
278
  Parameters
271
279
  ----------
272
280
  parameter
273
- The name of the parameter to set a prior for. Can also be 'radius ratios', 'baselines', or 'wn multipliers'
274
- to set identical priors on all the radius ratios, baselines, or white noise multipliers.
281
+ The name of the parameter to set a prior for. Can also be 'radius ratios', 'offsets', or 'wn multipliers'
282
+ to set identical priors on all the radius ratios, offsets, or white noise multipliers.
275
283
 
276
284
  prior
277
285
  The prior distribution for the parameter. This can be "NP" for a normal prior, "UP" for a
@@ -287,6 +295,9 @@ class ExoIris:
287
295
  elif parameter == 'wn multipliers':
288
296
  for par in self.ps[self._tsa._sl_wnm]:
289
297
  self.set_prior(par.name, prior, *nargs)
298
+ elif parameter == 'offsets':
299
+ for par in self.ps[self._tsa._sl_bias]:
300
+ self.set_prior(par.name, prior, *nargs)
290
301
  else:
291
302
  self._tsa.set_prior(parameter, prior, *nargs)
292
303
 
@@ -509,6 +520,12 @@ class ExoIris:
509
520
  else:
510
521
  return self._wa.std_errors
511
522
 
523
+ def set_radius_ratio_interpolator(self, interpolator: str) -> None:
524
+ """Set the interpolator for the radius ratio (k) model."""
525
+ if interpolator not in interpolators.keys():
526
+ raise ValueError(f"Interpolator {interpolator} not recognized.")
527
+ self._tsa.set_k_interpolator(interpolator)
528
+
512
529
  def add_radius_ratio_knots(self, knot_wavelengths: Sequence) -> None:
513
530
  """Add radius ratio (k) knots.
514
531
 
@@ -558,6 +575,12 @@ class ExoIris:
558
575
  nk = nk[(nk >= wlmin) & (nk <= wlmax)]
559
576
  self.set_radius_ratio_knots(r_[ck[ck < nk[0]], nk, ck[ck > nk[-1]]])
560
577
 
578
+ def set_limb_darkening_interpolator(self, interpolator: str) -> None:
579
+ """Set the interpolator for the limb darkening model."""
580
+ if interpolator not in interpolators.keys():
581
+ raise ValueError(f"Interpolator {interpolator} not recognized.")
582
+ self._tsa.set_ld_interpolator(interpolator)
583
+
561
584
  def add_limb_darkening_knots(self, knot_wavelengths: Sequence) -> None:
562
585
  """Add limb darkening knots.
563
586
 
@@ -1283,6 +1306,7 @@ class ExoIris:
1283
1306
  pri.header['t14'] = self.transit_duration
1284
1307
  pri.header['ndgroups'] = self.data.size
1285
1308
  pri.header['interp'] = self._tsa.interpolation
1309
+ pri.header['interp_ld'] = self._tsa.ld_interpolation
1286
1310
  pri.header['noise'] = self._tsa.noise_model
1287
1311
 
1288
1312
  if self._tsa.free_k_knot_ids is None:
@@ -1426,42 +1450,6 @@ class ExoIris:
1426
1450
  """
1427
1451
  return self._tsa.create_initial_population(n, source, add_noise)
1428
1452
 
1429
- def add_noise_to_solution(self, result: str = 'fit') -> None:
1430
- """Add noise to the global optimization result or MCMC parameter posteriors.
1431
-
1432
- Add noise to the global optimization result or MCMC parameter posteriors. You may want to do this if you
1433
- create a new analysis from another one, for example, by adding radius ratio knots or changing the intrinsic
1434
- data resolution.
1435
-
1436
- Parameters
1437
- ----------
1438
- result
1439
- Determines which result to add noise to. Default is 'fit'.
1440
-
1441
- Raises
1442
- ------
1443
- ValueError
1444
- If the 'result' argument is not 'fit' or 'mcmc'.
1445
- """
1446
- if result == 'fit':
1447
- pvp = self._tsa._de_population[:, :].copy()
1448
- elif result == 'mcmc':
1449
- pvp = self._tsa._mc_chains[:, -1, :].copy()
1450
- else:
1451
- raise ValueError("The 'result' argument must be either 'fit' or 'mcmc'")
1452
-
1453
- npv = pvp.shape[0]
1454
- pvp[:, 0] += normal(0, 0.005, size=npv)
1455
- pvp[:, 1] += normal(0, 0.001, size=npv)
1456
- pvp[:, 3] += normal(0, 0.005, size=npv)
1457
- pvp[:, self._tsa._sl_rratios] += normal(0, 1, pvp[:, self._tsa._sl_rratios].shape) * 0.002 * pvp[:, self._tsa._sl_rratios]
1458
- pvp[:, self._tsa._sl_ld] += normal(0, 1, pvp[:, self._tsa._sl_ld].shape) * 0.002 * pvp[:, self._tsa._sl_ld]
1459
-
1460
- if result == 'fit':
1461
- self._tsa._de_population[:, :] = pvp
1462
- else:
1463
- pvp = self._tsa._mc_chains[:, -1, :] = pvp
1464
-
1465
1453
  def optimize_gp_hyperparameters(self,
1466
1454
  log10_sigma_bounds: float | tuple[float, float] | None = None,
1467
1455
  log10_rho_bounds: float | tuple[float, float] = (-5, 0),
@@ -0,0 +1,172 @@
1
+ # ExoIris: fast, flexible, and easy exoplanet transmission spectroscopy in Python.
2
+ # Copyright (C) 2026 Hannu Parviainen
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify
5
+ # it under the terms of the GNU General Public License as published by
6
+ # the Free Software Foundation, either version 3 of the License, or
7
+ # (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
16
+
17
+ import numpy as np
18
+ from numba import njit
19
+
20
+ @njit
21
+ def _chol_solve(l: np.ndarray, b: np.ndarray) -> np.ndarray:
22
+ """Solve L L^T x = b given lower-triangular L."""
23
+ y = np.linalg.solve(l, b)
24
+ return np.linalg.solve(l.T, y)
25
+
26
+
27
+ @njit
28
+ def marginalized_loglike_mbl1d(
29
+ obs: np.ndarray,
30
+ mod: np.ndarray,
31
+ covs: np.ndarray,
32
+ sigma: np.ndarray,
33
+ tau: float = 1e6,
34
+ drop_constant: bool = False,
35
+ ) -> float:
36
+ """Compute the analytically marginalized log-likelihood for a multiplicative baseline model.
37
+
38
+ JIT-compiled implementation of the collapsed (marginalized) log-likelihood for a
39
+ model where systematic trends multiply the physical signal:
40
+
41
+ obs = mod(θ) · covs·a + ε
42
+
43
+ where ε ~ N(0, diag(σ²)) and a ~ N(0, Λ). The baseline coefficients a are
44
+ integrated out analytically, yielding a likelihood that depends only on the
45
+ physical model mod(θ).
46
+
47
+ This formulation is appropriate when the out-of-transit baseline level must be
48
+ estimated from the data. The design matrix `covs` should include a constant
49
+ column (ones) to capture the baseline flux level, with additional columns for
50
+ systematic trends.
51
+
52
+ The computation avoids explicit matrix inversion by exploiting the Woodbury
53
+ identity and matrix determinant lemma, working with k×k matrices rather than
54
+ n×n matrices. This makes the function efficient when k ≪ n.
55
+
56
+ Parameters
57
+ ----------
58
+ obs : ndarray of shape (n,), dtype float64
59
+ Observed flux values. Must be contiguous float64 array.
60
+ mod : ndarray of shape (n,), dtype float64
61
+ Physical transit model evaluated at the current parameters θ, normalized
62
+ such that the out-of-transit level is unity. Must be contiguous float64.
63
+ covs : ndarray of shape (n, k), dtype float64
64
+ Design matrix for the multiplicative baseline. Should include a constant
65
+ column (ones) as the first column to capture the baseline flux level.
66
+ Additional columns represent systematic trends (e.g., airmass, detector
67
+ position, PSF width). Trend columns should typically be mean-centered.
68
+ Must be contiguous float64 with C ordering.
69
+ sigma : ndarray of shape (n,), dtype float64
70
+ Per-observation measurement uncertainties (standard deviations). All
71
+ values must be strictly positive. Must be contiguous float64.
72
+ tau : float, default 1e6
73
+ Prior standard deviation for all baseline coefficients (Λ = τ²I).
74
+ drop_constant : bool, default False
75
+ If True, omit terms constant in θ (log|Σ| and n·log(2π)). Use this for
76
+ MCMC sampling over θ when σ is fixed, as these terms only shift the
77
+ log-posterior by a constant without affecting sampling.
78
+
79
+ Returns
80
+ -------
81
+ float
82
+ The marginalized log-likelihood value. If drop_constant is True, this
83
+ omits θ-independent normalization terms.
84
+
85
+ Raises
86
+ ------
87
+ numpy.linalg.LinAlgError
88
+ If Lambda or the internal matrix K is not positive definite (Cholesky
89
+ decomposition fails). No other validation is performed.
90
+
91
+ Notes
92
+ -----
93
+ The marginalized likelihood is obtained by integrating over the baseline
94
+ coefficients:
95
+
96
+ L(θ) = ∫ p(obs | θ, a) p(a) da
97
+
98
+ Defining Φ = diag(mod)·covs, the marginal distribution of obs is:
99
+
100
+ obs ~ N(0, C) where C = Σ + τ²ΦΦᵀ
101
+
102
+ Rather than inverting the n×n matrix C directly, the implementation uses:
103
+
104
+ C⁻¹ = W − τ²W·Φ·K⁻¹·Φᵀ·W (Woodbury identity)
105
+ |C| = |Σ|·|K| (matrix determinant lemma)
106
+
107
+ where W = Σ⁻¹ = diag(1/σ²) and K = I + τ²ΦᵀWΦ is a k×k matrix.
108
+
109
+ The log-likelihood is:
110
+
111
+ log L = −½ [obsᵀC⁻¹obs + log|C| + n·log(2π)]
112
+ = −½ [(obsᵀWobs − τ²cᵀK⁻¹c) + log|Σ| + log|K| + n·log(2π)]
113
+
114
+ where c = ΦᵀW·obs.
115
+
116
+ With the isotropic prior Λ = τ²I, the Cholesky factorization of Λ is trivially
117
+ L_Λ = τI, eliminating one matrix decomposition compared to the general case.
118
+ All operations involve only k×k matrices, giving O(nk² + k³) complexity
119
+ rather than O(n³).
120
+
121
+ In the limit τ → ∞, the marginalized likelihood approaches the profile
122
+ likelihood with a at its maximum likelihood estimate (ordinary least squares).
123
+ Numerical stability requires finite τ; values around 10⁶ are effectively
124
+ uninformative for normalized flux data while maintaining well-conditioned K.
125
+ """
126
+ n = obs.shape[0]
127
+ ncov = covs.shape[1]
128
+ tau2 = tau * tau
129
+ phi = mod[:, None] * covs
130
+ w = 1.0 / (sigma * sigma)
131
+ wphi = w[:, None] * phi
132
+ k = np.eye(ncov) + tau2 * (phi.T @ wphi)
133
+ c = phi.T @ (w * obs)
134
+ lk = np.linalg.cholesky(k)
135
+ obswobs = np.dot(w * obs, obs)
136
+ kinvc = _chol_solve(lk, c)
137
+ quad = obswobs - tau2 * np.dot(c, kinvc)
138
+ logdetk = 2.0 * np.sum(np.log(np.diag(lk)))
139
+ if drop_constant:
140
+ return -0.5 * (quad + logdetk)
141
+ logdetsigma = np.sum(np.log(sigma * sigma))
142
+ return -0.5 * (quad + logdetsigma + logdetk + n * np.log(2.0 * np.pi))
143
+
144
+ @njit
145
+ def marginalized_loglike_mbl2d(
146
+ obs: np.ndarray,
147
+ mod: np.ndarray,
148
+ err: np.ndarray,
149
+ covs: np.ndarray,
150
+ mask: np.ndarray,
151
+ tau: float = 1e6,
152
+ ) -> float:
153
+ nwl = obs.shape[0]
154
+ ncov = covs.shape[1]
155
+ tau2 = tau * tau
156
+ w = 1.0 / (err * err)
157
+
158
+ lnlike = 0.0
159
+ for i in range(nwl):
160
+ m = mask[i]
161
+ phi = mod[i, m, None] * covs[m]
162
+ wphi = w[i, m, None] * phi
163
+ k = np.eye(ncov) + tau2 * (phi.T @ wphi)
164
+ c = phi.T @ (w[i, m] * obs[i, m])
165
+ lk = np.linalg.cholesky(k)
166
+ obswobs = np.dot(w[i, m] * obs[i, m], obs[i, m])
167
+ kinvc = _chol_solve(lk, c)
168
+ quad = obswobs - tau2 * np.dot(c, kinvc)
169
+ logdetk = 2.0 * np.sum(np.log(np.diag(lk)))
170
+ logdetsigma = np.sum(np.log(err[i, m] * err[i, m]))
171
+ lnlike += -0.5 * (quad + logdetsigma + logdetk + mask[i].sum() * np.log(2.0 * np.pi))
172
+ return lnlike