第四章:决策树与集成学习
决策树这东西,说白了就是「如果-那么」的规则集合。我刚开始做机器学习时,觉得它太简单了,不就是一堆if else嘛。后来在工业界做风控模型时才发现,决策树的可解释性简直是救命稻草——业务方要你解释为什么拒绝贷款申请,你总不能甩给他一个神经网络权重矩阵吧?
4.1 信息增益与基尼系数
决策树的核心问题只有一个:如何选择最优划分特征。这里有两个主流指标,我分别说说我的理解。
4.1.1 信息增益(ID3算法)
信息增益基于信息论中的「熵」概念。熵衡量的是数据的不确定性——熵越大,数据越混乱。
# 计算熵的公式
import numpy as np
def entropy(y):
classes, counts = np.unique(y, return_counts=True)
probs = counts / len(y)
return -np.sum(probs * np.log2(probs))
# 信息增益 = 父节点熵 - 子节点加权熵
def information_gain(X, y, feature_idx, threshold):
parent_entropy = entropy(y)
# 按阈值划分左右子节点
left_mask = X[:, feature_idx] <= threshold
right_mask = ~left_mask
# 加权子节点熵
n = len(y)
child_entropy = (np.sum(left_mask) / n) * entropy(y[left_mask]) + \
(np.sum(right_mask) / n) * entropy(y[right_mask])
return parent_entropy - child_entropy
我在项目中遇到过一个问题:ID3算法偏好取值多的特征。比如「用户ID」这种特征,每个样本一个值,信息增益会特别大,但显然不适合用来做决策。这就是信息增益的天然缺陷。
4.1.2 基尼系数(CART算法)
基尼系数是另一种分裂标准,它衡量的是从数据集中随机抽取两个样本,其类别不一致的概率。基尼系数越小,纯度越高。
def gini(y):
classes, counts = np.unique(y, return_counts=True)
probs = counts / len(y)
return 1 - np.sum(probs ** 2)
# 基尼增益
def gini_gain(X, y, feature_idx, threshold):
parent_gini = gini(y)
left_mask = X[:, feature_idx] <= threshold
right_mask = ~left_mask
n = len(y)
child_gini = (np.sum(left_mask) / n) * gini(y[left_mask]) + \
(np.sum(right_mask) / n) * gini(y[right_mask])
return parent_gini - child_gini
你想想看,基尼系数计算起来比熵快——没有对数运算。CART算法默认用基尼系数,而且生成的是二叉树,结构更简洁。我个人习惯用CART做基线模型,速度快,效果也不差。
| 指标 | 计算公式 | 特点 | 适用算法 |
|---|---|---|---|
| 信息增益 | Entropy(parent) - Σ(weight * Entropy(child)) | 偏好取值多的特征 | ID3 |
| 信息增益率 | 信息增益 / 特征熵 | 惩罚多取值特征 | C4.5 |
| 基尼系数 | 1 - Σ(p_i²) | 计算快,二叉树 | CART |
4.2 决策树剪枝
决策树如果不剪枝,很容易长成「参天大树」——训练集准确率100%,测试集一塌糊涂。这就是典型的过拟合。
剪枝分两种:预剪枝和后剪枝。我个人的经验是:预剪枝更实用,因为后剪枝需要先生成完整树,计算成本高。
4.2.1 预剪枝
在树生长过程中提前停止。常用策略包括:
- 限制最大深度(max_depth)
- 限制叶子节点最少样本数(min_samples_leaf)
- 限制分裂所需的最小样本数(min_samples_split)
- 限制信息增益/基尼增益的最小阈值
4.2.2 后剪枝
先生成完整树,再自底向上剪掉对泛化能力贡献不大的分支。CART的后剪枝用「代价复杂度剪枝」,引入一个参数α来控制树的复杂度。
# 代价复杂度剪枝的损失函数
# Loss = 训练误差 + α * 叶子节点数
# α越大,树越简单
嗯,这里要注意:后剪枝虽然理论上更优,但实际项目中我很少用。为什么?因为数据量大时,生成完整树太慢了。预剪枝配合交叉验证,效果已经足够好。
4.3 随机森林
随机森林是Bagging思想 + 决策树的组合。它做了两件事来降低方差:
- 样本随机:每棵树用Bootstrap采样训练
- 特征随机:每个分裂点只考虑随机子集的特征
我记得有一次做用户流失预测,单棵决策树的AUC只有0.72,随机森林直接干到0.85。这就是集成的力量——弱学习器组合成强学习器。
from sklearn.ensemble import RandomForestClassifier
rf = RandomForestClassifier(
n_estimators=100, # 树的数量
max_depth=10, # 最大深度
min_samples_leaf=5, # 叶子节点最少样本数
max_features='sqrt', # 特征采样比例
random_state=42
)
rf.fit(X_train, y_train)
- n_estimators:越大越好,但收益递减。我一般设100-500
- max_depth:防止过拟合,通常5-20
- max_features:分类问题用sqrt,回归问题用1/3
- min_samples_leaf:防止叶子节点过小,通常1-10
4.4 XGBoost原理与调参
XGBoost是梯度提升决策树(GBDT)的工程化实现。它比随机森林更「聪明」——每棵新树都在拟合前一棵树的残差。
4.4.1 核心原理
XGBoost的目标函数包含两部分:
Obj = Σ L(y_i, ŷ_i) + Σ Ω(f_k)
其中:
- L是损失函数(如平方误差、对数损失)
- Ω是正则项,控制树的复杂度
- Ω(f) = γT + ½λ||w||²
- T是叶子节点数
- w是叶子权重
- γ和λ是正则化参数
说白了,XGBoost在追求「准」的同时,也在追求「简」。这个正则项是它比传统GBDT强的重要原因。
4.4.2 调参实战
我调XGBoost有个固定套路,分享给你:
import xgboost as xgb
# 第一步:设定初始参数
params = {
'objective': 'binary:logistic',
'learning_rate': 0.1, # 学习率,默认0.3
'max_depth': 6, # 树深度,默认6
'subsample': 0.8, # 样本采样比例
'colsample_bytree': 0.8, # 特征采样比例
'gamma': 0, # 分裂所需的最小损失减少
'reg_lambda': 1, # L2正则化
'reg_alpha': 0, # L1正则化
'eval_metric': 'auc'
}
# 第二步:用早停法找最优迭代次数
dtrain = xgb.DMatrix(X_train, label=y_train)
dval = xgb.DMatrix(X_val, label=y_val)
model = xgb.train(
params,
dtrain,
num_boost_round=1000,
evals=[(dtrain, 'train'), (dval, 'val')],
early_stopping_rounds=50,
verbose_eval=50
)
4.4.3 调参顺序
我建议按这个顺序调:
- 先调max_depth和learning_rate:深度3-10,学习率0.01-0.3
- 再调subsample和colsample_bytree:防止过拟合,通常0.6-0.9
- 最后调正则化参数:gamma、reg_lambda、reg_alpha
你想想看,为什么先调深度和学习率?因为这两个参数对模型性能影响最大,先确定它们的合理范围,后面调参才有意义。
知识体系总览
下面这张图展示了本章的核心逻辑:
从决策树到随机森林,再到XGBoost,本质上是一个从「简单规则」到「集成智慧」的演进过程。我个人觉得,理解决策树是基础,掌握集成方法是进阶,而调参则是把理论落地到工程的关键一步。
可可精品资料库