简介:针对无人机辅助移动边缘计算中的计算卸载优化问题,包内代码提供了基于深度确定性策略梯度(DDPG)的完整Python实现,覆盖从Actor-Critic网络构建到训练与评估的整个流程;代码采用参数化编程,注释清晰,便于调整学习率、折扣因子等超参数,也可在此基础上接入自定义网络结构。包内共17个文件(压缩包仅45KB),包括16个Python脚本和1个README说明文档,除DDPG主程序外,还额外实现了Edge_only、DQN、Local_only等对比策略模块,方便研究不同卸载方案的效果差异。资源同时附有可直接运行的案例数据,支持在Matlab 2014/2019a/2024a环境中调用验证,适合计算机、电子信息工程、数学等专业学生用于课程设计、期末大作业或毕业设计。对于希望快速入门深度强化学习在移动边缘计算应用的研究者,也可以借助该代码熟悉算法实现与实验对比方法。已有147人学习下载,是兼顾学习与实战的实用工具。
1. 无人机辅助移动边缘计算的计算卸载,为什么默认优先试深度确定性策略梯度
无人机把边缘计算节点带上天,地面设备把任务卸载给它,这类系统的关键决策是三个连续量:卸载多少、发射功率多大、无人机算力怎么分。计算卸载优化写到这一步,离散动作的 Q-learning 已经装不下连续状态,深度确定性策略梯度(DDPG)能直接输出连续动作,所以在移动边缘计算研究里几乎是默认起点的强化学习算法。这篇笔记按一套可复现的 Python 代码包来拆解:从 MDP 建模、Actor-Critic 实现,到训练主循环和调参避坑,再到基线对比方法。正在复现论文、做毕设,或者在仿真里做卸载方案验证的从业者,可以直接照着改。
2. 把计算卸载问题写成 MDP:状态、动作、奖励三层建模顺序
开写训练代码之前先想清楚一件事:DDPG 只负责“给定状态输出动作”,它不替你决定什么是状态,也不替你定义奖励。无人机辅助移动边缘计算的卸载优化,建模成马尔可夫决策过程(MDP)后,核心就是状态空间、动作空间、奖励函数三件事。很多人一上来把十几个维度的量塞进状态向量,结果训练几百个 episode 不收敛,问题往往不在 DDPG,而在建模这一步就已经埋了雷。
另一个常见的选型疑问是:为什么不用 PPO 或 DQN?DQN 处理不了连续动作,卸载比例、发射功率这类决策要么离散化,要么就丢掉精度;PPO 能处理连续动作,但训练方差大、数据利用率低。在单无人机单设备的仿真场景里,DDPG 的确定性策略让评估和部署都更省事,这也是大量计算卸载论文拿它当基线的直接原因。下面按状态、动作、奖励的顺序说落地细节。
2.1 状态空间的三条线:信道、任务队列、能量
状态设计我习惯按三条线组织。第一条线是信道状况:地面设备到无人机的链路质量直接决定卸载划不划算。常见做法是把路径损耗换算成信道增益放进状态,而不是放原始距离。城市环境里无人机与地面设备可能是视距也可能是非视距,严格做法用仰角算视距概率,工程上先用带附加损耗的经验公式:
import numpy as np def channel_gain(distance, height_uav=60.0, height_dev=2.0, fc=2e9): """ 地面设备到无人机的信道增益 distance : 水平距离, 单位 m height_uav : 无人机高度, 单位 m height_dev : 设备高度, 单位 m fc : 载频, 单位 Hz 返回: 归一化信道增益, 取值 (0, 1] """ d3 = np.sqrt(distance ** 2 + (height_uav - height_dev) ** 2) # 自由空间损耗 + 5dB 城市环境附加损耗(工程近似) pl_db = 20 * np.log10(d3) + 20 * np.log10(fc) - 147.55 + 5.0 return 10 ** (-pl_db / 10.0)这个函数返回的增益直接作为一个状态维度。注意三点:三维距离 d3 和载频 fc 的单位必须统一,fc 用 Hz 而不是 kHz,否则损耗差 60dB,训练必翻车;附加损耗 5dB 只是城市环境的近似,如果你的场景是郊区或山区,这个值要下调;返回值本身已经是 0 到 1 之间的小数,适合直接喂给网络,不需要再做归一化。
第二条线是任务队列:任务数据量大小、每比特需要的 CPU 周期数、以及任务允许的时延上限。第三条线是能量:设备剩余电量、无人机剩余电量。无人机位置坐标也可以进状态,但如果你把无人机移动轨迹也交给 DDPG 输出,状态里就必须带设备相对无人机的坐标,否则轨迹策略学不到空间信息。整体上状态向量控制在 8 到 12 维比较合适,超过 15 维又没有专门的特征工程,收敛速度会明显变慢。
2.2 动作空间:连续动作如何映射到卸载比例和发射功率
DDPG 的优势在动作连续。卸载优化里动作一般是三个:卸载比例、发射功率、无人机分配给该设备的算力比例。Actor 网络最后一层用 tanh,输出范围天然在 [-1, 1],所以要加一次线性映射。动态计算卸载层的接口通常就是下面这个纯函数:
# raw_action: actor 输出, 形状 (batch, 3), 取值 [-1, 1] def action_to_decision(raw_action, p_max=1.0, f_max=10e9): # 卸载比例映射到 [0, 1]: 0 表示全本地, 1 表示全卸载 offload_ratio = (raw_action[:, 0] + 1) / 2.0 # 发射功率映射到 [0, p_max], 单位 W tx_power = (raw_action[:, 1] + 1) / 2.0 * p_max # 无人机算力分配映射到 [0, f_max], 单位 Hz cpu_alloc = (raw_action[:, 2] + 1) / 2.0 * f_max return offload_ratio, tx_power, cpu_alloc这里有个细节值得单独说:卸载比例和算力分配是百分比,发射功率是绝对物理量。三者尺度不同,如果直接让 Actor 输出绝对值,功率维度会在梯度里占主导。常见做法是让 Actor 统一输出归一化动作,在环境侧再乘物理上限,也就是上面代码的写法,这样 Critic 的输入尺度统一,Q 值估计更稳。
如果你的论文场景是“任务只能全本地或全卸载”的二元决策,连续动作不能直接当离散用。常见做法是让 Actor 输出卸载倾向值,再用阈值采样得到 0/1 判断,但这样训练时梯度是断的。工程上的折中是把它当作 0-1 之间的软决策,仿真里按比例拆分任务量,既保住梯度,又近似二元场景。
2.3 奖励函数:时延与能耗的加权不要拍脑袋
奖励函数最常见的写法是负的加权和,时延和能耗都要考虑:
def compute_reward(t_total, e_total, alpha=0.7, beta=0.3, deadline=1.0, penalty=5.0): """ t_total: 任务完成时延(本地与卸载按比例的加权和), 单位 s e_total: 设备与无人机总能耗, 单位 J alpha, beta: 时延与能耗权重, alpha + beta = 1 penalty: 超过截止时延的额外惩罚 """ r = -(alpha * t_total + beta * e_total) if t_total > deadline: r -= penalty return ralpha 和 beta 不要拍脑袋设。我的做法是先跑一版全本地计算策略,统计时延和能耗的数量级,再调整 alpha 和 beta 让两个加权项在数值上相当。小的那个权重项如果比大的小两个数量级,它会被完全淹没,策略最后只优化时延或只优化能耗。这个调权重的过程有点玄学,但前提是量纲对齐,否则 alpha=0.7 根本不是“时延占七成”的意思。
提示:奖励里的时延和能耗都要各自除以典型最大值,归一化之后 alpha 和 beta 才是真正的权衡系数。这个细节在计算卸载论文复现里极其常见,属于血泪经验。
3. DDPG 的 Python 代码骨架:Actor-Critic、经验回放与软更新
拿到一份标注 DDPG 的源码包,解压后通常就是四块:仿真环境、Actor-Critic 网络、回放缓冲区和训练主循环。MDP 定完之后,DDPG 部分其实是一套可以复用的模板。下面给的是 PyTorch 最小实现,网络宽度、深度、学习率都按无人机卸载场景的常见配置给,照着跑完再接训练循环就可以出曲线。
3.1 Actor 和 Critic 网络的最小定义
Actor 输入状态,输出动作;Critic 输入状态与动作的拼接,输出 Q 值。两套网络各有一份 target 副本,这是 DDPG 能稳定的关键结构。
import torch import torch.nn as nn class Actor(nn.Module): def __init__(self, s_dim, a_dim, hidden=256): super(Actor, self).__init__() self.net = nn.Sequential( nn.Linear(s_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, a_dim), nn.Tanh(), # 输出限幅到 [-1, 1] ) def forward(self, s): return self.net(s) class Critic(nn.Module): def __init__(self, s_dim, a_dim, hidden=256): super(Critic, self).__init__() self.net = nn.Sequential( nn.Linear(s_dim + a_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, 1), ) def forward(self, s, a): return self.net(torch.cat([s, a], dim=-1))参数说明:hidden 取 256 在多数卸载场景够用,加宽到 512 对结果提升有限但训练时间翻倍;s_dim 对应状态维度,a_dim 通常取 3。Actor 输出层必须接 Tanh,否则动作越界后靠 clip 处理会破坏梯度;Critic 是状态和动作拼接后进全连接,拼接顺序没有硬性要求,但训练和评估必须保持一致。
容易翻车的地方在张量形状。Critic 拼接要求 s 和 a 都是二维的 (batch, dim),如果动作经过了噪声扰动,噪声后的动作也要走同样的归一化再喂给 Critic,否则训练时的 Q 值分布和预测时不一致,评估曲线会突然劣化。这个坑在单步调试时看不出来,往往要等训练几百个 episode 才暴露。
3.2 经验回放与软更新:两个最常被抄错的参数
经验回放的作用是打断时序相关性。卸载场景里相邻两步的状态高度相关,直接按顺序训练会让网络在局部反复震荡。缓冲区和软更新的实现如下:
from collections import deque import random class ReplayBuffer: def __init__(self, capacity=100000): self.buf = deque(maxlen=capacity) def push(self, s, a, r, s2, done): self.buf.append((s, a, r, s2, done)) def sample(self, batch_size): batch = random.sample(self.buf, batch_size) s, a, r, s2, d = map(torch.stack, zip(*batch)) return s, a, r, s2, d def __len__(self): return len(self.buf) def soft_update(target, source, tau): """ 软更新: target = tau * source + (1 - tau) * target tau 太小更新慢, 太大训练不稳 """ for tp, sp in zip(target.parameters(), source.parameters()): tp.data.copy_(tau * sp.data + (1.0 - tau) * tp.data)capacity 取 10 万,单 episode 200 步时能覆盖最近几百个 episode,够用。tau 常见区间是 0.001 到 0.01,我一般取 0.005。抄代码时最容易错的是两处:一是把 target 网络直接赋给 source,二是计算 target Q 时忘了 detach。target Q 的梯度必须切断,否则梯度穿过 target 网络传回,训练会变成一团浆糊,现象就是 Q 值震荡不收敛。
3.3 探索噪声:OU 噪声和高斯噪声怎么选
DDPG 是确定性策略,探索全靠给动作加噪声。经典论文用的是 OU 噪声,带时间相关性,适合惯性系统;但计算卸载仿真里信道和任务每步都在随机变化,时间相关性未必是优点。我的经验是:信道快变时用高斯噪声,方差从 0.1 起步;信道慢变或还要同时学无人机轨迹的场景用 OU 噪声,theta=0.15、sigma=0.2 是常用起点。
class GaussianNoise: def __init__(self, sigma=0.1, bound=1.0, dim=3): self.sigma = sigma self.bound = bound self.dim = dim def sample(self): # 噪声裁剪到 [-bound, bound], 避免动作长时间贴在tanh饱和区 return np.clip(self.sigma * np.random.randn(self.dim), -self.bound, self.bound)噪声方差不是越大越好。sigma 太大,动作一直在 [-1, 1] 边缘弹跳,看起来在探索,实际上策略权重被噪声主导,学不到环境结构;sigma 太小,策略很快收敛到局部解,后面加多少 episode 都没用。我习惯在训练后三分之一把 sigma 线性衰减到 0.02 左右,兼顾探索和利用。
4. 训练主循环:把 DDPG 和无人机仿真环境接起来的完整流程
网络和环境都齐了之后,训练主循环看起来只有几十行,训练稳定性却全在这几十行里。下面的代码把 Actor、Critic、target 网络、回放缓冲区串起来,是无人机辅助移动边缘计算里最标准的单智能体训练流。
4.1 训练主循环代码
def train_ddpg(env, actor, critic, target_actor, target_critic, replay, episodes=2000, max_steps=200, batch_size=128, gamma=0.99, tau=0.005, actor_lr=1e-4, critic_lr=1e-3): opt_a = torch.optim.Adam(actor.parameters(), lr=actor_lr) opt_c = torch.optim.Adam(critic.parameters(), lr=critic_lr) noise = GaussianNoise(sigma=0.1) for ep in range(episodes): state = env.reset() ep_cost = 0.0 for step in range(max_steps): # 确定性动作 + 探索噪声 action = actor(state).detach().numpy() action = np.clip(action + noise.sample(), -1.0, 1.0) next_state, reward, done = env.step(action) replay.push(state, action, reward, next_state, float(done)) ep_cost += reward state = next_state if len(replay) >= batch_size: s, a, r, s2, d = replay.sample(batch_size) # 1) 更新 Critic: 拟合 TD 目标 with torch.no_grad(): a_next = target_actor(s2) q_target = r + gamma * (1 - d) * target_critic(s2, a_next) q_pred = critic(s, a) loss_c = nn.MSELoss()(q_pred, q_target) opt_c.zero_grad(); loss_c.backward(); opt_c.step() # 2) 更新 Actor: 最大化 Critic 对当前动作的估值 loss_a = -critic(s, actor(s)).mean() opt_a.zero_grad(); loss_a.backward(); opt_a.step() # 3) 软更新两套 target 网络 soft_update(target_actor, actor, tau) soft_update(target_critic, critic, tau) if done: break if ep % 100 == 0: print(f"episode {ep}, avg cost {ep_cost / (step + 1):.4f}")逻辑说明:每步先取确定性动作加噪声,环境步进得到奖励和下一状态,存入回放;缓冲区攒够一个 batch 后,先更新 Critic 再更新 Actor,最后软更新两套 target 网络。更新顺序不能反过来,Actor 的梯度依赖当前 Critic 的估值,先更 Actor 后更 Critic,当轮梯度用的是旧 Q 值,训练方差会变大。
参数说明:actor 学习率取 1e-4,critic 学习率取 1e-3,这个一低一高是刻意为之。Critic 要快速逼近 Q 值的量级,Actor 要慢一点防止被不稳定的 Q 值带偏。两个学习率相等,常见结果是 Critic 还没稳住,Actor 已经在乱走。gamma 取 0.99 适用于步数不长的任务,如果单 episode 超过 500 步,可以降到 0.95 减少累计误差。
4.2 仿真场景参数表
环境部分按单无人机单地面设备的经典场景,给一组能直接用的参数:
| 参数 | 取值 | 说明 |
|---|---|---|
| 无人机高度 | 60 m | 固定高度,简化信道模型 |
| 设备发射功率上限 | 1 W | 动作映射的上界 |
| 载频 | 2 GHz | 与信道增益函数保持一致 |
| 任务数据量 | 100~500 KB | 每步随机采样 |
| 计算密度 | 1000 cycle/bit | 每比特需要的 CPU 周期 |
| 无人机算力 | 10 GHz | 等效 CPU 频率 |
| 设备本地算力 | 1 GHz | 决定卸载收益空间 |
| 时延权重 alpha | 0.7 | 与能耗权重 beta=0.3 |
| 单 episode 步数 | 200 | 任务到达次数 |
| 训练 episode 数 | 2000 | 视收敛情况调整 |
这组参数的关键在于本地算力和无人机算力拉开差距。如果两者接近,卸载的收益空间很小,DDPG 学出来的最优策略就是全本地,训练曲线看起来“收敛”,实际上白训。任务数据量范围也不宜过大,100 到 500 KB 的随机采样能覆盖信道变化带来的决策差异,范围再拉大,单次任务的最优卸载比例分布会失衡。
4.3 训练曲线怎么判读
训练结束后画两条曲线:episode 平均 cost(即负奖励)和 Critic 的 Q 值均值。cost 曲线平缓下降是好事,但平缓不等于收敛。我的判断标准是:用去掉噪声的 Actor 在环境里跑 100 个 episode,如果评估平均 cost 和训练末期 cost 相差在 5% 以内,才算真正收敛。训练曲线的锯齿状是正常的,确定性策略加回放抽样天然带来波动;锯齿突然消失反而要警惕,那可能是动作被夹在边界上,策略失去了探索能力。
5. 计算卸载调参避坑:五个翻车现场与排查步骤
DDPG 是出了名的难调。这一章把计算卸载场景里最常见的五个坑按“现象 -> 原因 -> 解决”写清楚,每个我都亲自踩过,按顺序排查能省下大量训练时间。
5.1 Q 值爆炸式增长,cost 曲线直接飞天
现象:训练几十个 episode 后 Critic 输出几千上万的 Q 值,cost 曲线不但没降反而震荡放大,最后出现 NaN。
原因:奖励尺度太大。TD 目标里 r + gamma * Q' 反复累加,Q 值被一层层推到天上去。计算卸载里时延是秒级、能耗是焦耳级,加权和经常到几十甚至上百,Critic 直接回归这个量级很容易发散。
解决:把奖励归一化到 [-1, 1] 附近。先统计前 200 个 episode 的原始奖励最大值,再用 reward / max_abs 缩放;或者按上一章的量纲对齐方法,时延和能耗各自除以典型最大值。改完奖励重新训练,Q 值落在个位数级别才算正常。
5.2 动作长期贴在边界上,tanh 饱和
现象:训练中打印 Actor 原始输出,发现卸载比例恒等于 0 或 1,发射功率恒等于上限,策略完全失去区分能力。
原因:tanh 在 |x| 大于 2 之后梯度几乎为零。如果输出层权重初始化太大,或者奖励对边界动作有利,网络会“懒惰”地停在饱和区,梯度消失后再也推不回来。
解决:把 Actor 输出层的权重初始化设成小数值,比如均值为 0、标准差 0.003 的正态分布;或者在奖励里给接近边界的动作加轻微惩罚,比如 -0.05 * |action|,逼策略回到中间区域探索。治本的办法是训练前期加大探索噪声,让动作经常落到中间区域而不是只在边界附近试。
5.3 奖励里加了能耗惩罚就发散
现象:只用时延项能收敛,把能耗项加上之后训练直接发散,或者策略收敛到全本地。
原因:能耗的数值量级和时延不匹配。时延是毫秒到秒,能耗是毫焦到焦耳,能耗数值大时 beta * e_total 会瞬间压过 alpha * t_total,奖励被能耗主导,策略全部倾向于最小能耗,也就是全本地。
解决:统计两个量的典型范围,各自除以最大值做归一化,再配权重。奖励写成 -(alpha * t_norm + beta * e_norm),alpha 和 beta 才真正代表时延和能耗的权衡。做完这一步,加能耗惩罚项才不会反噬训练。
5.4 同一份代码换随机种子结果差异巨大
现象:代码一行没改,换一个 random seed,一条收敛一条不收敛,最终性能差 30% 以上。
原因:DDPG 对初始化和抽样随机性极度敏感,计算卸载环境里信道和任务又是随机生成的,种子的影响被放大。不同种子等于不同的初始策略起点,这不是 bug,是算法特性。
解决:不要只跑一个种子就下结论。至少跑 5 个种子,汇报平均值和标准差。如果某个种子必炸,检查环境是否用了全局随机数而没有独立 seed,多进程训练时随机状态会悄悄串扰。固定 seed 的通用做法是在脚本开头一次设定 torch、numpy、random 三个库的种子。
5.5 卸载率在小数点后抖,策略退化成全本地
现象:训练后期 Actor 输出的卸载比例在 0.4 到 0.6 之间抖动,评估时任务却几乎全部本地执行,卸载率形同虚设。
原因:卸载比例接近 0.5,说明 Actor 没学会区分“卸载划算”和“本地划算”的边界,输出是噪声驱动的。更隐蔽的原因是 Critic 对两种决策的 Q 值区分度不够,梯度信号太弱,推不动策略往两端走。
解决:先做简化验证。固定其他变量只改信道增益,看最优卸载比例的理论值是否单调变化。如果是,说明状态和奖励没问题,那就在 Critic 输入里加一个手工特征:“当前信道下卸载与本地执行的时延差”。这个特征相当于给网络一个强先验,比加宽网络有效得多,在动态计算卸载层里属于性价比最高的修复手段。
6. 验证技巧:DDPG 和基线算法对比时先看这两个数
训练完被问得最多的问题是:“你的 DDPG 到底比基线好多少?”我的验证习惯是评估时去掉噪声,只算两个数:平均加权成本,以及任务超时率。
def evaluate(env, actor, episodes=100): costs, timeouts = [], 0 for _ in range(episodes): state = env.reset() ep_cost = 0.0 for step in range(env.max_steps): action = actor(state).detach().numpy() # 评估不加噪声 state, reward, done = env.step(action) ep_cost += reward if env.last_timeout: timeouts += 1 costs.append(ep_cost) return np.mean(costs), timeouts / (episodes * env.max_steps)对比表至少放四行:全本地计算、随机卸载、贪心卸载(信道好就近全卸载)、DDPG。示例结果大致是:
| 策略 | 平均加权成本 | 超时率 |
|---|---|---|
| 全本地 | 0.82 | 12% |
| 随机卸载 | 0.91 | 18% |
| 贪心卸载 | 0.55 | 7% |
| DDPG | 0.43 | 3% |
很多实现只写平均成本,但超时率才是无人机边缘计算里真正影响体验的指标。DDPG 如果只降平均成本而超时率不降,说明奖励权重里时延的约束被能耗稀释了。我个人的习惯是每次调参只改一个变量,改完先看超时率再看平均成本:超时率降、平均成本升,说明权重需要调整;两个都降,才是真正有效的改动。这个习惯帮我少踩了很多“曲线收敛、实际不可用”的坑,希望帮到你。
本文还有配套的精品资源,点击获取