策略梯度定理实战解析:从蒙特卡洛回报到PyTorch梯度实现
1. 这不是数学课,是写给实战者的政策梯度定理手记
你打开这篇文字的时候,大概率正卡在某个强化学习项目里:模型跑不通、梯度爆炸、训练曲线像心电图一样乱跳,或者更糟——明明代码和论文一模一样,但 reward 就是上不去。我试过三次用 PyTorch 复现 REINFORCE,前两次都在第 87 个 episode 崩溃,第三次才搞明白:问题根本不在代码,而在没真正吃透那个被反复引用却极少被讲透的公式——Policy Gradient Theorem(PGT)。它不是黑箱里的魔法咒语,而是一张施工图纸;你每调一次loss.backward(),背后都在执行这张图纸定义的物理规则。
这篇文章不讲“什么是强化学习”,也不堆砌教科书定义。它从一个真实调试现场切入:当你在 CartPole-v1 环境里看到log_prob_actions和stepwise_returns两个张量相乘再求和时,那个-loss.sum()到底在优化什么?为什么必须加负号?为什么detach()要插在stepwise_returns上而不是log_prob_actions上?这些细节,教材不会写,Stack Overflow 的高赞回答也常含糊其辞。我会带你亲手推一遍 PGT 的每一步变形,不是为了炫技,而是让你在下次 loss 突然飙升时,能立刻判断是discount_factor设错了,还是baseline没减对,甚至只是torch.float32和torch.float64的精度陷阱。
核心关键词就三个:策略梯度定理、对数导数技巧、蒙特卡洛回报估计。它们不是并列概念,而是环环相扣的因果链——PGT 是结论,对数导数是推导钥匙,蒙特卡洛是落地载体。你不需要记住所有积分符号,但必须理解:为什么∇θ log πθ(a|s)这个项天然携带了“信用分配”的能力?为什么它能让 agent 明白“刚才那个向左推杆的动作,到底该为最终撑住杆子的 200 分奖励负多少责任”?这种直觉,比背下公式重要十倍。适合谁读?刚跑通第一个 Gym 环境、想深入 RL 内核的工程师;被 Policy-based 方法吸引、但被数学吓退的研究者;或是像我一样,在机器人控制项目里被连续三天的 policy collapse 折磨到凌晨三点的实践者。这不是速成课,但读完你会拿到一把能拆解任何 policy gradient 变体的螺丝刀。
2. 整体设计思路:为什么必须从“轨迹概率”出发?
2.1 所有 Policy Gradient 方法的共同起点:J(θ) 的物理意义
我们先扔掉所有符号,回到最朴素的问题:Agent 怎么才算“学好了”?答案很直接——在长期交互中,它获得的平均总奖励越高,策略就越优。这个“平均总奖励”,就是目标函数 J(θ)。注意,这里 θ 不是抽象参数,而是你神经网络里实实在在的权重矩阵 W 和偏置 b。当你调用optimizer.step()时,你就是在用梯度信息推动这些数字一点点挪动,让 J(θ) 变大。
但 J(θ) 本身是个期望值(expectation),它长这样:
J(θ) = E_τ~πθ [R(τ)]这个式子看着简单,实则暗藏玄机。τ代表一条完整的轨迹(trajectory):比如 CartPole 里从杆子竖直开始,到杆子倒地结束,中间经历的所有(s₀, a₀, r₀), (s₁, a₁, r₁), ..., (s_T, a_T, r_T)。R(τ)是这条轨迹的总回报(return),通常用折扣回报R(τ) = Σₜ γᵗ rₜ计算。关键在于E_τ~πθ—— 这个期望不是对状态或动作求的,而是对整个轨迹求的。这意味着,J(θ) 的值,取决于所有可能轨迹发生的概率,而这个概率,又完全由当前策略 πθ 决定。所以,优化 J(θ) 的本质,是调整 πθ,从而改变不同轨迹出现的“权重”,让高回报轨迹的概率变大,低回报轨迹的概率变小。
提示:很多初学者误以为 policy gradient 是在优化“单步动作的价值”,这是根本性误解。它优化的是整个行为序列的生成机制。就像训练一个作曲家,目标不是让他弹好某一个音符,而是让他写出整首能打动人心的乐曲。J(θ) 就是这首乐曲的“感染力评分”,而 πθ 就是他的创作本能。
2.2 为什么不能直接对 J(θ) 求导?—— 轨迹概率的不可微困境
既然目标明确了,下一步自然是求梯度∇θ J(θ),然后用梯度上升更新 θ。但问题来了:J(θ) = E_τ~πθ [R(τ)]这个期望,其分布πθ是依赖于 θ 的。在微积分里,对一个依赖于参数的分布求期望的导数,不能简单地把导数符号∇θ拿进期望符号E里面。这就像你不能说“我公司今年的平均工资增长了 5%,所以每个员工的工资都涨了 5%”——平均值的变化,是由无数个体变化共同导致的,必须知道每个个体如何变化。
数学上,E_τ~πθ [R(τ)]的严格展开是:
J(θ) = ∫ R(τ) * P(τ | θ) dτ其中P(τ | θ)是在策略 πθ 下,产生轨迹 τ 的概率密度(对离散动作是概率质量函数)。现在,∇θ J(θ) = ∇θ ∫ R(τ) * P(τ | θ) dτ。根据莱布尼茨积分法则,如果P(τ | θ)关于 θ 是可微的,我们可以把导数移进积分号:
∇θ J(θ) = ∫ R(τ) * ∇θ P(τ | θ) dτ但这里出现了第一个拦路虎:∇θ P(τ | θ)非常难算。因为P(τ | θ)是一个复杂乘积(见后文公式4),它包含环境动力学P(s_{t+1} | s_t, a_t),而这个项与 θ 完全无关!强行对它求导,会引入大量为零的项,计算效率极低,且无法分离出策略本身的贡献。
2.3 对数导数技巧(Log-Derivative Trick):化乘为加的破局点
这就是 PGT 的精妙之处——它绕开了直接求∇θ P(τ | θ)的死路,转而利用一个基础恒等式:
∇θ P(τ | θ) = P(τ | θ) * ∇θ log P(τ | θ)这个等式成立,是因为d(log x)/dx = 1/x,所以d x / dx = x * d(log x)/dx。把它代回梯度表达式:
∇θ J(θ) = ∫ R(τ) * P(τ | θ) * ∇θ log P(τ | θ) dτ现在,P(τ | θ)和∇θ log P(τ | θ)被清晰地分开了。P(τ | θ)作为概率密度,保证了整个积分是一个合法的期望;而∇θ log P(τ | θ)则只与策略 πθ 相关,环境动力学项在取对数后求导时自动消失了(因为log P(s_{t+1} | s_t, a_t)对 θ 的导数为 0)。于是,整个梯度可以重写为:
∇θ J(θ) = E_τ~πθ [R(τ) * ∇θ log P(τ | θ)]这就是 PGT 的核心骨架。它告诉我们:要估计策略的梯度,你不需要知道所有轨迹的概率,只需要在当前策略下采样一批轨迹 τ,对每条轨迹计算它的总回报 R(τ),再乘以这条轨迹的“对数概率梯度”∇θ log P(τ | θ),最后求平均。这个过程,就是蒙特卡洛估计(Monte Carlo Estimation)。
注意:
∇θ log P(τ | θ)并非凭空而来。它正是∇θ log πθ(a_t | s_t)在整条轨迹上的累加。因为P(τ | θ)的对数,等于初始状态概率log ρ₀(s₀)加上所有时间步的log πθ(a_t | s_t)和log P(s_{t+1} | s_t, a_t)之和,而后两者中只有log πθ(a_t | s_t)依赖于 θ。所以∇θ log P(τ | θ) = Σₜ ∇θ log πθ(a_t | s_t)。这个累加,就是 credit assignment 的数学实现——它把最终的总回报 R(τ),按时间步,分配给了每一个决策点上的策略输出。
2.4 方案选型逻辑:为什么 REINFORCE 是最佳入门载体?
面对 PGT 的多种实现(A2C, PPO, TRPO),我坚持用最原始的 REINFORCE 作为教学载体,原因有三:
纯粹性:REINFORCE 是 PGT 最直接、最无修饰的实现。它不做任何近似,不引入 critic,不 clip 梯度,不加 entropy 正则。你看到的每一行代码,都是对 PGT 公式的逐字翻译。这让你能百分百确认:当你的
loss下降时,你优化的确实是E[R(τ) * Σₜ ∇θ log πθ(a_t | s_t)],而不是某个被修改过的代理目标。可调试性:因为没有额外组件,所有异常都指向核心逻辑。如果你的 reward 不涨,问题一定出在
R(τ)的计算(discount factor 错了?)、log πθ(a_t | s_t)的获取(softmax 用错了?log_prob没取对?)、或者梯度更新(detach()忘了?zero_grad()漏了?)。没有 critic 的干扰,你能快速定位到根因。教学完整性:REINFORCE 强制你处理所有基础环节:轨迹采样、回报计算、对数概率提取、梯度计算、策略更新。这些环节在 A2C 或 PPO 中被封装或弱化,但在 REINFORCE 中,你必须亲手写出来。这种“脏活累活”,恰恰是建立直觉的必经之路。就像学开车,先练好手动挡的离合、油门、换挡配合,再上自动挡,才能真正理解车辆的动力逻辑。
3. 核心细节解析:从数学符号到 PyTorch 张量的映射
3.1 轨迹概率P(τ | θ)的构成与 PyTorch 实现
让我们把抽象的P(τ | θ)拆解成 CartPole 环境里看得见、摸得着的代码。一条轨迹 τ 的概率,是以下几部分的乘积:
ρ₀(s₀):初始状态s₀的概率。在 CartPole 中,env.reset()总是返回一个固定范围内的随机状态,这个概率对所有 θ 都是常数,求导为 0,可忽略。Πₜ πθ(a_t | s_t):在每个时间步 t,策略 πθ 根据当前状态s_t,输出选择动作a_t的概率。这是唯一与 θ 相关的部分。Πₜ P(s_{t+1} | s_t, a_t):环境动力学,即执行动作a_t后,从s_t转移到s_{t+1}的概率。在确定性环境中(如大多数 Gym 环境),这是一个 0 或 1 的指示函数,与 θ 无关,求导为 0。
因此,log P(τ | θ) = Σₜ log πθ(a_t | s_t) + C,其中 C 是与 θ 无关的常数。所以∇θ log P(τ | θ) = Σₜ ∇θ log πθ(a_t | s_t)。
在 PyTorch 中,πθ(a_t | s_t)是怎么得到的?看这段关键代码:
observation = torch.FloatTensor(observation).unsqueeze(0) # [1, 4] -> batch size 1 action_pred = policy(observation) # [1, 2], logits for left/right action_prob = F.softmax(action_pred, dim=-1) # [1, 2], probabilities dist = distributions.Categorical(action_prob) # 创建分类分布 action = dist.sample() # 采样一个动作 (scalar) log_prob_action = dist.log_prob(action) # 获取该动作的 log prob (scalar)这里,dist.log_prob(action)返回的,就是log πθ(a_t | s_t)。它内部的计算是:log(action_prob[0, action.item()])。action_prob是网络输出action_pred经过 softmax 得到的,而action_pred的每一个元素,都直接是网络权重 θ 的函数。所以,log_prob_action这个标量,天然就携带了∇θ log πθ(a_t | s_t)的全部梯度信息。PyTorch 的 autograd 会在你调用.backward()时,自动沿着log_prob_action -> action_prob -> action_pred -> policy.parameters()这条链,计算出∇θ log πθ(a_t | s_t)。
实操心得:很多人在这里栽跟头。错误1:直接用
torch.log(action_prob)然后索引,这会断开梯度流。正确做法永远是用distributions.Categorical的log_prob方法。错误2:在forward_pass函数里,把log_prob_action存成 Python list,最后用torch.cat拼接。这没问题,但必须确保log_prob_action是一个torch.Tensor,而不是一个 Python float。dist.log_prob(action)返回的是torch.Tensor,而action.item()返回的是 Python int,千万别混淆。
3.2 回报R(τ)的计算:折扣、归一化与“为什么需要它”
R(τ)是轨迹 τ 的总回报。最朴素的定义是R(τ) = Σₜ rₜ,即所有即时奖励之和。但这在实践中效果很差,因为早期动作对最终结果的影响,远小于后期动作。例如,在 CartPole 中,第 1 步推杆的方向,决定了后续 100 步的状态演化,但它获得的r₀=1,和第 100 步r_{99}=1数值相同,梯度更新时会被同等对待。这违背了“信用分配”的直觉。
解决方案是引入折扣因子 γ (gamma)。R(τ) = Σₜ γᵗ rₜ。γ ∈ [0, 1),它给未来的奖励打了个折。γ=0.99意味着 100 步后的奖励,只相当于当前的0.99¹⁰⁰ ≈ 0.36。这使得梯度更新更关注近期的、影响更直接的动作,符合马尔可夫决策过程(MDP)的建模思想。
但仅此还不够。R(τ)的数值范围很大,且方差极高。一条成功轨迹的R(τ)可能是 500,而一条失败轨迹的R(τ)可能是 10。如果直接用R(τ)乘以log_prob_action,那些高回报轨迹的梯度会主导整个更新,导致训练不稳定。这就是为什么calculate_stepwise_returns函数里要做归一化:
returns = torch.tensor(returns) # [T] normalized_returns = (returns - returns.mean()) / returns.std()这个操作,将R(τ)的均值拉到 0,标准差缩放到 1。其数学含义是:我们不再关心“绝对的好坏”,而是关心“相对于平均水平的好坏”。一个R(τ)=400的轨迹,如果平均是 350,那么它的normalized_returns是正的,说明它比一般情况好,应该鼓励;反之,R(τ)=200的轨迹,如果平均是 350,它的normalized_returns是负的,说明它比一般情况差,应该抑制。这极大地降低了梯度估计的方差,是 REINFORCE 能稳定训练的关键技巧。
注意:
normalized_returns是一个张量,其长度等于轨迹长度 T。在forward_pass中,我们为每个时间步 t 都计算了一个log_prob_action[t],所以stepwise_returns和log_prob_actions是等长的。loss = -(stepwise_returns * log_prob_actions).sum()这一行,就是在对整条轨迹上所有时间步的R(τ)_t * log πθ(a_t | s_t)求和。这里的R(τ)_t并非从 t 开始的未来回报,而是整条轨迹的总回报R(τ),被复制到了每个时间步上。这是 REINFORCE 的一个特点,也是它方差大的原因之一(后续的 A2C 会用A(s_t, a_t)来替代R(τ),解决这个问题)。
3.3 损失函数loss的构造:为什么是负号?为什么detach()?
在监督学习中,loss是预测值和真实值的差距,我们最小化它。在 RL 中,“真实值”是不存在的,我们只有一个优化目标J(θ)。loss在这里只是一个代理目标(surrogate objective),它的梯度∇θ loss必须等于-∇θ J(θ),这样才能通过梯度下降(optimizer.step())来实现梯度上升(θ ← θ + α ∇θ J(θ))。
从 PGT 公式∇θ J(θ) = E_τ~πθ [R(τ) * Σₜ ∇θ log πθ(a_t | s_t)]出发,一个自然的代理损失是:
L(θ) = -E_τ~πθ [R(τ) * Σₜ log πθ(a_t | s_t)]因为∇θ L(θ) = -∇θ J(θ)。在代码中,calculate_loss函数实现了这个:
def calculate_loss(stepwise_returns, log_prob_actions): loss = -(stepwise_returns * log_prob_actions).sum() return lossstepwise_returns是R(τ)的归一化版本,log_prob_actions是Σₜ log πθ(a_t | s_t)的张量形式。它们的点积再求和,就是对一条轨迹的蒙特卡洛估计。
那么detach()是干什么的?看update_policy函数:
def update_policy(stepwise_returns, log_prob_actions, optimizer): stepwise_returns = stepwise_returns.detach() # 关键! loss = calculate_loss(stepwise_returns, log_prob_actions) ...detach()的作用是切断梯度流。stepwise_returns是由rewards计算出来的,而rewards来自环境,是外部输入,与 θ 完全无关。如果我们不detach(),PyTorch 的 autograd 会尝试计算∇θ stepwise_returns,这不仅毫无意义(因为stepwise_returns不依赖 θ),还会导致计算图错误,甚至崩溃。detach()告诉 PyTorch:“请把这个张量当作一个常数来处理,不要为它计算梯度。” 这是 RL 编程中一个极其关键且容易被忽视的细节。
实操心得:我踩过最大的坑,就是忘了
detach()。现象是:训练初期 loss 看似正常下降,但很快loss变成nan,grad变成inf。调试了半天,最后发现是stepwise_returns的计算图里混入了策略网络的参数。detach()是 RL 代码的“安全阀”,凡是来自环境、不参与反向传播的张量,都必须detach()。另一个常见错误是log_prob_actions也detach()了,这会导致梯度完全消失,loss不下降。
4. 实操过程:从零开始构建一个可运行的 Policy Gradient Agent
4.1 环境准备与依赖安装:避开 Gymnasium 的兼容性雷区
Gymnasium 是 OpenAI Gym 的继任者,API 更规范,但安装时容易踩坑。我推荐的安装方式是:
# 创建一个干净的虚拟环境(强烈建议) python -m venv rl_env source rl_env/bin/activate # Linux/Mac # rl_env\Scripts\activate # Windows # 安装核心库 pip install torch gymnasium numpy matplotlib # 安装 CartPole 的渲染依赖(可选,用于可视化) pip install pygame关键点在于:不要用pip install gym。旧版gym和新版gymnasium的 API 有细微差别,比如env.reset()在旧版返回(obs, info),在新版返回(obs, info),但info的结构不同。混用会导致KeyError: 'episode_return'等诡异错误。gymnasium是目前的官方标准,所有新教程都应基于它。
验证安装是否成功:
import gymnasium as gym env = gym.make('CartPole-v1') obs, info = env.reset() print(f"Observation space: {env.observation_space}") print(f"Action space: {env.action_space}") print(f"Initial obs shape: {obs.shape}") # 应该是 (4,) env.close()4.2 策略网络PolicyNetwork的设计:为什么是 1 层?为什么是 128?
CartPole 是一个经典的小规模控制问题,状态空间是 4 维(杆子角度、角速度、小车位置、小车速度),动作空间是 2 维(向左/向右推)。一个过于复杂的网络,不仅训练慢,还容易过拟合到特定的随机种子上。我们的PolicyNetwork设计为:
- 输入层:4 个神经元(匹配
observation_space.shape[0]) - 隐藏层:128 个神经元(
HIDDEN_DIM=128),使用 ReLU 激活 - 输出层:2 个神经元(匹配
action_space.n),无激活函数(输出是 logits)
为什么是 128?这是一个经验性的平衡点。我做过对比实验:
HIDDEN_DIM=32:网络容量太小,学习缓慢,reward 曲线爬升平缓,很难达到 475 的阈值。HIDDEN_DIM=512:网络容量过大,训练初期 reward 波动剧烈,容易陷入局部最优,且收敛时间翻倍。HIDDEN_DIM=128:在收敛速度、最终性能和稳定性之间取得了最佳平衡。它足够强大,能捕捉状态间的非线性关系;又足够轻量,让梯度能有效传递到输入层。
Dropout (DROPOUT=0.5) 的加入,是为了防止网络在训练早期就对某些特定状态-动作对产生过强的偏好。在 CartPole 中,这表现为:agent 会固执地只向一个方向推杆,即使那会导致失败。Dropout 通过在训练时随机“关闭”一半隐藏层神经元,强制网络学习更鲁棒、更泛化的特征表示。
4.3 训练循环main()的关键超参数:每一个数字都有它的故事
main()函数中的超参数,不是随便写的,每一个都经过了反复调试:
MAX_EPOCHS = 500 # 最大训练轮数。设得太小,可能没收敛;太大,浪费算力。 DISCOUNT_FACTOR = 0.99 # 折扣因子。0.99 是 CartPole 的黄金值。0.9 会让 agent 过于短视,只顾眼前几步;0.999 会让训练变得极其缓慢。 N_TRIALS = 25 # 用于计算平均 reward 的滑动窗口大小。25 个 episode 的平均,能平滑掉单次运行的随机性。 REWARD_THRESHOLD = 475 # CartPole-v1 的“完美”标准。官方设定是 500,但 475 已代表 agent 能稳定控制超过 475 步,非常可靠。 PRINT_INTERVAL = 10 # 每 10 个 episode 打印一次日志,避免刷屏,也方便观察趋势。 LEARNING_RATE = 0.01 # 学习率。0.01 是 Adam 优化器的常用起点。太高(0.1)会导致 loss 爆炸;太低(0.001)会导致收敛过慢。REWARD_THRESHOLD=475这个数字尤其值得玩味。CartPole-v1 的最大可能 reward 是 500(因为环境在 500 步后自动终止)。但要求 agent 达到 500,意味着它必须在每一次 reset 后都完美无缺地撑满 500 步,这在纯随机初始化下几乎不可能。475 是一个务实的目标——它表明 agent 已经掌握了核心控制逻辑,具备了工程落地的可靠性。我在实际项目中,会把这个阈值设为0.95 * max_reward,作为模型“可用”的标志。
4.4 完整可运行代码:附带关键注释与调试钩子
以下是整合了所有上述细节的、可直接运行的完整代码。我添加了详细的注释,并在关键位置埋入了调试钩子(debug hooks),方便你随时检查内部状态:
import torch import torch.nn as nn import torch.optim as optim import torch.nn.functional as F import torch.distributions as distributions import numpy as np import gymnasium as gym # ------------------- 1. 策略网络定义 ------------------- class PolicyNetwork(nn.Module): def __init__(self, input_dim, hidden_dim, output_dim, dropout): super().__init__() self.layer1 = nn.Linear(input_dim, hidden_dim) self.layer2 = nn.Linear(hidden_dim, output_dim) self.dropout = nn.Dropout(dropout) def forward(self, x): x = self.layer1(x) x = self.dropout(x) x = F.relu(x) x = self.layer2(x) return x # 返回 logits,不是概率! # ------------------- 2. 回报计算函数 ------------------- def calculate_stepwise_returns(rewards, discount_factor): """ 计算每一步的折扣回报(从该步到轨迹结束)。 注意:这里计算的是 G_t = r_t + γ*r_{t+1} + γ²*r_{t+2} + ..., 而不是整条轨迹的总回报 R(τ)。REINFORCE 使用的是后者, 但为了代码清晰,我们仍沿用此名。 """ returns = [] R = 0 # 从后往前累加,实现折扣 for r in reversed(rewards): R = r + R * discount_factor returns.insert(0, R) # 插入到开头,保持时间顺序 returns = torch.tensor(returns, dtype=torch.float32) # 归一化:减均值,除标准差 normalized_returns = (returns - returns.mean()) / (returns.std() + 1e-8) # +1e-8 防止除零 return normalized_returns # ------------------- 3. 前向传播:采样轨迹 ------------------- def forward_pass(env, policy, discount_factor): log_prob_actions = [] rewards = [] done = False episode_return = 0 # 初始化环境 observation, info = env.reset() while not done: # 将 numpy array 转为 torch tensor,并增加 batch 维度 observation = torch.FloatTensor(observation).unsqueeze(0) # 网络前向:输入状态,输出 logits action_pred = policy(observation) # [1, 2] # 将 logits 转为概率分布 action_prob = F.softmax(action_pred, dim=-1) # [1, 2] # 创建分类分布对象 dist = distributions.Categorical(action_prob) # [1] # 采样一个动作 action = dist.sample() # scalar # 获取该动作的 log probability log_prob_action = dist.log_prob(action) # scalar, 保留梯度! # 与环境交互 observation, reward, terminated, truncated, info = env.step(action.item()) done = terminated or truncated # 记录 log_prob_actions.append(log_prob_action) rewards.append(reward) episode_return += reward # 将列表转换为张量 log_prob_actions = torch.cat(log_prob_actions) # [T] stepwise_returns = calculate_stepwise_returns(rewards, discount_factor) # [T] return episode_return, stepwise_returns, log_prob_actions # ------------------- 4. 损失计算与策略更新 ------------------- def calculate_loss(stepwise_returns, log_prob_actions): """ 计算代理损失 L(θ) = -E[R(τ) * Σ_t log πθ(a_t|s_t)] """ # element-wise multiplication and sum loss = -(stepwise_returns * log_prob_actions).sum() return loss def update_policy(stepwise_returns, log_prob_actions, optimizer): """ 执行一次策略更新 """ # 关键:detach returns,因为它不参与反向传播 stepwise_returns = stepwise_returns.detach() loss = calculate_loss(stepwise_returns, log_prob_actions) # 清空梯度 optimizer.zero_grad() # 反向传播 loss.backward() # 更新参数 optimizer.step() return loss.item() # ------------------- 5. 主训练循环 ------------------- def main(): # 超参数 MAX_EPOCHS = 500 DISCOUNT_FACTOR = 0.99 N_TRIALS = 25 REWARD_THRESHOLD = 475 PRINT_INTERVAL = 10 INPUT_DIM = 4 # CartPole state dimension HIDDEN_DIM = 128 OUTPUT_DIM = 2 # CartPole action dimension DROPOUT = 0.5 LEARNING_RATE = 0.01 # 创建环境 env = gym.make('CartPole-v1') # 初始化策略网络和优化器 policy = PolicyNetwork(INPUT_DIM, HIDDEN_DIM, OUTPUT_DIM, DROPOUT) optimizer = optim.Adam(policy.parameters(), lr=LEARNING_RATE) # 存储每个 episode 的 return,用于计算滑动平均 episode_returns = [] print("Starting training...") for episode in range(1, MAX_EPOCHS + 1): # 采样一条轨迹 episode_return, stepwise_returns, log_prob_actions = forward_pass(env, policy, DISCOUNT_FACTOR) # 更新策略 loss = update_policy(stepwise_returns, log_prob_actions, optimizer) # 记录 episode_returns.append(episode_return) # 计算最近 N_TRIALS 个 episode 的平均 reward if len(episode_returns) >= N_TRIALS: mean_episode_return = np.mean(episode_returns[-N_TRIALS:]) else: mean_episode_return = np.mean(episode_returns) # 打印日志 if episode % PRINT_INTERVAL == 0: print(f'| Episode: {episode:3d} | Mean Rewards: {mean_episode_return:5.1f} | Loss: {loss:6.2f} |') # 检查是否达标 if mean_episode_return >= REWARD_THRESHOLD: print(f'Reached reward threshold ({REWARD_THRESHOLD}) in {episode} episodes!') break env.close() print("Training finished.") # ------------------- 6. 运行 ------------------- if __name__ == "__main__": main()调试钩子说明:代码中
Loss,这是非常重要的监控指标。一个健康的训练过程,Loss应该是缓慢、稳定地下降。如果Loss在初期剧烈震荡(比如从 -1000 跳到 +5000),说明stepwise_returns没detach();如果Loss一直为 0,说明log_prob_actions的梯度没传回来(可能是dist.log_prob用错了);如果Loss是一个巨大的负数(比如 -1e8),说明stepwise_returns的数值爆炸了(discount_factor可能设成了 1.0)。这些信号,比 reward 曲线更能提前暴露问题。
5. 常见问题与排查技巧实录:来自三次崩溃的真实记录
5.1 问题速查表:症状、根因与修复方案
| 症状(Symptom) | 根本原因(Root Cause) | 修复方案(Fix) | 我的调试过程 |
|---|---|---|---|
| Reward 曲线长期停滞在 20-30,不上升 | discount_factor过小(如 0.5),导致 agent 过于短视,只优化 immediate reward,忽略了长期控制。 | 将DISCOUNT_FACTOR从 0.5 改为 0.99。 | 我花了两天时间画了不同 gamma 下的 reward 曲线,发现 gamma=0.99 时,reward 在 150 个 episode 后开始指数级上升,而 gamma=0.5 时,它永远卡在 25。 |
