1. 从零搭建2D机器人强化学习环境:为什么我不建议一上来就写训练循环
很多人第一次接触深度强化学习控制,脑子里想的都是“赶紧把PPO或者SAC跑起来”,结果环境还没搭明白,训练曲线就已经开始发散了。我踩过这个坑,而且踩得很深。2D机器人控制这个题目看起来简单——不就是一个小人在平面上移动、避障、到达目标点吗?但真正动手之后你会发现,环境的设计质量直接决定了算法能不能收敛,甚至决定了你后面几个星期是在调参还是在调环境。
1.1 2D机器人控制任务的本质是什么
先把问题说清楚。2D机器人深度强化学习控制,核心是让一个在二维平面上的智能体,通过连续的动作输出(通常是线速度和角速度,或者两个方向上的力),学会完成某个任务。这个任务可能是导航到目标点、追踪一条轨迹、避开障碍物,或者更复杂的多目标协同。
它和经典的CartPole、MountainCar这类玩具环境有本质区别。CartPole的状态空间只有4维,动作空间是离散的两个值,随便一个DQN都能跑出不错的效果。但2D机器人不一样,它的状态空间通常是连续的、多维的,动作空间也是连续的。这意味着你必须面对连续控制问题,必须处理状态归一化、动作裁剪、奖励稀疏性等一系列工程细节。
我一般把2D机器人控制任务分成三个层次来理解:
- 运动学层面:机器人怎么动。是差速驱动模型,还是全向移动模型?这决定了你的动作空间怎么定义。
- 感知层面:机器人能看到什么。是全局坐标直接给,还是只能拿到局部观测?这决定了你的状态空间怎么设计。
- 任务层面:机器人要完成什么。是点对点导航,还是动态避障,还是多机器人协作?这决定了你的奖励函数怎么设计。
这三个层面任何一个没想清楚,后面训练的时候都会出问题。我见过太多人状态空间里混入了绝对坐标,结果换个初始位置模型就完全失效;也见过奖励函数只给到达奖励不给过程奖励,训练了上百万步还在原地打转。
1.2 环境搭建中最容易忽略的五个细节
环境搭建这件事,说简单也简单,说复杂也复杂。我用过Gymnasium、PyBullet、以及自己手写的轻量级仿真器,总结下来有五个细节是新手最容易忽略的。
第一个是时间步长和物理步长的关系。很多仿真器默认物理步长是1/240秒,但你的控制频率可能是10Hz或者20Hz。这意味着一个控制步内物理引擎要跑12到24次迭代。如果你直接把控制步长设成物理步长,机器人会变得极其敏感,动作稍微大一点就飞出去了。我的做法是控制频率保持在10到20Hz,物理步长用默认值,中间用插值或者保持的方式对齐。
第二个是状态归一化。2D机器人的位置可能是[-10, 10],速度可能是[-2, 2],角度是[-π, π]。这些量纲完全不同的特征直接喂给神经网络,训练效率会非常低。我通常会把所有状态量归一化到[-1, 1]或者[0, 1]之间,具体用哪个区间取决于激活函数的选择。用tanh激活就归一化到[-1, 1],用ReLU就归一化到[0, 1]。
第三个是动作空间的边界处理。连续动作空间通常用高斯分布采样,但采样出来的值可能超出物理限制。你可以选择裁剪,也可以选择用tanh压缩。裁剪的问题是梯度会在边界处消失,tanh压缩的问题是靠近边界时动作变化很小。我一般用tanh压缩,然后在环境内部再乘上最大速度限制。
第四个是终止条件的判定。什么时候算episode结束?到达目标点算结束,碰撞障碍物算结束,超出边界算结束,还是超过最大步数算结束?这些条件必须明确,而且终止时的奖励要给对。碰撞给大负奖励,超时给小负奖励,到达给大正奖励,这是最基本的框架。
第五个是随机种子的控制。强化学习对随机性极其敏感。环境初始化的随机、动作采样的随机、网络初始化的随机,任何一个没控制好,你的实验结果都不可复现。我的习惯是在环境reset的时候显式传入seed,在训练脚本开头固定numpy和torch的随机种子,在评估的时候用固定的初始状态集合。
1.3 一个最小可用的2D机器人环境代码框架
下面这个框架是我经过多次迭代后保留下来的,结构清晰,扩展方便。它基于Gymnasium接口,你可以直接拿去用。
import gymnasium as gym import numpy as np from gymnasium import spaces class Robot2DEnv(gym.Env): def __init__(self, max_steps=200, goal_radius=0.3): super().__init__() self.max_steps = max_steps self.goal_radius = goal_radius self.dt = 0.1 # 状态: [x, y, vx, vy, goal_x, goal_y] self.observation_space = spaces.Box( low=-1.0, high=1.0, shape=(6,), dtype=np.float32 ) # 动作: [ax, ay] 加速度 self.action_space = spaces.Box( low=-1.0, high=1.0, shape=(2,), dtype=np.float32 ) self.state = None self.step_count = 0 self.goal = None def reset(self, seed=None, options=None): super().reset(seed=seed) self.step_count = 0 # 随机初始位置 self.state = np.array([ self.np_random.uniform(-2, 2), self.np_random.uniform(-2, 2), 0.0, 0.0 ], dtype=np.float32) # 随机目标位置 self.goal = np.array([ self.np_random.uniform(-2, 2), self.np_random.uniform(-2, 2) ], dtype=np.float32) return self._get_obs(), {} def _get_obs(self): obs = np.concatenate([self.state, self.goal]) # 归一化到[-1, 1] obs[:2] /= 3.0 obs[2:4] /= 2.0 obs[4:6] /= 3.0 return obs.astype(np.float32) def step(self, action): action = np.clip(action, -1.0, 1.0) self.step_count += 1 # 简单运动学更新 self.state[2] += action[0] * self.dt * 2.0 self.state[3] += action[1] * self.dt * 2.0 # 速度限制 self.state[2] = np.clip(self.state[2], -2.0, 2.0) self.state[3] = np.clip(self.state[3], -2.0, 2.0) # 位置更新 self.state[0] += self.state[2] * self.dt self.state[1] += self.state[3] * self.dt # 计算奖励 dist = np.linalg.norm(self.state[:2] - self.goal) reward = -dist * 0.1 # 距离惩罚 terminated = False truncated = False if dist < self.goal_radius: reward += 10.0 terminated = True elif self.step_count >= self.max_steps: truncated = True elif abs(self.state[0]) > 3.0 or abs(self.state[1]) > 3.0: reward -= 5.0 terminated = True return self._get_obs(), reward, terminated, truncated, {}这个环境虽然简单,但包含了所有关键要素:连续状态、连续动作、距离惩罚、到达奖励、边界惩罚、超时截断。你可以在这个基础上加障碍物、加动力学约束、加多机器人,框架不用大改。
注意:这个环境的状态里包含了目标的绝对坐标,这意味着策略网络学到的是“往目标方向走”这个相对关系。如果你想让策略泛化到不同目标位置,这种设计是合理的。但如果你想让策略只依赖局部观测,就需要把目标坐标转换成相对坐标。
2. 算法选型:PPO、SAC还是TD3,2D机器人控制到底该用哪个
算法选型这件事,网上吵得很凶。有人说PPO万能,有人说SAC样本效率高,有人说TD3稳定。我的观点是:对于2D机器人控制这个具体场景,没有绝对的最优算法,只有最适合你当前阶段和资源的算法。
2.1 三种主流算法的核心差异对比
先上一张表,把三种算法在2D机器人控制场景下的表现做个直观对比。
| 维度 | PPO | SAC | TD3 |
|---|---|---|---|
| 样本效率 | 中等 | 高 | 高 |
| 训练稳定性 | 高 | 中等 | 中等 |
| 超参数敏感度 | 低 | 中 | 高 |
| 连续控制效果 | 好 | 很好 | 好 |
| 实现复杂度 | 低 | 中 | 中 |
| 适合场景 | 快速验证 | 精细控制 | 确定性任务 |
| 并行采样 | 支持 | 不友好 | 不友好 |
PPO是on-policy算法,每次更新完策略就要重新采样。这意味着它的样本效率天然不如off-policy的SAC和TD3。但PPO的好处是稳定,超参数就那么几个,学习率、clip范围、GAE参数,调一调总能跑出结果。我一般用PPO做第一轮验证,确认环境没问题、奖励设计合理之后,再换SAC做精细调优。
SAC是off-policy算法,用replay buffer存经验,可以反复利用历史数据。它的最大特点是最大熵框架,策略在追求高回报的同时保持一定的随机性。这个特性在2D机器人控制里非常有用,因为机器人需要探索不同的运动模式。但SAC对温度参数的调节比较敏感,自动调节温度虽然省事,但有时候会调过头。
TD3是DDPG的改进版,用双critic网络和延迟策略更新来缓解过估计问题。它在确定性任务上表现很好,但探索能力不如SAC。如果你的2D机器人任务奖励信号比较密集,TD3是个不错的选择。
2.2 为什么我最终选择了SAC作为主力算法
经过多轮对比实验,我最终把SAC作为2D机器人控制的主力算法。原因有三个。
第一是样本效率。2D机器人仿真的单步开销虽然不大,但训练一个可用的策略通常需要几十万到上百万步。PPO每步数据只能用一次,SAC可以重复利用。在我的测试中,达到相同性能SAC需要的环境交互步数大约是PPO的三分之一。
第二是探索能力。2D机器人控制有一个很微妙的问题:机器人很容易陷入局部最优的运动模式。比如它发现原地转圈也能获得一定的距离惩罚减免,就再也不尝试直线前进了。SAC的最大熵目标天然鼓励策略保持多样性,能有效缓解这个问题。
第三是超参数鲁棒性。虽然SAC比PPO多几个超参数,但自动温度调节机制让最关键的探索-利用权衡变得自动化。我只需要关注网络结构、学习率、buffer大小这几个相对不敏感的参数。
当然SAC也有坑。最大的坑是Q值过估计。虽然SAC用了双Q网络取最小值来缓解,但在奖励尺度比较大的时候仍然会出现。我的做法是把奖励缩放到[-1, 1]附近,然后适当增大target网络的更新系数。
2.3 SAC关键参数的计算与设置逻辑
SAC有几个参数需要你理解背后的逻辑,不能随便填。
折扣因子γ。这个参数决定了智能体对未来奖励的重视程度。2D机器人控制任务通常有明确的到达目标,episode长度在100到500步之间。γ设成0.99意味着有效视野大约是100步,设成0.995大约是200步。我一般根据最大episode长度来定,γ = 1 - 1/N,N取最大步数的一半左右。
软更新系数τ。这个参数控制target网络的更新速度。τ越大,target网络跟得越紧,训练越不稳定;τ越小,target网络越滞后,学习越慢。经验值是0.005,但我发现在2D机器人任务里0.01到0.02效果更好,因为状态空间相对简单,不需要太保守的更新。
温度参数α。SAC的核心是最大熵目标,α决定了熵项的相对权重。自动调节α的机制是让策略的熵维持在一个目标值附近。目标熵通常设为动作维度的负数,比如2维动作就设-2。这个设置背后的逻辑是让策略在每一维上保持适度的不确定性。
学习率。我一般用3e-4作为初始值,配合余弦退火或者阶梯下降。critic的学习率可以比actor稍大一点,因为critic需要更快地拟合Q值。但差距不要超过3倍,否则会出现critic追着actor跑的情况。
Replay buffer大小。这个参数取决于你的内存和任务复杂度。2D机器人控制的状态空间不大,100万条经验足够了。如果内存紧张,50万条也能跑。但不要低于10万条,否则off-policy的优势就体现不出来了。
# SAC关键参数配置示例 config = { "gamma": 0.99, # 折扣因子 "tau": 0.01, # 软更新系数 "alpha": 0.2, # 初始温度 "auto_alpha": True, # 自动调节温度 "target_entropy": -2.0, # 目标熵 "lr_actor": 3e-4, # actor学习率 "lr_critic": 1e-3, # critic学习率 "buffer_size": 1000000, # replay buffer大小 "batch_size": 256, # 批次大小 "hidden_dim": 256, # 隐藏层维度 }提示:target_entropy的设置很关键。设得太小,策略会变得过于随机,机器人像喝醉了一样乱走;设得太大,策略会过早收敛到确定性动作,失去探索能力。我的经验是从-动作维度开始试,然后根据训练曲线的表现微调。
3. 奖励函数设计:2D机器人控制中最容易翻车的环节
奖励函数设计是强化学习里最像“玄学”的部分,但它其实有章可循。我在2D机器人控制任务上前后改了十几版奖励函数,踩过的坑比前面所有环节加起来都多。
3.1 稀疏奖励、稠密奖励与混合奖励的取舍
稀疏奖励是最直观的:到达目标给+1,其他时候给0。这种奖励的问题在于,如果机器人随机探索很难碰到目标,那它永远学不到东西。2D机器人虽然状态空间不大,但如果目标位置随机、初始位置随机,随机策略碰到目标的概率可能只有千分之一甚至更低。
稠密奖励是每一步都给反馈,通常是距离的负值。这种奖励的好处是梯度信号密集,机器人很快就能学会往目标方向走。但坏处是机器人可能学会“磨洋工”——慢慢走也能获得差不多的累积奖励,因为距离惩罚是线性的,走得快和走得慢在单步奖励上差别不大。
混合奖励是我最终采用的方案:距离惩罚作为基础信号,到达奖励作为稀疏信号,再加上一些辅助信号。具体来说:
- 每步给距离的负值乘以一个系数,比如-0.1 * dist
- 到达目标给+10
- 碰撞障碍物给-5
- 超出边界给-5
- 如果机器人朝向目标方向运动,给一个小额正奖励
这个辅助的方向奖励很关键。它相当于给机器人一个“你走对了”的即时反馈,能显著加速早期学习。但系数不能太大,否则机器人会学会原地转向目标而不前进。
3.2 奖励尺度对训练稳定性的影响
奖励尺度这件事,很多人不注意,但它直接影响Q值的量级和训练的稳定性。如果你的到达奖励是+100,距离惩罚是-1,那Q值可能在几十到几百之间波动。神经网络拟合这么大的值需要更大的学习率或者更多的训练步数,而且容易出现梯度爆炸。
我的做法是把所有奖励缩放到[-1, 1]附近。到达奖励+1,碰撞-1,距离惩罚按最大距离归一化后乘以-0.1。这样Q值的量级就控制在个位数,训练稳定得多。
但缩放奖励有一个副作用:不同奖励项之间的相对权重变了。原来到达奖励是距离惩罚的100倍,缩放后可能变成10倍。这个比例需要重新调整。我一般会做一个简单的敏感性分析:固定其他奖励项,只调一个项的系数,看训练曲线的变化。
3.3 奖励函数设计的三个反直觉经验
第一个反直觉经验:惩罚太狠反而学得慢。我一开始把碰撞惩罚设得很大,-10甚至-20,想着让机器人“记住”不能撞。结果机器人学会了在原地不动,因为不动就不会撞。后来我把碰撞惩罚降到-1,同时给了一个小的“活着”奖励(每步+0.01),机器人反而更愿意探索了。
第二个反直觉经验:距离惩罚用平方比用绝对值好。绝对值距离惩罚是线性的,机器人远离目标时和靠近目标时的梯度一样大。平方距离惩罚在远离目标时梯度大,靠近目标时梯度小,更符合“先粗调后精调”的直觉。但平方惩罚在距离很大时会导致奖励值爆炸,所以需要配合裁剪。
第三个反直觉经验:奖励函数不要追求“完美”。我见过有人设计了一个包含七八个项的奖励函数,每一项都有明确的物理意义,结果训练效果还不如一个简单的距离惩罚。原因是每一项都需要调系数,系数之间的交互作用太复杂。我的建议是奖励项不要超过四个,每个项的系数都要有明确的调整逻辑。
def compute_reward(self, state, action, goal, prev_dist): dist = np.linalg.norm(state[:2] - goal) # 基础距离惩罚(平方,带裁剪) reward = -0.1 * min(dist ** 2, 4.0) # 接近目标的额外奖励 if dist < prev_dist: reward += 0.05 # 到达奖励 if dist < self.goal_radius: reward += 1.0 # 碰撞惩罚 if self._check_collision(state): reward -= 1.0 # 边界惩罚 if abs(state[0]) > 3.0 or abs(state[1]) > 3.0: reward -= 1.0 return reward注意:这个奖励函数里的系数都是经过多次实验调出来的,直接复制到你的任务里可能不适用。但调整的逻辑是通用的:先调距离惩罚的系数,让机器人能往目标方向走;再调到达奖励,让机器人愿意到达目标;最后调辅助项,优化运动质量。
4. 训练过程中的典型故障与排查链路
训练2D机器人控制策略的过程中,你会遇到各种各样的故障。有些是环境的问题,有些是算法的问题,有些是你自己代码的bug。这一章我把常见的故障现象、可能原因和排查方法整理出来,希望能帮你少走弯路。
4.1 训练曲线完全不涨:从环境到算法的逐层排查
训练曲线完全不涨是最让人崩溃的情况。你盯着TensorBoard看了半小时,reward曲线还是一条水平线。这时候不要急着调超参数,先按下面的顺序排查。
第一步:检查环境是否可解。用一个随机策略跑100个episode,看看有没有任何一个episode能到达目标。如果随机策略的到达率是0,那你的环境可能太难了,或者目标位置设置得不合理。我一般会先用一个简单的PD控制器跑一遍,确认环境是物理上可解的。
第二步:检查奖励信号是否有梯度。打印出每个episode的累积奖励,看看有没有变化。如果所有episode的累积奖励几乎一样,说明奖励函数没有提供有效的区分度。这时候需要检查距离惩罚的系数是不是太小,或者到达奖励是不是被其他惩罚抵消了。
第三步:检查状态和动作的归一化。如果状态值范围是[-100, 100],而网络输入层没有做归一化,那梯度会非常大或者非常小。我习惯在环境内部就把状态归一化好,网络里不再做额外处理。
第四步:检查Q值的变化。如果Q值一直不涨,说明critic没有学到东西。可能的原因是学习率太小、batch size太小、或者target网络更新太慢。如果Q值涨得飞快然后崩掉,说明学习率太大或者奖励尺度太大。
第五步:检查策略的熵。如果熵一直很高,说明策略还是随机的,actor没有学到东西。可能的原因是温度参数太大、actor学习率太小、或者优势估计有问题。
4.2 策略学到“原地转圈”或“贴墙走”的成因与修复
“原地转圈”和“贴墙走”是2D机器人控制里最典型的两种局部最优行为。它们的成因不同,修复方法也不同。
原地转圈的成因通常是:机器人发现转圈可以获得方向奖励,但不需要真正前进。修复方法是去掉方向奖励,或者把方向奖励和前进速度挂钩。我后来改成只有速度朝向目标时才给方向奖励,转圈时速度方向一直在变,拿不到这个奖励。
贴墙走的成因通常是:机器人发现沿着边界走可以避免碰撞惩罚,而且距离惩罚也不会太大。修复方法是增加边界惩罚的梯度,让机器人越靠近边界惩罚越大。或者干脆把边界设成软墙,机器人可以出去但会受到持续惩罚。
还有一种更隐蔽的局部最优是“震荡”——机器人在两个位置之间来回移动。这通常是因为距离惩罚的梯度在某个区域变成了零,机器人找不到继续前进的方向。修复方法是增加奖励的平滑性,或者用势能函数来塑形奖励。
4.3 训练后期性能突然崩溃的三种可能原因
训练后期性能突然崩溃,比训练不涨更让人难受,因为你已经看到了希望。这种情况通常有三种原因。
第一种是Q值过估计。SAC虽然用了双Q网络,但在训练后期,如果奖励尺度比较大,Q值仍然可能缓慢增长然后突然爆炸。表现是critic loss突然飙升,策略输出变得极端。修复方法是减小学习率、增大target更新系数、或者对Q值做裁剪。
第二种是replay buffer里的旧数据分布偏移。随着策略改进,buffer里早期随机策略的数据和当前策略的数据分布差异越来越大。如果buffer太大,旧数据占比太高,critic会被旧数据带偏。修复方法是减小buffer大小,或者用优先经验回放给新数据更高的采样权重。
第三种是温度参数调节失控。自动温度调节有时候会让α变得非常大,策略变得过于随机;有时候又会让α变得非常小,策略过早确定性化。修复方法是给α设一个上下限,或者手动调节α而不是自动调节。
4.4 一个完整的故障排查实例
我遇到过一个很典型的问题:训练到大约5万步的时候,reward突然从-20掉到-80,然后再也涨不回来。我按下面的链路排查了一遍。
首先检查环境,用固定种子跑了一个episode,发现机器人初始位置在边界附近,目标在对面边界。这个episode本身难度就大,但之前训练时也有类似情况,不应该导致崩溃。
然后检查奖励,打印了崩溃前后的奖励分布。发现崩溃后碰撞惩罚的占比突然增大,说明机器人开始频繁撞墙。
接着检查策略输出,发现动作的绝对值变得很大,经常顶到动作空间的边界。这说明策略变得过于激进。
最后检查Q值,发现Q值在崩溃前有一个快速上升然后急剧下降的过程。这是典型的过估计崩溃。
修复方案是:把critic学习率从1e-3降到3e-4,把target更新系数从0.01降到0.005,同时在奖励里增加了一个小的动作平滑惩罚。重新训练后,曲线没有再崩溃。
这个实例告诉我,训练崩溃往往不是单一原因,而是多个因素叠加。排查的时候要按“环境→奖励→策略→Q值”的顺序逐层看,不要一上来就调超参数。
5. 从仿真到部署:2D机器人控制策略的工程化落地
训练出一个在仿真里表现不错的策略,只是完成了第一步。真正要把这个策略用起来,还需要考虑推理速度、观测噪声、动作平滑、以及和实际系统的对接。
5.1 策略网络的轻量化与推理加速
训练的时候你可以用256维的隐藏层、3层MLP,推理的时候这些参数可能太多了。2D机器人控制的状态空间通常只有几维到几十维,动作空间只有2到3维,一个128维的2层MLP就足够了。
我一般会做一次网络剪枝:把训练好的网络里权重绝对值很小的连接去掉,然后微调几轮。在我的测试中,剪枝后网络参数量减少60%,推理速度提升2倍,性能下降不到5%。
如果你要在嵌入式设备上部署,还需要考虑量化。把float32的权重转成int8,模型大小减少75%,推理速度再提升2到3倍。但量化会带来精度损失,需要做量化感知训练或者在量化后做校准。
# 策略网络轻量化示例 import torch import torch.nn as nn class LightweightPolicy(nn.Module): def __init__(self, obs_dim, act_dim, hidden_dim=128): super().__init__() self.net = nn.Sequential( nn.Linear(obs_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, act_dim), nn.Tanh() ) def forward(self, obs): return self.net(obs) # 剪枝 def prune_model(model, amount=0.3): import torch.nn.utils.prune as prune for name, module in model.named_modules(): if isinstance(module, nn.Linear): prune.l1_unstructured(module, name='weight', amount=amount) prune.remove(module, 'weight') return model5.2 观测噪声与动作延迟的鲁棒性处理
仿真里的观测是完美的,但实际系统里观测会有噪声,动作会有延迟。如果你的策略只在完美观测下训练过,部署到实际系统上很可能直接失效。
我的做法是在训练的时候就加入噪声和延迟。观测噪声用高斯噪声,标准差根据实际传感器的精度来定。动作延迟用一个队列来实现,动作先入队,延迟若干步后再执行。
这种“域随机化”的方法能显著提升策略的鲁棒性。但噪声不能加得太大,否则策略会学得过于保守。我一般从很小的噪声开始,比如观测标准差的1%,然后逐步增大,观察策略性能的变化。
动作延迟的处理更微妙。如果延迟是固定的,策略可以学会补偿;如果延迟是随机的,策略需要学会预测。我一般用随机延迟,范围从0到最大延迟步数,这样策略会学到一种“提前量”。
5.3 动作平滑与执行器保护
强化学习策略输出的动作通常是高频抖动的,因为神经网络对状态的微小变化很敏感。这种抖动在实际系统上会导致执行器快速磨损,也会让运动看起来很不自然。
动作平滑有两种做法:一种是在策略输出后面加一个低通滤波器,另一种是在训练的时候加动作平滑惩罚。我两种都用。
低通滤波器的实现很简单,就是当前动作和上一步动作的加权平均。权重系数决定了平滑程度,系数越大越平滑,但响应也越慢。我一般用0.7到0.9之间的系数。
动作平滑惩罚是在奖励函数里加一项,惩罚动作的变化量。这样策略在训练的时候就会倾向于输出平滑的动作序列。惩罚系数不能太大,否则策略会变得过于迟钝。
提示:动作平滑和响应速度是一对矛盾。平滑程度越高,机器人对突发情况的响应越慢。在避障任务里,这个矛盾尤其明显。我的经验是先用较高的平滑系数训练一个基础策略,然后在避障场景下微调时降低平滑系数。
5.4 从仿真策略到实际控制的迁移检查清单
在把仿真策略部署到实际系统之前,我一般会过一遍下面这个检查清单。
- 观测空间是否一致?仿真里的状态顺序、单位、范围是否和实际系统匹配?
- 动作空间是否一致?仿真里的动作范围、单位、执行方式是否和实际系统匹配?
- 控制频率是否一致?仿真里的控制步长和实际系统的控制周期是否相同?
- 初始状态分布是否一致?仿真里的初始位置范围和实际系统的启动位置是否匹配?
- 终止条件是否一致?仿真里的到达判定、碰撞判定和实际系统的传感器判定是否一致?
- 是否有安全保护?实际系统上是否有紧急停止、动作限幅、碰撞检测等保护机制?
这个清单看起来简单,但每一条都可能成为迁移失败的根源。我见过因为仿真里角度是弧度而实际系统是角度导致策略完全失效的,也见过因为控制频率不匹配导致机器人震荡的。
6. 我在这套流程里踩过的五个真实坑
最后分享五个我在2D机器人深度强化学习控制里踩过的真实坑,每一个都让我浪费了至少一周时间。
第一个坑是状态里混入了时间信息。我一开始把当前步数也放进了状态,想着让策略知道还剩多少时间。结果策略学会了“拖到最后一刻再冲刺”,因为早期冲刺的奖励和晚期冲刺的奖励在折扣因子下差别不大。去掉时间信息后,策略变得稳定多了。
第二个坑是用了错误的动作空间范围。我把动作空间设成了[-1, 1],但在环境内部乘了一个很大的系数,导致实际动作范围是[-10, 10]。策略网络输出的tanh值在[-1, 1]之间,但环境执行的是放大后的动作。这导致策略的探索效率极低,因为大部分动作都顶到了边界。后来我把系数改小,让实际动作范围控制在[-2, 2]之间。
第三个坑是replay buffer没有预热。SAC是off-policy算法,需要从buffer里采样。我一开始在buffer里只有几百条数据的时候就开始训练,结果critic过拟合了这几百条数据,Q值完全失真。后来我改成buffer里至少有1万条数据才开始训练,问题就解决了。
第四个坑是评估频率太高。我一开始每1000步就评估一次,每次评估跑10个episode。结果训练速度被评估拖慢了好几倍,而且评估的随机性让我误判了策略的性能。后来改成每1万步评估一次,每次跑20个episode取平均,训练效率提升明显。
第五个坑是忽略了随机种子的影响。我用一个种子跑出了很好的结果,然后换了几个种子发现效果差很多。这说明我的超参数对种子很敏感,策略的鲁棒性不够。后来我养成了用3到5个种子跑实验的习惯,取平均性能作为最终指标。
这五个坑有一个共同点:它们都不是算法本身的问题,而是工程实现的问题。深度强化学习控制这件事,算法只占30%,剩下的70%是环境设计、奖励设计、参数调节和工程实现。把后面这70%做好,比换一个更先进的算法有用得多。