ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

三维在线装箱DQN实战:从状态编码到奖励函数的建模与避坑

2026/10/7 5:52:02 拓冰建站 浏览量
三维在线装箱DQN实战:从状态编码到奖励函数的建模与避坑 简介基于DQN深度强化学习解决三维在线装箱问题的完整Python工程面向物流智能优化、深度强化学习方向的在校学生、毕设开发者或算法研究者针对车厢装载率提升这一典型场景提供了可运行的强化学习解决方案。压缩包共10个文件包含5个Python源文件分别覆盖模型训练、在线评估、数据生成、容器环境定义等核心模块另有预训练模型权重.pth、README说明文档.md与装箱效果示意图.png模块划分清楚、文件用途一目了然整体体积约5.64MB。目前已有104人学习下载。代码已经过实际运行验证功能可靠从三维车厢建模、箱子序列生成到DQN训练与在线装箱评估形成完整链路配套文档说明了问题定义、运行方式与关键代码位置能够帮助读者快速理解DQN在组合优化中的应用思路。遇到环境配置或调用问题时可联系作者获得远程教学支持进一步降低上手门槛。1. 三维在线装箱上跑DQN为什么几何问题被做成了强化学习在线三维装箱问题是典型的序列决策箱子逐个到达每个箱子到达时必须在当前托盘状态上立刻决定放哪里、怎么旋转不能回头调整。这个特性让经典的启发式搜索很吃亏——搜索需要全局信息在线场景下信息永远不完整。把问题交给DQN这类深度强化学习等于训练出一个“看一眼当前堆叠空间和箱子尺寸就出放置策略”的决策模型装箱策略不再依赖人工规则模板而是用奖励信号从仿真里直接长出来。适合正在做仓储调度、集装箱配载、机械臂码垛仿真的人也适合想入行深度强化学习和组合优化交叉方向的研究者。这里从建模到训练踩坑给一条能在Python里复现的完整路径。2. 装箱问题怎么“喂”给DQN状态、动作、奖励的三件套建模2.1 为什么把容器切成立方体网格离散化是给神经网络“能理解”的关键在线三维装箱的难点在于连续空间里候选位置是无限的。DQN输出的是离散Q值神经网络没法一次性枚举无穷个候选点。常见做法是先把容器空间离散成小立方体网格例如把1200mm×1000mm×1200mm的托盘按20mm网格切分后续所有计算只在网格上做。网格粒度决定了状态大小和放置精度粒度太细状态维度爆炸粒度太粗无法处理一些箱体的尺寸差通常取箱体最小公因子的1/2到1/4之间。实际工程里我一般把容器离散成三维的高程矩阵和占用矩阵分别代表“当前每个柱位已经堆到多高”和“每个格子是否被某个箱子占用”。这样网络输入非常紧凑推理速度快。需要注意三维网格和图像网格不同箱子的放置位置一般只能在完整能放下的落点处不能像目标检测那样输出任意浮点坐标所以离散化同时也是动作空间的设计前提。2.2 状态编码用“最高高度图”加“剩余容量热度图”作为观测拍脑袋做状态编码往往让神经网络变成黑匣子且难以收敛。我常用的做法是把状态拆成多通道让每个通道都有物理含义。第一个通道是高度图将容器按俯视平面分成网格列每列的值是该列当前堆叠的最高高度。第二个通道是可用容量热度图统计每个网格位置上方剩余空间能容纳的最大箱体体积简单说给出一张“这里还放得下多少”的热力指示。第三个通道是当前待放置箱子的尺寸二进制平面图相当于把箱子的长宽投影到网格上方便网络做匹配。import numpy as np def encode_state(container: np.ndarray, box: np.ndarray): # container: 已经占用的三维体素数组shape(H, L, W) # box: 当前箱子尺寸shape(3,) height_map np.max(container, axis0) # 第一通道俯视高度图 capacity_map np.zeros_like(height_map, dtypenp.float32) L, W container.shape[1], container.shape[2] for x in range(L): for y in range(W): col_free int(container.shape[0] - height_map[x, y]) # 只要箱子高度不大于剩余高度就算能放进这个列位 if box[0] col_free: capacity_map[x, y] box[0] * box[1] * box[2] else: capacity_map[x, y] 0.0 box_proj np.zeros((L, W), dtypenp.float32) bl, bw box[1], box[2] # 这里默认箱子长宽投影到俯视图 box_proj[:bl, :bw] 1.0 state np.stack([height_map, capacity_map, box_proj], axis-1) return state这段代码的逻辑是把三类物理信息拼成多通道张量。height_map用最大函数对容器体素在高度轴上归约容量图则是一个简单的扫描填充只判断“这一列能不能装下当前箱子”能装就把箱体体积写在对应像素上不能就置零。这个通道的目的不是给网络一个精确数值而是告诉它“哪些列位还有利用价值”。box_proj作为待放置箱子的投影让网络在卷积归纳时能把“箱子占多大面积”和“高度图上有多少凹陷”对应起来。参数说明容器体素数组的shape约定是(height, length, width)全部体素只有0和1两种取值1代表已被占用height_map的单位是网格数而不是真实毫米训练时建议统一除以容器高度做归一化。容量图用箱体体积而不用剩余高度是因为不同箱子尺寸下“能装”和“装得值”是两回事体积信号更接近放置带来的空间收益。2.3 动作空间候选放置点与旋转策略动作空间设计是三维装箱DQN项目里最容易翻车的地方。如果直接对每个网格位判断放不放动作空间会膨胀到几十万维DQN几乎不可能收敛。常见做法是只把“贴着已放箱子或容器壁的角点”作为候选放置点这也是三维装箱领域里被验证过的空间邻域搜索经验一个稳定放置位置至少有一面贴着容器壁或者贴着已放箱子的面。def gen_candidate_actions(container, height_map, box, grid_size, support_thresh0.7): L, W height_map.shape actions [] for x in range(L - box[1] 1): for y in range(W - box[2] 1): # 只考察贴着容器边界或贴着已有箱体的列位 near_wall x 0 or y 0 or x L - box[1] or y W - box[2] near_box False if x 0 and height_map[x - 1, y] 0: near_box True if y 0 and height_map[x, y - 1] 0: near_box True if not (near_wall or near_box): continue # 检查该区域下方支撑是否足够 support_mask height_map[x:x box[1], y:y box[2]] support_ratio np.mean(support_mask 0) if support_ratio support_thresh: continue place_height int(np.max(support_mask)) if place_height box[0] container.shape[0]: continue actions.append((x, y, place_height)) return actions这段代码的要点是剪枝。第一剪枝条件是贴着容器壁或贴着已有箱子排除大量中空悬停位置第二剪枝条件是支撑率阈值support_thresh默认0.7表示该区域至少70%的网格列下方有支撑这能减少把箱子架空的倾向第三剪枝是高度检查防止箱子超出容器顶部。place_height取该区域所有列的最大高度这样箱子落下来后不会悬空实际代码里既可以放一个固定朝向也可以把4个旋转分别展开成多个动作分支。参数说明support_thresh越低越容易放低位置越高越保守填0.9时基本只允许放到平整面上旋转处理上建议把每种朝向作为独立动作放进动作空间网络自己学哪种朝向在当前状态更优不单独设朝向输出的分支能减少一个不稳定因素。2.4 奖励函数用体积利用率和稳定支撑做奖励避免一次给太多学分三维在线装箱的最大收益来自于最终的空间利用率但网络没法在每一步都看到最终结果。在线场景里奖励必须拆成“当前生效”的信号。我常用的奖励表达式是放置后体积利用的增量、放置稳定性和对后续空间的破坏性惩罚的组合。def compute_reward(before_volume, after_volume, box_volume, support_ratio, center_offset, punish_factor0.2): utilization_delta (after_volume - before_volume) / max(box_volume, 1e-6) stability support_ratio offset_penalty punish_factor * center_offset reward utilization_delta 0.3 * stability - offset_penalty return reward逻辑说明utilization_delta衡量这一放下去增量多少的相对体积利用率因为增量通常是个接近1但不到1的数字所以奖励数量级比较稳定。0.3乘上支撑率是稳定性加分支撑率越接近1说明放得越稳这个项对防止网络学出“把箱子放成一堵悬空墙”很关键。center_offset是放置位置中心相对于容器中心在高度方向偏移量惩罚项让网络不要把箱子堆到极高处。参数说明0.3这个系数在实验中表现为“稳定性的权重”调大容易让策略变得保守只放平整位置punish_factor默认0.2如果训练出来的策略频繁把箱子垫高就把它加到0.5。注意不要让三个项的量纲差得太远先把utilization_delta、stability、offset_penalty分别统计一遍确认它们大致在同一个数量级再叠加。3. DQN网络与训练主循环Python里的关键实现3.1 网络结构用二维双分支Q网络而不是直接吃三维体素三维体素数据直接丢给3D CNN理论上可行但动作空间是俯视网格上的候选点网络最后还是要映射回一个平坦的Q值向量。常见做法是把状态编码成高度图多通道再用2D卷积提取空间特征。这样计算成本低、收敛快而且容易debug。我一般用一个小型ResNet风格网络主干是两个卷积块每个块包含双卷积和残差连接最后接全连接层输出Q值向量。Q值向量的长度等于候选动作个数DQN训练时只在合法动作上取最大值。import torch import torch.nn as nn class DQN3D(nn.Module): def __init__(self, state_channels, num_actions): super().__init__() self.conv_block1 nn.Sequential( nn.Conv2d(state_channels, 32, kernel_size3, padding1), nn.ReLU(), nn.Conv2d(32, 32, kernel_size3, padding1), nn.ReLU(), ) self.conv_block2 nn.Sequential( nn.Conv2d(32, 64, kernel_size3, padding1), nn.ReLU(), nn.Conv2d(64, 64, kernel_size3, padding1), nn.ReLU(), ) self.pool nn.AdaptiveAvgPool2d((4, 4)) self.fc nn.Sequential( nn.Flatten(), nn.Linear(64 * 4 * 4, 256), nn.ReLU(), nn.Linear(256, num_actions), ) def forward(self, state): x self.conv_block1(state) x self.conv_block2(x) x self.pool(x) return self.fc(x)逻辑说明两个卷积块把多通道高度图逐步抽象成局部几何模式网络会学到类似“凹陷区域”“支撑面”“邻近箱体”的特征。AdaptiveAvgPool把特征图压缩成固定尺寸这样即使容器网格尺寸变化网络结构也不用改。最后全连接层输出num_actions维Q值训练时和动作空间对齐。参数说明state_channels对应状态编码的通道数通常是6高度图、容量图、箱子投影在4个旋转朝向的其中一个。num_actions不能用固定值因为每次状态下的候选动作数不同常见做法是设定最大候选数MAX_ACTIONS然后对不足的位置用mask填充Q值掩码在loss计算时把非法动作的Q值置为-1e9。卷积核3x3、通道数32/64是我试过的最稳妥配置更大的网络对三维装箱没有明显收益反而更难训。3.2 经验回放装箱过程的状态转移太稀疏必须用优先回放三维装箱场景天然稀疏且回合长如果只用均匀回放大量“箱子放到位”但收益一般的经验会把重要样本稀释掉。优先回放按TD误差给样本加权误差大的样本更值得学习。实现上不用引入太复杂的库只需要在普通ReplayBuffer上增加一列优先级。import random from collections import deque class PrioritizedReplayBuffer: def __init__(self, capacity20000, alpha0.6, beta_start0.4): self.buffer deque(maxlencapacity) self.priorities deque(maxlencapacity) self.alpha alpha self.beta beta_start def push(self, transition, td_error1.0): priority (td_error 1e-5) ** self.alpha self.buffer.append(transition) self.priorities.append(priority) def sample(self, batch_size): probs np.array(self.priorities) / np.sum(self.priorities) idx np.random.choice(len(self.buffer), batch_size, pprobs) batch [self.buffer[i] for i in idx] weights (len(self.buffer) * probs[idx]) ** (-self.beta) weights / weights.max() return batch, weights def update_priorities(self, idx, td_errors): for i, e in zip(idx, td_errors): self.priorities[i] (e 1e-5) ** self.alpha逻辑说明push时给td_error设置默认值1.0新样本优先级居中不至于一进来就霸占采样。sample按归一化概率采样再用重要性权重修正偏差防止优先回放引入过大的梯度偏差。update_priorities在每次Q网络更新后把这条样本的TD误差回填。参数说明alpha0.6是优先回放常用强度alpha0退化成均匀采样beta在训练过程中从0.4线性增长到1.0这个参数控制“修正偏差”力度大小前期的偏差修正到后期更强能在训练后期更好地稳定收敛。3.3 训练主循环回合内订单流、放置失败怎么办、网络更新的节奏训练主循环是整条路径的核心。每一步执行“环境步进→经验存储→网络更新→target网络软更新”四步。放置失败时我给一个负奖励并结束回合而不是把这个箱子跳过继续因为在线场景里箱子放不下就是放不下不能回头。def train_one_episode(env, q_net, target_net, optimizer, replay, batch_size128, gamma0.99, eps0.3, target_update_steps500): state env.reset() done False step 0 while not done: actions env.legal_actions() if random.random() eps: action random.choice(actions) else: with torch.no_grad(): q_values q_net(state_tensor) action actions[q_values.argmax().item()] next_state, reward, done, info env.step(action) replay.push((state, action, reward, next_state, done)) if len(replay.buffer) batch_size: batch, weights replay.sample(batch_size) states torch.stack([b[0] for b in batch]) actions_idx torch.tensor([b[1] for b in batch]) rewards torch.tensor([b[2] for b in batch]) next_states torch.stack([b[3] for b in batch]) q_sa q_net(states).gather(1, actions_idx.unsqueeze(1)).squeeze(1) with torch.no_grad(): next_q target_net(next_states).max(dim1)[0] target rewards gamma * (1 - torch.tensor(done, dtypetorch.float32)) * next_q loss (weights * (q_sa - target) ** 2).mean() optimizer.zero_grad() loss.backward() optimizer.step() replay.update_priorities(batch_idx, td_errors) if step % target_update_steps 0: target_net.load_state_dict(q_net.state_dict()) step 1逻辑说明epsilon-greedy探索和规则混合策略在下一章会细讲。经验存储之后先采样再更新更新用Q-learning目标value reward gamma * max(Q(s’))。注意这里next_q取自target_net而不是q_net避免目标值和预测值用同一套参数导致发散。target网络每隔一定步数复制一次q_net参数这种硬更新实现简单配合大量经验回放也够用。参数说明gamma0.99适合回合较长的装箱场景若每个回合只有5~10个箱子可以降到0.95target_update_steps500是比较稳的节奏更新太快容易震荡太慢则学习速度下降建议以500为基准训练曲线波动大就调到1000收敛慢就调到200。3.4 训练进度怎么盯从Q值曲线和放置成功率看收敛训练时只看loss曲线的习惯要改。DQN的loss下降不一定代表策略变好更实用的指标是“当前平均放置高度/容器总高”和“放置失败率”。我在训练中每回合打印两类指标放置成功率和平均体积利用率。放置成功率接近1且利用率稳定提升说明网络在真正学会装箱而不是把箱子全堆到一个角落。训练脚本里我加入一个简单回放评估每隔20个回合固定用同一个随机种子生成10个测试订单记录平均利用率。测试订单不参与训练这样能快速对比策略在不同训练阶段的效果。如果利用率迟迟不涨回到奖励函数排查这一步能省下很多盲调网络的时间。4. 让策略真正能在现场用订单流生成器与规则混合探索4.1 用订单流生成器替代固定散货集合在线场景才能练出来在线装箱和离线装箱最大的区别是“下一单不可预知”。很多复现项目直接用固定一组箱子做训练网络学出来只是记住了这批箱子的组合模式换一批箱子就失效。解决思路是训练时用一个可配置的订单流生成器每一轮往环境里发射随机生成的箱子序列。def generate_order_stream(num_boxes, size_range, weight_range): orders [] for _ in range(num_boxes): l random.uniform(*size_range[length]) w random.uniform(*size_range[width]) h random.uniform(*size_range[height]) wgt random.uniform(*weight_range) orders.append({dimension: (l, w, h), weight: wgt}) return orders逻辑说明这个生成器每次调用都产生一组新的订单序列使训练集不断变化网络被迫学习“看到当前堆叠和当前箱子就决策”的泛化能力而不是背下序列。weight字段用于后续的记录比如重物优先放底层这种业务约束可以加进奖励或动作筛选里。参数说明size_range控制箱体长宽高分布。工业场景常见尺寸在200mm到800mm之间若容器是1200mm×1000mm这个分布比较合理。实际使用我建议把尺寸分布和真实出库订单分布对齐而不是均匀分布这样训练效果更有指向性。4.2 规则混合探索在epsilon-greedy里混入SNF启发式纯随机探索在三维装箱里效率太低随机的放置位置大部分是无效的即使合法也往往很差网络学到有效经验的速度慢。常见的改进是规则混合探索在epsilon概率下一部分动作随机选一部分动作用空间邻域拟合SNF规则来选。def choose_action(state, actions, q_net, eps, rule_prob0.5): if random.random() eps: # 随机探索中有一定概率使用SNF规则挑选候选 if random.random() rule_prob and actions: # 贪心地选“当前放置位置最低”的动作 return min(actions, keylambda a: a[2]) return random.choice(actions) with torch.no_grad(): q_values q_net(state) valid_q q_values[actions] return actions[valid_q.argmax().item()]逻辑说明SNF规则是三维装箱中常见的放置策略之一其核心思想是“尽量选择最低且最靠近角落的放置点”。把它放进探索策略里网络在前期就能收集到不少质量较好的经验样本而不是纯靠随机瞎碰。等网络学到了足够好的策略Q值的贪心选择会逐步替代规则选择。参数说明rule_prob0.5表示在epsilon随机分支中有一半的概率使用规则其余一半仍然纯随机保证探索多样性。随着训练进度可以把rule_prob降到0.2甚至0避免规则策略限制了网络学出更好的策略。4.3 在线装箱的评估指标利用率、重工率和单步耗时都要看很多项目汇报时只报最终体积利用率但在线场景还有更关键的指标。一个是“重工率”即已经放置的箱子有多少需要被重新取出调整。DQN的决策不能回溯重工率太高说明策略在早期放置就牺牲了后期空间。另一个是单步决策耗时因为在线环境要求决策在箱子到达窗口内完成。评估时我习惯把这三个指标一起记录放在一张表里指标含义可接受范围体积利用率容器被箱体占据体积/容器总容积商业场景一般要求0.7以上重工率需要重摆放的箱子数/总箱子数越低越好最好0单步耗时生成一个放置动作的推理时间目标小于50ms训练好的网络在Python推理单步基本在几毫秒到几十毫秒量级瓶颈通常不在网络前向传播而在候选动作生成。如果候选点生成太慢可以预计算网格的邻接索引表把gen_candidate_actions里的双层循环优化成查表。4.4 数据管理把订单流和状态图存成文件训练可复现训练三维装箱DQN很依赖随机种子种子一变结果就变。为了能定位问题我会在训练时把每个回合的订单流和关键状态图保存到本地包括容器体素数组的dump文件和订单序列的JSON。这样就算后来改了网络结构也能用同一批训练数据对比前后效果。Python里直接用np.save和json.dump即可磁盘占用并不大。源代码管理上建议把环境代码、网络代码、训练脚本按模块拆开环境只暴露step和reset接口这样后面换策略算法时不用重写环境。5. 三维装箱DQN训练避坑5条血泪经验5.1 现象训练很久放置高度还是随机乱跳原因状态编码里没有把箱子尺寸与当前容器状态的关系体现出来网络无法区分“这个箱子放这里是否合适”Q值对任意动作都趋同。有些状态编码只给了高度图没给容量信号导致大量动作对应的Q值几乎一样。解决将容量热度图和箱子投影图加到状态通道里并确保奖励是“体积增量”这种尺度稳定的信号而不是“未使用空间总量”。体积增量是相对当前这一个箱子的归一化变化数值范围稳定未使用空间的总量随回合推进不断变化网络很难从这种非平稳信号里学到稳定策略。我见过仅去掉容量通道的实验利用率从0.72掉到0.61。5.2 现象训练出来的策略专门把箱子放高重心不稳原因奖励只盯着体积增量箱子放得再高只要体积利用到位就得分缺少稳定性惩罚。我从一次机械臂码垛仿真里看到模型学会把中等箱子垫在两个大箱子上方表面看空间利用率不错但箱体悬空支撑不足仿真里稍微加一点晃动就垮了。解决在奖励函数里加支撑率系数和重心偏移惩罚。先只调支撑率一项把0.3系数调到0.5观察放置高度是否下降如果还不降就再加重心偏移惩罚惩罚系数从0.2起步。另外可以在候选动作生成阶段就把支撑率阈值设到0.7以上从源头砍掉不稳定的候选位置效果比只改奖励更直接。5.3 现象候选动作太多训练速度极慢原因对每个网格列生成候选点动作空间上十万维DQN全连接层参数量急剧膨胀。有一次训练跑了8个小时利用率还是原地踏步一看日志每次状态生成动作列表花了近1秒网络更新反而只占很少时间。解决只用贴着容器壁或已放置箱子的候选点并且增加支撑率阈值。优化后动作数量通常降到几百到几千个训练速度能提升一个数量级。同时把Q值输出维度固定为最大候选数用mask处理不足的位置避免每次向前传播都重新建网络。5.4 现象离线测试很好一换订单流就崩原因训练时使用的订单流尺寸分布和测试分布不一致网络见过的大箱子少遇到大箱子就胡乱放置。通常表现为离线测试用的是同分布数据利用率好看到了新的订单流箱子尺寸范围变了策略立刻退化。解决订单流生成器要按真实业务分布配置尺寸范围、重量范围并在训练中周期性注入“极端箱型”比如超大箱占5%左右的比例让网络见过这种样本。还可以在评估阶段用三个不同的订单流验证一个同分布、一个大箱偏多、一个小箱偏多三个都达标才算策略稳定。5.5 现象训练后期loss忽高忽低Q值发散原因TD误差过大target网络更新太快当前网络和target网络差距太大。普通DQN的target和当前Q用同一套经验更新如果target更新频率太高目标值本身会飘loss自然跟着震。解决调低学习率到1e-4量级把target_update_steps从500调到1000或2000如果还发散把单步更新经验回放batch从128降到64降低单次更新的梯度噪声。这个优先级比换网络结构高我在同样环境里只动这个参数成功率就能稳定下来。6. 一个验证小技巧单步推演脚本观察Q值决策模型训完不能只看指标还要能在具体状态上看懂它的决策。我写过一个非常小的单步推演脚本专门用来手动检查“当前状态网络会不会选一个离谱的位置”。def inspect_step(env, q_net, state, actions): with torch.no_grad(): q_values q_net(torch.from_numpy(state).float().unsqueeze(0)).squeeze(0) ranked sorted(actions, keylambda a: q_values[a].item(), reverseTrue) print(Top-3 动作) for act in ranked[:3]: print(f位置{act}, Q{q_values[act].item():.4f}) worst ranked[-1] print(f最差动作{worst}, Q{q_values[worst].item():.4f}) return ranked这个脚本每次选一个特定订单状态打印Top-3和最差的动作Q值。我拿它做过一次很有价值的验证网络在某个状态把大箱子放到一个角落但旁边明明有更平整的支撑面Q值却非常接近说明网络对“平整支撑面”的感知还不够强。后来在状态编码里补充了支撑率通道问题就消失了。再往深一点可以把每步的Q值存下来和放置后的实际单步奖励做对比一旦出现“Q值很高但奖励非常低”的情况说明网络还没有对齐环境规则优先检查奖励函数而不是网络结构。这也是我踩过多次坑之后的习惯DQN不收敛先怀疑奖励、再怀疑编码、最后才怀疑模型大小。参数上我习惯把每轮训练seed固定、订单流落盘、Q值日志保留一个版本这样前后对比从来不靠记忆全部翻文件就能查。希望这篇笔记里的建模细节和踩坑记录能帮你少走几轮弯路希望帮到你。本文还有配套的精品资源点击获取