FJSP调度强化学习:multi-PPO双Actor与动作掩码设计实践
2026/9/17 5:35:31 网站建设 项目流程

简介:面向智能制造与运筹优化研究者的multi-PPO(多智能体近端策略优化)求解柔性作业车间调度问题(FJSP)完整实现,适合本科生、研究生及算法工程师学习与复现。资源包共482个文件,约73.21MB,核心为86个Python源码文件与126个已训练模型权重(.pth),配套101个字节码文件(.pyc)便于直接调用;同时包含56个标准FJSP算例(如Behnke系列)及txt/json/xml等配置与结果记录文件,svg、png等可视化结果直观展示调度甘特图与收敛曲线。已有248人学习下载。通过这份资源,读者可快速掌握multi-PPO在组合优化中的建模思路与训练流程,直接加载权重验证算法性能,并基于标准算例开展对比实验。对于需要撰写论文或准备竞赛的研究者,包内清晰的目录结构和源码注释能显著降低复现门槛。

1. FJSP与multi-PPO:为什么拆开比合并更好学

FJSP全称是Flexible Job-shop Scheduling Problem,比经典JSP多了一层机器选择的自由度:每道工序不再绑定固定设备,而是从一组可加工机器中任选一台。正是这层自由度,让解空间从工序排列扩展成排列加分配的复合空间,元启发式和启发式规则都容易在局部最优附近打转。把PPO直接套上去最常见的失败是动作空间过大——工序和机器的组合动作动辄上千维,随机策略在训练初期的有效探索几乎为零。multi-PPO的思路是把决策按业务语义拆开:一个策略决定下一步做哪道工序,另一个策略决定这道工序放哪台机器,两个actor共享一个critic评估全局状态。拆完之后单个策略的动作维度降到几十,配合合法性掩码能稳定学到调度策略。这套python实现里还带了Behnke系列标准算例,能直接跑出可对比的排产结果,适合做强化学习调度方向实验对比的研究生,也适合想快速搭调度智能体原型的工程师。

2. FJSP的MDP建模与动作掩码设计

把FJSP转成强化学习问题的第一步是定义MDP五元组。难点不在数学形式,而在状态的表达方式:状态要包含所有与决策相关的信息,但维度又不能太大。FJSP有两条时间轴,一条按工序逐个推进,一条按机器的工作队列推进,每一步决策必须同时考虑这两者的状态,否则策略会做出矛盾的选择。

2.1 状态空间:用矩阵拼出车间快照

我在实现时把状态拆成三块拼接。第一块是作业进度向量,记录每个作业已完成工序比例;第二块是机器状态向量,记录每台机器距离下一次空闲还剩多少时间;第三块是候选加工时间矩阵,记录每个作业当前工序在各台机器上的加工时间,不可用的位置补0。三块拼成固定长度的一维张量,直接作为actor和critic的输入。

def build_state(env, current_time): # 作业进度: 已完成的工序数除以该作业总工序数 progress = np.array([ job.finished_ops / job.total_ops for job in env.jobs ], dtype=np.float32) # 机器状态: 距离下一次可用的剩余时间 machine_time = np.array([ max(0.0, m.next_avail_time - current_time) for m in env.machines ], dtype=np.float32) # 候选加工时间: 作业数 x 机器数,当前工序不可用的机器填0 op_time = np.zeros((env.num_jobs, env.num_machines), dtype=np.float32) for job in env.jobs: op = job.current_operation if op is not None: for m_id, p_time in op.alt_machines: op_time[job.id, m_id] = p_time return np.concatenate([progress, machine_time, op_time.flatten()])

这段代码里,progress用已完工工序数占总工序数的比例描述作业推进情况,天然在0到1之间,不需要额外归一化。machine_time返回的是绝对时间差,实例不同数值范围变化大,实际使用时会除一个当前时间的平滑值把尺度压到1附近。op_time是维度最大的部分,它的作用是把当前工序的机器偏好信息直接暴露给网络,这样工序actor选完作业后,机器actor可以直接从矩阵对应行取候选机器和时间。

状态设计的原则是决策要用的信息必须出现在输入里。比如机器actor分配机器时,需要知道目标机器是否空闲、候选加工时间是多少,这些都在state里;工序actor选择作业时,需要知道哪个作业的当前工序已经就绪,这个信息由op_time对应的行是否全0来体现。只给网络目标值不给中间特征,策略再强也学不出合理的调度行为。

2.2 动作空间:把组合动作拆成两段独立决策

组合动作空间是FJSP强化学习建模最容易踩的坑。如果动作定义为“选择作业j的第k道工序并用机器m加工”,动作维度就是所有作业、所有工序与所有候选机器的笛卡尔积。以Behnke系列的中等实例来看,这个展开后的维度会超过一千,而其中很大一部分是非法或冗余的。用这种建模方式训练PPO,即使加了mask,策略网络也要维护大量与当前决策无关的权重,收敛很慢。

multi-PPO改成两段决策:第一步工序actor输出一个概率分布,覆盖当前所有已就绪的工序;第二步机器actor接收选中工序,在其候选机器集合上输出分布。此时动作维度降到工序数和机器数的量级,每个样本的信息密度高很多。这里有一个容易忽略的工程点:第二次决策必须拿到第一次的采样结果后才能构造输入,所以两个actor的forward是前后依赖的,不能用并行方式直接跑。

class StepBuffer: def __init__(self): self.state = None self.op_mask = None # 工序合法性掩码,1为可选 self.op_idx = None # 工序actor采样结果 self.machine_mask = None # 机器合法性掩码 def sample(self, op_actor, mc_actor, state, op_mask): self.state = state op_logits = op_actor(state) op_dist = masked_softmax(op_logits, op_mask) self.op_idx = op_dist.sample().item() # 根据选中工序动态构造机器掩码 self.machine_mask = build_machine_mask(self.op_idx) machine_logits = mc_actor(state, self.op_idx) machine_dist = masked_softmax(machine_logits, self.machine_mask) return self.op_idx, machine_dist.sample().item()

StepBuffer的sample方法串行调用两个actor。第6行的masked_softmax把非法工序的logits替换为负无穷,采样到的op_idx一定是合法工序。第11行的machine_logits中,机器actor需要额外输入op_idx来感知当前正在为哪道工序分配机器,实际实现里常见做法是先用torch.nn.Embedding把op_idx映射成一个32维向量,再与state拼接作为输入。

2.3 掩码机制与奖励函数设计

掩码在调度类强化学习里不是可选项,而是必须项。masked_softmax中把非法logits置为-1e9的方法,比采样后丢弃重采样高效得多,梯度传播也更干净。注意不能用零掩码替代:直接把softmax输入在某些位置置0,归一化后非法动作的概率依然可能大于0,短时间看不出问题,累积几万步后策略会对非法动作产生非零概率,一旦环境返回惩罚,训练曲线会剧烈震荡。

def masked_softmax(logits, mask): # mask为0-1向量,1表示该动作合法 logits = torch.where(mask == 1, logits, torch.full_like(logits, -1e9)) return torch.softmax(logits, dim=-1)

把非法位置的logits替换成-1e9后,softmax输出在这些位置的概率接近0,合法动作的概率被重新归一化且总和为1。这里不能改成logits乘mask再softmax,因为乘法改变的是logits的尺度而不是把它推向负无穷,归一化后非法动作仍会有残余概率。

奖励函数直接影响训练曲线形态。稀疏奖励只给最终makespan的负值,信号准确但方差大,在Behnke这类中等规模实例上可能跑上千步都拿不到一次有效梯度。我在实现时用的复合奖励:非法动作给-1,合法动作给基础步进奖励0,每次工序完工时根据当前makespan下界的收窄幅度给正反馈。下界取所有剩余加工时间总和除以机器数的值。

def reward_func(env, op_idx, mc_idx, legal): if not legal: return -1.0 improvement = env.lower_bound_before - env.lower_bound_now return 0.0 + 0.1 * improvement

这个reward函数的核心是lower_bound_now:每当有工序完成,下界被推近一步,智能体获得正反馈。系数0.1要与奖励量级匹配,过大会让策略偏向尽快完工的贪心工序,退化成最短加工时间优先规则;过小则密集奖励被0值淹没,训练信号消失。我在Behnke实例上调参时先固定0.1,观察前几千步平均奖励是否单调上升,再决定微调方向。

3. multi-PPO网络结构与双Actor联合训练

multi-PPO并不是两个各自独立的PPO智能体,而是两条策略分支共享一个价值网络。在FJSP场景中,工序决策和机器决策耦合在同一个调度结果里,如果给两个分支各配一个critic,价值估计会不一致,两个分支算出来的advantage对不上,更新步调就会互相干扰。共享critic让两条分支用同一个全局价值基准更新,协调性更好。

3.1 共享Critic与双Actor的参数结构

网络结构上,工序actor和机器actor各自是一个两层128维的全连接网络,输出层维度由各自动作空间决定。critic同样接收拼接后的完整状态,输出一个标量价值。工序actor只接收全局状态;机器actor要额外拼接当前选中工序的嵌入向量。

class ActorNet(nn.Module): def __init__(self, state_dim, hidden_dim, action_dim): super().__init__() self.fc = nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), ) self.head = nn.Linear(hidden_dim, action_dim) def forward(self, state): return self.head(self.fc(state)) class CriticNet(nn.Module): def __init__(self, state_dim, hidden_dim): super().__init__() self.fc = nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, 1), ) def forward(self, state): return self.fc(state).squeeze(-1)

ActorNet输出的是未归一化的logits,具体概率分布会在采样时套masked_softmax。CriticNet输出的是一个标量,表示当前状态的价值估计。两个actor的state_dim略有不同:工序actor直接用完整状态,机器actor实例化时传入的维度是state_dim加上embed_dim,因为它的输入里多了工序嵌入。

3.2 PPO裁剪损失与GAE计算

PPO的更新由三部分组成:策略裁剪损失、价值函数损失和策略熵正则。critic用MSE拟合回报,actor用clip后的概率比值限制更新步长,熵正则防止策略过早陷入确定性。GAE负责平衡偏差和方差,lambda取0.95时对调度这类带中间奖励的MDP效果最好。

multi-PPO里两个actor分别计算各自的ratio和裁剪损失,但GAE是共用的。采样完一个rollout后,先用当前critic计算每个时间步的GAE,再用同一组GAE分别更新工序actor和机器actor。这样两条动作分支共享同一条回报路径,更新时不会出现一个分支认为某步好、另一个分支认为某步差的情况。

def compute_gae(rewards, masks, values, gamma=0.99, lam=0.95): gae = 0.0 returns = [] for t in reversed(range(len(rewards))): delta = rewards[t] + gamma * values[t + 1] * masks[t] - values[t] gae = delta + gamma * lam * masks[t] * gae returns.append(gae + values[t]) return list(reversed(returns))

compute_gae中的masks表示episode是否结束,FJSP环境调度完成时mask为0,delta里的未来价值项被切断,避免跨episode污染。实际使用中values列表末尾补一个0,对应最终状态的价值。如果rollout因为步数限制被截断而调度还没完成,不能用0替代values[t+1],要用critic对截断状态的bootstrap值,否则GAE会系统性低估末尾状态的价值。

3.3 双Actor联合更新的数据流

训练时数据组织形式很关键。rollout收集阶段,每个时间步同时记录state、两个动作、两个旧log_prob、reward、mask。策略更新阶段,把数据打乱分成mini-batch,每个batch同时计算两个actor和critic的损失,再分别回传更新。两个actor建议分别用各自的优化器,因为动作分支的损失尺度不一样,共用优化器会让损失较大的一方主导学习率。

clip = 0.2 for epoch in range(ppo_epochs): for batch in sampled_batches: # 工序actor的裁剪目标 ratio_op = torch.exp(batch["logp_op_new"] - batch["logp_op_old"]) surr_op = ratio_op * batch["adv"] loss_op = -torch.min(surr_op, torch.clamp(ratio_op, 1-clip, 1+clip) * batch["adv"]).mean() # 机器actor同理 ratio_mc = torch.exp(batch["logp_mc_new"] - batch["logp_mc_old"]) surr_mc = ratio_mc * batch["adv"] loss_mc = -torch.min(surr_mc, torch.clamp(ratio_mc, 1-clip, 1+clip) * batch["adv"]).mean() loss_critic = F.mse_loss(batch["value"], batch["returns"]) optimizer_op.zero_grad(); loss_op.backward(); optimizer_op.step() optimizer_mc.zero_grad(); loss_mc.backward(); optimizer_mc.step() optimizer_cr.zero_grad(); loss_critic.backward(); optimizer_cr.step()

loss_op里用的advantage在更新前要做标准化,减均值除标准差。FJSP不同实例的makespan差异可能在一倍以上,如果不标准化,大scale的advantage会让更新步长过大,策略在几个epoch内就崩掉;标准化后,两个actor的更新步幅保持一致,训练稳定性提升非常明显。

4. 训练循环与关键超参数配置

代码实现上我习惯把环境、策略、buffer、trainer四层分开。主循环只做三件事:收集rollout、计算GAE、策略更新。下面是一个可直接改写的训练循环骨架。

4.1 主训练循环

for step in range(total_steps): state = env.reset() done = False while not done: op_idx, mc_idx = step_buffer.sample( op_actor, mc_actor, state, env.op_mask() ) next_state, reward, done, info = env.step(op_idx, mc_idx) rollout_buffer.push(state, op_idx, mc_idx, op_logp, mc_logp, reward, done) state = next_state if step % update_freq == 0: batch = rollout_buffer.sample() advantages, returns = compute_gae(batch.rewards, batch.masks, batch.values) advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8) update_policy(advantages, returns, batch)

主循环中env每完成一个完整调度就reset一次,rollout_buffer要跨多个episode累积,直到步数达到update_freq再做一次批量更新。Behnke中等实例通常一次完整调度有几十到上百步,rollout长度设置为512,大约覆盖三到六个完整episode。push时要同时记录两个动作的旧log_prob,因为更新ratio时两个actor各自需要旧概率。从matlab仿真工作流转过来的同学最容易漏掉这个字段,导致训练时报shape不匹配。

4.2 超参数与调整依据

我把调过的参数整理成一张表格,大多数FJSP实例可以从这个起点开始,再根据训练曲线微调。

参数名建议取值调整依据
gamma0.99调度长度有限,0.99足够覆盖远期收益
GAE lambda0.95过大高估回报,过小退化成TD(0)
clip0.2PPO默认值,调度场景下不用改
actor学习率3e-4与batch size联动,batch越大学习率越低
critic学习率1e-3critic收敛慢,给稍大学习率
ppo_epochs10超过10容易过拟合当前batch
rollout长度512至少覆盖3个完整调度过程
隐藏层宽度128x2中等规模实例够用,更大实例加到256
熵系数0.01FJSP合法动作少,熵太大会增加无效探索

学习率是最敏感的全局参数。如果训练前两百轮actor loss没有下降,我一般先把学习率降一个数量级,而不是增加rollout长度。rollout长度和batch size要配合:mini-batch切分后每个batch样本数最好不小于64,否则advantage标准化的均值和方差波动太大,更新方向不稳定。

4.3 环境接口与实例加载

环境部分保持了类似gym的接口,step返回的额外信息里要包含makespan和每个作业的完工时间表,训练回放和画甘特图都依赖这两项。甘特图单独用matplotlib画,不要放在环境里,否则绘图会阻塞训练循环。python环境用3.8以上版本加pytorch就能跑通,不需要额外安装调度相关的第三方库,整个训练脚本依赖很少。

class FJSPEnv: def reset(self): self.jobs = deepcopy(self.raw_jobs) for m in self.machines: m.next_avail_time = 0 return build_state(self, 0) def step(self, op_idx, mc_idx): # 先校验工序和机器的合法性 if not self.valid_action(op_idx, mc_idx): return self.state, -1.0, False, {"illegal": True} # 执行加工,更新机器空闲时间和作业进度 self.assign(op_idx, mc_idx) done = all(job.finished for job in self.jobs) return build_state(self, self.current_time), self.reward(), done, {}

step里先校验再执行,顺序不能反。非法动作返回-1并保持环境状态不变,这种设计能避免环境状态被污染,还能让策略从惩罚中学会避开非法动作。很多从matlab仿真转过来的实现习惯在校验失败时直接报错退出,这在训练循环里是不可接受的,一个epoch的非法样本会导致整个rollout作废。

4.4 训练日志记录

训练时我会每个更新周期记录三个量:平均reward、当前最优makespan、两个actor的熵均值。熵均值是判断策略是否坍缩最直接的指标,如果熵在几百轮内掉到接近0,说明策略过早确定,需要调大熵系数或降低学习率。最优makespan则用于保存checkpoint,调度问题里训练后期偶尔会跑出特别好的解,但平均性能一般,所以要同时保存“当前最优”和“最新策略”两份权重,避免评估时误用。

5. Behnke实例解析与收敛验证

Behnke系列fjs文件是FJSP领域常用的标准测试实例,资源里带了Behnke16到Behnke20等几个算例。文件格式沿用FJSP通用格式,第一行是作业数、机器数和平均备选机器数,后续每行定义一个作业。

5.1 fjs文件读取与校验

def load_fjs(path): with open(path, "r") as f: lines = f.readlines() first = list(map(int, lines[0].split())) num_jobs, num_machines = first[0], first[1] jobs = [] for line in lines[1:1 + num_jobs]: parts = list(map(int, line.split())) num_ops = parts[0] ops = [] idx = 1 for _ in range(num_ops): cand_count = parts[idx] idx += 1 cands = [] for _ in range(cand_count): m_id = parts[idx] - 1 # fjs里机器编号从1开始 p_time = parts[idx + 1] cands.append((m_id, p_time)) idx += 2 ops.append(cands) jobs.append(ops) return jobs, num_machines

这里最值得注意的就是m_id减1的操作。fjs文件为了可读性机器编号从1开始,python里所有矩阵索引起始为0,这个偏差如果不统一,训练时机器actor看到的掩码会全部偏移,采样到的动作没有对应机器,环境直接崩溃。校验方法很简单:加载后打印每台机器在所有作业里出现的次数,如果次数明显偏低或出现负索引,格式解析一定有错。

5.2 验证策略是否真的学到东西

单独跑一个episode得到漂亮的makespan不能说明策略学到了,随机种子好也可能撞出一个优解。我一般固定三组随机种子,训练相同步数,比较平均makespan和最优makespan,同时跑一条静态调度规则作为下限参考。如果multi-PPO在Behnke16到Behnke20上都稳定优于SPT规则,才认为策略泛化成立。

评估时有个常见陷阱:用训练时的采样策略去评估。PPO训练时actor输出经过概率采样,评估阶段需要把std置为0或直接取argmax。很多实现里忘了切换模式,导致测试结果比实际水平低10%到20%。评估调度结果时还要打印每个作业的完工时刻,检查是否有工序被直接分配到两台机器上,这是MDP建模错误时最常见的特征,出现这种情况基本可以判定为掩码或状态更新逻辑有bug。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询