从REINFORCE到PPO:用PyTorch构建强化学习训练闭环
2026/9/12 17:36:47 网站建设 项目流程

简介:一套基于PyTorch的《动手学强化学习》系列资料,面向希望从代码层面理解强化学习的初学者和算法爱好者。资源以经典算法为核心,包含DQN与DDPG的完整实现,分别对应CartPole-v0和Pendulum-v0等常见环境,通过实际运行体会智能体与环境交互、奖励反馈和策略更新的全过程。包内共9个文件,涵盖4个Python源文件、2个pyc编译文件、2个mp4教学演示视频和1个Markdown笔记,压缩包仅2.6MB,轻量易下载。配套视频和笔记可帮助梳理算法原理,代码注释与运行结果便于对照调试,从环境配置到模型训练均可在本地快速复现。目前已有157人学习下载,适合作为强化学习入门后的第一个动手实践项目,也可作为课程设计或论文复现的参考模板。

1. 动手学强化学习,先从 PyTorch 的短闭环开始

很多人学强化学习的第一课就卡在“程序能跑但曲线不动”上。算法公式写得出来,策略网络一层层堆上去,训练时终端却不断打印 reward=9、reward=11,仿佛模型根本没有在学。这种情况通常不是数学问题,而是环境接口、张量梯度和算法选型混在一起造成的。基于 PyTorch 的强化学习入门系列,核心就是把“采样一个 transition → 算 loss → 反向传播 → 更新一次随机策略”这条最短闭环先跑通,再逐步替换组件,用同一套代码去比较 REINFORCE、Actor-Critic 和 PPO 的差异。因此这类教程通常按章节组织成可独立运行的工程,适合有 Python 基础但没完整写过 RL 训练循环的人,也适合想快速验证算法效果的工程开发。

2. 强化学习环境搭建:用 Anaconda 创建 PyTorch 环境并验证 Gymnasium

2.1 用 Anaconda 建独立环境,避免 PyTorch 版本互相污染

我见过不少本地已经有 TensorFlow 或旧版 PyTorch 的机器,为了跑强化学习 demo 又把torch升了一遍,结果把其他项目的依赖顶掉,最后连 import 都报错。强化学习训练循环本身对 Python 环境非常敏感,建议从一开始就建一个独立 conda 环境,把 PyTorch、Gymnasium 和后续的日志库都装在里面,不要动 base 环境。

conda create -n rl python=3.10 -y conda activate rl pip install gymnasium pip install torch --index-url https://download.pytorch.org/whl/cpu python -c "import torch; print(torch.__version__)"

这套命令里,python=3.10 是为了避开一些老版本编译好的二进制包对 Python 3.12+ 的兼容问题;先装gymnasium再装torch,顺序上并没有强依赖,但能提前把环境类库装好,后面写网络时只面向 PyTorch 一方调试。最后一行用来确认 torch 是否真的装进当前环境,而不是误用了 base 里的旧版本。

CPU 版 PyTorch 足够跑 CartPole 这类小环境,不必一开始就在 GPU 上折腾 CUDA。如果你的机器有 NVIDIA 显卡,建议在 PyTorch 官网按cu121cu124这样的标签安装对应 wheel,而不是直接复制 CPU 版命令。强化学习入门阶段,训练瓶颈通常不是算力,而是算法不收敛带来的无效重跑,CPU 完全能承担。

2.2 先跑随机策略,把 Gymnasium 的 transition API 看清楚

拿到一个环境,我不建议立刻写策略网络,而是先跑一个纯随机策略。这一步能同时验证两件事:环境是否安装正确、观察值和奖励的数据结构是否符合预期。很多人在 Gymnasium 上踩的第一个坑就是env.step()的返回值数量——老版 Gym 返回 4 个值,Gymnasium 返回 5 个值,直接解包会报ValueError

import gymnasium as gym env = gym.make("CartPole-v1", render_mode="rgb_array") obs, info = env.reset(seed=42) total_reward = 0 steps = 0 while True: action = env.action_space.sample() # 离散动作空间里随机选一个动作 obs, reward, terminated, truncated, _ = env.step(action) total_reward += reward steps += 1 if terminated or truncated: break env.close() print(f"random policy: reward={total_reward}, steps={steps}")

这段代码里,env.reset(seed=42)返回的是(obs, info)二元组,不再是老版 Gym 里的obs单值。env.step()返回 obs、reward、terminated、truncated、info 五个值,其中terminated表示回合因为达到终止状态而结束,truncated表示因为步数上限或外部原因被截断。CartPole-v1 的默认最大步数是 500,倒立摆一旦倾斜超过阈值就会触发 terminated。

随机策略在这个环境上通常只能积累 9 到 15 的 reward,多跑几次大概也能在 20 以内。记住这个数字,后面写 REINFORCE 和 PPO 时,用它做基准线。如果随机策略能跑到几百,通常说明 reward 或 done 的语义理解错了,比如把 terminated 写成 always False,训练循环会永远采样不完一个 episode。

2.3 用一个表格理清强化学习里的张量维度

PyTorch 的强化学习代码里,最容易出错的地方不是网络结构,而是喂给网络的张量形状。CartPole 的 observation 是 4 维 float 向量,action 是 0/1 的整数标量,如果用torch.FloatTensor(action)把一个 int 转成带梯度回路的张量,后续运算往往会产生难以排查的维度错误。

数据类型形状给网络时怎么处理
obsfloat(4,)torch.FloatTensor(obs).unsqueeze(0)加 batch 维
actionint()直接传给Categorical,不用做 one-hot
rewardfloat()收敛到 Python 标量,后期存入 list
terminatedbool()转为0.01.0的浮点,供 GAE 计算
truncatedbool()与 terminated 一起决定 episode 是否结束

在训练循环里,策略网络通常接收(batch, obs_dim)形状的输入,CartPole 就是(1, 4)unsqueeze(0)是给单个样本补 batch 维最直接的方式。不要把 scalar 直接喂进nn.Linear,PyTorch 1.x 之后对一维输入虽然会兼容处理,但维度语义不清楚时会连带影响后续squeezedetach的判断。

3. 用 PyTorch 实现策略梯度 REINFORCE:第一个能收敛的算法

3.1 为什么先写 REINFORCE 而不是 DQN

常见的学习路径会从 Q-learning 开始,但到了 DQN 就需要同时维护目标网络、经验回放、双 Q 更新这些机制,对没有完整写过训练循环的人负担偏重。REINFORCE 只需要一个策略网络,做法可以概括成“用轨迹的总回报去放大或缩小对应动作的概率”。

策略梯度公式∇J(θ) = E[∑ ∇log π(a|s) · G]落到 PyTorch 代码里,就是一个非常直接的乘法:每个动作的log_prob乘以对应的回报 G,然后取负求梯度。因为 PyTorch 是做梯度下降,所以 loss 是负号。这个套路理解之后,后面 Actor-Critic、PPO 的改动都是在替换“用什么东西来加权 log_prob”,结构本身不会变。

3.2 CartPole 上的 REINFORCE 最小实现

下面这段代码按“采样一整条轨迹 → 计算每个时刻的折扣回报 → 用回报加权更新策略”的顺序执行。我会把完整训练循环拆出来,便于你直接放进.py文件运行。

import gymnasium as gym import torch import torch.nn as nn import torch.nn.functional as F import torch.optim as optim from torch.distributions import Categorical class PolicyNet(nn.Module): def __init__(self, obs_dim, act_dim): super().__init__() self.fc1 = nn.Linear(obs_dim, 128) self.fc2 = nn.Linear(128, act_dim) def forward(self, obs): x = F.relu(self.fc1(obs)) logits = self.fc2(x) return F.softmax(logits, dim=-1) def train(): env = gym.make("CartPole-v1") policy = PolicyNet(env.observation_space.shape[0], env.action_space.n) optimizer = optim.Adam(policy.parameters(), lr=3e-3) gamma = 0.99 max_episodes = 500 for episode in range(max_episodes): obs, _ = env.reset(seed=42) log_probs = [] rewards = [] done = False while not done: obs_t = torch.FloatTensor(obs).unsqueeze(0) probs = policy(obs_t) dist = Categorical(probs) action = dist.sample() log_probs.append(dist.log_prob(action)) obs, 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.tensor(returns, dtype=torch.float32) 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() if episode % 50 == 0: total_reward = sum(rewards) print(f"episode {episode}, reward_sum={total_reward}") if __name__ == "__main__": train()

这里每次dist.sample()都会从策略分布里抽一个动作,log_prob(action)记下当前策略下选中该动作的对数概率。轨迹采样完之后,用逆序遍历计算每个时刻的折扣回报G = r + gamma * G,再整体做一次归一化。returns.mean()returns.std()是在一个 episode 内统计的,目的是把不同 episode 的回报尺度拉齐,避免某些 episode 总奖励偏高导致更新步长过大。

3.3 returns 归一化与其背后的方差问题

REINFORCE 的方差来源是采样轨迹本身的随机性。同一组策略参数下,好一点能撑到几十步,差一点可能在十步内结束,直接用原始回报去更新,loss 会被少数高分轨迹主导。把整个 episode 的 returns 归一化之后,等于给每个时刻的回报减去一个“当前 episode 的平均水平”,再做方差缩放,对应到公式上就是一个可学习的 baseline 的退化版本。

这个技巧简单但有效。CartPole 上如果不做归一化,训练曲线会在前 100 个 episode 内剧烈抖动,很难从日志里判断是否在进步;归一化之后,大约 100 到 200 个 episode 能看到明显上升趋势。注意归一化统计的是当前 episode 的 returns,不是跨 episode 的 running mean,因为后者需要额外维护缓存,而且对 CartPole 这种短轨迹帮助有限。

3.4 两个容易写错的细节

第一,loss的拼接方式。上面用loss += -log_prob * G需要保证log_probG都是标量,如果有人在过程中给张量额外加了 batch 维,可以先.squeeze()再乘。第二,returns不需要detach(),因为它是直接从 Python 列表用torch.tensor()构造的,不在当前计算图里,反向传播只会经过log_prob到网络参数这一条路径。如果你把returns定义成某个网络输出的函数,那就必须detach(),否则策略网络的梯度会被价值路径污染。

4. 从 REINFORCE 到 Actor-Critic:用 PyTorch 改变方差结构

4.1 为什么策略梯度需要基线

REINFORCE 的更新量等于“回报 G”乘以“动作的对数概率”。当动作空间变大,或者环境给每个 step 一个固定负数奖励时,G 的绝对值会普遍偏高,导致策略更新的方差迅速增大。常见做法是给 G 减去一个 baseline b(s),从理论上讲 b(s) 只要是只和状态 s 相关的函数,就不会改变策略梯度的期望值,但可以显著降低方差。

Actor-Critic 通过引入一个 Critic 网络来输出状态价值估计V(s),把它当作可学习的基线。这样每个 step 的时序差分误差(TD error)就是G - V(s),在这个框架下,Actor 网络只负责输出动作分布,Critic 网络只负责拟合状态价值,两个网络共享底层特征但输出头不同。用 PyTorch 写这个结构比想象中简单,因为 PyTorch 的自动微分会同时处理两个 loss。

4.2 一个极简 Actor-Critic 实现

下面展示的是 Actor-Critic 模型定义和一个 step 的前向逻辑。和 REINFORCE 的区别在于:模型中多了一个v输出头,训练时用 TD error 更新 Actor,用 MSE loss 更新 Critic。

import torch import torch.nn as nn import torch.nn.functional as F from torch.distributions import Categorical class ActorCritic(nn.Module): def __init__(self, obs_dim, act_dim): super().__init__() self.fc = nn.Linear(obs_dim, 128) self.actor_head = nn.Linear(128, act_dim) self.critic_head = nn.Linear(128, 1) def forward(self, obs): x = F.relu(self.fc(obs)) probs = F.softmax(self.actor_head(x), dim=-1) value = self.critic_head(x) return probs, value

训练循环中关键的一步是计算 advantage 和两步 loss:

probs, value = model(obs_t) dist = Categorical(probs) action = dist.sample() log_prob = dist.log_prob(action) next_obs, reward, terminated, truncated, _ = env.step(action.item()) done = terminated or truncated with torch.no_grad(): _, next_value = model(next_obs_t) if not done else (None, torch.zeros(1)) td_target = reward + gamma * (1 - done) * next_value advantage = td_target - value.detach() actor_loss = -log_prob * advantage critic_loss = F.mse_loss(value.squeeze(), td_target.detach()) loss = actor_loss + critic_loss

注意next_valuetorch.no_grad()下计算,因为下一状态的价值只是用于构造 td_target,不需要对其求梯度。advantage使用value.detach(),保证 Actor loss 只更新策略网络参数,不会绕经 Critic 的价值预测路径。critic_loss里同样对td_targetdetach(),防止价值目标随训练而移动,导致价值网络追着自己的预测跑。

4.3 PPO 的 clip 机制,是稳定训练的关键

PPO 之所以被广泛使用,是因为它用一个简单的裁剪函数限制策略更新的幅度,让每个 batch 的更新不会因为某一步 advantage 特别大而把策略推得太远。公式里ratio表示新策略和旧策略在同一个状态下选中同一个动作的概率比,乘以 advantage 得到期望改进项,接下来做 clip。

ratio = (log_prob - old_log_prob).exp() surr1 = ratio * advantage surr2 = torch.clamp(ratio, 1.0 - clip_eps, 1.0 + clip_eps) * advantage actor_loss = -torch.min(surr1, surr2).mean()

clip_eps常用 0.2,意思是如果新旧策略概率比超过 1.2 或低于 0.8,就不再鼓励继续往那个方向更新。这样即使优势估计出错,策略更新也受到约束。配合old_log_prob.detach(),使旧概率作为一个固定参考点,不会因为同一批数据里的数次梯度更新而漂移。

4.4 更新策略时保留旧策略的必要性

PPO 的采样循环里,数据收集阶段用的是一份冻结的策略,通常叫 behavior policy;更新阶段用当前策略算log_prob,而old_log_prob是采集数据时保存下来的。如果只用一份log_prob既当新又当旧,ratio恒等于 1,clip 就完全失效。PyTorch 实现时要注意把old_log_prob单独保存,并在计算log_prob后及时detach()

5. 强化学习训练稳定性的检查清单与超参数速查

5.1 用 GAE 替换单步 TD,降低偏差与方差的冲突

单步 TD 用r + gamma * V(s')作为目标,偏差小但方差低,缺点是信息传递慢;Monte Carlo 用整条轨迹回报,偏差小但方差高。GAE 通过 lambda 参数在两者之间滑动,lambda 越接近 1 越像 Monte Carlo,越接近 0 越像单步 TD。常用 lambda 取 0.95。

def compute_gae(rewards, values, dones, gamma=0.99, lam=0.95): advantages = [] gae = 0 next_value = 0 for t in reversed(range(len(rewards))): delta = rewards[t] + gamma * next_value * (1 - dones[t]) - values[t] gae = delta + gamma * lam * (1 - dones[t]) * gae advantages.insert(0, gae) next_value = values[t] return advantages

这段代码里values[t]是第 t 步 Critic 对状态价值的预测,dones[t]用于在 episode 边界切断传播。逆序计算是 GAE 的标准写法,因为当前步的 gae 依赖于后一步的 gae。

5.2 常见算法在 CartPole 上的超参数速查

参数REINFORCEActor-CriticPPO
gamma0.990.990.99
lam不使用不使用0.95
学习率3e-31e-33e-4
隐藏层宽度128128128
熵系数000.01
clip_eps不使用不使用0.2

这个表不是铁律,但可以作为调参起点。REINFORCE 因方差大,可以适当调高学习率;PPO 的 clip 机制本身限制了更新幅度,学习率太高会表现为 loss 长时间不下降,太低则收敛缓慢。熵系数 0.01 的意义是给策略加一点随机性,防止它过早确定到某个次优动作序列上。

5.3 用日志判断训练是没收敛还是正常抖动

看训练日志时,先分辨“正常方差”和“发散”。CartPole 上策略梯度类算法前期奖励在 10 到 30 之间反复横跳完全正常,因为 episode 短、随机性大;如果 300 个 episode 后 reward 长期不超过 100,说明策略没有学到有效状态表示,优先检查学习率和熵系数。Critic loss 如果持续上升,往往是td_target没有detach(),或者价值网络的学习率过高。Entropy 如果骤降到接近 0,说明策略早就锁死,后面再多训练也是无效功。

把权重保存为.pt文件后,加载模型去掉训练模式,逐个状态手动推一遍看输出动作是否符合预期,是验证策略是否可用的最后一步。直接打印policy(obs)的输出分布,比单纯看 reward 曲线更能暴露“策略是否退化成了随机猜测”。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询