简介:针对能源效率模糊柔性作业车间调度问题(EFFJSP),这份PDF文档实现了带反馈机制的双种群进化算法(FBEA),面向科研人员与智能调度系统开发者。文档完整复现了论文《A Bi-Population Evolutionary Algorithm With Feedback for Energy-Efficient Fuzzy Flexible Job Shop Scheduling》算法框架,包含模糊数运算、约束处理、多目标优化等关键组件的Python代码与逐段解释;详细展示了模糊能耗计算、四种启发式初始种群生成、基于质量的种群反馈调整,以及新繁殖、交叉、变异和增强局部搜索等过程。可视化工具能够直观呈现调度方案分析结果,方便对比与扩展。资源共1个PDF文件,大小881KB,已有59人浏览学习,可作为学术研究复现基线或工业调度优化参考。
1. 为什么压住完工时间和能耗需要双种群进化算法
车间里有句老话:排产排得好,电费少一半,交期还不恼。模糊柔性作业车间调度(FFJSP)难在哪?工序可以选多台机器,每台机器的加工时间又是个模糊区间——操作工手法快慢、材料批次波动,都会让工时不是定数。这种系统里只盯完工时间,电费会涨;只盯能耗,交货期就崩。双种群进化算法的做法,是让一个种群顶着模糊完工时间搜,另一个种群压能耗,再定期把两边精英互换,让两个互相打架的指标在同一个框架里同时往前走。本文从问题建模写到 Python 主循环,把参数和踩坑一条条讲透,适合正在做排产系统设计、想少走弯路的工程师。
2. 模糊柔性作业车间调度的数学模型:三角模糊数与综合能耗目标
写代码前先把模型立住。这一章把一个 FFJSP 实例的输入、目标和约束都用数学形式固定下来,后面所有解码器、适应度函数都要回到这里。很多第一次做这个方向的人一上来就写进化算子,结果目标函数定义得稀里糊涂,后面换算例就翻车。
2.1 模糊加工时间用三角模糊数表示:三个参数从哪里来
柔性作业车间里,工序 o_ij 可以在候选机器集合 M_ij 中的任意一台机器上加工,这是“柔性”。加工时间不是一个确定值,而是“大概 4 小时、快则 3 小时、慢则 6 小时”,这就是“模糊”。工程上最常用的表示是三角模糊数 (a, b, c),a 是乐观时间,b 是最可能时间,c 是悲观时间。
这三个参数不是拍脑袋填的。如果车间有 MES 或手工记录的历史工时,我会按同一工序在同一台机器上的历史数据取分位数:a 取 10% 分位数,b 取 50% 分位数,c 取 90% 分位数。数据少的时候,就找班组长填“最快/最可能/最慢”三列,这个办法粗但能用。注意 b 不一定是 (a+c)/2,工人说“多数时候偏慢”,那 b 就更靠近 c,这很正常。
三角模糊数的加法是逐项相加:两个三角模糊数相加仍然是三角模糊数,这是调度推演能往下走的基础。比较和去模糊化我用加权平均公式:
defuzz = (a + 2b + c) / 4
也就是把最可能时间 b 的权重加倍。这个公式比直接取 (a+b+c)/3 更贴近车间里“多数工时的落点偏 b”的事实,而且代码里排序时就靠它做标量比较。后面第 5 章会专门说这个公式选错了会产生什么后果。
2.2 模糊完工时间的传播:从工序结束时间到系统最大完工时间
调度解出来以后,每个工序有一个模糊开工时间 S_ij 和模糊完工时间 C_ij。工序一旦在机器上开始,就不能被打断,所以 C_ij = S_ij + p_ij,其中 p_ij 是在选中机器上对应的三角模糊加工时间。开工时间要同时满足两个约束:同一台机器上一个工序干完才能干下一个,同一个工件的工序要按工艺顺序一个接一个。于是:
S_ij = max(该机器上一道工序完工时间, 该工件上一道工序完工时间)
这两个时间都是三角模糊数,max 怎么取?我用的办法是去模糊化后取较大者,再用它的原始模糊值往上累加。严格来说这丢掉了模糊数的部分形状信息,但工程上快、稳、不容易出怪结果。如果你的排序要求更严格,可以用两两比较的模糊数排序规则,但每步解码的耗时会上一个量级。
系统最大完工时间 C_max 等于所有工件最后一道工序完工时间中的最大值,它仍然是一个三角模糊数。最终上报给业务系统时,再用 defuzz = (a+2b+c)/4 换算成单个数,这个数就是调度方案在“按时交付”维度上的得分。
2.3 能耗目标:加工、待机与机器启停三部分怎么算
能耗模型是双种群算法里的另一个主角。一个完整到能拿来谈系统落地的能耗目标,至少包含三部分:
- 加工能耗:工序在机器上实际切削/成型时消耗的能量,等于加工功率 × 模糊加工时间去模糊化后的时长,按每个工序累加;
- 待机能耗:机器空转等待消耗的能量,等于待机功率 × 空转时长;
- 启停能耗:如果机器频繁开机、停机,设备说明书上的启动电流冲击和热量损失不能忽略,这部分通常折算成一次固定能耗。
一个常见的系统设计误区是只算加工能耗。如果只算加工能耗,进化算法会倾向于把工序集中到功率高但速度快的机器上,表面看总加工能耗很小,实际上其他机器空转等料,待机电费哗哗涨。所以我在目标里一定要加待机能耗:待机总时长用“每台机器的调度跨度减去该机器加工占用总时长”来估计。启停能耗可以在建模阶段先放一个常数,等后面第 6 章再谈怎么细化。
这样两个目标函数就固定下来:目标 1 是模糊最大完工时间去模糊化后的值 F1,目标 2 是总能耗 F2。F1 的分母单位是小时,F2 的单位是千瓦时,两者量纲不同、数值范围可能差几十倍,这为后面双种群的权重设计埋了一个大坑,第 3 章和第 5 章都会再提到。
3. 双种群进化算法设计:双链编码、遗传算子与精英迁移机制
模型定好了,接下来是算法结构。双种群进化算法不是把两个普通遗传算法并行跑那么简单,它的核心在“分工”和“交换”这两个动作上。这一章先把选型理由讲透,再给出编码、算子和迁移机制的完整设计,让第 4 章的代码有依据可循。
3.1 为什么拆成两个种群:带权目标在单一种群里怎么打起来
很多第一篇论文的做法是把两个目标加权成一个:fitness = w × F1 + (1-w) × F2,w 取 0.5,然后跑一个标准遗传算法。这个做法不是不能用,只是工程上有两个毛病。
第一,w 是固定值,算法的搜索方向被焊死。你不能让同一个种群在“赶工”和“省电”之间反复横跳,只能得到一条固定折中方向的解。第二,w 对目标数值范围极度敏感,F1 是几十小时级别,F2 是几千千瓦时级别,不归一化的话 w 根本没有意义。
双种群的做法是让两个种群各自持有一组权重:种群 A 用 w=0.8 主要压模糊完工时间,种群 B 用 w=0.2 主要压能耗。两个种群各自朝不同的方向演化,每过若干代把对方的好解拿过来。这样在一代一代搜索中,不是一群人同时照顾两个目标,而是两个专才队伍各自深挖,然后互相借脑。等到跑完,把两个种群合并起来做非支配排序,就能得到一个覆盖面更宽的 Pareto 前沿。这个“分工+交换”的框架,比一个大种群带一个动态权重要稳定得多。
3.2 染色体编码与解码方向:工序顺序串 + 机器选择串
FFJSP 的个体必须同时表达两件事:工序谁先谁后、每道工序用哪台机器。我用双链编码,这也是 FJSP 系列问题最经典的编码方式。
第一条链是工序顺序串。假设有 3 个工件,工件 0 有 2 道工序,工件 1 有 3 道,工件 2 有 2 道,那么顺序串长度为 7,内容是 0、0、1、1、1、2、2 的一个排列。解码时从左往右扫,第几次出现某个工件号,就代表这个工件的第几道工序。比如串是 [1,0,2,1,0,2,1],先解工件 1 的第 1 道工序,再解工件 0 的第 1 道工序,然后工件 2 的第 1 道工序,解到第五位是 0 时,解的是工件 0 的第 2 道工序。这种编码天然保证一个工件的工序顺序,不会解出“第 2 道工序干完才允许干第 1 道”这种非法解。
第二条链是机器选择串。长度和顺序串一样,第 k 个基因对应第 k 个位置上的工序所使用的机器编号。这里有个必须守住的约束:机器必须在工序的候选机器集合里。生成初代个体时如果随机指派了不可用机器,要做一次合法性修复,最简单的方法就是随机换一台候选机器。第 4 章代码里我会放一个更快的兜底逻辑。
解码时按顺序串扫描,对每个工序找到它的机器,计算这台机器的完工时间和该工件上一道工序的完工时间,取较大者作为模糊开工时间,加上加工时间得到模糊完工时间,更新时间表。整个过程沿着时间轴推进,不重新排列已调度工序,是标准贪心解码。贪心解码不是最优的,比如没有主动去填机器上的空闲缝隙,但它快、稳定、容易调试,适合作为系统基线。进阶的插入式解码在第 6 章提一句即可。
3.3 交叉、变异和精英迁移参数:从跑通到收敛
遗传算子也要跟着双链编码改。顺序串用顺序交叉(OX),做法是取父本 A 的一段连续基因作为模板,再从父本 B 中把不属于这段基因的工序号按顺序填进子代的空位里。机器选择串用单点交叉,就是从某个随机位置把两父本的机器基因切开来交换尾部。
变异算子我分两条链处理:顺序串随机交换两个位置的工序号,保证不能把同一工件的工序数量改掉;机器链以一定概率把某个工序换到另一台可用机器上。变异概率一般取 0.1 到 0.2,太高会把已经收敛的好解打散。两个种群规模在 40 到 100 之间都可以,小算例 40 足够。
精英迁移是关键参数。迁移间隔太长,两个种群各干各的,等于白做了交换机制;迁移间隔太短,比如每 2 代就迁,两个种群会迅速同质化,Pareto 前沿反而变窄。我一般用迁移间隔 10 到 20 代,每代从每个种群中挑出按自身适应度排名前 2 到 5 的个体,复制一份给对方种群,替换对方种群中最差的几个。迁移数控制在种群规模的 5% 到 10% 以内,超过这个量基本等于在合并两个种群。
整个算法的骨架如下,第 4 章会把每一步补成可运行的 Python 代码:
# 算法骨架(伪代码) # 初始化种群A(w_a偏完工时间)和种群B(w_b偏能耗) # 对每个种群独立执行: # 锦标赛选择 -> 顺序交叉 + 机器单点交叉 -> 变异 -> 生成新一代 # 每 migrate_interval 代: # 种群A的精英复制给种群B,种群B的精英复制给种群A # 各自替换最差的 migrate_num 个个体 # 迭代结束后合并两个种群的 Pareto 非支配解4. 系统设计落地:可运行的 Python 双种群调度核心代码
这一章把前面三章的模型和算法落成可以直接跑的 Python 代码。代码分成四段:模糊数类、问题生成器、解码器、双种群进化主循环。每一段后面跟着参数说明和设计原因。我没有用任何第三方库,标准库就能跑通,方便你直接放进自己的系统里做原型验证。
4.1 模糊数类与测试算例生成器
import random class TriFuzzy: """三角模糊数 (a, b, c),约束 a <= b <= c""" __slots__ = ("a", "b", "c") def __init__(self, a, b, c): if not (a <= b <= c): raise ValueError("三角模糊数必须满足 a <= b <= c") self.a, self.b, self.c = float(a), float(b), float(c) def __add__(self, other): return TriFuzzy(self.a + other.a, self.b + other.b, self.c + other.c) def defuzz(self): # 加权平均:中间值 b 权重加倍,更贴近车间现实 return (self.a + 2 * self.b + self.c) / 4.0 def __repr__(self): return f"({self.a}, {self.b}, {self.c})" def defuzz_key(t): return t.defuzz() def gen_instance(n_jobs, n_machines, seed=1): """生成一个随机 FFJSP 算例""" rng = random.Random(seed) instance = [] for _ in range(n_jobs): ops = [] for _ in range(rng.randint(2, 4)): cand = [] for m in range(n_machines): if rng.random() < 0.6: base = rng.randint(3, 9) cand.append((m, TriFuzzy(base - 2, base, base + 3))) if not cand: # 保证每道工序至少有一台机器能加工 m = rng.randrange(n_machines) cand.append((m, TriFuzzy(1, 3, 5))) ops.append(cand) instance.append(ops) return instance machine_power = [5.0, 2.0, 8.0] # 各机器加工功率,单位 kW idle_power = [0.5, 0.2, 0.8] # 各机器待机功率,单位 kW第一个类 TriFuzzy 是整个程序的地基。注意__slots__不是为了耍酷,是防止在做几十代进化的时候创建大量无用属性字典拖慢内存。加号运算符被重载,所以解码器里可以直接写end = start + ptime。defuzz方法就是参数说明里那个 (a+2b+c)/4 公式,所有需要比较和累加能耗的地方都用它。
gen_instance生成一个两层嵌套结构的算例:外层是工件列表,内层是每个工件的工序列表,每个工序是一个候选机器列表,候选机器列表的元素是“机器编号 + 三角模糊数”。0.6 这个概率控制机器柔性程度,概率越小机器可选性越低,问题越硬。如果你手上有真实的工艺路线数据,把这段换成从数据库读入即可,后面所有代码都不受影响。
4.2 解码器:从双链基因到模糊完工时间和能耗
def flat_ops(instance): """把嵌套的 instance 展平成全局工序索引表""" flat = [] key = {} for j, ops in enumerate(instance): for i, cand in enumerate(ops): key[(j, i)] = len(flat) flat.append((j, i, cand)) return flat, key def decode(instance, order, machine_gene): """输入:算例、工序顺序串、机器选择串 输出:去模糊化后的完工时间和总能耗 """ flat, key = flat_ops(instance) n_mach = len(machine_power) next_op = [0] * len(instance) # 每个工件下一次要解的工序下标 job_end = [TriFuzzy(0, 0, 0)] * len(instance) # 每个工件最后一道工序的模糊完工时间 mach_end = [TriFuzzy(0, 0, 0)] * n_mach # 每台机器最后一道工序的模糊完工时间 mach_busy = [0.0] * n_mach # 每台机器的加工占用时长(去模糊化) for jid in order: op_idx = next_op[jid] next_op[jid] += 1 k = key[(jid, op_idx)] cand = flat[k][2] m = machine_gene[k] # 如果机器基因非法,回退到耗时最短的候选机器 if m not in [x[0] for x in cand]: m = min(cand, key=lambda x: x[1].defuzz())[0] ptime = [x[1] for x in cand if x[0] == m][0] start = max(mach_end[m], job_end[jid], key=defuzz_key) end = start + ptime mach_end[m] = end job_end[jid] = end mach_busy[m] += ptime.defuzz() makespan = max(job_end, key=defuzz_key).defuzz() energy = 0.0 # 加工能耗:加工功率 x 加工时长 for k, (j, i, cand) in enumerate(flat): m = machine_gene[k] if m not in [x[0] for x in cand]: m = min(cand, key=lambda x: x[1].defuzz())[0] ptime = [x[1] for x in cand if x[0] == m][0] energy += ptime.defuzz() * machine_power[m] # 待机能耗:机器跨度减去加工占用时间 for m in range(n_mach): span = mach_end[m].defuzz() energy += max(0.0, span - mach_busy[m]) * idle_power[m] return makespan, energy解码器核心逻辑在for jid in order这个循环里。对顺序串的每一个基因,先取对应工件下一次要解的工序编号,再找到这台工序在全局工序索引表里的位置,读取候选机器集合。机器号的合法性兜底放在循环内,如果进化算子产生了非法机器,直接退回到该工序候选机器里“去模糊化时间最短”的那一台,保证任何一个个体都至少能被解码。
start = max(mach_end[m], job_end[jid], key=defuzz_key)这一行同时兑现了 2.2 节里的两个约束:机器忙就等机器,工件上一道工序没干完就等工序。模糊数没有原生的 max,这里用去模糊化之后的值比较大小,然后取原始模糊值。
待机能耗的计算是一个务实近似:假设机器从 0 时刻开始统计,每台机器的“跨度”用最后完工时间表示,减去实际加工占用时间,剩下的就是待机时长,再乘待机功率。这个模型在只有两台机器、订单排队严重时会有偏差,因为机器的空闲时间可能分散在加工区间里,但对于算法对比和参数调优来说已经足够稳定。如果想精确到每个空闲缝隙,需要在解码器里额外维护每台机器的区间列表,代码复杂度会上一个台阶,性能也会明显下降。
4.3 双种群进化主循环:选择、交叉、变异与精英迁移
def make_individual(instance, rng): """随机生成一个合法个体""" flat, _ = flat_ops(instance) order = [] for j, ops in enumerate(instance): order += [j] * len(ops) rng.shuffle(order) machine_gene = [] for _, _, cand in flat: machine_gene.append(rng.choice([m for m, _ in cand])) return order, machine_gene def crossover(pa, pb, rng): """顺序串 OX 交叉,机器串单点交叉""" order_a, mac_a = pa order_b, mac_b = pb n = len(order_a) if n > 2: p1, p2 = sorted(rng.sample(range(n), 2)) seg_a = order_a[p1:p2] tmp_b = [x for x in order_b if x not in seg_a] child_a = tmp_b[:p1] + seg_a + tmp_b[p1:] seg_b = order_b[p1:p2] tmp_a = [x for x in order_a if x not in seg_b] child_b = tmp_a[:p1] + seg_b + tmp_a[p1:] else: child_a, child_b = list(order_a), list(order_b) cut = rng.randrange(n) mac_c_a = mac_a[:cut] + mac_b[cut:] mac_c_b = mac_b[:cut] + mac_a[cut:] return (child_a, mac_c_a), (child_b, mac_c_b) def mutate(ind, instance, rng, p_mut=0.15): """顺序串交换两个位置,机器串换一台可用机器""" order = list(ind[0]) if rng.random() < p_mut and len(order) > 1: i, j = rng.sample(range(len(order)), 2) order[i], order[j] = order[j], order[i] mac = list(ind[1]) flat, _ = flat_ops(instance) for k in range(len(mac)): if rng.random() < p_mut: cand = flat[k][2] available = [m for m, _ in cand] if len(available) > 1: mac[k] = rng.choice([m for m in available if m != mac[k]]) return (order, mac) def fitness(ind, weight, scaler, instance): """权重适应度,目标值先做 min-max 归一化""" o1, o2 = decode(instance, ind[0], ind[1]) n1 = (o1 - scaler[0]) / (scaler[1] - scaler[0] + 1e-9) n2 = (o2 - scaler[2]) / (scaler[3] - scaler[2] + 1e-9) return weight * n1 + (1 - weight) * n2make_individual里用rng.shuffle保证每个工件的工序数量在顺序串中不丢失,机器基因逐个工序从候选机器集合里随机选一台,保证初始群体 100% 合法。交叉函数里[x for x in order_b if x not in seg_a]这一句是 OX 交叉的核心,它保留父本 B 中除模板片段之外的所有工序顺序,维持每个工件出现的次数不变。机器串的单点交叉没有合法性校验,因为合法性兜底已经被放在了解码器里。
适应度函数里有个关键细节:两个目标不直接乘权重,而是先做 min-max 归一化。scaler 是一个四元组,格式是“完工时间下限、完工时间上限、能耗下限、能耗上限”,由初始种群估算得到。这一步不做的话,能耗数值几千、完工时间才几十,w=0.8 也救不回来,这是 3.1 节里埋下的坑。
def tournament(pop, fit_func, rng, k=2): """锦标赛选择""" best = None best_fit = float("inf") for _ in range(k): ind = rng.choice(pop) f = fit_func(ind) if f < best_fit: best, best_fit = ind, f return best def run_dual_pop(instance, gen=80, pop_size=40, migrate_interval=10, migrate_num=2, w_a=0.8, w_b=0.2, seed=1): rng = random.Random(seed) pop_a = [make_individual(instance, rng) for _ in range(pop_size)] pop_b = [make_individual(instance, rng) for _ in range(pop_size)] # 用两个初始种群一起估算目标值范围 raw = [decode(instance, ind[0], ind[1]) for ind in pop_a + pop_b] scaler = ( min(x[0] for x in raw), max(x[0] for x in raw), min(x[1] for x in raw), max(x[1] for x in raw), ) def fit_a(ind): return fitness(ind, w_a, scaler, instance) def fit_b(ind): return fitness(ind, w_b, scaler, instance) for g in range(gen): new_a, new_b = [], [] while len(new_a) < pop_size: p1 = tournament(pop_a, fit_a, rng) p2 = tournament(pop_a, fit_a, rng) c1, c2 = crossover(p1, p2, rng) new_a.append(mutate(c1, instance, rng)) new_a.append(mutate(c2, instance, rng)) while len(new_b) < pop_size: p1 = tournament(pop_b, fit_b, rng) p2 = tournament(pop_b, fit_b, rng) c1, c2 = crossover(p1, p2, rng) new_b.append(mutate(c1, instance, rng)) new_b.append(mutate(c2, instance, rng)) pop_a = new_a[:pop_size] pop_b = new_b[:pop_size] if (g + 1) % migrate_interval == 0: pop_a.sort(key=fit_a) pop_b.sort(key=fit_b) best_a = pop_a[:migrate_num] best_b = pop_b[:migrate_num] pop_a[-migrate_num:] = best_b pop_b[-migrate_num:] = best_a # 合并两个种群的个体 total = pop_a + pop_b front = [] for ind in total: o1, o2 = decode(instance, ind[0], ind[1]) dominated = False for other in total: oo1, oo2 = decode(instance, other[0], other[1]) if (oo1, oo2) != (o1, o2) and oo1 <= o1 and oo2 <= o2: dominated = True break if not dominated: front.append((o1, o2, ind[0], ind[1])) return front instance = gen_instance(3, 3, seed=7) front = run_dual_pop(instance, gen=80, pop_size=40, migrate_interval=10, migrate_num=2, w_a=0.8, w_b=0.2, seed=7) for o1, o2, _, _ in sorted(front): print(f"makespan={o1:.2f} energy={o2:.2f}")主循环的关键参数在函数签名里:gen 是总迭代代数,pop_size 是每个种群的个体数,migrate_interval 是迁移间隔,migrate_num 是迁移数量,w_a 和 w_b 是两个种群各自对完工时间的权重。注意 migrate_num 取 2 相当于迁入个体只占单种群 40 个体的 5%,这是前面说过的安全区间。
run_dual_pop末尾的 Pareto 前沿筛选用了最直接的双重遍历,复杂度是 O(N²) 量级,原型阶段完全够用。如果算例到几百道工序,个体总数上千,这里会明显变慢,届时要改成先把所有个体按目标 1 排序,再线性扫描一遍,这个优化留给读者自己实现。
跑完代码,你会看到打印出来的若干行 (makespan, energy)。这些就是双种群算法在当前算例上找到的折中解集合,有的解完工时间短但能耗高,有的能耗低但工期长。把这些点画到二维坐标轴上,就是一条 Pareto 前沿。
5. 避坑指南:模糊数、能耗权重与迁移参数的 5 个现场翻车案例
这一章全部来自我实际调试这类算法时的血泪教训。每个案例按“现象 → 原因 → 解决”来写,方便你遇到类似情况时直接对照。
5.1 模糊数比较直接取 (a+b+c)/3,排序结果偏向中间值
现象:两个方案比较,明明一个方案的工期区间是 (2, 10, 10),另一个是 (4, 5, 12),直觉上前者风险极大,但两者算出来的平均值都是 7,算法认为它们一样好,导致评价结果一片糊,收敛很慢。
原因:三角模糊数的算术平均把所有可能值等权处理,没有体现“最可能时间”在车间里的主导地位。两个分布完全不同的模糊数可能算出同一个均值,这是模糊调度里最常见的错误之一。
解决:统一用 (a+2b+c)/4 做去模糊化,或者用基于面积比较的模糊数排序法。后者更严谨但代码开销大,我的项目中绝大多数场景用前者就够了。关键是全系统只用一个公式,不要在解码器里用一种、在适应度里又换另一种。
5.2 只算加工能耗不算待机能耗,机器越选越快、电费反而涨
现象:能耗优化种群跑出来的方案把所有工序都往高功率机器上塞,单个方案的“加工能耗”很小,但放到真实车间里,其他机器空转待机,总电费比优化前还高。
原因:目标函数里只有加工功率乘以加工时间,没有待机能耗项,算法自然发现“把工序集中到少数机器”是省能量的捷径,但这不真实。
解决:把待机能耗纳入目标 2。最简单的做法就是第 4 章代码里那种,用机器跨度减去加工占用时间作为待机时长,乘上待机功率。如果车间里有自动启停功能,还要加启停能耗,否则算法会设计出频繁启停机器的方案来“省待机费”。
5.3 迁移间隔设成 2 代,双种群退化成了单种群
现象:跑了 60 代,最后合并出来的 Pareto 前沿只有四五个点,两个种群各自的最优解几乎一样,多样性比单种群还差。
原因:迁移太频繁,种群 A 的精英很快充满种群 B,种群 B 的精英也充满种群 A,两个种群的选择压力趋同,等于把两个种群捏成了一个。
解决:迁移间隔至少要覆盖一个完整的选择-交叉-变异周期,我一般设 10 到 20 代。迁移数控制在种群规模的 5% 到 10%。另外,迁移时只复制精英个体的副本,用它们替换对方种群里的最差个体,不要拿对方精英把本地中等偏下的个体也挤掉,否则本地搜索方向会被破坏。
5.4 解码器里双重循环找插入位置,算例一大就超时
现象:换到有 20 台机器、30 个工件的算例后,单次评估耗时从几毫秒涨到几十毫秒,跑 100 代双种群 80 个个体,一个小时出不来结果。
原因:解码器里每解一道工序都要扫一遍候选机器集合,而且能耗计算里又扫了一次全部工序,Pareto 筛选阶段还反复解码同一个个体。三重浪费叠加在一起,复杂度直接爆炸。
解决:第一,把 flat 表和候选机器集合在生成个体前全部预计算好,不要在 decode 里重复生成;第二,Pareto 筛选时先缓存每个个体的 (o1, o2),不要对同一个个体解码两次;第三,机器合法性检查可以合并到能耗计算的那一次循环里,减少一次完整遍历。还有一招是固定随机种子之后用 profile 工具看热点,通常瓶颈都在 decode 函数里的列表推导式。
5.5 权重拍脑袋定,不先做目标归一化,换算例全崩
现象:w_a=0.8, w_b=0.2 在 3 机小算例上效果不错,换到 15 机算例后种群 A 完全不在乎能耗,种群 B 完全不在乎工期,搜索成了一边倒。
原因:两个目标的量纲和数量级不一样。小算例里完工时间 20 小时、能耗 2000 千瓦时,直接加权后能耗天然占据主导;换算例成了完工时间 200 小时、能耗 3000 千瓦时,权重关系又反过来。
解决:像第 4 章代码那样,先用初始群体样本估算两个目标各自的最小值和最大值,做 min-max 归一化再乘权重。归一化之后,w_a=0.8 意味着“这个种群 80% 的注意力放在完工时间上”,可解释性也强多了。每隔二三十代用当前群体的实际范围重新校准一次 scaler,效果会更好,代价是每次校准要多算一次所有个体的目标值。
6. 双种群系统跑通后的验证方法与三个进阶改进方向
代码跑通了,别急着写验收报告。先做三件事:第一,固定随机种子跑 5 次,检查 Pareto 前沿的稳定性;第二,把前沿解可视化,看是不是均匀覆盖两个目标的两端;第三,把最好方案和车间现行排产对比,确认能耗降低不是靠牺牲过度工期换来的。
稳定性验证可以直接用代码里的 seed 参数。把 seed 分别取 1 到 5,各跑一遍,记录每轮前沿解的完工时间最小值、能耗最小值,计算中位数和极差。如果极差超过中位数的 20%,说明算法稳定性有问题,优先检查迁移参数,再检查变异概率是不是太大。可视化方面,把每个解画成 (makespan, energy) 散点图,好的前沿应该像一条从左上到右下的弧线,中间没有明显缺口。
| 随机种子 | 最短完工时间 | 最低能耗 | 前沿解个数 |
|---|---|---|---|
| 1 | 12.4 | 86.3 | 7 |
| 2 | 12.1 | 89.5 | 8 |
| 3 | 12.8 | 84.9 | 6 |
| 4 | 12.2 | 88.1 | 7 |
| 5 | 12.5 | 85.7 | 8 |
这张表只是示意格式,具体数字以你的算例为准,但判断逻辑是通用的:几个种子的最短完工时间差异要小,前沿解数量要相对稳定。
三个进阶改进方向,按性价比排序。第一个是改解码器,把贪心解码升级成插入式解码,让新工序可以塞进机器空闲时间段的缝隙里,完工时间通常会再降 3% 到 8%,代价是解码耗时翻倍。第二个是把能耗模型细化,加入峰谷平电价和机器启停能耗,在电价高时段主动安排待机,这个对真实账单的影响比任何参数调优都大。第三个是把双种群框架和 NSGA-II 的拥挤度距离结合,两个种群各自跑完一定代数后,合并做一轮非支配排序,再按拥挤度分成新种群继续进化,Pareto 前沿的均匀性会明显改善。
我自己做这类调度系统时有个习惯:每一次调整算法,都先用三组固定随机种子跑完对照再下结论,绝不拿单次实验当成调参成果。这个习惯救过我很多次,因为进化算法本身就是随机过程,单次结果好可能只是运气。希望帮到你。
本文还有配套的精品资源,点击获取