资讯动态

从零手写决策树:信息熵、基尼系数与决策边界可视化实战

发布时间:2026/10/14 16:23:34 来源:尧图企业网站定制
1. 决策树到底在解决什么问题决策树这个算法我刚开始学机器学习的时候觉得它特别土——不就是一堆if-else堆起来的判断逻辑吗但真正在项目里用过几轮之后才发现越是简单直观的模型越能在实际场景中发挥出意想不到的价值。它不像神经网络那样需要调参调到怀疑人生也不像支持向量机那样对核函数选择极其敏感。决策树的核心思想非常朴素通过一系列是或否的问题一步步把数据切分到足够纯净的子集里。这篇文章适合谁看如果你已经了解机器学习的基本概念知道什么是特征、标签、训练集和测试集但还没真正动手实现过一个完整算法那决策树是最好的练手项目。它涉及的数学不深主要是信息熵和基尼系数这两个指标但代码实现过程中能让你真正理解模型到底在学什么这件事。另外如果你已经用过sklearn的DecisionTreeClassifier但对其内部机制一知半解这篇文章也会帮你把那些黑盒打开看看。决策树能做什么分类和回归都行。分类场景比如根据用户的年龄、消费频次、客单价判断是否会复购根据天气、温度、湿度决定要不要去打羽毛球。回归场景比如根据房屋面积、地段、楼层预测房价。它的输出不是概率值而是一条条从根节点到叶子节点的路径每条路径就是一条决策规则。这个特性在需要解释性的场景里特别吃香——银行风控、医疗辅助诊断这些领域往往要求模型给出为什么。我打算从三个层面来讲第一把决策树的数学原理拆开揉碎让你明白它凭什么这么切分数据第二手写一个完整的决策树实现不依赖sklearn从零开始第三用matplotlib把决策边界画出来直观感受模型到底学到了什么。整个过程我会穿插自己踩过的坑和实际项目中的经验尽量让你少走弯路。2. 核心原理拆解信息熵、信息增益与基尼系数2.1 信息熵衡量数据混乱程度的尺子决策树每次切分数据的时候都要回答一个问题用哪个特征来切最好这个最好需要一个量化标准信息熵就是最常用的那个。熵这个概念来自信息论用来衡量一个系统的不确定性。熵越大数据越混乱越难预测熵越小数据越纯净越容易预测。用公式表示对于有K个类别的数据集D信息熵的定义是H(D) -Σ(p_k * log2(p_k))其中p_k是第k类样本在D中占的比例。我举个具体例子你就明白了。假设一个数据集有10个样本5个正例5个负例那么p_正0.5p_负0.5熵就是-(0.5log2(0.5) 0.5log2(0.5)) 1。这是二分类问题中熵的最大值代表数据完全混乱跟抛硬币一样没法预测。如果10个样本里9个正例1个负例p_正0.9p_负0.1熵就是-(0.9log2(0.9) 0.1log2(0.1)) ≈ 0.469。数据明显更纯净了熵也降下来了。如果10个全是正例p_正1p_负0熵就是0。数据完全纯净没有任何不确定性。注意计算熵的时候如果某个类别的概率为0log2(0)在数学上是负无穷但实际代码中要约定0*log2(0)0因为极限是0。这个细节在写代码时一定要处理否则会报错或者得到nan。2.2 信息增益切分前后熵的差值知道了怎么衡量数据的混乱程度接下来就要看用某个特征切分之后混乱程度降低了多少。这个降低的量就叫信息增益。信息增益 切分前的熵 - 切分后各子集熵的加权平均。加权平均的权重是每个子集的样本数占总样本数的比例。公式写出来是Gain(D, A) H(D) - Σ(|D_v|/|D|) * H(D_v)其中A是用于切分的特征D_v是特征A取值为v的样本子集。我拿一个经典例子来算一遍。假设有14个样本目标是判断是否去打网球特征有天气晴、阴、雨。14个样本中9个去、5个不去切分前的熵是H(D) -(9/14)*log2(9/14) - (5/14)*log2(5/14) ≈ 0.940按天气切分后晴天5个样本2去3不去熵 -(2/5)*log2(2/5) - (3/5)*log2(3/5) ≈ 0.971阴天4个样本4去0不去熵 0雨天5个样本3去2不去熵 -(3/5)*log2(3/5) - (2/5)*log2(2/5) ≈ 0.971加权平均熵 (5/14)*0.971 (4/14)*0 (5/14)*0.971 ≈ 0.694信息增益 0.940 - 0.694 0.246这就是天气这个特征带来的信息增益。决策树在每一步会选择信息增益最大的特征来切分这就是ID3算法的核心逻辑。2.3 基尼系数另一种衡量纯度的方式信息增益有个问题它偏向于取值较多的特征。极端情况下如果有个特征是样本ID每个样本的ID都不同按它切分后每个子集只有一个样本熵全是0信息增益达到最大但这个切分毫无意义。C4.5算法用信息增益率来修正这个问题而CART算法则直接换了一个指标——基尼系数。基尼系数的定义是Gini(D) 1 - Σ(p_k^2)还是用刚才的例子切分前的基尼系数 1 - (9/14)^2 - (5/14)^2 ≈ 0.459。按天气切分后晴天1 - (2/5)^2 - (3/5)^2 0.48阴天1 - (4/4)^2 - 0 0雨天1 - (3/5)^2 - (2/5)^2 0.48加权平均基尼 (5/14)*0.48 (4/14)*0 (5/14)*0.48 ≈ 0.343基尼系数越小数据越纯。CART算法选择使基尼系数最小的特征来切分。相比信息熵基尼系数的计算不需要对数运算速度更快这也是sklearn默认使用基尼系数的原因。指标公式特点适用算法信息熵-Σ(p_k * log2(p_k))需要对数运算对不纯度敏感ID3信息增益H(D) - Σ(|D_v|/|D|)*H(D_v)偏向取值多的特征ID3信息增益率信息增益 / 特征固有值修正了信息增益的偏向C4.5基尼系数1 - Σ(p_k^2)无对数运算计算快CART2.4 决策树的生长与剪枝决策树构建是一个递归过程选择最优特征切分数据对每个子集重复这个过程直到满足停止条件。停止条件通常有三种子集已经纯净所有样本同一类别、没有特征可用了、或者达到了预设的最大深度。但这样长出来的树往往过拟合——它在训练集上表现完美但测试集上一塌糊涂。原因很简单树太深了把训练数据中的噪声也学进去了。解决办法是剪枝。剪枝分两种预剪枝和后剪枝。预剪枝是在树生长过程中提前停止比如限制最大深度、要求每个叶子节点最少包含多少个样本、要求信息增益至少达到某个阈值。后剪枝是先让树完全生长然后自底向上地考察每个子树如果把它替换成叶子节点能提升验证集准确率就剪掉。实操心得在实际项目中我通常先用预剪枝快速跑一版看看大概的深度和叶子节点数量然后再用后剪枝代价复杂度剪枝精细调整。sklearn的DecisionTreeClassifier提供了ccp_alpha参数来做后剪枝调这个参数比调max_depth更有效因为它不是一刀切而是根据子树对整体损失的贡献来决定剪不剪。3. 从零手写决策树代码实现与逐行解析3.1 数据结构设计与工具函数在动手写代码之前先想清楚数据结构。决策树本质上是一个嵌套的字典结构每个节点要么是叶子节点存储类别标签要么是内部节点存储切分特征、切分阈值、左子树、右子树。我定义两个类Node表示树节点DecisionTree表示整个树。Node包含feature_idx切分特征索引、threshold切分阈值、left、right、value叶子节点的类别。DecisionTree包含根节点和一系列方法。先写几个工具函数。计算基尼系数的函数import numpy as np def gini(y): if len(y) 0: return 0 _, counts np.unique(y, return_countsTrue) probs counts / len(y) return 1 - np.sum(probs ** 2)计算信息熵的函数def entropy(y): if len(y) 0: return 0 _, counts np.unique(y, return_countsTrue) probs counts / len(y) return -np.sum(probs * np.log2(probs 1e-10))注意这里加了1e-10防止log2(0)的问题。虽然理论上概率为0时该项贡献为0但代码里直接算会出问题。3.2 最优切分点的搜索逻辑对于连续特征切分点通常是排序后相邻两个值的中点。对于离散特征如果取值不多可以直接按每个取值切分如果取值很多也可以当作连续特征处理。搜索最优切分的逻辑是遍历每个特征对每个候选切分点计算切分后的加权不纯度选最小的那个。def best_split(X, y, criteriongini): best_gain -1 best_feature None best_threshold None n_features X.shape[1] base_score gini(y) if criterion gini else entropy(y) for feat_idx in range(n_features): thresholds np.unique(X[:, feat_idx]) if len(thresholds) 1: continue # 取相邻值的中点作为候选切分点 candidates (thresholds[:-1] thresholds[1:]) / 2 for thresh in candidates: left_mask X[:, feat_idx] thresh right_mask ~left_mask if np.sum(left_mask) 0 or np.sum(right_mask) 0: continue y_left, y_right y[left_mask], y[right_mask] if criterion gini: score (len(y_left) * gini(y_left) len(y_right) * gini(y_right)) / len(y) else: score (len(y_left) * entropy(y_left) len(y_right) * entropy(y_right)) / len(y) gain base_score - score if gain best_gain: best_gain gain best_feature feat_idx best_threshold thresh return best_feature, best_threshold, best_gain这段代码有个性能问题每次都要遍历所有特征的所有候选切分点数据量大时会很慢。实际项目中我会用向量化操作加速但作为教学版本这样写最清晰。注意候选切分点取相邻值的中点而不是直接用特征值是为了避免切分点恰好等于某个样本值导致边界模糊。比如特征值有1、2、3取1.5和2.5作为候选这样切分后左右子集不会重叠。3.3 递归构建树的完整流程有了最优切分函数构建树就是一个递归过程class Node: def __init__(self, featureNone, thresholdNone, leftNone, rightNone, valueNone): self.feature feature self.threshold threshold self.left left self.right right self.value value # 叶子节点的类别 class DecisionTree: def __init__(self, max_depth10, min_samples_split2, min_samples_leaf1, criteriongini): self.max_depth max_depth self.min_samples_split min_samples_split self.min_samples_leaf min_samples_leaf self.criterion criterion self.root None def fit(self, X, y): self.root self._grow(X, y, depth0) def _grow(self, X, y, depth): n_samples, n_features X.shape n_classes len(np.unique(y)) # 停止条件 if (depth self.max_depth or n_classes 1 or n_samples self.min_samples_split): return Node(valueself._majority(y)) feat, thresh, gain best_split(X, y, self.criterion) if feat is None or gain 0: return Node(valueself._majority(y)) left_mask X[:, feat] thresh right_mask ~left_mask if (np.sum(left_mask) self.min_samples_leaf or np.sum(right_mask) self.min_samples_leaf): return Node(valueself._majority(y)) left self._grow(X[left_mask], y[left_mask], depth 1) right self._grow(X[right_mask], y[right_mask], depth 1) return Node(featurefeat, thresholdthresh, leftleft, rightright) def _majority(self, y): values, counts np.unique(y, return_countsTrue) return values[np.argmax(counts)] def predict(self, X): return np.array([self._predict_one(x, self.root) for x in X]) def _predict_one(self, x, node): if node.value is not None: return node.value if x[node.feature] node.threshold: return self._predict_one(x, node.left) return self._predict_one(x, node.right)这个实现虽然简单但已经包含了决策树的所有核心要素递归生长、停止条件、多数投票。你可以用它跑iris数据集准确率大概在95%左右跟sklearn的默认配置差不多。3.4 与sklearn实现的对比验证为了验证手写版本的正确性我拿iris数据集做了对比from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.tree import DecisionTreeClassifier from sklearn.metrics import accuracy_score iris load_iris() X_train, X_test, y_train, y_test train_test_split( iris.data, iris.target, test_size0.3, random_state42) # 手写版本 my_tree DecisionTree(max_depth5, criteriongini) my_tree.fit(X_train, y_train) my_pred my_tree.predict(X_test) # sklearn版本 sk_tree DecisionTreeClassifier(max_depth5, criteriongini, random_state42) sk_tree.fit(X_train, y_train) sk_pred sk_tree.predict(X_test) print(f手写版本准确率: {accuracy_score(y_test, my_pred):.4f}) print(fsklearn版本准确率: {accuracy_score(y_test, sk_pred):.4f})实测下来两者准确率非常接近差异主要来自切分点选择的细微不同。sklearn在连续特征上做了优化会预先对特征排序并缓存搜索效率更高。另外sklearn还支持样本权重、类别权重等高级功能这些在生产环境中很有用。对比项手写版本sklearn版本切分搜索遍历所有候选点排序后优化搜索缺失值处理不支持支持样本权重不支持支持剪枝仅预剪枝预剪枝后剪枝速度慢快可解释性完全透明需要额外工具4. 决策边界绘制让模型看得见4.1 决策边界的本质与绘制思路决策边界是模型在特征空间中的划分线。对于二维特征的数据决策边界就是一条曲线或折线把平面分成不同的区域每个区域对应一个类别。决策树因为切分都是轴对齐的即沿着某个特征维度垂直切分所以它的决策边界是由一系列水平线和垂直线组成的阶梯状折线。绘制决策边界的思路很直接在特征空间上生成密集的网格点对每个点用模型预测类别然后用不同颜色填充。这样就能直观看到模型是怎么划分空间的。import matplotlib.pyplot as plt from matplotlib.colors import ListedColormap def plot_decision_boundary(model, X, y, titleDecision Boundary): # 生成网格 x_min, x_max X[:, 0].min() - 0.5, X[:, 0].max() 0.5 y_min, y_max X[:, 1].min() - 0.5, X[:, 1].max() 0.5 h 0.02 # 网格步长 xx, yy np.meshgrid(np.arange(x_min, x_max, h), np.arange(y_min, y_max, h)) # 预测网格点 grid_points np.c_[xx.ravel(), yy.ravel()] Z model.predict(grid_points) Z Z.reshape(xx.shape) # 绘制 cmap_light ListedColormap([#FFAAAA, #AAFFAA, #AAAAFF]) cmap_bold ListedColormap([#FF0000, #00FF00, #0000FF]) plt.figure(figsize(10, 6)) plt.contourf(xx, yy, Z, alpha0.4, cmapcmap_light) plt.scatter(X[:, 0], X[:, 1], cy, cmapcmap_bold, edgecolork, s30) plt.title(title) plt.xlabel(Feature 1) plt.ylabel(Feature 2) plt.show()这段代码的关键是meshgrid生成网格然后对每个网格点预测。步长h越小边界越精细但计算量越大。0.02是个比较平衡的选择画出来的图足够清晰计算也不会太慢。4.2 不同深度下的决策边界对比决策树的深度直接决定了决策边界的复杂度。我用一个二维的合成数据集来演示from sklearn.datasets import make_classification X, y make_classification(n_samples300, n_features2, n_informative2, n_redundant0, n_clusters_per_class1, random_state42) fig, axes plt.subplots(1, 3, figsize(18, 5)) for ax, depth in zip(axes, [1, 3, 10]): tree DecisionTree(max_depthdepth) tree.fit(X, y) x_min, x_max X[:, 0].min() - 0.5, X[:, 0].max() 0.5 y_min, y_max X[:, 1].min() - 0.5, X[:, 1].max() 0.5 xx, yy np.meshgrid(np.arange(x_min, x_max, 0.02), np.arange(y_min, y_max, 0.02)) Z tree.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape) ax.contourf(xx, yy, Z, alpha0.4, cmapRdYlBu) ax.scatter(X[:, 0], X[:, 1], cy, cmapRdYlBu, edgecolork, s20) ax.set_title(fMax Depth {depth}) plt.tight_layout() plt.show()深度为1时决策边界就是一条直线一个切分点模型只能做最简单的划分欠拟合明显。深度为3时边界变成阶梯状能捕捉到数据的主要分布模式。深度为10时边界变得非常碎出现了很多小区域这就是过拟合的典型表现——模型把训练数据中的噪声也当成了真实模式。实操心得看决策边界是诊断过拟合最快的方法。如果边界出现很多孤立的小岛或锯齿基本可以确定是过拟合了。这时候优先考虑增加min_samples_leaf而不是减小max_depth。因为min_samples_leaf直接限制了叶子节点的最小样本数能有效防止模型为个别样本单独划区域。4.3 决策边界的解读与模型诊断决策边界不仅能看还能读出很多信息。比如边界的方向告诉你哪些特征更重要——如果边界主要是垂直的说明第一个特征在起主要作用如果边界是阶梯状的说明两个特征都在被使用。另外边界上的样本点就是那些难分的样本它们离决策边界很近预测结果容易受噪声影响。在实际项目中我会特别关注这些边界附近的样本看看它们是不是标注错误或者特征缺失。还有一个技巧把训练集和测试集的决策边界画在一起对比。如果训练集的边界很复杂但测试集的边界很简单说明模型在训练集上过拟合了。如果两者都很简单说明模型欠拟合。理想情况是两者复杂度接近且测试集准确率跟训练集差不多。5. 常见问题与排查技巧实录5.1 决策树过拟合了怎么办这是最常见的问题。表现是训练集准确率接近100%测试集准确率低很多。排查思路按优先级来第一检查max_depth。默认不限制深度的话树会一直长到每个叶子节点只有一个样本。先设一个合理的值比如5到10看看效果。第二调min_samples_leaf。这个参数比max_depth更精细它直接控制叶子节点的最小样本数。我一般从5开始试逐步增加到20观察验证集准确率的变化。第三用ccp_alpha做后剪枝。这个参数需要交叉验证来选但效果通常比预剪枝好。sklearn提供了cost_complexity_pruning_path来帮你找候选值。第四检查特征。如果特征里有ID类的唯一标识或者跟标签高度相关的泄漏特征树会抓住这些特征过拟合。把这类特征删掉再试。5.2 决策树欠拟合了怎么办欠拟合的表现是训练集和测试集准确率都低。这时候要反着来增加max_depth减小min_samples_leaf减小min_samples_split。如果还是不行可能是特征不够需要做特征工程构造更多有区分度的特征。还有一种可能是数据本身就不适合用决策树。决策树的决策边界是轴对齐的如果真实边界是斜的比如y x这条线决策树需要用很多阶梯去逼近效率很低。这种情况下换模型可能更合适。5.3 特征重要性怎么解读sklearn的feature_importances_属性给出的是每个特征对不纯度降低的贡献总和归一化到0到1之间。但要注意这个重要性是基于训练集的如果模型过拟合了重要性也会失真。我通常会把特征重要性跟业务知识对照。如果模型认为最重要的特征跟业务直觉不符要么是数据有问题要么是发现了新的模式。两种情况都值得深挖。另外特征重要性对相关性高的特征会分摊重要性。比如两个特征高度相关模型可能随机选一个来切分导致两个特征的重要性都不高。这时候需要用permutation importance来交叉验证。5.4 决策树需要特征缩放吗不需要。决策树是基于阈值切分的特征的尺度不影响切分点的选择。比如特征值从[0, 1]缩放到[0, 100]切分点也会等比例缩放树的形状完全一样。这是决策树相比SVM和KNN的一个优势——省去了标准化这一步。但要注意如果特征里有异常值决策树会受影响。因为切分点可能被异常值拉到极端位置。这种情况下先做异常值处理或者用分位数变换把特征映射到均匀分布效果会更好。问题表现排查方向解决方案过拟合训练集准确率高测试集低树太深、叶子样本太少减小max_depth增大min_samples_leaf后剪枝欠拟合训练集和测试集都低树太浅、特征不足增大max_depth特征工程特征重要性失真重要性排序与业务不符特征相关性高、过拟合用permutation importance检查数据泄漏预测不稳定同一数据多次训练结果差异大数据量少、噪声大增加数据集成多棵树6. 从单棵树到集成模型决策树的进阶方向单棵决策树虽然直观但在实际项目中很少单独使用。原因很简单方差太大。数据稍微变一点树的结构可能完全不一样。解决办法是集成——把多棵树组合起来。Bagging的思路是有放回地采样多份数据每份数据训练一棵树然后投票。随机森林在此基础上更进一步每次切分时只随机考虑一部分特征进一步降低树之间的相关性。这两个方法都能显著降低方差提升泛化能力。Boosting的思路不同它是一棵树一棵树地串行训练每棵新树专注于修正前面模型的错误。梯度提升树和它的高效实现是结构化数据竞赛中的常胜将军。但集成模型牺牲了可解释性。随机森林你没法画出一棵完整的树只能看特征重要性。所以如果业务要求模型必须可解释单棵决策树仍然是首选。我的经验是先跑一版随机森林看上限再用单棵决策树去逼近那个上限同时保持可解释性。决策树还有一个常被忽略的用途特征离散化。用一棵浅层决策树对连续特征做分箱比等频分箱、等距分箱更合理因为切分点是基于标签信息选的。这个技巧在风控评分卡模型中很常用。代码实现层面如果你想继续深入我建议做这几件事给手写版本加上后剪枝、支持缺失值处理、实现多分类的基尼系数计算。这几个改动都不大但能让你对决策树的理解上一个台阶。另外把决策边界的绘制封装成一个通用函数以后换其他模型也能直接用省得每次重写。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑