尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

RLlib多智能体强化学习实战:MAPPO算法在车联网协同场景的配置与调优

RLlib多智能体强化学习实战:MAPPO算法在车联网协同场景的配置与调优 1. 从车联网场景切入为什么多智能体协同不能直接套单智能体PPO车联网里最典型的场景就是多车协同——比如十字路口无信号灯通行、高速匝道汇入、编队行驶。这些场景有一个共同特征每辆车都是一个独立的决策主体各自有观测、动作和奖励但它们的决策结果互相影响。你如果直接把单智能体PPO搬过来让一个中心化Critic看全局状态、所有车共享一套策略参数训练初期可能看着loss在降但实际部署时会出现一个很尴尬的现象车一多策略就开始“摆烂”要么所有车同时抢道要么集体僵住不动。这个问题的根子在于信用分配。单智能体PPO的Critic估计的是全局状态价值它没法告诉某辆车“你刚才那个转向动作对整体合作的贡献是多少”。在多车场景下每辆车的动作空间是连续的油门、刹车、转向动作维度叠加后联合动作空间爆炸Critic的估计方差会大到让优势函数几乎失去意义。MAPPOMulti-Agent PPO就是冲着这个痛点来的。它的核心思路是CTDE集中训练、分散执行训练时Critic可以看全局信息但每个智能体有自己独立的Actor执行时只依赖局部观测。这样既保留了PPO的稳定性和样本效率又让每辆车能根据自己的局部视角做决策。RLlib作为Ray生态里的强化学习库原生支持多智能体环境封装和MAPPO风格的训练配置省去了大量自己搭通信和参数共享逻辑的功夫。我这次拿simple_spread这个合作任务来跑通整条链路。它虽然是个简化环境但麻雀虽小五脏俱全——N个智能体要覆盖N个地标奖励是全局共享的同时有碰撞惩罚。这个任务的结构和车联网里的协同覆盖问题高度同构跑通它之后再迁移到SUMO或CARLA场景改动量主要在环境封装层。注意MAPPO不是“把PPO复制N份”那么简单。参数共享策略、Critic的输入设计、奖励的全局/局部拆分方式这三个点任何一个处理不好训练曲线都会很难看。2. RLlib多智能体环境封装simple_spread的接口适配细节2.1 环境注册与多智能体API的对接方式RLlib对多智能体环境的封装有一套自己的约定。simple_spread来自PettingZoo的MPE系列它的原生接口是Agent-by-Agent的循环调用模式而RLlib期望的是MultiAgentEnv子类需要实现reset()返回{agent_id: obs}字典、step()返回四元组字典。直接拿PettingZoo环境塞进去会报各种key不匹配的错。我的做法是写一个薄适配层继承MultiAgentEnv内部持有一个PettingZoo环境实例。关键点在于agent_id的映射PettingZoo用agent_0, agent_1...这种字符串RLlib内部也认这套命名但策略映射时要确保policy_mapping_fn返回的policy_id和环境里的agent_id能对上。我见过有人在这里把agent_0映射到policy_1结果训练时所有智能体都在用同一套参数更新完全失去了多智能体的意义。from ray.rllib.env.multi_agent_env import MultiAgentEnv from pettingzoo.mpe import simple_spread_v3 class SpreadEnv(MultiAgentEnv): def __init__(self, configNone): self.env simple_spread_v3.parallel_env(N3, max_cycles25) self.agents self.env.possible_agents self.observation_space self.env.observation_space(self.agents[0]) self.action_space self.env.action_space(self.agents[0]) self._agent_ids set(self.agents) def reset(self, *, seedNone, optionsNone): obs, info self.env.reset(seedseed) return obs, info def step(self, action_dict): obs, rew, term, trunc, info self.env.step(action_dict) # RLlib要求terminated和truncated分开返回 terminateds {__all__: all(term.values())} truncateds {__all__: all(trunc.values())} return obs, rew, terminateds, truncateds, info这段代码看起来简单但有两个坑我踩过。第一simple_spread_v3的parallel_env模式下step返回的terminated是每个agent一个布尔值RLlib需要__all__键来标记整个episode是否结束漏掉这个键训练会直接卡死。第二max_cycles25意味着episode很短如果gamma设得太大比如0.99价值估计会偏向远期但实际episode根本走不到那么远导致Critic学不到有效信号。我一般把gamma压到0.95左右。2.2 观测空间与动作空间的归一化处理simple_spread的观测是一个向量包含自身位置、速度、到各地标的相对位置、到其他智能体的相对位置。数值范围不统一位置大概在[-1,1]速度在[-5,5]左右。如果不做归一化神经网络的前几层会被大数值主导训练初期梯度方向乱飘。我的处理是在环境wrapper里加一层ObservationNormalizer用running mean/std做在线归一化。RLlib自带MeanStdFilter但多智能体环境下每个agent的观测分布可能不同我选择对每个agent独立维护一套统计量。动作空间是连续的simple_spread里是二维的力向量范围[-1,1]这个本身就在合理区间不需要额外缩放但输出层用tanh激活是必须的。提示归一化统计量在训练结束后要保存下来部署时用同一套均值方差做预处理。我见过有人训练时归一化了、推理时忘了结果策略表现直接崩掉。2.3 奖励结构与全局奖励的拆分逻辑simple_spread的奖励是全局共享的每个agent拿到的reward是一样的等于负的到最近地标的距离之和再减去碰撞惩罚。这种设计在合作任务里很常见但直接拿全局reward去更新每个agent的Actor会导致搭便车问题——某个agent什么都不做其他agent把地标覆盖了它也能拿到正奖励。MAPPO的论文里提到过一种处理方式Critic用全局reward但Actor的更新可以引入局部奖励塑形。我在这个任务里做了一个折中保持全局reward用于Critic训练但在计算优势函数时对每个agent的reward加上一个小的局部项——该agent到最近地标的距离变化量。这个局部项系数设0.1不改变整体合作目标但能给每个agent更密集的梯度信号。实测下来加了这个局部塑形之后收敛速度大概快了30%左右。不过系数不能太大超过0.3就会让agent变得自私只顾自己往地标跑不管碰撞。3. MAPPO在RLlib中的配置拆解参数共享与Critic设计3.1 策略映射函数什么时候共享参数、什么时候独立RLlib的多智能体配置里policies字典定义了有哪些策略policy_mapping_fn决定哪个agent用哪个策略。在simple_spread这种同构场景下所有agent的观测和动作空间完全一样共享一套策略参数是合理的——这能大幅减少参数量加快训练。但如果你的车联网场景里有大车和小车、或者有不同传感器配置的车辆观测维度不同就必须分开建策略。共享参数的配置大概长这样from ray.rllib.algorithms.ppo import PPOConfig config ( PPOConfig() .environment(SpreadEnv) .multi_agent( policies{shared_policy}, policy_mapping_fnlambda agent_id, episode, **kw: shared_policy, ) .training( train_batch_size4000, sgd_minibatch_size256, num_sgd_iter10, lr3e-4, gamma0.95, lambda_0.95, clip_param0.2, entropy_coeff0.01, vf_clip_param10.0, ) .resources(num_gpus1) )这里有个细节vf_clip_param默认是10但在多智能体合作任务里reward的累积值可能波动很大我一般会把它调到20甚至更高避免价值函数被过度裁剪导致学不动。entropy_coeff设0.01是为了保持一定的探索性太小会过早收敛到局部最优比如所有agent挤在一个地标附近。3.2 Critic的全局状态输入怎么拼、拼什么MAPPO和普通PPO在多智能体场景下的最大区别就在Critic。普通PPO的Critic只吃单个agent的观测MAPPO的Critic要吃全局状态。RLlib里实现这个的方式是自定义model配置让Critic的输入维度等于所有agent观测拼接后的维度。在simple_spread里3个agent每个观测维度是18拼接后是54。Critic的输入就是这54维向量。但这里有个问题RLlib默认的FullyConnectedNetwork对Actor和Critic用同一套输入。你需要通过custom_model或者model_config里的fcnet_hiddens分别指定或者更直接的方式——在环境里把全局状态塞进每个agent的info字典然后用postprocess_fn把它拼到Critic的输入里。我用的方案是自定义一个TorchModel重写forward方法class MAPPOModel(TorchModel): def __init__(self, obs_space, action_space, num_outputs, model_config, name): super().__init__(obs_space, action_space, num_outputs, model_config, name) # Actor网络输入局部观测 self.actor nn.Sequential( nn.Linear(obs_space.shape[0], 128), nn.Tanh(), nn.Linear(128, 128), nn.Tanh(), nn.Linear(128, num_outputs) ) # Critic网络输入全局状态所有agent观测拼接 global_dim obs_space.shape[0] * 3 self.critic nn.Sequential( nn.Linear(global_dim, 256), nn.Tanh(), nn.Linear(256, 256), nn.Tanh(), nn.Linear(256, 1) )然后在forward里根据is_training标志决定走哪条路。这个写法比RLlib默认的共享trunk更清晰也更容易调试。实测下来Critic用256宽的两层比128宽的效果好因为全局状态的信息量确实更大。3.3 训练批次与采样多智能体下的batch怎么算多智能体环境里一个step会产生N个transitionN个agent各一条。RLlib的train_batch_size统计的是所有agent的transition总数。如果你设40003个agent那实际环境步数是4000/3≈1333步。这个换算关系一定要搞清楚否则你会觉得“我设了4000怎么这么快就采完了”。sgd_minibatch_size同理它是在所有agent的transition上做划分。我一般设256保证每个minibatch里各个agent的样本都有覆盖。如果设得太小比如64可能会出现某个minibatch里全是agent_0的样本梯度更新偏向严重。还有一个容易忽略的点num_sgd_iter。多智能体下由于Critic的输入维度更高、方差更大我一般会把它从默认的10调到15让Critic多迭代几轮。但也不能太大超过20容易过拟合到当前batch的全局状态上。4. 训练过程中的典型故障与排查链路4.1 奖励不升反降从advantage分布查起第一次跑的时候前50个iteration奖励从-30慢慢爬到-25然后突然掉到-40之后就在-35附近震荡。这种“先升后崩”的曲线八成是advantage估计出了问题。我的排查步骤是这样的先在postprocess_fn里把每个agent的advantage打印出来看分布。正常情况下advantage应该近似零均值、标准差在1左右。结果发现agent_2的advantage标准差到了8而且均值是-3。这说明Critic对agent_2的状态价值估计严重偏高导致它的优势一直是负的Actor更新方向完全错了。根因是Critic的全局状态拼接顺序。RLlib在拼接多agent观测时默认按agent_id排序但我的环境里agent_id是agent_0, agent_1, agent_2排序后顺序没变看起来没问题。但问题出在reset时PettingZoo返回的obs字典顺序和step时不一致导致Critic看到的“全局状态”在不同step之间对应的agent位置错位了。修复方式是在wrapper里强制按固定顺序拼接def _get_global_state(self, obs_dict): return np.concatenate([obs_dict[fagent_{i}] for i in range(3)])这个坑很隐蔽因为obs字典本身是无序的Python 3.7之后虽然保持插入顺序但PettingZoo内部实现可能会变。显式排序是唯一可靠的做法。4.2 智能体“摆烂”熵系数与奖励塑形的平衡修好advantage之后奖励稳定爬升到-15左右但卡住了。观察agent轨迹发现agent_0和agent_1在动agent_2基本原地不动。这就是典型的“搭便车”——agent_2发现不动也能拿到差不多的全局奖励干脆躺平。我试了两个方向。第一是调大entropy_coeff从0.01加到0.05让策略保持更多随机性。效果是agent_2开始动了但整体奖励反而降了因为随机动作导致碰撞增多。第二是加局部奖励塑形就是我前面提到的距离变化量。这个方案更对症因为agent_2不动的时候它到地标的距离不变局部奖励为0而其他agent在靠近地标局部奖励为正。这样agent_2的相对优势就变成负的Actor会被推着去动。系数从0.1开始试0.1效果最好0.2的时候agent_2变得太激进经常撞到其他agent。最终配置是全局reward权重1.0局部塑形权重0.1。4.3 训练后期震荡学习率与clip_param的联动调整奖励爬到-8左右之后开始上下震荡幅度大概±3。这是PPO的典型问题——策略更新步长太大在最优解附近来回跳。常规做法是调小学习率但我发现单纯调小lr会让收敛变慢。我的做法是联动调整lr从3e-4降到1e-4同时clip_param从0.2降到0.1。clip_param控制的是策略比率的变化范围调小它相当于给更新加了更紧的约束。两个一起调既保持了收敛速度又压住了震荡。实测震荡幅度从±3降到±1以内。还有一个辅助手段是kl_coeff。RLlib的PPO默认用kl_coeff做自适应KL惩罚我把它从0.2调到0.5让KL散度超过目标值时更快地惩罚。这个参数在训练后期特别有用能防止策略突然崩掉。注意这些参数没有万能值。simple_spread的N3和N10最优参数差别很大。N越大Critic越难学vf_clip_param要调大lr要调小。我一般按N的平方根来粗略缩放学习率。5. 从simple_spread到车联网场景的迁移要点5.1 观测空间的扩展从理想向量到真实传感器数据simple_spread的观测是理想化的低维向量车联网场景里你要处理的是激光雷达点云、摄像头图像、V2X通信收到的邻居状态。直接把这些塞进MAPPO的Actor网络不现实需要先做特征提取。我的做法是分两阶段先用一个预训练的编码器比如PointNet处理点云、ResNet处理图像把原始传感器数据压成128维特征向量再把特征向量作为Actor的输入。Critic的全局状态则是所有车辆特征向量的拼接加上路侧单元提供的全局交通流信息。这个编码器可以在仿真里用监督学习预训练也可以和策略网络端到端联合训练但后者对样本量要求很高。5.2 动作空间的约束从自由力向量到车辆动力学simple_spread的动作是二维力车辆不能这么干。真实车辆的动作是油门、刹车、转向角而且有动力学约束——你不能让一辆车瞬间横向移动。迁移时需要在动作输出后加一层动力学模型把策略输出的归一化动作映射到合理的控制量。我一般用自行车模型做这层映射动作输出是加速度和转向角变化率经过积分得到下一时刻的速度和航向角。这层映射是可微的可以嵌在策略网络后面一起训练。但要注意动力学模型的参数轴距、最大加速度要和仿真环境一致否则策略学到的动作在仿真里执行会失真。5.3 通信约束下的分散执行部分可观测的处理车联网里V2X通信有延迟和丢包不是所有车辆都能实时拿到邻居的完整状态。MAPPO的分散执行阶段每个车辆的Actor只能依赖自己的局部观测。如果局部观测里包含邻居信息通信一断观测就缺了。我的处理是在训练时做观测丢弃增强以一定概率比如0.2随机把邻居信息置零让策略学会在信息不完整时也能做决策。这个技巧在鲁棒性要求高的场景里很管用代价是训练初期收敛慢一些但部署后的泛化能力明显更强。另外Critic在训练时可以用全局信息但部署时Critic是不需要的所以通信约束只影响Actor的输入设计。这一点在架构设计时就要想清楚别把Critic的依赖带到执行阶段。6. 一些调参之外的实操心得跑完这一轮有几个点我觉得比调参本身更重要。第一环境wrapper的测试要单独做。我写了一个小脚本随机采样动作跑100个episode检查obs、reward、done的维度和范围是否符合预期。这个步骤花不了十分钟但能省掉后面几小时的debug。多智能体环境里agent_id错位、done信号漏传这类问题在训练时表现为“莫名其妙不收敛”很难直接定位。第二训练日志要记录每个agent的独立指标。RLlib默认只输出全局的episode_reward_mean但多智能体场景下你需要看每个agent的reward、动作分布、观测统计。我在on_train_result回调里加了per-agent的reward记录发现agent_2摆烂就是靠这个日志看出来的。第三checkpoint要带归一化统计量。RLlib的save默认只存模型权重观测归一化的running mean/std不在里面。我自定义了save和load方法把统计量一起序列化。部署时加载checkpoint归一化层直接恢复省得重新统计。第四别迷信默认的fcnet_hiddens。RLlib默认是[256,256]在simple_spread这种低维观测下[128,128]就够了参数量少一半训练快不少。但Critic那边我反而加宽到[256,256]因为全局状态信息量大。Actor和Critic的网络容量要分开调别用同一套配置。最后说一个我踩过的坑simple_spread的max_cycles25意味着episode最多25步。如果你把train_batch_size设得很大比如10000那一个batch里会包含很多个完整episode。这本身没问题但num_sgd_iter如果也设得大Critic会在这些episode上反复过拟合。我的经验是train_batch_size和num_sgd_iter的乘积不要超过50000超过之后边际收益递减还容易过拟合。
返回列表