MADDPG实战:从单智能体失效到多智能体车联网资源分配落地
发布时间:2026/10/7 6:30:49 作者:尧图编辑部 阅读量:1,286

简介资源聚焦车联网通信资源分配优化问题以多智能体深度强化学习MADDPG为主线提供一套完整Python实现面向智能交通、车联网通信及强化学习方向的研究者与入门学习者。压缩包共24个文件含13个py源码、6个pyc编译文件、4个zbak备份及1个md说明文档源码覆盖环境构建、MADDPG智能体、经验回放、随机对比策略等模块备份文件与README便于对照阅读和恢复。包大小仅75KB轻量可快速运行调试。目前已有79人学习使用。通过该项目可直观理解策略网络与价值网络搭建、奖励机制设计等关键环节学会将多智能体强化学习落地到车联网资源分配的具体场景方便二次修改与扩展。1. 车联网通信资源分配为什么单智能体强化学习在路口场景集体失灵深夜的路口一辆公交车和三台网联汽车同时请求路侧单元RSU下发高清地图与感知融合数据。如果按传统轮询或最大吞吐优先算法调度公交车和大车往往把资源吃满外侧小车时延飙到几百毫秒——这正是通信资源分配优化要解决的典型矛盾。把这个问题交给多智能体深度强化学习MADRL去做思路是每个车辆终端或边缘节点是一个独立决策体各自观察局部信道与队列状态学习协同的频谱与功率分配策略最终目标是提升系统吞吐同时压住时延抖动。适合看这篇文章的人有三类一是做车联网V2X仿真的研究生二是搞边缘计算资源调度的算法工程师三是想用MADRL替换启发式策略、但被训练不稳定折腾到怀疑人生的开发者。这篇会从问题建模、Python环境搭建、MADDPG实现、参数调节一路拆到训练坑位让你能照着代码跑通并改出自己的策略。坦白说多智能体训练里有一半时间在跟玄学搏斗但把状态空间、奖励塑形和训练超参数这三件事做扎实收敛是可以预期的。2. 从单智能体到多智能体车联网资源分配的决策范式怎么选2.1 为什么集中式调度在动态车流下撑不住传统的通信资源分配方案比如轮询RR、比例公平PF、最大吞吐MT本质上都是一个集中式调度器在基站或RSU侧做全局决策。它的前提是调度器能拿到所有用户完整的信道状态信息CSI和队列状态。在LTE-V2X或802.11p场景里车辆高速移动导致CSI反馈周期跟不上信道变化一个时隙内决策没做完信道已经变了两轮。更麻烦的是车辆随时进出覆盖范围集中式调度器维护的全局状态维度不停跳动Q表或策略网络输入尺寸固定一旦车辆数变化就得重新训练。多智能体范式把决策权下放每辆车或每个RSU作为一个自主agent只依赖局部观测——自己的信道增益、队列积压、周围车辆密度、历史资源占用率——就能输出资源请求和功率等级。局部观测的维度可以固定车辆数变化不改变单个agent的输入结构。代价是环境变成非平稳的所有agent的策略都在同时更新对单个agent来说环境转移概率不再是稳定的这正是单智能体DQN在这里失效的根本原因。2.2 MADDPG的CTDE框架为什么适配这个场景多智能体深度确定性策略梯度MADDPG是目前最常被拿来做车联网资源分配的基础算法核心思路是中心化训练、去中心化执行CTDE。训练阶段每个agent有一个critic网络输入是全局状态和所有agent的动作可以准确评估联合动作的Q值执行阶段每个agent只用自己的actor网络输入是自己观测到的局部信息输出动作。这样既回避了非平稳性问题又保证了部署时不需要全局通信。相对QMIX或VDN这类值分解方法MADDPG处理连续动作空间更自然。车联网资源分配里发射功率是连续的调制编码方式MCS和资源块RB数量虽然是离散的但可以松弛成连续变量再映射。MADDPG的actor输出连续向量训练稳定后直接取整或分档就落到离散资源上。如果硬上DQN动作空间要拆成RB组合乘以功率档位的笛卡尔积组合爆炸会让探索效率变得极低。2.3 状态、动作与奖励设计建模比算法更决定上限把问题写成马尔可夫博弈需要明确每个agent的观测空间、动作空间和奖励函数。观测我一般用五元组当前信道增益归一化值、业务队列积压量、周围活跃车辆数、上一时隙自身占用RB数、信号与干扰加噪声比SINR。动作空间设计成两维连续量功率控制系数在[0,1]区间RB请求数量的归一化值。奖励是这篇文章的胜负手公式如下。def compute_reward(rate, queue_backlog, fairness_idx): # rate: 本时隙传输速率单位 Mbps # queue_backlog: 当前队列积压数据量单位 Mbit # fairness_idx: 系统级Jain公平指数取值0~1 alpha 0.6 # 吞吐项权重 beta 0.3 # 队列稳定项权重 gamma 0.1 # 公平性项权重 r alpha * math.log1p(rate) - beta * queue_backlog gamma * fairness_idx return r代码逻辑用log1p压住高吞吐样本的尺度差异避免单个高吞吐车辆把梯度拉爆队列积压作为惩罚项防止agent只顾传输快车数据而让重负载车饿死公平性指数拉全局视角让资源分配结果不偏离系统整体利益。在实践中奖励里的三个权重需要按场景调。alpha太小agent会变得保守宁可少传也不冒险beta过大会让agent只清空队列不管系统吞吐。我经验是吞吐权重占大头但公平性权重的下限不能低于0.05否则跑上几千回合必然出现某辆车长期得不到资源、奖励直接坍缩的现象。3. 搭建仿真环境与Python项目骨架从零到可训练的最小工程3.1 依赖安装与项目结构numpy、torch、matplotlib一个都不能少这个项目不依赖专门的交通仿真器用Python自建环境足够验证MADRL算法逻辑。需要安装的库集中在三块数值计算用numpy深度学习用PyTorch结果可视化用matplotlib。安装时直接pip安装即可Python版本建议3.8以上PyTorch选CPU版或CUDA版按机器情况决定。vehicular_madrl/ ├── config/ │ └── scenario.json # 场景参数车辆数、信道模型、业务模型 ├── env/ │ └── v2x_env.py # 车联网通信环境状态转移、奖励计算 ├── agents/ │ ├── maddpg.py # MADDPG核心Actor、Critic、经验回放 │ └── buffer.py # 多智能体经验回放缓冲区 ├── train.py # 训练主入口 └── evaluate.py # 评估脚本吞吐、时延、公平性指标项目结构里最重要的是config和env分离。场景参数放进json文件训练脚本不写死任何数字这样后面做泛化测试时只需要改json不用动代码。python结构化数据的好处在这里体现得很清楚——比直接在脚本里改参数安全改坏了还能对照git历史。3.2 车辆通信环境类信道模型与业务模型怎么写环境是强化学习训练的黑匣子外壳里面包着真实物理过程的简化抽象。我一般会做一个V2XEnvironment类核心接口是reset()和step()与Gym风格对齐。class V2XEnvironment: def __init__(self, config): self.n_agents config[n_agents] # 最大车辆数 self.n_rbs config[n_rbs] # 可用资源块总数 self.rb_bandwidth config[rb_bandwidth] # 单RB带宽Hz self.noise_power config[noise_power] # 噪声功率dBm self.vehicle_positions None # 车辆位置模拟动态变化 self.channel_gain None self.queue_backlog None self.time_slot 0 def reset(self): # 初始化车辆位置沿道路随机分布 self.vehicle_positions np.random.uniform(0, 500, (self.n_agents, 2)) self.queue_backlog np.random.exponential(5, self.n_agents) self.time_slot 0 return self._get_observations() def _compute_channel_gain(self): # 路径损耗 对数正态阴影衰落 # 简化为距离越远增益越小加随机扰动模拟快衰落 distance np.linalg.norm(self.vehicle_positions - self.rsu_position, axis1) path_loss 128.1 37.5 * np.log10(distance / 1000) shadowing np.random.normal(0, 8, self.n_agents) self.channel_gain -(path_loss shadowing) return self.channel_gain def _get_observations(self): # 每个agent观测自己相关信息维度固定便于网络输入 obs np.zeros((self.n_agents, 5)) obs[:, 0] self._normalize_channel_gain() obs[:, 1] self.queue_backlog / self.max_queue obs[:, 2] self._count_active_neighbors() obs[:, 3] self.rb_usage_last_slot / self.n_rbs obs[:, 4] self._compute_sinr() return obs def step(self, actions): # actions shape: (n_agents, 2) [power_coef, rb_request_ratio] # 分配资源、计算速率和奖励 self._alloc_resources(actions) rate self._compute_transmission_rate() fairness self._jain_index(rate) rewards np.array([ compute_reward(rate[i], self.queue_backlog[i], fairness) for i in range(self.n_agents) ]) self.queue_backlog np.maximum(self.queue_backlog - rate * self.time_slot_len, 0) self.queue_backlog self._generate_new_packets() # 泊松到达 self.time_slot 1 return self._get_observations(), rewards, self._check_done()参数说明n_agents开成20到30比较合适太少体现不出多智能体竞争太多训练时间成倍上涨rb_bandwidth用180kHz对应LTE一个RB的带宽这样算出来的速率数值有物理含义噪声功率按-100dBm设信道增益算出来是负dB值需要归一化到0~1区间再喂给网络。业务模型用泊松到达模拟数据包产生到达率lambda建议设成每个时隙0.5到2包包大小1Mbit左右。lambda太小环境太简单策略随便就能收敛lambda太大队列积压爆炸agent怎么学都救不回来训练曲线直接发散。这个参数要在训练前用小规模仿真试出合理区间。3.3 资源分配从动作到物理资源的映射MADDPG输出的连续动作要映射到资源块和功率上这个映射函数虽然简单但容易出问题。功率系数直接乘最大发射功率RB请求数则要做约束所有agent请求的RB数之和不能超过总RB数。def _alloc_resources(self, actions): power_coef np.clip(actions[:, 0], 0.05, 1.0) # 防止功率为0导致学不到信号 rb_requests np.clip(actions[:, 1], 0.0, 1.0) * self.n_rbs rb_alloc self._proportional_fair_allocation(rb_requests) self.rb_usage_last_slot rb_alloc self.power_alloc power_coef * self.max_power代码逻辑分两步先裁剪动作到合理范围再做归一化分配。直接用actor输出当RB数会导致所有agent都抢最大资源系统过载。比例公平分配在这里当约束器用既保证了总资源不超卖又给了agent学习空间。这个中间层被很多人忽略但往往决定了训练能否稳定收敛。4. MADDPG代码落地网络结构、经验回放与训练主循环4.1 Actor-Critic网络定义输入输出维度必须与观测动作空间严格对齐MADDPG的每个agent有一对网络actor负责从局部观测映射到动作critic接收全局信息评估动作价值。网络结构不追求深三层全连接足够重点是维度对齐和激活函数选择。下面这一段是完整的actor和critic定义维度注释写在代码里照着改不会出错。class Actor(nn.Module): def __init__(self, obs_dim, act_dim, hidden128): super().__init__() self.fc1 nn.Linear(obs_dim, hidden) self.fc2 nn.Linear(hidden, hidden) self.fc3 nn.Linear(hidden, act_dim) def forward(self, obs): x torch.relu(self.fc1(obs)) x torch.relu(self.fc2(x)) # 功率系数用sigmoid压到0~1RB系数也压到0~1 action torch.sigmoid(self.fc3(x)) return action class Critic(nn.Module): def __init__(self, obs_dim, act_dim, n_agents, hidden128): super().__init__() # critic输入 所有agent的观测 所有agent的动作 input_dim (obs_dim act_dim) * n_agents self.fc1 nn.Linear(input_dim, hidden) self.fc2 nn.Linear(hidden, hidden) self.fc3 nn.Linear(hidden, 1) def forward(self, obs_all, act_all): # obs_all: (batch, n_agents, obs_dim) x torch.cat([obs_all, act_all], dim-1) x x.view(x.size(0), -1) # 展平成向量 x torch.relu(self.fc1(x)) x torch.relu(self.fc2(x)) return self.fc3(x)代码里的关键设计是critic把全部观测和动作拼接成一个长向量。维度计算公式是(obs_dim act_dim)乘以agent数量如果agent数量是20obs_dim是5act_dim是2那输入维度是140——不算大两层128维隐藏层够用了。有些实现会在这里加attention机制处理变长agent数量但对固定规模场景来说直接拼接更简单可靠。激活函数的选择有讲究。actor输出层用sigmoid因为动作范围限定在0~1中间层用ReLU避免梯度饱和。有些项目在actor输出层用tanh再重新映射到0~1效果差不多但tanh在初始化阶段容易输出接近-1或1的饱和值导致资源分配一直撞上下界训练前期波动更大不如sigmoid省心。4.2 经验回放缓冲区多智能体样本怎么组织才不会维度崩溃经验回放是把训练样本缓存起来打乱采样打破时间相关性。但多智能体版本有个细节每个transition存的是所有agent的观测、动作和奖励不是一个agent的。组织方式是一个大列表每条记录存完整的多智能体快照。class ReplayBuffer: def __init__(self, capacity, n_agents, obs_dim, act_dim): self.capacity capacity self.n_agents n_agents self.obs_dim obs_dim self.act_dim act_dim self.buffer deque(maxlencapacity) def push(self, obs_all, act_all, reward_all, next_obs_all, done): self.buffer.append(( obs_all.copy(), act_all.copy(), reward_all.copy(), next_obs_all.copy(), done )) def sample(self, batch_size): batch random.sample(self.buffer, batch_size) obs_all torch.FloatTensor(np.stack([t[0] for t in batch])) act_all torch.FloatTensor(np.stack([t[1] for t in batch])) reward_all torch.FloatTensor(np.stack([t[2] for t in batch])) next_obs_all torch.FloatTensor(np.stack([t[3] for t in batch])) done torch.FloatTensor([t[4] for t in batch]) return obs_all, act_all, reward_all, next_obs_all, donenumpy结构化数据在这里的优势很明显obs_all用np.stack一次成型shape是(batch_size, n_agents, obs_dim)喂给网络之前转成torch.FloatTensor。这里最容易翻车的坑是copy()漏掉导致后续step里环境更新obs时buffer里存的引用也跟着变训练数据全被污染。4.3 训练主循环探索噪声、软更新与target网络MADDPG训练里每个agent有四个网络actor、target_actor、critic、target_critic。target网络不参与梯度更新而是用软更新方式逐步逼近在线网络目的是让Q值目标保持稳定。训练循环结构如下。def train_one_episode(env, agents, replay_buffer, args): obs_all env.reset() episode_reward np.zeros(env.n_agents) for t in range(args.episode_len): # 决策阶段每个agent用自己的actor输出动作加探索噪声 actions [] for agent in agents: obs torch.FloatTensor(obs_all[agent.idx]).unsqueeze(0) action agent.actor(obs).detach().numpy().squeeze() noise np.random.normal(0, args.noise_scale, sizeaction.shape) actions.append(np.clip(action noise, 0, 1)) actions np.array(actions) next_obs, rewards, done env.step(actions) replay_buffer.push(obs_all, actions, rewards, next_obs, done) episode_reward rewards obs_all next_obs # 经验足够后开始梯度更新 if len(replay_buffer.buffer) args.batch_size: update_all_agents(agents, replay_buffer, args) return episode_reward.mean() def update_all_agents(agents, replay_buffer, args): obs_all, act_all, r_all, next_obs_all, done replay_buffer.sample(args.batch_size) for agent in agents: # 计算target Q值target_critic输入next_obs和target_actor输出的next_action next_actions [] for a in agents: next_actions.append(a.target_actor(next_obs_all[:, a.idx])) next_actions torch.cat(next_actions, dim1).view(args.batch_size, env.n_agents, -1) next_q agent.target_critic(next_obs_all, next_actions).squeeze(-1) target_q r_all[:, agent.idx] args.gamma * (1 - done) * next_q current_q agent.critic(obs_all, act_all).squeeze(-1) critic_loss nn.MSELoss()(current_q, target_q.detach()) agent.critic_optimizer.zero_grad() critic_loss.backward() agent.critic_optimizer.step() # actor损失动作被critic评分走梯度上升 actions [] for a in agents: actions.append(a.actor(obs_all[:, a.idx])) actions torch.cat(actions, dim1).view(args.batch_size, env.n_agents, -1) actor_loss -agent.critic(obs_all, actions).mean() agent.actor_optimizer.zero_grad() actor_loss.backward() agent.actor_optimizer.step() # 软更新target网络 for target_param, param in zip(agent.target_actor.parameters(), agent.actor.parameters()): target_param.data.copy_(args.tau * param.data (1 - args.tau) * target_param.data) for target_param, param in zip(agent.target_critic.parameters(), agent.critic.parameters()): target_param.data.copy_(args.tau * param.data (1 - args.tau) * target_param.data)这段代码注意几个点。第一计算next_actions时用的是每个agent的target_actor而不是在线actor这是target网络存在的意义。第二current_q直接输入replay buffer里的历史action而actor_loss用的是当前actor重新输出的action两者必须区分否则actor梯度会穿过critic的输入产生严重偏差。第三critic_loss的target_q需要detach()切断梯度传播这步漏掉整个训练会震荡到怀疑人生。训练超参的经验值actor学习率1e-4critic学习率1e-3critic要比actor学得快因为actor依赖critic的评分信号折扣因子gamma设0.95车联网时隙短不太需要看很远的未来tau设0.01软更新步子太大target网络就退化成在线网络的复制品失去稳定作用。5. 训练与评估的坑收敛失败、维度错乱和公平性坍缩的排查记录5.1 奖励数值尺度悬殊导致loss爆炸或NaN现象训练跑到几百步critic loss突然变成NaN或actor输出的动作全部变成0或1的极端值。 原因通信速率的原始数值量级是每秒几十到几百Mbit而队列积压项和公平性项都在0到1之间。critic拟合目标里包含大数值速率梯度更新步长相对偏大数值不稳定。更隐蔽的情况是速率和队列积压在训练前期产生互相矛盾的梯度方向直接把loss推爆。 解决用compute_reward里的log1p压速率尺度把奖励整体除以一个基准值做归一化。我一般会另外监控每100回合的奖励均值和方差如果方差占总均值比重超过50%就说明奖励结构有问题回去调权重而不是硬加学习率衰减。5.2 经验回放batch里agent数量不一致导致维度崩现象训练一切正常突然报错RuntimeError: stack expects each tensor to be equal size或者shape变成(batch, 18, 5)这种奇怪数字。 原因仿真环境里车辆动态进出覆盖范围某一时隙的obs中有一部分agent的观测是无效数据但代码没用掩码而是直接截断n_agents造成维度跳变。 解决环境里固定n_agents上限车辆离开时用全零观测占位车辆进入时填充真实数据。这样状态空间维度恒定网络输入结构稳定。占位观测对应的动作要强制设成0避免幽灵车辆参与资源竞争。代码里要时刻记住这个约定占位不是多余数据而是维持维度统一的手段。5.3 个别agent长期饿死公平性奖励坍缩现象总吞吐指标不错但画出每个agent的奖励曲线有一两个agent的奖励长期为负甚至低于初始随机策略的水平。 原因吞吐权重alpha太大agent发现抢占资源比协作更划算形成赢家通吃的恶性竞争。多智能体场景里这个问题会被放大——抢到资源的agent奖励高强化了它的抢占策略没抢到的agent怎么探索都拿不到正向反馈策略逐渐坍缩到什么都不做。 解决把gamma从0.1提到0.3让公平性对奖励有实际影响力。还要检查Jain指数本身的计算方式如果只算瞬时吞吐的公平性波动太大agent学不到稳定信号。改成滑动窗口平均吞吐的公平性指标更平滑agent更容易理解协作带来的长期收益。5.4 训练时加了噪声评估时忘了关现象训练曲线收敛得不错一跑评估脚本性能比训练时差一大截而且每次评估结果波动巨大。 原因评估阶段直接复用训练代码actor输出被叠加了探索噪声。训练时噪声能帮agent探索评估时噪声就成了干扰尤其功率控制这类连续动作0.1的噪声偏差足以让SINR变化好几个dB。 解决评估脚本里显式把noise_scale设成0。更稳的做法是在agent类里加一个evaluate_mode标志训练时置False评估时置Trueforward函数里根据标志决定是否加噪声。这个坑几乎所有人都会踩一次因为训练代码看起来天经地义很少有人会怀疑自己的actor在评估时也要保持干净。5.5 target网络更新太快导致Q值振荡现象critic loss一直在降但降得很慢actor的奖励曲线像锯齿一样反复横跳训练中后期出现周期性崩溃。 原因tau设太大比如直接抄了单智能体DDPG的0.1target网络几乎实时跟踪在线网络Q值目标本身不稳定critic在追踪一个移动靶。 解决把tau降到0.005到0.01之间同时检查target网络的更新频率。还可以改成延迟更新每训练10步才同步一次target网络这种方式在MADDPG里比纯软更新更省计算量稳定性也不差。遇到过极端情况是tau0.01仍然振荡后来发现是critic学习率也偏大降到3e-4后问题消失。6. 回归测试与泛化验证固定随机种子做多组评估才有说服力模型训完之后验证工作比训练本身更考验工程习惯。我最常用的做法是写独立的评估脚本把车辆密度和业务负载配成多组参数每组固定随机种子跑五遍取均值。为什么强调固定随机种子因为Python的random和numpy.random如果不固定每次评估的车辆初始位置和信道衰落都不同结果方差极大。固定种子后同一配置下五次评估的差异只来自策略本身统计意义更干净。评估指标至少看四条曲线系统总吞吐、平均时延、Jain公平指数、队列积压长度。训练时看总奖励曲线评估时一定要把奖励剥开看原始物理指标因为奖励是工程化的合成信号有时奖励涨了但时延没降说明奖励塑形里有虚假关联。画出车辆数从10到50增长的吞吐曲线如果吞吐在车辆数增加时先升后降说明策略能利用多用户分集如果从某个点开始陡降大概率是资源竞争导致SINR恶化需要回头调功率约束。另一个容易忽略的验证是算法鲁棒性测试把信道模型里的阴影衰落标准差从8dB改到12dB把泊松到达率翻倍模型性能掉多少。如果掉得太多说明策略过拟合了训练时的业务模型。处理方式是在训练时就用domain randomization每个episode随机采样一组业务参数环境在reset时重新配置这样训练出来的策略对参数变化更迟钝。这个方法不算复杂但效果立竿见影。这套方案做下来正常情况是训练3000到5000个episode后吞吐比启发式PF策略提升10%到20%时延抖动降低30%以上。但想提醒你的是MADRL方案比传统算法重得多如果业务场景固定、车流模式稳定PF加调参可能已经够用。多智能体强化学习的价值在动态性和非平稳性突出的场景——车流密度剧烈波动的城市路口、多RSU协同的连续路段——这种地方传统算法跟不上MADRL的分布式决策才能显露出优势。我自己踩过最大的一次教训就是花了两周训练一个复杂模型最后发现基线策略稍加调优就能打平所以动手前先跑好基线别让神经网络替你背锅。希望帮到你。本文还有配套的精品资源点击获取