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小游戏来消磨时间,但是合成2048还是略有难度的,需要掌握一定的技巧才可以成功。 对于我这个游戏菜鸡,合成2048非常之困难。所以本项目的初衷就是,想要做一个智能体来完成我想要合成2048的小愿望。
二、PPO算法背景
2.1 强化学习背景知识
强化学习是一种通过不断试错,从而使智能体在特定环境中取得最大化奖励的一种优化方法。从1953 年贝尔曼提出贝尔曼条件,到1956/1957 年贝尔曼提出动态规划和马尔科夫决策过程的概念,再到现在,强化学习已经有了60 多年的发展历史。目前,强化学习已经出现了很多种方法,并被应用到了许多行业中,其发展潜力巨大。根据智能体的特点,可以将智能体分为三类:其中1,3均属于策略梯度算法,其代表流派是OpenAI;而2属于是价值算法,其代表流派是DeepMind。
- 基于策略(Policy-Based)的智能体:Reinforce等
- 基于价值(Value-Based)的智能体:DQN等
- 和结合策略和价值(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=0∑T∇θlogπθ(at∣st)A^πθ(st,at)]=∇θJ(πθ)=Eτ∼πθ[t=0∑T∇θlogπθ(at∣st)(l=0∑T(γλ)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的更新公式。
代码实现时需要注意的事项:
- 需要说明的一点是,在进行程序设计的时候,梯度并不是通过直接用公式计算出的,而是通过数据采样获得的,这依靠了概率统计中 期望估计的方法,即使用一批数据的均值来估计上述公式的梯度,得到的近似梯度如下,然后使用梯度上升去更新参数。
根据梯度式子我们也不难看出我们的目的:是想要 l o g π θ ( a t ∣ s t ) A ^ t log\pi_\theta(a_t|s_t) \hat A_t logπθ(at∣st)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πθ(at∣st)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 的平方误差即可。
- 第二点就是,在代码里面,我们并不直接手动计算梯度,把计算梯度的式子写到代码中,而是直接给出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πθ(at∣st)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=−∣Dk∣1τ∼Dk∑t=0∑Tlogπθ(at∣st)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=∣Dk∣T1τ∼Dk∑t=0∑T(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πθ(at∣st)A^t] 最大。 这里可以理解为我们将之前最大化目标中的
(
l
o
g
π
θ
(
a
t
∣
s
t
)
A
^
t
)
(log\pi_\theta(a_t|s_t) \hat A_t)
(logπθ(at∣st)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=0∑T∇θL(st,at,θk,θ)]g^≈∣Dk∣T1τ∼Dk∑t=0∑T∇θL(st,at,θk,θ)
并且考虑到更新价值网络,进行更加准确的GAE优势估计,我们得到了PPO-Clip算法的全貌:伪代码中没有写明梯度,直接注明了loss计算的方法
代码实现时需要注意的事项:
- 直接给出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=−∣Dk∣T1τ∼Dk∑t=0∑TL(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=∣Dk∣T1τ∼Dk∑t=0∑T(Vϕ(st)−R^t)2
三、实现部分
3.1 2048-gym环境
本文通过已有的 gym-2048 环境5作为训练环境,该环境已经被封装成了符合标准gym接口的环境。有 reset, step 以及 render函数,并且render函数包括直接打印以及打印图片两种渲染方法。
环境的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 αv | 1e-3 |
γ \gamma γ | 0.99 |
λ \lambda λ | 0.97 |
ϵ \epsilon ϵ | 0.2 |
Reward
LossPi
LossV
实验结果表明,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 在处理离散问题时,也可以得到不错的效果。