如果有人问我,机器学习所有算法里哪个最像人做决策,我会毫不犹豫说是决策树。这个机器学习系列笔记写到第八篇,前面聊过的线性回归、逻辑回归、支持向量机,无论推导多漂亮,在预测的那一刻基本都还是"一把尺子量到底";决策树则完全换了思路,它把判断拆成一连串的 if-else 问题:花瓣长度小于 2.45 就归为这一类,否则继续问花瓣宽度,一路问到叶子节点,最后给出结论。正因为它天然自带可解释性,直到今天,决策树依然活跃在风控、医疗辅助诊断、业务规则提取这些"既要准、又要能解释"的场景里。
想要真正吃透决策树,翻来覆去其实就两件事:划分选择和剪枝处理。划分选择决定了树往哪个方向长,剪枝处理决定了树应该长多深。无论你是期末复习、准备面试,还是想把决策树用在真实的收入预测、鸢尾花分类这类任务里,这两块不讲清楚,后面调参、集成全都会踩坑。这篇我就把划分选择和剪枝处理从头到尾拆开讲,最后再带代码跑一棵真实决策树,看看不剪枝和带约束的树到底差在哪。
1. 决策树凭什么不是"手动写 if-else"?
很多初学者会问一个问题:决策树本质不就是 if-else 的组合吗?我会写 if-else,为什么还要学它?这个疑问其实切中了要害,但答案也很简单:你确实能写出一堆 if-else,但你没法靠手工找到一个最优的 if-else 组合。
一个分类问题如果有几百个特征,每个特征又有多种取值,那么所有可能规则组合起来是指数级爆炸的。人肉枚举根本不可能,经验再丰富也只能排除几个明显没用的特征,剩下的依然只能靠模型去搜。决策树算法做的事情,本质上就是自动搜索一套 if-else 规则,并且让这套规则尽量小、尽量准。它把这个过程拆成了两个子问题:选择哪些特征、按什么顺序问;每个节点上按什么阈值切分。
1.1 一颗划分选择的问题为什么这么难
决策树的树形结构本身并不复杂:根节点代表全体样本,内部节点代表一次判断,叶子节点代表最终类别。从根到叶子的每一条路径,翻译过来就是一条"如果满足条件 A,再看条件 B,最后判定为类别 C"的规则。
但问题的关键不在结构,而在划分顺序。打个很生活化的比方,假如让你玩二三十个问题的猜物游戏,先问"是动物吗"和先问"它有毛吗",搜索效率完全不一样。同样的道理放在数据里:如果第一步就把数据切成几个比较纯的子集,后面的树会又矮又平;如果第一步胡乱切,树就会长得又深又乱,每个叶子只剩一两个样本,训练集上全对,测试集上一塌糊涂。所以,划分选择的本质是在每一步寻找让子节点类别纯度提升最快的特征。
通常的解法是贪心。决策树不追求全局最优,而是每到一个节点,就只看当前的数据,选出一个"当前最优"的特征,然后切开,再对每个子节点重复这个过程。这个过程听起来直接,但有一个前提:你需要一把尺子,能量化"切开前后纯度到底提升了多少"。这把尺子就是下一章要说的信息熵、信息增益、基尼指数这些概念。
1.2 为什么不能"每个特征都用一次"
还有一个常见误区是:既然特征都要考虑,干脆把每个特征都用于一次划分不就行了?这个想法听起来公平,实际行不通。
如果用上所有特征,每个叶子到最后只会剩下极少数样本,树被拉得特别深。尤其当特征很多时,为了让每个特征都用上,某些节点会被迫选择一个对分类根本没有帮助的特征,结果是模型把训练数据里的噪声一并记住了。这正好呼应了标题里的"剪枝"——树不是越长越壮,反而容易长过头。
所以学习决策树,第一关要先接受一个反直觉的事实:树的目标不是把训练数据分得干干净净,而是归纳出可泛化的规则。 后面所有划分指标和剪枝策略,其实都在围着这个目标转。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三种划分指标:信息增益、增益率、基尼指数到底怎么选
先说结论:不同算法用的划分指标不一样,但不能简单说谁更高级。ID3 用信息增益,C4.5 用增益率,CART 分类树用基尼指数。它们都围绕同一个底层概念展开,就是"纯度"。
2.1 信息熵:先有一把测量纯度的尺子
信息熵这个概念来自信息论,用来度量一个系统的混乱程度。对于分类数据,熵的计算公式为:
Ent(D) = -∑ p_k · log2(p_k)
其中 p_k 表示第 k 类样本所占比例。熵越小,数据越纯,全部样本属于同一类时熵为 0;类别五五开时熵最大,等于 1。二分类时最容易记:如果正例和负例各占一半,Ent = -(0.5·log20.5 + 0.5·log20.5) = 1,这是最混乱的情况。
注意log的底数用2,是因为信息论里以 bit 为单位。如果你用自然对数,得到的数值不同但排序效果一样,所以不用死记底数,关键是理解"熵大 = 纯度高?反过来?"。
这里我常跟身边同事开个玩笑:熵和纯度是反着来的。熵越大越混乱,纯度越小;熵越小越纯,纯度越大。面试做推导题时最怕把方向搞反,一旦方向反了,后面整棵树的构建逻辑就全错了。
2.2 信息增益:划分前后差了多大事
有了熵,就可以定义信息增益:用特征 a 对集合 D 做划分后,熵下降了多少。公式为:
Gain(D, a) = Ent(D) - ∑_v (|D^v| / |D|) · Ent(D^v)
其中 v 是特征 a 的每个取值,D^v 是划分后对应子集。它衡量的是:知道特征 a 的取值之后,样本类别的不确定度减少量。
举个例子。假设一个集合 D 有 10 条样本,好瓜、坏瓜各 5 条,所以 Ent(D) = 1。现在用一个叫"纹理"的特征划分,它有三个取值:
| 纹理取值 | 样本数 | 好瓜数 | 坏瓜数 | 子集熵 |
|---|---|---|---|---|
| 清晰 | 6 | 4 | 2 | 0.9183 |
| 稍糊 | 3 | 1 | 2 | 0.9183 |
| 模糊 | 1 | 0 | 1 | 0 |
计算子集熵的加权平均:
6/10 × 0.9183 + 3/10 × 0.9183 + 1/10 × 0 = 0.8265
信息增益为:
Gain = 1 - 0.8265 = 0.1735
这个 0.1735 的意思是:如果拿纹理特征划分,整体混乱度下降了 0.1735。对其他候选特征同样算一遍信息增益,然后选增益最大者,这就是 ID3 的划分策略。
2.3 信息增益的一个偏科问题,以及增益率怎么补偿
如果只看信息增益,很快会发现一个 bug:特征取值越细,信息增益越容易虚高。极端情况下,如果加一个"编号"特征,每条样本的编号都不同,每个子集里只有一条样本,纯度直接变成 1,信息增益会达到最大值。用这种特征划分得到的树毫无泛化能力。
原因在于信息增益天然偏爱取值数目多的特征。为了纠正这个问题,C4.5 引入了增益率:
Gain_ratio(D, a) = Gain(D, a) / IV(a)
其中 IV(a) 又被称为固有值,定义为一个惩罚项:
IV(a) = -∑ (|D^v| / |D|) · log2(|D^v| / |D|)
还是用上面的纹理特征,它的三个取值比例是 0.6、0.3、0.1,代入计算得到 IV ≈ 1.295,所以增益率约等于 0.1735 / 1.295 ≈ 0.134。
但要注意,增益率也不是完美的,它会把天平往取值少的特征偏。所以 C4.5 的做法很务实:先选出信息增益高于平均水平的特征,再从这些候选者里选增益率最高的那一个,两边都照顾一点。这个细节在很多网上资料里被省略了,但面试被追问时,它往往是加分项。
2.4 CART 的基尼指数:砍掉对数运算的工程选择
CART 分类树没有用熵,而是用基尼指数。基尼值的定义为:
Gini(D) = 1 - ∑ p_k^2
它表示从数据里随机抽两个样本,它们的类别不一致的概率。基尼值越小,说明数据越纯。二分类五五开时,Gini = 1 - 0.25 - 0.25 = 0.5,和熵同样是"最大值"。全同类时,基尼值为 0。
对于特征 a 的划分,CART 计算的是划分后基尼指数的加权和:
Gini_index(D, a) = ∑ (|D^v| / |D|) · Gini(D^v)
选择能够使基尼指数最小的特征作为划分特征。因为少了 log2 运算,工程上计算更快,这也是 sklearn 里 DecisionTreeClassifier 默认 criterion='gini' 的原因之一。
用前面那个纹理特征的例子:清晰子集的基尼值是 1 - (4/6)^2 - (2/6)^2 ≈ 0.444,稍糊子集同样是 0.444,模糊子集为 0,加权后约 0.4,比分裂前的 0.5 低,说明这个划分有效。
2.5 三张牌的适用场景
| 指标 | 出自算法 | 选择方向 | 天生偏好 | 主要注意事项 |
|---|---|---|---|---|
| 信息增益 | ID3 | 越大越好 | 偏好取值多的特征 | 可能选中"编号"这类无用特征 |
| 增益率 | C4.5 | 越大越好 | 反过来偏好取值少的特征 | 不能直接贪心,需先按信息增益筛选候选 |
| 基尼指数 | CART | 越小越好 | 相对折中 | sklearn 分类树默认使用,且对回归树同样有扩展 |
我个人在实际项目里几乎都用 CART,也就是 sklearn 里默认的决策树实现。ID3 和 C4.5 的很多思想已经内化到 CART 的工程实现里了,但学习时还是值得把三种指标都手推一遍。尤其算信息增益这个动作,能帮你理解后面所有 tree-based 模型里的 feature importance 到底是怎么来的。
3. 树过拟合的根源,以及剪枝的完整手算流程
剪枝处理的本质,是解决一个看起来矛盾的问题:决策树训练得越彻底,训练集误差越低,但测试集误差反而可能升高。这就是典型的过拟合。
为什么会这样?因为一棵完全生长的树会在叶子节点上保留过少的样本,把训练数据里的个别噪声当成规律。比如某条样本因为偶发原因被打错了标签,树为了拟合它,会在一个很深的节点单独开出一条分支。深度学习里的过拟合可以通过正则化缓解,决策树则主要通过剪枝。
3.1 先想清楚:用什么来评判"该不该剪"
剪枝不是凭感觉判断,它有一个清晰的评估标准:泛化性能。实际操作中,我们通常预留一部分验证集,用它来评估剪枝前后的分类精度。
这里有个初学者很容易犯的错:拿训练集精度去判断。拿训练集比的话,剪枝几乎永远是亏的,因为完整树已经把训练数据背下来了。判断剪枝必须用没有参与训练的数据来看,否则一切讨论都没有意义。
剪枝时机又分两种:在构建过程中提前终止,叫预剪枝;等整棵树长好之后再从底往上修,叫后剪枝。
3.2 预剪枝:边建边卡
预剪枝的思想很简单:每次打算对一个节点继续划分时,先做一个验证集精度对比。如果划分后验证集精度比不划分时低,就立即停止,把这个节点直接设为叶子节点。
这个做法的优点是省时间,树不用长得特别深再回头修;缺点是容易欠拟合。因为当前这一步看起来没有提升,不代表后续若干步不会有提升。决策树的划分是贪心的,你在根节点砍掉一个分支,等于后面所有潜在好特征都没有机会再上场了。这是预剪枝最经典的副作用。
工程上常见的 max_depth、min_samples_leaf 这类参数,本质上也是一种粗糙的预剪枝:用规则提前限制树的生长空间,而不是每次都做验证集比较。
3.3 后剪枝:等树长大再摘叶子
后剪枝的过程相反。先生成一棵完整决策树,然后自底向上,对每个非叶节点都做一次"替换测试":如果把这个节点下面整棵子树替换成一个叶子节点,验证集精度不下降,就果断替换。
后剪枝通常会保留比预剪枝更多的分支,欠拟合风险更低,泛化性能也常常更好。代价是训练时间更长,因为要先把完整树建出来,再逐一去考察每个非叶节点。
3.4 手算一个后剪枝判断
下面我用一个极简例子演示这个判断过程。假设某内部节点 T 覆盖的验证集样本有 10 条,其中 8 条实际类别为正类,2 条实际类别为负类。T 下面已经长了两片叶子:
| 分支 | 验证集样本情况 | 叶子预测类别 | 预测正确数 |
|---|---|---|---|
| 分支 A | 5 正 1 负 | 正 | 5 |
| 分支 B | 3 正 1 负 | 负 | 1 |
在剪枝前,这个子树的验证集正确数是 5 + 1 = 6,正确率 60%。
如果把这个内部节点 T 直接替换成一个叶子节点,T 覆盖的所有 10 条验证样本中,正类有 8 个、负类有 2 个,所以叶子会预测为正类,预测正确 8 条,正确率 80%。因为剪完精度从 60% 提升到 80%,这个分支就应该剪掉。
如果剪完精度与剪之前持平,我的习惯也是剪。因为模型复杂度下降而性能没有损失,是一件稳赚不赔的事。只有剪完精度明显下降时才保留子树。
4. 代码实操:跑一棵决策树,观察剪枝规律
理论讲完,上代码。我用 sklearn 自带的鸢尾花数据集做演示,这个数据集也是实训平台上很多决策树案例的主角。先看默认参数下,一棵完全不限制生长的树表现如何:
python复制from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
iris = load_iris()
X_train, X_test, y_train, y_test = train_test_split(
iris.data, iris.target, test_size=0.3, random_state=42, stratify=iris.target
)
tree_full = DecisionTreeClassifier(criterion="gini", random_state=42)
tree_full.fit(X_train, y_train)
print("train acc: {:.3f}".format(tree_full.score(X_train, y_train)))
print("test acc : {:.3f}".format(tree_full.score(X_test, y_test)))
print("depth :", tree_full.get_depth())
print("leaves :", tree_full.get_n_leaves())
我在本地跑这个代码时,训练集准确率几乎等于 1.0,测试集准确率大概在 0.93 左右。树深和叶子数会因为数据集切分不同略有差异,但规律是一致的:训练集分数非常高,但树长得很深、叶子很多,是明显的过拟合信号。
接下来换成不同 max_depth 看看:
python复制for depth in [1, 2, 3, None]:
clf = DecisionTreeClassifier(max_depth=depth, random_state=42)
clf.fit(X_train, y_train)
print(
"max_depth={:<4} train={:.3f} test={:.3f} leaves={}".format(
str(depth),
clf.score(X_train, y_train),
clf.score(X_test, y_test),
clf.get_n_leaves(),
)
)
这段代码就是最朴素的预剪枝实验。结果通常能看到一个趋势:depth=1 时训练集和测试集约在 0.9 上下,差距很小;depth 增加到 3 附近,测试集分数达到高点;继续不限制深度,训练集冲到接近满分,测试集反而可能回落。这组对比能帮你建立对"树不是越深越好"的直觉。
想直观看到树长什么样,可以用 sklearn 自带的绘图工具:
python复制from sklearn.tree import plot_tree, export_text
import matplotlib.pyplot as plt
tree_pruned = DecisionTreeClassifier(max_depth=3, random_state=42)
tree_pruned.fit(X_train, y_train)
plt.figure(figsize=(14, 6))
plot_tree(
tree_pruned,
feature_names=iris.feature_names,
class_names=iris.target_names,
filled=True,
)
plt.show()
print(export_text(tree_pruned, feature_names=iris.feature_names))
plot_tree 画出来是一张带颜色的树状图,export_text 则直接打印一棵文本树,适合放日志或快速查看。实际观察会发现,鸢尾花这个任务在树的第一层就围绕着花瓣长度或花瓣宽度做切分,说明这两个特征对类别区分的贡献最大,这跟现实植物学知识是一致的。
如果还想体验真正的后剪枝,可以用 sklearn 的最小成本复杂度剪枝,也就是 ccp_alpha 参数:
python复制path = tree_full.cost_complexity_pruning_path(X_train, y_train)
ccp_alphas, impurities = path.ccp_alphas, path.impurities
best_score = 0
best_tree = None
for alpha in ccp_alphas:
clf = DecisionTreeClassifier(random_state=42, ccp_alpha=alpha)
clf.fit(X_train, y_train)
s = clf.score(X_test, y_test)
if s > best_score:
best_score = s
best_tree = clf
print("best test acc: {:.3f}".format(best_score))
print("leaves after pruning:", best_tree.get_n_leaves())
ccp_alpha 越大,剪枝越激进,树越小。通过扫描 alpha 的候选值,可以找到测试集分数最高时的树。这个流程比手动调 max_depth 更接近教材里"后剪枝 + 验证集评估"的思路,也是 sklearn 目前比较推荐的做法。
5. 真实项目里容易被忽略的划分细节与剪枝经验
代码能跑通只是开始,真实项目里决策树还会遇到几类问题,这里把最常被问到的补上。
5.1 连续特征和离散特征的划分方式不一样
ID3 和 C4.5 在教科书里可以直接处理离散特征的多分支;但 sklearn 的 DecisionTreeClassifier 是基于 CART 的二叉树实现,所有划分都是"特征 <= 阈值"和"特征 > 阈值"这种二分形式。这意味着连续特征在决策树中真正的考验是:阈值怎么定出来。
CART 的做法很简单粗暴但有效:先把连续值排序,然后取相邻两个样本值的中点作为候选切分点,逐个计算候选点的基尼指数或信息增益,选择效果最好的那个阈值。所以连续特征在树里不是直接参与熵计算,而是先被转成了一组候选阈值。
这个细节在面试里经常被拿来问。回答时要突出两点:一是连续特征可以被重复使用,离散特征在多分支的 ID3 里用完一次就不再出现,但在二叉 CART 里一个特征可以在不同深度多次切分;二是连续特征的阈值选择是排序后线性扫描得到的,整体复杂度能控制在可接受范围。
5.2 缺失值处理:一个经常被人跳过但又很实际的问题
划分选择的公式看起来很美,但现实中特征总会有缺失。如果某条样本在特征 a 上缺失,你怎么去计算特征 a 的信息增益?算不出来怎么办?划分完之后这条样本又该进入哪个分支?
教科书里 C4.5 的做法是用带权样本解决:把缺失样本按不同取值出现的比例拆开,一部分进这个分支,一部分进那个分支,权重参与后续计算。sklearn 在较老版本里直接不支持 NaN,处理前要做填充。工程上如果确定要用决策树,我通常会先看缺失比例:缺失率低的用中位数或者众数填充就行;缺失率高的特征,要么删掉,要么考虑 XGBoost、LightGBM 这类原生支持缺失值学习的模型。
5.3 调参经验:别再死磕"预剪枝还是后剪枝"
如果你跑过上面的代码,应该会发现 sklearn 里并没有一个参数叫"预剪枝"或者"后剪枝",它给你的是 max_depth、min_samples_split、min_samples_leaf、ccp_alpha 这些可选项。纠结术语不如理解它们各自的作用边界。
我的调参顺序一般是这样:
- 先看 max_depth。从 1 开始逐步往上加,观察测试集分数什么时候开始不涨,那个深度附近就是模型的甜点区。
- 再看 min_samples_leaf。它要求每个叶子至少保留一定数量的样本,能有效防止叶子节点被个别噪声样本主导。小数据集一般从 5 开始试,不用设太小。
- 最后用 ccp_alpha 扫一遍,看后剪枝能不能再提升一点。
有一个很实用的判断信号是:训练集分数和测试集分数差距很小,说明树还没长够,可以试着加深;训练集接近满分、测试集掉了一截,说明已经开始过拟合,该剪了。这个信号比盯着某个具体参数数值更通用。
5.4 别把决策树当终点,它更是集成学习的起点
最后说一句可能让初学者有点意外的话:在真实项目里,一棵单独决策树的精度通常不会有太大竞争力。它的价值更多体现在可解释性,以及作为随机森林、GBDT、XGBoost 这些集成模型的基学习器。
如果你做完剪枝发现单棵树怎么调都到不了业务指标,不用太沮丧,这不代表你剪枝思路有错,而是问题本身可能就需要多个树模型投票来降低方差。把剪枝后的树扔进随机森林当基学习器,往往比你手工精调一棵树更稳健。
我在实际项目中还有一个习惯是:尽量把决策树模型导出成规则文本,交给业务同事做规则复核。很多模型上线前最怕的不是精度,而是说不清为什么。决策树好就好在可视化之后,每个节点都可以翻译成人话,业务同学也能看懂。这个特性,是那些精度更高但黑盒的模型很难替代的。
最后再分享一个小技巧:刚开始学剪枝时,不用急着背参数。把 iris 数据集的 max_depth 从 1 调到 6,打印每组 depth、叶子数、训练集分数、测试集分数,放在一起看 10 分钟,你对"决策树为什么会过拟合""剪枝到底在干什么"的理解,会比单纯看十篇帖子都深刻。
