boruta-quant 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- boruta_quant/__init__.py +29 -0
- boruta_quant/_version.py +3 -0
- boruta_quant/metrics/__init__.py +31 -0
- boruta_quant/metrics/auc.py +83 -0
- boruta_quant/metrics/directional_accuracy.py +67 -0
- boruta_quant/metrics/rank_ic.py +72 -0
- boruta_quant/oracle/__init__.py +32 -0
- boruta_quant/oracle/base.py +124 -0
- boruta_quant/oracle/block_permutation.py +165 -0
- boruta_quant/oracle/drop_column.py +99 -0
- boruta_quant/oracle/permutation.py +85 -0
- boruta_quant/profiling/__init__.py +18 -0
- boruta_quant/profiling/helpers.py +40 -0
- boruta_quant/profiling/results.py +37 -0
- boruta_quant/profiling/session.py +128 -0
- boruta_quant/profiling/timer.py +58 -0
- boruta_quant/py.typed +0 -0
- boruta_quant/selector/__init__.py +47 -0
- boruta_quant/selector/config.py +56 -0
- boruta_quant/selector/hypothesis.py +132 -0
- boruta_quant/selector/results.py +44 -0
- boruta_quant/selector/selector.py +353 -0
- boruta_quant/selector/shadow.py +100 -0
- boruta_quant/selector/shuffle.py +53 -0
- boruta_quant/temporal/__init__.py +40 -0
- boruta_quant/temporal/config.py +37 -0
- boruta_quant/temporal/cv.py +74 -0
- boruta_quant/temporal/purged_cv.py +147 -0
- boruta_quant/temporal/split.py +28 -0
- boruta_quant-0.1.0.dist-info/METADATA +203 -0
- boruta_quant-0.1.0.dist-info/RECORD +33 -0
- boruta_quant-0.1.0.dist-info/WHEEL +4 -0
- boruta_quant-0.1.0.dist-info/licenses/LICENSE +21 -0
boruta_quant/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""
|
|
2
|
+
boruta-quant: Temporal-aware Boruta feature selection for quantitative finance.
|
|
3
|
+
|
|
4
|
+
OOS-only importance computation with purged cross-validation,
|
|
5
|
+
for financial time series where temporal integrity is critical.
|
|
6
|
+
|
|
7
|
+
Example:
|
|
8
|
+
>>> from boruta_quant import BorutaSelector, BorutaSelectorConfig
|
|
9
|
+
>>> from boruta_quant.oracle import PermutationImportanceOracle
|
|
10
|
+
>>> from boruta_quant.temporal import PurgedTemporalCV, PurgedCVConfig
|
|
11
|
+
>>>
|
|
12
|
+
>>> selector = BorutaSelector(
|
|
13
|
+
... config=BorutaSelectorConfig(n_trials=20, percentile=100, alpha=0.05, two_step=True, random_state=42),
|
|
14
|
+
... oracle=PermutationImportanceOracle(scoring="neg_mean_squared_error"),
|
|
15
|
+
... cv=PurgedTemporalCV(PurgedCVConfig(n_splits=5, purge_window_days=5, embargo_window_days=5, min_train_size=100, test_size_ratio=0.2)),
|
|
16
|
+
... )
|
|
17
|
+
>>> result = selector.fit(X, y, timestamps, model)
|
|
18
|
+
>>> print(result.accepted_features)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from boruta_quant._version import __version__
|
|
22
|
+
from boruta_quant.selector import BorutaResult, BorutaSelector, BorutaSelectorConfig
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"__version__",
|
|
26
|
+
"BorutaSelector",
|
|
27
|
+
"BorutaSelectorConfig",
|
|
28
|
+
"BorutaResult",
|
|
29
|
+
]
|
boruta_quant/_version.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Scoring metrics for boruta-quant.
|
|
3
|
+
|
|
4
|
+
Available Metrics:
|
|
5
|
+
- rank_ic / rank_ic_scorer: Spearman correlation (rank information coefficient).
|
|
6
|
+
- directional_accuracy / directional_accuracy_scorer: Fraction of correct sign predictions.
|
|
7
|
+
- auc_score / auc_scorer: Area under ROC curve.
|
|
8
|
+
|
|
9
|
+
Example:
|
|
10
|
+
>>> from boruta_quant.metrics import rank_ic_scorer, directional_accuracy_scorer
|
|
11
|
+
>>> from boruta_quant.oracle import PermutationImportanceOracle
|
|
12
|
+
>>>
|
|
13
|
+
>>> oracle = PermutationImportanceOracle(scoring=rank_ic_scorer)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from boruta_quant.metrics.auc import auc_score, auc_scorer, auc_scorer_sklearn
|
|
17
|
+
from boruta_quant.metrics.directional_accuracy import (
|
|
18
|
+
directional_accuracy,
|
|
19
|
+
directional_accuracy_scorer,
|
|
20
|
+
)
|
|
21
|
+
from boruta_quant.metrics.rank_ic import rank_ic, rank_ic_scorer
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"rank_ic",
|
|
25
|
+
"rank_ic_scorer",
|
|
26
|
+
"directional_accuracy",
|
|
27
|
+
"directional_accuracy_scorer",
|
|
28
|
+
"auc_score",
|
|
29
|
+
"auc_scorer",
|
|
30
|
+
"auc_scorer_sklearn",
|
|
31
|
+
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AUC-ROC scorer for classification-based feature selection.
|
|
3
|
+
|
|
4
|
+
Use cases:
|
|
5
|
+
- Direction prediction (up/down)
|
|
6
|
+
- Regime classification
|
|
7
|
+
- Binary signal classification
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import numpy.typing as npt
|
|
16
|
+
from beartype import beartype
|
|
17
|
+
from sklearn.metrics import make_scorer, roc_auc_score
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@beartype
|
|
21
|
+
def auc_score(
|
|
22
|
+
y_true: npt.NDArray[Any],
|
|
23
|
+
y_pred_proba: npt.NDArray[Any],
|
|
24
|
+
) -> float:
|
|
25
|
+
"""
|
|
26
|
+
Compute AUC-ROC score for binary classification.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
y_true: True binary labels (0 or 1)
|
|
30
|
+
y_pred_proba: Predicted probabilities for positive class
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
AUC-ROC score in [0, 1]. 0.5 = random, 1.0 = perfect.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
AssertionError: If inputs have wrong shape or values.
|
|
37
|
+
"""
|
|
38
|
+
assert len(y_true) == len(y_pred_proba), "Length mismatch"
|
|
39
|
+
assert len(y_true) >= 2, "Need at least 2 samples"
|
|
40
|
+
|
|
41
|
+
# Handle edge case: single class in y_true
|
|
42
|
+
unique_classes = np.unique(y_true)
|
|
43
|
+
if len(unique_classes) == 1:
|
|
44
|
+
return 0.5 # Undefined AUC, return random baseline
|
|
45
|
+
|
|
46
|
+
return float(roc_auc_score(y_true, y_pred_proba))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@beartype
|
|
50
|
+
def auc_scorer(
|
|
51
|
+
estimator: Any,
|
|
52
|
+
X: npt.NDArray[Any],
|
|
53
|
+
y_true: npt.NDArray[Any],
|
|
54
|
+
) -> float:
|
|
55
|
+
"""
|
|
56
|
+
sklearn-compatible AUC scorer for use with permutation_importance.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
estimator: Fitted classifier with predict_proba method
|
|
60
|
+
X: Feature matrix
|
|
61
|
+
y_true: True binary labels
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
AUC-ROC score
|
|
65
|
+
"""
|
|
66
|
+
assert hasattr(estimator, "predict_proba"), "Estimator must have predict_proba"
|
|
67
|
+
|
|
68
|
+
y_pred_proba = estimator.predict_proba(X)[:, 1]
|
|
69
|
+
return auc_score(y_true, y_pred_proba)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# sklearn make_scorer compatible version
|
|
73
|
+
auc_scorer_sklearn = make_scorer(roc_auc_score, needs_proba=True, response_method="predict_proba")
|
|
74
|
+
"""
|
|
75
|
+
sklearn-compatible scorer for AUC-ROC.
|
|
76
|
+
|
|
77
|
+
Usage with PermutationImportanceOracle:
|
|
78
|
+
>>> oracle = PermutationImportanceOracle(scoring=auc_scorer_sklearn)
|
|
79
|
+
|
|
80
|
+
Usage with sklearn cross_val_score:
|
|
81
|
+
>>> from sklearn.model_selection import cross_val_score
|
|
82
|
+
>>> scores = cross_val_score(model, X, y, cv=cv, scoring=auc_scorer_sklearn)
|
|
83
|
+
"""
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Directional Accuracy scoring metric.
|
|
3
|
+
|
|
4
|
+
Measures what fraction of predictions got the sign right (up vs down).
|
|
5
|
+
The most intuitive alpha signal metric — "did you predict the right direction?"
|
|
6
|
+
|
|
7
|
+
Why Directional Accuracy?
|
|
8
|
+
- Every PM understands it immediately
|
|
9
|
+
- Works with continuous return predictions
|
|
10
|
+
- Complementary to Rank IC (IC measures rank order, DA measures sign)
|
|
11
|
+
- Baseline is 50% (random guessing)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import numpy.typing as npt
|
|
18
|
+
from beartype import beartype
|
|
19
|
+
from sklearn.metrics import make_scorer
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@beartype
|
|
23
|
+
def directional_accuracy(
|
|
24
|
+
y_true: npt.NDArray[Any],
|
|
25
|
+
y_pred: npt.NDArray[Any],
|
|
26
|
+
) -> float:
|
|
27
|
+
"""
|
|
28
|
+
Compute directional accuracy (fraction of correct sign predictions).
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
y_true: Actual values (e.g., forward returns).
|
|
32
|
+
y_pred: Predicted values (e.g., alpha signal).
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
Fraction of correct sign predictions in [0.0, 1.0].
|
|
36
|
+
0.5 = random, 1.0 = perfect direction.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
AssertionError: If inputs are empty or mismatched length.
|
|
40
|
+
|
|
41
|
+
Example:
|
|
42
|
+
>>> y_true = np.array([0.01, -0.02, 0.03, -0.01, 0.02])
|
|
43
|
+
>>> y_pred = np.array([0.5, -0.3, 0.8, 0.2, 0.4])
|
|
44
|
+
>>> da = directional_accuracy(y_true, y_pred)
|
|
45
|
+
>>> print(f"DA: {da:.2%}") # 80.00% (4/5 correct)
|
|
46
|
+
"""
|
|
47
|
+
assert len(y_true) == len(y_pred), (
|
|
48
|
+
f"Length mismatch: y_true={len(y_true)}, y_pred={len(y_pred)}"
|
|
49
|
+
)
|
|
50
|
+
assert len(y_true) > 0, "Need at least 1 sample"
|
|
51
|
+
|
|
52
|
+
true_signs = np.sign(y_true)
|
|
53
|
+
pred_signs = np.sign(y_pred)
|
|
54
|
+
|
|
55
|
+
# Zero returns/predictions are ambiguous — count as correct only if both zero
|
|
56
|
+
correct = true_signs == pred_signs
|
|
57
|
+
return float(np.mean(correct))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# sklearn-compatible scorer for use with permutation_importance
|
|
61
|
+
directional_accuracy_scorer = make_scorer(directional_accuracy, greater_is_better=True)
|
|
62
|
+
"""
|
|
63
|
+
sklearn-compatible scorer for Directional Accuracy.
|
|
64
|
+
|
|
65
|
+
Usage with PermutationImportanceOracle:
|
|
66
|
+
>>> oracle = PermutationImportanceOracle(scoring=directional_accuracy_scorer)
|
|
67
|
+
"""
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rank Information Coefficient (IC) scoring metric.
|
|
3
|
+
|
|
4
|
+
The Rank IC is the standard metric in quantitative finance for evaluating
|
|
5
|
+
alpha signals. It measures the Spearman correlation between predicted
|
|
6
|
+
values and actual forward returns.
|
|
7
|
+
|
|
8
|
+
Why Rank IC?
|
|
9
|
+
- Robust to outliers (uses ranks, not raw values)
|
|
10
|
+
- Interpretable: -1 to +1 scale
|
|
11
|
+
- Industry standard for alpha research
|
|
12
|
+
- Works with continuous predictions
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import numpy.typing as npt
|
|
19
|
+
from beartype import beartype
|
|
20
|
+
from scipy.stats import spearmanr
|
|
21
|
+
from sklearn.metrics import make_scorer
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@beartype
|
|
25
|
+
def rank_ic(y_true: npt.NDArray[Any], y_pred: npt.NDArray[Any]) -> float:
|
|
26
|
+
"""
|
|
27
|
+
Compute Rank Information Coefficient (Spearman correlation).
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
y_true: Actual values (e.g., forward returns).
|
|
31
|
+
y_pred: Predicted values (e.g., alpha signal).
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Spearman correlation coefficient in [-1, 1].
|
|
35
|
+
Returns 0.0 if correlation cannot be computed (e.g., constant input).
|
|
36
|
+
|
|
37
|
+
Example:
|
|
38
|
+
>>> y_true = np.array([0.01, -0.02, 0.03, -0.01, 0.02])
|
|
39
|
+
>>> y_pred = np.array([0.5, -0.3, 0.8, -0.2, 0.4])
|
|
40
|
+
>>> ic = rank_ic(y_true, y_pred)
|
|
41
|
+
>>> print(f"IC: {ic:.4f}")
|
|
42
|
+
"""
|
|
43
|
+
if len(y_true) < 2:
|
|
44
|
+
return 0.0
|
|
45
|
+
|
|
46
|
+
# Handle constant arrays
|
|
47
|
+
if np.std(y_true) == 0 or np.std(y_pred) == 0:
|
|
48
|
+
return 0.0
|
|
49
|
+
|
|
50
|
+
result = spearmanr(y_true, y_pred)
|
|
51
|
+
# Use index access for type safety (result is SignificanceResult namedtuple)
|
|
52
|
+
correlation: float = float(result[0]) # type: ignore[arg-type]
|
|
53
|
+
|
|
54
|
+
# Handle NaN (can occur with insufficient variance)
|
|
55
|
+
if np.isnan(correlation):
|
|
56
|
+
return 0.0
|
|
57
|
+
|
|
58
|
+
return correlation
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# sklearn-compatible scorer for use with permutation_importance
|
|
62
|
+
rank_ic_scorer = make_scorer(rank_ic, greater_is_better=True)
|
|
63
|
+
"""
|
|
64
|
+
sklearn-compatible scorer for Rank IC.
|
|
65
|
+
|
|
66
|
+
Usage with PermutationImportanceOracle:
|
|
67
|
+
>>> oracle = PermutationImportanceOracle(scoring=rank_ic_scorer)
|
|
68
|
+
|
|
69
|
+
Usage with sklearn cross_val_score:
|
|
70
|
+
>>> from sklearn.model_selection import cross_val_score
|
|
71
|
+
>>> scores = cross_val_score(model, X, y, cv=cv, scoring=rank_ic_scorer)
|
|
72
|
+
"""
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Importance oracles for boruta-quant.
|
|
3
|
+
|
|
4
|
+
All oracles compute importance on VALIDATION data only (never training data).
|
|
5
|
+
|
|
6
|
+
Available Oracles:
|
|
7
|
+
- PermutationImportanceOracle: Default. OOS permutation importance.
|
|
8
|
+
- DropColumnImportanceOracle: Ablation-based importance (refit without feature).
|
|
9
|
+
- BlockPermutationImportanceOracle: Temporal-aware block permutation.
|
|
10
|
+
|
|
11
|
+
Example:
|
|
12
|
+
>>> from boruta_quant.oracle import PermutationImportanceOracle
|
|
13
|
+
>>>
|
|
14
|
+
>>> oracle = PermutationImportanceOracle(
|
|
15
|
+
... scoring="neg_mean_squared_error",
|
|
16
|
+
... n_repeats=10,
|
|
17
|
+
... random_state=42,
|
|
18
|
+
... )
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from boruta_quant.oracle.base import ImportanceOracle, validate_importance_inputs
|
|
22
|
+
from boruta_quant.oracle.block_permutation import BlockPermutationImportanceOracle
|
|
23
|
+
from boruta_quant.oracle.drop_column import DropColumnImportanceOracle
|
|
24
|
+
from boruta_quant.oracle.permutation import PermutationImportanceOracle
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"ImportanceOracle",
|
|
28
|
+
"validate_importance_inputs",
|
|
29
|
+
"PermutationImportanceOracle",
|
|
30
|
+
"DropColumnImportanceOracle",
|
|
31
|
+
"BlockPermutationImportanceOracle",
|
|
32
|
+
]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base protocol for importance oracles.
|
|
3
|
+
|
|
4
|
+
All importance oracles must implement this protocol to ensure they compute
|
|
5
|
+
importance on validation data only, never on training data.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import pandas as pd
|
|
13
|
+
from beartype import beartype
|
|
14
|
+
from sklearn.metrics import get_scorer
|
|
15
|
+
|
|
16
|
+
# Type alias for scorer callables (DRY: used by all oracles)
|
|
17
|
+
ScorerType = str | Callable[[Any, pd.DataFrame, pd.Series], float]
|
|
18
|
+
ScorerCallable = Callable[[Any, pd.DataFrame, pd.Series], float]
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ImportanceOracle",
|
|
22
|
+
"validate_importance_inputs",
|
|
23
|
+
"get_scorer_callable",
|
|
24
|
+
"ScorerType",
|
|
25
|
+
"ScorerCallable",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ImportanceOracle(Protocol):
|
|
30
|
+
"""
|
|
31
|
+
Protocol for pluggable importance computation.
|
|
32
|
+
|
|
33
|
+
All implementations MUST compute importance on validation data only.
|
|
34
|
+
This is the core contract that ensures temporal integrity in feature selection.
|
|
35
|
+
|
|
36
|
+
The oracle is responsible for:
|
|
37
|
+
1. Fitting the model on training data (X_train, y_train)
|
|
38
|
+
2. Computing feature importance on validation data (X_val, y_val)
|
|
39
|
+
3. Returning importance scores for all features
|
|
40
|
+
|
|
41
|
+
CRITICAL: Importance MUST be computed on X_val, y_val only.
|
|
42
|
+
Computing importance on training data causes look-ahead bias.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
@beartype
|
|
46
|
+
def compute_importance(
|
|
47
|
+
self,
|
|
48
|
+
model: Any,
|
|
49
|
+
X_train: pd.DataFrame,
|
|
50
|
+
y_train: pd.Series,
|
|
51
|
+
X_val: pd.DataFrame,
|
|
52
|
+
y_val: pd.Series,
|
|
53
|
+
feature_names: list[str],
|
|
54
|
+
) -> dict[str, float]:
|
|
55
|
+
"""
|
|
56
|
+
Compute feature importance on VALIDATION data only.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
model: Unfitted model instance (will be cloned and fitted internally).
|
|
60
|
+
X_train: Training features (for fitting only).
|
|
61
|
+
y_train: Training target (for fitting only).
|
|
62
|
+
X_val: Validation features (importance computed HERE).
|
|
63
|
+
y_val: Validation target (importance computed HERE).
|
|
64
|
+
feature_names: List of feature names in X_train/X_val.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Dict mapping feature name to importance score.
|
|
68
|
+
Higher values indicate more important features.
|
|
69
|
+
|
|
70
|
+
Raises:
|
|
71
|
+
AssertionError: If validation data is empty or has wrong shape.
|
|
72
|
+
"""
|
|
73
|
+
...
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@beartype
|
|
77
|
+
def validate_importance_inputs(
|
|
78
|
+
X_train: pd.DataFrame,
|
|
79
|
+
y_train: pd.Series,
|
|
80
|
+
X_val: pd.DataFrame,
|
|
81
|
+
y_val: pd.Series,
|
|
82
|
+
feature_names: list[str],
|
|
83
|
+
) -> None:
|
|
84
|
+
"""
|
|
85
|
+
Validate inputs to importance computation.
|
|
86
|
+
|
|
87
|
+
This is a helper function that all oracle implementations should call
|
|
88
|
+
at the start of compute_importance().
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
AssertionError: If any validation fails.
|
|
92
|
+
"""
|
|
93
|
+
assert len(X_train) > 0, "X_train is empty"
|
|
94
|
+
assert len(X_val) > 0, "X_val is empty - cannot compute OOS importance"
|
|
95
|
+
assert len(y_train) == len(X_train), (
|
|
96
|
+
f"y_train length mismatch: {len(y_train)} vs {len(X_train)}"
|
|
97
|
+
)
|
|
98
|
+
assert len(y_val) == len(X_val), f"y_val length mismatch: {len(y_val)} vs {len(X_val)}"
|
|
99
|
+
assert list(X_train.columns) == feature_names, "X_train columns don't match feature_names"
|
|
100
|
+
assert list(X_val.columns) == feature_names, "X_val columns don't match feature_names"
|
|
101
|
+
assert not np.any(np.isnan(X_train.values)), "X_train contains NaN values"
|
|
102
|
+
assert not np.any(np.isnan(X_val.values)), "X_val contains NaN values"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@beartype
|
|
106
|
+
def get_scorer_callable(
|
|
107
|
+
scoring: ScorerType,
|
|
108
|
+
) -> ScorerCallable:
|
|
109
|
+
"""
|
|
110
|
+
Convert scoring parameter to callable scorer function.
|
|
111
|
+
|
|
112
|
+
DRY: Shared by all importance oracles that need custom scoring.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
scoring: Sklearn scorer string (e.g., "r2", "neg_mean_squared_error")
|
|
116
|
+
or callable(estimator, X, y) -> float.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Callable that takes (estimator, X, y) and returns score.
|
|
120
|
+
"""
|
|
121
|
+
if callable(scoring):
|
|
122
|
+
return scoring
|
|
123
|
+
scorer = get_scorer(scoring)
|
|
124
|
+
return lambda est, X, y: scorer(est, X, y)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Block Permutation Importance Oracle - Temporal-aware permutation importance.
|
|
3
|
+
|
|
4
|
+
Unlike standard permutation importance which shuffles individual rows,
|
|
5
|
+
this oracle shuffles contiguous blocks to preserve within-block temporal structure.
|
|
6
|
+
This is more appropriate for time-series data.
|
|
7
|
+
|
|
8
|
+
CRITICAL: Importance is ALWAYS computed on validation data, NEVER on training.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import pandas as pd
|
|
17
|
+
from beartype import beartype
|
|
18
|
+
from sklearn.base import clone
|
|
19
|
+
|
|
20
|
+
from .base import ScorerType, get_scorer_callable, validate_importance_inputs
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@beartype
|
|
24
|
+
class BlockPermutationImportanceOracle:
|
|
25
|
+
"""
|
|
26
|
+
Block permutation importance: shuffle temporal blocks, not individual rows.
|
|
27
|
+
|
|
28
|
+
For time-series data, shuffling individual rows destroys temporal patterns
|
|
29
|
+
within blocks. Block permutation preserves within-block structure while
|
|
30
|
+
breaking cross-block dependencies.
|
|
31
|
+
|
|
32
|
+
Importance = baseline_score - mean(permuted_scores)
|
|
33
|
+
Higher values indicate more important features.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
scoring: ScorerType,
|
|
39
|
+
block_size: int,
|
|
40
|
+
n_repeats: int = 10,
|
|
41
|
+
random_state: int | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Initialize BlockPermutationImportanceOracle.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
scoring: Sklearn scorer string or callable(estimator, X, y) -> float.
|
|
48
|
+
block_size: Number of consecutive rows per block.
|
|
49
|
+
n_repeats: Number of permutation repeats (default: 10).
|
|
50
|
+
random_state: Seed for reproducibility (default: None).
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
AssertionError: If scoring is None, block_size <= 0, or n_repeats <= 0.
|
|
54
|
+
"""
|
|
55
|
+
assert scoring is not None, "scoring is required"
|
|
56
|
+
assert block_size > 0, f"block_size must be > 0, got {block_size}"
|
|
57
|
+
assert n_repeats > 0, f"n_repeats must be > 0, got {n_repeats}"
|
|
58
|
+
|
|
59
|
+
self.scoring = scoring
|
|
60
|
+
self.block_size = block_size
|
|
61
|
+
self.n_repeats = n_repeats
|
|
62
|
+
self.random_state = random_state
|
|
63
|
+
|
|
64
|
+
def compute_importance(
|
|
65
|
+
self,
|
|
66
|
+
model: Any,
|
|
67
|
+
X_train: pd.DataFrame,
|
|
68
|
+
y_train: pd.Series,
|
|
69
|
+
X_val: pd.DataFrame,
|
|
70
|
+
y_val: pd.Series,
|
|
71
|
+
feature_names: list[str],
|
|
72
|
+
) -> dict[str, float]:
|
|
73
|
+
"""
|
|
74
|
+
Compute block permutation importance on VALIDATION data only.
|
|
75
|
+
|
|
76
|
+
For each feature:
|
|
77
|
+
1. Divide validation data into blocks
|
|
78
|
+
2. Shuffle the order of blocks (preserving within-block order)
|
|
79
|
+
3. Score on shuffled data
|
|
80
|
+
4. Repeat n_repeats times
|
|
81
|
+
5. Importance = baseline_score - mean(permuted_scores)
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
model: Unfitted model instance (will be cloned and fitted once).
|
|
85
|
+
X_train: Training features (for fitting only).
|
|
86
|
+
y_train: Training target (for fitting only).
|
|
87
|
+
X_val: Validation features (importance computed HERE).
|
|
88
|
+
y_val: Validation target (importance computed HERE).
|
|
89
|
+
feature_names: List of feature names in X_train/X_val.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Dict mapping feature name to importance score.
|
|
93
|
+
"""
|
|
94
|
+
validate_importance_inputs(X_train, y_train, X_val, y_val, feature_names)
|
|
95
|
+
|
|
96
|
+
n_val = len(X_val)
|
|
97
|
+
assert self.block_size <= n_val, (
|
|
98
|
+
f"block_size ({self.block_size}) > validation set size ({n_val})"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
rng = np.random.default_rng(self.random_state)
|
|
102
|
+
# DRY: Use shared scorer utility
|
|
103
|
+
scorer = get_scorer_callable(self.scoring)
|
|
104
|
+
|
|
105
|
+
# Fit model once on training data
|
|
106
|
+
fitted_model = clone(model)
|
|
107
|
+
fitted_model.fit(X_train, y_train)
|
|
108
|
+
baseline_score = scorer(fitted_model, X_val, y_val)
|
|
109
|
+
|
|
110
|
+
block_indices = self._compute_block_indices(n_val)
|
|
111
|
+
importances: dict[str, float] = {}
|
|
112
|
+
|
|
113
|
+
for feature in feature_names:
|
|
114
|
+
permuted_scores: list[float] = []
|
|
115
|
+
|
|
116
|
+
for _ in range(self.n_repeats):
|
|
117
|
+
X_permuted = X_val.copy()
|
|
118
|
+
X_permuted[feature] = self._permute_blocks(
|
|
119
|
+
X_val[feature].values, block_indices, rng
|
|
120
|
+
)
|
|
121
|
+
score = scorer(fitted_model, X_permuted, y_val)
|
|
122
|
+
permuted_scores.append(score)
|
|
123
|
+
|
|
124
|
+
importances[feature] = baseline_score - float(np.mean(permuted_scores))
|
|
125
|
+
|
|
126
|
+
return importances
|
|
127
|
+
|
|
128
|
+
def _compute_block_indices(self, n_samples: int) -> list[tuple[int, int]]:
|
|
129
|
+
"""
|
|
130
|
+
Compute block start/end indices.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
n_samples: Total number of samples.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
List of (start, end) tuples for each block.
|
|
137
|
+
"""
|
|
138
|
+
blocks: list[tuple[int, int]] = []
|
|
139
|
+
start = 0
|
|
140
|
+
while start < n_samples:
|
|
141
|
+
end = min(start + self.block_size, n_samples)
|
|
142
|
+
blocks.append((start, end))
|
|
143
|
+
start = end
|
|
144
|
+
return blocks
|
|
145
|
+
|
|
146
|
+
def _permute_blocks(
|
|
147
|
+
self,
|
|
148
|
+
values: np.ndarray,
|
|
149
|
+
block_indices: list[tuple[int, int]],
|
|
150
|
+
rng: np.random.Generator,
|
|
151
|
+
) -> np.ndarray:
|
|
152
|
+
"""
|
|
153
|
+
Permute values by shuffling blocks.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
values: 1D array of feature values.
|
|
157
|
+
block_indices: List of (start, end) tuples.
|
|
158
|
+
rng: Numpy random generator.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Permuted values with blocks shuffled but within-block order preserved.
|
|
162
|
+
"""
|
|
163
|
+
blocks = [values[start:end].copy() for start, end in block_indices]
|
|
164
|
+
rng.shuffle(blocks)
|
|
165
|
+
return np.concatenate(blocks)
|