简介:多智能体深度强化学习中的MADDPG算法PyTorch复现工程,以rar压缩包形式提供,面向研究多智能体协作与竞争场景的开发者、学生及算法爱好者。工程基于论文《Multi-Agent Actor-Critic for Mixed Cooperative-Competitive Environments》以及Multi-Agent Particle Environment环境,完整涵盖从环境交互到模型训练的实现链路,解压后运行main.py即可启动。压缩包共99个文件,体积仅1.8MB,内部以32个py源码文件作为核心模块(包含agent、model、maddpg、replay_buffer等),配合22个pyc编译文件和42个pkl数据文件,另附说明文档与环境配置脚本,目录结构清晰便于二次开发。已有882人学习下载,适合作为快速搭建MADDPG基准实验的参考。通过研读这些代码,可以深入理解Actor-Critic框架在混合合作-竞争环境下的具体实现,把握经验回放、策略更新与多智能体协作中的关键处理思路,为后续改进算法或对比实验节省大量环境配置时间。
1. 为什么跑通 MADDPG 比改通 DDPG 更值得花时间
MADDPG 是少见的论文公式和工程代码能逐行对上的多智能体深度强化学习算法。单智能体里很顺手的 DDPG,换成两个以上互相影响的智能体就会遇到非平稳问题:其他智能体策略一变,经验回放里的旧样本就"过期"了。Lowe 等人在 2017 年提出中心化训练、去中心化执行来绕开这个坑,而这个 can_work_MADDPG 包正是那篇论文的 PyTorch 复现,内置论文同款 Multi-Agent Particle Environment,直接运行 simple_tag 下的 main.py 就能看到三条红色追踪者围堵蓝色目标的训练过程。适合对"critic 为什么要吃全场信息"有疑问的读者,也适合要自建多智能体训练框架的工程师。下面从 Actor-Critic 架构开始拆。
2. MADDPG 的 Actor-Critic 架构:中心化训练与去中心化执行怎么写进代码
2.1 先想清楚:DDPG 直接搬到多智能体为什么不行
单智能体 DDPG 的经验回放里,(s, a, r, s') 服从固定环境动力学。多智能体环境下,第 i 个智能体只观察得到自己的 obs,但 next_obs 和 reward 同时受其他智能体动作影响。其他 agent 每更新一次策略,agent i 面临的转移分布就变一次,回放缓冲区里的大量旧样本就此失真。这是理论层面 MADDPG 要解决的核心矛盾,也是多智能体深度强化学习和单智能体强化学习在工程表现上差距最大的地方。
MADDPG 的答案是为每个智能体配一个中心化 critic:训练时 critic 输入是所有智能体的观测拼接 x = (o_1, ..., o_N) 和所有动作拼接 a = (a_1, ..., a_N)。因为 x 和 a 已经显式包含了"其他智能体决策的结果",对当前 agent 而言,critic 面对的 Q 函数训练过程是平稳的,可以照搬 DDPG 的时序差分更新。执行阶段 actor 仍然只看自己的 o_i,部署时不依赖任何通信。代价是 critic 输入维度随智能体数量线性增长,simple_tag 只有 4 个智能体还好,超过 10 个之后一层 MLP 就会明显吃力,这也是 MADDPG 论文实验普遍用小规模环境的原因。
注意:中心化 critic 只在训练阶段存在,保存模型做推理时只导出 actor,这一点决定了整个代码结构。
2.2 actor_critic.py 里的网络:actor 输出层是关键分支
这个仓库把每个智能体的 actor 和 critic 封装成独立 MLP,参数不共享,这是和"所有智能体共享一套策略"的朴素做法最大的区别。常见实现长这样:
import torch.nn as nn import torch.nn.functional as F class Actor(nn.Module): def __init__(self, obs_dim, action_dim, hidden_dim=64, discrete=True): super().__init__() self.fc1 = nn.Linear(obs_dim, hidden_dim) self.fc2 = nn.Linear(hidden_dim, hidden_dim) self.out = nn.Linear(hidden_dim, action_dim) self.discrete = discrete def forward(self, obs): x = F.relu(self.fc1(obs)) x = F.relu(self.fc2(x)) logits = self.out(x) if self.discrete: return F.softmax(logits, dim=-1) # 离散动作:输出概率向量 return torch.tanh(logits) # 连续动作:限制在 [-1, 1] class Critic(nn.Module): def __init__(self, total_obs_dim, total_act_dim, hidden_dim=64): super().__init__() self.fc1 = nn.Linear(total_obs_dim + total_act_dim, hidden_dim) self.fc2 = nn.Linear(hidden_dim, hidden_dim) self.out = nn.Linear(hidden_dim, 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.out(x)actor 的离散分支用 softmax 输出一热概率,是为了和粒子环境的 Discrete(5) 动作空间对齐;critic 直接 concat 全场 obs 和全场 action。这里最容易踩的坑是拼接顺序:obs_all 里 agent 的顺序必须和环境返回的 agent 顺序一致,act_all 也必须一致。我见过不少改坏的 fork,buffer 里取了转置,或者 reward 按 agent 排了序但 action 没排,结果 critic 学到的关联全是错位的,表现就是训练曲线一条直线。hidden_dim 取 64 还是 128,在这个场景下都能收敛,前者快一点,后者更稳。
2.3 目标网络、软更新与损失函数:maddpg.py 的核心
每个智能体除了当前 actor/critic,还各有一份 target actor/critic。目标网络参数不是定期硬拷贝,而是每步软更新:
def soft_update(target, source, tau): for tp, sp in zip(target.parameters(), source.parameters()): tp.data.copy_(tau * sp.data + (1.0 - tau) * tp.data)tau 默认 0.01,含义是每轮把当前网络参数向目标网络"挪"1%。tau 太小,目标网络更新过慢,actor 会抱着一个过时的 Q 函数优化;tau 太大,目标网络就失去了稳定作用。多智能体场景下这个值比单智能体更敏感,因为每轮要交替更新 N 个智能体,任何一个 target 滞后都会污染其他人计算 TD 目标。
单个智能体的更新逻辑可以拆成两段看。critic 是回归任务,用目标网络算 TD 目标,再对当前 Q 做 MSE:
target_next_actions = [target_actor[j](next_obs[j]) for j in range(n_agents)] target_q = target_critic(obs_all, target_next_actions) # 拼接后输入 y = rewards + gamma * target_q.detach() * (1.0 - done) # detach 阻断梯度 current_q = critic(obs_all, act_all) critic_loss = F.mse_loss(current_q, y)actor 走确定性策略梯度,目标是最小化负 Q 值:
current_actions = [actor[j](obs[j]) for j in range(n_agents)] actor_loss = -critic(obs_all, current_actions).mean()actor_loss 里的动作必须由当前 actor 重新前向得到,不能直接拿 buffer 里存的旧动作,否则梯度不会经过 actor 参数回传。critic 的 y 值必须 detach,否则目标网络也会被重复反向传播。这两个位置是这个仓库里改动最多、也最容易改错的地方。注意 actor_loss 里 critic 吃到的是 softmax 概率向量,而 buffer 里存的是 argmax 后的一热向量,两处动作分布存在轻微偏移,这是原版实现一直存在的已知近似,跑 simple_tag 不影响收敛,但换成高精度任务时要有这个意识。
| 参数 | 作用 | 常见默认值 | 调参建议 |
|---|---|---|---|
| tau | 目标网络软更新系数 | 0.01 | 发散时降到 0.005 |
| gamma | 折扣因子 | 0.95 | 单局 25 步,0.95 够用 |
| lr | actor/critic 学习率 | 0.01 | PyTorch 下建议从 1e-3 试起 |
| hidden_dim | MLP 隐层宽度 | 64 | 场景复杂可提到 128 |
| batch_size | 单次更新的样本条数 | 1024 | 太小则 Q 值抖动明显 |
| buffer_size | 回放缓冲容量 | 1000000 | 内存紧张可降到 100000 |
关于 lr 多说一句:论文实现里 0.01 跑得动,但同样的数值搬进这个 PyTorch 仓库后,不同 torch 版本的梯度尺度会有差异,我一般先把 lr 压到 1e-3 观察前 5000 局,没问题再放开。gamma 在这里不需要拉满,simple_tag 一局只有 25 步,0.95 和 0.99 的曲线差异很小。
3. 从 main.py 到 runner.py:把 simple_tag 训练链路完整走一遍
3.1 先看清文件分工再动手跑
这个仓库的目录结构分三层:common/ 放通用组件,arguments.py 管命令行参数,replay_buffer.py 管经验回放,utils.py 里是 one-hot 转换这类工具函数,model.py 和 agent.py 负责网络定义与单智能体训练入口;maddpg/ 是算法本体,actor_critic.py 组装网络,maddpg.py 实现多智能体更新;simple_tag/ 是实验目录,make_env.py 构造 Multi-Agent Particle Environment,runner.py 跑训练循环,main.py 是入口。仓库里的 cpython-37.pyc 说明原作者是在 Python 3.7 下打包的,建议用 3.7 或 3.8 配 torch 1.8 左右版本,能少踩一半依赖坑。
main.py 的职责非常薄,只做四件事:
def main(args): set_seed(args.seed) # 1. 固定随机种子 env = make_env(args.scenario) # 2. 构造粒子环境 maddpg = MADDPG(env, args) # 3. 实例化 N 个 actor-critic buffer = ReplayBuffer(args.buffer_size, args.seed) runner(env, maddpg, buffer, args) # 4. 进入训练循环顺序有讲究:先固定 seed,再建环境,最后建算法。粒子环境内部大量使用 numpy 随机数,seed 不固定的话,哪怕算法完全一致,两次实验曲线的方差也会大到没法比较。换场景时也只需要改 make_env 内部的 scenario 名,main.py 不用动。
3.2 参数核对表:哪些默认值不能随便动
以下参数按常见默认值整理,运行前逐项过一遍:
| 参数 | 常见默认值 | 说明 |
|---|---|---|
| --scenario | simple_tag | 对应 multiagent/scenarios/simple_tag.py |
| --max-episodes | 60000 | 论文实验的总 episode 数 |
| --max-episode-len | 25 | 单局步数,超时强制终止 |
| --num-adversaries | 1 | 蓝色目标的数量 |
| --batch-size | 1024 | 每次更新的 transition 条数 |
| --updates-per-episode | 1 | 每局结束后更新轮数 |
| --save-interval | 1000 | 每多少局存一次模型 |
max-episode-len 常被忽略,但它直接决定 episode reward 的统计口径:长度越大,绝对数值越大,不同长度下的曲线不能直接对比。把 25 改成 50,收敛速度会慢将近一倍,因为后半段追逐进入僵持,有效信息密度下降。num-adversaries 在 simple_tag 里是 1,改成 2 后整个博弈结构就变了,需要同步修改场景文件里的 agent 配置,不是只改参数就行。
3.3 replay_buffer 以整组 transition 为单位存储
多智能体场景下,一条经验不是"某个智能体的 (o, a, r, o')",而是所有智能体在同一时刻的完整状态转移。这是 MADDPG 能正确训练的数据基础:
class ReplayBuffer: def __init__(self, buffer_size, seed): self.buffer = deque(maxlen=buffer_size) self.rng = random.Random(seed) def push(self, obs, act, rew, next_obs, done): # 五个参数都是长度 n_agents 的列表,打包存一条 self.buffer.append((obs, act, rew, next_obs, done)) def sample(self, batch_size): batch = self.rng.sample(self.buffer, batch_size) n_agents = len(batch[0][0]) obs = [np.stack([b[0][i] for b in batch]) for i in range(n_agents)] act = [np.stack([b[1][i] for b in batch]) for i in range(n_agents)] # rew / next_obs / done 同样方式拆分 return obs, act, rew, next_obs, donesample 返回的每个元素都是按 agent 维度拆好的 batch,这样任意一条经验里,所有 agent 的动作都来自同一时间步,critic 拼接时才不会出现"这个 obs 配那个 action"的错位。很多改坏的多智能体回放代码,问题正出在把每个 agent 单独存 buffer、再各自采样,破坏了时间步对齐,训练出来的策略相互矛盾。
3.4 runner.py 的训练循环:收集、存储、更新三件事
runner 的主循环结构很直接:
for ep in range(args.max_episodes): obs = env.reset() ep_reward = np.zeros(n_agents) for step in range(args.max_episode_len): actions = maddpg.select_actions(obs, explore=True) next_obs, rewards, dones, _ = env.step(actions) buffer.push(obs, actions, rewards, next_obs, dones) obs = next_obs ep_reward += rewards # 整局结束后再统一更新 for _ in range(args.updates_per_episode): sample = buffer.sample(args.batch_size) for i in range(n_agents): maddpg.update(sample, i) if ep % args.save_interval == 0: maddpg.save_model(...)更新时机选在整局结束后而不是每个 step 后,是因为一局只有 25 步,相邻步样本相关性强,每步都更新会让 mini-batch 梯度方差偏大;攒完一局再利用,样本多样性更好。updates_per_episode 为 1 时,4 个智能体各更新一次,60000 局就是 24 万次梯度更新,单卡上通常要跑数小时,先用 5000 局小规模验证链路再放长跑是稳妥做法。
select_actions 里 explore=True 时,离散分支的常见实现是在 actor 输出概率上叠加高斯噪声再 argmax,等价于随机扰动策略。注意这个噪声在仓库里一般不自动衰减,跑长实验时我一般会手动按 episode 数线性调低,否则后期探索噪声会干扰已经收敛的策略。
4. simple_tag 场景拆解:合作-竞争混合博弈的奖励与收敛判断
4.1 观测空间:相对位置与相对速度的拼接规则
simple_tag 的标准配置是 3 个红色 good agents、1 个蓝色 adversary、2 个灰色 landmark 障碍。每个 agent 的观测是把视野内其他 agent 和所有 landmark 的相对位置、相对速度拼接成一维向量,scenario.py 里典型实现:
def observation(self, agent, world): rel_pos = [o.state.p_pos - agent.state.p_pos for o in world.agents if o is not agent] rel_vel = [o.state.p_vel - agent.state.p_vel for o in world.agents if o is not agent] landmark_pos = [l.state.p_pos - agent.state.p_pos for l in world.landmarks] return np.concatenate(rel_pos + rel_vel + landmark_pos)注意所有 agent 的观测维度并不相同:红色看到的是另外 2 红 + 1 蓝,蓝色看到的是 3 红,两者向量长度不一样。因此 buffer 里 obs 要按 agent 分别存,网络输入维度也要按各自 obs 维度单独构造,不能直接把四份观测堆成一个矩阵。想确认维度,直接跑一次 env.reset() 后打印每个 agent 的 obs.shape 最可靠,比对着场景代码数维度省事。MPE 自带的 multiagent/bin/interactive.py 还能手动控制智能体在场景里移动,用来核对"我改的 reward 到底在什么条件下触发"非常直观。
4.2 离散动作空间:Discrete(5) 与一热向量之间的转换
粒子环境默认动作空间是 Discrete(5),对应原地不动和上下左右四个方向。MADDPG 的 critic 需要对 action 求梯度,所以 utils.py 里有一个 one-hot 转换:
def onehot(actions, num_actions): onehot_actions = np.zeros((actions.shape[0], num_actions)) onehot_actions[np.arange(actions.shape[0]), actions] = 1.0 return onehot_actionsactor 输出 5 维 softmax 概率,explore 时叠加噪声后 argmax 得到离散动作索引存入 buffer;update 时再把索引还原成一热向量送进 critic。这里有个工程细节:critic 训练时看到的 action 并不全是严格的一热向量,actor loss 路径用的是当前 actor 输出的概率向量,两者存在轻微分布偏移,但训练和推理走同一通道,这个近似在 simple_tag 上不会引起明显偏差。
4.3 混合奖励结构:为什么三打一也能算"合作-竞争"
simple_tag 的奖励主体来自碰撞事件,红色之间共享同一套奖励,构成 cooperative 关系;蓝色目标和红色的目标正好相反,构成 competitive 关系,这正是论文标题里 mixed cooperative-competitive 的来源。由于碰撞是稀疏事件,随机探索很难命中,所以场景里通常还带距离 shaping 项,鼓励红色持续靠近蓝色。这个 shaping 的权重直接决定训练前期曲线形态:权重太大,红色会变成"只会追不会围";权重太小,前期几乎拿不到奖励信号,actor 梯度长期为零。
| 观察项 | 打印方式 | 健康表现 |
|---|---|---|
| 红色方 episode reward | runner 里逐局累加 | 前 5000 局震荡,之后趋势向上 |
| 碰撞频率 | 场景里统计 is_collision 次数 | 从 0 逐步变成正数 |
| 当前 Q 均值 | update 里打印 current_q.mean() | 与 episode reward 同趋势 |
| actor 输出方差 | 打印一段时间 action 分布 | 不应过早坍缩成固定动作 |
多智能体环境下单看 episode reward 不够,因为红色和蓝色的奖励是零和的,双方曲线互相拉扯,整体均值可能是平的。我一般把两方奖励分开画,红色向上同时蓝色向下,才是健康博弈该有的形态。
4.4 reward 不涨时的排查顺序
按下面顺序排查,能覆盖大部分"能跑但学不动"的情况:
- 先看 buffer 里 done 的比例,若大量 episode 提前终止,说明最大步数或环境参数让博弈过早见分晓,先加 max-episode-len 再看曲线。
- 打印 Q 值与 TD target 的差值,差值长期不缩小说明 critic 拟合能力不够,优先加 hidden_dim 或降 lr,而不是改网络层数。
- 检查 actor 输出分布是否退化,若动作集中到某个固定值,说明探索噪声不足或奖励把策略推到局部最优。
这四步里最容易忽略的是第二步。多智能体场景下 critic 同时要拟合 N 个智能体的行为,拟合压力比单智能体大得多,Q 值和 target 差值的平方长期不降,先怀疑 critic 欠拟合,再怀疑超参数。
5. 把 MADDPG 搬进自己的环境:换场景、验 critic、做推理
5.1 最小改造:先换官方场景,再写自己的 Scenario
不想动代码就先换场景。命令行把 --scenario 改成 simple_spread,就能切到 3 个智能体的纯合作覆盖任务,没有对抗者,训练曲线通常更平滑,适合当"仓库本身没坏"的冒烟测试。改完发现 simple_tag 能收敛而 simple_spread 不收敛,问题多半出在你的观测维度或奖励尺度上,而不是算法实现。
自建场景只需要在 multiagent/scenarios 下新增一个 .py 文件,实现四个方法:
class Scenario: def make_world(self): ... # 定义 agent、landmark、碰撞参数 def reset_world(self, world): ... # 随机初始化位置 def reward(self, agent, world): ... # 返回标量奖励 def observation(self, agent, world): ... # 返回该 agent 的观测向量仿照 simple_tag.py 复制一份改就行。智能体数量变化后,MADDPG 的 agent 数量是从 env 里自动读的,不需要硬编码,但 actor 输入维度要按新 obs 维度重建,这一步最容易漏。
5.2 验证 critic 是否真的在学:打印 Q 值间隔
critic 没学好的话 actor 的梯度方向也是废的,所以验证顺序永远是 critic 在前。在 update 里加一段:
with torch.no_grad(): gap = (target_q - current_q).mean().item() if ep % 100 == 0: print(f"ep {ep} | Q {current_q.mean().item():.2f} | " f"target Q {target_q.mean().item():.2f} | TD gap {gap:.2f}")健康状态是 Q 值随 episode 增长,TD gap 在零附近小幅波动。Q 一直贴零,说明 critic 没收到有效梯度,优先检查动作拼接维度和 one-hot 转换;TD gap 长期为正且不下降,说明 reward 被系统性低估,先调 tau 和 lr,再考虑奖励缩放。
5.3 训练完成后的推理导出
保存和加载与单智能体一致:存 maddpg 的 state_dict,加载后把 select_actions 的 explore 置为 False。唯一要注意的是离散分支,softmax 输出要 argmax 还原成 0 到 4 的整数动作索引,直接把概率向量喂给 env.step 会报错,因为粒子环境期望的是整数动作。
本文还有配套的精品资源,点击获取