ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

混合流水车间调度HFSSPW:工人约束建模与NSGA-II启发式解码实践

混合流水车间调度HFSSPW:工人约束建模与NSGA-II启发式解码实践 HFSSPW这篇文章我断断续续做了一年多从最初的建模陷阱到最终的Matlab代码框架中间重写了三版解码器。最初我以为难点在进化算法后来才意识到真正拉开差距的是解码策略。今天把完整的求解思路、代码实现的细节和踩坑记录整理出来给正在做混合流水车间调度或者准备投运筹类期刊的朋友一个可复现的参考。1. HFSSPW问题拆解工人约束到底约束了什么混合流水车间调度问题Hybrid Flow Shop Scheduling ProblemHFSS本身已经不新鲜多阶段、每阶段多台并行机的组合爆炸让精确算法基本止步于小规模实例。但加上“工人约束”之后问题性质会发生变化——不是简单增加一个约束条件而是同时解决两个互相耦合的调度子问题。1.1 混合流水车间的“混合”在哪里先统一一下问题描述。经典的HFSS有K个阶段每个阶段有若干台并行机工件按相同路径依次经过所有阶段。所谓“混合”指的是每个阶段的并行机可能是相同的也可能是不相关的。比如阶段1的3台设备是同一型号阶段2有2台设备但加工速度不同这种情况在汽车零部件产线、PCB制造、半导体封装测试里都很常见。HFSSPW加了工人约束后每个操作不仅是“某台机器在某个时间点空闲”就能开工还必须满足“某个工人在该时间点可用、且具备该机器的操作技能”。这会衍生出三类具体约束工人时间窗约束每个工人有上班时间、午休、下班时间甚至可能涉及加班上限机器24小时可用但工人不是。技能矩阵约束不是所有工人都能操作所有机器。常见的是技能等级差异有的工人只能操作某台机器有的可以操作多台能力不同导致加工时间也不同。工人与机器的占用关系一个工人同一时刻只能操作一台机器但这台机器是否必须全程由该工人操作取决于你建模的粒度。我用的模型是全程专属也就是一个操作不换人。这三类约束单独拿一个出来都不算难但组合在一起后调度解的空间结构变得很复杂——工序排序和工人分配互相牵制这也是为什么单纯用多目标进化算法加普通解码器效果不佳的原因。1.2 多目标不是把单目标加权求和这个课题的另一个关键点是多目标。HFSSPW最常见的目标组合是最大完工时间makespan和总加权延误total weighted tardiness有时候还会加上工人的总加班时长或者能耗。需要提醒的是这里不建议用加权求和把多目标变成单目标——我在早期实验里发现两个目标的量纲不同权重极难设定而且跑一次优化只能得到一条Pareto前沿上的一个点你需要跑好几组权重才能近似出前沿形状效率很低。正确做法是采用Pareto支配关系驱动的进化算法比如NSGA-II或者MOEA/D。我在项目里选了NSGA-II框架理由很直接实现成熟、文献对比方便、处理离散决策变量很顺手。MOEA/D也可以但它的分解策略对解码器的扰动更敏感调参成本更高。1.3 为什么叫“工人约束”而不是“人力资源约束”这里顺便澄清一个建模语义。文献里有的是把人当作和机器一样的资源来统一建模那就是广义的人力资源约束调度。但HFSSPW特指工人约束下的混合流水车间调度重点在于工人和机器是两类不同属性的资源——机器不能移动工人可以跨机器作业工人有技能级别机器没有。如果把工人当成机器来建模会丢失“工人可被复用在多台机器之间”这个灵活性也很容易产生合法但实际不可行的排产方案。2. 数学建模与决策变量设计三维离散决策的正确打开方式写代码之前先把数学模型定下来。这个步骤决定了后面的编码方式、解码器逻辑和Matlab数据结构一旦模型不严谨后面全部白做。2.1 形式化描述我用一个五元组 (J, M, W, S, T) 来描述问题工件集合J {1, 2, ..., n}每个工件有交付期 d_j 和权重 w_j。机器集合M {1, 2, ..., m}机器按阶段分组阶段 l 有 m_l 台机器Σ m_l m。工人集合W {1, 2, ..., q}每个工人有可用时间窗 [a_r, b_r]可多段但建模时先按单段处理。技能矩阵 Sw × m 的0-1矩阵S[r][i] 1 表示工人 r 可以操作机器 i。同时引入加工时间依赖即操作时间不仅取决于工件 j 和阶段 l还取决于操作工人 r记作 p_j,l,r。时间轴 T离散化或者连续时间建模都可以。我用的是连续时间的事件驱动建模Matlab里通过维护事件列表实现。2.2 决策变量与染色体编码HFSSPW的决策变量分三层工件在阶段间的加工顺序——这是经典的排列决策。每台机器在某个时刻加工哪个工件的哪个操作——机器分配决策。每个操作由哪个工人来执行——工人分配决策。我把染色体设计成两层上层是工件顺序层用排列编码permutation表示工件在所有阶段的优先顺序下层是工人分配层用整数编码每个位置对应某个阶段某台机器上由哪个工人操作。关于工人分配层具体的编码长度要等于“所有阶段机器数量之和”每位上的取值范围是可操作该机器的工人集合。这个设计的优势会在解码器里体现——它天然保证了技能约束的合法性任何染色体都不会违反技能矩阵。2.3 模型与解码的衔接为什么工序排序和工人分配要分开我一开始把两个决策层合成一个编码也就是每个基因位同时表示工件和工人结果发现进化过程中交叉和变异很容易破坏合法性。比如两个解都合法交叉之后可能出现工人被赋予一个他没技能的机器或者同一工人同一时间被安排了两台机器的操作。分开编码之后合法性维护就简单了工人分配层在生成时就限制在技能集合范围内工件顺序层就是排列任何交叉策略如OX、PMX都能保持合法。至于实际操作过程中是否发生同一工人的时间冲突这不是编码要管的事是解码器要解决的问题。3. 融合启发式解码算法性能的真正分水岭这是整篇文章我想重点展开的部分。很多代码实现会把解码器做成一个“根据染色体顺序硬排”的贪心模拟器但这对于HFSSPW远远不够。原因是给定一个工件顺序和工人分配方案不同的调度策略会产生截然不同的目标值而解码器决定了进化个体适应度评价的准确性。如果解码器质量太差再好的进化搜索也白搭。3.1 从普通解码到左移策略先看一个最基础的解码逻辑按工件顺序逐个安排在最早可用的机器上工人选择取最早可用时间满足技能要求的。这种纯贪心解码的问题是会产生大量不必要的空闲时间。举个例子阶段1有两台机器M1和M2工件A在M1上加工30分钟工件B在M2上加工20分钟。如果M2完成B之后空闲而M1还在加工A但有个工人从M2出来之后可以立即去操作M1上的下一个工件可是机器M1还占着工件C排到M2上又要等到M2空闲。这种串行思维会大大拉长makespan。改进方案是左移重调度策略left-shift rescheduling在安排一个新操作时不简单地把时间放在当前工件顺序所对应机器的最早空闲时刻而是尝试把它插入到该机器上的空闲时间窗内前提是该机器的空闲窗长度足够且工人可行。这个策略在单机调度里很常见但放到带工人时间窗约束的混合流水车间里需要同时检查机器时间和工人时间两个维度。我在Matlab里实现了一个叫insertWithWorker()的函数逻辑分三步遍历目标机器的空闲区间列表找到第一个长度大于等于加工时间的区间。对该区间内可能分配的工人集合检查工人时间窗口在该区间内是否有足够的连续空闲。如果工人窗口不满足则把区间起点后移到工人空闲起始点重新判断。这个左移策略每次可能把一个操作的开工时间提前很多对makespan的改善非常明显。但需要付出的代价是如果对每个操作都做这个检查计算量会上升一个量级所以要配合后面讲的缓存机制来加速。3.2 启发式规则融合从单一规则到规则池单纯左移还不够。不同问题实例下不同的排产规则效果差异很大。我做了规则池把四种启发式规则融合进解码器每次解码时根据个体或种群状态选择其中一种SPT规则最短加工时间优先安排工件时优先选择在当阶段加工时间最短的那个工件。适用于减少在制品库存。ERT规则最早就绪时间优先优先安排当前最早到达对应机器的工件。能有效压缩等待时间。MWKR规则最大剩余工作量优先优先处理剩余总加工时间最长的工件适合均衡负载防止瓶颈阶段滞留。工人可用性优先规则优先安排当前可用工人技能覆盖且其最近可用时间最早的工件。这个规则在工人紧张的场景下特别有效能最大化工人利用率。那“融合”是怎么做到的呢我在解码器里设计了一个规则选择机制不是固定的单一规则而是根据进化阶段自动切换。用种群迭代代数作为控制参数迭代早期也就是第1到第50代左右用最小化最大松弛时间的方式优先用ERT规则中后期种群解越来越收敛改用MWKR规则来细化调度避免局部拥挤。这样设计的原因是早期需要广泛搜索解空间而中后期需要在较好解附近精细挖掘。有读者可能会问为什么不直接随机选规则我试过效果不稳定。随机选择规则导致同一个染色体重解码出来的目标值方差很大严重干扰进化算法中的适应度评价。后来改成根据代数确定性轮换每次评价一个个体时使用规则索引作为染色体的隐性基因绩效稳定很多。这个在NSGA-II框架里被证明是一个非常实用的技巧而且在代码实现上几乎零成本——你只需要在个体结构体里额外加一个字段ruleIndex。3.3 解码器的时间复杂度控制解码器不是一个简单函数它是一个模块。我实现的时候最担心的是复杂度。在没有缓存的情况下对每个操作的插入检查需要遍历机器上的空闲区间列表最坏情况下复杂度接近 O(n × m × L)L是平均空闲区间段数。对于200个工件、5个阶段、20台机器的问题实例一个个体解码可能需要几毫秒种群100个个体、进化100代就是几秒量级其实还能接受但如果是500个工件的大规模实例就会很吃力。我的优化方案是把“每个工件的每个操作在每台机器上的最早可用时间”预计算成一张时间矩阵每次进化中对同一台机器的时间窗复用。说白了就是虽然每次IL位变异会让部分调度失效但大部分机器时间表没有变化没必要重新解码整个问题。具体实现是给每个个体维护一个增量解码标志位如果该个体的某段基因没有变化就复用上一次解码结果。这在连续多代进化里能省下约60%到80%的解码时间让步数可以开更大最终收敛效果更好。3.4 启发式解码与多目标进化如何交互这里有个容易忽略的设计细节启发式解码不是独立于NSGA-II的它会直接影响非支配排序和拥挤距离计算。如果解码器带了规则偏好比如MWKR规则天然会偏好较短的makespan但可能会牺牲总加权延误那么解分布会偏向某一侧。我用的办法是在解码过程中尝试做两个方向的策略适配对规则选择索引执行“多目标感知更新”——如果一个体的解码结果在目标1上表现好则在下一代变异解码时倾向于使用对目标2友好的规则尽量让同一个个体在不同规则下都能在规定时间内完成解码以保持两个目标方向的多样性。在拥挤距离计算时把规则索引当作一个虚拟的辅助目标参与密度评估。这样做不是为了计算距离而是为了让彼此规则不同的解在目标空间即使靠得很近也能在种群中保留下来避免早熟。事实证明这种方式让Pareto前沿的完整性和均匀性都显著提升。具体量化数据在实验部分给出。4. NSGA-II框架的适配从经典两目标到工人约束下的定制化NSGA-II本身很成熟但HFSSPW的问题特征要求对它的算子做定制。这一节分享我实际改动的细节以及为什么这样改。4.1 交叉算子工件顺序层用IPOX工人分配层用多点保留工件顺序层的交叉我选了IPOXImproved Precedence Operation Crossover变体。具体操作是从父代P1中随机选一个工件子集把这个子集完整地保留到子代C1的相应位置剩下的位置按P2中剩余工件的顺序填充。这个操作能最大程度保留父代中的工件相对顺序同时又引入多样性。工人分配层的交叉策略不同。因为每个基因位的值域是独立的不同阶段的工人候选集合不同我采用等位基因随机选择式交叉对每个基因位以50%概率从父代P1取值、50%概率从P2取值。这种简单的方法比均匀交叉效果更好因为各基因位其实不存在若耦合关系强行按连续片段交叉反而容易破坏已经匹配好的工人-机器组合。4.2 变异算子扰动要小但要打破局部极值变异我做两层设计。工件顺序层的变异采用移位变异——随机选一个工件插到另一个随机位置。这个操作保持排列合法性扰动程度可控。工人分配层的变异更关键以一定概率将某个基因位重新随机指派一个拥有该机器技能的工人。注意这里不是完全随机选而是偏向选当前负载最低的工人——我维护每个工人的已分配工时总和选择概率与当前负载的倒数成正比。这种“低负载偏置变异”的思路其实是从遗传算法中的启发式变异heuristic mutation借鉴过来的。它的作用是如果某个工人被过度使用成为瓶颈变异时大概率会把他身上的部分操作转移给其他有技能的工人。这相当于给进化过程加了一个局部优化引擎而且不需要额外调用局部搜索算子成本很低。4.3 适应度评价与Pareto排序的工程实现多目标进化算法的核心循环是——种群初始化、非支配排序、选择、交叉、变异、解码、合并种群、环境选择。其中非支配排序我在Matlab里没有用别人写的工具箱而是自己实现了一个 O(N²logN) 的快速排序版本。具体思路先算每个个体的支配关系矩阵然后分层。为了控制计算量种群大小我控制在100到150之间加上前面提到的增量解码单代计算在1秒到3秒之间。另外注意Matlab的循环慢是出了名的在JIT加速下部分循环已经能接受但涉及支配关系判断这种密集型循环时我建议用向量化分块计算。比如两两比较时用全矩阵运算替代逐条if判断200个个体的比较矩阵计算在一两毫秒内完成如果写循环可能要几百毫秒。5. Matlab代码实现的工程细节Matlab在这个问题上的优势是矩阵操作方便、调试直观、绘图和指标计算集成度高。缺点是大规模循环慢、内存管理不透明。下面是我在实现过程中总结出的工程要点。5.1 数据结构设计用结构体数组而非多维矩阵我踩过一个大坑一开始用三维矩阵存储调度甘特图的信息维度是阶段×机器×时间槽结果问题规模稍微变大内存直接爆了。后来改成结构体数组每个个体一个结构体内部字段包括% 个体结构体定义示例 pop(i).chromOrder randperm(n); % 工件顺序层染色体 pop(i).chromWorker randi([1, q], 1, m_total); % 工人分配层染色体 pop(i).ruleIndex 1; % 解码规则索引 pop(i).makespan inf; % 目标1最大完工时间 pop(i).tardiness inf; % 目标2总加权延误 pop(i).machineSchedule {}; % 解码后的机器甘特信息 pop(i).workerSchedule {}; % 解码后的工人甘特信息之所以不用稀疏矩阵或元胞数组是因为结构体数组在Matlab里对字段访问有很好的缓存优化而且后续做非支配排序、绘图都方便。5.2 事件驱动的时间推进机制解码器里的核心是事件驱动模拟维护两个事件列表。一是机器事件表每台机器记录当前已排操作的时间段列表二是工人事件表每个工人记录自己的忙碌时间段列表。每次安排一个操作逻辑如下% 伪码安排操作 j工件j阶段l机器i工人r % 1. 在机器i的空闲区间中找一个能容纳p_j,l,r的空窗 % 2. 在该空窗内检查工人r的可用性 % 3. 如果工人r不满足则尝试下一个候选工人 % 4. 如果所有候选工人都不行则选择最早可用的时间和工人这里有个关键点不要在机器空闲区间找到后立刻确定时间要先查询工人可用性再定夺。因为机器的时间窗口和工人的时间窗口交叉部分才真正可行。我写了个findFeasibleStart(machineWindow, workerWindow, duration)的工具函数来处理这个交集问题。这个函数在所有解码操作中被调用了成千上万次它的效率直接决定了整个算法的速度。我优化到了纯向量化操作用Matlab的逻辑索引快速定位窗口实测比循环快一个数量级。5.3 缓存与增量解码的具体实现前面提到了增量解码这里给出具体实现思路。我用了两步缓存第一层是全局最优缓存用一个容器map存储特征相似的染色体对应的解码结果Map的键是染色体序列的哈希值注意Matlab自带函数string2hash或者用DataHash工具包第二层是个体级备份每个个体在解码前先比对自身的基因是否与上次解码时一致如果一致就直接载入上次结果。这个机制在种群进化到后期尤其有效。后期大部分个体的基因被保选到下一代时没有发生变化但不巧的是NSGA-II的交叉变异会频繁操作它们。实测在使用最优缓存之后进化300代的总时间从47分钟降到了15分钟以内而最终解的HV值几乎没有损失。这个实验结果让我认识到Matlab做调度问题不能“傻算”缓存和增量计算带来的收益非常可观。5.4 结果可视化甘特图与工人负载图做研究发论文结果可视化是很重要的一环。Matlab自带的ganttChart类在新版本里比较好用但我用了自己写的绘图脚本逻辑不复杂对每台机器画一条时间轴用矩形块表示不同工件的加工区间颜色区分工件编号另外画一张工人时间轴图直观展示工人工作与空闲分布。这里有一个值得分享的点两张图放在一起对比才能发现瓶颈。有的调度方案机器利用率很高但工人负载严重不均匀——某个工人几乎全程满负荷另一个工人只用了20%的时间。这种方案在pareto前沿上可能只是中间解但实际生产很难落地。所以我自己在绘图脚本里加了“工人负载均衡度”这个辅助指标通过颜色深浅标注。6. 实验设计与性能验证调参和对比的完整链路代码能跑通只是第一步投论文或者做项目验收要拿出有说服力的实验结果。这一节讲实验怎么设计、指标怎么算、参数怎么标定。6.1 测试算例的生成公共算例方面HFSS领域最常用的是Taillard基准集但那些实例没有带工人约束。我参考了最近几年文献的思路在Taillard基础上生成带工人约束的扩展版本。具体做法从Taillard实例中提取阶段数、机器数、加工时间再随机生成技能矩阵和工人时间窗。技能矩阵的密度我控制在0.3到0.7之间太稀会导致大量工序无人可做太密会退化回普通HFSS。工人数量设为机器总数的60%到80%。一开始我设为50%结果发现很多实例的解被约束得太狠makespan飙到原始问题的2.5倍以上进化算法很难收敛。后来调整到70%左右问题难度适中Pareto前沿的分布也更有区分度。6.2 性能指标计算HV与IGD多目标优化的评价指标我用的是最常见的两个——HVHypervolume和IGDInverted Generational Distance。HV的前沿参考点和IGD的真实前沿都需要设定基准。HV参考点的选择很关键直接用区间的np-hard下界或者经验最大值。我在实验里取两个目标各自在纯单目标优化下得到最大值的1.2倍作为参考点这样计算出来的HV在0到1之间方便对比。IGD需要真实前沿。在没有标定前沿的情况下我用“所有算法所有重复实验跑出的非支配解集的并集”近似。注意这个做法有潜在偏向因为如果你的算法搜索能力弱可能根本摸不到真实前沿。所以我会额外做一个增强实验把种群翻倍、代数翻三倍跑一次全覆盖搜索用那次结果作为近似真实前沿。6.3 自身算法的参数敏感性分析参数敏感性分析是Matlab代码实现完之后的重要一步我做了三组对比种群大小50、100、200。结果显示100是最佳平衡点。50时早熟明显200时耗时翻倍但解质量只提升4%到6%性价比不高。交叉概率0.7、0.8、0.9。在0.9时结果最好这和染色体分段编码的特性有关——交叉概率高意味着更多来自父代片段的组合尝试而因为编码合法性永远被保证交叉不会产生坏解的惩罚所以放心开高。变异概率0.1、0.2、0.3。我采用自适应变异概率代数越大变异率越低。自适应方案比固定0.2的方案HV提升了2.8%并且终期解的分布更均匀。6.4 与现有方法的对比结果我在中等规模实例50个工件、5个阶段、20台机器上对比了三种方法带融合启发式解码的NSGA-II本文方案、普通贪心解码的NSGA-II、以及模拟退火版的SPEA2。结果显示融合启发式解码的NSGA-II在IGD指标上比贪心版本低约22%在HV指标上高约15%。需要说明的是这篇文章开头说的基于融合启发式解码确实不是花架子——它把进化搜索引导到了更有效的区域尤其对带工人约束的解空间启发式规则起到了非常强的局部搜索作用。在工人数为20、机器数为26的中等场景下我的算法求得的Pareto前沿达到28个非支配解其中15个解具有纳什均衡意义下的权衡性质可直接用于日常排产决策。7. 踩过的坑与Matlab实现警示最后一部分我把这一年多踩过的坑集中说一遍给后面做HFSSPW或者类似调度问题的读者省些时间。7.1 工人分配层染色体初始化的合法性问题这个坑很隐蔽。如果你生成初始种群时用randi随机生成工人编号哪怕加了技能约束解码器也可能失败。原因是工人分配层是把“某台机器由哪个工人操作”定了下来但一个操作可能同时有多个工人都能做随机指派效率很低。更麻烦的是有些机器在某个时间段只有某个工人可用而该工人又被其他操作占满就产生了隐性死锁。解决办法是初始化工人分配层时不是用均匀随机而是用一个“低负载优先”的概率分布。具体来说对每台机器的候选工人集合按他们当前累计工时从小到大排序用指数分布概率选择。这样初始种群的合法性接近100%解码器几乎不会陷入死锁。我测试过纯随机初始化和低负载偏置初始化的对比后者的初始前沿HV提升了约18%而且进化早期收敛速度明显更快。7.2 解码器中的浮点时间误差连续时间建模最怕浮点精度。我有一次跑实验发现同样的输入解码两次结果居然差了1e-10秒级的时间。这个问题在看甘特图时无感但会导致Pareto排序时认为两个相同的解一个支配另一个产生假非支配关系。这个bug排查了整整一天。修复方案是做一个时间对齐函数roundTime()所有时间变量在存储前统一四舍五入到1e-6精度。因为实际调度时间下限是秒级这种精度完全够用还能避免深层比较错误。7.3 Matlzb内存管理与版本兼容这个问题可能比较小众但重要。如果你的Matlab版本比较新结构体数组的字段访问速度在某些老代码里会退化很严重尤其用for循环反复访问。我的建议是如果个体数量不大200以内把关键字段拆成单独的数值矩阵比如用popOrder(n, N)这样的矩阵保存整个种群的顺序染色体而不是用结构体数组。这样能充分利用Matlab的矩阵运算优势程序可读性降低一些但速度提升明显。另外强烈建议所有函数先写完再批量向量化不要一开始就写矩阵操作否则调试起来非常痛苦。我当时的做法是先写清晰的for循环版本跑通正确性之后再改成向量化两者结果对比过完全一致才切换。7.4 关于仿真时间的经验值最后给一个仅供参考的经验数据。在配置为Intel i5-12600K、32GB内存的机器上Matlab R2023b环境下50工件、5阶段、20机器的实例种群100、进化300代设置好缓存和增量解码单次运行大约12到18分钟。如果这个量级你发现需要跑一晚上首先怀疑你是不是没开缓存、或者解码器里有死循环。解码器的关键词搜索值得检查工人和机器都不可行时是否及时触发重排逻辑。这个项目做到最后我最大的体会是调度优化问题里进化算法框架只占三成功劳编码设计和解码策略占七成。融合启发式解码看起来只是一个小小的改进却把搜索空间剪枝得非常漂亮。如果你要在这个方向上继续深挖下一步可以考虑把解码器里的规则选择也做成自适应学习机制比如用在线学习调整规则权重或者引入强化学习来动态切换启发式策略。这些扩展在Matlab里都有实现空间而且和现有代码框架的耦合度很低。祝各位顺利。
返回列表