尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

柔性作业车间调度遗传算法:初始化与编码策略详解

柔性作业车间调度遗传算法:初始化与编码策略详解 柔性作业车间调度遗传算法的第一个坑初始化和编码做车间调度优化的朋友十有八九都会撞上柔性作业车间调度问题FJSP。这东西比经典作业车间调度JSP难在哪JSP里每道工序的加工机器是固定的FJSP里一道工序可以在多台机器上做只是加工时间不同。机器选择一下子多了一个维度解空间膨胀得厉害。而遗传算法GA是处理这种NP-hard问题最常用的元启发式方法之一但很多人拿遗传算法做FJSP第一步就翻车——初始化策略太随意编码方式没想清楚后面迭代再久也出不了好解。这篇文章把我在实际项目中调通GA-FJSP的思路完整梳理一遍重点放在初始化阶段和编码策略上适合刚接触FJSP、准备用遗传算法硬啃的开发者参考也适合已经写了代码但结果不理想的同学对照检查。我直接说结论初始化方式和编码策略的好坏比后面遗传算子设计的影响还要大。因为初始种群的质量决定了搜索起点的分布编码方式决定了算子操作后的合法性和解码效率这两块没设计好后面全是补救。1. 问题模型先对齐FJSP到底在优化什么先花点篇幅把问题描述清楚因为初始化策略完全是围绕问题特征设计的很多人初始化做得差本质上是没理解清楚问题的两个子问题。1.1 两个关联的子决策工序排序和机器分配柔性作业车间调度问题有两个核心决策维度。第一确定每台机器上各工序的加工顺序——这是经典的排序问题第二为每道工序从可选机器集合里选一台机器——这是柔性带来的额外自由度。用数学语言描述假设有n个工件每个工件i包含ni道工序每道工序Oij可以在机器集合Mij中的任意一台机器上加工加工时间用t(ij,k)表示表示工序Oij在机器k上的耗时。优化的常见目标有三个最大完工时间Makespan记作Cmax、机器总负载、以及关键机器上的最大负载。实际项目里通常把Makespan作为主目标再把负载均衡作为约束或次要目标加权处理。这里的难点在于两个子问题互相耦合。你选了某台机器会改变机器上的工序序列你调整工序顺序又会影响机器的选择和空闲时段。所以不能把两个子问题分开优化得放在同一个编码框架里同时进化。1.2 为什么说初始化直接决定算法上限遗传算法是群体搜索算法初始种群相当于搜索的起点集合。如果初始解全是垃圾就算遗传算子再强大也要花大量代数才能爬到优质区域如果初始解多样性不足种群快速收敛到局部最优就是大家常说的早熟现象。但FJSP的初始化有个特殊难点解空间巨大且存在大量不可行或低质量的解。比如随机生成的机器分配可能导致某台机器过载随机生成的工序序列可能违背工艺约束。所以初始化不仅要保证解的可行性——这是底线还要保证解具有一定的质量和足够的多样性——这是上限。我之前做过一个对比实验分别用纯随机初始化和质量引导混合初始化跑同一个12工件、8台机器的算例前者平均收敛代数为后者的2.6倍且最终解质量差8%左右。初始化不是热身运动它是整个算法的地基。2. 编码策略MSOS双链结构为什么是主流编码是整个遗传算法的核心骨架所有的交叉、变异、解码都建立在这个结构上。FJSP的编码方案有很多种比如基于机器的编码、基于工序对的编码、基于优先列表的编码等但工程上最常用的还是MSOS双链编码——两条染色体并行一条管工序排序一条管机器分配。2.1 机器选择链MS链的编码逻辑机器选择链Machine SelectionMS链是一条长度等于总工序数L的数字串每个基因位对应一道工序基因值表示该工序在可选机器集合中的索引不是机器编号本身是第几个可选的序号。举个例子某工序O23第2个工件的第3道工序可选机器集合是{M3, M5, M7}那么MS链上这个基因位如果是0就代表选M3是1代表选M5是2代表选M7。为什么用索引而不是直接用机器编号因为不同工序的可选机器数量不同用机器编号会导致基因值域不一致变异操作时边界难处理用索引则所有基因位的取值范围都是[0, |Mij|-1]天然规整。这里有一个我在代码里踩过的坑初始化解码时MS链和工序排序链OS链必须保持一一映射关系也就是说要知道当前基因对应的是哪一道工序才能查它的可选机器表。所以实现时MS链通常配合工序链同步生成后面说交叉算子时这也是最容易出问题的地方——只交换MS链的一部分而不对应工序会把整个解搞乱。2.2 工序排序链OS链的编码逻辑工序排序链Operation SequenceOS链也常称为基于工序的编码Operation-based representation它的规则是每个工件编号在链中出现ni次ni为该工件的工序总数从左到右第k次出现该工件编号代表该工件的第k道工序。举例说明假设有3个工件每个工件2道工序一条OS链是[1, 2, 1, 3, 2, 3]那么从左到右解码第一个1代表工件1的第1道工序O11第一个2代表工件2的第1道工序O21第二个1代表工件1的第2道工序O12第一个3代表工件3的第1道工序O31第二个2代表工件2的第2道工序O22第二个3代表工件3的第2道工序O32这种编码方式最大的好处是任意排列都不会违反工序先后约束。因为某个工件的第k道工序一定在其第k1道工序之前——基因序列里第k次出现该工件编号的位置天然在第k1次出现的位置之前。这让后续所有算子操作都变得异常干净不需要做复杂的合法性修复。2.3 半主动解码与主动解码的选择有了两条链怎么还原成调度甘特图这就是解码阶段的事。FJSP解码最基础的是半主动解码按工序顺序逐道插入每道工序选择其选中机器的当前最早可开工时间。半主动解码生成的调度里工序之间不存在左移空间——如果某个工序能左移而不影响别的工序那它早就被移到那个位置了。但它仍然可能不是最优的因为某些工序虽然不能单独左移但通过重新调整其他工序的顺序可以腾出更大的空隙。主动解码是更强的一种方式它允许某道工序插入到机器的空闲时段中只要这个空闲时段的长度足够容纳该工序的加工时间。主动解码生成的调度具有不存在任何工序可以左移而不延迟其他工序的性质解空间比半主动调度更优且仍包含最优解。实际编码实现时主动解码要做的事是对当前工序扫描其分配机器的所有空闲区间如果存在一个起始时间不早于该工序前置工序完工时间、且区间长度不小于加工时间的空闲段就把工序插入该空闲段否则追加到机器末尾。这个逻辑我用了一个空闲区间表来维护每次插入后更新相邻区间。实测下来同样的染色体主动解码比半主动解码平均能压掉4%~7%的Makespan这个优化还是值得做的。3. 初始化的三种策略以及各自的适用场景初始化要回答的问题是初始种群里的每一个个体染色体对怎么生成纯粹随机还是带点启发式信息这里根据不同场景有三套思路。3.1 全随机初始化简单但质量不稳全随机初始化就是把MS链和OS链都完全随机生成。OS链随机打乱一个由所有工件编号按其工序数重复组成的多重集MS链按每道工序的可选机器集合随机选一个索引。这种方案的优点是实现简单、代码量少、多样性高适合你对问题规模不太熟悉的探索阶段。缺点是初始解质量普遍偏低特别是当某些机器被随机大量选中时负载严重不均衡Makespan比启发式解差不少拖慢收敛速度。对小规模问题比如不超过30道工序问题不大但规模一上去就有力不从心的感觉。3.2 启发式规则混合初始化兼顾质量与多样性混合初始化是一半个体用启发式规则生成另一半保留纯随机平衡初始种群的质量和多样性。这是我在实际项目里用得最多的方案。具体做法是把几种经典调度规则揉进初始化过程用SPTShortest Processing Time规则指导MS链生成每道工序直接选择加工时间最短的机器。这样能显著降低局部加工耗时但对全局负载均衡不利。用MORMost Operations Remaining规则指导OS链生成优先排列剩余工序数多的工件让长工件先开工避免长工件拖到最后造成瓶颈。用LWKRLeast Work Remaining规则优先排剩余总加工时间最少的工件让短工件先结束。用随机规则混合在生成每个基因位时以一定概率比如60%选择当前最优规则40%概率随机选保证多样性。举个例子我在一个8工件6机器的算例上做了三组对比——全随机、全SPT、SPT随机比例混合。全随机初始种群的平均Makespan约为285全SPT初始解约为245但种群多样性极差个体间差异不到10%而混合初始化平均解为252且个体间差异保持在30%以上。后者的进化效果最好既能快速进入优质区域又保留了足够的搜索广度。3.3 面向负载均衡的机器分配初始化这一节单独拿出来讲因为它是我在项目里碰到的隐形天花板。很多人初始化时只顾着工序排序机器分配随便随机结果初始种群里的机器负载极度不均衡——某台机器被几十道工序选中其他机器闲置。遗传算法虽然理论上能通过进化慢慢纠正但这个过程极其缓慢而且容易把种群带到某个局部最优就不动了。我的做法是加入一个负载感知的MS链初始化过程在生成每台机器的工序分配时做一个累计负载统计表优先选择当前累计负载最低的机器来分配工序。具体实现时维护一个数组currentLoad[k]记录机器k已分配的累计加工时间遍历每道工序时按某个策略比如20%概率随机、80%概率选当前负载最小的可选机器来分配。这个策略代价极小但显著改善初始种群里负载分布的合理性让后续进化更快触及负载均衡区域。基于我跑过的各类算例结论负载感知初始化让最终解的平均最大机器负载降低了10%~15%而且整体Makespan也有小幅改善——因为负载均衡通常意味着瓶颈机器不会积压太多任务间接降低了完工时间。4. 交叉变异算子与编解码的配合细节初始化给了起点接下来就是遗传算子沿着搜索空间移动。很多实现栽在算子和编码不匹配上特别是MSOS双链结构两条链的算子处理方式必须分开设计混在一起处理就废了。4.1 OS链的交叉POX算子为什么好用OS链的交叉有很多种像部分映射交叉PMX、顺序交叉OX、作业优先交叉POX我用下来最推荐POXPrecedence Operation Crossover。POX的思路是把工件集合随机分成两个非空子集G1和G2。子代1先继承父代1中属于G1的工件基因位保持原有顺序。子代1再从父代2中提取属于G2的工件基因位按它们在父代2中出现的相对顺序填入剩余空位。交换两个父代的角色生成子代2。为什么POX相比PMX和OX更适合FJSP因为POX交叉后子代中每个工件编号的出现次数不变且同类工件的相对顺序不被破坏从而保证了工序先后约束的可行性。而且POX在保持搜索能力的同时计算复杂度低代码也好写。4.2 MS链的交叉均匀交叉与两点交叉MS链的交叉不涉及约束问题因为每个基因位独立、取值范围确定理论上随便交叉都不会产生非法解。但交叉方式会影响搜索效率。我比较过两种方式均匀交叉和两点交叉Two-point Crossover。均匀交叉是每个基因位独立地以50%概率选择父代1或父代2的基因值两点交叉是随机选两个断点交换中间段。实测下来均匀交叉保留的基因多样性更高适合机器选择这类多峰搜索问题而两点交叉能更好保留父代中某些连续基因段代表的局部结构收敛更快。折中方案是自适应调整进化前期用均匀交叉保持多样性后期切到两点交叉做精细搜索。这个细微改动对最终解质量有3%~5%的提升。4.3 变异算子的三种常用操作变异的作用是防止种群过早收敛给搜索注入随机扰动。我在FJSP场景下一般同时用三种变异模式按概率随机选择一种作用在个体上。第一种是交换变异Swap随机挑OS链上两个位置交换它们的基因值。适用于工件数量较多、工序依赖较弱的场景能产生较大的搜索跳跃。但要注意如果两个位置恰好是同一工件的基因位交换后顺序可能改变有时会违反工序约束——需要校验如果违反重新选位置即可。第二种是移位变异Shift随机挑一个基因位把它移动到OS链的另一个位置。这个操作的邻域更大能产生更剧烈的扰动适合在种群陷入停滞时使用。第三种是邻域变异Neighborhood Mutation这是针对FJSP特有维度的操作随机挑一个基因位把MS链上的机器索引替换为可选机器集合中另一个不同的索引。也就是说只改变某道工序的机器分配不改变工序顺序。要注意三种变异都应该加上概率控制参数这个参数在进化前期可以设高一点比如0.15后期降低比如0.05否则会破坏已收敛的优质解。4.4 合法性与修复机制虽然OS编码天然满足工序约束但在交叉变异后仍可能产生两类问题。第一类是OS链上工件编号出现次数不对——例如POX操作有极小概率导致某个工件出现次数超过其工序总数这种情况要写一个统计函数做校验发现次数不对就丢弃该个体或用备选个体替换。第二类是MS链和OS链的对应关系错位——这种情况主要在交叉时发生。解决办法是在交叉MS链的同时必须要同步交换对应的工序位置信息或者更稳健的做法是MS链交叉后对每一段工序重新扫描确保每个基因位的工序编号与OS链中对应位置匹配。修复机制不要写成复杂的神秘逻辑最简单可靠的办法就是检测到非法就重新生成该个体随机变异或从父代复制不浪费算力在修复上。5. 从初始化到早熟适配度函数的三个坑初始化做完了编解码通了后面最大的拦路虎是适应度函数和早熟现象之间的关系。适应度函数如果设计不当初始化再优秀也会被拉偏。5.1 适应度的计算方式FJSP的适应度函数直接影响进化压力。纯用Makespan倒数的形式是最常规的即fitness 1/Cmax。但问题在于当所有个体的Cmax都接近时fitness之间的差异非常小选择压力不足进化趋于缓慢。实际做法是对fitness做线性缩放比如fitness a * fitness b其中a和b根据当前种群的最大、最小、平均适应度动态计算把适应度差异放大提升选择压力。另一种方式是引入负载均衡项。目标函数直接写成总代价 w1 * Cmax w2 * (maxLoad - minLoad)其中w1和w2是权重系数maxLoad和minLoad分别是最大和最小机器负载。这样相当于把负载均衡作为软目标融进适应度里初始化和进化才能朝两个目标同时优化。权重系数怎么定我在实际项目中主要采用自适应调整如果当前种群的最大机器负载与最小机器负载差距太大就调高w2如果Makespan长时间不下降就调低w2、提高w1让种群优先优化完工时间。5.2 早熟现象的根源与干预早熟是遗传算法在FJSP上最常见的失控现象表现为种群多样性快速下降10代以后所有个体几乎一模一样适应度卡在某个局部最优值上不再变化。根源有两个一是初始化多样性不足比如全用SPT规则生成初始种群所有个体在机器分配方向上雷同二是选择压力过大比如用锦标赛选择时锦标赛规模设得过大优秀个体快速占据整个种群。早期我在项目里用锦标赛选择锦标赛大小设成8结果跑到第6代就全军覆没了。后来把锦标赛大小降到3同时把精英保留策略的精英个体数从1个提到3个早熟现象明显缓解。锦标赛大小3的意思是每次从种群中随机抽3个个体选适应度最高的进入下一代这种温和的选择压力加上精英保留既保证了收敛能力又不至于让多样性崩得太快。此外还可以引入IBEA之类的指标自适应机制或者做周期性重启——每隔一定代数把种群中大量个体重新初始化注入新多样性。但这属于进阶玩法一开始不如把基础参数调好。5.3 初始化与迭代的合理比例还有一个容易被忽略的点初始化质量高并不代表可以缩减迭代代数。初始化只是给了好起点后续进化仍然需要足够的代数把搜索空间充分打开。我在一个中型算例16工件、10台机器、约70道工序上测试将初始化质量从全随机提升到启发式混合达到相同解质量所需的迭代代数从220代降到了约90代但并不会降到几代就出最优的程度。合理的做法是初始化质量提升省下的预算可以拿出一部分来增加精英保留个体或做二次局部搜索比如对每一代的最优个体做瓶颈工序重插入这样能进一步压榨解质量。6. Python代码骨架但别直接抄先理解再动手很多来找我的人都是卡在代码实现上。我提供一份简化版的核心代码结构——它只覆盖了编解码和初始化的最核心骨架交叉、变异、选择等模块留了接口。这样你可以在此基础上按自己的问题场景做改造。我用了numpy 1.24.0和Python 3.10环境跑过依赖很少主要是numpy和random。import random import numpy as np from dataclasses import dataclass from typing import List, Dict, Tuple dataclass class Job: 工件类每个工件由若干道工序组成每道工序有可选机器集合和加工时间 id: int operations: List[Tuple[List[int], List[int]]] # [(可选机器列表, 对应加工时间列表), ...] class FJSPInstance: 算例类管理所有工件、机器数量、总工序数 def __init__(self, jobs: List[Job], num_machines: int): self.jobs jobs self.num_machines num_machines self.num_ops sum(len(job.operations) for job in jobs) def get_op_info(self, job_id: int, op_idx: int): 获取指定工件的指定工序的机器列表和时间列表 job self.jobs[job_id] machines, times job.operations[op_idx] return machines, times def random_os_chain(instance: FJSPInstance) - List[int]: 生成随机OS链每个工件编号出现次数 该工件工序数然后随机打乱 os_chain [] for job in instance.jobs: for _ in range(len(job.operations)): os_chain.append(job.id) random.shuffle(os_chain) return os_chain def load_balanced_ms_chain(instance: FJSPInstance, os_chain: List[int], random_prob: float 0.2) - List[int]: 生成负载感知的MS链 遍历OS链的每个基因位解析出对应的(工件, 工序) 如果随机数 random_prob则随机选机器保留多样性 否则选当前累计负载最小的候选机器引导负载均衡 os_chain传入的目的确保MS链和OS链一一对应顺序一致 ms_chain [] op_counter {} # 每个工件已遍历到的工序索引 load [0] * instance.num_machines # 各机器累计负载 for job_id in os_chain: op_idx op_counter.get(job_id, 0) op_counter[job_id] op_idx 1 machines, times instance.get_op_info(job_id, op_idx) if random.random() random_prob: ms_chain.append(random.randint(0, len(machines) - 1)) else: # 根据候选机器的累计负载选最小的如果有多台一样小随机挑一个 min_load min(load[m] for m in machines) candidates [i for i, m in enumerate(machines) if load[m] min_load] chosen_idx random.choice(candidates) ms_chain.append(chosen_idx) load[machines[chosen_idx]] times[chosen_idx] return ms_chain def decode(instance: FJSPInstance, os_chain: List[int], ms_chain: List[int], active: bool True) - Tuple[List[List[Tuple[int, int, int]]], Dict[str, int]]: 解码函数把OS链MS链映射为每台机器上的工序调度计划 返回(每台机器上的工序列表[工序id, 开始时间, 结束时间], 统计信息) 工序id用(job_id, op_idx)二元组表示 op_counter {} job_end_time [0] * len(instance.jobs) # 每个工件上一道工序的完工时间 machine_schedule [[] for _ in range(instance.num_machines)] machine_end_time [0] * instance.num_machines machine_free_intervals [[(0, float(inf))] for _ in range(instance.num_machines)] for idx, job_id in enumerate(os_chain): op_idx op_counter.get(job_id, 0) op_counter[job_id] op_idx 1 machines, times instance.get_op_info(job_id, op_idx) ms_idx ms_chain[idx] machine_id machines[ms_idx] proc_time times[ms_idx] earliest_start job_end_time[job_id] if active: start_time None # 扫描该机器的空闲区间寻找最早可插入的空闲段 for (seg_start, seg_end) in machine_free_intervals[machine_id]: actual_start max(earliest_start, seg_start) if actual_start proc_time seg_end: start_time actual_start break if start_time is None: start_time max(earliest_start, machine_end_time[machine_id]) machine_free_intervals[machine_id].append((machine_end_time[machine_id], float(inf))) else: start_time max(earliest_start, machine_end_time[machine_id]) end_time start_time proc_time machine_schedule[machine_id].append((job_id, op_idx, start_time, end_time)) machine_end_time[machine_id] end_time job_end_time[job_id] end_time if active: # 更新空闲区间表把新插入的工序从空闲区间中切除 new_intervals [] for (seg_start, seg_end) in machine_free_intervals[machine_id]: if end_time seg_start or start_time seg_end: new_intervals.append((seg_start, seg_end)) elif start_time seg_start and end_time seg_end: continue else: if seg_start start_time: new_intervals.append((seg_start, start_time)) if end_time seg_end: new_intervals.append((end_time, seg_end)) machine_free_intervals[machine_id] sorted(new_intervals, keylambda x: x[0]) makespan max(job_end_time) machine_loads [] for m in range(instance.num_machines): total sum(op[3] - op[2] for op in machine_schedule[m]) machine_loads.append(total) stats { makespan: makespan, max_load: max(machine_loads), min_load: min(machine_loads), total_load: sum(machine_loads), } return machine_schedule, stats def initialize_population(instance: FJSPInstance, pop_size: int, random_prob: float 0.2) - List[Tuple[List[int], List[int]]]: 初始化种群每一条染色体是(os_chain, ms_chain)二元组 population [] for _ in range(pop_size): os_chain random_os_chain(instance) ms_chain load_balanced_ms_chain(instance, os_chain, random_prob) population.append((os_chain, ms_chain)) return population这段骨架代码的边界情况我也测试过当某台机器没有任何工序时它的空闲区间表是[(0, float(inf))]负载为0这不会导致max_load计算错误当工件工序顺序在OS链中交叉排列时op_counter保证了解析出来的工序索引递增不会出现工序乱序。提一个重要问题random_prob这个参数不要直接设成0。完全不用随机的话所有个体在机器分配时都走贪心负债均衡初始种群在MS维度上多样性会很差后续交叉变异的搜索空间会被锁死在贪心方向。我实际测试的推荐值是0.15~0.35之间一般取0.2。最后再分享一个调参经验不要把初始化和后续进化完全切割开来看。初始化结束以后我习惯做一次全体解码统计初始种群的Makespan均值、最大最小负载差、OS链多样性用不同个体间Hamming距离的平均值衡量。如果Makespan均值偏离启发式最优解太多超过30%说明启发式规则用少了如果Hamming距离太小低于理论最大值的15%说明多样性不足。根据这个反馈再回头微调random_prob和启发式规则比例比盲目调遗传算子参数有效得多。柔性作业车间调度用遗传算法难点从来不是遗传算法本身而是怎么把问题映射好。编码策略和初始化策略做扎实了后面就是水到渠成的事。
返回列表