2026/10/26 19:36:38

DQN车间排产:gym环境搭建、训练调参与评估避坑

DQN车间排产:gym环境搭建、训练调参与评估避坑 简介面向车间排产调度研究者和工程实践者这份Python代码以深度强化学习中的DQN算法求解生产排序问题并在gym风格模拟环境中训练与评估调度策略适合具备强化学习基础、希望将算法落地制造场景的中级读者。压缩包共3个Python文件整体约5KB分别覆盖DQN网络结构与参数更新、排产环境的状态动作奖励建模、训练评估运行流程体量轻便但模块划分清晰。已有848人学习。读者可获得可运行的DQN核心实现包括经验回放与目标网络等关键机制也能基于可扩展的环境定义替换奖励、约束和动作空间复现实验并对比不同排产策略作为工业调度优化的算法原型与二次开发起点。1. 别急着 pip install这套 DQN 排产环境翻车的从来不是算法某开发者拿到这份「深度强化学习 DQN 车间排产调度优化算法 gym 环境」的 Python 代码习惯性pip install torch gym numpy一把梭。import 不报错他以为成了训练脚本一跑reward 是一条直线episode 长度固定不变第二天早上看还是那张图。翻日志才发现 gym 的 step 返回值早就变了done永远不置真智能体在一个状态上打转。深度强化学习的 DQN 本体其实是整套代码里最好复现的部分难的是它脚下那层 gym 环境Python 3.9 和 3.11 装出来的 gym 不是一套 APItorch 的 CPU 轮子和 CUDA 轮子混装会让.cuda()直接抛断言numpy 2.x 又会把老代码里的类型别名清掉。这套东西适合两类人一类是想把 DQN 从倒立摆、CartPole 这类玩具环境挪到真实业务、拿车间排产练手的另一类是手上已经有一版 SPT、EDD 调度规则想验证学习型方法在 makespan 上到底能不能更省工期的人。装之前先把版本这件事定下来比装完再回滚省事得多。2. 环境定版Python 3.9 PyTorch 2.1 gym 0.26 怎么搭才不打架车间排产这类环境不像纯算法题它对运行时很敏感观测是个矩阵动作是离散的工单编号reward 由完工时间、延期惩罚、机器利用率几项加权拼出来。任何一层依赖版本对不上最先崩的不是训练速度而是你根本判断不了「学不会」到底是算法问题还是环境问题。所以第二步不是装是定版。2.1 先看对照表把「能一起活」的组合挑出来下面这套组合我反复用过装完能直接跑到收敛不用中途换轮子组件推荐版本可用区间取舍说明Python3.9.x3.8 – 3.103.8 部分新语法不支持3.11 有些科学计算轮子还没预编译PyTorch2.1.x1.13 – 2.32.x 的torch.load默认weights_only变了老代码要注意gym0.26.20.21 / 0.260.21 和 0.26 的 reset/step 签名完全不同二者只能选一numpy1.24.x1.21 – 1.262.x 删掉了一批类型别名老代码容易踩pandas2.0.x1.3读工单表、工时表用纯 numpy 也能替代matplotlib3.7.x3.5画训练曲线和甘特图无显示环境要切 Agg 后端几个选择我当时是这么权衡的Python 钉 3.9 是因为它同时满足 gym 0.26 和 PyTorch 2.1 的轮子覆盖向上到 3.10 也行但 3.11 之后 numpy、torch 的预编译包对齐得没那么整齐。gym 选 0.26.2 而不是更新的 gymnasium是因为这份 DQN 代码里的环境注册、reset(seed...)写法是按 0.26 铺的gymnasium 是 0.26 改名后的延续API 基本一致只是 import 名不同你完全可以二选一但不要两个都装——同时存在时import gym和import gymnasium会分别指向不同包环境类注册不上就是这么来的。PyTorch 我一般锁 2.1.x不用最新版。原因很实际DQN 的网络只有两三层全连接跑车间排产这种几千步一轮的小环境CPU 和 GPU 的差别远小于版本折腾的成本2.1 的 API 稳定、报错信息清楚够用。2.2 conda 建独立环境Python 版本要钉死别用系统自带的系统自带的 Python 别动那不是给你做实验用的。用 conda 拉一个独立环境把解释器版本写死# 创建独立环境解释器锁 3.9 conda create -n drl-sched python3.9 -y # 激活 conda activate drl-sched # 确认当前解释器确实是新环境里的那个 python -V # 期望输出 Python 3.9.x which python # 路径应包含 envs/drl-sched而不是 /usr/bin/python这几行的作用分别是conda create会在envs/下建一个独立目录装多少包都不会污染系统-y跳过交互确认方便写进脚本批量执行which python这一步别省我见过太多「装完还是旧版本」的情况本质是终端没激活成功或者 shell 初始化脚本把 PATH 覆盖回去了。如果which python指向的还不是envs/drl-sched先执行conda init bash再重开终端比手动改 PATH 稳妥。环境名我习惯用drl-sched这种带业务含义的同一台机器上还会并存drl-cartpole、rl-test名字含糊的话两个月后你自己都分不清哪个能跑。2.3 依赖写进 requirements.txt别一条条 pip一条条 pip 的问题是没法复现。第二个人照着你的聊天记录装装出来的版本大概率不一样。落成文件一次装完# 先把依赖清单写下来版本号用 锁死 cat requirements.txt EOF numpy1.24.4 pandas2.0.3 matplotlib3.7.3 gym0.26.2 torch2.1.2 tqdm4.66.1 EOF # 一次性安装失败就整单回滚不会装一半 pip install -r requirements.txt -i 你的内网或镜像源地址和的区别在这里很关键DQN 代码对 numpy 的依赖主要集中在数组形状和 dtype 上一旦跨到 2.x 就会出现np.float、np.bool找不到的报错而这类报错往往藏在第三方包里翻起来很烦。-i后面写你所在环境能通的源地址内网机器就换成内网源别硬连公网。装完之后用pip list | grep -E torch|gym|numpy核一遍三个版本号和你写的一致再往下走。2.4 内网或离线机器把轮子先下到本地不少车间现场的机器是不通外网的这种情况下pip install会卡在解析阶段。做法是在一台能联网的同架构机器上把包下载下来再拷过去# 在联网机器上把依赖和它们的依赖全部下载到 wheels 目录 pip download -r requirements.txt -d ./wheels --python-version 39 --platform manylinux2014_x86_64 --only-binary:all: # 拷贝到离线机器后从本地目录安装不走网络 pip install --no-index --find-links./wheels -r requirements.txt--python-version和--platform必须和你目标机器一致否则下到的轮子型号不对装的时候会提示is not a supported wheel on this platform。--only-binary:all:是为了避免下到源码包——源码包在离线机上编译缺编译器就直接失败了。这一步是血泪经验我第一次做内网部署时没加这两个参数拷过去 40 多个包一半是 tar.gz当场返工。3. 装依赖与跑通 gym 车间环境从 import 到 env.reset() 返回什么依赖装完只是把材料备齐能不能跑通取决于两件事torch 有没有装对轮子以及 gym 环境的 reset/step 签名有没有跟你手上的代码对齐。这一章把这两件事拆开做。3.1 装 PyTorch先确认有没有 CUDA再决定装哪个轮子别上来就装 CUDA 版。先看机器# 有 NVIDIA 卡且驱动正常这条命令才会有输出 nvidia-smi有输出、且右上角显示的 CUDA Version 不低于 11.8才可以考虑 GPU 轮子命令不存在或者报错就直接装 CPU 版别折腾。CPU 版对这套排产环境完全够用# CPU 版适合没有独显的机器安装体积小不会因为驱动问题报错 pip install torch2.1.2 --index-url 你的源地址 # 或者 CUDA 12.1 版确认过 nvidia-smi 之后再装 # pip install torch2.1.2 --index-url cu121 的源地址装完立刻验证这一步别跳import torch print(torch.__version__) # 期望 2.1.2 print(torch.cuda.is_available()) # CPU 机器上应为 False属正常 x torch.randn(2, 3) print(x.device) # 应为 cputorch.cuda.is_available()返回 False 不代表装坏了只代表没有可用 GPU 或者装的是 CPU 轮子。真正要警惕的是反过来装了 CUDA 轮子但驱动版本偏低import 阶段就会抛The NVIDIA driver on your system is too old这种时候不用怀疑代码降级装 CPU 版最快。3.2 gym 0.26 还是 gymnasium这个选择决定你后面要改多少行这一点值得单独说。0.21 时代env.step(a)返回四元组(obs, reward, done, info)env.reset()只返回一个 obs0.26 之后变成五元组(obs, reward, terminated, truncated, info)reset()返回(obs, info)。这份 DQN 代码如果按 0.26 写你装 0.21 就会在第一次 step 时炸掉报too many values to unpack。# 二选一不要都装 pip install gym0.26.2 # 如果你想用 gymnasiumAPI 与 0.26 基本一致只是包名不同 # pip install gymnasium0.29.1判断标准很简单打开资源包里的环境文件搜def step看它return了几个值。五个就装 0.26 或 gymnasium四个就装 0.21。改代码和改环境哪个成本低选哪个——我的习惯是统一往 0.26 靠因为后面的评估、渲染、wrapper 生态都在这个方向上。选 gymnasium 的话环境文件里的import gym要全部换成import gymnasium as gym注册函数也换成gymnasium.envs.registration.register其他基本不用动。3.3 把车间排产环境注册进 gymid、reset、step 三处对齐自定义环境不是gym.make(CartPole-v1)那种内置的得先注册。注册代码一般放在包入口或者训练脚本顶部import gym from gym.envs.registration import register register( idJobShopScheduling-v0, # 训练脚本里 gym.make 用这个 id必须完全一致 entry_pointenvs.job_shop:JobShopEnv, # 指向资源包里环境类的模块路径:类名 max_episode_steps200, # 兜底上限防止策略卡死时无限循环 kwargs{n_jobs: 10, n_machines: 5}, # 车间规模按实际工单量改 )三个参数各有讲究。id建议带版本后缀-v0以后改了状态定义就升到-v1避免旧模型加载到新环境上得出莫名其妙的评估结果。entry_point的冒号前面是模块路径、后面是类名路径是相对你运行脚本的目录算的所以训练脚本要在项目根目录下执行否则会报ModuleNotFoundError。max_episode_steps一定要给排产环境里如果智能体反复选同一个已经完成的工单环境自己不判结束这轮 episode 就永远不终止表现出来就是训练卡在第 1 轮不动。kwargs里的n_jobs、n_machines决定了观测维度和动作维度。10 工单 5 机器的规模动作空间一般是Discrete(10)或者Discrete(50)工单×机器的组合改这两个数之后DQN 的输出层也得跟着改后面第 4 章会讲。3.4 一次 smoke test五行代码确认环境没装歪跑训练之前先做冒烟测试这一步能挡掉八成的低級问题import numpy as np import gym import envs.job_shop # 触发环境模块导入确保 register 已执行 env gym.make(JobShopScheduling-v0) obs, info env.reset(seed42) # 0.26 起 reset 返回二元组 print(obs shape :, np.shape(obs)) # 期望一维数组长度与状态编码一致 print(action sp :, env.action_space) # 期望 Discrete(n) print(obs dtype :, np.asarray(obs).dtype) # 期望 float32int 会导致后面网络报错 total 0.0 for _ in range(10): a env.action_space.sample() # 随机策略只验证通路不验证效果 obs, r, terminated, truncated, info env.step(a) total r if terminated or truncated: break print(cum reward:, round(total, 3)) print(info keys :, list(info.keys())) # 期望能看到 makespan / tardiness 之类的键 env.close()这段代码验证四件事观测的形状和 dtype 对不对、动作空间是不是离散的、step 能不能正常返回五元组、info 里有没有排产相关的统计字段。obs dtype如果是 int64说明环境的状态编码没做归一化DQN 的线性层吃 int 会报类型错得在环境里astype(np.float32)。info里没有makespan这类字段的话第 6 章的评估就没法做趁早补上。冒烟测试通过环境这层就算落地了。4. 配置 DQN 训练入口状态维度、超参和模型落盘环境通了之后卡人的地方从「装不上」变成「装上了但学不会」。这一章按配置顺序过网络维度、超参取值、启动参数、模型保存。每一处都是改一个数就可能让训练白跑几万步的位置。4.1 状态和动作维度对齐网络输出层别写死DQN 的网络结构本身很简单坑全在维度上import torch import torch.nn as nn class QNetwork(nn.Module): def __init__(self, state_dim: int, action_dim: int, hidden: int 128): super().__init__() self.net nn.Sequential( nn.Linear(state_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, action_dim), # 输出维度必须等于可选动作数 ) def forward(self, x: torch.Tensor) - torch.Tensor: return self.net(x) # 维度从环境里读绝对不要手写 64、8 这种数字 import gym env gym.make(JobShopScheduling-v0) state_dim env.observation_space.shape[0] action_dim env.action_space.n print(state_dim, action_dim)state_dim从observation_space.shape[0]取action_dim从action_space.n取这两行是防止维度错位的唯一可靠做法。我见过的最典型的翻车是改了n_jobs之后忘了改输出层网络照常训练loss 也在降但 argmax 出来的动作编号有一半超出动作空间env.step直接抛IndexError——而且是在训练了半小时之后才抛。hidden128对 10 工单 5 机器的规模是够的。状态维度在几十到一两百之间时128 或 256 差别不大如果你的排产问题工单量上到几百观测维度会迅速膨胀这时候优先做状态特征压缩比如只编码剩余工序数和机器负载而不是把 hidden 加到 1024。另外注意forward的输入 dtype 必须是 float32从 numpy 数组转过来的时候显式指定s torch.as_tensor(obs, dtypetorch.float32, devicedevice).unsqueeze(0)unsqueeze(0)是补 batch 维因为网络按 batch 输入设计单个观测进去要凑成(1, state_dim)。4.2 超参表gamma、buffer、target 更新、epsilon 怎么定超参没有标准答案但排产场景有几条经验值可以照抄参数建议值调整方向说明gamma0.950.90 – 0.99一轮排产几十步0.99 会让远处回报权重过大容易发散lr1e-33e-4 – 1e-3配合 Adam高于 1e-3 常见 loss 抖动buffer_size500002e4 – 1e5小于 1e4 时样本相关性太强学不动batch_size6432 – 128CPU 上 64 是速度和质量的分界点target_update500200 – 1000步为单位太频繁等于没有目标网络eps_start / end1.0 / 0.05—探索率低于 0.05 后基本靠已有策略eps_decay_steps200001e4 – 5e4占总步数的 10% 左右比较合适gamma 这里单独提一句很多教程默认 0.99那是给 Atari 那种上千步一局的环境用的。车间排产一轮通常几十步就结束折扣因子拉太高早期随机探索产生的噪声回报会被放大表现出来就是 Q 值一路上冲然后 nan。0.95 是我在排产场景里比较稳的起点。target_update用步数而不是 episode 计数因为 episode 长度会随策略变化按局更新节奏不稳。4.3 训练脚本的启动参数seed、device、save-dir参数别硬编码在脚本里用 argparse 抽出来方便对比实验import argparse def parse_args(): p argparse.ArgumentParser() p.add_argument(--env-id, defaultJobShopScheduling-v0) p.add_argument(--seed, typeint, default42) p.add_argument(--device, defaultcpu, choices[cpu, cuda]) p.add_argument(--total-steps, typeint, default200_000) p.add_argument(--gamma, typefloat, default0.95) p.add_argument(--lr, typefloat, default1e-3) p.add_argument(--buffer-size, typeint, default50_000) p.add_argument(--batch-size, typeint, default64) p.add_argument(--target-update, typeint, default500) p.add_argument(--eps-decay, typeint, default20_000) p.add_argument(--save-dir, default./checkpoints/run-001) return p.parse_args()启动就一行python train_dqn.py --env-id JobShopScheduling-v0 --device cpu --seed 42 \ --total-steps 200000 --save-dir ./checkpoints/run-001--save-dir一定要带实验编号别都往./checkpoints里写。同一个目录覆盖三次之后你根本分不清哪个模型对应哪组超参这是最没必要的重复劳动。--device显式传不要在代码里if torch.cuda.is_available()自动判断——自动判断在多人共用的机器上会给你惊喜别人占着卡的时候你这次跑的是 CPU速度差十倍但日志里看不出来。4.4 断点续训与模型落盘别把三小时的训练赌在一次运行上保存的时候把 online 网络、target 网络、优化器状态、当前步数一起存import os import torch def save_ckpt(path, net, target_net, optim, step): os.makedirs(os.path.dirname(path), exist_okTrue) torch.save({ step: step, online: net.state_dict(), target: target_net.state_dict(), optim: optim.state_dict(), # 少了它续训后学习率状态会重置 }, path) def load_ckpt(path, net, target_net, optim, devicecpu): ckpt torch.load(path, map_locationdevice) net.load_state_dict(ckpt[online]) target_net.load_state_dict(ckpt[target]) optim.load_state_dict(ckpt[optim]) return ckpt[step] # 续训从这一步接着算 epsilon 衰减三个state_dict缺一不可。只存 online 网络的话续训时 target 网络是随机初始化的前几百步的 TD 目标全是噪声曲线会突然掉下去再爬回来很容易被误判成「模型崩了」。step也必须存因为 epsilon 是按全局步数衰减的不恢复步数就等于重新探索一遍。保存频率建议每 5000 步一次同时保留最近三个磁盘占用可控也不至于一次异常退出全丢。5. 避坑与排查五个让训练曲线一直躺平的常见问题这一章是我自己和身边人实际踩过的按「现象 → 原因 → 解决」记遇到对得上的直接照做。5.1 现象第一次 step 就报too many values to unpack原因环境按 0.26 写返回五元组但训练循环还是 0.21 的四元组写法obs, r, done, _ env.step(a)。解决全局搜env.step统一改成obs, r, terminated, truncated, info env.step(a)并且把原来判断done的地方改成done terminated or truncated。这两件事必须一起改只改解包不改判断episode 会在超时的时候不结束训练步数虚高但有效样本很少。顺手把env.reset()也改成obs, info env.reset(seed...)0.26 下少接一个返回值同样是解包错误。5.2 现象reward 一直不涨argmax 永远是同一个动作原因状态没做归一化。车间排产的原始观测里工单剩余工时可能是 0–480 这种量级机器负载率是 0–1两者差几百倍进网络之后大数值特征直接主导梯度小特征等于没输入。解决在环境的_get_obs里做缩放工时除以最大工时负载率保持 0–1交期转成「剩余时间/总工期」的比例。归一化之后再跑同样的超参下 Q 值分布会明显散开。如果不想改环境加一层gym.ObservationWrapper做 min-max 归一也行但要注意统计量得从训练集算别用测试集的数据。5.3 现象loss 跑到几千步突然变成 nan之后再也不恢复原因reward 量纲太大加上 gamma 偏高Q 值正反馈放大。排产环境里 makespan 动辄几百如果直接当 reward 用或者取负TD 目标会迅速膨胀。解决三件事一起做——reward 缩放比如除以基准 makespan 或者乘 0.01、损失换成nn.SmoothL1Loss()Huber对大误差不敏感、梯度裁剪torch.nn.utils.clip_grad_norm_(net.parameters(), 10)。我一般是先上 Huber还压不住再动 reward 量纲最后才考虑降 gamma。另外 nan 出现后不要试图从那个 checkpoint 续训权重已经废了回退到上一个正常存档重来。5.4 现象每次重跑结果差很多没法对比两个超参哪个好原因只设了random.seed()numpy、torch、环境内部的随机源都没管。排产环境里工单到达顺序、加工时间扰动往往各自用了独立的随机数生成器。解决写一个统一的种子函数在创建环境和创建网络之前各调一次import random import numpy as np import torch def seed_everything(seed: int 42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) # 无 GPU 时这行无副作用 torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark False # 两个位置都要调env.reset(seedseed) 之前以及网络初始化之前环境那边还要显式传env.reset(seedseed)光靠全局种子管不到环境内部的生成器。做完这步同参数两次运行的曲线应该基本重合重合不了说明环境里还有没受控的随机源得去翻环境代码。5.5 现象matplotlib 保存图片时报RuntimeError: Invalid DISPLAY variable原因在没有图形界面的机器远程终端、容器上matplotlib 默认想连显示服务。解决在 import pyplot 之前切后端import matplotlib matplotlib.use(Agg) # 必须在 import pyplot 之前 import matplotlib.pyplot as plt顺序错了不生效这是最容易忽略的细节。另外中文标签会显示成方框补一行plt.rcParams[font.sans-serif] [DejaVu Sans]并避免在图上写中文比折腾字体文件省事。6. 装完怎么验证跑通三个指标确认 DQN 真在学排产训练日志里 loss 在降不代表模型在学排产。DQN 完全可能把 Q 值整体平移而 argmax 不变loss 看着很漂亮策略跟随机没区别。所以装完之后我固定跑一套评估三个指标一起看缺一个都不下结论。评估时关掉探索走纯贪心import numpy as np import torch import gym import envs.job_shop torch.no_grad() def evaluate(net, env_id, episodes20, devicecpu): env gym.make(env_id) net.eval() # 切评估模式 rets, makespans [], [] for ep in range(episodes): obs, _ env.reset(seed1000 ep) # 固定评估种子保证每次可比 done, ret False, 0.0 while not done: s torch.as_tensor(obs, dtypetorch.float32, devicedevice).unsqueeze(0) a int(net(s).argmax(dim1).item()) # 贪心不加 epsilon 噪声 obs, r, term, trunc, info env.step(a) ret r done term or trunc rets.append(ret) makespans.append(info.get(makespan, np.nan)) env.close() return np.mean(rets), np.std(rets), float(np.nanmean(makespans))episodes20是为了让均值有意义单局的随机性太大评估种子固定成1000ep保证昨天和今天的模型是在同一批工单上比不然涨了跌了都说不清。net.eval()一定要有虽然这份网络没有 Dropout 和 BatchNorm但养成习惯以后换结构不会漏。三个指标这样看指标看什么合格线参考评估平均回报训练中途每 5000 步跑一次看趋势后 1/3 训练明显高于前 1/3且方差收窄平均 makespan和 SPT、EDD 这类规则基线对比不要求全面胜出但至少接近不能差出 50%动作分布统计贪心策略下各工单被选中的频次不能集中在单一动作上分布应随状态变化第二个指标是最容易被忽略也最有说服力的。规则基线不需要写多复杂SPT 就是每步选剩余加工时间最短的工单十几行代码。如果 DQN 训完还不如 SPT那大概率不是训练不够而是 reward 设计和目标不一致——比如 reward 里延期惩罚权重给得太高模型学会了「优先处理急单」而牺牲整体 makespan这时候该调的是 reward 权重不是继续加训练步数。第三个指标查起来很快跑一遍评估把每次的a记下来做个np.bincount如果 90% 都落在同一个编号上说明策略退化了多半是 5.2 里的归一化问题或者探索衰减太快。我第一次跑这套环境时训了六万步曲线一路向上评估出来却比 SPT 差一截。查了半天发现是评估时忘了固定种子每次都在不同工单上比涨跌全是噪声。从那以后我装完任何 DQN 环境都会先跑 20 局固定种子的贪心评估再决定要不要开长训练——省下来的时间比省下来的电费实在得多。这份代码和 gym 环境我整理在同一个包里按第 2 章的版本表装一遍第 3 章的冒烟测试过了再动训练脚本能少走不少弯路。希望帮到你。本文还有配套的精品资源点击获取