避坑指南:用PyTorch复现李宏毅RL作业中的Advantage Actor-Critic
最近在复现李宏毅老师课程中的强化学习作业,特别是Advantage Actor-Critic(A2C)算法时,踩了不少坑。网上很多教程和代码示例要么过于理论化,要么在关键细节上语焉不详,导致实际训练时效果天差地别。这篇文章,我想结合自己调试PyTorch代码的实战经验,分享几个最容易被忽视、但至关重要的“坑点”,并提供一套可直接运行的、经过验证的解决方案。目标不是复述理论,而是让你能真正把A2C跑起来,看到reward曲线稳步上升。
我们将聚焦于三个核心实践难题:如何设计有效的Baseline来大幅降低方差、Actor与Critic网络参数共享的代码实现技巧,以及在VizDoom这类复杂环境中如何设计Reward Shaping策略。我会提供带详细注释的Colab代码片段,并对比不同策略下的训练曲线,让你直观感受每一步调整带来的实际影响。
1. 环境搭建与算法核心思想再梳理
在动手写代码之前,我们需要对A2C(Advantage Actor-Critic)的核心思想有一个清晰、务实的理解。它本质上是对经典Policy Gradient(REINFORCE算法)的一次重大改进。Policy Gradient的直接想法是:如果一个动作带来了好的总回报(Return),就增加这个动作被选中的概率;反之则降低。但这里有个致命问题:方差(Variance)极高。因为总回报G受整个episode中大量随机因素(环境动态、策略随机性)影响,波动巨大,导致策略更新极其不稳定,训练过程像坐过山车。
A2C的妙处在于引入了Critic(价值函数)作为Baseline。它不再用原始的总回报G来评价动作,而是用 “优势函数(Advantage)”:A(s, a) = Q(s, a) - V(s)。其中:
Q(s, a): 在状态s下执行动作a后,能获得的期望回报。V(s): 在状态s下,遵循当前策略能获得的期望回报。
A(s, a)衡量的是在状态s下,执行特定动作a比遵循平均策略好多少。如果A为正,说明这个动作比“平均表现”更好,应该被鼓励;为负则反之。用V(s)作为Baseline(基准线)的好处是,它能抵消掉状态本身固有的“好运气”或“坏运气”,让更新信号更加集中在动作本身的相对优劣上,从而显著降低方差。
在实际实现中,我们常用TD误差(Temporal-Difference error)来近似估计优势函数:A(s, a) ≈ r + γ * V(s') - V(s)。这里的r是即时奖励,s'是下一个状态,γ是折扣因子。这个估计是无偏但低方差的,是A2C高效稳定的关键。
注意:理解“优势”的概念比记住公式更重要。你可以把它想象成考试中的“标准分”。你的原始分数(总回报G)受试卷难度(环境)影响很大,而标准分(优势A)则告诉你,相对于全班平均分(V(s)),你考得究竟如何。这样评价更公平,也更能指导你后续的学习(策略更新)。
为了后续代码演示,我们先准备好一个简单的环境。这里使用gym库中的CartPole-v1(倒立摆)作为示例环境,因为它足够轻量,能快速验证算法逻辑。
import gym
import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
from collections import deque
import matplotlib.pyplot as plt
# 创建环境
env = gym.make('CartPole-v1')
state_dim = env.observation_space.shape[0]
action_dim = env.action_space.n
# 设置随机种子,保证结果可复现
seed = 42
env.seed(seed)
torch.manual_seed(seed)
np.random.seed(seed)
2. 网络结构设计:共享参数与独立模块的权衡
网络结构是第一个容易踩坑的地方。Actor和Critic都需要观察状态s,并对其进行特征提取。一个直观的设计是为两者分别搭建独立的网络:
# 方案A:独立网络(初学者常见,但非最优)
class IndependentA2C(nn.Module):
def __init__(self, state_dim, action_dim):
super().__init__()
# Actor网络
self.actor_fc1 = nn.Linear(state_dim, 64)
self.actor_fc2 = nn.Linear(64, 64)
self.actor_out = nn.Linear(64, action_dim)
# Critic网络
self.critic_fc1 = nn.Linear(state_dim, 64)
self.critic_fc2 = nn.Linear(64, 64)
self.critic_out = nn.Linear(64, 1)
def forward(self, x):
# 前向传播需要分别计算
actor_x = torch.relu(self.actor_fc1(x))
actor_x = torch.relu(self.actor_fc2(actor_x))
logits = self.actor_out(actor_x)
critic_x = torch.relu(self.critic_fc1(x))
critic_x = torch.relu(self.critic_fc2(critic_x))
value = self.critic_out(critic_x)
return logits, value
这个方案看似清晰,但存在效率问题:Actor和Critic各自学习一套对状态的特征表示,这不仅是参数冗余,更可能导致两者对状态的理解不一致,影响优势函数的估计精度。
更优的方案是参数共享:让Actor和Critic共享底层的特征提取层,只在最后几层“分道扬镳”。这符合直觉——判断一个状态的价值(Critic)和决定在该状态下采取什么动作(Actor),基于的是对状态的同一套理解。共享参数能减少总参数量,加快训练,并提升稳定性。


584

被折叠的 条评论
为什么被折叠?



