给2D小车装上“大脑”这件事,我直到现在还记得第一次跑通时的画面:一辆用方块画的简易机器人在一堆圆形障碍物之间绕来绕去,最终停在了目标点上。路径谈不上优雅,甚至有点神经质,但它确实在没有一行人工规则的情况下自己学会了避障和导航。如果你也想上手深度强化学习,又不想一开始就被3D仿真、机械臂动力学、多传感器标定这些东西淹没,那2D机器人深度强化学习控制这条路线,我强烈建议你认真走一遍。
这篇内容不是理论综述,而是我实际做项目时沉淀下来的流程和踩坑记录。我会从为什么选2D环境讲起,带你从零搭一个自己的机器人训练环境,然后聊状态、动作、奖励这三个决定成败的设计,再给出一套PPO训练的完整代码和调参经验,最后说说这些仿真结果怎么才能真正迁移到真实机器人上。无论你是正在学强化学习的学生,还是已经在做机器人控制的工程师,都应该能从里面拿到直接能用的东西。
1. 为什么先用2D:一个"看起来简单"但价值极高的模型
很多人一谈到机器人强化学习,脑子里立刻出现的是三维仿真里的人形机器人、机械臂抓取、四足机器人翻越障碍,于是直接从Gazebo、Isaac Gym起步。这个想法不能说错,但对刚接触深度强化学习的人来说很容易劝退。我见过不少朋友一上来就被渲染引擎、模型URDF、通信接口劝退,最后连一个完整的训练脚本都没跑通。
1.1 别把"2D"当成幼稚园版本
2D环境看起来像是简化版,实际上它保留了强化学习所有核心矛盾:状态够不够、奖励会不会误导、策略收敛不收敛、仿真和实物差多少。这些矛盾在2D里已经足够让你头疼,也足够让你学到真本事。
拿我做的差速驱动机器人来说,状态就是位姿、速度、目标方向、激光距离,动作就是线速度和角速度。就这么简单的一个设定,我第一次做的策略依然会原地转圈、会贴着墙蹭过去、会在目标点附近反复摇摆。解决这些问题的过程,和你在3D环境里要做的事情一模一样:改观测,调奖励,换超参数,设计评估。2D只是让这些调试更快的加速器。
从工程角度看,2D有实打实的优势。训练速度快,一个几万步的实验几分钟就能跑完,一天能迭代几十个版本;可视化简单,能直接画轨迹看行为;环境完全可控,想模拟传感器噪声、控制延迟、动力学误差都很容易。相比起来,3D环境跑一次训练动辄几小时起步,出了问题你要花大量时间判断是策略问题还是环境问题。
1.2 2D适合练什么控制问题
很多控制问题天然就是2D的,或者可以安全地投影到2D平面来处理。我列几个我自己做过或者见过别人做得比较多的场景:
| 问题类型 | 典型任务 | 为什么适合2D |
|---|---|---|
| 平面导航 | 小车从起点到目标点避障 | 移动机器人在平地上就是2D运动规划问题 |
| 追逐与逃逸 | 追击移动目标,同时躲避拦截 | 位置、方向、速度都能在2D平面表达 |
| 2D机械臂 | 两连杆机械臂抓取平面物体 | 关节角、末端位置都在平面内 |
| 倒立摆类 | 平衡、摇摆上坡 | 这是经典2D基准,状态极少但难度不小 |
| 多机器人协作 | 编队、互相避让 | 2D下能直接观察交互行为是否合理 |
用2D做这些任务,能快速检验一个算法在你的控制对象上到底work不work,然后再决定要不要投入资源做高保真仿真或实物平台。
1.3 很多强化学习工程师的习惯:先跑2D再升级
我后来在社区里和几个做导航算法的朋友交流,发现大家不约而同有一个步骤:新点子在2D上验证,验证通过了再往3D、往真实机器人迁移。这个流程有点像写代码之前先画伪代码,成本极低,但能排除掉绝大部分低级设计错误。
打个比方,如果2D环境下你的策略都学不出合理的避障行为,那几乎可以肯定不是3D渲染不够真实的问题,而是状态设计或者奖励函数本身有毛病。这个判断在2D阶段做,十分钟就能得出,挪到3D阶段,可能要耗掉一周。别小看这一步,它可能是你整个项目里性价比最高的投资。
所以在进入实操之前,我希望你先建立一个认知:2D不是目标,是手段。它的价值在于让你用最低成本理解"深度强化学习怎么和机器人控制系统结合"这件事。后面所有在2D里养成的调试习惯,最后都会跟着你一起上实物。
2. 自建2D机器人训练环境:从运动学模型到传感器模拟
环境是强化学习的地基。算法脚本大家写的都差不多,真正拉开差距的是环境设计。我强烈建议你自己动手写一个2D机器人环境,而不是只用现成的健身房标准环境。原因后面说,我们先看怎么搭。
2.1 为什么自己写环境而不是直接用现成库
Gymnasium里确实有CartPole、MountainCar这类经典环境,它们适合学算法原理,但不适合学机器人控制。因为动作空间、观测方式、物理模型都和真实机器人差太远。而MuJoCo、PyBullet这类物理引擎环境虽然真实,但环境结构和机器人模型都写死了,改起来费劲,调试起来更费劲。
自己写2D环境最大的好处是每一个细节都透明:运动学方程是你定的,传感器数据是你模拟的,奖励逻辑是你设计的。训练出问题时,你能非常确定问题出在哪一层。这和用别人黑箱环境完全不是一个体验。
另一个好处是方便和真实的机器人对接。如果你后面要上实物车,把仿真环境里的观测替换成真实传感器数据,把动作输出接到真实电机驱动接口,这个改造路径会非常顺。因为你从一开始就知道观测空间的每一项是什么含义、从哪来、要去哪。
2.2 环境最小骨架:动作空间、观测空间与步进逻辑
我用Gymnasium的标准接口来写,因为Stable-Baselines3等算法库直接支持这个接口,训练代码几乎不用额外适配。下面给出一段最小骨架,实际项目里你可以在上面叠加更多细节。
import numpy as np import gymnasium as gym from gymnasium import spaces class SimpleDiffDriveRobot(gym.Env): def __init__(self, max_steps=200): super().__init__() # 动作:线速度(m/s),角速度(rad/s) self.action_space = spaces.Box( low=np.array([-0.6, -1.2]), high=np.array([0.6, 1.2]), dtype=np.float32 ) # 观测:目标相对角度、目标距离 + 8束激光测距 + 当前线速度和角速度 self.observation_space = spaces.Box( low=-np.inf, high=np.inf, shape=(13,), dtype=np.float32 ) self.max_steps = max_steps self.steps = 0 def reset(self, seed=None, options=None): super().reset(seed=seed) self.pos = np.zeros(2, dtype=np.float32) # 起点在原点 self.theta = 0.0 # 朝向 self.v = 0.0 # 当前线速度 self.omega = 0.0 # 当前角速度 self.goal = np.array([5.0, 5.0], dtype=np.float32) self.steps = 0 return self._get_obs(), {} def _get_obs(self): dx = self.goal[0] - self.pos[0] dy = self.goal[1] - self.pos[1] dist_to_goal = np.sqrt(dx**2 + dy**2) angle_to_goal = np.arctan2(dy, dx) - self.theta # 归一化角度到[-pi, pi] angle_to_goal = np.arctan2(np.sin(angle_to_goal), np.cos(angle_to_goal)) # 激光测距:这里假设周围有8个方向的传感器 lidar = self._scan_lidar(8) obs = np.concatenate([ [angle_to_goal, dist_to_goal, self.v, self.omega], lidar ]).astype(np.float32) return obs def step(self, action): # 控制量平滑,防止策略输出剧烈抖动 target_v = float(action[0]) target_omega = float(action[1]) self.v += 0.2 * (target_v - self.v) self.omega += 0.2 * (target_omega - self.omega) # 简单的差速运动学模型 dt = 0.1 self.theta += self.omega * dt self.pos[0] += np.cos(self.theta) * self.v * dt self.pos[1] += np.sin(self.theta) * self.v * dt self.steps += 1 # 奖励、终止判断等逻辑在这里完成 reward, terminated, info = self._compute_reward_and_done() if self.steps >= self.max_steps: terminated = True return self._get_obs(), reward, terminated, False, info def _scan_lidar(self, num_rays): # 用几何方法计算各方向的最近障碍距离,返回数组 distances = np.ones(num_rays, dtype=np.float32) * 5.0 # 实际实现需要根据障碍物集合做射线检测 return distances def _compute_reward_and_done(self): # 奖励逻辑在下一章详细展开 return 0.0, False, {}这段代码强调了几个关键细节:状态量都用float32,这是深度学习框架的默认精度;动作空间在初始化时给定了合理的物理范围,避免网络输出无意义的巨大控制量;控制量做了平滑处理,后面你会发现这对训练稳定性帮助极大。
2.3 差速运动学模型与2D激光雷达模拟
如果你的机器人模型是常见的两轮差速底盘,那么上面的运动学公式已经够用了。角速度直接改变航向角,线速度沿当前朝向位移。这个模型虽然简单,却保留了一个非常重要的特性:机器人不能横向平移,必须绕个弧线才能转到目标方向。这个约束在2D环境下就会让策略学会类人的转向路径,而不是像全向移动一样直接斜切过去。
激光雷达模拟我建议用射线检测来做。假设地图由若干圆形障碍物组成,传感器从当前位置按角度发射射线,分别计算射线与每个障碍圆的最短交点距离,取最小值。这个算法在2D下非常好实现,而且模拟出来的雷达数据趋势和真实雷达非常接近。如果你用真实的2D激光雷达,后续迁移时观测格式几乎完全一致。
2.4 渲染与调试:没有画面,很难判断机器人在干什么
训练曲线能告诉你策略学得怎么样,但很难告诉你它为什么这么做。很多奇怪行为必须亲眼看轨迹才能发现。比如小车在目标点附近画圈,从奖励曲线看是在涨,但从轨迹看完全是在钻规则漏洞。
所以在环境里一定要实现渲染方法。Gymnasium里可以用pygame或matplotlib画俯视图:机器人画成一个带朝向箭头的方块,目标画成一个小圆,障碍物画成灰色圆形,把激光射线也画出来。这样你就能实时观察策略在仿真里的行为模式。不要省这一步,我见过太多人只看奖励曲线调参,调了一周都不知道机器人在干什么。
3. 状态怎么选、动作怎么给、奖励怎么定:训练之前先解决这三件事
如果你把深度学习看作函数拟合,那状态就是输入,动作就是输出,奖励就是监督信号。三个元素任何一个设计歪了,再好的算法也白搭。这一章我详细说说每个设计背后的思路。
3.1 观测设计的三个原则与常用编码方式
第一个原则是相对量优先于绝对量。绝对坐标不是不能用,但一旦地图更换或起点位置变化,网络就要重新学。而目标相对角度、目标相对距离这类量,不随坐标系平移旋转改变,泛化能力天然更强。我在实际项目里用纯相对量做观测,训练出来的策略换个起始位置也能跑,但用绝对坐标的那个版本完全不具备这种能力。
第二个原则是状态必须包含速度信息。很多第一次写环境的人只给位置和角度,不给速度和角速度。结果就是策略不知道机器人当前动得快不快,输出动作就非常飘。加上v和omega这两个量之后,训练稳定性明显上升,因为网络总算能判断"我现在是停着的还是在往前冲"。
第三个原则是动态范围要友好。强化学习算法对输入尺度很敏感,目标距离如果是几十米,网络输入这个量级的大数,训练会很别扭。我习惯把距离除以最大距离归一化到[0,1]附近,角度直接归一化到[-1,1],速度除以最大速度。激光距离也做截断归一化。这么处理后训练收敛速度通常快上不少。
3.2 动作空间:连续输出与离散输出的实际差异
2D机器人控制动作空间有两种常见选择。一种是用连续值控制线速度和角速度,另一种是把动作离散成"前进、左转、右转、后退、停止"几个挡位。两者对应完全不同的算法家族:连续动作基本告别DQN,必须用PPO、SAC、TD3这类支持连续控制的算法;离散动作则可以继续用DQN这类经典方法。
从实际控制效果来看,我推荐你优先用连续动作空间。原因很简单:真实机器人电机的控制量是连续的,离散动作意味着一档一档切换,行为会明显僵硬。有些离散动作甚至没法表达"缓慢右转+保持速度"这种复杂组合,策略的表达能力受限。如果你的任务动作维度很低,离散化勉强可以接受,但连续控制才是更通用的方向。
3.3 奖励塑形的完整公式与系数选择的解释
奖励设计是整个项目里最容易被低估的部分。我用的那套方案可以拆成四个部分,每一项都有明确作用:
r = 1.0 * (d_t - d_{t+1}) # 势场引导:靠近目标给正反馈 - 0.01 # 时间惩罚:鼓励尽快完成 - 8.0 * collision # 碰撞惩罚:红灯行为 + 100.0 * reached # 到达奖励:稀疏大奖励,任务完成信号势场引导项(d_t - d_{t+1})是核心。它让智能体每走一步都能得到一个密集反馈:离目标更近了奖励为正,更远了奖励为负。这个设计比单纯给最终的稀疏奖励收敛快得多。但我必须提醒你,势场引导容易被钻空子,后面会专门说这个问题。
时间惩罚为什么不设成0?因为如果没有它,策略可能学会了绕远路、原地磨蹭也能拿到正的势场奖励,反正早晚能到。加了时间惩罚之后,绕远路就会被持续扣分,策略有动力走更短的路径。
到达奖励和碰撞惩罚都是terminal信号,权重应该明显大于普通步的奖励。这样策略才能明确区分"任务成功"和"严重错误"两种大事件。我的习惯是:惩罚做碰撞时,权重要让一个碰撞的惩罚大于几十步正常行走的奖励,否则策略会觉得撞一下不亏。
3.4 奖励黑客案例:我的小车学会"原地打转"
这是我真实踩过的坑,说出来给你避个雷。最初版本的奖励设计是:每一步给距离负值(离目标越远负得越多),到达给正奖励。小车确实学到了往目标方向走,但走了一半之后开始绕着一个半径很小的圆不停转圈。
为什么?因为原地转圈的时候,机器人到目标的距离不变,负奖励保持不变,但它通过反复改变朝向刷出了一系列"朝向变化"类特征,最终让某些中间变量在累积过程中产生虚假的势场增益。更直接的原因其实是势场项在转圈过程中每个步对距离差贡献接近零,而时间惩罚又小到几乎不计,于是它宁可这样原地耗着也不直行,因为直行可能因为惯性冲过头、瞬时距离变大反而扣分。
解决方式很简单:加大时间惩罚,同时给到达奖励加一个区域判定——进入目标点附近一定半径就算到,而不是非要精确到位。还有一个更稳的做法是,把势场引导设成只在连续两步都靠近目标时给正向反馈,避免它反复横跳刷奖励。你也可以在终止条件上做文章,比如允许小范围超调后限制步数,逼它尽快抵达。
4. 用PPO训练2D机器人的完整流程与调参经验
环境写好了,奖励函数也定了,接下来是真正的重头戏:训练。我推荐的第一选择是近端策略优化(PPO),不是因为它最先进,而是因为它最稳、最容易出活。
4.1 算法选型对比:DQN、PPO、SAC、TD3分别适合什么
先给一张对比表,方便你根据自己任务选择:
| 算法 | 类型 | 动作空间 | 样本效率 | 调参难度 | 适合场景 |
|---|---|---|---|---|---|
| DQN及其变体 | off-policy | 离散 | 中等 | 较低 | 动作有限、任务明确 |
| PPO | on-policy | 连续/离散 | 较低 | 低 | 训练稳定、通用性强的首选 |
| SAC | off-policy | 连续 | 较高 | 中等 | 样本有限且需要高效利用 |
| TD3 | off-policy | 连续 | 较高 | 中等 | 追求确定性策略的控制任务 |
从样本效率来看SAC和TD3都比PPO高,那为什么我还推荐PPO?因为样本效率高不代表训练过程容易掌控。SAC调温度参数、调熵正则,TD3的延迟更新和噪声机制理解成本更高。对大多数刚起步的2D机器人项目,训练一个episode只有几步,样本量根本不是瓶颈,稳定收敛才是。PPO在这方面的容错率最高,最少能用一套默认参数跑出来一个像样的结果。
如果你的环境非常宝贵、每一步都要很重成本,再考虑SAC。但该做两轮PPO确认问题和设计方向,再切换到SAC。用PPO做基线,用SAC做冲刺,这个策略帮我在好几个项目里省了大量时间。
4.2 用Stable-Baselines3跑通训练:代码与参数解释
假设你已经实现了上一章的环境类,现在把它扔给算法库训练。我用的是Stable-Baselines3,它是目前最省心的算法实现之一,支持向量化环境、回调函数、模型保存,做2D控制足够用。
from stable_baselines3 import PPO from stable_baselines3.common.env_util import make_vec_env from stable_baselines3.common.callbacks import EvalCallback # 创建8个并行环境,加快数据采集 env = make_vec_env(SimpleDiffDriveRobot, n_envs=8) # 参数说明: # learning_rate=3e-4:PPO常用默认学习率,过高会导致不稳定 # n_steps=2048:每个环境每次采集的步数,8个环境就是16384步 # batch_size=256:更新时用的批次大小 # gamma=0.99:折扣因子,适应中等时长episode # ent_coef=0.0:熵系数,先设0,看情况再调 model = PPO( "MlpPolicy", env, learning_rate=3e-4, n_steps=2048, batch_size=256, gamma=0.99, ent_coef=0.0, clip_range=0.2, verbose=1, ) # 评估回调:每5000步评估一次,保存最优模型 eval_cb = EvalCallback( env, best_model_save_path="./logs/best_model", log_path="./logs/results", eval_freq=5000, n_eval_episodes=5, ) model.learn(total_timesteps=500_000, callback=eval_cb) model.save("ppo_simple2d_final")这段代码里我特别想强调两个参数。一个是gamma设为0.99:如果你的任务步骤上限是200,50步之后的一分钱奖励打到当前只有大约0.99的50次方约0.6,这个折扣率对中距离导航是合理的。如果任务需要看很远期的收益,可以把gamma调到0.995。另一个是ent_coef,熵系数。它控制策略的探索程度,0代表完全靠奖励引导,训练后期如果需要防止过早收敛,我会调成0.01左右。
4.3 怎么判断训练是否健康:学习曲线与回调函数
训练过程中大多数人是盯着一行行日志看的,但我不建议只盯数值。我习惯的做法是看三个东西的组合:ep_rew_mean(每episode平均奖励)、ep_len_mean(每episode平均步数)、以及rollout里策略的熵值。
奖励均值上升且方差可控,说明在学习;步数均值下降,说明策略在优化行为路径;熵值慢慢变小,说明策略从随机探索逐渐走向确定。如果熵值掉得飞快、奖励曲线却很平,多半是过早收敛到了局部策略,这时候可以加ent_coef或降低学习率再试。
另外EvalCallback非常推荐,它会定期用独立环境评估策略,保存最优模型。这样即使训练到尾期发生不稳定的奖励崩溃,你手里始终有一个历史最优版本可以用,不用重新训练。
有条件的话,我建议每评估完一轮就录一段视频,肉眼确认策略行为确实如预期,而不是只信数字。我遇到过奖励在涨但策略在钻空子的情况,如果不是看了录像,根本发现不了。
4.4 训练不收敛排查清单
训练翻车是最常见的事,我列一份自己的排查清单,按优先级排列:
| 症状 | 可能原因 | 尝试方案 |
|---|---|---|
| 奖励完全不动 | 奖励尺度太小或观测没归一化 | 检查奖励数量级,调整系数 |
| 策略震荡厉害 | 学习率过高或批次过大 | 降到1e-4,减小batch_size |
| 行为完全随机 | 熵系数过高或奖励太稀疏 | 降低ent_coef,增加势场引导 |
| 奖励爆炸 | 奖励权重过大 | 整体缩小奖励尺度,看趋势 |
| loss出现NaN | 输入包含inf或学习率过高 | 在环境中clip观测,降低学习率 |
| 撞墙不避让 | 激光观测无效或碰撞惩罚太轻 | 检查雷达范围、加大碰撞权重 |
还有一条经验:在怀疑算法有问题之前,先用随机策略跑一遍你的环境和奖励函数,看看平均奖励是多少。如果随机策略都能拿到正奖励,说明奖励设计可能太宽松;如果随机策略连探索都做不了,那问题多半出在奖励稀疏性上。这个基线能帮你快速定位是环境的问题还是算法的问题。
5. 从仿真到实物的迁移:2D训练结果如何被真实机器人用起来
很多人训练完2D仿真策略,觉得任务结束了。但2D机器人深度强化学习控制的真正价值在于落地。这一步最难,也最容易被低估。
5.1 仿真和实物之间到底差了些什么
仿真和实物的差距可以归纳成四类:动力学差异、感知差异、执行延迟、安全风险。
动力学差异最常见的是摩擦、质量分布、轮子打滑。仿真里走直线的车,实物可能由于电机差异走歪。感知差异在于仿真激光雷达数据干净得离谱,真实雷达有噪声、有丢帧、有测量极限。执行延迟则是因为真实系统从发出动作到电机响应之间有一段延迟,仿真里不会有。安全风险就更好理解了:仿真撞了没事,实物撞了要修。
这些差异不可能靠调一个参数消除,只能靠设计策略时留出鲁棒性余量。
5.2 分层控制:RL做上层决策,PID/FOC做底层执行
我的推荐方案是分层控制,而不是让强化学习策略直接输出PWM脉宽信号给电机。底层还是走传统控制路线,用PID或者更底层的FOC控制电机电流和速度,上层由强化学习策略输出目标速度、目标角速度这类高层的运动指令。
举个例子,策略输出"线速度0.3m/s,角速度0.5rad/s",底层有一堆PID环路负责:轮子转速有没有跟上、两个轮子有没有转速差、电流有没有过流。这样强化学习只需要学会行为逻辑,不需要学习电机的电流波形和PWM频率。后者是PID这类经典的强项,没必要让神经网络重学一遍。
实际上很多开源机器人控制库,包括一些ROS2教程里的架构,都是这个套路。底层控制环路越扎实,上层策略训练越省心。
5.3 域随机化与动作平滑:让策略别太"脆"
想让仿真策略在实物上也能用,一个非常实用的技巧是域随机化。在训练阶段,不要把环境参数固定死,而是每次随机取一个范围。
比如机器人的最大速度在0.5到0.7之间随机,碰撞检测半径在0.15到0.25之间随机,激光噪声的方差在某个范围内随机,执行延迟在1到3个控制周期之间随机。策略在训练时见过各种各样的"身体状态",到实物上遇到哪怕偏差较大的情况,也大概率能应对。这个方法成本不高,但效果非常明显。
动作平滑我在环境设计时已经提过,就是不能让策略输出的动作直接生效,而要做一个低通滤波。真实机器人响应速度有限,爆炸性的动作变化会对机械结构造成冲击,也会让控制环路振荡。我在实物迁移时发现,不做动作平滑的话,策略输出的方向角速度抖动会直接反映成车身的S形摆动,做了平滑之后明显稳定。
5.4 安全护栏:动作限幅、急停与故障处置
仿真里怎么折腾都行,实物机器人必须加防护。动作限幅是第一道防线,无论策略输出什么,最终下发给电机的速度不超过机械允许的极限。这个限幅值一定要在环境设计时就写进去,让策略在仿真里就习惯在限幅范围内思考。
急停逻辑不可省。我建议在控制程序里加一个独立看门狗进程,不依赖策略运行。一旦检测到轮子堵转、电流过大、障碍物距离低于安全阈值,直接接管电机控制并停车。策略的正常训练与运行和底层的保护机制必须分离,否则策略一旦输出错误动作,没有任何东西能兜底。
还有一个工程上的小细节:在实物实车之前,先在仿真里模拟通信延迟和丢包。把动作指令每隔一段时间随机丢弃,观察策略表现是否还能稳定。如果不行,说明策略太依赖精确连续的执行环境,需要加强鲁棒性设计。
最后说一句私货:在2D阶段踩过的坑,比如奖励尺度太大导致小车原地转圈、观测里忘了放速度导致动作疯狂抖动,这些教训后来帮我省了巨量时间。我的习惯是每改动一次奖励就把机器人轨迹画出来看一眼,而不是只盯reward曲线。这个习惯,从2D一直带到了实物车,也推荐你养成。