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.
- telemetry_anomdet/__init__.py +20 -0
- telemetry_anomdet/evaluation.py +167 -0
- telemetry_anomdet/feature_extraction/features.py +200 -0
- telemetry_anomdet/ingest/__init__.py +21 -0
- telemetry_anomdet/ingest/ccsds_loader.py +18 -0
- telemetry_anomdet/ingest/csv_loader.py +133 -0
- telemetry_anomdet/ingest/dataset.py +80 -0
- telemetry_anomdet/ingest/smap.py +213 -0
- telemetry_anomdet/models/__init__.py +13 -0
- telemetry_anomdet/models/base.py +365 -0
- telemetry_anomdet/models/ensemble.py +273 -0
- telemetry_anomdet/models/supervised/__init__.py +7 -0
- telemetry_anomdet/models/unsupervised/__init__.py +12 -0
- telemetry_anomdet/models/unsupervised/gaussian_nb.py +61 -0
- telemetry_anomdet/models/unsupervised/isolation_forest.py +28 -0
- telemetry_anomdet/models/unsupervised/kmeans.py +204 -0
- telemetry_anomdet/models/unsupervised/pca.py +158 -0
- telemetry_anomdet/preprocessing/__init__.py +19 -0
- telemetry_anomdet/preprocessing/preprocessing.py +330 -0
- telemetry_anomdet/py.typed +0 -0
- telemetry_anomdet/version.py +15 -0
- telemetry_anomdet/visualization.py +13 -0
- telemetry_anomdet-0.1.0.dist-info/METADATA +364 -0
- telemetry_anomdet-0.1.0.dist-info/RECORD +27 -0
- telemetry_anomdet-0.1.0.dist-info/WHEEL +5 -0
- telemetry_anomdet-0.1.0.dist-info/licenses/LICENSE +201 -0
- telemetry_anomdet-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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,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
|
+
}
|