简介:一份基于Python实现的决策树经典算法完整源码包,集成CART、ID3与C4.5三种主流实现,面向需要完成机器学习课程设计、期末大作业的计算机专业学生,也适合初学决策树原理的开发者对照学习。项目曾获导师指导并以97分高分通过评审,代码可直接运行、无需修改,省去从零搭建与调试的时间成本。压缩包内共1个文件,为单一Python脚本(.py),整体仅5KB,文件结构非常精简,便于通读三种算法的核心逻辑。已有391人学习浏览,可作为算法对比实验、课堂报告或个人复习的参考素材。借助这份源码,读者能快速理清ID3基于信息增益、C4.5基于信息增益率、CART基于Gini指数的划分思路,并通过同一份代码直接观察不同准则在建树过程中的差异;后续还能基于现有框架扩展剪枝、可视化及自定义数据集适配,灵活性较高。
1. 决策树CART、ID3、C4.5:这套源码解决什么问题,适合谁读
决策树CART、ID3、C4.5这三个名字,课程作业让你实现、面试官让你对比,真到用Python做分类时第一反应又是sklearn里的DecisionTreeClassifier。标题这套完整源码,价值不在“三棵树本身”,而在把“特征怎么选、树怎么长、怎么剪”彻底读明白。决策树是少数几种从零手写成本很低、收益却极高的算法,核心公式不过三五个,递归循环十几行就能跑通。把这三种树依次实现完,再回头学随机森林、梯度提升,会发现它们全是这棵树的壳。这篇笔记就把分裂准则、递归骨架、真实数据处理的落地写法和调试踩坑点拆开讲,适合课程作业、面试准备和第一次用树模型做分类的新手,也适合想确认自己实现和sklearn差异在哪儿的熟手。
2. 分裂准则先行:信息增益、增益率与基尼系数的计算和选型
三棵树的名字不同,本质区别只有一个:选特征时按什么标准打分。ID3用信息增益,C4.5用信息增益率,CART用基尼指数。这一章先把三个公式写出来、算清楚,后面的树骨架才能共用一套。
2.1 信息熵与ID3的信息增益:先算一个能用手算的例子
信息熵衡量的是“不确定性”。一个集合里类别越均匀,熵越大;全是同一类,熵为0。决策树每次分裂的目标,就是让分裂后的子集熵尽可能小,也就是“信息增益尽可能大”。
import numpy as np def entropy(y): """计算标签集 y 的信息熵,传入一维列表或 numpy 数组""" _, counts = np.unique(y, return_counts=True) probs = counts / len(y) return -np.sum(probs * np.log2(probs + 1e-12))代码逻辑:np.unique把标签去重并统计出现次数,除以总样本数得到每个类别的概率,最后套信息熵公式。加1e-12是防止某类概率为0时log2(0)直接算出-inf,这一点在第5章还会专门讲。
拿一个能用手算的小例子验证:训练集4条样本,标签是“否、否、是、是”。运行entropy(["否","否","是","是"])会得到1.0,因为两类各占一半,熵正好是最大值1。这个数就是后面所有信息增益计算的基准。
def info_gain(X, y, feature_idx): """ID3 的分裂评估:计算某个离散特征的信息增益""" base_entropy = entropy(y) values = np.unique(X[:, feature_idx]) weighted_entropy = 0.0 for v in values: subset_y = y[X[:, feature_idx] == v] weighted_entropy += len(subset_y) / len(y) * entropy(subset_y) return base_entropy - weighted_entropy注意ID3的X[:, feature_idx]必须是离散取值,函数把每个取值单独分成一个分支,最后用子集样本占比加权求和。信息增益越大,说明这个特征把数据切得越“纯”。ID3的局限也在代码里暴露了:它天然偏爱取值多的特征。假如给样本加一个“ID”列,每条样本一个取值,每个子集的熵都是0,信息增益直接拉满,树就会优先选这种毫无泛化能力的特征。
2.2 C4.5的信息增益率:给多取值特征降温
C4.5针对ID3的偏好多取值问题,在信息增益基础上除以一个“固有值”,相当于对取值多的特征做惩罚。特征取值越多,固有值越大,增益率被压得越低。
def gain_ratio(X, y, feature_idx): """C4.5 的分裂评估:信息增益率""" gain = info_gain(X, y, feature_idx) values = np.unique(X[:, feature_idx]) iv = 0.0 for v in values: p = len(X[X[:, feature_idx] == v]) / len(X) iv -= p * np.log2(p + 1e-12) return gain / iv if iv > 0 else 0.0逻辑上看,gain_ratio就是在info_gain外面套了一层除法,除数是特征的固有值。这里有个工程细节:当某个特征取值极为分散时,iv会非常小,增益率被放大到异常大。所以我一般不会裸用增益率,而是像教科书的做法那样:先按信息增益筛出一批候选特征,再在候选里比较增益率,避免数值不稳定把选型带偏。
C4.5相比ID3还有两个重要升级:支持连续特征,支持缺失值。连续特征的处理方式和CART类似,都是“排序后找分裂点”,这一部分放到第4章展开,因为牵扯到阈值搜索的完整代码。
2.3 CART的基尼系数:二分裂让树天然是二叉树
CART的思路和前两者完全不同。它不用熵,用基尼系数;不做多叉,永远只二分。基尼系数衡量的是“从集合里随机抽两个样本,标签不一致的概率”,所以基尼越小,集合越纯。
def gini(y): """计算标签集 y 的基尼系数""" _, counts = np.unique(y, return_counts=True) probs = counts / len(y) return 1.0 - np.sum(probs ** 2) def gini_gain(X, y, feature_idx): """CART 的分裂评估:离散特征做二分,取基尼加权和最小的切法""" best = float("inf") for v in np.unique(X[:, feature_idx]): left_mask = X[:, feature_idx] == v left_gini = gini(y[left_mask]) right_gini = gini(y[~left_mask]) weighted = len(y[left_mask]) / len(y) * left_gini + len(y[~left_mask]) / len(y) * right_gini best = min(best, weighted) return -best这个函数对离散特征的每个取值都试一次“等于v”和“不等于v”的二分,取加权基尼最小的切法。返回值取负号,是为了统一“分数越大越好”的接口,方便后面的统一分裂评估循环。CART这种二分策略让树天然是二叉树,sklearn里的DecisionTreeClassifier就是沿用的这条路线,只是对连续特征额外做了阈值搜索。
CART还有一个多数教材不强调但很重要的能力:同一套框架可以做回归树,只要把叶子节点的值从“众数类”换成“均值”,把分裂评估从基尼换成均方误差即可。这也是热词里“cart回归树”频繁出现的原因。
2.4 三个准则怎么选:对比表与工程建议
| 算法 | 分裂准则 | 树结构 | 连续特征 | 缺失值 | 工程现状 |
|---|---|---|---|---|---|
| ID3 | 信息增益 | 多叉 | 不支持 | 不支持 | 教学演示,工程里基本不用 |
| C4.5 | 信息增益率 | 多叉 | 支持 | 支持(加权) | 经典思想,已被CART取代 |
| CART | 基尼指数 / 最小平方误差 | 二叉 | 支持 | 支持(代理分裂) | sk-learn等主流库默认 |
选型建议很直接:如果只是要用决策树做分类,直接选CART;如果你在交课程作业或准备面试,三个准则都得能手写。工程上很少真的去实现C4.5的多叉结构和缺失值加权逻辑,因为CART的二分结构在剪枝、特征重要性和后续集成时都更方便。
3. 决策树通用骨架:节点定义、递归构建与predict路径
三棵树的差异只在分裂评估函数上,树怎么长、怎么预测是可以完全共用的。先把骨架搭好,后面往里填不同准则就行。
3.1 节点数据结构与叶子值
决策树节点要同时兼容多叉和二叉,我一般把两种结构都放进去,一份源码就能支撑三种算法。
class TreeNode: def __init__(self): self.feature = None # 分裂特征编号 self.threshold = None # 连续特征分裂阈值,只有 CART/C4.5 用 self.value = None # 叶子节点存的类别(分类)或均值(回归) self.children = {} # ID3/C4.5 多叉用,键是特征取值 self.left = None # CART 二叉用 self.right = None # CART 二叉用用普通class而不是dataclass,是为了让Python 3.6以下的老环境也能直接跑,和当年我在课程作业里遇到的解释器版本保持一致。Node里feature为None表示这个节点是叶子,预测时直接返回value;反之就是内部节点,需要继续向下走。
3.2 递归构建树的统一流程
递归构建是决策树的核心,写得越短越不容易出bug。叶子节点的value取训练子集中出现次数最多的类别,用Counter一行搞定。
from collections import Counter def make_leaf(y): """用标签众数创建叶子节点""" node = TreeNode() node.value = Counter(y).most_common(1)[0][0] return node def build_tree(X, y, criterion="gini", max_depth=None, min_samples_split=2, depth=0): # 兜底1:样本太少或深度到限,直接当叶子 if len(X) < min_samples_split or (max_depth is not None and depth >= max_depth): return make_leaf(y) # 兜底2:所有样本已是同一类别,没必要再分 if len(np.unique(y)) == 1: return make_leaf(y) # 调用第2章的分裂评估函数,返回最优特征索引、阈值和分数 feature, threshold, score = choose_best_split(X, y, criterion) # 兜底3:找不到能让分数变好的切法,说明没必要分 if score <= 0: return make_leaf(y) node = TreeNode() node.feature = feature node.threshold = threshold if threshold is None: # 多叉分裂:ID3/C4.5 对离散特征的处理 for v in np.unique(X[:, feature]): mask = X[:, feature] == v node.children[v] = build_tree(X[mask], y[mask], criterion, max_depth, min_samples_split, depth + 1) else: # 二叉分裂:CART 以及连续特征的处理 mask = X[:, feature] <= threshold node.left = build_tree(X[mask], y[mask], criterion, max_depth, min_samples_split, depth + 1) node.right = build_tree(X[~mask], y[~mask], criterion, max_depth, min_samples_split, depth + 1) return node三个兜底条件缺一个就会翻车。缺了第一个,树会一直长到内存耗尽;缺了第二个,遇到纯叶子还在试图选特征;缺了第三个,会出现“分裂后分数没变好还要硬分”的无效节点。choose_best_split的返回值设计成三元组,其中threshold为None表示走多叉,等于统一了三个算法的分裂接口。score取值:ID3/C4.5传信息增益或增益率,CART传负基尼,这样主流程里只需要判断score > 0。
3.3 从树到预测:predict路径与没见过取值的兜底
预测过程是递归的逆向:从根节点往下走,根据当前样本的特征值决定进入哪个分支,直到走到叶子。
def predict_one(node, x): """预测单个样本,x 是特征向量""" if node.feature is None: return node.value if node.threshold is not None: # 二叉路径 if x[node.feature] <= node.threshold: return predict_one(node.left, x) else: return predict_one(node.right, x) # 多叉路径 v = x[node.feature] child = node.children.get(v) if child is None: # 兜底:训练集没见过这个取值,返回当前节点样本最多的类别 return node.value if node.value is not None else 0 return predict_one(child, x)多叉路径里的兜底是我见过最容易漏的边界情况。训练数据的特征取值不可能覆盖测试集全部情况,测试样本一旦带着新取值走到某个内部节点,dict里查不到分支,就会直接崩。我一般把当前节点的多数类也存一份在node.value上,这样遇到新取值还能给出一个有统计依据的预测,而不是报KeyError或返回一个空值。注意这里的x是一维特征向量,下标要和训练时X的列顺序完全一致,否则树就是黑匣子,预测结果毫无意义。
4. 让三棵树处理真实数据:连续特征、缺失值与剪枝的落地写法
入门教材里的ID3只能处理离散特征,但真实数据几乎全是连续值、缺失值,树不处理这两类问题就没法落地。这一章给出三棵树通用的处理方案。
4.1 连续特征阈值搜索:排序后取中点是最稳的起点
ID3不支持连续特征,C4.5和CART都支持,做法相似:把连续特征的所有取值排序,相邻两个值的中间点作为候选阈值,逐个尝试二分。
def find_best_threshold(X, y, feature_idx): """连续特征阈值搜索:排序后取相邻样本的中点""" sorted_idx = np.argsort(X[:, feature_idx]) X_sorted = X[sorted_idx] y_sorted = y[sorted_idx] best_score = -float("inf") best_threshold = None for i in range(1, len(y_sorted)): if X_sorted[i - 1, feature_idx] == X_sorted[i, feature_idx]: continue threshold = (X_sorted[i - 1, feature_idx] + X_sorted[i, feature_idx]) / 2 mask = X[:, feature_idx] <= threshold # 这里根据 criterion 调用第2章的 info_gain / gain_ratio / gini_gain score = calc_split_score(y, y[mask], y[~mask], criterion) if score > best_score: best_score = score best_threshold = threshold return best_threshold阈值取中点的理由是:只要样本按特征值排序,落在两个相邻值之间的任意阈值,切分效果完全等价。取中点既不偏向左边也不偏向右边,是最干净的选择。这里要注意跳过相邻取值相等的情况,否则会把相同取值硬切成两半,制造出不存在的分界。计算复杂度是每轮分裂要排序,O(n log n),数据量大时可以先用np.unique取出分位点再搜,牺牲一点精度换速度。
calc_split_score是一个统一封装的评估入口,内部根据算法类型分别调用第2章的info_gain、gain_ratio或gini_gain。这样做的好处是,连续特征和多叉离散特征可以共用一个主流程,不重复写分裂逻辑。
4.2 缺失值:从教科书策略到工程简化方案
三棵树对缺失值的理论支持各不相同:ID3完全不支持,C4.5用“样本加权”的思路把缺失样本按比例划入每个分支,CART用代理分裂寻找最相似的特征替代。前两者手写成本高,工程上最常见的做法是直接做填充,把决策树的训练变成一个常规流程。
def fill_missing(X): """连续列用均值填充,离散列用众数填充""" X = np.array(X, dtype=float) for col in range(X.shape[1]): vals = X[:, col] if not np.isnan(vals).any(): continue mean_val = np.nanmean(vals) X[np.isnan(vals), col] = mean_val return X这个方案会让缺失样本全部落在均值那一侧,严格来说低估了不确定性,但中小数据集上它是最不翻车的选择。如果项目对缺失值敏感,我一般会再加一列“是否缺失”的指示特征,让树自己学习缺失这个状态有没有预测力,这比强行填充更符合决策树的分裂逻辑。真要复刻C4.5的加权分裂,需要给每条样本维护一个权重,在计算熵和增益时按权重累计,代码量会翻一倍,多数场景不值当。
4.3 剪枝参数:预剪枝与后剪枝的最小实现
剪枝是决策树防过拟合的主要手段,分两类:预剪枝在建树时提前停,后剪枝先把树长满再回头剪。
预剪枝不用单独写代码,在build_tree的参数里控制即可:
| 参数 | 含义 | 典型值 | 说明 |
|---|---|---|---|
| max_depth | 树的最大深度 | 3~8 | 超过直接变叶子,深度越大越容易过拟合 |
| min_samples_split | 内部节点最少样本数 | 5~20 | 样本比这少就不再分裂 |
| min_samples_leaf | 叶子最少样本数 | 1~10 | 分裂后任一子集样本不够就当叶子 |
后剪枝最经典的实现是错误率降低剪枝(REP),自底向上把内部节点替换成叶子,验证集误差不增就保留替换。
def prune(node, X_val, y_val, train_y): """简化版后剪枝:验证集误差不增则把内部节点剪成叶子""" if node.feature is None: return node if node.left is not None: node.left = prune(node.left, X_val, y_val, train_y) if node.right is not None: node.right = prune(node.right, X_val, y_val, train_y) old_error = evaluate_error(node, X_val, y_val) leaf = make_leaf(train_y) if evaluate_error(leaf, X_val, y_val) <= old_error: return leaf return node后剪枝的所有评估都在验证集上做。这里有个关键细节:叶子节点的类别用训练集的众数,而不是验证集的众数,因为验证集只用来评估误差、不参与构建叶子。如果这里用错数据集,剪枝结果会严重失真,这也是第5章专门要讲的一个坑。
5. 决策树实现避坑指南:5个让新手和熟手都翻车的细节
三棵树源码不算长,能跑通不难,跑对才是分水岭。下面5个坑是我见过的翻车现场里出现频率最高的,全部按“现象→原因→解决”的顺序写。
5.1 特征索引错位:分裂时把标签列混进特征列
树长到一半,分裂用的特征突然变成标签列或样本ID,预测准确率直线下降,训练集上却表现正常。
原因是数据准备阶段没有把X和y严格分开。很多人用pandas读取后直接df.values当X,而label列还在里面,choose_best_split遍历所有列时把标签列也当成候选特征。标签列的信息增益天然最大,树第一层就会选它。
解决方法是统一用df.drop("label", axis=1).values取特征,选特征时只遍历range(X.shape[1]),并且在进入build_tree之前加一句断言:assert X.shape[1] == len(feature_names),防止后续传参时列数对不上。
5.2 熵计算里的log 0与除零:nan从哪来、怎么兜底
信息增益计算出一大片nan,树长一层就停止,特征选择结果随机漂移。
原因是np.log2(0)返回-inf,inf参与加法后变成nan;或者某个子集为空,len(subset_y)/len(y)算出的权重是0,再乘熵就出错。
解决方式是所有log运算都加上极小值保护,写成np.log2(probs + 1e-12);计算信息增益时遇到空子集直接跳过;最后在choose_best_split里加判断,score是nan或小于等于0时一律返回不分裂。这个兜底能让树在异常数据下不至于崩溃,只是提前停止。
5.3 连续特征阈值:取中点还是取实际样本值
手写树预测时,测试样本的特征值恰好落在两个训练样本之间,阈值判断结果和sklearn对不上,对着调试半天以为是代码bug。
原因是阈值取法不一致。C4.5原始论文取相邻值中点,sklearn则直接在训练样本的实际取值上测试切分效果,两者在大多数情况下结果接近,但不完全一样。另外,浮点数的中点计算还要考虑精度,比如0.3和0.4的中点实际存为0.35000000000000003,比较时容易被边界样本卡住。
解决方法是统一用中点方案,并接受和sklearn存在微小差异。对照验证时比的是准确率和树深度的数量级,不是逐节点对比阈值。
5.4 剪枝用训练集还是验证集
后剪枝一剪就崩,验证集准确率不升反降,整棵树被剪成只剩一个根节点。
原因是剪枝评估时用了训练集误差。训练集上叶子节点的多数类天然占优,把内部节点替换成叶子几乎不可能让误差变大,于是剪枝算法一路狂剪。
解决方法是严格拆出验证集,剪枝评估和模型选择都只在验证集上进行。常用比例是训练集70%、验证集30%;数据量少的场景用交叉验证,每一折单独做一次剪枝。
5.5 递归上限与空子集兜底
树训练到一半报RecursionError,或者预测时叶子节点的value是None直接报错。
原因有两个:max_depth没设置或设置了但没生效,树递归深度超过Python默认的1000层;分裂时某个子集刚好为空,build_tree对空集算不出多数类,叶子节点value停留在None。
解决方法是build_tree一开始就检查三个终止条件:len(X) == 0时返回一个默认叶子、len(np.unique(y)) == 1时直接返回当前多数类、depth >= max_depth时停止。这三个条件写在递归函数最前面,一行都不能省。
6. 用sklearn做对照验证:手写树写没写对,一比对就知道
6.1 与sklearn对照的验证脚本
手写树最怕的是“能跑但结果是错的”,错误可能藏在特征选择、阈值搜索或者递归切片里。最有效的验证方式是拿同一份数据,和sklearn的DecisionTreeClassifier做对照。先用pip install scikit-learn装好依赖,再跑下面的脚本:
import numpy as np from sklearn.datasets import load_iris from sklearn.tree import DecisionTreeClassifier X, y = load_iris(return_X_y=True) # 自己的树:用第3章的 build_tree 和 predict_one my_tree = build_tree(X, y, criterion="gini", max_depth=4) my_pred = np.array([predict_one(my_tree, x) for x in X]) # sklearn 对照 clf = DecisionTreeClassifier(criterion="gini", max_depth=4, random_state=0) clf.fit(X, y) sk_pred = clf.predict(X) def tree_depth(node): if node.feature is None: return 1 return 1 + max(tree_depth(node.left), tree_depth(node.right)) print("手写树准确率:", np.mean(my_pred == y)) print("sklearn准确率:", np.mean(sk_pred == y)) print("手写树深度:", tree_depth(my_tree), "sklearn深度:", clf.get_depth())判断标准是准确率和深度要在同一量级,例如鸢尾花数据集max_depth=4,两者准确率都应在0.95以上。不需要期望完全一致,因为sklearn的连续特征阈值搜索方式、离散特征二分策略和手写实现存在细微差异,但显著不一致就说明实现里有bug。这时优先检查第5章列出的5类问题,尤其是特征索引和分裂停止条件。
6.2 特征重要性:从自己的树里读出的第一手结论
决策树有一个天然优势:分裂时选哪个特征、每个特征被选了几次,可以直接从树结构里读出来,这是理解模型的第一步。
def feature_importance(node, importances): """统计每个特征被用作分裂节点的次数""" if node.feature is None: return importances[node.feature] += 1 if node.threshold is not None: feature_importance(node.left, importances) feature_importance(node.right, importances) else: for child in node.children.values(): feature_importance(child, importances) return importances importances = feature_importance(my_tree, np.zeros(X.shape[1])) print("特征被选中的次数:", importances)这只是简单的计数版本,真实工程中会按“分裂带来的纯度提升乘以子集样本占比”累加权值,得到更平滑的重要性分布。但计数版在做快速验证时足够好用:如果某个领域知识上明确无关的特征排名第一,说明树在走偏,回头查特征预处理和分裂准则。
6.3 三棵树源码再往前走一步
三棵树跑通之后,往前扩展几乎是无痛的。把叶子节点的value从众数换成均值,再把分裂评估从基尼指数换成均方误差,同一套骨架就变成CART回归树;给build_tree加一个max_features参数,分裂时随机抽一部分特征做候选,就拿到了随机森林的雏形。这正是“完整源码”比调包更有价值的地方——你手里有了一个可以任意改造的基座,而不是一个黑匣子。我自己当年把这三种树写完,最大的收获不是记住了公式,而是后面所有树模型相关的面试题,都能落到“分类看纯度、回归看方差、剪枝看验证集”这同一句话上。希望帮到你,从第二个章节的分裂准则函数开始写吧。
本文还有配套的精品资源,点击获取