简介:这份资源是CUG智能优化课程设计配套的Python实现NSGA-Ⅱ算法完整代码包,面向需要完成多目标优化课设或学习进化算法的本硕学生,同样适合准备算法竞赛的开发者参考。压缩包共185个文件,整体约969KB,包括12个Python源码、100个mat格式的种群迭代快照、50个txt结果记录,以及png可视化图表和xml工程配置等;其中算法主程序实现非支配排序、拥挤距离计算与遗传算子,赛题问题定义文件涵盖CEC-2021测试问题,mat格式种群快照可用于还原每次迭代的种群状态,便于对比验证。已有125人浏览学习。通过阅读代码可掌握NSGA-Ⅱ的初始化、选择交叉变异、非支配排序及精英保留等完整流程,并理解如何将算法应用于多目标优化问题;资源还附带HV等性能指标计算脚本,方便量化评估解的收敛性与分布性。代码将问题定义、算法主体与结果分析分离,结构清晰易扩展,既可作为课设直接参照,也能帮助学习者将NSGA-Ⅱ迁移到工程调度、路径规划等实际场景,是智能优化算法入门的实用素材。
1. 当课设题目是 NSGA-II:CUG 智能优化课设与 CEC-2021 到底在考什么
我第一次看到“Python 实现 NSGA-II 算法,解决 CEC-2021 竞赛中的问题”这个题目时,第一反应是:这不是个普通的排序题,也不是单纯调个库就能交差的作业。它要求你亲手写一遍 NSGA-II 的核心流程——快速非支配排序、拥挤度计算、锦标赛选择、精英保留、SBX 交叉和多项式变异,再把它丢到 CEC-2021 的基准测试函数上跟已知最优解比一比。CUG 的智能优化课设选这个题,其实是想让你把“多目标进化算法不是黑匣子”这件事真正搞清楚。
很多人拿到这个题的第一直觉是去 GitHub 上搜现成的 NSGA-II 实现,然后改个函数名交差。但 CEC-2021 的测试函数不是随便写个目标函数就能糊弄过去的,它里面既有单峰也有多峰,既有变量可分也有变量不可分的情况,有的函数还用移位和旋转制造了欺骗性。你的算法要是只会在简单问题上跑,一上竞赛函数就原形毕露。
这篇文章我按自己做这套课设的完整思路来写:从算法核心流程怎么拆,到 Python 代码怎么写,再到 CEC-2021 测试函数怎么用、参数怎么调、实验报告要放哪些数据,最后把最容易翻车的地方一次说清。新手跟完这套流程能交出一份拿得出手的课设,熟手也可以对照着查一下自己的实现哪里埋了雷。这套东西的价值不在“跑通”,而在你知道每个算子在干什么、为什么这样设计、失败了应该去查哪个环节。
2. 从“会调库”到“会写算法”:NSGA-II 的五个核心模块是怎么在 Python 里落地的
想用 Python 写一个能用的 NSGA-II,首先得把算法拆成几个能独立实现、独立验证的模块。很多课设翻车就是因为一上来就把整个算法糊在一个大循环里,出了问题根本不知道是交叉算子的 bug 还是选择环节的 bug。我一般会按五个模块来拆:种群初始化、快速非支配排序、拥挤度计算、锦标赛选择、交叉变异。每个模块单独写完先跑一个小测试,最后再拼成主循环。
“会写”的标志不是你背下了伪代码,而是你能解释为什么快速非支配排序要把种群分成一层一层的 Pareto 前沿、为什么拥挤度距离要在同一前沿内算、为什么锦标赛选择的压力参数 k 要取 2。这些设计决定不是拍脑袋拍出来的,每个都对应一个具体的问题场景。下面我先讲清楚为什么要这么设计,再给你能直接改着用的最小实现。
2.1 快速非支配排序:为什么把种群分成“一层一层”而不是直接比较所有个体
多目标优化里最核心的问题就是“两个解谁更好”。单目标比大小就行,多目标没有绝对的大小关系,只有支配关系:解 A 在所有目标上都不差于解 B,且至少在一个目标上严格优于解 B,那 A 支配 B。快速非支配排序要做的事情,就是把种群里的所有个体按照支配关系分成若干层,第一层是不被任何个体支配的个体集合,第二层是去掉第一层以后不被剩余个体支配的,以此类推。
分层有什么用?因为后续的选择操作需要优先保留更靠近真实 Pareto 前沿的个体。如果所有个体混在一起选,多样性是有了,但收敛性没法保证。NSGA-II 的核心思想就是先保证收敛(前沿层越靠前越优先保留),再保证多样性(同一层内用拥挤度拉开距离)。这两句话是整个 NSGA-II 的算法灵魂。
用 Python 实现的时候,最常见的做法是先用一个列表记录每个个体被哪些个体支配、又支配哪些个体,同时记录每个个体被支配的次数。被支配次数为 0 的放进第一层,然后把第一层移除后,剩下个体的被支配次数相应减一,再找出新的 0 支配次数个体作为第二层。这个过程用一个队列或者循环就能完成。真实实现代码如下:
def fast_non_dominated_sort(population, objectives): # population: 个体列表, 每个个体是一个 dict, 至少包含 'vector' 和 'fitness' # objectives: 目标函数列表, 每个函数接受一个向量返回一个标量 n = len(population) S = [[] for _ in range(n)] # S[i] 存被 i 支配的个体索引 Np = [0] * n # Np[i] 存支配 i 的个体数量 rank = [0] * n fronts = [[]] for i in range(n): fi = [f(population[i]['vector']) for f in objectives] population[i]['fitness'] = fi for j in range(n): if i == j: continue fj = [f(population[j]['vector']) for f in objectives] if dominates(fi, fj): S[i].append(j) elif dominates(fj, fi): Np[i] += 1 if Np[i] == 0: rank[i] = 0 fronts[0].append(i) k = 0 while fronts[k]: next_front = [] for i in fronts[k]: for j in S[i]: Np[j] -= 1 if Np[j] == 0: rank[j] = k + 1 next_front.append(j) k += 1 fronts.append(next_front) for i in range(n): population[i]['rank'] = rank[i] return fronts[:-1]这段代码的逻辑说明:先对每个个体计算全部目标值并存入自身 fitness 字段,然后两两比较支配关系,构建“被谁支配”和“支配谁”两张表。被支配次数为 0 的个体天然属于第一层,之后逐层剥离。fronts[:-1]是去掉最后那个空列表的小技巧。这里有一个我踩过的坑:在循环里对每个 j 都重新计算一遍目标函数值,会导致计算量翻好几倍。更好的做法是先把所有个体的目标值一次性算好,存成矩阵,比较时直接查表。CEC-2021 的函数计算量不小,尤其是维度拉到 30 甚至 50 的时候,每多算一遍都是实打实的等待时间。
dominates函数是我自己写的辅助函数,判断 a 是否支配 b,条件是 a 的每个目标值都不大于 b 且至少一个严格小于。注意 CEC-2021 的测试函数基本都是最小化问题,所以这里默认是越小越好。如果你的题目要求最大化,直接把目标值取负号就行,不用改排序逻辑。
2.2 拥挤度距离:同一层前沿里,怎么定义“哪个解更稀有”
分层只解决了收敛问题,还没解决多样性问题。如果同一层前沿上的个体都挤在一个区域内,比如全都集中在 Pareto 前沿的左端,那这个算法虽然收敛了,但得到的解分布很差,决策者拿不到多样化的方案。拥挤度距离就是用来衡量一个个体在目标空间里被周围个体“挤”得有多紧的指标。
拥挤度距离的计算思路并不复杂:对同一个非支配层内的所有个体,按每个目标分别排序,然后看每个个体在相邻两个个体之间的目标值跨度有多大。跨度越大说明这个个体周围的区域越“空旷”,越值得保留。两个极端边界个体在某一维上缺少邻居,通常直接给无穷大的距离值,保证边界个体一定被保留下来。
实现时最容易出错的是目标值相同的情况。如果两个个体在某个目标维度上值完全一样,排序后距离就是 0,这本身没问题,但如果你用max-min归一化时最大值等于最小值,就会出现除零错误。我常用的解决办法是给分母加一个小 epsilon,或者直接判断最大值等于最小值时,这一维上所有人距离都设为 0。
def crowding_distance(front, population, objectives): # front: 某一非支配层的个体索引列表 dist = {i: 0.0 for i in front} if len(front) <= 2: for i in front: dist[i] = float('inf') return dist for m in range(len(objectives)): front_sorted = sorted(front, key=lambda i: population[i]['fitness'][m]) dist[front_sorted[0]] = float('inf') dist[front_sorted[-1]] = float('inf') f_max = population[front_sorted[-1]]['fitness'][m] f_min = population[front_sorted[0]]['fitness'][m] if f_max - f_min < 1e-12: continue for k in range(1, len(front_sorted) - 1): idx = front_sorted[k] prev_idx = front_sorted[k - 1] next_idx = front_sorted[k + 1] dist[idx] += (population[next_idx]['fitness'][m] - population[prev_idx]['fitness'][m]) / (f_max - f_min) return dist这段代码的逻辑说明:先给边界的两个个体无穷大拥挤度,保证其存活,然后对内部个体按每个目标维度累加归一化后的距离跨度。如果你跑出来的解总是集中在一片区域,优先检查这个函数有没有写对,因为我见过不少课设代码把f_max - f_min写成了f_max + f_min,导致拥挤度计算完全失真。
一个容易被忽略的细节是:拥挤度距离要在每个前沿层内部单独计算,不能跨层。因为跨层比较没有意义——第一层的解和第二层的解在 Pareto 意义上根本不在一个档次上。有些人在实现时图省事,把所有个体拉在一起算拥挤度,这样会让优秀前沿的信息完全被稀释掉。
2.3 锦标赛选择与精英保留:控制种群规模的同时锁住优质解
有了排序和拥挤度,选择就顺理成章了。NSGA-II 用的是二元锦标赛选择:每次从种群中随机挑两个个体,优先选非支配层号小的;层号相同,选拥挤度距离大的。这样反复挑,直到选满需要的个体数量。锦标赛选择的优势是它不需要全局排序信息,操作随机性带来一定的探索能力。
二元锦标赛的压力参数是一个很值得讨论的事情。k=2 是经典配置,选择压力适中。如果 k 调大,比如 k=4,选择压力变大,收敛速度可能更快,但种群多样性容易崩;如果你发现算法收敛到某个局部区域彻底不动了,可以先看看是不是 k 设太大了。
精英保留是 NSGA-II 的另一个关键设计:子代生成以后,把父代和子代合并成一个大种群,然后按照“层号小的优先,层号相同拥挤度大的优先”排序,截取前 N 个个体作为下一代种群。这保证了最优解永远不会丢失——即使子代整体变差了,父代里的优秀个体还在合并池里兜底。
def tournament_selection(population, k=2): import random a = random.sample(population, k) best = min(a, key=lambda ind: (ind['rank'], -ind['crowd_dist'])) return best def elitist_merge(parents, offspring, pop_size): combined = parents + offspring # parents 和 offspring 里的个体都已经算好了 rank 和 crowd_dist combined.sort(key=lambda ind: (ind['rank'], -ind['crowd_dist'])) return combined[:pop_size]选择代码里的-ind['crowd_dist']是个小技巧:Python 的 sort 默认从小到大,想要“层号从小到大、拥挤度从大到小”,就利用元组排序时第二项取负来反转方向。这个写法简洁,但如果你后续要读代码的人不熟悉这个技巧,最好加一行注释说明。
精英保留这里有一个很多人没注意到的边界条件:如果合并后总个体数少于等于种群规模,那直接全部保留,不需要截断。但这种情况通常只会出现在算法初期,因为后续父代子代加起来一定是 2 倍规模,除非你的交叉变异实现有 bug 导致部分子代没生成。
2.4 模拟二进制交叉与多项式变异:算子参数到底怎么设才不玄学
SBX 交叉和多项式变异是 NSGA-II 的默认算子,也是课设里最容易“调参调成玄学”的地方。SBX 的分布指数 eta_c 控制子代离父代的远近:eta_c 越大,子代越可能贴近父代;越小,子代越可能远离父代。多项式变异的分布指数 eta_m 同理,控制变异步长的大小分布。Deb 在原始论文里的建议是 eta_c=20、eta_m=20,实际使用中我也建议你先从这两个值开始,不要随便改。
SBX 交叉的核心是一个随机数 u 和一个对应的分布因子 beta。当 u 小于等于 0.5 时按一个公式算,否则按另一个公式算。关键是这个对称设计:两个父代产生两个子代,保证种群规模不变。我见过有些初学者只生成一个子代,导致种群数量不对,主循环跑两代就崩掉或收敛异常。
def sbx_crossover(p1, p2, eta_c=20, lb=0, ub=1): import random c1 = [] c2 = [] for x1, x2 in zip(p1, p2): if random.random() <= 0.5: u = random.random() if u <= 0.5: beta = (2 * u) ** (1 / (eta_c + 1)) else: beta = (1 / (2 * (1 - u))) ** (1 / (eta_c + 1)) c1_x = 0.5 * ((x1 + x2) - beta * abs(x2 - x1)) c2_x = 0.5 * ((x1 + x2) + beta * abs(x2 - x1)) c1_x = min(max(c1_x, lb), ub) c2_x = min(max(c2_x, lb), ub) else: c1_x = x1 c2_x = x2 c1.append(c1_x) c2.append(c2_x) return c1, c2 def polynomial_mutation(child, eta_m=20, lb=0, ub=1): import random mutated = [] for x in child: if random.random() <= 1.0 / len(child): u = random.random() if u < 0.5: delta = (2 * u) ** (1 / (eta_m + 1)) - 1 else: delta = 1 - (2 * (1 - u)) ** (1 / (eta_m + 1)) new_x = x + delta * (ub - lb) mutated.append(min(max(new_x, lb), ub)) else: mutated.append(x) return mutated参数说明:交叉部分的lb和ub是决策变量的边界,CEC-2021 每个测试函数的边界在问题定义里写得明明白白,一般是对称区间,比如 [-100, 100]。变异概率这里用了1.0 / len(child),也就是每个变量平均变一个,这是常见的多项式变异配置。如果你想加快收敛,可以把变异概率调低一点;想增强探索,就调高一点。但一定记住:这两个算子的本质是概率性的,单次运行的结果有随机性,判断算法好坏要看多次运行的统计结果。
一个值得注意的坑:SBX 交叉后的子代可能越界。你在生成子代后必须做边界修正,否则 CEC-2021 的函数在某些区域会返回异常值或无穷大。很多人算法结构完全正确,就是因为没做边界修正,导致种群中混入越界个体,最终 IGD 指标爆炸。
3. 把 CEC-2021 挂到算法上:测试函数体系、Python 改写与最小跑通代码
课设题目明确点出了 CEC-2021。CEC 系列竞赛是进化计算领域的经典基准测试集,每年换一套新函数,设计思路一般是打乱函数之间的相似性,让“调参调出来的强算法”失去优势。CEC-2021 的多目标测试函数是从之前的 CEC-2019 多目标测试集延续演化过来的,也融合了单目标 CEC-2021 的一些设计思路。
函数定义、真实 Pareto 前沿、最优解位置等数据,都是公开的竞赛材料,通常以 PDF 或官网数据包形式发布。做课设的时候先不要急着写算法,把每个函数的公式和变量边界、目标数量、是否旋转、是否移位看清楚,这个步骤省下来,后面排查问题会痛苦很多。
3.1 CEC-2021 的函数结构:为什么说它“专门治各种过拟合”
CEC-2021 的多目标函数有一个显著特征:每个函数都附带对应的真实 Pareto 前沿,这个前沿是从已知最优解采样得到的,不是随便算出来的。你在验证自己算法时,要把最后得到的近似前沿跟真实前沿做对比计算 IGD 指标,这个指标是课设评分的重要参考。如果你的算法连 IGD 都不会算或算错了,实验报告的含金量会大打折扣。
CEC-2021 的函数设计有很多细节非常狠。比如有些函数是旋转过的——把决策变量先做线性变换再计算,导致变量之间不再独立可比。有些函数是欺骗性的——全局最优区域很小,让算法很容易收敛到局部最优。还有些函数是多峰密集型的,多个峰值之间差距很小,算法会在这几个峰之间反复横跳。这么设计的目的是确保一个算法只有在真正理解了多目标优化的核心机制、而不是靠某些问题特性取巧,才能在竞赛中拿到好成绩。
从工程角度看,做 CEC-2021 的课设不需要去重新实现这些函数的内部逻辑——题目通常会给 MATLAB 版本的函数包,但我们是 Python 实现,处理方式有两种:要么用scipy.io.loadmat读取 MATLAB 数据文件,要么自己用 Python 把函数重新写一遍。我建议你优先找有没有官方的 Python 接口,如果没有,就仔细读官方定义自己翻译。
3.2 真实前沿数据怎么加载:numpy 读取与指标计算的最小闭环
拿到官方数据包以后,通常会看到两类文件:函数定义的源码或描述文件、真实 Pareto 前沿的数据文件。真实前沿文件一般是一个矩阵,每一行是一个 Pareto 最优解的目标值向量,行数至少几百。用 Python 加载这类数据时我习惯用 numpy 的np.loadtxt或np.genfromtxt,注意文件头部可能有多行描述性的注释,需要正确处理。
import numpy as np def load_pf_file(file_path): # 跳过以 # 或 % 开头的注释行, 加载真实 Pareto 前沿 pf = np.loadtxt(file_path, comments='#') if pf.ndim == 1: pf = pf.reshape(1, -1) return pf这段代码虽然短,但解决了一个真实问题:很多 CEC 数据文件的第一行是文件格式说明,用#开头,直接丢给np.loadtxt会读取失败。如果你读出来的前沿形状不对,比如只有一行或者行数特别少,一定要先打印一下原始文件的前几行看看结构。另外,如果你的数据文件是 MATLAB 的.mat格式,用scipy.io.loadmat读取时需要先看清楚变量名——官方文件里不同年份的变量名并不统一,盲猜容易报错。
加载完真实前沿,接下来就是 IGD 指标的计算。IGD 衡量算法求得的近似前沿与真实前沿之间的平均距离,越小越好。它反映的是算法最终结果的综合表现——既看收敛性也看覆盖度。计算 IGD 的代码如下:
def compute_igd(approx_front, true_front): # approx_front: 算法得到的非支配解目标值矩阵, 每行一个解 # true_front: 真实 Pareto 前沿目标值矩阵 distances = [] for true_point in true_front: diff = approx_front - true_point dist = np.sqrt(np.sum(diff ** 2, axis=1)) distances.append(np.min(dist)) return np.mean(distances)IGD 的计算逻辑是:对真实前沿上的每个点,找到算法近似前沿中离它最近的那个点,计算欧氏距离,然后对所有真实前沿点取平均。IGD 值小说明算法得到的解不仅离真实前沿近,还能覆盖到真实前沿的各个区域。如果算法只收敛到真实前沿的某一段,IGD 依然会很高。这个指标很公平,它同时惩罚了漏掉区域和偏离前沿两种情况。
还有另一个常用指标是 HV(超体积指标),它计算算法得到的非支配解在目标空间中所覆盖的体积。HV 不需要知道真实前沿,对目标空间中的参考点选择比较敏感,但它在处理不规则前沿时比 IGD 更稳定。如果课设要求两个指标都算,建议你先把 IGD 做对,再补 HV。
3.3 跑通一个函数的完整主循环:种群初始化到下一代生成的代码骨架
所有模块都具备后,主循环是最后一步拼接。我把最常用的主循环结构贴在这里,它包含初始化、评价、排序、选择、交叉、变异、合并、截断的完整流程。注意这里为了可读性去掉了收敛判断和统计收集的代码,实际课设中你需要把这些信息记录下来画收敛曲线。
import numpy as np import random POP_SIZE = 100 MAX_GEN = 300 DIM = 10 LB, UB = -100, 100 def objective_sum(x): # 以 CEC-2021 某个函数为例, 这里用 ZDT1 替代演示结构 obj1 = x[0] g = 1 + 9.0 / (DIM - 1) * np.sum(x[1:]) obj2 = g * (1 - np.sqrt(obj1 / g)) return [obj1, obj2] population = [] for _ in range(POP_SIZE): vector = [random.uniform(LB, UB) for _ in range(DIM)] population.append({'vector': vector}) fronts = fast_non_dominated_sort(population, [objective_sum]) for front in fronts: distances = crowding_distance(front, population, [objective_sum]) for idx in front: population[idx]['crowd_dist'] = distances[idx] for gen in range(MAX_GEN): offspring = [] while len(offspring) < POP_SIZE: p1 = tournament_selection(population) p2 = tournament_selection(population) c1, c2 = sbx_crossover(p1['vector'], p2['vector'], eta_c=20, lb=LB, ub=UB) c1 = polynomial_mutation(c1, eta_m=20, lb=LB, ub=UB) c2 = polynomial_mutation(c2, eta_m=20, lb=LB, ub=UB) offspring.append({'vector': c1}) if len(offspring) < POP_SIZE: offspring.append({'vector': c2}) fronts = fast_non_dominated_sort(offspring, [objective_sum]) for front in fronts: distances = crowding_distance(front, offspring, [objective_sum]) for idx in front: offspring[idx]['crowd_dist'] = distances[idx] population = elitist_merge(population, offspring, POP_SIZE)主循环说明:这里首先生成父代并初始化排序,然后每代循环里不停做锦标赛选父、交叉、变异、生成子代。子代生成后重新计算非支配排序和拥挤度,最后合并父代子代截断到 POP_SIZE。细节上要注意两点:一是objective_sum函数每次调用都要重新计算目标值,如果在交叉变异后没有重新计算 fitness 就去做排序,那排序用的全是旧数据,算法会完全失效;二是elitist_merge里直接对组合列表排序,靠的是之前计算的 rank 和 crowd_dist,所以合并前必须确认子代个体这些字段都正确生成。
主循环写完后先别着急跑全参数,先用小规模验证一下:POP_SIZE=30, MAX_GEN=50, DIM=2,跑几次人工看看结果是否有基本合理的 Pareto 前沿形状。如果连这个简单情况都跑不对,直接上大规模只会浪费时间。
4. 课设代码要“看得懂”也要“跑得动”:CUG 智能优化课设的验收视角与实验设计
如果你交的课设只是代码能跑、结果能用,那也就是及格水平。CUG 这类智能优化课设的验收通常会追问几个问题:NSGA-II 为什么比随机搜索好?你的参数为什么这么设置?IGD 指标是怎么算的?收敛曲线有什么特点?这些问题你在做实验的时候顺手记录就能答上,临时抱佛脚容易露馅。
我的建议是:实验部分按“固定随机种子 + 多次运行 + 平均值/标准差”来做。多目标进化算法本质是随机算法,一次跑出来的结果不能说明任何问题。每次换一组随机种子跑 10 遍,记录 IGD 的均值和标准差,才能体现算法的稳定性。这也是课设报告里的核心数据来源。
4.1 实验报告要放的五类数据:从收敛曲线到非支配前沿的可视化
第一个必放的是收敛曲线。横轴是迭代代数,纵轴是 IGD 值。每代都算一次 IGD,把曲线画出来,可以看出算法是稳定下降还是反复震荡。震荡说明交叉变异算子太激进,稳定但下降慢说明选择压力不够。第二个必放的是最终的非支配前沿图,横纵轴是两个目标值,真实前沿和算法前沿画在一张图上,一眼就能看出收敛性和分布性好坏。
第三个必放的是不同运行次数下的 IGD 统计表,包括均值、标准差、最好值、最差值。这个表格比一张效果图更有说服力。第四个是参数敏感性分析,比如固定其他参数只改种群规模,看 IGD 怎么变化;固定种群规模只改迭代代数,看多少代开始收敛。第五个是把你的算法结果跟随机搜索或者单目标优化器的结果做一个对比,哪怕只有一组对比数据,也能凸显 NSGA-II 作为多目标优化算法的真正价值。
真实前沿加载和可视化画图时有个小坑:如果你的真实前沿文件是 CEC-2021 官方提供的,那里的目标值分布范围往往很大,画图前要不要归一化取决于你后续是否要跟算法结果画在同一个坐标系。我建议先直接画,观察一下分布范围,如果两者量级差太多再决定是否归一化。
4.2 怎么判断代码是不是“真的写对了”:从单目标退化到多目标验证的方法
一个非常有效的验证方法是:把一个多目标问题退化成单目标来测试。怎么退化?给两个目标加权求和变成一个目标,然后让 NSGA-II 去优化这个单目标,理论上最终应该逼近或接近单目标最优解。如果连这个都做不到,说明你的交叉变异或评价流程写错了。这不是绕路,而是用已知结论来验证算法实现正确性。
具体操作流程:先用一个已知最优解的单目标函数,比如 Sphere 函数,把 NSGA-II 里面的目标函数改成两个 Sphere,一个用 x 的奇偶位计算,一个用偶位计算,让它们既相关又有差异。跑完以后查看最终非支配前沿——真实的 Pareto 前沿你们自己都能推出来,对照验证。
另一种更直接的方式是拿 NSGA-II 跟一个成熟的多目标优化库做对比。虽然我不想依赖外部库,但课设验证阶段用pymoo或platypus跑同一个 CEC 函数做参考,看你的算法 IGD 跟库结果差多少,比你闷头调自己的代码快得多。注意到差距大不一定是你算法差,可能有几个原因:参考库用了不同的算子参数、不同的种群淘汰策略,或者你用的函数实现跟库用的不完全一致。
4.3 参数不调好别甩锅给算法:种群规模、迭代代数和算子参数的匹配原则
我做的时候有一条经验:先固定一个你认为合理的参数组合,跑通逻辑,再系统调参。千万不要同时改多个参数——比如把种群大小从 100 改成 200 的同时又把交叉概率从 1.0 改成 0.8,那实验结果变了你根本不知道是哪个参数起了作用。
参数设置有几个经验关系可以参考:种群规模越大,需要的迭代代数通常越多。如果你只打算跑 100 代,种群规模设成 50 或 100 就够了;如果你设 300 代,种群规模 100 仍然合理,但如果你设 1000 代且种群规模只有 50,算法可能在后半段出现近亲繁殖导致多样性下降。CEC 函数在 10 维到 50 维之间,我一般按“种群规模不低于维度数的 5 倍,不超过维度数的 20 倍”来粗估。维度 10 时用 100,维度 30 时用 300,这是一个不会出大错的起点。
变异概率和交叉概率的设定,我一般保持交叉概率接近 1.0,变异概率不按固定常数设,而是跟决策变量维度挂钩——每维变异概率取 1/DIM,这样平均一次变异会改动一个维度,比较平衡。如果你发现早熟收敛,可以把变异概率调高两倍;如果你发现收敛慢,则可以把变异概率降三分之一左右。这些调整幅度不要太大,一次只能动一个参数,效果要记录。
5. 把 CEC-2021 课设从“能跑”做到“好用”:避坑手记
5.1 现象:IGD 指标突然飙到无穷大或异常大
原因一般是目标函数计算过程中出现了 NaN 或无穷大。CEC-2021 里有些函数包含对数、根号、除法等操作,决策变量越界后这些操作会返回非数值。另一个常见原因是真实前沿文件中典型值很小,而算法前沿中部分点目标值数量级差异很大,导致距离计算出现巨大值。
解决方法是两件事同时做:一是在目标函数里对返回值做一次有效性检查,如果出现非有限数值直接赋一个极大值比如 1e10,防止污染排序;二是在交叉变异函数里严格做边界修剪,确保个体永远不会越界。这两个步骤做了以后,绝大多数 IGD 异常问题都能解决。你可以在调试阶段打印每一代中非有限数值个体数量,看看是哪里产生了问题。
5.2 现象:跑了很多代,解全部挤到目标空间的一个角落
原因通常是多样化机制失效了,要么拥挤度没起作用,要么选择时拥挤度排序写反了。我见过有人把排序条件写成了“拥挤度小的优先保留”,这会导致算法极度偏向拥挤区域,相当于主动放弃多样性。还有一种可能性是你的变异概率设得太低,种群很快被选择压力“吸”到一个局部区域,再也跳不出来。
解决方法是先检查选择函数里的排序元组,确认是rank正序、crowd_dist降序;然后在多个随机种子下重复跑,排除单次随机性的影响。如果问题依然存在,尝试把变异概率从 1/DIM 提高到 2/DIM,这通常能有效增加早期探索能力。解挤在一角的问题在 CEC-2021 的旋转函数上尤其常见,因为变量之间的耦合会让常用交叉算子产生大量无效子代。
5.3 现象:算法结果跟参考库差距巨大,但你的代码看起来没问题
原因可能不在算法本身,而在测试函数的 Python 实现上。CEC 系列函数的定义非常精细,稍微漏掉一个旋转矩阵、移位向量或者换了一个计算顺序,结果就会完全不同。如果你是自己照着 PDF 描述翻译成 Python 的,这里就是最大风险区。
解决办法是做一个交叉验证:用几组已知输入和对应输出,跟 MATLAB 参考实现或 pymoo 内置测试函数的结果做对比。如果目标值对不上,说明函数实现有误。这个验证过程也许要花一两个小时,但它能省掉后面怀疑算法结构的大量时间。如果目标值能对上,再找算法差异——一般就是参数配置不同了。
5.4 现象:运行速度慢得难以接受,改小规模后又怕结果不可信
原因通常是你实现时重复计算了太多目标函数。比如在非支配排序里每比较一对个体都重新计算目标值,或者每一代都对整个种群重新计算一遍真实前沿距离。CEC-2021 的函数复杂度不低,维度一大,重复计算会指数级拉慢。
解决方法是在算法主循环开始前,把种群个体的目标值先全部算好存入表格,后续排序、拥挤度计算都只查表。另外,IGD 计算不需要每代都做,可以每 10 代或者 50 代记录一次,最后画曲线时用记录值即可。用 Python 写这种计算密集型算法时,学会把计算集中到最必要的位置,速度提升往往非常明显。
5.5 现象:反复调参数,课设报告里却写不出“为什么这么调”
原因是你可能一直在黑盒式地调参,缺少对参数影响机理的理解。比如你发现增大种群规模后 IGD 下降了,如果你明白这是因为搜索空间的覆盖率提升了,那你在报告里就能写清楚:种群规模本质控制每代采样密度,密度太低时交叉变异产生的子代容易集中在有限区域。如果你发现增大迭代代数后 IGD 还在下降,说明算法还没完全收敛,需要跑更多代才能逼近真实前沿。
更好的办法是画一个参数敏感性热力图:横轴是种群规模或者迭代代数,纵轴是不同问题维度,颜色是 IGD 值。这一张图比你用文字解释一百句都有说服力。参数不在多,学做图就能讲明白。
6. 从课设到竞赛复现:交叉验证、CEC-2021 真实前沿对齐与性能验证
课设交完不是终点。很多人做完了这套题,对 NSGA-II 的认知还是停留在“能跑出图”的层面,实际上离“能复现论文结果”还有一段距离。这段距离可以用三个步骤缩短:第一步,用手工构造的简单问题验证每个模块的正确性;第二步,用多个函数交叉验证算法的整体性能;第三步,用统计手段确认结果可信。这三个步骤做完,你的代码才算真正有了可复现性——这不仅对课设有用,对将来发表论文或做工程落地也有直接帮助。
交叉验证的具体做法是:把种群初始化为几个已知的极值点,比如全 0、全 1、边界值,然后看经过一代选择交叉变异后,目标值是否符合预期方向。一个更直接的做法是把交叉概率设为 0、变异概率设为 0,跑一代后种群应该原封不动——这个测试能排除很多隐藏 bug。我调试时经常用这三个测试:全零初始化、单点收敛、固定随机种子。如果这三个都过了,再上真实 CEC-2021 函数。
CEC-2021 多目标测试集中的每个问题都有官方给出的真实前沿文件。在做最终比对前,先确认你的目标值方向跟官方一致:CEC 系列绝大多数是求最小化。如果你的优化方向反了,前沿图会完全错位,IGD 指标毫无意义。格式方面,官方数据文件中有些是横着排的,有些是竖着排的,你的true_front矩阵务必跟算法输出的格式保持一致。
性能验证的最后一块拼图是多次运行的统计报告。我常用的做法是固定 5 个不同的随机种子,每个种子跑 20 次,共 100 次运行结果,然后计算 IGD 的均值、中位数和标准差。这种方法能同时验证算法的平均性能和稳定性。如果均值低但标准差很大,说明算法不稳定——对某些幸运的初始化表现很好,对另一些初始化则表现很差;如果均值高但标准差小,说明算法稳定但能力不足,需要调整算子参数。
真正的竞赛复现还涉及一个问题:CEC-2021 的测试函数有时会提供不同维度设置,10 维、30 维、50 维用到的函数特征可能不同。做课设时你可能只跑了 10 维或 30 维,但如果你想升级这个项目,可以尝试在多维度上跑同一组参数,观察算法是否保持竞争力。这种“泛化能力”才是多目标进化算法研究者真正关心的东西。我自己做这类实验时,习惯把所有运行结果保存为 CSV 文件,便于后续统计分析。文件内容包含算法名、问题名、维度、随机种子、代数、IGD、HV 等字段。这套方法建议你直接照做,它能让你的课设报告从“写了一堆字”变成“数据支撑的结论”。
最后提一个习惯性的建议:代码和实验记录一定要分开。把算法实现放一个文件,把实验结果和图片放另一个目录,把你的参数调整记录存在 Excel 或 Markdown 文件里。这样即使过了几个月再回来看这个项目,也能立刻知道当时试了哪些参数、试出来的效果如何。我吃过不记录的亏,翻车以后再也不敢偷懒了。希望这套流程能帮你的课设省下几个通宵,也能让你真正把 NSGA-II 这件事做明白。
本文还有配套的精品资源,点击获取