简介:PyMARL是一套面向深度多智能体强化学习研究的开源框架,基于PyTorch实现,并以《星际争霸II》作为实验环境,适合从事MARL算法复现、对比实验与论文验证的研究生及算法工程师使用。框架内置多种经典多智能体算法的实现,配套SMAC基准任务,可用于协作策略训练与性能评测。资源包共52个文件,以32个Python源码为主体,涵盖学习器、控制器、运行器、环境封装与模块组件等核心逻辑;另有11个YAML配置、4个Shell脚本及Dockerfile、requirements.txt、README、LICENSE等,便于快速搭建运行环境与调整实验参数,压缩包约47KB。需注意SC2版本差异会影响结果可比性,SMAC论文结果基于SC2.4.6.2.69232而非SC2.4.10。目前已有1763人学习下载,适合希望系统理解多智能体强化学习工程结构、复现基线算法并开展对照实验的读者参考。
1. 从一份能跑通的多智能体训练脚本说起
如果你正在找一份能直接跑起来的多智能体强化学习代码,而不是又一篇只讲公式的论文,那 pymarl 这个 Python 框架值得花时间拆一遍。它把多智能体环境封装、策略网络、经验回放、并行采样和集中式训练这几块拼成了一个完整闭环,跑一个 StarCraft II 微操场景或者简单的粒子环境,改几行配置就能换算法。适合两类人:一类是想复现 MADDPG、QMIX、VDN 这类经典多智能体算法的同学,另一类是手里有自定义环境、想套一个成熟训练循环的开发者。我第一次拿到它的时候,最直观的感受是——终于不用自己从零写 replay buffer 和参数共享逻辑了,但它的配置层和注册机制也埋了不少需要动手改的地方。
2. 项目定位与技术选型:它到底解决多智能体训练的哪些麻烦
2.1 多智能体训练的三个核心痛点
单智能体 RL 的代码结构放到多智能体场景里会立刻崩掉,原因不复杂。第一,每个 agent 的观测维度可能不同,但你又希望共享一部分网络参数;第二,环境返回的是联合动作和联合奖励,经验回放里存的是全局轨迹而不是单条 transition;第三,训练时需要拿到全局状态做集中式 critic,执行时又只能看局部观测。pymarl 的设计基本就是围绕这三件事展开的。
它把 agent 的观测、动作、奖励、全局状态拆成独立的 batch 字段,用统一的EpisodeBatch和ReplayBuffer管理。策略侧用AgentController做一层抽象,具体算法(QMIX、COMA、MADDPG 等)只负责实现forward和select_action。这种分层的好处是,你换算法时不用动采样循环,换环境时不用动网络结构。
常见做法是每个算法单独写一套训练脚本,但 pymarl 选择了注册表模式。所有算法、环境、runner 都通过@REGISTRY.register挂进去,配置里写个字符串就能切换。这个设计在二次开发时特别省事,代价是你得先理解它的注册流程,否则加一个新算法会找不到入口。
2.2 技术栈与依赖关系
核心依赖就几个:PyTorch 做网络和梯度更新,Sacred 做实验配置和日志管理,PyYAML 读配置文件,TensorBoard 看训练曲线。环境侧如果是 StarCraft II,需要额外装smac和游戏本体;如果是粒子环境或者自定义 Gym 接口,只要符合它的MultiAgentEnv协议就行。
| 模块 | 依赖 | 作用 |
|---|---|---|
| 算法实现 | PyTorch | QMIX、COMA、MADDPG 等 |
| 配置管理 | Sacred + PyYAML | 实验参数、命令行覆盖 |
| 日志 | TensorBoard | 训练曲线、损失、胜率 |
| 环境 | SMAC / Gym | 多智能体交互接口 |
| 并行采样 | 多进程 | 同时跑多个 episode |
这里有个血泪经验:Sacred 的版本兼容性比较玄学,建议按项目requirements.txt里的版本装,不要自己升到最新。我见过有人用新版 Sacred 跑出来配置项读不到,排查了半天以为是算法问题。
2.3 目录结构速览
项目根目录下大致是这几个文件夹:
src/config/:YAML 配置文件,按算法和环境分目录src/controllers/:AgentController,负责动作选择和策略管理src/learners/:各算法的损失计算和网络更新src/modules/:网络组件,如 RNN、hypernet、attentionsrc/runners/:训练循环,episode 采样和并行调度src/envs/:环境封装,StarCraft II 和自定义环境src/components/:经验回放、epsilon 调度、动作选择器
这个结构的好处是职责清晰,坏处是文件跳转比较多。建议先看runners/episode_runner.py和learners/q_learner.py,这两个文件能把主流程串起来。
3. 跑通第一个实验:从环境准备到训练启动
3.1 环境安装与依赖检查
先确认 Python 版本,建议 3.8 到 3.10,太新的版本有些依赖轮子还没跟上。创建虚拟环境后按顺序装:
# 创建虚拟环境 python -m venv pymarl_env source pymarl_env/bin/activate # 安装 PyTorch,按自己的 CUDA 版本选 pip install torch==1.13.1 torchvision --index-url https://download.pytorch.org/whl/cu117 # 安装项目依赖 pip install -r requirements.txt # 如果跑 SMAC 环境,还需要装 smac pip install git+https://github.com/oxwhirl/smac.git装完之后用python -c "import torch; print(torch.cuda.is_available())"确认 GPU 能用。如果返回 False,先别急着跑训练,CPU 上跑多智能体实验会慢到怀疑人生。
提示:SMAC 需要 StarCraft II 游戏本体,装完后要设置
SC2PATH环境变量指向游戏目录,否则环境初始化会直接报错。
3.2 配置文件怎么读、怎么改
pymarl 的配置分两层:default.yaml放通用参数,算法和环境各自的 YAML 做覆盖。启动时用--config指定算法,用--env-config指定环境。比如跑 QMIX 在 2s3z 地图上:
python src/main.py --config=qmix --env-config=sc2 with env_args.map_name=2s3z关键参数我列几个经常要动的:
| 参数 | 含义 | 常用值 |
|---|---|---|
t_max | 总训练步数 | 2050000 |
batch_size | 每次更新采样数 | 32 |
buffer_size | 经验回放容量 | 5000 |
lr | 学习率 | 0.0005 |
gamma | 折扣因子 | 0.99 |
epsilon_start | 初始探索率 | 1.0 |
epsilon_finish | 最终探索率 | 0.05 |
改配置有两种方式:直接改 YAML 文件,或者命令行用with覆盖。后者更适合做消融实验,不用来回改文件。
3.3 启动训练与日志观察
启动后终端会打印每个 timestep 的损失和 epsilon,同时 TensorBoard 日志写到results/目录下。用tensorboard --logdir results就能看曲线。重点盯三个指标:loss是否震荡发散、epsilon是否按预期衰减、win_rate或者return是否在涨。
如果跑了几万步 return 还是平的,先检查环境是否正常返回奖励,再看 replay buffer 里有没有存进去数据。我一般会在episode_runner.py里临时加一行打印 batch 的 shape,确认obs、actions、rewards的维度对得上。
# 在 episode_runner.py 的 run 方法里临时加调试 batch = self.new_batch() # ... 采样逻辑 ... print("obs shape:", batch["obs"].shape) print("actions shape:", batch["actions"].shape) print("rewards shape:", batch["reward"].shape)这段代码不是项目自带的,是我调试时习惯加的。多智能体最容易翻车的地方就是维度对不上,提前打出来比看报错快得多。
4. 核心模块拆解:一个 episode 从采样到更新的完整链路
4.1 EpisodeRunner 的采样流程
EpisodeRunner负责跑完一个完整 episode,把每一步的观测、动作、奖励、掩码存进EpisodeBatch。核心逻辑在run方法里,简化后大致是这样:
def run(self, test_mode=False): # 重置环境,拿到初始观测 self.batch = self.new_batch() self.env.reset() pre_transition_data = { "state": [self.env.get_state()], "avail_actions": [self.env.get_avail_actions()], "obs": [self.env.get_obs()] } self.batch.update(pre_transition_data, ts=0) for t in range(self.episode_limit): # 所有 agent 选动作 actions, _ = self.mac.select_actions( self.batch, t_ep=t, t_env=self.t_env, test_mode=test_mode ) # 环境执行一步 reward, terminated, info = self.env.step(actions[0]) # 存 transition post_transition_data = { "actions": actions, "reward": [(reward,)], "terminated": [(terminated,)], } self.batch.update(post_transition_data, ts=t) # 存下一步观测 pre_transition_data = { "state": [self.env.get_state()], "avail_actions": [self.env.get_avail_actions()], "obs": [self.env.get_obs()] } self.batch.update(pre_transition_data, ts=t + 1) if terminated: break return self.batch这里有几个关键点。select_actions返回的是所有 agent 的动作列表,actions[0]是因为环境接口要求传一个 batch 维度的动作。reward存成 tuple 是为了和 batch 的维度对齐。terminated单独存是为了后面算 bootstrap 时判断是否截断。
参数说明:t_ep是 episode 内的时间步,t_env是全局训练步数,test_mode控制是否用 epsilon-greedy。这三个参数在调试时很有用,比如你想看某个时间步的动作分布,可以直接在select_actions里打印。
4.2 AgentController 与动作选择
AgentController是策略层的统一入口,它持有所有 agent 的网络,负责前向计算和动作采样。核心方法select_actions大致长这样:
def select_actions(self, ep_batch, t_ep, t_env, bs=slice(None), test_mode=False): # 只取当前时间步的观测 agent_inputs = self._build_inputs(ep_batch, t_ep) # 前向计算 Q 值或策略分布 agent_outs, self.hidden_states = self.agent(agent_inputs, self.hidden_states) # 根据算法类型选择动作 if self.agent_output_type == "q": # Q 值算法用 epsilon-greedy avail_actions = ep_batch["avail_actions"][:, t_ep] chosen_actions = self.action_selector.select_action( agent_outs[bs], avail_actions[bs], t_env, test_mode=test_mode ) else: # 策略梯度算法直接采样 chosen_actions = self.action_selector.select_action( agent_outs[bs], None, t_env, test_mode=test_mode ) return chosen_actions_build_inputs会把 agent 的观测、上一个动作、agent id 拼成网络输入。hidden_states是 RNN 的隐状态,多智能体里通常每个 agent 独立维护一个。action_selector根据agent_output_type决定用 epsilon-greedy 还是直接采样,这个设计让 Q-learning 和 policy gradient 共用同一套采样循环。
我一般会在_build_inputs里加一个断言,检查观测维度是否和网络输入维度一致。多智能体环境里 agent 数量变化时,这里最容易出维度错误。
4.3 Learner 的损失计算与参数更新
以 QMIX 为例,QMIXLearner的train方法负责从 replay buffer 采样并计算 TD loss:
def train(self, batch, t_env): # 从 batch 里取数据 rewards = batch["reward"][:, :-1] actions = batch["actions"][:, :-1] terminated = batch["terminated"][:, :-1].float() mask = batch["filled"][:, :-1].float() mask[:, 1:] = mask[:, 1:] * (1 - terminated[:, :-1]) avail_actions = batch["avail_actions"] # 前向计算每个 agent 的 Q 值 mac_out = [] self.mac.init_hidden(batch.batch_size) for t in range(batch.max_seq_length): agent_outs = self.mac.forward(batch, t=t) mac_out.append(agent_outs) mac_out = torch.stack(mac_out, dim=1) # 取实际执行动作的 Q 值 chosen_action_qvals = torch.gather( mac_out[:, :-1], dim=3, index=actions ).squeeze(3) # 计算 target Q 值 target_mac_out = [] self.target_mac.init_hidden(batch.batch_size) for t in range(batch.max_seq_length): target_agent_outs = self.target_mac.forward(batch, t=t) target_mac_out.append(target_agent_outs) target_mac_out = torch.stack(target_mac_out[1:], dim=1) # 用 avail_actions 屏蔽不可用动作 target_mac_out[avail_actions[:, 1:] == 0] = -9999999 target_max_qvals = target_mac_out.max(dim=3)[0] # 用 mixer 把个体 Q 值合成联合 Q 值 chosen_action_qvals = self.mixer(chosen_action_qvals, batch["state"][:, :-1]) target_max_qvals = self.target_mixer(target_max_qvals, batch["state"][:, :-1]) # 计算 TD target 和 loss targets = rewards + self.args.gamma * (1 - terminated) * target_max_qvals td_error = (chosen_action_qvals - targets.detach()) mask = mask.expand_as(td_error) masked_td_error = td_error * mask loss = (masked_td_error ** 2).sum() / mask.sum() # 反向传播更新 self.optimiser.zero_grad() loss.backward() grad_norm = torch.nn.utils.clip_grad_norm_(self.params, self.args.grad_norm_clip) self.optimiser.step()这段代码有几个容易看漏的地方。mask的处理是为了忽略 padding 和终止后的步数,不然后面算 loss 会被无效数据带偏。target_mac_out[avail_actions[:, 1:] == 0] = -9999999是把不可用动作的 Q 值压到极低,保证 max 操作不会选到非法动作。grad_norm_clip是梯度裁剪,多智能体训练里梯度爆炸很常见,这个参数别设太大。
参数说明:gamma是折扣因子,grad_norm_clip一般设 10,target_update_interval控制 target 网络同步频率。这些都在 YAML 里,改的时候注意别把 target 更新设得太频繁,否则训练不稳定。
4.4 ReplayBuffer 的存储与采样
ReplayBuffer继承自EpisodeBatch,按 episode 存数据,采样时随机抽一批 episode。核心方法是sample:
def sample(self, batch_size): # 随机选 episode 索引 indices = np.random.choice(self.episodes_in_buffer, batch_size, replace=False) # 从每个 episode 里取数据 return self._get_episode_data(indices)这里有个细节:pymarl 的 buffer 是按 episode 存的,不是按 transition 存。这样做的好处是能保留完整的轨迹信息,方便 RNN 做序列建模;坏处是采样粒度粗,episode 长度差异大时 batch 内 padding 会比较多。如果环境 episode 长度波动很大,可以考虑改成按 transition 采样,但那样就得自己处理 RNN 的隐状态初始化。
5. 避坑与排查:多智能体训练里那些让人抓狂的问题
5.1 训练不收敛,return 一直平
现象:跑了几十万步,TensorBoard 上的 return 曲线几乎是一条直线,loss 也不降。
原因通常有三个:一是学习率太大,多智能体里梯度方差比单智能体大得多,lr=0.001可能就炸了;二是 epsilon 衰减太快,agent 还没探索到有效策略就开始贪心;三是 reward 尺度问题,有些环境奖励是稀疏的,需要做 reward shaping。
解决:先把lr降到0.0005甚至0.0001,把epsilon_finish设到0.1左右,观察 return 有没有变化。如果还是平的,检查环境返回的 reward 是不是全零,可以在env.step后打印一下。
5.2 维度不匹配报错
现象:RuntimeError: Expected tensor to have size X but got Y,通常发生在select_actions或train里。
原因:agent 数量、观测维度、动作空间大小三者中有一个和配置对不上。多智能体环境里 agent 数量可能动态变化,但网络初始化时是按固定数量建的。
解决:在_build_inputs里加断言,确认obs.shape和self.args.obs_shape一致。如果是动态 agent 数量,需要在环境封装层做 padding 或者用 attention 机制处理变长输入。
5.3 GPU 显存不够
现象:CUDA out of memory,尤其是跑 SMAC 大地图时。
原因:batch_size和buffer_size设太大,或者 RNN 序列太长导致中间激活占显存。
解决:先把batch_size减半,再把buffer_size降到 2000 左右。如果还不够,把hidden_dim从 64 降到 32。实在不行就上梯度累积,用时间换显存。
5.4 Sacred 配置读不到
现象:命令行传了with env_args.map_name=xxx,但程序里读到的还是默认值。
原因:Sacred 的with语法对嵌套参数的处理有时会出问题,尤其是 YAML 里用了env_args这种字典结构。
解决:改成在 YAML 里直接写死,或者用--env-config指定一个单独的配置文件。我一般会为每个实验建一个独立的 YAML,避免命令行覆盖出玄学问题。
5.5 多进程采样卡死
现象:训练启动后卡在Waiting for workers...,CPU 占用很低。
原因:runner用了多进程但环境不支持 fork,或者SC2PATH在子进程里没继承到。
解决:把--n_workers设成 1 先跑通,确认单进程没问题再开多进程。如果是 SMAC,确保SC2PATH在~/.bashrc里 export 了,子进程能读到。
6. 二次开发:加一个新算法和自定义环境的实操思路
6.1 注册一个新算法
假设你想加一个简化版的 VDN,步骤不复杂。先在src/learners/下建vdn_learner.py,继承QLearner,重写mixer部分:
from src.learners.q_learner import QLearner from src.modules.mixers.vdn import VDNMixer class VDNLearner(QLearner): def __init__(self, mac, scheme, logger, args): super().__init__(mac, scheme, logger, args) # 用 VDN mixer 替换 QMIX mixer self.mixer = VDNMixer(args) self.target_mixer = VDNMixer(args)然后在src/learners/__init__.py里注册:
from src.learners.vdn_learner import VDNLearner REGISTRY.register("vdn", VDNLearner)最后在src/config/algs/下加一个vdn.yaml,把learner字段设成"vdn"。这样启动时--config=vdn就能跑起来。关键点是 mixer 的输入输出维度要和 QMIX 保持一致,否则train里的chosen_action_qvals会报错。
6.2 接入自定义环境
自定义环境需要实现MultiAgentEnv协议,核心是这几个方法:
class MyEnv: def reset(self): # 返回初始观测 return obs, state def step(self, actions): # 执行动作,返回 reward, terminated, info return reward, terminated, info def get_obs(self): # 返回所有 agent 的观测列表 return obs_list def get_state(self): # 返回全局状态 return global_state def get_avail_actions(self): # 返回可用动作掩码 return avail_actions def get_env_info(self): # 返回环境元信息 return { "n_agents": self.n_agents, "episode_limit": self.episode_limit, "state_shape": self.state_shape, "obs_shape": self.obs_shape, "n_actions": self.n_actions, "action_shape": self.action_shape }然后在src/envs/__init__.py里注册,配置里env字段填你的环境名。这里最容易翻车的是get_env_info返回的维度和实际数据对不上,建议先写个单元测试,手动调一遍reset和step,确认所有 shape 都对。
6.3 调参和验证的一个习惯
我一般会先用小地图、短 episode 跑 10 万步,确认 loss 在降、return 在涨,再放大到完整配置。验证时不要只看最终胜率,中间过程的 epsilon 曲线和 TD loss 曲线更能说明问题。如果 loss 震荡但 return 在涨,可能是探索还在起作用,不用急着调;如果 loss 和 return 一起崩,那基本是学习率或者 target 更新频率的问题。
从那以后我每次加新算法或者接新环境,都强制先跑一个t_max=50000的冒烟测试,确认整条链路通了再上完整训练。希望帮到你。
本文还有配套的精品资源,点击获取