MADDPG多智能体博弈对抗实战:从原理到Python实现 简介基于MADDPG的多智能体博弈对抗算法Python项目源码是一份评审98分的高分毕业设计面向计算机专业正在完成毕设、期末大作业、课程设计或希望对强化学习做实战练习的学习者。项目实现围绕多智能体训练流程展开代码清晰地拆分了经验回放缓冲、网络结构定义、DDPG/MADDPG核心逻辑与常用强化学习工具函数并配有独立测试脚本及ma-gym环境压缩包读者既能快速厘清各模块间的调用关系也能直接在其基础上替换任务环境开展对抗实验对理解博弈对抗场景中的策略更新与协作机制很有帮助。资源压缩包共14个文件以10个Python脚本为主线辅以环境配置、文本说明和版本管理文件整体仅1.6MB结构紧凑便于本地导入、断点调试和二次开发。目前已有189人学习使用对希望短时间跑通MADDPG并掌握多智能体强化学习实现要点的读者具有较高参考价值。1. 多智能体博弈对抗项目从哪下手先搞清楚MADDPG解决的是什么基于MADDPG的多智能体博弈对抗算法python实现项目源码——这句话出现在课程设计、毕设开题或者组内预研里时背后的诉求通常很具体想在连续动作空间里让两个或多个智能体相互对抗比如甲追乙逃、红蓝攻防并且要有一套能写进论文、能现场演示的Python实现。MADDPG在这类任务里是最稳妥的起手式。它用集中训练、分布式执行的思路让每个智能体决策时只看自己的观测训练时Critic却能看到全局信息绕开了多智能体环境下对手也在学习、环境不再平稳的核心矛盾。这篇文章从一个可运行的追捕博弈项目切入把环境、网络、更新循环、参数逐个拆开最后列我踩过的坑。2. 博弈对抗场景的选型逻辑MADDPG的CTDE机制替我们解决了什么2.1 独立学习为何在多智能体对抗里失灵先想清楚一个基础问题如果不引入MADDPG直接把单智能体算法拿过来套会怎样很多初学者第一版代码就是这么写的每个智能体当作独立个体各自用一个DQN或DDPG观测里包含对手的位置然后各自训练。这个方案在最初的几十个回合里往往表现还行但随后会看到一个典型现象奖励曲线剧烈震荡甚至一路跌回起点。原因在于多智能体环境不再满足强化学习最基本的马尔可夫性质设定。智能体i在做最优决策时默认环境动态是固定的可实际上环境动态由对手的策略决定而对手策略也在每个迭代轮次更新。智能体i感受到的状态转移里隐含了对手的动作这个动作的分布每一轮都在变。于是经验回放里的旧样本是上个版本对手的经验Critic一更新就把新旧经验混在一起学出来的Q值忽高忽低。在追捕博弈这种对抗场景里对手从乱跑到会躲的过程会让同一条轨迹的回报从正变成负信号信噪比非常差。独立策略梯度方法也面临类似麻烦。策略梯度估计的是当前策略下的期望回报梯度对手策略变化带来的非平稳性会让优势函数估计的方差被放大学起来像在流沙上盖房子。这里引出一个结论多智能体博弈对抗不是单智能体算法的简单叠加需要在框架层面引入全局信息来稳训练。2.2 集中训练、分布式执行Critic看全局Actor只看自己MADDPG的核心改进是把训练时的信息通路和执行时的信息通路彻底分开。每个智能体i包含一个Actor网络μ_i和一个Critic网络Q_i。执行时Actor只输入自己的观测o_i输出确定性动作a_iμ_i(o_i)不依赖其他智能体的信息这保证了对博弈环境里分布式执行的基本要求。训练时Critic的输入变成所有智能体的观测o_1...o_N以及所有智能体的动作a_1...a_N输出对智能体i长期回报的估计Q_i(o,a)。Critic更新时的目标值计算是MADDPG的精髓y_i r_i γ Q_i(o_1,...,o_N, μ_1(o_1),...,μ_N(o_N))。其中Q_i和μ_i是各自的Target网络参数。关键在于目标值里的下一个动作由所有智能体的Target Actor共同生成Critic在估计时已经显式考虑了对手下一步的可能行为而不是把对手当成固定不动的NPC。这就是它缓解非平稳性、让经验回放变得可用的根本原因。Actor的更新方式也很有代表性把Critic当作一个可导的评估器让Actor的参数往让Q值更大的方向走。梯度形式写成∇θ_i J ≈ E[∇θ_i μ_i(o_i) · ∇_{a_i} Q_i(o,a)|a_iμ_i(o_i)]。实现时要注意求梯度时Critic把其它智能体的动作固定住只对智能体i自己的动作维度做梯度。所以常见的实现会把动作张量复制一份只把第i个位置替换成当前Actor的输出再送进Critic求梯度。这个小细节写错Actor的梯度方向就完全偏了后面第4章的代码里会具体处理。2.3 MADDPG的适用边界什么时候该换MAPPO或QMIXMADDPG不是万能的选型时我一般先看任务属性。如果动作空间是离散的比如棋牌博弈里选牌、格斗游戏里出招MADDPG需要额外加Gumbel-Softmax做连续近似处理起来很别扭这种场景我会优先考虑QMIX或者直接上MAPPO。如果智能体数量很多超过十几二十个MADDPG的Critic输入维度是所有智能体观测与动作维度之和拼接后特征维度会爆炸训练内存和方差都顶不住改用MAPPO的parameter sharing更实际。还有一个容易被忽略的判断点MADDPG适合混合合作—竞争环境因为Critic输入天然允许把队友的动作也拼进去。如果你的对抗博弈里每个智能体都是单打独斗、没有合作用多智能体框架未必划算单独训N个DDPG再把对手权重冻结效果也接近。但追捕博弈不一样多个追捕者之间存在配合关系MADDPG的价值体现得非常明显。提示判断一个场景适不适合MADDPG问自己两个问题——动作连续吗训练时能否拿到所有智能体的观测和动作两个都是是才走这条路。3. 最小可训练的对抗环境追捕博弈的连续动作空间与奖励设计3.1 用纯Python写一个追捕-逃跑环境一个MADDPG项目源码的第一步往往不是网络而是环境。没有环境就无法验证算法的对抗行为。常见做法是直接用OpenAI的MPE粒子环境但它包装较重安装和改奖励都比较费时间。我一般会自己写一个几十行的版本状态、动作、奖励全由自己控制后面调参时也更顺手。import numpy as np class PursuitEvasionEnv: def __init__(self, world_size5.0, max_steps100, dt0.1): self.world_size world_size self.max_steps max_steps self.dt dt self.reset() def reset(self): # 追捕者(agent 0)与逃跑者(agent 1)对称初始在左右两侧 self.agents np.array([[-2.5, 0.0], [2.5, 0.0]], dtypenp.float32) self.steps 0 return self._obs() def _obs(self): # 每个智能体看到自己的位置和对方的相对位置 obs [] for i in range(2): rel self.agents[1 - i] - self.agents[i] obs.append(np.concatenate([self.agents[i], rel]).astype(np.float32)) return obs def step(self, actions): # actions: list/array of shape (2, 2)速度分量限定在[-1,1] max_speed np.array([1.5, 1.0]) # 追捕者略快否则永远追不上 vel np.clip(np.array(actions, dtypenp.float32), -1.0, 1.0) * max_speed # 记录上一步距离用于计算距离差奖励 pre_dist np.linalg.norm(self.agents[0] - self.agents[1]) self.agents vel * self.dt self.agents np.clip(self.agents, -self.world_size, self.world_size) dist np.linalg.norm(self.agents[0] - self.agents[1]) self.steps 1 caught dist 0.3 done caught or (self.steps self.max_steps) reward np.zeros(2, dtypenp.float32) if caught: reward[0] 10.0 # 追捕者抓到 reward[1] - 10.0 # 逃跑者被抓 # 追捕者比上一步更接近就给正激励逃跑者取相反 reward[0] -0.05 * self.dt (pre_dist - dist) * 0.5 reward[1] -0.05 * self.dt (dist - pre_dist) * 0.5 return self._obs(), reward, done, {dist: dist}这段代码的逻辑说明环境里两个智能体的观测都是4维由自己的位置和对手的相对位置拼接而成动作是2维连续速度。关键点是速度上限——追捕者1.5、逃跑者1.0速度差是追捕任务能学出来的前提。如果双方速度一样任何确定性策略都追不上MADDPG只会学出一个无意义的绕圈行为。奖励部分用的是稀疏奖励加距离差的组合抓到给±10的大额奖励保证最终行为指向捕获每步的距离差奖励则给探索提供梯度。这里特意用pre_dist - dist而不是用绝对距离就是为了避免算法卡在停在原地拿稳定稠密奖励的局部最优。参数方面dt0.1表示一个仿真步长0.1秒max_steps100限定了每回合最长10秒dist0.3是捕获半径需要和步长匹配步长太大时智能体可能直接越过对方太小则前期随机碰撞几乎触发不了捕获奖励全为零训练完全无法启动。3.2 奖励形状、最大步数与速度差异三个直接决定收敛的参数第一个参数是速度上限比。追捕者比逃跑者快20%到50%是常见区间。太快的话逃跑者策略没有意义随便跑都会被抓住学出来的是一个直线冲锋的假对抗太慢则追捕者几乎没有正反馈奖励信号全是负的。第二个参数是最大步数它决定了一回合的长度。回合越长同一批经验里的轨迹方差越大MADDPG越难收敛我一般先设80到120步让一回合几秒结束把训练节奏提上来。第三个参数是奖励形状里的系数权衡。距离差激励系数设在0.5左右抓到的±10保持不变。这里有个容易踩的细节如果距离差激励的系数远大于稀疏奖励智能体会趋向于一直贴近但不抓住因为每步都能拿到小的正奖励而一旦抓住回合就结束后续的奖励就没了。这种策略从回报上看是次优的但初期探索很容易陷进去。遇到这个现象时优先把距离差系数调小或者把抓到后继续给几步奖励这种平滑处理做掉。注意环境里done对两个智能体是一致的都是抓到或超时。如果逃跑者被抓后追捕者还能继续行动经验池里就会混入已结束回合的无效样本。训练时需要把done传给Q目标计算将终止后的目标值置零否则Critic的TD误差会被污染。4. 项目源码的核心骨架Actor-Critic网络、经验池与MADDPG更新循环4.1 网络定义为什么Critic输入是所有智能体的观测和动作环境就绪后下一步是定义网络。MADDPG的Actor本质上就是一个从观测到动作的映射因为输出要限定在环境规定的[-1,1]连续区间内最后一层用Tanh激活。Critic则把所有智能体的信息拼接起来中间用全连接层处理。这里有个容易忽略的原则Critic的输入顺序必须全局统一先拼所有观测、再拼所有动作后续训练和Target网络计算都要保持同样顺序不然学出来的Q值完全是错的。import torch import torch.nn as nn 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(), # 动作输出到[-1,1] ) def forward(self, obs): return self.net(obs) class Critic(nn.Module): def __init__(self, obs_dims, act_dims, hidden64): # obs_dims/act_dims: 每个智能体的观测/动作维度列表 super().__init__() input_dim sum(obs_dims) sum(act_dims) self.net nn.Sequential( nn.Linear(input_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, 1), ) def forward(self, obs_all, act_all): # obs_all/act_all: 每个智能体的张量列表按固定顺序拼接 x torch.cat(obs_all act_all, dim-1) return self.net(x)逻辑说明Actor结构和单智能体DDPG完全一样所以MADDPG在执行阶段可以直接复用单智能体策略网络。Critic输入维度由所有智能体共同决定这正是MADDPG里Multi-Agent的核心体现。实际项目里我一般让每个智能体各持有一份Actor和CriticCritic可以选择共享参数也可以各自独立。前一种方案参数少、训练快后一种在智能体异质时更稳比如追捕者和逃跑者的动作语义完全不同。如果两个智能体的观测维度不同就直接分开。参数说明hidden64对追捕这个规模已经够用做更复杂的攻防博弈时建议升到128或256。Tanh输出层的作用是把动作限制到[-1,1]环境侧还要再做一次速度缩放。如果网络输出过大而环境侧只按线性映射来约束梯度方向会被截断训练时会出现训练下降但实际策略没变的错觉。4.2 经验池与主循环更新软更新目标网络的关键一行MADDPG的经验池存入的是全局转移样本而不是每个智能体独立的五元组。一条样本包含所有智能体的观测、动作、下一观测、奖励和全局done。这样设计是必要的因为Critic更新时必须拿到全局信息。代价是经验池里一条样本同时服务于多个智能体的更新样本利用率变高但样本过期的影响也会更明显。import random import numpy as np class ReplayBuffer: def __init__(self, capacityint(1e6)): self.capacity capacity self.buffer [] self.pos 0 def push(self, obs_all, act_all, rew_all, obs_next_all, done_all): # obs_all/act_all 都是长度为n_agents的numpy数组列表 data (obs_all, act_all, rew_all, obs_next_all, done_all) if len(self.buffer) self.capacity: self.buffer.append(data) else: self.buffer[self.pos] data self.pos (self.pos 1) % self.capacity def sample(self, batch_size): batch random.sample(self.buffer, batch_size) obs_all torch.as_tensor(np.stack([b[0] for b in batch]), dtypetorch.float32) act_all torch.as_tensor(np.stack([b[1] for b in batch]), dtypetorch.float32) rew_all torch.as_tensor(np.stack([b[2] for b in batch]), dtypetorch.float32) obs_next_all torch.as_tensor(np.stack([b[3] for b in batch]), dtypetorch.float32) done_all torch.as_tensor(np.stack([b[4] for b in batch]), dtypetorch.float32) return obs_all, act_all, rew_all, obs_next_all, done_all经验池的逻辑说明采样后的obs_all形状是(batch_size, n_agents, obs_dim)所以更新时取第i个智能体的数据就是obs_all[:, i]。容量1e6对追捕任务偏大我通常用2e5到5e5。MADDPG的样本本身带有时效性保留太多旧样本会让非平稳性问题更严重。这个在避坑章节还会展开。接下来是更新循环也是整个源码的核心。每个智能体的Actor和Critic都各配一个Target网络用软更新方式同步。def update(agents, replay, batch_size128, gamma0.95, tau0.01): obs_all, act_all, rew_all, obs_next_all, done_all replay.sample(batch_size) n len(agents) # 先按智能体维拆开 obs_all [obs_all[:, i] for i in range(n)] act_all [act_all[:, i] for i in range(n)] obs_next_all [obs_next_all[:, i] for i in range(n)] for i, agent in enumerate(agents): # 1) 用Target网络计算目标Q值 with torch.no_grad(): next_actions [a.target_actor(obs_next_all[k]) for k, a in enumerate(agents)] target_q rew_all[:, i].unsqueeze(1) gamma * (1.0 - done_all[:, i].unsqueeze(1)) * \ agent.target_critic(obs_next_all, next_actions) # 2) 更新Critic q_value agent.critic(obs_all, act_all) critic_loss nn.MSELoss()(q_value, target_q) agent.critic_opt.zero_grad() critic_loss.backward() nn.utils.clip_grad_norm_(agent.critic.parameters(), 1.0) agent.critic_opt.step() # 3) 更新Actor复制动作张量只替换第i个智能体的动作 actions_for_q list(act_all) actions_for_q[i] agent.actor(obs_all[i]) actor_loss -agent.critic(obs_all, actions_for_q).mean() agent.actor_opt.zero_grad() actor_loss.backward() nn.utils.clip_grad_norm_(agent.actor.parameters(), 1.0) agent.actor_opt.step() # 4) 软更新Target网络 for target_param, param in zip(agent.target_actor.parameters(), agent.actor.parameters()): target_param.data.copy_(tau * param.data (1.0 - tau) * target_param.data) for target_param, param in zip(agent.target_critic.parameters(), agent.critic.parameters()): target_param.data.copy_(tau * param.data (1.0 - tau) * target_param.data)这段代码的每一段都有含义。第1步的目标Q值计算是MADDPG和DDPG最大的差异点next_actions由所有智能体的Target Actor生成Critic目标里显式包含了对手下一步会怎么动的估计。第2步的Critic MSE loss不再赘述。第3步有个关键细节操作时必须复制act_all再替换不能直接在原张量上改否则后续智能体的更新会被前一个智能体的动作覆盖。第4步的tau0.01是常见值。Target网络更新太慢Q值学习滞后更新太快Target和online几乎同步就失去了稳定训练目标的意义。4.3 一套能跑起来的超参数参考表参数建议值说明经验池容量2e5 ~ 5e5比单智能体DDPG小样本时效性太强batch_size128太小梯度不稳太大显存开销高gamma0.95 ~ 0.99回合较短时0.95足够tau0.01Target软更新系数一般优先固定critic lr1e-3Critic要让Q值尽快拟合TD目标actor lr1e-4Actor学慢点防止策略突变探索噪声std0.1 ~ 0.3高斯噪声随训练线性衰减单回合更新次数50 ~ 100每回合结束后从经验池采样更新的次数这张表的选型理由是Critic学习率比Actor高一个量级因为Critic的TD目标更新较快Actor依赖Critic给的方向Actor学太快就会在Critic还没稳的情况下反复横跳。探索噪声std的衰减节奏影响很大这里多少带点玄学成分后面避坑章节会专门展开。训练时更新次数要和新数据量挂钩攒够一个batch再更新别在同一个mini-batch上反复练。5. 训练不收敛排查MADDPG的5个高频翻车点5.1 奖励曲线震荡后骤降经验池样本与更新节奏不匹配现象前几十个回合奖励缓慢爬升随后突然大幅震荡甚至一路走低回到起点。原因这是MADDPG最典型的非平稳性问题。追捕者和逃跑者同时在学经验池里大部分样本还来自旧版本对手。Critic把这些旧样本当成当前策略的经验来拟合会先给出一个偏乐观的估计等新版本对手的策略刷新经验池后Critic又发现之前的Q值高估了于是曲线塌方。面对这个现象我的第一反应不是调网络结构而是调更新节奏。解决把单回合更新次数从100降到50同时缩小经验池容量让旧样本更快滚出去。另一个有效做法是只从最近5000条经验里采样。追捕博弈这个规模下这两招通常能把震荡压住。5.2 智能体学会原地罚站探索噪声消失得太早现象训练到中后期两个智能体都停在原地不动奖励维持在一个不高不低的水平追捕者不去抓逃跑者也不跑。原因策略收敛到了一个局部最优。如果奖励用的是绝对距离而不是距离差停在原地可以让每步的距离奖励稳定为正既不会被抓也拿不到大额奖励对MADDPG来说这个策略在数值上很安全。就算用了距离差奖励如果噪声std衰减到0.01以下前期那种偶然抓到的大额奖励对策略的校正作用也基本消失策略就冻在了一个平庸解上。解决把噪声std从0.3起步放到第300个回合左右线性减到0.1之后保持不要归零。同时检查done的来源占比如果绝大多数回合终止来自超时而非捕获说明策略根本没有在追应回退噪声参数重新探索。5.3 Critic loss降不下去拼接维度漏了某个智能体现象Critic loss在前几百次更新中下降到一定程度后彻底卡住Actor的loss开始乱跳。原因最大嫌疑是拼接顺序不一致。Critic定义时用了obs_all act_all的顺序计算target Q时却是act_next_all obs_next_all两种顺序维度恰好能对上但语义完全错位loss就会时而降时而升。还有一种常见情况Actor的输出被无意的squeeze操作去掉了一维动作张量维度缺失梯度数值异常。解决在每次更新前打印所有关键张量的shape写一个断言确保critic(obs_all, act_all)和target_critic(obs_next_all, next_actions)的拼接顺序一致。另外把网络权重初始化范围改小比如线性层初始std设在0.01附近让Critic初始Q值接近0TD误差起点更可控。5.4 Loss变成NaN梯度裁剪与动作边界现象训练到某个回合日志里的actor_loss和critic_loss同时出现NaN之后所有训练都被污染恢复不了。原因MADDPG的梯度链路是Actor输出动作Critic把动作当作输入反传梯度。一旦环境出现异常大的奖励Critic梯度变大反向传播到Actor后Actor参数被更新到一个输出NaN的区域。动作变成NaN后Target网络的目标Q也变成NaN经验池里混入脏数据整条链路崩溃。解决两处兜底必须同时做。第一每次参数更新都加clip_grad_norm_范数上限设在1.0到2.0之间第二动作送入环境之前做np.clip加isfinite检查发现NaN就直接用上一帧动作替换。第三个兜底是损失值检查critic_loss出现非有限值时跳过本次更新不给脏数据进网络的机会。5.5 测试表现远差于训练评估协议里的噪声问题现象训练曲线已经稳定但实际测试或现场演示时追捕者表现得像完全没学过连固定逃跑者都追不上。原因第一嫌疑是评估时还在给Actor加探索噪声或者直接用训练时在线网络最后一步的参数而没有用Target Actor。第二个原因是过拟合单一对手训练用的逃跑者恰好是某个随机种子下的特定行为模式换一个初始位置或者换一个策略追捕者就失效。解决把评估协议固定下来。actor.eval()加torch.no_grad()去掉噪声连续跑50到100个回合统计捕获成功率。如果成功率忽高忽低把逃跑者策略池扩到5个每个跑20回合取平均。这个评估流程一定要独立成脚本和训练脚本分开不然每次对比的结果都没有可比性。6. 让对抗策略更稳的进阶技巧对手库、轨迹可视化与结项评估6.1 用对手策略库提升鲁棒性上一节提到单一对手容易过拟合常见进阶做法是准备一个对手库包含几个不同随机种子、不同训练阶段保存下来的逃跑者策略每回合随机抽一个来对抗。这样追捕者不能只针对某个固定行为模式优化。evader_pool [load_model(fckpt/evader_seed{i}.pt) for i in range(5)] for ep in range(n_episodes): evader random.choice(evader_pool) obs env.reset() while not done: # 追捕者每回合都训练逃跑者用库里冻结的权重 action_pursuer pursuer_actor(obs[0]) noise action_evader evader.actor(obs[1]) obs, reward, done, info env.step([action_pursuer, action_evader]) replay.push(...)逻辑说明逃跑者权重冻结只更新追捕者。这样的对手多样性替代了单对手的套路化对抗对泛化性提升明显。代价是每回合的对手水平不同训练曲线会更颠簸所以评估时也要按对手库逐个测试而不是只挑最好打的那个。6.2 结项前怎么验证这套源码真的会对抗评估指标我一般看三样捕获成功率、平均追捕步数、距离曲线。捕获成功率是硬指标在固定评估协议下能打到七成以上才敢说策略是有效的。平均追捕步数看策略效率一个真正会对抗的追捕者应该在接近最大步数一半的位置完成捕获拖到超时才赢说明行为有严重缺陷。距离曲线帮助判断行为模式训练早期曲线应整体下降如果出现反复横跳说明奖励振荡还没消掉。保存策略时每100个回合存一份检查点结项时挑3到5个不同训练阶段的模型做横向对比这是答辩或汇报时很直观的可视化素材。最后说一个自己的习惯训练MADDPG时永远把随机种子和对手权重版本记在训练日志里。这个算法对随机性非常敏感同样代码不同种子可能一个收敛一个发散没有日志调参和写报告都无从谈起。希望这篇拆解能把多智能体博弈对抗从原理到落地的路铺清楚也帮你在自己的项目里少走几步弯路。本文还有配套的精品资源点击获取