
简介面向北京邮电大学自动化专业机器学习课程实验的决策树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, featureNone, thresholdNone, leftNone, rightNone, labelNone): self.feature feature self.threshold threshold self.left left self.right right self.label label def build_tree(X, y, max_depth3, min_leaf1, depth0): if len(np.unique(y)) 1 or depth max_depth or len(y) min_leaf: return Tree(labelCounter(y).most_common(1)[0][0]) split, gain best_split(X, y) if split is None or gain 1e-6: return Tree(labelCounter(y).most_common(1)[0][0]) f, th split left, right split_continuous(X, y, f, th) return Tree( featuref, thresholdth, leftbuild_tree(X[left], y[left], max_depth, min_leaf, depth 1), rightbuild_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, depth0): 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_yTrue) 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_depth3) 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_leafmin_leaf默认1表示不限制叶子大小真实实验建议改成3到5。第四预测时从根节点一路比较阈值落到叶子后返回多数类别。3.3 跑通最小示例用鸢尾花验证手写树在实验目录下执行python decision_tree.py终端会输出训练准确率、测试准确率和一棵可读的规则树。鸢尾花数据集上max_depth3时训练准确率大概在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_size0.3, random_state42, stratifyy )stratifyy的含义是让训练集和测试集中各类别的比例与原数据集保持一致。对于三分类的鸢尾花数据这个参数影响不大但如果是故障检测这类负样本很少的实验漏掉它会让测试结果完全失真。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_state42), param_grid, cv5, scoringaccuracy, ) 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_state42) path base.cost_complexity_pruning_path(X_train, y_train) alphas path.ccp_alphas for alpha in alphas: t DecisionTreeClassifier(random_state42, ccp_alphaalpha) 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_, filledTrue, feature_namesfeature_names, class_namesclass_names, ) plt.savefig(decision_tree.pdf, bbox_inchestight) ConfusionMatrixDisplay.from_estimator( grid.best_estimator_, X_test, y_test, cmapBlues ) plt.savefig(confusion_matrix.pdf, bbox_inchestight)导出时一定要用bbox_inchestight否则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_weightbalanced让少数类在计算基尼指数时获得更高权重评估指标改用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))把这份排序表放进实验报告的结论部分直接回答“哪个传感器对分类影响最大”这个问题。决策树允许两个特征高度相关时随机选择一个参与分裂所以跑两次排序可能会有细微差别报告里注明“该重要度来自单次固定随机种子”即可。本文还有配套的精品资源点击获取