news 2026/10/5 1:04:37

三维在线装箱DQN实战:从环境设计到训练避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
三维在线装箱DQN实战:从环境设计到训练避坑

简介:这份资源面向计算机、人工智能及相关专业的学生与开发者,提供基于DQN深度强化学习解决三维在线装箱问题的完整Python实现,适合用作毕业设计、期末大作业或课程设计的高分参考方案。压缩包共10个文件,约5.64MB,包含5个py源码文件、1个pth模型权重、1个md说明文档以及2个png和1个fig1图表文件,分别承担训练与评估逻辑、网络参数保存、项目说明和结果可视化等用途。项目代码注释详尽,新手也能理解,并配有文档说明,部署后即可运行。目前已有195人学习下载。读者可获得一套可直接复用的三维在线装箱DQN方案,涵盖环境建模、网络训练、模型评估与结果绘图等环节,便于快速理解深度强化学习在组合优化问题中的落地思路,也能在此基础上进行二次开发或撰写论文实验部分。

1. 三维在线装箱为什么让 DQN 从「能跑」变成「能用」

三维在线装箱(3D Online Bin Packing)和离线装箱最大的区别在于:货物到达顺序不可预知,你必须在每个箱子到达的瞬间决定它放进哪个容器、什么姿态、哪个坐标,而且不能反悔。传统启发式(First Fit、Best Fit、极值点法)在离线场景下能拿到不错的填充率,但一旦切到在线模式,规则写死之后遇到分布偏移就崩——比如前 20 个都是扁平件,后面突然来一批细长件,极值点列表直接失效。

DQN(Deep Q-Network)在这里的价值不是「比启发式更聪明」,而是它能把「当前容器剩余空间 + 当前货物尺寸」编码成状态,用网络学出一个放置策略,在货物序列变化时仍然保持决策质量。这个标题对应的项目,核心就是:用 Python 搭一个三维装箱环境,把每个装箱动作建模成离散或连续动作空间,用 DQN 训练一个 agent,最终输出可复现的源码和文档说明。

适合谁看:已经会 Python、跑过 PyTorch 或 TensorFlow 小模型,但没做过强化学习落地的人;或者做过二维装箱、想往三维在线场景迁移的工程师。如果你连numpy和gym都没装过,建议先把环境配好再回来,后面每一步都默认你能跑通基础 Python 脚本。

2. 把三维装箱写成 DQN 能吃的环境:状态、动作、奖励怎么定

2.1 状态编码:为什么不能直接把点云丢给网络

三维装箱的状态如果按原始体素表示,一个 100×100×100 的容器就是 100 万维,DQN 的 Q 网络根本训不动。常见做法是把状态压缩成三块:

  • 容器剩余空间描述:用「极值点集合」或「剩余空间长方体列表」表示,每个长方体用 6 个标量(x_min, y_min, z_min, x_max, y_max, z_max)描述,最多保留 K 个(K 取 20~50)。
  • 当前货物特征:长、宽、高、体积、可旋转标志,共 5 维。
  • 全局统计:已放置数量、当前填充率、容器数量,共 3 维。

这样状态维度控制在K*6 + 5 + 3,K=30 时约 188 维,全连接网络就能处理。下面是一个最小状态构造代码:

import numpy as np def build_state(free_spaces, item, placed_count, fill_rate, bin_count, K=30): """ free_spaces: list of (x_min, y_min, z_min, x_max, y_max, z_max) item: (l, w, h, volume, rotatable) """ # 按体积降序取前 K 个剩余空间,不足补零 spaces = sorted(free_spaces, key=lambda s: (s[3]-s[0])*(s[4]-s[1])*(s[5]-s[2]), reverse=True)[:K] space_feat = np.zeros((K, 6), dtype=np.float32) for i, s in enumerate(spaces): space_feat[i] = np.array(s, dtype=np.float32) space_feat = space_feat.flatten() item_feat = np.array([item[0], item[1], item[2], item[3], float(item[4])], dtype=np.float32) global_feat = np.array([placed_count, fill_rate, bin_count], dtype=np.float32) return np.concatenate([space_feat, item_feat, global_feat])

逻辑说明:free_spaces是环境维护的剩余空间列表,每次放置后更新。排序取前 K 个是为了固定状态维度,同时保留最大的可放置区域。参数 K 是超参,K 太小会丢失可用空间信息,K 太大会拖慢训练;实测 K=30 在 20 种货物类型下比较稳。

2.2 动作空间:离散化还是连续坐标

DQN 原生只支持离散动作。三维装箱的动作可以定义为「选择第 i 个剩余空间 + 选择第 j 种旋转姿态」,动作数 = K × 6(6 种旋转)。如果 K=30,动作空间 180 维,DQN 输出层 180 个节点,完全可训。

另一种做法是输出连续坐标 (x, y, z),用 DDPG 或 SAC,但那是另一套算法,标题锁死 DQN 就不要混。离散化的代价是精度受限于剩余空间粒度,但在线场景下货物到达快,粗粒度反而更稳。

import gym from gym import spaces class BinPackingEnv(gym.Env): def __init__(self, bin_size=(100, 100, 100), max_items=50, K=30): self.bin_size = bin_size self.max_items = max_items self.K = K self.action_space = spaces.Discrete(K * 6) # K个空间 × 6种旋转 self.observation_space = spaces.Box(low=-1, high=1, shape=(K*6+8,), dtype=np.float32) self.reset() def reset(self): self.free_spaces = [ (0, 0, 0, *self.bin_size) ] self.placed = [] self.step_count = 0 self.current_item = self._random_item() return self._get_state() def _random_item(self): l = np.random.randint(10, 50) w = np.random.randint(10, 50) h = np.random.randint(10, 50) return (l, w, h, l*w*h, True) def _get_state(self): fill = sum(i[3] for i in self.placed) / (self.bin_size[0]*self.bin_size[1]*self.bin_size[2]) return build_state(self.free_spaces, self.current_item, len(self.placed), fill, 1, self.K) def step(self, action): space_idx = action // 6 rot_idx = action % 6 # 根据rot_idx生成6种旋转后的尺寸 dims = self._rotate(self.current_item[:3], rot_idx) if space_idx >= len(self.free_spaces): return self._get_state(), -1.0, True, {} space = self.free_spaces[space_idx] if not self._fit(dims, space): return self._get_state(), -1.0, True, {} # 放置并更新剩余空间 self._place(dims, space) self.placed.append((*dims, dims[0]*dims[1]*dims[2], True)) self.step_count += 1 reward = dims[0]*dims[1]*dims[2] / (self.bin_size[0]*self.bin_size[1]*self.bin_size[2]) done = self.step_count >= self.max_items self.current_item = self._random_item() return self._get_state(), reward, done, {} def _rotate(self, dims, rot_idx): # 6种旋转排列 perms = [(0,1,2),(0,2,1),(1,0,2),(1,2,0),(2,0,1),(2,1,0)] p = perms[rot_idx] return (dims[p[0]], dims[p[1]], dims[p[2]]) def _fit(self, dims, space): return (dims[0] <= space[3]-space[0] and dims[1] <= space[4]-space[1] and dims[2] <= space[5]-space[2]) def _place(self, dims, space): # 简化:放置后从free_spaces移除该空间,并生成最多3个新空间 self.free_spaces.remove(space) x, y, z = space[0], space[1], space[2] # 沿三个方向切分剩余空间(实际项目会更复杂,这里给最小实现) if space[3] - (x + dims[0]) > 0: self.free_spaces.append((x+dims[0], y, z, space[3], space[4], space[5])) if space[4] - (y + dims[1]) > 0: self.free_spaces.append((x, y+dims[1], z, x+dims[0], space[4], space[5])) if space[5] - (z + dims[2]) > 0: self.free_spaces.append((x, y, z+dims[2], x+dims[0], y+dims[1], space[5]))

逻辑说明:step里先判断动作是否合法,非法直接给 -1 奖励并结束回合,这是为了让 agent 快速学会避开无效动作。合法放置的奖励用货物体积占比,鼓励塞大件。_place里的空间切分是最简版本,实际项目会用「最大剩余空间」或「接触面法」来减少碎片,但作为 DQN 环境已经够用。

参数说明:bin_size默认 100×100×100,可按数据集改;max_items控制回合长度,太小训练不充分,太大梯度不稳定,50 是常见起点;K影响状态和动作维度,改 K 要同步改网络输出层。

2.3 奖励设计:稀疏奖励是 DQN 在装箱任务里的头号杀手

如果只在回合结束给「填充率」作为奖励,DQN 在前几千步几乎学不到东西,因为大部分动作的即时反馈都是 0。常见做法是加 shaped reward:

  • 每步放置成功:+ 货物体积 / 容器体积
  • 放置后剩余空间碎片数增加超过阈值:-0.1
  • 非法动作:-1 并终止
  • 回合结束额外奖励:+ 最终填充率 × 2

这样 agent 在早期就能通过「放得进去」获得正反馈,逐步学会避开碎片化严重的放置。注意 shaped reward 不能太激进,否则 agent 会刷小件体积分,忽略全局填充率。

3. DQN 网络结构、训练循环与关键超参

3.1 用 PyTorch 搭一个能收敛的 Q 网络

状态维度 188,动作 180,网络不需要太深。两层 256 隐层 + ReLU 足够,输出层 180。经验回放池容量 50000,batch size 64,目标网络更新间隔 200 步。

import torch import torch.nn as nn import torch.optim as optim import random from collections import deque class QNet(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 DQNAgent: def __init__(self, state_dim, action_dim, lr=1e-3, gamma=0.99, epsilon=1.0, epsilon_min=0.05, epsilon_decay=0.995, buffer_size=50000, batch_size=64, target_update=200): self.action_dim = action_dim self.gamma = gamma self.epsilon = epsilon self.epsilon_min = epsilon_min self.epsilon_decay = epsilon_decay self.batch_size = batch_size self.target_update = target_update self.step_count = 0 self.q_net = QNet(state_dim, action_dim) self.target_net = QNet(state_dim, action_dim) self.target_net.load_state_dict(self.q_net.state_dict()) self.optimizer = optim.Adam(self.q_net.parameters(), lr=lr) self.buffer = deque(maxlen=buffer_size) def act(self, state): if random.random() < self.epsilon: return random.randint(0, self.action_dim - 1) with torch.no_grad(): s = torch.FloatTensor(state).unsqueeze(0) return self.q_net(s).argmax().item() def store(self, s, a, r, s_next, done): self.buffer.append((s, a, r, s_next, done)) def train_step(self): if len(self.buffer) < self.batch_size: return batch = random.sample(self.buffer, self.batch_size) s, a, r, s_next, done = zip(*batch) s = torch.FloatTensor(np.array(s)) a = torch.LongTensor(a).unsqueeze(1) r = torch.FloatTensor(r).unsqueeze(1) s_next = torch.FloatTensor(np.array(s_next)) done = torch.FloatTensor(done).unsqueeze(1) q = self.q_net(s).gather(1, a) with torch.no_grad(): q_next = self.target_net(s_next).max(1, keepdim=True)[0] target = r + self.gamma * q_next * (1 - done) loss = nn.MSELoss()(q, target) self.optimizer.zero_grad() loss.backward() self.optimizer.step() self.step_count += 1 if self.step_count % self.target_update == 0: self.target_net.load_state_dict(self.q_net.state_dict()) self.epsilon = max(self.epsilon_min, self.epsilon * self.epsilon_decay)

逻辑说明:act用 ε-greedy 平衡探索和利用,store把经验塞进回放池,train_step从池里随机采样打破时间相关性。目标网络每 200 步同步一次,防止 Q 值震荡。done参与 target 计算,终止状态不 bootstrap。

参数说明:lr=1e-3是 Adam 常用起点,装箱任务里如果 loss 震荡可以降到 5e-4;gamma=0.99表示看重长期填充率;epsilon_decay=0.995配合 50000 步左右能降到 0.05;target_update=200太小会导致目标网络跟着主网络跑,太大收敛慢,200 是实测比较稳的值。

3.2 训练循环:怎么判断 agent 真的在学而不是在背

训练循环里每 100 回合打印一次平均填充率和平均奖励,同时记录 loss。如果填充率在 500 回合后还在 0.3 以下,大概率是奖励设计或状态编码有问题,不是网络不够大。

env = BinPackingEnv(K=30) agent = DQNAgent(state_dim=30*6+8, action_dim=30*6) episodes = 5000 for ep in range(episodes): state = env.reset() total_reward = 0 done = False while not done: action = agent.act(state) next_state, reward, done, _ = env.step(action) agent.store(state, action, reward, next_state, done) agent.train_step() state = next_state total_reward += reward if ep % 100 == 0: print(f"Episode {ep}, Reward {total_reward:.3f}, Epsilon {agent.epsilon:.3f}")

逻辑说明:每个回合重置环境,agent 走完 max_items 步或触发非法动作结束。train_step每步都调,但内部有 batch 判断,前期回放池不满时直接返回。打印频率 100 回合一次,避免刷屏。

参数说明:episodes=5000是起步值,实际项目可能跑到 20000 以上;如果显存够,可以把 batch_size 提到 128,但学习率要相应降到 5e-4。

3.3 评估指标:填充率之外还要看什么

只看填充率会被「只放大件」的策略骗到。实际项目里我一般同时看三个数:

指标含义健康范围
平均填充率已放置货物体积 / 容器体积0.65~0.85
平均放置数每回合成功放置的货物数量接近 max_items
非法动作率非法动作次数 / 总动作次数低于 5%

如果填充率高但放置数低,说明 agent 学会了挑大件、避开小件,在线场景下会导致小件积压。这时候要在奖励里加「放置数量」的权重,或者把 max_items 调大逼它处理更多货物。

4. 三维在线装箱 DQN 落地避坑:5 个血泪翻车点

4.1 现象:训练 loss 一直降,但填充率不涨

原因:状态里剩余空间排序后取前 K 个,但每次放置后空间列表变化剧烈,网络学到的映射不稳定。解决:在状态里加入「剩余空间数量」和「最大剩余空间体积」两个全局特征,让网络知道当前碎片化程度。另外把 K 从 30 降到 20,减少无关空间干扰。

4.2 现象:agent 学会一直选同一个动作

原因:动作空间 180 维里,某些动作在早期偶然获得高奖励,ε 衰减太快导致没探索其他动作。解决:把 epsilon_decay 从 0.995 降到 0.999,或者用线性衰减代替指数衰减,前 2000 回合保持 ε>0.3。

4.3 现象:回放池爆内存

原因:状态是 188 维 float32,50000 条就是 50000×188×4 ≈ 37MB,加上 next_state 翻倍到 75MB,看起来不大,但如果状态里 K 取 100,直接 250MB 起步。解决:状态用 float16 存储,采样时再转 float32;或者把回放池降到 20000,配合优先经验回放提高样本效率。

4.4 现象:非法动作率居高不下

原因:动作空间里大量 (space_idx, rot_idx) 组合是无效的,agent 随机探索时频繁撞墙。解决:在环境里加动作掩码(action mask),把非法动作的 Q 值在训练和推理时都设为 -inf。PyTorch 里可以在输出后加q_values[invalid_mask] = -1e9。

4.5 现象:换一组货物尺寸分布就崩

原因:训练时货物尺寸固定在小范围随机,网络过拟合到特定分布。解决:训练时对货物尺寸做 domain randomization,长宽高在 5~80 之间均匀采样,并且每 500 回合换一次分布参数(比如从均匀分布换成正态分布),逼 agent 学通用策略。

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

5.1 用优先经验回放(PER)把样本效率拉高 3 倍

普通回放池均匀采样,但装箱任务里「非法动作」和「高填充率放置」的样本价值完全不同。PER 按 TD 误差给样本加权,让网络多学那些「意外」的 transition。实现上只需要改train_step里的采样逻辑:

import numpy as np class PrioritizedBuffer: def __init__(self, capacity, alpha=0.6): self.capacity = capacity self.alpha = alpha self.buffer = [] self.priorities = np.zeros(capacity, dtype=np.float32) self.pos = 0 def add(self, transition, td_error): max_prio = self.priorities.max() if self.buffer else 1.0 if len(self.buffer) < self.capacity: self.buffer.append(transition) else: self.buffer[self.pos] = transition self.priorities[self.pos] = (abs(td_error) + 1e-5) ** self.alpha self.pos = (self.pos + 1) % self.capacity def sample(self, batch_size, beta=0.4): prios = self.priorities[:len(self.buffer)] probs = prios / prios.sum() indices = np.random.choice(len(self.buffer), batch_size, p=probs) samples = [self.buffer[i] for i in indices] weights = (len(self.buffer) * probs[indices]) ** (-beta) weights /= weights.max() return samples, indices, weights

逻辑说明:add时用 TD 误差的 alpha 次方作为优先级,sample按优先级概率采样并计算重要性权重。beta 从 0.4 线性升到 1.0,抵消偏差。实测在装箱任务里 PER 能让收敛回合数从 8000 降到 2500 左右。

参数说明:alpha=0.6控制优先级强度,0 退化成均匀采样;beta起始 0.4,训练后期升到 1.0;容量 50000 不变。

5.2 验证 DQN 策略是否真的比启发式强:一个最小对比脚本

不要只看训练曲线,要拿固定测试集跑对比。下面脚本同时跑 DQN 和 Best Fit,输出填充率和耗时:

import time def evaluate(env, agent, episodes=100): fills = [] for _ in range(episodes): state = env.reset() done = False while not done: action = agent.act(state) # 评估时 epsilon=0 state, _, done, _ = env.step(action) fill = sum(i[3] for i in env.placed) / (env.bin_size[0]*env.bin_size[1]*env.bin_size[2]) fills.append(fill) return np.mean(fills), np.std(fills) def best_fit(env, episodes=100): fills = [] for _ in range(episodes): env.reset() done = False while not done: # 选能放下的最小剩余空间 best_action = None best_vol = float('inf') for si, space in enumerate(env.free_spaces[:env.K]): for ri in range(6): dims = env._rotate(env.current_item[:3], ri) if env._fit(dims, space): vol = (space[3]-space[0])*(space[4]-space[1])*(space[5]-space[2]) if vol < best_vol: best_vol = vol best_action = si * 6 + ri if best_action is None: break _, _, done, _ = env.step(best_action) fill = sum(i[3] for i in env.placed) / (env.bin_size[0]*env.bin_size[1]*env.bin_size[2]) fills.append(fill) return np.mean(fills), np.std(fills) agent.epsilon = 0.0 t0 = time.time() dqn_mean, dqn_std = evaluate(env, agent) t1 = time.time() bf_mean, bf_std = best_fit(env) t2 = time.time() print(f"DQN: {dqn_mean:.3f} ± {dqn_std:.3f}, {t1-t0:.1f}s") print(f"BestFit: {bf_mean:.3f} ± {bf_std:.3f}, {t2-t1:.1f}s")

逻辑说明:evaluate把 epsilon 设为 0 跑纯贪婪策略,best_fit是经典启发式基线。两个都跑 100 回合取均值和标准差。如果 DQN 均值高但标准差也大,说明策略不稳定,在线场景下反而危险。

参数说明:episodes=100是快速验证,正式对比建议 500 以上;env.K在 best_fit 里用来限制搜索范围,和 DQN 状态里的 K 保持一致才公平。

我自己的习惯是:每次改完奖励函数或状态编码,先跑这个对比脚本,DQN 至少要比 Best Fit 高 5 个点且标准差不超过 0.05,才继续调网络。否则回头查环境,别在网络上浪费时间。三维在线装箱的 DQN 落地,环境设计占七成,网络只占三成,这个比例我踩了无数次坑才认。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 1:04:34

STK 11.0安装与Matlab互联配置全指南:从环境预检到故障排查

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 1:04:06

安卓直链APK安装拦截全解析:从Play Protect到厂商管控

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 1:04:03

Cadence IC618 DRC License报错排查:虚拟机网络与HOSTID修复指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 1:03:11

EndNote到Overleaf:Elsevier投稿参考文献BibTeX全流程指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/5 1:02:31

把一座山谷装进纸盒:华为云码道 × Three.js 光影创作手记

状态压缩 DP 极限推导&#xff1a;大模型在旅行商问题与棋盘覆盖中的位运算优化表现十月四日清晨&#xff0c;教研室白板上还留着昨晚推导状态压缩转移方程写下的草稿。窗外秋雨淅淅沥沥&#xff0c;桌角摆着一杯刚泡好的黑咖啡。 在算法竞赛与大厂终面中&#xff0c;动态规划向…

作者头像 李华