
简介基于MADDPG的多智能体博弈对抗算法毕业设计源码包面向有机器学习/强化学习基础的读者适用于多智能体协作与竞争类项目研究。源码在演员-评论家框架下完整实现了MADDPG算法涵盖环境构建、智能体初始化、网络搭建、训练过程与结果展示等模块并加入详细中文注释有助于理解连续动作空间和中心化评论家等核心机制。压缩包共13个文件含10个Python脚本配合README说明、测试文本与配置文件整体仅12KB结构紧凑主程序与功能模块分离便于按需阅读和二次开发。现有122人学习下载资源对完成毕业设计、开展博弈对抗实验或算法改进均有直接借鉴价值。1. MADDPG毕设选题跑通一个多智能体博弈对抗项目难点不在算法而在工程MADDPGMulti-Agent Deep Deterministic Policy Gradient是当前多智能体强化学习领域出镜率最高的算法之一也是很多毕业生做“多智能体博弈对抗”方向的首选落点。它解决的是多个智能体同时与环境交互、同时学习策略时环境对单个智能体来说不平稳的问题——你学会了队友也学会了你的观测和奖励都在变单智能体算法在这里会翻车。而它最吸引人的一点是“集中训练、分散执行”训练时Critic可以看到所有人的观测和动作执行时每个Actor只依赖自己的局部观测这套框架正好贴合很多博弈对抗场景的真实约束。这个源码并不复杂但很多同学卡在同一个地方论文里的公式看懂了代码却不知道哪里对应Q函数更新、哪里对应策略梯度、噪声加在哪个环节、target网络什么时候该软更新。这篇文章就沿着代码结构把MADDPG拆开讲按照阅读源码、跑通训练、调参、排错、答辩验证的顺序走一遍。适合正在做算法类毕设、想在答辩现场把“你的代码做了什么”讲清楚的人。2. 从DDPG到MADDPG集中训练与分散执行是如何落在代码里的2.1 先建立直觉单一智能体视角下的MADDPGMADDPG并不是在DDPG外面套了一层循环那么简单。它的核心思想是每个智能体都维护一份Actor-CriticActor拿自己的局部观测输出动作Critic在训练时拿到所有智能体的观测和动作用来估算当前策略的Q值。DDPG的Critic输入是当前观测和动作而MADDPG的Critic输入是所有人的观测和动作这个改动直接解决了环境非平稳问题。还有一个常被忽略的细节MADDPG里每个智能体的Critic没有共享参数各自独立。虽然输入信息全但网络是分开训练的。这是因为多智能体场景里每个智能体的回报目标不同追捕者想要围堵逃跑者想要突围一个共享的Critic很难同时拟合两个相反的目标。代码层面一个智能体的Actor输出的是一个连续动作向量动作空间直接用Tanh限制在[-1, 1]之间。如果没有这个约束训练后期动作值发散导致环境回报异常是常见的第一个坑。class Actor(nn.Module): def __init__(self, obs_dim, act_dim, hidden256): 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)Actor的输出层必须接Tanh激活它的作用是把动作压缩到对称区间让探索噪声可以从动作空间外部叠加在[-1, 1]范围内。这里obs是单个智能体的局部观测不拼接其他智能体信息这是执行阶段分散性的直接体现。再看Critic。它的输入是全体的观测拼接和全体的动作拼接输出是一个标量Q值。训练时把动作也拼进去是为了拟合“在给定全局状态和全局动作下的期望回报”。这个设计直接对应论文里Q函数的定义也是所谓“Critic额外观测信息不影响执行”的最直观代码体现。class Critic(nn.Module): def __init__(self, obs_dim_total, act_dim_total, hidden256): super().__init__() self.net nn.Sequential( nn.Linear(obs_dim_total act_dim_total, 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)这里obs_all是把所有智能体的观测在特征维上拼接act_all同理。拼接顺序要保持稳定比如进场顺序按agent索引排列否则同一个智能体在不同批次里出现在不同位置训练会学出随机性。2.2 双Q网络的软更新为什么用target网络还是会在多智能体里震荡MADDPG沿用了DDPG的target网络机制。Actor和Critic各有一份target版本不参与梯度计算只用于计算TD目标。更新方式不是周期性硬拷贝而是每次更新后把target参数向当前参数方向滑动一段很小的比例。这段话讲起来很简单但很多源码实现里写错。一个典型的错误是在更新target时把tau设为1等于每步都硬拷贝这样等于没用target网络TD目标一直在跟着实时参数跳多智能体场景下尤其容易不收敛。另一个错误是忘记对target网络调用eval模式Dropout或BatchNorm在推理阶段和训练阶段行为不一致会引入额外的修正噪声。target网络的软更新代码如下训练循环里每一步都要调用。def soft_update(target_net, source_net, tau): for target_param, source_param in zip(target_net.parameters(), source_net.parameters()): target_param.data.copy_( tau * source_param.data (1.0 - tau) * target_param.data )tau的典型值是0.01意思是target网络每步只往当前网络靠近1%。这个值不要为了求快改大改到0.1以上训练曲线会明显震荡。源码里通常会封装成一个MADDPG类内部维护所有智能体的Actor、Critic和各自的target网络。2.3 经验回放里的形状陷阱obs_n是列表而不是张量训练多智能体时最容易被忽视的数据结构问题是观测的形状。环境返回的是obs_n它是一个长度为智能体数量的list每个元素是一个numpy数组形状为(obs_dim,)。ReplayBuffer存储时必须保留这个结构sample出来之后再统一转成torch.Tensor。class ReplayBuffer: def __init__(self, capacity): self.buffer deque(maxlencapacity) self.transition namedtuple(Transition, [obs_n, act_n, rew_n, obs_next_n, done_n]) def push(self, obs_n, act_n, rew_n, obs_next_n, done_n): self.buffer.append(self.transition(obs_n, act_n, rew_n, obs_next_n, done_n)) def sample(self, batch_size): batch random.sample(self.buffer, batch_size) obs_batch torch.tensor(np.array([t.obs_n for t in batch]), dtypetorch.float32) act_batch torch.tensor(np.array([t.act_n for t in batch]), dtypetorch.float32) rew_batch torch.tensor(np.array([t.rew_n for t in batch]), dtypetorch.float32) obs_next_batch torch.tensor(np.array([t.obs_next_n for t in batch]), dtypetorch.float32) done_batch torch.tensor(np.array([t.done_n for t in batch]), dtypetorch.float32) return obs_batch, act_batch, rew_batch, obs_next_batch, done_batch这里有一个形状细节obs_batch的最终形状是(batch_size, num_agents, obs_dim)。计算Critic时要把中间维度压平即将batch的obs_batch.view(batch_size, -1)再输入Critic而计算Actor时只取对应智能体那一列obs_batch[:, agent_idx]输入。源码里最常见的下标错误就发生在这里因为单个样本是list转成tensor后多出一维导致维度不匹配报错。3. 跑通一个追捕-逃逸对抗场景训练流程与三个必调参数3.1 追捕-逃逸场景的建模用最少的环境代码验证算法博弈对抗最常用的验证场景是追捕-逃逸。两个追捕者围堵一个逃跑者逃跑者被追捕者碰到即输掉回合。这类环境在开源粒子环境multiagent-particle-envs里有现成实现把simple_tag环境加载进来指定num_good1、num_adversaries2即可。环境加载代码很简单但要注意把环境返回的observation和action空间维度打印出来。很多同学直接把环境装好就开始训练训练到几百个episode后突然发现维度对不上然后排查半天结果只是把joint action传错了参数。from multiagent.environment import MultiAgentEnv import multiagent.scenarios as scenarios scenario scenarios.load(simple_tag.py) env MultiAgentEnv(scenarioscenario, worldscenario.world, reset_callbackscenario.reset_world, reward_callbackscenario.reward, observation_callbackscenario.observation, done_callbackscenario.observation, info_callbackscenario.info) print(num agents:, env.n) print(obs dim per agent:, env.observation_space[0].shape) print(action dim per agent:, env.action_space[0].shape)环境里每个智能体的action_space是Discrete(5)包含向上左右移动和不动五个离散动作。但MADDPG输出的是连续动作这里要做一层映射把连续值[-1, 1]映射到5个离散动作的分布上或者直接改用连续力接口。多数开源复现直接采样离散动作的环境再在代码里加一层argmax转换这样能跑通但丢失了连续控制的意义。毕设答辩时这个点容易被问到建议在代码注释里写清楚这层映射存在的理由。3.2 训练主循环一次参数更新里发生了什么训练主循环看起来就是标准的“采一段数据、存buffer、抽样更新”三步但多智能体版本里每一步都在做批量操作。下面给出一个可读性优先的训练循环骨架不是最高效的写法却最容易讲清楚。for episode in range(max_episodes): obs_n env.reset() episode_reward np.zeros(env.n) while not done: act_n [] for i in range(env.n): obs_tensor torch.FloatTensor(obs_n[i]).unsqueeze(0) act maddpg_agents[i].choose_action(obs_tensor, noise_scale) act_n.append(act.squeeze(0).numpy()) obs_next_n, rew_n, done, info env.step(act_n) replay_buffer.push(obs_n, act_n, rew_n, obs_next_n, done) obs_n obs_next_n if len(replay_buffer.buffer) batch_size: for agent_idx in range(env.n): maddpg_agents[agent_idx].update(agent_idx, replay_buffer, batch_size)这里噪声是加在动作上的。choose_action内部执行Actor的前向输出动作值再加一个高斯噪声或者OU噪声。注意噪声的scale要随着训练进程衰减从0.2线性降到0.02这个衰减策略比改学习率更直接影响探索与利用的平衡。MADDPG的update内部要做三件事更新Critic、更新Actor、软更新target。Critic的loss是TD误差的均方误差target值由target Critic网络在下一时刻的全局观测和target Actor网络下一步动作下计算得到。def update(self, agent_idx, replay_buffer, batch_size): obs_batch, act_batch, rew_batch, obs_next_batch, done_batch replay_buffer.sample(batch_size) obs_all obs_batch.view(batch_size, -1) act_all act_batch.view(batch_size, -1) obs_next_all obs_next_batch.view(batch_size, -1) with torch.no_grad(): target_act_next self.target_actor(obs_next_batch[:, agent_idx]) target_act_next_all act_batch.clone() target_act_next_all[:, agent_idx] target_act_next target_q self.target_critic(obs_next_all, target_act_next_all) target_q rew_batch[:, agent_idx].unsqueeze(1) self.gamma * (1 - done_batch[:, agent_idx]).unsqueeze(1) * target_q cur_q self.critic(obs_all, act_all) critic_loss nn.MSELoss()(cur_q, target_q) self.critic_optimizer.zero_grad() critic_loss.backward() self.critic_optimizer.step() current_act self.actor(obs_batch[:, agent_idx]) current_act_all act_batch.clone() current_act_all[:, agent_idx] current_act actor_loss -self.critic(obs_all, current_act_all).mean() self.actor_optimizer.zero_grad() actor_loss.backward() self.actor_optimizer.step() soft_update(self.target_critic, self.critic, self.tau) soft_update(self.target_actor, self.actor, self.tau)这段代码里有一个MADDPG的核心细节计算target值时其他智能体的动作用的是“当前target Actor输出的动作”而不是从buffer里取的真实动作。这是在模拟“其他人也会更新策略”的假设让Critic学会对对手策略变化保持稳定。3.3 训练效果最敏感的3个参数不同取值对收敛的影响第一个是gamma值。很多同学直接沿用单智能体DDPG的0.99但在追捕-逃逸这类任务里回合长度不长0.99会让智能体过于看重远期回报导致行为显得迟钝。我通常把gamma设在0.95左右让智能体更激进地追求近几回合的收益。实验对比里gamma从0.99改成0.95追捕成功率往往有明显提升。第二个是batch_size。多智能体场景里每个智能体更新时都从同一个buffer采样batch太小会放大样本噪声batch太大又让更新频率变低。经验上batch_size取256到1024比较合适。毕设机器显存有限的话不要用1024硬撑OOM后调小到256可能比改网络结构更解决问题。第三个是Critic的学习率。Actor和Critic的学习率不要设成一样的。Critic承担的是全局拟合任务输入维度比Actor大得多需要更小的学习率防止Q值震荡。常见配置是Actor学习率0.01、Critic学习率0.001这个比例在多个环境里都验证过。这几个参数可以在源码顶层做成一目了然的config字典方便答辩时演示“参数改动对曲线的影响”。建议在答辩前把一组对照实验跑完胜率曲线和奖励曲线的对比图比任何论文截图都有说服力。4. 多智能体训练中的常见问题跑挂5个环境后的排错清单4.1 reward在训练中途变成NaN问题往往不在loss而在动作边界现象是前几百个episode训练正常奖励曲线稳步上升然后在某一次更新后所有智能体的reward全部变成nan。第一时间查loss还是nan往往发现不了问题因为loss本身是由reward反向传播的源头在reward已经脏了。原因大多数是环境在某个状态下返回了非法动作或者无效碰撞导致reward数值溢出。常见于追捕者把所有出口堵死、逃跑者被迫进入连续碰撞状态的环境分支。环境代码只处理了有限种碰撞组合没覆盖的路径返回了inf。解决方式是先定位是哪个环境分支产出的reward异常。在reward_callback返回处加一个判断把非有限的数值直接替换成比当前最小reward再低一档的常数然后打印是哪两个智能体碰撞导致的。还有一个更隐蔽的坑Cython版本的环境库在某些平台上float32溢出可以把reward clip在一个合理范围内比如[-10, 10]而不是任其无限累加。注意排查NaN时先不要动网络结构。90%的情况是环境或数据的问题先检查reward的数值范围、action是否在边界内再决定要不要调loss函数。4.2 Critic的loss一直在降但Actor的reward没有任何改善现象是训练曲线里critic_loss一路下降可是肉眼观察智能体行为始终没学会围堵avg_reward纹丝不动。这种情况在多智能体对抗里特别常见原因是Critic只学会了拟合当前数据分布但没有给Actor提供有效的梯度信号。一个原因是reward太稀疏。逃逸者只在被抓住时收到负奖励追捕者平时探索不到正信号Critic在所有状态上的输出都趋于零策略梯度自然没有方向。很多人第一反应是调大网络但实际应该先做reward shaping给追捕者加一个“靠近逃逸者就有小惩罚”的密集奖励。另一个原因是Critic对全局状态过度敏感。Critic输入包含了所有智能体的位置如果某个智能体的观测里有绝对坐标分布在500x500的大地图上位置微小变化对Q值影响巨大策略梯度被这些无关维度淹没。解决方式是改为相对位置表示让Actor的输入是“逃逸者相对我的方向向量”而不是各自的绝对坐标。4.3 两个追捕者总是跑到同一个点而不是分工包围现象是追捕者学会了追击但两个追捕者始终挤在一起从同一个方向追逃跑者轻轻松松直线逃走。这本质上是多智能体的信用分配问题——两个动作同样的追捕者收到了相同的奖励它们的策略倾向于向同一区域收敛。这个问题的修复手段有三个层次。第一层是改环境奖励如果两个追捕者彼此距离太近就给予额外惩罚逼迫策略产生分化。第二层是给不同追捕者的Actor设置不同的初始参数虽然同为追捕者但网络初始化差异能让它们在训练早期探索到不同的区域。第三层是在Critic输入中同时包含自身和队友的位置关系让策略学习当队友占据某方向时自己去补位另一侧。这三个手段在代码里实现成本都很低第二个往往被忽略却最有效。注意如果想让两个追捕者真的分化不能只改随机种子要修改网络初始化时权重分布的scale或者给不同agent的actor加不同的输入偏置。4.4 训练时reward曲线看起来收敛了但一跑测试就原形毕露这种现象是最容易在答辩时翻车的训练过程中奖励一路走高达到预期水平结果用evaluate模式或者关闭探索噪声后测试智能体得分突然掉回随机水平。原因基本可以锁定在动作选择和探索策略的耦合上。训练时choose_action加入了高斯噪声噪声scale后期衰减到0.02即便策略已经很好这个噪声也能在测试时给动作引入偏差。更关键的是评估时如果忘记把噪声关闭策略输出被噪声污染表现当然远低于训练曲线。解决方式是在测试脚本里把noise_scale显式设为0并且把目标策略网络切换为当前网络。很多实现里target_actor的更新有滞后直接拿target网络评估效果会略低于实际水平必须在评估时用当前Actor。另一个常见陷阱是评估时没有用确定性动作。即使噪声为零如果Actor输出是概率分布采样的方法每次测试结果波动都很大。MADDPG的Actor是确定性策略评估时直接取输出值不要加任何扰动跑10个episode取平均值再画曲线。5. 博弈对抗实验结果的验证从“代码能跑”到“答辩过关”的最后一段路5.1 先定义清楚评估指标再谈训练效果毕设答辩最容易被追问的就是“你怎么证明你的算法比baseline好”。追捕-逃逸这类对抗场景单看总奖励不够因为不同episode的初始位置不同奖励差异很大。我建议至少统计三个指标做成一个表格。指标计算方式说明追捕成功率追捕者在时限内碰到逃逸者的episode比例直接反映任务完成度平均追捕时间成功回合的碰撞时间步均值反映策略效率逃逸者存活时间未成功回合中逃逸者存活步数反映对抗强度这三个指标在训练结束后跑100个episode测试即可。测试时固定随机种子保证多次评估结果可复现。评估代码要独立于训练脚本避免复用训练循环里的buffer和噪声参数。5.2 用奖励曲线和成功率的双图验证策略稳定性训练过程的可视化是答辩材料的一部分。很多同学只画一张奖励曲线但这远远不够奖励曲线受噪声影响波动极大看起来一塌糊涂。常见做法是计算滑动平均窗口设50把原始曲线画成浅色半透明滑动平均画成深色粗线这样既展示了真实波动又展示趋势。def moving_average(data, window50): return np.convolve(data, np.ones(window) / window, modevalid) plt.figure(figsize(10, 5)) plt.plot(raw_rewards, alpha0.3, colorblue) plt.plot(moving_average(raw_rewards), colordarkblue, linewidth2) plt.xlabel(episode) plt.ylabel(reward) plt.title(MADDPG training curve (moving average)) plt.grid(alpha0.3) plt.savefig(training_curve.png, dpi200)评估阶段则用胜率曲线。每100个episode做一次评估记录当前策略的追捕成功率最后画成阶梯式曲线。如果胜率曲线从0.2稳步爬升到0.85以上这个结果比任何单次训练曲线都有说服力。5.3 提升对抗强度的三个工程技巧第一个技巧是训练时不要让追捕者和逃逸者都从随机策略开始这样前期互相探索学习信号太稀疏。常见做法是先用单智能体策略或者规则脚本控制逃逸者让追捕者先学会追到随机游走的对手再把逃逸者换成可学习策略形成课程学习式的渐进难度。第二个技巧是给追捕者的Critic加一个“对手意图嵌入”。在Critic拼接入所有智能体观测的基础上再把每个智能体的历史动作编码加入输入让Critic能感知对手的执行模式。这个技巧提升的是训练上限代码上只是在拼接维度里多几个特征。第三个技巧是训练过程中周期性保存最优模型。很多毕设代码只在最后一次保存如果最后几百个episode过拟合或者策略退化直接没有后悔药。按每500个episode保存一组权重训练结束后统一评估选择最优权重作为最终结果这个习惯能在关键时候救你一命。确认最终的实验图和权重文件都齐了之后再把训练脚本里的随机种子固定住跑一遍从零训练到出图的完整流程确认每一步不依赖人工干预。答辩前我习惯把关键参数表打印在实验结果图旁边免得被问到“这个曲线是在什么配置下跑出来的”时答不上来。希望这些经验能帮你顺利跑通这个方向拿到一份真正属于你的实验结果。本文还有配套的精品资源点击获取