系列第五篇。决策树是可解释性最好的模型(每一层都是一个"是/否"问题),也是集成学习(随机森林、GBDT、XGBoost)的基石,下一篇就要用它。这篇的重点有两个:CART 树的分裂逻辑(基尼系数),以及泰坦尼克案例里被很多人忽略的数据预处理和调参细节。
本篇看完要回答的四个面试题:
一、决策树:一串"是/否"问题
决策树是一种树形结构:内部节点是一个特征上的判断,分支是判断结果的输出,叶子节点是最终的分类结果。
课件里的例子是女孩相亲:第一个问题问年龄(年龄 > 特定值?直接不见),第二个问长相,第三个问收入,最后问是否公务员。每个问题都在缩小范围,走到底就得出结论。
值得注意的是问题的先后顺序:她把年龄放在第一个问。先问哪个特征,本质上是在问"哪个特征最能把我关心的人群分开",这正是决策树构建的核心问题。
决策树的建立过程分三步:
二、CART 树:所有主流树模型的老祖宗
CART 全称 Classification and Regression Tree(分类与回归树),规则可以概括成一句话:不停做二分选择题,一层一层筛选数据,分得越纯越好。
2.1 为什么是 CART:前辈的局限
在 CART 之前有 ID3 和 C4.5 两代算法,它们有三个问题:
| 功能单一 | 只支持分类,无法做回归 |
| 容易过拟合 | 学了太多训练数据的细节噪声 |
| 分裂规则不统一 | 时而二分时而多分,计算逻辑不规范 |
CART 的解法是全部统一:既能分类又能回归,强制二分,自带剪枝机制。
2.2 核心规则一:每次只做二分
无论选哪个特征划分,都只能把数据分成两组。
- 类别特征"色泽":不允许一次分出"青绿、乌黑、浅白"三组,只能问"是青绿吗"或"是乌黑吗"
- 数值特征"重量":只能在数轴上切一刀,“大于 5 斤"和"小于等于 5 斤”
2.3 核心规则二:选"分得最纯"的特征
划分的目标是让分开后的两组数据内部尽可能一致。一堆里绝大多数是好瓜、另一堆绝大多数是坏瓜,说明这个特征有用。
衡量纯度的指标是基尼系数:
Gini(D)=1−∑k=1Kpk2Gini(D) = 1 – \\sum_{k=1}^{K} p_k^2Gini(D)=1−k=1∑Kpk2
其中 pkp_kpk 是第 k 类样本的占比。基尼系数越小,数据越纯。两类数据的极端情况很好算:五五开时 Gini = 0.5(最混乱),全是同一类时 Gini = 0(最纯)。
用泰坦尼克数据的根节点算一下:712 个训练样本,442 人遇难、270 人生存。
Gini=1−(442712)2−(270712)2=0.471Gini = 1 – \\left(\\frac{442}{712}\\right)^2 – \\left(\\frac{270}{712}\\right)^2 = 0.471Gini=1−(712442)2−(712270)2=0.471
接近 0.5,说明根节点处人群几乎对半开,非常混乱。
2.4 核心规则三:逐层递归分裂
对每个候选特征都尝试二分,计算"划分后两组的加权基尼系数",选让纯度提升最多的那个。以泰坦尼克的第一层为例(实测数据,下一节会讲怎么来的):
按"是否男性"划分后:男性组 455 人(377 遇难,88 生存),Gini = 0.307;女性组 247 人(65 遇难,182 生存),Gini = 0.388。加权平均:
455×0.307+247×0.388712=0.331\\frac{455 \\times 0.307 + 247 \\times 0.388}{712} = 0.331712455×0.307+247×0.388=0.331
从 0.471 降到 0.331,纯度明显提升,所以根节点选了这个特征。分出两组后,在每组内部重复同样的操作,直到叶子足够纯或达到停止条件。
2.5 核心规则四:回归问题怎么办
分类树的叶子输出类别(叶子中多数类),回归树的叶子输出数值(叶子中所有样本目标值的均值)。分裂逻辑不变,只是"纯度"的衡量从基尼系数换成了平方误差:让切出来的两堆,各自内部的房价(或其他目标值)差异尽量小。
2.6 优缺点
| 简单易懂,决策过程清晰可见,可解释性强 | 容易过拟合(参数不当时单棵树非常明显) |
| 分类回归都能做,功能全面 | 结果不稳定:数据微小扰动可能让整棵树结构剧变 |
| 强制二分,训练和预测速度快 | 贪心策略,每步只找局部最优,不保证全局最优 |
| 自带剪枝,也是随机森林、GBDT、XGBoost 的基础模块 |
缺点里的"不稳定"和"贪心"不是背书用的,第四节实测会亲眼看到:数据只动一点,最优深度和准确率就跟着跳。
三、泰坦尼克生存预测:重点在数据预处理
案例背景:1912 年泰坦尼克号沉没,2224 人中 1502 人遇难。救生艇不够,但"妇女、儿童和上流社会"更容易生存。我们要用乘客数据预测谁能幸存。
数据集 891 行,用三个特征:Pclass(船票等级 1/2/3)、Age(年龄)、Sex(性别),标签是 Survived(0 遇难,1 生存)。
这个案例的重点不是决策树本身(API 一行就完),而是中间的数据预处理,一共三件事:
3.1 第一课:不限深度的树长什么样
先按课件原样训练一棵不做任何限制的决策树:
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import classification_report
titan_df = pd.read_csv("./data/titanic/train.csv")
x = titan_df[["Pclass", "Age", "Sex"]]
y = titan_df["Survived"]
x["Age"] = x["Age"].fillna(x["Age"].mean())
x = pd.get_dummies(x)
x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.20, random_state=33)
estimator = DecisionTreeClassifier(random_state=33)
estimator.fit(x_train, y_train)
结果:
| 训练集准确率 | 0.8750 |
| 测试集准确率 | 0.8492 |
| 树的深度 | 16 |
| 叶子数 | 115 |
712 个训练样本被切成 115 个叶子,很多叶子里只剩一两个人。这就是过拟合的形态:树把训练集里大量偶然细节都当成了规则。测试集准确率看起来还行,但训练集和测试集的差距、以及夸张的叶子数都说明它学了不该学的东西。
3.2 第二课:限深之后发生了什么
限制 max_depth,从 2 到 10 逐个试(每组都固定 random_state=33):
| 2 | 0.7978 | 0.7821 |
| 3 | 0.8076 | 0.8156 |
| 4 | 0.8104 | 0.8156 |
| 5 | 0.8230 | 0.8045 |
| 6 | 0.8272 | 0.8101 |
| 7 | 0.8441 | 0.8436 |
| 8 | 0.8539 | 0.8492 |
| 9 | 0.8553 | 0.8547 |
| 10 | 0.8610 | 0.8268 |
两个观察:
3.3 第三课:我差点犯的数据泄漏错误
看到上表,很自然的反应是"那选测试集分数最高的深度 9 就完事了"。错,这是数据泄漏。
测试集的作用是最终评估,不是选参数。用测试集选超参数,等于让模型提前"看过"考试题再挑答案,选出来的分数是虚高的(深度 9 的 0.8547 就有这个成分)。正确做法是在训练集内部做交叉验证来选参数:
from sklearn.model_selection import cross_val_score
for depth in range(2, 11):
m = DecisionTreeClassifier(max_depth=depth, random_state=33)
scores = cross_val_score(m, x_train, y_train, cv=5)
print(f"max_depth={depth} 5折交叉验证={scores.mean():.4f}")
| 2 | 0.7823 |
| 3 | 0.7978 |
| 4 | 0.7879 |
| 5 | 0.7782 |
| 6 | 0.7767 |
| 7 | 0.7852 |
| 8 | 0.7838 |
| 9 | 0.7852 |
| 10 | 0.7852 |
交叉验证选出的最优深度是 3(0.7978),而不是偷看测试集得到的 9。它的真实测试集成绩:
best = DecisionTreeClassifier(max_depth=3, random_state=33)
best.fit(x_train, y_train)
y_pred = best.predict(x_test)
print(classification_report(y_test, y_pred, target_names=["died", "survived"]))
| 测试集准确率 | 0.8156 |
| 树的深度 | 3 |
| 叶子数 | 8 |
precision recall f1-score support
died 0.82 0.88 0.85 107
survived 0.80 0.72 0.76 72
accuracy 0.82 179
诚实地说:交叉验证选出的模型(0.8156)在这一次测试上确实低于"作弊"选出的 0.8547。但这不矛盾:0.8547 是提前看过考题挑出来的数字,换一批测试集大概率没这么好;0.8156 才是模型真实泛化能力的合理估计。深度 3 的树只有 8 个叶子,在可解释性和泛化之间取得了好平衡,面试里讲清楚这个取舍是加分项。
3.4 这棵树到底学到了什么
用 plot_tree 画出深度 3 的树(完整图见项目仓库,这里说结论):
- 根节点问的第一个问题是"是否男性"(Sex_female <= 0.5)。算法在所有特征里自动把性别排在了第一优先级
- 男性分支第二个问题问年龄(Age <= 5.5):男孩里 5 岁以下优先获救
- 女性分支第二个问题问船票等级(Pclass <= 2.5):一二等舱的女性优先获救
"妇女儿童优先 + 舱位差异"这个历史叙述,被数据完整复现了。看特征重要性更直接:
| Sex_female | 0.6621 |
| Pclass | 0.2431 |
| Age | 0.0947 |
| Sex_male | 0.0000 |
Sex_male 的重要性是 0:one-hot 编码产生了两列完全互斥的特征(一列是 1 另一列必是 0),树只需要用其中一列就够,另一列的信息量为零。one-hot 后出现某个哑变量重要性为 0 是正常现象,不是 bug。
四、回归树实战:加州房价
课件的回归树案例还在用波士顿房价,但 load_boston 从 sklearn 1.2 起已被移除(线性回归篇讲过的老坑),这里同样换 fetch_california_housing。回归树 API 是 DecisionTreeRegressor,流程和分类树一致:
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.tree import DecisionTreeRegressor
from sklearn.metrics import mean_squared_error, mean_absolute_error
import numpy as np
data = fetch_california_housing()
x_train, x_test, y_train, y_test = train_test_split(data.data, data.target, random_state=22)
scaler = StandardScaler()
x_train = scaler.fit_transform(x_train)
x_test = scaler.transform(x_test)
m = DecisionTreeRegressor(max_depth=8, random_state=22)
m.fit(x_train, y_train)
y_pred = m.predict(x_test)
mse = mean_squared_error(y_test, y_pred)
print(np.sqrt(mse), mean_absolute_error(y_test, y_pred))
深度 8 时:MSE = 0.4196,RMSE = 0.6478,MAE = 0.4525。对比线性回归篇正规方程的 MSE = 0.4918,单棵回归树在这个数据上比线性回归还准(特征关系有非线性成分,树能拟合出来)。
更有价值的是深度对照实验:
| 4 | 0.5581 | 0.5395 |
| 6 | 0.4455 | 0.4464 |
| 10 | 0.2276 | 0.4142 |
| 不限 | 0.0000 | 0.5274 |
不限深度时训练集 MSE 正好是 0:树把每个训练样本都分到了独享的叶子里,“预测"退化成"背答案”。测试集 MSE 随之从 0.4142(深度 10 的最优附近)恶化到 0.5274。训练误差 0 永远不是好事,它是过拟合最极端的样子。
另外注意:回归树对特征做了标准化,但严格说树模型对单调缩放不敏感(分裂只看相对大小),这里做标准化是沿用流程习惯,不影响结论。
五、剪枝:给树"减负"
剪枝是一种防止决策树过拟合的正则化方法,思路是把子树删掉、用叶子节点替换,牺牲一点训练集准确率换泛化能力。两种方式:
预剪枝:边生成边判断。每个节点分裂前先估计,如果这次划分不能带来泛化性能提升,就停止划分、直接标记为叶节点。上文用 max_depth 限深就是最常用的预剪枝手段。
后剪枝:先让树完整生成,再自底向上考察每个非叶节点,把某节点对应的子树替换为叶节点后如果泛化性能提升(通常用验证集判断),就剪掉。
| 预剪枝 | 分支少,训练和测试的时间开销显著降低 | 有些分支当前看着没用、后续分裂可能显著提升性能,提前停有欠拟合风险 |
| 后剪枝 | 保留更多分支,欠拟合风险小,泛化性能通常优于预剪枝 | 先生成再遍历,训练开销大 |
sklearn 的 DecisionTreeClassifier 里,max_depth、min_samples_split(节点再划分所需最小样本数)、min_samples_leaf(叶子节点最少样本数)这些都是预剪枝参数;后剪枝可用 ccp_alpha(代价复杂度剪枝)实现,本文实验以预剪枝为主。
课件里那道多选题值得记一下:剪枝防过拟合、分预剪枝和后剪枝、预剪枝是边生成边验证、后剪枝是构建完再遍历,四个说法全对。
六、易错点
- 用测试集调超参数就是数据泄漏:3.3 节的完整教训。调参只能用训练集内部的交叉验证,测试集只在最终评估时碰一次
- one-hot 之前必须先处理缺失值:如果先 get_dummies 再填充,缺失值会被当成一个独立类别处理,逻辑上就错了。本案例顺序是先 fillna 再 get_dummies
- pandas 的 inplace=True 链式赋值在新版本会报 FutureWarning 甚至失效:课件里的 x['Age'].fillna(…, inplace=True) 写法已经过时,推荐 x['Age'] = x['Age'].fillna(…)(本文代码已用新写法)
- 不限深度的决策树在训练集上 MSE 可以是 0:看到训练误差为 0 不要以为是模型很强,先看测试集
- classification_report 的参数顺序:classification_report(y_test, y_pred),真实值在前预测值在后。传反了报告照样能生成,但精确率和召回率就全部错位,这个 bug 很隐蔽
- 树模型不需要标准化(对单调缩放不敏感),但需要做类别特征编码;标准化在树模型流程里是"可做可不做",在 KNN、线性回归流程里是"必须做"
七、知识清单
- 决策树结构:根节点、内部节点、分支、叶子节点
- 建树三步:特征选择、生成、剪枝
- CART 五条核心规则:强制二分、选最纯分裂、逐层递归、叶子输出(类别 / 均值)、剪枝
- 基尼系数:Gini=1−∑pk2Gini = 1 – \\sum p_k^2Gini=1−∑pk2,越小越纯;五五开 0.5,纯一类 0
- ID3 / C4.5 局限:不能回归、易过拟合、分裂规则不统一
- 预剪枝参数:max_depth、min_samples_split、min_samples_leaf;后剪枝:ccp_alpha
- 回归树 API:DecisionTreeRegressor,评估用 MSE / RMSE / MAE
- 调参纪律:交叉验证选参,测试集只做最终评估
八、面试高频问答
Q1:决策树如何选择分裂特征?
计算每个候选特征划分后的不纯度(分类用基尼系数或信息熵,回归用平方误差),选使不纯度下降最多的特征和分裂点。CART 强制每次二分,逐层递归直到满足停止条件。
Q2:CART 和 ID3、C4.5 的区别?
ID3 用信息增益、只能处理离散特征、不能回归;C4.5 用信息增益比、能处理连续特征、仍不能回归;CART 用基尼系数(分类)或平方误差(回归)、强制二分、分类回归通吃,且是随机森林和 GBDT 的基础模块。
Q3:决策树过拟合怎么解决?
剪枝(预剪枝限制生长,如 max_depth、min_samples_leaf;后剪枝先生成再自底向上裁剪)、集成学习(把单棵不稳定大树换成多棵小树的随机森林,下一篇的主题)、以及增加训练数据。
Q4:预剪枝和后剪枝的区别与取舍?
预剪枝边生成边判断,省时间但可能提前砍掉后期有价值的分支,有欠拟合风险;后剪枝先生成完整树再自底向上考察,效果通常更好但训练开销大。工程上 sklearn 默认走预剪枝参数,追求性能时配合交叉验证调参。
网硕互联帮助中心


评论前必须登录!
注册