简介:这是一份基于Python与MADDPG(多智能体深度确定性策略梯度)实现的多智能体博弈对抗算法资源,适合希望入门强化学习或开展算法实验的学习者,也可作为毕业设计、课程设计、大作业或工程实训的起步代码。压缩包共14个文件,大小仅19KB,其中包含10个Python脚本,覆盖智能体交互环境、网络结构、经验回放与训练主循环等模块;另附Markdown说明文档、配置文件、依赖说明及测试文本。目前已有266人学习浏览,项目结构清晰,从环境构建到智能体训练均有对应代码,便于读者整体把握MADDPG原理。通过阅读源码,可以直观理解确定性策略梯度、目标网络与多智能体协同对抗的关键实现;在此基础上还可以继续扩展新的博弈场景或奖励函数,用于中期考核或项目立项非常合适。
1. 为什么我把毕设做成了 MADDPG 多智能体博弈对抗:一个不建议新手硬刚的选题
如果你搜到这篇笔记,大概率和我当初一样:被“多智能体博弈对抗”这八个字吸引,又不想只交一个跑通 MNIST 的普通深度学习毕设。MADDPG(Multi-Agent Deep Deterministic Policy Gradient)是当前多智能体强化学习里最常被拿来当课程设计、毕设题目的算法,它解决的是多个智能体在同一个环境里既合作又对抗时,环境对每个智能体来说都“不稳定”的难题。这套代码不是玩具,是真的能跑出“展开的抓捕者把移动目标围堵住”这类对抗效果的工程实现。适合正在做毕设、课程设计、大作业,或者想从单智能体强化学习往多智能体方向进阶的人。但我必须说实话:如果你连 DQN 都没跑通过,直接上手 MADDPG,前三天大概率是痛苦的。理解为什么难,比急着跑代码更重要,这也是这篇文章第一个想解决的问题。
2. 从 DDPG 到 MADDPG:集中训练与分散执行的博弈逻辑
2.1 单智能体 DDPG 的边界:连续动作空间与确定性策略
DDPG 是 MADDPG 的单智能体基石,不理解 DDPG 就直接看 MADDPG,容易在 actor-critic 的更新细节里翻车。DDPG 解决的是连续动作空间问题——比如机器人关节角度、车辆的油门大小,这些动作输出不是“左还是右”的离散选择,而是一个连续的实数。DDPG 用了 Actor-Critic 两套网络:Actor 根据状态输出确定性动作,Critic 根据状态加动作来评估这个动作有多好。
在训练时就出现了第一个关键机制:经验回放。强化学习要求样本具有独立性,但智能体连续交互产生的状态序列高度相关,一条轨迹里的样本如果按顺序学,模型容易在局部振荡甚至遗忘。经验回放就是把交互样本存进缓冲区,训练时随机抽样,把相关性打散。这套代码里的 buffer.py 干的就是这件事,它通常是一个固定容量的先进先出队列,存的内容是五元组:状态、动作、奖励、下一状态、是否终止。
# buffer.py 核心逻辑示意 class ReplayBuffer: def __init__(self, capacity): self.buffer = deque(maxlen=capacity) # 超出容量自动淘汰最旧样本 def push(self, state, action, reward, next_state, done): # 每个样本都是五元组,done 标记终止状态 self.buffer.append((state, action, reward, next_state, done)) def sample(self, batch_size): # 均匀随机抽样,打破时间相关性 batch = random.sample(self.buffer, batch_size) # 把 batch 按字段拆开,转成 torch.Tensor state = torch.FloatTensor([b[0] for b in batch]) action = torch.FloatTensor([b[1] for b in batch]) reward = torch.FloatTensor([b[2] for b in batch]) next_state = torch.FloatTensor([b[3] for b in batch]) done = torch.FloatTensor([b[4] for b in batch]) return state, action, reward, next_state, done代码里的deque(maxlen=capacity)是 Python 里最顺手的循环缓冲实现,容量满了之后新样本自动覆盖最旧样本。这里有个容易忽略的细节:done标志被转成了FloatTensor而不是LongTensor,因为它后面要参与 Q 值目标的计算,不是当分类标签用。抽样时用了均匀随机,这是最基础的做法,如果你的对抗环境里稀疏奖励问题特别严重,后面可以考虑优先经验回放,但在 MADDPG 多智能体场景下优先权计算成本会翻倍,不建议一开始就上。
2.2 MADDPG 到底改了什么:Critic 看到全部,Actor 只管自己
单智能体 DDPG 一旦放到多智能体环境里,立刻会遇到问题:每个智能体都在学习,相互之间都在改变策略,导致从任何一个智能体的视角看,环境状态转移概率一直在变,经验回放里的旧样本“过时”了。传统 DQN 面对这种情况基本无解。MADDPG 的核心改动是“集中训练,分散执行”。每个智能体依然有自己的 Actor 网络,但 Critic 网络在训练时会观察所有智能体的状态和动作。
这就是博弈对抗里最关键的设计:训练时大家信息全透明,执行时每个智能体只能根据自己看到的局部信息决策。这套代码里的 MADDPG.py 就是实现这个逻辑的,而 network.py 里的 Actor 和 Critic 网络结构,通常按“状态维度 + 动作维度”动态构建。一个典型的两层全连接网络配置是:隐藏层 256 个神经元,ReLU 激活,Critic 把“所有智能体的状态拼接 + 所有智能体的动作拼接”作为输入,输出一个 Q 值。
# network.py 典型结构示意 class Actor(nn.Module): def __init__(self, state_dim, action_dim, hidden=256): super(Actor, self).__init__() self.fc1 = nn.Linear(state_dim, hidden) self.fc2 = nn.Linear(hidden, hidden) self.fc3 = nn.Linear(hidden, action_dim) def forward(self, state): x = F.relu(self.fc1(state)) x = F.relu(self.fc2(x)) # 连续动作用 tanh 压缩到 [-1, 1] return torch.tanh(self.fc3(x)) class Critic(nn.Module): def __init__(self, state_dim, action_dim, hidden=256): super(Critic, self).__init__() # 注意:输入是 所有智能体状态拼接 + 所有智能体动作拼接 self.fc1 = nn.Linear(state_dim + action_dim, hidden) self.fc2 = nn.Linear(hidden, hidden) self.fc3 = nn.Linear(hidden, 1) def forward(self, state, action): x = torch.cat([state, action], dim=1) x = F.relu(self.fc1(x)) x = F.relu(self.fc2(x)) return self.fc3(x)Actor 输出层用tanh是为了把动作限制在 [-1,1] 区间,环境拿到之后通常会做一次线性映射,把 [-1,1] 映射到真正的动作范围。Critic 中间的torch.cat就是“集中训练”的落地——所有智能体的状态和动作拼成一个长向量。这句话听着简单,但它直接决定了 Critic 的输入维度是“智能体数量 ×(状态维度 + 动作维度)”,你后面自定义环境时,这个维度计算必须对得上,否则matmul维度不匹配的报错会让你怀疑人生。
2.3 经验回放里的“赛道陷阱”:为什么共享缓冲未必是好事
很多 MADDPG 项目把所有智能体的经验全部存进同一个 buffer,这个做法在合作任务上没大问题,但在博弈对抗场景里要小心。对抗双方的目标是相反的,追击者想让距离变小,逃跑者想让距离变大,这两类样本混在一起会让 Critic 的 Q 值预估产生巨大方差。
我看过一套代码的处理方式是每个智能体维护独立的 buffer,Actor 训练时只抽自己的样本。代价是内存占用翻倍,收益是收敛稳定性明显更好。本项目用的是共享大 buffer 还是独立 buffer,取决于 main.py 里的初始化代码,但我建议你在做对抗场景时优先尝试独立缓冲。经验回放容量在对抗任务里也是一个敏感参数,建议设置在 50 万到 100 万之间,过小会导致较早的策略样本被反复抽取,过大则会让网络难以收敛,训练时间也会线性增长。
2.4 rl_utils 与 mag.py 的分工:工具函数比算法更影响调试效率
rl_utils.py这类文件在多智能体项目里通常是“杂货铺”,比如计算折扣奖励、平滑画图、计算动作噪声标准差等。它本身不承担学习逻辑,但它的质量直接决定你的调试体验。mag.py在中文项目里大概率是“Multi-Agent Game”的缩写,通常负责场景与对抗逻辑的封装,比如定义追击者初始位置、逃跑者的移动策略、终止条件等。核心要点是:算法文件和游戏场景文件必须解耦,这样你换一个对抗场景时不需要动 MADDPG 的代码,只改环境接口。
3. 从 DDPG.py 到 MADDPG.py:核心模块的实现逻辑与参数含义
3.1 单智能体 DDPG 类为什么单独抽出来
这套代码里单独存在一个 DDPG.py,这不是多余的。MADDPG 的训练过程实际上是在循环调用每个智能体的 DDPG 更新逻辑,区别只是传给 Critic 的数据不同。把单智能体版本抽出来,可以让代码结构和论文中的公式一一对应,排错时也能先单智能体测试。一个标准的 DDPG 类需要包含四个网络:当前 Actor、目标 Actor、当前 Critic、目标 Critic。目标网络用软更新,也叫 Polyak 平均,公式是:target_param = tau * current_param + (1 - tau) * target_param。
class DDPG: def __init__(self, state_dim, action_dim, lr_actor=1e-4, lr_critic=1e-3, tau=0.01): self.actor = Actor(state_dim, action_dim) self.actor_target = Actor(state_dim, action_dim) self.critic = Critic(state_dim, action_dim) self.critic_target = Critic(state_dim, action_dim) # 硬同步一次:初始时让目标网络和当前网络完全一致 self.actor_target.load_state_dict(self.actor.state_dict()) self.critic_target.load_state_dict(self.critic.state_dict()) self.tau = tau def act(self, state, noise=0.0): # 推理时加动作噪声,增加探索 action = self.actor(state).cpu().data.numpy().flatten() return np.clip(action + noise, -1, 1) 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.0 - 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.0 - self.tau) * target_param.data)tau=0.01是常用配置,意思是每一步训练时目标网络只向当前网络靠近 1%,这个值太小会让学习缓慢,太大则会让训练不稳定,一般不建议超过 0.01。动作噪声用的是 OU 噪声还是高斯噪声,项目里通常在训练轮数增加后逐步衰减。我在自己的项目里更倾向高斯噪声,初始化标准差 0.2,每 1000 轮乘以 0.995 的衰减因子,相比 OU 噪声少两个超参数要调。
3.2 Actor 的更新逻辑:多智能体下的“单打独斗”
MADDPG 里每个 Actor 都只负责自己的动作,更新时也只需要自己的 Critic?不对,仔细看论文你会发现,MADDPG 里每个智能体实际上都拥有一个独立的 Critic 网络,第 i 个智能体的 Critic 以自己的状态动作为主,但会拼接上其他智能体的状态和动作。更新 Actor 时,梯度从 Critic 对动作的偏导反向传播。
# Actor 更新核心逻辑 def update_actor(self, agent_idx, all_states, all_actions): # 只取当前智能体的状态作为 Actor 输入 current_actor = self.agents[agent_idx].actor # 用当前策略预测动作,其他智能体的动作保持不变 predicted_actions = [] for idx, agent in enumerate(self.agents): if idx == agent_idx: predicted_actions.append(current_actor(all_states[:, idx, :])) else: predicted_actions.append(all_actions[:, idx, :].detach()) # 拼接所有动作,交给当前智能体的 Critic 计算 Q 值 combined_actions = torch.cat(predicted_actions, dim=1) q_value = self.agents[agent_idx].critic(all_states.view(-1, self.total_state_dim), combined_actions) # 目标是最大化 Q 值,所以损失取负 actor_loss = -q_value.mean()这段代码里最关键的是detach()——更新第 i 个 Actor 时,其他智能体的动作被当作常数,不进梯度计算。这是“分散执行”在训练阶段的体现:我不能通过改变对手的策略来提升自己的 Q 值,只能调整自己的 Actor。如果你忘了加detach(),训练时会出现梯度穿越智能体边界的问题,数值表现就是损失值到处乱跳,几个智能体相互干扰无法收敛。
3.3 Critic 的更新:真实 Q 值从哪里来
Critic 的训练目标是让预估 Q 值逼近真实回报,而真实回报用目标网络来计算。这里有个细节值得注意:目标 Q 值的计算用到了所有智能体的目标 Actor 输出的动作,然后再传给目标 Critic。这样做的原因是,MADDPG 在理论上假设每个智能体的行为都受全局状态影响,用目标网络计算能减少因策略更新带来的非平稳性。
# Critic 更新核心逻辑 def update_critic(self, agent_idx, batch_data, gamma=0.95): states, actions, rewards, next_states, dones = batch_data # 用目标 Actor 网络计算每个智能体的下一动作 next_actions = [] for idx, agent in enumerate(self.agents): next_actions.append(agent.actor_target(next_states[:, idx, :])) next_actions = torch.cat(next_actions, dim=1) # 用目标 Critic 计算下一状态的价值 next_q = self.agents[agent_idx].critic_target(next_states.view(-1, self.total_state_dim), next_actions) target_q = rewards[:, agent_idx].unsqueeze(1) + gamma * (1 - dones[:, agent_idx].unsqueeze(1)) * next_q current_q = self.agents[agent_idx].critic(states.view(-1, self.total_state_dim), actions) critic_loss = nn.MSELoss()(current_q, target_q.detach())gamma=0.95是一个值得商榷的参数,很多 MADDPG 项目用 0.95 而不是 DQN 论文里常用的 0.99,原因是多智能体环境的不确定性更高,太远的未来价值可信度低。你在做自己的对抗场景时,可以观察回合长度:如果单回合步数超过 200,建议把 gamma 提到 0.98 以上,否则远端奖励衰减太快,智能体只能学会短视策略。
4. 训练对抗博弈流程:main.py 到 test.py 的完整链路
4.1 训练主循环的运行机制
main.py 是把所有模块串起来的地方。运行之前,你需要先确认环境返回的状态结构。这套项目里的环境通常是自定的粒子世界类环境,每个智能体有位置和速度信息组合成状态向量。训练循环的典型结构是:初始化环境 → 重置 → 每步获取各智能体动作 → 环境返回全局状态、奖励、终止标志 → 存入各自 buffer → 每一定步数执行一次网络更新。
# 训练循环伪代码核心段 for episode in range(max_episodes): obs = env.reset() episode_reward = np.zeros(num_agents) for step in range(max_steps): # 每个智能体独立决策,动作噪声随训练进程衰减 actions = [] for i, agent in enumerate(agents): noise = max(0.1, 0.5 * (1 - episode / max_episodes)) actions.append(agent.act(torch.FloatTensor(obs[i]), noise)) # 环境返回下一步观察、奖励、终止标志 next_obs, rewards, done, _ = env.step(np.array(actions)) # 每个智能体的经验存自己的 buffer for i, agent in enumerate(agents): agent.buffer.push(obs[i], actions[i], rewards[i], next_obs[i], done) obs = next_obs episode_reward += rewards # 训练频率不宜过高 if step % 100 == 0 and step > 0: for i, agent in enumerate(agents): batch = agent.buffer.sample(batch_size=256) agent.update_critic(i, batch) agent.update_actor(i, batch)噪声衰减这里我用的是一次衰减函数而不是常数衰减,实际操作中你会发现这个衰减速度很影响探索效果。衰减太快会过早进入“吃老本”阶段,对抗场景里容易策略僵化;太慢则收敛缓慢。我一般习惯在前 1000 个 episode 保持噪声不低于 0.2,后面再按指数衰减到 0.05。训练频率每 100 步做一次更新是一种折中,每一步都更新会导致经验样本利用不充分且训练极慢。
4.2 main.py 里的超参数怎么调才不玄学
MADDPG 的超参数是有规律可循的,只是不同对抗场景的最佳点差异很大。以下是一份我多次实验后觉得最靠谱的参数表,可以作为起点:
| 参数 | 建议初始值 | 调整方向 |
|---|---|---|
| 经验回放容量 | 500000 | 样本多样性不足时调大,内存不够时调小 |
| 批量大小 | 256 | 训练抖动大时调大到 512,收敛慢时调小到 128 |
| 学习率 Actor | 1e-4 | 动作空间复杂时调低到 5e-5 |
| 学习率 Critic | 1e-3 | Critic 发散时优先调低 |
| gamma | 0.95 | 回合长超过 200 步时调到 0.98 |
| tau 软更新系数 | 0.01 | 训练不稳定时调到 0.005 |
| 隐藏层神经元 | 256 | 状态维度高时增加到 512 |
| 噪声初始标准差 | 0.5 | 探索不足时调大,不稳定时调小 |
这里最大的坑是 actor 和 critic 学习率的比例。很多人喜欢把两个都设为 1e-3,结果 Critic 学得快、Actor 学得慢,Q 值在快速变化中 Actor 的梯度方向变得不可靠。我的习惯是让 Critic 学习率比 Actor 大 10 倍左右,这个比例在多数连续控制任务上比“两者相同”更稳。
4.3 用 test_env.py 确认环境接口:90% 的运行报错都是环境维度问题
拿到项目第一步不是直接跑 main.py,而是先跑 test_env.py。这个文件的作用是模拟运行一个随机策略、打印环境的观测空间、动作空间、奖励范围以及一个完整轨迹的长度。它能帮你确认环境返回的数据类型和维度是不是符合算法预期。常见问题包括:动作数组是 numpy 需要转 tensor、观测是多智能体共享一份还是各一份、done 是布尔值还是数值。这些一分钟能定位的问题,直接跑训练可能要半小时才发现维度报错。
# test_env.py 的环境检查逻辑核心 def check_env(env): obs = env.reset() print(f"观测形状: {[o.shape for o in obs]}") print(f"智能体数量: {len(obs)}") act_space = env.action_space print(f"动作空间: {act_space}") total_reward = np.zeros(len(obs)) for step in range(100): actions = [env.action_space.sample() for _ in obs] next_obs, reward, done, info = env.step(actions) total_reward += reward if done: print(f"第 {step} 步终止, 累计奖励: {total_reward}") break环境检查这一步看起来不起眼,但它能帮你提前确认所有智能体的观测是否具有相同维度。如果有的智能体观测维度不同,MADDPG 的全局 Critic 就无法简单拼接输入,需要进行不对称处理。这属于高级改动,了解即可,一般对抗场景里所有智能体的观测维度都相同。
4.4 从 ceshi.py 与 test.py 看懂模型验证的方法
test.py 和 ceshi.py 是验证模型效果的文件,功能相似但侧重不同。test.py 通常负责加载训练好的模型权重,跑固定脚本回合,打印胜率或捕获率等评估指标;ceshi.py 可能是更加细致的可视化测试版本,比如把每个智能体的位置轨迹保存下来逐帧观看。模型加载时要特别注意网络结构权重是否匹配,如果你在训练时改过隐藏层大小或拼接方式,测试时忘记同步修改network.py,load_state_dict会直接抛出键值不匹配的错误。遇到这种情况,用torch.load打印权重字典的键名和形状,再比对当前网络的参数,基本一眼就能定位问题。
5. MADDPG 实战避坑:训练不收敛与维度爆炸的几条血泪记录
5.1 训练初期奖励乱跳大概率是噪声没有衰减机制
现象:前 500 个回合奖励在正负之间剧烈振荡,幅度完全没有收敛趋势。原因:动作噪声如果一直保持恒定大标准差,智能体的探索过于激进,收集的经验里“好样本”比例太低。解决:检查代码里是否有noise * (1 - episode / max_episodes)这类线性衰减计算,把初始噪声 0.5 每回合乘一个略小于 1 的系数,保证后期以开发为主。
5.2 Critic 损失不降反升,检查目标网络是否同步更新
现象:Critic 的 MSE 损失一直涨,从 1e-3 涨到 10 以上,训练越久越离谱。原因:目标网络同步出现了问题,目标 Q 值和当前 Q 值不是同分布的,每次更新都在追一个漂移的目标。解决:确认soft_update是否被调用,以及初始硬同步是否执行。我用torch.manual_seed(42)固定随机种子之后,这个问题通常能更稳定地重现和排查。
5.3 所有智能体最终学成一样的策略,丧失博弈多样性
现象:合作类任务里智能体集体协作没问题,但对抗任务中两个追击者的动作高度重合,互相卡位,导致围堵效率极低。原因:共享 Critic 参数或 Actor 初始化相同,导致对称性破坏不足。解决:给每个智能体的 Actor 网络采用不同的随机种子初始化,且在动作噪声上使用不同的随机序列。如果框架支持,可以在网络初始化时用正交初始化配合不同的增益参数。
5.4 训练速度越来越慢,问题出在不做梯度裁剪
现象:训练到中后期,单回合耗时明显增加,GPU 占用率却没有提高,CPU 反而拉满。原因:网络参数开始出现梯度爆炸,PyTorch 自动微分图越来越复杂,反向传播耗时增长。解决:在 Critic 更新之后加梯度裁剪nn.utils.clip_grad_norm_(agent.critic.parameters(), max_norm=0.5),这几乎是 MADDPG 训练的标配,不裁剪的代码在复杂对抗场景下基本必崩。
5.5matmul维度不匹配错误,永远优先查状态拼接维度
现象:报错信息指向某个Linear层的matmul,说 shape 对不上。原因:Critic 输入维度是“智能体数量 ×(状态维度 + 动作维度)”,实际传入的拼接向量少了或多了某个分量。解决:在torch.cat之前分别打印各段张量的 shape,对比fc1.in_features的期望值。我遇到过最隐蔽的翻车案例是view(-1, total_state_dim)里total_state_dim忘了乘智能体数量,结果每个样本的维度错位了半个,报错却指向完全无关的mean层,误导性极强。
6. 把 MADDPG 改造成你自己的对抗场景:环境接口与评估收尾
这一章写给那些不满足于跑通原项目,想把它迁移到自定义博弈场景的读者。MADDPG 算法本身不限制应用领域,限制只在环境接口。首先,你的自定义环境必须实现几个标准方法:reset()返回初始观测列表,每个智能体一个;step(actions)接收一个动作列表并返回下一观测列表、奖励列表、终止标志和额外信息。这是 OpenAI Gym 的多智能体扩展约定。改造时先写一个哑环境,用固定随机策略跑通 test_env.py,确认接口完全匹配后,再把真实的物理模型或博弈规则填充进去。
评估环节是很多毕设最容易忽视的。只输出训练曲线不足以证明算法有效,合理的评估方式是固定随机种子、固定初始位置,用训练好的模型连续测试 100 个回合,统计平均捕获时间或追击成功率,同时选一个基线策略做对比。基线可以是随机策略、单智能体 DDPG 分别控制的独立版本,或者预设的追踪算法。差距一拉出来,论文的图表就有支撑了。我自己的经验是,模型保存至少每 1000 回合存一次,用torch.save(agent.actor.state_dict(), f"actor_{episode}.pth"),中途训练崩了也有后悔药。从那以后我每次跑对抗实验都强制走一遍这个流程:先跑 test_env 确认接口,再固定随机种子短训 200 回合验证损失在下降,最后才放开正式训练。这套流程帮我少熬了很多个通宵,希望帮到你。
本文还有配套的精品资源,点击获取