1. 从“单打独斗”到“黄金搭档”为什么我们需要Actor-Critic如果你玩过一些需要反复尝试才能通关的游戏比如《只狼》或者《蔚蓝》你大概能理解强化学习智能体的处境面对一个未知的环境它需要不断试错从失败中学习最终找到最优的通关策略。在强化学习的早期研究者们主要靠两种“单打独斗”的思路来训练智能体但它们各有各的“脾气”也各有各的短板。第一种思路叫基于价值Value-based的方法。你可以把它想象成一个精于计算的“会计”。它的核心任务是学习一张“价值地图”这张地图上标注了在每一个“位置”状态下选择每一个“操作”动作未来能获得的总收益Q值。DQN就是这种方法的明星代表。这个“会计”非常稳健学习过程相对平滑因为它总是在估算一个具体的数值。但它有个死穴它只擅长处理“选择题”。当动作空间是离散的、有限的比如游戏里的“上下左右A键B键”它能很好地工作。可一旦遇到“填空题”或者“操作题”比如控制机器人手臂的精确角度一个连续值或者调整无人机的飞行姿态它就傻眼了。你没法为每一个可能的、无限细分的角度都计算一个Q值。第二种思路叫基于策略Policy-based的方法。这更像是一个凭感觉行事的“艺术家”。它不关心具体动作值多少钱而是直接学习一套“行为准则”策略。给定一个状态它输出的是一个动作的概率分布比如“在当前画面下70%的概率向左转30%的概率向右转”。策略梯度Policy Gradient是它的典型算法。这位“艺术家”的优点是极其灵活无论是离散动作还是连续动作它都能通过概率分布来建模天生适合精细控制。但它的毛病也很明显学习过程就像坐过山车方差极大非常不稳定。因为它的更新依赖于一整局游戏一个回合结束后的总奖励如果某次运气好得了高分它就会盲目地提高这一局中所有动作的概率哪怕有些动作其实是瞎蒙的。这种“靠天吃饭”的学习方式效率可想而知。于是一个很自然的想法就诞生了能不能让“会计”和“艺术家”组个队取长补短呢让“艺术家”Actor演员负责天马行空地尝试动作让“会计”Critic评论家在旁边实时计算、评估动作的好坏并给出专业的财务建议。这就是Actor-Critic算法的核心思想。它不再是单打独斗而是构建了一个“双网络协作”的系统。Actor负责探索和决策Critic负责评估和指导两者在交互中相互促进共同进化。这种架构不仅继承了策略方法处理连续动作的能力还借助价值方法的稳定评估大幅降低了学习方差让智能体学得更快、更稳。我刚开始接触这个算法时感觉就像给一个莽撞的探险家配了一个经验丰富的老向导一个敢闯一个会看组合起来探索新世界的效率瞬间翻倍。2. 拆解双核Actor与Critic究竟在干什么要理解这对黄金搭档如何工作我们得先抛开代码用最生活化的场景把它们的角色和职责掰扯清楚。很多人一开始会被两个神经网络相似的代码结构搞糊涂觉得它们差不多。其实它们的“大脑”结构可能相似但“思维方式”和“本职工作”天差地别。2.1 Actor演员那个在台上做动作的家伙想象一下你正在教一个从没开过门的小机器人开门。Actor就是这个机器人本身。它的核心是一个策略函数通常用一个神经网络来实现。这个网络吃进去的是当前状态State比如机器人摄像头看到的门把手图像、传感器检测到的距离。然后它经过内部计算输出的是一个动作的概率分布。比如对于“开门”这个任务可能的动作有“推”、“拉”、“拧”。经过Actor网络计算它可能会输出{“推”: 0.2, “拉”: 0.7, “拧”: 0.1}。这意味着根据它当前学到的“经验”在这个状态下选择“拉”这个动作的成功率或者说预期收益最高所以给了70%的概率。接下来它就会按照这个概率分布随机采样一个动作来执行。注意是采样不是永远选概率最高的这个“随机性”至关重要它保证了智能体有一定的探索能力不会过早地陷入局部最优。如果一开始就总是选概率最高的它可能永远发现不了“拧”门把手才是开门的正确方式。所以Actor的职责非常纯粹感知状态生成动作。它不判断这个动作好不好它只负责“做”。就像一个舞台上的演员拿到剧本状态后根据自己的理解策略网络表演出一个具体的动作。2.2 Critic评论家台下那个举着打分牌的评委现在机器人Actor执行了“拉”的动作。门可能被拉开了成功也可能纹丝不动失败。环境会立刻给出一个即时奖励Reward比如成功10分失败0分并且环境会进入一个新的状态Next State比如“门开了”或者“门没开但晃了一下”。这时Critic就该登场了。Critic的核心是一个价值函数同样通常用另一个神经网络实现。它的任务是评估。它评估什么呢主要有两种视角评估状态的价值输入一个状态s输出一个标量值V(s)。这个值代表的是“从状态s出发遵循当前Actor的策略一直玩下去预期能获得的总奖励是多少” 你可以把它理解为这个状态的“潜在得分”。评估状态-动作对的价值输入状态s和动作a输出一个标量值Q(s, a)。这个值代表的是“在状态s下执行动作a然后继续遵循当前策略预期能获得的总奖励是多少” 这直接评价了某个具体动作的优劣。在实际的Actor-Critic算法中更常用的是第一种即状态价值函数V(s)。Critic就像一个苛刻的评论家在演员每做一个动作后不是简单地看即时奖励那太短视了而是结合新状态快速地重新评估一下“嘿你做了这个动作之后整个局面的未来价值是变好了还是变差了”2.3 核心差异一张表看清本质虽然Actor网络和Critic网络的代码可能长得像都是几层全连接层加激活函数但它们的本质目标截然不同。我刚开始实现时就因为没分清这点把两个网络的损失函数搞混了训练结果一塌糊涂。下面这个表格能帮你彻底理清对比项PolicyNet (Actor)ValueNet (Critic)核心目标输出动作概率分布直接指导“选什么动作”。输出状态价值估计评估“当前状态有多好”。输出处理最后一层用Softmax确保所有动作概率之和为1。例如[0.2, 0.7, 0.1]。最后一层直接输出一个标量实数无需Softmax。例如5.34。训练依据依靠Critic提供的评价信号如TD误差来更新。目标是让高评价动作的概率升高。依靠环境反馈的实际奖励与自身预测的误差来更新。目标是让价值预测更准确。对决策的影响直接。它的输出就是动作采样依据直接决定了智能体下一步做什么。间接。它不直接决定动作而是通过评价来“引导”Actor调整策略告诉Actor哪些方向更有前途。类比舞台演员。负责执行具体的表演动作。场边教练/评委。不亲自表演但观察局面给演员的表演打分和提建议。简单来说Actor是“执行者”Critic是“评估者”。一个在台前一个在幕后。一个靠直觉和策略行事一个靠数据和计算说话。理解这个根本区别是掌握Actor-Critic算法的第一步。3. 协作的灵魂TD误差如何让112Actor和Critic不是各干各的它们通过一个叫做TD误差Temporal-Difference Error时序差分误差的纽带紧密协作。这是整个算法高效的关键也是我认为最精妙的设计。理解了TD误差你就抓住了Actor-Critic的魂。3.1 什么是TD误差一个炒股比喻我们先用一个炒股的例子来感受一下。假设你是一位股票分析师Critic你需要预测某只股票明天的价格状态价值。你昨天预测它今天收盘价是100元V(s)对旧状态的预测。今天它实际收盘价是105元并且你根据今天的新闻又预测它明天能涨到108元V(s‘)对新状态的预测。今天这只股票没有分红即时奖励r0。那么你昨天的预测准不准呢TD误差会这样算δ (实际观察到的结果) - (昨天的预测)。这里的“实际观察到的结果”不是简单的今天收盘价而是今天的收盘价 你对明天的预测的折扣值。假设我们给未来的预测打9折折扣因子γ0.9那么实际观察到的结果就是105 0.9 * 108 105 97.2 202.2。而你昨天的预测是100。所以TD误差δ 202.2 - 100 102.2。这个巨大的正误差告诉你你昨天严重低估了这只股票的价值你的预测模型需要立刻调整向上修正。把这个类比套回我们的机器人开门问题s机器人看到门关着的状态。a机器人执行“拉”的动作。r门没开即时奖励为0。s‘门依然关着的新状态。V(s)Critic之前预测“门关着”这个状态价值不高比如是1.0。V(s‘)Critic现在预测“门依然关着”这个新状态价值也很低比如是0.8。γ折扣因子比如0.95表示未来奖励不如眼前奖励值钱。那么TD误差δ r γ * V(s‘) - V(s) 0 0.95*0.8 - 1.0 0.76 - 1.0 -0.24。这个负的TD误差意味着实际的结果0.76比你之前的预测1.0还要差。这说明“拉”这个动作并没有带来预期的好结果。3.2 TD误差如何驱动双网络更新这个小小的δ成为了指挥两个网络更新的“总指挥棒”。对于Critic评论家它的目标就是让自己的预测尽可能准。TD误差δ就是它预测的“偏差”。所以Critic的更新目标非常直接最小化这个误差的平方。这通过一个标准的梯度下降就能完成。Loss_critic (δ)^2。每次都用δ来修正自己的价值网络参数让它下次预测得更准。这就像那个股票分析师每次预测失误后都修正自己的模型。对于Actor演员它的目标是最大化长期收益。但它自己不知道动作的好坏这时Critic提供的δ就成了一个优势Advantage信号。我们可以近似地认为δ ≈ A(s, a)即动作a相对于平均水平的优势。如果δ 0说明这个动作带来的实际结果好于预期。这是一个“好动作”Actor应该增加在未来遇到类似状态时选择这个动作的概率。如果δ 0说明这个动作带来的实际结果差于预期。这是一个“坏动作”Actor应该降低选择它的概率。Actor的更新公式体现了这个思想∇J(θ) ≈ δ * ∇ log π(a|s; θ)。这里∇ log π(a|s; θ)是所选动作概率的对数的梯度它指明了网络参数该如何微调才能提高当前动作的概率。δ作为系数决定了这个调整的幅度和方向。正δ就正向调整提高概率负δ就负向调整降低概率。δ的绝对值越大说明这个动作“出乎意料”的好或坏调整的力度也就越大。这个过程是在线、增量式发生的。每执行一个动作得到一次反馈r, s‘就立刻计算δ然后同时更新Actor和Critic。这比起必须等一整局游戏结束才能更新的纯策略梯度方法学习信号要及时得多效率自然也高得多。我常常觉得这就像学开车时副驾驶的教练Critic在你每次转弯、每次刹车后都立刻点评一句“方向打早了”或“刹车太猛”而不是等你开完全程再总结你的车技Actor策略当然提升得更快。4. 从理论到实践手把手实现一个Actor-Critic智能体光说不练假把式。下面我们就用PyTorch在经典的“CartPole”倒立摆环境中实现一个完整的Actor-Critic算法。这个环境的目标是控制小车左右移动不让杆子倒下来。我们会一步步拆解代码把前面讲的理论全部落地。4.1 搭建双网络结构首先定义两个网络也就是我们的Actor和Critic。import torch import torch.nn.functional as F import numpy as np import gym # 策略网络Actor输出动作概率 class PolicyNet(torch.nn.Module): def __init__(self, state_dim, hidden_dim, action_dim): super(PolicyNet, self).__init__() # 两层全连接网络 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)) # 第一层激活 # 关键输出层用Softmax将分数转换为概率分布 return F.softmax(self.fc2(x), dim1) # 价值网络Critic评估状态价值 class ValueNet(torch.nn.Module): def __init__(self, state_dim, hidden_dim): super(ValueNet, self).__init__() self.fc1 torch.nn.Linear(state_dim, hidden_dim) self.fc2 torch.nn.Linear(hidden_dim, 1) # 输出一个标量值 def forward(self, x): x F.relu(self.fc1(x)) # 关键直接输出一个实数代表状态价值V(s)没有Softmax return self.fc2(x)看代码结构确实很像都是Linear - ReLU - Linear。但最核心的区别就在输出层PolicyNet输出维度是action_dim并用Softmax归一化成概率。ValueNet输出维度是1直接就是一个数值。4.2 构建智能体与核心更新逻辑接下来我们把两个网络组合起来并实现核心的交互与更新循环。class ActorCritic: def __init__(self, state_dim, hidden_dim, action_dim, actor_lr, critic_lr, gamma, device): # 初始化两个网络 self.actor PolicyNet(state_dim, hidden_dim, action_dim).to(device) self.critic ValueNet(state_dim, hidden_dim).to(device) # 为两个网络分别设置优化器通常Critic的学习率可以设得大一点 self.actor_optimizer torch.optim.Adam(self.actor.parameters(), lractor_lr) self.critic_optimizer torch.optim.Adam(self.critic.parameters(), lrcritic_lr) self.gamma gamma # 折扣因子 self.device device def take_action(self, state): 根据当前状态按照Actor的策略采样一个动作 # 处理状态格式适应网络输入 state np.array(state) if state.ndim 1: state state.reshape(1, -1) # 变成 [1, state_dim] state_tensor torch.tensor(state, dtypetorch.float32, deviceself.device) # 不计算梯度只是前向传播获取概率 with torch.no_grad(): probs self.actor(state_tensor) # 形状 [1, action_dim] # 根据概率分布采样一个动作 action_dist torch.distributions.Categorical(probs) action action_dist.sample() return action.item() # 返回整型动作 def update(self, transition_dict): 用收集到的一批数据一个轨迹片段更新两个网络 # 将列表数据转换为Tensor states torch.tensor(transition_dict[states], dtypetorch.float).to(self.device) actions torch.tensor(transition_dict[actions]).view(-1, 1).to(self.device) # 变成列向量 rewards torch.tensor(transition_dict[rewards], dtypetorch.float).view(-1, 1).to(self.device) next_states torch.tensor(transition_dict[next_states], dtypetorch.float).to(self.device) dones torch.tensor(transition_dict[dones], dtypetorch.float).view(-1, 1).to(self.device) # ---------- Critic 更新的核心计算TD误差 ---------- # 计算TD目标 r γ * V(s) 如果是终止状态(done1)则没有未来价值 td_target rewards self.gamma * self.critic(next_states) * (1 - dones) # 计算TD误差 δ TD目标 - 当前估计V(s) td_delta td_target - self.critic(states) # 这个td_delta就是δ # ---------- Actor 更新的核心利用TD误差 ---------- # 计算所选动作的对数概率 log π(a|s) log_probs torch.log(self.actor(states).gather(1, actions)) # gather取出对应动作的概率 # Actor的损失 -δ * log π(a|s)。 注意负号因为我们要最大化目标而优化器默认是最小化。 actor_loss torch.mean(-log_probs * td_delta.detach()) # detach()阻止梯度流向Critic # ---------- Critic 的损失最小化TD误差的平方 ---------- critic_loss torch.mean(F.mse_loss(self.critic(states), td_target.detach())) # ---------- 反向传播更新网络 ---------- self.actor_optimizer.zero_grad() self.critic_optimizer.zero_grad() actor_loss.backward() critic_loss.backward() self.actor_optimizer.step() self.critic_optimizer.step()这段代码是算法的核心引擎有几个细节值得深究td_target的计算(1 - dones)这个项非常巧妙。当doneTrue比如杆子倒了游戏结束dones张量对应位置为1那么1 - dones 0td_target就只剩下rewards。这符合终止状态没有未来价值的定义。actor_loss中的detach()这是关键中的关键。td_delta是从Critic网络计算出来的。在计算Actor的损失时我们只希望用td_delta的数值作为权重而不希望Actor的梯度更新影响到Critic的计算图。detach()方法将td_delta从当前计算图中分离使其成为一个常量。这样actor_loss.backward()时梯度只会更新Actor网络的参数。critic_loss中的detach()同理td_target在计算时用到了self.critic(next_states)如果我们不对其进行detach()那么在计算Critic损失的反向传播时梯度会沿着td_target一路传回用于计算它的那个Critic网络也就是“旧”的Critic这虽然理论上可行但会增加计算图的复杂度和不稳定性。通常我们会将其分离将其视为一个固定的“标签”来训练当前的Critic网络。这是一种常见的技巧能使训练更稳定。4.3 训练循环与结果观察最后我们创建一个环境让智能体开始学习。# 超参数设置 actor_lr 1e-3 critic_lr 1e-2 # Critic通常可以学得更快一点 num_episodes 1000 hidden_dim 128 gamma 0.98 device torch.device(cuda if torch.cuda.is_available() else cpu) # 创建环境 env_name CartPole-v0 env gym.make(env_name, render_modehuman) # 可以先用‘human’模式看效果 torch.manual_seed(0) env.reset(seed0) state_dim env.observation_space.shape[0] action_dim env.action_space.n agent ActorCritic(state_dim, hidden_dim, action_dim, actor_lr, critic_lr, gamma, device) return_list [] # 记录每个回合的总奖励 for i_episode in range(num_episodes): episode_return 0 transition_dict {states: [], actions: [], next_states: [], rewards: [], dones: []} state, _ env.reset() done False while not done: action agent.take_action(state) next_state, reward, done, truncated, _ env.step(action) # 收集数据 transition_dict[states].append(state) transition_dict[actions].append(action) transition_dict[next_states].append(next_state) transition_dict[rewards].append(reward) transition_dict[dones].append(done) state next_state episode_return reward # 一个回合结束用这个回合收集的所有数据更新一次网络 agent.update(transition_dict) return_list.append(episode_return) # 每100回合打印一下平均成绩 if (i_episode 1) % 100 0: print(fEpisode {i_episode 1}, Average Return (last 100): {np.mean(return_list[-100:]):.2f}) env.close() # 绘制学习曲线 import matplotlib.pyplot as plt plt.plot(return_list) plt.xlabel(Episode) plt.ylabel(Return) plt.title(Actor-Critic on CartPole) plt.show()运行这段代码你会看到智能体从最开始杆子迅速倒下到后来能稳定保持平衡很长时间总奖励Return会不断上升并最终稳定在一个较高的水平。这个从“啥也不会”到“熟练掌握”的过程就是Actor和Critic不断协作、改进的结果。我第一次跑通看到曲线上升时那种感觉就像看着一个孩子从蹒跚学步到奔跑起来非常有成就感。5. 避坑指南与进阶思考让双网络协作更丝滑在实际项目中应用Actor-Critic直接套用上面的基础版本可能会遇到一些问题。这里分享几个我踩过的坑和对应的解决思路希望能帮你少走弯路。第一个坑学习率设置不当。Actor和Critic的学习率需要精心调整。通常Critic的学习率可以设置得比Actor大一些比如10倍因为价值函数的评估任务相对更直接、更稳定。如果Critic学得太慢它给Actor提供的TD误差信号就不准相当于一个糊涂教练在瞎指挥Actor肯定学不好。如果Critic学得太快而Actor跟不上也可能导致不稳定。我的经验是可以从critic_lr 1e-2actor_lr 1e-3开始尝试然后根据训练曲线是否收敛、是否震荡进行微调。第二个坑稀疏奖励下的“冷启动”问题。在很多复杂环境里有用的奖励信号非常稀疏。比如教一个机械臂拧螺丝只有把螺丝拧进去的那一刻才有正奖励之前的所有动作奖励都是0。在这种情况下Critic一开始对所有状态的估值都差不多接近0产生的TD误差δ也接近0。Actor得不到有效的梯度信号学习就会停滞。解决办法通常引入基线Baseline或者更高级的优势函数估计方法如GAE。简单来说就是让Critic的评价不是绝对的“价值”而是动作相对于平均水平的“优势”这样即使奖励稀疏也能分辨出相对好一点的动作。第三个坑探索与利用的平衡。我们的基础Actor通过输出概率分布并采样来探索。但在训练后期如果某个动作的概率变得非常高比如0.99智能体就几乎不再尝试其他可能更好的动作了。为了鼓励持续探索可以在Actor的损失函数中加入**熵正则化Entropy Regularization**项。熵可以衡量概率分布的随机性熵越大探索性越强。在损失中增加一项熵的负值即鼓励熵增大可以防止策略过早地收敛到一个确定性动作上。修改后的Actor损失类似于actor_loss -log_prob * td_delta - beta * entropy其中beta是一个控制探索强度的超参数。进阶方向从A2C到A3C。我们上面实现的是最基础的在线Actor-Critic它按顺序收集数据并更新。一个很自然的改进是异步优势Actor-CriticA3C和其同步版本优势Actor-CriticA2C。它们的核心思想是引入多个智能体副本或线程并行地在环境中探索收集更多的经验数据然后用这些数据共同更新一个全局的Actor和Critic网络。这大大加快了数据收集速度并且不同线程探索的不同轨迹有助于降低数据相关性使训练更稳定。如果你用我们的基础版本在更复杂的环境比如Atari游戏上效果不佳A2C/A3C通常是下一个值得尝试的强力基线。Actor-Critic框架就像一个强大的模版后来的很多著名算法如TRPO、PPO、SAC都是在它的基础上发展起来的主要解决其训练稳定性、采样效率等问题。理解了这个基础的“双人舞”机制再去学习这些高级算法你会感觉豁然开朗因为它们本质上都是在思考如何让这位“演员”和“评论家”合作得更默契、更高效。