Files
RL-Study/Notebooks/C10.ipynb
T
Hongru 6b8156eceb 添加 A2C/QAC 算法实现及训练结果
- 新增 RL_Algothrithms 模块,包含 A2C、QAC 智能体
- 添加 SAC 章节笔记和 C10 笔记
- 上传训练结果图片
- 完善 README 与 .gitignore
2026-03-18 09:07:01 +00:00

26 KiB

C10 演员评论家方法

最基础的AC算法:QAC

Actor网络:策略梯度上升

直接更新策略参数,以采取更优的动作。

我们希望最优化一个标量指标$J(\theta)$,他的梯度包含一个期望值:

\nabla_{\theta}J(\theta)=\mathbb{E}_{S\sim\eta,A\sim\pi}[\nabla_{\theta}\ln \pi(A|S,\theta_{t})q_{\pi}(S,A)]

由于实际工程中很难求出这个数学期望的解析解,因此采用随机的采样方法进行梯度上升的近似更新:

\theta_{t+1}=\theta_{t}+\alpha_{\theta}\nabla_{\theta}\ln \pi(a_{t}|s_{t},\theta_{t})q(s_{t},a_{t},w_{t})

如果某个动作在状态 s_t 下得到了很高的 q 值,梯度更新就会使得网络参数 \theta 发生改变,从而提高未来在相同状态下采取动作 a_t 的概率 $\pi$。这就是 Actor 学习的过程。

Critic网络:价值的时序差分更新

在 Actor 的更新公式中,我们需要知道动作价值 q_t 。如果采用蒙特卡洛方法(走完一整个回合再算),那就是 REINFORCE 算法 。但为了实现更高效的单步在线学习,我们使用时序差分(TD)学习来估计这个值,这就是 Actor-Critic 的精髓。

Critic 的任务是通过评估动作的价值来“批评” Actor 。在最基础的 QAC 算法中,它使用类似 Sarsa 的方式更新自己的参数 $w$:

$w_{t+1}=w_{t}+\alpha_{w}[r_{t+1}+\gamma q(s_{t+1},a_{t+1},w_{t})-q(s_{t},a_{t},w_{t})]\nabla_{w}q(s_{t},a_{t},w_{t})$。

公式方括号内的部分 [r_{t+1}+\gamma q(s_{t+1},a_{t+1},w_{t})-q(s_{t},a_{t},w_{t})] 就是著名的 TD 误差。它衡量了“当前预测的 q 值”与“实际得到的奖励加上下一步预测的 q 值”之间的差距。

算法流程:

  1. 策略执行(Actor 正向计算): 系统当前处于状态 $s_t$,Actor 依据当前的概率分布 \pi(a|s_{t},\theta_{t}) 采样出一个动作$a_t$并执行,获得真实物理系统的反馈奖励 r_{t+1} 和下一个状态 s_{t+1}
  2. 价值评估(Critic 反向更新): 系统依据策略再预演一步动作 $a_{t+1}$,以此计算 TD 误差,并使用梯度下降法更新打分器参数 $w_{t+1}$。
  3. 策略优化(Actor 反向更新): 拿着刚刚打出的分数 $q(s_{t},a_{t},w_{t})$,沿着梯度上升的方向更新策略参数 $\theta_{t+1}$。
In [8]:
import torch

# 打印 PyTorch 版本
print(f"PyTorch 版本: {torch.__version__}")
# 检查 GPU 是否可用 (如果输出 True,说明大功告成!)
print(f"GPU 是否可用: {torch.cuda.is_available()}")

if torch.cuda.is_available():
    # 打印当前使用的显卡型号
    print(f"当前显卡: {torch.cuda.get_device_name(0)}")
PyTorch 版本: 2.5.1+cu121
GPU 是否可用: True
当前显卡: NVIDIA GeForce RTX 5060 Ti
In [9]:
import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
import torch.distributions as distributions

# 定义超参数
# 学习率
ACTOR_LR = 0.001
CRITIC_LR = 0.002
# 折扣因子
GAMMA = 0.99
# 训练迭代次数
NUM_EPISODES = 1000

# 1. 定义 Actor 网络 (演员:输出策略)
class Actor(nn.Module):
    def __init__(self, state_dim, action_dim):
        super(Actor, self).__init__()
        # 定义两层全连接网络提取状态特征
        self.fc1 = nn.Linear(state_dim, 128)
        self.fc2 = nn.Linear(128, action_dim)
        
    def forward(self, state):
        # 使用 ReLU 激活函数
        x = F.relu(self.fc1(state))
        # 使用 Softmax 输出离散动作的概率分布
        action_probs = F.softmax(self.fc2(x), dim=-1)
        return action_probs

# 2. 定义 Critic 网络 (评论家:输出动作价值 Q 值)
class Critic(nn.Module):
    def __init__(self, state_dim, action_dim):
        super(Critic, self).__init__()
        # 定义全连接网络,输入是状态,输出是各个动作的 Q 值
        self.fc1 = nn.Linear(state_dim, 128)
        self.fc2 = nn.Linear(128, action_dim)
        
    def forward(self, state):
        x = F.relu(self.fc1(state))
        q_values = self.fc2(x)
        return q_values


# 3. 核心训练逻辑 (对应 Algorithm 10.1)
def train_step(actor, critic, actor_optimizer, critic_optimizer, 
               state, action, reward, next_state, next_action, done):
    """
    执行一步 QAC 算法的参数更新
    """
    # 转换数据格式为 Tensor
    state = torch.FloatTensor(state)
    next_state = torch.FloatTensor(next_state)
    reward = torch.FloatTensor([reward])
    
    # -----------------------------------------
    # Critic 更新 (价值更新)
    # -----------------------------------------
    # 计算当前状态动作的 Q(s_t, a_t, w_t)
    q_values = critic(state)        # 选取某个state对应的所有动作的Q值
    current_q = q_values[action]    # 选取当前动作对应的Q值
    
    # 计算下一状态动作的 Q(s_{t+1}, a_{t+1}, w_t)
    # 使用 .detach() 截断梯度,因为目标值不需要传递梯度回网络
    next_q_values = critic(next_state).detach()
    next_q = next_q_values[next_action]
    
    # 计算 TD 目标:r_{t+1} + gamma * Q(s_{t+1}, a_{t+1}) (如果是终止状态则没有下一步的Q)
    td_target = reward + GAMMA * next_q * (1 - int(done))
    
    # 计算 TD 误差并更新 Critic 参数
    # 对应公式: w_{t+1} = w_t + alpha_w * TD_Error * grad(Q)
    critic_loss = F.mse_loss(current_q, td_target)
    
    critic_optimizer.zero_grad()
    critic_loss.backward()
    critic_optimizer.step()
    
    # -----------------------------------------
    # Actor 更新 (策略更新)
    # -----------------------------------------
    # 获取当前状态下所有动作的概率分布
    action_probs = actor(state)
    # 构建概率分布对象,方便计算对数概率
    dist = distributions.Categorical(action_probs)
    
    # 计算 ln(pi(a_t | s_t, theta_t))
    log_prob = dist.log_prob(torch.tensor(action))
    
    # Actor 梯度上升目标:ln(pi) * Q(s, a)
    # 在 PyTorch 中优化器默认执行梯度下降,所以加个负号变成最小化损失
    # 注意这里使用的是刚刚更新前算出的 current_q,为了阻断梯度传到 Critic,使用 .detach()
    actor_loss = -log_prob * current_q.detach()
    
    actor_optimizer.zero_grad()
    actor_loss.backward()
    actor_optimizer.step()

    return actor_loss.item(), critic_loss.item()

控制任务

本处使用OpenAI的Gymnasium环境中的CartPole-v1作为测试环境。这个环境的目标是通过控制一个小车来保持杆子竖直。状态空间是连续的,包含了小车的位置、速度以及杆子的角度和角速度;动作空间是离散的,只有两个动作:向左或向右移动小车。

状态空间: 一个四维的连续向量

  • s_0: 小车的位置
  • s_1: 小车的速度
  • s_2: 杆子的角度
  • s_3: 杆子的角速度

动作空间: 两个离散动作

  • a_0: 向左移动小车
  • a_1: 向右移动小车

奖励函数: 每个时间步获得的奖励为 +1,直到杆子倒下或小车移出边界。

In [10]:
# 设置设备 - 使用 CPU
device = torch.device("cpu")
print(f"使用设备: {device}")

import matplotlib.pyplot as plt
import numpy as np
import gymnasium as gym

# 训练的主循环
def main():
    # 初始化环境
    env = gym.make('CartPole-v1')
    state_dim = env.observation_space.shape[0]  # type: ignore
    action_dim = int(env.action_space.n) # type: ignore

    # 实例化 Actor 和 Critic 网络
    actor = Actor(state_dim, action_dim).to(device)
    critic = Critic(state_dim, action_dim).to(device)
    actor_optimizer = optim.Adam(actor.parameters(), lr=ACTOR_LR)
    critic_optimizer = optim.Adam(critic.parameters(), lr=CRITIC_LR)
    
    # 记录训练数据
    episode_rewards = []
    actor_losses = []
    critic_losses = []

    for episode in range(NUM_EPISODES):
        state, _ = env.reset()
        episode_reward = 0
        episode_actor_loss = 0
        episode_critic_loss = 0
        steps = 0

        # 预先生成一个初始动作 a_0
        state_tensor = torch.FloatTensor(state).to(device)
        action_probs = actor(state_tensor)
        action = distributions.Categorical(action_probs).sample().item()

        while True:
            # 与环境交互,获取当前状态
            next_state, reward, terminated, truncated, _ = env.step(action)
            done = terminated or truncated

            # 预先生成下一个动作 a_{t+1},供 Critic 更新使用
            next_state_tensor = torch.FloatTensor(next_state).to(device)
            next_action_probs = actor(next_state_tensor)
            next_action = distributions.Categorical(next_action_probs).sample().item()

            # 执行单步的学习
            actor_loss, critic_loss = train_step(actor, critic, actor_optimizer, critic_optimizer,
                          state, action, reward, next_state, next_action, done)
            
            # 记录loss
            episode_actor_loss += actor_loss
            episode_critic_loss += critic_loss
            steps += 1
            
            # 更新状态和动作
            state = next_state
            action = next_action
            episode_reward += reward  # type: ignore

            if done:
                break

        # 记录本回合数据
        episode_rewards.append(episode_reward)
        actor_losses.append(episode_actor_loss / steps)
        critic_losses.append(episode_critic_loss / steps)

        # 打印训练进度
        if (episode + 1) % 100 == 0:
            avg_reward = np.mean(episode_rewards[-100:])
            print(f"Episode {episode + 1}/{NUM_EPISODES} completed. 近100回合平均reward: {avg_reward:.2f}")
    
    env.close()
    
    # 绘制训练曲线
    plot_qac_results(episode_rewards, actor_losses, critic_losses)

def plot_qac_results(rewards, actor_losses, critic_losses):
    """绘制QAC训练结果(保存到文件)"""
    fig, axes = plt.subplots(1, 3, figsize=(15, 4))
    
    # 绘制 reward 曲线
    axes[0].plot(rewards, alpha=0.6, label='Episode Reward')
    # 添加移动平均线
    window = min(50, len(rewards) // 10)
    if window > 1:
        moving_avg = np.convolve(rewards, np.ones(window)/window, mode='valid')
        axes[0].plot(np.arange(window-1, len(rewards)), moving_avg, 'r-', label=f'{window}-Episode Moving Avg')
    axes[0].set_xlabel('Episode')
    axes[0].set_ylabel('Reward')
    axes[0].set_title('QAC Training Rewards')
    axes[0].legend()
    axes[0].grid(True, alpha=0.3)
    
    # 绘制 Actor loss
    axes[1].plot(actor_losses, alpha=0.6, color='orange')
    axes[1].set_xlabel('Episode')
    axes[1].set_ylabel('Loss')
    axes[1].set_title('QAC Actor Loss')
    axes[1].grid(True, alpha=0.3)
    
    # 绘制 Critic loss
    axes[2].plot(critic_losses, alpha=0.6, color='green')
    axes[2].set_xlabel('Episode')
    axes[2].set_ylabel('Loss')
    axes[2].set_title('QAC Critic Loss')
    axes[2].grid(True, alpha=0.3)
    
    plt.tight_layout()
    # 保存图片到文件
    plt.savefig('qac_training_results.png', dpi=150)
    plt.close()
    
    # 打印统计信息
    print(f"\n=== QAC 训练统计 ===")
    print(f"总回合数: {len(rewards)}")
    print(f"平均Reward: {np.mean(rewards):.2f}")
    print(f"最高Reward: {np.max(rewards):.2f}")
    print(f"最后100回合平均Reward: {np.mean(rewards[-100:]):.2f}")
    print(f"图片已保存到: qac_training_results.png")

if __name__ == "__main__":
    main()
使用设备: cpu
/tmp/ipykernel_77052/2107052713.py:73: UserWarning: Using a target size (torch.Size([1])) that is different to the input size (torch.Size([])). This will likely lead to incorrect results due to broadcasting. Please ensure they have the same size.
  critic_loss = F.mse_loss(current_q, td_target)
Episode 100/1000 completed. 近100回合平均reward: 15.25
Episode 200/1000 completed. 近100回合平均reward: 33.00
Episode 300/1000 completed. 近100回合平均reward: 53.27
Episode 400/1000 completed. 近100回合平均reward: 34.57
Episode 500/1000 completed. 近100回合平均reward: 59.50
Episode 600/1000 completed. 近100回合平均reward: 51.37
Episode 700/1000 completed. 近100回合平均reward: 77.24
Episode 800/1000 completed. 近100回合平均reward: 77.66
Episode 900/1000 completed. 近100回合平均reward: 78.07
Episode 1000/1000 completed. 近100回合平均reward: 51.60

=== QAC 训练统计 ===
总回合数: 1000
平均Reward: 53.15
最高Reward: 336.00
最后100回合平均Reward: 51.60
图片已保存到: qac_training_results.png

A2C算法

通过上一个部分,我们看到其实最基础的QAC算法在这个控制算例中效果并不好,原因是他的Actor网络的更新时直接采用了Critic网络的输出作为动作价值的估计,这个估计可能非常不准确,导致Actor网络的更新方向错误,从而无法有效地学习到好的策略。另外,由于QAC是一个On-Policy的算法,一般产生一条轨迹,更新一次参数,而之后这些参数就不再被使用了,这样就浪费了很多数据。我们先解决第一个问题,即方差过大的问题,引入一个基线函数来降低方差,这就是 Advantage Actor-Critic (A2C) 算法。A2C 的核心思想是引入一个基线函数 b(s) 来减去动作价值 q(s,a) 中的平均水平,从而得到优势函数 $A(s,a) = q(s,a) - b(s)$。这个优势函数可以更准确地反映某个动作相对于平均水平的好坏,从而降低了梯度估计的方差。

从控制系统设计的角度来看,最基础的 QAC 就像是一个只看绝对误差、没有稳态基准的开环打分器,系统噪声和方差极其容易被放大,导致策略输出剧烈震荡。

A2C引入状态价值 v_{\pi}(s) 作为基线(Baseline),这就如同在闭环控制中引入了一个动态的参考基准。Critic 不再评估具体的动作有多好,而是评估当前状态的平均预期。Actor 更新的依据变成了优势函数(Advantage):

\delta_t = r_{t+1} + \gamma v(s_{t+1}) - v(s_t)
In [11]:
# 设置设备 - 使用 CPU
device = torch.device("cpu")
print(f"使用设备: {device}")

# 定义超参数
ACTOR_LR = 0.001
CRITIC_LR = 0.002
GAMMA = 0.99
NUM_EPISODES = 1000

# 1. 定义 Actor 网络 (演员:输出离散动作的概率分布)
class ActorA2C(nn.Module):
    def __init__(self, state_dim, action_dim):
        super(ActorA2C, self).__init__()
        self.fc1 = nn.Linear(state_dim, 128)
        self.fc2 = nn.Linear(128, action_dim)
        
    def forward(self, state):
        x = F.relu(self.fc1(state))
        action_probs = F.softmax(self.fc2(x), dim=-1)
        return action_probs
    
# 2. 定义 Critic 网络 (评论家:输出标量 V 值)
class CriticA2C(nn.Module):
    def __init__(self, state_dim):
        super(CriticA2C, self).__init__()
        self.fc1 = nn.Linear(state_dim, 128)
        self.fc2 = nn.Linear(128, 1)
        
    def forward(self, state):
        x = F.relu(self.fc1(state))
        v_value = self.fc2(x)
        return v_value

# 3. 核心训练逻辑:基于优势函数 (Advantage)
def train_step_a2c(actor, critic, actor_optimizer, critic_optimizer, 
                   state, action, reward, next_state, done):
    state = torch.FloatTensor(state).unsqueeze(0).to(device)
    next_state = torch.FloatTensor(next_state).unsqueeze(0).to(device)
    reward = torch.FloatTensor([reward]).unsqueeze(0).to(device)
    
    # Critic 更新
    v_value = critic(state)
    next_v_value = critic(next_state).detach()
    td_target = reward + GAMMA * next_v_value * (1 - int(done))
    advantage = td_target - v_value
    critic_loss = F.mse_loss(v_value, td_target)
    
    critic_optimizer.zero_grad()
    critic_loss.backward()
    critic_optimizer.step()
    
    # Actor 更新
    action_probs = actor(state)
    dist = distributions.Categorical(action_probs)
    log_prob = dist.log_prob(torch.tensor([action]).to(device))
    actor_loss = -(log_prob * advantage.detach()).mean()
    
    actor_optimizer.zero_grad()
    actor_loss.backward()
    actor_optimizer.step()

    return actor_loss.item(), critic_loss.item()


def plot_a2c_results(rewards, actor_losses, critic_losses):
    """绘制A2C训练结果(保存到文件)"""
    fig, axes = plt.subplots(1, 3, figsize=(15, 4))
    
    axes[0].plot(rewards, alpha=0.6, label='Episode Reward')
    window = min(50, len(rewards) // 10)
    if window > 1:
        moving_avg = np.convolve(rewards, np.ones(window)/window, mode='valid')
        axes[0].plot(np.arange(window-1, len(rewards)), moving_avg, 'r-', label=f'{window}-Episode Moving Avg')
    axes[0].set_xlabel('Episode')
    axes[0].set_ylabel('Reward')
    axes[0].set_title('A2C Training Rewards')
    axes[0].legend()
    axes[0].grid(True, alpha=0.3)
    
    axes[1].plot(actor_losses, alpha=0.6, color='orange')
    axes[1].set_xlabel('Episode')
    axes[1].set_ylabel('Loss')
    axes[1].set_title('A2C Actor Loss')
    axes[1].grid(True, alpha=0.3)
    
    axes[2].plot(critic_losses, alpha=0.6, color='green')
    axes[2].set_xlabel('Episode')
    axes[2].set_ylabel('Loss')
    axes[2].set_title('A2C Critic Loss')
    axes[2].grid(True, alpha=0.3)
    
    plt.tight_layout()
    plt.savefig('a2c_training_results.png', dpi=150)
    plt.close()
    
    print(f"\n=== A2C 训练统计 ===")
    print(f"总回合数: {len(rewards)}")
    print(f"平均Reward: {np.mean(rewards):.2f}")
    print(f"最高Reward: {np.max(rewards):.2f}")
    print(f"最后100回合平均Reward: {np.mean(rewards[-100:]):.2f}")
    print(f"图片已保存到: a2c_training_results.png")


# 4. 主循环函数
def main_a2c():
    env = gym.make('CartPole-v1')
    state_dim = env.observation_space.shape[0]   # type: ignore
    action_dim = int(env.action_space.n)         # type: ignore

    actor = ActorA2C(state_dim, action_dim).to(device)
    critic = CriticA2C(state_dim).to(device)
    actor_optimizer = optim.Adam(actor.parameters(), lr=ACTOR_LR)
    critic_optimizer = optim.Adam(critic.parameters(), lr=CRITIC_LR)
    
    episode_rewards = []
    actor_losses = []
    critic_losses = []
    
    print("开始 A2C 训练...")
    for episode in range(NUM_EPISODES):
        state, _ = env.reset()
        episode_reward = 0
        episode_actor_loss = 0
        episode_critic_loss = 0
        steps = 0
        
        while True:
            state_tensor = torch.FloatTensor(state).unsqueeze(0).to(device)
            action_probs = actor(state_tensor)
            action = distributions.Categorical(action_probs).sample().item()
            
            next_state, reward, terminated, truncated, _ = env.step(action)
            done = terminated or truncated
            
            actor_loss, critic_loss = train_step_a2c(actor, critic, actor_optimizer, critic_optimizer, 
                           state, action, reward, next_state, done)
            
            episode_actor_loss += actor_loss
            episode_critic_loss += critic_loss
            steps += 1
            
            state = next_state
            episode_reward += reward
            
            if done:
                break
        
        episode_rewards.append(episode_reward)
        actor_losses.append(episode_actor_loss / steps)
        critic_losses.append(episode_critic_loss / steps)
        
        if (episode + 1) % 100 == 0:
            avg_reward = np.mean(episode_rewards[-100:])
            print(f"回合 {episode + 1}/{NUM_EPISODES} 完成. 近 100 回合平均 Reward: {avg_reward:.2f}")

    env.close()
    plot_a2c_results(episode_rewards, actor_losses, critic_losses)

if __name__ == "__main__":
    main_a2c()
使用设备: cpu
开始 A2C 训练...
回合 100/1000 完成. 近 100 回合平均 Reward: 13.11
回合 200/1000 完成. 近 100 回合平均 Reward: 65.78
回合 300/1000 完成. 近 100 回合平均 Reward: 59.20
回合 400/1000 完成. 近 100 回合平均 Reward: 9.48
回合 500/1000 完成. 近 100 回合平均 Reward: 65.32
回合 600/1000 完成. 近 100 回合平均 Reward: 67.78
回合 700/1000 完成. 近 100 回合平均 Reward: 9.36
回合 800/1000 完成. 近 100 回合平均 Reward: 9.26
回合 900/1000 完成. 近 100 回合平均 Reward: 9.68
回合 1000/1000 完成. 近 100 回合平均 Reward: 51.43

=== A2C 训练统计 ===
总回合数: 1000
平均Reward: 36.04
最高Reward: 500.00
最后100回合平均Reward: 51.43
图片已保存到: a2c_training_results.png