Tree Feature Importance
Feature importance needs a question attached to it. A tree's built-in importance asks which inputs helped its training splits separate the targets. Permutation importance asks how much a fitted model's score changes when an input is shuffled. These can produce different rankings without either calculation being broken.
We will follow one small tree from class counts to importance scores, then shuffle its inputs. A second example shows how a noise feature can collect most of the training credit.
Start with how mixed the labels are
A classification node is pure if every row has the same label. The Gini impurity measures label mixture. If $p_k$ is the fraction of rows in class $k$, then
Four class-0 rows and four class-1 rows give $1-(4/8)^2-(4/8)^2=0.5$. A pure node gives 0. A useful split sends rows into children whose labels are less mixed. The impurity chapter explains other criteria.
Give each split credit for the rows it affects
Fit a tree to eight rows with binary inputs A and B. Its root splits on A; each child then splits on B. Click a split below to see its contribution. Counts always refer to the same eight training rows.
3 rows, all 0
Gini = 0B = 1
1 row, class 1
Gini = 0
3 rows, all 1
Gini = 0B = 1
1 row, class 0
Gini = 0
For a node $t$, let $n_t$ be the number of rows reaching it and $n_L,n_R$ the counts sent to its children. Let $N$ be the total training count. First average the children's impurities by their local row fractions. Then multiply the decrease by the fraction of all rows reaching this node:
A receives 0.125 from the root. B receives $0.1875+0.1875=0.375$ from its two splits. Dividing each by their total, 0.5, gives A: 25%, B: 75%. These are this tree's normalized impurity importances, often called mean decrease in impurity (MDI).
Those percentages divide up the training impurity reduction. They are not percentages of accuracy, probabilities of relevance, or effect directions. An unsplit tree has no reduction to divide up and reports zero importances. With sample weights, use weighted counts; forests aggregate importance across their fitted trees.
Now ask what happens when an input is disrupted
Keep the trained tree fixed and evaluate four controlled cases covering all binary input combinations. It correctly predicts class 1 when A and B differ. Shuffle one feature among rows, leaving the other feature and the labels in place. The shuffle preserves that column's values but changes which row receives each value.
| Row | A | B | Label | Prediction |
|---|---|---|---|---|
| 1 | 0 | 0 | 0 | 0 · correct |
| 2 | 0 | 1 | 1 | 1 · correct |
| 3 | 1 | 0 | 1 | 1 · correct |
| 4 | 1 | 1 | 0 | 0 · correct |
For a score where larger is better, subtract the score after shuffling from the original score. Repeat with different shuffles and average. Write $s$ for the original score and $s_j^{(r)}$ for the score after shuffle $r$ of feature $j$. With $R$ repeats:
In this four-row example, averaging over all 24 row permutations also gives 0.50 for each feature. Both inputs are necessary for this fitted rule even though their MDI scores differ. For a loss such as MSE, report shuffled loss minus original loss; scikit-learn's negative-loss scorers already handle this sign convention.
Python: reproduce the tree credits and all 24 permutations
import numpy as np
from itertools import permutations
from sklearn.tree import DecisionTreeClassifier
X = np.array([[0,0]] * 3 + [[0,1]] + [[1,0]] * 3 + [[1,1]])
y = np.array([0,0,0,1,1,1,1,0])
model = DecisionTreeClassifier(random_state=0).fit(X, y)
tree = model.tree_
credit = np.zeros(X.shape[1])
for node in range(tree.node_count):
left, right = tree.children_left[node], tree.children_right[node]
if left == right: # A leaf has no split credit.
continue
n = tree.weighted_n_node_samples
gain = (n[node] * tree.impurity[node]
- n[left] * tree.impurity[left]
- n[right] * tree.impurity[right]) / n[0]
credit[tree.feature[node]] += gain
print(credit) # [0.125, 0.375]
print(credit / credit.sum()) # [0.25, 0.75]
np.testing.assert_allclose(credit / credit.sum(), model.feature_importances_)
# Four controlled evaluation cases covering the binary input combinations.
X_eval = np.array([[0,0], [0,1], [1,0], [1,1]])
y_eval = np.array([0,1,1,0])
baseline = model.score(X_eval, y_eval) # 1.0
order = [2,0,3,1] # The one permutation shown in the figure.
for j in range(2):
changed = X_eval.copy()
changed[:, j] = X_eval[order, j]
print(j, baseline - model.score(changed, y_eval)) # 0.5 for each feature.
drops = []
for permutation in permutations(range(4)):
changed = X_eval.copy()
changed[:, j] = X_eval[list(permutation), j]
drops.append(baseline - model.score(changed, y_eval))
print("Average over all 24 permutations:", np.mean(drops)) # 0.5.
A feature can win training credit by fitting noise
A continuous feature offers many candidate thresholds. A binary feature offers only one. Searching more thresholds creates more opportunities to find an accidental training improvement, particularly in a deep tree. This is one reason MDI can favor features with many distinct values.
Generate a binary signal whose label is independently flipped with probability 0.2, then add independent binary and continuous noise features. An unrestricted tree fitted to 200 rows reaches 100% training accuracy but only 66.6% on 2,000 separate validation rows. Its two importance measures tell different stories:
Scale: 0 to 100%
Scale: −20 to +20 percentage points; line marks zero
Python: reproduce the noise example and shuffle variability
from sklearn.inspection import permutation_importance
def make_data(seed, n):
rng = np.random.default_rng(seed)
signal = rng.integers(0, 2, n)
binary_noise = rng.integers(0, 2, n)
continuous_noise = rng.random(n)
y = signal ^ (rng.random(n) < .2) # Independently flip 20% of labels on average.
return np.column_stack([signal, binary_noise, continuous_noise]), y
X_train, y_train = make_data(5, 200)
X_val, y_val = make_data(31, 2000)
noise_model = DecisionTreeClassifier(random_state=0).fit(X_train, y_train)
result = permutation_importance(
noise_model, X_val, y_val, scoring="accuracy",
n_repeats=30, random_state=7,
)
print(noise_model.score(X_train, y_train), noise_model.score(X_val, y_val))
# 1.000, 0.666
for j, label in enumerate(["Signal", "Binary noise", "Continuous noise"]):
print(label, noise_model.feature_importances_[j],
result.importances_mean[j], result.importances_std[j])
# MDI: [0.305661, 0.022480, 0.671859]
# Mean accuracy drop: [0.181583, -0.009000, -0.004800]
# Std across shuffles:[0.008475, 0.004930, 0.008056]
The standard deviations describe variation across shuffles of one validation sample and one fitted tree. They do not measure variation across new training datasets and are not confidence intervals for universal feature relevance.
Interpret the result for this model and dataset
Check model quality before interpreting importance. A weak model may fail to use a useful feature. Permutation importance measures the fitted model's reliance on the available representation, metric and evaluation rows; it does not measure the best prediction achievable from that feature.
Correlated inputs need care. A model may distribute its reliance across substitutes, so disrupting one can have a smaller effect than disrupting the group. This is not guaranteed: a single tree that uses only one copy can still depend strongly on that copy. Shuffling can also create combinations that would rarely occur in real data.
To inspect a meaningful group, shuffle its columns together with the same row permutation. That preserves relationships within the group while disrupting its relationship with the remaining inputs and labels. If you remove a column and retrain, you are asking a different question: how well can a newly fitted model compensate? Validate that new model directly.
Use importance to propose a subset, then evaluate it
Use training data to fit the model and development validation data to investigate permutation scores. If those scores guide feature removal, those validation rows have helped make a modeling decision. Reserve another evaluation set or use nested cross-validation for the final comparison.
Compare the reduced pipeline with the full eligible feature set under the same splits and metric. Repeat the selection within training folds, preserve group or time boundaries, and save the selected feature names in order. The RFE chapter shows how repeated fitting can change a ranking.
Optional implementation: score one fixed permutation
This C++ helper accepts a fitted prediction function and an explicit row permutation. It copies one row at a time, replaces only the chosen feature, and measures the change in accuracy. Keeping the permutation explicit makes the example reproducible and lets us inspect every changed row.
C++17: a permutation-importance measurement
#include <cmath>
#include <functional>
#include <stdexcept>
#include <vector>
using Matrix = std::vector<std::vector<double>>;
using Predictor = std::function<int(const std::vector<double>&)>;
// Measure a score drop for one supplied permutation; do not refit the predictor.
double permutation_drop(const Matrix& X, const std::vector<int>& y,
const Predictor& predict, std::size_t feature,
const std::vector<std::size_t>& order) {
if (X.empty() || X[0].empty() || y.size() != X.size() ||
order.size() != X.size() || feature >= X[0].size() || !predict)
throw std::invalid_argument("invalid shape, feature or predictor");
std::vector<bool> seen(X.size(), false);
for (auto i : order) {
if (i >= X.size() || seen[i]) throw std::invalid_argument("not a permutation");
seen[i] = true;
}
for (const auto& row : X) {
if (row.size() != X[0].size()) throw std::invalid_argument("ragged matrix");
for (double v : row) if (!std::isfinite(v))
throw std::invalid_argument("finite features required");
}
std::size_t before = 0, after = 0;
for (std::size_t i = 0; i < X.size(); ++i) {
before += predict(X[i]) == y[i];
auto changed = X[i];
changed[feature] = X[order[i]][feature];
after += predict(changed) == y[i];
}
return (static_cast<double>(before) - static_cast<double>(after)) / X.size();
}
For the fitted binary tree above, the prediction function returns 1 when the two inputs differ. Use zero-based order {2, 0, 3, 1} to reproduce the figure. Average the returned drops over repeated permutations for a mean importance. Other metrics need their own score function and consistent direction.
References and further reading
- Scikit-learn: DecisionTreeClassifier — weighted impurity decrease, fitted-tree structure and model controls.
- Scikit-learn: Permutation feature importance — score drops, repeated shuffles and interpretation for a fixed model.
- Scikit-learn: Permutation Importance vs Random Forest Feature Importance — training-set overfitting and the bias toward many-valued features.
- Scikit-learn: Permutation Importance with Correlated Features — how substitute predictors complicate individual importance.