☰
决策树ID3算法详解:从信息增益入手手写Python实现
2026/10/14 16:32:25 网站建设 项目流程

最近在整理一个用户流失预测的模拟项目,跑完逻辑回归之后,团队一直追问一个问题:到底是哪些特征在决定用户留不留下?为了把模型讲成人话,我把决策树从底层翻了出来,从信息增益开始,手写了ID3算法的Python实现。这篇文章就是那次折腾的完整记录,适合想搞懂特征选择逻辑、或者想自己从头写一棵决策树的同学。

先说结论:ID3算法不复杂,核心就一句话——每次选特征时,挑那个能让数据"混乱程度"下降最多的特征。把"混乱程度"量化成熵,把"下降多少"算成信息增益,剩下的就是递归和循环。这篇文章我会从熵的直觉讲起,手算一个信息增益的例子,然后给出可以直接跑起来的完整代码,最后聊聊我在实跑过程中遇到的坑,以及为什么今天你已经很少会直接用ID3。

1. 信息增益这个"评分标准",是怎么给特征打分的

1.1 熵:混乱度到底怎么量化

决策树第一次出现时,很多人的直觉是"看每个特征跟目标变量的相关性"。但相关性是一个连续值的打分方式,用在分类特征上不够直白。ID3的做法是把问题换成:如果我把数据按某个特征切开来,目标变量是不是变得更好猜了?

"更好猜"这件事,需要一个数值指标,这个指标就是熵。

熵这个概念来自信息论,说白了就是"一条消息里到底有多少不确定的信息"。对分类问题来说,假设数据集里有k个类别,第i类占比是p_i,熵的定义是:

H(D) = - Σ p_i * log2(p_i)

我习惯把它翻译成大白话:如果一份数据里所有样本标签都一样,那熵就是0,因为根本没有不确定性;如果两类各占一半,熵就是1,因为猜中概率只有一半,不确定性最大。多分类的时候熵可以超过1,但"越小越有序"这个直觉始终成立。

有了熵,再定义条件熵。按特征A的取值把数据切成几个子集,每个子集各自算熵,再按样本量加权平均,就得到条件熵H(D|A)。条件熵等于"我看了特征A之后,还剩多少不确定性"。那"看了特征A之后,不确定性减少了多少"就是信息增益:

g(D, A) = H(D) - H(D|A)

减得越多,说明这个特征对目标变量越有判别力。ID3在每一层做的事情,就是把所有还没用过的特征都算一遍信息增益,挑最大的那个当分裂点。整个过程是清晰的贪心策略。

1.2 完全手算一遍:为什么"活跃度"能排第一

光讲公式容易飘,我拿一份16条的模拟数据完整算一次。这是从"模拟项目X"里抽出来的小样本,四个特征分别是活跃度、付费能力、内容偏好、使用时段,目标字段是"是否流失"。

原始数据我先整理成一张表,方便后面代码沿用:

编号活跃度付费能力内容偏好使用时段是否流失
1高高娱乐白天否
2高中资讯晚上否
3高低工具白天是
4中高资讯晚上是
5中中娱乐白天否
6中低工具晚上是
7低高工具白天是
8低中资讯晚上是
9低低娱乐白天是
10高中娱乐晚上否
11中低资讯白天是
12中高工具晚上否
13低中娱乐晚上是
14高高工具白天否
15低低资讯白天是
16中中资讯晚上是

第一步,算总熵。16条样本里流失10条、未流失6条,占比分别是10/16和6/16:

H(D) = - (10/16) * log2(10/16) - (6/16) * log2(6/16) ≈ 0.955

这个0.955就是"整体不确定性"。

第二步,按"活跃度"切分。活跃度有高、中、低三个取值:

  • 高:5条样本,其中流失1条、未流失4条
  • 中:6条样本,其中流失4条、未流失2条
  • 低:5条样本,全部流失

分别算三个子集的熵:

  • H(高) = - (1/5) * log2(1/5) - (4/5) * log2(4/5) ≈ 0.722
  • H(中) = - (4/6) * log2(4/6) - (2/6) * log2(2/6) ≈ 0.918
  • H(低) = - (5/5) * log2(5/5) - 0 = 0

注意"低"这一组是纯流失样本,熵直接归零。这是算法最喜欢的状态。

条件熵就是按样本量加权:

H(D|活跃度) = (5/16) * 0.722 + (6/16) * 0.918 + (5/16) * 0 ≈ 0.57

信息增益:

g(D, 活跃度) = 0.955 - 0.57 = 0.385

第三步,用同样方式算另外三个特征。我把结果直接放表格里,你可以拿公式慢慢验算:

特征条件熵信息增益
活跃度0.5700.385
付费能力0.7340.221
内容偏好0.8500.105
使用时段0.9550.000

"使用时段"只看白天/晚上,每一组里流失和未流失的比例没有变化,所以增益是0,等于这个特征提供不了任何新信息。而"活跃度"让整体不确定性下降了0.385,排第一。于是根节点选"活跃度",这就是信息增益在做特征选择时的完整链路。

2. ID3的贪心递归:从根节点长成一棵完整树

2.1 分裂策略与树结构定义

算完根节点之后,事情并没有结束。ID3把"选特征"这件事递归地应用到每个分支上:按根节点的取值切出多个子集,每个子集再重复"算总熵、算条件熵、选最大增益特征"的过程,直到满足停止条件。

停止条件一般有三个:

  • 当前子集的样本全部属于同一类别,直接生成叶子节点;
  • 所有特征都用完了,但子集里标签仍然不纯,那就少数服从多数,用出现次数最多的类别当叶子;
  • 某个特征取值在子集里没有对应样本,空分支需要兜底处理。

递归过程中,已经用过的特征不会再被使用。因为按这个特征切完后,子集内部该特征的取值已经统一了,再拿它切分不会带来任何区分度。这也是为什么切分函数要顺手把当前特征列删掉。

树结构我直接用字典嵌套来表示,比如节点就是 {"活跃度": {"高": 子节点, "中": 子节点, "低": "是"}}。字典的好处是结构直观,很容易转成JSON,也能直接导出规则文案给业务团队看。

2.2 "取值越多越占便宜"的著名缺陷

到这里,ID3的核心逻辑其实已经全部讲完了。但你要是真拿去跑真实数据,很快会遇到第一个大坑:信息增益天然偏好取值数量很多的特征。

直觉也能理解。熵下降最猛的极端情况,是把每个样本都单独切成一个分支——比如加一个"用户编号"特征,16个编号切出16个子集,每个子集只有一个样本。如果这个样本恰好是流失,那子集熵就是0,条件熵就是0,信息增益一下子变成0.955,碾压所有正常特征。

问题是这样的树毫无泛化能力。它记住的是一个个独立个体,而不是一组可复用的规则。真实数据里ID编号、订单号这类高基数特征很容易被误选成根节点,这就是ID3最容易被吐槽的地方,也是C4.5用信息增益率来修正它的核心动机。

另外一个容易被低估的问题是:ID3默认只能处理离散特征。连续值要么先分箱,要么根本不能用。

分箱大家都有经验,比如"日活跃天数"按阈值切分成高、中、低。但阈值怎么定会影响很大,ID3没有自动寻找最优切分点的机制。这是它和CART的一个重要分水岭,后面章节我会专门对比。

3. 从零写Python代码:一份能直接跑的训练与预测实现

3.1 数据与数据结构怎么组织

代码我尽量写得朴素,方便你一行行读明白。数据集就用上面那张16条的表,特征列表和样本数据直接写在代码里。

features = ["活跃度", "付费能力", "内容偏好", "使用时段"] data = [ ["高", "高", "娱乐", "白天", "否"], ["高", "中", "资讯", "晚上", "否"], ["高", "低", "工具", "白天", "是"], ["中", "高", "资讯", "晚上", "是"], ["中", "中", "娱乐", "白天", "否"], ["中", "低", "工具", "晚上", "是"], ["低", "高", "工具", "白天", "是"], ["低", "中", "资讯", "晚上", "是"], ["低", "低", "娱乐", "白天", "是"], ["高", "中", "娱乐", "晚上", "否"], ["中", "低", "资讯", "白天", "是"], ["中", "高", "工具", "晚上", "否"], ["低", "中", "娱乐", "晚上", "是"], ["高", "高", "工具", "白天", "否"], ["低", "低", "资讯", "白天", "是"], ["中", "中", "资讯", "晚上", "是"], ]

这里数据是二维列表,每行最后一位是标签。实际项目里通常会用表格库读取,但为了讲清算法逻辑,列表足够了。

3.2 熵、切分、择优三个核心函数

先写熵计算。函数接收的是所有样本的标签列,也就是每行最后一个元素:

from collections import Counter import math def calc_entropy(data): labels = [row[-1] for row in data] total = len(labels) counter = Counter(labels) entropy = 0.0 for count in counter.values(): p = count / total entropy -= p * math.log2(p) return entropy

注意空数据集要提前处理,否则除零报错。

然后是切分函数。它干两件事:挑出特征值等于指定value的样本;把当前特征列从子集里删掉。

def split_dataset(data, feature_index, value): sub_data = [] for row in data: if row[feature_index] == value: reduced = row[:feature_index] + row[feature_index + 1:] sub_data.append(reduced) return sub_data

为什么删列?因为递归建树时,已经用过的特征不应该再出现在候选特征列表里。按值过滤是基础操作,删列则是防止后面重复选择。

接下来是选最优特征。遍历每个未用特征,计算信息增益,返回增益最大的那个索引:

def choose_best_feature(data): feature_count = len(data[0]) - 1 base_entropy = calc_entropy(data) best_gain = -1 best_feature = -1 for i in range(feature_count): values = set(row[i] for row in data) cond_entropy = 0.0 for value in values: sub_data = split_dataset(data, i, value) p = len(sub_data) / len(data) cond_entropy += p * calc_entropy(sub_data) gain = base_entropy - cond_entropy if gain > best_gain: best_gain = gain best_feature = i return best_feature

这里我要额外说一句:手动算信息增益时,很多人会把条件熵的公式记成"先加权再取log"之类的错误版本。实际上严格顺序是:按取值划分子集,每个子集单独算熵,再乘子集占比。代码顺序跟公式保持完全一致,不容易出错。

3.3 create_tree递归和predict路径

有了上面三个基础函数,建树主体就很短了。核心就是处理停止条件和递归分支:

def majority_label(labels): counter = Counter(labels) return counter.most_common(1)[0][0] def create_tree(data, current_features): labels = [row[-1] for row in data] if len(set(labels)) == 1: return labels[0] if len(current_features) == 0: return majority_label(labels) best_idx = choose_best_feature(data) best_feature = current_features[best_idx] tree = {best_feature: {}} remaining_features = current_features[:best_idx] + current_features[best_idx + 1:] values = set(row[best_idx] for row in data) for value in values: sub_data = split_dataset(data, best_idx, value) if len(sub_data) == 0: tree[best_feature][value] = majority_label(labels) else: tree[best_feature][value] = create_tree(sub_data, remaining_features) return tree

"if len(sub_data) == 0"这个分支容易被忽略,但真实数据里很常见。某个特征取值在整个训练集里都出现过,但经过上层切分后,这个取值在一个子集里恰好没有样本了。此时不能直接报错,我会用当前子集的多数类别兜底。

predict函数也不复杂,沿着字典路径往下走:

def predict(tree, sample): if not isinstance(tree, dict): return tree feature_name = next(iter(tree)) feature_value = sample.get(feature_name) branch = tree[feature_name] if feature_value not in branch: return None return predict(branch[feature_value], sample)

这里的输入sample是字典,比如 {"活跃度": "高", "付费能力": "高", "内容偏好": "娱乐", "使用时段": "白天"}。如果走到空分支,返回None,实际项目中我会在None的位置填充训练集的多数类别。

建树入口调用:

tree = create_tree(data, features) print(tree)

3.4 跑通一棵树:输出长什么样

在16条样本上跑完代码,得到的树结构大概是这样:

{ '活跃度': { '高': { '付费能力': { '高': '否', '中': '否', '低': '是' } }, '中': { '内容偏好': { '娱乐': '否', '工具': { '付费能力': { '高': '否', '中': '是', '低': '是' } }, '资讯': '是' } }, '低': '是' } }

用上面两条样本验证:

sample1 = {"活跃度": "高", "付费能力": "高", "内容偏好": "娱乐", "使用时段": "白天"} sample2 = {"活跃度": "中", "付费能力": "低", "内容偏好": "工具", "使用时段": "晚上"} print(predict(tree, sample1)) # 否 print(predict(tree, sample2)) # 是

预测结果符合原始数据分布:活跃度高的老样本倾向不流失,活跃度低的样本基本都流失,中活跃度再结合内容偏好进一步判断。

从根节点可以清楚看出,使用时段和信息增益最低的内容偏好根本没有出现在重要分支里。这种"树长出来就能当规则念"的特性,是决策树在业务分析里常年不过时的原因。

4. 用着用着就会踩到的坑和补救思路

4.1 连续特征怎么办:离散化

前面说了ID3不支持连续特征。最常见的补救办法是分箱,但分箱是个手艺活,阈值的敏感性很高。

比如"近30天打开次数"这个连续特征,如果按"0次、1到3次、4到7次、8次以上"分箱,和按"0到5次、6到15次、16次以上"分箱,最后的树结构可能完全不同。手动分箱不是不能做,前提是你对业务足够熟悉。

更聪明的做法是数据驱动找阈值。把特征值排序,取相邻两个值的中点当作候选切分点,每个切分点都算一次二分类的信息增益,选最大的那个。这是C4.5处理连续特征的经典思路。缺点是计算量大一些,因为要对每个特征做一次排序加遍历。

如果你的数据里有大量连续特征,我的建议是不要硬用ID3,直接换CART或者梯度提升树会省心很多。ID3强在"把离散规则讲明白",而不是处理复杂数值关系。

4.2 喜闻乐见的过拟合:剪枝

决策树不剪枝,很容易出现"训练集准确率100%,测试集一塌糊涂"的情况。原因是它会把所有噪声都学进去,比如训练集里某个分支只有一条样本属于异常类别,它也会专门长出一个叶子去记住。

剪枝分两种。预剪枝在建树过程中做,比如限制最大深度、限制叶子节点最少样本数、限制分裂时必须带来的增益最小值。这些参数能直接控制树的大小,但设置多少需要验证集调参。

后剪枝则先让树长满,再用验证集做自底向上的检查。做法是把某个内部节点替换成叶子节点,用多数类别代替子树输出,如果验证集误差没有上升,就接受这次剪枝。这个思路在ID3原版里是没有的,但你自己实现起来并不难。

我自己跑实验时,常用一组简单参数起步:最大深度4到6,叶子最少5条样本。在这个基础上再看树对业务解释是否清楚。ID3这类多路划分树天生比二叉树更容易碎,深度限制往往比最少样本数更有效。

4.3 ID3、C4.5、CART的横向对比

如果你去查文档,会发现现在主流工具里已经很难找到纯粹的ID3实现。主要因为它已经是两代之前的方案。我整理了这三个经典家族的差异:

维度ID3C4.5CART
特征评分信息增益信息增益率基尼系数
分支方式多路输出多路输出二叉树
连续特征不支持按阈值二分按阈值二分
缺失值不支持支持概率分配通常不支持
剪枝不支持后剪枝成本复杂度剪枝
叶子输出离散类别离散类别离散类别或回归值

C4.5引入信息增益率,核心是把信息增益除以该特征自带的分裂信息,对高基数特征做一个天然惩罚。分裂信息越大,分数被稀释得越厉害,"编号"这种特征就不会再占便宜了。

CART则直接换评分标准,用基尼系数。基尼系数的含义是随机抽两个样本标签不一致的概率,越小越纯。它的计算不涉及log,速度更快,而且强制二叉树,结构更稳定。sklearn里的决策树默认就是CART,即使你设置criterion="entropy",它本质上也还是二叉搜索加信息增益评分,跟课本里ID3的多路划分不是一回事。

5. 手写决策树之后,我现在会怎么用

5.1 从ID3改成C4.5,其实改动很小

如果你看完代码,想亲手把它升级成C4.5,最核心的改动就在choose_best_feature函数里。原来直接返回最大信息增益,现在先算信息增益,再算分裂信息:

split_info = - Σ (|Di| / |D|) * log2(|Di| / |D|)

增益率等于信息增益除以分裂信息。当某个特征取值非常多时,分裂信息会偏大,增益率被拉低。要注意split_info可能接近0,比如所有样本特征取值都相同时会出问题,实际实现要加一个很小的平滑值。

连续特征改造稍微费力。就是前面说的排序找中点,然后把原来"按值划分多个子集"改成"按阈值划分两个子集"。由于分支从多路变两路,递归逻辑也要跟着微调。

5.2 实际选型建议:什么时候还值得用ID3这种"老古董"

我不建议在正式大项目里硬上原版ID3。它处理不了连续值,对缺失值无能为力,也没有正则化机制,同样的数据换成CART或XGBoost,效果大概率更好。

但ID3的教育价值很高。它把"特征选择"这个问题拆得极干净,代码量又小,非常适合用来理解树模型的底层逻辑。我到现在还会用这段代码给新人讲熵和贪心递归。如果你要做强解释性的小样本规则提取,ID3或C4.5这种多路树反而方便,导出的规则能直接写成话术,不需要黑盒模型的那种概率输出。

真要用在项目里,最省事的方案就是sklearn的DecisionTreeClassifier。处理连续特征、自动剪枝、训练预测API都齐全,把参数调到手,可解释性也不会比手写版本差。但前提是先把ID3的数学逻辑搞懂,否则完全不知道criterion和max_depth在背后调什么,出了问题也只会盲试参数。

5.3 一点个人体会

回头再看这次手写经历,最大的收获不是"掌握了某个算法",而是建立了一种思考习惯:面对很多个候选维度时,先定义"不确定性怎么度量",再算"每个维度消除了多少不确定性"。这个思维在特征工程、AB实验排查、甚至日常方案选型里都能复用。

如果你也想动手试试,建议跑通文章代码后,自己往数据集里加一列"用户编号",再看看根节点会不会变。等你能解释清楚为什么变、怎么防止它变,信息增益这个概念才算是真正拿下了。

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

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

立即咨询