Задание 1.1 · 5 баллов вместе с 1.2
Своё дерево на ирисах
Пишем дерево с нуля: оно перебирает вопросы «признак < порог?», выбирает тот, что сильнее всего уменьшает беспорядок, и повторяет для каждой половины, пока не сработает критерий остановки.
Данные
Ирисы: 150 цветков трёх видов (setosa, versicolor, virginica), по 50 каждого. Признаки: длина и ширина чашелистика (sepal) и лепестка (petal) в сантиметрах. 70% (105 цветков) на обучение, 30% (45) на проверку.
- Узел
- Место в дереве, где лежит кучка объектов и задаётся вопрос.
- Корень
- Самый верхний узел, там вся обучающая выборка.
- Лист
- Узел без вопроса, в нём ответ: самый частый класс кучки.
- Предикат
- Вопрос в узле: [x_j < t], «признак j меньше порога t?». Да идёт налево, нет направо.
- IG, прирост информации
- Насколько уменьшился беспорядок после деления.
- Критерий остановки
- Правило «этот узел больше не делить».
- stratify
- При делении на train/test сохранить доли классов, чтобы в test было по 15 цветков каждого вида.
Как выбрать вопрос: прирост информации
Идея: беспорядок до деления минус беспорядок после. Но после деления две кучки разного размера, поэтому беспорядок каждой берём с весом «какая это часть от всех». Это и есть взвешенная формула, которую требует задание.
Попробуй руками
15 точек на одной оси: синие класс 0, оранжевые класс 1. Двигай порог t и смотри, как меняется прирост. Кнопка «Лучший порог» перебирает все t и берёт наибольший IG, так же делает best_split.
Критерии остановки
| Критерий | Смысл | Где в коде |
|---|---|---|
| max_depth = 5 | не больше 5 вопросов подряд от корня до листа | should_stop |
| min_samples_leaf = 5 | в каждом листе хотя бы 5 объектов | should_stop и отбор порогов в best_split |
| max_leaves = 5 | не больше 5 листьев во всём дереве | цикл в fit |
| purity | не делить чистый узел (все одного класса) | should_stop, min_impurity=0 |
Код, кусок 1: деление и прирост
impurity_functions = {'entropy': entropy, 'gini': gini_index, 'mse': mse}
def split_node(X, y, feature, threshold):
mask = (X[feature] < threshold).to_numpy()
return X[mask], y[mask], X[~mask], y[~mask]
def information_gain(y, y_left, y_right, impurity):
w_left, w_right = len(y_left) / len(y), len(y_right) / len(y)
return impurity(y) - (w_left * impurity(y_left) + w_right * impurity(y_right))
impurity_functionsсловарь «название → функция». Ссылки берутся сейчас: в задании 3 имяmseзаймёт другая функция с двумя аргументами, а дерево должно помнить старую.X[feature] < thresholdpandas сравнивает весь столбец с порогом, получается маска True/False.X[mask], X[~mask]строки, где True (налево), и где False (направо).~значит «наоборот».w_left, w_rightвеса: доля объектов в левой и правой части.impurity(y) - (...)ровно формула IG выше.impurityэто энтропия, Джини или MSE.
Код, кусок 2: узел и проверка остановки
class TreeNode:
def __init__(self, value, impurity, n_samples, depth):
self.value = value # ответ узла: самый частый класс
self.impurity = impurity
self.n_samples = n_samples
self.depth = depth
self.feature = None # лучший предикат и его прирост
self.threshold = None
self.gain = 0.0
self.left = None # пока потомков нет, узел является листом
self.right = None
def is_leaf(self):
return self.left is None
class TreeNode«карточка» узла: ответ, беспорядок, сколько объектов, глубина.feature, threshold, gainвопрос узла и его прирост; у листа их нет.left, rightссылки на детей. Нет детей значит лист (is_leaf).
def should_stop(self, y, depth):
too_deep = self.max_depth is not None and depth >= self.max_depth
too_small = len(y) < 2 * self.min_samples_leaf
pure = self.impurity(y) <= self.min_impurity
return too_deep or too_small or pure
too_deepдостигли максимальной глубины (если она задана).too_smallобъектов меньше, чем 2 × min_samples_leaf: тогда при любом делении одна половина будет слишком маленькой.pureбеспорядок не больше допуска. При допуске 0 это purity: все одного класса.
Код, кусок 3: поиск лучшего вопроса
def best_split(self, X, y):
best = (None, None, 0.0)
for feature in X.columns:
order = np.argsort(X[feature].to_numpy(), kind='stable')
values, y_sorted = X[feature].to_numpy()[order], y[order]
positions = np.flatnonzero(values[1:] != values[:-1]) + 1
positions = positions[(positions >= self.min_samples_leaf) & (positions <= len(y) - self.min_samples_leaf)]
for i in positions:
gain = information_gain(y, y_sorted[:i], y_sorted[i:], self.impurity)
if gain > best[2]:
best = (feature, (values[i - 1] + values[i]) / 2, gain)
return best
best = (None, None, 0.0)пока лучшего нет; деление принимаем, только если IG больше 0.for feature in X.columnsпо очереди каждый признак.np.argsortномера объектов по возрастанию признака. Сортируем значения и ответы в одном порядке.positionsместа, где значение меняется: только там есть смысл ставить порог. Например, значения 1.4, 1.4, 1.5: порог между двумя 1.4 ничего не делит.- вторая строка с
positionsоставляет только такие места, где слева и справа хотя бы min_samples_leaf объектов. y_sorted[:i], y_sorted[i:]первые i объектов идут налево, остальные направо. После сортировки это и есть деление «меньше порога / не меньше».(values[i - 1] + values[i]) / 2порог ставим посередине между соседними значениями, как sklearn.
Код, кусок 4: рост дерева и предсказание
def fit(self, X, y):
X = pd.DataFrame(X).reset_index(drop=True)
y = np.asarray(y)
self.root = self.create_node(X, y, depth=0)
self.n_leaves = 1
queue = [(self.root, X, y)] if self.root.gain > 0 else []
while queue and (self.max_leaves is None or self.n_leaves < self.max_leaves):
best = max(range(len(queue)), key=lambda k: queue[k][0].gain * queue[k][0].n_samples)
node, X_node, y_node = queue.pop(best)
X_left, y_left, X_right, y_right = split_node(X_node, y_node, node.feature, node.threshold)
node.left = self.create_node(X_left, y_left, node.depth + 1)
node.right = self.create_node(X_right, y_right, node.depth + 1)
self.n_leaves += 1
for child, X_child, y_child in [(node.left, X_left, y_left), (node.right, X_right, y_right)]:
if child.gain > 0:
queue.append((child, X_child, y_child))
return self
create_nodeделает узел: считает ответ и беспорядок, и если остановка не сработала, сразу ищет лучший вопрос.queueсписок листьев, которые ещё можно делить (у них нашёлся вопрос с IG > 0).whileпока есть что делить и листьев меньше лимита.max(..., key=gain * n_samples)первым делим лист, где деление уменьшает общий беспорядок сильнее всего. Так делает sklearn приmax_leaf_nodes.split_nodeделим кучку; дети создаются через тот жеcreate_node.n_leaves += 1один лист превратился в два: листьев стало на 1 больше.
def predict_one(self, row):
node = self.root
while not node.is_leaf():
node = node.left if row[node.feature] < node.threshold else node.right
return node.value
def predict(self, X):
X = pd.DataFrame(X)
return np.array([self.predict_one(row) for row in X.to_dict('records')])
predict_oneодин цветок спускается от корня: отвечает на вопрос, идёт налево или направо, пока не дойдёт до листа.X.to_dict('records')датафрейм превращается в список строк-словарей вида{'petal length (cm)': 1.4, ...}.predictпринимает датафрейм, как требует задание, и возвращает массив ответов.
Поиграй: проведи цветок по дереву
Это настоящее дерево из ноутбука (лимит 5 листьев). Двигай размеры цветка и смотри, по какому пути он идёт: так работает predict_one.
Результат
| Критерий | Энтропия | Джини | sklearn | Листьев | Глубина |
|---|---|---|---|---|---|
| max_depth = 5 | 0.933 | 0.911 | 0.889 | 7 | 5 |
| min_samples_leaf = 5 | 0.889 | 0.889 | 0.889 | 5 | 3 |
| max_leaves = 5 | 0.933 | 0.933 | 0.933 | 5 | 3 |
| purity | 0.933 | 0.911 | 0.889 | 8 | 6 |
- Accuracy = доля верных ответов. В test 45 цветков, одна ошибка = 0.022. 0.933 это 42 из 45, 0.889 это 40 из 45.
- Число листьев и глубина совпали с sklearn во всех 4 вариантах: алгоритм тот же.
- Разница в 1-2 цветка из-за ничьих: в корне и «petal length < 2.45», и «petal width < 0.8» одинаково идеально отделяют setosa. Мы берём первый найденный признак, sklearn перебирает признаки в случайном порядке.
- Ошибки только между versicolor и virginica, setosa отделяется без ошибок.
petal length (cm) < 2.45 ? (объектов 105, хаос 1.585)
да: ответ: 0 (объектов 35, хаос 0.000)
нет: petal width (cm) < 1.55 ? (объектов 70, хаос 1.000)
да: petal length (cm) < 4.95 ? (объектов 34, хаос 0.191)
да: ответ: 1 (объектов 33, хаос 0.000)
нет: ответ: 2 (объектов 1, хаос 0.000)
нет: sepal length (cm) < 6.10 ? (объектов 36, хаос 0.310)
да: ответ: 2 (объектов 8, хаос 0.811)
нет: ответ: 2 (объектов 28, хаос 0.000)
Так выглядит наше дерево с лимитом 5 листьев (печатает метод print_tree). Ответ 0 = setosa, 1 = versicolor, 2 = virginica.
Порог делит 100 объектов на 1 и 99. Слева H = 0, справа H почти как до деления. Какой будет взвешенный IG?
Беспорядок левой части берётся с весом 1/100, правой с весом 99/100. Почти ничего не изменилось, поэтому IG почти 0. В этом смысл весов.
Для какого критерия остановки важно растить дерево «сначала там, где полезнее»?
При лимите листьев важно, какие листья делить первыми. Поэтому в fit очередь: первым делится лист с наибольшим IG × размер.
Вопросы на защите
Где у тебя отдельные функции, которые требует задание?
Энтропия: entropy. Разделение узла через pandas: split_node. Прирост: information_gain. Лучшее разделение: best_split. Остановка: should_stop плюс проверка числа листьев в fit.
Почему порог посередине между значениями?
Любой порог между двумя соседними значениями делит обучающие объекты одинаково. Середина самый «нейтральный» выбор для новых объектов, так же делает sklearn.
Тут можно было без цикла по порогам?
Да. После сортировки можно посчитать накопленные количества классов (np.cumsum по one-hot ответам) и получить энтропию сразу для всех порогов одной формулой. Я оставил перебор, потому что условие прямо предлагает «перебрать t», а функционалы (энтропия, IG) остались отдельными функциями, как требуется. Сами функционалы без циклов.
Почему дерево без ограничений не лучше?
Оно делит до полностью чистых листьев и подстраивается под отдельные цветки (переобучение). На test оно 0.933, как и дерево с 5 листьями: лишние вопросы ничего не дают.
Что значит взвешенная формула IG?
Беспорядок каждой половины умножаем на её долю объектов. Иначе отрезать один объект в «чистую» половину выглядело бы выгодно, хотя почти ничего не меняет.