Машинное обучение Ансамбли: бэггинг, случайный лес, градиентный бустинг
0%

Ансамбли: бэггинг, случайный лес, градиентный бустинг

Ансамбли: бэггинг, случайный лес, градиентный бустинг

Одиночное решающее дерево — модель с прекрасной интерпретируемостью и отвратительной устойчивостью: сдвиньте один объект в обучающей выборке, и структура дерева может перестроиться целиком (https://courses.digitable.life/post/machine-learning/06-decision-trees/). Это выглядит как приговор, но на самом деле это ресурс. Из нестабильных моделей можно собрать чрезвычайно стабильную конструкцию — и почти двадцать лет ансамбли деревьев остаются моделью по умолчанию для табличных данных, обгоняя нейросети в большинстве практических задач.

В этой статье мы разберём два принципиально разных способа комбинировать модели: параллельный (бэггинг, случайный лес — уменьшаем дисперсию усреднением) и последовательный (бустинг — уменьшаем смещение, добавляя модели, которые исправляют ошибки предыдущих). Поймём, почему XGBoost считает вторые производные, зачем LightGBM квантует признаки в 256 бинов и что именно CatBoost чинит своим «упорядоченным бустингом». И — самое важное на практике — какие ошибки приводят к тому, что бустинг показывает 0.95 AUC на валидации и 0.62 в проде.


1. Почему усреднение вообще работает

Интуиция: мудрость толпы и теорема Кондорсе

Пусть есть M независимых бинарных классификаторов, каждый угадывает с вероятностью p = 0.6 — чуть лучше монетки. Возьмём мажоритарное голосование. Вероятность, что большинство ошибётся, — это хвост биномиального распределения, и он убывает экспоненциально по M. Для M = 11 голосующих точность ансамбля ≈ 0.75, для M = 101 ≈ 0.98. Это теорема Кондорсе о жюри присяжных (1785), и она же — самая честная интуиция про ансамбли.

Ключевое слово здесь — независимых. Если все одиннадцать «экспертов» читали один учебник и ошибаются в одних и тех же местах, голосование не даст ничего. Вся инженерия ансамблей — это инженерия декорреляции ошибок.

Строгая версия: дисперсия среднего коррелированных оценок

Пусть у нас M предсказаний f₁(x), …, f_M(x), каждое с дисперсией σ², и попарная корреляция между ними равна ρ. Дисперсия среднего:

Var( (1/M)·Σ f_m(x) )  =  ρ·σ²  +  (1 − ρ)/M · σ²

Разберём формулу по частям — она объясняет весь бэггинг:

  • Второе слагаемое (1−ρ)σ²/M уходит в ноль с ростом числа моделей. Это «бесплатная» часть выигрыша: добавляйте деревья, пока не надоест ждать.
  • Первое слагаемое ρσ²непреодолимый пол. Сколько бы деревьев вы ни добавили, ниже него не опуститься. Единственный способ его снизить — уменьшить ρ, то есть сделать модели менее похожими друг на друга.

Отсюда весь дизайн случайного леса: бутстрэп даёт разные выборки (снижает ρ), случайный выбор признаков в узле снижает ρ ещё сильнее, а глубокие несклонённые деревья держат смещение низким, чтобы усреднение работало по дисперсии, а не по систематической ошибке.

Смещение среднего, кстати, не меняется: E[(1/M)Σf_m] = E[f]. Усреднение одинаково смещённых моделей оставляет смещение на месте. Это и есть фундаментальное разделение труда:

Что уменьшает Базовая модель Как комбинирует
Бэггинг / RF дисперсию сильная, переобученная (глубокое дерево) параллельно, усреднение
Бустинг смещение слабая, недообученная (пень, глубина 3–8) последовательно, взвешенная сумма
Стекинг и то и другое разнородные модели обученная мета-модель

Подробный разбор разложения ошибки на смещение и дисперсию — в отдельной статье трека (https://courses.digitable.life/post/machine-learning/11-overfitting-and-regularization/); здесь мы пользуемся им как рабочим языком.

Карта семейства


2. Бэггинг: бутстрэп и усреднение

Bagging = Bootstrap AGGregatING, придуман Лео Брейманом в 1996 году (оригинальная статья). Алгоритм умещается в четыре строки.

Вход: обучающая выборка D размера n, число моделей M, базовый алгоритм A
для m = 1..M:
    D_m ← выборка размера n из D С ВОЗВРАЩЕНИЕМ (бутстрэп)
    f_m ← A(D_m)
Ответ: регрессия  F(x) = (1/M)·Σ f_m(x)
       классификация F(x) = argmax_c Σ [f_m(x) = c]  (или среднее вероятностей)

Магическое число 0.368

Какая доля исходных объектов попадёт в бутстрэп-выборку? Вероятность, что конкретный объект не выбран ни разу за n попыток:

(1 − 1/n)^n  →  e^(−1) ≈ 0.368  при n → ∞

То есть каждое дерево видит примерно 63.2% уникальных объектов, а оставшиеся 36.8% — это его out-of-bag (OOB) выборка. Отсюда бесплатный бонус: для каждого объекта можно усреднить предсказания только тех деревьев, которые его не видели, и получить честную оценку обобщающей способности без отдельной валидации и без кросс-валидации. На больших лесах OOB-оценка практически совпадает с leave-one-out CV.

from sklearn.ensemble import RandomForestClassifier

rf = RandomForestClassifier(
    n_estimators=500,
    oob_score=True,      # включаем OOB-оценку
    n_jobs=-1,
    random_state=42,
)
rf.fit(X_train, y_train)
print(f"OOB accuracy: {rf.oob_score_:.4f}")   # честная оценка без отдельного валидационного сплита

Оговорка: OOB честен только если объекты независимы. Для временных рядов, для данных с группами (несколько строк на одного пользователя) и для дублей OOB врёт ровно так же, как обычная случайная кросс-валидация — см. разбор утечек в https://courses.digitable.life/post/machine-learning/02-data-and-features/.

Когда бэггинг помогает, а когда бесполезен

Бэггинг снижает дисперсию. Значит, он полезен ровно настолько, насколько базовая модель нестабильна:

  • Глубокие деревья — идеальный кандидат: огромная дисперсия, низкое смещение.
  • kNN — почти бесполезно: метод и так усредняет по окрестности, ρ между моделями близко к единице. Брейман показал это ещё в исходной статье.
  • Линейная регрессия — почти бесполезно: оценка МНК устойчива, а бутстрэп добавляет только шум. Бэггинг линейных моделей приблизительно равен одной линейной модели.

Полезное следствие для собеседований и для отладки: если бэггинг вашей модели не даёт прироста — модель уже стабильна, и проблема, скорее всего, в смещении, а не в дисперсии. Лечится это бустингом или более выразительными признаками, а не увеличением n_estimators.


3. Случайный лес: декорреляция как отдельная фича

Случайный лес (Breiman, 2001, PDF) добавляет к бэггингу одну идею: при каждом разбиении узла рассматривается не весь набор признаков, а случайное подмножество размера mtry.

Зачем? Представьте, что есть один очень сильный признак. В бэггинге все деревья поставят его в корень, и структуры получатся почти одинаковыми — ρ высокая, потолок из формулы выше близко. Ограничив выбор, мы заставляем часть деревьев строиться на «вторых по силе» признаках. Каждое дерево становится чуть хуже (растёт смещение), но ρ падает сильнее, чем растёт σ², и итоговая ошибка снижается.

Классические дефолты: mtry = √d для классификации, mtry = d/3 для регрессии (в sklearn — max_features="sqrt" и max_features=1.0 соответственно; последний дефолт для регрессии стоит менять на 0.3–0.5, если признаков много).

Сложность

Пусть n — объектов, d — признаков, M — деревьев, k = mtry.

Операция Время Память
Обучение одного дерева O(k · n log²n) (сортировка/сканирование на каждом уровне) O(n) рабочая
Обучение леса O(M · k · n log²n), идеально параллелится O(M · L), L — число листьев
Инференс O(M · depth) — на практике сотни наносекунд вся модель в памяти

Практический ориентир: полностью выращенный лес из 500 деревьев на 1 млн объектов легко занимает сотни мегабайт — деревья не ограничены по глубине, и число листьев растёт как O(n). Это самая частая неприятность при выкатке RF в сервис с лимитом памяти. Лечится min_samples_leaf=5..50 (заодно почти не вредит качеству) или переходом на бустинг, где деревья мелкие.

ExtraTrees: ещё больше случайности

Extremely Randomized Trees (Geurts et al., 2006) идут дальше: порог разбиения выбирается случайно, а не оптимально, и по умолчанию бутстрэп не используется. Это ещё сильнее снижает ρ и радикально ускоряет обучение (не нужно сканировать все пороги). На шумных данных ExtraTrees часто выигрывает у RF; на чистых — чуть проигрывает. Стоит держать в списке того, что пробуется за пять минут.

Важности признаков: где здесь мина

feature_importances_ в sklearn — это MDI (mean decrease in impurity): сумма взвешенных уменьшений критерия по всем узлам, где признак использован. У неё два известных смещения:

  1. В пользу признаков с большой кардинальностью. Непрерывный признак или ID с тысячей уникальных значений даёт больше возможностей «случайно» уменьшить impurity, чем бинарный флаг. Классическая демонстрация: добавьте в данные полностью случайный вещественный столбец — он окажется в середине рейтинга важности, обгоняя реальные бинарные признаки.
  2. Считается на обучающей выборке — то есть частично отражает переобучение.

Более честная альтернатива — permutation importance на отложенных данных: перемешиваем столбец и смотрим, насколько упало качество. Дороже (O(d) полных инференсов), но измеряет ровно то, что нужно.

from sklearn.inspection import permutation_importance

r = permutation_importance(rf, X_valid, y_valid, n_repeats=10,
                           scoring="roc_auc", random_state=0, n_jobs=-1)
for i in r.importances_mean.argsort()[::-1][:10]:
    print(f"{feature_names[i]:<30} {r.importances_mean[i]:.4f} ± {r.importances_std[i]:.4f}")

Третья мина, общая для обоих методов: коррелированные признаки делят важность между собой. Два дубликата одного сигнала получат по половине и оба покажутся слабыми. Перед интерпретацией имеет смысл кластеризовать признаки по корреляции Спирмена и оценивать важность групп — рецепт есть в документации sklearn.


4. Бустинг: от AdaBoost к градиентному спуску в пространстве функций

AdaBoost за пять минут

Исторически первым практичным бустингом был AdaBoost (Freund & Schapire, 1997, PDF). Идея: обучаем слабый классификатор, увеличиваем веса объектов, на которых он ошибся, обучаем следующий на перевзвешенной выборке, и так далее. Итог — взвешенное голосование, где вес модели зависит от её точности.

w_i ← 1/n
для m = 1..M:
    f_m ← обучить на выборке с весами w
    err_m ← Σ w_i·[f_m(x_i) ≠ y_i] / Σ w_i
    α_m ← ½·ln((1 − err_m) / err_m)          # вес модели
    w_i ← w_i · exp(−α_m · y_i · f_m(x_i))   # ошибочные объекты тяжелеют
    нормировать w
Ответ: F(x) = sign( Σ α_m · f_m(x) )

Долгое время AdaBoost выглядел эвристикой, пока Фридман, Хасти и Тибширани не показали (Additive Logistic Regression, 2000), что это в точности пошаговая минимизация экспоненциальной функции потерь L(y, F) = exp(−y·F(x)) в аддитивной модели. Это открыло дорогу к общему случаю: если можно минимизировать exp-loss, можно минимизировать любую дифференцируемую потерю.

Практическое следствие экспоненциальной потери: AdaBoost крайне чувствителен к выбросам и шуму в метках — вес неверно размеченного объекта растёт экспоненциально, и ансамбль начинает обслуживать мусор. Именно поэтому в проде почти всегда используют градиентный бустинг с более мягкими потерями (log-loss, Huber), а не AdaBoost.

Градиентный бустинг: главная идея

Фридман (2001, Greedy Function Approximation) сформулировал обобщение. Мы хотим минимизировать эмпирический риск Σ L(y_i, F(x_i)) по функции F. Обычный градиентный спуск делает шаг в пространстве параметров. Здесь мы делаем шаг в пространстве функций: считаем градиент функционала по значениям F(x_i) в точках обучающей выборки:

r_i = − ∂L(y_i, F(x_i)) / ∂F(x_i)        # антиградиент = «псевдо-остаток»

Проблема: этот градиент определён только в n точках обучающей выборки, а нужна функция, определённая везде. Решение: обучить дерево регрессии, приближающее вектор псевдо-остатков — дерево и есть «направление шага, продолженное на всё пространство».

F₀(x) = argmin_c Σ L(y_i, c)              # константа: среднее для MSE, log(odds) для logloss
для m = 1..M:
    r_i ← −∂L(y_i, F_{m−1}(x_i))/∂F        # псевдо-остатки
    h_m ← дерево регрессии, обученное на (x_i, r_i)
    для каждого листа j дерева h_m:
        γ_jm ← argmin_γ Σ_{i ∈ лист j} L(y_i, F_{m−1}(x_i) + γ)   # переоценка значения листа
    F_m(x) ← F_{m−1}(x) + ν · γ_{j(x),m}   # ν — learning rate (shrinkage)

Обратите внимание на предпоследнюю строку. Дерево обучается предсказывать градиент (это всегда MSE-задача, независимо от целевой потери), но значения в листьях затем пересчитываются под настоящую функцию потерь. Для MSE эти два шага совпадают (псевдо-остаток = обычный остаток y − F), и поэтому объяснение «бустинг учится на остатках» верно только для квадратичной потери — в общем случае он учится на антиградиенте.

Пошаговая аппроксимация: как сумма деревьев приближает истинную зависимость

Learning rate и почему он важнее числа деревьев

ν ∈ (0, 1] — коэффициент сжатия шага. Фридман показал эмпирически: маленький ν (0.01–0.1) с большим M систематически даёт лучшее обобщение, чем ν = 1 с малым M. Причина та же, что у регуляризации: мелкие шаги не позволяют одному дереву захватить слишком много «объяснения» и оставляют пространство для усреднения последующих.

Правило большой пальца: ν и M торгуются почти обратно пропорционально. Уменьшили ν вдвое — увеличьте M примерно вдвое. Поэтому осмысленный протокол такой: зафиксировать ν = 0.05, поставить M = 10000 и найти M через раннюю остановку на валидации, а не перебирать M в grid search.

Стохастический градиентный бустинг

Friedman, 1999: подвыборка строк (subsample = 0.5..0.8) перед построением каждого дерева. Это добавляет декорреляцию в чисто последовательный алгоритм, ускоряет обучение и заметно улучшает качество на шумных данных — бустинг заимствует у бэггинга. Аналогично работает colsample_bytree — подвыборка признаков.

Полный цикл одной итерации

Реализация с нуля: 40 строк, которые всё объясняют

import numpy as np
from sklearn.tree import DecisionTreeRegressor


class GradientBoostingBinaryClassifier:
    """Градиентный бустинг для бинарной классификации с log-loss.

    Показывает суть: дерево учится на антиградиенте, значения листьев
    пересчитываются одним шагом Ньютона под настоящую потерю.
    """

    def __init__(self, n_estimators=200, learning_rate=0.1, max_depth=3, subsample=1.0, seed=0):
        self.n_estimators = n_estimators
        self.lr = learning_rate
        self.max_depth = max_depth
        self.subsample = subsample
        self.rng = np.random.default_rng(seed)
        self.trees = []

    @staticmethod
    def _sigmoid(z):
        return 1.0 / (1.0 + np.exp(-z))

    def fit(self, X, y):
        y = y.astype(float)
        # F_0 — логарифм шансов базовой частоты: минимум log-loss для константы
        p0 = np.clip(y.mean(), 1e-6, 1 - 1e-6)
        self.F0 = np.log(p0 / (1 - p0))
        F = np.full(len(y), self.F0)

        for _ in range(self.n_estimators):
            p = self._sigmoid(F)
            grad = y - p                 # антиградиент log-loss по F
            hess = p * (1 - p)           # вторая производная

            # стохастическая подвыборка строк
            if self.subsample < 1.0:
                idx = self.rng.choice(len(y), int(len(y) * self.subsample), replace=False)
            else:
                idx = np.arange(len(y))

            tree = DecisionTreeRegressor(max_depth=self.max_depth, min_samples_leaf=20)
            tree.fit(X[idx], grad[idx])  # дерево приближает градиент — обычная MSE-задача

            # шаг Ньютона в каждом листе: γ_j = Σ grad / Σ hess
            leaves_all = tree.apply(X)
            leaves_fit = leaves_all[idx]
            values = np.zeros(tree.tree_.node_count)
            for leaf in np.unique(leaves_fit):
                mask = leaves_fit == leaf
                values[leaf] = grad[idx][mask].sum() / (hess[idx][mask].sum() + 1e-9)

            F += self.lr * values[leaves_all]
            self.trees.append((tree, values))
        return self

    def decision_function(self, X):
        F = np.full(X.shape[0], self.F0)
        for tree, values in self.trees:
            F += self.lr * values[tree.apply(X)]
        return F

    def predict_proba(self, X):
        p = self._sigmoid(self.decision_function(X))
        return np.column_stack([1 - p, p])

Этот код на реальных данных даёт качество в пределах 1–2% от sklearn.GradientBoostingClassifier. Всё, что делают промышленные библиотеки поверх, — это скорость, регуляризация и работа с пропусками/категориями.


5. XGBoost: регуляризованная цель и Newton boosting

XGBoost (Chen & Guestrin, KDD 2016, arXiv:1603.02754) внёс главный концептуальный сдвиг: вместо шага по градиенту — шаг Ньютона, и вместо неявной регуляризации — явная, прямо в целевой функции.

Раскладываем потерю в ряд Тейлора второго порядка вокруг текущего предсказания:

Obj^(m) ≈ Σ_i [ g_i·h_m(x_i) + ½·h_i·h_m(x_i)² ]  +  Ω(h_m)

где  g_i = ∂L/∂F,  h_i = ∂²L/∂F²   (градиент и гессиан в точке F_{m−1}(x_i))
     Ω(h) = γ·T + ½·λ·Σ_j w_j²     (T — число листьев, w_j — значения листьев)

Дерево — кусочно-постоянная функция, поэтому внутри листа j все h_m(x_i) равны w_j. Обозначим G_j = Σ_{i∈j} g_i, H_j = Σ_{i∈j} h_i. Цель распадается по листьям на независимые квадратичные задачи, и оптимум берётся аналитически:

w_j* = − G_j / (H_j + λ)

Obj* = −½ · Σ_j G_j²/(H_j + λ)  +  γ·T

Это и есть structure score — численная мера качества конкретной структуры дерева. Из него сразу следует критерий разбиения узла:

Gain = ½·[ G_L²/(H_L + λ) + G_R²/(H_R + λ) − (G_L+G_R)²/(H_L+H_R + λ) ] − γ

Красота в том, что это не эвристика вроде Джини, а прямое уменьшение той самой функции потерь, которую мы минимизируем. И γ работает как порог: разбиение, дающее выигрыш меньше γ, просто не делается (pre-pruning), а XGBoost дополнительно умеет делать post-pruning — вырастить до max_depth и срезать ветви с отрицательным выигрышем.

Ещё две вещи, за которые XGBoost ценят:

  • Sparsity-aware split finding. У каждого узла есть «направление по умолчанию» для пропусков; оно выбирается обучением (пробуем отправить все NaN влево, потом вправо, берём лучший gain). То есть пропуски не нужно импутировать — и это часто лучше импутации, потому что сам факт пропуска бывает сигналом.
  • min_child_weight — это минимальная сумма гессианов в листе, а не число объектов. Для регрессии с MSE h_i = 1, и параметр совпадает с числом объектов. Для log-loss h_i = p(1−p), то есть «уверенные» объекты весят почти ноль — лист из тысячи объектов, в которых модель уже уверена, считается «лёгким» и может быть отсечён. Очень недооценённый параметр регуляризации.

6. LightGBM и CatBoost: где взялась скорость

Гистограммный алгоритм

Точный поиск сплита требует отсортировать значения признака и просканировать n − 1 порогов — O(n log n) на признак на узел. Гистограммный подход (пришёл из работ по распределённым деревьям, доведён до ума в LightGBM) квантует каждый признак один раз в #bins ≈ 255 корзин, а дальше работает только с гистограммами сумм градиентов и гессианов.

Гистограммный поиск сплита: бины, суммы градиентов и префиксные суммы

Что это даёт:

  • Стоимость поиска сплита в узле падает с O(n·d) до O(#bins·d) после O(n·d) построения гистограммы, а значения признаков хранятся как uint8экономия памяти в 4–8 раз.
  • Histogram subtraction: гистограмма правого потомка = гистограмма родителя минус гистограмма левого. Строим её только для меньшего потомка — экономия ещё вдвое.
  • Побочный эффект — лёгкая регуляризация: порог не может «прилипнуть» к единичному выбросу.

Именно поэтому sklearn.ensemble.HistGradientBoostingClassifier (порт идей LightGBM в стандартную библиотеку) на порядок быстрее старого GradientBoostingClassifier и должен быть дефолтом, если не хочется тянуть внешние зависимости.

GOSS и EFB

LightGBM (NIPS 2017, PDF) добавил два трюка:

  • GOSS (Gradient-based One-Side Sampling): объекты с большим по модулю градиентом — это те, где модель ещё ошибается; их оставляем все, а из «хорошо выученных» берём случайную часть с компенсирующим весом. Ускорение почти без потери качества.
  • EFB (Exclusive Feature Bundling): разреженные признаки, которые почти никогда не ненулевые одновременно (типичный one-hot), склеиваются в один признак со сдвигом бинов. Уменьшает эффективное d в разы на разреженных данных.

Leaf-wise против level-wise

XGBoost исторически растит дерево по уровням (level-wise): все узлы уровня делятся, пока не достигнута max_depth. LightGBM растит по листьям (leaf-wise): на каждом шаге делится тот лист во всём дереве, который даёт максимальный выигрыш.

Leaf-wise при том же числе листьев даёт меньшую ошибку, но строит несбалансированные, глубокие деревья и легче переобучается на малых выборках. Отсюда практическое правило: в LightGBM управляющий параметр — num_leaves (а не max_depth), и его надо держать заметно меньше чем 2^max_depth; типичный старт — num_leaves = 31 при min_data_in_leaf = 20..100. Самая частая причина «LightGBM переобучился» — это num_leaves = 255 на выборке в 20 тысяч строк.

CatBoost: target leakage и ordered boosting

CatBoost (arXiv:1706.09516) решает проблему, которую остальные игнорировали. Две её грани:

  1. Target statistics для категорий. Кодировать категорию средним таргетом по ней — мощно и опасно: значение признака объекта вычислено с использованием его собственного таргета, отсюда сдвиг и переобучение. CatBoost применяет упорядоченные target statistics: объекты виртуально перемешиваются, и кодировка объекта считается только по «прошлым» объектам в этой перестановке, плюс сглаживание априорной частотой.
  2. Ordered boosting. Та же проблема есть у самого бустинга: градиенты g_i считаются моделью, которая уже видела объект i, значит, оценка сдвинута («prediction shift»). CatBoost поддерживает набор моделей, каждая из которых обучена только на префиксе перестановки, и берёт градиент объекта из модели, его не видевшей.

Плюс — oblivious trees (symmetric trees): на каждом уровне дерева используется один и тот же предикат для всех узлов. Это сильная регуляризация и, что важно для прода, чрезвычайно быстрый инференс — путь по дереву превращается в вычисление индекса битовыми операциями, без ветвлений и промахов кэша.

Какую библиотеку брать

Короткая прагматика:

  • Много категориальных признаков, мало времени на тюнинг → CatBoost.
  • Десятки миллионов строк, нужна максимальная скорость → LightGBM.
  • Нужна зрелая экосистема, GPU, интеграции, monotone constraints → XGBoost.
  • Не хочется внешних зависимостей → HistGradientBoosting из sklearn.

Разница в качестве между ними при добросовестном тюнинге обычно в пределах шума; разница в скорости и удобстве — принципиальная.

Эволюция


7. Рабочий пример: бустинг с ранней остановкой и честной оценкой

import numpy as np
import lightgbm as lgb
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import roc_auc_score

params = dict(
    objective="binary",
    metric="auc",
    learning_rate=0.03,        # маленький ν + ранняя остановка вместо перебора n_estimators
    num_leaves=31,             # главный параметр сложности в leaf-wise росте
    min_data_in_leaf=50,       # защита от листьев-выбросов
    feature_fraction=0.8,      # colsample
    bagging_fraction=0.8,      # subsample строк
    bagging_freq=1,            # без этого bagging_fraction не работает — частая ошибка
    lambda_l2=1.0,
    max_bin=255,
    num_threads=-1,
    verbosity=-1,
)

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
oof = np.zeros(len(y))
best_iters = []

for fold, (tr, va) in enumerate(cv.split(X, y)):
    dtrain = lgb.Dataset(X.iloc[tr], y[tr], categorical_feature=cat_cols)
    dvalid = lgb.Dataset(X.iloc[va], y[va], reference=dtrain)

    model = lgb.train(
        params, dtrain,
        num_boost_round=10_000,
        valid_sets=[dvalid],
        callbacks=[
            lgb.early_stopping(stopping_rounds=200, verbose=False),
            lgb.log_evaluation(period=0),
        ],
    )
    oof[va] = model.predict(X.iloc[va], num_iteration=model.best_iteration)
    best_iters.append(model.best_iteration)
    print(f"fold {fold}: AUC={roc_auc_score(y[va], oof[va]):.4f}, iters={model.best_iteration}")

print(f"OOF AUC: {roc_auc_score(y, oof):.4f}")

# финальная модель на всех данных: числа итераций из фолдов, с поправкой на больший объём
final_rounds = int(np.mean(best_iters) * 1.1)
final = lgb.train(params, lgb.Dataset(X, y, categorical_feature=cat_cols),
                  num_boost_round=final_rounds)

Три детали, которые здесь важнее остального:

  1. Ранняя остановка — на валидационном фолде, а не на тесте. Если остановиться по тесту, оценка качества становится оптимистичной, и вы этого не заметите до продакшена.
  2. OOF-предсказания собираются по фолдам — это одновременно и честная оценка, и материал для стекинга (раздел 9).
  3. Финальная модель переобучается на всех данных с усреднённым числом раундов. Альтернатива — усреднить предсказания пяти фолдовых моделей: это уже мини-ансамбль и обычно чуть лучше, но в пять раз дороже на инференсе.

8. Гиперпараметры: что крутить и в каком порядке

Параметр (XGBoost / LightGBM) Что делает Разумный диапазон Приоритет
learning_rate размер шага 0.01–0.1 фиксируем 0.05, не тюним
n_estimators / num_boost_round число деревьев 100–10000 через early stopping
max_depth / num_leaves сложность дерева 3–8 / 15–127 высокий
min_child_weight / min_data_in_leaf минимальный «вес» листа 1–100 высокий
subsample / bagging_fraction доля строк 0.6–1.0 средний
colsample_bytree / feature_fraction доля признаков 0.5–1.0 средний
reg_lambda (L2) сжатие значений листьев 0–10 (лог-шкала) средний
reg_alpha (L1) обнуление листьев 0–10 низкий
gamma / min_gain_to_split порог выигрыша 0–5 низкий
scale_pos_weight дисбаланс классов neg/pos по ситуации

Практический протокол, который экономит дни: сначала подберите пару «сложность дерева + минимальный вес листа», затем подвыборки, затем L2. Learning rate трогайте в последнюю очередь и только вниз. Для автоматизации — байесовская оптимизация (Optuna с LightGBMTunerCV), а не полный перебор: пространство слишком велико для grid search.

Про дисбаланс классов: scale_pos_weight меняет градиенты и, как следствие, портит калибровку вероятностей. Если вам нужны честные вероятности (скоринг, ожидаемая выручка), лучше не трогать веса, а откалибровать выход отдельно — см. https://courses.digitable.life/post/machine-learning/10-model-evaluation/.

Монотонные ограничения — недооценённый инструмент прода

XGBoost, LightGBM и CatBoost умеют требовать монотонности предсказания по признаку:

# скоринг: вероятность дефолта не должна падать с ростом долговой нагрузки
params["monotone_constraints"] = [0, 1, 0, -1, 0]   # +1 возрастание, −1 убывание, 0 без ограничения

Это стоит почти ничего по качеству, но резко повышает доверие бизнеса и проходимость модельного риск-ревью: график «чем выше долг, тем ниже риск» на локальном шумном участке данных убивает проект надёжнее, чем −0.003 AUC.


9. Стекинг: обучаемая комбинация

Голосование и усреднение — фиксированные правила. Стекинг заменяет правило обученной мета-моделью: базовые модели дают предсказания, мета-модель учится, кому и когда верить.

Критическая деталь: мета-модель нельзя обучать на предсказаниях, сделанных на тех же данных, на которых базовые модели обучались, — они там переобучены, и мета-модель выучит неправильные веса доверия. Нужны out-of-fold предсказания.

from sklearn.ensemble import StackingClassifier, RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from lightgbm import LGBMClassifier

stack = StackingClassifier(
    estimators=[
        ("lgbm", LGBMClassifier(n_estimators=400, learning_rate=0.05, verbosity=-1)),
        ("rf",   RandomForestClassifier(n_estimators=500, min_samples_leaf=5, n_jobs=-1)),
        ("lr",   LogisticRegression(max_iter=2000, C=1.0)),
    ],
    final_estimator=LogisticRegression(max_iter=1000),  # простая мета-модель — почти всегда правильный выбор
    cv=5,                 # OOF-предсказания считаются внутри
    stack_method="predict_proba",
    passthrough=False,    # True — добавить исходные признаки на вход мета-модели
    n_jobs=-1,
)
stack.fit(X_train, y_train)

Что важно знать про стекинг:

  • Разнообразие важнее силы. Три версии LightGBM с разными seed дадут почти ноль. LightGBM + линейная модель + kNN + нейросеть — дадут заметный прирост, потому что ошибаются в разных местах.
  • Мета-модель должна быть простой. Логистическая/линейная регрессия, иногда с неотрицательными весами. Бустинг поверх бустинга почти всегда переобучается.
  • Цена/выгода в проде обычно плохая. Типичный прирост — 0.2–1% метрики; цена — N моделей в инференсе, N пайплайнов признаков, N точек отказа. На Kaggle стекинг решает; в проде чаще выигрывает одна хорошо оттюненная модель бустинга плюс усреднение по нескольким seed.

10. Типичные ошибки

  1. Ранняя остановка по тестовой выборке. Самая распространённая и самая незаметная утечка: eval_set=[(X_test, y_test)]. Метрика получается смещённой вверх, порой на несколько процентов.
  2. Target encoding без OOF. Кодирование категории средним таргетом, посчитанное на всей выборке, — прямая утечка. Либо считайте внутри фолдов, либо используйте CatBoost.
  3. num_leaves слишком большой в LightGBM. Leaf-wise рост с num_leaves=255 на небольших данных переобучается мгновенно и молча.
  4. Вера в feature_importances_. MDI смещена к признакам высокой кардинальности; решение убрать «неважные» признаки по ней регулярно выкидывает полезные.
  5. One-hot кодирование категорий высокой кардинальности для деревьев. Дерево на бинарных столбцах вынуждено тратить много уровней, чтобы выделить группу категорий. Лучше — нативная поддержка категорий (LightGBM/CatBoost) или target/ordinal encoding.
  6. Масштабирование признаков «на всякий случай». Для деревьев оно бесполезно: сплиты инвариантны к монотонным преобразованиям признака. Вреда нет, но время есть.
  7. Ожидание экстраполяции. Ансамбль деревьев не экстраполирует: за пределами диапазона обучающих значений он выдаёт константу крайнего листа. Для трендовых временных рядов это ловушка (https://courses.digitable.life/post/machine-learning/13-time-series/); лечится вычитанием тренда или предсказанием разностей.
  8. Некалиброванные вероятности. Бустинг с log-loss калиброван неплохо, но scale_pos_weight, ранняя остановка по AUC и агрессивная регуляризация калибровку портят. Случайный лес систематически «сжимает» вероятности к 0.5 из-за усреднения.
  9. Разные версии библиотеки в обучении и инференсе. Формат модели и дефолты меняются между мажорными версиями LightGBM/XGBoost. Версия должна быть пришпилена и зафиксирована вместе с артефактом (https://courses.digitable.life/post/machine-learning/15-mlops/).
  10. Случайная кросс-валидация на группированных данных. Несколько строк на одного пользователя, разъехавшиеся по фолдам, дают лес и бустинг, которые «узнают» пользователя. Нужен GroupKFold.

11. Как это выглядит в проде

Размер и латентность. 1000 деревьев глубины 6 — это до 64 тысяч листьев на дерево, но реально десятки мегабайт и 0.1–1 мс на объект в однопоточном режиме. Если этого мало: уменьшайте число деревьев (увеличив ν), компилируйте модель в нативный код (Treelite даёт 2–6× ускорение) или экспортируйте в ONNX. CatBoost с oblivious trees изначально быстрее на инференсе.

Батч против онлайна. Ансамбли деревьев не поддерживают инкрементальное дообучение в осмысленном виде — «дообучить бустинг на новых данных» означает дописать деревья поверх, что смещает модель, а не переобучает её. Стандартная схема — периодическое полное переобучение (раз в день/неделю) с автоматической проверкой качества перед выкаткой.

Мониторинг. Помимо метрик качества, следите за распределением предсказаний и за долей объектов, попадающих в «редкие» листья. Дрейф признаков быстро деградирует деревья: модель не экстраполирует и молча начинает возвращать константы крайних листьев.

Интерпретация. Индустриальный стандарт — SHAP с TreeExplainer: точные шепли-значения для ансамблей деревьев за полиномиальное время (Lundberg et al., Nature MI 2020). Он даёт и глобальную важность (среднее |SHAP|, свободное от смещения MDI), и объяснение конкретного решения — в кредитном скоринге это регуляторное требование.

import shap

explainer = shap.TreeExplainer(model)
sv = explainer.shap_values(X_valid)
shap.summary_plot(sv, X_valid)             # глобальная картина
shap.force_plot(explainer.expected_value, sv[0], X_valid.iloc[0])  # одно решение

Деревья или нейросети? Для табличных данных ответ на 2026 год по-прежнему «деревья» в большинстве случаев: Grinsztajn, Oyallon, Varoquaux, Why do tree-based models still outperform deep learning on typical tabular data? (NeurIPS 2022) показывает, что бустинг выигрывает при меньшем бюджете тюнинга, устойчив к неинформативным признакам и не страдает от неровных, кусочно-постоянных зависимостей, которые типичны для табличных данных. Нейросети начинают выигрывать там, где есть текст, изображения, последовательности или очень большие объёмы однородных данных — об этом трек https://courses.digitable.life/post/neural-networks/00-overview/.


12. Мини-итог

  • Ансамбль выигрывает ровно настолько, насколько декоррелированы ошибки его членов: Var = ρσ² + (1−ρ)σ²/M. Число моделей убирает второе слагаемое, разнообразие — первое.
  • Бэггинг и случайный лес снижают дисперсию, требуют сильных нестабильных базовых моделей, параллелятся идеально и дают бесплатную OOB-оценку. Почти не переобучаются от роста n_estimators.
  • Бустинг снижает смещение, строя модели последовательно на антиградиенте потери. Переобучается от роста M — поэтому learning rate и ранняя остановка обязательны.
  • XGBoost = Newton boosting с явной регуляризацией: w_j = −G_j/(H_j+λ) и Gain, выведенные напрямую из целевой функции. LightGBM = гистограммы + leaf-wise + GOSS/EFB (скорость). CatBoost = ordered boosting и упорядоченные target statistics (категории без утечки).
  • Основные риски — не в модели, а в протоколе: утечка через раннюю остановку и target encoding, неправильная схема кросс-валидации, слепая вера в MDI-важности.

Источники

  • Leo Breiman. Bagging Predictors, Machine Learning, 1996 — Springer
  • Leo Breiman. Random Forests, Machine Learning, 2001 — PDF
  • Jerome Friedman. Greedy Function Approximation: A Gradient Boosting Machine, Annals of Statistics, 2001 — Project Euclid
  • Jerome Friedman. Stochastic Gradient Boosting, 1999 — PDF
  • Friedman, Hastie, Tibshirani. Additive Logistic Regression: A Statistical View of Boosting, 2000 — Project Euclid
  • Chen, Guestrin. XGBoost: A Scalable Tree Boosting System, KDD 2016 — arXiv:1603.02754
  • Ke et al. LightGBM: A Highly Efficient Gradient Boosting Decision Tree, NIPS 2017 — PDF
  • Prokhorenkova et al. CatBoost: unbiased boosting with categorical features, NeurIPS 2018 — arXiv:1706.09516
  • Hastie, Tibshirani, Friedman. The Elements of Statistical Learning, главы 10 (boosting), 15 (random forests), 16 (ensemble learning) — бесплатный PDF
  • Grinsztajn et al. Why do tree-based models still outperform deep learning on typical tabular data?, NeurIPS 2022 — arXiv:2207.08815
  • Lundberg et al. From local explanations to global understanding with explainable AI for trees, Nature Machine Intelligence, 2020 — статья
  • Документация: scikit-learn Ensembles, XGBoost, LightGBM, CatBoost

Что дальше

Всё, что мы разбирали до сих пор, — обучение с учителем: у каждого объекта была метка. Следующая статья переходит к задачам, где меток нет вовсе и структуру в данных нужно находить самостоятельно: как определить, что такое «группа похожих объектов», чем k-means отличается от DBSCAN и почему выбор числа кластеров — это отдельная сложная задача.

Кластеризация: k-means, DBSCAN, иерархическая, GMM

Если хочется закрепить измерительную сторону — метрики, кросс-валидация и калибровка разобраны в https://courses.digitable.life/post/machine-learning/10-model-evaluation/, а карта всего трека собрана в https://courses.digitable.life/post/machine-learning/00-overview/.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов