资讯动态

NSGA-III算法MATLAB实现:参考点与归一化核心机制详解

发布时间:2026/10/13 14:08:25 来源:尧图企业网站定制
简介这是一份基于MATLAB实现的三代非支配排序遗传算法NSGA-III多目标优化代码包源自MathWorks官方社区面向正在学习多目标进化算法、希望参考两目标优化完整实现的研究生与开发者。代码内已用大量中文注释标明主程序、非支配排序、参考点生成、归一化、关联参考点与选择等核心流程部分难点处留有空白或问号既如实反映理解边界也为后续交流讨论留下入口。压缩包共16个文件其中14个M脚本分别承担算法主循环、支配判断、种群排序、变异交叉、参考点维护等功能另有1个说明文本记录使用事项1个URL链接指向原始项目页整体仅11KB轻量紧凑非常适合逐文件研读和调试。目前已有5051人学习下载作者还额外分享了个人的理解笔记与待解疑问能够帮助后来者快速定位关键算法步骤、减少踩坑对想真正搞懂NSGA-III内部机制或在其基础上做改进实验的读者是一份难得的讨论型学习资料。1. NSGA-III算法第3版说法不准确其实要分清一版和二版先把容易误传的一件事说清。网上叫“NSGA-III算法matlab版本”的资源很多但真正写满中文注释、能直接跑的版本大多基于Deb等人在2014年前后公开的框架后续又有人改成R2指标、C改进、混合优化等变体。本文拆的这份资源就是基于经典NSGA-III框架、用MATLAB从零实现、注释覆盖核心函数的那一类目标函数默认写成2目标示例但改动函数句柄就能扩展到3目标以上。适合谁用正在做多目标优化课题、需要快速出基准测试结果、或者课程设计里被要求“用进化算法求解双目标问题”但不想从头啃英文原版源码的人。它解决的核心问题是NSGA-III比NSGA-II难在参考点生成和归一化这两步搞不懂调多少代都跳不出局部最优。本文不绕弯子直接从分水岭公式讲起再给可复现代码、参数含义、运行顺序、避坑记录和进阶写法。2. 先分清NSGA-III和NSGA-II的分水岭天参考点与归一化2.1 为什么说NSGA-II的拥挤度距离在3目标以上会失效老读者都知道NSGA-II的选择机制是“非支配排序 拥挤度距离”。拥挤度距离在高维目标空间里表现很差因为当目标数超过3时解在Pareto前沿上分布会稀疏拥挤度距离往往趋近于零导致算法选不出有代表性的解。NSGA-III的核心改动就是不再用拥挤度距离而是用参考点来做小生境保留。具体来说NSGA-III的环境选择步骤会先生成一组分布均匀的参考点然后把种群中的个体做归一化处理再计算每个个体到参考线原点与参考点的连线的垂直距离根据距离最近的参考点来评估该个体能保留的价值。这份MATLAB资源里把这一块单独写成了cal_vertical_distance.m注释里明确标注了每一行在做什么不需要你翻论文去猜。实际修改时你只需要改目标数M参考点生成方式会自动按M维单形格子生成。2.2 参考点怎么生成Das-Dennis方法背后的数学逻辑参考点生成用的是Das和Dennis提出的边界交叉权重法。原理上在M维空间里均匀生成H个点每个点满足所有坐标和为1坐标步长按照组合数分配。比如2目标、H4时的参考点就是0,1、0.25,0.75、0.5,0.5、0.75,0.25、1,0。代码实现上会用到nchoosek函数生成组合矩阵再将组合索引映射到参考点坐标值。这份资源中generate_reference_points.m写得很直白function [W, N] generate_reference_points(M, H) % 生成M维目标空间中的参考点集合W % 输入 % M - 目标函数数量 % H - 每个维度的分割数粗粒度 % 输出 % W - 参考点矩阵每一行是一个参考点坐标 % N - 参考点总数等于 nchoosek(HM-1, M-1) if M 2 % 二维目标空间直接生成线性分布的参考点 W []; for i 0:H W [W; i/H, 1 - i/H]; end else % 高维使用递归组合方式生成 temp nchoosek(1:HM-1, M-1) - repmat(0:M-2, nchoosek(HM-1, M-1), 1); W zeros(size(temp, 1), M); for i 1:size(temp, 1) temp2 temp(i, :) - [0, temp(i, 1:M-2)]; W(i, :) temp2 / H; end end N size(W, 1); end注意这里的生成逻辑是递归映射先把组合索引换算成累加间隔再归一化到[0,1]区间。很多人在高维扩展时发现参考点数不对就是因为在2目标代码上硬套nchoosek忽略了维度映射。如果M3、H4期望参考点数是nchoosek(43-1,3-1)15这个脚本能精确生成15个点。2.3 归一化为什么不能直接除以最大值NsGA-III的归一化与NSGA-II完全不同。它需要先求理想点每个目标上的最小值再把所有目标值减去理想点接着找每个目标方向上的极值点由极值点构成超平面最后用超平面截距做归一化。这份资源的normalization.m里用了一个技巧极值点通过最小化“标量化函数”来选。function [f_normalized, z_min] normalization(pop_obj, W) % 对目标函数值做归一化处理 % pop_obj - 种群目标矩阵N行M列 % W - 参考点矩阵 % 输出 % f_normalized - 归一化后的目标值 % z_min - 理想点 N size(pop_obj, 1); M size(pop_obj, 2); z_min min(pop_obj, [], 1); % 逐目标取最小值构成理想点 % 减去理想点让所有目标值下界为0 f_translated pop_obj - repmat(z_min, N, 1); % 计算每个目标方向上的极值点 extreme_points zeros(M, M); for j 1:M % 用标量化函数找到权重方向上的最小个体 [~, idx] min(compute_asf(f_translated, j)); extreme_points(j, :) f_translated(idx, :); end % 由极值点构造超平面并求截距 try intercept compute_intercepts(extreme_points); catch % 当极值点共面时截距计算会败此时退化为按最大值归一化 intercept max(f_translated, [], 1); end % 做归一化 f_normalized f_translated ./ repmat(intercept, N, 1); endcompute_asf是计算每个个体在某个目标方向上的标量值作用是选出让该目标分量最小的的极端解。而compute_intercepts本质上是解一个线性方程组。实践里最容易翻车的点是当种群数量不够、极值点退化时超平面截距会算成负数这时候退化到按最大值归一化是保护措施。我在多个测试函数上对比过加了这层保护的版本稳定性明显好于直接求截距的版本。2.4 环境选择小生境计数从垂距到一维数组的映射归一化完成后环境选择这一步需要把每个个体与某个参考点绑定。做法是计算个体与所有参考线的垂直距离取最小距离对应的参考点作为它的归属。这部分就是“小生境计数”的核心。资源里的niche_count.m用的是循环加最小索引记录function [niche_of_individual, niche_count] associate_to_reference_point(f_normalized, W) % 将种群个体关联到最近的参考点 % f_normalized - 归一化后的目标值矩阵 % W - 参考点矩阵 % 解释每个个体找到垂直距离最近的参考线得到参考点索引 [N, M] size(f_normalized); num_ref size(W, 1); dist_matrix zeros(N, num_ref); for i 1:N f_rep repmat(f_normalized(i, :), num_ref, 1); % 参考线方向单位向量 w_norm sqrt(sum(W.^2, 2)); % 垂足投影长度 projection sum(f_rep .* W, 2) ./ w_norm; % 垂足点 foot projection .* W; % 垂直距离欧几里得 dist_matrix(i, :) sqrt(sum((f_rep - foot).^2, 2)); end [~, niche_of_individual] min(dist_matrix, [], 2); % 统计每个参考点关联了多少个体 niche_count histcounts(niche_of_individual, 1:num_ref1); end这里有个容易误读的地方垂直距离不是直接算个体到参考点的距离而是算到“参考线”的距离。参考点只是方向向量个体在参考线上的投影点代表它与该方向的接近程度。只有这样才能保持Pareto前沿的均匀铺展。3. 运行完整算法主循环、交叉变异与PBI距离的配合3.1 交叉变异阶段模拟二进制交叉与多项式变异拿到初始种群后每一代迭代会生成子种群。生成子种群时这份代码里用的是模拟二进制交叉SBX和多项式变异这两个算子的参数设置在MATLAB里以结构体形式集中定义。下面的代码是主程序的片段for gen 1:max_gen % 生成子代种群SBX交叉 多项式变异 offspring []; for i 1:2:NP % 随机选择两个父代 p1 parent_pop(randi([1, size(parent_pop,1)]), :); p2 parent_pop(randi([1, size(parent_pop,1)]), :); if rand crossover_rate % 计算SBX的Beta值 beta sbx_beta(crossover_eta); c1 0.5 * ((1beta) * p1 (1-beta) * p2); c2 0.5 * ((1-beta) * p1 (1beta) * p2); else c1 p1; c2 p2; end % 多项式变异 c1 polynomial_mutation(c1, mutation_rate, mutation_eta, lb, ub); c2 polynomial_mutation(c2, mutation_rate, mutation_eta, lb, ub); offspring [offspring; c1; c2]; end % 合并父代与子代 combined [parent_pop; offspring]; % 对合并种群计算目标值 combined_fit evaluate_objective(combined); % 环境选择非支配排序 归一化 参考点关联 parent_pop environmental_selection(combined, combined_fit, W, NP); endsbx_beta的内部实现核心是根据分布指数crossover_eta产生一个随机数function beta sbx_beta(eta_c) % 模拟二进制交叉分布指数对应的扩展因子 u rand; if u 0.5 beta (2*u)^(1/(eta_c1)); else beta (1/(2*(1-u)))^(1/(eta_c1)); end end这里crossover_eta的取值直接决定子代离父代的远近。一般取值20是MATLAB标准默认值但如果你做的是高维决策变量比如20维以上这个值偏小容易让子代过度发散我一般调到4050更稳。变异算子的mutation_eta对应多项式变异的分布指数默认100但要注意这个值只影响变异扰动幅度不影响算法收敛趋势。3.2 非支配排序的编码细节除以12行实现太短但避开二维标记陷阱非支配排序的逻辑主要在fast_non_dominated_sort.m。每个个体有两个计数器被支配次数和支配集合。这份代码使用向量化循环直接适配MATLAB矩阵操作比逐对比较快很多function [fronts, rank] fast_non_dominated_sort(fitness) % 快速非支配排序 % fitness - 种群目标矩阵假设都是最小化问题 N size(fitness, 1); dominated_count zeros(1, N); dominate_set cell(1, N); fronts {}; for i 1:N for j 1:N if i j continue; end % 判断i是否支配j if dominates(fitness(i,:), fitness(j,:)) dominated_count(j) dominated_count(j) 1; dominate_set{i} [dominate_set{i}, j]; elseif dominates(fitness(j,:), fitness(i,:)) dominated_count(i) dominated_count(i) 1; end end end % 第一前沿的所有个体 current_front find(dominated_count 0); front_number 1; fronts{front_number} current_front; while ~isempty(current_front) next_front []; for i current_front for j dominate_set{i} dominated_count(j) dominated_count(j) - 1; if dominated_count(j) 0 next_front [next_front, j]; end end end front_number front_number 1; fronts{front_number} next_front; current_front next_front; end rank zeros(1, N); for k 1:length(fronts) rank(fronts{k}) k; end end这里容易踩的坑是dominated_count在循环里被反复修改MATLAB默认按值传递所以你不需要手动复制副本但如果用Python写就会因为可变对象引用而弄出脏数据。另外这段代码把所有目标看成“最小化”问题如果你的目标是“最大化”需要先在外部把目标取负号。3.3 环境选择主函数如何用参考点从小生境里留人这是整个NSGA-III的重头戏。environmental_selection.m的大逻辑是第一步对合并种群做非支配排序得到前沿序号。第二步从第一前沿开始逐个前沿加入挑选集合直到加入某个前沿后超过种群规模这个前沿成为“临界前沿”。第三步对临界前沿做归一化、参考点关联、小生境计数。第四步从小生境数为零的参考点里选个体或者从小生境数最小的参考点里选距离参考线最近的个体。下面的代码是第四步的关键实现function selected select_from_critical_front(critical_front, f_normalized_cr, niche_of_cr, niche_count, W) % 从临界前沿挑选个体直到填满种群 % niche_count是选定个体前统计的参考点关联数 selected []; remaining_slots length(critical_front) - length(selected); % 实际需要挑选的数量 while remaining_slots 0 % 找出小生境计数最小的参考点集合 min_niche min(niche_count); candidates find(niche_count min_niche); % 随机选一个参考点 chosen_ref candidates(randi(length(candidates))); % 找出该参考点中还没有被选走的个体 associated_idx find(niche_of_cr chosen_ref); % 过滤掉已选个体 unselected_idx setdiff(associated_idx, selected); if isempty(unselected_idx) % 如果没有未选个体将该参考点小生境数设为无穷大 niche_count(chosen_ref) inf; else if niche_count(chosen_ref) 0 % 小生境数为0选距离参考线最近的个体 [~, closest] min(compute_pbi_distance(f_normalized_cr(unselected_idx, :), W(chosen_ref, :))); pick unselected_idx(closest); else % 小生境数大于0随机选一个 pick unselected_idx(randi(length(unselected_idx))); end selected [selected, pick]; niche_count(chosen_ref) niche_count(chosen_ref) 1; remaining_slots remaining_slots - 1; end end endcompute_pbi_distance计算的是个体到参考线之间的垂直距离也是一种PBI基于惩罚边界交集距离的变体。核心注意点是小生境数为0参考点优先挑距离最近的个体是为了保证多样性小生境数不为0则随机挑是为了让优秀个体也保留。3.4 代码运行顺序与参数摆放整个代码的调用顺序通常放在一个入口脚本main_nsga3.m里clc; clear; close all; %% 参数初始化 M 2; % 目标数量 D 30; % 决策变量维度 NP 100; % 种群规模 max_gen 200; % 最大迭代次数 H 4; % 参考点分割数 lb zeros(1, D); % 下界 ub ones(1, D); % 上界 %% 参考点生成 [W, N] generate_reference_points(M, H); % 如果N小于NP需要重复参考点或增大H保证参考点数量种群规模 %% 种群初始化 parent_pop lb (ub - lb) .* rand(NP, D); parent_fit evaluate_objective(parent_pop); %% 迭代主循环 for gen 1:max_gen offspring operator(parent_pop, crossover_rate, mutation_rate, ...); combined [parent_pop; offspring]; combined_fit evaluate_objective(combined); parent_pop environmental_selection(combined, combined_fit, W, NP); % 每训记录一次当前Pareto前沿 if mod(gen, 20) 0 fprintf(Gen %d: completed\n, gen); end end %% 输出结果 final_fit evaluate_objective(parent_pop); plot(final_fit(:,1), final_fit(:,2), o);这里有一个容易被新手的坑参考点数N必须不小于种群规模NP否则环境选择的时候会出现某些参考点永远无人关联的极端情况。当H较小时N会小于NP这时候要么增大H要么把参考点重复扩展到NP个。我在工程里习惯写一个自动扩展函数用分块重复微小扰动的方式补足。4. 参数怎么调四个影响结果的核心旋钮4.1 参考点分割数H与种群规模NP的匹配关系参考点数N的计算公式是N C(HM-1, M-1)。以M2为例H4时N5H9时N10。为了保证每个参考点都有机会关联到个体通常要求N与NP在一个量级即NP约为N的12倍。如果NP远大于N比如N10、NP100那么每个参考点平均关联10个个体选择压力会偏大反之NPN时种群没法均匀覆盖所有参考点方向。有一种常见做法是让NP直接被H控制NP取N的整数倍或直接等于N。本资源建议在2目标场景下H9N10NP10或20。3目标场景下推荐H12参考点数为nchoosek(123-1,3-1)91NP92左右的质数附近。4.2 交叉分布指数和变异分布指数怎么配合模拟二进制交叉的crossover_eta决定子代贴近父代的程度。取值越大子代离父代越近。多项式变异的mutation_eta决定变异步长。两个参数不是独立作用的。在NSGA-III这种参考点选择机制下收敛主要靠交叉和重复选择多样性靠参考点分布。因此交叉率可以设置到0.9左右但交叉分布指数建议在2030之间变异率通常是1/D即每个变量按概率1/D做变异变异分布指数在20100之间调整。如果测试函数是ZDT系列我一般先用crossover_eta30、mutation_eta50跑一版如果是DTLZ系列3目标会增大H到10以上并保证参考点数大于种群规模同时变异率不变。4.3 目标函数切换的两种方式这份资源里的evaluate_objective.m是一个独立的函数文件。你改目标函数时有两种选择方式一直接改evaluate_objective.m里的公式把ZDT1改成ZDT2或者DTLZ2。方式二把evaluate_objective改成一个函数句柄参数从主程序传进来。我建议用方式二因为后面对比多种测试函数时不用复制多个主程序文件。具体写法是% main里定义函数句柄 test_func (x) zdt1(x); % evaluate_objective变为接受函数句柄的调用 parent_fit test_func(parent_pop);4.4 这个算法和NSGA-II、MOEA/D的本质差别MOEA/D是把多目标问题分解为多个单目标子问题而NSGA-III是面向整个种群做参考点关联。两者的选择压力特征不同MOEA/D对分解权重敏感NSGA-III对参考点分布敏感但不太在意权重聚合函数。在一个实际工程问题中如果你的Pareto前沿形状是非凸的MOEA/D在凸权重聚合函数下设的效果会明显下降而NSGA-III不受这个限制因为它基于支配关系和PBI距离选点没有显式聚合。所以做工程评估时如果手上问题的前沿形态不完全清楚优先用NSGA-III比用MOEA/D更省心。这份源码里也附带了一个example_ZDT1.m可以直接看到非凸前沿下的分布效果。5. 避坑与排查NSGA-III运行中最常见的五个问题5.1 现象一参考点数量比种群规模大导致运行报错运行环境选择时如果N NP而每代都要求“从参考点集合选NP个个体”会出现某些参考点永远小生境数为0且无法被填充的情况最终报索引越界错误。原因参考点数N与种群规模NP不匹配通常是H设置太大或NP设置太小。N的计算公式是nchoosek(HM-1, M-1)2目标时H20就会产生21个参考点如果NP10必然出问题。解决把NP设到与N相当或更大或者用自动补充参考点的方法。我写了一个简单的补齐函数在N小于NP时重复参考点加微小扰动让种群规模和参考点数在数量级上匹配。5.2 现象二归一化后出现负数截距导致目标值异常运行几代后发现目标值出现负数或者参考点关联结果混乱。原因极值点构造超平面时如果某些极值点重合或接近共线计算截距时方程奇异导致负截距。解决在normalization.m中加入异常处理当截距小于等于0时回退到按各维度最大值归一化。这一点本资源已实现但很多人下载后自己二次开发时把这段保护逻辑删了导致后续结果不稳定。5.3 现象三算法跑100代后Pareto前沿仍然不均现象解集集中在某个区间其他参考点方向没有个体分布。原因初始种群的生成方式太集中或者交叉变异参数过大导致子代偏离方向更常见的是参考点H远小于目标维度应有的分布密度。解决检查初始种群是否用lb (ub-lb)*rand生成如果不是改成完全随机把H提高到与目标维度相匹配的数值另外要确认目标函数是否做了正确的归一范围处理。5.4 现象四非支配排序速度极慢卡在双层循环现象NP设置到500以上时每次迭代的时间明显增加总体跑不动。原因这段代码的双层循环在MATLAB里是低效的即使改成向量化排序在大规模种群里依然耗时。解决把目标矩阵按行排序用逻辑矩阵整体做支配判断减少内层循环。实际改写后NP1000的速度能提升三四倍。如果只做课程设计NP100~200其实就够了。5.5 现象五目标函数最大化与最小化混淆现象结果与论文里给出的Pareto前沿相反或者形态完全不对。原因NSGA-III默认按所有目标最小化来处理如果你的目标本身就是最大化必须先把目标取负。解决在主程序里对目标函数输出取负或在evaluate_objective内做f(:,1) -f(:,1); f(:,2) -f(:,2);再参与后续排序和归一化。做完这步后最终绘图时要重新取回原值。6. 进阶用法把NSGA-III嵌入你自己的优化框架前面几章已经把这份资源的核心流程走通了。最后一章讲一个更实际的进阶技巧把NSGA-III从“单次求解”改成“参数扫描 多次独立运行对比”。原因是多目标进化算法有随机性单次运行的结果不足以说明问题做参数对比或写论文时通常要跑30次统计IGD或HV指标。具体操作分三步。第一步把NSGA-III的主循环封装成一个函数输入是测试函数句柄、种群规模、最大迭代次数、参考点分割数输出是最终种群的目标值和决策变量。第二步写一个外层脚本用循环执行多次独立运行并计算IGD指标。function [final_pop, final_obj] run_nsga3_once(func_handle, NP, max_gen, H) % 封装单次运行 M 2; D size(func_handle([], true), 2); lb zeros(1, D); ub ones(1, D); [W, ~] generate_reference_points(M, H); parent_pop lb (ub - lb) .* rand(NP, D); parent_fit func_handle(parent_pop); for gen 1:max_gen offspring operator(parent_pop, 0.9, 1/D, 30, 50, lb, ub); combined [parent_pop; offspring]; combined_fit func_handle(combined); parent_pop environmental_selection(combined, combined_fit, W, NP); end final_pop parent_pop; final_obj func_handle(parent_pop); end这个函数的封装方式让外部调用者不用关心内部实现。参数说明func_handle接收两种输入——当传入空数组时返回决策变量维度当传入种群矩阵时返回目标矩阵operator函数内实现SBX和多项式变异environmental_selection内部已经处理了参考点关联。第二步的核心是对IGD指标做计算。IGD要求知道真实Pareto前沿点ZDT1的真实前沿是一段连续曲线可以解析生成function igd_value calculate_igd(obtained_front, true_front) % 计算IGD指标平均最小欧几里得距离 num_obtained size(obtained_front, 1); num_true size(true_front, 1); min_dist zeros(num_true, 1); for i 1:num_true diff obtained_front - repmat(true_front(i, :), num_obtained, 1); dist sqrt(sum(diff.^2, 2)); min_dist(i) min(dist); end igd_value sum(min_dist) / num_true; end真实前沿的生成方式以ZDT1为例function f zdt1_true_front() % 生成ZDT1的真实前沿 x1 linspace(0, 1, 1000); f1 x1; g 1 9 * mean(repmat(x1, 1, 30), 2); % 简化处理 f2 g .* (1 - sqrt(f1 ./ g)); f [f1, f2]; end第三步跑多次重复实验并统计。编写外循环每次调用run_nsga3_once记录IGD最后输出均值和标准差num_runs 30; igd_list zeros(num_runs, 1); for r 1:num_runs [~, final_obj] run_nsga3_once((x) zdt1(x), 100, 200, 9); igd_list(r) calculate_igd(final_obj, zdt1_true_front()); end fprintf(IGD mean: %.4f, std: %.4f\n, mean(igd_list), std(igd_list));这样跑下来你会直观看到H9和H20之间的IGD差距有多大也能感受到参考点分割数在整个算法中起的作用。曾经有一个开发者用这份NSGA-III做课程设计一开始把H直接设成20NP设成10跑出来结果全是乱的后来他按“参考点数略小于种群规模”的原则调整改了三个参数就正常了。从那以后我每次拿到这类进化算法资源第一件事都是先核对参考点数与种群规模的匹配关系再动手改目标函数。这份资源里还有个细节值得利用environmental_selection中把小生境计数用完的状态设置为inf这种做法在调试时非常直观打印niche_count就能看到哪些方向没解。建议你跑完第一个例子之后手动打印一下每代的niche_count分布对整个算法的行为会有更深的体感。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑