File size: 10,914 Bytes
d53dc44
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
"""

ml/feature_selection.py — SHAP-based feature selection & importance analysis.



Capabilities:

  1. SHAP value computation for tree-based models (XGB/LGB)

  2. Drop redundant and noisy features

  3. Regime-specific feature importance analysis

  4. Stable feature set extraction across walk-forward windows

"""
from __future__ import annotations

import logging
from dataclasses import dataclass, field

import numpy as np
import pandas as pd

logger = logging.getLogger("trading_system.ml.feature_selection")


@dataclass
class FeatureImportance:
    """Results of feature importance analysis."""
    feature_names: list[str]
    mean_abs_shap: np.ndarray            # mean |SHAP| per feature
    std_shap: np.ndarray                 # std of SHAP per feature
    regime_importance: dict[str, np.ndarray] = field(default_factory=dict)
    selected_features: list[str] = field(default_factory=list)
    dropped_features: list[str] = field(default_factory=list)


class SHAPFeatureSelector:
    """SHAP-based feature selection with stability analysis.



    Strategy:

      - Compute SHAP values for fitted tree model

      - Rank features by mean |SHAP|

      - Drop features with negligible contribution

      - Remove highly correlated redundant features

      - Optionally analyze per-regime importance

    """

    def __init__(

        self,

        min_importance_pct: float = 0.5,    # drop features < 0.5% of max importance

        max_correlation: float = 0.90,       # drop correlated pairs

        min_features: int = 10,              # keep at least this many

        max_features: int = 50,              # cap features

        stability_threshold: float = 0.3,    # feature must appear in 30% of WF windows

    ):
        self.min_importance_pct = min_importance_pct
        self.max_correlation = max_correlation
        self.min_features = min_features
        self.max_features = max_features
        self.stability_threshold = stability_threshold
        self._window_selections: list[list[str]] = []

    def compute_importance(

        self,

        model,

        X: pd.DataFrame,

        n_samples: int = 500,

    ) -> FeatureImportance:
        """Compute SHAP-based feature importance.



        Args:

            model: Fitted tree model (XGB/LGB/sklearn).

            X: Feature DataFrame.

            n_samples: Max samples for SHAP computation (speed).



        Returns:

            FeatureImportance with rankings.

        """
        import shap

        feature_names = list(X.columns)

        # Subsample for speed
        if len(X) > n_samples:
            X_sample = X.sample(n=n_samples, random_state=42)
        else:
            X_sample = X

        try:
            explainer = shap.TreeExplainer(model)
            shap_values = explainer.shap_values(X_sample)
        except Exception:
            # Fallback to permutation explainer
            try:
                explainer = shap.Explainer(model, X_sample)
                shap_obj = explainer(X_sample)
                shap_values = shap_obj.values
            except Exception as e:
                logger.warning("SHAP computation failed: %s. Using model importances.", e)
                return self._fallback_importance(model, feature_names)

        # Handle multi-class SHAP (list of arrays)
        if isinstance(shap_values, list):
            # Average across classes
            stacked = np.stack([np.abs(sv) for sv in shap_values], axis=0)
            abs_shap = stacked.mean(axis=0)  # (n_samples, n_features)
        elif shap_values.ndim == 3:
            abs_shap = np.abs(shap_values).mean(axis=2)
        else:
            abs_shap = np.abs(shap_values)

        mean_abs = abs_shap.mean(axis=0)
        std_vals = abs_shap.std(axis=0)

        return FeatureImportance(
            feature_names=feature_names,
            mean_abs_shap=mean_abs,
            std_shap=std_vals,
        )

    def _fallback_importance(self, model, feature_names) -> FeatureImportance:
        """Use model's built-in feature importance if SHAP fails."""
        try:
            imp = model.feature_importances_
        except AttributeError:
            imp = np.ones(len(feature_names))

        return FeatureImportance(
            feature_names=feature_names,
            mean_abs_shap=imp / imp.sum() if imp.sum() > 0 else imp,
            std_shap=np.zeros(len(feature_names)),
        )

    def select_features(

        self,

        importance: FeatureImportance,

        X: pd.DataFrame,

    ) -> FeatureImportance:
        """Select features based on importance and correlation.



        Returns updated FeatureImportance with selected/dropped lists.

        """
        feat_names = importance.feature_names
        scores = importance.mean_abs_shap

        # Rank by importance
        sorted_idx = np.argsort(-scores)
        threshold = scores.max() * (self.min_importance_pct / 100.0) if scores.max() > 0 else 0

        # Keep features above threshold
        kept_idx = [i for i in sorted_idx if scores[i] >= threshold]

        # Enforce min/max
        if len(kept_idx) < self.min_features:
            kept_idx = list(sorted_idx[:self.min_features])
        if len(kept_idx) > self.max_features:
            kept_idx = kept_idx[:self.max_features]

        kept_names = [feat_names[i] for i in kept_idx]

        # Remove correlated features (keep the more important one)
        kept_names = self._remove_correlated(X, kept_names, scores, feat_names)

        if len(kept_names) < self.min_features:
            # Re-add top features if too many removed
            for i in sorted_idx:
                if feat_names[i] not in kept_names:
                    kept_names.append(feat_names[i])
                if len(kept_names) >= self.min_features:
                    break

        dropped = [f for f in feat_names if f not in kept_names]

        importance.selected_features = kept_names
        importance.dropped_features = dropped

        # Record for stability tracking
        self._window_selections.append(kept_names)

        logger.info(
            "Selected %d/%d features (dropped %d)",
            len(kept_names), len(feat_names), len(dropped),
        )
        return importance

    def _remove_correlated(

        self,

        X: pd.DataFrame,

        kept_names: list[str],

        scores: np.ndarray,

        all_names: list[str],

    ) -> list[str]:
        """Remove highly correlated features, keeping the more important one."""
        if len(kept_names) < 2:
            return kept_names

        subset = X[kept_names].copy()
        corr = subset.corr().abs()

        to_drop = set()
        for i in range(len(kept_names)):
            if kept_names[i] in to_drop:
                continue
            for j in range(i + 1, len(kept_names)):
                if kept_names[j] in to_drop:
                    continue
                if corr.iloc[i, j] > self.max_correlation:
                    # Drop the less important one
                    idx_i = all_names.index(kept_names[i])
                    idx_j = all_names.index(kept_names[j])
                    if scores[idx_i] >= scores[idx_j]:
                        to_drop.add(kept_names[j])
                    else:
                        to_drop.add(kept_names[i])

        return [f for f in kept_names if f not in to_drop]

    def get_stable_features(self) -> list[str]:
        """Get features that appear consistently across walk-forward windows."""
        if not self._window_selections:
            return []

        n_windows = len(self._window_selections)
        feature_counts: dict[str, int] = {}
        for selection in self._window_selections:
            for feat in selection:
                feature_counts[feat] = feature_counts.get(feat, 0) + 1

        threshold = max(1, int(n_windows * self.stability_threshold))
        stable = [f for f, c in feature_counts.items() if c >= threshold]

        # Sort by frequency
        stable.sort(key=lambda f: -feature_counts.get(f, 0))
        return stable

    def regime_importance(

        self,

        model,

        X: pd.DataFrame,

        regime_labels: np.ndarray,

        regime_names: dict[int, str] | None = None,

    ) -> dict[str, np.ndarray]:
        """Compute SHAP importance per regime.



        Args:

            model: Fitted tree model.

            X: Feature DataFrame.

            regime_labels: Array of regime integers per row.

            regime_names: Optional mapping int→name.



        Returns:

            Dict of regime_name → mean |SHAP| array (per feature).

        """
        import shap

        if regime_names is None:
            regime_names = {0: "SIDEWAYS", 1: "BULL", 2: "BEAR"}

        result = {}
        for r_int, r_name in regime_names.items():
            mask = regime_labels == r_int
            if mask.sum() < 50:
                continue

            X_regime = X[mask]
            if len(X_regime) > 300:
                X_regime = X_regime.sample(n=300, random_state=42)

            try:
                explainer = shap.TreeExplainer(model)
                sv = explainer.shap_values(X_regime)
                if isinstance(sv, list):
                    abs_sv = np.mean([np.abs(s) for s in sv], axis=0)
                else:
                    abs_sv = np.abs(sv)
                result[r_name] = abs_sv.mean(axis=0)
            except Exception as e:
                logger.warning("Regime SHAP for %s failed: %s", r_name, e)

        return result


def print_feature_report(importance: FeatureImportance, top_n: int = 20):
    """Print a feature importance summary."""
    names = importance.feature_names
    scores = importance.mean_abs_shap

    sorted_idx = np.argsort(-scores)

    print("\n" + "=" * 60)
    print("  SHAP FEATURE IMPORTANCE REPORT")
    print("=" * 60)

    for rank, i in enumerate(sorted_idx[:top_n], 1):
        bar = "█" * int(scores[i] / (scores.max() + 1e-9) * 30)
        selected = "✓" if names[i] in importance.selected_features else "✗"
        print(f"  {rank:2d}. [{selected}] {names[i]:<30s}  {scores[i]:.4f}  {bar}")

    if importance.selected_features:
        print(f"\n  Selected: {len(importance.selected_features)} features")
        print(f"  Dropped:  {len(importance.dropped_features)} features")

    if importance.regime_importance:
        print("\n  --- Per-Regime Top-5 ---")
        for regime, imp in importance.regime_importance.items():
            top5 = np.argsort(-imp)[:5]
            feats = ", ".join(names[i] for i in top5)
            print(f"  {regime}: {feats}")
    print("=" * 60)