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,411 @@
1
+ """
2
+ Multivariate Autoregressive Model for Sensor Data
3
+
4
+ This module extends the single-sensor model to predict multiple
5
+ sensors simultaneously while modeling their interactions.
6
+ """
7
+
8
+ import logging
9
+ from typing import Dict, List
10
+
11
+ import matplotlib.pyplot as plt
12
+ import networkx as nx
13
+ import pandas as pd
14
+ import seaborn as sns
15
+ from matplotlib.figure import Figure
16
+
17
+ from ...analysis.dependency_network import SensorDependencyNetwork
18
+ from .base_model import BernoulliAutoregressiveModel
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class MultivariateAutoregressiveModel:
24
+ """
25
+ Multivariate extension of the Bernoulli autoregressive model.
26
+ Predicts multiple sensors simultaneously while modeling their interactions.
27
+ """
28
+
29
+ def __init__(self, sensor_names: List[str]):
30
+ """
31
+ Initialize multivariate model.
32
+
33
+ Args:
34
+ sensor_names: List of sensor names to model jointly
35
+ """
36
+ if not sensor_names:
37
+ raise ValueError("sensor_names must contain at least one sensor")
38
+
39
+ self.sensor_names = sensor_names
40
+ self.n_sensors = len(sensor_names)
41
+
42
+ # Model parameters for each sensor
43
+ self.models = {}
44
+ for sensor in sensor_names:
45
+ self.models[sensor] = BernoulliAutoregressiveModel(sensor_names, sensor)
46
+
47
+ # Cross-sensor dependency network
48
+ self.dependency_network = SensorDependencyNetwork()
49
+ self.network = None
50
+
51
+ def fit_joint_model(
52
+ self, data: pd.DataFrame, use_network_structure: bool = True
53
+ ) -> Dict:
54
+ """
55
+ Fit multivariate model with optional network-informed structure.
56
+
57
+ Args:
58
+ data: DataFrame with sensor data
59
+ use_network_structure: Whether to use dependency network for model selection
60
+
61
+ Returns:
62
+ Dictionary with fitting results for all sensors
63
+ """
64
+ logger.info("Fitting multivariate sensor model...")
65
+
66
+ # Build dependency network first
67
+ if use_network_structure:
68
+ self.network = self.dependency_network.build_network(data)
69
+ network_roles = self.dependency_network.identify_sensor_roles()
70
+ logger.info(f"Network roles identified: {network_roles}")
71
+
72
+ results = {}
73
+
74
+ # Fit individual models for each sensor
75
+ for sensor in self.sensor_names:
76
+ logger.info(f"Fitting model for {sensor}...")
77
+
78
+ # If using network structure, pre-select relevant sensors
79
+ if use_network_structure and self.network is not None:
80
+ # Get sensors that have causal influence on current sensor
81
+ predecessors = list(self.network.predecessors(sensor))
82
+ if predecessors:
83
+ # Pre-select sensors based on network structure
84
+ self.models[sensor].selected_sensors = predecessors
85
+ result = self.models[sensor].fit(data, perform_selection=False)
86
+ else:
87
+ # No network predecessors, use regular selection
88
+ result = self.models[sensor].fit(data, perform_selection=True)
89
+ else:
90
+ # Standard fitting with automatic selection
91
+ result = self.models[sensor].fit(data, perform_selection=True)
92
+
93
+ results[sensor] = result
94
+
95
+ return results
96
+
97
+ def predict_joint_probabilities(
98
+ self, data: pd.DataFrame, start_idx: int = None
99
+ ) -> pd.DataFrame:
100
+ """
101
+ Predict probabilities for all sensors jointly.
102
+
103
+ Args:
104
+ data: DataFrame with sensor data
105
+ start_idx: Starting index for prediction
106
+
107
+ Returns:
108
+ DataFrame with predicted probabilities for each sensor
109
+ """
110
+ predictions = {}
111
+
112
+ for sensor in self.sensor_names:
113
+ if self.models[sensor].params:
114
+ probs = self.models[sensor].predict_probabilities(data, start_idx)
115
+ predictions[f"{sensor}_prob"] = probs
116
+ else:
117
+ logger.warning(f"Model for {sensor} not fitted")
118
+
119
+ return pd.DataFrame(predictions)
120
+
121
+ def analyze_sensor_interactions(self, data: pd.DataFrame) -> Dict:
122
+ """
123
+ Comprehensive analysis of sensor interactions.
124
+
125
+ Args:
126
+ data: DataFrame with sensor data
127
+
128
+ Returns:
129
+ Dictionary with various interaction analyses
130
+ """
131
+ # Build network if not already built
132
+ if self.network is None:
133
+ self.network = self.dependency_network.build_network(data)
134
+
135
+ # Get network statistics
136
+ network_stats = self.dependency_network.get_network_statistics()
137
+ sensor_roles = self.dependency_network.identify_sensor_roles()
138
+
139
+ # Calculate mutual information between sensors
140
+ mutual_info = self.dependency_network.calculate_mutual_information(data)
141
+
142
+ # Find sensor clusters/communities
143
+ communities = self.dependency_network.detect_communities()
144
+
145
+ # Critical sensor analysis
146
+ critical_sensors = self.dependency_network.find_critical_sensors()
147
+
148
+ return {
149
+ "network_statistics": network_stats,
150
+ "sensor_roles": sensor_roles,
151
+ "mutual_information": mutual_info,
152
+ "communities": communities,
153
+ "critical_sensors": critical_sensors,
154
+ "causality_results": self.dependency_network.causality_results,
155
+ }
156
+
157
+ def compare_model_approaches(self, data: pd.DataFrame) -> Dict:
158
+ """
159
+ Compare different modeling approaches.
160
+
161
+ Args:
162
+ data: DataFrame with sensor data
163
+
164
+ Returns:
165
+ Dictionary comparing different approaches
166
+ """
167
+ comparison_results = {}
168
+
169
+ # 1. Independent models (no cross-sensor effects)
170
+ logger.info("Fitting independent models...")
171
+ independent_bics = {}
172
+ for sensor in self.sensor_names:
173
+ temp_model = BernoulliAutoregressiveModel([sensor], sensor)
174
+ result = temp_model.fit(data[[sensor]]) # Only use target sensor
175
+ independent_bics[sensor] = result.get("bic", float("inf"))
176
+
177
+ comparison_results["independent_models"] = {
178
+ "total_bic": sum(independent_bics.values()),
179
+ "individual_bics": independent_bics,
180
+ }
181
+
182
+ # 2. Network-informed models
183
+ logger.info("Fitting network-informed models...")
184
+ network_results = self.fit_joint_model(data, use_network_structure=True)
185
+ network_bics = {
186
+ k: v.get("bic", float("inf")) for k, v in network_results.items()
187
+ }
188
+
189
+ comparison_results["network_informed_models"] = {
190
+ "total_bic": sum(network_bics.values()),
191
+ "individual_bics": network_bics,
192
+ }
193
+
194
+ # 3. Full multivariate models (all sensors as predictors)
195
+ logger.info("Fitting full multivariate models...")
196
+ full_results = self.fit_joint_model(data, use_network_structure=False)
197
+ full_bics = {k: v.get("bic", float("inf")) for k, v in full_results.items()}
198
+
199
+ comparison_results["full_multivariate_models"] = {
200
+ "total_bic": sum(full_bics.values()),
201
+ "individual_bics": full_bics,
202
+ }
203
+
204
+ # Calculate improvements
205
+ baseline_bic = comparison_results["independent_models"]["total_bic"]
206
+ network_bic = comparison_results["network_informed_models"]["total_bic"]
207
+ full_bic = comparison_results["full_multivariate_models"]["total_bic"]
208
+
209
+ comparison_results["improvements"] = {
210
+ "network_vs_independent": baseline_bic - network_bic,
211
+ "full_vs_independent": baseline_bic - full_bic,
212
+ "network_vs_full": full_bic - network_bic,
213
+ }
214
+
215
+ return comparison_results
216
+
217
+ def generate_interaction_report(self, data: pd.DataFrame) -> str:
218
+ """
219
+ Generate a comprehensive text report of sensor interactions.
220
+
221
+ Args:
222
+ data: DataFrame with sensor data
223
+
224
+ Returns:
225
+ Formatted text report
226
+ """
227
+ analysis = self.analyze_sensor_interactions(data)
228
+ model_comparison = self.compare_model_approaches(data)
229
+
230
+ report = []
231
+ report.append("SENSOR INTERACTION ANALYSIS REPORT")
232
+ report.append("=" * 50)
233
+
234
+ # Network overview
235
+ net_stats = analysis["network_statistics"]
236
+ report.append("\nNETWORK OVERVIEW:")
237
+ report.append(f"Sensors analyzed: {net_stats['num_nodes']}")
238
+ report.append(f"Causal relationships: {net_stats['num_edges']}")
239
+ report.append(f"Network density: {net_stats['density']:.3f}")
240
+ report.append(f"Connected components: {net_stats['num_components']}")
241
+
242
+ # Sensor roles
243
+ roles = analysis["sensor_roles"]
244
+ report.append("\nSENSOR ROLES:")
245
+ for role, sensors in roles.items():
246
+ if sensors:
247
+ report.append(f"{role.capitalize()}: {', '.join(sensors)}")
248
+
249
+ # Critical sensors
250
+ critical = analysis["critical_sensors"]
251
+ if critical["most_critical"]:
252
+ report.append(f"\nMost critical sensor: {critical['most_critical']}")
253
+
254
+ # Communities
255
+ communities = analysis["communities"]
256
+ if communities:
257
+ report.append("\nSENSOR COMMUNITIES:")
258
+ for i, community in enumerate(communities, 1):
259
+ report.append(f"Community {i}: {', '.join(community)}")
260
+
261
+ # Model comparison
262
+ improvements = model_comparison["improvements"]
263
+ report.append("\nMODEL COMPARISON (BIC improvements):")
264
+ report.append(
265
+ f"Network-informed vs Independent: {improvements['network_vs_independent']:.2f}"
266
+ )
267
+ report.append(
268
+ f"Full multivariate vs Independent: {improvements['full_vs_independent']:.2f}"
269
+ )
270
+ report.append(
271
+ f"Network-informed vs Full: {improvements['network_vs_full']:.2f}"
272
+ )
273
+
274
+ # Significant relationships
275
+ significant = analysis["causality_results"][
276
+ analysis["causality_results"]["causality_detected"]
277
+ ]
278
+ if len(significant) > 0:
279
+ report.append("\nSIGNIFICANT CAUSAL RELATIONSHIPS:")
280
+ for _, row in significant.head(10).iterrows(): # Show top 10
281
+ report.append(
282
+ f"{row['cause']} → {row['effect']} (p={row['p_value']:.4f})"
283
+ )
284
+
285
+ return "\n".join(report)
286
+
287
+ def plot_interaction_summary(
288
+ self, data: pd.DataFrame, *, show: bool = True
289
+ ) -> Figure:
290
+ """
291
+ Create comprehensive visualization of sensor interactions.
292
+
293
+ Args:
294
+ data: DataFrame with sensor data
295
+ """
296
+ analysis = self.analyze_sensor_interactions(data)
297
+
298
+ # Create subplot layout
299
+ fig, _ = plt.subplots(2, 3, figsize=(20, 12))
300
+ fig.suptitle("Sensor Interaction Analysis Summary", fontsize=16, y=0.98)
301
+
302
+ # Plot 1: Dependency Network
303
+ plt.subplot(2, 3, 1)
304
+ try:
305
+ if self.network and len(self.network.edges()) > 0:
306
+ pos = nx.spring_layout(self.network, k=1, iterations=50)
307
+ nx.draw(
308
+ self.network,
309
+ pos,
310
+ with_labels=True,
311
+ node_color="lightblue",
312
+ node_size=500,
313
+ arrows=True,
314
+ arrowsize=20,
315
+ font_size=8,
316
+ )
317
+ plt.title("Dependency Network")
318
+ else:
319
+ plt.text(
320
+ 0.5,
321
+ 0.5,
322
+ "No significant\ndependencies found",
323
+ ha="center",
324
+ va="center",
325
+ transform=plt.gca().transAxes,
326
+ )
327
+ plt.title("Dependency Network")
328
+ except (nx.NetworkXException, ValueError) as exc:
329
+ logger.warning("Network visualization failed: %s", exc)
330
+ plt.text(
331
+ 0.5,
332
+ 0.5,
333
+ "Network visualization\nnot available",
334
+ ha="center",
335
+ va="center",
336
+ transform=plt.gca().transAxes,
337
+ )
338
+ plt.title("Dependency Network")
339
+ plt.axis("off")
340
+
341
+ # Plot 2: Causality Matrix
342
+ plt.subplot(2, 3, 2)
343
+ if self.dependency_network.causality_results is not None:
344
+ pivot_data = self.dependency_network.causality_results.pivot(
345
+ index="effect", columns="cause", values="test_statistic"
346
+ )
347
+ sns.heatmap(pivot_data, annot=True, fmt=".1f", cmap="Reds", cbar=False)
348
+ plt.title("Causality Test Statistics")
349
+ else:
350
+ plt.text(0.5, 0.5, "No causality data", ha="center", va="center")
351
+ plt.title("Causality Matrix")
352
+
353
+ # Plot 3: Mutual Information
354
+ plt.subplot(2, 3, 3)
355
+ mi_matrix = analysis["mutual_information"]
356
+ sns.heatmap(mi_matrix, annot=True, fmt=".3f", cmap="Blues", cbar=False)
357
+ plt.title("Mutual Information")
358
+
359
+ # Plot 4: Sensor Roles
360
+ plt.subplot(2, 3, 4)
361
+ roles = analysis["sensor_roles"]
362
+ role_counts = {role: len(sensors) for role, sensors in roles.items()}
363
+ if any(role_counts.values()):
364
+ plt.bar(role_counts.keys(), role_counts.values())
365
+ plt.title("Sensor Role Distribution")
366
+ plt.xticks(rotation=45)
367
+ else:
368
+ plt.text(
369
+ 0.5,
370
+ 0.5,
371
+ "No distinct roles\nidentified",
372
+ ha="center",
373
+ va="center",
374
+ transform=plt.gca().transAxes,
375
+ )
376
+ plt.title("Sensor Roles")
377
+
378
+ # Plot 5: Network Metrics
379
+ plt.subplot(2, 3, 5)
380
+ net_stats = analysis["network_statistics"]
381
+ metrics = ["num_edges", "density", "num_components"]
382
+ values = [net_stats[metric] for metric in metrics]
383
+ labels = ["Edges", "Density", "Components"]
384
+ plt.bar(labels, values)
385
+ plt.title("Network Metrics")
386
+ plt.xticks(rotation=45)
387
+
388
+ # Plot 6: Communities
389
+ plt.subplot(2, 3, 6)
390
+ communities = analysis["communities"]
391
+ if communities:
392
+ comm_sizes = [len(comm) for comm in communities]
393
+ plt.bar(range(len(comm_sizes)), comm_sizes)
394
+ plt.xlabel("Community")
395
+ plt.ylabel("Size")
396
+ plt.title(f"Communities ({len(communities)} found)")
397
+ else:
398
+ plt.text(
399
+ 0.5,
400
+ 0.5,
401
+ "No communities\ndetected",
402
+ ha="center",
403
+ va="center",
404
+ transform=plt.gca().transAxes,
405
+ )
406
+ plt.title("Community Structure")
407
+
408
+ plt.tight_layout()
409
+ if show:
410
+ plt.show()
411
+ return fig
@@ -0,0 +1,10 @@
1
+ """Change-point detection algorithms."""
2
+
3
+ from .deep import DeepChangePointDetector, DeepCPDConfig
4
+ from .pelt import PELTChangePointDetector
5
+
6
+ __all__ = [
7
+ "PELTChangePointDetector",
8
+ "DeepChangePointDetector",
9
+ "DeepCPDConfig",
10
+ ]
@@ -0,0 +1,65 @@
1
+ """Simple deep-learning-based change-point detector.
2
+
3
+ This implementation uses a small multilayer perceptron from scikit-learn
4
+ as a lightweight stand-in for more sophisticated deep architectures.
5
+ It trains on sliding windows of the input sequence and classifies whether
6
+ the next time step contains a change point.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+ from dataclasses import dataclass
12
+
13
+ import numpy as np
14
+ from sklearn.neural_network import MLPClassifier
15
+
16
+
17
+ @dataclass
18
+ class DeepCPDConfig:
19
+ """Configuration for :class:`DeepChangePointDetector`."""
20
+
21
+ window: int = 5
22
+ hidden: int = 32
23
+ threshold: float = 0.5
24
+
25
+
26
+ class DeepChangePointDetector:
27
+ """Detect change points using a small neural network."""
28
+
29
+ def __init__(self, cfg: DeepCPDConfig | None = None):
30
+ """Initialize the detector with optional configuration."""
31
+ self.cfg = cfg or DeepCPDConfig()
32
+ hidden = (self.cfg.hidden,)
33
+ self.model = MLPClassifier(hidden_layer_sizes=hidden, max_iter=200)
34
+
35
+ def _build_dataset(self, seq: Sequence[float]):
36
+ """Construct sliding-window features and change labels."""
37
+ x, y = [], []
38
+ w = self.cfg.window
39
+ arr = np.asarray(seq, dtype=float)
40
+ for i in range(len(arr) - w - 1):
41
+ window = arr[i : i + w] # noqa: E203
42
+ label = 1 if arr[i + w] != arr[i + w - 1] else 0
43
+ x.append(window)
44
+ y.append(label)
45
+ return np.vstack(x), np.array(y)
46
+
47
+ def fit(self, seq: Sequence[float]) -> DeepChangePointDetector:
48
+ """Train the underlying classifier on a sequence."""
49
+ X, y = self._build_dataset(seq)
50
+ if X.size:
51
+ self.model.fit(X, y)
52
+ return self
53
+
54
+ def detect(self, seq: Sequence[float]) -> np.ndarray:
55
+ """Return an array with 1 where a change point is predicted."""
56
+ X, _ = self._build_dataset(seq)
57
+ if X.size == 0:
58
+ return np.zeros(len(seq), dtype=int)
59
+ probs = self.model.predict_proba(X)[:, 1]
60
+ preds = probs > self.cfg.threshold
61
+ result = np.zeros(len(seq), dtype=int)
62
+ start = self.cfg.window + 1
63
+ stop = start + len(preds)
64
+ result[start:stop] = preds
65
+ return result
@@ -0,0 +1,159 @@
1
+ """Pruned Exact Linear Time (PELT) change-point detector.
2
+
3
+ The detector segments a univariate signal by minimizing
4
+
5
+ ``sum(segment_cost) + penalty * n_change_points``
6
+
7
+ with exact dynamic programming and PELT pruning. Returned indices are
8
+ 0-based change-point locations that mark the first sample of a new segment.
9
+
10
+ The default ``"l2"`` cost models piecewise-constant mean shifts and uses
11
+ prefix sums for fast segment evaluation. The alternative ``"l1"`` cost and
12
+ custom cost callables are supported for robustness or experimentation, but
13
+ they recompute segment costs directly and are therefore slower. As with
14
+ standard PELT, observed runtime is often near-linear when pruning is effective
15
+ and can degrade to quadratic in adversarial cases.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ from collections.abc import Callable, Sequence
22
+ from dataclasses import dataclass
23
+ from numbers import Integral
24
+ from typing import List
25
+
26
+ import numpy as np
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ SegmentCost = Callable[[np.ndarray], float]
31
+
32
+
33
+ @dataclass
34
+ class PELTChangePointDetector:
35
+ """Detect univariate change points with configurable penalties and costs."""
36
+
37
+ penalty: float = 1.0
38
+ min_segment_length: int = 2
39
+ cost: str | SegmentCost = "l2"
40
+
41
+ def __post_init__(self) -> None:
42
+ """Validate detector configuration at construction time."""
43
+ try:
44
+ self.penalty = float(self.penalty)
45
+ except (TypeError, ValueError) as exc:
46
+ raise ValueError("penalty must be positive and finite") from exc
47
+ if not np.isfinite(self.penalty) or self.penalty <= 0:
48
+ raise ValueError("penalty must be positive and finite")
49
+ if (
50
+ isinstance(self.min_segment_length, bool)
51
+ or not isinstance(self.min_segment_length, Integral)
52
+ or self.min_segment_length < 1
53
+ ):
54
+ raise ValueError("min_segment_length must be a positive integer")
55
+ self.min_segment_length = int(self.min_segment_length)
56
+ if not callable(self.cost) and self.cost not in {"l1", "l2"}:
57
+ raise ValueError("cost must be 'l1', 'l2', or a callable segment cost")
58
+
59
+ def _validate_signal(self, signal: Sequence[float] | np.ndarray) -> np.ndarray:
60
+ """Return *signal* as a finite 1D float array."""
61
+ arr = np.asarray(signal, dtype=float)
62
+ if arr.ndim != 1:
63
+ raise ValueError("PELTChangePointDetector only supports 1D signals")
64
+ if arr.size == 0:
65
+ raise ValueError("signal must contain at least one observation")
66
+ if not np.all(np.isfinite(arr)):
67
+ raise ValueError("signal must contain only finite numeric values")
68
+ return arr
69
+
70
+ def _cost_function(self, signal: np.ndarray) -> Callable[[int, int], float]:
71
+ """Build a segment cost function over half-open intervals."""
72
+ if callable(self.cost):
73
+
74
+ def custom_cost(start: int, end: int) -> float:
75
+ value = float(self.cost(signal[start:end]))
76
+ if not np.isfinite(value):
77
+ raise ValueError("segment cost must return finite values")
78
+ return value
79
+
80
+ return custom_cost
81
+
82
+ if self.cost == "l2":
83
+ prefix_sum = np.concatenate(([0.0], np.cumsum(signal)))
84
+ prefix_sq = np.concatenate(([0.0], np.cumsum(signal * signal)))
85
+
86
+ def l2_cost(start: int, end: int) -> float:
87
+ seg_sum = prefix_sum[end] - prefix_sum[start]
88
+ seg_sq = prefix_sq[end] - prefix_sq[start]
89
+ length = end - start
90
+ return float(seg_sq - (seg_sum * seg_sum) / length)
91
+
92
+ return l2_cost
93
+
94
+ if self.cost == "l1":
95
+ return lambda start, end: float(
96
+ np.abs(signal[start:end] - np.median(signal[start:end])).sum()
97
+ )
98
+ raise ValueError("cost must be 'l1', 'l2', or a callable segment cost")
99
+
100
+ def detect(self, signal: Sequence[float] | np.ndarray) -> List[int]:
101
+ """Detect change points in a univariate signal.
102
+
103
+ The returned indices are 0-based segment starts, excluding ``0`` and
104
+ ``len(signal)``. For example, ``[20, 35]`` means segments
105
+ ``signal[:20]``, ``signal[20:35]``, and ``signal[35:]``.
106
+ """
107
+ arr = self._validate_signal(signal)
108
+ n_samples = len(arr)
109
+ if n_samples < 2 * self.min_segment_length:
110
+ return []
111
+
112
+ segment_cost = self._cost_function(arr)
113
+ best_cost = np.full(n_samples + 1, np.inf)
114
+ previous = np.full(n_samples + 1, -1, dtype=int)
115
+ best_cost[0] = -self.penalty
116
+ candidates = [0]
117
+
118
+ for end in range(self.min_segment_length, n_samples + 1):
119
+ valid_candidates = [
120
+ start
121
+ for start in candidates
122
+ if end - start >= self.min_segment_length
123
+ and np.isfinite(best_cost[start])
124
+ ]
125
+ if not valid_candidates:
126
+ continue
127
+
128
+ totals = [
129
+ best_cost[start] + segment_cost(start, end) + self.penalty
130
+ for start in valid_candidates
131
+ ]
132
+ best_idx = int(np.argmin(totals))
133
+ best_start = valid_candidates[best_idx]
134
+ best_cost[end] = totals[best_idx]
135
+ previous[end] = best_start
136
+
137
+ retained = [
138
+ start
139
+ for start in candidates
140
+ if end - start < self.min_segment_length
141
+ or best_cost[start] + segment_cost(start, end) <= best_cost[end]
142
+ ]
143
+ candidates = retained + [end]
144
+
145
+ if not np.isfinite(best_cost[n_samples]):
146
+ return []
147
+
148
+ change_points: List[int] = []
149
+ end = n_samples
150
+ while previous[end] > 0:
151
+ change_points.append(int(previous[end]))
152
+ end = int(previous[end])
153
+ change_points.reverse()
154
+ logger.info(
155
+ "PELT detected %d change points in signal of length %d",
156
+ len(change_points),
157
+ n_samples,
158
+ )
159
+ return change_points
@@ -0,0 +1,5 @@
1
+ """Non-homogeneous Poisson process model with PELT segmentation."""
2
+
3
+ from .model import NHPPPELT, NHPPConfig
4
+
5
+ __all__ = ["NHPPPELT", "NHPPConfig"]