强化学习初探 DQN+PyTorch+gym倒立摆登山车

文章目录

  • 1.随便说几句
  • 2.为什么选择DQN作为第一个入手的模型
  • 2.工具准备
  • 3.实现思路
    • 3.1.环境采样
    • 3.2 Reward设计
    • 3.3 Q值近似计算
    • 3.4 主循环
  • 4.代码
  • 5.参考文献

1.随便说几句

疫情赋闲在家,就想着多学点东西吧。看了看GAN的东西,还看了看cs224的NLP,在做NLP作业的时候感觉虽然比单纯地刷准确率有意思点,但是a4的翻译作业竟然是翻译法语到英语的,我啥也分析不了,和刷准确率也差不多啊。
最后,我想去探索一下强化学习的领域了。强化学习可以用来玩游戏,而且也可以用于序列预测,和以后的方向有点关联还挺有意思的样子。以前听老师在AI导论课讲强化学习或者看CS231的强化学习,一般只有一节课,一堆概念乱糟糟的,也不怎么作为重点,所以学得一直很迷糊。然后在B站上看了David Silver的强化学习视频课,感觉收获很大。说老师讲得好确实有,但是可能更重要的是花了好多节课来学习,所以各个概念也弄得更清晰一点吧。老师确实很有耐心地解答问题,同学也很积极提问。

2.为什么选择DQN作为第一个入手的模型

这是在看到第5节课《免模型预测》后,想练练手解决个简单的问题。在这之前,课程讲了很多概念,也有挺多公式的。我对状态函数不是很感兴趣,我更倾向于使用Q值的估计,因为它更直观,哪个action的Q更大就选谁。但是在真正上手之后,有个问题摆在了我面前:课程里的Q(S,a)都是离散的(比方说走格子的游戏),但是许多游戏环境里的S是连续的(或者说实在太多了,不可能一一记录和遍历),怎么办呢,接着看下一节课呗。第6节课——值函数近似(也就是下一节课)老师就直白的说,“这节课过后,你们就可以将强化学习用于解决实际问题了”。这节课就是讲采用一些方法来近似估计连续的Q(S,a)。
在上CS231的强化学习课时,小姐姐就有说过:当你想要近似一个复杂函数的时候,你可以使用神经网络。当时还不太理解这句话,因为以为神经网络只能用来分类,对于拟合函数还没有太大的理解。
所以总结来说呢,采用DQN的原因就是:
1.使用神经网络估算Q值函数,普适性很强
2.策略就是取Q值最大的action,也就是贪婪方法,很直观
3.简单
依然是先讲解我一步一步思考问题的过程,然后再统一贴个代码。

2.工具准备

环境模拟器采用gym的简单小游戏:CartPole以及MountainCar
深度学习框架采用PyTorch
IDE嘛,这个随意了,我用的是VS2019(在写py这一块bug真不少)

3.实现思路

实现主要分为几个部分来讲解。第一个是环境采样部分,第二个是reward设计部分,第三个是Q值近似计算部分,第四个是主循环。

3.1.环境采样

数据肯定要从环境里采样。采样之前先要弄懂环境是什么样的,不然怎么指导zz模型学习呢:)
我先说CartPole的环境。这是一个倒立摆游戏,简单来说平衡得越久越好。
我们都知道gym每次step都会返回给我们四元组。(状态,奖励,游戏结束,额外信息)或者写作(state, reward, done, info)。其中,info是不能在学习中使用的,不然就是作弊了。接下来说的这些参数,都是gym内置的,并非我们规定的。
它的状态空间是4维的。具体是啥我就不细说了,因为好像这个问题里不需要特别地分析(分析的话可能更好),但是下面的几个就比较重要了。尤其和登山车对比来看,它涉及到对具体问题的具体分析。
reward:每活一个时间步,+1
done:游戏结束的标志,当倾角超过一定限度时或者到达200步后游戏结束
action:2维,[0,1],向左或者向右
强化学习初探 DQN+PyTorch+gym倒立摆登山车_第1张图片
接下来直接说登山车的环境,我们对比来看。
它的状态空间是2维的。第1维表示车现在的水平位置。很明显,位置越大越接近终点
reward:每一个时间步,-1
done:游戏结束的标志,当到达小旗时结束
action:3维,[0,1,2],依次是向左、不动、向右
强化学习初探 DQN+PyTorch+gym倒立摆登山车_第2张图片
对比两个环境我们可以发现不同:
1.reward是不一样,一个是尽量活的时间长,一个是尽量快到达终点。
2.action不一样,登山车有不动这个选项
3.done不一样,倒立摆坚持够200回合或者坚持不住了都会结束,但是登山车只有墨迹超过200回合才结束
有个重要的事情一定要看到:到达200回合后,两个游戏都结束了。根据环境分析,进行reward设计。

3.2 Reward设计

不是环境已经给了我们reward了吗,为什么还要设计reward呢???
一开始我在玩登山车的时候就没意识到这个问题,所以导致小车在低谷荡来荡去根本没法爬上坡,最令人崩溃的是训练出来的Q值近似函数永远都选择一个策略(也就是某个策略的Q一直最大),所以小车一直朝着一个方向开,或者一直熄火作周期运动。
后来我明白了:对于登山车来说,每个时间步减少1个奖励,但是达到目标并没有奖励。即使说就算达到目标有奖励,如果一直没法探索到目标,我们的模型也不可能知道有个地方还有奖励。
再者,这和我的Q函数设计有关。我的Q是基于最基础的TD-0,也就是只迭代一步,只看得到一步的奖励,对于整个游戏获得的奖励总和的感觉没那么深刻。
因此参考了别的博主的文章,改进并且设计了一个简单的分段reward。首先要把从环境观测的state打印出来,看看小车的位置一般对应哪些数值(state[0])。比方说,半山腰是-0.2,3/4山腰是-0.1,1/4山腰是-0.3这样。所以我决定在它获取阶段性胜利的时候给点甜头尝尝。先观察小车随机运动的规律,还是比较容易达到>-0.2的范围的,所以我设计的是:
1.当小车在-0.2~-0.15时,每一步给0.2的额外reward
2.当小车在-0.15~-0.1时,每一步给0.5的额外reward
3.当小车在-0.1~*时,每一步给0.7的额外reward

我还尝试过线性的reward,也就是额外reward=当前位置,但是小车容易卡在低谷;还试过线性截断的reward,当位置大于某值时才有额外的线性位置奖励。但是好像最终还是上面的方案比较好。

对于倒立摆,设计的reward就更简单了,因为倒立摆很容易就探索完整个状态空间了,所以给的额外reward就是,如果游戏结束,给-10的reward。

奖赏的设计其实应该是八仙过海各显神通的地方。比方说倒立摆的reward可以设计成坚持越久即时奖赏越多之类的,不过我这里就没试啦=v=

3.3 Q值近似计算

由于我觉得游戏本身不难,所以设计的神经网络也比较简单。三层的线性层(也可以说成是两层隐藏层),每层的输入输出分别为(以登山车为例,2入3出):
输入层:(2,16)+ LeakyReLU
隐藏层:(16,64)+ LeakyReLU
输出层:(64,3)
Loss:MSE
输入层的维度是根据state的维度来的,环境观测到几个维度就几个输入。隐藏层随意,毕竟要拟合的函数应该比较简单。输出层维度根据action来。
也就是说,这个网络输入是state,输出是各个action的Q值。

3.4 主循环

大致说一下思路:每次循环,首先进行环境采样,然后将每一步的(s, a, r, s_)存入数组中。但是原始数据和PyTorch内的数据并不兼容,所以要进行转换。再则,由于采用了batch训练以及随机抽样,所以将收集来的几千个样本做成一个dataset,采用dataloader进行批量加载。最后再进行训练并保存。因此这部分有如下三个部分:
1.环境采集函数,采集一批样本
2.数据转换部分,将采集到的数据生成dataset和dataloader,便于后续训练
3.训练部分,采用双网络进行训练,这里是直接借用课程里老师的思想的,代码也有参考别的博客的
4.保存模型。如果模型效果很好,则保存为好模型。

4.代码

我觉得该说明的基本上我都没漏呀。我只贴登山车的代码,倒立摆的只有网络输入输出个数不一样,reward不一样,还有对goodmodel的定义不一样。
有几个细节再提一下:
1.state(代码里是observation_xxx)的数据类型为numpy的数组,reward和action皆为基本类型浮点数和整数。
2.在采样环境的时候,加入了一定的随机action概率,便于在刚开始的时候采取不一样的行为
3.训练网络的时候,采用了双网络,这是老师的说法,说不容易发散。还采用了gather函数,因为一次(S, a)只影响一个Q值,也就是网络的一个输出,另外的两个输出不使用贝尔曼方程进行迭代更新。
4.登山车大概不到100次主循环就会出现游戏成功,也就是200步之内到达山顶的情况
5.env.render()是进行图形渲染的函数,训练的时候需要将其注释,否则采样很慢。测试的时候取消注释看效果

#这是一堆初始化
import gym
import random
import torch
import torch.nn as nn
from torch.utils.data import Dataset
#env = gym.make('CartPole-v0')
env = gym.make('MountainCar-v0') #action = (0,1,2) = (left, no_act, right)
#env = gym.make('Hopper-v3')
print(env.observation_space)
#print(env.action_space)

#简单的线性模型
def GetModel():
    #In features:2(state) ,out:3 action q
    return nn.Sequential(nn.Linear(2, 16), 
                         nn.LeakyReLU(inplace=True), 
                         nn.Linear(16,24),
                         nn.LeakyReLU(inplace=True), 

                         nn.Linear(24,3))

#创建数据集
class RLDataset(Dataset):
    def __init__(self, samples, transform = None, target_transform = None):
        #samples = [(s,a,r,s_), ...]
        self.samples = self.transform(samples)
    def __getitem__(self, index):
        #if self.transform is not None:
        #    img = self.transform(img) 
        return self.samples[index]
    def __len__(self):
        return len(self.samples)
    def transform(self, samples):
        transSamples = []
        for (s,a,r,s_) in samples:
            sT = torch.tensor(s,).float()
            sT_ = torch.tensor(s_).float()
            transSamples.append((sT, a, r, sT_))
        return transSamples

#采样环境函数,可以设置随机操作的概率。重点在于reward的设计
def GetSamplesFromEnv(env, model, epoch, max_steps, drop_ratio = 0.8):
    train_samples = []
    each_sample = None
    env.reset()
    observation_new = None
    observation_old = None
    model.eval()
    for i_episode in range(epoch):
        observation_new = env.reset()
        observation_old = env.reset()
        for t in range(max_steps):
            #env.render()
            #print(observation)
            if random.random() > 1-drop_ratio:
                action = env.action_space.sample()
            else:
                inputT = torch.tensor(observation_new).float()
                action = torch.argmax(model(inputT)).item()
                #print(action)
            observation_new, reward, done, info = env.step(action)
            #print(reward)
            #We record samples.
            if t > 0 :
                #reward += observation_new[0]
                #if observation_new[0] > -0.35:
                #    reward += (observation_new[0] + 0.36)*5
                if observation_new[0] > -0.2:
                    reward += 0.2
                elif observation_new[0] > -0.15:
                    reward += 0.5
                elif observation_new[0] > -0.1:
                    reward += 0.7
                each_sample = (observation_old, action, reward, observation_new)
                train_samples.append(each_sample)

            observation_old = observation_new

            if done:
            	#失败的采样不打印出来
                if t != 199:
                    print("Episode finished after {} timesteps".format(t+1))
                break
    return train_samples

#训练网络。这里可能gather函数比较绕,还有双网络更新比较费解。忽略掉这些,和正常训练循环一样
#gamma是贝尔曼方程里的衰减因子
def TrainNet(net_target, net_eval, trainloader, criterion, optimizer, device, epoch_total, gamma):
    running_loss = 0.0
    iter_times = 0
    net_target.eval()
    net_eval.train()
    for epoch in range(epoch_total + 1):
        if epoch > 0:           
            print('epoch %d, loss %.5f' % (epoch, running_loss))
        running_loss = 0.0
        if epoch == epoch_total: 
            break        
        for i, data in enumerate(trainloader, 0):
            if iter_times % 100 == 0:
                net_target.load_state_dict(net_eval.state_dict())
            s,a,r,s_ = data
            optimizer.zero_grad()

            #output = Q_predicted.
            q_t0 = net_eval(s)
            q_t1 = net_target(s_).detach()
            q_t1 = gamma * (r + torch.max(q_t1,dim=1)[0])
          
            loss = criterion(q_t1, torch.gather(q_t0, dim=1, index=a.unsqueeze(1)).squeeze(1))
            loss.backward()
            optimizer.step()
            running_loss += loss.item()
            iter_times += 1
    net_target.load_state_dict(net_eval.state_dict())    
    print('Finished Training')

#最后是一大堆主循环
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
net_target, net_eval = GetModel(), GetModel()
criterion = nn.MSELoss()
optimizer = torch.optim.Adam(net_eval.parameters(),lr=0.01)
train_samples = []

#这一堆是测试看效果用的
#PATH = 'mountain_model/goodmodel42.pth'
#net_eval.load_state_dict(torch.load(PATH))
#net_target.load_state_dict(torch.load(PATH))
#GetSamplesFromEnv(env,net_eval, 20, 200, 0)
goodmodel_idx = 0
for i in range(300):
    drop_ratio = 0.8 - 0.0077*i
    sample_times = 10
    tmpSample = GetSamplesFromEnv(env,net_eval, sample_times, 200, drop_ratio)
    train_samples += tmpSample
    #每次sample的长度就代表了采取的步数,登山车里是越小越好。如果是倒立摆,则是越大越好
    if len(tmpSample) < sample_times * 160:
        print("good model!save it!")
        torch.save(net_eval.state_dict(), "goodmodel" + str(goodmodel_idx) + ".pth")
        goodmodel_idx += 1
    #dataset里存着最新的不超过4000的样本
    if len(train_samples) > 4000:
        train_samples = train_samples[len(tmpSample):len(train_samples)]
    trainset = RLDataset(train_samples)
    trainloader = torch.utils.data.DataLoader(trainset, batch_size=64, shuffle=True, num_workers=0,pin_memory=True)
    TrainNet(net_target, net_eval, trainloader, criterion, optimizer, device, 10, 0.9)
    PATH = "model/model"+str(i)+".pth"
    torch.save(net_eval.state_dict(), PATH)
env.close()

5.参考文献

[1]强化学习 DQN 玩转 gym Mountain Car
[2]gym官方教程

你可能感兴趣的:(强化学习)