10 决策树与随机森林入门:从规则判断到集成学习

前言

逻辑回归通过线性得分和概率完成分类,K 近邻算法根据附近样本投票。决策树采用一种更接近日常条件判断的思路:

复制代码
如果振动值较高:
    如果设备温度也较高:
        判断为高风险
    否则:
        继续检查其他条件
否则:
    判断为低风险

实际模型中的条件并不是人工逐条写入。决策树会从训练数据中自动寻找用于判断的特征和阈值,再把一系列判断连接成树形结构。

本篇用代码生成 1200 条模拟设备运行数据,根据温度、振动、压力、连续运行时间和距离上次维护的天数,预测一个教学用的"故障风险"标签。

决策树是什么

决策树(Decision Tree)是一种通过不断提出条件问题,把样本逐步划分到不同分支的监督学习模型。分类树希望每次划分后,同一子节点中的样本类别更加集中,最终让叶节点尽量以某一类别为主。

根节点

根节点是整棵树开始的位置,包含全部训练样本。模型会在这里寻找第一个判断条件。

内部节点

内部节点表示模型提出的条件,例如:

复制代码
vibration <= 5.4

条件左边是真实特征,右边是模型从训练数据中选出的阈值。

分支

分支是条件为真或假后进入的不同路径。同一个样本会沿着唯一一条路径继续向下。

叶节点

叶节点是停止继续分裂并给出预测的位置。它通常保存到达此处的样本数量、各类别数量、最终预测类别和类别概率估计。

从条件判断理解决策树

本文的特征包括温度、振动、压力、连续运行时间和距离上次维护的天数。一棵树可能学出这样的结构:

复制代码
如果 vibration > 某个阈值:
    如果 temperature > 某个阈值:
        预测模拟故障
    否则:
        根据 maintenance_days 继续判断
否则:
    预测模拟正常

模型会尝试不同特征和阈值,寻找能让子节点类别更集中的问题。这些规则必须以实际训练结果为准,不能在运行前编造。本文最终树的第一层实际使用 vibration <= 6.315,但这个数值只适用于当前代码生成的数据和固定拆分,不是设备行业阈值。

决策树如何选择分裂条件

对于一个节点中的样本,模型会尝试许多候选问题,例如 temperature <= 72.5vibration <= 4.8operating_hours <= 310。它要比较哪一个问题能让分裂后的子节点更"纯"。

分类树常用基尼不纯度或信息熵衡量混杂程度。本篇使用 criterion="gini",重点解释基尼不纯度,不展开复杂的信息论推导。

基尼不纯度是什么

如果一个节点中正常类占 100%、故障类占 0%,它已经十分纯净,基尼不纯度为 0。如果两类各占一半,节点较混杂,不纯度更高。

复制代码
Gini = 1 - Σ(pᵢ²)

pᵢ 是节点中第 i 个类别的比例。计算时,把每个类别比例平方后求和,再用 1 减去结果。二分类各占 50% 时,结果是 1 - (0.5² + 0.5²) = 0.5;全部属于同一类时,结果是 0。

决策树不会只看某个子节点,而会综合两个子节点的样本数和不纯度,选择能让总体不纯度下降较多的分裂。

一条样本怎样走过决策树

训练完成后,预测过程不再重新搜索所有分裂。新样本从根节点出发,把自己的特征值与节点阈值比较:条件成立就进入左侧分支,不成立就进入右侧分支;到下一个内部节点后继续比较,直到抵达某个叶节点。

例如最终文本规则的根节点是 vibration <= 6.315。若新样本振动值为 5.8,它先进入左侧,再比较 pressure <= 117.885;若振动值为 6.8,则进入右侧,继续检查温度或其他条件。一次预测只走一条路径,而不会把整棵树所有条件都执行一遍。

到达叶节点后,分类结果通常取叶节点中的多数类别,predict_proba 则根据该叶节点的类别比例给出概率估计。因此,同一叶节点中的所有样本会得到相同类别和相同概率。树的路径使单次预测具有一定可解释性,但当树深达到二十多层时,一条完整路径也会包含很多条件,不能再简单等同于"几条清楚规则"。

还要区分"模型规则"和"现实规则"。模型只是在当前模拟训练样本中寻找能降低不纯度的阈值。换一个随机种子、重新抽样,甚至只改变少量数据,根节点和后续阈值都可能变化。这也是单棵树稳定性有限的原因。

决策树为什么容易过拟合

如果不限制树的生长,它可以不断细分,直到叶节点只剩很少样本,甚至把训练数据逐条记住。此时训练集准确率可能很高,验证集却明显下降,树也会变得很深、规则很复杂,并对噪声十分敏感。这就是过拟合。

本文未限制树在 840 个训练样本上得到 1.0000 的准确率和 F1,但验证集准确率只有 0.6778、F1 只有 0.3095;实际深度为 20,叶节点有 157 个。训练满分没有证明模型已经学到稳定规律,反而是需要检查泛化能力的提醒。

控制决策树复杂度的常用参数

max_depth

max_depth 限制最大树深。较小的值让规则更简单、更容易解释,但可能欠拟合;数值较大或不限制时可以表达复杂关系,也更容易过拟合。

min_samples_split

min_samples_split 规定一个节点至少包含多少样本才允许继续分裂。增大它通常能减少过细划分。

min_samples_leaf

min_samples_leaf 规定叶节点至少保留多少样本。数值增大可以避免叶节点只有一两个样本,让概率和预测相对稳定,但也可能忽略局部规律。

max_features

max_features 控制每次寻找分裂时允许考虑多少特征。单棵决策树通常可以查看全部特征;随机森林会限制每棵树每次能看到的特征,增加树之间的差异。

ccp_alpha

ccp_alpha 是成本复杂度剪枝参数。数值增大时,模型会更倾向于生成简单树。本篇主代码只搜索树深和叶节点最小样本数,避免一次改变过多参数。

什么是剪枝

剪枝(Pruning)是减少决策树复杂度的方法。预剪枝是在树生长过程中设置 max_depthmin_samples_splitmin_samples_leaf 等限制;后剪枝是先得到较完整的树,再删除收益较小的分支。scikit-learn 可以通过 ccp_alpha 做成本复杂度剪枝,本篇只在概念上介绍后剪枝。

决策树需要标准化吗

普通决策树通常不需要 StandardScaler。它主要判断某个特征是否小于或大于阈值。即使把某个特征统一乘以 100,样本相对顺序通常不变,模型仍能找到对应的新阈值。随机森林也通常不要求标准化,因此本篇没有使用标准化器。

不需要标准化不等于不需要清洗。缺失值、无穷值、错误类型、不合理字段和数据泄漏仍会破坏模型。尤其不能把故障发生后才生成的维修结果、报警总结等字段作为预测特征。

本文的模拟设备数据

代码固定 random_state=42,生成 1200 个样本和 5 个特征:

  • temperature:模拟设备温度;
  • vibration:模拟振动强度;
  • pressure:模拟压力;
  • operating_hours:模拟连续运行时间;
  • maintenance_days:模拟距离上次维护的天数。

标签 fault 中,0 表示模拟正常,1 表示模拟故障。生成过程设置了温度与振动同时较高、运行时间与维护间隔同时较大、压力过低或过高等非线性及交互规律,并加入随机噪声,再通过 Sigmoid 转成概率并随机采样标签。没有任何单个特征能够把两类完全分开。

实际生成 0 类 963 个、1 类 237 个。这些关系只是为了让树模型有可学习的模式,同时保留不确定性,绝不代表真实工业规律。

检查数据

建模前可以先查看:

复制代码
data.head()
data.shape
data.isna().sum()
data.describe()
data["fault"].value_counts()

完整程序还检查了 X 与 y 样本数、列名和顺序、数值类型、无穷值、重复特征行以及标签集合。实际数据形状为 (1200, 6),特征矩阵为 (1200, 5),缺失值 0,重复特征行 0,标签恰好包含 0 和 1。

拆分训练集、验证集和测试集

程序两次调用 train_test_split:先把完整数据拆成约 70% 的训练集和 30% 临时集,再把临时集平均拆成验证集和测试集。两次都固定 random_state=42,并分别根据相应标签分层抽样。

实际样本数和类别分布是:

复制代码
训练集 840:{0: 674, 1: 166}
验证集 180:{0: 144, 1: 36}
测试集 180:{0: 145, 1: 35}

训练集用于拟合候选模型,验证集用于比较树深、叶节点样本数和随机森林方案,测试集在参数全部确定后只评估一次。本文没有根据测试结果返回修改参数。

建立分类基线

多数类基线使用:

复制代码
DummyClassifier(strategy="most_frequent")

它始终预测开发数据中数量最多的 0 类。最终测试准确率为 0.8056,看起来不低,但精确率、召回率和 F1 都是 0,混淆矩阵为 TN=145、FP=0、FN=35、TP=0。它漏掉了全部 35 个模拟故障样本,说明类别不平衡时不能只看准确率。

训练第一棵决策树

先训练一棵不限制深度的树:

复制代码
tree = DecisionTreeClassifier(
    criterion="gini",
    random_state=42,
)
tree.fit(x_train, y_train)

get_depth() 返回实际深度,get_n_leaves() 返回叶节点数。本文的未限制树深度 20、叶节点 157;训练集满分而验证 F1 只有 0.3095,展示了单看训练成绩的风险。

使用验证集选择决策树参数

代码验证 7 个 max_depth 与 5 个 min_samples_leaf,共 35 个组合。每个组合都新建模型,只在训练集训练,再记录训练 F1、验证准确率、验证 F1、实际深度和叶节点数。

max_depth min_samples_leaf 训练F1 验证准确率 验证F1 实际深度 叶节点
2 1/3/5 0.0920 0.8000 0.0526 2 4
2 10 0.1023 0.8000 0.0526 2 4
2 20 0.1390 0.8056 0.1026 2 4
3 1 0.1136 0.8000 0.0526 3 7
3 3/5 0.0920 0.8000 0.0526 3 7
3 10 0.1023 0.8000 0.0526 3 7
3 20 0.1390 0.8056 0.1026 3 7
4 1 0.1136 0.8000 0.0526 4 11
4 3 0.2021 0.8000 0.0526 4 12
4 5 0.1739 0.8000 0.0526 4 12
4 10 0.2424 0.7944 0.0976 4 12
4 20 0.1390 0.8056 0.1026 4 10
5 1 0.3269 0.7778 0.0909 5 18
5 3 0.3188 0.7889 0.1364 5 20
5 5 0.3286 0.7722 0.0889 5 21
5 10 0.3062 0.7722 0.0889 5 18
5 20 0.2319 0.8222 0.2381 5 14
6 1 0.4201 0.7389 0.0408 6 28
6 3 0.3774 0.7556 0.0435 6 30
6 5 0.3946 0.7444 0.0800 6 27
6 10 0.4417 0.7167 0.1053 6 23
6 20 0.2319 0.8222 0.2381 6 17
8 1 0.5641 0.7444 0.1481 8 52
8 3 0.4957 0.7667 0.0455 8 50
8 5 0.5259 0.7556 0.1200 8 44
8 10 0.5039 0.7222 0.1071 8 37
8 20 0.2319 0.8222 0.2381 8 23
不限制 1 1.0000 0.6778 0.3095 20 157
不限制 3 0.7657 0.7167 0.2154 16 110
不限制 5 0.6951 0.6778 0.1471 16 85
不限制 10 0.5373 0.6889 0.1250 14 55
不限制 20 0.2319 0.8222 0.2381 12 32

规则预先规定:先找验证 F1 最高值;在相差不超过 0.005 的方案中,再优先实际深度更浅、叶节点更少的树。本次验证最高 F1 0.3095 来自 max_depth=None、min_samples_leaf=1,没有其他方案进入 0.005 容差,因此最终选择它。

这恰好也是训练分数最高的树,但选择依据仍是验证集,不是训练满分。结果也提示:当前特征、噪声、类别比例和候选网格下,简单树没有取得相近的验证 F1。我们如实保留结果,而不是看完测试集后再改搜索范围。

决策树的预测概率

tree.predict_proba(X) 通常使用叶节点中的类别比例估计概率。若某叶节点含 8 个正常样本、2 个故障样本,故障概率可能是 2/10=0.2。叶节点样本很少时,概率会很粗糙;未限制树甚至可能给出大量 0 或 1。它不是自动校准过的真实故障概率。

什么是随机森林

随机森林(Random Forest)是一种集成学习(Ensemble Learning)方法。它训练许多棵有差异的决策树,再通过多数投票或平均类别概率汇总结果。

复制代码
训练数据
→ 随机抽取多份样本
→ 分别训练多棵树
→ 每棵树给出预测
→ 汇总得到最终结果

Bootstrap 抽样

Bootstrap 是有放回抽样。训练一棵树时,从训练集随机抽一个样本,放回后继续抽。某些样本可能重复出现,另一些可能没有被这棵树抽到,所以不同树看到的训练数据不同。

随机选择特征

如果每棵树每次分裂都查看全部特征,很多树可能非常相似。随机森林会在每次分裂时只查看随机选出的部分特征。它的随机性主要来自训练样本的随机抽取和分裂特征的随机选择,两者共同增加树之间的差异。

为什么多棵树可能比一棵树稳定

单棵深树容易受训练数据的小变化影响,方差较高。随机森林让多棵树分别学习,每棵树可能犯不同错误,再通过投票或概率平均减少单棵树的偶然误差。它通常是有力的表格数据基线,但并不保证在所有数据和所有指标上都优于单树,本文实际结果正好说明了这一点。

随机森林的关键参数

n_estimators 是树的数量,增加它通常让结果更稳定,也会增加训练、预测和存储成本;max_depth 限制每棵树深度;min_samples_leaf 控制叶节点最少样本;max_features 控制每次分裂可考虑的特征数量;bootstrap=True 开启有放回抽样;random_state 固定随机过程;n_jobs=-1 使用可用 CPU 核心并行。并行只影响效率,不改变算法含义。

使用验证集选择随机森林参数

本篇只验证 4 个事先写好的候选方案:

树数 max_depth min_samples_leaf max_features 验证准确率 精确率 召回率 F1
100 不限制 1 sqrt 0.8167 0.6667 0.1667 0.2667
150 6 3 sqrt 0.8111 0.7500 0.0833 0.1500
200 8 5 sqrt 0.8000 0.5000 0.0278 0.0526
150 5 5 全部特征 0.8167 0.8000 0.1111 0.1951

选择规则是优先验证 F1,在最高值 0.005 内优先树更少、限制更清楚的方案。本次最佳参数为 n_estimators=100、max_depth=None、min_samples_leaf=1、max_features="sqrt"

这里也能看到,准确率约 0.81 并不意味着故障类识别良好:最佳方案的召回率只有 0.1667。类别比例不均衡时,必须同时检查召回率和 F1。

训练最终模型

参数确定后,把训练集与验证集合并为 1020 个开发样本,并用 ignore_index=True 重建连续索引。随后创建新的多数类基线、最佳决策树和最佳随机森林,在开发集重新训练。测试集的 180 个样本没有加入训练。

最终测试与模型比较

测试集只评估一次,结果如下:

模型 准确率 精确率 召回率 F1 TN FP FN TP
多数类基线 0.8056 0.0000 0.0000 0.0000 145 0 35 0
最佳决策树 0.7056 0.2353 0.2286 0.2319 119 26 27 8
最佳随机森林 0.7778 0.0000 0.0000 0.0000 140 5 35 0

随机森林的准确率比决策树高,但没有识别出任何故障类,F1 为 0;决策树找到 8 个故障样本,也产生了 26 个假警报。这不是可部署的工业结果,而是一个诚实的教学现象:模型名称更复杂不保证当前固定拆分上的少数类指标更好,也不能只看准确率。

由于本篇是模拟数据,不能替真实系统设定漏报和误报成本。真实故障任务需要领域专家、更多数据、严格时间切分、类别不平衡处理、概率校准和安全验证。

如何理解这组不理想的测试结果

首先,测试集没有参与参数搜索,所以最终结果可能比验证结果更差。这并不说明评估流程失败,恰恰说明独立测试集提供了模型开发阶段没有看到的新证据。如果看见随机森林召回率为 0 后,立刻回去增加树、修改深度,再在同一测试集上挑最好结果,那么测试集就会逐渐变成另一个验证集。

其次,类别 1 只有 237 条,占总数据约 19.75%。模型即使偏向多数类,也能得到较高准确率。多数类基线的 0.8056 已经说明,准确率在这里很容易掩盖问题。决策树虽然总体准确率较低,却至少识别出 8 个正类;随机森林总体准确率较高,却漏掉了全部正类。究竟哪种错误更重要,需要真实业务成本定义,而本文没有资格替工业系统作出判断。

再次,本文特意加入随机噪声,使人为规则不能完美决定标签。同样的特征组合可能生成不同标签,树就可能学习到偶然差异。未限制树把训练集完全记住,正是高方差的表现。随机森林虽然通过平均降低部分方差,但默认投票仍可能偏向多数类。

正确的后续工作应该在保持本次测试结果封存的前提下重新设计实验,例如重新划分独立测试集、在训练和验证阶段研究类别权重、阈值或采样方法,并预先规定评价指标。本文到此停止调参,把负面结果完整记录下来。

查看决策树规则

程序用 export_text(..., max_depth=3) 输出最终树前几层,不生成图片:

复制代码
|--- vibration <= 6.315
|   |--- pressure <= 117.885
|   |   |--- maintenance_days <= 249.811
|   |   |   |--- operating_hours <= 337.409
|   |   |   |   |--- truncated branch of depth 21
|   |   |--- maintenance_days > 249.811
|   |   |   |--- temperature <= 77.672
|   |--- pressure > 117.885
|   |   |--- maintenance_days <= 51.871
|   |   |--- maintenance_days > 51.871
|--- vibration > 6.315
|   |--- vibration <= 6.466
|   |   |--- pressure <= 104.177
|   |   |--- pressure > 104.177
|   |--- vibration > 6.466
|   |   |--- temperature <= 80.199
|   |   |--- temperature > 80.199

最终树在合并后的开发集重新训练后,深度变为 24、叶节点为 201,所以只展示前几层。文本规则能帮助理解路径,但深树完整规则很难整体解释,截断后的规则也不能单独用于人工设备判断。

特征重要性

树模型的 feature_importances_ 表示一个特征在分裂过程中对降低节点不纯度的累计贡献。实际结果为:

特征 决策树重要性 随机森林重要性
temperature 0.182647 0.185550
vibration 0.237944 0.211497
pressure 0.185682 0.220033
operating_hours 0.184089 0.183803
maintenance_days 0.209638 0.199118

这些重要性不表示因果关系。可分裂点较多的特征可能获得偏高重要性;相关特征会分散或替代彼此贡献;随机种子和数据拆分也可能改变排名。低重要性不代表特征在所有模型中无用,更不能根据高重要性直接改变真实设备参数。

对一个新设备样本预测

代码用开发集统计量构造一条处于已有范围内的新样本。两个模型的 classes_ 都是 [0, 1],程序据此查找类别 1 的概率列,而不是假定第二列永远对应故障类。

实际结果是:决策树预测类别 0、故障概率 0.0000;随机森林预测类别 0、故障概率 0.0700。这些值只是模型对人为构造数据的输出,且未经概率校准,不能解释为真实设备安全概率。

决策树与随机森林的对比

决策树

单树规则直观,不要求标准化,能处理非线性和特征交互,训练与预测通常较快;但它容易过拟合,对数据变化敏感,树很深时规则同样会变得复杂。

随机森林

随机森林通常比单树稳定,也能处理非线性与交互,不要求标准化,是常见表格数据基线;代价是模型更大、训练预测成本更高,也不容易为每条预测给出一套简短完整规则。其不纯度重要性同样可能有偏差。

决策树和随机森林常见问题

1. 决策树训练集接近满分

这可能说明树过深并记住了训练数据。应查看验证集和最终测试集,不能把训练满分当作成功。

2. 树深度设置过小

模型表达能力不足,训练和验证表现都可能较差,这属于欠拟合。

3. 树深度设置过大

训练表现上升而验证表现下降,规则复杂且对噪声敏感。

4. 使用测试集选择参数

测试集参与开发会使最终指标偏乐观。应只用训练集和验证集选参,或使用交叉验证。

5. 特征重要性被误解

高重要性不表示特征导致结果,也不表示人为改变该特征一定能改变目标。

6. 类别不平衡

多数类别可能主导分裂和预测。本文随机森林测试 F1 为 0 就是警示。后续可考虑 class_weight="balanced"、重采样、收集更多少数类数据和更合适的指标,但不能在看完本次测试结果后继续试参数并把同一测试集当作新证据。

7. 缺失值

当前模型与版本未必能直接处理缺失值。应在训练流程中先合理填补,并确保填补器只从训练数据学习。本篇模拟数据没有缺失值。

8. 高基数类别特征

拥有大量类别的字段若被简单编码,树可能产生复杂且不稳定的分裂,需要检查字段含义并合理编码。

9. 数据泄漏

故障处理结果、维修完成状态、事后报警总结等发生在目标之后的字段不能作为预测特征。

10. 随机森林树越多不一定越值得

更多树通常提高稳定性,也增加计算和存储成本,性能提升会逐渐变小。树数量仍需通过验证和成本评估决定。

什么时候适合使用决策树

当数据规模适中、包含非线性和特征交互、希望得到条件规则、不想标准化,并且可以通过限制深度控制复杂度时,决策树适合作为基线。若数据噪声很大、要求极高稳定性、必须用很深的树才能工作,或需要严格概率校准,单树通常不理想。

什么时候适合使用随机森林

随机森林适合表格数据、非线性关系和交互明显、希望获得比单树更稳定的强基线,并且能接受更高训练、预测和存储成本的场景。若模型必须极度轻量、每条预测都要完整简短规则、数据主要是图像音频或超高维稀疏文本,或者任务需要直接因果解释,它通常不是首选。

完整实践代码

下面代码保存为 10_tree_forest.py。它不访问网络、不读取外部数据、不生成图片,不导入绘图库,也不调用 Graphviz。

复制代码
"""决策树与随机森林入门:从规则判断到集成学习"""

from __future__ import annotations

import platform
import sys
from typing import Any, Sequence

try:
    import numpy as np
    import pandas as pd
    import sklearn
    from sklearn.base import ClassifierMixin
    from sklearn.dummy import DummyClassifier
    from sklearn.ensemble import RandomForestClassifier
    from sklearn.metrics import (
        accuracy_score,
        confusion_matrix,
        f1_score,
        precision_score,
        recall_score,
    )
    from sklearn.model_selection import train_test_split
    from sklearn.tree import DecisionTreeClassifier, export_text
except ImportError as exc:
    print(f"依赖导入失败:{exc}")
    print("请确认当前 Conda 环境已安装 NumPy、Pandas 和 scikit-learn。")
    raise SystemExit(1) from exc


RANDOM_STATE = 42
SAMPLE_COUNT = 1200
FEATURE_COLUMNS = [
    "temperature",
    "vibration",
    "pressure",
    "operating_hours",
    "maintenance_days",
]
TREE_DEPTHS: list[int | None] = [2, 3, 4, 5, 6, 8, None]
TREE_LEAF_SIZES = [1, 3, 5, 10, 20]
TREE_F1_TOLERANCE = 0.005
FOREST_F1_TOLERANCE = 0.005
FOREST_CANDIDATES: list[dict[str, Any]] = [
    {
        "n_estimators": 100,
        "max_depth": None,
        "min_samples_leaf": 1,
        "max_features": "sqrt",
    },
    {
        "n_estimators": 150,
        "max_depth": 6,
        "min_samples_leaf": 3,
        "max_features": "sqrt",
    },
    {
        "n_estimators": 200,
        "max_depth": 8,
        "min_samples_leaf": 5,
        "max_features": "sqrt",
    },
    {
        "n_estimators": 150,
        "max_depth": 5,
        "min_samples_leaf": 5,
        "max_features": None,
    },
]


def check_environment() -> None:
    """输出解释器与直接依赖版本,并确认使用 Python 3。"""
    print("环境信息:")
    print(f"Python: {platform.python_version()}")
    print(f"NumPy: {np.__version__}")
    print(f"Pandas: {pd.__version__}")
    print(f"Scikit-learn: {sklearn.__version__}")
    print(f"Interpreter: {sys.executable}")
    if sys.version_info.major < 3:
        raise RuntimeError("本示例需要 Python 3。")


def sigmoid(values: np.ndarray[Any, Any]) -> np.ndarray[Any, Any]:
    """稳定地把风险分数转换到 0~1。"""
    clipped = np.clip(values, -35.0, 35.0)
    return 1.0 / (1.0 + np.exp(-clipped))


def generate_dataset(sample_count: int = SAMPLE_COUNT) -> pd.DataFrame:
    """生成包含非线性、特征交互和随机噪声的教学数据。"""
    if sample_count < 900:
        raise ValueError("教学数据至少需要 900 个样本。")

    rng = np.random.default_rng(RANDOM_STATE)
    temperature = np.clip(rng.normal(68.0, 10.0, sample_count), 35.0, 105.0)
    vibration = np.clip(rng.normal(4.3, 1.5, sample_count), 0.4, 10.0)
    pressure = np.clip(rng.normal(100.0, 12.0, sample_count), 60.0, 140.0)
    operating_hours = np.clip(
        rng.gamma(shape=3.5, scale=75.0, size=sample_count),
        15.0,
        650.0,
    )
    maintenance_days = np.clip(
        rng.gamma(shape=2.0, scale=48.0, size=sample_count),
        0.0,
        365.0,
    )

    # 交互规则与非线性规则均为人为设置,不代表真实设备规律。
    risk_score = np.full(sample_count, -2.25, dtype=float)
    risk_score += 1.55 * ((temperature > 77.0) & (vibration > 5.2))
    risk_score += 1.30 * (
        (operating_hours > 340.0) & (maintenance_days > 110.0)
    )
    risk_score += 0.95 * ((pressure < 82.0) | (pressure > 118.0))
    risk_score += 0.75 * (vibration > 6.2)
    risk_score += 0.55 * (
        (temperature > 82.0) & (maintenance_days > 90.0)
    )
    risk_score += 0.30 * np.abs(pressure - 100.0) / 15.0
    risk_score += rng.normal(0.0, 0.65, sample_count)

    fault_probability = sigmoid(risk_score)
    fault = rng.binomial(1, fault_probability, sample_count).astype(int)

    return pd.DataFrame(
        {
            "temperature": temperature,
            "vibration": vibration,
            "pressure": pressure,
            "operating_hours": operating_hours,
            "maintenance_days": maintenance_days,
            "fault": fault,
        }
    )


def class_distribution(y: pd.Series) -> dict[int, int]:
    """按类别编号排序返回样本数。"""
    counts = y.value_counts().sort_index()
    return {int(label): int(count) for label, count in counts.items()}


def validate_dataset(data: pd.DataFrame) -> tuple[pd.DataFrame, pd.Series]:
    """检查形状、类型、缺失值、重复行、有限性和标签。"""
    expected_columns = FEATURE_COLUMNS + ["fault"]
    if list(data.columns) != expected_columns:
        raise ValueError("数据列名或列顺序与预期不一致。")

    X = data.loc[:, FEATURE_COLUMNS].copy()
    y = data.loc[:, "fault"].copy()
    if len(X) != len(y):
        raise ValueError("特征与标签样本数量不一致。")
    if len(data) < 900:
        raise ValueError("样本数量少于 900。")
    if not all(pd.api.types.is_numeric_dtype(dtype) for dtype in data.dtypes):
        raise TypeError("所有字段都必须是数值类型。")

    missing_count = int(data.isna().sum().sum())
    duplicate_feature_rows = int(X.duplicated().sum())
    if missing_count != 0:
        raise ValueError(f"数据中存在 {missing_count} 个缺失值。")
    if not np.isfinite(X.to_numpy(dtype=float)).all():
        raise ValueError("特征中存在无穷值。")

    labels = sorted(int(label) for label in y.unique())
    if labels != [0, 1]:
        raise ValueError(f"标签必须同时包含 0 和 1,实际为:{labels}")

    print("\n数据检查:")
    print(f"完整数据形状: {data.shape}")
    print(f"特征矩阵形状: {X.shape}")
    print(f"标签形状: {y.shape}")
    print(f"缺失值数量: {missing_count}")
    print(f"重复特征行数量: {duplicate_feature_rows}")
    print(f"完整数据类别分布: {class_distribution(y)}")
    print("数值有限性检查: 通过")

    feature_ranges = pd.DataFrame(
        {
            "minimum": X.min(),
            "maximum": X.max(),
            "mean": X.mean(),
            "standard_deviation": X.std(),
        }
    )
    print("\n特征范围:")
    print(feature_ranges.to_string(float_format=lambda value: f"{value:.4f}"))
    return X, y


def split_dataset(
    X: pd.DataFrame,
    y: pd.Series,
) -> tuple[
    pd.DataFrame,
    pd.DataFrame,
    pd.DataFrame,
    pd.Series,
    pd.Series,
    pd.Series,
]:
    """按约 70%/15%/15% 进行两次分层拆分。"""
    x_train, x_temporary, y_train, y_temporary = train_test_split(
        X,
        y,
        test_size=0.30,
        random_state=RANDOM_STATE,
        stratify=y,
    )
    x_validation, x_test, y_validation, y_test = train_test_split(
        x_temporary,
        y_temporary,
        test_size=0.50,
        random_state=RANDOM_STATE,
        stratify=y_temporary,
    )

    print("\n数据集拆分:")
    print(f"训练集样本数: {len(x_train)}")
    print(f"验证集样本数: {len(x_validation)}")
    print(f"测试集样本数: {len(x_test)}")
    print(f"训练集类别分布: {class_distribution(y_train)}")
    print(f"验证集类别分布: {class_distribution(y_validation)}")
    print(f"测试集类别分布: {class_distribution(y_test)}")
    return x_train, x_validation, x_test, y_train, y_validation, y_test


def evaluate_classifier(
    model: ClassifierMixin,
    X: pd.DataFrame,
    y: pd.Series,
) -> dict[str, float | int]:
    """计算二分类指标,并显式使用 [0, 1] 的混淆矩阵顺序。"""
    prediction = model.predict(X)
    matrix = confusion_matrix(y, prediction, labels=[0, 1])
    tn, fp, fn, tp = (int(value) for value in matrix.ravel())
    return {
        "accuracy": float(accuracy_score(y, prediction)),
        "precision": float(precision_score(y, prediction, zero_division=0)),
        "recall": float(recall_score(y, prediction, zero_division=0)),
        "f1": float(f1_score(y, prediction, zero_division=0)),
        "tn": tn,
        "fp": fp,
        "fn": fn,
        "tp": tp,
    }


def demonstrate_unrestricted_tree(
    x_train: pd.DataFrame,
    y_train: pd.Series,
    x_validation: pd.DataFrame,
    y_validation: pd.Series,
) -> DecisionTreeClassifier:
    """训练未限制深度的树,展示训练与验证表现差异。"""
    tree = DecisionTreeClassifier(
        criterion="gini",
        random_state=RANDOM_STATE,
    )
    tree.fit(x_train, y_train)
    train_metrics = evaluate_classifier(tree, x_train, y_train)
    validation_metrics = evaluate_classifier(tree, x_validation, y_validation)

    print("\n未限制决策树:")
    print(f"训练集准确率: {train_metrics['accuracy']:.4f}")
    print(f"训练集F1: {train_metrics['f1']:.4f}")
    print(f"验证集准确率: {validation_metrics['accuracy']:.4f}")
    print(f"验证集F1: {validation_metrics['f1']:.4f}")
    print(f"实际树深: {tree.get_depth()}")
    print(f"叶节点数量: {tree.get_n_leaves()}")
    return tree


def search_decision_tree(
    x_train: pd.DataFrame,
    y_train: pd.Series,
    x_validation: pd.DataFrame,
    y_validation: pd.Series,
) -> pd.DataFrame:
    """只使用训练集训练候选树,并只在验证集比较参数。"""
    records: list[dict[str, Any]] = []
    for max_depth in TREE_DEPTHS:
        for min_samples_leaf in TREE_LEAF_SIZES:
            candidate = DecisionTreeClassifier(
                criterion="gini",
                max_depth=max_depth,
                min_samples_leaf=min_samples_leaf,
                random_state=RANDOM_STATE,
            )
            candidate.fit(x_train, y_train)
            train_metrics = evaluate_classifier(candidate, x_train, y_train)
            validation_metrics = evaluate_classifier(
                candidate,
                x_validation,
                y_validation,
            )
            records.append(
                {
                    "max_depth": max_depth,
                    "min_samples_leaf": min_samples_leaf,
                    "train_f1": train_metrics["f1"],
                    "validation_accuracy": validation_metrics["accuracy"],
                    "validation_f1": validation_metrics["f1"],
                    "actual_depth": candidate.get_depth(),
                    "leaf_count": candidate.get_n_leaves(),
                }
            )

    results = pd.DataFrame(records)
    print("\n决策树验证参数结果:")
    print(
        results.to_string(
            index=False,
            formatters={
                "train_f1": lambda value: f"{value:.4f}",
                "validation_accuracy": lambda value: f"{value:.4f}",
                "validation_f1": lambda value: f"{value:.4f}",
            },
        )
    )
    return results


def select_best_tree(results: pd.DataFrame) -> dict[str, int | None]:
    """在最优 F1 的 0.005 内,按深度、叶数和准确率选简单树。"""
    if results.empty:
        raise ValueError("决策树验证结果为空。")
    best_f1 = float(results["validation_f1"].max())
    close_results = results[
        results["validation_f1"] >= best_f1 - TREE_F1_TOLERANCE
    ].copy()
    ranked = close_results.sort_values(
        by=[
            "actual_depth",
            "leaf_count",
            "validation_accuracy",
            "min_samples_leaf",
        ],
        ascending=[True, True, False, False],
        kind="mergesort",
    )
    best = ranked.iloc[0]
    max_depth_value = best["max_depth"]
    max_depth = None if pd.isna(max_depth_value) else int(max_depth_value)
    result = {
        "max_depth": max_depth,
        "min_samples_leaf": int(best["min_samples_leaf"]),
    }
    print("\n决策树选择规则:先找最高验证 F1;在相差不超过 0.005 的方案中,")
    print("优先实际深度更浅、叶节点更少的树,再比较验证准确率。")
    print(f"最佳决策树参数: {result}")
    return result


def search_random_forest(
    x_train: pd.DataFrame,
    y_train: pd.Series,
    x_validation: pd.DataFrame,
    y_validation: pd.Series,
) -> pd.DataFrame:
    """验证数量有限的随机森林候选方案。"""
    records: list[dict[str, Any]] = []
    for candidate_parameters in FOREST_CANDIDATES:
        candidate = RandomForestClassifier(
            **candidate_parameters,
            criterion="gini",
            bootstrap=True,
            random_state=RANDOM_STATE,
            n_jobs=-1,
        )
        candidate.fit(x_train, y_train)
        metrics = evaluate_classifier(candidate, x_validation, y_validation)
        records.append(
            {
                **candidate_parameters,
                "validation_accuracy": metrics["accuracy"],
                "validation_precision": metrics["precision"],
                "validation_recall": metrics["recall"],
                "validation_f1": metrics["f1"],
            }
        )

    results = pd.DataFrame(records)
    print("\n随机森林验证参数结果:")
    print(
        results.to_string(
            index=False,
            formatters={
                "validation_accuracy": lambda value: f"{value:.4f}",
                "validation_precision": lambda value: f"{value:.4f}",
                "validation_recall": lambda value: f"{value:.4f}",
                "validation_f1": lambda value: f"{value:.4f}",
            },
        )
    )
    return results


def select_best_forest(results: pd.DataFrame) -> dict[str, Any]:
    """在最优 F1 的 0.005 内优先树更少且限制更清楚的方案。"""
    if results.empty:
        raise ValueError("随机森林验证结果为空。")
    best_f1 = float(results["validation_f1"].max())
    close_results = results[
        results["validation_f1"] >= best_f1 - FOREST_F1_TOLERANCE
    ].copy()
    close_results["depth_priority"] = close_results["max_depth"].apply(
        lambda value: float("inf") if pd.isna(value) else float(value)
    )
    ranked = close_results.sort_values(
        by=[
            "n_estimators",
            "depth_priority",
            "min_samples_leaf",
            "validation_accuracy",
        ],
        ascending=[True, True, False, False],
        kind="mergesort",
    )
    best = ranked.iloc[0]
    max_depth_value = best["max_depth"]
    result: dict[str, Any] = {
        "n_estimators": int(best["n_estimators"]),
        "max_depth": None if pd.isna(max_depth_value) else int(max_depth_value),
        "min_samples_leaf": int(best["min_samples_leaf"]),
        "max_features": best["max_features"],
    }
    print("\n随机森林选择规则:先找最高验证 F1;在相差不超过 0.005 的方案中,")
    print("优先树数量更少、深度限制更明确的方案,再比较验证准确率。")
    print(f"最佳随机森林参数: {result}")
    return result


def train_final_models(
    x_train: pd.DataFrame,
    y_train: pd.Series,
    x_validation: pd.DataFrame,
    y_validation: pd.Series,
    best_tree_parameters: dict[str, int | None],
    best_forest_parameters: dict[str, Any],
) -> tuple[
    DummyClassifier,
    DecisionTreeClassifier,
    RandomForestClassifier,
    pd.DataFrame,
    pd.Series,
]:
    """合并训练集和验证集,创建并训练三个全新的最终模型。"""
    x_development = pd.concat(
        [x_train, x_validation],
        axis=0,
        ignore_index=True,
    )
    y_development = pd.concat(
        [y_train, y_validation],
        axis=0,
        ignore_index=True,
    )

    baseline = DummyClassifier(strategy="most_frequent")
    final_tree = DecisionTreeClassifier(
        criterion="gini",
        **best_tree_parameters,
        random_state=RANDOM_STATE,
    )
    final_forest = RandomForestClassifier(
        **best_forest_parameters,
        criterion="gini",
        bootstrap=True,
        random_state=RANDOM_STATE,
        n_jobs=-1,
    )
    baseline.fit(x_development, y_development)
    final_tree.fit(x_development, y_development)
    final_forest.fit(x_development, y_development)
    return baseline, final_tree, final_forest, x_development, y_development


def compare_final_models(
    baseline: DummyClassifier,
    final_tree: DecisionTreeClassifier,
    final_forest: RandomForestClassifier,
    x_test: pd.DataFrame,
    y_test: pd.Series,
) -> pd.DataFrame:
    """参数确定后,仅在测试集上对三个最终模型评估一次。"""
    rows: list[dict[str, Any]] = []
    for model_name, model in (
        ("多数类基线", baseline),
        ("最佳决策树", final_tree),
        ("最佳随机森林", final_forest),
    ):
        rows.append({"model": model_name, **evaluate_classifier(model, x_test, y_test)})

    comparison = pd.DataFrame(rows)
    print("\n最终测试集模型对比:")
    print(
        comparison.to_string(
            index=False,
            formatters={
                "accuracy": lambda value: f"{value:.4f}",
                "precision": lambda value: f"{value:.4f}",
                "recall": lambda value: f"{value:.4f}",
                "f1": lambda value: f"{value:.4f}",
            },
        )
    )
    return comparison


def extract_tree_rules(final_tree: DecisionTreeClassifier) -> str:
    """仅输出最终决策树前几层文本规则。"""
    rules = export_text(
        final_tree,
        feature_names=FEATURE_COLUMNS,
        max_depth=3,
        decimals=3,
    )
    print(f"\n最终决策树深度: {final_tree.get_depth()}")
    print(f"最终决策树叶节点数量: {final_tree.get_n_leaves()}")
    print("最终决策树前几层规则:")
    print(rules)
    return rules


def build_importance_table(
    model: DecisionTreeClassifier | RandomForestClassifier,
) -> pd.DataFrame:
    """将不纯度重要性与正确的特征名称一一对应。"""
    if len(model.feature_importances_) != len(FEATURE_COLUMNS):
        raise RuntimeError("特征重要性数量与特征列数量不一致。")
    return pd.DataFrame(
        {
            "feature": FEATURE_COLUMNS,
            "importance": model.feature_importances_,
        }
    ).sort_values("importance", ascending=False, kind="mergesort", ignore_index=True)


def positive_class_probability(
    model: DecisionTreeClassifier | RandomForestClassifier,
    sample: pd.DataFrame,
) -> float:
    """根据 classes_ 查找类别 1 的概率列,不假定列位置。"""
    classes = np.asarray(model.classes_)
    positive_positions = np.flatnonzero(classes == 1)
    if len(positive_positions) != 1:
        raise RuntimeError(f"无法从类别顺序 {classes.tolist()} 找到唯一的类别 1。")
    probabilities = model.predict_proba(sample)
    return float(probabilities[0, int(positive_positions[0])])


def build_new_sample(x_development: pd.DataFrame) -> pd.DataFrame:
    """用开发集统计量构造处于合理范围内的教学样本。"""
    sample = x_development.median().to_frame().T
    sample.loc[:, "temperature"] = x_development["temperature"].quantile(0.80)
    sample.loc[:, "vibration"] = x_development["vibration"].quantile(0.80)
    sample.loc[:, "maintenance_days"] = x_development[
        "maintenance_days"
    ].quantile(0.75)
    sample = sample.loc[:, FEATURE_COLUMNS]

    within_range = (
        sample.ge(x_development.min()).all(axis=None)
        and sample.le(x_development.max()).all(axis=None)
    )
    if not within_range:
        raise ValueError("新设备样本超出了开发数据的特征范围。")
    return sample


def predict_new_sample(
    final_tree: DecisionTreeClassifier,
    final_forest: RandomForestClassifier,
    x_development: pd.DataFrame,
) -> None:
    """输出一条教学新样本的两个模型预测与类别 1 概率。"""
    sample = build_new_sample(x_development)
    tree_prediction = int(final_tree.predict(sample)[0])
    forest_prediction = int(final_forest.predict(sample)[0])
    tree_probability = positive_class_probability(final_tree, sample)
    forest_probability = positive_class_probability(final_forest, sample)

    print("\n新设备样本(由开发集统计量构造):")
    print(sample.to_string(index=False, float_format=lambda value: f"{value:.4f}"))
    print(f"决策树类别顺序: {final_tree.classes_.tolist()}")
    print(f"随机森林类别顺序: {final_forest.classes_.tolist()}")
    print(f"新设备决策树预测类别: {tree_prediction}")
    print(f"新设备决策树故障概率: {tree_probability:.4f}")
    print(f"新设备随机森林预测类别: {forest_prediction}")
    print(f"新设备随机森林故障概率: {forest_probability:.4f}")


def main() -> int:
    """运行完整的模拟设备树模型实验。"""
    try:
        check_environment()
        data = generate_dataset()
        X, y = validate_dataset(data)
        (
            x_train,
            x_validation,
            x_test,
            y_train,
            y_validation,
            y_test,
        ) = split_dataset(X, y)

        demonstrate_unrestricted_tree(
            x_train,
            y_train,
            x_validation,
            y_validation,
        )
        tree_results = search_decision_tree(
            x_train,
            y_train,
            x_validation,
            y_validation,
        )
        best_tree_parameters = select_best_tree(tree_results)

        forest_results = search_random_forest(
            x_train,
            y_train,
            x_validation,
            y_validation,
        )
        best_forest_parameters = select_best_forest(forest_results)

        (
            baseline,
            final_tree,
            final_forest,
            x_development,
            _y_development,
        ) = train_final_models(
            x_train,
            y_train,
            x_validation,
            y_validation,
            best_tree_parameters,
            best_forest_parameters,
        )
        compare_final_models(
            baseline,
            final_tree,
            final_forest,
            x_test,
            y_test,
        )
        extract_tree_rules(final_tree)

        tree_importance = build_importance_table(final_tree)
        forest_importance = build_importance_table(final_forest)
        print("决策树特征重要性:")
        print(tree_importance.to_string(index=False, float_format=lambda value: f"{value:.6f}"))
        print("\n随机森林特征重要性:")
        print(forest_importance.to_string(index=False, float_format=lambda value: f"{value:.6f}"))

        predict_new_sample(final_tree, final_forest, x_development)
        print("\n谨慎结论:这些数据与规律均由代码人为生成,结果只用于教学。")
        print("指标、规则、概率和特征重要性都不能用于真实设备维修、安全检查或生产决策;")
        print("特征重要性表示模型中的不纯度下降贡献,不代表现实因果关系。")
        print("\n程序正常结束。")
        return 0
    except (ValueError, TypeError, RuntimeError) as exc:
        print(f"\n程序执行失败:{type(exc).__name__}: {exc}")
        return 1
    except Exception as exc:  # 捕获未预期错误并给出明确类型
        print(f"\n出现未预期错误:{type(exc).__name__}: {exc}")
        return 1


if __name__ == "__main__":
    raise SystemExit(main())

运行结果与谨慎结论

程序在 Conda base 环境中正常结束,使用 Python 3.11.4、NumPy 1.24.3、Pandas 1.5.3 和 scikit-learn 1.3.0。35 个决策树组合和 4 个随机森林方案均实际完成验证,测试集只在参数确定后评估一次。

本次结果没有证明随机森林已经适合故障识别,反而显示了训练过拟合、类别不平衡、准确率误导和概率粗糙等问题。保留失败或不理想的指标,比为了文章好看而反复利用测试集更符合机器学习实验规范。不同库版本可能造成输出格式或末位浮点数轻微差异。

本文总结

决策树通过不断选择特征和阈值,把样本划分到叶节点;基尼不纯度帮助模型判断哪次分裂更有效。树越深不一定越好,预剪枝、验证集和多指标评价是控制过拟合的重要工具。

随机森林通过 Bootstrap 样本和随机特征选择训练多棵有差异的树,再汇总预测。它通常更稳定,但不是自动成功的保证。本篇模拟实验中,随机森林测试准确率高于决策树,却没有识别出故障类,说明召回率、F1 和混淆矩阵不可省略。特征重要性也只能描述模型分裂贡献,不能当作因果证据。

下一篇预告

下一篇将学习《支持向量机的直观理解与实践》,从分类间隔、支持向量和核方法的直觉出发,继续认识另一类常用分类模型。

相关推荐
Ivanqhz1 天前
泰勒展开(Taylor Expansion)
算法·决策树·机器学习·集成学习
Ivanqhz2 天前
偏差与方差,及集成学习
人工智能·机器学习·集成学习
心运软件2 天前
基于机器学习的智能邮件分类系统:从数据采集到模型部署全流程实战
人工智能·python·算法·随机森林·机器学习·分类·scikit-learn
2601_960906722 天前
下一代iPhone的迭代改进
决策树·随机森林·线性回归·动态规划
2501_942389553 天前
Epoch AI旗下FrontierMath的负责人Elliot Glazer
数据结构·决策树·动态规划·散列表
AI科技星3 天前
曲率‑挠率与 $\boldsymbol{\omega/c}$ 的关系、精算验证及其物理意义
c语言·开发语言·线性代数·算法·决策树·机器学习·ai科技星
天辛大师4 天前
天心大师:不确定中锚定自我,AI生活的哲学命题
人工智能·算法·决策树·机器学习·生活·启发式算法
倒流时光三十年4 天前
第五阶段 41 · 相关性调优(boosting、function_score、filter vs query 上下文)
机器学习·集成学习·boosting
野生的程序媛5 天前
02-提示词与检索增强
人工智能·神经网络·算法·决策树·机器学习·ai·ai-native