Feature importance for financial data
Three methods, three different lies, and the worst of them is the default. Advances in
Financial Machine Learning (Lopez de Prado 2018), chapter 8. feature_importances_ is one
attribute access away and it is in-sample, biased toward high-cardinality columns, and split
between features that substitute for each other. Permutation importance fixes the first two and
not the third. And every one of them is meaningless if the fold was shuffled.
Every number below is printed by scripts/feature_importance.py (numpy, seed 0, 23 s,
scikit-learn optional — the script carries its own CART and bagged forest, so the claims hold
on a bare install and are checked against sklearn when it is present).
1. The design, so "wrong" is measurable
n = 1,500. y = 1.00 * x0 + 0.60 * x2 + e, where e is a rolling mean of 25 shocks — each row's
target is fully explained by its own features, and neighbouring rows share most of their
residual. That is what an overlapping label horizon does, and it is what makes section 5 happen.
| feature |
distinct values |
corr with x0 |
corr with y |
truth |
| x0 |
1,500 |
1.000 |
0.425 |
informative, coefficient 1.00 |
| x1 |
1,500 |
0.995 |
0.426 |
a noisy copy of x0 — no independent information |
| x2 |
1,500 |
0.005 |
0.293 |
informative, coefficient 0.60 |
| x3 |
1,500 |
0.032 |
0.016 |
irrelevant, continuous |
| x4 |
2 |
0.007 |
0.011 |
irrelevant, binary |
| x5 |
5 |
0.018 |
0.024 |
irrelevant, five levels |
| x6 |
1,500 |
0.017 |
-0.092 |
irrelevant, a random walk |
2. ✅ What sklearn's feature_importances_ actually computes
The per-node formula lives in the compiled _tree.pyx, which the wheel does not ship — so it is
verified numerically here, by rebuilding it from the public tree_ arrays:
imp[feature[i]] += wn[i]*impurity[i] - wn[L]*impurity[L] - wn[R]*impurity[R] # internal nodes
imp /= wn[root]; imp /= imp.sum()
✅ Against sklearn 1.4.2 on the same data: max abs diff 0.00e+00 for a single
DecisionTreeRegressor, and 0.00e+00 for a RandomForestRegressor when the per-tree
importances are aggregated as _forest.py does — ✅ source-verified there:
np.mean(all_importances, axis=0) over trees with node_count > 1, then / np.sum(...).
✅ Source-verified in sklearn/tree/_classes.py and sklearn/ensemble/_forest.py, the docstring
of feature_importances_ already says it: "impurity-based feature importances can be misleading
for high cardinality features (many unique values)", and points at
sklearn.inspection.permutation_importance. The warning is in the docstring nobody opens, not
in a runtime warning.
3. 🚨 MDI: three failures at once
✅ Measured, 25 trees, depth 5, max_features 3 of 7, min_samples_leaf 20:
| feature |
MDI |
rank |
truth |
| x0 informative |
0.3023 |
2 |
informative |
| x1 collinear copy |
0.3054 |
1 |
no information |
| x2 informative |
0.2396 |
3 |
informative |
| x3 noise, high card |
0.0261 |
5 |
irrelevant |
| x4 noise, binary |
0.0063 |
7 |
irrelevant |
| x5 noise, 5 levels |
0.0075 |
6 |
irrelevant |
| x6 noise, random walk |
0.1127 |
4 |
irrelevant |
- 🚨 The copy outranks the original. x1 is x0 plus a little noise and MDI puts it first. The
pair takes 60.8 % of the total between them — the group importance is right, the split
inside it is arbitrary, and it changes with the seed.
- 🚨 Cardinality bias, measured: x3 scores 4.1x x4, and both are pure noise. The only
difference between them is that one has 1,500 distinct values to try thresholds on and the
other has 2.
- 🚨 x6 ranks 4th of 7 with 11.3 % of the total. It is a random walk. MDI is computed on the
in-sample fit, and a slowly-varying column is a usable proxy for "which part of the sample
this row is in" whenever the residual is autocorrelated — which, with overlapping labels, it
always is.
✅ sklearn's RandomForestRegressor on the same data with the same hyper-parameters:
0.3604 0.2718 0.2336 0.0234 0.0022 0.0094 0.0992 — same shape, same conclusions.
4. MDA and SFI: what each one is actually asking
MDA = the drop in out-of-sample R² when one column is permuted. SFI = the out-of-sample R² of
a model fitted on that column alone. ✅ Both on an embargoed forward split (3 folds, 30 rows
embargoed either side, 3 permutations per feature):
| feature |
MDA |
rank |
SFI |
rank |
| x0 informative |
0.1165 |
2 |
0.1037 |
1 |
| x1 collinear copy |
0.0585 |
3 |
0.0913 |
2 |
| x2 informative |
0.1269 |
1 |
0.0203 |
3 |
| x3 noise, high card |
-0.0018 |
7 |
-0.0806 |
6 |
| x4 noise, binary |
-0.0011 |
5 |
-0.0543 |
4 |
| x5 noise, 5 levels |
0.0016 |
4 |
-0.0545 |
5 |
| x6 noise, random walk |
-0.0015 |
6 |
-0.4324 |
7 |
- ✅ MDA has no cardinality bias: all four irrelevant columns land within ±0.002 of zero, and
x3 no longer beats x4.
- 🚨 MDA under-states a collinear pair. x0 0.1165 and x1 0.0585 sum to 0.175 — about half
of the group's true importance (section 5: 0.3367). Permuting x0 leaves x1 to carry the signal,
and vice versa. Two features that look unimportant can be jointly essential.
- MDA and SFI disagree on purpose. MDA ranks x2 above x0 (0.1269 vs 0.1165); SFI ranks x0
above x2 (0.1037 vs 0.0203). MDA asks what does this add given the others; SFI asks what is
it worth alone. Both answers are correct for their own question, and SFI is the only method
here that says x1 is worth anything at all.
- ⚠️ SFI's blind spot is the mirror image: a feature that is useless alone and essential in
combination scores zero. There is no such feature in this design; there are plenty in real ones.
5. ✅ Clustered MDA: permute the group
Cluster the features first (single linkage on |correlation| ≥ 0.8 — ✅ it recovers exactly
[[x0, x1], [x2], [x3], [x4], [x5], [x6]]), then permute each cluster jointly:
| cluster |
clustered MDA |
rank |
| x0+x1 |
0.3367 |
1 |
| x2 |
0.1223 |
2 |
| x5 |
0.0009 |
3 |
| x3 |
-0.0004 |
4 |
| x4 |
-0.0010 |
5 |
| x6 |
-0.0018 |
6 |
✅ The pair scores 0.3367 against 0.1165 for its best single member — a factor of 2.9 — and
x2, which is in a cluster of its own, is unchanged (0.1223 against 0.1269, inside the noise).
This is the answer to substitution: stop asking which of two interchangeable features matters
and ask whether the group does.
6. 🚨 The split matters more than the metric
Same MDA, same model, one difference — a shuffled k-fold instead of an embargoed forward split:
| feature |
MDA, shuffled k-fold |
MDA, embargoed forward |
| x0 informative |
0.0758 |
0.1165 |
| x1 collinear copy |
0.0937 |
0.0585 |
| x2 informative |
0.1228 |
0.1269 |
| x3 noise, high card |
0.0007 |
-0.0018 |
| x4 noise, binary |
-0.0002 |
-0.0011 |
| x5 noise, 5 levels |
-0.0023 |
0.0016 |
| x6 noise, random walk |
+0.0521 |
-0.0015 |
🚨 The shuffled fold makes a random walk the third most important feature, at 41 % of x0's
score. The residual is a 25-row rolling mean, so a shuffled test row's neighbours are in the
training set, and a slowly-varying column identifies which neighbours. ✅ The overall out-of-sample
R² tells the same story: 0.2659 shuffled against 0.1626 embargoed — the shuffled number is 64 %
too high, and it is the number that would have gone in the report.
The embargoed forward split in this script is the minimum honest version. The real fix needs
each label's actual resolution time and belongs to
../../../fin-libraries/skills/lib-purgedcv/SKILL.md. Do not re-implement it.
7. Traps
- 🚨 Reporting
feature_importances_. Section 3. In-sample, cardinality-biased,
substitution-split, and it ranks a random walk fourth.
- 🚨 Dropping features by MDA. Section 4: a collinear pair reports half its joint value.
Cluster first (section 5), then drop clusters.
- 🚨 Any importance from a shuffled split. Section 6. With overlapping labels this is not a
small bias; it invents importance for anything that varies slowly.
- 🚨 One-hot encoding changes MDI. Splitting a 50-level categorical into 50 binary columns
moves it from the high-cardinality end of the bias to the low-cardinality end, and the total
importance the group receives changes with the encoding, not with the information. Compare only
within one encoding.
- 🚨 Importance is not stability. Rerun with a different seed and the split inside a collinear
cluster moves. Report importances with an error bar across seeds and folds, or report clusters.
- 🚨 A feature search is a trial ledger. Ranking 200 features, keeping the top 20 and
reporting the backtest of those 20 is a best-of-200 selection.
../../../fin-core/skills/backtest-validation/scripts/trial_ledger.py.
- 🚨 Feature selection must happen inside the fold. Choosing features on the whole sample and
then cross-validating the chosen set leaks the test folds into the selection. This is the same
error as section 6 in a different place.
- ⚠️ MDA needs enough test rows to be stable. Each fold here is 500 rows and 3 permutations;
the irrelevant features still scatter to ±0.002. Report the spread across folds, not just the
mean.
- ⚠️ Weights change importance too. With overlapping labels the samples are not equally
informative; an unweighted MDA over-counts crowded stretches
(
../sample-weights-and-uniqueness/SKILL.md).
8. Scripts
scripts/feature_importance.py — a CART regression tree with exact variance-reduction splits
and MDI accumulation, a bagged forest, MDA (single and clustered), SFI, single-linkage
correlation clustering, shuffled and embargoed splits, and the numerical reconstruction of
sklearn's feature_importances_ from tree_. numpy only; sklearn is imported inside one
function. Seed 0, 23 s.
9. Where this sits
../../../fin-libraries/skills/lib-purgedcv/SKILL.md owns purged and embargoed cross-validation —
section 6 is the reason it matters here, and the fix lives there.
../sample-weights-and-uniqueness/SKILL.md owns the weights that go with overlapping labels.
../triple-barrier-labeling/SKILL.md produces the labels whose overlap causes section 6.
../../../fin-core/skills/signal-construction/SKILL.md owns building the features causally in the
first place. ../../../fin-models/skills/factor-models/SKILL.md owns attribution of returns to
factors, which is a different question from attribution of a model to its inputs.
../../../fin-core/skills/backtest-validation/SKILL.md owns the trial count of a feature search.
1---2name: feature-importance-financial3description: Rank features without believing MDI - it is in-sample, it favours columns with many distinct values, and it splits credit between substitutable features; MDA under-states collinear pairs and leaks outright on a shuffled k-fold. TRIGGER - feature_importances_, feature importance, MDI, mean decrease impurity, Gini importance, MDA, mean decrease accuracy, permutation importance, permutation_importance, single feature importance, SFI, clustered feature importance, "which features matter", "my random forest says this random column is important", "importance changes every run", correlated features importance, substitution effect, Lopez de Prado chapter 8, AFML feature importance. SKIP for purged and embargoed cross-validation itself (lib-purgedcv), for the overlapping-label weights (sample-weights-and-uniqueness), for feature construction and causality (signal-construction), for factor-return attribution (factor-models), and for the trials a feature search spends (backtest-validation).4license: MIT5---67# Feature importance for financial data89**Three methods, three different lies, and the worst of them is the default.** Advances in10Financial Machine Learning (Lopez de Prado 2018), chapter 8. `feature_importances_` is one11attribute access away and it is in-sample, biased toward high-cardinality columns, and split12between features that substitute for each other. Permutation importance fixes the first two and13not the third. And every one of them is meaningless if the fold was shuffled.1415Every number below is printed by `scripts/feature_importance.py` (numpy, seed 0, **23 s**,16`scikit-learn` optional — the script carries its own CART and bagged forest, so the claims hold17on a bare install and are *checked against* sklearn when it is present).1819## 1. The design, so "wrong" is measurable2021n = 1,500. `y = 1.00 * x0 + 0.60 * x2 + e`, where `e` is a rolling mean of 25 shocks — each row's22target is fully explained by its own features, and neighbouring rows share most of their23*residual*. That is what an overlapping label horizon does, and it is what makes section 5 happen.2425| feature | distinct values | corr with x0 | corr with y | truth |26|---|---|---|---|---|27| **x0** | 1,500 | 1.000 | 0.425 | informative, coefficient 1.00 |28| **x1** | 1,500 | **0.995** | 0.426 | a noisy copy of x0 — **no independent information** |29| **x2** | 1,500 | 0.005 | 0.293 | informative, coefficient 0.60 |30| **x3** | **1,500** | 0.032 | 0.016 | **irrelevant**, continuous |31| **x4** | **2** | 0.007 | 0.011 | **irrelevant**, binary |32| **x5** | **5** | 0.018 | 0.024 | **irrelevant**, five levels |33| **x6** | 1,500 | 0.017 | -0.092 | **irrelevant**, a random walk |3435## 2. ✅ What sklearn's `feature_importances_` actually computes3637The per-node formula lives in the compiled `_tree.pyx`, which the wheel does not ship — so it is38verified **numerically** here, by rebuilding it from the public `tree_` arrays:3940```41imp[feature[i]] += wn[i]*impurity[i] - wn[L]*impurity[L] - wn[R]*impurity[R] # internal nodes42imp /= wn[root]; imp /= imp.sum()43```4445✅ Against sklearn 1.4.2 on the same data: **max abs diff 0.00e+00** for a single46`DecisionTreeRegressor`, and **0.00e+00** for a `RandomForestRegressor` when the per-tree47importances are aggregated as `_forest.py` does — ✅ source-verified there:48`np.mean(all_importances, axis=0)` over trees with `node_count > 1`, then `/ np.sum(...)`.4950✅ Source-verified in `sklearn/tree/_classes.py` and `sklearn/ensemble/_forest.py`, the docstring51of `feature_importances_` already says it: "impurity-based feature importances can be misleading52for high cardinality features (many unique values)", and points at53`sklearn.inspection.permutation_importance`. **The warning is in the docstring nobody opens, not54in a runtime warning.**5556## 3. 🚨 MDI: three failures at once5758✅ Measured, 25 trees, depth 5, `max_features` 3 of 7, `min_samples_leaf` 20:5960| feature | MDI | rank | truth |61|---|---|---|---|62| x0 informative | 0.3023 | 2 | informative |63| **x1 collinear copy** | **0.3054** | **1** | **no information** |64| x2 informative | 0.2396 | 3 | informative |65| x3 noise, high card | **0.0261** | 5 | irrelevant |66| x4 noise, binary | **0.0063** | 7 | irrelevant |67| x5 noise, 5 levels | 0.0075 | 6 | irrelevant |68| **x6 noise, random walk** | **0.1127** | **4** | **irrelevant** |6970- 🚨 **The copy outranks the original.** x1 is x0 plus a little noise and MDI puts it first. The71 pair takes **60.8 %** of the total between them — the group importance is right, the split72 inside it is arbitrary, and it changes with the seed.73- 🚨 **Cardinality bias, measured: x3 scores 4.1x x4**, and both are pure noise. The only74 difference between them is that one has 1,500 distinct values to try thresholds on and the75 other has 2.76- 🚨 **x6 ranks 4th of 7 with 11.3 % of the total.** It is a random walk. MDI is computed on the77 *in-sample* fit, and a slowly-varying column is a usable proxy for "which part of the sample78 this row is in" whenever the residual is autocorrelated — which, with overlapping labels, it79 always is.8081✅ sklearn's `RandomForestRegressor` on the same data with the same hyper-parameters:82`0.3604 0.2718 0.2336 0.0234 0.0022 0.0094 0.0992` — same shape, same conclusions.8384## 4. MDA and SFI: what each one is actually asking8586MDA = the drop in **out-of-sample** R² when one column is permuted. SFI = the out-of-sample R² of87a model fitted on that column **alone**. ✅ Both on an embargoed forward split (3 folds, 30 rows88embargoed either side, 3 permutations per feature):8990| feature | MDA | rank | SFI | rank |91|---|---|---|---|---|92| x0 informative | **0.1165** | 2 | **0.1037** | 1 |93| x1 collinear copy | 0.0585 | 3 | 0.0913 | 2 |94| x2 informative | **0.1269** | 1 | 0.0203 | 3 |95| x3 noise, high card | **-0.0018** | 7 | -0.0806 | 6 |96| x4 noise, binary | -0.0011 | 5 | -0.0543 | 4 |97| x5 noise, 5 levels | 0.0016 | 4 | -0.0545 | 5 |98| x6 noise, random walk | **-0.0015** | 6 | -0.4324 | 7 |99100- ✅ **MDA has no cardinality bias**: all four irrelevant columns land within ±0.002 of zero, and101 x3 no longer beats x4.102- 🚨 **MDA under-states a collinear pair.** x0 0.1165 and x1 0.0585 sum to 0.175 — about **half**103 of the group's true importance (section 5: 0.3367). Permuting x0 leaves x1 to carry the signal,104 and vice versa. **Two features that look unimportant can be jointly essential.**105- **MDA and SFI disagree on purpose.** MDA ranks x2 above x0 (0.1269 vs 0.1165); SFI ranks x0106 above x2 (0.1037 vs 0.0203). MDA asks *what does this add given the others*; SFI asks *what is107 it worth alone*. Both answers are correct for their own question, and SFI is the only method108 here that says x1 is worth anything at all.109- ⚠️ SFI's blind spot is the mirror image: a feature that is useless alone and essential in110 combination scores zero. There is no such feature in this design; there are plenty in real ones.111112## 5. ✅ Clustered MDA: permute the group113114Cluster the features first (single linkage on |correlation| ≥ 0.8 — ✅ it recovers exactly115`[[x0, x1], [x2], [x3], [x4], [x5], [x6]]`), then permute each cluster **jointly**:116117| cluster | clustered MDA | rank |118|---|---|---|119| **x0+x1** | **0.3367** | 1 |120| x2 | 0.1223 | 2 |121| x5 | 0.0009 | 3 |122| x3 | -0.0004 | 4 |123| x4 | -0.0010 | 5 |124| x6 | -0.0018 | 6 |125126✅ The pair scores **0.3367 against 0.1165 for its best single member — a factor of 2.9** — and127x2, which is in a cluster of its own, is unchanged (0.1223 against 0.1269, inside the noise).128This is the answer to substitution: **stop asking which of two interchangeable features matters129and ask whether the group does.**130131## 6. 🚨 The split matters more than the metric132133Same MDA, same model, one difference — a shuffled k-fold instead of an embargoed forward split:134135| feature | MDA, **shuffled** k-fold | MDA, **embargoed forward** |136|---|---|---|137| x0 informative | 0.0758 | 0.1165 |138| x1 collinear copy | 0.0937 | 0.0585 |139| x2 informative | 0.1228 | 0.1269 |140| x3 noise, high card | 0.0007 | -0.0018 |141| x4 noise, binary | -0.0002 | -0.0011 |142| x5 noise, 5 levels | -0.0023 | 0.0016 |143| **x6 noise, random walk** | **+0.0521** | **-0.0015** |144145🚨 **The shuffled fold makes a random walk the third most important feature**, at 41 % of x0's146score. The residual is a 25-row rolling mean, so a shuffled test row's neighbours are in the147training set, and a slowly-varying column identifies which neighbours. ✅ The overall out-of-sample148R² tells the same story: **0.2659 shuffled against 0.1626 embargoed** — the shuffled number is 64 %149too high, and it is the number that would have gone in the report.150151**The embargoed forward split in this script is the *minimum* honest version.** The real fix needs152each label's actual resolution time and belongs to153`../../../fin-libraries/skills/lib-purgedcv/SKILL.md`. Do not re-implement it.154155## 7. Traps156157- 🚨 **Reporting `feature_importances_`.** Section 3. In-sample, cardinality-biased,158 substitution-split, and it ranks a random walk fourth.159- 🚨 **Dropping features by MDA.** Section 4: a collinear pair reports half its joint value.160 Cluster first (section 5), then drop clusters.161- 🚨 **Any importance from a shuffled split.** Section 6. With overlapping labels this is not a162 small bias; it invents importance for anything that varies slowly.163- 🚨 **One-hot encoding changes MDI.** Splitting a 50-level categorical into 50 binary columns164 moves it from the high-cardinality end of the bias to the low-cardinality end, and the total165 importance the group receives changes with the encoding, not with the information. Compare only166 within one encoding.167- 🚨 **Importance is not stability.** Rerun with a different seed and the split inside a collinear168 cluster moves. Report importances with an error bar across seeds and folds, or report clusters.169- 🚨 **A feature search is a trial ledger.** Ranking 200 features, keeping the top 20 and170 reporting the backtest of those 20 is a best-of-200 selection.171 `../../../fin-core/skills/backtest-validation/scripts/trial_ledger.py`.172- 🚨 **Feature selection must happen inside the fold.** Choosing features on the whole sample and173 then cross-validating the chosen set leaks the test folds into the selection. This is the same174 error as section 6 in a different place.175- ⚠️ **MDA needs enough test rows to be stable.** Each fold here is 500 rows and 3 permutations;176 the irrelevant features still scatter to ±0.002. Report the spread across folds, not just the177 mean.178- ⚠️ **Weights change importance too.** With overlapping labels the samples are not equally179 informative; an unweighted MDA over-counts crowded stretches180 (`../sample-weights-and-uniqueness/SKILL.md`).181182## 8. Scripts183184- `scripts/feature_importance.py` — a CART regression tree with exact variance-reduction splits185 and MDI accumulation, a bagged forest, MDA (single and clustered), SFI, single-linkage186 correlation clustering, shuffled and embargoed splits, and the numerical reconstruction of187 sklearn's `feature_importances_` from `tree_`. numpy only; `sklearn` is imported inside one188 function. Seed 0, **23 s**.189190## 9. Where this sits191192`../../../fin-libraries/skills/lib-purgedcv/SKILL.md` owns purged and embargoed cross-validation —193section 6 is the reason it matters here, and the fix lives there.194`../sample-weights-and-uniqueness/SKILL.md` owns the weights that go with overlapping labels.195`../triple-barrier-labeling/SKILL.md` produces the labels whose overlap causes section 6.196`../../../fin-core/skills/signal-construction/SKILL.md` owns building the features causally in the197first place. `../../../fin-models/skills/factor-models/SKILL.md` owns attribution of *returns* to198factors, which is a different question from attribution of a *model* to its inputs.199`../../../fin-core/skills/backtest-validation/SKILL.md` owns the trial count of a feature search.