元数据卡
- 前置知识:ch04 ML 基础、ch05 线性模型
- 预计时间:50 分钟
- 核心难度:进阶
- 阅读模式:高度专注
- 完成标志:能实现决策树训练、理解 Bagging 和 Boosting 的区别、使用 XGBoost
你的进度
线性模型在工坊里跑了几天,表现不错——但数据越来越复杂。面积和价格之间不是简单的直线关系,有些拐点、有些分层。
你看了看旁边的工具架,拿出一把更粗糙但更灵活的工具:决策树。它不用算公式,它只是不停地问问题——每一步分裂数据,直到每个分组足够纯净。
你的任务
决策树是一种完全不同的学习范式:通过一系列"是/否"问题分割输入空间。单棵树容易过拟合,但通过集成——Bagging(随机森林)和 Boosting(XGBoost)——它成为过去十年最实战的模型家族之一。
本章分层
- 必读:决策树分裂准则(Gini/信息增益)、随机森林、XGBoost
- 选读:梯度提升的数学推导
- 进阶:直方图加速、分位数近似
破局 · 溯源
想象你在给一个怪物分类。你拿了一把尺子,开始问:身高超过 2 米吗?体重超过 100kg 吗?有翅膀吗?
每个问题砍掉一部分可能性,分裂越来越细——最终每个叶子节点只剩下一类。这棵"问题树"不需要任何数学变换,不需要特征标准化,就是你用直觉做分类的方式。
决策树
决策树的训练核心是:在每个节点选择一个特征和一个切分点,使得分裂后的数据"纯度"最大。
常用纯度指标:
- 信息增益 = 父节点熵 - 加权子节点熵
- Gini 不纯度 = 1 - sum(p_k^2)
# 决策树从零实现(简化版)
import numpy as np
class DecisionTreeNode:
def __init__(self, depth=0, max_depth=5, min_samples=2):
self.depth = depth
self.max_depth = max_depth
self.min_samples = min_samples
self.feature = None
self.threshold = None
self.left = None
self.right = None
self.value = None
def gini(self, y):
classes, counts = np.unique(y, return_counts=True)
prob = counts / len(y)
return 1 - np.sum(prob ** 2)
def build(self, X, y):
n_samples, n_features = X.shape
n_classes = len(np.unique(y))
# 停止条件
if (n_samples < self.min_samples
or n_classes == 1
or self.depth >= self.max_depth):
self.value = np.bincount(y).argmax()
return
best_gain = -1
for f in range(n_features):
thresholds = np.unique(X[:, f])
for t in thresholds:
mask = X[:, f] <= t
if sum(mask) == 0 or sum(~mask) == 0:
continue
gain = self.gini(y) - (
sum(mask)/n_samples * self.gini(y[mask]) +
sum(~mask)/n_samples * self.gini(y[~mask]))
if gain > best_gain:
best_gain = gain
self.feature = f
self.threshold = t
if best_gain < 0:
self.value = np.bincount(y).argmax()
return
mask = X[:, self.feature] <= self.threshold
self.left = DecisionTreeNode(self.depth+1, self.max_depth, self.min_samples)
self.right = DecisionTreeNode(self.depth+1, self.max_depth, self.min_samples)
self.left.build(X[mask], y[mask])
self.right.build(X[~mask], y[~mask])单棵决策树极容易过拟合(除非剪枝或限制深度)。但它有几个先天优势:可解释性极强(你可以把树画出来直接看),对缺失值鲁棒,不需要特征缩放。
随机森林
随机森林用 Bagging(Bootstrap Aggregating)解决决策树的过拟合问题:训练多棵树,每棵树用不同的数据子集(有放回采样)和不同的特征子集,对结果取平均或投票。
from sklearn.ensemble import RandomForestClassifier
rf = RandomForestClassifier(
n_estimators=100,
max_depth=10,
min_samples_split=5,
random_state=42
)
rf.fit(X_train, y_train)
print(f"RF 准确率: {rf.score(X_test, y_test):.3f}")随机森林的两个随机源(数据随机 + 特征随机)让各棵树的相关性降低,投票结果更稳定。在表格数据上,随机森林通常是应该最先尝试的模型。
特征重要性可以从随机森林中直接读到:遍历所有树,统计每个特征在节点分裂时带来的不纯度降低总量。
importances = rf.feature_importances_
for i, imp in enumerate(importances):
print(f"特征 {i}: {imp:.3f}")XGBoost
Boosting(提升)和 Bagging 思路相反:顺序训练一系列弱学习器,每个新学习器重点关注前一个的预测错误。
XGBoost 是梯度提升决策树(GBDT)的工程化实现,在 Kaggle 竞赛中统治了多年。
import xgboost as xgb
dtrain = xgb.DMatrix(X_train, label=y_train)
dtest = xgb.DMatrix(X_test, label=y_test)
params = {
'objective': 'binary:logistic',
'max_depth': 6,
'eta': 0.3, # 学习率
'subsample': 0.8,
'colsample_bytree': 0.8,
'eval_metric': 'logloss'
}
model = xgb.train(
params, dtrain,
num_boost_round=100,
evals=[(dtest, 'eval')],
early_stopping_rounds=10
)XGBoost 的几个关键工程优化:
- 二阶泰勒展开近似损失函数(比一阶梯度信息更准)
- 正则化项直接加入目标函数(控制模型复杂度)
- 列采样和行采样(类似随机森林)
- 分位数近似加速特征搜索
- 稀疏感知算法(对缺失值自动学习分支方向)
Bagging 减少方差,Boosting 减少偏差。随机森林可以在满深度树上工作,XGBoost 需要用浅树(max_depth=3~6)配合学习率逐步逼近。
常见陷阱
- 决策树对数据中的细微变化非常敏感——删掉一个训练样本可能导致整棵树重建。这就是为什么单棵树的稳定性差。
- 随机森林的 n_estimators 不是越大越容易过拟合(它与 Boosting 不同),但更多树需要更多内存和推理时间。
- XGBoost 的 early_stopping_rounds 依赖验证集——验证集不能太大也不能太小。
- 调参顺序:先调树的深度/学习率,再调正则化参数,最后调采样比例。
- 树模型对特征尺度的天然鲁棒是优势,但并不意味着特征工程不重要——特征交叉对树同样有帮助。
通关挑战
- 热身(10 分钟):用 sklearn.datasets.load_iris,分别训练决策树(max_depth=3)、随机森林(100 棵树)、XGBoost(50 轮),比较三个模型的准确率。
- 挑战(30 分钟):用随机森林的 feature_importances_ 做特征选择——只保留最重要的 5 个特征再用 XGBoost 训练,看性能变化。
- 观察:在 XGBoost 中记录每轮迭代的训练和验证 logloss 曲线,观察 Boosting 如何逐步降低误差。
旅人笔记
集成学习是一种"三个臭皮匠顶一个诸葛亮"的工程思维:把多个不完美的模型组合起来,利用它们的多样性降低整体误差。随机森林和 XGBoost 各胜擅场,至今仍是表格数据上的王者。
-> 下一站预告
有监督学习需要标签。但现实中大量数据没有标签——无监督学习登场。