ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

NSGA-II与插板式编码:多目标调度优化建模与实现

NSGA-II与插板式编码:多目标调度优化建模与实现 简介武汉理工大学2020年数学建模暑期培训课题成果围绕基于NSGA-II算法与插板式编码的多目标优化调度模型展开。压缩包内含完整论文与可运行实现代码整体约12.33MB适用对象包括数学建模竞赛参赛者、算法学习者以及生产调度或物流优化方向的毕业设计学生。论文从NSGA-II的非支配排序、拥挤距离、精英保留策略等核心机制入手结合插板式编码对任务顺序与时间窗口约束的结构化表达构建了可同时优化完工时间、资源消耗和负载均衡的调度模型。代码部分便于复现实验并验证算法性能有助于读者掌握多目标进化算法的建模与编码设计思路。目前已有72人学习下载对深入研究智能优化方法在调度问题中的应用具有切实的参考价值。1. 插板式编码的多目标优化调度模型解的是排产问题做生产调度或数学建模排产题时最常见的错误是先排一条任务序列再把任务逐个塞给当前空闲的机器。这种做法是贪心不是调度。真正要把“多台机器、多个任务、两个互相冲突的指标”同时做好得先把解空间定义清楚再用多目标进化算法去搜。这个标题里的 NSGA-II 负责搜索插板式编码负责把调度方案编码成一条长度固定的染色体两者合起来就是一套从建模到代码都自洽的多目标优化调度模型。下面的内容按“编码 → 算法 → 目标函数 → 验证”推进给出可以直接抄进数模论文的方法和实现细节。2. 插板式编码用一条任务排列加 m-1 个插板表示调度方案2.1 为什么多机调度不用排列编码而用插板以 8 个任务 3 台机器为例。最直观的编码是一条任务排列比如[2,5,1,6,3,8,4,7]然后规定前 3 个给机器 1中间 3 个给机器 2后 2 个给机器 3。这种写法的缺陷在于任务顺序一变机器的负载分配被迫跟着变搜索过程中难以单独调整“哪些任务归哪台机器”这个维度。插板式编码把这个维度拆开染色体由两个基因段组成长度 n 的任务序列段长度 m-1 的插板位置段。例如seq[2,5,1,6,3,8,4,7]cuts[3,6]解码结果是机器 1 拿[2,5,1]机器 2 拿[6,3,8]机器 3 拿[4,7]。和矩阵式编码相比插板式编码的优势是长度固定不随机器上任务数量动态变化交叉变异时分别处理序列段和插板段不容易出现任务重复或缺失的非法解。数模论文里这套编码也叫隔板法本质就是用 m-1 个分割点把一条 n 长排列切成 m 段。在后续的 NSGA-II 迭代中解码越简单评估速度越快这也是选这套编码的实际收益。2.2 解码从染色体到完工时间和总负载的最小实现下面是最小可用的解码函数。染色体用元组(seq, cuts)表示p_time是每个任务的加工时间列表任务编号从 0 开始。def decode(chrom, p_time): seq, cuts chrom[0], sorted(chrom[1]) n_machines len(cuts) 1 load [0.0] * n_machines start 0 for m, cut in enumerate(cuts [len(seq)]): for job in seq[start:cut]: load[m] p_time[job] start cut makespan max(load) total_load sum(load) return makespan, total_load逻辑说明cuts [len(seq)]把最后一个插板补成序列末尾每个(start, cut)区间内的任务分配给第 m 台机器load[m]累加该机器的加工时间。这里假设是并行机场景各机器同时开工、任务之间没有先后依赖所以最大完工时间makespan直接取max(load)。如果有工序顺序约束即有序流水车间解码需要改成逐台机器累加上游完成时间的矩阵形式不能直接max(load)这是最常见的误用点。参数方面p_time长度必须等于任务数cuts的元素必须在 1 到 n-1 之间。2.3 插板式编码的约束边界和修复策略插板式编码的合法性约束不算多但每个边界都会在实际运行中暴露问题。下面这张表总结了生成染色体、交叉变异后必须处理的规则。约束项合法条件越界/冲突处理插板严格递增cuts[i] cuts[i1]生成时排序去重变异后调用修复函数插板下界第一个插板 ≥ 1若为 0 则第一台机器空载允许空载时保留插板上界最后一个插板 ≤ n-1若等于 n 则最后一台机器空载看题目是否允许插板数量固定len(cuts) m-1数量不足时从合法位置随机补足实际处理时我一般写一个repair_cuts函数把去重、排序、补位一次做完交叉变异之后统一调用。注意一点空载机器在调度领域是合法状态但它会让目标函数出现一个为 0 的负载值如果题目明确要求每台机器都必须开工就需要把空载染色体判为非法并在选择阶段剔除。解码函数的输出是 NSGA-II 里支配关系比较和拥挤度计算的基础所以解码结果必须保证数值稳定不要在解码层面对目标值做归一化归一化放到指标计算阶段。def repair_cuts(cuts, n_jobs, needed): cuts sorted(set(cuts)) while len(cuts) needed: pos random.randint(1, n_jobs - 1) if pos not in cuts: cuts.append(pos) cuts.sort() return cuts[:needed]修复函数的参数n_jobs是任务总数needed是需要的插板数量。random.randint(1, n_jobs - 1)保证插板位置不落在 0 或 n 上避免出现空载机器。这个函数在第 3 章的交叉和变异算子中会被反复调用属于整套代码的基础工具。3. NSGA-II 算法实现非支配排序、拥挤度距离与编码算子3.1 为什么调度问题选 NSGA-II 而不是加权求和很多数模队伍拿到双目标问题第一反应是给两个目标各乘一个权重合并成单目标后用粒子群或模拟退火去搜。这种做法的问题在于权重怎么定、定完是否稳定而且加权法在帕累托前沿非凸时找不到某些折中解。在 3 台机器 10 个任务这种规模下加权法或许还能勉强用一旦任务数到 30 以上解空间爆炸单目标法一次只能给出一条曲线上的点重跑多次也不一定能覆盖完整前沿。NSGA-II 保留一整组互相不支配的解通过快速非支配排序把解分为不同层级再用拥挤度距离维持解的均匀分布。它不需要提前知道权重也不需要把多目标强行合并这正是调度场景需要的特性。快速非支配排序的思路是对种群中每个个体统计被谁支配、支配了谁逐层剥离出 pareto 前沿。拥挤度距离则是在同一前沿内计算每个解在目标空间中的稀疏程度边界个体给无穷大拥挤度保证前沿两端不会被丢掉。两个机制一个是收敛性保障一个是多样性保障。3.2 快速非支配排序与拥挤度距离的 Python 实现以下代码假设所有目标都是最小化。如果某个目标需要最大化在评估阶段取负再进入排序。def dominates(a, b): # a 支配 b所有目标不差且至少一个更优 return all(x y for x, y in zip(a, b)) and any(x y for x, y in zip(a, b)) def fast_non_dominated_sort(fitness): # fitness: list of [obj1, obj2] n len(fitness) S [[] for _ in range(n)] n_dom [0] * n fronts [[]] for i in range(n): for j in range(n): if dominates(fitness[i], fitness[j]): S[i].append(j) elif dominates(fitness[j], fitness[i]): n_dom[i] 1 if n_dom[i] 0: fronts[0].append(i) k 0 while fronts[k]: nxt [] for i in fronts[k]: for j in S[i]: n_dom[j] - 1 if n_dom[j] 0: nxt.append(j) k 1 fronts.append(nxt) return fronts[:-1]说明S[i]记录个体 i 支配的所有个体n_dom[i]记录支配个体 i 的数量。第一层是所有不被任何人支配的个体之后每剥离一层被支配计数减一减到 0 就进入下一层。外层双重循环是 O(N^2)对种群几百的场景完全够用不要在这个规模上去追求 Jmetal 里的索引优化版本那只会增加代码理解成本。之后是拥挤度距离def crowding_distance(fitness, front): dist [0.0] * len(fitness) m len(fitness[0]) for obj in range(m): idx sorted(front, keylambda i: fitness[i][obj]) dist[idx[0]] dist[idx[-1]] float(inf) for k in range(1, len(idx) - 1): dist[idx[k]] (fitness[idx[k1]][obj] - fitness[idx[k-1]][obj]) / (fitness[idx[-1]][obj] - fitness[idx[0]][obj] 1e-9) return dist距离计算按目标逐维做先对当前目标排序边界个体直接置无穷大中间个体用相邻两个个体在该目标上的差值除以整个前沿的极差累加所有目标的距离。分母加1e-9防止目标值全部相同导致除零。注意这里的 dist 数组长度是种群大小索引对应原种群下标传入的 front 是下标列表这样调用方可以直接用下标索引。3.3 NSGA-II 主循环基于支配关系的锦标赛选择主循环用三个基础模块组装锦标赛选择、交叉变异、精英保留。锦标赛选择只做两两比较不需要维护全局分层信息简化版实现如下。def nsga2_main(pop, fits, pop_size, max_gen200, pc0.95, pm0.1): for _ in range(max_gen): offspring [] while len(offspring) pop_size: a, b random.sample(range(pop_size), 2) p a if dominates(fits[a], fits[b]) else (b if dominates(fits[b], fits[a]) else random.choice([a, b])) c, d random.sample(range(pop_size), 2) q c if dominates(fits[c], fits[d]) else (d if dominates(fits[d], fits[c]) else random.choice([c, d])) s1, s2 crossover(pop[p], pop[q], pc) s1, s2 mutate(s1, pm), mutate(s2, pm) offspring [s1, s2] offspring offspring[:pop_size] ofits [evaluate(x) for x in offspring] union_pop, union_fits pop offspring, fits ofits fronts fast_non_dominated_sort([list(f) for f in union_fits]) new_pop, new_fits [], [] for fr in fronts: if len(new_pop) len(fr) pop_size: for i in fr: new_pop.append(union_pop[i]) new_fits.append(union_fits[i]) else: dist crowding_distance([list(f) for f in union_fits], fr) order sorted(fr, keylambda i: dist[i], reverseTrue) for i in order[:pop_size - len(new_pop)]: new_pop.append(union_pop[i]) new_fits.append(union_fits[i]) break pop, fits new_pop, new_fits return pop, fits主循环的精英保留机制是标准做法父代和子代合并成 2N 的种群重新分层后按层数从小到大填充下一代。某一层放不下时用该层的拥挤度距离从大到小选择剩余名额。这样收敛性和多样性同时被保留。参数设置方面给出常见区间和调整依据。参数常见取值调整依据种群规模100~300目标数多取大任务规模大取大最大代数200~500看前沿散点图是否连续多代不移动交叉概率0.85~0.95过高破坏优良基因过低搜索缓慢变异概率0.05~0.2早熟收敛时调大前沿在目标空间扎堆时优先调插板段锦标赛规模2二元联赛规模越大选择压力越大越小多样性越好3.4 插板式编码的交叉与变异算子任务序列段是有重复约束的排列不能用单点交叉直接交换片段否则会产生重复任务。常见做法是顺序交叉OX先保留父本的一段连续基因再从另一父本按顺序填入缺失基因。插板段则可以用逐位随机选择加修复来处理。def crossover_ox(seq1, seq2): n len(seq1) a, b sorted(random.sample(range(n), 2)) child [None] * n child[a:b] seq1[a:b] rest [g for g in seq2 if g not in seq1[a:b]] j 0 for i in range(n): if child[i] is None: child[i] rest[j] j 1 return child def crossover_cuts(cuts1, cuts2, n_jobs): child [random.choice([c1, c2]) for c1, c2 in zip(cuts1, cuts2)] return repair_cuts(child, n_jobs, len(cuts1)) def mutate(chrom, pm): seq, cuts list(chrom[0]), list(chrom[1]) n_jobs len(seq) if random.random() pm: i, j random.sample(range(n_jobs), 2) seq[i], seq[j] seq[j], seq[i] if random.random() pm and len(cuts) 0: k random.randrange(len(cuts)) cuts[k] random.randint(1, n_jobs - 1) return (seq, repair_cuts(cuts, n_jobs, len(cuts)))OX 交叉的两个切片下标a, b是从range(n)中随机采样的两个不同位置child[a:b]保留父本 1 的片段其余位置按父本 2 中未出现过的任务顺序填充保证子代仍是合法排列。插板段交叉逐位随机选一个父代的插板值然后交给repair_cuts去重和补位。变异里任务段用交换变异插板段选一个位置重新随机赋值之后再修复。这套算子和前面解码函数组合就是一个能跑的完整遗传算法框架。4. 调度模型与目标函数以最小化最大完工时间和总负载为例4.1 建立调度模型并行机场景的评估函数在数学建模培训的调度题里最常见的设定是 n 个任务、m 台并行机每台机器都能加工任意任务各任务加工时间已知目标是最小化最大完工时间makespan和机器总负载。第一目标反映效率第二目标反映能耗或成本两者天然冲突。下面用一个随机生成的加工时间表组装评估函数。import random n_jobs 10 n_machines 3 p_time [random.uniform(5, 20) for _ in range(n_jobs)] # 若各机器加工不同任务的速度不同p_time 改为 n_jobs x n_machines 矩阵decode 内按机器索引取值 def evaluate(chrom): makespan, total_load decode(chrom, p_time) return [makespan, total_load]这里的evaluate返回 list 而不是 tuple是为了直接兼容前面fast_non_dominated_sort里对fitness[i]的索引操作。如果题目是异速并行机即机器有自己的速度系数p_time要改成二维矩阵解码时第 m 台机器累加p_time[job][m]其余逻辑不变。这个改动很小但论文里需要单独说明模型从同速机到异速机的推广并给出推导后的目标表达式。4.2 三目标扩展加入拖期惩罚后的调度决策双目标够用但很多数模题目会追加交货期约束此时扩展成三目标更合理。设每个任务有交货期 due调度完成后实际完工时间超过 due 的部分计入拖期。定义第三个目标为总拖期时间评估函数随之扩展。def evaluate_tardiness(chrom, p_time, due): makespan, total_load decode(chrom, p_time) seq, cuts chrom[0], sorted(chrom[1]) # 按解码顺序累加每个任务的完工时间统计拖期 cur 0 tardiness 0.0 for m, cut in enumerate(cuts [len(seq)]): for job in seq[cur:cut]: # 并行机各机器独立累计简化处理为单线累计仅用于演示 tardiness max(0, cur p_time[job] - due[job]) cur cut return [makespan, total_load, tardiness]注意这段代码做了简化把拖期计算当成单线累计真实流水线场景需要按每台机器各自的完工时间累计。这里展示的是扩展思路实际使用时把解码循环内的负载累加逻辑复制一份记录每个任务的完成时刻即可。加入第三目标后NSGA-II 的种群规模建议从 100 提到 200拥挤度距离计算的目标维度从 2 变 3代码不需要其他改动。4.3 从最终种群提取帕累托前沿与常用评价指标算法跑完后最终种群第一层的个体就是帕累托近似前沿解集。提取方式直接复用第 3 章的排序函数取fronts[0]对应的个体和适应度值。指标计算对象用途理想值超体积 HV前沿解集与参考点的超体积同时衡量收敛性和分布性越大越好反世代距离 IGD真实前沿到近似前沿的平均距离衡量收敛精度越小越好前沿宽度 Spread近似前沿中相邻解的间距方差衡量分布均匀度越小越好数模论文里通常不强制要求这三个指标最稳妥的交付物是帕累托前沿散点图和对应的调度甘特图。散点图让评阅人一眼看出解的分布甘特图用于验证调度方案可执行。如果指导老师要求量化评价优先算 HV因为它不需要真实前沿只用参考点就能计算实现比 IGD 简单。5. 从论文到可交付代码把 NSGA-II 插板编码模型跑起来5.1 最小可运行版本的模块组织和首次运行建议把代码按逻辑拆成五个函数decode、repair_cuts、fast_non_dominated_sort、crowding_distance、nsga2_main再加交叉变异和评估函数全部放在一个 Python 文件里不要拆包。这个规模的问题不需要工程化拆文件反而增加调试成本。先用下面的命令准备环境。python -m venv .venv source .venv/bin/activate # Windows 使用 .venv\Scripts\activate pip install numpy matplotlib首次运行如果发现种群所有个体都堆在帕累托前沿的一个角落问题几乎都出在目标尺度差异上。比如 makespan 在 80 到 120 之间总负载在 200 到 300 之间拥挤度距离会被总负载主导。此时要在拥挤度计算前对每个目标做归一化把极差缩放到 0 到 1 的区间。5.2 用 matplotlib 画帕累托前沿散点图和甘特图帕累托前沿散点是最低交付标准。算法结束后取出第一前沿个体用绘图代码输出即可。import matplotlib.pyplot as plt front_pts [fits2[i] for i in fronts[0]] plt.scatter([p[0] for p in front_pts], [p[1] for p in front_pts]) plt.xlabel(makespan) plt.ylabel(total load) plt.title(NSGA-II Pareto Front) plt.show()甘特图按机器画水平条形图横轴是时间纵轴是机器编号每个任务一个矩形条。并行机场景下没有上游依赖任务条直接从 0 开始画宽度等于加工时间有序流水车间则需要累加前序完成时间。甘特图的主要用途不是展示最优性而是证明解码结果真的能排产这一步在数模论文里几乎是必放图。5.3 三个排错技巧早停、重初始化、降维验证早停判断用一个简单策略连续 30 代第一前沿的最优 makespan 没有变化直接终止迭代并输出当前种群。这个策略比固定代数省时间且不损害论文里的收敛性描述。插板段变异空间有限容易陷入插板位置分配早熟常见做法是每迭代 50 代随机重初始化 10% 个体的插板段任务序列段保持不动这比单纯调大变异率更有效。最实用的一条调试技巧是先把目标缩减到一个也就是只保留 makespan跑通解码、交叉、变异、选择全链路后再打开总负载目标。这样做的好处在于单目标下可以快速判断算法是否收敛如果单目标都发散问题一定在算子而不在多目标排序部分。最后再用固定随机种子重复 20 次实验取均值和标准差写入论文避免单次运行结果被评阅人质疑。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进