Ансамбли: бэггинг, случайный лес, градиентный бустинг
Одиночное решающее дерево — модель с прекрасной интерпретируемостью и отвратительной устойчивостью: сдвиньте один объект в обучающей выборке, и структура дерева может перестроиться целиком (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, если признаков много).
в узле: случайные mtry признаков] D --> B2[Бутстрэп 2] --> T2[Дерево 2] D --> B3[Бутстрэп ...] --> T3[Дерево ...] D --> BM[Бутстрэп M] --> TM[Дерево M] T1 --> AGG{{Усреднение / голосование}} T2 --> AGG T3 --> AGG TM --> AGG AGG --> OUT[Прогноз F x] B1 -.-> OOB1[OOB-объекты 36.8%] -.-> EVAL[[OOB-оценка качества]] B2 -.-> OOB1
Сложность
Пусть 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): сумма
взвешенных уменьшений критерия по всем узлам, где признак использован. У неё два
известных смещения:
- В пользу признаков с большой кардинальностью. Непрерывный признак или ID с тысячей уникальных значений даёт больше возможностей «случайно» уменьшить impurity, чем бинарный флаг. Классическая демонстрация: добавьте в данные полностью случайный вещественный столбец — он окажется в середине рейтинга важности, обгоняя реальные бинарные признаки.
- Считается на обучающей выборке — то есть частично отражает переобучение.
Более честная альтернатива — 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 —
подвыборка признаков.
Полный цикл одной итерации
в текущих предсказаниях F_m-1"] G --> SUB["Подвыборка строк subsample
и признаков colsample"] SUB --> HIST["Строим гистограммы G_k, H_k по бинам"] HIST --> SPLIT["Жадный поиск сплитов по Gain
с ограничениями depth / leaves / min_child_weight"] SPLIT --> LEAF["Значения листьев w_j = −G_j / H_j + λ"] LEAF --> UPD["F_m = F_m-1 + ν·h_m"] UPD --> VAL["Оценка на валидации"] VAL -- метрика не растёт N раундов --> DONE VAL -- растёт --> LOOP
Реализация с нуля: 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— это минимальная сумма гессианов в листе, а не число объектов. Для регрессии с MSEh_i = 1, и параметр совпадает с числом объектов. Для log-lossh_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) решает проблему, которую остальные игнорировали. Две её грани:
- Target statistics для категорий. Кодировать категорию средним таргетом по ней — мощно и опасно: значение признака объекта вычислено с использованием его собственного таргета, отсюда сдвиг и переобучение. CatBoost применяет упорядоченные target statistics: объекты виртуально перемешиваются, и кодировка объекта считается только по «прошлым» объектам в этой перестановке, плюс сглаживание априорной частотой.
- 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)
Три детали, которые здесь важнее остального:
- Ранняя остановка — на валидационном фолде, а не на тесте. Если остановиться по тесту, оценка качества становится оптимистичной, и вы этого не заметите до продакшена.
- OOF-предсказания собираются по фолдам — это одновременно и честная оценка, и материал для стекинга (раздел 9).
- Финальная модель переобучается на всех данных с усреднённым числом раундов. Альтернатива — усреднить предсказания пяти фолдовых моделей: это уже мини-ансамбль и обычно чуть лучше, но в пять раз дороже на инференсе.
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. Типичные ошибки
- Ранняя остановка по тестовой выборке. Самая распространённая и самая незаметная
утечка:
eval_set=[(X_test, y_test)]. Метрика получается смещённой вверх, порой на несколько процентов. - Target encoding без OOF. Кодирование категории средним таргетом, посчитанное на всей выборке, — прямая утечка. Либо считайте внутри фолдов, либо используйте CatBoost.
num_leavesслишком большой в LightGBM. Leaf-wise рост сnum_leaves=255на небольших данных переобучается мгновенно и молча.- Вера в
feature_importances_. MDI смещена к признакам высокой кардинальности; решение убрать «неважные» признаки по ней регулярно выкидывает полезные. - One-hot кодирование категорий высокой кардинальности для деревьев. Дерево на бинарных столбцах вынуждено тратить много уровней, чтобы выделить группу категорий. Лучше — нативная поддержка категорий (LightGBM/CatBoost) или target/ordinal encoding.
- Масштабирование признаков «на всякий случай». Для деревьев оно бесполезно: сплиты инвариантны к монотонным преобразованиям признака. Вреда нет, но время есть.
- Ожидание экстраполяции. Ансамбль деревьев не экстраполирует: за пределами диапазона обучающих значений он выдаёт константу крайнего листа. Для трендовых временных рядов это ловушка (https://courses.digitable.life/post/machine-learning/13-time-series/); лечится вычитанием тренда или предсказанием разностей.
- Некалиброванные вероятности. Бустинг с log-loss калиброван неплохо, но
scale_pos_weight, ранняя остановка по AUC и агрессивная регуляризация калибровку портят. Случайный лес систематически «сжимает» вероятности к 0.5 из-за усреднения. - Разные версии библиотеки в обучении и инференсе. Формат модели и дефолты меняются между мажорными версиями LightGBM/XGBoost. Версия должна быть пришпилена и зафиксирована вместе с артефактом (https://courses.digitable.life/post/machine-learning/15-mlops/).
- Случайная кросс-валидация на группированных данных. Несколько строк на одного
пользователя, разъехавшиеся по фолдам, дают лес и бустинг, которые «узнают» пользователя.
Нужен
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/.