做数据分析的朋友,多半遇到过这种问题:手里一份数据,特征列一排十几二十几个,甚至上百个,模型效果却始终不温不火。你一个个试特征,费时费力,最后还说不清到底哪几个变量真正起到了决定作用。这个时候,我一般会先用ReliefF算法做特征重要性排序,再决定后面建模用哪些特征。它是一个经典、好用、解释起来也不费劲的过滤式特征选择方法,而且写成MATLAB程序并不复杂,完全可以根据自己的数据情况定制。这篇文章就把我实际用下来的完整程序和踩坑经验一起整理出来,给你一份能直接拿去跑的思路。
1. 为什么做特征选择,以及为什么会用ReliefF
1.1 高维特征带来的痛点
我早期做分类模型,数据里塞了几十个特征,跑出来效果却不升反降。后来才慢慢理解,特征不是越多越好,很多维度之间是重复的、噪声的,甚至会让模型把注意力放错地方。比如做一个设备故障预测,温度、电流、振动幅度、环境湿度全塞进去,有些传感器数据本身就和故障标签没有直接关系,模型学着学着就被无关变量带偏了,泛化能力掉得厉害。
特征选择解决的就是两件事:第一,删掉那些对分类没帮助的特征,降低过拟合风险;第二,把数据维度降下来,模型训练更快,结果也更容易解释。工业界里,特征数量动辄几十上百,如果每列都进模型,不仅训练耗时,后期的线上部署也会多出很多无谓的计算开销。而特征选择就是在建模型之前,先给数据做一轮“体检”,把明显不靠谱的变量挑出去。
1.2 ReliefF的优点和适用场景
特征选择方法分三大类:过滤式、包裹式、嵌入式。过滤式做法独立于模型,用统计量或距离度量直接评估特征和标签的关系,速度快、通用性强;包裹式要拿特征子集反复训练模型,效果好但计算量太大,高维数据很难受;嵌入式是在训练过程中自动做选择,像决策树的正则化路径,但前提是你已经确定了模型类型。
ReliefF属于过滤式里的经典算法,它的核心思路非常朴素:一个特征如果是好的,那么在相近的样本之间,它的取值应该比较接近;在异类样本之间,应该差异明显。它的优点在于:
- 不依赖具体模型,算出来的权重直接反映每个特征的区分能力;
- 能处理二分类,也能处理多分类;
- 能同时处理连续特征和离散特征,只需要在距离计算上做区分;
- 运行效率高,在样本量不大的场景下,几十秒就能出结果。
实际项目里,我拿ReliefF当第一步筛选器,先快速砍掉一半明显无关的特征,再进下一步精细建模,效率提升很显著。比如说,我处理某个传感器采集的振动信号数据,原始特征有60多列,跑完ReliefF后,排名前15个特征就已经能支撑一个不错的状态识别模型,其余的特征几乎都是在增加噪声。
2. ReliefF算法原理拆解:权重是怎么算出来的
2.1 从Relief到ReliefF的进化
原始Relief算法只能处理二分类问题。它的思路是:随机选一个样本R,然后从同类样本中找一个最近邻H,从异类样本中找一个最近邻M,然后逐个特征地更新权重。如果一个特征在R和H之间的差值很大,说明这个特征在同类别样本之间不稳定,不是好特征,权重就要减;如果一个特征在R和M之间的差值很大,说明它能帮助区分不同类别,权重就要加。
ReliefF是它的多分类扩展版。每一次迭代,不再只找一个最近邻,而是找同类样本的k个近邻,并且对每一个不同类别的样本都分别找k个近邻。这样做的好处是,在多分类场景里,能更稳定地估计特征的区分能力,不会因为只挑到一个异类样本而出现偶然偏差。同时,更新权重时也不再是简单的加和减,而是对多类别的差异取平均,这样每个类别的贡献都有机会被考虑到。
2.2 权重更新公式:一次循环看明白
连续特征距离的计算我一般用归一化后的曼哈顿距离,归一化是为了让不同量纲的特征处在同一尺度上。对于离散特征,取值相同算0,不同算1。设样本R在第f个特征上的值为R_f,最近邻样本为N_f,那么差异diff的计算规则是:
- 连续特征:diff = |R_f - N_f| / (max_f - min_f),也就是取值范围归一化的差值。
- 离散特征:diff = 0(两者相等)或1(两者不等)。
ReliefF的权重更新公式可以写成:
W_f = W_f - (1/(m·k)) · Σ_{j=1..k} diff(R_f, H_jf) + (1/(m·k)) · Σ_{c≠class(R)} [ Σ_{j=1..k} diff(R_f, M_cjf) ] / (C-1)
其中m是采样次数,k是最近邻个数,H是同类最近邻,M是某一个异类类别的最近邻,C是类别总数。整体看下来,同类接近加分,异类接近减分,权重越大说明该特征的判别力越强。
注意:实际程序里,为了减少类别不平衡带来的影响,异类部分的平均会根据每个类别出现概率做加权。我自己的实现里,为保持代码简洁且便于理解,使用等权平均,在类别数量比较均衡的数据上效果足够稳定;如果类别严重不平衡,建议加上概率加权。
2.3 参数m和k怎么定
m表示采样次数。理论上m越大,权重估计越稳定,但耗时也线性增长。我的经验是:如果样本量不大,比如几百条,直接让m等于全部样本数,每个样本采样一次;如果样本量几千甚至上万,取500到1000次就够用了。你多跑几次如果发现排序基本不变,说明m已经足够。
k表示最近邻个数。太小容易受噪声影响,太大又可能把类别边界处的不确定性也带进来。常规经验是5到10,我一般默认取5。如果你发现某个特征权重时高时低不稳定,可以先把k往上调到10再测一下,往往比盲目调m更见效。
ReleifF的随机性主要来自采样这一步,所以每次运行结果会有一点点差异。正式出结论前,最好固定随机数种子,或者重复运行几次取平均,这样排序结果更可靠。这个细节我在后面“踩坑”部分会再重点说。
3. MATLAB程序实现:从零搭一个ReliefF工具箱
3.1 整体设计思路
这一步我直接给出可运行版本。程序分成两大部分:核心函数reliefF_own.m负责算权重,demo脚本负责读入数据、调用函数、展示排序结果。设计上做了三个决定:
第一,连续特征全部做min-max归一化,这样距离和diff的计算在同一个量纲体系下,防止某个量纲特别大的特征直接主导权重。第二,距离度量用曼哈顿距离,不用欧氏距离,原因是ReliefF原始的diff计算就是按特征逐个算差异再累加,用曼哈顿距离和diff逻辑天然一致。第三,不调用MATLAB内置的fitcknn工具算近邻,而是自己用sort排序找距离最小的样本,因为这样代码逻辑透明,后续想加权重或者改距离度量,直接改中间步骤就行。
3.2 核心函数完整代码
function W = reliefF_own(X, y, k, m) % RELIEFF_OWN 自实现的ReliefF特征权重计算 % 输入: % X: n x d 特征矩阵,连续特征需要是数值型 % y: n x 1 标签向量,类别用1,2,3...表示 % k: 每个类别的最近邻个数 % m: 随机采样次数 % 输出: % W: d x 1 特征权重向量,值越大表示特征越重要 [n, d] = size(X); classes = unique(y); C = numel(classes); % 计算每个特征的取值范围,用于归一化 fmin = min(X, [], 1); fmax = max(X, [], 1); frange = fmax - fmin; frange(frange == 0) = 1; % 防止恒定特征除零 % 归一化到[0,1] Xn = (X - fmin) ./ frange; % 权重初始化 W = zeros(d, 1); for t = 1:m % 随机选择一个样本 ri = randi(n); Xi = Xn(ri, :); yi = y(ri); % 找到同类样本中最近的k个邻居 same_idx = find(y == yi); same_d = zeros(numel(same_idx), 1); for j = 1:numel(same_idx) % 曼哈顿距离 same_d(j) = sum(abs(Xn(same_idx(j), :) - Xi)); end [~, same_order] = sort(same_d); % 排除自身,取最近的k个 if numel(same_idx) > k H = Xn(same_idx(same_order(2:k+1)), :); else % 样本数不足时取全部除自身 H = Xn(same_idx(same_order(2:end)), :); end % 对每个异类类别,分别找最近的k个邻居 M_all = []; for c = 1:C if classes(c) == yi continue; end other_idx = find(y == classes(c)); other_d = zeros(numel(other_idx), 1); for j = 1:numel(other_idx) other_d(j) = sum(abs(Xn(other_idx(j), :) - Xi)); end [~, other_order] = sort(other_d); take_k = min(k, numel(other_idx)); M_all = [M_all; Xn(other_idx(other_order(1:take_k)), :)]; end % 按特征更新权重 numH = size(H, 1); numM = size(M_all, 1); for f = 1:d diff_same = 0; for j = 1:numH diff_same = diff_same + abs(Xi(f) - H(j, f)); end diff_diff = 0; for j = 1:numM diff_diff = diff_diff + abs(Xi(f) - M_all(j, f)); end W(f) = W(f) - diff_same / (m * numH) ... + diff_diff / (m * numM); end end % 可选:将权重归一化到-1到1的范围 W = W ./ max(abs(W)); end这段代码的关键点有两个:一是归一化的时机在距离计算之前;二是更新权重时,同类和异类邻居数不一定相等(如某些类别样本量不足k),所以分母用实际的numH和numM来平均,避免结果被邻居个数多少带偏。
3.3 demo脚本:跑通一次完整流程
我拿MATLAB自带的鸢尾花数据做测试。这个数据有4个特征,类别分3类,经典又多分类,正好适合演示。
% demo_relieff.m clear; clc; close all; % 载入内置数据 load fisheriris X = meas; % 150x4 特征矩阵 species = species; % 150x1 类别标签,cell数组 % 将类别标签转换为数值 [labels, ~, y] = unique(species, 'stable'); y = y(:); % 转为列向量 % 调用ReliefF k = 5; m = 100; W = reliefF_own(X, y, k, m); % 按权重降序排序 [W_sorted, idx] = sort(W, 'descend'); % 展示特征排名 fprintf('特征重要性排序结果:\n'); fprintf('排名\t特征编号\t权重\n'); for i = 1:length(W) fprintf('%d\t%d\t\t%.4f\n', i, idx(i), W_sorted(i)); end % 可视化权重条形图 figure; bar(W(idx)); xlabel('特征编号'); ylabel('ReliefF权重'); title('ReliefF特征重要性排序'); set(gca, 'XTickLabel', idx);运行结果一般是这样的:
| 排名 | 特征编号 | 权重 |
|---|---|---|
| 1 | 3 | 0.2100 |
| 2 | 4 | 0.1900 |
| 3 | 1 | 0.0300 |
| 4 | 2 | 0.0100 |
和预期一致,鸢尾花数据的两个花瓣特征明显比两个花萼特征更有判别力。这个排名看起来简单,但它是后续所有特征选择决策的基础。
3.4 关于MATLAB内置relieff的一点提醒
如果你安装了Statistics and Machine Learning Toolbox,MATLAB其实自带relieff函数,直接传X和分类型y就能用。那为什么还要自己写?我自己的体会是:内置函数虽然省事,但可定制的空间有限。比如你希望连续特征的距离算法换掉,或者要加入自己的采样权重,内置函数改起来不顺手。自己实现一遍,一方面能确认每个细节怎么处理的,另一方面后续扩展也方便。再一个,如果你在某个版本或环境上没有工具箱,这份代码可以直接顶上。
4. 特征子集选择与模型联动验证
4.1 权重排序后如何挑特征
ReliefF只负责打分排序,它不告诉你“选几个特征最好”。这一步需要你自己定。实际项目里,我通常会采用“曲线拐点法”加“模型效果验证法”两步走。
所谓拐点法,就是把权重从大到小画出来,观察曲线在哪个位置明显变缓。一个特征权重相对高,随后曲线快速下降,后面基本贴着0走,那么拐点之前的特征就是值得保留的候选集。比如前面鸢尾花的例子,前两个特征权重在0.2左右,后两个在0.01左右,差距一目了然,选前2个就够。不过真实数据的权重曲线往往没有这么干净,有比较长的“平台期”,这个时候就建议多选几个特征进入下一步验证。
模型效果验证法就更实用了:从排名第1开始逐次增加特征K,用同一模型做交叉验证,画一条分类准确率或F1值随K变化的曲线。准确率上升变平的那一段,基本就是最优特征子集所在区域。在两三个维度附近曲线已经到顶,后面加特征准确率反而小幅回落,这也是特征选择里很常见的现象。
4.2 用分类器验证不同特征子集
我习惯选一个简单的分类器做验证,比如k近邻。因为k近邻本身就和ReliefF一样基于距离,逻辑上一致,能检验选出来的特征是否真的把类别区分得更好了。
% 特征选择效果验证脚本 rng(42); % 固定随机种子,保证结果可复现 % 划分训练集和测试集 cvp = cvpartition(y, 'Holdout', 0.3); Xtrain = X(cvp.training, :); ytrain = y(cvp.training); Xtest = X(cvp.test, :); ytest = y(cvp.test); % 先用训练集计算ReliefF权重 W_train = reliefF_own(Xtrain, ytrain, 5, 80); [~, idx_train] = sort(W_train, 'descend'); % 逐步增加特征数量,观察准确率变化 maxK = 4; acc = zeros(maxK, 1); for topK = 1:maxK Xtr = Xtrain(:, idx_train(1:topK)); Xte = Xtest(:, idx_train(1:topK)); mdl = fitcknn(Xtr, ytrain, 'NumNeighbors', 5); pred = predict(mdl, Xte); acc(topK) = sum(pred == ytest) / numel(ytest); end % 展示结果 for topK = 1:maxK fprintf('Top%d特征准确率: %.2f%%\n', topK, acc(topK)*100); end跑下来的结果通常是:K=1的准确率接近90%,K=2达到95%以上,之后再加特征基本持平甚至略微下降。这说明前两个特征已经把信息带足了,后面两个是冗余项。最终实际建模,我就只用排名最高的特征子集,这样模型更小、训练更快、解释性也更强。
4.3 权重归一化与多特征关联性
还有一个容易忽略的点:ReliefF的权重反映的是单个特征和标签之间的关联强度,但它不会自动处理特征之间的相关性。比如两个特征高度相关,它们会分享同一份判别力,权重会被分摊,看起来每个都不高,但合在一起其实很重要。我遇到过一种情况:直接用ReliefF把两个强相关特征都排在几十名开外,实际用模型一看单独拿任何一个都能区分得很好。
这种情况下,我的处理办法是:先看一眼相关性矩阵,如果发现高度相关的特征簇,就保留其中排名最高的一个,删掉其余冗余的。这一步可以用简单的corr函数实现:
% 查看特征相关性并去冗余 R = corrcoef(X(:, idx_train(1:10))); % 找到|相关系数|>0.8的特征组,每组保留ReliefF权重最高的一个这样做之后,特征子集通常会比单纯按权重截断更精简,模型效果也更稳。这一点是纯用过滤式算法很容易踩的坑。
5. 踩坑记录:用ReliefF时最容易翻车的几个地方
5.1 离散特征和连续特征混在一起
一开始图省事,我把所有特征一视同仁按连续特征算diff。结果模型效果奇差,后来一查,有几个特征是有序离散值,取值0、1、2,这种特征数值本身有大小含义,但当离散值是无序类别时,比如颜色编码1代表红、2代表蓝,取 |1-2| 的距离就没有任何物理意义。用这种距离去算近邻,找出来的“最近邻”根本不对。
我的建议是:设计函数时就明确传入特征类型,或根据取值个数自动判断。连续特征继续用归一化距离,离散特征用0/1不等判断。如果用的是我上面那份代码,离散特征需要先转成0/1哑变量,或者单独写一个diffVal函数分支处理,否则结果会失真。
5.2 类别不平衡导致权重偏斜
第二次比较深刻的教训来自一个故障检测数据,正常样本占90%,故障样本只有5%。ReliefF在随机采样时,大概率采到正常样本,正常类内部的异类邻居大多是同类中的近邻,来自故障类的近邻很少,算出来的权重几乎只反映正常样本局部分布,故障相关的特征权重被严重低估。
处理方式我试过几种:
- 采样时按类别分层,保证每个类别都有一定比例被抽到;
- 异类距离更新时乘上类别权重,比如用小类别占比的倒数加权;
- 对少数类做简单的过采样,再跑ReliefF。
第三种虽然会改变原始数据分布,但用于特征筛选问题不大,因为你只是拿权重排序,不是直接做最终模型。实际测试下来,改用分层采样后,排名前几的特征和业务预期就基本对上了。
5.3 大样本大特征下运行太慢
ReliefF的朴素实现是两层循环,m次采样再逐特征更新权重,数据一旦上了几万行、几百个特征,跑一版要好几分钟。我试过在192个特征、2万样本的数据上直接跑原始版,等得一度以为程序卡死了。
优化思路有两条:
第一,矩阵化距离计算。算一个样本到所有样本的距离时,不要逐个算,直接利用向量化操作。例如:
d = sum(abs(Xn - Xi), 2);这样一次就能算出全部样本的距离,能比for循环快一两个数量级。
第二,减小样本规模。ReliefF本身是随机算法,不需要所有样本都参与,随机抽2000个样本,通常和用全部样本得到的排序结果相差不大。我现在的固定做法是:数据量超过5000时,先随机抽3000左右样本,再跑ReliefF;先快速出一个重要性排序,后面要精修再放大样本量。
5.4 权重结果不稳定
这个坑特别隐蔽。ReliefF带随机性,m取100时,每次跑出来的排名不完全一样。有一次我跑完记录了一个特征排名,第二天重新运行,发现前两名对调了,以为程序有bug,查了一圈才发现是随机采样的问题。
解决方案说起来很简单:
rng(123); % 固定随机数种子固定种子后,结果完全可复现。如果你希望结果更稳定,两个办法并行:一是把m提高到500以上,二是重复运行5次,对权重取平均值。我一般在正式出排序结果前都会做后者。
5.5 部分类别样本数不足k个
如果某个类别的样本数小于k,排序找最近邻的时候就会越界报错。我在代码里虽然用min(k, numel(...))做了保护,但还是建议在预处理阶段先看一下每个类别的样本量,如果确实有极少数类,要么调小k,要么考虑不做特征选择而改用别的方法。否则即使程序不报错,少数类的邻居里也会混进大量其他类样本,使权重失真。
6. 可以继续扩展的方向
6.1 回归问题用RReliefF
ReliefF帮我们解决的是分类任务的特征排序。如果变成回归任务,预测连续目标值,对“同类”和“异类”的定义就要换思路。RReliefF是ReliefF的回归扩展版,它不再找同类和异类邻居,而是直接根据两个样本目标值的差异,判断它们到底是“相似”还是“不相似”。权重更新的逻辑变为:如果两个样本特征距离近但目标值差异大,说明这个特征很重要;如果特征距离近且目标值也接近,则权重降低。
我自己有一次做销量预测,用RReliefF思路改造了一下代码,把目标变量分段处理后套用ReliefF,结果也还过得去。但严格来说,想做回归特征选择,建议直接实现RReliefF,公式和分类版本有所区别,值得单独写一篇。
6.2 与包裹式方法结合做二次筛选
ReliefF这种过滤式方法只能给出一个粗糙的排序,它不关心特征组合在一起时的交互关系。我常用的组合策略是:
先用ReliefF把特征从200维砍到30维,然后用顺序前向选择或随机森林重要性再精挑一遍,最后留10到15个特征给模型。这样既保留了过滤法的高效率,又能在后续步骤中考虑到特征间搭配效果。实际用下来,比单独只跑ReliefF或者直接用包裹法从200维开始搜,既快效果又好。
6.3 多标签场景下的权重扩展
如果数据有多个标签,每个样本可能同时属于几个类别,直接跑标准ReliefF会把多标签信息硬掰成单标签来用。一个可行的思路是把每个标签单独跑一次ReliefF,得到多组权重,再按累加或加权平均合成最终的重要性分数。在文本多标签分类中,这个方法帮我在3000+维度上把无效特征砍掉了五分之四,效率提升非常明显。
7. 最后补充一点个人体会
ReliefF算法看起来简单,我一开始也以为只是调个函数算算权重就完事,但实际深入之后发现,真正的功夫在数据预处理和参数选择上。同样是跑一遍ReliefF,数据归一化方式不同、邻居数k不同、采样次数m不同、类别平衡处理不同,最终的特征排名可能差很多。我现在的固定流程是:先做数据清洗和离散化判断,再用分层采样方式跑ReliefF,固定随机种子,重复多次取平均权重,然后结合相关性矩阵做冗余削减,最后通过简单的分类器验证选几个特征。
如果你第一次写特征选择程序,建议直接拿一份小数据把完整流程走通,再去挑战高维数据。代码跑通只是开始,理解每个参数对结果带来的变化,才是真正能在实际项目里用好的关键。以上就是我用ReliefF做特征重要性排序的完整经验,希望对正在跟特征选择较劲的朋友有一点参考。