ARTICLE DETAIL

资讯详情

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

MADDPG多智能体博弈对抗算法Python源码实战解析

MADDPG多智能体博弈对抗算法Python源码实战解析 简介基于MADDPG的多智能体博弈对抗算法Python项目源码为一份98分期末大作业项目面向计算机专业正在完成课程设计或期末项目的学生也适合需要强化学习实战的开发者。项目围绕多智能体在博弈对抗中的训练与决策提供完整可运行的实验代码可帮助理解MADDPG多智能体框架以及DDPG单智能体算法的实现原理同时通过奖励设置、经验回放、网络更新等环节展示典型强化学习流程。压缩包共13个文件除Python脚本外还有配置文件和文本说明整体仅15KB轻量而模块密集核心脚本分别对应经验回放、网络结构、智能体训练、环境测试等部分便于按需阅读和二次开发。项目经过严格调试下载即可运行省去环境适配与排错成本。目前已有385人学习适合用于算法理解、课程报告或项目复现。1. 当两个智能体互相拆台时MADDPG项目源码为什么值得看做过单智能体强化学习的人第一次把DDPG用在对抗环境里通常会遇到一个很困惑的现象两个智能体明明各自都收敛了放到一起打却立刻崩掉。原因不难理解——对手的策略一直在变环境对单个智能体来说已经不是稳定环境了经验回放里存下来的“正确动作”很快过期。这个问题在博弈对抗场景里尤其致命因为你要的不只是“会玩”而是“能针对对手调整玩法”。基于MADDPG的多智能体博弈对抗算法Python实现项目源码解决的就是这一类问题它把训练过程拆成“集中训练、分散执行”让每个智能体在学自己策略的同时把对手行为也建模进来从而在追逐、拦截、竞速、攻防这类博弈任务里训练出能对抗的策略。这篇笔记适合三类人做多智能体强化学习课程设计或毕业设计的学生需要一套能快速跑通的基线代码做游戏AI或仿真对抗方向的工程师想看看MADDPG在自己的环境里能到什么程度以及已经跑过PPO、DQN想往多智能体方向走一步的强化学习新手。文章会从原理讲到跑通再到把这份源码改成自己能用的项目过程中会给出我会实际调整的参数和经验值尽量让你少走弯路。2. 为什么对抗博弈选MADDPG从非平稳环境到集中训练分散执行2.1 单智能体算法在多智能体对抗中失效的三个原因先建立一个直觉强化学习的核心假设是环境转移概率和奖励函数在训练过程中保持稳定。Q-learning和策略梯度方法都依赖这个假设来从历史经验里学习。多智能体对抗场景里这个假设直接被打破了——对手也在学习、也在更新策略所以当前状态转移到下一个状态的概率会因为对手策略的改变而改变。这就是所谓的非平稳环境。非平稳带来的第一个问题是经验回放过期。智能体存了一批“上一版本对手”下的经验等对手更新了策略这些经验里标注的收益就不再准确用它们更新价值网络会让Q值严重高估。第二个问题是梯度方向不稳定。对多个同时学习的智能体来说某个智能体策略的微小变化会改变其他智能体的观测分布而观测分布变化又反过来影响这个智能体的收益形成循环干扰。第三个问题是缺乏对手建模。单智能体算法根本不知道“对面那个家伙”在干嘛它把所有不确定性都归结为环境噪声这在博弈里等于瞎打。MADDPG的思路是既然训练阶段可以开卷那就不必让每个智能体自己猜对手。训练时让Critic看到所有智能体的状态和动作相当于考试时大家一起对答案学习阶段信息全共享考试时执行阶段再各凭观测做决策。这样一来非平稳性问题主要落在Critic的更新里Actor面临的非平稳性大大降低。2.2 中心化训练、去中心化执行的数学直觉与核心网络结构MADDPG全称是Multi-Agent Deep Deterministic Policy Gradient在DDPG框架上扩展而来。每个智能体i有自己的Actor网络μ_i输入自己的局部观测o_i输出确定性动作a_i。同时有一个中心化Critic网络Q_i输入是所有智能体的观测和动作即o_1, o_2, ..., o_N和a_1, a_2, ..., a_N输出对智能体i的Q值估计。关键点在于每个智能体都有自己的Critic但每个Critic都能看到全局信息。Critic的更新目标是用贝尔曼方程最小化时间差分误差。Actor的更新目标是最大化自己的Q值但因为Q_i知道所有智能体的动作所以Actor的梯度里包含了对其他智能体策略的隐式建模。这种设计让每个智能体在决策时不需要显式推断对手意图Critic已经把这些信息编码进Q值里了。我这里给一个最简的PyTorch版本网络结构方便你对照源码理解。实际项目里Actor末尾会加tanh把动作压缩到[-1, 1]区间import torch import torch.nn as nn class Actor(nn.Module): def __init__(self, obs_dim, act_dim, hidden64): 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() ) def forward(self, obs): return self.net(obs) class Critic(nn.Module): def __init__(self, obs_dim_all, act_dim_all, hidden64): super().__init__() # 输入是所有智能体的观测拼接 所有智能体的动作拼接 self.net nn.Sequential( nn.Linear(obs_dim_all act_dim_all, 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)这个结构本身不复杂复杂的是训练循环里的数据流。训练时要把所有智能体的观测拼成一个长向量喂给Critic更新时每个智能体单独算loss。执行阶段Actor只用自己的观测推理不需要知道其他智能体的信息。这种“训练开卷、执行闭卷”的架构就是MADDPG的核心贡献也是它区别于独立DDPG、独立PPO的地方。2.3 三个关键设计细节策略集成、经验回放与奖励设计视角MADDPG原文里有几个容易被忽略的工程细节理解了它们你才看得懂源码里的参数。第一个是策略集成policy ensemble每个智能体维护K个不同的Actor子策略每次更新随机选一个子策略与环境交互目标价值计算时取K个子策略输出动作的平均Q值。这样做的目的是缓解对手策略剧烈变化导致的价值估计震荡等于给对手建模加了一个平滑项。第二个是经验回放的博弈视角。在多智能体场景中经验缓冲区的采样策略非常讲究。如果按时间顺序连续采样相邻样本之间对手策略差异很小智能体学不到应对多样策略的能力。更有效的做法是随机从整个缓冲区采样让每一次更新面对的都是“历史不同版本对手”的混合样本。这就是为什么MADDPG项目里buffer容量通常设得比单智能体更大我一般设到50000条以上。第三个是奖励设计的博弈陷阱。MADDPG本身不解决奖励塑造问题但对抗场景中零和奖励一方收益等于另一方损失会让训练非常不稳定因为双方梯度方向完全相反。实际项目中常见做法是把赢家奖励设为1、输家设为-1同时加上小幅度动作惩罚项防止策略退化成原地抖动。你也可以在环境里加入稀疏的“接近奖励”或“命中奖励”来引导探索这部分后面会展开讲。3. 把“下载即用”的源码跑起来Python环境依赖与最小启动命令3.1 依赖版本为什么经常装完就报错这类源码包最常见的翻车点不是算法而是环境依赖冲突。MADDPG项目普遍依赖PyTorch和OpenAI Gym而这俩库的版本兼容性在Python 3.8到3.11之间差别很大。如果你直接用最新版PyTorch配最新版Gym去跑老项目大概率会遇到gym.make接口变动或者np.float属性报错这类问题。我的建议是固定一套能互相兼容的版本组合。经典MADDPG开源实现大多基于PyTorch 1.x和Gym 0.15到0.21之间这两个版本的接口比较稳定。操作系统层面Windows、Linux、macOS都能跑但如果你用的是WindowsGym的rendering模块偶尔会缺底层显示依赖训练模式下关掉渲染就能绕过去。推荐的环境创建命令如下Python版本用3.8或3.9兼容性最好conda create -n maddpg python3.9 conda activate maddpg pip install torch1.13.1 pip install gym0.21.0 pip install numpy1.23.5 pip install matplotlib pyyaml这里把numpy固定在1.23.5是为了避开numpy 2.x里移除np.float、np.int这类别名导致的兼容性错误。torch 1.13.1是1.x系列最后一个大版本既能在老代码上跑又对后续新硬件有基本支持。如果你电脑是Apple Silicon芯片可以试试pip install torch装官方预编译版一般能直接用。3.2 从解压到看到第一条训练曲线的完整步骤拿到压缩包后第一步不是直接运行而是先看目录结构。多数MADDPG项目源码的组织方式大体相似主训练脚本、模型定义文件、环境文件、配置目录各司其职。你需要先确认入口文件名通常是main.py、train.py或run_experiments.py。接下来执行三步安装依赖、确认入口、启动训练。假设入口文件是main.py# 第一步在项目根目录安装依赖 pip install -r requirements.txt # 第二步快速验证环境能不能初始化 python -c from envs import make_env; env make_env(simple_tag); print(env.observation_space) # 第三步启动训练 python main.py --scenario simple_tag --episodes 3000 --seed 42如果requirements.txt不存在就用前面提到的固定版本逐个装。启动命令里的--scenario指定环境场景simple_tag是MADDPG论文里经典的追捕-逃跑任务一个追捕者对抗多个逃跑者非常适合作为入手场景。训练过程中要观察三个指标每个episode的累计奖励、actor的loss量级、critic的loss量级。如果奖励曲线持续上升且loss没有爆炸到NaN说明训练方向正确。第一次跑的时候建议把--episodes设小一点比如500先验证整个链路能跑通再全量训练。训练时长取决于你的GPU。纯CPU跑simple_tag3000个episode可能要两三个小时GPU则快得多。数据量不大不需要分布式。3.3 跑通之前先确认的三件事文件结构、入口参数、保存路径下载即用的项目最怕的是“代码没问题但不知道去哪改参数、去哪看结果”。我每次拿到新源码都会先做三件事花十分钟省后面好几个小时。第一件事是确认训练配置从哪读。大多数项目支持命令行参数覆盖但也有项目把超参数硬编码在脚本里。搜一下代码里的learning_rate、batch_size、buffer_size这些关键词看它们定义在哪个文件。第二件事是确认模型保存路径和日志路径。训练完权重存成.pt还是.pth文件存储在models/还是runs/目录这决定了你中断训练后怎么续跑。第三件事是确认环境注册方式。Gym老版本需要gym.make(simple_tag)能直接找到环境类如果找不到说明环境文件没有被import进来需要在入口文件里手动import envs。这里给一个通用的文件结构参考表路径/文件作用跑通前要确认main.py训练入口解析命令行参数默认参数是否完整maddpg.py智能体初始化与单步更新智能体数量与环境是否匹配buffer.py经验回放缓冲buffer大小是否过小envs/自定义环境gym.make能否初始化config.yaml超参数配置学习率、噪声衰减是否合理检查完这三件事再启动训练基本能避免跑了一半发现路径不存在、保存失败、参数没生效等等问题。4. 核心代码模块拆解MADDPG训练主循环里每一步在做什么4.1 经验回放缓冲区对抗场景下应该存什么、怎么采MADDPG的经验回放和单智能体最大的区别在于每一条经验必须包含所有智能体的观测和动作。也就是说一条transition的格式是(obs_all, act_all, reward_all, next_obs_all, done_all)其中obs_all是N个观测向量的拼接act_all是N个动作的拼接。这直接决定了你的replay buffer数据组织方式。采样时一次随机采一个batch然后按智能体逐个更新。注意每个智能体的Critic更新用的是同一个batch的全局数据只是奖励和done字段取自己那一份。这意味着训练时所有智能体的网络都要能访问到全局状态这就是“中心化”的落地方式。class ReplayBuffer: def __init__(self, capacity, obs_dim_all, act_dim_all): self.capacity capacity self.obs_all np.zeros((capacity, obs_dim_all), dtypenp.float32) self.act_all np.zeros((capacity, act_dim_all), dtypenp.float32) self.rew_all np.zeros((capacity, num_agents), dtypenp.float32) self.next_obs_all np.zeros((capacity, obs_dim_all), dtypenp.float32) self.done_all np.zeros((capacity, 1), dtypenp.float32) self.ptr 0 self.size 0 def store(self, obs_all, act_all, rew_all, next_obs_all, done): idx self.ptr % self.capacity self.obs_all[idx] obs_all self.act_all[idx] act_all self.rew_all[idx] rew_all self.next_obs_all[idx] next_obs_all self.done_all[idx] done self.ptr 1 self.size min(self.size 1, self.capacity) def sample(self, batch_size): idx np.random.choice(self.size, batch_size, replaceFalse) return (self.obs_all[idx], self.act_all[idx], self.rew_all[idx], self.next_obs_all[idx], self.done_all[idx])这段代码有两点需要注意。第一rew_all是二维数组因为每个智能体的奖励不同存的时候不能合成标量。第二done字段在对抗任务里通常所有智能体共享同一个结束标志比如某一方被抓住但有些场景是部分结束这时候要对每个智能体单独记录。采样用replaceFalse防止同一条经验被重复采到如果buffer还很小就硬采会导致模型反复看到同一条数据而过拟合。4.2 集中式Critic的更新目标网络、贝尔曼误差与soft updateCritic的更新是整个MADDPG里最核心的数学逻辑。每个智能体的Critic网络要学习一个Q函数给定全局状态和所有智能体的动作输出自己从此刻开始的期望回报。目标值计算公式为y_i r_i gamma * Q_i_target(obs_next_all, act_next_all)其中act_next_all不是环境给的而是所有智能体的target Actor网络基于各自下一时刻观测输出的动作。这就形成了一种“自举”用target网络估算未来价值再用当前网络逼近这个目标。实际实现中每步更新会做一次梯度下降然后对target网络做软更新。软更新的参数tau通常取0.01意思是target网络的权重每次往当前网络靠近1%。tau太小导致target网络更新太慢模型学得慢tau太大则target网络追踪太快失去稳定目标的意义。我通常先把tau设为0.01如果训练曲线震荡剧烈再降到0.005。def update_critic(self, batch, agent_id, gamma0.95, tau0.01): obs_all, act_all, rew, next_obs_all, done batch with torch.no_grad(): # 用target Actor输出所有智能体的下一动作 next_act_all torch.cat([ self.target_actors[i](next_obs_all[:, obs_start[i]:obs_end[i]]) for i in range(num_agents) ], dim-1) target_q self.target_critics[agent_id](next_obs_all, next_act_all) y rew[:, agent_id].unsqueeze(-1) gamma * (1 - done) * target_q # 当前Critic对实际执行动作的Q值 q self.critics[agent_id](obs_all, act_all) td_loss nn.MSELoss()(q, y) self.critics_optimizer[agent_id].zero_grad() td_loss.backward() torch.nn.utils.clip_grad_norm_(self.critics[agent_id].parameters(), 0.5) self.critics_optimizer[agent_id].step()这里有一个常被忽略的细节计算target Q值时next_act_all必须用target Actor网络输出不能用当前Actor网络。原因是target网络的价值估计相对稳定如果用当前网络每一步更新都在追逐一个移动的靶子训练容易发散。另外我在反向传播前加了梯度裁剪max_norm0.5这个值应对奖励尺度大或loss爆炸的场景很有效项目源码里如果没加建议你自己补上。4.3 Actor的更新确定性策略梯度与对手建模的隐式表达Actor的更新目标相对简单但理解它需要绕过一个弯。每个智能体的Actor目标是最大化自己Critic给出的Q值。因为当前动作是通过Actor网络输出的Critic又依赖这个动作所以梯度可以从Critic的输出反传到Actor的参数上。数学形式是grad_mu_i grad_{a_i} Q_i(obs_all, act_all) * grad_{theta_i} mu_i(obs_i)对应到代码里更新时先让所有Actor基于各自的观测输出当前动作拼接成完整动作向量喂给Critic然后用反向传播优化Actor参数。由于Critic输入中包含其他智能体的动作Actor的梯度天然会受到其他智能体策略的间接影响这就是对手建模的隐式表达——不需要显式预测对手下一步动作Critic已经把这个知识编码在Q值里了。def update_actor(self, batch, agent_id): obs_all, act_all, rew, next_obs_all, done batch # 只更新当前智能体的Actor其他Actor输出用stop_gradient固定 new_act self.actors[agent_id](obs_all[:, obs_start[agent_id]:obs_end[agent_id]]) # 拼接时其他智能体的动作用实际执行的动作断开梯度 act_mixed torch.cat([ act_all[:, :act_start[agent_id]], new_act, act_all[:, act_end[agent_id]:] ], dim-1) actor_loss -self.critics[agent_id](obs_all, act_mixed).mean() self.actors_optimizer[agent_id].zero_grad() actor_loss.backward() self.actors_optimizer[agent_id].step()这里最微妙的地方在于其他智能体的动作act_all在拼接时不能被梯度更新否则反向传播会影响其他智能体的Actor参数。所以拼接后要确保act_mixed的梯度图里只有当前Actor的部分是活的。有些实现会在拼接前对act_all调用.detach()意义就在这。用-Q.mean()作为loss是因为PyTorch只能做梯度下降最大化Q值等价于最小化负Q值。4.4 一次完整训练迭代的事件顺序理解了三个更新函数之后把整个训练循环串起来看一遍。每个episode开始重置环境拿到所有智能体的初始观测。每一步先通过噪声添加探索性动作所有智能体执行动作后环境返回全局奖励和下一时刻观测存入buffer。每积累一定步数比如100步后从buffer采样一个batch依次更新每个智能体的Critic和Actor再软更新所有target网络。事件顺序的细节决定了项目的稳定训练行为。噪声添加是在Actor输出之后、环境执行之前更新顺序是Critic先更新、Actor再更新不能反过来。如果Actor先更新它当前的输出被用于Critic的target计算会在同一轮里产生依赖循环。软更新放在所有智能体更新完毕后做保持target网络滞后一步这是DDPG系列稳定训练的根本保证。噪声参数也需要配套调整。训练早期用较大的噪声鼓励探索之后逐步衰减。常见做法是noise_scale 0.1 * (1 - episode / total_episodes)让噪声线性衰减到零。如果你发现智能体训练到后期还乱逛多半是噪声衰减得太慢如果只学到一个动作不变那是衰减过快探索不足就收敛到了局部最优。5. 避坑排查我从MADDPG项目里踩过的五个经典问题5.1 训练中途loss变成NaN模型直接废掉现象训练正常跑到几百个episode突然Critic的loss变成NaNActor输出也全是NaN后续训练彻底崩掉。原因最常见的是学习率偏高加奖励尺度偏大导致Q值反向传播时梯度爆炸。其次可能是没有做梯度裁剪或者网络权重的初始化范围太大。还有一个比较隐蔽的原因是传入Critic的观测里含有NaN——如果你在环境里计算了“距离”之类的量初始状态下距离为无穷大进入网络后直接炸掉。解决我给Critic和Actor都加上梯度裁剪max_norm0.5。同时在环境返回的观测里做一次数值检查用np.isnan(obs).any()去排查。学习率方面把Actor和Critic的学习率从默认的0.01降到0.001训练更慢但从根上解决了发散问题。如果还没解决检查奖励设计把奖励除以一个常数缩放到[-1, 1]区间。5.2 两个智能体都“躺平”谁也不作为现象训练跑完两个智能体的行为都是待在原地不动。奖励曲线早早收敛到一个不高不低的水平push上去又掉下来。原因这是典型的奖励设计问题。如果环境给出的奖励是“赢者通吃”赢了10输了-10在策略还没成形时智能体发现“不作为”比“瞎折腾”的平均收益更高因为乱动的代价可能更大。博弈里这叫“安全策略”它避免失败但也放弃胜利。解决改用更平滑的奖励函数外加一个小的“探索鼓励”项。比如追捕任务里对追捕者每一步增加距离缩短的奖励——靠近目标就给小分抓住目标再给大分。对逃跑者则相反。同时给每个动作加上一个微小的动作惩罚例如-0.01 * ||a||防止智能体通过高频抖动“刷”距离奖励。修改奖励后再训练两个智能体才开始有实质对抗行为。5.3 训练后期策略震荡赢了又输、输了又赢现象训练曲线在前中期稳步上升到后期开始剧烈震荡智能体的胜率在90%和20%之间来回跳动。每个seed的结果都不太一样有的seed训练效果好有的seed直接废掉。原因这是非平稳环境的典型症状。对手策略更新太快导致你的Agent刚适应了当前对手版本对手已经换了新打法。MADDPG里target网络的存在缓解了一部分但target更新率tau太大时这个效应会被放大。另一个原因是buffer容量太小存不下足够多样的“历史对手样本”采样时多样性不够。解决把tau从0.01降到0.003让target网络更慢地追踪。buffer容量加大至少存到能覆盖几万条经验保证每次采样能混合不同时期的对手策略。如果还震荡就降低Critic的学习率让价值估计更保守。我还有一个比较偏门的技巧给“对手的移动轨迹”做一步预测加入观测空间。这样智能体能区分“对手正在进攻还是逃跑”策略更有分化。5.4 追捕任务里智能体互相推挤绕圈不捕现象追捕者明明已经贴近逃跑者了就是不出手两个智能体在一起绕圈谁都逮不住谁。原因这个现象在MADDPG里非常经典原因是“策略退化到局部最优”。如果距离奖励设计成“越近奖励越高”但接近后的“捕捉动作”没有显著额外奖励智能体学到的最优策略是保持近距离等待——因为捕捉动作一旦失败反而拉开距离、损失奖励。它宁可绕圈也不愿意冒进。解决给“捕捉成功”设置一个远高于其他奖励的稀疏大奖励比如50同时在捕捉失败时不给惩罚。这样智能体才能建立“接近-捕捉-成功”的完整因果链条。另一个缓解办法是给追捕者的动作空间加上“限制”比如每次只能移动一步或攻击一次避免它用连续的微小移动消磨时间。5.5 “下载即用”却一运行就报错路径问题与版本陷阱现象按照README里的命令启动训练报ModuleNotFoundError: No module named envs或者gym.make找不到环境名又或者np.floathas no attribute之类的错误。原因第一类错误是PYTHONPATH没包含项目根目录特别是从子目录启动脚本时Python找不到同级的envs包。第二类错误是Gym版本过高老接口gym.make的行为变了。第三类是numpy 2.0移除了np.float等旧别名。解决在项目根目录执行export PYTHONPATH$(pwd):$PYTHONPATHWindows用set PYTHONPATH%cd%然后重新启动。依赖版本上严格按前一章的固定版本表安装。另外给一个通用排查动作用pip list检查torch、gym、numpy三个包的版本只要跟我前面给的版本组合不一致优先对齐再谈其他。6. 从复现到改造把MADDPG用到你自己的博弈场景源码跑通只是第一步实际业务里的对抗环境不可能跟simple_tag一模一样。我分享三个最实用的改造技巧。第一个是自对弈机制。MADDPG本身就是自对弈的天然载体但如果你发现训练后期胜率震荡可以定期保存“冠军版本”的模型作为对手池中的一员。每次训练时当前智能体不仅跟“当前自己”打还跟历史版本的自己打。这能有效缓解策略退化和循环克制问题。实现时只需要把其他智能体的Actor网络替换成加载的历史权重其余的更新逻辑不变。第二个是场景迁移时的观测与动作维度对齐。你自己的博弈环境可能不是“位置速度”的粒子世界而是更接近股票交易、无人机竞速、游戏对战之类。迁移时先做一件事把观测和动作统一归一化到[-1, 1]区间。MADDPG的Actor输出层是tanh输入层如果接受了尺度差异很大的原始特征训练会非常吃力。我的经验是归一化之后奖励函数的尺度统一到[-1, 1]学习率可以相对提高收敛速度快一倍。第三个是评估指标要分开看。很多项目源码里有evaluate.py或--eval参数用它的结果来判断模型好坏是合理的。但要注意评估时关闭所有噪声固定随机种子让对手使用不同历史版本分别统计胜率和平均奖励。单一对局的结果带很大的偶然性我习惯至少跑20局取平均再做决策。评估完发现策略有漏洞回去调奖励或者调tau比盲目加训练轮数有效得多。我这几年跑这种策略对抗项目最深的一个教训是不要等到训练结束才去评估。每个500个episode快速评估一次把胜率曲线和训练曲线画在一起能提前暴露“训练曲线上升但胜率不上升”的假收敛问题。靠这种高频评估我堵掉了好几次项目后期推倒重来的风险。希望这篇笔记里的原理、参数和踩坑能让你拿到这份源码后少走弯路也希望你能在这个基础上做出真正有对抗能力的智能体。本文还有配套的精品资源点击获取
返回列表