简介:这份压缩包面向已具备MATLAB与强化学习基础、希望进阶多智能体协作训练的读者,围绕多个智能体在同一环境中执行协作任务展开,可用于复现示例、理解协作策略训练流程,并作为课程设计或科研原型的参考。压缩包共6个文件,约486KB,包含3个m脚本、1个mlx实时脚本、1个mat数据文件和1个slx Simulink模型,分别对应参数配置、环境搭建、训练数据与结果可视化等环节。已有898人学习,说明该示例在MATLAB强化学习入门到进阶场景中具有一定参考价值。读者可从中梳理协作任务的环境建模、智能体参数设置、训练过程重置与结果绘图思路,并借助模型与脚本对照理解多智能体强化学习的完整实现链路。
1. 多智能体协作训练翻车的第一个信号:回报涨了,活没干成
单智能体任务里,奖励写得差不多,PPO 曲线基本会抬头。同一套代码原地扩到三个智能体、改成协作任务,最常见的画面是:回合回报稳步上升,协作成功率却横在 0.2 附近不动,三个智能体挤在场地中央互相让路。这不是算法坏了,是环境建模和优势对齐这两步没做对——多智能体协作任务训练的核心工作量,从来不在网络结构上。
这篇讲的就是一个「训练多个智能体完成协作任务」的示例该长什么样:环境怎么切视角、共享奖励怎么给、集中训练分散执行(CTDE)的骨架怎么搭、优势估计在团队奖励下要不要算 N 遍,以及哪几个参数一动就会翻车。新手能照着代码跑通一个三智能体同时到位的最小任务,熟手可以直接跳到第 4、5 章的维度对齐和排查清单,那里全是血泪经验。
2. 把协作任务写成可训练的环境:视角切分与奖励口径
2.1 协作环境比单智能体多出的三件事
单智能体环境只有一份观测、一份动作、一份奖励,接口天然对齐。协作任务里必须多切出三样东西,缺一样后面训练都会别扭。
第一是观测切分。每个智能体拿一份自己的局部观测obs_i,形状是(N, obs_dim);同时还要维护一个全局状态state,形状是(state_dim,),专门喂给评论家。这两者不是一回事,很多人图省事直接把所有 obs 拼起来当 obs 发给策略,结果策略学会了「偷看别人的位置」,部署时拿不到这些信息,线上直接崩。
第二是动作的并发性。step(actions)接收的是(N, act_dim)的整批动作,同一 tick 内所有智能体一起决策、一起执行、一起结算。这里不能写成 for 循环逐个 step,否则先动的智能体会改变后动者的观测,任务性质就从「同时协作」变成了「轮流决策」,学到的东西完全不一样。
第三是奖励口径。强协作任务的默认做法是团队共享奖励:所有智能体拿到同一个标量。这一条决定了后面优势估计怎么写——共享奖励加团队评论家,整个 batch 的时间步只需要算一遍 GAE,不需要按智能体算 N 遍。
顺带说清同构和异构。同构智能体(能力、动作空间、观测维度都一样)可以参数共享,一个 Actor 网络服务所有智能体,样本效率高、训练稳;异构智能体就得按角色分组,每组一套参数。数量可变的任务更麻烦,网络输入维度别写死 N,用相对位移、注意力池化这类跟数量无关的编码方式。
2.2 用一段自包含代码写一个最小协作任务
下面这个环境不依赖任何特定版本的框架,纯 NumPy,直接能跑。任务设定:N 个智能体在单位正方形里,N 个目标点,所有智能体同时进入各自最近目标的 0.05 半径内才算一次成功协作。
import numpy as np class CooperativeReachEnv: """N 个智能体同时到位才算成功;共享团队奖励。""" def __init__(self, n_agents=3, world=1.0, max_steps=60, seed=0): self.n = n_agents self.world = world self.max_steps = max_steps self.rng = np.random.default_rng(seed) # 自身位置(2) + 全部目标点相对位移(2N) self.obs_dim = 2 + 2 * n_agents self.state_dim = self.obs_dim * n_agents # 全局状态 = 所有观测拼接 self.act_dim = 2 self._t = 0 def reset(self): self._t = 0 self.agent_pos = self.rng.uniform(0, self.world, size=(self.n, 2)) self.goal_pos = self.rng.uniform(0, self.world, size=(self.n, 2)) return self._obs() def _obs(self): # 每个智能体都看得到全部目标点:保证任务可分工,而不是纯部分可观测 rel = (self.goal_pos[None, :, :] - self.agent_pos[:, None, :]).reshape(self.n, -1) return np.concatenate([self.agent_pos, rel], axis=1).astype(np.float32) def step(self, actions): self._t += 1 actions = np.clip(np.asarray(actions, dtype=np.float32), -1.0, 1.0) self.agent_pos = np.clip(self.agent_pos + 0.1 * actions, 0.0, self.world) # 距离矩阵 (N, N):第 i 个智能体到第 j 个目标 dist = np.linalg.norm(self.agent_pos[:, None, :] - self.goal_pos[None, :, :], axis=-1) nearest = dist.min(axis=1) # 各自只对最近目标负责 arrived = nearest < 0.05 reward = -float(nearest.mean()) # 稠密项:逼近目标 if arrived.any(): # 已到位的智能体冻结在统计外,避免它继续抖动拉低团队奖励 reward = -float(nearest[~arrived].mean()) + 0.1 * arrived.mean() if (~arrived).any() else 0.1 success = bool(arrived.all()) if success: reward += 1.0 done = success or self._t >= self.max_steps info = {"success": success, "worst_dist": float(nearest.max())} # 共享奖励:所有智能体拿到同一个值 return self._obs(), np.full(self.n, reward, dtype=np.float32), done, info几个参数值得逐个说清。0.1 * actions是每步位移上限,配合world=1.0意味着从一角走到对角大约 14 步,max_steps=60给了四倍余量,太短会让成功奖励几乎采不到,太长会让「原地不动」也能混到不低的回合回报。0.05的到位阈值决定了任务的精度要求,调大到 0.15 任务立刻变简单,成功率几天就能上去,但学到的策略会很糙。
nearest = dist.min(axis=1)这一行是刻意不做固定分配的:谁离哪个目标近就管哪个。代价是可能出现两个智能体抢同一个目标、另一个目标没人管,这也正是协作要学的东西。如果你的任务需要严格一一对应,用匈牙利算法做每步的最优分配,再把分配结果作为观测的一部分喂进去,收敛会快很多。
arrived的冻结逻辑很多人会漏掉。到位后如果还把它算进nearest.mean(),智能体会学到一个反直觉行为:到了反而不动,因为乱动会让团队奖励变差。冻结之后稠密项只统计还没到位的,已到位的有额外小奖励,鼓励它们保持静止。
2.3 奖励口径怎么选:四种写法的适用边界
同一套环境,奖励换个写法,学出来的行为天差地别。下面这张表是我自己在协作任务里反复对比后留下的判断依据。
| 口径 | 写法 | 适合什么任务 | 主要风险 |
|---|---|---|---|
| 团队奖励 | 所有智能体同一个标量 r | 强耦合、目标完全共享 | 信用分配难,方差大,收敛慢 |
| 个体奖励 | 每个智能体一个 r_i | 松耦合、可独立完成 | 容易各自为战,学不出协同 |
| 差分奖励 | r_i = R(全部) − R(去掉 i) | 需要明确贡献度 | 每步要多算 N 次,噪声大 |
| 势函数塑形 | r += γΦ(s′) − Φ(s) | 成功奖励极度稀疏 | 塑形不当会改变最优策略 |
判断标准很简单:任务成功必须靠「同时」「配合」才能达成,就用团队奖励;智能体之间可以各干各的,用个体奖励更稳。差分奖励理论上最优雅,实操中在 N 大于 5 之后计算开销和方差都不划算,我一般只在需要分析贡献度时才用。
势函数塑形要注意一致性。用「到目标距离」做势函数是最常见的做法,但势函数必须满足 Φ 只依赖状态、且系数不能太大,否则策略会优化塑形项而不是真实回报。经验系数控制在 0.1 到 0.5 之间,并且一定要在评测时把塑形项从回报统计里剔除,否则你看到的曲线是假的。
3. 集中训练分散执行:MAPPO 风格骨架的最小实现
3.1 CTDE 到底改变了什么
协作任务最直接的困难是:智能体在部署时只能看到自己的局部观测,但训练时如果能偷看全局信息,信用分配会容易得多。CTDE 就是把这个「偷看」限制在评论家身上——策略网络只看局部观测,评论家看全局状态,训练用全局值函数做基线,部署时把评论家整个扔掉。
这个设计带来的直接好处是方差下降。团队奖励的方差天然比个体奖励大,因为一个智能体的失误会平摊到所有人头上;有了全局值函数当基线,优势信号的噪声会小一大截。代价是评论家的输入维度随智能体数量线性增长,N 变大时评论家更难训,这时候要么上注意力池化,要么退回局部评论家接受更大的方差。
还有一个常被忽略的点:CTDE 不是必须的。如果你的任务里每个智能体都能拿到完整观测,那就没必要搞两套网络,直接用共享的策略-价值网络(类似 IPPO 那种独立 PPO)反而更简单。CTDE 的价值在于「训练时有全局信息、部署时没有」这个不对称性,没有这个不对称,它就是纯增加复杂度。
3.2 Actor 参数共享与智能体 ID 编码
同构智能体必须参数共享,否则 N 个智能体 N 套参数,样本效率直接除以 N。但参数共享有个副作用:如果所有智能体的观测分布一样、网络又一样,它们会输出完全相同的动作,永远学不会分工。这就是对称性陷阱。
破对称最省事的做法是给每个智能体加一个可学习的身份编码(ID embedding),拼在观测后面。几行代码的事,效果比在观测里塞一堆手工特征好得多。
import torch from torch import nn class Actor(nn.Module): """参数共享的随机策略;身份编码负责破坏对称性。""" def __init__(self, obs_dim, n_agents, act_dim, hidden=64, id_dim=8): super().__init__() self.id_enc = nn.Embedding(n_agents, id_dim) self.net = nn.Sequential( nn.Linear(obs_dim + id_dim, hidden), nn.Tanh(), nn.Linear(hidden, hidden), nn.Tanh(), nn.Linear(hidden, act_dim), ) # 可学习 log 标准差:所有智能体共用一组,减少参数 self.log_std = nn.Parameter(torch.zeros(act_dim)) def forward(self, obs, ids): x = torch.cat([obs, self.id_enc(ids)], dim=-1) mu = torch.tanh(self.net(x)) # 动作限幅到 [-1, 1] std = self.log_std.exp().expand_as(mu) return torch.distributions.Normal(mu, std) class Critic(nn.Module): """集中式评论家:输入全局状态,输出团队价值。""" def __init__(self, state_dim, hidden=128): super().__init__() self.net = nn.Sequential( nn.Linear(state_dim, hidden), nn.Tanh(), nn.Linear(hidden, hidden), nn.Tanh(), nn.Linear(hidden, 1), ) def forward(self, state): return self.net(state).squeeze(-1)id_dim=8是个经验值,N 小于 10 时 4 到 8 维足够,维度再大容易过拟合到「身份」本身而不是学有用的分工。log_std做成全局共享而不是每智能体一份,是因为动作噪声的尺度应该由任务决定,不该由身份决定——如果某个智能体需要更大的探索噪声,那通常说明观测设计有问题。
mu = torch.tanh(...)这一步有个坑:Normal 分布的采样结果经过 tanh 压缩后,log_prob就不再是原始高斯的对数概率了,少了 Jacobian 修正项。玩具任务里影响不大,但在精度要求高的任务里会让策略梯度有偏。严谨做法是用 squashed Gaussian 加上log(1 - tanh(x)^2)的修正;偷懒做法是训练时正常采样、评测时直接取mu。
评论家输入是全局状态,这里是所有智能体观测的拼接,维度是obs_dim * N。这个写法在 N 变化时网络就废了,正式项目里我一般换成对所有智能体观测做注意力池化,输出维度固定,换数量不用重训。
3.3 共享奖励下的 GAE:只算一遍,然后广播
团队奖励加上团队评论家,意味着同一个时间步里所有智能体的优势值是一样的。这时候按智能体跑 N 遍 GAE 是纯粹的浪费,而且会引入不必要的实现分歧。正确做法是按时间步算一遍(T,)的优势,再在摊平的时候复制到每个智能体。
import numpy as np def compute_gae(rewards, values, dones, gamma=0.99, lam=0.95): """rewards/dones: (T,);values: (T+1,),末位是 bootstrap 值。""" T = len(rewards) adv = np.zeros(T, dtype=np.float32) last = 0.0 for t in reversed(range(T)): mask = 1.0 - float(dones[t]) # 终止步不跨 episode 传播 delta = rewards[t] + gamma * values[t + 1] * mask - values[t] last = delta + gamma * lam * mask * last adv[t] = last return adv, adv + values[:T] # 优势、回报values的长度是T+1而不是T,最后一位是末状态的 bootstrap 值。这个细节错了不会报错,只会让优势系统性偏低,表现为价值损失一直降不下去、策略更新方向微弱。mask用1 - done而不是直接乘done,是因为这里要的是「还能不能继续传播」,语义别搞反。
gamma=0.99配max_steps=60意味着有效视野大约 100 步,覆盖一整个 episode,合适。如果 episode 只有 20 步,gamma=0.99就偏大了,折扣几乎不起作用,值函数要拟合的目标变得很平;反过来 episode 上千步还用小 gamma,智能体就变成短视的,学不会长程协作。一个粗略的对照:有效视野约等于1/(1-gamma),让它和 episode 长度同量级。
3.4 训练主循环与六个必调超参
把采样和更新串起来,一个能跑的主循环大概是这样:
def collect_rollout(env, actor, critic, n_steps=128, device="cpu"): obs_list, id_list, act_list, logp_list = [], [], [], [] state_list, rew_list, done_list, val_list = [], [], [], [] obs = env.reset() ids = np.arange(env.n, dtype=np.int64) for _ in range(n_steps): obs_t = torch.as_tensor(obs, device=device) ids_t = torch.as_tensor(ids, device=device) state = obs.reshape(-1) # 全局状态 with torch.no_grad(): dist = actor(obs_t, ids_t) act = dist.sample() logp = dist.log_prob(act).sum(-1) # 按动作维求和 val = critic(torch.as_tensor(state, device=device)).item() next_obs, rew, done, info = env.step(act.cpu().numpy()) obs_list.append(obs); id_list.append(ids); act_list.append(act.cpu().numpy()) logp_list.append(logp.cpu().numpy()); state_list.append(state) rew_list.append(float(rew[0])) # 共享奖励取一个即可 done_list.append(float(done)); val_list.append(val) obs = env.reset() if done else next_obs # 末状态 bootstrap:截断和终止要区分处理 with torch.no_grad(): last_val = 0.0 if done_list[-1] else critic( torch.as_tensor(obs.reshape(-1), device=device)).item() values = np.array(val_list + [last_val], dtype=np.float32) adv, ret = compute_gae(np.array(rew_list), values, np.array(done_list)) # 按智能体广播:优势 (T,) -> (T, N) -> (T*N,) adv_all = np.repeat(adv[:, None], env.n, axis=1).reshape(-1) ret_all = np.repeat(ret[:, None], env.n, axis=1).reshape(-1) batch = { "obs": torch.as_tensor(np.concatenate(obs_list), device=device), "ids": torch.as_tensor(np.concatenate(id_list), device=device), "act": torch.as_tensor(np.concatenate(act_list), device=device), "logp": torch.as_tensor(np.concatenate(logp_list), device=device), "state": torch.as_tensor(np.stack(state_list), device=device), } return batch, torch.as_tensor(adv_all, device=device), torch.as_tensor(ret_all, device=device)这里有两处容易写错。logp = dist.log_prob(act).sum(-1)是按动作维度求和,得到每个智能体一个标量;如果忘了.sum(-1),后面算 ratio 的时候会变成按动作维的比值,PPO 的 clip 就失去意义了。np.repeat(adv[:, None], env.n, axis=1)是把时间步优势广播到智能体维度,顺序必须和 obs 的摊平顺序一致——obs 是(T, N, obs_dim)摊平成(T*N, obs_dim),C 顺序下第 t 个时间步的 N 个智能体是连续的,广播也得按这个顺序。
六个必调参数的经验区间:
| 参数 | 常用值 | 调整方向 |
|---|---|---|
| 学习率 | 3e-4(Adam) | 曲线剧烈震荡就降到 1e-4 |
| clip 系数 | 0.2 | 策略更新太慢调到 0.1,学不动调到 0.3 |
| gamma | 0.99 | 与 episode 长度匹配,见上文 |
| lam | 0.95 | 方差大降到 0.9,偏差大升到 0.98 |
| 更新轮数 | 4~10 | 超过 10 容易过拟合当前 batch |
| 熵系数 | 0.01 | 策略过早收敛到同一动作时调到 0.02 |
4. 工程细节:批次维度对齐、熵统计与日志
4.1 把 (T, N, D) 摊平成 (T·N, D) 的三个坑
多智能体训练里 80% 的玄学问题都出在维度顺序上。采样阶段数据天然是(T, N, D),更新阶段为了复用单智能体的 PPO 实现,通常摊平成(T*N, D)当普通 batch 处理。这个转换有三处必须对齐。
第一处是优势广播的顺序,上一节已经说过。第二处是价值函数。评论家吃的是(T, state_dim)的团队状态,输出的(T,)价值对应的是一整个时间步,不是单个智能体。如果你把state也按 N 复制成(T*N, state_dim)再算价值,那是白算 N 倍,而且如果 batch 里打乱了顺序,同一时间步的 N 个样本可能落到不同 minibatch 里,价值目标就对不上了。正确做法是价值损失只在(T,)上算。
第三处是 minibatch 采样。摊平之后随机切 minibatch,很可能把同一时间步的智能体切散。这在共享奖励下不会出错(优势本来就一样),但如果哪天换成个体奖励,切散就会破坏时序相关性。稳妥做法是按时间步切 block,再在 block 内部展开智能体维度。
def iter_minibatch(T, N, n_minibatch=4, shuffle=True, rng=None): """按时间步分块,保证同一 t 的 N 个智能体不被切散。""" rng = rng or np.random.default_rng(0) idx = np.arange(T) if shuffle: rng.shuffle(idx) for chunk in np.array_split(idx, n_minibatch): # 展开成 (len(chunk)*N,) 的扁平索引 yield (chunk[:, None] * N + np.arange(N)[None, :]).reshape(-1)n_minibatch=4配n_steps=128、N=3,每块大约 96 个样本,对 64 隐层的小网络刚好。块太小会让梯度噪声变大,块太大则更新轮数白给。
4.2 参数共享时 log_prob 和熵必须按智能体维度求和
参数共享的 Actor 一次前向输出(T*N, act_dim)的分布参数,log_prob也是(T*N, act_dim)。策略梯度里的logp必须是每个样本一个标量,所以要按最后一维求和得到(T*N,)。熵统计同理,dist.entropy().sum(-1).mean()得到的是每个智能体每步的平均熵,这个数字要分开记。
为什么要分开记:参数共享下熵会掉得比单智能体快,因为同一套参数被 N 倍的样本同时推。熵掉到 0.1 以下基本意味着策略退化成确定性输出,这时候会看到成功率卡住不动、动作方差极小。判断是不是这个原因,直接看动作标准差log_std.exp(),如果它一路往下掉到 0.05 以下,加熵系数或者给log_std加个下界(比如clamp(-1.5, 0.5))比调奖励管用。
4.3 训练曲线该看哪四条
多智能体训练的日志不能只看回合回报,那条曲线会骗人。我会同时盯四个指标,缺一个就会误判。
团队回报是基本盘,但要用评测策略(取均值动作)跑,不是采样策略。用采样策略的回报做判断,探索噪声会让曲线看起来比实际好。
协作成功率才是真指标。回报涨成功率不涨,说明智能体在优化稠密项(靠得更近)却没学会精确到位,这时候要加大成功奖励的权重,或者收紧到位阈值做课程。
平均到位距离的最差值(上面环境里的worst_dist)能定位短板。如果最差值一直在 0.3 附近,说明总有一个智能体拖后腿,多半是某类初始位置它没见过,需要扩初始分布。
策略熵和值损失放一起看。熵掉得快、值损失不降,通常是学习率太大或者n_steps太短,优势估计噪声压过了信号。
5. 排查清单:协作训练里 5 个高频翻车现场
5.1 回报涨到一定程度就不动,成功率横在低位
现象:训练曲线前 50 轮涨得挺漂亮,之后进入平台期,成功率稳定在 0.3 到 0.4,怎么调学习率都没用。
原因:稠密距离奖励和稀疏成功奖励的尺度失衡。距离项量级大概在 0.1 到 1 之间,成功奖励只有 +1,且成功事件在训练早期几乎采不到,梯度信号被稠密项完全主导。智能体学到的局部最优是「靠近目标」,而不是「精确停在目标上」。
解决:把成功奖励提到 5 到 10,或者把稠密项乘以 0.1 降权;更稳的做法是课程学习,先把到位阈值放宽到 0.2 让成功率上到 0.6,再逐步收紧到 0.05。我一般用后者,成功率曲线的形状会平滑很多。
5.2 所有智能体做同一个动作,像复制粘贴
现象:训练几百轮后,把三个智能体的动作打出来,方向和幅度几乎一样,任务永远只能完成一个目标点。
原因:参数共享 + 观测高度相似 + 没有身份区分,三个智能体在数学上完全等价,网络输出必然相同。这不是训练不充分,是问题本身有对称性,梯度里没有任何项能让它们分开。
解决:给 Actor 加身份编码(第 3.2 节的id_enc);同时把智能体的初始位置分布打散,让每个智能体在训练早期就经历不同的观测。如果加了 ID 还是同质化,检查一下id_enc是不是被初始化的太小、跟观测维度标准差差了几个数量级,Embedding 初始化用N(0, 1)比默认的N(0, 0.02)更容易破对称。
5.3 训练曲线很好,评测一跑就废
现象:训练日志里回报稳定在高位,评测脚本跑十次九次失败。
原因:训练用采样策略(带log_std噪声),评测用确定性动作时,策略从未在无噪声条件下被优化过;或者评测环境的随机种子和训练时不同,初始位置分布超出训练覆盖范围。
解决:训练时定期用确定性策略跑一批固定种子的评测回合,把这个数字也打进日志,跟训练回报对比。如果两者差距大,说明策略过度依赖探索噪声,加一点熵正则、或者把log_std衰减得更慢。评测种子必须覆盖一整个范围,不能只用一个。
5.4 换个智能体数量就彻底不能用
现象:三个智能体训好的模型,改成四个直接报维度错误;或者勉强能跑,成功率归零。
原因:网络输入维度写死成obs_dim * N,N一变整个网络失效。这是协作任务里最常见的架构债,尤其在用全局状态拼接做评论家输入时。
解决:短期做法是用跟数量无关的观测编码——每个智能体只看自己的相对位移加 K 个最近邻目标,评论家用对所有智能体观测做 mean pooling 或注意力池化,输出维度固定。长期做法是把智能体数量当成随机变量,训练时随机丢弃一部分智能体,学到的是「可变规模」的协作策略。
5.5 训练慢得离谱,GPU 利用率长期个位数
现象:一个三智能体的小任务,跑一轮迭代要好几秒,nvidia-smi显示利用率长期在 5% 以下。
原因:环境步进是纯 Python 循环,网络又小,GPU 大部分时间在等 CPU 喂数据。多智能体把这个矛盾放大了:每步要处理 N 份观测,但网络前向的计算量没变多少。
解决:把 N 个智能体当成 batch 维一次性前向(这份代码已经这么做了),别在 Python 层 for 循环;环境侧做向量化,同时跑 8 到 16 个环境实例,采样阶段攒够一个 batch 再统一更新。小网络场景下把整个模型放 CPU 反而更快,别迷信 GPU,血泪经验。
6. 从「能跑」到「敢复现」:消融、固定种子与角色扰动测试
跑通不代表做对。一个协作训练示例值不值得拿去改,看它能不能扛住三组消融。第一组去掉身份编码,如果成功率不掉,说明任务根本没有分工需求,你的「协作」可能只是「并行」。第二组把集中式评论家换成局部评论家,成功率掉得越明显,说明任务耦合越强,CTDE 越有价值;如果几乎不掉,那这套架构就是白加复杂度。第三组把成功奖励置零只留稠密项,如果成功率还能上去,说明你的评测指标可能被稠密项污染了。
种子这件事必须较真。协作任务的方差比单智能体大得多,因为一次失败会同时惩罚 N 个策略。至少跑 5 个种子,报告均值和标准差,而不是挑一条好看的曲线。我自己的判断标准是:5 个种子里有 4 个能达到目标成功率,这个方案才算稳;只有 2 个能行,那要求里一定藏着某个对初始条件敏感的假设。
最后推荐一个我觉得最有用的验证技巧——角色扰动测试。训练完之后固定策略,把智能体的初始位置两两交换,或者把某个智能体的观测里自己的位置替换成队友的位置,再跑评测。如果成功率掉得很厉害,说明策略学的是「位置到动作」的映射,而不是「角色分工」;如果掉得不多,协作是真的成立。这个测试只要几十行代码,比看任何曲线都直观。
def role_permutation_test(env, policy, n_episodes=50, seed=0): """交换智能体的初始位置,检查策略是否依赖固定角色。""" rng = np.random.default_rng(seed) base, perm = [], [] for ep in range(n_episodes): env.rng = np.random.default_rng(seed + ep) # 两组的初始场景完全一致 obs = env.reset() base.append(run_episode(env, policy, obs, permute=None)) env.rng = np.random.default_rng(seed + ep) obs = env.reset() perm = rng.permutation(env.n) # 交换身份,场景不变 perm.append(run_episode(env, policy, obs, permute=perm)) return float(np.mean(base)), float(np.mean(perm))关键在于两组用同一个随机种子重置环境,唯一的差别是智能体身份和初始位置的对应关系。差值控制在 10% 以内,我才会认为这个策略具备角色无关的协作能力。
这套东西我从最早的「先把网络堆起来再说」,到后来每次动手前先画清楚观测切分和奖励口径,中间踩过的坑基本都写在上面几章里了。经验是:多智能体协作任务的上限,八成在第 2 章的环境设计阶段就决定了,剩下的两成交给调参和耐心。希望帮到你。
本文还有配套的精品资源,点击获取