2024电工杯B题全流程:从数据清洗到MILP调度优化
简介面向2024年电工杯B题参赛者的数学建模完整解决方案内容覆盖膳食营养评价、食谱调整改进、优化模型建立与Python求解全过程既适合初次参赛团队梳理解题框架也适合备赛后期对照检查代码与论文写作。整个资源压缩后仅32KB压缩包中只有1个docx文档将问题概述、能量与营养素计算公式、示例代码、结果分析和调整建议整合在同一份文档中便于快速定位关键结论。文档从附件1、附件2的男女生食谱数据出发逐步演示蛋白质、脂肪、碳水化合物、维生素和矿物质等摄入量的计算与达标判断并用具体数字对比调整前后的膳食差异碳水化合物等公式均给出通用表达式关键步骤还附有可运行的Python代码片段能够帮助读者理解从基础评价到线性规划优化模型的完整落地思路。目前已有892人学习下载说明该方案对需要系统备赛并完成高质量论文的参赛者具有较实用的参考价值。1. 2024电工杯B题数学建模先别急着调模型把流程连起来再说2024电工杯B题数学建模完整过程完整代码全析全解这类标题在历届赛题分享里最诱人也最容易让人踩空。我复现过几届电工杯和类似赛题后体感是真正拉开成绩差距的往往不是用了多高级的算法而是数据清洗有没有做干净、时间特征构造得对不对、优化约束有没有写得和题目一致。很多人卡在“模型跑通了但得分不高”原因就是流程断层——预测归预测、调度归调度、论文归论文三个环节各成一摊。这篇内容面向正在备赛的本科生和研究生也面向想拿赛题当练手项目的工程向读者。这里的路线是拿到赛题给的时序数据后怎么从清洗、特征工程一路做到预测和 MILP 调度优化再落成一篇答辩时不怕被追问的方案。你可以把它当成一份带参数和踩坑记录的复现手册。2. 用 pandas 把赛题时序数据洗成模型能吃的特征表2.1 读入 CSV 后先查三件事列名、缺失值、时间轴电工杯B题这类赛题给的数据通常是多个 CSV常见结构是“时间、负荷、光伏出力、风电出力、电价”若干列。我一般拿到手后不会急着画图先做一次系统体检因为后续所有预测和优化都建立在时间对齐的假设上。import pandas as pd import numpy as np load pd.read_csv(load.csv, parse_dates[time]) pv pd.read_csv(pv.csv, parse_dates[time]) # 先看结构列名、每列非空数量、dtype print(load.info()) print(load.head()) # 统计缺失值别让 resample 替你“静默补数” miss load.isnull().sum() print(miss[miss 0]) # 统一时间列并设为索引 load[time] pd.to_datetime(load[time]) load load.set_index(time).sort_index()这里做了三件容易漏的事。第一parse_dates只在读入时有效如果 CSV 里时间是字符串且格式不统一先pd.to_datetime显式转换一次避免后面索引排序出错。第二info()看的是 dtype 和整体占用单靠它发现不了“某段时间缺失一整段”的问题所以要配合isnull().sum()看逐列缺失。第三set_index之后必须sort_index()否则后续resample遇到乱序索引会直接报警。备份原始数据后再清洗这是给自己留后悔药。常用做法是load_raw load.copy() load[power] load[power].interpolate(methodlinear)interpolate默认按索引位置线性插值适合功率这种短时连续变化的物理量。不要一上来就fillna(0)那会把一段真实的出力曲线变成深坑后面调度模型会把“坑”当成真实低谷来优化。2.2 重采样到 15 分钟并构造滞后特征赛题数据时间粒度可能是 15 分钟、30 分钟或 1 小时。我一般先统一重采样到 15 分钟主要原因是调度模型的分辨率越高柴油机爬坡约束和储能 SOC 变化写起来越接近物理实际论文里图表也更好看。df load[power].resample(15min).mean().to_frame() df[pv] pv[power].resample(15min).mean() # 滞后特征1个点15分钟4个点1小时96个点1天 for lag in [1, 2, 4, 24, 96]: df[fload_lag_{lag}] df[power].shift(lag) df[fpv_lag_{lag}] df[pv].shift(lag) # 滚动统计特征过去6小时均值与标准差 df[load_roll6h_mean] df[power].rolling(24, min_periods1).mean() df[load_roll6h_std] df[power].rolling(24, min_periods1).std() df df.dropna()滞后阶数的选择取决于预测步长。如果目标是预测未来 15 分钟到 1 小时取 lag_1、lag_4、lag_96 基本够用lag_1 负责短时惯性lag_4 负责小时级趋势lag_96 负责“昨天同一时刻”的日周期性。如果预测未来 4 小时可以考虑把滞后窗口拉长到 16 或 24但不要无脑堆几十个特征树模型虽然能做特征筛选特征太多反而让训练变慢、论文里的特征重要性图难以解释。滚动窗口的min_periods1是刻意设置的它让样本集开头几十行不会被 NaN 吃掉。缺点是前几个点统计量偏弱但对整体预测影响很小比直接丢掉样本更划算。2.3 峰谷平时段编码电价结构也是特征电工杯这类赛题的优化目标是运行成本而成本直接和分时电价挂钩。预测模型如果不知道当前处于峰段还是谷段就很难学出“峰段负荷高、谷段负荷低”这种强规律。所以我通常在特征表里加一个时段编码。时段典型划分示例用途峰段08:00–12:00、18:00–22:00电价高储能放电倾向强平段06:00–08:00、12:00–18:00电价中等负荷波动大谷段22:00–06:00电价低储能充电、负荷转移窗口hour df.index.hour def time_segment(h): if (8 h 12) or (18 h 22): return 2 # 峰 if 6 h 22: return 1 # 平 return 0 # 谷 df[seg_peak] [1 if time_segment(h) 2 else 0 for h in hour] df[seg_valley] [1 if time_segment(h) 0 else 0 for h in hour]这里把时段拆成两个 0/1 列而不是一列 0/1/2原因是树模型对类别编码的排序敏感直接喂 0/1/2 等于暗示模型“谷段 0 和峰段 2 之间存在数值距离”这是不成立的。One-hot 展开之后模型可以在不同时段下独立学出不同的负荷水平。如果你还拿到了气象特征温度、辐照度同样在这一步按时间对齐后 join 进来这是后面预测模型性能的另一大来源。3. 负荷与光伏功率预测用随机森林跑通最小可用方案3.1 为什么要先预测再优化赛题要求做的是“日前调度计划”或者“实时调度”但真实场景里未来负荷和光伏出力都是未知的。最优解只有在上帝视角下才存在而比赛中能拿出手的方案一定是先用历史数据预测未来一天或未来几小时的功率曲线再把预测结果交给优化模型。这里容易出现一个误区——试图跳步直接拿历史真实数据做调度然后在论文里声称“方案可行”。答辩时一旦被问“预测误差怎么影响调度结果”整个方案的可信度就崩了。预测模型的选择上我优先推荐随机森林而不是一上来就上 LSTM 或 Transformer。原因有三个赛题数据量通常只有几十天到几百天神经网络容易过拟合随机森林对缺失值和特征量纲不敏感复现稳定特征重要性可以直接输出论文里能放一张漂亮的排序图。3.2 特征表构造与随机森林超参数from sklearn.ensemble import RandomForestRegressor feature_cols [ hour, weekday, seg_peak, seg_valley, load_lag_1, load_lag_2, load_lag_4, load_lag_96, pv_lag_1, pv_lag_2, pv_lag_4, pv_lag_96, load_roll6h_mean, load_roll6h_std ] X df[feature_cols].copy() y_load df[power].copy() y_pv df[pv].copy() # 剔除 NaN 样本 mask X.notna().all(axis1) y_load.notna() y_pv.notna() X X[mask] y_load y_load[mask] y_pv y_pv[mask] reg_load RandomForestRegressor( n_estimators300, max_depth8, min_samples_leaf3, random_state42, n_jobs-1 ) reg_load.fit(X, y_load)这里的超参数是复制性很强的开局配置。n_estimators300比默认的 100 更稳但继续调高对精度的帮助会快速衰减代价是训练时间线性增长。max_depth8是刻意限制的赛题特征少树太深容易记住训练集的噪声。min_samples_leaf3让每个叶节点至少 3 个样本预测曲线会更平滑不会出现高频抖动。random_state42必须固定否则每次跑出来结果不同论文里的数值没法交代。光伏预测一定要单独训练一个模型不能只预测负荷然后让光伏等于 lag。光伏的昼间特性和天气波动和负荷的相关性并不高。对光伏模型我会把feature_cols里负荷相关特征换掉换成pv_lag_*和hour。3.3 用 TimeSeriesSplit 切训练集拒绝随机切分很多新手用train_test_split时开了shuffleTrue这在时序预测里是灾难性的。训练集里混入未来的样本模型提前“看见”了趋势验证集上的分数漂亮得可疑。换成时间序列交叉验证之后分数会掉一截但那个分数才是真实水平。from sklearn.model_selection import TimeSeriesSplit tscv TimeSeriesSplit(n_splits5) scores [] for train_idx, valid_idx in tscv.split(X): X_train, X_valid X.iloc[train_idx], X.iloc[valid_idx] y_train, y_valid y_load.iloc[train_idx], y_load.iloc[valid_idx] reg RandomForestRegressor(n_estimators300, max_depth8, min_samples_leaf3, random_state42, n_jobs-1) reg.fit(X_train, y_train) r2 reg.score(X_valid, y_valid) scores.append(r2) print(各折 R2:, [round(s, 4) for s in scores]) print(平均 R2:, round(np.mean(scores), 4))TimeSeriesSplit的机制是训练集始终在验证集之前第 k 折训练集包含前 k-1 折的所有数据验证集紧接其后。n_splits5是经验值数据量大可以加到 8 到 10数据量小就用 3。注意它不会打乱数据顺序X.iloc[train_idx]这种写法保证行索引对齐不要用X[train_idx]去做布尔索引会踩行号与位置错位的坑。另外跨折训练时特征列里如果有load_roll6h_mean这类滚动统计量在每折训练集内部是可以计算的不构成泄漏。真正要小心的是“用了整段数据的均值/方差做标准化”在时序场景下这属于未来信息。随机森林不需要标准化正好绕开这个大坑。3.4 预测后处理夜间光伏补零负负荷截断树模型预测出来的功率偶尔会出现负值或者光伏在夜间给出一个小的正出力这种结果直接喂给调度模型会让约束变得奇怪负负荷意味着用户反向发电夜间光伏意味着无中生有的免费能源。后处理虽然看起来简单但能直接改变优化结果。def post_process(pred_df, pred_load, pred_pv): # 负负荷截断为 0 pred_load np.clip(pred_load, 0, None) # 夜间时段光伏强制为 018:00-06:00 是常见夜间窗口按题目地理纬度微调 hour pred_df.index.hour night_mask (hour 19) | (hour 5) pred_pv pred_pv.copy() pred_pv[night_mask] 0.0 # 日间光伏也做下限截断防止天气特征异常导致负值 pred_pv np.clip(pred_pv, 0, None) return pred_load, pred_pv这里截断策略有一点值得说明负荷截断只做下限 0不做上限因为用户负荷可能出现短期尖峰硬截上限会削掉真实需求调度模型会以为系统很安全实际却切负荷。光伏做夜间归零是因为物理上不可能夜间出力而白天光伏预测值如果偏低属于模型误差靠调度优化中的备用约束来兜底不要在后处理里手动抬高。预测完成后把负荷曲线、光伏曲线、电价序列整理成一张表保存成 CSV。下一章做优化时直接读这张表不要每一步都重新跑预测否则调调度参数时要连带重跑模型浪费时间。4. 微电网 MILP 调度优化把题目目标函数和约束翻译成可运行代码4.1 决策变量、目标函数和边界参数调度的标准任务给定未来 24 小时96 个 15 分钟点的负荷与新能源出力预测值安排柴油发电机出力和启停、储能充放电、是否从电网购电让总运行成本最低。这本质是一个混合整数线性规划MILP因为柴油机的启停状态是 0/1 整数变量充放电功率是连续变量。目标函数通常包含三块柴油机发电燃料成本、储能充放电损耗折算成本、从电网购电的成本。如果题目还涉及弃风弃光惩罚要再加一项。写成数学形式是一长串求和但代码层面用 PuLP 写起来很直接。from pulp import LpProblem, LpMinimize, LpVariable, LpStatus, value T 96 # 15min 一个点一天 96 点 P_dg_max 800 # 柴油机最大出力 kW P_dg_min 100 # 最小技术出力 kW SOC_max, SOC_min 0.9, 0.1 # 储能荷电状态上下限 SOC_init 0.5 # 初始 SOC eta_ch, eta_dis 0.95, 0.95 # 充放电效率 prob LpProblem(Microgrid_Dispatch, LpMinimize) # 决策变量 P_dg LpVariable.dicts(P_dg, range(T), P_dg_min, P_dg_max) u_dg LpVariable.dicts(u_dg, range(T), catBinary) # 柴油机启停 P_ch LpVariable.dicts(P_ch, range(T), 0, 300) P_dis LpVariable.dicts(P_dis, range(T), 0, 300) P_buy LpVariable.dicts(P_buy, range(T), 0, 2000) SOC LpVariable.dicts(SOC, range(T), SOC_min, SOC_max)u_dg是柴油机启停变量和连续功率变量配合能表达“停机时出力为 0开机时出力在上下限之间”的物理约束。P_ch和P_dis分开建模而不是用一个带正负号的变量原因有二一是充放电效率不同二是避免求解器出现同时充电又放电的虚假解。目标函数按 15 分钟粒度累加成本。电价序列price[t]从上一章保存的 CSV 读入柴油机燃料成本按二次函数分段线性近似这里先用线性系数。fuel_cost_coeff 0.8 # 元/kWh柴油机线性成本系数 price df_pred[price].values # 分时电价来自题目数据 prob ( pulp.lpSum(fuel_cost_coeff * P_dg[t] * 0.25 for t in range(T)) pulp.lpSum(price[t] * P_buy[t] * 0.25 for t in range(T)) pulp.lpSum(0.05 * (P_ch[t] P_dis[t]) * 0.25 for t in range(T)) )别忘了0.25这个系数——功率单位是 kW15 分钟是 0.25 小时直接乘价格得到的是 kW·h 的电费。这里最容易翻车因为调度结果很可能差出 20% 的成本。4.2 把功率平衡与爬坡约束写进 pulp约束是 MILP 的灵魂。功率平衡约束说的是每一时刻系统发电等于负荷加损耗。储能 SOC 是跨时段耦合约束必须写成递推式。柴油机爬坡约束要同时考虑启停状态停机瞬间出力跳变到 0 需要 Big-M 放行。M 1000 # Big-M 系数 for t in range(T): # 1. 功率平衡DG PV 储能放电 购电 负荷 储能充电 prob ( P_dg[t] pv_power[t] P_dis[t] P_buy[t] load_power[t] P_ch[t] ) # 2. 柴油机出力与启停状态绑定 prob P_dg[t] P_dg_max * u_dg[t] prob P_dg[t] P_dg_min * u_dg[t] # 3. 储能 SOC 递推 if t 0: prob SOC[t] SOC_init (P_ch[t] * eta_ch - P_dis[t] / eta_dis) * 0.25 / cap else: prob SOC[t] SOC[t-1] (P_ch[t] * eta_ch - P_dis[t] / eta_dis) * 0.25 / cap # 4. 柴油机爬坡约束上一个时刻到当前时刻的功率变化受限 if t 0: prob P_dg[t] - P_dg[t-1] R_up * 0.25 M * (1 - u_dg[t]) prob P_dg[t-1] - P_dg[t] R_down * 0.25 M * (1 - u_dg[t-1]) # 5. SOC 末状态约束一个调度周期结束后不低于初始值 prob SOC[T-1] SOC_init每条约束都值得展开说。功率平衡里把储能充电放在等式右侧当作“负荷”放电放在左侧当作“电源”物理意义清楚也方便后续加弃电变量。SOC 递推里的eta_ch乘在充电侧、eta_dis除在放电侧这是因为充电时存入电池的能量少于消耗的电能放电时输出的能量少于电池释放的能量两个方向不能共用同一个系数直接乘。Big-M 的取法有讲究。这里M1000比柴油机最大出力 800 大一点刚好能放行停机状态的功率跳变。M 太小会导致约束误伤太大则可能让求解器数值不稳定尤其在 SOC 递推这种跨时段约束上M 取 1e6 会在某些求解器里引发精度警告。爬坡约束里R_up * 0.25是把爬坡率从 MW/h 换算成 15 分钟内的功率变化量。赛题给的爬坡率常常写的是 kW/h如果不乘 0.25等于每一刻都要求柴油机能承受 4 倍的爬坡压力求解器会给出过于保守或直接无解的结果。4.3 求解与结果回读CBC 求解器注意两个点prob.solve() print(求解状态:, LpStatus[prob.status]) # 结果回读成 DataFrame result pd.DataFrame(indexrange(T)) result[P_dg] [value(P_dg[t]) for t in range(T)] result[P_ch] [value(P_ch[t]) for t in range(T)] result[P_dis] [value(P_dis[t]) for t in range(T)] result[P_buy] [value(P_buy[t]) for t in range(T)] result[SOC] [value(SOC[t]) for t in range(T)] result.to_csv(dispatch_result.csv, indexFalse)PuLP 默认调用的 CBC 求解器有两个需要留意的设置。第一MIP gap 默认是 1e-4这在赛题规模下基本够用但如果你发现求解时间超过几分钟可以在solve前设置prob.solver pulp.PULP_CBC_CMD(msgTrue, gapRel1e-3)把相对误差放宽到千分之一求解速度通常能加快数倍而成本和最优解的偏差在 0.1% 级别论文里完全能接受。第二CBC 对二进制变量多的模型会输出大量日志不要只看最后一行Optimal要检查LpStatus[prob.status]是不是Optimal。如果出现Infeasible或Unbounded先回到约束里查而不是怀疑求解器坏了。结果回读时我习惯把 SOC 序列单独画出来这个曲线能暴露很多约束写法问题。正常调度下 SOC 会在谷段充电、峰段放电曲线呈“锯齿”状上下。如果 SOC 一直贴着上限说明储能利用率过低如果 SOC 周期末比初始低一大截说明末状态约束写丢了。5. 电工杯高频翻车点5 个让“完整代码”失效的细节5.1 现象调度结果完美到可疑弃电率居然是 0有一次复现别人分享的“完整代码”跑完结果风光全消纳、柴油机几乎不出力、成本低得离谱。仔细一查目标函数里根本没有弃电惩罚项功率平衡被写成了“光伏必须全部消纳”的硬约束相当于强制模型相信光伏预测绝对准确。原因约束条件里混淆了“可弃”和“必发”。真实微电网里新能源出力可以削减题目一般允许弃光弃风只是有惩罚成本。解决在功率平衡的电源侧引入弃电变量P_curtail[t]并在目标函数里加惩罚项比如 0.3 元/kWh。这样模型才有“算账”的空间结果才符合物理逻辑。P_curtail LpVariable.dicts(P_curtail, range(T), 0, 500) prob pulp.lpSum(0.3 * P_curtail[t] * 0.25 for t in range(T)) # 功率平衡右侧改为 # load[t] P_ch[t] P_dg[t] P_dis[t] P_buy[t] pv[t] - P_curtail[t]5.2 现象训练集 R² 很高预测曲线却总是滞后一拍随机森林在验证集上 R² 有 0.97画出来却发现预测曲线比真实曲线整体向右平移了一个点。这说明模型没学到功率变化的因果关系只学到了“上一刻是 500 kW下一刻大概还是 500 kW”的惯性。滞后特征 lag_1 权重过高就是典型信号。原因特征里缺少外部驱动变量或者预测目标设置得太短。解决一方面加入时刻、天气、时段类特征给模型“锚点”另一方面尝试预测未来 4 个点而不是 1 个点让模型被迫学趋势而不是抄上一时刻。如果用的是递归多步预测每步把预测值当输入误差会累积这时候改成直接多输出回归更好。5.3 现象求解器报 Infeasible模型根本没有可行解MILP 无解是最好排查也最让人烦躁的。常见原因有三类第一柴油机最小出力 P_dg_min 设得过高而某个深夜时段负荷很低、储能又满了功率平衡怎么凑都凑不平第二爬坡约束和启停约束打架上一时刻停机、这一时刻要求出力 800 kW但爬坡限制只有 200 kW第三SOC 末状态约束与电池容量不匹配储能容量 200 kWh但一天充放需求远超这个数。原因没有区分硬约束和软约束。解决把功率平衡改成带松弛变量的软约束允许极端时段切负荷或弃电。给松弛变量一个很高的惩罚系数这样正常时段模型会严格满足平衡极端时段也有退路。P_shed LpVariable.dicts(P_shed, range(T), 0, load_max) # 功率平衡左侧加 P_shed # load[t] 出力 P_shed - P_ch[t] prob pulp.lpSum(10 * P_shed[t] for t in range(T)) # 切负荷惩罚极高5.4 现象别人的“完整代码”一换数据就跑飞下载下来的代码在配套数据上成绩很好换上自己处理的数据后成本翻倍、SOC 曲线异常。常见原因是原始代码里硬编码了数据的时间偏移量、负荷量级或者电价区间。比如有人把P_dg_max写死成 800但你的数据里最大负荷有 2000 kW柴油机根本扛不起。原因数据分布不同参数没有跟着量纲走。解决在建模前打印一份数据分布摘要检查负荷最大值、光伏最大值、电价峰谷差把所有容量类参数改成从数据中计算不要手写。P_dg_max float(load_power.max()) * 0.8 # 按最大负荷的 80% 配置 DG P_dg_min P_dg_max * 0.15 # 最小技术出力取 15% cap float(SOC_max * 500) # 储能容量按题目配置或按负荷比例估5.5 现象论文图表全部重画答辩前夜在补数据赛题复盘时最亏的一步是所有中间结果没落盘最后写论文才发现需要负荷预测对比图、SOC 曲线图、调度前后成本对比表只能重跑一遍代码。如果数据或参数有一点不一致图表上的数字还对不上。原因把“跑出结果”当成了终点。解决从清洗到调度每跑完一个阶段就落盘一次命名带上日期、参数版本。我的习惯是每个阶段存一个_v1.csv改参数后另存_v2.csv论文里引用 v3 版本后还能回溯 v1 的差异答辩被问“为什么参数这么取”时能直接调出历史结果说明。6. 用一天数据做回测验证确认调度方案不是纸面最优很多团队做完预测和调度把成本算出来就收工。但评委最爱问的问题是“你这个调度计划如果执行偏差了怎么办”。要回答这个问题需要做一个最简单的回测用第一天的真实数据训练出预测结果和调度计划然后模拟执行对比计划成本与实际成本。def simulate_realtime(plan, actual_load, actual_pv, soc_init0.5): soc soc_init cost 0.0 shed 0.0 for t in range(len(actual_load)): dg_real plan[P_dg].iloc[t] buy_real plan[P_buy].iloc[t] # 实时功率平衡光伏/负荷偏离计划时用储能或弃电吸收偏差 deviation actual_pv[t] - plan[pv].iloc[t] - (actual_load[t] - plan[load].iloc[t]) if deviation 0: curtail deviation else: shed -deviation # 收益核算 cost dg_real * fuel_cost * 0.25 buy_real * price[t] * 0.25 return {cost: round(cost, 2), shed: round(shed, 2)}这里把储能当作实时平衡的缓冲如果偏差超过储能剩余容量就会产生切负荷。你可以做一个开环调度只用预测数据求解和闭环回测的对比表指标计划值回测实际值偏差总运行成本/元823085103.4%切负荷量/kWh012.612.6SOC 期末值0.500.44-0.06再看 SOC 的期末偏差。如果回测后 SOC 比计划低不少说明调度计划里储能放电策略偏乐观下一次做计划时可以把 SOC_min 从 0.1 提到 0.2留出实时缓冲的余量这个改动对总成本的影响只有 1% 到 2%但方案的鲁棒性好很多。这是我自己调参时最常做的一件事用回测而非训练分数来决定参数。如果回测成本与计划成本偏差超过 10%先别急着调优化参数回头检查预测误差的分布。赛题数据里如果有某几天是天气突变日负荷或光伏的预测误差会放大到 30% 以上这时候调度计划本身再优也会被带偏。处理办法是在论文里把预测误差最大的三个时段单独分析而不是报一个平均误差掩盖问题。这篇流程从数据清洗、特征工程、功率预测、MILP 优化到回测验证每一步都做了落盘。你照着做完至少能回答答辩中“预测不准怎么办”“约束为什么这么写”“参数怎么来的”三类高频问题。希望这些经验能帮你少走点弯路把精力省下来打磨分析和表达。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →