强化学习算法实战:从Q-learning到PPO的调参避坑指南
发布时间:2026/9/30 9:12:55来源:尧图网络
强化学习这个方向我断断续续折腾了快三年。最开始是被智能体自己学会玩游戏这种demo吸引进来的结果一头扎进公式和代码里才发现真正难的不是看懂Q-learning的更新公式而是搞清楚什么时候该用哪种算法、为什么它在这个场景下能收敛、以及那些论文里不会写的调参细节。这篇笔记就是把我踩过的坑、反复验证过的理解整理出来从最基础的Q-learning一路讲到策略梯度、PPO和DQN每个算法都配上能直接跑的代码和实测心得。不管你是刚接触强化学习的新手还是已经能跑通demo但总觉得知其然不知其所以然的进阶者应该都能从里面找到点有用的东西。1. 先搞清楚强化学习到底在解决什么问题1.1 和监督学习的本质区别很多人入门强化学习时最大的困惑是这不就是个分类或者回归问题吗我直接拿神经网络拟合一下不就行了这个想法在简单场景下确实能work但很快就会撞墙。监督学习的核心假设是数据独立同分布你有一批标注好的样本模型学的是输入到输出的映射。但强化学习里智能体的每一步动作都会改变环境状态进而影响后续能拿到的数据。你今天做的决策会决定你明天看到什么局面。这种数据分布随策略变化而变化的特性是强化学习最本质的难点。举个具体的例子。假设你在训练一个走迷宫的智能体用监督学习思路你需要先收集大量在某个位置该往哪走的标注数据。但问题是如果智能体从来没走到过迷宫右上角你就没有那个区域的数据模型也就永远学不会怎么从那里走出去。这就是所谓的探索与利用困境——你得先探索到那些状态才能学到对应的策略但探索本身又需要策略指导。1.2 马尔可夫决策过程把问题形式化强化学习的数学框架是马尔可夫决策过程MDP用五元组 $(S, A, P, R, \gamma)$ 表示。S是状态空间A是动作空间P是状态转移概率R是奖励函数$\gamma$ 是折扣因子。这里最关键的是马尔可夫性当前状态包含了做决策所需的全部历史信息。换句话说给定当前状态未来和过去无关。这个假设在实际问题里经常不成立比如你玩扑克牌当前手牌不能完全反映之前的出牌历史。这时候就需要把历史信息编码进状态或者用循环神经网络来处理部分可观测的问题。折扣因子 $\gamma$ 的作用是平衡当前奖励和未来奖励。$\gamma$ 越接近1智能体越有远见越接近0越短视。我一般从0.99开始调如果任务回合很短比如几十步就结束可以降到0.95甚至0.9。1.3 值函数与策略两条技术路线强化学习的算法大致分两派基于值函数和基于策略。值函数方法学的是在某个状态下采取某个动作到底有多好然后根据值函数选动作。Q-learning和DQN都属于这一类。优点是样本效率相对高缺点是只能处理离散动作空间连续动作就得做离散化维度一高就爆炸。策略方法直接学一个策略函数 $\pi(a|s)$输出动作的概率分布。REINFORCE和PPO属于这一类。优点是天然支持连续动作缺点是方差大、收敛慢。还有一类是Actor-Critic把两者结合起来Actor负责选动作Critic负责评估动作好坏。A3C、SAC、TD3都是这个框架下的。2. Q-learning从表格到神经网络的跨越2.1 表格版Q-learning的更新逻辑Q-learning的核心就一个更新公式$$Q(s,a) \leftarrow Q(s,a) \alpha [r \gamma \max_{a} Q(s,a) - Q(s,a)]$$这个公式在做什么它把当前估计的Q值和实际拿到的奖励加上下一状态最大Q值之间的差距按学习率 $\alpha$ 缩小。这个差距叫TD误差是强化学习里最重要的概念之一。我用一个简单的格子世界来演示。假设有个4x4的格子智能体从左上角出发目标是右下角每走一步奖励-1到达目标奖励0。用表格Q-learning训练import numpy as np # 4x4格子世界 n_states 16 n_actions 4 # 上下左右 Q np.zeros((n_states, n_actions)) alpha 0.1 gamma 0.99 epsilon 0.1 def step(state, action): row, col state // 4, state % 4 if action 0: row max(0, row - 1) elif action 1: row min(3, row 1) elif action 2: col max(0, col - 1) elif action 3: col min(3, col 1) next_state row * 4 col reward 0 if next_state 15 else -1 done (next_state 15) return next_state, reward, done for episode in range(1000): state 0 done False while not done: if np.random.random() epsilon: action np.random.randint(n_actions) else: action np.argmax(Q[state]) next_state, reward, done step(state, action) td_target reward gamma * np.max(Q[next_state]) * (1 - done) Q[state, action] alpha * (td_target - Q[state, action]) state next_state跑完1000个回合后把Q表reshape成4x4每个格子取最大Q值就能看到一条从起点到终点的价值梯度。2.2 为什么Q-learning是off-policy的这是面试常问的问题。Q-learning更新时用的是 $\max_{a} Q(s,a)$也就是假设下一步会选最优动作而不是实际执行的动作。这意味着它可以从任何行为策略产生的数据里学习哪怕行为策略是随机乱走的。这就是off-policy的含义。对比一下SARSA它的更新目标是 $r \gamma Q(s, a)$其中 $a$ 是实际执行的下一个动作。所以SARSA是on-policy的它学的是当前策略的价值。这个区别在实际中很重要。如果环境有风险比如悬崖Q-learning会学到贴着悬崖走的最优路径因为它假设下一步总是选最优的而SARSA会学到离悬崖远一点的保守路径因为它考虑了探索时可能掉下去的风险。2.3 从表格到DQN函数逼近的坑状态空间一大表格就存不下了。比如Atari游戏状态是210x160的像素图状态数是指数级的。这时候就需要用神经网络来逼近Q函数。DQN的核心技巧有两个经验回放和目标网络。经验回放是把 $(s, a, r, s, done)$ 存进一个buffer训练时随机采样。这样做的好处是打破了样本之间的时间相关性。如果按顺序训练相邻样本高度相关神经网络容易学偏。随机采样让数据更接近独立同分布。目标网络是另外复制一份网络用来计算TD目标每隔一段时间才同步一次参数。如果不这样做TD目标会随着当前网络参数变化而不断移动就像追一个移动的靶子训练极不稳定。import torch import torch.nn as nn import random from collections import deque class QNet(nn.Module): def __init__(self, state_dim, action_dim): super().__init__() self.net nn.Sequential( nn.Linear(state_dim, 128), nn.ReLU(), nn.Linear(128, 128), nn.ReLU(), nn.Linear(128, action_dim) ) def forward(self, x): return self.net(x) class DQNAgent: def __init__(self, state_dim, action_dim): self.q_net QNet(state_dim, action_dim) self.target_net QNet(state_dim, action_dim) self.target_net.load_state_dict(self.q_net.state_dict()) self.optimizer torch.optim.Adam(self.q_net.parameters(), lr1e-3) self.buffer deque(maxlen10000) self.batch_size 64 self.gamma 0.99 self.epsilon 1.0 self.epsilon_min 0.05 self.epsilon_decay 0.995 self.update_target_every 100 self.step_count 0 def select_action(self, state): if random.random() self.epsilon: return random.randint(0, 3) with torch.no_grad(): q_values self.q_net(torch.FloatTensor(state)) return q_values.argmax().item() def store(self, transition): self.buffer.append(transition) def train(self): if len(self.buffer) self.batch_size: return batch random.sample(self.buffer, self.batch_size) states, actions, rewards, next_states, dones zip(*batch) states torch.FloatTensor(states) actions torch.LongTensor(actions).unsqueeze(1) rewards torch.FloatTensor(rewards).unsqueeze(1) next_states torch.FloatTensor(next_states) dones torch.FloatTensor(dones).unsqueeze(1) q_values self.q_net(states).gather(1, actions) with torch.no_grad(): next_q self.target_net(next_states).max(1, keepdimTrue)[0] td_target rewards self.gamma * next_q * (1 - dones) loss nn.MSELoss()(q_values, td_target) self.optimizer.zero_grad() loss.backward() self.optimizer.step() self.step_count 1 if self.step_count % self.update_target_every 0: self.target_net.load_state_dict(self.q_net.state_dict()) self.epsilon max(self.epsilon_min, self.epsilon * self.epsilon_decay)实测下来DQN在CartPole上大概200个回合就能稳定到200分满分但在更复杂的任务上超参敏感度极高。学习率、batch size、目标网络更新频率任何一个没调好都可能不收敛。注意DQN的损失函数用的是MSE但实际中Huber损失smooth L1往往更稳因为它对异常值不那么敏感。我在Atari任务上对比过Huber损失的收敛曲线明显更平滑。3. 策略梯度直接优化策略的思路3.1 REINFORCE的直觉理解值函数方法的核心是先学价值再选动作。策略梯度反过来直接参数化策略 $\pi_\theta(a|s)$然后沿着让期望回报增大的方向更新参数。REINFORCE的更新公式是$$\nabla_\theta J(\theta) \mathbb{E}[\nabla_\theta \log \pi_\theta(a|s) \cdot G_t]$$其中 $G_t$ 是从时刻t开始的累积回报。直觉上如果某个动作带来的回报高就增大它被选中的概率回报低就减小。这个公式的推导用到了对数求导技巧$\nabla_\theta \pi_\theta \pi_\theta \nabla_\theta \log \pi_\theta$。这样就能把梯度写成期望形式用采样来估计。class PolicyNet(nn.Module): def __init__(self, state_dim, action_dim): super().__init__() self.net nn.Sequential( nn.Linear(state_dim, 128), nn.ReLU(), nn.Linear(128, action_dim), nn.Softmax(dim-1) ) def forward(self, x): return self.net(x) def reinforce_train(env, episodes1000, lr1e-2, gamma0.99): state_dim env.observation_space.shape[0] action_dim env.action_space.n policy PolicyNet(state_dim, action_dim) optimizer torch.optim.Adam(policy.parameters(), lrlr) for episode in range(episodes): log_probs [] rewards [] state, _ env.reset() done False while not done: state_tensor torch.FloatTensor(state) probs policy(state_tensor) dist torch.distributions.Categorical(probs) action dist.sample() log_probs.append(dist.log_prob(action)) state, reward, terminated, truncated, _ env.step(action.item()) rewards.append(reward) done terminated or truncated # 计算折扣回报 returns [] G 0 for r in reversed(rewards): G r gamma * G returns.insert(0, G) returns torch.FloatTensor(returns) # 标准化减小方差 returns (returns - returns.mean()) / (returns.std() 1e-8) loss 0 for log_prob, G in zip(log_probs, returns): loss - log_prob * G optimizer.zero_grad() loss.backward() optimizer.step()3.2 方差问题与基线技巧REINFORCE最大的问题是方差大。同一个策略不同回合的回报可能差很多导致梯度估计噪声很大训练慢且不稳定。解决办法是引入基线baseline。把回报 $G_t$ 替换成 $G_t - b(s)$其中 $b(s)$ 是只依赖状态的函数。数学上可以证明只要基线不依赖动作梯度的期望不变但方差会减小。最常用的基线是状态价值函数$V(s)$这时候 $G_t - V(s)$ 就是优势函数$A(s,a)$表示这个动作比平均水平好多少。实践中还有一个简单粗暴但有效的技巧回报标准化。把一批回报减均值除标准差效果立竿见影。我在CartPole上试过不加标准化要500回合才能收敛加了之后200回合就稳了。3.3 从REINFORCE到Actor-CriticREINFORCE是蒙特卡洛方法必须等一个回合结束才能更新。如果回合很长或者任务没有终止状态就没法用了。Actor-Critic用时序差分来估计价值不需要等回合结束。Critic学一个价值函数 $V(s)$Actor用 $r \gamma V(s) - V(s)$ 作为优势估计来更新。这个框架下有很多变体。A2C是同步版本的A3C用多个环境并行采样PPO在A2C基础上加了重要性采样裁剪让更新更稳定SAC在最大熵框架下优化探索性更强。4. PPO工业界最常用的策略优化算法4.1 重要性采样与信任域PPO的核心动机是策略更新不能太猛。如果一步更新太大新策略和旧策略差异过大之前采样的数据就不再适用训练会崩。TRPO用KL散度约束新旧策略的距离但计算复杂。PPO用了一个更简单的裁剪技巧$$L^{CLIP}(\theta) \mathbb{E}[\min(r_t(\theta) A_t, \text{clip}(r_t(\theta), 1-\epsilon, 1\epsilon) A_t)]$$其中 $r_t(\theta) \frac{\pi_\theta(a|s)}{\pi_{\theta_{old}}(a|s)}$ 是重要性采样比。当优势为正时限制 $r_t$ 不超过 $1\epsilon$优势为负时限制不低于 $1-\epsilon$。这样就不会因为某个动作优势特别大而过度更新。$\epsilon$ 一般取0.1或0.2。我实测下来0.2在大多数任务上比较稳如果训练不稳定可以降到0.1。4.2 PPO的完整实现要点PPO的实现有几个容易踩坑的地方优势估计用GAE。广义优势估计Generalized Advantage Estimation用参数 $\lambda$ 在偏差和方差之间权衡。$\lambda0$ 就是单步TD偏差大方差小$\lambda1$ 就是蒙特卡洛偏差小方差大。一般取0.95。多轮更新。同一批数据可以更新多次但每次更新后策略会变重要性采样比会偏离1。一般更新3-10轮就停再多就不安全了。价值函数裁剪。PPO还会裁剪价值函数的更新防止Critic变化太大。class PPOAgent: def __init__(self, state_dim, action_dim, lr3e-4, gamma0.99, lam0.95, clip_eps0.2, epochs10, batch_size64): self.actor PolicyNet(state_dim, action_dim) self.critic nn.Sequential( nn.Linear(state_dim, 128), nn.ReLU(), nn.Linear(128, 128), nn.ReLU(), nn.Linear(128, 1) ) self.optimizer torch.optim.Adam( list(self.actor.parameters()) list(self.critic.parameters()), lrlr) self.gamma gamma self.lam lam self.clip_eps clip_eps self.epochs epochs self.batch_size batch_size def compute_gae(self, rewards, values, dones): advantages [] gae 0 values values [0] for t in reversed(range(len(rewards))): delta rewards[t] self.gamma * values[t1] * (1 - dones[t]) - values[t] gae delta self.gamma * self.lam * (1 - dones[t]) * gae advantages.insert(0, gae) returns [adv val for adv, val in zip(advantages, values[:-1])] return advantages, returns def update(self, trajectories): states torch.FloatTensor([t[0] for t in trajectories]) actions torch.LongTensor([t[1] for t in trajectories]) old_log_probs torch.FloatTensor([t[2] for t in trajectories]) rewards [t[3] for t in trajectories] dones [t[4] for t in trajectories] values [t[5] for t in trajectories] advantages, returns self.compute_gae(rewards, values, dones) advantages torch.FloatTensor(advantages) advantages (advantages - advantages.mean()) / (advantages.std() 1e-8) returns torch.FloatTensor(returns) dataset_size len(trajectories) for _ in range(self.epochs): indices np.random.permutation(dataset_size) for start in range(0, dataset_size, self.batch_size): idx indices[start:startself.batch_size] probs self.actor(states[idx]) dist torch.distributions.Categorical(probs) new_log_probs dist.log_prob(actions[idx]) ratio torch.exp(new_log_probs - old_log_probs[idx]) surr1 ratio * advantages[idx] surr2 torch.clamp(ratio, 1-self.clip_eps, 1self.clip_eps) * advantages[idx] actor_loss -torch.min(surr1, surr2).mean() values_pred self.critic(states[idx]).squeeze() critic_loss nn.MSELoss()(values_pred, returns[idx]) loss actor_loss 0.5 * critic_loss self.optimizer.zero_grad() loss.backward() nn.utils.clip_grad_norm_( list(self.actor.parameters()) list(self.critic.parameters()), 0.5) self.optimizer.step()4.3 PPO调参的实战经验PPO的超参比DQN少但每个都很关键。我整理了一个调参优先级参数推荐范围影响调整建议学习率1e-4 ~ 3e-4太大不收敛太小太慢先用3e-4不收敛降到1e-4clip_eps0.1 ~ 0.3控制更新幅度训练不稳降到0.1GAE lambda0.9 ~ 0.99偏差方差权衡默认0.95任务随机性大降到0.9epochs3 ~ 10数据复用次数太多会过拟合旧数据batch_size64 ~ 4096梯度估计质量越大越稳但越慢还有一个容易被忽略的点观测标准化。如果状态各维度量纲差异大比如位置是0-1速度是-10到10一定要做归一化否则网络很难学。我用RunningMeanStd来在线更新均值和方差效果比固定归一化好。5. 算法选型什么场景用什么算法5.1 离散动作 vs 连续动作这是选型的第一个分水岭。动作空间离散且维度不高比如几十个优先考虑DQN系列。动作连续比如机器人关节角度必须用策略梯度或Actor-Critic。有人会想连续动作离散化不就行了理论上可以但维度灾难很快就会出现。一个6自由度的机械臂每个关节离散成10个值动作空间就是 $10^6$ 个DQN根本训不动。5.2 样本效率 vs 稳定性如果环境交互成本很高比如真实机器人样本效率是首要考虑。这时候off-policy方法更有优势因为可以用经验回放反复利用数据。SAC和TD3是连续控制里样本效率最高的。如果环境是模拟器可以并行采样那on-policy方法PPO、A2C更简单稳定调参也少。5.3 我的选型决策表场景推荐算法理由离散动作状态简单表格Q-learning无需神经网络调试直观离散动作状态高维DQN / Double DQN经验回放样本效率高连续动作样本宝贵SAC / TD3off-policy样本效率最高连续动作追求稳定PPO实现简单超参鲁棒多智能体MADDPG / QMIX考虑智能体间交互离线数据CQL / IQL不需要在线交互提示新手建议从PPO入手。它的实现相对简单超参不敏感在大多数任务上都能跑出不错的结果。等理解了策略梯度的核心思想再去看SAC、TD3这些更复杂的算法会轻松很多。6. 那些论文里不会写的踩坑记录6.1 奖励设计最容易翻车的地方强化学习里有一句话奖励设计决定成败。我见过太多人算法调了半天最后发现是奖励函数写错了。最常见的坑是稀疏奖励。比如机械臂抓取任务只有抓到才给奖励其他时候都是0。这种情况下智能体随机探索几乎不可能碰到正奖励学习根本启动不了。解决办法有几种奖励塑形在中间过程给一些引导性奖励比如靠近物体给小额正奖励课程学习先从简单任务开始逐步增加难度HERHindsight Experience Replay把失败的经验重新标注为达成了其他目标来学习。奖励塑形也有风险如果塑形奖励设计不当智能体会钻空子。比如你想让机器人往前走给了向前速度的奖励结果它学会了原地抖动来获取速度奖励。这种叫奖励黑客是强化学习里很头疼的问题。6.2 训练不收敛的排查思路遇到不收敛我一般按这个顺序排查第一步检查环境。手动跑几个回合看看奖励范围是否合理状态是否有异常值NaN、inf。我遇到过一次状态里混进了NaN导致整个网络输出全是NaN查了两天才发现是环境的一个边界条件没处理好。第二步检查奖励尺度。如果奖励范围是0-1000而学习率是1e-3梯度会爆炸。一般把奖励缩放到-10到10之间比较安全。第三步看价值函数。如果Critic的损失一直不降说明价值函数没学好Actor的梯度就是错的。这时候先冻结Actor单独训练Critic等Critic能准确预测回报了再联合训练。第四步调学习率。这是最玄学的部分。同样的代码学习率从3e-4改成1e-4可能就从发散变成收敛。我的经验是先用大学习率快速试如果发散就每次除以3。6.3 随机种子的重要性强化学习对随机种子极其敏感。同一个算法种子不同最终性能可能差一倍。所以一定要跑多个种子取平均至少3个最好5个。画学习曲线时用均值和置信区间。置信区间可以用标准差除以 $\sqrt{n}$ 来算或者用bootstrap。我一般用seaborn的lineplot它自动帮你算置信区间。import seaborn as sns import pandas as pd # 假设有5个种子的训练数据每个是(step, reward)的列表 data [] for seed in range(5): for step, reward in zip(steps, rewards[seed]): data.append({step: step, reward: reward, seed: seed}) df pd.DataFrame(data) sns.lineplot(datadf, xstep, yreward, errorbarsd)6.4 并行环境加速训练PPO这类on-policy算法采样是瓶颈。用多个环境并行采样能线性加速。最简单的做法是用gym.vector或者SubprocVecEnvfrom stable_baselines3.common.vec_env import SubprocVecEnv def make_env(env_id, seed): def _init(): env gym.make(env_id) env.reset(seedseed) return env return _init env SubprocVecEnv([make_env(CartPole-v1, i) for i in range(8)])8个环境并行采样速度大概能提升6-7倍。注意每个环境要设不同的种子否则数据会高度相关。6.5 从仿真到现实的鸿沟如果你做的是机器人方向仿真训练好的策略直接部署到真机上大概率会失败。原因包括动力学差异仿真里的摩擦模型和真实不一样、传感器噪声、延迟。常用的应对手段有域随机化训练时随机改变仿真参数质量、摩擦、延迟让策略对这些变化鲁棒系统辨识先测量真实系统的参数再调整仿真在线微调在真机上用少量数据继续训练。我做过一个机械臂抓取的项目仿真里成功率95%真机上第一次跑只有30%。后来加了域随机化真机成功率提到70%再在真机上微调了200个回合最终到85%。这个过程很磨人但确实是必经之路。7. 学习路径与资源建议7.1 按什么顺序学我的建议是先跑通再理解最后深挖。第一步用stable-baselines3跑通几个经典环境CartPole、LunarLander、HalfCheetah感受一下不同算法的表现差异。这一步不用管原理先建立直觉。第二步自己手写Q-learning和REINFORCE。这两个算法足够简单能帮你把核心概念TD误差、策略梯度、回报搞清楚。第三步读PPO和SAC的论文配合开源实现对照着看。Sutton的《Reinforcement Learning: An Introduction》是必读的但不用从头啃到尾当工具书查就行。第四步选一个自己感兴趣的任务从头实现一遍。这个过程会遇到各种问题解决问题的过程就是真正进步的时候。7.2 代码资源stable-baselines3最成熟的RL库PPO、SAC、DQN都有文档清晰适合快速实验。CleanRL单文件实现代码简洁适合学习算法细节。Tianshou国产RL库模块化设计好中文文档友好。Gymnasium环境接口标准新版本替代了老gym。7.3 我个人的一点体会强化学习这个领域理论很漂亮但实践很脏。论文里的算法在标准环境上跑出漂亮曲线但换到你的任务上可能各种不work。这时候不要怀疑自己这是常态。我的经验是先把baseline跑通再逐步改。不要一上来就自己从头写先用成熟库跑出一个能工作的版本然后针对你的问题一点点改。每次只改一个地方改完对比效果。这样虽然慢但每一步都知道自己在做什么。还有就是不要迷信SOTA。最新的算法不一定适合你的任务。PPO是2017年的算法到现在还是工业界最常用的因为它在效果和易用性之间平衡得最好。选算法要看你的约束条件不是越新越好。最后强化学习的调试周期很长一个实验跑几个小时甚至几天很正常。做好实验管理记录每次改动的配置和结果不然跑着跑着就忘了哪个配置对应哪个结果了。我用wandb或者tensorboard来记录配合git管理代码版本能省很多事。
网站建设高端定制企业官网