云计算百科
云计算领域专业知识百科平台

机器学习学习笔记(五):决策树,CART 原理、泰坦尼克生存预测与剪枝

系列第五篇。决策树是可解释性最好的模型(每一层都是一个"是/否"问题),也是集成学习(随机森林、GBDT、XGBoost)的基石,下一篇就要用它。这篇的重点有两个:CART 树的分裂逻辑(基尼系数),以及泰坦尼克案例里被很多人忽略的数据预处理和调参细节。

本篇看完要回答的四个面试题:

  • 决策树怎么选分裂特征?基尼系数是什么?
  • CART 树和 ID3、C4.5 有什么区别?
  • 决策树过拟合怎么办?预剪枝和后剪枝的区别?
  • 回归树是怎么输出连续值的?

  • 一、决策树:一串"是/否"问题

    决策树是一种树形结构:内部节点是一个特征上的判断,分支是判断结果的输出,叶子节点是最终的分类结果。

    课件里的例子是女孩相亲:第一个问题问年龄(年龄 > 特定值?直接不见),第二个问长相,第三个问收入,最后问是否公务员。每个问题都在缩小范围,走到底就得出结论。

    值得注意的是问题的先后顺序:她把年龄放在第一个问。先问哪个特征,本质上是在问"哪个特征最能把我关心的人群分开",这正是决策树构建的核心问题。

    决策树的建立过程分三步:

  • 特征选择:选取分类能力强的特征
  • 决策树生成:根据选择的特征递归生成树
  • 剪枝:决策树容易过拟合,用剪枝缓解
  • 二、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∑K​pk2​

    其中 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 一行就完),而是中间的数据预处理,一共三件事:

  • 缺失值处理:Age 列有 177 个缺失,用均值填充
  • 类别特征转数值:Sex 是字符串,模型不认识,用 pd.get_dummies 做 one-hot 编码,展开成 Sex_female 和 Sex_male 两列 0/1
  • 数据集划分:8 : 2 切分训练集和测试集
  • 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):

    max_depth训练集测试集
    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

    两个观察:

  • 训练集准确率随深度单调上升,测试集不是。这就是欠拟合/过拟合的完整曲线:浅了欠拟合(深度 2 只有 0.78),深了过拟合(深度 10 反而掉到 0.8268)
  • 测试集分数从深度 9 到 10 直接掉了近 3 个百分点,这就是第二节说的"结果不稳定":树的分裂路径对数据和深度极其敏感
  • 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}")

    max_depth5 折交叉验证
    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,单棵回归树在这个数据上比线性回归还准(特征关系有非线性成分,树能拟合出来)。

    更有价值的是深度对照实验:

    max_depth训练集 MSE测试集 MSE
    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 默认走预剪枝参数,追求性能时配合交叉验证调参。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 机器学习学习笔记(五):决策树,CART 原理、泰坦尼克生存预测与剪枝
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!