← All Posts
Deep Learning · Data Preparation and Preprocessing · Feature Selection

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

$$I=1-\sum_k p_k^2.$$

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.

↓ A = 0
B = 0
3 rows, all 0
Gini = 0
B = 1
1 row, class 1
Gini = 0
↓ A = 1
B = 0
3 rows, all 1
Gini = 0
B = 1
1 row, class 0
Gini = 0
Root A: local decrease 0.500 − 0.375 = 0.125. All 8/8 rows reach it, so its credit is 0.125.
A split affecting half the training rows gets half the weight. Each B split reduces its local impurity by 0.375, but only four of the eight rows reach it. Each therefore contributes 0.1875 to the whole tree.

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:

$$\text{credit}(t)=\frac{n_t}{N}\left(I_t-\frac{n_L}{n_t}I_L-\frac{n_R}{n_t}I_R\right).$$

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.

RowABLabelPrediction
10000 · correct
20111 · correct
31011 · correct
41100 · correct
Original accuracy: 4/4 = 1.00. Accuracy drop: 0.00.
The model is evaluated again, not retrained. Each shuffled column uses the fixed source-row order 3, 1, 4, 2. Both shuffles cause two mistakes here: an accuracy drop of 0.50, or 50 percentage points. This is one visible permutation.

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:

$$\text{permutation importance}_j=s-\frac1R\sum_{r=1}^{R}s_j^{(r)}.$$

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:

Share of training impurity reduction

Scale: 0 to 100%

Signal
30.57%
Binary noise
2.25%
Continuous noise
67.19%
Validation accuracy drop after shuffling

Scale: −20 to +20 percentage points; line marks zero

Signal
+18.16 pp
Binary noise
−0.90 pp
Continuous noise
−0.48 pp
Most training credit goes to a variable generated independently of the target. Validation drops are means across 30 shuffles of the same fitted model. Small negative values mean shuffled predictions scored slightly better in this sample; they do not prove that removing a feature will improve a refitted model.
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