☰
粗糙集属性约简:从决策表到最小属性集的可解释之路
2026/10/9 19:37:26 网站建设 项目流程

简介:面向数据挖掘学习者的粗糙集属性约简算法演示项目,基于Python构建,自带完整的图形可视化操作界面。程序通过main.py一键启动,内置csv样例数据,可直观展示决策表、属性约简过程与约简结果,帮助理解粗糙集理论中的核属性、区分矩阵、约简求解等关键概念。资源共16个文件,以py脚本为核心,涵盖主程序、主页面控制、数据输入与统计模块,另有pyc编译缓存、xml项目配置、txt使用说明及csv样例数据,压缩包仅14KB,轻量精简。项目采用工程化目录结构,平台使用说明清楚标注运行注意事项——需在data_dimension_reduction目录下执行main.py,且不可移动文件夹内文件,避免路径错误。已有1216人学习/下载,适合数据挖掘课程实验、算法对比研究或毕业设计参考,可快速获得一套可运行的粗糙集可视化Demo。

1. 粗糙集简约不是降维,是「找最小必要属性集」:从一张决策表说起

如果你做过表格数据的分类建模,大概率遇到过这种情况:原始特征几十列,真正对决策有贡献的却只有少数几个,其余全是冗余或者高度相关。常规PCA / LDA这些方法处理的是数值分布,压缩出来的是“线性组合后的潜变量”,可解释性很差。而粗糙集属性约简(也叫粗糙集简约)完全换了一条路:它不假设任何概率分布,只用“对象在给定属性下是否可分辨”这个集合关系,直接砍掉对决策没有贡献的条件属性。它面向的是决策表,也就是“对象→属性→决策标签”这样的结构化数据。做完约简,你得到的不是映射结果,而是一个可以拿去写规则、解释给业务听的最小属性集。这也让可视化变得非常有价值:等价类、正域、边界域、依赖度曲线,都能直观看到算法在做什么。

2. 粗糙集属性约简的核心逻辑:正域、依赖度与约简的判定标准

2.1 决策表、不可分辨关系与等价类

粗糙集处理的对象是一张决策表,通常写成四元组:U 是对象集合,A=C∪D 是属性集合,其中 C 是条件属性,D 是决策属性,V 是所有属性取值域,f 是信息函数,把每个对象映射到对应属性值。比如一张审批决策表,U 是申请人,C 是年龄、收入、信用,D 是审批是否通过。

在这里,最核心的关系叫不可分辨关系。给定属性子集 B⊆C,两个对象 x、y 关于 B 不可分辨,指的是对 B 里的每个属性 a,都有 f(x,a)=f(y,a)。比如只看“年龄”和“信用”,两个同为青年且信用良的人,虽然在收入上不同,但在 B 下是不可分辨的。这种不可分辨关系把 U 划分成若干等价类,每个等价类里的对象在 B 上“长得一样”。

等价类是后续所有计算的基石。正域、边界域、依赖度,本质上都是在统计这些等价类里决策标签是否一致。如果某个等价类中所有对象的决策值完全一样,这个类就属于正域;如果一类里既有通过也有拒贷,就落进边界域。粗糙集的“粗糙”不是指数据噪声大,而是说在当前属性子集下,存在无法被确定归类的边界对象。

2.2 正域和依赖度怎么算:一个7行表的算例

直接看个例子。下面这张表共有 7 个对象,条件属性是“年龄”“收入”“信用”(都已经离散化为 0/1/2),决策属性是“审批”(1 表示通过,0 表示拒贷)。

对象年龄收入信用审批
u10001
u20011
u30111
u41111
u51120
u62220
u72200

先看不带任何条件属性的情况,整个 U 是一个等价类,里面有两条决策(1 和 0),这个类属于边界域,所以正域是空集,依赖度是 0。带上全部条件属性 C={年龄,收入,信用} 后,每个对象在三个属性上都不完全相同,7 个对象各自成为一个等价类,每个类里决策都唯一,所以正域等于 U,依赖度 γ(C,D)=|POS_C(D)|/|U|=7/7=1。

现在尝试去掉某个属性。只看 B={年龄,收入},等价类划分是:{u1,u2} 决策都是通过,{u3} 通过,{u4,u5} 一个通过一个拒贷,属于边界域,{u6,u7} 都是拒贷。正域对象是 u1、u2、u3、u6、u7,共 5 个,依赖度 5/7。所以 {年龄,收入} 达不到完全确定决策。再看 B={收入,信用},同样会得到边界对象,依赖度小于 1。而 B={年龄,信用} 时,等价类是 {u1}、{u2,u3}、{u4}、{u5}、{u6}、{u7},每个类里决策唯一,依赖度正好等于 1。

这说明在保持决策分辨能力不变的前提下,属性“收入”是可以删掉的,{年龄,信用} 就是一个约简结果。判断条件很清晰:约简的属性子集 B 要满足 γ(B,D)=γ(C,D),同时去掉 B 中任何一个属性,依赖度都会下降。

2.3 约简与核:区分矩阵找必要属性

约简这个概念和“看似重要但可替代”的冗余属性直接相关。如果 B 是一个约简,说明 B 自身刚好够用,但也可能不止一个约简。比如某些数据里 {年龄,收入} 和 {信用,收入} 都能达到同样依赖度,且都不能再删,那这张表就有两个约简。所有约简的交集叫核属性,也就是必须保留、不可替代的属性。

找核属性有一个很实用的工具叫区分矩阵。对任意两个对象 x 和 y,如果它们的决策值不同,就收集在条件属性上取值不同的那些属性,放到矩阵位置 M(x,y) 里。如果某个位置只有一个属性 a,那 a 就是核属性。因为要想区分这两个对象,非 a 不可。整个约简问题可以理解成:选一个最小的属性集合,让它和每个 M(x,y) 都有交集。这个视角在属性不多时可以直接做集合覆盖穷举,属性多的时候才需要启发式搜索。

需要特别记住的是,粗糙集约简并不保证你得到的是“预测准确率最高”的属性集,而是保证“在训练数据上保持决策分辨能力不变的前提下,属性最少”。这也是为什么后来会有基于邻域粗糙集、模糊粗糙集等变种,用来兼容连续属性和噪声数据。

3. 自己复现一个基于依赖度的启发式约简:从决策表到最小属性集的 Python 代码

3.1 连续属性离散化:先决定“看到”哪些符号

粗糙集天然处理符号型数据。连续数值必须先离散化,再参与不可分辨关系计算。常见做法有三类:等宽分箱、等频分箱、基于决策类别的有监督分箱。等宽分箱实现简单,但遇到离群值会让很多对象挤进同一箱;等频分箱每个箱的样本数接近相等,更平衡;有监督分箱则会参考决策标签,尽量让每个箱里决策更纯。

下面这个函数封装了前两种常见做法:

import pandas as pd def discretize_series(series, method="eq_freq", bins=3): """将连续列离散化为整数分类码,供粗糙集使用。 method: eq_freq=等频分箱, eq_width=等宽分箱 """ if method == "eq_freq": # qcut 按分位数切分,每个箱样本量大致相等 return pd.qcut(series, q=bins, labels=False, duplicates="drop") elif method == "eq_width": # cut 按数值区间等宽切分 return pd.cut(series, bins=bins, labels=False) raise ValueError("method 只支持 eq_freq / eq_width")

这里 labels=False 表示直接返回整数编码,因为粗糙集只需要比较取值相等还是不相等,不需要保留区间含义。qcut 对存在大量重复值的列会报错,duplicates="drop" 会自动减少分箱数。等宽分箱的 bins 可以传一个整数,也可以传区间边界列表。一般来说,bins 的取值对后续正域影响很大,建议用交叉验证去做选择。

拿到离散化结果后,把它替换进原始 DataFrame 的对应列。注意:决策列如果是连续值,比如评分,也要先离散化或按阈值切分,否则正域计算会退化成“几乎每个对象一个决策值”,依赖度虚高。

3.2 计算正域与依赖度:两个小而关键的函数

要复现约简搜索,最核心的物流就是正域计算。我用 pandas 的 groupby 按条件属性组合分组,然后检查每组决策值是否唯一。为了避免索引错位,我会保留原始 DataFrame 的行索引。

def positive_region(df, cond_cols, dec_col): """返回正域对象索引集合。 条件属性相同的一组对象,如果决策值唯一,则都属于正域。 """ pos = set() grouped = df.groupby(cond_cols, sort=False) for _, group in grouped: if group[dec_col].nunique() == 1: pos.update(group.index.tolist()) return pos def dependence(df, cond_cols, dec_col): """依赖度 = 正域对象数 / 总对象数""" return len(positive_region(df, cond_cols, dec_col)) / len(df)

positive_region 里的 groupby(cond_cols) 会把条件属性取值完全相同的对象合并成一组,sort=False 保留原始出现顺序,速度快一些。nunique() 统计决策列的唯一取值个数,等于 1 说明这是一致等价类,也就是正域的一部分。这里用 set 收集索引而不是行号,防止重复对象出现时计数错误。

依赖度是一种单调不减的指标:你加入更多属性,等价类只会变得更细,正域不会缩小。所以依赖度适合做前向搜索的得分函数。但要注意,如果数据本身有噪声,依赖度永远到不了 1,这时你要设定一个阈值,比如 0.95,而不是死等 1。

3.3 向前贪心搜索找约简:代码与参数说明

有了依赖度函数,就可以做最常用的顺序前向选择:从空属性集开始,每一步挑一个能让依赖度提升最多的属性加进来,直到依赖度不再提升或者已经达到阈值。这个方案不保证全局最优,但实现快,适合高维数据。

def greedy_reduct(df, cond_cols, dec_col, threshold=1.0): """向前贪心属性约简。 threshold: 目标依赖度,默认1.0;数据有噪声时可降到0.95 """ selected = [] remaining = list(cond_cols) current_dep = 0.0 while remaining: best_attr = None best_dep = current_dep for attr in remaining: dep = dependence(df, selected + [attr], dec_col) # 只接受带来严格提升的属性,防止重复加冗余 if dep > best_dep + 1e-9: best_dep = dep best_attr = attr if best_attr is None: break selected.append(best_attr) remaining.remove(best_attr) current_dep = best_dep print("加入属性", best_attr, "依赖度=", round(current_dep, 4)) if current_dep >= threshold - 1e-9: break return selected, current_dep

循环里计算依赖度的代价是 O(n·|selected|),整体复杂度约等于 O(|C|²·n),对于几千行、几十列的数据完全够用。参数 threshold 控制的是“我们认为达到充分分类的程度”,默认 1.0 表示要求严格保持决策分辨能力;实际做规则提取时,阈值太高会导致约简保留过多属性,太低又会损失信息。

用第 2 章的 7 行决策表直接跑:

import pandas as pd df = pd.DataFrame({ "年龄": [0, 0, 0, 1, 1, 2, 2], "收入": [0, 0, 1, 1, 1, 2, 2], "信用": [0, 1, 1, 1, 2, 2, 0], "审批": [1, 1, 1, 1, 0, 0, 0], }) conds = ["年龄", "收入", "信用"] reduct, dep = greedy_reduct(df, conds, "审批", threshold=1.0) print("约简属性:", reduct, "依赖度:", dep)

运行结果是先加“年龄”到 5/7,再加“信用”到 1,最后返回约简属性 ['年龄', '信用']。这个结果和手算一致。需要说明的是,pandas 的 groupby 默认会忽略 NaN 值,所以如果条件列里有缺失值,等价类划分会漏对象。下一章的避坑部分会专门讲这个。

4. 图形可视化:把等价类、依赖度和约简路径画出来

4.1 决策表矩阵网格图:一眼看出边界对象

理论算完,代码也跑通,接下来就是让结果“看得见”。最直观的可视化叫作决策表网格图:横轴放条件属性,纵轴放对象,单元格颜色深浅代表属性取值。然后我在网格上把不在正域里的对象加个红色边框,边界情况一目了然。

import matplotlib.pyplot as plt import numpy as np def plot_table_grid(df, cond_cols, dec_col): pos = positive_region(df, cond_cols, dec_col) data = df[cond_cols].astype(int).to_numpy() fig, ax = plt.subplots(figsize=(len(cond_cols) * 1.4, len(df) * 0.6)) im = ax.imshow(data, cmap="Blues", aspect="auto") for r in range(data.shape[0]): for c in range(data.shape[1]): ax.text(c, r, data[r, c], ha="center", va="center", fontsize=10) # 给边界对象加红框 for row_idx, obj_idx in enumerate(df.index): if obj_idx not in pos: rect = plt.Rectangle((-0.5, row_idx - 0.5), data.shape[1], 1, fill=False, edgecolor="red", lw=2) ax.add_patch(rect) ax.set_xticks(range(len(cond_cols))) ax.set_xticklabels(cond_cols) ax.set_yticks(range(len(df))) ax.set_yticklabels(df.index) ax.set_title("决策表网格图:红框对象属于边界域") return fig, ax

这里的红色框对象就是当前属性子集下无法被确定分类的对象。约简的目标就是让红色框尽可能少。对比看:用全部条件属性时红框为 0;用 {年龄,收入} 时红框是 u4、u5;换成 {年龄,信用} 后红框恢复为 0。可视化把“属性集有多大能力”变成了“图上有没有红框”,用来向业务解释特别顺手。

网格图最适合属性值少于 6 类的场景。如果离散化后有很多值,颜色图会变得杂乱,建议改成在格子内写数字、不使用连续 colormap。

4.2 依赖度增长曲线:看约简的边际收益

做前向贪心搜索时,我一般顺手记录每一步的依赖度变化,画一条增长曲线。它能看到两个信息:从无到有,前几步依赖度提升有多猛;最后几步还值不值得加属性。如果曲线已经平了,再加属性就是过拟合。

def dependency_trace(df, cond_cols, dec_col): selected = [] remaining = list(cond_cols) trace = [0.0] while remaining: best_attr = None best_dep = trace[-1] for attr in remaining: dep = dependence(df, selected + [attr], dec_col) if dep > best_dep + 1e-9: best_dep = dep best_attr = attr if best_attr is None: break selected.append(best_attr) remaining.remove(best_attr) trace.append(best_dep) return selected, trace selected, trace = dependency_trace(df, conds, "审批") plt.figure(figsize=(6, 4)) plt.plot(range(len(trace)), trace, marker="o") plt.xticks(range(len(trace)), ["空集"] + selected) plt.xlabel("已选属性") plt.ylabel("依赖度 γ") plt.title("前向贪心搜索过程中的依赖度变化") plt.grid(alpha=0.3)

这段曲线在只有一个约简的小数据上看起来就是 0→0.714→1.0,一步一个台阶。真实数据上更常见的形状是前面陡峭,后面变缓,最后平顶。如果出现中间掉头下降,就要检查代码:依赖度理论上单调不减,下降必然是因为离散化或者索引处理有 bug。

横轴的标签是每一步被选中的属性名,所以这条曲线也等于完整展示算法的决策路径。业务方看到曲线在前两个属性就到顶,后面全平,就会理解“为什么后面那些字段没必要建入模型”。

4.3 约简前后规则数量对比条形图

约简的直接收益是让后续规则表变小。一个等价类对应一条可能规则,条件属性越多,等价类切得越碎,规则数越多。约简之后,规则数通常会明显下降,而且每条规则覆盖的对象数更多,更稳定。这个对比用简单条形图就能表达。

def rule_count(df, cond_cols): """统计条件组合的唯一数量""" return df.groupby(cond_cols).ngroups original_count = rule_count(df, conds) reduct_count = rule_count(df, red=True? )

上面这个 red=True 是故意写成占位符,实际使用时直接传约简后的属性列表:

original_count = rule_count(df, conds) reduct_count = rule_count(df, reduct) plt.figure(figsize=(6, 4)) plt.bar(["原始属性集", "约简属性集"], [original_count, reduct_count], color=["#aaa", "#5b9bd5"]) plt.ylabel("唯一条件组合数") plt.title("约简前后等价类数量对比") for i, v in enumerate([original_count, reduct_count]): plt.text(i, v + 0.1, str(v), ha="center") plt.show()

注意“唯一条件组合数”并不等同于最终规则数,因为同一个组合如果决策不唯一,还会产生多条带置信度的规则。但作为对比,它能直观说明约简如何减少建模复杂度和计算量。我在实际处理一张 50 个属性的表时,约简后经常从几千个条件组合降到几十个,这个条形图用来做报告摘要非常直接。

5. 粗糙集约简落地中的5个常见坑与排查方法

5.1 离散化分箱导致「假依赖」

现象:依赖度算出来是 1.0,约简结果也很漂亮,可是放到验证集上一看,规则泛化能力很差。

原因:连续属性分箱数设得太多,比如直接用 qcut 分成 10 箱,很多对象被拆成单例等价类,每个类里决策自然唯一,正域虚高到接近全量。依赖度高不代表找出了真实规律,只代表数据被切到足够碎。

解决:先用 3~5 箱做粗分,观察依赖度变化;再结合业务含义选边界。如果某个箱里的对象数过少,说明分箱过细。更稳的方法是做有监督分箱,让决策标签参与边界选择。切记依赖度要和规则泛化效果一起看,不要单独看指标。

5.2 缺失值被 groupby 悄悄忽略

现象:用 pandas 算正域时,正域对象数比预期少,依赖度偏低;有的对象明明决策一致,却没进入正域。

原因:groupby 在分组键含有 NaN 时,会把缺失值对象单独放一组,而且默认不显示。粗暴的结果是这些对象没有被并入正确的等价类,甚至被丢掉。

解决:参与约简计算之前,所有条件属性都做好缺失值处理。常见做法是用众数或均值补齐;也可以显式将缺失值编码成独立符号,比如 -1,让缺失在等价类划分里被当作一种真实取值。决策列必须保证无缺失。检查依赖度异常偏低时,先统计每列缺失值。

5.3 贪心搜索停在局部最优,不是最小约简

现象:向前贪心得到 4 个属性,但用区分矩阵穷举后发现 3 个属性就能达到同样依赖度。

原因:依赖度函数是单调的,但属性之间可能存在替代关系。贪心每一步都选当前收益最大的属性,可能过早选入某个表现好的属性,后面就再也发现不了能替代它的组合。

解决:属性不超过 20 个时,优先用区分矩阵做集合覆盖穷举;属性多时用贪心结果做初始化,再做一个反向剪枝:从已选属性里尝试逐个移除,如果移除后依赖度不变就删掉。这样至少能去掉一部分冗余。还可以用多次随机起始顺序跑贪心,取最短结果。

5.4 索引错位让可视化对不上

现象:网格图第一行不是原本索引为 0 的对象,或者规则提取出来的条件和表里对不上。

原因:DataFrame 在经过离散化、dropna、reset_index 后,行位置变了,但正域计算时用的是原始索引。如果后又用 range(len(df)) 去画图,必然错位。

解决:从数据预处理开始就尽量保留原始索引。离散化函数里先 copy(),不要原地覆盖;如果必须 reset_index,使用 reset_index(drop=False) 把旧索引保留为单独列。正域函数中始终用 df.index 作为对象标识,画图时用 enumerate(df.index) 得到每个格子的真实对象编号,不要在函数里临时生成 0~n-1 的假索引。

5.5 决策类别太多,圈选边界域的图形难以阅读

现象:画网格图时,红框都叠在一起,颜色也分不清哪些对象属于哪一类,整张图变成一片杂色。

原因:决策属性如果被切分了十几个类别,matplotlib 的颜色表无法清楚区分;边界对象一多,红框叠加后盖住单元格,可视化失去意义。

解决:先降低决策类别粒度,比如把多分类映射成少数几个业务大类;或只在图上显示一个特定的二元决策,比如“是否通过”。红框可以改成在单元格左侧画竖线,而不是围绕整行画方框。真正常见的做法是分图展示:先画条件属性网格,再用另一张散点图映射决策类别。

6. 把约简结果变成可解释规则:等价类转规则与置信度验证

有了约简后的属性集,下一步就是把它变成可以直接读的规则。我一般会把每个等价类转换成一条候选规则:条件部分是约简属性取某个组合,结论是组内多数决策,置信度是多数决策占比。代码实现如下。

def extract_rules(df, cond_cols, dec_col, min_conf=0.8): rules = [] for vals, group in df.groupby(cond_cols, sort=False): counts = group[dec_col].value_counts() total = len(group) majority_cls = counts.idxmax() conf = counts[majority_cls] / total if conf < min_conf: continue rules.append({ "condition": dict(zip(cond_cols, vals)), "decision": majority_cls, "support": total, "confidence": conf }) return rules rules = extract_rules(df, reduct, "审批", min_conf=0.8) for r in rules: print(r)

这里 min_conf 是一个关键参数。设置太高会丢掉等价类中的低置信样本,设置太低则会留下冲突明显的规则。常见做法是对原始条件属性集和约简属性集各提取一遍规则,对比规则数和平均置信度:约简后规则数应该更少,平均置信度不应该明显下降。如果置信度掉太多,说明约简把重要属性误删了,需要回退到上一章介绍的区分矩阵穷举再验证。

有了规则表,你还能画一张规则决策图:左侧是条件属性取值节点,中间是规则节点,右侧是决策类别节点。用 networkx 和 matplotlib 画有向图,每种决策给一种颜色,节点大小按 support 映射。这个图比表格更适合做交付展示,也能快速发现哪些条件组合覆盖了大多数样本。

最后回到一个基本原则:约简只是数据压缩的预处理,不是模型训练的终点。我最初做这个方向时,也误把依赖度 1.0 当成“完美表格”,后来才发现那是离散化过细造成的幻觉。真正靠谱的习惯是对照原始依赖度、记录每种分箱参数下的约简结果和验证集上的规则置信度,再一起决定要不要采用这组属性。这三张图——网格图、依赖度曲线、规则对比图,就是我判断约简质量的常规三件套。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询