简介:这是一份基于Python与gym框架的多智能体追逃博弈强化学习平台源代码,面向正在完成课程设计、期末大作业的计算机相关专业学生,也适合需要强化学习项目实战的开发者借鉴。项目经导师指导并通过认可,属于高分课程设计成果。资源包共40个文件,以27个Python源码文件为主,辅以pyc预编译文件、依赖环境清单、项目说明文档及配置文件,整体约79KB,轻量易读。代码中可见二维/三维环境模块、空战博弈环境以及测试脚本等结构,覆盖从环境定义到智能体交互的关键环节,可直接对照学习或二次扩展。目前已有146人学习下载,对于希望快速掌握gym环境下多智能体博弈建模、或需要一套完整可运行的大作业代码模板的读者而言,能有效降低起步门槛,并借助清晰的目录结构快速定位功能模块。
1. 多智能体追逃博弈强化学习平台:一份把 MARL 落地成可运行课程设计的 Python 源码
多智能体追逃博弈强化学习平台,是本科课程设计里最容易被高估、也最值得拆解的一类题目。这套基于 gym 框架的 Python 源码,把追捕者与逃逸者的博弈封装成标准环境接口,同时给出 DQN 基线和 MADDPG 扩展两套算法。它不只是交作业的素材:状态空间怎么编排、共享奖励还是个体奖励、距离塑形系数怎么配、训练到哪一步算真正收敛,这些单智能体教程里含糊带过的细节,在这份代码里都有完整落地的答案。适合正在写强化学习课程设计、毕业设计,或者第一次接触多智能体强化学习(MARL)的开发者直接拿去跑。
2. 环境层怎么拆:从 gym 接口到追逃博弈的状态与奖励设计
2.1 为什么用 gym 而不是自己写 while 循环
刚入门时我也干过这事:把游戏逻辑和训练逻辑写在一个 while 循环里,状态是几个零散变量,动作靠 if 判断。改一次地图大小要翻半天代码,换算法等于重写。gym 的价值不是多了一个类,而是把「环境」和「算法」之间的契约固定下来:reset 返回初始观测,step 接收动作返回观测、奖励、done、info,observation_space 和 action_space 描述数据边界。算法层只认这四个接口,游戏规则随便改。
追逃博弈的特殊之处在于环境里有两类角色:追捕者看的是相对坐标,逃逸者看的是距离变化,两者共用同一个 step,但各自的观测和奖励由环境按角色分发。这份代码的环境层是这么起头的:
import numpy as np import gym from gym import spaces class PursuitEvasionEnv(gym.Env): """多追一逃的网格世界环境,接口对齐 gym 规范""" def __init__(self, grid_size=12, n_pursuers=3, n_evaders=1, capture_dist=1.5, max_steps=300): super().__init__() self.grid_size = grid_size self.n_pursuers = n_pursuers self.n_evaders = n_evaders self.capture_dist = capture_dist self.max_steps = max_steps # obs = 自己坐标(2) + 全部逃逸者相对坐标 + 同伴相对坐标 obs_dim = 2 * (1 + n_evaders + n_pursuers - 1) self.observation_space = spaces.Box( low=-grid_size, high=grid_size, shape=(obs_dim,), dtype=np.float32) # 0 原地等待,1-4 上下左右移动 self.action_space = spaces.Discrete(5) self.step_count = 0这里的 obs_dim 是动态算出来的:你加一个逃逸者,向量自动变长,训练脚本完全不用动。Box 的 low 和 high 设成地图边长,限制的是数值边界;后面喂给网络之前还要再归一化一次,后面讲。action_space 用 Discrete(5) 而不是 Discrete(4),是因为原地等待在合围场景里很重要——追捕者不总是需要移动,学会「堵位置」比学会「追直线」高级得多。
2.2 观测空间与动作空间:先定形状再写代码
观测用什么形式,直接决定算法能不能收敛。这份代码选的是全局相对坐标:每个追捕者拿到的是「自己的绝对坐标 + 所有其他智能体的相对坐标」。相对坐标比绝对坐标好在平移不变性——网络不需要记住地图坐标系,换个出生点照样工作。你要是拿绝对坐标训练,换一张地图或改一下 reset 的出生范围,模型的表现立刻崩。
如果地图做得更大,或者想模拟真实场景里的局部感知,也有人会把观测换成局部视野栅格,比如周围 5×5 的 0/1 占用图,再用卷积网络处理。栅格观测的地图泛化性好,但观测维度高、训练慢,课程设计里没必要一上来就上卷积。三种方案的取舍如下:
| 观测方案 | 维度 | 动作空间 | 适合场景 | 收敛难度 |
|---|---|---|---|---|
| 全局相对坐标 | 2*(1+n_e+n_p-1) | Discrete(5) | 小地图、课程设计 | 低 |
| 局部视野栅格 | 2kk | Discrete(5) | 大地图、部分可观测研究 | 高 |
| 全局坐标+速度 | 4n | Box(-1,1,(2,)) | 连续动作博弈 | 中 |
动作空间同理:网格地图用离散动作,连续平面上用 Box 控制速度和方向。选题是课程设计的话建议别碰连续动作,MADDPG 的连续版本要在 actor 输出层加 tanh,还要配合 OU 噪声做探索,调参成本直接翻倍,不是加分项而是劝退项。
这里还有一个我踩过的细节:相对坐标范围是 [-grid_size, grid_size],直接喂网络数值跨度太大,收敛很慢。我在 _get_obs 里会把每个相对坐标除以 grid_size,归一化到 [-1, 1] 再返回。也就是说 observation_space 里声明的是边界范围,实际输入是归一化后的值——space 声明的是「合法边界」,不是「输入值域」,这两个概念别混。
2.3 奖励函数:稀疏奖励和距离塑形的取舍
追逃博弈最经典的坑是「追捕者前期完全拿不到奖励」。只给捕获奖励,10×10 的地图里三个追捕者纯靠随机探索,几百轮可能一次都抓不到,梯度根本传不回去。所以项目在稀疏奖励之外加了一层距离塑形:追捕者每步拿「离逃逸者的最近距离缩短量」作为即时奖励,捕获成功再叠加大额奖励。
逃逸者的奖励是反过来:它会想尽办法把和追捕者的最近距离拉大。这里有平衡问题——双方奖励系数差太多,弱的一方永远学不动,结果就是要么逃逸者出生即被围,要么追捕者永远追不上。项目里一般把逃逸者的距离权重设成追捕者的 1.2 到 1.5 倍,用速度系数补偿数量劣势。step 函数里奖励是这么算的:
def step(self, actions): self.step_count += 1 self._apply_actions(actions) dists = self._pairwise_distances() # (n_p, n_e) 距离矩阵 min_dists = dists.min(axis=0) # 每个逃逸者离最近追捕者多远 dist_delta = self.prev_min_dists - min_dists reward_p = dist_delta * 1.0 + (min_dists < self.capture_dist) * 10.0 reward_e = (-dist_delta) * 1.5 - (min_dists < self.capture_dist) * 10.0 done = bool((min_dists < self.capture_dist).any()) \ or self.step_count >= self.max_steps self.prev_min_dists = min_dists.copy() return self._get_obs(), {"pursuer": reward_p, "evader": reward_e}, done, {}reward_p 左边的 dist_delta 是塑形项,右边是捕获大奖 10.0,比例大概是 1:10。塑形项的幅度绝对不能盖过主奖励,否则智能体会发现「原地抖动也能靠距离变化刷分」,然后彻底摆烂。reward_e 用 -dist_delta,让逃逸者把「拉大距离」当成直接目标。done 的判断用.any()而不是.all()——只要有一个逃逸者被抓,这一局就该结束,写错的话整局会拖满 max_steps,训练效率差一个数量级。step 的 reward 返回一个字典按角色分发,算法层按 key 取就行。
注意:改奖励函数后必须从头重训,不存在微调续训的便宜事。
3. 算法层怎么选:从 DQN 到 MADDPG 的落地路径
3.1 单智能体基线:每个追捕者一个 DQN
环境写完之后先别直接上多智能体算法。我的习惯是先跑通一个基线:每个追捕者独立持有一个 DQN,把其他智能体都当成环境的一部分。这样有两个好处:一是验证环境接口没写错,二是给后续 MADDPG 提供一个对比下限——如果高级算法的表现还不如三个独立 DQN,说明是实现的问题,不是算法的问题。
DQN 的核心组件就四样:一个 eval 网络算 Q 值,一个 target 网络延迟同步,一个 replay buffer 打破样本相关性,一个 epsilon-greedy 控制探索。训练更新这一段是整个项目里最难写对的地方:
def dqn_update(agent, batch): state = torch.FloatTensor(batch["state"]) action = torch.LongTensor(batch["action"]).unsqueeze(1) reward = torch.FloatTensor(batch["reward"]).unsqueeze(1) next_state = torch.FloatTensor(batch["next_state"]) done = torch.FloatTensor(batch["done"]).unsqueeze(1) q_value = agent.q_net(state).gather(1, action) # 取本次动作对应的 Q with torch.no_grad(): max_next = agent.target_net(next_state).max(1, keepdim=True)[0] target = reward + agent.gamma * max_next * (1 - done) loss = F.mse_loss(q_value, target) agent.optimizer.zero_grad() loss.backward() agent.optimizer.step()gather 是按 batch 里每条样本实际执行的动作把对应的 Q 值捞出来,只更新这个动作的 Q。target 计算里的(1 - done)是关键的掩码——终止状态后面没有未来收益,不乘这个掩码,Q 值会被系统性高估,训练后期会莫名炸掉。target 网络不参与梯度计算,每几百步用 eval 网络的参数软更新一次。
三个追捕者各持一个这样的 DQN,经验可以共享,网络必须独立。注意一个坑:三个网络初始权重必须不同,否则对称初始化会让所有追捕者学出完全一样的策略,三个人追同一条线,合围永远不可能发生。下面 5.4 节这条坑我再展开讲。
3.2 MADDPG:集中训练、分布执行的真正多智能体解法
三个独立 DQN 的局限很明显:追逃是零和对抗环境,追捕者的观测里同伴在动、逃逸者在学,整个环境对某个追捕者来说是非平稳的。DQN 的经验回放假设环境分布稳定,这在多智能体场景下不成立——这也是为什么三个 DQN 练到后期会原地踏步。
MADDPG 的思路是集中训练、分布执行:actor 只用自己的局部观测选动作,critic 在训练时拿到所有智能体的观测和动作拼接成全局状态来打分。每个追捕者不知道同伴的策略,但 critic 知道,梯度就能绕过非平稳性。核心更新代码:
def maddpg_update(agents, replay_buffer, batch): obs_all = torch.cat([batch["obs"][i] for i in range(len(agents))], dim=1) act_all = torch.cat([batch["action"][i] for i in range(len(agents))], dim=1) for i, agent in enumerate(agents): # 计算 next Q 时,所有智能体的 next action 都要用目标 actor 生成 next_act = [a.target_actor(batch["next_obs"][j]) for j, a in enumerate(agents)] next_q_in = torch.cat( [torch.cat([batch["next_obs"][j] for j in range(len(agents))], dim=1), torch.cat(next_act, dim=1)], dim=1) target_q = batch["reward"][i] + agent.gamma * \ agent.target_critic(next_q_in) * (1 - batch["done"][i]) q = agent.critic(obs_all, act_all) critic_loss = F.mse_loss(q, target_q.detach()) agent.critic_optimizer.zero_grad() critic_loss.backward() agent.critic_optimizer.step()这段和 DQN 最大的差别在 next_act 的拼接:算 next Q 时一个智能体都不能漏,漏掉一个,全局状态就不完整,critic 学到的价值是偏的。另一个麻烦是离散动作——MADDPG 原版是连续动作,如果坚持用 Discrete(5),actor 输出要先过 Gumbel-Softmax,或者干脆把动作放宽成连续值再用 argmax 离散化。课程设计阶段,我建议先把 DQN 基线跑通并拿到能看的指标,MADDPG 作为进阶加分项来调,别一上来就两个算法一起debug。
3.3 训练循环与超参数清单
两份算法的外层训练循环是一样的:reset 拿初始观测,循环 step,攒经验,定期更新。差异只在 agent 内部。统一的主循环大致是:
for episode in range(total_episodes): obs = env.reset() done = False while not done: actions = [agent.choose_action(agent_obs, epsilon) for agent, agent_obs in zip(agents, obs["pursuer"])] next_obs, reward, done, _ = env.step(actions) for i, agent in enumerate(agents): agent.store_transition(obs["pursuer"][i], actions[i], reward["pursuer"][i], done) obs = next_obs if replay_buffer.size() > batch_size: maddpg_update(agents, replay_buffer) if episode % 500 == 0: for agent in agents: agent.soft_update(tau=0.01)这里面最容易翻车的是 epsilon 的退火节奏:前 20% 的 episode 让它从 1.0 线性降到 0.1,后面保持小探索。退太快,智能体没来得及探索到合围路径;退太慢,策略不够 greedy,训练日志指标虚高。常用超参可以直接照抄:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| learning_rate | 1e-3 | critic 可降到 1e-4,更稳 |
| gamma | 0.95 | 单局最多 300 步,0.99 没必要 |
| epsilon | 1.0 -> 0.1 | 前 20% episode 线性退火 |
| buffer_size | 50000 | 网格地图经验多样性有限 |
| batch_size | 128 | 64 也能跑,128 更稳 |
| soft_update tau | 0.01 | 每 500 episode 同步一次 |
多智能体训练某种程度上是门玄学,但超参数翻车造成的假象比算法翻车多得多。两个算法用同一套参数跑,一个收敛一个发散,先怀疑实现差异,再怀疑参数。
4. 源码包怎么跑:目录结构、入口脚本与自定义参数
4.1 目录结构与模块职责
拿到这份源码,先别急着跑 train.py。花十分钟过一遍目录,弄清楚每个文件是干什么的,后面排错至少省半天。这类多智能体平台项目的目录结构大多是下面这个样子:
pursuit_evasion_marl/ ├── envs/ │ ├── __init__.py │ └── pursuit_evasion_env.py ├── agents/ │ ├── base_agent.py │ ├── dqn_agent.py │ └── maddpg_agent.py ├── config.py ├── train.py ├── evaluate.py ├── requirements.txt └── 运行说明.md各文件的职责如下表:
| 文件 | 职责 | 你该关注的点 |
|---|---|---|
| envs/pursuit_evasion_env.py | 游戏逻辑、观测/奖励/done 计算 | 改地图尺寸、改人数都在这里 |
| agents/dqn_agent.py | 单智能体基线训练更新 | 验证环境正确性的第一站 |
| agents/maddpg_agent.py | 集中训练分布执行 | 加分项,最后的难关 |
| config.py | 全部超参与路径配置 | 训练前先改这里 |
| train.py | 训练主循环、模型保存 | 输出 models/ 下的权重 |
| evaluate.py | 加载权重、跑评估指标 | 判断收敛与否的唯一标准 |
容易忽略的一点:agents 目录里几乎一定有个 base_agent.py,它定义 choose_action、store_transition、save_model 这些公共接口。如果你后面想自己加一个 PPO 或者 SAC,继承这个基类会省非常多事——这也是这份代码能叫「平台」而不是「一次性脚本」的关键设计。接手别人的代码时先看基类,比先看具体算法更高效。
4.2 三步跑通训练
环境配置是第一个坎。requirements.txt 里的依赖尽量用 conda 装,别直接用最新版 gym。我一般这么建环境、装依赖、跑训练:
conda create -n marl python=3.9 -y conda activate marl pip install -r requirements.txt python train.py --algo dqn --episodes 2000 --save-dir ./models python evaluate.py --algo dqn --model-path ./models/dqn_best.pth --render前两步是重建干净环境,第三条装依赖,后面两条分别是训练和评估。train.py 的 --episodes 决定训练轮数,课程设计里 2000 轮 DQN 在小地图上足够看到明显的收敛趋势;--save-dir 是模型输出目录,训练过程会自动挑表现最好的 checkpoint 存成 *_best.pth。evaluate.py 的 --render 打开可视化窗口,你能直接看到追捕者和逃逸者在地图上的运动轨迹——这是判断「是不是真学会了」最直观的手段,比盯 loss 曲线诚实得多。
如果 train.py 跑起来一直报 import 错误,八成是 gym 版本不兼容,两成是 Python 版本和 torch 对不上。先把 conda 环境按上面命令重建一遍再跑一次,环境配置问题大多能消掉。我习惯装 gym 0.21.x 这种和老接口兼容的版本,盯着 requirements.txt 锁好的版本来,别手滑升级成最新版。
提示:改完 config.py 里的任何环境参数,都要从头重训,之前存的模型权重不能继续用。
4.3 想改地图和人数:改哪几个参数
课程设计被问得最多的问题就是「能不能换个场景、换个难度」。答案是在 config.py 里改参数就行,不用动算法代码:
# config.py ENV_PARAMS = { "grid_size": 12, # 地图边长,调大训练明显变慢 "n_pursuers": 3, # 追捕者数量 "n_evaders": 1, # 逃逸者数量 "capture_dist": 1.5, # 判定捕获的最近距离 "max_steps": 300, # 单局最大步数 }这里有个必须记住的联动:n_evaders 或 n_pursuers 一改,2.1 节里说的 obs_dim 会自动变,但已经训练好的模型权重输入维度已经固定,直接加载会报尺寸不匹配。所以「改参数 → 重新训练」是铁律。capture_dist 建议保持在网格边长的 1/8 到 1/10,太大变成出生即捕获,太小训练难度陡增。
另外要注意,把 grid_size 调大以后,逃逸者会慢慢学会往角落钻——这不是 bug,是奖励函数的边界效应:角落能把追捕者的逼近方向限制在一侧,逃逸者用距离塑形刷到正收益的策略是自然涌现。想避免这种玩法,在 reset 时把逃逸者出生点限定在地图中央区域,或者给奖励函数加一个「靠近边界扣分」的项,都能缓解。
5. 避坑记录:版本冲突、奖励翻车与评估虚高的五个现场
这一章是我反复跑这套代码攒下来的血泪经验。每条都是真实发生过的翻车现场,按「现象 → 原因 → 解决」写,遇到类似问题直接照方抓药。
5.1 gym 版本升级导致环境 import 直接崩
现象:跑 train.py 直接报错,要么是 "unpacking a sequence of size 5",要么是 gym.make 找不到注册的环境。
原因:gym 在 0.26 之后把 step 的返回值从 4 个改成 5 个,新增了 terminated 和 truncated 两个布尔量,同时环境注册机制也有改动。requirements.txt 如果没锁死版本,直接 pip install gym 装到新版,和源码里的旧式接口就对不上。
解决:锁版本。我一般装 gym 0.21.x,这是和这套代码兼容性最好的搭配。如果非要留在新版 gym,环境类里的 step 要改成返回五元组,训练循环里也要按 (obs, reward, terminated, truncated, info) 解包,改动面很大,课程设计阶段完全不值得。
5.2 距离塑形系数过大,训练曲线像心电图
现象:reward 曲线每几十轮就大起大落,训练几千轮后平均奖励还在零附近晃,一看就是没学会。
原因:塑形项的系数太大,每步距离变化的奖励和捕获大奖已经处于同一量级。智能体发现「靠近一步刷个 3 分」比「费半天劲抓到刷 10 分」更划算,于是策略变成追着逃逸者反复横跳,永远不完成真正合围。
解决:把塑形系数压到主奖励的 1/10 以下。项目默认的 1.0 配 10.0 就是这个比例。你自己改 reward 时记住一条经验线:主奖励至少是每步塑形奖励的 8 到 10 倍。改完系数必须从头重训,曲线才会从「毛刺多」变成「整体下行后收敛」。
5.3 done 条件用 all() 判断,episode 永远拖满 300 步
现象:训练日志里平均 episode 长度稳定在 max_steps,loss 下降也正常,但评估时捕获率是零。
原因:多逃逸者场景下用dists.min(axis=0) < capture_dist再调.all(),只要有一个逃逸者没被抓到,done 就一直是 False,整局被 max_steps 截断。结果训练样本里绝大多数是「没抓到」的长序列,智能体学会了在 300 步内周旋,而不是真正完成捕获。
解决:判断改成.any(),任何一个逃逸者被捕获立刻结束本局。同时建议在 info 里记一下是谁被抓、第几步抓到的,评估时能分开统计。写完 done 判断最好单独写个小脚本,随机撒几个初始位置跑一局,手动确认 done 的触发时机对不对,再开正式训练。
5.4 共享网络导致三个追捕者策略完全同化
现象:三个追捕者的权重文件大小一模一样,运动轨迹高度重合,三个人永远追在同一条线上,逃逸者轻松溜走。
原因:三个 agent 要么共用了同一个 Q 网络实例,要么各自建了网络但用了相同权重初始化。对称博弈下,从相同初值出发、吃相同分布的经验,梯度方向也相同,结果就是学出一模一样的策略。合围需要差异化,三个人都抢同一条追击路线,合围就永远不存在。
解决:每个 agent 独立创建网络,并且用不同的随机种子初始化。更稳妥的是给三个追捕者在 reset 时分配不同的初始位置区域,让探索经验天然有差异。代码里对每个 agent 单独调一遍 torch.manual_seed 即可,这类问题立刻消失。
5.5 评估时忘了关探索,指标虚高
现象:训练日志最后一轮平均奖励已经不错,把模型单独拿出来跑 100 局,捕获率只有训练时的一半。
原因:训练用的是 epsilon-greedy,日志里的奖励是在带探索噪声的条件下统计的;评估时如果继续沿用训练的选动作函数,epsilon 没置零,随机动作拖低了真实水平。更隐蔽的翻车是评估脚本忘了加载权重,直接拿随机初始化的网络在跑。
解决:evaluate.py 里强制把 epsilon 设成 0.0,选动作只走 argmax 分支;加载模型前打印一遍网络参数的均值或校验值,确认和训练保存的一致。评估至少跑 100 局再算平均捕获率,50 局以内波动太大,结论不可信。
6. 验证与调参习惯:用分场景评估判断智能体是不是真学会
6.1 训练曲线别只看奖励,要看捕获率
训练日志里的平均奖励是「感觉指标」,它受探索噪声和 reward 尺度影响很大,曲线向下不代表学坏,向上也不代表学会。真正能用来验收的是捕获率:用贪心策略跑 100 局,统计成功捕获的局数占比。这个数字超过 80%,基本可以认定策略可用;低于 50%,说明训练还没到位,别急着换模型。
6.2 分场景评估与可视化回放
evaluate.py 的核心逻辑其实很短:
success = 0 for _ in range(100): obs = env.reset() while True: actions = [a.greedy_action(o) for a, o in zip(agents, obs["pursuer"])] obs, reward, done, _ = env.step(actions) if done: success += int(env.captured_any) break print(f"capture_rate = {success / 100:.2f}")greedy_action 是选动作时固定走 argmax 分支,env.captured_any 是环境里记录本局是否完成捕获的标志位。如果捕获率达标但回放时发现逃逸者总往同一个死角跑,那是边界 reward 的产物,可以在 reset 里限制出生点,属于调参玄学范畴,不影响主线结论。
从那以后,我每次跑完一套多智能体训练,都强制自己走一遍「100 局 greedy 评估 → 分场景统计 → 可视化回放」的验收流程,缺一步都不算通过。这份源码里的 train.py 和 evaluate.py 就是按这个流程配好的,你拿到后直接改 config 里的参数就能复现出训练曲线和捕获率指标。希望帮到你。
本文还有配套的精品资源,点击获取