Bootstrap

【Pinocchio】 7) 学习飞行(又名策略学习)

目标

本教程的目标是研究如何直接求解最优控制问题,既可以通过计算从当前状态到目标状态的轨迹,也可以通过计算策略来实现。为了使用简单的Python代码保持合适的计算时间,我们将仅处理一个扭矩有限的简单倒立摆问题,这个倒立摆需要摆动到直立状态。本教程介绍的算法依次计算最优轨迹、使用Q表和线性网络的离散策略,以及使用深度神经网络的连续策略。

7.0) 先决条件

我们需要一个倒立摆模型和一个神经网络。

先决条件1:倒立摆模型

教程提供了两个模型。第一个是连续模型,使用Pinocchio库实现,在pendulum.py文件中以Pendulum类的形式存在。

"""
创建一个N摆的仿真环境。
使用示例:

env = Pendulum(N)
env.reset()

for i in range(1000):
    env.step(zero(env.nu))
    env.render()

"""

import time  # 导入时间模块,用于控制渲染速度

import numpy as np  # 导入numpy库,用于数值计算
import pinocchio as pin  # 导入pinocchio库,用于机器人动力学和运动学计算
from display import Display  # 导入Display类,用于3D可视化
from numpy.linalg import inv  # 导入矩阵求逆函数
from pinocchio.utils import rand  # 导入随机数生成函数


classVisual:
    """
    表示机器人一个3D网格的类,附加到关节上。该类包含:
    * Gepetto查看器中3D对象的名称。
    * 关节在运动学树中的ID。
    * 物体相对于关节框架的位置。
    此类仅在Robot.visuals列表中使用(见下文)。
    """

    def__init__(self, name, jointParent, placement):
        self.namename = name  # Gepetto查看器中的名称
        self.jointParentjointParent = jointParent  # 关节的ID(整数)
        self.placementplacement = placement  # 物体相对于关节的位置,即bodyMjoint

    defplace(self, display, oMjoint):
        oMbody = oMjoint * self.placementplacement  # 计算物体在世界坐标系中的位置
        display.place(self.namename, oMbody,False)# 在查看器中放置物体


classPendulum:
    """
    定义一个具有7自由度的机器人类(肩部=3 + 肘部=1 + 手腕=3)。
    配置为nq=7,速度相同。
    类的成员包括:
    * viewer: 封装gepetto查看器客户端的显示对象,用于创建和放置3D对象。
    * model: 机器人的运动学树。
    * data: 用于运动学算法的临时变量。
    * visuals: 渲染机器人所需的所有'visual' 3D对象的列表,列表中的每个元素都是一个Visual对象(见上文)。

    参见tp1.py以获取使用示例。
    """

    def__init__(self, nbJoint=1):
        """创建一个N摆的Pinocchio模型,N为参数<nbJoint>。"""
        self.viewerviewer = Display()# 初始化显示对象
        self.visualsvisuals =[]# 存储所有视觉对象的列表
        self.modelmodel = pin.Model()# 初始化Pinocchio模型
        self.createPendulumcreatePendulum(nbJoint)# 创建N摆模型
        self.datadata = self.modelmodel.createData()# 创建模型数据

        self.q0q0 = np.zeros(self.modelmodel.nq)# 初始配置

        self.DTDT =5e-2# 时间步长
        self.NDTNDT =2# 每次积分的欧拉步数(内部)
        self.KfKf =0.10# 摩擦系数
        self.vmaxvmax =8.0# 最大速度(超出时会被裁剪)
        self.umaxumax =2.0# 最大扭矩(超出时会被裁剪)
        self.withSinCoswithSinCos =False# 如果为True,状态为[cos(q),sin(q),qdot],否则为[q,qdot]

    defcreatePendulum(self, nbJoint, rootId=0, prefix="", jointPlacement=None):
        color =[red, green, blue, transparency]=[1,1,0.78,1.0]# 定义颜色
        colorred =[1.0,0.0,0.0,1.0]# 红色

        jointId = rootId  # 初始关节ID
        jointPlacement =(
            jointPlacement if jointPlacement isnotNoneelse pin.SE3.Identity()
        )# 如果未提供关节位置,则使用单位变换
        length =1.0# 摆臂长度
        mass = length  # 质量与长度成正比
        inertia = pin.Inertia(
            mass,
            np.array([0.0,0.0, length /2]),
            mass /5* np.diagflat([1e-2, length**2,1e-2]),
        )# 惯性矩阵

        for i inrange(nbJoint):# 循环创建多个关节
            istr =str(i)
            name = prefix +"joint"+ istr
            jointName, _bodyName =[name +"_joint", name +"_body"]
            jointId = self.modelmodel.addJoint(
                jointId, pin.JointModelRY(), jointPlacement, jointName
            )# 添加旋转关节
            self.modelmodel.appendBodyToJoint(jointId, inertia, pin.SE3.Identity())
            try:
                self.viewerviewer.viewer.gui.addSphere(
                    "world/"+ prefix +"sphere"+ istr,0.15, colorred
                )# 添加球体表示关节
            except:# noqa: E722
                pass
            self.visualsvisuals.append(
                Visual("world/"+ prefix +"sphere"+ istr, jointId, pin.SE3.Identity())
            )
            try:
                self.viewerviewer.viewer.gui.addCapsule(
                    "world/"+ prefix +"arm"+ istr,0.1,0.8* length, color
                )# 添加胶囊表示摆臂
            except:# noqa: E722
                pass
            self.visualsvisuals.append(
                Visual(
                    "world/"+ prefix +"arm"+ istr,
                    jointId,
                    pin.SE3(np.eye(3), np.array([0.0,0.0, length /2])),
                )
            )
            jointPlacement = pin.SE3(np.eye(3), np.array([0.0,0.0, length]))

        self.modelmodel.addFrame(
            pin.Frame("tip", jointId,0, jointPlacement, pin.FrameType.OP_FRAME)
        )# 添加末端执行器框架

    defdisplay(self, q):
        pin.forwardKinematics(self.modelmodel, self.datadata, q)# 计算前向运动学
        for visual in self.visualsvisuals:
            visual.place(self.viewerviewer, self.datadata.oMi[visual.jointParent])
        self.viewerviewer.viewer.gui.refresh()# 刷新查看器

    @property
    defnq(self):
        return self.modelmodel.nq  # 返回配置维度

    @property
    defnv(self):
        return self.modelmodel.nv  # 返回速度维度

    @property
    defnx(self):
        return self.nqnq + self.nvnv  # 返回状态维度

    @property
    defnobs(self):
        return self.nxnx + self.withSinCoswithSinCos  # 返回观测维度

    @property
    defnu(self):
        return self.nvnv  # 返回控制输入维度

    defreset(self, x0=None):
        if x0 isNone:
            q0 = np.pi *(rand(self.nqnq)*2-1)# 随机初始化配置
            v0 = rand(self.nvnv)*2-1# 随机初始化速度
            x0 = np.vstack([q0, v0])# 组合初始状态
        assertlen(x0)== self.nxnx
        self.xx = x0.copy()
        self.rr =0.0
        return self.obsobs(self.xx)# 返回观测值

    defstep(self, u):
        assertlen(u)== self.nunu  # 检查控制输入维度
        _, self.rr = self.dynamicsdynamics(self.xx, u)# 更新状态并计算奖励
        return self.obsobs(self.xx), self.rr  # 返回观测值和奖励

    defobs(self, x):
        if self.withSinCoswithSinCos:
            return np.vstack(
                [np.vstack([np.cos(qi), np.sin(qi)])for qi in x[: self.nqnq]]
                +[x[self.nqnq :]],
            )# 返回带有sin/cos的状态
        else:
            return x.copy()# 返回原始状态

    deftip(self, q):
        """返回摆尖的高度"""
        pin.framesKinematics(self.modelmodel, self.datadata, q)# 计算末端执行器运动学
        return self.datadata.oMf[1].translation[2,0]# 返回Z轴高度

    defdynamics(self, x, u, display=False):
        """
        动态函数:x,u -> xnext=f(x,y)。
        将结果存储在x中(初始值被销毁)。
        同时计算此步骤的成本。
        返回x以便于使用,并返回成本。
        """

        defmodulePi(th):
            return(th + np.pi)%(2* np.pi)- np.pi  # 角度归一化到[-π, π]

        defsumsq(x):
            return np.sum(np.square(x))# 计算平方和

        cost =0.0
        q = modulePi(x[: self.nqnq])# 归一化角度
        v = x[self.nqnq :]# 提取速度
        u = np.clip(np.reshape(np.array(u),[self.nunu,1]),-self.umaxumax, self.umaxumax)# 裁剪控制输入

        DT = self.DTDT / self.NDTNDT  # 计算每一步的时间间隔
        for i inrange(self.NDTNDT):
            pin.computeAllTerms(self.modelmodel, self.datadata, q, v)# 计算所有动力学项
            M = self.datadata.M  # 质量矩阵
            b = self.datadata.nle  # 非线性效应
            a = inv(M)*(u - self.KfKf * v - b)# 计算加速度

            v += a * DT  # 更新速度
            q += v * DT  # 更新角度
            cost +=(sumsq(q)+1e-1* sumsq(v)+1e-3* sumsq(u))* DT  # 累积成本

            if display:
                self.displaydisplay(q)# 显示当前状态
                time.sleep(1e-4)

        x[: self.nqnq]= modulePi(q)# 更新状态
        x[self.nqnq :]= np.clip(v,-self.vmaxvmax, self.vmaxvmax)

        return x,-cost  # 返回更新后的状态和负成本

    defrender(self):
        q = self.xx[: self.nqnq]# 提取当前配置
        self.displaydisplay(q)# 显示当前状态
        time.sleep(self.DTDT /10)  # 控制渲染速度

这段代码是针对N连杆摆的通用代码,我们将使用1自由度模型。状态为[q, v],即摆的角度和角速度。控制量是关节扭矩。这个摆重1千克,长1米,质心在距离关节0.5米处。在渲染模型之前,不要忘记启动gepetto-gui。每次对模拟器进行积分时,它会返回一个新状态和前一动作的奖励(奖励设定为位置、速度和控制量平方的加权和)。状态作为类的成员被记录下来,可以通过env.x访问。在修改状态之前,不要忘记进行复制。

from pendulum import Pendulum
from pinocchio.utils import *
env = Pendulum(1) # 连续倒立摆
NX = env.nobs # ... 训练在使用角度、角速度且神经元数量翻倍时收敛
NU = env.nu # 控制量维度为1:关节扭矩
x = env.reset() # 采样一个初始状态
u = rand(NU) # 采样一个控制量
x, reward = env.step(u) # 对控制量u进行模拟器积分并获取奖励
env.render() # 显示状态为env.x时的模型

同一模型的第二个版本在dpendulum.py文件中,具有离散动力学。状态同样是[q, v]不过在NQ个位置和NV个速度上进行了离散化。此时状态是一个整数,等于iq*NV+iv,取值范围从0到NQ*NV=NX。控制量也从0到NU进行了离散化。

import time  # 导入时间模块,用于控制渲染速度

import numpy as np  # 导入numpy库,用于数值计算
from numpy import pi  # 导入pi常量
from pendulum import Pendulum  # 导入Pendulum类,用于模拟单摆系统

p = Pendulum(1)# 创建一个单摆对象

NQ =20# 位置的离散化步数
NV =19# 速度的离散化步数
VMAX =5# 最大速度(速度范围为[-VMAX, VMAX])
NU =9# 扭矩的离散化步数
UMAX =5# 最大扭矩(扭矩范围为[-UMAX, UMAX])
DT =3e-1# 时间步长

DQ =2* pi / NQ  # 位置的离散化间隔
DV =2.0*(VMAX)/ NV  # 速度的离散化间隔
DU =2.0*(UMAX)/ NU  # 扭矩的离散化间隔


# 连续到离散
defc2dq(q):
    q =(q + pi)%(2* pi)# 将角度归一化到[0, 2π]
    returnint(round(q / DQ))% NQ  # 返回离散化的索引


defc2dv(v):
    v = np.clip(v,-VMAX +1e-3, VMAX -1e-3)# 将速度裁剪到有效范围内
    returnint(np.floor((v + VMAX)/ DV))# 返回离散化的索引


defc2du(u):
    u = np.clip(u,-UMAX +1e-3, UMAX -1e-3)# 将扭矩裁剪到有效范围内
    returnint(np.floor((u + UMAX)/ DU))# 返回离散化的索引


defc2d(qv):
    """从连续值转换为离散值"""
    return c2dq(qv[0]), c2dv(qv[1])# 分别对位置和速度进行离散化


# 离散到连续
defd2cq(iq):
    iq = np.clip(iq,0, NQ -1)# 将索引裁剪到有效范围内
    return iq * DQ - pi  # 返回连续的角度值


defd2cv(iv):
    iv = np.clip(iv,0, NV -1)-(NV -1)/2# 将索引裁剪并调整到速度范围
    return iv * DV  # 返回连续的速度值


defd2cu(iu):
    iu = np.clip(iu,0, NU -1)-(NU -1)/2# 将索引裁剪并调整到扭矩范围
    return iu * DU  # 返回连续的扭矩值


defd2c(iqv):
    """从离散值转换为连续值"""
    return d2cq(iqv[0]), d2cv(iqv[1])# 分别对位置和速度进行连续化


defx2i(x):
    return x[0]+ x[1]* NQ  # 将二维状态索引转换为一维索引


defi2x(i):
    return[i % NQ, i // NQ]# 将一维索引转换为二维状态索引


# --- 单摆环境


classDPendulum:
    def__init__(self):
        self.pendulumpendulum = Pendulum(1)# 初始化单摆对象
        self.pendulumpendulum.DT = DT  # 设置时间步长
        self.pendulumpendulum.NDT =5# 设置内部积分步数

    @property
    defnqv(self):
        return[NQ, NV]# 返回位置和速度的离散化步数

    @property
    defnx(self):
        return NQ * NV  # 返回状态空间的总大小

    @property
    defnu(self):
        return NU  # 返回控制输入的离散化步数

    @property
    defgoal(self):
        return x2i(c2d([0.0,0.0]))# 返回目标状态的索引

    defreset(self, x=None):
        if x isNone:
            x =[np.random.randint(0, NQ), np.random.randint(0, NV)]# 随机初始化状态
        else:
            x = i2x(x)# 将一维索引转换为二维状态
        assertlen(x)==2# 确保状态维度正确
        self.xx = x  # 设置当前状态
        return x2i(self.xx)# 返回一维索引形式的状态

    defstep(self, iu):
        self.xx = self.dynamicsdynamics(self.xx, iu)# 更新状态
        reward =1if x2i(self.xx)== self.goalgoal else0# 计算奖励
        return x2i(self.xx), reward  # 返回新状态和奖励

    defrender(self):
        q = d2cq(self.xx[0])# 将离散位置转换为连续值
        self.pendulumpendulum.display(
            np.array(
                [
                    q,
                ]
            )
        )# 显示当前状态
        time.sleep(self.pendulumpendulum.DT)# 控制渲染速度

    defdynamics(self, ix, iu):
        x = np.array(d2c(ix))# 将离散状态转换为连续值
        u = d2cu(iu)# 将离散控制输入转换为连续值

        self.xc, _ = self.pendulumpendulum.dynamics(x, u)# 调用单摆的动力学模型
        return c2d(x.T.tolist()[0])# 返回更新后的离散状态


"""
env = DPendulum()

print env.reset(x2i([14,11]))
hq = []
hv = []
hqc = []
hvc = []
u = 0
for i in range(100):
    ix,r=env.step(u)
    q,v = i2x(ix)
    env.render()
    if d2cv(v)==0.0: u = MAXU-1 if u==0 else 0
    hq.append( d2cq(env.x[0]) )
    hv.append( d2cv(env.x[1]) )
    hqc.append( env.xc[0,0] )
    hvc.append( env.xc[1,0] )

"""


"""
EPS = 1e-3
q = 0.0
v = -VMAX
hq = []
hv = []
hiq = []
hiv = []
hqa = []
hva = []

while q<2*pi:
    hq.append(q)
    iq = c2dq(q)
    hiq.append(iq)
    hqa.append(d2cq(iq))
    q += EPS
while v<VMAX:
    iv = c2dv(v)
    hv.append(v)
    hiv.append(iv)
    hva.append(d2cv(iv))
    v += EPS

"""
from dpendulum import DPendulum
env = DPendulum()
NX = env.nx # (离散)状态数量
NU = env.nu # (离散)控制量数量
env.reset()
env.step(5)
env.render()

也可以使用其他模型。特别地,我们使用了与OpenAI的Gym类似的API,你可能有兴趣浏览并尝试将其与后续算法结合使用。

先决条件2:带优化器的神经网络

我们将使用谷歌的TensorFlow库,可以通过pip安装。

pip install --user tensorflow tflearn
7.1) 优化最优轨迹

在第一个教程中,我们实现了一个非线性优化程序,用于优化连续倒立摆单个轨迹的成本。轨迹由其初始状态x0和分段常数控制向量U=[u0 ... uT-1]表示,其中T是时间步数。成本简单来说就是倒立摆环境返回的成本函数l(x,u)的积分。

然后,从一个零控制轨迹开始优化积分成本,直到倒立摆最终达到直立状态。注意,摆动次数可能并非最优。

优化轨迹的代码在ocp.py文件中。

"""
通过直接优化单条轨迹来解决最优控制问题的示例。
"""

import signal  # 导入信号模块,用于处理用户输入信号
import time  # 导入时间模块,用于控制渲染速度

import matplotlib.pyplot as plt  # 导入matplotlib库,用于绘图
import numpy as np  # 导入numpy库,用于数值计算
from pendulum import Pendulum  # 导入Pendulum类,用于模拟单摆环境
from scipy.optimize import fmin_l_bfgs_b  # 导入优化算法L-BFGS-B

env = Pendulum(1)# 初始化单摆环境
NSTEPS =50# 轨迹的时间步数
x0 = env.reset().copy()# 获取初始状态并复制


defcost(U):
    """计算从初始状态X0开始,控制U下的轨迹成本"""
    env.reset(x0)# 重置环境到初始状态
    csum =0.0# 初始化累计奖励
    for t inrange(NSTEPS):# 遍历每个时间步
        u = U[env.nu * t : env.nu *(t +1)]# 提取当前时间步的控制输入
        _, r = env.step(u)# 执行一步单摆动力学,并获取奖励r
        csum += r  # 累计奖励
    return-csum  # 返回成本(负的奖励)


defdisplay(U, verbose=False):
    """在Gepetto查看器中显示轨迹"""
    x = x0.copy()# 复制初始状态
    if verbose:# 如果启用详细输出
        print("U = "," ".join(map(lambda u:f"{u:.1f}", np.asarray(U).flatten())))# 打印控制输入
    for i inrange(len(U)// env.nu):# 遍历每个时间步
        env.dynamics(x, U[env.nu * i : env.nu *(i +1)],True)# 更新状态
        env.display(x)# 在查看器中显示当前状态
        time.sleep(5e-2)# 控制渲染速度
        if verbose:# 如果启用详细输出
            print(f"X{i}")# 打印当前状态索引


classCallBack:
    """回调函数,用于跟踪优化器的步骤"""

    def__init__(self):
        self.iteriter =0# 初始化迭代次数
        self.withdisplaywithdisplay =False# 是否启用显示
        self.h_rwdh_rwd =[]# 存储每次迭代的成本历史

    def__call__(self, U):
        """回调函数主体"""
        print(
            "Iteration ",
            self.iteriter,
            " ".join(map(lambda u:f"{u:.1f}", np.asarray(U).flatten())),# 打印当前控制输入
        )
        self.iteriter +=1# 迭代次数加1
        self.UU = U.copy()# 保存当前控制输入
        self.h_rwdh_rwd.append(cost(U))# 计算并记录当前成本
        if self.withdisplaywithdisplay:# 如果启用显示
            display(U)# 显示轨迹

    defsetWithDisplay(self, boolean=None):
        """设置是否启用显示"""
        self.withdisplaywithdisplay =not self.withdisplaywithdisplay if boolean isNoneelse boolean


callback = CallBack()# 初始化回调函数
signal.signal(signal.SIGTSTP,lambda x, y: callback.setWithDisplay())# 当按下CTRL-Z时切换显示模式

# --- 最优控制问题求解
# 初始猜测:所有控制输入为负的最大值
U0 = np.zeros(NSTEPS * env.nu)- env.umax
bounds =(
    [
        [-env.umax, env.umax],# 每个控制输入的上下界
    ]
    * env.nu
    * NSTEPS
)# 设置控制输入的边界为[-umax, umax]

# 使用L-BFGS-B算法进行优化
U, c, info = fmin_l_bfgs_b(
    cost, x0=U0, callback=callback, approx_grad=True, bounds=bounds
)

# 优化完成后,在Gepetto查看器中显示轨迹
display(U,True)

plt.plot(callback.h_rwd)# 绘制成本历史曲线
plt.show()  # 显示图表
7.2) 离散倒立摆的Q表求解

现在我们考虑离散模型,它有NX个状态和NU个控制量。可以把这个模型想象成一个棋盘,上面绘制了一个迷宫(可能是非平面的),你要求系统找到从初始状态到棋盘中心最终状态的路径。在进行试验时,如果系统在100步内到达目标,会收到奖励1,否则收到奖励0,以此得知试验成功与否。为了记录已经探索过的路径,我们可以存储一个大小为NX×NU的表格,每个值表示在状态X下采取动作U时获得奖励的可能性。这个表格被称为Q表,它对应于离散系统的哈密顿量(Q值)。Q值可以使用Dijkstra算法在表格中反向传播。由于我们不知道目标状态,反向传播是沿着迷宫内部的随机滚动进行的,这很可能收敛到精确哈密顿量的近似值。一旦计算出Q表,最优策略就是简单地选择对应于状态X所在行的Q值向量中的最大值。

该算法在qtable.py文件中。

"""
使用简单的离散化单摆环境进行Q表学习的示例。
"""

import signal  # 导入信号模块,用于处理用户输入信号
import time  # 导入时间模块,用于生成随机种子

import matplotlib.pyplot as plt  # 导入matplotlib库,用于绘图
import numpy as np  # 导入numpy库,用于数值计算
from dpendulum import DPendulum  # 导入DPendulum类,用于模拟离散化的单摆环境

# --- 随机种子
RANDOM_SEED =int((time.time()%10)*1000)# 根据当前时间生成随机种子
print(f"Seed = {RANDOM_SEED}")# 打印随机种子
np.random.seed(RANDOM_SEED)# 设置随机种子以确保结果可复现

# --- 超参数
NEPISODES =500# 训练的总回合数
NSTEPS =50# 每个回合的最大步数
LEARNING_RATE =0.85# 学习率
DECAY_RATE =0.99# 折扣因子(未来奖励的衰减率)

# --- 环境
env = DPendulum()# 初始化离散化的单摆环境
NX = env.nx  # 状态空间的大小(离散状态数)
NU = env.nu  # 动作空间的大小(离散动作数)

Q = np.zeros([env.nx, env.nu])# 初始化Q表为零矩阵


defrendertrial(maxiter=100):
    """使用贪婪策略从随机状态开始执行一次试验。"""
    s = env.reset()# 重置环境并获取初始状态
    for i inrange(maxiter):# 最大迭代次数为maxiter
        a = np.argmax(Q[s,:])# 使用贪婪策略选择动作(选择Q值最大的动作)
        s, r = env.step(a)# 执行动作并获取新状态和奖励
        env.render()# 渲染当前状态
        if r ==1:# 如果获得奖励(达到目标状态)
            print("Reward!")# 打印提示信息
            break# 结束试验


signal.signal(
    signal.SIGTSTP,lambda x, y: rendertrial()
)# 当按下CTRL-Z时调用rendertrial函数进行渲染
h_rwd =[]# 学习历史记录(用于绘图)

for episode inrange(1, NEPISODES):# 遍历每个训练回合
    x = env.reset()# 重置环境并获取初始状态
    rsum =0.0# 初始化当前回合的累计奖励
    for steps inrange(NSTEPS):# 遍历每个时间步
        u = np.argmax(
            Q[x,:]+ np.random.randn(1, NU)/ episode
        )# 使用带噪声的贪婪策略选择动作(探索与利用)
        x2, reward = env.step(u)# 执行动作并获取新状态和奖励

        # 计算参考Q值(遵循HJB方程)
        Qref = reward + DECAY_RATE * np.max(Q[x2,:])

        # 更新Q表以更好地满足HJB方程
        Q[x, u]+= LEARNING_RATE *(Qref - Q[x, u])
        x = x2  # 更新状态
        rsum += reward  # 累计奖励
        if reward ==1:# 如果获得奖励(达到目标状态)
            break# 结束当前回合

    h_rwd.append(rsum)# 记录当前回合的累计奖励
    ifnot episode %20:# 每20个回合打印一次进度
        print(f"Episode #{episode} done with {sum(h_rwd[-20:])} success")

print(f"Total rate of success: {sum(h_rwd)/ NEPISODES:.3f}")# 打印总的平均成功率
rendertrial()# 渲染一次试验
plt.plot(np.cumsum(h_rwd)/range(1, NEPISODES))# 绘制累计平均奖励曲线
plt.show()  # 显示图表
7.3) 使用线性网络的Q表

其思路是类似地对连续模型的Q值进行近似。由于连续模型具有无限多个状态和控制量,无法使用表格表示。我们将使用任何函数基来近似Q值。在本教程中,我们选择使用深度神经网络。首先,让我们使用一个简单的网络来存储Q表。

基本上,对于所有可能的控制量u的Q值向量,是通过将Q表与对应于状态的独热向量(除了单个1之外其余全为0)相乘得到的。然后,最优策略是这个向量中的最大值:iu^* = argmax(Q*h(ix)),其中h(ix) = [ 0 0 ... 0 1 0 ... 0]ixiu分别是状态和控制量的索引。我们使用TensorFlow来存储数组Q。Q值网络就是Q与独热编码的状态x相乘,而策略则是结果的最大值索引。

现在,Q的系数是定义Q值(进而定义策略)的参数。必须对它们进行优化,使其符合成本函数。根据哈密顿 - 雅可比 - 贝尔曼方程,我们知道Q(x,u) = l(x,u) + max_u2 Q(f(x,u),u2)。我们优化Q值,使得沿着从迷宫内部连续滚动收集的样本,这个残差最小化。

该算法的实现代码在qnet.py文件中。注意,其收敛速度不如Q表算法快。

7.4) 演员 - 评论家网络

现在,我们将使用“Continuous control with deep reinforcement learning”,作者Lillicrap等人,arXiv:1509.02971中提出的“演员 - 评论家”方法,优化连续策略和相应的Q函数。

使用两个网络来表示Q函数和策略。第一个网络有两个输入:状态x和控制量u,输出是一个标量。它被优化以最小化沿着之前滚动收集的一批采样点对应的HJB方程的残差。

策略函数只有一个输入:状态X,输出是一个控制向量U(对于倒立摆,维度为1)。它被优化以最大化Q函数,即在每个状态下,Pi(x)对应于所有可能控制量u上Q(x,u)的最大值。

论文中提到并在本教程中实现了两个关键方面。第一,我们在从许多之前的滚动中收集的一批随机样本上进行学习,以打破批次中的时间依赖性。第二,我们通过存储Q值(评论家)和策略(演员)网络的副本,并在每一步仅对这些副本进行轻微修改,来对这两个网络的优化进行正则化。

相应的算法在continuous.py文件中实现。训练阶段需要100次滚动,可能需要几分钟(在虚拟机上可能更久)。

"""
使用线性Q网络的简单离散化单摆环境中的Q表学习示例。
"""

import signal  # 导入信号模块,用于处理用户输入信号
import time  # 导入时间模块,用于生成随机种子

import matplotlib.pyplot as plt  # 导入matplotlib库,用于绘图
import numpy as np  # 导入numpy库,用于数值计算
import tensorflow as tf  # 导入TensorFlow库,用于构建和训练神经网络
from dpendulum import DPendulum  # 导入DPendulum类,用于模拟离散化的单摆环境

# --- 随机种子
RANDOM_SEED =int((time.time()%10)*1000)# 根据当前时间生成随机种子
print(f"Seed = {RANDOM_SEED}")# 打印随机种子
np.random.seed(RANDOM_SEED)# 设置NumPy的随机种子
tf.set_random_seed(RANDOM_SEED)# 设置TensorFlow的随机种子

# --- 超参数
NEPISODES =500# 训练的总回合数
NSTEPS =50# 每个回合的最大步数
LEARNING_RATE =0.1# 优化器的学习率
DECAY_RATE =0.99# 折扣因子(未来奖励的衰减率)

# --- 环境
env = DPendulum()# 初始化离散化的单摆环境
NX = env.nx  # 状态空间的大小(离散状态数)
NU = env.nu  # 动作空间的大小(离散动作数)


# --- Q值网络
classQValueNetwork:
    def__init__(self):
        x = tf.placeholder(shape=[1, NX], dtype=tf.float32)# 输入占位符,表示状态的one-hot编码
        W = tf.Variable(tf.random_uniform([NX, NU],0,0.01, seed=100))# 权重矩阵,初始化为均匀分布
        qvalue = tf.matmul(x, W)# 计算Q值:Q(s, u) = x * W
        u = tf.argmax(qvalue,1)# 使用贪婪策略选择动作(最大Q值对应的动作)

        qref = tf.placeholder(shape=[1, NU], dtype=tf.float32)# 参考Q值占位符
        loss = tf.reduce_sum(tf.square(qref - qvalue))# 定义损失函数(均方误差)
        optim = tf.train.GradientDescentOptimizer(LEARNING_RATE).minimize(loss)# 使用梯度下降优化器最小化损失

        self.xx = x  # 网络输入
        self.qvalueqvalue = qvalue  # Q值作为状态x的函数
        self.uu = u  # 策略作为状态x的函数
        self.qrefqref = qref  # 参考Q值(用于更新)
        self.optimoptim = optim  # 优化器


# --- TensorFlow初始化
tf.reset_default_graph()# 重置默认计算图
qvalue = QValueNetwork()# 初始化Q值网络
sess = tf.InteractiveSession()# 创建交互式会话
tf.global_variables_initializer().run()# 初始化所有变量


defonehot(ix, n=NX):
    """返回一个向量,在索引<i>处为1,其余位置为0。"""
    return np.array(
        [
            [(i == ix)for i inrange(n)],
        ],
        np.float,
    )


defdisturb(u, i):
    """在动作上添加噪声以进行探索。"""
    u +=int(np.random.randn()*10/(i /50+10))# 噪声随回合数逐渐减小
    return np.clip(u,0, NU -1)# 将动作裁剪到有效范围内


defrendertrial(maxiter=100):
    """使用贪婪策略从随机状态开始执行一次试验并渲染过程。"""
    x = env.reset()# 重置环境并获取初始状态
    for i inrange(maxiter):# 最大迭代次数为maxiter
        u = sess.run(qvalue.u, feed_dict={qvalue.x: onehot(x)})[0]# 使用贪婪策略选择动作
        x, r = env.step(u)# 执行动作并获取新状态和奖励
        env.render()# 渲染当前状态
        if r ==1:# 如果获得奖励(达到目标状态)
            print("Reward!")# 打印提示信息
            break# 结束试验


signal.signal(
    signal.SIGTSTP,lambda x, y: rendertrial()
)# 当按下CTRL-Z时调用rendertrial函数进行渲染

# --- 学习历史记录
h_rwd =[]# 学习历史记录(用于绘图)

# --- 训练
for episode inrange(1, NEPISODES):# 遍历每个训练回合
    x = env.reset()# 重置环境并获取初始状态
    rsum =0.0# 初始化当前回合的累计奖励

    for step inrange(NSTEPS -1):# 遍历每个时间步
        # 使用贪婪策略选择动作,并添加噪声进行探索
        u = sess.run(qvalue.u, feed_dict={qvalue.x: onehot(x)})[0]
        u = disturb(u, episode)

        x2, reward = env.step(u)# 执行动作并获取新状态和奖励

        # 计算参考Q值(遵循HJB方程)
        Q2 = sess.run(qvalue.qvalue, feed_dict={qvalue.x: onehot(x2)})# 下一状态的Q值
        Qref = sess.run(qvalue.qvalue, feed_dict={qvalue.x: onehot(x)})# 当前状态的Q值
        Qref[0, u]= reward + DECAY_RATE * np.max(Q2)# 更新参考Q值

        # 更新Q表以更好地满足HJB方程
        sess.run(qvalue.optim, feed_dict={qvalue.x: onehot(x), qvalue.qref: Qref})

        rsum += reward  # 累计奖励
        x = x2  # 更新状态
        if reward ==1:# 如果获得奖励(达到目标状态)
            break# 结束当前回合

    h_rwd.append(rsum)# 记录当前回合的累计奖励
    ifnot episode %20:# 每20个回合打印一次进度
        print(f"Episode #{episode} done with {sum(h_rwd[-20:])} success")

print(f"Total rate of success: {sum(h_rwd)/ NEPISODES:.3f}")# 打印总的平均成功率
rendertrial()# 渲染一次试验
plt.plot(np.cumsum(h_rwd)/range(1, NEPISODES))# 绘制累计平均奖励曲线
plt.show()  # 显示图表
7.5) 使用OCP求解器训练网络

使用OCP求解器,你可以从各种初始条件计算一些最优轨迹(比如10条)。用组成这10条最优轨迹的10×50个点初始化回放记忆,并仅从这个回放记忆中优化网络(无需额外滚动,但使用相同的小批量)。调整学习参数,直到网络收敛。

如果实现得当,OCP比策略具有更高的精度。然而,在运行时,评估策略的成本比求解新的OCP要低得多。我目前正在考虑如何在运行时使用网络来热启动或引导OCP求解器。

提供的求解器(轨迹和策略)在1连杆摆上运行效果较好。对于更复杂的动力学系统,如2连杆摆,调优会更加困难。你可能想在四旋翼机器人上尝试(因此本教程以此为题),但我认为这会是一项艰巨的工作。

;