简介:面向 MATLAB 用户的多目标优化资源包,聚焦工程与建模中常见的多个冲突目标同时优化问题,帮助使用者通过遗传算法求取帕累托最优解集,适用于数学建模竞赛、算法课程设计及科研预研。压缩包共六十四个文件,大小仅二点七四兆,主体为六十个脚本,涵盖遗传算法主程序、选择交叉变异算子、多目标测试函数等,可直接运行或二次修改;另附两个说明文件、一份工具箱文档和一个绘图文件,用于快速上手与原理参考。各模块按函数拆分,便于替换目标函数和调试。已有二千五百四十人浏览学习。借助这套代码,读者既能理解从目标定义、约束设置到种群进化、帕累托前沿可视化的完整流程,也能利用自带测试函数验证算法效果,并将自身业务目标嵌入模型求解;通过运行和修改脚本,还能对比不同参数设置对优化结果的影响,无论是竞赛快速搭建模型,还是深入研究算法机理,都能获得直观可复用的代码支持。
1. 多目标函数优化:一次运行拿回一整条Pareto前沿
做多目标函数优化和做单目标优化,心态上是完全不一样的。之前做结构参数优化时,我拿到两个互相打架的目标就习惯性用加权和法,跑一次只能出一个点,权重稍微调一调就得整个流程重跑,来回折腾几次就有种“调参全靠玄学”的感觉。多目标优化MOP要解决的是另一套玩法——不把多个目标简单压成一个标量,而是直接对着整个目标向量做非支配比较,一次运行拿到的是整条Pareto前沿上的候选解组。这套MATLAB项目就是一份完整的NSGA-II多目标优化模型实现,从种群初始化、快速非支配排序、拥挤度距离到SBX交叉、多项式变异和精英保留全部能跑通,适合刚接触NSGA-II的开发者拿来做算法复现,也适合科研实验需要一个稳定基线框架时直接改。
2. 数学模型与算法选型:从Pareto支配到NSGA-II框架
2.1 多目标优化问题的数学表达:决策空间、目标空间与Pareto支配
先把问题说严谨。一个多目标优化问题可以写成标准形式:
min F(x) = (f1(x), f2(x), ..., fm(x))
x ∈ Ω ⊆ R^n
其中Ω是决策空间,x是n维决策变量,F(x)把决策空间映射到m维目标空间。这里和单目标最大的区别在于目标之间通常互相冲突——比如结构设计里同时最小化重量和最大化刚度,几乎不存在一个解能让两个目标同时达到最优。于是就有了Pareto支配的定义:如果解A在所有目标上都不劣于解B,并且至少在一个目标上严格优于B,就称A支配B。所有不被任何其他解支配的解构成Pareto前沿,也就是这个项目的核心输出。
理解这个支配关系是看懂这个项目的钥匙。很多工程场景里,开发者拿到多目标问题的第一反应还是加权重。加权和法本质上是把f1和f2的线性组合当成新目标,跑一次出一个点,权重靠经验去试。这在目标少、冲突弱的时候勉强能用,但一旦Pareto前沿是非凸的,加权和法会直接遗漏前沿中间凹下去的那一段解,这是方法本身的几何缺陷,不是调权重能救回来的。这个项目里所有排序和选择逻辑都建立在Pareto支配关系上,不做任何加权,这点从代码入口就能看出来。
2.2 算法选型对比:NSGA-II、MOEA/D与SPEA2的取舍
多目标进化算法目前主流的路线有三派:以NSGA-II为代表的基于Pareto支配排序的方法,以MOEA/D为代表的分解方法,以及以SPEA2为代表的基于强度和密度估计的方法。NSGA-II的综合表现最均衡,快速非支配排序的复杂度是O(MN²),M是目标数,N是种群规模,配合拥挤度距离保持解在目标空间里分布均匀,再加上精英保留策略合并父子种群一起竞争,收敛性和分布性都有保障。
MOEA/D需要预先定义一组权重向量和聚合函数,权重的分布方式直接决定最终解集的几何形状,调起来比NSGA-II讲究得多。SPEA2用k近邻密度估计来计算个体适应度,某些测试问题上表现更好,但实现复杂度明显偏高,对第一次做多目标复现的人不够友好。
所以这份项目选择NSGA-II作为主算法是合理的。我一般也建议第一次做多目标优化的人直接用NSGA-II把框架跑通,之后再按需替换算子做改进。这个项目里需要手调的参数不多,主要几个列在下面:
| 参数 | 典型取值 | 作用说明 |
|---|---|---|
| 种群规模 N | 100 | 决定Pareto前沿一次能采到多少个候选点 |
| 最大迭代次数 maxGen | 250 | 控制算法收敛深度,太大浪费计算 |
| 交叉概率 pc | 0.9 | 子代通过交叉产生的比例 |
| 变异概率 pm | 1/nVar | 每个决策变量被扰动的概率 |
| SBX分布指数 eta_c | 15 | 子代接近父代的概率,越大子代越像父代 |
| 多项式变异指数 eta_m | 20 | 变异步长的集中程度,越大步长越短 |
这里pm按变量数取倒数是个常用经验值。决策变量30个时pm约0.033,意味着每个个体平均有1个变量发生变异,既保证扰动又不过度破坏已收敛的解。
2.3 测试问题怎么选:ZDT系列与DTLZ系列的分工
算法写出来不能只在一个例子上跑。这个项目里用ZDT和DTLZ两类标准测试集。ZDT系列面向双目标优化,ZDT1的Pareto前沿是凸的,ZDT2是凹的,ZDT3是断开成几段弧线的,ZDT4带大量局部前沿。这些不同几何形状的前沿专门用来测试算法在凸、凹、不连续、多峰前沿上会不会翻车。DTLZ系列则支持扩展到任意多个目标,DTLZ1是线性前沿,DTLZ2是球面前沿,适合验证算法在三维、五维目标空间里的表现。
项目默认演示用的是ZDT1。ZDT1的决策变量数是30,目标函数定义如下:
f1(x) = x1
g(x) = 1 + 9·(sum(x2..x30)) / 29
f2(x) = g·(1 − sqrt(f1/g))
它的真实Pareto前沿满足g=1,也就是x2到x30全为0时,f2 = 1 − sqrt(f1),x1∈[0,1]。这个解析解可以直接用来算IGD指标,不需要额外跑参考算法生成真实前沿,这也是ZDT1适合做演示问题的重要原因。
3. 核心模块拆解:NSGA-II从初始化到精英保留的完整链路
3.1 项目目录结构与数据流入口
整个项目按功能拆分得很清楚:
multiObjOpt/ ├── main.m # 主入口:选问题、配参数、跑NSGA-II主循环 ├── nsga2/ │ ├── fastNonDominatedSort.m # 快速非支配排序 │ ├── crowdingDistance.m # 拥挤度距离计算 │ ├── tournamentSelection.m # 锦标赛选择 │ ├── sbxCrossover.m # 模拟二进制交叉 │ ├── polynomialMutation.m # 多项式变异 │ └── elitistSelection.m # 精英保留选择下一代 ├── problems/ │ ├── ZDT1.m / ZDT2.m / ZDT3.m │ └── DTLZ2.m ├── metrics/ │ ├── computeIGD.m # 反转世代距离 │ └── computeHV.m # 超体积指标 └── utils/ ├── initPopulation.m # 种群初始化 ├── evaluatePop.m # 批量评估目标函数 └── plotFront.m # Pareto前沿绘图数据流入口在main.m。主循环里每一代做的事情是:先通过锦标赛选择挑父母,再做SBX交叉和多项式变异生成子代,把父代和子代合并成一个2N规模的大种群,然后对这个大种群做非支配排序和拥挤度计算,最后用精英保留策略选出N个个体进入下一代。整个循环结束后,第一前沿F{1}里的个体就是算法最终求得的Pareto解集。
3.2 种群初始化与批量评估
初始化这一步只负责生成决策变量,不急着算目标值。所有目标函数的评估统一放到evaluatePop里做,方便后面改成parfor并行。
function pop = initPopulation(N, nVar, lb, ub) % N : 种群规模 % nVar: 决策变量个数 % lb : 1×nVar 下界向量 % ub : 1×nVar 上界向量 pop = struct('x', [], 'f', [], 'rank', [], 'crowdDist', []); pop = repmat(pop, N, 1); for i = 1:N % 在决策空间里均匀随机采样 pop(i).x = lb + (ub - lb) .* rand(1, nVar); end end这里用repmat预分配结构体数组,避免循环里动态增长拖慢速度。结构体的字段x存决策变量,f存目标函数值,rank存非支配层级,crowdDist存拥挤度距离。之所以把rank和crowdDist也放进结构体,是为了在选择时直接通过pop(i).rank访问,不用额外维护索引映射。
function pop = evaluatePop(pop, objFunc) % objFunc: 目标函数句柄,输入x,输出1×m目标向量 for i = 1:numel(pop) pop(i).f = objFunc(pop(i).x); end end常见做法是main.m里把具体问题函数用句柄传进来,这样evaluatePop不需要关心你用的是ZDT1还是DTLZ2。目标函数句柄的一等公民特性在这个项目里用得很直接,后面换测试函数也只需改main.m里的一行。
3.3 快速非支配排序:O(MN²)的支配计数实现
非支配排序是NSGA-II的核心,目的是把整个种群分层,第一层是互不支配的解集,第二层是被第一层某个解支配的解集,以此类推。我按经典实现用“支配计数 + 被支配解集”两个辅助结构来写:
function [pop, F] = fastNonDominatedSort(pop) % 输入: pop为已评估目标值的种群 % 输出: F为各前沿索引集合,F{1}是第一前沿 N = numel(pop); S = cell(N, 1); % S{i} 记录被个体i支配的个体索引 n = zeros(N, 1); % n(i) 记录支配个体i的个体数量 F = cell(N, 1); % F{k} 第k前沿的个体索引 for i = 1:N for j = 1:N if i == j, continue; end if dominates(pop(i).f, pop(j).f) S{i} = [S{i}, j]; elseif dominates(pop(j).f, pop(i).f) n(i) = n(i) + 1; end end if n(i) == 0 pop(i).rank = 1; F{1} = [F{1}, i]; end end k = 1; while ~isempty(F{k}) Q = []; for i = F{k} for j = S{i} n(j) = n(j) - 1; if n(j) == 0 pop(j).rank = k + 1; Q = [Q, j]; end end end k = k + 1; F{k} = Q; end F = F(~cellfun(@isempty, F)); end function flag = dominates(f1, f2) % 判断f1是否支配f2: 所有目标不劣于f2,且至少一个目标严格优于f2 flag = all(f1 <= f2) && any(f1 < f2); end这段代码的双层循环保证每个个体都和其他所有个体做过一次支配比较。每找到一个支配者,就把被支配者加入S{i},同时给被支配个体的n(j)加一。第一前沿是那些n为0的个体,之后每处理完一层,就把该层所有个体对S中个体的贡献减去,当某个n减到0时它就属于下一层。
这里的dominates函数是判断逻辑的集中点。f1支配f2的条件是全部目标不劣于f2且至少一个目标严格更优,all(f1 <= f2) && any(f1 < f2)这行是精髓。尤其需要注意边界情况:如果两个解的目标值完全相同,f1 <= f2成立但f1 < f2不成立,此时不构成支配关系,这符合Pareto支配的定义。
3.4 拥挤度距离与锦标赛选择
分层结束后,同一前沿内部的个体要做取舍。谁该留下来?NSGA-II的思路是看拥挤度——周围邻居越稀疏的个体,对维持前沿均匀分布越重要,越应该保留。
function pop = crowdingDistance(pop, idxList, nObj) % idxList: 当前前沿的个体索引集合 % nObj : 目标函数个数 n = numel(idxList); if n <= 2 for i = idxList pop(i).crowdDist = Inf; end return; end for i = idxList pop(i).crowdDist = 0; end for m = 1:nObj fvals = arrayfun(@(k) pop(k).f(m), idxList); [~, order] = sort(fvals); sortedIdx = idxList(order); % 边界点拥挤度设为Inf,保证边界解绝对保留 pop(sortedIdx(1)).crowdDist = Inf; pop(sortedIdx(end)).crowdDist = Inf; fmax = fvals(order(end)); fmin = fvals(order(1)); for k = 2:n-1 prev = pop(sortedIdx(k-1)).f(m); next = pop(sortedIdx(k+1)).f(m); pop(sortedIdx(k)).crowdDist = pop(sortedIdx(k)).crowdDist ... + (next - prev) / (fmax - fmin); end end end拥挤度距离的计算方式是对每个目标分别做一次排序,然后看前后邻居在这一维上的距离差,累加后再除以该目标上的取值范围做归一化。边界点距离直接设为Inf,这是因为前沿两端的解没有外侧邻居,如果不特殊处理它们会被优先淘汰,导致前沿在两端收缩。这是个容易踩坑的细节,很多自己实现NSGA-II的人漏了这一步,结果就是前沿两端缺一块。
锦标赛选择则利用rank和crowdDist做二元比较:
function idx = tournamentSelection(pop, T) % T: 锦标赛规模,通常取2 cand = randi(numel(pop), 1, T); best = cand(1); for i = 2:T if pop(cand(i)).rank < pop(best).rank || ... (pop(cand(i)).rank == pop(best).rank && ... pop(cand(i)).crowdDist > pop(best).crowdDist) best = cand(i); end end idx = best; end比较规则很简单:rank更小的胜出;rank相同比拥挤度,crowdDist更大的胜出。这保证了非支配层级低的解优先参与繁殖,同时在同一前沿内,稀疏区域的解有更多机会被选中。
3.5 SBX交叉与多项式变异:算子参数的实际影响
交叉算子用的是模拟二进制交叉SBX。它模拟二进制编码下单点交叉的效果,但直接作用在实数变量上,同时可以通过分布指数控制子代离父代的远近。
function [c1, c2] = sbxCrossover(p1, p2, eta_c) % p1, p2: 1×nVar 父代决策变量 % eta_c : SBX分布指数,典型值15~20 u = rand(size(p1)); beta = zeros(size(p1)); idx1 = u <= 0.5; idx2 = ~idx1; beta(idx1) = (2 * u(idx1)) .^ (1 / (eta_c + 1)); beta(idx2) = (1 ./ (2 * (1 - u(idx2)))) .^ (1 / (eta_c + 1)); % 标准SBX产生两个对称子代 c1 = 0.5 * ((1 + beta) .* p1 + (1 - beta) .* p2); c2 = 0.5 * ((1 - beta) .* p1 + (1 + beta) .* p2); endbeta的取值决定了子代在父代连线周围的分布。eta_c越大,beta越接近1,子代越靠近父代,搜索偏向局部精修;eta_c越小,子代越可能远离父代,偏向全局探索。很多调参经验里把eta_c取15是个兼顾两者的折中位置。
多项式变异相对简单:
function c = polynomialMutation(x, lb, ub, eta_m, pm) % x : 1×nVar 待变异个体 % eta_m: 变异分布指数,典型值20 % pm : 单变量变异概率,通常取1/nVar c = x; r = rand(size(x)); for i = 1:numel(x) if r(i) < pm u = rand; delta = 0; if u < 0.5 delta = (2 * u) .^ (1 / (eta_m + 1)) - 1; else delta = 1 - (2 * (1 - u)) .^ (1 / (eta_m + 1)); end c(i) = x(i) + delta * (ub(i) - lb(i)); end end % 防止浮点误差导致越界 c = min(max(c, lb), ub); enddelta的取值范围在[-1, 1]之间,变异步长是delta乘以变量区间长度。eta_m越大,delta越靠近0,变异步长越短,适合后期微调;eta_m越小,变异越可能跨越大范围,有助于跳出局部前沿。
3.6 精英保留:合并种群后如何选回N个个体
精英保留策略是NSGA-II收敛性的保障:父代和子代合并成2N规模的种群,从中选出N个进入下一代。这样即使子代整体质量不好,父代里的好解也不会丢失。
function pop = elitistSelection(combined, F, N) % combined: 2N规模的合并种群 % F : 对合并种群做完非支配排序后的前沿集合 % N : 下一代种群规模 pop = []; for k = 1:numel(F) if numel(F{k}) + numel(pop) <= N % 当前前沿能全放进去就全放 for i = F{k} pop = [pop, combined(i)]; end else % 放不下时按拥挤度降序取需要的个数 needed = N - numel(pop); combined = crowdingDistance(combined, F{k}, numel(combined(1).f)); [~, order] = sort([combined(F{k}).crowdDist], 'descend'); chosen = F{k}(order(1:needed)); for i = chosen pop = [pop, combined(i)]; end break; end end end这套选择逻辑保证了三层性质:优先放低层级前沿的解;同一层内优先放拥挤度大的解;当某个前沿只能放一部分时,按稀疏度取舍。三者合起来就是NSGA-II赖以平衡收敛性(rank优先)和多样性(crowding优先)的机制。
4. 实验设计与评估:ZDT测试集和IGD/HV指标的复现路径
4.1 测试函数接入:以ZDT1和ZDT3为例
测试函数文件放在problems目录,每个文件接收一个决策变量向量x,返回目标向量f。以ZDT1为例:
function f = ZDT1(x) % ZDT1: 凸Pareto前沿, 30决策变量 n = numel(x); f1 = x(1); g = 1 + 9 * sum(x(2:end)) / (n - 1); h = 1 - sqrt(f1 / g); f = [f1, g * h]; endZDT3比ZDT1多了一个sin项,前沿从连续弧线变成断开的多段弧,专门测试算法在非连续前沿上的表现:
function f = ZDT3(x) % ZDT3: 非连续Pareto前沿, 5段弧线 n = numel(x); f1 = x(1); g = 1 + 9 * sum(x(2:end)) / (n - 1); h = 1 - sqrt(f1 ./ g) - (f1 ./ g) .* sin(10 * pi * f1); f = [f1, g * h]; end接入新的测试函数只需要写一个类似结构的函数文件,然后在main.m里把句柄替换掉。这个设计让问题模块和算法模块完全解耦,做实验时可以批量切换测试集而不用动算法本体。
4.2 IGD指标:如何量化“解集离真实前沿有多近”
IGD反转世代距离是评估多目标算法最常用的指标之一。它的思路非常直接:对真实Pareto前沿上的每个采样点,找算法解集中离它最近的点,把所有最近距离取平均。
function igd = computeIGD(P, PF_true) % P : 算法求得的Pareto前沿点,格式N×m % PF_true: 真实前沿采样点,格式K×m % 距离矩阵 D(i,j) 表示第i个算法解到第j个真实解的距离 D = pdist2(P, PF_true); % 对每个真实前沿点找最近的算法解,取距离均值 igd = mean(min(D, [], 1)); endIGD越小越好,越小说明算法解集对真实前沿的覆盖越完整。这里有个容易忽略的操作:真实前沿采样密度要足够高,通常采样500到1000个点,否则IGD会低估或高估算法的实际表现。我一般会从ZDT1的解析式直接均匀采样生成PF_true,避免引入参考算法的偏差。
4.3 HV指标与二维超体积的简化实现
HV超体积表示算法解集在目标空间中覆盖参考点区域的体积。以参考点为右上角边界,解集中的点作为左下角锚点,围出的区域越大说明解的支配面积越大。
function hv = computeHV(P, refPoint) % P : 解集,N×2 % refPoint: 参考点,各目标维度上的最大值 % 按第一目标降序排列 P = sortrows(P, 1, 'descend'); hv = 0; maxF2 = 0; for i = 1:size(P, 1) % 只统计在参考点范围内的解 if P(i, 2) > maxF2 && P(i, 1) < refPoint(1) && P(i, 2) < refPoint(2) hv = hv + (refPoint(1) - P(i, 1)) * (P(i, 2) - maxF2); maxF2 = P(i, 2); end end hv = max(hv, 0); end这个代码是二维场景下的简化实现。核心逻辑是:按f1从大到小排序,用一个滑动高度maxF2记录当前已覆盖区域的最高点,每遇到一个更高的解就累加一段矩形面积。二维下精确且高效,三维及以上的HV计算复杂度呈指数增长,建议直接调用现成的高维超体积工具库。
4.4 实验对比怎么设计:至少跑20次取中位数
单次运行NSGA-II的结果有随机性,因为锦标赛选择和变异都用到了随机数生成器。做实验时我通常每组配置独立运行20次,记录每代的IGD序列,最终取中位数画收敛曲线,而不是只报最后一次运行的结果。种群的随机性来源有两个:初始化的随机采样和每代选择时randi抽到的候选个体。同一个参数配置重复跑几次,结果会有波动,只用单次实验得结论很容易被噪声误导。
标准的主循环里可以在每代结束后提取第一前沿来计算指标:
[combined, F] = fastNonDominatedSort(combined); pop = elitistSelection(combined, F, N); % 取当前第一前沿的目标值矩阵 fvals = [combined(F{1}).f]; fvals = reshape(fvals, numel(combined(F{1}(1)).f), [])'; histIGD(gen) = computeIGD(fvals, PF_true);从实验习惯上来说,我一般会同时记录IGD和运行时间两个维度。IGD衡量解的质量,运行时间衡量算法开销。NSGA-II在ZDT1这种30维问题上,N=100、maxGen=250的情况下单次运行在普通桌面上通常是秒级完成,跑20次做统计不会太耗时。
5. 避坑指南:NSGA-II跑偏的五个典型翻车现场
5.1 前沿点大量重叠,种群多样性快速坍塌
现象:跑完画出的Pareto前沿只有稀稀拉拉几个点,大量个体几乎重叠在相同的位置,分布性很差。
原因:最常见的是两种可能。一是拥挤度距离计算里对边界点没有处理成Inf,导致前沿两端每代都被淘汰,前沿逐渐向内收缩;二是拥挤度距离没有在各目标上归一化,某个目标量纲特别大时,该目标维度在距离计算里占主导,另一个目标维度对排序几乎没影响。
解决:检查crowdingDistance里是否把排序后的首尾点设为Inf;计算距离时先按目标做归一化。如果已经做了这两步还是聚集,把交叉概率pc提到0.9以上,同时把变异概率pm调大到1/nVar附近,让子代有更大探索空间。
5.2 MATLAB结构体循环越跑越慢
现象:N=100时还能接受,N一提到500,单代运行时间突然飙升到秒级,整个实验跑下来非常难受。
原因:结构体数组在循环里反复访问字段会触发MATLAB的防御性复制机制。如果代码里频繁使用pop = [pop, offspring]这种动态拼接,每拼一次都会重新分配一整块内存。这是MATLAB写优化算法最常见的性能杀手,不是算法本身慢,是数据结构用错了。
解决:两个方向。第一个方向是预分配,像initPopulation里那样用repmat提前把结构体数组建好,循环内只赋值不增长;第二个方向是彻底换存储方式,决策变量用一个N×nVar矩阵存,目标值用一个N×m矩阵存,rank和crowdDist用N×1向量存,每代操作全部走矩阵运算和逻辑索引,能比结构体快好几倍。
5.3 非支配排序莫名其妙少一层前沿
现象:对比标准NSGA-II的文献结果时发现,自己代码得到的前沿层数总是少一层,F{1}里的个体数量明显偏多。
原因:我遇到过最典型的情况是dominates函数里的判断条件写错了。有人写成all(f1 < f2),把相等的情况也当成支配的一部分;也有人写成any(f1 <= f2) && any(f1 < f2),导致两个互不支配的解被误判成支配关系,本该分到不同层级的前沿被合并了。
解决:dominates必须严格写成all(f1 <= f2) && any(f1 < f2),注意是all加any的组合。写完后用一个两目标的小例子测一下:A=[1,2],B=[2,1],C=[1,2],确认A不支配B、A不支配C、A和C互相不支配。这种两两组合的手工验证能快速定位排序逻辑的问题。
5.4 越界变量被直接截断到边界,种群堆满边界值
现象:某个决策变量长期停留在下界或上界的固定值上,完全没有多样性,Pareto前沿在高纬度方向上看起来像被压扁了。
原因:交叉或变异后产生的变量值越界,很多实现直接min(max(c, lb), ub)把变量拉回边界。问题在于当eta_m很小、变异步长很大时,大量子代变量会越界,截断后全都变成边界值,造成严重的重复解。
解决:不要一律截断到边界。对越界变量,常见做法是重新在[lb, ub]内随机采样替换,或者用lb + rand * (ub - lb)做随机重初始化。这样既保持了边界约束,又不会把一整个变量维度都堆在边界上。这个改动对ZDT4这类多峰函数尤其重要,否则算法很容易提前收敛到局部前沿。
5.5 换测试函数后结果变差,以为是算法崩了
现象:把默认的ZDT1换成ZDT2,IGD曲线整体变差甚至不收敛,第一反应是NSGA-II实现有bug。
原因:ZDT2和ZDT1的决策变量维度同样是30,但前沿是凹的。更重要的是,不同测试函数对变异算子的敏感性不一样,ZDT4等函数带有大量局部前沿,需要的变异步长比ZDT1大得多。参数配置完全照搬,自然会在切换测试问题时表现崩坏。
解决:换测试问题时,先确认目标函数的计算方式是否正确,再检查真实前沿的采样方式是否需要同步更换。ZDT2的真实前沿采样公式和ZDT1不同,如果用ZDT1的PF_true去算ZDT2的IGD,指标必然失真。参数层面,对多峰问题可以尝试把eta_m从20降到10,或把pm调大,让变异步长覆盖更多区域。从那以后我每次切换测试问题都强制走一遍“检查解析式、检查采样密度、检查参数适用性”三个步骤,再也没被这种假故障骗过。
6. 二次开发:换测试函数、换算子、上并行
这套框架的扩展点非常清晰,最常做的三件事是接入新测试问题、替换遗传算子、改造并行评估。
接入新测试问题时,只需要在problems目录下新增一个函数文件,按“输入x、输出f”的约定实现。多目标优化模型的测试问题基本都是这个套路,ZDT、DTLZ系列都可以在几分钟内接入。main.m里把问题句柄换成新函数,同时同步修改nVar、lb、ub和真实前沿采样逻辑。需要注意ZDT系列一律是双目标30变量,DTLZ系列需要额外指定目标个数m和变量个数n,这些参数在问题文件内部计算,外部只需正确传递。
替换遗传算子比想象中容易。以SBX换成差分进化算子为例,差分进化的核心变异公式是:
v = x_r1 + F * (x_r2 − x_r3)
其中x_r1、x_r2、x_r3是从种群中随机挑的三个不同个体,F是缩放因子。实现时写一个新函数替换sbxCrossover的位置即可,主循环不用改。如果要做并行化改造,最直接的方式是把evaluatePop替换成parfor版本:
function pop = evaluatePopParallel(pop, objFunc) parfor i = 1:numel(pop) pop(i).f = objFunc(pop(i).x); end end这里的前提是objFunc是纯函数,内部不依赖工作区共享变量。另外,parfor里对结构体字段的写操作需要明确确认每个迭代索引互斥,MATLAB对parfor写struct数组的支持在不同版本有差异,最稳妥的做法是先取出决策变量矩阵,并行算出目标值矩阵,再填回结构体:
X = reshape([pop.x], nVar, N)'; parfor i = 1:N Fvals(i, :) = objFunc(X(i, :)); end这样避免了parfor写结构体的兼容问题。
对于想把这个项目改造成命令行工具或者批量实验脚本的人,有个实用技巧:把main.m的主循环封装成一个函数,输入是问题句柄和参数结构体,输出是最终前沿和每代IGD历史。这样可以用一个循环批量跑不同测试函数、不同参数组合,实验结果集中写进表格。我后来给某实验室做算法对比基线时就是这么改的,一个入口函数配合参数扫描脚本,一周内的实验量半天就排完了。从那以后我每次搭实验框架都强制坚持“问题句柄传入、参数集中管理、输出记录完整”三个习惯,希望帮到你。
本文还有配套的精品资源,点击获取