У каждого наступает момент, когда нужно быстро освежить в памяти огромный пласт информации по всему ML. Причины разные - подготовка к собеседованию, начало преподавания или просто найти вдохновение.

Времени мало, объема много, цели амбициозные - нужно научиться легко и быстро объяснять, но так же не лишая полноты!

? Обращу внимание, самый действенный способ разобраться и запомнить - это своими руками поисследовать задачу! Это самое важное, оно происходит в секции с кодом. Поэтому попробуйте сами решить предложенную задачку и придумать свою!

Будет здорово получить ваши задачи и в следующих выпусках разобрать!

Мы продолжаем. Обязательно испытайте себя в предыдущей [1] части!

Я считаю самый полный и простой способ заполнить все пробелы - это взять хороший экзамен и ответить на все вопросы - понятно и быстро. А что бы запомнилось лучше - решить задачку. Приступим!

Сначала попробуйте сами быстро ответить, а потом после просмотра! Стало быстрее-понятнее объяснять?

Для более полного погружения в конце приложу важные ресурсы. Делитесь своими!


11. Анализ главных компонент. Связь с SVD. Теорема Эккарта-Янга. Как применять PCA на практике.

? Краткий ответ

PCA (Principal Component Analysis или анализ главных компонент) — метод понижения размерности, который:

  • Решает задачу апроксимации исходной матрицы, матрицами меньшего ранга (как например ALS)

  • Ее можно решать как задачу оптимизации функции ошибки MSE или конструктивно с SVD

  • Еще одна итерпретация - предсказание исходной матрицы скалярным произведением латентных векторов юзера и товара

  • Ищет такие оси (направления), вдоль которых данные имеют максимальную дисперсию

  • Проецирует данные на первые k таких осей (компонент), сохраняя как можно больше информации (в смысле дисперсии) - почему? идем к SVD

  • Реализуется через SVD (Singular value decomposition или сингулярное разложение) - разложение матрицы на две ортонормированные [поворот] и диагональную [растяжение] (с сингулярными числами=важность/дисперсия нового признака)

  • Строки у разжатия можно перемещать (соответвенно, что финальная матрица не изменится) \Rightarrow можно оставить только самые важные признаки!

  • Теорема Эккарта-Янга - утверждает, такая апроксимация внутри своего ранга по норме Фробениуса наименьшая!

? Подробный разбор

На одном дыхании можно послушать тут или почитать хендбук. Отличный английский видеоформат .

? Сжимаем картинку через SVD и смотрим на важность направлений

Научимся представлять картинку меньшим кол-вом памяти с помощью SVD !

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import load_sample_image
from skimage.color import rgb2gray
from skimage.transform import resize

# --- Загружаем и обрабатываем картинку
china = load_sample_image("china.jpg")   # встроенное изображение
gray = rgb2gray(china)                   # перевод в ч/б
gray = resize(gray, (256, 256), anti_aliasing=True)  # уменьшаем до квадрата

# --- SVD
U, S, VT = np.linalg.svd(gray, full_matrices=False)

# --- Восстановление при разных k
ks = [5, 20, 50, 100, 200]
fig, axes = plt.subplots(1, len(ks) + 1, figsize=(15, 4))

# Оригинал
axes[0].imshow(gray, cmap='gray')
axes[0].set_title("Оригинал")
axes[0].axis('off')

# При разных k
for i, k in enumerate(ks):
    approx = U[:, :k] @ np.diag(S[:k]) @ VT[:k, :]
    axes[i + 1].imshow(approx, cmap='gray')
    axes[i + 1].set_title(f"k = {k}")
    axes[i + 1].axis('off')

plt.suptitle("Сжатие изображения с помощью SVD")
plt.tight_layout()
plt.show()
# Расчёт накопленной доли дисперсии
explained = np.cumsum(S) / np.sum(S)

# Отрисовка: сингулярные числа + накопленная доля
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))

# --- 1. Сами сингулярные числа (в лог масштабе)
ax1.plot(S, marker='o')
ax1.set_yscale("log")
ax1.set_title("Сингулярные значения")
ax1.set_xlabel("Номер компоненты")
ax1.set_ylabel("Значение (log)")
ax1.grid(True)

# --- 2. Накопленная доля дисперсии
ax2.plot(explained, label='Cуммарная доля дисперсии')
ax2.axhline(0.9, color='red', linestyle='--', label='90% информации')
ax2.set_xlabel("Число компонент")
ax2.set_ylabel("Накопленная доля")
ax2.set_title("Сколько информации несут сингулярные значения")
ax2.grid(True)
ax2.legend()

plt.tight_layout()
plt.show()
? Проецируем многомерные данные (картинки цифр) в 2D с помощью PCA

Визуализируем датасет рукописных цифр (digits) — в нём 64 признака (8×8 картинка), и сожмём до 2 главных компонент, чтобы посмотреть, как PCA группирует похожие объекты:

from sklearn.datasets import load_digits
from sklearn.decomposition import PCA
import matplotlib.pyplot as plt

# --- Загружаем данные
digits = load_digits()
X = digits.data       # 64 признака (8x8 пикселей)
y = digits.target     # метки (0-9)

# --- PCA до 2 компонент
pca = PCA(n_components=2)
X_pca = pca.fit_transform(X)

# --- Визуализация
plt.figure(figsize=(8, 6))
scatter = plt.scatter(X_pca[:, 0], X_pca[:, 1], c=y, cmap='tab10', s=15, alpha=0.8)
plt.legend(*scatter.legend_elements(), title="Цифры", loc="best", bbox_to_anchor=(1.05, 1))
plt.title("PCA-проекция данных (64D → 2D)")
plt.xlabel("1-я главная компонента")
plt.ylabel("2-я главная компонента")
plt.grid(True)
plt.tight_layout()
plt.show()

12. Этапы обучения, валидации и тестирования модели. Проблема переобучения, способы её обнаружения.

? Краткий ответ

Обучение ML-модели делится на три ключевых этапа:

  1. Обучение (train) — модель подбирает параметры на обучающей выборке.

  2. Валидация (validation) — подбираем гиперпараметры на валидационной выборке.

  3. Тест (test) — финальная оценка качества, только один раз.

Бывают, что тест опускают и финальное качество берут лучшее с валидации, но это ведет к переобучению гиперпараметров.


Переобучение — это ситуация, когда модель слишком хорошо подгоняется под обучающую выборку, но плохо обобщает на новые данные.

Чтобы меньше зависеть от разбиение train/val/test используют обучение по фолдам (k-fold cross-validation) - Обучается k моделей. Датасет делиться на k частей, на k - 1 модель учиться, на оставшейся замеряется качество. Метрика усредняется.

Такой подход подход хоть устойчевее к шуму, но не всегда хочется делать бэггинг над этими k моделей. Обычно в начале EDA (exploratory data analysis или разведочный анализ) проверяют, что разбиение train/val/test подходит и учат одну модель.


Как понять, что модель начала переобучаться? (если неграмотно сохранять чекпоинты, можно остаться с переобученной моделью!)

  • Метрика на train продолжает падать, а на val начала возрастать. (Если нету графиков, то метрика на train сильно лучше, чем на val)

Как бороться?

  • Early stopping (если метрика на валидации не уменьшалась k шагов - остановиться)

  • Регуляризации - L_1/L_2, Dropout/BatchNorm, аугментация данных и тд

  • Выбросить совсем незначащие признаки (если их очень много, модель будет на них отвлекаться, может и сойдется, но дОльше!)

  • Больше данных насыпать

  • Бэггинг (Bagging, от bootstrap aggregating) - учим несколько моделей на бутсреп данных и объединяем => уменьшаем дисперсию ответа

Классическая симптомы переобучения (можно раньше времени спутать с мифическим Double Descent)

? Подробный разбор

Оказывается, не стоит раньше времени останавливать обучения, может быть у вас Double Descent. Ситуация в которой лосс на валидации взлетит, а потом начнет уменьшаться до меньших значений!

Я не знаю на практике у кого такое было. Но как явления очень интересное.

Подробнее про Double Descent можно почитать 1 и 2.

? Пример переобучения: наблюдаем за лоссом и предсказаниями

Смотрим, что происходит при увеличением эпох с лоссом и предсказаниями, если признаковое пространство недостаточно покрыто данными.

Я специалньо взял не очень много точек (=150), потому что данные достаточно очень легко устроены/разъединяются и при большем не наблюдается эффекта переобучения.

import torch
import torch.nn as nn
import torch.optim as optim
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
import matplotlib.pyplot as plt
import numpy as np

# --- 1. Данные
X, y_true = make_moons(n_samples=150, noise=0.3, random_state=42)
# y_random = np.random.permutation(y_true)  # случайные метки!

# X_train, X_val, y_train, y_val = train_test_split(X, y_random, test_size=0.3, random_state=42)
X_train, X_val, y_train, y_val = train_test_split(X, y_true, test_size=0.3, random_state=42)

X_train_t = torch.tensor(X_train, dtype=torch.float32)
y_train_t = torch.tensor(y_train.reshape(-1, 1), dtype=torch.float32)

X_val_t = torch.tensor(X_val, dtype=torch.float32)
y_val_t = torch.tensor(y_val.reshape(-1, 1), dtype=torch.float32)

# --- 2. Модель
# h_size = 256
h_size = 100
model = nn.Sequential(
    nn.Linear(2, h_size),
    nn.ReLU(),
    nn.Linear(h_size, h_size),
    nn.ReLU(),
    nn.Linear(h_size, 1),
    nn.Sigmoid()
)

loss_fn = nn.BCELoss()
optimizer = optim.Adam(model.parameters(), lr=0.01)

train_losses, val_losses = [], []
snapshots = [0, 10, 30, 99]  # эпохи, для которых сохраним визуализацию

grids = np.meshgrid(
    np.linspace(X[:, 0].min() - 0.5, X[:, 0].max() + 0.5, 300),
    np.linspace(X[:, 1].min() - 0.5, X[:, 1].max() + 0.5, 300)
)
grid_points = np.c_[grids[0].ravel(), grids[1].ravel()]
grid_tensor = torch.tensor(grid_points, dtype=torch.float32)

# --- 3. Обучение
snapshots_preds = []

for epoch in range(100):
    model.train()
    y_pred = model(X_train_t)
    loss = loss_fn(y_pred, y_train_t)
    
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

    model.eval()
    with torch.no_grad():
        val_pred = model(X_val_t)
        val_loss = loss_fn(val_pred, y_val_t)
        if epoch in snapshots:
            pred_grid = model(grid_tensor).reshape(300, 300)
            snapshots_preds.append((epoch, pred_grid.numpy()))
    
    train_losses.append(loss.item())
    val_losses.append(val_loss.item())

# --- 4. График потерь
plt.figure(figsize=(8, 5))
plt.plot(train_losses, label='Train Loss')
plt.plot(val_losses, label='Validation Loss')
plt.axvline(np.argmin(val_losses), linestyle='--', color='gray', label='Лучшая эпоха (val)')
plt.title("Переобучение: валидационная ошибка начинает расти")
plt.xlabel("Эпоха")
plt.ylabel("Binary CrossEntropy")
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()

# --- 5. Визуализация данных + предсказания
fig, axes = plt.subplots(1, len(snapshots_preds), figsize=(16, 4))
for ax, (epoch, Z) in zip(axes, snapshots_preds):
    ax.contourf(grids[0], grids[1], Z, levels=20, cmap='coolwarm', alpha=0.7)
    ax.scatter(*X_train.T, c=y_train, cmap='bwr', s=15, edgecolor='k', label='Train')
    ax.scatter(*X_val.T, c=y_val, cmap='bwr', marker='x', s=15, label='Val')
    ax.set_title(f"Эпоха {epoch}")
    ax.axis('off')
plt.suptitle("Как модель обучается на шум: границы решений со временем")
plt.tight_layout()
plt.show()
? Переобучение полинома большой степени
# Повторный запуск после сброса состояния
import numpy as np
import matplotlib.pyplot as plt
from sklearn.preprocessing import PolynomialFeatures
from sklearn.linear_model import LinearRegression
from sklearn.pipeline import make_pipeline
from sklearn.metrics import mean_squared_error
from sklearn.model_selection import train_test_split

# --- 1. Генерируем данные
np.random.seed(42)
X = np.linspace(-3, 3, 10).reshape(-1, 1)  # мало точек
y = np.sin(X).ravel() + 0.3 * np.random.randn(*X.shape).ravel()

X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.3, random_state=42)

# --- 2. Обучаем модели с разной степенью полинома
degrees = [1, 4, 15]
preds = {}
val_errors = {}

x_plot = np.linspace(-3, 3, 300).reshape(-1, 1)

plt.figure(figsize=(15, 4))

for i, deg in enumerate(degrees):
    model = make_pipeline(PolynomialFeatures(degree=deg), LinearRegression())
    model.fit(X_train, y_train)
    
    y_plot = model.predict(x_plot)
    y_val_pred = model.predict(X_val)
    val_errors[deg] = mean_squared_error(y_val, y_val_pred)
    
    plt.subplot(1, 3, i + 1)
    plt.scatter(X_train, y_train, label='Train', color='blue')
    plt.scatter(X_val, y_val, label='Val', color='red', alpha=0.5)
    plt.plot(x_plot, y_plot, color='green', label=f"Poly deg={deg}")
    plt.title(f"deg={deg} | val MSE={val_errors[deg]:.2f}")
    plt.legend()
    plt.grid(True)

plt.suptitle("Сравнение моделей с разной степенью полинома")
plt.tight_layout()
plt.show()

val_errors
# {1: 0.0623793189026051, 4: 0.16693688909602888, 15: 714985.2317858242}

13. Стратегии валидации. Кросс-валидация. Утечки данных.

? Краткий ответ

Валидация — способ понять, как модель будет работать на новых данных и сравнить модели друг с другом.

Основные стратегии:

  • Hold-Out — просто делим на train/val.

  • K-Fold — учим k моделей, валидируем по очереди на каждом фолде.

  • Stratified K-Fold — сохраняем пропорции классов.

  • TimeSeriesSplit — используем прошлое, предсказываем будущее.


Data leakage (утечка данных) — модель видит информацию, которую не должна:

  • Масштабирование или выбор признаков до train/val split

  • Использование target в признаках

  • Временная утечка

Обычно модель на своих данных показывает высокую метрику, а на продовых сильно хуже.

? Демонстрация val утечки в train. Предварительный отбор признаков SelectKBest на train VS train + val.

Удивительно, что даже с кроссвалидацией утечка сильно влияет на финальную метрику!

import numpy as np
from sklearn.datasets import make_classification
from sklearn.model_selection import cross_val_score
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.ensemble import RandomForestClassifier
from sklearn.pipeline import make_pipeline

# --- Данные: много признаков, немного информативных
X, y = make_classification(
    n_samples=1000, n_features=500, n_informative=30,
    random_state=42, shuffle=False
)

# --- ⚠️ Утечка: отбор признаков ДО cross-validation
selector = SelectKBest(score_func=f_classif, k=50)
X_leak = selector.fit_transform(X, y)

model = RandomForestClassifier(random_state=42)
scores_leak = cross_val_score(model, X_leak, y, cv=5)

# --- ✅ Честно: отбор фич ВНУТРИ кросс-валидации
pipeline = make_pipeline(
    SelectKBest(score_func=f_classif, k=50),
    RandomForestClassifier(random_state=42)
)
scores_clean = cross_val_score(pipeline, X, y, cv=5)

print(f"⚠️ CV accuracy с утечкой:  {scores_leak.mean():.3f}")
print(f"✅ CV accuracy без утечки: {scores_clean.mean():.3f}")

# ⚠️ CV accuracy с утечкой:  0.787
# ✅ CV accuracy без утечки: 0.754

14. Компромисс смещения-дисперсии (Bias-variance trade-off). Double Descent.

? Краткий ответ

Это разложение ошибки (и способ ее формально определить!) (на тестовой выборке) на три компоненты: bias, variance, noise (на нее не влияем). (Текущая формула верна для MSE, но аналоги существуют и для других!)

Показывает как выбрать оптимальную модель при некоторых предположениях: при усложнении модели bias падает, variance растет. Тогда сумма их графиков U-образная \Rightarrow есть оптимум! (При Double Descent это не выполняется!)

Описание крайних ситуаций bias и variance


Теперь подробнее и по математически

Пусть:

  • y(x, \varepsilon) = f(x) + \varepsilon — целевая переменная с шумом.

\mathbb{E}[\varepsilon] = 0,\quad \mathrm{Var}[\varepsilon] = \mathbb{E}[\varepsilon^2] = \sigma^2
  • x — объект из тестовой выборки. X - обучающая выборка

  • a(x, X) — алгоритм, обученный на случайной выборке X,

Тогда среднеквадратичная ошибка (MSE) алгоритма имеет вид:

\mathbb{E}_x \mathbb{E}_{X, \varepsilon} \left[ y(x, \varepsilon) - a(x, X) \right]^2= \mathbb{E}_x \left( \underbrace{\left( \mathbb{E}_X[a(x, X)] - f(x) \right)^2}_{\text{смещение (bias)}^2}+ \underbrace{\text{Var}_X[a(x, X)]}_{\text{дисперсия (variance)}}+ \underbrace{\sigma^2}_{\text{шум}} \right)

Пояснение:

\text{bias}_X[a(x, X)] = \mathbb{E}_X[a(x, X)] - f(x)
  • смещение (bias) предсказания алгоритма в точке x, усреднённого по всем возможным обучающим выборкам, относительно истинной зависимости f

    \text{Var}_X[a(x, X)] = \mathbb{E}_X\left[(a(x, X) - \mathbb{E}_X[a(x, X)])^2\right]
  • дисперсия (variance) предсказаний алгоритма в зависимости от обучающей выборки X

\sigma^2 = \mathbb{E}_x \mathbb{E}_\varepsilon \left[ y(x, \varepsilon) - f(x) \right]^2
  • неустранимый шум в данных.

? Подробный разбор

Сначала наслаждаемся MLU-explAIn и потом идем читать с выводами формул хендбук

? Bias–Variance разложение на настоящем примере. Множество предсказаний моделей в одном классе сложности и усреднение.

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

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_regression
from sklearn.tree import DecisionTreeRegressor
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import train_test_split
from sklearn.utils import resample

# --- Данные
X_all, y_all = make_regression(n_samples=1000, n_features=1, noise=15, random_state=42)
X_train, X_val, y_train, y_val = train_test_split(X_all, y_all, test_size=0.3, random_state=42)
X_clean, y_clean = make_regression(n_samples=300, n_features=1, noise=0, random_state=42)
true_model = LinearRegression().fit(X_clean, y_clean)

X_test = np.linspace(X_all.min(), X_all.max(), 200).reshape(-1, 1)
true_y = true_model.predict(X_test)

# --- Bias², Variance, Noise
depths = range(1, 20)
n_models = 100
biases, variances, noises = [], [], []
models_by_depth = {d: [] for d in [1, 4, 10]}

for d in depths:
    preds = []
    for _ in range(n_models):
        X_boot, y_boot = resample(X_train, y_train)
        model = DecisionTreeRegressor(max_depth=d)
        model.fit(X_boot, y_boot)
        y_pred = model.predict(X_test)
        preds.append(y_pred)
        if d in models_by_depth and len(models_by_depth[d]) < 50:
            models_by_depth[d].append(y_pred)
    preds = np.array(preds)
    mean_preds = preds.mean(axis=0)
    bias2 = ((mean_preds - true_y) ** 2).mean()
    var = preds.var(axis=0).mean()
    noise = np.var(y_val - model.predict(X_val))  # приближённо
    biases.append(bias2)
    variances.append(var)
    noises.append(noise)

# --- График ошибок
plt.figure(figsize=(10, 6))
plt.plot(depths, biases, label='Bias²')
plt.plot(depths, variances, label='Variance')
plt.plot(depths, noises, label='Noise (приближённо)')
plt.plot(depths, np.array(biases) + np.array(variances) + np.array(noises),
         label='Total Error', linestyle='--')
plt.xlabel("Глубина дерева")
plt.ylabel("Ошибка")
plt.title("Bias-Variance Trade-off")
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()

Теперь визуализируем сами модели: нарисуем все деревья на разных глубинах, их усреднение и настоящую функцию.

# --- Визуализация моделей
fig, axes = plt.subplots(1, 3, figsize=(18, 5), sharey=True)
for ax, d in zip(axes, [1, 4, 10]):
    all_preds = np.array(models_by_depth[d])
    for y_pred in all_preds:
        ax.plot(X_test.ravel(), y_pred, color='gray', alpha=0.2)
    ax.plot(X_test.ravel(), all_preds.mean(axis=0), color='blue', linewidth=2, label='Усреднённая модель')
    ax.plot(X_test.ravel(), true_y, color='green', linestyle='--', label='f(x) (истинная)')
    ax.scatter(X_train, y_train, s=10, color='black', alpha=0.6, label='Train data')
    ax.set_title(f"Глубина дерева = {d}")
    ax.set_xlabel("x")
    ax.legend()
    ax.grid(True)

axes[0].set_ylabel("y")
plt.suptitle("Смещение и дисперсия на примере деревьев")
plt.tight_layout()
plt.show()

И наконец — как устроены сами данные. Train/test и истинная f(x)

# Визуализация обучающих и тестовых данных на фоне регрессионной прямой
plt.figure(figsize=(8, 5))

# Линия истинной функции
plt.plot(X_test, true_y, color='green', linestyle='--', label='f(x) (истинная)')

# Обучающие и тестовые данные
plt.scatter(X_train, y_train, color='black', s=20, alpha=0.7, label='Train')
plt.scatter(X_val, y_val, color='red', s=20, alpha=0.5, label='Test')

plt.xlabel("x")
plt.ylabel("y")
plt.title("Генерация и распределение данных")
plt.grid(True)
plt.legend()
plt.tight_layout()
plt.show()

15. Процедура построения дерева решений (Decision tree).

? Краткий ответ

Дерево решений строится итеративно, разбивая множество объектов в узле на 2 подмножества по признаку x_j и порогу t, чтобы максимально уменьшить информативность (impurity) (чем ниже, тем обьекты ближе можно представить константным значением):

Процедура разбиения узла:

  1. Перебираются все признаки x_j и возможные пороги t. Все элементы с признаком меньше порога попадают в левый узел, остальные вправо.

  2. Для каждого разбиения вычисляется:

\text{Impurity} = \frac{n_\text{left}}{n} \cdot I(D_\text{left}) + \frac{n_\text{right}}{n} \cdot I(D_\text{right})

где I(\cdot) — функция impurity:

  • Джини или энтропия (классификация)

    • \text{Gini}(p) = \sum_{k=1}^{K} p_k (1 - p_k) = 1 - \sum_{k=1}^{K} p_k^2

    • \text{Entropy}(p) = - \sum_{k=1}^{K} p_k \log_2 p_k

  • дисперсия (регрессия)

    • \text{MSE}(D) = \frac{1}{n} \sum_{i=1}^{n} (y_i - \bar{y})^2

  1. Выбирается разбиение с наименьшей информативностью

После разбиения:

  • В каждом потомке заново пересчитывается "ответ" узла:

    • мода (класс) для классификации

    • среднее значение y для регрессии

Разбиение продолжается рекурсивно, пока не выполнены условия остановки:

  • достигнута макс. глубина

  • мало объектов (min_samples_split)

  • impurity = 0

Важно: дерево строится жадно, локально оптимизируя на каждом шаге, не гарантируя глобального оптимума.

Важность для каждого признака считается так: берут изменение информативности для каждого узла и усредняется на кол-во всех разбиений.


Вопросы на подумать:

  • Почему важно информативность поправлять на размер узла?

  • Почему изменение информативности на каждом шаге не отрицательна?

  • Как эффективно перебирать пороги?

? Подробный разбор
? Пишем свое дерево решения и сравниваем с sklearn.

tldr:

  • Видно, что качество по метрики выбиваем такое же

  • А вот, то что важность у признаков так отличается интересно.


import numpy as np
import pandas as pd
from sklearn.datasets import load_breast_cancer
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
import matplotlib.pyplot as plt

# Загружаем датасет
data = load_breast_cancer()
X, y = data.data, data.target
feature_names = data.feature_names
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)

# Свое дерево решений
class Node:
    def __init__(self, gini, samples, value, feature_index=None, threshold=None, left=None, right=None):
        self.gini = gini
        self.samples = samples
        self.value = value
        self.feature_index = feature_index
        self.threshold = threshold
        self.left = left
        self.right = right

class MyDecisionTreeClassifier:
    def __init__(self, max_depth=3, min_samples_split=2):
        self.max_depth = max_depth
        self.min_samples_split = min_samples_split
        self.feature_importances_ = None
        self.tree_ = None

    def fit(self, X, y):
        self.n_classes_ = len(set(y))
        self.n_features_ = X.shape[1]
        self.feature_importances_ = np.zeros(self.n_features_)
        self.tree_ = self._grow_tree(X, y)
    
    def _gini(self, y):
        m = len(y)
        return 1.0 - sum((np.sum(y == c) / m) ** 2 for c in np.unique(y))

    def _best_split(self, X, y):
        m, n = X.shape
        if m <= 1:
            return None, None

        best_gini = 1.0
        best_idx, best_thr = None, None
        parent_gini = self._gini(y)

        for idx in range(n):
            thresholds, classes = zip(*sorted(zip(X[:, idx], y)))
            num_left = [0] * self.n_classes_
            num_right = np.bincount(classes, minlength=self.n_classes_)
            for i in range(1, m):
                c = classes[i - 1]
                num_left[c] += 1
                num_right[c] -= 1
                gini_left = 1.0 - sum((num_left[x] / i) ** 2 for x in range(self.n_classes_))
                gini_right = 1.0 - sum((num_right[x] / (m - i)) ** 2 for x in range(self.n_classes_))
                gini = (i * gini_left + (m - i) * gini_right) / m

                if thresholds[i] == thresholds[i - 1]:
                    continue

                if gini < best_gini:
                    best_gini = gini
                    best_idx = idx
                    best_thr = (thresholds[i] + thresholds[i - 1]) / 2
                    impurity_reduction = parent_gini - gini
                    self.feature_importances_[idx] += impurity_reduction

        return best_idx, best_thr

    def _grow_tree(self, X, y, depth=0):
        num_samples_per_class = [np.sum(y == i) for i in range(self.n_classes_)]
        predicted_class = np.argmax(num_samples_per_class)
        node = Node(
            gini=self._gini(y),
            samples=len(y),
            value=num_samples_per_class
        )

        if depth < self.max_depth and len(y) >= self.min_samples_split and node.gini > 0:
            idx, thr = self._best_split(X, y)
            if idx is not None:
                indices_left = X[:, idx] <= thr
                X_left, y_left = X[indices_left], y[indices_left]
                X_right, y_right = X[~indices_left], y[~indices_left]
                node.feature_index = idx
                node.threshold = thr
                node.left = self._grow_tree(X_left, y_left, depth + 1)
                node.right = self._grow_tree(X_right, y_right, depth + 1)
        return node

    def _predict(self, inputs):
        node = self.tree_
        while node.left:
            if inputs[node.feature_index] <= node.threshold:
                node = node.left
            else:
                node = node.right
        return np.argmax(node.value)

    def predict(self, X):
        return np.array([self._predict(inputs) for inputs in X])

# Обучение и сравнение
my_tree = MyDecisionTreeClassifier(max_depth=3)
my_tree.fit(X_train, y_train)
y_pred_my = my_tree.predict(X_test)

sk_tree = DecisionTreeClassifier(max_depth=3, random_state=42)
sk_tree.fit(X_train, y_train)
y_pred_sk = sk_tree.predict(X_test)

# Сравнение результатов
acc_my = accuracy_score(y_test, y_pred_my)
acc_sk = accuracy_score(y_test, y_pred_sk)
print(f"{acc_my=} {acc_sk=}")
# acc_my=0.9590643274853801 acc_sk=0.9649122807017544


importances_df = pd.DataFrame({
    "Feature": feature_names,
    "MyTree": my_tree.feature_importances_ / np.sum(my_tree.feature_importances_),
    "Sklearn": sk_tree.feature_importances_
}).sort_values(by="MyTree", ascending=False)

importances_df

# Feature	MyTree	Sklearn
# 0	mean radius	0.637033	0.000000
# 7	mean concave points	0.260352	0.809978
# 2	mean perimeter	0.031954	0.000000
# 1	mean texture	0.030274	0.025169
# 6	mean concavity	0.020749	0.000000
# 20	worst radius	0.009949	0.043482
# 21	worst texture	0.002868	0.066145
# 23	worst area	0.001924	0.040310
# 22	worst perimeter	0.001833	0.000000
# 3	mean area	0.001694	0.000000
# 10	radius error	0.001371	0.000000

16. Критерии информации. Критерии энтропии, неопределенности Джини.

? Краткий ответ

Смотри предыдущий пункт. Самое главное понимать, что максимум функции (информативности) достигается, когда всех обьектов поровну, а минимум все бъекты одного класса.

Критерии
График функций информативности для двух классов
? Визуализируем критерии информативности
import numpy as np
import matplotlib.pyplot as plt

# Вероятности для одного из классов (например, класс 1)
p = np.linspace(0, 1, 500)

# Энтропия (Shannon entropy)
entropy = -p * np.log2(p + 1e-9) - (1 - p) * np.log2(1 - p + 1e-9)

# Джини
gini = 2 * p * (1 - p)

# Визуализация
plt.figure(figsize=(8, 5))
plt.plot(p, entropy, label='Entropy', linewidth=2)
plt.plot(p, gini, label='Gini Impurity', linewidth=2)
plt.xlabel("p (доля одного класса)")
plt.ylabel("Impurity")
plt.title("Сравнение критериев информации")
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()
Критерии
График функций информативности для двух классов

17. Ансамблевые методы. Бутстрап (bootstrap). Бэггинг (bagging). Стекинг (stacking)

? Краткий ответ

Ансамбли — это способ объединить множество простых моделей, чтобы получить одну, более устойчивую.

Бутстрап: процесс генерации подвыборок с помощью семплирования с возвращением.

Бэггинг (bagging, bootstrap aggregation) - обучаем несколько моделей на бутстрап-выборках и агрегируем их ответы (усреднение или голосование).

Теория вокруг Bias-variance trade-off говорит, что смещение (bias) не изменится, a дисперсия (variance) уменьшится в k раз (Если предположить независимость базовых алгоритмов). Вывод утверждения для задачи регрессии. \Rightarrow берем сложные, глубокие модели, чтобы уменьшить смещение (а дисперсию уменьшим с помощью беггинга!)

Стекинг (stacking): 1. Учим несколько (разной природы) моделей на разных фолдах, и потом на отдельном фолде учим мета модель .


Например:

Случайный лес (Random Forest) = бэггинг + метод случайных подпространств(=в каждом узле дерева выбирается случайное подмножество признаков):

Из какого кол-во признаков выбирать?

  • Много признаков \Rightarrow большая скоррелированность моделей (=маленький эффект от ансамблирования).

  • Мало признаков \Rightarrow модели слабые (=смещение увеличивается).

Практическая рекомендация — брать корень из числа всех признаков для классификации и треть признаков для регрессии.

? Подробный разбор
? Сравнения ансамблёвых моделей

# Импорт всех нужных библиотек
from sklearn.ensemble import (
    BaggingClassifier,
    StackingClassifier,
    RandomForestClassifier,
    GradientBoostingClassifier
)
from sklearn.tree import DecisionTreeClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.svm import SVC
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
import matplotlib.pyplot as plt

# Загружаем датасет
X, y = load_breast_cancer(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)

# 1. Обычное дерево
tree = DecisionTreeClassifier(random_state=42)
tree.fit(X_train, y_train)
y_pred_tree = tree.predict(X_test)
acc_tree = accuracy_score(y_test, y_pred_tree)

# 2. Бэггинг
bag = BaggingClassifier(DecisionTreeClassifier(), n_estimators=100, random_state=42)
bag.fit(X_train, y_train)
y_pred_bag = bag.predict(X_test)
acc_bag = accuracy_score(y_test, y_pred_bag)

# 3. Стекинг: дерево + SVM → логрегрессия
stack = StackingClassifier(
    estimators=[
        ("dt", DecisionTreeClassifier()),
        ("svm", SVC(probability=True))
    ],
    final_estimator=LogisticRegression(),
    cv=5
)
stack.fit(X_train, y_train)
y_pred_stack = stack.predict(X_test)
acc_stack = accuracy_score(y_test, y_pred_stack)

# 4. Случайный лес
rf = RandomForestClassifier(n_estimators=100, random_state=42)
rf.fit(X_train, y_train)
y_pred_rf = rf.predict(X_test)
acc_rf = accuracy_score(y_test, y_pred_rf)

# 5. Градиентный бустинг
boost = GradientBoostingClassifier(n_estimators=100, learning_rate=0.1, max_depth=3, random_state=42)
boost.fit(X_train, y_train)
y_pred_boost = boost.predict(X_test)
acc_boost = accuracy_score(y_test, y_pred_boost)

# Визуализация результатов
models = ['Decision Tree', 'Bagging', 'Stacking', 'Random Forest', 'Boosting']
accuracies = [acc_tree, acc_bag, acc_stack, acc_rf, acc_boost]

plt.figure(figsize=(10, 5))
plt.bar(models, accuracies, color=['skyblue', 'lightgreen', 'salmon', 'gold', 'orchid'])
plt.ylim(0.9, 1.0)
plt.ylabel("Accuracy")
plt.title("Сравнение ансамблевых моделей на Breast Cancer Dataset")
plt.grid(axis='y')
plt.tight_layout()
plt.show()

# Вывод точности всех моделей
acc_tree, acc_bag, acc_stack, acc_rf, acc_boost
# (0.9415204678362573,
#  0.9590643274853801,
#  0.9649122807017544,
#  0.9707602339181286,
#  0.9590643274853801)

18. Случайный лес (Random Forest), метод случайных подпространств.

? Краткий ответ

Смотри прошлый билет!

? Поиск оптимального кол-ва случайных признаков. Несогласованность и точность моделей.

Видим, как с увеличением случайных признаков увеличивается согласованность ответов \Rightarrow модели становятся скоррелированны \Rightarrow качество бесконечно не растет
.


# Будем оценивать дисперсию (variance) предсказаний разных деревьев
# для каждого значения max_features

tree_variances = []
mean_accuracies = []
max_features_range = range(1, n_features + 1)

for mf in max_features_range:
    rf = RandomForestClassifier(
        n_estimators=100, max_features=mf, random_state=42, oob_score=False
    )
    rf.fit(X_train, y_train)
    
    # Предсказания всех деревьев
    all_preds = np.array([tree.predict(X_test) for tree in rf.estimators_])  # shape (n_estimators, n_samples)
    
    # Среднее предсказание (majority vote)
    majority_vote = np.round(np.mean(all_preds, axis=0)).astype(int)
    acc = accuracy_score(y_test, majority_vote)
    mean_accuracies.append(acc)
    
    # Оценка "дисперсии" (простейшая: средняя доля несогласия между деревьями)
    disagreement = np.mean(np.var(all_preds, axis=0))
    tree_variances.append(disagreement)

# Визуализация
fig, ax1 = plt.subplots(figsize=(10, 5))

color = 'tab:blue'
ax1.set_xlabel("max_features")
ax1.set_ylabel("Disagreement (Variance across trees)", color=color)
ax1.plot(max_features_range, tree_variances, color=color, label="Variance across trees")
ax1.tick_params(axis='y', labelcolor=color)
ax1.grid(True)

ax2 = ax1.twinx()
color = 'tab:green'
ax2.set_ylabel("Accuracy", color=color)
ax2.plot(max_features_range, mean_accuracies, color=color, linestyle='--', label="Accuracy")
ax2.tick_params(axis='y', labelcolor=color)

plt.title("Variance across trees vs Accuracy (Random Forest)")
fig.tight_layout()
plt.show()

19. Бустинг и градиентный бустинг (Gradient Boosting). Основная идея, производная градиента.

? Краткий ответ

Бустинг — это ансамблевый метод, в котором модели обучаются последовательно, каждая новая модель пытается исправить ошибки предыдущих.

  • В отличие от бэггинга (параллельные модели), здесь идет цепочка из "корректоров".

  • Итоговое предсказание — взвешенная сумма всех слабых моделей.

Градиентный бустинг (Gradient Boosting) — это частный случай, где каждая новая модель аппроксимирует антиградиент функции потерь (напр. MSE, логлосс) по предсказаниям текущего ансамбля.

? Подробный разбор

? Общая идея бустинга

Очень круто, подробно и просто описано тут.

Имеем задачу регрессии или классификации с функцией потерь L(y, F(x)).
Хотим построить итоговую модель в виде:

F(x) = \sum_{m=1}^{M} \gamma_m h_m(x)

Каждый новый слабый алгоритм h_m(x) обучается на остатках или антиградиенте предыдущей ошибки.


Градиентный бустинг (Gradient Boosting)

На шаге m строим новую модель h_m, которая аппроксимирует антиградиент функции потерь по текущим предсказаниям:

r_i^{(m)} = - \left. \frac{\partial L(y_i, F(x_i))}{\partial F(x_i)} \right|_{F = F_{m-1}}

После этого:

  • обучаем h_m на (x_i, r_i^{(m)})

  • выбираем шаг \gamma_m

  • обновляем:

    F_m(x) = F_{m-1}(x) + \gamma_m h_m(x)

Примеры градиентов:

  • MSE (регрессия):

    \frac{\partial}{\partial F(x)} \frac{1}{2}(y - F(x))^2 = F(x) - y\Rightarrow r_i = y_i - F(x_i)

    То есть просто остатки! Совпадает с определением бустинга.

  • Log-loss (бинарная классификация):

    L(y, F) = \log(1 + e^{-yF}) \Rightarrow r_i = \frac{-y_i}{1 + e^{y_i F(x_i)}}

Почему работает?

  • Рассматриваем задачу не как минимизация расстояния между вектором предсказания и истинных значений, а с точки зрения функции потерь!

  • Итеративно минимизируем функцию потерь — как градиентный спуск в функциональном пространствеn мерном пространстве, n - размер датасета). Каждый шаг - это добавление антиградиента к аргументу функции \Rightarrow уменьшение лосса.

  • Для MSE это одно и тоже! (остаток и антиградиент равны)

Критерии
Решение задачи как минимизация расстояния предсказания и истины
Критерии
Решение задачи как минимизация функции потерь

Обращу внимание, тот и тот оба решает задачу, только второй это делает по наискорейшему направлению.

А как выбрать базовые модели? Берутся "простые" модели (с низкой дисперсией), и итеративно уменьшается смещение


Основные реализации, работающие в проде. XGBoost, LightGBM и CatBoost.
Очень важно знать важные фишки и отличия.

? Реализовываем свой градиентный бустинг. Сравниваемся с GradientBoostingRegressor и RandomForestRegressor.
  • Получился код, с таким же качеством как из sklearn.

  • Дерево решение проиграло бустингу.

# Полный код: сравнение градиентного бустинга и случайного леса

import numpy as np
import matplotlib.pyplot as plt
from sklearn.tree import DecisionTreeRegressor
from sklearn.metrics import mean_squared_error
from sklearn.model_selection import train_test_split
from sklearn.ensemble import GradientBoostingRegressor, RandomForestRegressor

# 1. Синтетические данные
np.random.seed(42)
X = np.linspace(0, 10, 500).reshape(-1, 1)
y_true = np.sin(X).ravel()
y = y_true + np.random.normal(0, 0.3, size=X.shape[0])
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)

# 2. Собственный градиентный бустинг
class MyGradientBoostingRegressor:
    def __init__(self, n_estimators=100, learning_rate=0.1, max_depth=3):
        self.n_estimators = n_estimators
        self.learning_rate = learning_rate
        self.max_depth = max_depth
        self.models = []
        self.gammas = []

    def fit(self, X, y):
        self.models = []
        self.gammas = []
        self.init_val = np.mean(y)
        F = np.full_like(y, fill_value=self.init_val, dtype=np.float64)

        for m in range(self.n_estimators):
            residuals = y - F
            tree = DecisionTreeRegressor(max_depth=self.max_depth)
            tree.fit(X, residuals)
            prediction = tree.predict(X)
            gamma = 1.0
            F += self.learning_rate * gamma * prediction
            self.models.append(tree)
            self.gammas.append(gamma)

    def predict(self, X):
        F = np.full(X.shape[0], self.init_val)
        for gamma, tree in zip(self.gammas, self.models):
            F += self.learning_rate * gamma * tree.predict(X)
        return F

# 3. Обучение всех моделей
# Собственный бустинг
my_gb = MyGradientBoostingRegressor(n_estimators=100, learning_rate=0.1, max_depth=3)
my_gb.fit(X_train, y_train)
y_pred_my = my_gb.predict(X_test)
mse_my = mean_squared_error(y_test, y_pred_my)

# sklearn GB (обычный)
sklearn_gb = GradientBoostingRegressor(n_estimators=100, learning_rate=0.1, max_depth=3, random_state=42)
sklearn_gb.fit(X_train, y_train)
y_pred_sklearn = sklearn_gb.predict(X_test)
mse_sklearn = mean_squared_error(y_test, y_pred_sklearn)

# sklearn GB (низкий learning_rate)
sklearn_gb_slow = GradientBoostingRegressor(n_estimators=300, learning_rate=0.03, max_depth=3, random_state=42)
sklearn_gb_slow.fit(X_train, y_train)
y_pred_slow = sklearn_gb_slow.predict(X_test)
mse_slow = mean_squared_error(y_test, y_pred_slow)

# Случайный лес
rf = RandomForestRegressor(n_estimators=100, max_depth=6, random_state=42)
rf.fit(X_train, y_train)
y_pred_rf = rf.predict(X_test)
mse_rf = mean_squared_error(y_test, y_pred_rf)

# 4. Сортировка для гладких графиков
sorted_idx = np.argsort(X_test.ravel())
X_plot = X_test[sorted_idx]
y_true_plot = np.sin(X_plot).ravel()
y_test_plot = y_test[sorted_idx]
y_pred_my_plot = y_pred_my[sorted_idx]
y_pred_sklearn_plot = y_pred_sklearn[sorted_idx]
y_pred_slow_plot = y_pred_slow[sorted_idx]
y_pred_rf_plot = y_pred_rf[sorted_idx]

# 5. Визуализация
plt.figure(figsize=(12, 6))
plt.plot(X_plot, y_true_plot, label="True function sin(x)", color='green', linestyle='--')
plt.plot(X_plot, y_pred_my_plot, label=f"My GB (MSE={mse_my:.4f})", color='red')
plt.plot(X_plot, y_pred_sklearn_plot, label=f"sklearn GB (0.1) (MSE={mse_sklearn:.4f})", color='blue')
plt.plot(X_plot, y_pred_slow_plot, label=f"sklearn GB (0.03) (MSE={mse_slow:.4f})", color='orange')
plt.plot(X_plot, y_pred_rf_plot, label=f"Random Forest (MSE={mse_rf:.4f})", color='purple')
plt.scatter(X_plot, y_test_plot, label="Test data", s=10, alpha=0.3)
plt.legend()
plt.title("Сравнение бустинга (ручной и sklearn) и случайного леса")
plt.grid(True)
plt.tight_layout()
plt.show()

Что дальше?

Вдумчиво разбираемся в вопросах, но коротко и понятно рассказываем суть.

Следующий этап - уметь, понимая базу, отвечать на каверзные вопросы. Поэтому сохраняем их для будущей тренировки!

Материалы


В следующей части продолжим — начнем с ввода в глубокое обучение, а закончим разбором важнейших классических архитектур. Пока подписывайтесь и делитесь своими находками!

Комментарии (1)


  1. navi_king
    24.06.2025 11:48

    Супер, ждем третью серию, каждая статья супер! Спасибо за ссылки, годно!