Pesquisa de site

Primeiros passos com OpenAI Gym: criando ambientes de academia personalizados


OpenAI Gym vem com muitos ambientes incríveis, desde ambientes com tarefas de controle clássicas até aqueles que permitem treinar seus agentes para jogar jogos Atari como Breakout, Pacman e Seaquest. No entanto, você ainda pode ter uma tarefa em mãos que exige a criação de um ambiente personalizado que não faz parte do pacote Gym. Felizmente, o Gym é flexível o suficiente para permitir que você faça isso e esse é exatamente o tema deste post.

Neste post, estaremos projetando um ambiente personalizado que envolverá pilotar um helicóptero (ou helicóptero) evitando obstáculos no ar. Observe que esta é a segunda parte da série Open AI Gym, e o conhecimento dos conceitos introduzidos na Parte 1 é assumido como pré-requisito para esta postagem. Então, se você ainda não leu a Parte 1, aqui está o link.

Pré-requisitos

  • Python: uma máquina com Python instalado e experiência para iniciantes com codificação Python é recomendada para este tutorial
  • Open AI Gym: este pacote deve ser instalado na máquina/droplet que está sendo usada

Dependências/Importações

Começamos primeiro instalando algumas dependências importantes.

!pip install opencv-python 
!pip install pillow

Também começamos com as importações necessárias.

import numpy as np 
import cv2 
import matplotlib.pyplot as plt
import PIL.Image as Image
import gym
import random

from gym import Env, spaces
import time

font = cv2.FONT_HERSHEY_COMPLEX_SMALL 

Descrição do Meio Ambiente

O ambiente que estamos criando é basicamente um jogo fortemente inspirado no jogo Dino Run, aquele que você joga no Google Chrome se estiver desconectado da Internet. Há um dinossauro e você tem que pular sobre cactos e evitar bater nos pássaros. A distância que você percorre representa a recompensa que você acaba recebendo.

No nosso jogo, em vez de um dinossauro, nosso agente será um piloto de Chopper.

  1. O helicóptero deve percorrer a maior distância possível para obter a recompensa máxima. Haverá pássaros que o helicóptero deverá evitar.
  2. O episódio termina em caso de colisão com um pássaro. O episódio também pode terminar se o Chopper ficar sem combustível.
  3. Tal como os pássaros, existem tanques de combustível flutuantes (sim, não há pontos por estar perto da realidade, eu sei!) que o Chopper pode recolher para reabastecer o helicóptero até à sua capacidade total (que é fixada em 1000 L).

Observe que esta será apenas uma prova de conceito e não o jogo mais esteticamente agradável. Porém, caso você queira melhorar, este post vai te deixar com conhecimento suficiente para isso!

A primeira consideração ao projetar um ambiente é decidir que tipo de espaço de observação e espaço de ação usaremos.

  • O espaço de observação pode ser contínuo ou discreto. Um exemplo de espaço de ação discreto é o de um mundo em grade onde o espaço de observação é definido por células, e o agente pode estar dentro de uma dessas células. Um exemplo de espaço de ação contínuo é aquele em que a posição do agente é descrita por coordenadas com valores reais.
  • O espaço de ação também pode ser contínuo ou discreto. Um exemplo de espaço discreto é aquele em que cada ação corresponde ao comportamento particular do agente, mas esse comportamento não pode ser quantificado. Um exemplo disso é Mario Bros, onde cada ação levaria a mover-se para a esquerda, para a direita, pular, etc. Suas ações não podem quantificar o comportamento que está sendo produzido, ou seja, você pode pular, mas não pular alto, mais alto ou mais baixo. No entanto, em um jogo como Angry Birds, você decide quanto esticar o estilingue (você quantifica).

Classe ChopperScape

Começamos a implementar a função __init__ da nossa classe de ambiente, ChopperScape. Na função __init__ definiremos os espaços de observação e de ação. Além disso, também implementaremos alguns outros atributos:

  1. canvas: representa nossa imagem de observação.
  2. x_min, y_min, x_max, y_max: Isso define a área legítima da nossa tela onde vários elementos da tela, como o Chopper e os pássaros, podem ser colocados. Outras áreas são reservadas para exibir informações como combustível restante, recompensas e preenchimento.
  3. elements: armazena os elementos ativos armazenados na tela em um determinado momento (como helicóptero, pássaro, etc.)
  4. max_fuel: Combustível máximo que o helicóptero pode conter.
class ChopperScape(Env):
    def __init__(self):
        super(ChopperScape, self).__init__()



        # Define a 2-D observation space
        self.observation_shape = (600, 800, 3)
        self.observation_space = spaces.Box(low = np.zeros(self.observation_shape), 
                                            high = np.ones(self.observation_shape),
                                            dtype = np.float16)


        # Define an action space ranging from 0 to 4
        self.action_space = spaces.Discrete(6,)

        # Create a canvas to render the environment images upon 
        self.canvas = np.ones(self.observation_shape) * 1

        # Define elements present inside the environment
        self.elements = []

        # Maximum fuel chopper can take at once
        self.max_fuel = 1000

        # Permissible area of helicper to be 
        self.y_min = int (self.observation_shape[0] * 0.1)
        self.x_min = 0
        self.y_max = int (self.observation_shape[0] * 0.9)
        self.x_max = self.observation_shape[1]

Elementos do Meio Ambiente

Uma vez determinados o espaço de ação e o espaço de observação, precisamos finalizar quais seriam os elementos do nosso ambiente. Em nosso jogo, temos três elementos distintos: o helicóptero, os pássaros voadores e os postos de combustível flutuantes. Estaremos implementando tudo isso como classes separadas que herdam de uma classe base comum chamada Point.

Classe Base de Ponto

A classe Point é usada para definir qualquer ponto arbitrário em nossa imagem de observação. Definimos esta classe com os seguintes atributos:

  • (x,y): Posição do ponto na imagem.
  • (x_min, x_max, y_min, y_max): Coordenadas permitidas para o ponto. Se tentarmos definir a posição do ponto fora desses limites, os valores da posição serão fixados nesses limites.
  • nome: Nome do ponto.

Definimos as seguintes funções-membro para esta classe.

  • get_position: obtém as coordenadas do ponto.
  • set_position: Defina as coordenadas do ponto para um determinado valor.
  • move: Mova os pontos por determinado valor.
class Point(object):
    def __init__(self, name, x_max, x_min, y_max, y_min):
        self.x = 0
        self.y = 0
        self.x_min = x_min
        self.x_max = x_max
        self.y_min = y_min
        self.y_max = y_max
        self.name = name

    def set_position(self, x, y):
        self.x = self.clamp(x, self.x_min, self.x_max - self.icon_w)
        self.y = self.clamp(y, self.y_min, self.y_max - self.icon_h)

    def get_position(self):
        return (self.x, self.y)

    def move(self, del_x, del_y):
        self.x += del_x
        self.y += del_y

        self.x = self.clamp(self.x, self.x_min, self.x_max - self.icon_w)
        self.y = self.clamp(self.y, self.y_min, self.y_max - self.icon_h)

    def clamp(self, n, minn, maxn):
        return max(min(maxn, n), minn)

Agora definimos as classes Chopper, Bird e Fuel. Essas classes são derivadas da classe Point e introduzem um conjunto de novos atributos:

  • icon: ícone do ponto que será exibido na  imagem de observação quando o jogo for renderizado.
  • (icon_w, icon_h): Dimensões do ícone.
class Chopper(Point):
    def __init__(self, name, x_max, x_min, y_max, y_min):
        super(Chopper, self).__init__(name, x_max, x_min, y_max, y_min)
        self.icon = cv2.imread("chopper.png") / 255.0
        self.icon_w = 64
        self.icon_h = 64
        self.icon = cv2.resize(self.icon, (self.icon_h, self.icon_w))


class Bird(Point):
    def __init__(self, name, x_max, x_min, y_max, y_min):
        super(Bird, self).__init__(name, x_max, x_min, y_max, y_min)
        self.icon = cv2.imread("bird.png") / 255.0
        self.icon_w = 32
        self.icon_h = 32
        self.icon = cv2.resize(self.icon, (self.icon_h, self.icon_w))

class Fuel(Point):
    def __init__(self, name, x_max, x_min, y_max, y_min):
        super(Fuel, self).__init__(name, x_max, x_min, y_max, y_min)
        self.icon = cv2.imread("fuel.png") / 255.0
        self.icon_w = 32
        self.icon_h = 32
        self.icon = cv2.resize(self.icon, (self.icon_h, self.icon_w))

De volta à aula do ChopperScape

Lembre-se da Parte 1 que qualquer classe Env de academia tem duas funções importantes:

  1. reset: redefine o ambiente ao seu estado inicial e retorna a observação inicial.
  2. step : Executa uma etapa no ambiente aplicando uma ação. Retorna a nova observação, recompensa, status de conclusão e outras informações.

Nesta seção, implementaremos as funções reset e step do nosso ambiente junto com muitas outras funções auxiliares. Começamos com a função reset.

Função de reinicialização

Quando redefinimos nosso ambiente, precisamos redefinir todas as variáveis baseadas em estado em nosso ambiente. Isso inclui coisas como o combustível consumido, o retorno episódico e os elementos presentes no ambiente.

No nosso caso, quando reiniciamos nosso ambiente, não temos nada além do Chopper no estado inicial. Inicializamos nosso helicóptero aleatoriamente em uma área no canto superior esquerdo de nossa imagem. Essa área representa de 5 a 10 por cento da largura da imagem e de 15 a 20 por cento da altura da imagem.

Definimos também uma função auxiliar chamada draw_elements_on_canvas que basicamente coloca os ícones de cada um dos elementos presentes no jogo em suas respectivas posições na imagem de observação. Se a posição estiver além do intervalo permitido, os ícones serão colocados nos limites do intervalo. Também imprimimos informações importantes como o combustível restante.

Finalmente devolvemos a tela na qual os elementos foram colocados como observação.

%%add_to ChopperScape

def draw_elements_on_canvas(self):
    # Init the canvas 
    self.canvas = np.ones(self.observation_shape) * 1

    # Draw the heliopter on canvas
    for elem in self.elements:
        elem_shape = elem.icon.shape
        x,y = elem.x, elem.y
        self.canvas[y : y + elem_shape[1], x:x + elem_shape[0]] = elem.icon

    text = 'Fuel Left: {} | Rewards: {}'.format(self.fuel_left, self.ep_return)

    # Put the info on canvas 
    self.canvas = cv2.putText(self.canvas, text, (10,20), font,  
               0.8, (0,0,0), 1, cv2.LINE_AA)

def reset(self):
    # Reset the fuel consumed
    self.fuel_left = self.max_fuel

    # Reset the reward
    self.ep_return  = 0

    # Number of birds
    self.bird_count = 0
    self.fuel_count = 0

    # Determine a place to intialise the chopper in
    x = random.randrange(int(self.observation_shape[0] * 0.05), int(self.observation_shape[0] * 0.10))
    y = random.randrange(int(self.observation_shape[1] * 0.15), int(self.observation_shape[1] * 0.20))
    
    # Intialise the chopper
    self.chopper = Chopper("chopper", self.x_max, self.x_min, self.y_max, self.y_min)
    self.chopper.set_position(x,y)

    # Intialise the elements 
    self.elements = [self.chopper]

    # Reset the Canvas 
    self.canvas = np.ones(self.observation_shape) * 1

    # Draw elements on the canvas
    self.draw_elements_on_canvas()


    # return the observation
    return self.canvas 

Antes de prosseguirmos, vamos agora ver como é a nossa observação inicial.

env = ChopperScape()
obs = env.reset()
plt.imshow(obs)

Como nossa observação é igual à tela de jogo do jogo, nossa função de renderização também retornará nossa observação. Construímos funcionalidade para dois modos, um human que renderizaria o jogo em uma janela pop-up, enquanto rgb_array o retorna como uma matriz de pixels.

%%add_to ChopperScape

def render(self, mode = "human"):
    assert mode in ["human", "rgb_array"], "Invalid mode, must be either \"human\" or \"rgb_array\""
    if mode == "human":
        cv2.imshow("Game", self.canvas)
        cv2.waitKey(10)
    
    elif mode == "rgb_array":
        return self.canvas
    
def close(self):
    cv2.destroyAllWindows()

env = ChopperScape()
obs = env.reset()
screen = env.render(mode = "rgb_array")
plt.imshow(screen)

Função de etapa

Agora que já resolvemos a função reset, começamos a trabalhar na implementação da função step, que conterá o código para fazer a transição do nosso ambiente de um estado para o próximo. dada uma ação. Em muitos aspectos, esta seção é a proverbial carne do nosso meio ambiente, e é para lá que vai a maior parte do planejamento.

Primeiro precisamos listar coisas que precisam acontecer em uma etapa de transição do ambiente. Isso pode ser basicamente dividido em duas partes:

  1. Aplicando ações ao nosso agente.
  2. Tudo o mais que acontece nos ambientes, como o comportamento dos atores não pertencentes à RL (por exemplo, pássaros e postos de gasolina flutuantes).

Então, vamos primeiro nos concentrar em (1). Fornecemos ações ao jogo que controlarão o que nosso helicóptero faz. Basicamente temos 5 ações, que são mover para a direita, para a esquerda, para baixo, para cima ou não fazer nada, denotadas por 0, 1, 2, 3 e 4, respectivamente.

Definimos uma função membro chamada get_action_meanings() que nos dirá para qual número inteiro cada ação é mapeada para nossa referência.

%%add_to ChopperScape

def get_action_meanings(self):
    return {0: "Right", 1: "Left", 2: "Down", 3: "Up", 4: "Do Nothing"}

Também validamos se a ação que está sendo passada é válida ou não, verificando se ela está presente no espaço de ação. Caso contrário, levantamos uma afirmação.

# Assert that it is a valid action 
assert self.action_space.contains(action), "Invalid Action"

Feito isso, alteramos a posição do helicóptero usando a função move que definimos anteriormente. Cada ação resulta em movimento em 5 coordenadas nas respectivas direções.

# apply the action to the chopper
if action == 0:
    self.chopper.move(0,5)
elif action == 1:
    self.chopper.move(0,-5)
elif action == 2:
    self.chopper.move(5,0)
elif action == 3:
    self.chopper.move(-5,0)
elif action == 4:
    self.chopper.move(0,0)

Agora que cuidamos de aplicar a ação no helicóptero, focamos nos demais elementos do ambiente:

  • Os pássaros aparecem aleatoriamente na borda direita da tela com uma probabilidade de 1% (ou seja, é provável que um pássaro apareça na borda direita uma vez a cada cem quadros). O pássaro move 5 pontos de coordenadas a cada quadro para a esquerda. Se eles acertarem o Chopper, o jogo termina. Caso contrário, eles desaparecem do jogo quando atingem a borda esquerda.
  • Os tanques de combustível aparecem aleatoriamente na borda inferior da tela com uma probabilidade de 1% (ou seja, é provável que um tanque de combustível apareça na borda inferior uma vez a cada cem quadros). O pássaro move 5 coordenadas para cima em cada quadro. Se atingirem o Chopper, o Chopper será abastecido em sua capacidade total. Caso contrário, eles desaparecem do jogo quando atingem a borda superior.

Para implementar os recursos descritos acima, precisamos implementar uma função auxiliar que nos ajude a determinar se dois objetos Point (como um Chopper/Bird, Chopper/Fuel Tank) colidiram ou não. Como definimos uma colisão? Dizemos que dois pontos colidiram quando a distância entre as coordenadas dos seus centros é inferior a metade da soma das suas dimensões. Chamamos essa função de has_collided.

%%add_to ChopperScape

def has_collided(self, elem1, elem2):
    x_col = False
    y_col = False

    elem1_x, elem1_y = elem1.get_position()
    elem2_x, elem2_y = elem2.get_position()

    if 2 * abs(elem1_x - elem2_x) <= (elem1.icon_w + elem2.icon_w):
        x_col = True

    if 2 * abs(elem1_y - elem2_y) <= (elem1.icon_h + elem2.icon_h):
        y_col = True

    if x_col and y_col:
        return True

    return False

Além disso, temos que fazer alguma contabilidade. A recompensa para cada etapa é 1, portanto, o contador de retorno episódico é atualizado em 1 a cada episódio. Se houver uma colisão, a recompensa será -10 e o episódio terminará. O contador de combustível é reduzido em 1 a cada passo.

Finalmente, implementamos nossa função step. Escrevi comentários extensos para orientá-lo.

%%add_to ChopperScape

def step(self, action):
    # Flag that marks the termination of an episode
    done = False

    # Assert that it is a valid action 
    assert self.action_space.contains(action), "Invalid Action"

    # Decrease the fuel counter 
    self.fuel_left -= 1 

    # Reward for executing a step.
    reward = 1      

    # apply the action to the chopper
    if action == 0:
        self.chopper.move(0,5)
    elif action == 1:
        self.chopper.move(0,-5)
    elif action == 2:
        self.chopper.move(5,0)
    elif action == 3:
        self.chopper.move(-5,0)
    elif action == 4:
        self.chopper.move(0,0)

    # Spawn a bird at the right edge with prob 0.01
    if random.random() < 0.01:

        # Spawn a bird
        spawned_bird = Bird("bird_{}".format(self.bird_count), self.x_max, self.x_min, self.y_max, self.y_min)
        self.bird_count += 1

        # Compute the x,y co-ordinates of the position from where the bird has to be spawned
        # Horizontally, the position is on the right edge and vertically, the height is randomly 
        # sampled from the set of permissible values
        bird_x = self.x_max 
        bird_y = random.randrange(self.y_min, self.y_max)
        spawned_bird.set_position(self.x_max, bird_y)

        # Append the spawned bird to the elements currently present in Env. 
        self.elements.append(spawned_bird)    

    # Spawn a fuel at the bottom edge with prob 0.01
    if random.random() < 0.01:
        # Spawn a fuel tank
        spawned_fuel = Fuel("fuel_{}".format(self.bird_count), self.x_max, self.x_min, self.y_max, self.y_min)
        self.fuel_count += 1

        # Compute the x,y co-ordinates of the position from where the fuel tank has to be spawned
        # Horizontally, the position is randomly chosen from the list of permissible values and 
        # vertically, the position is on the bottom edge
        fuel_x = random.randrange(self.x_min, self.x_max)
        fuel_y = self.y_max
        spawned_fuel.set_position(fuel_x, fuel_y)

        # Append the spawned fuel tank to the elemetns currently present in the Env.
        self.elements.append(spawned_fuel)   

    # For elements in the Ev
    for elem in self.elements:
        if isinstance(elem, Bird):
            # If the bird has reached the left edge, remove it from the Env
            if elem.get_position()[0] <= self.x_min:
                self.elements.remove(elem)
            else:
                # Move the bird left by 5 pts.
                elem.move(-5,0)

            # If the bird has collided.
            if self.has_collided(self.chopper, elem):
                # Conclude the episode and remove the chopper from the Env.
                done = True
                reward = -10
                self.elements.remove(self.chopper)

        if isinstance(elem, Fuel):
            # If the fuel tank has reached the top, remove it from the Env
            if elem.get_position()[1] <= self.y_min:
                self.elements.remove(elem)
            else:
                # Move the Tank up by 5 pts.
                elem.move(0, -5)

            # If the fuel tank has collided with the chopper.
            if self.has_collided(self.chopper, elem):
                # Remove the fuel tank from the env.
                self.elements.remove(elem)

                # Fill the fuel tank of the chopper to full.
                self.fuel_left = self.max_fuel

    # Increment the episodic return
    self.ep_return += 1

    # Draw elements on the canvas
    self.draw_elements_on_canvas()

    # If out of fuel, end the episode.
    if self.fuel_left == 0:
        done = True

    return self.canvas, reward, done, []

Vendo isso em ação

Isso conclui o código do nosso ambiente. Agora execute algumas etapas no ambiente utilizando um agente que realiza ações aleatórias!

from IPython import display

env = ChopperScape()
obs = env.reset()


while True:
    # Take a random action
    action = env.action_space.sample()
    obs, reward, done, info = env.step(action)

    # Render the game
    env.render()

    if done == True:
        break

env.close()

Renderizando o Ambiente

Conclusão

É isso por esta parte, pessoal. Espero que este tutorial tenha lhe dado algumas dicas sobre algumas das considerações e decisões de design necessárias para projetar um ambiente OpenAI personalizado. Agora você pode tentar criar um ambiente de sua escolha ou, se desejar, pode fazer diversas melhorias naquele que acabamos de projetar para praticar. Algumas sugestões logo de cara são:

  1. Em vez de o episódio terminar com o primeiro ataque ao pássaro, você pode implementar várias vidas para o helicóptero.
  2. Projete uma raça alienígena maligna de pássaros mutantes que também são capazes de disparar mísseis contra o helicóptero, e o helicóptero terá que evitá-los.
  3. Faça algo a respeito quando um tanque de combustível e um pássaro colidirem!

Com essas sugestões, é um embrulho. Boa codificação!

Artigos relacionados: