ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

NSGA-II多目标优化MATLAB代码实现:非支配排序与拥挤距离详解

NSGA-II多目标优化MATLAB代码实现:非支配排序与拥挤距离详解

简介:这份资源是NSGA-II多目标优化算法的MATLAB实现代码,面向正在学习智能优化算法、准备课程设计或科研实验的高校学生与研究人员。代码围绕非支配排序、拥挤距离计算、精英策略与基因操作等核心模块展开,并针对二维测试函数ZDT1-6与三维测试函数DTLZ1-6提供了完整的测试数据与仿真图像,便于读者理解算法在标准测试集上的收敛性与分布性表现。压缩包共27个文件,包含10个m脚本、10个txt测试数据文件与7个fig仿真结果图,整体约2.41MB,目录结构清晰,主程序与各功能模块分离,方便按需查阅与二次修改。目前已有3204人学习下载,适合作为算法入门复现、实验对比或论文写作的参考素材,读者可借助现成脚本快速跑通流程,并对照仿真图像验证非支配解集的分布效果。

1. 从一次“跑不通”的选型说起:NSGA-II 的 MATLAB 代码到底能解决什么

如果你手头有一个多目标优化问题——比如同时压低成本和能耗、同时拉高精度和吞吐——大概率绕不开 NSGA-II。它属于遗传算法家族里的经典多目标版本,核心是三件事:非支配排序、拥挤距离、精英策略。这三件套决定了它能在一次运行里给出一整条帕累托前沿,而不是像加权求和那样只能拿到一个折中点。这份 MATLAB 代码包就是把这三件事完整落地的实现,适合两类人:一类是课程设计或论文里需要跑出帕累托前沿、但不想从零手写排序逻辑的;另一类是已经用过工具箱函数、想拆开看内部机制到底怎么转的。它不依赖额外工具箱,纯 MATLAB 脚本加函数文件,拿到就能在本地跑通,改目标函数也只需要动一个文件。下面按“先立住原理、再动手复现、最后说坑”的顺序拆开讲。

2. 非支配排序与拥挤距离:NSGA-II 的两根支柱怎么在代码里落地

2.1 非支配排序:把种群切成一层一层的“优劣梯队”

多目标优化里没有单一最优解,只有“我不比你差”的支配关系。解 A 支配解 B,意思是 A 在所有目标上都不劣于 B,且至少有一个目标严格更优。非支配排序就是反复找出当前种群里不被任何其他解支配的那一批,标为第一层,剔除后再找第二批,依此类推。代码里这一步通常是一个双层循环:外层遍历每个个体,内层拿它和其余个体逐一比较支配关系,统计被支配次数和它支配了谁。被支配次数为 0 的个体就是当前层的成员。

% 非支配排序核心逻辑(简化示意) front = 1; F(front).f = []; individual = []; for i = 1:N individual(i).n = 0; % 被支配计数 individual(i).p = []; % 支配的解集合 for j = 1:N dom_less = 0; dom_equal = 0; dom_more = 0; for k = 1:M if x(i).cost(k) < x(j).cost(k) dom_less = dom_less + 1; elseif x(i).cost(k) == x(j).cost(k) dom_equal = dom_equal + 1; else dom_more = dom_more + 1; end end if dom_less == 0 && dom_equal ~= M individual(i).n = individual(i).n + 1; % j 支配 i elseif dom_more == 0 && dom_equal ~= M individual(i).p = [individual(i).p j]; % i 支配 j end end if individual(i).n == 0 x(i).rank = 1; F(front).f = [F(front).f i]; end end

这段逻辑里M是目标个数,N是种群规模。dom_less、dom_equal、dom_more三个计数器分别记录第 k 个目标上“更小、相等、更大”的情况。判断支配的条件是:没有任何一个目标更差(dom_less == 0),且不是所有目标都相等(dom_equal ~= M)。参数上最容易出问题的是目标方向——如果你的问题是最大化,得先把目标取负,否则排序结果会完全反过来。常见做法是在目标函数里统一转成最小化,排序逻辑就不用改。

2.2 拥挤距离:同一层里怎么再分出个先后

非支配排序把种群分了层,但同一层里可能有几十个解,选谁进下一代?NSGA-II 用拥挤距离来衡量一个解周围有多“空”。距离越大,说明它周围解越稀疏,越该保留,这样帕累托前沿才能铺得开、不会全挤在一处。计算方式是对每个目标排序,边界解的距离设为无穷大(必须保留),中间解取相邻两个解在该目标上的差值归一化后累加。

% 拥挤距离计算(对某一层 front 内的解) costs = reshape([x(F(front).f).cost], M, [])'; % 取出该层所有解的目标值 n = numel(F(front).f); distance = zeros(n, 1); for k = 1:M [~, idx] = sort(costs(:, k)); distance(idx(1)) = inf; % 边界解距离无穷大 distance(idx(end)) = inf; f_max = max(costs(:, k)); f_min = min(costs(:, k)); if f_max - f_min == 0 continue; % 该目标全相等,跳过避免除零 end for t = 2:n-1 distance(idx(t)) = distance(idx(t)) + ... (costs(idx(t+1), k) - costs(idx(t-1), k)) / (f_max - f_min); end end

f_max - f_min是归一化分母,这一步很关键:如果某个目标量纲是几千、另一个是零点几,不归一化的话大量纲目标会完全主导拥挤距离。代码里对分母为 0 的情况做了跳过处理,这是血泪经验——早期版本没加这个判断,遇到所有解在该目标上相等时直接出 NaN,整个选择环节就崩了。边界解赋inf保证它们一定被选中,这是维持前沿两端不被截断的常规做法。

2.3 精英策略:父代和子代合并后再选,为什么更稳

很多遗传算法的做法是父代产生子代、子代直接替换父代。NSGA-II 不一样,它把父代和子代合并成 2N 个个体,一起做非支配排序和拥挤距离计算,再从中挑 N 个进下一代。这个“精英策略”的好处是已经找到的好解不会因为一次交叉变异就被丢掉,收敛更稳。选择顺序是:先按 rank 从小到大整层整层地取,取到某一层放不下时,用拥挤距离从大到小补齐。

% 精英选择:合并种群后截断到 N combined = [pop_parent, pop_child]; [combined, F] = non_dominated_sort(combined); new_pop = []; front = 1; while numel(new_pop) + numel(F(front).f) <= N new_pop = [new_pop, F(front).f]; front = front + 1; end % 最后一层按拥挤距离排序补齐 last = F(front).f; dist = crowding_distance(combined, last); [~, order] = sort(dist, 'descend'); need = N - numel(new_pop); new_pop = [new_pop, last(order(1:need))];

combined的长度是 2N,while循环负责整层接收,need是还差多少个名额。这里有个容易翻车的点:如果某一层的解数量刚好等于剩余名额,while条件用的是<=,会整层收下,不会触发后面的补齐逻辑,这是对的;但如果写成<,就会多走一轮,把本该整层收下的层拆开,破坏前沿结构。参数上N一般取 100 到 200,太小前沿稀疏,太大单代计算量明显上升,非支配排序是 O(MN²),N 翻倍计算量约四倍。

3. 把代码跑起来:目标函数、参数设置与一次完整运行

3.1 目标函数怎么写:以两个经典测试问题为例

拿到代码包后,第一个要改的就是目标函数。代码通常把目标函数单独放在一个文件里,输入是决策变量向量,输出是目标值向量。以 ZDT1 为例,它有两个目标,决策变量 30 维,帕累托前沿是一条已知的凸曲线,常用来验证算法是否正确。

function f = evaluate_objective(x) % ZDT1: 两个目标,x 为 1×30 决策变量 f(1) = x(1); g = 1 + 9 * sum(x(2:end)) / (numel(x) - 1); f(2) = g * (1 - sqrt(x(1) / g)); end

f(1)直接取第一个变量,f(2)由g和x(1)共同决定。这个函数的好处是帕累托前沿解析已知:当g = 1时,f(2) = 1 - sqrt(f(1)),f(1)在 0 到 1 之间。跑完之后把结果和这条曲线对比,就能判断代码有没有问题。如果你要换成自己的问题,保持输入输出格式不变,把内部计算替换掉即可,目标个数M在参数文件里同步改。

3.2 参数文件怎么配:种群、代数、交叉变异率

参数一般集中在一个脚本或结构体里,常见字段如下表。这些值不是拍脑袋定的,交叉率 0.9、变异率 1/决策变量数 是文献里的常用起点。

参数含义常用取值调整影响
N种群规模100太小前沿稀疏,太大计算量平方增长
max_gen最大代数200看收敛曲线,前沿不再移动即可停
pc交叉概率0.9太低探索不足,太高破坏好解
pm变异概率1/n_var按变量数自适应,太大退化成随机搜索
eta_c交叉分布指数20越大子代越靠近父代
eta_m变异分布指数20控制变异步长,越大扰动越小

pm取1/n_var是经验做法:变量越多,单个变量被变异的概率越低,整体变异强度保持稳定。eta_c和eta_m是模拟二进制交叉和多项式变异里的分布指数,取值越大子代越集中在父代附近,探索能力下降但局部搜索更细。我一般先用默认值跑一遍,看收敛曲线再决定要不要调。

3.3 一次完整运行:从初始化到输出帕累托前沿

主循环的结构是:初始化种群、评估、非支配排序、进入迭代、每代做选择交叉变异、合并父子代、精英选择、更新。下面是一次运行的骨架。

% 主流程骨架 pop = initialize_population(N, n_var, lb, ub); pop = evaluate_population(pop, @evaluate_objective); [pop, F] = non_dominated_sort(pop); pop = calculate_crowding_distance(pop, F); for gen = 1:max_gen child = tournament_selection(pop, N); child = crossover(child, pc, eta_c, lb, ub); child = mutation(child, pm, eta_m, lb, ub); child = evaluate_population(child, @evaluate_objective); combined = [pop, child]; [combined, F] = non_dominated_sort(combined); combined = calculate_crowding_distance(combined, F); pop = truncate_population(combined, N); % 精英选择截断 fprintf('第 %d 代,第一层解数:%d\n', gen, numel(F(1).f)); end % 输出第一层作为帕累托前沿 pareto = pop([pop.rank] == 1); scatter([pareto.cost(1,:)], [pareto.cost(2,:)], 'filled');

tournament_selection是二元锦标赛:随机抽两个,先比 rank,rank 小的赢;rank 相同比拥挤距离,大的赢。truncate_population就是 2.3 节讲的精英选择。跑完后pareto里就是第一层解,画出来应该是一条平滑曲线。如果画出来是一团散点或者只有一两个点,多半是排序或截断环节出了问题,下一章专门说。

4. 避坑与排查:五个让代码“看起来对但结果不对”的坑

4.1 帕累托前沿只有一两个点

现象:跑完几百代,第一层解数始终是个位数,散点图几乎看不出曲线。原因通常是变异概率过低或种群多样性在早期就丢失,也可能是目标函数里某个目标恒为常数,导致支配关系退化。解决:先把pm调到1/n_var确认不是变异问题;再检查目标函数,打印每代各目标的取值范围,如果某个目标方差接近 0,说明它没起作用,需要重新设计目标或检查变量映射。

4.2 结果每次跑都不一样,且差异很大

现象:同一份代码、同一组参数,两次运行的前沿形状差很多。原因:遗传算法本身是随机的,但如果差异大到前沿覆盖范围都变了,通常是没固定随机种子,或者种群规模太小导致随机性被放大。解决:在初始化前加rng(1)固定种子,方便复现和调试;正式跑的时候把N提到 100 以上,多跑几次取包络,不要用单次结果下结论。

4.3 拥挤距离出现 NaN 或 Inf 传播

现象:迭代到某一代,选择环节报错或结果全变成 NaN。原因:某个目标上所有解取值相等,归一化分母为 0,除法产生 NaN,NaN 参与比较后污染整个距离数组。解决:在拥挤距离计算里对分母为 0 的目标直接continue,跳过该目标的距离累加,代码 2.2 节已经给了这个判断。另外边界解赋inf是正常的,但如果inf出现在中间解上,说明排序索引越界,检查idx的取值范围。

4.4 最大化问题直接套用导致前沿反向

现象:目标明明是越大越好,跑出来的前沿却集中在目标值很小的区域。原因:非支配排序默认所有目标都是最小化,最大化问题不转换方向,支配关系就反了。解决:在目标函数里对最大化目标取负,f(k) = -raw_value,排序逻辑不动,输出时再取回负号。这是最常见的翻车点,尤其是从别人代码改过来的时候,很容易漏掉方向转换。

4.5 代数够了但前沿还在移动

现象:设了 500 代,跑到最后前沿还在缓慢变化,不确定该不该停。原因:收敛判据不能只看代数,要看前沿是否稳定。解决:每隔若干代记录第一层解的目标均值或超体积指标,画收敛曲线,当曲线在若干代内波动小于阈值就可以停。常见做法是连续 20 代超体积变化小于 1e-4 就提前终止,比死磕固定代数更省时间。

5. 进阶技巧:用超体积指标验证前沿质量,而不是只看图

跑出前沿只是第一步,怎么判断这次运行比上次好?肉眼看散点图太主观,我一般用超体积指标。它衡量的是帕累托前沿和参考点围成的区域面积,值越大说明前沿越靠前、铺得越开,同时反映收敛性和多样性。计算时先定一个参考点,取各目标上比最差解还差一点的值,然后对前沿解按第一目标排序,逐段累加矩形面积。

function hv = hypervolume(points, ref) % points: n×M 的前沿目标值,ref: 1×M 参考点 [~, idx] = sort(points(:,1)); points = points(idx, :); hv = 0; for i = 1:size(points,1)-1 width = points(i+1,1) - points(i,1); height = ref(2) - max(points(i,2), points(i+1,2)); if height > 0 hv = hv + width * height; end end hv = hv + (ref(1) - points(end,1)) * (ref(2) - points(end,2)); end

这段是二维简化版,ref要选得比所有解都差,否则算出来偏大。width是相邻解在第一目标上的间隔,height是参考点到该段较高那个解的距离。二维情况下这个逻辑够用,三维及以上需要做切片或调用专门库,代码包里如果带了这个函数,直接传前沿和参考点即可。我习惯每跑完一次就记一个超体积值,调参时对比这个数,比盯着散点图靠谱得多。从那以后我每次改完参数都强制走一遍超体积对比,不再凭感觉判断“好像好了一点”。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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