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,158 @@
|
|
|
1
|
+
# src/telemetry_anomdet/models/unsupervised/pca.py
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
PCA based anomaly detection.
|
|
5
|
+
|
|
6
|
+
This model learns a low dimensional subspace of nominal telemetry features
|
|
7
|
+
using Principal Component Analysis (PCA). Anomaly scores are computed as
|
|
8
|
+
the reconstruction error when projecting samples into the PCA subspace
|
|
9
|
+
and back into the original space.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
from sklearn.decomposition import PCA
|
|
16
|
+
from sklearn.preprocessing import StandardScaler
|
|
17
|
+
|
|
18
|
+
from ...feature_extraction import features
|
|
19
|
+
from ..base import BaseDetector
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PCAAnomaly(BaseDetector):
|
|
23
|
+
"""
|
|
24
|
+
PCA-based anomaly detector.
|
|
25
|
+
|
|
26
|
+
Accepts 3D windowed input (n_windows, window_size, n_features) and
|
|
27
|
+
flattens internally via features_stat() before fitting PCA. The caller
|
|
28
|
+
never needs to manage this conversion.
|
|
29
|
+
|
|
30
|
+
Parameters
|
|
31
|
+
----------
|
|
32
|
+
n_components : int or None, default=None
|
|
33
|
+
Number of principal components to retain. If None, all components
|
|
34
|
+
are kept. Choose a value that retains your desired fraction of
|
|
35
|
+
variance — inspect ``model.explained_variance_ratio_`` after fitting.
|
|
36
|
+
scale : bool, default=True
|
|
37
|
+
Apply StandardScaler before PCA. Recommended when telemetry channels
|
|
38
|
+
differ significantly in scale (e.g. voltage vs. temperature).
|
|
39
|
+
percentile : float, default = 95.0
|
|
40
|
+
Percentile of training reconstruction errors used to set the default
|
|
41
|
+
anomaly threshold. 95.0 means the top 5% most anomalous training
|
|
42
|
+
windows are labelled as anomalies.
|
|
43
|
+
|
|
44
|
+
Attributes (set after fit)
|
|
45
|
+
--------------------------
|
|
46
|
+
decision_scores_ : np.ndarray, shape (n_windows,)
|
|
47
|
+
Reconstruction errors on training data.
|
|
48
|
+
threshold_ : float
|
|
49
|
+
Default anomaly cutoff derived from training scores at ``percentile``.
|
|
50
|
+
labels_ : np.ndarray, shape (n_windows,)
|
|
51
|
+
Binary anomaly labels on training data. 0 = normal, 1 = anomaly.
|
|
52
|
+
model : sklearn.decomposition.PCA
|
|
53
|
+
Fitted PCA instance.
|
|
54
|
+
scaler : sklearn.preprocessing.StandardScaler or None
|
|
55
|
+
Fitted scaler when ``scale = True``, otherwise None.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
n_components: int | None = None,
|
|
61
|
+
scale: bool = True,
|
|
62
|
+
percentile: float = 95.0,
|
|
63
|
+
):
|
|
64
|
+
super().__init__(percentile=percentile)
|
|
65
|
+
self.n_components = n_components
|
|
66
|
+
self.scale = scale
|
|
67
|
+
|
|
68
|
+
# Fit artifacts: set in fit()
|
|
69
|
+
self.model: PCA | None = None
|
|
70
|
+
self.scaler: StandardScaler | None = None
|
|
71
|
+
|
|
72
|
+
# ---- helpers ----
|
|
73
|
+
def _flatten(self, X: np.ndarray) -> np.ndarray:
|
|
74
|
+
"""
|
|
75
|
+
Flatten 3D windowed tensor to 2D feature matrix via features_stat().
|
|
76
|
+
|
|
77
|
+
Parameters
|
|
78
|
+
----------
|
|
79
|
+
X : np.ndarray, shape (n_windows, window_size, n_features)
|
|
80
|
+
|
|
81
|
+
Returns
|
|
82
|
+
-------
|
|
83
|
+
X2d : np.ndarray, shape (n_windows, n_features * 6)
|
|
84
|
+
Statistical features per window: mean, std, min, max, median, slope.
|
|
85
|
+
"""
|
|
86
|
+
return features.features_stat(X)
|
|
87
|
+
|
|
88
|
+
def _scale_fit(self, X2d: np.ndarray) -> np.ndarray:
|
|
89
|
+
if self.scale:
|
|
90
|
+
self.scaler = StandardScaler()
|
|
91
|
+
return self.scaler.fit_transform(X2d)
|
|
92
|
+
self.scaler = None
|
|
93
|
+
return X2d
|
|
94
|
+
|
|
95
|
+
def _scale_transform(self, X2d: np.ndarray) -> np.ndarray:
|
|
96
|
+
if self.scale:
|
|
97
|
+
if self.scaler is None:
|
|
98
|
+
raise RuntimeError("Scaler is not fitted. Was the model fitted with scale = True?")
|
|
99
|
+
return self.scaler.transform(X2d)
|
|
100
|
+
return X2d
|
|
101
|
+
|
|
102
|
+
def _reconstruction_error(self, Xs: np.ndarray) -> np.ndarray:
|
|
103
|
+
"""Project into PCA subspace and compute per-window reconstruction error."""
|
|
104
|
+
Z = self.model.transform(Xs)
|
|
105
|
+
X_recon = self.model.inverse_transform(Z)
|
|
106
|
+
return np.sum((Xs - X_recon) ** 2, axis=1)
|
|
107
|
+
|
|
108
|
+
def fit(self, X: np.ndarray, y: np.ndarray | None = None) -> PCAAnomaly:
|
|
109
|
+
"""
|
|
110
|
+
Fit PCA on nominal telemetry windows.
|
|
111
|
+
|
|
112
|
+
Parameters
|
|
113
|
+
----------
|
|
114
|
+
X : np.ndarray, shape (n_windows, window_size, n_features)
|
|
115
|
+
Windowed telemetry tensor from windowify().
|
|
116
|
+
y : ignored
|
|
117
|
+
Present for API consistency.
|
|
118
|
+
|
|
119
|
+
Returns
|
|
120
|
+
-------
|
|
121
|
+
self : PCAAnomaly
|
|
122
|
+
"""
|
|
123
|
+
X = self._validate_X(X)
|
|
124
|
+
X2d = self._flatten(X)
|
|
125
|
+
Xs = self._scale_fit(X2d)
|
|
126
|
+
|
|
127
|
+
self.model = PCA(n_components=self.n_components)
|
|
128
|
+
self.model.fit(Xs)
|
|
129
|
+
|
|
130
|
+
scores = self._reconstruction_error(Xs)
|
|
131
|
+
self._set_post_fit(scores)
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
def decision_function(self, X: np.ndarray) -> np.ndarray:
|
|
135
|
+
"""
|
|
136
|
+
Compute reconstruction error for each window.
|
|
137
|
+
|
|
138
|
+
Parameters
|
|
139
|
+
----------
|
|
140
|
+
X : np.ndarray, shape (n_windows, window_size, n_features)
|
|
141
|
+
|
|
142
|
+
Returns
|
|
143
|
+
-------
|
|
144
|
+
scores : np.ndarray, shape (n_windows,)
|
|
145
|
+
Reconstruction errors. Higher = more anomalous.
|
|
146
|
+
"""
|
|
147
|
+
self._require_fit()
|
|
148
|
+
X = self._validate_X(X)
|
|
149
|
+
X2d = self._flatten(X)
|
|
150
|
+
Xs = self._scale_transform(X2d)
|
|
151
|
+
return self._reconstruction_error(Xs)
|
|
152
|
+
|
|
153
|
+
def _get_params(self) -> dict:
|
|
154
|
+
return {
|
|
155
|
+
"n_components": self.n_components,
|
|
156
|
+
"scale": self.scale,
|
|
157
|
+
"percentile": self.percentile,
|
|
158
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from .preprocessing import (
|
|
2
|
+
clean,
|
|
3
|
+
dedupe,
|
|
4
|
+
integrity_check,
|
|
5
|
+
interpolate_gaps,
|
|
6
|
+
normalize_fit,
|
|
7
|
+
pipeline,
|
|
8
|
+
resample,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"clean",
|
|
13
|
+
"dedupe",
|
|
14
|
+
"integrity_check",
|
|
15
|
+
"resample",
|
|
16
|
+
"interpolate_gaps",
|
|
17
|
+
"normalize_fit",
|
|
18
|
+
"pipeline",
|
|
19
|
+
]
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# telemetry_anomdet/preprocessing.py
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Preprocessing utilities for telemetry data.
|
|
5
|
+
|
|
6
|
+
This module handles data cleaning, normalization,
|
|
7
|
+
and other transformations before feature extraction.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
# Long form telemetry means one observation per row, wide means all variables get their own column, with timestamps as the index
|
|
11
|
+
|
|
12
|
+
import fnmatch
|
|
13
|
+
|
|
14
|
+
import pandas as pd
|
|
15
|
+
|
|
16
|
+
# Canonical column names used throughout the preprocessing pipeline
|
|
17
|
+
_TS, _VAR, _VAL = "timestamp", "variable", "value"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def clean(df: pd.DataFrame, *, physical_bounds=None) -> pd.DataFrame:
|
|
21
|
+
"""
|
|
22
|
+
Remove non existant values, non numeric readings, and physically impossible sensor values.
|
|
23
|
+
|
|
24
|
+
Arguments:
|
|
25
|
+
df (pd.DataFrame): Long form telemetry data with columns ['timestamp', 'variable', 'value'].
|
|
26
|
+
physical_bounds (dict, optional): Mapping of variable names or patterns to (min, max) valid ranges.
|
|
27
|
+
Example: {'Battery_Voltage': (0, 20), 'Battery_Temp': (-40, 85)}.
|
|
28
|
+
Returns:
|
|
29
|
+
pd.DataFrame: Cleaned dataset.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
# Work on a copy
|
|
33
|
+
df = df.copy()
|
|
34
|
+
|
|
35
|
+
# Drop rows with missing core fields
|
|
36
|
+
df = df.dropna(subset=[_TS, _VAR, _VAL])
|
|
37
|
+
|
|
38
|
+
# Apply physical bounds
|
|
39
|
+
if physical_bounds:
|
|
40
|
+
# Mask that marks all rows as valid
|
|
41
|
+
mask = pd.Series(True, index=df.index)
|
|
42
|
+
|
|
43
|
+
# Loop over each variable pattern and its allowed (min, max) range
|
|
44
|
+
for pattern, bounds in physical_bounds.items():
|
|
45
|
+
if bounds is None:
|
|
46
|
+
# Skip None bounds
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
# Bounds must be a 2 element tuple/list (min/max)
|
|
50
|
+
if not (isinstance(bounds, (tuple, list)) and len(bounds) == 2):
|
|
51
|
+
raise ValueError("Must be (min, max)")
|
|
52
|
+
|
|
53
|
+
lower, upper = bounds
|
|
54
|
+
|
|
55
|
+
# Select rows whose variable name matches the given pattern.
|
|
56
|
+
# Bind `pattern` as a default arg so the closure captures this
|
|
57
|
+
# iteration's value, not the loop variable (ruff B023).
|
|
58
|
+
sel = df[_VAR].map(lambda v, pattern=pattern: fnmatch.fnmatch(v, pattern))
|
|
59
|
+
|
|
60
|
+
# Mask out rows that fall below or above the valid range
|
|
61
|
+
if lower is not None:
|
|
62
|
+
mask &= ~(sel & (df[_VAL] < float(lower)))
|
|
63
|
+
if upper is not None:
|
|
64
|
+
mask &= ~(sel & (df[_VAL] > float(upper)))
|
|
65
|
+
|
|
66
|
+
# Apply the mask incrementally
|
|
67
|
+
df = df[mask]
|
|
68
|
+
|
|
69
|
+
# Deterministic ordering (by timestamp and variable)
|
|
70
|
+
df = df.sort_values([_TS, _VAR]).reset_index(drop=True)
|
|
71
|
+
|
|
72
|
+
return df
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def dedupe(df: pd.DataFrame) -> pd.DataFrame:
|
|
76
|
+
"""
|
|
77
|
+
Remove duplicate or retransmitted rows.
|
|
78
|
+
|
|
79
|
+
Arguments:
|
|
80
|
+
df (pd.DataFrame): Long form telemetry data with potential duplicates.
|
|
81
|
+
Returns:
|
|
82
|
+
pd.DataFrame: DataFrame with duplicates (timestamp, variable) removed.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
if not isinstance(df, pd.DataFrame):
|
|
86
|
+
raise TypeError("dedupe() expects a pandas DataFrame")
|
|
87
|
+
|
|
88
|
+
df = df.copy()
|
|
89
|
+
|
|
90
|
+
# Sort so duplicates are grouped together
|
|
91
|
+
df = df.sort_values([_TS, _VAR, _VAL])
|
|
92
|
+
|
|
93
|
+
# Drop exact duplicates
|
|
94
|
+
df = df.drop_duplicates()
|
|
95
|
+
|
|
96
|
+
# Drop retransmits (same timestamps, keep last occurence)
|
|
97
|
+
df = df.drop_duplicates(subset=[_TS, _VAR], keep="last")
|
|
98
|
+
|
|
99
|
+
# Reset index for cleanliness before returning
|
|
100
|
+
return df.reset_index(drop=True)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def integrity_check(
|
|
104
|
+
df: pd.DataFrame, *, require_utc: bool = True, require_sorted: bool = True
|
|
105
|
+
) -> None:
|
|
106
|
+
"""
|
|
107
|
+
Verify timestamp format, timezone, and column consistency.
|
|
108
|
+
|
|
109
|
+
Arguments:
|
|
110
|
+
df (pd.DataFrame): Long form telemetry data.
|
|
111
|
+
require_utc (bool): If True, ensure timestamps are UTC.
|
|
112
|
+
require_sorted (bool): If True, ensure timestamps are sorted ascending.
|
|
113
|
+
Raises:
|
|
114
|
+
ValueError: If schema or ordering fails validation.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
if not isinstance(df, pd.DataFrame):
|
|
118
|
+
raise TypeError("integrity_check() expects a pandas DataFrame")
|
|
119
|
+
|
|
120
|
+
# Extract timestamp column
|
|
121
|
+
ts = df[_TS]
|
|
122
|
+
|
|
123
|
+
# Ensure datetime (datetime64), if not try to coerce
|
|
124
|
+
if not pd.api.types.is_datetime64_any_dtype(ts):
|
|
125
|
+
try:
|
|
126
|
+
ts = pd.to_datetime(ts, errors="raise", utc=require_utc)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
raise ValueError("'timestamp' must be datetime format") from e
|
|
129
|
+
|
|
130
|
+
# UTC requirement
|
|
131
|
+
if require_utc:
|
|
132
|
+
if ts.dt.tz is None:
|
|
133
|
+
raise ValueError("Timestamps must be timezone aware UTC")
|
|
134
|
+
if str(ts.dt.tz) not in ("UTC", "UTC+00:00", "tzutc()"):
|
|
135
|
+
raise ValueError("Timestamps must be in UTC.")
|
|
136
|
+
|
|
137
|
+
# Sorted requirement (chronological order if required)
|
|
138
|
+
if require_sorted and not ts.is_monotonic_increasing:
|
|
139
|
+
raise ValueError("Timestamps must be ascending.")
|
|
140
|
+
|
|
141
|
+
# 'value' must be numeric
|
|
142
|
+
if not pd.api.types.is_numeric_dtype(df[_VAL]):
|
|
143
|
+
raise ValueError("'value' column must be numeric.")
|
|
144
|
+
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def resample(df: pd.DataFrame, *, rule: str = "5s", agg: str = "mean") -> pd.DataFrame:
|
|
149
|
+
"""
|
|
150
|
+
Resample irregularly spaced data to a uniform cadence.
|
|
151
|
+
|
|
152
|
+
Arguments:
|
|
153
|
+
df (pd.DataFrame): Long form telemetry data.
|
|
154
|
+
rule (str): Resample frequency ('1s', '5s', '1min').
|
|
155
|
+
agg (str): Aggregation method ('mean', 'median', etc.) when multiple values exist per interval.
|
|
156
|
+
Returns:
|
|
157
|
+
pd.DataFrame: Resampled dataset with regular time intervals.
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
df = df.copy()
|
|
161
|
+
|
|
162
|
+
# Ensure proper timestamp dtype
|
|
163
|
+
df[_TS] = pd.to_datetime(df[_TS])
|
|
164
|
+
|
|
165
|
+
# Pivot to wide form
|
|
166
|
+
wide = df.pivot_table(index=_TS, columns=_VAR, values=_VAL)
|
|
167
|
+
|
|
168
|
+
# Resample
|
|
169
|
+
if agg == "mean":
|
|
170
|
+
wide = wide.resample(rule).mean()
|
|
171
|
+
elif agg == "median":
|
|
172
|
+
wide = wide.resample(rule).median()
|
|
173
|
+
elif agg == "min":
|
|
174
|
+
wide = wide.resample(rule).min()
|
|
175
|
+
elif agg == "max":
|
|
176
|
+
wide = wide.resample(rule).max()
|
|
177
|
+
else:
|
|
178
|
+
raise ValueError(f"Unsupported agg method: {agg}")
|
|
179
|
+
|
|
180
|
+
# Fill gaps with forward-fill then backfill
|
|
181
|
+
# This reduces NaNs in windowing
|
|
182
|
+
wide = wide.ffill().bfill()
|
|
183
|
+
|
|
184
|
+
# Melt back to long form
|
|
185
|
+
long = (
|
|
186
|
+
wide.reset_index()
|
|
187
|
+
.melt(id_vars=[_TS], var_name=_VAR, value_name=_VAL)
|
|
188
|
+
.dropna(subset=[_VAL]) # remove variables missing entirely
|
|
189
|
+
.sort_values(_TS)
|
|
190
|
+
.reset_index(drop=True)
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return long
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def interpolate_gaps(
|
|
197
|
+
df: pd.DataFrame, *, method: str = "ffill", limit: int | None = 1
|
|
198
|
+
) -> pd.DataFrame:
|
|
199
|
+
"""
|
|
200
|
+
Fill small missing gaps to ensure continuous time steps.
|
|
201
|
+
|
|
202
|
+
Arguments:
|
|
203
|
+
df (pd.DataFrame): Resampled telemetry data in long form.
|
|
204
|
+
method (str): Interpolation strategy ('ffill', 'bfill', 'linear', etc.).
|
|
205
|
+
limit (int, optional): Maximum consecutive non existant value steps to fill.
|
|
206
|
+
None means no limit.
|
|
207
|
+
Returns:
|
|
208
|
+
pd.DataFrame: Gap filled dataset in long form.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
if not isinstance(df, pd.DataFrame):
|
|
212
|
+
raise TypeError("interpolate_gaps() expects a pandas DataFrame")
|
|
213
|
+
|
|
214
|
+
df = df.copy()
|
|
215
|
+
df[_TS] = pd.to_datetime(df[_TS])
|
|
216
|
+
|
|
217
|
+
# Pivot to wide form so each variable is a contiguous time series
|
|
218
|
+
wide = df.pivot_table(index=_TS, columns=_VAR, values=_VAL)
|
|
219
|
+
|
|
220
|
+
# Fill gaps column by column, capped at `limit` consecutive steps
|
|
221
|
+
if method == "ffill":
|
|
222
|
+
wide = wide.ffill(limit=limit)
|
|
223
|
+
elif method == "bfill":
|
|
224
|
+
wide = wide.bfill(limit=limit)
|
|
225
|
+
else:
|
|
226
|
+
# Time-aware interpolation methods (e.g. 'linear', 'time', 'spline')
|
|
227
|
+
try:
|
|
228
|
+
wide = wide.interpolate(method=method, limit=limit, limit_direction="forward")
|
|
229
|
+
except (ValueError, NotImplementedError) as e:
|
|
230
|
+
raise ValueError(f"Unsupported interpolation method: {method}") from e
|
|
231
|
+
|
|
232
|
+
# Melt back to long form, dropping gaps that remained unfilled
|
|
233
|
+
long = (
|
|
234
|
+
wide.reset_index()
|
|
235
|
+
.melt(id_vars=[_TS], var_name=_VAR, value_name=_VAL)
|
|
236
|
+
.dropna(subset=[_VAL])
|
|
237
|
+
.sort_values([_TS, _VAR])
|
|
238
|
+
.reset_index(drop=True)
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
return long
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def normalize_fit(df: pd.DataFrame, *, method: str = "zscore") -> dict:
|
|
245
|
+
"""
|
|
246
|
+
Compute normalization parameters for each variable.
|
|
247
|
+
|
|
248
|
+
Arguments:
|
|
249
|
+
df (pd.DataFrame): Cleaned telemetry data (usually training subset).
|
|
250
|
+
method (str): Normalization method ('zscore' or 'minmax').
|
|
251
|
+
Returns:
|
|
252
|
+
dict: Mapping {variable: (center, scale)} where the pair is
|
|
253
|
+
(mean, std) for 'zscore' or (min, range) for 'minmax'.
|
|
254
|
+
A scale of 0 (constant variable) is stored as 1.0 so that
|
|
255
|
+
applying the parameters never divides by zero.
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
if not isinstance(df, pd.DataFrame):
|
|
259
|
+
raise TypeError("normalize_fit() expects a pandas DataFrame")
|
|
260
|
+
if method not in ("zscore", "minmax"):
|
|
261
|
+
raise ValueError(f"Unsupported normalization method: {method}")
|
|
262
|
+
|
|
263
|
+
params: dict = {}
|
|
264
|
+
|
|
265
|
+
# Compute per-variable statistics independently
|
|
266
|
+
for variable, group in df.groupby(_VAR):
|
|
267
|
+
values = group[_VAL].astype(float)
|
|
268
|
+
|
|
269
|
+
if method == "zscore":
|
|
270
|
+
center = float(values.mean())
|
|
271
|
+
# Sample std (ddof = 1); guard against constant/single-sample series
|
|
272
|
+
scale = float(values.std(ddof=1))
|
|
273
|
+
else: # minmax
|
|
274
|
+
center = float(values.min())
|
|
275
|
+
scale = float(values.max() - center)
|
|
276
|
+
|
|
277
|
+
# Avoid divide-by-zero for constant variables when params are applied
|
|
278
|
+
if not scale > 0:
|
|
279
|
+
scale = 1.0
|
|
280
|
+
|
|
281
|
+
params[variable] = (center, scale)
|
|
282
|
+
|
|
283
|
+
return params
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def pipeline(
|
|
287
|
+
df: pd.DataFrame,
|
|
288
|
+
*,
|
|
289
|
+
physical_bounds: dict | None = None,
|
|
290
|
+
resample_rule: str | None = "5s",
|
|
291
|
+
resample_agg: str = "mean",
|
|
292
|
+
interpolate_method: str = "ffill",
|
|
293
|
+
gap_limit: int | None = 1,
|
|
294
|
+
) -> pd.DataFrame:
|
|
295
|
+
"""
|
|
296
|
+
Execute minimal preprocessing pipeline for this dataset.
|
|
297
|
+
|
|
298
|
+
Steps: clean -> dedupe -> integrity_check -> resample -> interpolate_gaps.
|
|
299
|
+
|
|
300
|
+
Normalization is intentionally kept out of this pipeline. Following
|
|
301
|
+
scikit-learn convention, fitting normalization stats is a separate step:
|
|
302
|
+
call :func:`normalize_fit` on the returned (training) frame and reuse the
|
|
303
|
+
resulting params at inference to prevent leakage.
|
|
304
|
+
|
|
305
|
+
Arguments:
|
|
306
|
+
df (pd.DataFrame): Raw telemetry dataset.
|
|
307
|
+
physical_bounds (dict, optional): Min/max physical limits per variable.
|
|
308
|
+
resample_rule (str): Resampling frequency (default '5s'). None skips resampling.
|
|
309
|
+
resample_agg (str): Aggregation method (default 'mean').
|
|
310
|
+
interpolate_method (str): Gap-fill strategy ('ffill', 'linear', etc.).
|
|
311
|
+
gap_limit (int, optional): Max consecutive steps to interpolate.
|
|
312
|
+
Returns:
|
|
313
|
+
pd.DataFrame: Fully preprocessed dataset.
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
# Remove nulls, non physical readings, and sort data
|
|
317
|
+
df = clean(df, physical_bounds=physical_bounds)
|
|
318
|
+
|
|
319
|
+
# Remove dupes or retransmits
|
|
320
|
+
df = dedupe(df)
|
|
321
|
+
|
|
322
|
+
# Validate schema/ordering before time-based operations
|
|
323
|
+
integrity_check(df, require_utc=False, require_sorted=False)
|
|
324
|
+
|
|
325
|
+
# Resample to a uniform cadence, then fill remaining small gaps
|
|
326
|
+
if resample_rule is not None:
|
|
327
|
+
df = resample(df, rule=resample_rule, agg=resample_agg)
|
|
328
|
+
df = interpolate_gaps(df, method=interpolate_method, limit=gap_limit)
|
|
329
|
+
|
|
330
|
+
return df
|
|
File without changes
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# telemetry_anomdet/version.py
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Package version.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
except ImportError: # Python < 3.8
|
|
10
|
+
from importlib_metadata import PackageNotFoundError, version # type: ignore[assignment]
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
__version__ = version("telemetry-anomdet")
|
|
14
|
+
except PackageNotFoundError:
|
|
15
|
+
__version__ = "0.0.0"
|