简介:面向希望系统学习多智能体强化学习的高校学生与开发者,这份基于Python与MADDPG的多智能体博弈对抗算法资源,完整覆盖算法搭建、训练与测试流程,可直接用于毕业设计、课程设计、工程实训或初期项目立项。压缩包共含14个文件,其中10个Python脚本构成核心代码,分别实现网络结构定义、DDPG与MADDPG智能体构建、经验回放缓冲区、训练主循环、测试环境交互等模块;同时附带README说明文档、配置文件及虚拟环境配置信息,方便快速还原运行环境。整个压缩包仅19KB,代码量不大但结构完整,便于逐行阅读与二次开发。目前已有266人学习,适合希望从零理解多智能体博弈机制的入门者或中高级学习者。读者可通过这些脚本搭建自己的对抗实验,观察智能体在竞争与合作环境中的策略演化,并在此基础上修改奖励函数或环境逻辑,扩展出更强的自定义博弈算法。
1. 多智能体博弈对抗,为什么简单复制单智能体策略会全线翻车
设一个场景:三架无人机围堵一架入侵机。你把单智能体的 DDPG 复制成三个独立进程去跑,结果经常是——三架无人机先乱撞一气,然后集体躺平,在原地转圈。原因不复杂:多智能体强化学习里,每个智能体面对的“环境”都包含其他智能体的策略,而别人的策略一直在变,单智能体那套“环境稳定”假设被直接打破。MADDPG 算法就是冲这个痛点来的。它保留 DDPG 在连续控制上的优势,用“中心化训练、去中心化执行”的思路,让训练阶段每个智能体能看到他人的状态和动作,部署时却只依赖自身局部观测。这篇文章会从算法原理、环境搭建、PyTorch 落地到训练踩坑,把一条可复现的博弈对抗训练路线完整走一遍。适合刚开始做多智能体强化学习的同学,也适合想把现有单智能体代码升级改造的工程师。
2. 读懂 MADDPG 的核心思想:中心化训练、去中心化执行,改的是什么
2.1 先理解 DDPG,再看 MADDPG 改了什么
DDPG 是单智能体连续控制的经典方案:actor 网络根据状态输出动作,critic 网络评估“状态 + 动作”的价值。训练时 critic 给 actor 提供梯度方向,actor 负责实际决策。它在机械臂、自动驾驶等单智能体连续控制任务上表现稳定,到了多智能体对抗场景却会出问题。根本原因是每个智能体的 reward 不只取决于自己的动作,还取决于对手和队友的当前策略;当队友的策略随训练不断更新,“环境”对任意单个智能体都是非平稳的。
非平稳性对经验回放的伤害尤其明显。DDPG 依赖经验回放,假设过去采样的经验仍然能代表当前环境分布,但多智能体对抗中,对手策略一变,旧经验的奖励含义就过时了,直接拿去更新会让价值估计左右摇摆。MADDPG 的解法很直接:每个智能体训练时,把环境中所有智能体的观测和动作都喂给自己的 critic,让价值函数在“看得见全局”的前提下做评估,从而把其他智能体策略变化带来的非平稳性,部分转化成了可观测的输入变量。这就是它和 DDPG 最本质的区别。
2.2 critic 看全局、actor 看局部:为什么这是对抗场景下的正确折衷
先明确一点,MADDPG 并不是一个全局策略算法,而是一组“每个智能体一套 actor-critic”的结构。训练阶段,第 i 个智能体的 critic 输入是环境中所有智能体的观测拼接、所有智能体的动作拼接;而它的 actor 输入只有自己的观测。两者结构差异可以这样对比:
| 结构项 | 单智能体 DDPG | MADDPG |
|---|---|---|
| actor 输入 | 自身观测 o | 自身观测 o_i |
| critic 输入 | 自身观测 + 自身动作 | 全部观测 [o_1,...,o_N] + 全部动作 [a_1,...,a_N] |
| 目标网络数量 | actor、critic 各 1 个 | 每智能体 actor、critic 各 1 个目标网络 |
| 部署时 | actor 可用 | 只部署 actor,critic 留在训练端 |
critic 看全局能带来两个实际好处。第一,在对抗中,对手动作被显式作为输入,Q 值拟合时不用再去“猜测”对手下一步会干什么,对手策略更新造成的影响被纳入了输入维度,经验回放的失效问题大幅缓解。第二,在协作对抗混合场景里,队友动作也在 critic 输入里,价值评估能正确反映“配合”带来的增益,而不是把队友造成的奖励变化误判成环境噪声。
actor 保持局部观测则是一个工程折衷。在真实对抗系统里,部署时往往没有全局通信条件:链路可能丢包、带宽有限、某些观测天然不可共享。如果策略依赖全局状态,部署时就会因为信息缺失而直接失灵。MADDPG 把“全局信息”只留在训练端,部署时每个智能体靠自己的局部观测独立决策,这符合绝大多数实际系统的约束。很多初学着看到 CTDE 就以为算法能集中决策,这是最常见的误解——它只是集中地“学”,并不要集中地“做”。
2.3 目标网络与软更新:参数怎么设才不激进
MADDPG 的更新逻辑沿袭 DDPG:每个智能体都有主网络和目标网络两组参数。计算 TD 目标时,下一时刻的 Q 值用目标网络算,避免自举式更新导致的价值发散。目标值的表达式是:
y = r + γ × Q_target(全部下一观测, 全部目标动作)
这里的“目标动作”不是主网络输出的动作,而是每个智能体各自目标 actor 网络输出的动作。我见过不少实现图省事,直接用当前 actor 输出算下一时刻动作,这样会让目标值里同时包含两份在更新的参数,梯度被持续注入偏置,收敛会慢很多。正确做法是目标 actor 输出后接 detach,切断梯度回传。
软更新参数 tau 是第二个容易踩的坑。常见做法是每训练一步就把目标网络参数向主网络滑动一点,tau 取 0.01 起步。在多智能体对抗里,我一般取 tau=0.01,同时把更新频率从“每步”改成“每 100 步同步一次”,相当于兼顾平滑和稳定。如果 Q 值曲线像锯齿一样剧烈抖动,把 tau 降到 0.005,并把同步间隔拉大到 200 步。
注意:目标网络参数不是复制越勤越好。多智能体场景里每个 critic 都耦合了所有智能体的策略,某个智能体策略突变会被同步放大到其他人的目标值里,表现为集体振荡。软更新参数宁可保守。
另外要强调的是,MADDPG 并没有修改 DDPG 的另一个基础组件——探索。训练时 actor 输出的动作要叠加噪声,常见做法是高斯噪声或 Ornstein-Uhlenbeck 噪声。对抗环境下 OU 噪声的时序相关性有助于维持探索的连贯性,但参数不好调;我实际用下来,简单的高斯噪声配合噪声方差衰减,在大多数仿真环境里已经够用,也更少出现调参玄学。
3. 环境与博弈配置:用 MPE 搭一个可训练的对抗环境
3.1 选哪个环境:simple_tag 里天然带对抗与追逐博弈
算法学习不能只在 gym 的连续控制环境里跑,那里压根没有第二个对手。做 MADDPG 博弈对抗入门,最常用的是 OpenAI 早期开源的 multiagent-particle-envs,简称 MPE。它是一组 2D 粒子世界环境,虽然物理精度不高,但动作连续、观测可控、训练速度快,非常适合先验证多智能体算法逻辑再迁移到重仿真场景。
MPE 里有几个环境,选型时要分清楚:simple_spread 是纯协作,多个智能体分散覆盖地标,里面没有对手,不适合验证对抗;simple_adversary 是“1 个追踪者 vs 2 个被追者”的不对称博弈,适合练手;simple_tag 则是最经典的追逐对抗:追捕方若干、猎物若干,还有一块障碍区域,每个 agent 都能施加连续加速度。做 2v2 或 3v3 对称对抗,我通常从 simple_tag 改起。
选这个环境的理由很实际。一是它足够小,一次完整训练几分钟到十几分钟就能看出策略有没有分化,迭代速度快。二是它的观测是“相对位置 + 相对速度 + 自身信息”这种低维向量,便于排查 Q 值、奖励曲线等训练指标,比一上来就上 AirSim、Isaac 这类重型仿真器好定位问题。等算法在 MPE 上真的学出追逐和逃跑策略,再迁移到高保真环境,是效率最高的技术路线。
3.2 多智能体如何配置:最小安装与依赖版本
MPE 的安装不算复杂,但版本兼容问题容易卡住新手。这个环境包依赖较早版本的 gym 接口,直接用最新 gym 运行常会报 API 不匹配。常见的做法是 clone 源码后,在虚拟环境里以可编辑模式安装,之后如果跑出 gym 相关报错,就说明依赖版本偏新,需要把 gym 降到老版本。我第一次跑的时候,问题就出在 gym 新老接口的差异上,折腾了一个多小时才定位到,所以这步值得提前注意。
一个更省事的配置做法是:先把环境目录放到项目文件夹里,直接在代码里 import,避免全局安装带来依赖污染。下面的命令是最小配置路径:
# 创建虚拟环境避免污染系统 Python python3 -m venv maddpg_env source maddpg_env/bin/activate # 安装 torch 和基本依赖 pip install torch numpy # 从源码安装 MPE(在项目目录下执行) git clone https://github.com/openai/multiagent-particle-envs.git cd multiagent-particle-envs pip install -e .参数说明:虚拟环境是这里最重要的一步,多智能体项目后续会频繁改依赖版本,没有虚拟环境隔离,系统级 Python 会被搞得一团糟。-e表示可编辑安装,环境包源码在 project 目录中改动后立即生效,方便临时修改环境逻辑,比如改奖励函数。
安装完成后,用一段脚本确认环境能正常加载:
# test_env.py from multiagent.environment import MultiAgentEnv import multiagent.scenarios as scenarios # 加载 simple_tag 场景 scenario = scenarios.load("simple_tag.py").Scenario() world = scenario.make_world() env = MultiAgentEnv( world, scenario.reset_world, scenario.reward, scenario.observation, info_callback=scenario.info, ) obs_n = env.reset() print("智能体数量:", env.n) print("观测维度:", [o.shape[0] for o in obs_n]) print("动作空间:", env.action_space[0])这段代码只做一件事:确认环境对象能不能正常创建。环境数量env.n打印出来应该是场景中的智能体总数,观测维度每个智能体可能不同,这取决于场景各自的观测函数定义。动作空间打印出的类型决定了后续网络输出层怎么设计。如果这段脚本直接报错,先检查 gym 版本,再看 Python 版本是否过新,而不是去怀疑算法代码。
3.3 观测空间与动作空间:写 reward 前先把坐标算明白
MPE 的动作空间默认是离散的 5 个动作:上下左右不动。但 MADDPG 的 actor 输出层通常接 Tanh,输出连续值在 [-1, 1]。两个对不上时,常见的做法是写一个动作映射层:把连续动作映射到离散动作区间。例如 [-1, -0.5) 映射为向左、[-0.5, 0.5) 映射为不动、(0.5, 1] 映射为向右。更顺手的做法是直接改场景,把动作空间改成连续力控制,这样 MADDPG 原生的连续动作输出就能直接用。
我建议走连续动作这条路,原因有两个。一是省掉映射层的精度损失,离散映射会让细小的策略差异被抹平;二是后续迁移到真实机器人或无人机仿真时,控制量本来就是连续的,提前用连续动作训练,策略迁移性更好。改法不复杂,在场景的make_world里把 mover agent 的action_space从离散改成连续即可。
观测空间则要看场景的observation函数。simple_tag 里每个智能体的观测大致包括自己的相对位置、相对速度、标记,以及与其他实体的相对坐标。训练前一定要打印一份实际观测向量,逐段搞明白每个维度是什么。我踩过最典型的坑是:reward 里用了“距离差”做目标,但观测里给的是相对坐标,网络要自己学习“距离 = 坐标差的范数”这个映射。这个映射不是学不会,而是会拖慢收敛。不如直接在 reward 和观测里都用距离,让网络把精力放在策略上而不是解算术。
一个稳定的 reward 设计方案是分层:给追逐方设置“接近猎物给正奖励 + 持续贴近保持不动给正奖励 + 出界惩罚”;给猎物设置“远离追捕者给正奖励 + 被抓住给大惩罚”。注意奖励幅度不要跨数量级,距离奖励量级在 0.1 左右、捕获惩罚在 -5 左右,这样一个量级内的奖励设计,Q 值不会因为方差过大而爆炸。
4. PyTorch 落地:MADDPG 训练主循环的四个关键代码步骤
4.1 网络结构:两层 MLP 起步,先别上复杂结构
很多初学者一上来就给 actor 加注意力机制、给 critic 加图神经网络,结果收敛更差。做 MADDPG 博弈对抗,我的经验是最稳的结构就是两层 MLP:actor 中间层 128,critic 中间层 128。先把这条基线跑通,再根据任务复杂度逐步加深。下面是最小可用的网络定义:
import torch import torch.nn as nn class Actor(nn.Module): """每个智能体一套,输入只看自己的观测""" def __init__(self, obs_dim, act_dim, hidden=128): super().__init__() self.net = nn.Sequential( nn.Linear(obs_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, act_dim), nn.Tanh(), # 输出限制在 [-1, 1],匹配连续动作范围 ) def forward(self, obs): return self.net(obs) class Critic(nn.Module): """训练阶段用,输入所有智能体的观测和动作""" def __init__(self, n_agents, obs_dim, act_dim, hidden=128): super().__init__() input_dim = (obs_dim + act_dim) * n_agents self.net = nn.Sequential( nn.Linear(input_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, 1), ) def forward(self, obs_all, act_all): x = torch.cat([obs_all, act_all], dim=-1) return self.net(x)逻辑说明:actor 输出层用 Tanh 把动作限制在 [-1, 1],这正好匹配 MPE 连续动作空间的边界。如果动作空间范围不是 [-1, 1],可以在环境侧做 scale,而不是改网络的激活函数。critic 把“所有观测”和“所有动作”拼成一个大向量,这个拼接发生在特征维度上,所以每个智能体的 obs 和 act 都要先展平再拼接。
参数说明:hidden=128是起步值,对绝大多数 MPE 级别的任务足够;如果任务状态维度特别高,可以先翻倍到 256,而不是直接加网络层数。激活函数用 ReLU 即可,不需要 Gelu 这类花活,多智能体训练稳定性瓶颈通常不在激活函数。
4.2 经验回放:多智能体数据的结构比容量更重要
多智能体的经验回放和单智能体有一个关键区别:一条经验必须同时保存所有智能体的观测、动作、奖励,否则更新时 critic 拿不齐全局输入。很多直接把单智能体经验回放搬过来的实现,只存了当前智能体的数据,训练时其他智能体的历史数据全是乱的,导致 critic 输入格式对不上。
import numpy as np from collections import deque class ReplayBuffer: def __init__(self, capacity=100000): self.buffer = deque(maxlen=capacity) def push(self, obs_n, act_n, rew_n, next_obs_n, done_n): # 一次性存入所有智能体的数据 self.buffer.append((obs_n, act_n, rew_n, next_obs_n, done_n)) def sample(self, batch_size): batch = np.random.choice(len(self.buffer), batch_size, replace=False) # 按智能体维度拆开,每个智能体拿到自己的一批数据 obs_n = [[] for _ in range(len(self.buffer[0][0]))] act_n = [[] for _ in range(len(self.buffer[0][1]))] rew_n = [[] for _ in range(len(self.buffer[0][2]))] next_obs_n = [[] for _ in range(len(self.buffer[0][3]))] done_n = [[] for _ in range(len(self.buffer[0][4]))] for i in batch: o, a, r, no, d = self.buffer[i] for j in range(len(o)): obs_n[j].append(o[j]) act_n[j].append(a[j]) rew_n[j].append(r[j]) next_obs_n[j].append(no[j]) done_n[j].append(d[j]) return ([np.array(x) for x in obs_n], [np.array(x) for x in act_n], [np.array(x) for x in rew_n], [np.array(x) for x in next_obs_n], [np.array(x) for x in done_n])逻辑说明:push这次把整条多智能体经验存进去,而不是只存某个智能体的;sample返回的是按智能体索引拆分后的列表,每个元素形状为(batch_size, obs_dim)。更新时访问obs_n[i]就拿到第 i 个智能体的一批观测。
参数说明:capacity=100000是中小型对抗任务的起步值,太小会让经验分布频繁被新策略覆盖,失去回放的意义;太大则老样本占比过高,策略更新后旧经验价值失真。跑一次训练如果发现前中期 Q 值摆动剧烈,优先检查 buffer 容量是不是设太小。batch_size我建议取 512 或 1024,比单智能体常用的 64 大一个量级,因为 critic 的输入维度更高,小 batch 的梯度方差更大。
4.3 更新公式的代码化:actor 的梯度到底从哪来
这一节是整个实现里最容易写错的部分。MADDPG 更新分两步:先更新 critic,再更新 actor。critic 的 loss 是 TD 误差,actor 的 loss 则不是常见的策略梯度形式,而是“最大化当前状态下被 critic 打分的 Q 值”。换句话说,actor 的梯度来自 critic 对自己输出动作的评分:
def update(agent_id, replay_buffer, batch_size, actors, critics, target_actors, target_critics, optimizers_actor, optimizers_critic, gamma=0.95, tau=0.01): obs_n, act_n, rew_n, next_obs_n, done_n = replay_buffer.sample(batch_size) # 当前智能体自己的数据 obs = torch.FloatTensor(obs_n[agent_id]) act = torch.FloatTensor(act_n[agent_id]) rew = torch.FloatTensor(rew_n[agent_id]).unsqueeze(-1) next_obs = torch.FloatTensor(next_obs_n[agent_id]) done = torch.FloatTensor(done_n[agent_id]).unsqueeze(-1) # 用目标 actor 生成所有智能体的下一个动作 next_act_n = [] for j in range(len(actors)): next_act_j = target_actors[j](torch.FloatTensor(next_obs_n[j])).detach() next_act_n.append(next_act_j) obs_all = [torch.FloatTensor(o) for o in obs_n] act_all = [torch.FloatTensor(a) for a in act_n] next_obs_all = [torch.FloatTensor(o) for o in next_obs_n] # ========= 更新 critic ========= target_q = target_critics[agent_id](next_obs_all, next_act_n).detach() y = rew + gamma * (1 - done) * target_q q_current = critics[agent_id](obs_all, act_all) critic_loss = ((q_current - y) ** 2).mean() optimizers_critic[agent_id].zero_grad() critic_loss.backward() optimizers_critic[agent_id].step() # ========= 更新 actor ========= # 用当前 actor 替换掉自己原来的动作 act_all[agent_id] = actors[agent_id](obs) q_actor = critics[agent_id](obs_all, act_all) actor_loss = -q_actor.mean() optimizers_actor[agent_id].zero_grad() actor_loss.backward() optimizers_actor[agent_id].step() return critic_loss.item(), actor_loss.item()逻辑说明:更新 critic 时,y里的 target Q 值完全用目标网络计算并 detach,防止梯度穿回目标网络。更新 actor 时,act_all[agent_id]被替换成当前 actor 的输出,然后整体送给 critic 打分;对actor_loss反向传播时,梯度会流经 critic 网络再回到 actor,这就是“critic 指导 actor”的含义。其他智能体的动作在这次更新里保持采样值不变,这体现的是 MADDPG“站在别人真实动作上评估自己动作价值”的思想。
参数说明:gamma=0.95适合步数较短、回合结束快的对抗博弈;如果任务是长程围捕,可以提到 0.99。但不要在高 gamma 下配合稀疏奖励直接训,前期的 Q 值会因为远期回报方差过大而飘。我一般把 gamma 和回合长度挂钩:回合长度越长,gamma 越接近 1。
4.4 目标网络软更新:一条命令的事,别省略
目标网络更新有两种写法:硬复制(每 N 步直接赋值)和软更新(每步滑动混合)。多智能体对抗里我坚持用软更新,因为多个智能体的目标网络同时硬复制,会造成目标值跳跃式变化,这种跳跃在多智能体相互耦合的情况下会被放大成集体振荡:
def soft_update(target_net, net, tau): for target_param, param in zip(target_net.parameters(), net.parameters()): target_param.data.copy_( tau * param.data + (1.0 - tau) * target_param.data ) # 每个智能体更新后调用一次 for agent_id in range(n_agents): soft_update(target_actors[agent_id], actors[agent_id], tau=0.01) soft_update(target_critics[agent_id], critics[agent_id], tau=0.01)参数说明:tau=0.01意味着每步只把主网络参数向目标网络移动 1%,目标网络的变化足够平滑。实际项目中我常用另一个版本:每 100 步把上面这段soft_update循环执行一次,每次 tau 取 0.05。这样既控制了更新频率,又保持了平滑度,比每步都软更新省计算资源,效果反而更稳。
训练结束时还有一个容易被忽略的细节:actor 网络保存时,要把训练时叠加的噪声去掉后再保存一轮“干净版本”。如果直接保存正在带噪声探索的 actor,评估时会发现策略表现明显偏差,因为噪声已经把动作污染了。保存前先eval()模式跑一遍推理,再存权重文件。
torch.save(actors[0].state_dict(), "actor_0.pt") torch.save(actors[1].state_dict(), "actor_1.pt") # 加载时同样先建网络再 load_state_dict加载模型时注意和保存时保持相同的网络结构,隐藏层数、激活函数都对齐,否则state_dict会直接报 key 不匹配。多智能体项目里每个智能体一套权重文件是最清晰的做法,比混合在一个文件里省去不少定位时间。
5. 训练避坑:收敛慢、策略崩溃、Q 值爆炸的六个典型问题
5.1 现象:所有智能体 reward 一起归零,策略集体“摆烂”
训练到中期曲线突然断崖式下跌,所有智能体的回合奖励都在零附近,无论调学习率还是 batch size 都拉不回来。
原因是 reward 设计给对抗双方设了绝对值相反的导向,而某一方策略先崩了,另一方跟着失去训练信号。比如追逐方的奖励是“接近猎物 + 1”,猎物是“被接近 -1”,追逐方一旦找到“站住不动也能拿 0 分”的局部最优,猎物就再没有逃跑压力,双方策略同时固化。
解决的做法是在 reward 里加一个“过程导向”的持续激励,而不是只给相对量。追逐方除了“接近”加分,还要在持续追出距离时给额外奖励,让“行动”而不是“不动作”成为收益来源。同时给双方训练曲线分开记录,不要只看平均 reward——对抗场景下平均奖励被两个相反方向的曲线抵消,看起来像归零,实际是双方在对抗。
5.2 现象:Q 值起步正常,几千步后直接上千万
训练日志里 critic loss 从个位数跳到百万级,Q 值输出数值爆炸。
原因是 reward 量级跨度过大,或者 target Q 的更新里用了没 detach 的目标网络输出。MADDPG 的 critic 输入是所有智能体拼接向量,维度高、数值量级差异大(比如速度在 0.1 量级、距离在 10 量级),未经归一化直接进全连接层,梯度会不稳定。
解决的顺序:先确认更新代码里next_act_n和target_q都加了.detach(),再检查 reward 量级是否控制在 0.1 到 5 之间,最后给 critic 输入做标准化。我常写一个简单的观测归一化层,把 reward 和观测都缩放到 [-1, 1] 区间,Q 值爆炸基本会在前一万步内暴露并解决。
5.3 现象:训练曲线反复横跳,甚至出现间歇性疯狂尖峰
同一份代码,同一份超参数,训练曲线每次跑出来长得很不一样,有几条 run 的曲线会突然出现尖峰后回弹。
原因是多智能体环境初始状态是随机的,智能体出生位置分布差异大,某些初始配置下回合特别长或奖励特别高,被存进经验回放后干扰了价值估计。这在博弈对抗里是常态,因为对抗双方的初始位形直接决定了一整局的难度。
解决的关键是固定初始分布的随机种子,并做“混合采 样”。我在环境 reset 时固定随机种子,让每局初始条件覆盖到主要位形,但不必完全确定。更有效的办法是经验回放里按回合分段抽样,让每局的经验被均匀抽到,避免某一局极端经验独占 batch。实现的代价不大,但对曲线稳定性的改善非常明显。
5.4 现象:训练完成后策略变成“高频抖动机”
actor 输出的动作序列在很小的时间尺度上剧烈跳动,仿真里表现为智能体原地高频抖动,几乎没有有效位移。
原因是追求平滑的策略梯度下,actor 被训练得对输入中的噪声高度敏感,而输入观测本身就有 1e-3 量级的数值噪声。策略在噪声方向放大了微小差异,反映出动作的高频分量。
解决的思路是在 replay buffer 保存时对观测做轻量低通滤波,或者更直接的做法是训练时给 actor 输出加动作平滑惩罚,将相邻两步动作差值的平方加入 loss。我一般会同时做两件事:训练开始时把噪声方差的衰减调慢一些,让探索噪声在最早期保证动作充分多样性;训练后期则对动作加平滑项,让策略只保留低频控制分量。
5.5 现象:target 网络每步都在更新,前期正常后期震荡加剧
目标网络跟随主网络太紧,训练后期主网络参数在局部区域轻微来回移动,目标网络也跟着在每个时间步追最新值,导致 target Q 持续小幅跳变,critic 永远追不上会动的标靶,震荡越到后期越明显。
原因是 tau 设成较大固定值,加上目标网络更新频率和主网络训练频率完全一致。在多智能体场景,这种“跟得太紧”的问题尤其突出,因为每个智能体的目标网络耦合了所有人的策略变化。
解决的要点是把软更新变成“低频大幅”模式:每 100 到 200 步做一次完整软更新,tau 取 0.05,比每步 0.01 的版本更稳。运行完第一万步后如果震荡仍明显,把同步间隔继续加大到 300 步。这个操作不对实验曲线造成显著副作用,却能把后期策略崩溃的概率降一个量级。
5.6 现象:reward 曲线一路向上,但实际对抗还是一碰就碎
训练日志里 reward 稳步上升,你觉得快成功了,拿训练好的 actor 去做实际对抗测试,却发现策略莽撞、经常被简单战术克制。
原因是 reward 曲线只能衡量“在当前对手策略下的收益”,不能衡量策略的真实强度。对抗训练中对手是自己,所以 reward 上升可能只是双方同时变笨后的“和平相处”,而不是真的更强。repo 里训练日志展示的 reward 上升,往往没法和实际对抗能力画等号。
解决的唯一可靠手段是定期评估对抗胜率。训练过程每 N 局保存一组候选模型,训练结束后,把不同 checkpoints 两两对战,绘制固定种子的胜率热力图。只有胜率矩阵能告诉你哪个 checkpoint 真正更强,而不是看谁 reward 曲线更高。这个习惯应该从第一天就建立,不要等技术方案全跑完了才去补评估脚本。
6. 从“能跑”到“能打”:自对弈、优先经验回放与胜率评估
多智能体博弈对抗训练里,最难的不是让 reward 上升,而是让策略“真的变强”。要验证和固化成 果,我常用的三招是:自对弈蓄水、优先经验回放、胜率矩阵选模型。
自对弈的思路是维护一个“历史策略池”,每隔一段训练轮数就把当前模型复制一份放进去,然后让在线智能体和历史版本随机对战。这样做的目的很朴素:如果只和“现在的自己”练,双方会同时进化,但可能一起走向某个局部最优;和历史版本打,相当于逼策略去适应更多变体,而不是只记住当前对手的弱点。在 MPE 这类轻环境里,策略池每个阶段就存一份,池子容量 10 个左右,每次对战随机抽一个对手,效果比单纯和当前版本对练好很多。
优先经验回放是另一个收益明显的改动。博弈对抗中大部分经验是平淡的:双方距离远、没接触、奖励接近零,这些样本对网络价值不大。反而是那些发生捕获、发生剧烈机动、奖励差异大的稀有经验,携带了更多策略信息。给每条经验按 TD 误差大小记一个优先级,采样时按优先级加权,能让网络更频繁地“复习”关键时刻。实现时注意两点:优先级指数 alpha 取 0.6 左右,不要设过高否则采样分布太偏;刚存进去的经验给一个中等偏上的初始优先级,避免完全不被采到。
胜率评估是最终裁决手段。训练过程中每几个训练轮次固定保存一组 actor,全部训练结束后,把所有 checkpoint 两两对阵,用相同随机种子跑足够多局,统计胜率。为什么必须这样?因为对抗训练里 reward 的绝对数值没有意义,只有相对某个固定对手的胜率才反映真实强度。我看过太多 reward 曲线很好看、实际对战一碰就碎的项目,都是因为跳过了这一步。评估用的对手必须是“冻结策略”,不能在评估时打开探索噪声,否则胜率数据会混入噪声变量,无法反映策略本身水平。
这三步做完,一个 MADDPG 博弈对抗项目才算真正闭环:自对弈保证训练动力,优先回放提高样本效率,胜率矩阵给出可信的模型选择依据。做项目时我养成的习惯是评估脚本和训练脚本同步开发,第一次启动训练前评估代码就已经能跑通了,而不是训练跑了几天之后才补评估。这个习惯省下过无数次返工,希望帮到你。
本文还有配套的精品资源,点击获取