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

资讯详情

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

多智能体强化学习入门:基于Gym的追逃博弈平台实战解析

多智能体强化学习入门:基于Gym的追逃博弈平台实战解析 简介强化学习通过智能体与环境的持续交互优化决策策略而多智能体场景则进一步引入了协作与对抗的复杂博弈关系。追逃博弈作为控制论与博弈论的经典问题天然具备多智能体对抗与协作并存的特征是学习多智能体强化学习的高价值载体。在工程实践中基于 Gym 框架搭建统一接口的环境不仅能实现状态空间、动作空间与奖励函数的模块化设计还能高效适配 MADDPG、独立 PPO 等主流算法。通过合理的奖励塑造与训练稳定性调优开发者可清晰评估算法在围捕协作、策略对抗中的表现。这类项目广泛应用于课程设计、毕业设计及算法对比实验帮助研究者从环境建模到策略训练形成完整认知从而更平滑地迁移到其他多智能体博弈任务中。 最近在整理这个追逃博弈项目的时候正好有不少朋友来问多智能体强化学习入门到底怎么做拿什么场景练手最合适我一直推荐追逃博弈。原因很简单它天然就是多智能体对抗和协作并存的场景追捕方要协作围堵逃跑方要单独决策双方目标直接对立训练过程直观、可评估、可视化效果好用来学习多智能体强化学习再合适不过。这篇文章我会把一套基于 Python 和 gym 框架的多智能体追逃博弈强化学习平台完整拆开讲。它不是一个玩具demo而是我实际用来跑 MADDPG、独立PPO、TD3 等算法对比实验的项目。你可以直接把它当作课程设计、毕业设计的多智能体强化学习方向参考因为它把环境建模、奖励设计、算法封装、训练稳定性和调试技巧都覆盖了。如果你正在琢磨 “多智能体强化学习环境怎么搭” 或者 “追逃博弈的奖励函数怎么设计”这篇内容应该能帮你省下几个星期的试错时间。1. 内容整体设计与思路拆解1.1 追逃博弈为什么天然适合强化学习追逃博弈pursuit-evasion是控制论和博弈论里的经典问题一组追捕者要在一个有边界的区域里捕获一个或多个逃跑者。放到强化学习的语境下它的好处非常明显。第一它具备标准的马尔可夫决策过程要素。状态是各方智能体的位置、速度、观测信息动作是加速度或转向控制奖励函数可以围绕捕获距离、逃逸成功、时耗、边界约束来设计。所有要素都能清晰定义不存在歧义。第二它天然是多智能体问题但又不像围棋、Dota 那种复杂到需要巨大算力才能跑起来。追逃博弈的智能体数量通常很少两三个追捕者对一个逃跑者就能产生非常丰富的协作策略。对初学者算力压力也小普通笔记本的 CPU 就能跑通。第三它的对抗性和协作性同时存在。追捕者之间需要学会配合比如从两侧包抄而不是挤在一起追同一个点逃跑者要学会利用边界、变速变向而不是直线逃跑。这种协作和对抗并存的特点是很多多智能体场景的缩影学会在这个项目里设计智能体策略迁移到其他场景也相对顺滑。第四训练效果容易量化。你可以用“追捕成功率”、“平均捕获步数”、“逃跑者存活时间”这些指标直接评估算法优劣对比实验做起来非常方便。1.2 为什么选 gym 框架来做环境承载这个问题值得好好说说。很多新手会问我不用 gym直接用 Python 写个类来模拟环境不也一样吗理论上是但 gym现在叫 Gymnasium的价值在于它定义了一套统一的接口规范reset、step、observation_space、action_space、reward。这套规范不是摆设它带来三个实际好处。首先算法代码和环境代码分离。你可以把 PPO、MADDPG、TD3 这些算法写成一个通用训练器只需要环境满足 gym 接口规范换环境时训练代码几乎不用改。也就是说你这次跑追逃博弈下次想跑多智能体协作搬运核心算法代码直接复用。如果不用 gym每换一个场景就要重写训练循环非常痛苦。其次生态齐全。OpenAI 的 baseline、CleanRL、stable-Baselines3 等主流强化学习库都支持 gym 接口。虽然这些库多数面向单智能体但你可以借鉴它们的训练循环、经验回放、buffer 管理代码把多智能体版本改出来。没有 gym 接口这些现成的东西都用不上。最后可调试性更好。gym 环境可以通过 env.render() 可视化智能体的运动过程这对强化学习调试来说几乎是必须的。你训练一个策略不能只看奖励曲线还要看智能体是不是真的在学会“追捕”和“逃跑”一张动态仿真图比任何 log 都直观。我们这个项目的环境设计就是扩展 gym.Env 写一个自定义环境多智能体共享一个二维空间每个智能体是带有质量和速度上限的小圆点追捕者在半径 R 内捕获逃跑者即算成功。1.3 方案选型背后的取舍逻辑整套平台的方案选型我是有意识做了一些取舍的。动作空间上我选择连续动作空间而不是离散动作空间。原因很简单追逃博弈本质是连续控制问题智能体需要平滑地调整速度和方向。如果用离散动作比如前、后、左、右策略会显得非常僵硬逃跑者很容易被围住训练出来的策略也不容易迁移到真实的连续控制场景。当然离散动作空间训练起来更简单收敛更快所以我保留了环境里的动作空间参数你可以通过一个参数在连续和离散之间切换做对比实验。状态空间上我没有把所有智能体的全局状态全塞给每个智能体。实际项目里我给每个追捕者设置了一个有限的观测范围它只能看到一定半径内的其他智能体状态。这个设计更接近现实也为后续做部分可观测POMDP方向的扩展留了接口。当然参数是可以调的如果设成全局观测就等价于中心化训练、中心化执行训练会好收敛很多。奖励设计上我采用稀疏奖励结合形状奖励的混合方案。纯稀疏奖励捕获到给 1没捕获到给 0训练速度太慢纯形状奖励则容易让策略钻奖励函数的空子走捷径。最后我选择每一步根据追捕者与逃跑者的距离变化给一个小额密奖励捕获成功给一个大奖励逃跑者逃出边界给追捕者大惩罚逃跑者则反过来。这个细节在后面第 2 部分会展开。算法选型上我优先推荐 MADDPG 作为基线和主推算法同时也留了独立 PPO 的接口用于消融实验。MADDPG 的核心思想是中心化训练去中心化执行训练时 critic 可以看到所有智能体的状态和动作actor 只用自己的局部观测做决策。这个设计正好符合追逃博弈“全局评估、局部决策”的需求是所有多智能体算法里最适合入门的一个。2. 核心细节解析与实操要点2.1 状态空间设计到底要让智能体看到什么状态空间是环境设计里最关键的一步。很多初学者喜欢把所有信息一股脑丢给智能体看似信息越全越好实际结果往往很差维度爆炸、训练极慢、策略不收敛。追逃博弈的状态设计需要想清楚一个问题每个智能体做决策时真正需要哪些信息以逃跑者为例它需要知道自己的位置 (x, y)自己的速度 (vx, vy)自己的航向角 θ与最近追捕者的相对位置和相对速度与边界的最短距离决定是否需要转向避开边界追捕者需要知道自己的位置、速度、航向与逃跑者的相对位置和相对速度其他追捕者的位置用于协作包围边界距离这里有个细节值得注意我不是把“绝对坐标”直接作为特征而是把自己的观测空间设计成以自身为中心的极坐标特征组合。相对距离、方位角、相对速度这些特征比绝对坐标更有利于策略的泛化能力。因为智能体无论身处地图哪个位置看到的“局面”都是类似的策略学到的是一种空间关系判断能力而不是死记硬背几个坐标点。我自己在实际跑的时候状态向量设计大概是这样的智能体状态维度具体内容追捕者10 维自身位置(2) 自身速度(2) 与逃跑者相对距离及方位(2) 最近队友相对位置(2) 距边界最近距离(1) 是否观测到逃跑者(1)逃跑者10 维自身位置(2) 自身速度(2) 与最近追捕者相对距离及方位(2) 追捕者数量(1) 距边界最近距离(1) 是否被观测到(1) 剩余步数归一化(1)这个设计不是一步到位的中间调整过很多次。一开始我把所有追捕者的位置都拼进去结果发现智能体数量一变状态维度就变训练代码改起来很崩溃。后来改成“最近队友”“最近追捕者”这种聚合特征状态维度固定智能体数量增减都不影响维度一致性代码通用性一下子好了很多。2.2 动作空间设计连续动作的边界与策略动作空间决定了智能体能做出哪些控制指令。这个项目里我默认使用连续动作空间每个智能体的动作是一个二维向量 (ax, ay)表示 X 轴和 Y 轴方向的加速度控制量取值范围 [-1, 1] 归一化后映射到实际最大加速度。有些初学者会问为什么不用“方向 速度”这种更直觉化的二维动作而是用 (ax, ay)原因是用加速度分量做动作环境动力学更简单直接而且这个动作维度就是连续控制的最终指令形态和真实机器人控制方式一致。策略网络输出一个二维向量直接叠加到当前速度上不需要额外的方向转换逻辑。实际运动学公式很简单vx_new clamp(vx ax * max_accel * dt, -max_speed, max_speed) vy_new clamp(vy ay * max_accel * dt, -max_speed, max_speed) x_new x vx_new * dt y_new y vy_new * dt这里 clamp 非常重要。如果不限制最大速度智能体会在奖励函数的驱动下不断加速到不可控的状态训练不稳定。限制最大速度还能保证追捕者和逃跑者之间存在“速度差异”的设计空间比如逃跑者最大速度略高于追捕者这样追捕者就必须通过协作和抄近路来弥补速度劣势策略更有看点。我还做了一个可选的离散动作模式把动作拆成 4 个方向键。切换参数 action_modediscrete 就行。这个模式适合做算法对比实验离散动作可以让独立 PPO 这类单智能体算法更容易收敛方便你验证“是不是环境本身有 bug”。2.3 奖励函数设计追捕者和逃跑者完全相反的目标奖励函数是整个项目里最需要反复打磨的部分。追逃博弈的奖励设计有个天然优势追捕者和逃跑者的目标相反奖励函数天然形成对抗关系不需要额外设计博弈机制。追捕者的奖励我这样设计r_pursuer λ1 * Δd λ2 * cooperation_reward catch_reward - λ3 * boundary_penalty其中Δd 是“追捕者与逃跑者距离变化量”。如果这一步追捕者离逃跑者更近了给一个正奖励比如 0.1如果远了给负奖励。这个距离变化奖励是训练能够收敛的核心驱动。我实测下来λ1 取 0.5 比较合适。cooperation_reward 是协作奖励当两个追捕者处于逃跑者两侧连线的夹角在 120° 到 160° 之间时给予额外的正奖励。这个设计是为了让追捕者学会包抄而不是扎堆。一开始我没加这个奖励智能体学会的是“所有人都往逃跑者当前位置冲”导致它们常常撞在一起整体围捕效率很低。后来加了协作奖励策略明显改善。catch_reward 是捕获到逃跑者时的大奖励我设为 10这个数值要比所有单步奖励之和都大让智能体明确知道自己到底该追求什么。boundary_penalty 是越界惩罚我设为 -5强度比单步奖励高因为越界是严重失误需要立刻纠正。逃跑者的奖励对称设计r_evader λ1 * (-Δd) survive_reward - λ4 * boundary_penalty逃跑者每一步存活下来给一个小正奖励比如 0.01但这里有个关键点存活奖励不能太大否则逃跑者会学出一个“原地转圈”的极简策略来最大化累计奖励。我建议把存活奖励设置得非常小主要奖励还是来自“拉开距离”的 Δd 项以及最终存活到边界或时间耗尽的大奖励。2.4 终止条件设计什么时候算一个 episode 结束终止条件的设置直接影响训练效率和策略行为。我的环境里定义了三类终止捕获成功追捕者与逃跑者的距离小于捕获半径默认 0.5 米episode 结束追捕者获得 10 奖励。逃跑成功逃跑者跑出边界episode 结束追捕者获得 -10 奖励即逃跑者视为成功。时间耗尽达到最大步数默认 500 步episode 结束双方各按当前距离状态给予一个终局奖励。这里面有个细节时间耗尽的终局奖励应该怎么给我建议不要给 0因为训练里大量 episode 会走到时间耗尽分支如果奖励全是 0这个方向完全没有梯度信号。正确的做法是根据终局时追捕者和逃跑者的距离给一个相对小的奖励如果距离小于捕获半径的 2 倍就按比例给小奖励如果距离很远就给负奖励。这样智能体在时间耗尽时不会出现“无所谓”的盲区训练信号时刻都在。2.5 训练稳定性为什么奖励尺度对齐如此重要多智能体强化学习训练不稳定的一个常见原因就是双方奖励尺度不匹配。追捕者和逃跑者的奖励如果量级差太多会直接导致训练震荡甚至发散。举个具体的坑我一开始追捕者的单步距离奖励是 0.5逃跑者则是 -0.5但捕获奖励给了 50。结果追捕者训练非常激进总是试图“猛冲”去捕获但策略极度不稳定方差极大奖励曲线像过山车。后来我把所有奖励都按比例缩放到同一量级单步奖励不超过 0.5事件奖励不超过 10训练立刻稳定了很多。稳定奖励尺度的技巧很简单在训练刚开始时打印每个智能体的累计奖励均值和方差确保两个阵营的奖励量级不超过 10 倍差距。一旦发现某方奖励爆炸优先压缩事件类奖励的幅度再调整单步奖励的比例。3. 实操过程与核心环节实现3.1 环境代码实现核心部分详解环境类我命名为 PursuitEvasionEnv继承自 gym.Env。代码的核心部分在 reset 和 step 两个方法里。reset 方法负责初始化每个智能体的位置和速度。默认初始位置是随机生成的但有个参数可以控制随机范围如果 init_modefixed追捕者和逃跑者每次都从固定位置开始方便单步调试init_moderandom 则随机生成用来训练泛化策略。实际做训练时用 random做调试时用 fixed这个“双模式”设计帮我省了很多时间。import gym import numpy as np from gym import spaces class PursuitEvasionEnv(gym.Env): metadata {render_modes: [human, rgb_array]} def __init__(self, num_pursuers3, map_size10.0, max_steps500, catch_radius0.5, max_speed_pursuer1.0, max_speed_evader1.2, action_modecontinuous, obs_modepartial): super().__init__() self.num_pursuers num_pursuers self.num_evaders 1 self.map_size map_size self.max_steps max_steps self.catch_radius catch_radius self.max_speed_pursuer max_speed_pursuer self.max_speed_evader max_speed_evader self.action_mode action_mode self.obs_mode obs_mode self.current_step 0 # 动作空间每个智能体一个连续动作2维加速度或离散4方向 if action_mode continuous: self.action_space spaces.Box(low-1.0, high1.0, shape(2,), dtypenp.float32) else: self.action_space spaces.Discrete(4) # 观测空间固定维度具体内容初始化时根据领域逻辑填充 self.obs_dim 10 self.observation_space spaces.Box(low-np.inf, highnp.inf, shape(self.obs_dim,), dtypenp.float32) self.pursuers_pos None self.evader_pos None self.pursuers_vel None self.evader_vel None self._rendered_trace [] def reset(self, *, seedNone, optionsNone): super().reset(seedseed) self.current_step 0 # 随机初始化位置并保证不重叠 self.pursuers_pos np.random.uniform(1.0, self.map_size - 1.0, size(self.num_pursuers, 2)) self.evader_pos np.random.uniform(1.0, self.map_size - 1.0, size(1, 2)) i 0 while np.linalg.norm(self.evader_pos[0] - self.pursuers_pos[i]) 3.0: self.evader_pos np.random.uniform(1.0, self.map_size - 1.0, size(1, 2)) self.pursuers_vel np.zeros((self.num_pursuers, 2), dtypenp.float32) self.evader_vel np.zeros((1, 2), dtypenp.float32) self._rendered_trace [self._get_all_pos()] return self._get_obs(), {} def step(self, actions): # actions 是一个字典key 是 pursuer_0 这类标识value 是对应的动作 self.current_step 1 for i in range(self.num_pursuers): action actions[fpursuer_{i}] acc self._convert_action(action) self.pursuers_vel[i] np.clip( self.pursuers_vel[i] acc * 0.1, -self.max_speed_pursuer, self.max_speed_pursuer, ) self.pursuers_pos[i] self.pursuers_vel[i] * 0.1 # 逃跑者动作 action actions[evader_0] acc self._convert_action(action) self.evader_vel[0] np.clip( self.evader_vel[0] acc * 0.1, -self.max_speed_evader, self.max_speed_evader, ) self.evader_pos[0] self.evader_vel[0] * 0.1 self._apply_boundary_constraint() reward_dict, done self._compute_rewards() obs self._get_obs() truncated self.current_step self.max_steps terminated done if self.render_mode human: self.render() return obs, reward_dict, terminated, truncated, {} def _convert_action(self, action): if self.action_mode discrete: # 离散动作映射到连续加速度 mapping np.array([[0.0, 1.0], [0.0, -1.0], [1.0, 0.0], [-1.0, 0.0]]) return mapping[action] return np.clip(action, -1.0, 1.0) * 2.0上面代码里我顺手把 step 返回值写成了标准的 Gymnasium 5 元组格式obs、reward、terminated、truncated、info。这里有个容易踩的坑旧版 gym 是 4 元组新版 gymnasium 是 5 元组。如果你的训练代码是从网上抄来的一定要确认版本匹配否则 unpack 会直接报错。3.2 奖励计算与单步约束的核心逻辑reward 计算我单独拆出来写成 _compute_rewards 方法这样方便单独调试。它的逻辑是追捕者这边先把每个追捕者到逃跑者的距离都算出来找到全局最近距离 d_min。然后和前一步记录的 prev_d_min 比较计算出 Δd。这个 Δd 是所有追捕者的共享奖励因为它体现的是“围捕整体是否在接近目标”。def _compute_rewards(self): reward_dict {} # 计算追捕者与逃跑者的距离 d_min np.min(np.linalg.norm(self.pursuers_pos - self.evader_pos, axis1)) delta_d self.prev_d_min - d_min pursuer_reward 0.5 * delta_d # 接近奖励 evader_reward -0.5 * delta_d # 逃跑者拉开距离奖励 # 协作奖励计算追捕者之间的连线夹角 if self.num_pursuers 2: # 找两个离逃跑者最近的追捕者 dists np.linalg.norm(self.pursuers_pos - self.evader_pos, axis1) idx np.argsort(dists)[:2] p1, p2 self.pursuers_pos[idx[0]], self.pursuers_pos[idx[1]] v1 p1 - self.evader_pos[0] v2 p2 - self.evader_pos[0] # 通过余弦夹角计算两者的包抄角度 cos_angle np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) 1e-8) angle np.arccos(np.clip(cos_angle, -1.0, 1.0)) if angle np.pi * 0.4 and angle np.pi * 0.8: pursuer_reward 0.3 # 捕获判定 caught d_min self.catch_radius # 逃跑成功判定逃跑者出界 evader_out self._is_outside(self.evader_pos[0]) if caught: pursuer_reward 10.0 evader_reward - 10.0 done True elif evader_out: pursuer_reward - 10.0 evader_reward 10.0 done True else: # 边界惩罚 for i in range(self.num_pursuers): if self._is_outside(self.pursuers_pos[i]): pursuer_reward - 5.0 if self._is_close_to_boundary(self.evader_pos[0]): evader_reward - 0.1 done False for i in range(self.num_pursuers): reward_dict[fpursuer_{i}] pursuer_reward reward_dict[evader_0] evader_reward self.prev_d_min d_min return reward_dict, done这里有三个细节要强调。第一追捕者内部共享同一个 rewards 值属于团队奖励设定。这样每个追捕者得到的梯度信号是一致的避免了“某个追捕者觉得自己贡献不大所以躺平”的问题。要改成个体奖励也可以但训练难度会高很多不推荐新手尝试。第二逃跑者的边界处理我做了区分追捕者出界直接给重罚逃跑者靠近边界给轻罚但不算出界。为什么这么设计因为逃跑者可以利用边界来防守但完全贴边会有侥幸空间给轻罚是为了让它学会“利用边界但不能依赖边界”。如果惩罚重了逃跑者会永远躲在地图中央策略很呆。这个度的拿捏是我调了很多版才找到的平衡点。第三捕获判定用的是“两点距离”而不是“所有追捕者都有贡献”的评判标准。现实中如果你想让追捕者必须形成至少两个方向的围堵才算捕获就需要额外加角度约束。我这里保持最简单的距离判定方便理解后续你扩展时可以考虑加围堵条件。3.3 状态观测编码从物理位置到特征向量_got_obs 方法负责把物理位置、速度这些原始信息转换成向量。这是最容易写错的地方因为所有数据都要拼成一个定长向量位置稍微错位策略网络就废了。我设计的观测向量是 10 维拼接顺序如下def _get_obs(self): obs {} if self.obs_mode global: # 全局观测所有智能体看到全部信息 for i in range(self.num_pursuers): state np.concatenate([ self.pursuers_pos[i], self.pursuers_vel[i], self.evader_pos[0], self.evader_vel[0], self._get_team_center(), ]).astype(np.float32) obs[fpursuer_{i}] state # 逃跑者看到所有追捕者的均值位置 evader_state np.concatenate([ self.evader_pos[0], self.evader_vel[0], np.mean(self.pursuers_pos, axis0), np.zeros(2, dtypenp.float32), self._get_boundary_dist(self.evader_pos[0]), ]).astype(np.float32) obs[evader_0] evader_state else: # 部分观测每个智能体只看到自身周边信息 for i in range(self.num_pursuers): rel_pos self.evader_pos[0] - self.pursuers_pos[i] dist np.linalg.norm(rel_pos) rel_vel self.evader_vel[0] - self.pursuers_vel[i] # 找最近队友 team_dists np.linalg.norm(self.pursuers_pos - self.pursuers_pos[i], axis1) team_dists[i] np.inf idx_nearest np.argmin(team_dists) nearest_team_rel self.pursuers_pos[idx_nearest] - self.pursuers_pos[i] state np.concatenate([ self.pursuers_pos[i], self.pursuers_vel[i], rel_pos / (dist 1e-8), # 单位方向向量 np.array([dist / self.map_size]), nearest_team_rel / self.map_size, self._get_boundary_dist(self.pursuers_pos[i]), ]).astype(np.float32) obs[fpursuer_{i}] state # 逃跑者观测 dists np.linalg.norm(self.pursuers_pos - self.evader_pos[0], axis1) idx_nearest np.argmin(dists) nearest_rel self.pursuers_pos[idx_nearest] - self.evader_pos[0] state np.concatenate([ self.evader_pos[0], self.evader_vel[0], nearest_rel / (dists[idx_nearest] 1e-8), np.array([dists[idx_nearest] / self.map_size]), np.array([self.num_pursuers / 5.0]), # 追捕者数量归一化 self._get_boundary_dist(self.evader_pos[0]), ]).astype(np.float32) obs[evader_0] state return obs这里我提供全局观测和部分观测两种模式。全局观测模式下每个智能体直接把所有状态拼接起来训练起来最简单部分观测模式更接近真实场景但训练难度明显增加。如果你想做对比实验可以先用 global 模式跑通流程再切到 partial 模式看看算法性能差距。有个小技巧状态里的相对位置我建议除了保留绝对的 rel_pos 值再额外加一个归一化的单位方向向量。为什么因为绝对距离的取值范围随地图大小变化很大直接喂给网络会拉大不同特征之间的尺度差距影响优化效率。归一化到 [0, 1] 区间之后网络收敛速度会明显变快。这也是强化学习里“特征缩放”这个老生常谈的细节。3.4 训练主循环MADDPG 的核心逻辑环境搞定之后训练主循环就是整个平台的重头戏。这里我选了 MADDPG 作为主算法核心原因是它天然支持“中心化训练、去中心化执行”非常匹配追逃博弈里“追捕者各自看局部信息、但评估整体协作效果”的需求。MADDPG 的完整实现代码量不小这里我把最关键的训练循环逻辑写出来帮助你理解它和单智能体算法的区别。class MADDPG: def __init__(self, n_agents, obs_dim, action_dim): self.n_agents n_agents self.agents [] for i in range(n_agents): # 每个智能体有独立的 actor 和 critic actor ActorNetwork(obs_dim, action_dim) critic CriticNetwork(obs_dim * n_agents, action_dim * n_agents) self.agents.append({ actor: actor, actor_target: copy.deepcopy(actor), critic: critic, critic_target: copy.deepcopy(critic), actor_optimizer: optim.Adam(actor.parameters(), lr1e-4), critic_optimizer: optim.Adam(critic.parameters(), lr1e-3), }) self.replay_buffer ReplayBuffer(capacity100000) def update(self, batch_size256): batch self.replay_buffer.sample(batch_size) obs, actions, rewards, next_obs, dones batch for i, agent in enumerate(self.agents): # 计算 critic 的 target next_actions [] for j in range(self.n_agents): next_actions.append(self.agents[j][actor_target](next_obs[j])) next_actions torch.cat(next_actions, dim-1) target_q self.agents[i][critic_target](next_obs, next_actions) target_q rewards[i] 0.95 * (1 - dones[i]) * target_q # 更新 critic current_q self.agents[i][critic](obs, actions) critic_loss F.mse_loss(current_q, target_q.detach()) agent[critic_optimizer].zero_grad() critic_loss.backward() agent[critic_optimizer].step() # 更新 actor目标是最大化 critic 对当前动作的评估 # 注意这里要保持其他智能体动作不变只更新当前 actor 的参数 actor_loss -self.agents[i][critic](obs, actions).mean() agent[actor_optimizer].zero_grad() actor_loss.backward() agent[actor_optimizer].step() # 软更新 target 网络 soft_update(agent[actor_target], agent[actor], tau0.01) soft_update(agent[critic_target], agent[critic], tau0.01)MADDPG 更新里有个关键点更新某个 actor 时critic 输入用的 actions 必须是由其他智能体当前策略生成的而这些动作不参与当前 actor 的梯度回传。初学者容易犯的错误是把所有动作拼好之后直接对全部求梯度导致每个 actor 都试图把其他智能体的动作也纳入优化训练直接失控。实际操作中我会在 forward 之前就把当前 actor 的动作单独计算其他动作用其他分支的 detach() 处理。这段代码看起来简单但在调试的时候非常容易出错需要仔细核对张量的梯度传播路径。3.5 训练循环与可视化仿真测试训练主循环的核心结构就是标准的强化学习流程环境重置采集经验存入 buffer定期更新网络周期性评估。我跑这套追逃博弈的标准配置是 3 个追捕者 vs 1 个逃跑者地图大小 10x10最大步数 500共训练 2000 个 episode。每训练 50 个 episode我会跑一次评估固定初始位置连续执行 20 个 episode统计追捕成功率、平均捕获步数、逃跑者存活步数。这三个指标为主奖励曲线为辅。训练完成之后我写了一个 render_episode 函数用 matplotlib 逐帧绘制智能体的运动轨迹。这个功能调试价值极高你可以直接看到智能体是在围堵还是乱跑是在利用边界还是躲在角落。我看过无数个奖励曲线“看起来很完美”但策略实际上在钻漏洞的案例没有渲染可视化这些漏洞很容易被忽视。3.6 调参记录一组稳定收敛的参数配置我不喜欢只给理论不碰实际。这里记录一组我实测稳定收敛的参数配置你可以直接拿去做基准参数值说明追捕者数量3默认场景配置逃跑者数量1当前实现固定为 1地图大小10.0 x 10.0正方形区域最大步数500超过则 episode 截断捕获半径0.5追捕者和逃跑者距离小于该值即捕获追捕者最大速度1.0单位m/s逃跑者最大速度1.2略快于追捕者制造策略需求最大加速度2.0归一化动作乘上的上限单步距离奖励系数0.5λ1捕获成功奖励10追捕者获得逃跑成功奖励10逃跑者获得追捕者出界惩罚-5严重错误actor 学习率1e-4比 critic 低稳定策略更新critic 学习率1e-3比 actor 高评估更准确折扣因子 γ0.95兼顾近远期收益soft update τ0.01目标网络软更新系数经验回放容量100000防止遗忘之前的经验batch size256单次更新样本量这套配置在我自己的机器普通笔记本无 GPU上大约跑到 800 个 episode 的时候追捕成功率开始明显上升到 1500 个 episode 左右趋于稳定稳定在 80% 到 90% 之间。如果你的训练效果比我这个差不少优先检查奖励函数是否出了 bug其次是状态特征是否拼接正确。强化学习项目里大部分“训练失败”不是算法问题而是数据流问题。4. 常见问题与排查技巧实录4.1 训练不收敛奖励曲线震荡得像地震图谱这个是最常见的问题。我在调试这个项目的过程中遇到过好几次奖励曲线剧烈震荡表现是奖励值在一段正常的上升之后突然掉头向下或者一直无规则地忽上忽下完全看不出上升趋势。排查这件事我的经验是分四步走。第一步检查观测输入的数值尺度。把所有状态特征打印出来看看最大值和最小值。如果出现几百几千量级的数字网络是没法稳定训练的。我的处理是把位置除以地图大小、速度除以最大速度、距离除以地图大小全部归一化到 [0, 1] 附近。第二步检查奖励尺度。对着上面第 2.5 节的方法打印各方平均单步奖励看是否在 ±1 范围内。如果单步奖励动辄几十几百梯度的范围就会失控。第三步检查策略是否出现了“模式坍塌”。比如追捕者会不会永远朝一个方向跑逃跑者会不会原地抖动这些情况光看奖励曲线看不出来必须可视化。我建议在训练过程中每隔一定 episode 自动保存一次当前模型快照之后用保存的模型渲染一次动画观察策略行为的变化过程。第四步调整超参数。如果前两步都正常但训练还是震荡就优先调低 actor 学习率。MADDPG 的 actor 学习率通常要比单智能体 DDPG 更低因为策略更新要考虑其他智能体的反应更新步长太大会导致“谁都想改谁都改不好”的恶性循环。4.2 追捕者学会了“扎堆”不会协作包围这个现象在训练早期特别明显所有追捕者的轨迹几乎重叠都往同一个方向追导致逃跑者从另一个方向轻松溜走。这是多智能体强化学习里经典的“合作失败”问题。我解决这个问题加的是协作奖励第 2.3 节提到的夹角奖励。如果你发现加了协作奖励之后还是扎堆可以检查一下协作奖励的触发条件是否太苛刻。我一开始设的是“夹角在 140° 到 160° 之间”过于严格几乎很难触发奖励形同虚设。后来放宽到 120° 到 160°效果才好起来。还有一个替代方案值得尝试给每个追捕者设置不同的“目标区域”。比如把逃跑者周围的扇形区域按追捕者数量等分每个追捕者的隐藏奖励是基于自己是否在负责区域内。这个方法更稳定但设定起来稍麻烦属于进阶玩法。4.3 逃跑者学会了原地转圈无限拖时间这个现象也比较常见。逃跑者的逻辑很简单我跑不掉那我就原地转圈或者在一个小区域内来回晃动只要不被抓到单步奖励就一直有。虽然最终时间耗尽的终局奖励是负的但如果时间拖得足够长累计单步奖励可能比负终局奖励还多。解决思路有两个方向。第一个方向是加大时间耗尽的终局惩罚力度让逃跑者“拖时间”变得不划算。第二个方向是给逃跑者的速度变化加一点惩罚每次改变动作方向都损失一点奖励这样一来逃跑者频繁变向就会积累负奖励原地转圈策略就失效了。两个方向可以同时用效果更彻底。我在项目里的做法是输出动作时对逃跑者的加速度变化率加一个小惩罚项惩罚系数 0.02。这样逃跑者学会的是“大方向上的逃跑”而不是高频抖动。4.4 gym 版本 API 不兼容代码能跑但报各种错跨版本兼容问题是环境搭建时最容易出的坑。gym 0.21 和 gymnasium 0.28 的接口有显著区别reset 返回值从“只有状态”变成了“状态信息字典”step 返回值从“4元组”变成了“5元组”。如果你从不同来源复制代码很容易混用。我的建议是统一用 gymnasium不要用老版 gym。具体到代码层面环境类重写时需要这样匹配# gymnasium 版本 def reset(self, *, seedNone, optionsNone): super().reset(seedseed) ... return obs, {} def step(self, action): ... return obs, reward, terminated, truncated, info如果你用的是老版 gym 的教程代码记得把 self.reset() 改成返回两个值。我还在项目里专门加了一个版本检测函数如果检测到 import gym 的版本是 0.21 及以下就直接提示用户使用 gymnasium 代替省去很多排查时间。4.5 部分可观测模式下训练效果差有些朋友会用 partial 观测模式直接训练发现效果比 global 模式差很多。这是正常的部分可观测场景信息量更少训练难度指数级增加。但如果你确实需要 partial 模式的泛化能力这里有两个实用建议。第一个建议是给每个智能体增加一个“记忆机制”不要只输入当前时刻的观测而是把最近几步的观测拼接起来。这是最简单粗暴但非常有效的“时间上下文”注入法。代价是状态维度线性增加训练也会变慢。第二个建议是调整终端奖励的权重。在部分可观测模式下智能体的单步奖励信号会更模糊必须依赖更强烈的终端反馈来驱动学习。我会把捕获奖励从 10 提到 15逃跑成功奖励也从 10 提到 15让终端事件在整体奖励中比重更大能明显缓解部分观测下的训练困难。4.6 代码性能优化训练太慢怎么办如果你的电脑没有独立 GPUMADDPG 训练到后期会很慢。这里分享几个我自己用过的优化技巧。减少每步的矩阵运算开销检查你的环境 step 方法里有没有不必要的 np.array 转换和重复计算。比如每个 step 都要计算的前一步最短距离 prev_d_min你应该在 step 开头保存而不是内部再遍历一次。降低评估频率评估 20 个 episode 的效果其实和评估 5 个 episode 差别不大但时间成本差了 4 倍。把评估频率从每 50 个 episode一次改成每 100 个 episode 一次评估 episode 数降到 5 个训练速度能提升 15% 以上。牺牲一部分环境保真度如果你的地图是 10x10可以尝试降到 8x8速度差距非常明显策略行为基本不变。做实验阶段用小的出图时再用大的这是我在科研项目里常用的策略。4.7 常见问题速查表现象可能原因排查/解决办法奖励曲线完全平坦奖励计算有 bug或智能体没有获得有效梯度打印单步奖励原始值检查是否一直为 0奖励震荡剧烈奖励尺度不匹配actor 学习率过高归一化奖励降低 actor 学习率到 1e-4 甚至 1e-5追捕者扎堆缺少协作奖励或协作触发条件太苛刻加入夹角奖励调试触发角度范围逃跑者原地抖动单步生存奖励偏高速度变化无惩罚降低存活奖励加入动作变化惩罚训练早期就“成功”但策略很蠢奖励函数被钻漏洞可视化回放看看具体行为修正漏洞reset 报错缺少返回值gym 版本不一致统一升级到 gymnasium改返回值为标准格式部分观测训练不收敛信息量不足梯度信号弱拼接多步观测加大终端奖励权重CPU 训练太慢环境计算量太大小地图、少智能体、降评估频率5. 扩展方向与个人踩坑总结这套平台到这一步已经能稳定训练和评估了但多智能体强化学习的探索空间还很大。如果你准备拿这个项目继续扩展有几个方向我认为性价比不错。多逃跑者场景是我最先想到的扩展方向。直接把逃跑者数量从 1 改为 2 或 3环境本身不需要大改只需要把状态空间里的“单个逃跑者”字段换成多个奖励逻辑改为追捕者必须逐个捕获。但这个改动会带来新的问题多个逃跑者之间是否会合作如果合作训练难度又会上升一个台阶。这个方向适合作为进阶毕设或者科研小课题。引入通信机制也是个有意思的方向。现在所有智能体都只是“看”和“动”没有显式的通信。多智能体强化学习的一个重要分支就是“通信学习”让智能体学会在训练中自主产生和解读通信消息。你可以在观测空间里加一个 communication action 字段让智能体输出额外的通信向量MADDPG 的框架天然支持这种扩展只需要把通信量拼接进 critic 的输入就行。修改变速上限和捕获判定条件可以模拟更丰富的策略空间。比如逃跑者速度优势更大时策略会倾向于极限操作和边界利用追捕者如果速度相等策略会倾向于直接围堵和预判拦截。这个方向适合做“游戏难度等级”的设定也方便你研究不同速度比下策略的变化规律。我个人在实际操作中最大的体会是多智能体强化学习项目的问题很少真出在算法上大部分时间都在和环境代码、奖励函数、状态表征搏斗。写环境时一定要把每一步的输入输出都验证清楚多用打印和可视化确认数据流没有拼错。每修改一次奖励函数务必记录当时的训练曲线和策略表现否则你根本不知道是哪个改动让效果变好还是变坏。最后再分享一个小技巧如果你想快速验证环境是否正确先跑一个随机策略的 episode打印每一步的观测、奖励、状态。随机策略跑出来的 sequence 如果看不出明显逻辑问题再跑强化学习算法。不要一上来就训练算法因为如果连随机策略都走不通训练算法一定会让你debug到怀疑人生。这套平台我已经完整跑通代码里的环境类、MADDPG 训练器、评估脚本、可视化渲染都写好了你拿这套框架去改自己的场景基本不需要重写底层只需要调整状态定义和奖励函数就能套用到其他多智能体博弈问题上。本文还有配套的精品资源点击获取
返回列表