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,188 @@
|
|
|
1
|
+
"""Utilities for handling missing sensor data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class MissingDataResult:
|
|
13
|
+
"""Container for imputed data and uncertainty metadata.
|
|
14
|
+
|
|
15
|
+
Attributes
|
|
16
|
+
----------
|
|
17
|
+
data
|
|
18
|
+
Data after applying the selected missing-data strategy.
|
|
19
|
+
original_missing_mask
|
|
20
|
+
Boolean mask identifying cells that were missing in the input.
|
|
21
|
+
imputed_mask
|
|
22
|
+
Boolean mask identifying cells that were missing originally and were
|
|
23
|
+
filled by the chosen strategy.
|
|
24
|
+
remaining_missing_mask
|
|
25
|
+
Boolean mask identifying cells still missing after processing.
|
|
26
|
+
long_gap_mask
|
|
27
|
+
Boolean mask marking gaps longer than ``max_gap`` when provided.
|
|
28
|
+
summary
|
|
29
|
+
Per-column metadata about missingness, imputation, and longest gaps.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
data: pd.DataFrame
|
|
33
|
+
original_missing_mask: pd.DataFrame
|
|
34
|
+
imputed_mask: pd.DataFrame
|
|
35
|
+
remaining_missing_mask: pd.DataFrame
|
|
36
|
+
long_gap_mask: pd.DataFrame
|
|
37
|
+
summary: dict[str, dict[str, Any]]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _gap_lengths(mask: pd.Series) -> pd.Series:
|
|
41
|
+
"""Return the length of each missing run for every position."""
|
|
42
|
+
lengths = pd.Series(0, index=mask.index, dtype=int)
|
|
43
|
+
run_length = 0
|
|
44
|
+
run_index: list[Any] = []
|
|
45
|
+
for idx, is_missing in mask.items():
|
|
46
|
+
if bool(is_missing):
|
|
47
|
+
run_length += 1
|
|
48
|
+
run_index.append(idx)
|
|
49
|
+
continue
|
|
50
|
+
if run_index:
|
|
51
|
+
lengths.loc[run_index] = run_length
|
|
52
|
+
run_length = 0
|
|
53
|
+
run_index = []
|
|
54
|
+
if run_index:
|
|
55
|
+
lengths.loc[run_index] = run_length
|
|
56
|
+
return lengths
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _long_gap_mask(df: pd.DataFrame, max_gap: int | None) -> pd.DataFrame:
|
|
60
|
+
"""Return a boolean mask for missing runs exceeding ``max_gap``."""
|
|
61
|
+
if max_gap is None:
|
|
62
|
+
return pd.DataFrame(False, index=df.index, columns=df.columns)
|
|
63
|
+
mask = df.isna()
|
|
64
|
+
long_gap = pd.DataFrame(False, index=df.index, columns=df.columns)
|
|
65
|
+
for col in df.columns:
|
|
66
|
+
lengths = _gap_lengths(mask[col])
|
|
67
|
+
long_gap[col] = lengths > max_gap
|
|
68
|
+
return long_gap
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _apply_numeric_interpolation(df: pd.DataFrame, max_gap: int | None) -> pd.DataFrame:
|
|
72
|
+
"""Interpolate numeric columns while leaving unsupported dtypes untouched."""
|
|
73
|
+
result = df.copy()
|
|
74
|
+
numeric_cols = result.select_dtypes(include="number").columns
|
|
75
|
+
if len(numeric_cols) > 0:
|
|
76
|
+
result.loc[:, numeric_cols] = result.loc[:, numeric_cols].interpolate(
|
|
77
|
+
method="linear",
|
|
78
|
+
limit=max_gap,
|
|
79
|
+
limit_area="inside",
|
|
80
|
+
)
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _build_summary(
|
|
85
|
+
original_missing: pd.DataFrame,
|
|
86
|
+
imputed_mask: pd.DataFrame,
|
|
87
|
+
remaining_missing: pd.DataFrame,
|
|
88
|
+
) -> dict[str, dict[str, Any]]:
|
|
89
|
+
"""Summarize missingness and imputation per column."""
|
|
90
|
+
summary: dict[str, dict[str, Any]] = {}
|
|
91
|
+
for col in original_missing.columns:
|
|
92
|
+
gap_lengths = _gap_lengths(original_missing[col])
|
|
93
|
+
summary[col] = {
|
|
94
|
+
"original_missing": int(original_missing[col].sum()),
|
|
95
|
+
"imputed": int(imputed_mask[col].sum()),
|
|
96
|
+
"remaining_missing": int(remaining_missing[col].sum()),
|
|
97
|
+
"longest_gap": int(gap_lengths.max()) if len(gap_lengths) else 0,
|
|
98
|
+
"missing_ratio": float(original_missing[col].mean()),
|
|
99
|
+
}
|
|
100
|
+
return summary
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def handle_missing_data(
|
|
104
|
+
df: pd.DataFrame,
|
|
105
|
+
strategy: str = "forward_fill",
|
|
106
|
+
*,
|
|
107
|
+
max_gap: int | None = None,
|
|
108
|
+
add_indicators: bool = False,
|
|
109
|
+
) -> MissingDataResult:
|
|
110
|
+
"""Apply a missing-data strategy and return imputation metadata.
|
|
111
|
+
|
|
112
|
+
Parameters
|
|
113
|
+
----------
|
|
114
|
+
df
|
|
115
|
+
Sensor data indexed by time.
|
|
116
|
+
strategy
|
|
117
|
+
One of ``"forward_fill"``, ``"interpolate"``, ``"gap_aware"``,
|
|
118
|
+
``"drop"``, or ``"flag"``.
|
|
119
|
+
max_gap
|
|
120
|
+
Maximum consecutive missing samples to fill. Longer gaps remain missing
|
|
121
|
+
for ``"forward_fill"``, ``"interpolate"``, and ``"gap_aware"``.
|
|
122
|
+
add_indicators
|
|
123
|
+
When ``True``, append ``"<column>_was_missing"`` boolean columns.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
if max_gap is not None and max_gap < 1:
|
|
127
|
+
raise ValueError("max_gap must be at least 1 when provided")
|
|
128
|
+
|
|
129
|
+
original = df.copy()
|
|
130
|
+
original_missing = original.isna()
|
|
131
|
+
long_gap = _long_gap_mask(original, max_gap)
|
|
132
|
+
data = original.copy()
|
|
133
|
+
|
|
134
|
+
if strategy in {"forward_fill", "ffill"}:
|
|
135
|
+
data = data.ffill(limit=max_gap)
|
|
136
|
+
elif strategy == "interpolate":
|
|
137
|
+
data = _apply_numeric_interpolation(data, max_gap)
|
|
138
|
+
non_numeric = data.columns.difference(
|
|
139
|
+
data.select_dtypes(include="number").columns
|
|
140
|
+
)
|
|
141
|
+
if len(non_numeric) > 0:
|
|
142
|
+
data.loc[:, non_numeric] = data.loc[:, non_numeric].ffill(limit=max_gap)
|
|
143
|
+
elif strategy == "gap_aware":
|
|
144
|
+
data = _apply_numeric_interpolation(data, max_gap)
|
|
145
|
+
data = data.ffill(limit=max_gap).bfill(limit=max_gap)
|
|
146
|
+
data = data.mask(long_gap)
|
|
147
|
+
elif strategy == "drop":
|
|
148
|
+
data = data.dropna()
|
|
149
|
+
elif strategy == "flag":
|
|
150
|
+
pass
|
|
151
|
+
else:
|
|
152
|
+
raise ValueError(f"Unsupported missing-data strategy: {strategy}")
|
|
153
|
+
|
|
154
|
+
if add_indicators:
|
|
155
|
+
for col in original.columns:
|
|
156
|
+
data[f"{col}_was_missing"] = original_missing[col]
|
|
157
|
+
|
|
158
|
+
if strategy == "drop":
|
|
159
|
+
kept_missing = original_missing.loc[data.index]
|
|
160
|
+
imputed_mask = pd.DataFrame(False, index=data.index, columns=original.columns)
|
|
161
|
+
remaining_missing = data[original.columns].isna()
|
|
162
|
+
long_gap = long_gap.loc[data.index]
|
|
163
|
+
summary = _build_summary(kept_missing, imputed_mask, remaining_missing)
|
|
164
|
+
else:
|
|
165
|
+
imputed_mask = original_missing & ~data[original.columns].isna()
|
|
166
|
+
remaining_missing = data[original.columns].isna()
|
|
167
|
+
summary = _build_summary(original_missing, imputed_mask, remaining_missing)
|
|
168
|
+
|
|
169
|
+
return MissingDataResult(
|
|
170
|
+
data=data,
|
|
171
|
+
original_missing_mask=original_missing
|
|
172
|
+
if strategy != "drop"
|
|
173
|
+
else original_missing.loc[data.index],
|
|
174
|
+
imputed_mask=imputed_mask,
|
|
175
|
+
remaining_missing_mask=remaining_missing,
|
|
176
|
+
long_gap_mask=long_gap,
|
|
177
|
+
summary=summary,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def forward_fill(df: pd.DataFrame) -> pd.DataFrame:
|
|
182
|
+
"""Forward-fill missing values in a DataFrame."""
|
|
183
|
+
return handle_missing_data(df, strategy="forward_fill").data
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def interpolate_linear(df: pd.DataFrame) -> pd.DataFrame:
|
|
187
|
+
"""Linearly interpolate interior missing values in numeric columns."""
|
|
188
|
+
return handle_missing_data(df, strategy="interpolate").data
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Plotting helpers for sensor modeling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
import matplotlib.pyplot as plt
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from matplotlib.figure import Figure
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def plot_quantile_intervals(
|
|
16
|
+
actual_counts: np.ndarray,
|
|
17
|
+
quantile_info: dict,
|
|
18
|
+
title: str = "Model Validation",
|
|
19
|
+
sensor_name: str = "",
|
|
20
|
+
show: bool = True,
|
|
21
|
+
) -> Figure:
|
|
22
|
+
"""Plot observed counts against predicted quantile intervals."""
|
|
23
|
+
time_of_day = np.arange(len(actual_counts)) * 15 / 60.0
|
|
24
|
+
|
|
25
|
+
fig, ax = plt.subplots(figsize=(12, 6))
|
|
26
|
+
ax.fill_between(
|
|
27
|
+
time_of_day,
|
|
28
|
+
quantile_info["lower_quantiles"],
|
|
29
|
+
quantile_info["upper_quantiles"],
|
|
30
|
+
alpha=0.3,
|
|
31
|
+
color="blue",
|
|
32
|
+
label="95% Prediction Interval",
|
|
33
|
+
)
|
|
34
|
+
ax.plot(time_of_day, actual_counts, "r-", linewidth=2, label="Observed Events")
|
|
35
|
+
ax.plot(time_of_day, quantile_info["means"], "k--", label="Predicted Mean")
|
|
36
|
+
ax.set_xlabel("Hour of Day")
|
|
37
|
+
ax.set_ylabel("Event Count")
|
|
38
|
+
ax.set_title(title or f"Model Validation for {sensor_name}")
|
|
39
|
+
ax.legend()
|
|
40
|
+
ax.grid(alpha=0.3)
|
|
41
|
+
fig.tight_layout()
|
|
42
|
+
logger.info("Displayed validation plot for sensor %s", sensor_name)
|
|
43
|
+
if show:
|
|
44
|
+
plt.show()
|
|
45
|
+
return fig
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def plot_sensor_activity_patterns(data: pd.DataFrame, show: bool = True) -> Figure:
|
|
49
|
+
"""Plot average sensor activation patterns across the day."""
|
|
50
|
+
df = data.copy()
|
|
51
|
+
df["hour"] = df.index.hour
|
|
52
|
+
means = df.groupby("hour")[data.columns].mean()
|
|
53
|
+
ax = means.plot(figsize=(10, 6))
|
|
54
|
+
fig = ax.figure
|
|
55
|
+
ax.set_xlabel("Hour of Day")
|
|
56
|
+
ax.set_ylabel("Activation Probability")
|
|
57
|
+
ax.set_title("Sensor Daily Activity Patterns")
|
|
58
|
+
ax.grid(alpha=0.3)
|
|
59
|
+
fig.tight_layout()
|
|
60
|
+
logger.info("Plotted sensor activity patterns")
|
|
61
|
+
if show:
|
|
62
|
+
plt.show()
|
|
63
|
+
return fig
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def plot_change_points(
|
|
67
|
+
series: np.ndarray,
|
|
68
|
+
change_points: np.ndarray,
|
|
69
|
+
title: str = "Change Points",
|
|
70
|
+
show: bool = True,
|
|
71
|
+
) -> Figure:
|
|
72
|
+
"""Plot time series with vertical lines at detected change points."""
|
|
73
|
+
fig, ax = plt.subplots(figsize=(10, 4))
|
|
74
|
+
ax.plot(series, label="series")
|
|
75
|
+
for cp in change_points:
|
|
76
|
+
ax.axvline(cp, color="red", linestyle="--", alpha=0.7)
|
|
77
|
+
ax.set_title(title)
|
|
78
|
+
ax.legend()
|
|
79
|
+
fig.tight_layout()
|
|
80
|
+
logger.info("Plotted %d change points", len(change_points))
|
|
81
|
+
if show:
|
|
82
|
+
plt.show()
|
|
83
|
+
return fig
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def plot_benchmark_results(results: dict[str, float], show: bool = True) -> Figure:
|
|
87
|
+
"""Bar chart of benchmark times for different algorithms."""
|
|
88
|
+
names = list(results.keys())
|
|
89
|
+
times = list(results.values())
|
|
90
|
+
fig, ax = plt.subplots(figsize=(8, 4))
|
|
91
|
+
ax.bar(names, times, color="skyblue")
|
|
92
|
+
ax.set_ylabel("Seconds")
|
|
93
|
+
ax.set_title("CPD Benchmark Runtime")
|
|
94
|
+
fig.tight_layout()
|
|
95
|
+
logger.info("Plotted benchmark results for %s", names)
|
|
96
|
+
if show:
|
|
97
|
+
plt.show()
|
|
98
|
+
return fig
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Validation utilities for sensor models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
|
|
12
|
+
from .data_io import SensorDataset
|
|
13
|
+
from .plotting import plot_quantile_intervals
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SupportsPredictionIntervals(Protocol):
|
|
19
|
+
"""Model interface required by prediction validation."""
|
|
20
|
+
|
|
21
|
+
def predict_probabilities(self, data: pd.DataFrame) -> np.ndarray:
|
|
22
|
+
"""Return predicted event probabilities."""
|
|
23
|
+
...
|
|
24
|
+
|
|
25
|
+
def compute_quantile_intervals(
|
|
26
|
+
self, probabilities: np.ndarray, confidence: float
|
|
27
|
+
) -> Mapping[str, np.ndarray]:
|
|
28
|
+
"""Return lower, upper, and mean prediction intervals."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate_model_predictions(
|
|
33
|
+
model: SupportsPredictionIntervals,
|
|
34
|
+
test_data: pd.DataFrame | SensorDataset,
|
|
35
|
+
sensor_name: str,
|
|
36
|
+
confidence: float = 0.95,
|
|
37
|
+
*,
|
|
38
|
+
plot: bool = True,
|
|
39
|
+
) -> dict[str, object]:
|
|
40
|
+
"""Validate model predictions using quantile coverage metrics."""
|
|
41
|
+
if not 0 < confidence < 1:
|
|
42
|
+
raise ValueError("confidence must be between 0 and 1")
|
|
43
|
+
|
|
44
|
+
df = test_data.to_dataframe() if isinstance(test_data, SensorDataset) else test_data
|
|
45
|
+
if sensor_name not in df.columns:
|
|
46
|
+
raise KeyError(f"Sensor '{sensor_name}' not found in test data")
|
|
47
|
+
|
|
48
|
+
probabilities = model.predict_probabilities(df)
|
|
49
|
+
quantile_info = model.compute_quantile_intervals(probabilities, confidence)
|
|
50
|
+
if not quantile_info:
|
|
51
|
+
return {
|
|
52
|
+
"validation_successful": False,
|
|
53
|
+
"reason": "Insufficient data for validation",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
test_array = df[sensor_name].values
|
|
57
|
+
n_days = len(test_array) // 96
|
|
58
|
+
if n_days == 0:
|
|
59
|
+
return {"validation_successful": False, "reason": "Insufficient test data"}
|
|
60
|
+
test_reshaped = test_array[: n_days * 96].reshape(n_days, 96)
|
|
61
|
+
actual_counts = np.sum(test_reshaped, axis=0)
|
|
62
|
+
|
|
63
|
+
outside_count = np.sum(
|
|
64
|
+
(actual_counts < quantile_info["lower_quantiles"])
|
|
65
|
+
| (actual_counts > quantile_info["upper_quantiles"])
|
|
66
|
+
)
|
|
67
|
+
outside_percentage = (outside_count / 96) * 100
|
|
68
|
+
expected_outside = (1 - confidence) * 100
|
|
69
|
+
mse = np.mean((actual_counts - quantile_info["means"]) ** 2)
|
|
70
|
+
coverage_accuracy = 100 - abs(outside_percentage - expected_outside)
|
|
71
|
+
|
|
72
|
+
if plot:
|
|
73
|
+
plot_quantile_intervals(actual_counts, quantile_info, sensor_name=sensor_name)
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
"validation_successful": True,
|
|
77
|
+
"outside_interval_count": int(outside_count),
|
|
78
|
+
"outside_interval_percentage": outside_percentage,
|
|
79
|
+
"expected_outside_percentage": expected_outside,
|
|
80
|
+
"coverage_accuracy": coverage_accuracy,
|
|
81
|
+
"mean_squared_error": mse,
|
|
82
|
+
"actual_counts": actual_counts,
|
|
83
|
+
"quantile_info": quantile_info,
|
|
84
|
+
"is_well_calibrated": bool(abs(outside_percentage - expected_outside) < 2.5),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def create_model_comparison_report(results: Mapping[str, object]) -> str:
|
|
89
|
+
"""Create a formatted comparison report for multiple modeling approaches."""
|
|
90
|
+
report = ["MODEL COMPARISON REPORT", "=" * 40, "\nBIC COMPARISON:"]
|
|
91
|
+
bic_scores: dict[str, float] = {}
|
|
92
|
+
for approach, data in results.items():
|
|
93
|
+
if isinstance(data, Mapping) and isinstance(data.get("total_bic"), int | float):
|
|
94
|
+
total_bic = float(data["total_bic"])
|
|
95
|
+
report.append(f"{approach.replace('_', ' ').title()}: {total_bic:.2f}")
|
|
96
|
+
bic_scores[approach] = total_bic
|
|
97
|
+
|
|
98
|
+
improvements = results.get("improvements")
|
|
99
|
+
if isinstance(improvements, Mapping):
|
|
100
|
+
report.append("\nIMPROVEMENTS (Lower BIC is better):")
|
|
101
|
+
for comparison, improvement in improvements.items():
|
|
102
|
+
if not isinstance(improvement, int | float):
|
|
103
|
+
continue
|
|
104
|
+
improvement_value = float(improvement)
|
|
105
|
+
direction = "better" if improvement_value > 0 else "worse"
|
|
106
|
+
report.append(
|
|
107
|
+
f"{comparison.replace('_', ' ').title()}: {improvement_value:.2f} ({direction})"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if bic_scores:
|
|
111
|
+
best_approach = min(bic_scores, key=bic_scores.get)
|
|
112
|
+
report.append(
|
|
113
|
+
f"\nBest performing approach: {best_approach.replace('_', ' ').title()}"
|
|
114
|
+
)
|
|
115
|
+
report.append(f"BIC score: {bic_scores[best_approach]:.2f}")
|
|
116
|
+
|
|
117
|
+
return "\n".join(report)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Healthcare focused visualizations and summaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Collection
|
|
6
|
+
from typing import Dict
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import plotly.express as px
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _require_columns(
|
|
13
|
+
data: pd.DataFrame, required: Collection[str], context: str
|
|
14
|
+
) -> None:
|
|
15
|
+
"""Raise a clear error when required visualization columns are missing."""
|
|
16
|
+
missing = sorted(set(required) - set(data.columns))
|
|
17
|
+
if missing:
|
|
18
|
+
names = ", ".join(missing)
|
|
19
|
+
raise ValueError(f"{context} requires columns: {names}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def activity_summary(data: pd.DataFrame) -> px.bar:
|
|
23
|
+
"""Return a patient-friendly bar chart summarizing activity levels."""
|
|
24
|
+
_require_columns(data, {"activity"}, "activity_summary")
|
|
25
|
+
summary = data.groupby("activity").size().reset_index(name="count")
|
|
26
|
+
return px.bar(summary, x="activity", y="count")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def clinical_alerts(
|
|
30
|
+
data: pd.DataFrame, thresholds: Dict[str, float]
|
|
31
|
+
) -> Dict[str, bool]:
|
|
32
|
+
"""Flag sensors that exceed clinical thresholds."""
|
|
33
|
+
_require_columns(data, {"sensor", "value"}, "clinical_alerts")
|
|
34
|
+
alerts = {}
|
|
35
|
+
for sensor, thresh in thresholds.items():
|
|
36
|
+
alerts[sensor] = bool((data[data["sensor"] == sensor]["value"] > thresh).any())
|
|
37
|
+
return alerts
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def trend_monitor(data: pd.DataFrame, window: int = 7) -> px.line:
|
|
41
|
+
"""Plot rolling averages over weeks or months."""
|
|
42
|
+
_require_columns(data, {"sensor", "timestamp", "value"}, "trend_monitor")
|
|
43
|
+
if window < 1:
|
|
44
|
+
raise ValueError("window must be at least 1")
|
|
45
|
+
|
|
46
|
+
rolled = (
|
|
47
|
+
data.set_index("timestamp")
|
|
48
|
+
.groupby("sensor")["value"]
|
|
49
|
+
.rolling(window)
|
|
50
|
+
.mean()
|
|
51
|
+
.reset_index()
|
|
52
|
+
)
|
|
53
|
+
return px.line(rolled, x="timestamp", y="value", color="sensor")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def compare_norms(data: pd.DataFrame, normative: pd.DataFrame) -> px.line:
|
|
57
|
+
"""Compare patient data against normative statistics."""
|
|
58
|
+
required = {"sensor", "timestamp", "value"}
|
|
59
|
+
_require_columns(data, required, "compare_norms patient data")
|
|
60
|
+
_require_columns(normative, required, "compare_norms normative data")
|
|
61
|
+
merged = pd.merge(
|
|
62
|
+
data, normative, on=["timestamp", "sensor"], suffixes=("_patient", "_norm")
|
|
63
|
+
)
|
|
64
|
+
fig = px.line(
|
|
65
|
+
merged, x="timestamp", y=["value_patient", "value_norm"], color="sensor"
|
|
66
|
+
)
|
|
67
|
+
return fig
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Interactive dashboards using Plotly and Bokeh.
|
|
2
|
+
|
|
3
|
+
This module exposes helpers that make it easy to explore sensor data in
|
|
4
|
+
real time, tune model parameters, drill into detected changes, and export
|
|
5
|
+
figures for presentations. The functions are intentionally lightweight so
|
|
6
|
+
that they can run in environments without a full web server.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Collection, Iterable, Sequence
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
import plotly.express as px
|
|
18
|
+
from bokeh.embed import file_html
|
|
19
|
+
from bokeh.layouts import column
|
|
20
|
+
from bokeh.models import ColumnDataSource, CustomJS, Div, HoverTool, Slider
|
|
21
|
+
from bokeh.plotting import figure
|
|
22
|
+
from bokeh.resources import CDN
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _require_columns(
|
|
26
|
+
data: pd.DataFrame, required: Collection[str], context: str
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Raise a clear error when required visualization columns are missing."""
|
|
29
|
+
missing = sorted(set(required) - set(data.columns))
|
|
30
|
+
if missing:
|
|
31
|
+
names = ", ".join(missing)
|
|
32
|
+
raise ValueError(f"{context} requires columns: {names}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def real_time_display(data: pd.DataFrame) -> px.line:
|
|
36
|
+
"""Create a Plotly line figure for streaming sensor data.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
data:
|
|
41
|
+
DataFrame with at least ``timestamp``, ``sensor`` and ``value`` columns.
|
|
42
|
+
"""
|
|
43
|
+
_require_columns(data, {"sensor", "timestamp", "value"}, "real_time_display")
|
|
44
|
+
return px.line(data, x="timestamp", y="value", color="sensor")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _score_parameter(model: Any, param: str, value: float) -> float:
|
|
48
|
+
"""Return a diagnostic score for ``param=value`` using model hooks."""
|
|
49
|
+
if hasattr(model, "score_parameter"):
|
|
50
|
+
scorer = getattr(model, "score_parameter")
|
|
51
|
+
try:
|
|
52
|
+
return float(scorer(param, value))
|
|
53
|
+
except TypeError:
|
|
54
|
+
return float(scorer(value))
|
|
55
|
+
if hasattr(model, "evaluate_parameter"):
|
|
56
|
+
evaluator = getattr(model, "evaluate_parameter")
|
|
57
|
+
try:
|
|
58
|
+
return float(evaluator(param, value))
|
|
59
|
+
except TypeError:
|
|
60
|
+
return float(evaluator(value))
|
|
61
|
+
raise ValueError(
|
|
62
|
+
"parameter_tuning requires model.score_parameter(...) or "
|
|
63
|
+
"model.evaluate_parameter(...) to compute diagnostics"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _build_parameter_sweep(
|
|
68
|
+
model: Any, param: str, values: Sequence[float]
|
|
69
|
+
) -> tuple[list[float], list[float]]:
|
|
70
|
+
"""Evaluate the model over candidate parameter values."""
|
|
71
|
+
ordered_values = sorted({float(v) for v in values})
|
|
72
|
+
if not ordered_values:
|
|
73
|
+
raise ValueError("values must contain at least one candidate")
|
|
74
|
+
if not np.isfinite(ordered_values).all():
|
|
75
|
+
raise ValueError("values must contain only finite candidates")
|
|
76
|
+
|
|
77
|
+
scores = [_score_parameter(model, param, value) for value in ordered_values]
|
|
78
|
+
if not np.isfinite(scores).all():
|
|
79
|
+
raise ValueError("parameter scores must be finite")
|
|
80
|
+
return ordered_values, scores
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parameter_tuning(model: Any, param: str, values: Iterable[float]):
|
|
84
|
+
"""Return a Bokeh layout for parameter diagnostics.
|
|
85
|
+
|
|
86
|
+
The model must expose either ``score_parameter(param, value)`` or
|
|
87
|
+
``evaluate_parameter(param, value)``. The returned view shows a score curve
|
|
88
|
+
across candidate values and updates the highlighted selection plus summary
|
|
89
|
+
text when the slider moves. In Python-backed Bokeh sessions, the callback
|
|
90
|
+
also writes the chosen value back to ``model.<param>``.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
param_values, scores = _build_parameter_sweep(model, param, list(values))
|
|
94
|
+
current_value = float(getattr(model, param, param_values[0]))
|
|
95
|
+
if current_value not in param_values:
|
|
96
|
+
current_value = param_values[0]
|
|
97
|
+
current_idx = param_values.index(current_value)
|
|
98
|
+
best_idx = max(range(len(scores)), key=lambda idx: scores[idx])
|
|
99
|
+
|
|
100
|
+
curve_source = ColumnDataSource(
|
|
101
|
+
{
|
|
102
|
+
"x": param_values,
|
|
103
|
+
"y": scores,
|
|
104
|
+
"label": [f"{value:.4g}" for value in param_values],
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
selected_source = ColumnDataSource(
|
|
108
|
+
{"x": [param_values[current_idx]], "y": [scores[current_idx]]}
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
plot = figure(
|
|
112
|
+
height=320,
|
|
113
|
+
sizing_mode="stretch_width",
|
|
114
|
+
title=f"{param} diagnostic sweep",
|
|
115
|
+
x_axis_label=param,
|
|
116
|
+
y_axis_label="score",
|
|
117
|
+
tools="pan,wheel_zoom,box_zoom,reset,save",
|
|
118
|
+
)
|
|
119
|
+
plot.line("x", "y", source=curve_source, line_width=2, color="#1f77b4")
|
|
120
|
+
plot.scatter("x", "y", source=curve_source, size=8, color="#1f77b4", alpha=0.85)
|
|
121
|
+
plot.scatter(
|
|
122
|
+
"x",
|
|
123
|
+
"y",
|
|
124
|
+
source=selected_source,
|
|
125
|
+
size=14,
|
|
126
|
+
color="#d62728",
|
|
127
|
+
line_color="white",
|
|
128
|
+
line_width=2,
|
|
129
|
+
)
|
|
130
|
+
plot.add_tools(
|
|
131
|
+
HoverTool(
|
|
132
|
+
tooltips=[(param, "@label"), ("score", "@y{0.000}")],
|
|
133
|
+
renderers=plot.renderers[:2],
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
summary = Div(
|
|
138
|
+
text=(
|
|
139
|
+
f"<b>{param}</b>: {param_values[current_idx]:.4g} | "
|
|
140
|
+
f"<b>score</b>: {scores[current_idx]:.4f} | "
|
|
141
|
+
f"<b>best</b>: {param_values[best_idx]:.4g} "
|
|
142
|
+
f"({scores[best_idx]:.4f})"
|
|
143
|
+
),
|
|
144
|
+
sizing_mode="stretch_width",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
slider = Slider(
|
|
148
|
+
start=0,
|
|
149
|
+
end=len(param_values) - 1,
|
|
150
|
+
step=1,
|
|
151
|
+
value=current_idx,
|
|
152
|
+
title=f"{param}: {param_values[current_idx]:.4g}",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def _update(attr: str, old: int, new: int) -> None: # pragma: no cover
|
|
156
|
+
value = param_values[int(new)]
|
|
157
|
+
setattr(model, param, value)
|
|
158
|
+
|
|
159
|
+
slider.on_change("value", _update)
|
|
160
|
+
slider.js_on_change(
|
|
161
|
+
"value",
|
|
162
|
+
CustomJS(
|
|
163
|
+
args=dict(
|
|
164
|
+
slider=slider,
|
|
165
|
+
selected=selected_source,
|
|
166
|
+
summary=summary,
|
|
167
|
+
values=param_values,
|
|
168
|
+
scores=scores,
|
|
169
|
+
param=param,
|
|
170
|
+
best_value=param_values[best_idx],
|
|
171
|
+
best_score=scores[best_idx],
|
|
172
|
+
),
|
|
173
|
+
code="""
|
|
174
|
+
const idx = slider.value;
|
|
175
|
+
const value = values[idx];
|
|
176
|
+
const score = scores[idx];
|
|
177
|
+
selected.data = {x: [value], y: [score]};
|
|
178
|
+
selected.change.emit();
|
|
179
|
+
slider.title = `${param}: ${value.toPrecision(4)}`;
|
|
180
|
+
summary.text =
|
|
181
|
+
`<b>${param}</b>: ${value.toPrecision(4)} | <b>score</b>: ${score.toFixed(4)} | ` +
|
|
182
|
+
`<b>best</b>: ${best_value.toPrecision(4)} (${best_score.toFixed(4)})`;
|
|
183
|
+
""",
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
return column(slider, summary, plot, sizing_mode="stretch_width")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def drill_down(changes: pd.DataFrame) -> px.scatter:
|
|
190
|
+
"""Plot detected changes with interactive hover information."""
|
|
191
|
+
_require_columns(changes, {"score", "time"}, "drill_down")
|
|
192
|
+
return px.scatter(changes, x="time", y="score")
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def export(fig: Any, path: str) -> None:
|
|
196
|
+
"""Export a Plotly or Bokeh figure to an HTML file."""
|
|
197
|
+
output_path = Path(path)
|
|
198
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
199
|
+
if hasattr(fig, "write_html"):
|
|
200
|
+
fig.write_html(output_path)
|
|
201
|
+
elif fig.__class__.__module__.startswith("bokeh"):
|
|
202
|
+
html = file_html(fig, CDN, "export")
|
|
203
|
+
with open(
|
|
204
|
+
output_path, "w", encoding="utf-8"
|
|
205
|
+
) as fh: # pragma: no cover - simple file write
|
|
206
|
+
fh.write(html)
|
|
207
|
+
else: # pragma: no cover - defensive fallback
|
|
208
|
+
raise TypeError("Unsupported figure type")
|