ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

决策树算法ID3、C4.5、CART:从信息增益到基尼系数的选型与实战

决策树算法ID3、C4.5、CART:从信息增益到基尼系数的选型与实战

1. 从"二十个问题"游戏说起:决策树到底在解决什么

决策树这个模型,我第一次接触的时候觉得它特别"土"——不就是一堆if-else堆出来的规则吗?但真正在项目里用过几轮之后才发现,越是看起来简单的东西,越容易在细节上翻车。尤其是当你要在ID3、C4.5、CART这三种经典算法之间做选择的时候,如果只是背几句"ID3用信息增益、C4.5用信息增益率、CART用基尼系数",那基本上等于什么都没学。

决策树的本质,其实就是一个"二十个问题"的游戏。你心里想一个东西,我通过一系列是/否的问题把它猜出来。每问一个问题,我就排除掉一部分可能性。问得好的问题,能一次性排除掉一大半候选;问得差的问题,排除掉的可能只有零星几个。决策树要做的,就是自动从数据里学出"问哪些问题、按什么顺序问"。

这个"问得好不好"的衡量标准,就是三种算法的核心分歧所在。ID3用信息增益,C4.5用信息增益率,CART用基尼系数。这三个指标背后对应着三套不同的价值观,也决定了它们各自适合什么场景、有什么致命缺陷。

这篇文章我打算把三种算法拆开揉碎讲清楚。不是那种教科书式的定义罗列,而是从"为什么需要这个指标""这个指标在实际计算中长什么样""用的时候会在哪里踩坑"这几个角度来展开。如果你正在做特征工程、正在选基线模型、或者正在准备面试被问到"ID3和C4.5的区别",这篇应该能帮你把这条线彻底理清楚。

先给一个全局的对照,让你心里有个谱:

维度ID3C4.5CART
分裂指标信息增益信息增益率基尼系数
树的结构多叉树多叉树二叉树
特征类型离散离散+连续离散+连续
缺失值处理不支持支持支持
剪枝无有(悲观剪枝)有(代价复杂度剪枝)
任务类型分类分类分类+回归

这张表你先扫一眼,后面我会逐行拆开讲每个格子背后的"为什么"。

2. 信息熵:三种算法共同的"祖先"

在讲ID3之前,必须先把信息熵这个东西说透。因为信息增益、信息增益率、基尼系数,本质上都是在信息熵的基础上做变形。你如果对熵的理解只停留在"越乱熵越大"这种模糊层面,后面算增益率的时候一定会卡住。

2.1 熵的物理含义与计算公式

信息熵衡量的是一个集合的"纯度"。假设你有一个数据集,里面只有两类标签:A和B。如果所有样本都是A,那这个集合的熵就是0,因为它纯得不能再纯了,你闭着眼睛猜都是对的。如果A和B各占一半,那熵就达到最大值1(以2为底的情况下),因为你完全猜不准。

公式长这样:

$$H(D) = -\sum_{i=1}^{k} p_i \log_2 p_i$$

其中 $p_i$ 是第 $i$ 类样本在数据集 $D$ 中占的比例,$k$ 是类别总数。

我举个具体的数字让你有感觉。假设一个数据集有10个样本,标签分布是6个正例、4个负例:

$$H(D) = -\frac{6}{10}\log_2\frac{6}{10} - \frac{4}{10}\log_2\frac{4}{10} \approx 0.971$$

如果分布变成8个正例、2个负例:

$$H(D) = -\frac{8}{10}\log_2\frac{8}{10} - \frac{2}{10}\log_2\frac{2}{10} \approx 0.722$$

你看,从6:4变成8:2,熵从0.971降到了0.722。纯度提高了,熵就降低了。这就是熵的核心直觉。

注意:熵的底数取2还是取e还是取10,只影响数值的绝对大小,不影响比较关系。机器学习里默认用2,因为信息论的传统是以比特为单位。你在代码里用scipy.stats.entropy的时候要注意它默认用的是自然对数,算出来的值会和手算的不一样。

2.2 条件熵:知道某个特征之后,不确定性还剩多少

光知道数据集本身的熵没用,我们需要知道"如果按某个特征切一刀,切完之后不确定性降低了多少"。这就引出了条件熵。

条件熵 $H(D|A)$ 的意思是:在已知特征 $A$ 的取值的情况下,数据集 $D$ 还剩多少不确定性。计算公式是:

$$H(D|A) = \sum_{v=1}^{V} \frac{|D^v|}{|D|} H(D^v)$$

其中 $V$ 是特征 $A$ 的取值个数,$D^v$ 是特征 $A$ 取值为 $v$ 的那部分样本子集。

用人话说就是:按特征A把数据分成几堆,每堆各自算熵,然后按每堆的大小加权平均。堆分得越纯,加权平均之后的熵就越低。

我拿一个经典例子走一遍。假设我们要根据"天气"来判断"是否打球",数据集如下:

天气温度湿度是否打球
晴高高否
晴高高否
阴高高是
雨中高是
雨低正常是
雨低正常否
阴低正常是
晴中高否
晴低正常是
雨中正常是
晴中正常是
阴中高是
阴高正常是
雨中高否

总共14个样本,9个"是",5个"否"。根节点的熵:

$$H(D) = -\frac{9}{14}\log_2\frac{9}{14} - \frac{5}{14}\log_2\frac{5}{14} \approx 0.940$$

现在看"天气"这个特征。它有3个取值:晴、阴、雨。

  • 晴天:5个样本,2个是、3个否。熵 = $-\frac{2}{5}\log_2\frac{2}{5} - \frac{3}{5}\log_2\frac{3}{5} \approx 0.971$
  • 阴天:4个样本,4个是、0个否。熵 = 0
  • 雨天:5个样本,3个是、2个否。熵 = $-\frac{3}{5}\log_2\frac{3}{5} - \frac{2}{5}\log_2\frac{2}{5} \approx 0.971$

条件熵:

$$H(D|天气) = \frac{5}{14} \times 0.971 + \frac{4}{14} \times 0 + \frac{5}{14} \times 0.971 \approx 0.694$$

信息增益就是 $0.940 - 0.694 = 0.246$。

这个0.246就是ID3用来选特征的依据。哪个特征的信息增益最大,就选哪个先分裂。

2.3 为什么信息增益会"偏爱"取值多的特征

这里有一个ID3的致命问题,必须提前说清楚,否则你后面理解C4.5的时候会莫名其妙。

假设我们有一个特征叫"样本编号",每个样本的编号都是唯一的。按这个特征分裂,每个子集里只有一个样本,每个子集的熵都是0,条件熵就是0,信息增益就是0.940——最大值。但这个分裂毫无意义,因为它完全没有泛化能力。

这就是信息增益的缺陷:它倾向于选择取值数量多的特征。因为取值越多,每个子集就越小,越容易纯,条件熵就越低,增益就越大。

C4.5就是为了解决这个问题而生的。它引入了"分裂信息"来惩罚取值多的特征,这个后面细讲。

3. ID3的实操逻辑与它的三个硬伤

3.1 ID3的完整计算流程

ID3的算法流程其实很简单,我用伪代码的形式给你梳理一遍:

输入:数据集D,特征集A,阈值epsilon 输出:决策树T 1. 如果D中所有样本属于同一类Ck,则T为单节点树,返回Ck 2. 如果A为空(没有特征可用了),则T为单节点树,返回D中样本数最多的类 3. 否则,计算A中每个特征对D的信息增益 4. 选择信息增益最大的特征Ag 5. 如果Ag的信息增益小于阈值epsilon,则T为单节点树,返回D中样本数最多的类 6. 否则,对Ag的每一个可能取值ai: - 将D分割为若干非空子集Di - 将Di中样本数最多的类作为标记,构建子节点 - 对第i个子节点,以Di为训练集,以A-{Ag}为特征集,递归调用1-5步 7. 返回T

这个流程里有两个地方容易出问题。第一个是第5步的阈值epsilon,实际写代码的时候很多人直接设成0,导致树长得特别深,过拟合严重。第二个是第6步的"非空子集",如果某个取值在训练集里没出现过,但在测试集里出现了,ID3是没法处理的。

3.2 ID3的三个硬伤

硬伤一:只能处理离散特征。如果有一个特征是"身高",取值是连续的浮点数,ID3直接傻眼。你可能会想,那我手动分箱不就行了?可以,但分箱的边界怎么定?分多少个箱?这些都是超参数,调起来很麻烦。C4.5和CART都内置了连续特征的处理机制,不需要你手动分箱。

硬伤二:信息增益偏向多取值特征。前面已经说过了,这是ID3最被诟病的地方。在实际项目中,如果你有一个类似"用户ID"这样的高基数特征,ID3会优先选它,然后整棵树就废了。

硬伤三:没有剪枝机制。ID3会一直分裂到每个叶子节点都纯为止,这在训练集上准确率可以到100%,但在测试集上可能一塌糊涂。剪枝是决策树防止过拟合的核心手段,ID3完全没有。

实操心得:如果你现在还在用ID3做实际项目,我建议直接换C4.5或CART。ID3更适合用来理解决策树的基本原理,不适合上生产。我早期做过一个用户流失预测的项目,用ID3跑出来的树有几百个节点,在验证集上的F1只有0.5出头,换成CART剪枝之后直接涨到0.72。

3.3 一个手算ID3的完整例子

为了让你对ID3的计算过程有肌肉记忆,我把前面那个"打球"数据集完整跑一遍。

数据集有4个特征:天气、温度、湿度。标签是"是否打球"。

已经算过天气的信息增益是0.246。现在算温度和湿度。

温度有3个取值:高、中、低。

  • 高:4个样本,2是2否。熵 = 1.0
  • 中:6个样本,4是2否。熵 = $-\frac{4}{6}\log_2\frac{4}{6} - \frac{2}{6}\log_2\frac{2}{6} \approx 0.918$
  • 低:4个样本,3是1否。熵 = $-\frac{3}{4}\log_2\frac{3}{4} - \frac{1}{4}\log_2\frac{1}{4} \approx 0.811$

条件熵 = $\frac{4}{14} \times 1.0 + \frac{6}{14} \times 0.918 + \frac{4}{14} \times 0.811 \approx 0.892$

信息增益 = $0.940 - 0.892 = 0.048$

湿度有2个取值:高、正常。

  • 高:7个样本,3是4否。熵 = $-\frac{3}{7}\log_2\frac{3}{7} - \frac{4}{7}\log_2\frac{4}{7} \approx 0.985$
  • 正常:7个样本,6是1否。熵 = $-\frac{6}{7}\log_2\frac{6}{7} - \frac{1}{7}\log_2\frac{1}{7} \approx 0.592$

条件熵 = $\frac{7}{14} \times 0.985 + \frac{7}{14} \times 0.592 \approx 0.788$

信息增益 = $0.940 - 0.788 = 0.152$

三个特征的信息增益对比:天气0.246 > 湿度0.152 > 温度0.048。所以根节点选"天气"。

这就是ID3的完整决策过程。你看,计算量其实不大,但每一步都要算熵,特征多了之后计算量会线性增长。

4. C4.5的改进:增益率如何修正ID3的偏差

4.1 分裂信息与增益率的定义

C4.5引入了一个叫"分裂信息"(Split Information)的东西,专门用来惩罚取值多的特征。公式是:

$$SplitInfo(D, A) = -\sum_{v=1}^{V} \frac{|D^v|}{|D|} \log_2 \frac{|D^v|}{|D|}$$

你仔细看这个公式,它其实就是在算"特征A本身的取值分布有多分散"。如果A有10个取值,每个取值的样本数都差不多,那SplitInfo就很大;如果A只有2个取值,SplitInfo就相对小。

增益率的定义就是:

$$GainRatio(D, A) = \frac{Gain(D, A)}{SplitInfo(D, A)}$$

分子是信息增益,分母是分裂信息。取值多的特征,分子可能大,但分母也大,一除就被压下来了。这样就修正了ID3的偏差。

4.2 增益率的计算实例

还是用"打球"数据集。天气这个特征有3个取值,分布是5、4、5。

$$SplitInfo(天气) = -\frac{5}{14}\log_2\frac{5}{14} - \frac{4}{14}\log_2\frac{4}{14} - \frac{5}{14}\log_2\frac{5}{14} \approx 1.577$$

增益率 = $0.246 / 1.577 \approx 0.156$

湿度有2个取值,分布是7、7。

$$SplitInfo(湿度) = -\frac{7}{14}\log_2\frac{7}{14} - \frac{7}{14}\log_2\frac{7}{14} = 1.0$$

增益率 = $0.152 / 1.0 = 0.152$

温度有3个取值,分布是4、6、4。

$$SplitInfo(温度) = -\frac{4}{14}\log_2\frac{4}{14} - \frac{6}{14}\log_2\frac{6}{14} - \frac{4}{14}\log_2\frac{4}{14} \approx 1.557$$

增益率 = $0.048 / 1.557 \approx 0.031$

现在对比一下:

特征信息增益分裂信息增益率
天气0.2461.5770.156
湿度0.1521.0000.152
温度0.0481.5570.031

按增益率排序,天气还是第一,但湿度和天气的差距从0.094缩小到了0.004。如果有一个取值更多的特征,它的增益率会被压得更狠。

4.3 C4.5处理连续特征的方法

C4.5处理连续特征的思路是二分法。假设特征A有n个不同的取值,把它们从小到大排序,取相邻两个值的中点作为候选分割点,一共有n-1个候选点。对每个候选点,把数据分成"小于等于分割点"和"大于分割点"两部分,计算增益率,选增益率最大的那个分割点。

举个例子,假设"温度"这个特征的实际取值是[15, 18, 20, 22, 25, 28, 30],候选分割点就是16.5, 19, 21, 23.5, 26.5, 29。对每个点算一次增益率,选最好的。

这个方法的问题在于计算量大。如果特征有1000个不同的取值,就要算999次。C4.5的优化是先对特征排序,然后只考虑标签发生变化的那些分割点,能减少一部分计算量。

注意:C4.5在选分割点的时候,用的是信息增益率而不是信息增益。但实际实现中,很多开源库会先用信息增益筛出一批候选点,再从中选增益率最大的,这样能进一步降低计算量。

4.4 C4.5的剪枝策略:悲观剪枝

C4.5用的是悲观剪枝(Pessimistic Error Pruning, PEP)。它的核心思想是:用训练集上的误差率加上一个惩罚项来估计泛化误差,如果剪掉某个子树之后估计误差不升反降,就剪掉。

具体做法是:对于一棵子树,假设它有L个叶子节点,训练集上的误分类样本数是E,那么估计误差率是:

$$e = \frac{E + 0.5L}{N}$$

其中N是子树覆盖的样本数,0.5是惩罚项(每个叶子节点加0.5个误差)。如果把这棵子树替换成一个叶子节点,误分类样本数是E',估计误差率是:

$$e' = \frac{E' + 0.5}{N}$$

如果 $e' \leq e$,就剪枝。

悲观剪枝的优点是不需要单独的验证集,用训练集就能剪。缺点是那个0.5的惩罚项是拍脑袋定的,理论上不够严谨。但在实际使用中效果还不错,尤其是数据量不大的时候。

5. CART的二叉树哲学与基尼系数

5.1 为什么CART坚持二叉树

ID3和C4.5生成的都是多叉树,一个特征有几个取值就分几叉。CART不一样,它每次只做二分,不管特征有多少个取值,都只分成两堆。

这个设计选择背后有几个考虑。第一,二叉树在数学上更简洁,每个内部节点只有两个分支,可以用递归的方式统一处理。第二,多叉树在处理取值多的特征时,每个分支的样本数会很少,统计意义不足。第三,二叉树可以更方便地处理连续特征,因为连续特征本来就可以通过一个阈值二分。

但二叉树也有代价。如果一个特征有10个取值,多叉树一次就能分完,二叉树可能需要分好几次。不过CART通过"对特征取值进行组合"的方式,实际上也能达到类似多叉树的效果,只是树的深度会增加。

5.2 基尼系数的计算与直觉

基尼系数的公式是:

$$Gini(D) = 1 - \sum_{i=1}^{k} p_i^2$$

和熵的公式对比一下,熵是 $-\sum p_i \log p_i$,基尼是 $1 - \sum p_i^2$。两者都在衡量纯度,但基尼没有对数运算,计算更快。

还是用6正4负的例子:

$$Gini = 1 - (0.6^2 + 0.4^2) = 1 - (0.36 + 0.16) = 0.48$$

8正2负:

$$Gini = 1 - (0.8^2 + 0.2^2) = 1 - (0.64 + 0.04) = 0.32$$

和熵的变化趋势一致,纯度越高,基尼越小。

基尼系数的直觉解释是:从数据集中随机抽两个样本,它们标签不一致的概率。如果数据集很纯,抽到不同类的概率就低,基尼就小。

CART选择分裂点的依据是基尼指数最小化。对于每个特征,遍历所有可能的分割点,选基尼指数最小的那个。如果是分类任务,基尼指数最小的分裂点就是最优分裂点;如果是回归任务,用的是均方误差最小化。

5.3 CART处理连续特征与缺失值

CART处理连续特征的方式和C4.5类似,也是排序后取相邻中点为候选分割点。但CART只做二分,所以每个候选点只需要算一次基尼指数,比C4.5的增益率计算更快。

缺失值处理方面,CART用的是"代理分裂"(Surrogate Split)。如果某个样本在最优分裂特征上缺失,就找一个和最优特征相关性最高的替代特征来分裂。这个方法比简单填充均值要精细,但计算量也更大。

实操心得:在实际项目中,如果缺失值比例不高(比如低于5%),直接填充中位数或众数就够了,没必要上代理分裂。代理分裂的计算开销在特征多的时候非常可观,我试过一个200维的数据集,开启代理分裂后训练时间从3分钟涨到了20多分钟,但准确率只提升了0.3个百分点。

5.4 CART的代价复杂度剪枝

CART用的是代价复杂度剪枝(Cost Complexity Pruning, CCP),也叫最弱连接剪枝。它的核心是定义一个代价复杂度指标:

$$R_\alpha(T) = R(T) + \alpha |T|$$

其中 $R(T)$ 是树T的训练误差,$|T|$ 是叶子节点数,$\alpha$ 是惩罚系数。$\alpha$ 越大,对树的大小的惩罚越重,树就越简单。

CCP的过程是:从完全生长的树开始,逐步增大 $\alpha$,每次剪掉一个"最不重要"的子树,直到只剩根节点。这样就得到了一系列不同复杂度的树。然后用交叉验证选最优的 $\alpha$,对应的树就是最终模型。

这个方法的优点是理论严谨,有明确的优化目标。缺点是计算量大,需要生成一系列树并逐一评估。不过scikit-learn的DecisionTreeClassifier里已经内置了ccp_alpha参数,你只需要用cost_complexity_pruning_path拿到候选的alpha值,然后交叉验证选一个就行。

6. 三种算法的选型决策与实战踩坑

6.1 什么场景选什么算法

这个问题没有标准答案,但有一些经验性的判断。

如果你的特征全是离散的,取值不多,数据量也不大,ID3够用。但说实话,现在几乎没有理由用ID3了,C4.5在各方面都更好。

如果你的特征里有很多连续特征,或者有缺失值,C4.5是比ID3更合适的选择。C4.5的增益率也能避免高基数特征的干扰。

如果你需要做回归任务,那只能选CART,因为ID3和C4.5都只支持分类。如果你需要概率输出(比如输出样本属于某类的概率),CART也能做,它用叶子节点里各类样本的比例作为概率估计。

如果你追求工程上的简洁和效率,CART是最优选择。它的二叉树结构实现简单,基尼系数计算快,剪枝策略成熟。scikit-learn的决策树实现就是基于CART的。

场景推荐算法理由
纯离散特征、小数据C4.5增益率避免偏差,剪枝防过拟合
混合特征类型CART统一处理连续和离散,二叉树简洁
回归任务CART唯一支持回归的算法
需要概率输出CART叶子节点比例即概率
教学演示ID3原理最简单,便于理解

6.2 踩坑记录:增益率的一个隐藏陷阱

C4.5的增益率虽然修正了ID3的偏差,但它自己也有问题。如果某个特征的分裂信息接近0(也就是某个取值的样本数接近总数),增益率会变得非常大甚至无穷大。C4.5的解决方案是:先选信息增益高于平均值的特征,再从中选增益率最高的。这个启发式规则避免了增益率的不稳定性。

我在一个项目里就踩过这个坑。有一个特征99%的样本都是同一个取值,剩下1%是另一个取值。这个特征的分裂信息非常小,增益率算出来特别大,C4.5就选它做分裂。但分完之后,那个1%的分支只有几个样本,完全没有统计意义。后来我加了一个约束:每个分支的样本数不能少于总样本数的5%,问题才解决。

6.3 剪枝参数怎么调

不管是C4.5的悲观剪枝还是CART的代价复杂度剪枝,实际调参的时候都有几个关键参数。

对于CART,scikit-learn里最常用的剪枝参数是:

  • max_depth:树的最大深度。这个是最直接的防过拟合手段。一般从3开始试,逐步增加到10左右。
  • min_samples_split:内部节点再划分所需的最小样本数。默认是2,建议调到5到20之间。
  • min_samples_leaf:叶子节点最少样本数。默认是1,建议调到2到10之间。
  • ccp_alpha:代价复杂度参数。用cost_complexity_pruning_path拿到候选值,交叉验证选最优。

我的经验是,max_depth和min_samples_leaf这两个参数对最终效果的影响最大。先把这两个调好,再考虑ccp_alpha。

注意:剪枝参数不是越小越好。min_samples_leaf设得太大,树会欠拟合;设得太小,树会过拟合。我一般用网格搜索在验证集上找最优值,范围是[1, 5, 10, 20, 50]。

6.4 特征重要性的计算差异

三种算法计算特征重要性的方式也不一样。ID3和C4.5用的是信息增益(或增益率)的加权和,CART用的是基尼指数的减少量。虽然数值不同,但排序通常差不多。

不过有一个细节要注意:CART的特征重要性是基于"该特征在所有分裂节点上带来的不纯度减少总量"来算的,然后归一化到0-1之间。如果一个特征被用了很多次,它的重要性会被累加。这导致CART倾向于给连续特征更高的重要性,因为连续特征有更多的分裂点。

如果你发现某个连续特征的重要性异常高,可以检查一下是不是因为它的分裂点太多导致的。一个简单的验证方法是:把这个特征离散化之后再跑一遍,看重要性排序有没有变化。

7. 从零实现一个简化版CART

光说不练假把式。我用Python写一个简化版的CART分类树,把基尼系数计算、分裂点选择、递归建树这几个核心环节都实现一遍。代码不长,但能帮你把前面讲的原理串起来。

import numpy as np from collections import Counter class SimpleCART: def __init__(self, max_depth=5, min_samples_leaf=2): self.max_depth = max_depth self.min_samples_leaf = min_samples_leaf self.tree = None def gini(self, y): """计算基尼系数""" if len(y) == 0: return 0 counts = Counter(y) probs = np.array([c / len(y) for c in counts.values()]) return 1 - np.sum(probs ** 2) def split_dataset(self, X, y, feature_idx, threshold): """按特征和阈值二分数据集""" left_mask = X[:, feature_idx] <= threshold right_mask = ~left_mask return (X[left_mask], y[left_mask]), (X[right_mask], y[right_mask]) def find_best_split(self, X, y): """遍历所有特征和候选阈值,找基尼指数最小的分裂点""" best_gini = float('inf') best_feature = None best_threshold = None n_features = X.shape[1] for feature_idx in range(n_features): values = np.unique(X[:, feature_idx]) # 候选阈值取相邻值的中点 thresholds = (values[:-1] + values[1:]) / 2 for threshold in thresholds: (X_left, y_left), (X_right, y_right) = self.split_dataset( X, y, feature_idx, threshold ) # 跳过样本数太少的子集 if len(y_left) < self.min_samples_leaf or len(y_right) < self.min_samples_leaf: continue # 加权基尼指数 n = len(y) weighted_gini = (len(y_left) / n) * self.gini(y_left) + \ (len(y_right) / n) * self.gini(y_right) if weighted_gini < best_gini: best_gini = weighted_gini best_feature = feature_idx best_threshold = threshold return best_feature, best_threshold, best_gini def build_tree(self, X, y, depth=0): """递归建树""" # 终止条件1:所有样本同一类 if len(set(y)) == 1: return {'type': 'leaf', 'class': y[0]} # 终止条件2:达到最大深度 if depth >= self.max_depth: return {'type': 'leaf', 'class': Counter(y).most_common(1)[0][0]} # 终止条件3:样本数太少 if len(y) < 2 * self.min_samples_leaf: return {'type': 'leaf', 'class': Counter(y).most_common(1)[0][0]} # 找最优分裂点 feature_idx, threshold, gini_val = self.find_best_split(X, y) # 如果找不到有效分裂点,返回叶子 if feature_idx is None: return {'type': 'leaf', 'class': Counter(y).most_common(1)[0][0]} # 递归构建左右子树 (X_left, y_left), (X_right, y_right) = self.split_dataset( X, y, feature_idx, threshold ) return { 'type': 'node', 'feature': feature_idx, 'threshold': threshold, 'left': self.build_tree(X_left, y_left, depth + 1), 'right': self.build_tree(X_right, y_right, depth + 1) } def fit(self, X, y): self.tree = self.build_tree(X, y) def predict_one(self, x, node): if node['type'] == 'leaf': return node['class'] if x[node['feature']] <= node['threshold']: return self.predict_one(x, node['left']) else: return self.predict_one(x, node['right']) def predict(self, X): return np.array([self.predict_one(x, self.tree) for x in X])

这段代码里有两个地方值得注意。第一,find_best_split里我加了min_samples_leaf的检查,如果某个子集样本数太少就直接跳过这个分裂点。这是防止过拟合的第一道防线。第二,build_tree里有三个终止条件,任何一个满足就返回叶子节点。这三个条件分别对应"纯节点""达到深度限制""样本太少"。

你可以拿鸢尾花数据集跑一下,看看准确率。我实测下来,max_depth=3的时候准确率大概在0.95左右,max_depth=5能到0.97,再往上就过拟合了。

实操心得:自己实现一遍CART之后,我对min_samples_leaf这个参数的理解深了很多。它不仅仅是防过拟合,还能让叶子节点的概率估计更稳定。如果一个叶子节点只有1个样本,它输出的概率就是0或1,非常极端。如果有10个样本,概率就是0.1的粒度,平滑很多。

8. 三种算法的计算复杂度与工程取舍

最后聊一下工程上的取舍。三种算法的计算复杂度主要取决于两个因素:特征数量和数据量。

ID3和C4.5在每次分裂时,需要对每个特征的每个取值计算信息增益(或增益率)。如果有m个特征,每个特征平均有v个取值,那一次分裂的计算量是O(mv)。建树过程中,每个内部节点都要做一次这样的计算,总复杂度是O(mv * n),其中n是内部节点数。

CART因为只做二分,每个特征只需要考虑O(v)个候选分割点,但每个分割点都要算一次基尼指数。不过基尼指数的计算比信息增益快(没有对数运算),所以实际运行时间CART通常比C4.5快。

在scikit-learn里,决策树的训练时间主要受max_depth和min_samples_split影响。如果你发现训练太慢,优先调小max_depth,因为树的节点数是指数增长的。

算法单次分裂复杂度是否支持连续特征是否支持缺失值剪枝方式
ID3O(mv)否否无
C4.5O(mv log v)是是悲观剪枝
CARTO(mv)是是代价复杂度剪枝

C4.5的O(mv log v)里那个log v来自连续特征的排序。如果全是离散特征,C4.5的复杂度和ID3一样。

在实际项目中,我一般直接用scikit-learn的DecisionTreeClassifier,它底层是CART的优化实现,支持并行计算,速度比自己写的快很多。但理解底层原理还是有必要的,至少当模型效果不好的时候,你知道该调哪个参数、为什么调。

决策树这个模型,入门容易精通难。三种算法的区别看起来只是几个公式的差异,但背后是对"什么是一个好的分裂"这个问题的不同回答。ID3说"能降低最多不确定性的就是好的",C4.5说"不仅要降低不确定性,还要考虑分裂本身的代价",CART说"别整那些虚的,直接最小化不纯度就行"。没有哪个绝对更好,只有哪个更适合你手头的数据和任务。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进