Введение
Кажется, что нейросети могут все. Мы видим результат их работы в системах распознавания лиц, видеоаналитике. Нас поражает умение нейросетей рисовать изображения, распознавать и синтезировать голос, а большие языковые модели уже давно проходят тест Тьюринга и ведут беседу как живые люди. Но, как показал мой эксперимент, в задачах кредитного скоринга они нисколько не лучше классических ML-моделей. В этой статье я расскажу, как решающее дерево, случайный лес, бустинг и даже многослойная нейросеть упираются в один и тот же потолок на данных кредитного скоринга.
Ход эксперимента
В качестве датасета были взяты данные кредитного скоринга с https://www.kaggle.com/competitions/GiveMeSomeCredit. Сначала мы обучили решающее дерево, с кросс-валидацией. График зависимости roc-auc от количества элементов выборки (Ореол вокруг кривой – это дисперсия (разброс) при кроссвалидации):

А вот таблица результирующих метрик:
Метрика | CV (5-fold) | Тест |
accuracy | 0.794 ± 0.017 | 0.798 |
precision | 0.209 ± 0.011 | 0.214 |
recall | 0.736 ± 0.023 | 0.755 |
f1 | 0.326 ± 0.012 | 0.334 |
roc-auc | 0.829 ± 0.005 | 0.840 |
Интерпретация результатов:
ROC-AUC ≈ 0.84 — модель неплохо ранжирует заёмщиков по риску; это главная метрика для скоринга.
Recall ≈ 75% — находит 3 из 4 реальных просрочек, но с большим числом ложных тревог.
Precision ≈ 21% — из 10 предсказанных просрочек только ~2 реальные.
Accuracy ≈ 80% — на фоне 93% «хороших» клиентов выглядит приемлемо, но сама по себе обманчива.
F1 ≈ 33% – очень мало (модель бесполезна): много пропусков либо много ложных тревог, либо и то и другое. В нашем случае ужасающее количество ложных тревог: 8 из 10 заемщиков, которым мы отказываем, на самом деле хорошие заемщики и вовремя бы вернули кредит.
Стоит заметить, что такие результаты мы получили при включенной опции весов «balanced» – специальная опция решающего дерева, предназначенная для обучения на несбалансированных данных:
def build_decision_tree_model() -> PreprocessingClassifier: return PreprocessingClassifier( preprocessor=MissingValueHandler(), model=DecisionTreeClassifier( random_state=RANDOM_STATE, class_weight="balanced", max_depth=8, ), )
Если убрать эту опцию, то тогда дерево просто всегда будет «говорить», что не будет просрочки. И тогда точность достигнет 93% – ровно столько, сколько процентов нормальных заемщиков. Но в этом случае будет еще ужаснее полнота и другие метрики:
Метрика | CV (5-fold) | Тест |
accuracy | 0.934 ± 0.0006 | 0.936 |
precision | 0.539 ± 0.012 | 0.568 |
recall | 0.181 ± 0.009 | 0.172 |
f1 | 0.271 ± 0.011 | 0.264 |
roc-auc | 0.839 ± 0.0055 | 0.850 |
Именно поэтому accuracy в отрыве от остальных показателей – бесполезная метрика. Теперь проиграемся еще с одним параметром: максимальная глубина (max_depth):
Метрика | 3 | 8 | 15 |
accuracy | 0.677 | 0.798 | 0.819 |
precision | 0.155 | 0.214 | 0.210 |
recall | 0.853 | 0.755 | 0.613 |
f1 | 0.262 | 0.334 | 0.312 |
roc-auc | 0.821 | 0.840 | 0.721 |
Данная таблица иллюстрирует, что максимальная глубина была выбрана достаточно оптимально – ее увеличение ведет к переобучению, а уменьшение к потере точности. Да, у нас при max_depth=3 возрос recall, но упала точность.
В общем, одиночное решающее дерево никуда не годится. Нужен целый «лес» – random forest.
Сравним его метрики с простым решающим деревом:
Метрика | Decision Tree | Random Forest |
accuracy | 0.798 | 0.800 |
precision | 0.214 | 0.219 |
recall | 0.755 | 0.770 |
f1 | 0.334 | 0.341 |
roc-auc | 0.840 | 0.864 |
Как видно из таблицы, метрики улучшились (все), но незначительно.
Попробуем использовать случайный поиск RandomizedSearchCV для подбора параметров. Сравним результаты:
Метрика | До | После |
accuracy | 0.800 | 0.812 |
precision | 0.219 | 0.227 |
recall | 0.770 | 0.751 |
f1 | 0.341 | 0.349 |
roc-auc | 0.864 | 0.866 |
Что мы тут видим? Точность незначительно улучшилась, полнота упала, в целом метрики чуть-чуть улучшились. Вот какие параметры были подобраны алгоритмам (сравним их с параметрами по умолчанию):
Параметр | Подобрано | По умолчанию | Изменение |
n_estimators | 100 | 100 | без изменений |
max_depth | 10 | 8 | глубже (+2) |
min_samples_leaf | 20 | 1 | сильно больше |
min_samples_split | 50 | 2 | сильно больше |
max_features | sqrt | sqrt | без изменений |
Сделаем второй прогон, с другим RANDOM_STATE:
Метрика | Старый прогон | Новый прогон |
accuracy | 0.812 | 0.815 |
precision | 0.227 | 0.233 |
recall | 0.751 | 0.763 |
f1 | 0.349 | 0.357 |
roc-auc | 0.866 | 0.872 |
Видим, что метрики незначительно поменялись (стали чуть-чуть получше). Смотрим как изменились настройки:
Параметр | Старый прогон | Новый прогон | Изменение |
n_estimators | 100 | 200 | увеличился |
max_depth | 10 | 10 | без изменений |
min_samples_leaf | 20 | 20 | без изменений |
min_samples_split | 50 | 50 | без изменений |
max_features | sqrt | sqrt | без изменений |
Видим, что тут увеличилось n_estimators (общее количество деревьев решений в ансамбле).
И так, мы выяснили, что ни решающее дерево, ни случайный лес тут не работают. Попробуем бустинг. Сравним:
Метрика | Random Forest | Hist gradient boosting |
accuracy | 0.812 | 0.801 |
precision | 0.228 | 0.220 |
recall | 0.750 | 0.772 |
f1 | 0.350 | 0.343 |
roc-auc | 0.866 | 0.867 |
Как видим, используя лес и бустинг, мы смогли лишь слегка увеличить accuracy, при этом precision оставили на прежнем уровне, recall тоже смогли чуть-чуть поднять.
Теперь попробуем нейросеть (два скрытых слоя, на 64 и 32 нейрона, разумеется есть дропауты для борьбы с переобучением):
Метрика | Hist gradient boosting | Нейросеть |
accuracy | 0.801 | 0.815 |
precision | 0.220 | 0.222 |
recall | 0.772 | 0.702 |
f1 | 0.343 | 0.338 |
roc-auc | 0.867 | 0.835 |
То есть нейросеть не показала лучших результатов, чем бустинг. На первый взгляд, такой результат может показаться странным. Но, дело в том, что нейросети хороши там, где есть нелинейные зависимости между фичами и выходными признаками, особенно, когда эти зависимости имеют сложный пространственный характер (выходной параметр зависит нелинейно сразу от нескольких фич)
Попробуем больше слоев, например, не (64, 32), а (128, 64, 32):
Метрика | Старая нейросеть | Новая нейросеть |
accuracy | 0.815 | 0.812 |
precision | 0.222 | 0.220 |
recall | 0.702 | 0.705 |
f1 | 0.338 | 0.335 |
roc-auc | 0.835 | 0.835 |
Практически, ничего не изменилось, мы только усилили переобучение. По сути, мы достигли предела. Дальше остается только работа с данными: в выборке просто не хватает фич.
Заключение
Итак, решающее дерево не справилось с задачей кредитного скоринга, случайных лес и бустинг показали результаты чуть получше. Вопреки ожиданиям, нейросеть тоже не справилась. Это можно объяснить следующими причинами:
1. Проклятие несбалансированности. Самая основная причина катастрофически низкого F1 – несбалансированность выборки. В датасете 93% «хороших» и 7% «плохих» элементов. При таких условиях даже идеальная модель будет делать ошибки, так как она вынуждена балансировать между recall (найти всех плохих) и precision (не забраковать ошибочно хороший элемент). Включение опции class_weight='balanced' сдвигает модель в сторону поиска «плохих» объектов, но значительно увеличивает количество ложных тревог. И это не потому, что модель «плохая», а потому что такова объективная реальность: «плохие» и «хорошие» заемщики сильно пересекаются в пространстве признаков.
2. Сила признаков (Причина "Потолка ROC-AUC"). Метрика ROC-AUC показывает, насколько хорошо модель отличает «плохие» объекты от «хороших», то есть ставит первых выше вторых по вероятности. Значение 0.86 – это довольно хороший, но отнюдь не выдающиеся результат. Мы видим, что бустинг и нейросеть не помогают, это значит, что все полезные сигналы уже «выжаты» из фич, и между фичами и выходными признаками нет сложных нелинейных зависимостей, которые могла бы обнаружить. Далее, сами признаки довольно слабые: возраст, количество открытых кредитов, количество иждивенцев – они не позволяют однозначно предсказать, вернет ли заемщик долг.
Можно ли улучшить результат? Несмотря на недостаток фич, полагаю, что да, возможно. Во-первых, в качестве метрики использовать не AUC-ROC, а AUC-PR, он лучше подходит для несбалансированной выборки. Во-вторых, перед выбором и обучением провести фиче-инженеринг, проанализировать, какие фичи коррелирует с целевыми признаками, в-третьих, попробовать составные фичи, когда мы вычисляем новые фичи на основании существующих, делая над ними различные математические операции. В-четвертых поработать с заполнением пропущенных данных. Возможно, отсутствие каких-то данных это уже сам по себе «красный» флаг – новая фича.
