☰
MADDPG多智能体博弈对抗算法:核心原理、源码实现与训练避坑指南
2026/9/28 6:09:36 网站建设 项目流程

简介:这份Python实现的MADDPG多智能体博弈对抗算法源码,主要面向强化学习与人工智能方向的高校学生、科研工作者及算法开发者,尤其适合计算机、自动化、电子信息等相关专业用于多智能体协作、竞争与混合博弈场景的算法研究与实验复现。压缩包共91个文件,以76个Python脚本为核心,涵盖MADDPG主算法、神经网络结构、经验回放机制、环境交互逻辑与测试模块;另有10个GIF动态演示、1个Jupyter Notebook示例、1个配置文件及说明文档,整体仅3.26MB,下载后可快速部署运行。目前已有92人学习下载。源码附有完整的训练与测试流程,可直接运行复现对抗实验结果,也便于修改奖励函数或网络结构,扩展至追逃博弈、多智能体战斗等自定义场景;资源内附带配置文件、说明文档与GIF演示,有助于理解环境搭建和复现细节,可作为课程设计、毕业设计或项目前期的算法验证基础,也适合初学者结合示例逐步理解MADDPG原理。

1. MADDPG 多智能体博弈对抗算法:为什么它成了多智能体强化学习的默认起点

做过多智能体强化学习的人,第一次跑通 MADDPG 的感觉,大概率是“拍大腿”——原来让一群智能体学会配合或者互坑,不需要复杂的通信协议,只要在训练阶段把所有人的动作和观测拼在一起喂给 Critic 就够了。这个算法由 OpenAI 在 2017 年提出,全称是 Multi-Agent Deep Deterministic Policy Gradient,专治“一个智能体在环境里单打独斗,其他智能体一动就把环境变成非平稳”的痛点。它适合三类人:你在做机器人编队、博弈对抗推演、或者研究多智能体决策的入门级复现,这份源码和实验结果就是你判断算法到底有没有用的最快路径。

2. 从 DDPG 到 MADDPG:集中训练分散执行,到底改了谁的策略?

2.1 单智能体 DDPG 的局限:环境一变,梯度的根基就歪了

DDPG 当年在连续控制任务里表现很好,核心思路是让 Actor 网络根据当前状态输出连续动作,让 Critic 网络估计该状态下这个动作的 Q 值。训练时,Critic 通过时序差分误差去逼近真实的 Q 函数,Actor 则沿着 Q 值上升的方向更新自己的策略梯度。这套逻辑建立在“环境的状态转移是稳定的”这一潜在假设上——也就是说,下一个时刻的状态只取决于当前状态和当前动作,背后的转移概率不会变。

但在多智能体环境中,这个假设撑不住了。以经典的两个机器人对抗场景为例,智能体 A 的策略在更新,但智能体 B 也在同步更新,于是环境对 A 来说每时每秒都在变。上一轮梯度告诉 A “向左走能得分”,但 B 的策略学得快一点,下一轮变成“向左走会被撞开”,A 的 Q 值估计就被污染了。DDPG 在训练过程中看到的经验回放样本,因为环境非平稳,不再能代表真实的动态分布,这也就是多智能体强化学习最常说的“非平稳性”问题。

MADDPG 解决这个问题的关键词就 8 个字:集中训练、分散执行。训练时,每个智能体的 Critic 可以获得全局信息,包括所有智能体的观测和动作;执行时,Actor 只需要自己的局部观测就能决策。这样 Critic 对 Q 值的估计是站在全局视角做的,把其他智能体策略变化带来的“干扰”显式当作输入,梯度自然就准了。

2.2 Critic 的输入拼接:这是 MADDPG 最核心的改动

实现层面看,MADDPG 对 DDPG 的改动并不大,核心在 Critic 网络的输入形状上。单智能体 DDPG 的 Critic 输入是 (state, action) 拼接;MADDPG 的 Critic 输入换成 (obs_1, obs_2, ..., obs_n, action_1, action_2, ..., action_n) 的拼接。

import torch import torch.nn as nn import torch.nn.functional as F class MADDPGCritic(nn.Module): def __init__(self, num_agents, obs_dim, action_dim, hidden_dim=128): super().__init__() self.num_agents = num_agents self.obs_dim = obs_dim self.action_dim = action_dim # 关键:输入维度是所有智能体的观测拼接 + 所有智能体的动作拼接 input_dim = num_agents * (obs_dim + action_dim) self.fc1 = nn.Linear(input_dim, hidden_dim) self.fc2 = nn.Linear(hidden_dim, hidden_dim) self.q = nn.Linear(hidden_dim, 1) def forward(self, all_obs, all_actions): # all_obs 形状: [batch, num_agents, obs_dim] # all_actions 形状: [batch, num_agents, action_dim] batch_size = all_obs.size(0) obs_flat = all_obs.view(batch_size, -1) action_flat = all_actions.view(batch_size, -1) x = torch.cat([obs_flat, action_flat], dim=1) x = F.relu(self.fc1(x)) x = F.relu(self.fc2(x)) q_value = self.q(x) return q_value

这段代码里有两个细节要注意。第一个是view(batch_size, -1)的用法,它把所有智能体的观测沿特征维拉平,保证 Critic 看到的输入顺序是固定且一致的;第二个是激活函数选择,中间层用 ReLU 是通用做法,最后输出 Q 值不加激活,因为 Q 值可以是任意实数,不需要限定在某个区间。如果你的环境里智能体数量固定,这个实现直接可用;如果智能体数量会变化,比如对抗中某一方中途退出,你就得考虑用 masking 或者上限智能体数量来固定输入维度。

2.3 Actor 与 Critic 的分工决定了博弈对抗的稳定性

Actor 网络仍然只输入自己的观测,输出动作,结构上和单智能体 DDPG 完全一致。区别在于训练时,Actor 的梯度来自 Critic 对自己动作的偏导,而此时 Critic 已经把其他智能体的动作当作固定输入了,所以 Actor 学习到的策略,隐含了“在对手当前策略下如何做得最好”这个含义。这比单纯地在一堆历史样本里做回归更贴近博弈对抗的本质,因为你的策略要动态应对对手,而不是对着一个静态环境练死招。

另一个值得注意的设计是目标网络。MADDPG 延续了 DDPG 的 soft update 方式,也就是目标网络参数不是定期从在线网络硬拷贝,而是每次更新时按比例 τ 向在线网络参数靠近。这个比例一般取 0.01,你会发现如果设到 0.1,训练初期很容易因为目标参数跟着在线参数剧烈变动而疯狂震荡。原因在于多智能体场景里,每个智能体的策略都在变,目标网络的平滑作用比单智能体场景更重要。

3. 把源码跑起来:Python 环境准备与最小训练命令

3.1 拿到“源码及实验结果.zip”之后先检查什么

解压这个 zip 之后,我一般不会急着运行,先按目录结构确认里面有没有三样东西:算法模块、实验脚本、以及实验结果文件。常见的排列方式是maddpg.py或algorithms/目录放算法主体,main.py或train.py是入口脚本,results/或logs/目录下放训练日志、reward 曲线图、模型权重文件。优先级从train.py开始看,因为入口脚本能告诉你这个项目默认跑哪个环境、训练多少轮、评估频率是多少。

有的源码包会把模型参数和 reward 曲线图一起打包,但如果里面只有“实验结果.txt”或 CSV 日志,你最终还是要自己跑一遍训练才能对比。确认环境依赖之前,建议先看一下有没有requirements.txt;没有就用模块文件头的 import 列表来推断依赖。

3.2 最小训练命令:从 Python 安装到第一步训练

MADDPG 项目最常见的复现环境是 Python 3.8 及以上,配 PyTorch 和 OpenAI 的 Multi-Agent Particle Environment,也就是 MPE。MPE 在 PyPI 上有社区维护的 mpe 包,也可以从源码安装。我的建议是不要在这个环节追求最新版本,能跑通最重要。

# 1. 创建虚拟环境,避免污染系统 Python python -m venv maddpg_env source maddpg_env/bin/activate # Windows 下用 maddpg_env\Scripts\activate # 2. 安装核心依赖 pip install torch --index-url https://download.pytorch.org/whl/cpu pip install numpy matplotlib pip install mpe # 3. 跑最小训练,先把轮数调小验证环境 python train.py --scenario simple_adversary --max-episodes 100 --episode-len 25

这里每个命令都有取舍。--index-url指定 CPU 版 PyTorch,是因为 MADDPG 的训练通常不需要 GPU,MPE 环境本身是 Python 实现的控制器,瓶颈在环境仿真而非张量计算;如果你本机有 NVIDIA 显卡且想跑大规模实验,去掉这个参数装默认版本就行。--scenario simple_adversary是 MPE 里最经典的对抗环境,两个对手、一个要被争夺的目标点,很适合验证算法正确性。--max-episodes 100是我强烈建议你在第一次跑的时候设置的值,因为完整训练常常要跑几千轮,不要浪费一晚上在环境报错上。

3.3 训练日志和实验结果文件:怎么判断这次跑成功了

跑完这 100 个 episode 之后,打开results/目录下生成的 CSV,你能看到每轮的平均 reward。此刻先别急着看曲线高低,看三个异常信号:日志里有没有 NaN;reward 是否有任何上升趋势;程序有没有在回合中途异常退出。如果 100 轮 reward 从 -50 慢慢爬升到 -20,哪怕绝对值仍然是负的,也说明梯度在正常流动,整套环境、模型、回放缓冲区的链路是通的。

这 100 轮只是抽烟测试,真正要复现源码包里实验结果的完整训练,建议训练 2000~3000 episode,评估间隔设成 100。MADDPG 在 MPE 的小地图上对算力要求很低,纯 CPU 跑一个场景也就四五个小时,这个开销远低于大多数 CV 训练任务,也正因此它成为多智能体强化学习入门复现的首选算法。

4. 核心实现解读:每个智能体如何“欺骗”对手

4.1 动作空间的分岔路:连续动作与离散动作的处理

MADDPG 原论文的理论推导建立在连续动作空间上,Actor 的输出层通常用 tanh 把动作限制在 [-1, 1]。但 MPE 里的大多数场景,动作空间其实是离散的,比如上下左右和不动五个动作,这就造成了一个经典的动手坑:直接把离散动作输入 Critic,梯度回传时离散动作没有连续导数,训练就会中断。

常见做法是两种。第一种是把离散动作转成 one-hot 再拼给 Critic,同时 Actor 输出的连续值转成概率分布,用 Gumbel-Softmax 做可微采样;第二种更粗暴,Actor 照常输出连续值,实际环境执行时按阈值映射成离散动作,训练时仍然用连续值。我倾向于推荐你写第一种,因为梯度路径更干净。

import torch import torch.nn.functional as F from torch.distributions import Categorical def discrete_action_sample(logits, tau=1.0, hard=True): # logits: [batch, num_discrete_actions] 每个智能体独立预测的动作概率 # 用 Gumbel-Softmax 让离散采样过程可微 gumbels = -torch.empty_like(logits).exponential_().log() gumbels = (logits + gumbels) / tau y_soft = F.softmax(gumbels, dim=-1) if hard: index = y_soft.argmax(dim=-1) y_hard = torch.zeros_like(logits) y_hard.scatter_(1, index.unsqueeze(-1), 1.0) # 直通估计:前向用硬 one-hot,反向用 soft 的梯度 return (y_hard - y_soft).detach() + y_soft else: return y_soft

两个参数值得进一步说。tau是温度系数,控制样本的随机程度,训练初期可以设 1.0 让探索更充分,训练后期降到 0.1 左右让策略更果断;hard设为 True 时,前向传播拿到的是硬 one-hot 动作,但反向传播时梯度经过的是 soft 版本,这就是直通估计的主意,也就是这些年许多离散动作强化学习实现里都能看到的手法。你在阅读源码时如果发现 Actor 输出层之后接了F.softmax又做了detach,八成就是这种处理。

4.2 目标网络与经验回放:MADDPG 的“后悔药”和“黑匣子”

没有经验回放,多智能体强化学习根本练不动。理由很简单:环境非平稳,如果你只在当前轨迹上做梯度更新,那么智能体 A 的一个动作导致智能体 B 的行为变化,这种影响没有任何机制被记录下来。经验回放缓冲区把 (所有智能体观测, 所有智能体动作, 奖励, 下一帧所有智能体观测) 的元组存起来,训练时随机抽取一批样本,相当于把前一刻的“当下环境”冻结成历史标本,让 Critic 在相对稳定的数据分布上拟合。

缓冲区大小一般设 100000,批次大小设 1024。实际项目中,缓冲区大小对显存影响不大,因为 MPE 的单条经验很短;批次大小反而更敏感,设太大会导致每步更新太慢,设太小梯度噪声太大。经验回放相当于是这个算法的“后悔药”,训练跑崩了之后,你可以缩小探索噪声重新初始化,但不必从头开始攒经验。

4.3 训练循环里的 4 个关键超参数怎么定

我每次复现 MADDPG,都会把超参数表打印出来贴在终端旁边。固定要调的是这四个:Actor 学习率 1e-4,Critic 学习率 1e-3,这个比例关系不是随意定的,Critic 收敛速度需要快于 Actor,否则 Q 值还没拟合好,Actor 就被带偏了;折扣因子 gamma 取 0.95,MPE 单回合步数短,取太接近 1 会让价值估计在回合结束时拖泥带水;soft update 的 tau 取 0.01,作用前面已经说过了;最后是探索噪声,如果你用高斯噪声,初始标准差 0.3 是个安全值,训练到中段逐步衰减到 0.05。

# 训练循环核心片段,省略了环境交互部分 for episode in range(max_episodes): obs_n = env.reset() # obs_n 是 list,每个元素是一个智能体的观测 episode_reward = [0 for _ in range(num_agents)] for step in range(episode_len): # 探索:连续动作加噪声,离散动作提高温度 actions_n = [] for i, agent in enumerate(agents): obs_tensor = torch.FloatTensor(obs_n[i]).unsqueeze(0) action = agent.actor(obs_tensor) if continuous: action += torch.randn_like(action) * exploration_rate else: action = discrete_action_sample(action, tau=exploration_rate) actions_n.append(action.detach()) # 环境步进,拿奖励和下一帧观测 next_obs_n, rewards_n, done_n = env.step(actions_n) # 存入 replay buffer,必须存多智能体全量信息 replay_buffer.add(obs_n, actions_n, rewards_n, next_obs_n, done_n) # 每 100 步做一次真正的参数更新 if replay_buffer.size() > batch_size and step % 100 == 0: batch_obs, batch_act, batch_rew, batch_next_obs, batch_done = replay_buffer.sample(batch_size) # 计算 target Q 时,需要用目标网络输出下一时刻所有智能体的动作 with torch.no_grad(): next_actions_n = [] for i, agent in enumerate(agents): next_actions_n.append(agent.target_actor(batch_next_obs[i])) target_q = agent.target_critic(batch_next_obs, next_actions_n) target_q = batch_rew + gamma * target_q * (1 - batch_done) # Critic 损失:预测 Q 与 target Q 的 MSE current_q = agent.critic(batch_obs, batch_act) critic_loss = F.mse_loss(current_q, target_q) # Actor 损失:最大化当前 Critic 对自己动作的估值 current_actions = [agent.actor(batch_obs[0]) for agent in agents] actor_loss = -agent.critic(batch_obs, current_actions).mean() # 更新网络 critic_optimizer.zero_grad() critic_loss.backward() critic_optimizer.step() actor_optimizer.zero_grad() actor_loss.backward() actor_optimizer.step() # soft update 目标网络 for target_param, param in zip(agent.target_actor.parameters(), agent.actor.parameters()): target_param.data.copy_(tau * param.data + (1 - tau) * target_param.data)

这段代码是 MADDPG 训练循环的全貌,我用 python 伪代码的形式把省略的环境交互留在注释里。两个关键点需要展开。第一,next_actions_n必须用目标 Actor 计算,而不是在线 Actor,否则 target Q 的计算会被当前策略的噪声污染,违背了时序差分里“target 应该是对固定策略的期望”这一原则。第二,Actor 的损失函数是对 Critic 输出取负均值,这是 DDPG 系列一脉相承的操作,Critic 对这个动作的打分越高,Actor 的损失越小,从而推动 Actor 朝高分方向演进。

超参数的初始值我通常按照上述数值设置,然后观察前 500 episode 的 reward 曲线走向。如果曲线一路向下,先调学习率,尤其是 Critic 的 1e-3;如果曲线不动,检查探索噪声是否过早衰减到 0;如果曲线剧烈震荡,把 tau 调小到 0.005,并增大 batch size 到 2048。这套排查顺序在多个场景下都管用。

5. 跑不通、不收敛、震荡?MADDPG 训练常见的 5 个坑

5.1 训练 500 episode reward 还在负值,是算法坏了还是奖励函数问题?

现象:reward 曲线平得像一条直线,或者在负值区间内抖动但毫无上升趋势,许多人在这一步就放弃了。原因有两个概率最高:第一,探索噪声太大,Actor 输出的动作被噪声淹没,Critic 学到的 Q 值是对一堆随机动作的平均,策略信号微弱;第二,MPE 的 sparse reward 环境里奖励本来就稀疏,比如只有最后撞到目标点才给 +10,训练中大部分时间 reward 为 0 或负的小惩罚,曲线自然难看。

解决:先降探索噪声,把初始标准差从 0.3 降到 0.1,观察 200 episode;如果还没有变化,就在环境交互阶段加 reward shaping,比如靠近目标点给一个小奖励梯度,帮助 Critic 建立中间状态的价值估计。注意 reward shaping 只用于训练,评估时要关掉。

5.2 离散动作环境直接报错:ValueError 的根源

现象:程序跑在simple_tag这类连续动作场景时一切正常,但换到simple_adversary离散动作就报ValueError: shape mismatch,或者 loss 直接变成 NaN。原因很直接,MPE 里动作空间定义成 Discrete(5),而 MADDPG 的 Actor 输出是 tanh 激活的连续值,长度也不是 5,拼给 Critic 后维度不匹配。

解决:按 4.1 节 Gumbel-Softmax 的方法处理,把 Actor 输出维度改成跟离散动作数一致,用温度参数控制探索。如果项目已经有可用的行动模块,也可以把离散动作转为 one-hot 向量,再拼接到 Critic 输入,这样 Critic 的输入维度恒定为 num_agents * (obs_dim + num_discrete_actions)。

5.3 同一套参数在合作场景稳定、在对抗场景震荡

现象:跑合作场景simple_spread收敛得很漂亮,切换成对抗场景simple_adversary之后 reward 曲线来回大力震荡,看上去像两个智能体在互相比谁先“翻车”。原因是竞争场景里面,两个智能体的目标天然冲突,Critic 输入里拼接的对手动作在高频变化,导致 Q 值估计方差变大。这是 MADDPG 本身的一个已知软肋,论文里虽然有对抗实验,但收敛速度明显慢于合作场景。

解决:把 batch size 从 1024 提到 2048,提升目标 Q 值估计的置信度;再加长目标网络 soft update 的周期,比如 tau 从 0.01 降到 0.005,给目标 Q 值更大的惯性。还有一个容易忽略的点:对抗场景的评估频率要降低。合作场景每 100 episode 评估一次就够,对抗场景最好等到收敛迹象出现后再评估,否则容易把中间阶段的策略误判为失败。

5.4 保存和加载智能体权重时维度错位

现象:训练结束保存模型,重新加载想继续训练时,出现size mismatch for fc1.weight: copying a param with shape torch.Size([128, 6]) from checkpoint。原因很常见:你保存的是agent.actor.state_dict(),但加载时头脑一热用了torch.load('model.pth')直接赋值给新模型,而新模型的 obs_dim 或智能体数量跟训练时不一致。

解决:保存时用一个包装字典,把网络名、智能体索引、维度信息全部记下来:

checkpoint = { 'actor_i0': agents[0].actor.state_dict(), 'critic_i0': agents[0].critic.state_dict(), 'obs_dim': 4, 'action_dim': 5, 'num_agents': 2, 'episode': episode_count } torch.save(checkpoint, 'maddpg_checkpoint.pt')

加载时先校验维度再load_state_dict,校验不通过就打印提示而不是直接报错。多智能体项目里,这个习惯能帮你省下大量调试时间,尤其是当你改了环境配置又想用旧权重接着训练时。

5.5 训练速度慢到怀疑人生

现象:CPU 占用拉满,但每 100 episode 要跑十几分钟,日志刷新像是挤牙膏。原因有两个:一是没有利用 MPE 环境本身是轻量仿真这个特点,没必要每次采样都重新reset()整个场景;二是训练代码里可能不小心在环境交互循环内做了模型前向传播的grad记录,浪费大量计算。解决:把采样阶段包在torch.no_grad()里,只在真正训练更新时开梯度。另一个有效手段是把 MPE 环境版本换成 vectorized 实现,比如用ray做并行 rollout,这样四个环境同时采样,训练速度基本能翻倍。我自己的经验是,先在单环境把小规模参数跑通,再上并行,不要一上来就并行,排查问题会复杂得多。

6. 如何验证“源码及实验结果”里的结论:从 reward 曲线到胜率

6.1 把 reward 曲线分为三个区间来看

拿到实验结果文件里的 reward 曲线后,不要只看最终数值。我会把曲线纵向切成三段:前 20% 是探索期,曲线应该在波动中缓慢爬升;中间 60% 是学习期,曲线会出现台阶式上升,因为智能体偶尔发现一个新策略后,Critic 暂时高估了新策略的价值,随后回摆到合理位置;最后 20% 是收敛期,曲线在某个水平附近小幅波动。如果最后阶段不是波动而是单调上升,说明还没收敛,需要更多 episode。

6.2 对抗场景的硬指标:胜率与平均奖励统计

reward 曲线看趋势,胜率看结论。GitHub 上大多数 MADDPG 源码在simple_adversary里会用“对抗双方的胜率”作为主指标,但实现方式五花八门。最可靠的做法是在评估阶段固定对手为随机策略,让待评估智能体连续对战 100 局,统计胜率。这样做能排除训练时的探索噪声,得到一个近似真实性能的数字。另外一份实验结果的 reward 表,哪怕平均值写得再漂亮,也要看标准差。标准差大于平均值的均值是在向你发警告:训练不稳定,重跑几次结果可能完全不同。

6.3 定制你自己的实验结果:修改环境参数复现实验

想真正吃透这个源码包,最后一步是修改环境参数跑一次对比实验。比如把simple_adversary环境中的N_GOOD = 1改成 2,让两个好智能体合作对抗一个坏智能体,你就能直观理解智能体数量变化对 Critic 输入维度的影响。改完这组对比实验,你再回头理解“集中训练”这四个字,感受会完全不同——你会发现 Critic 的输入维度成倍膨胀,而 Actor 的输入维度一点没变,这正是分散执行的意义所在。

这几年我在多智能体方向反复折腾,最大的教训是:MADDPG 的高门槛从来不在公式,而在环境、动作空间和参数之间的隐性耦合。只要你遇到离奇报错时不急着怀疑算法本身,而是先打印维度、检查动作空间是否连续、再看噪声参数,多半能在半小时内定位问题。希望这篇笔记能帮你在跑通自己的“源码及实验结果”时,少走一点我当时走过的弯路,把时间花在真正有趣的对抗策略设计上。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询