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,213 @@
|
|
|
1
|
+
# src/telemetry_anomdet/ingest/smap.py
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Loader for the NASA SMAP / MSL telemetry benchmark (telemanom format).
|
|
5
|
+
|
|
6
|
+
The benchmark ships one ``.npy`` array per channel under ``train/`` and
|
|
7
|
+
``test/`` directories, each of shape ``(timesteps, features)`` where column 0
|
|
8
|
+
is the telemetry value and the remaining columns are one-hot command context.
|
|
9
|
+
Anomaly labels live in ``labeled_anomalies.csv`` and are used for evaluation
|
|
10
|
+
only, never for training.
|
|
11
|
+
|
|
12
|
+
SMAP arrays carry no real timestamps, so this loader synthesizes a uniform
|
|
13
|
+
time index (configurable cadence). Output is the canonical long form
|
|
14
|
+
``[timestamp, variable, value]`` wrapped in a :class:`TelemetryDataset`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import ast
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
import pandas as pd
|
|
25
|
+
|
|
26
|
+
from telemetry_anomdet.ingest.dataset import TelemetryDataset
|
|
27
|
+
|
|
28
|
+
# Canonical columns
|
|
29
|
+
_TS, _VAR, _VAL = "timestamp", "variable", "value"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_smap_labels(labels_csv: str | Path, *, spacecraft: str | None = "SMAP") -> pd.DataFrame:
|
|
33
|
+
"""
|
|
34
|
+
Load and parse ``labeled_anomalies.csv``.
|
|
35
|
+
|
|
36
|
+
Arguments:
|
|
37
|
+
labels_csv: Path to the telemanom ``labeled_anomalies.csv``.
|
|
38
|
+
spacecraft: Keep only rows for this spacecraft ('SMAP' or 'MSL').
|
|
39
|
+
None keeps all rows.
|
|
40
|
+
Returns:
|
|
41
|
+
pd.DataFrame: The label rows with two added columns:
|
|
42
|
+
'sequences' (list of ``(start, end)`` index tuples) and
|
|
43
|
+
'anomaly_span' (total number of anomalous timesteps).
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
labels = pd.read_csv(labels_csv)
|
|
47
|
+
if spacecraft is not None:
|
|
48
|
+
labels = labels[labels["spacecraft"] == spacecraft].reset_index(drop=True)
|
|
49
|
+
if len(labels) == 0:
|
|
50
|
+
raise ValueError(f"No rows for spacecraft {spacecraft!r} in {labels_csv}")
|
|
51
|
+
|
|
52
|
+
labels = labels.copy()
|
|
53
|
+
labels["sequences"] = labels["anomaly_sequences"].apply(_parse_sequences)
|
|
54
|
+
labels["anomaly_span"] = labels["sequences"].apply(
|
|
55
|
+
lambda seqs: sum(end - start + 1 for start, end in seqs)
|
|
56
|
+
)
|
|
57
|
+
return labels
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def anomaly_point_mask(sequences: Sequence[tuple[int, int]], n_timesteps: int) -> np.ndarray:
|
|
61
|
+
"""
|
|
62
|
+
Build a point-level boolean mask from anomaly index ranges.
|
|
63
|
+
|
|
64
|
+
Arguments:
|
|
65
|
+
sequences: Iterable of inclusive ``(start, end)`` index ranges.
|
|
66
|
+
n_timesteps: Length of the mask.
|
|
67
|
+
Returns:
|
|
68
|
+
np.ndarray: Boolean array of length ``n_timesteps``, True inside any range.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
mask = np.zeros(int(n_timesteps), dtype=bool)
|
|
72
|
+
for start, end in sequences:
|
|
73
|
+
lo = max(0, int(start))
|
|
74
|
+
hi = min(n_timesteps - 1, int(end))
|
|
75
|
+
if hi >= lo:
|
|
76
|
+
mask[lo : hi + 1] = True
|
|
77
|
+
return mask
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load_smap_channel(
|
|
81
|
+
npy_path: str | Path,
|
|
82
|
+
chan_id: str | None = None,
|
|
83
|
+
*,
|
|
84
|
+
dims: str | Sequence[int] = "nonzero",
|
|
85
|
+
cadence: str = "1s",
|
|
86
|
+
start: str = "2000-01-01",
|
|
87
|
+
tz: str = "UTC",
|
|
88
|
+
) -> TelemetryDataset:
|
|
89
|
+
"""
|
|
90
|
+
Load a single SMAP channel ``.npy`` into a long-form TelemetryDataset.
|
|
91
|
+
|
|
92
|
+
Arguments:
|
|
93
|
+
npy_path: Path to the channel array of shape ``(timesteps, features)``.
|
|
94
|
+
chan_id: Channel name used to prefix variables. Defaults to the file stem.
|
|
95
|
+
dims: Which feature columns to keep. 'nonzero' drops all-zero columns
|
|
96
|
+
(the default, matching the command one-hots that are inactive for a
|
|
97
|
+
channel), 'all' keeps every column, 'telemetry' keeps only column 0,
|
|
98
|
+
or pass an explicit list of column indices.
|
|
99
|
+
cadence: Synthetic sampling interval (pandas offset alias, e.g. '1s').
|
|
100
|
+
start: Synthetic start timestamp for the first sample.
|
|
101
|
+
tz: Timezone for the synthesized index.
|
|
102
|
+
Returns:
|
|
103
|
+
TelemetryDataset: Long form with variables named ``f"{chan_id}_dim{j}"``.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
npy_path = Path(npy_path)
|
|
107
|
+
if not npy_path.exists():
|
|
108
|
+
raise FileNotFoundError(f"SMAP channel file not found: {npy_path}")
|
|
109
|
+
if chan_id is None:
|
|
110
|
+
chan_id = npy_path.stem
|
|
111
|
+
|
|
112
|
+
arr = np.load(npy_path)
|
|
113
|
+
if arr.ndim == 1:
|
|
114
|
+
arr = arr.reshape(-1, 1)
|
|
115
|
+
n_timesteps = arr.shape[0]
|
|
116
|
+
|
|
117
|
+
used_dims = _select_dims(arr, dims)
|
|
118
|
+
timestamps = pd.date_range(start, periods=n_timesteps, freq=cadence, tz=tz)
|
|
119
|
+
|
|
120
|
+
frames = [
|
|
121
|
+
pd.DataFrame(
|
|
122
|
+
{
|
|
123
|
+
_TS: timestamps,
|
|
124
|
+
_VAR: f"{chan_id}_dim{j}",
|
|
125
|
+
_VAL: arr[:, j].astype(float),
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
for j in used_dims
|
|
129
|
+
]
|
|
130
|
+
long = pd.concat(frames, ignore_index=True)
|
|
131
|
+
return TelemetryDataset(_coerce_long(long))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def load_smap(
|
|
135
|
+
data_dir: str | Path,
|
|
136
|
+
channels: Sequence[str],
|
|
137
|
+
*,
|
|
138
|
+
split: str = "test",
|
|
139
|
+
dims: str | Sequence[int] = "nonzero",
|
|
140
|
+
cadence: str = "1s",
|
|
141
|
+
start: str = "2000-01-01",
|
|
142
|
+
tz: str = "UTC",
|
|
143
|
+
) -> TelemetryDataset:
|
|
144
|
+
"""
|
|
145
|
+
Load several SMAP channels into one combined long-form TelemetryDataset.
|
|
146
|
+
|
|
147
|
+
Arguments:
|
|
148
|
+
data_dir: Directory containing the ``train/`` and ``test/`` subfolders.
|
|
149
|
+
channels: Channel ids to load (e.g. ['A-1', 'D-2']).
|
|
150
|
+
split: 'train' or 'test'.
|
|
151
|
+
dims, cadence, start, tz: Passed through to :func:`load_smap_channel`.
|
|
152
|
+
Returns:
|
|
153
|
+
TelemetryDataset: Combined long form; each channel keeps its own
|
|
154
|
+
``f"{chan_id}_dim{j}"`` variables so they never collide.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
if not channels:
|
|
158
|
+
raise ValueError("channels must be a non-empty sequence of channel ids")
|
|
159
|
+
|
|
160
|
+
split_dir = Path(data_dir) / split
|
|
161
|
+
frames = [
|
|
162
|
+
load_smap_channel(
|
|
163
|
+
split_dir / f"{chan_id}.npy",
|
|
164
|
+
chan_id,
|
|
165
|
+
dims=dims,
|
|
166
|
+
cadence=cadence,
|
|
167
|
+
start=start,
|
|
168
|
+
tz=tz,
|
|
169
|
+
).to_pandas()
|
|
170
|
+
for chan_id in channels
|
|
171
|
+
]
|
|
172
|
+
long = pd.concat(frames, ignore_index=True)
|
|
173
|
+
return TelemetryDataset(_coerce_long(long))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# --------------------------------------------------------------------------- #
|
|
177
|
+
# helpers
|
|
178
|
+
# --------------------------------------------------------------------------- #
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _parse_sequences(seq_str: str) -> list[tuple[int, int]]:
|
|
182
|
+
"""Parse an ``anomaly_sequences`` cell into a list of (start, end) tuples."""
|
|
183
|
+
parsed = ast.literal_eval(seq_str) if isinstance(seq_str, str) else seq_str
|
|
184
|
+
return [(int(a), int(b)) for a, b in parsed]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _select_dims(arr: np.ndarray, dims: str | Sequence[int]) -> list[int]:
|
|
188
|
+
"""Resolve the ``dims`` argument to a concrete list of column indices."""
|
|
189
|
+
n_dims = arr.shape[1]
|
|
190
|
+
if isinstance(dims, str):
|
|
191
|
+
if dims == "all":
|
|
192
|
+
return list(range(n_dims))
|
|
193
|
+
if dims == "telemetry":
|
|
194
|
+
return [0]
|
|
195
|
+
if dims == "nonzero":
|
|
196
|
+
used = [j for j in range(n_dims) if np.any(arr[:, j] != 0.0)]
|
|
197
|
+
return used or [0] # fall back to the telemetry column if all zero
|
|
198
|
+
raise ValueError(f"Unknown dims option: {dims!r}")
|
|
199
|
+
used = [int(j) for j in dims]
|
|
200
|
+
if not used:
|
|
201
|
+
raise ValueError("dims list must not be empty")
|
|
202
|
+
if any(j < 0 or j >= n_dims for j in used):
|
|
203
|
+
raise ValueError(f"dims {used} out of range for array with {n_dims} columns")
|
|
204
|
+
return used
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _coerce_long(df: pd.DataFrame) -> pd.DataFrame:
|
|
208
|
+
"""Sort, type, and order a long-form frame (mirrors csv_loader.coerce_long)."""
|
|
209
|
+
df[_TS] = pd.to_datetime(df[_TS], utc=True)
|
|
210
|
+
df[_VAL] = pd.to_numeric(df[_VAL], errors="coerce")
|
|
211
|
+
df[_VAR] = df[_VAR].astype(str)
|
|
212
|
+
df = df.dropna(subset=[_TS, _VAR, _VAL]).sort_values([_TS, _VAR]).reset_index(drop=True)
|
|
213
|
+
return df[[_TS, _VAR, _VAL]]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# src/telemetry_anomdet/models/__init__.py
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Models package for telemetry_anomdet.
|
|
5
|
+
|
|
6
|
+
Subpackages:
|
|
7
|
+
- supervised: models that require labels (classification/regression).
|
|
8
|
+
- unsupervised: anomaly detection algorithms that operate without labels.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from . import supervised, unsupervised
|
|
12
|
+
|
|
13
|
+
__all__ = ["supervised", "unsupervised"]
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
# src/telemetry_anomdet/models/base.py
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
BaseDetector: Shared interface for all anomaly detectors in telemetry_anomdet.
|
|
5
|
+
|
|
6
|
+
Design
|
|
7
|
+
------
|
|
8
|
+
All detectors in this library follow PyOD convention.
|
|
9
|
+
|
|
10
|
+
fit(x): Train the model on the provided data, sets post-fit attributes.
|
|
11
|
+
predict(x): Return binary labels (0 for normal, 1 for anomaly).
|
|
12
|
+
decision_function(x): Return anomaly scores (higher means more anomalous).
|
|
13
|
+
is_anomaly(x): Boolean mask. Supports runtime thresholding override.
|
|
14
|
+
|
|
15
|
+
Input Convention
|
|
16
|
+
----------------
|
|
17
|
+
All detectors accept X of shape (n_windows, window_size, n_features). Classical detecrtors flatten X internally
|
|
18
|
+
using feature_stat(). Sequence detectors (GDN, TranAD) consume X directly.
|
|
19
|
+
The caller never maneges this distinction.
|
|
20
|
+
|
|
21
|
+
Post-fit Attributes
|
|
22
|
+
----------------
|
|
23
|
+
After fit(), every detectror exposes:
|
|
24
|
+
|
|
25
|
+
decision_scores_: np.ndarray (n_windows,) Training anomaly scores.
|
|
26
|
+
threshold_: float Score cutoff from training.
|
|
27
|
+
labels_: np.ndarray (n_windows,) Binary labels from training (0 normal, 1 anomaly).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import pickle
|
|
33
|
+
from abc import ABC, abstractmethod
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
import numpy as np
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BaseDetector(ABC):
|
|
40
|
+
"""
|
|
41
|
+
Abstract base class for all anomaly detectors in telemetry-anomdet.
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
percentile : float, default = 95.0
|
|
46
|
+
Percentile of training anomaly scores used to set ``threshold_``.
|
|
47
|
+
For example, 95.0 means the top 5% most anomalous training windows
|
|
48
|
+
are labelled as anomalies by default. Must be in (0, 100).
|
|
49
|
+
|
|
50
|
+
Attributes (set after fit)
|
|
51
|
+
--------------------------
|
|
52
|
+
decision_scores_ : np.ndarray, shape (n_windows,)
|
|
53
|
+
Anomaly scores on the training data. Higher values indicate greater
|
|
54
|
+
anomaly likelihood. Set by ``_set_post_fit()`` at the end of ``fit()``.
|
|
55
|
+
threshold_ : float
|
|
56
|
+
Score cutoff derived from ``decision_scores_`` at ``self.percentile``.
|
|
57
|
+
Used as the default decision boundary in ``predict()`` and
|
|
58
|
+
``is_anomaly()``.
|
|
59
|
+
labels_ : np.ndarray, shape (n_windows,)
|
|
60
|
+
Binary anomaly labels on the training data. 0 = normal, 1 = anomaly.
|
|
61
|
+
Derived from ``decision_scores_`` and ``threshold_``.
|
|
62
|
+
|
|
63
|
+
Notes
|
|
64
|
+
-----
|
|
65
|
+
Subclasses must implement ``fit()`` and ``decision_function()``.
|
|
66
|
+
All other methods are provided and should not need to be overridden
|
|
67
|
+
unless the detector has non-standard scoring behavior.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self, percentile: float = 95.0):
|
|
71
|
+
if not (0.0 < percentile < 100.0):
|
|
72
|
+
raise ValueError(f"percentile must be in (0, 100), got {percentile!r}.")
|
|
73
|
+
self.percentile = percentile
|
|
74
|
+
|
|
75
|
+
# Post-fit attributes; None until fit() is called!
|
|
76
|
+
self.decision_scores_: np.ndarray | None = None
|
|
77
|
+
self.threshold_: float | None = None
|
|
78
|
+
self.labels_: np.ndarray | None = None
|
|
79
|
+
|
|
80
|
+
@abstractmethod
|
|
81
|
+
def fit(self, X: np.ndarray, y: np.ndarray | None = None) -> BaseDetector:
|
|
82
|
+
"""
|
|
83
|
+
Train the detector on nominal telemetry windows.
|
|
84
|
+
|
|
85
|
+
Subclasses must call ``self._set_post_fit(scores)`` at the end of
|
|
86
|
+
this method, where ``scores`` are the training anomaly scores. This
|
|
87
|
+
sets ``decision_scores_``, ``threshold_``, and ``labels_``
|
|
88
|
+
automatically.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
X : np.ndarray, shape (n_windows, window_size, n_features)
|
|
93
|
+
Windowed telemetry tensor from ``windowify()``.
|
|
94
|
+
y : np.ndarray, optional
|
|
95
|
+
Ignored for unsupervised detectors. Present for API consistency.
|
|
96
|
+
|
|
97
|
+
Returns
|
|
98
|
+
-------
|
|
99
|
+
self : BaseDetector
|
|
100
|
+
Fitted detector instance, for method chaining.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
...
|
|
104
|
+
|
|
105
|
+
@abstractmethod
|
|
106
|
+
def decision_function(self, X: np.ndarray) -> np.ndarray:
|
|
107
|
+
"""
|
|
108
|
+
Compute raw anomaly scores for each input window.
|
|
109
|
+
|
|
110
|
+
Higher scores indicate greater anomaly likelihood. This is the
|
|
111
|
+
core scoring method. All other scoring methods call this one.
|
|
112
|
+
|
|
113
|
+
Parameters
|
|
114
|
+
----------
|
|
115
|
+
X : np.ndarray, shape (n_windows, window_size, n_features)
|
|
116
|
+
Windowed telemetry tensor from ``windowify()``.
|
|
117
|
+
|
|
118
|
+
Returns
|
|
119
|
+
-------
|
|
120
|
+
scores : np.ndarray, shape (n_windows,)
|
|
121
|
+
Anomaly score per window.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
...
|
|
125
|
+
|
|
126
|
+
# Core methods (implemented by subclass)
|
|
127
|
+
def predict(self, X: np.ndarray):
|
|
128
|
+
"""
|
|
129
|
+
Classify each window as normal (0) or anomalous (1).
|
|
130
|
+
|
|
131
|
+
Uses ``threshold_`` from training by default. For runtime threshold
|
|
132
|
+
adjustment, use ``is_anomaly()`` with a ``threshold`` or
|
|
133
|
+
``percentile`` override instead.
|
|
134
|
+
|
|
135
|
+
Parameters
|
|
136
|
+
----------
|
|
137
|
+
X : np.ndarray, shape (n_windows, window_size, n_features)
|
|
138
|
+
Windowed telemetry tensor from ``windowify()``.
|
|
139
|
+
|
|
140
|
+
Returns
|
|
141
|
+
-------
|
|
142
|
+
labels : np.ndarray, shape (n_windows,)
|
|
143
|
+
Binary labels. 0 = normal, 1 = anomaly.
|
|
144
|
+
"""
|
|
145
|
+
scores = self.decision_function(X)
|
|
146
|
+
return (scores > self._get_threshold()).astype(int)
|
|
147
|
+
|
|
148
|
+
def is_anomaly(
|
|
149
|
+
self,
|
|
150
|
+
X: np.ndarray,
|
|
151
|
+
*,
|
|
152
|
+
threshold: float | None = None,
|
|
153
|
+
percentile: float | None = None,
|
|
154
|
+
) -> np.ndarray:
|
|
155
|
+
"""
|
|
156
|
+
Boolean anomaly mask with optional runtime threshold override.
|
|
157
|
+
|
|
158
|
+
This is the human-in-the-loop hook. Operators can adjust sensitivity
|
|
159
|
+
at inference time without retraining by passing a custom ``threshold``
|
|
160
|
+
or ``percentile``. If neither is provided, the training derived
|
|
161
|
+
``threshold_`` is used.
|
|
162
|
+
|
|
163
|
+
Priority: ``threshold`` > ``percentile`` > ``threshold_``.
|
|
164
|
+
|
|
165
|
+
Parameters
|
|
166
|
+
----------
|
|
167
|
+
X : np.ndarray, shape (n_windows, window_size, n_features)
|
|
168
|
+
Windowed telemetry tensor from ``windowify()``.
|
|
169
|
+
threshold : float, optional
|
|
170
|
+
Fixed score cutoff. Windows with scores above this are anomalies.
|
|
171
|
+
percentile : float, optional
|
|
172
|
+
Compute the cutoff as this percentile of the current batch's
|
|
173
|
+
scores. Useful when the operator wants to flag only the top N%
|
|
174
|
+
of a given pass's windows rather than using training statistics.
|
|
175
|
+
Must be in (0, 100).
|
|
176
|
+
|
|
177
|
+
Returns
|
|
178
|
+
-------
|
|
179
|
+
mask : np.ndarray of bool, shape (n_windows,)
|
|
180
|
+
True where the window is anomalous.
|
|
181
|
+
|
|
182
|
+
Examples
|
|
183
|
+
--------
|
|
184
|
+
Default — use training threshold:
|
|
185
|
+
flags = detector.is_anomaly(X3d)
|
|
186
|
+
|
|
187
|
+
Tighten sensitivity (only top 2% flagged):
|
|
188
|
+
flags = detector.is_anomaly(X3d, percentile=98.0)
|
|
189
|
+
|
|
190
|
+
Fixed cutoff from operator feedback:
|
|
191
|
+
flags = detector.is_anomaly(X3d, threshold=0.72)
|
|
192
|
+
"""
|
|
193
|
+
scores = self.decision_function(X)
|
|
194
|
+
|
|
195
|
+
if threshold is not None:
|
|
196
|
+
thr = float(threshold)
|
|
197
|
+
elif percentile is not None:
|
|
198
|
+
if not (0.0 < percentile < 100.0):
|
|
199
|
+
raise ValueError(f"percentile must be in (0, 100), got {percentile!r}.")
|
|
200
|
+
thr = float(np.percentile(scores, percentile))
|
|
201
|
+
else:
|
|
202
|
+
thr = self._get_threshold()
|
|
203
|
+
|
|
204
|
+
return scores > thr
|
|
205
|
+
|
|
206
|
+
def score_samples(self, X: np.ndarray) -> np.ndarray:
|
|
207
|
+
"""
|
|
208
|
+
Alias for ``decision_function()``.
|
|
209
|
+
|
|
210
|
+
Provided for compatibility with code that uses the sklearn/PyOD
|
|
211
|
+
``score_samples`` convention. Prefer ``decision_function()`` for
|
|
212
|
+
new code.
|
|
213
|
+
|
|
214
|
+
Parameters
|
|
215
|
+
----------
|
|
216
|
+
X : np.ndarray, shape (n_windows, window_size, n_features)
|
|
217
|
+
|
|
218
|
+
Returns
|
|
219
|
+
-------
|
|
220
|
+
scores : np.ndarray, shape (n_windows,)
|
|
221
|
+
"""
|
|
222
|
+
return self.decision_function(X)
|
|
223
|
+
|
|
224
|
+
def _set_post_fit(self, scores: np.ndarray) -> None:
|
|
225
|
+
"""
|
|
226
|
+
Set the three standard post-fit attributes from training scores.
|
|
227
|
+
|
|
228
|
+
Call this at the end of ``fit()`` in every subclass:
|
|
229
|
+
|
|
230
|
+
scores = self._compute_my_scores(X)
|
|
231
|
+
self._set_post_fit(scores)
|
|
232
|
+
return self
|
|
233
|
+
|
|
234
|
+
Parameters
|
|
235
|
+
----------
|
|
236
|
+
scores : np.ndarray, shape (n_windows,)
|
|
237
|
+
Anomaly scores on the training data.
|
|
238
|
+
"""
|
|
239
|
+
scores = np.asarray(scores, dtype=float)
|
|
240
|
+
if scores.ndim != 1:
|
|
241
|
+
raise ValueError(f"_set_post_fit() expects a 1D score array, got shape {scores.shape}.")
|
|
242
|
+
self.decision_scores_ = scores
|
|
243
|
+
self.threshold_ = float(np.percentile(scores, self.percentile))
|
|
244
|
+
self.labels_ = (scores > self.threshold_).astype(int)
|
|
245
|
+
|
|
246
|
+
def _get_threshold(self) -> float:
|
|
247
|
+
"""
|
|
248
|
+
Return the training derived threshold, raising if not yet fitted.
|
|
249
|
+
"""
|
|
250
|
+
if self.threshold_ is None:
|
|
251
|
+
raise RuntimeError(f"{self.__class__.__name__} is not fitted. Call fit() first.")
|
|
252
|
+
return self.threshold_
|
|
253
|
+
|
|
254
|
+
def _require_fit(self) -> None:
|
|
255
|
+
"""
|
|
256
|
+
Raise if the detector has not been fitted.
|
|
257
|
+
|
|
258
|
+
Use at the top of ``decision_function()`` in subclasses:
|
|
259
|
+
|
|
260
|
+
def decision_function(self, X):
|
|
261
|
+
self._require_fit()
|
|
262
|
+
X = self._validate_X(X)
|
|
263
|
+
...
|
|
264
|
+
"""
|
|
265
|
+
if self.threshold_ is None:
|
|
266
|
+
raise RuntimeError(f"{self.__class__.__name__} is not fitted. Call fit() first.")
|
|
267
|
+
|
|
268
|
+
def _validate_X(self, X: np.ndarray) -> np.ndarray:
|
|
269
|
+
"""
|
|
270
|
+
Validate and coerce input to a clean 3D float array.
|
|
271
|
+
|
|
272
|
+
Enforces the (n_windows, window_size, n_features) input convention.
|
|
273
|
+
Call at the top of both ``fit()`` and ``decision_function()``.
|
|
274
|
+
|
|
275
|
+
Parameters
|
|
276
|
+
----------
|
|
277
|
+
X : array like
|
|
278
|
+
Input to validate.
|
|
279
|
+
|
|
280
|
+
Returns
|
|
281
|
+
-------
|
|
282
|
+
X : np.ndarray, shape (n_windows, window_size, n_features)
|
|
283
|
+
Validated array, coerced to float64.
|
|
284
|
+
|
|
285
|
+
Raises
|
|
286
|
+
------
|
|
287
|
+
ValueError
|
|
288
|
+
If X is not 3D, has 0 windows, or contains non-finite values.
|
|
289
|
+
"""
|
|
290
|
+
X = np.asarray(X, dtype=float)
|
|
291
|
+
|
|
292
|
+
if X.ndim != 3:
|
|
293
|
+
raise ValueError(
|
|
294
|
+
f"Expected 3D input (n_windows, window_size, n_features), "
|
|
295
|
+
f"got shape {X.shape}. "
|
|
296
|
+
f"Pass the direct output of windowify()."
|
|
297
|
+
)
|
|
298
|
+
if X.shape[0] == 0:
|
|
299
|
+
raise ValueError(
|
|
300
|
+
"Input X has 0 windows. Check that windowify() received "
|
|
301
|
+
"enough data for at least one window."
|
|
302
|
+
)
|
|
303
|
+
if X.shape[1] == 0:
|
|
304
|
+
raise ValueError("Input X has window_size = 0.")
|
|
305
|
+
if X.shape[2] == 0:
|
|
306
|
+
raise ValueError("Input X has 0 features (channels).")
|
|
307
|
+
if not np.isfinite(X).all():
|
|
308
|
+
n_bad = (~np.isfinite(X)).sum()
|
|
309
|
+
raise ValueError(
|
|
310
|
+
f"Input X contains {n_bad} non-finite value(s) (NaN or Inf). "
|
|
311
|
+
f"Run interpolate_gaps() and normalize_fit() before windowing."
|
|
312
|
+
)
|
|
313
|
+
return X
|
|
314
|
+
|
|
315
|
+
def save(self, path: str | Path) -> None:
|
|
316
|
+
"""
|
|
317
|
+
Serialize the fitted detector to disk using pickle.
|
|
318
|
+
|
|
319
|
+
Parameters
|
|
320
|
+
----------
|
|
321
|
+
path : str or Path
|
|
322
|
+
Destination file path. Conventionally ends in ``.pkl``.
|
|
323
|
+
"""
|
|
324
|
+
path = Path(path)
|
|
325
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
326
|
+
with path.open("wb") as f:
|
|
327
|
+
pickle.dump(self, f)
|
|
328
|
+
|
|
329
|
+
@classmethod
|
|
330
|
+
def load(cls, path: str | Path) -> BaseDetector:
|
|
331
|
+
"""
|
|
332
|
+
Deserialize a detector from disk.
|
|
333
|
+
|
|
334
|
+
Parameters
|
|
335
|
+
----------
|
|
336
|
+
path : str or Path
|
|
337
|
+
Path to a pickled detector file created by ``save()``.
|
|
338
|
+
|
|
339
|
+
Returns
|
|
340
|
+
-------
|
|
341
|
+
detector : BaseDetector
|
|
342
|
+
The deserialized, fitted detector instance.
|
|
343
|
+
|
|
344
|
+
Notes
|
|
345
|
+
-----
|
|
346
|
+
Only load files from trusted sources.
|
|
347
|
+
"""
|
|
348
|
+
path = Path(path)
|
|
349
|
+
with path.open("rb") as f:
|
|
350
|
+
return pickle.load(f)
|
|
351
|
+
|
|
352
|
+
def __repr__(self) -> str:
|
|
353
|
+
fitted = self.threshold_ is not None
|
|
354
|
+
params = self._get_params()
|
|
355
|
+
param_str = ", ".join(f"{k}={v!r}" for k, v in params.items())
|
|
356
|
+
return f"{self.__class__.__name__}({param_str}, fitted={fitted})"
|
|
357
|
+
|
|
358
|
+
def _get_params(self) -> dict:
|
|
359
|
+
"""
|
|
360
|
+
Return constructor parameters for ``__repr__``.
|
|
361
|
+
|
|
362
|
+
Subclasses can override this to include their own hyperparameters.
|
|
363
|
+
By default returns ``{"percentile": self.percentile}``.
|
|
364
|
+
"""
|
|
365
|
+
return {"percentile": self.percentile}
|