十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MASAC多智能体强化学习:从算法原理到PyTorch工程实现与调参避坑指南

MASAC多智能体强化学习:从算法原理到PyTorch工程实现与调参避坑指南 多智能体强化学习这两年从学术圈一路火到了工程落地但凡涉及到多个智能体协同决策的场景——比如仓储机器人调度、无人机编队、交通信号协同控制、甚至游戏AI里的多英雄配合——都绕不开一个核心问题多个智能体同时学习、同时更新策略环境对每个个体来说都是非平稳的训练极容易发散。MASACMulti-Agent Soft Actor-Critic就是在这个背景下被提出来的它把最大熵强化学习的框架搬到了多智能体场景用集中训练、分散执行的思路让每个智能体在训练时能看到全局信息执行时又只依赖自己的局部观测。这篇内容我会从算法动机讲起把MASAC的核心公式、网络结构、PyTorch实现细节、训练调参的坑以及我实际跑实验时踩过的雷全部摊开讲清楚。适合已经了解单智能体强化学习基础、想往多智能体方向深入的同学也适合手里有PyTorch基础、想找一个完整多智能体项目练手的工程师。1. 为什么多智能体场景不能直接套单智能体算法1.1 环境非平稳性到底意味着什么先把最核心的矛盾说清楚。单智能体强化学习里智能体面对的环境转移概率是固定的虽然策略在变但环境本身不会因为策略变化而改变。到了多智能体场景假设有N个智能体每个智能体i的观测里包含了其他智能体的动作或状态信息那么从智能体i的视角看环境转移概率变成了 $P(s|s, a_1, a_2, ..., a_N)$而其他智能体的策略 $a_{-i} \sim \pi_{-i}$ 是在不断更新的。这就导致智能体i眼中的环境动态一直在变昨天学到的最优策略今天可能就失效了。我打个比方这就像你在一个十字路口开车如果所有其他司机的驾驶习惯都是固定的你很快就能学会最优的通过策略。但现实是其他司机也在学习、也在调整你刚摸清规律他们的行为模式又变了。这种移动靶效应就是多智能体训练不稳定的根源。1.2 独立学习为什么经常崩掉最朴素的做法是每个智能体各自跑一个单智能体算法比如各自跑一个SAC或者PPO互相把对方当成环境的一部分。这种做法叫Independent Learning实现简单但在很多任务上会直接崩。原因有两个一是上面说的非平稳性二是信用分配问题——团队获得了奖励到底是谁的贡献独立学习没法回答这个问题每个智能体只能拿到全局奖励梯度信号里混入了大量噪声。我在一个简单的合作导航任务上做过对比独立SAC在3个智能体时还能勉强收敛到5个智能体时奖励曲线就开始剧烈震荡训练5000个episode都没有稳定下来。而换成MASAC之后同样的任务大概2000个episode就能看到明显的收敛趋势。这个差距不是调参能弥补的是算法框架本身的差异。1.3 集中训练分散执行这个思路的来龙去脉CTDECentralized Training with Decentralized Execution是目前多智能体强化学习最主流的范式。核心思想是训练阶段允许使用全局信息因为训练是在仿真环境或者离线数据里做的全局信息拿得到执行阶段每个智能体只能用自己的局部观测因为实际部署时通信带宽、延迟、隐私都可能限制全局信息的获取。这个思路最早在MADDPG里被系统化提出后来QMIX、COMA、MAPPO都沿用了这个框架。MASAC本质上也是CTDE范式下的产物只不过它把SAC的最大熵思想引入了进来。理解CTDE是理解MASAC的前提因为MASAC的Critic网络设计、经验回放的组织方式、甚至损失函数的构造都是围绕这个范式展开的。2. MASAC的核心机制拆解2.1 最大熵框架给多智能体带来了什么SAC的核心是在标准RL目标上加了策略熵的项目标函数变成$$J(\pi) \sum_t \mathbb{E}{(s_t,a_t)\sim\rho\pi} \left[ r(s_t,a_t) \alpha \mathcal{H}(\pi(\cdot|s_t)) \right]$$其中 $\alpha$ 是温度系数$\mathcal{H}$ 是策略熵。这个熵项鼓励策略保持一定的随机性不要过早收敛到某个确定性动作上。在多智能体场景里这个特性特别有价值因为其他智能体在探索如果你过早变得确定性很容易陷入一个对其他智能体策略的过拟合一旦对方策略更新你就崩了。熵项相当于给每个智能体保留了一定的探索余量让整个系统在训练过程中保持活力。我在实验里观察到把 $\alpha$ 设得太小比如0.01训练前期收敛快但后期容易卡在次优解设得太大比如0.5探索充分但收敛慢。自动调节 $\alpha$ 的机制在多智能体里比单智能体更重要因为不同智能体的探索需求可能不一样。2.2 集中式Critic的输入到底怎么组织MASAC的Critic是一个集中式的Q网络输入是所有智能体的联合观测和联合动作输出是一个Q值。具体来说如果有N个智能体每个智能体的观测维度是 $o_i$动作维度是 $a_i$那么Critic的输入就是 $(o_1, ..., o_N, a_1, ..., a_N)$输出 $Q(o_1,...,o_N, a_1,...,a_N)$。这里有个细节容易搞混Critic用的是观测observation还是状态state在MADDPG里用的是全局状态但在MASAC的很多实现里直接把所有智能体的观测拼接起来当作联合观测输入。这两种做法在完全可观测环境下等价在部分可观测环境下有区别。我个人的建议是如果你的环境能拿到全局状态优先用全局状态如果只能拿到局部观测那就拼接所有智能体的观测但要注意观测维度会随智能体数量线性增长网络参数量要相应调整。Critic的输出维度是1表示这个联合动作在联合观测下的价值。每个智能体在计算策略梯度时都会用到这个共享的Critic但梯度只对自己那部分动作参数求导。这就是集中式Critic实现信用分配的方式——通过联合Q值把全局奖励的影响分解到每个智能体的动作上。2.3 策略网络如何做到分散执行每个智能体有一个独立的Actor网络输入是自己的局部观测 $o_i$输出是自己动作分布的参数。如果是连续动作空间通常输出高斯分布的均值和标准差如果是离散动作空间输出每个动作的logits。执行时每个智能体只需要自己的Actor完全不依赖其他智能体的信息这就是分散执行的含义。训练时Actor的损失函数是$$\mathcal{L}\pi \mathbb{E}{o_i \sim \mathcal{D}} \left[ \alpha \log \pi_i(a_i|o_i) - Q(o_1,...,o_N, a_1,...,a_N) \right]$$注意这里的Q是集中式Critic的输出输入包含了所有智能体的观测和动作。Actor在更新时只对自己的策略参数求梯度但Q值里包含了其他智能体的信息所以梯度信号里隐含了其他智能体的影响。这就是MASAC实现协同的核心机制。2.4 双Critic与目标网络在多智能体里的作用MASAC沿用了SAC的双Critic结构用两个Q网络取最小值来缓解Q值高估。在多智能体场景里Q值高估的问题比单智能体更严重因为联合动作空间随智能体数量指数增长Critic的泛化误差更大。双Critic相当于给Q值估计加了一个下界约束虽然不能完全消除高估但能显著缓解。目标网络的作用是稳定TD目标。MASAC里通常有四个网络两个Critic网络、两个目标Critic网络加上每个智能体一个Actor网络。目标网络的更新用软更新$$\theta_{target} \leftarrow \tau \theta (1-\tau) \theta_{target}$$$\tau$ 一般取0.005到0.01。在多智能体里$\tau$ 取小一点更稳因为环境本身在变目标网络更新太快会让TD目标抖动加剧。我试过 $\tau0.05$训练曲线明显比 $\tau0.005$ 更毛躁。3. PyTorch实现的关键细节3.1 网络结构的模块化设计先给一个我实际用的网络结构设计。Actor网络用两层全连接每层256个隐藏单元激活函数用ReLU。输出层分两路一路输出均值一路输出log_stdlog_std要clamp到[-20, 2]之间防止数值溢出。Critic网络输入是联合观测和联合动作的拼接也是两层256单元的全连接输出一个标量Q值。import torch import torch.nn as nn import torch.nn.functional as F class Actor(nn.Module): def __init__(self, obs_dim, act_dim, hidden_dim256): super().__init__() self.fc1 nn.Linear(obs_dim, hidden_dim) self.fc2 nn.Linear(hidden_dim, hidden_dim) self.mean nn.Linear(hidden_dim, act_dim) self.log_std nn.Linear(hidden_dim, act_dim) def forward(self, obs): x F.relu(self.fc1(obs)) x F.relu(self.fc2(x)) mean self.mean(x) log_std torch.clamp(self.log_std(x), -20, 2) return mean, log_std def sample(self, obs): mean, log_std self.forward(obs) std log_std.exp() normal torch.distributions.Normal(mean, std) x_t normal.rsample() action torch.tanh(x_t) log_prob normal.log_prob(x_t) - torch.log(1 - action.pow(2) 1e-6) log_prob log_prob.sum(dim-1, keepdimTrue) return action, log_prob class Critic(nn.Module): def __init__(self, joint_obs_dim, joint_act_dim, hidden_dim256): super().__init__() self.fc1 nn.Linear(joint_obs_dim joint_act_dim, hidden_dim) self.fc2 nn.Linear(hidden_dim, hidden_dim) self.q_out nn.Linear(hidden_dim, 1) def forward(self, joint_obs, joint_act): x torch.cat([joint_obs, joint_act], dim-1) x F.relu(self.fc1(x)) x F.relu(self.fc2(x)) return self.q_out(x)这里有个细节值得说动作用tanh压缩到[-1,1]之后log_prob需要做相应的修正减去 $\log(1-a^2)$ 的项。这个修正很多实现里会漏掉导致策略熵计算错误训练效果打折扣。我在早期实现里就犯过这个错后来对比官方实现才发现。3.2 经验回放池的组织方式多智能体的经验回放池和单智能体最大的区别是存储的是联合转移 $(o_1,...,o_N, a_1,...,a_N, r, o_1,...,o_N, done)$。采样的时候一个batch里每个样本都是完整的联合转移。这样Critic训练时可以直接拿到联合观测和联合动作不需要额外拼接。class ReplayBuffer: def __init__(self, capacity, num_agents, obs_dim, act_dim): self.capacity capacity self.ptr 0 self.size 0 self.num_agents num_agents self.obs np.zeros((capacity, num_agents, obs_dim), dtypenp.float32) self.act np.zeros((capacity, num_agents, act_dim), dtypenp.float32) self.rew np.zeros((capacity, 1), dtypenp.float32) self.next_obs np.zeros((capacity, num_agents, obs_dim), dtypenp.float32) self.done np.zeros((capacity, 1), dtypenp.float32) def push(self, obs, act, rew, next_obs, done): self.obs[self.ptr] obs self.act[self.ptr] act self.rew[self.ptr] rew self.next_obs[self.ptr] next_obs self.done[self.ptr] done self.ptr (self.ptr 1) % self.capacity self.size min(self.size 1, self.capacity) def sample(self, batch_size): idx np.random.randint(0, self.size, sizebatch_size) return (self.obs[idx], self.act[idx], self.rew[idx], self.next_obs[idx], self.done[idx])注意奖励的存储。如果是完全合作任务所有智能体共享同一个全局奖励那rew就是一个标量。如果是混合任务每个智能体有自己的奖励那rew的维度应该是(num_agents, 1)。MASAC的原始论文主要针对完全合作任务但扩展到混合任务也不难只需要在Critic输出时对每个智能体分别输出Q值即可。3.3 损失函数的逐项拆解Critic的损失是标准的TD误差def critic_loss(critic, target_critic, actor, batch, gamma, alpha): obs, act, rew, next_obs, done batch with torch.no_grad(): next_act, next_log_prob actor.sample(next_obs) target_q1 target_critic(next_obs, next_act) target_q2 target_critic(next_obs, next_act) target_q torch.min(target_q1, target_q2) - alpha * next_log_prob target rew gamma * (1 - done) * target_q q1 critic(next_obs, act) q2 critic(next_obs, act) loss F.mse_loss(q1, target) F.mse_loss(q2, target) return lossActor的损失def actor_loss(critic, actor, obs): act, log_prob actor.sample(obs) q1 critic(obs, act) q2 critic(obs, act) q torch.min(q1, q2) loss (alpha * log_prob - q).mean() return loss温度系数的损失自动调节alphadef alpha_loss(log_alpha, log_prob, target_entropy): loss -(log_alpha * (log_prob target_entropy).detach()).mean() return losstarget_entropy一般设为 -act_dim这是SAC论文里的经验值。在多智能体里我建议把target_entropy设得稍微大一点绝对值小一点比如 -act_dim * 0.8让策略保持更多探索。3.4 训练循环的完整骨架for episode in range(max_episodes): obs env.reset() episode_reward 0 for step in range(max_steps): actions [] for i in range(num_agents): with torch.no_grad(): act, _ actors[i].sample(torch.FloatTensor(obs[i]).unsqueeze(0)) actions.append(act.squeeze(0).numpy()) actions np.array(actions) next_obs, rewards, dones, _ env.step(actions) global_reward np.sum(rewards) buffer.push(obs, actions, global_reward, next_obs, dones[0]) obs next_obs episode_reward global_reward if buffer.size batch_size: batch buffer.sample(batch_size) # 更新Critic c_loss critic_loss(critic, target_critic, actor, batch, gamma, alpha) critic_optimizer.zero_grad() c_loss.backward() critic_optimizer.step() # 更新Actor a_loss actor_loss(critic, actor, batch[0]) actor_optimizer.zero_grad() a_loss.backward() actor_optimizer.step() # 更新Alpha al_loss alpha_loss(log_alpha, log_prob, target_entropy) alpha_optimizer.zero_grad() al_loss.backward() alpha_optimizer.step() # 软更新目标网络 soft_update(target_critic, critic, tau)这个骨架是最简版本实际工程里还需要加梯度裁剪、学习率调度、日志记录等。梯度裁剪在多智能体里特别重要因为联合Q值的梯度可能很大不裁剪容易炸。4. 训练调参里那些文档不会写的事4.1 学习率与批大小的搭配关系MASAC对学习率比较敏感。我试过的组合里Actor学习率1e-4到3e-4、Critic学习率3e-4到1e-3是比较稳的区间。Critic学习率通常要比Actor大一点因为Critic需要更快地拟合Q值。批大小建议至少256太小的话梯度方差大训练曲线会很毛躁。我用128的批大小跑过收敛需要的episode数几乎是256的两倍。还有一个容易忽略的点如果智能体数量增加批大小也要相应增加。因为联合动作空间的维度变大了Critic需要更多的样本来覆盖这个空间。5个智能体时我一般用512的批大小10个智能体时用1024。4.2 奖励尺度对训练稳定性的影响奖励尺度是另一个大坑。如果奖励范围是[0, 1]那基本不用动如果奖励范围是[0, 100]甚至更大一定要做归一化或者缩放。我见过有人直接用原始奖励跑结果Q值爆炸训练完全没法进行。做法很简单在环境wrapper里把奖励除以一个常数或者用running mean/std做标准化。在多智能体合作任务里还有一个细节全局奖励是所有智能体奖励的和如果智能体数量多全局奖励的尺度会随智能体数量线性增长。这时候要么把全局奖励除以智能体数量要么把奖励缩放因子调大。我一般倾向于除以智能体数量这样不同规模的任务之间奖励尺度可比。4.3 探索噪声与熵系数的平衡MASAC本身通过熵项做探索但实际训练时前期往往还需要额外的探索噪声。我的做法是在训练前N个episode比如前1000个给动作加高斯噪声噪声标准差从0.3线性衰减到0.05。这个技巧在稀疏奖励任务里特别有用能帮智能体更快地碰到有奖励的状态。熵系数alpha的自动调节机制在多智能体里有时候会失灵。如果所有智能体的策略熵都很快降到target_entropy以下alpha会一直增大导致策略过于随机。我遇到过这种情况解决办法是给alpha设一个上界比如1.0或者用多个智能体共享一个alpha但分别计算log_prob。4.4 目标网络更新频率的取舍前面提过tau的取值这里再展开说一下。tau越小目标网络越稳定但TD目标更新越慢学习效率低tau越大学习快但容易不稳定。在多智能体里我建议从tau0.005开始试如果训练曲线震荡厉害就降到0.001如果收敛太慢就升到0.01。不要超过0.02否则基本都会崩。还有一个技巧是延迟更新目标网络比如每更新Critic两次才更新一次目标网络。这个技巧在单智能体SAC里就有在多智能体里效果更明显因为环境非平稳性让TD目标本身就不稳降低目标网络更新频率能进一步稳定训练。5. 实测中遇到的典型问题与排查路径5.1 训练完全不收敛的排查顺序遇到训练完全不收敛我一般按这个顺序排查第一步检查环境接口。多智能体环境的reset和step返回格式容易搞错特别是观测的维度、奖励的维度、done的维度。我写过一个检查脚本跑一个随机策略打印每一步的观测形状、奖励值、done标志确认环境没问题再开始训练。第二步检查网络输出范围。Actor输出的动作是否在环境接受的范围内如果环境要求动作在[-1,1]但网络输出没有经过tanh那动作可能超出范围环境会截断或者报错。Critic输出的Q值范围是否合理如果Q值一开始就是几百上千那说明初始化或者奖励尺度有问题。第三步检查损失值。Critic损失是否在下降如果Critic损失一直很大或者NaN那可能是学习率太大或者奖励尺度太大。Actor损失是否在合理范围Actor损失通常是负的因为它是alphalog_prob - QQ一般比alphalog_prob大。第四步检查经验回放。buffer里的数据是否正常有没有出现全零或者全NaN的情况我遇到过一次因为环境返回的观测里有inf导致整个buffer被污染训练直接崩。5.2 奖励上升后又突然崩掉的原因这种情况通常是策略崩溃。原因可能有几个一是Critic过拟合了早期数据Q值估计偏高Actor被误导去追求一个实际上不存在的高的Q值二是其他智能体的策略发生了突变导致环境动态剧烈变化当前策略失效三是alpha调节失控策略变得过于随机或者过于确定。对应的解决办法增加Critic的更新频率或者增大批大小缓解过拟合降低Actor学习率让策略更新更平滑给alpha加上下界约束。我一般还会加一个策略熵的监控如果熵突然掉到很低说明策略在快速变得确定这时候要警惕。5.3 智能体之间出现搭便车现象怎么处理搭便车是指部分智能体不干活靠其他智能体的贡献拿奖励。这在合作任务里很常见特别是奖励是全局共享的时候。MASAC本身没有显式的机制来惩罚搭便车但可以通过调整奖励分配来缓解。一种做法是用difference reward每个智能体的奖励设为全局奖励减去它不参与时的全局奖励这样每个智能体的奖励就反映了它的边际贡献。另一种做法是在Critic里对每个智能体输出单独的Q值然后用各自的Q值更新各自的Actor这样每个智能体只对自己的贡献负责。我在一个4智能体的合作任务里试过difference reward搭便车现象明显减少但训练前期收敛慢一些因为difference reward的方差更大。5.4 从仿真到实际部署的gap仿真里训练好的策略直接部署到实际系统上性能往往会下降。原因有几个一是仿真和实际的动态有差异比如延迟、摩擦、噪声二是实际系统里其他智能体的策略可能和仿真里不一样三是观测在实际系统里可能有噪声或者缺失。缓解办法在仿真里加domain randomization随机化环境参数让策略对参数变化更鲁棒在训练后期加入观测噪声让策略适应有噪声的观测如果可能在实际系统上做少量fine-tune用实际数据继续训练。我在一个机器人编队任务里仿真训练的策略直接部署成功率只有60%左右加了domain randomization之后提升到85%。6. 几个能直接抄的工程优化技巧6.1 用共享参数减少网络数量如果所有智能体的观测和动作维度相同可以让所有智能体共享一个Actor网络和一套Critic网络。共享参数的好处是样本效率高因为所有智能体的经验都用来更新同一套参数。坏处是智能体之间无法差异化如果任务需要异构策略共享参数就不合适。我的经验是同构任务比如所有机器人型号相同优先用共享参数异构任务比如不同型号的机器人用独立参数。共享参数时Actor的输入里最好加上智能体ID的one-hot编码让网络能区分不同智能体。6.2 优先经验回放的轻量实现优先经验回放PER在单智能体里很有效在多智能体里也能用但实现要轻量。我的做法是用TD误差的绝对值作为优先级但不用sum tree直接用numpy的random.choice按概率采样。这样实现简单性能损失不大。def sample_with_priority(self, batch_size, alpha0.6): priorities np.abs(self.td_errors) ** alpha probs priorities / priorities.sum() idx np.random.choice(self.size, batch_size, pprobs) weights (self.size * probs[idx]) ** (-0.4) weights weights / weights.max() return idx, weights注意重要性采样权重要乘到损失上否则会引入偏差。6.3 梯度裁剪与参数初始化梯度裁剪在多智能体里几乎是必须的。我一般用torch.nn.utils.clip_grad_norm_max_norm设0.5到1.0。参数初始化用正交初始化增益设sqrt(2)这对ReLU网络效果比较好。def init_weights(m): if isinstance(m, nn.Linear): nn.init.orthogonal_(m.weight, gainnp.sqrt(2)) nn.init.constant_(m.bias, 0.0)输出层的初始化要特别注意。Actor的均值输出层用小的增益比如0.01让初始动作接近0log_std输出层的bias初始化为0让初始标准差接近1。Critic的输出层也用小的增益让初始Q值接近0。6.4 训练日志该记录什么训练日志不是记给自己看的是记给未来的自己或者队友看的。我一般记录这些每个episode的全局奖励、每个智能体的单独奖励、策略熵、alpha值、Critic损失、Actor损失、Q值的均值和最大值、梯度范数。这些指标能帮你快速定位问题。如果条件允许用TensorBoard或者Weights Biases做可视化。曲线比数字直观得多训练不收敛的时候看一眼曲线就知道是震荡、发散还是停滞。7. 从MASAC出发还能往哪走MASAC不是终点它更像是一个扎实的baseline。如果你想继续深入有几个方向值得探索。一是通信机制MASAC本身没有显式的通信智能体之间通过集中式Critic间接共享信息但执行时完全不通信。如果你的场景允许通信可以加入显式的消息传递比如用attention机制让智能体选择性地接收其他智能体的信息。二是异构智能体MASAC假设所有智能体的观测和动作空间相同异构场景需要扩展。三是离线多智能体强化学习用离线数据训练MASAC这在数据采集成本高的场景里很有价值。我自己最近在试的是把Transformer引入Critic用attention来聚合其他智能体的信息而不是简单拼接。初步结果看在智能体数量多的时候10个以上attention版本比拼接版本收敛更快因为拼接版本的输入维度太高Critic很难学好。这个方向还在实验阶段等有更稳定的结果再分享。最后说一个我踩过的坑不要一上来就调最复杂的配置。我刚开始做多智能体的时候总想着一步到位用最大的网络、最复杂的技巧结果训练不收敛排查了半天发现是学习率设大了。后来学乖了先用最简单的配置跑通确认baseline能收敛再逐步加技巧。这个习惯帮我省了很多时间。
返回列表