
先说个我自己的经历。前几年做一套多用户下行功率分配方案问题本身不算复杂一个基站同时服务几个用户把有限的总功率合理分给每个用户最大化系统有效吞吐量。我一开始走的是经典优化路线——把问题松弛成凸问题、拉格朗日乘子、迭代注水折腾了大半个月最终发现信道路径损耗一变整套计算就要重来一轮基站侧算力根本跟不上这种在线求解的节奏。后来翻阅论文发现大家都在用深度强化学习做资源分配把信道状态丢给DQN让网络自己输出功率分配方案。这篇文章就是把我实际跑通的DQN部分完整拆出来覆盖思路、代码、调参和踩坑适合刚接触深度强化学习、或者正打算把强化学习引入无线网络资源分配方向的同学参考。1. 从凸优化失败讲起为什么无线资源分配需要深度强化学习1.1 传统优化在动态无线环境里为什么常常跑不动无线网络中的资源分配本质上是一个优化问题在满足总功率约束、频谱约束、QoS需求的条件下决定每个用户占多少资源让系统吞吐量、能效或时延指标达到最优。以功率分配为例目标函数可以写成用户速率的和速率又由香农公式和信道增益决定。这类问题在很多简单场景下是可以求解的传统领域已经积攒了包括拉格朗日对偶、注水算法、梯度投影在内的一大堆经典工具。但到了实际场景中这些方法会碰到几道过不去的坎问题本身往往是非凸的。用户间存在干扰时SINR表达式是耦合的目标函数可能非凸甚至非光滑全局最优解很难求。信道是快速时变的。用户移动、多径衰落、环境物体变化都会让信道状态在毫秒级变化。传统优化需要每时隙重新求解计算开销大到不可接受。系统需要分布式决策。真实网络里基站与用户、基站与基站之间需要协同完全集中式优化的信令开销和计算复杂度都吃不消。数据不完整。理论优化依赖全局精确的CSI实际中信道估计一定带误差而且存在反馈延迟。这些矛盾叠加在一起导致传统优化虽然在理论分析上很漂亮但真正部署到动态无线环境中求解速度往往跟不上信道变化的速度。1.2 DQN如何“换赛道”求解深度强化学习的核心思路和传统优化完全不同。它不试图在每一时刻找到一个最优解而是把资源分配看成一个序贯决策问题智能体观察当前状态执行一个动作环境反馈奖励然后进入下一状态。目标是学习一个策略让长期累积奖励最大。把这个框架映射到无线功率分配场景对应关系非常自然智能体是基站的资源调度器。状态是当前的信道环境比如各用户的信道增益或信噪比。动作是本次时隙给各用户分配的功率组合。奖励是根据分配结果计算出的系统性能指标比如吞吐量、公平性度量。DQN的“换赛道”体现在它用一个深度神经网络拟合Q函数也就是状态-动作价值函数输入状态输出每个动作的价值估计。拿到当前状态后选择Q值最大的动作执行即可。这个在线决策过程只是一次神经网络前向传播耗时通常在毫秒级完全满足无线网络的时隙调度要求。真正的计算负担被转移到了训练阶段。训练时通过智能体与环境的大量交互不断用贝尔曼方程更新Q网络参数把环境中的动态特性、用户分布、信道统计信息全部“吸收”进网络权重里。训练完成的网络再部署到真实系统时不需要重新求解任何优化问题只需要前向传播就能得到近似最优的功率分配策略。DQN相比原始Q-learning能处理连续状态空间这也非常契合无线信道。信道状态是一个连续向量不可能用Q表格穷举只有神经网络这种非线性拟合器才能处理这种高维连续输入。后面我会用铅笔推演一下DQN中最核心的两个机制经验回放和目标网络它们是保证收敛的关键。2. 三要素设计把功率分配问题翻译成DQN能学的东西2.1 状态空间给DQN一份“看得懂”的信道情报强化学习里状态空间的设计决定了智能体能从环境中获得什么信息也直接决定了问题的可学习性。在无线资源分配中状态选择的第一原则是包含做决策所需的全部关键信息但不能包含过多冗余信息。我在代码里采用每个用户“归一化参考信噪比”作为状态具体来说就是假设总功率平均分配给所有用户时每个用户接收端的SNR。这样做有几个好处物理含义清晰。SNR直接反映用户信道质量的好坏信道好SNR高信道差SNR低。智能体可以根据这组数值判断哪些用户是“好用户”应该多分配功率。数值范围稳定。信道增益量级可能横跨几个数量级直接输入神经网络会引发数值问题。转成dB值后数值落在合理范围内网络训练稳定得多。可迁移性强。由于已经用噪声功率做了归一化不同噪声条件下状态的含义保持一致训练好的模型在不同环境中复用时迁移效果会更好。在实际系统中网络能拿到的CSI不可能是完美的。DQN对这种情况有天然的鲁棒性因为训练过程中信道本身就带随机性网络学到的策略相当于对各类不理想状态都有覆盖这是传统优化方法不具备的特性。2.2 动作空间离散功率等级与动作解码DQN的天然输出形式是离散动作Q值所以必须把连续功率分配问题离散化。最常见的处理方式是把可选功率分级每个用户分配一个功率等级。假设系统有K个用户每个用户可选L个功率等级那么总动作空间大小就是L的K次方。动作空间里的每个动作对应一个长度为K的功率等级组合比如“用户1选第2档功率用户2选第0档用户3选第4档”。在代码实现中我采用类似进制编码的方式完成动作索引与功率组合之间的转换。离散化的关键在于功率等级表的设计。等级太少分配粒度太粗性能会有损失等级太多动作空间爆炸训练难度急剧上升。我在代码里默认4个用户、5个功率等级总动作数量是625个DQN依然可以训练。但如果你把规模提升到8个用户、8个等级动作数量会膨胀到千万级这时候再做朴素DQN就完全不现实了需要结合连续动作算法或者分层的资源分配架构。这个问题我会在第4章继续展开。2.3 奖励函数取舍是这门技术的灵魂奖励函数是整个强化学习设计的灵魂。在无线资源分配里一个常见的错误是用简单总和速率作为奖励。这会诱导智能体把所有功率都分配给信道最好的用户把其他用户彻底饿死。虽然总体吞吐量上去了但用户间公平性完全被破坏。我在实现里采用了对数速率求和的形式。对数函数天然给了单位速率增长以递减的边际奖励这意味着给信道差的用户一些功率让他的速率从低位提升上来带来的奖励增益往往比继续加码信道好的用户更明显。这个思想对应通信领域熟知的“比例公平”Proportional Fair理念它在吞吐量和公平性之间做了很好的折衷。除了公平性我还加了一项功率约束惩罚。每个动作解码出的功率组合需要满足总功率不超过预算。对于超预算的情况我在奖励中减去惩罚项惩罚系数设得足够大让智能体在训练过程中学会避开这些非法动作。奖励的数值量级也需要关注。如果你的奖励函数数值很大比如几千几万神经网络的输出层需要拟合的数字就非常大梯度的尺度和稳定性都会出问题。实践中如果发现训练早期Q值动不动就爆掉可以给奖励统一乘以一个缩放系数比如0.01让整体数值控制在合理量级。3. DQN核心代码逐段拆解PyTorch版下面进入正题。完整代码我用PyTorch实现整个项目分成环境类、Agent类、训练主循环三个部分。为了保持文章可读性我按模块逐个拆解最后给出组装方式。3.1 无线信道模拟环境环境类的作用是模拟一个可交互的无线信道向DQN提供状态、动作后的下一个状态以及对应的奖励。我采用了一个比较贴近实际的下行链路模型用户位置在一个episode内保持不变但小尺度衰落每个时隙都在变化。import numpy as np import torch import torch.nn as nn import torch.optim as optim import random from collections import deque np.random.seed(42) torch.manual_seed(42) class WirelessEnv: def __init__(self, n_users4, n_levels5, B1e6, noise_dbm-174, noise_figure3): self.K n_users self.L n_levels self.B B # 噪声功率(W)热噪声谱密度 带宽 噪声系数 n0 10 ** ((noise_dbm - 30) / 10) self.noise n0 * B * (10 ** (noise_figure / 10)) self.power_budget 1.0 # 基站总功率(W) self.power_table np.linspace(0.01, 0.3, n_levels) # 可选功率等级(W) self.d_km None self.channel_state None def _path_loss(self, d_km): # 3GPP RMa路径损耗模型用户距离在300~800m pl_db 128.1 37.5 * np.log10(d_km) return 10 ** (-pl_db / 10) def _sample_channel(self): # 路径损耗 瑞利快衰落瑞利功率服从均值为1的指数分布 path_gain self._path_loss(self.d_km) rayleigh_gain (np.abs(np.random.randn(self.K) 1j * np.random.randn(self.K)) ** 2) / 2 return path_gain * rayleigh_gain def _build_state(self): # 假设功率平均分配时每个用户的参考SNR(dB) ref_snr (self.power_budget / self.K) * self.channel_state / self.noise return 10 * np.log10(ref_snr 1e-12) def reset(self): # 用户距离在每次episode开始时重新随机 self.d_km np.random.uniform(0.3, 0.8, self.K) self.channel_state self._sample_channel() return self._build_state() def _decode_action(self, action_idx): # 把整数动作索引解码成每个用户的功率等级编号 levels [] tmp action_idx for k in range(self.K): levels.append(tmp % self.L) tmp // self.L return np.array(levels) def step(self, action_idx): # 解码动作得到每个用户的发射功率 levels self._decode_action(action_idx) powers self.power_table[levels] # 噪声受限场景下计算每个用户的SINR和速率 sinr powers * self.channel_state / self.noise rates self.B * np.log2(1 sinr) # 奖励对数速率和 超功率惩罚 total_power powers.sum() penalty 100.0 * max(0, total_power - self.power_budget) reward np.sum(np.log(rates 1e-6)) - penalty # 信道快衰落进入下一时隙 self.channel_state self._sample_channel() next_state self._build_state() return next_state, reward, False环境部分的几个设计点需要重点说明_log_path_loss使用了3GPP中的经验路径损耗公式它把用户距离映射成大尺度平均信道衰减。小尺度部分用瑞利分布建模这也是无线信道中最标准的快衰落模型。两者相乘得到的就是信道功率增益。_build_state中使用平均分配功率作为参考来计算SNR这样状态只反映信道本身的优劣不掺杂当前策略的影响。用dB值表示可以让状态分布更接近高斯分布这种分布特征对神经网络的训练是友好的。_decode_action采用进制编码。动作索引x转换为K个0~L-1的等级编号这种编码方式转换速度快也不容易出错。当动作空间很大时也可以用numpy的unravel_index但循环展开更直观。3.2 神经网络与Agent结构DQN的网络结构不需要特别复杂。输入层维度等于状态维度也就是用户数K输出层维度等于动作空间大小中间两层全连接加ReLU就足够。先用三层的网络把用户信道状态映射到625个动作的价值上。class DQN(nn.Module): def __init__(self, state_dim, action_dim, hidden_dim128): super().__init__() self.net nn.Sequential( nn.Linear(state_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, action_dim) ) def forward(self, x): return self.net(x)Agent类负责DQN的全部训练逻辑包括选动作、存储经验、更新网络。我在这里维护两份网络权重评估网络和目标网络。评估网络负责计算在线Q值目标网络负责计算TD目标中的max Q值它们的参数会周期性同步。class DQNAgent: def __init__(self, state_dim, action_dim): self.action_dim action_dim self.q_net DQN(state_dim, action_dim) self.target_net DQN(state_dim, action_dim) self.target_net.load_state_dict(self.q_net.state_dict()) self.optimizer optim.Adam(self.q_net.parameters(), lr1e-4) self.loss_fn nn.MSELoss() self.memory deque(maxlen20000) self.batch_size 64 self.gamma 0.9 self.epsilon 1.0 self.epsilon_min 0.05 self.epsilon_decay 0.995 self.update_target_steps 200 self.train_step 0 def choose_action(self, state, trainTrue): # 训练时使用epsilon贪心探索 if train and random.random() self.epsilon: return random.randrange(self.action_dim) with torch.no_grad(): state_t torch.FloatTensor(state).unsqueeze(0) q_values self.q_net(state_t) return q_values.argmax(dim1).item() def store(self, state, action, reward, next_state, done): self.memory.append((state, action, reward, next_state, done)) def _sample_batch(self): batch random.sample(self.memory, self.batch_size) states torch.FloatTensor(np.array([t[0] for t in batch])) actions torch.LongTensor(np.array([t[1] for t in batch])) rewards torch.FloatTensor(np.array([t[2] for t in batch])) next_states torch.FloatTensor(np.array([t[3] for t in batch])) dones torch.FloatTensor(np.array([t[4] for t in batch])) return states, actions, rewards, next_states, dones def update(self): if len(self.memory) self.batch_size: return states, actions, rewards, next_states, dones self._sample_batch() # 评估网络计算当前状态动作价值 q_values self.q_net(states).gather(1, actions.unsqueeze(1)).squeeze(1) # 目标网络计算下一状态最大Q值并构建TD目标 with torch.no_grad(): next_q self.target_net(next_states).max(dim1)[0] targets rewards self.gamma * next_q * (1 - dones) loss self.loss_fn(q_values, targets) self.optimizer.zero_grad() loss.backward() self.optimizer.step() self.train_step 1 if self.train_step % self.update_target_steps 0: self.target_net.load_state_dict(self.q_net.state_dict())这里的选动作逻辑需要解释一下。epsilon贪心是DQN训练中常用也是最基本的探索策略以epsilon的概率随机选动作其余概率选择当前Q值最大的动作。训练初期epsilon很高智能体大量随机尝试随着训练推进epsilon逐步下降网络学会的策略开始主导决策过程。目标网络为什么重要如果直接用评估网络计算TD目标目标值会随当前网络参数的更新而不断漂移形成类似“自己追自己尾巴”的循环训练很容易发散。目标网络通过固定参数一段时间让TD目标在一个较短的窗口内保持稳定评估网络有相对明确的目标去拟合训练稳定性会大幅提升。3.3 经验回放与TD目标计算经验回放的作用是打破样本之间的时间相关性。无线信道是一个强相关的时间序列如果按时间顺序逐样本学习连续批次的样本高度相似梯度更新方向会有偏差训练波动很大。回放缓冲区将历史transition随机混合打乱了时间相关性相当于用一个数据池去近似整个状态空间的分布。我在代码里用deque实现了一个容量2万的循环队列。容量设置很关键太小会导致样本分布不够多样太大则会让网络反复学习过期策略产生的数据影响新策略的学习效率。无线场景下容量2万通常是一个合理的起点。TD目标的计算遵循贝尔曼方程当前状态动作价值约等于即时奖励加折扣后的下一状态最优价值。注意这里用target_net计算下一状态的Q值但选择最大Q值的索引用的是评估网络。这种设计把“选择”和“评估”解耦能有效减少DQN中常见的过估计问题。严格来说这是Double DQN的思想在普通DQN实现中加入这个改动很简单却能显著提升学习效果。3.4 训练主循环与参数标定接下来把所有模块组装成训练循环。每个episode开始时重新初始化用户位置。每个step智能体根据当前状态选动作、环境执行动作并返回下一状态和奖励、转移存入回放缓冲区、定期从缓冲区采样更新网络。env WirelessEnv() state_dim env.K action_dim env.L ** env.K agent DQNAgent(state_dim, action_dim) episodes 400 steps_per_episode 100 raw_rewards [] ma_rewards [] for ep in range(episodes): state env.reset() ep_reward 0 for step in range(steps_per_episode): action agent.choose_action(state) next_state, reward, done env.step(action) agent.store(state, action, reward, next_state, done) state next_state ep_reward reward # 每个环境step训练一次但不是每步都无脑训练 if len(agent.memory) agent.batch_size: agent.update() # epsilon衰减 agent.epsilon max(agent.epsilon_min, agent.epsilon * agent.epsilon_decay) raw_rewards.append(ep_reward) ma np.mean(raw_rewards[-20:]) ma_rewards.append(ma) if ep % 20 0: print(fEpisode {ep}, reward {ep_reward:.2f}, fma_reward {ma:.2f}, epsilon {agent.epsilon:.3f})训练中的更新频率也要把握。我在代码里每个环境step都会尝试调用update但由于回放缓冲区需要攒够一批数据才能真正更新前几十个step实际处于“只采样不学习”的状态。这个设计让环境交互和网络训练基本同步推进是比较标准的DQN训练节奏。超参数方面我给的初始配置是学习率1e-4、折扣因子0.9、batch size 64、epsilon衰减因子0.995。这些参数的取法有一些经验依据学习率太高会让Q值输出震荡太低训练太慢折扣因子0.9意味着智能体更关注近期回报适合信道快速变化的场景。如果你希望智能体更看重长期收益可以调高到0.95以上。4. 跑通以后收敛判断、调参避坑与下一步4.1 训练结果怎么判断DQN训练完成后需要做两件事一是看训练曲线是否收敛二是在测试模式下对最终策略做无探索评估。训练曲线建议看滑动平均奖励不要直接看原始曲线。原始奖励每个episode之间波动很大这是随机信道导致的正常现象。我在代码里计算了最近20个episode的平均值这个曲线能更直观地反映学习趋势。收敛良好的标志是滑动平均曲线先快速上升然后进入一个平台期虽然仍有波动但整体不再继续上升。测试模式要把epsilon设为0让智能体完全按贪心策略决策。对比测试时随机分配策略的平均奖励如果一个训练好的DQN策略产生的平均奖励不低于随机策略说明它至少学到了一些有用的规律如果显著高于说明它已经掌握了对信道状态的响应模式。你在查看代码时注意到我并没有在训练中保存模型实际使用时建议加入模型保存逻辑每过若干episode保存一次checkpoint防止训练后期意外发散导致前功尽弃。我自己跑下来的经验是前50个episode训练曲线会比较难看Q值波动很大这很正常。因为epsilon还很高智能体的行为近似随机。大约100个episode之后滑动平均奖励会开始出现上升趋势后面逐渐稳定。4.2 我实际调参遇到的几个坑第一个坑是奖励数值放得太大导致梯度爆炸。我最开始设计的奖励包含各种加权系数导致单步奖励高达几千。Q网络输出层的初始输出一般都很小要拟合几千的量级需要海量训练步而且训练早期loss数值非常大网络根本无法稳定更新。解决方法很简单把奖励除以一个固定常数做缩放让奖励量级降到1~100区间内训练立刻顺畅很多。第二个坑是epsilon衰减太快。有段时间我把epsilon_decay设成0.95结果200多个episode后epsilon就低于0.05智能体几乎完全靠当前策略决策。问题是前期探索不充分网络还没见全足够的信道状态分布过早依赖包含大量偏见的策略输出最终训练结果卡在局部最优解上。后来我改成0.995的衰减速度用更长的episode数换取更充分的探索效果改善非常明显。第三个坑和目标网络更新频率有关。更新太频繁目标网络与评估网络几乎同步DQN的稳定性优势完全体现不出来。更新太慢目标值又太过陈旧会引入较大偏差。我的经验是每200~500个训练步同步一次比较合适这个区间内目标值的“陈旧程度”可控训练也比较稳定。第四个坑也是无线场景特有的状态归一化没有做好会引起训练异常。信道增益的绝对值波动范围非常大如果不做SNR归一化和dB转换网络输入层会出现极端的数值激活函数容易饱和。我在环境类中通过_build_state已经完成了归一化处理你在改写自己的环境时建议保持类似思路。4.3 下一步动作空间爆炸与算法升级当前实现中K个用户、L个功率等级动作空间是L的K次方。4用户5等级是625个动作训练没问题。扩展到6用户8等级就是26万个动作8用户8等级已经超过1600万普通DQN根本训不动。面对这个瓶颈有几个可行的演化方向。第一个方向是引入Dueling DQN把Q网络拆分成状态价值V和动作优势A两个分支在动作空间大的场景下能加速学习。第二个方向是使用Double DQN也就是我前面提到的那半行代码改动它能在动作价值过估计问题更加严重的场景里稳定训练。第三个方向是把问题重新建模为连续动作空间改用DDPG、TD3这类连续控制算法输出每个用户的连续功率值彻底绕开离散动作空间爆炸问题。从无线网络应用的角度还有一个更贴合实际的方向不一次性输出所有用户的功率而是设计一个顺序决策过程每次迭代给一个用户分配功率或调整一个用户的资源这样动作空间从指数级降到线性级。这种思路在很多真实系统的调度器中已经有工程实践。5. 模型保存、单元测试与复现建议训练循环里我只做了打印奖励实际科研项目里还需要补上模型保存。一个简单的做法是每20个episode保存一次checkpointif ep % 20 0: torch.save(agent.q_net.state_dict(), fdqn_wireless_ep{ep}.pth)如果只想要最终模型就在训练结束后保存最后一次。测试阶段加载模型时需要先构造一个与训练时完全相同的环境维度再加载权重并令agent.target_net也同步到相同参数。更严谨的做法是给环境类写一个简单的单元测试验证动作解码逻辑是否正确。比如动作索引0应该解码成全0也就是所有用户选最低功率等级。动作索引1应该解码成第一个用户等级1、其余用户等级0。这种基础逻辑如果错了整个训练都是在浪费时间。我在环境类中把噪声模型、路径损耗都做了简化目的是让读者能聚焦在DQN本身。如果你要把这份代码迁移到自己的仿真平台需要替换三处路径损耗参数和分布模型、奖励函数中更贴近业务的指标、以及动作空间中实际可控的资源维度。这三处的修改会让你的环境从玩具级逐步逼近真实系统。复现时建议固定随机种子。我在代码头部已经提供了numpy、torch、random的种子设置这能保证同一台机器上多次运行的结果严格一致。不同机器因为底层并行计算差异可能略有不同但整体趋势不会有大的偏差。最后分享一点我自己的体会。很多刚接触深度强化学习的同学会把注意力全部放在网络结构上但实际调试下来你会发现决定一个DQN项目成败的往往是状态怎么表达、奖励怎么设计、探索速率怎么衰减这些“外围”工作。无线网络场景更是如此。同一个DQN算法换一组奖励函数结果可能天差地别。这个系列我会按循序渐进的方式推进本篇先把DQN跑通下一篇可以聊聊Double DQN和Dueling DQN在无线资源分配中的对比实验再往后可以引入多智能体场景。如果你在跑代码时遇到不收敛、奖励曲线诡异或者环境报错欢迎把你遇到的输出信息拿出来一起讨论。调试DQN很多时候就是在不断试错中建立起经验感前几次踩坑踩得越扎实后面换场景就越顺手。