简介:面向数学建模竞赛参赛者的赛题解析资料,聚焦2024年电工杯B题,围绕大学生一日食谱的膳食营养评价与优化问题,完整讲解从问题理解、模型建立到程序实现的全流程。压缩包内含一个docx文档,体积约三十二千字节,虽小却系统梳理了赛题分析、计算公式与代码实现。文档先依据参考标准计算能量、蛋白质、脂肪、碳水化合物及维生素矿物质摄入量,评估膳食结构与营养素缺乏或过量情况;再针对高校食堂食材提出增加优质蛋白与蔬果、减少高脂高糖食物的调整方案;最后构建营养优化模型,用编程求解并生成平衡膳食的日食谱与周食谱表格。读者可对照样例代码快速复现求解过程,掌握数据处理与建模思路,适合备赛同类竞赛或膳食优化建模任务的学生参考。已有892人学习下载,可作为备赛阶段的直接借鉴。
1. 电工杯B题到底考什么:不只是算法,是整套建模流程
很多队伍在电工杯B题上翻车,不是败给算法不够新,而是败给流程:拿到题目花一天半读数据,建模到第三天发现约束漏了,最后一天通宵改代码,提交前连结果合理性都没验证。B题和A题最大的差别在于,它往往给足真实场景和表格数据,工程量集中在数据清洗、约束梳理和结果解释上,而不是拼谁会用神经网络。你需要的不是某个灵光一现的模型,而是一条能稳定复现的流水线:读题拆问题、洗数据、建模型、写代码求解、排错、验结果。这篇笔记就按这个顺序把完整过程拆开讲,代码直接能跑,参数掰开揉碎说清楚,适合正在备赛的学生,也适合第一次带队的指导者照着检查进度。
2. 把题意翻译成数学模型:目标函数与约束条件的拆解
2.1 第一步不是写代码,是给问题下定义
我见过太多队伍对着赛题第一反应就是打开编辑器,结果写了一堆用不上的函数。正确做法是用半天时间把题目文字翻译成数学语言,翻译完了,代码只是体力活。B题的题干通常包含场景描述、数据表说明、求解要求三部分,你要从中提取三类信息:决策变量、目标函数、约束条件。这三样缺一个,模型就没法定型。
具体操作建议按这个顺序走:先把题目里所有“求”“使”“最小化”“最大化”字样的句子圈出来,那里面藏着目标函数;再把所有“满足”“受限于”“不超过”“不低于”“必须”圈出来,那里面藏着约束;最后看表格的列名和数据范围,确定哪些量是给定的参数、哪些量是要算的决策变量。这一步做完,你会得到一张草稿纸,上面是数量关系而不是中文句子。
2.2 决策变量三分类与建模表格
决策变量在电工杯B题里通常分三类:连续变量、整数变量、0-1变量。连续变量比如发电机出力、流量大小;整数变量比如设备台数、人员数量;0-1变量最常见,比如设备是否启动、开关是否闭合、方案是否采用。三类变量决定了后面求解器的选型,这一步分错了,后面代码结构全错。
为了不容易漏,我一般会画一张建模表,把题目给的每一个物理量填进去。下面这张表是通用模板,你可以直接抄:
| 物理量 | 符号 | 类型 | 范围/取值 | 来源 |
|---|---|---|---|---|
| 机组出力 | P_i(t) | 连续 | [P_min, P_max] | 待决策 |
| 机组启停 | x_i(t) | 0-1 | 0或1 | 待决策 |
| 负荷需求 | D(t) | 参数 | 给定序列 | 数据表 |
| 燃料成本系数 | c_i | 参数 | 给定 | 数据表 |
| 最大爬坡率 | r_i | 参数 | 给定 | 数据表 |
表格填完,你就能直观看出哪些量是求解器要算的、哪些是读数据进来的。一个重要的检查点:如果题目给了一张数据表,表里每个字段都必须能在你的建模表里找到归宿,找不到的那个字段要么是冗余信息,要么是你漏读了一个约束,后者的概率远大于前者。
2.3 约束条件的层次化整理
约束条件不要一股脑写进代码,先按层次理清楚。我习惯分四层:第一层是物理边界,也就是变量的上下界,比如出力不能超过机组额定功率;第二层是平衡约束,比如每个时段总出力等于总负荷;第三层是逻辑约束,比如设备启动了才能出力,也就是出力大于0的前提是状态变量为1;第四层是工程附加约束,比如机组不能频繁启停、检修时段不能运行。这四层优先级依次降低,前三层是模型正确性的底线,第四层是得分点。
一个容易犯的错误是把所有约束写成一大段不等式,不分层。后果是当求解器报“无解”的时候,你根本不知道是哪一层约束在打架。正确的做法是在纸上先把约束写成带编号的数学表达式,比如“约束C1:∑P_i(t) = D(t), ∀t”,然后再开始写代码。一旦后期排查,你能按编号快速禁用某组约束做测试,而不是对着几百行代码发呆。这一步花两小时,后面能给你省两天。
3. 数据预处理与特征构造:从赛题数据到可求解的输入
3.1 读取赛题数据:格式假设与读取代码
电工杯B题的数据文件常见格式是Excel表格,偶尔也会给CSV。第一个坑就是打开文件的瞬间发现表头不是第一行、列名带单位、时间列是文本格式。这些问题不处理,后面的合并和计算全崩。下面这段代码是我每次拿到赛题数据都会先跑一遍的“体检脚本”:
import pandas as pd import numpy as np # 读取Excel,假设sheet名为Sheet1 df = pd.read_excel('load_data.xlsx', sheet_name='Sheet1') # 体检:查看行列数、列名、每列非空数、数据类型 print("形状:", df.shape) print("列名:", df.columns.tolist()) print("类型:\n", df.dtypes) print("缺失统计:\n", df.isnull().sum()) print("前5行:\n", df.head())这段代码的作用是让你的数据底牌暴露出来,而不是靠肉眼猜。注意四点:第一,read_excel的sheet_name参数要和你实际打开Excel时看到的表名一致,有时候表名叫“数据1”而不叫Sheet1,读不对就报错;第二,df.dtypes会告诉你哪些列被读成了字符串,比如时间列如果显示object,说明需要转时间格式;第三,isnull().sum()是排查缺失值最快的方式,比手动翻Excel靠谱得多;第四,head()必须看,因为表头有没有被当数据读进去,看前两行就知道。
3.2 缺失值与异常值处理:为什么不能用均值填
赛题数据里常见两类问题:一是某些时段的数据缺失,二是某些采样点数值明显偏离物理范围,比如负荷突然变成负数或超出变压器容量。很多队伍图省事直接用均值填充,这是个隐蔽的坑。对于电力负荷这类强时间相关序列,均值填充会直接抹掉峰谷特征,导致后面约束被错误满足或违反。
我一般这样处理:先看缺失值比例,少于总样本量5%的,用前后时刻线性插值;比率较高的,先检查是不是整个时间段缺失——那可能是题设故意让你合并外部数据,而不是让你猜。对于异常值,用物理边界判断:超出设备容量的直接标为异常,然后按上下相邻点插值修正。代码里用线性插值最稳妥:
# 时间列转datetime df['time'] = pd.to_datetime(df['time']) # 按时间排序,务必先排序再插值 df = df.sort_values('time') # 设定数值列的物理边界示例:负荷列合理范围为[0, 2000] col = 'load' df.loc[df[col] < 0, col] = np.nan df.loc[df[col] > 2000, col] = np.nan # 线性插值填充缺失值 df[col] = df[col].interpolate(method='linear', limit_direction='both')这里有个细节值得说:interpolate默认不填充序列开头和结尾的缺失值,所以加了limit_direction='both',让首尾也能用最近邻的逻辑补齐。注意,先sort_values再插值是必须的,否则时间乱序时插值结果没有物理意义。做完这一步,把处理后的数据画个折线图,肉眼看一遍有没有尖刺或平台,数值问题基本就能拦住。
3.3 特征构造与量纲统一
B题的数据往往不是直接可用,需要构造派生特征。最常见的几个:峰谷时段标识(把24小时分成峰、平、谷三段)、累计平均值、滑动窗口均值、或者把日期转换成星期几。这些特征的作用是让模型捕捉到负荷的周期性规律,或者让优化约束变得更贴近实际。比如分时电价场景,峰谷标识就是成本计算的关键输入。
量纲统一是这里最容易翻车的地方。题目给你的数据可能是功率单位千瓦、成本单位元每兆瓦时、时间单位小时,混在一起的时候必须全部折算到同一套单位制。我见过一组队伍在目标函数里加了燃料成本和启停成本,一个单位是元、另一个是万元,结果求解器给出的方案看起来“成本极低”,其实是把不同量纲的数直接相加了。建议在代码开头定义一个基础单位,比如功率统一用兆瓦、成本统一用元、时间统一用小时,所有数据进来先换算,再进模型。这个习惯能避免90%的数值玄学问题。
4. 核心求解代码:基于MILP的完整实现与参数解读
4.1 求解器选型:什么时候用线性、什么时候用混合整数
到了求解环节,先不要急着套遗传算法。B题里绝大多数优化问题都可以用线性规划(LP)或混合整数线性规划(MILP)解决,区别在于你的决策变量里有没有整数或0-1变量。全连续变量就用scipy.optimize.linprog;含0-1或整数变量就用scipy.optimize.milp,这两个都是开箱即用的求解器,不需要额外许可证。
很多队伍一看到“优化”两个字就上遗传算法,理由是怕模型是NP难的。实际上,B题给的规模通常不会大到求解器跑不动。我做过一次对比:一个包含30台设备、24个时段的MILP模型,scipy.optimize.milp默认参数下几秒到几十秒就能出结果,而同样的问题用遗传算法,参数调不好时可能陷入局部最优,还解释不清。能用精确求解器解决的,就坚决不用启发式。等精确求解器确实跑不动了,再考虑上启发式。
4.2 完整求解代码:机组启停与负荷分配
下面这套代码是一个典型的机组组合(Unit Commitment)简化模型,目标是最小化燃料成本和启动成本,约束包括负荷平衡、出力上下限、启停逻辑。这是电工杯B题里出镜率最高的优化骨架,你拿着换成赛题里的设备和约束就能用。注意这里我特意用小型示例数据保证代码可以直接运行:
import numpy as np from scipy.optimize import milp, LinearConstraint, Bounds # ---------- 参数设置 ---------- n_units = 3 # 机组数量 T = 24 # 时段数,单位小时 Pmin = np.array([50, 30, 20]) # 各机组最小出力,MW Pmax = np.array([200, 150, 100]) # 各机组最大出力,MW fuel_cost = np.array([0.55, 0.65, 0.75]) # 燃料成本,元/MWh start_cost = np.array([300, 200, 150]) # 启停成本,元/次 # 24小时负荷需求,单位为MW,实际赛题从Excel读入 demand = np.array([ 180, 170, 160, 155, 150, 160, 200, 240, 280, 320, 330, 310, 290, 280, 300, 340, 360, 380, 420, 400, 360, 300, 220, 190 ]) # ---------- 变量编号约定 ---------- # 变量排列顺序:前 n*T 个是启停状态 x,中间 n*T 个是出力 p,最后 n*T 个是启动辅助变量 s def idx_x(i, t): return i * T + t def idx_p(i, t): return n_units * T + i * T + t def idx_s(i, t): return 2 * n_units * T + i * T + t n_var = 3 * n_units * T # ---------- 目标函数系数 ---------- c = np.zeros(n_var) for i in range(n_units): for t in range(T): c[idx_p(i, t)] = fuel_cost[i] # 出力成本 c[idx_s(i, t)] = start_cost[i] # 启动成本 # ---------- 变量边界 ---------- lb = np.zeros(n_var) ub = np.ones(n_var) for i in range(n_units): for t in range(T): lb[idx_p(i, t)] = Pmin[i] ub[idx_p(i, t)] = Pmax[i] ub[idx_s(i, t)] = 1.0 bounds = Bounds(lb, ub) # ---------- 约束矩阵组装 ---------- A_rows = [] lb_con = [] ub_con = [] # 约束1:每个时段总出力等于负荷需求 for t in range(T): row = np.zeros(n_var) for i in range(n_units): row[idx_p(i, t)] = 1 A_rows.append(row) lb_con.append(demand[t]) ub_con.append(demand[t]) # 约束2:出力受启停状态约束(p 在 0 和 x*Pmax 之间) for i in range(n_units): for t in range(T): row1 = np.zeros(n_var) row1[idx_p(i, t)] = 1 row1[idx_x(i, t)] = -Pmax[i] A_rows.append(row1) lb_con.append(-np.inf) ub_con.append(0) # 保证 p - x*Pmax <= 0 row2 = np.zeros(n_var) row2[idx_p(i, t)] = -1 row2[idx_x(i, t)] = Pmin[i] A_rows.append(row2) lb_con.append(-np.inf) ub_con.append(0) # 保证 -p + x*Pmin <= 0 # 约束3:启动变量与状态变化的关系 s >= x[t] - x[t-1] for i in range(n_units): for t in range(1, T): row = np.zeros(n_var) row[idx_x(i, t)] = 1 row[idx_x(i, t - 1)] = -1 row[idx_s(i, t)] = -1 A_rows.append(row) lb_con.append(-np.inf) ub_con.append(0) # 保证 x[t] - x[t-1] - s <= 0 constraints = LinearConstraint(np.array(A_rows), lb_con, ub_con) # ---------- 指定整数变量:启停状态为0-1 ---------- integrality = np.zeros(n_var) for i in range(n_units): for t in range(T): integrality[idx_x(i, t)] = 1 # 1表示整数约束 # ---------- 求解 ---------- res = milp(c, integrality=integrality, bounds=bounds, constraints=constraints) # ---------- 结果输出 ---------- if res.success: print("求解成功,总成本: %.2f 元" % res.fun) x_opt = res.x[:n_units * T].reshape(n_units, T) p_opt = res.x[n_units * T:2 * n_units * T].reshape(n_units, T) print("各时段总出力:", p_opt.sum(axis=0).round(1)) print("机组1启停状态:", x_opt[0].astype(int)) else: print("求解失败:", res.message)这段代码的核心逻辑分成四个模块:变量定义、目标函数、约束组装、求解输出。milp函数的四个关键参数分别是c(目标系数向量)、integrality(标记哪些变量是整数)、bounds(变量的上下界)、constraints(线性约束)。特别注意integrality里我只把启停状态x标记为1,出力p和辅助变量s保持0也就是连续变量——如果你把p也标记成整数,模型会被迫要求出力是整数兆瓦,那既不符合物理实际,也会让求解变慢几倍。
LinearConstraint的组装方式是用三个数组描述一组不等式:A_rows是系数矩阵的每一行,lb_con和ub_con是每行的下界和上界。对于形如A·x ≤ b的不等式,下界设成-np.inf、上界设成0;对于等式约束,上下界都设成同一个数。这个写法是scipy的风格,和pulp那种逐条addConstraint不同,习惯后批量组装更快,也不容易漏约束。
4.3 结果的工程检验:可行性与可解释性
求解器返回success=True不代表结果一定正确。我拿到结果习惯先做三个检验:第一,把每个时段的p_opt.sum(axis=0)和demand逐项对比,误差超过1e-6就说明约束组装时漏了某个时段;第二,检查启停状态和出力是否匹配,也就是某个机组x=0的时段,对应出力必须为0,这是约束2在起作用;第三,把目标函数值拆开,分别统计燃料成本和启动成本,看看启动成本占比是否合理——如果启动次数多得离谱,通常说明你漏掉了最小启停时间约束,误差会被评委一眼看出来。
另外,res.fun的单位取决于你目标系数c的单位。前面写的fuel_cost单位是元每兆瓦时,出力单位是兆瓦,时间单位是小时,所以乘积单位就是元。这个换算链要在论文里写清楚,很多评委专门挑这种单位口径不统一的错。
5. 避坑清单:五天赛程里最常翻车的五个环节
5.1 单位错位:数字对了但物理意义错了
现象:求解结果的总成本数值很小,小到明显不符合常识,或者约束“看似满足”但画图上曲线对不上。原因:数据表里某列的单位是千瓦,模型里却按兆瓦参与运算,导致数量级差了1000倍。解决:在代码开头写一个单位换算区,所有数据读入后第一时间统一到一套单位制,再进入模型。输出结果时再换回题目要求的单位,不要在模型内部来回切换单位。
5.2 漏掉隐式约束:边界条件拆题不干净
现象:模型跑通了,结果也很漂亮,但对照赛题原文发现有个前提条件没有被体现,比如“机组在检修时段不得启动”“设备启动后至少运行2小时”。原因:拆题时只看到了显式的数字约束,忽略了文字描述里的逻辑约束。解决:把题目文字逐句过一遍,遇到描述运行规则的长句,全部改写成逻辑表达式,写不进数学表达式的也要在论文里作为假设说明。宁可多设一个约束让问题变紧,也不能漏掉一个约束扣分。
5.3 求解器提示infeasible:不是题目错了,是建模漏了
现象:milp返回success=False,报错信息显示无可行解。原因:最常见的三个——负荷数据有误导致平衡约束达不到;某个机组Pmin设得过高,在低谷时段无法只靠这一台机组满足低负荷;约束之间互相矛盾,比如同时要求某机组“必须运行”和“出力为零”。解决:用二分法排查,先把第四层附加约束全删掉,只保留平衡约束和物理边界,跑通了再加回一条、再跑,直到找出哪两条约束打架。注意Pmin和Pmax的赋值不能搞反,这个错误我见过不止一次。
5.4 时间戳没有对齐:跨天数据排错的常见坑
现象:预处理后画图,负荷曲线在每天交界处出现断崖式跳变,或者某些时段的出力计算出来是负数。原因:原始数据的时间列是字符串格式,排序时按字母序排,导致“2024-06-01 08:00:00”排在“2024-06-01 07:00:00”前面,整个序列错位。解决:读入时间列后立即执行pd.to_datetime,然后强制sort_values,排序后再检查首尾时间是否连续。这个坑特别隐蔽,因为大部分数据看起来还是“有序”的,只有画图才能暴露。
5.5 可视化误导:自己给自己造成困惑
现象:画出的调度甘特图负荷曲线看着完美,但团队内部对结果理解不一致,有人觉得“曲线光滑过头了不真实”。原因:可视化代码里用了平滑插值或默认的线条填充,掩盖了实际离散采样点的信息;或者坐标轴范围设得不合理,把异常波动压平了。解决:画结果图一律用散点加折线,不额外平滑;坐标轴刻度和范围固定,不要用自动缩放;给每张图标注单位。可视化是为了让评委快速抓住你的方法,而不是制造解释不清的视觉误差。
6. 验证与进阶:灵敏度分析让模型站得住脚
B题拿奖和拿高分的分界线,往往在于有没有做验证。最容易被忽略但性价比最高的验证手段是灵敏度分析:把关键参数从基准值上下浮动10%~20%,重新求解,观察目标函数值的变化方向和幅度。这一步直接回应了评委最爱问的“你这个结果对参数敏感吗”。
代码很简单,基于第4章的模型,把demand整体放大1.1倍,再跑一次milp,对比前后总成本。我习惯做成参数扫描,记录每个场景下的目标值和决策变量,整理成一张表:
scale_list = [0.8, 0.9, 1.0, 1.1, 1.2] cost_list = [] for scale in scale_list: demand_scaled = demand * scale # 重新拼约束矩阵,此处省略与第4章相同的装配代码 # res = milp(...) cost_list.append(res.fun) print("不同负荷水平下的最优成本:") for s, c in zip(scale_list, cost_list): print(f" 负荷倍数 {s:.1f} -> 成本 {c:.2f} 元")如果成本随负荷近似线性增长,说明模型行为符合物理直觉;如果某一段出现跳跃,大概率是某个机组在临界点启停,这个现象反而值得写进论文,作为结论的支撑。除了灵敏度分析,我还有个多年养成的习惯:深夜写好代码,第二天早上重新读一遍自己的约束清单再提交,因为熬夜时最容易犯“以为写了但实际没写”的低级错误。这不是客套话,是真实成本换来的习惯。希望这篇拆解能帮你在赛程里少走几个弯路,把时间花在真正拉分的地方。
本文还有配套的精品资源,点击获取