上面的动画是本篇文章要达到的效果:一开始杆子不能稳定的倒立,经过一会的训练(自我学习),杆子可以稳定的倒立。这篇文章继续上文深度强化学习专栏 —— 1.研究现状中最后提到的使用深度强化学习实现倒立摆的前奏。本节我们从DQN(Deep Q-Network)算法开始说起,会经历阅读论文、手撕算法、最后实现CartPole倒立几个过程。  

一、阅读论文

搞强化学习的小伙伴都会了解论文在强化学习领域的重要性。深度强化学习技术从13年被提出以来,经历了非常快速的发展,许多新的算法被提出来,以至于书籍的更新速度远远跟不上最新的研究进展,同时,对于国内作者编写的强化学习书籍,有些甚至是把论文的翻译直接写在书中;再者说,很多书籍只是在一个较为低级的层次上来解读强化学习,我们能从这类书籍中学到的精髓少之又少,因此,论文是强化学习领域知识的源泉,利用好论文能非常有助于我们对算法的理解、对强化学习应用的理解。但是,就像每个领域都有神书(圣经)一样,强化学习领域也有一本圣经和一本可以提高我们实践能力的书:《Reinforcement Learning An Introduction second edition》和《Deep Reinforcement Learning Hands-On》。我认为,能够消化完这两本书,再读大量的论文,多写程序,就可以在深度强化学习领域做出一些贡献。   DQN算法来自这两篇论文“Playing Atari with Deep Reinforcement Learning”,“Human-level control through deep reinforcement learning”。 第一篇论文在Atari2600游戏中的6款中超过了之前的方法,第二篇论文引入了一个target Q-network, 在Atari2600中的49款中达到了人类的水平。我们看一下两篇文章的摘要部分。     Human-level control through deep reinforcement learning   图 1  Playing Atari with Deep Reinforcement Learning论文摘要 图2  Human-level control through deep reinforcement learning论文摘要  注:图片来自上文所列举论文   这两篇论文中提出的算法是用来解决Atari2600游戏的问题,也就是通过提取游戏的图像信息,经由DQN算法处理,得到游戏中的主体的动作,使游戏的得分尽可能的高。在深度强化学习的算法论文中,我们重点需要关注的是:神经网络结构+强化学习算法。神经网络结构+强化学习算法构成了深度强化学习的主体。在这两篇论文中,我们可以看到处理图像的网络架构的描述,但是,这些与我们今天的任务并没有什么关系,因为我们要处理的环境:倒立摆,并不是通过将倒立摆运动的图像信息传输给DQN算法进行处理,而是倒立摆环境会输出倒立摆当前的状态,即[x, x_dot, theta, theta_dot],分别代表 [底座的位置,底座的速度,杆的角度,杆的角速度]。我们是将这些标量或者向量信息传输给DQN算法,算法是得出控制倒立摆的动作序列。所以我们今天处理的是标量或者向量问题,而不是代表图像的矩阵信息,因此,网络的结构会和论文中提到的并不相同。我们重点需要的是论文中描述的强化学习算法及其参数,即下图所示部分:          

图3  DQN算法和其参数

  注:图片来自上文所列举论文

  那有些同学就问了,为什么倒立摆的环境会输出[x, x_dot, theta, theta_dot]这些信息?又为什么网络的结构与论文中不同?我们带着问题,进行下面的探讨。  

二、OpenAI Gym

涉及强化学习的编程实现,是很难绕过Gym这道坎的。Gym是一个开发和比较强化学习算法的工具箱,它支持教会代理从步行到打乒乓球或弹球等游戏[1]。这句话来自Gym的官方主页,在强化学习领域,Gym已经被广泛的用于游戏、机器人等环境的训练。Gym接口简单、完全开源(除了第三方Mujoco刚体动力学仿真引擎,但是有免费开源的PyBullet作为替代品),且源代码可读性高,非常易于使用。   稍微了解过强化学习的小伙伴,应该都知道Gym中“CartPole”这个东西,它就像编程世界里的“Hello World”,被用于强化学习的入门一课。我们也从这个资料最多的小白鼠环境入手,一步一步深入到倒立摆的环境。在开始之前,我们需要读一下cartpole问题的原始论文:Neuronlike adaptive elements that can solve difficult learning control problems ,前面我们说,论文是所有资料的最原始来源。从这篇论文中,可以了解到cartpole问题的背景、之前的解决方法以及系统的输入信息、输出信息分别是什么。  

三、使用Pytorch手撕DQN算法实现CartPole环境的倒立

  说了那么多,终于到了编程实现的环节了。在写代码之前,我们需要思考代码结构是怎样的,即怎么组织代码。这需要我们既要参考DQN算法的描述,也需要看一下网络上其他人的实现方式,不妨多去GitHub看看。   图4 DQN算法 注:图片来自上文所列举论文   我们仔细分析这两篇论文以及上图中的算法描述,不难发现有几个大的模块:1)网络结构;2)模型初始化;3)动作选择规则;4)学习优化过程。我们将这几个大的模块封装成类或者类中的函数,可以让代码结构更加清楚。 完整代码可在这里找到:dqn_cartpole_pytorch.ipynb  

1)网络结构

  因为处理的不是图片数据,因此不能使用论文中提到的卷积神经网络,对于标量或者向量类型的数据,使用含有至少一个隐藏层的全连接网络即可,也即多层感知机,如下图所示:   图5  多层感知机结构

注:图片来自漫谈ANN(2):BP神经网络| HaHack

  多层感知机由网络结构和网络参数所组成,网络结构即使用几个隐藏层、输入层维度、输出层维度等信息,网络参数指每层的网络权重。   我们首先需要明确一个问题,解决一个深度强化学习问题,哪些部分是属于算法的范畴?输入信息是否是?输出信息呢?网络结构算不算?   算法的范畴:神经网络结构和强化学习算法; 环境的范畴:算法的输入信息由环境的输出信息决定,算法的输出信息由环境的输入信息决定。   我们从CartPole的论文、Gym的CartPole例子的源码cartpole.py和一些博客中可以了解到,cartpole env返回状态信息和奖励信息:分别是1x4维的状态信息[x, x_dot, theta, theta_dot],代表 [底座的位置,底座的速度,杆的角度,杆的角速度]和1个代表奖励信息的标量值;一共有向左和向右运动两个动作,接收来自算法的1个标量的动作信号。环境输出的状态信息决定了算法的输入信息形状是1x4,环境只接受1个动作值决定了算法最终的输出信息形状是1。  
# CartPole.py源代码

"""
Classic cart-pole system implemented by Rich Sutton et al.
Copied from http://incompleteideas.net/sutton/book/code/pole.c
permalink: https://perma.cc/C9ZM-652R
"""

import math
import gym
from gym import spaces, logger
from gym.utils import seeding
import numpy as np


class CartPoleEnv(gym.Env):
    """
    Description:
        A pole is attached by an un-actuated joint to a cart, which moves along
        a frictionless track. The pendulum starts upright, and the goal is to
        prevent it from falling over by increasing and reducing the cart's
        velocity.
    Source:
        This environment corresponds to the version of the cart-pole problem
        described by Barto, Sutton, and Anderson
    Observation:
        Type: Box(4)
        Num     Observation               Min                     Max
        0       Cart Position             -4.8                    4.8
        1       Cart Velocity             -Inf                    Inf
        2       Pole Angle                -0.418 rad (-24 deg)    0.418 rad (24 deg)
        3       Pole Angular Velocity     -Inf                    Inf
    Actions:
        Type: Discrete(2)
        Num   Action
        0     Push cart to the left
        1     Push cart to the right
        Note: The amount the velocity that is reduced or increased is not
        fixed; it depends on the angle the pole is pointing. This is because
        the center of gravity of the pole increases the amount of energy needed
        to move the cart underneath it
    Reward:
        Reward is 1 for every step taken, including the termination step
    Starting State:
        All observations are assigned a uniform random value in [-0.05..0.05]
    Episode Termination:
        Pole Angle is more than 12 degrees.
        Cart Position is more than 2.4 (center of the cart reaches the edge of
        the display).
        Episode length is greater than 200.
        Solved Requirements:
        Considered solved when the average return is greater than or equal to
        195.0 over 100 consecutive trials.
    """
  从上面已经了解到,算法的输入信息是4个元素的向量,而输出是1个标量值。根据DQN论文中的一段话,我们可以确定网络结构的输出层的形状。如下图:                                

图6  Playing Atari with Deep Reinforcement Learning论文中关于神经网络作用的说明

注:图片来自上文所列举论文

  Playing Atari with Deep Reinforcement Learning论文中说,使用神经网络来估计动作状态价值函数(Q(s,a))的值,这意味着网络的输出层的形状要和动作的个数对应,即是包含2个元素,输入层上面提到了,4个元素。   下面编程实现:完整代码可在这里找到:dqn_cartpole_pytorch.ipynb  
import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
import gym
import matplotlib.pyplot as plt

env = gym.make('CartPole-v0')
env = env.unwrapped     # 这个的作用后面时候
N_ACTIONS = env.action_space.n
N_STATES = env.observation_space.shape[0]

class Net(nn.Module):
    def __init__(self, ):
        super(Net, self).__init__()
        self.fc1 = nn.Linear(N_STATES, 50)
        self.fc1.weight.data.normal_(0, 0.1)   # 网络权重参数初始化
        self.out = nn.Linear(50, N_ACTIONS)
        self.out.weight.data.normal_(0, 0.1)   # 网络权重参数初始化

    def forward(self, x):
        x = self.fc1(x)
        x = F.relu(x)
        actions_value = self.out(x)
        return actions_value
pytorch搭建网络的基本语法和结构就是这样,比keras稍微复杂,但更可控。上面代码搭建的网络结构和图5是类似的,区别只是隐藏层,图5显示隐藏层有5个元素,而上面代码搭建的隐藏层有50个元素。输入层和隐藏层之间包含一个ReLU()激活函数。至于为什么是这样的网络结构,我也是借鉴别人的成功的网络结构,但是最终结果分析,网络结构的微小差异,对最终的结果影响不会很大。  

2)模型初始化

    图7 DQN算法中初始化部分                                                                                                                                                    图8 DQN算法部分参数

   注:图片来自上文所列举论文

  根据论文,编写代码,我们将强化学习算法部分单独写到一个类中。这里面涉及到缓冲区的容量,所以要先定义这个参数值的大小。完整代码可在这里找到:dqn_cartpole_pytorch.ipynb  
device=torch.device("cuda" if torch.cuda.is_available() else "cpu")
MEMORY_CAPACITY = 2000     # 对应图8中 replay memory size
class DQN:
    def __init__(self):
        self.net,self.target_net=Net().to(device),Net().to(device)   #初始化图7中的action-value function Q 和 target action-value function
        
        self.learn_step_counter=0
        self.memory_counter=0
        self.memory = np.zeros((MEMORY_CAPACITY, N_STATES * 2 + 2))     # 初始化图7 replay memory
        self.optimizer = torch.optim.Adam(self.net.parameters(), lr=LR)  # 初始化优化器
        self.loss_func = nn.MSELoss()                                    # 初始化损失函数
        

3)动作选择规则

  图9 DQN动作选择策略及参数

注:图片来自上文所列举论文

  论文中的动作选择策略如图9所示,其机制是初始epsilon=1,然后逐渐降低,经过100万个frame后降到0.1。以epsilon的概率选择随机的动作,以1-epsilon的概率贪心选择。完整代码可在这里找到:dqn_cartpole_pytorch.ipynb  
EPSILON = 0.9
class DQN:
    def __init__(self):
        self.net,self.target_net=Net().to(device),Net().to(device)
        
        self.learn_step_counter=0
        self.memory_counter=0
        self.memory = np.zeros((MEMORY_CAPACITY, N_STATES * 2 + 2))     # initialize memory
        self.optimizer = torch.optim.Adam(self.net.parameters(), lr=LR)  #优化方法论文中使用的是RMSProp,我们在这里使用Adam。近些年来证明,Adam的性能很好。
        self.loss_func = nn.MSELoss()
        

    def choose_action(self, x):
        x = torch.unsqueeze(torch.FloatTensor(x), 0)
        # input only one sample
        if np.random.uniform() < EPSILON:                  #请注意这个地方
            actions_value = self.net.forward(x)
            action = torch.max(actions_value, 1)[1].data.cpu().numpy()
            action = action[0]
        else:   
            action = np.random.randint(0, N_ACTIONS)
            
        return action
  但是大家会发现,代码写的怎么跟上面说的不一样?代码中写的是以epsilon的概率贪心选择,以1-epsilon的概率随机选择。这是因为,按照论文的方法做,并不能成功让杆子立起来,我猜测是问题规模的原因,对于cartpole这样的小系统,贪心选择足以得出较好的Q值,而论文中的方法更加适合Atari游戏这种高维状态输入的环境。  

4)学习优化过程

 

图10 DQN学习过程

注:图片来自上文所列举论文

  我们将数据存储和学习过程写在一起。完整代码可在这里找到:dqn_cartpole_pytorch.ipynb  
torch.FloatTensor=torch.cuda.FloatTensor if torch.cuda.is_available() else torch.FloatTensor   #如果有GPU和cuda,数据将转移到GPU执行
torch.LongTensor=torch.cuda.LongTensor if torch.cuda.is_available() else torch.LongTensor
 
def store_transition(self, s, a, r, s_):
        transition = np.hstack((s, [a, r], s_))
        # replace the old memory with new memory
        index = self.memory_counter % MEMORY_CAPACITY
        self.memory[index, :] = transition
        self.memory_counter += 1

    def learn(self):
        # target parameter update
        if self.learn_step_counter % TARGET_REPLACE_ITER == 0:
            self.target_net.load_state_dict(self.net.state_dict())
        self.learn_step_counter += 1

        # sample batch transitions
        sample_index = np.random.choice(MEMORY_CAPACITY, BATCH_SIZE)
        batch_memory = self.memory[sample_index, :]
        batch_s = torch.FloatTensor(batch_memory[:, :N_STATES])
        batch_a = torch.LongTensor(batch_memory[:, N_STATES:N_STATES+1].astype(int))
        batch_r = torch.FloatTensor(batch_memory[:, N_STATES+1:N_STATES+2])
        batch_s_ = torch.FloatTensor(batch_memory[:, -N_STATES:])

        
        q = self.net(batch_s).gather(1, batch_a)  # shape (batch, 1)
        q_target = self.target_net(batch_s_).detach()     # detach from graph, don't backpropagate
        y = batch_r + GAMMA * q_target.max(1)[0].view(BATCH_SIZE, 1)   # shape (batch, 1)
        loss = self.loss_func(q, y)

        self.optimizer.zero_grad()
        loss.backward()
        self.optimizer.step()
       
  到此为止,DQN算法的框架已经搭建完成。  

四、开始训练

  完整代码可在这里找到:dqn_cartpole_pytorch.ipynb  
dqn = DQN()
   
plot_x_data,plot_y_data=[],[]
for i_episode in range(260):
    s = env.reset()
   # frames = []
    episode_reward = 0
    while True:
        # env.render()
        a = dqn.choose_action(s)

        # take action
        s_, r, done, info = env.step(a)

        dqn.store_transition(s, a, r, s_)

        episode_reward += r
        if dqn.memory_counter > MEMORY_CAPACITY:
            dqn.learn()
            if done:
                print('Episode: ', i_episode,
                      '| Episode_reward: ', round(episode_reward, 2))

        if done:
            break
        s = s_
    plot_x_data.append(i_episode)
    plot_y_data.append(episode_reward)
    plt.plot(plot_x_data,plot_y_data)
  当自我感觉算法编写良好,满怀信心的运行程序时,现实总是喜欢给当头一棒。怎么运行5000个Episodes,奖励值怎么还是在50左右徘徊,结果还是这么糟糕???    

图11 DQN未修改奖励函数时训练CartPole结果

  我也是思来想去没有找到原因,最后在github上见到了一个修改办法,就是将环境的奖励信号由稀疏的变为连续计算的,具体怎么操作呢?   我们先来看一下cartpole.py中是如何编写关于环境奖励的。  
 Reward: Reward is 1 for every step taken, including the termination step
 
if not done:
            reward = 1.0
        elif self.steps_beyond_done is None:
            # Pole just fell!
            self.steps_beyond_done = 0
            reward = 1.0
        else:
            if self.steps_beyond_done == 0:
                logger.warn(
                    "You are calling 'step()' even though this "
                    "environment has already returned done = True. You "
                    "should always call 'reset()' once you receive 'done = "
                    "True' -- any further steps are undefined behavior."
                )
            self.steps_beyond_done += 1
            reward = 0.0
  意思是只要杆子不达到done的条件,就给+1的奖励。   怎样修改更好呢?  
        # modify the reward
        x, x_dot, theta, theta_dot = s_
        r1 = (env.x_threshold - abs(x)) / env.x_threshold - 0.8             # env.x_threshold=2.4,在cartpole.py中有写
        r2 = (env.theta_threshold_radians - abs(theta)) / env.theta_threshold_radians - 0.5     # env.theta_threshold_radians=12度,同样在cartpole.py中有写
        r = r1 + r2
  我们将r1和r2的函数图像画出来,可以看到奖励信号由离散值变为了连续值,即与底座离中心的距离、杆偏离竖直的角度成正比,能给系统更加丰富的反馈信号。从DQN的论文中,我们也能读到,作者写的强化学习的一个难点之一就是稀疏的奖励信号,设法增加更多的奖励信号, 对于强化学习的训练是非常有帮助的。      

 图12  r1和r2的函数的图像

  修改奖励信号后的系统,就能达到非常好的效果。可以看到经过约100个Episodes,就能达到500以上的奖励,相比于不修改奖励函数时的大约50的奖励,真是巨大的进步。完整代码可在这里找到:dqn_cartpole_pytorch.ipynb    

  图13 修改奖励函数后的算法性能

五、不修改奖励函数,使用环境默认的奖励函数,如何达到较高的性能?

 

六、将编写成功的算法应用到倒立摆上

  这两部分内容我们留到写一篇讲,因为内容还有很多。

References:

[1]: https://gym.openai.com/