telemetry-anomdet 0.1.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.
@@ -0,0 +1,273 @@
1
+ # src/telemetry_anomdet/models/ensemble.py
2
+
3
+ """
4
+ Ensemble combinator for anomaly detectors.
5
+
6
+ This module defines an `AnomalyEnsemble` that can wrap multiple
7
+ BaseDetector compatible detectors and combine their scores into a
8
+ single ensemble score and anomaly decision.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Mapping
14
+
15
+ import numpy as np
16
+
17
+ from .base import BaseDetector
18
+
19
+
20
+ class AnomalyEnsemble(BaseDetector):
21
+ """
22
+ Stacking ensemble of anomaly detectors.
23
+
24
+ Parameters
25
+ ----------
26
+ models : Mapping[str, BaseDetector]
27
+ Named detectors to include in the ensemble. Example:
28
+
29
+ {
30
+ "pca": PCAAnomaly(n_components = 10),
31
+ "iso": IsolationForestAnomaly(),
32
+ "gdn": GDN(epochs = 50),
33
+ "tranad": TranAD(window_size = 50),
34
+ }
35
+
36
+ combine : str, default = "mean"
37
+ Strategy for combining normalized per-model scores.
38
+ One of ``"mean"``, ``"median"``, ``"max"``.
39
+ normalize : str, default = "robust"
40
+ Per-model score normalization before combining.
41
+ One of ``"none"``, ``"minmax"``, ``"robust"``.
42
+ Statistics are estimated from training data.
43
+ percentile : float, default = 95.0
44
+ Percentile of ensemble training scores used as the default
45
+ anomaly threshold. Passed to ``BaseDetector._set_post_fit()``.
46
+
47
+ Attributes (set after fit)
48
+ --------------------------
49
+ decision_scores_ : np.ndarray, shape (n_windows,)
50
+ Ensemble anomaly scores on training data.
51
+ threshold_ : float
52
+ Default anomaly cutoff derived from training scores at ``percentile``.
53
+ labels_ : np.ndarray, shape (n_windows,)
54
+ Binary anomaly labels on training data. 0 = normal, 1 = anomaly.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ models: Mapping[str, BaseDetector],
60
+ combine: str = "mean",
61
+ normalize: str = "robust",
62
+ percentile: float = 95.0,
63
+ ):
64
+ super().__init__(percentile=percentile)
65
+ self.models = models
66
+ self.combine = combine
67
+ self.normalize = normalize
68
+
69
+ self._norm_stats: dict[str, dict] = {}
70
+
71
+ # ---- Helpers ----
72
+ def _compute_norm_stats(self, scores: np.ndarray, method: str) -> dict:
73
+ """
74
+ Compute normalization statistics from training scores.
75
+
76
+ Called once during ``fit()``. The returned dict is stored in
77
+ ``_norm_stats`` and reused at inference time by ``_apply_norm()``,
78
+ ensuring test scores are scaled on the same training distribution.
79
+
80
+ ``"robust"`` is preferred over ``"minmax"`` for anomaly detection
81
+ because anomalies are extreme values by definition: they would skew
82
+ a minmax scale and compress the normal operating range.
83
+
84
+ Parameters
85
+ ----------
86
+ scores : np.ndarray, shape (n_windows,)
87
+ Raw training scores from a single detector.
88
+ method : str
89
+ One of ``"none"``, ``"minmax"``, ``"robust"``.
90
+
91
+ Returns
92
+ -------
93
+ dict
94
+ Statistics needed to apply the same normalization to new data.
95
+ """
96
+
97
+ if method == "none":
98
+ return {}
99
+ if method == "minmax":
100
+ s_min = float(np.min(scores))
101
+ s_max = float(np.max(scores))
102
+ if s_max == s_min:
103
+ s_max = s_min + 1e-12
104
+ return {"min": s_min, "max": s_max}
105
+ if method == "robust":
106
+ med = float(np.median(scores))
107
+ q1 = float(np.percentile(scores, 25.0))
108
+ q3 = float(np.percentile(scores, 75.0))
109
+ iqr = q3 - q1
110
+ if iqr <= 0.0:
111
+ iqr = 1e-12
112
+ return {"median": med, "iqr": iqr}
113
+ raise ValueError(f"Unknown normalization method: {method!r}")
114
+
115
+ def _apply_norm(self, scores: np.ndarray, stats: dict, method: str) -> np.ndarray:
116
+ """
117
+ Apply normalization to a score array using training statistics.
118
+
119
+ For ``"robust"``, the formula ``0.5 + (x - median) / (2 * IQR)``
120
+ maps the middle 50% of training scores to approximately ``[0, 1]``.
121
+ Scores outside that range are clipped: genuine anomalies saturate
122
+ at 1.0, which is the desired behavior.
123
+
124
+ Parameters
125
+ ----------
126
+ scores : np.ndarray
127
+ Raw scores from a single detector (training or inference).
128
+ stats : dict
129
+ Statistics returned by ``_compute_norm_stats()``.
130
+ method : str
131
+ Must match the method used to compute ``stats``.
132
+
133
+ Returns
134
+ -------
135
+ np.ndarray
136
+ Normalized scores clipped to ``[0, 1]``.
137
+ """
138
+
139
+ scores = np.asarray(scores, dtype=float)
140
+ if method == "none":
141
+ return scores
142
+ if method == "minmax":
143
+ out = (scores - stats["min"]) / (stats["max"] - stats["min"])
144
+ return np.clip(out, 0.0, 1.0)
145
+ if method == "robust":
146
+ out = 0.5 + (scores - stats["median"]) / (2.0 * stats["iqr"])
147
+ return np.clip(out, 0.0, 1.0)
148
+ raise ValueError(f"Unknown normalization method: {method!r}")
149
+
150
+ def _combine_matrix(self, S: np.ndarray) -> np.ndarray:
151
+ """
152
+ Combine per-model score matrix into ensemble scores.
153
+
154
+ Parameters
155
+ ----------
156
+ S : np.ndarray, shape (n_models, n_windows)
157
+
158
+ Returns
159
+ -------
160
+ np.ndarray, shape (n_windows,)
161
+ """
162
+ if self.combine == "mean":
163
+ return np.mean(S, axis=0)
164
+ if self.combine == "median":
165
+ return np.median(S, axis=0)
166
+ if self.combine == "max":
167
+ return np.max(S, axis=0)
168
+ raise ValueError(f"Unknown combine strategy: {self.combine!r}")
169
+
170
+ # ---- BaseDetector interface ----
171
+ def fit(self, X: np.ndarray, y: np.ndarray | None = None) -> AnomalyEnsemble:
172
+ """
173
+ Fit all detectors and estimate normalization statistics.
174
+
175
+ Parameters
176
+ ----------
177
+ X : np.ndarray, shape (n_windows, window_size, n_features)
178
+ Windowed telemetry tensor from windowify().
179
+ y : ignored
180
+
181
+ Returns
182
+ -------
183
+ self : AnomalyEnsemble
184
+ """
185
+ X = self._validate_X(X)
186
+
187
+ for _, model in self.models.items():
188
+ model.fit(X) if y is None else model.fit(X, y)
189
+
190
+ # Per-model training scores
191
+ per_model = {name: model.decision_function(X) for name, model in self.models.items()}
192
+
193
+ # Learn normalization stats from training scores
194
+ self._norm_stats = {
195
+ name: self._compute_norm_stats(scores, self.normalize)
196
+ for name, scores in per_model.items()
197
+ }
198
+
199
+ # Build normalized score matrix to ensemble scores
200
+ S = np.vstack(
201
+ [
202
+ self._apply_norm(scores, self._norm_stats[name], self.normalize)
203
+ for name, scores in per_model.items()
204
+ ]
205
+ )
206
+ ensemble_scores = self._combine_matrix(S)
207
+
208
+ self._set_post_fit(ensemble_scores)
209
+ return self
210
+
211
+ def decision_function(self, X: np.ndarray, *, normalize: bool = True) -> np.ndarray:
212
+ """
213
+ Compute ensemble anomaly scores.
214
+
215
+ Parameters
216
+ ----------
217
+ X : np.ndarray, shape (n_windows, window_size, n_features)
218
+ normalize : bool, default=True
219
+ Apply configured normalization before combining.
220
+
221
+ Returns
222
+ -------
223
+ scores : np.ndarray, shape (n_windows,)
224
+ Higher = more anomalous.
225
+ """
226
+ self._require_fit()
227
+ X = self._validate_X(X)
228
+
229
+ per_model = self.score_components(X)
230
+
231
+ S = []
232
+ for name, scores in per_model.items():
233
+ if normalize and self.normalize != "none":
234
+ stats = self._norm_stats.get(name)
235
+ if stats is None:
236
+ raise RuntimeError(
237
+ f"No normalization stats for model '{name}'. "
238
+ "Call fit() on the ensemble first."
239
+ )
240
+ S.append(self._apply_norm(scores, stats, self.normalize))
241
+ else:
242
+ S.append(np.asarray(scores, dtype=float))
243
+
244
+ return self._combine_matrix(np.vstack(S))
245
+
246
+ # ---- SHAP hook ----
247
+ def score_components(self, X: np.ndarray) -> dict[str, np.ndarray]:
248
+ """
249
+ Per-model raw anomaly scores before combination.
250
+
251
+ This is the input to SHAPExplainer; SHAP perturbs X and measures
252
+ how each channel affects each model's score independently.
253
+
254
+ Parameters
255
+ ----------
256
+ X : np.ndarray, shape (n_windows, window_size, n_features)
257
+
258
+ Returns
259
+ -------
260
+ dict
261
+ ``{model_name: np.ndarray of shape (n_windows,)}``
262
+ """
263
+ X = self._validate_X(X)
264
+ return {name: model.decision_function(X) for name, model in self.models.items()}
265
+
266
+ # ---- repr ----
267
+ def _get_params(self) -> dict:
268
+ return {
269
+ "models": list(self.models.keys()),
270
+ "combine": self.combine,
271
+ "normalize": self.normalize,
272
+ "percentile": self.percentile,
273
+ }
@@ -0,0 +1,7 @@
1
+ # src/telemetry_anomdet/models/supervised/__init__.py
2
+
3
+ """
4
+ Supervised models (labelled data).
5
+ """
6
+
7
+ __all__ = []
@@ -0,0 +1,12 @@
1
+ # src/telemetry_anomdet/models/unsupervised/__init__.py
2
+
3
+ """
4
+ Unsupervised anomaly detection models.
5
+ """
6
+
7
+ from .gaussian_nb import GaussianNaiveBayes
8
+ from .isolation_forest import IsolationForestModel
9
+ from .kmeans import KMeansAnomaly
10
+ from .pca import PCAAnomaly
11
+
12
+ __all__ = ["IsolationForestModel", "GaussianNaiveBayes", "KMeansAnomaly", "PCAAnomaly"]
@@ -0,0 +1,61 @@
1
+ # src/telemetry_anomdet/models/supervised/bayes.py
2
+
3
+ """
4
+ Gaussian Naive Bayes
5
+
6
+ Each feature is assumed to follow a Gaussian (normal) distribution conditioned on the class label y.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+ import numpy as np
14
+
15
+ from ..base import BaseDetector
16
+
17
+
18
+ @dataclass
19
+ class GaussianNaiveBayes(BaseDetector):
20
+ """
21
+ Gaussian Naive Bayes classifier.
22
+
23
+ Attributes
24
+ ----------
25
+ classes : np.ndarray
26
+ Unique class labels observed during training.
27
+ mean : dict
28
+ Per-class feature means.
29
+ var : dict
30
+ Per-class feature variances.
31
+ priors : dict
32
+ Class prior probabilities.
33
+ """
34
+
35
+ def fit(self, X: np.ndarray, y: np.ndarray):
36
+ """
37
+ Estimate mean, variance, and prior for each class.
38
+ """
39
+
40
+ pass
41
+
42
+ def _pdf(self, class_idx, x: np.ndarray):
43
+ """
44
+ Compute the Gaussian probability density for a given class.
45
+ """
46
+
47
+ pass
48
+
49
+ def _posterior(self, x: np.ndarray):
50
+ """
51
+ Compute the posterior probability for each class.
52
+ """
53
+
54
+ pass
55
+
56
+ def predict(self, X: np.ndarray):
57
+ """
58
+ Predict class label for each sample.
59
+ """
60
+
61
+ pass
@@ -0,0 +1,28 @@
1
+ # src/telemetry_anomdet/models/unsupervised/isolation_forest.py
2
+
3
+ """
4
+ Isolation Forest unsupervised anomaly detector wrapper.
5
+
6
+ This module exposes a simple class with a consistent API:
7
+ - fit(X)
8
+ - predict(X) -> anomaly scores (higher = more anomalous)
9
+ - save / load via BaseModel
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+
16
+ from ..base import BaseDetector
17
+
18
+
19
+ @dataclass
20
+ class IsolationForestModel(BaseDetector):
21
+ """
22
+ Wrapper for sklearn IsolationForest.
23
+
24
+ Parameters
25
+ ----------
26
+ config:
27
+ Optional dict to pass to sklearn IsolationForest.
28
+ """
@@ -0,0 +1,204 @@
1
+ # src/telemetry_anomdet/models/unsupervised/kmeans.py
2
+
3
+ """
4
+ K-Means clustering anomaly detection.
5
+
6
+ Each telemetry window is assigned to its nearest cluster centroid.
7
+ Anomaly scores are distances to the nearest centroid. Windows far from
8
+ any learned nominal operating mode score higher and are flagged as anomalies.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import numpy as np
14
+ from sklearn.cluster import KMeans
15
+ from sklearn.preprocessing import StandardScaler
16
+
17
+ from ...feature_extraction import features
18
+ from ..base import BaseDetector
19
+
20
+
21
+ class KMeansAnomaly(BaseDetector):
22
+ """
23
+ K-Means clustering based anomaly detector.
24
+
25
+ Accepts 3D windowed input (n_windows, window_size, n_features) and
26
+ flattens internally via features_stat() before clustering. The caller
27
+ never needs to manage this conversion.
28
+
29
+ Parameters
30
+ ----------
31
+ n_clusters : int, default = 5
32
+ Number of clusters (nominal operating modes) to learn. Each cluster
33
+ represents a recurring pattern in the telemetry.
34
+ scale : bool, default = False
35
+ Apply StandardScaler before clustering. Enable when channels differ
36
+ significantly in scale so distance calculations are not dominated by
37
+ high-magnitude channels.
38
+ percentile : float, default = 95.0
39
+ Percentile of training centroid distances used to set the default
40
+ anomaly threshold. 95.0 means the top 5% most distant training
41
+ windows are labelled as anomalies.
42
+
43
+ Attributes (set after fit)
44
+ --------------------------
45
+ decision_scores_ : np.ndarray, shape (n_windows,)
46
+ Distance-to-nearest-centroid scores on training data.
47
+ threshold_ : float
48
+ Default anomaly cutoff derived from training scores at ``percentile``.
49
+ labels_ : np.ndarray, shape (n_windows,)
50
+ Binary anomaly labels on training data. 0 = normal, 1 = anomaly.
51
+ model : sklearn.cluster.KMeans
52
+ Fitted KMeans instance.
53
+ centroids : np.ndarray, shape (n_clusters, n_features)
54
+ Learned cluster centers in the (optionally scaled) feature space.
55
+ scaler : sklearn.preprocessing.StandardScaler or None
56
+ Fitted scaler when ``scale=True``, otherwise None.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ n_clusters: int = 5,
62
+ scale: bool = False,
63
+ percentile: float = 95.0,
64
+ ):
65
+ super().__init__(percentile=percentile)
66
+ self.n_clusters = n_clusters
67
+ self.scale = scale
68
+
69
+ # Fit artifacts: set in fit()
70
+ self.model: KMeans | None = None
71
+ self.centroids: np.ndarray | None = None
72
+ self.scaler: StandardScaler | None = None
73
+
74
+ # ---- helpers ----
75
+ def _flatten(self, X: np.ndarray) -> np.ndarray:
76
+ """
77
+ Flatten 3D windowed tensor to 2D feature matrix via features_stat().
78
+
79
+ Parameters
80
+ ----------
81
+ X : np.ndarray, shape (n_windows, window_size, n_features)
82
+
83
+ Returns
84
+ -------
85
+ X2d : np.ndarray, shape (n_windows, n_features * 6)
86
+ Statistical features per window: mean, std, min, max, median, slope.
87
+ """
88
+ return features.features_stat(X)
89
+
90
+ def _scale_fit(self, X2d: np.ndarray) -> np.ndarray:
91
+ if self.scale:
92
+ self.scaler = StandardScaler()
93
+ return self.scaler.fit_transform(X2d)
94
+ self.scaler = None
95
+ return X2d
96
+
97
+ def _scale_transform(self, X2d: np.ndarray) -> np.ndarray:
98
+ if self.scale:
99
+ if self.scaler is None:
100
+ raise RuntimeError("Scaler is not fitted. Was the model fitted with scale=True?")
101
+ return self.scaler.transform(X2d)
102
+ return X2d
103
+
104
+ def _centroid_distances(self, Xs: np.ndarray) -> np.ndarray:
105
+ """Distance from each sample to its nearest centroid."""
106
+ return self.model.transform(Xs).min(axis=1)
107
+
108
+ # ---- BaseDetector interface ----
109
+ def fit(self, X: np.ndarray, y: np.ndarray | None = None) -> KMeansAnomaly:
110
+ """
111
+ Fit K-Means on nominal telemetry windows.
112
+
113
+ Parameters
114
+ ----------
115
+ X : np.ndarray, shape (n_windows, window_size, n_features)
116
+ Windowed telemetry tensor from windowify().
117
+ y : ignored
118
+ Present for API consistency.
119
+
120
+ Returns
121
+ -------
122
+ self : KMeansAnomaly
123
+
124
+ Raises
125
+ ------
126
+ ValueError
127
+ If n_clusters < 1 or n_clusters > n_windows.
128
+ """
129
+ X = self._validate_X(X)
130
+ X2d = self._flatten(X)
131
+ n_windows = X2d.shape[0]
132
+
133
+ if self.n_clusters < 1:
134
+ raise ValueError("n_clusters must be at least 1.")
135
+ if self.n_clusters > n_windows:
136
+ raise ValueError(
137
+ f"n_clusters ({self.n_clusters}) cannot exceed the number of "
138
+ f"windows ({n_windows}). Reduce n_clusters or increase your data."
139
+ )
140
+
141
+ Xs = self._scale_fit(X2d)
142
+
143
+ self.model = KMeans(
144
+ n_clusters=self.n_clusters,
145
+ n_init=10,
146
+ random_state=0,
147
+ )
148
+ self.model.fit(Xs)
149
+ self.centroids = self.model.cluster_centers_
150
+
151
+ scores = self._centroid_distances(Xs)
152
+ self._set_post_fit(scores)
153
+ return self
154
+
155
+ def decision_function(self, X: np.ndarray) -> np.ndarray:
156
+ """
157
+ Compute distance-to-nearest-centroid scores for each window.
158
+
159
+ Parameters
160
+ ----------
161
+ X : np.ndarray, shape (n_windows, window_size, n_features)
162
+
163
+ Returns
164
+ -------
165
+ scores : np.ndarray, shape (n_windows,)
166
+ Centroid distances. Higher = more anomalous.
167
+ """
168
+ self._require_fit()
169
+ X = self._validate_X(X)
170
+ X2d = self._flatten(X)
171
+ Xs = self._scale_transform(X2d)
172
+ return self._centroid_distances(Xs)
173
+
174
+ # ---- KMeans specific method ----
175
+ def predict_clusters(self, X: np.ndarray) -> np.ndarray:
176
+ """
177
+ Assign each window to its nearest cluster.
178
+
179
+ Unique to KMeansAnomaly. Not part of the BaseDetector interface.
180
+ Useful for understanding which nominal operating mode each window
181
+ belongs to, independent of whether it is flagged as an anomaly.
182
+
183
+ Parameters
184
+ ----------
185
+ X : np.ndarray, shape (n_windows, window_size, n_features)
186
+
187
+ Returns
188
+ -------
189
+ cluster_labels : np.ndarray of int, shape (n_windows,)
190
+ Cluster index in [0, n_clusters - 1] for each window.
191
+ """
192
+ self._require_fit()
193
+ X = self._validate_X(X)
194
+ X2d = self._flatten(X)
195
+ Xs = self._scale_transform(X2d)
196
+ return self.model.predict(Xs)
197
+
198
+ # ---- Repr support ----
199
+ def _get_params(self) -> dict:
200
+ return {
201
+ "n_clusters": self.n_clusters,
202
+ "scale": self.scale,
203
+ "percentile": self.percentile,
204
+ }