深圳杯D题建模实战:数据清洗、特征工程与可复现代码
简介2025深圳杯数学建模竞赛D题完整分析论文是一份面向参赛学生、建模爱好者及指导教师的PDF参考资料围绕竞赛题目的背景与目标依次给出问题重述、问题分析、模型假设与符号定义并针对问题一、问题二和问题三建立了完整求解流程。文中重点展示了特征工程、分类模型结构与数学表达、模型总体性能对比以及不同贡献者数量下的表现差异后续还涉及贡献者兼容性筛选、混合比例优化与组合匹配评分函数并配有可运行代码与相关数据说明便于读者对照赛题完整复现研究过程。资源共1个PDF文件压缩包大小约2.71MB目录结构清晰按摘要、问题分析、模型建立与求解等模块逐层展开可直接按章查阅。目前已有330人学习适合用于备赛训练、论文框架参考或方法验证。1. 2025深圳杯D题拿到题目之后先做什么每年深圳杯的数学建模竞赛D题总是那个“看着亲切、做着痛苦”的题数据量大、背景贴近现实、一眼能看懂要干什么但真要交出一篇完整的分析论文团队往往会卡在“模型不涨分”和“代码不可复现”上。很多队伍输掉比赛不是没跑出结果而是交上来的代码换个目录就报错数据路径写死、随机种子不固定、模型参数全在交互式环境里手工调。我说的“可运行”不是在自己电脑上能跑而是任何评委在任意一台机器上双击 README 里的入口脚本就能复现你的全部结果。这个标题里真正值钱的是三个词模型、可运行代码、数据。一篇能获奖的D题论文本质是把这三样东西焊在一起数据清洗到什么程度、模型为什么选它、代码怎么组织才能让评委信任。无论题目背景是环境监测、交通流量还是订单预测你的核心任务都是把一张原始表变成一套可打分、可解释、可回滚的分析流程。这篇文章就按这个套路从数据、模型、代码到论文逐层拆开每个环节给可直接抄的做法和参数顺带把我在竞赛里踩过的坑写给你看。新手跟着能跑通熟手也能在边界参数和异常处理上找到可调的空间。2. 数据清洗和特征工程把「数据」变成「样本」的三种常见做法2.1 先建数据契约时间列、ID列和缺失值的关系D题的数据通常不会太干净最常见的问题是时间列格式混乱、同一对象在不同表里的 ID 不一致、缺失值藏在空字符串里而不是 NaN。我的习惯是拿到数据先写一段“数据契约”代码把三件事固定下来:统一时间格式、确认主键唯一性、把缺失值改成 pandas 可识别的pd.NA。这份契约不做特征、不建模型只为了让后面每张表的行为可预期。import pandas as pd def load_and_fix(path, time_coltime, id_colid): # 读入原始数据 df pd.read_csv(path, encodingutf-8, enginepython) # 统一时间格式避免混合了 2025-01-01 和 2025/1/1 df[time_col] pd.to_datetime(df[time_col], errorscoerce) # 空字符串统一处理为缺失值 df df.replace(r^\s*$, pd.NA, regexTrue) # 确认每个对象的记录连续简单按时间排序 df df.sort_values([id_col, time_col]).reset_index(dropTrue) return df这段代码的作用是防呆。errorscoerce会把解析不了的时间变成NaT后面一眼就能看出有多少脏时间空字符串替换成正则匹配的空值避免df.isna().sum()漏掉隐藏的缺失最后按 ID 和时间排序是为了下一步做滑动窗口或差分时不打乱对象内部的顺序。参数上我一般把时间列名显式传进去因为 D题数据的列名经常随题变不要硬编码在函数体里。2.2 滑窗特征和滞后特征给模型时间上的上下文D题只要数据带时间戳就一定要做时序特征否则模型只看当前时刻的快照必然漏掉趋势和周期。常用做法是先做滞后列lag再做滑动窗口均值但要注意窗口边界不能用未来数据否则就是数据泄漏。我通常用shift生成滞后 1/3/6 期的值然后用 rolling 窗口算滑动均值、标准差和斜率把最近 6 条记录的趋势“压扁”成一个特征向量。def add_time_features(df, group_colid, y_coltarget, lags(1, 3, 6), window6): df df.copy() for lag in lags: # 按组做滞后每组内部独立计算 df[flag_{lag}] df.groupby(group_col)[y_col].shift(lag) df[rolling_mean_6] df.groupby(group_col)[y_col].transform( lambda x: x.shift(1).rolling(window, min_periods1).mean() # shift(1) 避免用当前点 ) # 删除每组开头的空值行数会减少后面训练时要对齐索引 df df.dropna(subset[flag_{lag} for lag in lags]) return df这段代码里最容易出错的是shift(1)。如果不先平移一个位置再算滚动均值特征里就会包含当前时刻的 y 值模型在训练集上会被完美预测混淆到验证集立刻崩盘。窗口大小window我一般取在时间尺度上能覆盖一个完整业务周期的长度比如日数据取 7 或 30如果不知道业务周期就用验证集精度去选。滑窗特征做出来后原表的时间列就可以降级为参考列模型输入只剩下特征矩阵和标签列。2.3 数据切分的雷区不要随便train_test_split时序数据用随机切分是最常见的翻车点我也踩过。随机切分会把未来数据混进训练集模型等于提前拿到了答案虽然验证分数很好看但提交到测试集立刻打回原形。竞赛评委也一定会做时间切分检验所以你要主动用“训练集在前、验证集在后”的切法。def temporal_split(df, test_ratio0.2, time_coltime): df df.sort_values(time_col).reset_index(dropTrue) cut_idx int(len(df) * (1 - test_ratio)) train df.iloc[:cut_idx].copy() valid df.iloc[cut_idx:].copy() # 把分组列和时间列留在原表里只给模型传特征和标签 return train, valid按时间切分后验证集精度才接近测试集的真实水平。如果 D题里包含多个独立个体比如多个店铺、多台设备单纯按行切分也不好最好按个体 ID 切groupby(id).apply把每个个体的最后 20% 划入验证集。这里你可能会发现训练和验证样本量不够均衡那就在模型里设置样本权重或者直接用全部历史数据做时序交叉验证每折前 70% 训练、后 30% 验证既保证样本量也保证时间顺序。3. 模型选型与参数设定从简单到复杂的第一步3.1 先跑一个线性基线再谈 LightGBM 回归模型每次 D题开赛都有队伍直接上神经网络结果特征没做好网络也学不到什么规律。我一般先跑一个 Ridge 回归或者带 L2 的线性回归作为基线因为线性模型能快速告诉你当前特征有没有信息量。如果基线 R 方不到 0.5大概率是特征工程的问题而不是模型复杂度的问题。from sklearn.linear_model import Ridge from sklearn.metrics import mean_squared_error ridge Ridge(alpha1.0) # X_train, y_train 由上一章的数据切分获得 ridge.fit(X_train, y_train) y_pred ridge.predict(X_valid) print(RMSE:, mean_squared_error(y_valid, y_pred, squaredFalse)) print(R2 :, ridge.score(X_valid, y_valid))基线跑完我才会把 LightGBM 搬出来因为它的梯度提升框架在中小表格数据上几乎不会输给深度网络而且对缺失值天然友好。D题的数据量通常几万到几十万行LightGBM 的直方图算法能在几秒内完成一轮训练这给调参留了充裕的时间。要定义“模型能拿得出手”的标准我会以验证集 R² 比基线高至少 5 个点为界否则回到特征工程去补滑窗和外生变量。3.2 LightGBM 核心参数num_leaves、learning_rate、feature_fraction很多人把 LightGBM 当成黑匣子一顿grid_search后过拟合到验证集。我不建议早早就上搜索先手动设定几个关键参数。num_leaves控制树复杂度等比公式是2^{max_depth}-1但 LightGBM 用叶子生长策略可以超过这个值对中小数据集我一般取 31大了容易过拟合。learning_rate从 0.05 开始n_estimators用早停法决定不要在训练时一次性把树设置成 1000 以上。import lightgbm as lgb model lgb.LGBMRegressor( num_leaves31, # 叶子数量控制过拟合的核心参数 learning_rate0.05, # 步长越小越稳但需要更多迭代 n_estimators500, # 最大迭代数实际由早停截断 feature_fraction0.8, # 每棵树随机选 80% 特征增加多样性 bagging_fraction0.8, # 每次迭代随机采 80% 样本防止过拟合 bagging_freq1, random_state42 # 固定随机种子保证结果可复现 ) model.fit( X_train, y_train, eval_set[(X_valid, y_valid)], callbacks[lgb.early_stopping(50), lgb.log_evaluation(100)] )early_stopping(50)表示验证集指标连续 50 轮不改善就停止训练返回的best_iteration_就是最优树数量。feature_fraction和bagging_fraction是防过拟合的两大利器数据量越小这两个参数越要调低比如低于 1 万行时降到 0.6 和 0.7。feature_fraction还与前面的滑窗特征配合如果窗口算了很多相关特征0.8 会得到更平滑的特征贡献曲线。最后固定random_state42这是本篇论文能可复现的前提。3.3 结果解读别只盯着 RMSE要看误差分布模型的验证指标只是第一步评阅人真正想看的是结论。如果预测目标是连续值我会做一张残差图——横轴真实值、纵轴预测值或者横轴时间、纵轴残差——来确认有没有系统性偏估。比如在高峰期总是低估、低谷期总是高估这多半是特征里缺少周期变量而不是模型参数的问题。import matplotlib.pyplot as plt residual y_valid - y_pred plt.figure(figsize(10, 4)) plt.subplot(1, 2, 1) plt.scatter(y_valid, y_pred, alpha0.3) plt.plot([y_valid.min(), y_valid.max()], [y_valid.min(), y_valid.max()], r--) plt.xlabel(True) plt.ylabel(Pred) plt.subplot(1, 2, 2) plt.hist(residual, bins50) plt.xlabel(Residual) plt.show()残差分布如果出现双峰说明数据混合了两种不同行为模式比如工作日和周末、晴天和雨天。这时候不要急着加特征先按模式拆分建模或者把“时段”“星期几”作为分类型特征喂给模型。这个诊断分析会成为论文里的一个章节用了什么模型为什么这样选误差长什么样。你要在论文里明确写“R²0.87 但残差在尖峰处呈厚尾”这句话比十张指标表更有说服力。4. 让代码“可运行”注释、抽参和数据划分的实操技巧4.1 从 notebook 到脚本三步重构你的代码竞赛记录阶段notebook 是跑试验用的好工具但交上去的代码要是 notebook评委要先装内核、改路径、补包过程很痛苦。我建议把最终代码抽成三个脚本data_process.py、train_model.py、predict.py外加一个config.yaml放参数。这样评委只用执行python train_model.py就能完整复现一次训练而不是手动点格子。# train_model.py import pandas as pd from sklearn.linear_model import Ridge import lightgbm as lgb from data_process import load_and_fix, add_time_features, temporal_split df load_and_fix(data/raw.csv) df add_time_features(df) train, valid temporal_split(df) X_train train.drop(columns[target, time, id]) y_train train[target] X_valid valid.drop(columns[target, time, id]) y_valid valid[target] # 先用 Ridge 做基线 ridge Ridge(alpha1.0) ridge.fit(X_train, y_train) print(Baseline R2:, ridge.score(X_valid, y_valid))关键点是你要用if __name__ __main__包住主流程避免做成模块时重复执行路径统一用相对路径从项目根目录出发而不是写死本地绝对路径。制造“可运行”的核心是任何人在任何机器上都能跑通所以requirements.txt要精确到主要包的版本号最少列出pandas、scikit-learn、lightgbm。4.2 参数配置分离改一行不要翻遍代码初赛时很容易把参数散落在各个 cell 里后来想调一个learning_rate得全局搜索。开赛第二天我就会建一个config.yaml把窗口大小、滞后步长、LightGBM 参数、数据切分比例全部放进去代码里用yaml.safe_load读进来。这样早上换窗口、中午换模型、晚上写论文全不会乱。# config.yaml data: path: data/raw.csv time_col: time id_col: id features: lags: [1, 3, 6] window: 6 model: type: lightgbm num_leaves: 31 learning_rate: 0.05 feature_fraction: 0.8 split: test_ratio: 0.2 random_seed: 42代码里只需要params yaml.safe_load(open(config.yaml))一次然后把params[features][lags]传给特征函数。写论文时你要有依据地呼应参数值比如为什么num_leaves31因为数据量约 5 万行叶子数取特征数的 1.5 倍左右为什么滑动窗口取 6因为业务周期是一周而我用滞后 6 去覆盖日周期。这些说明放在论文的“参数设定”小节里是你区别于抄网贴的关键。4.3 保存模型和预测结果别让评委替你跑第二次D题提交时往往不仅要求论文还要求给出测试集预测结果。你必须在predict.py里把训练好的模型序列化保存并生成与测试集同行的prediction.csv。常见做法是用 joblib 把booster存下来预测脚本只依赖这个文件不依赖训练数据。# predict.py import joblib import pandas as pd model joblib.load(models/lgb_model.joblib) test pd.read_csv(data/test.csv) # 预测前必须执行与训练时完全相同的数据清洗和特征工程 test add_time_features(test) X_test test.drop(columns[time, id], errorsignore) pred model.predict(X_test) output pd.DataFrame({id: test[id], pred: pred}) output.to_csv(result/prediction.csv, indexFalse)这段代码前面是模型加载后面是特征对齐。最容易犯的错是特征顺序训练时用X_train的列顺序预测时drop之后列顺序可能不一样模型会报“feature mismatch”。为了让预测和训练结果一致我会在训练完把特征列名存成feature_names.json预测时按这个列表重新排序这既是代码细节也是笔者的血泪经验——去年我在现场重构预测脚本时翻过这个车当时距提交只剩半小时。5. 避坑赛后复盘最常见的五个翻车点5.1 时间切分和随机切分的混淆现象验证集 R² 高达 0.95上线却很惨。原因用了train_test_split的随机切分未来数据混入训练集模型超前学习。解决一律按时间切分或多个个体按时间比例切分然后对验证集做“前训练、后预测”的模拟预测。这事我赛前提醒过自己发现比赛现场还是有人觉得随机切分“没毛病”。5.2 滑窗特征泄漏当前答案现象加了滑动均值后训练集分数暴涨但真预测时没法复现。原因rolling(window).mean()没有含当前值直接把当前 y 算进了特征。解决先shift(1)再rolling.mean()在论文里可以用一个公式写清楚特征来自 t-1 时刻之前的窗口预测目标是 t 时刻。这个坑每次比赛都有队伍踩防不胜防。5.3 LightGBM 的随机种子不固定现象同一份代码重跑两遍结果差 0.01 到 0.02评阅人怀疑你动过手脚。原因没设random_state也没有设bagging_seed。解决统一设一个种子文件训练、切分、特征漂移全部用同一个提交前把随机种子写进 README评阅人复现时会因为你的严谨增加信任。建模本身有点玄学但复现性是工程问题不是玄学。5.4 预测脚本和训练脚本的特征顺序不一致现象predict.py报错 ValueError或者预测结果在本地正常、换机器后就错。原因训练时特征列按字典序排列预测时又按看起来合理的顺序排列LightGBM 对特征名和特征位置都敏感。解决训练时保存特征列表预测前用reindex(columnsfeature_names)强制对齐不要依赖列名的偶然排列。5.5 论文里的图表没有对应到代码的中间结果现象论文写了“用滑窗滤波去噪”但代码里没有滤波步骤写了“模型调参”但参数表与代码不一。评委找人复现时立刻露馅。解决论文里的每个数据表、曲线图、参数表都要能在你的脚本里找到它的产生语句。把你做过的所有实验保留成 md 日志写论文直接从日志抄数据我现在的习惯是跑一次训练就写一行README_log.md记录时间、参数、验证指标这个习惯帮我在答辩时应对了评委每一个细节提问。6. 论文写作与结果验证把代码回到分析图表的最后一步6.1 可视化的三个必画图比表格更说服人评委看论文的时间很有限三段图能最快建立信任。第一段是数据分布图展示原始序列和滑窗滤波后的序列对比证明你的预处理确实有用的第二段是特特征重要性图用 LightGBM 的feature_importance给出 top 10 特征同时标注每个特征对应前面哪一步构建第三段是预测对比图测试集上画出真实曲线和预测曲线并标出误差带。lgb.plot_importance(model, figsize(8, 6), max_num_features10) plt.tight_layout() plt.savefig(figures/feature_importance.png, dpi150) plt.close()配图要写进论文里时我习惯在标题下加一行“特征重要性由 LightGBM 默认的 split gain 计算”这行注明能堵住评阅人很多追问。参数max_num_features10保证图不拥挤同时暗示你做了特征选择而不是瞎堆量。6.2 验证方法比指标更值得大写特写论文的“模型验证”章节要体现你对泛化能力的思考。常见做法是时序 k 折验证每个折的训练数据都在验证数据之前最后把五折的 RMSE 平均。如果还有测试集可以提交就把五个实验里验证表现最好的参数组合跑一遍作为最终结果。不要只写最后那一次跑出来的数值。from sklearn.model_selection import TimeSeriesSplit tscv TimeSeriesSplit(n_splits5) results [] for fold_index, (tr_idx, va_idx) in enumerate(tscv.split(X)): X_tr, X_va X.iloc[tr_idx], X.iloc[va_idx] y_tr, y_va y.iloc[tr_idx], y.iloc[va_idx] m lgb.LGBMRegressor(num_leaves31, learning_rate0.05, random_state42) m.fit(X_tr, y_tr) score mean_squared_error(y_va, m.predict(X_va), squaredFalse) results.append(score) print(CV RMSE:, results, avg:, sum(results) / len(results))时序 5 折的每一折之间会有所重叠但因为是前进式切分信息不会倒灌。我建议论文里写清楚每个折的样本起点和终点比如“第一折用 1-8000 训练、8001-10000 验证第二折用 1-10000 训练、10001-12000 验证”这比写“5 折验证”更有说服力也让代码和文字一一对应。6.3 从“做个题”到“讲明白一个题”论文的逻辑链不容易但模板好搭我参赛第一年把大量精力放在模型调参上结果论文被评阅人批评“像实验报告而不像分析论文”。后来我把逻辑固定成四句话数据有什么表格、分布→ 我要预测什么任务定义→ 用什么方法含选型理由→ 效果如何验证和误差分析。每一章都要能在这条链上找到位置别把特征工程堆在分析模型之后。这个模板也适合回答“为什么用 LightGBM 而不用随机森林”这类问题因为数据量大、表格特征多、滑窗特征高相关LightGBM 的 leaf-wise 生长和 feature_fraction 更适合。写完论文再看一遍最重要的图表如果评委只能记住一张图我希望是“预测对比图”而非“损失曲线图”——前者让他看到你解决的是真实任务后者只说明你在跑流水账。带着正式代码、干净日志和清晰图表去答辩不管线上还是线下都会比对手多一分脚踏实地的信任感。今天说的这些细节每一条都是我实际踩过或看队友踩过的坑希望帮到你。本文还有配套的精品资源点击获取