Machine Learning · Практика
Деревья решений
на Python
От первой строчки import до собственной реализации алгоритма с нуля. Двумя путями придём к одному результату.
Этот документ — практическое продолжение теории. Мы дважды построим дерево решений на одних и тех же данных. Сначала — быстрым промышленным способом с библиотекой scikit-learn. Затем — напишем алгоритм самостоятельно, чтобы понять, что происходит внутри.
Набор данных — классический Титаник. Задача: по полу, возрасту и классу билета предсказать, выжил ли пассажир.
Готовая реализация в scikit-learn
Scikit-learn (сокращённо sklearn) — стандартная библиотека машинного обучения на Python. В ней уже реализованы десятки алгоритмов, включая деревья решений. Начнём с неё, чтобы быстро получить работающую модель.
01Подготовка среды
Импортируем нужные библиотеки. Делаем это один раз в начале — потом все инструменты будут доступны.
[1] Импортыpython
import pandas as pd import matplotlib.pyplot as plt from sklearn.tree import DecisionTreeClassifier, plot_tree from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score, confusion_matrix
Что для чего нужно:
pandas — для работы с таблицами данных
matplotlib — для рисования графиков и дерева
DecisionTreeClassifier — сам алгоритм
plot_tree — функция для визуализации
train_test_split — делит данные на обучение и тест
accuracy_score, confusion_matrix — метрики качества
02Загрузка данных
Датасет Титаника лежит в открытом доступе на GitHub. Скачаем его прямо оттуда — это быстрее, чем искать файл локально.
[2] Загрузка CSVpython
# URL с данными о пассажирах Титаника url = "https://raw.githubusercontent.com/datasciencedojo/datasets/master/titanic.csv" # Читаем CSV-файл прямо из интернета df = pd.read_csv(url) print(f"Загружено строк: {len(df)}") print(f"Столбцов: {df.shape[1]}")
Вывод
Загружено строк: 891 Столбцов: 12
03Первый взгляд на данные
Прежде чем что-то обучать, всегда смотрим, что у нас в руках. Посмотрим первые пять строк:
[3] Первые строкиpython
df.head()
Вывод
PassengerId Survived Pclass ... Sex Age Fare 0 1 0 3 ... male 22.0 7.2500 1 2 1 1 ... female 38.0 71.2833 2 3 1 3 ... female 26.0 7.9250 3 4 1 1 ... female 35.0 53.1000 4 5 0 3 ... male 35.0 8.0500
Главные столбцы:
Survived — что предсказываем (1 = выжил, 0 = погиб)
Pclass — класс билета (1, 2, 3)
Sex — пол
Age — возраст
Проверим, сколько пропусков в данных:
[4] Пропускиpython
df[['Survived', 'Pclass', 'Sex', 'Age']].isnull().sum()
Вывод
Survived 0 Pclass 0 Sex 0 Age 177
В столбце Age пропущено 177 значений. Простейшее решение — убрать строки с пропусками. На реальных задачах часто заполняют пропуски средним или медианой, но сейчас мы хотим простоты.
04Подготовка признаков
Надо сделать две вещи: убрать пропуски и преобразовать текстовый столбец Sex в числа (алгоритмы машинного обучения работают только с числами).
[5] Подготовкаpython
# Берём только нужные столбцы и убираем строки с пропусками df = df[['Survived', 'Pclass', 'Sex', 'Age']].dropna() # Пол — текст, переводим в числа: male = 0, female = 1 df['Sex'] = (df['Sex'] == 'female').astype(int) # X — признаки (то, на чём учимся) # y — целевая переменная (то, что предсказываем) X = df[['Pclass', 'Sex', 'Age']] y = df['Survived'] print(f"Осталось строк: {len(df)}")
Вывод
Осталось строк: 714
Почему X и y?
Это стандартное математическое обозначение. X (большая) — матрица признаков (обычно много столбцов). y (маленькая) — вектор ответов (один столбец). Это соглашение встречается во всех курсах и книгах по ML.
05Разделение выборки
Нельзя обучать модель и проверять её на одних и тех же данных — она их просто запомнит. Делим данные на две части: на одной учимся, на другой проверяем.
[6] train / test splitpython
# 80% — для обучения, 20% — для проверки X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, # для воспроизводимости stratify=y # чтобы доли классов были одинаковыми ) print(f"Обучение: {len(X_train)} пассажиров") print(f"Тест: {len(X_test)} пассажиров")
Вывод
Обучение: 571 пассажиров Тест: 143 пассажиров
random_state
Параметр random_state=42 фиксирует генератор случайных чисел. Это значит, что при каждом запуске мы получим одно и то же разбиение — удобно для отладки и воспроизведения результатов.
06Обучение модели
Теперь — главное. Всего три строки кода:
[7] Обучениеpython
# Создаём дерево с максимальной глубиной 3 model = DecisionTreeClassifier( max_depth=3, random_state=42 ) # Обучаем на тренировочных данных model.fit(X_train, y_train) print("Модель обучена!")
Вывод
Модель обучена!
Всё. За доли секунды алгоритм построил дерево — перебрал все возможные вопросы, нашёл оптимальные пороги, построил структуру. Нам остаётся только её использовать.
07Оценка качества
Насколько хорошо модель работает? Сравним её предсказания с реальными ответами на тестовой выборке.
[8] Точностьpython
# Получаем предсказания на тесте y_pred = model.predict(X_test) # Считаем точность acc = accuracy_score(y_test, y_pred) print(f"Точность на тесте: {acc:.1%}") # Смотрим на матрицу ошибок cm = confusion_matrix(y_test, y_pred) print("\nМатрица ошибок:") print(f" Правильно погиб: {cm[0][0]}") print(f" Правильно выжил: {cm[1][1]}") print(f" Ошибочно выжил: {cm[0][1]}") print(f" Ошибочно погиб: {cm[1][0]}")
Вывод
Точность на тесте: 79.7% Матрица ошибок: Правильно погиб: 79 Правильно выжил: 35 Ошибочно выжил: 5 Ошибочно погиб: 24
Почти 80% — неплохой результат для такой простой модели с тремя признаками. Для сравнения: если просто угадывать «все погибли», получилось бы ~60% точности.
08Предсказание для одного человека
Используем обученную модель, чтобы предсказать судьбу конкретного пассажира.
[9] Предсказание для нового пассажираpython
# Мужчина, 25 лет, 3-й класс # [Pclass, Sex, Age] — порядок должен совпадать с X passenger = [[3, 0, 25]] # Предсказание: класс result = model.predict(passenger)[0] print("Предсказание:", "Выжил" if result == 1 else "Погиб") # Предсказание: вероятности proba = model.predict_proba(passenger)[0] print(f"Вероятность погибнуть: {proba[0]:.1%}") print(f"Вероятность выжить: {proba[1]:.1%}")
Вывод
Предсказание: Погиб Вероятность погибнуть: 82.1% Вероятность выжить: 17.9%
09Визуализация дерева
Главное преимущество деревьев — их можно нарисовать и увидеть логику решения.
[10] Рисуем деревоpython
plt.figure(figsize=(16, 8)) plot_tree( model, feature_names=['Pclass', 'Sex', 'Age'], class_names=['Погиб', 'Выжил'], filled=True, # заливка по классу rounded=True, # закруглённые углы fontsize=10 ) plt.show()
В результате появится нарисованное дерево: в каждом узле видно условие (например, Sex ≤ 0.5), Gini, количество пассажиров и распределение классов. Это наглядный способ объяснить модель человеку, который не разбирается в ML.
10Важность признаков
Какие признаки оказались самыми полезными? Дерево само это знает:
[11] feature_importances_python
features = ['Pclass', 'Sex', 'Age'] importances = model.feature_importances_ # Выводим отсортированные по важности for i in range(len(features)): print(f"{features[i]:10s} — {importances[i]:.2%}")
Вывод
Pclass — 14.21% Sex — 66.83% Age — 18.96%
Пол оказался самым важным признаком — на него «приходится» 67% решений дерева. Это согласуется с исторической правдой: женщин сажали в шлюпки первыми.
11Влияние max_depth
Главный гиперпараметр дерева — максимальная глубина. Проверим, как она влияет на точность:
[12] Эксперимент с глубинойpython
train_scores = [] test_scores = [] depths = range(1, 16) for d in depths: m = DecisionTreeClassifier(max_depth=d, random_state=42) m.fit(X_train, y_train) train_acc = m.score(X_train, y_train) test_acc = m.score(X_test, y_test) train_scores.append(train_acc) test_scores.append(test_acc) print(f"depth={d:2d} обучение={train_acc:.1%} тест={test_acc:.1%}")
Вывод (фрагмент)
depth= 1 обучение=78.6% тест=77.6% depth= 2 обучение=80.0% тест=79.0% depth= 3 обучение=83.2% тест=79.7% depth= 5 обучение=86.5% тест=80.4% depth= 8 обучение=93.2% тест=78.3% depth=15 обучение=98.1% тест=74.8%
Переобучение в действии
Видно: с ростом глубины точность на обучении монотонно растёт (вплоть до 98%), а на тесте сначала растёт, потом падает. Это классическая картина переобучения — модель зазубривает тренировочные данные и теряет способность обобщать.
Нарисуем это на графике:
[13] График переобученияpython
plt.figure(figsize=(10, 5)) plt.plot(depths, train_scores, 'o-', label='Обучение') plt.plot(depths, test_scores, 's-', label='Тест') plt.xlabel('max_depth') plt.ylabel('Точность') plt.title('Переобучение с ростом глубины') plt.legend() plt.grid(alpha=0.3) plt.show()
На графике будет видно, как две линии сначала идут вместе, а затем расходятся: обучение продолжает расти, тест — падает. Оптимальная глубина для этих данных — около 4–5.
ЧАСТЬ IIРеализация с нуля
12Зачем писать самим
Sklearn удобен, но работает как чёрный ящик. Напишем свою версию алгоритма — всего 50 строк кода. Это поможет понять, что происходит внутри.
Наша задача: сделать функции, которые на вход получают данные, а на выходе дают обученное дерево. Будем использовать обычные Python-списки и словари, без сложных структур данных.
Понять алгоритм — значит уметь его написать самому.
13Метрика Gini
Начнём с самого простого — функции подсчёта индекса Джини.
[14] Функция ginipython
def gini(labels): """Считает Gini для списка меток (0 и 1)""" if len(labels) == 0: return 0 # Считаем, сколько объектов каждого класса count_0 = 0 count_1 = 0 for label in labels: if label == 0: count_0 += 1 else: count_1 += 1 # Доли классов n = len(labels) p0 = count_0 / n p1 = count_1 / n return 1 - p0**2 - p1**2 # Проверяем: идеальная чистота vs полный хаос print("Все нули: ", gini([0, 0, 0, 0])) print("Пополам: ", gini([0, 0, 1, 1])) print("Почти все нули:", gini([0, 0, 0, 1]))
Вывод
Все нули: 0.0 Пополам: 0.5 Почти все нули: 0.375
Работает как ожидалось: идеальная группа — 0, перемешанная пополам — 0,5.
14Поиск лучшего разбиения
Вот главная функция алгоритма. Она перебирает все возможные вопросы и выбирает тот, что даёт минимальный взвешенный Gini.
[15] Функция best_splitpython
def best_split(X, y): """Находит лучший признак и порог для разбиения""" best_gini = 1.0 # чем меньше — тем лучше best_feature = None best_threshold = None n_samples = len(X) n_features = len(X[0]) # Перебираем все признаки for feature in range(n_features): # Собираем уникальные значения этого признака values = sorted(set(X[i][feature] for i in range(n_samples))) # Перебираем пороги как середины между соседними значениями for i in range(len(values) - 1): threshold = (values[i] + values[i+1]) / 2 # Делим метки на левые и правые left = [] right = [] for j in range(n_samples): if X[j][feature] <= threshold: left.append(y[j]) else: right.append(y[j]) # Пропускаем неинформативные разбиения if len(left) == 0 or len(right) == 0: continue # Взвешенный Gini после разбиения w_left = len(left) / n_samples w_right = len(right) / n_samples total_gini = w_left * gini(left) + w_right * gini(right) # Запоминаем лучший вариант if total_gini < best_gini: best_gini = total_gini best_feature = feature best_threshold = threshold return best_feature, best_threshold
Эта функция — «сердце» алгоритма. Её вычислительная сложность растёт как количество признаков умноженное на количество точек. Для сотен-тысяч объектов это быстро, для миллионов — уже медленно.
15Построение дерева
Теперь — рекурсия. Дерево будем хранить как вложенный словарь.
[16] Функция build_treepython
def majority_class(y): """Возвращает самый частый класс""" count_0 = 0 for label in y: if label == 0: count_0 += 1 count_1 = len(y) - count_0 return 0 if count_0 >= count_1 else 1 def build_tree(X, y, depth=0, max_depth=3): """Рекурсивно строит дерево решений""" # Условие остановки 1: все метки одного класса if len(set(y)) == 1: return {'leaf': True, 'label': y[0]} # Условие остановки 2: достигли максимальной глубины if depth >= max_depth: return {'leaf': True, 'label': majority_class(y)} # Ищем лучшее разбиение feature, threshold = best_split(X, y) # Если разбиение не нашлось — делаем лист if feature is None: return {'leaf': True, 'label': majority_class(y)} # Делим данные на левую и правую части X_left, y_left = [], [] X_right, y_right = [], [] for i in range(len(X)): if X[i][feature] <= threshold: X_left.append(X[i]) y_left.append(y[i]) else: X_right.append(X[i]) y_right.append(y[i]) # Рекурсивно строим поддеревья return { 'leaf': False, 'feature': feature, 'threshold': threshold, 'left': build_tree(X_left, y_left, depth + 1, max_depth), 'right': build_tree(X_right, y_right, depth + 1, max_depth) }
Как выглядит дерево в памяти
Каждый узел — это словарь с ключом 'leaf'. Если True, это лист с ответом 'label'. Если False, это внутренний узел с условием и двумя поддеревьями 'left' / 'right'. Вложенные словари образуют древовидную структуру.
16Функция предсказания
Чтобы получить предсказание, надо спуститься по дереву сверху вниз.
[17] Функция predictpython
def predict_one(tree, x): """Предсказание для одного объекта""" # Если пришли в лист — возвращаем ответ if tree['leaf']: return tree['label'] # Иначе — идём влево или вправо по условию if x[tree['feature']] <= tree['threshold']: return predict_one(tree['left'], x) else: return predict_one(tree['right'], x) def predict(tree, X): """Предсказание для всей выборки""" predictions = [] for i in range(len(X)): pred = predict_one(tree, X[i]) predictions.append(pred) return predictions
17Проверка результата
Обучим наше дерево на тех же данных Титаника и сравним с результатом sklearn.
[18] Запуск нашего дереваpython
# Переводим pandas DataFrame в обычные списки X_train_list = X_train.values.tolist() y_train_list = y_train.tolist() X_test_list = X_test.values.tolist() y_test_list = y_test.tolist() # Строим дерево с глубиной 3 (как sklearn) my_tree = build_tree(X_train_list, y_train_list, max_depth=3) # Делаем предсказания my_pred = predict(my_tree, X_test_list) # Считаем точность correct = 0 for i in range(len(y_test_list)): if my_pred[i] == y_test_list[i]: correct += 1 my_acc = correct / len(y_test_list) print(f"Наше дерево: {my_acc:.1%}") print(f"Sklearn дерево: {acc:.1%}")
Вывод
Наше дерево: 79.0% Sklearn дерево: 79.7%
Результат
Наше дерево работает практически так же! Небольшая разница возможна, потому что sklearn использует более продвинутые стратегии выбора порогов и обработки ничьих. Но ядро алгоритма мы воспроизвели правильно.
Посмотрим на структуру нашего дерева:
[19] Печать дереваpython
feature_names = ['Pclass', 'Sex', 'Age'] def print_tree(tree, depth=0): indent = " " * depth if tree['leaf']: answer = "Выжил" if tree['label'] == 1 else "Погиб" print(f"{indent}→ {answer}") else: name = feature_names[tree['feature']] t = tree['threshold'] print(f"{indent}{name} <= {t:.2f}?") print(f"{indent} ДА:") print_tree(tree['left'], depth + 2) print(f"{indent} НЕТ:") print_tree(tree['right'], depth + 2) print_tree(my_tree)
Вывод
Sex <= 0.50? ДА: Age <= 6.50? ДА: Pclass <= 2.50? ДА: → Выжил НЕТ: → Погиб НЕТ: → Погиб НЕТ: Pclass <= 2.50? ДА: → Выжил НЕТ: Age <= 38.50? ДА: → Выжил НЕТ: → Погиб
Вот и вся логика модели — в виде обычного текста. Каждую строку можно прочитать как «если-то-иначе».
ЧАСТЬ IIIЗакрепление
18Практические задания
Попробуйте самостоятельно:
- Добавьте признак
Fare (цена билета) в модель. Изменится ли точность?
- Найдите оптимальную глубину. Постройте дерево с
max_depth от 1 до 10 и найдите ту, где точность на тесте максимальна.
- Добавьте в
best_split прирост информации. Сейчас функция выбирает разбиение по минимальному Gini. Перепишите так, чтобы выбирать по максимальному Gain = gini(родитель) − взвешенный gini(дети). Результат должен быть таким же — убедитесь на эксперименте.
- Обработайте пропуски в
Age иначе — не удаляя строки, а заполняя средним возрастом. Как это повлияет на точность?
- Попробуйте другой датасет — например,
sklearn.datasets.load_iris() (цветы ириса, 3 класса). Потребуется немного доработать функцию для нескольких классов.
Интерактивная проверка
В интерактивной лаборатории (отдельный документ) можно поэкспериментировать со своими данными и увидеть, как дерево режет пространство признаков на зоны. Сверьте: реализует ли лаборатория то же самое, что наш код?
19Что мы узнали
Мы построили дерево решений двумя способами:
| |
Sklearn |
С нуля |
| Строк кода |
~15 |
~50 |
| Время написания |
5 минут |
час |
| Производительность |
высокая |
низкая |
| Понимание алгоритма |
поверхностное |
глубокое |
| Когда использовать |
в реальных задачах |
для обучения |
В жизни вы будете пользоваться sklearn. Но понимание того, что происходит внутри, отличает специалиста от любителя. Когда sklearn даст неожиданный результат, вы сможете разобраться почему.
Лучший способ понять алгоритм — реализовать его самому.
А потом спокойно пользоваться готовой библиотекой.
Что дальше
- Random Forest — много деревьев, обученных на случайных подвыборках. В sklearn:
RandomForestClassifier. Точность обычно на 3–7% выше, чем у одного дерева.
- Gradient Boosting — деревья, исправляющие ошибки друг друга. Библиотеки:
XGBoost, LightGBM, CatBoost. Это лидеры соревнований на Kaggle.
- Метрики качества — точность (accuracy) не всегда подходит. Precision, recall, F1, ROC-AUC — следующий обязательный шаг в обучении ML.
✦ ✦ ✦