专栏 AI 学习笔记 子专栏 AI 学习笔记 31 篇

Day 9|决策树与随机森林:从「if-else 树」到「民主投票箱」(AI 学习笔记 · 经典机器学习周 · 第 9 篇)

决策树把分类/回归问题拆成一连串「是 / 否」问题,像 20 问游戏——可解释、可视化、白盒模型。但单棵树方差大、易过拟合。随机森林用「多棵树 + Bagging + 特征随机」三个独立机制把单棵树的方差压下来,几乎成为表格数据的默认 baseline——Kaggle 80% 的表格赛冠军都从它起步。


1. 决策树:递归二分特征空间

1.1 一棵树的生长过程

从根节点出发,每一步:

  1. 遍历所有特征 f 和候选阈值 t,计算「按 (f ≤ t) 切分后,左右子节点的纯度提升」
  2. 选提升最大的 (f, t),把当前节点切成两个子节点
  3. 在每个子节点上递归继续,直到满足停止条件(max_depth / min_samples_leaf / 纯度)

叶子节点给出预测:分类任务 = 多数类;回归任务 = 均值。

1.2 直观:20 问游戏

判断「今天打不打网球」:

Outlook = Sunny ?
├── Yes → Humidity 高 ?
│         ├── Yes → 不打
│         └── No  → 打
└── No  → Wind 强 ?
          ├── Yes → 不打
          └── No  → 打

每个内部节点问一个特征,每条路径对应一个 if-else 规则。树的深度 = 最长路径的问答数。

1.3 为什么决策树至今仍被使用

  • 可解释:整棵树就是 if-else,业务方能直接看
  • 无需特征标准化:不依赖距离/梯度
  • 处理异质特征:数值 + 类别混用都行
  • 能捕捉非线性关系 + 特征交互
  • 预测快:O(树深度)

代价:单棵树方差大、训练数据小变化可能结构大变 → 集成(随机森林 / GBDT)登场。

1.4 回归树 vs 分类树

任务 叶子输出 切分准则
回归 叶子内样本均值 MSE 下降(方差下降)
分类 叶子内多数类 Gini / 熵下降(不纯度下降)

2. 不纯度度量:熵、Gini、信息增益

2.1 信息熵(Entropy)

H(p) = -\sum_{k=1}^{K} p_k \log_2 p_k
  • 衡量节点里类别有多「乱」
  • 最纯(只有 1 类):H = 0
  • 最乱(K 类均匀):H = log₂ K
  • 例子:[50 正, 50 负] 的 H = 1.0;[100 正, 0 负] 的 H = 0

2.2 Gini 不纯度

\text{Gini}(p) = 1 - \sum_{k=1}^{K} p_k^2
  • 同样衡量混乱度,曲线和熵几乎重合
  • 二分类时 Gini = 2p(1−p),是熵的泰勒一阶近似
  • 不算 log,计算更快 1 个量级
  • sklearn 默认用 Gini

2.3 三种不纯度对比

  度量 公式 计算复杂度 用途
信息熵 −Σ p log p 需 log ID3 经典  
Gini 1 − Σ p² 只需平方 CART / sklearn 默认  
误分类率 1 − max p 最快 实际很少直接优化  

2.4 信息增益(ID3)

\text{Gain}(f, t) = H(\text{parent}) - \frac{N_L}{N} H(L) - \frac{N_R}{N} H(R)

切分后不纯度下降越多,信息增益越大,特征越有用。

缺陷:ID3 对「取值多的特征」有偏心——比如「身份证号」可以把每个样本单独切到子节点,Gain 极大但毫无泛化能力。

2.5 增益率(C4.5)

C4.5 用「信息增益率」修正:

\text{GainRatio} = \frac{\text{Gain}(f, t)}{\text{SplitInfo}(f, t)}
\text{SplitInfo}(f, t) = -\sum \frac{N_i}{N} \log_2 \frac{N_i}{N}

归一化掉了「切得越碎 Gain 越大」的偏心。

2.6 CART 的 Gini 选择

CART 总是二分裂,选 Gini 下降最大的 (f, t)。Gini 计算比熵快且数学性质接近,工业界首选。sklearn 的 DecisionTreeClassifier 默认 criterion=’gini’。

2.7 三种算法的工程取舍

算法 准则 任务 多分裂 现代主流
ID3 信息增益 分类 是 ✗(偏心严重)
C4.5 增益率 分类 是 部分
CART Gini / MSE 分类 + 回归 否(二分裂) ✓ sklearn / XGBoost

3. 剪枝:防止过拟合

3.1 过拟合表现

树在训练集上完美(每个叶子纯度 100%),但在测试集上塌方——典型的「过拟合」,学到了训练集的噪声。

3.2 预剪枝(Pre-pruning)

边长树边限:

  • max_depth:最大深度,常用 5~10
  • min_samples_split:节点最少样本数才继续分裂
  • min_samples_leaf:叶子最少样本数
  • min_impurity_decrease:分裂必须带来的最小不纯度下降
  • max_leaf_nodes:最多叶子数

优点:训练快、防过拟合;缺点:贪心、可能欠拟合。

3.3 后剪枝(Post-pruning)

先长满一棵树,再用验证集把没用的子树「合并回去」:

C_\alpha(T) = \text{Loss}(T) + \alpha \cdot |T|

|T| = 叶子数,α 控制复杂度惩罚。sklearn 用 cost-complexity pruning(CCP):

clf = DecisionTreeClassifier(random_state=42)
path = clf.cost_complexity_pruning_path(X_train, y_train)
for ccp_alpha in path.ccp_alphas:
    clf_t = DecisionTreeClassifier(random_state=42, ccp_alpha=ccp_alpha)
    clf_t.fit(X_train, y_train)
    # 用验证集挑最佳 α

优点:不贪心、效果上限高;缺点:训练慢一截(需先长满)。

3.4 预剪枝 vs 后剪枝对比

  维度 预剪枝 后剪枝
训练速度 快 慢(先长满)  
欠拟合风险 较高(贪心) 低  
过拟合控制 中 强  
工业默认 sklearn/XGBoost(用) sklearn 提供 ccp_alpha  
适用场景 大数据 / 快速基线 小数据 / 强解释  

3.5 信用卡欺诈检测:为什么后剪枝更稳

信用卡欺诈正样本 < 0.1%,严重不平衡。预剪枝可能在「欺诈模式还没充分暴露」时就停手,导致召回率塌方。后剪枝靠验证集兜底,能更好保留稀有模式的细节。


4. 随机森林:Bagging + 特征随机

4.1 核心思想

单棵树方差大 → 训练 N 棵树「投票」降方差。N 棵树去相关是关键,Bagging 和特征随机是两条独立去相关路径:

\hat{y} = \text{mode}\{T_1(x), T_2(x), ..., T_N(x)\}

N 棵树投票(分类)或取均值(回归)。

4.2 Bagging(样本随机)

每棵树从训练集中有放回地抽 N 个样本(bootstrap 采样)。大约 37% 样本不会被抽到,这就是 OOB(Out-of-Bag)样本。

直觉:每棵树看到的「世界」略有不同,学到的模式不同,集成后多样性高。

4.3 特征随机(column subsampling)

每棵树在每个分裂节点只看随机 m 个特征,而不是全部 d 个:

  • 分类:m = √d (sklearn 默认 max_features='sqrt')
  • 回归:m = d / 3

直觉:防止最强特征垄断每棵树的根节点分裂。

4.4 为什么两个随机都必要

只看 Bagging 不做特征随机:

所有树都会优先用最强特征(比如「信用分」)在根节点分裂,树之间高度相关,Bagging 的方差下降打折扣。

加入特征随机后:

每棵树被迫学「非主流特征」的判别力,多样性显著提升,泛化能力增强。

类比:一群同质化的「专家」(都看信用分)vs 一群异质化的「专家」(有的看信用分、有的看收入、有的看负债),后者的「三个臭皮匠」效应强得多。

4.5 OOB 估计:免费 CV

每棵树有 ~37% 的样本没被抽到,直接当验证集:

from sklearn.ensemble import RandomForestClassifier

clf = RandomForestClassifier(n_estimators=300, oob_score=True, random_state=42)
clf.fit(X_train, y_train)
print(clf.oob_score_)  # OOB 准确率,等价于一次交叉验证

优点:省下一次完整 CV 耗时,适合大 N 数据集。

4.6 两种特征重要性

重要性 来源 优点 缺点
MDI(Mean Decrease Impurity) 节点不纯度下降总和 计算快,内置 偏高分基数特征
Permutation importance 打乱特征看 OOB 掉多少 无偏,稳健 慢
from sklearn.inspection import permutation_importance

# MDI(内置)
importances = clf.feature_importances_

# Permutation importance
result = permutation_importance(clf, X_test, y_test, n_repeats=10, random_state=42)
sorted_idx = result.importances_mean.argsort()

4.7 超参数速查

超参 默认 推荐范围 说明
n_estimators 100 200~1000 越多越稳,但收益递减
max_features ‘sqrt’ sqrt / log2 分类 sqrt,回归 d/3
max_depth None 10~30 太大易过拟合
min_samples_leaf 1 1~10 数据量大时调到 5+
oob_score False True 想要免费 CV 时开
n_jobs None -1 全核并行

5. PyTorch / sklearn 实战

5.1 sklearn:RandomForest 跑 Titanic

import pandas as pd
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, classification_report

# 假设已加载 Titanic 数据 + 预处理(Pclass/Sex/Age/Fare/Embarked)
df = pd.read_csv('titanic.csv')
X = df[['Pclass', 'Sex', 'Age', 'Fare', 'Embarked']].copy()
X['Sex'] = (X['Sex'] == 'male').astype(int)
X['Age'] = X['Age'].fillna(X['Age'].median())
X['Embarked'] = X['Embarked'].fillna('S')
X = pd.get_dummies(X, columns=['Embarked'], drop_first=True)
y = df['Survived']

X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=42, stratify=y)

rf = RandomForestClassifier(n_estimators=300, max_features='sqrt',
                            min_samples_leaf=3, oob_score=True, n_jobs=-1, random_state=42)
rf.fit(X_tr, y_tr)

print(f"OOB score:    {rf.oob_score_:.4f}")
print(f"Test accuracy: {accuracy_score(y_te, rf.predict(X_te)):.4f}")
print(classification_report(y_te, rf.predict(X_te)))

预期:OOB ≈ 0.81,Test ≈ 0.82~0.84(Titanic 的经典区间)。

5.2 特征重要性可视化

import matplotlib.pyplot as plt

# MDI
mdi = pd.Series(rf.feature_importances_, index=X.columns).sort_values(ascending=True)

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

mdi.plot(kind='barh', ax=axes[0], color='steelblue')
axes[0].set_title('MDI Feature Importance')
axes[0].set_xlabel('Mean Decrease Impurity')

# Permutation
from sklearn.inspection import permutation_importance
result = permutation_importance(rf, X_te, y_te, n_repeats=10, random_state=42)
perm = pd.Series(result.importances_mean, index=X.columns).sort_values(ascending=True)
perm.plot(kind='barh', ax=axes[1], color='coral')
axes[1].set_title('Permutation Importance')
axes[1].set_xlabel('Accuracy drop after shuffling')

plt.tight_layout()
plt.show()

Top-3 通常是 Sex / Fare / Pclass——业务上完全合理(妇女儿童优先、票价反映舱位、舱位反映逃生位置)。

5.3 从零实现 CART(简化版)

import numpy as np

def gini(y):
    p = np.bincount(y) / len(y)
    return 1 - np.sum(p ** 2)


def best_split(X, y):
    best_gain, best_feat, best_thr = -1, None, None
    parent = gini(y)
    for feat in range(X.shape[1]):
        for thr in np.unique(X[:, feat]):
            left = y[X[:, feat] <= thr]
            right = y[X[:, feat] > thr]
            if len(left) == 0 or len(right) == 0:
                continue
            gain = parent - (len(left) * gini(left) + len(right) * gini(right)) / len(y)
            if gain > best_gain:
                best_gain, best_feat, best_thr = gain, feat, thr
    return best_feat, best_thr, best_gain


class Node:
    def __init__(self, feat=None, thr=None, left=None, right=None, value=None):
        self.feat = feat; self.thr = thr
        self.left = left; self.right = right
        self.value = value  # 叶子节点的预测


class CartTree:
    def __init__(self, max_depth=8, min_samples_leaf=2):
        self.max_depth = max_depth
        self.min_samples_leaf = min_samples_leaf

    def fit(self, X, y):
        self.root = self._grow(X, y, depth=0)

    def _grow(self, X, y, depth):
        if depth >= self.max_depth or len(y) < 2 * self.min_samples_leaf or gini(y) == 0:
            return Node(value=np.bincount(y).argmax())
        feat, thr, gain = best_split(X, y)
        if gain == 0:
            return Node(value=np.bincount(y).argmax())
        left_mask = X[:, feat] <= thr
        return Node(feat=feat, thr=thr,
                   left=self._grow(X[left_mask], y[left_mask], depth + 1),
                   right=self._grow(X[~left_mask], y[~left_mask], depth + 1))

    def predict(self, X):
        return np.array([self._traverse(x, self.root) for x in X])

    def _traverse(self, x, node):
        if node.value is not None:
            return node.value
        if x[node.feat] <= node.thr:
            return self._traverse(x, node.left)
        return self._traverse(x, node.right)


tree = CartTree(max_depth=6, min_samples_leaf=5)
tree.fit(X_tr.values, y_tr.values)
print(f"Hand-crafted CART acc: {accuracy_score(y_te, tree.predict(X_te.values)):.4f}")

预期:手写 CART 在 Titanic 上 ~0.78~0.82,略低于 sklearn 的 RF(~0.83),但结构和 sklearn 基本对齐。

5.4 网格搜 + 后剪枝

from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import GridSearchCV

param_grid = {
    'max_depth': [3, 5, 7, 10, None],
    'min_samples_leaf': [1, 3, 5, 10],
    'ccp_alpha': [0.0, 0.001, 0.005, 0.01],  # 后剪枝强度
}
grid = GridSearchCV(DecisionTreeClassifier(random_state=42),
                    param_grid, cv=5, scoring='accuracy', n_jobs=-1)
grid.fit(X_tr, y_tr)
print(f"Best params: {grid.best_params_}")
print(f"Best CV: {grid.best_score_:.4f}")
print(f"Test: {grid.score(X_te, y_te):.4f}")

ccp_alpha 是 sklearn 的 cost-complexity 后剪枝 α,α 越大剪得越狠。


6. vs 其他方法对比

6.1 决策树 vs RF vs Boosting

维度 单棵决策树 随机森林 GBDT(XGBoost)
集成方式 — Bagging(并行) Boosting(串行)
降什么 — 方差 偏差
训练速度 极快 中 慢
预测速度 极快 中(N 棵树) 较快(N 棵树)
可解释性 高(白盒) 中(特征重要性) 中(特征重要性 + SHAP)
调参难度 低 低 高
偏差 低 低 更低
方差 高 中 低
默认首选 解释场景 表格 baseline Kaggle 冲榜

6.2 集成学习的两大流派

Bagging(Bootstrap AGGregatING):并行训 N 个独立模型,投票/平均。代表:随机森林。降方差。

Boosting:串行训,每棵新树去拟合前一棵的「错误」(残差 / 负梯度)。代表:AdaBoost / GBDT / XGBoost。降偏差。

直觉:Bagging 是「多个人独立答卷然后投票」,Boosting 是「一个学生反复纠错」。

6.3 RF vs Extra Trees

  维度 RandomForest Extra Trees
分裂阈值 搜索最优 随机选  
偏差 低 稍高  
方差 中 更低  
训练速度 中 更快  

Extra Trees 用随机阈值,增加随机性 → 方差更低,适合极端噪声数据。


7. 常见坑

7.1 训练集和测试集分布不一致时 OOB 不准

症状:OOB score 虚高,实部署效果差 原因:OOB 来自训练集 bootstrap,如果有时间漂移 / 分布漂移,OOB 失效 修法:用 TimeSeriesSplit 或按业务分层采样做真实 CV,OOB 仅作快速 sanity check

7.2 类别特征直接当数值喂

症状:Sex=male=1, female=2, other=3 被决策树当作「other > male > female」的有序关系 修法:① 用 sklearn 的 OrdinalEncoder(树模型可接受);② 类别多时用 OneHotEncoder(树也能跑,只是分裂数增加);③ 树模型对有序类别用 ordinal,对名义类别用 onehot

7.3 max_depth=None 默认值让树长到 100% 训练准确率

症状:Train 99%,Test 70%,典型过拟合 修法:必须设 max_depth=5~15、min_samples_leaf=5+,或在网格搜里加约束

7.4 类别不平衡 RF 全预测多数类

症状:欺诈 / 罕见病 recall < 5% 修法:class_weight='balanced' 或者 SMOTE 过采样;评估用 PR-AUC 而非 accuracy

7.5 n_estimators 设 1000 但 n_jobs=-1 时内存爆

症状:MemoryError 或 swap 爆 修法:减小 n_jobs,或用 joblib.Parallel 流式训练

7.6 把 feature_importances_ 当因果

症状:「性别重要」→ 业务误判「性别是因果」 原因:MDI 只反映「对模型的贡献」,可能是相关性而非因果 修法:Permutation importance + SHAP,做因果推断要靠 A/B test 或因果图

7.7 决策树直接处理高维稀疏特征(如 TF-IDF)

症状:训练慢、效果差 原因:树对高维稀疏特征不友好,split 搜索爆炸 修法:稀疏高维走线性模型(LogReg / SVM + L2),密集低维走树

7.8 训练时间想降,但忘了 n_jobs=-1

症状:RF 训 10 分钟才发现是单核 修法:RF/ET 是 embarrassingly parallel,默认开 n_jobs=-1(全核)

7.9 预测时输入含 NaN

症状:sklearn 0.24+ 直接抛 ValueError 修法:RF 不原生支持 NaN,预处理用 SimpleImputer(中位数 / 众数);或换 xgboost(原生支持 NaN)

7.10 验证集 leak:用全部数据做特征选择再 split

症状:Test acc 虚高 5~10 个百分点 修法:先 train_test_split,再在训练集上做特征选择 / 网格搜,验证集独立评估


8. 自检三问

A. CART 用 Gini 而 ID3 用信息增益,这两者数学上什么关系?为什么工业界更爱 Gini?

要点:Gini 是熵在 p=0.5 附近的泰勒一阶近似:H(p) ≈ log₂ e · [2p(1−p)] ≈ 1.44 × Gini(p)。曲线几乎重合,但 Gini 不算 log,每次分裂计算快一个量级;sklearn / XGBoost 等工业实现全选 Gini。详见 §2.6 + §2.3。

B. 随机森林的「随机」体现在两个独立层面——样本层面的 Bagging 和特征层面的 column subsampling。如果只做 Bagging 不做特征随机,在「最强特征 + 弱相关特征」场景下会出什么问题?

要点:所有树都会优先用最强特征(比如「信用分」)在根节点分裂 → 每棵树的「骨架」几乎相同 → 树之间高度相关 → Bagging 的方差下降打折扣。加入特征随机后,每棵树被迫在「非最强特征」上找分裂,多样性显著提升。详见 §4.4。

C. 预剪枝 max_depth=3 vs 后剪枝让 full tree 收敛到 depth≈10,两种在偏差-方差谱上各在哪一端?为什么信用卡欺诈这种严重不平衡的数据集,后剪枝往往更稳?

要点:① 预剪枝深度浅 → 偏差高、方差低(欠拟合);后剪枝深度深 → 偏差低、方差略高(需 α 调平衡);② 信用卡欺诈正样本 < 0.1%,稀有模式需要足够深的树才能学到,预剪枝在「欺诈信号充分暴露前」就停手 → 召回率塌方;后剪枝靠验证集兜底能保留稀有模式细节。详见 §3.5。


9. 推荐资源

视频

  • StatQuest《Decision Trees》《Random Forests》合集—— 图解最直观
  • 李宏毅《机器学习》决策树章节—— 中文系统讲解
  • Andrew Ng《Machine Learning Specialization》Week 4—— Bagging / Boosting
  • CS229(Stanford)《Decision Trees and Ensemble Methods》—— 数学推导

教科书

  • 《统计学习方法》(李航) 第 5 章—— 决策树 ID3 / C4.5 / CART
  • **《机器学习》(周志华)》第 4 章—— 决策树 + 集成学习
  • 《Hands-On Machine Learning》(Aurélien Géron) 第 6-7 章—— sklearn 实战
  • 《The Elements of Statistical Learning》(Hastie et al.) 第 8 / 15 章—— 决策树 / RF 数学

论文

  • Quinlan 1986《Induction of Decision Trees》—— ID3 起源
  • Quinlan 1993《C4.5: Programs for Machine Learning》—— C4.5
  • Breiman et al. 1984《CART》—— CART 算法 + cost-complexity 剪枝
  • Breiman 2001《Random Forests》—— RF 原始论文,MDI 特征重要性
  • Geurts et al. 2006《Extremely Randomized Trees》—— Extra Trees
  • Louppe 2014《Understanding Random Forests》—— PhD 论文,数学最严谨

博客 / 课程

  • Lilian Weng《Random Forest》—— 集成学习全景
  • 《Interpretable ML》(Christoph Molnar)—— 特征重要性 + SHAP
  • Distill.pub《Visualizing Representations》—— 树的可视化
  • fast.ai 课程 Random Forest 章节—— 工程视角

代码

  • sklearn.ensemble.RandomForestClassifier / Regressor—— 工业默认
  • xgboost / lightgbm—— 表格数据 SOTA,内部用 CART 作基学习器
  • ranger(C++ 实现)—— 高性能 RF,处理超大数据集
  • SHAP—— 解释 RF 特征贡献,优于纯 MDI

10. 本节要点

  • 决策树 = 递归二分特征空间,每个内部节点一个 (feature, threshold),叶子给出预测;白盒、可解释,但单棵方差大。
  • 不纯度:熵 H = −Σ p log p(信息论)、Gini = 1 − Σ p²(快一档,sklearn 默认)、信息增益 = 父节点熵 − 加权子节点熵。
  • 三种算法:ID3(信息增益,偏心)、C4.5(增益率,归一化)、CART(Gini,工业默认,二分裂)。
  • 剪枝:预剪枝(便宜易欠拟合)vs 后剪枝(慢但稳,CCP α· T );不平衡数据后剪枝更稳。
  • 随机森林 = N 棵决策树 + Bagging(bootstrap)+ 特征随机(m=√d),两路独立去相关,降方差。
  • OOB 估计:每棵树的 ~37% 未抽到样本当验证集,免费 CV;MDI 和 Permutation 两种特征重要性互补。
  • vs 单棵树 / GBDT:RF 降方差,GBDT 降偏差;RF 默认首选,GBDT Kaggle 冲榜。

11. 下一节:Day 10 · 梯度提升 XGBoost / LightGBM

主题:从 Bagging 的「并行降方差」翻转到 Boosting 的「串行降偏差」。覆盖:

  • Boosting 核心:每棵新树去拟合前一棵的残差 / 负梯度,串行纠错
  • Gradient Boosting 统一视角:损失 L,每轮学 fₜ 让 L(y, Fₜ₋₁(x)+fₜ(x)) 最小,fₜ ≈ −∂L/∂F
  • XGBoost:二阶泰勒展开 + 正则化项(叶子数 + 叶子权重 L2),工程加速(近似分位直方图、稀疏感知)
  • LightGBM 三大杀器:Histogram-based split + GOSS(单边梯度采样)+ EFB(互斥特征捆绑),比 XGBoost 快一个数量级

产出物:① 在 Titanic 上 XGBoost vs LightGBM 跑同一份数据,对比 AUC + 训练时间;② 用 SHAP 解释 XGBoost 预测,对比 Day 9 RF 的 MDI 重要性。


作者:林馨予 + 林晓月 最后更新:2026-07-04 版权:CC BY-NC-SA 4.0

说明 · 本站内容均为学习笔记与经验总结,所有菜谱与技法请结合实际食材、季节与个人口味灵活调整。涉及生食、营养与健康的内容仅供参考,特殊体质或疾病请咨询专业营养师/医生。