用PPO玩2048游戏--可以达到合成2048的目的

2048游戏-PPO算法解决思路

本项目的github链接如下:
链接: github_2048_ppo
alogos/ppo 中有四个文件

core.py
ppo.py
ppo_test.py
ppo_train.py

运行ppo_train.py 即可开始训练,运行ppo_test.py 即可调用训练好的模型进行2048游戏并得到gif动图。

一、2048游戏介绍

  《2048》 是一款比较流行的数字游戏,最早于2014年3月20日发行。原版2048首先在GitHub上发布,原作者是Gabriele Cirulli,后被移植到各个平台。每次可以选择上下左右其中一个方向去滑动,每滑动一次,所有的数字方块都会往滑动的方向靠拢外,系统也会在空白的地方乱数出现一个数字方块,相同数字的方块在靠拢、相撞时会相加。不断的叠加最终拼凑出2048这个数字就算成功。下方的动图演示了2048的游戏流程。

2048游戏流程

图1. 2048游戏流程示意gif

   我们时常也会通过微信小程序上的2048小游戏来消磨时间,但是合成2048还是略有难度的,需要掌握一定的技巧才可以成功。 对于我这个游戏菜鸡,合成2048非常之困难。所以本项目的初衷就是,想要做一个智能体来完成我想要合成2048的小愿望。

二、PPO算法背景

2.1 强化学习背景知识

  强化学习是一种通过不断试错,从而使智能体在特定环境中取得最大化奖励的一种优化方法。从1953 年贝尔曼提出贝尔曼条件,到1956/1957 年贝尔曼提出动态规划和马尔科夫决策过程的概念,再到现在,强化学习已经有了60 多年的发展历史。目前,强化学习已经出现了很多种方法,并被应用到了许多行业中,其发展潜力巨大。根据智能体的特点,可以将智能体分为三类:其中1,3均属于策略梯度算法,其代表流派是OpenAI;而2属于是价值算法,其代表流派是DeepMind。

  1. 基于策略(Policy-Based)的智能体:Reinforce等
  2. 基于价值(Value-Based)的智能体:DQN等
  3. 和结合策略和价值(Actor-Critic)的智能体:VPG,TRPO,PPO,DDPG,SAC等等。

  而在游戏方面,DQN以及其衍生算法非常的流行,并且对于2048游戏也有许多现成的DQN类的解决方案,并且也得到了不错的结果。但是本人作为OpenAI学派的忠实粉丝,想要用策略梯度类型的算法来解决2048问题,于是就有了这篇博文——使用 PPO-Clip1 来解决2048问题。

2.2 PPO-Clip介绍

   PPO算法全称是Proximal Policy Optimization2 (这里参考了Spinningup的介绍,具体内容详见spinningup)。这是一种 On-Policy策略梯度RL算法,属于前文提到的第三类。其PPO的动机是:如何使用当前拥有的数据对策略采取最大可能的改进步骤,而不会意外导致性能崩溃?PPO使用一阶方法,以及一些其他技巧使新策略接近旧策略。通过这种手段,PPO可以使新的策略和上一次的策略的差别保持在一个很小范围内,从而保证更新过程的稳定性,并且可以最大限度的利用收集到的数据,有一定的记忆性。
   PPO论文提出了两种实现PPO的主要手段:PPO-Penalty 以及 PPO-Clip。 前者通过KL散度作为约束条件保证策略在一定范围内更新,而后者直接通过简单的裁剪,来保证稳定性。在施加的编程操作中,PPO-Clip也更容易操作,并且效果更好。

2.2.1 基本策略梯度定理+GAE

   接下来将直接对PPO算法的工作流程以及伪代码进行简单解释,其中包含的策略梯度RL更新公式,以及基础的强化学习知识,本文不再做详解,有兴趣的同学可以直接参考该推导过程3。已知策略梯度算法的策略梯度基础公式如下:(我们的目标是最大化式中的期望,所以更新策略参数时使用的是梯度上升,以下式为梯度)
在这里插入图片描述

  但是根据前人的研究,发现策略梯度公式有通用形式,即式2所示:式中 R ( τ ) R(\tau) R(τ) 被置换为了 Φ t \Phi_t Φt 。而 Φ t \Phi_t Φt 可以有多种表达形式,比如折扣奖励,动作价值,优势函数等等。
在这里插入图片描述

这里,我们使用优势函数作为通用公式中的函数,即

在这里插入图片描述
在这里插入图片描述

从而策略梯度公式变为了:
在这里插入图片描述
  又因为实际操作过程中,优势函数并不好计算,所以使用了GAE4广义优势估计算法来替换上式中的 A。GAE的具体推导过程见原始论文,通过GAE替换A之后,策略梯度公式变为
∇ θ J ( π θ ) = E τ ∼ π θ [ ∑ t = 0 T ∇ θ l o g π θ ( a t ∣ s t ) A ^ π θ ( s t , a t ) ] = ∇ θ J ( π θ ) = E τ ∼ π θ [ ∑ t = 0 T ∇ θ l o g π θ ( a t ∣ s t ) ( ∑ l = 0 T ( γ λ ) l δ t + l V ) ] \nabla_\theta J(\pi_\theta) = E_{\tau\sim \pi_{\theta}}[\sum_{t=0}^{T}\nabla_\theta log \pi_\theta (a_t|s_t)\hat A^{\pi_{\theta}}(s_t,a_t)] \\ = \nabla_\theta J(\pi_\theta) = E_{\tau\sim \pi_{\theta}}[\sum_{t=0}^{T}\nabla_\theta log \pi_\theta (a_t|s_t)(\sum_{l=0}^{T}(\gamma \lambda)^l \delta_{t+l}^V)] θJ(πθ)=Eτπθ[t=0Tθlogπθ(atst)A^πθ(st,at)]=θJ(πθ)=Eτπθ[t=0Tθlogπθ(atst)(l=0T(γλ)lδt+lV)]
其中 δ t V = r t + γ v ( s t + 1 ) − v ( s t ) \delta_{t}^V = r_t+\gamma v(s_{t+1})-v(s_t) δtV=rt+γv(st+1)v(st) v ( s t ) v(s_t) v(st) 是状态价值函数得到的值,所以 PPO 算法需要用到状态价值网络来计算状态价值v,而状态价值网络就是所谓的Critic。所以PPO属于是 Actor-Critic 算法。

上式就是PPO要用到的基础策略梯度+GAE的更新公式。

代码实现时需要注意的事项:

  1. 需要说明的一点是,在进行程序设计的时候,梯度并不是通过直接用公式计算出的,而是通过数据采样获得的,这依靠了概率统计中 期望估计的方法,即使用一批数据的均值来估计上述公式的梯度,得到的近似梯度如下,然后使用梯度上升去更新参数。
    根据梯度式子我们也不难看出我们的目的:是想要 l o g π θ ( a t ∣ s t ) A ^ t log\pi_\theta(a_t|s_t) \hat A_t logπθ(atst)A^t 在整个轨迹的期望 E τ ∼ π θ [ ∑ 0 T l o g π θ ( a t ∣ s t ) A ^ t ] E_{\tau\sim\pi_{\theta}}[\sum^T_0log\pi_\theta(a_t|s_t) \hat A_t] Eτπθ[0Tlogπθ(atst)A^t] 最大,而期望可以用数理统计里的知识进行估计,所以梯度为:
    在这里插入图片描述
    θ k + 1 = θ k + α g ^ k \theta_{k+1} = \theta_k + \alpha \hat g_k θk+1=θk+αg^k
    与此同时,需要更新价值网络 Critic,让GAE 估计出的 A ^ t \hat A_t A^t 更加准确。对价值网络的参数我们记为 ϕ \phi ϕ, 它的更新很简单,就是对 Reward-to-go进行估计,所以直接最小化 v 和 Reward-to-go 的平方误差即可。
    在这里插入图片描述
  2. 第二点就是,在代码里面,我们并不直接手动计算梯度,把计算梯度的式子写到代码中,而是直接给出loss,让torch等这类优化软件自带的梯度优化器去计算梯度。所以真正在代码里我们给出的loss如下:
    策略网络的loss如下:
    为什么这里是负号,因为之前说了,我们想要整个轨迹的期望 E τ ∼ π θ [ ∑ 0 T l o g π θ ( a t ∣ s t ) A ^ t ] E_{\tau\sim\pi_{\theta}}[\sum^T_0log\pi_\theta(a_t|s_t) \hat A_t] Eτπθ[0Tlogπθ(atst)A^t] 最大化,所以我们要取这个最大化目标的相反数作为loss。所以loss要加负号,又因为期望可以用数理统计的知识进行估计,所以得下式:
    π l o s s = − 1 ∣ D k ∣ ∑ τ ∼ D k ∑ t = 0 T l o g π θ ( a t ∣ s t ) A ^ t \pi_{loss} = -\frac{1}{|D_k|}\sum_{\tau\sim D_k}\sum_{t=0}^{T} log \pi_\theta (a_t|s_t)\hat A_t πloss=Dk1τDkt=0Tlogπθ(atst)A^t
    而价值网络的loss如下:
    v l o s s = 1 ∣ D k ∣ T ∑ τ ∼ D k ∑ t = 0 T ( V ϕ ( s t ) − R ^ t ) 2 v_{loss} = \frac{1}{|D_k|T}\sum_{\tau\sim D_k}\sum_{t=0}^{T}(V_\phi(s_t)-\hat R_t)^2 vloss=DkT1τDkt=0T(Vϕ(st)R^t)2
2.2.2 PPO-Clip 更新流程

  根据前文所述,PPO是一种策略梯度算法,它想要在更新策略时,保证前后两个策略相差不太大,所以PPO的目标函数如下: 它想让L最大化。
在这里插入图片描述
而L的定义如下:(式子中的 A A A其实是 GAE 估计 A ^ \hat A A^)
在这里插入图片描述
  之前基础的策略梯度算法,我们想要整个轨迹的期望 E τ ∼ π θ [ ∑ 0 T l o g π θ ( a t ∣ s t ) A ^ t ] E_{\tau\sim\pi_{\theta}}[\sum^T_0log\pi_\theta(a_t|s_t) \hat A_t] Eτπθ[0Tlogπθ(atst)A^t] 最大。 这里可以理解为我们将之前最大化目标中的 ( l o g π θ ( a t ∣ s t ) A ^ t ) (log\pi_\theta(a_t|s_t) \hat A_t) (logπθ(atst)A^t) 整体替换成了下式的 L
  L 本质上就是 π θ π θ k A ^ t \frac{\pi_\theta}{\pi_{\theta_k}}\hat A_t πθkπθA^t ,也就是说给原本的优化目标了一个系数,系数是新策略 θ \theta θ和老策略 θ k \theta_k θk的比值。再整体看L的构造,是一个min函数,即从两个值中挑出来一个最小的,右侧是将系数限定到了一个它本身的范围内,比如 < 0.9 π θ π θ k , 1.1 π θ π θ k > <0.9\frac{\pi_\theta}{\pi_{\theta_k}},1.1\frac{\pi_\theta}{\pi_{\theta_k}}> <0.9πθkπθ,1.1πθkπθ>, 然后用限定范围内的系数去乘 A ^ t \hat A_t A^t 。然后从左右两个值中挑出来最小的那个,作为我们的优化目标。
  结合这个新的优化目标,不难看出算法的意图:想要新策略比老策略好,并且想要新旧策略相差在很小范围内。
上式可以进一步写为
在这里插入图片描述
  至此,PPO-Clip的优化目标已经明确,只需要代入到策略梯度计算公式中即可。写出来就是:分母中的T是回合时间,是个常数,可加可不加,源论文中加了。
∇ θ J ( π θ ) = E τ ∼ π θ [ ∑ t = 0 T ∇ θ L ( s t , a t , θ k , θ ) ] g ^ ≈ 1 ∣ D k ∣ T ∑ τ ∼ D k ∑ t = 0 T ∇ θ L ( s t , a t , θ k , θ ) \nabla_\theta J(\pi_\theta) = E_{\tau\sim \pi_{\theta}}[\sum_{t=0}^{T}\nabla_\theta L(s_t,a_t,\theta_k,\theta)]\\ \hat g \approx \frac{1}{|D_k|T}\sum_{\tau\sim D_k}\sum_{t=0}^{T}\nabla_\theta L(s_t,a_t,\theta_k,\theta) θJ(πθ)=Eτπθ[t=0TθL(st,at,θk,θ)]g^DkT1τDkt=0TθL(st,at,θk,θ)
并且考虑到更新价值网络,进行更加准确的GAE优势估计,我们得到了PPO-Clip算法的全貌:伪代码中没有写明梯度,直接注明了loss计算的方法
在这里插入图片描述

图2. PPO-clip伪代码

代码实现时需要注意的事项:

  1. 直接给出loss,让torch等这类优化软件自带的梯度优化器去计算梯度。所以真正在代码里我们给出的loss如下:
    为什么这里是负号,因为之前说了,梯度上升,所以loss要加负号。
    π l o s s = − 1 ∣ D k ∣ T ∑ τ ∼ D k ∑ t = 0 T L ( s t , a t , θ k , θ ) \pi_{loss} = -\frac{1}{|D_k|T}\sum_{\tau\sim D_k}\sum_{t=0}^{T} L(s_t,a_t,\theta_k,\theta) πloss=DkT1τDkt=0TL(st,at,θk,θ)
    而价值网络的loss如下:
    v l o s s = 1 ∣ D k ∣ T ∑ τ ∼ D k ∑ t = 0 T ( V ϕ ( s t ) − R ^ t ) 2 v_{loss} = \frac{1}{|D_k|T}\sum_{\tau\sim D_k}\sum_{t=0}^{T}(V_\phi(s_t)-\hat R_t)^2 vloss=DkT1τDkt=0T(Vϕ(st)R^t)2

三、实现部分

3.1 2048-gym环境

  本文通过已有的 gym-2048 环境5作为训练环境,该环境已经被封装成了符合标准gym接口的环境。有 reset, step 以及 render函数,并且render函数包括直接打印以及打印图片两种渲染方法。
在这里插入图片描述

图3. 2048obs渲染示意图

  环境的observation_space,以及action_space如下:

Box([0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.
 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0. 0.], [1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.
 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.
 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.
 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.
 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.
 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.
 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.
 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.
 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.
 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.
 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.], (256,), float32)
Discrete(4)

  每个obs是一个长为256的向量,向量内部的数字非0即1:16x16=256, 第一个16代表 4*4 =16个格子。第二个16意味着每个格子里面的数字可以用长16位的2进制数表示。
  action是离散的整数 0,1,2,3 分别代表上下左右四个操作。
环境的step 函数如下,接受动作后,返回s_,r,d,info 四个值。r代表该步操作得到的合起来的数字的值。

def step(self, action):
        """Perform one step of the game. This involves moving and adding a new tile."""
        logging.debug("Action {}".format(action))
        score = 0
        done = None
        info = {
            'illegal_move': False,
        }
        try:
            score = float(self.move(action))
            self.score += score
            assert score <= 2**(self.w*self.h)
            self.add_tile()
            done = self.isend()
            reward = float(score)
        except IllegalMove:
            logging.debug("Illegal move")
            info['illegal_move'] = True
            done = True
            reward = self.illegal_move_reward

        #print("Am I done? {}".format(done))
        info['highest'] = self.highest()

        # Return observation (board state), reward, done and info dict
        return stack(self.Matrix), reward, done, info

3.2 env wrapper 设计

  1. 因为原本环境的obs是拉直的(256,)形状的向量,直接将其输入到策略网络,这种扁平化的输入效果并不是很好,所以设计了 env wrapper 将其变形到 (1,16,16) ,即可以视为一张灰度图。 然后用pytorch结合CNN做特征提取,这样效果更好。
   2. 另外,因为reward到了后期会非常大,可能会造成梯度爆炸等问题,故参考前人的工作,本文对 reward 进行了scale处理。
   3. 最后,将环境得到的obs在wrapper中统统变成pytorch向量,这样更方便训练时的处理。

class ConvFloat(gym.ObservationWrapper):
    '''
    将array转成tensor
    将 (256,) 的 obs 转换成 (1,16,16)的图片形状
    将reward按照log函数缩放
    '''

    def __init__(self, env):
        super().__init__(env)
        self.observation_space = gym.spaces.Box(
                    low=0, high=1, shape=(1,16,16), dtype=np.float32
        )

    def reset(self, **kwargs):
        observation = self.env.reset(**kwargs)
        observation = observation.reshape(1, 16, 16).astype(np.float32)
        return observation

    def step(self, action):
        s_, r, d, info = self.env.step(action)
        s_ = s_.reshape(1, 16, 16).astype(np.float32)

        # 把奖励归一
        r = np.log(r + 1) /16
        return s_,r,d,info

3.3 PPO 网络结构

3.3.1 policy 网络

输入:obs_shape 的 obs
输出:action_dim 的 logits
网络通过CNN进行特征提取,然后通过几层全连接层得到 4个离散动作的 logits值。

MLPCategoricalActor(
  (conv_net): Sequential(
    (0): Conv2d(1, 32, kernel_size=(3, 3), stride=(1, 1))
    (1): ReLU()
    (2): Conv2d(32, 64, kernel_size=(3, 3), stride=(1, 1))
    (3): ReLU()
  )
  (logits_net): Sequential(
    (0): Linear(in_features=9216, out_features=512, bias=True)
    (1): ReLU()
    (2): Linear(in_features=512, out_features=128, bias=True)
    (3): ReLU()
    (4): Linear(in_features=128, out_features=4, bias=True)
  )
)

之后通过 (N, 4) 的logits网络输出,构建一个Category分布,然后从分布中按照logits概率sample动作。

class Actor(nn.Module):
    '''
    创建actor
    类里面的方法出现前置下划线是,代表这个函数是该类私有的,只能在内部调用
    这个类没有 __init__(self, inputs) 所以是不可实例化的类,只是一个用来继承的模板
    '''
    def _distribution(self, obs):
        '''
        这个是提示目前这个函数还没写,是一种技巧,先需要有一个这个函数,另一个类继承过来的时候再写
        obs的维度是[N,obs_dim],N可以是1,这是就是单个的obs
        如在连续空间中,actor将产生[N,act_dim]维度的mu
        然后利用生成的参数产生分布dist,格式是dist(loc:size=[N,act_dim],scale:size=[N,act_dim])
        dist分布其实就是 pi(.|s)给定s时的分布函数
        '''
        raise NotImplementedError
    
    def _log_prob_from_distribution(self, pi, act):
        '''
        计算 dist.log_prob(a)
        '''
        raise NotImplementedError

    def forward(self, obs, act=None):
        '''
        这个函数是为了计算目前的logpa,操作的是批量数据,批量数据仅仅在update的时候需要用到!
        只在upadate这一步计算loss时才需要用到
        带梯度
        产生给定状态的分布dist
        计算分布下,给定动作对应的log p(a)
        actor里面forward一般是只接收批量的数据,每一步的计算用上面的函数
        '''
        dist = self._distribution(obs)   # \pi(\cdot|s)
        logp_a = None
        if act is not None:
            logp_a = self._log_prob_from_distribution(dist, act)
        return dist, logp_a

class MLPCategoricalActor(Actor):
    '''
    继承Actor类,并修改一些基类的方法,产生离散的分布,就是PMF概率质量分布率,用于处理离散动作空间 Discrete
    可以实例化
    '''
    def __init__(self, obs_dim, act_dim):
        '''初始一个logits网络,可以直接输出各个动作对应的概率'''
        super().__init__()
        self.conv_net, self.logits_net, self.feature_size = conv_mlp(obs_dim, act_dim)

    def _distribution(self, obs):
        '''返回离散分布dist [N,act_dim],每个分布中,每个动作就对应一个确切的概率'''
        conv_feature = self.conv_net(obs).view(obs.size(0), -1)   # [N, conv_features num]
        # 如果第一维度不是1,这样就错了,得道 conv_features 之后要拉/直
        conv_feature = conv_feature.view(-1, self.feature_size) # 拉直变成 linear层输入的形状!!
        logits = self.logits_net(conv_feature)
        return Categorical(logits=logits)
    
    def _log_prob_from_distribution(self, pi, act):
        '''输出形为[N,]的logprob'''
        return pi.log_prob(act) # 离散动作空间,输入act的维度是[N,],因为选择出来的动作是act_dim里面的一个概率最大的动作,然后输出也是[N,]
                                # 比如倒立摆小车,离散动作空间维度为2,但是最后输出的动作是左或者右,只有1维,这是离散动作的特点!
                                # 输入1个动作,那就输出1个这个动作对应的概率!
3.3.2 value网络

输入:obs_shape 的 obs
输出:obs 对应的 value
网络通过CNN进行特征提取,然后通过几层全连接层得到 1个状态价值。

MLPCritic(
  (vconv_net): Sequential(
    (0): Conv2d(1, 32, kernel_size=(3, 3), stride=(1, 1))
    (1): ReLU()
    (2): Conv2d(32, 64, kernel_size=(3, 3), stride=(1, 1))
    (3): ReLU()
  )
  (vlogits_net): Sequential(
    (0): Linear(in_features=9216, out_features=512, bias=True)
    (1): ReLU()
    (2): Linear(in_features=512, out_features=128, bias=True)
    (3): ReLU()
    (4): Linear(in_features=128, out_features=1, bias=True)
  )
)
class MLPCritic(nn.Module):
    '''Critic的输出维度只有[N,1],输入是状态'''
    def __init__(self, obs_dim):
        super().__init__()
        self.vconv_net, self.vlogits_net, self.vfeature_size = conv_mlp(obs_dim, 1)
    
    def forward(self, obs):
        conv_feature = self.vconv_net(obs).view(obs.size(0), -1)   # [N, conv_features num]
        # 如果第一维度不是1,这样就错了,得道 conv_features 之后要拉/直
        conv_feature = conv_feature.view(-1, self.vfeature_size) # 拉直变成 linear层输入的形状!!
        logits = self.vlogits_net(conv_feature)
        return torch.squeeze(logits, -1)  # 保证Critic输出的价值的维度也是 [N,]
3.3.3 融合AC

把策略和价值网络合并到一个类中,便于管理。
可以用这个合体类,进行获取obs,输出动作以及相关的其他信息的操作。

class MLPActorCritic(nn.Module):
    '''
    创建一个默认参数的,可以调用的ActorCritic网络
    '''
    def __init__(self, observation_space, action_space,
                 hidden_sizes=(64,64), activation=nn.Tanh):
        super().__init__()
        
        obs_dim = observation_space.shape

        act_dim = action_space.n
        self.pi = MLPCategoricalActor(obs_dim, act_dim)
        
        # 建立Critic策略v
        self.v = MLPCritic(obs_dim)

    def step(self, obs):
        '''
        只接受1个obs,用于驱动环境运行
        这个函数是计算出的 old_logpa
        不用梯度,测试的输出该状态下
        使用策略得到的动作, 状态的价值, 动作对应的log p(a)
        '''
        with torch.no_grad():
            dist = self.pi._distribution(obs)
            a = dist.sample()
            logp_a = self.pi._log_prob_from_distribution(dist, a)

            v = self.v(obs)
        return a.cpu().numpy(), v.cpu().numpy(), logp_a.cpu().numpy()
    
    def act(self, obs):
        '''
        这个函数,仅仅用在ppo_test里面,给一个状态,得到一个动作,用于测试。
        '''
        return self.step(obs)[0]

3.4 算法更新

与伪代码对应,ppo的更新部分代码如下所示。其中adv等数值通过外置函数进行计算,具体见全部详细代码。

def compute_loss_pi(self, data):
        # 代码中ppo的old信息是直接从buffer读取的,而不是用一个旧的网络,因为参数更新完全基于自动梯度
        obs, act, adv, logp_old = data['obs'], data['act'], data['adv'], data['logp']
        # policy loss
        dist, logp = self.ac.pi(obs, act)   # 这里使用了pi的forward函数 , [N, ] logp的形状
        # 这里是ppo的重点
        ratio = torch.exp(logp - logp_old)
        clip_adv = torch.clamp(ratio, 1 - self.clip_ratio, 1 + self.clip_ratio) * adv
        loss_pi = -(torch.min(ratio * adv, clip_adv)).mean()
        # 有用的额外信息
        approx_kl = (logp_old - logp).mean().item()  # 返回元素值,近似kl散度,在trpo里面的kl是精准的kl散度
        ent = dist.entropy().mean().item()           # 熵
        clipped = ratio.gt(1 + self.clip_ratio) | ratio.lt(1 - self.clip_ratio)   # ratio比1+0.2 大或者ration比1-0.2 小, 返回的是 True Fasle这种bool量[True, Fasle...] [N,]
        clipfrac = torch.as_tensor(clipped, dtype=torch.float32).mean().item()    # 返回平均值的纯数字
        pi_info = dict(kl=approx_kl, ent=ent, cf=clipfrac)

        return loss_pi, pi_info
    
    # 设置计算PPO的 value loss
    def compute_loss_v(self, data):
        obs, ret = data['obs'], data['ret']
        return ((self.ac.v(obs)- ret)**2).mean()
    
    def update(self):
        '''更新机制'''
        data = self.buf.get()
        # 在更新前得到pi和v的loss去掉梯度变成纯数值,并得到pi的info,相当于先备份
        pi_loss_old, pi_info_old = self.compute_loss_pi(data)
        pi_loss_old = pi_loss_old.item()    # item是得到纯数字
        v_loss_old = self.compute_loss_v(data).item()

        # 梯度下降法来更新pi,也更新好多次
        for i in range(self.train_pi_iters):
            self.pi_optimizer.zero_grad()
            loss_pi, pi_info = self.compute_loss_pi(data)    # 因为在这个小循环里面,更新一次pi之后,这一步算出来的值也会发生变化的,所以判断语句写下面了
            kl = pi_info['kl']
            if kl > 1.5 * self.target_kl:
                print('因为KL超过限定的KL,所以训练在%d 次更新终止'%i)
                break
            loss_pi.backward()
            self.pi_optimizer.step()

        self.information['StopIter'] = i

        # 更新tranv_iters次v
        for i in range(self.train_v_iters):
            self.vf_optimizer.zero_grad()
            loss_v = self.compute_loss_v(data)   # 在这 i 次里面,lossv会变化,因为v网络变了,所以算loss也变了
            loss_v.backward()
            self.vf_optimizer.step()
        
        # 记录更新前后损失和KL和熵的改变
        kl, ent, cf = pi_info['kl'], pi_info_old['ent'], pi_info['cf']
        self.information['LossPi'] = pi_loss_old
        self.information['LossV'] = v_loss_old
        self.information['KL'] = kl
        self.information['ClipFrac'] = cf
        self.information['Entropy'] = ent
        self.information['DeltaLossPi'] = (loss_pi.item() - pi_loss_old)
        self.information['DeltaLossV'] = (loss_v.item() - v_loss_old)

四、 实验验证

4.1 训练部分

根据上述算法设计以及网络结构,对2048环境进行了实验验证。

超参数Value
α π \alpha_\pi απ3e-4
α v \alpha_v αv1e-3
γ \gamma γ0.99
λ \lambda λ0.97
ϵ \epsilon ϵ0.2

Reward
在这里插入图片描述

图4. Rewards-epochs

LossPi
在这里插入图片描述

图5. LossPi-epochs

LossV
在这里插入图片描述

图6. LossV-epochs

实验结果表明,PPO训练过程中,智能体在2048游戏中的得分持续升高,经过5000次训练,最高分可以达到2w左右。

4.2 测试部分

在训练时,智能体根据策略输出的四个概率,依照概率筛选动作。但是在测试时,直接使用四个概率中最大的概率对应的动作,结果显示,测试时智能体的表现基本能达到一个正常玩家的水准。

def get_action(model, x, determine=True):
    '''因为model的act,需要传入tensor 的obs,这里写个函数转化'''
    with torch.no_grad():
        x = torch.as_tensor(x, dtype=torch.float32).unsqueeze(0)
        if determine:
            dist = model.pi._distribution(x)
            logits = dist.logits
            logits = logits.numpy()
            a = np.argmax(logits)
        else:
            a = model.act(x) 
    return a

在大多数情况下,智能体可以快速合成1024, 少数情况下,可以得到2048.
请添加图片描述
请添加图片描述

五、 结语

本文算法超参数尚未微调,但是已经可以取得不错的效果,有兴趣的同学可以在此基础上进行参数调整,或者模型改进,得到更好的效果。
本实验也验证了 策略梯度 算法 PPO 在处理离散问题时,也可以得到不错的效果。


  1. https://arxiv.org/abs/1707.06347 ↩︎

  2. https://spinningup.openai.com/en/latest/algorithms/ppo.html ↩︎

  3. https://spinningup.openai.com/en/latest/spinningup/rl_intro3.html ↩︎

  4. https://arxiv.org/abs/1506.02438 ↩︎

  5. https://github.com/rgal/gym-2048 ↩︎

  • 11
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值