# 02 · 决策树与集成学习 > AI生成|2026年10月26日|codebuddy **本篇目标**:理解决策树如何「问问题」,以及为什么把成百上千棵弱树组合起来(随机森林、梯度提升)会成为**表格数据上最强的一类产品 —— 至今 XGBoost / LightGBM 仍是 Kaggle 表格赛的默认答案**。 **导航**:[栏目首页](./README.md) | 上一篇:[01 · 线性回归与逻辑回归](./01-线性回归与逻辑回归.md) | 下一篇:[03 · SVM、KNN 与朴素贝叶斯](./03-SVM-KNN-朴素贝叶斯.md) --- ## 一、核心概念 **决策树**本质是一串嵌套的 if-else 规则: ```text if 花瓣长度 ≤ 2.45 → 类别 A else if 花瓣宽度 ≤ 1.75 → 类别 B else → 类别 C ``` 它的三个组成部分:**根节点**(全部样本)、**内部节点**(一次特征判定)、**叶节点**(最终预测值)。 **集成学习(Ensemble)**的思路是「三个臭皮匠」:把多个弱模型的结果汇总。三种主流方式: | 方式 | 怎么组合 | 代表 | 主要作用 | | --- | --- | --- | --- | | **Bagging** | 并行训练多个模型,取平均/投票 | 随机森林、ExtraTrees | **降方差** | | **Boosting** | 串行训练,每个新模型补前面的短板 | AdaBoost、GBDT、XGBoost | **降偏差** | | **Stacking** | 用多个模型的输出再训一个元模型 | 多层融合 | 兼顾两者,复杂度高 | --- ## 二、模型原理 ### 2.1 树是怎么长出来的:划分准则 树在每个节点上要回答一个问题:**用哪个特征、在什么阈值切一刀,能让左右两堆样本变得「更纯」?** 衡量「纯不纯」的常用指标: ```text 基尼不纯度 Gini(t) = 1 − Σ p_k² (CART 默认) 信息熵 Entropy(t) = −Σ p_k · log₂ p_k (ID3 / C4.5) 回归任务 MSE(t) = 1/n · Σ (yᵢ − ȳ)² ``` 一次切分的好坏用**不纯度下降量**衡量: ```text Δ = Impurity(父节点) − (n_left/n)·Impurity(左) − (n_right/n)·Impurity(右) ``` 算法遍历所有特征 × 所有候选切点,选 Δ 最大的那刀,然后递归下去。 **关键特性**: - **贪心**:每一步只看当前最优,不保证全局最优(这也是为什么"穷搜最优树"是 NP 难)。 - **不需要特征缩放**:分裂只看排序,量纲无关。 - **能处理非线性、特征交互**:天然支持「年龄>30 且 收入<5000」这类组合规则。 - **不能外推**:回归树的预测是叶节点里训练样本的均值,**训练集中没出现过的取值范围,它预测不出来**——这是树模型做时间序列外推时最大的坑。 ### 2.2 控制复杂度:剪枝 不加限制的树会长到每个叶子只剩一个样本(100% 记住训练集)。常用控制手段: | 参数 | 作用 | 直觉 | | --- | --- | --- | | `max_depth` | 最大深度 | 最常用、最直接的刹车 | | `min_samples_split` | 节点再切最少需要多少样本 | 样本少了就停 | | `min_samples_leaf` | 叶子最少保留多少样本 | 防止长出针状叶子,效果好,常与 boosting 搭配 | | `max_features` | 每次分裂只看多少特征 | 引入随机性,为集成服务 | | `ccp_alpha` | 代价复杂度剪枝系数 | 自底向上按「性价比」剪枝,比贪心限制更优雅 | 代价复杂度剪枝的优化目标: ```text R_α(T) = 树的总不纯度 + α · |叶节点数| —— α 越大,越倾向留下更简单的树 ``` ### 2.3 随机森林(Bagging 的巅峰) **训练流程**: 1. 从训练集做 **B** 次 bootstrap 有放回抽样,得到 B 个略有不同的子数据集(每次约 63.2% 的原始样本被抽到,剩下的是 **OOB 袋外样本**)。 2. 每个子数据集训一棵**不剪枝的深树**;分裂时只看**随机抽出的 m 个特征**(分类默认 m ≈ √p)。 3. 预测时**投票**(分类)或**平均**(回归)。 **它为什么有效**: ```text B 棵树预测的平均,方差 ≈ σ² / B(当各树误差相互独立时) ``` 所以核心是**让树之间的误差尽量不相关**:样本随机(bootstrap)+ 特征随机(`max_features`)双重去相关。注意前提——单棵树必须「又深又不稳」,否则方差本来就低,平均也没有可降的空间。**平均几乎不改变偏差,但显著降低方差**。 **三个自带的实用属性**: - **OOB 误差**:不用额外切验证集,直接拿袋外样本评估:`RandomForestClassifier(oob_score=True)`。 - **特征重要性**:基于不纯度下降累计值的 `feature_importances_`;更可靠的是在验证集上「列打乱」的 `permutation_importance`。 - **几乎不用调参**:`RandomForestClassifier(n_estimators=500, random_state=42)` 通常就能打。 ### 2.4 Boosting:串行纠错 **AdaBoost 思想**:每一轮把上一轮分错的样本权重调高,让下一棵树重点关注它们;最终结果是所有树的加权投票。 **梯度提升(GBDT)思想**:不调样本权重,而是让新树去拟合**当前模型的残差(负梯度)**: ```text F₀(x) = 常数(回归取均值,分类取 log-odds) for m in 1..M: rᵢ = −∂L(yᵢ, F(xᵢ)) / ∂F(xᵢ) ← 伪残差 hₘ = 拟合 {(xᵢ, rᵢ)} 的一棵回归树 Fₘ = Fₘ₋₁ + η · hₘ ← η 是学习率/步长 ``` **XGBoost / LightGBM / CatBoost 相比 sklearn 的 `GradientBoosting` 强在哪**: | 改进 | 说明 | | --- | --- | | 损失函数二阶泰勒展开 | 用上 Hessian 信息,收敛更快、支持自定义损失 | | 目标函数里加正则项 | 惩罚叶节点数与叶子权重,自带防过拟合 | | 直方图 / 分位点算法 | 把连续特征离散化成桶,训练速度提升一个量级 | | Leaf-wise 生长(LightGBM) | 每次挑增益最大的叶子分裂,同样深度下拟合更强 | | 原生支持类别特征与缺失值 | 不用自己做 one-hot,也不用猜缺失值怎么填 | 一句话对比:**RF 是「大家一起平均,别出错」,GBDT 是「一个接一个补锅,越补越准」**。前者抗噪声、后者上限更高,但对噪声和过拟合更敏感(用 `learning_rate` + `subsample` 抑制)。 --- ## 三、实例 ### 3.1 单棵树 vs 随机森林:用交叉验证挑深度 用葡萄酒数据集(3 类、13 个化学特征)演示如何给单棵树选深度,以及森林带来的提升幅度。 ```python import numpy as np import matplotlib.pyplot as plt from sklearn.datasets import load_wine from sklearn.model_selection import train_test_split, cross_val_score, StratifiedKFold from sklearn.tree import DecisionTreeClassifier, export_text from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report wine = load_wine() X, y, names = wine.data, wine.target, wine.feature_names X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 ) cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) # 1) 深度扫描:找单棵树的偏差-方差平衡点 depths = list(range(1, 16)) cv_curve, train_curve = [], [] for d in depths: tree = DecisionTreeClassifier(max_depth=d, random_state=42) cv_curve.append(cross_val_score(tree, X_train, y_train, cv=cv, scoring="accuracy").mean()) tree.fit(X_train, y_train) train_curve.append(tree.score(X_train, y_train)) best_d = depths[int(np.argmax(cv_curve))] print("CV 最优深度 = {},CV accuracy = {:.4f}".format(best_d, max(cv_curve))) plt.plot(depths, cv_curve, "o-", label="CV accuracy") plt.plot(depths, train_curve, "s--", label="train accuracy") plt.xlabel("max_depth"); plt.ylabel("accuracy"); plt.legend(); plt.show() # 2) 单棵树 vs 森林 tree = DecisionTreeClassifier(max_depth=best_d, random_state=42) rf = RandomForestClassifier(n_estimators=500, random_state=42, n_jobs=-1) for tag, m in [("DecisionTree", tree), ("RandomForest", rf)]: s = cross_val_score(m, X_train, y_train, cv=cv, scoring="accuracy", n_jobs=-1) print("{:14s} CV = {:.4f} +/- {:.4f}".format(tag, s.mean(), s.std())) # 3) 看树内部到底问了什么问题 tree.fit(X_train, y_train) print(export_text(tree, feature_names=list(names), max_depth=3)) # 4) 森林给出的特征排序 rf.fit(X_train, y_train) order = np.argsort(rf.feature_importances_)[::-1][:8] plt.barh(np.array(names)[order][::-1], rf.feature_importances_[order][::-1]) plt.title("Top-8 feature importance"); plt.tight_layout(); plt.show() print(classification_report(y_test, rf.predict(X_test), target_names=wine.target_names)) ``` **怎么读输出**: - 深度扫描图上,**train 准确率随深度单调上升,CV 准确率先升后降**,两条线的间距(泛化间隙)直观显示了过拟合程度——这张图就是 01 篇讲的偏差-方差权衡的可视化。 - 森林的 CV **标准差**通常明显小于单棵树,这是 Bagging 降方差的直接证据。 - `export_text` 输出的是可读规则,非常适合拿去和业务同学对拍:「这条规则符不符合直觉?」 ### 3.2 集成模型横向对比 + 自动调参 ```python import numpy as np from sklearn.datasets import load_wine from sklearn.model_selection import (train_test_split, StratifiedKFold, cross_val_score, RandomizedSearchCV) from sklearn.pipeline import Pipeline from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression from sklearn.ensemble import (RandomForestClassifier, ExtraTreesClassifier, GradientBoostingClassifier, AdaBoostClassifier, HistGradientBoostingClassifier) X, y = load_wine(return_X_y=True) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 ) cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) models = { "LogisticRegression": Pipeline([("s", StandardScaler()), ("m", LogisticRegression(max_iter=5000))]), "RandomForest": RandomForestClassifier(n_estimators=500, random_state=42), "ExtraTrees": ExtraTreesClassifier(n_estimators=500, random_state=42), "GradientBoosting": GradientBoostingClassifier(random_state=42), "AdaBoost": AdaBoostClassifier(n_estimators=200, random_state=42), "HistGradientBoosting": HistGradientBoostingClassifier(random_state=42), } for name, model in models.items(): s = cross_val_score(model, X_train, y_train, cv=cv, scoring="accuracy", n_jobs=-1) print("{:22s} CV = {:.4f} +/- {:.4f}".format(name, s.mean(), s.std())) # 对最强的一类做随机搜索(用候选列表代替分布,避免额外依赖) pipe = Pipeline([("rf", RandomForestClassifier(random_state=42, n_jobs=-1))]) space = { "rf__n_estimators": [300, 500, 1000], "rf__max_depth": [None, 6, 10, 16], "rf__max_features": ["sqrt", "log2", 0.3], "rf__min_samples_leaf": [1, 2, 4], } search = RandomizedSearchCV(pipe, space, n_iter=25, cv=cv, scoring="f1_macro", random_state=42, n_jobs=-1) search.fit(X_train, y_train) print("\n最优参数:", search.best_params_) print("CV f1_macro = {:.4f} 测试集 f1_macro = {:.4f}".format( search.best_score_, search.score(X_test, y_test))) ``` **怎么读输出**: - 逻辑回归作为基线:如果它和森林差不多,说明这个数据基本是线性的,上森林往往得不偿失(部署与维护成本更高)。 - `HistGradientBoostingClassifier` 是 sklearn 里对标 LightGBM 的实现,**样本上万时它的速度远超 `GradientBoosting`**。 - 随机搜索只跑 25 组通常就能接近最优——超参数空间里大部分维度互相不敏感,这个经验规律会在 06 篇展开。 --- ## 四、常见坑与调参要点 | 坑 | 现象 | 解决 | | --- | --- | --- | | 树越深越好 | CV 分数反而降 | 扫描 `max_depth`、看学习曲线 | | 用不纯度重要性做特征选择 | 高基数特征(ID、手机号)排第一 | 改用 `permutation_importance` | | 树模型做外推预测 | 预测值永远落在训练范围内 | 改用线性趋势 + 树拟合残差,或直接用线性模型 | | 缺失值处理 | sklearn 树模型默认不接受 NaN | 加 `SimpleImputer`;或用 LGBM/XGBoost 原生处理 | | 类别变量 one-hot 爆炸 | 上千列极度稀疏 | 序数编码 + `HistGradientBoosting` 的类别支持 | | Boosting 过拟合 | 训练集 1.0,测试集崩 | 调低 `learning_rate`、`subsample=0.8`、`max_depth=3~6`、早停 | | 数据不平衡 | 少数类几乎不被预测 | `class_weight="balanced_subsample"`(XGBoost 用 `scale_pos_weight`) | **调参优先级**(表格数据通用): 1. `n_estimators`:越大越好直到边际收益消失(配合 `n_jobs=-1`)。 2. `max_depth` / `min_samples_leaf`:控制单棵树复杂度,最敏感的一组。 3. `learning_rate`(Boosting):降到 0.05 左右并相应加大 `n_estimators`,通常最稳。 4. `max_features`:森林里一般用默认值(`sqrt`),调它的收益通常最小。 --- ## 五、小结 - 决策树 = 贪心地一次次找最优切分;可解释、免缩放、能抓非线性,但**高方差、不能外推**。 - **Bagging(随机森林)降方差**,赢在「稳」;**Boosting 降偏差**,赢在「准」。先用森林拿基线,再用 GBDT 冲上限。 - 集成的第一原则是**基模型要有差异又有能力**:完全相同的树平均一百次也没用。 - 工业界表格任务的首选路径:逻辑回归基线 → 随机森林 → LightGBM / XGBoost 调参。 下一篇换三条完全不同的思路:[03 · SVM、KNN 与朴素贝叶斯](./03-SVM-KNN-朴素贝叶斯.md)。