Source code for ts2net.sklearn.feature_extractor

"""
sklearn-compatible transformers for network feature extraction.
"""

from __future__ import annotations

from collections.abc import Sequence
from typing import Any

import numpy as np
from numpy.typing import NDArray
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.utils.validation import check_array, check_is_fitted

from ts2net import HVG, NVG, RecurrenceNetwork, TransitionNetwork

_METHOD_BUILDERS = {
    "hvg": HVG,
    "nvg": NVG,
    "recurrence": RecurrenceNetwork,
    "transition": TransitionNetwork,
}

_DEFAULT_STATS = [
    "n_nodes",
    "n_edges",
    "avg_degree",
    "std_degree",
    "min_degree",
    "max_degree",
    "density",
]


[docs] class NetworkFeatureExtractor(BaseEstimator, TransformerMixin): """ Extract network summary features from time series for sklearn pipelines. Each input row is treated as one univariate time series. The transformer builds a network with the chosen method and returns summary statistics as a numeric feature vector. Parameters ---------- method : {"hvg", "nvg", "recurrence", "transition"}, default "hvg" Network construction method. output : {"stats", "degrees"}, default "stats" Builder output mode. ``"stats"`` is memory-efficient and recommended for panel data. features : list of str, optional Subset of summary statistics to return. Defaults to all available stats. prefix : str, optional Prefix for feature names (e.g. ``"hvg_"``). **builder_kwargs Additional keyword arguments passed to the network builder (e.g. ``limit=2000`` for NVG, ``rule="knn", k=5`` for recurrence). Examples -------- >>> import numpy as np >>> from sklearn.pipeline import Pipeline >>> from sklearn.preprocessing import StandardScaler >>> from sklearn.linear_model import LogisticRegression >>> from ts2net.sklearn import NetworkFeatureExtractor >>> X = np.random.randn(40, 200) >>> y = np.array([0] * 20 + [1] * 20) >>> pipe = Pipeline([ ... ("net", NetworkFeatureExtractor(method="hvg")), ... ("scale", StandardScaler()), ... ("clf", LogisticRegression(max_iter=500)), ... ]) >>> pipe.fit(X, y).score(X, y) # doctest: +SKIP """ def __init__( self, method: str = "hvg", output: str = "stats", features: Sequence[str] | None = None, prefix: str | None = None, **builder_kwargs: Any, ) -> None: self.method = method self.output = output self.features = features self.prefix = prefix self.builder_kwargs = builder_kwargs
[docs] def fit( self, X: NDArray[np.float64], y: NDArray[Any] | None = None, ) -> NetworkFeatureExtractor: """Learn feature names from a representative sample.""" X = self._validate_X(X) if self.method not in _METHOD_BUILDERS: raise ValueError( f"Unknown method {self.method!r}. " f"Choose from {sorted(_METHOD_BUILDERS)}" ) probe = self._extract_series_features(X[0]) self.feature_names_ = list(probe.keys()) self.n_features_in_ = X.shape[1] self.n_features_out_ = len(self.feature_names_) return self
[docs] def transform(self, X: NDArray[np.float64]) -> NDArray[np.float64]: """Extract network features for each time series.""" check_is_fitted(self, "feature_names_") X = self._validate_X(X) rows = [self._series_to_vector(x) for x in X] return np.vstack(rows)
[docs] def get_feature_names_out( self, input_features: Sequence[str] | None = None ) -> np.ndarray: """Return output feature names for sklearn >= 1.0.""" check_is_fitted(self, "feature_names_") return np.asarray(self.feature_names_, dtype=object)
def _validate_X(self, X: NDArray[np.float64]) -> NDArray[np.float64]: try: X = check_array( X, dtype="numeric", ensure_2d=True, ensure_all_finite=False ) except TypeError: # sklearn < 1.6 X = check_array( X, dtype="numeric", ensure_2d=True, force_all_finite=False ) if X.shape[0] == 0: raise ValueError("X must contain at least one sample") if X.shape[1] < 3: raise ValueError( f"Each time series must have at least 3 points, got {X.shape[1]}" ) return X def _create_builder(self): builder_cls = _METHOD_BUILDERS[self.method] return builder_cls(output=self.output, **self.builder_kwargs) def _extract_series_features(self, x: NDArray[np.float64]) -> dict[str, float]: x = np.asarray(x, dtype=np.float64) x = x[np.isfinite(x)] if len(x) < 3: raise ValueError("Time series must have at least 3 finite values") builder = self._create_builder() builder.build(x) if self.output == "stats": stats = builder.stats() elif self.output == "degrees": degrees = builder.degree_sequence() stats = { "n_nodes": float(builder.n_nodes), "n_edges": float(builder.n_edges), "avg_degree": float(np.mean(degrees)), "std_degree": float(np.std(degrees)) if len(degrees) > 1 else 0.0, "min_degree": float(np.min(degrees)), "max_degree": float(np.max(degrees)), "density": float(builder.n_edges) / max(builder.n_nodes * (builder.n_nodes - 1) / 2, 1), } else: raise ValueError( f"Unsupported output mode {self.output!r}. Use 'stats' or 'degrees'." ) selected = self.features if self.features is not None else _DEFAULT_STATS prefix = self.prefix or f"{self.method}_" return {f"{prefix}{key}": float(stats[key]) for key in selected if key in stats} def _series_to_vector(self, x: NDArray[np.float64]) -> NDArray[np.float64]: features = self._extract_series_features(x) return np.array( [features[name] for name in self.feature_names_], dtype=np.float64 )