All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 2m58s
172 lines
6.3 KiB
Python
172 lines
6.3 KiB
Python
"""Stable feature transformers shared by model training and runtime inference."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Iterable
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
from .config import FAULT_LABELS, FREQ_COLS
|
|
|
|
FEATURE_SETS = ("raw", "deviation", "all")
|
|
|
|
|
|
class SeverityFeatures:
|
|
"""Create engine-relative spectral features without using labels.
|
|
|
|
Engine-relative values use a leave-one-cylinder-out median reference. Input
|
|
to ``transform`` must therefore contain every cylinder of each engine.
|
|
``diagnostic_label`` is optional and may contain only a model-predicted
|
|
label at validation or inference time.
|
|
"""
|
|
|
|
def __init__(self, feature_set: str, include_fault_type: bool = False) -> None:
|
|
if feature_set not in FEATURE_SETS:
|
|
raise ValueError(f"Unknown feature_set={feature_set!r}")
|
|
self.feature_set = feature_set
|
|
self.include_fault_type = include_fault_type
|
|
|
|
def fit(
|
|
self, X: pd.DataFrame, y: Iterable[str] | None = None
|
|
) -> "SeverityFeatures":
|
|
self._validate(X)
|
|
spectra = X[FREQ_COLS].apply(pd.to_numeric, errors="coerce")
|
|
fallback = spectra.median(axis=0).to_numpy(dtype=float)
|
|
if np.isnan(fallback).any():
|
|
raise ValueError("A frequency column is entirely NaN in training.")
|
|
self.fallback_medians_ = fallback
|
|
return self
|
|
|
|
def transform(self, X: pd.DataFrame) -> np.ndarray:
|
|
self._validate(X)
|
|
if not hasattr(self, "fallback_medians_"):
|
|
raise RuntimeError("SeverityFeatures must be fitted before transform().")
|
|
raw = self._clean_spectra(X)
|
|
reference = self._leave_one_out_engine_median(
|
|
raw, X["engine_id"].to_numpy()
|
|
)
|
|
relative = raw - reference
|
|
absolute_relative = np.abs(relative)
|
|
ratio_delta = raw / np.maximum(np.abs(reference), 1e-6) - 1.0
|
|
|
|
if self.feature_set == "raw":
|
|
features = raw
|
|
elif self.feature_set == "deviation":
|
|
features = np.hstack(
|
|
[
|
|
relative,
|
|
absolute_relative,
|
|
ratio_delta,
|
|
self._summary_features(raw, relative, ratio_delta),
|
|
]
|
|
)
|
|
else:
|
|
features = np.hstack(
|
|
[
|
|
raw,
|
|
relative,
|
|
absolute_relative,
|
|
ratio_delta,
|
|
self._summary_features(raw, relative, ratio_delta),
|
|
]
|
|
)
|
|
|
|
if self.include_fault_type:
|
|
if "diagnostic_label" not in X.columns:
|
|
raise ValueError(
|
|
"diagnostic_label is required when include_fault_type=True"
|
|
)
|
|
labels = X["diagnostic_label"].to_numpy(dtype=object)
|
|
one_hot = np.column_stack(
|
|
[labels == label for label in FAULT_LABELS]
|
|
).astype(float)
|
|
features = np.hstack([features, one_hot])
|
|
if np.isnan(features).any() or np.isinf(features).any():
|
|
raise ValueError("Severity features contain NaN or infinity.")
|
|
return features
|
|
|
|
def fit_transform(
|
|
self, X: pd.DataFrame, y: Iterable[str] | None = None
|
|
) -> np.ndarray:
|
|
return self.fit(X).transform(X)
|
|
|
|
def _clean_spectra(self, X: pd.DataFrame) -> np.ndarray:
|
|
spectra = X[FREQ_COLS].apply(pd.to_numeric, errors="coerce")
|
|
spectra = spectra.interpolate(axis=1, limit_direction="both")
|
|
spectra = spectra.fillna(pd.Series(self.fallback_medians_, index=FREQ_COLS))
|
|
return spectra.to_numpy(dtype=float)
|
|
|
|
@staticmethod
|
|
def _leave_one_out_engine_median(
|
|
raw: np.ndarray, groups: np.ndarray
|
|
) -> np.ndarray:
|
|
reference = np.empty_like(raw)
|
|
for engine_id in pd.unique(groups):
|
|
positions = np.flatnonzero(groups == engine_id)
|
|
if len(positions) < 2:
|
|
raise ValueError(
|
|
f"Engine {engine_id!r} has fewer than two cylinders in transform()."
|
|
)
|
|
engine_values = raw[positions]
|
|
for local_position, global_position in enumerate(positions):
|
|
keep = np.arange(len(positions)) != local_position
|
|
reference[global_position] = np.median(engine_values[keep], axis=0)
|
|
return reference
|
|
|
|
@staticmethod
|
|
def _summary_features(
|
|
raw: np.ndarray, relative: np.ndarray, ratio_delta: np.ndarray
|
|
) -> np.ndarray:
|
|
absolute_relative = np.abs(relative)
|
|
gradients = np.diff(raw, axis=1)
|
|
frequency = np.arange(raw.shape[1], dtype=float)
|
|
centered_frequency = frequency - frequency.mean()
|
|
slope = raw @ centered_frequency / np.sum(centered_frequency**2)
|
|
if hasattr(np, "trapezoid"):
|
|
spectral_auc = np.trapezoid(raw, axis=1)
|
|
else: # pragma: no cover - compatibility with NumPy 1.x
|
|
spectral_auc = np.trapz(raw, axis=1)
|
|
|
|
columns = [
|
|
raw.mean(axis=1),
|
|
raw.std(axis=1),
|
|
raw.min(axis=1),
|
|
raw.max(axis=1),
|
|
np.ptp(raw, axis=1),
|
|
spectral_auc,
|
|
raw.argmax(axis=1) / 20.0,
|
|
raw.argmin(axis=1) / 20.0,
|
|
slope,
|
|
np.abs(gradients).mean(axis=1),
|
|
np.abs(gradients).max(axis=1),
|
|
relative.mean(axis=1),
|
|
relative.std(axis=1),
|
|
relative.min(axis=1),
|
|
relative.max(axis=1),
|
|
absolute_relative.mean(axis=1),
|
|
absolute_relative.max(axis=1),
|
|
np.sqrt(np.mean(relative**2, axis=1)),
|
|
ratio_delta.mean(axis=1),
|
|
ratio_delta.std(axis=1),
|
|
]
|
|
for start, stop in ((0, 5), (5, 10), (10, 15), (15, 21)):
|
|
columns.extend(
|
|
[
|
|
raw[:, start:stop].mean(axis=1),
|
|
relative[:, start:stop].mean(axis=1),
|
|
absolute_relative[:, start:stop].mean(axis=1),
|
|
]
|
|
)
|
|
return np.column_stack(columns)
|
|
|
|
@staticmethod
|
|
def _validate(X: pd.DataFrame) -> None:
|
|
required = {"engine_id", *FREQ_COLS}
|
|
missing = sorted(required.difference(X.columns))
|
|
if missing:
|
|
raise ValueError(f"Missing columns: {missing}")
|
|
|
|
|
|
__all__ = ["FEATURE_SETS", "SeverityFeatures"]
|