
简介这份资源面向正在学习机器学习基础算法、需要完成课程设计或期末大作业的本科生与自学者核心是用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_{k1}^{|y|} p_k \log_2 p_k$。熵越小纯度越高。用某个特征 $a$ 划分后信息增益是划分前的熵减去划分后各子集熵的加权和$$Gain(D,a) Ent(D) - \sum_{v1}^{V} \frac{|D^v|}{|D|} Ent(D^v)$$问题出在加权和这一项。如果一个特征有 100 个取值每个取值下只有一个样本那么每个子集的熵都是 0加权和也是 0信息增益直接等于原始熵达到最大。这就是 ID3 的致命偏置它会把「身份证号」这种唯一标识特征当成最优分裂点。所以 ID3 只适合取值数量少、且没有唯一标识列的离散特征场景。2.2 C4.5 的增益率把偏置压下去代价是什么C4.5 引入分裂信息 $IV(a) -\sum_{v1}^{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_{k1}^{|y|} p_k^2$ 衡量纯度基尼指数 $Gini_index(D,a) \sum_{v1}^{V} \frac{|D^v|}{|D|} Gini(D^v)$。基尼值比熵计算简单没有对数运算且对噪声的敏感度略低。更重要的是CART 每次只做二分无论特征有多少取值都切成「等于某值」和「不等于某值」两支天然支持连续特征和离散特征统一处理。对比项ID3C4.5CART分裂准则信息增益信息增益率基尼指数树结构多叉树多叉树二叉树连续特征不支持二分法支持二分法支持缺失值不支持权重分配代理分裂任务类型分类分类分类回归剪枝无悲观剪枝代价复杂度剪枝选型建议很直接数据干净、特征全是离散且取值少ID3 够用有连续特征和缺失值、又想要多叉树C4.5工业场景默认 CART因为二叉树实现简单、剪枝成熟、还能做回归。sklearn 的DecisionTreeClassifier默认就是 CARTcriterionentropy只是把基尼换成熵树结构仍然是二叉树。3. 用 Python 从零复现三个算法核心代码与参数说明3.1 数据准备与熵、基尼值的计算函数先写最底层的纯度计算三个算法共用。用 numpy 实现避免循环。import numpy as np def entropy(y): 计算信息熵y 是标签数组 _, counts np.unique(y, return_countsTrue) probs counts / len(y) return -np.sum(probs * np.log2(probs 1e-12)) # 加极小值防 log0 def gini(y): 计算基尼值 _, counts np.unique(y, return_countsTrue) 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, modeid3): 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_splitschoose_best_feature遍历每个特征对每个取值切出索引子集算条件熵和增益。modeid3时直接用增益modec4.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_rightchoose_best_split_cart对每个特征先排序去重取值多于 10 个时按相邻中点生成候选阈值否则直接按值切。w_gini是加权基尼指数越小越纯。best_left和best_right是二分后的索引递归建树时用它们切分。这里用len(values) 10区分连续和离散是个经验阈值实际项目里更稳妥的做法是看特征类型标记而不是靠取值数量猜。3.4 递归建树与剪枝入口有了分裂函数建树就是递归。下面用 CART 举例ID3/C4.5 把分裂函数换掉即可。class TreeNode: def __init__(self, featureNone, threshNone, leftNone, rightNone, valueNone): self.feature feature # 分裂特征索引 self.thresh thresh # 分裂阈值 self.left left # 左子树 self.right right # 右子树 self.value value # 叶子节点的预测值 def build_tree(X, y, depth0, max_depth5, min_samples2): # 停止条件纯节点、样本太少、达到深度 if len(np.unique(y)) 1 or len(y) min_samples or depth max_depth: return TreeNode(valuenp.bincount(y).argmax()) feat, thresh, left, right choose_best_split_cart(X, y) if feat is None: # 无法分裂 return TreeNode(valuenp.bincount(y).argmax()) node TreeNode(featurefeat, threshthresh) 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 nodebuild_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_depth5, min_samples_leaf5)。如果还不行上后剪枝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(criteriongini)比较训练集和测试集准确率。如果差距超过 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_yTrue) X_train, X_test, y_train, y_test train_test_split(X, y, test_size0.3, random_state42) # sklearn CART clf DecisionTreeClassifier(criteriongini, max_depth5, min_samples_leaf2, random_state42) 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_depth5, min_samples2) preds [predict_one(tree, x) for x in X_test] print(手写:, accuracy_score(y_test, preds))这段代码的关键在predict_one从根节点开始按特征和阈值往左或往右走直到叶子节点返回预测值。random_state42保证 sklearn 的随机性可复现手写版没有随机性所以两者差异只来自实现细节。如果准确率差很多先检查choose_best_split_cart里的w_gini计算有没有漏掉权重再检查build_tree的停止条件是否和 sklearn 一致。三个调参习惯是我踩了多次坑之后固定下来的。第一永远先调max_depth从 3 开始往上加每加 1 看验证集准确率涨不动就停。第二min_samples_leaf不要低于 5尤其样本量小于 1000 时低于 5 的叶子基本是噪声。第三分类任务优先用criteriongini它比entropy快效果通常差不多除非你的类别极度不平衡那时可以试试entropy。最后别迷信单棵树决策树的真正价值在于它是随机森林和 GBDT 的基学习器单棵树的准确率上限就在那里把精力放在特征工程和集成上更划算。希望帮到你。本文还有配套的精品资源点击获取