Привет, Хабр! Меня зовут Андрей Бирюков. Я — независимый эксперт в области ИТ и ИБ, преподаю в учебных центрах и пишу статьи и книги.
В современном машинном обучении градиентный бустинг занимает уникальное положение. На табличных данных он часто превосходит более сложные архитектуры, оставаясь при этом алгоритмом, чья внутренняя логика поддается математически строгой интерпретации. Его принцип — последовательное исправление ошибок, то есть превращение ансамбля слабых моделей в мощный предсказательный инструмент.
Бустинг как последовательная компенсация ошибок
В основе бустинга лежит идея, противоположная бэггингу: если в случайном лесу базовые алгоритмы строятся независимо, то в бустинге каждый следующий алгоритм нацелен на исправление ошибок уже построенной композиции. Этот последовательный подход позволяет ансамблю постепенно улучшаться на тех участках данных, где предыдущие модели «не доработали».
Рассмотрим процесс на примере регрессии с квадратичной функцией потерь. Итоговый алгоритм ищется в виде суммы базовых моделей:

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

Если прибавить эти остатки к ответам построенного алгоритма, ошибка на обучающей выборке исчезнет. Поэтому второй алгоритм настраивается так, чтобы его предсказания были как можно ближе к этим остаткам. Каждый следующий алгоритм обучается на остатках, оставшихся после предыдущих. Ниже представлен пример на Python ручной реализации бустинга для регрессии.
import numpy as np import matplotlib.pyplot as plt from sklearn.tree import DecisionTreeRegressor from sklearn.metrics import mean_squared_error from sklearn.datasets import make_regression # Генерируем синтетические данные X, y = make_regression(n_samples=200, n_features=1, noise=15, random_state=42) # Параметры бустинга n_estimators = 50 learning_rate = 0.1 max_depth = 2 # Инициализация: базовое предсказание (среднее значение) F = np.full_like(y, np.mean(y), dtype=float) models = [] # Последовательное обучение for i in range(n_estimators): # Вычисляем остатки (невязки) residuals = y - F # Обучаем дерево на остатках tree = DecisionTreeRegressor(max_depth=max_depth) tree.fit(X, residuals) # Получаем предсказания дерева tree_pred = tree.predict(X) # Обновляем ансамбль с learning rate F += learning_rate * tree_pred # Сохраняем дерево models.append(tree) # Выводим прогресс каждые 10 итераций if (i + 1) % 10 == 0: mse = mean_squared_error(y, F) print(f"Итерация {i+1}: MSE = {mse:.4f}") # Функция предсказания для нового объекта def predict(X_new, models, learning_rate, initial_pred): pred = np.full(len(X_new), initial_pred) for tree in models: pred += learning_rate * tree.predict(X_new) return pred # Визуализация результатов X_test = np.linspace(X.min(), X.max(), 500).reshape(-1, 1) y_pred = predict(X_test, models, learning_rate, np.mean(y)) plt.figure(figsize=(12, 6)) plt.scatter(X, y, alpha=0.5, label='Обучающие данные') plt.plot(X_test, y_pred, color='red', linewidth=2, label='Ансамбль из 50 деревьев') plt.xlabel('Признак') plt.ylabel('Целевая переменная') plt.legend() plt.title('Градиентный бустинг: последовательная коррекция ошибок') plt.show()
Этот код демонстрирует ключевую идею бустинга: каждое следующее дерево обучается на остатках предыдущих, постепенно уменьшая ошибку ансамбля. Параметр learning_rate контролирует вклад каждого дерева — типичное значение 0.1 позволяет избежать переобучения.
Обобщение через градиент: переход к произвольным функциям потерь
При этом, важно учитывать, что остатки можно интерпретировать как антиградиент функции потерь по ответу модели. Для квадратичной функции потерь мы будем следующее:

Это наблюдение позволяет обобщить метод на любые дифференцируемые функции потерь. На каждом шаге мы:
Вычисляем антиградиент функции потерь по текущим предсказаниям модели
Обучаем базовый алгоритм приближать этот антиградиент
Добавляем полученный алгоритм в композицию с некоторым коэффициентом
По сути, это градиентный спуск, но выполняемый не в пространстве параметров, а в функциональном пространстве — пространстве возможных предсказаний модели на объектах обучающей выборки. Каждый новый базовый алгоритм задает направление наискорейшего убывания функционала ошибки.
На Python реализовать градиентный бустинг с произвольной функцией потерь можно следующим образом:
import numpy as np from sklearn.tree import DecisionTreeRegressor from scipy.special import expit # sigmoid для логистической регрессии class GradientBoostingCustomLoss: """ Реализация градиентного бустинга для произвольной дифференцируемой функции потерь. """ 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.initial_pred = None def _compute_gradient(self, y, pred, loss_type='mse'): """ Вычисляет градиент функции потерь по предсказаниям. Для MSE: градиент = pred - y (умноженный на -1 для антиградиента) Для LogLoss: антиградиент = y - sigmoid(pred) """ if loss_type == 'mse': # Антиградиент для MSE: y - pred return y - pred elif loss_type == 'logloss': # Антиградиент для логистической потери: y - sigmoid(pred) return y - expit(pred) else: raise ValueError("Поддерживаются только 'mse' и 'logloss'") def fit(self, X, y, loss_type='mse'): # Инициализация: константное предсказание, минимизирующее функцию потерь if loss_type == 'mse': self.initial_pred = np.mean(y) elif loss_type == 'logloss': # Для логистической потери: log(mean(y) / (1 - mean(y))) p = np.mean(y) self.initial_pred = np.log(p / (1 - p)) F = np.full(len(y), self.initial_pred, dtype=float) self.models = [] for i in range(self.n_estimators): # Вычисляем антиградиент gradient = self._compute_gradient(y, F, loss_type) # Обучаем дерево на антиградиенте tree = DecisionTreeRegressor(max_depth=self.max_depth) tree.fit(X, gradient) # Получаем предсказания дерева tree_pred = tree.predict(X) # Обновляем предсказания ансамбля F += self.learning_rate * tree_pred # Сохраняем дерево self.models.append(tree) # Логирование if (i + 1) % 20 == 0: if loss_type == 'mse': loss = np.mean((y - F) ** 2) else: # LogLoss p_pred = expit(F) loss = -np.mean(y * np.log(p_pred + 1e-15) + (1-y) * np.log(1 - p_pred + 1e-15)) print(f"Итерация {i+1}: Loss = {loss:.6f}") def predict(self, X): pred = np.full(len(X), self.initial_pred, dtype=float) for tree in self.models: pred += self.learning_rate * tree.predict(X) return pred def predict_proba(self, X): """Для бинарной классификации возвращает вероятности""" return expit(self.predict(X)) # Демонстрация на задаче классификации from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score, roc_auc_score # Генерируем данные для классификации X_clf, y_clf = make_classification(n_samples=1000, n_features=20, n_informative=15, random_state=42) X_train, X_test, y_train, y_test = train_test_split(X_clf, y_clf, test_size=0.3, random_state=42) # Обучаем градиентный бустинг с логистической функцией потерь gb_clf = GradientBoostingCustomLoss(n_estimators=100, learning_rate=0.1, max_depth=3) gb_clf.fit(X_train, y_train, loss_type='logloss') # Оценка качества y_pred_proba = gb_clf.predict_proba(X_test) y_pred_class = (y_pred_proba > 0.5).astype(int) accuracy = accuracy_score(y_test, y_pred_class) auc = roc_auc_score(y_test, y_pred_proba) print(f"\nКачество классификации:") print(f"Accuracy: {accuracy:.4f}") print(f"ROC-AUC: {auc:.4f}")
Этот пример показывает, как метод градиентного спуска в функциональном пространстве позволяет адаптировать бустинг к произвольным задачам. Для логистической потери антиградиент имеет вид y - sigmoid(pred), что делает возможным использование бустинга для бинарной классификации.
Базовые алгоритмы и их роль
Для базовых алгоритмов в градиентном бустинге обычно выбирают решающие деревья небольшой глубины. Такие деревья обладают большим смещением (bias), но не склонны к переобучению, что идеально соответствует концепции «слабых» алгоритмов.
В процессе обучения каждого нового дерева смещение композиции уменьшается, а разброс (variance) остается неизменным или растет незначительно.

Важная особенность деревьев — их структура: каждое дерево разбивает пространство признаков на непересекающиеся области и в каждой области выдает константный ответ:

Где RjRj — области разбиения, bnjbnj — значения в листьях. Это позволяет после построения дерева улучшить качество композиции, подобрав свой коэффициент для каждого листа:

Пример оптимизации значений на листьях дерева представлен ниже.
import numpy as np from sklearn.tree import DecisionTreeRegressor from sklearn.datasets import make_regression class GradientBoostingLeafOptimization: """ Градиентный бустинг с оптимизацией значений в листьях деревьев. """ 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.leaf_values = [] # Храним оптимальные значения для каждого листа self.initial_pred = None def fit(self, X, y): self.initial_pred = np.mean(y) F = np.full(len(y), self.initial_pred, dtype=float) self.models = [] self.leaf_values = [] for i in range(self.n_estimators): # Вычисляем остатки residuals = y - F # Обучаем дерево на остатках tree = DecisionTreeRegressor(max_depth=self.max_depth) tree.fit(X, residuals) # Получаем предсказания дерева и индексы листьев tree_pred = tree.predict(X) leaf_indices = tree.apply(X) # Номера листьев для каждого объекта # Оптимизируем значения в каждом листе unique_leaves = np.unique(leaf_indices) leaf_opt_values = {} for leaf in unique_leaves: mask = leaf_indices == leaf # Для квадратичной потери оптимальное значение = mean(residual) # Но мы можем использовать line search для других функций потерь opt_value = np.mean(residuals[mask]) leaf_opt_values[leaf] = opt_value # Обновляем предсказания для объектов в этом листе F[mask] += self.learning_rate * opt_value # Сохраняем дерево и оптимизированные значения self.models.append(tree) self.leaf_values.append(leaf_opt_values) # Логирование if (i + 1) % 20 == 0: mse = np.mean((y - F) ** 2) print(f"Итерация {i+1}: MSE = {mse:.6f}") def predict(self, X): pred = np.full(len(X), self.initial_pred, dtype=float) for i, (tree, leaf_vals) in enumerate(zip(self.models, self.leaf_values)): leaf_indices = tree.apply(X) tree_pred = np.array([leaf_vals[leaf] for leaf in leaf_indices]) pred += self.learning_rate * tree_pred return pred # Демонстрация X_reg, y_reg = make_regression(n_samples=500, n_features=5, noise=10, random_state=42) gb_leaf = GradientBoostingLeafOptimization(n_estimators=100, learning_rate=0.1, max_depth=3) gb_leaf.fit(X_reg, y_reg) # Сравнение со стандартной реализацией from sklearn.ensemble import GradientBoostingRegressor sklearn_gb = GradientBoostingRegressor(n_estimators=100, learning_rate=0.1, max_depth=3, random_state=42) sklearn_gb.fit(X_reg, y_reg) print(f"Custom GB (leaf optimization) MSE: {np.mean((y_reg - gb_leaf.predict(X_reg)) ** 2):.4f}") print(f"Sklearn GB MSE: {np.mean((y_reg - sklearn_gb.predict(X_reg)) ** 2):.4f}")
Оптимизация значений в листьях улучшает качество ансамбля, позволяя каждому дереву точнее подстраиваться под текущие остатки. В современных реализациях, таких как XGBoost, эта оптимизация выполняется с учетом регуляризации для предотвращения переобучения.
Проблема смещения предсказаний и Ordered Boosting
Все классические реализации градиентного бустинга страдают от фундаментальной проблемы: модель, полученная после нескольких шагов, опирается на целевые переменные всех обучающих примеров. Это приводит к смещению распределения предсказаний для обучающих объектов относительно распределения для тестовых объектов — явлению, которое исследователи называют «prediction shift».
Источник этой проблемы — особый вид утечки целевой переменной (target leakage). На каждом шаге мы вычисляем градиент на основе предсказаний модели для тех же объектов, на которых модель обучалась. Это создает замкнутый круг: модель подстраивается под шум в данных, а затем этот шум влияет на следующие шаги обучения.
Важно отметить, что эта проблема характерна не только для градиентного бустинга, но и для методов предобработки категориальных признаков. При преобразовании категорий в числовые значения на основе целевой переменной (например, вычисление среднего целевого значения по категории) возникает та же утечка — статистика для каждой категории вычисляется с использованием информации о целевых значениях тех же объектов.
Решить эту проблему можно через Ordered Boosting — модификацию алгоритма, использующую перестановки данных. Идея заключается в том, чтобы при вычислении градиента для объекта использовать только информацию о предыдущих объектах в некоторой перестановке, полностью исключая использование информации о самом объекте. Это позволяет избежать утечки целевой переменной и значительно улучшает качество обобщения.
Аналогичный подход применяется к обработке категориальных признаков: для каждого объекта значение целевой статистики вычисляется на основе предыдущих объектов в перестановке, что исключает смещение.
Ниже представлен пример упрощенной реализации Ordered Boost.
import numpy as np from sklearn.tree import DecisionTreeRegressor from sklearn.model_selection import train_test_split class OrderedGradientBoosting: """ Упрощенная реализация Ordered Boosting из CatBoost. Использует несколько перестановок для устранения prediction shift. """ def __init__(self, n_estimators=50, learning_rate=0.1, max_depth=2, n_permutations=3): self.n_estimators = n_estimators self.learning_rate = learning_rate self.max_depth = max_depth self.n_permutations = n_permutations self.models = [] self.permutations = [] self.initial_pred = None def fit(self, X, y): n_samples = len(y) self.initial_pred = np.mean(y) # Генерируем несколько случайных перестановок for perm_id in range(self.n_permutations): perm = np.random.permutation(n_samples) self.permutations.append(perm) # Обучаем ансамбль на этой перестановке F = np.full(n_samples, self.initial_pred, dtype=float) trees = [] # Для каждого объекта используем только предыдущие объекты в перестановке for i in range(self.n_estimators): # Создаем буфер для хранения предсказаний для каждого объекта # Используем только объекты, идущие раньше в перестановке F_ordered = np.zeros(n_samples) for pos, idx in enumerate(perm): # Для объекта idx используем только первые pos объектов в перестановке if pos > 0: # Обучаем дерево на предыдущих объектах prev_indices = perm[:pos] X_prev = X[prev_indices] y_prev = y[prev_indices] # Вычисляем остатки на предыдущих объектах residuals_prev = y_prev - F[prev_indices] # Обучаем дерево tree = DecisionTreeRegressor(max_depth=self.max_depth) tree.fit(X_prev, residuals_prev) # Предсказываем для текущего объекта F_ordered[idx] = F[idx] + self.learning_rate * tree.predict(X[idx].reshape(1, -1))[0] else: # Для первого объекта используем базовое предсказание F_ordered[idx] = self.initial_pred # Обновляем предсказания для всех объектов F = F_ordered trees.append(tree) self.models.append(trees) def predict(self, X): # Усредняем предсказания по всем перестановкам all_preds = [] for trees in self.models: pred = np.full(len(X), self.initial_pred, dtype=float) for tree in trees: pred += self.learning_rate * tree.predict(X) all_preds.append(pred) return np.mean(all_preds, axis=0) # Демонстрация X_ordered, y_ordered = make_regression(n_samples=300, n_features=3, noise=5, random_state=42) # Сравнение обычного бустинга и Ordered Boosting gb_standard = GradientBoostingCustomLoss(n_estimators=50, learning_rate=0.1, max_depth=2) gb_standard.fit(X_ordered, y_ordered, loss_type='mse') gb_ordered = OrderedGradientBoosting(n_estimators=50, learning_rate=0.1, max_depth=2, n_permutations=3) gb_ordered.fit(X_ordered, y_ordered) print(f"Standard GB MSE: {np.mean((y_ordered - gb_standard.predict(X_ordered)) ** 2):.4f}") print(f"Ordered GB MSE: {np.mean((y_ordered - gb_ordered.predict(X_ordered)) ** 2):.4f}")
Хотя эта реализация сильно упрощена, она иллюстрирует основную идею Ordered Boosting: использование информации только о предыдущих объектах в перестановке позволяет избежать утечки целевой переменной.
Что в «сухом остатке»
Подведем краткий итог, того, о чем мы говорили в этой статье. Градиентный бустинг требует тщательной настройки гиперпараметров. Важнейший из них — скорость обучения (learning rate), или коэффициент при каждом новом алгоритме. Слишком большой шаг может привести к переобучению, слишком маленький — к медленной сходимости.
Глубина деревьев также критична. Очень глубокие деревья становятся слишком сильными и нарушают концепцию «слабых» алгоритмов, увеличивая риск переобучения. Слишком мелкие могут не захватывать важные зависимости. На практике деревья глубиной 4–8 обычно дают хороший баланс.
Благодаря своей гибкости, градиентный бустинг находит применение в широком спектре задач от поискового ранжирования и рекомендательных систем до прогнозирования погоды и таргетирования рекламы. Однако на однородных данных — изображениях, текстах, звуке — нейросетевые подходы показывают лучшее качество. Поэтому важно учитывать, что для разных видов задач необходимо использовать различные инструменты ИИ.
Градиентный бустинг проще понять на практике: увидеть, как модель последовательно исправляет ошибки и как параметры влияют на качество.
Продолжить тему можно на бесплатных открытых уроках OTUS от преподавателей-практиков. Записывайтесь и готовьте свои вопросы:
1 июля в 18:00. «Градиентный бустинг — мощный алгоритм ансамблирования в ML»: разбор механики бустинга и ансамблирования.
15 июля в 18:00. «Решаем задачу регрессии методами ML на Python»: практика ML на задаче регрессии.