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,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from .utils import type_check
|
|
8
|
+
|
|
9
|
+
Array1D = np.ndarray
|
|
10
|
+
Array2D = np.ndarray
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def open_uniform_knots(
|
|
14
|
+
delta: float, degree: int, n_basis: int, internal_knots: Optional[Array1D]
|
|
15
|
+
) -> Array1D:
|
|
16
|
+
"""
|
|
17
|
+
Construct an open (clamped) knot vector on [0, delta].
|
|
18
|
+
|
|
19
|
+
If internal_knots is provided, then:
|
|
20
|
+
n_basis == len(internal_knots) + degree + 1
|
|
21
|
+
and we place degree+1 repeats at boundaries.
|
|
22
|
+
|
|
23
|
+
Returns
|
|
24
|
+
-------
|
|
25
|
+
knots : np.ndarray, shape (n_basis + degree + 1,)
|
|
26
|
+
"""
|
|
27
|
+
type_check(delta > 0, "delta must be positive.")
|
|
28
|
+
type_check(degree >= 0, "degree must be non-negative.")
|
|
29
|
+
|
|
30
|
+
if internal_knots is not None:
|
|
31
|
+
internal_knots = np.asarray(internal_knots, dtype=float)
|
|
32
|
+
type_check(
|
|
33
|
+
np.all((internal_knots > 0) & (internal_knots < delta)),
|
|
34
|
+
"internal_knots must be in (0, delta).",
|
|
35
|
+
)
|
|
36
|
+
expected = internal_knots.size + degree + 1
|
|
37
|
+
type_check(
|
|
38
|
+
n_basis == expected,
|
|
39
|
+
f"n_basis must equal len(internal_knots)+degree+1 = {expected}.",
|
|
40
|
+
)
|
|
41
|
+
breaks = np.r_[0.0, internal_knots, delta]
|
|
42
|
+
else:
|
|
43
|
+
n_internal = n_basis - degree - 1
|
|
44
|
+
type_check(n_internal >= 0, "n_basis must be at least degree+1.")
|
|
45
|
+
if n_internal > 0:
|
|
46
|
+
internal = np.linspace(0.0, delta, n_internal + 2)[1:-1]
|
|
47
|
+
breaks = np.r_[0.0, internal, delta]
|
|
48
|
+
else:
|
|
49
|
+
breaks = np.array([0.0, delta], dtype=float)
|
|
50
|
+
|
|
51
|
+
t0 = np.repeat(breaks[0], degree + 1)
|
|
52
|
+
t1 = np.repeat(breaks[-1], degree + 1)
|
|
53
|
+
return np.r_[t0, breaks[1:-1], t1]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _basis_at_scalar(t: float, degree: int, knots: Array1D) -> Array1D:
|
|
57
|
+
"""Cox–de Boor recursion for all basis functions at a scalar t."""
|
|
58
|
+
M = knots.size
|
|
59
|
+
n_basis = M - degree - 1
|
|
60
|
+
N = np.zeros((degree + 1, n_basis), dtype=float)
|
|
61
|
+
|
|
62
|
+
# degree 0
|
|
63
|
+
for i in range(n_basis):
|
|
64
|
+
if knots[i] <= t < knots[i + 1] or (
|
|
65
|
+
t == knots[-1] and knots[i] <= t <= knots[i + 1]
|
|
66
|
+
):
|
|
67
|
+
N[0, i] = 1.0
|
|
68
|
+
|
|
69
|
+
# recursion
|
|
70
|
+
for k in range(1, degree + 1):
|
|
71
|
+
for i in range(n_basis):
|
|
72
|
+
left_den = knots[i + k] - knots[i]
|
|
73
|
+
right_den = knots[i + k + 1] - knots[i + 1]
|
|
74
|
+
left = 0.0 if left_den <= 0 else (t - knots[i]) / left_den * N[k - 1, i]
|
|
75
|
+
right = (
|
|
76
|
+
0.0
|
|
77
|
+
if right_den <= 0
|
|
78
|
+
else (knots[i + k + 1] - t) / right_den * N[k - 1, i + 1]
|
|
79
|
+
if i + 1 < n_basis
|
|
80
|
+
else 0.0
|
|
81
|
+
)
|
|
82
|
+
N[k, i] = left + right
|
|
83
|
+
|
|
84
|
+
return N[degree, :]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def bspline_design_matrix(ts: Array1D, degree: int, knots: Array1D) -> Array2D:
|
|
88
|
+
"""
|
|
89
|
+
Evaluate B-spline basis at vector ts (shape (m,)),
|
|
90
|
+
returning matrix Psi with shape (m, n_basis).
|
|
91
|
+
"""
|
|
92
|
+
ts = np.asarray(ts, dtype=float)
|
|
93
|
+
out = np.empty((ts.size, knots.size - degree - 1), dtype=float)
|
|
94
|
+
for r, t in enumerate(ts):
|
|
95
|
+
out[r, :] = _basis_at_scalar(float(t), degree, knots)
|
|
96
|
+
return out
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
import matplotlib.pyplot as plt
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from .diagnostics import DiagConfig, plot_time_rescaling_diagnostics
|
|
12
|
+
from .model import NHPPPELT, NHPPConfig
|
|
13
|
+
from .plotting import (
|
|
14
|
+
HistConfig,
|
|
15
|
+
PlotConfig,
|
|
16
|
+
plot_segments_and_intensities_with_histograms,
|
|
17
|
+
)
|
|
18
|
+
from .utils import save_results_json
|
|
19
|
+
|
|
20
|
+
Array1D = np.ndarray
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# --------------------------- I/O helpers ---------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _parse_grid(expr: str) -> List[float]:
|
|
27
|
+
"""
|
|
28
|
+
Parse a numeric grid:
|
|
29
|
+
- comma list: 0,0.001,0.01
|
|
30
|
+
- range: start:stop:step (inclusive end within tolerance)
|
|
31
|
+
"""
|
|
32
|
+
if ":" in expr:
|
|
33
|
+
a, b, c = expr.split(":")
|
|
34
|
+
start, stop, step = float(a), float(b), float(c)
|
|
35
|
+
if step <= 0:
|
|
36
|
+
raise ValueError("step must be > 0")
|
|
37
|
+
vals, x = [], start
|
|
38
|
+
while x <= stop + 1e-12:
|
|
39
|
+
vals.append(x)
|
|
40
|
+
x += step
|
|
41
|
+
return vals
|
|
42
|
+
return [float(x) for x in expr.split(",") if x.strip()]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _load_npz_days(path: str) -> List[Array1D]:
|
|
46
|
+
"""
|
|
47
|
+
Load days from a .npz created as:
|
|
48
|
+
np.savez(path, days=np.array(list_of_arrays, dtype=object))
|
|
49
|
+
"""
|
|
50
|
+
with np.load(path, allow_pickle=True) as data:
|
|
51
|
+
if "days" not in data:
|
|
52
|
+
raise ValueError("NPZ must contain array 'days' (dtype=object).")
|
|
53
|
+
arr = data["days"]
|
|
54
|
+
if arr.dtype != object:
|
|
55
|
+
raise ValueError("NPZ 'days' must be an object array of 1-D float arrays.")
|
|
56
|
+
return [np.asarray(x, float) for x in arr.tolist()]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _parse_betas(expr: str) -> List[float]:
|
|
60
|
+
"""
|
|
61
|
+
Parse betas:
|
|
62
|
+
- Comma list: 10,20,40
|
|
63
|
+
- Range: start:stop:step (inclusive-ish, step > 0)
|
|
64
|
+
"""
|
|
65
|
+
if ":" in expr:
|
|
66
|
+
a, b, c = expr.split(":")
|
|
67
|
+
start, stop, step = float(a), float(b), float(c)
|
|
68
|
+
if step <= 0:
|
|
69
|
+
raise ValueError("step must be > 0")
|
|
70
|
+
vals = []
|
|
71
|
+
x = start
|
|
72
|
+
# include stop within tolerance
|
|
73
|
+
while x <= stop + 1e-12:
|
|
74
|
+
vals.append(x)
|
|
75
|
+
x += step
|
|
76
|
+
return vals
|
|
77
|
+
return [float(x) for x in expr.split(",") if x.strip()]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# --------------------------- Subcommands ---------------------------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cmd_fit(args: argparse.Namespace) -> None:
|
|
84
|
+
days = _load_npz_days(args.input)
|
|
85
|
+
cfg = NHPPConfig(
|
|
86
|
+
delta=args.delta,
|
|
87
|
+
degree=args.degree,
|
|
88
|
+
n_basis=args.n_basis,
|
|
89
|
+
knot_strategy=args.knot_strategy,
|
|
90
|
+
min_seg_len=args.min_seg_len,
|
|
91
|
+
penalty_beta=(None if args.beta is None else float(args.beta)),
|
|
92
|
+
)
|
|
93
|
+
model = NHPPPELT(cfg).fit(days)
|
|
94
|
+
print("Changepoints:", model.changepoints_)
|
|
95
|
+
print("Segments:", model.segments_)
|
|
96
|
+
|
|
97
|
+
if args.json_out:
|
|
98
|
+
os.makedirs(os.path.dirname(args.json_out) or ".", exist_ok=True)
|
|
99
|
+
save_results_json(model, args.json_out)
|
|
100
|
+
print(f"Saved JSON to {args.json_out}")
|
|
101
|
+
|
|
102
|
+
if args.plot_out:
|
|
103
|
+
fig = plot_segments_and_intensities_with_histograms(
|
|
104
|
+
days,
|
|
105
|
+
model,
|
|
106
|
+
config=PlotConfig(grid_points=args.grid_points),
|
|
107
|
+
hist=HistConfig(bins="fd", density=True, alpha=0.4),
|
|
108
|
+
save_path=args.plot_out,
|
|
109
|
+
)
|
|
110
|
+
plt.close(fig)
|
|
111
|
+
print(f"Saved plot to {args.plot_out}")
|
|
112
|
+
|
|
113
|
+
if args.diag_out:
|
|
114
|
+
fig = plot_time_rescaling_diagnostics(
|
|
115
|
+
days,
|
|
116
|
+
model,
|
|
117
|
+
config=DiagConfig(grid_points=args.grid_points),
|
|
118
|
+
save_path=args.diag_out,
|
|
119
|
+
)
|
|
120
|
+
plt.close(fig)
|
|
121
|
+
print(f"Saved diagnostics to {args.diag_out}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def cmd_sweep_beta(args: argparse.Namespace) -> None:
|
|
125
|
+
from .utils import sweep_beta
|
|
126
|
+
|
|
127
|
+
days = _load_npz_days(args.input)
|
|
128
|
+
base_cfg = NHPPConfig(
|
|
129
|
+
delta=args.delta,
|
|
130
|
+
degree=args.degree,
|
|
131
|
+
n_basis=args.n_basis,
|
|
132
|
+
knot_strategy=args.knot_strategy,
|
|
133
|
+
min_seg_len=args.min_seg_len,
|
|
134
|
+
penalty_beta=None,
|
|
135
|
+
)
|
|
136
|
+
betas = _parse_betas(args.betas)
|
|
137
|
+
results = sweep_beta(days, base_cfg, betas)
|
|
138
|
+
# Print as JSON lines for easy piping
|
|
139
|
+
for beta, n_cp, total in results:
|
|
140
|
+
print(json.dumps({"beta": beta, "n_changepoints": n_cp, "total_cost": total}))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def cmd_sweep_gamma(args: argparse.Namespace) -> None:
|
|
144
|
+
from .model import NHPPConfig
|
|
145
|
+
from .regularization import sweep_gamma
|
|
146
|
+
|
|
147
|
+
days = _load_npz_days(args.input)
|
|
148
|
+
base_cfg = NHPPConfig(
|
|
149
|
+
delta=args.delta,
|
|
150
|
+
degree=args.degree,
|
|
151
|
+
n_basis=args.n_basis,
|
|
152
|
+
knot_strategy=args.knot_strategy,
|
|
153
|
+
min_seg_len=args.min_seg_len,
|
|
154
|
+
penalty_beta=(None if args.beta is None else float(args.beta)),
|
|
155
|
+
)
|
|
156
|
+
gammas = _parse_grid(args.gammas)
|
|
157
|
+
results = sweep_gamma(days, base_cfg, gammas, order=args.order)
|
|
158
|
+
for gamma, n_cp, total in results:
|
|
159
|
+
print(json.dumps({"gamma": gamma, "n_changepoints": n_cp, "total_cost": total}))
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# --------------------------- CLI wiring ---------------------------
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
166
|
+
p = argparse.ArgumentParser(
|
|
167
|
+
prog="nhpp",
|
|
168
|
+
description="NHPP segmentation with B-splines & PELT (fit/plot/diagnostics).",
|
|
169
|
+
)
|
|
170
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
171
|
+
|
|
172
|
+
# fit
|
|
173
|
+
pf = sub.add_parser("fit", help="Fit model to days and optionally save plots/JSON.")
|
|
174
|
+
pf.add_argument(
|
|
175
|
+
"--input", required=True, help="Path to .npz with object array 'days'."
|
|
176
|
+
)
|
|
177
|
+
pf.add_argument("--delta", type=float, default=24.0)
|
|
178
|
+
pf.add_argument("--degree", type=int, default=3)
|
|
179
|
+
pf.add_argument("--n-basis", type=int, default=5, dest="n_basis")
|
|
180
|
+
pf.add_argument(
|
|
181
|
+
"--knot-strategy", choices=["quantile", "equispaced"], default="quantile"
|
|
182
|
+
)
|
|
183
|
+
pf.add_argument("--min-seg-len", type=int, default=2, dest="min_seg_len")
|
|
184
|
+
pf.add_argument("--beta", type=float, default=None, help="If omitted, uses SIC.")
|
|
185
|
+
pf.add_argument("--grid-points", type=int, default=500)
|
|
186
|
+
pf.add_argument("--json-out", default=None, help="Save fitted state JSON here.")
|
|
187
|
+
pf.add_argument("--plot-out", default=None, help="Save segmentation plot PNG here.")
|
|
188
|
+
pf.add_argument(
|
|
189
|
+
"--diag-out", default=None, help="Save time-rescaling diagnostics PNG here."
|
|
190
|
+
)
|
|
191
|
+
pf.set_defaults(func=cmd_fit)
|
|
192
|
+
|
|
193
|
+
# sweep-beta
|
|
194
|
+
ps = sub.add_parser("sweep-beta", help="Evaluate different penalties (β).")
|
|
195
|
+
ps.add_argument(
|
|
196
|
+
"--input", required=True, help="Path to .npz with object array 'days'."
|
|
197
|
+
)
|
|
198
|
+
ps.add_argument("--delta", type=float, default=24.0)
|
|
199
|
+
ps.add_argument("--degree", type=int, default=3)
|
|
200
|
+
ps.add_argument("--n-basis", type=int, default=5, dest="n_basis")
|
|
201
|
+
ps.add_argument(
|
|
202
|
+
"--knot-strategy", choices=["quantile", "equispaced"], default="quantile"
|
|
203
|
+
)
|
|
204
|
+
ps.add_argument("--min-seg-len", type=int, default=2, dest="min_seg_len")
|
|
205
|
+
ps.add_argument("--betas", required=True, help="Comma list or start:stop:step.")
|
|
206
|
+
ps.set_defaults(func=cmd_sweep_beta)
|
|
207
|
+
|
|
208
|
+
# sweep-gamma
|
|
209
|
+
pg = sub.add_parser(
|
|
210
|
+
"sweep-gamma", help="Evaluate different P-spline strengths (γ)."
|
|
211
|
+
)
|
|
212
|
+
pg.add_argument(
|
|
213
|
+
"--input", required=True, help="Path to .npz with object array 'days'."
|
|
214
|
+
)
|
|
215
|
+
pg.add_argument("--delta", type=float, default=24.0)
|
|
216
|
+
pg.add_argument("--degree", type=int, default=3)
|
|
217
|
+
pg.add_argument("--n-basis", type=int, default=5, dest="n_basis")
|
|
218
|
+
pg.add_argument(
|
|
219
|
+
"--knot-strategy", choices=["quantile", "equispaced"], default="quantile"
|
|
220
|
+
)
|
|
221
|
+
pg.add_argument("--min-seg-len", type=int, default=2, dest="min_seg_len")
|
|
222
|
+
pg.add_argument("--beta", type=float, default=None, help="If omitted, uses SIC.")
|
|
223
|
+
pg.add_argument(
|
|
224
|
+
"--gammas",
|
|
225
|
+
required=True,
|
|
226
|
+
help="Comma list or start:stop:step, e.g. 0:0.1:0.005",
|
|
227
|
+
)
|
|
228
|
+
pg.add_argument(
|
|
229
|
+
"--order", type=int, default=2, help="Difference order (usually 2)."
|
|
230
|
+
)
|
|
231
|
+
pg.set_defaults(func=cmd_sweep_gamma)
|
|
232
|
+
|
|
233
|
+
return p
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def main() -> None:
|
|
237
|
+
parser = build_parser()
|
|
238
|
+
args = parser.parse_args()
|
|
239
|
+
args.func(args)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
if __name__ == "__main__":
|
|
243
|
+
main()
|
|
@@ -0,0 +1,234 @@
|
|
|
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
|
|
6
|
+
|
|
7
|
+
import matplotlib.pyplot as plt
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
Array1D = np.ndarray
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _check(cond: bool, msg: str) -> None:
|
|
14
|
+
if not cond:
|
|
15
|
+
raise ValueError(msg)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class DiagConfig:
|
|
20
|
+
"""
|
|
21
|
+
Configuration for time-rescaling diagnostics.
|
|
22
|
+
|
|
23
|
+
Attributes
|
|
24
|
+
----------
|
|
25
|
+
grid_points : int
|
|
26
|
+
Uniform grid resolution for λ(t) and Λ(t) approximation.
|
|
27
|
+
figsize : tuple[float, float]
|
|
28
|
+
Matplotlib figure size.
|
|
29
|
+
dpi : int
|
|
30
|
+
Figure DPI.
|
|
31
|
+
hist_bins : int
|
|
32
|
+
Number of bins for U-histogram (Uniform(0,1) check).
|
|
33
|
+
alpha : float
|
|
34
|
+
Transparency for histograms and fills.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
grid_points: int = 2000
|
|
38
|
+
figsize: Tuple[float, float] = (12.0, 7.0)
|
|
39
|
+
dpi: int = 110
|
|
40
|
+
hist_bins: int = 20
|
|
41
|
+
alpha: float = 0.5
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _lambda_grid(
|
|
45
|
+
model: Any, seg_index: int, grid_points: int
|
|
46
|
+
) -> Tuple[Array1D, Array1D]:
|
|
47
|
+
"""Compute λ̂_k(t) on a uniform grid for segment k."""
|
|
48
|
+
delta: float = float(model.delta_)
|
|
49
|
+
grid = np.linspace(0.0, delta, grid_points)
|
|
50
|
+
lam = np.asarray(model.intensity_on_grid(seg_index, grid), float)
|
|
51
|
+
_check(lam.shape == grid.shape, "intensity_on_grid must match grid shape.")
|
|
52
|
+
return grid, lam
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _cumulative_int(grid: Array1D, lam: Array1D) -> Array1D:
|
|
56
|
+
"""
|
|
57
|
+
Cumulative integral Λ(t) via the trapezoidal rule on a uniform grid.
|
|
58
|
+
Returns array of same length as grid with Λ(0)=0.
|
|
59
|
+
"""
|
|
60
|
+
# Guard
|
|
61
|
+
_check(
|
|
62
|
+
grid.ndim == 1 and lam.ndim == 1 and grid.size == lam.size,
|
|
63
|
+
"grid/lam shape mismatch.",
|
|
64
|
+
)
|
|
65
|
+
d = np.diff(grid)
|
|
66
|
+
# trapezoid increments: ∫ lam ~ 0.5*(lam_i + lam_{i+1})*Δ
|
|
67
|
+
incr = 0.5 * (lam[:-1] + lam[1:]) * d
|
|
68
|
+
return np.r_[0.0, np.cumsum(incr)]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _interp_cumint(t: Array1D, grid: Array1D, Lambda: Array1D) -> Array1D:
|
|
72
|
+
"""
|
|
73
|
+
Interpolate Λ(t) for arbitrary event times using linear interpolation
|
|
74
|
+
over the precomputed (grid, Λ(grid)).
|
|
75
|
+
"""
|
|
76
|
+
t = np.asarray(t, float)
|
|
77
|
+
t = np.clip(t, grid[0], grid[-1])
|
|
78
|
+
return np.interp(t, grid, Lambda)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def time_rescale_events_for_segment(
|
|
82
|
+
model: Any,
|
|
83
|
+
seg_index: int,
|
|
84
|
+
events_per_day: Sequence[Array1D],
|
|
85
|
+
grid_points: int = 2000,
|
|
86
|
+
) -> Tuple[List[Array1D], List[Array1D]]:
|
|
87
|
+
"""
|
|
88
|
+
Apply the time-rescaling theorem *within a fitted segment*:
|
|
89
|
+
ΔΛ_j should be i.i.d. Exp(1);
|
|
90
|
+
U_j = 1 - exp(-ΔΛ_j) should be i.i.d. Uniform(0,1).
|
|
91
|
+
|
|
92
|
+
Parameters
|
|
93
|
+
----------
|
|
94
|
+
model : fitted NHPPPELT-like object
|
|
95
|
+
seg_index : int
|
|
96
|
+
Segment index in 0..K-1.
|
|
97
|
+
events_per_day : list of 1-D arrays
|
|
98
|
+
Only the days that belong to this segment.
|
|
99
|
+
grid_points : int
|
|
100
|
+
Resolution for Λ approximation.
|
|
101
|
+
|
|
102
|
+
Returns
|
|
103
|
+
-------
|
|
104
|
+
delta_L_list : list[np.ndarray]
|
|
105
|
+
For each day, the Exp(1)-should-be inter-arrival ΔΛ sequence.
|
|
106
|
+
U_list : list[np.ndarray]
|
|
107
|
+
Uniform(0,1)-should-be values 1 - exp(-ΔΛ).
|
|
108
|
+
"""
|
|
109
|
+
grid, lam = _lambda_grid(model, seg_index, grid_points)
|
|
110
|
+
Lambda = _cumulative_int(grid, lam)
|
|
111
|
+
|
|
112
|
+
delta_L_list: List[Array1D] = []
|
|
113
|
+
U_list: List[Array1D] = []
|
|
114
|
+
|
|
115
|
+
for ev in events_per_day:
|
|
116
|
+
ev = np.asarray(ev, float)
|
|
117
|
+
if ev.size == 0:
|
|
118
|
+
delta_L_list.append(np.array([], float))
|
|
119
|
+
U_list.append(np.array([], float))
|
|
120
|
+
continue
|
|
121
|
+
# Rescaled event times Λ(t_j)
|
|
122
|
+
L_times = _interp_cumint(ev, grid, Lambda)
|
|
123
|
+
# Inter-arrival increments; include leading gap from 0 to first event
|
|
124
|
+
L_with0 = np.r_[0.0, L_times]
|
|
125
|
+
dL = np.diff(L_with0)
|
|
126
|
+
U = 1.0 - np.exp(-dL)
|
|
127
|
+
delta_L_list.append(dL)
|
|
128
|
+
U_list.append(U)
|
|
129
|
+
|
|
130
|
+
return delta_L_list, U_list
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def ks_stat_uniform(u: Array1D) -> float:
|
|
134
|
+
"""
|
|
135
|
+
Kolmogorov-Smirnov statistic for Uniform(0,1).
|
|
136
|
+
(Asymptotic p-value can be added if needed; we return only D.)
|
|
137
|
+
"""
|
|
138
|
+
u = np.sort(np.asarray(u, float))
|
|
139
|
+
n = u.size
|
|
140
|
+
if n == 0:
|
|
141
|
+
return np.nan
|
|
142
|
+
# D+ = max_i i/n - u_i ; D- = max_i u_i - (i-1)/n
|
|
143
|
+
i = np.arange(1, n + 1, dtype=float)
|
|
144
|
+
d_plus = np.max(i / n - u)
|
|
145
|
+
d_minus = np.max(u - (i - 1.0) / n)
|
|
146
|
+
return float(max(d_plus, d_minus))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def plot_time_rescaling_diagnostics(
|
|
150
|
+
days: Sequence[Array1D],
|
|
151
|
+
model: Any,
|
|
152
|
+
config: DiagConfig = DiagConfig(),
|
|
153
|
+
*,
|
|
154
|
+
save_path: Optional[str] = None,
|
|
155
|
+
) -> plt.Figure:
|
|
156
|
+
"""
|
|
157
|
+
Build a 2-row diagnostics figure per segment:
|
|
158
|
+
Row 1 (left-to-right over segments): histogram of U with Uniform(0,1) reference line.
|
|
159
|
+
Row 2: exponential QQ plot for ΔΛ vs Exp(1) quantiles.
|
|
160
|
+
|
|
161
|
+
Notes
|
|
162
|
+
-----
|
|
163
|
+
- No external stats deps; uniform line and exponential quantiles are computed analytically.
|
|
164
|
+
- Uses Matplotlib defaults for colors.
|
|
165
|
+
"""
|
|
166
|
+
_check(
|
|
167
|
+
hasattr(model, "segments_") and hasattr(model, "delta_"),
|
|
168
|
+
"Model missing required attributes.",
|
|
169
|
+
)
|
|
170
|
+
segments: List[Tuple[int, int]] = list(model.segments_)
|
|
171
|
+
n_segments = len(segments)
|
|
172
|
+
_check(n_segments >= 1, "No segments found.")
|
|
173
|
+
|
|
174
|
+
# Group events per segment
|
|
175
|
+
seg_days: List[List[Array1D]] = []
|
|
176
|
+
for i_start, i_end in segments:
|
|
177
|
+
seg_days.append([np.asarray(d, float) for d in days[i_start - 1 : i_end]])
|
|
178
|
+
|
|
179
|
+
# Precompute transformed values
|
|
180
|
+
all_U: List[Array1D] = []
|
|
181
|
+
all_dL: List[Array1D] = []
|
|
182
|
+
for k, evs in enumerate(seg_days):
|
|
183
|
+
dL_list, U_list = time_rescale_events_for_segment(
|
|
184
|
+
model, k, evs, grid_points=config.grid_points
|
|
185
|
+
)
|
|
186
|
+
all_U.append(np.concatenate(U_list) if U_list else np.array([], float))
|
|
187
|
+
all_dL.append(np.concatenate(dL_list) if dL_list else np.array([], float))
|
|
188
|
+
|
|
189
|
+
# Figure layout: 2 rows × K columns
|
|
190
|
+
fig = plt.figure(figsize=config.figsize, dpi=config.dpi)
|
|
191
|
+
gs = fig.add_gridspec(nrows=2, ncols=n_segments, hspace=0.35, wspace=0.2)
|
|
192
|
+
|
|
193
|
+
# Row 1: U histograms
|
|
194
|
+
for k in range(n_segments):
|
|
195
|
+
ax = fig.add_subplot(gs[0, k])
|
|
196
|
+
u = all_U[k]
|
|
197
|
+
# Histogram (counts normalized to density); no explicit color
|
|
198
|
+
ax.hist(
|
|
199
|
+
u, bins=config.hist_bins, range=(0.0, 1.0), density=True, alpha=config.alpha
|
|
200
|
+
)
|
|
201
|
+
# Uniform(0,1) reference density = 1 on [0,1]
|
|
202
|
+
ax.plot([0.0, 1.0], [1.0, 1.0]) # default color/style
|
|
203
|
+
D = ks_stat_uniform(u)
|
|
204
|
+
ax.set_title(f"Seg {k+1} — U histogram (KS D={D:.3f})", fontsize=10)
|
|
205
|
+
ax.set_xlim(0.0, 1.0)
|
|
206
|
+
ax.set_xlabel("U = 1 - exp(-ΔΛ)")
|
|
207
|
+
if k == 0:
|
|
208
|
+
ax.set_ylabel("Density")
|
|
209
|
+
ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.5)
|
|
210
|
+
|
|
211
|
+
# Row 2: Exponential QQ (ΔΛ vs Exp(1))
|
|
212
|
+
for k in range(n_segments):
|
|
213
|
+
ax = fig.add_subplot(gs[1, k])
|
|
214
|
+
dL = np.sort(all_dL[k])
|
|
215
|
+
n = dL.size
|
|
216
|
+
if n >= 2:
|
|
217
|
+
# Theoretical Exp(1) quantiles for p_i = (i-0.5)/n
|
|
218
|
+
p = (np.arange(1, n + 1) - 0.5) / n
|
|
219
|
+
q = -np.log(1.0 - p)
|
|
220
|
+
ax.plot(q, dL, linestyle="None", marker="o", markersize=3, alpha=0.8)
|
|
221
|
+
# y=x reference
|
|
222
|
+
lim = float(max(q[-1], dL[-1])) if n > 0 else 1.0
|
|
223
|
+
ax.plot([0.0, lim], [0.0, lim])
|
|
224
|
+
ax.set_title(f"Seg {k+1} — QQ: ΔΛ vs Exp(1)", fontsize=10)
|
|
225
|
+
ax.set_xlabel("Theoretical Exp(1) quantiles")
|
|
226
|
+
if k == 0:
|
|
227
|
+
ax.set_ylabel("Observed ΔΛ")
|
|
228
|
+
ax.grid(True, linestyle="--", linewidth=0.5, alpha=0.5)
|
|
229
|
+
|
|
230
|
+
fig.suptitle("Time-rescaling diagnostics per segment", y=0.99, fontsize=12)
|
|
231
|
+
fig.tight_layout()
|
|
232
|
+
if save_path:
|
|
233
|
+
fig.savefig(save_path, dpi=config.dpi, bbox_inches="tight")
|
|
234
|
+
return fig
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import glob
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from typing import List
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
Array1D = np.ndarray
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_days_from_npy_folder(folder: str) -> List[Array1D]:
|
|
15
|
+
"""
|
|
16
|
+
Load days from a folder of .npy files.
|
|
17
|
+
Each .npy must contain a 1-D float array of event times in [0, delta].
|
|
18
|
+
Files are sorted by filename to define day order.
|
|
19
|
+
"""
|
|
20
|
+
paths = sorted(glob.glob(os.path.join(folder, "*.npy")))
|
|
21
|
+
days: List[Array1D] = []
|
|
22
|
+
for p in paths:
|
|
23
|
+
arr = np.load(p)
|
|
24
|
+
if arr.ndim != 1:
|
|
25
|
+
raise ValueError(f"{p}: expected 1-D array.")
|
|
26
|
+
days.append(arr.astype(float, copy=False))
|
|
27
|
+
return days
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_days_from_csv_folder(folder: str, column: str = "time") -> List[Array1D]:
|
|
31
|
+
"""
|
|
32
|
+
Load days from a folder of .csv files (one file per day).
|
|
33
|
+
The CSV must have a header row including `column` with event times (floats).
|
|
34
|
+
Files are sorted by filename to define day order.
|
|
35
|
+
"""
|
|
36
|
+
paths = sorted(glob.glob(os.path.join(folder, "*.csv")))
|
|
37
|
+
days: List[Array1D] = []
|
|
38
|
+
for p in paths:
|
|
39
|
+
vals: List[float] = []
|
|
40
|
+
with open(p, newline="") as f:
|
|
41
|
+
reader = csv.DictReader(f)
|
|
42
|
+
if reader.fieldnames is None or column not in reader.fieldnames:
|
|
43
|
+
raise ValueError(f"{p}: missing '{column}' column.")
|
|
44
|
+
for row in reader:
|
|
45
|
+
try:
|
|
46
|
+
vals.append(float(row[column]))
|
|
47
|
+
except (TypeError, ValueError):
|
|
48
|
+
continue # skip malformed rows
|
|
49
|
+
days.append(np.asarray(vals, float))
|
|
50
|
+
return days
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def save_days_npz(days: Sequence[Array1D], path: str) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Save list of days to an .npz that works with nhpp.cli (--input).
|
|
56
|
+
"""
|
|
57
|
+
obj = np.array([np.asarray(d, float) for d in days], dtype=object)
|
|
58
|
+
np.savez(path, days=obj)
|