☰
基于DQN的三维在线装箱实战:从建模到调参避坑
2026/10/5 14:56:13 网站建设 项目流程

简介:这份资源面向计算机、人工智能及相关专业的学生与开发者,提供基于DQN深度强化学习解决三维在线装箱问题的完整Python实现,适用于毕业设计、期末大作业与课程设计等场景。项目将深度Q网络与三维装箱决策相结合,涵盖环境建模、状态与动作设计、网络训练及评估等关键环节,对想入门强化学习落地应用的读者具有较高参考价值。压缩包共10个文件,约5.64MB,以py源码为主,辅以pth模型权重、png与fig1图表、md说明文档,分别对应训练脚本、评估脚本、容器建模、绘图与使用说明,结构清晰、注释充分,新手也能较快理解整体流程。目前已有195人学习下载。读者可获得一套可直接部署运行的高分项目源码与文档说明,便于快速复现实验、理解DQN在组合优化问题中的建模思路,并在此基础上完成二次开发或论文撰写。

1. 三维在线装箱为什么让 DQN 有用武之地

电商仓配现场最头疼的不是「箱子不够大」,而是「箱子来得太快」。传送带上的包裹一个接一个,系统必须在几百毫秒内决定它塞进哪个容器、放在什么位置,而且不能回头重排——这就是三维在线装箱(3D Online Bin Packing)的残酷之处:物品序列未知、到达即决策、空间三维约束、目标是最小化容器数量或最大化空间利用率。传统 First-Fit、Best-Fit 这类贪心规则在离线场景够用,但在线场景下它们只看当前物品,缺乏对「未来空间形态」的预判,很容易把大件物品逼到无箱可放。

深度强化学习(Deep Reinforcement Learning)之所以被引入,是因为它能把「当前容器状态 + 待放物品特征」映射成一个长期回报最优的放置动作,而不是只贪当前一步。DQN(Deep Q-Network)作为 value-based 方法的代表,用神经网络拟合 Q(s,a),配合经验回放和目标网络,能在离散动作空间里稳定训练。三维装箱的动作空间天然离散——选哪个容器、沿哪个轴、放在哪个候选点,都可以编码成有限动作集,这正是 DQN 的舒适区。

这篇笔记面向三类人:想用 Python 复现一个 DQN 装箱项目的学生、需要给仓储调度加智能决策的工程师、以及评估「深度强化学习到底能不能落地装箱」的技术负责人。我会把状态表示、动作设计、奖励函数、训练循环、评估指标和踩坑记录全部摊开,代码可直接跑,参数可调,边界也说清楚。读完你应该能判断:这个方向值不值得投入,以及第一版怎么搭。

2. 把三维装箱建模成 DQN 可训练的问题

2.1 状态、动作、奖励三件套怎么定义

DQN 不是拿来就能套的,装箱问题必须先转成标准 MDP。我一般这样切:

状态 s:容器集合的占用情况 + 当前待放物品的尺寸。容器用三维体素网格表示,每个格子 0/1 表示占用;物品用 (l, w, h) 表示。为了控制输入维度,体素分辨率通常取 10×10×10 或 16×16×16,太大网络吃不消,太小精度崩。

动作 a:离散化为「选择第 k 个容器 + 选择第 m 个候选放置点」。候选点来自当前容器的可放置角点(corner points),常见做法是维护一个可行点集合,每次放置后更新。动作总数 = 容器数上限 × 每容器候选点数,训练时用掩码把非法动作屏蔽掉。

奖励 r:这是最影响收敛的部分。我的经验是分三段:

  • 成功放入:+1,再叠加一个基于「剩余空间紧凑度」的 shaping 奖励,鼓励贴边放置;
  • 放不下、开新容器:-0.5,惩罚容器数量增长;
  • 非法动作:-1 并终止该 episode。
import numpy as np class BinPackingEnv: def __init__(self, bin_size=(10,10,10), max_bins=5): self.bin_size = np.array(bin_size) self.max_bins = max_bins self.reset() def reset(self): # 每个容器用三维体素表示,0 表示空 self.bins = np.zeros((self.max_bins, *self.bin_size), dtype=np.int8) self.used_bins = 0 self.current_item = None return self._get_state() def _get_state(self): # 状态 = 容器占用体素 + 当前物品尺寸归一化 item = self.current_item if self.current_item is not None else np.zeros(3) return np.concatenate([self.bins.flatten(), item / self.bin_size]) def step(self, action): bin_idx, corner_idx = divmod(action, self.max_corners) # 非法动作判定:容器未启用或角点越界 if bin_idx > self.used_bins: return self._get_state(), -1.0, True, {} placed = self._try_place(bin_idx, corner_idx) if placed: reward = 1.0 + self._compactness_bonus(bin_idx) done = False else: self.used_bins += 1 reward = -0.5 done = self.used_bins >= self.max_bins return self._get_state(), reward, done, {}

这段代码的关键点:_get_state把体素和物品尺寸拼成一个一维向量,直接喂给全连接网络;step里用divmod把一维动作解码成「容器 + 角点」,这是离散动作空间最省事的编码方式。_compactness_bonus是可选的空间紧凑奖励,我通常用「放置后新增接触面数量 / 6」来算,贴边越多奖励越高。参数上,bin_size和max_bins要按你的实际场景定,max_corners建议设成 20~50,太小会丢可行解,太大动作空间爆炸。

2.2 网络结构与经验回放的工程取舍

DQN 的网络不需要多深。装箱状态是结构化体素,我用三层全连接(256-256-动作数)就够,卷积反而容易过拟合小样本。输出维度等于动作总数,每个 Q 值对应一个「容器+角点」组合。

经验回放池建议 50000~100000 条,太小训练不稳定,太大早期垃圾样本拖慢收敛。目标网络更新用软更新(tau=0.005)比硬更新每 C 步同步更平滑,这是我踩过坑之后的固定选择。

import torch import torch.nn as nn import random from collections import deque class QNetwork(nn.Module): def __init__(self, state_dim, action_dim, hidden=256): 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): return self.net(x) class ReplayBuffer: def __init__(self, capacity=100000): self.buffer = deque(maxlen=capacity) def push(self, s, a, r, s_next, done): self.buffer.append((s, a, r, s_next, done)) def sample(self, batch_size): batch = random.sample(self.buffer, batch_size) s, a, r, s_next, done = zip(*batch) return (torch.FloatTensor(np.array(s)), torch.LongTensor(a), torch.FloatTensor(r), torch.FloatTensor(np.array(s_next)), torch.FloatTensor(done))

QNetwork的state_dim由体素分辨率决定,10×10×10×5 个容器就是 5000 维,加上物品 3 维,输入约 5003 维,第一层 256 神经元参数量约 128 万,单卡完全跑得动。ReplayBuffer用deque自动淘汰旧样本,sample返回的是张量,省去训练循环里反复转换。注意done要转成 float,否则 TD 目标计算会报类型错误——这个坑我见过太多次。

2.3 训练循环与 epsilon 衰减策略

训练循环是 DQN 的骨架:采样动作、存回放、抽 batch、算 TD 误差、反向传播、软更新目标网络。epsilon 从 1.0 线性衰减到 0.05,衰减步数占总训练步数的 30%~50%,太慢探索不足,太快早熟收敛。

def train(env, episodes=2000, batch_size=64, gamma=0.99, lr=1e-4, tau=0.005, eps_start=1.0, eps_end=0.05, decay_steps=50000): state_dim = env.bins.size + 3 action_dim = env.max_bins * env.max_corners q_net = QNetwork(state_dim, action_dim) target_net = QNetwork(state_dim, action_dim) target_net.load_state_dict(q_net.state_dict()) optimizer = torch.optim.Adam(q_net.parameters(), lr=lr) buffer = ReplayBuffer() steps = 0 for ep in range(episodes): state = env.reset() done = False while not done: eps = max(eps_end, eps_start - steps / decay_steps * (eps_start - eps_end)) if random.random() < eps: action = random.randint(0, action_dim - 1) else: with torch.no_grad(): q = q_net(torch.FloatTensor(state).unsqueeze(0)) action = q.argmax().item() next_state, reward, done, _ = env.step(action) buffer.push(state, action, reward, next_state, float(done)) state = next_state steps += 1 if len(buffer.buffer) >= batch_size: s, a, r, s_next, d = buffer.sample(batch_size) q_values = q_net(s).gather(1, a.unsqueeze(1)).squeeze(1) with torch.no_grad(): next_q = target_net(s_next).max(1)[0] target = r + gamma * next_q * (1 - d) loss = nn.MSELoss()(q_values, target) optimizer.zero_grad() loss.backward() # 梯度裁剪,防止装箱稀疏奖励下梯度爆炸 nn.utils.clip_grad_norm_(q_net.parameters(), 10.0) optimizer.step() # 软更新目标网络 for tp, p in zip(target_net.parameters(), q_net.parameters()): tp.data.copy_(tau * p.data + (1 - tau) * tp.data) return q_net

几个参数必须说清楚:gamma=0.99是因为装箱是长序列决策,折扣太短会只看眼前;lr=1e-4比常规 1e-3 小,装箱奖励稀疏,大学习率容易震荡;clip_grad_norm_的 10.0 是经验值,不加的话稀疏奖励下梯度偶尔会炸。decay_steps=50000对应大约 500~1000 个 episode,具体看你每 episode 的步数。

3. 从零跑通:环境、数据与训练脚本

3.1 Python 环境与依赖安装的稳妥路径

别一上来就折腾最新版。我固定用 Python 3.8~3.10,PyTorch 装 CPU 版就够跑通,有卡再换 CUDA 版。numpy 是刚需,gym 可选用来自定义环境,但我更推荐自己写 Env 类,少一层抽象少一堆坑。

# 建议用 conda 建独立环境,避免污染系统 Python conda create -n dqn_bin python=3.9 -y conda activate dqn_bin # 安装核心依赖,CPU 版 PyTorch 足够验证算法 pip install torch==2.0.1 numpy==1.24.3 matplotlib==3.7.1 tqdm # 验证安装 python -c "import torch, numpy; print(torch.__version__, numpy.__version__)"

torch==2.0.1和numpy==1.24.3是验证过兼容的组合,numpy 2.x 和部分 torch 版本有 ABI 冲突,新手最容易在这里翻车。matplotlib用来画训练曲线,tqdm看进度。如果你用 VS Code,记得把解释器切到dqn_bin环境,否则ModuleNotFoundError会让你怀疑人生。

3.2 物品序列生成与数据集构造

在线装箱的关键是「序列未知」,但训练需要可复现的数据。我一般用随机尺寸 + 固定随机种子生成物品流,尺寸分布按实际场景调——电商包裹偏扁,工业件偏方。

import numpy as np def generate_items(n_items=200, seed=42, size_range=(1, 5)): """生成物品序列,尺寸为整数体素单位""" rng = np.random.RandomState(seed) items = rng.randint(size_range[0], size_range[1] + 1, size=(n_items, 3)) # 按体积降序,模拟大件先到的压力测试场景 items = items[np.argsort(-items.prod(axis=1))] return items def evaluate_policy(policy_fn, items, env): """评估策略:返回使用的容器数量和空间利用率""" env.reset() for item in items: env.current_item = item state = env._get_state() action = policy_fn(state) _, _, done, _ = env.step(action) if done: break total_volume = items.prod(axis=1).sum() used_volume = env.bins[:env.used_bins + 1].sum() return env.used_bins + 1, used_volume / total_volume

generate_items里按体积降序是有意为之:在线装箱最怕大件后到,降序序列能压测策略的鲁棒性。size_range要小于bin_size,否则永远放不下。evaluate_policy返回两个指标——容器数和利用率,前者是主目标,后者看空间浪费。注意used_volume用体素和近似,精度受分辨率限制,想要精确值得单独记录每个物品的实际体积。

3.3 训练、保存与加载的完整命令

把前面几块拼起来,训练脚本控制在 150 行以内,方便调试。

if __name__ == "__main__": env = BinPackingEnv(bin_size=(10,10,10), max_bins=5) env.max_corners = 30 # 每容器候选角点数 items = generate_items(n_items=200) q_net = train(env, episodes=1500, batch_size=64) # 保存模型 torch.save(q_net.state_dict(), "dqn_binpack.pth") # 加载并评估 loaded = QNetwork(env.bins.size + 3, env.max_bins * env.max_corners) loaded.load_state_dict(torch.load("dqn_binpack.pth")) loaded.eval() def greedy_policy(state): with torch.no_grad(): return loaded(torch.FloatTensor(state).unsqueeze(0)).argmax().item() bins_used, utilization = evaluate_policy(greedy_policy, items, env) print(f"容器数: {bins_used}, 利用率: {utilization:.3f}")

episodes=1500是能跑出可用策略的下限,想更稳就 3000 起。torch.save存 state_dict 而不是整个模型,加载时先实例化同结构网络再load_state_dict,这是 PyTorch 的标准姿势,跨设备迁移也不会出问题。评估时loaded.eval()必须加,否则 BatchNorm/Dropout 会干扰结果——虽然这个网络没有,但养成习惯没坏处。

4. 避坑与排查:装箱 DQN 最容易翻车的五处

4.1 奖励稀疏导致 Q 值全塌成常数

现象:训练几千步后,所有动作的 Q 值几乎一样,策略退化成随机选。原因:成功放入才给 +1,episode 前期大量非法动作拿 -1,正样本极少,网络学不到区分度。解决:加 shaping 奖励,比如放置后接触面数量、剩余空间紧凑度;同时把非法动作的 -1 改成 -0.1 并只终止当前步不终止 episode,让网络有更多机会探索合法动作。

4.2 体素分辨率选太大,显存和收敛双崩

现象:把 bin_size 设成 32×32×32,训练一个 episode 要几十秒,loss 还降不下去。原因:状态维度随分辨率三次方增长,32³×5 容器就是 16 万维输入,全连接第一层参数量爆炸。解决:分辨率控制在 10~16,或者改用「已放置物品列表 + 角点集合」的稀疏表示,状态维度能降一个数量级。装箱不需要体素级精度,角点表示足够。

4.3 动作掩码没做,网络一直在学非法动作

现象:训练曲线剧烈震荡,评估时经常选到未启用容器。原因:动作空间里大量非法组合(容器未启用、角点越界),网络被迫从负样本里学,效率极低。解决:在step前对 Q 值加掩码,非法动作的 Q 置为 -inf,采样时只在合法动作里选。这一步能让收敛速度提升 3~5 倍,是我认为最值得加的工程优化。

4.4 目标网络更新太频繁,TD 目标追着自己跑

现象:loss 不降反升,Q 值发散到几百。原因:目标网络和在线网络同步太快,TD 目标一直在变,相当于自己追自己。解决:用软更新 tau=0.005,或者硬更新每 1000 步同步一次。我倾向软更新,曲线更平滑。同时检查 gamma 是不是设太大,0.99 以上配合稀疏奖励容易发散。

4.5 评估指标只看容器数,忽略在线约束

现象:离线评估容器数很少,上线后效果差。原因:训练时物品序列固定且可打乱,评估时用了未来信息,违反在线约束。解决:评估必须严格按到达顺序逐个决策,禁止回看或重排。另外要测不同随机种子下的方差,单次结果没有说服力。我一般跑 5 个种子取均值和标准差,方差大的策略不敢上线。

5. 让 DQN 装箱真正可用的三个进阶技巧

第一版跑通之后,决定这个方案能不能落地的往往不是算法本身,而是几个工程细节。下面三个是我反复验证过、收益最明显的。

技巧一:用 Double DQN 压住 Q 值高估。标准 DQN 的max操作会系统性高估 Q 值,装箱这种动作空间大的场景尤其明显。改法很简单,把目标计算里的动作选择交给在线网络、动作评估交给目标网络:

with torch.no_grad(): # Double DQN: 在线网络选动作,目标网络评估 next_actions = q_net(s_next).argmax(1, keepdim=True) next_q = target_net(s_next).gather(1, next_actions).squeeze(1) target = r + gamma * next_q * (1 - d)

这一行改动通常能让评估容器数再降 5%~10%,几乎零成本。

技巧二:优先经验回放(PER)替代均匀采样。装箱的稀疏奖励让「成功放入」的样本极其珍贵,均匀采样会淹没它们。PER 按 TD 误差给样本加权,高误差样本多抽。实现上给 ReplayBuffer 加优先级数组和重要性采样权重,代码量增加约 30 行,收敛速度提升明显。注意 beta 要从 0.4 退火到 1.0,否则偏差修正不充分。

技巧三:课程学习,从简单序列过渡到困难序列。一上来就训大件先到的困难序列,网络很难起步。我的做法是分三阶段:第一阶段物品尺寸小且均匀、容器充足;第二阶段引入大件;第三阶段才用降序压力测试。每阶段训练 500 episode,策略迁移时只保留网络权重、重置回放池。这个技巧让我的项目从「训不出来」变成「稳定收敛」,血泪经验。

验证方法上,除了容器数和利用率,我还会看两个指标:决策延迟(单步推理时间,在线场景要求 < 100ms)和策略稳定性(连续 100 episode 的容器数标准差)。延迟用time.perf_counter()包住推理那几行就能测,稳定性看训练曲线尾部是否平坦。

技巧改动量预期收益适用阶段
Double DQN3 行容器数 -5%~10%第一版就跑通后
优先经验回放~30 行收敛步数 -30%训练慢时
课程学习~50 行从训不出到稳定困难序列场景

最后说个习惯:我每次改完奖励函数或网络结构,都会先用 200 episode 的小规模跑一遍,看 loss 曲线和评估指标的趋势,确认方向对了再上全量训练。直接上 3000 episode 然后发现奖励设计错了,那种后悔药没地方买。装箱 DQN 不难,难的是耐心调那几个参数和奖励项,希望帮到你。

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

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

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

立即咨询