资讯动态

强化学习求解车辆路径问题:CVRP建模、PPO训练与避坑实践

发布时间:2026/10/21 15:40:34 来源:尧图企业网站定制
简介一份同时包含学术论文与可运行代码的强化学习求解车辆路径问题VRP资料包面向计算机相关专业本科及研究生适用于毕业论文、课程大作业或组合优化算法复现。包内含《Reinforcement Learning for Solving the Vehicle Routing Problem》论文 PDF以及基于 PyTorch 的代码工程主要有 4 个 Python 脚本、6 个模型权重 pt 文件、12 张说明用图片、2 个 pyc 缓存文件和 1 个 README 说明文档全部二十六个文件压缩后仅 5.89MB便于快速了解代码结构。目前已有 150 人学习下载是入门强化学习落地应用的完整样例。通过对照论文阅读源码能掌握端到端训练策略网络求解 VRP 的完整思路包括随机策略建模、策略梯度优化以及容量受限场景下的批次交付扩展训练好的权重也可直接用于快速验证为后续改进模型或撰写论文实验部分节省时间。代码目录按 tasks、model、docs 等模块划分结构清晰便于按需拆解复现。1. 强化学习求解车辆路径问题这套代码能帮你走到哪一步前阵子帮A同学整理毕业论文里“车辆路径问题”的强化学习代码发现真正卡人的不是神经网络层数而是三件事动作掩码、奖励设计和训练稳定性。这份资源是一套用Python实现的强化学习闭环代码目标是带容量约束的车辆路径问题CVRP从环境仿真、PPO训练、路线可视化到评估脚本都齐了定位很明确给毕业论文、大作业和想从零跑通RL的人用。适用范围是单仓库、车辆容量一致、目标为总路径最短的场景不适合带时间窗或分钟级响应的实时调度。读完你至少能复现一条从环境到策略再到对比实验的完整链路。2. 问题建模把CVRP拆成有奖励信号的马尔可夫决策过程2.1 为什么不用精确算法和启发式非要上强化学习精确算法在客户数只有几十的时候很漂亮节点数一过百组合爆炸的程度会让求解时间变得不可接受。工业界真正常用的是各种启发式算法比如节约算法、邻域搜索之类但它们每换一个业务约束就要重新设计一套算子调试成本很高。强化学习换个思路不写规则用一个策略网络直接学“下一个访问谁”的条件分布。推理时每一步根据当前状态查概率表挑一个合法节点走过去形式上就是一个构造型求解器。但你得清醒一点强化学习不是用来替代成熟运筹工具的。真要固定网络做日常配送调度直接上开源运筹工具包加邻域搜索稳、快、省心。可毕业论文和大作业不一样需要展示“模型怎么设计、训练怎么收敛、实验怎么对比”强化学习的完整链路比传统求解器更有故事性也更适合做改进型课题。而且约束扩展很方便加时间窗、多车型时改动作掩码和奖励函数就行不需要重写整个求解内核。所以这份资源给你的不是一个“更快”的求解器而是一个“能学习”的求解器。它在小规模同分布实例上通常能接近甚至超过简单启发式在路线质量上的提升过程肉眼可见用来做算法对比和消融实验都很顺手。2.2 CVRP的MDP五要素状态、动作、转移、奖励、折扣把CVRP写成强化学习先要定义好马尔可夫决策过程的五个要素。先说状态我在环境里用一个向量拼接表示当前情况所有节点的坐标、每个节点的需求量、当前车辆的剩余容量、每个节点是否已被访问以及当前所在节点。坐标要提前归一化到0到1之间需求量除以容量做归一化否则数值尺度不稳定网络很难收敛。动作空间是0到N的整数索引。0代表仓库其余代表客户节点。很多初学实现只让模型输出客户节点忽略了“回仓库补货”本身也是一个决策动作结果训练到一半发现车辆永远不回场或者回场时机完全不对。正确的做法是把回仓库显式建模为一个动作能不能选这个动作由动作掩码控制。转移逻辑是这样的从当前节点移动到动作指向的节点路径总长度累加欧氏距离。如果动作是0则剩余容量重置为容量上限否则标记该客户已访问并从剩余容量里扣掉它的需求量。单步奖励用移动距离的负值也就是走一步给一个负奖励距离越短奖励越大。有同学问为什么不用回合结束时的负总距离作为稀疏奖励那样梯度方差特别大小规模还能碰运气收敛规模稍大就彻底不动了。逐步奖励等于给策略网络一个密集的回归目标训练稳定很多。整个回合终止条件是所有客户都被访问过。折扣因子gamma我习惯设0.99。因为每步都有负奖励如果gamma设得小模型就会更在意眼前那几步的负距离忽略整条路线的长远代价最后学出来的路径非常短视。gamma0.99能让模型尽量把全线路程放在同一量级上比较。2.3 基线启发式先写一个最近邻拒绝“没有对比”很多同学训练完强化学习模型兴奋地截个loss曲线就完事了。但评审最常问的问题是你的模型比基线好多少所以代码里一定要先写一个最简单、最不会出错的启发式基线。最近邻加容量回程是最合适的起点逻辑很直白每次从当前节点出发找距离最近且需求量不超过剩余容量的客户如果所有未访问客户都装不下就回仓库补货再出发。def nearest_neighbor(coords, demands, capacity): n len(demands) visited [False] * n route [] total 0.0 cur 0 cap_left capacity while not all(visited): best -1 best_dist float(inf) for j in range(n): if visited[j]: continue if demands[j] cap_left: continue d ((coords[cur][0] - coords[j][0]) ** 2 (coords[cur][1] - coords[j][1]) ** 2) ** 0.5 if d best_dist: best_dist d best j if best -1: # 剩余容量装不下任何未访问客户强制回仓库补货 total ((coords[cur][0] - coords[0][0]) ** 2 (coords[cur][1] - coords[0][1]) ** 2) ** 0.5 route.append(0) cur 0 cap_left capacity else: total best_dist visited[best] True cap_left - demands[best] cur best return route, total这段代码的核心是best -1分支。它解决了CVRP里的容量约束不是每个客户在当前容量下都能服务装不下时必须先回仓库。注意visited里下标0对应仓库但仓库本身不参与客户访问跑完后route里可能有多个0表示车辆回场补货。如果你的坐标是经纬度记得先投影成平面坐标再算欧氏距离否则直接算经纬度距离路线会偏差很大。这个基线的价值在于给强化学习定了一个及格线。后面训练完做评估时用同一批测试实例跑这个最近邻再跑强化学习策略如果平均总路程比最近邻还长先别调超参数回去检查状态表示和奖励是不是有问题。模型连最朴素的算法都打不过问题多半不在网络结构上。3. 环境与数据生成先把仿真器写好再谈训练3.1 训练数据分布随机生成CVRP实例强化学习训练CVRP有个天然优势每回合都可以重新生成一个随机实例模型接触的样本更丰富不容易把某个固定实例背下来。生成实例时我一般用这样的参数客户数N取20或50坐标在0到1的方形区域内均匀随机生成仓库放在(0.5, 0.5)附近需求在容量的10%到30%之间均匀抽样。这个比例很关键如果需求上限到容量的80%一车只能装一两个客户车辆回场频率太高模型很容易学成“每个客户跑一趟”路径质量很差。import random def generate_instance(num_customers, capacity, seedNone): if seed is not None: random.seed(seed) coords [(0.0, 0.0)] for _ in range(num_customers): coords.append((random.random(), random.random())) # 需求只占容量的一小部分保证一辆车能服务多个客户 demands [random.uniform(0.1, 0.3) * capacity for _ in range(num_customers)] return coords, demands这个生成函数的核心参数有三个num_customers控制问题规模capacity控制单趟装载能力seed保证实验可复现。注意需求用的是浮点数不是整数这在网络训练里没有语义问题如果你做的是带整数箱数的场景把demands改成从1到5的整数抽样也行。重点是需求范围要和容量匹配让平均每趟能塞进三到五个客户训练难度才合适。我在代码里额外注意了一种边界情况如果随机生成的需求全部很小剩余容量永远充足模型就不需要学回仓库的时机如果需求全部接近容量上限模型又只剩回仓库一条路。两种极端都会让策略退化。所以在生成脚本里可以加一句检查统计平均每辆车服务客户数低于3就调大容量高于5就调小容量。3.2 环境类状态数组、掩码更新、step函数环境类是强化学习代码的核心模块但它特别容易写错主要错在掩码更新逻辑上。我提供一个精简版的环境框架实际使用时会在这个基础上增加状态向量的拼装和批量处理。import math class VRPEnv: def __init__(self, coords, demands, capacity): self.coords coords self.demands demands self.capacity capacity self.num_nodes len(coords) def reset(self): self.visited [False] * self.num_nodes self.visited[0] True self.cur 0 self.cap_left self.capacity self.distance 0.0 self._update_mask() return self._get_state() def _any_feasible(self): for i in range(1, self.num_nodes): if not self.visited[i] and self.demands[i] self.cap_left: return True return False def _update_mask(self): self.mask [False] * self.num_nodes if self._any_feasible(): for i in range(1, self.num_nodes): if not self.visited[i] and self.demands[i] self.cap_left: self.mask[i] True # 当前不在仓库时允许主动回场补货 if self.cur ! 0 and self.cap_left self.capacity: self.mask[0] True else: # 所有客户都装不下强制回仓库 self.mask[0] True self.mask[1:] [False] * (self.num_nodes - 1) def step(self, action): if not self.mask[action]: raise ValueError(f非法动作 {action} 被掩码拒绝) prev self.cur prev_distance self._distance(prev, action) self.distance prev_distance self.cur action if action 0: self.cap_left self.capacity else: self.visited[action] True self.cap_left - self.demands[action] reward -prev_distance done all(self.visited[1:]) self._update_mask() return self._get_state(), reward, done def _distance(self, i, j): return math.hypot(self.coords[i][0] - self.coords[j][0], self.coords[i][1] - self.coords[j][1]) def _get_state(self): # 实际实现会拼成向量坐标、需求、剩余容量、访问标记、掩码 return None这段代码里最重要的是_update_mask的两个分支。第一个分支是当前剩余容量还能装下至少一个未访问客户此时可选的节点是那些需求不超过剩余容量的未访问客户同时允许回仓库补货第二个分支是剩余容量已经装不下任何客户必须强制回仓库。很多翻车现场都在这里忘了把已访问节点屏蔽或者回仓库动作没有显式放行模型就会在一个全False的掩码里选动作训练直接崩掉。step函数里有一个细节容易忽略计算奖励时要用prev保存移动前的节点而不是移动后的cur否则奖励永远是0甚至反向。reward设为负的欧氏距离意味着模型每多走一步都会收到负反馈它自然倾向于用更少的步数完成配送这与最小化总路程的目标一致。_get_state在当前版本里返回None实际使用时会把坐标、需求、剩余容量、访问标记和掩码拼接成一个一维向量或节点特征矩阵再喂给策略网络。环境引入一个初始化参数capacity后reset里把cap_left重置为capacity这一点别忘了。我遇到过有人把容量初始化写成常量导致每次回合之间容量状态残留训练出来完全是垃圾策略。3.3 环境自检随机策略跑10个回合看约束是否被违反训练大型模型之前花三分钟先跑一遍环境自检非常划算。常见做法是写一个随机策略随机采样合法动作跑几个回合然后检查三类问题是否访问过已访问节点、每辆车装载量是否超过容量、所有客户是否都被访问过。env VRPEnv(coords, demands, capacity) for ep in range(10): state env.reset() done False while not done: legal [i for i, m in enumerate(env.mask) if m] action random.choice(legal) state, reward, done env.step(action) assert all(env.visited[1:]), 存在未访问客户 # 简单校验装载量 for i in range(1, len(demands)): if env.visited[i]: pass print(环境自检通过)随机策略虽然完全没智能但它能暴露环境里的结构性错误。比如掩码更新错位、动作空间越界、访问标记没更新这些问题在随机采样下就能暴露。如果随机策略都跑不通训练策略网络只会放大环境错误。我一般会把这段自检脚本放在训练入口处每次训练前自动执行一遍省得反复调试同一个环境bug。4. 训练实现PPO策略网络与参数配置4.1 策略网络不是只有Transformer才叫注意力策略网络接收的是节点特征序列和掩码输出每个节点的动作概率和状态价值。网络结构不必过于复杂一个embedding层加一层自注意力就足够处理20到50个节点的CVRP。代码里我用了一个简化版本便于阅读和复现。import torch import torch.nn as nn class PolicyNet(nn.Module): def __init__(self, input_dim, hidden_dim): super().__init__() self.embed nn.Linear(input_dim, hidden_dim) self.attn nn.MultiheadAttention(hidden_dim, num_heads8, batch_firstTrue) self.ff nn.Sequential( nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim) ) self.policy_head nn.Linear(hidden_dim, 1) self.value_head nn.Linear(hidden_dim, 1) def forward(self, node_feats, mask): emb self.embed(node_feats) attn_out, _ self.attn(emb, emb, emb) out self.ff(attn_out) logits self.policy_head(out).squeeze(-1) logits logits.masked_fill(mask, float(-inf)) values self.value_head(out).mean(dim1) return logits, values这里mask的形状是[batch_size, seq_len]值为True的位置会被masked_fill置为负无穷softmax之后概率就是0模型永远不可能选中非法节点。value_head输出状态价值估计用来计算优势函数。我特别提醒一点如果你在batch里对不同实例做了padding掩码里必须包含padding位置否则模型会去选择那些全是0的虚拟节点训练时会莫名出现一条走到原点又回到原点的不合理路径。我不建议在小作业里硬套大型Transformer模型训练开销大不说还可能因为数据量不足导致过拟合。一个embedding加一层attention的容量对N20到50的CVRP已经够了先把闭环跑通再逐步加深网络。4.2 PPO的损失函数与GAEPPO的核心目标是让策略更新不过猛。它用新旧策略的概率比值配合裁剪项限制每次参数更新的幅度从而保证训练稳定。计算优势函数时我习惯用GAE也就是广义优势估计它平衡了偏差和方差。def compute_gae(rewards, values, dones, gamma0.99, lam0.95): advantages torch.zeros_like(rewards) gae 0.0 next_value 0.0 for t in reversed(range(len(rewards))): delta rewards[t] gamma * next_value * (1 - dones[t]) - values[t] gae delta gamma * lam * (1 - dones[t]) * gae advantages[t] gae next_value values[t] returns advantages values return advantages, returnsgamma是折扣因子lam是GAE的平滑系数。dones用来区分真实回合终止还是截断。训练时要注意CVRP每个回合都要跑到所有客户访问完dones里大部分是False只有最后一步为True所以代码里(1 - dones[t])保证了跨回合不会错误传播价值估计。策略损失的计算是PPO的核心ratio (new_logp - old_logp).exp() surr1 ratio * advantages surr2 torch.clamp(ratio, 1 - clip_eps, 1 clip_eps) * advantages policy_loss -torch.min(surr1, surr2).mean() value_loss (returns - values).pow(2).mean()new_logp是当前策略下动作的对数概率old_logp是采样的策略下动作的对数概率两者相减再取指数就是概率比值。clip_eps我设0.2这是PPO论文里的常见取值意思是最多允许单次更新改变20%的采样概率。policy_loss里面取min的操作保证当优势为正时不会过度提高概率当优势为负时不会过度压低概率。value_loss则让价值网络拟合真实的回报。4.3 训练主循环实例级RL与经验收集这里给出训练主循环的框架实际跑的时候需要根据显存和速度调整batch大小。for iteration in range(total_iterations): states, actions, rewards, dones, old_logps, values [], [], [], [], [], [] env VRPEnv(coords, demands, capacity) state env.reset() done False while not done: action, logp, value select_action(policy, state) next_state, reward, done env.step(action) states.append(state) actions.append(action) rewards.append(reward) dones.append(done) old_logps.append(logp) values.append(value) state next_state states torch.stack(states) rewards torch.tensor(rewards, dtypetorch.float32) dones torch.tensor(dones, dtypetorch.float32) values torch.stack(values) advantages, returns compute_gae(rewards, values, dones) for _ in range(ppo_epochs): # 小批量更新策略网络 new_logps, new_values policy(states) ...这个循环每回合只推一个随机生成的实例所以模型每一次经验都来自不同的问题规模分布不容易过拟合。select_action里需要把当前状态拼成节点特征再用策略网络输出logits最后从合法动作中按概率采样。注意训练时一定要用采样不要贪心选择概率最大的动作否则策略会被锁死在局部最优里。ppo_epochs我一般设3意思是对同一批经验重复利用三次。次数太少样本利用率低次数太多又会违反PPO对单次更新幅度的限制。total_iterations可以按问题规模设N20时跑一万个回合基本能看到明显收敛N50时需要多跑一些。如果loss出现剧烈震荡就把lr从1e-4降到3e-5同时检查奖励是否出现了极端值。4.4 训练参数速查这里整理了一份我常用的参数表可以直接抄进代码里也可以按自己机器的显存做调整。参数名取值作用gamma0.99折扣因子控制长期回报权重lambda0.95GAE平滑系数平衡偏差与方差clip_eps0.2PPO裁剪阈值限制更新幅度lr1e-4Adam优化器学习率ppo_epochs3同一批经验复用次数hidden_dim128网络隐层维度num_heads8注意力头数batch_size128小批量更新样本数这批参数在设计上偏向保守主要保证训练过程稳定。如果你的实验环境显卡显存不大优先降低batch_size和hidden_dim不要先动clip_eps。很多同学为了追求训练速度把clip改到0.5结果策略波动剧烈奖励曲线像心电图一样上下跳最后还得调回来。5. 避坑车辆路径强化学习训练中的五个翻车现场5.1 训练loss下降总路程不降这是最常见的翻车现象看PPO的策略损失一路下滑满怀期待去跑评估脚本发现平均路线长度和随机策略差不多甚至更差。原因通常是奖励数值没有归一化。CVRP里坐标如果直接用经纬度距离可能动辄几十上百每一步的负奖励数值巨大价值网络根本拟合不过来。PPO的优势函数被极端奖励主导策略更新方向就偏了。解决方法是坐标统一归一化到0到1之间奖励可以用一个滑动平均统计量做标准化比如实时维护reward的均值和方差让优势函数在一个稳定的量级上。我一般做法是每收集一批经验先对奖励做标准化再算GAE。经过处理后就算坐标范围变化训练也会稳定很多。5.2 mask实现出错智能体原地打转另一个常见翻车是训练过程中动作概率几乎均匀路线图呈现来回走的锯齿。原因大多出在掩码逻辑上要么没有屏蔽已访问节点要么强制回仓库分支没写导致模型在剩余容量不足时还能继续选客户环境就给你报“非法动作”或者干脆让车辆原地打转。这种情况训练不会立刻崩溃但策略会非常差。解决方法是把掩码更新写成一个独立函数加入断言。我在每次step之后强制检查合法动作数量至少为1所有已访问节点都被屏蔽所有需求超过剩余容量的节点也被屏蔽。如果断言不通过立即打印当前状态和掩码定位是哪个分支写错了。这条断言留在训练代码里开销很低能省掉大量排查时间。5.3 Padding导致策略对虚拟节点输出概率做批量训练时很多同学会把不同长度实例padding到同一长度方便一次性送入网络。结果就出现模型选中某个坐标为(0,0)、需求为0的虚拟节点路线在图上画出一个奇怪的拐点。这是因为padding节点参与了attention计算而掩码里忘记把它们屏蔽掉网络以为这些虚拟节点是合法客户。解决方法是给padding节点单独设置掩码为True并且在计算loss时把对应位置的logits排除掉。还有一种更省心的做法是让训练数据始终使用相同数量的客户节点也就是batch内全部是N20或全部是N50不做跨长度padding。这样虽然牺牲一点数据随机性但换来的是实现简单、不易出错作业场景下非常推荐。5.4 模型只学会“回仓库”有同学跑完训练发现生成路线里大量出现“访问一个客户就回仓库”的短趟平均每车装载量低得离谱。原因多半是容量C设置太小或者需求生成范围太宽导致车辆一趟装不下几个客户模型被迫频繁回场。另外逐步奖励也会诱导模型拆成短趟因为每次回到仓库虽然多走一段路程但也让剩余容量重置模型可以在后续几步拿到相对较低的负奖励。解决方法是控制需求范围。我一般让需求上限为容量的20%到30%保证平均每车能服务三到五个客户。如果问题规模是N50可以把容量调成总需求的四分之一或五分之一。训练时还要同时监控“平均每车客户数”这个指标低于3就说明容量配置有问题。另外一个补救措施是把奖励改成稀疏的回合总距离但那样训练会慢很多优先还是调数据分布。5.5 测试时遇到不同分布模型瞬间崩溃用N20训练出来的模型直接拿到N50的实例上去测试路线质量通常会大幅下滑甚至比随机策略还差。这是因为策略网络虽然在同分布实例上能泛化但客户数量一变注意力层的输入规模变长模型对序列边界的敏感度就会显露出来。很多同学误以为自己是“学了解算法”实际上只是“了解了这个规模下输出节点之间的配对规律”。解决方法是把测试分成两拨同分布测试和跨分布测试。同分布测试用于说明模型在训练规模上的表现跨分布测试放在论文里作为“泛化性讨论”。严格来说跨分布泛化并不是强化学习求解CVRP的必要条件但你必须清楚自己的模型边界。我每次训练都固定只报一个规模的数字绝不说“模型可以求解任意规模VRP”。6. 验证与演示从“训练完成”到“论文截图”6.1 固定测试集批量评估与对比训练完成后第一步不是截图而是跑一个固定测试集。准备100个随机实例每个实例都用模型推理出一条完整路线同时跑最近邻基线的结果最后统计平均总路程和可行率。评估脚本不复杂但一定要加上可行性校验。def evaluate_policy(policy, env_generator, num_episodes100): total_length 0.0 feasible_cnt 0 for _ in range(num_episodes): env env_generator() state env.reset() done False while not done: action select_greedy_action(policy, state) state, reward, done env.step(action) assert all(env.visited[1:]), 存在未访问客户 total_length env.distance feasible_cnt 1 return total_length / num_episodes, feasible_cnt / num_episodes推理时要用贪心动作也就是选择概率最大的合法节点不是采样。采样会引入随机性导致评估结果每次都不一样。select_greedy_action内部和训练时的采样不同直接argmax概率分布即可。跑完输出平均总路程与最近邻基线做差算出一个百分比这个数字就是论文里最有说服力的指标。6.2 画路线图让约束违反直接暴露在图上文字指标再好看也不如一张路线图直观。建议把模型生成的路线和最近邻基线的路线画在同一张坐标图里不同车辆用不同颜色表示。import matplotlib.pyplot as plt def plot_routes(coords, routes): plt.figure(figsize(8, 8)) for route in routes: xs [coords[i][0] for i in route] ys [coords[i][1] for i in route] plt.plot(xs, ys, markero, linewidth1.5) plt.scatter([coords[0][0]], [coords[0][1]], cred, s100) plt.title(Route Visualization) plt.show()画完图后要马上做三件事看是否每辆车都从仓库出发并回到仓库、看车辆之间的路线是否重叠严重、看有没有节点被漏掉。如果图上出现一条横跨全图的长直线基本可以断定是坐标归一化或距离计算有问题。路线图是演示文稿里最容易引起老师注意的部分值得多花一点时间调好看一点。6.3 论文和大作业里的标准呈现方式最后整理结果时我习惯做一张对比表三行内容随机策略、最近邻基线、强化学习策略。每行列出平均路线长度和平均每车客户数。可行性单独写一列任何一次实例不满足约束都要标红这是评审最关注的安全性问题。方法平均路线长度平均每车客户数可行率随机策略待填入待填入待填入最近邻基线待填入待填入待填入强化学习策略待填入待填入待填入再加两张图训练过程中平均路线长度随迭代数的变化曲线以及上述路线对比图。曲线图要用平滑窗口处理不然高频抖动会影响阅读体验。整个实验部分四件套就齐了模型设计、训练曲线、路线可视化、数值对比表。从那以后我每次跑完训练都强制走一遍固定测试集加可行性断言确认没问题再截图放进论文这个习惯帮我省下的返工时间比任何调参技巧都值。希望这份代码和踩坑记录能让你少折腾几个通宵。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑