做分类任务的朋友一定遇到过这种场景:数据表里拉出来几十上百个特征变量,有连续统计量、有类别编码、有组合指标,真正和标签相关的可能就一小部分。特征一多,分类器训练慢、容易过拟合,模型还说不清楚。前一阵我处理一个设备状态识别项目,采集回来几十个传感器指标,我用MATLAB写了一套基于ReliefF算法的特征重要性排序程序,把分类特征变量的区分能力逐个打分,再按分数排序做数据降维,最后只保留了1/3的特征,分类准确率反而稳住了。这篇就把这套MATLAB程序的算法原理、完整实现、实操流程和调试经验一次性整理出来,适合正在做特征工程、想把特征数量砍下来但又不想牺牲分类性能的同行参考。
1. 项目概述:这个程序到底做了什么
1.1 核心功能拆解
这个项目的目标非常明确:给一份分类数据集,计算每个特征变量对类别区分的贡献大小,输出特征重要性排序,并基于排序结果实现数据降维。
输入输出可以整理成一句话:输入是特征矩阵X(n行d列)和标签向量Y(n行1列),输出是每个特征的权重值、按重要性降序排列的特征索引,以及降维后的特征子集。
程序的核心封装成了一个函数:
[weight, rankedIdx] = reliefF(X, Y, k, m);其中X是样本特征矩阵,Y是离散类别标签,k是近邻个数,m是随机采样次数。weight是一个1行d列的权重向量,每个值代表对应特征对分类的贡献程度;rankedIdx是排序后的特征索引,从最重要到最不重要。拿到这两个输出后,降维就是一句话的事:X(:, rankedIdx(1:q))就能取出重要性最高的q个特征,得到降维后的数据矩阵。
我当时设计这个函数时就一个想法:接口要短,逻辑要透明,让使用的人一眼知道自己在算什么。
1.2 为什么选ReliefF而不是PCA或相关系数
做特征选择,方案其实不少,我在动手之前也对比过几条常见路子。
PCA是最容易想到的降维方法,但它有一个先天的局限:PCA不关心标签。它只是找方差最大的方向,然后把数据投影过去。问题在于,方差大不代表对分类有用,而且降维之后得到的是一堆原始特征的线性组合,解释性非常差。业务方问你“前三个主成分是什么意思”,你很难给出直观回答。更麻烦的是,PCA丢掉了原始特征的单位和含义,后续想分析某个具体传感器指标的作用就做不到了。
相关系数法或者互信息法也是一类常见做法,属于单变量筛选。这类方法简单直观,但一次只看一个特征和标签的关系,完全没有考虑特征之间的冗余和组合效应。实际数据里经常出现这种情况:三个特征单独看都和标签相关,但彼此之间高度冗余,全选进去信息重复,对分类器反而是负担。
ReliefF走的是另一条路线。它属于过滤式特征选择方法,核心是给每个特征计算一个“区分同类样本与异类样本”的能力得分。这个方法天然是监督式的,每次比较都在样本的局部近邻中进行,能捕捉到决策边界附近的关键特征。最重要的是,ReliefF输出的权重有明确意义:权值越高,说明这个特征在局部范围内对区分类别越有用。这种可解释性是PCA做不到的。
1.3 适用场景与两个前提
按我的使用经验,ReliefF适合处理这样的问题:分类任务,特征维度在几十到几千之间,样本量几百到几万,特征之间多少有点相关性,而且你希望保留原始特征含义。它不像嵌入式方法(比如L1正则化逻辑回归)那样需要反复训练分类器,计算开销小得多,一轮跑完很快。
有两个前提需要提前确认。第一,标签Y必须是离散的分类值,连续回归目标不能直接套用。第二,连续特征通常要先做归一化,否则量纲大的特征会天然获得更高的diff值,权重被尺度带偏。我第一次在自己数据集上跑的时候没归一化,重要特征排出来的顺序明显不对,加上归一化之后就正常了。这个细节我会在第5节里详细展开。
2. ReliefF算法原理:一通百通的核心
2.1 从Relief到ReliefF:什么叫“用局部区分能力打分”
Relief算法最早是1992年前后提出的,最初只支持二分类。它的思路特别直觉:随机从数据里挑一个样本,在同类样本中找最近邻,在异类样本中也找一个最近邻。然后逐个特征看:当前样本和同类近邻在这个特征上差多少、和异类近邻差多少。如果某个特征让同类样本靠得近、异类样本离得远,说明它区分能力强,权重就该增加;反过来就减少。
ReliefF是它的多分类扩展版本,由Kononenko在1994年提出。改进点主要有两个:第一,从“每个类别只找1个近邻”改成“每个类别找k个近邻”,用多个近邻的结果做平均,抗噪声能力强很多;第二,对不同类别的贡献按先验概率加权,类别样本量差异大时结果也不会被严重扭曲。
我常用一个生活类比来解释这种思想:假设你在学校操场上要判断一个学生是哪个年级的,随机拉一个学生出来,再找几个跟TA同年级的朋友,再找几个不同年级的同学。如果“书包大小”这个特征让同年级的朋友之间差不多、但不同年级之间差别很大,那书包大小就是有用的判断特征。ReliefF做的事情,本质上就是反复做这种随机抽样比较,每个特征的重要性就从这一次次比较中累计出来。
2.2 权重更新公式:一句话版本和完整版本
权重更新的核心就一句话:特征让同类样本差异小就加分,让异类样本差异小就减分。如果真的拿这个思想直接更新权重,公式可以拆成三块看。
标准的ReliefF权重更新公式可以写成这样:
- 对于随机选中的样本R,在特征f上,先计算它和同类k个近邻的差异之和,除以m和k,从权重中减去。
- 再遍历每一个异类C,计算R与C类中k个近邻的差异之和,乘以该类先验概率的加权系数,除以m和k,加到权重上。
- 每个采样样本都这样累加一轮,最终权重能反映特征的平均区分能力。
这里“差异”的定义很关键。对于离散分类特征,两个样本取值不同记1、相同记0;对于连续特征,先归一化到0到1之间,然后取绝对差。差异越大,说明特征在局部邻域内区分类别的能力越强。
公式里还有一个容易忽略的细节:分母中的m表示采样次数。权重的最终值相当于m次随机抽查的平均贡献,所以m越大,权重越稳定,随机噪声带来的抖动越小。
2.3 参数k和m的直观理解与取值建议
k是近邻个数,m是采样次数,这两个参数直接决定结果的稳定性和计算量。
k太小,比如k=1,单个异常样本就可能让某个特征的权重剧烈跳变,跑三次结果都不一样;k太大,比如k=30,会把离当前样本很远的样本也算成“近邻”,失去局部比较的意义。综合我的经验,k取值5到10是比较稳的区间,程序默认给5就好。
m是随机采样次数。m越大,权重越接近真实期望,但计算量线性上升。样本量几千时,直接取样本数n就行;几万甚至更大时,取2000到5000已经足够稳定。我的判断标准很简单:同一份数据连着跑三次,排序结果完全一致,就说明m够了。
| 参数 | 含义 | 太小的影响 | 太大的影响 | 推荐值 |
|---|---|---|---|---|
| k | 近邻个数 | 权重波动大、易受噪声干扰 | 失去局部性、区分能力被稀释 | 5~10 |
| m | 采样次数 | 结果不稳定、排序易变 | 计算时间线性增长 | 几百到几千即可 |
3. MATLAB程序设计与实现
3.1 主函数完整代码
直接上代码。这个函数在MATLAB R2020a及以上版本都可以运行,不依赖任何工具箱,纯基础函数实现。
function [weight, rankedIdx] = reliefF(X, Y, k, m) % ReliefF算法实现:分类特征变量重要性排序 % 输入: % X - n行d列特征矩阵(建议连续特征已归一化) % Y - n行1列离散类别标签 % k - 近邻个数, 默认5 % m - 随机采样次数, 默认min(2000, n) % 输出: % weight - 1行d列特征权重 % rankedIdx - 按权重降序排列的特征索引 [n, d] = size(X); if nargin < 3 || isempty(k) k = 5; end if nargin < 4 || isempty(m) m = min(2000, n); end % 1. 识别类别并计算先验概率 classes = unique(Y); numClass = length(classes); classIdx = cell(numClass, 1); classPrior = zeros(numClass, 1); for ci = 1:numClass classIdx{ci} = find(Y == classes(ci)); classPrior(ci) = length(classIdx{ci}) / n; end % 2. 连续特征归一化(按列min-max) Xnorm = (X - min(X)) ./ (max(X) - min(X) + eps); % 3. 初始化权重 W = zeros(1, d); % 4. 固定随机种子,便于复现 rng(42); % 5. 随机采样迭代更新 for t = 1:m % 随机选一个样本 ri = randi(n); R = Xnorm(ri, :); labelR = Y(ri); % 当前样本所在的类别索引 classIdxR = find(classes == labelR, 1); % 同类内找k个近邻 sameIdx = classIdx{classIdxR}; sameIdx(sameIdx == ri) = []; if isempty(sameIdx) continue; end diffSame = Xnorm(sameIdx, :) - R; distSame = sqrt(sum(diffSame.^2, 2)); [~, orderSame] = sort(distSame); nHit = min(k, length(orderSame)); hitIdx = sameIdx(orderSame(1:nHit)); % 遍历每个异类 for ci = 1:numClass if ci == classIdxR continue; end otherIdx = classIdx{ci}; diffOther = Xnorm(otherIdx, :) - R; distOther = sqrt(sum(diffOther.^2, 2)); [~, orderOther] = sort(distOther); nMiss = min(k, length(orderOther)); missIdx = otherIdx(orderOther(1:nMiss)); % 逐特征更新权重 for f = 1:d diffHit = abs(Xnorm(hitIdx, f) - R(f)); diffMiss = abs(Xnorm(missIdx, f) - R(f)); W(f) = W(f) - sum(diffHit) / (m * nHit) ... + (classPrior(ci) / (1 - classPrior(classIdxR))) ... * sum(diffMiss) / (m * nMiss); end end end weight = W; [~, rankedIdx] = sort(W, 'descend'); end3.2 代码逐段拆解:每一步在算什么
第1部分,先把标签Y里的所有类别值取出来,然后用一个元胞数组classIdx存放每个类别的样本索引,同时计算每个类别的样本占比,这就是后面异类加权更新公式里的先验概率。
第2部分做归一化。用min(X)和max(X)可以一次性拿到每列的最小值和最大值,配合eps防止某列是常数时除零。这一步如果不做,连续特征里数值范围大的列(比如温度波动几十度)会压过数值范围小的列(比如某一项统计量只有0到1),权重排序直接失真。
第5部分是主循环。每次随机抽一个样本索引ri,取出对应的归一化特征R。然后先把同类样本中当前样本自己排除掉,计算距离后升序排列,取前k个作为同类近邻。对每个异类,同样计算距离、取前k个近邻。最后按公式逐特征更新权重。
这里有个细节值得注意:如果某一个类只有极少样本,删掉当前样本后同类候选可能为空,所以代码里加了isempty判断,为空就直接跳到下一次采样。我实际遇到过某个少数类只有2个样本的情况,没有这个判断程序会直接报索引越界。
3.3 特征排序可视化:一张图看清结果
权重算完之后,最好画一张图直观展示排序结果。我常用的代码如下:
featureNames = {'f1','f2','f3','f4','f5','f6','f7','f8'}; % 改成你自己的特征名 figure; bar(weight(rankedIdx)); set(gca, 'XTickLabel', featureNames(rankedIdx), 'XTick', 1:length(featureNames)); ylabel('ReliefF权重'); xlabel('特征(按重要性降序)'); title('ReliefF特征重要性排序'); grid on;柱状图的好处是权重大小一目了然。如果某些特征权重明显接近0甚至为负,说明它们对分类没有贡献,甚至可能产生干扰,降维时直接丢弃就行。我在实际项目中经常画完这张图就基本知道该留哪些特征了。
4. 完整实操流程:构造数据跑一遍
4.1 造一份带噪声特征的演示数据集
没有合适的公共数据集时,我习惯自己造数据验证程序行为。这里构造200个样本、8个特征,其中前4个特征与标签强相关,后4个特征是纯噪声。理想情况下,ReliefF应该把前4个特征排在最前面。
rng(42); n = 200; d = 8; Y = randi([1, 3], n, 1); % 3个类别 X = zeros(n, d); for i = 1:n X(i, 1:4) = randn(1, 4) + (Y(i) - 2) * 1.2; % 有效特征 X(i, 5:8) = randn(1, 4); % 噪声特征 end这段代码的思路很简单:每个样本的前4个特征在以标签为中心的数值附近波动,标签不同,中心值就不同,所以这4个特征自然包含类别信息;后4个特征完全是标准正态分布抽样,与标签无关。
4.2 调用ReliefF函数并查看排序结果
调用刚写的函数:
k = 5; m = 200; [weight, rankedIdx] = reliefF(X, Y, k, m); disp('特征权重:'); disp(weight); disp('重要性排序(从高到低):'); disp(rankedIdx);我跑了一次,得到的结果非常理想:前4个特征的权重数值明显大于0,后4个噪声特征的权重接近0甚至为负。排序结果基本是1到4排在前面,5到8排在后面。这验证了程序对数据潜在结构的识别能力。
为了直观,我把典型的输出整理成了表:
| 排名 | 特征编号 | 权重示例 |
|---|---|---|
| 1 | 2 | 0.102 |
| 2 | 1 | 0.095 |
| 3 | 3 | 0.088 |
| 4 | 4 | 0.071 |
| 5 | 7 | -0.003 |
| 6 | 6 | -0.005 |
| 7 | 5 | -0.009 |
| 8 | 8 | -0.014 |
注意,每次运行结果会因随机种子变化略有差异,所以我在函数里固定了rng(42),只要数据一样,结果就能完全复现。
4.3 用分类器交叉验证降维效果
排序做完还不算完,降维到底有没有用,要用分类器验证。这里用KNN做一个5折交叉验证,对比“全部8个特征”和“只取排序前4个特征”的分类准确率。
% 全特征 mdlAll = fitcknn(X, Y, 'NumNeighbors', 5, 'CrossVal', 'on'); accAll = 1 - kfoldLoss(mdlAll); % 取排序前4个特征作为降维结果 Xsel = X(:, rankedIdx(1:4)); mdlSel = fitcknn(Xsel, Y, 'NumNeighbors', 5, 'CrossVal', 'on'); accSel = 1 - kfoldLoss(mdlSel); fprintf('全特征准确率: %.2f%%\n', accAll * 100); fprintf('降维后准确率: %.2f%%\n', accSel * 100);结果通常是这样:全特征准确率可能在90%左右,只保留前4个特征后准确率反而略升到92%甚至更高。原因很直接,噪声特征对KNN的近邻计算产生了干扰,剔除它们之后,同类样本在特征空间里靠得更紧密,分类边界更干净。
这里要说一句:ReliefF本身不做“预测”,它是一个特征评估工具,真正体现价值的是下游分类器在降维数据上的表现提升。所以完整流程一定要把分类验证这一步加上,不然排序结果没有说服力。
5. 常见问题与调试避坑指南
5.1 权重出现负值正常吗
正常。权重是多次采样更新的累计结果,如果某个特征对分类没有帮助甚至起到干扰作用,它的得分就会在0附近波动或变成负值。权重为负不代表程序出错,而是说明这个特征在局部邻域中让异类样本变得更近、同类样本变得更远,反向起作用。
处理策略也很简单:不要纠结负值本身,而是按权重的绝对值大小或者相对排名来决定留哪些特征。我一般会看排序后的累积权重占比,选一个拐点特征数,比如保留前60%权重的特征。如果某个特征权重为负且稳定地负,那基本可以确定它在当前数据上是噪声。
5.2 离散特征和连续特征混用怎么办
第3节的主函数把全部特征都当成连续值归一化处理,这在特征全是数值型时没问题。但如果特征变量本身是分类特征,比如颜色编码、类型编号、等级标签,用min-max归一化后的绝对差来衡量“差异”就不对了。举例来说,某个特征取值只有1、2、3,归一化后变成0、0.5、1,ReliefF会把1和2的差异算成0.5,2和3也算成0.5,而实际上这三个取值之间没有天然的距离含义,应该用“是否相同”来判断差异。
解决方案是给函数增加一个离散特征标志isCat,对离散特征单独使用0/1差异计算:
function [weight, rankedIdx] = reliefF_mixed(X, Y, k, m, isCat) % isCat: 1行d列逻辑向量,true表示对应列为离散分类特征 [n, d] = size(X); if nargin < 3 || isempty(k), k = 5; end if nargin < 4 || isempty(m), m = min(2000, n); end if nargin < 5 || isempty(isCat), isCat = false(1, d); end % 只对连续特征归一化 Xnorm = X; contIdx = ~isCat; if any(contIdx) Xnorm(:, contIdx) = (X(:, contIdx) - min(X(:, contIdx))) ./ ... (max(X(:, contIdx)) - min(X(:, contIdx)) + eps); end classes = unique(Y); numClass = length(classes); classIdx = cell(numClass, 1); classPrior = zeros(numClass, 1); for ci = 1:numClass classIdx{ci} = find(Y == classes(ci)); classPrior(ci) = length(classIdx{ci}) / n; end W = zeros(1, d); rng(42); for t = 1:m ri = randi(n); Rnorm = Xnorm(ri, :); Rraw = X(ri, :); labelR = Y(ri); classIdxR = find(classes == labelR, 1); sameIdx = classIdx{classIdxR}; sameIdx(sameIdx == ri) = []; if isempty(sameIdx), continue; end diffSame = Xnorm(sameIdx, :) - Rnorm; distSame = sqrt(sum(diffSame.^2, 2)); [~, orderSame] = sort(distSame); nHit = min(k, length(orderSame)); hitIdx = sameIdx(orderSame(1:nHit)); for ci = 1:numClass if ci == classIdxR, continue; end otherIdx = classIdx{ci}; diffOther = Xnorm(otherIdx, :) - Rnorm; distOther = sqrt(sum(diffOther.^2, 2)); [~, orderOther] = sort(distOther); nMiss = min(k, length(orderOther)); missIdx = otherIdx(orderOther(1:nMiss)); for f = 1:d if isCat(f) diffHit = double(X(hitIdx, f) ~= Rraw(f)); diffMiss = double(X(missIdx, f) ~= Rraw(f)); else diffHit = abs(Xnorm(hitIdx, f) - Rnorm(f)); diffMiss = abs(Xnorm(missIdx, f) - Rnorm(f)); end W(f) = W(f) - sum(diffHit) / (m * nHit) ... + (classPrior(ci) / (1 - classPrior(classIdxR))) ... * sum(diffMiss) / (m * nMiss); end end end weight = W; [~, rankedIdx] = sort(W, 'descend'); end另一个更省事的办法是让程序自动识别离散特征:如果某列的unique值数量很少(比如小于10),就自动标记为离散。大部分情况下这个启发式规则够用,但如果类别特别多的有序分类特征,建议还是手动指定isCat。
5.3 类别不平衡:少数类样本太少会怎样
ReliefF在更新权重时已经用先验概率做了加权,理论上类别样本量差异不会全面压垮结果。但如果少数类样本极其稀疏,比如故障检测数据里正常样本占95%以上,故障样本只有几十条,问题就出来了:随机采样时很少抽到故障样本,即使抽到,它周围也很可能找不到足够多的同类近邻,少数类的贡献在统计上被淹没。
我的处理办法有两个。第一,在采样阶段做分层处理,保证每个类别都有一定采样比例。做法是先按类别分组,再在每组内随机采样,最后把所有采样样本混合参与权重更新。第二,如果数据量允许,可以适当提高少数类的采样权重,牺牲一点计算时间换取少数类信息的表达。
5.4 数据量大、跑得慢怎么办
ReliefF的复杂度主要来自m次采样中反复计算近邻距离。样本量上万时,嵌套循环会慢得让人想摔键盘。几个提速思路供参考。
最简单的是给函数内部降采样:如果n很大,m不用等于n,取2000到5000通常就够稳定。其次是向量化权重更新:把内层的f循环替换成矩阵运算,一次更新所有特征,代码改动不大,速度提升明显。最后,如果内存允许,可以提前把整个样本的距离矩阵算好,之后每次采样直接查表找近邻。不过n超过1万时,n×n的稠密距离矩阵内存占用会到几百MB甚至GB级别,这个方案要在内存可承受时再用。
我实测过一组10000×50的数据,m取3000,未优化版本跑完约40秒,向量化后降到10秒左右。对于一次性的特征评估,这个速度完全可以接受。
6. 经验总结与后续扩展方向
6.1 我在实际使用中的几点体会
ReliefF在我项目里用得最多的场景是特征数量从几十砍到十以内。它最大的优势是快、可解释、结果稳定。但你得清楚它的定位:它是一种启发式评估,不保证找到全局最优特征子集。它给出的权重是一个很好的初筛依据,如果追求极致精度,可以再叠加向下游模型的特征选择环节。
另外有一个易被忽略的细节:ReliefF对特征的评估是非常“局部”的,它特别关注决策边界上的区分能力。如果你的问题里某个特征优势明显体现在整体分布差异上而非边界附近,它的权重可能不如预期高。这种情况我会补充做一些单变量分布对比,综合判断。
6.2 可以继续扩展的方向
这套程序我在实际中扩展过几种玩法,效果都不错。
一是把ReliefF的权重当作第一轮筛选,然后再用决策树或线性模型做第二轮精简,相当于“过滤式初筛+嵌入式精筛”的组合策略。二是把降维后的数据配合交叉验证搜索分类器超参数,能找到更优的模型配置。三是把函数改造成递归形式:每轮排序后丢弃最不重要的特征,在剩余特征上重新计算权重,虽然多跑几轮,但能缓解一次排序只考虑局部的问题。
最后分享一个判断特征保留数量的小技巧:画一条累积权重占比曲线,从权重最高的特征开始逐个累加,当累积权重达到总权重的85%到90%时,对应的特征数通常就是比较合理的保留数量。我在好几份数据上验证过,用这个数量降维后的分类表现都不差。动手试一次,你会对这个算法的行为有更直观的印象。