☰
决策树算法选型指南:ID3、C4.5、CART 原理与 Python 实现
2026/10/3 10:56:06 网站建设 项目流程

简介:这份资源面向正在学习机器学习基础算法、需要完成课程设计或期末大作业的本科生与自学者,核心是用Python从零实现决策树的三种经典算法:ID3、C4.5与CART。项目已获导师指导并通过,取得97分的高分评价,下载后无需修改即可直接运行,适合作为课程设计提交或算法原理对照练习。压缩包内共1个文件,为单个py源码文件,整体约5KB,代码结构紧凑,便于逐行阅读与调试,能清晰呈现信息增益、增益率与基尼指数在分裂准则上的差异。目前已有391人学习下载,说明该实现方案在同类课程作业中具有一定参考价值。读者可借此掌握决策树递归建树、特征选择与剪枝思路的完整编码流程,并在此基础上扩展至随机森林或集成学习实验,快速完成从理论理解到代码落地的闭环。

1. 从一份决策树源码包说起:ID3、C4.5、CART 到底该怎么选

很多人第一次接触机器学习,是从一份「基于 Python 实现决策树 CART、ID3、C4.5(完整源码)」的压缩包开始的。下载、解压、跑通,看着鸢尾花数据集被切成几片叶子,准确率还挺高,于是觉得决策树不过如此。但真到项目里,问题就来了:为什么我的树在训练集上 100%,测试集上却惨不忍睹?为什么连续特征一多,ID3 直接罢工?为什么同样的数据,CART 跑出来和 C4.5 完全不是一个形状?

这三个算法不是三个可以随便替换的库函数,它们背后是三套不同的分裂准则、不同的特征处理方式和不同的剪枝逻辑。ID3 用信息增益,天生偏爱取值多的特征;C4.5 用信息增益率把这个偏置压下去,还顺手支持了连续值和缺失值;CART 则彻底换成基尼指数,只造二叉树,既能分类也能回归。你选哪个,直接决定了模型对数据的假设、对噪声的容忍度,以及后面要不要剪枝、怎么剪。

这份源码包的价值,不在于它替你实现了三个算法,而在于它把三套逻辑摊开给你看。适合谁?适合已经会用 sklearn 的DecisionTreeClassifier、但被「criterion 参数到底填 gini 还是 entropy」卡住的人;适合想自己手写一遍分裂过程、搞清楚「阈值是怎么选的」的人;也适合做特征工程时想理解「为什么这个特征被树忽略了」的人。接下来我不复述源码,而是按「先立住原理、再动手复现、最后踩坑」的顺序,把这三个算法从公式到代码到调参讲透。

2. 三个算法的分裂准则:信息增益、增益率、基尼指数怎么算

2.1 ID3 的信息增益:为什么它偏爱取值多的特征

ID3 的核心是信息熵。对数据集 $D$,假设第 $k$ 类样本占比为 $p_k$,熵定义为 $Ent(D) = -\sum_{k=1}^{|y|} p_k \log_2 p_k$。熵越小,纯度越高。用某个特征 $a$ 划分后,信息增益是划分前的熵减去划分后各子集熵的加权和:

$$Gain(D,a) = Ent(D) - \sum_{v=1}^{V} \frac{|D^v|}{|D|} Ent(D^v)$$

问题出在加权和这一项。如果一个特征有 100 个取值,每个取值下只有一个样本,那么每个子集的熵都是 0,加权和也是 0,信息增益直接等于原始熵,达到最大。这就是 ID3 的致命偏置:它会把「身份证号」这种唯一标识特征当成最优分裂点。所以 ID3 只适合取值数量少、且没有唯一标识列的离散特征场景。

2.2 C4.5 的增益率:把偏置压下去,代价是什么

C4.5 引入分裂信息 $IV(a) = -\sum_{v=1}^{V} \frac{|D^v|}{|D|} \log_2 \frac{|D^v|}{|D|}$,增益率定义为 $Gain_ratio(D,a) = \frac{Gain(D,a)}{IV(a)}$。取值越多,$IV(a)$ 越大,增益率被除得越小,偏置被压制。但 C4.5 并不是直接选增益率最大的特征,而是先用信息增益筛出高于平均值的候选,再从中选增益率最高的。这个两步走很关键,否则会矫枉过正,偏向取值极少的特征。

C4.5 还解决了两个 ID3 处理不了的问题:连续特征用二分法找阈值,把相邻取值中点作为候选切分点,选信息增益最大的那个;缺失值按权重分配样本,划分时给缺失样本按子集比例分权重。这两点让 C4.5 能直接吃真实数据,但也让计算量上去了。

2.3 CART 的基尼指数:为什么工业界更爱它

CART 用基尼值 $Gini(D) = 1 - \sum_{k=1}^{|y|} p_k^2$ 衡量纯度,基尼指数 $Gini_index(D,a) = \sum_{v=1}^{V} \frac{|D^v|}{|D|} Gini(D^v)$。基尼值比熵计算简单,没有对数运算,且对噪声的敏感度略低。更重要的是,CART 每次只做二分,无论特征有多少取值,都切成「等于某值」和「不等于某值」两支,天然支持连续特征和离散特征统一处理。

对比项ID3C4.5CART
分裂准则信息增益信息增益率基尼指数
树结构多叉树多叉树二叉树
连续特征不支持二分法支持二分法支持
缺失值不支持权重分配代理分裂
任务类型分类分类分类+回归
剪枝无悲观剪枝代价复杂度剪枝

选型建议很直接:数据干净、特征全是离散且取值少,ID3 够用;有连续特征和缺失值、又想要多叉树,C4.5;工业场景默认 CART,因为二叉树实现简单、剪枝成熟、还能做回归。sklearn 的DecisionTreeClassifier默认就是 CART,criterion='entropy'只是把基尼换成熵,树结构仍然是二叉树。

3. 用 Python 从零复现三个算法:核心代码与参数说明

3.1 数据准备与熵、基尼值的计算函数

先写最底层的纯度计算,三个算法共用。用 numpy 实现,避免循环。

import numpy as np def entropy(y): """计算信息熵,y 是标签数组""" _, counts = np.unique(y, return_counts=True) probs = counts / len(y) return -np.sum(probs * np.log2(probs + 1e-12)) # 加极小值防 log0 def gini(y): """计算基尼值""" _, counts = np.unique(y, return_counts=True) probs = counts / len(y) return 1 - np.sum(probs ** 2) def split_info(y, split_indices): """C4.5 的分裂信息 IV(a)""" sizes = np.array([len(idx) for idx in split_indices]) probs = sizes / len(y) return -np.sum(probs * np.log2(probs + 1e-12))

entropy和gini都先做np.unique统计类别频次,再算概率。1e-12是防止某个类别概率为 0 时log2(0)报错。split_info接收划分后的索引列表,算各子集占比的熵,对应 C4.5 公式里的 $IV(a)$。这三个函数是后面所有分裂逻辑的基础,参数只有一个:标签数组或索引列表。

3.2 ID3 与 C4.5 的分裂逻辑:离散特征怎么选

ID3 选信息增益最大的特征,C4.5 先筛再选增益率。下面这段代码把两者放在一起,用mode参数切换。

def choose_best_feature(X, y, mode='id3'): """X: 二维数组,y: 标签。返回最佳特征索引和划分后的索引列表""" base_ent = entropy(y) n_features = X.shape[1] best_gain = -1 best_feature = -1 best_splits = None for i in range(n_features): values = np.unique(X[:, i]) splits = [np.where(X[:, i] == v)[0] for v in values] # 信息增益 cond_ent = sum(len(idx) / len(y) * entropy(y[idx]) for idx in splits) gain = base_ent - cond_ent if mode == 'id3': score = gain else: # c4.5 iv = split_info(y, splits) score = gain / iv if iv > 1e-12 else 0 if score > best_gain: best_gain = score best_feature = i best_splits = splits return best_feature, best_splits

choose_best_feature遍历每个特征,对每个取值切出索引子集,算条件熵和增益。mode='id3'时直接用增益;mode='c4.5'时除以分裂信息。注意 C4.5 严格实现应该先筛增益高于平均的特征,这里为了代码简洁直接比增益率,实际项目里如果发现选了取值极少的特征,就要补上那一步筛选。best_splits返回的是每个取值对应的样本索引,递归时直接用它切数据。

3.3 CART 的二分逻辑:连续特征阈值怎么找

CART 对连续特征的处理是排序后取相邻中点,选基尼指数最小的切分点。离散特征则按「等于某值」二分。

def choose_best_split_cart(X, y): """CART 二分:返回最佳特征、阈值、左右索引""" best_gini = float('inf') best_feat, best_thresh = None, None best_left, best_right = None, None for i in range(X.shape[1]): values = np.sort(np.unique(X[:, i])) # 连续特征:相邻中点作为候选阈值 if len(values) > 10: candidates = (values[:-1] + values[1:]) / 2 else: candidates = values # 离散特征直接按值切 for thresh in candidates: left = np.where(X[:, i] <= thresh)[0] right = np.where(X[:, i] > thresh)[0] if len(left) == 0 or len(right) == 0: continue w_gini = (len(left) / len(y)) * gini(y[left]) + \ (len(right) / len(y)) * gini(y[right]) if w_gini < best_gini: best_gini = w_gini best_feat, best_thresh = i, thresh best_left, best_right = left, right return best_feat, best_thresh, best_left, best_right

choose_best_split_cart对每个特征先排序去重,取值多于 10 个时按相邻中点生成候选阈值,否则直接按值切。w_gini是加权基尼指数,越小越纯。best_left和best_right是二分后的索引,递归建树时用它们切分。这里用len(values) > 10区分连续和离散是个经验阈值,实际项目里更稳妥的做法是看特征类型标记,而不是靠取值数量猜。

3.4 递归建树与剪枝入口

有了分裂函数,建树就是递归。下面用 CART 举例,ID3/C4.5 把分裂函数换掉即可。

class TreeNode: def __init__(self, feature=None, thresh=None, left=None, right=None, value=None): self.feature = feature # 分裂特征索引 self.thresh = thresh # 分裂阈值 self.left = left # 左子树 self.right = right # 右子树 self.value = value # 叶子节点的预测值 def build_tree(X, y, depth=0, max_depth=5, min_samples=2): # 停止条件:纯节点、样本太少、达到深度 if len(np.unique(y)) == 1 or len(y) < min_samples or depth >= max_depth: return TreeNode(value=np.bincount(y).argmax()) feat, thresh, left, right = choose_best_split_cart(X, y) if feat is None: # 无法分裂 return TreeNode(value=np.bincount(y).argmax()) node = TreeNode(feature=feat, thresh=thresh) node.left = build_tree(X[left], y[left], depth + 1, max_depth, min_samples) node.right = build_tree(X[right], y[right], depth + 1, max_depth, min_samples) return node

build_tree的停止条件有三个:标签全同、样本数小于min_samples、深度达到max_depth。叶子节点的预测值用np.bincount(y).argmax()取多数类。max_depth和min_samples是最重要的两个预剪枝参数,前者控制模型复杂度,后者防止过拟合到个别样本。CART 的后剪枝(代价复杂度剪枝)需要先建完整树再自底向上合并,代码量较大,实际项目里用 sklearn 的ccp_alpha参数更省事。

4. 避坑与排查:决策树训练中最容易翻车的 5 个点

4.1 现象:训练集准确率 100%,测试集不到 60%

原因:树长得太深,把训练集里的噪声也学进去了。ID3 和 C4.5 没有内置剪枝时尤其严重,CART 如果不设max_depth也一样。

解决:先设max_depth在 3 到 10 之间试,再配合min_samples_leaf不低于 5。sklearn 里用DecisionTreeClassifier(max_depth=5, min_samples_leaf=5)。如果还不行,上后剪枝,CART 用ccp_alpha从 0 开始逐步增大,观察验证集准确率拐点。

4.2 现象:ID3 选了一个明显无关的特征做根节点

原因:那个特征取值特别多,信息增益被高估。这是 ID3 的固有缺陷,不是代码写错了。

解决:换 C4.5 或 CART。如果必须用 ID3,先做特征筛选,把取值数超过样本数 10% 的特征剔掉,或者对取值多的特征做合并。

4.3 现象:连续特征在 C4.5 里分裂点选得莫名其妙

原因:候选阈值是相邻取值中点,如果特征没排序或排序后没去重,中点会重复甚至错位。

解决:分裂前先np.sort(np.unique(X[:, i])),再取(values[:-1] + values[1:]) / 2。另外注意,C4.5 选阈值时用的是信息增益,不是增益率,别搞混。

4.4 现象:预测时新样本某个特征缺失,直接报错

原因:ID3 不支持缺失值,C4.5 和 CART 的支持方式不同。C4.5 按权重分配,CART 用代理分裂,手写代码时很容易漏掉。

解决:手写版最简单的方式是训练前用中位数或众数填充,别在树里处理。sklearn 的决策树不接受缺失值,必须提前SimpleImputer填充。如果一定要在树里处理,C4.5 的权重分配逻辑要改递归函数,把样本权重传下去。

4.5 现象:同样的数据,CART 和 C4.5 跑出来特征重要性排序完全不同

原因:分裂准则不同,对特征价值的评估就不同。基尼指数和信息增益率不是单调对应的,尤其在特征间有相关性时。

解决:别把单一算法的特征重要性当真理。用随机森林的feature_importances_做平均,或者用 permutation importance 交叉验证。如果两个算法都排在前面的特征,可信度才高。

5. 进阶技巧:用 sklearn 对照验证手写实现,以及三个调参习惯

手写版跑通后,一定要和 sklearn 对照,否则你永远不知道自己的实现有没有隐蔽 bug。做法很简单:同一份数据,同一组max_depth和min_samples_leaf,分别跑手写 CART 和DecisionTreeClassifier(criterion='gini'),比较训练集和测试集准确率。如果差距超过 2 个百分点,大概率是分裂阈值或停止条件写错了。

from sklearn.tree import DecisionTreeClassifier from sklearn.model_selection import train_test_split from sklearn.datasets import load_iris from sklearn.metrics import accuracy_score X, y = load_iris(return_X_y=True) X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42) # sklearn CART clf = DecisionTreeClassifier(criterion='gini', max_depth=5, min_samples_leaf=2, random_state=42) clf.fit(X_train, y_train) print("sklearn:", accuracy_score(y_test, clf.predict(X_test))) # 手写 CART 预测需要自己写 predict 函数,遍历树到叶子 def predict_one(node, x): if node.value is not None: return node.value if x[node.feature] <= node.thresh: return predict_one(node.left, x) return predict_one(node.right, x) tree = build_tree(X_train, y_train, max_depth=5, min_samples=2) preds = [predict_one(tree, x) for x in X_test] print("手写:", accuracy_score(y_test, preds))

这段代码的关键在predict_one:从根节点开始,按特征和阈值往左或往右走,直到叶子节点返回预测值。random_state=42保证 sklearn 的随机性可复现,手写版没有随机性,所以两者差异只来自实现细节。如果准确率差很多,先检查choose_best_split_cart里的w_gini计算有没有漏掉权重,再检查build_tree的停止条件是否和 sklearn 一致。

三个调参习惯,是我踩了多次坑之后固定下来的。第一,永远先调max_depth,从 3 开始往上加,每加 1 看验证集准确率,涨不动就停。第二,min_samples_leaf不要低于 5,尤其样本量小于 1000 时,低于 5 的叶子基本是噪声。第三,分类任务优先用criterion='gini',它比entropy快,效果通常差不多,除非你的类别极度不平衡,那时可以试试entropy。最后,别迷信单棵树,决策树的真正价值在于它是随机森林和 GBDT 的基学习器,单棵树的准确率上限就在那里,把精力放在特征工程和集成上更划算。

希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询