把Q-Learning换到PPO时踩过的坑
Agent在环境里试错,跟人在公司里摸鱼本质上是一回事——都在寻找最优策略,只不过一个靠探索利用,一个靠踩红线与打擦边球。
强化学习这一路走来,我从Q-Learning的表格查法走到PPO的神经网络策略,踩了不少坑,也认清了一个现实:没有银弹,只有合适场景的合适方法。
为什么折腾强化学习
起因是个游戏:Gym里的CartPole,让小车平衡杆子。看起来简单,但写规则太麻烦。这类场景恰好是强化学习的用武之地——你定义环境、奖惩机制,让模型自己学策略。
中间又试了几次:让Agent学会走迷宫、优化资源调度、甚至在某个游戏里自动挂机。有些成功了,有些失败了。这个过程里最大的感受是:强化学习不像监督学习那样可以线性堆算力,更多是跟环境设计、超参数调整和训练稳定性较劲。
先从Q-Learning开始
Q-Learning是最早的强化学习算法之一,原理粗暴直接:用一张表格记下每个状态和动作的价值,然后更新。
import numpy as np
import gymnasium as gym
class QLearningAgent:
def __init__(self, state_size, action_size, learning_rate=0.1, discount_factor=0.95, epsilon=0.1):
self.q_table = np.zeros((state_size, action_size))
self.learning_rate = learning_rate
self.discount_factor = discount_factor
self.epsilon = epsilon
self.action_size = action_size
def act(self, state):
if np.random.rand() < self.epsilon:
return np.random.randint(self.action_size)
return np.argmax(self.q_table[state])
def learn(self, state, action, reward, next_state):
best_next_action = np.argmax(self.q_table[next_state])
td_target = reward + self.discount_factor * self.q_table[next_state][best_next_action]
td_error = td_target - self.q_table[state][action]
self.q_table[state][action] += self.learning_rate * td_error
# 训练
env = gym.make('FrozenLake-v1', is_slippery=False)
state_size = env.observation_space.n
action_size = env.action_space.n
agent = QLearningAgent(state_size, action_size)
episodes = 1000
for episode in range(episodes):
state, _ = env.reset()
done = False
while not done:
action = agent.act(state)
next_state, reward, done, truncated, info = env.step(action)
agent.learn(state, action, reward, next_state)
state = next_state
这套东西在FrozenLake这种离散小环境里效果还行,但问题很快就来了:状态空间一变大,表格就爆炸。CartPole还好,但换个复杂点的情况,比如连续状态空间,直接就炸了。
训练过程中遇到的第一个坑:exploration和exploitation的平衡。epsilon太大全是瞎试,太小容易陷入局部最优。常见做法是epsilon decay,从1.0慢慢降到0.01,但衰减节奏得配合环境复杂度。
另一个坑是reward design。CartPole里给-1到1的即时奖励没问题,但复杂任务里需要稀疏奖励,这时候模型根本学不到东西。解决办法通常是reward shaping,比如给中间步骤一些引导信号,但引导太多又会改了原始目标。
从表格到神经网络:DQN
状态空间一大,Q-Learning的表格就顶不住了。DQN用神经网络拟合Q函数,把状态输入进去,输出每个动作的价值。
import torch
import torch.nn as nn
import torch.optim as optim
from collections import deque
import random
class DQNNetwork(nn.Module):
def __init__(self, state_size, action_size):
super(DQNNetwork, self).__init__()
self.fc1 = nn.Linear(state_size, 64)
self.fc2 = nn.Linear(64, 64)
self.fc3 = nn.Linear(64, action_size)
def forward(self, x):
x = torch.relu(self.fc1(x))
x = torch.relu(self.fc2(x))
x = self.fc3(x)
return x
class DQNAgent:
def __init__(self, state_size, action_size):
self.state_size = state_size
self.action_size = action_size
self.memory = deque(maxlen=10000)
self.gamma = 0.95
self.epsilon = 1.0
self.epsilon_min = 0.01
self.epsilon_decay = 0.995
self.learning_rate = 0.001
self.model = DQNNetwork(state_size, action_size)
self.target_model = DQNNetwork(state_size, action_size)
self.update_target_model()
self.optimizer = optim.Adam(self.model.parameters(), lr=self.learning_rate)
def update_target_model(self):
self.target_model.load_state_dict(self.model.state_dict())
def remember(self, state, action, reward, next_state, done):
self.memory.append((state, action, reward, next_state, done))
def act(self, state):
if np.random.rand() <= self.epsilon:
return random.randrange(self.action_size)
act_values = self.model(torch.FloatTensor(state))
return np.argmax(act_values.data.numpy())
def replay(self, batch_size):
if len(self.memory) < batch_size:
return
minibatch = random.sample(self.memory, batch_size)
for state, action, reward, next_state, done in minibatch:
target = self.model(torch.FloatTensor(state))
if done:
target[0][action] = reward
else:
t = self.target_model(torch.FloatTensor(next_state)).max()
target[0][action] = reward + self.gamma * t.item()
pred = self.model(torch.FloatTensor(state))
loss = nn.MSELoss()(pred, target)
self.optimizer.zero_grad()
loss.backward()
self.optimizer.step()
if self.epsilon > self.epsilon_min:
self.epsilon *= self.epsilon_decay
DQN比Q-Learning强在能处理连续状态空间,但新坑又来了:训练不稳定、收敛慢。解决办法有几个:
- Experience Replay:存一批样本,训练时随机取,打破样本间的相关性
- Target Network:用一个目标网络计算Q值,定期同步,避免自我训练产生的发散
- Double DQN:用主网络选动作,目标网络算Q值,减少Q值高估
这张图想解释DQN里主网络和目标网络的关系:主网络负责做决策和梯度更新,目标网络提供稳定的Q值估计,避免训练过程中的震荡。实际用的时候,每隔几步就把主网络的权重拷给目标网络。
DQN在CartPole上能收敛,但学习曲线很抖。有时候训练几千步突然垮了,得重新来。这种不稳定性是后续很多改进算法要解决的问题。
Policy Gradient:直接学策略
DQN还在学Q函数,然后从Q函数推导策略。Policy Gradient更直接:学一个策略网络,输入状态,输出动作概率。
class PolicyGradientNetwork(nn.Module):
def __init__(self, state_size, action_size):
super(PolicyGradientNetwork, self).__init__()
self.fc1 = nn.Linear(state_size, 128)
self.fc2 = nn.Linear(128, 128)
self.fc3 = nn.Linear(128, action_size)
def forward(self, x):
x = torch.relu(self.fc1(x))
x = torch.relu(self.fc2(x))
x = torch.softmax(self.fc3(x), dim=-1)
return x
class PolicyGradientAgent:
def __init__(self, state_size, action_size):
self.policy = PolicyGradientNetwork(state_size, action_size)
self.optimizer = optim.Adam(self.policy.parameters(), lr=0.001)
self.gamma = 0.99
self.episodes = []
def act(self, state):
probs = self.policy(torch.FloatTensor(state))
action_dist = torch.distributions.Categorical(probs)
action = action_dist.sample()
log_prob = action_dist.log_prob(action)
return action.item(), log_prob
def store_transition(self, reward, log_prob):
self.episodes.append((reward, log_prob))
def update_policy(self):
rewards = [e[0] for e in self.episodes]
log_probs = [e[1] for e in self.episodes]
# 计算discounted rewards
returns = []
R = 0
for r in reversed(rewards):
R = r + self.gamma * R
returns.insert(0, R)
returns = torch.FloatTensor(returns)
returns = (returns - returns.mean()) / (returns.std() + 1e-8)
policy_loss = []
for log_prob, R in zip(log_probs, returns):
policy_loss.append(-log_prob * R)
self.optimizer.zero_grad()
policy_loss = torch.stack(policy_loss).sum()
policy_loss.backward()
self.optimizer.step()
self.episodes = []
Policy Gradient的好处是能处理连续动作空间,比如控制机器人的扭矩。但问题是方差太大,训练不稳定,样本效率低。一个episode里的运气好坏直接影响梯度方向。
实际训练Policy Gradient的时候,常常遇到reward scale的问题。如果reward太大或者太小,梯度会异常。通常做法是把reward归一化,或者用advantage代替原始reward。
Actor-Critic:结合值函数和策略
Policy Gradient方差大,Actor-Critic的思路是:用Critic网络估计状态价值,减少方差。Actor网络负责输出策略,Critic网络负责估计状态值。
class Actor(nn.Module):
def __init__(self, state_size, action_size):
super(Actor, self).__init__()
self.fc1 = nn.Linear(state_size, 128)
self.fc2 = nn.Linear(128, 128)
self.fc3 = nn.Linear(128, action_size)
def forward(self, x):
x = torch.relu(self.fc1(x))
x = torch.relu(self.fc2(x))
x = torch.softmax(self.fc3(x), dim=-1)
return x
class Critic(nn.Module):
def __init__(self, state_size):
super(Critic, self).__init__()
self.fc1 = nn.Linear(state_size, 128)
self.fc2 = nn.Linear(128, 128)
self.fc3 = nn.Linear(128, 1)
def forward(self, x):
x = torch.relu(self.fc1(x))
x = torch.relu(self.fc2(x))
x = self.fc3(x)
return x
class ActorCriticAgent:
def __init__(self, state_size, action_size):
self.actor = Actor(state_size, action_size)
self.critic = Critic(state_size)
self.actor_optimizer = optim.Adam(self.actor.parameters(), lr=0.001)
self.critic_optimizer = optim.Adam(self.critic.parameters(), lr=0.001)
self.gamma = 0.99
def act(self, state):
probs = self.actor(torch.FloatTensor(state))
action_dist = torch.distributions.Categorical(probs)
action = action_dist.sample()
return action.item()
def train(self, state, action, reward, next_state, done):
state = torch.FloatTensor(state)
next_state = torch.FloatTensor(next_state)
action = torch.tensor(action)
reward = torch.tensor(reward, dtype=torch.float)
# Critic update
value = self.critic(state)
next_value = self.critic(next_state)
target = reward + self.gamma * next_value * (1 - done)
critic_loss = nn.MSELoss()(value, target)
self.critic_optimizer.zero_grad()
critic_loss.backward()
self.critic_optimizer.step()
# Actor update
probs = self.actor(state)
action_dist = torch.distributions.Categorical(probs)
log_prob = action_dist.log_prob(action)
advantage = target - value.detach()
actor_loss = -log_prob * advantage
self.actor_optimizer.zero_grad()
actor_loss.backward()
self.actor_optimizer.step()
Actor-Critic比Policy Gradient稳定一些,但还是有坑:如果Critic训练不好,会误导Actor。实践中常加上entropy regularization,防止策略过早收敛到确定性动作。
这个阶段的训练曲线平滑了一些,但收敛还是很慢。跟DQN比,样本效率是明显短板。
PPO:现代算法的收敛
PPO(Proximal Policy Optimization)是目前最常用的算法之一,关键是在policy update时加上trust region限制,避免策略更新过大导致训练崩掉。
class PPOAgent:
def __init__(self, state_size, action_size, clip_epsilon=0.2):
self.actor = Actor(state_size, action_size)
self.critic = Critic(state_size)
self.actor_optimizer = optim.Adam(self.actor.parameters(), lr=3e-4)
self.critic_optimizer = optim.Adam(self.critic.parameters(), lr=1e-3)
self.gamma = 0.99
self.clip_epsilon = clip_epsilon
self.epochs = 10
self.batch_size = 64
self.memory = []
def act(self, state):
state = torch.FloatTensor(state)
probs = self.actor(state)
action_dist = torch.distributions.Categorical(probs)
action = action_dist.sample()
log_prob = action_dist.log_prob(action)
value = self.critic(state)
return action.item(), log_prob.item(), value.item()
def store(self, transition):
self.memory.append(transition)
def update(self):
states = torch.FloatTensor([t[0] for t in self.memory])
actions = torch.LongTensor([t[1] for t in self.memory])
old_log_probs = torch.FloatTensor([t[2] for t in self.memory])
rewards = torch.FloatTensor([t[3] for t in self.memory])
next_states = torch.FloatTensor([t[4] for t in self.memory])
dones = torch.FloatTensor([t[5] for t in self.memory])
old_values = torch.FloatTensor([t[6] for t in self.memory])
# 计算advantages
with torch.no_grad():
next_values = self.critic(next_states).squeeze()
returns = rewards + self.gamma * next_values * (1 - dones)
advantages = returns - old_values
advantages = (advantages - advantages.mean()) / (advantages.std() + 1e-8)
# 多次更新
for _ in range(self.epochs):
indices = torch.randperm(len(self.memory))
for start in range(0, len(self.memory), self.batch_size):
end = start + self.batch_size
batch_indices = indices[start:end]
batch_states = states[batch_indices]
batch_actions = actions[batch_indices]
batch_old_log_probs = old_log_probs[batch_indices]
batch_advantages = advantages[batch_indices]
batch_returns = returns[batch_indices]
# Actor update with clipping
probs = self.actor(batch_states)
action_dist = torch.distributions.Categorical(probs)
new_log_probs = action_dist.log_prob(batch_actions)
ratio = torch.exp(new_log_probs - batch_old_log_probs)
surr1 = ratio * batch_advantages
surr2 = torch.clamp(ratio, 1 - self.clip_epsilon, 1 + self.clip_epsilon) * batch_advantages
actor_loss = -torch.min(surr1, surr2).mean()
# Critic update
values = self.critic(batch_states).squeeze()
critic_loss = nn.MSELoss()(values, batch_returns)
self.actor_optimizer.zero_grad()
actor_loss.backward()
self.actor_optimizer.step()
self.critic_optimizer.zero_grad()
critic_loss.backward()
self.critic_optimizer.step()
self.memory = []
PPO的优势在于样本效率和稳定性平衡得不错,训练过程中能持续进步而不是突然崩掉。实际用的时候,关键是调好clip_epsilon和learning rate。clip_epsilon太大等于没约束,太小策略更新太慢。
另一个容易踩的坑是advantage normalization。不归一化的话,训练过程会非常抖,特别是reward scale不稳定的时候。
实际项目里的踩坑记录
去年做一个机器人路径规划的项目,用强化学习让机器人自动避障。第一个版本用的DQN,结果在仿真环境里还行,一上真实机器人就崩了。问题有两个:真实环境的物理参数跟仿真不一致,以及真实环境的延迟比仿真大。
后来换了PPO,并在仿真里加了domain randomization——随机改变摩擦系数、传感器噪声、执行器延迟等参数,让策略更鲁棒。最后迁移效果好了不少,但训练时间从一天变成了三天。
# Domain randomization示例
def create_randomized_env():
friction = np.random.uniform(0.5, 1.0)
noise = np.random.uniform(0.0, 0.1)
latency = np.random.uniform(0.0, 0.1)
env = RobotNavigationEnv(
friction=friction,
sensor_noise=noise,
action_latency=latency
)
return env
另一个项目是资源调度,用强化学习动态分配服务器资源。这个坑在reward design:最初设计的reward是响应时间,结果模型学会了拒绝请求来降低延迟。后来改成了综合指标,考虑了吞吐量和响应时间,模型才正常工作。
# 错误的reward design
def reward_wrong(response_time):
return -response_time # 模型会拒绝请求
# 改进后的reward design
def reward_improved(response_time, throughput):
return 10 * throughput - response_time
环境设计比算法更重要
实践中发现,算法选型其实不是最关键的问题。环境设计、reward shaping、状态空间表示这些,往往决定成败。
一个经典的反面教材:想让Agent学会玩游戏,结果reward只给"游戏结束时的胜负"。这种稀疏reward导致Agent随机探索到能赢的策略需要的时间太长。后来加了几个引导reward,比如"距离目标越近奖励越高",训练时间直接从几天变成几小时。
# 稀疏reward(不推荐)
def reward_sparse(done, won):
if won:
return 100
elif done:
return -100
else:
return 0
# 引导reward(推荐)
def reward_shaped(distance_to_goal, done, won):
if won:
return 100
elif done:
return -100
else:
return -distance_to_goal * 0.1 # 鼓励靠近目标
状态空间的表示也很关键。如果给模型喂了一堆无用的信息,训练效率会大打折扣。实践经验是:先人工筛选,只保留跟任务强相关的特征,然后再逐步增加信息量。
什么时候用什么
Q-Learning:状态空间小且离散的情况,比如FrozenLake这类。表格能存得下,而且训练快。
DQN:状态空间较大但离散,或者连续状态但动作空间离散。比如Atari游戏、CartPole。但要准备好应对训练不稳定的问题。
Policy Gradient:连续动作空间,比如控制机器人的关节角度。但样本效率低,适合仿真环境,不适合真实世界高频交互。
Actor-Critic:想在策略梯度基础上提升稳定性的情况。比纯Policy Gradient稳定,但调参复杂。
PPO:通用性强,样本效率和稳定性都不错。适合大多数现代RL应用,是默认选择。
Reward-based learning vs. Imitation learning:如果有expert demo,优先考虑模仿学习或者跟强化学习结合。纯强化学习从头训练成本太高。
代码库与工具
折腾过程中用过的几个工具:
- Gymnasium:标准环境库,跟旧版Gym不兼容,注意依赖版本
- Stable-Baselines3:现成的RL算法实现,PPO、SAC、TD3都有,适合快速验证
- Ray RLLib:分布式训练,适合大规模任务,但学习成本高
- CleanRL:代码简洁,适合学习算法原理,但工程化程度一般
安装命令:
pip install gymnasium
pip install stable-baselines3[extra]
pip install "ray[rllib]"
pip install cleanrl
这一路走下来的感受
从Q-Learning到PPO,算法越来越复杂,但核心思想没变:试错、反馈、调整。不同的是,现在的算法在"如何试"“如何反馈"“如何调整"这几个环节上做了大量工程优化。
最大的感受是:强化学习不像监督学习那样容易复现。同一个算法换个人实现,训练过程可能完全不同。代码实现细节、超参数选择、随机种子,都会影响结果。
另一个感受是:reward design比算法选择更重要。一个好的reward能让你用简单算法达到不错效果,一个差的reward再高级算法也救不回来。
最后,强化学习不是万能药。它能处理很多传统算法搞不定的问题,但训练成本、调试难度、工程复杂度都摆在那里。遇到问题时,先想想传统方法能不能解决,再考虑强化学习。
参考资源
- Sutton & Barto, “Reinforcement Learning: An Introduction”
- Schulman et al., “Proximal Policy Optimization Algorithms”
- Mnih et al., “Playing Atari with Deep Reinforcement Learning”
- Stable-Baselines3文档
- CleanRL代码仓库
这些东西折腾到现在,差不多能说明白了。强化学习这一路从Q-Learning的表格查法走到PPO的神经网络策略,核心是试错与反馈,工程上则一直在解决训练稳定性与样本效率的问题。选算法前先想清楚环境特性、reward设计和工程约束,比盲目追求最新模型重要得多。
可用性说明:本文发布于 2021 年 2 月,距今已超过五年。文中涉及的软件版本、接口、下载地址、命令参数和操作界面可能已经发生变化,部分方案在当前环境下可能失效。请结合官方最新文档核对后再操作,生产环境使用前务必先行验证。
版权声明: 本文首发于 指尖魔法屋-把Q-Learning换到PPO时踩过的坑(https://blog.thinkmoon.cn/post/171-rl-deep-dive-qlearning-to-ppo/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。