Scikit-learn
human-activity-recognition
wearable
wrist
time-series
cpu
scikit-learn
WISP / src /wisp /cpu /algorithms.py
Zipeng365's picture
Add files using upload-large-folder tool
80b01cc verified
Raw History Blame Contribute Delete
22.7 kB
# Paper-scoped implementation; see SOURCE_PROVENANCE.json.
from __future__ import annotations
import pickle
import time
from dataclasses import dataclass, field
from typing import Any, Literal
import numpy as np
from numpy.typing import NDArray
from sklearn.base import BaseEstimator, ClassifierMixin, clone
from sklearn.ensemble import ExtraTreesClassifier, RandomForestClassifier
from sklearn.linear_model import LogisticRegression, RidgeClassifierCV
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.utils.class_weight import compute_sample_weight
from .features import IntervalDistributionSketch, InvariantPhysicsFeatureExtractor, RandomConvSketch, RandomShapeletSketch, WearableFeatureExtractor
from .hmm import TransitionSmoother
from .probability import estimator_proba, global_classes, labels_from_proba
def _softmax(z: NDArray[np.float64]) -> NDArray[np.float64]:
z = np.asarray(z, dtype=np.float64)
z = z - np.max(z, axis=1, keepdims=True)
e = np.exp(z)
return e / np.sum(e, axis=1, keepdims=True)
def _classifier(kind: str, *, seed: int, n_jobs: int=-1) -> Any:
if kind == 'extratrees':
return ExtraTreesClassifier(n_estimators=120, max_features='sqrt', min_samples_leaf=1, class_weight='balanced', random_state=seed, n_jobs=n_jobs)
if kind == 'rf':
return RandomForestClassifier(n_estimators=120, max_features='sqrt', min_samples_leaf=1, class_weight='balanced_subsample', random_state=seed, n_jobs=n_jobs)
if kind == 'logreg':
return LogisticRegression(C=2.0, max_iter=2000, class_weight='balanced', solver='lbfgs', random_state=seed)
if kind == 'ridge':
return RidgeClassifierCV(alphas=np.logspace(-3, 3, 9))
raise ValueError(f'Unknown classifier kind: {kind}')
class _HMMMixin:
use_hmm: bool
smoother_: TransitionSmoother | None
classes_: NDArray[np.int64] | None
trained_classes_: NDArray[np.int64] | None
def _set_classes(self, y: Any, n_classes: int | None) -> None:
yy = np.asarray(y, dtype=np.int64)
self.classes_ = global_classes(yy, n_classes)
self.trained_classes_ = np.unique(yy)
def _fit_smoother(self, y: NDArray[np.int64], groups: Any | None, time_index: Any | None) -> None:
self.smoother_ = None
if self.use_hmm:
if self.classes_ is None:
raise RuntimeError('Global class axis is not initialized')
self.smoother_ = TransitionSmoother().fit(y, groups=groups, time_index=time_index, n_classes=int(self.classes_.size))
def _maybe_smooth(self, proba: NDArray[np.float64], groups: Any | None, time_index: Any | None) -> NDArray[np.int64]:
if self.classes_ is None:
raise RuntimeError('Global class axis is not initialized')
if getattr(self, 'smoother_', None) is not None and groups is not None:
return self.smoother_.predict_from_proba(proba, groups=groups, time_index=time_index)
return labels_from_proba(proba, self.classes_)
@dataclass
class HarSculptForestHMM(BaseEstimator, ClassifierMixin, _HMMMixin):
classifier: Literal['extratrees', 'rf', 'logreg'] = 'extratrees'
use_hmm: bool = True
segment_sizes: tuple[int, ...] = (4, 8, 16)
symbolic_bins: int = 6
seed: int = 42
n_jobs: int = -1
feature_extractor_: WearableFeatureExtractor | None = field(default=None, init=False)
model_: Any = field(default=None, init=False)
smoother_: TransitionSmoother | None = field(default=None, init=False)
classes_: NDArray[np.int64] | None = field(default=None, init=False)
trained_classes_: NDArray[np.int64] | None = field(default=None, init=False)
fit_seconds_: float = field(default=0.0, init=False)
def fit(self, X: Any, y: Any, *, groups: Any | None=None, time_index: Any | None=None, n_classes: int | None=None) -> 'HarSculptForestHMM':
start = time.perf_counter()
y = np.asarray(y, dtype=np.int64)
self._set_classes(y, n_classes)
self.feature_extractor_ = WearableFeatureExtractor(segment_sizes=self.segment_sizes, symbolic_bins=self.symbolic_bins)
F = self.feature_extractor_.fit_transform(X, y)
clf = _classifier(self.classifier, seed=self.seed, n_jobs=self.n_jobs)
if self.classifier == 'logreg':
self.model_ = Pipeline([('scale', StandardScaler()), ('clf', clf)])
else:
self.model_ = clf
self.model_.fit(F, y)
self._fit_smoother(y, groups, time_index)
self.fit_seconds_ = time.perf_counter() - start
return self
def predict_proba(self, X: Any) -> NDArray[np.float64]:
if self.model_ is None or self.feature_extractor_ is None or self.classes_ is None:
raise RuntimeError('Model is not fitted')
F = self.feature_extractor_.transform(X)
return estimator_proba(self.model_, F, self.classes_)
def predict(self, X: Any, *, groups: Any | None=None, time_index: Any | None=None) -> NDArray[np.int64]:
return self._maybe_smooth(self.predict_proba(X), groups, time_index)
def design_profile(self) -> dict[str, Any]:
return {'name': 'har_sculpt_forest_hmm', 'route': 'cpu', 'operators': ['zscore', 'magnitude', 'jerk', 'multiscale_interval_stats', 'fft_bandpower', 'autocorr', 'symbolic_transition', 'extratrees', 'hmm' if self.use_hmm else 'argmax'], 'motifs': ['orientation_invariance', 'local_periodicity', 'transition_smoothing', 'tail_class_bias']}
@dataclass
class RocketSketchRidgeHMM(BaseEstimator, ClassifierMixin, _HMMMixin):
n_kernels: int = 384
classifier: Literal['ridge', 'logreg'] = 'ridge'
use_hmm: bool = True
seed: int = 42
n_jobs: int = -1
sketch_: RandomConvSketch | None = field(default=None, init=False)
model_: Any = field(default=None, init=False)
smoother_: TransitionSmoother | None = field(default=None, init=False)
classes_: NDArray[np.int64] | None = field(default=None, init=False)
trained_classes_: NDArray[np.int64] | None = field(default=None, init=False)
fit_seconds_: float = field(default=0.0, init=False)
def fit(self, X: Any, y: Any, *, groups: Any | None=None, time_index: Any | None=None, n_classes: int | None=None) -> 'RocketSketchRidgeHMM':
start = time.perf_counter()
y = np.asarray(y, dtype=np.int64)
self._set_classes(y, n_classes)
self.sketch_ = RandomConvSketch(n_kernels=self.n_kernels, random_state=self.seed)
F = self.sketch_.fit_transform(X, y)
clf = _classifier(self.classifier, seed=self.seed, n_jobs=self.n_jobs)
self.model_ = Pipeline([('scale', StandardScaler()), ('clf', clf)])
sample_weight = compute_sample_weight('balanced', y) if self.classifier == 'ridge' else None
if sample_weight is not None:
self.model_.fit(F, y, clf__sample_weight=sample_weight)
else:
self.model_.fit(F, y)
self._fit_smoother(y, groups, time_index)
self.fit_seconds_ = time.perf_counter() - start
return self
def predict_proba(self, X: Any) -> NDArray[np.float64]:
if self.model_ is None or self.sketch_ is None or self.classes_ is None:
raise RuntimeError('Model is not fitted')
F = self.sketch_.transform(X)
return estimator_proba(self.model_, F, self.classes_)
def predict(self, X: Any, *, groups: Any | None=None, time_index: Any | None=None) -> NDArray[np.int64]:
return self._maybe_smooth(self.predict_proba(X), groups, time_index)
def design_profile(self) -> dict[str, Any]:
return {'name': 'rocket_sketch_ridge_hmm', 'route': 'cpu', 'operators': ['zscore', 'random_conv', 'ppv_pool', 'ridge', 'hmm' if self.use_hmm else 'argmax'], 'motifs': ['phase_tolerant_local_shape', 'fast_cpu', 'transition_smoothing']}
@dataclass
class SymbolicIntervalForest(BaseEstimator, ClassifierMixin, _HMMMixin):
use_hmm: bool = True
seed: int = 42
n_jobs: int = -1
feature_extractor_: WearableFeatureExtractor | None = field(default=None, init=False)
model_: Any = field(default=None, init=False)
smoother_: TransitionSmoother | None = field(default=None, init=False)
classes_: NDArray[np.int64] | None = field(default=None, init=False)
trained_classes_: NDArray[np.int64] | None = field(default=None, init=False)
fit_seconds_: float = field(default=0.0, init=False)
def fit(self, X: Any, y: Any, *, groups: Any | None=None, time_index: Any | None=None, n_classes: int | None=None) -> 'SymbolicIntervalForest':
start = time.perf_counter()
y = np.asarray(y, dtype=np.int64)
self._set_classes(y, n_classes)
self.feature_extractor_ = WearableFeatureExtractor(include_raw_stats=True, include_magnitude=True, include_jerk=False, include_multiscale=True, segment_sizes=(5, 10, 20), include_spectral=False, include_autocorr=False, include_cross_channel=True, include_symbolic=True, symbolic_bins=8)
F = self.feature_extractor_.fit_transform(X, y)
self.model_ = ExtraTreesClassifier(n_estimators=160, max_features='sqrt', class_weight='balanced', random_state=self.seed, n_jobs=self.n_jobs)
self.model_.fit(F, y)
self._fit_smoother(y, groups, time_index)
self.fit_seconds_ = time.perf_counter() - start
return self
def predict_proba(self, X: Any) -> NDArray[np.float64]:
if self.model_ is None or self.feature_extractor_ is None or self.classes_ is None:
raise RuntimeError('Model is not fitted')
F = self.feature_extractor_.transform(X)
return estimator_proba(self.model_, F, self.classes_)
def predict(self, X: Any, *, groups: Any | None=None, time_index: Any | None=None) -> NDArray[np.int64]:
return self._maybe_smooth(self.predict_proba(X), groups, time_index)
def design_profile(self) -> dict[str, Any]:
return {'name': 'symbolic_interval_forest', 'route': 'cpu', 'operators': ['segment', 'aggregate_stats', 'quantize', 'transition_histogram', 'forest', 'hmm' if self.use_hmm else 'argmax'], 'motifs': ['interpretable_symbols', 'micro_pattern', 'transition_smoothing']}
@dataclass
class HybridCPUEnsemble(BaseEstimator, ClassifierMixin):
use_hmm: bool = True
seed: int = 42
n_jobs: int = -1
members_: list[Any] = field(default_factory=list, init=False)
classes_: NDArray[np.int64] | None = field(default=None, init=False)
trained_classes_: NDArray[np.int64] | None = field(default=None, init=False)
fit_seconds_: float = field(default=0.0, init=False)
def fit(self, X: Any, y: Any, *, groups: Any | None=None, time_index: Any | None=None, n_classes: int | None=None) -> 'HybridCPUEnsemble':
start = time.perf_counter()
yy = np.asarray(y, dtype=np.int64)
self.classes_ = global_classes(yy, n_classes)
self.trained_classes_ = np.unique(yy)
self.members_ = [HarSculptForestHMM(classifier='extratrees', use_hmm=False, seed=self.seed, n_jobs=self.n_jobs), RocketSketchRidgeHMM(n_kernels=256, classifier='ridge', use_hmm=False, seed=self.seed + 1, n_jobs=self.n_jobs), SymbolicIntervalForest(use_hmm=False, seed=self.seed + 2, n_jobs=self.n_jobs)]
for m in self.members_:
m.fit(X, yy, groups=groups, time_index=time_index, n_classes=int(self.classes_.size))
self.smoother_ = TransitionSmoother().fit(yy, groups=groups, time_index=time_index, n_classes=int(self.classes_.size)) if self.use_hmm else None
self.fit_seconds_ = time.perf_counter() - start
return self
def predict_proba(self, X: Any) -> NDArray[np.float64]:
if not self.members_ or self.classes_ is None:
raise RuntimeError('Model is not fitted')
out = np.mean([m.predict_proba(X) for m in self.members_], axis=0)
return out / np.maximum(out.sum(axis=1, keepdims=True), 1e-12)
def predict(self, X: Any, *, groups: Any | None=None, time_index: Any | None=None) -> NDArray[np.int64]:
P = self.predict_proba(X)
if getattr(self, 'smoother_', None) is not None and groups is not None:
return self.smoother_.predict_from_proba(P, groups=groups, time_index=time_index)
return labels_from_proba(P, self.classes_)
def design_profile(self) -> dict[str, Any]:
return {'name': 'hybrid_cpu_ensemble', 'route': 'cpu', 'operators': ['feature_union', 'random_conv', 'symbolic', 'probability_average', 'hmm' if self.use_hmm else 'argmax'], 'motifs': ['multi_view_cpu', 'robustness_by_diversity', 'transition_smoothing']}
@dataclass
class InvariantIntervalShapeForest(BaseEstimator, ClassifierMixin, _HMMMixin):
classifier: Literal['extratrees', 'rf', 'logreg'] = 'extratrees'
use_hmm: bool = True
segment_sizes: tuple[int, ...] = (4, 8, 16)
symbolic_bins: int = 6
n_shapelets: int = 32
seed: int = 42
n_jobs: int = -1
physics_: InvariantPhysicsFeatureExtractor | None = field(default=None, init=False)
shapelets_: RandomShapeletSketch | None = field(default=None, init=False)
model_: Any = field(default=None, init=False)
smoother_: TransitionSmoother | None = field(default=None, init=False)
classes_: NDArray[np.int64] | None = field(default=None, init=False)
trained_classes_: NDArray[np.int64] | None = field(default=None, init=False)
fit_seconds_: float = field(default=0.0, init=False)
def fit(self, X: Any, y: Any, *, groups: Any | None=None, time_index: Any | None=None, n_classes: int | None=None) -> 'InvariantIntervalShapeForest':
start = time.perf_counter()
y = np.asarray(y, dtype=np.int64)
self._set_classes(y, n_classes)
self.physics_ = InvariantPhysicsFeatureExtractor(segment_sizes=self.segment_sizes, symbolic_bins=int(self.symbolic_bins), random_intervals=24, random_state=self.seed, include_axis_features=True).fit(X, y)
self.shapelets_ = RandomShapeletSketch(n_shapelets=int(self.n_shapelets), random_state=self.seed + 17).fit(X, y)
F = np.concatenate([self.physics_.transform(X), self.shapelets_.transform(X)], axis=1)
clf = _classifier(self.classifier, seed=self.seed, n_jobs=self.n_jobs)
self.model_ = Pipeline([('scale', StandardScaler()), ('clf', clf)]) if self.classifier == 'logreg' else clf
self.model_.fit(F, y)
self._fit_smoother(y, groups, time_index)
self.fit_seconds_ = time.perf_counter() - start
return self
def _features(self, X: Any) -> NDArray[np.float64]:
if self.physics_ is None or self.shapelets_ is None:
raise RuntimeError('Model is not fitted')
return np.concatenate([self.physics_.transform(X), self.shapelets_.transform(X)], axis=1)
def predict_proba(self, X: Any) -> NDArray[np.float64]:
if self.model_ is None or self.classes_ is None:
raise RuntimeError('Model is not fitted')
return estimator_proba(self.model_, self._features(X), self.classes_)
def predict(self, X: Any, *, groups: Any | None=None, time_index: Any | None=None) -> NDArray[np.int64]:
return self._maybe_smooth(self.predict_proba(X), groups, time_index)
def design_profile(self) -> dict[str, Any]:
return {'name': 'invariant_interval_shape_forest', 'route': 'cpu', 'operators': ['zscore', 'magnitude', 'jerk', 'gram_eig', 'dyadic_intervals', 'quantile_distribution', 'random_shapelet', 'extratrees', 'hmm' if self.use_hmm else 'argmax'], 'motifs': ['orientation_invariance', 'device_shift_robustness', 'interval_distribution', 'local_shape', 'transition_smoothing']}
@dataclass
class StateRocketIntervalHMM(BaseEstimator, ClassifierMixin, _HMMMixin):
n_kernels: int = 384
n_intervals: int = 24
classifier: Literal['ridge', 'logreg'] = 'ridge'
use_hmm: bool = True
seed: int = 42
n_jobs: int = -1
rocket_: RandomConvSketch | None = field(default=None, init=False)
interval_: IntervalDistributionSketch | None = field(default=None, init=False)
state_: WearableFeatureExtractor | None = field(default=None, init=False)
model_: Any = field(default=None, init=False)
smoother_: TransitionSmoother | None = field(default=None, init=False)
classes_: NDArray[np.int64] | None = field(default=None, init=False)
trained_classes_: NDArray[np.int64] | None = field(default=None, init=False)
fit_seconds_: float = field(default=0.0, init=False)
def fit(self, X: Any, y: Any, *, groups: Any | None=None, time_index: Any | None=None, n_classes: int | None=None) -> 'StateRocketIntervalHMM':
start = time.perf_counter()
y = np.asarray(y, dtype=np.int64)
self._set_classes(y, n_classes)
self.rocket_ = RandomConvSketch(n_kernels=int(self.n_kernels), random_state=self.seed).fit(X, y)
self.interval_ = IntervalDistributionSketch(n_random_intervals=int(self.n_intervals), random_state=self.seed + 29).fit(X, y)
self.state_ = WearableFeatureExtractor(include_raw_stats=False, include_magnitude=True, include_jerk=True, include_multiscale=False, include_spectral=True, include_autocorr=True, include_cross_channel=True, include_symbolic=True, symbolic_bins=6).fit(X, y)
F = self._features(X)
clf = _classifier(self.classifier, seed=self.seed, n_jobs=self.n_jobs)
self.model_ = Pipeline([('scale', StandardScaler()), ('clf', clf)])
if self.classifier == 'ridge':
self.model_.fit(F, y, clf__sample_weight=compute_sample_weight('balanced', y))
else:
self.model_.fit(F, y)
self._fit_smoother(y, groups, time_index)
self.fit_seconds_ = time.perf_counter() - start
return self
def _features(self, X: Any) -> NDArray[np.float64]:
if self.rocket_ is None or self.interval_ is None or self.state_ is None:
raise RuntimeError('Model is not fitted')
return np.concatenate([self.rocket_.transform(X), self.interval_.transform(X), self.state_.transform(X)], axis=1)
def predict_proba(self, X: Any) -> NDArray[np.float64]:
if self.model_ is None or self.classes_ is None:
raise RuntimeError('Model is not fitted')
return estimator_proba(self.model_, self._features(X), self.classes_)
def predict(self, X: Any, *, groups: Any | None=None, time_index: Any | None=None) -> NDArray[np.int64]:
return self._maybe_smooth(self.predict_proba(X), groups, time_index)
def design_profile(self) -> dict[str, Any]:
return {'name': 'state_rocket_interval_hmm', 'route': 'cpu', 'operators': ['zscore', 'random_conv', 'ppv_pool', 'dyadic_intervals', 'quantile_distribution', 'symbolic_transition', 'ridge', 'hmm' if self.use_hmm else 'argmax'], 'motifs': ['phase_tolerant_local_shape', 'interval_distribution', 'state_dynamics', 'fast_cpu', 'transition_smoothing']}
@dataclass
class PosteriorCPUEnsemble(BaseEstimator, ClassifierMixin):
use_hmm: bool = True
seed: int = 42
n_jobs: int = -1
weights: tuple[float, ...] = (0.34, 0.33, 0.33)
members_: list[Any] = field(default_factory=list, init=False)
smoother_: TransitionSmoother | None = field(default=None, init=False)
classes_: NDArray[np.int64] | None = field(default=None, init=False)
trained_classes_: NDArray[np.int64] | None = field(default=None, init=False)
fit_seconds_: float = field(default=0.0, init=False)
def fit(self, X: Any, y: Any, *, groups: Any | None=None, time_index: Any | None=None, n_classes: int | None=None) -> 'PosteriorCPUEnsemble':
start = time.perf_counter()
yy = np.asarray(y, dtype=np.int64)
self.classes_ = global_classes(yy, n_classes)
self.trained_classes_ = np.unique(yy)
self.members_ = [StateRocketIntervalHMM(n_kernels=256, n_intervals=16, use_hmm=False, seed=self.seed, n_jobs=self.n_jobs), InvariantIntervalShapeForest(n_shapelets=24, use_hmm=False, seed=self.seed + 1, n_jobs=self.n_jobs), SymbolicIntervalForest(use_hmm=False, seed=self.seed + 2, n_jobs=self.n_jobs)]
for member in self.members_:
member.fit(X, yy, groups=groups, time_index=time_index, n_classes=int(self.classes_.size))
self.smoother_ = TransitionSmoother().fit(yy, groups=groups, time_index=time_index, n_classes=int(self.classes_.size)) if self.use_hmm else None
self.fit_seconds_ = time.perf_counter() - start
return self
def predict_proba(self, X: Any) -> NDArray[np.float64]:
if not self.members_ or self.classes_ is None:
raise RuntimeError('Model is not fitted')
probs = [m.predict_proba(X) for m in self.members_]
w = np.asarray(self.weights, dtype=np.float64)
if w.size != len(probs):
w = np.ones((len(probs),), dtype=np.float64)
w = w / np.maximum(w.sum(), 1e-12)
out = sum((float(wi) * pi for wi, pi in zip(w, probs)))
return out / np.maximum(out.sum(axis=1, keepdims=True), 1e-12)
def predict(self, X: Any, *, groups: Any | None=None, time_index: Any | None=None) -> NDArray[np.int64]:
P = self.predict_proba(X)
if self.smoother_ is not None and groups is not None:
return self.smoother_.predict_from_proba(P, groups=groups, time_index=time_index)
return labels_from_proba(P, self.classes_)
def design_profile(self) -> dict[str, Any]:
return {'name': 'posterior_cpu_ensemble', 'route': 'cpu', 'operators': ['posterior_weighting', 'random_conv', 'invariant_physics', 'symbolic_transition', 'probability_ensemble', 'hmm' if self.use_hmm else 'argmax'], 'motifs': ['multi_view_cpu', 'posterior_over_motifs', 'state_dynamics', 'orientation_invariance', 'transition_smoothing']}
def build_cpu_estimator(spec: dict[str, Any] | str) -> Any:
if isinstance(spec, str):
name = spec
params: dict[str, Any] = {}
else:
name = str(spec.get('name', spec.get('algorithm', 'har_sculpt_forest_hmm')))
params = dict(spec.get('params', {}))
if name == 'har_sculpt_forest_hmm':
return HarSculptForestHMM(**params)
if name == 'rocket_sketch_ridge_hmm':
return RocketSketchRidgeHMM(**params)
if name == 'symbolic_interval_forest':
return SymbolicIntervalForest(**params)
if name == 'hybrid_cpu_ensemble':
return HybridCPUEnsemble(**params)
if name == 'invariant_interval_shape_forest':
return InvariantIntervalShapeForest(**params)
if name == 'state_rocket_interval_hmm':
return StateRocketIntervalHMM(**params)
if name == 'posterior_cpu_ensemble':
return PosteriorCPUEnsemble(**params)
raise ValueError(f'Unknown CPU estimator: {name}')
def estimate_pickle_size_mb(model: Any) -> float:
return len(pickle.dumps(model)) / (1024.0 * 1024.0)