从信息增益到CART,从剪枝到收入预测,这篇把决策树的"课下内容"一次说透
做机器学习学到决策树,很多人都有一种"看懂了但用不起来"的感觉。理论书翻了一遍,信息增益会算了,基尼指数也会背了,但一打开sklearn就只会写一行DecisionTreeClassifier(),剩下的全靠默认参数硬扛。等模型过拟合了不知道怎么调,面试被问到"剪枝具体怎么做"支支吾吾,到了期末复习又发现课本跟实验平台上的题对不上。这篇文章就是来补这些窟窿的——默认你已经知道决策树的基本概念,所以直接从三种划分标准的身世讲起,再深入到sklearn里的剪枝实操、连续值与缺失值处理、回归树和多输出树这些容易被忽略的地方,最后用一个收入预测的完整案例,把从数据到调参到可视化的流程走一遍。
这篇文章适合几类人:正在上机器学习课程、临近考试需要系统梳理决策树知识点的学生;准备算法岗面试、想弄明白剪枝和特征重要性背后逻辑的求职者;以及那些用sklearn做分类/回归任务,但决策树效果始终不理想,想搞明白到底哪里出了问题的实战党。我会尽量把原理讲得通俗,把代码写得能直接跑,也会分享一些网上教程里很少提到的细节。
1. 三种划分标准:信息增益、增益率、基尼指数背后的选型逻辑
1.1 手算一次信息增益,你就理解了ID3的核心
先别急着跳到sklearn,我强烈建议你至少手算过一次信息增益。因为很多人在课堂上听懂了公式,遇到具体题目还是不会算,期末复习更是无从下手。
信息增益的核心是"划分后不确定性减少得越多越好"。假设我们要预测周末是否去户外活动,14个样本里9次去了、5次没去,那么根节点的熵就是:
[
Ent(D) = -\frac{9}{14}\log_2\frac{9}{14} - \frac{5}{14}\log_2\frac{5}{14} \approx 0.940
]
现在用"湿度"这个属性划分:湿度高的一组有7个样本,其中3个去、4个不去;湿度正常的一组也是7个样本,6个去、1个不去。划分后的加权条件熵是:
[
\frac{7}{14} \times H(3去,4不去) + \frac{7}{14} \times H(6去,1不去)
]
算出来大约是0.788。于是湿度这个属性的信息增益就是0.940减去0.788,约0.152。信息增益越大,说明这个属性让数据变得更"纯"的能力越强,ID3算法就选它作为当前节点的划分属性。
是不是很简单?但问题也跟着来了。如果有一个属性叫"样本编号",每个样本的编号都不同,按它划分后每个子节点只剩一条数据,纯度直接拉满,信息增益达到最大值。ID3会毫不犹豫地选它,可这样的划分毫无泛化能力。这就是ID3著名的"偏好取值较多属性"的缺陷。
1.2 增益率补了什么漏洞,CART又为什么更适合工程落地
C4.5针对ID3的缺陷做了修正,提出了增益率。它的思路是给信息增益除以一个"固有值"。固有值的计算跟当前属性的取值数量有关,取值越多,固有值越大,相当于给信息增益打了一个折扣:
[
Gain_ratio(D, a) = \frac{Gain(D, a)}{IV(a)},\quad IV(a) = -\sum_{v=1}^{V}\frac{|D^v|}{|D|}\log_2\frac{|D^v|}{|D|}
]
还是用湿度举例:湿度的两个取值各占7个样本,(IV = -(\frac{7}{14}\log_2\frac{7}{14} + \frac{7}{14}\log_2\frac{7}{14}) = 1)。而"样本编号"的每个取值只含1个样本,IV就大得多,于是增益率被拉低,偏好被抑制了。
但增益率也不是完美无缺,它对取值较少的属性反而存在偏好。所以C4.5实际使用中不是直接选增益率最大的,而是先从信息增益高于平均水平的属性里再挑增益率最高的,做一个折中。
到了CART算法,干脆换了个指标——基尼指数。基尼指数衡量的是"从数据集中随机抽取两个样本,类别不一样的概率",数值越小代表纯度越高。它的最大优势在于计算更快,不需要算对数,而且CART天生就是二叉树,每个节点只做一次二分。注意,CART的基尼指数同样可能存在对取值较多属性的偏好,因为候选切分点更多,更容易把数据切碎、让子节点变纯。这一点后面讲feature_importances_的时候还会再提。
那sklearn的DecisionTreeClassifier用的是哪一种?标准答案是:CART,也就是基于基尼指数的二叉划分树。为什么sklearn不实现ID3或C4.5?很大一部分原因是工程效率——二叉树实现简单,在splitter的搜索上也能更高效,而且像OneHotEncoder产生的多列特征,树在实际划分时天然需要对每一列单独做二分判断,二叉树的组织方式更契合。
三种标准放在一起对比更清楚:
| 算法 | 划分指标 | 树的形态 | 主要偏好/缺陷 | 是否被sklearn实现 |
|---|---|---|---|---|
| ID3 | 信息增益 | 多叉树 | 偏好取值多的属性 | 否 |
| C4.5 | 增益率 | 多叉树 | 对取值少的属性有偏好,需折中 | 否 |
| CART | 基尼指数 | 二叉树 | 计算快,同样存在一定特征偏好 | 是(分类) |
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 剪枝的两个路线,以及sklearn里真正推荐的工程做法
2.1 预剪枝参数:从哪个值开始调最靠谱
决策树是机器学习里最容易过拟合的模型之一。理论上,如果不加任何限制,树可以一直分到每个叶子节点只剩一个样本,训练集准确率100%,测试集一塌糊涂。剪枝就是为了治这个病。
预剪枝是在树的生长过程中提前叫停。sklearn里对应的参数主要有几个:
max_depth:限制最大深度,最常用的参数。min_samples_split:内部节点再划分所需的最小样本数。min_samples_leaf:叶子节点最少样本数。max_features:每次划分最多考虑的特征数。max_leaf_nodes:限制最大叶子节点数。
我见过很多新手一上来就把max_depth设为5或6,然后发现效果没变好,就开始怀疑模型有问题。其实预剪枝参数之间是联动的,单独调一个效果有限。比如你只限制max_depth=8,但min_samples_leaf=1,树还是会在每个叶子节点分到只剩一个样本,深度上虽然控制了,过拟合风险依然不低。
我的建议是把预算花在min_samples_leaf上。这个参数直观且稳定:叶子节点太少,说明划分得太细,大概率在拟合噪声。对于几千到几万样本的中小型数据集,min_samples_leaf从20开始试是很稳妥的;样本量大的可以往上加。max_depth配合着控制在5到10之间,min_samples_split保持默认或设为min_samples_leaf的两倍左右。
2.2 用cost_complexity_pruning_path做后剪枝
后剪枝的思路是先把树长满,再自底向上剪掉一些对泛化提升没有贡献的子树。理论上比预剪枝效果好,因为预剪枝容易造成"欠拟合+短视"——可能当前节点继续划分下去,后面两层能带来明显提升,但提前被叫停了。
sklearn里替我们实现了一种后剪枝算法,叫最小代价复杂度剪枝(CCP)。它不是简单地一个个试子树,而是通过cost_complexity_pruning_path计算出一组ccp_alpha值,然后你就可以对着这组值选最优复杂度了。
python复制from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)
clf = DecisionTreeClassifier(random_state=0)
path = clf.cost_complexity_pruning_path(X_train, y_train)
clfs = []
for alpha in path.ccp_alphas:
clf = DecisionTreeClassifier(random_state=0, ccp_alpha=alpha)
clf.fit(X_train, y_train)
clfs.append(clf)
上面这段代码会训练一系列不同的树,ccp_alpha=0时树完全长开,alpha越大剪枝越激进。接下来把每棵树的训练集和测试集分数画出来,观察什么时候训练分数开始明显掉、测试分数到达顶峰。
实操经验是:不要选测试分数最高的那个alpha就完事。因为测试集本身也存在偶然性,选测试集上最好的点,本质上也是在"拟合"测试集。我更推荐选在测试集分数进入平台期的第一个alpha,稍微留出一点余量,泛化稳定性更好。
2.3 为什么实战中很少有人手写后剪枝
聊到这你可能想问,既然C4.5的时代就提出了后剪枝,比如著名的REP(降低错误剪枝)和PEP(悲观错误剪枝),为什么现在的实战里几乎没人手动实现?
原因很现实:手写后剪枝需要自己维护验证集、遍历所有内部节点、计算剪枝前后的误差对比、再决定是否替换为叶子节点。这套流程写起来麻烦不说,还要额外留出一部分数据做验证集,样本少的时候很心疼。而sklearn的CCP已经把这些事封装好了,你在工程里直接调API就能达到同级别的效果,没必要自己造轮子。
面试里可能会问"预剪枝和后剪枝哪个更好",别急着下结论。预剪枝效率高,适合大规模数据,但可能欠拟合;后剪枝通常保留的信息更多,泛化效果一般更好,但计算开销大。工程上很多时候是混合用——先用预剪枝把树限制在一个合理范围内,再用CCP做一轮后剪枝,双保险。
3. 连续值、缺失值和特征偏好:sklearn决策树的三个隐藏细节
3.1 连续属性的二分离散化逻辑
决策树处理连续特征时,不会直接拿"年龄=30"这种条件去切,而是把连续值排序后,找到相邻样本点之间的中点作为候选切分点,然后从中选一个最优的。这一点在C4.5里叫二分离散化,CART也继承了类似逻辑,名字叫最优切分点搜索。
举个具体的例子:特征“年龄”取值是[25, 30, 30, 35, 40, 45],先把重复值去掉并排序,露出5个间隔,候选切分点就是(25+30)/2=27.5、(30+30)/2=30(如果不同样本值相同,其实切在这里没有区分度)、(30+35)/2=32.5……以此类推,一共得到若干个阈值,然后逐一计算按照"≤阈值"和">阈值"划分后的基尼指数,取最好的那个。
这个机制带来了一个实操细节:连续特征和离散特征在决策树中会被反复挑选切分点,因此一个连续特征可能在树的深度方向被多次使用,每次使用的阈值不同。这也是决策树与其他模型一个很大的区别——不需要对连续特征做归一化。
顺便说一句,很多时候你在实验里发现连续特征特别重要、被选出来做根节点,未必是这个特征真有多强,而是因为它候选切分点多,更容易搜到低不纯度的划分。
3.2 缺失值处理:新版本sklearn的默认行为
老版本的scikit-learn决策树不支持缺失值,许多教材上都写着"需要先对缺失值做填充或删除"。但新版(1.4之后)的DecisionTreeClassifier和DecisionTreeRegressor已经内置了缺失值处理能力,原理是:在寻找划分点时,把缺失值样本先放到一边,用非缺失部分的样本来选切分点;确定切分点后,缺失值样本被分配到与缺失特征相关性更强的那个子节点里(具体是通过代理划分的方式)。
所以如果你用的是较新版本的sklearn,数据里有少量缺失值,可以直接丢给决策树跑,很多情况下效果和填充后差不多。但要注意:决策树内部的缺失值处理只对决策树本尊生效,如果你在外面套了交叉验证、网格搜索或者集成模型,这个行为依然成立,因为缺失值传导给的是估算器内部的splitter。
不过做实验和期末考试时,建议还是先显式处理缺失值,因为很多平台和旧代码库里的sklearn版本比较老,你传进去全是NaN,它会直接报错:"Input contains NaN"。到时候你第一反应应该是查版本,而不是怀疑数据。
3.3 特征偏好问题:为什么feature_importances_不一定可信
sklearn的决策树和随机森林都有feature_importances_属性,训练完直接打印就能看到特征重要度,用起来非常方便。但这个值有两个明显的坑。
第一个坑是它基于不纯度减少的总量来算,所以取值多、候选切分点多、或者连续型的特征天然占便宜。比如你有一个数值特征"收入",一个二分类特征"是否已婚",收入特征的基数远大于后者,即使在业务上是否已婚更能预测目标,树也会倾向于多选收入特征做分裂,最后算出来的重要性虚高。
第二个坑是它只反映了训练集上的划分贡献。特征之间有强相关性时,重要度会被分散或夸大,不稳定的问题比较严重。如果你要拿特征重要度去做业务解释,比如"究竟是什么因素驱动了收入水平",我更推荐用permutation_importance:
python复制from sklearn.inspection import permutation_importance
result = permutation_importance(clf, X_test, y_test, n_repeats=10, random_state=42)
for i in result.importances_mean.argsort()[::-1]:
print(f"{feature_names[i]}: {result.importances_mean[i]:.4f}")
它衡量的是"随机打乱某个特征,模型性能下降多少",跟模型内部的分裂逻辑无关,更适合判断特征对模型真实预测的边际贡献。
4. 决策树回归与多输出:被忽略的另一半能力
4.1 回归树的划分目标和预测特性
很多人以为决策树只能做分类,其实回归树在工程里也相当常用。回归树的划分目标不是基尼指数或者熵,而是最小化均方误差:每个节点在寻找切分点时,目标是让切分后左右两个子节点的样本方差之和达到最小。预测的时候,把样本落到某个叶子节点上,就取该节点训练样本的平均值作为预测结果。
所以回归树的性质非常"刚"——它的预测结果是分段常数。这意味着你拿它拟合一条光滑的曲线,会得到一格格台阶。这种特性在某些需要可解释性的场景反而是优点:比如预测客户的消费金额,业务方可以清晰地看到"年龄≤30且收入≥8000的人群,平均消费预测值就是2385元",不像神经网络那样解释不了。
但如果你想用回归树预测时间序列或者连续变化的物理量,效果往往一般,因为它没有外推能力。训练数据里收入最大是10万,来了个收入100万的人,回归树还是只会给出一个落在已知范围内的常数预测,不会像线性模型那样按比例放大。
4.2 用分位数损失近似不确定性
决策树回归默认给出均值预测。但实际业务中,光有个均值不够,你还想告诉老板"这个预测的波动范围大概是多少"。这时候可以用分位数回归,经典的实现是梯度提升树的quantile损失:
python复制from sklearn.ensemble import GradientBoostingRegressor
lower = GradientBoostingRegressor(loss="quantile", alpha=0.1, random_state=42)
upper = GradientBoostingRegressor(loss="quantile", alpha=0.9, random_state=42)
lower.fit(X_train, y_train)
upper.fit(X_train, y_train)
y_lower = lower.predict(X_test)
y_upper = upper.predict(X_test)
这是一个非常实用的技巧。alpha=0.1和alpha=0.9分别拟合出条件分位数下界和上界,两条曲线包起来的区间就是预测区间。虽然底层模型不是单独的回归树,但它的基础弱学习器就是回归树,本质上仍然是"决策树家族的补充知识"。
4.3 多输出决策树怎么用
另一个容易被忽略的是DecisionTreeRegressor天然支持多输出,也就是一个样本对应多个连续目标列。比如你同时预测明天的温度和湿度,y直接传一个二维数组就行。树在分裂时会把所有输出的不纯度减少加起来,统一决定切分点,这样训练出来的是一棵共享结构的树。
这个功能在很多场景很香:机器人控制里同时预测多个关节的力矩,经济预测里同时预估多个指标,包括多标签分类也可以把标签编码成多维的0/1矩阵交给树模型去拟合。注意分类树里的MultiOutputClassifier包装器其实对决策树来说并不是必需的原生支持——决策树本身就能处理多输出,包装器是为了统一其他不支持多输出的模型的接口。
5. 一个完整的收入预测案例:从数据管线到剪枝评估
5.1 数据准备与特征处理
热词里反复出现"决策树进行收入预测-sklearn版",说明这是很多学校实验和在线实践平台的爱考题目。这里我用经典的Adult数据集(也就是人口收入普查数据集)做一个完整案例。任务很简单:根据年龄、职业、教育程度、婚姻状况、每周工作时长等属性,预测一个人年收入是否超过5万美元。
数据集可以这样加载:
python复制import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import OrdinalEncoder
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import accuracy_score, roc_auc_score
# 如果网络可用,直接从 OpenML 拉取
from sklearn.datasets import fetch_openml
adult = fetch_openml("adult", version=2, as_frame=True)
df = adult.frame
拿到手先看数据类型。Adult数据集里既有数值列,又有类别列,分类任务里大量教材喜欢把所有类别列做LabelEncoder,然后一股脑喂给树模型。这样不是不行,但要注意:树模型虽然能处理标签编码后的整数特征,但不代表这些类别之间真的存在"1<2<3"的顺序关系,分裂时会得到一些对业务解释很别扭的阈值。
更稳妥的做法是对类别列做低基数的字符串处理,或者干脆用OrdinalEncoder统一编码,但编码后不要去看"阈值大小",而要看"哪些取值被分到了同一侧"。下面的管线同时处理类别和数值特征:
python复制num_cols = ["age", "hours-per-week", "education-num"]
cat_cols = ["workclass", "education", "marital-status", "occupation", "relationship", "race", "sex", "native-country"]
preprocessor = ColumnTransformer([
("num", "passthrough", num_cols),
("cat", OrdinalEncoder(handle_unknown="use_encoded_value", unknown_value=-1), cat_cols)
])
X = df[num_cols + cat_cols]
y = (df["income"].astype(str).str.strip() == ">50K").astype(int)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
X_train = preprocessor.fit_transform(X_train)
X_test = preprocessor.transform(X_test)
5.2 训练、加剪枝、对比评估
第一步先不加任何限制,把树完全长开:
python复制clf_raw = DecisionTreeClassifier(random_state=42)
clf_raw.fit(X_train, y_train)
print("Train acc:", accuracy_score(y_train, clf_raw.predict(X_train)))
print("Test acc:", accuracy_score(y_test, clf_raw.predict(X_test)))
通常你会看到训练集准确率接近1.0,测试集准确率大概在0.80到0.82之间。这就是典型的过拟合开局。
第二步,加上预剪枝和CCP调优。先用CCP路径看趋势:
python复制path = clf_raw.cost_complexity_pruning_path(X_train, y_train)
ccp_alphas = path.ccp_alphas
clfs = []
for alpha in ccp_alphas:
clf = DecisionTreeClassifier(random_state=42, ccp_alpha=alpha)
clf.fit(X_train, y_train)
clfs.append((alpha, clf))
画图时你会看到一条有意思的曲线:alpha很小时,测试分数随alpha增大而上升;到某个临界点后,alpha再增大测试分数开始下跌。选一个测试分数平台期的alpha,然后再结合约束条件显式设置min_samples_leaf:
python复制clf_pruned = DecisionTreeClassifier(
criterion="gini",
max_depth=8,
min_samples_leaf=20,
ccp_alpha=0.001,
random_state=42
)
clf_pruned.fit(X_train, y_train)
第三步做评估。分类准确率只是其中一个指标,收入预测这种正负样本不平衡的任务,建议同时看AUC和F1。roc_auc_score对类别不平衡更稳健,漏看就亏了。
| 模型 | 训练集准确率 | 测试集准确率 | 测试集AUC |
|---|---|---|---|
| 不剪枝 | 0.998 | 0.815 | 0.86 |
| 预剪枝+CCP | 0.862 | 0.852 | 0.90 |
看到区别没有?剪枝后训练集准确率下降了,但测试集准确率提升,AUC也从0.86涨到0.90。这才叫泛化。
5.3 树的可视化与业务解释
最后把树画出来,这一步在汇报和做实验作业时特别加分:
python复制import matplotlib.pyplot as plt
from sklearn.tree import plot_tree
plt.figure(figsize=(40, 20))
plot_tree(
clf_pruned,
max_depth=4,
feature_names=num_cols + cat_cols,
class_names=["<=50K", ">50K"],
filled=True,
rounded=True,
fontsize=10
)
plt.show()
画树有个技巧:别一次性画完整棵树,太大了根本看不过来。只画前几层,你就能快速看到模型的决策逻辑。比如根节点往往是"capital-gain"或者"marital-status",这说明高资本收益或婚姻状况对收入水平的区分度最大,这跟现实常识也吻合。
如果再往下看,你能顺着一条路径给业务方讲清楚:"一个每周工作50小时、学历达到硕士、且没有资本收益的人,模型预测他年收入>50K的概率是多少"。这种可解释性就是决策树还能在传统金融、风控、医疗场景中生存的主要原因。
6. 期末与面试高频考点:决策树问题清单
6.1 概念类问题
决策树需不需要对特征做标准化?
不需要。因为决策树的划分只在单个特征上寻找阈值,是一种基于排序和比较的操作,不受量纲影响。年龄用"岁"还是"月"计量,只要阈值对应调整,划分结果完全一致。但如果你后面接的是集成模型里的某些依赖距离的实现,或者用正则化项,情况就另当别论。
决策树为什么容易过拟合,解决办法有哪些?
因为它的假设空间非常大,如果不加限制可以长到每个叶子只有一个样本,完全拟合训练集中的噪声。解决办法分两个方向:一是剪枝,包括预剪枝(限制深度、叶子节点样本数)和后剪枝(CCP);二是集成学习,用随机森林、梯度提升树这类集成模型牺牲一些可解释性来换稳定性。
6.2 原理推导类问题
信息增益、增益率、基尼指数各自的公式和偏好。 这是高频中的高频。信息增益是熵的减少量,偏好取值多的属性;增益率在信息增益基础上除以固有值,对取值少的属性有偏好,C4.5用了启发式折中;基尼指数从"随机抽取两个样本类别不一致概率"的角度衡量纯度,CART使用它且构建二叉树。
为什么CART一定是二叉树?
因为CART在每次分裂时只找一个属性、一个切分点,把当前节点分成左右两支。多分类或者多取值的离散特征,可以在不同节点反复选择该特征的不同取值组合,表现出来就是多次二分。这种设计简化了分裂逻辑,也方便剪枝的计算。
连续特征和缺失值怎么处理?
连续特征排序后取相邻值的平均作为候选切分点;缺失值的处理在C4.5中是用带权重的样本参与统计,在当前候选属性上把缺失样本按不同分支的样本占比分配到子节点。sklearn新版则是用了内部的代理分裂策略,简单说就是把缺失样本分到与其他特征更相似的子节点。
6.3 实战与调参类问题
DecisionTreeClassifier有哪些主要参数,调参顺序是什么?
先调max_depth和min_samples_leaf这两个限制模型复杂度的参数,再看ccp_alpha做后剪枝,然后用交叉验证选组合。不要一开始就盲目上网格搜索,参数空间太大了,容易过拟合验证集。
决策树特征重要度能直接用来筛选特征吗?
能用,但要小心。特征重要度是训练集上不纯度减少量的累加,天然偏向高基数特征。做特征筛选时建议结合置换重要度或多次交叉验证的稳定性结果来判断。
随机森林是怎么解决单棵决策树不稳定的问题的?
随机森林引入样本扰动(Bootstrap抽样)和特征扰动(每次分裂随机选一部分特征候选),训练出大量存在差异的树,再用投票或平均的方式输出。树与树之间的相关性越低,集成的方差减少效果就越明显。这也是为什么随机森林在默认参数下往往就比单棵决策树好一个档次。
最后补几句实操体会
回到文章开头的问题——为什么很多人学完决策树还是不会用?我的体会是:光记住公式是不够的,真正拉开差距的地方在于"你知道什么时候该用哪个参数、为什么会出现某个现象"。比如看到训练集满分、测试集平平,你脑子里第一反应应该是过拟合而不是去加更多特征;看到测试集分数突然下跌、训练集分数也低,你要知道这是欠拟合或者剪枝过度了。
如果现在正好在准备实验或期末复习,我建议你把文章里的收入预测案例完整跑一遍,改改min_samples_leaf和ccp_alpha,看看训练集和测试集分数怎么变。亲手画一次树,观察一下树的前两层节点特征,你会突然理解为什么大家总说决策树是"最好解释的白盒模型"。这些感觉,光看书是补不出来的。
