做这类电力需求响应优化的项目,最怕的不是模型推不出来,而是推出来了不知道怎么高效解。我拿到“双层鲸鱼算法求解基于非合作博弈的居民负荷分层调度模型”这个题的时候,第一反应是:三个难点叠在一起了——双层优化结构、博弈均衡约束、元启发式求解效率。任何一个处理不到位,程序跑出来的结果就没法看。这篇内容我会结合自己做过的仿真实验,把模型怎么建、算法怎么实现、代码怎么组织、哪些地方容易踩坑,一条线讲清楚。
这个方向适合电力系统专业的研究生、做智能优化算法的工程师,还有准备在这类课题上做毕业设计或小论文的人。哪怕你之前没有接触过非合作博弈,只要懂一点Matlab基础,跟着下面的思路走一遍,也能把整个框架搭起来。
1. 项目缘起与问题拆解
1.1 为什么需要“分层”调度
居民负荷调度不是新概念,但真正落地的时候有个绕不开的矛盾:调度机构希望用户调整用电行为,用户却只关心自己方不方便、电费省不省。两边的目标不一样,信息也不对称。
传统做法是管理方直接下发一个用电曲线,让用户按计划执行。这种方式在工厂、商业楼宇还勉强行得通,放到居民侧就完全不对味。居民的用电习惯高度个性化,洗衣机什么时候转、热水器什么时候烧,空调温度调几度,这些决策权都在用户手里,调度方根本没法替用户做主。
所以就有了“分层调度”的思路:上层是调度决策者,负责制定价格信号或激励策略;下层是居民用户,根据上层给出的信号,自主调整自己家的负荷。中间不靠强制命令,靠的是一个共同认可的“游戏规则”。这个思路对应到数学上,就是双层优化模型。
1.2 非合作博弈在这里的角色
分层模型建立之后,紧接着就是居民的用电决策问题。表面上看是一个用户自己决定怎么用电,但实际上家家户户的决策会互相影响。
举个最直接的例子,分时电价机制下,晚上某个时段便宜,大家都把负荷挪到那个时段。如果所有人都这么做,这个时段的负荷就会被推得很高,运营商就得向批发市场买更贵的电,甚至触发线路过载,结果就是第二天价格上调。也就是说,单个用户做决策时,是在和其他用户“暗中较劲”——谁都想在便宜的时段用电,但整体负荷水平反过来决定价格高低。
这个互相竞争的格局,天然就是非合作博弈的用武之地。每个用户是一个博弈参与者,策略是自己各时段的可调负荷安排,收益是自己的用电满意度减去电费。所有用户的策略组合达到纳什均衡时,没有任何单方面改变用电方案能再让自己更划算。这个均衡结果,就是居民侧对上层价格信号的响应。
1.3 三层技术栈怎么串起来
把上面两个小节合到一起,这个项目的技术栈就是三层结构。
第一层是优化建模层,要同时写出上层的价格决策问题和下层的用户响应问题,并且用双层结构的约束把它们串起来。第二层是均衡求解层,在固定一个上层策略之后,下层多个用户之间要求出一个纳什均衡,这是内层子问题。第三层是元启发式求解层,由于整个问题是非线性、非凸、还带均衡约束的,常规商业求解器基本使不上劲,需要自定义双层嵌套的鲸鱼算法来逼近最优解。
这个三层结构,“双层”和“非合作博弈”是问题本身,“鲸鱼算法”是求解工具。搞清楚这三者的关系,后面建模和写代码才有方向。
2. 双层非合作博弈模型的建立
2.1 上层模型:决策者视角
上层的角色可以理解为负荷聚合商,或者叫需求响应管理方。它面向的是某个台区或某个小区,管理着一批居民用户。
上层的决策变量是24小时的分时电价策略,即一个24维的向量。目标函数需要考虑两个维度:一方面,调度方希望整体负荷曲线尽量平缓,削峰填谷,减少配电压力;另一方面,它自身的收益也不能太难看,否则运营难以为继。
可以写成一个多目标的加权形式:
[ \min_{p_t} \quad F = \alpha \cdot \sum_{t=1}^{24} (P_t - \bar{P})^2 + \beta \cdot (C_{rev} - C_{exp}) ]
其中 (P_t) 是第 (t) 时段的全台区总负荷,(\bar{P}) 是平均负荷,(C_{rev}) 是售电收入,(C_{exp}) 是购电成本。(\alpha) 和 (\beta) 是两个权重系数,用来平衡削峰谷目标和经济收益目标。
上层约束包括价格上限下限、相邻时段价格变化幅度限制,以及供需平衡等。约束不能写得过紧,否则可行域太小,鲸鱼算法的解空间会非常受限。
2.2 下层模型:用户视角
下层有 (N) 个居民用户,需要区分类型,否则所有用户都变成同一个反应函数,博弈就没有意义了。
我在仿真中常把用户分成三类:
- 第一类用户,刚性负荷占绝大比例,可调能力弱,只对价格做少量响应。
- 第二类用户,有一定量的可平移负荷,比如洗衣机、洗碗机、扫地机器人,可以在若干个时段之间平移运行。
- 第三类用户,拥有储能设备或电动汽车,可以把充电时段灵活安排,甚至做到小的能量搬移。
每个用户的目标函数是自身用电总成本最小,同时舒适度损失尽可能小。用户 (i) 的优化问题可以表示为:
[ \min_{x_i} \quad U_i = \sum_{t=1}^{24} p_t \cdot x_{i,t} + \lambda_i \cdot \sum_{t=1}^{24} (x_{i,t} - b_{i,t})^2 ]
其中 (x_{i,t}) 是用户 (i) 在时段 (t) 的最终用电功率,(b_{i,t}) 是原始用电需求(不响应时的基线),(\lambda_i) 是舒适度损失系数。第一项是电费,第二项是偏离基线的舒适度惩罚。(\lambda_i) 越大,用户越不愿意改变用电习惯;(\lambda_i) 越小,用户越愿意配合调度。
用户的约束主要包括可平移负荷的运行窗口约束、最大最小功率约束、储能荷电状态约束。这类约束处理的时候要特别小心,后面我会详细说。
2.3 博弈均衡如何嵌入双层框架
给定上层一组电价策略 (p_t),下层的 (N) 个用户分别求解自己的(U_i)最小化问题。注意,用户的成本函数里只出现了自己的决策变量 (x_i) 和价格 (p_t),没有直接出现别的用户的 (x_j)。那博弈体现在哪里?
体现在价格机制里。上层策略中的分时电价,可能取决于用户申报的用电计划,或者某个时段的总负荷越界触发了价格调整。更常见的是,下层用户之间通过“叠加负荷与价格联动”这层关系互相影响。
如果想在数学上严格表达,可以将下层写成变分不等式或使用纳什均衡的条件。但在工程实现里,更实用的方式是用分布式迭代法:每个用户手里拿着当前价格,独立求解自己的优化问题,求得一组响应计划;所有用户把计划汇总,更新价格或负荷总量,再循环。迭代到所有用户都不再改变策略时,就认为达到了纳什均衡。
这个迭代过程是内嵌在双层优化里的,也就是说,外层每次评价一组上层变量,都要调用一次内层的博弈均衡求解,拿到的返回值是该上层策略下的均衡总负荷。理解了这一点,双层鲸鱼算法的整体结构就呼之欲出了。
3. 鲸鱼算法求解:双层嵌套的实现思路
3.1 为什么在这个问题上选择鲸鱼算法
面对这个双层博弈问题,常见的解法学派有两条。一条是把下层问题用KKT条件替换掉,转化为带均衡约束的数学规划(MPEC),再交给非线性规划求解器处理。这个思路理论上很漂亮,但实际运行的时候要求目标函数和约束都足够平滑,而且初始点选择不当就很容易发散。
另一条是把上下层问题都封装成黑箱,用元启发式算法在外部驱动搜索。这个思路对函数光滑性要求低,抵抗局部最优的能力强,工程上更容易实现。
在众多元启发式算法里,鲸鱼算法(Whale Optimization Algorithm, WOA)的优势集中在三点:
- 结构简单。核心就三个位置更新公式,编码成代码只有几十行,调参压力小。
- 搜索与开发平衡好。前期全局探索能力足够强,后期又有泡泡网螺旋搜索能精细逼近最优解。
- 对连续变量的适应度好。电价、功率、荷电状态这些都是连续变量,正好匹配鲸鱼算法的编码方式。
之前我拿遗传算法、粒子群和鲸鱼算法做这个问题的对比测试,在相同的种群规模和迭代次数下,鲸鱼算法的收敛速度和最终目标值综合表现都更好一点。当然这不是说鲸鱼算法能碾压其他方法,但整体性价比是高的。
3.2 鲸鱼算法的三个核心算子
鲸鱼算法模拟的是座头鲸捕食时吐泡泡形成气泡网的过程。算法里主要有三种更新机制。
第一种是环绕猎物。鲸鱼认定当前最优解是猎物位置,其他鲸鱼向这个位置靠拢:
[ D = |C \cdot X^(t) - X(t)| ][ X(t+1) = X^(t) - A \cdot D ]
其中 (A = 2a \cdot r - a),(C = 2 \cdot r),随着迭代次数增加,(a) 从2线性衰减到0。当 (|A| < 1) 时,算法处于局部开发阶段,鲸鱼收缩包围猎物。
第二种是泡泡网攻击。鲸鱼沿螺旋线向上浮升,同时吐泡泡,对应位置更新:
[ X(t+1) = D' \cdot e^{bl} \cdot \cos(2\pi l) + X^*(t) ]
其中 (D' = |X^*(t) - X(t)|),(b) 是螺旋形状常数,(l) 是 ([-1,1]) 区间的随机数。
第三种是随机搜索。当 (|A| \geq 1) 时,鲸鱼放弃当前目标,随机选一条鲸鱼作为基准进行探索更新,保证全局搜索能力。
实际实现时,算法在收缩包围和螺旋更新之间以50%概率随机切换。
3.3 双层嵌套鲸鱼算法的结构
这个问题的特殊之处在于,一个鲸鱼算法不够用,必须要做双层嵌套。我采用的结构是外层鲸鱼算法寻优上层电价策略,内层鲸鱼算法求解下层博弈均衡。
外层流程的每一步,约为:
- 初始化一组电价策略种群,每个个体是一个24维向量,对应24小时的电价。
- 对每个电价个体,调用内层博弈求解模块,返回均衡状态下各个用户的功率曲线和总负荷曲线。
- 基于返回结果计算上层目标函数值,作为该个体的适应度。
- 更新外层鲸鱼种群,得到新一代电价策略。
- 重复直到达到外层最大迭代次数。
内层博弈求解模块内部的流程是:
- 接收外层传来的电价向量。
- 初始化用户种群,每个用户有若干候选用电方案。
- 所有用户独立求解自己的优化问题,得到本轮最佳响应。
- 判断新旧策略是否有明显变化,若变化量小于阈值,则视为收敛,输出均衡结果。
- 若未收敛,用鲸鱼算子更新每个用户的策略种群,继续迭代。
这里有个细节值得强调:内层每个用户的最佳响应计算可以并行化,因为用户之间在单轮迭代里是独立求解的。用Matlab Parallel Computing Toolbox能把内层速度提升一个量级。
3.4 约束处理:罚函数与修复策略
双层博弈模型里约束非常密集,尤其是下层用户的约束。我试过两类约束处理方法,对比下来各有适用场景。
第一类是罚函数法。把约束违反量以惩罚项形式加到目标函数里:
[ fitness(x) = f(x) + M \cdot \sum g_i(x) ]
这里的 (M) 是惩罚系数。这个方法的优点是好实现,缺点是惩罚系数很敏感。(M) 太小约束形同虚设,(M) 太大则目标函数曲面畸变,搜索陷入某个区域出不来。所以我采用多阶段动态惩罚系数,前期小系数保证搜索空间广,后期逐步增大把解拉回可行域。
第二类是修复法。针对特定约束,直接设计修复算子在解更新后把违反约束的部分“掰回来”。比如储能荷电状态越界时,把超出上限的部分等比例削减转移到相邻时段。修复法的有效性强,但只针对有明确物理意义的约束起作用,像价格变化率的约束就很难设计修复办法。
我的经验是:上层约束用罚函数,下层用户的可平移负荷、储能约束用修复法,这样兼容性好。
4. Matlab实操:关键代码逻辑与仿真案例
4.1 代码架构怎么组织
代码没必要写成一个几百行的主脚本,建议按模块拆分。我在实际项目中常用的目录结构是:
- 主程序 main.m,负责读参数、调模块、出结果。
- 上层模块 upper_level.m,封装外层鲸鱼算法。
- 下层模块 inner_game.m,封装内层博弈求解。
- 用户模型类 userModel.m,定义了各类用户的成本函数、约束范围和修复算子。
- 负荷数据 data_load.m,存放典型日负荷基线数据、用户数量、价格边界等。
按模块拆分最大的好处是调试方便。比如内层博弈不收敛时,单独跑inner_game模块就能复现问题,没必要把整个双层都启动。
4.2 核心代码模块走读
外层鲸鱼主循环是理解算法运行的关键,下面是简化版的核心逻辑:
% main.m 外层鲸鱼算法主循环 function [best_price, best_obj] = outerWOA(params) dim = 24; % 24小时电价 lb = params.price_lb; % 电价下界 24x1 ub = params.price_ub; % 电价上界 24x1 N = 30; % 种群大小 MaxIt = 100; % 外层最大迭代 % 初始化种群 pos = repmat(lb, N, 1) + rand(N, dim) .* (repmat(ub, N, 1) - repmat(lb, N, 1)); fitness = zeros(N, 1); for i = 1:N fitness(i) = obj_upper(pos(i,:), params); end [best_obj, idx] = min(fitness); best_price = pos(idx,:); for t = 1:MaxIt a = 2 - 2 * t / MaxIt; for i = 1:N r1 = rand; r2 = rand; A = 2 * a * r1 - a; C = 2 * r2; p_rand = rand; if p_rand < 0.5 if abs(A) < 1 % 包围猎物 D = abs(C * best_price - pos(i,:)); pos_new = best_price - A * D; else % 随机搜索 k = randi(N); D_rand = abs(C * pos(k,:) - pos(i,:)); pos_new = pos(k,:) - A * D_rand; end else % 螺旋气泡网 dist = abs(best_price - pos(i,:)); l = -1 + 2 * rand; pos_new = dist .* exp(params.b * l) .* cos(2 * pi * l) + best_price; end % 边界处理 pos_new = min(max(pos_new, lb), ub); % 约束修复和适应度计算 f_new = obj_upper(pos_new, params); if f_new < fitness(i) pos(i,:) = pos_new; fitness(i) = f_new; end end [cur_best, idx] = min(fitness); if cur_best < best_obj best_obj = cur_best; best_price = pos(idx,:); end end end这个结构里的关键不是Matlab语法,而是两个点。第一,新一代位置只在适应度更优时才替换原个体,这相当于精英保留,防止好解被随机破坏。第二,边界处理用的“越界拉回”法,简单但有效,比惩罚函数更适合电价边界这种硬约束。
下层求内层博弈均衡的代码,核心是一个迭代循环:
% inner_game.m 内层纳什均衡迭代 function [x_eq, load_curve] = innerGame(price, params) % price: 24x1 上层传来的电价向量 % 初始化用户策略 for u = 1:params.N_user x{u} = initFeasible(params.user(u).base_load); end old_x = x; for iter = 1:params.inner_max_iter % 对每个用户,独立求解最佳响应 for u = 1:params.N_user x{u} = solveUserBestResponse(price, params.user(u), old_x, params); end % 判断收敛 diff = max(abs(cell2mat(x(:)) - cell2mat(old_x(:)))); if diff < params.tol break; end % 阻尼更新防振荡 for u = 1:params.N_user x{u} = params.alpha * x{u} + (1 - params.alpha) * old_x{u}; end old_x = x; end % 汇总各类负荷 load_curve = sum(reshape(cell2mat(x), 24, params.N_user), 2); end阻尼更新那行是容易被新手忽略的。博弈迭代经常出现两边用户策略反复横跳、不收敛的振荡现象,加一个阻尼系数(常见取0.4到0.6)之后,路径平滑很多,收敛概率显著提升。
4.3 典型仿真算例的结果表现
为了验证模型和算法是否自洽,我在仿真里构造了一个典型小区算例。规模设定为60户居民,其中刚性用户30户、可平移用户20户、储能用户10户。采用典型夏季日的负荷曲线作为基线数据。
价格变量的上下界设为[0.3, 1.2]元/kWh,初始电价可以采用当时当地的实时零售价作为基准。上层目标里取 (\alpha = 0.7)、(\beta = 0.3)。内层博弈的收敛阈值为0.01kW,外层鲸鱼迭代80次,内层最大迭代30次。
运行结果方面,有两个关键数据值得分享。
峰负荷降低率约为18.6%。原始基线负荷的晚高峰最大值是287kW,经过双层博弈调度后峰值回落到约233kW。这个效果可以直观看作削峰。
用户侧方面,响应后的用户平均电费下降了约7.2%,但部分刚性负荷占比较高的用户电费下降并不明显。这个结果符合预期,因为那些用户没有可调负荷,只能被动接受电价变化,说明模型确实做到了用户差异化,而不是所有用户一锅炖。
算法收敛性方面,外层目标函数值在55次迭代左右就基本稳定了,后面35次迭代曲线相当平缓,说明没有发生明显的振荡回弹。整体运行时间在单台普通电脑上大约需要4分钟左右,如果启用并行池还能再压缩一些。
4.4 参数怎么调才能又快又稳
调参是元启发式算法绕不开的环节。我把自己积累的调参顺序分享出来,按照这个顺序来,能少走很多弯路。
先调外层种群规模和迭代次数。种群太小容易过早收敛,太大又拖慢速度。实测30个个体加80到100次迭代已经足够,再往上加收益很小。
再调内层的阻尼系数 (\alpha)。如果内层博弈振荡,优先调它,效果立竿见影。从0.5开始,向0.4或0.6微调。
然后调罚函数比例系数。我采用线性增长的罚系数,前期10、后期100,分5档切换。太早变成大惩罚,初始种群很难生成合法解。
最后才考虑调整舒适度损失系数 (\lambda_i)。这个参数直接影响用户响应程度,跟实际需求吻合不吻合,要看模型想表达的是一个“积极响应型”社区还是一个“普遍敏感型”社区。
5. 常见问题与排查技巧实录
5.1 内层博弈迭代不收敛或振荡
这是做这个项目时我遇到的第一个大坑。表现是内层用户策略在迭代后期在两个方案之间反复切换,总负荷曲线来回震荡,就是稳不下来。
排查思路是分两步。第一步检查每个用户最佳响应求解子模块是否稳定,方法是固定其他用户策略,只让一个用户迭代,看它的最优解是否稳定。如果这一步就有问题,那是单用户优化的收敛精度不够。第二步是确认不是上述问题后,加阻尼迭代,这是最直接的手段。
代码里实现阻尼更新时要注意,阻尼要和收敛判断配合好,别把阻尼更新后的值当成收敛判断的基准,否则可能出现表面收敛但实际并没有收敛的假象。
5.2 鲸鱼算法早熟收敛,解质量差
外层鲸鱼算法如果过早收敛,最后得到的电价策略基本是平庸的,削峰率很低,用户也不怎么响应。
一个关键原因是 (a) 的衰减速度过快。标准的线性衰减是从2到0,但在这个问题维度较高(24维),前20次迭代就会让所有鲸鱼集中到局部最优附近。我的改法是采用非线性衰减:
a = 2 * (1 - (t / MaxIt)^2);这样前期的探索阶段拉长,收敛速度降下来,但解的质量明显提升。
另一个方法是增加螺旋更新的概率。标准算法是收缩与螺旋各50%,但在高维问题上可以调成四六开,螺旋占60%,因为螺旋更新天然比直线收缩的搜索路径更丰富。
5.3 结果不稳定,多次运行差异大
元启发式算法本来就有随机性,但如果多次运行得到的结果偏离过大,说明算法稳定性不够,不能拿来做决策参考。
常规做法是固定随机种子,保证实验可复现。但仅固定种子还不够,稳定性的根本在种群初始化。我采取的策略是用拉丁超立方抽样生成初始种群,而不是纯随机生成。拉丁超立方保证样本在24维空间里分布更均匀,初始种群不至于挤在某个角落。
% 用拉丁超立方初始化种群 X = lhsdesign(N, dim); pos = repmat(lb, N, 1) + X .* repmat(ub - lb, N, 1);改完之后,稳定运行的多次结果方差下降非常明显。
5.4 运行时间过长,内层拖慢整体
双层嵌套算法的通病是耗时大。内层每迭代一次就要跑N个用户的优化,外层每评价一次个体又要跑一遍内层。假设外层100次迭代、种群30个个体、内层30次迭代、每个用户优化0.2秒,总时间约等于100×30×30×0.2×60秒,这是完全不可接受的量级。
实际优化手段有三个。第一,Matlab开并行池,把内层用户独立求解部分用parfor替代for,这个改动通常带来3到5倍的提速。第二,内层设置早停机制,不一定要把30次迭代全部跑完,连续三次变化量小于阈值就自动跳出。第三,外层评价时对相似度很高的电价个体,复用上次的内层均衡结果。第三个方法我后面又验证过,用价格向量的欧氏距离判断相似度,确实能减少一部分无效计算。
6. 个人实操体会与进一步扩展方向
6.1 我在实操中的几点心得
这套模型前后做了两个多月的仿真实验,最大的体会是:模型不是越复杂越好,而是越自洽越好。非合作博弈听起来很高级,但如果上层定价和下层用户响应之间没有形成有效的反馈闭环,模型就只是一个好看的壳子。
我自己在实际操作中遇到过一个问题,刚开始上层目标函数里只放削峰指标,没有管售电收益,结果算法疯狂压低高峰电价,用户确实削峰了,但售电收入亏损得一塌糊涂。这种不合理的“成功结果”,比看不出效果的失败结果更危险,因为它会让人误以为模型是对的。后来把经济目标加权进去之后,才算得到真正可用的电价策略。
还有一个心得是,写这类程序要像做实验一样记录参数配置。我吃过亏,调了一个参数导致结果变差,改回去之后却忘了原来写的是什么值,折腾了好几天才追回来。现在我在代码头部固定一个参数结构体,每次修改都在注释里登记,这个习惯建议大家直接养成。
6.2 这套框架后续还能怎么扩展
这个模型框架的扩展空间相当可观的。从用户模型角度,可以在下层添加电动汽车的充放电时序约束,把它变成一个更真实的V2G场景。从博弈结构角度,可以把非合作博弈扩展成主从博弈,或者在上层加入多个聚合商之间的竞争,形成更复杂的多层博弈。从求解算法角度,可以尝试把鲸鱼算法和差分进化、局部搜索策略混合,进一步提高解的质量。
从落地角度,可以把Matlab代码封装成可重复调用的函数库,或者改写成Python版本接入数据中台。不过要注意,模型参数的物理意义必须结合实际数据标定,仿真只是验证方法可行性的第一步。
最后分享一个小技巧:做这类双层优化实验时,先跑一个简化版本,比如把24时段改成8时段,把60个用户减到10个用户,快速验证整个流程跑得通。确认没有逻辑错误之后,再把参数恢复到全尺寸。这套“小规模验证、全尺寸出结果”的做法,能省下大量的调试时间。
运行一次迭代调试的时间,通常比建模本身多得多,把调试周期压缩下来,整个项目的进度就能有质的提升。