☰
PyMARL多智能体强化学习框架:QMIX训练、配置管理与SMAC避坑指南
2026/10/24 8:26:31 网站建设 项目流程

简介:这份资源是面向深度多智能体强化学习研究者的 PyMARL 框架源码包,基于 PyTorch 实现,并以《星际争霸II》与 SMAC 基准环境作为实验平台,适合已具备强化学习基础、希望复现或扩展多智能体算法(如 QMIX、COMA、VDN 等)的高校学生与算法工程师使用,可省去从零搭建训练与评测流程的成本。压缩包共 52 个文件、约 47KB,以 32 个 py 源码文件为主体,覆盖 components、learners、controllers、runners、modules、envs 等核心目录,配合 11 个 yaml 配置定义算法与实验参数,另有 sh 脚本、Dockerfile、requirements.txt、README 与 LICENSE 等环境与说明文件,便于快速构建容器并安装 SC2 与 SMAC。该资源已有 1765 人学习,价值在于提供可直接运行的训练入口与交互式运行脚本,读者可据此理解算法注册、经验回放与多智能体环境封装的组织方式,并对照配置调参复现论文结果;作者也提示需注意 SC2 版本差异,SMAC 结果基于 SC2.4.6.2.69232 而非 SC2.4.10,便于规避版本不一致导致的性能偏差。

1. PyMARL 值不值得下:一套把多智能体实验固定在配置里的 Python 框架

多智能体强化学习最容易让人放弃的,不是公式,而是你写完 QMIX 的 mixer,还要再补一遍 VDN 的、再补一遍 IQL 的,最后发现三份代码的 batch 维度还对不上。PyMARL 这个 Python 多智能体强化学习框架解决的就是这件事:环境、采样、学习器、混合网络被拆成四层,算法差异缩小到几份 yaml 加一个 learner。跑 QMIX、VDN、IQL、COMA 这类值分解方法,换的往往只是--config后面那个名字。它适合两类人:一类是要在 SMAC 星际微操地图上复现基线的新手,照着命令就能出曲线;另一类是准备往框架里加入自己算法的熟手,看清目录分层就知道该动哪个文件、哪些地方不要碰。

2. 环境与配置系统:sacred 的命名配置怎么把实验固定下来

PyMARL 有一套设计:训练脚本里几乎没有硬编码超参,所有能影响结果的数字都从 sacred 的配置树里来。这套设计决定你能不能在两周后复现自己昨天的实验。配置一旦不限于一套 yaml,再配unique_token生成的结果目录,实验才真正可追溯。下面按“分工、安装依赖、配置组合”三步展开。

2.1 四个抽象:环境只负责交互,其余各管一层

先把目录和职责对应起来,后面看任何报错都能定位到层。这个框架把“智能体怎么选动作”和“联合 Q 怎么聚合”拆开,所以换算法不需要动环境,换地图不需要动 learner。

目录职责什么时候需要改动
src/envs/环境封装,返回 obs、reward、avail_actions、terminated换地图或换环境
src/runners/采样,把多步交互拼成 EpisodeBatch改采样节奏、改并行
src/learners/从 buffer 取数据、算 TD、反传换损失、换算法
src/modules/agents/单智能体 Q 网络(默认 RNN)改网络结构
src/modules/mixers/联合 Q 的聚合方式新增值分解形式
src/controllers/mac,调度 agent 与动作选择器改探索策略
src/config/sacred 的 yaml 配置树加超参、加实验组合

关键在mac这一层。它持有 agent 网络和 action selector,训练时被 learner 调用前向,测试时被 runner 调用选动作,隐藏状态由它自己维护。所以你改探索策略时只动action_selector,不用碰 learner 的损失函数。这种划分对新手友好:出错时先看报错栈来自哪个目录,基本就能判断是配置问题、环境问题还是网络维度问题。

EpisodeBatch 的维度约定要提前记住,否则后面看任何切片都会晕:(episode, timestep, batch, agent, feature)。采样阶段 batch 维固定为 1,进 buffer 训练时才会按batch_size拼起来。

2.2 依赖安装:Python、PyTorch 和星际客户端三件事

这套代码出生在 PyTorch 1.x 早期,常见问题是用最新的 torch 直接装,跑到一半发现某个旧 API 已经被改名。我的习惯是锁一个偏老的 1.x 版本,配套 Python 3.7 或 3.8,别用 3.11 去试,省下的时间比省下的磁盘值钱。

# 1) 独立环境,别和主环境混,sacred 对依赖版本比较挑 conda create -n pymarl python=3.7 -y conda activate pymarl # 2) 老版本 torch,和仓库里的写法对齐 pip install torch==1.6.0 torchvision==0.7.0 # 3) 框架核心依赖 pip install sacred==0.8.2 numpy==1.19.5 tensorboard_logger tensorboardX # 4) 星际采样依赖,protobuf 必须压住,否则 import 就炸 pip install pysc2==3.0.0 pip install "protobuf==3.19.6"

上面四步里,真正容易出问题的是第四步。pysc2依赖s2clientprotocol,后者对 protobuf 生成的 Python 描述符有版本要求;装成 4.x 之后import阶段就会抛Descriptors cannot be created directly,看着像代码错误,其实是依赖版本问题。压到 3.19.x 一般就安静了。

环境变量和地图目录也要一次性配好。星际客户端解压后有一层StarCraftII目录,SC2PATH要指到那一层,不是它的父目录;地图放进StarCraftII/Maps/,SMAC 用的 32×32 微操地图都在这里被加载。

# 指到包含 StarCraftII 可执行文件的那一层,多一层少一层都会启动失败 export SC2PATH=/opt/StarCraftII # 确认地图能被找到 ls $SC2PATH/Maps | head

提示:无显示器的 Linux 机器上把env_args.visualize保持 False,不需要开图形界面;本机调试想看得见单位移动时才改成 True,线上训练不要开。

2.3 sacred 的配置树:--config与--env-config怎么合并

sacred在这里承担两件事:一是把default.yaml、algs/*.yaml、envs/*.yaml三份配置按顺序合并,二是把最终配置和结果目录绑定,方便回查。--config=qmix选算法层,--env-config=sc2选环境层,命令行with里的键值对最后覆盖。

# src/config/algs/qmix.yaml 的关键片段,注释是我按自己的习惯补的 action_selector: "epsilon_greedy" epsilon_start: 1.0 epsilon_finish: 0.05 epsilon_anneal_time: 50000 # 退火步数,一般给总步数的 1/40 左右 runner: "episode" # 一条 episode 一采,避免早期 padding 太多 mac: "basic_mac" agent: "rnn" agent_output_type: "q" learner: "q_learner" mixer: "qmix" mixing_embed_dim: 32 # 混合网络隐层宽度,8m 这种小图 32 够用 hypernet_layers: 2 hypernet_embed: 64
# src/config/envs/sc2.yaml env: "sc2" env_args: map_name: "8m" # 8 个陆战队员对 8 个,SMAC 最小地图之一 step_mul: 8 # 智能体每步对应 8 个游戏帧,调大能明显提速 difficulty: 7 # 内置 AI 难度,非 7 时对手行为会变 game_steps_per_episode: 0 # 0 表示用地图默认时限 visualize: False

命令行覆盖时,sacred 对类型是严格的:yaml 里是整数的键,你在with里写成batch_size="32"会直接报类型错误;而往配置里塞一个原本不存在的键,需要加+前缀,写成with +my_flag=1。这两条规则不了解,会浪费很多时间调试“为什么我改的参数没生效”。我的做法是只在命令行覆盖少数会频繁调整的量(总步数、测试间隔、batch 大小),结构性参数一律落进 yaml,这样一次实验的完整配置能从结果目录里的config.json原样读回来。

3. 跑通第一个 QMIX 训练:从启动命令到日志曲线的完整链路

配置理顺之后,第一次跑通只需要一条命令,但这条命令背后有五层参数在起作用。这一章把启动参数逐个拆开,再把主循环的执行顺序走一遍,最后说清楚该盯哪几条曲线判断训练是不是在正常进行。

3.1 启动命令:每个参数都在替你决定实验结论

python src/main.py --config=qmix --env-config=sc2 with \ env_args.map_name=8m \ env_args.difficulty=7 \ t_max=2050000 \ test_interval=10000 \ test_nepisode=32 \ batch_size=32 \ buffer_size=5000 \ gamma=0.99 \ save_model=True \ save_model_interval=500000

逐个说明,这些都是会直接改变结论的量:

  • t_max:环境交互总步数。8m 这种小图,QMIX 一般几十万步就能到高胜率,给到两百万步是留给大图(比如 27m_vs_30m)的余量。给小了会误判成“算法不收敛”。
  • test_interval:每隔多少环境步跑一次测试。设太小会让测试开销吃掉训练时间,设太大曲线点太稀看不出趋势,一万步是个折中。
  • test_nepisode:每轮测试跑多少条 episode 取均值。8m 随机性小,32 条够;换成 3s_vs_5z 这种以少打多的地图,我给到 100 条以上,否则胜率曲线会上下波动。
  • batch_size/buffer_size:前者是每次梯度更新的样本条数,后者是 episode 级缓冲的容量。注意 buffer 存的是整条 episode,不是单步 transition,所以 5000 条在小图上占的内存已经不小。
  • save_model_interval:多久保存一次网络。断点续训靠它,中途需要看中间态也靠它。

注意:test_interval和runner_log_interval是两个独立的计时器,前者触发测试,后者只写训练日志。曾经把两者配成同一个值,以为是同一个东西,结果日志曲线看着很密,测试点稀疏得没法判断趋势。

3.2 主循环:采样、入池、学习、测试的固定顺序

跑起来之后,main.py里的循环结构大致是这样,我加了注释,函数名以你手上的版本为准,逻辑是一致的:

# 简化骨架,只保留影响结果的四个分支 while t_env < args.t_max: # 1) 采一整条 episode,batch 维为 1 episode_sample = runner.generate_episode(episode_time) # 2) 截掉没填满的 timestep,不让 padding 参与 TD 计算 max_t_filled = episode_sample.max_t_filled() episode_sample = episode_sample[:, :max_t_filled] buffer.insert_episode_batch(episode_sample) # 3) 缓冲够一个 batch 就开始学,每采一条学一次是这个框架的默认节奏 if buffer.can_sample(args.batch_size): batch = buffer.sample(args.batch_size) learner.train(batch, t_env, episode) # 4) 日志与测试分开计时 if t_env - last_log_T > args.runner_log_interval: last_log_T = t_env logger.log_stat("episode", episode, t_env) if t_env - last_test_T > args.test_interval: last_test_T = t_env test_ret = run_episode(parallel_env, test_mode=True) # 关键:测试模式关探索 logger.log_stat("test_return_mean", test_ret["return_mean"], t_env)

有三点值得说清楚。第一,t_env是按环境步累加的,不是 episode 数,所有 interval 类参数都以此为准。第二,test_mode=True会让动作选择器走贪心分支、不采样噪声;漏掉这个参数,测试曲线会带着探索噪声,看起来永远不收敛。第三,max_t_filled()那一步不能省,padding 位置如果参与 loss,target 会被无意义的值污染,表现出来就是 td_error 一直降不下去。

3.3 指标:只看 reward 会被坑,要成对看

日志里值得看的就那么几个,但要成对看才有意义:

指标含义异常信号
test_return_mean测试平均回报长时间不动先查测试模式与探索率
test_battle_won_mean测试胜率比回报更直观,是主要结论指标
td_error_absTD 误差绝对值长期不降说明 target 或 mask 写错了
q_taken_mean实际动作的 Q 均值单调暴涨通常是 reward 尺度问题
target_meanTD target 均值与q_taken_mean拉开距离后不再收敛就要注意
epsilon当前探索率长时间停在初值说明退火步数配得过大

最实用的判断组合是q_taken_mean和target_mean。两者同步缓慢上升、td_error_abs逐步下降,说明在学习;如果 q 值一路飙升而胜率不动,八成是 reward 缩放或 gamma 配得太乐观,模型在“自娱自乐”地抬高估值。另一种常见情形是胜率在前十万步一直为 0,别急着换算法,先确认epsilon_anneal_time是否比t_max还长——探索率没退完,策略当然看起来是随机的。

4. 接入自己的算法:注册表、learner 三件套与动作空间改动边界

框架的价值在能改动。很多人卡在“我知道要写个新 learner,但写完不知道在哪注册”,或者改完观测编码发现老模型加载不了。这一章按注册机制、learner 契约、动作与观测边界三块讲。

4.1 注册机制:字符串到类的映射,改三处就够

框架里所有可选组件都是靠字符串在 yaml 里指定的,mixer: "qmix"这行的背后是一张注册表。核心实现很短:

# src/components/__init__.py,类体做了精简,结构一致 class Registry: def __init__(self): self._registry = {} def register(self, name): def _register(cls): self._registry[name] = cls # 名字 -> 类,yaml 里写的就是这个 name return cls return _register def __getitem__(self, name): return self._registry[name]

新增一个算法,通常要动四个地方,顺序不能乱:

# 1) 实现类,以 learner 为例,放在 src/learners/my_learner.py # 2) 在 src/learners/__init__.py 里登记 REGISTRY = {} from .q_learner import QLearner REGISTRY["q_learner"] = QLearner from .my_learner import MyLearner REGISTRY["my_learner"] = MyLearner # 3) 在 src/config/algs/my_algo.yaml 里把 learner 指向新名字 # learner: "my_learner" # 4) 启动时 --config=my_algo

agent 和 mixer 的登记方式一致,分别在src/modules/agents/__init__.py和src/modules/mixers/__init__.py。真正容易漏的是第 2 步:类写好了但没 import,运行时报KeyError: 'my_learner',而错误栈指向配置解析,看起来像 yaml 拼错了。

4.2 learner 的契约:三件事必须自己实现

一个 learner 只要满足接口约定,内部怎么写都行。约定的是三件事:把 EpisodeBatch 拆成智能体级张量、算 TD target 并反传、存读模型。下面这段是我做新算法时常用的骨架,重点在维度注释:

import torch as th # EpisodeBatch 的五维约定:(episode, timestep, batch, agent, feature) # 采样阶段 batch=1,进 learner 后按 batch_size 重排 rewards = batch["reward"][:, :-1] # (B, T-1, 1, N, 1) terminated = batch["terminated"][:, :-1].float() filled = batch["filled"][:, :-1].float() # episode 结束后的 bootstrap 必须截断,否则会把下一局的开局当成当前局未来 mask = filled.clone() mask[:, 1:] = filled[:, 1:] * (1 - terminated[:, :-1]) gamma = 0.99 q_tot = mixer_out # 联合 Q,(B, T, 1, 1) targets = rewards[:, :-1] + gamma * q_tot[:, 1:] * mask[:, :-1]

三个细节决定成败。mask那两行的含义是:只有“上一帧不是终止态、且当前帧确实有数据”的位置才允许从未来 bootstrap;少乘filled,padding 会参与计算;少乘(1 - terminated),跨 episode 的信息会串。gamma别硬编码,从args.gamma取,否则配置里改了也不生效。q_tot的维度要保持(B, T, 1, 1),mixer 输出如果少了最后一维,广播会让 loss 悄悄算错而不是报错。

优化器一侧,默认的optim_alpha、optim_eps在大多数地图上不用动;要做梯度裁剪就加在反传之前,裁剪阈值给 5 到 10,太小会让大图上学习停滞,太大等于没裁。

4.3 动作空间与观测编码:改动会直接导致旧模型不可用

动作选择前必须屏蔽不可用动作,这一步写错不会报错,只会让智能体学会“选一个当前做不到的动作然后什么都不发生”:

# agent_outs: (B, N, A) 的 Q 值,avail_actions: (B, N, A) 的 0/1 q = agent_outs.clone() q[avail_actions == 0] = -float("inf") # 用 -inf,不要用 0

用 0 屏蔽是常见误用:Q 值本身可以是负数,填 0 反而抬高了被屏蔽动作的优先级。只有当 Q 值恒为正时这个 bug 才不显现,所以它在某些地图上“看起来是对的”。

观测编码这边有两个开关值得注意。obs_last_action打开后,上一帧动作会拼进观测,对协作类地图有帮助,但会把观测维度加宽n_actions;obs_agent_id给每个智能体拼一个 one-hot 编号,解决同构智能体的对称性问题。两个都打开之后,观测维度变大,如果此时去加载之前训好的模型,会直接维度不匹配——这不是 bug,是必须重新训练。血泪经验是:改这两个开关之前先备份结果目录,不然后悔药没得吃。

5. 避坑与排查:五个把我卡了半天的具体故障

5.1 启动就超时,报地图找不到

现象:命令下去几十秒后抛异常,提示地图不存在或客户端启动超时,日志里连第一条 episode 都没开始。

原因:SC2PATH指错了层级,或者地图没放进Maps目录。这个变量要指到包含StarCraftII可执行文件的那一层,指到父目录时框架也能“找到”路径,但拉不起客户端,报错信息长得像网络问题。

解决:echo $SC2PATH之后ls $SC2PATH/Maps,能列出地图名才算对。地图文件缺失时,从 SMAC 的地图包里补齐再重跑,别在代码里改路径。

5.2 import 阶段就抛 protobuf 描述符错误

现象:python src/main.py还没进入训练就退出,报Descriptors cannot be created directly。

原因:pysc2与s2clientprotocol依赖的生成代码,和当前 protobuf 版本不兼容,4.x 之后行为变了。

解决:把 protobuf 压到 3.19.x 并重装pysc2,别只降 protobuf 不重装下游包,生成的_pb2.py是安装时固化的。装完用python -c "import pysc2"验证一遍再启动训练。

5.3 命令行覆盖了参数,实验行为却没变

现象:with batch_size=64写了,日志里的配置显示还是 32,或者直接报键不存在。

原因:sacred 的类型是严格的,yaml 里是整数的键传字符串会被拦;而配置树里不存在的键,不加+前缀不会新建。两种情况报错位置都不在“你写错的那个键”上,所以难以定位。

解决:先跑--help或者看结果目录里的config.json,确认最终生效值;新增键统一写成with +key=value;数值型参数别加引号。改完参数养成看一次config.json的习惯,比事后猜要快得多。

5.4 训练跑着跑着内存涨到被系统杀掉

现象:进程被 OOM killer 终止,之前没有任何异常日志,只有一条Killed。

原因:buffer 存的是整条 episode,内存占用约等于buffer_size × episode 长度 × 智能体数 × 观测维度,大图上这个乘积很可观;如果再叠加并行采样,每个进程各持一份环境,占用翻倍。

解决:先砍buffer_size到 2000 以内看是否稳定,再考虑把runner换成单 episode 模式;观测里如果开了obs_last_action和obs_agent_id,维度会明显变大,也要一并核算。

5.5 断点续训之后效果断崖式下跌

现象:从保存的模型继续训,胜率从 0.8 掉到 0.1,几十万步都回不来。

原因:续训时只恢复了网络权重,优化器状态、目标网络、探索率进度、t_env计数都没接上,等于用一个“已经学会的策略”配一个“全新的学习率状态和满探索率”继续跑。

解决:保存时把优化器状态和当前步数一起落盘,加载后把t_env重置为保存时的值,探索率按t_env重新计算;测试确认一次——续训后的第一条测试胜率应该和保存前最后一轮接近,差太多说明状态没接全。

6. 进阶:多地图评测与种子复现的两点手感

单地图跑出高分意义有限,QMIX 这类方法在 8m 上很容易到接近满胜率,换到以少打多的地图立刻原形毕露。我评测时至少铺三张难度递增的图,固定同一套超参,只看趋势不看单点:

地图双方规模难点观察重点
8m8 对 8 陆战队员同构对称,容易过拟合收敛速度,一般几十万步见顶
3s_vs_5z3 追猎者对 5 虫族以少打多,需要风筝走位回报方差大,test_nepisode给到 100 以上
MMM2混合兵种大图观测维度高、协作复杂内存与buffer_size,容易 OOM

复现这块,决定结论可信度的不是算法,是种子。同一个配置换三个种子,8m 上差异可能只有几个百分点,3s_vs_5z上能差出二十个点。我一般把种子、地图名、test_nepisode三个值写进启动脚本,一次跑三组,报结论时报均值加区间,而不是挑最好看的那条曲线。种子入口交给 sacred 统一管,固定住之后结果目录名里的标识才好对齐,回头查某次实验到底用的是哪套配置才有依据。

还有个具体技巧:评测阶段别只看胜率,把平均剩余单位数和平均 episode 长度一起打出来。前者能区分“惨胜”和“碾压”,后者能暴露出智能体在拖延时间刷回报的行为——胜率不掉但局长翻倍,通常意味着奖励设计有漏洞,而不是策略变强了。从那以后我每次开新实验,都强制先把这三个指标、三个种子、三张地图写进启动脚本再按回车,宁可多等半小时,也不想再对着一堆无法归因的曲线猜是哪次改动起的作用。希望帮到你。

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

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

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

立即咨询