目标
本教程的目标是研究如何直接求解最优控制问题,既可以通过计算从当前状态到目标状态的轨迹,也可以通过计算策略来实现。为了使用简单的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]
,ix
和iu
分别是状态和控制量的索引。我们使用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连杆摆,调优会更加困难。你可能想在四旋翼机器人上尝试(因此本教程以此为题),但我认为这会是一项艰巨的工作。