mcarma 0.1.2__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.
mcarma/__init__.py ADDED
@@ -0,0 +1,54 @@
1
+ """mcarma — multivariate CARMA modeling of irregularly sampled light curves.
2
+
3
+ Penalized maximum-likelihood fitting of multiband continuous-time ARMA models
4
+ to astronomical light curves, plus the closed-form multivariate PSD
5
+ and a data simulator. Builds on single-band CARMA (Kelly et al. 2014) and the
6
+ multiband damped random walk (Hu et al. 2020).
7
+
8
+ Quick start
9
+ -----------
10
+ from mcarma import ObservationData, fit, compute_aicc, mcarma_psd
11
+
12
+ data = ObservationData(t, y, band, R, d=5) # scalar (t, band) observations
13
+ res = fit(data, p=2, q=1) # MLE + optional penalties
14
+ aicc = compute_aicc(res["loglik_pure"], k, data.n)
15
+ psd = mcarma_psd(res["F"], res["G"], res["H"], res["Sigma"], freqs)
16
+
17
+ See `examples/sdss_stripe82_demo.py` for a full worked example on real SDSS
18
+ Stripe 82 data.
19
+
20
+ Note on JAX: the analytic-gradient objective (`mcarma.jax_loglik`), the JAX
21
+ transition helpers (`mcarma.jax_transitions`), the RTS smoother
22
+ (`mcarma.smoother`, which uses them), and the smoother-based reconstruction
23
+ helpers (`mcarma.rts`) import JAX at module load. They are **not** imported here,
24
+ so `import mcarma` stays JAX-free and safe on login nodes; import those
25
+ submodules explicitly on a compute node when you need them.
26
+ """
27
+ import logging as _logging
28
+
29
+ # The library logs progress through the standard logging module and attaches no
30
+ # handler of its own, so an application that has not configured logging sees
31
+ # nothing. The research drivers in fits/ call logging.basicConfig, which is what
32
+ # puts these lines back in the SLURM logs.
33
+ _logging.getLogger(__name__).addHandler(_logging.NullHandler())
34
+
35
+ from .observation import ObservationData
36
+ from .fit import fit
37
+ from .model_utils import compute_aicc
38
+ from .simulate import simulate, mcarma_psd
39
+ from .reporting import sigma_to_var_corr
40
+
41
+ # The one place the version is written. pyproject.toml derives the
42
+ # distribution version from this attribute, so the two cannot drift; they
43
+ # already had, with this file saying 0.1 while everything else said 0.1.1.
44
+ __version__ = "0.1.2"
45
+
46
+ __all__ = [
47
+ "ObservationData",
48
+ "fit",
49
+ "compute_aicc",
50
+ "simulate",
51
+ "mcarma_psd",
52
+ "sigma_to_var_corr",
53
+ "__version__",
54
+ ]
mcarma/bootstrap.py ADDED
@@ -0,0 +1,178 @@
1
+ """Per-object parametric bootstrap: SE for every parameter + finite-sample
2
+ bias-correction of the MA coefficient b.
3
+
4
+ See docs/METHODS.Rmd sec 8-9 (bias-correction b_bc = 2*b_hat - boot_mean; subsampling SE).
5
+
6
+ Why this exists. The MLE of a moving-average coefficient carries a known
7
+ O(1/n) downward finite-sample bias (Cox & Snell 1968 for the general MLE
8
+ expansion; Cordeiro & Klein 1994 for ARMA specifically; the classic MA(1)
9
+ "pile-up" toward the non-invertibility boundary). The simulation study measured
10
+ it at ~22% (median -0.245 in log b) and a lambda-probe confirmed it is
11
+ estimator-intrinsic, NOT our prior (it persists, and grows, as the MA band prior
12
+ lambda -> 0). The SEs are well-calibrated (bootstrap SE ~ Hessian SE); only the
13
+ point estimate is offset.
14
+
15
+ Crucially the correction is estimated PER OBJECT, not applied as a blanket shift
16
+ from the study's (narrow) parameter region: for each object we simulate from its
17
+ OWN fit on its OWN cadence, refit with the same per-band warm start, and take the
18
+ empirical mean/SD over refits. bias-corrected theta = 2*theta_fit - boot_mean.
19
+
20
+ Mirrors the validated real-data bootstrap in fits/process_quasars/validate_object.py
21
+ (object 1035792). Depends only on the mcarma package (warm start, simulate, fit,
22
+ per-band centering), so it lives here in the core library.
23
+ """
24
+ import logging
25
+ import warnings
26
+ import numpy as np
27
+
28
+ from mcarma.preprocess import center_bands
29
+ from mcarma.model_utils import build_perband_warm_theta
30
+ from mcarma.observation import ObservationData
31
+ from mcarma.simulate import simulate
32
+ from mcarma.fit import fit
33
+
34
+ _log = logging.getLogger(__name__)
35
+
36
+
37
+ def parametric_bootstrap_se(theta_fit, data, p, q, prior=None, n_boot=24,
38
+ n_restarts_perband=4, maxiter=300,
39
+ use_jax_grad=False, seed0=7000, verbose=False):
40
+ """Parametric bootstrap of the fit at (p, q) on `data`'s exact design.
41
+
42
+ Simulate `n_boot` CARMA(p,q) light curves from `theta_fit` on the same
43
+ times / bands / per-obs noise as `data`, refit each at (p,q) with a per-band
44
+ warm start (no slopes: the simulated process is trend-free), and summarise
45
+ the refit theta vectors.
46
+
47
+ Returns dict(theta_se, boot_mean, theta_bc, boot_n, boot_thetas) or None if
48
+ fewer than 2 reps succeeded. theta_bc = 2*theta_fit - boot_mean is the
49
+ bias-corrected estimate; theta_se is the per-parameter empirical SD (the
50
+ honest, Hessian-free SE). For q >= 1 the MA coeff is theta[p*d].
51
+ """
52
+ theta_fit = np.asarray(theta_fit, dtype=float)
53
+ d = data.d
54
+ t = data.t_obs
55
+ band = data.band
56
+ R = np.array([data.R_list[k][0, 0] for k in range(data.n)])
57
+ tmpl = ObservationData(t, np.zeros(data.n), band, R, d=d)
58
+ prior = prior or {}
59
+
60
+ thetas = []
61
+ for m in range(n_boot):
62
+ rng = np.random.default_rng(seed0 + m)
63
+ try:
64
+ sim = simulate(theta_fit, tmpl, p=p, q=q,
65
+ initial_state="stationary",
66
+ seed=int(rng.integers(1 << 31)))
67
+ yb = center_bands(t, sim.data.y_obs, band)
68
+ db = ObservationData(t, yb, band, R, d=d)
69
+ warm = build_perband_warm_theta(
70
+ t, yb, band, R, d=d, p=p, q=q,
71
+ n_restarts=n_restarts_perband,
72
+ seed=int(rng.integers(1 << 31)))
73
+ with warnings.catch_warnings():
74
+ warnings.simplefilter("ignore")
75
+ r = fit(db, p, q, n_restarts=1, warm_theta=warm,
76
+ maxiter=maxiter, use_jax_grad=use_jax_grad, **prior)
77
+ th = np.asarray(r["theta"], dtype=float)
78
+ if th.shape != theta_fit.shape:
79
+ continue
80
+ thetas.append(th)
81
+ if verbose:
82
+ bstr = f" b11_log={th[p * d]:+.3f}" if q >= 1 else ""
83
+ _log.info(f" [boot {m:2d}]{bstr}")
84
+ except Exception as exc:
85
+ _log.warning(f" [boot {m:2d}] failed: {exc}")
86
+ continue
87
+
88
+ if len(thetas) < 2:
89
+ return None
90
+ thetas = np.vstack(thetas)
91
+ theta_se = np.std(thetas, axis=0, ddof=1)
92
+ boot_mean = np.mean(thetas, axis=0)
93
+ theta_bc = 2.0 * theta_fit - boot_mean
94
+ return dict(theta_se=theta_se, boot_mean=boot_mean, theta_bc=theta_bc,
95
+ boot_n=int(thetas.shape[0]), boot_thetas=thetas)
96
+
97
+
98
+ def subsampling_se(theta_fit, data, p, q, prior=None, m=None, n_sub=40,
99
+ n_restarts_perband=4, maxiter=300, use_jax_grad=False,
100
+ seed0=9000, verbose=False):
101
+ """m-out-of-n subsampling SE (Politis-Romano-Wolf), WITHOUT replacement.
102
+
103
+ Independent of the parametric bootstrap above: instead of regenerating data
104
+ from the fitted model, we refit on genuine subsamples of the observed light
105
+ curve, so it does NOT assume the model is correctly specified.
106
+
107
+ Procedure. Draw ``n_sub`` subsamples of ``m = round(n**(2/3))`` epochs
108
+ without replacement, stratified by band (so every band keeps its share and
109
+ stays identifiable), refit each at (p, q) with the same per-band warm start
110
+ and prior, take the per-parameter SD of the subsample estimates, and rescale
111
+ to the full-sample SE by ``sqrt(m/n)``. The rescaling is the subsampling
112
+ identity: if ``sqrt(k)*(theta_k - theta)`` shares one limiting law, then
113
+ ``SD(theta_m) ~ sqrt(V/m)`` and the full-sample SE is
114
+ ``SD(theta_m)*sqrt(m/n) ~ sqrt(V/n)``. CARMA is continuous-time, so dropping
115
+ epochs just yields a sparser irregular series the Kalman filter handles
116
+ natively.
117
+
118
+ Returns dict(theta_se, m, n, sub_n, theta_sub_std, sub_thetas) or None if
119
+ fewer than 2 subsample fits succeeded. For q >= 1 the MA coeff is
120
+ theta[p*d].
121
+ """
122
+ theta_fit = np.asarray(theta_fit, dtype=float)
123
+ d = data.d
124
+ t = np.asarray(data.t_obs, dtype=float)
125
+ band = np.asarray(data.band, dtype=int)
126
+ y = np.asarray(data.y_obs, dtype=float)
127
+ R = np.array([data.R_list[k][0, 0] for k in range(data.n)])
128
+ n = int(data.n)
129
+ prior = prior or {}
130
+ if m is None:
131
+ m = int(round(n ** (2.0 / 3.0)))
132
+ m = int(max(d + 2, min(m, n - 1)))
133
+
134
+ band_idx = [np.where(band == b)[0] for b in range(d)]
135
+ thetas = []
136
+ for s in range(n_sub):
137
+ rng = np.random.default_rng(seed0 + s)
138
+ parts = []
139
+ for b in range(d):
140
+ bi = band_idx[b]
141
+ if len(bi) == 0:
142
+ continue
143
+ mb = int(round(len(bi) * m / n))
144
+ mb = min(max(mb, 1), len(bi))
145
+ parts.append(rng.choice(bi, size=mb, replace=False))
146
+ if not parts:
147
+ continue
148
+ idx = np.sort(np.concatenate(parts))
149
+ try:
150
+ ys = center_bands(t[idx], y[idx], band[idx])
151
+ ds = ObservationData(t[idx], ys, band[idx], R[idx], d=d)
152
+ warm = build_perband_warm_theta(
153
+ t[idx], ys, band[idx], R[idx], d=d, p=p, q=q,
154
+ n_restarts=n_restarts_perband,
155
+ seed=int(rng.integers(1 << 31)))
156
+ with warnings.catch_warnings():
157
+ warnings.simplefilter("ignore")
158
+ r = fit(ds, p, q, n_restarts=1, warm_theta=warm,
159
+ maxiter=maxiter, use_jax_grad=use_jax_grad, **prior)
160
+ th = np.asarray(r["theta"], dtype=float)
161
+ if th.shape != theta_fit.shape:
162
+ continue
163
+ thetas.append(th)
164
+ if verbose:
165
+ bstr = f" b11_log={th[p * d]:+.3f}" if q >= 1 else ""
166
+ _log.info(f" [sub {s:2d}] m={idx.size}{bstr}")
167
+ except Exception as exc:
168
+ _log.warning(f" [sub {s:2d}] failed: {exc}")
169
+ continue
170
+
171
+ if len(thetas) < 2:
172
+ return None
173
+ thetas = np.vstack(thetas)
174
+ theta_sub_std = np.std(thetas, axis=0, ddof=1)
175
+ theta_se = theta_sub_std * np.sqrt(m / n)
176
+ return dict(theta_se=theta_se, m=int(m), n=int(n),
177
+ sub_n=int(thetas.shape[0]), theta_sub_std=theta_sub_std,
178
+ sub_thetas=thetas)