先说一个容易混淆的点所谓“双层鲸鱼算法”并不是某个新提出的鲸鱼变种而是用鲸鱼优化算法WOA去求解一个双层规划问题——外层是鲸鱼在搜电价或者激励信号内层是用户之间的非合作博弈在收敛。理解了这一层后面看代码就不晕了。这段时间我一直在做居民负荷分层调度方向的东西核心模型是“基于非合作博弈的居民负荷分层调度模型”求解器选了双层鲸鱼算法环境是Matlab。这套东西在论文里看着挺顺真正落地在代码里牵扯到上下层怎么咬合、用户博弈怎么收敛、惩罚系数怎么标定、运行速度怎么优化每一步都有一堆坑。这篇文章把我的完整思路、模型约束、Matlab代码结构和排查过程都梳理出来目标读者是正在做需求响应、家庭能量管理、或者接触双层优化但被实现细节卡住的人。我会尽量把“为什么这么设计”讲透而不是只丢一堆公式。1. 先说清楚居民负荷为什么非要“分层非合作博弈”1.1 传统调度的失灵点把用户当成“可遥控的电灯”最早接触居民负荷调度的时候我用的还是经典的需求响应模型电网出一个分时电价用户被建模成一个开关或者一条静态的负荷平移曲线只要电价高就往低价时段挪像在遥控一盏灯。逻辑很简单代码也容易跑通但我拿去跟实际场景一对比发现这东西根本不成立。居民不是一盏灯。每个家庭里有老人、小孩、上班族有空调、热水器、洗衣机、电动车大家在乎的是电费、舒适度和生活习惯的综合平衡。你半夜给低价让洗衣机挪到凌晨转理论上电网很高兴但用户早上起来发现衣服没洗完第二次就不会再配合了。所以居民负荷调度不能是“集中控制”必须把用户意愿作为一个独立的决策层放进去让用户自己权衡成本和舒适度。这一条其实是整个模型分层结构的最根本动机。1.2 用户之间的“抢资源”关系就是非合作博弈更麻烦的是用户之间不是彼此独立的他们会互相“抢资源”。共享一台配电变压器容量有限或者所在小区执行阶梯电价、容量电费总负荷太高大家都要承担额外费用。一个人傻乎乎地在高峰把所有强用电设备全开不仅自己要付高价电费还会把变压器挤到接近过载让邻居也没法正常用电。这种关系在博弈论里有一个非常贴切的框架叫非合作博弈。每个用户只关心自己的利益最大化不会主动为邻居的舒适度买单。你让某个用户少用点电给邻居腾容量在没有任何补偿机制的前提下他凭什么答应所以整个调度体系必须承认这种“各自为战”的客观现实然后用价格信号去引导他们自发调整而不是假装所有用户都听调度中心的指挥。1.3 双层结构是怎样自然形成的一旦接受上面两个前提模型结构基本就浮出来了上层聚合商或者售电公司制定分时电价目标可能是削峰填谷、降低负荷方差也可能是最大化自身收益。下层用户拿到电价后各自优化自己的用电计划。但因为用户之间要共享变压器容量或者承受峰值费用用户和用户之间必须打成一个非合作博弈的均衡。上层给出价格影响用户行为用户行为反过来又决定上层目标的完成度。谁也离不开谁这就是典型的双层规划bi-level programming。我之前也想过把它压成单层比如直接把用户反应拟合成一个函数但一旦加上设备开关、空调温度约束、可用时段窗口这些限制用户响应根本不是线性可拟合的。最后只能老老实实做双层。2. 双层鲸鱼算法是怎么和博弈模型咬合在一起的2.1 为什么选鲸鱼算法而不是直接上求解器确定了双层结构之后下一步是选求解方法。很多人第一反应是用商业求解器比如CPLEX或者Gurobi。我试过效果不太理想。原因在于上层的目标函数和约束可能还算规矩但下层经过用户博弈之后反馈上来的总负荷曲线是非常不平滑的。设备是0-1开关变量空调功率又是连续变量混合了整数、非线性、不连续这些特征之后上层目标函数对电价几乎不存在解析梯度传统基于梯度的求解器很难直接啃。元启发算法在这种场景反而有优势。它不需要梯度不怕非凸、不连续只靠“采样-评估-进化”就能把搜索往前推进。而在元启发里我最终选择鲸鱼算法WOA主要看中两点结构简单、参数少。像遗传算法要操心交叉概率、变异概率、锦标赛规模粒子群要调惯性权重、个体学习因子和社会学习因子WOA的核心参数就那么几个初始化和阈值设定基本照搬原论文就有不错表现。对于我这种经常需要改模型、反复跑对照实验的人来说简单本身就是一种效率。2.2 WOA的三种位置更新机制快速复习WOA模拟的是座头鲸的捕食行为更新策略分成三种模式包围猎物当随机数 p0.5 且 |A|1 时鲸鱼个体向当前最优个体收缩包围。气泡网螺旋攻击当 p≥0.5 时鲸鱼用螺旋路径逼近猎物。这就对应座头鲸吐气泡把鱼群逼到水面再张口吞下的行为。随机搜索当 p0.5 且 |A|≥1 时个体随机挑一个同伴的位置作为参考去全局探索。核心的控制参数是 A 和 CA 2a·r - aC 2·r其中 a 从 2 线性衰减到 0r 是 [0,1] 均匀随机数。当 |A|≥1 时算法更倾向探索当 |A|1 时更倾向开发这就是它平衡全局搜索和局部收敛的方式。代码实现起来很干净我在第4章会给出关键片段。2.3 上下层之间的数据流才是“双层”的真正含义在双层鲸鱼算法里每条鲸鱼的位置就代表一组上层决策变量在当前算例里是24个时段的电价。一次完整的适应度评估要经历这些步骤上层把一组电价传给下层。下层根据这组电价初始化所有用户的用电计划。用户之间反复做博弈迭代直到收敛到纳什均衡。把均衡状态下每个用户的负荷汇总成总负荷曲线计算上层目标函数比如峰谷差、负荷方差、售电收益。把这个目标函数值返回给上层作为这条鲸鱼位置的适应度。所以每一次“适应度评估”都不是简单算一个函数而是要完整地跑完一次下层博弈求解。这直接决定了整个算法的计算体量。假设你有30条鲸鱼、迭代100代下层又有50个用户每个用户内部还要解一个小的优化子问题总的求解次数就是30×100×50再乘上博弈内部迭代次数。这个数量级一开始我没概念结果第一次全规模联调直接跑了两三个小时才出结果后来才逐步优化掉一部分计算瓶颈。3. 居民负荷分层调度模型的数学表达与约束清单3.1 负荷分类与可调度潜力在写约束之前先把居民家里的负荷分个类。不同负荷的调度方式完全不同不能笼统一股脑处理。我采用的分类是传统的三大类负荷类型典型代表调度方式主要约束可转移负荷洗衣机、洗碗机、电动汽车把启动时间平移到其他时段总耗电量不变必须在规定窗口内完成可中断/可调节负荷空调、热水器调节功率或者温度设定值温度保持在舒适区间功率有上下限刚性负荷照明、冰箱、路由器不参与调度全天必须按基线负荷供电刚性负荷虽然不参与调度但它的存在不能忽略因为它构成了总负荷的基底也是峰谷差计算里不可压缩的部分。可转移负荷负责“搬峰”可调节负荷负责“填谷削峰”三者叠加才是完整的居民负荷曲线。3.2 上层目标函数与约束上层的目标可以有很多种写法我这次用的思路是兼顾削峰填谷和运营商收益目标函数为min F w1 * load_var(price, X_eq) w2 * |revenue(price, X_eq) - revenue_target| / revenue_target其中 load_var 是24个时段总负荷的方差或者峰谷差X_eq 是下层用户在给定 price 下的纳什均衡策略组合。第二项是售电收益偏离惩罚目的是防止价格信号只管削峰但把售电公司搞亏了。上层约束包括电价上下限price_min ≤ price_t ≤ price_max防止价格过高引发用户强烈反弹相邻时段电价变化率限制|price_t - price_{t-1}| ≤ delta_max避免出台“突然暴涨”这种完全不可执行的离谱电价总售电收益不超过某个比例变化。这一层就是鲸鱼要搜索的决策空间。如果price有24个维度那就对应24维搜索空间如果按峰平谷三段电价压缩那搜索维度直接降到3收敛速度和稳定性都会好很多可以做对比实验。3.3 下层用户模型与博弈均衡下层模型是我调试时间最长的部分。每个用户 i 的目标函数我写成了三项的加权和min C_i 电费支出 舒适度惩罚 共享设施过载惩罚电费支出就是各时段用电量乘以电价求和。舒适度惩罚对空调负荷来说我采用简化等效热参数模型ETP模型算室内温度然后把室内温度偏离用户设定区间的积分值折算成成本对可转移负荷来说则是用“实际完成时间偏离期望时间”的程度来折算。共享设施过载惩罚是精髓。它把用户之间的博弈关系真正搭起来penalty_over beta * max(0, 所有用户总负荷 - 变压器容量)^2每个用户单独看自己的目标函数时这个惩罚项受其他用户影响因为总负荷是所有人叠加的。于是一个用户就不得不去猜测“别人大概会在这个时段用多少电”然后决定自己要不要避让。这就是非合作博弈。纳什均衡的含义是一组用电策略让每个用户都做到了“在别人策略不变的情况下我已经没法单方面改策略来降低自己的成本”。通俗讲就是每人都不愿意再独自调整了。下层求解器的任务就是把这个均衡点找出来。3.4 均衡存在性的直觉聊到这里经常有人问你怎么保证这个均衡一定存在严格的存在性证明需要用在凸函数和拟凹对策的条件下论证我这块只给物理解释当用户的目标函数是连续凸函数时纯策略纳什均衡在一定条件下存在。实际算例里各个用户的目标函数基本是二次型结构加上约束是线性区间约束整个博弈模型跑下来都能稳定收敛。不过“存在”不等于“能找到”。数值求解能不能稳定收敛很大程度取决于迭代策略是否合理这个问题我在第5章会展开讲。4. Matlab代码落地主程序框架与关键函数实现4.1 主程序框架与数据结构Matlab里最容易写成一锅粥的就是双层模型因为上下层数据要反复传递嵌套的循环又多。我的建议是不要用全局变量把数据用struct组织好每个用户的参数独立存放。% 初始化用户参数 for i 1:N_user users(i).loadType shiftable; % 负荷类型 users(i).window [8, 20]; % 可转移窗口 users(i).energy 2.5; % 单次耗电量(kWh) users(i).alpha 0.3; % 舒适度权重 users(i).baseLoad baseLoad24; % 刚性负荷曲线 % 空调参数... end % 上层参数 par.price_min 0.3; par.price_max 1.5; par.delta_max 0.2;主程序结构分成三段参数初始化、上层WOA优化、结果后处理。中间的优化主体就是整个算法最核心的部分。4.2 上层鲸鱼算法的代码骨架WOA部分的代码并不复杂关键是每更新完一次位置都要调用适应度函数。核心更新逻辑如下for iter 1:Tmax a 2 - 2 * iter / Tmax; % a从2线性降到0 for i 1:N_pop p rand; A 2 * a * rand - a; C 2 * rand; if p 0.5 if abs(A) 1 % 包围猎物 D abs(C .* X_best - X(i,:)); X(i,:) X_best - A .* D; else % 随机搜索 r_idx randi([1, N_pop]); D abs(C .* X(r_idx,:) - X(i,:)); X(i,:) X(r_idx,:) - A .* D; end else % 气泡网螺旋更新 dist abs(X_best - X(i,:)); L -1 2 * rand; X(i,:) dist .* exp(1 * L) .* cos(2 * pi * L) X_best; end % 边界修正 X(i,:) min(max(X(i,:), lb), ub); % 计算适应度(调用下层博弈) fitness(i) fitness_eval(X(i,:), users, par); end % 更新全局最优 [~, best_idx] min(fitness); if fitness(best_idx) fitness_best fitness_best fitness(best_idx); X_best X(best_idx, :); end end有个细节值得提一下螺旋更新里的距离写法不同论文有微小差别但通常用当前个体到全局最优的绝对值距离即可。系数 exp(b*l) 里的 b 控制螺旋形状我用的是 b1实际调参时把它放在0.51.5之间都试过对结果影响不是特别大反而是种群规模和下层收敛精度的影响更明显。4.3 下层博弈迭代求解的代码设计下层博弈怎么迭代是整套代码里最影响稳定性的部分。我一开始用的是Jacobi式同时更新每个用户并行算自己的最优策略然后一起更新。结果负荷曲线在两种方案之间来回跳根本不收敛。后来改成Gauss-Seidel式的逐用户更新再加上阻尼系数才算稳定下来。核心思路是每个用户在求解自己的子问题时把当前其他用户的最新策略作为已知输入计算自己的最优反应然后用混合更新的方式慢慢逼近均衡x_all init_load(users, price); for k 1:K_max x_all_old x_all; for i 1:N_user x_others x_all; x_others(i, :) []; % 不看自己只看别人 x_i_opt solve_single_user(price, x_others, users(i)); % 阻尼更新 x_all(i, :) (1 - damp) * x_all(i, :) damp * x_i_opt; end % 收敛判据 diff_norm norm(x_all - x_all_old, fro) / norm(x_all_old, fro); if diff_norm tol break; end enddamp 这个阻尼系数是关键我一般取0.5到0.7。取1就等于纯同时更新大概率振荡取太小收敛太慢下层博弈要多跑很多轮。这个参数在数学上相当于给用户更新加了惯性防止用户“反应过度”。4.4 适应度函数的“缝合”写法适应度函数是上下层之间的桥。它的输入是上层给的24维电价向量输出是上层目标函数值。我在实现时把它单独拆成一个函数方便调试。function fval fitness_eval(price, users, par) % 1. 下层博弈迭代求纳什均衡 x_eq nash_equilibrium(price, users, par); % 2. 汇总总负荷 total_load sum(x_eq, 1); % 各用户逐时段叠加 % 3. 计算上层目标 load_var var(total_load); % 负荷方差 revenue sum(price .* total_load); rev_penalty abs(revenue - par.rev_target) / par.rev_target; fval par.w1 * load_var par.w2 * rev_penalty; end这个结构最大的好处是调试下层博弈时可以直接固定一组 price单独调用 nash_equilibrium 函数看均衡收敛过程调试上层时也可以用一组固定博弈结果替代下层单独测WOA能不能收敛。两头可以分开验证。4.5 单个用户子问题的建模示例单个用户内部优化也要单独写成一个函数。以可转移负荷为例最直观的建模是用0-1变量表示“某个时段是否启动洗衣机”约束包括总耗电量不变、必须在指定窗口内完成、每天只能启动一次。Matlab里有0-1规划的求解函数也可以自己枚举窗口内的所有可行启动时段因为24小时窗口的可行解是有限的。function x_opt solve_single_user(price, x_others, user) % 可转移负荷枚举窗口内所有启动时段 win user.window(1):user.window(2); feasible_hours win; if length(feasible_hours) * user.energy user.energy error(窗口长度不足以完成负荷); end best_cost inf; best_start feasible_hours(1); for s feasible_hours x_i zeros(1, 24); x_i(s) user.energy; % 假设单时段完成 cost sum(price .* x_i) ... % 电费 user.alpha * comfort_penalty(s, user) ... % 舒适度 beta * max(0, sum(x_others,1) x_i - par.capacity)^2; % 共享过载 if cost best_cost best_cost cost; best_start s; end end x_opt zeros(1, 24); x_opt(best_start) user.energy; end实际项目中可转移负荷可能持续多个时段电动车充电这种还会连续好几个小时那就得把“启动时段”换成“启动时刻持续时段”的组合来枚举。复杂度会涨但思路还是一样的。5. 实测踩坑记录不收敛、早熟、慢速三大难题的排查过程5.1 下层博弈振荡症状、定位过程、修复先说我遇到最典型的现象跑完整个算法输出结果里用户负荷曲线在“方案A”和“方案B”之间来回跳负荷曲线看起来像锯齿峰谷差反而比不调度时还大。初步判断是上层算法没收敛后来我用固定电价单独测下层博弈发现问题根本不在上层。在固定价格下所有用户同步更新策略今天你往8点挪明天一看别人也在8点你又改到9点后天别人又跟到9点永远追不上就形成了振荡。这就是Jacobi式同时更新的经典问题。修复方案分两步第一把同时更新改成逐用户顺序更新后更新的用户能看到前面用户的最新选择第二给每次更新加阻尼系数 damp0.6让策略变化幅度不要那么激进。改完之后下层博弈一般20到40轮就能收敛到固定点。5.2 上层“伪收敛”先查下层再查上层另一个高频问题是上层适应度曲线很早就不再下降但输出的峰谷差还不如基准分时电价。一开始我怀疑是WOA本身陷进局部最优于是把种群从30加到100迭代次数从100加到300发现改善很微弱。这时候才意识到问题不在搜索能力而在下层返回的“均衡解”根本是错的。排查链路是这样的我先固定一组电价单独看下层博弈的收敛过程结果发现因为5.1那个振荡问题没有完全根除下层在迭代上限到了之后返回的是一组未收敛的中间解。上层把这种有噪声的适应度当成真实反馈搜索方向自然被带偏提前停滞。这个教训很重要双层模型联调时一定要先把下层单独跑稳确认在任何给定电价下都能收敛到真均衡再挂到上层鲸鱼里去。否则上层适应度本身就是噪声算法再强也没用。5.3 惩罚系数量纲失衡用户“全员省钱舒适度崩盘”用了一段时间后我发现一个诡异的现象用户电费确实整体下降了但结果是所有用户的空调温度设定全部贴着下限跑室内温度完全偏离舒适区间直接把“舒适度惩罚”这项变成摆设。查下来就是量纲问题。电费单位是元空调部分如果直接用“温度偏差”来惩罚数值可能是零下十几度减设定值跟几十元的电费根本不在一个数量级。两者相加舒适度那项的梯度被完全淹没用户的最优决策就变成了“只省钱不管舒不舒服”。修复办法是归一化处理把温度偏差折算成等价的电价成本。比如我设定偏离设定温度1℃持续一小时等效为多付0.05元电费。这样舒适度惩罚就和电费有了可比较的单位w的调节才真正有意义。标定这个等效系数的过程我常用二分法给一组模拟负荷曲线先让系数很小观察用户在极端省钱和极端舒适之间的切换点再反推一个适中的参数。5.4 算例跑太慢计算量拆解与优化另一个让我头疼的问题就是慢。全规模算例第一次跑30条鲸鱼、100代迭代、50个用户、下层博弈50轮单次适应度评估要调用50次用户子问题求解理论上相当于30×100×50×50次子优化加上每个子优化内部还有枚举或者迭代整体计算量非常可观。我的优化思路分三步走。第一步是下层博弈收敛判断提前终止只要有连续几轮用户策略变化量接近零就不再等固定50轮。第二步是把上层WOA的单轮内循环向量化比如负荷叠加用矩阵求和而不是for循环逐用户加。第三步是用Matlab并行工具箱的parfor把每条鲸鱼的适应度评估分散到多核并行。需要注意parfor里不能写共享变量和随机数种子相关的操作否则结果不可复现。经过这三步优化全规模算例从两个多小时压到二十分钟左右已经可以接受。如果还有进一步需求可以把24维电价压缩成峰平谷三段价格搜索维度降到3收敛速度会成倍提升。5.5 参数推荐表最后给一组我在当前算例下比较稳定的参数组合方便直接照抄参数推荐取值说明鲸鱼种群规模3050维度低时30够用维度高可以加到50上层最大迭代100200配合多起点策略更稳下层博弈迭代上限50实际通常在1530轮就收敛阻尼系数 damp0.50.7建议从0.6起步下层收敛阈值 tol1e-4太松会导致上层误判舒适度惩罚折算系数0.020.10 元/(℃·h)需要按算例标定多起点数量5避免WOA早熟的低成本方案6. 模型还能怎么用从仿真算例到实际部署的扩展建议6.1 算法验证怎么做基线对比与收敛性检查仿真算例阶段我用一组公开的典型居民日负荷数据做基线对比几种方案的峰谷差和用户平均电费不调度直接按原负荷、传统固定分时电价、单层遗传算法调度、以及双层鲸鱼算法调度。结果上双层模型能在用户电费基本不变或者小幅下降的前提下把峰谷差再降一截。但严谨一点说这是在仿真数据和模型假设下的结果不能直接当作真实系统的承诺值。用这个框架去给具体的居民社区做方案时必须用当地真实用电数据重新标定。除了最终效果还要检查算法自身的收敛曲线。我习惯把每一代最优适应度打出来看趋势如果曲线快速下降后进入平台期说明搜索正常如果在多次独立重复实验中结果差异特别大就说明种群初始化或者随机种子影响过大需要用多起点策略去稳定。6.2 扩展方向不确定性、电动汽车和用户聚类这套模型往下走有几个自然的扩展方向。第一是把确定性负荷改成随机场景比如光伏出力随天气波动那下层博弈就从单场景变成多场景期望均衡第二是把电动车加进来因为电动汽车的可转移窗口受车主出行时间约束而且充电功率比洗衣机大得多对峰谷差的影响非常显著第三是用户数量上百甚至上千时逐用户博弈迭代会非常慢常见做法是先对用户做聚类把相似负荷曲线的用户聚成几类每类用一个“代表用户”参与博弈最后再把代表用户的结果映射回具体家庭。6.3 更贴近现实的交互方式只传价格不传隐私最后聊一个部署层面的现实问题。实际系统里用户通常不愿意把自己的详细用电计划直接告诉聚合商这涉及隐私也缺少激励机制。更现实的落地方式是聚合商只发布价格信号用户在家里本地的智能终端上运行自己的优化程序然后把优化后的总负荷曲线或实际用电数据回报给上层用于结算。用户之间也不需要交换彼此的负荷计划而是通过共享价格和容量约束间接交互。这样一来下层博弈就不再是“所有用户共享所有信息”的理想化设定而是每个用户对外的可见信息只有公共电价和电网可能发布的容量紧张提示。这恰恰是分布式需求响应的常见形态也和我前面写的阻尼迭代和本地优化框架天然兼容。如果要做真正产品化的东西我建议一开始就把“隐私约束”写进模型需求而不是等到系统做完了再回头改数据流。我在实际跑这些算例时最大的体会是双层模型最怕的不是数学难而是分层不彻底。只要下层博弈一天没调稳上层无论用什么算法都会被带偏。先把下层单独跑稳再挂上鲸鱼算法去搜电价这一步顺序千万别反过来。