ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MADDPG多智能体强化学习实战:原理、PyTorch实现与避坑指南

MADDPG多智能体强化学习实战:原理、PyTorch实现与避坑指南 简介Python实现的MADDPG多智能体博弈对抗算法源码包面向机器学习与人工智能等相关专业的高校学生、科研工作者和行业从业者可应用于多智能体协作与对抗场景适合作为毕业设计、课程设计和项目初期演示的完整参考。压缩包共91个文件以76个py源码文件为核心覆盖MADDPG算法、DDPG单智能体基线、经验回放buffer、网络构建与多种测试脚本另含10张gif实验效果动图、1个ipynb示例笔记本、ma-gym环境库和说明文档整体仅3.26MB目录层级清晰、便于按模块检索。作者已对代码进行严格测试并附有实验结果读者可直接运行也可基于此修改实现更多功能快速搭建多智能体实验环境。目前已有92人学习下载适合希望从代码层面理解MADDPG原理并动手实战的初学者与进阶者。1. MADDPG多智能体博弈对抗里最容易被拿来当起点的算法如果你要做多智能体博弈对抗仿真比如追捕-逃跑、多无人机协同围剿、或者几个智能体在一张地图上互相竞争MADDPG几乎是绕不开的名字。它解决了单智能体强化学习算法在多智能体环境里最尴尬的问题——环境对每个智能体来说都是非平稳的Q值估计和策略梯度都会因此剧烈震荡。MADDPG的思路是集中式训练、分布式执行训练时让每个智能体的Critic看到所有智能体的观测和动作执行时每个Actor只用自己的局部观测做决策。这个设计让它天然适配博弈对抗场景也是我今天想拆给你看的东西。这篇笔记我会从一个可跑的Python实现出发把MADDPG的核心代码、训练参数、实验结果怎么读、以及在复现过程中最容易翻车的几个点全部过一遍。你不需要预先精通多智能体强化学习理论只要会Python、装过PyTorch跟着这篇文章就能把一个最小可跑的MADDPG在本地跑起来并且知道怎么判断它到底学没学到东西。适合谁想做多智能体论文复现的研究生或者要在仿真环境里验证博弈策略的工程师。2. MADDPG的算法原理集中式Critic、分布式Actor与经验回放的设计思路2.1 集中式训练分布式执行为什么Critic必须看到全局信息MADDPG解决的问题本质上是非平稳性。假设你在做一个两架无人机追一架逃跑无人机的仿真每个无人机都是独立学习的DDPG智能体。当其中一个追击者策略更新了它对环境造成的影响会改变逃跑者的状态分布而另一个追击者的Critic还在用旧的经验去估计Q值——这就导致目标Q值一直在移动像在流沙上盖房子。MADDPG的解法很直接给每个智能体i单独配一个Critic Q_i输入是所有智能体的观测o_1,...,o_N和动作a_1,...,a_N输出是该智能体动作价值的估计。Actor则只用自己的观测o_i来输出动作a_i。这样Critic在训练时能看到全局信息环境非平稳性对价值估计的干扰被大幅削弱执行时每个智能体只依赖自己的局部观测不需要和其他智能体通信这个特性对分布式部署非常友好。目标网络的设计和DDPG类似但更新的目标值用的是所有智能体的目标策略输出的动作公式是y_i r_i γ·Q_i(o, a_1,...,a_N) | a_j μ_j(o_j)其中Q_i和μ_j是软更新的目标网络。这里有一个关键点目标Q值计算时必须用目标Actor输出的动作而不是用当前Actor输出的动作。因为当前Actor的参数在持续变化用它算出来的目标Q值会和实际执行的策略不一致导致训练方差变大。我见过有人把这个写错直接在目标Q里复用当前策略的动作结果训练曲线震荡得非常厉害。代码实现里常见的做法是单独定义一个MADDPG类内部持有N个Agent对象每个Agent包含Actor网络、Critic网络以及它们各自的目标网络。Update时先统一更新所有Critic再更新所有Actor顺序不要反过来。2.2 目标网络、连续动作与经验回放MADDPG里三个必须调对的核心部件MADDPG继承自DDPG所以DDPG那套稳定性组件它全都要目标网络的软更新、经验回放池、以及用于探索的OU噪声或者高斯噪声。这三个组件在单智能体场景里可能随便调调就能跑但在多智能体对抗场景里任何一个设错都会让训练彻底报废。目标网络软更新MADDPG不采用定期硬拷贝而是每次梯度更新后做一次插值θ_target ← τ·θ (1-τ)·θ_target。τ通常取0.01太小更新太慢、价值估计追不上当前策略太大则训练不稳定。在多智能体场景里所有智能体共享同一个τ但你可以在代码里给每个智能体单独传参方便分阶段调。经验回放池MADDPG的经验池里存的不是单个经验而是所有智能体的联合经验包括联合观测{o_1,...,o_N}、联合动作{a_1,...,a_N}、联合奖励{r_1,...,r_N}以及下一时刻联合观测。采样时按时间步整条取出不能只取某一个智能体的数据。回放池容量建议10万起步因为多智能体探索需要的样本量远大于单智能体。探索噪声MADDPG的Actor输出的是连续动作比如无人机的偏航角速度或者追击者的加速度。训练初期如果噪声太小策略会过早陷入局部最优中后期如果噪声不衰减策略又永远在随机游走。常见做法是用OU噪声或者零均值高斯噪声跑固定步数后线性衰减。import torch import torch.nn as nn import numpy as np from collections import deque import random class ReplayBuffer: 多智能体经验回放池保存所有智能体的联合经验 def __init__(self, capacity100000): self.buffer deque(maxlencapacity) def push(self, obs_n, act_n, rew_n, obs_next_n, done): # obs_n: (num_agents, obs_dim)的列表或数组 # act_n: (num_agents, act_dim) # rew_n: (num_agents,) self.buffer.append((obs_n, act_n, rew_n, obs_next_n, done)) def sample(self, batch_size): batch random.sample(self.buffer, batch_size) obs_n torch.FloatTensor(np.array([x[0] for x in batch])) act_n torch.FloatTensor(np.array([x[1] for x in batch])) rew_n torch.FloatTensor(np.array([x[2] for x in batch])) obs_next_n torch.FloatTensor(np.array([x[3] for x in batch])) done torch.FloatTensor(np.array([x[4] for x in batch])) return obs_n, act_n, rew_n, obs_next_n, done这里的obs_n是三维张量形状为(batch_size, num_agents, obs_dim)存储时把所有智能体的观测堆叠到一起。这样设计是让Critic在训练时能方便地拿到全局信息。注意采样返回后不需要再做转置因为PyTorch在计算Q值时直接用索引就能取出每个智能体对应的输入。经验回放的参数里capacity我一般设10万到50万具体取决于环境的复杂程度。batch_size常用1024比单智能体DDPG的256要大——因为MADDPG的Critic输入维度高小批量会导致价值估计的方差偏大。如果你的显存紧张至少也要保持512。3. 搭一个能跑的MADDPG依赖安装、环境配置与最小代码骨架3.1 依赖清单与Python环境torch、numpy、gym版本匹配MADDPG的官方源码是基于TensorFlow 1.x写的现在要跑的话我建议直接用PyTorch重写一版省去兼容性问题。依赖非常简单PyTorch 1.10以上、NumPy、gym如果你需要跑经典的多智能体测试环境还需要装maddpg-envs或者直接用开源社区重写的PettingZoo环境。这里要注意的是Python版本3.8到3.10都行不要用3.11以下的老版本配最新版torch很容易出现预编译包找不到的情况。pip install torch numpy gym pip install pettingzoo # 可选用于跑经典多智能体测试环境gym版本这里有个坑如果你用gym.make()创建环境gym 0.21和gym 0.26以上的API不兼容新版返回的obs是一个字典而不是数组。MADDPG的代码里通常直接索引obs[agent_id]如果拿到的是字典就会报错。我一般锁定gym0.21这是社区里MADDPG实现最常见的配置。PettingZoo的使用方式也略有不同需要先parallel_env env.parallel_env()再调用reset()。环境配置完以后先写一个简单的check脚本确认观测空间和动作空间的维度。很多MADDPG代码假设所有智能体的观测维度一致但实际环境里往往不一样——比如追击者能看到逃跑者的位置逃跑者看不到追击者的内部状态。如果你的场景是这样代码里处理Critic输入时就要做拼接不能直接torch.cat(obs_n, dim-1)。3.2 最小训练循环从智能体初始化到一次梯度更新的完整流程MADDPG的训练主循环其实不复杂核心就三件事通过Actor和环境交互收集经验、从经验池采样、分别更新Critic和Actor。下面这个示例是我常用的最小骨架去掉了reward shaping和日志统计的细节保留关键路径。import torch import torch.nn as nn import torch.optim as optim import numpy as np class Actor(nn.Module): def __init__(self, obs_dim, act_dim, hidden64): super().__init__() self.net nn.Sequential( nn.Linear(obs_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, act_dim), nn.Tanh() ) def forward(self, obs): return self.net(obs) class Critic(nn.Module): def __init__(self, obs_dim_n, act_dim_n, hidden64): # obs_dim_n: 所有智能体观测维度之和 # act_dim_n: 所有智能体动作维度之和 super().__init__() self.net nn.Sequential( nn.Linear(obs_dim_n act_dim_n, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, 1) ) def forward(self, obs_n, act_n): return self.net(torch.cat([obs_n, act_n], dim-1)) class MADDPGAgent: def __init__(self, obs_dim, act_dim, obs_dim_n, act_dim_n, lr1e-4, tau0.01): self.actor Actor(obs_dim, act_dim) self.actor_target Actor(obs_dim, act_dim) self.actor_target.load_state_dict(self.actor.state_dict()) self.critic Critic(obs_dim_n, act_dim_n) self.critic_target Critic(obs_dim_n, act_dim_n) self.critic_target.load_state_dict(self.critic.state_dict()) self.actor_opt optim.Adam(self.actor.parameters(), lrlr) self.critic_opt optim.Adam(self.critic.parameters(), lrlr) self.tau tau def soft_update(self): for target_param, param in zip(self.actor_target.parameters(), self.actor.parameters()): target_param.data.copy_(self.tau * param.data (1 - self.tau) * target_param.data) for target_param, param in zip(self.critic_target.parameters(), self.critic.parameters()): target_param.data.copy_(self.tau * param.data (1 - self.tau) * target_param.data)上面的Actor输出层用了Tanh激活把动作限制在[-1,1]区间这是连续控制里的标准做法。如果你的环境动作范围不是[-1,1]在环境交互时做一次线性映射即可不要修改网络的输出范围否则梯度更新会不稳定。Critic的输入是所有观测和所有动作拼起来的一个大向量输出是标量Q值。def update(agents, replay_buffer, batch_size1024, gamma0.95): obs_n, act_n, rew_n, obs_next_n, done replay_buffer.sample(batch_size) # obs_n shape: (batch, num_agents, obs_dim) num_agents len(agents) # 先更新每个智能体的Critic for i, agent in enumerate(agents): with torch.no_grad(): act_next_n [] for j, a in enumerate(agents): act_next_n.append(a.actor_target(obs_next_n[:, j])) act_next_n torch.stack(act_next_n, dim1) # (batch, num_agents, act_dim) target_q rew_n[:, i:i1] gamma * (1 - done) * agent.critic_target( obs_next_n.view(batch_size, -1), act_next_n.view(batch_size, -1)) current_q agent.critic(obs_n.view(batch_size, -1), act_n.view(batch_size, -1)) critic_loss nn.MSELoss()(current_q, target_q) agent.critic_opt.zero_grad() critic_loss.backward() torch.nn.utils.clip_grad_norm_(agent.critic.parameters(), 0.5) agent.critic_opt.step() # 再更新所有Actor for i, agent in enumerate(agents): act_n [] for j, a in enumerate(agents): if j i: act_n.append(a.actor(obs_n[:, j])) else: act_n.append(a.actor(obs_n[:, j]).detach()) act_n torch.stack(act_n, dim1) actor_loss -agent.critic(obs_n.view(batch_size, -1), act_n.view(batch_size, -1)).mean() agent.actor_opt.zero_grad() actor_loss.backward() torch.nn.utils.clip_grad_norm_(agent.actor.parameters(), 0.5) agent.actor_opt.step()这段代码里有几个细节值得展开。第一更新Actor时除了当前智能体i以外其他智能体的Actor输出要加.detach()。原因是Actor的梯度只应该通过当前智能体自己的Critic反向传播如果把其他智能体Actor的计算图保留梯度会把它们的策略参数也牵扯进来这既不符合MADDPG的推导也会让优化目标变得混乱。第二Critic的梯度裁剪很关键。MADDPG的Critic输入维度高训练早期Q值经常因为目标值计算误差而爆炸设个0.5的梯度裁剪能有效防止NaN。第三所有智能体的Critic更新完之后再统一更新Actor顺序不能混——如果先更新ActorCritic还没同步到最新的策略Actor的梯度方向就是旧的。4. 源码结构与实验结果复现训练曲线怎么看、收敛了没有4.1 源码目录这样组织模型定义、Buffer、训练器与绘图脚本分离拿到一个MADDPG源码包第一件事不是急着跑训练而是先看目录结构。一个结构清晰的MADDPG项目通常会把模型定义、经验回放、训练逻辑和可视化分开。常见的组织方式是这样的models/放Actor和Critic的网络定义utils/放ReplayBuffer、OUNoise这些工具类train.py是训练入口evaluate.py是单独的策略评估脚本。如果你拿到的包把所有代码揉在一个几百行的文件里后面调参和加功能会非常痛苦。我打开这类源码包后习惯先看三个东西环境初始化代码、奖励函数定义、以及训练循环里有没有evaluate逻辑。环境初始化代码决定你要跑的环境是什么——是简单的1v1追逃还是多智能体协作加对抗混合场景。奖励函数决定这个实验学没学会的评判标准很多源码包里奖励函数写得非常简略只有r -distance这种很粗糙的写法导致训练结果看起来不收敛。训练循环里有没有evaluate逻辑更重要因为如果只有reward曲线没有周期性评估你很难区分策略真的变好了还是只是探索噪声碰巧撞到了高奖励。4.2 复现实验的完整步骤、关键超参与期望结果复现实验的基本流程是先跑一个短训练500个episode左右确认loss和reward的数值范围正常再开长训练。这里有一组我常用的起步参数适合绝大多数连续动作博弈对抗场景参数取值说明智能体数量2~4超过4个训练难度显著上升Actor学习率1e-4调大到5e-4易震荡Critic学习率1e-3可以比Actor大一档软更新系数τ0.01目标网络跟踪速度折扣因子γ0.95对抗场景下步数多不用太接近1经验池容量1e5小了易过拟合批量大小1024小批量方差大探索噪声初始σ0.3高斯噪声探索噪声衰减每千步×0.995训练中后期保持必要探索训练日志至少要记录每个episode的累计奖励均值、每个智能体获得的单独奖励、以及Critic的loss值。累计奖励均值是判断策略好坏的直接指标单独奖励能看出某个智能体是不是在躺赢——比如追击博弈里如果一个追击者总是靠近目标而另一个总是原地不动那说明奖励分配机制出了问题。Critic的loss开始出现持续下降是好事说明价值估计越来越准但如果loss降到极低而actor loss还在涨可能是出现了严重的Q值过估计。收敛的期望结果要分环境看。在经典的SimpleSpeakerListener或者SimpleTag环境里2000个episode以内应该能看到奖励曲线明显上升。在更复杂的自定义对抗环境里10000个episode都可能不够。判断收敛不要光看绝对数值要看曲线斜率是否持续为正、震荡幅度是否收窄。还有一个重要的观察指标是策略僵化程度训练结束后把噪声关掉让策略纯贪婪执行如果表现反而大幅下降说明策略依赖噪声来完成动作这是训练不足或噪声衰减太慢的信号。5. MADDPG避坑指南训练发散、探索失效与性能瓶颈的5个经典问题5.1 训练前几百个episode奖励恒定在初值完全不动现象累计奖励一直是一条水平线没有上升也没有下降很像随机策略的表现。检查Actor输出的动作分布发现总是徘徊在动作区间边界。原因奖励太稀疏或奖励尺度过小导致梯度信号太弱。MADDPG的Critic初始化的Q值都很小如果环境给的奖励是像-0.01这样的小数值Critic的loss虽然能算但梯度几乎无法推动Actor更新。另一个常见原因是Tanh输出层饱和——如果策略输出的动作长时间贴近±1Tanh的梯度接近0Actor学不动。解决给奖励乘一个放大系数比如把所有奖励乘以10或100让Q值的量级落在1附近。这是最简单有效的办法。对于动作饱和问题在Actor的输出层后面加一个线性缩放层把输出范围从[-1,1]缩放到环境实际动作范围的十分之一避免一开始就撞边界。检查方法是在训练循环里打印前几个episode的动作均值正常应该分布在0附近。5.2 训练到一半Critic的loss爆炸数值变成NaN现象训练过程一路顺利突然某个step之后loss变成NaN接着整个训练崩溃target网络参数也跟着变成NaN。原因目标Q值的计算链条太长误差累积导致梯度爆炸。MADDPG的Critic输入包含了所有智能体的观测和动作维度高早期Q值估计稍微偏差一点经过γ·Q_next的递推后就会放大。如果学习率再偏高一次梯度更新就可能把参数推出数值稳定区间。解决首先给Critic的梯度做裁剪clip_grad_norm_的阈值设为0.5。其次把Critic的学习率从1e-3降到5e-4牺牲一点速度换稳定性。如果已经出现NaN光改参数不够需要把训练回滚到最后一个正常checkpoint然后降低学习率重新开始。这个问题的根源往往是学习率不是一个值打天下——训练初期可以用稍大的学习率加速收敛一旦loss出现跳升就手动降低。5.3 Actor的loss持续下降但实际策略表现没有变好现象日志里Actor的loss稳定向下但evaluate时的累计奖励毫无起色甚至小幅变差。这看起来像指标和实际效果脱节。原因Actor的loss是-Q(s,a)Q值是由Critic给出的。如果Critic没有学对Actor会沿着一个错误的Q值方向优化。典型场景是在对抗博弈里Critic发现了一个虚假的高价值区域——比如某个动作组合恰好让Q值出现了局部高点但真实环境里这个组合收益并不高。这是Q值过估计在多智能体场景下的直接表现。解决核心思路是让Critic更准确。一是增加训练频率每1个环境step更新Critic至少1次我之前甚至试过环境step和更新比例到5:1二是检查奖励函数是否有漏洞比如防守方智能体可以通过远离对手来获得不失分的奖励导致Critic把这套策略错判为高价值。另一个实用的做法是定期做评估时把探索噪声完全关掉用纯贪婪策略跑几次看是否有真实提升。5.4 探索噪声没有衰减策略永远在随机游走现象训练了上万episode策略看起来还是充满随机性动作轨迹抖动得厉害奖励曲线有上升但平台期很短掉头往下走。原因高斯噪声或者OU噪声的σ从头到尾没有变化。MADDPG的探索需要一个衰减计划比如sigma max(initial_sigma * decay_rate^step, min_sigma)。如果衰减太慢训练后期策略还在大幅探索累积的梯度方向被噪声冲散如果衰减太快又会导致前面说的策略僵化。解决设一个明确的衰减公式比如每1000步乘0.995下限设0.02。除了这个计划表还应该设置一个探索预算——比如总训练步数的前40%允许较大噪声之后强制衰减到下限。这是多智能体场景里特别重要的调参思路因为智能体之间的交互会放大噪声的影响一个智能体没收敛会连锁干扰其他智能体的学习。5.5 环境观测维度不统一torch.cat报错现象自定义环境里两个智能体的观测维度不一样代码运行到Critic前向传播时报错维度拼不起来。原因MADDPG的公式假设所有智能体共享同一个联合空间但实际对抗场景里竞争双方的观测往往不对称——追击者能看到逃跑者的位置逃跑者不知道追击者的策略参数。很多开源实现偷懒地直接torch.cat(obs_n, dim-1)遇到不对称观测就挂。解决有两种方案。第一种是在环境层做对称化给维度小的智能体补零填充把观测padding到相同维度。第二种是修改Critic的输入处理函数允许每个智能体的观测维度不同分别从obs_n里按索引切出来再拼接。第二种方案更干净但需要改动源码里Critic的forward函数。我通常优先选第二种因为补零会引入虚假的固定输入让Critic浪费容量去学习这些零向量没有意义。6. 让MADDPG跑得更稳更快的三个进阶技巧从对手抽样到定期对局评估第一个进阶技巧是对手抽样在一套完整的源码包里如果只有基础MADDPG这个方向扩展起来其实不难而且能明显改善对抗博弈下的收敛稳定性。思路是在训练过程中周期性地把当前智能体的对手策略记录下来作为对手池——可以是向量化的参数快照训练时随机从对手池抽取一个历史版本和当前智能体对练。这样做的好处是对手不再是训练过程中不停变化的目标而是多个固定水平的混合分布价值函数的估计会平稳很多。实现时只需要在每N个episode做一次deepcopy保存actor参数抽样时从其中随机选一个用它的目标网络去算目标Q值。第二个技巧是奖励塑形但塑形要小心。多智能体博弈场景里你给了一个额外的引导奖励等于是在改变博弈的均衡结构原本可能会演化出合作-背叛这样有趣策略组合的环境被你硬掰成了朝向目标点移动的直线行为。我的经验是塑形只用来解决稀疏奖励问题比如追击者长时间没有接近目标时给一个小的方向性奖励一旦目标进入可追击范围就立刻关闭塑形。塑形系数从0.1开始调不要超过主奖励的一半。这个做法可以显著加快早期学习但训练中期一定要评估去掉塑形后的真实表现。第三个技巧也是我认为最重要的验证手段定期保存模型做对局评估而不是只看reward曲线变化。操作方法很简单每1000个episode把当前所有智能体的Actor参数保存到一个单独的目录然后写一个evaluate脚本加载这些历史checkpoint让不同时期的策略互相对局。你会发现一些有意思的现象——比如训练3000步的策略虽然奖励分数低但它和训练8000步的策略对打时反而因为探索性强偶尔赢几局这说明奖励数值本身存在一个虚高的问题。另一个常见习惯是把最终模型的state_dict单独保存一份去掉optimizer和target network方便后续直接加载做推理。这套评估流程虽然会增加一点存储开销但在博弈对抗场景里几乎是必须的。因为我吃过亏——只盯着reward曲线调参调了三天发现模型在某类对手面前完全不堪一击而这类对手是训练过程中早期出现的后期被遗忘了。MADDPG的多智能体本质决定了学会打一个对手和学会对抗一类策略之间有很大距离。定期对局评估能逼你看清楚模型泛化能力到底在哪里断档。后来我做所有多智能体对抗项目默认都会加这个环节训练时间即使翻倍也比出了结果才发现策略脆弱要好得多。希望这些经验能帮你在复现和调优MADDPG时少走点弯路。本文还有配套的精品资源点击获取
返回列表