2. Double DQN解决过估计问题

任务目标

在CartPole-v1环境中分别实现标准DQN与Double DQN,通过对比两者对动作价值Q值的估计偏差,验证Double DQN能够缓解过估计问题,并展示其在训练稳定性与最终性能上的提升。

核心原理与实现要点

标准DQN使用同一个网络同时完成动作选择和Q值评估,即 max_a Q(s', a; θ),由于最大化操作会引入正向偏差,导致Q值被高估。Double DQN将动作选择与价值评估解耦:用当前网络选择最优动作 a* = argmax_a Q(s', a; θ),再用目标网络评估该动作的价值 Q(s', a*; θ⁻),从而有效降低过估计。

实现中两个智能体共享完全相同的网络结构、超参数与经验回放缓冲区,唯一区别在于目标Q值的计算方式。训练过程中每200步同步一次目标网络参数,每1000步评估一次当前策略的回报与平均Q值。

完整实现代码

import gymnasium as gym
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
from collections import deque
import random

# ---------- 网络定义 ----------
class QNetwork(nn.Module):
    def __init__(self, state_dim, action_dim):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(state_dim, 128), nn.ReLU(),
            nn.Linear(128, 128), nn.ReLU(),
            nn.Linear(128, action_dim)
        )
    def forward(self, x):
        return self.net(x)

# ---------- 经验回放 ----------
class ReplayBuffer:
    def __init__(self, capacity):
        self.buffer = deque(maxlen=capacity)
    def push(self, s, a, r, s2, done):
        self.buffer.append((s, a, r, s2, done))
    def sample(self, batch_size):
        batch = random.sample(self.buffer, batch_size)
        s, a, r, s2, d = map(np.stack, zip(*batch))
        return (torch.FloatTensor(s), torch.LongTensor(a), torch.FloatTensor(r),
                torch.FloatTensor(s2), torch.FloatTensor(d))
    def __len__(self):
        return len(self.buffer)

# ---------- DQN智能体(use_double控制是否使用Double DQN) ----------
class DQNAgent:
    def __init__(self, state_dim, action_dim, use_double=False):
        self.action_dim = action_dim
        self.use_double = use_double
        self.q_net = QNetwork(state_dim, action_dim)
        self.target_net = QNetwork(state_dim, action_dim)
        self.target_net.load_state_dict(self.q_net.state_dict())
        self.optimizer = optim.Adam(self.q_net.parameters(), lr=1e-3)
        self.buffer = ReplayBuffer(20000)
        self.batch_size = 64
        self.gamma = 0.99
        self.epsilon = 1.0
        self.epsilon_min = 0.01
        self.epsilon_decay = 0.995
        self.update_counter = 0

    def act(self, state, eval_mode=False):
        if not eval_mode and random.random() < self.epsilon:
            return random.randrange(self.action_dim)
        with torch.no_grad():
            q = self.q_net(torch.FloatTensor(state).unsqueeze(0))
        return q.argmax().item()

    def learn(self):
        if len(self.buffer) < self.batch_size:
            return None
        s, a, r, s2, done = self.buffer.sample(self.batch_size)
        q_current = self.q_net(s).gather(1, a.unsqueeze(1)).squeeze()

        with torch.no_grad():
            if self.use_double:
                # Double DQN: 当前网络选动作,目标网络给价值
                next_actions = self.q_net(s2).argmax(dim=1, keepdim=True)
                q_target = self.target_net(s2).gather(1, next_actions).squeeze()
            else:
                # 标准DQN: 目标网络直接取max
                q_target = self.target_net(s2).max(dim=1)[0]
            q_target = r + self.gamma * q_target * (1 - done)

        loss = nn.MSELoss()(q_current, q_target)
        self.optimizer.zero_grad()
        loss.backward()
        self.optimizer.step()

        self.update_counter += 1
        if self.update_counter % 200 == 0:
            self.target_net.load_state_dict(self.q_net.state_dict())
        self.epsilon = max(self.epsilon_min, self.epsilon * self.epsilon_decay)
        return loss.item()

# ---------- 训练与评估 ----------
def train_agent(use_double, episodes=300):
    env = gym.make('CartPole-v1')
    agent = DQNAgent(env.observation_space.shape[0], env.action_space.n, use_double)
    eval_interval = 10
    eval_episodes = 5
    results = {'returns': [], 'q_values': []}

    for ep in range(episodes):
        state, _ = env.reset()
        done = False
        ep_return = 0
        while not done:
            action = agent.act(state)
            next_state, reward, terminated, truncated, _ = env.step(action)
            done = terminated or truncated
            agent.buffer.push(state, action, reward, next_state, float(done))
            state = next_state
            ep_return += reward
            if len(agent.buffer) > 500:
                agent.learn()
        if (ep + 1) % eval_interval == 0:
            eval_return, avg_q = evaluate(agent, env)
            results['returns'].append(eval_return)
            results['q_values'].append(avg_q)
            print(f"{'DoubleDQN' if use_double else 'DQN'} | Ep {ep+1} | "
                  f"EvalReturn: {eval_return:.1f} | AvgQ: {avg_q:.2f}")
    env.close()
    return results

def evaluate(agent, env, episodes=5):
    returns = []
    q_values = []
    for _ in range(episodes):
        state, _ = env.reset()
        done = False
        ep_ret = 0
        while not done:
            action = agent.act(state, eval_mode=True)
            with torch.no_grad():
                q = agent.q_net(torch.FloatTensor(state).unsqueeze(0)).max().item()
            q_values.append(q)
            state, reward, terminated, truncated, _ = env.step(action)
            done = terminated or truncated
            ep_ret += reward
        returns.append(ep_ret)
    return np.mean(returns), np.mean(q_values)

# ---------- 运行对比 ----------
if __name__ == '__main__':
    seed = 42
    torch.manual_seed(seed)
    np.random.seed(seed)
    random.seed(seed)

    print("训练标准DQN...")
    dqn_results = train_agent(use_double=False)
    print("\n训练Double DQN...")
    ddqn_results = train_agent(use_double=True)

    # 保存结果用于绘图
    np.save('dqn_results.npy', dqn_results)
    np.save('ddqn_results.npy', ddqn_results)
    print("\n训练完成,结果已保存。")

运行与验证

环境依赖:需要安装 gymnasium、torch、numpy。运行命令:

pip install gymnasium torch numpy
python double_dqn_cartpole.py

验证方法与判定标准:

  1. Q值对比:观察训练日志中两个智能体的 AvgQ 指标。标准DQN的Q值通常会快速膨胀(例如超过200),而Double DQN的Q值更接近真实回报(CartPole-v1每步奖励为1,因此合理Q值应接近平均回合长度)。
  2. 性能对比:比较 EvalReturn 曲线。Double DQN应达到500分(环境最大步数)所需的训练回合更少,且训练过程更稳定(曲线波动更小)。
  3. 预期输出:在相同随机种子下,标准DQN的最终平均Q值通常比Double DQN高20%以上,而Double DQN的评估回报达到500的回合数更早。具体数值因随机性略有差异,但过估计缓解的趋势是稳定可复现的。
DQN vs Double DQN 过估计对比示意 训练回合 Q值估计 标准DQN (过估计) Double DQN (缓解) 真实回报≈200 Q值虚高 Q值更准确

常见问题:若训练不收敛,可检查经验回放是否达到500条后才开始学习(代码中已设置);若Q值仍然过高,可适当降低学习率或增大 epsilon_decay 的衰减速度。CartPole-v1的奖励上限为500,当评估回报稳定在500附近时说明策略已收敛到最优。