sensor-modeling 0.2.0__py3-none-any.whl

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