这篇博文我打算从“复现者在论文之外最需要知道的几件事”切入。市面上的EI论文讲解大多只停留在模型公式和结论,真正决定成败的是博弈结构怎么搭、多级市场时序怎么对齐、Matlab里迭代求解怎么稳住。这篇就直接围绕这些展开,把我复现过程中的思路、取舍和踩坑都摊开讲。
1. 为什么售电商的套餐设计必须引入主从博弈
先说一个很多刚接触这个方向的人都会问的问题:零售套餐的定价,直接做优化不就行了吗?为什么非得上主从博弈?我最初也有这个疑惑,直到我真正把用户侧的响应行为加进模型之后,才明白单纯优化和博弈建模之间的本质区别。
售电商这个角色很特殊,它夹在两头中间。一头是批发侧的电力市场,不管是中长期合约、日前市场还是实时平衡市场,购电价格基本不受单个售电商控制,它是市场出清的结果;另一头是零售侧的用户,他们手里的选择权反而越来越大——分时电价、阶梯套餐、定制化方案越来越普及,用户会根据电价调整自己的用电行为。也就是说,售电商的收益不是由一个决策问题决定的,而是由“售电商定价”和“用户响应”两个决策主体之间的交互共同决定的。
这种“你先出价、我后响应”的结构,天然就是主从博弈(Stackelberg Game)的标准应用场景。售电商是领导者(Leader),它先制定零售套餐和价格;用户是跟随者(Follower),在给定套餐下做出自己的用电决策。这里的关键在于:售电商在做决策时不能假设用户需求是固定不变的,必须把用户的理性响应纳入自己的优化里。这跟传统的单层优化模型有本质区别——单层优化里需求曲线是外生给定的,主从博弈里它变成了内生变量。
我在建模时把这个问题抽象成了三个要素。参与者方面是售电商-用户两级;策略空间方面是售电商的套餐定价变量,用户的用电量调整变量;收益函数方面是售电商的利润函数,用户的用电效用函数。这套抽象看起来简单,但后续所有Matlab实现都是围绕这个框架展开的。如果你在复现时发现自己写的模型怎么也说不通“用户为什么会在这个套餐下选择这个用电量”,那大概率是博弈层级没有理清楚。
2. 多级市场购电时序与决策变量设计:先买还是后买,直接影响套餐定价
售电商的购电策略不是一次性行为。电力市场是分时间尺度出清的,这段时间上的配合关系,恰恰是套餐设计能否落地的关键约束。
完整的购电链路通常是这样的。第一层是中长期合约市场,交易周期以月、季度甚至年为尺度,价格相对稳定,作用是锁定基础电量、规避现货价格波动风险。第二层是日前市场,提前一天申报第二天的购电曲线,市场按小时或更细粒度统一出清,价格随时间变化。第三层是实时平衡市场,在实际运行前很短的时间内出清,用于平衡预测误差和突发情况,价格波动最剧烈。
售电商的整体购电成本就是这三层采购之和。我不建议一上来就把三层全部建模进去,否则你的Matlab程序会因为维度爆炸而很难调试。更合理的做法是先做两层——中长期合约加日前市场,先把主从博弈的逻辑跑通,再扩展实时层。
在我复现的这篇论文里,零售套餐包含两个维度:一个是分时电价套餐,不同时段价格不同;另一个是阶梯用电套餐,用电量超过某个阈值后价格进入下一个阶梯。这两个维度各自有对应的用户响应机制。分时价格刺激用户在时间维度上转移用电,阶梯价格刺激用户控制总用电量。有意思的是,两种套餐同时存在时,售电商的决策变量不是简单地叠加,而是要考虑它们之间的交互——比如分时价格如果定得太高,用户可能直接缩减总用电量,而这一步缩减会同时影响阶梯收入。
决策变量因此分成了两层。上层变量是售电商的套餐价格向量,以及各市场购电量分配;下层变量是各类用户的用电量,包括分时用电量和总用电量的调整。这个分层结构我建议在写代码之前就用矩阵维度把它固定下来。比如套餐有24个时段的电价,再加上3个阶梯阈值和3个阶梯价格,上层变量大概是30维左右;用户群体按典型用电模式聚成若干类,每类用户在24个时段都有决策变量,下层变量就是24乘以用户类别数。
在程序里,我通常用一个结构体数组来管理这些变量,而不是一股脑塞进一个大向量里。这样后面做灵敏度分析和结果可视化时,取数据会特别方便。预处理阶段先把时段、用户类别、套餐档位这些索引全部建立好,后面所有矩阵运算都用索引对齐,就不会出现“数据维度对不上”这种低级错误。
3. 主从博弈模型的核心拆解:上层利润与下层效用的相互作用
模型这块是整个复现最需要花时间吃透的部分。很多人Matlab代码写得溜,但模型一复杂就开始蒙,根本原因是没有把上下层各自的优化逻辑和它们之间的耦合关系讲清楚。
上层的售电商利润函数,我拆成了四个部分。第一是零售收入,也就是向用户卖电的收入;第二是中长期市场购电成本;第三是日前市场购电成本;第四是实时市场平衡成本或收益。其中零售收入是套餐价格乘以用户用电量的乘积,而这个用电量恰恰是下层用户的决策结果,这就构成了上下层之间的第一层耦合。
零售收入的核心在于:套餐价格越高,单位利润越大,但用户会减少用电,导致售电量下降;价格低则反之。这个此消彼长的关系决定了上层优化不是简单求极值,而是找一个均衡点。
下层的用户决策,我采用的是效用最大化框架。每类用户有一个用电效用函数,这个函数通常是凹函数,意味着用户不会无限增加用电量,边际效用是递减的。给定售电商的套餐价格之后,用户会选择一个用电量水平,使得用电效用减去电费支出的净收益最大化。
这个净收益最大化问题的一阶条件很关键。用户在某个时段的用电决策满足“边际效用等于该时段的边际电价”这个条件。对于阶梯套餐,情况复杂一些,因为总用电量跨越阶梯阈值时,边际成本会突变。这时候需要用分段线性函数来处理电费函数,并引入0-1变量来标识用户落在哪个阶梯区间。
上下层的交互过程在数值上可以这样理解:给定上层价格,下层用户在Matlab里通过求解一个带约束的二次规划就能得到最优用电量;把用户的最优响应返回给上层,上层通过迭代调整价格,直到双方的策略不再变化——这个不动点就是Stackelberg均衡。
求解方法上,文献里常见的有两类。一类是KKT条件转换法,把下层问题用KKT条件替换进上层问题,再把互补松弛条件用大M法或罚函数法线性化,最后整体变成一个单层的混合整数规划。另一类是迭代求解法,比如差分进化、粒子群加下层规划嵌套求解,每一轮迭代都重新计算用户的最优响应。我复现时首选了KKT转换路线,因为它能借助CPLEX这类商业求解器,收敛稳定性远好于启发式迭代。
不过KKT路线有个前提,就是下层的用户问题必须是凸优化问题。这一点在论文里通常会满足——效用函数取二次凹函数,约束是线性的。但如果你的套餐模型引入了非线性阶梯结构,就需要先做线性化处理,否则KKT条件的强对偶性不成立,模型结果会有偏差。
4. Matlab实现的关键模块与数据结构设计
代码架构这件事,我是在经历了一次彻底推翻重写之后才想明白的。第一次写的时候,我图省事把所有代码堆在一个脚本里,结果光是对着输出结果查公式就查了三天。后来老老实实拆成模块,每个模块负责一件事,调试效率完全不一样。
我建议你按这样的模块拆分来实现。
数据定义模块:集中定义所有常量、参数、时段集合、用户类别。比如用户数、分时时段数、阶梯档位数量、市场参数、负荷基准值。所有标幺值换算在这里统一完成,后面所有模块引用同一个参数结构体。
上层模型构建模块:负责构建售电商利润表达式,定义套餐价格变量的上下界、市场购电量的平衡约束。这里要特别注意:购电量平衡约束要写成等式的形式,即总购电量等于总售电量加上网损,否则模型会出现电力不平衡,结果明显失真。
下层模型构建模块:负责定义每类用户的用电效用函数和电费支付函数,生成用户侧约束。如果是用YALMIP建模求解,这里就是把约束和目标函数二元组表达出来的地方。
KKT拼接与求解模块:把下层问题的KKT条件通过YALMIP写入上层优化问题,同时把互补松弛条件用大M法或逻辑约束改写,最后指派CPLEX/Gurobi求解。这个模块是整套代码的灵魂,也是出错率最高的地方。
结果解析模块:把求解结果从求解器返回的变量里提取出来,还原成套餐价格、各时段购电量、用户响应电量等物理量,并做数据检验。
我实际操作时用的是YALMIP加CPLEX的组合。YALMIP的好处是建模层非常友好,尤其适合快速验证模型逻辑;CPLEX负责处理后半部分的混合整数线性规划求解。如果你用的是Matlab R2023b或更高版本,需要注意CPLEX和YALMIP的版本兼容性,建议用CPLEX 12.10以上版本,亲测跟Matlab 2022b以上的适配问题较少。
底层数据结构上,我强烈建议以“类”的思维来组织代码——不一定要真的用Matlab的classdef,但要把相关的变量集合成结构体。比如params结构体放所有系统参数,user_data结构体放用户用电基线、效用系数、弹性参数,market_data放各市场价格曲线。这样做的最大好处是:当你需要跑上百组参数做灵敏度分析时,只要循环里改对应结构体的字段即可,不会出现全局变量满天飞的恐怖局面。
求解配置方面,有几个坑值得提前说。第一,MIPGap不要默认,我一般设到0.01%到0.1%之间。太松会导致结果波动大,太紧会让复杂算例算到天荒地老。第二,互补松弛条件的大M值要根据电价和用电量的量纲来标定,我在一组典型参数下用M=1e6得到了稳定结果,但换了一组数据后就出现了数值病态,后来改成分段标定才解决。第三,求解器输出的dual variable处理要小心,YALMIP里获取对偶变量的方式有特定语法,写错会静默给错误结果,最好打印出来手动核验一两个约束的互补性。
5. 多级市场购电策略如何与零售套餐联动
前面讲的模型结构里,购电策略和零售套餐是两个独立的决策块,但实际上它们必须联动优化。这是我复现过程中反复体会到的。
先说为什么必须联动。梯级套餐的定价上限,理论上可以定得很高,但如果过高,用户用电量下降,总购电量减少,中长期市场锁定的大电量就变成负担——你签了合约就必须履约,用不完就要在实时市场贱卖,反而是亏损。反过来,如果零售价格定得过低,虽然能刺激用户用电,但购电侧成本可能超过零售收入,依然会亏损。所以套餐价格和购电比例本质上是同一个决策坐标系下的两个维度。
我在模型里把这种联动通过购电量平衡约束和套利激励约束来体现。购电量平衡约束要求总购电量严格等于所有用户的销售电量之和,这个约束在KKT拼接后依然要精确满足;套利激励约束则是把中长期市场与日前市场的价差信号传导到零售套餐定价里——比如日前市场高峰时段预测价格很高,那么零售分时套餐的高峰时段价格就应该相应上浮,但这个上浮幅度不是直接复制日前价格,而是经过用户响应弹性折算后的值。
这里有一个很有操作感的细节:市场价差矩阵的设计。我在数据处理阶段,把中长期市场的分时段价格、日前市场的价格预测、实时市场的历史波动区间整理成三个矩阵,然后计算各层之间的期望价差与波动率。主从博弈模型里的购电决策,实际上就是在这三个矩阵的约束下动态分配购电比例。如果价差为正且稳定,就倾向于增加中长期锁定比例,反之则增加现货比例。这个逻辑看起来像常识,但在建模时要把它转化为数学表达式,不能拍脑袋定比例。
联动求解时,我遇到过一个问题:用户响应后的用电量曲线和市场价格的峰值时段发生错位。比如用户把空调负荷转移到夜间谷时段,导致谷时段购电量上升、峰时段购电量下降,整体购电成本反而优化了。但如果谷时段的现货价格并没有预期那么低,这种转移就会导致成本恶化。这说明,套餐定价必须放在真实的购电成本曲线上来计算,不能脱离市场数据单独设计。
我这边的做法是,在主循环里先给定一组初始套餐价格,求解购电策略,然后根据购电侧的边际成本曲线重新调整套餐价格,再次求解用户响应,如此迭代。这种迭代机制在主从博弈的框架下是可以收敛到均衡的,但它对价格调整步长很敏感。步长太大,振荡;步长太小,收敛慢。我最终用了一个自适应步长:前后两轮的套餐价格差超过阈值时缩短步长,稳定时拉大步长,效果不错。
如果你直接套用论文里的参数,可能一次就收敛了。但换了实际数据,比如某个省份的真实负荷和市场价格,就必须处理量纲和标幺化问题。我的建议是,所有价格型参数统一标幺后再进入模型,基值选为基准时段的平均购电成本。这样既避免数值病态,又方便对结果做直观解读。
6. 复现过程中最典型的三个坑与排查链路
代码跑不出预期结果,是复现论文的日常。这里我挑三个我在这个项目里真正踩过的坑,每个都附带完整的排查链路,希望你能少走几周弯路。
坑一:下层用户问题的KKT条件对偶变量方向反了。这是一个非常隐蔽的错误。下层问题是用户最大化净效用,约束条件是“用电量大于等于0”和“阶梯用电量限制”。当我手动推导KKT条件并写入YALMIP时,把不等式约束的对偶变量符号写反了。表面上看,目标函数值变化不大,但套餐价格的解严重偏离论文结论。排查链路是这样的:我先打印所有约束的互补松弛残差,发现有一个约束对偶变量始终为零但约束取等号,于是回溯到该约束的一阶条件,重新推了一遍符号,才发现是拉格朗日乘子的符号约定问题。这个坑提醒我:写入求解器之前,先用手算一个两时段两用户的小算例验证对偶变量,比直接跑完整算例再猜错因靠谱得多。
坑二:互补松弛条件的大M法数值病态。在用大M法处理互补条件时,M值太大,CPLEX会报告numerical trouble;M值太小,某些本应成立的互补条件被错误割断。我在一组参数下运行正常,换一组就出现“整数无解”。排查后发现,问题出在M值与市场电价量纲不匹配。我的做法是:将M值设为电价量纲的100倍左右,同时使用分段线性约束而非单一的大M表达式。如果你用的是YALMIP,可以考虑用implies来写逻辑约束,让求解器内部处理这个大M,比手动指定更鲁棒。
坑三:多级市场购电量的比例约束写成了不等式。这个错误更基础,但危害很大。购电比例约束如果写成小于等于,模型会“聪明地”只买最低价市场的电,导致零售套餐价格异常。我排查时发现,各市场购电量之和小于总售电量,那么多余电量凭空消失,模型当然失真。确认约束类型后,我把所有市场的购电量总和改成等式约束,并加了各市场购电量占比的上下限约束,整个模型才回到正轨。这里要提醒的是:等式约束对KKT转换影响很大,必须确保它们在拼接后依然严格成立,否则强对偶条件会被破坏。
除了这三个坑,还有一类问题是结果数值合理但物理上荒谬。比如某个时段的用户响应电量是负值。这类问题基本都出在用户效用函数参数设置上——二次函数的一次项系数太低,导致边际效用长期低于电价,用户的最优用电量变成零甚至负值。处理方式很简单:对用户用电量加一个宽松的下限约束,并检查效用参数是否符合物理直觉。
复现这类论文,我的总体经验是:不要奢望一次性把完整代码写完再调试,那是效率最低的方式。先把模型降到“单层优化+固定用户需求”,验证购电策略模块的正确性,再引入用户响应,最后叠加主从博弈的KKT转换。每加一层,都保留上一层的输出结果做对照。这样出问题时,你能立刻定位到是哪一层引入的错误。
7. 一个实用的小技巧:用灵敏度分析验证博弈均衡的唯一性
主从博弈模型有一个绕不开的理论问题——均衡是否唯一。很多论文默认存在唯一均衡,但实际模型里,由于套餐变量的非线性耦合,可能出现多均衡或者无均衡的情况。这时候,直接在代码层面验证就非常重要。
一个我跟同行交流后反复使用的方法是,对关键套餐价格变量做网格扫描。具体操作是:把某个时段的零售电价从下限到上限均匀取20个点,对每个点固定这个变量,求解完整的KKT拼接模型,看对应时段的目标函数值和用户响应电量变化是否平滑。如果出现跳跃或不连续,说明模型存在多个局部均衡点,需要检查模型线性化是否引入伪解。
另一个更直接的做法是改变初始值。用两组差异较大的初始价格向量分别启动迭代求解,对比最终收敛的均衡点是否一致。如果一致,大概率是唯一均衡;如果不一致,模型就可能存在多均衡,这时需要在论文里说明选取哪个均衡的准则,比如选择社会总福利最大的均衡。
我在这个项目的实际算例中,稳定性结果是让人满意的——不同初始值最终都收敛到了同一组零售套餐价格和购电分配方案。这很大程度上得益于下层问题严格的凸性,以及上层目标函数在有效约束区间内的拟凹性。如果你在复现时遇到多个均衡,先回去检查套餐模型的线性化处理是否引入了非凸分段,这是最常见的原因。
另外,灵敏度分析本身也是论文里很好的补充素材。把几个核心参数——比如中长期的锁定比例、用户的价格弹性系数、阶梯阈值——逐一做变化,观察零售套餐价格和购电策略的响应方向,如果能跟经济学直觉对得上,那模型的可靠性就更有说服力。写论文或做项目汇报时,这种分析图是审稿人和领导都很买账的。
8. 从代码实现到结果复现:最后的验证闭环
代码全部跑通之后,还有一个必须做的环节,就是把模型输出的数值还原成物理量,验证它们是否满足所有约束,并且与论文的算例结果规律一致。这一步被很多人忽略,但它恰恰是“复现”两个字的意义所在。
我习惯在结果解析模块里加一个自动校验函数,逐项检查以下内容:第一,所有时段的购电量之和是否等于销售电量之和,误差允许在1e-8之内;第二,每个用户的最优用电量是否在允许区间内,且满足阶梯套餐的0-1变量约束;第三,KKT残差是否足够小,这可以通过“用户边际效用减去边际电价”来衡量;第四,售电商的利润是否大于零,如果利润为负,即使模型收敛,参数设置也一定有问题。
校验通过之后,再关注结果规律与论文是否一致。比如,分时套餐价格的峰谷比通常应该大于日前市场价格的峰谷比,这反映了售电商对用户响应不确定性的风险溢价。如果这个比例严重偏离论文值,可能是用户弹性参数设置不合理。我在复现时,论文里的用户弹性参数是0.3到0.5之间,我在实际参数标定时取了0.4,得到的峰谷比与论文结果在一个量级,差异主要来自负荷基线数据不同,这属于正常范围。
最后,把这个闭环做成一个标准化的主运行脚本,输入是市场和用户参数结构体,输出是结果结构体和校验报告。这样不管以后换什么数据集,都能一套流程跑完,也方便在同一个框架下扩展新的套餐类型或多级市场成员。到了这个时候,整个复现工作才算真正闭环。我自己的体会是,复现论文本质上不是比谁的代码高级,而是比谁把每个环节的“为什么”想得更透。模型结构、数据、代码三者对齐,结果自然就出来了。