简介:面向计算机相关专业学生、教师及企业研发人员,这份压缩包提供基于深度强化学习的部分计算任务卸载延迟优化Python源码,并配有详细代码注释。项目聚焦移动边缘计算下的任务卸载决策,通过深度强化学习模型在本地执行与边缘卸载之间分配计算任务,从而降低系统整体延迟,可作为毕业设计、课程设计或课程大作业的完整基础实现。包内共5个文件,包括可运行的核心仿真脚本、Markdown说明文档、实验效果对比图(PNG)以及Git属性等配置文件,压缩包整体仅约4KB,轻量易部署。目前已有583人浏览学习,代码已在实际运行中验证通过,注释覆盖网络结构与训练流程,便于快速理解卸载决策与强化学习的结合方式。读者可借助源码和文档直接复现延迟优化结果,也可在此基础上调整卸载比例、奖励函数或替换强化学习算法,用于论文实验、方案对比或项目演示。
1. 延迟优化不是把任务全扔给边缘,而是学会留一部分在本地
说到基于深度强化学习的部分计算任务卸载延迟优化,不少读者第一反应是这是毕设选题,但它在边缘计算里其实是一门实打实的调度工程:任务可分割,本地有空闲算力,边缘服务器响应更快,可无线信道又在不断波动,你没法用一个固定比例去拆任务,于是用深度强化学习把“看状态、给卸载比例”这件事学成一张策略表。这篇笔记只解决三件事:方案为什么成立、Python源码怎么跑通、以及哪些坑会让训练结果变成玄学。适合正在做边缘计算仿真、物联网延迟优化或DRL落地的工程师跟读,新手照着代码也能改出自己的实验版本。
2. 部分计算任务卸载:延迟从哪来,DRL为什么比贪心策略强
2.1 一个任务拆成两份:并行计算为什么能降延迟
部分卸载和整体卸载的区别,一句话就能说清:整体卸载把不可分割的任务整个搬去边缘,端到端延迟等于传输时间加边缘执行时间;而部分卸载把任务按比例切成两块,本地算一份、边缘算一份,两块并行跑。关键公式在这里:
本地这一路的完成时间是t_local = (Q_local + α * D) / C_local,其中Q_local是本地队列里已经积压的比特数,α是本地承担的比例,D是任务大小,C_local是本地计算吞吐。另一路边缘完成时间是t_edge = Q_edge / C_edge + (1 - α) * D / R + (1 - α) * D / C_edge,它由边缘排队、无线传输、边缘计算三段组成。因为是并行执行,端到端延迟不是两路相加,而是取两者较大值:delay = max(t_local, t_edge)。
这个 max 结构就是优化空间的来源。如果你把任务百分之百放本地,延迟只受本地算力约束;全部卸载,延迟被无线信道和边缘排队锁定;而部分卸载让两条路径互相掩盖——信道差时多留本地,本地排队深时多放边缘。现实中任务往往在几百 KB 到几 MB 之间,边缘服务器算力是终端的 5 到 10 倍,但无线速率是波动的,所以延迟曲线里总存在一个最优的拆分点。
当然,部分卸载不是没有代价。任务切分之后,边缘那一份的传输时间(1-α)D/R是纯开销,不像本地计算能立刻开始。如果你的边缘计算吞吐C_edge只是本地的一两倍,而信道速率又远低于本地处理速率,那“拆开跑”反而可能比“本地全跑”更慢。这也是为什么静态的 50/50 切分方案在实际仿真里经常翻车:它忽略了 R 这个变量。
2.2 信道波动与队列积压:静态卸载比例的失效场景
静态比例失效的第一类场景是信道剧烈波动。我见过不少实验把无线速率设成固定 8 Mbps,训练出来的策略只要一换成动态信道(2 到 12 Mbps 之间随机跳)就直接报废。原因不复杂:当信道好时,卸载到边缘的传输时延可以压到几毫秒,边缘算力优势得到发挥;信道一差,传输时延变成几十甚至几百毫秒,此时卸载越狠延迟越大。策略必须在每个决策时刻感知当前 R 值,而不是记住一个平均最优值。
第二类失效场景是队列积压的慢变量效应。单独看一个任务,当前信道好就全卸载确实最优;但边缘服务器不是独占的,之前到达的任务可能还在边缘队列里排队。如果你只盯瞬时延迟做贪心决策,边缘队列会被连续的高卸载比例任务灌满,后面每个任务的排队时间都在涨。要避免这种“只顾眼前”的翻车,决策必须把当前Q_local和Q_edge作为状态的一部分,让强化学习在训练中自然地学会“排队深了就少往那边塞”的隐式策略。
这两类场景加起来,静态比例和单步贪心都站不住。贪心只看当前,静态比例完全不看状态;而部分卸载延迟优化本质是一个连续状态、离散动作的时序决策问题,每一轮的卸载比例都会影响后续队列,进而影响后续所有任务的延迟。这就是马尔可夫决策过程(MDP)的标准画像,也是深度强化学习入场的直接理由。
2.3 DRL选型:DQN家族为什么适合这个离散决策问题
深度强化学习的算法选择上,我的习惯是先分动作空间再选算法。卸载比例在实际工程里往往先按 10% 一档离散化,比如动作集合是{0.0, 0.1, ..., 1.0}共 11 档,这正是离散动作空间,DQN(Deep Q-Network)及它的变体是最省事的解法。DQN 用经验回放打破样本相关性,用目标网络稳定训练目标,对状态维度低、动作维度中等的调度问题收敛速度很理想,而且在 pytorch 和 tf 框架下的实现非常成熟。
为什么我不建议一上来就用 PPO 或 A3C?部分卸载场景的仿真时隙往往只有几十毫秒,一个 episode 就几十个决策步,属于典型的小规模 MDP;PPO 需要多步轨迹才能估计优势函数,在短 episode 环境里优势估计方差反而大。而 DQN 的每个样本都是单步转移(s, a, r, s'),天然适合这种短视界问题。如果后续想把卸载比例做成连续动作,比如要精确到 0.01,那再去换 SAC 或者 P-DQN 不迟。
这里也顺便提一句深度强化学习的跨场景迁移问题——和基于深度强化学习的移动机器人室内自主导航方法做个类比:导航策略在换地图后要重新训练,卸载策略在换任务分布后同样需要微调。两者共性的经验是,DRL 学到的是“状态到动作的映射”,状态设计得越贴近物理本质(导航是距离和障碍,卸载是队列和信道),迁移时需要的重训数据就越少。所以接下来的 MDP 建模,比算法本身更能决定你能不能收敛。
3. 把卸载问题写成MDP:状态、动作、奖励的工程取舍
3.1 状态设计:任务大小、排队积压与信道速率怎么组合
状态是DRL的输入,也是整个环境建模的浓缩。最小可用的状态我一般选四个特征:当前任务大小D、本地队列积压Q_local、边缘队列积压Q_edge、无线信道速率R。为什么没有 CPU 利用率?因为在任务卸载这类吞吐模型中,CPU 利用率是个间接指标,它不直接告诉网络“当前排队要等多久”,而队列积压直接进入延迟公式,和优化目标绑定得更紧。
状态做归一化这一步千万不能省。我的写法是把每个特征除以各自的常见上界:D/5e5、Q_local/1e6、Q_edge/1e6、R/12e6。这样四个特征落入 0 到 1 附近的量纲,网络一层的线性变换就能很好地混合特征。如果你不归一化,D 的量级是 1e5,R 的量级是 1e7,初始化阶段 Q 值会被大数特征主导,训练开头几百个 episode 基本白跑。
更细的读者可能要问:要不要把任务历史到达间隔也放进状态?对延迟优化来说,短期到达模式已经通过队列积压隐含表达。如果你的仿真里任务到达不是同分布而是突发模型(比如波松到达),那么建议加一个“距上一任务间隔”特征,帮助网络区分突发期和空闲期。这个动作成本低,但对突发场景的收敛帮助非常明显。
3.2 动作空间:卸载比例的离散化粒度怎么定
动作空间我把卸载比例 α 从 0 到 1 均匀切成 11 档,步长 0.1。这个粒度是实践出来的折中:太粗(比如只有 0/0.5/1 三档)策略表达力不够,信道中等时找不到接近最优的拆分点;太细(比如 1% 一档,101 个动作)会让 Q 值在相近动作之间产生大量混淆,因为相邻档位之间的延迟差只有几毫秒,落在 Q 值噪声里,探索效率反而下降。
10% 一档时,动作之间的延迟差异在信道变化明显时能形成清晰的梯度信号,网络容易区分哪个方向更优。如果实验要求更细粒度,常见做法是先训一个 11 档的 DQN,把网络当初始化模型,再把输出层换成 21 档继续微调,转移学习比直接训 21 档快得多。这算是我在卸载实验里验证过的一个“后悔药”技巧。
另外动作空间设计还要和状态里的 R 匹配。如果仿真中最大信道速率只有 6 Mbps,状态归一化却除以 12 Mbps,那 R 特征永远在 0.2 以下波动,网络对信道变化的敏感度会被压缩。所以归一化上界要按真实分布的 90 分位去取,而不是按理论边界。
3.3 奖励函数:延迟取负值之后的量纲陷阱
奖励函数最直接的写法是reward = -delay,延迟越小奖励越大。但我强烈建议把 delay 从“秒”换成“毫秒”,或者给奖励乘一个放大系数。我代码里给的是reward = -delay * 10,实际项目中我见过因为 delay 用秒做单位,奖励值始终在 -0.1 到 -1.5 之间,Q 值梯度过小,loss 下降但策略不进步。延迟量纲和奖励尺度直接决定梯度大小,这是 DRL 卸载任务最常见的隐形坑。
折扣因子 γ 我通常取 0.95。延迟优化的场景里,当前决策对后续时隙的影响主要是通过队列积压传导的,这种影响会随着时隙数增加而衰减,γ 取 0.9 到 0.99 区间都合理。γ=0.9 时网络更看重眼前延迟,适合队列能力强的环境;γ=0.99 时网络会愿意短期牺牲一点延迟去清空队列,适合任务密集的高负载场景。我不建议 γ 取 1.0,那种情况 Q 值容易发散,尤其当 episode 无终止状态时。
奖励里要不要加能量惩罚?标题锁定的是延迟优化,所以我更推荐第一版不加任何辅助项,先把延迟压下去。等延迟曲线稳定了,再引入reward = -delay - λ * energy,λ 从 0.1 开始搜,观察延迟稍微上升时能量省了多少。这个“先单目标、后多目标”的顺序,会让你在排查收敛问题时少一半干扰。
4. 源码跑通:环境类、DQN网络与训练主循环
4.1 卸载环境 OffloadEnv:延迟计算与队列更新的关键代码
环境类负责生成任务、计算延迟、更新队列,是整个仿真能复现的基石。下面这段是核心逻辑,可以直接存成一个offload_env.py文件。
import numpy as np class OffloadEnv: """ 部分计算任务卸载环境: 每个时隙到达一个大小随机的任务,决策者给出卸载比例 alpha, 本地计算 alpha 份,边缘计算 (1-alpha) 份,两条路径并行执行。 """ def __init__(self, c_local=8e6, c_edge=50e6, dt=0.04): self.c_local = c_local # 本地计算吞吐,单位 bps,约 8Mbps self.c_edge = c_edge # 边缘计算吞吐,单位 bps,约 50Mbps self.dt = dt # 一个决策时隙长度,单位秒 def reset(self): self.q_local = 0.0 # 本地队列积压,单位 bit self.q_edge = 0.0 # 边缘队列积压,单位 bit self.D = np.random.uniform(2e5, 5e5) # 当前任务大小,单位 bit self.r = np.random.uniform(2e6, 12e6) # 当前无线信道速率,单位 bps return self._get_state() def _get_state(self): # 状态做归一化,四个特征都在 0~1 附近 return np.array([ self.D / 5e5, # 任务大小,约 0.4~1.0 self.q_local / 1e6, # 本地队列积压 self.q_edge / 1e6, # 边缘队列积压 self.r / 12e6 # 信道速率,约 0.16~1.0 ], dtype=np.float32) def step(self, alpha): # 本地完成时间:排队 + 本地承担的部分任务量 t_local = (self.q_local + alpha * self.D) / self.c_local # 边缘完成时间:边缘排队 + 无线传输 + 边缘计算 t_edge = (self.q_edge / self.c_edge + (1 - alpha) * self.D / self.r + (1 - alpha) * self.D / self.c_edge) delay = max(t_local, t_edge) # 并行执行,端到端延迟取较大值 # 队列更新:服务完的积压从队列里扣除,新任务进来累加 self.q_local = max(0.0, self.q_local + alpha * self.D - self.c_local * self.dt) self.q_edge = max(0.0, self.q_edge + (1 - alpha) * self.D - self.c_edge * self.dt) # 生成下一个时隙的任务与信道,保证状态与转移对应同一时刻 self.D = np.random.uniform(2e5, 5e5) self.r = np.random.uniform(2e6, 12e6) reward = -delay * 10 # 延迟越小奖励越高,放大系数方便梯度回传 return self._get_state(), reward, False, {}这段代码里最需要理解的是delay = max(t_local, t_edge)。如果你误写成了相加,环境就变成了“串行执行”,部分卸载的并行优势会被完全抹掉,训练出来的策略会倾向把任务全部丢给某一条路径。这是复现论文代码时最容易看走眼的一行。队列更新的逻辑则是把“服务的量”和“任务到达的量”按同一个dt时隙结算,self.D = np.random.uniform(...)放在step末尾,是为了让下一个状态里的任务与当前延迟计算解耦——你总不能用一个还没到达的任务去做当前决策。
4.2 DQN与经验回放:网络结构的选择依据
DQN 网络我采用两隐藏层、每层 64 神经元的 MLP。输入维度是 4(状态特征数),输出维度是 11(卸载比例 0.0 到 1.0 的 11 个离散档位),结构非常简单。对这类低维状态问题,更深的网络反而会带来过拟合和训练不稳定。
import random from collections import deque import torch import torch.nn as nn class DQN(nn.Module): def __init__(self, state_dim=4, action_dim=11, hidden=64): super().__init__() self.net = nn.Sequential( nn.Linear(state_dim, hidden), nn.ReLU(), nn.Linear(hidden, hidden), nn.ReLU(), nn.Linear(hidden, action_dim), ) def forward(self, x): return self.net(x) class ReplayBuffer: """经验回放:打乱样本相关性,提高训练稳定性""" def __init__(self, capacity=20000): self.buffer = deque(maxlen=capacity) def push(self, s, a, r, s2, done): self.buffer.append((s, a, r, s2, done)) def sample(self, batch_size): batch = random.sample(self.buffer, batch_size) s, a, r, s2, done = zip(*batch) return (np.array(s), np.array(a), np.array(r), np.array(s2), np.array(done)) def __len__(self): return len(self.buffer)网络结构上,隐藏层 64 是一个经验值:状态只有 4 维,64 已经足够表达“队列深、信道好、任务小”这类组合;如果状态扩展到 6 到 8 个特征(比如加入任务到达间隔、历史平均信道),我会同步把隐藏层提到 128。回放缓冲区容量 20000,按每个 episode 50 步算,约等于 400 个 episode 的样本,容量太小会让网络反复看最近一段时间的数据,队列动态学不准。
4.3 训练主循环:超参数怎么设才能收敛
训练主循环的写法决定了你能不能稳定复现实验结果。下面这段是完整的 DQN 训练过程,含 epsilon 线性衰减和目标网络周期同步。
import torch import torch.nn.functional as F def train_dqn(env, episodes=2000, steps_per_episode=50, batch_size=64, gamma=0.95, lr=1e-3): q_net = DQN() target_net = DQN() target_net.load_state_dict(q_net.state_dict()) # 目标网络初始与评估网络一致 optimizer = torch.optim.Adam(q_net.parameters(), lr=lr) buffer = ReplayBuffer(20000) epsilon_start, epsilon_end = 1.0, 0.05 decay_episodes = 1200 # epsilon 在 200~1400 episode 内线性衰减 for ep in range(episodes): state = env.reset() total_reward = 0.0 if ep < 200: epsilon = epsilon_start elif ep < 1400: epsilon = 1.0 - (ep - 200) / decay_episodes * (1.0 - epsilon_end) else: epsilon = epsilon_end for t in range(steps_per_episode): if random.random() < epsilon: action = random.randint(0, 10) # 随机探索 11 个卸载比例 else: with torch.no_grad(): q = q_net(torch.FloatTensor(state).unsqueeze(0)) action = int(q.argmax(dim=1).item()) next_state, reward, done, _ = env.step(action) buffer.push(state, action, reward, next_state, done) state = next_state total_reward += reward if len(buffer) >= batch_size * 8: s, a, r, s2, _ = buffer.sample(batch_size) s = torch.FloatTensor(s) a = torch.LongTensor(a).unsqueeze(1) r = torch.FloatTensor(r) s2 = torch.FloatTensor(s2) # 计算当前动作的 Q 值,gather 按索引取对应维度 q_pred = q_net(s).gather(1, a).squeeze(1) with torch.no_grad(): q_target = r + gamma * target_net(s2).max(dim=1).values loss = F.mse_loss(q_pred, q_target) optimizer.zero_grad() loss.backward() optimizer.step() if ep % 200 == 0: # 硬更新目标网络,避免 Q 值发散 target_net.load_state_dict(q_net.state_dict()) print(f"episode {ep}, avg_reward {total_reward / steps_per_episode:.3f}, epsilon {epsilon:.3f}")代码里q_net(s).gather(1, a)是从预测出的 11 个 Q 值里挑出当前执行动作对应的那个值,q_target用目标网络计算下一个状态的贪心 Q 值,再乘 gamma 加奖励。这个“评估网络选动作、目标网络估值”的双网络结构是 DQN 稳定的核心,200 步硬更新的节奏在低维环境下表现很好。epsilon 衰减范围我采取分段策略:前 200 个 episode 全随机探索,中间 1200 个 episode 线性降下,最后保持 0.05 的探索率防止策略锁死在某个次优动作上。
关键超参数按我的习惯整理如下,供你做第一版实验时直接抄:
| 参数 | 推荐值 | 作用与调整方向 |
|---|---|---|
| hidden | 64 | 状态维度低,64 足够;扩展状态后上到 128 |
| gamma | 0.95 | 队列影响衰减速度,高负载环境可试 0.99 |
| batch_size | 64 | 训练批大小,样本少时降到 32 稳定梯度 |
| lr | 1e-3 | Adam 默认就够,震荡时降一半 |
| epsilon_end | 0.05 | 探索下限,动作空间大时提高到 0.1 |
| 目标网络同步间隔 | 200 episode | 间隔太短会震荡,太长会发散 |
5. 避坑清单:训练loss下降但延迟不降,问题出在哪
5.1 现象:loss降了,端到端延迟曲线不动
这是卸载任务里最常见的“假收敛”:Q 值的 MSE loss 在平滑下降,但把训练好的策略拉出来跑延迟,发现和随机策略差不了多少。原因基本都在奖励量纲上。delay 如果用秒做单位,数值落在 0.05 到 0.5 之间,reward=-delay的梯度非常弱,网络虽然在学习但改变的只是 Q 值的小数位,动作决策几乎没有变化。解决方式是放大奖励尺度,我代码里reward = -delay * 10,如果任务延迟更小,直接乘以 100 或者用毫秒做单位都行。验证方法是打印动作分布:如果训练结束后动作分布接近均匀,说明 Q 值差异没建立起决策信号,优先查量纲。
5.2 现象:换一个任务分布就失效
训练时任务大小固定采样自U(2e5, 5e5),测试时改成U(1e5, 8e5),策略性能立刻崩盘。这其实是状态归一化上界设死了导致输入分布漂移:测试任务 8e5 除以 5e5 得到 1.6,超出训练时的0.4~1.0范围,网络在训练分布之外没有任何区分能力。解决有两条路:一是训练时就放大采样范围并给任务大小乘一个0.8~1.2的扰动,让网络见过边界附近的状态;二是把归一化分母改成一个保守的更大值,比如1e6,让特征尽量不越界。不要在测试时修归一化,那等于重训一个新环境。
5.3 现象:动作塌缩,全部选择同一个卸载比例
训练两百个 episode 后,动作分布快速收敛到某一个档位(比如永远选 0.6),换初始种子也一样。这个问题多半是 epsilon 衰减太快的锅,策略还没把动作空间探索完就被锁死在了早期遇到的局部最优。解决方法是把 epsilon 衰减段拉长,前面 20% 的 episode 保持全随机,中间 60% 线性衰减,最后保留不低于 0.05 的探索率。另外可以检查网络的最后一层初始化:如果输出层 bias 全为 0,初始化阶段某个动作的 Q 值会因为随机权重偶然而恒定偏高,带动整条训练轨迹偏过去。给输出层用小方差初始化,能减少这种“冷启动偏好”。
5.4 现象:队列计算与决策时隙不一致,收敛极慢
这里的典型错误是状态里的队列积压是上一时隙的量,而延迟计算里用的是本时隙已经更新过的队列值,两个量差了一个dt。后果就是网络输入与奖励之间多了一层时序错位,收敛速度掉一半以上。要统一这个口径,我习惯让step()里的延迟计算和队列更新使用同一时刻的q_local、q_edge,然后才生成下一时隙的任务并返回next_state。也就是“先结算当前时隙的延迟,再更新队列,最后生成新任务”。代码 4.1 里的顺序就是按这个原则写的,你改代码时务必保持这个先后关系,这是这个仿真题里最隐蔽的时序坑。
5.5 现象:目标网络更新不当,Q值发散
把目标网络每步都同步,或者整个训练过程不更新,都会让 Q 值偏离真实累积奖励。前者的典型表现是训练中期 loss 突然跳到几千,Q 值绝对值一路涨到 1e4;后者则是 loss 降不下去,策略长期不变化。解决方法是固定同步节奏:硬更新每 200 episode 一次,或者采用软更新,每步执行target = tau * q + (1 - tau) * target,tau取 0.005。我更推荐软更新配合大回放缓冲区,它对卸载这种队列动态缓慢的场景更平滑。训练中每隔 50 个 episode 打印一次 Q 值的均值和最大值,一旦超过训练初期峰值的两倍,就降低学习率并检查同步间隔。
6. 验证方法:用三条基线和参数敏感性证明策略有效
6.1 画延迟曲线的正确姿势
训练结束后不要只打印 reward 曲线,那玩意对非 DRL 背景的同事没有说服力。我通常固定一个随机种子,让测试环境生成同一批任务序列,然后把训练好的策略放上去,和三条基线对比:全本地卸载(alpha=1)、全边缘卸载(alpha=0)、固定 50/50 比例。注意结果要算移动平均,窗口取 50 个时隙,否则单步延迟曲线抖动太大,看不出趋势。判断标准很简单:DRL 策略的延迟曲线应该在前 20% 时隙和基线重叠,随后稳步低于全部三条基线。
6.2 值得投入的进阶方向
如果基础 DQN 已经跑通,我建议按顺序尝试两个改动。第一个是把网络替换成 Dueling DQN,把 Q 值拆成状态价值 V 和动作优势 A,卸载问题里不同动作的差异主要体现在优势部分,这种结构能让训练更稳。第二个是把奖励扩展成-delay - λ * energy,λ 从 0.1 开始搜,观察延迟和能耗的帕累托曲线。如果实验对卸载比例精度要求高,可以试 P-DQN 或 SAC 这类连续动作算法,但先别急着换,离散 11 档在大多数边缘计算仿真里已经够用。
我自己的一个习惯是每次实验先跑一个随机策略基线,再跑最短时延贪心基线,DRL 策略不如这两者就不上生产。早期我在奖励量纲上翻过最狠的车——秒和毫秒混着用,曲线怎么调都不收敛,后来全环境统一成毫秒才正常。从那以后我对自己定了一条规矩:新环境先打印一组随机策略的奖励分布,再决定奖励系数,而不是凭感觉拍脑袋。希望这个习惯对你有用,也帮你在做这个方向时少走几步弯路。
本文还有配套的精品资源,点击获取