简介:面向北京邮电大学自动化专业机器学习课程实验的决策树Python实现,适合正在学习监督学习与分类模型的本科学生参考。代码围绕决策树完整流程展开:先完成数据加载与预处理、缺失值与分类变量处理,再通过信息增益或基尼不纯度进行特征选择,随后借助scikit-learn构建决策树分类器,并完成训练、预测与交叉验证评估;同时包含准确率、精确率、召回率、F1分数等指标,以及可视化与剪枝调参,帮助理解决策树原理与工程调优方法。资源共1个py源码文件,压缩包仅1KB,轻量便携,便于在课程实验或自学场景中直接查看与二次修改。目前已有1055人学习下载,适合课程设计、期末复习及机器学习入门实操者快速借鉴。
1. 机器学习课程实验为什么从决策树开始
北邮自动化的机器学习课程实验,排在最前面的往往不是神经网络,而是决策树。原因很直接:它不需要推导梯度,不需要设置学习率,却能把“特征选择—训练—过拟合—评估”这条完整流程一次性走通。自动化专业平时接触的是状态方程、传递函数和PID参数,面对决策树时会发现它是另一套逻辑——模型不是写成显式方程,而是从数据里自己长出一组if-else规则。作为机器学习分类器里最经典的一支,吴恩达和李宏毅的公开课都会把决策树放在集成学习之前单独讲。本文顺着课程实验的实际交付物来写:信息熵怎么算、决策树怎么用Python从零写出来、sklearn里哪些参数值得调,以及实验报告里最容易丢分的三个细节。
2. 从信息熵到信息增益:决策树的分裂标准先手算一遍
2.1 信息熵:课程实验里第一个要算的指标
一个数据集越“纯”,分错的概率就越低。信息熵是量化这种纯度的标准方式。对于二分类问题,假设正样本占比为p,负样本占比为1-p,熵的定义是:
H(D) = -p·log2(p) - (1-p)·log2(1-p)
如果正负样本各占一半,熵等于1,这是最“混乱”的状态;如果所有样本都属于同一类,熵等于0。多分类问题把求和符号展开即可。课程实验里写代码时不需要从头实现log运算,但理解这个公式能帮你判断后面打印出来的每个节点熵值是否合理。
自动化同学容易在这里犯一个错:把熵当成某个特征自带的东西。实际上熵是“整个集合”的属性,不是特征的属性。一个特征好不好,要看的是用这个特征切分后,各个子集熵的加权和比原来的熵低了多少,这个差值才是信息增益。
2.2 信息增益、增益率与基尼指数:三种分裂标准的取舍
决策树发展这么多年,主流分裂标准就三种。课程实验报告里如果只写了“用信息增益选特征”,等于只讲了ID3的思路;scikit-learn默认用的是CART,默认的criterion是gini。三种标准的对比如下:
| 标准 | 核心思想 | 特点 | 代表算法 |
|---|---|---|---|
| 信息增益 | H(D) - H(D|A) | 偏好取值多的特征 | ID3 |
| 增益率 | 信息增益 / 固有值 | 抑制多取值特征 | C4.5 |
| 基尼指数 | 1 - Σ p_k² | 计算开销小,天然二叉树 | CART |
信息增益的问题是:一个特征取值越多,切分后子集越纯,增益虚高。最极端的例子是给每条样本一个随机ID作为特征,拿它分裂能把每个样本单独分到一个节点,熵降到0,但这种分裂毫无意义。C4.5用增益率除以“固有值”来惩罚多取值,CART则干脆改用基尼指数。scikit-learn的DecisionTreeClassifier把gini和entropy都实现了,但不管选哪个,建出来的树都是二叉树。
2.3 手算一次信息增益:为什么第一层先选天气
我用一个14条的小数据集说明计算过程。特征是天气(晴/阴/雨)、温度(高/中/低)、湿度(高/正常)、有风(是/否),标签是适不适合打球。
| 编号 | 天气 | 温度 | 湿度 | 有风 | 打球 |
|---|---|---|---|---|---|
| 1 | 晴 | 高 | 高 | 否 | 否 |
| 2 | 晴 | 高 | 高 | 是 | 否 |
| 3 | 阴 | 高 | 高 | 否 | 是 |
| 4 | 雨 | 中 | 高 | 否 | 是 |
| 5 | 雨 | 低 | 正常 | 否 | 是 |
| 6 | 雨 | 低 | 正常 | 是 | 否 |
| 7 | 阴 | 低 | 正常 | 是 | 是 |
| 8 | 晴 | 中 | 高 | 否 | 否 |
| 9 | 晴 | 低 | 正常 | 否 | 是 |
| 10 | 雨 | 中 | 正常 | 否 | 是 |
| 11 | 晴 | 中 | 正常 | 是 | 是 |
| 12 | 阴 | 中 | 高 | 是 | 是 |
| 13 | 阴 | 高 | 正常 | 否 | 是 |
| 14 | 雨 | 中 | 高 | 是 | 否 |
14条记录里9个“是”、5个“否”,根节点的熵是0.940。按天气划分后三个子集分别是:晴5条(2是3否)、阴4条(4是0否)、雨5条(3是2否)。三个子集的熵分别约为0.971、0、0.971,加权条件熵就是0.694。信息增益为0.940减0.694,约0.246。
同样方法算另外三个特征:温度增益约0.029,湿度增益约0.151,有风增益约0.048。天气的增益最大,所以第一层分裂特征必须是天气。这个手算过程就是课程实验里“解释为什么这么选特征”的完整论据。
3. 用Python从零实现决策树:从信息熵到递归建树
3.1 先决定数据结构:离散特征还是连续特征
自动化课程实验的数据大多是传感器读数,特征是连续的浮点数。传统ID3按离散取值多叉分裂,到连续值上就失效了。所以我习惯直接实现CART风格:每次切分只把数据分成左、右两部分,连续特征取相邻值中点作为候选阈值,离散特征则按“等于/不等于”处理。这样写出来的树与sklearn的DecisionTreeClassifier结构一致,后面调参时两种实现可以互相印证。
3.2 用Python手写决策树核心代码:熵、切分点与递归生成
下面这段代码可以整体存成decision_tree.py直接运行。它包含五个部分:计算熵、找最佳切分点、递归建树、预测、打印树结构。
import math import numpy as np from collections import Counter def entropy_labels(y): n = len(y) if n == 0: return 0.0 c = Counter(y) ent = 0.0 for cnt in c.values(): p = cnt / n ent -= p * math.log2(p) return ent def split_continuous(X, y, feat_idx, threshold): left = X[:, feat_idx] <= threshold return left, ~left def best_split(X, y): n_samples, n_feats = X.shape base_ent = entropy_labels(y) best_gain, best = 0.0, None for f in range(n_feats): col = np.unique(X[:, f]) if len(col) < 2: continue thresholds = (col[:-1] + col[1:]) / 2.0 for th in thresholds: left, right = split_continuous(X, y, f, th) if left.sum() == 0 or right.sum() == 0: continue ent_after = ( left.sum() / n_samples * entropy_labels(y[left]) + right.sum() / n_samples * entropy_labels(y[right]) ) gain = base_ent - ent_after if gain > best_gain: best_gain, best = gain, (f, th) return best, best_gain class Tree: def __init__(self, feature=None, threshold=None, left=None, right=None, label=None): self.feature = feature self.threshold = threshold self.left = left self.right = right self.label = label def build_tree(X, y, max_depth=3, min_leaf=1, depth=0): if len(np.unique(y)) == 1 or depth >= max_depth or len(y) < min_leaf: return Tree(label=Counter(y).most_common(1)[0][0]) split, gain = best_split(X, y) if split is None or gain < 1e-6: return Tree(label=Counter(y).most_common(1)[0][0]) f, th = split left, right = split_continuous(X, y, f, th) return Tree( feature=f, threshold=th, left=build_tree(X[left], y[left], max_depth, min_leaf, depth + 1), right=build_tree(X[right], y[right], max_depth, min_leaf, depth + 1), ) def predict_one(node, x): if node.label is not None: return node.label if x[node.feature] <= node.threshold: return predict_one(node.left, x) return predict_one(node.right, x) def predict(tree, X): return np.array([predict_one(tree, x) for x in X]) def print_tree(node, feature_names, depth=0): if node.label is not None: print(" " * depth + "-> class {}".format(node.label)) return print(" " * depth + "if {} <= {:.2f}".format(feature_names[node.feature], node.threshold)) print_tree(node.left, feature_names, depth + 1) print(" " * depth + "else:") print_tree(node.right, feature_names, depth + 1) if __name__ == "__main__": try: from sklearn.datasets import load_iris X, y = load_iris(return_X_y=True) except ImportError: rng = np.random.default_rng(42) X = rng.uniform(0, 5, size=(150, 2)) y = (X[:, 0] + X[:, 1] > 5).astype(int) X_train, X_test = X[:120], X[120:] y_train, y_test = y[:120], y[120:] tree = build_tree(X_train, y_train, max_depth=3) print("train acc:", (predict(tree, X_train) == y_train).mean()) print("test acc:", (predict(tree, X_test) == y_test).mean()) print_tree(tree, ["sepal_len", "sepal_wid", "petal_len", "petal_wid"])代码逻辑按四条线拆开看。第一,entropy_labels只接收一维标签数组,内部用Counter统计类别频次,避免手工写累加循环出错。第二,best_split对每个连续特征取所有相邻唯一值的中点作为候选阈值,这是C4.5处理连续特征的经典做法,课程实验报告里可以直接写“阈值取相邻样本的中点,遍历所有特征找信息增益最大点”。第三,build_tree的终止条件有三个:类别全纯、达到最大深度、样本数少于min_leaf;min_leaf默认1表示不限制叶子大小,真实实验建议改成3到5。第四,预测时从根节点一路比较阈值,落到叶子后返回多数类别。
3.3 跑通最小示例:用鸢尾花验证手写树
在实验目录下执行python decision_tree.py,终端会输出训练准确率、测试准确率和一棵可读的规则树。鸢尾花数据集上,max_depth=3时训练准确率大概在0.97左右,测试集准确率略低,树的结构类似:
if petal_len <= 2.45: -> class 0 else: if petal_wid <= 1.75: -> class 1 else: -> class 2这个结果说明一件事:决策树确实能从数据里自动找到关键特征。鸢尾花的分类只需要花瓣长度和花瓣宽度两个特征,花萼的两个特征完全没有进入第一层。这是手写树和sklearn结果一致的地方,也是实验报告里“模型可解释性”的直观证据。
4. 课程实验工程化:sklearn决策树调参与泛化分析
4.1 数据集划分为什么必须分层
手写树在120条训练样本上表现稳定,但课程实验的数据集往往没这么大。使用train_test_split时有两个必须注意的点:一是random_state要固定,否则每次跑结果都变,实验报告没法写;二是stratify参数要按标签类别比例分层抽样。自动化实验里的正负样本经常不平衡,如果不分层,测试集可能只包含一种类别,准确率虚高。
from sklearn.model_selection import train_test_split X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y )stratify=y的含义是让训练集和测试集中各类别的比例与原数据集保持一致。对于三分类的鸢尾花数据,这个参数影响不大;但如果是故障检测这类负样本很少的实验,漏掉它会让测试结果完全失真。
4.2 决策树必调的五个超参数
默认参数的DecisionTreeClassifier在训练集上表现几乎完美,在测试集上经常表现一般,这是过拟合的典型信号。课程实验里需要调的参数集中在下面这张表:
| 超参数 | 作用 | 常用起点 | 主要坑 |
|---|---|---|---|
| criterion | 分裂标准 | gini | 讲原理时用entropy做对比即可,不改变树结构的大方向 |
| max_depth | 最大深度 | 3~6 | 默认None会一直分裂到叶子纯,极易过拟合 |
| min_samples_split | 内部节点最少样本数 | 10~20 | 设1等于关闭该限制 |
| min_samples_leaf | 叶子最少样本数 | 3~5 | 防止叶子只剩一个样本 |
| ccp_alpha | 最小成本复杂度剪枝 | 从剪枝路径里选 | 数值过大会把树剪成只剩根节点 |
调参顺序也有讲究。我一般先固定max_depth,把树打到3到5层;然后用min_samples_leaf控制叶子大小;最后再看是否需要ccp_alpha做进一步剪枝。criterion不太需要反复换,它对最终准确率的影响通常小于深度和叶子大小。
4.3 用GridSearchCV和ccp_alpha做剪枝:代码与结果
网格搜索是课程实验最省事的选参方式。把候选参数写成一个字典,GridSearchCV会在训练集上做5折交叉验证,自动挑出平均得分最高的一组参数。
from sklearn.model_selection import GridSearchCV from sklearn.tree import DecisionTreeClassifier param_grid = { "criterion": ["gini", "entropy"], "max_depth": [2, 3, 4, 5, 8], "min_samples_leaf": [1, 2, 4, 8], } grid = GridSearchCV( DecisionTreeClassifier(random_state=42), param_grid, cv=5, scoring="accuracy", ) grid.fit(X_train, y_train) print(grid.best_params_) print(grid.score(X_test, y_test))网格搜索得到的是离散候选点里的最优组合。想继续压缩树的规模,可以走cost complexity pruning路径,把ccp_alpha当成连续变量扫一遍:
base = DecisionTreeClassifier(random_state=42) path = base.cost_complexity_pruning_path(X_train, y_train) alphas = path.ccp_alphas for alpha in alphas: t = DecisionTreeClassifier(random_state=42, ccp_alpha=alpha) t.fit(X_train, y_train) print(round(alpha, 5), round(t.score(X_test, y_test), 4), t.get_depth())观察输出中测试准确率随ccp_alpha的变化,准确率不再上升的那个点就是合适的剪枝强度。网格搜索和剪枝两个步骤加在一起,正好对应实验报告里的“模型选择”和“模型简化”两节。
4.4 把树和混淆矩阵导出成PDF
实验报告要交可视化结果。plot_tree可以画整棵树并导出PDF,混淆矩阵用于展示分类错误集中在哪些类别之间。
import matplotlib.pyplot as plt from sklearn.tree import plot_tree from sklearn.metrics import ConfusionMatrixDisplay plt.figure(figsize=(12, 6)) plot_tree( grid.best_estimator_, filled=True, feature_names=feature_names, class_names=class_names, ) plt.savefig("decision_tree.pdf", bbox_inches="tight") ConfusionMatrixDisplay.from_estimator( grid.best_estimator_, X_test, y_test, cmap="Blues" ) plt.savefig("confusion_matrix.pdf", bbox_inches="tight")导出时一定要用bbox_inches="tight",否则pdf四周会留大量白边。画树之前先用一次grid.best_estimator_的get_depth确认树的深度,超过6层就缩小max_depth,否则画出来的图字体挤成一团,老师看不清楚。
5. 离散特征、不平衡数据与特征重要性:课程实验的加分细节
5.1 离散特征不能直接照搬连续特征代码
第3章的代码只处理了连续特征。如果课程实验数据里有挡位、模式、指令类型这类离散特征,直接套用会出错。连续特征判断的是x <= threshold,离散特征应该判断x == value。用sklearn训练时内部会自动处理混合类型,但手写代码阶段需要加一个分支判断:
if isinstance(threshold, str): left = X[:, feat_idx] == threshold else: left = X[:, feat_idx] <= threshold还要注意高基数问题:一个离散特征如果有几十个取值,决策树会倾向于优先选它做分裂,因为多取值切分会制造出更多纯度高的子集。实验报告里如果发现树的第一层被一个取值极多的离散特征霸占,可以考虑把它改成数值编码,或者直接用C4.5的增益率思想做惩罚。
5.2 类别不均衡时别只报accuracy
故障检测、缺陷识别这类自动化实验里,负样本往往只占5%到10%。如果树把所有样本都判成正样本,准确率依然有90%以上,但模型毫无价值。遇到这种情况,先给DecisionTreeClassifier加上class_weight="balanced",让少数类在计算基尼指数时获得更高权重;评估指标改用classification_report里的precision、recall和F1-score,单独看少数类的召回率。
5.3 用feature_importances_给传感器通道排序
sklearn的决策树在拟合后会自动计算feature_importances_,本质是每个特征在所有节点上带来的基尼下降量归一化。自动化实验里传感器通道可能有三四十个,用树筛选出真正有用的通道再去做后续分析,比一上来就把所有通道灌进模型要省很多事:
for name, imp in zip(feature_names, grid.best_estimator_.feature_importances_): print(name, round(imp, 4))把这份排序表放进实验报告的结论部分,直接回答“哪个传感器对分类影响最大”这个问题。决策树允许两个特征高度相关时随机选择一个参与分裂,所以跑两次排序可能会有细微差别,报告里注明“该重要度来自单次固定随机种子”即可。
本文还有配套的精品资源,点击获取