使用Gym和CNN构建多智能体自动驾驶马里奥赛车

本文描述的卷积神经网络超出了简单模式识别的范畴,能够学习到控制一辆自动汽车所需的所有过程。作者介绍了如何利用 CNN 和 OpenAI Gym,创建一个多智能体的系统,这些模型可以自动驾驶马里奥赛车,并且彼此竞争。

对机器学习感兴趣的人都知道基于人工智能的强化学习的能力。过去的纪念见证了很多使用强化学习(RL)做出的突破。DeepMind 将强化学习与机器学习相结合,在很多 Atari 游戏中达到了超越人类的结果,并且在 2016 年 3 月的时候以 4:1 的成绩击败了围棋冠军李世石。尽管强化学习目前在很多游戏环境中超越了人类,但是用它来解决一些需要最优决策和效率的问题还是比较新颖的,而且强化学习也会在未来的机器智能方面起到重要的作用。

简单地解释,强化学习就是智能体通过采取行动与环境交互以尝试最大化所得的积累奖励的计算方法。下面是一张简图(智能体—环境循环),图来自于强化学习简介(第二版)(Reinforcement Learning: An Introduction 2nd Edition,http://incompleteideas.net/sutton/book/the-book-2nd.html)。


在当前状态下 St 的智能体采取行动 At,环境对这个动作进行交互和回应,返回一个新的状态 S(t+1),并给智能体一个 R(t+1) 的奖励。然后智能体选择下一个行动,然后这个循环一直重复,直到问题被解决了或者被中断了。

最近强化学习被用来解决一些挑战性问题,从游戏到机器人学。在工业应用中,强化学习也开始作为一个实际组件出现,例如数据中心冷却。而强化学习的绝大多数成功则来自于单智能体领域,在单智能体领域中不需要对其他行动者的行为进行建模和预测。

然而,也存在很多涉及多智能体交互的重要应用,其中会出现共同进化的智能体的新兴行为和复杂度。例如,多机器人控制,通信和语言的发现,多个玩家参与的游戏,以及对社会困境的分析都会涉及多智能体领域。相关的问题也可以以不同的级别和水平来等同于多智能体问题,例如分层强化学习的变体也可以被看做多智能体系统。此外,多智能体自我模拟最近也被证明是一个有用的训练范式。在构建能够与人类有效交互的人工智能系统时,将强化学习成功地扩展到多智能体问题中是很关键的。

不幸的是,Q-learning 和策略梯度等传统的强化学习方法不能很好地适应于多智能体环境。一个问题是,每个智能体的策略都是随训练过程发生变化的,并且在任意单独的智能体的角度看来,环境会变得不稳定(以一种在智能体自己的策略中没有解释解释得方式)。这就带来了学习稳定性的挑战,以及避免了对过去经验回放(experience replay)的直接利用,经验回放对稳定深度 Q 学习是很关键的。另一方面,当需要多智能体协作的时候,策略梯度方法会表现出较高的方差。

或者,还可以使用基于模型的策略优化,这种优化可以通过反向传播学到最佳策略,但是这需要一个不同的关于环境变化的模型以及关于智能体之间交互的假设。从优化角度来看,将这些方法应用在竞争环境中也是有挑战的,其在对抗训练方法中已被证明存在高度的不稳定性。

卷积神经网络

卷积神经网络革新了模式识别。在卷积神经网络广泛使用之前,绝大多数模式识别任务都是用初始阶段的人工特征加一个分类器执行的。

卷积神经网络的突破就是:所有的特征都是从样本自动学习到的。卷积神经网络在图下个识别任务上特别强大,因为卷积运算能够捕获图像的二维本质特点。此外,通过使用卷积核来扫描整个图片,与所有的运算次数相比,最终学到的参数会相对少一些。

尽管卷积神经网络在商业应用中已经有超过 20 年的历史了,但是对它们的应用在最近几年才开始爆发(这是因为以下两个进展):

  • 有了便于使用的大规模的标签数据集,例如大规模视觉识别挑战(Large Scale Visual Recognition Challenge,ILSVRC)。

  • 在大规模并行的 GPU 上实现了卷积神经网络,这极大的加快了学习和推理过程的速度。

在这篇文章中,我们描述的卷积神经网络已经超出了简单模式识别的范畴。它能够学习到控制一辆自动汽车所需的所有过程。

使用卷积神经网络和 OpenAI Gym,我们可以创建一个多智能体的系统,这些模型可以自动驾驶马里奥赛车,并且彼此竞争。


在 Royal Raceway 赛道上(未训练)进行驾驶

必要条件和安装

  • Ubuntu

  • 最新支持 CUDA 的 NVIDIA GPU

  • mupen64plus N64 模拟器

  • MarioKart(马里奥赛车)N64 ROM

  • OpenAI Gym

  • Mupen64Plus Gym 环境

  • tensorflow-gpu

OpenAI Gym

OpenAI Gym 是用来开发和对比强化学习算法的工具箱

在强化学习中有两个基本的概念:环境(也就是智能体所处的外部世界)和智能体(也就是所开发的算法)。智能体向环境发送行动,环境回应一个观察和奖励(也就是一个分数)。

核心 gym 接口是一个 Env(https://github.com/openai/gym/blob/master/gym/core.py)。这里没有提供智能体的接口,需要你去开发。下面是你应该知道的 Env 的方法:

  • reset(self):重设环境状态。返回状态观察。

  • step(self, action):将环境推进一个时间步长。返回观察值、奖励、done 和 info。

  • render(self, mode=』human』, close=False):提交一帧环境。默认的模式会做一些友好的事情,例如弹出一个窗口。将最近的标志信号传送到这种窗口。

录制和训练

我们开发了一个 python 脚本来捕捉模拟器的屏幕,以及游戏手柄和操纵杆的位置。

这个脚本可以让我们选择用来存储训练数据的文件夹。它也能够实时的绘制出被触发的游戏手柄的命令。


记录玩家的行动

这个脚本主要使用输入 python 模块来记录被按下去的按钮以及操纵杆的位置。我使用 PS4 DualShock 4 手柄来训练这个模型。记录部分是按照这样配置的:我们每隔 200ms 对模拟器进行一次截屏操作。

为了开始记录,你必须遵循以下的步骤:

1. 启动你的模拟器程序(mupen64plus),运行 Mario Kart 64。

2. 保证你将操纵杆连接好了,并且 t mupen64plus 在使用简易直控媒体层(SDL)插件。

3. 运行 record.py 脚本

4. 确保图形返回相应操纵杆的输入操作

5. 将模拟器置于合适的位置(左上角),以保证程序能够截取到图像。

6. 开始记录,并且将某一个级别的游戏玩遍。

训练数据

record.py 脚本会将你所玩过的一个级别的所有级别的截图保存下来,同时还保存了一个 data.csv 的文件。

训练数据

data.csv 包含与玩家所使用的一系列控制操作相关的图片的路径链接。


data.csv

当我们截取到了足够多的训练数据之后,下一步就是开始实际的训练。开始训练之前,需要准备一下我们的数据。

运行 utils.py 来准备样本:用一个由样本的路径组成的数组来构建用来训练模型的矩阵 X 和 y。zsh 会扩展到所有的样本路径。传递一个全局路径也是可以的.

X 是三维图像矩阵。

y 是期望的操纵杆输出数组

 [0] joystick x axis
  [1] joystick y axis
  [2] button a
  [3] button b
  [4] button rb

模型

我们训练一个卷积神经网络模型,作为从原始单个前置摄像头直接到控制命令的映射。最终结果证明这个端到端的方法识特别强大的。使用最少的人类玩家的训练数据,这个系统就能够学会在拥挤的道路上驾驶马里奥赛车,无论是在有分道标志还是没有分道标志的高速路上。它也能够在没有明显视觉引导的区域进行操作,例如停车区或者没有铺砌的道路上。

系统自动地学会了必要处理步骤的内部表征,例如仅仅使用人类操纵的角度作为训练信号来检测有用道路特征。我们从未显式地训练它去做这种检测,例如,使用道路轮廓。

训练神经网络

与这个问题的显式分解(例如道路标志检测、路径规划好和控制)相比,我们的端到端系统对这些处理步骤做了同时优化。我们认为这种处理机制会带来更好的性能和更小规模的系统。会得到更好性能的原因是内部组件自优化到更好的全局系统性,而不是去优化由人类选择的中间判断标准,例如道路检测。这种标准当然是便于人类理解的,但是它不能自动化地保证最大化系统的性能。而最终网络规模会比较小的原因可能是系统在更少数量的步骤内学会了解决这个问题。

网络架构

我们的网络结构是对 NVIDIA 这篇论文中所描述的结构的实现 (https://arxiv.org/pdf/1604.07316.pdf)。

我们训练网络权重参数,以最小化网络控制命令的结果和其他人类驾驶员的控制输出之间的均方差,或者为偏离中心或者发生旋转的图片调整命令。我们的网络结构如图 4 所示。网络包含 9 层,包括一个正则化层,5 个卷积层和 3 个全连接层。输入被分割成 YUV 色彩空间的平面,然后被传递到网络中。网络的第一层执行图像正则化的操作。正则化是硬编码处理,它在学习的过程中不会被调整。在网络中执行正则化可以使得正则化方案随着网络的结构而改变,并且可以通过 GPU 处理来加速。卷积层是被设计用来进行特征提取的,是根据经验从一系列的层配置中选择的。我们在前三个卷积层中使用卷积核为 5×5,步进为 2×2 的步进卷积,在最后两层使用卷积核为 3×3 的非步进卷积。在 5 个卷积层之后是三个全连接层,最终的输出值是反转半径。全连接层被设计用来进行转向控制,但是我们要注意,通过以端到端的形式训练网络,所以不太可能明确地区别网络中的哪一部分属于控制器,哪一部分属于特征提取器。


卷积神经网络结构。网络总共拥有大约 2700 万个连接和 25 万个参数

训练

train.py 会基于 google 的 TensorFlow 训练一个模型,同时会采用 cuDNN 为 GPU 加速。训练会持续一段时间(大约 1 小时),具体耗时会因所用的训练数据和系统环境相关。当训练结束的时候,程序会将模型存储在硬盘上。

单智能体的自主驾驶

play.py 会使用 gym-mupen64plus 环境来执行在马里奥赛车环境中对智能体的训练。这个环境会捕获模拟器的截图。这些图像会被送到模型中以获取将要发送的操纵杆命令。人工智能操纵杆命令可以通过「LB」按钮来重写。


自主驾驶的 LuiGi 赛道

模型的训练使用了以下的环境

  • Luigi 赛道上的 4 次竞赛

  • Kalimari 沙漠赛道中的 2 次竞赛

  • Mario 赛道上的两次竞赛

即使在小的数据集上训练,模型有时候也能够泛化到一个新赛道上(例如上述的 Royal Raceway)。

多智能体的自主驾驶

为了让智能体自主驾驶,OpenAI mupen64plus gym 环境需要连接到一个「自动程序」输入插件。因为我们对多智能体环境感兴趣,所以我们需要一种能够使用多智能体输入程序的方式。因此,基于 mupen64plus-input-bot 和官方插件 API(https://github.com/mupen64plus/mupen64plus-core/blob/master/doc/emuwiki-api-doc/Mupen64Plus-v2.0-Plugin-API.mediawiki#input-plugin-api),我们创建了 4 个玩家输入自动程序。自动输入程序后面的主要思想就是一个 python 服务器。它能够发送 JSON 命令,并把这些命令转译到较低水平的指令。

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>

#include <netinet/tcp.h>
#include <sys/socket.h>
#include <syspes.h>
#include <netinet/in.h>
#include <netdb.h>

#include "plugin.h"
#include "controller.h"

#include "json.h"
#include "json_tokener.h"

#define HOST "localhost"
#define CONTROLLER_PORT1 8082
#define CONTROLLER_PORT2 8083

int socket_connect(char *host, int portno) {
 struct hostent *server;
 struct sockaddr_in serv_addr;
 int sockfd;

 /* create the socket */
 sockfd = socket(AF_INET, SOCK_STREAM, 0);
 if (sockfd < 0) DebugMessage(M64MSG_ERROR, "ERROR opening socket");

 /* lookup the ip address */
 server = gethostbyname(host);
 if (server == NULL) DebugMessage(M64MSG_ERROR, "ERROR, no such host");

 /* fill in the structure */
 memset(&serv_addr, 0, sizeof(serv_addr));
 serv_addr.sin_family = AF_INET;
 serv_addr.sin_port = htons(portno);
 memcpy(&serv_addr.sin_addr.s_addr, server->h_addr, server->h_length);

 /* connect the socket */
 if (connect(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
 DebugMessage(M64MSG_INFO, "ERROR connecting, please start bot server.");
 return -1;
 }


 return sockfd;
}

void clear_controller(Control) {
 controller[Control].buttons.R_DPAD = 0;
 controller[Control].buttons.L_DPAD = 0;
 controller[Control].buttons.D_DPAD = 0;
 controller[Control].buttons.U_DPAD = 0;
 controller[Control].buttons.START_BUTTON = 0;
 controller[Control].buttons.Z_TRIG = 0;
 controller[Control].buttons.B_BUTTON = 0;
 controller[Control].buttons.A_BUTTON = 0;
 controller[Control].buttons.R_CBUTTON = 0;
 controller[Control].buttons.L_CBUTTON = 0;
 controller[Control].buttons.D_CBUTTON = 0;
 controller[Control].buttons.U_CBUTTON = 0;
 controller[Control].buttons.R_TRIG = 0;
 controller[Control].buttons.L_TRIG = 0;
 controller[Control].buttons.X_AXIS = 0;
 controller[Control].buttons.Y_AXIS = 0;
}

void read_controller(int Control) {
 int port;

 // Depending on controller, select whether port 1 or port 2
 switch (Control) {
 case 0:
 port = CONTROLLER_PORT1;
 break;
 case 1:
 port = CONTROLLER_PORT2;
 break;
 default:
 port = CONTROLLER_PORT1;
 }

 if(Control == 1){
 // Ignore controller 1
 // return;
 }

 // DebugMessage(M64MSG_INFO, "Controller #%d listening on port %d", Control, port );

 int sockfd = socket_connect(HOST, port);

 if (sockfd == -1) {
 clear_controller(Control);
 return;
 }

 int bytes, sent, received, total;
 char message[1024], response[4096]; // allocate more space than required.
 sprintf(message, "GET / HTTP/1.0\r\n\r\n");

 /* print the request */
 #ifdef _DEBUG
 DebugMessage(M64MSG_INFO, "[REQUEST] PORT %d: %s", port, message);
 #endif


 /* send the request */
 total = strlen(message);
 sent = 0;
 do {
 bytes = write(sockfd, message + sent, total - sent);
 if (bytes < 0)
 DebugMessage(M64MSG_ERROR, "ERROR writing message to socket");
 if (bytes == 0)
 break;
 sent += bytes;
 } while (sent < total);

 /* receive the response */
 memset(response, 0, sizeof(response));
 total = sizeof(response) - 1;
 received = 0;
 do {
 bytes = read(sockfd, response + received, total - received);
 if (bytes < 0)
 DebugMessage(M64MSG_ERROR, "ERROR reading response from socket");
 if (bytes == 0)
 break;
 received += bytes;
 } while (received < total);

 if (received == total)
 DebugMessage(M64MSG_ERROR, "ERROR storing complete response from socket");

/* print the response */
#ifdef _DEBUG
 DebugMessage(M64MSG_INFO, "[RESPONSE] PORT %d: %s", port, response);
#endif

 /* parse the http response */
 char *body = strtok(response, "\n");
 for (int i = 0; i < 5; i++)
 body = strtok(NULL, "\n");

 /* parse the body of the response */
 json_object *jsonObj = json_tokener_parse(body);

/* print the object */
#ifdef _DEBUG
 DebugMessage(M64MSG_INFO, json_object_to_json_string(jsonObj));
#endif

 controller[Control].buttons.R_DPAD =
 json_object_get_int(json_object_object_get(jsonObj, "R_DPAD"));
 controller[Control].buttons.L_DPAD =
 json_object_get_int(json_object_object_get(jsonObj, "L_DPAD"));
 controller[Control].buttons.D_DPAD =
 json_object_get_int(json_object_object_get(jsonObj, "D_DPAD"));
 controller[Control].buttons.U_DPAD =
 json_object_get_int(json_object_object_get(jsonObj, "U_DPAD"));
 controller[Control].buttons.START_BUTTON =
 json_object_get_int(json_object_object_get(jsonObj, "START_BUTTON"));
 controller[Control].buttons.Z_TRIG =
 json_object_get_int(json_object_object_get(jsonObj, "Z_TRIG"));
 controller[Control].buttons.B_BUTTON =
 json_object_get_int(json_object_object_get(jsonObj, "B_BUTTON"));
 controller[Control].buttons.A_BUTTON =
 json_object_get_int(json_object_object_get(jsonObj, "A_BUTTON"));
 controller[Control].buttons.R_CBUTTON =
 json_object_get_int(json_object_object_get(jsonObj, "R_CBUTTON"));
 controller[Control].buttons.L_CBUTTON =
 json_object_get_int(json_object_object_get(jsonObj, "L_CBUTTON"));
 controller[Control].buttons.D_CBUTTON =
 json_object_get_int(json_object_object_get(jsonObj, "D_CBUTTON"));
 controller[Control].buttons.U_CBUTTON =
 json_object_get_int(json_object_object_get(jsonObj, "U_CBUTTON"));
 controller[Control].buttons.R_TRIG =
 json_object_get_int(json_object_object_get(jsonObj, "R_TRIG"));
 controller[Control].buttons.L_TRIG =
 json_object_get_int(json_object_object_get(jsonObj, "L_TRIG"));
 controller[Control].buttons.X_AXIS =
 json_object_get_int(json_object_object_get(jsonObj, "X_AXIS"));
 controller[Control].buttons.Y_AXIS =
 json_object_get_int(json_object_object_get(jsonObj, "Y_AXIS"));

 close(sockfd);
}

多控制器输入程序

此外,为了支持多智能体的 gym 环境,我们必须更新 gym-mupen64plus。因此,我们 fork 了官方的代码库并创建了我们自己的 gym-mupen64plus。主要的区别都是跟步进/观察/奖励函数相关。我们需要一种能够仅仅查看一部分截屏,为了知道下一步应该采取什么行动来获得所需的信息。

为了启动多智能体的马里奥赛车,只需要运行:

play.py --num_agents=2

这个脚本会根据智能体的数目来创建 python 服务器,并给每个智能体分配端口。然后,使用 mupen64plus 环境 ttps://github.com/bzier/gym-mupen64plus),这个脚本将会选择随机玩家来开始竞赛。

现在,第一个智能体得到了上述的 CNN 模型,同时第二个智能体也得到了一个非常通用的 CNN,它包含 3 个卷积层和 2 个全连接层。每个模型都得到了一定比例的屏幕,然后预测操纵杆的位置和速度按钮。

值得一提的是,为了让游戏看起来比较平滑,我们为每个玩家创建了一个新线程(https://github.com/bzier/gym-mupen64plus)。每个线程必须提供基于全局状态的行动。

if __name__ == '__main__':
  env = gym.make('Mario-Kart-Luigi-Raceway-Multi-v0')
  obs = env.reset()
  env.render()

  while not end_episode:
    # Action should be multi-threaded + setting agent
    for i in range(num_agents):
        agent = i+1

        # Set current agent
        CurrentAgent.set_current_agent(agent)

        # CReate Thread
        thread = AgentThread(target=get_action, args=(agent, obs,actors[i],))
        thread.start()

        # Get action
        action = thread.join()
        # cprint('[Gym Thread] Got action %s for agent %i' %((action,agent)),'red')
        obs, reward, end_episode, info = env.step(action)

  env.render()
  #self.queue.put('render')
  total_reward += reward

从 main 文件中抽取出的代码

哪个模型更好一些?

我们假设能够让智能体赢得竞赛的模型是更准确的,是性能更好的。

结论

我们创建了一个含有使用不同模型的多智能体的系统,这些智能体可以为了赢得比赛而相互竞争。当结合强化学习时,系统回答了一个关键问题:为了得到奖励,什么样的模型性能最好。我们的系统可以被用作了解一个模型比其他模型好的基准工具。


原文链接:https://medium.com/@aymen.mouelhi/multi-agents-self-driving-mario-kart-with-tensorflow-and-cnns-c0f2812b4c50

工程Gym卷积神经网络智能体自动驾驶
3