ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

NSGA-Ⅲ求解梯级水电火电联合调度的Matlab实现解析

NSGA-Ⅲ求解梯级水电火电联合调度的Matlab实现解析

梯级水电和火电联调这种话题,做优化的人应该不陌生。水电机组启动快、调节灵活,但受来水和库容约束;火电机组出力稳定,但煤耗和排放都是大头。两者放一起做联合调度,本质上是个多目标、强约束、非线性的高维优化问题。以往搞加权单目标或者分层序列法,最头疼的就是权重怎么定、量纲怎么统一,而且一次只能出一组解,换一组权重又得重跑一遍。

我最近在复现和扩展的这套NSGA-Ⅲ梯级水电火电联合调度项目,正好是冲着这些痛点去的。它用非支配排序加参考点机制,一次性跑出整个Pareto前沿,让调度人员可以从解集里挑符合实际需求的方案,而不是被单个最优解绑死。代码用Matlab实现,整套逻辑从问题建模、算法设计到结果分析都是通的,非常适合电力系统方向的研究生、做运行优化的工程师,以及想找多目标算法落地案例的开发者参考。这篇文章我从模型原理、算法改进、代码结构到实际调参踩坑,完整拆一遍,希望能帮正在看类似项目的你省点时间。

1. 项目整体设计与思路拆解

1.1 这到底是个什么问题

先看清楚这个调度问题的物理本质。梯级水电厂不是孤立的一台机组,沿着同一条河流从上到下串联着好几座电站,上游出库流量经过一段时间延迟变成下游的入库流量,这叫水力耦合。火电则分布在电网的不同节点,响应速度比不上水电,但胜在稳定、不靠天吃饭。

联合调度的意思是:在若干个调度时段内,把水、火两类机组的出力曲线全部定下来,目标是同时满足系统负荷需求、各类运行约束,并且让多个性能指标尽可能都好。典型的指标包括系统总煤耗最小、污染物排放最少、水库水位越限惩罚最小,或者机组启停成本最低等等。

这个问题难在哪?首先是非线性程度高——水电机组的出力是水头与发电流量的乘积关系,火电煤耗特性曲线通常用二次函数拟合;其次是强耦合——上下游电站之间、水电与火电之间、不同时段之间的变量互相牵制;最后是解空间大——每个机组的每个时段都是一个决策变量,随便一个实例就是几十上百个维度。

传统加权法把多个目标用权重线性叠加成一个综合指标,但权重本质上靠经验拍脑袋,而且如果两个目标的量纲差异太大,比如煤耗按“吨”算、排放按“吨”算但数值差几个数量级,得先做归一化,做完归一化权重的含义又变得不直观了。项目里用NSGA-Ⅲ这类多目标进化算法,最核心的逻辑转变就是:不做加权,直接对多个目标同时优化,最终输出一整组互不支配的折中解。

1.2 为什么选NSGA-Ⅲ而不是NSGA-Ⅱ

很多人在多目标优化里第一个接触的是NSGA-Ⅱ,经典确实经典,但它在处理高维目标时有明显的短板。NSGA-Ⅱ的多样性保持靠的是拥挤距离——比如两个解在目标空间同一个方向上扎堆,拥挤距离就小,算法会优先淘汰它们。可一旦目标个数增加到3个、4个,甚至5个以上,拥挤距离计算会退化,解集容易在某个局部区域过度聚集,整个Pareto前沿分布不均。

NSGA-Ⅲ的改进思路非常直接,它不再用拥挤距离,而是预先在整个目标空间铺设一系列参考点。比如3个目标时,参考点均匀分布在标准单纯形上,每个种群个体在环境选择阶段匹配到离自己最近的参考点,算法通过统计每个参考点关联的个体数量来维持多样性。这样做的效果是:无论目标空间实际形状怎么变化,算法始终有明确的多样性引导方向,不会因为某个区域角度刁钻就失去覆盖。

实际跑下来我的感受是:2目标场景下NSGA-Ⅲ的优势还不算特别明显,但一旦把碳排放、煤耗、水位偏差三个以上目标放进来,NSGA-Ⅲ得到的解集比NSGA-Ⅱ均匀和完整得多,特别是在前沿面的边界区域,NSGA-Ⅱ经常丢三落四,NSGA-Ⅲ能稳定覆盖。这也是这个项目把算法定为NSGA-Ⅲ的主要原因——它和梯级水电火电调度这种典型的高维目标场景非常契合。

1.3 项目的整体架构

我看到的这套Matlab实现,流程主线非常清晰,大致可以分成以下几个模块:

  • 数据输入:包含水库特性参数、火电煤耗系数、负荷曲线、来水预测、水位库容关系曲线等。
  • 问题建模:把决策变量编码处理成适合进化的染色体结构,目标函数和约束条件写成独立的m函数。
  • 算法主循环:按照种群初始化、非支配排序、选择交叉变异生成子代、参考点关联维持多样性、环境选择产生下一代这样的流程循环迭代。
  • 结果输出:绘制Pareto前沿图、折中解调度曲线、收敛曲线等。

这种模块化结构本身也是值得学习的点。它让问题定义和算法框架分离,以后你想换一套算法,比如把NSGA-Ⅲ换成MOEA/D或者SPEA2,不需要动目标函数部分,只需要替换算法主循环就可以了。我扩展实验时,把NSGA-Ⅲ换成了MOEA/D,整个过程中唯一改的就是算法主体和少数参数设置,目标函数完全没动。

2. 核心数学模型解析与目标函数设计

2.1 决策变量与编码方式

在这个项目里,决策变量是各调度时段内水电机组的发电流量和火电机组的出力。Matlab实现时通常用实数编码,一条染色体就是把所有变量串在一起。比如系统有3个水电站、2个火电厂,调度周期取24个时段,那一个个体就表示成:

% 假设 nHyd = 3, nThermal = 2, T = 24 % 决策变量排列顺序:[水电1各时段流量, 水电2各时段流量, 水电3各时段流量, 火电1各时段出力, 火电2各时段出力] % 染色体长度 = nHyd * T + nThermal * T = 120 nVar = nHyd * T + nThermal * T;

这里有个细节值得注意:水电机组的出力其实是通过流量和水头计算出来的,不是直接作为决策变量。原因是如果直接把出力当决策变量,后续验算库容平衡时经常会矛盾——出力定了,流量推不出来,水位变化就和流量对不上。项目编码时选择发电流量作为水电子系统的决策变量,保证在水力约束层面天然可解。这个设计细节我实际操作中认为非常重要,后期约束修正的负担能减轻一大半。

2.2 目标函数设计

多目标调度问题,目标函数直接决定了你会得到一个什么样的Pareto前沿,所以这部分的建模要认真对待。这个项目里重点考虑了三个目标:

第一个目标是系统总煤耗量最小。火电机组的煤耗特性通常用二次函数拟合:

function f1 = calcCoalCost(P_th) % P_th: 火电机组出力向量 % a, b, c: 煤耗系数,具体数值由机组特性决定 f1 = sum(a .* P_th.^2 + b .* P_th + c); end

这里需要注意的是,煤耗系数矩阵a、b、c的维度要和火电机组数量一致。项目数据里火电机组的煤耗系数来自典型机组特性数据,这是公开常见的测试数据,不是凭空编的。水电部分不直接消耗燃料,但可以折算成煤耗节约量,不过在目标函数里通常不这么处理,而是直接用系统总煤耗来表达,水电高了火电自然就低,这是间接达到节能效果的。

第二个目标是污染物排放量最小。排放量也一般用二次函数拟合,和煤耗曲线是独立的另一套系数。有些文献会把煤耗和排放做线性加权合并成一个目标,但项目里把它作为独立目标,因为煤耗最少时排放不一定最少——高煤耗低排放的机组组合在现实中是存在的,两个目标放一起才有让大家权衡的空间。

第三个目标是水库水位偏差惩罚最小。梯级水电站在调度过程中不能为了追求发电量就让水位长期偏高或偏低,这会影响通航、灌溉、生态流量等需求。这个目标通常写成各时段实际水位和目标水位的偏差平方和最小:

function f3 = calcWaterLevelDeviation(Z) % Z: 各水库各时段实际水位 % Z_ref: 各水库目标水位 f3 = sum(sum((Z - Z_ref).^2)); end

当然,如果你有更多实际场景需求,比如电网峰谷差最小、机组启停次数最少、弃水量最小等,都可以在这个框架里加进来,只需要保证新的目标函数是决策变量的合理映射就行。项目代码里目标函数统一返回一个目标值向量,NSGA-Ⅲ对这个向量只做支配关系判断和参考点关联计算,不关心目标内部怎么算出值来。

2.3 约束条件的处理方式

约束处理是多目标进化算法里最考验工程能力的一环。这个项目的约束主要包括:电力系统负荷平衡约束(各机组出力总和等于系统负荷)、水库水量平衡约束、发电流量上下限、库容上下限、火电出力上下限和爬坡速率约束。

其中负荷平衡约束是最麻烦的。因为决策变量是随机初始化的,要满足“水电出力+火电出力=系统负荷”这个等式约束,在进化算法里很难通过惩罚函数硬来。项目里采用的是修正法:算完水电总出力之后,剩余的负荷差额由火电分配时统一补足。具体实现是这样的:

% 计算水电总出力 P_hyd_total = calcHydropowerTotal(Q_hyd, H); % 剩余负荷由火电机组承担 P_th_remaining = P_load - P_hyd_total; % 按比例分配到各火电机组 P_th = distributeThermalLoad(P_th_remaining);

这种做法实际上把等式约束从“搜索”问题变成了“函数计算”问题,不再需要算法去探索满足等式的可行域,因为每一个个体天然满足负荷平衡条件。它保证了种群中大多数个体都是可行或接近可行的。对不等式约束,比如出力上下限,则在生成初始种群时先限定在边界内,交叉变异后用边界吸收法修整,也就是越界的变量直接拉到边界值,而不是让其留在非法状态。

这种方法比单纯加惩罚函数的优势非常明显。惩罚函数法最大的问题是惩罚系数怎么设——设小了约束等于无效,设大了相当于又偷偷加了一个目标,而且这个目标的权重照旧要靠经验来调。项目里的“等式约束变换+不等式边界修正”策略,我复现的时候觉得代码逻辑干净,对算法收敛速度的负面影响也很小。如果读者将来把这个框架迁移到别的领域,比如园区综合能源调度、配电网重构,这种做法同样值得沿用。

3. NSGA-Ⅲ算法核心机制与Matlab实现要点

3.1 参考点怎么生成

NSGA-Ⅲ最核心的机制就是参考点。参考点的作用是把目标空间均匀划分成若干个区域,种群中的个体按区域分组,每个区域至少保留一个个体,从而保证多样性。

假设目标个数是M,每个目标方向上的划分份数是p,那么参考点总数是组合数C(M+p-1, p)。比如3目标、每维划分4份,参考点总数就是C(4+3-1, 4)=15个;如果划分10份,参考点数就会到66个。这个数字直接和种群规模相关。通常种群规模N取最接近参考点总数的整数,不能随便设,否则参考点使用效率会降低。这是很多人在参数设置时最容易忽略的点。

Matlab里生成参考点可以用Das和Dennis的边界交叉方法,或者用R-NSGA-Ⅲ里的两步法(在超平面上生成参考点,再映射到单位单纯形上)。项目代码里直接实现了Das-Dennis方法,核心思路是递归枚举所有满足条件的分量组合,得到坐标值,再转换到标准化目标空间。生成参考点的算法本身不难,难的是理解“参考点要分布在归一化后的目标超平面上”这句话。很多代码生成的是单位立方体里的网格点,而不是单纯形上的均匀参考点,这两者在高维目标下的多样性维持效果差异很大,我的建议是直接照着论文里的公式写一遍,不要偷懒用网格点代替。

3.2 适应度评估与非支配排序

非支配排序在NSGA-Ⅲ中依然承担着分层选择的作用。定义很简单:解A支配解B,当且仅当A在所有目标上都不比B差,并且至少在一个目标上严格优于B。把所有不受别的个体支配的个体划为第1层,然后把它们暂时移出,再找下一层,以此类推。

Matlab里常见做法是用“待支配数”和“被支配集合”两个数据结构来判断。每个个体的待支配数初始化为被多少个体支配,被支配集合记录它支配了谁。排序时先把待支配数为0的个体放进当前层,然后对它们支配的每个个体把待支配数减1,减到0了再进下一层,如此反复。这个过程的时间复杂度大概在O(N²)数量级,对常规种群规模(几百到一两千)完全够用,不需要刻意优化成竞技场选择那种复杂版本。

这个环节有个容易错的地方:目标函数值是否需要归一化。我测试过,如果不做归一化,当不同目标的量纲差异悬殊时,参考点关联阶段的“垂直距离”计算会失去意义,基本上全被量纲大的目标主导,整个算法的多样性形同虚设。所以项目里的标准化步骤是必须的,它不是锦上添花,而是NSGA-Ⅲ能否正常工作的前提。

3.3 环境选择:从前沿面到固定种群规模

环境选择是每一代进化结束时做的事情:从“父代+子代”的合并种群中选出N个个体组成下一代。这部分具体流程是:

第一,先对合并种群做非支配排序,把所有个体划分成F1、F2、F3……若干个非支配层。第二,从F1开始逐个整层纳入下一代种群,一直加到某一层Fk时,累计数量首次超过N。第三,此前已经纳入了F1到F(k-1)层,数量可能已经接近N了,现在需要从Fk中挑选一部分个体,凑满N。这一步就是参考点发挥关键作用的地方——统计Fk中每个个体和哪个参考点关联,优先选择那些还没有被已入选个体覆盖的参考点所关联的个体。

具体到代码层面,这个挑选过程不是简单地从Fk里按随机顺序取个体,而是要频繁地重算每个参考点已被关联的个体数量。这是整个算法里最费时间的部分,但它保证了算法“宁可从临界层多选一点,也不要把种群集中在某个区域”。我的调试经验是:写这部分时把“每个参考点被关联个体数”做成一个数组,每次选择后更新,循环里只用这个数组做判断,这样逻辑不容易出错,运行速度也快一些。

3.4 交叉变异参数设置

这个项目的遗传操作采用模拟二进制交叉(SBX)和多项式变异(PM),这在连续变量优化里是最常见的搭配。

SBX的核心参数是分布指数eta_c,它控制子代与父代的接近程度。eta_c越大,子代越可能在父代附近,搜索越局部化;eta_c越小,子代越可能远离父代,搜索范围越大。项目里通常设eta_c为10到20,交叉概率0.8到0.9。PM的分布指数eta_m在20左右,变异概率取1/nVar比较合适——每个变量有大约1/变量总数的概率发生变异,这样既保证了变异的全局搜索能力,又不至于让种群变成随机噪声。

如果你使用的是Matlab自带的多目标优化工具箱配合自定义问题,可以直接用gamultiobj或者paretosearch,但我个人的体验是:工具箱毕竟是通用实现,自由度受限,尤其是参考点数量、归一化方式这些细节不好定制。项目里用完整手写NSGA-Ⅲ的实现,虽然代码量稍微大一点,但对算法每一处行为的掌控力要强得多。做研究要改算法模块、加约束、换目标,手写版本的灵活性是工具箱比不了的。

4. Matlab代码实现结构与关键函数拆解

4.1 代码目录结构

项目代码的核心组织方式大致如下:

├── main.m % 主程序入口 ├── ProblemDef/ │ ├── ObjectiveFunction.m % 目标函数 │ ├── Constraints.m % 约束定义 │ └── ProblemParams.m % 问题参数与数据 ├── Algorithm/ │ ├── NSGA3.m % 算法主循环 │ ├── GenerateReferencePoints.m │ ├── NonDominatedSort.m │ ├── AssociateToReferencePoint.m │ └── EnvironmentalSelection.m └── Plot/ ├── PlotParetoFront.m └── PlotScheduleResult.m

这个结构是我见过比较舒服的一种,把问题定义、算法主体、后处理分开。万一你要测试不同问题,比如换成风-光-水-火联合调度,只需要在ProblemDef里改数据,算法部分完全不用动。

4.2 主循环的关键逻辑

下面是算法主循环的最简化结构,保留核心步骤,注释也写清楚了每步在干什么:

for gen = 1:maxGen % 生成子代种群 offspring = zeros(popSize, nVar); for i = 1:2:popSize % 锦标赛选择两个父代 p1 = tournamentSelect(P, 2); p2 = tournamentSelect(P, 2); % SBX交叉 [c1, c2] = simulatedBinaryCrossover(p1, p2, eta_c); % 多项式变异 c1 = polynomialMutation(c1, eta_m); c2 = polynomialMutation(c2, eta_m); % 变量边界修正 c1 = boundaryFix(c1, lb, ub); c2 = boundaryFix(c2, lb, ub); offspring(i,:) = c1; offspring(i+1,:) = c2; end % 合并父代和子代,目标与约束计算 combinedPop = [P; offspring]; combinedF = evaluateObjectives(combinedPop); % 环境选择,生成下一代 P = environmentalSelection(combinedPop, combinedF, refPoints, popSize); end

这里的tournamentSelect就是用锦标赛方式从父代挑两个较优个体。所谓“较优”不只看非支配层,还看参考点关联的小生境计数。在NSGA-Ⅲ中,锦标赛选择的标准一般是非支配层编号小者优先;层编号相同时,优先选择所在小生境个体数量少的那个。这样做能有效避免种群在进化早期就过早收敛到某一区域。

耦合到问题的部分是evaluateObjectives函数,它对每一个个体调用前面说的目标函数计算模块,依次算出总煤耗、总排放和水位偏差三个值。实测中,这个评估函数在全部计算耗时里占比往往过半,因为它需要把染色体解码、计算水库过渡过程、再算火电分配。如果读者在做更大规模的问题,可以用向量化计算或者并行池加速。

4.3 关键函数的实现细节:环境选择

环境选择是整个算法中代码最绕的部分,我在这里把逻辑拆分一下:

第一步,对合并种群做非支配排序,得到一系列层F1、F2、F3……第二步,把F1层开始整层加入新种群newPop,直到加入某一层Fk后数量超过popSize,记下这一层为临界层,并且计算此时newPop中每个参考点关联个体数。第三步,从Fk中挑出需要补足的那部分个体:先对Fk里的个体做目标归一化,找出它们各自最近的参考点,然后优先选那些关联数最少(最好是0)的参考点对应的个体进入新种群。

有一个我在调试时踩过的坑:如果临界层里的个体都集中在某几个参考点附近,那么参考点关联的“最少个体数”可能永远不为0,这样算法会把同一参考点附近的其他个体也拉进来,导致该区域密度过高。一种有效的应对是引入参考点使用次数的“小生境保护”逻辑:即使临界层个体数量不够,也优先从它的近邻参考点里补充,而不是追求完全填满。虽然这会稍微损失一点纯多样性,但换来的是种群不会因为某个区域实在找不到合适个体就一直出现空白。

4.4 数据与结果的输入输出

项目数据里一般包含这几张表:系统负荷曲线(24个时段,单位MW)、火电机组参数(煤耗系数、排放系数、出力上下限)、水电站参数(库容上限下限、发电流量范围、水头-库容关系曲线)、梯级拓扑关系(上游出流到下游入库的延迟时间)。

用Matlab实现时,数据组织成结构体是最直观的方案:

sysData = struct(); sysData.loadProfile = load24h; % 1x24 系统负荷 sysData.thermal = struct('a', a, 'b', b, 'c', c, ...); % 火电参数 sysData.hydro = struct('Vmax', Vmax, 'Vmin', Vmin, ...); % 水电参数

输出结果时,至少应该包括这些图:Pareto前沿三维散点图(3个目标)、折中解的水火电出力过程配合负荷曲线、水库水位过程线。Pareto前沿图能直观展示解的分布质量;调度曲线能说明解在实际中可不可行;水位过程线能揭示梯级水电的运行状态是否健康。项目里这三张图都会画,我认为对于写论文和实际汇报非常关键。

5. 实验结果分析与决策偏好

5.1 从三维Pareto前沿面提取决策信息

跑完算法,得到的是一组Pareto解。比如3目标场景,每个解对应一组(煤耗,排放,水位偏差),画出来的就是一个三维前沿曲面,或者它的二维投影。有经验的人看这图,基本能感受到3个目标之间的制约程度:煤耗小的时候排放是不是也小?两者如果呈强正相关性,说明这两个目标之间的冗余度高;反之,如果两者在某个区域呈反向走势,那就是典型的折中区域,决策者要在这里做选择。

我个人在做结果分析时的经验是:首先要选一个“折中解”,最常用的方法是用最小距离法到理想点。理想点的定义是每个目标独立最优时形成的点,这个点真实存在但通常不可达。计算每个Pareto解到理想点的欧式距离(注意要先对每个目标做归一化,否则数值小的目标会被忽略),距离最小的那个解就是最合理的折中解。这在Matlab里实现非常简单:

ideal = min(F_pareto, [], 1); % 各目标理想值 normF = (F_pareto - ideal) ./ (max(F_pareto, [], 1) - ideal); dist = sqrt(sum(normF.^2, 2)); [~, idx] = min(dist); bestSolution = F_pareto(idx, :);

用这个方法选出来的解在多个目标间平衡得最好,比从图里肉眼挑或者凭感觉拍板要靠谱得多。

5.2 收敛性与分布性指标

除了用肉眼看图,用数值指标量化算法效果也是学术报告和论文里几乎必须有的内容。常用的有:超体积指标(HV)、世代距离(GD)、反转世代距离(IGD)和间距指标(SP)。项目代码里至少实现了IGD和HV,这是多目标优化论文的两个标配指标。

IGD的含义是:真实Pareto前沿上的每个点到算法所得解集的最小距离的平均值。IGD越小说明解集越接近真实前沿。但问题在于,梯级水火电调度这种真实工程问题,我们通常不知道真实Pareto前沿长什么样,这种情况下一般用参考前沿代替,参考前沿可以是所有算法多次运行结果合并后再做一次非支配排序取第1层的集合,或者用已知的基准函数前沿。

HV则不需要参考前沿,它计算解集在目标空间里覆盖的体积。HV越大,说明解集越“丰满”,综合表现越好。Matlab里用凸包排除法可以计算三维情况下的HV,代码量不大,但要注意坐标平移,把目标值转为正数再算,否则结果会失真。

实际使用时我的建议是:论文里两个指标都放,HV评价综合质量,IGD评价收敛精度,双指标配合不容易被审稿人挑毛病。而且不同算法之间的比较一定要用同一套参考前沿,否则IGD没有可比性。

5.3 多方案比选与决策偏好分析

有时候光有整体权衡还不够,工程师在现场还要看具体解。项目做结果分析时,可以从Pareto解集里挑三个极端解和一个折中解进行对比:

  • 最小煤耗解(经济性最好):火电比例相对低,梯级水电发得比较满。
  • 最小排放解(环保性最好):可能多用了水电,但某些时段煤电的压负荷导致系统运行约束更紧。
  • 最小水位偏差解(水位最稳):水电在接近目标水位的区间运行,灵活性利用最少。
  • 折中解:三项指标都不冒尖但也都不落后。

把这四个解放在同一张图上对比,能很直观地看出来不同偏好下的调度方案差异。比如经济最优解可能让梯级水电长时间满发,下游水库水位偏高,影响生态流量;环保最优解让火电频繁调节出力,爬坡压力大。这种对比分析是论文讨论部分的重要素材,也是实际调度部门做方案预评估时最关心的内容。

6. 复现和调参过程中的常见问题与排查技巧

6.1 种群规模与参考点数量的匹配

NSGA-Ⅲ的种群规模和目标数、参考点划分份数p强相关。如果目标数M等于3,p等于10,参考点数约66个,那么种群规模最好设置在70到100附近。如果种群规模太小,比如只有40,那么环境选择阶段会出现大量参考点根本没有个体关联的情况,算法多样性保障就是空转;如果种群规模太大,比如500,计算量不必要的成倍上涨,而且参考点的细分粒度跟不上种群规模,最后多样性反而下降。

我的经验是:先用参考点总数估算一个基础值,再把种群规模取成参考点总数的整数倍加一点余量。具体来说,参考点总数约等于种群规模稍微多一点或接近相等,效果最好。你不需要每一代都精确地让每个参考点都刚好分到一个人,但整体数量级一定要对。

6.2 目标归一化导致的Pareto形状变形

归一化方法是NSGA-Ⅲ里绝对不能忽略的环节。项目里用的是理想点和极端点定义的超平面做归一化。极端点可以通过计算各目标的最小值对应的轴截距获得。如果极端点搜索不准——比如某些目标在进化早期根本没有接近极值——那么整个归一化超平面都会歪掉,后续参考点关联、环境选择全都跟着歪。

一个实践里很有效的改进是:归一化用的极端点不应只从当前种群找,而应该用历史所有发现过的极值点来估计,也可以每隔若干代重新计算一次极端点,而不是每一代都重算。我在复现时发现,如果不做这种“极端点记忆”,在进化早期,前沿面尚未完全打开时,归一化很容易把种群引向偏差方向。加记忆机制之后,算法激进性明显下降,特别是在目标动态变化大的场景下,收敛稳定多了。

6.3 约束违反程度与算法搜索效率的矛盾

约束太多太强,会让进化算法在可行域外围徘徊,很难深入内部找到好解。项目里为约束做了分类处理,这个思路值得保留:等式约束尽量通过变量代换消去;简单范围约束在初始化时直接限定;只有真正没法转换的复杂约束才用惩罚法。通过这种方式,算法搜索空间中的可行域占比大幅提高,收敛速度自然就上去了。

如果你确实遇到约束占比太大、算法半天找不到可行解的问题,一个可以临时用的招是:先放松约束跑几百代,等种群整体进入目标前沿附近后,再逐渐把约束加严,在生态进化意义上叫“可行性逐步收缩”。这种手段在代码里实现并不复杂,无非是加一个随代数变化的约束放大系数,但效果往往立竿见影。

6.4 Matlab代码调试的几个常见坑

Matlab跑这类程序最常见的坑,我列一下自己遇到最多的情况:

第一,数组维度不匹配。目标函数里火电出力是1x24行向量,但水电流量算出来是3x24矩阵,直接拼接成染色体流进算法主循环后,如果解码时维度对不上,报错很难查。建议在解码函数第一行加上维度断言,测试的时候很快就能定位问题。

第二,参考点生成顺序和个体关联顺序不一致。参考点生成的先后顺序有讲究,但很多人没注意,结果环境选择里每次都把个体匹配到错误的参考点上。建议把参考点生成函数跑完后,打印几个参考点坐标人工检查一下,同时把参考点存储顺序固定下来,不要在每次运行时重新排列。

第三,多目标优化结果不稳定。遗传算法的随机性是天性,但不同随机种子结果差异太大就说明出了别的问题。如果跑十次的结果前沿形状都不一样,优先检查是不是种群规模太小、代数太少,或者交叉变异概率设置不合理。一般把代数至少跑到500代以上,再观察前沿稳定性。

6.5 关于代码效率的优化技巧

当系统规模变大、时段数增多时,评估一次种群可能要好几秒,跑500代就是几千秒,这个等待时间很折磨人。项目代码里做了几个优化值得借鉴:

首先是向量化计算。Matlab循环是出了名的慢,尽量把目标函数里的求和、乘除都写成矩阵运算。比如计算火电煤耗,直接把整个种群的火电出力矩阵带进二次函数表达式里一次算完,不要一个个个体用for循环算。用矩阵运算和用循环的差距有时能达到几十倍。

其次是候选解缓存。进化的后期,大量个体会重复或者非常接近,直奔目标函数算太浪费。可以维护一个Hash表,把已经算过的解的目标值存下来,遇到相同或极其接近的个体直接查表返回,能省不少时间。虽然大部分情况下个体不是严格重复,但接近重复的确实不少,实测能节省10%到20%的时间。

最后是并行计算。Matlab的parfor可以直接用在种群评估循环上,但要注意随机数种子管理,否则并行后每次跑出来的结果不一致程度会增大。这个优化要谨慎,最好在算法基本调通后再开并行。

7. 扩展方向:这套框架还能往哪里用

这个项目虽然目标是梯级水电和火电联调,但它的方法论迁移性其实很强。我自己在这个项目的基础上扩展过几个方向,这里分享给大家参考。

第一个方向是加入风、光等新能源。风电、光伏出力受气象条件影响大,具有间歇性和不确定性。在目标函数里加入新能源出力追踪或者弃风弃光惩罚项之后,NSGA-Ⅲ的参考点机制在高维目标下依然能保持很好的分布性。我在扩展实验里增加到5个目标(煤耗、排放、水位偏差、弃风弃光惩罚、火电爬坡标准差),解集质量依然不错,这要是用NSGA-Ⅱ来做,效果就差强人意了。

第二个方向是考虑日前市场的电价信号,做一个经济调度和报价策略的联合优化。这时候目标可以改为购电成本最小、市场收益最大、风险最小,约束条件里加入潮流约束或备用容量约束。NSGA-Ⅲ天然适合这种多目标多约束的组合优化场景。

第三个方向是改成滚动时域调度模型,不是一次优化24小时,而是每几个小时滚动一次,每次更新来水和负荷预测数据。配合NSGA-Ⅲ的快速求解能力,基本能控制在可接受的计算时间内,对实际运行很有参考价值。

第四个方向是如果读者想通过强化学习做调度,这套NSGA-Ⅲ代码可以作为专家示范数据生成器。用NSGA-Ⅲ算出的Pareto最优解集来训练智能体做出力决策,这在近期学术圈是一个挺有想象力的方向。

8. 写在最后的实践心得

这个项目前后我反复跑了很多轮。从最开始的复现调试,到后来改参数、加目标、换场景,整体走下来,最大的感受是NSGA-Ⅲ这种算法不是拿来即用的黑盒子,它每一处设计都有明确的物理和数学含义。参考点划分份数影响的是解的分布偏好;种群规模和参考点数量的匹配影响的是搜索的覆盖广度;目标归一化的准确性影响的是进化过程是否跑偏。把这些细节一个个吃透,才算真正掌握了这门工具。

另外一点,代码本身的工程组织方式也非常重要。做这种研究类项目,最忌讳的是把所有代码糊在一个m文件里。模块化写清楚目录、函数名、注释,后期调试和更换场景的效率能翻倍。我在一开始偷懒把目标函数都放在主循环里,等要加一个新目标时改得头皮发麻,重构回模块化结构之后整个世界清净了。

如果你正在复现或打算复现这个项目,我的建议是不急着直接上大算力,先在一个小规模测试案例上把算法跑通、把Pareto前沿调出来,然后逐步增加机组数和时段数。优化算法这东西,小规模能跑通不代表性规模能跑好,但小规模能迅速暴露逻辑bug,比直接上大规模调试要高效得多。

最后再分享一个小技巧:调试时每100代保存一次种群中间结果到mat文件,不要等到全部跑完再统一保存。有一次我数据算到900代停电没保存,全部白干,从那以后我都是阶梯式保存,这个习惯不知道救了我多少次。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进