简介:对应周志华《机器学习》(西瓜书)第四章决策树的学习需求,这份代码压缩包将信息熵与基尼指数两种划分选择算法完整落地为可运行脚本。包内共9个文件,包含5个csv数据文件(西瓜数据集2.0、3.0以及iris、adult-stretch等UCI数据)和4个py脚本,分别实现基于信息熵的决策树生成、基于基尼指数的CART构建、预剪枝与后剪枝对比,以及决策树可视化绘图。压缩包整体仅16KB,轻量便携,适合边看教材边逐行调试。已有10322人在学习下载,代码可直接复现西瓜数据集3.0上的决策树,也能在2.0数据集上完成剪枝效果比较;进一步利用4个UCI数据集进行统计显著性检验,能有效帮助读者理解特征选择偏好、剪枝策略与模型泛化能力之间的深层联系,是巩固机器学习基础的良好帮手。
1. 西瓜书第四章的决策树,为什么值得你用Python亲手写一遍
很多人学《机器学习》第四章,读的时候觉得决策树就那点事:选特征、递归、剪枝。但真到机器学习期末或面试,被问到"信息增益为什么偏好取值多的特征""预剪枝和后剪枝的验证集精度怎么算",就卡壳了。根源是只看了公式,没写过代码。决策树是西瓜书里最适合手写的模型:数据集小、逻辑直白、只用 numpy 就能实现一个能跑的分类器,而且和 sklearn 的 DecisionTreeClassifier 在行为上完全对得上,顺带还能解释清楚随机森林和决策树区别。这篇按"信息熵 → 建树 → 剪枝 → 调包避坑"的顺序,把所有关键代码落在西瓜书数据集上,适合正在复习期末、想搞懂头歌决策树任务、或者准备在 vscode 里跑第一个机器学习模型的人。
2. 信息熵、信息增益与基尼指数:先写出三个核心函数
2.1 信息熵为什么能衡量"纯度"
决策树的每一次分裂,目标都是让划分后的子集更"纯"。纯度的数学度量是信息熵——样本越乱,熵越高;样本类别越集中,熵越低。设样本类别分布为 p1, p2, ..., pk,熵的定义是 Ent(D) = -Σ pk·log2(pk)。
先写熵函数,它是后面所有选特征计算的地基:
import numpy as np def entropy(y): """计算标签向量 y 的信息熵。 参数 y: 一维 numpy 数组,元素是类别标签。 返回: 熵值(比特)。所有样本同一类时返回 0.0。 """ _, counts = np.unique(y, return_counts=True) probs = counts / len(y) return -np.sum(probs * np.log2(probs))逻辑说明:np.unique 的 return_counts=True 同时返回类别列表和每种出现次数,counts / len(y) 把频数转成概率。只有一类时 probs=[1.0],结果就是 0;类别越均衡,结果越接近 log2(k),k 是类别数。
参数说明:y 必须是 numpy 一维数组。如果是从 pandas 的 DataFrame 里取的一列,建议先 .values 转换,否则很多教科书代码里的布尔索引写法在 Series 上语义不一样,会多出莫名其妙的索引对齐问题。
信息熵的取值范围是 [0, log2(k)],二分类时上限是 1 比特。"抛硬币"就是最混乱的状态,熵为 1。这个度量会贯穿第四章:决策树每选一个特征划分,本质上都是在用特征消除一部分不确定性。
有个常见疑问:为什么不用错误率当纯度度量?因为错误率对分布变化不敏感。一个子集里好瓜从 60% 变到 70%,错误率从 40% 降到 30%,是线性变化;而熵的变化是非线性的,越接近纯粹,再提升一点纯度带来的信息量越大。决策树要的是快速切到纯,熵在这个目标上比错误率敏锐得多。
2.2 信息增益:ID3 的选特征依据
信息增益衡量的是"知道这个特征之后,不确定性下降了多少":Gain(D, a) = Ent(D) - Σ (|Dv| / |D|) · Ent(Dv)。
def info_gain(X, y, feature_idx): """按第 feature_idx 列特征划分后的信息增益。 X: 二维特征矩阵,字符串或数值都可以 y: 一维标签数组 """ base = entropy(y) values = np.unique(X[:, feature_idx]) weighted = 0.0 for v in values: subset_y = y[X[:, feature_idx] == v] weighted += len(subset_y) / len(y) * entropy(subset_y) return base - weighted逻辑说明:循环内用布尔索引把特征取值等于 v 的样本筛出来,逐个子集算熵,再按样本占比加权。返回值就是划分前后的熵差,ID3 每次选增益最大的特征来分裂。
注意信息增益的系统性偏好:它偏爱取值多的特征。因为取值多,子集就小,子集熵容易趋近 0,增益虚高。把"编号"这个特征放进西瓜数据集计算,它的增益一定最大,因为每个编号只对应一个样本,划分后熵为 0。这是决策树实现里第一个要留意的坑,C4.5 引入增益率就是为了治它。
2.3 增益率与基尼指数:C4.5 与 CART 的修正
增益率在信息增益上除以"固有值"。固有值按特征取值的分布算权重,取值越多越均匀,固有值越大,对增益的惩罚越狠。
def gain_ratio(X, y, feature_idx): """C4.5 的增益率:信息增益 / 固有值。IV 只与特征取值分布有关,与标签无关。""" values = np.unique(X[:, feature_idx]) iv = 0.0 n = len(X) for v in values: prob = np.sum(X[:, feature_idx] == v) / n if prob > 0: iv -= prob * np.log2(prob) gain = info_gain(X, y, feature_idx) return gain / iv if iv > 0 else 0.0逻辑说明:iv 的计算公式和熵长得一样,但它统计的是"特征取值分布"而不是标签分布。分母为 0 时(所有样本同一个值)直接返回 0,防止除零错误。
基尼指数是另一条路线,不用对数,直接算"随机抽两个样本、类别不同的概率":
def gini(y): """基尼值:1 - Σ pk^2。越小越纯,CART 选划分时找基尼指数最小的特征。""" _, counts = np.unique(y, return_counts=True) probs = counts / len(y) return 1 - np.sum(probs ** 2)三种选特征标准的对比:
| 方法 | 选择标准 | 对取值多特征的偏好 | 计算量 |
|---|---|---|---|
| ID3 | 信息增益最大 | 强 | 中,含 log2 |
| C4.5 | 增益率最大 | 中等,受固有值惩罚 | 大,还要算 IV |
| CART | 基尼指数最小 | 弱 | 小,无对数 |
增益率也有缺陷:固有值可能很小,导致增益率爆炸式增长。C4.5 的做法是先筛出信息增益高于平均水平的特征,再从中挑增益率最大的,这个细节在期末题里经常出现。
实践感受是:手写学习用 ID3 帮助理解原理,实际调包用 gini。sklearn 的 DecisionTreeClassifier 默认 criterion='gini',在几万样本上计算明显快于 entropy,而且二者的精度差距通常可以忽略。
3. 从零手写ID3决策树:在西瓜数据集上跑通分类
3.1 准备数据:构造西瓜书数据集
西瓜书第四章最经典的例子是"好瓜判定",17 条数据,前六列是特征,最后一列是标签。先把它打进去:
import numpy as np import pandas as pd data = [ ['青绿', '蜷缩', '浊响', '清晰', '凹陷', '硬滑', '是'], ['乌黑', '蜷缩', '沉闷', '清晰', '凹陷', '硬滑', '是'], ['乌黑', '蜷缩', '浊响', '清晰', '凹陷', '硬滑', '是'], ['青绿', '蜷缩', '沉闷', '清晰', '凹陷', '硬滑', '是'], ['浅白', '蜷缩', '浊响', '清晰', '凹陷', '硬滑', '是'], ['青绿', '稍蜷', '浊响', '清晰', '稍凹', '软粘', '是'], ['乌黑', '稍蜷', '浊响', '稍糊', '稍凹', '软粘', '是'], ['乌黑', '稍蜷', '浊响', '清晰', '稍凹', '硬滑', '是'], ['乌黑', '稍蜷', '沉闷', '稍糊', '稍凹', '硬滑', '否'], ['青绿', '硬挺', '清脆', '清晰', '平坦', '软粘', '否'], ['浅白', '硬挺', '清脆', '模糊', '平坦', '硬滑', '否'], ['浅白', '蜷缩', '浊响', '模糊', '平坦', '软粘', '否'], ['青绿', '稍蜷', '浊响', '稍糊', '凹陷', '硬滑', '否'], ['浅白', '稍蜷', '沉闷', '稍糊', '凹陷', '硬滑', '否'], ['乌黑', '稍蜷', '浊响', '清晰', '稍凹', '软粘', '否'], ['浅白', '蜷缩', '浊响', '模糊', '平坦', '硬滑', '否'], ['青绿', '蜷缩', '沉闷', '模糊', '平坦', '硬滑', '否'], ] df = pd.DataFrame(data, columns=['色泽', '根蒂', '敲声', '纹理', '脐部', '触感', '好瓜']) X = df.iloc[:, :-1].values y = df.iloc[:, -1].values参数说明:特征全部是字符串,numpy 可以直接对字符串数组做比较和 np.unique。如果想把特征换成 0/1/2 数字编码,手写树的逻辑完全不用改,只是后面打印树结构时不直观。我跑实验时习惯保留原始字符串,方便和书上的树对答案。
3.2 递归建树:特征选择与停止条件
建树前的辅助函数是多数表决,当子集不再分裂时,用出现最多的类别当叶子:
def majority_vote(y): """简单多数表决:返回出现次数最多的类别""" classes, counts = np.unique(y, return_counts=True) return classes[np.argmax(counts)]核心建树逻辑写在类里,递归调用。停止条件有三个:当前子集全同类、可用特征用完、子集样本过少。
class ID3Tree: def __init__(self): self.tree = None self.feature_names = None def fit(self, X, y, feature_names): self.feature_names = feature_names self.tree = self._build(X, y, list(range(X.shape[1]))) def _build(self, X, y, available_features): # 条件1:子集样本全属于同一类别,直接返回该类别 if len(np.unique(y)) == 1: return y[0] # 条件2:没有可用特征了,返回多数表决结果 if len(available_features) == 0: return majority_vote(y) # 条件3:计算每个可用特征的信息增益,选最大者 gains = [info_gain(X, y, f) for f in available_features] best_idx = available_features[np.argmax(gains)] best_name = self.feature_names[best_idx] node = {best_name: {}} for v in np.unique(X[:, best_idx]): mask = X[:, best_idx] == v if np.sum(mask) == 0: continue child_features = [f for f in available_features if f != best_idx] node[best_name][v] = self._build(X[mask], y[mask], child_features) return node def predict_one(self, x, node): """沿树走到叶子。node 是字符串就返回,是字典就继续往下。""" if not isinstance(node, dict): return node feature_name = list(node.keys())[0] feature_idx = list(self.feature_names).index(feature_name) value = x[feature_idx] child = node[feature_name].get(value) if child is None: return None # 训练时没见过这个取值,返回空,交由上层处理 return self.predict_one(x, child) def predict(self, X): return [self.predict_one(x, self.tree) for x in X]逻辑说明:树用嵌套字典表示,比如 {'纹理': {'清晰': '是', '模糊': '否', '稍糊': {...}}}。predict_one 每一步都取当前节点的特征名,找到样本里对应的值,再往下一层走。遇到训练时没见过的取值,get 返回 None,这是手写树一个明显的边界点。
注意三个停止条件的顺序是有讲究的:先判纯,再判特征耗尽,否则对一个已经纯的子集继续选特征会得到零增益,白白浪费时间。条件3里每次递归都重新算一次所有特征的信息增益,17 条数据没问题,几万行数据就会很慢。优化方向是预先缓存每个特征取值对应的样本索引,但初学者不建议一上来就做这个优化,会把可读性毁掉。
3.3 跑通与可视化:看一眼树长什么样
训练、预测、打印结构三步走:
tree = ID3Tree() tree.fit(X, y, list(df.columns[:-1])) # 预测前几个样本 print(tree.predict(X[:3])) # 应该输出 ['是', '是', '是'] # 用 json 美化打印树结构 import json print(json.dumps(tree.tree, ensure_ascii=False, indent=2))输出的树是一个多层嵌套字典,第一层大概率是"纹理"——西瓜书里手工推演的结果,根节点就是纹理。这就是信息增益计算在真实数据上的复现。
这个手写模型不依赖任何机器学习库,只需要 numpy 和 pandas 就能在 vscode 里配好的 python 环境里直接跑。跑通之后你会自然理解:决策树的本质是把特征空间切成若干矩形区域,每个区域给一个类别标签;而"背答案"就是说这些矩形切得太细,每个训练样本独占一格。
4. 预剪枝与后剪枝:让决策树从"背答案"变成"会推理"
4.1 为什么必须剪枝:训练精度和泛化精度的跷跷板
不剪枝的决策树会一直分裂到每个叶子只剩一个或同类样本,训练精度 100%,但测试时稍微换个样本就翻车,这是典型的过拟合。西瓜书的思路是引入验证集:一个节点该不该继续分,要看分完后验证集精度是否提升。
工程上最常见的预剪枝手段其实不是验证集,而是直接限制树的生长参数。这个方案简单、可复现效果好,也是 sklearn 里默认需要你手动设置的:
class LimitedDepthID3: """带最大深度和最小叶子样本数限制的 ID3,等价于预剪枝。""" def __init__(self, max_depth=3, min_samples_leaf=2): self.tree = None self.feature_names = None self.max_depth = max_depth self.min_samples_leaf = min_samples_leaf def fit(self, X, y, feature_names): self.feature_names = feature_names self.tree = self._build(X, y, list(range(X.shape[1])), depth=0) def _build(self, X, y, available_features, depth): if len(np.unique(y)) == 1 or len(available_features) == 0: return majority_vote(y) if depth >= self.max_depth or len(y) <= self.min_samples_leaf: return majority_vote(y) gains = [info_gain(X, y, f) for f in available_features] best_idx = available_features[np.argmax(gains)] node = {self.feature_names[best_idx]: {}} for v in np.unique(X[:, best_idx]): mask = X[:, best_idx] == v if np.sum(mask) > 0: child_features = [f for f in available_features if f != best_idx] node[self.feature_names[best_idx]][v] = self._build( X[mask], y[mask], child_features, depth + 1 ) return node参数说明:max_depth 是树的最大深度,depth 从根节点 0 开始累加;min_samples_leaf 是当前子集样本数下限,低于这个值就不再分裂。这两个参数是最常用的预剪枝手段,效果稳定,不会像验证集版本那样受数据划分影响太大。
4.2 用验证集评估的预剪枝:书上的标准做法
书上的预剪枝是每选一个特征,都要先比较"不分"和"分"的验证集精度。把这一步抽成独立函数:
def should_split_by_val(X_sub, y_sub, X_val, y_val, feature_idx): """预剪枝判断:特征 feature_idx 分裂后,验证集精度是否提升。 返回 True 表示可以分裂,False 表示剪掉,当前节点变成多数类叶子。 """ leaf_pred = majority_vote(y_sub) acc_leaf = np.mean(leaf_pred == y_val) # 模拟一层分裂:每个分支直接给多数类标签 temp_tree = {} for v in np.unique(X_sub[:, feature_idx]): mask = X_sub[:, feature_idx] == v if np.sum(mask) > 0: temp_tree[v] = majority_vote(y_sub[mask]) preds = [] for x in X_val: v = x[feature_idx] preds.append(temp_tree.get(v, leaf_pred)) acc_split = np.mean(np.array(preds) == y_val) return acc_split > acc_leaf逻辑说明:这个函数的关键是"模拟一层分裂",它不递归建完整树,而是只看这一个特征切完之后验证集上的表现。预剪枝追求的是局部决策快,真要每个节点都递归建完再评估,计算量会爆炸。acc_split 不大于 acc_leaf 时返回 False,在递归建树函数里遇到 False 就直接返回 majority_vote(y_sub),不再往下分。
注意这里的验证集精度是一个近似,因为真正递归分裂后每层还会有后续划分,效果可能比"一层就停"更好,也可能更差。工程上为了省时间普遍接受这个近似,数据量小且对精度敏感时再考虑完整递归评估。
4.3 后剪枝:先生长,再回退
后剪枝的思路是先把树完全长出来,然后自底向上考察每个内部节点:把该节点的子树替换成多数类叶子,如果验证集精度不下降,就保留替换。核心动作只有三个:替换、评估、回退。
def collect_leaf_labels(node): """收集子树里所有叶子标签,用于近似该节点的多数类。""" if not isinstance(node, dict): return [node] labels = [] feature_name = list(node.keys())[0] for child in node[feature_name].values(): labels.extend(collect_leaf_labels(child)) return labels def try_prune_by_val(tree, X_val, y_val, predict_fn): """自底向上扫描内部节点,能剪就剪。 tree: 完整建好的树(嵌套字典) predict_fn: 接收一棵树,返回在验证集上的预测数组 """ acc_before = np.mean(predict_fn(tree) == y_val) def visit(node): if not isinstance(node, dict): return feature_name = list(node.keys())[0] for child in node[feature_name].values(): if isinstance(child, dict): visit(child) # 到这步时,子树已经全部处理完。评估当前节点能否剪掉。 labels = collect_leaf_labels(node) leaf_label = majority_vote(np.array(labels)) if labels else None saved = node[feature_name] node[feature_name] = leaf_label # 临时替换成叶子 acc_new = np.mean(predict_fn(tree) == y_val) if acc_new >= acc_before: pass # 验证集精度不下降,保留剪枝 else: node[feature_name] = saved # 回退 visit(tree) return np.mean(predict_fn(tree) == y_val)逻辑说明:visit 先递归处理子节点,保证剪枝是自底向上的。每个内部节点都会经历一次"替换成叶子 → 重新预测 → 精度不降就保留"的流程。collect_leaf_labels 用子树里的叶子标签做多数表决,是对该节点训练集多数类的一个近似,这也是后剪枝实现里唯一不精确的地方。
生产环境不推荐自己写后剪枝,直接用 sklearn 的代价复杂度剪枝更可靠,一行参数就能完成:
from sklearn.tree import DecisionTreeClassifier clf = DecisionTreeClassifier(ccp_alpha=0.01) # ccp_alpha 越大,剪得越狠4.4 预剪枝和后剪枝怎么选:一个实用结论
实践里,小数据集(几百行内)用后剪枝更稳,预剪枝容易剪过头导致欠拟合;大数据集(上万行)用预剪枝更划算,省时间。sklearn 里的 max_depth 和 min_samples_leaf 都是预剪枝,ccp_alpha 是做后剪枝。初学阶段建议三种都跑一遍——不剪、预剪、后剪,打印三个测试集精度,你会亲眼看到"训练精度高不等于测试精度高"这件事,比读十遍书都管用。
5. 决策树代码实现的五个坑:手写和调包都躲不过
5.1 坑一:连续特征直接送进手写树,信息增益虚高
现象:把西瓜数据里的"密度""含糖率"这类连续特征直接传给 info_gain,np.unique 会列出一堆浮点数,每个取值只对应一两个样本,加权熵趋近于 0,这个特征的增益接近 1,必定被选中。
原因:ID3 的信息增益天然偏好取值多的特征,连续特征取值几乎每个样本一个,增益虚高是系统性偏差。
解决:连续特征先离散化。常见做法是二分法:把取值排序,相邻两点的中点当成候选切分点,找信息增益最大的阈值。
def best_split_for_continuous(X, y, feature_idx): """为连续特征找最佳二分点,返回 (阈值, 增益)""" values = np.unique(X[:, feature_idx]) if len(values) < 2: return None, 0.0 base = entropy(y) best_gain = 0.0 best_threshold = None for i in range(len(values) - 1): threshold = (values[i] + values[i + 1]) / 2 left_y = y[X[:, feature_idx] <= threshold] right_y = y[X[:, feature_idx] > threshold] gain = base - (len(left_y) / len(y) * entropy(left_y) + len(right_y) / len(y) * entropy(right_y)) if gain > best_gain: best_gain = gain best_threshold = threshold return best_threshold, best_gain逻辑说明:外层循环遍历所有相邻取值的中点,每次都计算二分后的加权熵。返回的阈值可以作为新特征参与建树,也可以按西瓜书的方式在节点里同时记录特征名和阈值。一个连续特征在树的不同分支可能被反复选中,每次选的阈值不同,这是正常现象。
5.2 坑二:特征有缺失值,手写树直接翻车
现象:数据里某个特征有空值,np.unique 会把 NaN 当成一个特殊取值,照样生成分支,但预测时新样本没有 NaN,永远走不到那条路径。
原因:缺失值处理在西瓜书里是一整节内容,需要按"有值的样本先算增益,再按权重把缺失样本分到各个子节点"。手写代码时如果完全忽略,树的结构就会带病生长。
解决:初学阶段先别贪心地实现完整版缺失值处理,把缺失行删掉或用众数填充,等树跑通再升级。
def info_gain_with_missing_filled(X, y, feature_idx): """先用众数填充缺失值,再算信息增益。""" col = X[:, feature_idx].copy() mask = pd.isna(col) if mask.any(): vals, counts = np.unique(col[~mask], return_counts=True) col[mask] = vals[np.argmax(counts)] base = entropy(y) weighted = 0.0 for v in np.unique(col): subset_y = y[col == v] weighted += len(subset_y) / len(y) * entropy(subset_y) return base - weighted逻辑说明:pd.isna 能同时识别 None 和 NaN,比用 col == None 靠谱得多。众数填充保留了该特征的整体分布,不会像删除缺失行那样损失样本。sklearn 的树内部有自己处理缺失值的策略,但手写时这一步必须显式做,否则行为不可控。
5.3 坑三:sklearn 默认参数直接跑,树深到没法看
现象:DecisionTreeClassifier 不设 max_depth 直接 fit,树深十几层,plot_tree 画出来全是细枝末节,测试集精度反而不如剪枝后的浅树。
原因:sklearn 默认不限制树生长,只要求每个叶子至少一个样本。这是标准过拟合。
解决:先网格搜索 max_depth 和 min_samples_leaf。我的经验值是 max_depth 从 3 开始试,min_samples_leaf 从 5 开始试,小数据集上效果立竿见影。暴力验证一版就能感受到差别:
from sklearn.model_selection import GridSearchCV from sklearn.tree import DecisionTreeClassifier clf = DecisionTreeClassifier(random_state=42) params = {'max_depth': [3, 5, 7], 'min_samples_leaf': [3, 5, 10]} gs = GridSearchCV(clf, params, cv=5, scoring='roc_auc') gs.fit(X_train, y_train) print(gs.best_params_)参数说明:GridSearchCV 的 cv=5 做五折交叉验证,scoring='roc_auc' 比 accuracy 更能反映二分类的真实表现。best_params_ 会直接给出当前数据上最优的预剪枝参数组合。
5.4 坑四:把类别特征直接喂给 sklearn 的树
现象:色泽 = ["青绿", "乌黑", "浅白"],用 LabelEncoder 编码成 0,1,2 后喂给 DecisionTreeClassifier,树会按大小关系找切分点,比如"色泽 <= 1",这对无序类别没有物理意义。
原因:sklearn 的树只支持数值特征,任何整数都会被当作连续值处理。整数编码让无序类别产生了虚假的顺序关系。
解决:用 pandas 的 get_dummies 做 OneHot 编码:
df_encoded = pd.get_dummies(df, columns=['色泽', '根蒂', '敲声', '纹理', '脐部', '触感'])逻辑说明:get_dummies 把每个类别值变成一列 0/1,树就可以对"是否等于青绿"做判断。注意 OneHot 会把特征维度撑大,但决策树对高维稀疏并不像线性模型那样敏感,不需要额外做特征选择。如果特征类别特别多(几百个),可以先做频数编码再进树,那是另一个话题。
5.5 坑五:用 accuracy 衡量一棵树,被不平衡数据骗
现象:好瓜和坏瓜比例 8:2,树全预测"是",accuracy 照样有 80%。看起来效果不错,实际对"否"这一类毫无分辨能力。
原因:准确率对多数类不敏感,任何分类模型在不平衡数据上都会被 accuracy 骗,决策树尤其明显,因为熵和基尼都受样本占比影响。
解决:二分类看 AUC,多分类看 macro-F1,类别不平衡时给少数类加权重:
clf = DecisionTreeClassifier(class_weight='balanced', random_state=42)逻辑说明:class_weight='balanced' 会根据类别频率自动调权,少数类样本的误差被放大,树会更努力地给它分对。但根本解法是把评价指标和业务目标对齐——如果业务关心的是"把坏瓜挑出来",那就只看少数类的召回率和 F1,而不是整体准确率。
6. 决策树落地:参数、可视化与回归逼近的最后一块拼图
6.1 真正值得调的参数:就这四个
| 参数 | 作用 | 经验值 |
|---|---|---|
| criterion | gini 或 entropy | 小数据用 entropy 方便解释,大数据用 gini 更快 |
| max_depth | 最大深度 | 3~8,先小后大做网格搜索 |
| min_samples_leaf | 叶子最少样本数 | 5 起步,防止叶子过细 |
| class_weight | 类别权重 | 不平衡数据设 balanced |
6.2 一个最小实验,同时验证分类和回归逼近
from sklearn.datasets import load_iris from sklearn.tree import DecisionTreeClassifier, DecisionTreeRegressor, export_text from sklearn.model_selection import train_test_split X, y = load_iris(return_X_y=True) X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=42, stratify=y) clf = DecisionTreeClassifier(criterion='entropy', max_depth=3, min_samples_leaf=5) clf.fit(X_tr, y_tr) print(clf.score(X_te, y_te)) print(export_text(clf, feature_names=load_iris().feature_names))export_text 打印的就是一棵能直接读的树,比嵌套字典清晰得多。类似的任务在头歌的决策树收入预测、鸢尾花分类里都能这样跑,换数据不换流程。
想看"决策树如何逼近真实曲线",把分类器换成 DecisionTreeRegressor,对 sin 函数拟合:
import numpy as np rng = np.random.RandomState(42) x = np.sort(5 * rng.rand(100, 1), axis=0) y = np.sin(x).ravel() + 0.1 * rng.randn(100) reg = DecisionTreeRegressor(max_depth=4).fit(x, y) x_test = np.linspace(0, 5, 300)[:, None] import matplotlib.pyplot as plt plt.plot(x_test, np.sin(x_test), label='真实曲线') plt.plot(x_test, reg.predict(x_test), label='决策树预测') plt.legend() plt.show()把 max_depth 从 2 调到 10,你会看到预测曲线从几段台阶变成密集锯齿,这就是回归树的偏差-方差窗口。
我的习惯是任何数据先跑一棵 max_depth=4 的树,出基线再决定要不要上随机森林——随机森林对数据扰动抗性强,但可解释性明显下降;业务上要讲清"为什么拒绝这笔申请"时还是得回到单棵树。这个习惯让我躲过很多次"一上来调 XGBoost 调一周、不如一棵树"的尴尬,希望帮到你。
本文还有配套的精品资源,点击获取