Защита
Рассказ на защите
Сценарий, чтобы пройтись по лабе по порядку. Листаешь ноутбук сверху вниз, на каждом шаге смотришь в указанную ячейку и говоришь 2-4 фразы. Целиком минут 8-10.
- В Colab: «Среда выполнения → Перезапустить сеанс и выполнить все». Прогон занимает 2-3 минуты. Номера ниже даны для такого запуска: [1], [2], [3]...
- Проверь ячейку [2]: там версии библиотек. Если в Colab другая версия sklearn, некоторые числа могут отличаться в третьем знаке (особенно accuracy на ирисах и номер лучшей итерации бустинга). Выводы те же, просто назови числа со своего экрана.
- Говори своими словами, текст ниже это опора.
Начало
Здравствуйте, могу рассказать по порядку? Лаба про деревья решений и ансамбли: я написал своё дерево для классификации и регрессии, свой случайный лес и свой градиентный бустинг, и сравнил их с sklearn.
Подготовка
[1] · import numpy as np
В первую ячейку я добавил import seaborn: ниже в примере с бустингом вызывается sns.scatterplot, а импорта в шаблоне не было.
[3] · def entropy(y):
Функционалы написаны векторно, без циклов: np.unique считает, сколько объектов каждого класса, делю на общее число и получаю доли. Энтропия это сумма p на log2 от 1 на p, Джини это 1 минус сумма квадратов долей, MSE это среднее квадратов отклонений от среднего.
Если спросят: почему log2(1/p), а не минус log2(p)?
Это одно и то же, но для чистого узла так получается ровно 0.0, а не -0.0. Тест сравнивает через ==.
[4] и [5] · # Tests / # тесты выше с rtol=1e5
Тесты проходят, но в них rtol=1e5, такой допуск пропускает почти всё. Поэтому я отдельно напечатал значения: энтропия 0.650 и 1.585, Джини 0.278 и 0.667, MSE 0.667 и 66.67. Это совпадает с ожидаемыми, в тестах они обрезаны.
Задание 1.1 · дерево на ирисах
[6] · # ссылки на функционалы запоминаем сейчас
Здесь всё дерево. Отдельные функции, как просит задание: split_node делит узел маской pandas, information_gain считает прирост по взвешенной формуле, best_split перебирает признаки и пороги посередине между соседними значениями, should_stop проверяет глубину, размер листа и чистоту. Число листьев проверяется в fit: там очередь листьев, и первым делится тот, где деление сильнее всего уменьшает беспорядок, как в sklearn при max_leaf_nodes.
Если спросят: зачем словарь impurity_functions?
В задании 3 имя mse переопределяется функцией с двумя аргументами. Словарь запоминает старую функцию, и дерево для регрессии не ломается.
Если спросят: тут можно было без цикла по порогам?
Да: после сортировки через np.cumsum можно посчитать количество классов слева для всех порогов сразу и получить энтропию всех делений одной формулой. Я оставил перебор, потому что условие прямо говорит «перебрать t», а сами функционалы векторные.
Если спросят: почему веса в IG?
Без весов отрезать один объект в чистую половину выглядело бы выгодно. Вес учитывает, какую часть данных реально упорядочили.
[8] · X_df = pd.DataFrame(X, columns=feature_names)
Делю ирисы 70 на 30 со stratify, в test 45 цветков по 15 каждого вида, поэтому accuracy тут честная метрика. Четыре критерия остановки: глубина 5, минимум 5 объектов в листе, максимум 5 листьев и purity. Accuracy 0.89-0.93, одна ошибка это 0.022. Число листьев и глубина совпадают с sklearn во всех вариантах, разница в 1-2 цветка из-за ничьих: в корне длина и ширина лепестка одинаково идеально отделяют setosa.
[9] · # как выглядит дерево с ограничением на 5 листьев
Так выглядит дерево с пятью листьями: первый вопрос «длина лепестка меньше 2.45» отделяет все 35 setosa, дальше ширина лепестка разделяет два других вида. Ошибки только между versicolor и virginica.
Задание 1.2 · регрессия
[11] · # od.download просит логин и ключ Kaggle
opendatasets требует логин и ключ Kaggle, поэтому скачиваю через kagglehub: он берёт публичные датасеты без ключа.
[12] и [13] · life = pd.read_csv(...) / life_clean = life.drop(...)
2307 строк, 179 стран, пропусков и дубликатов нет. Удалил index и название страны: страна это идентификатор, дерево бы её запомнило. Год был текстом «2003 [YR2003]», взял первые 4 символа. Масштабировать не нужно: дерево сравнивает с порогом, порядок значений от масштаба не меняется.
[14] · class TreeRegressor(Tree):
TreeRegressor наследует Tree и меняет две вещи: беспорядок это MSE, ответ листа это среднее. Метрик четыре: MAE и RMSE в годах, MAPE в процентах и R2, доля объяснённого разброса.
[15] · from sklearn.model_selection import GroupShuffleSplit
Делю по странам: страна целиком в train или в test, 134 и 45 стран. Иначе модель узнаёт страну по соседним годам. Лучшее дерево глубины 5: MAE 3.2 года, R2 0.686. Purity в регрессии значит все числа в листе равны, это дерево из 1673 листьев, оно переобучено; с допуском MSE не больше 1 оно в 8 раз меньше и чуть лучше.
[16] и [17] · # для сравнения: что будет, если делить строки случайно
Для проверки: при случайном делении то же дерево показывает R2 0.861, это завышение из-за утечки. На графике видно, что глубокое дерево разбрасывает прогнозы сильнее.
Задание 2.1 · случайный лес
[18] · class RandomForest:
Лес свой: для каждого дерева бутстрап, то есть m случайных строк с возвращением, дерево sklearn с max_features, то есть в каждом узле смотрит только часть случайных признаков. Ответ это доля голосов деревьев, а для регрессии среднее. Готовый RandomForest из sklearn только для сравнения.
[19] · churn = pd.read_csv(...)
Удалил номер строки, id клиента и фамилию, пол перевёл в 0/1, страну в one-hot, потому что у стран нет порядка. Ушли 20.4%, это дисбаланс, поэтому деление со stratify.
[20] · from sklearn.model_selection import StratifiedKFold
Метрика ROC-AUC. Accuracy не подходит: модель «никто не уйдёт» получает 0.797. ROC-AUC не зависит от доли классов и порога и показывает, насколько хорошо модель ранжирует клиентов по риску. Подбирал на трёх фолдах train сетку из 48 вариантов: критерий, глубина, минимальный лист, число признаков. Лучшее: gini, глубина 14, минимум 10 в листе, 60% признаков, 0.853.
[21] · # число деревьев
Качество растёт быстро до 50 деревьев и выходит на плато, взял 200. Чтобы не обучать заново, учил 300 деревьев и считал качество для первых n.
[22] · import time
На test мой лес даёт ROC-AUC 0.859, лес sklearn 0.860, одно дерево 0.806. Мой лес чуть медленнее, потому что цикл Python по деревьям.
Если спросят: recall всего 0.45, это плохо?
При пороге 0.5 да, находим меньше половины. Порог можно снизить, если важнее найти больше уходящих. ROC-AUC от порога не зависит, поэтому выбор модели верный.
Задание 2.2 · важность
[23] и [24] · def permutation_importance(...) / # в какую сторону действуют главные признаки
Важность двумя способами: MDI из деревьев и своя перестановочная важность, перемешиваю столбец в test и смотрю падение ROC-AUC. Оба способа выделяют число продуктов, возраст, баланс, активность и Германию. MDI завышает зарплату и кредитный рейтинг, у них много разных значений, а перестановка показывает, что они почти бесполезны. По группам: в 51-60 лет уходит 56%, с тремя продуктами 83%, в Германии 32%.
Задание 2.3 · лес для регрессии
[25] и [26] · rows = [] / def r2_drop_score(...)
Для регрессии в лесу меняется только task: деревья-регрессоры и среднее. При всех четырёх критериях лес лучше дерева, сильнее всего для глубоких деревьев: R2 0.639 у дерева и 0.760 у леса, потому что усреднение гасит случайные ошибки. Главные признаки расходы на медицину и высшее образование.
Задание 3.1 · потери и бустинг
[27] · # axis=-1
Потери и градиенты одной формулой numpy, без циклов: np.mean, np.abs, np.sign, np.where. axis=-1 позволяет подать матрицу и получить потерю для каждой строки, это нужно для подбора ρ.
[28] и [29] · y_real = np.array(...) / # значения сверяем вручную
Тесты проходят, но я проверил вручную и нашёл неточности шаблона. MAE равна 0.22, а не 0.21. MSE в тесте без одной второй, поэтому градиент 2(f - y). Градиент Lq в тесте не совпадает с численной производной формулы: он для той же потери с q и 1 - q наоборот. Сделал как в тесте и написал об этом. MSE и MAE численная производная подтверждает.
[30] · class GradientTreeBoosting:
Бустинг: начальный прогноз это среднее. На каждом шаге считаю антиградиент, учу на нём DecisionTreeRegressor и подбираю ρ из 201 значения от -1 до 1. Все 201 вариант считаются одной матричной операцией без цикла. Если const_p, ρ равен 1. predict по очереди прибавляет вклад каждого дерева.
Задание 3.2 · проверка
[31] и [32] · # генерируем данные / clf_const = ...
На синусе одно дерево глубины 3 даёт ступеньки, MSE 0.236, а бустинг из 10 таких деревьев 0.026, это почти уровень шума. Подбор каждый раз выбирает ρ = 0.5, а с ρ = 1 бустинг хуже даже одного дерева.
[34] · # число итераций подбираем на валидационной части
Число деревьев подобрал на валидации из train: шаг большой, поэтому лучшее качество уже после 4 деревьев, дальше переобучение.
[35] и [36] · %%time / const_model, rho_model = ...
На оттоке бустинг с подбором ρ даёт ROC-AUC 0.850, F1 0.515, одно дерево 0.795, а с ρ = 1 только 0.433. Причина точная: дерево учится на 2(y - f), то есть на удвоенной ошибке. Шаг с ρ = 1 перепрыгивает ответ, средняя ошибка в листе меняет знак, но не уменьшается. На графике MSE на обучении стоит ровно 0.1622, столько же, сколько у простого среднего. Подбор находит ρ = 0.5, и ошибка падает. По времени дерево быстрее всего, подбор ρ в 2-3 раза медленнее ρ = 1, потому что считает 201 вариант.
Если спросят: а если бы потеря была ½(y - f)²?
Тогда градиент f - y, и ρ = 1 сразу правильный шаг. Подбор ρ как раз исправляет такие проблемы масштаба.
Конец
Итого: дерево на уровне sklearn, лес лучше дерева и в классификации, и в регрессии, бустинг с подбором шага лучше одного дерева, а с постоянным шагом из-за градиента 2(f - y) не учится. Готов ответить на вопросы.
Вероятные вопросы
Где в лабе можно было без цикла или по формуле?
Функционалы, потери, градиенты и подбор ρ уже без циклов. Перебор порогов в дереве можно заменить накопленными суммами (np.cumsum). Циклы по деревьям в лесу и бустинге нужны по смыслу: деревья строятся по одному (в бустинге каждое зависит от предыдущих).
Чем бэггинг отличается от бустинга?
Бэггинг: независимые модели на бутстрап-выборках, усреднение, уменьшает случайную ошибку. Бустинг: модели по очереди исправляют ошибки суммы предыдущих, уменьшает систематическую ошибку.
Что такое стэкинг?
Несколько моделей разного типа и мета-модель, которая учится делать итоговый прогноз по их ответам. В лабе не использовался, только упоминается.
Почему одно дерево переобучается, а лес нет?
Лес тоже может, но слабее: каждое дерево видит свою выборку и свои признаки, их ошибки разные и гасятся при голосовании.
Что будет, если запустить ещё раз?
Все зёрна зафиксированы (random_state=42), числа те же. В другой версии sklearn возможны отличия в третьем знаке.