☰
pymarl多智能体训练数据通路:episode采样与RNN隐状态工程解析
2026/10/24 4:17:49 网站建设 项目流程

简介:PyMARL 是面向深度多智能体强化学习研究与工程实践的 PyTorch 框架打包,适合需要复现 SMAC 基准实验、阅读算法实现或搭建训练流程的高校研究者与算法工程师。压缩包共 52 个文件,约 47KB,其中 32 个 Python 源码承担算法、环境交互与训练主流程,11 个 YAML 文件用于实验参数配置,4 个 Shell 脚本负责环境构建和启动,另有说明文档、许可证与 Dockerfile 辅助部署,体量轻巧但模块划分完整。目录围绕 components、learners、controllers、runners、envs、config、utils 等部分展开,便于梳理智能体训练流程、算法实现与参数配置之间的对应关系。配套 Docker 构建脚本和星际争霸 II/SMAC 安装脚本,可帮助读者较快搭建实验环境;描述中特别提醒关注 SC2 版本差异,SMAC 结果基于 SC2.4.6.2.69232 而非 SC2.4.10,这对复现实验具有参考价值。已有 1765 人学习,适合作为多智能体强化学习入门、代码阅读和实验复现的工程模板。

1. pymarl 的反直觉设计:为什么把整条 episode 当采样单位

在多智能体强化学习里,一个被反复验证的现象是:同一份 Python 代码、同一个随机种子,换台机器跑出来的胜率曲线能差出十几个点。pymarl 这套多智能体强化学习框架给出的答案有点反直觉——它不把单步 transition 当成采样的最小单位,而是把整条 episode 连皮带骨塞进 buffer,训练时整条取出、按最短长度裁齐。这个设计看着笨重、显存吃得凶,却让 RNN 智能体的隐状态重放、已死亡智能体的动作掩码、可变长度 episode 的批处理三件事一次性对齐了。想搞懂多智能体训练为什么难复现,以及一个框架要在哪一层做妥协才能同时兼顾算法对比和工程效率,这条从环境到梯度的数据通路值得逐行拆开看。

2. 采样数据通路:一条 episode 是怎么被拆成训练张量的

2.1 Runner 的 while 循环:pre-transition 与 post-transition 为什么要分两次写

多智能体采样和单智能体最大的差别不在网络,而在数据结构。单智能体里一条经验是(s, a, r, s', done)五元组,到了多智能体这里,s要拆成每个智能体的局部观测obs和全局状态state,a要拆成 N 个智能体的联合动作,还多出一个avail_actions掩码矩阵。pymarl 的做法是把一个时间步的写入拆成「动作之前」和「动作之后」两批字段,写进同一个 batch 的同一个时间索引上。

常见的实现思路大致是这样:

def run(self, test_mode=False): self.reset() terminated = False episode_return = 0 # 每个并行环境各有一份隐状态,batch_size 维是环境数 self.mac.init_hidden(batch_size=self.batch_size) while not terminated: # 第一批:动作还没产生,先把状态类字段写进 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=self.t) # 动作由 MAC 统一选,内部走 epsilon-greedy + avail_actions 掩码 actions = self.mac.select_actions( self.batch, t_ep=self.t, t_env=self.t_env, bs=self.envs_not_termianted, test_mode=test_mode, ) reward, terminated, env_info = self.env.step(actions[0]) episode_return += reward # 第二批:动作之后才知道的字段,仍然写在 t 时刻 post_transition_data = { "actions": actions, "reward": [(reward,)], # 注意这里:environment 自然结束和到达时间上限要分开记 "terminated": [(terminated != env_info.get("episode_limit", False),)], } self.batch.update(post_transition_data, ts=self.t) self.t += 1 # 最后一步只有观测没有动作,单独补一帧,供 bootstrap 使用 last_data = { "state": [self.env.get_state()], "avail_actions": [self.env.get_avail_actions()], "obs": [self.env.get_obs()], } self.batch.update(last_data, ts=self.t) return self.batch

这段代码里有三个值得停一下的地方。

第一,ts=self.t这个索引参数决定了 batch 的布局是(episode, time, agent, feature)四维,而不是常见的(batch, feature)两维。多出来的time维是后面整段前向的基础,也是这个框架内存占用偏高的直接原因。一条 episode 有 120 步、5 个智能体、每个智能体观测维度 100,光是 obs 一个字段就是 6 万个浮点数,还没算 RNN 隐状态。

第二,"terminated": [(terminated != env_info.get("episode_limit", False),)]这一行是整个数据通路里最容易被忽略、又最影响结果的地方。环境返回的terminated有两种含义:一种是任务真的结束了(比如单位全灭),另一种只是撞上了最大步数被强行截断。前一种情况下下个状态的价值应该是 0,后一种情况下下个状态还有价值,必须做自举(bootstrap)。用一个布尔异或把两者压成一个字段,Learner 里就只需要判断这个字段就够了,不用再关心环境上限是多少。

第三,last_data单独补的那一帧。因为动作是在t时刻观测之后、t+1时刻观测之前产生的,所以一条 episode 的观测帧数永远比动作帧数多一。这个「多一帧」在多智能体里不是可选项——中心化 critic 算目标值时要看最后一个观测,没这一帧就只能拿倒数第二帧凑,价值估计直接偏。

2.2 EpisodeBatcher:按整条取样的收益与代价

采样层面,pymarl 的 buffer 存的是EpisodeBatch而不是 transition 队列。采样时不是随机抽 N 条 transition,而是随机抽 N 条完整 episode,再按这批 episode 的最短长度裁齐。

def sample(self, batch_size): # buffer 里只放已经跑完的 episode,所以抽样上限是 episode 条数 assert batch_size <= len(self.buffer), \ "Batch size bigger than the number of available episodes" if batch_size == len(self.buffer): # 正好全取走,省一次拷贝,然后立刻重建一个空 batch self.new_batch = self.buffer self.buffer = EpisodeBatch( self.scheme, self.groups, self.args.batch_size, self.args.episode_limit + 1, ) else: # 随机不重复地抽 episode 下标 indices = self.buffer.sample_episode_indices(batch_size) self.new_batch = self.buffer.get_episodes(indices) return self.new_batch

这么设计的直接动机是 RNN。智能体的隐状态是随时间累积的,如果按 transition 采样,一条样本从 episode 中间开始,隐状态要么是 0(分布错位),要么从 buffer 里读一个采样时存下的值(值本身是对的,但序列被切断后没法做 BPTT)。按整条 episode 采样,训练时就能从t=0的零隐状态开始,逐帧重放整段序列,训练分布和采样分布严格一致。

代价也很明确。assert batch_size <= len(self.buffer)这句会在训练刚开始时直接挂掉——buffer 里还没攒够 episode,你以为设了batch_size=32就能跑,实际第 8 条 episode 结束时就会抛异常。另外为了对齐到最短长度,长 episode 的尾部数据每一轮都被丢掉,数据利用率明显低于 transition 采样。常见的缓解办法是设一个buffer_size上限后按先进先出淘汰,让 episode 长度分布尽量均匀;或者给EpisodeBatch加 padding 加 mask,代价是每次前向都要多算一批无效时间步。

提示:episode_limit + 1这个+1就是 2.1 节里补的那一帧。自己改数据结构时如果漏掉它,会在t = episode_limit这一步索引越界,报错位置离真正的错因很远。

3. Agent、Controller、MAC 三层抽象:参数共享靠什么落地

3.1 三层各自的边界,以及动作选择为什么拆两层

pymarl 把「一个智能体」拆成了三个对象,职责边界划得很干脆:

对象职责手里有什么
Agent纯网络 + 损失函数计算一个 nn.Module、一个 target 网络
Controller单个智能体视角的动作选择一个 agent 引用、visible mask、epsilon
MAC多智能体编排N 个 controller、全局 state

Agent 这一层是参数量所在,也是参数共享发生的地方。整个系统里只实例化一个agent(或者每种 Agent 类型一个),所有智能体共享同一套权重,智能体维度靠 batch 维展开。这就是为什么 actor 网络的输入形状是(batch, n_agents, input_dim)而不是单个智能体的input_dim——共享是通过把智能体塞进 batch 维实现的,不是通过 for 循环实现。

Controller 这一层看起来薄,但它是「中心化训练、去中心化执行」这个范式在代码里的落点。每个 controller 只管自己那一个智能体的动作,看不见别人的观测;而中心化信息(全局 state)只在 MAC 层和 Learner 里出现。这种切法保证了执行阶段天然不依赖全局信息,不需要在部署时再写一套裁剪逻辑。

MAC 层的select_actions是整个链路里最常被读的一段:

def select_actions(self, ep_batch, t_ep, t_env, bs=slice(None), test_mode=False): # 只取当前时间步的可用动作掩码,而不是整段 avail_actions = ep_batch["avail_actions"][:, t_ep] # 整批智能体一次前向,不做逐智能体循环 agent_outputs = self.forward(ep_batch, t_ep, test_mode=test_mode) chosen_actions = self.action_selector.select_action( agent_outputs[bs], avail_actions[bs], t_env, test_mode=test_mode ) return chosen_actions

forward里有个细节值得注意:它接收的是整个ep_batch加一个时间下标t_ep,而不是一个已经切好的观测张量。这样做是为了让 agent 自己决定需要哪些字段——比如某个算法需要上一帧的动作做输入,某个算法需要全局 state 做混合网络输入,这些都在 agent 内部从 batch 里取,MAC 层不用预先知道。代价是 agent 和 batch 的字段名耦合了,改字段名要全局搜。

action_selector里做的是 epsilon-greedy。这里的 epsilon 是按全局环境步数t_env退火的,不是按 episode 数,也不是按 learner 的更新次数。多进程采样时,t_env是所有并行环境累加的步数,退火曲线才会跟论文里的横轴对得上。这一层如果写错,表现是「训练曲线形状对但横轴被拉伸」——看着像超参没调好,实际是计数口径错了。

3.2 RNN Agent 的隐状态:三处流转,一处错全盘错

多智能体里用 RNN 智能体是常态(部分可观测),隐状态的生命周期是这个框架最容易翻车的地方。看 agent 的实现:

class RNNAgent(nn.Module): def __init__(self, input_shape, args): super(RNNAgent, self).__init__() self.args = args self.fc1 = nn.Linear(input_shape, args.rnn_hidden_dim) self.rnn = nn.GRUCell(args.rnn_hidden_dim, args.rnn_hidden_dim) self.fc2 = nn.Linear(args.rnn_hidden_dim, args.n_actions) def init_hidden(self): # 注意形状是 (1, hidden_dim),靠 batch 维广播到所有智能体 return self.fc1.weight.new(1, self.args.rnn_hidden_dim).zero_() def forward(self, inputs, hidden_state): x = F.relu(self.fc1(inputs)) # GRUCell 要求二维输入,这里把 (batch, agents, dim) 压平 h_in = hidden_state.reshape(-1, self.args.rnn_hidden_dim) h = self.rnn(x, h_in) q = self.fc2(h) return q, h

隐状态在三个地方流转,每一处都有讲究。

采样时:mac.init_hidden(batch_size)在每个 episode 开始时归零,然后每一帧前向后把新的h存回 controller。因为select_actions是逐帧调用的,h是唯一的跨帧状态,写错就等于整个 episode 的记忆错乱。

存 buffer 时:batch 里在t=0存零向量,之后每一帧存「进入这一帧时的 h」。这里有个常见的实现分歧——存进入时的 h 还是离开时的 h。存进入时的 h,训练时从t=0的零开始重放,每一帧的输入隐状态正好和采样时一致;存离开时的 h,训练重放就会错开一帧。差异很小,但会让训练和采样的前向计算结果系统性偏移,表现出来就是「训练 loss 正常下降、评测效果就是不如论文」。

训练时:Learner 里重新init_hidden(batch_size),然后for t in range(max_seq_length)逐帧走完整个序列,把每帧的mac_out堆起来。这一步不能省,也不能只对需要梯度的部分重放——因为隐状态是链式的,跳过任何一帧,后面的隐状态就全错了。

注意:如果 agent 用了nn.GRU而不是nn.GRUCell,init_hidden要返回(num_layers, batch, hidden)三维张量,且forward里不需要手动 reshape。两种写法在同一个框架里混用,症状是形状对不上但报错位置飘忽。

4. Learner 的训练循环:整段前向、目标网络与多进程梯度怎么对齐

4.1 为什么 learner 必须整段前向而不是逐步反传

Learner 的入口是train(batch),进来的是 2.2 节采出的整批 episode。它做的第一件事是把所有 episode 的序列一次性展开成(batch, time, agents, ...),然后循环时间步收集网络输出:

def train(self, batch): # 时间步计数按 batch 里真实消耗的步数累加,用于 epsilon 退火和日志 self.trained_timesteps += self.batch_size * self.episode_length # 动作必须是 long,float 会静默走错分支 batch["actions"] = batch["actions"].long() mac_out = [] self.mac.init_hidden(batch_size=batch.batch_size) for t in range(batch.max_seq_length): # 注意是 t=t,不是取 batch 里的某一帧,隐状态在内部滚动 agent_outs = self.mac.forward(batch, t=t) mac_out.append(agent_outs) mac_out = th.stack(mac_out, dim=1) # (batch, time, agents, n_actions) # 非法动作置一个极大负数,argmax 时自然不会被选中 avail_actions = batch["avail_actions"] mac_out[avail_actions == 0] = -9999999

-9999999这个数字看着随意,其实是个折中:用-inf会在某些算法里产生 NaN(比如 softmax 之后乘 0 得到 0/0),用一个足够大的负数既能保证 argmax 不选中它,又不会污染梯度。代价是这个值会参与 max 运算,如果 Q 值本身量级很大(比如 reward 没归一化),可能出现「合法动作的 Q 值比 -9999999 还小」的极端情况——概率很低,但确实发生过。

真正体现「整段前向」必要性的是目标值计算。以值分解这一类算法为例,目标值要拿 target 网络对t+1时刻的输出算 max,再乘上(1 - terminated)做掩码:

target_mac_out = [] self.target_mac.init_hidden(batch_size=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) # 目标网络不回传梯度,detach 掉省显存 target_mac_out = th.stack(target_mac_out[1:], dim=1).detach() target_mac_out[avail_actions[:, 1:] == 0] = -9999999 target_max_qvals = target_mac_out.max(dim=3)[0] # 这里的关键:只有被截断的 episode 才保留 bootstrap 项 rewards = batch["reward"][:, :-1] terminated = batch["terminated"][:, :-1].float() mask = batch["filled"][:, :-1].float() mask[:, 1:] = mask[:, 1:] * (1 - terminated[:, :-1]) targets = rewards + self.args.gamma * (1 - terminated) * target_max_qvals

三段掩码必须同时正确,缺一段结果就会偏:

  • target_mac_out[1:]相对rewards错开一帧,这是时序对齐;
  • avail_actions[:, 1:]用的是下一帧的可用动作,因为 max 是对下一状态做的;
  • terminated只对自然终止置位,截断的情况保留自举,这个语义在 2.1 节就埋好了。

mask那一行mask[:, 1:] * (1 - terminated[:, :-1])是在处理「episode 中途有智能体死亡」的情形。已死亡的智能体后续帧不应该贡献 loss,否则它会被迫学习「死后该做什么」,而这种样本在真实执行时根本不存在。

4.2 多进程采样与目标网络:参数什么时候对齐

单 learner 时,训练循环就是一个while里反复sample加train。pymarl 的设计里真正有意思的是并行模式:多个采样进程各跑各的环境,多个 learner 进程各算各的梯度,参数通过进程间同步机制交换。

这里的设计取舍很直白。多进程采样几乎必须做,因为多智能体环境(尤其是需要跑微操类仿真的)单步开销大,Python 的 GIL 又让多线程采样没有意义;多 learner 则是为了多卡,每个进程绑一张卡,各自算一部分 batch 的梯度。参数同步的常见实现思路是:每个 learner 周期性把参数写到共享位置,其他 learner 从那里拉最新参数,同时更新自己的 target 网络。这种「准异步」方案省去了分布式框架的复杂度,代价是各 learner 的梯度严格来说不是同一个参数点上的,收敛性分析会麻烦一些。

目标网络的更新是另一个容易忽略的点。软更新(target = tau * source + (1 - tau) * target)和硬更新(每隔 N 步整份拷贝)在这个框架里通常靠配置切换,但触发时机是按trained_timesteps算的,不是按 learner 的更新次数。多个 learner 并行时,如果各自独立计数,会出现「某张卡上的 target 更新得比另一张卡快」的情况。常见的规避方式是让计数挂在全局步数上,或者干脆只在主 learner 上触发更新、其他 learner 拉取。

提示:并行 learner 下显存排查要看两处——每个进程各自建 CUDA context 本身就占几百 MB,加上target_mac_out这类中间张量,batch_size开大一倍很容易在某张卡上单独 OOM,而其他卡还宽裕。

5. 避坑与排查:隐状态、掩码和显存上的高频翻车点

5.1 隐状态和掩码相关的三类翻车

现象一:训练 loss 从第几十步开始稳定下降,但评测胜率一直在随机水平附近晃。

原因基本锁定在隐状态重放。采样时mac.init_hidden在每个 episode 开始时归零,如果因为并行环境里有智能体提前死亡、bs=self.envs_not_termianted这个切片导致部分环境的隐状态没有被重置,下一轮 episode 就会带着上一轮的记忆起跑。训练时 Learner 里是严格从零开始的,两条路径的隐状态分布就此分叉。解决方式是把init_hidden的调用点挪到显式 reset 之后,并且在bs切片的位置补一次掩码重置,而不是依赖广播。

现象二:训练曲线开头有个巨大尖峰,然后一路平,看起来像学习率太猛。

这类尖峰常常来自terminated字段的语义混用。如果在采样时把「到达时间上限」也写进了terminated,Learner 里(1 - terminated)就会把所有 episode 的结尾都当成真终止,自举项全被抹掉。短 episode 上区别不大,长 episode 上价值估计会系统性偏低,表现为训练初期 Q 值快速塌陷再慢慢爬。排查方法是把 batch 里terminated为 1 的帧位置打印出来,看最后一步的占比——如果接近 100%,说明截断和终止没分开。

现象三:mac_out[avail_actions == 0] = -9999999之后,某些帧的 argmax 结果全部相同。

这不是网络学坏了,是那一帧的avail_actions全零。多智能体环境里全零掩码通常意味着「这一帧没有智能体可以动作」,但实现上仍然会走一次 argmax。如果算法用 softmax 而不是 argmax,-9999999经过 exp 之后是 0,除以所有项之和 0,直接出 NaN。稳妥的写法是在掩码赋值后检查每行是否至少有一个合法动作,没有就整体跳过这一帧的 loss 累积。

5.2 并行度与显存:跑起来之后的排查

现象四:assert batch_size <= len(self.buffer)在训练刚开始就挂了,把batch_size调小又能跑。

这是 2.2 节那个按 episode 采样的直接后果。buffer 里装的不是 transition 而是完整 episode,所以采样上限是「已完成的 episode 条数」。训练起步阶段这个数很小,batch_size设 32 意味着至少要攒够 32 条完整 episode 才会采第一次样。解决方式有两种:把batch_size降到个位数并接受更大的梯度噪声,或者加一个 warmup 阶段——前若干步只采样不训练,等 buffer 满了再开始。后者更常用,但要记得 warmup 期间不要消耗t_env,否则 epsilon 会在没训练的时候就退火完。

现象五:训练能跑,但显存占用随时间缓慢上涨,跑十几个小时之后 OOM。

多进程 learner 下最常见的原因是每个进程各建了一份 CUDA context,且中间张量(mac_out、target_mac_out)在train里被重新分配但没有及时释放。mac_out的形状是(batch, time, agents, n_actions),batch_size=32、time=120、agents=5、n_actions=6时是 11 万个浮点数,不至于爆,但如果算法内部还留了 reference(比如日志里存了 dict 引用批数据),张量的生命周期会超出预期。排查方式是每次train结束后打印一次torch.cuda.memory_allocated(),看曲线是阶梯上升还是锯齿震荡——阶梯上升说明有引用没释放,锯齿才是正常。

这些坑的共同特征是:报错信息和真正的错因隔了好几层,assert挂在采样处、NaN 出在掩码处、OOM 出在日志引用处。这也是为什么这个框架的配置驱动设计虽然方便,但第一次接手时必须把数据流的形状和语义完整过一遍。

6. 配置驱动与注册表:为什么这套框架宁可牺牲 IDE 跳转也要换算法一行配置

这套框架的工程骨架是两层:一层是 YAML 配置,一层是字符串注册表。算法、agent 类型、MAC 类型、环境、runner 全部用字符串在配置里指定,运行时通过一个全局 registry 查表实例化。想换算法,改一行learner: "q_learner";想换混合网络结构,改一行mixer: "qmix"。代码一行不动。

这么设计的原因不复杂:多智能体强化学习的实验成本主要不在训练,而在对比。同一个环境、同一套采样流程、同一个 batch 布局下比较五个算法,如果每个算法都是一个独立的继承类,改动会沿着抽象基类一路扩散,最后没人说得清哪个差异是算法带来的、哪个是实现细节带来的。注册表把「算法」压缩成了一个可替换的函数指针,让对比实验的变量控制变得廉价。

代价也很具体。IDE 里点"q_learner"跳不到实现,拼写错误在配置加载阶段才报,而且报的是KeyError而不是「你写错了算法名」。更麻烦的是字段耦合——agent 从 batch 里按字段名取值,改一个字段名要全局搜字符串,编译器帮不上忙。我见过的最省事的缓解办法是在注册函数里加一层校验,实例化后立刻用一条假数据跑一次前向,把字段缺失的错误提前到启动时暴露,而不是等到第一个训练 step。

提示:如果打算在这个框架上做长时间的算法对比,建议把每次实验的完整配置连同代码版本一起落盘,而不是只存结果。配置驱动的框架里,「同一份代码」这个前提只有在配置完全一致时才成立。

从那以后我每次接这类配置驱动的框架,都强制先在最小配置下跑通一遍端到端、把 batch 里每个字段的形状和语义打印出来,再动算法代码。希望帮到你。

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

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

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

立即咨询