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