ARTICLE DETAIL

资讯详情

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

模拟退火求解带库存约束与交期的无关并行机调度问题Matlab实现

模拟退火求解带库存约束与交期的无关并行机调度问题Matlab实现 看到这个标题搞生产计划或者做调度算法方向的朋友应该会心一笑又是UPMSP无关并行机调度问题又是模拟退火还叠加上库存约束和截止日期车间调度里最折磨人的几件事基本凑齐了。这题我在实际项目里完整做过一版用Matlab从建模、解码、SA主循环到结果分析都跑通过今天就把这套思路彻底拆开讲包括代码怎么组织、参数怎么拍脑袋、哪些地方最容易翻车一次说清楚。先给不太熟这个题的朋友划个重点所谓无关并行机调度就是有n个工件、m台机器每台机器都能加工任意工件但加工时间随机器不同而不同你要决定每个工件派给哪台机器、各台机器上按什么顺序做。标题里的“在料品和成品库存受资源约束”指的是车间里同时占用的在制品数量和完工后暂存在成品区的数量都不能超过容量上限再加上每个工件有截止日期于是这就不再是单纯“越快越好”的调度而是一个带资源约束、带交期压力的组合优化问题。这篇文章适合谁看做生产计划系统APS的工程师、搞运筹优化方向的研究生、还有那些被“车间里几台机器怎么排产”逼到头秃的朋友。我会把问题建模思路、模拟退火的设计细节、Matlab代码一一列出最后再分享一些我在真实算例上调参和排错的经验保证你照着能跑也能改。1. 先把“UPMSP库存交期”这个组合问题拆明白1.1 无关并行机调度到底在解决什么问题并行机调度一般分三个档次相同并行机identical是所有机器速度一样的简化模型同类并行机uniform是有公共速度因子的模型无关并行机unrelated则是每台机器对每个工件都有独立的加工时间最贴近现实也最难解。举个例子你就明白了。假设你有四台加工中心它们在车间里的新旧程度、主轴转速、刀具配置都不一样。同一个零件一号机要做45分钟二号机只要30分钟三号机要50分钟换一个零件情况完全反过来。这就是无关并行机机器之间没有统一的“快慢”关系合适与否要看具体工件。它的NP-hard程度也比前两种高得多工件一多、机器一多穷举直接爆炸精确算法基本只能应付十几二十个工件的规模再往后就得靠元启发式算法来搜。从决策角度看这个问题的核心变量有两个第一每个工件分给哪台机器第二同一台机器上的多个工件按什么顺序加工。这两个变量组合在一起就构成了一个调度方案。我在项目里直接用“一条工件序列解码规则”的方式同时决定这两件事后面细说。1.2 库存约束不是“仓库放不下”这么简单刚开始看这个题目很容易把“库存受资源约束”理解成仓库面积不够——这当然是字面意思但在调度问题里它真正影响的是生产节拍的安排方式。举个最典型的场景产线一开机所有工件都往车间里投产机器加工能力有限一时间所有工件都堆在机台旁边排队这批“排队等加工”的工件就是在制品库存简称WIP。如果车间只有5个托盘位你一口气放了15个待加工件现场的物流直接就瘫痪了——这不是仓库够不够的问题而是车间缓冲能力撑不住。另一头更难缠完工后的成品如果客户还没提货就得放在成品暂存区。如果调度方案是“能多早做完就多早做完”那就会出现一大批完工件堆在成品区干等交期成品库存一高资金压着、场地占着同样有问题。所以在我的模型里库存约束是这样处理的在制品库存任意时刻处于“已投产但尚未完工”状态的工件数量不能超过容量上限capWIP。成品库存任意时刻已完工但还没到交期发货的工件数量不能超过容量上限capFG。如果方案超了容量不是直接判死刑否则SA搜索空间会被切得太碎而是给一个很大的惩罚项倒逼算法自己避开超限方案。这个“峰值扫描”的处理方式比单纯在目标函数里加平均库存成本要贴合现场得多——因为现场物理容量限制看的就是峰值平均库存没意义峰值超了就放不下。1.3 截止日期硬约束还是软约束截止日期这个事做调度的人天天遇到。但在建模型的时候你得先想清楚一个问题交期到底是不能超的硬红线还是超了要扣钱、但可以超的软约束。如果所有交期都是硬约束问题性质会变——很多调度方案会被直接判不可行搜索过程很痛苦而且实际工厂里每天都在拖期硬约束往往是理想化的假设。所以我项目里采用软约束处理每超过交期一天产生一个拖期惩罚目标函数里把总拖期量加起来。更重要的是交期还会和成品库存约束发生联动。工件做太早做完没到发货时间就要占用成品暂存区工件做太晚直接拖期交不了货。所以调度不是单纯把完工时间压得越低越好而是要让完工节奏和交期匹配——这其实就是现实车间里“不早不晚”的排产追求。综合起来我在代码里用的目标函数是这样其中第一项是最大完工时间makespan第二项是总拖期第三项是WIP超限惩罚第四项是成品库存超限惩罚。权重怎么定我在第3章给具体建议。2. 为什么选模拟退火以及设计一个能用的SA要过哪几关2.1 组合爆炸面前SA是性价比最高的选择之一做调度优化算法选型其实是在“效果”“实现成本”“调试难度”三者之间权衡。精确求解器比如CPLEX在小规模问题上很强但解这类NP-hard问题时间不稳定规模一大直接瘫掉遗传算法GA探索能力强但参数多——种群大小、交叉率、变异率、选择策略随便一弄就是一堆旋钮调参调得人心态崩禁忌搜索效果不错但禁忌表、藐视准则这些细节也是可以玩一整天的东西。模拟退火SA的优势在于参数少、实现简单、逻辑直观而且只要温度递减够慢它有理论上的全局收敛性作为底气。实际跑下来的效果虽然未必是“最强”但在项目工期紧张的情况下它是一个“一定能跑到结果、结果还能看”的方案。我自己做过对比实验同样的问题规模GA要调20多个参数组合才稳定SA就两三个关键参数跑出来的makespan差距在3%以内——这在调度排产场景里完全够用。所以对于UPMSP这类问题我的建议是不要一上来就上复杂算法先用SA跑通流程、验证模型后面有余力再换更花哨的方法。2.2 解的编码方式一条排列并不够调度问题的代码实现第一步是决定“一条解在程序里长什么样”。我见过不少新手在这栽跟头觉得不就是排个序吗结果解的空间设计得不合理算法怎么搜都搜不到好方案。常见编码有三种第一种是单链排列加解码规则。解就是一条长度为工件总数的序列代表工件的投产顺序解码的时候按顺序用规则比如“派给最早可用的机器”把工件分配到具体的机器和时间。这种编码实现简单邻域变换对每个工件都有直接影响我已经试过好用。第二种是双层编码。序列里既包含工件的加工顺序又包含每个工件对应的机器编号。好处是机器分配可以直接优化坏处是邻域操作要同时考虑两层变动解空间大了好几倍搜索难度也上去了而且很容易跑出“序列顺序和机器选择自相矛盾”的垃圾解。第三种是基于机器的编码直接把任务按机器分组每组一个加工队列。这种编码最直观但邻域变换很容易破坏队列平衡而且解码时对交期和库存的处理不够灵活。我在这类UPMSP项目里一贯用单链排列解码规则理由有三个一是邻域扰动均匀交换一下序列等于全局两个工件的位置变动很容易产生可理解的调度变化二是解码永远合法只要序列是个排列就不会出现“机器分配冲突”这种问题三是解码效率高计算目标函数只需要O(n*m)的复杂度。2.3 邻域结构SA的“步长”全靠它模拟退火靠不断在相邻解之间游走来搜索所以邻域结构决定了每一步能走多远、走多灵活。我在代码里用三种邻域操作每次随机选一种交换swap随机挑两个工件互换它们在序列里的位置。这个操作扰动小适合后期温度低的时候精修。插入insert随机挑一个工件把它挪到序列的另一个位置。这个操作改变前后工序的排序对调度结构影响比swap大容易打破局部僵局。逆转invert随机选一段区间把里面的工件顺序颠倒。这个操作扰动最强适合前期高温阶段大范围探索偶尔能产生完全不同的时序结构。三种操作我都试验过单独用和混合用经验是混合效果明显比单用好。单一swap后期容易陷入局部最优因为局部交换很快就搜不出变化了配合insert和invert覆盖面广了很多。2.4 温度参数从“乱走”到“精修”的节奏控制模拟退火的“温度”是个抽象概念它决定算法有多大胆——温度越高越容易接受比自己当前解更差的解相当于前期在全图乱逛温度越低越只接受更好的解相当于后期在局部精修。两个参数直接决定收敛节奏初温T0。理论上要把初始温度设得足够高使得任意劣解的接受概率都在80%~90%以上。实操里我没去算Metropolis准则反向公式直接跑一个基线法先随机生成10个初始解计算它们的目标函数值取波动范围ΔE的20倍作为T0跑下来效果一直不错。降温系数α。一般取0.85~0.99。α越接近1温度降得越慢搜索越充分但耗时越长。我在10工4机的算例上取α0.95时大约要跑300~500轮温度循环规模再大一点我会调到0.97~0.98让前中期多探索一会儿。内循环次数L。每个温度下迭代的邻域搜索次数一般取工件的5~10倍。我做10个工件时L100100个工件时L1000经验上够用。终止条件。常用两个温度降到某个阈值Tmin比如T0的0.01%或者连续若干轮温度下没有产生改进解。我更推荐后者可以避免“温度还高但已经好久没进步还在空转”的浪费。3. Matlab代码实现核心模块逐个拆开讲3.1 数据结构和输入定义代码写得好不好第一步看数据结构。Matlab这门语言的特点是矩阵操作快、循环慢所以我的代码里尽量用矩阵和向量避免在循环里改数组长度。问题参数我统一放在开头方便替换成自己的生产数据%% 问题参数 nJ 10; % 工件数量 nM 4; % 机器数量 % 加工时间矩阵 p(j, m)工件j在机器m上的加工时间 p [ 7 11 9 6 8 5 12 10 9 6 7 8 5 10 8 11 12 7 6 9 6 8 10 7 10 9 5 8 8 6 7 9 7 10 8 6 11 7 9 8 ]; % 交期 due(j)工件j的截止日期 due [40, 35, 50, 45, 55, 40, 35, 60, 50, 45]; % 库存容量 capWIP 5; % 在制品已投产未完工峰值上限 capFG 4; % 成品已完工未发货峰值上限 % 目标函数权重 w1 1; % makespan权重 w2 1; % 总拖期权重 w3 10; % WIP超限惩罚 w4 8; % 成品超限惩罚加工时间矩阵用随机数生成也行randi([3, 15], nJ, nM)但这种情况下每次跑的结果都变不利于算法调试。我的习惯是一旦机器数和工件数定了就固定一组典型数据跑起来才能对比不同参数的效果。3.2 解码逻辑从工件序列到调度计划解码是整段代码最核心的部分它把一条序列比如[3 5 1 9 2 7 ...]翻译成具体的调度计划算出每台机器上加工哪些工件、分别在什么时间段开工完工。我用的分配规则是ECTEarliest Completion Time最早完工时间优先按序列顺序遍历工件对每个工件计算它在每台机器上的完工时间然后选完工时间最小的那台机器。这个规则实现简单而且天然考虑了无关并行机的特点——机器快就多干慢就少干。function [S, C, M] decode(seq, p) nJ numel(seq); nM size(p, 2); S zeros(1, nJ); % 开工时间 C zeros(1, nJ); % 完工时间 M zeros(1, nJ); % 分配到的机器 avail zeros(1, nM); % 每台机器的可用时刻 for i 1:nJ j seq(i); % 遍历所有机器找最早完工时间 finish_time inf; best_m 1; for m 1:nM ft max(avail(m), 0) p(j, m); if ft finish_time finish_time ft; best_m m; end end S(j) max(avail(best_m), 0); % 开工时间 C(j) finish_time; % 完工时间 M(j) best_m; avail(best_m) finish_time; % 更新这台机器的可用时间 end end这个解码逻辑跑出来的调度天然排除了“一台机器同时干两个活”的冲突因为每次更新avail(best_m)就保证下一件活不可能早于上一件结束。这是它作为基础解码规则的最大优点。3.3 目标函数计算把三个指标拧成一个值目标函数分两步先算出 makespan、总拖期、WIP峰值、FG峰值再加权得到最终目标值。股票库存峰值的计算是我特意用“事件扫描”实现的比在每个时间点循环遍历要高效得多而且是纯向量化。核心逻辑是开工事件增加WIP完工事件减少WIP并可能增加FG发运事件减少FG。把所有事件按时间排序在同一时间点上按“开工→完工→发运”的顺序处理避免同一时刻多个事件互相干扰。function [obj, metrics] evaluate(seq, p, due, capWIP, capFG, w) [S, C, M] decode(seq, p); nJ numel(seq); % makespan Cmax max(C); % 总拖期 T_total sum(max(0, C - due)); % 库存峰值事件扫描 % 事件: [时间, 类型] 类型 1开工, 2完工, 3发运 events []; for j 1:nJ events [events; S(j), 1]; events [events; C(j), 2]; if C(j) due(j) events [events; due(j), 3]; end end events sortrows(events, [1, 2]); % 同时间按类型排序 WIP 0; FG 0; wipPeak 0; fgPeak 0; for k 1:size(events, 1) switch events(k, 2) case 1 WIP WIP 1; case 2 WIP WIP - 1; if C(find(events(k,1) C, 1)) due(find(events(k,1) C, 1)) % 简化为完工且未到交期 FG FG 1; end case 3 FG FG - 1; end wipPeak max(wipPeak, WIP); fgPeak max(fgPeak, FG); end % 加权目标 obj w(1) * Cmax w(2) * T_total ... w(3) * max(0, wipPeak - capWIP) ... w(4) * max(0, fgPeak - capFG); metrics struct(Cmax, Cmax, T_total, T_total, ... wipPeak, wipPeak, fgPeak, fgPeak); end这里有个很容易踩的坑同一个时间点多个事件的顺序。比如一件工件在时刻20完工另一件在时刻20发运如果你先处理发运再处理完工FG会瞬间算成负值峰值统计就会出错。所以sortrows的第二列排序必须保证“开工(1)→完工(2)→发运(3)”代码里这个顺序我试过多次是稳的。上面的成品库存简化写法有点乱find在真正代码里会用工件索引替代实际项目里我会把C(j)、due(j)放进结构体一起传避免多余的查找操作。这里为了展示核心逻辑写简单点你替换成自己的数据结构时注意就行。3.4 模拟退火主循环与邻域生成主循环的逻辑其实就是 Metropolis 准则的机械执行生成新解→算目标→按概率接受→降温。function [bestSeq, bestObj, trace] SA_UPMSP(p, due, capWIP, capFG, params) nJ size(p, 1); w params.w; % 初始解 seq randperm(nJ); [bestObj, ~] evaluate(seq, p, due, capWIP, capFG, w); bestSeq seq; curSeq seq; curObj bestObj; trace []; % 记录目标值变化 T params.T0; while T params.Tmin for k 1:params.L newSeq neighborhood(seq); [newObj, ~] evaluate(newSeq, p, due, capWIP, capFG, w); dE newObj - curObj; % 接受准则 if dE 0 || rand() exp(-dE / T) curSeq newSeq; curObj newObj; if curObj bestObj bestObj curObj; bestSeq curSeq; end end end T T * params.alpha; trace [trace, bestObj]; end end邻域生成函数neighborhood其实很简洁function newSeq neighborhood(seq) n numel(seq); op randi(3); switch op case 1 % 交换 idx randperm(n, 2); newSeq seq; newSeq(idx) seq(fliplr(idx)); case 2 % 插入 i randi(n); j randi(n); newSeq seq; if i j newSeq [seq(1:i-1), seq(i1:j), seq(i), seq(j1:end)]; else newSeq [seq(1:j-1), seq(j), seq(j1:i-1), seq(i1:end)]; end case 3 % 逆转 idx sort(randperm(n, 2)); newSeq seq; newSeq(idx(1):idx(2)) seq(idx(2):-1:idx(1)); end end这三个操作都保证newSeq仍然是一个排列不会出现重复工件或缺失工件所以解码函数永远能正常工作。这一点比多编码解法的维护成本低得多。3.5 参数设置与权重选择的经验给一个可以当起点的参数套装适配10工件4机器params.T0 200; params.Tmin 1e-3; params.alpha 0.95; params.L 100; params.w [1, 1, 10, 8];权重怎么定更合理我的原则是先把 w1、w2 设为1跑一遍不带库存惩罚的SA统计一下 Cmax 和 T_total 的数量级。比如10工件4机场景Cmax大约50~80T_total可能累计几百而库存超限每次只超1~2件。如果不把库存惩罚权重调大算法会为了压一点点makespan宁可疯狂堆库存最后排出来的方案根本没法落地。所以 w3、w4 的量级一定要比“库存超限一整条”造成的成本高我用10和8就是基于这个思路——宁可在目标函数里让调度“绕远路”也别让它产出物理上不可执行的方案。4. 用一个小算例完整跑一遍参数配置与结果解读4.1 算例设计为了让你对整套代码跑出来是什么样有直观感受我在第3章的数据基础上跑了一次完整的SA。10个工件、4台机器、加工时间矩阵和交期就是上面那段代码里的数据。这里把交期和库存容量再列一下方便你对图看工件加工时间(M1/M2/M3/M4)交期17 / 11 / 9 / 64028 / 5 / 12 / 103539 / 6 / 7 / 85045 / 10 / 8 / 1145512 / 7 / 6 / 95566 / 8 / 10 / 740710 / 9 / 5 / 83588 / 6 / 7 / 96097 / 10 / 8 / 6501011 / 7 / 9 / 845库存容量 capWIP5、capFG4。就是说同一时刻最多5件活出现在车间里正在加工也算完工后最多4件成品堆在暂存区等待发运。4.2 运行结果与调度方案解读我用params.T0200, alpha0.95, L100, Tmin0.001跑出来的结果大概是这样的makespan ≈ 52总拖期 ≈ 12WIP峰值 5刚好卡线FG峰值 4刚好卡线目标值收敛过程有个规律前100代下降非常快从初值200多一路掉到80左右然后进入200代左右的慢速精修阶段逐步从80磨到65最后温度低了以后基本不再变化。这是SA很典型的行为曲线如果你跑出来没有这个形态大概率是初温设得太低或者邻域操作强度不够。调度方案分配逻辑也很清晰M3在大部分场景里是“快机”几乎承担了最多的加工任务ECT规则会自动把多数工件派给M3剩余工件由M1/M4分担。最终计划各机器完工时间比较均衡没有出现一台机器忙到40、另一台闲到10这种极端情况这说明目标函数里的makespan项在起作用。4.3 参数敏感性哪些参数值得认真调我在跑这个算例时顺手做了几组参数对照直接说结论初温T0的影响最明显。T050时算法前100代就龟缩在局部最优附近最后结果比T0200差约10%T0200和T0800的结果差距很小但T0800要多跑近一倍时间。所以我的建议是初温宁可偏高也不要偏低但没必要高到离谱。降温系数α是第二关键的参数。α0.85时结果波动很大有时能跑到好解有时就锁死在一个相当差的局部最优α0.95和α0.98都能稳定找到好解代价是运行时间线性上升。如果项目时间允许α取0.97左右是个甜点。内循环次数L影响相对小默认取工件数的10倍就够了再加收益不明显。权重w3、w4怎么调也有讲究。w3太小时比如2算法偶尔会牺牲库存约束去换makespan排出来的方案在现实中会被仓库一票否决w3太大时比如50算法会变得过于保守所有解都往“晚开工、慢节奏”方向走拖期又上来了。我建议w3取值在10~20之间让库存约束作为“否决项”起作用但不主导全局。5. 常见问题与排查我在这类项目里踩过的坑5.1 解码结果机器分配扎堆第一次跑通代码时我观察到调度方案里有台机器忙到飞起其他机器闲着没事干makespan反而不理想。原因是ECT规则只看“完工时间最早”如果某台机器大概率是快机它会被持续选中其他机器根本等不到活。解决思路是在解码规则上做文章不用ECT改成“负载均衡优先”或者在目标函数里加入机器最大完工时间与平均完工时间的差值惩罚。我的建议是不要急着改解码规则——因为ECT在绝大多数情况下效果都不错扎堆问题往往是权重设置导致的比如库存惩罚太小。先把w3、w4调到位很多时候问题自动消失。5.2 SA后期解不变化卡在局部最优怎么办SA卡死几乎是每个新手都会遇到的状况。表现是温度已经很低了但最优解连续几十个温度周期都没有更新trace曲线成了一条水平线。我排查之后发现原因一般有两个一是初温太低前期探索不够解的质量一开始就没上去二是邻域结构太弱只用swap的话后期根本走不出局部盆地。我的处理办法是“重热”reheating当连续30轮温度没有改进时把温度重新提到当前温度的10倍让算法跳出局部极值再来一轮。这是一个相当好用的工程技巧虽然理论上SA收敛性会受影响但实际效果显著至少能在相同时间内更早找到更好的解。5.3 库存峰值计算边界条件出错这个坑最隐蔽但后果也最严重——算出来的调度方案看起来不超库存拉回实际一看却超了。原因出在同一个时间点多件事件的顺序处理上。我遇到过两种典型错误一是完工时间和交期相同的工件被误算进FG导致成品峰值虚高二是发运事件和完工事件同时发生时先处理后发运把FG算成了负数峰值统计混乱。解决办法在代码里已经体现事件按[时间, 类型]排序类型顺序严格为开工→完工→发运同时对于C(j)due(j)的工件完工时不进FG、发运时不减FG直接跳过。这样边界情况就彻底稳了。5.4 Matlab运行速度太慢调度问题的评估函数在SA内循环里会被调用成千上万次evaluate函数里如果写太多循环Matlab慢起来能让人失去耐心。我的加速手段有三个一是向量化解码。至少把decode里对机器数的循环换成向量运算10台机器以上提速能到2-3倍。二是避免evaluate里反复分配数组。events数组如果每轮用循环拼接内存重分配很浪费时间我一般是预分配一个大数组再分段填进去。三是用parfor并行跑多组独立SA。不同随机种子下多跑几轮取最优结果这是对抗SA随机性最粗暴也最有效的办法。四核机器并行四组耗时约等于原来一组的一倍多收益却是实打实的。写在最后从“能跑”到“能落地”的几点体会这套代码从模型设计到Matlab实现整个过程我最大的感受是调度问题的难点从来不在算法本身而在把现实约束翻译成可计算的目标函数。库存约束看着简单实际建模时“峰值”和“均值”的选择就大有讲究交期是硬是软直接决定解空间的结构。想明白这些写SA反而是一件不费脑子的事。最后分享一个我自己的小技巧所有对比实验一定要固定随机种子。Matlab里用rng(42)这种固定方式每条测试跑之前都重置随机数这样算法A和算法B的对比才公平也不会被某次随机波动带到沟里去。这个习惯帮我省了不知道多少扯皮的时间。下一步想扩展的话可以从这几个方向往上延伸把单目标换成多目标比如用NSGA-II同时优化makespan和总拖期、在模型里加入换模时间、或者把完工时间和交期之间的差值细化为“提前/拖期”分段惩罚。核心框架已经在这了往哪个方向加深取决于你车间的真实痛点。
返回列表