摘要:本文以电力业务为背景,系统讲解决策树与随机森林的原理、训练、调参与实战应用。内容涵盖:决策树如何通过“层层提问”做分类、深度与过拟合的关系、随机森林如何通过组队投票降低方差、决策树回归的局限(不会外推、输出是台阶)、如何可视化决策树、特征重要性的解读与“重要≠因果”的辨析,最后以反窃电用户识别为例,演示从数据划分、模型对比、特征分析到人工复核的完整流程。
一、问题引入
什么是决策树?
核心结论:决策树就是一种模仿人类“层层提问、逐步排除”来做判断的机器学习模型;它长得像一棵倒着的树:最上面是起点,一路往下问问题,走到最底下得到结论
什么是随机森林?
随机森林就是由很多棵决策树组成的——它是“决策树的集体会诊版”。 单棵决策树像一位医生独立下诊断,水平不稳定还容易钻牛角尖(过拟合);随机森林请了几百位医生,每位医生只看一部分病历(随机抽样)、只参考部分检查指标(随机选特征),最后大家投票,取多数意见作为诊断结果;个体可以有偏差,但集体投票后的结论稳定得多
二、如何训练第一棵决策树?
在这里我们使用sklearn 的方法,数据集是官方自带的鸢尾花,更多细节用法可以官网查询:scikit-learn: machine learning in Python — scikit-learn 1.9.1 documentation
sklearn三部曲
model = DecisionTreeClassifier(max_depth=3, random_state=42) # 第 1 步:创建(空壳) model.fit(X_tr, y_tr) # 第 2 步:训练(真正学习) model.score(X_te, y_te) # 第 3 步:使用(打分/预测)在这里max_depth=3表示深度,意思是“问三个问题”;即:这棵树切了三刀;它能问三层:第一刀分出 setosa,第二、三刀用花瓣宽度把剩下两类再切开
测试代码展示:
from sklearn.datasets import load_iris from sklearn.model_selection import train_test_split from sklearn.tree import DecisionTreeClassifier 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 ) model = DecisionTreeClassifier(max_depth=3,random_state=42) train_model = model.fit(X_tr, y_tr) print("训练集准确率:", train_model.score(X_tr, y_tr)) print("测试集准确率:", train_model.score(X_te, y_te))测试结果:
为什么深度=3,测试集准确率为1?深度=1的时候,测试集的正确率就降了许多?
因为这次测试的是鸢尾花数据集,他是三分类
| 标签 | 学名 | 中文名 | 特点 |
|---|---|---|---|
| 0 | setosa | 山鸢尾 | 花瓣又短又小(长 ~1.5cm、宽 ~0.25cm),和其他两种隔得很远 |
| 1 | versicolor | 变色鸢尾 | 花瓣中等(长 ~4.3cm、宽 ~1.3cm) |
| 2 | virginica | 维吉尼亚鸢尾 | 花瓣最大(长 ~5.5cm、宽 ~2.0cm) |
如果你是深度等于3,那么模型就能分的清特征,要是等于1,等于1也被称为树桩,只切一刀,剩下特征模型分不清,就会误判,正确率就会下来
那如果切两刀,正确率会如何?
倘若只切两刀,还是不能达到最好,为什么呢?
-->>第一刀的“任务”是注定的。 建树时算法挑信息增益最大的一刀,永远先是“花瓣长度 ≤ 2.45”——这一刀把 50 朵山鸢尾完整切出去(这片叶子纯净了)。此时深度预算用掉 1 层;
第二步:剩下 100 朵混战,只剩一刀可问; depth=2 时,混战区只能再问一个问题,比如“花瓣宽度 ≤ 1.75 吗”;问题是:变色鸢尾和维吉尼亚鸢尾的花瓣宽度在1.0~1.8cm 之间是重叠的——一条阈值线切下去,重叠带里的花必然有错判,这片叶子永远不纯;所以测试集卡在 0.93~0.96 附近上不去
值得注意的是,不是所有的测试集准确率都是1的,之所以这次鸢尾花的数据集可以做到,是因为太简单了,遇到复杂的业务,有噪声、有重叠,测试的时候,就会出现正确率落到 0.85~0.95 这个“更真实”的区间
要是深度设为None;结果又会怎么样?
-->>在这里,由于没有噪声等干扰因素,两者都是1,训练集是巧合;不是规律,且这次数据集没有出现过拟合的情况,下面会另外介绍。
生活化类比:一个学生把复习资料里的偏题怪题也全背了(过拟合的苗头),结果期末卷恰好没出怪题——考了满分;你能说他“背怪题”是好策略吗?不能,只能说这次卷子配合,换个爱出怪题的老师(噪声大的真实数据)。后面介绍再跑 depth=None 就会看到熟悉的剧本:训练集逼近 1.0,测试集明显往下掉——训练集和测试集的差距(泛化间隙)才是衡量过拟合的尺子
三、深度与过拟合
第一:测试不同深度不同的结果
测试代码:
from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split from sklearn.tree import DecisionTreeClassifier X, y = make_classification( n_samples=600, n_features=6, n_informative=4, n_redundant=1, flip_y=0.1, random_state=42 ) X_tr, X_te, y_tr, y_te = train_test_split( X, y, test_size=0.3, random_state=42 ) depths = [1, 2, 3, 5, 10, None] max_depths = [1, 2, 3, 5, 10, None] for max_depth in max_depths: model = DecisionTreeClassifier(max_depth=max_depth, random_state=42) train_model = model.fit(X_tr, y_tr) print(f"max_depth={max_depth} 训练集准确率: {train_model.score(X_tr, y_tr)}") print(f"max_depth={max_depth} 测试集准确率: {train_model.score(X_te, y_te)}")部分参数意思:
n_informative 表示有 4 个特征是"有信息"的,其他 2 个特征是"冗余"的 flip_y=0.1 表示 10% 的标签被随机翻转过——现实中数据永远不干净 n_redundant 表示有 1 个特征是"冗余"的,即与目标变量没有直接关系测试结果:
哪一个最好呢?
-->>选模型的完整逻辑是三步,顺序不能乱:
- 第一标准:测试集(或交叉验证)分数—— 这是对“未来新数据表现”的直接估计;depth=10 得 0.800 > depth=5 得 0.761,先赢一局。
- 间隙只是第二位的体检项—— 它的作用是提醒你:“这个 0.800 里有背题成分,可信度要打折扣,换批数据可能没这么漂亮” 它是个风险提示,不是淘汰标准。
- 分数接近时才轮到间隙说话—— 如果 depth=5 和 depth=10 考 0.795 vs 0.800,差距在噪声范围内,才按“简单优先”选 5。但 0.761 和 0.800 差了 4 个百分点,不算接近
为什么 depth=5 间隙小反而不是好事?看它的间隙是怎么来的
depth=5: 0.895 − 0.761 = 0.13 ← 训练分低把间隙"压小"了 depth=10: 0.998 − 0.800 = 0.20 ← 训练分高把间隙"撑大"了depth=5 的间隙小,一部分原因是它学得本来就不够多——它没背那么多题,但也因此漏掉了一些真实的细分规律。间隙小只保证“没背题”,不保证“学得全”。从 5 到 10 多出来的那些分裂,一部分确实在背噪声(浪费),但另一部分学到了真结构的细枝末节(赚到了),赚的比亏的多,测试分就上去了
深度为 None 时训练集准确率是1,为什么它能这么高?
ax_depth=None解除了“提问次数”的限制,而 sklearn 决策树的默认规则是“只要这片叶子还有杂类,就继续切,切到每片叶子纯净为止”。 预算无限 + 目标是切干净 = 必然把 420 个训练样本切到全对
机制拆开看三步:
- 默认停止条件:
DecisionTreeClassifier建树时,只有三种情况会停下——叶子纯净了(gini=0)、样本太少(min_samples_split兜底)、或深度用完。前两个条件不设限的话,它就会一直切。 - 连续特征保证了“总能切下去”:只要特征是连续数值(如花瓣长度 4.2、4.3),任何一片混杂的叶子里,总能找到一个阈值把“异类样本”单独圈出来。哪怕最后一片叶子只剩 1 个样本,它也必然是纯的。
- 极限情况:树一路切下去,最坏就是每个样本独占一片叶子——相当于把整本习题册逐题背答案。150 或 420 个样本对它来说绰绰有余,所以训练集正好 1.0
第二:测试random_state=0的情况
X, y = make_classification( n_samples=600, n_features=6, n_informative=4, n_redundant=1, flip_y=0.1, random_state=0 ) X_tr, X_te, y_tr, y_te = train_test_split( X, y, test_size=0.3, random_state=0 )改了两处种子;结果自然变?这次相比较,还是深度为10好,理由是:
-->>
| 深度 | seed=42 测试分 | seed=0 测试分 | 结论 |
|---|---|---|---|
| 5 | 0.761 | 0.833 | 波动 ±0.04 |
| 10 | 0.800 | 0.800 | 稳 |
| None | 0.794 | 0.789 | 稳 |
切忌在写报告、文献等地方使用此方法——“挑种子”
即反复换种子、只把最好看的那次结果写进汇报。这等于对测试集过拟合,是数据造假的温和版。种子的作用是“固定实验条件”,不是“美化实验结果”
第三:和随机森林做对比
from sklearn.datasets import make_classification from sklearn.model_selection import train_test_split from sklearn.tree import DecisionTreeClassifier from sklearn.ensemble import RandomForestClassifier X, y = make_classification( n_samples=600, n_features=6, n_informative=4, n_redundant=1, flip_y=0.1, random_state=42 ) X_tr, X_te, y_tr, y_te = train_test_split( X, y, test_size=0.3, random_state=42 ) TODO 1: 训练一棵不限制深度的单棵决策树,打印测试集准确率(作为基准) single_tree = DecisionTreeClassifier(max_depth=None, random_state=42) single_tree.fit(X_tr, y_tr) print("单棵决策树 测试集准确率:", single_tree.score(X_te, y_te)) TODO 2: n_trees = [5, 50, 200] for n_estimators in n_trees: model = RandomForestClassifier(n_estimators=n_estimators, random_state=42) model.fit(X_tr, y_tr) print(f"n_estimators={n_estimators} 测试集准确率: {model.score(X_te, y_te)}")得出的结论:
1.单棵树效果没有随机森林好;
2.数量不是越多越好,会出现过拟合现象
随机森林里的每棵树都用 max_depth=None(默认不剪枝);单棵时这样会过拟合,为什么组队之后反而没那么容易过拟合了?
-->>单棵树时代,“过拟合”是缺点;进了森林,“每棵树深到过拟合”反而是设计意图——因为森林要的不是“每棵树都对”,而是“每棵树错得不一样”,投票才能把错消掉
为什么单棵树必须“深”?
-->>树的能力分两块:偏差(学得够不够透)和方差(换批数据结论抖不抖);深树 = 偏差低(真规律学得干干净净,连细枝末节都学到了)+ 方差大(噪声也背了不少,换个划分结论就变)。浅树正好反过来:方差小,但偏差大——很多真规律它根本没学到。森林的修正能力只能消方差,消不了偏差
-->>投票能消掉“背题”,消不掉“没学到”
-->>树的数量增加,方差被越削越小,但削的幅度是边际递减的(方差理论上按 1/N 衰减,且树与树并非完全独立,削不完)。所以实战经验:50~200 棵后基本躺平,树多解决“稳不稳”,不解决“准不准”——再想准要靠调特征、调参数或换更强的模型(如 XGBoost)
XGBoost简单介绍
XGBoost 是“决策树的另一组建队方式”——随机森林是几百棵树各自独立、同时做题再投票(并行集成,学名 Bagging);XGBoost 是几百棵树排队接力、后一棵专门修正前一棵的错误(串行集成,学名 Boosting)
四、决策树回归
决策树除了可以直观的画图(后面介绍)之外,也可以去进行预测。决策树回归的作用是“用一串 if-else 阈值直接预测连续数值”,最适合有非线性突变、特征是表格、还想要可解释性的场景。 它单独出场不多,更多是以“回归树”的身份当零件,组成随机森林回归和 XGBoost。
它擅长什么?
- 关系是“台阶式/突变式”而不是平滑曲线——真实业务里很多规律本来就是分段的:
<ul> - 电价阶梯:用电量 0~200 度一个价、200~400 一个价;
- 峰谷电价:不同时段电价直接跳变;
- 变压器负载率超 80% 后温升曲线突然变陡。
这些“阈值行为”正是树的天生母语——线性回归拟合它反而要掰弯一条直线。 - 特征混合类型:数值(电压、温度)和类别(设备型号、区域)混在一张表里,树不用做标准化、不用 one-hot 也能切。
- 要给业务方解释:输出的不是玄学系数,而是“负载率 > 82% 且环境温度 > 35℃ → 预计寿命损耗 0.4%/年”,一眼能审
它的两大死穴
- 不会外推:训练数据里负荷最高见过 500MW,预测明年夏天 600MW 的场景,它只能给你输出 500 附近——树永远“台阶到底”,趋势外推要靠线性类模型或做好特征变换。
- 输出是台阶:本质是“分段平均值”,对平滑连续的物理规律(如温度-损耗曲线)拟合得粗。
台阶到底什么意思?
树的预测从头到尾都是台阶,连最后“越界外推”的地方也是平的,永远不给斜线。 看图里橙色线的右端——越过数据边界后它不是顺着绿色曲线的趋势继续爬,而是直接水平延长。因为树的输出规则是“每片叶子输出一个固定平均值”,它没有“趋势”这个概念,只有“档位”;所谓“到底”,就是从第一段到无穷远处,全程都是水平段,一段斜率都没有
对平滑规律拟合得粗”怎么理解——用数字算一遍最直观:
假设真实的物理规律是:温度每升高 1℃,损耗精确增加 0.01(这是条平滑直线)。
- 线性回归学到
损耗 = 0.01 × 温度——25℃ 答 0.25,25.5℃ 答 0.255,26℃ 答 0.26,每一个点都给不同的、连续的值,误差可以趋近于零。 - 回归树只能切成几段,比如 25~27℃ 这一段统一答“0.26”(段内三个温度的平均)。结果:
- 26℃(正好在段中间):答 0.26,误差 0 ✔
- 25℃:答 0.26,多答了 0.01 ✘
- 27℃:答 0.26,少答了 0.01 ✘
段内除了中点,两端全有误差——这就是图上红色虚线标的那截。想消除这些误差只有一条路:把段切得无限窄 → 树无限深 → 又回到过拟合。所以面对平滑规律,树模型的误差是结构性的:不是它没学好,而是“分段常数”这种表达形式天生描不平一条斜线
代码展示:
import numpy as np from sklearn.model_selection import train_test_split from sklearn.tree import DecisionTreeRegressor rng = np.random.default_rng(42) X = np.linspace(0, 10, 200).reshape(-1, 1) # 200 个 x 点 y = np.sin(X).ravel() + rng.normal(0, 0.1, 200) # sin 曲线 + 噪声 X_tr, X_te, y_tr, y_te = train_test_split( X, y, test_size=0.3, random_state=42 ) TODO 1: 训练两棵回归树:max_depth=2 和 max_depth=10 model_shallow = DecisionTreeRegressor(max_depth=2, random_state=42) model_shallow.fit(X_tr, y_tr) model_deep = DecisionTreeRegressor(max_depth=10, random_state=42) model_deep.fit(X_tr, y_tr) TODO 2: 均方误差(MSE)函数:np.mean((y_true - y_pred) ** 2) def mse(y_true, y_pred): return np.mean((y_true - y_pred) ** 2) TODO 3: 分别打印两棵树在测试集上的 MSE print("浅树(depth=2) 测试集 MSE:", mse(y_te, model_shallow.predict(X_te))) print("深树(depth=10) 测试集 MSE:", mse(y_te, model_deep.predict(X_te))) TODO 4: "真实 y 的散点 + 两棵树的预测曲线" from matplotlib import pyplot as plt plt.rcParams["font.sans-serif"] = ["Microsoft YaHei"] # 修中文方框 关键:用"排好序的密集网格"去预测,而不是乱序的 X_te x_grid = np.linspace(0, 10, 500).reshape(-1, 1) plt.figure(figsize=(10, 5)) plt.scatter(X_te, y_te, s=15, alpha=0.5, label="真实 y(散点)") plt.plot(x_grid, model_shallow.predict(x_grid), linewidth=2, label="浅树 depth=2") plt.plot(x_grid, model_deep.predict(x_grid), linewidth=2, label="深树 depth=10") plt.legend() plt.show()输出结果:
结论:
1. 数据的“真身”是正弦波 + 噪声
蓝点沿一条平滑的 sin 曲线上下分布、带随机抖动——这就是“真实物理规律 + 测量噪声”的标准长相,也是我们故意造的数据背景
2. 浅树(蓝线)抓住了骨架,丢掉了形状——欠拟合的实锤
它只切了 4 大段:0.15 / 0.8 / −0.65 / 0.48。你看它的山峰和谷底位置全对(x≈1.5 附近高、x≈4.5 附近低),但 sin 的圆滑起伏全没了,x∈[3, 6] 的整个下坡直接一条水平线糊过。偏差大的模型就是这样:大方向对,细节全错——对应它 MSE 0.31,是深树的近 3 倍
3. 深树(橙线)贴着数据走,但连“毛刺”也照抄——方差的实锤
用几十级小台阶精确贴合散点,但注意 x≈1.3 处它跟着一个孤立噪声点猛地冒尖、x≈9.5 之后跟着最后几个散点一路下坠到 −0.55;这些扭动不是“规律”,是在背噪声——它追的是每个点,不是那条隐约的 sin 曲线
4. 阶梯状的疏密 = 树的“档位细度”
蓝线 4 级台阶、橙线几十级——这就是“深度预算”的直接可视化:每多一层分裂,x 轴被多切几刀,台阶变小变密
5. 两棵树都不会外推,且深树把边缘噪声也背进去了
图最左端 x<0.2:蓝线平在 0.15,橙线怪异地翘到 −0.05(那里几乎没有训练数据,纯靠边缘叶子兜底);图最右端 x>9.8:橙线还跟着最后几个点继续往下冲。训练数据没覆盖的区间,树给出的不是预测,是“最后一片叶子的复读”
不会外推:树的预测永远不会超出它见过的最大/最小答案——数据边界之外,它只会“复读”,不会“顺着趋势推”
树在外推区会发生什么?
-->>图里面的区间是0~10,倘若在10之外,如10.5
- 决策树:10.5 落进“最右边那片叶子”,该片叶子的平均值是 −0.55,于是它回答 −0.55——哪怕 sin 曲线的真实趋势到那里应该回升;它没有“斜率”“趋势”的概念,只有叶子里的平均数,所以出了边界就只能把最后一片叶子的值水平地复读下去。
- 线性回归(对比):学到
y = 0.6x + b这样的公式,x=10.5 就把 10.5 代进公式,顺着趋势往外延伸——可能错(趋势未必延续),但至少“会推”
在电力业务里,这是大问题:
-->>电力预测最值钱的时刻,恰恰是突破历史纪录的时刻:
- 极端高温日:历史最高气温 39℃,明天预报 42℃——负荷预测最需要准的就是这种天,而树模型对“42℃”只能复读“39℃ 那片叶子”的值,往往系统性低估(恰恰是最不能低估的一天,低估=备电网负荷不足=拉闸风险);
- 负荷自然增长:今年园区新增产线,明年用电整体抬升 15%——树模型学的是“历史范围里的形状”,整体抬升它感知不到,除非你把“用户数、产能”这些能表征增长的特征喂进去;
- 新能源场站扩容:风电场装机从 100MW 扩到 150MW,满发时刻功率早冲破历史值,纯靠历史数据训练的树全部失灵
把“趋势”翻译成特征喂给树——比如不直接用“气温”,改用“气温对历史分位数的超越程度”;不直接用负荷绝对值,改用“负荷/装机容量”的比值。趋势被装进特征里,树就不用自己外推了
五、如何画决策树
在这里还是使用sklearn方法,鸢尾花数据集
代码展示:
from matplotlib import pyplot as plt from sklearn.datasets import load_iris from sklearn.tree import DecisionTreeClassifier from sklearn.tree import plot_tree from sklearn.model_selection import train_test_split#拆分训练 / 测试集 from sklearn.metrics import classification_report#评估模型效果 plt.rcParams["font.family"] = "SimHei" plt.rcParams["axes.unicode_minus"] = False #导入鸢尾花数据集 data = load_iris() #print(data) #导入特征值 x = data['data'] y = data['target'] #划分数据集 x_train,x_test,y_train,y_test = train_test_split(x,y,train_size=0.8,random_state=42) #训练决策树 dt = DecisionTreeClassifier( max_depth=3, min_samples_leaf=2, min_samples_split=5, max_leaf_nodes=10 ) dt.fit(x_train,y_train) #评估模型和可视化 y_pre = dt.predict(x_test) print(classification_report(y_test,y_pre)) #画决策树 fig = plt.figure(figsize=(10,6)) plot_tree( dt, feature_names=data['feature_names'], class_names=data['target_names'], filled=True ) plt.show()输出:
怎么理解?
-->>以根节点为例:
| 行 | 内容 | 含义 |
|---|---|---|
petal width ≤ 0.8 | 提问 | 这一刀的判据(False 走右,True 走左) |
gini = 0.667 | 混乱度 | 0=纯净,0.667=三类最混的极限值 |
samples = 120 | 样本量 | 到达此节点的训练样本数 |
value = [40, 41, 39] | 构成 | 三类各多少朵(加起来=120,守恒) |
class = versicolor | 当前结论 | 如果在此强停,输出多数类 |
颜色是一门语言:色相=类别,深浅=纯度
橙=setosa、绿=versicolor、紫=virginica;颜色越深、越饱和,gini 越接近 0;发白的节点(如 samples=2 那个,gini=0.5)就是“混战区”(看 gini 数值,不用算,对表就知道这个节点“有多混”)
白色怎么理解?
-->>在这里,白色 = “没有主导类别”的节点——三种颜色(类别)掺在一起互相稀释,谁也压不住谁,最后只剩一片白
颜色编码的完整规则(两条)
- 色调(什么颜色):由节点的多数类决定——多数是 versicolor 就偏绿,多数是 virginica 就偏紫;
- 饱和度(多深多浅):由主导程度决定——value 里某类一家独大 → 颜色浓;三类(或两类)势均力敌 → 颜色被“稀释”成发白。
所以白色的准确含义是:这个节点里没有哪类样本占明显优势,是“混战区”——对应 gini 高、对应“在此强停则预测基本靠蒙”
那这部分怎么样,是不是不行,分到后面还是分不清,还有参杂?
这两个白叶子就是整棵树的“能力死角”——树用完了 3 层提问预算,还是切不干净,只能带着 50% 的含混率交卷。 但“不行”要分两层说:作为“预测可信度”它确实不行;作为“模型的诚实表现”它反而是合理的
但是它们“闯祸的能力”其实很小
这两个叶子只蓄了 10/120 ≈ 8% 的训练样本,而且是最边缘的样本。整棵树 92% 的流量都流进了 4 片深色纯叶;评估一个坏叶子,要同时看它的“含混率”和“接客量”:gini=0.5 很糟,但 samples=2/8 很小,影响面有限;真正危险的是“又大又白”的叶子——那种才是要连夜修
实战中怎么处理这种“分不清”?
业务上不会硬猜,而是让模型说出“我没把握”:
proba = dt.predict_proba([[5.0, 3.5, 4.5, 1.5]]) # 若输出类似 [0, 0.5, 0.5] → 落进白叶子,置信度只有 50%规则:最大概率低于阈值(如 0.7)的预测,不自动下结论,转人工复核;对应到反窃电场景就是:模型对 900 个用户自动判定,剩下 100 个“两头不像”的标记为“存疑”,交给稽查人员现场核查——这比硬给 1000 个答案专业得多
六、特征的重要性
四层目的:
目的 1(表层):掌握feature_importances_这个高频工具
它是随机森林/决策树最有实用价值的“附加产出”——训练白送,不用额外花钱;未来写任何 项目的结题报告,“哪些因素最关键”这张排名表几乎是必配图
目的 2(业务层):练习“把模型输出翻译成业务语言”
模型吐出0.35 接头温度,要能写成“建议巡检优先核查接头测温异常的台区”;模型负责算,工程师负责“翻译+落措施”
目的 3(思维层):“重要” ≠ “因果”
重要性衡量的是“模型分类时多用它”,不是“改变它能改变结果”
-->>
- 相关但非因果:某地区“冰激凌销量”对“溺水人数”预测很重要(都是夏天的影子),但禁售冰激凌救不了溺水的人;
- 电力版:假设“运行年限”重要性很高——它可能只是“老化故障”的影子;真正该做的也许是换掉老化设备(因果),而不是盯着年限数字做文章;
- 更隐蔽的坑:两个高度相关的特征(比如“接头温度”和“负载率”——负载高导致温度高),模型可能把重要性随机分给其中一个,你据此做决策就会打偏
目的 4(稳健层):重要性是“参考”,不是“定论”
把 200 棵树改成 20 棵,排名大概率会抖——因为重要性是“几百次随机划分的平均统计量”,样本少、树少时它不稳定;业务上对应的安全规范:特征重要性只用于“生成核查优先级假设”,最终措施要用业务逻辑和实地验证来确认
代码展示:
from sklearn.datasets import make_classification# 生成分类数据集 from sklearn.model_selection import train_test_split# 划分数据集 from sklearn.ensemble import RandomForestClassifier# 随机森林分类器 特征名:假设是 6 个电力监测指标 feature_names = ["电压偏差", "谐波畸变率", "三相不平衡", "接头温度", "负载率", "运行年限"] X, y = make_classification( n_samples=800, n_features=6, n_informative=4, n_redundant=1, weights=[0.7, 0.3], flip_y=0.08, random_state=7 ) X_tr, X_te, y_tr, y_te = train_test_split( X, y, test_size=0.3, random_state=42 ) TODO 1: 训练随机森林(n_estimators=200, random_state=42),打印测试集准确率 model = RandomForestClassifier(n_estimators=200, random_state=42) model.fit(X_tr, y_tr) print("测试集准确率:", model.score(X_te, y_te)) TODO 2: 打印每个特征的 importance,并按从大到小排序输出 for importance, feature in sorted(zip(model.feature_importances_, feature_names), reverse=True): print(f"{importance:.2f} {feature}") TODO 3: 把 n_estimators 改成 20 再跑一遍, model1 = RandomForestClassifier(n_estimators=20, random_state=42) model1.fit(X_tr, y_tr) print("把 n_estimators 改成 20 再跑一遍;测试集准确率:", model1.score(X_te, y_te))测试结果:
问题一:为什么接头温度排第一而负载率排第三?
-->>负载率和接头温度的关系:一对“因果孪生指标”;物理因果链:负载率↑ → 负荷电流↑ → 发热按 I²R 放大 → 接头温度↑。两者高度相关、信息重叠(n_redundant=1,有 1 个特征是有效特征的线性组合(比如“接头温度”≈ a×负载率 + b),它信息重复但和目标是有关系的,只是不提供新信息)
模拟电力监测里“测了温度就不用再测负载也能猜个八九不离十”的真实情况
问题二:为什么负载率只排第三(0.14,还和三相不平衡打平)
冗余摊薄:它和接头温度信息重叠,模型“知道负载率了就不太需要温度、知道温度了就不太需要负载”,重要性在孪生特征间分配时,接头温度(信息更纯、可能非线性关系更强)拿走了 0.33 的大头,负载率只剩残羹。孪生指标注定有一个要“让贤”
名字是随机贴的(诚实声明):合成数据里哪个匿名特征强是随机决定的,给特征起名时不掌握真相;所以“负载率凭什么是第三”在合成数据里没有物理答案——重要性由数据决定,不由名字和直觉决定
0.14 的“第三”和 0.11 的“第五”未必是真实差距:在 8% 标签噪声 + 单次划分下,中间这几名的排序本来就有抖动,别过度解读 0.14 vs 0.11
问题三:为什么树少(20 棵)准确率反而低?
同一套数据、同一个划分,200 棵 0.8917 vs 20 棵 0.8792,掉了 1.2 个百分点——这就是“树数量影响方差削减”的直接证据
一个特征"重要"是否等于它和结果"有因果关系"?
-->>不等于。“重要”只说明“模型预测时很依赖它”(预测功劳),“有因果”说明“改变它会改变结果”(干预有效)——两者是两码事
七、综合
场景:供电局反窃电嫌疑用户识别;某供电局营销部门想用机器学习从用电数据里筛出"窃电嫌疑用户"
数据共 1000 个用户,5 个特征:
月均用电量(kWh) —— 窃电户往往异常偏低
深夜用电占比(0~1) —— 窃电户常在深夜大功率用电
电压合格率(0~1) —— 窃电/私拉电线会导致电压质量下降
历史告警次数 —— 计量装置异常告警越多越可疑
台区线损关联度(0~1) —— 与所在台区线损异常的相关程度
标签:0 = 正常用户,1 = 窃电嫌疑用户
代码展示:
import numpy as np from sklearn.model_selection import train_test_split from sklearn.tree import DecisionTreeClassifier from sklearn.ensemble import RandomForestClassifier feature_names = ["月均用电量", "深夜用电占比", "电压合格率", "历史告警次数", "线损关联度"] rng = np.random.default_rng(2026) def make_group(n, usage, night, volt, alarm, loss): data = np.column_stack([ rng.normal(usage, usage * 0.3, n), np.clip(rng.normal(night, night * 0.4, n), 0, 1), np.clip(rng.normal(volt, 0.02, n), 0.8, 1.0), np.round(np.clip(rng.normal(alarm, 1.2, n), 0, 12)), np.clip(rng.normal(loss, loss * 0.3, n), 0, 1), ]) return data normal = make_group(800, usage=320, night=0.10, volt=0.98, alarm=1, loss=0.04) thief = make_group(200, usage=130, night=0.45, volt=0.90, alarm=5, loss=0.12) X = np.vstack([normal, thief]) y = np.array([0] * 800 + [1] * 200) -------------------------------------- TODO 1: 划分训练/测试集 X_tr, X_te, y_tr, y_te = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y ) #stratify=y 保证训练集和测试集里;"正常:嫌疑"都保持 4:1,避免测试集偶然偏斜(比如嫌疑用户全挤进训练集)导致评估失真。 TODO 2:训练决策树 tree_model = DecisionTreeClassifier(max_depth=4, random_state=42) tree_model.fit(X_tr, y_tr) print("决策树测试集准确率:", tree_model.score(X_te, y_te)) TODO 3: 训练随机森林 forest_model = RandomForestClassifier(n_estimators=100, random_state=42) forest_model.fit(X_tr, y_tr) print("随机森林测试集准确率:", forest_model.score(X_te, y_te)) TODO 4: 写循环让决策树 max_depth 从 1 取到 10, best_depth, best_acc = None, 0.0 for depth in range(1, 11): m = DecisionTreeClassifier(max_depth=depth, random_state=42) m.fit(X_tr, y_tr) acc = m.score(X_te, y_te) print(f"max_depth={depth}: 测试集准确率 {acc:.4f}") if acc > best_acc: best_acc, best_depth = acc, depth print(f"\n最优深度: {best_depth},测试集准确率: {best_acc:.4f}") TODO 5: print("\n特征重要性排名(从高到低):") for importance, name in sorted(zip(forest_model.feature_importances_, feature_names), reverse=True): print(f"{importance:.2f} {name}") TODO 6: 现有 3 个新用户待判定,数据如下(顺序与 feature_names 一致): 用户 A: [290, 0.12, 0.97, 1, 0.05] 用户 B: [150, 0.52, 0.88, 6, 0.15] 用户 C: [310, 0.48, 0.95, 0, 0.03] (a) 用训练好的随机森林逐一预测 (b) 用户 C 比较特殊:用电量正常、电压正常,但深夜用电占比很高。 结合特征重要性,分析模型为什么这么判,作为人工复核者是否同意。 new_users = np.array([ [290, 0.12, 0.97, 1, 0.05], [150, 0.52, 0.88, 6, 0.15], [310, 0.48, 0.95, 0, 0.03], ]) preds = forest_model.predict(new_users) confidences = forest_model.predict_proba(new_users).max(axis=1) for name, p, conf in zip(["用户A", "用户B", "用户C"], preds, confidences): label = "窃电嫌疑" if p == 1 else "正常" print(f"{name}: 判定={label}(置信度 {conf:.2f})")输出结果:
通过特征重要性排名可以优先核查什么?
-->>电压合格率异常 + 深夜用电占比异常的用户
理由:电压合格率是窃电最直接的物理后果——绕表、私拉线路会拉低台区电压质量,
仪表一测便知;深夜占比是行为证据;而"月均用电量低"反而几乎没贡献(0.01):
一是正常用户用电量本身波动很大(±30%),区分度弱;
二是被更早分裂的电压/深夜特征"抢了功劳"(掩蔽效应)——所以业务直觉里的"头号指标"未必是模型眼里的关键
新用户ABC里面,为什么用户C是正常但是置信度55%?----是典型的"特征打架"样本
结论:
1.电压合格率(0.37)是模型眼里的头号指标,月均用电量(0.01)几乎为零——和业务直觉(用电量低=窃电)相反
两个解释:正常用户用电量本身波动 ±30%,区分度弱;且电压/深夜特征先分裂,把它的功劳“掩蔽”了。教训:业务直觉的关键指标 ≠ 模型的关键特征,排名信数据、措施靠机理
2.用户 C 是“人工复核”案例:置信度 0.55,深夜占比 0.48 指向嫌疑,但电压、告警、线损全指向正常(特征打架)。正确业务动作:不列嫌疑(可能有夜班工厂的正常解释)但转现场核实
3.整体 99.7%~100% 的准确率说明这是一套“干净的合成数据”:真实反窃电稽查的准确率远没这么漂亮(嫌疑用户会伪装),这段代码的价值在于流程骨架——划分→对比→选型→解释→复核