简介:这是一份关于动态加权条件互信息的特征选择算法的技术文档,面向机器学习、数据挖掘领域的研究人员、高维数据分析学习者以及需要开展特征选择实验的开发者,重点解决传统过滤式特征选择方法中参数预先设置、冗余特征误判以及分类信息权重失衡等问题。该算法(WMRI)基于信息论框架,利用条件互信息衡量特征与类标签的相关性及特征间冗余性,通过均值和标准差动态调节新分类信息与保留类别信息的权重,在不显著增加计算量的前提下提升最优特征子集的整体质量。资源包包含1个docx格式文档,压缩包大小454KB,内容涵盖算法研究背景、相关算法对比分析(如MRI、DCSF、JMIM等)、完整公式推导、伪代码实现以及在10个基准数据集上的实验对比,结构清晰,既可作为学术论文写作的参考素材,也便于读者按步骤复现算法。目前已有80人学习下载,适合具备一定信息论基础、希望深入理解条件互信息机制并改进特征选择方法的读者。
1. 为什么特征选择要引入“动态加权”的条件互信息
高维数据里做特征选择,互信息(MI)是入门首选,但它在存在冗余特征时会给出虚高的相关性打分。条件互信息(CMI)把“已选特征集合”作为条件纳入了计算,能剥离掉这部分干扰。可一旦条件集合变大,CMI 的估计值就会系统性偏低,特征排序时高维特征被持续低估,选出来的子集往往不够稳定。动态加权解决的就是这个问题——权重随条件集合的规模动态变化,而不是用固定系数去矫正。这套思路在基因表达数据、文本分类、工业传感数据的特征筛选中都有应用场景。适合已经在用过滤式特征选择、但发现排序结果不稳定或模型效果不升反降的工程师。读完可以落地一个可运行的 Python 版本,并知道参数怎么调、坑在哪儿。
2. 条件互信息的估计路径:从互信息到动态权重
2.1 从互信息到条件互信息的演进逻辑
互信息衡量两个变量之间的共同信息量,公式是 I(X;Y) = H(X) + H(Y) − H(X,Y)。它在特征选择里有个致命缺陷:如果两个特征强相关,它们各自与标签的 MI 都很高,但加在一起并没有带来双倍的信息增益。条件互信息把已选特征 S 放进条件里,计算 I(X;Y|S),得到的是“在已知 S 的情况下,X 还能为 Y 提供多少新信息”。这正好用来做增量式特征选择——每次从候选池里挑一个能让 CMI 最大的特征加入 S。
但 CMI 不是免费的午餐。当 S 的维度增长,条件空间呈指数膨胀,有限样本下每个条件单元格里的样本数急剧减少,概率密度估计的方差随之飙升。实际表现就是:候选特征的 CMI 值普遍偏低,且不同特征之间的区分度变小。另一个更隐蔽的问题是,不同候选特征所面对的“条件集合大小”不同——先被选进 S 的特征在计算时条件较少,估值相对可靠;后参与竞争的特征要面对更大的条件集合,估值被压缩得更狠。这等于让先来者占了便宜,排序结果偏离了真实的特征重要性。
2.2 动态权重为什么能缓解高维条件集合带来的偏差
动态加权的核心思路:对 CMI 的值乘上一个随 |S| 变化的权重函数。常见做法是 w(|S|) = |S|^γ 或 w(|S|) = log(1 + |S|),其中 γ 是一个可调的超参数。权重会补偿因为条件空间增大而导致的 CMI 低估,让在不同阶段参与竞争的特征处于一个相对公平的比较基准上。
这个设计的动机来自非参数估计的收敛速度。基于 k 近邻或核密度估计的 CMI 估计量,其偏差与条件维度 d 相关,典型形式是 O(n^−1/(d+4)) 这样的量级。当 d 从 1 涨到 10,收敛速度会急剧下降。动态权重本质上是在做方差-偏差的折中:条件集合越大,权重给得越高,把被压缩的信号放大回来,但不是无脑放大——γ 太大会让高维特征被过度补偿,γ 太小又回到没有补偿的原始状态。
实际选型时,我一般会先用 γ = 1 跑一轮,观察所选特征在验证集上的表现,再在 0.5 到 1.5 之间做网格搜索。需要注意:权重只对参与排序的 CMI 值生效,不参与特征本身的条件概率计算,所以不会引入新的估计偏差。
2.3 动态权重与传统加权特征选择的本质区别
传统加权特征选择(比如用互信息乘以一个固定系数)调整的是“所有特征的整体偏好”,相当于全局性地提高或压低某一类特征的分值。动态加权的本质是“按选择进度调整竞争环境”——在选择过程的前期,条件集合小,权重接近 1,CMI 基本是原始值;随着 S 变大,权重逐渐升高,对后参与竞争的特征给出补偿。
这样做的收益在特征维度超过 50 时开始明显。维度较低时,CMI 的估计方差本来就小,加权与否差别不大,固定权重反而好调参。正因为这个特性,动态加权的实现必须先有一个可靠的 CMI 基座——基座估计都偏的话,权重补偿只是在放大噪声。
3. 用 Python 实现动态加权 CMI 特征选择
3.1 数据集准备与基准方法对比
实现上需要一个能算 CMI 的库。scikit-learn的mutual_info_classif支持conditional参数吗?不支持,它只算 MI。可以用f_pvalues或者chi2做基准对比,但核心 CMI 计算得自己写或另找实现。常见方案是:用skfeature库的cmim函数做基座,或者用numpy自己写基于分箱的估计器。前者方便,但只能处理离散特征;后者可控,能接连续特征。先用skfeature跑通流程,再替换成自实现。
数据集用公开的 UCI 乳腺癌数据集做演示。这个数据集 30 个特征、569 个样本,维度不算高,但足够看出动态加权在排序稳定性上的差异。加载后用train_test_split划分,特征做标准化。注意:分箱之前不要标准化,因为分箱依赖原始分布;标准化留到分箱之后做或者不做都行,取决于分类器需求。
import pandas as pd from sklearn.datasets import load_breast_cancer from sklearn.model_selection import train_test_split data = load_breast_cancer() X = pd.DataFrame(data.data, columns=data.feature_names) y = pd.Series(data.target, name='target') X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.3, random_state=42, stratify=y ) print(f'Train shape: {X_train.shape}, Test shape: {X_test.shape}')逻辑说明:stratify=y保证训练集和测试集的标签分布一致,避免类别不平衡影响 CMI 的估计——CMI 对边缘分布非常敏感,训练集和测试集分布差距大,排序结果就不稳定。标准化在这个阶段不做,因为分箱需要原始特征的天然阈值。
3.2 核心实现:kNN 熵估计 + 动态权重
写一个基于 k 近邻的 CMI 估计器。思路不复杂:CMI = H(X|S) + H(Y|S) − H(X,Y|S)。有了熵,CMI 就是加减法。k 近邻熵估计用 Kozachenko-Leonenko 估计量,公式是 H ≈ ψ(k) + log(N) − ψ(N) − mean(log(2 * distance_to_kth_neighbor)),其中 ψ 是 digamma 函数。
这里给出一个可直接运行的实现,用scipy.spatial里的cKDTree做加速。
import numpy as np from scipy.special import digamma from scipy.spatial import cKDTree def _knn_entropy(X, k=3): """Kozachenko-Leonenko k近邻熵估计""" n = X.shape[0] if n <= k + 1: return 0.0 tree = cKDTree(X) dist, _ = tree.query(X, k=k + 1) # 第一个是自身,距离为0 rho = dist[:, -1] # 第k近邻距离 # 避免距离为0导致log(0) rho = np.maximum(rho, 1e-12) h = digamma(k) + np.log(n) - digamma(n) + np.mean(np.log(2 * rho)) return h def conditional_mutual_information(X, y, S_idx, k=3): """计算 I(X; Y | S),S_idx是已选特征的下标列表""" # 把X、y和S拼成条件空间 if len(S_idx) > 0: S_data = X[:, S_idx] cond_data = np.hstack([S_data, y.reshape(-1, 1)]) else: cond_data = y.reshape(-1, 1) X_cond = np.hstack([X.reshape(-1, 1), y.reshape(-1, 1)]) h_xs = _knn_entropy(X_cond, k) h_ys = _knn_entropy(cond_data, k) h_xys = _knn_entropy(np.hstack([X.reshape(-1, 1), S_data, y.reshape(-1, 1)]), k) # CMI = H(X,S) + H(Y,S) - H(X,Y,S) - H(S) # 由链式法则推导:I(X;Y|S) = H(X|S) + H(Y|S) - H(X,Y|S) h_x = _knn_entropy(X.reshape(-1, 1), k) cmi = h_xs + h_ys - h_xys - _knn_entropy(S_data, k) if len(S_idx) > 0 else h_x + h_ys - h_xys return max(cmi, 0.0)逻辑说明:_knn_entropy用了距离的 log 均值作为熵的估计量,这是 kNN 估计的核心。conditional_mutual_information里的几个熵分量分别对应条件空间和联合空间。cKDTree.query返回距离和索引,k+1 取一个是因为第一个点是自身。代码里留了max(cmi, 0.0)是因为数值误差可能让 CMI 变成微小的负数。
3.3 前向搜索 + 动态权重选择特征
有了 CMI 基座,接下来做前向贪心搜索,并加入动态权重。
def dynamic_weighted_cmi_selection(X, y, num_features, k=3, gamma=1.0): """动态加权条件互信息的特征选择 Parameters: - X: 特征矩阵 (n_samples, n_features) - y: 标签向量 - num_features: 要选择的特征数 - k: kNN参数 - gamma: 动态权重指数 """ n_samples, n_features = X.shape selected = [] remaining = list(range(n_features)) for step in range(num_features): scores = [] for idx in remaining: # 原始CMI cmi_val = conditional_mutual_information(X[:, idx], y, selected, k) # 动态权重:|S|^gamma weight = (len(selected) + 1) ** gamma scores.append((cmi_val * weight, idx)) # 选得分最高的 scores.sort(reverse=True, key=lambda t: t[0]) best_score, best_idx = scores[0] selected.append(best_idx) remaining.remove(best_idx) print(f'Step {step + 1}: selected feature {best_idx}, ' f'CMI={best_score:.4f}, weight={len(selected) ** gamma:.2f}') return selected参数说明:gamma=1.0是线性权重,len(selected) + 1的乘方保证了第一步权重为 1、之后逐步递增。k=3是 kNN 估计的默认参数,后续会说明怎么调。实际运行时建议把打印改成日志输出,特征多的时候remaining.remove是 O(n) 操作,可以考虑用布尔掩码优化。
4. 参数调优与排错:k 值、γ 与特征分箱
4.1 k 近邻参数与 CMI 估计方差的权衡
k 值的大小直接影响熵估计的偏差-方差取舍。k 太小(1 或 2),距离最近邻很近,估计方差大,容易出现 CMI 值虚高;k 太大(超过 10),估计会过度平滑,弱特征和强特征之间的差距被抹平。我常用的范围是 3~5。
一个更实操的判断方法:把同一个 CMI 计算跑 5 次(加不同的随机种子重采样),看结果的方差。如果方差占到均值的 30% 以上,k 加大一档;如果两个候选特征的 CMI 差值小于方差,这个差值本身没有统计意义,说明样本量不够,或者需要降维预处理。
# 用重采样验证k值的稳定性 python - <<'EOF' import numpy as np from your_module import conditional_mutual_information X = np.random.rand(200, 5) y = (X[:, 0] + 0.3 * np.random.randn(200) > 0.5).astype(int) for k in [1, 3, 5, 10]: cmi_values = [] for seed in range(5): idx = np.random.RandomState(seed).choice(200, 150, replace=False) cmi_values.append(conditional_mutual_information(X[idx, 0], y[idx], [], k)) print(f'k={k}, mean={np.mean(cmi_values):.4f}, std={np.std(cmi_values):.4f}') EOF运行后会看到 k=1 时标准差明显高于 k=5。这个判断方法比死记参数值实用得多——数据量不同、特征分布不同,最优 k 都不一样。
4.2 动态权重 γ 的灵敏度验证
γ 是动态加权里最需要小心调的超参数。γ 太大,后选择的特征会被严重高估,可能把噪声特征推上来;γ 太小,就退化成普通 CMI,失去了动态加权的意义。
验证 γ 的方式是看“选择顺序的稳定性”。做法:把数据集随机分成 10 份,跑 10 次前向搜索,对比不同 γ 下选出的前 10 个特征的 Jaccard 相似度。
import numpy as np from sklearn.model_selection import StratifiedKFold def stability_score(X, y, gamma, num_features=10, folds=10): """计算给定gamma下特征选择的稳定性""" skf = StratifiedKFold(n_splits=folds, shuffle=True, random_state=42) feature_sets = [] for train_idx, _ in skf.split(X, y): X_train = X.iloc[train_idx].values y_train = y.iloc[train_idx].values selected = dynamic_weighted_cmi_selection( X_train, y_train, num_features=num_features, gamma=gamma ) feature_sets.append(set(selected)) # 两两计算Jaccard相似度 sims = [] for i in range(len(feature_sets)): for j in range(i + 1, len(feature_sets)): inter = len(feature_sets[i] & feature_sets[j]) union = len(feature_sets[i] | feature_sets[j]) sims.append(inter / union) return np.mean(sims) for gamma in [0.0, 0.5, 1.0, 1.5, 2.0]: score = stability_score(X_train, y_train, gamma) print(f'gamma={gamma}, stability={score:.3f}')逻辑说明:gamma=0.0时权重恒为 1,等价于普通 CMI。观察不同 γ 下的稳定性曲线,通常 γ=1 附近会出现一个平台期,这就是建议的工作区间。如果曲线一直上升没有下降趋势,说明特征之间存在强相关结构,需要加特征聚类作预处理。
4.3 连续特征分箱的三个关键设置
kNN 估计器理论上不需要分箱,但实际数据里经常混着离散特征和连续特征。cKDTree 的度量要求所有维度数值尺度一致,直接用会出问题。常见做法是先给离散特征做 one-hot,再跟连续特征一起标准化。但这样 CMI 计算时 one-hot 维度会被当成连续空间的一部分,产生度量失真。
替代方案是分箱后统一处理。分箱参数有三个关键点:箱数(bin count)、分箱方式(等宽/等频)、缺失值处理。等宽分箱对长尾分布不友好,np.linspace切出来的区间可能在尾部没有样本;等频分箱则要处理重复值——大量重复值都在同一个箱里,箱数会变少。
def equal_freq_bin(x, bins=10): """等频分箱,处理重复值""" quantiles = np.percentile(x, np.linspace(0, 100, bins + 1)) # 去重并保证边界单调 quantiles = np.unique(quantiles) if len(quantiles) < 3: quantiles = np.array([np.min(x), np.median(x), np.max(x)]) return np.digitize(x, quantiles[1:-1])注意np.digitize返回的索引从 0 开始,刚好可以直接送给cKDTree当整数点用。但需要说明:分箱后的 CMI 估计值会小于连续空间的真实 CMI,因为它丢失了箱内的分布信息。所以分箱后的 CMI 值只能用于特征之间的相对比较,不能当作信息量的绝对估计。这一点在跨数据集对比时尤其要小心——同一个特征在不同数据集上分箱后算出的 CMI,不能直接比较大小。
提示:分箱数一般 8~12。箱数过少,区分度下降;箱数过多,每个箱内样本太少,CMI 方差变大。缺失值在分箱前用中位数填充,否则
np.percentile会报错。
5. 把算法接进实际数据流水线:两个完整案例
5.1 案例一:高维生物数据上的特征降维
生物信息学场景里常见的是几千到几万个特征、几百个样本的矩阵。这种数据直接跑前向搜索不现实——每轮都要对所有候选特征算 CMI,复杂度是 O(F²) 量级。先把动态加权 CMI 和过滤法结合:第一轮用 MI 或方差过滤砍掉 80% 的低信息特征,再在剩余特征上跑动态加权 CMI。
from sklearn.feature_selection import VarianceThreshold from sklearn.preprocessing import StandardScaler # 1. 方差过滤:删除低方差特征 scaler = StandardScaler() X_scaled = scaler.fit_transform(X) selector = VarianceThreshold(threshold=0.01) X_filtered = selector.fit_transform(X_scaled) kept_idx = selector.get_support(indices=True) # 2. 在过滤后的特征上跑动态加权CMI X_filtered_df = pd.DataFrame(X_filtered, columns=[f'f{i}' for i in kept_idx]) selected_local = dynamic_weighted_cmi_selection( X_filtered_df.values, y_train.values, num_features=50, k=3, gamma=1.0 ) selected_global = [kept_idx[i] for i in selected_local]这里VarianceThreshold(0.01)的参数需要根据特征量纲调——如果特征已经标准化,0.01 意味着方差低于 0.01 的特征基本是常数或近常数。但注意:方差过滤会删掉一些方差低但区分度高的特征,比如某个基因只在少数样本中高表达,方差不大,但对稀有类别的区分很有价值。所以过滤阈值不要设太激进,0.01 或 0.05 都是安全范围。
5.2 案例二:工业传感数据的在线特征选择
工业场景里特征选择往往要做成在线模式——数据流式到达,特征重要性随时间漂移。动态加权在这里的用法不是跑一遍前向搜索,而是维护一个滑动窗口,每个窗口结束后重算 CMI,用时间衰减代替条件集合规模的加权。
class StreamingCMISelector: def __init__(self, window_size=1000, k=3, gamma=1.0, top_k=20): self.window = [] self.window_size = window_size self.k = k self.gamma = gamma self.top_k = top_k self.selected_features = None def add_sample(self, x, y): self.window.append((x, y)) if len(self.window) > self.window_size: self.window.pop(0) def recompute(self): if len(self.window) < 50: return X = np.array([s[0] for s in self.window]) y = np.array([s[1] for s in self.window]) # 时间衰减权重:越近的样本权重越大 n = len(self.window) time_weights = np.exp(-np.arange(n) / n) self.selected_features = dynamic_weighted_cmi_selection( X, y, num_features=self.top_k, k=self.k, gamma=self.gamma ) return self.selected_features这个结构里dynamic_weighted_cmi_selection没有显式用time_weights——要真正接入需要对 CMI 估计器做样本加权。常见做法是把样本权重传给cKDTree的距离计算,或者在重采样时按权重采样。轻量级的替代方案是:每次重算时从窗口内按权重采样固定数量的样本,再用采样后的子集去跑 CMI。牺牲一点精度,换来实现复杂度大幅下降。
5.3 一个容易踩的坑:特征尺度差异造成的距离失真
最后说一个最容易踩的坑。cKDTree 用欧氏距离找近邻,如果某个特征量纲是 0~1,另一个是 0~10000,后者会主导距离计算,导致 k 近邻几乎都集中在那个高量纲特征的取值范围内,其他维度的信息被当噪声忽略。
解决方案不是标准化,而是对每个特征单独做分位数变换。标准化只保证均值为 0、方差为 1,但长尾分布仍然会让距离集中在少数异常值附近。分位数变换会把数据映射到均匀分布上,让每个特征对距离的贡献大致相等,CMI 估计才会稳定。
from sklearn.preprocessing import QuantileTransformer qt = QuantileTransformer(output_distribution='uniform', n_quantiles=100, random_state=42) X_transformed = qt.fit_transform(X)这里n_quantiles=100意味着每个特征被映射到 100 个取值上,分箱效应已经存在,后续不必再分箱。output_distribution='uniform'会消除特征的边缘分布差异,CMI 估计器里假设的条件分布会更接近均匀,方差也会小一些。如果特征已经做过分箱,这一步可以跳过,避免双重分箱带来的信息损失。
本文还有配套的精品资源,点击获取