手写决策树三种经典算法:ID3、C4.5、CART原理与实现
2026/9/23 18:55:23 网站建设 项目流程

简介:面向Python与机器学习初学者的决策树算法学习包,涵盖ID3、C4.5、CART三种经典分类/回归树实现,帮助读者理解信息增益、信息增益比、基尼不纯度等核心概念,并掌握基于scikit-learn的建模与预测流程。压缩包内共9个文件,以6个.py源码为主,附带2个.pyc缓存和1份csv鸢尾花数据集,整体仅14KB,轻量易用,适合快速下载与本地运行。目前已有389人学习下载。代码按算法分目录组织,CART与C4.5各含独立脚本,ID3文件夹另配有可视化绘图工具,便于对比三种算法的分裂标准与树结构差异。通过阅读和运行这些脚本,初学者可快速搭建决策树实验环境,结合数据集进行训练与测试,直观感受不同算法的适用场景,为后续调参与模型优化打下基础。

1. 决策树三种经典算法实现:调包调腻了,手写一遍才知道自己在调什么

拿 sklearn 的 DecisionTreeClassifier 调包,一行 fit 一行 predict,树就出来了,但很多人对“信息增益怎么算、特征在哪一刀切、深度设多少”全凭感觉。“决策树三种经典算法实现”这类项目,讲到底就是拿 Python 把 ID3、C4.5、CART 三棵树的构建逻辑从熵算到递归、从分裂到剪枝完整复现一遍。它解决的痛点是:调包调多了之后,树模型对你成了黑匣子,面试被问分裂标准就开始含糊;课程设计、简历项目又需要一个能讲清楚细节的算法实现。适合刚学完机器学习理论、想用代码验证公式的初学者,也适合写过几十个调包模型、想回补底层视角的工程师。对 Python 基础语法熟练的读者可以直接跳到第 3 章,但建议把第 2 章的原理扫一眼,十分钟就能对齐三种算法的关键差异。

2. 三种算法怎么选:信息增益、增益率与基尼指数差在哪

2.1 分裂标准的数学直觉

先统一度量。决策树每一层要回答一个问题:当前节点里的样本已经被分过好几轮了,剩下这些该按哪个特征、哪个切分点再劈一刀?三种算法给出三个不同的答案。

ID3 用信息增益。它先算节点当前的熵,再算按某个特征切出去之后各个子节点熵的加权平均,两者之差就是信息增益。熵越小说明子节点越“纯”,信息增益越大说明这一刀劈得越值。公式层面,熵 Ent(D) = -∑ p_k log₂ p_k,按特征 a 划分后的条件熵是 ∑ |D_v|/|D| Ent(D_v),信息增益就是两者相减。背后有个直觉:我们想知道“在知道特征 a 的取值之后,对样本标签的不确定性减少了多少”,减少得越多,这个特征越值得当根节点。

C4.5 在信息增益基础上除以一个固有值 IV(a)。这个 IV 衡量的是特征 a 自身取值分布的均匀程度,取值个数越多、分布越均匀,IV 越大。这样一除,信息增益对“取值特别多”的特征的天然偏好就被压住了。CART 则换了个角度,直接用基尼指数 Gini(D) = 1 - ∑ p_k²,它度量的是从节点里随机抽两个样本、标签不一致的概率。基尼指数越小,节点越纯,CART 每次选的切分就是让左右子节点基尼加权和最小的那个。

这三个标准没必要背公式,只需要记得:ID3 是“不确定性减得最多的特征优先”,C4.5 是“减得多还要扣掉特征本身取值太多的罚分”,CART 是“两两样本最容易分清的切分优先”。面试和考试都只问这个层面的差异,代码实现再往下落到矩阵运算就行。

2.2 决策树如何逼近真实曲线:启发式搜索为什么够用

“决策树如何逼近真实曲线”这个问题,是所有实现里最容易被忽略的一层。真实的分类边界几乎不可能是矩形块,而决策树天生只能产生垂直于坐标轴的切分面。所谓逼近,是靠不断递归、用越来越小的矩形块去贴合边界。这块做得好不好,直接决定树是“拟合了分布”还是“背下了样本”。

要逼近真实曲线,第一步是处理连续特征。ID3 原本只处理离散值,C4.5 的标准做法是:把一个连续特征的所有取值排序,在相邻两个不同值之间取中点当候选切分阈值,逐个试,选增益率最大的那个点。CART 同理,只不过评估函数换成基尼指数。这个“排序-取中点-逐一试”的操作,就是树模型能逼近真实曲线的关键机制。特征值越密集,候选切分点越多,边界能贴得越细,代价是计算量线性上涨,避坑章节会专门踩到。

第二步要接受贪心。从所有可能的树里找全局最优那棵是 NP 难问题,所以三种经典算法统一采用贪心策略:每一层只保证当前这一个节点的分裂是最优的,不去管这一步会不会把后面的路走死。这套启发式搜索在大多数数据集上表现足够好,但它带来了一个副作用:单棵树在训练数据上贴得越细,越容易把噪声也学进来。这也是随机森林和决策树的区别最直观的体现,集成模型靠多棵树平均来压低单棵树的方差,而单棵决策树想逼近真实曲线,就必须配上剪枝来限制复杂度。

2.3 三种算法的适用边界

选型这部分直接上结论,我做项目时的判断顺序是这样的。数据全是离散特征、只是想快速跑基线,用 ID3 就够了,但心里要清楚它会偏好取值多的特征,比如把“用户 ID”这种列放进去,信息增益直接爆表,树会把 ID 当成第一刀。

特征里混着连续值,或者需要把树做小、做稳,优先 C4.5。它支持连续特征二分、能处理缺失值,还自带后剪枝步骤,代价是实现复杂度高,训练稍慢。CART 则是工程上默认选择,它只生成二叉树,天然适合连续特征切分,同时支持分类和回归,scikit-learn 里 DecisionTreeClassifier 的 criterion 默认就是 gini。做业务模型时我基本只考虑 CART,先把默认跑通,再看变量重要性决定要不要上集成。讲解 DEMO、课程设计、面试手撕代码这种场景,反而建议三种都实现一遍,因为差异就集中在分裂标准那十几行代码上,这是理解树模型一整条知识链的锚点。

算法分裂标准树形连续特征缺失值常用场景
ID3信息增益多叉不支持不支持教学、离散基线
C4.5增益率多叉支持支持解释性优先
CART基尼指数二叉支持有限支持工程默认、集成学习基学习器

选型的另一个维度是解释成本。给非技术方讲 CART 的“某个特征的某个值以下算 A 类”,比讲 ID3 的多叉结构更容易被接受。多叉树一深,分支数量成倍膨胀,展示图片时根本放不下;二叉树可以一直下钻而不失控,这也是工程上更认 CART 的隐形原因。

3. 用 Python 手写三种算法:核心代码拆解与参数说明

3.1 项目结构:三个文件讲清楚一件事

手写决策树的常见做法是拆成三个文件。tree_utils.py 放熵与基尼指数等纯函数;decision_tree.py 放 DecisionTree 类,负责最优分裂搜索、递归建树、预测;demo.py 放数据加载和评估逻辑。这样拆的好处是:想单独验证熵算得对不对,可以直接在 REPL 里 import tree_utils,不必抬头看别的文件。

这一版的实现做了两个简化约定。第一,所有特征统一按连续特征处理,用阈值二分,天然兼容二值特征和多值特征,代价是离散特征的信息增益会打一点折扣;第二,分裂标准只影响内部打分函数,树的结构和建树流程完全公用,这样三种算法的差异被压缩到了十几行的函数里。

# decision_tree.py import numpy as np from tree_utils import entropy, gini class DecisionTree: def __init__(self, criterion="gini", max_depth=None, min_samples_split=2): self.criterion = criterion self.max_depth = max_depth if max_depth is not None else float("inf") self.min_samples_split = min_samples_split self.tree_ = None

max_depth 控制树的最大深度,min_samples_split 控制节点继续分裂所需的最小样本数。这两个参数是预剪枝的主力,调包时你调的 max_depth、min_samples_leaf、ccp_alpha,本质都是在这些递归终止条件上加保险。criterion 这里直接暴露给调用方一个字符串,后面统一靠它切分分裂标准。

3.2 信息熵与基尼指数的实现:纯函数先跑通

熵和基尼的计算不依赖任何模型状态,写成纯函数最合适。它们分别对应 ID3 和 CART 的纯度度量,C4.5 的增益率会在这两个函数之上再组合。

# tree_utils.py import numpy as np def entropy(y): _, counts = np.unique(y, return_counts=True) p = counts / counts.sum() return float(-(p * np.log2(p + 1e-12)).sum()) def gini(y): _, counts = np.unique(y, return_counts=True) p = counts / counts.sum() return float(1 - (p ** 2).sum())

np.unique 的 return_counts=True 直接统计每个类别的样本数,然后归一化成概率。熵的公式里加 1e-12 是为了防止 log2(0),概率为 0 的类别本来不参与计算,但加个小常数更省心。gini 的公式里不需要对数,纯乘加运算,所以 CART 在工程上比 ID3 快一截,数据量上百万之后这个差异非常明显。

这两个函数写完后,先用几个简单数组验证:两类各半时熵等于 1、基尼等于 0.5;单一类别时两者都等于 0。对不上就说明 np.unique 的用法或返回顺序有问题,在进入建树流程之前把这些基础函数钉死,后面所有 bug 才追得下去。

3.3 连续特征的最优切分点搜索:排序加二分

这是“决策树如何逼近真实曲线”落到代码上的核心函数。给定一个特征列,要找到让左右两个子节点纯度提升最大的阈值。所有候选阈值都来自排序后相邻取值的均值,这就是理论章节说的二分策略。

def _best_split(self, X, y, feature_idx, ent_total): sorted_idx = np.argsort(X[:, feature_idx], kind="stable") X_sorted = X[sorted_idx, feature_idx] y_sorted = y[sorted_idx] best_score = float("inf") best_th = None for i in range(len(y_sorted) - 1): if X_sorted[i] == X_sorted[i + 1]: continue th = (X_sorted[i] + X_sorted[i + 1]) / 2.0 mask = X_sorted <= th if mask.all() or not mask.any(): continue score = self._criterion_score(y_sorted, ent_total, mask) if score < best_score: best_score, best_th = score, th return best_score, best_th

先说排序。argsort 加 kind="stable" 保证样本顺序稳定,这是让结果可复现的一个细节,避坑章节会再踩。相邻两个取值相等时直接跳过,避免在同一个值上反复切分,这个优化能把候选切分点从样本数级别降到特征值级别。阈值取相邻两个特征值的均值,是 C4.5 和 CART 共用的标准做法,二值特征只有一个候选点,多值特征自动退化为多阈值搜索。

有一个隐藏 bug 值得注意:mask.all() 的判断。真实数据里常出现某一侧子节点为空的情况,尤其特征取值分布极端时。如果 mask 全为 True,说明阈值切不出左子树,这个切分点没有意义,直接跳过。不做这个检查,递归建树会无限循环或者挂出 None 节点,这手写实现里最容易翻车的位置之一。

提示:阈值搜索循环的次数等于“不同特征值的数量减一”,而不是样本数减一。数据量大时这个优化直接影响训练耗时。

3.4 三种分裂标准切换:让打分函数统一成“越小越好”

三种算法的差异在这里合流。为了让建树代码不用写 if-else 套 if-else,把三种标准都转成“分数越小越好”的统一接口,外部搜索逻辑不用再区分算法。

def _criterion_score(self, y, ent_total, mask): n = len(y) y_left, y_right = y[mask], y[~mask] n_l, n_r = len(y_left), len(y_right) if n_l == 0 or n_r == 0: return float("inf") if self.criterion == "gini": return n_l * gini(y_left) / n + n_r * gini(y_right) / n if self.criterion == "entropy": # ID3 weighted = n_l * entropy(y_left) / n + n_r * entropy(y_right) / n return -(ent_total - weighted) # gain_ratio,对应 C4.5 weighted = n_l * entropy(y_left) / n + n_r * entropy(y_right) / n gain = ent_total - weighted iv = -(n_l / n * np.log2(n_l / n + 1e-12) + n_r / n * np.log2(n_r / n + 1e-12)) return -(gain / (iv + 1e-12))

gini 分支直接返回左右子节点基尼指数的加权平均,越小越纯。entropy 分支先算条件熵 weighted,信息增益等于 ent_total 减去 weighted,因为增益越大越好,取负号之后就可以统一走 min 搜索。gain_ratio 分支多算一个固有值 iv,iv 越小说明特征取值越少,惩罚越轻;iv 为 0 时会导致除零,所以分母加 1e-12。这个细节就是 C4.5 相对 ID3 的核心改动,代码层面只差三行。

调参时注意 criterion 的字符串传参要跟后面建树代码保持一致。以后想扩展回归树,只需要在 _criterion_score 里加一个 variance 分支,其他代码全都不用动。接口统一带来的维护收益,在三种算法都实现一遍之后会体会得很明显。

3.5 递归建树与预测:把分裂过程组织成字典节点

有了分裂搜索和打分函数,建树就变成了一个标准的递归过程。每层选出最优特征和阈值,把样本劈成两半,然后对左右子节点分别做同样的事。

def _build(self, X, y, depth): classes, counts = np.unique(y, return_counts=True) if len(classes) == 1 or depth >= self.max_depth or len(y) < self.min_samples_split: return {"is_leaf": True, "class": classes[np.argmax(counts)]} ent_total = entropy(y) best = None for f in range(X.shape[1]): score, th = self._best_split(X, y, f, ent_total) if best is None or score < best[0]: best = (score, f, th) if best is None or best[0] == float("inf"): return {"is_leaf": True, "class": classes[np.argmax(counts)]} _, f_idx, th = best mask = X[:, f_idx] <= th return { "is_leaf": False, "feature": f_idx, "threshold": th, "left": self._build(X[mask], y[mask], depth + 1), "right": self._build(X[~mask], y[~mask], depth + 1), }

递归终止条件有三个:节点内只有一种类别、到达最大深度、样本数小于最小分裂阈值。这三个条件缺一个都会出问题,只靠纯度终止的话,欠拟合和 RecursionError 会交替出现。best 为 None 或分数为 inf 的情况,说明数据本身没法再分裂,比如所有样本的特征值完全相同,这时候直接生成叶子节点返回,不再向下挖。

树的存储用字典而不是自定义类节点,好处是打印、序列化、可视化都方便,调试时一眼能看穿整棵树的结构。每个节点只存四个字段:是否叶子、类别或分裂特征、阈值、左右子树,预测过程就是在这些字典里递归往下走。

def _predict_one(self, x, node): if node["is_leaf"]: return node["class"] if x[node["feature"]] <= node["threshold"]: return self._predict_one(x, node["left"]) return self._predict_one(x, node["right"]) def predict(self, X): return np.array([self._predict_one(x, self.tree_) for x in X])

predict 里每一行样本都从根节点走到叶子,分类结果就是叶子节点里样本数最多的类别。这个预测逻辑对三种算法完全通用,因为树的结构没有差别,差别只在建树时谁被选成分裂点。到这里,三种算法的最小可用实现就齐了。

4. 决策树分类器跑通全流程:从数据准备到可视化

4.1 数据准备:树的预处理比想象中少

决策树对数据预处理的要求是所有模型里最低的一档,不用标准化、不用归一化、不用处理特征共线性。唯一要确认的是没有全空的列,以及标签确实编码成了从 0 开始的整数。我这里用 sklearn 自带的 wine 数据集,13 个特征、3 个类别、178 个样本,大小正好用来演示三种算法,也够看出过拟合的苗头。

# demo.py from sklearn.datasets import load_wine from sklearn.model_selection import train_test_split from decision_tree import DecisionTree X, y = load_wine(return_X_y=True) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42 ) print(X_train.shape, y_train.shape)

train_test_split 的 random_state 必须固定,否则每次跑出来的训练集不同,三种算法的比较就没有意义。这里固定成 42 只是习惯,只要是常数就行。wine 数据集三层类别比例均衡,直接用默认切分不会出偏斜问题;如果你的数据集类别不均衡,记得给 train_test_split 传 stratify=y。

fit 之前还要确认一件事:X 里有没有 NaN。决策树实现里没有做缺失值处理的简化版本,遇到 NaN 排序会直接乱掉,阈值搜索会越界。检查方式是 np.isnan(X).any(),有就先用 SimpleImputer 填中位数,这一步比调任何参数都重要。

4.2 三种算法的训练与评估:准确率不是唯一指标

fit 之后立刻打印训练集和测试集的准确率,同时输出树的叶子数和深度,这两个指标能立刻暴露过拟合问题。只看测试集准确率会漏掉大量信息,叶子数才是树复杂度的直接体现。

for name, criterion in [("ID3", "entropy"), ("C4.5", "gain_ratio"), ("CART", "gini")]: tree = DecisionTree(criterion=criterion, max_depth=5, min_samples_split=2) tree.fit(X_train, y_train) train_acc = (tree.predict(X_train) == y_train).mean() test_acc = (tree.predict(X_test) == y_test).mean() def count_leaves(node): if node["is_leaf"]: return 1 return count_leaves(node["left"]) + count_leaves(node["right"]) leaves = count_leaves(tree.tree_) print(f"{name:6s} train={train_acc:.3f} test={test_acc:.3f} leaves={leaves}")

max_depth=5 是我在这种中小数据集上比较稳的起点,别一上来就用默认。叶子数比准确率更能反映树有没有学会“背答案”:训练集准确率高、叶子数多到离谱,基本就是过拟合。C4.5 和 CART 的分数计算方式不同,叶子数量会有差异,这属于正常现象,不用纠结谁高谁低。

顺手可以做一棵深树和一棵浅树的对比,把 max_depth 改成 20 再跑一遍,你会看到训练集升到接近 1.0,测试集掉下来,叶子数翻好几倍。这个实验对新手理解“逼近真实曲线”和“过拟合”的关系比任何文档都直观。

4.3 树的可视化:先文本后图形

拿不到图形环境也不要紧,打印树结构是调试阶段最实用的可视化方式。手写树的字典结构很适合文本打印,缩进代表深度,一眼能看到每层选了哪个特征、阈值落在哪。

def print_tree(node, depth=0): if node["is_leaf"]: print(" " * depth + f"-> class {node['class']}") return print(" " * depth + f"[feat {node['feature']} <= {node['threshold']:.2f}]") print_tree(node["left"], depth + 1) print_tree(node["right"], depth + 1)

打印出来的效果是一棵缩进树,每一行是一个分裂点。文本打印的好处是能直接看到第一刀选中了哪个特征、阈值大概在什么范围,这对排查“为什么这个特征没被选上”非常有效。缺点是叶子多了之后没有颜色和宽度信息,看起来比较累。

需要正式图形时,常见的做法是跑一棵 sklearn 的 DecisionTreeClassifier 再画,而不是自己造绘图代码。sklearn 的 plot_tree 输出的是真正的树形图,节点颜色深浅代表类别纯度,适合放进报告和展示页。这段代码直接复制就能用:

from sklearn.tree import DecisionTreeClassifier, plot_tree import matplotlib.pyplot as plt clf = DecisionTreeClassifier(criterion="gini", max_depth=4, random_state=42) clf.fit(X_train, y_train) fig, ax = plt.subplots(figsize=(12, 8)) plot_tree(clf, filled=True, feature_names=[f"f{i}" for i in range(X.shape[1])], ax=ax) fig.savefig("tree.png", dpi=150)

这里用 sklearn 训练只是借用它的图表能力,模型本身跟手写实现没有关联。plot_tree 的 filled=True 让节点按类别着色,max_depth=4 是为了防止画满整棵树导致图片糊成一团。feature_names 传进去会在图上显示特征名,比 f0、f1 这种默认名直观得多。

5. 决策树调参避坑:5 个让分类器翻车的经典问题

手写实现加调参踩坑,我攒了不少血泪经验。下面 5 个问题按出现频率排序,每一条都按“现象 → 原因 → 解决”列清楚,照着对照就能定位大多数训练事故。

5.1 RecursionError 直接崩掉

现象:训练时抛 RecursionError: maximum recursion depth exceeded。特征多、数据量大、深度不限的时候几乎必现。

原因很简单:递归建树的深度受 Python 解释器递归上限约束,默认一般是 1000 层。决策树在特征值极重复的情况下,可能一路劈到很深,甚至出现 999 层还在分裂的情况。

解决:治本是限制 max_depth;治标是脚本开头加 sys.setrecursionlimit(10000)。我调试新算法时会两个都做,先限制深度把口径定住,再调高递归上限让极端情况不至于直接崩。只调高不限制深度,结果通常是树没崩但内存先炸。

5.2 训练集准确率 100%,测试集只有七成

现象:fit 完一看 train_acc 等于 1.0,心里还没高兴完,test_acc 只有 0.7 上下。这个现象在决策树上比在回归模型里更典型。

原因是树把每个训练样本的特征组合都背下来了,叶子节点几乎只含一个样本,天然过拟合。决策树不像线性模型那样自带正则化兜底,树的复杂度完全由深度和叶节点数量控制,不给限制就无限生长。

解决:预剪枝三件套。max_depth 从 3 开始试,min_samples_split 设 5 到 10,min_samples_leaf 设 1 到 5。试完之后对比训练集和测试集准确率的差距,差距缩到 10 个百分点以内,才说明树的复杂度可控。如果差距还是很大,下一步就该想特征筛选而不是继续加深度。

5.3 第一刀永远劈在特征取值最多的那一列

现象:把 ID 列、用户编号这种高基数特征喂进模型,根节点一定是它。第一刀劈完准确率虚高,其实是把每个样本单独劈成了一个小房间,模型完全不可解释。

原因前文说过,信息增益天然偏好多值特征,取值越多条件熵越小,增益越大。这个偏好不是 bug,是 info-gain 的定义本身带来的属性,ID3 里没法根除。

解决:换 C4.5 或 CART。C4.5 用增益率做惩罚,CART 因为只做二分,对多值特征的偏好天然弱。业务里坚持用 ID3,就必须在特征工程阶段手动排除高基数变量。我第一次做用户分群模型时踩过这个坑,最后的树前两层完全不可解释,成了典型的黑匣子,换成 CART 之后才讲清逻辑。

5.4 训练慢到以为进了死循环

现象:几千个样本跑几十秒,十几万样本跑几十分钟。卡点几乎都在 _best_split 的 for 循环里。

原因:候选阈值数量等于排序后相邻不同值的个数,默认实现里接近样本数。样本多、特征多、每层每个特征都要重新排序搜索,计算量是叠加的。

解决:先 np.unique 去重再取相邻均值,循环次数立刻降到不同特征值数减一;再把排序和搜索向量化,用 searchsorted 替代 Python 循环:

unique_vals = np.unique(X_sorted) th_list = (unique_vals[:-1] + unique_vals[1:]) / 2.0 for th in th_list: mask = X_sorted <= th score = self._criterion_score(y_sorted, ent_total, mask) if score < best_score: best_score, best_th = score, th

这行替换掉原来的 for i in range(len(y_sorted) - 1) 主循环,性能差异巨大。注意这样改之后阈值范围缩小到“不同取值之间的中点”,不会改变切分语义,因为重复值之间的中点本来就不会被选中。数据量再大就做分箱,把连续特征切成离散桶,这是工程实践默认接受的路子。

注意:优化后的循环里同样要保留 mask.all() 或 not mask.any() 的防御判断,数据稀疏时仍然可能出现单侧为空。

5.5 同一个数据,两次训练出两棵不同的树

现象:上周跑出来的特征重要性排序,这周跑对不上。代码一行没改,结果却变了,很容易让人怀疑自己动了什么不该动的地方。

原因有两类。一类是数据划分的随机性,train_test_split 没设 random_state;另一类藏在 np.argsort 的默认行为里,默认的快速排序算法不稳定,相同特征值的样本顺序每次可能不一样,阈值候选点不变,但同样分数下选出的切分点可能换了。

解决:train_test_split 固定 random_state;argsort 显式指定 kind="stable";脚本里如果有其他随机数生成器,set 一个全局种子。把这些做齐,跑出来的树才能真正作为结论沉淀下来。我现在的习惯是每个训练脚本第一行固定 np.random.seed(42),从源头掐断可复现隐患。

6. 让决策树逼近真实曲线的三个进阶技巧

第一个技巧是跟 sklearn 对拍。拿同样的数据,让 sklearn 的 DecisionTreeClassifier(criterion="gini") 和手写 CART 各自生成树,打印前两层分裂特征和阈值。两者不完全一致是正常的,sklearn 有默认的叶子节点最少样本数等细节差异;但逐层对比能验证建树流水线没有结构性 bug,这比对拍准确率可靠得多。我手写实现也是靠着跟 sklearn 一棵一棵对,才确认分裂搜索逻辑没有写偏。

第二个技巧是做后剪枝,而不是只靠预剪枝。预剪枝容易欠拟合,后剪枝等树长满再自底向上合并叶子,效果通常更好。调包时用 cost_complexity_pruning_path 拿到 alpha 序列,画测试集准确率随 alpha 变化的曲线,选拐点处的 alpha 重新训练:

from sklearn.tree import DecisionTreeClassifier path = clf.cost_complexity_pruning_path(X_train, y_train) clf_pruned = DecisionTreeClassifier(ccp_alpha=path.ccp_alphas[-3], random_state=42) clf_pruned.fit(X_train, y_train)

ccp_alpha 越大剪枝越狠,准确率曲线通常先升后降,拐点附近就是模型在“逼近真实曲线”和“不过度拟合噪声”之间的平衡点。别直接取最后一个 alpha,那会把树剪成只剩根节点,取倒数第三个是我常用的起点。

第三个技巧是把决策树当特征选择器用。树的 feature_importances_ 是每个特征参与分裂时带来的纯度提升的加权和,排序后只留下 top-k 特征再训练一次,不仅训练更快,有时候准确率还会小涨,因为砍掉了纯噪声特征。配合第 5 章的稳定性修正,特征重要性排序就是一份可以直接写进报告的特征清单。

说点自己的习惯:以前做模型先调参后看数据,后来发现纠结 max_depth=10 还是 12 毫无意义,真正让树“活”起来的永远是对数据分布的理解。现在我的固定动作是盲跑三组对比,先深后浅,再直接看训练集和测试集差距,最后才动剪枝参数。决策树的调参根本不是玄学,把深度、最小分裂样本、随机种子三个值钉死,模型的可复现性和可解释性就都立住了。希望帮到你。

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

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

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

立即咨询