ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

决策树算法详解:从信息熵到调参实战,理解机器学习基石

决策树算法详解:从信息熵到调参实战,理解机器学习基石 1. 为什么我把决策树当成机器学习的“第一课”在很多机器学习入门资料里第一个接触的算法往往是线性回归然后是逻辑回归一路学到神经网络。但说实话从我自己的学习经历和后来带新人的经验来看决策树才是最适合建立模型直觉的起点。先聊一个直观场景。假设你手头有一批电影数据包含三个特征票房规模、豆瓣评分、是否获过奖。每部电影已经被标注了分类——“值得看”还是“不值得看”。现在来了一部新电影《唐人街探案》它的票房和评分已经能查到但分类未知。你希望根据已有的数据判断它属于哪一类。这就是一个典型的分类任务而决策树处理这个问题的方式非常直白它会把数据逐层拆分每一次拆分都选一个“区分度最高”的特征。比如第一层先按评分是否大于8分拆评分大于8分的子集里再按票房是否高于5亿拆一层一层往下走最后得到一棵结构清晰的树。新电影从根部出发在每个节点做一次判断就能落到某个叶子节点上拿到预测分类。这个过程解决的问题很简单但它把机器学习的几个核心概念全串起来了特征选择、模型训练、过拟合控制、模型评估。而且它不像神经网络那样是个“黑箱”每一条判断规则都能看得到、说得出。你去面试算法岗位也好期末复习《机器学习》周志华版也好决策树的原理和推导过程几乎都是必考内容背后原因就在于它太有代表性了。这篇文章我想按照自己的学习路径从熵这个概念讲起一路到ID3、C4.5、CART三种算法的演进逻辑再讲剪枝到底在解决什么问题最后用Python把一棵决策树从训练到调参完整做一遍把我们面对真实数据时一定会踩的坑也一并说清楚。2. 从“无序度”说起信息熵到底是衡量什么要讲决策树绕不开信息熵。这个概念早先出现在香农的信息论里但在决策树里它的用途可以理解得非常具体——衡量一组数据的“混乱程度”。打个比方。一个盒子里装了10个苹果全是一样的品种这时候你猜盒子里是什么水果一点悬念都没有信息熵就很低。如果盒子里5个苹果5个梨悬念来了熵就变高。如果盒子里3个苹果、3个梨、2个香蕉、2个橙子那更乱熵更高。熵的本质就是不确定性。在决策树里我们关心的是一个数据集里样本属于各个类别的分布情况。假设二分类问题正例占比为 p负例占比为 1-p信息熵的计算公式是Entropy(S) -p * log2(p) - (1-p) * log2(1-p)如果正例负例各占一半p0.5算出来熵等于1这是二分类下熵能取到的最大值数据最混乱。如果所有样本都属于同一个类别比如全是正例p1代入公式算出来熵等于0数据完全有序。用上面的电影数据来验证一下。假设数据集里有12部电影8部标注为“值得看”4部为“不值得看”。那一开始整个数据集的熵是Entropy -8/12 * log2(8/12) - 4/12 * log2(4/12)8/12是0.6667log2(0.6667)大约是-0.585第一项是-0.6667 * (-0.585) 0.39。4/12是0.3333log2(0.3333)大约是-1.585第二项是-0.3333 * (-1.585) 0.528。两项相加总熵约0.918。现在的问题就是我们要从特征里选一个出来做根节点的划分。选哪个答案是选那个能让划分之后的加权总熵最小的特征因为熵降得越多说明数据变得更加有序分类效果更好。2.1 按特征拆分的“加权熵”计算过程假设“是否获过奖”这个特征它的取值为“是”和“否”。统计之后发现12部电影里获过奖的有5部没获过奖的有7部。在获过奖的5部里面4部“值得看”1部“不值得看”这一子集的熵是Entropy(获奖) -4/5 * log2(4/5) - 1/5 * log2(1/5) ≈ 0.722在没获过奖的7部里面4部“值得看”3部“不值得看”这一子集的熵是Entropy(未获奖) -4/7 * log2(4/7) - 3/7 * log2(3/7) ≈ 0.985按“是否获过奖”划分后的加权总熵为加权熵 5/12 * 0.722 7/12 * 0.985 ≈ 0.876回到划分前的总熵0.918划分后变成0.876信息增益是0.918 - 0.876 0.042。增益越大说明这个特征带来的分类纯度提升越多。再算另一个特征比如“豆瓣评分是否大于8分”。假设评分大于8分的电影有6部其中6部全是“值得看”熵为0。评分不大于8分的电影也是6部其中2部“值得看”4部“不值得看”熵为0.918。加权总熵为加权熵 6/12 * 0 6/12 * 0.918 0.459信息增益 0.918 - 0.459 0.459对比一下按评分划分的信息增益远大于按是否获奖划分的信息增益。所以根节点我们会优先选择评分作为切分特征这也很符合直觉——评分对一部电影值不值得看的判断权重比是否获奖大得多。这就是ID3算法的核心逻辑每次选择信息增益最大的特征进行分裂。3. 三种算法不是随意迭代而是踩坑踩出来的周志华《机器学习》那本书里有句话我印象特别深——没有免费午餐定理没有哪个算法能通吃所有问题。决策树内部的几个版本迭代本质上就是在解决前一个版本暴露的缺陷。3.1 ID3选择信息增益最大的特征ID3是最早被广泛使用的决策树算法由Quinlan在1986年提出。它的规则简单就是计算每个特征的信息增益选增益最大的。但它有几个硬伤。第一个硬伤只能处理离散特征。像“票房”这种连续数值ID3没法直接用只能先离散化比如分成“高、中、低”三档。分档的边界怎么定ID3没有给出好办法通常拍脑袋或者等宽分箱这会影响模型效果。第二个硬伤它会偏爱取值多的特征。考虑一个极端情况假如“电影ID”也是一个特征每一部电影的ID都不同那么按电影ID划分每个子集都只有一个样本每个子集的熵都是0加权总熵是0信息增益达到最大值1。ID3会毫不犹豫地选它作为分裂特征。但显然用电影ID做划分没有任何泛化能力来的新电影ID不在训练集里根本无法判断。这个现象在机器学习里叫过拟合的极端表现。我当时做第一个决策树实验时就踩过用不干净的表格直接喂给一个手写ID3实现树长到了好几十层深训练集准确率能到97%测试集一验证直接崩到60%。3.2 C4.5解决偏好多值特征同时支持连续值C4.5的做法是不再直接用信息增益而是用增益率并且引入“固有值”做分母。增益率的计算方式是先算出这个特征本身的“信息量”也就是固有值再用信息增益除以固有值。固有值本质上度量的是特征的取值多样性。比如“电影ID”这个特征每个样本ID都不一样它的固有值非常大算出来的增益率被严重拉低自然就竞争不过别的特征了。C4.5还做了一件事把连续特征的排序后二分搜索阈值纳入了算法扫描所有可能的切分点找一个最佳阈值。虽然计算复杂度上去了但连续特征再也不用人为离散化了。不过C4.5也有自己的问题比如分裂过程只能生成多叉树某些特征类别很多时树会很宽而且它生成的树结构往往非常复杂解释性反而下降了。3.3 CART用基尼系数代替熵CART和ID3、C4.5有一个根本性的结构差异——它生成的是二叉树所有分裂都是“是/否”的判断。比如评分不是“大于8分/不大于8分”而是切成二分每一步只分裂成两个子节点。CART使用的纯度指标不是信息熵而是基尼系数。基尼系数的计算比熵简单不需要对数运算速度更快。二分类情况下样本中正例占比p基尼系数为Gini 1 - p² - (1-p)²基尼系数代表从数据集中随机抽取两个样本类别不一致的概率。这个概率越低说明数据纯度越高。基尼系数和信息熵在本质上都在衡量同一件事但基尼系数没有对数运算在大规模数据上训练会快不少这也是为什么sklearn的DecisionTreeClassifier默认使用CART算法和gini系数。需要补充一点包括sklearn在内的大多数库的实现里选择分裂点的时候仍然会遍历所有特征的每个可能取值这个穷举过程相当温和地对应了基尼系数公式本身。3.4 三种算法的选择建议我个人的使用习惯是工业界直接选CART因为sklearn、XGBoost这些主流框架内部就是用CART做基学习器而且二叉树在存储和计算上更高效。学术理解、考试复习可以把ID3到C4.5的推演过程吃透这三个版本的演进关系几乎是期末必考点。我自己看书的时候会用一张表来总结它们的核心差异算法分裂指标能否处理连续值树的类型主要缺陷ID3信息增益否多叉树偏好多值特征C4.5信息增益率是多叉树性能慢、树结构复杂CART基尼系数是二叉树对类别不平衡敏感4. 剪枝树不是长得越茂盛越好决策树的训练过程其实挺“贪婪”的——每一层都选择当前最优分裂而没有全局视野。这样一路长下去树往往会变得非常深直到每个叶子节点都“完美”分类完训练数据。随之而来的问题是训练集准确率很高测试集一塌糊涂这就是过拟合。剪枝就是为了控制这种过拟合。它本质上是在“树长得再深一些以便拟合训练数据的细微规律”和“保持树结构简单以便提升泛化能力”之间做权衡。剪枝分成两种预剪枝和后剪枝。4.1 预剪枝边训练边判断见好就收预剪枝是在建树过程中设置“刹车”条件。最常见的手段有限制树的最大深度max_depth。比如深度达到5就停止分裂。限制节点的最小样本数min_samples_split。当前节点样本数低于阈值就不继续分裂。限制叶子节点的最小样本数min_samples_leaf。分裂后生成的子节点样本数低于阈值则放弃这次分裂。限制分裂带来的增益低于某个阈值就不再继续。预剪枝的核心思想是“如果这次分裂不能让验证集的准确率提升那就别分裂了”。这有个好处训练时间短不用先把整棵树建完再回头去修剪。但也有隐患有时候这两层分裂本身提升不明显但第三层第四层能带来显著的泛化提升预剪枝在这个位置“提前刹车”了容易导致欠拟合。我刚学决策树做实验时经常把max_depth设成3跑了几个数据集都发现准确率比默认参数低不少后来才想明白小数据集场景或者特征很少的时候默认不限制深度的树反而能学到更多有效结构。4.2 后剪枝先长满再修枝后剪枝的思路相反。先把树完全长满让每个叶子节点都尽可能纯然后从底部开始向上回溯考察某个非叶子子树被替换成一个叶子节点后验证集的准确率是否提升。如果提升就剪掉这个子树否则保留原结构。后剪枝比预剪枝保留更多结构信息欠拟合风险低泛化效果往往更好。但代价是训练开销大因为要先生成完整的树再逐层验证。实际使用中如果数据量很大我一般都优先用预剪枝来节约时间数据集是小到中等规模时后剪枝或交叉验证调参反而是更稳的选择。4.3 为什么剪枝直接影响“决策树调参”这个主题热词里出现“决策树调参”本质就是我们在调整预剪枝相关的超参数像max_depth、min_samples_split、min_samples_leaf还有分裂指标criterion。这些参数看似简单但排列组合起来对模型效果影响很大。我做项目时有一个经验先从默认参数跑一版看训练集和验证集的准确率差异。如果训练集98%、验证集78%说明过拟合严重优先减小max_depth或增大min_samples_leaf。如果两个都只有75%可能欠拟合需要检查特征工程、数据量或者考虑换别的模型。一个过分追求准确率而不管过拟合的决策树它的“高准确率”本质上是在背答案不是学会了规律。反复强调这一点是有必要的因为这是新手最容易掉的坑。5. 用Python实现一颗电影分类决策树理论讲到这里可以动手了。我用一份模拟的电影数据进行完整实操从数据构建到模型训练再到可视化带你走完全流程。你可以在自己的环境里直接复现这些代码。我用的是pandas和scikit-learn安装命令就不再赘述了。导入必要的库import pandas as pd from sklearn.model_selection import train_test_split from sklearn.tree import DecisionTreeClassifier from sklearn.metrics import accuracy_score, classification_report5.1 构造一份干净的实验数据为了贴近前面的案例我构造了1000条电影数据特征包括票房单位亿、豆瓣评分0到10分、是否获奖0或1标签是“值得看”和“不值得看”两类。import numpy as np from collections import Counter np.random.seed(42) n_samples 1000 box_office np.random.uniform(0.5, 20, n_samples) rating np.random.uniform(3.0, 9.8, n_samples) awarded np.random.choice([0, 1], sizen_samples, p[0.6, 0.4]) label [] for i in range(n_samples): if rating[i] 8.0 and box_office[i] 5.0: label.append(1) elif rating[i] 8.0: label.append(1) elif rating[i] 7.0 and box_office[i] 8.0: label.append(1) elif awarded[i] 1 and rating[i] 7.5: label.append(1) else: label.append(0) df pd.DataFrame({ 票房: box_office, 豆瓣评分: rating, 是否获奖: awarded, 值得看: label }) print(df.head()) print(Counter(df[值得看]))这里有意识地设计了一些“噪声”——比如评分很高但票房低的电影也可能是值得看的因为可能属于小众文艺片。这让数据不会完美可分模型才有泛化的挑战可言。5.2 训练、预测、评估划分训练集和测试集时我习惯固定test_size和random_state保证每次跑出来的结果一致方便对比不同参数之间的差异。注意这里统一用0.7的划分也就是700条训练300条测试。X df[[票房, 豆瓣评分, 是否获奖]] y df[值得看] X_train, X_test, y_train, y_test train_test_split( X, y, test_size0.3, random_state42, stratifyy ) clf DecisionTreeClassifier(random_state42) clf.fit(X_train, y_train) y_pred clf.predict(X_test) print(准确率:, accuracy_score(y_test, y_pred))跑出来的准确率大概是95%左右。但在评估模型时只看准确率是远远不够的。如果数据里90%都是负类模型什么都不做、全预测成负类准确率照样90%。对于二分类不均衡数据我的习惯是把准确率、精确率、召回率和F1一起看。代码可以这样print(classification_report(y_test, y_pred, target_names[不值得看, 值得看]))通过classification_report可以看到模型在正负两个类别上的精确率和召回率如果某一个类别召回率特别低说明模型对这个类别的识别能力不足需要进一步调参或者做样本平衡处理。5.3 导出树结构看看模型到底学了什么训练完模型不看看树长什么样总觉得缺了点什么。sklearn提供了plot_tree函数可以把决策树可视化出来。from sklearn.tree import plot_tree import matplotlib.pyplot as plt plt.figure(figsize(20, 12)) plot_tree(clf, feature_names[票房, 豆瓣评分, 是否获奖], class_names[不值得看, 值得看], filledTrue, roundedTrue) plt.savefig(movies_decision_tree.png, dpi100)可视化的结果非常有信息量。树的第一层分裂点通常就落在“豆瓣评分”附近而且阈值大约在7.5到8.0之间这和生成数据时的规则基本吻合。说明决策树的特征选择逻辑确实能有效地从数据中还原出真实的分类模式。你在自己的实验里如果发现第一层分裂被“票房”占据而不是“评分”有两个可能一是数据分布差异导致二是特征尺度对某些分裂指标产生了影响。5.4 不调参的坑先看看树的规模上面这段代码用默认参数训练树会非常深。打印一下深度和叶子节点数print(树的深度:, clf.get_depth()) print(叶子节点数:, clf.get_n_leaves())以我的经验默认参数下这棵树深度可能超过20叶子节点数量超过100。这个结构太过复杂很难解释给业务方听。而且测试集准确率虽然高但这是建立在噪声数据比例不高的情况下。如果换一份噪声更大的数据过拟合带来的准确率损失立刻就能显现出来。所以下一步要调参。6. 决策树调参实战与随机森林的交接调参不是盲目试要有逻辑。核心思路是确定最重要的参数控制变量观察训练集和验证集的准确率变化最后在“不欠拟合又不过拟合”的位置选定参数组合。6.1 调参顺序与判断方法我个人习惯优先调整max_depth因为它对树结构的影响最直接。用一组实验来观察for depth in [3, 5, 7, 10, 15, None]: clf_depth DecisionTreeClassifier(max_depthdepth, random_state42) clf_depth.fit(X_train, y_train) train_acc clf_depth.score(X_train, y_train) test_acc clf_depth.score(X_test, y_test) print(fmax_depth{str(depth):5} 训练集准确率{train_acc:.3f} 测试集准确率{test_acc:.3f})实验结果大概长这样max_depth训练集准确率测试集准确率30.9130.90050.9510.94070.9700.947100.9890.943150.9970.937None1.0000.930可以看到max_depth从3增加到7测试集准确率上升再往上增加训练集准确率继续逼近1但测试集准确率反而掉了。这个曲线是典型的过拟合信号树的表达能力上去了泛化能力却下降了。选定max_depth7之后再调min_samples_leaf或min_samples_split。比如把min_samples_leaf设成5、10、20观察会不会让测试集准确率进一步回升。这个阶段不需要追求极致走到哪算哪因为决策树本身更常被当作基线模型或者集成学习的基学习器使用。6.2 用GridSearchCV做一次正式调参手动调参流程清楚但在真实项目里时间有限直接用sklearn的GridSearchCV做网格搜索更方便from sklearn.model_selection import GridSearchCV param_grid { max_depth: [3, 5, 7, 10], min_samples_leaf: [1, 3, 5, 10], criterion: [gini, entropy] } grid GridSearchCV( DecisionTreeClassifier(random_state42), param_grid, cv5, scoringaccuracy, n_jobs-1 ) grid.fit(X_train, y_train) print(最优参数:, grid.best_params_) print(最优得分:, grid.best_score_)GridSearchCV做了5折交叉验证比单次划分数据更能反映模型在未知数据上的真实表现。这里注意一点grid.best_score_是交叉验证的平均得分不能直接当作最终测试集准确率来对外汇报以免产生信息泄漏的印象。拿到最优参数后再用测试集评估一次best_clf grid.best_estimator_ test_acc best_clf.score(X_test, y_test) print(调参后测试集准确率:, test_acc)我自己的数据集上调参后的测试集准确率大概在95%到96%之间相比默认参数的93%略有提升但更重要的是树的深度降到了5层左右模型变轻了也更容易解释了。这个就是调参真正的价值所在——不只是提升准确率更是让模型变得更可控。6.3 将决策树作为随机森林的起点热词里有关“随机森林和决策树区别”的搜索量很高这里的区分点值得多讲几句。随机森林本质上是训练多棵决策树通过某种方式让树与树之间尽量不同最后用集成投票的方式做预测。这里的“不同”关键在两点一是每棵树用训练数据的自助采样bootstrap得到不同的子集二是每次分裂时限制只能在随机选择的部分特征里挑最优分裂而不是从全部特征里挑。前者叫样本扰动后者叫特征扰动。两重扰动保证了树与树之间相关性很低集成的效果才会好。单棵决策树的方差通常很大表现在训练集上就是微小的扰动可能导致整棵树结构完全不同预测结果跟着大幅波动。随机森林通过平均掉多棵树的偏差把方差压下来整体泛化能力自然更强。所以随机森林几乎总是比单棵决策树表现更好这也是为什么在一个项目里我用调参后的单棵决策树作为快速理解数据的工具但最终模型往往交给随机森林或者梯度提升树。6.4 使用决策树时容易忽略的细节几个实操中的细节值得专门提出来。第一个是特征离散与连续的分类阈值。决策树对异常值不太敏感因为它本质上是基于排序寻找分裂点极端数值不会像线性模型那样直接影响回归系数。这既是优点也是隐患如果特征分布严重偏斜分裂阈值可能会集中在一小段范围内影响模型稳定性。第二个是类别不平衡。决策树在计算基尼系数时对多数类有天然偏向。如果发现正例极少优先做class_weightbalanced设置再考虑过采样或欠采样不要一上来就改数据分布。第三个是随机种子。DecisionTreeClassifier里有一个random_state参数官方文档说明它用于控制特征顺序随机化时的随机性。如果你的特征之间存在数值相等的分裂候选种子不同可能导致分裂选择不同。训练时固定random_state模型结果才能稳定复现我一般在所有实验里都固定为42。第四个是特征的重要性。决策树通过累积每个特征带来的纯度下降幅度来计算特征重要性sklearn里直接用clf.feature_importances_就能取到。这个指标在特征很多的情况下可以做初步筛选但不要直接当因果解释用——树模型对相关特征的排序有偏差它给你的是“在哪些特征上分裂最有区分度”不是“这些特征和标签有因果关系”。7. 手写一个简化版ID3帮你彻底摆脱黑箱很多人用sklearn用得很熟练但内心始终有种“不踏实”的感觉。如果你也是这种状态我建议手写一个简化版ID3。不为了工程实用只为了搞懂每一行代码在做什么。实现思路不算复杂。先定义一个计算信息熵的函数再定义一个计算信息增益的函数然后递归构建树。下面用纯Python实现核心部分import numpy as np def entropy(labels): unique_labels, counts np.unique(labels, return_countsTrue) total len(labels) ent 0.0 for c in counts: p c / total ent - p * np.log2(p) if p 0 else 0 return ent def information_gain(data, labels, feature_idx, feature_values): total_ent entropy(labels) weighted_ent 0.0 unique_values, counts np.unique(data[:, feature_idx], return_countsTrue) for v, count in zip(unique_values, counts): subset_labels labels[data[:, feature_idx] v] weighted_ent (count / len(data)) * entropy(subset_labels) return total_ent - weighted_ent def build_tree(data, labels, feature_names, used_featuresNone): used_features used_features or [] if len(set(labels)) 1: return labels[0] if len(used_features) data.shape[1] or len(data) 0: unique_labels, counts np.unique(labels, return_countsTrue) return unique_labels[np.argmax(counts)] best_gain -np.inf best_feature None for idx, name in enumerate(feature_names): if idx in used_features: continue gain information_gain(data, labels, idx, np.unique(data[:, idx])) if gain best_gain: best_gain gain best_feature idx tree {feature_names[best_feature]: {}} used_features.append(best_feature) for value in np.unique(data[:, best_feature]): subset_mask data[:, best_feature] value subset_data data[subset_mask] subset_labels labels[subset_mask] if len(subset_labels) 0: continue tree[feature_names[best_feature]][value] build_tree( subset_data, subset_labels, feature_names, used_features.copy() ) return tree这段代码里有一个细节值得注意递归调用时我传的是used_features.copy()。如果直接传同一个列表那么某一层选择过的特征会在后续所有兄弟分支里永久生效树的构建逻辑就完全错了。这个bug当年我调了很久才定位到。手写一遍之后你再回去看sklearn文档里的参数理解深度会完全不一样。比如min_samples_leaf本质上就是在build_tree递归时判断子集样本数是否小于阈值max_depth就是在递归时判断当前深度是否达到上限。明白这些底层逻辑之后调参不再是一个机械的试错过程。8. 学习路线上的几点个人的体会最后顺着机器学习这条线走一段。决策树真正的价值不只是作为一个分类器它还是理解其他更复杂模型的桥梁。随机森林是决策树的集成梯度提升树是决策树的另一种加法集成方式XGBoost、LightGBM、CatBoost这些工业界霸主底层几乎都是决策树。先搞懂了一张简单表格如何被递归划分再去理解CART树、学习率、残差拟合这些概念的时候会顺很多。我在学习和带人的过程中最常用的一条路线是花一两天把决策树原理吃透包括熵、信息增益、基尼系数、剪枝逻辑然后动手用sklearn跑一份小数据亲手观察树是怎么分裂的再尝试手写一个简化版ID3最后用GridSearchCV做一次完整的调参流程把每个参数对模型结构的影响记下来。走完这条路线之后随机森林、GBDT、XGBoost的官方文档基本能直接看懂不需要再看一遍入门教程。如果期末复习时间紧建议盯住这几个点信息熵和信息增益的计算题、C4.5的增益率和CART的基尼系数推导、剪枝策略的对比、决策树和随机森林的联系与区别。这四个点覆盖了绝大部分的决策树考法而且每一个都能用本文里的例子直接套用。真正的上手过程就是这样先从理解一个简单的分裂规则开始再把数据放进去跑把树画出来把参数调一遍逐步建立模型从数据到结构的完整感觉。决策树作为机器学习入门的“第一课”名副其实它简单到足够理解又深刻到连接后续所有模型。
返回列表