深入理解深度确定性策略梯度DDPG:基于python从零实现

向所有学习者致敬!

“学习不是装满一桶水,而是点燃一把火。” —— 叶芝


我的博客主页: https://lizheng.blog.csdn.net

欢迎点击加入AI人工智能社区!

让我们一起努力,共创AI未来!


前言

深度确定性策略梯度(DDPG)是一种离线策略的演员-评论家算法,专门为具有连续动作空间的环境设计。它结合了深度 Q 网络(DQN)中的思想,例如回放缓存和目标网络,并将其应用于演员-评论家框架,适应确定性策略的策略梯度。这使得它成为处理机器人控制和模拟物理环境等任务的强大工具,这些任务中的动作是实数值。

什么是 DDPG?

DDPG 学习两个主要的网络:

  1. 演员(Actor) μ ( s ; θ μ ) \mu(s; \theta^\mu) μ(s;θμ):一个策略网络,它接收状态 s s s 并输出一个特定的确定性动作 a = μ ( s ) a = \mu(s) a=μ(s),而不是动作的概率分布。参数化为 θ μ \theta^\mu θμ
  2. 评论家(Critic) Q ( s , a ; θ Q ) Q(s, a; \theta^Q) Q(s,a;θQ):一个 Q 值网络,它接收状态 s s s 和动作 a a a 并输出该状态-动作对的估计 Q 值(预期回报)。参数化为 θ Q \theta^Q θQ

它借鉴了 DQN 中的技术来稳定学习:

  • 回放缓存(Replay Buffer):存储经验 ( s t , a t , r t , s t + 1 ) (s_t, a_t, r_t, s_{t+1}) (st,at,rt,st+1) 并从中采样小批量数据进行更新,实现离线策略学习并打破数据相关性。
  • 目标网络(Target Networks):为演员( μ ′ \mu' μ)和评论家( Q ′ Q' Q)分别维护独立的目标网络,这些目标网络会缓慢更新(软更新)到主网络。这为评论家的学习提供了稳定的靶子。

核心思想:确定性策略与离线策略学习

  • 确定性策略:直接输出动作,相比参数化复杂的连续概率分布,这在连续空间中简化了学习过程。不过,这也需要显式地添加探索噪声。
  • 离线策略:使用回放缓存可以让 DDPG 重用旧策略生成的经验,这比 REINFORCE、A2C 或 TRPO/PPO 等在线策略方法更高效,尤其是在环境交互成本很高的情况下。

DDPG 的应用场景

DDPG 主要用于具有连续动作空间的问题:

  1. 机器人技术:学习机器人手臂的控制策略、行走、操作等。
  2. 连续控制基准测试:例如摆动(Pendulum)、连续山地车(MountainCarContinuous)、MuJoCo 环境(如单足机器人 Hopper、行走机器人 Walker 等)。
  3. 自动驾驶(仿真):控制方向盘、油门等。

DDPG 适用于以下情况:

  • 动作空间是连续的。
  • 样本效率很重要(离线策略学习很有帮助)。
  • 可以接受或需要确定性策略。

然而,DDPG 对超参数比较敏感,有时会出现 Q 值高估和不稳定的问题。因此,开发了像 TD3(双延迟 DDPG)这样的扩展算法来解决这些问题。

DDPG 的数学基础

演员-评论家框架回顾

核心思想依然是:评论家评估状态-动作值,而演员根据评论家的评估更新其策略。

确定性策略梯度定理

对于确定性策略 a = μ ( s ; θ μ ) a = \mu(s; \theta^\mu) a=μ(s;θμ),性能目标 J ( θ μ ) J(\theta^\mu) J(θμ) 的梯度为:
∇ θ μ J ( θ μ ) = E s ∼ ρ β [ ∇ θ μ μ ( s ; θ μ ) ∇ a Q ( s , a ; θ Q ) ∣ a = μ ( s ; θ μ ) ] \nabla_{\theta^\mu} J(\theta^\mu) = \mathbb{E}_{s \sim \rho^\beta} [ \nabla_{\theta^\mu} \mu(s; \theta^\mu) \nabla_a Q(s, a; \theta^Q)|_{a=\mu(s; \theta^\mu)} ] θμJ(θμ)=Esρβ[θμμ(s;θμ)aQ(s,a;θQ)a=μ(s;θμ)]
其中 ρ β \rho^\beta ρβ 是在某种探索策略 β \beta β 下的状态分布。由于 DDPG 是离线策略且使用回放缓存,期望是针对从缓存中采样的状态进行的。
直观来说,演员的参数 θ μ \theta^\mu θμ 会朝着增加评论家预测的 Q 值的方向更新。

评论家(Q 网络)更新

评论家 Q ( s , a ; θ Q ) Q(s, a; \theta^Q) Q(s,a;θQ) 的更新方式类似于 DQN,使用回放缓存中的样本 ( s i , a i , r i , s i + 1 ) (s_i, a_i, r_i, s_{i+1}) (si,ai,ri,si+1)。它最小化均方贝尔曼误差(MSBE):
L ( θ Q ) = E ( s , a , r , s ′ ) ∼ D [ ( y − Q ( s , a ; θ Q ) ) 2 ] L(\theta^Q) = \mathbb{E}_{(s,a,r,s') \sim \mathcal{D}} [ (y - Q(s, a; \theta^Q))^2 ] L(θQ)=E(s,a,r,s)D[(yQ(s,a;θQ))2]
目标值 y y y 是通过目标演员( μ ′ \mu' μ)和目标评论家( Q ′ Q' Q)计算的:
y = r + γ Q ′ ( s ′ , μ ′ ( s ′ ; θ μ ′ ) ; θ Q ′ ) y = r + \gamma Q'(s', \mu'(s'; \theta^{\mu'}) ; \theta^{Q'}) y=r+γQ(s,μ(s;θμ);θQ)
使用目标网络通过解耦目标计算与当前正在更新的参数,从而提供稳定性。

演员(策略网络)更新

演员 μ ( s ; θ μ ) \mu(s; \theta^\mu) μ(s;θμ) 通过最大化主评论家 Q Q Q 的预期输出来更新,具体是通过梯度上升实现的:
J ( θ μ ) ≈ E s ∼ D [ Q ( s , μ ( s ; θ μ ) ; θ Q ) ] J(\theta^\mu) \approx \mathbb{E}_{s \sim \mathcal{D}} [ Q(s, \mu(s; \theta^\mu) ; \theta^Q) ] J(θμ)EsD[Q(s,μ(s;θμ);θQ)]
在实际操作中,这意味着计算评论家输出(使用演员的动作评估)相对于演员参数的梯度。使用 PyTorch 的 autograd,可以通过计算损失 L ( θ μ ) = − 1 N ∑ i Q ( s i , μ ( s i ; θ μ ) ; θ Q ) L(\theta^\mu) = -\frac{1}{N} \sum_i Q(s_i, \mu(s_i; \theta^\mu) ; \theta^Q) L(θμ)=N1iQ(si,μ(si;θμ);θQ) 并执行梯度下降来实现。

目标网络和软更新

为了稳定学习,DDPG 使用目标网络 Q ′ ( s , a ; θ Q ′ ) Q'(s, a; \theta^{Q'}) Q(s,a;θQ) μ ′ ( s ; θ μ ′ ) \mu'(s; \theta^{\mu'}) μ(s;θμ),参数分别为 θ Q ′ \theta^{Q'} θQ θ μ ′ \theta^{\mu'} θμ。这些网络不是直接训练的,而是通过“软更新”缓慢地向主网络参数( θ Q , θ μ \theta^Q, \theta^\mu θQ,θμ)更新:
θ ′ ← τ θ + ( 1 − τ ) θ ′ \theta' \leftarrow \tau \theta + (1 - \tau) \theta' θτθ+(1τ)θ
其中 τ ≪ 1 \tau \ll 1 τ1(例如 0.001、0.005)是软更新率。这使得目标值 y y y 缓慢变化,从而提高稳定性。

探索噪声

由于演员策略是确定性的,因此在训练过程中必须外部添加探索。常见的方法是在演员的输出动作中添加噪声,然后在环境中执行:
a t = μ ( s t ; θ μ ) + N t a_t = \mu(s_t; \theta^\mu) + \mathcal{N}_t at=μ(st;θμ)+Nt
其中 N t \mathcal{N}_t Nt 是噪声过程(例如,用于时间相关噪声的奥恩斯坦-乌伦贝克过程,或者更简单的高斯噪声)。噪声水平通常会随着时间逐渐降低。

DDPG 的逐步解释

  1. 初始化:演员网络 μ ( s ; θ μ ) \mu(s; \theta^\mu) μ(s;θμ) 和评论家网络 Q ( s , a ; θ Q ) Q(s, a; \theta^Q) Q(s,a;θQ)
  2. 初始化:目标网络 μ ′ ( s ; θ μ ′ ) \mu'(s; \theta^{\mu'}) μ(s;θμ) Q ′ ( s , a ; θ Q ′ ) Q'(s, a; \theta^{Q'}) Q(s,a;θQ),并设置 θ μ ′ ← θ μ \theta^{\mu'} \leftarrow \theta^\mu θμθμ θ Q ′ ← θ Q \theta^{Q'} \leftarrow \theta^Q θQθQ
  3. 初始化:回放缓存 D \mathcal{D} D 和噪声过程 N \mathcal{N} N
  4. 对于每个回合
    a. 重置环境,获取初始状态 s 0 s_0 s0。重置噪声过程。
    b. 对于每一步 t t t
    i. 选择动作 a t = μ ( s t ; θ μ ) + N t a_t = \mu(s_t; \theta^\mu) + \mathcal{N}_t at=μ(st;θμ)+Nt。如果需要,将动作裁剪以符合环境的边界。
    ii. 执行 a t a_t at,观察奖励 r t r_t rt、下一个状态 s t + 1 s_{t+1} st+1 和完成标志 d t d_t dt
    iii. 将转换 ( s t , a t , r t , s t + 1 , d t ) (s_t, a_t, r_t, s_{t+1}, d_t) (st,at,rt,st+1,dt) 存储到 D \mathcal{D} D 中。
    iv. 采样小批量:从 D \mathcal{D} D 中随机获取 N N N 个转换的批次。
    v. 计算评论家目标:对于批次中的每个样本 j j j
    a j + 1 ′ = μ ′ ( s j + 1 ; θ μ ′ ) a'_{j+1} = \mu'(s_{j+1}; \theta^{\mu'}) aj+1=μ(sj+1;θμ)
    y j = r j + γ ( 1 − d j ) Q ′ ( s j + 1 , a j + 1 ′ ; θ Q ′ ) y_j = r_j + \gamma (1-d_j) Q'(s_{j+1}, a'_{j+1} ; \theta^{Q'}) yj=rj+γ(1dj)Q(sj+1,aj+1;θQ)
    vi. 更新评论家:通过梯度下降最小化损失 L = 1 N ∑ j ( y j − Q ( s j , a j ; θ Q ) ) 2 L = \frac{1}{N} \sum_j (y_j - Q(s_j, a_j; \theta^Q))^2 L=N1j(yjQ(sj,aj;θQ))2
    vii. 更新演员:通过梯度上升最大化目标(或最小化负目标) J = 1 N ∑ j Q ( s j , μ ( s j ; θ μ ) ; θ Q ) J = \frac{1}{N} \sum_j Q(s_j, \mu(s_j; \theta^\mu); \theta^Q) J=N1jQ(sj,μ(sj;θμ);θQ)。注意:梯度从评论家输出反向传播到演员。
    viii. 更新目标网络:执行软更新:
    θ Q ′ ← τ θ Q + ( 1 − τ ) θ Q ′ \theta^{Q'} \leftarrow \tau \theta^Q + (1 - \tau) \theta^{Q'} θQτθQ+(1τ)θQ
    θ μ ′ ← τ θ μ + ( 1 − τ ) θ μ ′ \theta^{\mu'} \leftarrow \tau \theta^\mu + (1 - \tau) \theta^{\mu'} θμτθμ+(1τ)θμ
    ix. s t ← s t + 1 s_t \leftarrow s_{t+1} stst+1
    x. 如果 d t d_t dt,则结束当前回合。
  5. 重复:直到收敛或达到最大回合数。

DDPG 的关键组成部分

演员网络(确定性策略)

  • 将状态 s s s 映射到一个特定的连续动作 a = μ ( s ) a = \mu(s) a=μ(s)
  • 训练目标是输出能够最大化评论家估计的 Q 值的动作。
  • 输出层通常使用 tanh 激活函数,并根据动作范围进行缩放。

评论家网络(Q 值函数)

  • 估计在状态 s s s 下采取动作 a a a 的价值 Q ( s , a ) Q(s, a) Q(s,a)
  • 输入包括状态和动作。
  • 使用目标网络计算的贝尔曼方程目标进行训练。

目标演员和目标评论家网络

  • 分别是演员和评论家网络的独立副本,用于计算评论家更新时的稳定目标值 y y y
  • 通过软更新缓慢更新。

回放缓存

  • 存储 ( s , a , r , s ′ , d o n e ) (s, a, r, s', done) (s,a,r,s,done) 转换。
  • 允许离线策略学习,并通过随机采样小批量数据打破数据相关性。

探索噪声过程

  • 在训练过程中添加到确定性演员的输出动作中,以鼓励探索。
  • 示例:奥恩斯坦-乌伦贝克(相关噪声)、高斯噪声。通常会随着时间逐渐降低。

软目标更新

  • 使用更新率 τ \tau τ 将主网络参数缓慢混合到目标网络参数中。
  • 与不频繁的硬更新相比,这是稳定性的关键。

超参数

  • 回放缓存大小、批量大小。
  • 演员( α μ \alpha_\mu αμ)和评论家( α Q \alpha_Q αQ)的学习率。
  • 目标网络软更新率( τ \tau τ)。
  • 折扣因子( γ \gamma γ)。
  • 探索噪声参数(类型、规模、衰减)。
  • 网络架构。

实际案例:摆动环境

深入理解深度确定性策略梯度DDPG:基于python从零实现_第1张图片

为什么选择摆动环境?(连续动作)

DDPG 专为需要输出连续值的环境设计(例如,施加特定的扭矩、设置速度或定位机器人关节)。网格世界(Grid World)的动作是离散的(上、下、左、右),而 Pendulum-v1 环境是一个标准的基准测试,具有以下特点:

  • 连续状态[cos(theta), sin(theta), theta_dot]
  • 连续动作:施加到关节的扭矩,通常是一个介于 [-2.0, 2.0] 之间的值。

使用这个环境可以正确展示 DDPG 如何处理连续动作。这需要 gymnasium 库,与参考 DQN 笔记本中“仅限基本库”的约束略有偏差,因为 DDPG 本质上适用于此类环境。

设置环境

导入必要的库,包括 gymnasium

# 导入必要的库
import numpy as np
import matplotlib.pyplot as plt
import random
import math
from collections import namedtuple, deque
from itertools import count
from typing import List, Tuple, Dict, Optional, Callable, Any, Union
import copy
import os
import time

# 导入 PyTorch
import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F

# 导入 Gymnasium 用于连续环境
try:
    import gymnasium as gym
except ImportError:
    print("未找到 Gymnasium。请使用 'pip install gymnasium' 或 'pip install gym[classic_control]' 进行安装")
    # 如果 gym 是必需的,这里可以退出或抛出错误
    gym = None # 如果导入失败,则将 gym 设置为 None

# 设置设备
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"使用设备:{device}")

# 设置随机种子以确保可重复性
seed = 42
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
if torch.cuda.is_available():
    torch.cuda.manual_seed_all(seed)

%matplotlib inline
使用设备:cpu

创建连续环境(Gymnasium)

使用 Gymnasium 实例化摆动环境。

# 实例化摆动环境
if gym is not None:
    try:
        # 创建环境
        env = gym.make('Pendulum-v1')
        
        # 为环境设置种子以确保可重复性
        env.reset(seed=seed)
        env.action_space.seed(seed)

        # 获取状态和动作空间的维度
        n_observations_ddpg = env.observation_space.shape[0]
        n_actions_ddpg = env.action_space.shape[0] # DDPG 处理连续动作
        action_low = env.action_space.low[0]
        action_high = env.action_space.high[0]

        print(f"摆动环境:")
        print(f"状态维度:{n_observations_ddpg}")
        print(f"动作维度:{n_actions_ddpg}")
        print(f"动作最小值:{action_low}")
        print(f"动作最大值:{action_high}")
        
        # 测试重置
        obs, info = env.reset()
        print(f"初始观测值:{obs}")
        
    except Exception as e:
        print(f"创建 Gymnasium 环境时出错:{e}")
        # 如果环境创建失败,则设置虚拟值
        n_observations_ddpg = 3
        n_actions_ddpg = 1
        action_low = -2.0
        action_high = 2.0
        env = None # 标记环境无法使用
else:
    print("未找到 Gymnasium。无法创建摆动环境。")
    # 设置虚拟值
    n_observations_ddpg = 3
    n_actions_ddpg = 1
    action_low = -2.0
    action_high = 2.0
    env = None
摆动环境:
状态维度:3
动作维度:1
动作最小值:-2.0
动作最大值:2.0
初始观测值:[-0.6306115   0.77609867  0.39473605]

实现 DDPG 算法

定义演员、评论家、回放缓存、噪声和更新逻辑。

定义演员网络

输出一个确定性的连续动作,通过 tanh 缩放到环境的动作范围。

class ActorNetwork(nn.Module):
    """ DDPG 的确定性演员网络 """
    def __init__(self, n_observations: int, n_actions: int, action_high_bound: float):
        super(ActorNetwork, self).__init__()
        self.action_high_bound = action_high_bound
        # 简单的多层感知机架构
        self.layer1 = nn.Linear(n_observations, 256)
        self.layer2 = nn.Linear(256, 256)
        self.layer3 = nn.Linear(256, n_actions)
        
        # 初始化最终层权重以获得较小的初始输出
        # 在 DDPG 中通常很有帮助
        nn.init.uniform_(self.layer3.weight, -3e-3, 3e-3)
        nn.init.uniform_(self.layer3.bias, -3e-3, 3e-3)

    def forward(self, state: torch.Tensor) -> torch.Tensor:
        """
        将状态映射到确定性动作。
        参数:
        - state (torch.Tensor):输入状态张量。
        返回:
        - torch.Tensor:确定性动作,缩放到环境的动作范围。
        """
        x = F.relu(self.layer1(state))
        x = F.relu(self.layer2(x))
        # 使用 tanh 将输出限制在 -1 和 1 之间
        action_tanh = torch.tanh(self.layer3(x))
        # 缩放到环境的动作范围
        scaled_action = action_tanh * self.action_high_bound
        return scaled_action

定义评论家网络

给定状态和动作,输出一个 Q 值。通常在处理初始状态后将动作与状态特征连接起来。

class CriticNetwork(nn.Module):
    """ DDPG 的 Q 值评论家网络 """
    def __init__(self, n_observations: int, n_actions: int):
        super(CriticNetwork, self).__init__()
        # 首先单独处理状态
        self.state_layer1 = nn.Linear(n_observations, 256)
        # 在第二层将状态特征和动作结合起来
        self.combined_layer2 = nn.Linear(256 + n_actions, 256)
        self.output_layer3 = nn.Linear(256, 1)

    def forward(self, state: torch.Tensor, action: torch.Tensor) -> torch.Tensor:
        """
        将状态和动作映射到 Q 值。
        参数:
        - state (torch.Tensor):输入状态张量。
        - action (torch.Tensor):输入动作张量。
        返回:
        - torch.Tensor:估计的 Q(s, a) 值。
        """
        state_features = F.relu(self.state_layer1(state))
        # 将状态特征和动作连接起来
        combined = torch.cat([state_features, action], dim=1)
        x = F.relu(self.combined_layer2(combined))
        q_value = self.output_layer3(x)
        return q_value

定义回放缓存

与 DQN 类似的标准回放缓存实现。

# 定义存储转换的结构
Transition = namedtuple('Transition',
                        ('state', 'action', 'reward', 'next_state', 'done'))

# 定义回放缓存类
class ReplayMemory(object):
    """ 存储转换并允许采样批次。 """
    def __init__(self, capacity: int):
        """
        初始化回放缓存。
        参数:
        - capacity (int):最大存储转换数量。
        """
        # 使用 deque 实现高效的先进先出缓冲区
        self.memory = deque([], maxlen=capacity)

    def push(self, *args: Any) -> None:
        """
        保存一个转换。
        参数:
        - *args:转换元素(state, action, reward, next_state, done)。
                 状态/动作/奖励/下一个状态应为张量或易于转换。
        """
        # 确保数据正确存储(例如,张量在 CPU 上)
        processed_args = []
        for arg in args:
            if isinstance(arg, torch.Tensor):
                processed_args.append(arg.cpu()) # 在 CPU 上存储张量
            elif isinstance(arg, (bool, float, int)):
                 # 如果需要,可以将 bool/float/int 转换为张量以保持一致性,但直接存储原始值也可以。
                 # 对于 done/reward,存储原始值,对于状态/动作,存储张量。
                 processed_args.append(arg) 
            elif isinstance(arg, np.ndarray):
                 processed_args.append(torch.from_numpy(arg).float().cpu()) # 将 numpy 数组转换为张量
            else:
                 processed_args.append(arg) # 保持其他类型不变
                 
        self.memory.append(Transition(*processed_args))

    def sample(self, batch_size: int) -> List[Transition]:
        """
        从内存中随机采样一批转换。
        参数:
        - batch_size (int):要采样的转换数量。
        返回:
        - List[Transition]:包含采样转换的列表。
        """
        return random.sample(self.memory, batch_size)

    def __len__(self) -> int:
        """ 返回当前内存的大小。 """
        return len(self.memory)

定义探索噪声

简单的高斯噪声实现。奥恩斯坦-乌伦贝克噪声是另一种常见选择,但稍微复杂一些。

class GaussianNoise:
    """ 简单的高斯噪声过程用于探索。 """
    def __init__(self, action_dimension: int, mean: float = 0.0, std_dev: float = 0.1):
        """
        初始化高斯噪声。
        参数:
        - action_dimension (int):动作空间的维度。
        - mean (float):高斯分布的均值。
        - std_dev (float):高斯分布的标准差。
        """
        self.action_dim = action_dimension
        self.mean = mean
        self.std_dev = std_dev

    def get_noise(self) -> np.ndarray:
        """ 生成噪声。 """
        # 使用 numpy 生成噪声
        noise = np.random.normal(self.mean, self.std_dev, self.action_dim)
        return noise

    def reset(self) -> None:
        """ 重置噪声状态(高斯噪声没有状态)。 """
        pass

软更新函数

帮助函数,用于执行目标网络参数的软更新。

def soft_update(target_net: nn.Module, main_net: nn.Module, tau: float) -> None:
    """
    执行目标网络参数的软更新。
    $\theta_{\text{target}} = \tau \cdot \theta_{\text{local}} + (1 - \tau) \cdot \theta_{\text{target}}$

    参数:
    - target_net (nn.Module):要更新的目标网络。
    - main_net (nn.Module):提供参数的主网络。
    - tau (float):软更新因子 ($\tau$)。
    """
    for target_param, main_param in zip(target_net.parameters(), main_net.parameters()):
        target_param.data.copy_(tau * main_param.data + (1.0 - tau) * target_param.data)

DDPG 更新步骤

使用从回放缓存中采样的批次执行一次 DDPG 更新。

def update_ddpg(memory: ReplayMemory,
                  batch_size: int,
                  actor: ActorNetwork,
                  critic: CriticNetwork,
                  target_actor: ActorNetwork,
                  target_critic: CriticNetwork,
                  actor_optimizer: optim.Optimizer,
                  critic_optimizer: optim.Optimizer,
                  gamma: float,
                  tau: float) -> Tuple[float, float]:
    """
    执行一次 DDPG 更新步骤(演员和评论家)。

    参数:
    - memory:回放缓存对象。
    - batch_size:要采样的小批量大小。
    - actor, critic:主网络。
    - target_actor, target_critic:目标网络。
    - actor_optimizer, critic_optimizer:优化器。
    - gamma:折扣因子。
    - tau:软更新因子。

    返回:
    - Tuple[float, float]:评论家损失和演员损失,用于日志记录。
    """
    # 如果缓冲区中没有足够的样本,则不进行更新
    if len(memory) < batch_size:
        return 0.0, 0.0

    # 采样一个批次
    transitions = memory.sample(batch_size)
    batch = Transition(*zip(*transitions))

    # 解包批次数据并移动到设备
    # 确保状态/下一个状态是 FloatTensors,动作是 FloatTensors,奖励/完成标志是 FloatTensors
    state_batch = torch.stack([s for s in batch.state if s is not None]).float().to(device)
    action_batch = torch.stack([a for a in batch.action if a is not None]).float().to(device)
    reward_batch = torch.tensor(batch.reward, dtype=torch.float32, device=device).unsqueeze(1)
    next_state_batch = torch.stack([s for s in batch.next_state if s is not None]).float().to(device)
    # 将布尔类型的 'done' 标志转换为浮点张量(完成为 1.0,未完成为 0.0)
    done_batch = torch.tensor(batch.done, dtype=torch.float32, device=device).unsqueeze(1)

    # --- 评论家更新 --- 
    
    # 1. 计算目标 Q 值 (y)
    with torch.no_grad(): # 目标计算不需要跟踪梯度
        # 从目标演员获取下一个动作
        next_actions = target_actor(next_state_batch)
        # 从目标评论家获取下一个状态/动作的 Q 值
        target_q_values = target_critic(next_state_batch, next_actions)
        # 计算目标 $ y = r + \gamma \cdot Q'_{\text{target}} \cdot (1 - \text{done}) $
        y = reward_batch + gamma * (1.0 - done_batch) * target_q_values

    # 2. 获取主评论家的当前 Q 值
    current_q_values = critic(state_batch, action_batch)

    # 3. 计算评论家损失(均方误差)
    critic_loss = F.mse_loss(current_q_values, y)

    # 4. 优化评论家
    critic_optimizer.zero_grad()
    critic_loss.backward()
    # 可选:对评论家进行梯度裁剪
    # torch.nn.utils.clip_grad_norm_(critic.parameters(), 1.0)
    critic_optimizer.step()

    # --- 演员更新 --- 

    # 1. 计算演员损失(负平均 Q 值)
    # 我们希望最大化 $ Q(s, \mu(s)) $,因此最小化 $ -Q(s, \mu(s)) $
    actor_actions = actor(state_batch)
    q_values_for_actor = critic(state_batch, actor_actions) # 使用主评论家
    actor_loss = -q_values_for_actor.mean()

    # 2. 优化演员
    actor_optimizer.zero_grad()
    actor_loss.backward()
    # 可选:对演员进行梯度裁剪
    # torch.nn.utils.clip_grad_norm_(actor.parameters(), 1.0)
    actor_optimizer.step()

    # --- 更新目标网络 --- 
    soft_update(target_critic, critic, tau)
    soft_update(target_actor, actor, tau)

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

运行 DDPG 算法

设置超参数,初始化网络、优化器、缓冲区和噪声,然后运行 DDPG 训练循环。

超参数设置

为摆动环境定义 DDPG 超参数。

# DDPG 在 Pendulum-v1 上的超参数
BUFFER_SIZE = int(1e6)     # 回放缓存容量
BATCH_SIZE = 128           # 更新的小批量大小
GAMMA_DDPG = 0.99          # 折扣因子
TAU = 1e-3                 # 目标网络的软更新因子
ACTOR_LR_DDPG = 1e-4       # 演员的学习率
CRITIC_LR_DDPG = 1e-3      # 评论家的学习率(通常高于演员)
WEIGHT_DECAY = 0           # 评论家优化器的 L2 权重衰减(可选)

NOISE_STD_DEV = 0.2        # 高斯探索噪声的标准差
NOISE_DECAY = 0.999        # 噪声标准差的衰减因子(可选退火)
MIN_NOISE_STD_DEV = 0.01   # 噪声标准差的最小值

NUM_EPISODES_DDPG = 100    # 训练回合数
MAX_STEPS_PER_EPISODE_DDPG = 500 # 摆动环境每回合的最大步数
UPDATE_EVERY = 1           # 每隔多少步执行一次更新(例如,每步一次)
NUM_UPDATES = 1            # 每个 UPDATE_EVERY 区间内的更新步数

初始化

初始化演员、评论家、目标网络、优化器、回放缓存和噪声过程。

# 确保环境创建成功
if env is None:
    raise RuntimeError("无法创建 Gymnasium 环境 'Pendulum-v1'。请确保已安装 gymnasium。")

# 初始化网络
actor_ddpg = ActorNetwork(n_observations_ddpg, n_actions_ddpg, action_high).to(device)
critic_ddpg = CriticNetwork(n_observations_ddpg, n_actions_ddpg).to(device)

# 初始化目标网络(最初硬拷贝)
target_actor_ddpg = ActorNetwork(n_observations_ddpg, n_actions_ddpg, action_high).to(device)
target_critic_ddpg = CriticNetwork(n_observations_ddpg, n_actions_ddpg).to(device)
target_actor_ddpg.load_state_dict(actor_ddpg.state_dict())
target_critic_ddpg.load_state_dict(critic_ddpg.state_dict())

# 初始化优化器
actor_optimizer_ddpg = optim.Adam(actor_ddpg.parameters(), lr=ACTOR_LR_DDPG)
critic_optimizer_ddpg = optim.Adam(critic_ddpg.parameters(), lr=CRITIC_LR_DDPG, weight_decay=WEIGHT_DECAY)

# 初始化回放缓存
memory_ddpg = ReplayMemory(BUFFER_SIZE)

# 初始化噪声过程
noise = GaussianNoise(n_actions_ddpg, std_dev=NOISE_STD_DEV)
current_noise_std_dev = NOISE_STD_DEV

# 用于绘图的列表
ddpg_episode_rewards = []
ddpg_episode_actor_losses = []
ddpg_episode_critic_losses = []

训练循环

DDPG 训练循环。

print("开始在 Pendulum-v1 上训练 DDPG...")

# --- DDPG 训练循环 ---
total_steps = 0
for i_episode in range(1, NUM_EPISODES_DDPG + 1):
    # 重置环境和噪声
    state_np, info = env.reset()
    state = torch.from_numpy(state_np).float().to(device)
    noise.reset()
    noise.std_dev = current_noise_std_dev # 设置当前噪声水平
    
    episode_reward = 0
    actor_losses = []
    critic_losses = []

    for t in range(MAX_STEPS_PER_EPISODE_DDPG):
        # --- 动作选择 --- 
        actor_ddpg.eval() # 将演员设置为评估模式以选择动作
        with torch.no_grad():
            action_deterministic = actor_ddpg(state)
        actor_ddpg.train() # 恢复训练模式
        
        # 添加探索噪声
        action_noise = noise.get_noise()
        action_noisy = action_deterministic.cpu().numpy() + action_noise # 在 CPU 上添加噪声
        
        # 将动作裁剪到环境的范围内
        action_clipped = np.clip(action_noisy, action_low, action_high)

        # --- 环境交互 --- 
        next_state_np, reward, terminated, truncated, _ = env.step(action_clipped)
        done = terminated or truncated
        
        # --- 存储经验 --- 
        # 将数据转换为张量以便存储(存储执行的动作)
        action_tensor = torch.from_numpy(action_clipped).float() # 存储执行的动作
        next_state_tensor = torch.from_numpy(next_state_np).float()
        # 注意:状态已经是张量
        memory_ddpg.push(state, action_tensor, reward, next_state_tensor, done)

        state = next_state_tensor.to(device) # 更新状态以供下次循环使用
        episode_reward += reward
        total_steps += 1

        # --- 更新网络 --- 
        if len(memory_ddpg) > BATCH_SIZE and total_steps % UPDATE_EVERY == 0:
            for _ in range(NUM_UPDATES):
                c_loss, a_loss = update_ddpg(
                    memory_ddpg, BATCH_SIZE, 
                    actor_ddpg, critic_ddpg,
                    target_actor_ddpg, target_critic_ddpg,
                    actor_optimizer_ddpg, critic_optimizer_ddpg,
                    GAMMA_DDPG, TAU
                )
                critic_losses.append(c_loss)
                actor_losses.append(a_loss)

        if done:
            break
            
    # --- 回合结束 --- 
    ddpg_episode_rewards.append(episode_reward)
    ddpg_episode_actor_losses.append(np.mean(actor_losses) if actor_losses else 0)
    ddpg_episode_critic_losses.append(np.mean(critic_losses) if critic_losses else 0)
    
    # 退火噪声
    current_noise_std_dev = max(MIN_NOISE_STD_DEV, current_noise_std_dev * NOISE_DECAY)
    
    # 打印进度
    if i_episode % 10 == 0:
        avg_reward = np.mean(ddpg_episode_rewards[-10:])
        avg_actor_loss = np.mean(ddpg_episode_actor_losses[-10:])
        avg_critic_loss = np.mean(ddpg_episode_critic_losses[-10:])
        print(f"回合 {i_episode}/{NUM_EPISODES_DDPG} | 平均奖励:{avg_reward:.2f} | 演员损失:{avg_actor_loss:.4f} | 评论家损失:{avg_critic_loss:.4f} | 噪声标准差:{current_noise_std_dev:.3f}")

print("Pendulum-v1 训练完成(DDPG)。")
开始在 Pendulum-v1 上训练 DDPG...
回合 10/100 | 平均奖励:-1490.10 | 演员损失:13.9089 | 评论家损失:1.2726 | 噪声标准差:0.198
回合 20/100 | 平均奖励:-1443.33 | 演员损失:26.3751 | 评论家损失:3.1495 | 噪声标准差:0.196
回合 30/100 | 平均奖励:-1376.16 | 演员损失:38.1891 | 评论家损失:7.6305 | 噪声标准差:0.194
回合 40/100 | 平均奖励:-951.15 | 演员损失:46.8234 | 评论家损失:11.8599 | 噪声标准差:0.192
回合 50/100 | 平均奖励:-870.70 | 演员损失:53.7064 | 评论家损失:12.6563 | 噪声标准差:0.190
回合 60/100 | 平均奖励:-359.14 | 演员损失:56.8262 | 评论家损失:15.7296 | 噪声标准差:0.188
回合 70/100 | 平均奖励:-435.37 | 演员损失:57.6520 | 评论家损失:17.4160 | 噪声标准差:0.186
回合 80/100 | 平均奖励:-381.79 | 演员损失:58.4119 | 评论家损失:19.1516 | 噪声标准差:0.185
回合 90/100 | 平均奖励:-115.01 | 演员损失:58.0655 | 评论家损失:20.3253 | 噪声标准差:0.183
回合 100/100 | 平均奖励:-240.34 | 演员损失:56.7522 | 评论家损失:22.7741 | 噪声标准差:0.181
Pendulum-v1 训练完成(DDPG)。

可视化学习过程

绘制回合奖励和平均损失。

# 绘制 DDPG 在 Pendulum-v1 上的结果
plt.figure(figsize=(18, 4))

# 回合奖励
plt.subplot(1, 3, 1)
plt.plot(ddpg_episode_rewards)
plt.title('DDPG 摆动:回合奖励')
plt.xlabel('回合')
plt.ylabel('总奖励')
plt.grid(True)
# 添加移动平均值
if len(ddpg_episode_rewards) >= 10:
    rewards_ma_ddpg = np.convolve(ddpg_episode_rewards, np.ones(10)/10, mode='valid')
    plt.plot(np.arange(len(rewards_ma_ddpg)) + 9, rewards_ma_ddpg, label='10 回合移动平均', color='orange')
    plt.legend()

# 评论家损失
plt.subplot(1, 3, 2)
plt.plot(ddpg_episode_critic_losses)
plt.title('DDPG 摆动:平均评论家损失')
plt.xlabel('回合')
plt.ylabel('均方误差')
plt.grid(True)
if len(ddpg_episode_critic_losses) >= 10:
    closs_ma_ddpg = np.convolve(ddpg_episode_critic_losses, np.ones(10)/10, mode='valid')
    plt.plot(np.arange(len(closs_ma_ddpg)) + 9, closs_ma_ddpg, label='10 回合移动平均', color='orange')
    plt.legend()

# 演员损失
plt.subplot(1, 3, 3)
plt.plot(ddpg_episode_actor_losses)
plt.title('DDPG 摆动:平均演员损失')
plt.xlabel('回合')
plt.ylabel('平均 -Q 值')
plt.grid(True)
if len(ddpg_episode_actor_losses) >= 10:
    aloss_ma_ddpg = np.convolve(ddpg_episode_actor_losses, np.ones(10)/10, mode='valid')
    plt.plot(np.arange(len(aloss_ma_ddpg)) + 9, aloss_ma_ddpg, label='10 回合移动平均', color='orange')
    plt.legend()

plt.tight_layout()
plt.show()

深入理解深度确定性策略梯度DDPG:基于python从零实现_第2张图片

DDPG 学习曲线分析(摆动环境):

  1. 回合奖励(左图):
    从大约 -1500 到 -200,总奖励呈现出明显的上升趋势(变得不那么负),这表明智能体在解决摆动任务上取得了显著进步。这种任务对 DDPG 来说比较敏感,因此奖励的波动很大,但移动平均线确认了积极的学习趋势。

  2. 平均评论家损失(中图):
    评论家的均方误差损失在整个训练过程中意外地增加了。虽然有些反直觉,但这在 DDPG 中很常见。随着策略的改进和探索更高价值(不那么负)的状态-动作对,评论家不断调整以预测这些增加的目标 Q 值,导致损失上升,而不是收敛到零。不过,这也可能暗示学习过程中存在一些不稳定性。

  3. 平均演员损失(右图):
    这个图(代表演员动作的平均 Q 值)显示出强劲且平滑的上升趋势,与奖励的改善密切相关。这表明演员成功地学习到了评论家评价越来越好的动作(导致更高的 Q 值)。末尾的平稳趋势表明策略已经收敛到一个有效的解决方案。

总体结论:
DDPG 在摆动任务上取得了显著的学习成果,将奖励推向了更好的值。演员有效地根据评论家的评价优化了策略。尽管评论家损失的增加值得关注(可能表明评论家难以跟上演员的步伐或适应变化的价值尺度),但总体奖励趋势确认了成功的学习,尽管存在 DDPG 常见的波动。

分析学习到的策略(测试)

通过在环境中运行训练好的 DDPG 智能体(无噪声,确定性动作),可视化其表现,运行几回合。

def test_ddpg_agent(actor_net: ActorNetwork, 
                    env_instance: gym.Env, 
                    num_episodes: int = 5, 
                    render: bool = False, # 设置为 True 以可视化
                    seed_offset: int = 1000) -> None:
    """
    测试训练好的 DDPG 智能体(确定性动作)。
    
    参数:
    - actor_net:训练好的演员网络。
    - env_instance:环境实例。
    - num_episodes:测试回合数。
    - render:如果为 True,则尝试渲染环境。
    - seed_offset:种子偏移量,用于在测试中随机化环境。
    """
    if env_instance is None:
        print("环境不可用,无法进行测试。")
        return
        
    actor_net.eval() # 将演员设置为评估模式(非常重要!)
    
    print(f"\n--- 测试 DDPG 智能体({num_episodes} 回合) ---")
    all_rewards = []
    for i in range(num_episodes):
        state_np, info = env_instance.reset(seed=seed + seed_offset + i) # 使用不同的种子进行测试
        state = torch.from_numpy(state_np).float().to(device)
        episode_reward = 0
        done = False
        t = 0
        while not done:
            if render:
                try:
                    # 尝试渲染(可能需要额外的环境/系统设置)
                    env_instance.render()
                    time.sleep(0.01) # 稍微减慢渲染速度
                except Exception as e:
                    print(f"渲染失败:{e}。禁用渲染。")
                    render = False # 如果渲染失败,则禁用渲染
            
            with torch.no_grad():
                # 确定性地选择动作(无噪声)
                action = actor_net(state).cpu().numpy()
            
            # 在测试中仍然需要裁剪动作
            action_clipped = np.clip(action, env_instance.action_space.low, env_instance.action_space.high)
            
            next_state_np, reward, terminated, truncated, _ = env_instance.step(action_clipped)
            done = terminated or truncated
            state = torch.from_numpy(next_state_np).float().to(device)
            episode_reward += reward
            t += 1
        
        print(f"测试回合 {i+1}:奖励 = {episode_reward:.2f},长度 = {t}")
        all_rewards.append(episode_reward)
        if render:
             env_instance.close() # 关闭渲染窗口

    print(f"--- 测试完成。平均奖励:{np.mean(all_rewards):.2f} ---")

# 运行测试回合(确保环境仍然可用)
test_ddpg_agent(actor_ddpg, env, num_episodes=3, render=False) # 如果有显示设置,可以设置 render=True
--- 测试 DDPG 智能体(3 回合) ---
测试回合 1:奖励 = -130.24,长度 = 200
测试回合 2:奖励 = -118.55,长度 = 200
测试回合 3:奖励 = -369.77,长度 = 200
--- 测试完成。平均奖励:-206.19 ---

DDPG 中常见的挑战及解决方案

挑战 1:对超参数敏感

  • 问题:DDPG 对学习率、目标更新率 ( τ \tau τ)、噪声参数、网络架构和批量大小都非常敏感。
    解决方案
    • 仔细调整:从已知适用于该环境的良好值开始,系统地进行调整。
    • 减小 τ \tau τ:更慢的目标更新(更小的 τ \tau τ)通常可以提高稳定性。
    • 使用不同的学习率:为演员和评论家使用不同的(通常是更低的)学习率可能会有帮助。
    • 批量归一化:在某些情况下,可以在网络层中使用批量归一化来稳定学习。

挑战 2:Q 值高估

  • 问题:评论家可能会高估 Q 值,尤其是在使用函数近似时。这可能导致演员学习到一个次优的策略,利用这些高估的 Q 值。
    解决方案
    • TD3(双延迟 DDPG):DDPG 的直接继任者,通过使用双评论家(取最小目标)、延迟策略更新和目标策略平滑来解决这个问题。
    • 目标网络的使用:与不使用目标网络相比,目标网络已经在一定程度上缓解了这个问题。

挑战 3:连续空间中的探索

  • 问题:简单的噪声(如高斯噪声)可能不足以有效地探索复杂的状态-动作空间。
    解决方案
    • 相关噪声(奥恩斯坦-乌伦贝克):在原始 DDPG 论文中使用,鼓励更一致的探索轨迹。
    • 参数空间噪声:直接向演员的参数添加噪声,而不是动作,可能会导致更一致的探索。
    • 自适应噪声:根据性能或演员与目标演员参数之间的距离调整噪声规模。

挑战 4:复杂任务的学习速度慢

  • 问题:虽然由于离线策略学习,DDPG 的样本效率很高,但在非常困难的任务上,收敛速度仍然可能很慢。
    解决方案
    • 优先经验回放(PER):从回放缓存中更频繁地采样重要的转换。
    • TD3/SAC:继任算法通常学习速度更快、更稳健。
    • 分布式 DDPG(例如,Ape-X DDPG):使用多个演员并行收集经验,喂给中央学习器。

总结

深度确定性策略梯度(DDPG)成功地将演员-评论家方法扩展到连续动作空间,通过学习确定性策略并利用 DQN 中的技术(如回放缓存和目标网络)实现稳定的离线策略学习。它在处理机器人技术和连续控制任务时表现出色。

尽管 DDPG 对超参数比较敏感,有时会出现 Q 值高估的问题,但它的核心概念——离线策略演员-评论家学习、确定性策略和目标网络——为更先进、更稳健的算法(如 TD3 和 SAC)铺平了道路。理解 DDPG 是解决强化学习中连续控制问题的关键。

你可能感兴趣的:(复现强化学习RL算法,python,开发语言,人工智能,机器学习,神经网络,强化学习,RL)