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,218 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, List, Optional, Tuple, Union
|
|
6
|
+
|
|
7
|
+
import matplotlib.pyplot as plt
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from .utils import type_check
|
|
11
|
+
|
|
12
|
+
Array1D = np.ndarray
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class PlotConfig:
|
|
17
|
+
"""Styling for the raster (top) and λ̂ curves (middle)."""
|
|
18
|
+
|
|
19
|
+
figsize: Tuple[float, float] = (12.0, 8.5)
|
|
20
|
+
dpi: int = 110
|
|
21
|
+
raster_markersize: float = 4.0
|
|
22
|
+
raster_alpha: float = 0.9
|
|
23
|
+
band_alpha: float = 0.08
|
|
24
|
+
grid_points: int = 400
|
|
25
|
+
title_top: str = "Daily event raster with segment bands"
|
|
26
|
+
title_bottom: str = "Estimated segment intensities λ̂(t)"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class HistConfig:
|
|
31
|
+
"""Config for per-segment average histograms (bottom row)."""
|
|
32
|
+
|
|
33
|
+
bins: Union[int, str] = "fd" # 'fd' | 'sqrt' | 'sturges' | int
|
|
34
|
+
density: bool = True # True → average rate (events/day/unit-time)
|
|
35
|
+
alpha: float = 0.5
|
|
36
|
+
title: str = "Per-segment average histograms of event times"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _common_bin_edges(
|
|
40
|
+
all_events: Array1D, delta: float, bins: Union[int, str]
|
|
41
|
+
) -> Array1D:
|
|
42
|
+
type_check(delta > 0, "delta must be positive.")
|
|
43
|
+
all_events = np.asarray(all_events, float)
|
|
44
|
+
all_events = all_events[(all_events >= 0.0) & (all_events <= delta)]
|
|
45
|
+
|
|
46
|
+
if isinstance(bins, int):
|
|
47
|
+
type_check(bins >= 1, "bins must be >= 1.")
|
|
48
|
+
return np.linspace(0.0, delta, bins + 1)
|
|
49
|
+
|
|
50
|
+
if all_events.size == 0:
|
|
51
|
+
return np.linspace(0.0, delta, 21)
|
|
52
|
+
|
|
53
|
+
if bins == "fd":
|
|
54
|
+
q25, q75 = np.quantile(all_events, [0.25, 0.75])
|
|
55
|
+
iqr = max(q75 - q25, 1e-12)
|
|
56
|
+
h = 2.0 * iqr / np.cbrt(all_events.size)
|
|
57
|
+
k = 20 if (not np.isfinite(h) or h <= 0) else max(1, int(np.ceil(delta / h)))
|
|
58
|
+
return np.linspace(0.0, delta, k + 1)
|
|
59
|
+
if bins == "sqrt":
|
|
60
|
+
k = max(1, int(np.ceil(np.sqrt(all_events.size))))
|
|
61
|
+
return np.linspace(0.0, delta, k + 1)
|
|
62
|
+
if bins == "sturges":
|
|
63
|
+
k = max(1, int(np.ceil(np.log2(all_events.size) + 1.0)))
|
|
64
|
+
return np.linspace(0.0, delta, k + 1)
|
|
65
|
+
raise ValueError("bins must be int or one of {'fd','sqrt','sturges'}.")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _avg_hist_per_segment(
|
|
69
|
+
days: Sequence[Array1D],
|
|
70
|
+
segments: Sequence[Tuple[int, int]],
|
|
71
|
+
edges: Array1D,
|
|
72
|
+
density: bool,
|
|
73
|
+
) -> List[Array1D]:
|
|
74
|
+
"""
|
|
75
|
+
Average per-day histogram for each segment, using common bin edges.
|
|
76
|
+
If `density` is True, convert counts/bin/day to rate by dividing by bin width.
|
|
77
|
+
"""
|
|
78
|
+
edges = np.asarray(edges, float)
|
|
79
|
+
type_check(np.all(np.diff(edges) > 0), "edges must be strictly increasing.")
|
|
80
|
+
|
|
81
|
+
widths = np.diff(edges)
|
|
82
|
+
avg_counts: List[Array1D] = []
|
|
83
|
+
|
|
84
|
+
for i_start, i_end in segments:
|
|
85
|
+
type_check(1 <= i_start <= i_end <= len(days), "segment indices out of range.")
|
|
86
|
+
L = i_end - i_start + 1
|
|
87
|
+
|
|
88
|
+
acc = np.zeros(edges.size - 1, dtype=float)
|
|
89
|
+
for d in range(i_start - 1, i_end):
|
|
90
|
+
ev = np.asarray(days[d], float)
|
|
91
|
+
if ev.size == 0:
|
|
92
|
+
continue
|
|
93
|
+
ev = ev[(ev >= edges[0]) & (ev <= edges[-1])]
|
|
94
|
+
counts, _ = np.histogram(ev, bins=edges)
|
|
95
|
+
acc += counts.astype(float)
|
|
96
|
+
|
|
97
|
+
mean_per_day = acc / max(L, 1)
|
|
98
|
+
if density:
|
|
99
|
+
mean_per_day = mean_per_day / widths
|
|
100
|
+
avg_counts.append(mean_per_day)
|
|
101
|
+
|
|
102
|
+
return avg_counts
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def plot_segments_and_intensities_with_histograms(
|
|
106
|
+
days: Sequence[Array1D],
|
|
107
|
+
model: Any,
|
|
108
|
+
config: PlotConfig = PlotConfig(),
|
|
109
|
+
hist: HistConfig = HistConfig(),
|
|
110
|
+
*,
|
|
111
|
+
save_path: Optional[str] = None,
|
|
112
|
+
) -> plt.Figure:
|
|
113
|
+
"""
|
|
114
|
+
Three-row figure:
|
|
115
|
+
(1) event raster with segment bands,
|
|
116
|
+
(2) segment λ̂(t) curves (optionally add CI bands outside),
|
|
117
|
+
(3) per-segment average histograms (small multiples).
|
|
118
|
+
"""
|
|
119
|
+
type_check(len(days) >= 1, "days must contain at least one array.")
|
|
120
|
+
need = ["segments_", "knots_", "degree_", "delta_", "P_", "weights_"]
|
|
121
|
+
missing = [a for a in need if not hasattr(model, a)]
|
|
122
|
+
type_check(len(missing) == 0, f"Model is missing attributes: {missing}.")
|
|
123
|
+
type_check(
|
|
124
|
+
hasattr(model, "intensity_on_grid"), "Model must implement intensity_on_grid()."
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
n_days = len(days)
|
|
128
|
+
segments: List[Tuple[int, int]] = list(model.segments_)
|
|
129
|
+
delta: float = float(model.delta_)
|
|
130
|
+
n_segments = len(segments)
|
|
131
|
+
type_check(n_segments >= 1, "Model has no segments.")
|
|
132
|
+
|
|
133
|
+
all_events = (
|
|
134
|
+
np.concatenate([np.asarray(d, float) for d in days])
|
|
135
|
+
if n_days
|
|
136
|
+
else np.array([], float)
|
|
137
|
+
)
|
|
138
|
+
edges = _common_bin_edges(all_events, delta, hist.bins)
|
|
139
|
+
widths = np.diff(edges)
|
|
140
|
+
centers = edges[:-1] + 0.5 * widths
|
|
141
|
+
avg_counts = _avg_hist_per_segment(days, segments, edges, density=hist.density)
|
|
142
|
+
|
|
143
|
+
fig = plt.figure(figsize=config.figsize, dpi=config.dpi)
|
|
144
|
+
gs = fig.add_gridspec(nrows=3, ncols=1, height_ratios=[1.4, 1.0, 1.0], hspace=0.35)
|
|
145
|
+
ax_top = fig.add_subplot(gs[0, 0])
|
|
146
|
+
ax_mid = fig.add_subplot(gs[1, 0])
|
|
147
|
+
|
|
148
|
+
# -------- Top: raster with segment bands --------
|
|
149
|
+
for k, (i_start, i_end) in enumerate(segments):
|
|
150
|
+
y0, y1 = i_start - 0.5, i_end + 0.5
|
|
151
|
+
if k % 2 == 0:
|
|
152
|
+
ax_top.axhspan(y0, y1, alpha=config.band_alpha)
|
|
153
|
+
ax_top.text(
|
|
154
|
+
delta * 1.002,
|
|
155
|
+
(y0 + y1) / 2.0,
|
|
156
|
+
f"Seg {k+1}\n({i_start}–{i_end})",
|
|
157
|
+
va="center",
|
|
158
|
+
ha="left",
|
|
159
|
+
fontsize=9,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
for i, ev in enumerate(days, start=1):
|
|
163
|
+
ev = np.asarray(ev, float)
|
|
164
|
+
if ev.size:
|
|
165
|
+
ax_top.plot(
|
|
166
|
+
ev,
|
|
167
|
+
np.full_like(ev, i, float),
|
|
168
|
+
linestyle="None",
|
|
169
|
+
marker="|",
|
|
170
|
+
markersize=config.raster_markersize,
|
|
171
|
+
alpha=config.raster_alpha,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
ax_top.set_xlim(0.0, delta)
|
|
175
|
+
ax_top.set_ylim(0.5, n_days + 0.5)
|
|
176
|
+
ax_top.set_xlabel("Time within day")
|
|
177
|
+
ax_top.set_ylabel("Day index")
|
|
178
|
+
ax_top.set_title(config.title_top)
|
|
179
|
+
ax_top.grid(True, linestyle="--", linewidth=0.5, alpha=0.5)
|
|
180
|
+
|
|
181
|
+
# -------- Middle: intensity curves (no explicit colors) --------
|
|
182
|
+
grid = np.linspace(0.0, delta, config.grid_points)
|
|
183
|
+
for k, (i_start, i_end) in enumerate(segments):
|
|
184
|
+
lam = np.asarray(model.intensity_on_grid(k, grid), float)
|
|
185
|
+
type_check(lam.shape == grid.shape, "intensity_on_grid must match grid shape.")
|
|
186
|
+
label = f"Seg {k+1} (days {i_start}–{i_end})"
|
|
187
|
+
ax_mid.plot(grid, lam, label=label)
|
|
188
|
+
|
|
189
|
+
ax_mid.set_xlim(0.0, delta)
|
|
190
|
+
ax_mid.set_xlabel("Time within day")
|
|
191
|
+
ax_mid.set_ylabel("Intensity λ(t)")
|
|
192
|
+
ax_mid.set_title(config.title_bottom)
|
|
193
|
+
ax_mid.grid(True, linestyle="--", linewidth=0.5, alpha=0.5)
|
|
194
|
+
ax_mid.legend(loc="best", fontsize=9, frameon=False)
|
|
195
|
+
|
|
196
|
+
# -------- Bottom: per-segment average histograms --------
|
|
197
|
+
subgs = gs[2, 0].subgridspec(1, n_segments, wspace=0.15)
|
|
198
|
+
for k in range(n_segments):
|
|
199
|
+
ax = fig.add_subplot(subgs[0, k])
|
|
200
|
+
ax.bar(centers, avg_counts[k], width=widths, align="center", alpha=hist.alpha)
|
|
201
|
+
ax.set_xlim(0.0, delta)
|
|
202
|
+
if k == 0:
|
|
203
|
+
ax.set_ylabel(
|
|
204
|
+
"Avg rate\n(events/day/unit-time)"
|
|
205
|
+
if hist.density
|
|
206
|
+
else "Avg count/day/bin"
|
|
207
|
+
)
|
|
208
|
+
ax.set_xlabel("Time within day")
|
|
209
|
+
ax.set_title(f"Seg {k+1}: days {segments[k][0]}–{segments[k][1]}", fontsize=10)
|
|
210
|
+
ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.5)
|
|
211
|
+
|
|
212
|
+
fig.text(0.5, 0.04 + 0.03, hist.title, ha="center", va="center", fontsize=11)
|
|
213
|
+
|
|
214
|
+
fig.tight_layout()
|
|
215
|
+
if save_path:
|
|
216
|
+
fig.savefig(save_path, dpi=config.dpi, bbox_inches="tight")
|
|
217
|
+
|
|
218
|
+
return fig
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# nhpp/quad.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Tuple
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from .utils import type_check
|
|
10
|
+
|
|
11
|
+
Array1D = np.ndarray
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class QuadratureConfig:
|
|
16
|
+
"""
|
|
17
|
+
Settings for Gauss–Legendre quadrature used to integrate terms like
|
|
18
|
+
∫ exp(wᵀψ(t)) {1, ψ, ψψᵀ} dt.
|
|
19
|
+
|
|
20
|
+
Attributes
|
|
21
|
+
----------
|
|
22
|
+
n_points : int
|
|
23
|
+
Order of the Gauss–Legendre rule (≥ 2). Larger → more accurate, slower.
|
|
24
|
+
ridge : float
|
|
25
|
+
Small diagonal ridge added to the Hessian to ensure positive definiteness.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
n_points: int = 256
|
|
29
|
+
ridge: float = 1e-8
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def leggauss_on_interval(a: float, b: float, n: int) -> Tuple[Array1D, Array1D]:
|
|
33
|
+
"""
|
|
34
|
+
Compute Gauss–Legendre nodes and weights on a closed interval [a, b].
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
a : float
|
|
39
|
+
Interval start (can equal b for a degenerate interval; not useful here).
|
|
40
|
+
b : float
|
|
41
|
+
Interval end; must satisfy b > a in practice.
|
|
42
|
+
n : int
|
|
43
|
+
Number of quadrature points (order). Must be ≥ 2.
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
nodes : np.ndarray, shape (n,)
|
|
48
|
+
Quadrature abscissae in [a, b].
|
|
49
|
+
weights : np.ndarray, shape (n,)
|
|
50
|
+
Corresponding quadrature weights that integrate polynomials of degree 2n-1 exactly.
|
|
51
|
+
|
|
52
|
+
Notes
|
|
53
|
+
-----
|
|
54
|
+
We obtain nodes/weights on [-1, 1] via `numpy.polynomial.legendre.leggauss`
|
|
55
|
+
and map them affinely to [a, b].
|
|
56
|
+
"""
|
|
57
|
+
type_check(n >= 2, "Quadrature order n must be >= 2.")
|
|
58
|
+
type_check(np.isfinite(a) and np.isfinite(b), "Interval bounds must be finite.")
|
|
59
|
+
type_check(b >= a, "Require b >= a.")
|
|
60
|
+
|
|
61
|
+
# Nodes/weights on [-1, 1]
|
|
62
|
+
x, w = np.polynomial.legendre.leggauss(n)
|
|
63
|
+
|
|
64
|
+
# Affine map to [a, b]
|
|
65
|
+
xm = 0.5 * (b + a)
|
|
66
|
+
xr = 0.5 * (b - a)
|
|
67
|
+
nodes = xm + xr * x
|
|
68
|
+
weights = xr * w
|
|
69
|
+
return nodes.astype(float, copy=False), weights.astype(float, copy=False)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
__all__ = ["QuadratureConfig", "leggauss_on_interval"]
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Sequence
|
|
4
|
+
from typing import TYPE_CHECKING, List, Tuple
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .model import NHPPConfig
|
|
10
|
+
|
|
11
|
+
Array1D = np.ndarray
|
|
12
|
+
Array2D = np.ndarray
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def difference_matrix(p: int, order: int = 2) -> Array2D:
|
|
16
|
+
"""
|
|
17
|
+
Finite-difference operator D^(order) for P-splines.
|
|
18
|
+
|
|
19
|
+
For p parameters and order=2, returns a (p-2)×p matrix whose rows each
|
|
20
|
+
realize [1, -2, 1] at consecutive positions. More generally:
|
|
21
|
+
D^(k) = diff(I_p, n=k, axis=0).
|
|
22
|
+
|
|
23
|
+
Returns
|
|
24
|
+
-------
|
|
25
|
+
D : np.ndarray, shape (p - order, p)
|
|
26
|
+
"""
|
|
27
|
+
if p <= order:
|
|
28
|
+
raise ValueError("p must be > order.")
|
|
29
|
+
D = np.eye(p, dtype=float)
|
|
30
|
+
for _ in range(order):
|
|
31
|
+
D = np.diff(D, n=1, axis=0)
|
|
32
|
+
return D
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def p_spline_RtR(p: int, order: int = 2, gamma: float = 0.0) -> Array2D:
|
|
36
|
+
"""
|
|
37
|
+
Build γ * (D^T D), where D is the 'order'-th difference operator.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
p : int
|
|
42
|
+
Number of spline coefficients (P).
|
|
43
|
+
order : int
|
|
44
|
+
Difference order (2 for the classic curvature penalty).
|
|
45
|
+
gamma : float
|
|
46
|
+
Penalty strength γ ≥ 0.
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
RtR : np.ndarray, shape (p, p)
|
|
51
|
+
γ * (Dᵀ D). If gamma==0 returns a zero matrix for convenience.
|
|
52
|
+
"""
|
|
53
|
+
if gamma <= 0.0:
|
|
54
|
+
return np.zeros((p, p), dtype=float)
|
|
55
|
+
D = difference_matrix(p, order=order)
|
|
56
|
+
return float(gamma) * (D.T @ D)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def sweep_gamma(
|
|
60
|
+
days: Sequence[np.ndarray],
|
|
61
|
+
base_cfg: NHPPConfig,
|
|
62
|
+
gammas: Iterable[float],
|
|
63
|
+
*,
|
|
64
|
+
order: int = 2,
|
|
65
|
+
) -> List[Tuple[float, int, float]]:
|
|
66
|
+
"""
|
|
67
|
+
Fit across a grid of γ values (P-spline penalty strength) and return:
|
|
68
|
+
(gamma, n_changepoints, total_penalized_cost)
|
|
69
|
+
|
|
70
|
+
Notes
|
|
71
|
+
-----
|
|
72
|
+
- Uses the model's existing penalty_beta (SIC if None).
|
|
73
|
+
- The reported total cost matches the objective: sum segment costs (incl. γ term)
|
|
74
|
+
+ β per segment.
|
|
75
|
+
"""
|
|
76
|
+
# local imports to avoid circular deps
|
|
77
|
+
from .bspline import bspline_design_matrix
|
|
78
|
+
from .model import NHPPPELT, NHPPConfig
|
|
79
|
+
from .optimizer import SegmentOptimizer
|
|
80
|
+
from .quad import QuadratureConfig
|
|
81
|
+
|
|
82
|
+
out: List[Tuple[float, int, float]] = []
|
|
83
|
+
|
|
84
|
+
for g in gammas:
|
|
85
|
+
cfg = NHPPConfig(
|
|
86
|
+
**{
|
|
87
|
+
**base_cfg.__dict__,
|
|
88
|
+
"pspline_gamma": float(g),
|
|
89
|
+
"pspline_order": int(order),
|
|
90
|
+
}
|
|
91
|
+
)
|
|
92
|
+
model = NHPPPELT(cfg).fit(days)
|
|
93
|
+
|
|
94
|
+
# re-accumulate the exact objective with the fitted weights
|
|
95
|
+
total = 0.0
|
|
96
|
+
quad = QuadratureConfig(n_points=cfg.quad.n_points, ridge=cfg.hessian_ridge)
|
|
97
|
+
opt = SegmentOptimizer(
|
|
98
|
+
delta=model.delta_,
|
|
99
|
+
degree=model.degree_,
|
|
100
|
+
knots=model.knots_,
|
|
101
|
+
quad=quad,
|
|
102
|
+
newton=cfg.newton,
|
|
103
|
+
pspline_gamma=cfg.pspline_gamma,
|
|
104
|
+
pspline_order=cfg.pspline_order,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
for (i, j), w in zip(model.segments_, model.weights_):
|
|
108
|
+
# sufficient stats s for [i..j]
|
|
109
|
+
s = np.zeros(cfg.n_basis, dtype=float)
|
|
110
|
+
for d in range(i - 1, j):
|
|
111
|
+
ev = np.asarray(days[d], float)
|
|
112
|
+
if ev.size:
|
|
113
|
+
s += bspline_design_matrix(ev, cfg.degree, model.knots_).sum(axis=0)
|
|
114
|
+
# minimize with warm-start = fitted w to recover cost term
|
|
115
|
+
_, c = opt.minimize(L=j - i + 1, s=s, w0=w)
|
|
116
|
+
total += float(c)
|
|
117
|
+
|
|
118
|
+
total += len(model.segments_) * float(model.beta_)
|
|
119
|
+
out.append((float(g), len(model.changepoints_), total))
|
|
120
|
+
|
|
121
|
+
return out
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Iterable, Sequence
|
|
5
|
+
from typing import TYPE_CHECKING, List, Tuple
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .model import NHPPPELT, NHPPConfig
|
|
11
|
+
|
|
12
|
+
# ------------------------------- Types -------------------------------
|
|
13
|
+
|
|
14
|
+
Array1D = np.ndarray
|
|
15
|
+
Array2D = np.ndarray
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------- Validators ----------------------------
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def type_check(condition: bool, msg: str) -> None:
|
|
22
|
+
"""Runtime guard with a concise error message."""
|
|
23
|
+
if not condition:
|
|
24
|
+
raise ValueError(msg)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# -------------------------- Normal PPF (Φ⁻¹) -------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def normal_ppf(p: float) -> float:
|
|
31
|
+
"""
|
|
32
|
+
Approximate the standard-normal inverse CDF Φ⁻¹(p).
|
|
33
|
+
Accuracy ~ 4e-4 absolute in typical ranges. SciPy-free.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
p : float
|
|
38
|
+
Probability in (0, 1).
|
|
39
|
+
|
|
40
|
+
Returns
|
|
41
|
+
-------
|
|
42
|
+
float
|
|
43
|
+
Approximate quantile.
|
|
44
|
+
|
|
45
|
+
Notes
|
|
46
|
+
-----
|
|
47
|
+
Combines a Beasley–Springer/Wichura-style central approximation
|
|
48
|
+
with a simple tail refinement. Good enough for CI bands.
|
|
49
|
+
"""
|
|
50
|
+
type_check(0.0 < p < 1.0, "p must be in (0,1).")
|
|
51
|
+
# exploit symmetry
|
|
52
|
+
if p > 0.5:
|
|
53
|
+
return -normal_ppf(1.0 - p)
|
|
54
|
+
|
|
55
|
+
# coefficients (central region)
|
|
56
|
+
a = [2.50662823884, -18.61500062529, 41.39119773534, -25.44106049637]
|
|
57
|
+
b = [-8.47351093090, 23.08336743743, -21.06224101826, 3.13082909833]
|
|
58
|
+
|
|
59
|
+
# auxiliary for mild tails
|
|
60
|
+
c = [
|
|
61
|
+
0.3374754822726147,
|
|
62
|
+
0.9761690190917186,
|
|
63
|
+
0.1607979714918209,
|
|
64
|
+
0.0276438810333863,
|
|
65
|
+
0.0038405729373609,
|
|
66
|
+
0.0003951896511919,
|
|
67
|
+
0.0000321767881768,
|
|
68
|
+
0.0000002888167364,
|
|
69
|
+
0.0000003960315187,
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
# central transform
|
|
73
|
+
t = np.sqrt(-2.0 * np.log(p))
|
|
74
|
+
x = t - (((a[3] * t + a[2]) * t + a[1]) * t + a[0]) / (
|
|
75
|
+
(((b[3] * t + b[2]) * t + b[1]) * t + b[0]) * t + 1.0
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# refine for not-too-small p
|
|
79
|
+
if p >= 0.02425:
|
|
80
|
+
q = p - 0.5
|
|
81
|
+
r = q * q
|
|
82
|
+
poly = c[8]
|
|
83
|
+
for k in range(7, -1, -1):
|
|
84
|
+
poly = poly * r + c[k]
|
|
85
|
+
return float(q * poly)
|
|
86
|
+
return float(x)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --------------------------- JSON exporters --------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def to_jsonable(model: NHPPPELT) -> dict:
|
|
93
|
+
"""
|
|
94
|
+
Convert a fitted model into a JSON-serializable dictionary.
|
|
95
|
+
"""
|
|
96
|
+
return {
|
|
97
|
+
"delta": float(model.delta_),
|
|
98
|
+
"degree": int(model.degree_),
|
|
99
|
+
"n_basis": int(model.P_),
|
|
100
|
+
"beta": float(model.beta_),
|
|
101
|
+
"knots": model.knots_.tolist(),
|
|
102
|
+
"changepoints": [int(c) for c in model.changepoints_],
|
|
103
|
+
"segments": [(int(i), int(j)) for (i, j) in model.segments_],
|
|
104
|
+
"weights": [w.astype(float).tolist() for w in model.weights_],
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def save_results_json(model: NHPPPELT, path: str) -> None:
|
|
109
|
+
"""
|
|
110
|
+
Save fitted model parameters/results to a JSON file.
|
|
111
|
+
|
|
112
|
+
Parameters
|
|
113
|
+
----------
|
|
114
|
+
model : NHPPPELT
|
|
115
|
+
Fitted model.
|
|
116
|
+
path : str
|
|
117
|
+
Destination path.
|
|
118
|
+
"""
|
|
119
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
120
|
+
json.dump(to_jsonable(model), f, ensure_ascii=False, indent=2)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ------------------------------ Sweep β ------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def sweep_beta(
|
|
127
|
+
days: Sequence[Array1D],
|
|
128
|
+
base_cfg: NHPPConfig,
|
|
129
|
+
betas: Iterable[float],
|
|
130
|
+
) -> List[Tuple[float, int, float]]:
|
|
131
|
+
"""
|
|
132
|
+
Fit across candidate penalties and return tuples of:
|
|
133
|
+
(beta, n_changepoints, total_penalized_cost)
|
|
134
|
+
|
|
135
|
+
Useful to visualize an elbow/stability curve before fixing β.
|
|
136
|
+
|
|
137
|
+
Notes
|
|
138
|
+
-----
|
|
139
|
+
- Reuses each solution’s segment weights as warm starts to
|
|
140
|
+
recompute exact segment costs for the reported total.
|
|
141
|
+
"""
|
|
142
|
+
from .bspline import bspline_design_matrix
|
|
143
|
+
from .model import NHPPPELT, NHPPConfig
|
|
144
|
+
from .optimizer import QuadratureConfig, SegmentOptimizer
|
|
145
|
+
|
|
146
|
+
results: List[Tuple[float, int, float]] = []
|
|
147
|
+
|
|
148
|
+
for b in betas:
|
|
149
|
+
cfg = NHPPConfig(**{**base_cfg.__dict__, "penalty_beta": float(b)})
|
|
150
|
+
model = NHPPPELT(cfg).fit(days)
|
|
151
|
+
|
|
152
|
+
total_cost = 0.0
|
|
153
|
+
for (i, j), w in zip(model.segments_, model.weights_):
|
|
154
|
+
quad = QuadratureConfig(n_points=cfg.quad.n_points, ridge=cfg.hessian_ridge)
|
|
155
|
+
opt = SegmentOptimizer(
|
|
156
|
+
delta=model.delta_,
|
|
157
|
+
degree=model.degree_,
|
|
158
|
+
knots=model.knots_,
|
|
159
|
+
quad=quad,
|
|
160
|
+
newton=cfg.newton,
|
|
161
|
+
)
|
|
162
|
+
# build sufficient statistics s for days i..j
|
|
163
|
+
s = np.zeros(cfg.n_basis, dtype=float)
|
|
164
|
+
for d in range(i - 1, j):
|
|
165
|
+
ev = np.asarray(days[d], dtype=float)
|
|
166
|
+
if ev.size:
|
|
167
|
+
s += bspline_design_matrix(ev, cfg.degree, model.knots_).sum(axis=0)
|
|
168
|
+
_, c = opt.minimize(L=j - i + 1, s=s, w0=w) # warm start
|
|
169
|
+
total_cost += float(c)
|
|
170
|
+
|
|
171
|
+
total_cost += len(model.segments_) * float(b)
|
|
172
|
+
results.append((float(b), len(model.changepoints_), total_cost))
|
|
173
|
+
|
|
174
|
+
return results
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Canonical, hardware-neutral representation of sensor observations.
|
|
2
|
+
|
|
3
|
+
This package defines the single data model every other stage of the pipeline
|
|
4
|
+
consumes. The central claim it encodes is that a sensor record is *evidence*,
|
|
5
|
+
not behaviour: an :class:`Observation` carries the value, the unit it was
|
|
6
|
+
measured in, the reliability attached to it, and the provenance of any repair
|
|
7
|
+
applied during ingestion, so that later stages can weight it honestly.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .adapters import (
|
|
11
|
+
LegacyConversionError,
|
|
12
|
+
naive_utc,
|
|
13
|
+
observations_from_dataset,
|
|
14
|
+
observations_from_frame,
|
|
15
|
+
observations_from_records,
|
|
16
|
+
)
|
|
17
|
+
from .ingest import (
|
|
18
|
+
ClockOffsetEstimator,
|
|
19
|
+
IngestionReport,
|
|
20
|
+
ObservationIngestor,
|
|
21
|
+
RejectedObservation,
|
|
22
|
+
)
|
|
23
|
+
from .observation import Observation, default_kind, require_aware
|
|
24
|
+
from .registry import (
|
|
25
|
+
SensorContractError,
|
|
26
|
+
SensorRegistry,
|
|
27
|
+
SensorSpec,
|
|
28
|
+
UnknownSensorError,
|
|
29
|
+
)
|
|
30
|
+
from .stream import Gap, ObservationStream
|
|
31
|
+
from .types import Modality, ObservationFlag, ObservationKind
|
|
32
|
+
from .units import Unit, convert, to_canonical
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"ClockOffsetEstimator",
|
|
36
|
+
"Gap",
|
|
37
|
+
"IngestionReport",
|
|
38
|
+
"LegacyConversionError",
|
|
39
|
+
"Modality",
|
|
40
|
+
"Observation",
|
|
41
|
+
"ObservationFlag",
|
|
42
|
+
"ObservationIngestor",
|
|
43
|
+
"ObservationKind",
|
|
44
|
+
"ObservationStream",
|
|
45
|
+
"RejectedObservation",
|
|
46
|
+
"SensorContractError",
|
|
47
|
+
"SensorRegistry",
|
|
48
|
+
"SensorSpec",
|
|
49
|
+
"Unit",
|
|
50
|
+
"UnknownSensorError",
|
|
51
|
+
"convert",
|
|
52
|
+
"default_kind",
|
|
53
|
+
"naive_utc",
|
|
54
|
+
"observations_from_dataset",
|
|
55
|
+
"observations_from_frame",
|
|
56
|
+
"observations_from_records",
|
|
57
|
+
"require_aware",
|
|
58
|
+
"to_canonical",
|
|
59
|
+
]
|