200字范文,内容丰富有趣,生活中的好帮手!
200字范文 > 深度增强学习PPO(Proximal Policy Optimization)算法源码走读

深度增强学习PPO(Proximal Policy Optimization)算法源码走读

时间:2023-05-30 15:33:23

相关推荐

深度增强学习PPO(Proximal Policy Optimization)算法源码走读

原文地址:/jinzhuojun/article/details/80417179

OpenAI出品的baselines项目提供了一系列deep reinforcement learning(DRL,深度强化学习或深度增强学习)算法的实现。现在已经有包括DQN,DDPG,TRPO,A2C,ACER,PPO在内的近十种经典算法实现,同时它也在不断扩充中。它为对DRL算法的复现验证和修改实验提供了很大的便利。本文主要走读其中的PPO(Proximal Policy Optimization)算法的源码实现。PPO是由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 envreturn _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 policyvf = 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]) # actionADV = tf.placeholder(tf.float32, [None]) # advantageR = tf.placeholder(tf.float32, [None]) # returnOLDNEGLOGPAC = tf.placeholder(tf.float32, [None]) # old -log(action)OLDVPRED = tf.placeholder(tf.float32, [None])# old value predictionLR = tf.placeholder(tf.float32, [])# learning rateCLIPRANGE = 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 * ratiopg_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 // nbatchfor 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 + Valuereturn (*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 versioninds = np.arange(nbatch) for _ in range(noptepochs): # epoch为4np.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_trainmbinds = inds[start:end]slices = (arr[mbinds] for arr in (obs, ...))# 将前面得到的batch训练数据作为参数,调用模型的train()函数进行参数学习。mblossvals.append(model.train(lrnow, cliprangenow, *slices)) # Advantage = Return - Valueadvs = returns - values # Normalizationadvs = (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 gymfrom gym import spacesimport multiprocessingimport joblibimport sysimport osimport numpy as npimport tensorflow as tffrom baselines.ppo2 import ppo2from mon.cmd_util import make_atari_env, atari_arg_parserfrom mon.atari_wrappers import make_atari, wrap_deepmindfrom baselines.ppo2.policies import CnnPolicyfrom mon.vec_env.vec_frame_stack import VecFrameStackdef 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 = Truesess = tf.Session(config=config)env_id = "BreakoutNoFrameskip-v4"seed = 0nenvs = 1nstack = 4env = wrap_deepmind(make_atari(env_id))ob_space = env.observation_spaceac_space = env.action_spacewos = env.observation_spacelow = 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_spaceact_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--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 = Falsefor _ in range(1000):env.render()obs = np.expand_dims(obs, axis=0)stackedobs = np.roll(stackedobs, shift=-1, axis=-1)stackedobs[..., -obs.shape[-1]:] = obsactions, 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步只损了一条命。

本内容不代表本网观点和政治立场,如有侵犯你的权益请联系我们处理。
网友评论
网友评论仅供其表达个人看法,并不表明网站立场。