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

资讯详情

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

Python多智能体协同围捕实战:环境建模、PPO训练与跨环境迁移

Python多智能体协同围捕实战:环境建模、PPO训练与跨环境迁移

简介:这套多智能体协同围捕算法Python源码,源于一项获导师好评的学术研究,完整实现不同环境配置下多个智能体通过通信、路径规划与协同决策完成目标围捕的核心逻辑。代码面向计算机相关专业开展课程设计、综合实践或项目开发的学生,也可作为多智能体系统、路径规划与博弈决策方向的研究参考。资源包共18个文件,其中13个Python脚本构成主体,涵盖多出口围捕、凸障碍环境、Voronoi划分、MADDPG训练器等模块,同时附带备份文件、说明文档与打包数据,便于对照工程结构理解程序组织。压缩包仅57KB,小巧轻量,适合快速部署测试;目前已有67人学习使用。内容既包含可直接运行的算法程序,也保留了不同场景下的仿真入口,有助于读者结合方案说明拆解协作机制与代码实现,是一份兼顾教学演示与二次开发参考的实用源码包。

1. 接手一个多智能体协同围捕任务,别急着上强化学习

多智能体协同围捕算法在 Python 里落地,难点往往不在算法公式,而在环境建模和复用边界。做仓储巡检或者无人机安防的人应该都有体会:让三五个机器人围住一个移动目标,单看每个机器人移动都不难,难的是它们能共享信息、互补站位,在同一个决策框架里稳定收敛。Python 生态让这件事能在一个二维沙盘里先跑起来,再迁移到 Webots、Gazebo 这类机器人仿真环境,但跨环境迁移时的参数和接口问题,比想象中多得多。这篇笔记想讲透一件事:如何用 Python 实现一套可迁移的协同围捕基线,并把项目解析、避坑经验和验证方法一次说清楚,适合正在做机器人集群、多智能体强化学习或者相关算法验证的开发者。

2. 协同围捕先建模:状态空间、动作空间与奖励参数决定收敛上限

很多项目翻车不是因为算法选错,而是起步时没把多智能体围捕问题建模清楚。围捕本质上是一个追逃博弈:N 个追捕者要围住一个逃逸者,逃逸者也会根据追捕者的位置实时改变逃跑方向。你需要先把状态、动作、奖励这几个东西固定下来,否则后面写训练代码时,每一步都不知道该看什么指标。

2.1 从追逃博弈到部分可观测马尔可夫决策过程:先定下谁的全局视野

围捕任务在学术上通常建模为 DEC-POMDP,即去中心化部分可观测马尔可夫决策过程。实际做项目时不用背这个概念,但里面一个选择必须做:每个追捕者的观测是全局信息还是局部信息。

我一般先跑一个全局观测的版本,证明算法有收敛能力,然后再切到局部观测。全局观测就是直接把所有追捕者和逃逸者的坐标拼成一个向量,代码简单,调试直观;局部观测则是每个追捕者只能看到自己周围一定半径内的队友和猎物,更贴近真实机器人,但训练难度明显上升。

建模项推荐起始配置说明
智能体数量3 个追捕者 + 1 个逃逸者3 个追捕者最容易形成包围圈
观测空间所有智能体坐标拼接先跑通基线,再切局部观测
动作空间连续二维速度连续动作便于后续迁移到真实机器人
捕获条件任意 2 个追捕者同时进入逃逸者半径内比单个追捕者碰到目标更接近围捕语义
单回合上限200 步太长训练慢,太短围捕完不成

这里有个容易忽视的点:捕获判定不应该设成“任意一个追捕者碰到逃逸者”,否则智能体会退化成单纯追猎,而不是协同围捕。用“至少两个追捕者同时进入捕获半径”作为终止条件,才能逼着它们分散站位。

2.2 包围策略比追逐策略更容易先收敛:半径、夹角与旋转方向

在没有强化学习之前,围捕问题可以用几何方法当基线。最简单的做法是给每个追捕者计算一个目标点,让这些目标点均匀分布在逃逸者周围,并且让每个追捕者去追离自己最近的目标点。这个做法能让追捕者先从四面八方包过去,而不是一起往逃逸者身后追。

import numpy as np def generate_encircling_targets(pursuers, evader, radius=2.0): """ 生成各追捕者的期望围捕位置。 pursuers: (n, 2) 追捕者坐标 evader: (2,) 逃逸者坐标 radius: 围捕半径,同时决定包围圈大小 """ n = len(pursuers) # 先把圆周按追捕者数量等分 angles = np.linspace(0, 2 * np.pi, n, endpoint=False) # 计算每个追捕者相对逃逸者的当前方位角 offsets = pursuers - evader heading = np.arctan2(offsets[:, 1], offsets[:, 0]) # 目标角度 = 等分角度 + 当前方位角,实现从当前位置出发的环形分配 target_angles = angles + heading targets = evader + radius * np.stack( [np.cos(target_angles), np.sin(target_angles)], axis=1 ) return targets

这段代码的关键是target_angles = angles + heading。如果不加当前方位角,每个追捕者的目标点永远是固定的 0 度、120 度、240 度,容易出现交叉换位,效率很低。加上heading之后,目标点会顺着追捕者当前方位旋转,相当于让离哪个方位近的追捕者就去补哪个方位。

这里的radius是围捕半径,不是捕获半径。围捕半径可以比捕获半径大得多,比如捕获半径是 0.5,围捕半径先设成 2.0,让追捕者先在外圈站好位,再一起收敛。这个几何基线的价值在于:如果这个简单策略已经能到 60% 的捕获率,那你再用强化学习就有一个非常明确的对比对象,而不是对着一个黑匣子猜超参。

2.3 奖励参数必须拆分:捕获奖励、距离奖励与围捕熵的权重怎么调

如果直接把捕获成功当成唯一奖励,追捕者要在一个 200 步的回合里完成大量随机探索,训练效率会低到让人劝退。常见的做法是把奖励拆成三份:

  • 捕获奖励:捕获成功后给一个较大的正奖励,比如 +5。
  • 距离奖励:每个追捕者与逃逸者的距离减少量,乘一个权重后累加。
  • 围捕熵:用追捕者相对逃逸者的角度分布计算熵,熵越大,包围圈越均匀。
  • 每步惩罚:给一个很小的负奖励,比如 -0.05,逼着智能体高效行动。

奖励公式可以写成:

R = alpha * delta_distance + beta * delta_entropy + gamma * capture_reward + step_penalty

delta_distance是这一步所有追捕者到逃逸者距离之和的变化量,负值意味着整体在靠近。delta_entropy是围捕熵的变化量,提醒追捕者别挤成一团。alpha我一般取 0.5,beta取 0.2,gamma不要太大,否则智能体会过度投机,只往逃逸者身上撞。

提示:奖励权重不是绝对值越大越好,它要和学习率匹配。如果发现训练 5000 步奖励曲线还在来回震荡,先把所有奖励值整体除以 10,再调整学习率,往往比盲目改网络结构更有效。

围捕熵的计算也比较简单:以逃逸者为圆心,把所有追捕者的方位角收集起来,做直方图统计,再计算熵。这个指标不仅可以进奖励函数,也可以在后期的评估阶段单独拿出来看。

3. Python 实现一个最小围捕环境:Gym 接口、step 逻辑与训练闭环

建模完了就要动手写环境。这里强烈建议把环境接口设计成 Gym 风格,也就是统一reset、step、obs、reward、done、info这套协议。原因不是为好看,而是为了后面跨环境复用。你在 Gazebo 里跑机器人,最终也要把传感器数据包装成 obs,把速度指令包装成 action;提前把环境层和算法层切干净,后续迁移会省一大半时间。

3.1 为什么用 Gym 式接口为跨环境应用打下地基

Gym 式接口的核心很简单:环境只负责输出状态和执行动作,算法只负责根据状态输出动作。回到围捕任务里,reset生成追捕者和逃逸者的初始位置,step接收每个追捕者的动作,更新坐标,返回新观测、奖励、是否结束和额外信息。

这个接口越干净,你的算法就越不关心环境到底是 Python 数组还是 Gazebo 激光数据。后面做跨环境应用时,只需要写一个适配器,把真实机器人数据转成同样的 obs 格式即可。

3.2 写一个可运行的最小环境:reset、step 与捕获判定

下面是一个最简围捕环境实现,适合作为第一版基线。它不依赖任何强化学习库,直接用 NumPy 就能跑起来。

import numpy as np class PursuitEnv: def __init__(self, n_pursuers=3, field_size=10.0, capture_radius=0.5, max_steps=200): self.n_pursuers = n_pursuers self.field_size = field_size self.capture_radius = capture_radius self.max_steps = max_steps self.dt = 0.1 self.action_repeat = 4 self.obs_dim = 2 * (n_pursuers + 1) self.action_dim = 2 self.reset() def reset(self): # 追捕者初始位置分散在场地中,避免一开局就贴脸 self.pursuers = np.random.uniform( 1.0, self.field_size - 1.0, size=(self.n_pursuers, 2) ) self.evader = np.random.uniform( 1.0, self.field_size - 1.0, size=(2,) ) self.step_num = 0 return self._get_obs() def _get_obs(self): # 全局观测:所有追捕者坐标 + 逃逸者坐标 return np.concatenate( [self.pursuers.flatten(), self.evader] ).astype(np.float32) def _evader_policy(self): # 逃逸者策略:朝最近的追捕者反方向跑 dists = np.linalg.norm(self.pursuers - self.evader, axis=1) nearest = self.pursuers[np.argmin(dists)] direction = self.evader - nearest norm = np.linalg.norm(direction) + 1e-8 return 0.5 * direction / norm def step(self, actions): # 动作限定在 [-1, 1],避免跑出离谱速度 actions = np.clip(actions, -1.0, 1.0) # 追捕者行动:动作 * dt * action_repeat 表示一个决策周期内的位移 self.pursuers = np.clip( self.pursuers + actions * self.dt * self.action_repeat, 0, self.field_size ) # 逃逸者同步行动 self.evader = np.clip( self.evader + self._evader_policy() * self.dt * self.action_repeat, 0, self.field_size ) self.step_num += 1 captured = self._check_capture() done = captured or self.step_num >= self.max_steps # 稀疏奖励:捕获成功 +5,每一步 -0.05 reward = 5.0 if captured else -0.05 return self._get_obs(), reward, done, {"captured": captured} def _check_capture(self): # 任意两个追捕者同时进入捕获半径,才算围捕成功 within = np.linalg.norm( self.pursuers - self.evader, axis=1 ) < self.capture_radius return np.sum(within) >= 2

这段代码里最需要注意的参数是action_repeat。它不是运动学必需的,而是为了模拟决策频率:强化学习策略每发出一个动作,环境内部连续推进action_repeat个子步。真实机器人上,你不可能让策略在每一个控制周期都做一次推理,通常策略频率低,控制频率高。把这个参数设置在环境里,后面从二维迁移到三维仿真时,只需要调整action_repeat和dt,不需要改算法。

dt在这个模型里被同时用于追捕者和逃逸者的位移,注意这里只是简化物理,不是真正的动力学仿真。如果你要接入 Gazebo 这类物理引擎,环境内部的物理逻辑可以全部换成机器人 API,但对外输出的 obs 和动作动作空间保持这个形式就行。

初始位置用np.random.uniform(1.0, field_size - 1.0)是为了避免智能体出生在边界上,导致第一帧就撞墙。这个看似小的设定,其实对训练影响很大,边界出生会让智能体学到无效的原地踏步策略。

3.3 接入 RLlib 或自写 PPO:共享策略比单独策略更容易复现

环境写好后,可以先手动测试一下动作:随机生成几个动作,循环跑 100 步,看看追捕者会不会靠近逃逸者。这一步过关后,再接入强化学习库。

常见的做法是用 Ray RLlib 的 PPO 跑多智能体场景。由于上面环境只返回单个观测向量,你需要把它包装成 RLlib 能识别的 Gym 环境形式,然后修改两个地方:一是告诉环境用几个智能体推进,二是把策略网络配置成共享参数。

from ray.rllib.algorithms.ppo import PPOConfig config = PPOConfig() config.environment( env=PursuitEnv, env_config={ "n_pursuers": 3, "field_size": 10.0, "capture_radius": 0.5, "max_steps": 200, }, ) config.rollouts(num_rollout_workers=2, rollout_fragment_length=200) config.training( lr=3e-4, gamma=0.99, train_batch_size=4000, sgd_minibatch_size=512, ) algo = config.build()

这里用的是共享策略,也就是 3 个追捕者共用一套网络参数。共享策略的好处是训练稳定,不像独立策略那样每个智能体各训一个网络,容易出现某个追捕者特别激进、另一个追捕者原地不动的情况。第一次跑通时,训练曲线会来回抖动,不用慌,先让它跑完 200 个训练迭代,再看捕获率。

4. 跨环境应用:同一套围捕逻辑如何在 PyGame、Webots、Gazebo 之间复用

标题里最容易被低估的两个字是“跨环境”。很多人以为跨环境就是把 Python 代码原样放到另一个仿真器里跑。实际上,真正能跨环境复用的是算法和接口,不是环境内部的一行行物理代码。把环境适配层写好,从二维 PyGame 迁移到 Webots、Gazebo,改动量可以控制在一天以内。

4.1 环境与算法解耦的唯一标准:策略只认 obs 和 action

跨环境复用的核心是定义好适配器接口。你可以把环境后端看作一个黑盒,它负责产生观测、执行动作,而策略永远只拿obs进,拿action出。

class PursuitAdapter: def __init__(self, backend): self.backend = backend def get_obs(self): return self.backend.get_observation() def send_action(self, action): return self.backend.apply_action(action)

这看起来简单,但真正做到很难。很多项目在二维环境里把状态直接写成[x1, y1, x2, y2, ...],到了 Gazebo 里又为了迁就传感器,把状态改成带朝向角和线速度的格式,算法输入维度一变,之前的训练成果全部作废。正确做法是:无论后端是二维沙盘还是三维机器人仿真,适配器输出的 obs 永远是同一个长度、同一个含义的数组。

我一般会在适配器里把坐标和速度先做归一化。比如二维环境场地是 10 米,Gazebo 世界可能是 50 米,直接用原始坐标,策略在换环境后的第一帧就会懵掉。统一除以field_size,控制在 0 到 1 之间,是成本最低的鲁棒性提升手段。

4.2 PyGame 二维沙盘:改渲染不改算法

PyGame 在围捕项目里最大价值是可视化调试,不是参与计算。你在环境内部已经用 NumPy 更新坐标了,PyGame 只需要把坐标放大到窗口尺寸,再用pygame.draw.circle画追捕者和逃逸者即可。这种分离带来的好处是:你可以关闭渲染跑训练,开启渲染看回放,二者互不干扰。

一个常见错误是把 PyGame 的主循环和强化学习训练写在一起,导致训练被渲染卡在 30 FPS,进度慢得离谱。我一般会把环境实现分成两个类:PursuitStepEnv负责逻辑,PursuitRenderEnv负责渲染,渲染类持有逻辑类的实例,只在render()里读取坐标。这样跨环境迁移时,最低成本的做法就是整块删掉渲染类,换成 Webots 或 Gazebo 的可视化接口。

PyGame 迁移到 Webots 时还要注意时间步长。PyGame 沙盘里一帧可以当作一个决策周期,但 Webots 里 world 基础步长通常是 16 毫秒或 32 毫秒,机器人的速度指令是以 m/s 为单位的。你需要把环境里dt * action_repeat换成真实的控制周期,否则策略输出的动作会被机器人执行得过快或过慢。

4.3 从 2D 到 3D 真正要调的东西:坐标系、速度量纲与动作频率

Gazebo 和 Webots 这类三维仿真,最常踩的坑不是算法本身,而是坐标转换和单位换算。

二维围捕环境里动作是[vx, vy],表示平面内两个方向的速度,这个模型适合全向移动机器人。但很多真实机器人是差速驱动,只能接受线速度和角速度。此时需要一个适配层,把[vx, vy]转换成[linear, angular]。常见的转换逻辑是这样的:

def convert_action_to_diff_drive(action, current_yaw): vx, vy = action linear = np.hypot(vx, vy) target_yaw = np.arctan2(vy, vx) angular = wrap_to_pi(target_yaw - current_yaw) return np.array([linear, angular]) def wrap_to_pi(angle): return (angle + np.pi) % (2 * np.pi) - np.pi

这里wrap_to_pi很重要。如果你不做角度环绕归一化,那么当目标角度从 179 度变到 -179 度时,角度差会被算成 358 度,机器人会往反方向猛转。做过真实机器人的人都懂这种翻车。迁移到 Gazebo 前,先确认机器人的模型是全向还是差速,再决定是否需要这层转换。

速度量纲也是个大坑。二维沙盘里你说“速度 1”,可能对应的是一个步进位移;Gazebo 里线速度 1 就是每秒 1 米。如果环境里没有做缩放,策略学习到的速度范围在三维仿真里可能直接让机器人冲过头。解决方式是把适配层的输入输出都归一化到 [-1, 1],然后在实现层乘上限速系数,例如max_linear_speed = 0.5。

5. 项目解析与避坑:最容易让协同围捕训练翻车的四个模块边界

协同围捕项目看着不大,但真正从模型训练一路走到跨环境部署时,有一些问题会反复出现。下面按“现象 → 原因 → 解决”的顺序来拆解,每一段都是血泪经验。

5.1 同一套代码,换场地尺寸后训练崩溃

现象:在 10x10 的场地里训练得很好,捕获率 80%。把field_size改成 20 后,训练上千步,捕获率直接掉到 0,损失曲线震荡不停。

原因:奖励函数里的距离奖励和捕获半径没有跟着场地尺寸缩放。场地变大以后,追捕者到逃逸者的初始距离从 5 涨到 15,同样的奖励权重视角下,距离信息对策略的引导作用变小。捕获半径仍然是 0.5,在更大尺度下相当于更难完成围捕。

解决:把奖励里的距离项除以field_size,或者直接用归一化距离。例如delta_distance / field_size作为奖励增量。如果是固定场景规模,就把field_size写进环境配置,并用一个基准实验对比不同规模下的表现。

5.2 智能体数量变化导致网络维度冲突

现象:3 个追捕者能正常训练,把n_pursuers改成 4 后,程序报维度不匹配,或者训练能跑但行为明显混乱。

原因:观测向量是2 * (n_pursuers + 1),策略网络输入层把智能体数量写死了。增删一个智能体,就改变了网络输入维度和输出维度,之前保存的权重直接废掉。

解决:如果计划支持不同数量的智能体,就不要把网络输入硬编码为固定长度。常见做法是用最大智能体数做 padding:比如最多 6 个追捕者,每次只激活前 N 个位置,动作里无效位置填 0。另一种做法是改成基于聚合特征的输入,例如所有追捕者分别到逃逸者的距离和方位角,这样数量变化时特征长度不变。项目初稿阶段先定死智能体数量,也是一个可行策略,但要在配置文件里写死,别让别人去猜。

5.3 跨环境时动作坐标系方向反了

现象:PyGame 沙盘里策略是正常的,换到 Gazebo 后,追捕者总往逃逸者的反方向跑,怎么调学习率都无效。

原因:二维环境坐标系和 Gazebo 世界系不一致,常见的是 Y 轴方向相反。Gazebo 用右手坐标系,而很多 2D 图形库默认左上角原点,Y 轴向下。这一步在转换时被忽略了,导致所有动作方向上下颠倒。

解决:适配器里加一个坐标变换标志位:

def transform_position(pos, world_to_env=True): if world_to_env: return np.array([pos[0], -pos[1]]) return np.array([pos[0], -pos[1]])

我建议在get_obs和send_action两个方法里都做一次坐标变换,而不是把 Gazebo 原坐标直接传给策略。测试方法很简单:手动让逃逸者静止,给追捕者一个向 X 轴正方向的速度动作,看机器人是否真的往 X 正方向移动。

5.4 随机种子重复导致多进程实验不可复现

现象:同一个随机种子跑三次,三次结果差别很大,让人怀疑算法是不是玄学。

原因:种子只在环境初始化时设置了一次,但多个训练 worker 会复用同一个 RNG 状态。尤其在使用 RLlib、Ray 这类多进程库时,每个 worker 拿到的初始随机数序列完全相同,导致采集数据过于相似,训练效果波动极大。

解决:保存一个基础 seed,在配置里记录,然后在每个 worker 初始化时用base_seed + worker_id作为新的随机种子。评测阶段单独固定一个评测种子,保证每次测试追捕者初始位置完全一致,比如[42, 43, 44]。

5.5 项目配置、日志和模型检查点怎么组织

跨环境项目的复杂度一半来自配置管理。推荐把所有超参写进一个 YAML 文件,而不是散落在代码里。

env: n_pursuers: 3 field_size: 10.0 capture_radius: 0.5 max_steps: 200 dt: 0.1 action_repeat: 4 algo: name: PPO lr: 3.0e-4 gamma: 0.99 train_batch_size: 4000 sgd_minibatch_size: 512 seed: 41 log_dir: runs/pursuit_20250101

每次训练跑完,把这一份 YAML 和模型权重保存在同一个目录下。加载模型时,先读配置,再重建环境和策略,这样跨环境复现才不会变成一场灾难。如果你只用代码里的参数跑,过两周再看,连自己都会忘记当时用的奖励权重到底是多少。

6. 验证围捕效果比追求训练曲线更难:评估指标、可视化与三个实验习惯

训练曲线只是过程,围捕效果才是交付标准。我习惯在每次训练结束后跑 100 个评估回合,统计三个指标:捕获率、平均捕获步数、围捕熵。捕获率代表任务完成度,平均捕获步数代表效率,围捕熵则能看出追捕者是不是真的形成包围圈。

围捕熵可以在评估阶段单独计算,不进训练日志:

def encirclement_entropy(pursuers, evader, bins=8): angles = np.arctan2( pursuers[:, 1] - evader[1], pursuers[:, 0] - evader[0] ) hist, _ = np.histogram(angles, bins=bins, range=(-np.pi, np.pi)) prob = hist / hist.sum() return -np.sum(prob * np.log(prob + 1e-8))

这个熵值如果长期接近 0,说明追捕者永远挤在同一个方向,根本没有形成围捕,只是跟着逃逸者屁股后面追。真正有效的协同围捕,熵值应该稳定在一个较高水平,并且捕获率同步上升。

可视化调试我推荐一个最精简的方案:评估阶段保存每回合的关键帧图片,命名格式为episode_回合数_step_步数.png。这样即使不看视频,也能快速定位是哪一步开始失去协同。跨环境部署时尤其需要这种历史回放,因为三维仿真里的失败往往发生在你不在现场的时段。

最后说三个实验习惯。第一,每次训练必须记录配置、种子、模型版本,三者同时放在保存目录里,缺一个就不算可复现实验。第二,调参时紧着一只变量调,学习率、奖励权重、围捕半径一次只动一个,否则训练崩了根本不知道是哪一步导致的。第三,在你确定强化学习算法之前,先把几何包围基线跑一遍,它既是你算法的对照,也是你排查环境 bug 的工具。

我自己的习惯是,任何一次实验跑完,先看捕获率、平均捕获步数、围捕熵三项指标,再保存一份配置文件到结果目录里。围捕训练不是一句“算法不行”就能甩锅的问题,多数翻车都出在环境参数和接口上。把环境做稳、接口做窄、记录做全,这个方向完全值得投入。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表