我前前后后帮好几个课题小组审过这类题目,凡是带“主从博弈”和“粒子群”两个词的能源系统优化,十有八九都是围绕同一个核心问题:多个决策主体,各自打自己的小算盘,但彼此之间又有上下级的制约关系,怎么在算法上把这层关系解出来。这篇文章我就直接拿“基于粒子群优化算法的三方三层主从博弈能源系统优化模型”当例子,把从模型搭建到Matlab代码实现的过程完整拆开讲一遍,包括我踩过的坑和调参经验。
看完之后,你至少能搞清楚三件事:第一,三方三层主从博弈的数学结构到底怎么落到代码里;第二,粒子群算法在这个模型里承担什么角色,为什么要把它放在内层而不是外层;第三,整个Matlab工程该怎么组织,才能既跑得通、又方便改。文章适合正在做能源系统优化、综合能源调度、电力市场策略方向的研究生,也适合想用智能算法做博弈问题但还没理清思路的工程师。
1. 先把问题的骨架摸清:三方三层到底在优化什么
1.1 主从博弈的本质与能源系统场景
主从博弈,也叫Stackelberg博弈,核心思想就是“leader先出牌,follower看牌后再行动”。这个逻辑放到能源系统里非常贴合现实:能源服务商(售电公司、综合能源运营商)先定电价或者热价,用户看到价格之后再调整自己的用能计划,运营商反过来又根据用户的用能行为去修正定价策略。两层决策层层嵌套,每一层都是独立的优化问题,但目标函数彼此耦合。
比传统单层优化麻烦的地方在于:你不能直接把所有决策变量丢进一个优化器里求解,因为每个主体的目标不一样。运营商想多赚钱,用户想少花钱,两边同时优化得不到一个全局“最优解”,只能找一个博弈均衡点——在均衡点上,任何一方单独改变策略都不能让自己变得更好。
这在数学上对应一类特殊的优化问题:上层是带下层反应函数(或KTT条件)的约束优化。处理方式要么是求下层问题的KKT条件塞进上层约束里(MPEC/EPEC做法),要么就是迭代逼近——先给上层一个策略,下层求解后反馈,每次根据反馈调整上层策略,反复迭代直到收敛。后者对场景规模比较大、下层问题不好解析求导的情况尤其友好,也是粒子群这类元启发式算法的用武之地。
1.2 三层结构如何对应三方利益
“三方”指的是博弈里的三个决策主体,比如:综合能源服务商(上级)、储能运营商或微网运营商(中间层)、终端用户(底层)。“三层”指的是决策嵌套的层级关系,上层定价,中层做容量或调度决策,底层做用能决策。
用我自己做过的项目举例,一个典型的设定是这样:
- 上层(能源服务商):决策变量是向终端用户售电的电价、售热的热价,目标是最小化自身运行成本或最大化利润。它要考虑从上级电网购电的成本、自己运营分布式能源的成本,并预估用户对价格的响应。
- 中层(储能/微网运营商):决策变量是储能的充放电策略、购售电功率,目标是在服务商给的结算价格和用户负荷需求之间做一个最优调度,赚取峰谷价差或者减少自身购电成本。
- 底层(终端用户):决策变量是可转移负荷、可削减负荷的调整量,目标是在电价信号下最小化自己的用能成本,同时保证舒适度约束不能打破。
三层之间的信息流就是“上层定价→中层调度→下层用能”的顺次传递。用户调整完用能计划后,反馈给中间层一个负荷曲线,中间层再反馈给上层一个总购电需求,上层根据总需求调整价格。这个过程循环往复,每一次循环都是一次完整的“三层求解”。
如果你把三层拆开分别建模,会发现每一层单独拿出来都不难,关键是层与层之间的接口怎么设计。接口设计得好,迭代收敛就快;接口设计得抽象,代码改起来也省事。
1.3 为什么偏偏选粒子群
能源系统主从博弈的下层问题还好说,很多时候是线性规划或二次规划,用linprog、quadprog都能解。麻烦的是上层。上层的目标是利润最大化,同时牵涉到价格变量、需求响应约束、设备运行约束,再把下层问题的反应函数叠加上来,整个可行域严重非凸,甚至不连续。传统基于梯度的优化工具在这种问题上容易陷进局部最优,或者根本不收敛。
粒子群优化算法(PSO)做这种事有三个天然优势:第一,它不要求目标函数可导,也不要求约束光滑,跑出什么“脏”目标值都能用;第二,全局搜索能力强,在处理高维非凸问题上比梯度法稳得多;第三,Matlab实现非常方便,三五十行代码就能跑起一个基础的粒子群核心。
当然粒子群也有短板,比如后期容易早熟收敛、速度参数不好调,但这些完全可以通过改进策略来规避。后面的实操部分我会讲怎么加自适应惯性权重、怎么处理越界粒子、怎么在博弈迭代里嵌PSO才不会把时间耗死。
2. 模型设计与参数标定:从数学问题到可计算问题
2.1 数学表达式的设计与互补约束处理
我见过不少同学在模型设计阶段就把自己困住了。一上来就试图把所有主体的目标函数和约束都写进一个大模型里,结果非线性约束、整数变量、互补条件堆在一起,MATLAB根本跑不动,然后跑来问是不是算法不行。实际上思路应该反过来——先是把“谁先动、谁后动、层间传什么”想清楚,再拆分模块化建模。
一个我常用的切入方式是:先把三层主体的目标函数分别写出来。
上层服务商的目标函数大致是:
f_up = sum(price_e .* P_user_e + price_h .* P_user_h) - C_purchase - C_ope;其中price_e和price_h是上层要定的电、热价格向量,P_user_e和P_user_h是用户响应后的负荷向量,C_purchase是向电网购电的成本,C_ope是自己的设备运行维护成本。
中层的调度目标大致是:
f_mid = sum(price_grid .* P_grid) - sum(price_e .* P_user_e) + C_bess;这里C_bess是储能充放电的折旧成本,通过把充放电功率折算成统一的成本系数实现。
底层用户的目标比较简单:
f_low = sum(price_e .* P_user_e + price_h .* P_user_h) + C_discomfort;C_discomfort是用户调整用能计划引起的舒适度损失,可以当成惩罚项。
约束条件方面,最核心的几类是:
- 功率平衡约束:系统内发电/购电功率 = 用户负荷 + 储能充放电 + 网损;
- 储能约束:荷电状态(SOC)上下限、充放电功率上下限、充放电效率、日始末SOC相等;
- 用户舒适度约束:可转移负荷的转移量范围、室内温度允许波动范围;
- 价格约束:价格要落在政府指导价或市场限价区间内。
还有一个常见的坑是互补约束。比如储能不能同时充电和放电,如果直接写P_ch >= 0和P_dis >= 0再加二值变量,在非商业求解器里会拖垮速度。工程上常用三种处理办法:第一种是引入0-1变量(需要求解器支持混合整数);第二种是加小规模罚函数,把“同时充放”的量做成惩罚项加进目标函数;第三种是干脆用P_bess = P_dis - P_ch单变量建模,用效率分段表示,避免同时性。我自己的习惯是:如果储能数量不多,直接引入一个0-1变量,布尔求解在Matlab里用intlinprog就能做;如果储能多到几百个节点规模,那就用惩罚项近似。这个取舍对收敛速度影响很大。
2.2 粒子群参数设置
粒子群算法要设的参数不多,但每一个都直接影响收敛行为。
标准粒子群的核心公式是速度更新和位置更新:
v = w * v + c1 * r1 * (pbest - x) + c2 * r2 * (gbest - x); x = x + v;其中w是惯性权重,c1和c2是学习因子,r1和r2是[0,1]之间的随机数,pbest是粒子个体历史最优,gbest是全局最优。
我调试过几次之后发现,对能源系统这样的高维连续优化问题,一套比较稳的参数配置是:
| 参数 | 推荐取值 | 说明 |
|---|---|---|
| 种群规模 | 30~60 | 状态变量多时往高取,但要平衡耗时 |
| 迭代次数 | 100~300 | 主从博弈外层迭代里内层要重复调用,适当缩小 |
| 惯性权重w | 0.9→0.4线性递减 | 前期全局搜索,后期局部收敛 |
| c1(个体学习因子) | 1.5~2.0 | 越大越依赖个体历史最优 |
| c2(社会学习因子) | 1.5~2.0 | 越大越依赖群体最优 |
| 速度上限 | 变量范围的10%~20% | 防止粒子飞得太离谱 |
一个很容易被忽略的细节是速度初始化。很多初学者直接把v设成全零矩阵,这样前几次迭代粒子几乎不移动,白白浪费代数。更好的做法是用变量范围的比例随机初始化速度,比如取变量可行域宽度的10%。
另一个细节是约束处理。PSO本身不直接处理约束,要么用罚函数把违反约束的量折算进目标值,要么直接对越界的粒子做“位置修正+速度重置”。
我自己更推荐速度重置的做法:当粒子某维越界时,不仅把位置拉回边界,还把该维速度置零。这样粒子不会继续朝边界外冲,迭代稳定性高很多。
2.3 博弈迭代与PSO内层求解的耦合方式
博弈迭代和粒子群不是简单的“一个套一个”关系,具体耦合方式要看你把哪个问题交给PSO。
我常用的做法是:上层服务商的策略(价格)用PSO来寻优,中层和下层用Matlab的优化工具箱(linprog或quadprog)精确求解。每次PSO迭代产生一组价格方案,就调用一次中下层求解模块,得到用户和中间商的最优反应,然后把反应结果带回上层目标函数算出适应度。这个方案的好处是:PSO只负责最不光滑的上层问题,中下层仍然用确定性求解器,速度和稳定性都有保障。
还有一种做法是三层全用PSO,形成“外层PSO套中层PSO再套内层PSO”的俄罗斯套娃结构。说实话,这种做法在论文里写起来漂亮,但实践起来很难收敛,因为内层的随机扰动会不断传递放大,最后外层看到的适应度噪声非常大,粒子根本分不清哪个位置是真正的好位置。如果非要用全PSO结构,内层的迭代次数至少要压到50代以内,而且要固定随机种子,保证每次评价同一方案时结果一致,不然优化完全失去意义。
我个人的经验是:能用精确求解器的地方不要轻易用元启发式。博弈迭代本身已经很耗时了,再层层嵌套随机算法,调试体验会非常糟糕。先跑通简单方案,再逐步考虑增复杂度,比一次性搭大而全的框架靠谱得多。
3. Matlab实现:代码框架与核心环节逐步实现
3.1 主循环架构
一个实际能跑的Matlab工程,我建议按“数据输入——参数初始化——主从迭代——结果输出”四个模块来组织。不要把所有代码堆在一个文件里,否则到后面你会被自己刚写的代码劝退。
我一般这样组织目录:
|-- main.m % 主程序入口 |-- data/ | |-- load_data.m % 加载负荷、价格、设备参数 |-- model/ | |-- upper_objective.m % 上层目标函数 | |-- middle_solve.m % 中层调度求解 | |-- lower_solve.m % 底层需求响应求解 |-- algorithm/ | |-- pso_main.m % 粒子群主程序 | |-- pso_update.m % 粒子速度位置更新 |-- result/ | |-- plot_result.m % 绘图与结果分析主程序的循环逻辑用一个while来实现博弈迭代。
%% main.m 主循环框架 % 初始化 price_iter = price0; % 初始价格方案(上层决策) max_iter = 20; % 主从博弈最大迭代次数 tol = 1e-3; % 收敛精度 for k = 1:max_iter % 1. 给定价格,求解中层调度问题 [P_mid, f_mid] = middle_solve(price_iter, system_data); % 2. 给定价格和中层调度结果,求解底层用户响应用量 [P_low, f_low] = lower_solve(price_iter, system_data); % 3. 把中下层各主体的响应结果返回上层,代入上层目标函数的适应度计算 fitness = upper_objective(price_iter, P_mid, P_low, system_data); % 4. 用PSO更新价格策略 [price_new, swarm_state] = pso_main(swarm_state, system_data); % 5. 收敛判断 if norm(price_new - price_iter) < tol break; end price_iter = price_new; end这里主从博弈叠了个PSO,两个循环清清楚楚,所以不容易把逻辑搞混。max_iter设20次左右的用意是:一般而言主从博弈的定价策略经过十几次迭代就能趋于稳定,如果超过30次还不收敛,大概率是模型参数或者迭代步长设置有问题,跑再多轮也白搭。
3.2 PSO核心代码实战
粒子群主程序我通常写成一个独立的m函数,这样换场景时可以直接复制调用。
function [gbest, gbest_fit, history] = pso_main(fitness_func, dim, lb, ub, opts) % 粒子群优化主函数 % fitness_func: 函数句柄,输入位置向量,输出适应度值 % dim: 决策变量维度 % lb, ub: 变量上下界向量 % opts: 结构体,包含 nP, maxIter, wMax, wMin, c1, c2 nP = opts.nP; % 种群规模 maxIter = opts.maxIter; % 最大迭代次数 wMax = opts.wMax; wMin = opts.wMin; c1 = opts.c1; c2 = opts.c2; % 初始化位置和速度 X = repmat(lb, nP, 1) + rand(nP, dim) .* repmat(ub - lb, nP, 1); V = repmat(lb - ub, nP, 1) .* (0.1 * rand(nP, dim)); % 速度用可行域宽度的10%初始化 % 初始化个体最优和全局最优 pbest = X; pbest_fit = arrayfun(@(i) fitness_func(X(i, :)), 1:nP); gbest = pbest(1, :); gbest_fit = pbest_fit(1); for i = 2:nP if pbest_fit(i) < gbest_fit gbest = pbest(i, :); gbest_fit = pbest_fit(i); end end % 迭代 history = zeros(maxIter, 1); for iter = 1:maxIter w = wMax - (wMax - wMin) * iter / maxIter; % 惯性权重线性递减 for i = 1:nP r1 = rand(1, dim); r2 = rand(1, dim); V(i, :) = w * V(i, :) + c1 * r1 .* (pbest(i, :) - X(i, :)) + c2 * r2 .* (gbest - X(i, :)); % 速度限幅 Vmax = 0.2 * (ub - lb); V(i, :) = max(min(V(i, :), Vmax), -Vmax); % 位置更新 X(i, :) = X(i, :) + V(i, :); % 越界处理:拉回边界,速度置零 for d = 1:dim if X(i, d) < lb(d) || X(i, d) > ub(d) X(i, d) = min(max(X(i, d), lb(d)), ub(d)); V(i, d) = 0; end end % 适应度评价 fit = fitness_func(X(i, :)); if fit < pbest_fit(i) pbest_fit(i) = fit; pbest(i, :) = X(i, :); if fit < gbest_fit gbest_fit = fit; gbest = X(i, :); end end end history(iter) = gbest_fit; end end这段代码有几个值得拎出来细讲的点:
一是V的初始化不要用全零。全零初速度会让前几轮粒子只在原地小幅震荡,搜索效率极低。用可行域宽度的10%作为初始速度区间,能让粒子一开始就有足够的探索动能。
二是“越界拉回+速度置零”。很多新手抄PSO代码时直接让越界粒子的位置等于边界值,但速度不处理,结果粒子下一轮更新时又冲出边界,在边界来回弹跳。把越界维度的速度置零后,粒子被“摁”在边界上,需要靠其他维度的社会/个体学习项把它带回来,稳定性好很多。
三是适应度函数统一写成最小化形式。上层服务商的利润最大化和目标函数要加负号,或者整体取倒数,否则粒子群会朝适应度最大的方向飞,和代码逻辑冲突。
3.3 中下层求解模块与目标函数衔接
中层的储能调度问题,如果规模不大,直接用linprog就行。以典型的24小时调度问题为例,决策变量是每个小时的储能充放电功率,维度48(如果充电、放电分开两个变量)或者24(用净功率单变量建模)。
function [P_bess, f_mid, exitflag] = middle_solve(price_iter, data) % 中层储能调度:min C_grid * P_grid - revenue + C_bess % 采用净功率建模,P_bess > 0 表示放电,P_bess < 0 表示充电 T = data.T; % 24小时 P_load = data.P_load; % 用户预测负荷 % 决策变量: [P_bess(1:T), SOC(1:T)] nVar = 2 * T; % 目标函数系数向量 f = [zeros(1, T), zeros(1, T)]; % 具体系数根据场景调整 % 等式约束:功率平衡 Aeq = [eye(T), zeros(T, T)]; beq = P_load - price_iter .* 0; % 等等,这里要根据场景修改,占位方便说明 % 边界条件 lb = [-data.P_bess_max * ones(1, T), 0.2 * ones(1, T)]; ub = [data.P_bess_max * ones(1, T), 0.9 * ones(1, T)]; options = optimoptions('linprog', 'Display', 'off'); [x, f_mid, exitflag] = linprog(f, A, b, Aeq, beq, lb, ub, options); if exitflag < 0 error('中层求解失败,检查约束是否矛盾'); end P_bess = x(1:T); end底层用户需求响应部分同理,根据可转移负荷和可削减负荷的比例,构建线性规划。如果引入舒适度温度约束,可能需要用到二次规划(quadprog),因为用户的舒适度损失函数写成二次形式更自然。
中下层写完以后,最要紧的一步是用测试数据单测。我见过太多人直接把三层代码串起来一跑就是几个小时,结果最后发现中层功率平衡约束写反了符号,全部结果都是错的。单层测试时,固定另外两层传入的数据,检查这层求解出来的变量是否物理上合理,花不了多少时间,但能省下大量调试时间。
4. 常见问题与排查实录
4.1 粒子群早熟收敛,结果明显不是最优解
这个大概是使用PSO过程里最常碰到的事。能源系统优化问题往往决策变量多,如果所有维度的搜索策略都一样,很快就全体朝某个局部最优靠拢,粒子多样性丧失。
我自己的处理思路是:先把线性递减惯性权重的范围拉开,比如从0.95降到0.35;同时把c1和c2设成非对称,比如c1=1.8、c2=1.2,让粒子前期更偏向自我搜索,不要过早被群体最优带走。要是跑了好几遍都一样,那就是初始种群没覆盖好,可以尝试用Sobol序列或者拉丁超立方抽样生成初始位置,避免随机抽样导致种群在可行域里挤成一团。
还有一个小技巧是每隔一定迭代代数,对适应度最差的那批粒子做“重新初始化”,把它们的速度和位置随机重置,相当于给种群注入新鲜血液。
4.2 罚函数系数取值敏感,轻微调整结果差异巨大
罚函数是处理约束时最简单的方式,但系数设小了约束根本满足不了,设大了目标函数的真实梯度又会被淹没。
我的建议是:不要在全模型用同一个罚函数系数。按约束类型分类惩罚,功率平衡约束的惩罚系数取量级比较大的数,比如1e4;设备容量约束的惩罚系数可以小一些,比如1e2到1e3。原因很简单,功率平衡偏差的物理量纲本身就比设备容量超限的偏差大得多,惩罚系数也必须跟着匹配。
如果非要用罚函数,建议把违反约束的量做归一化处理。比如储能SOC越界量除以其允许波动范围,这样不同尺度的约束在惩罚项里权重才一致。
4.3 博弈迭代不收敛,上层价格在几个值之间来回震荡
这是主从博弈嵌套优化里非常经典的现象。价格迭代时会出现在两个方向同时偏移的“锯齿形”震荡,根源往往是迭代步长太大,或者用户响应函数对价格过于敏感。
两个可行的解决办法:一是给价格迭代加阻尼,每次更新时不完全跳到新值,而是取新旧值的加权平均:
price_new = alpha * price_pso + (1 - alpha) * price_old;alpha取0.6左右比较稳。二是检查用户需求响应模型是否合理,如果需求弹性系数过大,价格稍微一波动,用户负荷就剧烈变化,上层看到的目标函数当然也会剧烈变化。很多时候是模型参数本身没标定好,不是算法的问题。
4.4 算法整体运行太慢,主从迭代加PSO双重循环跑不动
这种双重循环结构跑起来确实慢,尤其是当底层用linprog、中层用quadprog,每个粒子都要调一次完整中下层求解的时候。24小时场景、36个小时的场景,60个粒子,30次博弈迭代,每次迭代里每个粒子都要调度一次中下层,累计求解次数惊人,Matlab单线程下可能要跑上几个小时。
我优化这个瓶颈的方法主要有三个:
- 减少不必要的粒子评价。PSO迭代前期,粒子位置变化很大,但很多位置的适应度差得很明显,根本不影响全局最优,就没必要每次都调用完整求解器。思路是先把粒子位置做粗糙的可行域检查,明显不合理的位置直接赋个很大的惩罚值,不进求解器。
- 中下层求解器选型上,能用线性规划就不要上非线性规划。很多储能调度问题在目标函数和约束都是线性的前提下,直接用linprog比用fmincon快一个数量级。
- 如果单次循环时间实在降不下去,改用并行计算。Matlab的
parfor可以把每个粒子的适应度评价分散到多核,通信开销可接受,提速基本能到核心数级别。
还有一个隐藏很深的坑是:主从迭代过程中每层都在重复加载数据或重新构造稀疏矩阵,这些“固定开销”往往被忽略。把不随迭代变化的数据提前算好存入结构体,每次只传递句柄,能省下不少时间。
4.5 常见问题速查
| 现象 | 可能原因 | 排查顺序与对策 |
|---|---|---|
| PSO结果每次跑都不一样,且差异大 | 随机性太强,种群初始化覆盖差 | 固定随机种子复现;改用拉丁超立方初始化;加大种群规模 |
| 结果符合约束但经济性差 | 罚函数系数把目标函数压没了 | 降低惩罚系数,分类型设置;检查目标函数是否无意中包含了惩罚项 |
| 中下层求解器报错“无可行解” | 约束过强或传入的接口数据异常 | 单独测试中下层输入输出,先放松一两个约束定位矛盾源 |
| 价格迭代剧烈震荡 | 迭代步长过大或需求弹性过大 | 加阻尼;减小价格更新幅度;检查价格上下限是否过宽 |
| 整体耗时太长 | 双重循环中每个粒子都调用完整求解器 | 并行计算;预检查减少无效评价;尽量把中下层写成线性规划 |
| 上层目标函数值出现NaN | 下层求解失败或罚函数溢出 | 用exitflag捕获下层求解状态;对NaN直接赋值巨大惩罚值 |
5. 实操心得与后续扩展
调这个模型的过程中我最大的体会是:主从博弈加粒子群,最大的难点不在算法本身,而在“接口设计”。三层模型里每一层的数据格式、变量顺序、约束矩阵构造方式必须从一开始就定好规范,否则代码写到后面,层与层之间互相传数组都对不上,排错排到头大。
一个小技巧是:所有层之间的数据传递都用结构体统一格式,例如layers.up.price、layers.mid.P_bess、layers.low.P_shift,这样在断点调试时一目了然,也能避免把行向量传成列向量的经典错误。
另外,为了让中间过程可解释,建议每一轮主从迭代把三个层的目标函数值记录到数组里,最后画在一张图中。这样你能直观看到三个主体的利益演化轨迹——哪一方在博弈中占据优势、哪一方在让利,一眼便知。这张图放在论文里也很有说服力。
关于后续扩展,你还可以往这几个方向深挖:
- 把粒子群改成多目标版本(MOPSO),同时考虑经济性和碳排放两个目标,画Pareto前沿;
- 上层引入多种类型售能主体(电、热、气),形成“多领导者—多跟随者”的复杂博弈结构;
- 把用户侧真实需求响应历史数据接进来,替代数学响应模型;
- 将PSO换成改进的量子粒子群或混合灰狼PSO,提升高维下的搜索能力。
不过我的建议是:先把基础的单一PSO版本跑通、跑稳,把所有模块验证到位,再逐渐往上加复杂度。我见过太多人一上来就想一步到位做多目标多主体,结果卡在调试上两个月,心态直接崩掉。一步一步来,先把一个能收敛的版本攥在手里,再考虑升级的事。
最后再分享一个小技巧:无论你用什么智能算法做博弈优化,一定要在代码里固定随机种子(rng(2024)这种),否则你改一次参数、重跑一遍,结果可能面目全非,根本没法判断到底是改动生效了还是纯随机波动。固定种子之后,算法之间的细微差别才真正可比较、可复现。这是我踩过无数次坑之后最想提醒你的一件事。