sensor-modeling 0.2.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.
- sensor_modeling/__init__.py +45 -0
- sensor_modeling/alerts/__init__.py +26 -0
- sensor_modeling/alerts/alert.py +532 -0
- sensor_modeling/analysis/__init__.py +43 -0
- sensor_modeling/analysis/_frame.py +19 -0
- sensor_modeling/analysis/behavioral_analysis.py +57 -0
- sensor_modeling/analysis/behavioral_metrics.py +66 -0
- sensor_modeling/analysis/comparison.py +164 -0
- sensor_modeling/analysis/dependency_network.py +408 -0
- sensor_modeling/analysis/granger_causality.py +314 -0
- sensor_modeling/analysis/pipeline.py +168 -0
- sensor_modeling/analysis/reporting.py +109 -0
- sensor_modeling/baseline/__init__.py +30 -0
- sensor_modeling/baseline/adaptive.py +520 -0
- sensor_modeling/baseline/features.py +224 -0
- sensor_modeling/change_point/__init__.py +13 -0
- sensor_modeling/change_point/_validation.py +31 -0
- sensor_modeling/change_point/adaptive_normalization.py +55 -0
- sensor_modeling/change_point/embedding_cpd.py +60 -0
- sensor_modeling/change_point/energy_efficient.py +57 -0
- sensor_modeling/change_point/genetic_optimization.py +65 -0
- sensor_modeling/cli.py +416 -0
- sensor_modeling/context/__init__.py +33 -0
- sensor_modeling/context/occupancy.py +529 -0
- sensor_modeling/data/__init__.py +5 -0
- sensor_modeling/data/loaders.py +146 -0
- sensor_modeling/data/preprocessing.py +83 -0
- sensor_modeling/data/synthetic.py +121 -0
- sensor_modeling/data/validation.py +81 -0
- sensor_modeling/evaluation/__init__.py +92 -0
- sensor_modeling/evaluation/ablation.py +303 -0
- sensor_modeling/evaluation/attribution.py +474 -0
- sensor_modeling/evaluation/detection.py +297 -0
- sensor_modeling/evaluation/metrics.py +541 -0
- sensor_modeling/evaluation/provenance.py +309 -0
- sensor_modeling/examples/__init__.py +1 -0
- sensor_modeling/examples/demos/__init__.py +1 -0
- sensor_modeling/examples/demos/ambient_pipeline_demo.py +418 -0
- sensor_modeling/examples/demos/bernoulli_ar_demo.py +356 -0
- sensor_modeling/examples/demos/cpd_ar_demo.py +25 -0
- sensor_modeling/examples/demos/cpd_benchmark.py +42 -0
- sensor_modeling/examples/demos/hmm_granger_demo.py +30 -0
- sensor_modeling/examples/demos/nhpp_pelt_demo.py +80 -0
- sensor_modeling/examples/tutorials/__init__.py +1 -0
- sensor_modeling/fusion/__init__.py +46 -0
- sensor_modeling/fusion/defaults.py +296 -0
- sensor_modeling/fusion/emissions.py +339 -0
- sensor_modeling/fusion/estimate.py +375 -0
- sensor_modeling/fusion/filter.py +323 -0
- sensor_modeling/health/__init__.py +31 -0
- sensor_modeling/health/monitor.py +590 -0
- sensor_modeling/health/status.py +74 -0
- sensor_modeling/hmm/__init__.py +15 -0
- sensor_modeling/hmm/adaptive_hmm.py +22 -0
- sensor_modeling/hmm/base.py +134 -0
- sensor_modeling/hmm/circadian_hmm.py +22 -0
- sensor_modeling/hmm/heterogeneous_hmm.py +22 -0
- sensor_modeling/hmm/hierarchical_hmm.py +35 -0
- sensor_modeling/hmm/scaled_dirichlet_hmm.py +23 -0
- sensor_modeling/interop/__init__.py +57 -0
- sensor_modeling/interop/fhir.py +418 -0
- sensor_modeling/interop/privacy.py +308 -0
- sensor_modeling/models/__init__.py +12 -0
- sensor_modeling/models/bernoulli_ar/__init__.py +6 -0
- sensor_modeling/models/bernoulli_ar/base_model.py +569 -0
- sensor_modeling/models/bernoulli_ar/multivariate_model.py +411 -0
- sensor_modeling/models/change_point_detection/__init__.py +10 -0
- sensor_modeling/models/change_point_detection/deep.py +65 -0
- sensor_modeling/models/change_point_detection/pelt.py +159 -0
- sensor_modeling/models/nhpp_pelt/__init__.py +5 -0
- sensor_modeling/models/nhpp_pelt/bspline.py +96 -0
- sensor_modeling/models/nhpp_pelt/cli.py +243 -0
- sensor_modeling/models/nhpp_pelt/diagnostics.py +234 -0
- sensor_modeling/models/nhpp_pelt/io.py +58 -0
- sensor_modeling/models/nhpp_pelt/model.py +408 -0
- sensor_modeling/models/nhpp_pelt/optimizer.py +142 -0
- sensor_modeling/models/nhpp_pelt/plotting.py +218 -0
- sensor_modeling/models/nhpp_pelt/quad.py +72 -0
- sensor_modeling/models/nhpp_pelt/regularization.py +121 -0
- sensor_modeling/models/nhpp_pelt/utils.py +174 -0
- sensor_modeling/observations/__init__.py +59 -0
- sensor_modeling/observations/adapters.py +195 -0
- sensor_modeling/observations/ingest.py +269 -0
- sensor_modeling/observations/observation.py +270 -0
- sensor_modeling/observations/registry.py +262 -0
- sensor_modeling/observations/stream.py +342 -0
- sensor_modeling/observations/types.py +107 -0
- sensor_modeling/observations/units.py +117 -0
- sensor_modeling/online/__init__.py +36 -0
- sensor_modeling/online/benchmarks.py +242 -0
- sensor_modeling/online/pipeline.py +485 -0
- sensor_modeling/simulation/__init__.py +54 -0
- sensor_modeling/simulation/faults.py +191 -0
- sensor_modeling/simulation/household.py +862 -0
- sensor_modeling/states/__init__.py +23 -0
- sensor_modeling/states/markov.py +105 -0
- sensor_modeling/states/ontology.py +238 -0
- sensor_modeling/utils/__init__.py +41 -0
- sensor_modeling/utils/data_io.py +199 -0
- sensor_modeling/utils/logging_config.py +10 -0
- sensor_modeling/utils/missing.py +188 -0
- sensor_modeling/utils/plotting.py +98 -0
- sensor_modeling/utils/validation.py +117 -0
- sensor_modeling/visualization/__init__.py +3 -0
- sensor_modeling/visualization/clinical.py +67 -0
- sensor_modeling/visualization/interactive.py +208 -0
- sensor_modeling/visualization/research.py +60 -0
- sensor_modeling/visualization/web_app.py +137 -0
- sensor_modeling-0.2.0.dist-info/METADATA +683 -0
- sensor_modeling-0.2.0.dist-info/RECORD +114 -0
- sensor_modeling-0.2.0.dist-info/WHEEL +5 -0
- sensor_modeling-0.2.0.dist-info/entry_points.txt +18 -0
- sensor_modeling-0.2.0.dist-info/licenses/LICENSE +21 -0
- sensor_modeling-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from collections.abc import Iterable, Sequence
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Dict, List, Optional, Tuple
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from ...utils.data_io import SensorDataset
|
|
11
|
+
from .bspline import bspline_design_matrix, open_uniform_knots
|
|
12
|
+
from .optimizer import NewtonConfig, SegmentOptimizer
|
|
13
|
+
from .quad import QuadratureConfig
|
|
14
|
+
from .utils import normal_ppf, type_check
|
|
15
|
+
|
|
16
|
+
Array1D = np.ndarray
|
|
17
|
+
Array2D = np.ndarray
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class NHPPConfig:
|
|
22
|
+
"""
|
|
23
|
+
Configuration for NHPP segmentation with B-spline intensities.
|
|
24
|
+
|
|
25
|
+
Attributes
|
|
26
|
+
----------
|
|
27
|
+
delta : float
|
|
28
|
+
Length of the time window for each day (e.g., 24.0 for hours).
|
|
29
|
+
degree : int
|
|
30
|
+
B-spline degree (3 = cubic).
|
|
31
|
+
n_basis : int
|
|
32
|
+
Number of basis functions P.
|
|
33
|
+
knot_strategy : {'quantile', 'equispaced'}
|
|
34
|
+
How to place internal knots.
|
|
35
|
+
quad : QuadratureConfig
|
|
36
|
+
Quadrature setup.
|
|
37
|
+
newton : NewtonConfig
|
|
38
|
+
Newton solver setup.
|
|
39
|
+
penalty_beta : float | None
|
|
40
|
+
If None, use SIC: (P+1) * log(n_days).
|
|
41
|
+
hessian_ridge : float
|
|
42
|
+
Ridge added to Hessian (and for covariance inversion).
|
|
43
|
+
min_seg_len : int
|
|
44
|
+
Minimum number of days per segment (>=1). Use 1 to disable.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
delta: float = 24.0
|
|
48
|
+
degree: int = 3
|
|
49
|
+
n_basis: int = 5
|
|
50
|
+
knot_strategy: str = "quantile"
|
|
51
|
+
quad: QuadratureConfig = QuadratureConfig()
|
|
52
|
+
newton: NewtonConfig = NewtonConfig()
|
|
53
|
+
penalty_beta: Optional[float] = None
|
|
54
|
+
hessian_ridge: float = 1e-8
|
|
55
|
+
min_seg_len: int = 2
|
|
56
|
+
pspline_gamma: float = 0.0
|
|
57
|
+
pspline_order: int = 2
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class NHPPPELT:
|
|
61
|
+
"""
|
|
62
|
+
Changepoint detection over a sequence of daily inhomogeneous Poisson processes (IHPPs)
|
|
63
|
+
where each segment k shares a B-spline log-intensity:
|
|
64
|
+
|
|
65
|
+
λ_k(t) = exp( w_kᵀ ψ(t) ), ψ: B-spline basis
|
|
66
|
+
|
|
67
|
+
Segment cost = minimized negative log-likelihood:
|
|
68
|
+
C(i..j) = min_w [ L*∫exp(wᵀψ)dt - sᵀw ]
|
|
69
|
+
with L = j-i+1 and s = Σ_{events in i..j} ψ(t).
|
|
70
|
+
|
|
71
|
+
Penalized objective (PELT):
|
|
72
|
+
min over partitions Σ_k [ C(segment_k) + β ].
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
# ----------------------------- Basics -----------------------------
|
|
76
|
+
|
|
77
|
+
def __init__(self, config: NHPPConfig):
|
|
78
|
+
self.cfg = config
|
|
79
|
+
self._fitted = False
|
|
80
|
+
|
|
81
|
+
# -------------------------- Knot builder --------------------------
|
|
82
|
+
|
|
83
|
+
def _build_knots(self, all_event_times: Array1D) -> Array1D:
|
|
84
|
+
deg = self.cfg.degree
|
|
85
|
+
P = self.cfg.n_basis
|
|
86
|
+
type_check(P >= deg + 1, f"n_basis must be >= degree+1 (= {deg + 1}).")
|
|
87
|
+
|
|
88
|
+
n_internal = P - deg - 1
|
|
89
|
+
if n_internal <= 0:
|
|
90
|
+
internal = None
|
|
91
|
+
else:
|
|
92
|
+
if self.cfg.knot_strategy == "quantile":
|
|
93
|
+
if all_event_times.size >= n_internal + 1:
|
|
94
|
+
qs = np.linspace(0, 1, n_internal + 2)[1:-1]
|
|
95
|
+
internal = np.quantile(all_event_times, qs)
|
|
96
|
+
else:
|
|
97
|
+
internal = np.linspace(0.0, self.cfg.delta, n_internal + 2)[1:-1]
|
|
98
|
+
elif self.cfg.knot_strategy == "equispaced":
|
|
99
|
+
internal = np.linspace(0.0, self.cfg.delta, n_internal + 2)[1:-1]
|
|
100
|
+
else:
|
|
101
|
+
raise ValueError("knot_strategy must be 'quantile' or 'equispaced'.")
|
|
102
|
+
|
|
103
|
+
return open_uniform_knots(self.cfg.delta, deg, P, internal)
|
|
104
|
+
|
|
105
|
+
# ------------------------------ Fit ------------------------------
|
|
106
|
+
|
|
107
|
+
def fit(
|
|
108
|
+
self, data: Sequence[Array1D] | SensorDataset, sensor: str | None = None
|
|
109
|
+
) -> NHPPPELT:
|
|
110
|
+
"""
|
|
111
|
+
Fit the model. After fitting, attributes include:
|
|
112
|
+
- changepoints_ : List[int] in 1..n-1
|
|
113
|
+
- segments_ : List[(start,end)] 1-based inclusive
|
|
114
|
+
- weights_ : List[(P,)] B-spline weights per segment
|
|
115
|
+
- degree_, delta_, P_, knots_, beta_
|
|
116
|
+
"""
|
|
117
|
+
if isinstance(data, SensorDataset):
|
|
118
|
+
if sensor is None:
|
|
119
|
+
raise ValueError("sensor must be provided when using SensorDataset")
|
|
120
|
+
days = data.to_event_sequences(sensor)
|
|
121
|
+
else:
|
|
122
|
+
days = data
|
|
123
|
+
|
|
124
|
+
type_check(len(days) >= 1, "At least one day is required.")
|
|
125
|
+
n = len(days)
|
|
126
|
+
delta = float(self.cfg.delta)
|
|
127
|
+
|
|
128
|
+
all_ts = (
|
|
129
|
+
np.concatenate([np.asarray(d, dtype=float) for d in days])
|
|
130
|
+
if n > 0
|
|
131
|
+
else np.array([], float)
|
|
132
|
+
)
|
|
133
|
+
if all_ts.size > 0:
|
|
134
|
+
type_check(
|
|
135
|
+
np.all((all_ts >= 0.0) & (all_ts <= delta)),
|
|
136
|
+
"Event times must lie in [0, delta].",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
knots = self._build_knots(all_ts)
|
|
140
|
+
degree = self.cfg.degree
|
|
141
|
+
P = self.cfg.n_basis
|
|
142
|
+
|
|
143
|
+
# Per-day sufficient stats s_i = Σ ψ(t)
|
|
144
|
+
s_list: List[Array1D] = []
|
|
145
|
+
for d in days:
|
|
146
|
+
d = np.asarray(d, dtype=float)
|
|
147
|
+
if d.size == 0:
|
|
148
|
+
s_list.append(np.zeros(P, dtype=float))
|
|
149
|
+
else:
|
|
150
|
+
Psi = bspline_design_matrix(d, degree, knots)
|
|
151
|
+
s_list.append(Psi.sum(axis=0))
|
|
152
|
+
S = np.vstack(s_list) # (n,P)
|
|
153
|
+
S_cum = np.vstack([np.zeros((1, P)), np.cumsum(S, axis=0)]) # (n+1,P)
|
|
154
|
+
|
|
155
|
+
# Penalty
|
|
156
|
+
beta = (
|
|
157
|
+
self.cfg.penalty_beta
|
|
158
|
+
if self.cfg.penalty_beta is not None
|
|
159
|
+
else (P + 1) * math.log(n)
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Optimizer
|
|
163
|
+
quad = QuadratureConfig(
|
|
164
|
+
n_points=self.cfg.quad.n_points, ridge=self.cfg.hessian_ridge
|
|
165
|
+
)
|
|
166
|
+
opt = SegmentOptimizer(
|
|
167
|
+
delta=delta, degree=degree, knots=knots, quad=quad, newton=self.cfg.newton
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Segment caches
|
|
171
|
+
cost_cache: Dict[Tuple[int, int], float] = {}
|
|
172
|
+
w_cache: Dict[Tuple[int, int], Array1D] = {}
|
|
173
|
+
|
|
174
|
+
def seg_stats(i: int, j: int) -> Tuple[int, Array1D]:
|
|
175
|
+
L = j - i + 1
|
|
176
|
+
s = S_cum[j, :] - S_cum[i - 1, :]
|
|
177
|
+
return L, s
|
|
178
|
+
|
|
179
|
+
def seg_cost(
|
|
180
|
+
i: int, j: int, warm: Optional[Array1D] = None
|
|
181
|
+
) -> Tuple[float, Array1D]:
|
|
182
|
+
key = (i, j)
|
|
183
|
+
if key in cost_cache:
|
|
184
|
+
return cost_cache[key], w_cache[key]
|
|
185
|
+
L, s = seg_stats(i, j)
|
|
186
|
+
w0 = warm
|
|
187
|
+
if w0 is None and (i, j - 1) in w_cache:
|
|
188
|
+
w0 = w_cache[(i, j - 1)]
|
|
189
|
+
if w0 is None and (i + 1, j) in w_cache:
|
|
190
|
+
w0 = w_cache[(i + 1, j)]
|
|
191
|
+
w_star, c_star = opt.minimize(L=L, s=s, w0=w0)
|
|
192
|
+
cost_cache[key] = c_star
|
|
193
|
+
w_cache[key] = w_star
|
|
194
|
+
return c_star, w_star
|
|
195
|
+
|
|
196
|
+
# ------------------------------ PELT ------------------------------
|
|
197
|
+
msl = max(1, int(self.cfg.min_seg_len))
|
|
198
|
+
F = np.full(n + 1, np.inf, dtype=float) # DP table
|
|
199
|
+
F[0] = -beta
|
|
200
|
+
last_change = np.full(n + 1, -1, dtype=int)
|
|
201
|
+
R: List[int] = [0]
|
|
202
|
+
|
|
203
|
+
for q in range(1, n + 1):
|
|
204
|
+
best_val = np.inf
|
|
205
|
+
best_p = -1
|
|
206
|
+
for p in R:
|
|
207
|
+
if (q - p) < msl:
|
|
208
|
+
continue
|
|
209
|
+
c, _ = seg_cost(p + 1, q)
|
|
210
|
+
val = F[p] + c + beta
|
|
211
|
+
if val < best_val:
|
|
212
|
+
best_val, best_p = val, p
|
|
213
|
+
F[q] = best_val
|
|
214
|
+
last_change[q] = best_p
|
|
215
|
+
|
|
216
|
+
# pruning
|
|
217
|
+
new_R = []
|
|
218
|
+
for p in R:
|
|
219
|
+
if (q - p) < msl:
|
|
220
|
+
continue
|
|
221
|
+
c, _ = seg_cost(p + 1, q)
|
|
222
|
+
if F[p] + c < F[q]:
|
|
223
|
+
new_R.append(p)
|
|
224
|
+
R = new_R + [q]
|
|
225
|
+
|
|
226
|
+
# backtrack
|
|
227
|
+
cps: List[int] = []
|
|
228
|
+
q = n
|
|
229
|
+
while q > 0:
|
|
230
|
+
p = last_change[q]
|
|
231
|
+
if p <= 0:
|
|
232
|
+
if p == 0:
|
|
233
|
+
cps.append(0)
|
|
234
|
+
break
|
|
235
|
+
cps.append(p)
|
|
236
|
+
q = p
|
|
237
|
+
cps = sorted([c for c in cps if 0 < c < n])
|
|
238
|
+
|
|
239
|
+
# segments and weights
|
|
240
|
+
segments: List[Tuple[int, int]] = []
|
|
241
|
+
weights: List[Array1D] = []
|
|
242
|
+
start = 1
|
|
243
|
+
for cp in cps + [n]:
|
|
244
|
+
c, w = seg_cost(start, cp if cp > 0 else n)
|
|
245
|
+
segments.append((start, cp if cp > 0 else n))
|
|
246
|
+
weights.append(w)
|
|
247
|
+
start = cp + 1 if cp > 0 else n + 1
|
|
248
|
+
|
|
249
|
+
# store
|
|
250
|
+
self.changepoints_ = cps
|
|
251
|
+
self.segments_ = segments
|
|
252
|
+
self.weights_ = weights
|
|
253
|
+
self.knots_ = knots
|
|
254
|
+
self.delta_ = delta
|
|
255
|
+
self.degree_ = degree
|
|
256
|
+
self.P_ = P
|
|
257
|
+
self.beta_ = beta
|
|
258
|
+
self._fitted = True
|
|
259
|
+
return self
|
|
260
|
+
|
|
261
|
+
# ----------------------------- Helpers -----------------------------
|
|
262
|
+
|
|
263
|
+
def intensity_on_grid(self, seg_index: int, grid: Array1D) -> Array1D:
|
|
264
|
+
"""Evaluate λ̂_k(t) on a grid for segment k."""
|
|
265
|
+
type_check(self._fitted, "Call .fit() first.")
|
|
266
|
+
type_check(0 <= seg_index < len(self.weights_), "Invalid segment index.")
|
|
267
|
+
Psi = bspline_design_matrix(np.asarray(grid, float), self.degree_, self.knots_)
|
|
268
|
+
w = self.weights_[seg_index]
|
|
269
|
+
return np.exp(Psi @ w)
|
|
270
|
+
|
|
271
|
+
def segment_covariance(self, seg_index: int) -> Array2D:
|
|
272
|
+
"""
|
|
273
|
+
Approximate Cov(ŵ_k) via observed Hessian inverse at optimum:
|
|
274
|
+
H(ŵ) = L * ∫ exp(ψᵀŵ) ψψᵀ dt + ridge*I + 2·(γ DᵀD)
|
|
275
|
+
so Σ ≈ H^{-1}.
|
|
276
|
+
"""
|
|
277
|
+
from .regularization import p_spline_RtR # local import to avoid cycles
|
|
278
|
+
|
|
279
|
+
type_check(self._fitted, "Call .fit() first.")
|
|
280
|
+
type_check(0 <= seg_index < len(self.weights_), "Invalid segment index.")
|
|
281
|
+
w = self.weights_[seg_index]
|
|
282
|
+
i_start, i_end = self.segments_[seg_index]
|
|
283
|
+
L = i_end - i_start + 1
|
|
284
|
+
|
|
285
|
+
quad = QuadratureConfig(
|
|
286
|
+
n_points=self.cfg.quad.n_points, ridge=self.cfg.hessian_ridge
|
|
287
|
+
)
|
|
288
|
+
opt = SegmentOptimizer(
|
|
289
|
+
self.delta_,
|
|
290
|
+
self.degree_,
|
|
291
|
+
self.knots_,
|
|
292
|
+
quad=quad,
|
|
293
|
+
newton=self.cfg.newton,
|
|
294
|
+
pspline_gamma=self.cfg.pspline_gamma,
|
|
295
|
+
pspline_order=self.cfg.pspline_order,
|
|
296
|
+
)
|
|
297
|
+
_, _, I2 = opt._integrals(w)
|
|
298
|
+
|
|
299
|
+
RtR = p_spline_RtR(
|
|
300
|
+
self.P_, order=self.cfg.pspline_order, gamma=self.cfg.pspline_gamma
|
|
301
|
+
)
|
|
302
|
+
H = (
|
|
303
|
+
L * I2
|
|
304
|
+
+ (self.cfg.hessian_ridge * np.eye(I2.shape[0], dtype=float))
|
|
305
|
+
+ 2.0 * RtR
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
Sigma = np.linalg.inv(H)
|
|
310
|
+
except np.linalg.LinAlgError:
|
|
311
|
+
Sigma = np.linalg.pinv(H)
|
|
312
|
+
return Sigma
|
|
313
|
+
|
|
314
|
+
def intensity_with_bands(
|
|
315
|
+
self,
|
|
316
|
+
seg_index: int,
|
|
317
|
+
grid: Array1D,
|
|
318
|
+
ci: float = 0.95,
|
|
319
|
+
) -> Tuple[Array1D, Array1D, Array1D]:
|
|
320
|
+
"""
|
|
321
|
+
Return λ̂, lower, upper using a delta-method CI on the log-scale:
|
|
322
|
+
|
|
323
|
+
Var[log λ(t)] = ψ(t)ᵀ Σ ψ(t),
|
|
324
|
+
Var[λ(t)] ≈ λ(t)^2 * Var[log λ(t)].
|
|
325
|
+
|
|
326
|
+
No SciPy required (uses a built-in normal PPF).
|
|
327
|
+
"""
|
|
328
|
+
type_check(self._fitted, "Call .fit() first.")
|
|
329
|
+
type_check(0.0 < ci < 1.0, "ci must be in (0,1).")
|
|
330
|
+
z = float(normal_ppf(0.5 + 0.5 * ci))
|
|
331
|
+
|
|
332
|
+
Psi = bspline_design_matrix(np.asarray(grid, float), self.degree_, self.knots_)
|
|
333
|
+
w = self.weights_[seg_index]
|
|
334
|
+
lam = np.exp(Psi @ w)
|
|
335
|
+
|
|
336
|
+
Sigma = self.segment_covariance(seg_index)
|
|
337
|
+
var_log = np.einsum("ij,jk,ik->i", Psi, Sigma, Psi)
|
|
338
|
+
se_log = np.sqrt(np.maximum(var_log, 0.0))
|
|
339
|
+
log_l = np.log(lam) - z * se_log
|
|
340
|
+
log_u = np.log(lam) + z * se_log
|
|
341
|
+
return lam, np.exp(log_l), np.exp(log_u)
|
|
342
|
+
|
|
343
|
+
# ---------------------------- Model choice ----------------------------
|
|
344
|
+
|
|
345
|
+
@staticmethod
|
|
346
|
+
def select_P_via_AIC(
|
|
347
|
+
days: Sequence[Array1D],
|
|
348
|
+
delta: float,
|
|
349
|
+
degree: int,
|
|
350
|
+
P_grid: Iterable[int],
|
|
351
|
+
knot_strategy: str = "quantile",
|
|
352
|
+
quad: QuadratureConfig = QuadratureConfig(),
|
|
353
|
+
newton: NewtonConfig = NewtonConfig(),
|
|
354
|
+
) -> int:
|
|
355
|
+
"""
|
|
356
|
+
Pick P by minimizing average AIC across days fitted individually.
|
|
357
|
+
AIC_i(P) = 2P - 2*loglik_i(P), loglik = - min cost with L=1.
|
|
358
|
+
"""
|
|
359
|
+
type_check(len(days) > 0, "Need at least one day.")
|
|
360
|
+
all_ts = (
|
|
361
|
+
np.concatenate([np.asarray(d, float) for d in days])
|
|
362
|
+
if len(days)
|
|
363
|
+
else np.array([], float)
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
def build_knots(P: int) -> Array1D:
|
|
367
|
+
n_internal = P - degree - 1
|
|
368
|
+
if n_internal <= 0:
|
|
369
|
+
internal = None
|
|
370
|
+
else:
|
|
371
|
+
if knot_strategy == "quantile":
|
|
372
|
+
if all_ts.size >= n_internal + 1:
|
|
373
|
+
qs = np.linspace(0, 1, n_internal + 2)[1:-1]
|
|
374
|
+
internal = np.quantile(all_ts, qs)
|
|
375
|
+
else:
|
|
376
|
+
internal = np.linspace(0.0, delta, n_internal + 2)[1:-1]
|
|
377
|
+
elif knot_strategy == "equispaced":
|
|
378
|
+
internal = np.linspace(0.0, delta, n_internal + 2)[1:-1]
|
|
379
|
+
else:
|
|
380
|
+
raise ValueError(
|
|
381
|
+
"knot_strategy must be 'quantile' or 'equispaced'."
|
|
382
|
+
)
|
|
383
|
+
return open_uniform_knots(delta, degree, P, internal)
|
|
384
|
+
|
|
385
|
+
best_P, best_avg = None, np.inf
|
|
386
|
+
for P in P_grid:
|
|
387
|
+
type_check(P >= degree + 1, f"P must be >= degree+1 (= {degree + 1}).")
|
|
388
|
+
knots = build_knots(P)
|
|
389
|
+
opt = SegmentOptimizer(delta, degree, knots, quad=quad, newton=newton)
|
|
390
|
+
|
|
391
|
+
aics = []
|
|
392
|
+
for d in days:
|
|
393
|
+
d = np.asarray(d, float)
|
|
394
|
+
if d.size == 0:
|
|
395
|
+
# discourage degenerate zero-event days
|
|
396
|
+
aics.append(1e6)
|
|
397
|
+
continue
|
|
398
|
+
Psi = bspline_design_matrix(d, degree, knots)
|
|
399
|
+
s = Psi.sum(axis=0)
|
|
400
|
+
_, cost = opt.minimize(L=1, s=s)
|
|
401
|
+
loglik = -cost
|
|
402
|
+
aics.append(2 * P - 2 * loglik)
|
|
403
|
+
|
|
404
|
+
avg = float(np.mean(aics))
|
|
405
|
+
if avg < best_avg:
|
|
406
|
+
best_avg, best_P = avg, P
|
|
407
|
+
|
|
408
|
+
return int(best_P)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional, Tuple
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from .bspline import bspline_design_matrix
|
|
10
|
+
from .quad import QuadratureConfig, leggauss_on_interval
|
|
11
|
+
from .regularization import p_spline_RtR # <-- NEW
|
|
12
|
+
from .utils import type_check
|
|
13
|
+
|
|
14
|
+
Array1D = np.ndarray
|
|
15
|
+
Array2D = np.ndarray
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class NewtonConfig:
|
|
20
|
+
"""Tuning for Newton backtracking solver."""
|
|
21
|
+
|
|
22
|
+
max_iter: int = 60
|
|
23
|
+
tol: float = 1e-7
|
|
24
|
+
step_shrink: float = 0.5
|
|
25
|
+
min_step: float = 1e-6
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SegmentOptimizer:
|
|
29
|
+
"""
|
|
30
|
+
Optimize the segment negative log-likelihood with optional P-spline penalty:
|
|
31
|
+
|
|
32
|
+
cost(w) = L * ∫ exp(wᵀψ(t)) dt - sᵀ w + wᵀ( RtR )w
|
|
33
|
+
|
|
34
|
+
where s = ∑_events ψ(t_j), L = number of days in the segment, and RtR = γ·DᵀD.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
delta: float,
|
|
40
|
+
degree: int,
|
|
41
|
+
knots: Array1D,
|
|
42
|
+
quad: QuadratureConfig = QuadratureConfig(),
|
|
43
|
+
newton: NewtonConfig = NewtonConfig(),
|
|
44
|
+
*,
|
|
45
|
+
pspline_gamma: float = 0.0, # <-- NEW (optional)
|
|
46
|
+
pspline_order: int = 2, # <-- NEW (optional)
|
|
47
|
+
) -> None:
|
|
48
|
+
type_check(delta > 0.0, "delta must be positive.")
|
|
49
|
+
self.delta = float(delta)
|
|
50
|
+
self.degree = int(degree)
|
|
51
|
+
self.knots = np.asarray(knots, dtype=float)
|
|
52
|
+
self.quad = quad
|
|
53
|
+
self.newton = newton
|
|
54
|
+
|
|
55
|
+
# Precompute quadrature nodes and basis at nodes
|
|
56
|
+
nodes, weights = leggauss_on_interval(0.0, self.delta, quad.n_points)
|
|
57
|
+
self._q_nodes = nodes
|
|
58
|
+
self._q_w = weights
|
|
59
|
+
self._q_psi = bspline_design_matrix(nodes, self.degree, self.knots) # (Q,P)
|
|
60
|
+
self.P = self._q_psi.shape[1]
|
|
61
|
+
|
|
62
|
+
# Cache RtR = γ·DᵀD (zero matrix if gamma==0)
|
|
63
|
+
self._RtR = p_spline_RtR(self.P, order=pspline_order, gamma=pspline_gamma)
|
|
64
|
+
|
|
65
|
+
# ---------------------- Stable integral block ----------------------
|
|
66
|
+
|
|
67
|
+
def _integrals(self, w: Array1D) -> Tuple[float, Array1D, Array2D]:
|
|
68
|
+
psi = self._q_psi # (Q,P)
|
|
69
|
+
u = psi @ w # (Q,)
|
|
70
|
+
m = float(np.max(u))
|
|
71
|
+
e_scaled = np.exp(u - m)
|
|
72
|
+
wq = self._q_w
|
|
73
|
+
|
|
74
|
+
I0_s = float(np.sum(e_scaled * wq))
|
|
75
|
+
I1_s = psi.T @ (e_scaled * wq)
|
|
76
|
+
I2_s = psi.T @ (psi * (e_scaled * wq)[:, None])
|
|
77
|
+
|
|
78
|
+
scale = math.exp(m)
|
|
79
|
+
return I0_s * scale, I1_s * scale, I2_s * scale
|
|
80
|
+
|
|
81
|
+
# ------------------------- Newton optimizer -------------------------
|
|
82
|
+
|
|
83
|
+
def minimize(
|
|
84
|
+
self, L: int, s: Array1D, w0: Optional[Array1D] = None
|
|
85
|
+
) -> Tuple[Array1D, float]:
|
|
86
|
+
type_check(L >= 1, "Segment length L must be >= 1.")
|
|
87
|
+
s = np.asarray(s, dtype=float)
|
|
88
|
+
w = (
|
|
89
|
+
np.zeros(self.P, dtype=float)
|
|
90
|
+
if w0 is None
|
|
91
|
+
else np.asarray(w0, dtype=float).copy()
|
|
92
|
+
)
|
|
93
|
+
type_check(w.shape == (self.P,), "Initial w has wrong shape.")
|
|
94
|
+
|
|
95
|
+
ridgeI = self.quad.ridge * np.eye(self.P, dtype=float)
|
|
96
|
+
|
|
97
|
+
for _ in range(self.newton.max_iter):
|
|
98
|
+
I0, I1, I2 = self._integrals(w)
|
|
99
|
+
cost = L * I0 - float(s @ w)
|
|
100
|
+
grad = L * I1 - s
|
|
101
|
+
H = L * I2 + ridgeI
|
|
102
|
+
|
|
103
|
+
# ======== P-spline penalty (γ‖D²w‖²) — 3 math lines ========
|
|
104
|
+
if np.any(self._RtR): # skip if gamma == 0
|
|
105
|
+
cost += float(w @ (self._RtR @ w))
|
|
106
|
+
grad += 2.0 * (self._RtR @ w)
|
|
107
|
+
H += 2.0 * self._RtR
|
|
108
|
+
# ===========================================================
|
|
109
|
+
|
|
110
|
+
# Convergence
|
|
111
|
+
if np.linalg.norm(grad, ord=np.inf) <= self.newton.tol * max(
|
|
112
|
+
1.0, np.linalg.norm(s, ord=np.inf)
|
|
113
|
+
):
|
|
114
|
+
return w, cost
|
|
115
|
+
|
|
116
|
+
# Newton step
|
|
117
|
+
try:
|
|
118
|
+
step = np.linalg.solve(H, grad)
|
|
119
|
+
except np.linalg.LinAlgError:
|
|
120
|
+
step = np.linalg.pinv(H) @ grad
|
|
121
|
+
|
|
122
|
+
# Backtracking
|
|
123
|
+
t = 1.0
|
|
124
|
+
while t >= self.newton.min_step:
|
|
125
|
+
w_new = w - t * step
|
|
126
|
+
I0_new, _, _ = self._integrals(w_new)
|
|
127
|
+
cost_new = L * I0_new - float(s @ w_new)
|
|
128
|
+
# penalty contribution at trial point
|
|
129
|
+
if np.any(self._RtR):
|
|
130
|
+
cost_new += float(w_new @ (self._RtR @ w_new))
|
|
131
|
+
if cost_new <= cost - 1e-8 * t * float(grad @ step):
|
|
132
|
+
w, cost = w_new, cost_new
|
|
133
|
+
break
|
|
134
|
+
t *= self.newton.step_shrink
|
|
135
|
+
else:
|
|
136
|
+
w = w - self.newton.min_step * step
|
|
137
|
+
|
|
138
|
+
I0, _, _ = self._integrals(w)
|
|
139
|
+
final_cost = L * I0 - float(s @ w)
|
|
140
|
+
if np.any(self._RtR):
|
|
141
|
+
final_cost += float(w @ (self._RtR @ w))
|
|
142
|
+
return w, final_cost
|