Orange3 数据挖掘工作流:可视化建模与 Python 脚本复现
简介:Orange 是一款基于组件的数据挖掘与机器学习软件套装,面向数据分析初学者、教学人员及需要快速验证建模思路的开发者,内置可视化编程前端与 Python 脚本扩展,可完成数据预处理、特征探索、建模、模式评估与结果可视化等流程。压缩包共收录约 2000 个文件,整体约 19.26MB,内容以 png 界面与图表、py 脚本、txt/rst 文档说明为主,并包含 svg、cpp/hpp、tab、csv 等素材、源码、数据表与示例配置,便于了解组件实现和示例数据组织。该资源已有 2102 人学习下载,适合作为 Orange 组件、示例工作流和跨平台 Qt 图形界面的参考资料,也可用于课堂教学演示、脚本开发入门和数据分析项目原型搭建,帮助读者较快理解从数据接入、预处理到建模评估的完整工具链。
1. 数据挖掘不用先写代码:Orange 把建模流程摊在画布上
很多人第一次接触数据挖掘和机器学习,卡住的地方不是算法,而是"我该先做什么、后做什么"。Orange 这个开源项目解决的就是这件事:它把读数据、清洗、特征变换、训练、评估这几步做成一块块可以拖拽的组件,连上线就能跑出一份带交叉验证的结果;而它背后又是纯 Python 的Orange3库,画布上每个组件的参数,都能用同样的几行脚本复现出来。对刚要把一套流程跑通的人,它是可视化试验台;对要交付可复现脚本的人,它是把探索过程直接转成代码的桥梁。下面按装环境、喂数据、搭流程、写脚本、避坑的顺序走一遍。
2. 环境与数据入口:pip 装完之后先解决列类型
在画布上拖组件之前,有两件事必须先落地:一是 Python 环境别和系统里已有的东西打架,二是数据和列类型要提前定死。这两步做得糙,后面所有模型的结论都不可信。
2.1 用独立环境装 Orange3,避开 Qt 与科学计算栈的版本冲突
Orange 的图形界面依赖 Qt,底层又依赖 numpy、scipy、scikit-learn、pandas 这一整串。如果直接往系统 Python 里装,很容易撞上已有的 Qt 绑定或者旧版 scikit-learn,表现出来就是双击图标闪退、菜单里组件灰掉、插件装不上。常见做法是给每个项目单独开一个虚拟环境。
# 建一个干净环境,Python 版本别太老,3.10 往上比较省心 python -m venv orange-env # Linux / macOS 激活 source orange-env/bin/activate # Windows 激活 # orange-env\Scripts\activate # 升级打包工具,再装核心包 python -m pip install --upgrade pip setuptools wheel pip install orange3 # 起图形界面 python -m Orange.canvas这段命令的逻辑很直白:venv隔离依赖,pip install orange3会把运行时需要的一整串科学计算包一起拉下来,最后那句python -m Orange.canvas是启动入口——注意不是orange3这个命令,从模块方式启动能避免 PATH 里指向另一个解释器的问题。
装完先别急着开界面,用几行代码确认库能正常导入、版本能打印出来:
import Orange import numpy, scipy, sklearn, pandas # 打印各层依赖的实际版本,出问题时这张表比任何报错都值钱 print("orange3 :", Orange.__version__) print("numpy :", numpy.__version__) print("scipy :", scipy.__version__) print("sklearn :", sklearn.__version__) print("pandas :", pandas.__version__)这段脚本的作用是留一份"环境快照"。Orange 的组件在渲染时会去读底层库的接口,一旦 scikit-learn 的版本跳得太多,某些组件的参数面板会直接报错但界面不崩,属于典型黑匣子现象。把版本打出来存一份,后面换机器复现时直接对着看。
参数层面要注意的是:不要在一个环境里同时装多个大版本的 Orange,也不要为了让某个插件装上而强行pip install -U scikit-learn去覆盖已有版本。要装插件,就在这个干净环境里装,让 pip 自己去解依赖。
2.2 CSV 进 Orange 之前,先想清楚哪些列是连续、哪些是分类
Orange 的数据模型把列分成三类:特征(features)、目标(target/class)、元数据(metas)。其中特征和目标的取值又分连续(numeric)和离散(categorical)。这个分类不是装饰,它决定了后面哪些算法能用:需要距离度量的算法(kNN、k-Means、SVM)遇到没编码的字符串列会直接失效,逻辑回归遇到没归一化的连续列会收敛得很差。
Orange 读 CSV 时有自己的一套类型标记语法。第一行写列名,第二行写类型标记,第三行写角色标记。这套写法很多人不知道,结果全靠自动推断,推断错了再回来改,来回好几轮。
| 标记 | 含义 | 什么时候用 |
|---|---|---|
c | 离散(categorical) | 性别、渠道、等级这类取值有限的列 |
s | 字符串(string) | 订单号、备注,一般放元数据,不参与建模 |
# | 元数据(meta) | 只跟着行显示,不进入特征矩阵 |
| 空 | 连续(numeric) | 金额、时长、次数 |
第三行写class | 标为目标列 | 分类任务的标签 |
第三行写meta | 标为元数据 | 同上,只是显式声明 |
一份带标记的 CSV 长这样:
客户编号,访问次数,平均停留时长,来源渠道,是否流失 s,,,c,class meta,,,,meta C0001,12,3.5,搜索,否 C0002,3,0.8,推荐,是第二行四个位置分别是s(字符串)、空(连续)、空(连续)、c(离散),第五个位置空着表示目标列的类型交给 Orange 按取值推断——因为只有"是/否"两个值,会被识别成二分类。第三行标了class和meta,明确角色。
如果不用这套标记,那就得保证三件事:表头只有一行且没有合并单元格,缺失值统一写成空或者?(不要写NA、null、-),文件编码用不带 BOM 的 UTF-8。Excel 另存 CSV 默认带 BOM,Orange 读到第一列列名时会多出一个不可见字符,界面上看不出来,但写脚本时按列名取属性会抛KeyError,这个坑踩过的人都记得。
提示:拿不准列类型时,先在 File 组件里看一眼每列图标——连续列是数值图标,离散列是分类图标,字符串列带引号。图标错了就说明推断错了,回去改 CSV 的类型标记行,比在界面里一列一列手改快得多。
2.3 用 Table 把同一份数据读进脚本,验证"所见即所得"
画布上看到的数据和脚本里读到的数据必须是同一份。这一步验证过了,后面在界面里调参数、在脚本里批处理才不会出现两套结论。
from Orange.data import Table # 直接按路径读,Orange 会自动识别 CSV 的分隔符和上面的类型标记 data = Table("customers.csv") # 打印域信息:有哪些特征、目标是什么、哪些是元数据 print(data.domain) print("目标列:", data.domain.class_var) print("特征:", [a.name for a in data.domain.attributes]) print("元数据:", [a.name for a in data.domain.metas]) # 三个矩阵的形状:X 特征值、Y 目标值、metas 元数据 print(data.X.shape, data.Y.shape, data.metas.shape) # 看缺失值分布,这一步决定后面要不要挂 Impute import numpy as np print("特征缺失数:", np.isnan(data.X).sum())这里几个属性值得记住:data.domain.attributes只含特征,data.domain.class_var是目标变量(没有目标时为None),data.domain.metas是元数据。data.X永远是个二维浮点数组,data.Y是一维,data.metas是对象数组。
参数层面的关键点是domain:Orange 里几乎每个组件之间传的都是Table对象,而Table的核心就是domain加三个矩阵。想改列类型,正确做法是构造一个新的Domain再生成新Table,而不是去改data.X里的值——X是浮点数组,离散变量在这里存的是索引,直接改数字等于改了类别编号,后果很难查。
from Orange.data import Domain, ContinuousVariable, DiscreteVariable # 把"访问次数"从连续改成离散,取值按分位数切成三档 visits = data.domain["访问次数"] binned = DiscreteVariable.make( "访问次数分档", values=["低", "中", "高"], compute_value=Orange.preprocess.Discretize( method=Orange.preprocess.Discretize.EqualFreq(n=3) )(visits) ) # 用新变量替换旧变量,其余列保持不动 new_domain = Domain( [binned] + [a for a in data.domain.attributes if a.name != "访问次数"], data.domain.class_var, data.domain.metas, ) data2 = Table.from_table(new_domain, data) print(data2.domain)这段做的是域重写:Discretize.EqualFreq(n=3)表示按等频切三档,compute_value是 Orange 的惰性求值机制——变量本身记住"我怎么从原变量算出来",等到真正生成Table时才计算。Table.from_table(new_domain, data)就是触发这次计算的地方。
为什么不用Discretize组件在画布上点?两者结果一样,但脚本里做的好处是这一步能进版本管理,下次换数据直接重跑。这也是 Orange 的双面性:界面适合探索,脚本适合固化。
3. 画布上的建模流程:从 Impute 到 Test & Score 的最小闭环
一个能得出结论的 Orange 工作流,组件不会超过十个。但顺序错了、连错口,出来的数字照样漂亮,只是不可信。这一章把最小闭环拆开讲。
3.1 预处理组件的连接顺序,为什么不能随便调
一条稳妥的顺序是:File→Impute→(按需)Continuize/Discretize→Normalize→Feature Constructor→ 学习器 →Test & Score。每一步都在解决下一步的前置条件。
Impute放在最前面,是因为后面所有组件碰到缺失值的行为都不一致:Normalize会跳过缺失,Discretize会把缺失单独归一类,学习器有的报错有的静默丢弃整行。先补齐,后面才有确定行为。它的参数面板里几个选项的差别很实际:
| 选项 | 行为 | 适用场景 |
|---|---|---|
| Average / Mode | 连续列填均值,离散列填众数 | 缺失比例低(5% 以内),分布大致对称 |
| Model-based imputer | 用其余列训练一个模型来预测缺失值 | 列之间相关性明显,缺失不是随机产生 |
| As a distinct value | 缺失单独成一类 | 缺失本身有业务含义,比如"从未填写" |
| Remove rows with missing values | 直接删行 | 缺失比例高且样本量充足 |
Continuize处理离散特征。默认是 one-hot,把 k 个取值的列拆成 k 个 0/1 列。如果某个列是有序等级(低/中/高),用 one-hot 会丢掉顺序信息,这时候应该选 "Treat as ordinal",把它映射成 0/1/2。这个选择对线性模型影响很大,对树模型影响很小——树模型只关心切分点,不关心数值间隔。
Normalize必须在Continuize之后。原因是归一化作用在数值尺度上,离散列 one-hot 之后是 0/1,归一化会把它们变成负数,反而破坏了稀疏性;而连续列如果量纲差两个数量级(比如金额是万级、次数是个位数),距离度量会被金额完全主导。常规做法是只对连续列做标准化,Orange 的Normalize组件默认就只处理连续特征。
注意:
Feature Constructor放在归一化之后,是因为新构造的特征(比如两个连续列的比值)需要基于已清洗的值计算。如果先构造再清洗,缺失值会通过运算扩散,一个缺失变成两个缺失。
3.2 三类模型的参数落点:逻辑回归、随机森林、kNN
同一个数据集上跑三种模型,目的不是挑一个最高的分数,而是看不同假设下的结果差异有多大。差异小说明信号稳定,差异大说明模型选择本身就在影响结论,这时候更该去看特征重要性而不是盯准确率。
from Orange.data import Table from Orange.preprocess import Impute, Continuize, Normalize from Orange.classification import LogisticRegressionLearner from Orange.ensemble import RandomForestLearner from Orange.classification import KNNLearner from Orange.evaluation import CrossValidation, CA, AUC, F1 data = Table("customers.csv") # 预处理链:补缺失 -> 离散转 one-hot -> 连续标准化 pre = [ Impute(method=Impute.Model()), Continuize(multinomial_treatment=Continuize.Indicators), Normalize(zero_based=False), # False 表示按标准差标准化,不是压到 [0,1] ] # 逻辑回归:C 是正则化强度的倒数,越小正则越强 lr = LogisticRegressionLearner( penalty="l2", C=1.0, preprocessors=pre, ) # 随机森林:树的数量给够,深度先不限制,靠叶子最小样本数控复杂度 rf = RandomForestLearner( n_estimators=200, max_depth=None, min_samples_leaf=5, max_features="sqrt", random_state=42, preprocessors=pre, ) # kNN:k 取奇数避免二分类平票,距离用欧氏 knn = KNNLearner( n_neighbors=7, metric="euclidean", preprocessors=pre, ) learners = [lr, rf, knn] res = CrossValidation(k=10, stratified=True, random_state=42)(data, learners) print("CA :", CA(res)) print("AUC:", AUC(res)) print("F1 :", F1(res, average="weighted"))逐段看。Impute(method=Impute.Model())用的是模型补缺,适合列间有相关性的场景;如果数据量小、缺失多,换成Impute.Average()更稳。
Continuize.Indicators就是 one-hot,k 个取值产生 k 列。如果某个离散列取值特别多(比如上百个城市),one-hot 会把特征维度炸开,这时候改成Continuize.Remove直接丢掉,或者先做取值合并。
Normalize(zero_based=False)是按标准差标准化(减均值除标准差),True是压到 [0,1]。两种都行,但 kNN 和逻辑回归更常用前者,因为对异常值的敏感度低一些。
三个模型的参数里,最值得先动的是RandomForestLearner的min_samples_leaf。默认值很小,数据有噪声时会过拟合到每条记录,训练集分数虚高、交叉验证分数掉一截。调到 3 到 10 之间,通常能看到交叉验证分数先升后降,中间那个拐点就是比较合适的取值。
CrossValidation(k=10, stratified=True)的分层很重要:类别不平衡时不分层,某些折里正例可能只有一两个,AUC 会算出极端值。random_state固定住,是为了让同一份代码两次运行结果一致——这在调参时是必需的,否则你分不清分数变化来自参数还是来自切分。
3.3 Test & Score 的结果怎么读,别只看第一列
界面上Test & Score出来是一张表,行是模型,列是指标。默认第一列是 AUC 或者 CA,取决于目标列类型。多数人扫一眼第一列就下结论,这一步最容易翻车。
AUC衡量的是排序能力,和阈值无关。它只看正负样本的相对得分,所以对类别不平衡不敏感,这在流失预测、欺诈识别这类场景里比准确率可靠得多。但 AUC 有个盲区:它不关心概率校准得好不好。一个模型 AUC 很高,输出概率全挤在 0.4 到 0.6 之间,拿去做业务决策(比如按概率分档定价)就没法用。
CA(分类准确率)在类别不平衡时会骗人。正例占 5% 的数据集,全预测负例也有 95%。这时候要看F1和MCC,前者是精确率和召回率的调和平均,后者把四个混淆矩阵格子都考虑进去了,不平衡下更稳。
Precision / Recall是一对,业务上要哪个取决于错误代价。漏掉一个高风险客户代价大,看 Recall;误伤一个正常客户代价大,看 Precision。
Test & Score里还有个容易忽略的设置:Resampling 方式。交叉验证是默认,重复次数(Repeat train/test)可以设成 3 到 5,把随机切分带来的方差平均掉。如果样本量很少(几百条以内),改成 Leave-one-out,虽然慢但方差最小。数据量大到几十万条,交叉验证的十折训练成本就很高了,这时候用 "Test on train data" 快速看是否严重过拟合,再用 "Test on test data" 加独立验证集做最终判断。
提示:目标列是连续变量时,
Test & Score会切到回归指标(MSE、RMSE、MAE、R2)。这时候 AUC 那列直接消失,不是界面出问题,是任务类型变了。
4. Python 脚本里的 Orange3:把工作流变成可批处理的代码
画布适合探索,不适合每天跑。数据每周更新、模型要重新训练、结果要写进报表,这些都得靠脚本。Orange3 作为库用起来比想象中轻,关键是理解Table和Domain这两个对象。
4.1 Table 与 Domain 的操作边界
Table是数据的容器,Domain是列的定义。所有变换本质上都是"生成新 Domain,再用它生成新 Table"。理解这一点,就不会去改data.X里的值。
from Orange.data import Table, Domain, ContinuousVariable, DiscreteVariable data = Table("customers.csv") # 按行筛选:只保留访问次数大于 5 的记录 mask = data[:, "访问次数"].X.flatten() > 5 filtered = data[mask] print(len(data), "->", len(filtered)) # 按列选取:取前三个特征加目标列,其余丢掉 cols = [a.name for a in data.domain.attributes[:3]] sub = data[:, cols + [data.domain.class_var.name]] print(sub.domain) # 手写构造一个 Domain,把某一列升成目标列 target = data.domain["是否流失"] new_domain = Domain( [a for a in data.domain.attributes if a.name != "是否流失"], target, ) relabeled = Table.from_table(new_domain, data) print(relabeled.domain.class_var)data[mask]的行筛选返回新Table,原对象不变。data[:, cols]的列选取语法和 pandas 有点像,但第一个冒号是行、第二个是列,列可以用名字列表。Table.from_table(new_domain, data)是重写域的标准入口——它按新域的定义从原表里重新取值,比手工拼数组安全得多。
Domain构造函数的三个参数依次是:特征列表、目标变量、元数据列表。目标可以是None(无监督任务),元数据可以省略。这里最容易出错的是把同一个变量同时放进特征和目标,运行不报错,但评估结果会异常好——因为标签泄进了特征。
4.2 Python Script 组件里的 in_data / out_data 契约
画布上有个Python Script组件,它把脚本嵌进工作流里跑。这个组件的输入输出变量名是固定的,写错名字不会报错,只会拿到None。
# 输入变量(组件连了什么就有什么,没连的是 None) # in_data : Table 上游数据 # in_learner : Learner 上游学习器 # in_classifier: Model 上游已训练模型 # in_object : 任意对象 上游自定义对象 # 输出变量(不赋值就是没输出) # out_data : Table # out_learner : Learner # out_classifier: Model # out_object : 任意对象 import numpy as np from Orange.data import Table, Domain, ContinuousVariable out_data = None if in_data is not None: # 例:给每个样本加一列"访问频次的对数" freq = in_data[:, "访问次数"].X.flatten() log_freq = np.log1p(np.nan_to_num(freq)) new_var = ContinuousVariable("访问次数对数") new_domain = Domain( list(in_data.domain.attributes) + [new_var], in_data.domain.class_var, in_data.domain.metas, ) out_data = Table.from_table(new_domain, in_data) # 显式把算好的值写回新列 out_data[:, new_var.name] = log_freq.reshape(-1, 1)这段的关键在最后两行。Table.from_table只负责按新域重建结构,新加的列默认是缺失值,得手动赋值。赋值时右边必须是二维数组(reshape(-1, 1)),因为out_data[:, 列名]拿到的切片按列处理。
in_data为None的判断不能省。组件在画布上可能还没连线,或者上游数据为空,这时脚本直接跑会抛AttributeError,而且报错栈藏在组件的日志面板里,界面上只看到一个红叉。
参数层面,Python Script组件本身没有参数面板,所有配置都写在代码里。这意味着脚本里的常量(比如np.log1p的底、阈值)改一次就得重存一次工作流。常见做法是把这些常量提到脚本顶部,用注释标清楚业务含义,别散在中间。
4.3 保存与复现 .ows 工作流
画布上搭好的流程存下来是一个.ows文件。它本质是个压缩包,里面记录组件类型、位置、连线和每个组件的参数设置,以及一份版本信息。
复现时最容易出问题的地方是版本。同一个.ows在新版本 Orange 里打开,组件可能会被自动升级,个别参数被重命名或删除,界面上看不出来但结果会变。血泪经验是:交付工作流时,把环境快照和 .ows 一起存。第 2 章那段打印版本的脚本,就是干这个用的。
另一个细节是数据源路径。File组件里记的是绝对路径还是相对路径,直接决定了换台机器能不能打开。相对路径的前提是.ows和数据文件在同一个目录树下,交付时整包一起给。
如果要把工作流变成无人值守的批处理,正确做法不是去解析.ows,而是把流程用脚本重写一遍(就是 3.2 节那段代码),用Table读数据、用Learner训练、用CrossValidation评估。.ows只作为"当时是怎么试的"这份记录的载体。
5. 避坑:五个把结论带偏的常见问题
5.1 数据侧:预处理放错位置,交叉验证会泄漏
现象:交叉验证 AUC 跑到 0.95,换个数据集或者换个随机种子掉到 0.7 左右。模型本身没变,分数却大幅波动。
原因:Impute和Normalize如果单独挂在File后面、并且数据在进入Test & Score之前就已经被处理过一遍,那么填充用的均值、标准化用的标准差里包含了验证折的信息。这叫数据泄漏,验证折的统计量污染了训练过程,分数虚高。
解决:预处理要么作为Learner的preprocessors参数传入(像 3.2 节那样),让它在每一折内部重新拟合;要么在画布上把预处理组件连到每个学习器的输入口,而不是连在File和数据分叉点之间。判据很简单:预处理是否"见过"验证数据。见过就是泄漏。
5.2 建模侧:不平衡数据下准确率骗人
现象:模型准确率 0.93,业务方很满意,上线后发现该识别出来的样本几乎一个都没抓到。
原因:正例占比 7%,全预测负例就能拿到 0.93。Test & Score默认第一列可能显示 CA,这个数字在没有基线对比时完全没意义。
解决:先算一个"全预测多数类"的基线,把它和模型结果并排放。然后切到 AUC、F1、MCC 看。如果确实要做不平衡建模,Orange 里可以用Learner的class_weight参数(多数学习器支持)给少数类加权,或者在上游用Select Rows做过采样/欠采样——但采样只能在训练折内做,放到全量数据上做又会回到 5.1 的泄漏问题。
5.3 建模侧:随机森林不设随机种子,调参结论不可复现
现象:同一个参数组合跑两遍,分数差 0.02,来回试了十几轮,不知道哪次是真的提升。
原因:随机森林的样本自助采样和特征随机选择都带随机性,不固定random_state时,两次训练用的是不同的随机子集,分数波动本身就盖过了参数带来的差异。
解决:所有带随机性的学习器统一设random_state。更稳妥的做法是配合CrossValidation的重复次数(重复 3 到 5 次),看分数的均值和标准差,而不是单次结果。判断一个参数是否真的有用,看的是"改动后均值提升是否超过重复实验的标准差",不是看某一次跑出来的数字。
5.4 环境侧:插件装上了,组件是灰的
现象:pip install某个 Orange 插件显示成功,重启画布后菜单里能看到对应分类,但组件图标是灰的,拖不动。
原因:插件依赖的底层库版本和当前环境冲突,或者插件用了当前 Orange 版本已经移除的接口。装的时候 pip 没报错,是因为它只检查了包是否存在,没检查运行时接口。
解决:先在命令行单独import一次插件模块,把真实报错打出来:
python -c "import orangecontrib.example; print('ok')"如果这一步就失败,报错信息里会指明缺哪个接口或者哪个库版本不对。按报错把冲突的包降到兼容版本,别盲目升级 Orange 本身——升级可能让其他已经跑通的组件失效。装插件前先看一眼它的依赖声明,和 2.1 节存的环境快照对一遍。
5.5 环境侧:CSV 带 BOM,按列名取属性抛 KeyError
现象:界面里数据加载正常、列名显示也正常,但脚本里data.domain["某列名"]抛KeyError,把列名打印出来看着一模一样。
原因:Excel 另存的 CSV 默认带 UTF-8 BOM,第一个列名前面多了一个不可见字符。界面上渲染时被吞掉了,字符串比较时却实打实存在。
解决:读的时候显式指定编码,或者先做一次清洗:
from Orange.data import Table # 让 pandas 先按 utf-8-sig 读掉 BOM,再交给 Orange import pandas as pd df = pd.read_csv("customers.csv", encoding="utf-8-sig", sep=",") data = Table.from_numpy( domain=None, # 传 None 让 Orange 自动推断域 X=df.select_dtypes("number").to_numpy(), )或者更直接的办法是回到源头,用文本编辑器把文件另存为不带 BOM 的 UTF-8。批量处理时,utf-8-sig是更省事的写法——它对不带 BOM 的文件同样有效,属于可以无脑加上的参数。
6. 进阶:用 Rank 加重复交叉验证,判断特征重要性是不是真的
Rank组件给出的特征重要性排序,很多人当成结论直接用。但它只是一个模型、一次训练下的结果。树模型的特征重要性在高相关特征之间会互相"抢分",两个高度相关的列,重要性可能一个 0.3、一个 0.05,而实际上它们提供的信息差不多。直接按这个排序做特征筛选,很容易把有用的一列删掉。
我的做法是把重要性当成一个分布来看,而不是一个点。具体是三步:换模型、换种子、看排名稳定性。
import numpy as np from Orange.data import Table from Orange.preprocess import Impute, Continuize, Normalize from Orange.ensemble import RandomForestLearner from Orange.classification import LogisticRegressionLearner from Orange.evaluation import CrossValidation data = Table("customers.csv") pre = [Impute(method=Impute.Average()), Continuize(), Normalize()] def importance_ranking(learner, data, seeds=(0, 1, 2, 3, 4)): """在不同随机种子下训练,收集每次的特征重要性排序""" names = [a.name for a in data.domain.attributes] ranks = [] for s in seeds: learner.random_state = s model = learner(data) # 树模型和线性模型都能导出 feature_importance imp = model.feature_importance if hasattr(model, "feature_importance") \ else np.abs(model.coefficients).flatten()[:len(names)] order = np.argsort(-imp) ranks.append({names[i]: r for r, i in enumerate(order)}) return ranks rf_ranks = importance_ranking(RandomForestLearner(n_estimators=200, min_samples_leaf=5, preprocessors=pre), data) # 汇总:每个特征在 5 次实验里的平均排名和排名波动 names = [a.name for a in data.domain.attributes] summary = [] for n in names: r = [rank[n] for rank in rf_ranks] summary.append((n, np.mean(r), np.std(r))) for name, mean_rank, std_rank in sorted(summary, key=lambda x: x[1]): print(f"{name:20s} 平均排名 {mean_rank:.1f} 波动 {std_rank:.2f}")这段的判读逻辑是:平均排名靠前、波动小的特征是真正稳定的信号。平均排名靠前但波动很大的,说明它和另一个相关特征在抢位置,删掉它模型可能一样好;平均排名靠后但波动小的,基本可以确认是噪声。
然后换一个假设完全不同的模型做对照。随机森林基于不纯度减少,逻辑回归基于系数绝对值,两者对同一批特征给出的排序如果高度一致,说明信号是真的;如果差得很远,说明重要性更多反映的是模型偏好,不是数据本身的结构。
# 用逻辑回归做一次对照,看两套排序的 Spearman 相关 from scipy.stats import spearmanr lr = LogisticRegressionLearner(C=0.1, preprocessors=pre) lr_model = lr(data) lr_imp = np.abs(lr_model.coefficients).flatten()[:len(names)] rf_mean = np.array([np.mean([r[n] for r in rf_ranks]) for n in names]) rho, p = spearmanr(rf_mean, -lr_imp) # 取负号:排名越小越重要,重要性越大越重要 print(f"两种模型排序一致性 rho={rho:.2f}, p={p:.3f}")C=0.1是刻意加大正则强度。正则强的时候,冗余特征的系数会被压向 0,相关性高的特征群会自然收敛到一个代表上,这个结果比不设正则时更能反映"去掉冗余之后还剩什么"。spearmanr这里做的是秩相关,不看数值大小只看排序,正好对应"重要性排序是否一致"这个问题。
还有一个容易被忽略的点:min_samples_leaf会改变重要性分布。叶子样本数设得太小,模型会记住噪声,噪声列的重要性被抬高;设得太大,模型欠拟合,真正有用的弱信号列被压平。所以交叉验证选出来的那个min_samples_leaf,同时也应该是算重要性的那个值,两处别用不同的设置。
从那以后我每次拿特征重要性出结论,都强制走一遍"多种子 + 双模型"的对照——单次、单模型的重要性排序,我最多拿它当个探索方向,不敢往报告里写。希望帮到你。
本文还有配套的精品资源,点击获取