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,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,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
|