赞
踩
原文地址:https://blog.csdn.net/jinzhuojun/article/details/80417179
OpenAI出品的baselines项目提供了一系列deep reinforcement learning(DRL,深度强化学习或深度增强学习)算法的实现。现在已经有包括DQN,DDPG,TRPO,A2C,ACER,PPO在内的近十种经典算法实现,同时它也在不断扩充中。它为对DRL算法的复现验证和修改实验提供了很大的便利。本文主要走读其中的PPO(Proximal Policy Optimization)算法的源码实现。PPO是2017年由OpenAI提出的一种DRL算法,它不仅有很好的performance(尤其是对于连续控制问题),同时相较于之前的TRPO方法更加易于实现。之前写过一篇杂文《深度增强学习(DRL)漫谈 - 信赖域(Trust Region)系方法》对其历史、原理及相关方法做了简单介绍,因此本文主要focus在代码实现的学习了解上。
OpenAI baselines项目中对于PPO算法有两个实现,分别位于ppo1和ppo2目录下。其中ppo2是利用GPU加速的,官方号称会快三倍左右,所以下面主要是看ppo2。对应论文为《Proximal Policy Optimization Algorithms》,以下简称PPO论文。本文我们就以atari这个经典的DRL实验场景为例看一下大体流程。启动训练的命令在readme中有:
$ python3 -m baselines.ppo2.run_atari
这样就开始训练了,每轮参数更新后会打印出相关信息。如:
------------------------------------
| approxkl | 0.003101161 |
| clipfrac | 0.17260742 |
| eplenmean | 941 |
| eprewmean | 34.9 |
| explained_variance | 0.704 |
| fps | 981 |
| nupdates | 1653 |
| policy_entropy | 0.85041255 |
| policy_loss | -0.01297911 |
| serial_timesteps | 211584 |
| time_elapsed | 1.63e+03 |
| total_timesteps | 1692672 |
| value_loss | 0.036234017 |
------------------------------------
可以看到,入口为run_atari.py中的main():
def main():
# 实现位于common/cmd_util.py。它主要为parser添加几个参数:
# 1) env:代表要执行atari中的哪个游戏环境。默认为BreakoutNoFrameskip-v4,即“打砖块”。
# 2) seed:随机种子。默认为0。
# 3) num-timesteps:训练的频数。默认为10M次。
parser = atari_arg_parser()
# 通过参数选择policy network的形式,实现在policies.py。默认为CNN。这里有三种选择:
# 1) CNN:相应函数为CnnPolicy()。发表于《Nature》上的经典DRL奠基论文《Human-level control through
# deep reinforcement learning》中使用的神经网络结构:conv->relu->conv->relu->conv->relu->
# fc->relu。注意它是双头网络,一头输出policy,一头输出value。
# 2) LSTM:相应函数为LstmPolicy()。它将CNN的输出之上再加上LSTM层,这样就结合了时间域的信息。
# 3) LnLSTM:相应函数为LnLstmPolicy()。其它的和上面一样,只是在构造LSTM层时添加了Layer normalization
# (详见论文《Layer Normalization》)
parser.add_argument('--policy', help='Policy architecture', choices=['cnn', 'lstm', 'lnlstm'], default='cnn')
# 用刚才的构建的parser解析命令行传入的参数。
args = parser.parse_args()
# 这个项目中实现了简单的日志系统。其中日志所在目录和格式可以用过OPENAI_LOGDIR和OPENAI_LOG_FORMAT两个环境
# 变量控制。实现类Logger中主要有两个字典:name2val和name2cnt。它们分别是名称到值和计数的映射。
logger.configure()
# 这里是开始正式训练了。
train(args.env, num_timesteps=args.num_timesteps, seed=args.seed,
policy=args.policy)
主函数中最后调用了train()函数进行训练。
def train(env_id, num_timesteps, seed, policy):
# 首先是一坨和TensorFlow相关的环境设置,比如根据cpu核数设定并行线程数等。
...
# 构建运行环境。流程还比较长,下面会再详细地理下。
env = VecFrameStack(make_atari_env(env_id, 8, seed), 4)
# 对应前面说的三种策略网络。用于根据参数选取相应的实现函数。
policy = {'cnn' : CnnPolicy, 'lstm' : LstmPolicy, 'lnlstm' : LnLstmPolicy}[policy]
# 使用PPO算法进行学习。其中传入的参数不少是模型的超参数。详细可参见PPO论文中的Table 5。
ppo2.learn(policy=policy, env=env, nsteps=128, nminibatches=4,
lam=0.95, gamma=0.99, noptepochs=4, log_interval=1,
ent_coef=.01,
lr=lambda f : f * 2.5e-4,
cliprange=lambda f : f * 0.1,
total_timesteps=int(num_timesteps * 1.1))
可以看到,train()函数中比较重要的就是两大块:环境构建和模型参数学习。首先看看环境构建流程:
# make_atari_env()函数实现位于common/cmd_util.py。看函数名就知道主要就是创建atari环境。通过OpenAI gym
# 创建基本的atari环境后,还需要层层封装。gym中提供了Wrapper接口,让开发者通过decorator设计模式来改变环境中的设# 定。
def make_atari_env(env_id, num_env, ...): # 这里的num_env为8,意味着会创建8个独立的并行运行环境。
def make_env(rank):
def _thunk():
# 创建由gym构建的atari环境的封装类。
env = make_atari(env_id):
# 通过OpenAI的gym接口创建gym环境。
env = gym.make(env_id)
# NoopResetEnv为gym.Wrapper的继承类。每次环境重置(调用reset())时执行指定步随机动作。
env = NoopResetEnv(env)
# MaxAndSkipEnd也是gym.Wrapper的继承类。每隔4帧返回一次。返回中的reward为这4帧reward
# 之和,observation为最近两帧中最大值。
env = MaxAndSkipEnd(env)
return env
# 每个环境选取不同的随机种子,避免不同环境跑得都一样。
env.seed(seed + rank)
# 实现在monitor.py中。Monitor为gym中Wrapper的继承类,对环境Env进行封装,主要添加了对
# episode结束时信息的记录。
env = Monitor(env, ...)
return wrap_deepmind(env, ...):
# 标准情况下,对于atari中的很多游戏(比如这儿的打砖块),命掉光了(如该游戏有5条命)算episode
# 结束,环境重置。这个Wrapper的作用是只要掉命就让step()返回done,但保持环境重置的时机不变
#(仍然是命掉完时)。原注释中说这个trick在DeepMind的DQN中用来帮助value的估计。
env = EpisodeicLifeEnv(env)
# 通过OpenCV将原始输入转成灰度图,且转成84 x 84的分辨率。
env = WarpFrame(env)
# 将reward按正负值转为+1, -1和0。
env = ClipRewardEnv(env)
...
return env
return _thunk
...
# 返回SubprocVecEnv对象。
return SubprocVecEnv([make_env(i + start_index) for i in range(num_env)])
创建num_env个元素(这里为8)的数组,每一个元素为一个函数闭包_thunk()。VecEnv实现在baselines/common/vec_env/__init__.py,它是一个抽象类,代表异步向量化环境。其中包括几个重要的抽象函数:
reset()用于重置所有环境,step_async()用于通知所有环境开始根据给定动作执行一步,step_wait()得到执行完的结果。step_wait()等待step_async()的结果。step()就是step_async() 加上step_wait()。而VecEnvWrapper也为VecEnv的继承类,和gym中提供的Wrapper功能类似,如果要对VecEnv实现的默认行为做修改的话就可以利用它。
上面函数最后返回的SubprocVecEnv类为VecEnv的继承类,它主要将上面创建好的函数放到各个子进程中去执行。在SubprocVecEnv实现类中,构造时传入在子进程中执行的函数。通过Process创建子进程,并通过Pipe进行进程间通信。make_atari_env()中创建SubprocVecEnv后,又立马被VecFrameStack封装了一把。VecFrameStack为VecEnvWrapper的实现类,实现在vec_frame_stack.py。在VecFrameStack的构造函数中,wos为gym环境中的原始状态空间,维度为[84,84,1]。low和high分别为这些维度的最低和最高值。stackedobs就是把几个环境的状态空间叠加起来,即维度变为(8, 84, 84, 4)。8为环境个数,(84,84)为单帧状态维度,也就是游戏的屏幕输出,4代表最近4帧(因为会用最近4的帧的游戏画面来作为网络模型的输入)。
可以看到,除了正常的封装外,还需要做一些比较tricky,比较靠经验的处理。理论上我们希望这部分越少越好,因为越少算法就越通用。然而现状是这一块tuning对结果的好坏可能产生比较大的影响。。。
好了,接下去就可以看看PPO算法主体了。入口为ppo2.py的learn()函数。
# 首先一坨参数设定,仍然以run_atari.py为例。
nenvs = env.num_envs # 8
ob_space = env.observation_space # Box(84,84,4)
ac_space = env.action_space # Discrete(4)
nbatch = nenvs * nsteps # 1024 = 8 * 128。共8个并行环境,每个环境执行128步。即nbatch为单个batch中所有环境中执行的总步数。
nbatch_train = nbatch // nminibatches # 256 = 1024 / 4。nbatch_train为训练时batch的大小。即1024步分4次训练。
# make_model()函数是一个用于构造Model对象的函数。
make_model = lambda : Model(policy=policy, ob_space=ob_space, ...)
# 创建Model。
model = make_model()
模型的构建也是最核心的部分。这块要和PPO论文配合起来看,否则容易晕。
class Model(object):
def __init__(self, *, policy, ob_space, ac_space, nbatch_act, nbatch_train,
nsteps, ent_coef, vf_coef, max_grad_norm):
# 用前面指定的网络类型构造两个策略网络。act_model用于执行策略网络根据当前observation返回
# action和value等,即只做inference。train_model顾名思义主要用于参数的更新(模型的学习)。
# 注意这两个网络的参数是共享的,因此train_model更新的参数可以体现在act_model上。假设使用默
# 认的CnnPolicy,其中的step()函数计算action, value function和action提供的信息量;
# value()函数计算value。
# nbatch_act = 8,就等于环境个数nenvs。因为每一次都分别对8个环境执行,得到每个环境中actor的动作。
# 1为nsteps。其实在CNN中没啥用,在LSTM才会用到(因为LSTM会考虑前nsteps步作为输入)。
act_model = policy(sess, ob_space, ac_space, nbatch_act, 1, reuse=False)
h = nature_cnn(X) # 如前面所说,《Nature》上的网络结构打底。然后输出policy和value。
pi = fc(h, 'pi', ...) # for policy
vf = fc(h, 'v') # for value function
# 根据action space创建相应的参数化分布。如这里action space是Discrete(4),那分布
# 就是CategoricalPdType()。然后根据该分布类型,结合网络输出(pi),得到动作概率分
# 布CategoricalPd,最后在该分布上采样,得到动作a0。neglogp0即为该动作的自信息量。
pdtype = make_pdtype()
pd = self.pdtype.pdfromflat(pi)
a0 = self.pd.sample()
neglogp0 = self.pd.neglogp(a0)
# 和构建action model类似,构建用于训练的网络train_model。nbatch_train为256,因为是用于模型的学习,
# 因此和act_model不同,这儿网络输入的batch size为256。
train_model = policy(sess, ob_space, ac_space, nbatch_train, nsteps, reuse=True)
# 创建一坨placeholder,这些是后面要传入的。
A = train_model.pdtype.sample_placeholder([None]) # action
ADV = tf.placeholder(tf.float32, [None]) # advantage
R = tf.placeholder(tf.float32, [None]) # return
OLDNEGLOGPAC = tf.placeholder(tf.float32, [None]) # old -log(action)
OLDVPRED = tf.placeholder(tf.float32, [None]) # old value prediction
LR = tf.placeholder(tf.float32, []) # learning rate
CLIPRANGE = tf.placeholder(tf.float32, []) # clip range,就是论文中的epsilon。
neglogpac = train_model.pd.neglogp(A) # -log(action)
entropy = tf.reduce_mean(train_model.pd.entropy())
# 训练模型提供的value预测。
vpred = train_model.vf
# 和vpred类似,只是与上次的vpred相比变动被clip在由CLIPRANGE指定的区间中。
vpredclipped = OLDVPRED + tf.clip_by_value(train_model.vf - OLDVPRED, - CLIPRANGE, CLIPRANGE)
vf_losses1 = tf.square(vpred - R)
vf_losses2 = tf.square(vpredclipped - R)
# V loss为两部分取大值:第一部分是网络预测value值和R的差平方;第二部分是被clip过的预测value值
# 和return的差平方。这部分和论文中似乎不太一样。主要目的应该是惩罚value值的过大更新。
vf_loss = .5 * tf.reduce_mean(tf.maximum(vf_losses1, vf_losses2))
# 论文中的probability ratio。把这里的exp和log展开就是论文中的形式。
ratio = tf.exp(OLDNEGLOGPAC - neglogpac)
pg_losses = -ADV * ratio
pg_losses2 = -ADV * tf.clip_by_value(ratio, 1.0 - CLIPRANGE, 1.0 + CLIPRANGE)
# 论文公式(7),由于前面都有负号,这里是取maximum.
pg_loss = tf.reduce_mean(tf.maximum(pg_losses, pg_losses2))
approxkl = .5 * tf.reduce_mean(tf.square(neglogpac - OLDNEGLOGPAC))
clipfrac = tf.reduce_mean(tf.to_float(tf.greater(tf.abs(ratio - 1.0), CLIPRANGE)))
# 论文公式(9),ent_coef, vf_coef分别为PPO论文中的c1, c2,这里分别设为0.01和0.5。entropy为文中的S;pg_loss为文中的L^{CLIP}
loss = pg_loss - entropy * ent_coef + vf_loss * vf_coef
# 构建trainer,用于参数优化。
grads = tf.gradients(loss, params)
trainer = tf.train.AdamOptimizer(learning_rate=LR, max_grad_norm)
_train = trainer.apply_gradients()
上面模型构造完了,接下来就是模型学习过程的skeleton了。Runner类主要作为学习过程的组织协调者。
# Runnder是整个训练过程的协调者。
runner = Runner(env=env, model=model, nsteps=nsteps,...)
# total_timesteps = 11000000, nbatch = 1024,因此模型参数更新nupdates = 10742次。
nupdates = total_timesteps // nbatch
for update in range(1, nupdates+1) # 对应论文中Algorithm的外循环。
obs, returns, masks, actions, values, ... = runner.run()
# 模型(上面的act_model)执行nsteps步。有8个环境,即共1024步。该循环对应论文中Algorithm的第2,3行。
for _ in range(self.nsteps):
# 执行模型,通过策略网络返回动作。
actions, values, self.states, ... = self.model.step(self.obs, self.status, ...)
# 通过之前创建的环境执行动作,得到observation和reward等信息。
self.obs[:], rewards, self.dones, infos = self.env.step(actions)
# 上面环境执行返回的observation, action, values等信息都加入mb_xxx中存起来,后面要拿来学习参数用。
mb_obs = np.asarray(mb_obs, dtype=self.obs.dtype)
mb_rewards = np.asarray(mb_rewards, dtype=np.float32)
mb_actions = np.asarray(mb_actions)
...
# 估计Advantage。对应化文中Algorithm的第4行。
for t in reversed(range(self.nsteps)):
# 论文中公式(12)。
delta = mb_rewards[t] + self.gamma * nextvalues * nextnonterminal - mb_values[t]
# 论文中公式(11)。
mb_advs[t] = lastgaelam = delta + self.gamma * self.lam * nextnonterminal * lastgaelam
mb_returns = mb_advs + mb_values # Return = Advantage + Value
return (*map(sf01, (mb_obs, mb_returns, mb_dones, mb_actions, mb_values, mb_neglogpacs)), mb_states, epinfos)
epinfobuf.extend(epinfos) # Gym中返回的info。
# 论文中Algorithm 1第6行。
if states is None: # nonrecurrent version
inds = np.arange(nbatch)
for _ in range(noptepochs): # epoch为4
np.random.shuffle(inds)
# 8个actor,每个运行128步,因此单个batch为1024步。1024步又分为4个minibatch,
# 因此单次训练的batch size为256(nbatch_train)。
for start in range(0, nbatch, nbatch_train): # [0, 256, 512, 768]
end = start + nbatch_train
mbinds = inds[start:end]
slices = (arr[mbinds] for arr in (obs, ...))
# 将前面得到的batch训练数据作为参数,调用模型的train()函数进行参数学习。
mblossvals.append(model.train(lrnow, cliprangenow, *slices))
# Advantage = Return - Value
advs = returns - values
# Normalization
advs = (advs - advs.mean()) / (advs.std() + 1e-8)
# cliprange是随着更新的步数递减的。因为一般来说训练越到后面越收敛,每一步的差异也会越来越小。
# neglogpacs和values都是nbatch_train维向量,即shape为(256, )。
td_map = {train_mode.X:obs, A:actions, ADV:advs, R:returns, LR:lr,
CLIPRANGE:cliprange, OLDNEGLOGPAC:neglogpacs, OLDVPRED:values}
return sess.run([pg_loss, vf_loss, entropy, approxkl, clipfrac, _train], td_map)
else:
...
# 每过指定间隔打印以下参数。
if update % log_interval == 0 or update == 1:
ev = explained_variance(values, returns)
logger.logkv("serial_timesteps", update*nsteps)
logger.logkv("nupdates", update)
...
# 满足条件时保存模型。
if save_interval and (update % save_interval == 0 or update == 1) and logger.get_dir():
...
model.save(savepath)
env.close()
训练结束,我们可以通过下面命令将训练过程中的主要指标-Episode Rewards图形化。可以用–dirs参数指定前面训练时log所在目录。
python3 -m baselines.results_plotter
可以看到,如期望地,随着训练的进行,学习到的策略使得agent能在一轮游戏中玩得越来越久,一轮中的累积回报也就越来越大。
最后,是骡子是马还是要出来溜溜才知道。下面脚本用于将训练产生的ckpt load起来,然后运行atari环境,执行策略网络产生的动作,并将过程渲染出来。参数为ckpt文件路径。
import gym
from gym import spaces
import multiprocessing
import joblib
import sys
import os
import numpy as np
import tensorflow as tf
from baselines.ppo2 import ppo2
from baselines.common.cmd_util import make_atari_env, atari_arg_parser
from baselines.common.atari_wrappers import make_atari, wrap_deepmind
from baselines.ppo2.policies import CnnPolicy
from baselines.common.vec_env.vec_frame_stack import VecFrameStack
def main(argv):
ncpu = multiprocessing.cpu_count()
config = tf.ConfigProto(allow_soft_placement=True,
intra_op_parallelism_threads=ncpu,
inter_op_parallelism_threads=ncpu)
config.gpu_options.allow_growth = True
sess = tf.Session(config=config)
env_id = "BreakoutNoFrameskip-v4"
seed = 0
nenvs = 1
nstack = 4
env = wrap_deepmind(make_atari(env_id))
ob_space = env.observation_space
ac_space = env.action_space
wos = env.observation_space
low = np.repeat(wos.low, nstack, axis=-1)
high = np.repeat(wos.high, nstack, axis=-1)
stackedobs = np.zeros((nenvs,)+low.shape, low.dtype)
observation_space = spaces.Box(low=low, high=high, dtype=env.observation_space.dtype)
vec_ob_space = observation_space
act_model = CnnPolicy(sess, vec_ob_space, ac_space, nenvs, 1, reuse=False)
with tf.variable_scope('model'):
params = tf.trainable_variables()
#load_path = '/tmp/openai-2018-05-27-15-06-16-102537/checkpoints/00030'
load_path = argv[0]
loaded_params = joblib.load(load_path)
restores = []
for p, loaded_p in zip(params, loaded_params):
restores.append(p.assign(loaded_p))
sess.run(restores)
print("model " + load_path + " loaded")
obs = env.reset()
done = False
for _ in range(1000):
env.render()
obs = np.expand_dims(obs, axis=0)
stackedobs = np.roll(stackedobs, shift=-1, axis=-1)
stackedobs[..., -obs.shape[-1]:] = obs
actions, values, states, neglogpacs = act_model.step(stackedobs)
print("%d, action=%d" % (_, actions[0]))
obs, reward, done, info = env.step(actions[0])
if done:
print("done")
obs = env.reset()
stackedobs.fill(0)
sess.close()
if __name__ == '__main__':
if (len(sys.argv)) != 2:
sys.exit("Usage: %s ckpt_path" % sys.argv[0])
if not os.path.exists(sys.argv[1]):
sys.exit("ckpt file %s not found" % sys.argv[1])
main(sys.argv[1:])
可以看到当更新迭代500次时,算法已经能学习到一些游戏的基本策略了,但仍不是很娴熟。5条命基本在1000步内就会被干光。
当更新迭代5000次后,学到的策略比500次时已经成熟很多了,5条命在1000步内基本够用。
当更新迭代10000次后,基本已经玩得很溜了。试验中1000步只损了一条命。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。