hackathon-ENGIN/engin/features.py
Jakub Famulski 2 8e6dcb750d
All checks were successful
ENGIN CI / Build, test and smoke (push) Successful in 2m58s
prod: load versioned model artifact at runtime
2026-08-25 13:06:14 +02:00

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"]