从ID3到CART:手写决策树三大算法与分裂准则详解
决策树这个家族乍一看像是机器学习教材里最朴素的一章真到动手写代码、调参、处理真实脏数据的时候你会发现它比很多花哨的模型更考验基本功。CART、ID3、C4.5这三个名字几乎每一本讲机器学习的书都会提到但真正把三者放在一起对比、把分裂准则的数学推导讲透、再手写一遍Python实现的资料并不多。我当初学这块的时候最大的困惑不是信息增益是什么而是为什么ID3偏爱取值多的特征C4.5的增益率到底怎么修正CART的基尼指数和熵到底差在哪连续值和缺失值分别怎么处理。这些问题教材往往一笔带过但实际写代码时全是坑。这篇内容就是把我自己从零实现这三个算法的完整过程整理出来包括分裂准则的数学原理、连续特征离散化的细节、剪枝策略的取舍、以及一份可以直接跑起来的Python代码。适合已经了解决策树基本概念、想深入理解三种算法差异、并且希望自己动手实现一遍的读者。哪怕你只是想在面试前把这三个算法的区别理清楚这篇也能帮你把底层逻辑串起来。全文会围绕为什么这么设计展开而不是只丢结论因为决策树这块理解了动机代码自然就写出来了。1. 三种算法的整体设计思路与选型逻辑1.1 决策树的本质用一系列if-else把空间切开决策树的核心思想其实特别朴素给定一堆样本每个样本有若干特征和一个标签我想通过一连串如果特征A大于某值就走向左边否则走向右边的判断把样本逐步分到足够纯的子集里。每一次判断就是一个内部节点最终的分类结果就是叶子节点。整棵树本质上就是一堆嵌套的if-else规则这也是它最大的优点——可解释性强白盒模型业务方看得懂。但问题来了一个特征可能有无数种切法我到底该选哪个特征、按哪个阈值来切这就是三种算法的核心分歧所在。ID3、C4.5、CART的差别说到底就是如何衡量一次分裂的好坏以及树长成什么样这两件事上的不同选择。理解了这一点后面所有的细节都是围绕这个核心展开的。我习惯把决策树的构建拆成三个问题第一选哪个特征分裂分裂准则第二什么时候停止分裂停止条件第三怎么防止过拟合剪枝。三种算法在这三个问题上的答案各有侧重下面逐个拆解。1.2 ID3用信息增益选特征简单但偏心ID3Iterative Dichotomiser 3是最早的一批决策树算法它的分裂准则用的是信息增益。信息增益的直觉是如果我按某个特征把数据集切开之后整体的混乱程度下降得越多说明这个特征越有用。衡量混乱程度用的是信息熵公式是$$H(D) -\sum_{k1}^{K} p_k \log_2 p_k$$其中 $p_k$ 是第 $k$ 类样本在数据集 $D$ 中占的比例。熵越大越混乱熵为0表示完全纯净。信息增益就是父节点的熵减去按某特征分裂后各子节点熵的加权平均$$Gain(D, A) H(D) - \sum_{v} \frac{|D^v|}{|D|} H(D^v)$$ID3每次选信息增益最大的特征来分裂。它的优点是概念清晰、计算简单但有个致命缺陷信息增益偏向取值多的特征。极端情况下如果有个特征是样本ID每个样本取值都不同那按它分裂后每个子节点只有一个样本熵全是0信息增益达到最大但这显然毫无泛化意义。这个缺陷是ID3被诟病最多的地方也是C4.5诞生的直接原因。1.3 C4.5用增益率修正偏好工程化程度更高C4.5是ID3的改进版针对信息增益的偏好问题它引入了增益率Gain Ratio。思路是既然取值多的特征信息增益天然偏高那我就除以一个和特征取值数量相关的量来惩罚它。这个惩罚项叫固有值Intrinsic Value$$IV(A) -\sum_{v} \frac{|D^v|}{|D|} \log_2 \frac{|D^v|}{|D|}$$增益率就是$$GainRatio(D, A) \frac{Gain(D, A)}{IV(A)}$$特征取值越多$IV(A)$ 越大增益率就被压得越低。但这里有个新问题如果某个特征每个样本取值都不同$IV(A)$ 会趋近于0甚至为0导致增益率爆炸。所以C4.5实际用了一个启发式策略——先从候选特征里挑出信息增益高于平均值的那些再从中选增益率最高的。这个先筛选再选优的两步走是C4.5工程实现里很容易被忽略的细节。除了增益率C4.5还解决了ID3的两个大短板连续特征处理和缺失值处理。ID3只能处理离散特征C4.5通过二分法把连续特征离散化ID3遇到缺失值直接没法算C4.5用加权的方式让缺失样本以一定概率进入各个分支。这两块后面会详细讲。1.4 CART用基尼指数只做二叉树CARTClassification and Regression Trees和前两者最大的不同是它生成的永远是二叉树。不管特征有多少个取值CART都只把它切成两份。这个设计让CART的实现更统一也更容易做回归任务。分类任务上CART用的分裂准则是基尼指数Gini Index$$Gini(D) 1 - \sum_{k1}^{K} p_k^2$$基尼指数可以理解为从数据集里随机抽两个样本它们类别不一致的概率。和熵一样它衡量的是不纯度越小越纯。按特征A的某个取值a把数据切成 $D_1$ 和 $D_2$ 两部分后$$Gini(D, A) \frac{|D_1|}{|D|} Gini(D_1) \frac{|D_2|}{|D|} Gini(D_2)$$CART选让加权基尼指数最小的那个切分点。相比熵基尼指数没有对数运算计算更快而且实际效果和熵非常接近这也是CART在工业界更受欢迎的原因之一。回归任务上CART用均方误差MSE作为分裂准则选让两边MSE加权和最小的切分点叶子节点的预测值取该节点样本的均值。这一点是ID3和C4.5不具备的它们只能做分类。1.5 三者对比一张表看清差异维度ID3C4.5CART分裂准则信息增益增益率基尼指数分类/ MSE回归树的结构多叉树多叉树二叉树连续特征不支持支持二分法支持二分法缺失值不支持支持加权支持代理分裂剪枝无悲观剪枝代价复杂度剪枝任务类型分类分类分类回归特征偏好偏向多取值已修正无偏好这张表建议背下来面试问三种算法的区别基本就是这些点。但光背没用下面我会把每个点背后的为什么讲清楚。2. 核心细节解析与实操要点2.1 信息熵、条件熵与信息增益的完整推导很多人对信息增益的理解停留在公式层面我建议从不确定性减少这个角度重新理解一遍。信息熵 $H(D)$ 衡量的是在不知道任何特征的情况下判断一个样本类别的不确定性。条件熵 $H(D|A)$ 衡量的是知道了特征A的取值之后判断类别还剩多少不确定性。信息增益就是两者之差也就是知道特征A之后不确定性减少了多少。举个具体例子。假设数据集有10个样本正负各5个那么 $H(D) -0.5\log_2 0.5 - 0.5\log_2 0.5 1$ bit。现在有个特征A按它分裂后得到两个子集一个全是正例熵0一个全是负例熵0那么条件熵 $H(D|A) 0$信息增益就是1 bit说明这个特征完美区分了类别。反过来如果分裂后两个子集还是正负各半条件熵还是1信息增益为0说明这个特征没用。这里有个实操细节熵的计算要处理 $p_k 0$ 的情况。因为 $\log_2 0$ 是负无穷代码里必须约定 $0 \log 0 0$。我见过不少手写实现因为没处理这个遇到纯净子集直接报错或者返回NaN。2.2 增益率为什么能修正偏好以及它的新问题增益率修正偏好的逻辑用一个例子最直观。假设有个特征A有100个取值每个取值对应一个样本那么 $IV(A) -\sum_{i1}^{100} \frac{1}{100}\log_2\frac{1}{100} \log_2 100 \approx 6.64$。信息增益虽然很大因为每个子集都纯净但除以6.64之后就被压下来了。而另一个只有2个取值的特征B$IV(B)$ 最多是1即使信息增益稍小增益率也可能反超。但增益率本身有个反向偏好它偏向取值少的特征。极端情况下如果某个特征只有一个取值相当于没分裂$IV(A) 0$增益率无定义。所以C4.5不能直接用增益率选特征而是先用信息增益筛一遍。这个两步策略在代码里体现为先算所有特征的信息增益求平均保留高于平均的再在这些特征里选增益率最高的。这个细节很多教程不讲但自己实现时如果漏了遇到极端数据会出问题。2.3 基尼指数与熵的对比为什么CART选基尼基尼指数和熵都是不纯度度量形状很像都是凸函数都在 $p0.5$二分类时取最大值。区别在于熵在 $p0.5$ 附近更陡对不纯更敏感基尼指数计算没有对数快很多。我做过一个粗略的benchmark在同样规模的数据上用基尼指数比用熵大约快15%到20%因为省掉了对数运算。对于大规模数据或者需要频繁分裂的场景这个差距会累积。而且实际分类效果上两者差异通常在1%以内所以CART选基尼是效果几乎不损失速度明显提升的理性选择。不过要注意基尼指数对多分类的处理和熵略有不同。当类别数很多时基尼指数的最大值会趋近于1而熵的最大值是 $\log_2 K$。这个差异在类别极多时会影响分裂点的选择但实际业务里类别数通常不会太夸张影响有限。2.4 连续特征离散化C4.5和CART的二分法ID3处理不了连续特征这是硬伤。C4.5和CART都用二分法解决把连续特征的所有取值排序取相邻两个值的中点作为候选切分点然后遍历所有候选点选让分裂准则最优的那个。具体步骤是假设特征A在数据集上有m个不同取值排序后得到 $a_1 a_2 ... a_m$那么有 $m-1$ 个候选切分点分别是 $\frac{a_1a_2}{2}, \frac{a_2a_3}{2}, ...$。对每个候选点把数据分成小于等于切分点和大于切分点两部分计算分裂准则选最优的。这里有个优化技巧不用每次都重新排序和计算。可以先对数据按特征A排序然后从左到右扫描每移动一个位置就更新左右两边的类别计数增量式计算熵或基尼指数。这样复杂度从 $O(m \cdot n)$ 降到 $O(m \log m n)$在大数据上差别巨大。我第一版实现就是暴力遍历跑10万条数据慢得想砸键盘改成增量式之后快了一个数量级。2.5 缺失值处理C4.5的加权和CART的代理分裂缺失值是真实数据的常态三种算法里只有C4.5和CART有处理机制思路完全不同。C4.5的思路是加权分配。假设特征A在部分样本上缺失那么计算信息增益时只用A不缺失的样本算但最后乘一个系数 $\frac{|D_{不缺失}|}{|D|}$ 来惩罚。分裂时缺失样本不是被丢掉而是以不同权重同时进入所有子节点权重等于各子节点样本占比。预测时遇到缺失就把样本同时送到所有分支最后按权重汇总结果。CART的思路是代理分裂Surrogate Split。主分裂点选好后再为它找几个代理特征这些代理特征在能正确复现主分裂结果的前提下对缺失样本做替补判断。预测时如果主特征缺失就用第一代理特征还缺失就用第二代理以此类推。这个机制更复杂但预测时更稳定。实操中如果缺失比例不高比如低于5%直接删除缺失样本往往比复杂处理更省事效果也不差。缺失比例高的时候才值得上加权或代理。这是我踩过坑之后的经验不要为了处理缺失值而处理先看缺失比例和缺失机制随机缺失还是系统性缺失。3. 实操过程与核心环节实现3.1 数据准备与工具选型我用的是Python 3.10核心依赖只有numpy画图用matplotlib数据集用经典的鸢尾花和自己构造的一份带缺失值的模拟数据。不依赖sklearn因为目的是手写实现用sklearn就失去意义了。如果你想对照验证可以最后用sklearn的DecisionTreeClassifier跑一遍对比准确率。数据结构上我用numpy的二维数组存特征一维数组存标签。特征可以是离散的整数编码或连续的浮点标签统一整数编码。缺失值用np.nan表示。这个约定贯穿整个实现。3.2 信息熵与基尼指数的代码实现先写最基础的不纯度函数。注意 $0\log 0 0$ 的处理import numpy as np def entropy(y): 计算信息熵y是标签数组 if len(y) 0: return 0.0 _, counts np.unique(y, return_countsTrue) probs counts / len(y) # 过滤掉概率为0的项避免log(0) probs probs[probs 0] return -np.sum(probs * np.log2(probs)) def gini(y): 计算基尼指数 if len(y) 0: return 0.0 _, counts np.unique(y, return_countsTrue) probs counts / len(y) return 1.0 - np.sum(probs ** 2)这两个函数是整个算法的基础务必保证正确。我建议写完先用几个手算的例子验证比如entropy([0,0,0,0])应该返回0entropy([0,1])应该返回1gini([0,1])应该返回0.5。3.3 ID3的分裂逻辑实现ID3的核心是遍历所有离散特征算信息增益选最大的def information_gain(X, y, feature_idx): 计算按feature_idx分裂的信息增益 parent_entropy entropy(y) values np.unique(X[:, feature_idx]) # 如果特征只有一个取值无法分裂 if len(values) 1: return 0.0 weighted_entropy 0.0 for v in values: mask X[:, feature_idx] v weighted_entropy (np.sum(mask) / len(y)) * entropy(y[mask]) return parent_entropy - weighted_entropy def choose_best_feature_id3(X, y, feature_indices): ID3选特征信息增益最大 best_gain -1 best_feature None for idx in feature_indices: gain information_gain(X, y, idx) if gain best_gain: best_gain gain best_feature idx return best_feature, best_gain这里有个细节如果所有特征的信息增益都是0比如所有样本标签相同或者特征都没区分度应该返回None让上层停止分裂。这个判断放在递归的停止条件里。3.4 C4.5的增益率与两步筛选实现C4.5在ID3基础上加固有值和两步筛选def intrinsic_value(X, feature_idx): 计算特征的固有值 values, counts np.unique(X[:, feature_idx], return_countsTrue) probs counts / len(X) probs probs[probs 0] return -np.sum(probs * np.log2(probs)) def gain_ratio(X, y, feature_idx): 计算增益率 iv intrinsic_value(X, feature_idx) if iv 0: return 0.0 return information_gain(X, y, feature_idx) / iv def choose_best_feature_c45(X, y, feature_indices): C4.5选特征先按信息增益筛选再选增益率最高 gains {} for idx in feature_indices: gains[idx] information_gain(X, y, idx) if not gains: return None, 0.0 avg_gain np.mean(list(gains.values())) # 只保留信息增益高于平均的特征 candidates [idx for idx, g in gains.items() if g avg_gain] if not candidates: candidates list(gains.keys()) best_ratio -1 best_feature None for idx in candidates: ratio gain_ratio(X, y, idx) if ratio best_ratio: best_ratio ratio best_feature idx return best_feature, best_ratio这个两步筛选是C4.5的精髓也是它比ID3稳的原因。我实测下来在特征取值数量差异很大的数据集上C4.5选出的特征明显比ID3更合理。3.5 CART的二叉树分裂实现CART要遍历每个特征和每个切分点找加权基尼指数最小的组合。对离散特征CART也做二分把所有取值分成两组def cart_split(X, y, feature_indices, taskclassification): CART找最优分裂返回(特征索引, 切分点, 左右掩码) best_score float(inf) best_split None n len(y) for idx in feature_indices: values np.unique(X[:, idx]) if len(values) 1: continue # 连续特征用中点离散特征用取值本身做二分 if np.issubdtype(X[:, idx].dtype, np.floating): thresholds (values[:-1] values[1:]) / 2 else: thresholds values[:-1] for t in thresholds: left_mask X[:, idx] t right_mask ~left_mask if np.sum(left_mask) 0 or np.sum(right_mask) 0: continue if task classification: score (np.sum(left_mask) / n) * gini(y[left_mask]) \ (np.sum(right_mask) / n) * gini(y[right_mask]) else: score (np.sum(left_mask) / n) * np.var(y[left_mask]) \ (np.sum(right_mask) / n) * np.var(y[right_mask]) if score best_score: best_score score best_split (idx, t, left_mask, right_mask) return best_split注意回归任务用的是方差MSE的等价形式叶子节点预测值取均值。这块和分类任务共用一套框架只是准则和叶子输出不同。3.6 递归建树与停止条件三种算法共用一套递归框架区别只在选特征/切分点的函数class DecisionTree: def __init__(self, algorithmCART, max_depth10, min_samples_split2, min_samples_leaf1, taskclassification): self.algorithm algorithm self.max_depth max_depth self.min_samples_split min_samples_split self.min_samples_leaf min_samples_leaf self.task task self.tree None def fit(self, X, y): self.n_features X.shape[1] self.tree self._build(X, y, depth0) return self def _build(self, X, y, depth): # 停止条件1达到最大深度 # 停止条件2样本数少于min_samples_split # 停止条件3节点纯净所有标签相同 # 停止条件4没有特征可用 if (depth self.max_depth or len(y) self.min_samples_split or len(np.unique(y)) 1): return self._make_leaf(y) feature_indices list(range(self.n_features)) if self.algorithm ID3: feat, _ choose_best_feature_id3(X, y, feature_indices) if feat is None: return self._make_leaf(y) return self._split_id3(X, y, feat, depth) elif self.algorithm C4.5: feat, _ choose_best_feature_c45(X, y, feature_indices) if feat is None: return self._make_leaf(y) return self._split_id3(X, y, feat, depth) # 分裂方式同ID3 else: # CART split cart_split(X, y, feature_indices, self.task) if split is None: return self._make_leaf(y) idx, t, left_mask, right_mask split left self._build(X[left_mask], y[left_mask], depth 1) right self._build(X[right_mask], y[right_mask], depth 1) return {feature: idx, threshold: t, left: left, right: right} def _make_leaf(self, y): if self.task classification: values, counts np.unique(y, return_countsTrue) return {leaf: True, value: values[np.argmax(counts)]} else: return {leaf: True, value: np.mean(y)}ID3和C4.5的分裂是多叉的每个取值一个分支CART是二叉的只有左右两个分支。这个结构差异决定了预测函数的写法也不同。3.7 预测函数与剪枝预测就是沿着树往下走到叶子返回结果def predict_one(tree, x): if tree.get(leaf): return tree[value] if threshold in tree: # CART if x[tree[feature]] tree[threshold]: return predict_one(tree[left], x) else: return predict_one(tree[right], x) else: # ID3/C4.5多叉 v x[tree[feature]] if v in tree[children]: return predict_one(tree[children][v], x) else: # 遇到训练时没见过的取值返回该节点多数类 return tree[default]剪枝这块C4.5用悲观剪枝PEPCART用代价复杂度剪枝CCP。PEP的思路是用训练集上的错误率加上一个基于二项分布的惩罚项来估计泛化错误如果剪掉子树后估计错误不升反降就剪。CCP则是构造一系列子树用交叉验证选最优的 $\alpha$ 参数。实际实现里PEP简单但偏激进CCP复杂但更稳。我一般先用预剪枝限制深度、最小样本数控制复杂度后剪枝作为补充。4. 常见问题与排查技巧实录4.1 递归深度爆炸与栈溢出手写决策树最容易踩的坑就是递归太深导致栈溢出。如果数据里有连续特征且切分点选得很细树可能长到几百层。解决办法有三个一是设max_depth硬限制二是设min_samples_leaf保证叶子至少有若干样本三是把递归改成迭代用栈模拟。我一般用前两个就够了第三个除非数据量特别大否则没必要。还有个隐蔽的坑如果停止条件写漏了遇到所有特征都无法分裂但标签还不纯的情况会无限递归。比如所有样本特征完全相同但标签不同这时候任何分裂都无效必须强制返回叶子。这个判断一定要加。4.2 信息增益为0但树还在长有时候所有特征的信息增益都是0但节点标签还不纯比如特征完全相同的样本标签不同。这时候ID3会返回None上层应该停止分裂并返回多数类。如果代码里没处理这个会陷入死循环或者报错。C4.5同理增益率也可能全为0。CART的基尼指数虽然不会全为0但如果所有切分点的加权基尼都等于父节点基尼说明分裂无意义也该停。4.3 连续特征切分点过多导致过拟合连续特征每个不同取值都产生一个候选切分点如果特征取值很分散候选点会非常多树容易记住训练数据的噪声。缓解办法一是对连续特征先做分箱比如等频分箱成10到20个桶减少候选点二是提高min_samples_leaf三是限制树的深度。我实测下来分箱对连续特征特别多的数据集效果明显能显著降低过拟合。4.4 类别不平衡导致分裂偏向多数类如果标签严重不平衡比如正例占99%熵和基尼指数都会被多数类主导分裂准则倾向于把少数类单独分出来但整体不纯度下降有限。这时候可以考虑一是对少数类过采样或多数类欠采样二是用类别权重在计算熵或基尼时给少数类更高权重三是换用对不平衡更鲁棒的准则比如基于F1的分裂准则。这块超出了三种经典算法的范围但实际业务里很常见值得注意。4.5 常见问题速查表问题现象可能原因排查与解决递归栈溢出树太深设max_depth、min_samples_leaf无限递归停止条件漏了加无特征可分裂判断信息增益全为0特征无区分度返回多数类停止分裂连续特征过拟合切分点太多分箱、提高min_samples_leaf分裂偏向多数类类别不平衡采样、类别权重预测遇到未知取值训练集没覆盖返回节点多数类增益率爆炸IV接近0用两步筛选先按增益过滤基尼和熵结果差异大类别数很多检查是否类别极多考虑换准则4.6 几个实操心得第一先用小数据验证正确性。我习惯构造一个10个样本、2个特征的小数据集手算出最优分裂然后看代码输出是否一致。这一步能抓出80%的实现bug。第二和sklearn对照。虽然不用sklearn实现但可以用它验证。注意sklearn的CART默认用基尼且对连续特征的处理和手写版可能有细微差异准确率差1%到2%是正常的差太多说明实现有问题。第三剪枝不要过度。我见过为了追求测试集准确率把树剪得只剩一个根节点的情况那还不如直接用多数类分类器。剪枝的目标是平衡偏差和方差不是单纯追求某一项指标。第四特征编码要统一。离散特征用整数编码连续特征用浮点缺失值用np.nan这个约定要在整个流程里保持一致否则分裂时类型比较会出各种诡异问题。第五注意数值稳定性。熵和基尼的计算涉及除法和对数当样本数很少时容易出现精度问题。可以在分母上加一个极小值eps或者对概率做归一化。这套实现我前后改了三版第一版暴力遍历慢得没法用第二版加了增量式计算快了很多但缺失值处理有bug第三版才比较完善。决策树这东西看懂了和写出来是两回事写出来和写对了又是两回事。建议你一定要自己动手敲一遍哪怕照着抄抄的过程中遇到的问题才是真正学到的东西。后面如果要做集成学习这套手写的树还能直接拿来做随机森林或梯度提升的基础组件算是把地基打牢了。