决策树这个算法,我最早接触是在做一个用户流失预测的小项目上。当时手头有一批用户行为数据,领导要求给出一个"能解释清楚为什么判断这个用户会流失"的模型。试过逻辑回归,系数解释起来太绕;试过KNN,根本说不清哪些特征在起作用。最后选了决策树,原因很简单——它把判断逻辑直接摊开给你看,像一棵倒着长的树,每个分叉点都在回答"这个特征重不重要、重要到什么程度"。这就是决策树最核心的价值:可解释性。而ID3、C4.5、CART这三个经典算法,恰好代表了决策树从"能跑"到"跑得好"再到"什么都能跑"的三个阶段。这篇文章我会把这三个算法的构建原理拆开讲透,包括它们各自用什么指标选特征、为什么这么选、实际写代码时哪里容易踩坑,以及我这些年用下来的一些经验判断。
1. 决策树到底在解决一个什么问题
1.1 从"二十问"游戏理解决策树的本质
决策树的思路其实特别朴素,你小时候可能玩过一个游戏:我心里想一个东西,你问我问题,我只能回答"是"或"不是",看你能不能在二十个问题内猜出来。聪明的问法不是"它是苹果吗",而是"它是水果吗"——因为后者能把可能性空间砍掉一大半。
决策树干的就是这件事。它把特征空间一层层切分,每次切分都试图让切出来的子集"纯度"更高。所谓纯度,就是子集里的样本尽可能属于同一个类别。切到某个节点时,如果里面的样本已经全是同一类了,就不用再切了,这个节点变成叶子节点,它的类别就是预测结果。
这个过程的数学化表达就是:给定训练集D,特征集A,递归地选择一个最优划分特征,把D分成若干子集,对每个子集重复这个过程,直到满足停止条件。听起来简单,但"最优划分特征"怎么定义,就是ID3、C4.5、CART三个算法的分水岭。
1.2 为什么不用"准确率"直接选特征
有人可能会想,那我直接试每个特征,看哪个划分后子集准确率最高不就行了?这个想法有两个致命问题。
第一,准确率对多路划分有偏好。假设有个特征叫"用户ID",每个ID对应一个样本,按它划分后每个子集只有一个样本,纯度100%,准确率满分。但这棵树毫无泛化能力,纯粹是记住了训练数据。这就是过拟合的典型表现。
第二,准确率不是凸函数,优化起来不友好。我们需要一个能衡量"不确定性"的指标,不确定性越小说明纯度越高。信息熵和基尼指数就是干这个的。信息熵来自信息论,基尼指数来自概率论,两者本质都在度量"随机抽两个样本,类别不一致的概率"。
这里有个常见误解:很多人以为决策树选特征就是选"最重要的特征"。实际上它选的是"当前节点上最能降低不确定性的特征",这个特征在树的全局重要性排名里可能并不靠前,因为它的作用可能被上层节点"抢"走了。
1.3 三个算法的核心差异一览
在深入细节之前,先用一张表把ID3、C4.5、CART的核心区别摆出来,后面再逐个拆解。
| 维度 | ID3 | C4.5 | CART |
|---|---|---|---|
| 特征选择指标 | 信息增益 | 信息增益率 | 基尼指数 |
| 特征类型 | 离散 | 离散+连续 | 离散+连续 |
| 树的形态 | 多叉树 | 多叉树 | 二叉树 |
| 缺失值处理 | 不支持 | 支持 | 支持 |
| 剪枝策略 | 无(易过拟合) | 悲观剪枝 | 代价复杂度剪枝 |
| 任务类型 | 分类 | 分类 | 分类+回归 |
这张表建议先记住,后面每个算法我会展开讲为什么会有这些差异,以及这些差异在实际使用中意味着什么。
2. ID3:用信息增益选特征的"开山之作"
2.1 信息熵:不确定性的度量尺
ID3的核心是信息增益,而信息增益建立在信息熵之上。信息熵的公式是:
$$H(D) = -\sum_{k=1}^{K} p_k \log_2 p_k$$
其中$p_k$是第k类样本在数据集D中的比例。这个公式在干什么?它在算"把D中样本的类别当作随机变量,它的不确定性有多大"。
举个具体例子。假设D有10个样本,5个正类5个负类,那么$p_正=0.5$,$p_负=0.5$,$H(D) = -(0.5 \times \log_2 0.5 + 0.5 \times \log_2 0.5) = 1$比特。如果10个样本全是正类,$p_正=1$,$H(D) = -(1 \times \log_2 1) = 0$比特。熵为0意味着完全确定,熵为1意味着最不确定(二分类情况下)。
我习惯用一个类比来解释:熵就像一锅汤的"混乱程度"。所有料都均匀混在一起,混乱度最高;如果料分层了,每层都是同一种,混乱度就低。决策树要做的就是不断"分层",让每个子集尽量纯。
2.2 信息增益的计算过程与偏好陷阱
信息增益的定义是:划分前的熵减去划分后的条件熵。
$$Gain(D, a) = H(D) - \sum_{v=1}^{V} \frac{|D^v|}{|D|} H(D^v)$$
其中$a$是特征,$V$是$a$的取值个数,$D^v$是$a$取第v个值的样本子集。
我拿一个具体数据走一遍。假设有6个样本,特征"天气"取值为晴、阴、雨,标签是"是否出门"。
| 样本 | 天气 | 是否出门 |
|---|---|---|
| 1 | 晴 | 是 |
| 2 | 晴 | 是 |
| 3 | 阴 | 否 |
| 4 | 雨 | 否 |
| 5 | 雨 | 否 |
| 6 | 阴 | 是 |
划分前:3个"是",3个"否",$H(D) = 1$。
按天气划分:
- 晴:2个样本,全是"是",$H = 0$
- 阴:2个样本,1是1否,$H = 1$
- 雨:2个样本,全是"否",$H = 0$
条件熵 = $\frac{2}{6} \times 0 + \frac{2}{6} \times 1 + \frac{2}{6} \times 0 = \frac{1}{3}$
信息增益 = $1 - \frac{1}{3} = \frac{2}{3} \approx 0.667$
现在问题来了。如果还有一个特征叫"样本编号",取值1到6,每个值对应一个样本。按它划分,每个子集只有一个样本,熵全是0,条件熵为0,信息增益 = 1。比天气的0.667大,ID3会选样本编号。但这显然是个垃圾特征。
这就是信息增益的偏好陷阱:它偏好取值多的特征。因为取值越多,划分越细,每个子集越纯,条件熵越低,增益越大。C4.5就是为了解决这个问题而生的。
2.3 ID3的代码实现与实操注意
用Python手写一个简化版ID3,核心逻辑如下:
import numpy as np from collections import Counter def entropy(y): counts = Counter(y) probs = [c / len(y) for c in counts.values()] return -sum(p * np.log2(p) for p in probs if p > 0) def information_gain(X, y, feature_idx): base_entropy = entropy(y) values = np.unique(X[:, feature_idx]) cond_entropy = 0 for v in values: mask = X[:, feature_idx] == v subset_y = y[mask] cond_entropy += (len(subset_y) / len(y)) * entropy(subset_y) return base_entropy - cond_entropy def id3(X, y, features): # 停止条件1:全同一类 if len(set(y)) == 1: return y[0] # 停止条件2:特征用完 if len(features) == 0: return Counter(y).most_common(1)[0][0] gains = [information_gain(X, y, f) for f in features] best_f = features[np.argmax(gains)] tree = {best_f: {}} remaining = [f for f in features if f != best_f] for v in np.unique(X[:, best_f]): mask = X[:, best_f] == v tree[best_f][v] = id3(X[mask], y[mask], remaining) return tree这段代码有几个实操中容易忽略的点。第一,entropy函数里加了if p > 0的判断,因为$0 \times \log 0$在数学上定义为0,但代码里会报错。第二,停止条件2返回多数类,这是处理特征用完但样本还没分纯的情况,实际工程中还会加一个"节点样本数小于阈值"的停止条件。第三,递归时remaining去掉了已用特征,这隐含了一个假设:每个特征在一条路径上只用一次。这个假设在ID3里成立,但在某些变体中会被打破。
实操心得:ID3对连续特征无能为力,如果你的数据里有年龄、收入这类连续值,必须先手动分箱。分箱的粒度很讲究,太粗丢失信息,太细又容易过拟合。我一般先用等频分箱切成5到10箱,再根据业务含义微调。
3. C4.5:用信息增益率修正ID3的偏好
3.1 信息增益率怎么算,为什么能修正偏好
C4.5引入了一个叫"分裂信息"的量,也叫固有值:
$$SplitInfo(D, a) = -\sum_{v=1}^{V} \frac{|D^v|}{|D|} \log_2 \frac{|D^v|}{|D|}$$
这个量衡量的是特征$a$本身的取值分布有多分散。取值越多、越均匀,分裂信息越大。然后信息增益率就是:
$$GainRatio(D, a) = \frac{Gain(D, a)}{SplitInfo(D, a)}$$
回到刚才"样本编号"的例子。样本编号有6个取值,每个取值1个样本,分裂信息 = $-\sum_{v=1}^{6} \frac{1}{6} \log_2 \frac{1}{6} = \log_2 6 \approx 2.585$。信息增益是1,增益率 = $1 / 2.585 \approx 0.387$。
天气的分裂信息:晴2个、阴2个、雨2个,$-\left(\frac{2}{6}\log_2\frac{2}{6} \times 3\right) = \log_2 3 \approx 1.585$。增益率 = $0.667 / 1.585 \approx 0.421$。
现在天气的增益率反而比样本编号高了,偏好被修正了。
3.2 增益率也有坑:偏好取值少的特征
但增益率不是完美的。如果某个特征只有一个取值,分裂信息为0,增益率会变成无穷大(或者未定义)。所以C4.5的实际做法是:先从候选特征中选出信息增益高于平均值的特征,再从中选增益率最高的。这是一个两阶段筛选,避免了增益率对取值少特征的极端偏好。
这个细节很多教程一笔带过,但实际写代码时如果漏掉,会出现除零错误或者选出无意义的单值特征。我在早期实现时就踩过这个坑,当时数据里有个特征所有样本取值都一样,结果程序直接崩了。
3.3 C4.5对连续特征和缺失值的处理
C4.5相比ID3最大的工程改进是支持连续特征和缺失值。
连续特征处理用的是二分法。假设特征$a$在D上有m个不同取值,排序后得到${a^1, a^2, ..., a^m}$。取相邻值的中点$\frac{a^i + a^{i+1}}{2}$作为划分点,把D分成$\leq t$和$> t$两部分。对每个候选划分点算信息增益率,选最好的。这里有个优化:如果相邻两个样本类别相同,它们之间的划分点可以跳过,因为不会带来纯度提升。
缺失值处理分两个层面。一是在选划分特征时,只用在特征$a$上没有缺失的样本子集$\tilde{D}$来计算增益,最后乘以一个系数$\frac{|\tilde{D}|}{|D|}$来惩罚缺失多的特征。二是划分样本时,如果样本在特征$a$上缺失,就把它同时分配到所有子节点,但带一个权重,权重等于各子节点样本比例。
def gain_ratio_with_missing(X, y, feature_idx, missing_mask): # 只用非缺失样本计算增益 valid = ~missing_mask[:, feature_idx] X_valid, y_valid = X[valid], y[valid] if len(y_valid) == 0: return 0 gain = information_gain(X_valid, y_valid, feature_idx) # 惩罚系数 penalty = len(y_valid) / len(y) # 分裂信息 values, counts = np.unique(X_valid[:, feature_idx], return_counts=True) probs = counts / len(y_valid) split_info = -sum(p * np.log2(p) for p in probs if p > 0) if split_info == 0: return 0 return (gain / split_info) * penalty注意:缺失值处理会让样本在树中"分裂"成多份,训练完后每个样本可能落在多个叶子节点上。预测时如果遇到缺失值,也是按权重加权各叶子的输出。这个机制在sklearn的决策树里没有完全复现,sklearn要求先自己填充缺失值。
3.4 悲观剪枝:C4.5的过拟合对策
决策树不剪枝几乎必然过拟合。C4.5用的是悲观剪枝(Pessimistic Error Pruning, PEP)。它的思路是:对每个子树,比较剪枝前后在训练集上的错误率,如果剪枝后错误率没有显著上升,就剪掉。
具体做法是给每个叶子节点的错误数加0.5的惩罚(连续修正),然后计算子树的错误率上界。如果子树根节点的错误率上界小于等于子树的加权错误率,就剪枝。这个"悲观"体现在它假设训练集上的错误率会比真实错误率低,所以加了惩罚项。
PEP的优点是只需要训练集,不需要单独的验证集,在数据量少的时候很实用。缺点是偏向于剪掉那些叶子节点多的子树,可能剪过头。我个人的经验是,如果数据量够,还是用验证集做剪枝更稳。
4. CART:基尼指数与二叉树的组合拳
4.1 基尼指数为什么比熵计算更快
CART用基尼指数替代信息熵:
$$Gini(D) = 1 - \sum_{k=1}^{K} p_k^2$$
基尼指数可以理解为"从D中随机抽两个样本,类别不一致的概率"。它的计算比熵简单,因为不需要算对数。在特征选择时,CART算的是划分后的加权基尼指数:
$$Gini_index(D, a) = \sum_{v=1}^{V} \frac{|D^v|}{|D|} Gini(D^v)$$
选使这个值最小的特征。
为什么基尼指数比熵快?熵要算$\log_2$,基尼只要算平方。在大规模数据上,这个差异会累积。有研究表明,基尼指数和熵在大多数情况下选出的特征是一致的,只在少数边界情况下有差异。所以sklearn默认用基尼指数,不是没有道理的。
4.2 CART为什么坚持二叉树
ID3和C4.5生成的是多叉树,一个特征有几个取值就分几叉。CART不管特征有几个取值,只做二分。对离散特征,它会把取值分成两组,穷举所有二分组合选最优的。对连续特征,它选一个切分点$t$,分成$\leq t$和$> t$。
二叉树的好处有三个。第一,避免多叉树带来的碎片化。多叉树在取值多的特征上会把数据切得很碎,每个子节点样本少,统计不可靠。第二,二叉树等价于递归地做二分类,结构更规整,剪枝和后续处理更方便。第三,对离散特征的有序性不敏感。多叉树把离散取值当作无序的,但CART的二分可以捕捉到"取值A和B一组,C和D一组"这种组合信息。
代价是计算量。对V个取值的离散特征,CART要穷举$2^{V-1} - 1$种二分组合。V大了会爆炸。所以实际实现里,对离散特征通常限制取值数,或者用启发式搜索代替穷举。
4.3 CART回归树:用方差代替基尼
CART不仅能分类,还能回归。回归树的叶子输出不是类别,而是该节点样本的均值。特征选择的指标从基尼指数变成均方误差(MSE):
$$MSE(D, a) = \sum_{v=1}^{2} \frac{|D^v|}{|D|} \text{Var}(D^v)$$
选使MSE最小的划分。预测时,新样本落到哪个叶子,就输出那个叶子的均值。
这里有个实操细节:回归树对异常值敏感,因为均值会被极端值拉偏。如果数据里有异常值,可以考虑用中位数代替均值,或者先做异常值处理。我在一个房价预测项目里就遇到过,几套豪宅把整个区域的预测值拉高了,后来把目标变量做了对数变换才缓解。
def cart_regression_split(X, y): best_mse = float('inf') best_feature, best_threshold = None, None for f in range(X.shape[1]): thresholds = np.unique(X[:, f]) for t in thresholds: left = y[X[:, f] <= t] right = y[X[:, f] > t] if len(left) == 0 or len(right) == 0: continue mse = (len(left) * np.var(left) + len(right) * np.var(right)) / len(y) if mse < best_mse: best_mse = mse best_feature, best_threshold = f, t return best_feature, best_threshold4.4 代价复杂度剪枝:CART的剪枝逻辑
CART用的是代价复杂度剪枝(Cost Complexity Pruning, CCP),也叫 weakest link pruning。它的目标函数是:
$$R_\alpha(T) = R(T) + \alpha |T|$$
其中$R(T)$是树T的训练误差,$|T|$是叶子节点数,$\alpha$是惩罚系数。$\alpha$越大,对树的大小惩罚越重,树越简单。
算法分两步。第一步,从完整的树开始,自下而上计算每个内部节点的"剪枝收益"。对每个内部节点,算它被剪掉后$R_\alpha$的变化,找到使$R_\alpha$最小的那个节点剪掉,得到一个子树序列。第二步,用交叉验证在子树序列里选最优的$\alpha$。
sklearn里的ccp_alpha参数就是控制这个的。默认是0,不剪枝。实际用的时候,我一般先用cost_complexity_pruning_path拿到一系列$\alpha$值,然后对每个$\alpha$训练一棵树,用验证集选最好的。
实操心得:
ccp_alpha的调参不要凭感觉。先用path = clf.cost_complexity_pruning_path(X_train, y_train)拿到path.ccp_alphas,然后对每个alpha训练模型看验证集准确率,画一条曲线,选拐点。这个流程比手动试快得多,而且有据可循。
5. 三个算法在实际项目中的选型与踩坑记录
5.1 什么场景选哪个算法
虽然现在sklearn的DecisionTreeClassifier默认就是CART的变体,但理解三个算法的差异对选型和调参仍然重要。
| 场景 | 推荐算法 | 理由 |
|---|---|---|
| 纯离散特征,数据量小 | ID3 | 实现简单,信息增益直观 |
| 混合特征,有缺失值 | C4.5 | 原生支持连续值和缺失值 |
| 需要回归任务 | CART | 唯一支持回归的 |
| 需要特征重要性排序 | CART | 基尼重要性稳定,sklearn原生支持 |
| 需要概率输出 | CART | 叶子节点类别比例即概率 |
我个人的经验是,实际项目里90%的情况直接用CART。ID3和C4.5更多是理解原理的阶梯,工程上CART的二叉树结构、剪枝机制、回归能力都更完善。但如果你在面试或者教学场景,能把三个算法的演进逻辑讲清楚,是很加分的。
5.2 特征重要性怎么算才靠谱
sklearn的feature_importances_用的是基尼重要性:一个特征在所有节点上带来的基尼指数下降的加权和。但这个指标有两个坑。
第一,它偏向于高基数特征(取值多的特征),和ID3的信息增益偏好类似。第二,它是在训练集上算的,如果过拟合了,重要性排序可能不可信。
更稳的做法是用排列重要性(Permutation Importance):把某个特征的值随机打乱,看模型性能下降多少。下降越多说明该特征越重要。这个方法是模型无关的,而且可以在验证集上算。
from sklearn.inspection import permutation_importance result = permutation_importance(clf, X_val, y_val, n_repeats=10, random_state=42) for i in result.importances_mean.argsort()[::-1]: print(f"Feature {i}: {result.importances_mean[i]:.4f} +/- {result.importances_std[i]:.4f}")5.3 决策树过拟合的几种表现和应对
决策树过拟合的表现很直观:训练集准确率接近100%,验证集准确率差一大截。我总结了几种典型情况和应对手段。
情况一:树太深,叶子节点样本太少。应对:限制max_depth,或者设min_samples_leaf。我一般先设min_samples_leaf=5起步,根据数据量调整。
情况二:特征太多,噪声特征被选中。应对:先做特征筛选,或者用max_features限制每次分裂考虑的特征数。后者会让树更随机,类似随机森林的思路。
情况三:连续特征切分点太细。应对:对连续特征先分箱,或者设min_samples_split防止在样本少的节点上继续切。
情况四:类别不平衡,树偏向多数类。应对:设class_weight='balanced',或者对少数类过采样。
from sklearn.tree import DecisionTreeClassifier clf = DecisionTreeClassifier( criterion='gini', max_depth=8, min_samples_leaf=5, min_samples_split=10, class_weight='balanced', ccp_alpha=0.01, random_state=42 )5.4 一个完整的调参流程
我把实际项目里的调参流程整理成步骤,可以直接抄。
- 先不剪枝训练一棵完整的树,看训练集和验证集的准确率差距。差距大说明过拟合严重,需要强剪枝。
- 用
cost_complexity_pruning_path拿到alpha序列,对每个alpha训练模型,画验证集准确率曲线,选拐点附近的alpha。 - 固定alpha,调
max_depth和min_samples_leaf。这两个参数控制树的复杂度,先用网格搜索粗调,再细调。 - 如果还有提升空间,试
max_features。设成sqrt(n_features)或log2(n_features),引入随机性。 - 最后用排列重要性检查特征,把重要性接近0的特征删掉,重新训练。
这个流程走下来,一般能比默认参数提升5到15个百分点。具体提升多少取决于数据质量,如果特征本身噪声大,提升有限。
5.5 决策树的几个隐藏坑
坑一:random_state不设,结果不可复现。决策树在特征重要性相同时会随机选一个,不设随机种子每次跑的结果可能不一样。这个坑我在复现实验时踩过,找了半天原因。
坑二:类别标签必须是数值。sklearn不接受字符串标签,要先LabelEncoder。但注意,LabelEncoder会把标签映射成0,1,2...,如果标签本身有序(比如"低中高"),这个映射是合理的;如果无序(比如"红绿蓝"),映射后的数值大小没有意义,但决策树不会受影响,因为它只关心相等与否。
坑三:连续特征的量纲不影响决策树。决策树不需要归一化,因为它只关心大小关系,不关心绝对距离。这点和SVM、KNN不同。但如果你用排列重要性,量纲会影响打乱后的效果,不过影响不大。
坑四:决策树对训练集的小扰动敏感。数据里改几个样本,树的结构可能大变。这是决策树方差大的表现,也是随机森林、GBDT这些集成方法存在的原因。如果单棵树效果不稳定,考虑上集成。
6. 从单棵树到集成:决策树的延伸价值
6.1 为什么决策树是集成学习的基石
决策树虽然单棵效果一般,但它是随机森林、GBDT、XGBoost、LightGBM这些强算法的基学习器。原因有三个。
第一,决策树是"弱学习器"的典型代表,容易过拟合但偏差低。集成方法通过组合多棵低偏差、高方差的树,降低方差,提升泛化能力。第二,决策树的训练可以并行,随机森林里每棵树独立训练,天然适合分布式。第三,决策树对特征缩放不敏感,预处理简单,适合作为集成的组件。
理解了单棵树的构建原理,再看随机森林的"随机选特征"、GBDT的"拟合残差",就很容易明白它们在解决什么问题。随机森林是在降低方差,GBDT是在降低偏差。
6.2 决策树的可解释性在业务中的价值
我做过一个信贷风控的项目,业务方最关心的不是AUC多高,而是"为什么拒绝这个客户"。决策树能直接给出一条规则路径:收入低于X且负债率高于Y且近半年逾期次数大于Z,拒绝。这条规则业务方能看懂,能拿去和客户解释,也能拿去和监管沟通。
这是神经网络做不到的。神经网络可能效果更好,但它是黑盒。在需要解释性的场景,决策树和它的集成变体(尤其是GBDT配合SHAP值)是首选。
6.3 决策树规则提取的实操方法
从训练好的树里提取规则,sklearn提供了tree_.threshold和tree_.children_left/right,可以遍历。但更简单的方法是用export_text:
from sklearn.tree import export_text rules = export_text(clf, feature_names=feature_names) print(rules)输出是缩进的文本,每个if-else分支就是一条规则。如果要转成业务规则表,可以自己写个递归函数遍历树结构,把路径上的条件收集起来。
注意:提取出来的规则在训练集上100%准确,但不代表在真实数据上有效。规则要配合覆盖率一起看,覆盖率太低的规则(比如只覆盖几个样本)不要用,那是过拟合的产物。
6.4 决策树在特征工程中的反向应用
决策树不仅能做预测,还能用来做特征工程。具体做法是:用决策树对特征做离散化,把连续特征切成若干区间,每个区间作为一个新特征。这个方法叫基于树的离散化,在逻辑回归等线性模型前处理中很有用。
另一个用法是用决策树的叶子节点编号作为新特征,喂给其他模型。这就是GBDT+LR的经典套路:GBDT负责特征组合和离散化,LR负责最终分类。这个方案在点击率预测里用了很多年,效果稳定。
# 用决策树叶子编号做特征 leaf_ids = clf.apply(X_train) # 返回每个样本落在哪个叶子 # leaf_ids 可以作为新特征喂给其他模型这个套路的原理是:决策树的每条路径代表一种特征组合,叶子编号就是组合的编码。相比手动做交叉特征,树自动找到了有区分度的组合。
7. 手写实现与sklearn对照:几个关键差异
7.1 分裂标准的实现差异
手写ID3时,我用的是信息增益。sklearn的criterion='entropy'也是信息增益,但它的实现做了优化:用直方图加速连续特征的阈值搜索,而不是穷举所有唯一值。这个优化在大数据上差异明显,手写版在10万样本上可能跑几分钟,sklearn几秒就完了。
CART的基尼指数在sklearn里也是直方图实现。如果你要自己实现,建议先对特征排序,然后用累积和算左右子集的基尼,避免每次重新遍历。
7.2 停止条件的差异
手写版的停止条件通常只有"全同一类"和"特征用完"。sklearn多了几个:max_depth、min_samples_split、min_samples_leaf、min_impurity_decrease。其中min_impurity_decrease最容易被忽略,它要求分裂带来的不纯度下降至少达到这个值,否则不分裂。这个参数在噪声大的数据上很有用,能防止树在无意义的特征上浪费深度。
7.3 缺失值处理的差异
前面说过,C4.5原生支持缺失值,但sklearn的决策树不支持。sklearn要求输入没有NaN。如果你有缺失值,要么填充,要么用支持缺失值的梯度提升库(比如某些版本的XGBoost和LightGBM)。这是个实际使用中必须注意的差异,别以为C4.5支持就以为sklearn也支持。
7.4 多输出任务的差异
sklearn的决策树支持多输出:一个样本可以同时预测多个标签。比如预测一个人的多个属性(性别、年龄段、收入档)。实现上,它把多输出当作多个单输出任务,在每个节点上算所有输出的平均不纯度下降。这个功能在手写版里通常不实现,因为用得少,但知道有这回事,遇到多标签任务时能想到。
8. 写在最后:我这些年用决策树的几点体会
决策树是我工具箱里最常用的算法之一,不是因为它效果最好,而是因为它最"透明"。每次模型效果不好,我第一件事就是打印一棵决策树看看,往往能发现数据里的问题:某个特征的分裂点很奇怪,说明数据有异常值;某个分支特别深,说明这类样本特别难分;某个特征从来没被用到,说明它可能是冗余的。
ID3、C4.5、CART这三个算法,我建议每个学机器学习的人都亲手实现一遍。不是为了造轮子,而是为了理解"特征选择"这件事的本质。信息增益、增益率、基尼指数,这三个指标背后的思想——用不确定性度量纯度——会贯穿你后续学随机森林、GBDT、甚至神经网络的很多设计。
最后分享一个我常用的调试技巧:训练完一棵树后,用export_text打印出来,然后拿几条验证集样本,手动沿着树走一遍,看预测路径是否符合直觉。如果不符合,要么是数据有问题,要么是树过拟合了。这个手动验证的过程,比看准确率数字更能发现问题。