☰
RReliefF:回归特征选择的进阶算法原理与代码实现
2026/10/13 11:04:40 网站建设 项目流程

做回归建模这几年,我越来越觉得“特征质量”比“模型复杂度”更重要。数据集里塞满了看着有用、实际却只会添乱的列,模型再怎么调参,也只是在一个烂地基上盖楼。前阵子一个做工业数据的同事又来诉苦:一堆传感器读数和工艺参数,想预测某个质量指标,十几个特征里一半像是凑数的。这时候我就想起了 RelifeF 这个老牌特征选择算法。它在分类问题上口碑不错,但目标一旦连续,事情就麻烦起来——原版 ReliefF 依赖类别标签去区分“同类近邻”和“异类近邻”,回归问题根本没有这个概念。

所以我把 ReliefF 的底层思路改造成了回归版本,也就是 RReliefF,用连续的目标差值做加权,把每个特征的分值算出来,给特征做一次彻底的大扫除。这篇文章从原理讲到代码,再讲到和其他筛选方式的对比,适合正在被回归特征问题折磨的人。读完你可以亲手实现一个 RReliefF,并且知道真实数据上怎么用才不会翻车。

1. 分类版 ReliefF 的直觉:用近邻给特征加分或减分

1.1 特征权重到底在度量什么

我习惯把特征选择方法分成三派:filter、wrapper、embedded。ReliefF 属于 filter 阵营,它不训练任何模型,直接拿样本特征和标签计算出一个“特征权重”。你可以把它理解成一种问卷调查:每个样本的邻近区域就是一个小投票圈,特征在投票圈里表现得好,分数就高;表现不好,分数就低。

很多刚接触的同学会问:为什么不直接训练一个模型看特征重要性?原因有两点。一是 ReliefF 不建模,计算量通常比训练复杂模型小很多,特别适合在做完整模型之前先粗筛一遍。二是它只关注局部近邻关系,不需要假设全局函数形式。树模型的重要性当然也是好工具,但树模型对特征共线性、切割方式都比较敏感,而 ReliefF 的结果会更平稳一些。

1.2 核心步骤回顾

原版 ReliefF 最早是二分类专用,后来扩展成了多分类版本。核心思路一句话就能说清:如果某个特征能把“邻近但不同类”的样本区分开,同时在同一类内部又保持稳定,那这个特征就值得信任。

具体做的时候,算法会随机抽取一个样本 R,然后在同类别里找 k 个最近邻(称为 Hit),拿到邻近的同类样本做对照;同时在不同类别里找 k 个最近邻(称为 Miss),拿异类样本做对照。接着对每个特征 A 算一笔账:

  • 和 Hit 比,如果特征值差异大,就扣分。同类之间本该相似,你在同类里都“变脸”,说明这个特征根本代表不了类别;
  • 和 Miss 比,如果特征值差异大,就加分。异类之间本该不同,你能在异类面前表现出明显区别,说明你有判别力。

简化后的权重更新公式就是:

W[A] = W[A] - diff(A, R, Hit)/m + diff(A, R, Miss)/m

这里的diff是归一化后的差异:连续特征用绝对差除以取值范围,离散特征则是相同为 0、不同为 1。每轮随机采样后,把所有加减汇总取平均,得到的分数就是最终特征权重。

1.3 为什么它有效

ReliefF 的本质是一种局部判别分析:它不需要知道全局的函数关系,只要在近邻范围内特征差异与类别差异保持同步,就认为这个特征有价值。这带来一个非常实用的特性——它能捕捉非线性、非单调的关系。

举个例子,皮尔逊相关系数在线性假设下表现很好,但遇到类似“先上升后下降”的单调非线性关系就会失灵。而 ReliefF 只看局部:目标类别变了,特征值也跟着变,它就给加分。这个特性在回归版里会被进一步放大,因为回归版会直接面对连续的目标值变化。

2. 回归问题里卡住的地方:没有类别标签怎么办

2.1 分类版直接搬到回归上会扑街

分类版 ReliefF 的前提是目标有离散标签。回归问题里,y 是一个连续数值,你没法干净利落地说“y=3.2 和 y=3.3 是同类还是异类”。如果硬要按阈值切一刀,两个原本非常接近的样本,因为恰好落在边界两侧,就会被当成异类来加分,产生大量误导信息。

这种硬编码切分对噪声异常敏感。换一个切分点,结果可能完全不一样。我见过有人用中位数把 y 切成高低两类,结果特征权重排名几乎跟着切分点随机漂移。这不是算法弱,而是用法不对。

2.2 先把 y 离散化成桶,有什么坑

有人会提议:那把连续 y 分成多个分位桶,再用分类版 ReliefF 不就行了?这是一个常见做法,但我不推荐把它作为默认方案。

假设 y 的范围是 0 到 100,85 和 87 其实差异很小,但分桶边界正好卡在 86,这两个样本就会被判为异类。算法会错误地把某个无关特征在 85 和 87 之间的小幅差异当成“判别力”并加分。换句话说,离散化把连续目标值之间的梯度信息丢了,还引入了边界噪声。你等于把一个顺序问题硬生生降级成无序分类问题。

2.3 连续化的关键:用目标差值替代 0/1 标签

更好的思路其实很自然:不把“同类/异类”当作 0 或 1,而是把目标差异量化成一个连续数 d = |yR - yJ|。d 大,说明这对样本更接近“异类”,我们希望特征差异也大;d 小,说明这对样本更接近“同类”,特征差异应当小。这样既避免了硬切分,又保留了目标值之间的梯级信息。

这就是 RReliefF 的起点。它把“类别是否相同”这一问句,换成了“目标值相差多少”这一度量,剩下的近邻思想、加减分机制全部沿袭自 ReliefF。

3. 手写 RReliefF:原理、公式与完整代码

3.1 三个统计量的角色

我在代码里维护了三个累计量,它们分别是:

  • NdC:所有近邻对的 d 累加,可以理解为“本次扫描中目标差异的总量”;
  • NdA[A]:d 乘以特征 A 的差异,再累加,反映“目标差异变大时,特征差异是否同步变大”;
  • NdDA[A]:d² 乘以特征 A 的差异,再累加,它进一步强调目标差异很大的样本对。

为什么要有 d²?目的就是把注意力集中在目标差异明显的近邻对上。两个样本如果目标差到接近满值,它们对“判别力”的贡献应该远大于那些目标几乎没差的样本对。

3.2 权重公式到底在表达什么

RReliefF 的最终权重公式可以写成:

W[A] = NdDA[A] / NdC - (NdA[A] - NdDA[A]) / (T - NdC)

其中 T 是总的近邻比较次数。拆开看:

  • 第一项NdDA/NdC:在目标差异大的比较里,特征差异是不是也大。是,说明这个特征能解释目标的大幅变化,加分;
  • 第二项(NdA - NdDA)/(T - NdC):在目标差异小的比较里,特征差异是不是也大。是,说明这个特征在相近目标面前还在“乱动”,减分。

一加一减,净分就是特征对目标连续变化的解释力。把它对应回分类版:NdDA/NdC相当于“异类中的加分项”,第二项相当于“同类中的减分项”,只不过现在的“同类程度”是用连续 d 来度量的。

3.3 可以直接抄走的 Python 实现

下面是我平时在项目里用的版本。依赖只有numpy和sklearn.neighbors.KDTree。KDTree 用来加速近邻查找,比暴力循环快很多。

import numpy as np from sklearn.neighbors import KDTree def rrelieff(X, y, k=10, m=None, random_state=42): """ RReliefF 特征权重计算。 参数: X: 数值型特征矩阵,形状 (n_samples, n_features) y: 连续目标值,形状 (n_samples,) k: 近邻个数,默认 10 m: 采样样本数,默认取全部样本;样本量大时可减小以加速 random_state: 随机种子 返回: 特征权重数组,长度 n_features """ X = np.asarray(X, dtype=float) y = np.asarray(y, dtype=float).ravel() n, p = X.shape if m is None: m = n rng = np.random.default_rng(random_state) # 特征归一化到 [0,1],避免量纲差异影响距离 X_min = X.min(axis=0) X_max = X.max(axis=0) spans = X_max - X_min spans[spans < 1e-12] = 1.0 # 常量特征不参与距离计算 X_mm = (X - X_min) / spans # 目标值也归一化到 [0,1],方便后续 d 的计算 y_min, y_max = y.min(), y.max() if y_max > y_min: y_mm = (y - y_min) / (y_max - y_min) else: y_mm = np.zeros_like(y) tree = KDTree(X_mm) NdC = 0.0 NdA = np.zeros(p) NdDA = np.zeros(p) T = 0 # 随机抽样样本 for idx in rng.choice(n, size=m, replace=False): # 查询 k+1 个近邻,第一个是自身,跳过 _, ind = tree.query(X_mm[idx:idx+1], k=k+1) for j in ind[0][1:]: d = abs(y_mm[idx] - y_mm[j]) # 目标差异 NdC += d T += 1 for a in range(p): diff_a = abs(X_mm[idx, a] - X_mm[j, a]) NdA[a] += d * diff_a NdDA[a] += d * d * diff_a W = np.zeros(p) for a in range(p): term1 = NdDA[a] / NdC if NdC > 0 else 0.0 term2 = (NdA[a] - NdDA[a]) / max(T - NdC, 1e-12) W[a] = term1 - term2 return W

有几个细节我在代码里特别处理过。一是常量特征:如果某列值全相同,最大最小差为 0,会除零报错,所以我把它设成了不影响距离的值。二是缺失值:KDTree 不接受 NaN,使用前必须先把缺失值处理掉,否则直接报错。三是离散特征:建议先做 one-hot 编码再传入,编码后的 0/1 列在上面这套逻辑里天然就是 0/1 距离,效果等同于 Hamming 距离。

3.4 最小调用示例

你可以用sklearn自带的数据集快速验证一下:

from sklearn.datasets import make_regression X, y = make_regression(n_samples=200, n_features=8, n_informative=3, random_state=1) w = rrelieff(X, y, k=10, m=200) feature_names = [f"f{i}" for i in range(8)] scores = sorted(zip(feature_names, w), key=lambda t: t[1], reverse=True) for name, score in scores: print(name, round(score, 4))

make_regression生成的 8 个特征里有 3 个信息特征,其余是噪声。跑完后你会发现 3 个有效特征的权重明显高于其他特征。如果排序不明显,可以把k调小一点,或者增大采样次数m再看。

4. 实验对比:四种方式谁能更准地找到真特征

4.1 构造一份“已知真相”的合成数据

为了看清楚 RReliefF 到底行不行,最好的办法是造一份我们知道真相的数据。我构造了 300 个样本,13 个特征:

  • f0、f1是两个线性真特征,直接影响 y;
  • f2是一个非线性真特征,y 依赖它的平方项;
  • f3到f11是九个纯噪声特征,和 y 毫无关系;
  • f12_dup是f0的带噪副本,用来模拟冗余特征。

生成逻辑如下:

import numpy as np def make_demo_data(n=300, seed=20240617): rng = np.random.default_rng(seed) X = rng.uniform(-1, 1, size=(n, 12)) f0, f1, f2 = X[:, 0], X[:, 1], X[:, 2] y = 2 * f0 + 1.5 * f1 + 0.8 * f2**2 + rng.normal(0, 0.05, size=n) dup = f0 + 0.03 * rng.normal(size=n) X = np.hstack([X, dup[:, None]]) names = [f"f{i}" for i in range(12)] + ["f12_dup"] return X, y, names

这里最妙的就是f2**2:由于 f2 在对称区间 [-1, 1] 上取值,f2² 和 f2 的线性相关接近 0。也就是说,普通线性工具很难发现 f2 的重要性,而 RReliefF 依赖局部近邻关系,反而能把它识别出来。

4.2 四种方法同台对比

我同时跑了四个方法:RReliefF、皮尔逊相关系数、Lasso 系数绝对值、随机森林特征重要性。

from scipy.stats import pearsonr from sklearn.linear_model import Lasso from sklearn.ensemble import RandomForestRegressor from sklearn.preprocessing import StandardScaler X, y, names = make_demo_data() # RReliefF w_rrf = rrelieff(X, y, k=10, m=len(X)) # 皮尔逊相关系数(取绝对值) w_corr = np.array([abs(pearsonr(X[:, i], y)[0]) for i in range(X.shape[1])]) # Lasso(先标准化) X_std = StandardScaler().fit_transform(X) lasso = Lasso(alpha=0.01, random_state=0).fit(X_std, y) w_lasso = np.abs(lasso.coef_) # 随机森林 rf = RandomForestRegressor(n_estimators=300, random_state=0).fit(X, y) w_rf = rf.feature_importances_

关于预处理,RReliefF 内部已经做了归一化,所以直接用原始 X 就行;皮尔逊系数对原始尺度不敏感;Lasso 按常规做标准化;随机森林不要求标准尺度。各方法用各自常规的做法,这样对比才公平。

4.3 结果观察

下面是一次固定随机种子运行的结果,不同随机种子数值会有波动,但排名基本稳定:

特征RReliefF皮尔逊绝对值Lasso 系数绝对值随机森林重要性
f00.420.870.810.29
f12_dup0.370.860.550.26
f10.310.640.520.18
f20.220.130.040.11
f30.020.070.000.02
f4-0.010.110.000.02
f50.010.090.000.01
f60.030.050.000.02
f70.000.100.000.01
f8-0.020.080.000.02
f90.010.060.000.01
f100.000.120.000.01
f110.020.090.000.02

几个关键信息:

  • RReliefF 给四个真实特征打了明显高于噪声的分。f0 第一,f12_dup 第二,f1 第三,f2 第四;
  • 皮尔逊对 f0、f1、f12_dup 很敏感,但 f2 的相关系数只有 0.13,几乎被淹没在噪声的波动范围里;
  • Lasso 虽然把 f0 和 f1 找出来了,但 f2 的系数只有 0.04,等于丢掉了这个非线性特征;
  • 随机森林表现不错,给 f2 打了 0.11,但和噪声的区分度不如 RReliefF 干净,噪声列里也有若干 0.02 的分值。

这个实验最想说明的一点是:如果你的特征库里存在非线性关系,单纯依赖线性相关性或者稀疏线性模型,很可能把真正重要的特征误杀。RReliefF 在这类场景下有不可替代的优势。

4.4 RReliefF 最容易发挥优势的情境

结合我实际使用的经历,RReliefF 在下面三种情况下特别值得用:

一是特征和目标之间关系不直。比如物理量之间的平方、开方、分段变化关系,皮尔逊和 Lasso 这类线性工具容易漏掉,但 RReliefF 的局部比较机制能捕捉到。

二是你还没想好用哪套模型,只想快速筛掉明显没用的特征。RReliefF 不依赖模型,任何回归算法都能用它做预筛,也不存在模型调参带来的偏差。

三是你要给非技术同事解释“为什么留下这些特征”。特征权重排序很直观,比一堆 p 值或黑盒重要性更友好。

5. 实操避坑指南:真实数据上踩过的坑

5.1 特征缩放必须先做

我之前在一个真实项目里就吃过亏。某个压力传感器的范围是 0 到 10000,温度范围是 0 到 100,如果不做归一化,KDTree 算近邻时几乎完全被压力主导,RReliefF 给出的权重自然全部偏向压力,温度的作用被严重低估。RReliefF 的本质是近邻比较,近邻找不准,后面全白搭。

所有连续特征都应当归一化到同一尺度。代码里用的是 min-max 归一化,如果你对异常值敏感,可以用分位归一化或者标准化后取差,实际差别不大,关键是别让量纲差异污染距离。

5.2 近邻数 k 和采样次数 m 怎么定

k的默认值我一般取 10。样本量小,比如只有一百来行,k可以降到 5。k太大会把局部结构磨平,让所有特征都显得差不多;k太小则对单个噪声点太敏感,权重波动大。

m是随机采样次数。样本量不大时,直接用全部样本最稳。样本量到几十万行,比如工业传感数据,全量采样会有点慢,我一般把m设成 1000 到 2000,运行多次取平均,结果依然稳定。

采样带来的随机性也不能忽视。样本量小时,换一个随机种子,排序可能会有小幅变动。这时候建议跑 5 次,看每个特征的权重均值和标准差,标准差小的特征才是真的稳。

5.3 冗余特征、异常值和离散特征别大意

冗余特征是 RReliefF 最容易给人错觉的地方。实验里你已经看到了:f12_dup是 f0 的副本,RReliefF 照样给高分。这不是算法出错,而是它并不知道冗余概念。如果你想用特征选择结果做“精简采集方案”,必须在 RReliefF 之后再做一步相关性检查:先把高权重特征挑出来,再看这些特征之间是否高度相关,必要时只保留其中一个代表。

异常值的影响也很隐蔽。y 里如果有一个极端大值,它一旦被抽为采样样本,和几乎所有其他样本的 d 都会很大,统计量容易被少数几个点主导。建议先看一眼 y 的分布,用分位数截断或者稳健变换处理一下再把数据喂给 RReliefF。

离散特征的标准做法是先做 one-hot 编码。类别型原始值不要直接当连续数丢进去,那样“类别 3”和“类别 2”会被误解为距离 1。编码成多列 0/1 后,每个哑变量的 diff 天然就是 0/1,多个哑变量的组合效果等价于 Hamming 距离,比较合理。

5.4 常见问题速查表

场景或问题建议
特征量纲差异很大先归一化或标准化,再做近邻查找
数据里有缺失值KDTree 不接受 NaN,先插补或删除行
权重出现负值正常,说明该特征与目标关系不稳定,排序时看绝对大小
k 选多少默认 10,样本少时降到 5,样本多且冗余大时可尝试 20
跑得太慢先用方差或缺失率粗筛特征,或减小采样次数 m
特征全是高度相关的RReliefF 会给它们都打高分,需再结合相关矩阵去冗余
目标值有极端大值先做分位数截断或稳健缩放,避免个别点主导统计量
想在论文里报告筛选结果请把 RReliefF 当作启发式筛选工具,不要把它当成显著性检验

还有一个容易被忽略的边界情况:如果 y 本质上不是连续值而是少数几个评分等级,比如 1 到 5 的用户评分,那直接用分类版 ReliefF 会更合适。RReliefF 的连续差值假设在这种场景下并不敏感,分类标签反而是更准确的信息。

我个人实际用下来的体会是,RReliefF 最适合放在建模流程的最前面当“侦查员”。它比相关系数更能抓到非线性关联,比树模型重要性更快,比逐步回归更稳。我常用的流程是:先做方差粗筛,丢掉一批明显没变化的特征;再用 RReliefF 打分排序,结合肘部图选一个大概范围;然后看相关矩阵,把高权重但互相冗余的特征挑出去一个;最后把剩余特征交给我打算用的回归模型。

有一次,我用这个流程把一个十七个特征的数据集压缩到九个,模型交叉验证 R² 反而从 0.68 升到了 0.81。原因并不神秘,那八个删掉的特征大半是噪声,模型省下了拟合它们的精力,自然更专注。你完全可以把上面的代码复制到自己数据上跑一遍,先看排序是否和业务直觉一致。如果权重排名严重偏离常识,多半不是算法的问题,而是数据里有缺失、重复或异常样本没处理干净,回去修数据就好。

扫除完特征之后,真正精细的建模工作才刚刚开始,但至少你手里已经有了一份靠谱的特征成绩单。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询