Source code for fynance.research.importance

#!/usr/bin/env python
# -*- coding: utf-8 -*-

""" Walk-forward permutation feature importance (MDA).

Mean-Decrease-Accuracy (MDA) importance evaluated **out-of-fold** on the
existing purged walk-forward splitter (:func:`fynance.data.split.walk_forward`):
for each fold the model is fit once on the train window, scored on the test
window, then re-scored after permuting one feature at a time *within the test
window only* -- the score drop attributed to that feature is averaged across
folds and repeats. Entry point: :func:`walk_forward_mda`.

"""

# Built-in
from __future__ import annotations

from dataclasses import dataclass
from typing import Callable

# Third-party
import numpy as np
from numpy.typing import NDArray

# Local
from fynance.core import SignalModel
from fynance.data.split import walk_forward

__all__ = ['ImportanceResult', 'walk_forward_mda']


def _neg_mse(y_true: NDArray[np.float64], y_pred: NDArray[np.float64]) -> float:
    """ Negative mean squared error (higher is better). """
    return float(-np.mean((y_true - y_pred) ** 2))


[docs] @dataclass class ImportanceResult: """ Result of :func:`walk_forward_mda`. Parameters ---------- importances : numpy.ndarray, shape (n_features,) Mean out-of-sample score drop per permuted feature, averaged across folds and repeats. Higher means more important (permuting the feature hurts the score more). stds : numpy.ndarray, shape (n_features,) Standard deviation of the score drop across folds x repeats. baseline : float Mean (unpermuted) out-of-sample score across folds. n_folds : int Number of walk-forward folds evaluated. feature_names : list of str, optional Feature labels aligned with ``importances``/``stds``, if provided. """ importances: NDArray[np.float64] stds: NDArray[np.float64] baseline: float n_folds: int feature_names: list[str] | None = None
[docs] def walk_forward_mda( model_factory: Callable[[], SignalModel], X: NDArray[np.float64], y: NDArray[np.float64], *, train: int = 252, test: int = 63, step: int | None = None, purge: int = 0, metric: Callable[[NDArray[np.float64], NDArray[np.float64]], float] | None = None, n_repeats: int = 5, seed: int = 0, feature_names: list[str] | None = None, ) -> ImportanceResult: """ Mean-decrease-accuracy feature importance, evaluated out-of-fold. Splits ``X``/``y`` with the purged walk-forward generator :func:`fynance.data.split.walk_forward`. Per fold: fit ``model_factory()`` once on the train window, score it on the (unpermuted) test window, then -- without refitting -- for each repeat and each feature, permute that feature's column **within the test window only** and re-score. The score drop (``baseline - permuted``) is the importance signal; it is averaged across folds x repeats (mean -> ``importances``, std -> ``stds``). Parameters ---------- model_factory : callable Zero-argument callable returning a fresh model exposing ``fit(X, y) -> self`` / ``predict(X) -> ndarray`` (:class:`fynance.core.SignalModel`). Called once per fold, so every fold starts from an untrained model. X : numpy.ndarray, shape (n_samples, n_features) Feature matrix, strictly time-ordered (row ``t`` must not depend on rows after ``t``). y : numpy.ndarray, shape (n_samples,) Target aligned with ``X``. train, test : int Train and test window lengths, forwarded to :func:`~fynance.data.split.walk_forward`. step : int, optional Roll step (defaults to ``test``; see :func:`~fynance.data.split.walk_forward`). purge : int Embargo between train and test windows, forwarded as-is. metric : callable, optional ``(y_true, y_pred) -> float``, **higher is better**. Defaults to the negative mean squared error (``-mean((y_true - y_pred) ** 2)``). n_repeats : int Number of independent permutation draws per feature per fold. seed : int Master seed. Fold ``i`` / repeat ``r`` draws its permutations from ``numpy.random.default_rng(seed + i * 1000 + r)``, so the result is fully reproducible for a fixed seed and independent across folds and repeats. feature_names : list of str, optional Labels carried through to :attr:`ImportanceResult.feature_names`; must match ``X.shape[1]`` if given. Returns ------- ImportanceResult See :class:`ImportanceResult`. Raises ------ ValueError If ``X`` is not 2-D, ``y`` is not 1-D, their lengths mismatch, ``train + test`` exceeds the number of samples (no fold would fit), or ``feature_names`` has the wrong length. Examples -------- A closed-form linear model recovers the planted feature (column 0) as the most important, well above the pure-noise columns: >>> import numpy as np >>> from fynance.research.importance import walk_forward_mda >>> class LinearModel: ... def fit(self, X, y): ... design = np.column_stack([X, np.ones(len(X))]) ... self.coef_, *_ = np.linalg.lstsq(design, y, rcond=None) ... return self ... def predict(self, X): ... design = np.column_stack([X, np.ones(len(X))]) ... return design @ self.coef_ >>> rng = np.random.default_rng(0) >>> n, k = 300, 3 >>> X = rng.standard_normal((n, k)) >>> y = 2.0 * X[:, 0] + 0.1 * rng.standard_normal(n) >>> result = walk_forward_mda(LinearModel, X, y, train=150, test=50, ... n_repeats=3, seed=0) >>> result.n_folds 3 >>> bool(result.importances[0] > result.importances[1]) True >>> bool(result.importances[0] > result.importances[2]) True """ X = np.asarray(X, dtype=np.float64) y = np.asarray(y, dtype=np.float64) if X.ndim != 2: raise ValueError(f"X must be 2-D (n_samples, n_features), got ndim={X.ndim}") if y.ndim != 1: raise ValueError(f"y must be 1-D, got ndim={y.ndim}") n_samples, n_features = X.shape if y.shape[0] != n_samples: raise ValueError( f"X and y length mismatch: X has {n_samples} rows, y has " f"{y.shape[0]}" ) if train + test > n_samples: raise ValueError( f"train + test ({train + test}) exceeds the number of samples " f"({n_samples}); no walk-forward fold would fit" ) if feature_names is not None and len(feature_names) != n_features: raise ValueError( f"feature_names has {len(feature_names)} entries, expected " f"{n_features} (X.shape[1])" ) if metric is None: metric = _neg_mse fold_drops: list[NDArray[np.float64]] = [] fold_baselines: list[float] = [] windows = walk_forward(n_samples, train=train, test=test, step=step, purge=purge) for fold_idx, (train_idx, test_idx) in enumerate(windows): model = model_factory() model.fit(X[train_idx], y[train_idx]) X_test = X[test_idx] y_test = y[test_idx] baseline_pred = model.predict(X_test) baseline_score = float(metric(y_test, baseline_pred)) fold_baselines.append(baseline_score) repeat_drops = np.empty((n_repeats, n_features), dtype=np.float64) for r in range(n_repeats): rng = np.random.default_rng(seed + fold_idx * 1000 + r) for j in range(n_features): X_perm = X_test.copy() X_perm[:, j] = rng.permutation(X_perm[:, j]) permuted_score = float(metric(y_test, model.predict(X_perm))) repeat_drops[r, j] = baseline_score - permuted_score fold_drops.append(repeat_drops) all_drops = np.concatenate(fold_drops, axis=0) return ImportanceResult( importances=all_drops.mean(axis=0), stds=all_drops.std(axis=0), baseline=float(np.mean(fold_baselines)), n_folds=len(fold_drops), feature_names=feature_names, )