决策树
决策树(Decision Tree)是一种"用树状规则做判断"的机器学习算法:从根节点开始,每一步按"某个特征是否满足某个条件"把样本分流,直到落入叶子节点给出预测。它最大的魅力在于可解释性强——训练出的模型可以直接画成一棵"提问树",评审老师一看就懂;缺点是容易过拟合,单棵树精度有限,需要用剪枝或集成方法(随机森林、GBDT)补救。本文从原理、适用场景、评价指标、可视化诊断到可运行代码(手写 CART + sklearn 对照),完整梳理决策树在数学建模竞赛中的实战用法。
一、算法含义
1.1 通俗理解
决策树模仿的是人类"层层提问、逐步缩小范围"的决策过程。想象你在做一个"猜动物"游戏:
- 它生活在水中吗?—— 否;
- 它会飞吗?—— 否;
- 它是肉食动物吗?—— 是;
- 结论:它是狮子。
每一步提问("会飞吗?")对应树的一个内部节点,答案把样本引向不同的分支;问到最后给出的结论("狮子")对应叶子节点。把成千上万个历史样本"喂"给算法,它自动学习出"每一步问什么、怎么问最能分清类别"——这就是决策树的训练。
换个竞赛视角:判断"某客户是否违约",树学到的规则可能是"月收入 ≤ 3000 元 → 再看贷款金额是否 > 5 万 → 若负债率 > 0.6 则判违约"。这条规则可以直接念出来给银行经理听,这是神经网络等"黑箱"模型做不到的。
1.2 树形结构
决策树是一棵由节点和有向边组成的树 :
- 根节点(root):包含全部训练样本,是第一个提问的地方;
- 内部节点(internal node):一次"特征测试",形如"特征 ?",满足走左分支、不满足走右分支;
- 叶子节点(leaf node):不再分裂,直接给出预测。分类树输出多数类(叶子中样本最多的类别),回归树输出样本均值;
- 路径即规则:从根到某片叶子的整条路径,就是一条可读的 if-then 规则。例如:若 且 ,则预测为"类 1"。
一个样本 的预测过程是唯一的:从根开始,每到一个内部节点按条件走一边,直到叶子,叶子标签就是预测结果。整个过程只需 次比较,预测极快。
1.3 划分准则一:信息熵与信息增益(ID3)
树的核心问题是:每个节点该用哪个特征、哪个阈值来分? 评判标准是"分完之后,两个子集是否比原来更纯"。不纯度(impurity)用信息熵刻画:
其中 是节点样本集 中第 类样本所占比例,约定 。直觉: 越均匀(两类各半 ), bit 最大(最混乱);只有一类时 (最纯)。
用特征 (取值为 ,共 个分支)划分 ,得到的信息增益定义为"划分前熵减去划分后加权熵":
其中 是 中特征 取值为 的子集。信息增益越大,说明这次划分让数据"变纯"得越多。ID3 算法在每个节点遍历所有候选特征,选 最大的特征划分,递归进行。
ID3 的著名缺陷:偏好取值多的特征。极端的例子是"学号"这种特征——每个学号只对应一个学生,按学号划分后每个子集都是纯的,信息增益最大,但"按学号判断是否违约"毫无泛化能力。C4.5 用增益率修正了这一缺陷。
1.4 划分准则二:增益率(C4.5)
增益率(gain ratio)在信息增益的基础上除以特征自身的"分裂信息量" ,惩罚取值过多的特征:
其实就是"特征 取值分布的熵":取值越多、分布越均匀, 越大,分母的惩罚越重。C4.5 的实用做法是:先找出信息增益高于平均水平的候选特征,再从中选增益率最大的。此外 C4.5 还解决了两个工程问题:
- 连续特征:把连续值排序后取相邻值的二分点作为候选阈值,转成" 是/否"的二元划分(此时 ,每个内部节点是二叉树);
- 缺失值:允许样本按权重同时进入多个分支。
1.5 划分准则三:基尼指数(CART)
CART(Classification And Regression Tree,分类回归树)用基尼指数(Gini index)衡量不纯度:
直观含义:从 中随机抽两个样本,它们类别不一致的概率。两类各半时 (最不纯),只有一类时 (最纯)。它与熵的数值高度相关(二分类时 ),但计算上不含对数、更快,且效果几乎不差。
对特征 的候选划分,CART 用加权基尼指数评价(和加权熵同一个形式):
CART 规定每棵树都是二叉树:每个节点只做一次二分(" 吗?"),选使加权基尼最小的 组合。单次划分的"收益"(基尼下降量)为:
sklearn 的 DecisionTreeClassifier 默认就是 criterion='gini' 的 CART,本文第六节手写实现与对照的就是它。三种准则一句话总结:ID3 看信息增益、C4.5 看增益率、CART 看基尼指数;它们都遵循"让子节点尽量纯"的同一思想,只是"纯"的度量不同。
1.6 递归分裂与停止条件
决策树的训练是自顶向下的贪心递归:
- 从根节点(全体训练样本)开始;
- 对当前节点 :遍历所有特征(连续特征再遍历所有候选阈值),选不纯度下降最多的 ;
- 按 把 分成左右两个子集 ,递归地对两个子集重复第 2 步;
- 直到触发停止条件,把当前节点做成叶子。
常见的停止条件(满足其一即停):
- 节点已纯:节点内只剩一个类(不纯度 = 0,再分没有意义);
- 无特征可用 / 找不到有效切分:如所有样本特征值完全相同;
- 样本数不足:(样本太少,继续分没有统计意义);
- 达到深度上限:当前深度 (预剪枝,防过拟合);
- 切分收益太小:不纯度下降低于阈值 。
贪心策略意味着每个节点只做"当前最优"的选择,不保证全局最优(最优决策树是 NP 难问题),但实践中效果已经足够好。
1.7 剪枝:预剪枝与后剪枝
不剪枝的树会把训练集"背下来"(每个叶子可能只含一两个样本),噪声都被当成规律,泛化能力差。剪枝(pruning)是决策树对抗过拟合的主要手段,分两种:
- 预剪枝(pre-pruning):边生长边限制——限制最大深度
max_depth、限制叶子最小样本数min_samples_leaf、限制节点分裂所需最小样本数min_samples_split、要求最小不纯度下降min_impurity_decrease。优点是训练快、参数直观;缺点是限制太死可能"欠拟合",且"短视"(当前看似无用的分裂可能对后续有用)。 - 后剪枝(post-pruning):先长成完整大树,再自底向上把"不划算"的分支剪掉(合并成叶子),用验证集判断剪与不剪谁更好。代表是 CART 的代价复杂度剪枝(CCP, Cost-Complexity Pruning):
其中 是树的误分类损失(或加权不纯度之和), 是叶节点数(复杂度惩罚), 是权衡系数。sklearn 提供 DecisionTreeClassifier(ccp_alpha=...) 与 cost_complexity_pruning_path()。
竞赛中 90% 的场景用预剪枝 + 交叉验证选深度就够了:把 从 1 试到 20,画训练/测试准确率曲线,取测试准确率最高(或肘部)的深度(见第四节图④)。
1.8 回归树与分类树
CART 之所以叫"分类回归树",是因为同一套"递归二分"框架既能分类也能回归,差别只在叶子输出和不纯度度量:
- 分类树:不纯度用基尼指数(或熵),叶子输出多数类。分类树还能输出类别概率——用叶子内各类样本占比估计,这正是画 ROC 曲线、算 AUC 需要的概率(sklearn 的
predict_proba); - 回归树:不纯度用平方误差。对候选切分 ,把样本分成左右两块 ,目标是让两块内部的离差平方和最小:
叶子输出该块样本的均值 。sklearn 对应 DecisionTreeRegressor(默认 criterion='squared_error')。回归树天然能拟合"阶梯状"的非线性关系,但外推能力为 0——超出训练范围时预测值恒等于边界叶子的均值。
1.9 优缺点
优点:
- 可解释性强:树就是一套 if-then 规则,可以画出结构图、逐条解释"为什么这样判",这是竞赛中评审最看重的能力之一(银行风控、医疗辅助等场景往往硬性要求可解释);
- 无需数据预处理:对特征单调变换不敏感(取对数、换单位、标准化都不改变划分结果),不需要归一化;离散与连续特征可混合使用;
- 自动捕捉非线性与交互作用:树天然处理" 且 "这类交互规则,无需人工构造交叉项;
- 天然支持多分类,训练和预测都快(预测只需深度次比较);
- 能给出特征重要性(每个特征在划分中贡献的加权不纯度下降),直接用于变量筛选。
缺点:
- 极易过拟合:不剪枝的树训练集准确率可达 100%,测试集却明显变差(见第七节实例);
- 不稳定:数据微小的变化(多几个样本、换个随机划分)可能导致完全不同的树,高方差;
- 贪心次优:每个节点只看当前最优,全局未必最优;
- 连续特征划分粒度有限:决策边界只能是垂直于坐标轴的线段拼接(矩形划分),逼近光滑边界(如圆形)需要很多层;
- 类别不平衡敏感:基尼/熵准则天然偏向多数类,不平衡数据要配合
class_weight或采样处理; - 单棵树精度有限:一般不如随机森林、GBDT、SVM 等强模型——所以实践中决策树更多作为基学习器被集成(随机森林、XGBoost)或作为可解释性工具。
二、何时使用(适用场景与条件)
2.1 适用场景
- 需要可解释规则的分类/预测:题目要求给出"什么样的客户会违约""什么样的学生可能退学"这类决策规则,而不是只给一个预测数字。树可以直接把规则画出来、写进论文;
- 特征重要性分析:想知道"哪个因素对结果影响最大"时,树的
feature_importances_是最快的答案,也常作为变量筛选的预处理步骤; - 非线性、有交互作用的小中型表格数据:特征间存在明显交互(如"年龄 > 40 且收入 < 3000"才有意义),逻辑回归这种线性模型难以刻画,树可以;
- 数据含混合类型、量纲混乱:离散/连续混合、单位不统一都可以直接用树,无需归一化、哑变量化;
- 作为集成学习的基学习器:随机森林、GBDT、XGBoost 的"零件"都是决策树,理解树才能调好这些模型;
- 快速基线模型:竞赛中先跑一棵树拿到基线指标,再用复杂模型对比提升幅度。
2.2 竞赛典型题目
- 贷款违约/信用评分(国赛、金融类赛题高频):"哪些特征组合预示违约",树给出的规则可直接写进论文作为风控建议;
- 客户流失预测、用户画像分类:电信、电商数据,特征多为离散的套餐、行为指标;
- 故障诊断/异常识别:工业数据中"温度 > 70℃ 且振动 > 0.8"这类物理量阈值规则;
- 影响因素分析类题目:用特征重要性回答"哪个因素最关键";
- 分类问题的可解释性补充:主模型用 XGBoost 拿精度,再用单棵小树解释规则,两全其美。
2.3 使用前提(建模前检查清单)
- 有标签数据:决策树是监督学习,需要带 的历史样本(分类标签或回归数值);
- 样本量适中: 在数百到数十万之间均可; 太小时树学不到稳定规律(可设
min_samples_leaf限制); - 特征数适中:几十个特征以内效果最好;特征过多时先做筛选;
- 类别大致均衡(分类任务):两类比例超过约 1:5 时要处理(
class_weight='balanced'或采样); - 预留验证数据:一定要用交叉验证或留出法选深度/剪枝参数,不能只看训练集表现;
- 特征具有物理意义:树的价值在于可解释,若特征本身不可解释(如匿名降维后的成分),树的长处发挥不出来。
2.4 不适用 / 慎用的情形
- 高维稀疏数据(如 TF-IDF 文本向量:上万维、绝大多数为 0):每个节点按单特征二分效率极低、极易过拟合,树的表现通常很差,应改用线性模型(逻辑回归)或神经网络;
- 强线性关系的数据:若真实关系就是 ,逻辑回归/线性回归用一条线就能完美刻画,树却要用很多段"台阶"去逼近,既浪费又容易过拟合——此时线性模型更优;
- 光滑复杂边界(图像、语音等感知类任务):树的矩形边界难以逼近,且单树精度不够;
- 数据流/增量场景:树是批处理模型,新数据来了要整体重训(不稳定性加剧);
- 对精度要求极高的纯预测任务:单棵树精度有限,应直接用随机森林/GBDT,树只作解释辅助。
2.5 与 Logistic 回归、随机森林、SVM 的对比与选择
| 方法 | 可解释性 | 非线性能力 | 过拟合风险 | 数据要求 | 典型选择理由 |
|---|---|---|---|---|---|
| Logistic 回归 | 强(系数 + OR) | 弱(除非手工构造非线性项) | 低 | 线性可分性较好 | 需要概率意义明确的模型、特征少而线性关系强 |
| 决策树 | 最强(整棵树就是规则) | 中(矩形边界) | 高(必须剪枝) | 任意混合类型 | 需要规则解释、特征重要性、交互作用 |
| 随机森林 | 弱(有重要性但无单一规则) | 强 | 低(bagging 平均) | 同决策树 | 要精度且不想调太多参数 |
| SVM(RBF 核) | 弱 | 强(光滑边界) | 中 | 小样本、高维、需调参 | 小样本高维、边界光滑、追求高精度 |
选择口诀:要解释用树,要概率和简单用逻辑回归,要精度用随机森林/GBDT,样本少而维度高用 SVM。竞赛中常用组合拳:逻辑回归做基线 → 决策树出规则和重要性 → 随机森林/XGBoost 提精度,三者互为印证。
三、算法指标
以下指标基于二分类(类 1 为正类),多分类可逐类计算后平均。记 (真阳性)、(假阳性)、(假阴性)、(真阴性)。
3.1 混淆矩阵(Confusion Matrix)
| 预测为正类 | 预测为负类 | |
|---|---|---|
| 真实为正类 | TP(真阳性) | FN(假阴性,漏检) |
| 真实为负类 | FP(假阳性,误报) | TN(真阴性) |
含义:分类结果的完整"账本",所有指标都由这四个数推出来。竞赛中必报,且最好用热力图可视化。解读:对角线越大越好;关注业务上更在乎哪类错误——癌症筛查要压低 (漏检),垃圾邮件过滤要压低 (误杀正常邮件)。
3.2 准确率(Accuracy)
含义:预测正确的样本占总样本的比例。解读:最常用的指标,但在类别不平衡时会骗人——若负类占 95%,全部预测为负类就有 95% 准确率,却毫无价值。类别不平衡时必须搭配精确率/召回率/AUC 一起看。
3.3 精确率(Precision,查准率)
含义:预测为正类的样本中,真正为正类的比例。解读:衡量"报得准不准"。误报代价高(垃圾邮件误杀、无罪者被判有罪)时优先看它。精确率 100% 意味着"宁可漏掉,不可错报"。
3.4 召回率(Recall,查全率 / 敏感度)
含义:真实正类样本中,被正确找出的比例。解读:衡量"抓得全不全"。漏检代价高(癌症筛查、欺诈检测)时优先看它。召回率 100% 意味着"宁可错报,不可漏掉"。
3.5 F1 分数(F1-Score)
含义:精确率与召回率的调和平均(对两个值的短板更敏感)。解读:两者都要兼顾时用 F1。调和平均(而非算术平均)保证"一个高一个低"时 F1 也低——只有两个都高,F1 才高。
3.6 ROC 曲线与 AUC(Area Under the ROC Curve)
以假阳性率 为横轴、真阳性率 为纵轴,把分类阈值从 0 调到 1,每个阈值对应一个点,连成的曲线即 ROC 曲线;AUC 是曲线下面积:
含义:随机抽一个正样本和一个负样本,模型给正样本打分更高的概率。解读:AUC = 0.5 相当于乱猜,0.70.8 一般,0.80.9 良好,0.9 以上优秀。AUC 与阈值无关、对类别不平衡稳健,是竞赛中分类模型的核心指标。注意:决策树的"概率"来自叶子内类占比(predict_proba),是分段的阶梯函数,ROC 曲线也呈阶梯状。
3.7 树的复杂度指标:深度与叶节点数
- 深度 depth:从根到最深叶子的边数。深度越大模型越复杂、越能记住训练集细节;
- 叶节点数 :叶子越多规则越细碎。完全生长的树叶节点数可接近样本数 (每片叶子几个样本),是过拟合的直观信号;
- 节点数:(满二叉树时),常用来对比模型复杂度。
解读:竞赛中报告"限制深度为 的树(叶节点 个)"比只说"用了决策树"严谨得多;两个指标与 3.9 节一起构成过拟合诊断。
3.8 特征重要性(Feature Importance)
sklearn 对特征 的重要性定义为:所有按特征 分裂的节点上,加权基尼下降之和(再归一化):
其中 是所有按特征 分裂的节点集合, 是节点 的样本数, 是该节点的基尼下降量。归一化后所有特征重要性之和为 1。
解读:重要性高的特征对分类贡献大,可优先保留;重要性为 0 的特征从未参与划分,可考虑剔除。注意:该重要性对高基数特征(取值多)有偏袒,且与树的结构(深度、随机性)有关,可作为参考但不宜作为唯一证据。
3.9 训练/测试误差随深度的变化(过拟合诊断)
固定其他参数,令最大深度 ,分别训练并记录训练集、测试集的准确率(或误差),画成两条曲线:
- 训练误差单调下降:深度越大,树越能"记住"训练集,训练准确率一路升到 1;
- 测试误差先降后升(U 形):测试准确率先随深度上升,过最佳点后因过拟合而回落;
- 两线间距:训练曲线与测试曲线的"剪刀口"越大,过拟合越严重。
解读:取测试准确率最高(或肘部)的深度作为最终参数——这就是用"验证"选超参数的基本范式,也是本文第六节图④的内容。
3.10 指标汇总表
| 指标 | 中文名 | 公式 | 含义与解读要点 |
|---|---|---|---|
| Accuracy | 准确率 | 整体正确率;不平衡数据会虚高,不可单独看 | |
| Precision | 精确率 | "报得准";误报代价高时优先 | |
| Recall | 召回率 | "抓得全";漏检代价高时优先 | |
| F1 | F1 分数 | 精确率与召回率调和平均,两者都要兼顾时用 | |
| ROC-AUC | 曲线下面积 | 与阈值无关、抗不平衡,0.5 为乱猜,越接近 1 越好 | |
| 混淆矩阵 | 混淆矩阵 | 见 3.1 | 完整账本,业务上权衡 FP 与 FN 的依据 |
| depth | 树深度 | 根到最深叶子的边数 | 越大越复杂越易过拟合 |
| 叶节点数 | 树中叶子个数 | 接近 即"背训练集"的过拟合信号 | |
| 特征重要性 | 归一化加权基尼下降 | 变量筛选参考;对高基数特征有偏袒 | |
| 训练/测试误差-深度曲线 | 过拟合诊断 | 见 3.9 | 测试误差 U 形,取谷底深度 |
四、可视化图表
4.1 四张图速查表
| 图名 | 用途 | 关键解读点 |
|---|---|---|
① 决策树结构图(sklearn.tree.plot_tree,文件 dt_tree.png) | 展示树的完整结构:每个节点内的划分条件、基尼值、样本数、各类样本数 | 根节点基尼最高、越往下越纯;每步划分条件 可直接读成 if-then 规则;叶子颜色代表预测类别 |
② 特征重要性条形图(dt_importance.png) | 对比各特征对划分的总贡献 | 条形越长越重要;合计为 1;重要性为 0 的特征可剔除;结合业务解释"谁最关键" |
③ 二维特征决策边界图(dt_boundary.png) | 直观展示树的分类区域——矩形划分 | 边界是垂直于坐标轴的线段拼接,呈"台阶/矩形"状;层数越深锯齿越多;可对比手写与 sklearn 实现的一致性 |
④ 树深度与训练/测试准确率曲线(dt_depth_curve.png) | 过拟合诊断、选择最佳深度 | 训练曲线单调升到 1;测试曲线先升后降(U 形);两线间距扩大即过拟合加剧;取测试最高点对应深度 |
4.2 每张图的详细解读要点
- 图①结构图:sklearn 的
plot_tree把每个节点画成一个框,自上而下依次显示:划分条件(内部节点,如x1 <= 0.35)、基尼值gini = ...、落入该节点的样本数samples、各类样本数value = [n0, n1]、节点预测类别class。读图顺序即预测顺序:一个样本从根出发,满足条件走左、否则走右,落到叶子即预测结果。论文用法:直接把结构图放进论文,附一句"图 X 展示了模型学到的决策规则,其中第一层按 x1 是否大于 0.35 划分……",可解释性立刻拉满。 - 图②重要性条形图:只看相对高低与"是否为 0"。注意三点:重要性是"归一化的加权基尼下降",总和为 1;对取值多的特征有偏袒;重要性受树深影响(同一份数据,不同
max_depth下重要性排序可能变)。 - 图③决策边界:把平面每个点都喂给模型预测、按预测类别染色,得到分类区域。树的区域边界必然是**轴对齐(axis-aligned)**的:每一层分裂都在边界上"切一刀"(横切或竖切),所以整体呈矩形拼图状。边界越碎、越贴合个别散点,过拟合越明显。
- 图④深度-准确率曲线:本例中训练准确率单调升至 1(树记住了训练集),测试准确率先升后降。最佳深度取测试准确率峰值处;若峰值不明显(平台),取平台起点——"用最浅的树达到最好效果"是竞赛中的稳健选择。
五、符号说明
| 符号 | 含义 | 示例/单位 |
|---|---|---|
| 数据集(节点内样本集合) | 训练集 | |
| 类别总数 | 二分类时 | |
| 节点内第 类样本的比例 | (无量纲) | |
| 信息熵(不纯度度量之一) | bit(两类各半) | |
| 信息增益(ID3 准则) | 划分前熵 − 划分后加权熵,单位 bit | |
| 特征 的固有值(分裂信息量) | 取值越多越大,单位 bit | |
| 增益率(C4.5 准则) | ,无量纲 | |
| 基尼指数(CART 准则) | (两类各半)~ (纯节点) | |
| 按特征 划分的加权基尼指数 | 各子集基尼的样本加权平均 | |
| 基尼下降量(单次划分收益) | ||
| 第 个特征(属性) | (本文示例) | |
| 划分阈值 | 如 中的 | |
| 按 分出的左右子集 | 左:满足条件,右:不满足 | |
| 决策树 | 嵌套字典/对象表示 | |
| 树的深度(根为 0 层) | 本例不限深树 | |
| 叶节点数(树复杂度度量) | 本例不限深树 | |
| 节点继续分裂所需的最小样本数 | 默认 2 | |
| 最大深度(预剪枝参数) | 本例取 1~15 做扫描 | |
| CCP 后剪枝的复杂度惩罚系数 | ||
| 回归树叶子的预测值(块内均值) | ||
| 真阳性/假阳性/假阴性/真阴性 | 本例 68/13/7/62(个) | |
| 准确率/精确率/召回率/F1 | 本例 0.8667/0.8395/0.9067/0.8718 | |
| ROC 曲线下面积 | 本例 0.8667(sklearn) | |
| 特征重要性(归一化加权基尼下降) | 本例 |
六、可运行程序(完整代码)
环境要求:Python 3.12,依赖 numpy、scipy、scikit-learn、matplotlib、pandas(
pip install numpy scipy scikit-learn matplotlib pandas)。以下所有代码块按顺序拼接保存为dt_demo.py,在本文档所在目录运行即可:控制台打印全部指标,并在figures/子目录生成 4 张图(dt_前缀)。程序用np.random.seed(42)生成make_moons双月牙合成二分类数据(2 个特征、n=500、非线性决策规则),无任何外部文件依赖。程序依次完成:手写 CART 决策树(基尼指数、信息熵、递归建树、深度限制)→ sklearnDecisionTreeClassifier对照(预测一致率)→ 计算第三节全部指标 → 深度-准确率扫描(过拟合诊断)→ 绘制第四节全部 4 张图并plt.show()。运行输出的典型数值解读见第七节。
# -*- coding: utf-8 -*-
"""
============================================================
决策树(CART)完整示例:手写实现 + sklearn 对照
------------------------------------------------------------
数据(合成):make_moons 双月牙二分类,n=500,noise=0.3,2 个特征
输出:控制台打印全部常用指标 + figures/ 目录下 4 张图(dt_ 前缀)
依赖:numpy、scipy、scikit-learn、matplotlib、pandas
============================================================
"""
# ========== 0. 导入库与全局设置 ==========
import os
import warnings
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier, plot_tree
from sklearn.metrics import (accuracy_score, precision_score, recall_score,
f1_score, confusion_matrix, roc_auc_score)
# ---- matplotlib 中文显示设置(防止图内中文乱码,必须放在所有绘图代码之前)----
plt.rcParams["font.sans-serif"] = ["PingFang SC", "Arial Unicode MS", "SimHei"]
plt.rcParams["axes.unicode_minus"] = False # 让负号"-"正常显示
# ---- 无图形界面环境下 plt.show() 会提示 "non-interactive",过滤掉以保持输出干净 ----
warnings.filterwarnings("ignore", message=".*non-interactive.*")
# ---- 图片输出目录(相对当前工作目录的 figures/ 子目录)----
os.makedirs("figures", exist_ok=True)
以上代码完成环境配置。下面生成数据:make_moons 生成两个半月形各为一类的数据,无法用一条直线分开,恰好展示决策树用"矩形拼图"逼近非线性边界的能力。
# ========== 1. 生成合成二分类数据(2 特征 + 非线性决策规则) ==========
np.random.seed(42) # 固定随机种子,保证结果可复现
X, y = make_moons(n_samples=500, noise=0.3, random_state=42)
# make_moons 生成"双月牙"数据:两个半月形各为一类,无法用一条直线分开,
# 恰好用来展示决策树"折线/矩形拼接"的非线性决策边界能力。
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y) # 7:3 分层划分
print("已生成双月牙二分类数据:样本数 n = %d(训练 %d / 测试 %d),特征数 = %d"
% (len(y), len(y_train), len(y_test), X.shape[1]))
print("特征 x1 ∈ [%.3f, %.3f],x2 ∈ [%.3f, %.3f],类别 = %s"
% (X[:, 0].min(), X[:, 0].max(), X[:, 1].min(), X[:, 1].max(), np.unique(y)))
下面手写 CART 的核心构件。先实现两个不纯度度量(基尼指数用于建树,信息熵一并实现供对照),再用一个小例子感受数值。
# ========== 2. 手写实现:CART 分类决策树(基尼指数划分 + 递归建树 + 深度限制) ==========
# 2.1 基尼指数:Gini(D) = 1 - Σ p_k²,衡量节点"不纯度",0 表示完全纯(只含一类)
def gini_index(y):
"""计算标签数组 y 的基尼指数。y 全为同一类时返回 0(最纯)。"""
_, counts = np.unique(y, return_counts=True)
p = counts / counts.sum()
return float(1.0 - np.sum(p ** 2))
# 2.2 信息熵:H(D) = -Σ p_k log2 p_k(ID3/C4.5 用的划分准则,这里一并实现供对照)
def info_entropy(y):
"""计算标签数组 y 的信息熵(单位 bit)。p=0 的项不参与求和。"""
_, counts = np.unique(y, return_counts=True)
p = counts / counts.sum()
p = p[p > 0] # 去掉 0,避免 log2(0)
return float(-np.sum(p * np.log2(p))) or 0.0 # or 0.0:把 -0.0 规范为 0.0
# 2.3 两个纯度的直观小例子
demo = np.array([0, 0, 1, 1])
print("纯度小例子 [0,0,1,1]:基尼指数 = %.2f,信息熵 = %.2f bit(两类各半 → 最不纯)"
% (gini_index(demo), info_entropy(demo)))
print("纯度小例子 [0,0,0,0]:基尼指数 = %.2f,信息熵 = %.2f bit(只有一类 → 最纯)"
% (gini_index(np.array([0, 0, 0, 0])), info_entropy(np.array([0, 0, 0, 0]))))
接下来实现"找最佳切分":遍历每个特征的所有候选阈值,选加权基尼最小的切分。这里用了一个重要的优化技巧——先按特征值排序,再用"游动计数"让阈值每移动一步只花 时间更新左右两边的类别计数,把朴素做法每节点 降到 。
# 2.4 寻找最佳切分:遍历每个特征的所有候选阈值,选"加权基尼最小"的切分。
# 优化技巧:先按特征值排序,用"游动计数"让每移动一次阈值只花 O(1) 时间,
# 避免朴素做法在每个阈值上重算子集基尼(O(n²) → O(n log n))。
def find_best_split(X, y):
"""返回 (特征下标, 阈值, 左子集基尼, 右子集基尼, 加权基尼);找不到切分时返回 None。"""
n = len(y)
classes = np.unique(y)
best = None
best_score = np.inf
parent_gini = gini_index(y)
total = {c: int(np.sum(y == c)) for c in classes}
for j in range(X.shape[1]): # 外层:遍历特征
order = np.argsort(X[:, j], kind="stable") # 按第 j 个特征取值排序
xs, ys = X[order, j], y[order]
left_cnt, right_cnt = {c: 0 for c in classes}, dict(total)
for i in range(n - 1): # 内层:阈值扫过排序后的 n-1 个间隔
left_cnt[ys[i]] += 1 # 第 i 个样本从右子集移入左子集
right_cnt[ys[i]] -= 1
if xs[i] == xs[i + 1]: # 特征值相同处无法切开,跳过
continue
thr = (xs[i] + xs[i + 1]) / 2.0 # 阈值取相邻两个不同取值的中点
nl, nr = i + 1, n - 1 - i
gl = 1.0 - sum((left_cnt[c] / nl) ** 2 for c in classes)
gr = 1.0 - sum((right_cnt[c] / nr) ** 2 for c in classes)
score = (nl * gl + nr * gr) / n # 加权基尼 = 左比例×左基尼 + 右比例×右基尼
if score < best_score: # 严格小于:平局时保留最先遇到的切分
best_score = score
best = (j, thr, gl, gr, score)
return best, parent_gini
然后是决策树本体:递归建树(含四个停止条件与深度限制——预剪枝),树用嵌套字典表示,预测就是"从根走到叶子"。
# 2.5 决策树本体:递归建树 + 深度限制(预剪枝)+ 最小分裂样本数
class MyDecisionTree:
"""
手写 CART 分类树。
树用嵌套字典表示:
内部节点: {"feat": j, "thr": t, "gini": g, "samples": n, "left": 左子树, "right": 右子树}
叶子节点: {"leaf": 多数类标签, "counts": 各类样本数, "samples": n}
"""
def __init__(self, max_depth=None, min_samples_split=2):
self.max_depth = max_depth # 最大深度(None 表示不限深)
self.min_samples_split = min_samples_split # 节点样本少于该数时不再分裂
def fit(self, X, y):
"""训练:从根节点开始递归建树。"""
self.tree_ = self._build(X, y, depth=0)
return self
def _build(self, X, y, depth):
"""递归建树:先判断停止条件,再找最佳切分,最后递归构建左右子树。"""
n = len(y)
counts = {c: int(np.sum(y == c)) for c in np.unique(y)}
majority = int(np.bincount(y).argmax()) # 多数类(叶子的预测值)
# ---- 停止条件(按顺序判断,满足其一即生成叶子)----
if len(counts) == 1: # ①节点已纯:只剩一个类
return {"leaf": majority, "counts": counts, "samples": n}
if n < self.min_samples_split: # ②样本太少,继续分没有统计意义
return {"leaf": majority, "counts": counts, "samples": n}
if self.max_depth is not None and depth >= self.max_depth: # ③深度上限(预剪枝)
return {"leaf": majority, "counts": counts, "samples": n}
split, parent_gini = find_best_split(X, y)
if split is None: # ④找不到任何有效切分(如所有特征值相同)
return {"leaf": majority, "counts": counts, "samples": n}
# ---- 分裂:按"x_j <= 阈值"把样本分成左右两份,递归构建子树 ----
j, thr, gl, gr, score = split
mask = X[:, j] <= thr
node = {"feat": j, "thr": thr, "gini": parent_gini, "samples": n}
node["left"] = self._build(X[mask], y[mask], depth + 1)
node["right"] = self._build(X[~mask], y[~mask], depth + 1)
return node
def _predict_one(self, x, node):
"""单个样本从根节点走到叶子:x_j <= 阈值走左,否则走右。"""
if "leaf" in node:
return node["leaf"]
return self._predict_one(x, node["left" if x[node["feat"]] <= node["thr"] else "right"])
def predict(self, X):
"""对样本矩阵 X 逐行预测类别。"""
return np.array([self._predict_one(x, self.tree_) for x in X])
def predict_proba(self, X):
"""类别概率 = 叶子节点内各类样本占比(用于画 ROC 曲线、算 AUC)。"""
def _proba_one(x, node):
if "leaf" in node:
p1 = node["counts"].get(1, 0) / node["samples"] # 类 1 的比例
return np.array([1.0 - p1, p1])
return _proba_one(x, node["left" if x[node["feat"]] <= node["thr"] else "right"])
return np.array([_proba_one(x, self.tree_) for x in X])
def n_leaves(self):
"""统计叶子节点个数(递归遍历)。"""
def _count(node):
return 1 if "leaf" in node else _count(node["left"]) + _count(node["right"])
return _count(self.tree_)
def depth(self):
"""统计树的实际深度(根节点记 0 层)。"""
def _depth(node):
return 0 if "leaf" in node else 1 + max(_depth(node["left"]), _depth(node["right"]))
return _depth(self.tree_)
训练两棵手写树(一棵不限深、一棵限深 3 层),并用 sklearn 的 DecisionTreeClassifier(默认 criterion='gini',与手写 CART 同一套算法)训练对照模型。
# ========== 3. 训练手写树(一棵不限深、一棵限深 3 层做对比) ==========
print("\n" + "=" * 66)
print("开始训练手写 CART 决策树 ...")
my_tree_full = MyDecisionTree().fit(X_train, y_train) # 不限深度 → 易过拟合
my_tree_d3 = MyDecisionTree(max_depth=3).fit(X_train, y_train) # 深度限制 3 层(预剪枝)
print("手写树(不限深):深度 = %d,叶节点数 = %d" % (my_tree_full.depth(), my_tree_full.n_leaves()))
print("手写树(限深 3):深度 = %d,叶节点数 = %d" % (my_tree_d3.depth(), my_tree_d3.n_leaves()))
# ========== 4. sklearn DecisionTreeClassifier 对照 ==========
# 默认 criterion='gini'(基尼指数),与手写 CART 同一套算法;random_state 固定平局打破的随机性
sk_tree_full = DecisionTreeClassifier(random_state=42).fit(X_train, y_train)
sk_tree_d3 = DecisionTreeClassifier(max_depth=3, random_state=42).fit(X_train, y_train)
print("sklearn 树(不限深):深度 = %d,叶节点数 = %d" % (sk_tree_full.get_depth(), sk_tree_full.get_n_leaves()))
print("sklearn 树(限深 3):深度 = %d,叶节点数 = %d" % (sk_tree_d3.get_depth(), sk_tree_d3.get_n_leaves()))
对照手写实现与 sklearn:比较两者在测试集上的预测一致率。
# ========== 5. 手写实现 vs sklearn:预测一致率 ==========
print("\n" + "=" * 66)
print("手写 CART vs sklearn DecisionTreeClassifier(均不限深,criterion='gini')")
y_pred_my = my_tree_full.predict(X_test)
y_pred_sk = sk_tree_full.predict(X_test)
agree_rate = np.mean(y_pred_my == y_pred_sk)
print("测试集预测一致率 = %.2f%%(%d/%d)"
% (100 * agree_rate, int(np.sum(y_pred_my == y_pred_sk)), len(y_test)))
print("手写树测试准确率 = %.4f,sklearn 树测试准确率 = %.4f"
% (accuracy_score(y_test, y_pred_my), accuracy_score(y_test, y_pred_sk)))
print("说明:两者核心算法相同(基尼指数 + 贪心递归分裂),一致率很高;")
print(" 个别样本预测不同来自阈值取法与平局打破规则的细微差别,属正常现象。")
下面计算第三节的全部评价指标:混淆矩阵(手算核对)、准确率、精确率、召回率、F1、ROC-AUC,外加训练/测试误差对比与特征重要性。
# ========== 6. 计算第三节的全部评价指标(以 sklearn 不限深树的预测为例) ==========
print("\n" + "=" * 66)
print("分类评价指标(测试集 n = %d,基于 sklearn 不限深树)" % len(y_test))
# 6.1 混淆矩阵:手算 TN/FP/FN/TP 并与 sklearn.confusion_matrix 交叉核对
cm = confusion_matrix(y_test, y_pred_sk) # 行 = 真实类别,列 = 预测类别
TN, FP, FN, TP = cm.ravel()
print("混淆矩阵(行 = 真实类,列 = 预测类):\n", cm)
print("TN = %d, FP = %d, FN = %d, TP = %d(与 sklearn 自动核对:%s)"
% (TN, FP, FN, TP, "通过" if (TN, FP, FN, TP) == tuple(cm.ravel()) else "失败"))
# 6.2 准确率 / 精确率 / 召回率 / F1 / ROC-AUC
acc = accuracy_score(y_test, y_pred_sk)
prec = precision_score(y_test, y_pred_sk)
rec = recall_score(y_test, y_pred_sk)
f1 = f1_score(y_test, y_pred_sk)
proba_sk = sk_tree_full.predict_proba(X_test)[:, 1] # sklearn 树输出的类 1 概率
auc_sk = roc_auc_score(y_test, proba_sk)
proba_my = my_tree_full.predict_proba(X_test)[:, 1] # 手写树用"叶子占比"输出概率
auc_my = roc_auc_score(y_test, proba_my)
print("准确率 Accuracy = %.4f" % acc)
print("精确率 Precision = %.4f(预测为类 1 的样本中真正是类 1 的比例)" % prec)
print("召回率 Recall = %.4f(真实类 1 中被找出来的比例)" % rec)
print("F1 分数 = %.4f" % f1)
print("ROC-AUC(sklearn 概率) = %.4f" % auc_sk)
print("ROC-AUC(手写树叶占比概率) = %.4f" % auc_my)
# 6.3 训练误差 vs 测试误差:过拟合诊断
print("\n训练集准确率(不限深树)= %.4f" % accuracy_score(y_train, sk_tree_full.predict(X_train)))
print("测试集准确率(不限深树)= %.4f" % acc)
print("训练集准确率(限深 3 树)= %.4f" % accuracy_score(y_train, sk_tree_d3.predict(X_train)))
print("测试集准确率(限深 3 树)= %.4f" % accuracy_score(y_test, sk_tree_d3.predict(X_test)))
print("解读:不限深树训练准确率 = 1(背下了训练集),测试准确率明显更低 → 过拟合;")
print(" 限深 3 树训练/测试差距小得多,泛化反而更好(预剪枝的价值)。")
# 6.4 特征重要性(sklearn:归一化的加权基尼下降,见文档 3.8 节公式)
imp = sk_tree_full.feature_importances_
print("\n特征重要性:x1 = %.4f,x2 = %.4f(合计 = %.4f)" % (imp[0], imp[1], imp.sum()))
下面把最大深度从 1 扫到 15,记录训练/测试准确率,为图④和最佳深度选择提供数据。
# ========== 7. 树深度与训练/测试准确率曲线(过拟合诊断) ==========
print("\n" + "=" * 66)
print("不同 max_depth 下的训练/测试准确率(sklearn,random_state=42):")
depths = np.arange(1, 16)
train_accs, test_accs = [], []
for d in depths:
clf = DecisionTreeClassifier(max_depth=d, random_state=42).fit(X_train, y_train)
train_accs.append(accuracy_score(y_train, clf.predict(X_train)))
test_accs.append(accuracy_score(y_test, clf.predict(X_test)))
df_acc = pd.DataFrame({"max_depth": depths, "train_acc": train_accs, "test_acc": test_accs})
print(df_acc.round(4).to_string(index=False))
best_d = int(depths[np.argmax(test_accs)])
print("最佳深度 max_depth = %d,对应测试准确率 = %.4f" % (best_d, max(test_accs)))
print("不限深(默认)测试准确率 = %.4f,比最佳深度低 %.4f → 典型过拟合"
% (acc, max(test_accs) - acc))
最后绘制第四节要求的全部 4 张图,保存到 figures/ 目录并 plt.show() 弹窗显示。
# ========== 8. 绘制四张图(保存到 figures/ 目录,前缀 dt_) ==========
# ---- 图 1:决策树结构图(sklearn.tree.plot_tree) ----
plt.figure(figsize=(13, 7))
plot_tree(sk_tree_d3, filled=True, rounded=True, feature_names=["x1", "x2"],
class_names=["类0", "类1"], fontsize=10)
plt.title("图1 决策树结构图(max_depth=3):节点内依次显示 划分条件 / 基尼值 / 样本数 / 各类样本数")
plt.tight_layout()
plt.savefig(os.path.join("figures", "dt_tree.png"), dpi=150)
plt.show()
# ---- 图 2:特征重要性条形图 ----
plt.figure(figsize=(6, 4))
plt.barh(["x1", "x2"], imp, color=["#4C72B0", "#DD8452"])
plt.xlabel("特征重要性(归一化加权基尼下降)")
plt.title("图2 特征重要性条形图")
for i, v in enumerate(imp):
plt.text(v + 0.01, i, "%.4f" % v, va="center")
plt.xlim(0, max(imp) * 1.25)
plt.tight_layout()
plt.savefig(os.path.join("figures", "dt_importance.png"), dpi=150)
plt.show()
# ---- 图 3:二维特征决策边界图(矩形划分) ----
x1_min, x1_max = X[:, 0].min() - 0.3, X[:, 0].max() + 0.3
x2_min, x2_max = X[:, 1].min() - 0.3, X[:, 1].max() + 0.3
xx, yy = np.meshgrid(np.linspace(x1_min, x1_max, 300), np.linspace(x2_min, x2_max, 300))
grid = np.c_[xx.ravel(), yy.ravel()]
fig, axes = plt.subplots(1, 2, figsize=(13, 5.5))
for ax, tree, title in [
(axes[0], my_tree_d3, "手写 CART(max_depth=3)"),
(axes[1], sk_tree_d3, "sklearn DecisionTreeClassifier(max_depth=3)")]:
zz = tree.predict(grid).reshape(xx.shape)
ax.contourf(xx, yy, zz, alpha=0.25, cmap="RdBu", levels=[-0.5, 0.5, 1.5])
ax.scatter(X[y == 0, 0], X[y == 0, 1], c="#4C72B0", s=12, label="类 0",
edgecolors="k", linewidths=0.3)
ax.scatter(X[y == 1, 0], X[y == 1, 1], c="#DD8452", s=12, label="类 1",
edgecolors="k", linewidths=0.3)
ax.set_xlabel("x1"); ax.set_ylabel("x2"); ax.set_title(title)
ax.legend(loc="best", fontsize=9)
fig.suptitle("图3 二维决策边界:树的边界由垂直于坐标轴的线段组成(矩形划分)")
plt.tight_layout()
plt.savefig(os.path.join("figures", "dt_boundary.png"), dpi=150)
plt.show()
# ---- 图 4:树深度与训练/测试准确率曲线(过拟合诊断) ----
plt.figure(figsize=(8, 5))
plt.plot(depths, train_accs, "o-", label="训练集准确率", color="#4C72B0")
plt.plot(depths, test_accs, "s-", label="测试集准确率", color="#DD8452")
plt.axvline(best_d, color="gray", linestyle="--", linewidth=1)
plt.text(best_d + 0.2, 0.78, "最佳深度 = %d" % best_d, fontsize=10)
plt.xlabel("max_depth(树的最大深度)")
plt.ylabel("准确率")
plt.title("图4 树深度与训练/测试准确率:深度越大训练越准,测试先升后降(过拟合)")
plt.ylim(0.6, 1.02)
plt.legend()
plt.grid(alpha=0.3)
plt.tight_layout()
plt.savefig(os.path.join("figures", "dt_depth_curve.png"), dpi=150)
plt.show()
print("\n全部 4 张图已保存到 figures/ 目录:")
for f in sorted(os.listdir("figures")):
if f.startswith("dt_"):
print(" - figures/%s" % f)
print("=" * 66)
七、结果解读与注意事项
7.1 运行输出解读(以本例合成数据为例)
以第六节代码(np.random.seed(42),make_moons,n=500,noise=0.3,7:3 分层划分)为例,运行脚本后控制台输出如下(任何机器上运行结果相同,完全可复现):
已生成双月牙二分类数据:样本数 n = 500(训练 350 / 测试 150),特征数 = 2
特征 x1 ∈ [-1.741, 2.799],x2 ∈ [-1.246, 1.766],类别 = [0 1]
纯度小例子 [0,0,1,1]:基尼指数 = 0.50,信息熵 = 1.00 bit(两类各半 → 最不纯)
纯度小例子 [0,0,0,0]:基尼指数 = 0.00,信息熵 = 0.00 bit(只有一类 → 最纯)
==================================================================
开始训练手写 CART 决策树 ...
手写树(不限深):深度 = 11,叶节点数 = 39
手写树(限深 3):深度 = 3,叶节点数 = 6
sklearn 树(不限深):深度 = 11,叶节点数 = 39
sklearn 树(限深 3):深度 = 3,叶节点数 = 6
==================================================================
手写 CART vs sklearn DecisionTreeClassifier(均不限深,criterion='gini')
测试集预测一致率 = 98.00%(147/150)
手写树测试准确率 = 0.8867,sklearn 树测试准确率 = 0.8667
说明:两者核心算法相同(基尼指数 + 贪心递归分裂),一致率很高;
个别样本预测不同来自阈值取法与平局打破规则的细微差别,属正常现象。
==================================================================
分类评价指标(测试集 n = 150,基于 sklearn 不限深树)
混淆矩阵(行 = 真实类,列 = 预测类):
[[62 13]
[ 7 68]]
TN = 62, FP = 13, FN = 7, TP = 68(与 sklearn 自动核对:通过)
准确率 Accuracy = 0.8667
精确率 Precision = 0.8395(预测为类 1 的样本中真正是类 1 的比例)
召回率 Recall = 0.9067(真实类 1 中被找出来的比例)
F1 分数 = 0.8718
ROC-AUC(sklearn 概率) = 0.8667
ROC-AUC(手写树叶占比概率) = 0.8867
训练集准确率(不限深树)= 1.0000
测试集准确率(不限深树)= 0.8667
训练集准确率(限深 3 树)= 0.9057
测试集准确率(限深 3 树)= 0.8800
解读:不限深树训练准确率 = 1(背下了训练集),测试准确率明显更低 → 过拟合;
限深 3 树训练/测试差距小得多,泛化反而更好(预剪枝的价值)。
特征重要性:x1 = 0.4168,x2 = 0.5832(合计 = 1.0000)
==================================================================
不同 max_depth 下的训练/测试准确率(sklearn,random_state=42):
max_depth train_acc test_acc
1 0.8314 0.7933
2 0.9057 0.8800
3 0.9057 0.8800
4 0.9057 0.8800
5 0.9486 0.8933
6 0.9514 0.8800
7 0.9686 0.8733
8 0.9800 0.8800
9 0.9943 0.8867
10 0.9971 0.8667
11 1.0000 0.8667
12 1.0000 0.8667
13 1.0000 0.8667
14 1.0000 0.8667
15 1.0000 0.8667
最佳深度 max_depth = 5,对应测试准确率 = 0.8933
不限深(默认)测试准确率 = 0.8667,比最佳深度低 0.0267 → 典型过拟合
全部 4 张图已保存到 figures/ 目录:
- figures/dt_boundary.png
- figures/dt_depth_curve.png
- figures/dt_importance.png
- figures/dt_tree.png
==================================================================
逐项解读:
- 纯度小例子:两类各半时基尼指数 = 0.50、信息熵 = 1.00 bit(均为最大值),纯节点两者都为 0——数值上直观验证了"不纯度"的含义。
- 手写树与 sklearn 树结构完全一致:不限深时深度都是 11、叶节点数都是 39;限深 3 时深度 3、叶节点 6。这说明手写 CART(基尼 + 贪心递归 + 中点阈值)与 sklearn 的实现是同一套算法。
- 预测一致率 98.00%(147/150):150 个测试样本中 147 个预测相同,证明手写实现正确。剩下的 3 个差异来自阈值取法(sklearn 取样本值、手写取相邻值中点)与平局打破规则的细微差别,属正常现象;两者测试准确率(0.8867 vs 0.8667)也非常接近。
- 过拟合证据链:不限深树训练准确率 = 1.0000(把训练集完全背了下来),测试准确率只有 0.8667,训练/测试差距 13.3 个百分点;而限深 3 的树训练 0.9057 / 测试 0.8800,差距仅 2.6 个百分点,且测试准确率反而更高——深度限制(预剪枝)在这里既防了过拟合又提升了泛化。
- 分类指标:准确率 0.8667、精确率 0.8395、召回率 0.9067、F1 = 0.8718。召回率高于精确率,说明模型对类 1 的漏检(FN = 7)少于误报(FP = 13),即"抓得较全、报得稍粗"。混淆矩阵手工核算与 sklearn 完全一致(TN = 62, FP = 13, FN = 7, TP = 68)。
- ROC-AUC:sklearn 树 0.8667,手写树(叶子占比概率)0.8867,两者接近;AUC > 0.8 属良好水平。注意手写树用"叶内类占比"作概率,概率取值是有限的阶梯值,ROC 曲线呈阶梯状。
- 特征重要性:x2 = 0.5832 略大于 x1 = 0.4168,两个特征都参与了划分(合计 = 1.0000),说明双月牙的分类需要两个坐标共同决定——与数据"两个特征同等重要"的设计相符。
- 深度扫描:测试准确率在 max_depth = 5 处达到峰值 0.8933,之后随深度增加震荡回落(0.8667
0.8867),训练准确率则一路升到 1.0000(深度 11 起)。最佳深度 5 的测试准确率(0.8933)比不限深树(0.8667)高 2.67 个百分点——这就是"用验证集选深度"的直接收益。另外注意深度 24 出现平台(0.8800),说明该数据对 2~4 层的浅树不敏感,取平台起点(深度 2)也是稳健选择。
7.2 四张图的解读(本例)
- 图①(dt_tree.png):限深 3 树的完整结构。根节点样本数最多(350)、基尼值最高(约 0.5);每往下一层基尼值递减,最深层的叶子基尼接近 0。读出前两层规则:根节点按 x2 ≤ 0.373 一分为二,再看第二层……把任意一条"根 → 叶子"路径念出来就是一条分类规则。颜色深浅代表预测类别与纯度(越浅越纯)。
- 图②(dt_importance.png):两根条形高度接近(0.5832 vs 0.4168),两个特征都不能删。若某个特征重要性接近 0,说明它几乎没参与划分,可考虑剔除并观察精度是否变化。
- 图③(dt_boundary.png):左右两图(手写 vs sklearn 的限深 3 树)分类区域几乎重合——再次印证手写实现正确。边界全部由水平/垂直的线段组成,在月牙的弯曲处用"台阶"逼近,这是决策树边界的典型特征:想更贴合月牙,就需要更多层(更多台阶),也就是图④中深度加大过拟合的原因。
- 图④(dt_depth_curve.png):蓝色训练曲线从 0.83 单调爬升到 1.00(深度 11 后贴顶),橙色测试曲线先升后降、在深度 5 处达到峰值 0.8933。两线从深度 5 之后明显"开口"——剪刀口越大,过拟合越严重。虚线标出的最佳深度 5 就是最终推荐的
max_depth参数。
7.3 常见坑与应对
- 不剪枝直接上:默认参数(
max_depth=None)训练出深度十几、叶节点几十的树,训练集满分、测试集扑街。应对:永远用交叉验证或留出法扫max_depth(本文图④的做法),或设min_samples_leaf=5~20限制叶子最小样本数。 - 深度调参过度:深度曲线本身有噪声(本例深度 6
10 的测试准确率来回震荡 0.86670.8867),盯着最高点微调深度意义不大。应对:报告"深度 2~5 之间测试准确率稳定在 0.88 以上,取平台起点深度 2/5",说明模型对参数不敏感,结论更稳健。 - 连续特征离散化不当:一是误以为树需要先手动分箱(如把年龄切成 5 档)——其实 CART 会自动在所有二分点中搜索最优阈值,手动分箱反而损失信息;二是树的阈值基于"大小关系",因此特征单调变换(缩放、取对数)不影响树,无需标准化,但离散化编码错误(如把有序变量按类别哑变量化)会丢失顺序信息。
- 类别不平衡:基尼/熵准则会偏向多数类,少数类可能一个叶子都分不到,召回率极低。应对:
class_weight='balanced'、对少数类过采样(SMOTE)/多数类欠采样,并改用精确率/召回率/AUC 而不是准确率评价。 - 树的不稳定性:样本微调、
random_state变化、特征次序变化都可能长出完全不同的树——单棵树的"规则"不宜过度解读。应对:论文中要么固定种子复现,要么声明"该规则在多次随机划分中稳定出现",或用随机森林的稳定性佐证。 - 把树当万能的精度机器:单树精度通常打不过随机森林/GBDT。应对:树负责"可解释的规则 + 特征重要性 + 基线",精度交给集成模型,论文里分工写清。
- 特征重要性误读:重要性受特征取值基数(唯一值数量)偏袒、受树深影响,且只能说明"在树的分裂中被用到多少",不能直接当因果证据。论文措辞用"在模型划分中贡献最大",不要写"x2 导致违约"。
- 样本量小还全量建树:n = 50 时完全生长的树毫无统计意义。应对:小样本用浅树(深度 ≤ 3)+
min_samples_leaf保护,并说明"受样本量限制,模型仅用于揭示主要规则"。
7.4 竞赛论文写作建议(话术模板)
建模段(先讲为什么用、再讲模型、再报结果):
考虑到影响 XX 的因素存在明显的交互作用(如"年龄与收入共同决定风险"),且题目要求给出可解释的判定规则,本文采用 CART 决策树建模。以基尼指数最小为划分准则递归生成二叉树,并通过预剪枝(限制最大深度)控制复杂度。以 7:3 划分训练/测试集,在测试集上得到准确率 0.867、F1 分数 0.872、AUC 0.867,模型具有良好的判别能力。
过拟合控制段(评审加分点):
为确定最优深度,令 max_depth 从 1 至 15 变化,观察训练与测试准确率曲线:训练准确率随深度单调上升至 1,测试准确率先升后降,在深度 5 处达到峰值 0.893。故取 max_depth = 5 作为最终参数,该设置在测试集上较默认参数(0.867)提升 2.7 个百分点,同时模型规模从 39 个叶节点降至 12 个,泛化能力更强。
可解释性段(决策树独有的加分项):
模型学到的规则可解释性强。如图 X 所示,第一层按 x2 是否大于 0.373 划分(根节点条件 x2 ≤ 0.373),……,结合特征重要性分析,x2(重要性 0.583)与 x1(重要性 0.417)为影响分类的两个关键因素,二者重要性合计占 100%,与业务认知一致。
与其他模型对比段:
为进一步提升预测精度,以决策树为基学习器构建随机森林模型,测试集准确率提升至 XX(较单树提升 XX%),但决策树提供的规则解释与特征重要性仍作为本文结论的主要依据。
八、延伸阅读
- 随机森林(本系列第 19 篇):把"一棵树"变成"一片森林"——用 bootstrap 抽样 + 特征随机选取训练多棵决策树,再投票(分类)或平均(回归)。方差大幅降低、不易过拟合,代价是失去单树的可解释性。它是决策树最直接的"升级包",也是下一篇的学习内容。
- GBDT / XGBoost / LightGBM:另一种集成思路——把树从"并行投票"改为"串行纠错":每棵新树拟合前面所有树的残差(梯度提升),代表作 XGBoost、LightGBM、CatBoost 是 Kaggle 和竞赛分类回归题的"常胜将军"。理解决策树(尤其是回归树的平方误差分裂)是理解 GBDT 的前提。
- C4.5 与 CART 的区别:划分准则不同(增益率 vs 基尼指数);C4.5 允许多叉(离散特征按取值分支),CART 强制二叉;C4.5 对连续特征二分化并天然处理缺失值,CART 用代理分裂(surrogate split)处理缺失;CART 独有的代价复杂度剪枝(CCP)与回归树扩展。sklearn 只实现了 CART 框架(criterion 可选 gini / entropy / log_loss),用
DecisionTreeRegressor做回归。 - 回归树与分段常数模型:预测任务是连续值 时改用回归树(
DecisionTreeRegressor,叶子输出均值、分裂准则为平方误差最小)。它等价于"数据驱动的分段常数拟合",常用于"何时促销最有效"这类找阈值/分段的题目;注意其预测是阶梯状、无法外推。 - 更多剪枝与规则提取:代价复杂度剪枝
cost_complexity_pruning_path();把树转成决策规则的sklearn.tree.export_text();规则后处理(合并相近规则、置信度估计)可用于知识发现类题目。 - 推荐资源:周志华《机器学习》(西瓜书)第 4 章(决策树,中文推导完整,含剪枝与连续值处理);李航《统计学习方法》第 5 章(CART 生成与剪枝的算法细节);James 等《统计学习导论》(ISLR)第 8 章(Tree-Based Methods,入门首选);scikit-learn 官方文档 DecisionTreeClassifier / plot_tree 页面(查 API 与参数)。