ЛР3
Случайный лес

Задание 2.1 · 2 балла

Случайный лес

Строим 200 деревьев, каждое на своей случайной версии данных и со случайными признаками в каждом узле, и даём им проголосовать. Задача: уйдёт ли клиент из банка.

Данные об оттоке

10 000 клиентов банка, ушли 20.4% (Exited = 1). Классы несбалансированы: оставшихся вчетверо больше.

СтолбецЧто сделалиПочему
RowNumber, CustomerIdудалилиномера, причин ухода в них нет
Surnameудалили2932 разные фамилии, только переобучение
GenderMale = 1, Female = 0дерево понимает только числа
Geographyone-hot: 3 столбца 0/1у стран нет порядка, номера 0/1/2 его бы придумали
One-hot
Вместо одного столбца «страна» три: «Франция?», «Германия?», «Испания?», в каждом 0 или 1.
Бэггинг
Учим много одинаковых моделей на разных случайных подвыборках и усредняем.
Бутстрап
Случайная выборка того же размера с возвращением: кто-то попадёт дважды, кто-то ни разу (около 37%).
max_features
Сколько случайных признаков дерево смотрит в каждом узле. Эвристика из задания: k = √d.
Голосование
Каждое дерево говорит «уйдёт / не уйдёт», побеждает большинство. Доля голосов это оценка вероятности.
На пальцах. Один эксперт может ошибаться по-своему. Если спросить 200 экспертов, каждый из которых видел немного разные данные и обращал внимание на разное, их случайные ошибки взаимно гасятся, а общее мнение оказывается точнее любого из них.

Поиграй: бутстрап

10 клиентов. Тянем 10 раз с возвращением: кто-то попадёт дважды, кто-то ни разу. Так у каждого дерева своя версия данных.

все клиенты (пунктир = не попал в выборку)

что вытянули

Поиграй: почему среднее деревьев лучше

Каждое серое дерево глубокое и обучено на своём бутстрапе. Жёлтая линия: их среднее. Двигай число деревьев.

ошибка на новых точках

Код: RandomForest

class RandomForest:
    def __init__(self, n_estimators=100, task='classification', max_features='sqrt', random_state=None, **tree_kwargs):
        ...

    def fit(self, X, y):
        X, y = np.asarray(X), np.asarray(y)
        rng = np.random.default_rng(self.random_state)
        tree_class = DecisionTreeClassifier if self.task == 'classification' else DecisionTreeRegressor
        if self.task == 'classification':
            self.classes_ = np.unique(y)
        self.trees = []
        for _ in range(self.n_estimators):
            rows = rng.integers(0, len(X), size=len(X))  # бутстрап
            tree = tree_class(max_features=self.max_features, random_state=int(rng.integers(2**31 - 1)), **self.tree_kwargs)
            self.trees.append(tree.fit(X[rows], y[rows]))
        return self
  1. **tree_kwargs «всё остальное»: критерий остановки и функционал (max_depth, criterion...) передаём деревьям как есть.
  2. np.random.default_rng(self.random_state) генератор случайных чисел с зерном: при каждом запуске одинаковый лес.
  3. tree_class для классификации дерево-классификатор, для регрессии дерево-регрессор (это задание 2.3).
  4. rng.integers(0, len(X), size=len(X)) m случайных номеров строк от 0 до m-1, повторы разрешены. Это и есть бутстрап.
  5. max_features=... дерево sklearn в каждом узле выбирает случайные k признаков и ищет лучший вопрос только среди них. Остальные признаки не выброшены, они участвуют в других узлах.
  6. random_state=int(rng.integers(...)) у каждого дерева своё зерно, иначе все деревья выбирали бы одинаковые признаки.
    def predict_proba(self, X):
        votes = np.array([tree.predict(np.asarray(X)) for tree in self.trees])  # (деревья, объекты)
        return (votes[:, :, None] == self.classes_).mean(axis=0)

    def predict(self, X):
        if self.task == 'regression':
            return np.mean([tree.predict(np.asarray(X)) for tree in self.trees], axis=0)
        return self.classes_[self.predict_proba(X).argmax(axis=1)]
  1. votes таблица: строка = дерево, столбец = клиент, в клетке ответ дерева.
  2. votes[:, :, None] == self.classes_ для каждого класса отмечаем, кто за него голосовал; .mean(axis=0) доля деревьев. Если 150 из 200 сказали «уйдёт», вероятность 0.75.
  3. argmax побеждает класс с наибольшей долей голосов.
  4. Для регрессии ответ это среднее прогнозов всех деревьев.
Ловушка: «готовый лес нельзя». Мы используем из sklearn только одиночное дерево (DecisionTreeClassifier), это разрешено условием. Бутстрап, случайные зёрна, голосование и среднее написаны сами. RandomForestClassifier используется только для сравнения.

Метрика: почему ROC-AUC

модель «никто не уйдёт»: accuracy = 0.797, ROC-AUC = 0.500, F1 = 0 → accuracy хвалит модель, которая не нашла ни одного уходящего
ROC-AUC
Вероятность, что случайный ушедший клиент получит от модели оценку выше, чем случайный оставшийся. 0.5 = монетка, 1 = идеально. Не зависит ни от порога, ни от доли классов.
precision
Из тех, кого модель назвала «уйдёт», сколько правда ушли.
recall
Из всех, кто правда ушёл, скольких модель нашла.
F1
Среднее гармоническое precision и recall: высокое, только если обе хорошие. Зависит от порога 0.5.
Кросс-валидация (CV)
Train делим на 3 части, по очереди учим на двух и проверяем на третьей, качество усредняем. Test при подборе не трогаем.

Выбрали ROC-AUC: он честен при дисбалансе и оценивает главное для банка, умение упорядочить клиентов по риску (кого обзванивать первым). F1, precision и recall показаны рядом.

Поиграй: порог и метрики

20 клиентов, отсортированы по оценке модели (число = оценка в процентах). Оранжевые правда ушли (4 из 20, как 20% в данных). Обведённых модель назвала «уйдёт».

accuracy
precision
recall
F1
ROC-AUC

Подбор гиперпараметров

Сетка из 48 вариантов, каждый на 3 фолдах по 50 деревьев: критерий (gini, entropy) × max_depth (нет, 6, 10, 14) × min_samples_leaf (1, 5, 10) × max_features (√12 ≈ 3 или 60% = 7).

лучшее: gini, max_depth = 14, min_samples_leaf = 10, max_features = 0.6 ROC-AUC (CV) = 0.853; первые 8 вариантов отличаются меньше чем на 0.004
ROC-AUC леса растёт с числом деревьев и выходит на плато
Число деревьев: 1 дерево 0.685, 10 деревьев 0.818, 50 деревьев 0.853, 200 деревьев 0.856, 300 деревьев 0.857. После 50 почти плато, взяли 200.
На пальцах, трюк с кривой. Лес из первых 50 деревьев леса из 300 это ровно лес из 50 деревьев (у них те же зёрна). Поэтому учим один раз 300 деревьев и считаем качество для первых 1, 5, 10, ... без переобучения.

Итог на test

МодельROC-AUCF1precisionrecallaccuracyвремя
наш лес0.8590.5710.7820.4500.863~3.4 с
лес sklearn0.8600.5670.7690.4500.861~2.3 с
одно дерево0.8060.5630.6500.4960.8430.03 с
«никто не уйдёт»0.5000000.797

Ушли 20% клиентов. Модель всем отвечает «не уйдёт». Какая у неё accuracy?

Что делает max_features в дереве леса?

Вопросы на защите

Почему не accuracy?

Классы 80/20. Модель, которая всем отвечает «не уйдёт», получает 0.797, не найдя ни одного уходящего. ROC-AUC у неё 0.5, то есть сразу видно, что она бесполезна.

Зачем случайные признаки в каждом узле?

Иначе все деревья начинали бы с одного и того же сильного признака (возраст) и были бы похожи. Похожие деревья ошибаются одинаково, и усреднение не помогает. Случайность делает деревья разными.

Лучшее max_features = 0.6, а в задании √d. Это нарушение?

Нет: √d это эвристика по умолчанию, и она была в сетке. Задание просит подобрать гиперпараметры, на наших данных 7 признаков из 12 дали ROC-AUC чуть выше.

Сколько объектов не попадает в бутстрап?

Примерно 37%: вероятность ни разу не попасть при m выборах равна (1 - 1/m)^m ≈ 1/e ≈ 0.37.

Почему лес лучше дерева?

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

Одна фразаЛес = 200 деревьев на бутстрап-выборках со случайными признаками в узлах и голосование; подбирал по ROC-AUC из-за дисбаланса 80/20; на test 0.859, как у sklearn, и лучше одного дерева (0.806).