做三维路径规划绕不开一个问题如何让智能体在一个立体的环境里自己走出一条从起点到终点的无碰撞路径。Q-learning是最经典的强化学习方法之一把三维空间离散成网格之后它可以用一张Q表把每个位置的动作价值都记下来。这篇文章记录我用Python和NumPy实现三维Q-learning路径规划项目的完整过程重点放在三维状态与动作设计、奖励函数调参、训练主循环以及项目跑到002号版本时新增的“策略保存与读取”能力。整套代码不依赖深度学习框架一张表格就能完成训练特别适合想验证强化学习基础算法、或者需要在三维网格里做路径规划验证的同学参考。1. 先把思路理清楚三维路径规划到底怎么套进Q-learning1.1 为什么选Q-learning而不是A*、RRT或者DQN很多朋友第一反应是三维空间路径规划直接用A或者RRT不就行了确实在已知静态地图里A这类搜索算法效率很高。但Q-learning有个搜索类算法没有的特性它是从“与环境交互的试错”中学习出来的不要求环境模型完全已知而且训练完成的策略本质上是一张“当前位置下哪个方向最优”的对照表拿到相似地图上可以继续用或者做微调。我之所以在这个项目里选Q-learning还有一个原因三维网格离散化后状态空间是有限的正好是表格型强化学习的传统主场。相比DQN需要训练神经网络、调网络结构、处理经验回放Q-learning只需要维护一张表和几个超参数训练逻辑清晰调试起来也直观。对想快速跑通一个三维导航Demo的人来说Q-learning是性价比最高的起点。1.2 三维和二维的差距不是多一个坐标轴那么简单从二维扩展到三维最大的变化是状态空间规模暴涨。一个20×20的二维网格只有400个格子换成20×20×20三维网格就变成8000个格子直接翻了20倍。如果每个格子的动作从上下左右4个方向变成上下前后左右6个方向Q表的条目数会从二维的400×4变成三维的8000×6整整涨了30倍。这意味着智能体需要更多的episode才能探索到足够多的状态训练时间成倍拉长。除了状态数变多三维环境还带来两个之前很少遇到的麻烦一是边界检测从“检查四个方向”变成“检查六个方向”任何一个方向越界都要拦截二是障碍物从二维的“线段”变成了三维的“体素块”碰撞判定逻辑不能只数格子得考虑高度层的交叉。三维环境下智能体还容易“绕远路”本来二维路径几部就能走到三维环境因为多了一个高度维度经常走着走着就飘到其他楼层路径变得又长又怪。这些细节在代码实现时都得单独处理。1.3 整体代码架构环境、智能体、训练调度、策略持久化四层分离这个项目的代码我分了四层每层管一件事。第一层是环境层负责三维网格地图、障碍物、起点终点对外暴露reset和step两个接口第二层是智能体层维护Q表、选择动作、执行Q值更新第三层是训练调度层负责episode循环、epsilon衰减、日志打印第四层是策略持久化层负责把训练好的Q表存到磁盘以及从磁盘恢复策略继续训练或变现。这样拆的好处很简单以后如果想把Q-learning换成SARSA或者DQN只需要改智能体那部分环境和训练调度完全不动如果想把仿真环境换成真实三维数据也只需要重写环境层Q-learning的更新公式不用碰。这个结构值得养成习惯尤其是后面想做算法对比实验的时候换个算法就是换个类的事。2. 三维环境、动作空间和奖励函数的核心设计2.1 状态空间与Q表为什么我最后选了字典而不是三维数组三维网格的离散状态我直接用一个坐标元组(x, y, z)来表示。每个坐标对应一个网格体素网格本体用NumPy的三维数组存数组中1表示障碍物0表示可通行区域。Q表在代码里用一个Python字典来维护键是状态坐标值是一个长度为6的NumPy数组分别记录六个动作的Q值。刚开始我也想用大数组直接初始化成np.zeros([nx, ny, nz, 6])但实际尝试后发现很浪费。三维网格里大量状态是障碍物智能体根本进不去没必要为它们分配内存。如果是50×50×50的网格数组形态的Q表要占用50×50×50×6个浮点数大约75万个数内存没问题但大部分是垃圾数据。字典写法只记录智能体实际访问过的状态更贴合真实需求。而且后面做策略保存时字典转数组、数组转字典的过程也很顺手不会丢失结构。需要注意的是字典键是tuple查询时要求类型一致。训练过程中我始终用(tuple)存键加载策略时也从数组重新转回tuple这两个环节一旦类型不一致Q表就会查不到值表现为“明明加载了策略智能体还是乱走”。这个问题我们在第4部分详细展开。2.2 动作空间定义与碰撞检测的几种处理三维6方向的增量表写法很固定我放在环境类里初始化self.action_moves [ (1, 0, 0), # 0: 沿x轴正向 (-1, 0, 0), # 1: 沿x轴负向 (0, 1, 0), # 2: 沿y轴正向 (0, -1, 0), # 3: 沿y轴负向 (0, 0, 1), # 4: 沿z轴正向 (0, 0, -1), # 5: 沿z轴负向 ]动作索引从0到5后续Q表里每个索引对应一种移动。碰撞检测必须处理两种非法动作一是新坐标超出地图边界二是新坐标落在障碍物体素上。这两种情况我统一处理成“返回原状态给一个较大的负奖励但不结束episode”。为什么不直接结束因为如果一碰障碍物就终止智能体学到的策略会倾向于“远离所有可能导致碰撞的区域”路径反而变得极度保守。让智能体撞墙后留在原地并继续探索它才有机会绕过去。为了调试方便我还在环境里加了一个max_step限制。训练时如果一个episode超过200步还没到达目标就强制结束避免智能体在某个区域无限绕圈。2.3 奖励函数别急着上稀疏奖励先用密集奖励把路径“引”出来奖励设计是三维Q-learning最容易翻车的地方。我在二维项目里试过纯稀疏奖励到达目标给50其它每一步给-1。二维地图小跑通勉强可以到三维地图稀疏奖励收敛慢到怀疑人生原因很简单——状态空间大随机探索要撞到目标的概率极低。这次我改用密集奖励加时间惩罚的组合。基础逻辑是这样if next_state self.goal: reward 50.0 done True elif self.grid[next_state] 1: reward -10.0 next_state self.state # 留在原地 else: reward -0.05 new_dist self.manhattan(next_state) old_dist self.manhattan(self.state) reward 0.5 * (old_dist - new_dist)这里的关键是最后一行用曼哈顿距离的变化量作为额外引导。如果智能体这一步离目标更近了就多给0.5的正奖励如果更远就扣0.5。这个“距离差奖励”的效果是告诉智能体“朝目标方向走是对的”大大加速三维场景的前期探索。但系数不要设置太大否则智能体可能为了刷距离奖励而绕圈真正到达目标时反而不在乎。我把时间惩罚控制在-0.05防止智能体原地踏步混吃等死和距离奖励保持一个合理的平衡。撞障碍物的-10奖励也要拿捏分寸。如果设置成-50智能体会由于恐惧碰撞进入“动都不敢动”的惯性状态如果设置成-1它又会频繁撞墙路径很毛糙。我试下来-10在这个量级比较合适。2.4 训练参数alpha、gamma、epsilon的量级参考训练参数我直接给出一组实测有效的初始值后面调试时可以在此基础上调整参数建议值作用alpha学习率0.1每次更新Q值时新信息的权重过大训练震荡过小收敛慢gamma折扣因子0.9未来奖励的重视程度三维路径里0.9~0.99均可epsilon0.3起步逐步衰减到0.05探索概率前中期多探索后期多用已有策略max_steps200每个episode最大步数防止绕圈死循环epsilon衰减我用的是一步到位的线性衰减每完成一个episodeepsilon乘以0.995同时设置下限0.05。大概跑400个episode之后epsilon从0.3掉到接近0.05训练进入以利用为主阶段。如果衰减太快智能体还没见过足够的路径就过早停止探索如果衰减太慢后期大量动作是随机试错已经学到的路径也会被干扰。3. 训练主循环与策略保存/加载的完整实现3.1 搭建三维网格环境地图生成、起点终点与边界检测环境类是训练和测试共用的基础。这里给出完整的三维环境代码包含reset、step、碰撞检测和距离计算import numpy as np class Grid3DEnv: def __init__(self, grid, start, goal): self.grid grid self.start tuple(start) self.goal tuple(goal) self.dims grid.shape self.state self.start self.action_moves [ (1, 0, 0), (-1, 0, 0), (0, 1, 0), (0, -1, 0), (0, 0, 1), (0, 0, -1), ] def reset(self): self.state self.start return self.state def step(self, action_idx): delta self.action_moves[action_idx] ns ( self.state[0] delta[0], self.state[1] delta[1], self.state[2] delta[2], ) # 边界检测 if not all(0 ns[i] self.dims[i] for i in range(3)): return self.state, -10.0, False # 障碍物检测 if self.grid[ns] 1: return self.state, -10.0, False self.state ns if self.state self.goal: return self.state, 50.0, True reward -0.05 0.5 * (self.manhattan(self.state) - self.manhattan(ns)) return self.state, reward, False def manhattan(self, s): return (abs(s[0] - self.goal[0]) abs(s[1] - self.goal[1]) abs(s[2] - self.goal[2]))注意step里对障碍物分支的处理先返回原状态不结束episode。这种“留在原地”的语义让智能体在撞到障碍物时得到惩罚但同时有机会换个方向再试。边界检测和障碍物检测分开写方便后续扩展成“越界终止”之类的变体。地图生成我还建议加一步连通性验证。用BFS从起点遍历一遍看看能否到达目标跑训练之前先把不可达的地图筛掉能省下大量无效探索时间。三维地图比二维更容易出现“障碍物围成一圈、通道被堵死”的情况这一步很有必要。3.2 智能体类与训练主循环更新公式别写错智能体类负责Q表初始化和Q值更新。这里最值得说的是get_q方法它会在状态首次出现时自动初始化一条全零Q值记录class QAgent: def __init__(self, action_size, alpha0.1, gamma0.9, epsilon0.3): self.action_size action_size self.alpha alpha self.gamma gamma self.epsilon epsilon self.q_table {} def get_q(self, state): if state not in self.q_table: self.q_table[state] np.zeros(self.action_size) return self.q_table[state] def choose_action(self, state): q self.get_q(state) if np.random.random() self.epsilon: return np.random.randint(self.action_size) return int(np.argmax(q)) def learn(self, state, action, reward, next_state, done): q self.get_q(state) q_next self.get_q(next_state) best_next 0 if done else np.max(q_next) td_target reward self.gamma * best_next q[action] self.alpha * (td_target - q[action])Q-learning的更新核心就是这行q[action] self.alpha * (reward self.gamma * best_next - q[action])。其中best_next在done为True时必须置0否则会把终点状态的Q值错误传播给前一步。这个细节是训练发散的一个典型来源务必检查。训练调度层用最简单的方式跑def train(env, agent, episodes1000, max_steps200): for ep in range(episodes): state env.reset() total_reward 0 done False steps 0 while not done and steps max_steps: action agent.choose_action(state) next_state, reward, done env.step(action) agent.learn(state, action, reward, next_state, done) state next_state total_reward reward steps 1 agent.epsilon max(0.05, agent.epsilon * 0.995) if ep % 100 0: print(fepisode {ep}, reward{total_reward:.2f}, steps{steps})我习惯每100个episode用贪心模式temporarily把epsilon设成0做一次路径回放记录“是否成功到达目标”作为训练过程是否收敛的观测指标。光看total_reward有时候会被密集奖励的数值骗到成功率才是最终目的。3.3 策略保存为什么np.savez比pickle稳项目做到002版新增的“保存和读取策略”核心原因很简单训练一次Q表不容易尤其三维地图训练时间可能从几分钟到几十分钟如果每次启动程序都重新训练效率太低。保存策略既能复用训练结果也能把不同参数下的模型存档对比。我选np.savez而不是pickle主要有几个原因。一是np.savez只存数组和标量不涉及Python对象的序列化协议兼容性更好不会出现“换台机器不同Python版本加载报错”的问题。二是np.savez_compressed自带压缩三维Q表动辄几十MB时压缩效果明显。三是可以用key-value方式在同一个文件里同时保存Q表、超参数、地图元信息结构清晰。保存函数这样写def save_policy(agent, env, filename): states np.array(list(agent.q_table.keys()), dtypenp.int16) values np.array(list(agent.q_table.values()), dtypenp.float32) np.savez_compressed( filename, statesstates, valuesvalues, alphanp.float32(agent.alpha), gammanp.float32(agent.gamma), epsilonnp.float32(agent.epsilon), world_shapenp.array(env.dims, dtypenp.int16), startnp.array(env.start, dtypenp.int16), goalnp.array(env.goal, dtypenp.int16), )有人说Q表用字典直接保存为pickle就完事了但pickle保存的是整个字典对象文件格式和Python版本强相关。np.savez方案看着多写几行换来的是长期可复用。3.4 策略读取与路径复现加载后必须记得关探索加载策略的过程是保存的逆操作需要注意两点把数组键重新转成tuple以及把保存的元信息和当前环境做对比。完整加载逻辑def load_policy(agent, filename): data np.load(filename, allow_pickleFalse) for s, v in zip(data[states], data[values]): agent.q_table[tuple(s)] v agent.alpha float(data[alpha]) agent.gamma float(data[gamma]) agent.epsilon float(data[epsilon]) return data加载完成后做路径回放时一定要先把agent.epsilon设为0再用argmax选动作。这里有个很常见的坑加载策略后epsilon还是0.3智能体有30%的概率随机乱走看起来就是“策略白加载了”。调试这种问题的时候先检查加载后epsilon的取值。路径复现的代码很短def replay(env, agent, max_steps200): state env.reset() path [state] agent.epsilon 0.0 for _ in range(max_steps): action agent.choose_action(state) state, _, done env.step(action) path.append(state) if done: break return path返回的path就是三维坐标序列可以直接输出成日志也可以传给matplotlib的三维散点图做可视化。三维路径可视化比二维要直观很多把path按顺序连线再叠加障碍物体素能看到智能体是怎么爬楼层、绕障碍物的。4. 调试心得与常见问题速查4.1 训练不收敛先查这五个环节这是我在三维版本遇到最多的问题。训练跑了几百个episodeepisode reward一直在波动成功率始终上不去多半是下面某一个环节出了问题第一epsilon衰减过慢或过快。过慢导致后期仍然是随机探索已经学到的策略被不断覆盖过快导致前期探索不够Q表大量状态没有准确值。第二alpha过大。学习率大于0.3时Q值更新波动很大像醉酒走路训练曲线来回震荡。第三奖励量级不合适。时间惩罚与障碍物惩罚差太多智能体会倾向于某一种极端行为比如原地不动。第四max_steps太短。三维路径可能确实需要走很长时间200步是不够的可以提高到500或者1000。第五地图本身不可达。这个一定要用BFS预检不然怎么调参都不可能收敛。如果这些检查完还没起色我建议把episode数量翻倍试试。三维空间需要的探索量远超二维1000个episode只是起步复杂地图3000~5000很正常。4.2 Q表膨胀与内存优化稀疏访问是核心思路三维Q表最大的隐患是内存增长。如果地图是50×50×50理论状态数125000再乘6个动作接近75万条记录。如果智能体探索得很充分全量存入字典也需要数百MB内存。我的处理思路是只给实际访问过的状态分配Q值记录。在choose_action和learn里统一调用get_q第一次遇到某个状态时才创建全零数组。这样障碍物内部的、永远访问不到的状态根本不会出现在Q表里。再加上np.savez_compressed自带的压缩能力磁盘占用也能控制住。如果地图精度再往上走比如每秒移动需要0.1格的连续空间那表格型方法就撑不住了。这种时候需要换成函数逼近方案比如DQN。但那是另一个量级的问题小规模三维网格里Q-learning加稀疏访问完全够用。4.3 加载策略后路径不对多半是这几种情况加载策略后路径完全错乱先不要怀疑保存代码先排查三件事。第一epsilon没有置0。这是最高频的原因一句话总结就是“贪心策略没开”。第二地图不一致。加载时如果重新生成了一张新的随机障碍物地图和保存时的地图完全不同Q表自然失效。所以保存时我特意把world_shape、start、goal存进文件加载后做一个简单比对不一致就立即报警。第三状态键的类型不一致。字典用tuple作键但np.load出来的states是一个二维数组遍历时数组元素是np.array。如果直接用np.array作键去查Q表永远查不到必须用tuple(s)转回tuple否则Q表看起来是空的实际上只是键类型不匹配。第四种情况不那么容易发现保存的Q表里大量状态的值都是初始化的0。这是因为训练中很多状态只被少量访问过Q值没有学习充分。加载后这些状态的值还是0argmax会选择第一个动作导致路径在未充分训练区域表现很差。解决办法是在保存前做充分训练或者加载后用小规模额外训练做微调也就是warm start迁移学习。4.4 三维路径特别容易绕远路奖励系数是罪魁祸首三维里智能体绕远路的现象比二维明显。原因很多最主要的是距离奖励与时间惩罚的比例失衡。如果我加大时间惩罚到-0.5智能体确实会少绕圈但有时候为了省几步会往障碍物里撞整体路径反而更差。如果时间惩罚太小智能体又不在乎路径长度怎么绕都行。我最终把时间惩罚稳定在-0.05与距离奖励0.5配合。这个组合让智能体优先朝目标方向走同时不会为了零点几的奖励差去冒险穿墙。如果想进一步压缩路径长度可以换成比例更强的惩罚比如时间惩罚-0.2、距离奖励系数0.2训练完成后多跑几次验证是否稳定。还有一个容易被忽略的点三维网格的坐标顺序。很多地图生成工具里x、y、z轴的增量方向并不统一如果环境类的动作增量和地图生成模块的坐标含义对不上路径会“整体镜像翻转”。遇到这种情况先打印一条无障碍直线路径看方向是否符合预期再继续调策略。4.5 顺手把测试阶段做个简单的成功率统计项目做到能保存和读取策略这个版本我会习惯在训练结束后留一小段评估代码把当前策略在随机生成的多个测试地图上跑一遍统计成功率。代码非常简单def evaluate(agent, env, episodes10, max_steps300): success 0 for _ in range(episodes): path replay(env, agent, max_steps) if path[-1] env.goal: success 1 return success / episodes这个百分比指标能直观反映策略的水平。看到成功率从0.1涨到0.9再稳定下来比盯着reward曲线踏实得多。三维Q-learning项目里我强烈建议训练和评估代码必须分离训练用epsilon探索评估必须关掉epsilon这是维持可信度的底线。整套代码跑下来我的体会是三维Q-learning的代码量并没有比二维大多少真正费时间的是奖励函数比例和epsilon衰减这类超参数的微调。还有一个非常实际的小建议保存策略时顺手在文件名里带上地图尺寸和训练episode数量比如qtable_20x20x20_ep2000.npz。这样后续加载对比时一眼就能知道这份策略是在什么条件下训练的避免不同配置的策略互相覆盖。三维路径规划的项目到这个程度已经能支撑很多具体应用后续如果想让路径更顺滑可以在Q表策略基础上加一个路径平滑后处理或者把网格精度提高一层重新训练。每一步扩展都建立在当前这套保存与读取机制之上。