简介:面向2025年数学建模竞赛C题参赛者,这份代码与思路整合包提供从建模到结果解释的完整流程,覆盖建模、编程与结果输出,但不含论文部分,可直接用于思路参考与算法验证。包内共40个文件,总大小约92.42MB,以Python脚本、TXT报告、PNG图表和Markdown说明为主,另有Excel数据附件、训练好的模型文件、赛题PDF原文及一键安装环境的批处理脚本。内容按题目子任务拆分为多个模型模块:第二个问题完成体质指数分组分析,第三个问题实现多因素优化,第四个问题开展女性异常检测,并配套无创产前检测模型的训练、验证与可视化脚本。训练完成的模型可免去重复训练,直接输出预测结果;报告和图表清晰展示各步骤结论、误差分布及模型表现,目录划分明确,同时提供环境状态说明与项目总结文档,适合快速理解C题整体思路、进行二次开发或核对结果的中级建模选手。目前已有206人浏览学习。
1. 2025年数学建模C题:先把题型吃透,再谈代码和结果
三天赛程,对大多数队伍来说,数学建模C题真正拼的不是谁算法更炫,而是谁能在有限时间内把一套“数据→特征→模型→结果”的流水线跑通并自圆其说。以当下的赛题热度看,2025年C题大概率落在医学检测数据方向,例如无创产前检测(NIPT)的时点选择与胎儿异常判定这类“数据+医学指标”的题目,给你一批检测记录,要你建模预测指标或找出最优监测时点。这类题的特点很明确:数据量大、字段杂、不要求你推导偏微分方程,但要求你的每一步都可解释、可复现、结果可验证。适合有pandas和sklearn基础、想在三天内拿到稳定奖项的队伍。这篇笔记把常用来啃C题的“代码+思路+结果”三件套路线从头拆开,包括可以直接抄的代码和最容易翻车的几个坑。
2. 拆解C题的“代码+思路+结果”三件套:从任务书到可交付目录
2.1 C题在考什么:从赛题描述里挖出“可计算的命题”
翻开C题任务书,第一件事不是读数据,而是把题干拆成三个可计算的问题:输入是什么,输出是什么,评判好坏的标准是什么。以NIPT时点选择为例,输入通常是孕周、胎儿分数(fetal fraction)、Z值、游离DNA浓度等检测记录,输出是“第几周做检测能最大化异常检出率且不增加假阳性”,评判标准就是灵敏度、特异度、AUC这一类指标。C题和A题的本质区别就在这里:A题考机理建模,B题考优化决策,C题考的是从数据里找规律,并写代码把它验证一遍。
所以“思路”部分不应该是长篇大论的背景综述,而是“我打算在什么变量上做什么假设,用哪个模型验证,通过什么指标判断假设成立”。如果你对“假设—检验—决策”这个骨架没有直观印象,翻一翻往年的数学建模优秀论文,尤其是华为杯数学建模优秀论文里的数据分析类题目,会发现高分队伍的思路部分几乎都是这个骨架,只不过用赛题语言包装过。把题目翻译成这句话,你的代码目录和论文结构就有了主线。
另一个容易被忽略的点是,C题的数据往往不是一张表,而是多张表通过ID关联。比如NIPT类题目可能同时给出“孕妇基本信息表”“检测指标记录表”“随访结果表”,建模前需要先把它们合并成宽表。这一步没有想清楚就急着跑模型,后面八成要返工重做特征。建议第一天上午专门花两个小时把表间关系画清楚,再动代码。
2.2 搭一个“无论文”的代码工程目录:三天不乱靠结构
建模比赛写得最多的是代码,最后交给评委的也是代码加结果文件。如果代码是一坨main.py,第三天凌晨你自己都改不动,更不用说评委复现。我习惯在动手写第一行代码之前,先建好下面这套目录结构:
2025_C_solution/ ├── data/ │ ├── raw/ # 原始数据,只读不写 │ │ ├── train.csv │ │ └── test.csv │ └── processed/ # 清洗后的中间数据 │ ├── train_clean.csv │ └── test_features.csv ├── src/ │ ├── 01_eda.py # 数据体检与可视化 │ ├── 02_feature_engineering.py # 特征工程 │ ├── 03_model_baseline.py # 基线模型(逻辑回归) │ ├── 04_model_advanced.py # 对比模型(LightGBM) │ └── 05_export_result.py # 结果汇总与导出 ├── models/ # 保存训练好的模型 │ └── lr_model.pkl └── results/ # 所有输出文件 ├── predictions_test.csv ├── feature_importance.png └── roc_curve.png这个目录把数据、代码、结果三块硬隔离。data下的raw子目录只放组委会给的原文件,processed放中间产物;src按数字编号排好执行顺序,新增分析就往后追加编号,不要打乱已有脚本;results里只出现能直接给评委看的图、表和预测文件。这个习惯是我在多次比赛里吃过大亏后养成的,第三天凌晨改代码时,最怕的就是翻遍整个文件夹找不到上一版结果。
| 文件 | 用途 | 必须存在的时间点 |
|---|---|---|
| data/processed/train_clean.csv | 清洗后用于建模的数据 | 第一天晚上 |
| models/lr_model.pkl | 训练好的逻辑回归模型 | 第二天中午 |
| results/predictions_test.csv | 测试集预测结果 | 第三天上午 |
| results/roc_curve.png | 模型评估图 | 第三天中午 |
数据文件统一用csv,不要用xlsx;文件名全部小写加下划线,避免不同系统下因大小写敏感导致脚本报错。写入路径全部基于项目根目录的相对路径,路径工具函数在src里统一维护,后面所有脚本都从它导入。
2.3 把“思路”写成“假设—检验—决策”三步,而不是论文摘要
思路文档的读者是和你一起熬夜的队友,以及第三天翻你论文的评委。它要回答的不是“背景多么重要”,而是“你到底打算怎么算”。我会按三段写:假设、检验、决策。以NIPT时点问题为例,思路可以压缩成一段可执行描述:假设胎儿异常信号在孕12到20周之间存在一个可检出的敏感窗口;先用方差分析判断不同孕周的胎儿分数是否显著变化,再用Logistic回归对孕周分段输入建立异常判定模型;最后在验证集上比较各孕周分段的AUC,把AUC最高的区间作为推荐时点。
这段话每个分句都能对应到代码里的一个步骤:方差分析对应EDA的分组统计,Logistic回归对应baseline模型,AUC比较对应结果评估脚本。所以我常对队友说,思路不是单独写的文档,它是代码的注释版。反过来说,如果你的代码没有一个能和“假设—检验—决策”逐句对应的注释,说明你还没想清楚自己要干什么。
3. 数据清洗与特征工程:C题拿分的第一道坎
3.1 读入数据先做“三查”:缺失、分布、类型
数据到手后,先运行一小段脚本做“体检”,绝不直接建模。以下面的train.csv为例,包含孕周(gestational_week)、胎儿分数(fetal_fraction)、Z值(z_score)等数值字段,标签是is_abnormal(0/1):
import pandas as pd df = pd.read_csv("data/raw/train.csv", encoding="utf-8") print("shape:", df.shape) # 1) 缺失率:从高到低排序 missing = df.isnull().mean().sort_values(ascending=False) print("missing rate:\n", missing[missing > 0]) # 2) 数值分布:describe 直接看 min/max/mean num_cols = df.select_dtypes(include="number").columns.tolist() print("numeric describe:\n", df[num_cols].describe().T) # 3) 类型和唯一值:揪出 ID 列和格式异常的文本列 for col in df.columns: if df[col].dtype == "object": print(col, "unique:", df[col].nunique(), df[col].unique()[:5])这段代码做三件事:缺失率排序、数值型字段的describe、分类型字段的唯一值。缺失率高且业务上无意义的列直接删,例如“备注”这种自由文本;分布范围异常大的列后续要做对数变换或裁剪;object类型如果唯一值数量等于样本量,说明是ID列,建模时要排除。
NIPT这类医学数据最常见的一个坑是孕周字段被填成“12+3”这种字符串,读进来全是object,describe直接失效。遇到这种情况,用正则把数字提取出来再转int,后面所有时间相关的计算才不会崩。清洗完成后,把结果写回data/processed/train_clean.csv,这一步是给后续所有脚本准备的唯一数据入口:
df.to_csv("data/processed/train_clean.csv", index=False, encoding="utf-8-sig")3.2 特征工程:从原始字段里造出能解释的模型输入
特征工程的目标不是造几百个花哨特征去刷AUC,而是造出评委能看懂、模型也用得上的特征。我会按三条线做:医学常识线、统计分布线、组合交互线。承接上一步清洗后的数据:
import numpy as np import pandas as pd df = pd.read_csv("data/processed/train_clean.csv", encoding="utf-8") # 医学常识线:孕周区间二值化 df["week_group"] = pd.cut( df["gestational_week"], bins=[10, 12, 14, 16, 18, 20], labels=["10-12", "12-14", "14-16", "16-18", "18-20"] ) # 统计分布线:对偏态严重的胎儿分数做 log1p 变换 df["fetal_fraction_log"] = np.log1p(df["fetal_fraction"]) # 组合交互线:Z值与胎儿分数的比值,捕捉“浓度效应” df["z_frac_ratio"] = df["z_score"] / (df["fetal_fraction"] + 1e-6)pd.cut把连续孕周转成有序分组,后面画趋势图和做分层方差分析有了依据;log1p变换压低胎儿分数的右偏分布,让逻辑回归数值更稳;z_frac_ratio这种交互特征在医学检测数据里常常比单个字段更有区分度,它刻画的是标准化信号与浓度之间的冲突程度。log1p里的1是平滑项,防止log0;分母加1e-6是避零,不影响数值稳定性。
要特别提醒一句,特征不是造完就完了。每造一个特征都要回到上面看一眼缺失和分布,否则第三步模型会莫名其妙报错。这里有个判断特征是否值得保留的经验:单独拿它做一次单变量AUC或t检验,如果p值大于0.05且AUC低于0.55,说明这个特征和标签没什么关系,造了也白造。单变量筛选的脚本可以在五分钟内跑完,值得做。
3.3 数据划分与分层抽样:别让偶然毁掉整个结果
建模前对训练集做划分,这一划分决定了后面所有评估数字是否可信。C题样本量一般在几千到几万这个量级,直接随机切分可能出问题:正负样本不均衡时,验证集里可能一个正例都没有。用统计方法保证比例保持一致:
from sklearn.model_selection import train_test_split X = df.drop(columns=["sample_id", "is_abnormal"]) y = df["is_abnormal"] X_train, X_val, y_train, y_val = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 )stratify=y的作用是让训练集和验证集里的正负比例与原数据集保持一致。test_size=0.2是常规设置,样本不到一万时建议提到0.3,让验证集更稳;random_state固定住,保证反复跑代码拿到的分数一致,这是“可复现”最基本的一条,后面模型训练里所有random_state都要写死。
分层划分之后还有一个容易被忽略的动作:把验证集标签单独存一份csv放到data/processed目录。后面模型训练、画ROC、算AUC,可能换了三个脚本,但验证集标签必须始终引用同一份,否则结果就乱了。记住变量只存在内存里,脚本一关就没了,中间文件才是你三天里真正信得过的东西。
4. 建模与代码实现:把思路变成可运行的结果
4.1 可解释模型打底:逻辑回归与最优时点搜索
建模顺序永远是先上一位“性格透明”的模型,逻辑回归或线性回归,把基线和系数跑出来,再考虑树模型和黑盒。理由很简单,C题的评委大多有统计背景,拿一个带p值和系数的逻辑回归,比拿一个不知道内部逻辑的梯度提升树更能说清楚“结果为什么成立”。调参有时是玄学,但逻辑回归不是,它的每一个系数都能翻译成业务语言。
下面这段代码用逻辑回归判断胎儿异常,并按孕周分组找最优检测时点:
from sklearn.linear_model import LogisticRegression from sklearn.metrics import roc_auc_score import pandas as pd # 训练基线模型 lr = LogisticRegression(max_iter=1000, C=0.5, solver="liblinear", random_state=42) lr.fit(X_train, y_train) y_pred = lr.predict_proba(X_val)[:, 1] print("val AUC:", roc_auc_score(y_val, y_pred)) # 按孕周分组找最优时点 val_df = X_val.copy() val_df["y_true"] = y_val.values val_df["y_score"] = y_pred val_df["week_group"] = pd.cut( val_df["gestational_week"], bins=[10, 12, 14, 16, 18, 20], labels=["10-12", "12-14", "14-16", "16-18", "18-20"] ) auc_by_group = val_df.groupby("week_group", observed=False).apply( lambda g: roc_auc_score(g["y_true"], g["y_score"]) ) print(auc_by_group)max_iter=1000给足迭代次数;solver="liblinear"适合中小样本;C=0.5是L2正则强度,C越小正则越强,验证集AUC比训练集低很多时,先把C往0.1、0.05方向调,比换模型有效。groupby那一步输出的就是“第几周做检测效果最好”的数字依据,结果表里直接引用这组AUC就能回答题目的时点问题。
逻辑回归还有一个隐性优势:它输出的概率是做过校准的,可以直接用0.5做阈值,不必像树模型那样反复调threshold。这在三天赛程里能省下大量调参时间,也少了很多“阈值调哪里才合理”的争论。
4.2 模型对比与兜底:LightGBM的正确打开方式
如果逻辑回归的验证集AUC低于0.8,或者你通过特征分析明显感到关系是非线性的,比如胎儿分数与异常率之间呈U型关系,这时才上LightGBM。注意它的定位是对比和兜底,不是炫技。
from lightgbm import LGBMClassifier gbm = LGBMClassifier( n_estimators=300, learning_rate=0.05, num_leaves=15, max_depth=4, reg_alpha=0.1, reg_lambda=0.1, subsample=0.8, colsample_bytree=0.8, random_state=42, verbose=-1 ) gbm.fit(X_train, y_train) y_pred_gbm = gbm.predict_proba(X_val)[:, 1] print("gbm val AUC:", roc_auc_score(y_val, y_pred_gbm)) # 特征重要性,导出供论文使用 importance = pd.Series(gbm.feature_importances_, index=X_train.columns) print(importance.sort_values(ascending=False).head(10))n_estimators=300配合learning_rate=0.05,是速度与精度的常见折中,不要一上来就填1000;num_leaves=15和max_depth=4是故意把模型摁小,C题数据集通常只有几万行,树太大必然过拟合;reg_alpha和reg_lambda是L1/L2正则,医学数据噪声多,留0.1比设0稳;subsample=0.8是行采样,colsample_bytree=0.8是列采样,既加速又防过拟合。
跑完gbm后要做的是和逻辑回归对比,而不是直接选它。如果gbm的AUC只高0.02,特征重要性排名前几位又是逻辑回归里显著的变量,那么论文里仍然以逻辑回归为主,把gbm作为“结论稳健性验证”写进附注即可。C题评阅看重稳定性和可解释性,一个加了正则、参数收敛、系数方向符合医学常识的逻辑回归,胜过千棵树的堆叠。
4.3 结果导出:把预测值、指标、系数整理成“交付物”
建模完成后最容易被忽略也最容易丢分的,是把代码结果翻译成评委能直接读取的交付文件。这里说的交付文件不是论文,而是三个csv:测试集预测结果、模型评估表、特征系数表。
feature_cols = X_train.columns.tolist() # 测试集预测 test = pd.read_csv("data/raw/test.csv", encoding="utf-8") test_feat = test[feature_cols] test["pred_prob"] = lr.predict_proba(test_feat)[:, 1] test["pred_label"] = (test["pred_prob"] >= 0.5).astype(int) out = test[["sample_id", "gestational_week", "pred_prob", "pred_label"]] out.to_csv("results/predictions_test.csv", index=False, encoding="utf-8-sig") # 模型评估表 metrics = pd.DataFrame({ "model": ["LogisticRegression", "LightGBM"], "val_auc": [auc_lr, auc_gbm] }) metrics.to_csv("results/metrics_summary.csv", index=False, encoding="utf-8-sig") # 特征系数表 coef_df = pd.DataFrame({ "feature": feature_cols, "coef": lr.coef_[0], "odds_ratio": np.exp(lr.coef_[0]) }).sort_values("odds_ratio", ascending=False) coef_df.to_csv("results/feature_coefficients.csv", index=False, encoding="utf-8-sig")encoding="utf-8-sig"是写给Excel看的,不加这个,Windows下打开csv会乱码,这是最容易踩的小坑。odds_ratio是系数的指数变换,医学数据里评委喜欢看“某项指标升高一个单位,风险增加多少倍”,这一列可以直接放进结果表。注意代码里的auc_lr和auc_gbm是前面训练好的模型在验证集上的AUC值,导出前要确认变量还在,脚本重跑能复现,而不是靠复制粘贴填进去的。
交付物统一放results目录,命名清晰且稳定。这样第三天凌晨你才敢打包提交,因为你是在完整复现过结果的基础上交的,而不是凭感觉觉得没问题。
5. C题最坑的5个地方:代码与结果反复翻车的血泪复盘
5.1 缺失值一把drop,样本量当场崩盘
现象:数据里缺失率不到10%,建模前一个dropna()把缺失行全删了,结果训练集直接缩水三分之一,后面所有系数和AUC都不对劲,队友之间互相怀疑数据没发全。
原因:缺失并不是均匀分布的。它往往集中在某个特定字段上,比如某些检测项目在部分医院没有上报,连带这个字段的整条记录一起缺失。这个字段恰好还是重要预测变量,删行等于把关键样本一起扔了。
解决:先按字段看缺失率和业务含义。字段缺失超过80%且非核心,直接删列;缺失率在30%-50%之间且业务关键,优先用中位数或模型预测填补;缺失偏低但属于文本备注类字段,单独置为“未知”类别。补全后的数据统一存入data/processed,供下游脚本引用,不清洗两次。
5.2 归一化用了全样本的统计量,悄悄泄露了测试集信息
现象:第三天用测试集跑预测,发现预测概率普遍偏高,和训练集表现严重不一致。反复检查代码逻辑没发现问题,最后才想到是数据预处理环节出了岔子。
原因:建模前对全量数据一起做了StandardScaler,或者对训练集做了fit_transform后,后续特征工程脚本里又顺手用全样本的mean和std重新处理了一遍。测试集的统计量被混进了训练过程,这在机器学习里叫数据泄露,会让验证集分数虚高。
解决:scaler只能在训练集上fit,验证集和测试集只做transform。把这个规则焊死在特征工程脚本里:fit_transform(X_train),然后transform(X_val),transform(X_test)。写完脚本后自查一下,凡是用到fit_transform的代码,确认它的输入对象只有训练集。这是老生常谈,但每届比赛都能见人在这上面熬夜返工。
5.3 模型追高AUC,结果业务解释完全失控
现象:LightGBM调了一晚上,验证集AUC从0.82提到0.88,兴奋地打开特征重要性排序,发现第一名是样本ID的哈希列,完全没法解释,第二三名也是几个不知所云的中间特征。
原因:特征工程阶段把sample_id当成了数值特征喂进模型。树模型基于ID切分,天然能记住训练集样本,形成假高分。这种情况在很多队伍里都发生过,本质上是把“数值型”和“有意义的数值型”画了等号。
解决:ID列在特征工程第一步就drop。凡是“唯一值数量等于行数”的列,一律排除。更重要的是立一个规矩:任何模型的输入列必须能和业务字段一一对应,答不出“这个特征在医学上代表什么”就删掉。AUC高但不能自圆其说,在评阅里毫无意义,反而会给评委留下制造假特征的印象。
5.4 代码改了三版,生成的结果文件对不上号
现象:第三天下午导出的predictions_test.csv里,用的还是第二版模型,提交前和队友核对时发现和第2天的分析结果互相矛盾,谁也说不清哪份是最终版。
原因:脚本里没有统一的模型选择开关,每个人在自己机器上各跑一版,输出文件不断被覆盖。到最后results目录下躺着好几个类似的csv,文件名差一个字母,内容完全不一样。
解决:在项目根目录加一个config.py,统一管理model_name、seed和输出目录。每次跑新模型前,先删掉results下旧文件再重新生成。比这更有效的习惯是给中间结果文件带版本号,例如predictions_test_v3.csv,但最终提交前把版本号去掉,重命名成干净的名字,避免评委困惑。代码提交前,用find . -name "*.csv"看一遍全项目的文件清单,确认没有多个版本的预测文件混在一起。
5.5 提交的代码里藏着绝对路径,评委一跑就报错
现象:打包提交后评委复现,控制台立刻报FileNotFoundError,一看路径写的是C:/Users/xxx/Desktop/2025_C_solution/data/raw/train.csv。这是因为写脚本时图省事用了绝对路径,没考虑到代码会换一台机器运行。
原因:绝对路径绑定了作者自己的电脑目录结构。换到评委机器上,盘符和用户名都变了,路径自然失效。这是代码提交里最常见的低级错误,但每年都有队伍踩。
解决:所有文件读取统一用相对路径,并以项目根目录为基准。在src目录建一个paths.py:
from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent RAW_DIR = BASE_DIR / "data" / "raw" PROCESSED_DIR = BASE_DIR / "data" / "processed" RESULTS_DIR = BASE_DIR / "results"所有脚本统一从paths.py导入目录常量,而不是到处写字符串拼接路径。提交前用一条命令检查是否有残留的绝对路径:
grep -rn "C:" src/如果输出为空,说明路径问题已经处理干净。这三分钟的自查,能避免评委复现时直接打回的最坏结果。
6. 结果验证与可视化:让评委在第一眼就信任你的输出
模型跑完,结果文件齐了,别急着打包。C题的评阅专家大概率会在几分钟内浏览你的论文和结果,可视化的重要性不亚于模型本身。我会把最后四小时留给三件事:交叉验证复核、图表输出、一键复现。
交叉验证复核是第一道保险。用分层K折做一次K折交叉验证,看AUC的均值和标准差。标准差超过0.05说明模型不稳定,大概率是特征噪声太大或树模型过深,这时候回头调参还来得及。
from sklearn.model_selection import StratifiedKFold from sklearn.metrics import roc_auc_score skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) auc_scores = [] for tr_idx, va_idx in skf.split(X_train, y_train): lr_fold = LogisticRegression(max_iter=1000, C=0.5, solver="liblinear") lr_fold.fit(X_train.iloc[tr_idx], y_train.iloc[tr_idx]) auc_scores.append(roc_auc_score(y_train.iloc[va_idx], lr_fold.predict_proba(X_train.iloc[va_idx])[:, 1])) print("CV AUC:", round(np.mean(auc_scores), 4), "+/-", round(np.std(auc_scores), 4))图表输出的顺序也有讲究,我会保证这些图全部出自脚本,不是手动p出来的:
| 图 | 输出文件 | 说明 |
|---|---|---|
| 关键特征分布图 | feature_distribution.png | 证明数据质量,展示核心变量的分组分布 |
| 相关性热力图 | correlation_heatmap.png | 展示特征间相关性,说明筛选依据 |
| 分组趋势图 | trend_by_week.png | 不同孕周下指标均值或检出率趋势,呼应时点选择主题 |
| ROC曲线 | roc_curve.png | 模型性能的直观证据 |
| 特征重要性图 | feature_importance.png | 支持业务结论,指出哪个指标最值得关注 |
最后一件事是打包前的一键复现。按顺序跑一次全部脚本:
cd 2025_C_solution python src/01_eda.py python src/02_feature_engineering.py python src/03_model_baseline.py python src/04_model_advanced.py python src/05_export_result.py跑完这五条命令,results目录下应该重新生成所有提交文件。如果中间报错,优先检查路径、random_state和中间文件是否被覆盖。
我吃过最大的亏就是模型结果都齐了,偏偏因为复现脚本里有两处绝对路径,评委复现失败被扣了印象分。从那以后,打包前全流程复现一遍成了铁律,也给所有代码补上了paths.py这个统一入口。这个习惯帮我省下来的时间,远超过当时踩坑付出的代价。希望帮到你。
本文还有配套的精品资源,点击获取