ЛР3
Дерево для регрессии

Задание 1.2 · входит в 5 баллов

Дерево для регрессии

То же дерево, но ответ теперь число: сколько лет в среднем живут в стране. Главное здесь не код (меняются две строки), а честная проверка и выбор метрик.

Данные

2307 строк: страна и год (2000-2019, 179 стран). Пропусков и дубликатов нет.

ПризнакЧто это
Health_Expenditure_Per_Capitaрасходы на здравоохранение на человека, $
Tertiary_School_Enrollmentсколько молодых людей учится в вузах, %
Urban_Population_Rateдоля горожан, %
Population_Densityлюдей на км²
PM25_AirPollutionзагрязнение воздуха мелкой пылью
Political_Stability_Indexиндекс политической стабильности (от -3 до 2)
Life_Expectancyцель: ожидаемая продолжительность жизни, лет

Предобработка и почему так

СтолбецЧто сделалиПочему
indexудалилиномер строки, не свойство страны
Country Name/Codeудалили из признаковиначе дерево запомнит «Япония = 84», а не связь с медициной
Year'2003 [YR2003]' → 2003дерево понимает только числа

Масштабировать и логарифмировать не нужно: дерево задаёт вопрос «x < t?», а если все значения, например, поделить на 1000, порядок не изменится и деления будут те же.

life_clean = life.drop(columns=['index', 'Country Name', 'Country Code'])
life_clean['Year'] = life_clean['Year'].str[:4].astype(int)  # '2003 [YR2003]' -> 2003
life_X = life_clean.drop(columns='Life_Expectancy')
life_y = life_clean['Life_Expectancy']
life_groups = life['Country Code']
  1. drop(columns=[...]) убирает столбцы.
  2. .str[:4] первые 4 символа текста («2003»), .astype(int) превращает в число.
  3. life_X признаки, life_y цель.
  4. life_groups код страны, нужен только чтобы делить на train/test по странам.

Честная проверка: делим по странам

У каждой страны до 19 строк, соседние годы почти одинаковы. Если делить строки случайно, в test попадёт «Франция 2010», а в train «Франция 2009 и 2011»: модель просто узнаёт страну.

На пальцах. Это как экзамен, где задача из билета отличается от домашней одной цифрой. Оценка будет высокой, но умения решать новые задачи она не покажет.
splitter = GroupShuffleSplit(n_splits=1, test_size=0.25, random_state=42)
train_idx, test_idx = next(splitter.split(life_X, life_y, groups=life_groups))
  1. GroupShuffleSplit делит не строки, а группы (страны): 25% стран целиком уходят в test.
  2. next(...) берём первое (и единственное) разбиение. Вышло: train 1687 строк из 134 стран, test 620 строк из 45 стран.
то же дерево глубины 5: случайное разбиение строк: R² = 0.861, MAE = 2.2 года (завышено) разбиение по странам: R² = 0.686, MAE = 3.2 года (честно)

Код: TreeRegressor

class TreeRegressor(Tree):
    def __init__(self, max_depth=None, min_samples_leaf=1, max_leaves=None, min_impurity=0.0):
        super().__init__('mse', max_depth, min_samples_leaf, max_leaves, min_impurity)

    def leaf_value(self, y):
        return np.mean(y)
  1. class TreeRegressor(Tree) наследование: берём всё от Tree и меняем только нужное.
  2. super().__init__('mse', ...) вызываем конструктор Tree, но беспорядок теперь MSE.
  3. leaf_value ответ листа: не самый частый класс, а среднее.

Всё остальное (поиск порога, IG, остановка, очередь листьев, predict) работает без изменений: IG с MSE это «насколько уменьшилась дисперсия».

IG = MSE(до) - ( |лев|/|все| · MSE(лев) + |прав|/|все| · MSE(прав) )

Критерии остановки с поправкой на регрессию

Purity в регрессии значит «все числа в листе равны» (MSE = 0). На непрерывных числах так бывает почти только в листе из одного объекта: вышло 1673 листа на 1687 строк, дерево зазубрило выборку. Поэтому добавлен вариант с допуском: не делить лист, если MSE ≤ 1, то есть разброс около ±1 года.

Поиграй: глубина и переобучение

Белые точки: train (по ним учится дерево). Голубые: test (их дерево не видит). Двигай глубину и смотри на две ошибки.

ошибка на train
ошибка на test
листьев (до)

Метрики (нужно минимум 3, взяли 4)

MAE
Средняя ошибка в годах: среднее |правда - прогноз|. Самая понятная.
RMSE
Корень из среднего квадрата ошибки, тоже в годах. Сильнее наказывает крупные промахи.
MAPE
Средняя ошибка в процентах от правильного ответа.
R²
Доля разброса, которую объясняет модель: 0 = как «всегда отвечать среднее», 1 = идеально.
КритерийЛистьевMAERMSEMAPE %R²
max_depth = 5323.174.975.050.686
min_samples_leaf = 52753.495.365.500.635
max_leaves = 553.625.495.740.618
purity (MSE = 0)16733.575.335.600.639
purity (MSE ≤ 1)2113.535.265.560.648
Точки: настоящая продолжительность жизни против предсказания двух деревьев
По горизонтали правда, по вертикали прогноз. Чем ближе к красной линии, тем лучше. Слева дерево глубины 5 (горизонтальные «полки» = 32 листа), справа дерево до чистых листьев: точки разбросаны сильнее.
Ловушка: утечка через страну. Если спросят «почему R² всего 0.69», ответ: потому что проверка честная, на странах, которых модель не видела. При случайном делении было бы 0.86, но это самообман.

Почему при случайном делении строк R² вырос до 0.861?

Ответ листа в TreeRegressor это...

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

Зачем 4 метрики, а не одна?

MAE понятна в годах, RMSE показывает крупные промахи, MAPE даёт проценты, R² сравнивает с простым средним и не зависит от единиц. Вместе они дают полную картину.

Почему не масштабировал признаки?

Дерево сравнивает признак с порогом. Монотонное преобразование не меняет порядок значений, значит, и деления, и прогноз будут те же.

Чем TreeRegressor отличается от Tree?

Двумя вещами: беспорядок считается через MSE, а ответ листа это среднее, а не самый частый класс. Остальной код общий.

Почему удалил страну, она же важна?

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

Одна фразаTreeRegressor это то же дерево с MSE и средним в листе; проверял честно, деля по странам; лучшее дерево глубины 5 ошибается на 3.2 года, R² = 0.69.