我最早开始做这个课题的时候,其实是先拍脑袋定了一个惩罚系数,试图把排放目标强行塞进成本函数里。后来发现,当梯级水电的约束条件一多,单目标加权这种做法的结果会变得非常不稳定——你调一次权重,凌晨3点的水库水位可能就偷偷越了限。后来换NSGA-Ⅲ做多目标调度,在Matlab里同时优化发电成本和污染物排放,让算法自己生成一整个Pareto前沿,才真正把“成本低”和“排放少”这两个相互拉扯的目标讲清楚了。
这个标题本身要解决的问题很明确:基于NSGA-Ⅲ优化算法,做梯级水电和火电机组的联合多目标调度,并给出Matlab代码实现。它做的事情是,考虑梯级水电站之间的水力耦合约束和火电的爬坡、出力上下限,求出一组可行调度方案,让发电成本与环保代价共同达到近似最优。整套流程在Matlab里是可以见到图和表的,尤其适合电力系统方向的学生、刚入职的调度计划岗、以及想深入研究多目标进化算法应用的人参考。
1. 联合调度的痛点与NSGA-Ⅲ的适用边界
1.1 问题背景:梯级水电+火电为什么需要联合调度
先说说为什么这个问题不能简单套用一个优化工具。梯级水电站指的是同一条河流上依次串联的多个水库,上游电站的出库流量,直接决定了下游电站的入库来水。这种上下游耦合关系,让调度不再是“各自把出力算好再相加”这么简单。上游为了多发一度电放掉的水,下游可能就得调整自己的蓄水策略;下游为了满足生态流量或通航需求,又可能反过来限制上游的放水节奏。
火电机组呢,响应快,调峰能力强,但燃料成本和排放指标摆在那里。水电清洁、启动快、运行成本几乎为零,但受制于来水季节性、库容上下限和水头变化。两者单独调度都有自己的天花板:纯火电在经济性和环保指标上很难兼顾,纯水电在枯水期又扛不起负荷曲线。所以实际工程里最常用的思路,是把两类电源放进同一个优化框架,让算法去协调“什么时段让谁多发、谁少发”。
这个协调过程的难度,主要来自三方面。一是时序耦合,24小时或更长的调度周期里,每个时段的决策都会影响后续时段的库容和出力,不是独立的单点优化。二是多约束嵌套,水量平衡、库容上下限、发电流量限值、火电爬坡率、系统功率平衡,这些约束交织在一起,随便碰一个就可能导致整条解不可行。三是目标冲突,成本和排放往往背道而驰,火电多发意味着成本上升但可能排放更多,水电站蓄水保后期发电又可能导致当前时段出力不足。这三个特点叠加,就构成了一个典型的高维、非线性、多目标优化问题。
1.2 NSGA-Ⅲ的适用边界:它适合解决什么问题
面对这类问题,常见的选择有线性规划、动态规划、加权法、粒子群、NSGA-Ⅱ等。但每个方案都有自己的适用边界。
加权法看起来最简单,把排放乘以一个权重加进成本函数,然后单目标求解。问题是这个权重太难标定,而且当Pareto前沿非凸时,加权法根本搜不出凹区间里的解。更麻烦的是,多目标调度的偏好往往不是固定的,决策者可能需要看完整的前沿,再根据实际场景选方案,加权法一步到位反而失去了这种弹性。
线性规划需要把煤耗曲线、水头-出力关系全部线性化。两目标、少量机组还能凑合,一旦梯级水电的非线性关系加入,模型规模迅速膨胀,精度损失也让人头疼。动态规划可以做小规模确定性调度,但梯级多、时段多之后,“维数灾”直接劝退。
NSGA-Ⅱ是经典的多目标进化算法,二维目标上表现很好,但目标维度一旦到3个以上,它靠拥挤距离维持多样性的方式就很容易失效,个体堆在某些目标方向上,前沿覆盖不完整。NSGA-Ⅲ正是针对这个问题设计的,它用参考点替代拥挤距离,让解在整个目标空间里均匀铺开。下表是不同方法的特点对比:
| 方法 | 核心机制 | 适合目标维度 | 非线性/约束处理 | 主要风险 |
|---|---|---|---|---|
| 加权法 | 权重合并 | 1 | 简单 | 权重难标定,非凸前沿失效 |
| 线性规划 | 单纯形/内点法 | 1 | 必须线性化 | 建模误差大 |
| 动态规划 | 状态递推 | 1~2 | 状态爆炸 | 维数灾 |
| 粒子群 | 速度-位置更新 | 1~3 | 依赖惩罚 | 易早熟,多峰问题差 |
| NSGA-Ⅱ | 拥挤距离 | 2~3 | 罚函数方便 | 3目标以上多样性差 |
| NSGA-Ⅲ | 参考点+小生境 | 2~15 | 罚函数方便 | 目标数太大会跑不动 |
所以做梯级水火联合调度这种天然带3个以上优化目标(成本、排放、水库期末蓄能等)的问题,NSGA-Ⅲ基本是当前进化算法路线里最顺手的选项。当然它也不是万能的,目标超过15个以后参考点数量会爆炸,计算代价陡增。对这个课题来说,控制在2到5个目标,正好是它的舒适区。
2. 把调度问题翻译成多目标优化数学模型
2.1 目标函数怎么定:发电成本与排放强度的取舍
做调度优化的第一步,是先把“好”和“坏”量化成数学表达式。这个课题里最核心的两个目标,一是总发电成本,二是污染物排放量。
火电成本一般用煤耗特性二次曲线来表示,这也是电网调度里最常用的做法。对第i台火电机组在时段t的输出功率P_{i,t},煤耗量可以写成:
f_i(P_{i,t}) = a_i * P_{i,t}^2 + b_i * P_{i,t} + c_i
其中a_i、b_i、c_i是机组的历史拟合参数。全周期的燃料成本就是所有机组、所有时段求和,再乘以燃料单价。水电成本在这里可以近似视作零或一个很小的运行维护常数,因为来水本身不花钱,主要成本体现在前期建设投入里,日常优化调度一般不摊基建成本。所以总成本目标f1可以写为:
min f1 = sum_t sum_{i in thermal} (a_i * P_{i,t}^2 + b_i * P_{i,t} + c_i) * coal_price + sum_t sum_{j in hydro} M_j * P_hydro_{j,t}
其中M_j是水电单位出力的运行维护费,通常很小,取常数即可,不影响Pareto前沿的形态,只是为了数值上完整。
排放目标f2的做法类似,火电排放量也可以按负荷率拟合为二次函数。工程上常见的是CO2、SO2、NOx分别建模,但作为研究性课题,通常先合并成一个综合排放指标:
min f2 = sum_t sum_{i in thermal} (alpha_i * P_{i,t}^2 + beta_i * P_{i,t} + gamma_i)
这里的alpha、beta、gamma是根据机组排放监测数据拟合出来的系数。需要注意,成本和排放两个目标的量纲完全不同——一个可能是几十万元量级,一个是几百吨量级,这给后面NSGA-Ⅲ的处理带来了一个关键需求:归一化。
我遇到过不少人在这里直接套用目标函数,没做任何归一化,结果算法把所有搜索能力都花在数值大的目标上,排放目标的前沿几乎被压成一条直线。NSGA-Ⅲ内置的归一化和超平面构造正是为了解决这个问题的,但前提是你在建模阶段就不要随意省略量纲信息。
2.2 约束条件的分类与解题思路
调度问题真正的技术含量,集中在约束条件的处理上。这个课题里常见的约束可以分成三类。
第一类是“编码时就能满足的约束”。比如火电出力上下限、发电流量上下限,这些在生成决策变量时直接限定随机数的采样区间就行,只要不越界,天然满足。第二类是“需要递推计算才能验证的约束”,典型的就是梯级水量平衡。第三类是“完全没法靠编码保证、只能事后检查的约束”,比如系统功率平衡,它由所有机组出力共同决定,只能在目标函数评估时通过罚函数或可行性判断来处理。
具体到这个课题,主要约束如下:
- 系统功率平衡:sum P_thermal + sum P_hydro = Load_t,这是硬约束。
- 火电出力上下限:P_min <= P_{i,t} <= P_max。
- 火电爬坡约束:|P_{i,t} - P_{i,t-1}| <= R_i。
- 梯级水量平衡:V_{j,t+1} = V_{j,t} + (I_{j,t} + Q_{j-1,t-L} - Qgen_{j,t} - S_{j,t}) * dt。
- 库容上下限:V_min <= V_{j,t} <= V_max。
- 发电流量上下限:0 <= Qgen_{j,t} <= Qgen_max。
- 期末库容约束:V_{j,T} 尽量贴近调度目标 V_end。
其中水量平衡式里的Q_{j-1,t-L},表示上游电站发电流量经过L个时段的输送延迟后到达本站。这个L如果在模型里完全忽略,上游放水和下游来水就变成了同时段事件,梯级库容曲线很容易失真,后面我会单独讲这个坑。
约束处理我个人的习惯是多数用罚函数,少数用递推检查。功率平衡这种最核心的约束,罚函数权重一定要给足;库容和用水量平衡这类约束,则通过状态递推和边界裁剪来处理,而不是全部丢给罚函数,否则进化算法会在不可行域里耗费大量时间。
2.3 决策变量选择与Matlab里的代码结构
决策变量怎么选,直接影响代码复杂度和收敛速度。这个课题里我推荐直接用“火电各时段出力 + 梯级各站各时段发电流量”作为决策变量。设系统里有NT台火电机组、NH个水电站、T个调度时段,那么决策变量维度是D = NT * T + NH * T。
举个例子,3台火电、4个梯级水电站、24个时段,D = 324 + 424 = 168,属于中等规模,NSGA-Ⅲ处理起来没有问题。每个个体就是一行168维的向量,前半段是火电出力序列,后半段是发电流量序列。
也许你会问,为什么不直接用水库水位作为决策变量?水位和库容的换算关系非线性很强,而且水位上下限的允许区间窄,随机采样时很容易越界,初始化可行率低。发电流量的允许范围相对宽裕,而且水电站出力通常可以直接用发电流量和净水头近似计算,递推库容也更顺。
在Matlab里,初始化种群的代码就是一个双层循环,外层是种群个体,内层是决策变量维度:
function pop = initialize_population(N, data) % N: 种群规模 % data: 包含机组参数、时段数、边界条件 nThermal = data.nThermal; nHydro = data.nHydro; T = data.T; D = nThermal * T + nHydro * T; pop = zeros(N, D); for i = 1:N for k = 1:(nThermal * T) idx = mod(k-1, T) + 1; j = floor((k-1) / T) + 1; % 火电出力在上下限之间均匀采样 pop(i, k) = data.Pmin(j) + rand * (data.Pmax(j) - data.Pmin(j)); end % 发电流量部分类似,在Qmin和Qmax之间采样 for k = 1:(nHydro * T) idx = mod(k-1, T) + 1; j = floor((k-1) / T) + 1; pop(i, nThermal*T + k) = data.Qmin(j) + rand * (data.Qmax(j) - data.Qmin(j)); end end end这里有一个容易被忽略的细节:火电出力部分如果机械地按“机组-时段”顺序排列,后面的目标函数评估时索引会绕乱。我习惯先按时段排、再按机组排,也就是把每个时段的全部机组出力放在连续的位置上,这样在做系统功率平衡检查时可以直接按列求和。
3. 参考点才是NSGA-Ⅲ的灵魂:算法机制拆解
3.1 非支配排序是怎么运作的
NSGA-Ⅲ的前半部分延续了NSGA-Ⅱ的经典框架,第一件事就是非支配排序。多目标优化里,解A支配解B是指A在所有目标上都不比B差,且至少有一个目标严格优于B。例如成本更低同时排放也低的解,显然支配成本和排放都更高的解;但如果A成本低排放高,B成本高排放低,两者互不支配,就都算Pareto前沿上的候选。
非支配排序要做的,是给整个种群分层。先把所有不被任何解支配的个体放进第一层,然后去掉它们,在剩余解里再找出一层,如此循环。最终得到的层级F1、F2、F3等,层级越小说明解的“综合优势”越强,在环境选择时优先保留。
实现上可以用经典的O(M*N^2)双循环判断,也可以用更快的非支配排序算法。Matlab里我一般自己写判断函数,因为算法工具箱里的排序不一定是你要的版本。基本逻辑是记录每个解被多少解支配,以及它支配了哪些解,然后从被支配数为0的个体开始逐层剥离。
3.2 从拥挤距离到参考点:NSGA-Ⅲ的进化关键
NSGA-Ⅱ在环境选择时,对同一非支配层内的个体计算拥挤距离,也就是看个体在目标空间里和邻居的密集程度,距离大的优先保留。这个思路在二维目标下非常直观,就是把点摊开,别让一堆解挤在一起。
但问题出现在3个目标以上。拥挤距离的计算基于逐目标排序后相邻个体的距离,在高维空间里很难准确反映“稀疏程度”,结果就是种群容易聚到某些目标偏好方向上,Pareto前沿覆盖残缺。比如成本-排放-期末蓄能三个目标时,很多解可能都在成本低但蓄能低的角落,中间区域反而没人。
NSGA-Ⅲ的解决思路是:不在选择阶段看个体之间的相对距离,而是预先在目标空间里布一套均匀分布的参考点,然后让解尽可能去覆盖这些参考点方向。谁覆盖的参考点方向越独特,谁就越值得保留。这样即使目标维度增加,只要参考点分布均匀,种群多样性就能保持住。
参考点的生成通常用Das-Dennis方法,在M维单位单纯形上均匀取点。如果每个维度划分成p份,参考点数量H = C(M+p-1, p)。举例:2个目标p=5时H=6;3个目标p=10时H=66;4个目标p=10时H=286。可以看到目标数或者分割数一提高,参考点数量会迅速膨胀,超平面构造的计算量就上来了,这也是NSGA-Ⅲ不适合目标数太多问题的原因。
3.3 归一化、关联操作与小生境计数
参考点布好了,但不同目标的量纲差异会让参考点失去意义。所以NSGA-Ⅲ做了三步关键操作。
第一步,确定理想点,也就是每个目标在当前种群里的最小值,然后把所有目标值平移减去理想点。
第二步,求每个目标方向上的极端点。所谓极端点,就是用一种标量化函数找出在每个目标方向上离原点最远的解,然后用这些极端点构造一个M维超平面。这个超平面的截距就用来把每个目标归一化到[0,1]区间。
第三步,把种群里的每个解和所有参考点关联。每个参考点对应一条从原点出发的射线,解关联到哪条射线上,就看它和哪条射线的垂直距离最近。然后统计每个参考点被多少个解关联,这就是小生境计数。
环境选择时,层内的个体按照“关联到小生境计数少的方向优先”的原则被选入下一代。这一步本质上是在维护多样性:已经有解的参考点方向,容易在竞争中再给其他方向让路。
关于极端点构造,有一个细节容易写错,就是标量化函数里的权重w。w必须是正数还是非负数,代码实现时经常搞混。标准写法里w >= 0,但纯零权重会导致被加权的目标没被约束,极端点定位不准。我按照常见实现处理时习惯加一个微小的epsilon避免除零问题。
3.4 为什么在3目标以上NSGA-Ⅱ不够用
这个问题值得多说两句,因为很多人做两目标时用NSGA-Ⅱ效果不错,换到3目标就懵了。我做过一个对照实验,同一套梯级水火调度模型,分别用NSGA-Ⅱ和NSGA-Ⅲ跑3目标,结果NSGA-Ⅱ的前沿在目标空间里明显集中在两三个区域,而NSGA-Ⅲ能铺满整个曲面。
原因是多方面的。拥挤距离忽略了目标之间的关联结构,而参考点方法本质上是把目标空间分割成固定方向的小生境,更适合高维拓扑。另外NSGA-Ⅲ在归一化时自动处理了量纲差异,NSGA-Ⅱ如果不在目标函数阶段手动加权,很难处理成本(万元级)和排放(吨级)这种差异极大的情况。
所以这个课题我最终选NSGA-Ⅲ,不是因为它“更新更厉害”,而是因为它的多样性维持机制和我们的问题特性对上了。如果你只做成本+排放两目标,NSGA-Ⅱ完全可以,不必执着于用新算法;一旦目标是3个以上,NSGA-Ⅲ就是一个更稳妥的选择。
4. Matlab代码主干:从初始化到进化循环
4.1 决策变量编码与种群初始化的实现细节
前面提过决策变量编码的基本形式,这里展开说说初始化时容易踩的坑。种群规模N的选取有一个常用参考,就是让N不小于参考点数量H。3目标分割数p=12时H=91,所以N可以取92或96。4目标p=6时H=84,N取84或88。这样每个参考点方向基本都有个体去关联,不会出现大量空方向。
初始化时除了均匀采样,还有一个重要步骤,就是递推检查梯级库容。随机生成发电流量序列后,按照水量平衡方程从第一时段算到最后一个时段,如果某个水库的库容超出或低于限值,有几种处理办法:
- 简单粗暴:直接把这个个体丢掉重新采样。缺点是在约束严格时生成一个可行个体可能要试很多次。
- 温和处理:保留这个个体,但把越限程度记录到约束违例向量里,由目标函数罚函数来收拾。
我推荐第二种。进化算法允许不可行解存在,只要在选择压力下逐渐淘汰它们就行。完全丢弃不可行个体的做法,会让初始种群多样性大幅下降,而且梯级调度这种问题约束太多,想生成全部可行的初始种群代价很高。
具体代码不做完整贴出,但建议把水量平衡递推写成一个独立的函数,比如hydro_state(x, data),输入决策变量,输出每个时段各站的库容和出力矩阵。这个函数要在目标函数评估里反复调用,性能会影响整体运行时间,尽量向量化,别在里面对时段写for循环。
4.2 主循环结构:父代、子代合并与环境选择
NSGA-Ⅲ的主循环并不复杂,逻辑上和NSGA-Ⅱ几乎一样,关键差异只在环境选择环节。
opt.N = 96; % 种群规模 opt.MaxGen = 500; % 最大进化代数 opt.M = 3; % 目标数量 opt.p = 12; % 参考点分割数 % 生成参考点 [ref_dir, H] = generate_reference_points(opt.M, opt.p); % 初始化 population = initialize_population(opt.N, data); [obj, cons] = evaluate_population(population, data); % 记录原始目标+约束,后续NSGA-III需要 for gen = 1:opt.MaxGen % 1. 用交叉变异生成子代种群 offspring = genetic_operators(population, data); [obj_off, cons_off] = evaluate_population(offspring, data); % 2. 合并父代和子代 population_all = [population; offspring]; obj_all = [obj; obj_off]; cons_all = [cons; cons_off]; % 3. 非支配排序分层 F = nondominated_sort(obj_all); % 4. NSGA-III环境选择,从合并种群中选出N个个体进入下一代 population = environmental_selection(population_all, obj_all, cons_all, F, ref_dir, opt.N); end % 最后一代的Pareto前沿 F = nondominated_sort(obj); pareto_front = obj(F{1}, :);环境选择函数里,先一层一层往里加,直到加满N为止。最后需要从某一层部分选入时,就用前面说的归一化+关联+小生境计数那一套。这个函数是代码里最长的部分,也是最容易出bug的地方,建议对照算法原文逐行验证。
写代码时有一个常见体验:如果直接把网上开源NSGA-Ⅲ代码拿过来套,往往会发现它的目标函数接口是黑盒,根本不给你传约束违例向量,罚函数也不知道往哪儿塞。这个课题里最好自己重写环境选择模块,把约束罚函数的逻辑融合进去,否则后面做约束违反处理很别扭。
4.3 目标函数评估与功率平衡的罚函数写法
目标函数评估这个环节,既是仿真逻辑的核心,也是性能瓶颈。我给出一个常用的评估框架,你可以直接参考改造:
function [obj, cons] = evaluate_function(x, data) nThermal = data.nThermal; nHydro = data.nHydro; T = data.T; % 拆解决策变量 P_thermal = reshape(x(1:nThermal*T), [T, nThermal]); % 每个时段所有火电出力 Qgen = reshape(x(nThermal*T+1:end), [T, nHydro]); % 每个时段各站发电流量 % 递推计算梯级水电库容和出力 [V, P_hydro] = hydro_simulation(Qgen, data); % 目标1:总煤耗成本 + 水电运维 f1 = 0; for t = 1:T for k = 1:nThermal f1 = f1 + data.a(k)*P_thermal(t,k)^2 + data.b(k)*P_thermal(t,k) + data.c(k); end f1 = f1 + sum(data.M .* P_hydro(t,:)); end % 目标2:火电排放 f2 = 0; for t = 1:T for k = 1:nThermal f2 = f2 + data.alpha(k)*P_thermal(t,k)^2 + data.beta(k)*P_thermal(t,k) + data.gamma(k); end end % 系统功率平衡误差(硬约束,罚函数处理) P_total = sum(P_thermal, 2) + sum(P_hydro, 2); power_balance_error = sum(abs(P_total - data.Load)'); % 约束违例向量:可以把爬坡、库容越限等也汇总进来 cons = power_balance_error; % 可扩展 % 罚函数权重这里取经验值,下面会讲怎么标定 lambda = data.penalty_weight; obj = [f1 + lambda * power_balance_error, f2 + lambda * power_balance_error]; end罚函数权重lambda怎么标定?我自己的经验是先跑一次完全不罚的版本,看功率平衡最大偏差量级是多少。比如最大偏差100MW,成本目标量级5000万元,那罚权重取1e5到1e6之间比较合适。这样100MW的偏差会折算成1000万左右的目标损失,足以让可行解占据优势,又不至于因为罚值过大导致数值震荡。
有一个新手容易犯的错误,是给所有约束用同一个罚权重。这很危险,因为爬坡约束的偏差单位和功率平衡的偏差单位都是MW,但物理意义完全不同,混在一起会让算法被某一类约束牵着走。我给每个约束单独设权重,最后在环境选择时再统一处理,这样调试起来定位问题也快。
5. 跑出来的Pareto前沿如何读取与决策
5.1 收敛性和多样性怎么判断
算法跑完,第一件事不是急着选方案,而是先看优化效果。二维目标时直接散点图最直观:
plot(pareto_front(:,1), pareto_front(:,2), '.'); xlabel('总成本(元)'); ylabel('排放总量(吨)');一个正常收敛的结果,Pareto前沿应该呈现出平滑递减的曲线,从高成本低排放端延伸到低成本高排放端。如果点很离散,或者某个区间缺了一大块,就要怀疑是多样性出了问题或收敛度不够。
判断收敛性我常用两个办法。一是把第一代和最后一代的前沿叠在一起画,观察前沿是否持续向下包络推进,如果最后几百代没有明显变化,基本可以认为收敛了。二是记录每一代种群的Hypervolume(超体积)指标,也就是Pareto前沿和目标空间参考点围成的面积/体积,HV曲线趋于平台时代表收敛。Matlab里可以自己写二维HV计算,三维以上面积计算比较麻烦,工程上可以直接看前沿图的世代变化。
多样性问题则要关注参考点的关联计数。如果种群里的解只关联到一小部分参考点方向,说明多样化机制没有完全发挥。可以输出每个参考点关联个体数的直方图,看看是不是有明显的空方向。
5.2 从Pareto前沿中挑选最终调度方案的常用方法
Pareto前沿给出的是全系列“不差”的备选方案,真正落到实际调度时,决策者需要从中选一个。最常用的两种方法是模糊隶属度法和最小距离法。
模糊隶属度法的思想是,对每个目标,把前沿上最好的值映射为1,最差的映射为0,然后每个解计算所有目标的平均贴近度,贴近度最高的解作为推荐方案。这个方法的好处是简单、可解释,缺点是它假设所有目标权重均等,如果决策者更看重成本,需要为不同目标分配权重。
最小距离法的实现更直接,就是找到离理想点(各目标同时取最优的组合)欧氏距离最近的解。为了避免量纲问题,先用最小值-最大值归一化再算距离。代码可以这样写:
min_f1 = min(pareto_front(:,1)); max_f1 = max(pareto_front(:,1)); min_f2 = min(pareto_front(:,2)); max_f2 = max(pareto_front(:,2)); norm_f1 = (pareto_front(:,1) - min_f1) / (max_f1 - min_f1); norm_f2 = (pareto_front(:,2) - min_f2) / (max_f2 - min_f2); dist = sqrt(norm_f1.^2 + norm_f2.^2); [~, idx] = min(dist); best_solution = pareto_front(idx, :);用这个best_solution反查出对应的决策变量,再代入仿真模型做一次完整校验。这一步不能省,因为罚函数的存在意味着有些前沿解可能有轻微约束违界,直接拿去用会出问题。
5.3 对比实验怎么设计:证明NSGA-Ⅲ的有效性
做研究型项目往往需要对比实验结果。我建议至少和NSGA-Ⅱ做一组对照,比较指标用HV、IGD(反向世代距离)和运行时间。工程上IGD需要参考前沿,一个可用的替代方案是把NSGA-Ⅲ多次独立运行合并后的非支配解集近似当作参考前沿。
设计对比实验时必须注意随机性。多目标进化算法是随机算法,至少跑5到10次独立实验,取均值和标准差,画箱线图。每次都固定随机种子,比如用rng(k)来控制,保证结果可以复现。这里给出一个表格参考格式:
| 算法 | HV均值 | HV标准差 | IGD均值 | 运行时间(秒) |
|---|---|---|---|---|
| NSGA-Ⅱ | 0.412 | 0.023 | 0.087 | 68.5 |
| NSGA-Ⅲ | 0.487 | 0.011 | 0.043 | 83.2 |
当然,具体的数值取决于你的算例规模、种群大小和代数,重要的是实验设计口径一致。我在实际对比中发现,NSGA-Ⅲ在3目标下的HV通常领先5%到15%,但运行时间也会多出15%到25%,因为参考点关联计算是有额外开销的。如果你的项目只要求2目标,那NSGA-Ⅲ的优势就不明显,硬上反而亏。
6. 实际调试中容易踩的坑与参数调优
6.1 参考点生成与种群规模不匹配的问题
这个坑我踩过不止一次。Das-Dennis方法生成的参考点数量是组合数,很多情况下H和种群规模N对不上。比如3目标p=12时H=91,如果你把N设成96,那91个参考点方向里只有91个有对应方向,剩余5个个体怎么选?处理不好,种群多样性就打了折扣。
工程上有两种常见策略。一种是让N和H严格相等,种群规模正好等于参考点数,每个方向保证有“坑位”。另一种是N大于H时,给部分参考点复制额外的“虚拟参考点”,相当于某些方向可以有多个个体。我建议没有特殊需求时,直接让N=H或略大一点,代码简单很多。
还有,目标数较高时参考点数爆炸的问题也要预防。比如4目标p=10时H=286,N也取286的话,每一代评估286个个体不算多,但参考点关联计算要构建4维超平面,复杂度上来了。如果只是常规研究,4目标时p取6左右就够了,H=84,计算量友好得多。
6.2 梯级水量平衡与水流时滞的简化边界
梯级水电站建模里,水流时滞是个老生常谈的问题。严格说,上游出库流经一定距离到达下游入库,需要时间,这段时间就是时滞。如果你的调度时段是1小时,而上下游距离很近导致水流行进时间小于1小时,时滞可以忽略;但流域尺度大、站间距离远的时候,忽略时滞会让同一时段上游出库和下游入库“撞车”,下游库容被虚高评估。
我在代码里用了一个很轻量级的处理方式:把上游的上一两个时段的出库流量,作为当前时段下游的入库流量。具体做法是在hydro_simulation函数里维护一个流量队列,每个站记录最近L个时段的上游来水,然后按时序平移取用。代码只需多几行,但对库容曲线的准确性影响很大。
另一个常被忽略的问题是生态流量约束。梯级调度里下游河道通常有一个最小下泄流量要求,这个约束如果不加,算法很可能会为了发电效益把下泄压到零。处理时我建议作为单独的约束违例项加入罚函数,但权重不要像功率平衡那么高,否则可行域被切得太小,Pareto前沿会瞬间缩水。调这个权重的办法和前面一样,先跑无惩罚版本,看最大违例量再定初值。
6.3 交叉变异参数与罚函数权重的经验标定
NSGA-Ⅲ的交叉变异操作一般沿用NSGA-Ⅱ的SBX交叉和多项式变异。经验参数是:模拟二进制交叉分布指数eta_c=20,交叉概率p_c=0.9;多项式变异分布指数eta_m=20,变异概率p_m=1/D,其中D是决策变量维度。这套参数在各种算例里表现都比较稳,遇到收敛慢的情况优先调eta_m到50或100,变异步长更大,更容易跳出局部区域。
罚函数权重也是个大坑。权重太小,很多不可行解混进Pareto前沿;权重太大,目标值被罚值淹没,数值稳定性变差,甚至可能因为浮点数精度问题让所有解都显得差不多。我一般按目标函数量级的1%到10%来定初始罚权重,然后跑50代看未违界比例。如果初始种群违界率超过80%,说明罚权重设置太苛刻或决策变量采样范围不合理,需要回调。
多目标优化还有一个反复出现的教训:单看最终前沿图判断算法好坏是不靠谱的。进化算法有随机性,一次运行运气好可能出好结果,运气差就崩。我习惯把每代的平均违界量、目标值中位数都打印出来,看曲线是否平滑下降。如果突然跳变,大概率是罚权重不合理,或者交叉变异把大量不可行解搅了进来。
6.4 性能优化:向量化评估与并行计算
最后聊聊Matlab代码的性能问题。这个课题的仿真计算主要集中在目标函数评估里,如果写成三层for循环(个体、时段、机组),跑500代可能要几个小时。先把能向量化的部分全向量化,尤其是煤耗求和、排放求和这些纯运算,尽量用矩阵乘法一次性算完。
此外,种群评估天然适合给循环加parfor。但要注意,parfor里每个worker要能访问到data结构体,最好用parfor而不是直接改所有for,并且确认随机数流的独立性,否则并行结果可能无法复现。我自己在调试阶段用单线程加小规模种群,出图好看了再上完整规模并行,这样省时间也少踩坑。
还有一个容易被忽视的优化点,是提前把参考点、极端点计算等与代数无关的中间结果缓存起来,而不是每代重算。参考点生成只在初始化时做一次,而归一化中涉及的极端点每代都要重新算,这部分无法避免,但可以用矩阵操作替代循环遍历,提速效果明显。
说到收尾,我最后再分享一个小习惯:保存中间结果,别等500代全跑完才看输出。我去年的一个项目就是因为跑到第300代发现罚权重设错了,整轮白跑。后来改成每50代自动保存一次当前种群和前沿,出了问题能从上次检查点接着跑,省出的时间足够多调好几轮参数。多目标优化的调试本来就是个试错过程,给自己留好后路,才敢放手改参数。