刚看到这个题目的时候我脑海里其实冒出来两个相冲突的念头一个是主从博弈加售电商零售套餐加多级市场购电这套组合确实是电力市场方向复现率很高的经典题目做出来既有模型深度又有工程味另一个是真打开这种论文准备复现时往往会被一大堆同一个字母加上不同上标下标定义的变量绕得晕头转向。我这次以这个标题为任务做了一次完整复现把从模型拆解到Matlab落地的全过程记录在下面。先说清楚这篇博文不是论文复述而是一份复现地图我会按照“问题背景—博弈结构—套餐建模—多级市场—代码实现—踩坑记录”的顺序把标题里每一个关键词如何变成数学式、再变成可运行代码讲透。如果你正在做电力市场方向的课题或者急着要复现一篇博弈类优化论文这篇文章应该能帮你省下一个星期的试错时间。尤其是那些看到“主从博弈”四个字就开始头皮发麻的同学我建议你先深呼吸这个模型没有想象中那么高不可攀。1. 这篇论文到底在算什么——售电商的三层钱账1.1 用一句“人话”概括整个模型把所有数学符号都剥掉之后这个模型本质上回答了一个非常朴素的问题一个售电公司手里有一批用户它要从批发市场买电再卖给这些用户那么它应该如何设计零售套餐、如何安排购电计划才能让利润最大这里有个关键前提售电商不是单纯的价格接受者。它可以主动定套餐价格用户看到价格之后决定用多少电。用户对价格的反应多用电还是少用电反过来又会影响售电商需要采购的电量。所以“套餐设计”和“购电策略”这两件事必须放到同一个优化框架里求解而不能分开拍脑袋定。我复现的时候给这个模型起了一个外号叫“三层钱账”最底层是用户的用电行为中间层是售电公司的零售收入最顶层是批发市场的购电成本。三层之间互相拉扯零售价定高了用户用电量掉下去零售收入可能不升反降购电买少了到了实时市场发现负荷超出预期只能高价补电。理解了这三层钱的流向后面所有公式都只是给这三层关系穿上数学外套。1.2 三个参与层各自的“算盘”我在搭模型之前习惯先画一张角色表明确每一层到底在优化什么、手里有什么变量。层级扮演的角色决策内容核心目标用户层电力消费者各时段用电量、挑选套餐类型用电效用扣除电费后最大售电层中间零售商套餐定价、套餐结构参数零售收入减全部购电成本后最大市场层批发市场出清方决定中长期合同电量、日前购电曲线、实时平衡量价格响应售电商按市场规则结算这张表看起来简单但它决定了后面Matlab代码里变量的归属。我见过不少人复现这种论文时写到一半就乱了原因就是没分清哪个变量是上层的、哪个变量是下层的最后把用户负荷当成售电商的直接决策变量来优化整个问题的意义就走偏了。1.3 为什么这种问题天然是主从博弈主从博弈Stackelberg博弈描述的场景简单说就是“一个人先出牌其他人看了牌之后做自己的最优反应”。售电公司先公布套餐价格和购电计划用户再根据这个价格决定自己用多少电这个过程就是一个标准的领导者-跟随者结构。它不是一般意义上的“同时决策”博弈。用户面对套餐价格没有任何议价权只能在给定价格下做最优响应但售电公司做决策时必须把用户的这种响应行为纳入预期。用数学语言说售电公司的问题是上层问题leader problem用户的问题是下层问题follower problem这两层问题互相嵌套所以叫“主从”。复现到这一步时我一度觉得很难但后来发现一个非常关键的简化点下层用户问题通常是连续凸优化所以它的最优性条件可以直接用KKT条件刻画。这样一来我不需要真的在求解过程中反复迭代上下层而是把用户的KKT条件作为一组约束塞进上层问题一次性求出均衡解。这个思路我在第二章会详细展开。2. 主从博弈的数学骨架上层定规则、下层做响应2.1 上层问题售电商利润最大化的三层结构上层问题的目标函数看起来长其实拆开就是收入减成本。零售收入这一项等于每个套餐的单价乘以对应用户群的用电量再对所有时段求和。购电成本这一项则需要区分市场层级签了中长期合同的部分对应合同电价日前市场的部分对应日前出清价实时偏差部分则对应实时价格。很多时候模型还要加上一个风险项因为购电成本不是完全确定的价格波动意味着利润波动没法只盯着期望值做决策。目标函数里最容易忽略的是“零售收入”和“购电量”之间的隐含关系。用户用电量受到套餐价格影响而购电量又必须覆盖用户的总用电量。换句话说上层如果想把购电成本压低它可以减少购电但减少的这部分必须对应到用户用电的下降否则就要到实时市场高价买电来平衡。这个约束是连接上下层的生命线代码里写漏了整个模型就会得到一些看似利润很高、实际无法落地的解。2.2 下层问题用户效用最大化的展开用户侧的目标函数通常是“用电效用减去电费支出”。用电效用的常见写法是二次函数形式U(q) a·q - b·q²其中q是该时段用电量a和b是反映用户用电偏好和饱和程度的参数。这个形式的妙处在于它天然是凹函数最大化问题具有良好的凸性后面用KKT条件替换时一点毛病都没有。电费支出则需要根据套餐规则计算。如果套餐是单一电价那很简单单价乘以用电量如果是峰谷分时电价就要按时段分别计价如果是阶梯套餐还要分段累进。我复现的时候最痛苦的就是套餐规则和效用函数写在一起后的表达式变得又长又绕所以强烈建议在Matlab里用数组索引来管理这些项比如把电价写成 T×1 的向量用电量写成 T×1 的向量电费就是向量内积。千万别把一段时间内每个时段的电价穷举成十几个散落的变量。2.3 为什么可以用KKT条件把下层问题“吃掉”这是整篇复现里最核心的一步操作。在一个双层优化问题里直接求解是很麻烦的因为你必须反复回答给定上层参数下层最优是什么但如果下层问题是凸优化那么下层的最优解等价于它满足KKT条件也就是“目标函数梯度为零、约束满足、互补松弛成立”那一整套条件。于是我可以把下层用户的KKT条件当作一组等式和不等式约束完整地拼到上层问题的约束集中。这样原本的两层问题就变成了一个单层问题也就是带均衡约束的数学规划MPEC。求解器在处理单层约束优化时工具链成熟得多Gurobi这类商用求解器也能直接发挥威力。这一步唯一的代价是模型规模膨胀。每个用户、每个时段的决策变量都会引入对应的对偶变量互补松弛条件又会带来非线性项。但相比上下层反复迭代的不稳定这个代价完全可以接受。我第一次把KKT条件拼进上层跑通时整个人都松了一口气因为终于不用再担心迭代振荡的问题了。2.4 模型规模膨胀是必然的别慌以96个时段、3类用户、3个市场层级为例上层的套餐变量大约有几百个下层的用户决策变量和KKT对偶变量加在一起可能有上千个。看到这个数量级先别慌线性规划和二次规划在这个规模下对商用求解器来说只是“热身运动”。真正需要留意的不是变量数量而是约束之间的数值尺度。比如套餐电价可能是零点几元/千瓦时而用户一个月用电量是几千千瓦时两者相乘得到的是几千这个量级。如果我们把目标函数的各项直接在约束里混用求解器内部的数值稳定性和预处理都会受到影响。后面我会专门讲数值尺度处理这是复现成败的隐形关键。3. 多元零售套餐怎么用一张套餐表影响用户行为3.1 单一电价为什么不够用很多刚接触电力市场的人会问一个电字一个价大家按用电量交钱不就行了吗为什么要搞套餐答案是如果只卖单一电价售电商完全没办法区分不同类型的用户。有些用户对电价很敏感电价稍微涨一点就大幅减少用电另一些用户弹性很小电价涨了也照样用。所有用户都面对同一个单价时售电商只能在“调高电价多赚利润但流失电量”和“调低电价保住电量但压缩毛利”之间二选一非常被动。套餐的本质其实是“电价结构设计”。它把单一的用电单价拆成多个维度有固定月费有不同时段的电价差有阶梯区间的价格变化。这样一来不同用电习惯的用户会被不同的套餐吸引售电商就能用一套组合拳同时服务多类用户而不是用一个价格硬扛所有人。这个概念有点像运营商的手机流量套餐有人追求通话时长有人只看流量多少套餐分档才能让每个用户都觉得自己选到了最划算的方案。3.2 套餐设计的四个维度我复现的时候把套餐参数归纳成四个维度每一维度在模型里都有对应的变量套餐维度模型中的表达作用固定月费一个标量变量覆盖基础成本用户选择套餐的“入场费”分时电价T×1维向量峰谷平各时段的单价引导用户避峰阶梯阈值一维或多维变量定义用电量区间用于分段定价浮动系数与用电量挂钩的比例参数实现用量越大单价越优惠或越贵的结构表格里的每个维度都不是孤立的它们组合起来才能构成一个用户真正会选择的套餐。比如“固定月费高、分时电价低”的套餐适合用电量大的用户“固定月费低、分时电价高”的套餐则适合用电量小、用电时间灵活的用户。我在Matlab里用结构体数组来保存每个套餐的参数索引清晰后续迭代套餐数量时也不会把变量下标搞乱。3.3 用户的选择和负荷响应如何被数学化用户面对多个套餐时会选择给自己净效用最大的那个。这里的“净效用”可以写成用电效用减去该套餐下的电费支出。每个用户对每个套餐都能算出一个最优用电水平和对应的最大净效用然后比较这些净效用选出最大值对应的套餐。实际操作中很多复现代码并不显式处理“用户选套餐”这个离散问题而是引入类型化用户和分流系数的概念把用户按用电特征分成若干类每一类用户根据套餐参数的取值按比例分流到不同套餐。这个分流比例可以由Logit模型之类的离散选择模型给出也可以作为负荷响应函数的一部分嵌入KKT体系。我复现的时候采用了一种更稳妥的做法先假设每类用户只考虑最适合自己的那个套餐再在模型里加入套餐吸引力约束限制套餐价格不能出现“一个套餐完全支配另一个套餐”的退化情况。这样既简化了模型又避免了所有用户涌向单一套餐导致的结果失真。3.4 一个朴素但有用的检查方法三类用户套餐分流验证我建好套餐模型后用三类典型用户做过一次验证白天上班的家庭用户、夜间用电为主的用户、弹性很大的商业用户。白天上班的用户更适合峰谷分时套餐夜间用户更适合夜间低价套餐商业用户则适合固定月费高但电量单价低的套餐。当模型求解出的套餐结构和这三类用户的直觉匹配时我基本就能确认套餐模块的代码没有写错。这个方法听起来太朴素但它真的很管用。很多复现错误不会导致求解失败而是会让模型输出一些“看似数学正确、实际违背常识”的结果。如果我发现夜间用户被分到了白天高价套餐下不用怀疑肯定是哪个目标函数项的正负号写错了。4. 多级市场购电策略中长期锁底、日前精调、实时找平4.1 为什么要同时考虑三个市场电力不能大规模储存所以从“知道要用多少电”到“实际用电”之间存在时间差不同时间尺度上的电力交易就构成了多级市场。售电商在用户还没用电之前就要决定一部分购电量随着预测越来越精确再逐步把购电计划调整到位。为什么不能全部等到最后一刻再买因为离用电时间越近价格不确定性越大实时市场的价格波动往往非常剧烈全部依赖实时购电等于裸奔。为什么不能全部靠中长期合同锁死因为预测总有误差签多了要承担多余电量的成本签少了还要到实时市场补。所以多级市场购电策略的本质是在不同时间尺度的市场之间做风险与收益的权衡。4.2 三个市场的时间尺度与模型角色市场层级交易提前量结算价格模型中的决策变量风险特征中长期市场数周至数月双边协商或合同价各时段合同电量价格锁定风险低日前市场提前一天日前统一出清价日前申报购电曲线价格随供需波动中等风险实时市场提前数分钟至数小时实时平衡价实时偏差电量价格波动大高风险这三个市场的决策变量加在一起构成售电商的完整购电组合。中长期合同电量是早早就定好的日前购电量在日前市场开市前定好实时偏差电量则在接近真实用电时根据最新负荷预测确定。模型需要同时给出这三组变量而不是只做某一个市场的单独优化。4.3 三个市场之间的物理与时序耦合三个市场不能各自为政。最基本的物理约束是电量平衡各时段的用户总用电量必须等于中长期合同电量、日前购电量和实时偏差电量之和。这个等式约束把三个市场紧紧绑在一起任何一个市场的决策变化都会传导到另外两个市场。时序上也有先后。中长期合同签订最早日前市场在运行日的前一天开展实时市场最后执行。如果模型把三个市场的决策放在同一时刻同时决定其实是对现实的简化。很多EI复现文章都会用随机规划来处理这种时序决策问题把不确定性量化为多个场景每个阶段对应不同的决策变量。我在初始复现时先把模型简化成“已知价格期望”的确定性等价模型所有购电决策同时优化跑通之后才扩展到随机场景这样每一步出问题都能快速定位。4.4 价格不确定性怎么进目标函数购电成本既然是波动的目标函数里就不能只写“成本等于电量乘以固定价格”。常见做法是给价格引入多个场景目标函数改为所有场景下成本的期望值再加上一个风险惩罚项。比如用条件风险价值CVaR来刻画最差情况下的尾部损失让模型在追求平均利润的同时避免出现极端亏损。这里要注意一个细节并不是所有购电决策变量都要带场景下标。中长期合同和日前购电决策在不确定性实现之前就要确定所以它们不带场景下标而实时偏差电量是在看到实时价格后做出的调节所以实时购电量应该带场景下标。这个“决策时机的信息结构”如果不理解代码就会写出一些时序上不合理、求解器却照样能解的模型结果表面漂亮但实际不可用。5. Matlab实现架构从论文公式到可运行代码5.1 先搭数据骨架再写数学式复现这类模型最容易犯的错误是打开编辑器就开始堆变量。我的习惯是先定义四个数据块市场场景数据、套餐参数数据、用户分组数据、求解选项数据。这四个数据块用Matlab结构体保存字段名起得直观一点比如price_spot、base_load、elasticity。后续写约束时直接引用字段代码的可读性和可维护性提升一个档次。数据组织好后再顺手检查所有数组维度。我的经验是90%的复现错误都出在维度对不上。比如套餐电价应该是一个nTariff × T的矩阵结果传参时写成了T × nTariff求解器不会报错但结果完全错乱。最稳妥的办法是在建模脚本开头加一组assert判断凡是维度不匹配第一时间抛错中止。5.2 求解器选择YALMIPGurobi的组合Matlab建模优化问题有两条路线一是用自带的Optimization Toolbox写optimvar和optimconstr二是用YALMIP把约束和目标函数以符号方式组合后调用外部求解器。我强烈建议用YALMIP搭配Gurobi原因只有一个当你的模型里有几百个变量、几十个KKT条件时YALMIP的约束拼接和调试体验远优于裸写optimconstr。尤其对于从没接触过MPEC的同学YALMIP还内置了kkt函数可以直接对下层优化问题生成KKT系统省去手推一堆对偶条件的时间。这个函数不是万能的它要求下层问题是凸的而且某些老版本里对互补松弛条件的处理需要额外小心但作为第一步跑通Demo它非常够用。5.3 KKT拼接的Matlab代码骨架下面这段代码是我复现时整理出的核心骨架。它不会直接在你的机器上跑出结果因为它依赖具体数据规模但结构上完全对应上层决策、下层KKT、约束拼接和求解% 1. 上层变量套餐电价矩阵 (nTariff x T)中长期合同电量 (nContract x 1) tariff sdpvar(nTariff, T, full); p_contract sdpvar(nContract, 1, full); % 2. 下层用户变量每类用户各时段负荷 (nUser x T) load_user sdpvar(nUser, T, full); % 3. 构造下层用户问题以单类用户为例循环可推广 utility a * sum(load_user, 2) - b * sum(load_user.^2, 2); expense sum(tariff_rate .* load_user, 2); % tariff_rate 与套餐价格关联 obj_user utility - expense; constr_user [load_user 0, load_user load_max]; % 4. 用YALMIP生成下层问题的KKT系统 [KKT_sys, details] kkt(constr_user, obj_user, load_user); % 5. 把KKT条件拼进上层约束上层也有自己的变量上下界和电量平衡约束 constraints_all [tariff_lb tariff tariff_ub, ... sum(load_user, 1) p_contract p_dayahead p_real, ... KKT_sys]; % 6. 目标函数售电商利润最大化注意符号YALMIP默认最小化 obj_retailer sum(sum(tariff .* load_user)) - cost_contract - cost_dayahead - cost_real; % 7. 求解 ops sdpsettings(solver, gurobi, verbose, 2); optimize(constraints_all, -obj_retailer, ops);注意代码里tariff .* load_user是一个双线性项我在第六章专门讲它怎么处理。第一版复现时先跑通这个小规模骨架确认每一行的逻辑再逐步扩展到完整维度这是最稳的路径。5.4 求解完别急着画图先做残差诊断很多人的复现流程结束在“优化返回成功”这一步画完图就收工。我强烈建议多做一个验证步骤把求解器返回的套餐价格代回原始用户问题用数值方法重新求解用户的最优负荷看它是不是和整体模型里下层KKT得到的负荷一致。这一步能同时揪出两类问题。第一类是目标函数符号问题如果你想最大化利润却写成了最小化求解器依然会返回“成功”但结果彻底相反。第二类是互补松弛条件写错KKT条件拼错了但求解器不报错结果就是用户子问题没有真正达到最优。残差诊断工具我通常只写十几行Matlab但它的价值超过所有调试日志。6. 复现最容易卡住的三个问题与对应处理思路6.1 双层迭代不收敛优先考虑KKT整体求解初学复现主从博弈时很多人第一反应是用迭代法先给一组套餐价格解用户问题得到负荷再把负荷代回上层重新调整价格如此循环直到收敛。听起来很自然但实际跑起来非常容易振荡电价升高导致用户减少用电负荷下降导致售电商成本下降于是电价又可以降下来用户又恢复用电循环往复永远无法收敛到稳定点。我实际测下来KKT整体求解从根源上消灭了这个问题它不再需要反复调用上下层求解器而是把均衡条件一次性打包交给Gurobi。如果你因为某些原因必须保留迭代法那就给价格更新加一个阻尼系数比如新值等于旧值加0.3倍的候选增量可以有效压制振荡。阻尼系数不能取得太小否则收敛速度慢得要命也不能接近1否则振荡依旧。我一般从0.3开始调。6.2 双线性项处理Big-M线性化与M取值经验目标函数里出现的价格乘以负荷在数学上是双线性项直接交给Gurobi求解非凸二次规划不仅速度慢而且容易陷入局部最优。实际复现中我更常用的是把负荷区间离散化然后引入0-1变量和Big-M线性化。举个最简单的例子假设某时段负荷落在0到1000千瓦之间我把它切成10个档位每个档位对应一个0-1变量和一个固定的负荷取值。价格乘以负荷就近似为价格乘以离散常数再通过大M约束把0-1变量和价格之间的关系表达成线性不等式。档位越多精度越高但变量数量也会膨胀。Big-M里的M取值是这门手艺的玄学。M太小线性化约束会把可行域切掉导致求不出正确解M太大数值矩阵的条件数恶化求解器内部预处理时间暴增甚至报数值错误。我常用的经验是取“变量的物理上界乘以安全系数1.2”比如电价上限1.5元/千瓦时、负荷上限1000千瓦时M就取1800。跑出结果后还要检查所有互补松弛残差确认没有因为M取值过大产生伪解。6.3 模型规模爆炸导致无解或求解缓慢从小规模标定开始KKT拼接后模型变量和约束的数量往往比原始问题大一个量级。如果直接上96时段、上千个用户、几十个场景Gurobi哪怕再强也可能要跑几分钟甚至更久。更大的风险是模型写错时求解器报告“无解”而你面对上百条约束根本不知道哪里出了问题。我的做法是先建一个标定用的微型模型只取3个时段、2类用户、1个中长期合同场景所有参数从完整模型里抽一小块。先把这个微型模型跑通再逐步放大。放大过程中一旦出现无解就二分定位最近加入的那批约束。这个方法可以帮你把“大海捞针”式的排错变成线性排查效率提升显著。此外无解时先检查电量平衡约束是否闭合。上下层之间的连接如果漏掉某一项模型永远不会得到可行解。Gurobi的输出里会给出冲突约束集YALMIP也支持conflict诊断先用这些工具定位而不是对着代码发呆。6.4 多场景下“哪些变量带场景下标”最容易写错扩展到随机场景后售电商的实时购电量应该随着实时价格场景变化所以实时购电变量要带场景下标但中长期合同和日前购电决策发生在场景实现之前所以它们不能带场景下标。很多复现代码把平衡约束和决策时序混淆结果变量全部带上了场景下标模型退化成“每个场景单独优化”完全丢失了多级市场决策的时序价值。我自己的检查方法是每次写完某组变量都停下来问一句“这个变量什么时候决定我是不是设定了它不需要知道的未来信息”这个思维习惯比任何公式都管用。如果你从这一章只记住一件事那就是多级市场的“多级”本质上体现在不同阶段的决策所依赖的信息集不同而不是简单地把三组变量拼在一起。跑完这套模型之后我最大的感受是复现博弈类论文最花时间的往往不是求解器调参而是变量下标和时段索引的对齐。刚开始我习惯直接上手写代码后来改成先在纸上把上标下标画成树状图效率高了不少。另一个很实用的小技巧是先搭一个忽略套餐分流细节的小规模版本验证主从博弈的KKT逻辑正确再补齐所有套餐和场景维度。去年有一次我把用户数量的维度搞反了导致所有套餐价格在最优点全部贴到上界检查了半天才发现是索引错位。从那以后我在建模脚本开头都会用assert检查所有数组尺寸这个习惯救了我很多次。如果你也准备复现这篇论文我建议你从第三章的套餐结构开始搭再一步一步把购电侧的多级市场接进来这样每一步的结果都能对照常识验证踩坑概率会小很多。