☰
基于Keras实现MADDPG:多智能体强化学习的集中式训练与分布式执行
2026/10/18 18:49:05 网站建设 项目流程

简介:这是基于Keras实现的多代理深度确定性策略梯度(MADDPG)算法项目,面向强化学习研究者与多智能体系统开发者。项目包含完整可运行的源码与预训练权重,其中12个h5文件为各智能体actor/critic网络及目标网络参数,7个py脚本覆盖模型搭建、环境定义、经验回放、训练与预测流程,另有README说明与LICENSE授权信息,压缩包整体约7.86MB,便于快速上手。目前已有813人学习下载。通过此项目可直观理解MADDPG如何扩展DDPG处理连续动作空间下的多智能体协作与竞争问题,并借助Keras简洁接口部署实验。项目结构清晰,适合希望复现MADDPG算法、开展多智能体强化学习实验或进行二次开发的Python用户。

1. 多代理强化学习的非平稳问题:在 Keras 里实现 MADDPG 能做什么

跑多智能体强化学习有一种共识:单智能体训得好好的算法,一放进多智能体环境就全线崩溃。原因很简单,每个智能体同时在学习、同时改变策略,环境对任何一个智能体而言都是不平稳的——Q 函数的输入明明没变,输出分布却在漂移。MADDPG 就是为了治这个病出现的。这套用 Keras 实现的多代理深度确定性策略梯度项目,解决的就是这一类协作/竞争场景下的决策问题:智能体各看各的观测,各自输出连续动作,但每个智能体的评价器能“偷看”所有人的观测和动作。项目本身是一个干净、可扩展的 Python 实现,适合想从单智能体 DDPG 跳到多智能体训练的同学做基线、做对比、做二次开发。

2. 架构设计与代码结构:集中式训练、分布式执行的落地方式

MADDPG 不是把 DDPG 复制 N 份那么简单。直接复制会踩一个大坑:每个智能体的 Critic 只能看到自己的状态和动作,但环境奖励往往依赖于所有智能体的联合行为,单凭局部信息估计出来的 Q 值方差极大,训练几乎必然发散。所以这个项目的第一个设计决策,就是把“策略网络”(Actor)和“评价网络”(Critic)彻底拆开,做集中式训练、分布式执行。

2.1 集中式训练、分布式执行:MADDPG 的设计核心

拆开之后的结构是这样:第 i 个智能体拥有自己的 Actor 网络,输入只有自己的观测 o_i,输出自己的动作 a_i。但第 i 个智能体的 Critic 网络输入是所有智能体的观测拼接 [o_1, o_2, ..., o_n] 加上所有智能体的动作拼接 [a_1, a_2, ..., a_n],输出一个全局 Q 值 Q_i(x, a),其中 x 是全局状态。这里有个容易理解偏差的地方:每个智能体都有自己的 Critic,而不是所有智能体共享一个 Critic。为什么要一人一个?因为不同智能体的奖励结构可能不一样,用一个 Q 函数去拟合所有人的回报,会强行拉平个体差异,最后学到的是“平均策略”而不是“各自最优策略”。

这个设计带来的直接好处是:Actor 在部署时不需要知道别人的观测或动作,通信只在训练阶段发生。真实场景里,智能体之间的通信带宽有限、时延敏感,集中式训练分布式执行几乎成了多智能体强化学习的工程标配。项目里每个智能体的 Actor 输入维度只取决于自身的观测空间,Critic 输入维度取决于全体观测与动作空间之和,网络结构差异正好把这个核心思想映射到了代码上。

2.2 目录与文件职责:整个仓库是怎么组织的

拿到项目后建议先按下面的顺序读文件,这个顺序也是数据流的顺序:

config.py # 全局超参数配置 replay_buffer.py # 经验回放缓冲区,存储并采样多智能体转移数据 networks.py # Actor 与 Critic 网络定义 agent.py # 单个智能体封装:网络、目标网络、训练逻辑 maddpg.py # 多智能体协调器:调度所有 agent 的前向与反向传播 environment.py # 一个可跑的模拟协作环境(围捕任务) train.py # 训练主循环 evaluate.py # 加载权重,跑评估并输出奖励曲线

networks.py只干一件事:返回 Keras 的 Model 对象,不掺任何训练逻辑,方便你替换网络结构。agent.py是单智能体的完整生命周期,包括目标网络创建、软更新、计算 Critic loss 和 Actor gradient。maddpg.py是所有训练逻辑的协调层,它知道现在一共有几个智能体、谁该用谁的输出做梯度更新。把协调层独立出来而不是塞进 agent 里,是因为 Critic 在计算梯度时要使用其他智能体的动作,这个交叉依赖放在单智能体内部实现会让代码绕成一团。

2.3 超参数怎么配置:核心参数都在 config.py

config.py 里的参数按功能分了三块:网络结构参数、训练参数、环境参数。训练参数里最关键的是这几个:

参数默认值说明
gamma0.95折扣因子,偏小适合短 horizon 任务,调大后长收益任务更容易收敛
tau0.01目标网络软更新系数,太小更新慢但稳定,太大会让 target 飘
actor_lr1e-4Actor 学习率,这个值不能给大,否则动作输出直接震荡
critic_lr1e-3Critic 学习率,可以比 actor 大一到两个数量级
batch_size1024每次梯度更新的样本数,样本量不足时 Q 值方差大
buffer_size100000经验池容量,建议比单智能体 DDPG 大,因为多条经验轨迹并存
warmup_steps5000前 5000 步只收集经验不训练,先把 replay buffer 填起来

我拿到项目后一般把 gamma 调到 0.99 看长期收益,再把 warmup_steps 加一倍,因为多智能体轨迹的方差比单智能体大很多,buffer 里没有足够多样化的转移数据时,第一批梯度会把 Q 网络带偏。

3. 核心模块拆解:从观测到梯度更新的完整链路

这一章是代码的精髓,我会按照训练时一个样本的流转顺序来拆:观测输入 Actor 拿到动作 → 动作喂进 Critic 算 Q 值 → 用目标网络算 target Q → 计算两个 loss 并反向传播。整套流程在 train.py 里每步执行一次,理解顺序比理解单行代码更重要。

3.1 Actor 网络:每个智能体只用自己的观测做决策

先看networks.py里的 Actor 构建函数,用 Keras 函数式 API 写,简洁直接:

def build_actor(state_shape, action_dim, action_bound=1.0, name='actor'): state_input = Input(shape=state_shape) x = Dense(128, activation='relu')(state_input) x = Dense(128, activation='relu')(x) raw_action = Dense(action_dim, activation='tanh')(x) scaled_action = Lambda(lambda v: v * action_bound)(raw_action) return Model(state_input, scaled_action, name=name)

这段代码有一个关键点:输出层用了tanh激活,把动作压缩到 [-1, 1],再通过Lambda层乘上action_bound。注意action_bound必须是环境动作空间的绝对值上界,而不是动作范围的一半——例如环境动作在 [-2, 2],这里应传 2.0。初始化权重时 Keras 默认的 glorot 均匀分布对 tanh 输入输出范围是匹配的,不需要额外处理。隐藏层 128 是经验值,连续控制任务我用 128 起步,不够再加;别一上来就上 512,训练时间翻倍但收益不会线性增长。

这里的输入state_shape是单个智能体的观测维度,不是全局维度。当观测是向量时 shape 填(obs_dim,);当观测是图像时,需要自己加卷积层后再 Flatten,然后接 Dense 层,改动位置只在函数前半段。

3.2 Critic 网络:集中式的全局 Q 函数

Critic 是 MADDPG 与 DDPG 最大的结构差异所在,也最容易被忽略。看一下代码:

def build_critic(obs_dim_list, act_dim_list, name='critic'): obs_inputs = [Input(shape=(dim,)) for dim in obs_dim_list] act_inputs = [Input(shape=(dim,)) for dim in act_dim_list] concat = Concatenate()(obs_inputs + act_inputs) x = Dense(256, activation='relu')(concat) x = Dense(256, activation='relu')(x) q_value = Dense(1)(x) return Model(obs_inputs + act_inputs, q_value, name=name)

obs_dim_list和act_dim_list是两个列表,长度等于智能体数量。每个智能体的观测和动作分别作为独立输入,最后全部 Concatenate。输出是一个标量 Q 值,代表“在当前全局状态和全局动作下,该智能体的期望回报”。Critic 构建函数的返回值不是单个 Model,而是接收多个输入的 Model,这意味着调用critic([obs_1, obs_2, ..., act_1, act_2, ...])时每个智能体的数据要按顺序传进入口。

实际使用时有个小细节:计算第 i 个智能体的 Actor 梯度时,要把第 i 个智能体的动作输入替换为该智能体 Actor 的当前输出,其他智能体的动作保持训练样本值不变。这相当于把其他智能体的动作当成常数,只对第 i 个智能体的动作求梯度。如果不这么做,梯度会流经其他智能体的动作,导致策略更新互相污染,训练曲线会变得异常嘈杂。从数学上讲,这正是对“其他智能体策略不变”这个假设的实现。

3.3 目标网络与软更新:稳定训练的后手

多智能体场景里 Q 值的 target 本身也在持续变化,如果 target 网络直接复制在线网络权重,训练初期的反馈信号会来回跳。项目里用的是软更新:

def soft_update(target_model, source_model, tau=0.01): for target_param, source_param in zip(target_model.trainable_variables, source_model.trainable_variables): target_param.assign(tau * source_param + (1 - tau) * target_param)

这段代码在 agent.py 每次训练后调用,把源网络 1% 的权重挪到目标网络里。为什么用trainable_variables而不是get_weights()?因为get_weights()返回的是 numpy 数组,赋值回去会额外复制一份内存,而且 Keras 的权重顺序在不同版本间不透明;trainable_variables是 Tensor 对象,assign操作可以直接在图上完成。如果你在 TensorFlow 2.x 环境下跑,强烈建议用这种写法,能避开不少“weight 没更新”的玄学问题。

软更新频率不需要和训练频率一样,我习惯每 2 步训练做一次软更新;如果发现 Q 震荡,把 tau 降到 0.005,效果通常立竿见影。注意 Critic 的 target 网络 Q 值是用 target actor 算出来的 next actions,不是在线 actor 算出来的,代码里训练循环顺序是:target actor 输出 next actions → target critic 计算 target Q → 在线 critic 计算当前 Q → 做 MSE。这个依赖链别写反,否则 target Q 和在线 Q 用的是同一套参数,就成了自监督自,训练直接失去意义。

3.4 经验回放与训练循环:多智能体转移数据怎么存怎么取

多智能体经验回放和单智能体最大的区别在于存储粒度。单智能体存一条(s, a, r, s'),这里存的是“一个时间步所有智能体联合产生的转移数据”:

def store_transition(self, obs, actions, rewards, next_obs, done): # obs/actions/rewards 都是长度为 n_agents 的列表 self.buffer.append(( np.array(obs, dtype=object), np.array(actions, dtype=object), np.array(rewards, dtype=object), np.array(next_obs, dtype=object), done )) def sample_batch(self, batch_size): batch = random.sample(self.buffer, batch_size) obs = np.array([b[0] for b in batch]) actions = np.array([b[1] for b in batch]) rewards = np.array([b[2] for b in batch]) next_obs = np.array([b[3] for b in batch]) done = np.array([b[4] for b in batch]) return obs, actions, rewards, next_obs, done

注意这里用dtype=object,因为每个智能体的观测维度可以完全不同(异构智能体项目里很正常)。如果没有这一步,numpy 会尝试把不等长的列表强转成二维数组,直接抛异常。采样时返回维度是(batch_size, n_agents, obs_dim),训练循环里对第 i 个智能体的操作是obs[:, i],也就是取“这一批样本里所有智能体 i 的观测向量”。

batch 大小我建议给到 1024。单智能体用 256 没问题,但多智能体的转移数据里协同信号本来就稀疏,batch 太小会导致一个 batch 里几乎看不到协作行为的样本,Critic 学不到联合动作的价值差异。这是少数“加数据就能涨点”的地方。

训练循环的核心逻辑如下,每个智能体轮流更新:

for i in range(n_agents): # 1. 用 target actor 算所有智能体的 next actions next_actions = [] for j in range(n_agents): next_actions.append(target_actors[j](next_obs[:, j])) # 2. 算第 i 个智能体的 target Q next_q = target_critics[i]([*next_obs.transpose(1, 0, 2), *next_actions]) target_q = rewards[:, i] + gamma * next_q * (1 - done) # 3. 在线 critic 拟合 target_q critic_loss = mse(critics[i]([*obs.transpose(1, 0, 2), *actions]), target_q) # 4. 更新第 i 个 actor:让 Q 对动作的偏导最大化 actions_in = [*actions[:i], actors[i](obs[:, i]), *actions[i+1:]] actor_loss = -critics[i]([*obs.transpose(1, 0, 2), *actions_in])

第 4 步里的actions_in是更新 Actor 时的关键:把第 i 个智能体的动作换成当前在线 actor 的输出,其他位置保持 batch 里的旧动作。然后对actor_loss求梯度,实际上是对-Q求梯度,也就是沿着让 Q 增大的方向更新 Actor。actor_loss是负数很正常,它不是普通意义上的 loss,而是“负 Q 值”,数值越小说明 Q 越大。

4. 跑通一个协作围捕场景:训练、调参与日志解读

项目里附带了一个简单的协作围捕环境:两个追捕智能体要合围一个逃跑者,单个追捕者靠近目标只能拿到很小的奖励,只有两个追捕者同时接近目标两侧时,才会给一个较大的联合奖励。这个环境故意用稀疏协作奖励设计,是为了让“计算联合 Q 值”的价值体现出来。

4.1 模拟环境接口设计

环境接口浓缩成三个方法:

class PursuitEnv: def reset(self): # 返回 [obs_a, obs_b],每个 obs 包含自身位置、目标相对位置、另一个追捕者相对位置 pass def step(self, actions): # actions[i] 是智能体 i 在 [-1, 1] 内的连续速度指令 # 计算新位置 # 奖励规则:只有两者同时进入目标邻域才给正奖励 # 返回 obs, rewards, done, info pass def get_obs_dim(self): # 返回每个智能体的观测维度,这个环境里追捕者同构,都是 6 pass

这个接口和 OpenAI Gym 的单智能体接口唯一的区别是step里传入的是一个动作列表,返回的也分别是列表。MADDPG 项目自身不挑环境,只要你把所有跟环境交互的地方都替换成列表即可。写环境时建议把奖励打印打开,先手动跑几步看看奖励事件是否触发了,再关掉去训模型。否则你会发现训练半天奖励一直为零,最后定位到是奖励条件里两个智能体的坐标判断写错了——这种低级错误在纸上检查很难发现,但跑一遍就能暴露。

4.2 启动训练与阶段的日志解读

训练入口是train.py,核心调用大致如下:

python train.py --env pursuit --episodes 2000 --save_dir ./checkpoints

启动后每 100 个 episode 打印一次日志,包含最近 100 个 episode 的平均 reward、平均 episode 长度、当前探索噪声标准差。训练初期你会看到:前 200 个 episode 的平均 reward 几乎为零,这是正常的,因为warmup_steps阶段智能体还在随机探索,经验池里真正“有效合作”的样本极少。随后的 300 到 600 个 episode,reward 会开始出现零星的正值,此时大多是单次凑巧合作,曲线毛刺很大。

从 600 到 1200 个 episode 这个区间是训练的“分水岭”。如果参数没调错,平均 reward 会有一段缓慢上升,然后平稳。如果这期间 reward 曲线一直水平纹丝不动,问题基本不在网络结构,而在奖励设计或探索噪声的幅度——多智能体环境里“协作信号太稀疏”比“网络容量不够”常见得多。训练完后评估脚本会加载 checkpoint,关闭探索噪声跑 100 个 episode 输出确定性策略下的平均 reward,这个值才是可比较的最终指标。

4.3 调参经验:从发散到收敛的几个关键旋钮

参数里最影响收敛的是探索噪声的标准差。项目里用的是高斯噪声叠加在动作上,我见过太多人固定noise_std=0.1从头训到尾,结果中期探索能力不足,协作策略学不出来。正确做法是设置 noise 衰减:

noise_std = max(0.05, 0.3 * (1 - episode / total_episodes))

系数 0.3 起步、线性衰减到 0.05 的 floor,这个范围对连续动作空间比较通用。衰减太快会过早进入“吃老本”状态,衰减太慢后期策略震荡。另外一个容易被忽略的是warmup_steps:经验池里连 5000 条转移都没有的时候,任何梯度更新都是噪声拟合。我在多智能体项目里习惯把它设到 10000,哪怕多等几分钟,也要保证第一批 batch 里有足够多样化的状态动作对。

学习率的调整遵循一个原则:Actor 永远比 Critic 慢。Critic 至少要比 Actor 快 10 倍,因为 Actor 的梯度方向完全取决于 Critic 给出的 Q 值,Critic 本身还没收敛时,Actor 更新得越快就越容易冲进一个 Q 值虚高的区域,等 Critic 修正后 Actor 又得重新回来。这里用1e-4和1e-3的组合开局,如果发现 Critic loss 降到很小但 reward 不涨,再降actor_lr到3e-5。

5. 避坑与排查:MADDPG 训练现场的四次翻车

这一章是我自己跑类似项目时真实踩过的坑,项目代码本身没问题,但运行方式不对、超参不合理或者环境配置粗心,都会触发下面的现象。每条按现象、原因、解决的顺序写,遇到同款可以对照排查。

5.1 现象:actor loss 打出来是负数,以为代码写错了

第一次训练时把 actor loss 打印到控制台,发现是-12.345这种值,第一反应是梯度方向反了。核对代码后发现actor_loss = -Q本来就是负的,因为我们在做梯度上升而不是下降。负的 actor loss 意味着 Q 值正在变大,恰恰是收敛的迹象。

原因:对“loss”的直觉停留在监督学习里,忘了强化学习里 Actor 的目标是极大化 Q,不是极小化某个误差项。解决:把日志列名从actor_loss改成actor_objective,提醒自己它越高越好;真正要看的是每个智能体的 Q 均值是否在上升。

5.2 现象:前几百回合奖励纹丝不动,一度怀疑环境坏了

追捕环境里两个智能体如果总是各跑各的,永远无法同时进入目标邻域,奖励持续为零。手动跑环境没问题,但放进训练就僵住。原因:warmup_steps太短,经验池里全是“没合作”的样本,Critic 把“不合作”的 Q 值学到很低,Actor 探索程度又不够,导致哪怕随机碰到了协作机会也会被判别为低价值,陷入死循环。

解决:把warmup_steps提到 10000,同时把初始 noise_std 提到 0.3。要是还不行,给环境加一个“单智能体接近目标给 0.1 小奖励”的 shaping 奖励,让 Actor 先学会“靠近”这个基本动作,再接上协作信号。

5.3 现象:两个智能体的策略越来越像,失去分工

追捕任务的理想策略是一个从左边包抄、一个从右边包抄,但训练结果经常是两个智能体都往同一个方向追,导致效率减半。原因:两个智能体的初始策略相同、Actor 网络同构、更新顺序固定,梯度方向几乎一致,天然倾向对称收敛。

解决:给两个 Actor 不同的初始化种子,或者把智能体编号加入观测输入,让每个智能体对自身角色有感知。更简单的做法是在训练时交替打乱两个智能体的更新顺序——先更新 A 再更新 B,下一轮先更新 B 再更新 A——打破对称性,这是零成本改动。

5.4 现象:Critic loss 震荡剧烈,完全不收敛

Critic loss 在一个大范围内来回跳,训练曲线看起来像噪声。原因:一个批次里混入了大量新旧差异很大的转移数据。多智能体本来策略就在同步变化,刚学到的策略产生的数据很快“过期”,如果经验池里老数据与新数据比例失衡,Critic 会一会儿拟合新策略一会儿又去拟合旧策略。

解决:适当缩小 buffer 容量,让旧策略产生的经验更快被淘汰,比如从 100000 降到 30000;再把 batch 从 512 提到 1024,增大采样覆盖。极端情况下可以改成按 episode 采样而不是按 step 采样,保证一个 batch 里承接的是同一策略阶段的数据。

6. 上手改造:把 MADDPG 用到你自己的多智能体环境

这个项目最值得下功夫的地方不在网络结构,而在于把数据流理顺。我第一次接一个新多智能体环境时,先用两张纸分别画出“每个智能体在每个时间步能观测到什么”和“每个智能体的奖励由谁决定”,两张图一对比就能发现:环境该给谁发奖励、Critic 该拼接哪些输入,全部一目了然。

改造的岔路口在这里:如果所有智能体的动作范围不是均匀对称的,比如智能体 A 的动作在 [-1, 1]、智能体 B 的动作在 [-5, 5],传给 Critic 前最好先做归一化,否则 B 的量纲会淹没 A 的贡献。如果智能体观测是图像,Actor 的卷积层要和 Critic 分开定义,不要复用同一组卷积权重,因为二者输入完全不同——Actor 是单视角图,Critic 是多视角拼接图。

如果目标是做离散动作版本(比如动作是 5 个离散档位),可以保留 Actor 输出连续向量,再用 Gumbel-Softmax 做一个可微离散采样层替换 tanh 输出,梯度可以正常回传,这里我不再展开。改动前后用 t-SNE 可视化一下动作分布,马上能看出不同智能体的策略有没有分化。

这套项目给了我一个长期受用的调试习惯:每次训练前先随机策略跑 20 个 episode,打印每个智能体的观测信息和奖励事件触发情况,确认环境接口自洽之后再开训练。从那以后,我每接一个新的多智能体场景都强制走一遍这个流程,省下的排查时间远超那几分钟的等待。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询