08 决策树 (Decision Trees)

本章会用到的数据

  • 公开下载上市公司财务报表。代码会在首次运行时下载并保存到 data/course/

  • 这些数据能做什么:用公司财务指标建立决策树,并观察树如何切分样本。

  • 分析时注意:特征必须在预测日期前已经披露;树的深度只用训练和验证数据选择。

  • 判断模型:把决策树与简单基准放在同一最终测试期比较。

【课堂核心】2.5 学时学习安排

学习内容 分钟
动机与先修 15
分裂与复杂度 35
示例输入说明 15
点时示例 15
形成性检查 15
独立任务与反馈 45
本章小结 10

【可选拓展】:剪枝推导、全部不纯度证明与额外可视化。

课程路线图:我们今天的旅程

课程路线图 展示本章四个主要部分的流程图:核心思想、回归树、分类树、实践。 1. 核心思想 与动机 2. 回归树 (Regression) 3. 分类树 (Classification) 4. Python实践 (Implementation)

本章学习目标:解锁决策树的四大关键

  1. 理论层面:理解树状模型的基本思想,它与我们熟悉的线性模型有何根本不同?
  2. 应用层面:如何利用决策树对复杂的经济金融数据进行分类(如判断客户是否违约)和回归(如预测股票收益率)?
  3. 技术层面:决策树模型是如何从数据中“学习”到决策规则的?其背后的训练算法是什么?
  4. 实践层面:如何使用 Python 从零开始构建、训练并评估一个决策树模型?

温故知新:线性模型的世界观

我们之前学习的线性模型(如OLS)非常强大,它们用一个统一的、全局的公式来描述世界。

\[ \large{y = \beta_0 + \beta_1 x_1 + \beta_2 x_2 + ... + \epsilon} \]

这个公式假设特征和结果之间是线性可加的关系。

动机:线性模型的局限性

然而,在真实的经济世界中,许多关系是非线性的交互的

  • 非线性: 一个人的收入对信贷风险的影响,在高收入区间和低收入区间可能是完全不同的。
  • 交互作用: 年龄对风险的影响,可能取决于他/她是否有房产。

线性模型很难直接捕捉这种复杂的结构。

可视化:线性模型的“边界”

线性模型(如逻辑回归)只能画出直线来分割数据。如果数据的真实边界是弯曲的,线性模型就会犯很多错误。

线性模型的局限性 一个线性模型试图用直线分割非线性数据,导致分类错误。 模型预测为 A 类 模型预测为 B 类 分类错误!

核心思想的转变:从“一个公式”到“一套规则”

模型类型 核心思想 例子
线性模型 用一个单一的、全局的数学公式来描述 xy 的关系。 \(y = \beta_0 + \beta_1 x_1 + \epsilon\)
树模型 找到一套分层的、局部的决策规则来细分样本空间。 如果收入 > 5000,则…

决策树的思路更接近人类的决策过程,因此具有很强的可解释性

决策树的类比:20个问题游戏

想象一下经典的“20个问题”游戏。你通过一系列“是/否”问题来缩小可能性范围,最终猜出答案。

决策树就是机器在玩这个游戏。

  • 人类问题: “它比面包盒大吗?”
  • 决策树节点: 尺寸 > 面包盒?

每问一个问题,就离答案更近一步。

第一部分:理解树的基本概念

什么是“树”?一种抽象的层次结构

在深入决策树之前,我们先理解什么是抽象意义上的“树”。

  • 它是一种用来表示节点 (Node) 与节点之间层次关系的数据结构。
  • 它看起来像一个倒挂的真实树木,根在上,叶在下。

可视化:一棵抽象的树

抽象的树结构 一个树形结构图,包含根节点、内部节点和叶节点。 根节点 内部节点 A 内部节点 B 叶节点 叶节点 叶节点

树的核心组成元素

一棵树由几种关键部分构成,我们来逐一认识它们。

  1. 根节点 (Root Node)
  2. 内部节点 (Internal Node)
  3. 叶节点 (Leaf / Terminal Node)
  4. 边 (Edge / Branch)

核心元素 (1): 根节点 (Root Node)

树的最顶层节点,唯一的,没有父节点。 它是所有决策的起点,包含了整个数据集。

根节点示意图 一棵树的结构,高亮显示其顶部的根节点。 根节点 内部节点 内部节点 叶节点 叶节点 叶节点

核心元素 (2): 内部节点 (Internal Node)

非叶子节点,它们既有父节点,也有子节点。 每个内部节点都代表一个决策点或一个问题

内部节点示意图 一棵树的结构,高亮显示其中间的内部节点。 根节点 内部节点 内部节点 叶节点 叶节点 叶节点

核心元素 (3): 叶节点 (Leaf Node)

树的末端节点,没有子节点。 它们代表了最终的预测结果决策结论

叶节点示意图 一棵树的结构,高亮显示其底部的叶节点。 根节点 内部节点 内部节点 叶节点 叶节点 叶节点

关键度量:树的深度 (Depth)

深度 (Depth) 是衡量树结构复杂性的一个重要指标。

  • 定义: 从根节点到最远的叶节点所需要经过的的数量。
  • 直观理解: 一个深度为 d 的树,最多需要 d 个问题就能得到最终答案。

深度越大的树,模型越复杂,决策规则越多。

可视化:树的深度

树的深度示意图 一棵深度为2的树,标注了各个层级和测量深度的路径。 ... ... 深度 = 0 深度 = 1 深度 = 2 这棵树的深度 = 2

从抽象树到决策树:赋予节点经济含义

现在,我们将抽象概念赋予经济含义。

决策树 (Decision Tree) 是一种机器学习方法,其中:

  • 每个内部节点 代表一个对特征的测试(一个分割规则)。
  • 每个分支 代表一个测试输出(决策路径)。
  • 每个叶节点 代表一个最终的决策或预测值(例如“高风险”或预测收益率0.05)。

实例:用决策树进行信用风险评估

这是一个经典的信用违约数据分类例子。我们的目标是根据客户信息,判断一笔贷款是“高风险”还是“低风险”。

我们的(模拟)数据集

假设我们有以下客户数据:

客户ID 收入 (千元) 拥有房产 年龄 风险等级
1 4.5 28
2 8.0 45
3 6.2 35
4 3.1 50
5 7.5 29

第一步:寻找最佳的第一个问题

算法会检查所有特征,找到那个最有区分度的特征来进行第一次划分。

假设模型发现收入是最重要的特征。

  • 规则: 收入 > 5.0 千元?

这个规则将所有申请人分成了两组,构成了我们决策树的根节点

信用风险评估:根节点分裂

信用风险决策树的根节点 决策树的第一个分裂,基于收入是否大于5000元。 收入 > 5.0? ... ...

可视化:第一次数据分割

基于收入的数据分割 散点图显示数据点根据收入被一条垂直线分割成两部分。 收入 (千元) 年龄 收入=5.0 收入3.1, 高风险 收入4.5, 高风险 收入6.2, 低风险 收入7.5, 低风险 收入8.0, 低风险

第二步:递归分裂 (Recursive Splitting)

现在我们对每个分支进行递归处理。

  • 对于收入 <= 5.0 的群体,模型发现他们的违约风险普遍很高,可以直接判定为高风险。这是一个叶节点
  • 对于收入 > 5.0 的群体,风险尚不明确。我们需要寻找下一个最有区分度的特征,比如是否拥有房产
  • 新规则: 拥有房产? (是/否)

信用风险评估:最终的决策树

最终,我们得到了一棵完整的决策树,它是一套清晰、可执行的规则。

完整的信用风险决策树 一个三层决策树,用于判断信用风险是高还是低。 收入 > 5.0? 高风险 有房产? 高风险 低风险

解读这棵树:将规则翻译成语言

这棵树告诉我们一个清晰的决策流程:

  1. 首先,检查客户的收入是否大于5000元。
  2. 如果不是,直接判定为“高风险”。
  3. 如果是,则接着检查客户是否拥有房产。
  4. 如果没有房产,判定为“高风险”。
  5. 如果有房产,判定为“低风险”。

决策树的强大优势

  1. 极强的可解释性 (Interpretability): 我们可以清晰地追踪模型的决策路径,理解为什么模型会做出这样的预测。这在金融风控、医疗诊断等领域至关重要。

  2. 高度的灵活性 (Flexibility): 决策树不要求特征与目标变量之间存在线性关系。它可以捕捉复杂的非线性和交互作用。

  3. 应用广泛: 决策树既可以用于回归问题 (Regression Tree),也可以用于分类问题 (Classification Tree)

第二部分:回归树 (Regression Tree)

回归树的目标:预测一个连续值

当我们想要预测一个连续的目标变量 y 时(例如股票收益率、房价、公司利润),我们使用回归树。

  • 核心问题: 如何构建一棵树,使其对 y 的预测最准确?
  • 关键: 我们需要一个评价标准来衡量预测的准确性。

我们需要一个代价函数 (Cost Function)

和线性回归一样,我们需要一个代价函数来量化模型的预测误差。我们的目标是找到一棵能够最小化这个代价函数的树。

  • 在线性回归中,我们使用的代价函数是均方误差 (Mean Squared Error, MSE)残差平方和 (Residual Sum of Squares, RSS)
  • 这个思想可以被完美地沿用到回归树中。

回归树的代价函数:残差平方和 (RSS)

对于一棵给定的树,它将样本空间划分为 M 个互不重叠的区域(叶节点) \(R_1, R_2, ..., R_M\)

对于任何落入区域 \(R_m\) 的观测值,我们的预测值都是该区域内所有训练样本目标值的平均值,记为 \(\hat{y}_{R_m}\)

\[ \large{\hat{y}_{R_m} = \frac{1}{N_m} \sum_{x_i \in R_m} y_i} \]

我们的目标是最小化总的 RSS:

\[ \large{RSS = \sum_{m=1}^{M} \sum_{x_i \in R_m} (y_i - \hat{y}_{R_m})^2} \]

可视化理解 RSS

RSS 就是所有数据点到其所在区域预测值(水平线)的垂直距离的平方和。我们的目标是调整分割线,让这些红色虚线的总长度(的平方)最小。

残差平方和 (RSS) 的可视化 散点图显示了数据点和两个区域的平均值线,以及它们之间的残差。 特征 X 目标 Y ŷ_R1 ŷ_R2

挑战:如何找到最优的树?

寻找能够最小化全局 RSS 的最优树是一个NP-hard问题。

为什么?因为可能的树结构数量是指数级的。我们不可能检查每一棵可能的树。这是一个组合爆炸问题,在计算上是不可行的。

可视化:组合爆炸

即使只有少数几个数据点,构建树的方式也多得惊人。

树结构的组合爆炸 从一个根节点出发,可以生成多种不同结构的树,数量迅速增加,形成混乱的组合爆炸。 起始数据 无数种可能的树...

解决方案:贪心算法 (Greedy Algorithm)

  • 动机:全局最优树难以穷举,因此寻找足够好的局部解。
  • 方向:从根节点向下构建(top-down)。
  • 规则:每一步只选当前最优分裂,不预见后续树结构(greedy)。
  • 算法名称:递归二元切分(Recursive Binary Splitting)。

贪心算法的类比:最速下降

就像一个徒步者在山上,每一步都选择最陡峭的下山路径,而不考虑这是否会将他带入一个无法走出的山谷。

贪心算法类比:最速下降 一个人在山顶选择最陡的路径下山,而忽略了可能通往全局最低点的另一条路径。 你在这里 贪心选择 (当前最陡) 全局最优路径 局部最优 全局最优

递归二元切分:算法步骤

算法从根节点开始,包含所有数据:

  1. 遍历所有特征: 对每一个特征 j
  2. 遍历所有可能的分裂点: 对特征 j 的每一个可能的分割点 s
  3. 计算分裂增益: 计算如果按照 (j, s) 这个规则进行分裂,会导致 RSS 减少多少。
  4. 选择最佳分裂: 选取那个能使 RSS 减少得最多的特征 j 和分割点 s
  5. 执行分裂: 用选出的最佳规则 (j, s) 将当前节点分裂成两个子节点。
  6. 递归: 对每个新的子节点,重复步骤 1-5,直到满足某个停止条件

关键计算:如何衡量一次分裂的好坏?

假设我们正在考虑用特征 j 和分割点 s 来分裂一个节点。这个分裂会产生两个区域:

  • \(R_1(j,s) = \{x | x_j < s\}\)
  • \(R_2(j,s) = \{x | x_j \ge s\}\)

这次分裂带来的 RSS 减少量 \(\Delta RSS\) 为:

\[ \large{\Delta RSS_{j,s} = RSS_{parent} - (RSS_{R_1} + RSS_{R_2})} \]

我们的目标就是在所有可能的 js 中,找到能使 \(\Delta RSS_{j,s}\) 最大化的那个组合。

一个数值例子:理解分裂过程

假设我们有一个节点,里面有5个样本,我们想基于特征 x 对它进行分裂。

样本 特征 x 目标 y
1 2.2 10
2 3.5 12
3 6.0 25
4 9.8 80
5 15.0 90

问题: 最佳的分割点在哪里?

可能的分割点

特征 x 的值是 [2.2, 3.5, 6.0, 9.8, 15.0]

我们可以在任意两个相邻点的中点进行分割。所以我们有4个可能的分割点:

  1. s1 = (2.2 + 3.5) / 2 = 2.85
  2. s2 = (3.5 + 6.0) / 2 = 4.75
  3. s3 = (6.0 + 9.8) / 2 = 7.9
  4. s4 = (9.8 + 15.0) / 2 = 12.4

程序化复算:四个候选必须来自同一输入

代码
import numpy as np  # 用数组按同一叶均值口径复算所有候选分裂
import pandas as pd  # 将候选阈值与复算结果整理为检查表
tree_feature_values = np.array([2.2, 3.5, 6.0, 9.8, 15.0])  # 固定题面五个特征值
tree_target_values = np.array([10.0, 12.0, 25.0, 80.0, 90.0])  # 固定题面五个目标值
candidate_thresholds = np.array([2.85, 4.75, 7.90, 12.40])  # 由相邻特征中点生成候选阈值
def calculate_split_rss(split_threshold):  # 对一个候选按两叶各自均值计算总平方误差
    left_targets = tree_target_values[tree_feature_values < split_threshold]  # 取阈值左侧目标
    right_targets = tree_target_values[tree_feature_values >= split_threshold]  # 取阈值右侧目标
    return float(np.square(left_targets - left_targets.mean()).sum() + np.square(right_targets - right_targets.mean()).sum())  # 合并两叶 RSS
candidate_rss = np.array([calculate_split_rss(threshold) for threshold in candidate_thresholds])  # 一次复算四个候选
expected_rss = np.array([4556.75, 2452.00, 182.6666666667, 3236.75])  # 确定可手算的正确答案
assert np.allclose(candidate_rss, expected_rss, atol=1e-8) and candidate_thresholds[candidate_rss.argmin()] == 7.90  # 确定四值与最优阈值
display(pd.DataFrame({'分割点 s': candidate_thresholds, '总 RSS': candidate_rss}).round(2))  # 输出与后续手算和图形同源的比较表
表 1: 同一五行示例程序化生成的四个候选分裂 RSS
分割点 s 总 RSS
0 2.85 4556.75
1 4.75 2452.00
2 7.90 182.67
3 12.40 3236.75

可视化:可能的分割点

可能的分割点 一条数轴上标出了5个数据点和4个可能的分割点。 x 0 5 10 15 20 2.2 3.5 6.0 9.8 15.0 s1=2.85 s2=4.75 s3=7.9 s4=12.4

检验分割点 1:s = 2.85

  • 规则: x < 2.85
  • 左子节点 R1: {样本1 (y=10)}
    • 预测值 \(\hat{y}_{R1} = 10\)
    • \(RSS_{R1} = (10-10)^2 = 0\)
  • 右子节点 R2: {样本2,3,4,5 (y=12,25,80,90)}
    • 预测值 \(\hat{y}_{R2} = (12+25+80+90)/4 = 51.75\)
    • \(RSS_{R2} = (12-51.75)^2 + ... + (90-51.75)^2 = 4556.75\)
  • 分裂后总 RSS: \(0 + 4556.75 = 4556.75\)

检验分割点 3:s = 7.9

  • 规则: x < 7.9
  • 左子节点 R1: {样本1,2,3 (y=10,12,25)}
    • 预测值 \(\hat{y}_{R1} = (10+12+25)/3 \approx 15.67\)
    • \(RSS_{R1} = (10-15.67)^2 + ... + (25-15.67)^2 \approx 132.67\)
  • 右子节点 R2: {样本4,5 (y=80,90)}
    • 预测值 \(\hat{y}_{R2} = (80+90)/2 = 85\)
    • \(RSS_{R2} = (80-85)^2 + (90-85)^2 = 50\)
  • 分裂后总 RSS: \(132.67 + 50 \approx 182.67\)

可视化检验分割点 3

分割点 s=7.9 的可视化 散点图显示在 x=7.9 处分割数据,以及两个区域的平均值线。 特征 X 目标 Y x=2.2, y=10 x=3.5, y=12 x=6.0, y=25 x=9.8, y=80 x=15.0, y=90 x=7.9 ŷ_R1=15.67 ŷ_R2=85

比较所有分割点

分割点 s 分割后的总 RSS
2.85 4556.75
4.75 2452.00
7.90 182.67
12.40 3236.75

结论: s = 7.9 是当前节点的最佳分割点,因为它使得分裂后的 RSS 最小。

可视化理解:好的分裂 vs 差的分裂

好的分裂与差的分裂对比 并排比较了最优分裂(s=7.9)和四个候选中最差分裂(s=2.85)的效果,显示了正确的RSS值。 最优分裂 (s=7.9) RSS = 182.67 最差分裂 (s=2.85) RSS = 4556.75

过拟合风险取决于可分性,也必须样本外检验

  • 可能插值的条件:训练特征可逐步区分响应,且树没有深度或叶样本约束。
  • 训练结果:回归树可能细分到单样本叶,将训练 RSS 降至 0。
  • 反例边界:重复特征对应不同响应时,树未必能实现插值。
  • 不可直接推论:插值通常提高估计方差,但不逻辑推出未来表现必然很差。
  • 检验标准:比较开发窗与时间外验证窗;验证误差或相对基线优势恶化,才构成当前任务的泛化失败证据。

何时停止分裂?– 超参数的作用

为了防止过拟合,我们需要设定一些停止条件,这些在 scikit-learn 中被称为超参数 (Hyperparameters)

我们来认识几个最重要的:

  1. 最大深度 (max_depth)
  2. 叶节点最少样本数 (min_samples_leaf)
  3. 最小分裂样本数 (min_samples_split)
  4. 最小不纯度减少量 (min_impurity_decrease)

超参数 (1): max_depth

树允许生长的最大层数。 这是控制模型复杂最直接的方法。

最大深度 max_depth 一棵树在达到最大深度2后停止生长。 ... ... ... Leaf Leaf max_depth = 2, 禁止继续生长!

超参数 (2): min_samples_leaf

一个叶节点必须包含的最少训练样本数。 这可以防止模型为少数几个异常点创建单独的规则。

叶节点最少样本数 min_samples_leaf 一个节点因为分裂后子节点的样本数少于阈值而停止分裂。 Samples = 12 成为叶节点 Samples = 8 Samples = 4 若叶节点下限为 5, 则不允许这次分裂

超参数 (3): min_samples_split

一个内部节点必须包含的最少样本数才能被分裂。

最小分裂样本数 min_samples_split 一个节点因为其样本数少于分裂阈值而成为叶节点。 Samples = 15 成为叶节点 如果 min_samples_split = 20, 这个节点就不能再分裂

一个完整的回归树例子:预测每股收益率

下图展示了一个训练好的、深度为2的回归树,用于预测公司的每股收益率 (EPS)

每股收益预测决策树示例 一个深度为2的回归树,用于预测EPS,布局清晰可读。 pps <= 1093.0 samples = 4224 pps <= 111.665 samples = 4215 roa <= 0.069 samples = 9 value = 0.64 value = 2.363 value = 31.08 value = 21.475

叶路径对应的 EPS 预测

路径 samples mse value
pps≤1093pps≤111.665 4190 0.99 0.640
pps≤1093pps>111.665 25 2.33 2.363
pps>1093roa≤0.069 5 101.30 31.080
pps>1093roa>0.069 4 25.40 21.475

树图负责回答“如何分裂”,本表负责保留每条叶路径的训练样本、误差与预测值。

解读这棵树:追踪一条路径

让我们追踪一家普通公司:pps = 100, roa = 0.05

  1. 根节点: pps (100) <= 1093.0? 。往左走。
  2. 左侧节点: pps (100) <= 111.665? 。往左走。
  3. 到达叶节点: 最终预测该公司的 EPS 为 0.64

解决方案:决策树的剪枝 (Pruning)

剪枝是防止过拟合、简化决策树的主要方法。有两种策略:

  1. 预剪枝 (Pre-pruning): 在树的生长过程中,提前停止。我们之前讨论的停止条件(如max_depth, min_samples_leaf)就是一种预剪枝。
  2. 后剪枝 (Post-pruning): 先生成一棵可能过拟合的“大树”,然后自底向上地裁剪掉一些分支。

后剪枝通常效果更好,因为它看到了树的全貌。

可视化:预剪枝 vs 后剪枝

预剪枝与后剪枝的对比 两棵树,左边显示了预剪枝(提前停止),右边显示了后剪枝(生长后裁剪)。 预剪枝 (Pre-pruning) 达到停止条件, 不再生长 后剪枝 (Post-pruning) 先完整生长, 再修剪

后剪枝的核心思想:代价复杂度剪枝

这是一种最常用的后剪枝方法。它的思想是,我们不再仅仅最小化 RSS,而是最小化一个加了惩罚项的代价函数:

\[ \large{C_{\alpha}(T) = \underbrace{\sum_{m=1}^{|T|} \sum_{x_i \in R_m} (y_i - \hat{y}_{R_m})^2}_{\text{误差项 (RSS)}} + \underbrace{\alpha |T|}_{\text{复杂度惩罚项}}} \]

  • \(|T|\) 是树 \(T\)叶节点数量(衡量树的复杂度)。
  • \(\alpha \ge 0\) 是一个惩罚参数 (Tuning Parameter),需要通过交叉验证来选择。

惩罚参数 \(\alpha\) 的作用

\(\alpha\) 控制着我们对模型简单性和拟合优度之间的权衡。

惩罚参数 alpha 的作用 一个滑块代表alpha值,从0到无穷大,对应下方的树从复杂到简单。 惩罚参数 α α = 0 α → ∞ 中等 α 最大树 (过拟合) 最优子树 (泛化好) 根节点 (欠拟合)

第三部分:分类树 (Classification Tree)

分类树的目标:预测一个类别

当我们想要预测一个离散的目标变量 y 时(例如“违约”/“不违约”,股票“涨”/“跌”/“平”),我们使用分类树。

  • 预测方式: 对于一个叶节点,它的预测结果是该节点中数量最多的那个类别。
  • 核心问题: RSS 不再适用。我们需要一个新的标准来衡量一个节点的“纯度 (purity)”,并以此来指导分裂。

核心概念:节点纯度 (Purity)

一个好的分裂应该使得分裂后的子节点更“纯”

节点纯度示意图 三个容器,分别代表纯、不纯和最不纯的节点。 最纯的节点 (Gini=0) 较不纯的节点 最不纯的节点 (Gini=0.5)

衡量“不纯度”的指标

我们有两种常用的指标来量化一个节点的“不纯度” (Impurity):

  1. 基尼不纯度 (Gini Impurity)
  2. 信息熵 (Entropy)

指标一:基尼不纯度 (Gini Impurity)

对于一个给定的节点,基尼不纯度的计算公式为:

\[ \large{G = \sum_{k=1}^{K} \hat{p}_{mk} (1 - \hat{p}_{mk})} \]

其中:

  • \(K\) 是类别的总数。
  • \(\hat{p}_{mk}\) 是在节点 m 中,第 k 类样本所占的比例

直观理解: \(G\) 度量了从一个节点中随机抽取的两个样本类别不同的概率。

  • \(G=0\): 完全纯。
  • \(G\) 越大: 越不纯。

基尼不纯度计算示例

假设一个节点有10个样本,4个是“违约”(+),6个是“不违约”(-)。

  • \(p(+) = 4/10 = 0.4\)
  • \(p(-) = 6/10 = 0.6\)

该节点的基尼不纯度为:

\[ \large{G = p(+)(1-p(+)) + p(-)(1-p(-))} \]

\[ \large{G = 0.4(0.6) + 0.6(0.4) = 0.24 + 0.24 = 0.48} \]

指标二:信息熵 (Entropy)

信息熵源于信息论,衡量的是一个系统的不确定性混乱程度

\[ \large{D = - \sum_{k=1}^{K} \hat{p}_{mk} \log_2(\hat{p}_{mk})} \]

  • \(D=0\): 完全纯,系统没有任何不确定性。
  • \(D\) 越大: 越混乱,不确定性越大。

注意: 约定 \(0 \log 0 = 0\)

信息熵计算示例

同样是10个样本,4个“违约”(+),6个“不违约”(-)。

  • \(p(+) = 0.4\)
  • \(p(-) = 0.6\)

该节点的信息熵为:

\[ \large{D = - [p(+) \log_2(p(+)) + p(-) \log_2(p(-))]} \]

\[ \large{D = - [0.4 \times (-1.32) + 0.6 \times (-0.74)] \approx 0.97} \]

基尼 vs 熵:有何区别?

  • 实践中: 两者效果非常相似,最终生成的树几乎没有差别。基尼系数的计算稍微快一些,因为它不涉及对数运算。scikit-learn 的默认选项是基尼系数。
  • 理论上: 熵更倾向于产生更“平衡”的分裂。
代码
# 为“基尼 vs 熵:有何区别?”导入 `numpy` 并绑定 `np`,用于执行当前任务的数组、数值或随机机制计算。
import numpy as np
# 为“基尼 vs 熵:有何区别?”导入 `matplotlib.pyplot` 并绑定 `plt`,用于构建当前任务的坐标轴并呈现比较结果。
import matplotlib.pyplot as plt

# 建立 `p` 的有序取值网格,用于展示“二分类问题中,基尼不纯度与信息熵的比较”随参数变化的比较结果。
p = np.linspace(0.001, 0.999, 200)
# 按二分类定义计算各概率下的基尼不纯度。
gini = 2 * p * (1-p)
# 对 `p * np.log2(p) + (1 - p) * np.log2(1 - p)` 应用一元运算,得到 `entropy`。
entropy = - (p * np.log2(p) + (1-p) * np.log2(1-p))


# 创建 `fig, ax` 画布,承载“二分类问题中,基尼不纯度与信息熵的比较”的并排视觉比较。
fig, ax = plt.subplots(figsize=(7, 3.5))
# 以 `p` 为横轴、`gini` 为纵轴绘制曲线,展示“二分类问题中,基尼不纯度与信息熵的比较”。
ax.plot(p, gini, label='Gini Impurity (基尼不纯度)', color='royalblue', linewidth=2.5)
# 以 `p` 为横轴、`entropy` 为纵轴绘制曲线,展示“二分类问题中,基尼不纯度与信息熵的比较”。
ax.plot(p, entropy, label='Entropy (信息熵)', color='crimson', linewidth=2.5)
# 将图题设为“基尼不纯度 vs. 信息熵 (二分类)”,直接说明当前图形的比较目的。
ax.set_title('基尼不纯度 vs. 信息熵 (二分类)', fontsize=16)
# 将横轴标为“类别 1 的比例 (p)”,明确横向编码的变量。
ax.set_xlabel('类别 1 的比例 (p)', fontsize=12)
# 将纵轴标为“不纯度”,明确纵向编码的变量。
ax.set_ylabel('不纯度', fontsize=12)
# 在 `0.5` 处添加垂直参考线,标出“二分类问题中,基尼不纯度与信息熵的比较”的基准或阈值。
ax.axvline(0.5, color='grey', linestyle='--', lw=1)
# 以箭头标注“不纯度最高”,指出“二分类问题中,基尼不纯度与信息熵的比较”中的候选拐点或阈值。
ax.annotate('不纯度最高', xy=(0.5, 1.0), xytext=(0.55, 0.9),
            # 指定 `arrowprops` 为注释箭头样式,细化“二分类问题中,基尼不纯度与信息熵的比较”的输出。
            arrowprops=dict(facecolor='black', shrink=0.05, width=1, headwidth=8),
            # 将“最大差异”注释设为 12pt,保证箭头说明在图中可读。
            fontsize=12)
# 显示“基尼 vs 熵:有何区别?”图例,使颜色或线型与比较对象一一对应。
ax.legend()
# 显示基尼不纯度与熵随正类比例变化的曲线,比较两者在纯节点处为零、在均衡节点处最大的共同性质。
plt.show()
横轴为类别 1 比例 p、纵轴为不纯度;基尼与熵曲线均在 p=0.5 最高、在纯节点两端最低。
图 1: 二分类问题中,基尼不纯度与信息熵的比较

分类树的分裂标准:不纯度减少量

与回归树类似,分类树在选择最佳分裂时,寻找的是那个能够最大化不纯度减少量 (Impurity Decrease) 的分裂。

\[ \large{\Delta I = I_{parent} - (\frac{N_{left}}{N_{parent}} I_{left} + \frac{N_{right}}{N_{parent}} I_{right})} \]

这个值也被称为信息增益 (Information Gain)(如果使用熵作为不纯度度量)。

可视化:信息增益

我们选择能让分裂后的子节点(右边两个小罐子)的加权平均纯度最高(不纯度最低)的分裂。

信息增益可视化 一个不纯的父节点分裂成两个更纯的子节点,显示了信息增益。 父节点: 不纯 子节点1: 纯 子节点2: 纯 信息增益很高!

第四部分:Python实践——公开A股盈利预测

我们的任务:用基本面数据预测下一年 EPS

我们将使用真实的金融数据来构建一个回归树。

  • 目标:用 \(t\) 年已披露的年度财务指标预测同一公司 \(t+1\) 年基本每股收益。
  • 特征:总资产对数、负债率、资产回报率;均来自公开 financial_statement.h5
  • 样本:8 家长三角上市公司,2012—2024 年年度财报;按目标财年最终测试后段测试期。
  • 边界:这是条件预测练习,不把决策树分裂解释为因果效应或投资建议。

步骤一:导入必要的库

这是我们的标准起手式:导入所有需要的Python库。

代码
# 为“步骤一:导入必要的库”导入 `pandas` 并绑定 `pd`,用于整理当前任务的表格、字段与时间索引。
import pandas as pd
# 为“步骤一:导入必要的库”导入 `numpy` 并绑定 `np`,用于执行当前任务的数组、数值或随机机制计算。
import numpy as np

# 为“步骤一:导入必要的库”,机器学习库
from sklearn.tree import DecisionTreeRegressor
# 为“步骤一:导入必要的库”,从 `sklearn.metrics` 导入`mean_squared_error` 用于计算真实值与预测值的均方误差。
from sklearn.metrics import mean_squared_error

# 为“步骤一:导入必要的库”导入 `matplotlib.pyplot` 并绑定 `plt`,用于构建当前任务的坐标轴并呈现比较结果。
import matplotlib.pyplot as plt
# 为“步骤一:导入必要的库”,导入决策树绘图函数,展示分裂特征与阈值。
from sklearn.tree import plot_tree

from pathlib import Path  # 管理 Linux 与 Windows 的公开数据路径

步骤二 (1/2): 获取和准备数据

读取公开上市公司财务披露版本。公司池与字段预先确定;每个固定决策日都从原始多版本表重新筛选,禁止先对全样本 keep='last' 再回切历史。

代码
from urllib.request import urlretrieve  # 复用本章隐藏设置单元安装的浏览器标识下载器
# 按 Linux 共享数据、Windows 共享数据、项目缓存的顺序选择财务报表文件。
financial_path = next((candidate_path for candidate_path in [Path('/home/ubuntu/r2_data_mount/data/stock/financial_statement.h5'), Path('C:/qiufei/data/stock/financial_statement.h5'), Path('data/course/financial_statement.h5')] if candidate_path.exists()), Path('data/course/financial_statement.h5'))
if not financial_path.exists():
    financial_path.parent.mkdir(parents=True, exist_ok=True)
    urlretrieve('https://assets.qiufei.site/data/stock/financial_statement.h5', financial_path)
yangtze_company_ids = ['600276.XSHG', '600009.XSHG', '600585.XSHG', '603259.XSHG', '002415.XSHE', '600104.XSHG', '600019.XSHG', '600406.XSHG']  # 固定沪苏浙皖公司池
financial_columns = ['order_book_id', 'quarter', 'info_date', 'total_assets', 'total_liabilities', 'net_profit_parent_company', 'basic_earnings_per_share']  # 限定读取字段
company_frames = []  # 收集公司级年度财报
for company_id in yangtze_company_ids:  # 避免载入全市场全部列
    company_frame = pd.read_hdf(financial_path, key='financial_data', where=f'order_book_id == "{company_id}"', columns=financial_columns)  # 选择性读取单家公司
    company_frames.append(company_frame)  # 保存当前公司数据
annual_raw = pd.concat(company_frames, ignore_index=True)  # 合并但保留全部披露版本
annual_raw = annual_raw.query("quarter.str.endswith('q4')", engine='python').copy()  # 只保留年度报告版本
annual_raw['info_date'] = pd.to_datetime(annual_raw['info_date'])  # 统一信息可得日类型
annual_raw['feature_year'] = annual_raw['quarter'].str[:4].astype(int)  # 提取报告财年
annual_raw = annual_raw.query('2012 <= feature_year <= 2024').copy()  # 确定财年范围
display(annual_raw[['order_book_id', 'quarter', 'info_date', 'basic_earnings_per_share']].rename(columns={
    'order_book_id': '证券代码', 'quarter': '报告期', 'info_date': '披露日',
    'basic_earnings_per_share': '基本每股收益'
}).head(8))
部分获取到的公司财务数据
证券代码 报告期 披露日 基本每股收益
31 600276.XSHG 2012q4 2014-04-09 0.7921
35 600276.XSHG 2013q4 2015-03-31 0.8251
39 600276.XSHG 2014q4 2016-04-13 0.7778
43 600276.XSHG 2015q4 2017-03-11 0.9245
47 600276.XSHG 2016q4 2019-02-16 0.7100
51 600276.XSHG 2017q4 2020-03-23 0.7300
55 600276.XSHG 2018q4 2021-04-20 0.7600
59 600276.XSHG 2019q4 2022-04-23 0.8300

目标年份与披露日双重检查阻止错位

代码
def last_asof(raw, fiscal_year, cutoff):  # 每个截止日都从原始版本重建数据版本
    all_versions = raw.loc[raw['feature_year'].eq(fiscal_year)].copy()  # 保留该财年全部版本
    eligible = all_versions.loc[all_versions['info_date'].le(cutoff)].copy()  # 先按截止日过滤
    if eligible.empty:  # 空窗口不得真空通过
        raise RuntimeError(f'{fiscal_year}@{cutoff.date()} 空窗')  # 发现点时空窗即中止,避免静默使用未来版本
    snapshot = eligible.sort_values(['order_book_id', 'feature_year', 'info_date']).drop_duplicates(['order_book_id', 'feature_year'], keep='last')  # 再选点时版本
    assert snapshot['info_date'].le(cutoff).all()  # 确定可得日不变量
    flow = {'raw_versions': len(all_versions), 'eligible_versions': len(eligible), 'excluded_after_cutoff': len(all_versions) - len(eligible), 'unique_companies': snapshot['order_book_id'].nunique(), 'max_info_date': snapshot['info_date'].max()}  # 记录样本数量变化
    return snapshot, flow  # 返回数据版本与检查结果

def build_eps_year(target_year):  # 构造固定目标年的特征与事后评分标签
    decision = pd.Timestamp(f'{target_year + 2}-06-30')  # 按公开披露滞后在目标年后第二个六月末预测
    score = pd.Timestamp(f'{target_year + 3}-06-30')  # 再过一年评分
    features, feature_flow = last_asof(annual_raw, target_year - 1, decision)  # 取上一财年点时特征
    label_all = annual_raw.loc[annual_raw['feature_year'].eq(target_year)].copy()  # 标签版本先不去重
    assert len(label_all) > 0 and label_all['info_date'].min() > decision  # 任何目标版本均不得在决策日可见
    labels, label_flow = last_asof(annual_raw, target_year, score)  # 到评分日选择标签版本
    features = features.rename(columns={'info_date': 'feature_info_date'})  # 明示特征可得日
    labels = labels[['order_book_id', 'info_date', 'basic_earnings_per_share']].rename(columns={'info_date': 'target_info_date', 'basic_earnings_per_share': 'next_eps'})  # 明示标签可得日
    panel = features.merge(labels, on='order_book_id', how='inner').assign(target_year=target_year, decision_date=decision, score_cutoff=score)  # 合并公司年
    panel['log_assets'] = np.log(panel['total_assets'])  # 构造规模
    panel['debt_ratio'] = panel['total_liabilities'] / panel['total_assets']  # 构造负债率
    panel['roa'] = panel['net_profit_parent_company'] / panel['total_assets']  # 构造盈利率
    assert len(panel) > 0 and panel['feature_info_date'].le(decision).all() and panel['target_info_date'].gt(decision).all() and panel['target_info_date'].le(score).all()  # 阻止泄漏
    return panel, {'target_year': target_year, 'feature': feature_flow, 'label': label_flow, 'merged': len(panel)}  # 返回样本与样本数量变化
代码
point_time_parts, version_flows = [], []  # 只收集开发期逐目标年样本与检查结果
for target_year in range(2016, 2023):  # 构造 2016—2022 开发目标年,不提前构造 2023 标签
    year_panel, year_flow = build_eps_year(target_year)  # 执行固定决策日与评分日说明
    point_time_parts.append(year_panel)  # 保存当年面板
    version_flows.append(year_flow)  # 保存版本选择证据
development_panel = pd.concat(point_time_parts, ignore_index=True).replace([np.inf, -np.inf], np.nan).dropna(subset=['log_assets', 'debt_ratio', 'roa', 'next_eps'])  # 合并开发期有效样本
assert not development_panel.duplicated(['order_book_id', 'target_year']).any()  # 每公司目标年唯一
assert development_panel['target_year'].max() == 2022  # 保证最终测试目标年尚未构造或查看
assert set(development_panel['target_year']) == set(range(2016, 2023))  # 核对七个开发目标年完整
feature_names = ['log_assets', 'debt_ratio', 'roa']  # 确定特征字典
X_development, y_development = development_panel[feature_names], development_panel['next_eps']  # 构造开发期特征和目标
flow_table = pd.json_normalize(version_flows)[['target_year', 'feature.raw_versions', 'feature.excluded_after_cutoff', 'label.raw_versions', 'label.excluded_after_cutoff', 'merged']].set_axis(['年', '特征版本', '特征排除', '标签版本', '标签排除', '合并'], axis=1)  # 用短列名展示完整样本数量变化
display(flow_table)  # 输出全部目标年的版本与排除数
print({'开发期X': X_development.shape, 'feature_max': development_panel.feature_info_date.max().date(), 'label_min': development_panel.target_info_date.min().date(), '最终测试状态': '2023 标签尚未构造'})  # 输出紧凑不变量摘要
特征版本 特征排除 标签版本 标签排除 合并
0 2016 8 0 8 0 8
1 2017 8 0 8 0 8
2 2018 8 0 8 0 8
3 2019 8 0 8 0 8
4 2020 8 0 8 0 8
5 2021 8 0 8 0 8
6 2022 8 0 8 0 8
{'开发期X': (56, 3), 'feature_max': datetime.date(2024, 4, 27), 'label_min': datetime.date(2019, 2, 16), '最终测试状态': '2023 标签尚未构造'}

按目标年划分开发训练窗与伪测试窗

为示范预测与评分流程,我们只在开发期内部划分两部分:

  • 开发训练窗:2016—2021 目标年,用于拟合示范树。
  • 开发期伪测试窗:2022 目标年,用于课堂评分与候选比较;它仍属开发证据,不是最终最终测试。

可视化:开发训练窗 vs 2022 伪测试窗

开发训练窗与伪测试窗划分 开发样本按目标年分为2016至2021训练窗与2022伪测试窗;2023仍保持未构造的最终测试状态。 开发期样本 开发训练:2016—2021 伪测试:2022

步骤三:执行代码

代码
demo_validation_year = 2022  # 确定开发期伪测试目标年
demo_training_mask = development_panel['target_year'] < demo_validation_year  # 较早开发目标年用于训练
demo_validation_mask = development_panel['target_year'] == demo_validation_year  # 2022 只作开发期评分
X_demo_train = X_development.loc[demo_training_mask]  # 提取开发训练特征
X_demo_valid = X_development.loc[demo_validation_mask]  # 提取开发期伪测试特征
y_demo_train = y_development.loc[demo_training_mask]  # 对齐开发训练目标
y_demo_valid = y_development.loc[demo_validation_mask]  # 对齐开发期伪测试目标
print({'开发训练': X_demo_train.shape, '2022伪测试': X_demo_valid.shape})  # 报告开发窗样本形状
print(f'开发训练披露期: {development_panel.loc[demo_training_mask, "target_info_date"].min().date()}{development_panel.loc[demo_training_mask, "target_info_date"].max().date()}')  # 报告训练窗口
print(f'2022伪测试披露期: {development_panel.loc[demo_validation_mask, "target_info_date"].min().date()}{development_panel.loc[demo_validation_mask, "target_info_date"].max().date()}')  # 报告开发评分窗口
{'开发训练': (48, 3), '2022伪测试': (8, 3)}
开发训练披露期: 2019-02-16—2024-04-27
2022伪测试披露期: 2025-03-18—2025-04-30

树复杂度只能在开发期选择

现在我们来构建我们的决策树模型。

  • 我们使用 DecisionTreeRegressor 因为我们的目标y是连续的。
  • 我们设置 max_depth=2 作为预剪枝策略,防止模型过于复杂,也方便我们后续的可视化和解读。
代码
# 建立最大深度为二的回归树并固定随机状态,限制教学模型复杂度。
cart_regressor = DecisionTreeRegressor(max_depth=2, random_state=42)

# 仅用训练期特征与 EPS 目标估计回归树的分裂阈值和叶节点均值。
# .fit() 方法是所有scikit-learn模型的训练入口
cart_regressor.fit(X_demo_train, y_demo_train)

# 报告示范树已只用较早开发窗拟合,2023 仍未构造。
print('2022 开发期伪测试示范树训练完成;2023 仍最终测试。')
2022 开发期伪测试示范树训练完成;2023 仍最终测试。

步骤五:在 2022 开发期伪测试窗预测

示范树只应用于 2022 开发期伪测试窗。该结果允许用于教学与候选比较,不产生最终性能结论。

代码
# 对 2022 开发期伪测试特征生成预测。
demo_predictions = cart_regressor.predict(X_demo_valid)

# 将开发期真实值与预测值整理为可核对表格。
demo_results = pd.DataFrame({'2022开发真实值': y_demo_valid, '示范树预测值': demo_predictions})
# 输出前五条开发期结果,明确它不是 2023 最终测试证据。
display(demo_results.head())
2022开发真实值 示范树预测值
48 1.37 0.945724
49 -1.26 0.945724
50 0.55 1.725154
51 1.40 1.725154
52 0.62 0.945724

开发期伪测试也必须与训练均值同窗比较

  • 指标:均方误差(Mean Squared Error, MSE)。
  • 计算:对预测值与真实值之差的平方取平均。
  • 判读:在同一任务与样本上,MSE 越小表示平均平方预测误差越低。

\[ \large{MSE = \frac{1}{n} \sum_{i=1}^{n} (y_i - \hat{y}_i)^2} \]

代码
# 计算 2022 开发期伪测试 MSE,只用于流程示范。
demo_tree_mse = mean_squared_error(y_demo_valid, demo_predictions)
# 只用较早开发训练期均值建立同窗朴素基准。
demo_mean_predictions = np.repeat(y_demo_train.mean(), len(y_demo_valid))
# 计算同一 2022 开发窗的训练均值基准误差。
demo_baseline_mse = mean_squared_error(y_demo_valid, demo_mean_predictions)
# 报告开发证据并再次声明 2023 尚未开启。
print({'2022示范树MSE': round(demo_tree_mse, 4), '2022训练均值MSE': round(demo_baseline_mse, 4), '最终测试状态': '2023 未开启'})
{'2022示范树MSE': 2.6932, '2022训练均值MSE': 1.9006, '最终测试状态': '2023 未开启'}

每条规则只解释模型路径

scikit-learn 提供了强大的 plot_tree 函数,让我们能够直观地看到模型内部的决策规则。这是决策树模型最大的魅力所在。

代码
# 为“步骤七:可视化与解读决策树”建立投影画布,给坐标、图例与标注预留空间。
plt.figure(figsize=(22, 10))

# 绘出回归树的分裂条件、样本量、节点误差与预测值。
plot_tree(cart_regressor,
          # 固定 `feature_names` 的特征名称,使图表标签与模型输入列一一对应。
          feature_names=feature_names,
          filled=True, # 用颜色梯度编码各回归节点的预测值
          rounded=True, # 使用圆角矩形
          impurity=False, # 省略课堂解读不需要的节点误差字段
          proportion=True, # 用样本比例替代较难比较的原始计数
          # 指定 `fontsize` 为投影字号,细化“训练好的每股收益预测决策树 (最大深度=2)”的输出。
          fontsize=16,
          precision=3) # 显示3位小数

# 将图题设为“决策树模型:预测每股收益 (EPS)”,直接说明当前图形的比较目的。
plt.title('2022 开发期伪测试示范树:预测每股收益 (EPS)', fontsize=20)
# 显示已拟合决策树的分裂变量、阈值、样本数与叶节点预测,核对 EPS 预测路径。
plt.show()
自顶向下显示深度不超过 2 的回归树;节点列出切分特征、阈值、样本数与预测值,左右分支对应条件真假。
图 2: 2022 开发期伪测试示范树(最大深度=2)

如何解读 plot_tree 字段?

下图数值只用于说明字段,不对应本次拟合输出;实际节点、阈值与样本数以上一页执行生成的树图为准。

plot_tree 节点字段说明 使用示意数值解释分裂规则、节点均方误差、样本数与预测值;不对应本次拟合树。 ROA <= 0.051 mse = 14.892 samples = 32 value = 5.234 分裂规则 (问题) 该节点的均方误差 落入该节点的样本数 该节点的预测值 (样本均值)

第五部分:总结与展望

决策树的优点与缺点

优点 (Pros)

  • 可解释性强:规则直观易懂。
  • 预处理要求低:不需特征缩放。
  • 处理非线性关系:能捕捉复杂特征交互。
  • 处理混合数据:能同时用于数值和类别特征。

缺点 (Cons)

  • 不稳定:对数据微小变动敏感。
  • 容易过拟合:需要剪枝来控制。
  • 贪心算法:不保证找到全局最优树。
  • 预测性能相对较弱:单个决策树精度通常不高。

核心权衡:可解释性 vs. 预测精度

在机器学习中,这是一种永恒的权衡。

可解释性与预测精度的权衡 一个坐标轴,显示不同模型在可解释性和预测精度上的位置。 预测精度 可解释性强 可解释性弱 线性回归 决策树 随机森林 神经网络

展望:从一棵树到一片森林

决策树最大的缺点是不稳定。但这个缺点也正是它的力量来源!

通过平均随机化树,可能降低方差;效果取决于树间误差相关性、采样、特征随机化与验证设计,不能预先保证更强或更稳健。

下一章:集成学习

  • 并行平均:随机森林(Random Forest)。
  • 序列修正:梯度提升树(Gradient Boosting Trees)。

练习:知识理解

我们使用决策树模型对一个数据集进行拟合。通过交叉验证,我们发现模型在训练数据中的损失远小于在验证数据集中的损失。

  1. 这个问题是过拟合还是欠拟合?
  2. 以下哪种方法可能可以对于这种问题有所帮助? 为什么?
      1. 减少决策树的深度。
      1. 对现有决策树的叶节点进行分裂。
      1. 对决策树进行剪枝。

反馈:训练—验证差距指向高方差

  1. 这是典型的过拟合 (Overfitting)。模型在它“熟悉”的训练数据上表现很好,但在“陌生”的验证数据上表现很差,说明它学习了太多训练数据特有的噪声,缺乏泛化能力。

  2. 有帮助的方法是 (a) 和 (c)

    • (a) 减少决策树的深度: 这是预剪枝的一种方式。通过限制树的复杂度,强迫模型学习更具泛化性的规律。
    • (b) 对叶节点进行分裂: 这会使树更深、更复杂,从而加剧过拟合。
    • (c) 对决策树进行剪枝: 这是后剪枝。通过移除对泛化能力贡献不大的分支来简化模型,是对抗过拟合的核心技术。
  • 选择“继续分裂”:回看 停止条件
  • 说明深度/剪枝仍须在开发期选择:进入 独立任务

独立任务:修订披露、目标错位与一次最终测试

  1. 找出两段错误伪代码的 bug:先对所有修订版本 drop_duplicates(..., keep='last') 再筛 decision_date;只 shift(-1) 却不核对 target_year==feature_year+1
  2. 仅在开发期比较 max_depth∈{1,2,3} × min_samples_leaf∈{2,5,10} 的逐窗 MSE,并与训练期均值和深度 1 树同窗比较。
  3. 预先确定 tie-break;合并开发期重训,只打开一次 2023 目标年测试。
  4. 对首个测试样本提交具名路径规则卡:每个 split 的特征、阈值、方向与最终叶值。

练习:先完成再查看答案:树候选先最终测试

提交九种树规格的四窗比较结果、训练均值基线、确定规则与一张节点规则卡;不得只报最优树。提交后才进入完整答案。

完整答案:开发窗确定与具名规则卡

  • 版本筛选:每个决策日先筛 info_date<=decision_date,再在公司 × 财年内保留当时最后版本。
  • 标签对齐:标签必须来自严格相邻财年。
  • 停止条件:任一窗口为空、年份不相邻、目标在决策日已知,或测试被重复查看。
表 2
代码
from sklearn.metrics import mean_squared_error  # 统一比较开发窗和最终测试窗
from sklearn.tree import DecisionTreeRegressor  # 建立事先确定浅树候选
candidate_rows = []  # 保存全部候选的逐窗比较结果
development_pool = development_panel.copy()  # 使用 2016—2022 开发期,2023 标签尚未构造
for validation_year in [2019, 2020, 2021, 2022]:  # 使用可实现目标年的扩展窗口
    decision = pd.Timestamp(f'{validation_year + 2}-06-30')  # 确定当前预测日
    train_fold = development_pool.query('target_year < @validation_year and target_info_date <= @decision')  # 只用预测日已评分历史标签
    valid_fold = development_pool.query('target_year == @validation_year')  # 固定当前目标年
    if train_fold.empty or valid_fold.empty:  # 空窗显式失败
        raise RuntimeError(f'{validation_year} 训练或验证空窗')  # 窗口不完整时拒绝生成不可比的验证分数
    fold_mean = train_fold['next_eps'].mean()  # 训练期均值同窗基线
    for depth in [1, 2, 3]:  # 遍历事先确定的三种树深度
        for leaf_size in [2, 5, 10]:  # 遍历事先确定的三种叶节点下限
            candidate_tree = DecisionTreeRegressor(max_depth=depth, min_samples_leaf=leaf_size, random_state=42).fit(train_fold[feature_names], train_fold['next_eps'])  # 仅在当前训练窗拟合候选树
            candidate_rows.append({'validation_year': validation_year, 'max_depth': depth, 'min_samples_leaf': leaf_size, 'mse': mean_squared_error(valid_fold['next_eps'], candidate_tree.predict(valid_fold[feature_names])), 'mean_baseline_mse': mean_squared_error(valid_fold['next_eps'], np.repeat(fold_mean, len(valid_fold)))})  # 保存模型与同窗均值基线误差
candidate_table = pd.DataFrame(candidate_rows)  # 保留所有逐窗证据

完整答案:四个开发窗的九规格比较结果

代码
expected_development_years = [2019, 2020, 2021, 2022]  # 确定四个开发验证窗
candidate_counts = candidate_table.groupby('validation_year').size()  # 统计每窗候选覆盖
assert candidate_counts.reindex(expected_development_years).eq(9).all()  # 每窗必须九规格齐全
assert len(candidate_table) == 36  # 四窗乘九规格必须形成三十六条比较结果
candidate_wide = candidate_table.pivot(index=['max_depth', 'min_samples_leaf'], columns='validation_year', values='mse').reindex(columns=expected_development_years).reset_index()  # 压缩为九规格乘四窗
assert candidate_wide.shape == (9, 6)  # 两个参数列加四个开发窗
development_baseline_by_year = candidate_table.groupby('validation_year')['mean_baseline_mse'].first().reindex(expected_development_years)  # 提取四窗训练均值基线
baseline_consistency = candidate_table.groupby('validation_year')['mean_baseline_mse'].nunique().reindex(expected_development_years)  # 核对同窗基线未随树规格变化
assert baseline_consistency.eq(1).all()  # 每个开发窗只能有一个训练均值基线
print({'每窗候选数': candidate_counts.to_dict(), '候选总行数': len(candidate_table), '训练均值基线MSE': development_baseline_by_year.round(4).to_dict()})  # 输出覆盖与同窗基线检查
display(candidate_wide.round(4))  # 完整展示三十六个候选 MSE
{'每窗候选数': {2019: 9, 2020: 9, 2021: 9, 2022: 9}, '候选总行数': 36, '训练均值基线MSE': {2019: 3.4001, 2020: 4.1258, 2021: 3.6541, 2022: 1.9006}}
表 3: 2019—2022 四个开发窗的九规格验证 MSE
validation_year max_depth min_samples_leaf 2019 2020 2021 2022
0 1 2 2.6670 3.0804 2.5180 2.0547
1 1 5 2.6670 3.0804 2.5180 2.0547
2 1 10 3.1397 3.0804 2.5180 2.0547
3 2 2 2.5482 0.6583 0.5962 2.6932
4 2 5 2.6630 1.8733 1.0259 1.9490
5 2 10 3.1397 3.2394 2.5519 1.8119
6 3 2 1.3613 0.6982 0.5603 2.5428
7 3 5 2.6630 2.2654 1.1110 1.8048
8 3 10 3.1397 3.2394 2.5519 1.7741

九种规格跨窗汇总与确定依据

代码
candidate_summary = candidate_table.groupby(['max_depth', 'min_samples_leaf'], as_index=False).agg(mean_validation_mse=('mse', 'mean'), worst_validation_mse=('mse', 'max'))  # 汇总四窗平均与最差误差
assert len(candidate_summary) == 9  # 九种规格必须全部进入汇总
best_mse = candidate_summary['mean_validation_mse'].min()  # 确定最小跨窗均值 MSE
shortlist = candidate_summary.loc[candidate_summary['mean_validation_mse'].le(best_mse + 1e-12)]  # 只保留容差内并列者
selected_spec = shortlist.sort_values(['max_depth', 'min_samples_leaf'], ascending=[True, False]).iloc[0]  # 并列选更浅、再选更大叶节点
final_tree = DecisionTreeRegressor(max_depth=int(selected_spec.max_depth), min_samples_leaf=int(selected_spec.min_samples_leaf), random_state=42).fit(development_pool[feature_names], development_pool['next_eps'])  # 用完整开发期重新拟合
display(candidate_summary.round(4))  # 展示全部九种规格的确定依据
print({'选择结果': {'最大深度': int(selected_spec.max_depth), '叶节点最少样本数': int(selected_spec.min_samples_leaf)}, '并列时的选择': '先选浅树,再选叶节点样本数较大的树'})
{'选择结果': {'最大深度': 3, '叶节点最少样本数': 2}, '并列时的选择': '先选浅树,再选叶节点样本数较大的树'}
表 4: 九种树规格的跨窗均值、最差窗与确定结果
max_depth min_samples_leaf mean_validation_mse worst_validation_mse
0 1 2 2.5800 3.0804
1 1 5 2.5800 3.0804
2 1 10 2.6982 3.1397
3 2 2 1.6240 2.6932
4 2 5 1.8778 2.6630
5 2 10 2.6857 3.2394
6 3 2 1.2906 2.5428
7 3 5 1.9611 2.6630
8 3 10 2.6763 3.2394

一次性最终测试检查:2023 目标年

代码
sealed_rows, sealed_flow = build_eps_year(2023)  # 确定规格与全开发期重训后才构造最终测试标签
sealed_rows = sealed_rows.replace([np.inf, -np.inf], np.nan).dropna(subset=[*feature_names, 'next_eps'])  # 按确定字段清理最终测试样本
assert sealed_rows['target_year'].eq(2023).all() and len(sealed_rows) > 0  # 核对唯一最终测试目标年与非空样本
assert not sealed_rows.duplicated(['order_book_id', 'target_year']).any()  # 核对公司目标年唯一
sealed_predictions = final_tree.predict(sealed_rows[feature_names])  # 生成最终测试预测
sealed_baseline = np.repeat(development_pool['next_eps'].mean(), len(sealed_rows))  # 建立全开发期均值基线
sealed_results = sealed_rows[['order_book_id', 'next_eps']].assign(tree_prediction=sealed_predictions, mean_baseline=sealed_baseline)  # 对齐最终测试真实值与两组预测
sealed_metrics = {'树MSE': mean_squared_error(sealed_rows['next_eps'], sealed_predictions), '训练均值MSE': mean_squared_error(sealed_rows['next_eps'], sealed_baseline)}  # 只在本块计算一次最终测试指标
tree_has_incremental_value = sealed_metrics['树MSE'] < sealed_metrics['训练均值MSE']  # 以同一最终测试窗基线比较决定是否存在增量预测证据
test_conclusion = '值得用新的时间段继续验证' if tree_has_incremental_value else '暂不采用:没有优于训练均值基线'
print({'目标年': 2023, '最大深度': int(selected_spec.max_depth), '叶节点最少样本数': int(selected_spec.min_samples_leaf), **sealed_metrics, '优于基线': tree_has_incremental_value, '结论': test_conclusion})
display(sealed_results.rename(columns={
    'order_book_id': '证券代码', 'next_eps': '实际每股收益',
    'tree_prediction': '决策树预测', 'mean_baseline': '训练均值预测'
}).round(4))
{'目标年': 2023, '最大深度': 3, '叶节点最少样本数': 2, '树MSE': 2.133873079838967, '训练均值MSE': 0.8715059276147957, '优于基线': False, '结论': '暂不采用:没有优于训练均值基线'}
表 5: 所选决策树在 2023 目标年的测试结果
证券代码 实际每股收益 决策树预测 训练均值预测
0 002415.XSHE 1.520 4.3100 1.5893
1 600009.XSHG 0.380 -0.9700 1.5893
2 600019.XSHG 0.540 0.9925 1.5893
3 600104.XSHG 1.226 2.3439 1.5893
4 600276.XSHG 0.680 1.0599 1.5893
5 600406.XSHG 0.900 1.0599 1.5893
6 600585.XSHG 1.970 0.9925 1.5893
7 603259.XSHG 3.270 1.0599 1.5893

最终测试结果决定停止还是继续

动态决策反馈

  • 不采用:若树 MSE 不低于训练均值基线 MSE,则当前确定树没有最终测试期增量价值。
  • 进入评审:树 MSE 更低,且稳定性、成本与治理门槛同时通过。
  • 本次结果:2023 年树 MSE 为 2.1339,基线 MSE 为 0.8715;因此停止实际使用路径。

2023 首个样本的具名规则卡

代码
first_values = sealed_rows.iloc[[0]][feature_names]  # 固定首个最终测试样本
tree_state = final_tree.tree_  # 读取所选决策树结构
node_id = 0  # 从根节点开始追踪规则
rule_card = []  # 收集每个分裂与最终叶节点
while tree_state.children_left[node_id] != tree_state.children_right[node_id]:  # 遍历到叶节点为止
    feature_name = feature_names[tree_state.feature[node_id]]  # 取得当前节点特征名
    threshold = tree_state.threshold[node_id]  # 取得当前节点阈值
    go_left = float(first_values.iloc[0][feature_name]) <= threshold  # 按首个样本决定分支方向
    feature_label = {'log_assets': '总资产对数', 'debt_ratio': '负债率'}[feature_name]
    rule_card.append({'变量': feature_label, '分割值': threshold, '方向': '<=' if go_left else '>', '样本值': float(first_values.iloc[0][feature_name])})
    node_id = tree_state.children_left[node_id] if go_left else tree_state.children_right[node_id]  # 前进到对应子节点
rule_card.append({'变量': '叶节点', '分割值': np.nan, '方向': '预测结果', '样本值': float(tree_state.value[node_id].ravel()[0])})
np.testing.assert_allclose(rule_card[-1]['样本值'], final_tree.predict(first_values)[0])  # 叶节点数值必须等于模型预测
display(pd.DataFrame(rule_card).round(4))  # 输出可检查的逐节点规则卡
表 6: 确定树对首个 2023 样本的逐节点具名路径
变量 分割值 方向 样本值
0 总资产对数 25.4020 > 25.5044
1 总资产对数 26.0973 <= 25.5044
2 负债率 0.2345 > 0.3880
3 叶节点 NaN 预测结果 4.3100

阶段小结

  • 决策树通过一系列分层规则来对数据进行分割,模拟人类决策过程。
  • 回归树使用 RSS 作为分裂标准,预测值为叶节点内样本的均值
  • 分类树使用基尼不纯度信息熵作为分裂标准,预测值为叶节点内样本的众数
  • 决策树非常易于解释,但单个树容易过拟合不稳定
  • 通过剪枝(预剪枝/后剪枝)和设置超参数可以有效控制过拟合。
  • 它们是构建更强大的集成模型的基础。
  • 本章 2023 最终测试结果中,确定树 MSE 为 2.1339、高于训练均值基线 0.8715,因此动态规则输出“暂不采用:无增量预测价值”。

Q & A

有任何问题吗?

本章小结

  • 能核对修订披露、相邻目标年、开发期候选与一次最终测试,并输出具名规则卡。
  • 深度与叶节点规格只由开发窗和预设 tie-break 确定。
  • 单棵树路径只解释模型,不证明因果或未来稳定性。
  • 下一章进入集成学习,比较平均与逐步加法对偏差—方差的条件性影响。