首页 / 资讯中心 / 文章详情

NSGA2在Matlab中的完整实现:多目标优化与拥挤度距离详解

NSGA2在Matlab中的完整实现:多目标优化与拥挤度距离详解 ★ FEATURED ARTICLE
简介这是一套基于Matlab的NSGA-II多目标优化算法实现面向需要求解Pareto最优解集的科研人员、工程师及算法学习者。压缩包共10个文件包含8个.m源码文件与2个xlsx结果表格整体仅660KB轻量易用。8个.m文件分别对应种群初始化、目标函数评估、非支配排序、锦标赛选择、交叉变异、精英保留与环境选择等核心模块函数分工清晰便于逐段调试和二次开发2个xlsx结果表格保存了典型测试问题下的非支配解集与目标值可用于对比验证Pareto前沿分布。已有3333人学习下载说明该资源在同类教程中具备一定参考价值。通过研读和运行这套代码可直观理解NSGA-II的迭代流程与参数设置快速迁移到生产调度、路径规划、资源分配等实际多目标问题中适合作为学习和科研的起点。1. NSGA2 在 Matlab 里求解多目标优化它在解决什么问题多目标优化不是“找到唯一最优解”而是在一堆互相冲突的目标里找一组折中解。NSGA2 是整个多目标进化算法体系里最常用的基线算法之一和 Matlab 配合时矩阵化的种群操作能让迭代逻辑特别紧凑。很多人直接调现成工具箱用了一两年真到要改编码方式、加约束、换交叉算子时才意识到自己并不清楚每一步发生了什么。这篇文章会让读的人从“知道 NSGA2”走向“能在 Matlab 里独立写出可运行版本”非支配排序怎么写、拥挤度怎么算、子代如何生成、参数从哪调起。默认所有目标都按最小化处理遇到最大化目标就直接取负这个小习惯能少踩很多坑。2. 从帕累托支配到拥挤度距离NSGA2 为什么能同时管住收敛和分布2.1 帕累托支配与前沿先过一遍判断逻辑两个解比较时如果一个解在所有目标上都不比另一个差并且至少在一个目标上严格更优就说它支配另一个解。解集里所有不被其他解支配的个体组成第一前沿去掉这层个体再重复判断就得到第二前沿、第三前沿。这种逐层剥离的思路是整个 NSGA2 的骨架。判断支配关系时有个特别容易出错的地方只写了“全部小于”而遗漏“等于”。假设两个目标值分别是 (0.5, 1.0) 和 (0.5, 0.8)后者的第二个目标更小但第一个目标相等此时前者并不支配后者后者也不支配前者它们互不支配。如果漏掉等号边界上会涌出一堆虚假的“互不支配”个体排序层数直接乱掉。正确的判定逻辑应该写成某个解在全部目标上都不大于对方同时至少在一个目标上严格小于对方。实际代码里很少直接比较“谁支配谁”两次工程上更常见的做法是数“被多少个体支配”。每个个体维护一个被支配计数同时记录它支配了哪些个体每一层结束后把被自己支配的那些个体的计数减一谁减到零就进入下一层。这正是下一小节要展开的快速非支配排序。2.2 快速非支配排序的 O(MN²) 实现先在 Matlab 里写短设种群规模为 N目标数为 M。两两比较一次需要 O(M) 时间全部比较就是 O(MN²)这也是快速非支配排序在理论上的主要计算量。对 N200、M2 这种常见规模一次排序只有 4 万次比较在 Matlab 里毫秒级完成完全不用焦虑复杂度。真出现性能瓶颈通常也不是排序本身而是目标函数计算。写 Matlab 实现时很多人习惯用结构体数组存个体再用数组索引做筛选。结构体看着直观但频繁的字段访问在循环里会拖慢速度。我一般直接用普通矩阵pop 是 N×nvars 的决策变量矩阵fitness 是 N×M 的目标值矩阵。排序过程里需要记录“每个个体支配谁”这个关系可以用长度为 N 的 cell 数组保存每个 cell 里存一组被支配个体的下标。快速非支配排序的分层过程可以描述为三步先扫一遍种群初始化每个个体的被支配计数和支配列表把所有计数为零的个体归入第 1 层然后逐层向下遍历当前层每个个体支配的列表将对应个体的计数减一减到零就放入下一层。这个过程天然适合用队列思路实现但写循环时注意不要在遍历 cell 数组的过程中动态修改被迭代的列表否则下标容易串位。复杂度上还有个容易被忽略的点空间开销。支配列表在最坏情况下接近 N²/2 个下标N 到 500 以上时内存增长明显。如果后续要跑几千次迭代的大规模实验可以把 cell 数组换成稀疏邻接矩阵用 sparse 存储支配关系但在常规小规模验证阶段cell 数组反而更容易读代码和查错。2.3 拥挤度距离与精英保留从 NSGA 到 NSGA2 的关键改法老版 NSGA 靠共享函数保持种群多样性需要人为设定共享半径参数敏感且结果不稳定。NSGA2 用拥挤度距离替代它对同一前沿内的个体按每个目标值排序处于两端的个体距离设为无穷大中间个体的距离累加相邻目标差与全段范围的比值。距离大的个体周围更稀疏优先进入下一代。这种机制的物理意义很直接——不让种群扎堆在帕累托前沿的某一段。拥挤度计算的细节很影响最终效果。每个目标维度都要独立排序累加后才是综合拥挤度如果没有按目标循环只对某个目标算了一次距离种群会在其他目标方向上严重聚集。另一个常见问题是排序方向从大到小取是保留稀疏个体一旦方向反了就会形成“指望着多样性却越选越挤”的局面。精英保留策略则是 NSGA2 相对 NSGA 的另一处关键改进。每代生成子代后不直接淘汰父代而是把父代和子代合并成 2N 个体统一重新做非支配排序再按前沿层级逐层填入下一代直到某一层装不下时用拥挤度距离裁掉多余个体。这样做保证了最优个体绝不会因为交叉变异波动而丢失也让算法整体收敛曲线更平滑。约束处理也在这里做文章。很多实际问题带不等式约束常见的做法是把约束违反量作为支配比较的附加规则可行解支配不可行解两个可行解按原来的目标支配关系比较两个不可行解按违反量大小比较。这个逻辑加到支配判断函数里只需要多传一个约束向量但要是不先在无约束版本上跑通排查时很难分清是约束问题还是算法问题。3. Matlab 实现第一步种群数据结构、初始化和主循环骨架3.1 用矩阵还是结构体种群数据的组织方式种群数据我一般不用结构体数组而是拆成两个矩阵pop 保存决策变量fitness 保存目标值。这么做有两个直接好处一是矩阵切片和索引运算比结构体字段访问快不少二是调试时只要盯住两三个变量不用在长长短短的结构体字段里翻找。决策变量维数用 nvars 表示目标数用 M 表示两个维度都从 size 动态获取后续换测试函数不用改主循环。边界 lb 和 ub 单独保存长度为 1×nvars。初始化和变异都需要它们尤其变异后要把越界个体拉回边界内。顺带说Matlab 的随机数默认状态在每次启动时相同跑实验前固定 rng 才能保证结果可复现这一点在参数对比时特别重要。3.2 初始化种群与目标函数接口设计初始化代码很直观核心是利用 rand 生成 0 到 1 之间的随机数再映射到边界区间。这里有一个值得坚持的习惯把目标函数作为函数句柄传给主程序而不是在主程序内部写死函数名。这样想测 ZDT1 就传 zdt1 的句柄想换成自己的工程模型只需要保证入参是单个决策变量行向量、返回一行目标值。function pop initialize_population(N, nvars, lb, ub) % 生成 N 个个体每个个体是 nvars 维行向量 pop lb rand(N, nvars) .* (ub - lb); end function f evaluate_population(pop, test_func) % 逐个体调用目标函数返回 N×M 的适应度矩阵 N size(pop, 1); f_list cell(N, 1); for i 1:N f_list{i} test_func(pop(i, :)); end f cell2mat(f_list); end逻辑说明initialize_population 是均匀随机初始化所有变量都等概率落在边界内。evaluate_population 用 cell 暂存每行结果再合并因为 Matlab 在循环里动态拼接矩阵会反复分配内存速度明显下降。参数说明N 是种群规模nvars 是决策变量个数lb 和 ub 可以是标量也可以是和变量同样长度的向量目标函数句柄 test_func 必须返回行向量列数就是目标个数 M。这一步对应的坑是目标函数返回值方向。fitness(a,b) 中每个个体对应的目标必须是一行因为后续非支配排序按行比较如果模型本身返回的是列向量cell2mat 出来会得到 M×N 的转置矩阵排序逻辑会全乱。3.3 主循环骨架先能跑起来再谈优化主循环把排序、拥挤度、锦标赛选择、SBX 交叉、多项式变异、精英保留串起来。核心骨架如下算子和精英保留的具体实现分别放在后面小节里先看整体流程。rng(1); N 100; maxgen 250; nvars 30; lb zeros(1, nvars); ub ones(1, nvars); test_func zdt1; pop initialize_population(N, nvars, lb, ub); fitness evaluate_population(pop, test_func); for gen 1:maxgen [fronts, rank] fast_non_dominated_sort(fitness); cd zeros(N, 1); for f 1:length(fronts) cd(fronts{f}) crowding_distance(fitness, fronts{f}); end offspring zeros(N, nvars); for i 1:2:N-1 p1 tournament_selection(rank, cd, 2); p2 tournament_selection(rank, cd, 2); [offspring(i, :), offspring(i1, :)] ... sbx_crossover(pop(p1, :), pop(p2, :), lb, ub, 20); offspring(i, :) polynomial_mutation(... offspring(i, :), lb, ub, 1/nvars, 20); offspring(i1, :) polynomial_mutation(... offspring(i1, :), lb, ub, 1/nvars, 20); end offspring_fitness evaluate_population(offspring, test_func); pop elitist_selection(pop, fitness, offspring, offspring_fitness, N); fitness evaluate_population(pop, test_func); end scatter(fitness(:, 1), fitness(:, 2), 10, filled); xlabel(f1); ylabel(f2);逻辑说明每一代先对当前适应度排序得到前沿编号和拥挤度再用锦标赛选父代、SBX 交叉产生两个子代、多项式变异扰动子代最后把父代和子代合并交给精英选择函数裁回 N 个个体。fitness 在上一代精英选择后重新计算一次这一步不是冗余因为 pop 的行顺序已经改变对应的 fitness 需要同步更新。参数说明maxgen250 对 ZDT 系列测试函数是够用的起步值交叉分布指数 20、变异分布指数 20 是 NSGA2 论文里常用的经典设置变异概率用 1/nvars 可以保证每个个体平均有一个变量发生变异锦标赛 k2 让选择压力适中。跑完后的 scatter 图能第一眼看出前沿形状和分布情况。这里最容易犯的错误是 for i 2:N 的奇偶配对。种群规模必须是偶数否则最后一个子代缺失如果目标函数昂贵也可考虑子代规模略小于父代但精英选择逻辑要对应调整不要写出索引越界的脚本。4. 参数设置与结果验证测试函数、指标和调参顺序4.1 用 ZDT1 当最小可复现用例默认参数怎么给ZDT1 是多目标优化里最经典的双目标测试函数变量数通常取 30目标函数定义如下第一个目标就是第一个决策变量本身第二个目标由 g 函数和 x1 混合构成。它的真实帕累托前沿已知x1 从 0 到 1 变化其余变量为 0对应 f2 等于 1 减根号下 f1。这个解析解让结果验证非常直接。function f zdt1(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]; end参数设置上我推荐的起步值是种群 N100迭代代数 maxgen250交叉概率 pc0.9交叉分布指数 eta_c20变异概率 pm1/nvars变异分布指数 eta_m20。这套参数在 ZDT1 上已经能跑出比较完整的前沿适合用来验证代码正确性。参数默认值作用与调节方向N100种群规模增大可提升前沿覆盖度但拉长每代计算时间maxgen250迭代次数收敛慢时优先加大pc0.9交叉概率过低会削弱信息交换eta_c20交叉分布指数越小子代偏离父代越远pm1/nvars变异概率过大会变成随机搜索eta_m20变异分布指数越小变异扰动幅度越大ZDT1 的前沿是凸的算法容易收敛如果换到前沿非凸或带断点的测试问题同样的参数表现可能剧烈下滑。做方法对比时不能只靠一个测试函数下结论至少准备 ZDT 系列和 DTLZ 系列各一个。4.2 三个指标判断算法质量收敛性、多样性和运行时间散点图只能看出大概形状定量判断还需要指标。最常用的是 GD 和 IGD作用对象都是算法得到的前沿解集合 P 和真实前沿参考集合 R。GD 衡量收敛性对 P 中每个点找它到 R 里最近点的欧氏距离所有距离取平均。GD 越小说明得到的解越贴近真实前沿。IGD 反过来看多样性对 R 中每个参考点找它到 P 里最近点的距离取平均。如果 P 只覆盖了前沿的一段远处参考点找不到近邻IGD 会明显变大所以 IGD 能同时惩罚不收敛和不完整的前沿。实际计算时参考集合 R 可以用解析解在 ZDT1 上均匀采样 1000 个点得到。每代跑完都算一次 IGD画成下降曲线能清楚看到算法是稳定收敛还是中途卡住。function igd_val compute_igd(P, R) % P: 算法所得前沿点集, R: 真实前沿参考点集 d zeros(size(R, 1), 1); for i 1:size(R, 1) dists sqrt(sum((P - R(i, :)).^2, 2)); d(i) min(dists); end igd_val mean(d); end运行时间也要记录。多目标算法的竞争本质是“同样时间内谁的前沿质量更高”只比最终 IGD 而不计时间没有工程意义。我的习惯是每个参数组合跑 5 个随机种子取 IGD 的中位数和标准差只看单次最好结果很容易被运气左右。4.3 调参顺序先动哪个后动哪个参数不能一上来全调。收敛慢和多样性差往往需要完全相反的操作调反了会互相掩盖问题。第一步看 IGD 曲线的尾部如果在最大代数附近还在明显下降说明还没收敛优先增大 maxgen或者增大变异概率让种群跳出局部区域。第二步看前沿覆盖如果两端总是空的考虑降低 eta_c 让交叉产生更偏离父代的子代或者检查边界修复逻辑是否把子代过度压缩到中间。第三步看前沿均匀性如果某些区间点密、某些区间点稀优先调拥挤度选择方向确认排序是降序而不是升序。还有个工程经验先用小规模快速验证再上大规模。N50、maxgen100 跑一轮只要几秒适合检查代码逻辑确认没问题后再把 N 拉到 200、代数拉到 500 做正式实验。调参时每次只改一个变量记录前后 IGD 变化否则两个参数同时调整很难定位是哪个起了作用。5. 常见问题与排查NSGA2 在 Matlab 里容易翻车的 5 个点5.1 现象初始代就没几个非支配个体前沿稀稀拉拉一开始种群就很差最常见的原因不是算法问题而是目标方向不统一。某个目标本身是最大化但所有代码都按最小化实现那整个评估结果颠倒排序自然全乱。跑任何实际问题前第一件事是检查每个目标的方向最大化目标直接取负变成最小化。另一个隐蔽原因是边界设置太宽rand 生成的初始种群大量落在物理上不可行的区域这时要看约束处理逻辑是否提前作用。排查办法很简单把初始种群的目标值提出来画散点图如果绝大部分点堆在某个角落而不是散布整个空间从评估函数和边界两个方向找问题。解决方式是在 evaluate_population 之后加一行统一符号处理把最大化变成最小化并在注释里标明哪些目标是取负过的。这样做虽然看起来简陋但能保证整个算法流程只用写一套最小化逻辑。5.2 现象非支配排序结果和预期不符前沿层级明显不对如果同一个个体出现在两个不同前沿或者某些层里混入了被其他层支配的点问题基本出在被支配计数的更新顺序上。快速非支配排序要求当前层的所有个体都必须先完成“支配列表减一”的传播才能进入下一层如果一边遍历一边修改 fronts 结构会把还没处理完的个体提前推进队列。Matlab 的 cell 数组在循环中动态扩展时尤其容易出现这种下标错位。排查方式是打印每一层的前沿编号和个体数量人工抽几个点验证支配关系第一层的点应该两两互不支配第一层和第二层之间必须满足“第二层至少被第一层里的某个个体支配”。代码上建议把队列 Q 单独用普通数组保存当前层遍历结束后再合并不要直接往正在遍历的 cell 末尾追加元素。5.3 现象所有子代都集中到边界附近甚至越过边界产生 NaNSBX 交叉本身不保证子代一定落在变量边界内它只是按照分布指数生成一个扰动比例。如果父代一个在边界、一个在中间扰动后子代很容易越界。裁剪处理如果只是简单 clamp会让大量个体贴在边界上产生“边界聚集效应”。更隐蔽的问题是变异算子的越界判断没有检查 ub-lb 是否为零某些变量上下界相同导致除零出现 NaN。解决方法是给交叉和变异都加边界修复但修复方式要有区分交叉后越界的变量直接 clamp 到边界变异越界时用反射方式映射回内部空间。反射比 clamp 的分布更均匀不容易在边界形成堆叠。实现时每个变量单独检查并在修复后加一次校验确保没有 NaN 漏网。5.4 现象前沿点集中在中段两端始终没有解前沿缺两端通常有两个来源。第一个是拥挤度排序方向写反本应选择拥挤度距离大的个体如果写成升序排列就会优先保留距离小、密度高的个体种群越选越聚集。第二个是交叉分布指数的值取得太大子代永远在父代周围非常小的邻域内产生探索能力被限制住。排查时先输出前后两代前沿的范围比较 f1 的最小最大值有没有逐步外扩。如果一直不变检查拥挤度选择代码里 sort 的方向如果方向正确尝试把 eta_c 从 20 降到 5 再跑一次子代偏离父代的概率会显著上升两端覆盖也会更快改善。5.5 现象父代和子代合并后某些好解在下一次迭代里反而消失精英保留环节的典型错误在索引映射。合并后种群变成了 [pop; offspring]排序得到的前沿队列里存的是合并后个体的下标。如果直接在原来的 pop 或 fitness 上按这个下标取数据就一定会拿错个体。另一个常见问题是没有在精英选择函数里同步更新 fitness导致下一代排序用的目标值和实际个体不匹配。解决方式是让精英选择函数返回完整的下一代 pop 和 fitness合并状态只在函数内部存在外部不要手工拼接后再从中间取行。我习惯把这一步单独封装函数内做完排序、裁剪、重新评估最后统一写回这样外部主循环永远面对的一致接口排查时只需要看这一个函数内部的索引变化。6. 进阶技巧向量化评估、平行实验与收尾验证6.1 向量化评估目标函数让每代运行时间从秒级降到毫秒级主循环里逐个体调用目标函数代码直观但效率一般。Matlab 的强项是矩阵计算代价允许的情况下可以把整个种群一次性传入目标函数内部用向量化写法算完全部适应度。比如 ZDT1 可以写成接收 N×nvars 矩阵sum 按列求和一行代码同时处理所有个体。function f zdt1_batch(pop) f1 pop(:, 1); g 1 9 * sum(pop(:, 2:end), 2) / (size(pop, 2) - 1); f2 g .* (1 - sqrt(f1 ./ g)); f [f1, f2]; end逻辑说明pop 每行是一个个体第一列参与 f1sum 的第二个参数 2 表示按行求和得到每个个体的 g 值最后用点乘和点除完成全部计算。参数说明这只适用于目标函数可以写成向量运算的情况工程模型大多是仿真或外部程序反而应该维持逐个体接口考虑用 parfor 并行。初始代码和向量化版本要在同一套随机种子下对比一次确认结果一致后再替换避免换函数时顺手引入目标方向错误。这是我吃过大亏的地方当时为了提速改写批量函数少乘了一个系数前前沿收敛得飞快结果全是错的。6.2 输出每一代前沿保留现场可回溯主循环里每代算完 IGD 后把前沿数值、种群目标值、运行时间和参数组合一起存盘。前期做参数对比时这样的记录能直接画出收敛曲线省去重复跑实验的等待。养成好的输出习惯之后你会发现改一个井号参数的影响比预期更复杂——凸前沿和凹前沿对 eta_c 的敏感度完全不同。我前几年做对比实验时算法里某个排序方向写反了前 30 代看起来都在正常收敛图也漂亮直到把每一代前沿落盘后才发现边界在缓慢收缩而不是扩张。那之后就养成了每代打印层数和首末目标值范围的习惯。NSGA2 这类算法逻辑不难难的是每个环节都建立了看似可用的局部判断组合起来却不自洽所以跑通只是第一步把排错代码留在工程里才是长久省力的事。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站