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,569 @@
|
|
|
1
|
+
"""Base Bernoulli Autoregressive Model for sensor data.
|
|
2
|
+
|
|
3
|
+
This module contains the core single-sensor model implementation
|
|
4
|
+
based on Gillam et al. (2022).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from typing import Dict, List
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import pandas as pd
|
|
12
|
+
from scipy.optimize import minimize
|
|
13
|
+
from scipy.signal import lfilter
|
|
14
|
+
from scipy.stats import norm
|
|
15
|
+
|
|
16
|
+
from ...utils.data_io import SensorDataset
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class BernoulliAutoregressiveModel:
|
|
22
|
+
"""Bernoulli Autoregressive Model for sensor data.
|
|
23
|
+
|
|
24
|
+
Described in "Modeling and forecasting of at home activity in older adults
|
|
25
|
+
using passive sensor technology" by Gillam et al. (2022).
|
|
26
|
+
|
|
27
|
+
This model predicts the probability of a sensor triggering in 15-minute
|
|
28
|
+
intervals based on:
|
|
29
|
+
|
|
30
|
+
- Previous activations of the same sensor (autoregressive term)
|
|
31
|
+
- Other sensors in the household
|
|
32
|
+
- Daily seasonal patterns
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, sensor_names: List[str], target_sensor: str):
|
|
36
|
+
"""
|
|
37
|
+
Initialize the model.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
sensor_names: List of all sensor names in the dataset
|
|
41
|
+
target_sensor: Name of the sensor to predict
|
|
42
|
+
"""
|
|
43
|
+
self.sensor_names = sensor_names
|
|
44
|
+
self.target_sensor = target_sensor
|
|
45
|
+
self.other_sensors = [s for s in sensor_names if s != target_sensor]
|
|
46
|
+
|
|
47
|
+
# Model parameters (will be estimated)
|
|
48
|
+
self.params = {}
|
|
49
|
+
self.selected_sensors = [] # Sensors selected through model selection
|
|
50
|
+
|
|
51
|
+
# Data storage
|
|
52
|
+
self.y = None # Target sensor data
|
|
53
|
+
self.X_other = None # Other sensor data
|
|
54
|
+
|
|
55
|
+
# Constants
|
|
56
|
+
self.INTERVALS_PER_DAY = 96 # 15-minute intervals in 24 hours
|
|
57
|
+
|
|
58
|
+
def _log_likelihood(self, param_vector: np.ndarray) -> float:
|
|
59
|
+
"""
|
|
60
|
+
Compute the negative log-likelihood for optimization.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
param_vector: Flattened parameter vector
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Negative log-likelihood
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
# Convert parameter vector to dictionary
|
|
70
|
+
params = self._vector_to_params(param_vector)
|
|
71
|
+
|
|
72
|
+
T = len(self.y)
|
|
73
|
+
ar_cache = np.zeros(T)
|
|
74
|
+
if T > 1:
|
|
75
|
+
ar_cache[1:] = lfilter(
|
|
76
|
+
[1.0],
|
|
77
|
+
[1.0, -params["phi_b"]],
|
|
78
|
+
self.y[:-1],
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
sensor_cache = {s: np.zeros(T) for s in self.selected_sensors}
|
|
82
|
+
for s in self.selected_sensors:
|
|
83
|
+
idx = self.other_sensors.index(s)
|
|
84
|
+
psi_key = f"psi_{s}"
|
|
85
|
+
if T > 1:
|
|
86
|
+
sensor_cache[s][1:] = lfilter(
|
|
87
|
+
[1.0],
|
|
88
|
+
[1.0, -params[psi_key]],
|
|
89
|
+
self.X_other[:-1, idx],
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
seasonal = np.zeros(T)
|
|
93
|
+
if "pi_d" in params and "phi_d" in params:
|
|
94
|
+
for t in range(self.INTERVALS_PER_DAY, T):
|
|
95
|
+
prev_day = t - self.INTERVALS_PER_DAY
|
|
96
|
+
start = max(0, prev_day - 1)
|
|
97
|
+
end = min(len(self.y), prev_day + 2)
|
|
98
|
+
window_max = np.max(self.y[start:end])
|
|
99
|
+
seasonal[t] = (
|
|
100
|
+
params["phi_d"] * seasonal[t - self.INTERVALS_PER_DAY]
|
|
101
|
+
+ window_max
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
delta = params["a"] + params["pi_b"] * ar_cache
|
|
105
|
+
if self.selected_sensors:
|
|
106
|
+
stack = np.vstack(
|
|
107
|
+
[
|
|
108
|
+
params[f"tau_{s}"] * sensor_cache[s]
|
|
109
|
+
for s in self.selected_sensors
|
|
110
|
+
]
|
|
111
|
+
)
|
|
112
|
+
delta += stack.sum(axis=0)
|
|
113
|
+
if "pi_d" in params and "phi_d" in params:
|
|
114
|
+
delta += params["pi_d"] * seasonal
|
|
115
|
+
|
|
116
|
+
delta = np.clip(delta, -500, 500)
|
|
117
|
+
log_lik = np.sum(self.y * delta - np.log1p(np.exp(delta)))
|
|
118
|
+
return -log_lik # Return negative for minimization
|
|
119
|
+
|
|
120
|
+
except (OverflowError, ValueError, RuntimeWarning):
|
|
121
|
+
return 1e10 # Return large value for invalid parameters
|
|
122
|
+
|
|
123
|
+
def _prepare_training_frame(
|
|
124
|
+
self, data: SensorDataset | pd.DataFrame
|
|
125
|
+
) -> pd.DataFrame:
|
|
126
|
+
"""Validate model input and return a numeric training frame."""
|
|
127
|
+
df = data.to_dataframe() if isinstance(data, SensorDataset) else data
|
|
128
|
+
if not isinstance(df, pd.DataFrame):
|
|
129
|
+
raise TypeError("data must be a pandas DataFrame or SensorDataset")
|
|
130
|
+
if df.empty:
|
|
131
|
+
raise ValueError("data must contain at least one observation")
|
|
132
|
+
|
|
133
|
+
missing = [sensor for sensor in self.sensor_names if sensor not in df.columns]
|
|
134
|
+
if missing:
|
|
135
|
+
raise ValueError(f"Missing sensor columns: {', '.join(missing)}")
|
|
136
|
+
if self.target_sensor not in self.sensor_names:
|
|
137
|
+
raise ValueError("target_sensor must be included in sensor_names")
|
|
138
|
+
|
|
139
|
+
sensor_frame = df[self.sensor_names]
|
|
140
|
+
try:
|
|
141
|
+
numeric_frame = sensor_frame.astype(float)
|
|
142
|
+
except (TypeError, ValueError) as exc:
|
|
143
|
+
raise ValueError("sensor columns must be numeric") from exc
|
|
144
|
+
if numeric_frame.isna().any().any():
|
|
145
|
+
raise ValueError("sensor columns must not contain NaN values")
|
|
146
|
+
|
|
147
|
+
unique_values = set(np.unique(numeric_frame.to_numpy()))
|
|
148
|
+
if not unique_values <= {0.0, 1.0}:
|
|
149
|
+
raise ValueError("sensor columns must be binary with values 0 and 1")
|
|
150
|
+
|
|
151
|
+
return numeric_frame
|
|
152
|
+
|
|
153
|
+
def _set_training_arrays(self, df: pd.DataFrame) -> None:
|
|
154
|
+
"""Cache target and predictor arrays for fitting."""
|
|
155
|
+
self.y = df[self.target_sensor].to_numpy(dtype=float)
|
|
156
|
+
self.X_other = df[self.other_sensors].to_numpy(dtype=float)
|
|
157
|
+
|
|
158
|
+
def _vector_to_params(self, param_vector: np.ndarray) -> Dict:
|
|
159
|
+
"""Convert parameter vector to dictionary."""
|
|
160
|
+
params = {}
|
|
161
|
+
idx = 0
|
|
162
|
+
|
|
163
|
+
# Always include intercept and autoregressive terms
|
|
164
|
+
params["a"] = param_vector[idx]
|
|
165
|
+
idx += 1
|
|
166
|
+
params["pi_b"] = param_vector[idx]
|
|
167
|
+
idx += 1
|
|
168
|
+
params["phi_b"] = param_vector[idx]
|
|
169
|
+
idx += 1
|
|
170
|
+
|
|
171
|
+
# Other sensor parameters
|
|
172
|
+
for sensor in self.selected_sensors:
|
|
173
|
+
params[f"tau_{sensor}"] = param_vector[idx]
|
|
174
|
+
idx += 1
|
|
175
|
+
params[f"psi_{sensor}"] = param_vector[idx]
|
|
176
|
+
idx += 1
|
|
177
|
+
|
|
178
|
+
# Seasonal parameters (if included)
|
|
179
|
+
if idx < len(param_vector):
|
|
180
|
+
params["pi_d"] = param_vector[idx]
|
|
181
|
+
idx += 1
|
|
182
|
+
params["phi_d"] = param_vector[idx]
|
|
183
|
+
idx += 1
|
|
184
|
+
|
|
185
|
+
return params
|
|
186
|
+
|
|
187
|
+
def _params_to_vector(self, params: Dict) -> np.ndarray:
|
|
188
|
+
"""Convert parameter dictionary to vector."""
|
|
189
|
+
vector = [params["a"], params["pi_b"], params["phi_b"]]
|
|
190
|
+
|
|
191
|
+
for sensor in self.selected_sensors:
|
|
192
|
+
vector.extend([params[f"tau_{sensor}"], params[f"psi_{sensor}"]])
|
|
193
|
+
|
|
194
|
+
if "pi_d" in params and "phi_d" in params:
|
|
195
|
+
vector.extend([params["pi_d"], params["phi_d"]])
|
|
196
|
+
|
|
197
|
+
return np.array(vector)
|
|
198
|
+
|
|
199
|
+
def _stepwise_selection(
|
|
200
|
+
self, data: pd.DataFrame, max_sensors: int = 5
|
|
201
|
+
) -> List[str]:
|
|
202
|
+
"""
|
|
203
|
+
Perform stepwise forward selection using BIC criterion.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
data: DataFrame containing sensor data
|
|
207
|
+
max_sensors: Maximum number of sensors to select
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
List of selected sensor names
|
|
211
|
+
"""
|
|
212
|
+
logger.info("Performing stepwise sensor selection...")
|
|
213
|
+
|
|
214
|
+
self._set_training_arrays(data)
|
|
215
|
+
|
|
216
|
+
selected = []
|
|
217
|
+
available = self.other_sensors.copy()
|
|
218
|
+
|
|
219
|
+
# Always start with autoregressive term only
|
|
220
|
+
current_model_bic = self._fit_model_subset( # noqa: E501
|
|
221
|
+
selected, include_seasonal=False
|
|
222
|
+
)
|
|
223
|
+
logger.info(f"Baseline model BIC: {current_model_bic:.2f}")
|
|
224
|
+
|
|
225
|
+
while len(selected) < max_sensors and available:
|
|
226
|
+
best_sensor = None
|
|
227
|
+
best_new_bic = float("inf")
|
|
228
|
+
|
|
229
|
+
# Try adding each available sensor
|
|
230
|
+
for sensor in available:
|
|
231
|
+
test_sensors = selected + [sensor]
|
|
232
|
+
|
|
233
|
+
# Try without seasonal term
|
|
234
|
+
bic_no_seasonal = self._fit_model_subset(
|
|
235
|
+
test_sensors, include_seasonal=False
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# Try with seasonal term if we haven't already included it
|
|
239
|
+
bic_seasonal = self._fit_model_subset(
|
|
240
|
+
test_sensors, include_seasonal=True
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
test_bic = min(bic_no_seasonal, bic_seasonal)
|
|
244
|
+
|
|
245
|
+
if test_bic < best_new_bic:
|
|
246
|
+
best_new_bic = test_bic
|
|
247
|
+
best_sensor = sensor
|
|
248
|
+
|
|
249
|
+
# Add sensor if it improves BIC
|
|
250
|
+
if best_new_bic < current_model_bic:
|
|
251
|
+
selected.append(best_sensor)
|
|
252
|
+
available.remove(best_sensor)
|
|
253
|
+
current_model_bic = best_new_bic
|
|
254
|
+
logger.info( # noqa: E501
|
|
255
|
+
f"Added sensor '{best_sensor}', new BIC: {current_model_bic:.2f}" # noqa: E501
|
|
256
|
+
)
|
|
257
|
+
else:
|
|
258
|
+
logger.info("No improvement found, stopping selection")
|
|
259
|
+
break
|
|
260
|
+
|
|
261
|
+
# Final check for seasonal term
|
|
262
|
+
if len(selected) > 0:
|
|
263
|
+
bic_with_seasonal = self._fit_model_subset( # noqa: E501
|
|
264
|
+
selected, include_seasonal=True
|
|
265
|
+
)
|
|
266
|
+
if bic_with_seasonal < current_model_bic:
|
|
267
|
+
logger.info( # noqa: E501
|
|
268
|
+
f"Including seasonal term, final BIC: {bic_with_seasonal:.2f}" # noqa: E501
|
|
269
|
+
)
|
|
270
|
+
self.include_seasonal = True
|
|
271
|
+
else:
|
|
272
|
+
self.include_seasonal = False
|
|
273
|
+
else:
|
|
274
|
+
self.include_seasonal = False
|
|
275
|
+
|
|
276
|
+
logger.info(f"Selected sensors: {selected}")
|
|
277
|
+
return selected
|
|
278
|
+
|
|
279
|
+
def _fit_model_subset(
|
|
280
|
+
self, sensors: List[str], include_seasonal: bool = False
|
|
281
|
+
) -> float:
|
|
282
|
+
"""
|
|
283
|
+
Fit model with subset of sensors and return BIC.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
sensors: List of sensor names to include
|
|
287
|
+
include_seasonal: Whether to include seasonal component
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
BIC value
|
|
291
|
+
"""
|
|
292
|
+
try:
|
|
293
|
+
self.selected_sensors = sensors
|
|
294
|
+
|
|
295
|
+
# Initialize parameters
|
|
296
|
+
n_params = 3 + 2 * len(sensors) # a, pi_b, phi_b, + 2 per sensor
|
|
297
|
+
if include_seasonal:
|
|
298
|
+
n_params += 2 # pi_d, phi_d
|
|
299
|
+
|
|
300
|
+
# Starting values
|
|
301
|
+
init_params = np.zeros(n_params)
|
|
302
|
+
init_params[0] = -3.0 # Intercept
|
|
303
|
+
init_params[1] = 0.5 # pi_b
|
|
304
|
+
init_params[2] = 0.5 # phi_b
|
|
305
|
+
|
|
306
|
+
idx = 3
|
|
307
|
+
for i in range(len(sensors)):
|
|
308
|
+
init_params[idx] = 0.5 # tau_j
|
|
309
|
+
init_params[idx + 1] = 0.5 # psi_j
|
|
310
|
+
idx += 2
|
|
311
|
+
|
|
312
|
+
if include_seasonal:
|
|
313
|
+
init_params[idx] = 0.5 # pi_d
|
|
314
|
+
init_params[idx + 1] = 0.8 # phi_d
|
|
315
|
+
|
|
316
|
+
# Set bounds to ensure stability
|
|
317
|
+
bounds = []
|
|
318
|
+
bounds.append((-10, 10)) # a
|
|
319
|
+
bounds.append((-5, 5)) # pi_b
|
|
320
|
+
bounds.append((-0.99, 0.99)) # phi_b (stability)
|
|
321
|
+
|
|
322
|
+
for i in range(len(sensors)):
|
|
323
|
+
bounds.append((-5, 5)) # tau_j
|
|
324
|
+
bounds.append((-0.99, 0.99)) # psi_j (stability)
|
|
325
|
+
|
|
326
|
+
if include_seasonal:
|
|
327
|
+
bounds.append((-5, 5)) # pi_d
|
|
328
|
+
bounds.append((-0.99, 0.99)) # phi_d (stability)
|
|
329
|
+
|
|
330
|
+
# Optimize
|
|
331
|
+
result = minimize(
|
|
332
|
+
self._log_likelihood,
|
|
333
|
+
init_params,
|
|
334
|
+
method="L-BFGS-B",
|
|
335
|
+
bounds=bounds,
|
|
336
|
+
options={"maxiter": 500},
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
if result.success:
|
|
340
|
+
# Calculate BIC
|
|
341
|
+
log_lik = -result.fun
|
|
342
|
+
n_obs = len(self.y)
|
|
343
|
+
bic = -2 * log_lik + n_params * np.log(n_obs)
|
|
344
|
+
return bic
|
|
345
|
+
else:
|
|
346
|
+
return float("inf")
|
|
347
|
+
|
|
348
|
+
except (FloatingPointError, RuntimeError, ValueError) as exc:
|
|
349
|
+
logger.warning("Error in model fitting: %s", exc)
|
|
350
|
+
return float("inf")
|
|
351
|
+
|
|
352
|
+
def fit(
|
|
353
|
+
self,
|
|
354
|
+
data: SensorDataset | pd.DataFrame,
|
|
355
|
+
perform_selection: bool = True,
|
|
356
|
+
) -> Dict:
|
|
357
|
+
"""
|
|
358
|
+
Fit the Bernoulli autoregressive model.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
data: DataFrame with sensor data (15-minute intervals)
|
|
362
|
+
perform_selection: Whether to perform automatic sensor selection
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
Dictionary containing fitted parameters and model info
|
|
366
|
+
"""
|
|
367
|
+
logger.info(f"Fitting model for target sensor: {self.target_sensor}")
|
|
368
|
+
|
|
369
|
+
# Prepare data
|
|
370
|
+
df = self._prepare_training_frame(data)
|
|
371
|
+
self._set_training_arrays(df)
|
|
372
|
+
|
|
373
|
+
# Perform sensor selection
|
|
374
|
+
if perform_selection:
|
|
375
|
+
self.selected_sensors = self._stepwise_selection(df)
|
|
376
|
+
else:
|
|
377
|
+
self.selected_sensors = self.other_sensors
|
|
378
|
+
self.include_seasonal = True
|
|
379
|
+
|
|
380
|
+
# Fit final model
|
|
381
|
+
n_params = 3 + 2 * len(self.selected_sensors)
|
|
382
|
+
if hasattr(self, "include_seasonal") and self.include_seasonal:
|
|
383
|
+
n_params += 2
|
|
384
|
+
|
|
385
|
+
# Initialize parameters
|
|
386
|
+
init_params = np.zeros(n_params)
|
|
387
|
+
init_params[0] = -3.0 # Intercept
|
|
388
|
+
init_params[1] = 0.5 # pi_b
|
|
389
|
+
init_params[2] = 0.5 # phi_b
|
|
390
|
+
|
|
391
|
+
idx = 3
|
|
392
|
+
for i in range(len(self.selected_sensors)):
|
|
393
|
+
init_params[idx] = 0.5 # tau_j
|
|
394
|
+
init_params[idx + 1] = 0.5 # psi_j
|
|
395
|
+
idx += 2
|
|
396
|
+
|
|
397
|
+
if hasattr(self, "include_seasonal") and self.include_seasonal:
|
|
398
|
+
init_params[idx] = 0.5 # pi_d
|
|
399
|
+
init_params[idx + 1] = 0.8 # phi_d
|
|
400
|
+
|
|
401
|
+
# Set bounds
|
|
402
|
+
bounds = []
|
|
403
|
+
bounds.append((-10, 10)) # a
|
|
404
|
+
bounds.append((-5, 5)) # pi_b
|
|
405
|
+
bounds.append((-0.99, 0.99)) # phi_b
|
|
406
|
+
|
|
407
|
+
for i in range(len(self.selected_sensors)):
|
|
408
|
+
bounds.append((-5, 5)) # tau_j
|
|
409
|
+
bounds.append((-0.99, 0.99)) # psi_j
|
|
410
|
+
|
|
411
|
+
if hasattr(self, "include_seasonal") and self.include_seasonal:
|
|
412
|
+
bounds.append((-5, 5)) # pi_d
|
|
413
|
+
bounds.append((-0.99, 0.99)) # phi_d
|
|
414
|
+
|
|
415
|
+
# Final optimization
|
|
416
|
+
logger.info("Fitting final model...")
|
|
417
|
+
result = minimize(
|
|
418
|
+
self._log_likelihood,
|
|
419
|
+
init_params,
|
|
420
|
+
method="L-BFGS-B",
|
|
421
|
+
bounds=bounds,
|
|
422
|
+
options={"maxiter": 1000},
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
if result.success:
|
|
426
|
+
self.params = self._vector_to_params(result.x)
|
|
427
|
+
|
|
428
|
+
# Calculate final statistics
|
|
429
|
+
log_lik = -result.fun
|
|
430
|
+
n_obs = len(self.y)
|
|
431
|
+
bic = -2 * log_lik + n_params * np.log(n_obs)
|
|
432
|
+
|
|
433
|
+
logger.info(f"Model fitted successfully. BIC: {bic:.2f}")
|
|
434
|
+
|
|
435
|
+
return {
|
|
436
|
+
"parameters": self.params,
|
|
437
|
+
"selected_sensors": self.selected_sensors,
|
|
438
|
+
"include_seasonal": hasattr(self, "include_seasonal")
|
|
439
|
+
and self.include_seasonal,
|
|
440
|
+
"log_likelihood": log_lik,
|
|
441
|
+
"bic": bic,
|
|
442
|
+
"convergence": True,
|
|
443
|
+
}
|
|
444
|
+
else:
|
|
445
|
+
logger.error("Model fitting failed to converge")
|
|
446
|
+
return {"convergence": False}
|
|
447
|
+
|
|
448
|
+
def predict_probabilities(
|
|
449
|
+
self, data: pd.DataFrame | SensorDataset, start_idx: int = None
|
|
450
|
+
) -> np.ndarray:
|
|
451
|
+
"""
|
|
452
|
+
Predict one-step-ahead probabilities.
|
|
453
|
+
|
|
454
|
+
Args:
|
|
455
|
+
data: DataFrame with sensor data
|
|
456
|
+
start_idx: Starting index for prediction
|
|
457
|
+
(if None, start from beginning)
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
Array of predicted probabilities
|
|
461
|
+
"""
|
|
462
|
+
if not self.params:
|
|
463
|
+
raise ValueError("Model must be fitted before prediction")
|
|
464
|
+
|
|
465
|
+
# Prepare data
|
|
466
|
+
df = data.to_dataframe() if isinstance(data, SensorDataset) else data
|
|
467
|
+
self.y = df[self.target_sensor].values.astype(float)
|
|
468
|
+
self.X_other = df[self.other_sensors].values.astype(float)
|
|
469
|
+
|
|
470
|
+
if start_idx is None:
|
|
471
|
+
start_idx = 0
|
|
472
|
+
|
|
473
|
+
T = len(self.y)
|
|
474
|
+
ar_cache = np.zeros(T)
|
|
475
|
+
sensor_cache = {s: np.zeros(T) for s in self.selected_sensors}
|
|
476
|
+
for t in range(1, T):
|
|
477
|
+
ar_cache[t] = (
|
|
478
|
+
self.params["phi_b"] * ar_cache[t - 1] + self.y[t - 1]
|
|
479
|
+
) # noqa: E501
|
|
480
|
+
for s in self.selected_sensors:
|
|
481
|
+
idx = self.other_sensors.index(s)
|
|
482
|
+
psi_key = f"psi_{s}"
|
|
483
|
+
sensor_cache[s][t] = (
|
|
484
|
+
self.params[psi_key] * sensor_cache[s][t - 1]
|
|
485
|
+
+ self.X_other[t - 1, idx]
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
seasonal = np.zeros(T)
|
|
489
|
+
if "pi_d" in self.params and "phi_d" in self.params:
|
|
490
|
+
for t in range(self.INTERVALS_PER_DAY, T):
|
|
491
|
+
prev_day = t - self.INTERVALS_PER_DAY
|
|
492
|
+
start = max(0, prev_day - 1)
|
|
493
|
+
end = min(len(self.y), prev_day + 2)
|
|
494
|
+
window_max = np.max(self.y[start:end])
|
|
495
|
+
seasonal[t] = (
|
|
496
|
+
self.params["phi_d"] * seasonal[t - self.INTERVALS_PER_DAY]
|
|
497
|
+
+ window_max
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
delta = self.params["a"] + self.params["pi_b"] * ar_cache
|
|
501
|
+
for s in self.selected_sensors:
|
|
502
|
+
delta += self.params[f"tau_{s}"] * sensor_cache[s]
|
|
503
|
+
if "pi_d" in self.params and "phi_d" in self.params:
|
|
504
|
+
delta += self.params["pi_d"] * seasonal
|
|
505
|
+
|
|
506
|
+
delta = delta[start_idx:]
|
|
507
|
+
probabilities = np.exp(delta) / (1 + np.exp(delta))
|
|
508
|
+
return probabilities
|
|
509
|
+
|
|
510
|
+
def compute_quantile_intervals(
|
|
511
|
+
self, probabilities: np.ndarray, confidence: float = 0.95
|
|
512
|
+
) -> Dict:
|
|
513
|
+
"""
|
|
514
|
+
Compute quantile intervals using Poisson binomial distribution.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
probabilities: Array of predicted probabilities
|
|
518
|
+
(grouped by time of day)
|
|
519
|
+
confidence: Confidence level (default 0.95)
|
|
520
|
+
|
|
521
|
+
Returns:
|
|
522
|
+
Dictionary with quantile information
|
|
523
|
+
"""
|
|
524
|
+
# Group probabilities by time of day (15-minute intervals)
|
|
525
|
+
n_intervals = len(probabilities)
|
|
526
|
+
n_days = n_intervals // self.INTERVALS_PER_DAY
|
|
527
|
+
|
|
528
|
+
if n_days == 0:
|
|
529
|
+
logger.warning("Not enough data for daily quantile intervals")
|
|
530
|
+
return {}
|
|
531
|
+
|
|
532
|
+
# Reshape probabilities by time of day
|
|
533
|
+
prob_by_time = probabilities[
|
|
534
|
+
: n_days * self.INTERVALS_PER_DAY
|
|
535
|
+
].reshape( # noqa: E501
|
|
536
|
+
n_days, self.INTERVALS_PER_DAY
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
alpha = 1 - confidence
|
|
540
|
+
lower_quantiles = []
|
|
541
|
+
upper_quantiles = []
|
|
542
|
+
means = []
|
|
543
|
+
|
|
544
|
+
for time_idx in range(self.INTERVALS_PER_DAY):
|
|
545
|
+
time_probs = prob_by_time[:, time_idx]
|
|
546
|
+
|
|
547
|
+
# Compute mean and variance for this time period
|
|
548
|
+
mean = np.sum(time_probs)
|
|
549
|
+
variance = np.sum(time_probs * (1 - time_probs))
|
|
550
|
+
|
|
551
|
+
means.append(mean)
|
|
552
|
+
|
|
553
|
+
# Approximate quantiles using normal distribution
|
|
554
|
+
# (for computational efficiency vs exact Poisson binomial)
|
|
555
|
+
std = np.sqrt(variance)
|
|
556
|
+
|
|
557
|
+
# Normal approximation quantiles
|
|
558
|
+
lower_q = max(0, mean + norm.ppf(alpha / 2) * std)
|
|
559
|
+
upper_q = min(n_days, mean + norm.ppf(1 - alpha / 2) * std)
|
|
560
|
+
|
|
561
|
+
lower_quantiles.append(lower_q)
|
|
562
|
+
upper_quantiles.append(upper_q)
|
|
563
|
+
|
|
564
|
+
return {
|
|
565
|
+
"lower_quantiles": np.array(lower_quantiles),
|
|
566
|
+
"upper_quantiles": np.array(upper_quantiles),
|
|
567
|
+
"means": np.array(means),
|
|
568
|
+
"time_of_day": np.arange(self.INTERVALS_PER_DAY),
|
|
569
|
+
}
|