Source code for denspp.offline.ml.fex
from dataclasses import dataclass
from typing import Literal
import joblib
import numpy as np
from sklearn.decomposition import PCA, FastICA
from umap import UMAP
[docs]
@dataclass(frozen=True)
class SettingsFeature:
"""Configuration for feature extraction.
Attributes:
num_features: Number of output features / embedding dimensions.
"""
num_features: int = 3
DefaultSettingsFeature = SettingsFeature(num_features=3)
[docs]
class FeatureExtraction:
"""Feature extraction methods for signal frames."""
def __init__(self, settings: SettingsFeature | None = None):
self._settings = settings if settings is not None else SettingsFeature()
self._model: PCA | FastICA | UMAP | None = None
self.fe_method = None
# ------------------------------------------------------------------
# create estimators
# ------------------------------------------------------------------
def _create_pca(self, svd_solver: str, random_state: int | None) -> PCA:
return PCA(
n_components=self._settings.num_features,
svd_solver=svd_solver,
random_state=random_state if svd_solver == "randomized" else None,
)
def _create_umap(self, n_neighbors: int, random_state: int, min_dist: float) -> UMAP:
return UMAP(
n_components=self._settings.num_features,
n_neighbors=n_neighbors,
min_dist=min_dist,
random_state=random_state,
)
def _create_ica(self, random_state: int) -> FastICA:
return FastICA(n_components=self._settings.num_features, random_state=random_state)
# ------------------------------------------------------------------
# helpers
# ------------------------------------------------------------------
@property
def is_state_available(self) -> bool:
return self._model is not None
def _ensure_method_matches(self, expected_method: str) -> None:
"""Ensure that an already loaded/stored model matches the requested method."""
if self._model is not None and self.fe_method != expected_method:
raise RuntimeError(
f"State conflict: expected '{expected_method}' but found '{self.fe_method}'. "
"Please call erase_state() before using a different method."
)
[docs]
def transform(self, frame_in: np.ndarray) -> np.ndarray:
"""Transform input data using the currently stored fitted model.
Args:
frame_in: Input array of shape (n_samples, n_features).
Returns:
Transformed data.
Raises:
RuntimeError: If no fitted model is available.
TypeError: If the stored model does not provide a transform method.
"""
if self._model is None:
raise RuntimeError(
"No fitted model available. Call a fit_* method first or load a fitted state."
)
if not hasattr(self._model, "transform"):
raise TypeError("The stored model does not provide a transform(...) method.")
return self._model.transform(frame_in)
[docs]
def load_state(self, state_to_load, fe_method_name: str):
"""Load a fitted model into the class. If a different model has been fitted before, erase_state()
needs to be called first.
Args:
state_to_load: A fitted model object providing transform(...).
fe_method_name: Name of the feature extraction method the model belongs to.
Raises:
TypeError: If the model does not provide transform(...).
ValueError: If the model is not fitted.
"""
if not isinstance(state_to_load, (PCA, UMAP, FastICA)):
raise TypeError("state_to_load must be a fitted PCA, UMAP, or FastICA model.")
self._ensure_method_matches(fe_method_name)
if isinstance(state_to_load, PCA) and not hasattr(state_to_load, "components_"):
raise ValueError("PCA state_to_load is not fitted (missing components_).")
if isinstance(state_to_load, UMAP) and not hasattr(state_to_load, "embedding_"):
raise ValueError("UMAP state_to_load is not fitted (missing embedding_).")
if isinstance(state_to_load, FastICA) and not hasattr(state_to_load, "components_"):
raise ValueError("FastICA state_to_load is not fitted (missing components_).")
self._model = state_to_load
self.fe_method = fe_method_name
[docs]
def export_state(self, path2save: str):
"""Export the currently stored fitted model to a .joblib file.
Args:
path2save: Output path. If it does not already end with '.joblib',
the extension is added automatically.
Raises:
RuntimeError: If no fitted model is available.
"""
if self._model is None:
raise RuntimeError(
"No fitted model available. Call a fit_* method first or load a fitted state."
)
if not path2save.endswith(".joblib"):
path2save += ".joblib"
joblib.dump(self._model, path2save)
[docs]
def erase_state(self):
"""Clear the currently stored model state."""
self._model = None
self.fe_method = None
# ------------------------------------------------------------------
# PDAC
# ------------------------------------------------------------------
[docs]
def pdac_min(self, frame_in: np.ndarray) -> np.ndarray:
"""Compute PDAC features using the minimum value of each frame. Needs to be calculated freshly every time.
Args:
frame_in: Input array of shape (n_frames, frame_length).
Returns:
Array with features [a0, a1, ymax, ymin], where:
- a0: sum(frame[:idx_valley] - ymin)
- a1: sum(frame[idx_valley:] - ymin)
- ymax: maximum frame value
- ymin: minimum frame value
"""
pdac_out = []
for frame in frame_in:
ymin = np.min(frame)
idx_valley = np.argmin(frame)
ymax = np.max(frame)
a0 = np.sum(frame[:idx_valley] - ymin)
a1 = np.sum(frame[idx_valley:] - ymin)
pdac_out.append([a0, a1, ymax, ymin])
return np.array(pdac_out)
[docs]
def pdac_max(self, frame_in: np.ndarray) -> np.ndarray:
"""Compute PDAC features using the maximum value of each frame.
Args:
frame_in: Input array of shape (n_frames, frame_length).
Returns:
Array with features [a0, a1, ymax, ymin], where:
- a0: sum(ymax - frame[:idx_peak])
- a1: sum(ymax - frame[idx_peak:])
- ymax: maximum frame value
- ymin: minimum frame value
"""
pdac_out = []
for frame in frame_in:
ymin = np.min(frame)
ymax = np.max(frame)
idx_peak = np.argmax(frame)
a0 = np.sum(ymax - frame[:idx_peak])
a1 = np.sum(ymax - frame[idx_peak:])
pdac_out.append([a0, a1, ymax, ymin])
return np.array(pdac_out)
# ------------------------------------------------------------------
# PCA (full)
# ------------------------------------------------------------------
[docs]
def fit_pca_full(self, frame_in: np.ndarray) -> None:
"""Fit a PCA model using the full SVD solver and store it internally."""
self._ensure_method_matches("pca_full")
self._model = self._create_pca("full", None)
self._model.fit(frame_in)
self.fe_method = "pca_full"
[docs]
def fit_transform_pca_full(self, frame_in: np.ndarray) -> np.ndarray:
"""Fit a PCA model using the full SVD solver, store it, and return transformed data."""
self._ensure_method_matches("pca_full")
self._model = self._create_pca("full", None)
self.fe_method = "pca_full"
return self._model.fit_transform(frame_in)
# ------------------------------------------------------------------
# PCA (custom)
# ------------------------------------------------------------------
[docs]
def fit_pca_custom(
self,
frame_in: np.ndarray,
svd_solver_mode: Literal["covariance_eigh", "arpack", "randomized", "full"],
random_state: int | None = None,
) -> None:
"""Fit a PCA model with a selectable SVD solver and store it internally."""
method_name = f"pca_{svd_solver_mode}"
self._ensure_method_matches(method_name)
self._model = self._create_pca(svd_solver_mode, random_state)
self._model.fit(frame_in)
self.fe_method = method_name
[docs]
def fit_transform_pca_custom(
self,
frame_in: np.ndarray,
svd_solver_mode: Literal["covariance_eigh", "arpack", "randomized", "full"],
random_state: int | None = None,
) -> np.ndarray:
"""Fit a PCA model with a selectable SVD solver, store it, and return transformed data."""
method_name = f"pca_{svd_solver_mode}"
self._ensure_method_matches(method_name)
self._model = self._create_pca(svd_solver_mode, random_state)
self.fe_method = method_name
return self._model.fit_transform(frame_in)
# ------------------------------------------------------------------
# UMAP
# ------------------------------------------------------------------
[docs]
def fit_umap(
self,
frame_in: np.ndarray,
n_neighbors: int = 15,
random_state: int = 42,
min_dist: float = 0.1,
) -> None:
"""Fit a UMAP model and store it internally."""
self._ensure_method_matches("umap")
self._model = self._create_umap(n_neighbors, random_state, min_dist)
self._model.fit(frame_in)
self.fe_method = "umap"
[docs]
def fit_transform_umap(
self,
frame_in: np.ndarray,
n_neighbors: int = 15,
random_state: int = 42,
min_dist: float = 0.1,
) -> np.ndarray:
"""Fit a UMAP model, store it, and return transformed data."""
self._ensure_method_matches("umap")
self._model = self._create_umap(n_neighbors, random_state, min_dist)
self.fe_method = "umap"
return self._model.fit_transform(frame_in)
# ------------------------------------------------------------------
# ICA
# ------------------------------------------------------------------
[docs]
def fit_ica(self, frame_in: np.ndarray, random_state: int = 42) -> None:
"""Fit a FastICA model and store it internally."""
self._ensure_method_matches("ica")
self._model = self._create_ica(random_state)
self._model.fit(frame_in)
self.fe_method = "ica"
[docs]
def fit_transform_ica(self, frame_in: np.ndarray, random_state: int = 42) -> np.ndarray:
"""Fit a FastICA model, store it, and return transformed data."""
self._ensure_method_matches("ica")
self._model = self._create_ica(random_state)
self.fe_method = "ica"
return self._model.fit_transform(frame_in)