尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

强化学习实战笔记:从Q-learning到策略梯度的代码实现与调参指南

强化学习实战笔记:从Q-learning到策略梯度的代码实现与调参指南 1. 从“算法笔记”到能跑通的代码我为什么重新整理强化学习强化学习这个词这几年被提得太多了。招聘网站上算法工程师的岗位描述里十个有八个写着“熟悉强化学习优先”各种技术社区里Q-learning、REINFORCE、PPO 的讨论帖层出不穷。但真正上手写过的人都知道从看懂公式到跑通一个能收敛的智能体中间隔着的不是一层窗户纸而是一整个太平洋。我自己是从传统机器学习转过来的最开始看 Sutton 那本《强化学习导论》前几章还能跟得上到了时序差分和策略梯度就开始卡壳。公式推导看着都懂但一到自己写代码就发现到处都是坑奖励怎么设计、状态怎么离散化、探索率怎么衰减、回合怎么终止这些细节书里不会手把手教你但恰恰是决定算法能不能跑通的关键。这份笔记的起因很简单我在实际项目里需要用一个智能体去优化一套资源调度策略试了 Q-learning 和 REINFORCE 两种方案中间踩了无数坑最后整理出了一套自己觉得还算清晰的实现框架。我把这些经验写下来不是要写一本教科书而是想给那些和我一样“看公式能懂、写代码就懵”的朋友一条能走通的路。这篇笔记会覆盖从 Q-learning 到策略梯度的核心算法重点放在为什么这样设计和实际写代码时要注意什么上。我不会堆砌数学公式但关键推导会给出来源和直觉解释我不会只给伪代码而是会给出可以直接跑的 Python 实现并解释每一行为什么这么写。如果你正在入门强化学习或者已经看过一些资料但始终觉得隔了一层这篇笔记应该能帮你把那些散落的知识点串起来。2. Q-learning从查表法到函数逼近的完整实现路径2.1 为什么 Q-learning 是入门首选以及它的适用边界Q-learning 几乎是所有人接触强化学习的第一个算法。它的核心思想非常直观维护一张表记录在某个状态下采取某个动作的“价值”然后根据贝尔曼方程不断更新这张表。我第一次实现 Q-learning 是在一个简单的格子世界环境里智能体要从起点走到终点撞墙有惩罚到达终点有奖励。代码不到一百行就跑通了那种“它真的在学”的感觉非常上头。但 Q-learning 的局限也很明显。它本质上是一种离线策略off-policy的时序差分方法更新公式是Q(s, a) ← Q(s, a) α [r γ max Q(s, a) - Q(s, a)]这个公式里max Q(s, a)用的是下一个状态的最优动作价值而不是实际采取的动作。这意味着 Q-learning 可以在探索的同时学习最优策略这是它的优势。但问题在于当状态空间连续或者维度很高时维护一张 Q 表就不现实了。比如你用图像作为状态输入状态空间是无限的查表法直接失效。所以 Q-learning 的适用边界很清晰状态和动作都是离散且有限的场景。格子世界、简单的棋类游戏、资源调度的离散决策点这些场景下 Q-learning 简单有效。一旦状态变成连续值或者动作空间很大就需要转向函数逼近的方法比如 DQN 或者策略梯度。我在实际项目里遇到的一个典型问题是状态里有连续的温度值和压力值直接离散化会导致状态爆炸。我的处理方式是先做分桶离散化把连续值映射到有限的区间然后再用 Q-learning。这样做虽然损失了一些精度但在早期验证阶段足够用了。如果你也遇到类似情况建议先用粗粒度的离散化跑通流程再考虑上函数逼近。2.2 探索率衰减一个被低估的关键参数Q-learning 的更新公式里有一个隐含的前提智能体需要尝试各种动作才能发现哪些动作价值高。这就是探索与利用的权衡。最常用的方法是 ε-greedy以 ε 的概率随机选动作以 1-ε 的概率选当前认为最优的动作。很多人实现的时候会把 ε 设成一个固定值比如 0.1。这样做不是不行但效果往往不好。原因很简单训练初期Q 表全是初始值智能体需要大量探索来填充这张表训练后期Q 表已经比较准确了这时候还保持高探索率反而会干扰收敛。我的做法是让 ε 从 1.0 线性衰减到 0.01衰减的步数根据总训练步数来定。具体来说如果计划训练 10000 个回合每个回合平均 200 步总步数大约 200 万步那么可以让 ε 在前 50 万步内从 1.0 降到 0.01。这个比例不是固定的需要根据环境的复杂度和奖励的稀疏程度调整。注意ε 衰减太快会导致探索不足智能体可能陷入局部最优衰减太慢则收敛速度慢训练时间拉长。我通常会在小规模实验里先跑几组不同衰减速度的对比选一个收敛曲线最平滑的。还有一个细节ε 的衰减应该基于步数而不是回合数。因为不同回合的长度可能差异很大基于回合数衰减会导致探索率下降不均匀。这个坑我在早期实现时踩过后来改成基于步数衰减训练稳定性明显提升。2.3 奖励设计稀疏奖励下的生存指南奖励函数的设计是强化学习里最玄学的部分。理论上奖励应该反映任务目标但实际操作中奖励太稀疏会导致智能体学不到东西奖励太密集又可能引导智能体走捷径。我在格子世界项目里遇到过一个经典问题智能体在到达终点前一直收到 0 奖励只有到达终点才有 1 奖励。结果智能体在前几千个回合里完全随机游走几乎学不到任何有效策略。这就是稀疏奖励问题。解决稀疏奖励有几种常见思路。第一种是奖励塑形reward shaping在中间状态给出一些引导性奖励。比如在格子世界里可以根据智能体到终点的曼哈顿距离给出小的负奖励距离越近负奖励越小。这样智能体就有了持续的信号能更快找到终点。但奖励塑形有个风险如果塑形奖励设计不当智能体可能学会“刷奖励”而不是真正完成任务。比如它可能学会在某个能持续获得小奖励的区域来回移动。第二种思路是课程学习curriculum learning先从简单的任务开始逐步增加难度。比如先让智能体在无障碍的格子里走到终点再逐步加入障碍物。这样智能体在早期就能获得成功经验Q 表能更快地积累有效信息。第三种思路是事后经验回放HER这个在机器人控制里用得比较多。简单说就是把失败的经验重新标记为成功让智能体从失败中也能学到东西。这个思路在稀疏奖励场景下非常有效但实现起来相对复杂适合有一定基础之后再尝试。我个人的经验是先用奖励塑形快速验证算法流程再逐步减少塑形奖励让智能体最终依赖原始奖励收敛。这样既能加快早期学习又能避免智能体过度依赖塑形信号。2.4 从零实现一个可运行的 Q-learning 智能体下面是我在实际项目中反复使用的一个 Q-learning 实现框架。这个框架结构清晰容易扩展适合作为入门和快速验证的起点。import numpy as np import random class QLearningAgent: def __init__(self, state_size, action_size, learning_rate0.1, discount_factor0.95, epsilon1.0, epsilon_min0.01, epsilon_decay0.995): self.state_size state_size self.action_size action_size self.lr learning_rate self.gamma discount_factor self.epsilon epsilon self.epsilon_min epsilon_min self.epsilon_decay epsilon_decay self.q_table np.zeros((state_size, action_size)) def choose_action(self, state): if random.random() self.epsilon: return random.randint(0, self.action_size - 1) return np.argmax(self.q_table[state]) def update(self, state, action, reward, next_state, done): current_q self.q_table[state, action] if done: target_q reward else: target_q reward self.gamma * np.max(self.q_table[next_state]) self.q_table[state, action] self.lr * (target_q - current_q) def decay_epsilon(self): self.epsilon max(self.epsilon_min, self.epsilon * self.epsilon_decay)这个实现里有几个关键点值得展开说。第一done标志的处理非常重要。如果回合结束target_q就是即时奖励不再加后续状态的价值。这个细节如果漏掉智能体会在终止状态附近学到错误的价值估计。第二epsilon_decay我用了乘法衰减而不是线性衰减。乘法衰减在早期下降快后期下降慢实际用下来比线性衰减更稳定。当然这不是绝对的你可以根据具体任务调整。第三学习率lr我设的是 0.1这个值在大多数离散任务里都能工作。如果环境噪声大可以降到 0.05 或更低如果环境确定性高可以适当提高。训练循环里我通常会在每个回合结束后调用decay_epsilon()但前面说过更好的做法是基于步数衰减。你可以把decay_epsilon改成每步调用然后把epsilon_decay设得更接近 1比如 0.9999这样衰减曲线会更平滑。3. REINFORCE策略梯度为什么比 Q-learning 更难调3.1 从值函数到策略思维方式的转变Q-learning 学的是值函数然后根据值函数导出策略。REINFORCE 直接学策略跳过了值函数这一步。这个转变听起来简单但思维方式完全不同。在 Q-learning 里你关心的是“这个状态动作对值多少分”。在 REINFORCE 里你关心的是“在这个状态下我应该选哪个动作的概率更大”。前者是评估问题后者是优化问题。REINFORCE 的核心是策略梯度定理它告诉我们策略参数的更新方向应该使得高回报的动作概率增加低回报的动作概率降低。具体来说更新公式是∇θ J(θ) ≈ E[∇θ log πθ(a|s) * G]其中 G 是从当前时刻开始的累积回报。这个公式的直觉解释是如果某个动作带来的回报高就增加它被选中的概率如果回报低就降低它的概率。我第一次实现 REINFORCE 的时候最大的困惑是为什么回报要乘在 log 概率的梯度上后来想明白了log 概率的梯度指向“增加这个动作概率”的方向乘以回报 G 之后高回报的动作梯度被放大低回报的动作梯度被缩小甚至反向。这样参数更新就会倾向于增加高回报动作的概率。但这个公式有一个致命问题方差太大。因为 G 是一个随机变量不同回合的回报波动很大导致梯度估计的方差很高。这就是为什么 REINFORCE 通常需要很多回合才能收敛而且训练曲线非常抖动。3.2 基线技巧让 REINFORCE 从不可用到可用解决 REINFORCE 方差问题的标准方法是引入基线baseline。基线的基本思想是不从原始回报 G 中减去一个基准值 b(s)使得更新变成∇θ J(θ) ≈ E[∇θ log πθ(a|s) * (G - b(s))]理论上可以证明减去基线不会改变梯度的期望但会显著降低方差。最常用的基线是状态价值函数 V(s)它表示从状态 s 出发的平均回报。这样 (G - V(s)) 就变成了优势函数 A(s, a)表示某个动作比平均水平好多少。在实际实现中我们可以用一个简单的神经网络来估计 V(s)然后用它作为基线。这个网络和策略网络分开训练策略网络用策略梯度更新价值网络用均方误差更新。我试过不加基线的 REINFORCE在 CartPole 环境里跑了 2000 个回合都没能稳定收敛。加上基线之后大约 500 个回合就能达到不错的性能。这个对比非常直观地说明了基线的重要性。提示如果你刚开始实现 REINFORCE建议先用一个简单的滑动平均作为基线比如最近 100 个回合的平均回报。这样实现简单效果也不错。等跑通了再换成神经网络基线。3.3 策略网络的设计细节输出层为什么用 SoftmaxREINFORCE 的策略网络输出的是动作概率分布。对于离散动作空间输出层通常用 Softmax 激活函数把原始输出转换成概率。这个选择看起来理所当然但背后有一些细节值得注意。Softmax 的输出是所有动作概率之和为 1这符合概率分布的要求。但 Softmax 有一个问题当某个动作的 logit 远大于其他动作时它的概率会接近 1其他动作的概率接近 0。这会导致探索不足因为智能体几乎总是选同一个动作。为了解决这个问题我通常会在 Softmax 之前加一个温度参数 τπ(a|s) exp(z_a / τ) / Σ exp(z_i / τ)温度 τ 越大概率分布越均匀探索越充分τ 越小分布越尖锐利用越强。训练初期可以用较大的 τ后期逐渐减小。这个技巧在策略梯度方法里非常实用但很多教程不会提到。另一个细节是策略网络的输出层不要加偏置项。因为 Softmax 对输入的平移不变加偏置没有意义反而会增加参数。这个细节虽然小但在实现时容易忽略。3.4 完整 REINFORCE 实现与训练循环下面是一个带基线的 REINFORCE 实现。这个实现包含了策略网络、价值网络和完整的训练循环可以直接在 CartPole 或类似环境里运行。import torch import torch.nn as nn import torch.optim as optim import numpy as np class PolicyNetwork(nn.Module): def __init__(self, state_dim, action_dim, hidden_dim128): super().__init__() self.fc1 nn.Linear(state_dim, hidden_dim) self.fc2 nn.Linear(hidden_dim, action_dim) def forward(self, x): x torch.relu(self.fc1(x)) return torch.softmax(self.fc2(x), dim-1) class ValueNetwork(nn.Module): def __init__(self, state_dim, hidden_dim128): super().__init__() self.fc1 nn.Linear(state_dim, hidden_dim) self.fc2 nn.Linear(hidden_dim, 1) def forward(self, x): x torch.relu(self.fc1(x)) return self.fc2(x) class REINFORCEAgent: def __init__(self, state_dim, action_dim, lr_policy1e-3, lr_value1e-3, gamma0.99): self.policy PolicyNetwork(state_dim, action_dim) self.value ValueNetwork(state_dim) self.optimizer_policy optim.Adam(self.policy.parameters(), lrlr_policy) self.optimizer_value optim.Adam(self.value.parameters(), lrlr_value) self.gamma gamma def choose_action(self, state): state torch.FloatTensor(state).unsqueeze(0) probs self.policy(state) dist torch.distributions.Categorical(probs) action dist.sample() return action.item(), dist.log_prob(action) def update(self, log_probs, rewards, states): returns [] G 0 for r in reversed(rewards): G r self.gamma * G returns.insert(0, G) returns torch.FloatTensor(returns) states torch.FloatTensor(states) values self.value(states).squeeze() advantages returns - values.detach() policy_loss [] for log_prob, adv in zip(log_probs, advantages): policy_loss.append(-log_prob * adv) policy_loss torch.stack(policy_loss).sum() value_loss nn.MSELoss()(values, returns) self.optimizer_policy.zero_grad() policy_loss.backward() self.optimizer_policy.step() self.optimizer_value.zero_grad() value_loss.backward() self.optimizer_value.step()这个实现里advantages returns - values.detach()是关键。detach()确保价值网络的梯度不会传到策略网络两个网络独立更新。这个细节如果搞错训练会非常不稳定。另外policy_loss我用了-log_prob * adv然后求和。负号是因为 PyTorch 默认做梯度下降而我们要最大化策略梯度所以取负号变成最小化。这个符号问题在实现时容易搞混建议写完之后用小规模数据验证一下梯度方向。4. 训练不收敛时我是怎么一步步排查的4.1 先看奖励曲线区分“没学到”和“学歪了”训练不收敛是强化学习里最常见的问题。我的排查流程第一步永远是看奖励曲线。奖励曲线大致有三种典型形态对应不同的问题。第一种是奖励一直很低且不上升。这说明智能体根本没学到有效策略。可能的原因包括奖励太稀疏、探索不足、学习率太低、网络容量不够。我会先检查奖励设计看看是不是只有终止状态才有奖励。如果是就加奖励塑形或者用课程学习。第二种是奖励上升后突然崩溃。这通常是训练不稳定的表现。可能的原因包括学习率太高、批量大小太小、策略更新步长太大。我会先降低学习率然后检查有没有做梯度裁剪。策略梯度方法对学习率非常敏感我通常从 1e-4 开始试而不是 1e-3。第三种是奖励波动很大但整体趋势向上。这其实是正常的强化学习的训练曲线本来就有很大方差。只要趋势向上就不用太担心。可以通过平滑曲线或者增加评估回合数来更清楚地看趋势。我还会同时看回合长度和价值估计。如果回合长度在增加说明智能体在学会存活更久如果价值估计在发散说明价值网络的学习率太高或者奖励尺度太大。4.2 梯度爆炸与梯度裁剪一个必须加的保险策略梯度方法里梯度爆炸是一个高频问题。因为策略梯度的更新量是log_prob * advantage如果 advantage 很大梯度就会很大一次更新可能把策略网络推到一个完全错误的方向。解决方法很简单梯度裁剪。在反向传播之后、优化器更新之前把梯度的范数限制在一个阈值内。PyTorch 里一行代码就能搞定torch.nn.utils.clip_grad_norm_(self.policy.parameters(), max_norm0.5)max_norm我通常设 0.5 或 1.0。这个值太小会导致学习缓慢太大则起不到保护作用。我试过在 CartPole 上不裁剪梯度大约每十次训练就有一次会崩溃加上裁剪之后训练稳定性大幅提升。还有一个相关的技巧是奖励缩放。如果奖励的数值范围很大比如从 -1000 到 1000梯度也会很大。我通常会把奖励缩放到 [-1, 1] 或者标准化到均值为 0、方差为 1。这个操作在实现上很简单但对训练稳定性的影响非常大。4.3 探索与利用的平衡从 ε-greedy 到熵正则探索与利用的平衡是强化学习的核心难题。Q-learning 里用 ε-greedy策略梯度里则通常用熵正则。熵正则的思路是在策略损失里加一项熵的负值鼓励策略保持一定的随机性。具体来说loss policy_loss - β * entropy其中 β 是熵的权重。β 越大策略越随机β 越小策略越确定。训练初期可以用较大的 β 鼓励探索后期逐渐减小 β 让策略收敛。我在实现 REINFORCE 时一开始没有加熵正则结果策略很快变得确定性智能体总是选同一个动作探索完全停止。加上熵正则之后策略保持了足够的随机性最终性能明显更好。熵的计算很简单entropy -(probs * torch.log(probs 1e-8)).sum(dim-1).mean()注意加一个很小的常数1e-8防止 log(0)。这个细节虽然小但如果不加遇到概率为 0 的动作时会得到 NaN训练直接崩溃。4.4 环境随机性太大怎么办固定种子与多次评估有些环境的随机性很大比如 Atari 游戏有随机初始状态机器人控制有随机噪声。这种情况下单次训练的奖励曲线可能完全看不出趋势。我的做法是固定随机种子然后跑多次独立训练取平均曲线。固定种子可以消除环境随机性带来的方差让不同超参数之间的对比更公平。具体来说我会在训练开始前设置import random import numpy as np import torch def set_seed(seed): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed)然后在评估时我会用多个不同的种子跑评估回合取平均回报。这样得到的评估结果更可靠不会因为某一次幸运的回合而高估性能。还有一个经验是不要只看最终性能要看学习速度。有些超参数组合最终性能差不多但学习速度差异很大。在实际项目里学习速度往往比最终性能更重要因为训练时间是有成本的。5. 从笔记到落地我总结的几条实用原则5.1 先跑通再优化不要一上来就追求 SOTA我见过很多初学者一上来就想实现 PPO、SAC 这些先进算法结果卡在环境配置和调试上几周都跑不出结果。我的建议永远是先用最简单的算法跑通再逐步升级。Q-learning 和 REINFORCE 虽然简单但它们包含了强化学习的核心概念值函数、策略、探索、回报、梯度。把这些概念在简单算法里搞清楚了再去看 PPO 的裁剪目标函数、SAC 的最大熵框架就会觉得顺理成章。我在实际项目里的流程通常是先用 Q-learning 或 REINFORCE 在简化环境里验证问题建模是否正确奖励设计是否合理然后再换更复杂的算法。这样做的好处是如果出了问题你能确定是算法的问题还是建模的问题排查范围小很多。5.2 日志和可视化训练过程必须可观测强化学习的训练过程是一个黑盒如果不加日志和可视化出了问题根本不知道从哪里查。我通常会记录以下信息记录项用途频率回合奖励判断整体学习趋势每回合回合长度判断智能体是否学会存活每回合策略熵判断探索是否充足每 N 步价值估计均值判断价值网络是否发散每 N 步梯度范数判断是否梯度爆炸每 N 步动作分布判断策略是否退化每 N 回合这些指标里策略熵和梯度范数是最容易被忽略但最有诊断价值的。策略熵持续下降说明探索在减少如果下降太快智能体可能过早收敛到次优策略。梯度范数突然增大说明可能出现了梯度爆炸需要检查奖励尺度或加梯度裁剪。可视化方面我通常用 Matplotlib 画奖励曲线和回合长度曲线用 TensorBoard 记录更详细的指标。TensorBoard 的好处是可以实时看训练过程不用等训练结束再画图。5.3 超参数调优从粗调到精调强化学习的超参数很多全部调一遍不现实。我的策略是先粗调再精调。粗调阶段我会固定大部分参数只调学习率和折扣因子 γ。学习率通常从 {1e-2, 1e-3, 1e-4} 里选γ 从 {0.9, 0.95, 0.99} 里选。每个组合跑短时间训练看哪个组合的学习趋势最好。精调阶段我会固定学习率和 γ然后调网络结构、批量大小、熵正则权重这些。这个阶段需要更长的训练时间但搜索空间已经小很多了。还有一个经验是不要同时调多个参数。如果一次改多个参数即使性能提升了你也不知道是哪个参数起了作用。我通常一次只改一个参数记录变化然后再改下一个。这样做虽然慢但能积累对参数影响的理解长期来看效率更高。5.4 从离散到连续什么时候该换算法Q-learning 和 REINFORCE 主要针对离散动作空间。如果你的动作是连续的比如机器人的关节角度、无人机的推力这两个算法就不太适用了。连续动作空间的策略梯度方法最常用的是 DDPG、TD3、SAC。它们的核心思想是策略网络直接输出动作的均值和方差然后从高斯分布里采样动作。这样策略梯度就可以对连续动作求导。我切换到连续动作空间时第一个踩的坑是动作范围。策略网络输出的动作是实数但实际环境的动作有范围限制比如关节角度在 [-90°, 90°]。如果不做裁剪智能体会输出超出范围的动作环境可能直接报错或者给出异常奖励。解决方法是在策略网络的输出层加一个 tanh 激活把输出压缩到 [-1, 1]然后再缩放到实际范围。另一个坑是探索噪声。连续动作空间不能用 ε-greedy因为随机选一个连续值没有意义。通常的做法是在动作上加高斯噪声噪声的方差随着训练逐渐减小。这个噪声方差也是一个需要调的超参数太大导致训练不稳定太小导致探索不足。6. 写在最后一些零散但有用的经验强化学习这个领域理论很漂亮但落地很磨人。我在这份笔记里尽量把那些“书上不会写、但实际会用到”的细节写出来了。如果你正在入门我的建议是不要追求一次看懂所有公式先跑通一个最简单的例子然后逐步增加复杂度。我在实际项目里最大的体会是奖励设计比算法选择更重要。一个设计良好的奖励函数配上简单的 Q-learning往往比一个设计糟糕的奖励函数配上 PPO 效果更好。所以如果你发现训练效果不好先回头看看奖励函数是不是有问题而不是急着换算法。还有一个经验是多动手改代码少盯着公式看。强化学习的很多细节比如终止状态的处理、梯度的符号、探索率的衰减只有自己写一遍才能记住。看别人的代码和论文很容易产生“我懂了”的错觉但一动手就发现到处都是问题。最后分享一个小技巧如果你在调试一个复杂的强化学习系统可以先用一个确定性环境比如没有随机性的格子世界验证算法实现是否正确。如果确定性环境都跑不通那肯定是代码有问题如果确定性环境跑通了但随机环境不行那可能是探索或方差控制的问题。这个排查思路帮我省了很多时间。
返回列表