简介:这套代码对应2022年全国大学生数学建模竞赛C题,是作者在获省一等奖后的配套MATLAB实现,适合正在备战国赛、希望参考完整解题流程的同学。代码覆盖问题一至问题三的主要环节,包括数据读取与预处理、预测类型判定、聚类分析、随机森林预测以及混淆矩阵绘图等,各脚本基于MATLAB内置函数编写,无需额外安装外部工具包,按目录运行即可复现结果。压缩包共24个文件,以13个.m程序文件为核心,辅以6个.mat中间数据文件和5个.xlsx结果文件,方便对照输入输出;整个资源包仅405KB,轻量易下载。目前已有1404人学习使用。作者坦言当时的代码习惯并非最优,但整体结构清晰,可作为快速上手国赛C题建模与编程的参考模板,尤其适合初次接触赛题、需要在短时间内搭建可行方案的队伍。
1. 2022 数学建模国赛C题代码:别扒个压缩包就开跑
经常有人拿一个压缩包来问我,说“2022 数学建模国赛C题代码”能不能直接用。这类压缩包十有八九是网上转来转去的:主脚本能打开,一运行全是警告,唯一能看懂的就是最后那张预测结果表。C题的背景是古代玻璃制品的成分分析,表面是一堆氧化物百分比,本质却是小样本分类问题——先判断样品是否风化,再对风化样品做高钾/铅钡亚分类。真正让代码包翻车的从来不是模型,而是数据处理、标签泄漏和结果反转这三个环节。这篇文章按数据读取、主模型、亚分类、验证四段拆开讲,给出一套能复现、能改、能写进论文的C题代码思路。适合正在备赛的学生,也适合处理成分类小数据的从业者。
2. 读懂C题数据:成分表、风化标记与预处理里的三个隐含条件
任何C题代码包,第一件事永远是读数据。别急着import sklearn,先花十分钟确认手里这张表到底是什么结构。我一般会用 pandas 读入后直接打印列名和前五行,这两行命令能避开后面八成以上的报错。国赛C题的数据通常是一张宽表:每一行是一个玻璃样品,每一列是一种化学成分的百分比含量,末尾还有几个描述性字段。如果不先把这些字段的用途分清楚,后面做特征选择、做标签映射的时候很容易自己把自己绕晕。
2.1 数据字段拆分:先看清每一列再谈建模
拿到表之后,先把列按“特征 / 标签 / 无用信息”三类归档。C题数据常见字段可以对照下面这张表理解:
| 字段示例 | 含义 | 建模用途 |
|---|---|---|
| 样品编号 | 样本的唯一标识 | 直接排除,不参与建模 |
| 类型 | 高钾玻璃 / 铅钡玻璃 | 亚分类标签 y_type |
| 风化 | 风化 / 未风化 / 严重风化 | 主标签 y_feng |
| 纹饰、颜色 | 描述性属性 | 本方案暂不编码 |
| 各氧化物成分 | SiO2、Na2O、K2O 等百分比 | 特征矩阵 X |
这里有一个C题特有的细节:氧化物成分列的数量和名称,不同来源的数据版本可能不一样。有的表里是 14 种成分,有的表里是 17 种,但凡是“百分比含量”的列,基本都可以作为特征进入模型。真正要小心的是“风化”这一列,它的取值不一定只有“风化”和“未风化”两个值,很多数据里会出现“严重风化”这类描述。备赛时不要假设数据长什么样,一定先跑一次value_counts()看看实际取值。
2.2 清洗成建模 DataFrame:一份可跑的预处理代码
我习惯把预处理单独写成一个脚本,这样后面换数据、换特征时不需要动模型代码。下面这段是C题代码里最基础的一段,直接复制就可以开始调:
import pandas as pd import numpy as np df = pd.read_excel("glass_data.xlsx", engine="openpyxl") print(df.shape) print(df.columns.tolist()) # 特征列:排除编号、类型、风化以及其他描述性字段 feature_cols = [c for c in df.columns if c not in ["编号", "类型", "风化", "纹饰", "颜色"]] X = df[feature_cols].fillna(0) # 主标签:风化二值化 # 注意:有的数据集里写“严重风化”,也要归入风化类 y_feng = df["风化"].map({"风化": 1, "严重风化": 1, "未风化": 0}).astype(int) # 亚分类标签:高钾 / 铅钡 y_type = df["类型"].map({"高钾玻璃": 0, "铅钡玻璃": 1}).astype(int) print(X.shape) print(y_feng.value_counts()) print(y_type.value_counts())这段代码里有两个参数值得解释。第一个是engine="openpyxl",pandas 读取 xlsx 文件时,部分环境中默认引擎不支持该格式,显式指定能稳定避开报错。第二个是fillna(0),成分列里出现空值,通常表示该成分未检出或低于检出限,按 0 处理比直接删除整行更合理——玻璃样品数量本来就不多,再用dropna()删下去,训练集可能连最基本的类别平衡都保不住。标签映射那里要格外确认原始表的取值,如果原始表里只有“风化”和“未风化”两种,上面字典里多写一个“严重风化”也不会报错,但如果你漏写了原始表里实际存在的某个取值,pandas 会将其映射成 NaN,后面的模型直接报错。
2.3 特征选择:为什么保留成分列、去掉编号和纹饰
很多新手会把“所有非标签列”一股脑塞进模型,这个习惯在C题上特别危险。样品编号看起来是数字,实际上只是流水号,模型会从里面强行学出规律,导致交叉验证分数虚高;纹饰和颜色这类文本字段,就算做了 one-hot 编码,类别数量也远多于样本数量,对 LDA 这类模型没有任何帮助。我一般在C题里只保留氧化物成分列,最多加一列“成分总和”作为辅助特征。氧化物成分之间存在天然的负相关——百分比总和接近 100,一种成分高了,另一种必然低——这个共线性问题暂时不用处理,到第 3 章建 LDA 模型时会用参数来吸收,但至少在这一步不要人为地往特征里添乱。
3. 主模型搭建:聚类探路加判别落地,C题分类代码的两段式结构
3.1 为什么先用聚类再看判别
C题第一问的核心是“风化是否会影响玻璃的成分结构”。这个问题可以直接丢给分类器,让 LDA 输出一个准确率,但论文里只有准确率是不够的。常见做法是先做无监督聚类,看样本在成分空间里是否天然分成两簇;如果簇边界和风化标签高度重合,说明风化带来的差异是结构性的,后续用有监督模型做定量判别才站得住脚。这个顺序相当于先让数据自己说话,再让模型给出可解释的判决规则。KMeans 在这个阶段足够用,核聚类和层次聚类不是不行,但KMeans的结果最容易和论文里的散点图对照。
3.2 轮廓系数定K与 KMeans 探路代码
KMeans 唯一要调的参数是簇数 k。C题样本少,直接手写一个循环,把 k 从 2 到 6 的轮廓系数都算出来,比拍脑袋定 k 靠谱得多:
from sklearn.cluster import KMeans from sklearn.metrics import silhouette_score for k in range(2, 7): km = KMeans(n_clusters=k, random_state=42, n_init=10).fit(X) score = silhouette_score(X, km.labels_) print(f"k={k}, silhouette={score:.3f}")轮廓系数接近 1 表示簇内紧凑、簇间分离明显。C题里 k=2 时轮廓系数往往是最高或接近最高的一档,因为原始数据里风化/未风化的结构确实存在。n_init=10这个参数很多人忽略,它表示 KMeans 用 10 个不同的初始中心分别跑,最后取最好结果,能减少随机初始化带来的波动。如果你跑出来的最优 k 不是 2,别急着怀疑代码,先回到第 2 章的value_counts()看看标签分布,可能是风化类严重不平衡。
聚类做完后还有一个必须处理的细节:KMeans 输出的簇编号是 0 和 1,但 0 不一定对应“未风化”,1 也不一定对应“风化”。直接用聚类标签计算准确率,结果可能只有 50%,看起来像模型完全失效。需要做一次标签对齐:
from scipy.stats import mode mapping = {} for cluster_id in np.unique(km.labels_): real_labels_in_cluster = y_feng[km.labels_ == cluster_id] mapping[cluster_id] = mode(real_labels_in_cluster).mode[0] aligned = np.array([mapping[c] for c in km.labels_])这段逻辑是:对每个簇,统计簇内出现最多的真实标签,把这个标签当作该簇的对齐结果。mode().mode[0]取的是出现频率最高的那个值。输出画像时,最好把“簇 0 里有多少样本是风化”“簇 1 里有多少样本是未风化”打印出来,论文里写“聚类结果与风化标签的一致性达到多少”时用得上。
3.3 LDA 判别分类与交叉验证
聚类看结构,分类看指标。C题里我常用线性判别分析 LDA 做有监督分类,因为它能输出线性判别函数,论文里可以直接写“判别函数的系数表明 K2O 和 PbO 对风化状态影响最大”,这是评阅时很加分的内容。代码不长:
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis from sklearn.model_selection import cross_val_score lda = LinearDiscriminantAnalysis(solver="lsqr", shrinkage="auto", random_state=42) scores = cross_val_score(lda, X, y_feng, cv=5) print(f"LDA acc: {scores.mean():.3f} ± {scores.std():.3f}")shrinkage="auto"是这段代码的关键参数。C题的特点是特征多、样本少,协方差矩阵的估计不稳定,自动收缩能让判别分析在小样本下不至于直接崩溃。如果你的数据特征数量更大,或者跑的时候报“特征数大于样本数”的错误,可以先用 PCA 把维度降到 5 维以下,再把降维结果喂给 LDA。交叉验证用cv=5是保守选择,样本量再少一点的团队可以换成留一验证,这个到第 6 章细说。输出里的 ± 表示五次验证的标准差,写论文时这个浮动范围比单次准确率更可信,也更能经得起评阅老师推敲。
4. C题代码最常见的5个坑:跑不通、过拟合与结果方向全反
网上流传的C题代码包,绝大多数问题不在模型选型,而在数据处理和随机性控制。这些坑单个拿出来都很简单,但凑在一起足够让一个准确率 95% 的方案变成 50%。我把碰到最多的情况按数据层和建模层分开写,每条都附上现象、原因和解决方式。
4.1 数据层面的三个坑
坑一:风化标签映射错位。现象:模型跑完准确率 70% 上下,怎么也提不上去,混淆矩阵里有一类样本数量异常少。原因:原始数据里“风化”列同时存在“风化”“严重风化”“未风化”三种取值,map字典里漏写了“严重风化”,这一部分样本全部变成了 NaN,被 pandas 静默丢弃。解决:预处理脚本里不要直接写字典,先跑一次df["风化"].value_counts(),把所有取值打印出来,再按第 2 章的映射方式统一二值化。C题数据经过多人转手,字段取值经常被改动,这个步骤每次换数据都要重新做。
坑二:编号列混进特征。现象:交叉验证准确率异常高,甚至接近 100%,但换到测试集上就崩。原因:第 2 章的feature_cols排除列表漏写了“样品编号”,编号是流水号,模型直接把它当成有效特征记住了训练集的顺序模式。解决:打印X.columns.tolist(),确认特征列里只有氧化物成分和必要的辅助列。这个坑最隐蔽的地方在于,准确率高得反常时,人往往会觉得是自己的模型调得好,而不是怀疑数据出问题。
坑三:成分空值用 dropna 删除导致样本不足。现象:代码报“训练集样本不足”或交叉验证时某一折里只有一个类别。原因:成分表里存在大量“未检出”空值,新手图省事直接.dropna(),把行删到只剩 20 多个样本,类别平衡直接被破坏。解决:用fillna(0)填充。玻璃成分数据里空值通常代表低于检出限,按 0 处理不仅保住了样本量,也更符合检测逻辑。如果你的数据空值是真实缺失而非未检出,再用插补,但C题场景下 0 填充基本够用。
4.2 建模层面的两个坑
坑四:准确率 100%,但论文里一个字都写不出来。现象:LDA 或逻辑回归在训练集上表现完美,怎么看怎么可疑。原因:这是C题最经典的标签泄漏——把“是否风化”这一列也放进了特征矩阵 X。风化状态本身就是标签,模型只靠这一列就能 100% 分对,等于拿着答案抄。解决:每次建模前检查X.columns,“风化”只能出现在 y_feng 里,绝不能出现在 X 里。第 2 章的排除列表就是为了防止这个问题,不要因为换列名就把这行删了。
坑五:随机种子不固定,跑两次结果差 5 个百分点。现象:不改任何代码,重启 kernel 再跑一遍,交叉验证分数明显变化。原因:交叉验证的样本划分由随机状态决定,C题样本量小,某一折里混入两个极端样本就能把均分拉低不少。解决:所有涉及随机的步骤统一固定random_state=42,交叉验证如果要做数据打乱,也写上shuffle=True并固定种子。论文的方法部分写一句“所有实验固定随机种子为 42”,既堵住质疑,也让复现的人跑出和你一样的结果。
5. 亚分类落地:风化/未风化分开建模,让高钾与铅钡更可解释
5.1 为什么不用全局随机森林,而选分层建模
C题还会要求进一步区分玻璃类型,常见的就是高钾玻璃和铅钡玻璃。一种省事的方案是直接训练一个全局分类器,把“类型”当标签,把成分列当特征,让模型自己学。但实际操作下来,这个方案在小样本上有两个问题:一是风化状态对成分的影响太大,全局模型会把主要分裂都用在“是否风化”上,对高钾/铅钡的区分能力反而被压下去;二是论文里只能写“随机森林准确率多少”,写不出更多机理层面的结论。我一般会选择分层建模:先把样本按风化状态分成两个子集,再分别对每个子集训练高钾/铅钡分类器。这样做的直接收益是,每个子模型只回答一个问题,判断规则会清晰很多。
5.2 分层建模代码:对风化子集训练决策树
下面这段代码是亚分类的核心。注意特征矩阵 X、主标签 y_feng、亚类标签 y_type 都来自第 2 章的预处理结果,变量名别混:
from sklearn.tree import DecisionTreeClassifier for feng_value in [0, 1]: mask = (y_feng == feng_value) X_sub = X[mask] y_sub = y_type[mask] print("风化状态:", feng_value, "样本数:", X_sub.shape[0]) clf = DecisionTreeClassifier(max_depth=3, random_state=42) clf.fit(X_sub, y_sub) print("训练集准确率:", clf.score(X_sub, y_sub))max_depth=3是一个刻意设置的参数。C题整体样本量只有几十个,风化子集的样本量更小,决策树如果不限深度,很容易长成一棵深树,把每个训练样本都背下来,训练集准确率 100%,但根本没法解释。限制到 3 层之后,树的分裂路径里出现的氧化物就是论文里最值得写的内容,比如“未风化子集中,PbO 是区分高钾与铅钡的第一分裂变量”。如果你希望得到更平滑的概率输出,可以把DecisionTreeClassifier换成LogisticRegression,但决策树对异常值更鲁棒,作为第一版跑通更省心。
这段循环的输出会同时给出两个子模型的准确率。如果发现风化状态的样本量严重不平衡——比如风化样本只有 15 个,未风化样本有 50 个——就别再用五折交叉验证评估子模型了,折数太少,每折只有 3 个样本,结果波动会非常大。子模型用第 6 章的留一验证更合理。
5.3 模型选型对比:C题小样本下的四种分类器
分层方案确定后,选哪个分类器还需要再斟酌。下面这张表按我自己的使用优先级排序:
| 模型 | C题上的优势 | C题上的注意点 |
|---|---|---|
| 逻辑回归 | 系数可直接解释成“某成分含量每增加 1%,判别概率变化多少” | 成分共线性强时系数不稳定,需要先看相关性 |
| 决策树(max_depth=3) | 分裂变量清晰,写进结论最直观 | 不剪枝必然过拟合 |
| RBF-SVM | 小样本泛化能力不错 | C、gamma 参数不好解释,论文说服力弱 |
| 随机森林 | 抗噪声能力强 | 样本太少时每棵树都很弱,整体提升有限 |
逻辑回归和决策树是C题论文里出现最多的两个选择。逻辑回归的优势在线性系数,适合说明“某个氧化物对类型的判别方向是正向还是负向”;决策树的优势在分裂结构,适合画树形图放进论文附录。如果两个模型在留一验证下准确率差不多,我倾向选决策树,因为C题评阅看的是分析过程,不是单纯的最优分数。
6. 从“能跑”到“能拿分”:C题代码的一个验证习惯与三个小技巧
6.1 用留一验证替代五折交叉验证
C题样本量通常只有几十个,五折交叉验证会把每折训练集缩到很小,结果方差很大。留一验证(LOOCV)每次只留一个样本做测试,其余全部训练,虽然训练成本高一点,但对小样本来说结果确定、没有随机划分的波动:
from sklearn.model_selection import LeaveOneOut, cross_val_score loo = LeaveOneOut() scores = cross_val_score(lda, X, y_feng, cv=loo) print("LOOCV acc:", scores.mean())我习惯把第 3 章的 LDA 和这里换成 LOOCV,得到的结果作为论文主结果,五折交叉验证的结果作为稳健性分析放在附录里。两个结果接近,说明模型的稳定性经得起推敲。
6.2 三张图让代码部分更值钱
代码跑通之后,别只输出一个准确率数字。三张图能让你的论文立刻和其他队伍拉开差距:第一张是用 PCA 降维后的聚类散点图,颜色按风化状态标,直接说明风化样本和未风化样本在成分空间里的分离趋势;第二张是混淆矩阵热力图,指出错误集中在哪一类,这比空口说“模型表现良好”有力得多;第三张是第 5 章决策树的可视化,用sklearn.tree.plot_tree画出来,作为亚分类的直观证据。绘图代码就几行,但论文里的位置很关键。
6.3 给变量命名留后路
预处理、聚类、LDA、决策树四个阶段不要挤在一个脚本里,分文件写,命名规范统一:X永远是特征矩阵,y_feng是风化标签,y_type是类型标签。某次模拟项目X里,我因为图省事用df["type"]覆盖了原始类型列,导致后面所有画图全部错位,最后两小时全部用来排查,那次之后我养成了习惯:永远不要覆盖原始列,新增列一律用xxx_new这种明确标识。这个习惯在比赛中的价值比任何调参技巧都大,因为写论文时你一定会回头改预处理,而最怕的就是改完忘记哪些列被动过。希望帮到你。
本文还有配套的精品资源,点击获取