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.
- 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.
- 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.
- 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:
canvas
: representa nossa imagem de observação.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.elements
: armazena os elementos ativos armazenados na tela em um determinado momento (como helicóptero, pássaro, etc.)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:
reset
: redefine o ambiente ao seu estado inicial e retorna a observação inicial.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:
- Aplicando ações ao nosso agente.
- 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:
- Em vez de o episódio terminar com o primeiro ataque ao pássaro, você pode implementar várias vidas para o helicóptero.
- 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.
- 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!