2025, Nov 02 15:01
Почему клон DecisionTreeRegressor расходится с деревом RandomForestRegressor и как их сблизить
Разбираем, почему одно дерево RandomForestRegressor нельзя воспроизвести DecisionTreeRegressor по бутстрапу: роль дубликатов, выбор разбиений и учет весов.
Воспроизвести отдельное дерево решений из обученного RandomForestRegressor с помощью автономного DecisionTreeRegressor на первый взгляд кажется просто: взять точную бутстрап-выборку, которую использовал лес, передать те же гиперпараметры и сид, и обучить модель. На практике получается другое дерево и другие прогнозы. Ключ к разгадке — в том, как лес обращается с повторяющимися строками в бутстрапе и как это влияет на выбор разбиений по сравнению с вычислением значений и ошибок.
Постановка задачи: почему наивный клон расходится
Следующий фрагмент кода обучает лес, извлекает бутстрап-индексы первого дерева и пытается собрать это дерево как автономный регрессор, используя ровно эти строки. Несмотря на одинаковые данные, гиперпараметры и random_state, получившийся клон обычно отличается от первого дерева леса — и по структуре, и по предсказаниям.
# Импорты
import numpy as np
from sklearn.ensemble import RandomForestRegressor
from sklearn.tree import DecisionTreeRegressor
from sklearn.datasets import make_regression
import matplotlib.pyplot as plt
from sklearn.tree import plot_tree
# Размерности данных
n_obs = 160
n_dim = 20
# Обучающая выборка
X_train, y_target = make_regression(
n_samples=n_obs,
n_features=n_dim,
random_state=0,
shuffle=False
)
# Тестовая выборка
np.random.seed(0)
X_eval = np.random.standard_normal((40, n_dim))
# Конфигурация случайного леса
n_trees = 10
max_depth_cfg = 4
min_leaf_cfg = 17
min_split_cfg = 3
forest_model = RandomForestRegressor(
random_state=0,
oob_score=False,
max_features=None,
n_estimators=n_trees,
max_depth=max_depth_cfg,
min_samples_leaf=min_leaf_cfg,
min_samples_split=min_split_cfg,
)
forest_model.fit(X_train, y_target)
rf_pred = forest_model.predict(X_eval)
# Осмотр первого дерева леса
first_est = forest_model.estimators_[0]
plot_tree(first_est, filled=True, rounded=True, node_ids=True, fontsize=16)
plt.figure(figsize=(20, 10))
plt.show()
# Попытка воспроизвести первое дерево, используя его бутстрап-выборку
boot_idx_full = forest_model.estimators_samples_[0]
X_boot_full = X_train[boot_idx_full]
y_boot_full = y_target[boot_idx_full]
clone_tree = DecisionTreeRegressor(
random_state=first_est.random_state,
max_features=None,
max_depth=max_depth_cfg,
min_samples_leaf=min_leaf_cfg,
min_samples_split=min_split_cfg,
)
clone_tree.fit(X_boot_full, y_boot_full)
clone_pred = clone_tree.predict(X_eval)
# Визуализация клона
plot_tree(clone_tree, filled=True, rounded=True, node_ids=True, fontsize=16)
plt.figure(figsize=(20, 10))
plt.show()
Что на самом деле происходит внутри леса
Суть в том, как обрабатываются дубликаты в бутстрапе. В случайном лесе одна и та же строка может попадать в бутстрап одного дерева несколько раз. Для выбора признаков и порогов логика обучения использует каждую уникальную строку ровно один раз. Иными словами, дубликаты не допускаются к влиянию на выбор разбиений по нечистоте. Однако при вычислении «значения» листа и связанных метрик дубликаты учитываются: наблюдение, встретившееся k раз, вносит вклад с весом k. Этого различия достаточно, чтобы DecisionTreeRegressor, обученный на выборке с повторами, принимал иные решения о разбиениях и порогах и, как следствие, имел другую структуру.
Даже при полностью совпадающих данных и параметрах дерево лишь почти детерминировано. Ничьи по нечистоте могут приводить к разным, но эквивалентным порогам, а несколько порогов между двумя соседними наблюдаемыми значениями дают одно и то же разбиение на обучающем наборе. Это ещё один источник небольших расхождений, даже когда обучающая выборка фиксирована.
Исправление: использовать уникальные строки для обучения разбиений, затем учитывать веса для значений и ошибки
Чтобы приблизиться к внутреннему дереву леса, обучайте автономное дерево на уникальных строках бутстрапа (чтобы повторы не смещали выбор разбиений), а затем учитывайте количество повторов, когда нужно воспроизвести значения листьев и метрики ошибки.
# Пересборка с использованием уникальных строк бутстрапа для выбора разбиений
uniq_idx, dup_counts = np.unique(forest_model.estimators_samples_[0], return_counts=True)
X_boot_uniq = X_train[uniq_idx]
y_boot_uniq = y_target[uniq_idx]
rebuilt_tree = DecisionTreeRegressor(
random_state=first_est.random_state,
max_features=None,
max_depth=max_depth_cfg,
min_samples_leaf=min_leaf_cfg,
min_samples_split=min_split_cfg,
)
rebuilt_tree.fit(X_boot_uniq, y_boot_uniq)
rebuilt_pred = rebuilt_tree.predict(X_eval)
plot_tree(rebuilt_tree, filled=True, rounded=True, node_ids=True, fontsize=16)
plt.figure(figsize=(20, 10))
plt.show()
# Пересчет значения листа и ошибки с учетом весов дубликатов
# Пример: левый потомок корня
root_feat = rebuilt_tree.tree_.feature[0]
root_thr = rebuilt_tree.tree_.threshold[0]
left_mask = X_boot_uniq[:, root_feat] <= root_thr
# Среднее без весов (то, что использует автономное дерево)
leaf_mean_unweighted = y_boot_uniq[left_mask].mean()
# Взвешенное среднее, чтобы совпасть со «значением» листа в лесе
leaf_weighted_mean = (y_boot_uniq[left_mask] * dup_counts[left_mask]).sum() / dup_counts[left_mask].sum()
# Невзвешенная MSE, которую использует автономное дерево для этого листа
leaf_mse_unweighted = ((y_boot_uniq[left_mask] - leaf_mean_unweighted) ** 2).mean()
# Взвешенная MSE, которую использует лес для этого листа
n_eff = dup_counts[left_mask].sum()
leaf_mse_weighted = (((y_boot_uniq[left_mask] - leaf_weighted_mean) ** 2) * dup_counts[left_mask]).sum() / n_eff
Эта процедура отражает две разные фазы. Сначала выбор разбиений копирует поведение леса, используя каждую уникальную строку обучающего набора ровно один раз. Затем статистики листьев отражают логику леса за счет взвешивания каждой уникальной строки по количеству ее появлений в бутстрапе. Отсюда понятно, почему дерево, обученное на данных с дубликатами, расходится: повторы меняют расчеты нечистоты и, как следствие, выбранные разбиения.
Насколько «достаточно близко»
Такой подход обычно выравнивает признаки и пороги с соответствующим деревом в лесе. Значения и ошибки затем совпадают, если применять веса по количеству повторов при агрегировании в листьях. Тем не менее добиться побайтной идентичности может не получиться. Ничьи по нечистоте и диапазоны эквивалентных порогов могут приводить к различным, но функционально схожим разбиениям. Кроме того, учет дубликатов как весов внутри леса взаимодействует с гиперпараметрами препруннинга, что еще одна причина, почему обучение на сырых данных с повторами ведет себя не так, как внутренняя логика леса.
Зачем это понимать
Понимание того, что делает дерево внутри леса, помогает в отладке, интерпретации и воспроизводимости. Если ожидать, что одно дерево леса будет точной копией DecisionTreeRegressor, обученного на его бутстрапе с повторами, различия в структуре и числах будут сбивать с толку. Знание того, что для выбора разбиений дубликаты схлопываются, а для значений и ошибок — учитываются как веса, снимает противоречие и позволяет корректно валидировать то, что вы наблюдаете в лесе.
Выводы
Чтобы приблизить внутреннее дерево RandomForestRegressor, обучайте клон на уникальных бутстрап-пробах, чтобы воспроизвести решения о разбиениях, а статистики листьев считайте с учетом количества дублей, чтобы воспроизвести значения и ошибки. Ожидайте почти полного совпадения, а не идеальной идентичности: ничьи по нечистоте и эквивалентные пороги оставляют пространство для допустимых, но произвольных выборов. Когда цель — понять, как лес строит свои деревья, этого взгляда достаточно, чтобы согласовать наблюдаемые различия и ясно рассуждать о работе модели.