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.
Files changed (114) hide show
  1. sensor_modeling/__init__.py +45 -0
  2. sensor_modeling/alerts/__init__.py +26 -0
  3. sensor_modeling/alerts/alert.py +532 -0
  4. sensor_modeling/analysis/__init__.py +43 -0
  5. sensor_modeling/analysis/_frame.py +19 -0
  6. sensor_modeling/analysis/behavioral_analysis.py +57 -0
  7. sensor_modeling/analysis/behavioral_metrics.py +66 -0
  8. sensor_modeling/analysis/comparison.py +164 -0
  9. sensor_modeling/analysis/dependency_network.py +408 -0
  10. sensor_modeling/analysis/granger_causality.py +314 -0
  11. sensor_modeling/analysis/pipeline.py +168 -0
  12. sensor_modeling/analysis/reporting.py +109 -0
  13. sensor_modeling/baseline/__init__.py +30 -0
  14. sensor_modeling/baseline/adaptive.py +520 -0
  15. sensor_modeling/baseline/features.py +224 -0
  16. sensor_modeling/change_point/__init__.py +13 -0
  17. sensor_modeling/change_point/_validation.py +31 -0
  18. sensor_modeling/change_point/adaptive_normalization.py +55 -0
  19. sensor_modeling/change_point/embedding_cpd.py +60 -0
  20. sensor_modeling/change_point/energy_efficient.py +57 -0
  21. sensor_modeling/change_point/genetic_optimization.py +65 -0
  22. sensor_modeling/cli.py +416 -0
  23. sensor_modeling/context/__init__.py +33 -0
  24. sensor_modeling/context/occupancy.py +529 -0
  25. sensor_modeling/data/__init__.py +5 -0
  26. sensor_modeling/data/loaders.py +146 -0
  27. sensor_modeling/data/preprocessing.py +83 -0
  28. sensor_modeling/data/synthetic.py +121 -0
  29. sensor_modeling/data/validation.py +81 -0
  30. sensor_modeling/evaluation/__init__.py +92 -0
  31. sensor_modeling/evaluation/ablation.py +303 -0
  32. sensor_modeling/evaluation/attribution.py +474 -0
  33. sensor_modeling/evaluation/detection.py +297 -0
  34. sensor_modeling/evaluation/metrics.py +541 -0
  35. sensor_modeling/evaluation/provenance.py +309 -0
  36. sensor_modeling/examples/__init__.py +1 -0
  37. sensor_modeling/examples/demos/__init__.py +1 -0
  38. sensor_modeling/examples/demos/ambient_pipeline_demo.py +418 -0
  39. sensor_modeling/examples/demos/bernoulli_ar_demo.py +356 -0
  40. sensor_modeling/examples/demos/cpd_ar_demo.py +25 -0
  41. sensor_modeling/examples/demos/cpd_benchmark.py +42 -0
  42. sensor_modeling/examples/demos/hmm_granger_demo.py +30 -0
  43. sensor_modeling/examples/demos/nhpp_pelt_demo.py +80 -0
  44. sensor_modeling/examples/tutorials/__init__.py +1 -0
  45. sensor_modeling/fusion/__init__.py +46 -0
  46. sensor_modeling/fusion/defaults.py +296 -0
  47. sensor_modeling/fusion/emissions.py +339 -0
  48. sensor_modeling/fusion/estimate.py +375 -0
  49. sensor_modeling/fusion/filter.py +323 -0
  50. sensor_modeling/health/__init__.py +31 -0
  51. sensor_modeling/health/monitor.py +590 -0
  52. sensor_modeling/health/status.py +74 -0
  53. sensor_modeling/hmm/__init__.py +15 -0
  54. sensor_modeling/hmm/adaptive_hmm.py +22 -0
  55. sensor_modeling/hmm/base.py +134 -0
  56. sensor_modeling/hmm/circadian_hmm.py +22 -0
  57. sensor_modeling/hmm/heterogeneous_hmm.py +22 -0
  58. sensor_modeling/hmm/hierarchical_hmm.py +35 -0
  59. sensor_modeling/hmm/scaled_dirichlet_hmm.py +23 -0
  60. sensor_modeling/interop/__init__.py +57 -0
  61. sensor_modeling/interop/fhir.py +418 -0
  62. sensor_modeling/interop/privacy.py +308 -0
  63. sensor_modeling/models/__init__.py +12 -0
  64. sensor_modeling/models/bernoulli_ar/__init__.py +6 -0
  65. sensor_modeling/models/bernoulli_ar/base_model.py +569 -0
  66. sensor_modeling/models/bernoulli_ar/multivariate_model.py +411 -0
  67. sensor_modeling/models/change_point_detection/__init__.py +10 -0
  68. sensor_modeling/models/change_point_detection/deep.py +65 -0
  69. sensor_modeling/models/change_point_detection/pelt.py +159 -0
  70. sensor_modeling/models/nhpp_pelt/__init__.py +5 -0
  71. sensor_modeling/models/nhpp_pelt/bspline.py +96 -0
  72. sensor_modeling/models/nhpp_pelt/cli.py +243 -0
  73. sensor_modeling/models/nhpp_pelt/diagnostics.py +234 -0
  74. sensor_modeling/models/nhpp_pelt/io.py +58 -0
  75. sensor_modeling/models/nhpp_pelt/model.py +408 -0
  76. sensor_modeling/models/nhpp_pelt/optimizer.py +142 -0
  77. sensor_modeling/models/nhpp_pelt/plotting.py +218 -0
  78. sensor_modeling/models/nhpp_pelt/quad.py +72 -0
  79. sensor_modeling/models/nhpp_pelt/regularization.py +121 -0
  80. sensor_modeling/models/nhpp_pelt/utils.py +174 -0
  81. sensor_modeling/observations/__init__.py +59 -0
  82. sensor_modeling/observations/adapters.py +195 -0
  83. sensor_modeling/observations/ingest.py +269 -0
  84. sensor_modeling/observations/observation.py +270 -0
  85. sensor_modeling/observations/registry.py +262 -0
  86. sensor_modeling/observations/stream.py +342 -0
  87. sensor_modeling/observations/types.py +107 -0
  88. sensor_modeling/observations/units.py +117 -0
  89. sensor_modeling/online/__init__.py +36 -0
  90. sensor_modeling/online/benchmarks.py +242 -0
  91. sensor_modeling/online/pipeline.py +485 -0
  92. sensor_modeling/simulation/__init__.py +54 -0
  93. sensor_modeling/simulation/faults.py +191 -0
  94. sensor_modeling/simulation/household.py +862 -0
  95. sensor_modeling/states/__init__.py +23 -0
  96. sensor_modeling/states/markov.py +105 -0
  97. sensor_modeling/states/ontology.py +238 -0
  98. sensor_modeling/utils/__init__.py +41 -0
  99. sensor_modeling/utils/data_io.py +199 -0
  100. sensor_modeling/utils/logging_config.py +10 -0
  101. sensor_modeling/utils/missing.py +188 -0
  102. sensor_modeling/utils/plotting.py +98 -0
  103. sensor_modeling/utils/validation.py +117 -0
  104. sensor_modeling/visualization/__init__.py +3 -0
  105. sensor_modeling/visualization/clinical.py +67 -0
  106. sensor_modeling/visualization/interactive.py +208 -0
  107. sensor_modeling/visualization/research.py +60 -0
  108. sensor_modeling/visualization/web_app.py +137 -0
  109. sensor_modeling-0.2.0.dist-info/METADATA +683 -0
  110. sensor_modeling-0.2.0.dist-info/RECORD +114 -0
  111. sensor_modeling-0.2.0.dist-info/WHEEL +5 -0
  112. sensor_modeling-0.2.0.dist-info/entry_points.txt +18 -0
  113. sensor_modeling-0.2.0.dist-info/licenses/LICENSE +21 -0
  114. 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