Привет, Хабр!

После обучения модели почти в каждом ноутбуке есть одна и та же ячейка:

pd.Series(model.feature_importances_, X.columns).sort_values().plot.barh()

Картинка уезжает в презентацию, и по ней принимают решения. Скидка внизу списка, значит, на отток почти не влияет, и маркетинг можно урезать. Доход наверху, значит, это главный драйвер, и под него переделывают сегментацию.

Считается этот график по тому, как часто и насколько удачно дерево резало данные по признаку на обучающей выборке. С тем, насколько признак нужен модели на новых данных, это связано слабее, чем кажется, а с тем, как признак влияет на отток в жизни, ещё слабее.

На синтетике, где ответ известен заранее, номер строки и столбец случайного шума получили по 0,104, а настоящая скидка — 0,110. Permutation importance и SHAP тоже ошибаются, только в других местах.

Всё ниже посчитано на scikit-learn 1.9.

Шум наравне со скидкой

Данные: 5000 клиентов, отток зависит от дохода, возраста, стажа и скидки с убывающей силой. Плюс три признака, в которых информации нет вообще: номер строки, случайное число и случайный пол.

logit = 1.5*X.income + 1.0*X.age + 0.5*X.tenure + 0.3*X.discount
y = (rng.random(n) < 1 / (1 + np.exp(-logit))).astype(int)
X["row_id"] = np.arange(n)
X["noise"] = rng.normal(size=n)
X["gender"] = rng.integers(0, 2, n)

rf = RandomForestClassifier(300, random_state=0).fit(X_train, y_train)

Важность по feature_importances_:

income    0.314
age       0.223
tenure    0.130
discount  0.110
noise     0.104
row_id    0.104
gender    0.015

Шум, номер строки и скидка стоят вплотную, а случайный пол почти на нуле. Модель от случайного пола и случайного числа получает одинаково ничего, но у числа 3500 уникальных значений в обучении, а у пола — два.

Встроенная важность в лесу (MDI, mean decrease in impurity) суммирует, насколько каждое разбиение по признаку почистило узлы. Посчитано это на обучающих данных, а деревья в лесу по умолчанию растут до чистых листьев: точность на обучении — 1,00, на тесте — 0,761.

Глубоко внизу дереву надо чем-то отделить последние две-три строки, и признак с тысячами уникальных значений для этого подходит лучше всех. Так шум зарабатывает свои 0,104.

Поэтому в настоящих проектах сверху графика часто оказываются user_id, timestamp, почтовый индекс или хеш, а бинарные флаги вроде «есть подписка» сползают вниз, даже когда на деле они важнее.

Линейка из случайного столбца

Самый дешёвый способ понять, где на графике кончается сигнал, — добавить в данные заведомо пустой признак и смотреть, кто ниже него:

X["_noise"] = np.random.default_rng(0).normal(size=len(X))
imp = pd.Series(model.fit(X, y).feature_importances_, X.columns)
print(imp[imp <= imp["_noise"]].index.tolist())    # кандидаты в шум

На наших данных в эту зону попадает и скидка, которая на деле влияет на отток. По такому графику скидку от шума не отличить, хотя она влияет.

Один шумовой столбец с одним сидом тоже шумит, поэтому надёжнее завести несколько таких столбцов или повторить с разными сидами. На этой идее построен Boruta.

Помогает ограничить рост деревьев. С min_samples_leaf=20 точность на тесте даже выросла: с 0,761 до 0,770, а шумовые признаки упали с 0,104 до 0,04. Только скидка тоже упала, до 0,047, и разрыв между ней и шумом остался в пределах погрешности. Смещение к уникальным значениям стало меньше, но осталось.

Permutation importance на трейне повторяет ту же ошибку

Правильный ответ на MDI в sklearn называется permutation_importance: признак перемешивают и смотрят, насколько упала метрика. Если упала сильно, модель на признак опиралась.

Считать его можно на трейне и на тесте, и результаты сильно разные:

from sklearn.inspection import permutation_importance

r_train = permutation_importance(rf, X_train, y_train, n_repeats=10, random_state=0)
r_test  = permutation_importance(rf, X_test,  y_test,  n_repeats=10, random_state=0)
          на трейне   на тесте
income      0.323      0.174
age         0.241      0.082
tenure      0.129      0.017
discount    0.082      0.007
noise       0.055      0.000
row_id      0.047      0.007
gender      0.000     -0.002

На трейне лес выучил каждую строку наизусть, в том числе по шуму, и перемешивание шума ломает это запоминание: 0,055 и 0,047. На тесте запоминание не работает, и шум падает к нулю. Отрицательные значения у пола — просто разброс вокруг нуля.

Скидка с её 0,007 стоит вровень с номером строки. Но по умолчанию метрика здесь accuracy, а она грубая.

С scoring="roc_auc" на том же тесте скидка получает 0,0060 при разбросе 0,0020, а номер строки — 0,0016 при разбросе 0,0021, и их уже можно отличить.

importances_std отражает только случайность перестановок, разброс от данных и от обучения модели в него не входит, так что признаки, у которых среднее меньше двух таких отклонений, смело подписывайте на графике как «неотличим от нуля».

Копия признака делит важность пополам

В реальных данных у признаков есть родственники: доход и доход за прошлый месяц, возраст и год рождения, сумма в рублях и в долларах. Добавим к данным почти точную копию дохода:

X2["income_copy"] = X2.income + rng.normal(scale=0.05, size=n)
               MDI    perm на тесте
age          0.249        0.081
income_copy  0.232        0.054
income       0.222        0.056

Доход был первым с отрывом, 0,314 против 0,223 у возраста. Теперь первым стал возраст, а доход разрезан на два признака, каждый из которых меньше возраста. Деревья по очереди берут то одну копию, то другую, и вклад делится между ними.

Permutation importance страдает от этого ещё сильнее. Если перемешать income, модель достаёт ту же информацию из income_copy, и метрика почти не падает. Обе копии выглядят средненько, 0,056 и 0,054.

Я переобучил лес три раза, по-разному убирая копии:

обе копии на месте                0.757
убрали income                     0.757
убрали income и income_copy       0.621

Каждая копия по отдельности не нужна вообще, а вместе они стоят 14 пунктов точности, больше любого другого признака. Если отбирать признаки по графику и выкидывать нижние, такую пару можно потерять целиком в два шага.

Перемешивать группами

Исправляется это перестановкой коррелированных признаков вместе. Группы находятся иерархической кластеризацией по ранговой корреляции, так делают в примере из документации sklearn про мультиколлинеарность:

from scipy.cluster import hierarchy
from scipy.spatial.distance import squareform
from scipy.stats import spearmanr

corr = spearmanr(X).correlation
corr = (corr + corr.T) / 2
np.fill_diagonal(corr, 1)
link = hierarchy.ward(squareform(1 - np.abs(corr)))
labels = hierarchy.fcluster(link, 0.5, criterion="distance")

На наших данных кластеризация сама нашла пару income и income_copy, остальные признаки остались по одному. Потом перемешиваем всю группу разом:

def group_importance(model, X, y, cols, n_repeats=10):
    base = model.score(X, y)
    drops = []
    for i in range(n_repeats):
        idx = np.random.default_rng(i).permutation(len(X))
        Xp = X.copy()
        Xp[cols] = X[cols].values[idx]
        drops.append(base - model.score(Xp, y))
    return np.mean(drops)

Группа дохода получила 0,173, как один income до появления копии, и снова стоит на первом месте с отрывом от возраста. Порог 0.5 в fcluster подбирается под данные. Чем он больше, тем крупнее группы и тем грубее картина.

В бустингах свои умолчания

У HistGradientBoostingClassifier в sklearn атрибута feature_importances_ нет совсем. Код, переехавший с GradientBoostingClassifier, падает с AttributeError, и для него permutation_importance — единственный встроенный путь.

LightGBM атрибут отдаёт, но по умолчанию у LGBMClassifier стоит importance_type="split". Это число разбиений по признаку, без учёта того, насколько они полезны. Признак с тысячами значений, по которому дерево много раз мелко режет данные, по такому счёту выигрывает у сильного бинарного флага, который хватает одного раза в корне.

Для графика в презентацию нужен хотя бы importance_type="gain". Сам я LightGBM в этом прогоне не запускал: умолчание взято из документации.

SHAP часто советуют вместо всего этого, но он считает вклад признака в конкретный прогноз. На копиях признаков TreeSHAP, как и MDI, делит вклад между ними, так что группировка из прошлого раздела нужна и с ним. Его я в этом прогоне тоже не запускал.


Важность признака — свойство модели. Она говорит, на что опирались деревья, обученные на этих данных, с этими гиперпараметрами и этими родственниками у признаков.

Скидка в конце графика может означать, что скидка не влияет, что её эффект меньше шума при таком объёме или что её информацию забрал другой признак.

Чтобы различить эти три случая, нужны тест, разброс по повторам и группировка, и график из одной строки кода не даёт ни одного из них.

А какой признак был на первом месте в вашем последнем графике важности, и проверял ли кто-нибудь, что это не идентификатор?

График важности признаков — лишь один из способов понять, как модель использует данные. Чтобы глубже разобраться в устройстве ансамблей, их возможностях и особенностях работы, стоит изучить разные подходы к построению ML-моделей. Это поможет осознаннее выбирать инструменты для решения задач машинного обучения и критически оценивать результаты.

На бесплатном открытом уроке можно продолжить знакомство с этой темой:

  • 2 декабря, 18:00. «Ансамбли моделей — мощная техника решения ML-задач». Записаться

Больше практических разборов и полезных тем — в дайджесте открытых уроков ноября.