前几天同事抱着一份三百多个特征的数据表来找我,问该怎么在建模前先挑出有用的特征。直接用模型硬扛当然可以,可一旦特征数量太多、样本量又小,模型很容易抖起来,后期解释每个变量也费劲。我当时给他推荐的就是ReliefF算法——一种轻量、直观、不需要训练复杂模型的特征重要性排序方法。这篇文章把完整的MATLAB实现和使用心得整理出来,适合做特征工程、特征选择,以及想在建模前加一道快速过滤器的人参考。
ReliefF会给每个特征打一个权重,权重大小代表该特征对区分不同类别样本的贡献程度。把这个权重排个序,特征重要性一目了然。整个过程不需要训练分类器,也不需要对数据分布做假设,跑起来非常快。下面我会先讲清楚算法原理,再给出一份可运行、可改的MATLAB程序,最后聊聊怎么基于权重结果做特征选择,以及实操中容易踩的坑。
1. ReliefF权重更新过程拆解:为什么它能衡量特征对分类的价值
1.1 从二分类Relief到多分类ReliefF
Relief算法最初是针对二分类问题设计的,思路非常朴素:随机从数据里挑一个样本R,然后找到离它最近的同类样本H和最近的异类样本M。如果某个特征让R和H靠得更近,那这个特征在同类之间表现稳定,值得加分;如果某个特征让R和M拉得更远,那说明它对区分类别有贡献,也应该加分。反过来,如果这个特征在同类样本之间差异很大,或者在异类样本之间差异很小,那它的区分能力就值得怀疑。
ReliefF是Relief的多分类扩展版本,做了三件重要的事:
- 不再只看最近的一个近邻,而是同时看多个近邻,减少单个异常样本带来的干扰。
- 对每个异类类别分别找近邻,而不是把“异类”笼统当成一类,这样天然支持多分类问题。
- 在不同类别之间引入先验概率加权,类别样本量不同时,更新贡献也会按比例调整,防止多数类主导权重。
这些改进让ReliefF在真实数据集上比原始Relief稳定得多,也是它至今仍是特征选择常用方法的原因。
1.2 权重更新公式的直观理解
假设采样了m个样本,每次随机取一个样本R,找k个同类近邻H和k个每个异类类别的近邻M。特征A的权重更新公式可以写成:
$$W(A) \leftarrow W(A) - \frac{1}{m k} \sum_{j=1}^{k} \text{diff}(A, R, H_j) + \sum_{C \neq \text{class}(R)} \frac{P(C)}{1 - P(\text{class}(R))} \cdot \frac{1}{m k} \sum_{j=1}^{k} \text{diff}(A, R, M_j(C))$$
其中diff函数衡量的是:在特征A上,样本R和近邻I之间的差异程度。
对于连续特征,通常定义为:
$$\text{diff}(A, R, I) = \frac{|R_A - I_A|}{\max(A) - \min(A)}$$
也就是两个样本在该特征上的绝对差值,除以该特征在整个数据集上的取值范围,目的就是消除不同特征量纲的影响。对于离散特征,定义更简单:取值相同记0,取值不同记1。
这个公式读起来有点抽象,我习惯把它理解成“惩罚与奖励”:
- 同类近邻之间的特征差异越小,说明该特征在同类内部越一致,权重减少得少;差异越大,说明同一个类别的样本在这个特征上七零八落,这个特征的分类价值就打折扣。
- 异类近邻之间的特征差异越大,说明该特征能把不同类别分开,权重增加得多;差异小,说明不同类别在这个特征上长得差不多,它就没有区分力。
- 对所有异类类别加权平均时,类别样本越少、占比越低的类别,在公式里的权重系数反而会放大,避免稀有类别被多数类淹没。
1.3 权重正负号怎么读
运行完ReliefF之后,每个特征会得到一个权重值,可正可负。很多人第一次看到负权重会愣住,实际上它并不表示特征“不好”,而是表示在采样到的近邻组合里,这个特征在同类之间和异类之间的差异表现整体偏弱,甚至带有干扰性。
一般来说:
- 权重正值较大:该特征对区分类别有明显帮助。
- 权重接近零:该特征与类别标签基本没关系,或者只是噪声。
- 权重明显为负:该特征在当前数据上有一定干扰作用,删除它往往不会损失信息,有时还能提升模型稳定性。
不过要注意,负权重不完全是坏事。某些特征单独看可能是噪声,但和别的特征组合在一起又会有用。所以ReliefF更适合做初筛,先把明显有区分力的特征挑出来,再配合建模验证最终保留哪些。
2. 自写myReliefF函数:一份可运行、可改的MATLAB实现
2.1 函数设计与输入约定
MATLAB自带的统计和机器学习工具箱里其实已经有relieff函数,一行就能拿到排序结果。那我为什么还要自己写一遍?主要是三点考虑:
relieff对多数人来说是个黑盒,权重到底怎么算出来、某一步是怎么处理的,不清楚。- 项目里经常需要调整算法行为,比如针对类别不平衡改抽样方式、自定义距离度量、在每次迭代中加额外逻辑,自己写实现会灵活很多。
- 理解原理之后,出问题才好排查,也才敢信任结果。
我的函数设计如下:
function [W, idxW, Wsorted] = myReliefF(X, y, varargin) % MYRELIEF 自实现ReliefF特征权重计算函数 % [W, idxW, Wsorted] = myReliefF(X, y, ...) % % 输入: % X : n行d列数值矩阵,n为样本数,d为特征数 % y : n行1列标签,支持数值、逻辑值、字符元胞或categorical类型 % 可选参数: % 'NumIterations' : 迭代次数m,默认min(n,200) % 'NumNeighbors' : 近邻数k,默认10 % % 输出: % W : d行1列权重向量,顺序与原始特征列一致 % idxW : 按权重降序排列的特征编号 % Wsorted : 与idxW对应的排序后权重输入数据的约定很简单:X必须是数值矩阵,如果你有性别、地区这类名义变量,建议先转成one-hot编码再传进来,原因后面避坑部分会详细说。
2.2 完整代码实现
下面是完整实现。逻辑不算复杂,最核心的就是两层循环:外层随机采样样本,内层对每个样本找近邻并更新权重。
function [W, idxW, Wsorted] = myReliefF(X, y, varargin) p = inputParser; addParameter(p, 'NumIterations', []); addParameter(p, 'NumNeighbors', 10); parse(p, varargin{:}); m = p.Results.NumIterations; k = p.Results.NumNeighbors; [n, d] = size(X); if n < 3 error('样本数至少需要3个。'); end % 标签转为类别编号 [~, ~, clsIdx] = unique(y(:)); nClass = max(clsIdx); if nClass < 2 error('数据至少需要两个类别。'); end % 默认迭代次数,通常取样本量,但不超过200 if isempty(m) m = min(n, 200); end % 连续特征归一化到[0,1],消除量纲影响 Xmin = min(X, [], 1); Xmax = max(X, [], 1); span = Xmax - Xmin; span(span == 0) = 1; % 常量特征避免除零 Xn = (X - Xmin) ./ span; % 各类别先验概率 prior = accumarray(clsIdx, 1) / n; % 固定随机种子,保证结果可复现 rng(2025); W = zeros(1, d); for t = 1:m ri = randi(n); R = Xn(ri, :); % 当前样本到所有样本的欧氏距离 distAll = sqrt(sum((Xn - R).^2, 2)); distAll(ri) = Inf; % 排除自身 % 同类近邻更新 sameIdx = find(clsIdx == clsIdx(ri)); [~, ordSame] = sort(distAll(sameIdx)); kSame = min(k, numel(sameIdx) - 1); if kSame >= 1 Hidx = sameIdx(ordSame(1:kSame)); W = W - (1/m) * (1/kSame) * sum(abs(Xn(Hidx, :) - R), 1); end % 异类近邻更新,按类别分别处理 for c = 1:nClass if c == clsIdx(ri) continue; end cIdx = find(clsIdx == c); [~, ordC] = sort(distAll(cIdx)); kC = min(k, numel(cIdx)); Midx = cIdx(ordC(1:kC)); cWeight = prior(c) / (1 - prior(clsIdx(ri))); W = W + (1/m) * (1/kC) * cWeight * sum(abs(Xn(Midx, :) - R), 1); end end W = W(:); [Wsorted, idxW] = sort(W, 'descend'); end2.3 代码里几个值得注意的实现细节
这段代码里有些细节是实际写项目时才会注意到的,单独拿出来说一下。
第一,为什么先归一化再算diff。标准的ReliefF公式里diff要除以特征取值范围,相当于把每个特征拉到了同一个尺度。我直接在函数开头用min-max归一化把X映射到[0,1],这样后面diff就是两个样本在该维上的绝对值差,省掉了每次都要除范围的麻烦。而且这样算欧氏距离找近邻时,所有特征权重一致,不会因为某个特征数值特别大而主导近邻的选取。
第二,为什么要排除样本自身。计算距离时,当前样本R到自身的距离一定是0,如果不排除,那同类最近的样本必然是它自己,同类近邻的diff全部变成0,同类项的惩罚就失效了。把距离设置成Inf是处理这个问题最省事的办法。
第三,先验概率校正为什么要放在异类项。公式里异类项乘以P(C)除以(1-P(class(R))),本质是让每个异类类别对权重更新的贡献和它在数据中的占比成正比。假如一个类别占90%,另一个占10%,如果不加这个校正,来自90%那类的异类近邻会频繁出现,把另一类的信号盖过去。加上校正之后,稀有类别的差异信息也能正常进入权重。
2.4 运行一个最简单的例子
代码写完后,可以先在随机数据上看一眼效果:
rng(42); X = randn(200, 10); % 200个样本,10个特征,全是噪声 y = double(X(:, 1) + X(:, 2) > 0); % 只有前两个特征真正与标签有关 [W, idxW, Wsorted] = myReliefF(X, y, 'NumIterations', 200, 'NumNeighbors', 10); disp(idxW(1:5)'); disp(Wsorted(1:5)');在这个例子中,特征1和特征2通常会被排到最前面,权重也相对更大,后面8个噪声特征的权重则在0附近波动。看到这个输出,基本就能确认函数工作正常了。
3. 权重排序之后怎么真正做特征选择:三条落地路线
3.1 策略一:权重正负与Top-K截断
最简单粗暴的方法,就是把权重大于0的特征全部保留,或者直接取排序后的前K个特征。比如第一次跑完,发现特征3、特征7、特征12的权重明显高于其他特征,就可以先用这三个特征建模。
这种方法适合特征数量不多、业务上需要可解释性的场景。比如一份30个特征的报表,你只想快速筛出最关键的5个,那按权重排序直接取Top-5就完事,后面再人工检查一下这几个特征的业务含义,速度非常快。
但这里有一个隐含风险:Top-K截断的“K”需要你来定,而权重曲线不一定有明显的断崖。如果几个特征的权重差距很小,随便截断反而可能丢掉有用的特征。所以我更建议配合下面的曲线法一起判断。
3.2 策略二:累计权重曲线找拐点
ReliefF权重可以看成每个特征对分类区分的贡献量。把权重绝对值的累计占比画出来,和主成分分析里的碎石图很像:
Wabs = abs(Wsorted); cumRatio = cumsum(Wabs) / sum(Wabs); plot(1:d, cumRatio, 'o-'); xlabel('按权重排序的特征数量'); ylabel('累计权重占比');这张图会显示:随着特征数量增加,累计占比迅速上升,然后趋于平稳。拐点附近就是“再加特征也增加不了多少信息”的位置,可以把该位置对应的特征数量作为筛选目标。
我自己的使用习惯是:一般选择累计占比达到80%-90%时对应的特征数量。不过要注意,ReliefF对相关特征群会“重复计功”,多个高度相关的特征可能同时挤进前几名,这时候累计曲线会显得贡献很大,实际上有效信息只有一份,后面还会再提这个问题。
3.3 策略三:用交叉验证回环找最优子集
最稳妥的做法,是用权重排序作为搜索顺序,从1个特征开始,逐步增加特征数量,每加一次就用一个轻量分类器做交叉验证,看分类准确率怎么变。特征选择的下一个候选集始终排在权重排序的前面位置,这相当于沿着ReliefF给出的路径做一个前向搜索。
示例代码如下,用5折交叉验证和KNN分类器:
rng(1); cp = cvpartition(y, 'KFold', 5); % 按实际情况设定候选特征数量节点 qList = unique([1:5:40, 50, 80, d]); acc = zeros(numel(qList), 1); for i = 1:numel(qList) q = qList(i); accFold = zeros(cp.NumTestSets, 1); for j = 1:cp.NumTestSets tr = training(cp, j); te = test(cp, j); % 建议对子集再标准化一次,避免KNN被大尺度特征带偏 subXtr = zscore(X(tr, idxW(1:q))); subXte = zscore(X(te, idxW(1:q))); mdl = fitcknn(subXtr, y(tr), 'NumNeighbors', 5); accFold(j) = 1 - loss(mdl, subXte, y(te)); end acc(i) = mean(accFold); end [bestAcc, bestIdx] = max(acc); bestQ = qList(bestIdx);这里还有两个细节需要注意。一是KNN对特征尺度敏感,ReliefF筛选出来的特征子集在喂给KNN之前,最好再做一次zscore标准化,不然数值范围大的特征仍然会主导距离计算。二是如果朴素贝叶斯或逻辑回归这类模型做回环评估,可能比KNN更快,但对于非线性关系,树模型又更合适。选什么分类器,取决于你后面真正要用的模型,尽量保持一致。
3.4 三种策略怎么选
我把三种策略的适用情况整理成了表格,方便对照:
| 策略 | 计算成本 | 可解释性 | 适用场景 |
|---|---|---|---|
| 权重正负与Top-K截断 | 极低 | 高 | 特征数量少、时间紧、只需要快速筛选 |
| 累计权重曲线找拐点 | 低 | 中 | 想要一个相对客观的保留数量依据 |
| 交叉验证回环 | 中高 | 高 | 特征数量适中、算力允许、关注最终模型效果 |
三个策略可以组合使用:先用策略二画曲线,圈定一个大致的特征数量范围,再用策略三做精确回环,最后用策略一给业务方一个可解释的清单。
4. 实操中决定成败的细节:归一化、不平衡和参数陷阱
4.1 特征归一化与类别变量处理
ReliefF的diff计算对连续特征会按取值范围归一,所以理论上你传入原始数据,函数内部也会处理。但问题出在近邻计算这一步:我在实现里是先归一化再算整体欧氏距离,这意味着每个特征对距离的贡献相同,这一点很关键。
更常见的一个坑是类别变量直接当成数值用。比如性别编码成1和2,ReliefF会把这个特征当作连续值处理,算出1和2之间的差为1,和某个取值范围为0-100的连续特征相比,它的“差异贡献”被严重低估。更合理的做法是把类别变量拆成one-hot形式的多个0/1特征再传入,这样ReliefF能正确处理离散特征,同时近邻计算也不会被虚假的数值差误导。
4.2 类别不平衡与先验校正
很多ReliefF的简化实现会忽略先验概率校正,这在类别分布均匀时影响不大,但一旦数据不平衡,问题就会暴露。举个极端例子:二分类里正类占2%,负类占98%。随机采样时,绝大多数样本来自负类,来自正类的样本很少。如果没有先验校正,权重更新几乎被负类主导,正类特征的区分信号反而体现不出来。
我在代码里保留了完整的先验校正项,如果你用的是MATLAB自带函数,也可以查阅文档看看它是否默认做了这个处理。另外,在样本量差别悬殊的情况下,还可以考虑在随机采样时做分层采样,也就是每个类别内部按比例采样,而不是全量随机抽取。
4.3 近邻数k与迭代次数m如何设置
这两个参数直接影响结果稳定性和运行速度,实际项目中值得多试几次。
k设小,比如2-3,对局部细节更敏感,但单个噪声样本容易带偏权重。k设大,比如20-30,结果更平滑,但如果某个类别本身样本就少,过大的k会把不同分布边缘的样本也算进近邻,反而模糊边界。我的经验是从k=10开始,如果数据量很大,可以提高到20,同时观察分类回环效果有没有明显变化。
m的取值同理。m太小,采样不够,权重波动大;m太大,运行时间线性上涨,收益越来越小。比较稳妥的做法是先取样本量的一半跑一次,再取全量样本跑一次,如果两次排序结果差不多,说明稳定性够了,就没必要继续加大m。在时间紧张的项目里,我会直接在最小和全量之间取一个折中值。
4.4 随机种子与结果稳定性
ReliefF带有随机性,每次运行结果会有细微波动。写代码时如果不固定随机种子,很容易出现这次跑出来特征A排第一,下次重跑变成特征B排第一,直接导致候选特征集不稳定。
所以在函数里固定rng(2025)是一个好习惯。如果你希望结果更稳,还有一个土办法:把整个流程重复跑10次,每次换不同随机种子,最后看每个特征的平均权重排序。这样能得到一个更加平滑的排名,代价是运行时间增加10倍,但结果说服力也更强。
4.5 高度相关特征的冗余问题
ReliefF有一个天然弱点:面对一组高度相关的特征,它会把其中每一个都评为重要特征,因为它们都能区分类别。比如血压的收缩压和舒张压高度相关,两者都能帮助判断心血管风险,ReliefF会同时给它们高分,但实际保留其中一个就足够了。
所以在ReliefF筛选之后,建议对高权重特征再做一个相关性检查。用相关系数矩阵找出高度相关的特征群,每组只保留一个代表特征,再去建模,这样既能控制特征数量,又不会给模型引入过多冗余。我在实际项目中经常把ReliefF和皮尔逊相关系数结合着用,效果比单用ReliefF要干净很多。
5. 用一组带噪声的模拟数据验证ReliefF的排序效果
5.1 模拟数据设计
光讲理论容易显得空,我这里构造一组模拟数据,把整个流程串起来看一遍。数据设计得很克制:共500个样本,20个特征,其中只有前3个特征真正和标签有关,其余17个特征全是纯噪声。
rng(42); n = 500; d = 20; X = randn(n, d); % 真实信号只来自前三个特征,再加一点噪声 score = 0.8 * X(:, 1) - 1.2 * X(:, 2) + 1.5 * X(:, 3) + 0.3 * randn(n, 1); y = double(score > median(score)); % 转为二分类标签 % 跑ReliefF [W, idxW, Wsorted] = myReliefF(X, y, 'NumIterations', 300, 'NumNeighbors', 10); % 输出前8名 disp([idxW(1:8), Wsorted(1:8)]);因为随机种子固定了,这个例子可以直接复现,每次跑出来的结果都一样。
5.2 排序结果解读
实测下来,前三个特征会毫无悬念地排在最前面,而且权重明显比后面的噪声特征高一个量级。比较典型的结果是特征3、特征2、特征1轮流占据前三位,具体顺序取决于随机近邻的组合方式,但前3名的身位非常稳固。排名第四之后,权重会掉到接近零的水平,有时还会出现小幅负值,这就是纯噪声特征的表现。
这类结果的价值在于:它告诉我们后17个特征虽然看着只是随机数,但ReliefF没有被它们骗走分数。即使特征总数是有效特征的五倍以上,算法依然能稳定抓住真正的信号。这也是我敢在高维数据上一开始就用ReliefF做初筛的原因。
5.3 用回环法确认有效特征数
接着用交叉验证回环法,看看只用前3个特征时,分类效果能不能打平甚至超过全量20个特征。
rng(1); cp = cvpartition(y, 'KFold', 5); qList = [1, 2, 3, 4, 5, 10, 15, 20]; acc = zeros(numel(qList), 1); for i = 1:numel(qList) q = qList(i); for j = 1:cp.NumTestSets tr = training(cp, j); te = test(cp, j); mdl = fitcknn(zscore(X(tr, idxW(1:q))), y(tr), 'NumNeighbors', 5); acc(i) = acc(i) + (1 - loss(mdl, zscore(X(te, idxW(1:q))), y(te))) / cp.NumTestSets; end end T = table(qList', acc', 'VariableNames', {'特征数', '交叉验证准确率'}); disp(T);在这个模拟数据里,q=3或q=4时准确率就能达到最高水平,q继续增大到20反而可能因为噪声特征干扰而略微下滑。这个结果也印证了ReliefF排序的作用:它在建模前就帮我们把有限的计算资源集中到了真正重要的特征上,而不是让模型自己去高维噪声里挣扎。
我自己习惯把这类模拟实验留在每个特征选择项目的最前面跑一遍,不是为了炫技,而是为了验证实现代码和分析流程本身没有系统性问题。先把流程在已知真值的数据上跑通,再去面对真实的高维业务数据,心里会踏实很多。如果你手头正有一份高维数据不知道该从哪里下手,不妨复制上面的代码,先把特征重要性排序结果跑出来,再看怎么筛。这个过程花不了几分钟,但对后续建模方向的帮助往往比想象中大。