ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深圳杯数学建模D题避坑指南:数据清洗与可复现代码

深圳杯数学建模D题避坑指南:数据清洗与可复现代码

简介:2025深圳杯数学建模竞赛D题完整分析论文是一份面向参赛学生与数学建模研究者的PDF资料,系统梳理了从问题重述、问题分析、模型假设、符号定义到模型建立与求解的完整过程。文件包共1个PDF文件,大小为2.71MB,正文针对三个具体问题展开,重点包含特征工程设计、分类模型结构与数学表达、模型总体性能对比,以及各模型在不同贡献者数量下的表现;后续还补充了模型评估指标、数据分布概览、重要特征分析和贡献者兼容性筛选等内容。资料中提供了可运行代码与相关数据,适合用于快速理解建模流程、复现实验或作为竞赛备赛的参考框架。目前已有330人学习下载,结构清晰、分步骤呈现关键解决方案,具备较高的实践参考价值。

1. 2025 深圳杯数学建模竞赛 D 题:难点不在模型,在交付

一份标着「完整分析论文(含模型、可运行代码、数据)」的 D 题资料,到底值钱在哪?我的判断只有一条:别人能不能只靠这篇论文和附件,不问你一句话,就把文中每一张图、每一个数原样跑出来。这个标准看着基础,实际上每年会淘汰一大批队伍。多数人不是没有建模能力,而是栽在数据没洗干净、代码跑不通、论文数字和代码输出对不上这类细碎问题上。这篇内容面向准备深圳杯 D 题的建模手、编程手和写作手,也适合想按完整链路备赛的人。我会把拆题、清洗、建模、写论文和排错这条链路上最容易翻车的点,逐个讲清楚,并给出一套可以直接照做的流程。

2. 拿到 D 题先拆题:四步把问题变成建模任务与验证指标

D 题给的材料通常包含背景说明、几张数据表和两到四个小问。很多队伍拿到题的第一反应是「这题能套个什么模型」,这个顺序是错的。模型是答案,不是问题。正确顺序是先把四个问题想清楚:这题在求什么、数据长什么样、约束是什么、交什么才算答完。四个问题定了,后面每一步都会快很多。

2.1 目标变量与问法:先回答「这题在求什么」

先把题目里每个小问的「问题句」圈出来,从动词判断建模类型。出现「预测」「估计」「未来时段的值」这类词,是预测类;出现「评价」「排序」「打分」这类词,是评价类;出现「最大化」「最小化」「安排」「调度」这类词,是优化类;出现「判别」「归类」这类词,是分类类。不同类型对应的模型族、验证指标和交付物完全不一样。预测类要回答「用哪些特征、误差是多少」,评价类要回答「指标体系怎么建、权重怎么定、排序结果是什么」,优化类要回答「目标函数是什么、约束有哪些、最优解落在那组取值上」。

拆题这一步的重点是定验证方式,而不是选模型。拿最常见的表格型预测任务举例:目标变量是某一个数值列,其余列是特征,验证指标用 MAE、RMSE;如果题目把目标变量定义成「高/中/低」三档,任务就变成了分类,指标要换成准确率和 F1。同一个数据集,因为问法不同,建模任务就不同,这是拆题最先要确定的事。常见错误是看到数据里有时间列就非要上时序模型,其实如果题目问的是「该指标与哪些因素相关」,回归或树模型反而更有解释力,也更方便写进论文。

2.2 数据形态与约束:数据长什么样,模型就怎么选

第二步看数据形态:宽表还是长表、有没有时间戳、字段是数值还是文本、样本量和特征量分别是多少。宽表适合直接进树模型和线性模型,长表通常要先做透视或分组聚合;文本字段要考虑编码方式;样本量只有几百条时,深度学习基本可以排除,树模型和线性模型更稳。这些判断不需要跑复杂代码,用 pandas 读一遍、打印 shape 和 dtypes 就够,但很多人跳过这一步直接建模,后面全在给数据格式擦屁股。

第三步看约束。D 题优化类小问的约束经常写得比较含糊,比如「资源有限」到底指总量限制还是每天限制,「多个目标」是要加权还是分层,这些必须在模型假设里明确下来,并且每个假设都要在正文里交代。约束理解错,后面最优解再漂亮都是错的。第四步是提前确认评分点:摘要是否点对点回答所有小问、关键结论表清不清晰、附件有没有从原始数据读到最终结果表的完整脚本、模型假设是否经得起追问。这不是最后一天才想的事,拆题当天就要按这个清单分配时间。

题目问法建模类型常用方法验证指标交付物
预测未来值回归/时序线性回归、随机森林、XGBoost、ARIMAMAE、RMSE、R2预测结果表 + 误差分析
判别人群类别分类逻辑回归、随机森林、LightGBM准确率、F1、ROC-AUC混淆矩阵 + 指标报告
方案排队打分评价熵权法、TOPSIS、层次分析法排序稳定性、灵敏度评分表 + 权重说明
资源调度取舍优化线性规划、整数规划、启发式算法目标函数值、约束满足率最优方案表 + 约束分析

2.3 拆题阶段就定交付物清单,别拖到写论文才想

拆题当天我会顺手建立一个交付物清单,它同时是分工依据和进度表。第一项是清洗后数据集,要求原始数据、清洗脚本、清洗结果三个文件分开存放,别人能通过脚本从原始数据得到清洗结果;第二项是建模脚本,要求每个小问有一个独立脚本,跑完能输出该小问对应的结果表和图;第三项是结果汇总表,把每个小问的关键数字统一放在 output 目录里,写论文时只从这张表取数;第四项是模型说明,包括选型理由、参数设置和一次灵敏度分析结果。这个清单看起来琐碎,但它保证最后一晚不会被「论文缺一张图、代码少一段、数据不知道存哪」这类问题打垮。

3. 数据清洗与特征构造:从原始表格到能跑的模型数据

D 题的数据大概率不会干净。缺值、异常值、文本型数字、口径不一致,这些是常态。清洗的目的不是把数据「变好看」,而是让模型结果可以被复现、被解释。下面这套流程是我处理表格型 D 题时都会先跑一遍的,覆盖了大部分常见情况。

3.1 用 pandas 做一轮可复现的探索与清洗

先读数据、看结构、做类型修正,再决定缺失值怎么处理。要养成一个习惯:原始数据文件永远不动,清洗结果单独存一份。这样任何时候发现清洗逻辑有 bug,都能从原始数据重跑,而不是在已经改过的数据上继续打补丁。

import pandas as pd import numpy as np # 原始数据放在 data/raw 下,清洗后的数据写到 data/processed df = pd.read_csv("data/raw/raw_data.csv", encoding="utf-8-sig") print(df.shape) print(df.dtypes) print(df.isnull().sum()) # 文本类字段统一尝试转数值,转不动的置为 NaN,后续统一处理 for col in df.columns: if df[col].dtype == "object": df[col] = pd.to_numeric(df[col], errors="coerce") # 缺失率超过 30% 的列直接删除,低于阈值且是数值型的用中位数填补 missing_ratio = df.isnull().mean() drop_cols = missing_ratio[missing_ratio > 0.3].index.tolist() df.drop(columns=drop_cols, inplace=True) for col in df.columns: if df[col].dtype in ("float64", "int64") and df[col].isnull().sum() > 0: df[col].fillna(df[col].median(), inplace=True) # 连续变量按 1% 和 99% 分位数裁剪,压掉极端值 for col in df.select_dtypes(include=["float64", "int64"]).columns: lo, hi = df[col].quantile([0.01, 0.99]) df[col] = df[col].clip(lo, hi) # 每一步处理都留注释,论文的“数据预处理”章节直接从这里改写 df.to_csv("data/processed/clean_data.csv", index=False, encoding="utf-8-sig") print(df.shape)

这段代码里有几个点值得展开。pd.to_numeric(errors="coerce")是把 Excel 导出的文本型数字转成真正数值的常见做法,转不了的会变成 NaN,这样不会因为某个单元格里混了一个「约 3000」就把整列变成对象类型。缺失率阈值 30% 是我常用的经验值,不是硬标准:缺失太多的列,中位数填补会引入系统性偏差,删掉更干净;低于 30% 的数值列,用中位数而不是均值填补,是因为这类数据经常有偏态分布字段,均值容易被极端值带偏。

分位数裁剪用的是 1% 和 99%,比直接删除异常值保守,既保留了样本量,又把极端值对树模型分裂点的影响压住。如果题目对异常值有明确业务定义,比如「超过某阈值即为无效」,那应该按业务定义处理,而不是机械裁剪。还有一个容易忽略的点:填补缺失值用的中位数、裁剪用的分位数,在正式建模时必须只用训练集计算。

提示:先用全量数据计算统计量,再划分训练集和测试集,会造成信息泄漏,最终验证指标偏乐观,评审批复时数值对不上。

3.2 特征构造、标准化与数据划分:让模型有东西可学

清洗完成后是特征构造。D 题数据里不是每个字段都适合直接进模型,常见做法是从业务含义出发构造差值、比值、占比这类组合特征。比如三个相关性很强的指标,它们的差值往往比原始值更有解释力;两个量纲不同的指标相除,能消除规模影响。构造特征时把握一个原则:每个新特征都要能在论文里用一句话解释清楚,构造完说不出含义的特征,后面全是负担。

from sklearn.model_selection import train_test_split from sklearn.preprocessing import StandardScaler df = pd.read_csv("data/processed/clean_data.csv", encoding="utf-8-sig") # 假定原始字段为 f1、f2、f3,构造差值和比值两个组合特征 df["f2_minus_f1"] = df["f2"] - df["f1"] df["f3_ratio_f1"] = df["f3"] / (df["f1"] + 1e-6) feature_cols = ["f1", "f2", "f3", "f2_minus_f1", "f3_ratio_f1"] X = df[feature_cols] y = df["target"] # 划分训练集和测试集,固定随机种子保证可复现 X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, shuffle=True ) # StandardScaler 只 fit 训练集,再 transform 训练集和测试集 scaler = StandardScaler().fit(X_train) X_train_scaled = scaler.transform(X_train) X_test_scaled = scaler.transform(X_test) # 树模型可以不吃标准化,线性模型和距离模型必须吃,所以保留两种版本 X_train_tree, X_test_tree = X_train, X_test

这里有个常见误区要澄清:随机森林、LightGBM 这类树模型不需要标准化,因为分裂只关心特征排序不关心尺度;但线性回归、逻辑回归、KNN 这类模型对尺度敏感,必须标准化。代码里保留了X_train_scaled和X_train_tree两份,方便后续换模型时不重跑特征构建。random_state=42的作用是让每次划分结果一致,这是可复现的底线,也是评审批复代码时第一眼看的东西。shuffle=True在有时间顺序的数据上要谨慎:如果题目数据带时间戳且要做时序切分,应该按时间先后划分而不是随机划分,否则未来信息会泄漏进训练集。

4. 模型训练与调参:先把基线跑通,再谈精度

D 题评分看的是问题有没有被完整回答,不是模型排行榜。一个能跑通、能解释、误差在合理范围的随机森林,远比一个训练了一整晚但答非所问的复杂模型得分高。建模环节的通用顺序是:先选一个训练快、参数少、不容易翻车的模型把全流程跑通,再根据误差情况决定要不要换更强的模型。

4.1 基线模型:先用随机森林跑通全流程

随机森林在表格型数据上是基线模型的最佳人选:能处理非线性关系、对缺值不敏感、能输出特征重要性、几乎不需要调参就能得到可接受的基线。从头到尾跑通之后,再考虑换成 LightGBM 还是 XGBoost。如果数据量到了几万行以上,可以切换 LightGBM 开 1000 棵树;但 D 题常见的数据规模下,随机森林完全够用。

from sklearn.ensemble import RandomForestRegressor from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score model = RandomForestRegressor( n_estimators=200, max_depth=8, min_samples_leaf=5, random_state=42, n_jobs=-1, ) model.fit(X_train, y_train) y_pred = model.predict(X_test) print("MAE:", mean_absolute_error(y_test, y_pred)) print("RMSE:", mean_squared_error(y_test, y_pred, squared=False)) print("R2:", r2_score(y_test, y_pred))

这段代码里的参数值得逐个说。n_estimators=200是树的数量,200 在大多数 D 题数据量下足够,再多提升有限但训练时间线性增长;max_depth=8限制每棵树的最大深度,防止单棵树过拟合,数据量小的时候要再调小;min_samples_leaf=5要求每个叶子至少有 5 个样本,它比max_depth更直接地控制模型平滑程度,值越大模型越保守;random_state=42保证每次运行结果一致;n_jobs=-1用满全部 CPU 核心。

这几个参数在数据量只有几百到几千条时,调整优先级从高到低是min_samples_leaf、max_depth、n_estimators。数据量小的时候先调大min_samples_leaf,比盲目加树更有效。如果题目是分类问法,把RandomForestRegressor换成RandomForestClassifier,指标换成准确率、F1 和混淆矩阵,其余流程不变。评价类问法把这一节换成熵权法加 TOPSIS;优化类问法换成scipy.optimize的线性规划,验证指标变成目标函数值和约束满足率。建模工具随问法变,但「先跑通、再调参、再验证」的顺序不变。

跑完还要做一件事:输出特征重要性,这是论文里最有说服力的材料之一。

# 特征重要性排序,论文“影响因素分析”小节直接引用 imp = pd.Series(model.feature_importances_, index=X.columns).sort_values(ascending=False) print(imp)

特征重要性回答了评委最关心的问题:哪些变量在驱动结果。如果某个业务上很重要的字段重要性排名垫底,要么是特征构造方式有问题,要么是该字段本身信息量低,需要回到清洗和构造环节复查。把这张排序表放进论文的特征分析小节,比贴一个黑匣子模型强得多。

4.2 交叉验证与网格搜索:参数怎么设才算数

基线模型跑通之后,下一步是把参数从「手拍」变成「搜索」。D 题数据量一般不大,网格搜索完全够用。核心原则是:搜索只在训练集内做,测试集只在最后用一次,不能在测试集上调参。

from sklearn.model_selection import GridSearchCV from sklearn.ensemble import RandomForestRegressor param_grid = { "n_estimators": [100, 200], "max_depth": [6, 8, 10], "min_samples_leaf": [2, 5, 10], } grid = GridSearchCV( RandomForestRegressor(random_state=42, n_jobs=-1), param_grid, cv=5, scoring="neg_mean_absolute_error", n_jobs=-1, ) grid.fit(X_train, y_train) print("best params:", grid.best_params_) print("best cv score:", grid.best_score_) # 用最优参数在测试集上做最终评估,只做一次 best_model = grid.best_estimator_ y_pred = best_model.predict(X_test) print("test MAE:", mean_absolute_error(y_test, y_pred))

cv=5表示五折交叉验证,训练集被切成 5 份,每次用 4 份训练、1 份验证轮流来,最终分数取平均,比单次划分更稳。scoring="neg_mean_absolute_error"是因为 sklearn 网格搜索默认「越大越好」,回归误差类指标都要带负号形式,选错会直接报错。param_grid的范围按数据量控制:几百条样本时n_estimators搜到 200 就够,max_depth从 6 到 10 搜三个值足够,范围拉太大只会让搜索时间成倍增加而精度不涨。

注意:网格搜索的scoring参数,回归误差类要用负号形式(如neg_mean_absolute_error),分类指标直接用'f1_macro'、'accuracy'这类原名即可。

这里有个调参玄学要讲清楚:网格搜索出来的最优参数,在测试集上分数不一定比基线高,这是正常的。因为网格搜索选的是训练集交叉验证里平均表现最好的参数;如果测试集分数反而明显下降,说明最优参数过拟合了训练集,应该回到更小的max_depth和更大的min_samples_leaf,而不是继续扩大搜索范围。数学建模比赛里,参数「恰好调到测试集最好」往往是测试集泄漏的信号,评审批复代码时一眼就能看出来。分类任务还要多确认一步:目标变量类别是否均衡。如果某一类占比超过 80%,准确率会被多数类带偏,scoring要换"f1_macro",模型里加上class_weight="balanced"。

5. 深圳杯 D 题里最常见的五个坑:现象、原因与排查顺序

以下是处理 D 题资料时反复遇到过的五类问题,每一个都直接威胁「可运行、可复现」这个底线。按顺序排查,能救回一大半翻车现场。

5.1 附件代码在本地能跑,换个环境就死

现象:代码在自己电脑上一切正常,放到评审环境或队友电脑上,第一行pd.read_csv就报错,或者跑到一半提示找不到文件。原因:几乎全是路径和依赖问题。脚本里写了绝对路径,比如C:/Users/.../data.csv;文件名带中文或空格;用到的第三方库没有固定版本,评审环境装的是另一个大版本,API 直接变了。解决:所有路径改成相对路径,以项目根目录为基准;文件命名只保留英文、数字和下划线;项目根目录放一个requirements.txt,把用到的库和版本列全。数据读取统一写成pd.read_csv("data/raw/raw_data.csv")这种从根目录出发的相对路径,配合pathlib取脚本所在目录,基本能杜绝这类问题。

5.2 标准化和缺失值填补用了全量数据,指标虚高

现象:交叉验证分数和测试集分数都很漂亮,但自己重新划分一次数据后分数明显下降,或者评审批复时复现不出论文里的数。原因:清洗阶段用全量数据计算了中位数、均值、标准差、分位数,再划分训练集和测试集。测试集的信息已经通过统计量进入了训练过程,这是数据泄漏,结果是验证指标偏乐观。解决:所有统计量都只从训练集计算,再应用到测试集。代码上体现为scaler = StandardScaler().fit(X_train)而不是fit(X),缺失值填补同样先切分再算训练集的中位数。

5.3 论文里的表格数字和附件代码输出对不上

现象:论文正文写着 MAE 是 12.8,附件脚本跑出来是 15.3;摘要里的排序结果和正文某一节的表格顺序不一致。原因:论文分几天写完,中途修改了参数但没有重新跑脚本,数字停留在旧版本;或者结果从多个脚本里手动复制粘贴,版本没对齐。解决:把「出结果」和「写论文」在时间上隔离。先固定全部参数,完整跑一遍所有脚本,所有数字统一从输出目录里取,再开始写论文。写作期间如果改了参数,必须重跑对应脚本并更新所有引用该数字的地方。我的习惯是结果汇总表单独存一个 CSV,论文里的数字一律从这张表抄,不手动填。

5.4 重复运行脚本,每次结果都不一样

现象:同一个脚本连着跑三次,交叉验证分数和测试集分数一次一个样,甚至特征重要性排序都变了,自己都说不清论文里该写哪个数。原因:随机种子没有固定。数据划分、模型初始化、网格搜索内部都有随机过程,不固定种子就等于每次拿不同的训练集和初始状态在跑。解决:在脚本入口统一固定种子,包括random.seed、numpy.random.seed,以及模型和train_test_split里的random_state。更省事的做法是写一个set_seed()函数放在公共模块里,所有脚本第一行调用。固定之后脚本输出应当完全一致,这是「可运行代码」的最低门槛。

5.5 看似是数字的列,其实是文本

现象:df.dtypes显示某列是object,直接送进模型报类型错误,或者模型能跑但结果明显不合理。原因:Excel 导出数据时,某些单元格混入了「约 3000」「>5000」这类文本,或数字列里有空单元格被识别成了字符串。pandas 读取时为了不丢信息,把整列推断成对象类型。解决:读入数据后统一做一轮pd.to_numeric(col, errors="coerce"),能转的转成数值,不能转的变成 NaN,再走缺失值处理流程。这是第 3 章清洗代码里第一个循环干的事,不要跳过。

6. 让附件代码变成加分项:一键运行与固定随机种子

论文写完了,代码也能跑,但「能跑」和「好复现」是两回事。下面这两个收尾动作,是每次交付前必做的操作,它们能把附件代码的可用性再提一档。

第一个动作是一键运行。把整个流程拆成按顺序执行的脚本,用一个入口脚本统一调用。别人拿到资料后只需要跑一条命令,就能从原始数据一路跑到全部结果图。

# scripts/run_all.py import subprocess import sys from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent STEPS = [ "scripts/01_data_clean.py", "scripts/02_feature_build.py", "scripts/03_model_train.py", "scripts/04_gen_figures.py", ] for step in STEPS: print(f"running {step} ...") subprocess.run([sys.executable, str(BASE_DIR / step)], check=True)

Path(__file__).resolve().parent.parent向上取两级得到项目根目录,后续所有路径都从BASE_DIR出发,彻底绕开「在哪个目录下运行」的问题。check=True让任何一个脚本报错时立即终止,避免后面的脚本拿着不完整的数据继续跑出错误结果。

第二个动作是把固定随机种子写成一个公共函数,而不是在每个脚本里重复三行代码。数据划分、模型初始化、网格搜索三处都要用同一个种子,散落各处容易漏改。

# scripts/utils.py import random import numpy as np def set_seed(seed=42): random.seed(seed) np.random.seed(seed)

模型里再统一传random_state=seed,两个动作配合,输出即可复现。第三个动作是目录约定:data/raw放原始数据,data/processed放清洗结果,scripts放脚本,figures放图,output放结果表。这套结构在论文附录里用两三行写清楚,评审不需要在十几个散落文件里猜哪个是原始数据、哪个是中间产物。

我个人的习惯是:交付资料前,把项目复制到一个干净目录,清掉所有输出文件,只保留原始数据、脚本和requirements.txt,然后从入口脚本开始完整跑一遍,盯着有没有报错、输出数字是否和论文一致。这道工序做完,才敢把资料交出去。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进