五分钟入门强化学习Actor-Critic (演员-评论家)

五分钟入门强化学习Actor-Critic (演员-评论家) QAC的出生背景为什么需要QACQACQ Actor-CriticQ演员-评论家算法是强化学习从“回合制老学究”走向“单步在线进化”的分水岭。它的诞生是为了解决早期策略梯度算法Policy Gradient在面对现实世界复杂的长尾、连续任务时暴露出的致命缺陷。要说清它的出生背景我们需要看一看当时的强化学习正卡在怎样的两座大山之间。代码实现import gym # 导入 gym 库用于构建强化学习的物理交互环境 import torch # 导入 PyTorch 核心库用于张量计算和深度学习 import torch.nn.functional as F # 导入 PyTorch 的函数式接口如激活函数 relu、softmax损失函数 mse_loss import numpy as np # 导入 NumPy用于进行高效的数值计算 import matplotlib.pyplot as plt # 导入 Matplotlib用于训练结束后的数据可视化画图 import rl_utils # 导入自定义的强化学习工具包比如用来玩游戏、存数据的辅助函数 class PolicyNet(torch.nn.Module): 策略网络 (Actor 演员)负责根据看到的状态直接输出每个动作的概率 def __init__(self, state_dim, hidden_dim, action_dim): super(PolicyNet, self).__init__() # 继承并初始化 PyTorch 的 nn.Module 父类 self.fc1 torch.nn.Linear(state_dim, hidden_dim) # 定义第一层全连接层输入状态维度输出隐藏层维度 self.fc2 torch.nn.Linear(hidden_dim, action_dim) # 定义第二层全连接层输入隐藏层维度输出动作空间维度 def forward(self, x): x F.relu(self.fc1(x)) # 状态 x 输入第一层后经过 ReLU 激活函数过滤负数增加非线性拟合能力 # 经过第二层后使用 softmax 函数在维度 1动作维度上进行归一化 # 这样网络吐出来的就是所有动作的“概率分布”比如 [0.7, 0.2, 0.1]加起来等于 1 return F.softmax(self.fc2(x), dim1) class ValueNet(torch.nn.Module): 价值网络 (Critic 评论家)负责评价当前状态有多好输出一个标量打分 V(s) def __init__(self, state_dim, hidden_dim): super(ValueNet, self).__init__() # 继承并初始化 PyTorch 的 nn.Module 父类 self.fc1 torch.nn.Linear(state_dim, hidden_dim) # 定义第一层全连接层输入状态维度输出隐藏层维度 # 评论家不需要给每个动作打分它只评价“当前处境(状态)”好不好所以输出维度死死固定为 1一个标量分数 self.fc2 torch.nn.Linear(hidden_dim, 1) def forward(self, x): x F.relu(self.fc1(x)) # 状态 x 输入第一层后经过 ReLU 激活函数 return self.fc2(x) # 直接输出第二层的结果不需要任何激活函数因为分数可以是任意实数 class ActorCritic: Actor-Critic 算法核心大脑 def __init__(self, state_dim, hidden_dim, action_dim, actor_lr, critic_lr, gamma, device): # 实例化策略网络 (Actor)并将其搬运到指定的计算设备CPU 或 GPU上 self.actor PolicyNet(state_dim, hidden_dim, action_dim).to(device) # 实例化价值网络 (Critic)同样搬运到指定设备上 self.critic ValueNet(state_dim, hidden_dim).to(device) # 为 Actor 配置 Adam 优化器专门负责更新 Actor 脑子里的参数学习率为 actor_lr self.actor_optimizer torch.optim.Adam(self.actor.parameters(), lractor_lr) # 为 Critic 配置独立的 Adam 优化器专门负责更新 Critic 脑子里的参数学习率为 critic_lr self.critic_optimizer torch.optim.Adam(self.critic.parameters(), lrcritic_lr) self.gamma gamma # 保存折扣因子 gamma控制智能体对未来奖励的重视程度 self.device device # 保存当前使用的计算设备 def take_action(self, state): # 将环境传来的 numpy 格式的状态转换为 PyTorch 的 float 张量增加一个 batch 维度 [1, state_dim]并送入设备 state torch.tensor([state], dtypetorch.float).to(self.device) # 让 Actor 网络进行前向传播输出当前状态下各个动作的概率分布如 [0.8, 0.2] probs self.actor(state) # 根据算出来的概率分布构建一个类别分布生成器Categorical Distribution action_dist torch.distributions.Categorical(probs) # 根据概率进行“掷骰子”采样。概率越大的动作越容易被抽中但也保留了抽中小概率动作的可能这就是策略梯度的天然探索机制 action action_dist.sample() # 把抽中的动作张量转换成普通的 Python 标量数字如 0 或 1返回给环境去执行 return action.item() def update(self, transition_dict): # 这个方法用于单步/批量更新参数。下面 4 行将字典里的状态、动作、奖励、完成标志转为张量并送入设备 states torch.tensor(transition_dict[states], dtypetorch.float).to(self.device) # 用 view(-1, 1) 将一维动作序列重塑为列向量维度 [batch, 1]方便后续进行按列索引操作 actions torch.tensor(transition_dict[actions]).view(-1, 1).to(self.device) # 将奖励重塑为列向量 [batch, 1] rewards torch.tensor(transition_dict[rewards], dtypetorch.float).view(-1, 1).to(self.device) # 将下一状态转换为张量 [batch, state_dim] next_states torch.tensor(transition_dict[next_states], dtypetorch.float).to(self.device) # 将游戏结束标志重塑为列向量 [batch, 1]结束为 1.0未结束为 0.0 dones torch.tensor(transition_dict[dones], dtypetorch.float).view(-1, 1).to(self.device) # 【核心公式 1计算 TD 目标 (TD Target)】 # 真实的眼前奖励 衰减的未来期望分数由 Critic 预测下一状态得分。如果游戏结束(dones1)未来期望强制清零。 td_target rewards self.gamma * self.critic(next_states) * (1 - dones) # 【核心公式 2计算 TD 误差 (TD Delta / Advantage)】 # 实际拿到的目标分数 (td_target) 减去 之前瞎猜的分数 (critic(states))。 # 如果是正数说明这个动作比预期的好惊喜如果是负数说明比预期的差惊吓。 td_delta td_target - self.critic(states) # 【计算 Actor 的预测对数概率】 # self.actor(states) 输出所有动作的概率。gather(1, actions) 提取出真正执行过的那个动作的概率。 # torch.log() 对其求自然对数这是策略梯度推导公式中必不可少的一步。 log_probs torch.log(self.actor(states).gather(1, actions)) # 【核心公式 3计算 Actor 的损失 (Actor Loss)】 # -log_probs * td_delta 表示如果 TD 误差是正数惊喜就顺着梯度增大该动作概率如果是负数就减小概率。 # 为什么要加 .detach()因为 td_delta 里包含了 Critic 的参数我们更新 Actor 时绝不能连带着把 Critic 的参数也错误地改了 # 加负号是因为 PyTorch 默认是梯度下降求极小值而我们要让策略收益最大化梯度上升所以取负。 actor_loss torch.mean(-log_probs * td_delta.detach()) # 【核心公式 4计算 Critic 的损失 (Critic Loss)】 # Critic 的目标是让自己的打分越来越接近真实的 TD 目标。所以直接算它俩的 MSE 均方误差。 # td_target 作为一个给定的标准答案也必须 .detach() 锁定不参与反向传播。 critic_loss torch.mean(F.mse_loss(self.critic(states), td_target.detach())) # 清空 Actor 和 Critic 优化器中上一步残留的旧梯度信息 self.actor_optimizer.zero_grad() self.critic_optimizer.zero_grad() # 误差反向传播根据 actor_loss 自动计算出 Actor 网络里每一个神经元权重该怎么改求偏导 actor_loss.backward() # 误差反向传播根据 critic_loss 自动计算出 Critic 网络里每一个神经元权重该怎么改 critic_loss.backward() # 优化器执行真正的“刀斧手”操作顺着刚才算出来的梯度方向实质性地修改 Actor 网络的参数 self.actor_optimizer.step() # 实质性地修改 Critic 网络的参数 self.critic_optimizer.step()actor_loss的计算公式.detach()有什么作用为什么会产生这种作用一、.detach()的直接作用是什么用一句话概括.detach()是一把“剪刀”它的作用是强行切断 PyTorch 的“动态计算图”阻止梯度Gradient继续向回传导。当你在一个张量Tensor后面加上.detach()时它会生成一个数值上一模一样的新张量但这个新张量失去了记忆——它忘记了自己是怎么被算出来的不再和之前的神经网络参数有任何瓜葛。二、 为什么会产生这种作用底层逻辑解密之前我们聊过PyTorch 的核心魔法是“动态计算图”和“导火索”。当你执行A * B C时C 身上就挂了一根连着 A 和 B 的导火索。当你执行C.backward()时误差信号就会像火花一样顺着导火索烧回去修改 A 和 B 的参数。我们来看上一节代码中最经典的这一行Pythonactor_loss torch.mean(-log_probs * td_delta.detach())如果不加.detach()这根导火索是怎么连的呢log_probs是Actor 神经网络算出来的。td_delta是Critic 神经网络算出来的。它俩相乘得到了actor_loss。如果你不加.detach()直接执行actor_loss.backward()火花会顺着log_probs烧回去更新Actor的参数。这是我们想要的✅致命错误火花同时也会顺着td_delta烧回去跨界冲进Critic神经网络把 Critic 的参数也给改了这是灾难❌灾难的现实比喻Actor 是“演员”Critic 是“评委”。演员演得不好评委给他打了低分产生了 Loss。此时我们应该让演员去修改自己的演技。如果你不加.detach()相当于演员不仅修改了自己的演技还跑过去把评委的脑子给洗了强迫评委以后给他打高分这样下去评委的打分系统就彻底崩溃了。加上.detach()后td_delta变成了一个绝对客观的、冷冰冰的数字常量。火花烧到td_delta时发现路断了就只能乖乖地去更新 Actor 的参数。