简介:这是面向多目标优化研究者及Matlab初学者的NSGA-II算法完整实现包。算法由Deb等提出,通过非支配排序与拥挤距离维护一组Pareto前沿解,适合处理目标函数冲突的工程与运筹问题。压缩包内共10个文件,其中8个.m脚本覆盖初始化变量、锦标赛选择、模拟二进制交叉、多项式变异、非支配排序、目标评估及染色体替换等核心步骤,主程序nsga_22.m可直接运行;另含2个xlsx结果表,便于查看寻优输出与后续分析。整个压缩包仅660KB,轻量易部署。目前已有3333人学习下载。对照代码可以快速掌握非支配排序、精英保留及拥挤距离计算的具体实现,并以此为模板改造成自定义目标函数,用于教学演示或实际项目中的多目标寻优,是理解NSGA-II原理、上手Matlab编程的高质量实用素材。
1. NSGA2与Matlab的多目标求解:调参一天看不到理想前沿时问题出在哪
很多人拿着NSGA2的Matlab代码跑多目标优化,第一轮迭代看着还行,改两行目标函数之后就完全不是那么回事:要么前沿挤在一个角上,要么跑了几十代还是一堆重复解。这时候问题往往不在算法本身,而在你还没把“非支配排序”和“拥挤度”这两个核心机制吃透。NSGA2是解决多目标优化问题最常用的遗传算法家族成员,它的长处不是像加权和那样把多个目标压成一个数,而是同时找出一整套有代表性的最优解,让决策者后面去挑。这篇笔记把NSGA2的原理、Matlab骨架实现、最常见翻车点和验证习惯一次讲完,适合正在做多目标问题但不确定参数和代码怎么落地的开发者。
2. 非支配排序与拥挤度:NSGA2的两大核心机制是怎么定义的
2.1 帕累托支配:两个解谁赢谁输的判定规则
多目标优化里,解之间不是所有时候都有明确的胜负。假设现在有两个目标,都是越小越好,解A的两个目标值是(2,5),解B是(3,1),A在第一个目标上占优,B在第二个目标上占优,谁也没完全压过谁。按数学上的说法,它们互不支配。这时如果出现一个解C=(1,2),它在两个目标上都不比A、B差,而且至少有一个目标严格更好,那C支配A也支配B。
支配关系的定义用一句话说:对最小化问题,解X支配解Y,当且仅当X在所有目标上都不劣于Y,且至少在其中一个目标上严格优于Y。注意关键词是“所有目标都不劣”加“至少一个严格优”。只要有一个目标比对方差,哪怕其他目标再好,也不是支配关系,只是互不支配。
非支配排序做的事情,就是把整个种群按支配关系一层层揭开:先找出所有不被任何个体支配的解,作为第一层帕累托前沿;把这一层暂时拿掉,再在剩余个体里找不被任何剩余个体支配的解,作为第二层;重复这个过程直到所有个体都被分层。层数越靠前,说明这个解越“接近最优”。
举个例子,5个个体在两个最小化目标下的值如下表:
| 个体 | f1 | f2 | 所在前沿 |
|---|---|---|---|
| P1 | 2.0 | 8.0 | 1 |
| P2 | 3.0 | 5.0 | 1 |
| P3 | 4.5 | 4.0 | 1 |
| P4 | 5.0 | 8.0 | 2 |
| P5 | 6.0 | 6.0 | 2 |
P1、P2、P3之间互不支配,谁也没法完全压过另外两个,所以它们一起进第一前沿。P4被P1支配(f1和f2都比P1差),被P2支配,被P3支配;P5被P2和P3支配,但P4和P5谁也不能完全压过谁,所以它们进第二前沿。这个分层结果就是选择的基础:第一前沿的个体一定比第二前沿优先保留。
为什么强调这个机制?因为传统加权和法把多个目标乘上权重加起来变成一个数,等于人为预设了目标之间的“兑换率”。实际工程里这个兑换率往往拍脑袋定,比如成本和性能的关系根本说不准。NSGA2不做这个预设,它只维护“哪些解不被其他解压过”这一套排序关系,让解的优劣由数据说话,而不是由权重说话。
2.2 拥挤度距离:保证解的分布不扎堆
非支配排序只告诉你在同一层里“谁和谁是同一级别”,但同一层里几十个解到底保留哪些、淘汰哪些,还需要第二个指标——拥挤度距离。
拥挤度距离的直观含义是:这个解在目标空间里,离旁边两个邻居有多远。计算分三步。第一步,对同一个前沿内的所有个体,按某个目标值从小到大排序;第二步,排在最前和最后的边界个体的拥挤度直接设为无穷大,因为它们无论如何也不会被淘汰,淘汰它们会让前沿端点丢失;第三步,对中间的每个个体,取它前后两个邻居在该目标上的差值,除以该目标在全体个体中的取值范围,得到一个无量纲的相对距离,把所有目标的相对距离累加起来,就是这个个体的拥挤度。
公式表达是:d_i = sum_j ( f(i+1, j) - f(i-1, j) ) / ( fmax_j - fmin_j ),其中j遍历所有目标。分母是第j个目标在这一前沿内的最大值减最小值,作用是消除不同目标之间的量纲差异。如果一个前沿内某目标的所有值都一样,分母为0,这时这一维直接跳过不加,避免除零。
边界个体设无穷大,是为了保证每一层前沿两端的极端解一定被活下来。想象一个双目标问题,最优前沿是一个从左下到右上的弧线,弧线两个端点往往对应牺牲一个目标成全球最值的方案,这种方案在中庸解扎堆时很容易被挤掉。不保留端点,下一代前沿就会往里收缩,跑几十代后整个前沿缩成一个点。这就是很多调参失败案例里“前沿不见了”的直接原因。
如果某个前沿里只有两个个体,两个都是边界个体,拥挤度都是无穷大,说明它们都值得保留。
2.3 锦标赛选择与精英保留:NSGA2不会丢最优解的机制
有了非支配层级和拥挤度,NSGA2的选择规则就很清楚了:任意两个个体比较时,先看所在前沿,层数小的赢;如果两个个体在同一层,看拥挤度,拥挤度大的赢。这个规则叫二元锦标赛选择,整个选择过程的成本很低,不需要全局排序。
另一个关键设计是精英保留。普通遗传算法生成子代后直接用子代替换父代,万一父代里有接近最优的解,可能在交叉变异中被破坏。NSGA2采取另一个策略:把父代和子代合并成一个2N规模的临时种群,对这个合并种群做完整的非支配排序和拥挤度计算,然后按“层数优先、拥挤度次之”的顺序排序,取前N个作为下一代。
这带来的直接好处是:上一代哪怕所有个体都进了第二第三前沿,只要它们比子代好,就能留在下一代里,最优解不会因为进化过程中的随机破坏而彻底丢失。这也是“NSGA2”这个二代版本名字里II的核心价值,第一版的NSGA没有这个设计,种群容易退化。
实际编码时,这个排序可以一句话实现:sortrows([rank, -dist]),先按rank升序,再按拥挤度降序,取前N行就是下一代。能看到,NSGA2的整体框架非常简洁:一个非支配排序、一个拥挤度计算、一个选择交叉变异的循环,所有复杂逻辑都被这三个模块拆干净了。
3. Matlab从初始化到迭代:一份可直接改目标函数的NSGA2骨架
3.1 问题定义与参数设置:变量个数、目标个数、边界怎么填
先看一个典型问题的定义。假设决策变量有6个,都在[0,1]之间,目标函数取经典的双目标测试形式:第一个目标只与第一个变量有关,第二个目标与全部变量有关,并且希望两个目标都越小越好。这种测试问题常用来验证NSGA2能不能跑出均匀分布的前沿,因为它的真实前沿是已知的,跑完能直观看差距。
%% 问题定义 D = 6; % 决策变量个数 lb = zeros(1, D); % 每个变量的下界 ub = ones(1, D); % 每个变量的上界 nM = 2; % 目标个数,这个例子里固定为2 %% NSGA2参数 N = 80; % 种群大小,保持偶数,因为交叉是一对一生成 MaxGen = 200; % 迭代代数,复杂问题往上加到500甚至1000 pc = 0.9; % 交叉概率,SBX的默认推荐区间是0.8~0.95 pm = 1/D; % 变异概率,常见做法是1除以决策变量个数 eta_c = 15; % SBX交叉分布指数,15~20是经典推荐 eta_m = 20; % 多项式变异分布指数,20左右用得多 %% 目标函数:输入N*D矩阵,输出N*2矩阵 g = @(x) 1 + 9 * mean(x(:, 2:end), 2); objfun = @(x) [x(:, 1), g(x) .* (1 - sqrt(x(:, 1) ./ g(x)))];这段代码定义一个函数句柄而不是单独写一个文件,好处是后面跑不同测试问题时只需要替换句柄内容。第一行g用了mean函数,把所有除x1以外的变量平均贡献到第二个目标里,这是经典测试问题的标准构造方式,目的是让第二目标与决策变量数量产生耦合,变量越多,多目标求解难度越大。
注意到objfun接受的是一个N×D矩阵,返回N×2矩阵。这个设计必须从一开始就保持,因为NSGA2的种群里所有个体是同时进化的,目标函数一次性批量计算能省下大量循环开销。如果写成每次只接受一个决策向量返回一行目标值,后面拥挤度计算和绘图时还要再包一层循环,性能差很多。
参数的选取逻辑这样理解:种群80对于两个目标的问题够用,如果目标数增加到三个或四个,前沿是一个三维以上的超曲面,需要更多个体去填充,N至少128甚至256。MaxGen=200是在这个简单测试问题上的起步值,如果你替换成真实工程问题,建议先设500跑一次,看前沿是否还在明显变化,如果是,继续加代数。
3.2 快速非支配排序:数据结构与Matlab实现
非支配排序里最直观的写法是双重循环,每个个体和所有其他个体比较支配关系,统计谁被谁支配,然后逐层剥离。
function rank = non_dominated_sort(f) % f: N*M矩阵,每行是一个个体的M个目标值,全部为最小化方向 N = size(f, 1); dom_count = zeros(N, 1); % 第i个个体被多少个个体支配 dom_set = cell(N, 1); % 第i个个体支配了哪些个体 % 第一遍:两两比较,记录支配关系 for i = 1:N for j = i+1:N if dominates(f(i,:), f(j,:)) dom_set{i} = [dom_set{i}, j]; dom_count(j) = dom_count(j) + 1; elseif dominates(f(j,:), f(i,:)) dom_set{j} = [dom_set{j}, i]; dom_count(i) = dom_count(i) + 1; end end end % 找出第一前沿:没有被任何个体支配 front = {}; front{1} = find(dom_count == 0); % 逐层剥离:把当前前沿支配的个体计数减1,减到0就进下一层 k = 1; while ~isempty(front{k}) Q = []; for i = front{k} for j = dom_set{i} dom_count(j) = dom_count(j) - 1; if dom_count(j) == 0 Q = [Q, j]; end end end k = k + 1; front{k} = Q; end front(end) = []; % 最后一层必然是空集,去掉 % 个体编号转成分层编号 rank = zeros(N, 1); for i = 1:numel(front) rank(front{i}) = i; end end function b = dominates(a, b) % a支配b的条件:a所有目标不大于b,且至少一个小于b b = all(a <= b) && any(a < b); end这个实现的逻辑分三段。第一段的双重循环建立完整的支配关系图,dom_count(i)统计有多少个体支配第i个个体,dom_set{i}记录第i个个体支配了谁。第二段是一个循环剥离过程:第一前沿是所有dom_count为0的个体;接下来把第一前沿支配过的每个个体的dom_count减1,一旦某个个体被自己支配的个体数全部扣完,说明除了已经去掉的前沿个体外,已经没有别的个体能支配它,它就进第二前沿。第三段只是把前沿编号回填到rank数组里。
dominates子函数里的条件严格符合帕累托支配定义,注意这里用的a是行向量,b也是行向量,all(a <= b)表示a在所有目标上都不比b差,any(a < b)表示至少一个目标严格更好。两个条件缺一个都不是严格支配。
复杂度方面,双重循环是O(N^2),对80个个体的种群完全没问题;如果N上到500且目标数又超过3个,这层比较会变成主要瓶颈,后面排坑那章再聊怎么处理。
3.3 拥挤度计算与选择交叉变异:主循环剩下那几步
拥挤度计算按前面讲的公式实现,注意几个边界条件需要单独处理。
function dist = crowding_distance(f, rank) N = size(f, 1); dist = zeros(N, 1); unique_fronts = unique(rank); for r = unique_fronts' idx = find(rank == r); if numel(idx) <= 2 % 前沿里不超过两个个体时,全部视为边界 dist(idx) = inf; continue; end nObj = size(f, 2); for m = 1:nObj [~, order] = sort(f(idx, m)); idx_sorted = idx(order); fmin = f(idx_sorted(1), m); fmax = f(idx_sorted(end), m); norm_range = fmax - fmin; % 边界个体距离设为无穷大,保证前沿端点被保留 dist(idx_sorted(1)) = inf; dist(idx_sorted(end)) = inf; if norm_range == 0 % 这一维所有值相同,跳过不做任何贡献 continue; end for i = 2:numel(idx_sorted)-1 neighbor_dist = f(idx_sorted(i+1), m) - f(idx_sorted(i-1), m); dist(idx_sorted(i)) = dist(idx_sorted(i)) + neighbor_dist / norm_range; end end end end单独说一下if range == 0这行。真实工程问题里经常出现某个目标在某一层前沿内总是一个定值,比如约束刚好卡死,这时分母为0,如果不跳过就会出现NaN,而NaN在排序里会被Matlab排到最前面,导致全部保留顺序错乱。跳过这一维后,其他目标仍然正常累计拥挤度,行为是合理的。
主循环实现包含了比较、交叉、变异、精英保留全链路:
%% 初始化 Pop = lb + rand(N, D) .* (ub - lb); Obj = objfun(Pop); rank = non_dominated_sort(Obj); dist = crowding_distance(Obj, rank); %% 迭代主循环 for gen = 1:MaxGen % 二元锦标赛选择,选出交配池 MatingPool = zeros(N, D); for i = 1:N cand = randi(N, 1, 2); if rank(cand(1)) < rank(cand(2)) winner = cand(1); elseif rank(cand(1)) > rank(cand(2)) winner = cand(2); else if dist(cand(1)) > dist(cand(2)) winner = cand(1); else winner = cand(2); end end MatingPool(i, :) = Pop(winner, :); end % SBX交叉与多项式变异,生成子代 Offspring = zeros(N, D); for i = 1:N/2 p1 = MatingPool(2*i-1, :); p2 = MatingPool(2*i, :); c1 = p1; c2 = p2; for j = 1:D if rand() <= pc u = rand(); if u <= 0.5 beta = (2*u)^(1/(eta_c+1)); else beta = (1/(2*(1-u)))^(1/(eta_c+1)); end c1(j) = 0.5 * ((1+beta)*p1(j) + (1-beta)*p2(j)); c2(j) = 0.5 * ((1-beta)*p1(j) + (1+beta)*p2(j)); end end c1 = polynomial_mutation(c1, lb, ub, pm, eta_m); c2 = polynomial_mutation(c2, lb, ub, pm, eta_m); Offspring(2*i-1, :) = c1; Offspring(2*i, :) = c2; end % 精英保留:父代子代合并后统一排序 PopAll = [Pop; Offspring]; ObjAll = [Obj; objfun(Offspring)]; rankAll = non_dominated_sort(ObjAll); distAll = crowding_distance(ObjAll, rankAll); % 综合排序:层数升序,同一层拥挤度降序 [~, order] = sortrows([rankAll, -distAll]); keep = order(1:N); Pop = PopAll(keep, :); Obj = ObjAll(keep, :); rank = rankAll(keep); dist = distAll(keep); end %% 绘制最终帕累托前沿 plot(Obj(:, 1), Obj(:, 2), 'o'); xlabel('f1'); ylabel('f2'); title('NSGA2 Pareto Front');SBX交叉里那个beta计算是模拟二进制交叉的标准公式,u是一个[0,1]随机数,eta_c越大,产生的后代越接近父代;eta_c越小,后代偏离越远。多项式变异同理,eta_m控制变异步长。
变异函数单独看:
function x_new = polynomial_mutation(x, lb, ub, pm, eta_m) x_new = x; for j = 1:numel(x) if rand() <= pm r = rand(); if r < 0.5 delta = (2*r)^(1/(eta_m+1)) - 1; else delta = 1 - (2*(1-r))^(1/(eta_m+1)); end x_new(j) = x(j) + delta * (ub(j) - lb(j)); x_new(j) = min(max(x_new(j), lb(j)), ub(j)); end end end这里对每个决策变量按概率pm独立变异,没有变异的变量保持原值。变异后的值用min和max夹回边界,这个夹取操作在Matlab里是对标量生效,如果x_new是向量就要配合循环或者用数组运算,实际使用时注意一下。
如果你把这套代码原样跑一遍,应该能看到一个从左下往右上分布的散点图,前沿点大致均匀铺在一条弧线上。如果出来的图是一坨密集的点或者空荡荡只有一两个点,多半出在下面第4章的某个坑里。
4. 排查:NSGA2最容易翻车的五个地方
4.1 可行解全灭:约束违反处理不当导致种群全是废物
现象:加了约束条件后,跑几十代,画出来的前沿点越来越少,最后只剩一两个;或者目标函数值出现明显不合理的异常值。
原因:很多人处理约束时直接把约束写成罚函数加到目标上,比如f1 = f1 + 100 * violation。罚系数太小,不可行解照样进入前沿;罚系数太大,可分解和不可行解之间数值差距悬殊,拥挤度计算被这一项带偏,整个前沿分布一团糟。另一种更隐蔽的原因是选择环节完全没有考虑约束,导致子代里掺杂大量不可行解,越进化越脏。
解决:最直接的做法是在锦标赛选择里先比约束违反总量,再比分层。给每个个体维护一个CV值,表示所有约束违反量之和,等于0就是可行解。选择时按以下顺序比较:两解CV都为0,正常按层数和拥挤度比;一个CV为0一个不为0,可行解直接赢;两个CV都不为0,CV小的赢。这个逻辑保证可行解优先,但不彻底丢弃所有不可行解,给约束边界附近的搜索留了空间。
function winner = select_tournament(idx1, idx2, rank, dist, CV) if CV(idx1) < CV(idx2) winner = idx1; elseif CV(idx2) < CV(idx1) winner = idx2; else % 两者约束状态相同,按标准规则比较 if rank(idx1) < rank(idx2) winner = idx1; elseif rank(idx1) > rank(idx2) winner = idx2; else if dist(idx1) > dist(idx2) winner = idx1; else winner = idx2; end end end end核心参数是约束违反总量怎么算。常见做法是把每个约束都写成不大于0的形式,违反部分max(0, g_i(x)),全部累加。这套方案能处理绝大多数带约束工程问题,比罚函数可控得多。
4.2 帕累托前沿只集中在决策空间某个角落
现象:前沿点确实存在,但全部挤在目标空间的一角,弧线应有的两端没铺开,看起来像半个前沿。
原因:第一个可能原因是变量边界写反了,或者个别变量上下界设置范围太大,让变异很难在小范围内产生有效扰动。第二个原因是变异步长和变量范围不匹配,比如一个变量变化范围是0到10000,另一个变量是0到1,使用统一的eta_m和pm时,小范围变量几乎每次变异都直接跳出去再被夹回边界,等于失效。
解决:把决策变量全部归一化到[0,1]区间内参与进化,计算完目标函数后再映射回真实取值范围。归一化之后,变异和交叉操作的相对步长对所有变量一视同仁,不会出现某个变量被边缘化的情况。真实工程里我还要先排查一遍上下界向量lb和ub有没有填反,这个低级错误能把前沿彻底带偏,却常常是最耗时间的坑。
4.3 目标函数量纲差距大导致拥挤度被某一维带走
现象:第一个目标值的范围是1到10,第二个目标值的范围是1000到10000。跑出来的前沿在第二目标方向上铺得很开,但第一目标方向几乎只有一两个值,前沿看起来像一条竖直线,而不是正常弧线。
原因:拥挤度距离计算里各维目标差值的累加是直接相加的,数值大的目标占主导地位。拥挤距离大的个体集中在数值大的目标维度上,选择压力就偏向那一维,另一维很难被单独保留下来。这是NSGA2一个已知的弱点,标准和简化实现里都没有自动解决。
解决:在计算拥挤度之前,对每一维目标做归一化。具体来说,读取当前合并种群中该目标的最小值和最大值,把目标值线性映射到[0,1]区间,然后用归一化后的值计算差距。归一化里注意两点:一是要对每次合并后的2N个个体整体做归一化,不能只用父代或子代的单独范围;二是如果某维范围过于窄(最大值最小值差小于某个阈值,如1e-6),直接把该维设为无贡献,防止数值抖动。
% 在 crowding_distance 内部对每一维目标做归一化后再累计 for m = 1:nObj col = f(idx, m); lo = min(col); hi = max(col); if hi - lo < 1e-12 continue; end col_norm = (col - lo) / (hi - lo); [~, order] = sort(col_norm); idx_sorted = idx(order); dist(idx_sorted(1)) = inf; dist(idx_sorted(end)) = inf; for i = 2:numel(idx_sorted)-1 dist(idx_sorted(i)) = dist(idx_sorted(i)) + ... (col_norm(i+1) - col_norm(i-1)); end end这样处理后,每一维目标对拥挤度的贡献最大都是1量级,不会再被大数值目标绑架。代价是同一前沿内不同代之间的拥挤度数值不再可比,但选择比较只在同一时间内进行,所以不影响正确性。
4.4 迭代到一半种群多样性消失:变异率不是越大越好
现象:前50代前沿还在缓慢变化,后面50代完全不动,画出来每代结果一模一样,或者所有点最后收敛到一个区域。
原因:变异率设置过低会让种群失去探索能力,过早收敛;变异率过高会把已经找到的好区域破坏掉,让搜索一直在原地踏步。很多人看前沿不动就加大变异率,结果更糟。
解决:调整变异率时不要凭感觉,先试两个参考值:pm=1/D是比较均衡的起点,如果D=6就大约0.17,意味着每个个体平均有不到一个变量发生变异;如果前沿收敛过快,把变异率提高到2/D,给更多探索机会;如果收敛不稳定、结果抖动大,降到0.5/D。另一个更实用的做法是分阶段降低变异率:前一半代数用pm=2/D,后一半代数用pm=0.5/D。
if gen < MaxGen/2 pm_current = 2/D; else pm_current = 0.5/D; end这个分段策略有直观道理:进化前期需要广泛探索,找到有希望的区域;后期需要精细挖掘,把前期发现的区域做局部搜索。比固定一个变异率跑全程要稳。
4.5 Matlab跑一批要两小时:向量化带来的提速
现象:种群规模只有80,代数也只有200,但整套代码跑完要好几个小时,检查后发现目标函数内部用了大量for循环逐个体计算。
原因:这通常是目标函数写的太“串行”导致的。比如目标函数里对每个个体单独调用一次外部程序,或者用了循环算累加,而没有利用Matlab的矩阵运算。NSGA2的每一代都要对2N个个体计算一次目标函数,如果目标函数单次执行要几毫秒,200代80个种群总调用次数在两万次左右,累积起来非常可观。
解决:第一优先级是让目标函数支持批处理,输入是N×D矩阵,输出是N×M矩阵。所有能用矩阵运算符表达的地方,绝对不用循环。算累加用sum或mean,算条件用逻辑索引,计算时间能降到原来的几十分之一。第二优先级是预分配所有数组,尤其是Offspring和MatingPool,避免循环里不断拼接数组。Matlab里[A; B]拼接会反复申请内存,数量一大就会拖慢整体速度。
有一种情况矩阵批处理做不了:目标函数必须调用外部商业求解器或仿真软件,每次只能算一个方案。这时能做的是把目标函数改成可分块计算的版本,用parfor并行跑。这个在后续文章里再细说,这里先知道方向即可。
5. 跑通之后:帕累托前沿的验证与三组复跑习惯
5.1 先画图再看数:前沿分布和收敛情况的判读法
代码跑通后第一件事不是看数据,而是画图。双目标问题直接画散点图,去看前沿是否有明显边界、端点是否够极端、中段是否均匀。真正的帕累托前沿通常是一条平滑弧线,散点分布应尽量贴近这条弧线而不是糊成一片。如果点之间有空档,说明某些区域缺少代表性解,下一步该调整eta_m增大局部探索;如果端点缺失,说明边界个体在精英保留时被挤掉了,去检查拥挤度的inf边界是否正确设置。
三目标以上问题时,散点图看不出整体分布,建议投影到两两目标构成的平面,画多个子图对比,同时记录每一代的最小目标值变化曲线,确认是否还在下降。如果第150代和200代画出来的图几乎重合,说明收敛得差不多了,再跑更多代数意义不大。
5.2 重复跑N次,取中间那次而不是最好那次
NSGA2是随机算法,初始化种群、锦标赛选择的随机抽取、交叉变异的随机数都导致每次运行结果不同。只跑一次就下结论非常危险,因为这次结果可能恰好落在随机性的幸运侧或倒霉侧。我自己的习惯是同一组参数跑5到10次,把每次的最终前沿叠加画到一张图上,看骨架是否一致。
选择哪次作为最终结果也有讲究:别取目标函数值综合最好那次,那通常是随机性恰好把初始种群撒在了好的区域,不代表算法稳定水平。取画出来后最“典型”的一次,也就是5次结果的中位数版本,它反映的是算法在这组参数下的平均水平。更严格的做法是记录每次结果的超体积指标,取指标最接近中位数的那次。这样既避免被单次异常结果欺骗,也保留了可复现的固定结果用于后续对比。
5.3 用间距与超体积判断这组参数值不值得用
看图画线始终有主观成分,定量判断参数好坏才能服众。三个指标最常用:
| 指标 | 计算方式 | 说明 |
|---|---|---|
| 间距指标SP | 计算相邻前沿点之间的欧氏距离标准差 | 标准差越小,说明前沿点分布越均匀 |
| 超体积HV | 以参考点为界,前沿点与界之间覆盖的体积 | HV越大,说明前沿覆盖范围越广,解集越好 |
| 反转世代距离IGD | 计算真实前沿每个点到所求前沿最近距离的平均值 | 需要已知真实前沿;数值越小越接近真实解集 |
在真实工程问题里,真实前沿常常未知,IGD用不上,但HV和SP可以直接用。HV的计算有一个需要注意的点:参考点必须比所有前沿点在每个目标上的值都差,一般取目标最大值的1.2倍左右,保证所有点都包含在体积范围内。HV的值也受目标量纲影响,跨问题对比时要先归一化所有解集再算。
有了这三个指标,验证就不是玄学:同一参数跑10次,算平均HV和平均SP,两个数一摆就能比较不同参数组合的高下。我自己常用的是先画图看有没有严重畸形,再算SP看均匀性,最后算HV看覆盖度,三者一起通过才算这组参数可靠。每次换目标函数都强制走一遍这一步,能挡掉很多调参翻车风险。希望帮到你。
本文还有配套的精品资源,点击获取