2026/10/15 8:06:48

NSGA-II多目标优化算法MATLAB实战:从原理到代码实现

NSGA-II多目标优化算法MATLAB实战:从原理到代码实现 做过多目标优化项目的朋友都知道单目标问题再复杂也就是一个最优解的事但一旦目标变成两个、三个很多传统方法就不好使了。因为两个目标往往是冲突的比如成本要低、性能要高这种互相矛盾的指标根本不存在一个完美解让所有目标同时最优。这时候需要的就是多目标优化算法其中NSGA-II是被应用最广、也最适合上手的一个。这篇文章用MATLAB从原理讲到代码实现再到完整实例跑通给你一套可以直接抄作业的方案。这篇文章适合正在做课程设计、毕业论文或者工程选型的人也适合想系统掌握NSGA-II而不是只会调现成工具箱的进阶开发者。我会把非支配排序、拥挤度距离、精英保留策略这些核心机制用通俗的话讲透再给出完整可复现的MATLAB代码。文中的代码结构清晰、注释详细核心算法没有依赖工具箱函数方便你移植到自己的问题上。1. 为什么是NSGA-II多目标优化的真实需求1.1 实际工程里的目标冲突问题先说一个我实际遇到过的例子。某产品设计要同时优化两个目标一个是结构重量要轻一个是承载强度要高。你很快就会发现想让它更轻往往就得牺牲材料厚度强度随之下降想加强度就得加厚材料重量又上去了。这种目标之间的关系不是同增同减而是互相制约专业上叫冲突目标。如果是单目标优化直接一个目标函数就能评价好坏梯度下降、遗传算法都能处理。但多目标优化里的最优不再是唯一的点而是一组解的集合。这组解的特点是:任何一个解在某个目标上改好了必然在另一个目标上变差。这样的解叫非支配解也叫Pareto最优解。NSGA-II就是专门用来高效找到并维护这组解的算法。1.2 Pareto最优解的概念理解非支配关系是入门的第一步。假设有两个解A和B每个解都有两个目标函数值。如果A的两个目标值都不比B差且至少有一个目标比B好就说A支配B。反过来也一样。如果两个解互有胜负谁都支配不了谁它们就处于同一层非支配关系。把所有互不支配的解放在一起就是第一层Pareto前沿也叫非支配前沿。这一层解是最优的没有其他解能同时在这两个目标上都超过它们。剩下的解把第一层去掉之后再找互不支配的就是第二层依次类推。NSGA-II把整个种群分成一层一层的梯队每层内部的解按照拥挤度排序最后按层和拥挤度来选择下一代个体。这里容易产生一个误区Pareto前沿不是一条固定曲线它取决于实际问题的可行域和优化目标跑完一次优化得到的是一个近似前沿。算法质量好不好就看你这个近似前沿和真实前沿的距离以及前沿上解的分布是否均匀。1.3 NSGA-II相比传统方法的核心优势在NSGA-II之前多目标优化常用权重法就是把多个目标乘上不同的权重然后相加变成一个目标。这个方法最大的问题是权重不好定而且对于非凸的Pareto前沿权重法根本找不到完整的解集。还有一个老前辈叫NPGA基于小生境技术的Pareto遗传算法但它的参数太多使用起来比较麻烦对新手很不友好。NSGA-II能在2000年前后成为标杆算法主要靠三个改进。第一个是快速非支配排序把第一代NSGA里复杂度O(MN^3)的排序算法降到了O(MN^2)这个提升在处理大种群时非常明显。第二个是拥挤度距离的引入直接替代了原来需要人为指定的共享半径参数保持种群多样性时不再需要额外调参。第三个是精英保留策略父代和子代合并后再统一筛选保证了最优解不会在迭代中丢失。这三板斧合在一起效果就是:收敛速度更快、解集分布更均匀、实现起来还比老算法简洁。所以后来很多改进算法比如NSGA-III、MOEA/D都是在它的框架上演进的把NSGA-II学明白等于给进阶之路打好了地基。2. NSGA-II核心机制拆解四大关键环节2.1 快速非支配排序给解集分梯队快速非支配排序的过程可以这样直观理解把种群里的每个个体当作一个选手逐个比较谁支配谁。传统做法是把所有个体两两比较复杂度很高。NSGA-II的做法是建立两个辅助信息一个是支配该个体的解的数量记为np另一个是该个体支配的其他解的集合记为Sp。排序第一步找出所有np0的个体。这些个体不被任何其他人支配属于第一层把它们统统放入当前层集合。然后遍历这一层每个个体的Sp集合把Sp里每个成员的np减1。如果一个成员的np减到0了就说明它在剩下的解里已经不被任何人支配放进下一层。这样一层一层往外剥直到所有个体都被分层。这个过程的代码逻辑很清晰我拆成伪代码你就看懂了。先初始化每个个体的np和Sp两层循环做非支配比较然后开始分层循环每处理完一层就更新下一批np0的个体。整个排序过程只需要遍历种群有限次所以复杂度比两两全比较低一个量级。2.2 拥挤度距离同一梯队内的多样性度量分层之后同一层里的解怎么取舍NSGA-II用拥挤度距离来评价局部密度。直观理解就是看某个解周围有多少邻居离得越远说明这个解越孤独越值得保留因为它能代表这片区域的特征保留它能维持种群多样性。拥挤度距离的计算方法是对同一层的个体按某个目标函数值排序目标值最大和最小的两个个体拥挤度设为无穷大以保证前沿的两端一定被保留。中间个体的拥挤度就是相邻两个个体在该目标上的归一化距离之和。每个目标都算一遍最后累加就得到每个个体的拥挤度。这里要注意归一化的问题。不同目标的量纲差异可能很大比如重量几百千克强度几十兆帕如果不做归一化量纲大的目标会主导拥挤度计算。所以实现的时候需要用该层中目标的最大值和最小值做归一化保证各目标对拥挤度的贡献是均衡的。算完拥挤度之后同一层个体就有一个明确的取舍标准优先保留拥挤度大的个体因为它们处在分布稀疏的区域对保持解的多样性贡献更大。2.3 选择与精英保留策略让优秀基因不丢失NSGA-II的选择策略是锦标赛选择。每次随机从当前种群中抽两个个体先比较所在层级层级编号小的胜出如果层级相同拥挤度大的胜出。这种两阶段比较天然体现了先收敛、后多样的思想。精英保留策略就更直接了。每一代算法生成子代种群然后把父代和子代合并成一个大小为2N的临时种群。对这个临时种群做非支配排序和拥挤度计算然后从第一层开始往里填直到填满N个个体。如果填到某一层时超出了剩余名额那就用这一层里拥挤度最大的个体把剩余名额补上。这样做的一个好处是即使父代里有优秀的个体也绝不会因为子代的随机波动而被淘汰。从实际效果看精英保留是整个算法能快速收敛的关键因素。我一开始实现时没做这一步只从子代里选结果跑了上百代前沿还是不干净后来才发现是优秀解流失了。2.4 交叉与变异产生新解的两个引擎NSGA-II用模拟二进制交叉和多项式变异两个算子来生成新个体。模拟二进制交叉的特点是两个父代个体交换信息后生成两个子代子代到父代的距离分布在一个指定的分布指数范围内。这个分布指数eta_c越大子代越贴近父代搜索范围越小越小子代越可能远离父代搜索范围越大。多项式变异的思路类似对一个基因位施加一个随机扰动扰动幅度由分布指数eta_m控制。变异概率通常取1/nn是决策变量的个数。这个取法的逻辑是平均每次变异只改变一个变量这样既能引入新基因又不会破坏已经积累的优秀模式。在实际编码里交叉和变异都是按概率发生的。如果随机数大于交叉概率那两个子代就直接复制父代。这个细节容易被忽略但它对算法稳定性影响很大。另一个常见问题是交叉算子要在决策变量的上下界内对子代做截断因为SBX生成的子代有可能超出边界不做处理就会产生非法解。3. MATLAB从零实现主程序与关键函数3.1 环境准备与总体架构我用的是MATLAB R2021a版本但代码基本没有用新特性老版本从R2016b往后都能直接运行。整个项目不需要额外安装工具箱纯基础函数就能实现这样方便你直接移植到自己的项目里。工程结构我建议这样组织一个主脚本负责设置参数并调用函数另外单独建几个函数文件分别实现目标函数、非支配排序、拥挤度计算、锦标赛选择、SBX交叉和多项式变异。这样分层清晰后期改算法或者换测试问题只需要动对应的函数文件就行。主程序的架构是一个固定流程生成初始种群计算目标函数值进入迭代循环每次循环做排序、选择、交叉、变异、合并种群、环境选择直到达到最大代数。这个流程是NSGA-II的标准写法下面我会逐个环节给出可直接运行的函数代码。3.2 初始化与目标函数设计初始化种群时需要在决策变量的上下界范围内随机生成种群。假设决策变量维度是nVar种群大小是nPop变量下界和上界分别是lb和ub生成代码很简单lb zeros(1, nVar); % 下界 ub ones(1, nVar); % 上界 pop lb (ub - lb) .* rand(nPop, nVar);这里要注意的是如果你的每个变量有不同的上下界lb和ub就要写成分量不同的向量。比如三变量问题中第一个变量范围是[0,10]第二个是[0,5]第三个是[0,1]那就分别写成[0 0 0]和[10 5 1]不要用统一的标量。目标函数我用现在最常见的双目标测试函数ZDT1来演示。这个函数的定义是第一个目标是所有决策变量中的第一个变量第二个目标是某个函数乘以一个惩罚项这个惩罚项和所有其他变量相关。用MATLAB实现时我建议把目标函数写成独立文件输入是决策变量行向量输出是目标函数值行向量这样主程序调用起来非常灵活function f evaluate_objective(x) n length(x); f1 x(1); g 1 9 * sum(x(2:end)) / (n - 1); f2 g * (1 - sqrt(f1 / g)); f [f1, f2]; endZDT1有一个已知的理论Pareto前沿就是当g1时f21-sqrt(f1)的曲线。拿它做测试的好处是你能直观对比算法跑出来的前沿和真实前沿的差距方便验证代码对错。3.3 非支配排序的函数实现快速非支配排序是整个算法的核心。我给出一个可以直接复制的MATLAB函数输入是目标函数值矩阵矩阵每一行对应一个个体每一列对应一个目标。输出有两个每个个体所在的层级以及每个层级的个体索引列表。function [rank, layer] fast_non_dominated_sort(fitness) [nPop, ~] size(fitness); np zeros(nPop, 1); sp cell(nPop, 1); front []; rank zeros(nPop, 1); layer cell(1, 1); % 非支配关系比较 for i 1:nPop for j i1:nPop if dominates(fitness(i,:), fitness(j,:)) sp{i}(end1) j; np(j) np(j) 1; elseif dominates(fitness(j,:), fitness(i,:)) sp{j}(end1) i; np(i) np(i) 1; end end end % 找第一层 front find(np 0); layer{1} front; rank(front) 1; k 1; while ~isempty(front) next_front []; for i front for j sp{i} np(j) np(j) - 1; if np(j) 0 next_front(end1) j; %#okAGROW end end end k k 1; if ~isempty(next_front) layer{k} next_front; rank(next_front) k; end front next_front; end end function yes dominates(a, b) yes all(a b) any(a b); end这个实现里dominates函数的写法比较巧妙all(a b)表示a所有目标都不比b差any(a b)表示至少有一个目标严格优于b两个条件同时满足就是支配关系。3.4 拥挤度计算与锦标赛选择实现拥挤度计算的输入是新种群的目标函数值矩阵和个体所处的层级输出是每个个体的拥挤度。我对每一层分别计算内部对每个目标依次排序并做归一化处理function crowd crowding_distance(fitness, rank) nPop size(fitness, 1); crowd zeros(nPop, 1); maxRank max(rank); for r 1:maxRank idx find(rank r); if length(idx) 2 crowd(idx) inf; continue; end nObj size(fitness, 2); dist zeros(length(idx), 1); for m 1:nObj [~, order] sort(fitness(idx, m)); sorted_idx idx(order); fmin fitness(sorted_idx(1), m); fmax fitness(sorted_idx(end), m); if fmax fmin continue; end dist(1) inf; dist(end) inf; for i 2:length(idx)-1 dist(i) dist(i) (fitness(sorted_idx(i1), m) - fitness(sorted_idx(i-1), m)) / (fmax - fmin); end end crowd(idx) dist; end end锦标赛选择的实现更直接。每次从当前种群中随机选两个个体比较层级和拥挤度胜者作为父本。交叉需要两个父本所以这个过程调用两次function p tournament_selection(fitness, rank, crowd) nPop size(fitness, 1); idx1 randi(nPop); idx2 randi(nPop); if rank(idx1) rank(idx2) p idx1; elseif rank(idx1) rank(idx2) p idx2; else if crowd(idx1) crowd(idx2) p idx1; else p idx2; end end end3.5 交叉变异算子实现SBX交叉算子的实现逻辑是对两个父代个体的每个变量按一定概率进行交叉交叉点的分布用一个随机数beta控制。代码如下function [c1, c2] sbx_crossover(p1, p2, lb, ub, pc, eta_c) nVar length(p1); c1 zeros(1, nVar); c2 zeros(1, nVar); for i 1:nVar if rand pc u rand; if u 0.5 beta (2*u)^(1/(eta_c1)); else beta (1/(2*(1-u)))^(1/(eta_c1)); end c1(i) 0.5 * ((1beta)*p1(i) (1-beta)*p2(i)); c2(i) 0.5 * ((1-beta)*p1(i) (1beta)*p2(i)); % 边界截断 c1(i) min(max(c1(i), lb(i)), ub(i)); c2(i) min(max(c2(i), lb(i)), ub(i)); else c1(i) p1(i); c2(i) p2(i); end end end多项式变异的实现相对简单对每个变量以变异概率pm判定是否变异变异公式考虑边界截断function child polynomial_mutation(parent, lb, ub, pm, eta_m) nVar length(parent); child parent; for i 1:nVar if rand pm u rand; if u 0.5 delta (2*u)^(1/(eta_m1)) - 1; else delta 1 - (2*(1-u))^(1/(eta_m1)); end child(i) parent(i) delta * (ub(i) - lb(i)); child(i) min(max(child(i), lb(i)), ub(i)); end end end这里有两个细节值得注意。第一交叉和变异的边界处理必须在每次操作后做否则迭代几代之后种群会逐渐出现超出边界的大量非法值导致目标函数计算异常。第二如果目标函数在边界附近有特殊性质可以考虑对边界值做拉回处理比如把超出边界的值直接置为最近的边界值不要用模运算之类的复杂映射。4. 完整实例双目标优化问题跑通全流程4.1 测试问题定义与算法参数配置我用ZDT1函数作为目标函数变量个数是30种群大小选100最大迭代次数250代。这个配置在普通电脑上大概需要十几秒跑完非常适合用来调试代码。交叉概率pc取0.9这是绝大多数遗传算法的默认值变异概率pm取1/30大约0.033符合平均每次变异一个变量的原则。分布指数eta_c和eta_m都取20这两个参数控制子代和父代的接近程度20是文献中常用的取值。参数配置这部分我建议单独写在主程序开头方便修改。nVar 30; lb zeros(1, nVar); ub ones(1, nVar); nPop 100; maxGen 250; pc 0.9; pm 1 / nVar; eta_c 20; eta_m 20;4.2 主循环流程演示先初始化种群然后进入迭代。每一代做的事情是用锦标赛选择选出父代做SBX交叉生成子代再做多项式变异。把父代和子代合并成大小为2N的临时种群计算所有个体的目标函数值、层级和拥挤度然后按精英保留策略选出新的种群。这个流程循环到最大代数为止。主程序里环境选择的写法是关键的。% 初始化 pop lb (ub - lb) .* rand(nPop, nVar); fitness zeros(nPop, 2); for i 1:nPop fitness(i,:) evaluate_objective(pop(i,:)); end % 迭代 for gen 1:maxGen % 选择父代 [rank, ~] fast_non_dominated_sort(fitness); crowd crowding_distance(fitness, rank); offspring zeros(nPop, nVar); for k 1:2:nPop p1_idx tournament_selection(fitness, rank, crowd); p2_idx tournament_selection(fitness, rank, crowd); [offspring(k,:), offspring(k1,:)] sbx_crossover(pop(p1_idx,:), pop(p2_idx,:), lb, ub, pc, eta_c); offspring(k,:) polynomial_mutation(offspring(k,:), lb, ub, pm, eta_m); offspring(k1,:) polynomial_mutation(offspring(k1,:), lb, ub, pm, eta_m); end % 合并父代与子代 combined_pop [pop; offspring]; combined_fitness zeros(size(combined_pop,1), 2); for i 1:size(combined_pop, 1) combined_fitness(i,:) evaluate_objective(combined_pop(i,:)); end % 环境选择 [combined_rank, ~] fast_non_dominated_sort(combined_fitness); combined_crowd crowding_distance(combined_fitness, combined_rank); new_pop []; r 1; while length(new_pop) sum(combined_rank r) nPop idx find(combined_rank r); new_pop [new_pop; combined_pop(idx,:)]; r r 1; end if length(new_pop) nPop idx find(combined_rank r); remaining nPop - length(new_pop); [~, order] sort(combined_crowd(idx), descend); new_pop [new_pop; combined_pop(idx(order(1:remaining)), :)]; end pop new_pop; end这段代码的环境选择部分就是2.3节讲的精英保留策略的具体实现。while循环先把整层加入当某一层装不下了就按拥挤度从大到小补满。4.3 结果展示与Pareto前沿绘图跑完之后从最终种群中挑出第一层级个体这些就是算法找到的Pareto近似前沿。绘图时把第一层个体的两个目标函数值用散点图画出来final_fitness zeros(nPop, 2); for i 1:nPop final_fitness(i,:) evaluate_objective(pop(i,:)); end [final_rank, ~] fast_non_dominated_sort(final_fitness); pf_idx find(final_rank 1); plot(final_fitness(pf_idx, 1), final_fitness(pf_idx, 2), o); hold on; f1_true 0:0.01:1; f2_true 1 - sqrt(f1_true); plot(f1_true, f2_true, r-, LineWidth, 1.5); xlabel(f1); ylabel(f2); legend(NSGA-II结果, 真实前沿, Location, northeast); grid on;把算法结果和真实前沿对照画出来能看到散点基本分布在红色曲线附近且从0到1分布均匀。如果分布不均匀说明拥挤度计算或者选择过程有问题如果离曲线太远说明收敛不好需要增加迭代次数或者调大交叉概率。5. 常见坑与排查技巧实录5.1 典型报错与逻辑问题实战中经常遇到的一个问题是非支配排序后rank出现0值。这种情况一般发生在某些个体被遗漏在排序过程之外常见原因是contour判断写错了把相等目标值的情况也判定为支配。记住相等不算支配只有一个目标严格更优且其他目标不差才算支配。另一个高频问题是个体索引错位。合并父代和子代后如果你用原来的索引去访问新数组就会得到错误的结果。我的建议是任何函数的输入输出都用统一的索引规则不要在中间环节搞偏移。用MATLAB调试时可以随机挑几个个体手动计算目标值和层数然后用assert语句验证结果。种群大小设成奇数也是个容易踩的坑。锦标赛选择每次生成两个子代奇数种群会导致最后一组只有一个子代代码会报维度错误。我的习惯是强制把nPop设为偶数或者在循环里加判断。5.2 收敛慢、多样性差的调参方向算法跑完发现前沿离真实前沿很远首先要检查迭代次数是不是太少了。NSGA-II本质是进化算法前期探索后期收敛如果代数只有50代很多时候还在寻找不同区域没有充分收敛。其次看交叉概率和分布指数pc太低会导致种群信息交换不足搜索效率下降eta_c太大会让子代总是贴近父代缺乏探索能力。前沿出现明显的断带现象也就是中间缺了一段通常是拥挤度计算的问题。检查归一化是否做对了如果某个目标的取值范围在某一层内特别窄它的归一化分母很小会让拥挤度距离异常大导致选择过于偏好这个目标的极端区域。还有一个容易被忽略的问题ZDT1这类测试函数决策变量数量较多时如果不限制边界多项式变异产生的扰动在变量数量很多时会让种群过于分散。你可以在变异函数里增加一个限制让变异后的变量与父代的差异不超过变量范围的某一百分比比如20%这样能加速局部收敛。5.3 性能优化与工程化建议我这里给出的代码更注重逻辑清晰不追求极致速度。如果要跑大规模问题比如种群500以上、变量100以上有几个优化点可以动手。第一个是把目标函数计算向量化。现在的写法是for循环逐个体计算MATLAB真正擅长的是矩阵运算。你可以把evaluate_objective改成能接收矩阵输入的形式一次计算整个种群的目标函数值速度能提升几十倍。第二个是避免在非支配排序里动态扩展数组。MATLAB的cell和向量动态扩展在数据量大时非常慢可以先预设cell大小为种群规模用完再截断。我在3.3节的实现里用了最直观的写法只是为了方便你理解生产环境建议优化。第三个是如果要跑很多次独立实验求统计结果可以在主循环外加一个重复实验的循环每次更换随机数种子。随机数种子对结果影响很大固定同一个种子可能得到一个特别好的结果换个种子就变差了。我做实验时一般跑30次独立重复统计前沿的覆盖率、均匀性和收敛指标这样报告的结论才可信。如果需要用现成工具箱MATLAB的Global Optimization Toolbox里有gamultiobj函数可以直接做多目标优化。但我的建议是先用我们自己写的版本把原理吃透再看工具箱的文档理解它的输出格式和设置项这样出了问题你也知道去排查哪里。6. 踩过几次坑之后的个人体会写这篇文章时我回忆起自己第一次用NSGA-II做项目时的经历。当时我用的是网上找的C语言版本代码写的非常紧凑注释也不全跑出来的结果总是不对劲。后来我下决心用MATLAB自己从零写一遍在实现非支配排序的时候才真正理解了层级结构是怎么通过np和sp维护的。这个东西只看公式是体会不到的必须亲手把数据结构的每一步变化打印出来看一遍。如果你上手之后发现结果不理想我的建议是先别急着改参数而是把种群规模调小、迭代代数调少比如nPop20、maxGen30然后在每一步循环后输出当前的层级分布。把每一代第一层个体数量打印出来观察它是不是逐渐增加的。如果增长了说明算法框架没问题问题出在调参如果一直不涨就要回头看代码逻辑了。最后再分享一个实用技巧在你自己的工程问题中如果决策变量之间有耦合关系比如三个参数必须满足和为1那初始化时就要先生成前两个第三个用1减去前两个赋值。这种约束如果只靠边界控制是处理不了的需要提前在生成初始种群和交叉变异后都做一遍修正。多目标优化算法本身不负责处理约束约束都要你自己在代码里加护盾。把这一点想清楚你后面换任何实际问题都不会慌。