简介:面向计算机专业毕业设计、课程设计及强化学习入门者,这是一份基于MADDPG(多智能体深度确定性策略梯度)的博弈对抗算法Python完整项目。代码覆盖经验回放缓冲区、Actor-Critic网络、DDPG训练流程、环境交互与测试模块,并配有详细中文注释,可直接运行验证,便于理解策略梯度、目标网络与探索机制等关键环节。压缩包共14个文件,以10个.py源码模块为主,辅以环境配置、虚拟环境信息及说明文档等,整体仅1.6MB,结构紧凑、层次清晰。项目源自经导师指导的高分毕业设计(评审98分),所有源码均本地编译调试通过,能够支撑多智能体对抗场景实验、算法对比与二次开发。已有72人学习下载,适合需要快速上手多智能体强化学习或筹备毕业设计的同学参考借鉴。
1. 先说结论:这份MADDPG项目到底值不值得啃
先说结论:如果你要在有限时间内完成一个“能跑、能讲、能答辩”的多智能体博弈对抗课题,基于MADDPG的多智能体博弈对抗算法python源码加详细注释,几乎是当前性价比最高的选型。MADDPG(Multi-Agent Deep Deterministic Policy Gradient)用“集中训练、分散执行”的设计,让每个智能体只靠自己的局部观测做决策,却在训练时共享全局信息,一套代码就能同时撑起理论分析、对比实验和可视化展示。它解决的痛点很具体:多个智能体在同一个环境里互相竞争或协作时,环境对每个智能体来说都是“非平稳”的,单智能体强化学习算法在这里会集体失效。这篇笔记我会按阅读源码的顺序,从环境搭建、网络定义、梯度更新、参数调优一路讲到答辩时怎么把效果讲清楚,适合需要一个学期内出成果、并且想在论文里有扎实实验支撑的同学。
2. 为什么多智能体对抗不能用单智能体算法:MADDPG的设计逻辑与最小跑通实验
多智能体博弈对抗里最反直觉的一件事是:你把DQN、PPO这些单智能体算法分别套到每个智能体身上,它们通常会学得很差,甚至不如随机策略。这不是算法没调好,而是问题结构就不一样。这一章先把“为什么不行”讲透,再给一个能跑起来的最小实验。
2.1 单智能体算法在多智能体博弈中的两个死穴
第一个死穴叫非平稳性。单智能体强化学习的基本假设是环境转移概率固定,智能体只需要在这个固定环境里找最优策略。但多智能体对抗里,每个智能体的环境由“物理环境”和“其他智能体的策略”共同构成。对手的策略在训练中不断变化,意味着转移概率和奖励函数对当前智能体来说每一轮都不一样。DQN这种基于经验回放的算法,会从旧经验里学到已经被对手策略淘汰的映射关系,导致训练震荡或直接发散。
第二个死穴是信用分配。博弈对抗中一次胜负是多方共同作用的结果,单智能体算法无法区分“这次赢是因为我动作好,还是因为对手失误”。没有这个区分能力,梯度方向就是噪声,策略更新自然不稳定。MADDPG针对这两个死穴给出了一个工程上非常优雅的解法:训练时给每个智能体配一个能看到全局信息的 Critic,执行时仍然只依赖自己的 Actor。这一设计就是整个算法的灵魂。
2.2 MADDPG的“集中训练、分散执行”到底改了什么
MADDPG延续了DDPG的Actor-Critic结构,但改动集中在Critic上。原始DDPG里,Critic的输入是“自己的观测+自己的动作”;MADDPG里,第i个智能体的Critic输入变成“所有智能体的观测+所有智能体的动作”。这意味着每个智能体的Q函数都对全局状态建模,能准确评估自己在当前全局局面下的动作价值,非平稳性问题就被绕开了。
Actor这边保持局部输入:第i个智能体的Actor只接收自己的观测,输出自己的动作。训练结束后部署时,Critic整个丢掉,每个智能体只带自己的Actor和环境交互。这就是“集中训练、分散执行”的完整含义。
还有一个容易被忽略的设计:目标网络的软更新。MADDPG中的每个智能体都维护一份目标Actor和目标Critic,用tau做滑动平均。在多智能体场景里,硬拷贝(每隔固定步数直接复制参数)会让对手策略出现突变,训练曲线会突然崩坏,软更新能把这种跳变平滑掉,是MADDPG能稳定训练的重要前提。我一般看到源码里tau=0.01,就默认作者踩过这个坑。
2.3 跑通第一个MADDPG对抗实验:MPE环境与最小训练循环
最常见的MADDPG实验环境是OpenAI的Multi-Agent Particle Environments,里面有几个经典场景:simple_tag(追击对抗)、simple_adversary(欺骗对抗)、simple_spread(协作覆盖)。对毕业设计来说,simple_tag是最容易讲出故事的:红色追捕者要合力抓住黑色逃跑者,黑方有一个“加速”技能。这就是一个天然的博弈对抗问题。
环境加载代码如下,这段代码可以直接放进项目入口文件:
import numpy as np def make_env(env_name='simple_tag'): # MPE环境的标准加载方式:加载场景类 -> 创建世界 -> 包装成MultiAgentEnv from multiagent.environment import MultiAgentEnv import multiagent.scenarios as scenarios scenario = scenarios.load(env_name + '.py').Scenario() world = scenario.make_world() env = MultiAgentEnv( world, scenario.reset_world, scenario.reward, scenario.observation, info_callback=None ) return env这段代码背后有两点需要注意。第一,scenarios.load会自动在multiagent/scenarios目录下找对应的.py文件,所以环境名必须和文件名严格一致。第二,MultiAgentEnv的返回接口完全对齐Gym风格:reset()返回一个list(每个元素是某个智能体的观测),step()接收一个动作list并返回(obs_n, reward_n, done_n, info_n)。后面所有训练代码都是围绕这四个返回值展开的。
最小训练循环如下:
from buffer import ReplayBuffer env = make_env('simple_tag') n_agents = env.n obs_dim = env.observation_space[0].shape[0] act_dim = env.action_space[0].n # simple_tag是离散动作空间 buffer = ReplayBuffer(capacity=1000000) agents = [MADDPGAgent(obs_dim, act_dim, idx=i) for i in range(n_agents)] for episode in range(10000): obs_n = env.reset() episode_reward = np.zeros(n_agents) for step in range(25): # MPE每个episode固定25步 action_n = [] for i in range(n_agents): action_n.append(agents[i].select_action(obs_n[i], explore=True)) obs_next_n, reward_n, done_n, info_n = env.step(action_n) buffer.push(obs_n, action_n, reward_n, obs_next_n, done_n) obs_n = obs_next_n episode_reward += np.array(reward_n) # 每个episode结束后统一更新,比每步更新更稳定 for i in range(n_agents): agents[i].update(buffer) if episode % 500 == 0: print(f"Episode {episode}, reward: {episode_reward}")这里有几个参数是MPE环境特有的,不能乱改。max_steps=25是环境内部设定的推进步数,超过就强制结束;explore=True表示动作加噪声,MADDPG训练时必须有探索噪声,评估时设为False。ReplayBuffer的容量建议不低于100万,因为多智能体经验包含n_agents组观测和动作,样本多样性消耗很快。把这个循环跑起来,能看到reward在一个合理的范围内波动,就说明环境接口和算法骨架是通的。
3. 阅读MADDPG源码:网络定义、经验回放和梯度更新的代码落点
拿到一份MADDPG源码,不要从第一个文件顺序读到最后一个文件。它通常包含环境封装、网络定义、经验回放、训练器这几个模块,其中只有三个文件是核心。这一章我带你直接定位这三个文件里的关键函数,并给出可以直接复用的代码片段。
3.1 先按这四条主线读源码,五分钟定位关键函数
读MADDPG源码时,我习惯先梳理四条主线:策略网络、价值网络、目标网络更新、经验采样。在源码里依次找这四样东西:Actor类、Critic类、target_update函数、buffer.sample函数。找到之后,整个项目的骨架就清晰了。
一个常见的源码组织方式是:models.py里放Actor和Critic网络定义,maddpg.py里放单个智能体的训练逻辑(包含目标网络更新),buffer.py里放经验回放,train.py或main.py里放训练循环。如果你拿到的源码只有两个文件,那大概率是把网络定义和更新逻辑揉在了一起,读的时候按类去拆,不要按文件去拆。
Actor和Critic的定义有一个关键区别要记住:Actor的输入维度是“单个智能体的观测维度”,Critic的输入维度是“所有智能体的观测维度之和加上所有智能体的动作维度之和”。我在看源码时经常发现有人把Critic的输入错写成单个智能体的维度,这是导致多智能体训练完全无效的最隐蔽错误。定位到维度拼接那一行,一眼就能分辨。
3.2 Actor-Critic与目标网络:一段可以直接用的核心定义
大多数MADDPG实现基于PyTorch,网络结构并不复杂。下面这段代码是一个标准实现,可以直接替换项目里的网络文件:
import torch import torch.nn as nn import torch.nn.functional as F class Actor(nn.Module): def __init__(self, obs_dim, action_dim, hidden=64): super().__init__() self.fc1 = nn.Linear(obs_dim, hidden) self.fc2 = nn.Linear(hidden, hidden) self.mu = nn.Linear(hidden, action_dim) def forward(self, obs): x = F.relu(self.fc1(obs)) x = F.relu(self.fc2(x)) # 连续动作空间一般用tanh,把输出限制在[-1, 1] return torch.tanh(self.mu(x)) class Critic(nn.Module): def __init__(self, n_agents, obs_dim, act_dim, hidden=64): super().__init__() # 输入 = 全部智能体的观测 + 全部智能体的动作 total_obs_dim = n_agents * obs_dim total_act_dim = n_agents * act_dim self.fc1 = nn.Linear(total_obs_dim + total_act_dim, hidden) self.fc2 = nn.Linear(hidden, hidden) self.q = nn.Linear(hidden, 1) def forward(self, obs_all, act_all): x = torch.cat([obs_all, act_all], dim=-1) x = F.relu(self.fc1(x)) x = F.relu(self.fc2(x)) return self.q(x)注意Critic.forward里有一个torch.cat操作,这是MADDPG区别于DDPG的核心位置。训练时,obs_all是所有智能体观测拼接后的张量,形状是(batch_size, n_agents * obs_dim);act_all同理。Actor拿到的是单个智能体的obs,形状是(batch_size, obs_dim)。这个维度差异建议写在代码注释的第一行,后面调试会省很多时间。
再解释一个常见困惑:为什么Actor输出层用tanh而不用sigmoid?因为MPE的连续动作版本要求动作在[-1, 1]区间,tanh的输出刚好覆盖这个范围,且梯度在零点附近比较饱和。如果你的项目用的是离散动作环境(比如gym里的Discrete空间),输出层要换成LogSoftmax配合Gumbel-Softmax采样,或者直接输出one-hot向量。很多源码在离散动作上踩坑,就是因为直接用了tanh输出,导致动作分布完全错位。
3.3 Critic的全局拼接经验:从buffer到TD误差
经验回放里存的是每个智能体的观测、动作、奖励和下一观测。采样出来后,训练逻辑的代码落点如下:
def update(self, buffer, batch_size=1024, gamma=0.95, tau=0.01): obs_n, act_n, rew_n, obs_next_n, done_n = buffer.sample(batch_size) # 为每个智能体构造当前Critic输入 obs_all = torch.cat([obs_n[i] for i in range(self.n_agents)], dim=-1) act_all = torch.cat([act_n[i] for i in range(self.n_agents)], dim=-1) obs_next_all = torch.cat([obs_next_n[i] for i in range(self.n_agents)], dim=-1) # 目标动作由目标Actor计算,必须用no_grad包裹 with torch.no_grad(): act_next_all = torch.cat([ self.target_actors[i](obs_next_n[i]) for i in range(self.n_agents) ], dim=-1) q_target = self.target_critic(obs_next_all, act_next_all) y = rew_n[i] + gamma * (1 - done_n[i]) * q_target # 当前Critic输出与TD目标做MSE q_current = self.critic(obs_all, act_all) critic_loss = F.mse_loss(q_current, y.detach()) self.critic_optimizer.zero_grad() critic_loss.backward() self.critic_optimizer.step() # 更新Actor:用当前Critic的输出来评估Actor动作的好坏 actor_loss = -self.critic(obs_all, act_all).mean() self.actor_optimizer.zero_grad() actor_loss.backward() self.actor_optimizer.step() # 软更新目标网络 for target_param, param in zip(self.target_critic.parameters(), self.critic.parameters()): target_param.data.copy_(tau * param.data + (1 - tau) * target_param.data) for target_param, param in zip(self.target_actors.parameters(), self.actor.parameters()): target_param.data.copy_(tau * param.data + (1 - tau) * target_param.data)这段代码揭示了两个容易读错的地方。第一,目标动作必须从“目标Actor”里算,不能从当前Actor算,否则TD目标会和当前策略耦合,训练会变成追着自己的尾巴跑。第二,actor_loss取负号是因为我们需要最大化Q值,而优化器默认做梯度下降。有些源码里会写成-q_value.mean(),看起来反直觉,但加上梯度方向就对了。
关于y.detach():TD目标在计算时依赖目标网络,目标网络本身在缓慢变化,所以这个目标值不应该回传到Critic的梯度中。如果不加detach(),梯度会同时流过当前Critic和目标Critic,导致目标网络被“快速拖拽”,软更新的意义就没了。这是PyTorch实现MADDPG时最容易漏掉的一行。
4. MADDPG参数调优:先让Critic学得动,再让博弈策略长出来
MADDPG调参的优先级和单智能体算法不一样。在DDPG里,你可以先粗调Actor的学习率;在MADDPG里,第一优先级永远是Critic能否收敛。Critic是全局价值评估器,它学不动,Actor的梯度就是随机噪声,博弈策略永远学不出来。这一章给出一组经过验证的起点参数,以及一个训练调度的模板。
4.1 八个参数的推荐起点与调整顺序
下面是我在多智能体粒子环境里反复用过的起点参数表,适用于simple_tag、simple_adversary这类对抗场景。
| 参数 | 推荐起点 | 调整方向说明 |
|---|---|---|
| gamma | 0.95 | 对抗场景中未来收益不确定性高,gamma不宜过大;调大需谨慎 |
| lr_critic | 1e-3 | Critic必须比Actor学得快,否则Actor得不到有效梯度 |
| lr_actor | 1e-4 | Actor更新要“克制”,多智能体场景里大步更新容易破坏平衡 |
| tau | 0.01 | 目标网络软更新系数,调小到0.005可以让训练更平缓 |
| batch_size | 1024 | MPE小规模场景足够;显存够可以加到2048 |
| buffer_size | 1e6 | 多智能体经验多样性消耗快,容量建议保持百万级 |
| noise_std | 0.1 | 高斯探索噪声的初始标准差;训练后期可以衰减到0.01 |
| update_freq | 每episode一次 | 每个episode结束后更新所有智能体,比每步更新稳定 |
调参顺序有个经验法则:先调lr_critic和batch_size让Critic损失能稳定下降,再调noise_std让策略探索充分,最后才动gamma和tau。很多人一上来就调Actor学习率,结果发现Critic的Q值在乱跳,白白浪费时间。
4.2 一个能收敛的训练调度:先更新Critic,再更新Actor
训练调度决定了经验的利用率。建议一个episode结束后,从buffer里采样多次,依次更新所有智能体的Critic和Actor。下面这个模板可以直接嵌入训练循环:
def train_one_episode(agents, buffer, batch_size=1024, updates_per_episode=5): # 一个episode结束后,重复采样更新多次,提高样本利用率 for _ in range(updates_per_episode): for agent in agents: agent.update(buffer, batch_size)说明:updates_per_episode设为5是常见做法,含义是每个episode结束后用同一批经验做5次梯度更新。这个值不宜过大,比如设成20以上时,Critic会在同一小批数据上过拟合,导致Q值高估,后续训练直接发散。如果你观察到训练曲线在中间段突然垂直起飞然后消失,先检查这个值。
还有一个细节:更新顺序必须是“先整体更新Critic,再整体更新Actor”。上面代码里for agent in agents的循环结构保证了每个智能体都先算自己的Critic损失,再算Actor损失。如果把Actor更新提前,会让后续智能体的Critic输入中的动作分布产生偏移。
4.3 训练与评估节奏:怎么判断博弈两方“真的会打”了
训练过程中不能只看总奖励。对抗博弈里总奖励会随对手策略调整而波动,有时候一方赢了,另一方输了,总奖励看起来还是正的,但博弈质量并没有提升。我一般每隔500个episode做一次评估,评估时关闭所有探索噪声,让每个智能体纯用Actor输出动作。
评估指标分三层:第一层是平均回报,看整体趋势;第二层是各方独立回报,比如simple_tag里追捕者的回合均分和逃跑者的回合均分,这两个分数必须分开记录,合并会掩盖问题;第三层是行为观察,固定随机种子,录一段视频,看追捕者是否真的形成了“包抄”动作而不是傻追。行为观察这个步骤容易被忽略,但它才是博弈对抗算法“有没有学会对抗”的最直接证据。
如果你发现平均回报在涨、但录像是傻追,问题几乎一定出在奖励塑形上。MPE默认奖励比较稀疏,追捕者只有在撞到逃跑者时才有正奖励。这时需要在环境返回的reward上做额外的reward shaping,比如给“距离缩短”一个小的正奖励。修改环境奖励的代码要在环境工厂函数里做,不要改算法内部。
5. MADDPG训练避坑:六个最常见的翻车现场与排查清单
这一章直接把我在训练MADDPG过程中亲眼见过的问题列出来。每一条都按“现象、原因、解决”来写,你可以拿着这个清单去核对项目里的训练曲线。
5.1 现象:总回报从第一轮就开始掉,最后变成NaN
原因:Critic的Q值估计很快爆炸,梯度回传过程中数值溢出。常见诱因是lr_critic设置过大、Critic缺少梯度裁剪,或者batch_size太小导致TD目标方差过大。
解决:先给Critic更新加上梯度裁剪:
torch.nn.utils.clip_grad_norm_(self.critic.parameters(), max_norm=1.0)然后把lr_critic降到1e-4重新训练。如果NaN依然出现,检查buffer里是否存入了None或inf奖励;MPE环境在某些边界碰撞时可能返回异常值,需要在buffer.push前做一次数值检查。
5.2 现象:两个智能体学出了完全一样的动作
原因:多智能体共享Critic输入后,Actor的梯度方向会被拉得趋同,尤其是在初始种子相同、网络结构相同的情况下。代码里如果没有为每个智能体单独设置随机种子,或者Actor初始化权重一致,就会出现“角色分化失败”。
解决:为每个智能体单独创建网络时传入不同的随机种子:
torch.manual_seed(seed + agent_idx)同时在select_action里给不同智能体配不同的噪声强度。比如追捕者一方用noise_std=0.1,逃跑者用noise_std=0.3,两者探索策略分化,学出的动作模式也会多样化。这一招对simple_tag特别有效。
5.3 现象:损失一直在降,但博弈对抗里输给随机策略
原因:Critic陷入了自嗨。损失下降只说明TD误差在被压缩,不代表Q值估计准确。如果Actor一直拿到的是被高估的Q值,它会在错误的梯度方向上越走越远。高估的根源是Critic对“所有智能体动作”的联合评估出现了偏差,而经验池里某些罕见局面样本较少。
解决:最简单的是降低gamma,让未来收益的权重变小,减少Q值的传播误差。其次检查updates_per_episode,把它从5降到2或3,减少在同一批经验上的过拟合。如果还不行,就需要给每个智能体引入独立的Critic网络,而不是所有智能体共用一个Critic。很多源码实现里“所有智能体共用一个Critic”,这会明显削弱博弈对抗中的角色区分度。
5.4 现象:训练到5000轮还好好的,重启后从头再来
原因:目标网络和主网络的参数差距在训练中途被意外拉大。最常见的原因是某个episode的reward出现极端值(比如追捕者一次性抓到多个逃跑者),TD目标突变,目标网络被拖动,然后整个训练过程崩溃。软更新本应平滑这种突变,但tau=0.01在批量更新下每次拷贝的值仍然偏大。
解决:把tau降到0.005,同时检查reward计算中是否有异常累加逻辑。另一种做法是给目标网络更新加一个“更新间隔”:每更新100次主网络才软更新一次目标网络,这相当于手动把软更新改成带间隔的平滑更新。训练5000轮后的崩溃,绝大多数时候是目标网络更新太激进导致的。
5.5 现象:评估时同一个策略,换个对手成绩差一倍
原因:MADDPG训练时对手策略一直在进化,评估时刻选择的对手“快照”不同,成绩自然差异巨大。这是多智能体博弈评估的固有问题,不是bug。你需要固定评估协议:训练结束后,冻结所有智能体的目标网络,用冻结版本互相对抗,记录胜率,才能得到可复现的评估结果。
解决:在训练循环里定期保存检查点,评估时加载同一组检查点,用固定随机种子跑100个episode取平均回报。不要用“当前训练中的网络”去互相测试,因为策略还在漂移,测出来的数据没有可比性。如果你在论文里要放“胜率曲线”,这个固定检查点评估就是标准做法。
5.6 现象:使用离散动作环境时报错,维度对不上
原因:MPE原生环境大部分是离散动作空间,但大量MADDPG源码默认演员输出连续动作。把离散动作直接喂给输出层为tanh的Actor,得到的动作值和环境接口要求的整数动作完全对不上。
解决:两种常见做法。第一种是改环境,把离散动作空间映射为连续动作区间:每个离散动作对应一个固定的连续向量(比如“左”对应[-1, 0])。第二种是改Actor输出层,用Gumbel-Softmax对离散动作做可微采样。毕业设计场景下,第一种更简单,代码改动少,行为表现也直观。具体映射关系建议放在环境工厂函数里做,不要分散在各个智能体的动作选择逻辑里,否则排查时找不到入口。
6. 让MADDPG能答辩:消融实验、学习曲线与可视化录屏
毕业设计和工业项目不一样,你不仅要让算法“跑起来”,还要让评委在二十分钟内相信“它真的学到了博弈对抗能力”。这一章讲三件事:做哪些对比实验、怎么画图、怎么录视频。
6.1 至少做这三组对比实验
第一组:MADDPG对比独立DDPG。把每个智能体当成单智能体环境训练,其余智能体当作环境的一部分,测同一场景的胜率。这个对比直接验证“集中训练、分散执行”的价值。第二组:MADDPG对比随机策略。随机策略作为下限基准,证明你的算法在博弈中确实有正向收益。第三组:消融实验。去掉软更新(改成硬拷贝)或者去掉全局Critic(改成局部Critic),对比学习曲线。这三组实验做完,论文的实验章节基本就有了骨架。
6.2 用matplotlib画学习曲线,录像做成gif
学习曲线要画“平滑版本”,原始数据噪声太大,不建议直接上论文。用滑动平均处理:取过去50个episode的平均回报作为纵轴,横轴是episode序号。用matplotlib的plot函数加rolling窗口即可,颜色建议追捕者和逃跑者用对比色分开。视频可以用matplotlib.animation逐帧渲染,每帧调用一次环境的render(mode='rgb_array'),再拼接成动画。答辩时录像是一个很加分的展示方式,因为博弈对抗的动态过程用文字根本讲不清楚。
还有一个技巧:固定随机种子录制“从一开始的学习效果”和“训练结束后的效果”对比。同一个起点,前期策略和后期策略的对抗动作差异一目了然,比任何曲线都有说服力。
6.3 从MADDPG到更前沿的方向
如果你的毕设需要展示“扩展性”,可以在README里留一节说明如何从MADDPG扩展到MAPPO或QMIX。MAPPO把PPO的多智能体版本应用到同场景,QMIX则走值函数分解路线,适合协作场景。你不需要真正跑通这些算法,但写清楚它们的适用边界,再对比MADDPG的“连续动作、集中训练”特点,就能体现你对领域整体是理解的。
我自己的习惯是:每次调完一组参数,先把训练曲线和录像存档,命名里带日期和参数版本,答辩前最后一轮训练如果效果不如存档,就用旧结果。这个习惯帮我避免过很多次“临答辩发现新训练结果崩了”的情况。希望这个流程对你有用,希望帮到你。
本文还有配套的精品资源,点击获取