I created a custom gym environment representing ships which have to intercept asteroids. I used the ray RLLib to train a PPO algorithm on my environment
My issue is that using ray RLLib (a PPOTrainer with a simple config), the environment training does not converge as I expect. The rewards_mean is never above 2,5, knowing that the max is 3. I played with a lot of hyperparameters but it was not better. So I think the problem is maybe in my environment. Maybe I have to add hyperparameters in my environment and not in my PPO algorithm. I focused only on the position et and the speed of my ships and my asteroids. I am not an expert of physic sciences, and I don't understand what could be the cause of the problem.
Here is my environment class. Comments are in french so don't hesitate ask me any explanation about this class or another one, like asteroid or ship.
import itertools
# from ipdb import set_trace as st
import numpy as np
from gym import core, spaces
from gym.envs.classic_control import rendering
from gym.utils import seeding
from scipy.spatial import distance_matrix
# Spécificités du simulateur (habillage)
NB_VAISSEAUX = 1
NB_ASTEROIDES = NB_VAISSEAUX + 1
NB_ACTIONS_POSSIBLES_PAR_VAISSEAU = 2
NB_OBSERVATION_UNITAIRE = 2
VITESSE_VAISSEAUX = 1
OFFSCREEN_SPACE = 3
SCREEN_WIDTH = 800
SCREEN_HEIGHT = 600
SCREEN_TITLE = "Affect'a Meute"
LEFT_LIMIT = OFFSCREEN_SPACE
RIGHT_LIMIT = SCREEN_WIDTH - OFFSCREEN_SPACE
BOTTOM_LIMIT = OFFSCREEN_SPACE
TOP_LIMIT = SCREEN_HEIGHT - OFFSCREEN_SPACE
#Représentation des astéroides
DISTANCE_COLLISION = 50
RAYON_ASTEROIDES = 25
COULEUR_ASTEROIDES_R = 200
COULEUR_ASTEROIDES_G = 0.8
COULEUR_ASTEROIDES_B = 0
#Dimensions et couleurs des vaisseaux
LONGUEUR_VAISSEAU = 20
LARGEUR_VAISSEAU = 5
red = np.random.randn(NB_VAISSEAUX)
green = np.random.randn(NB_VAISSEAUX)
blue = np.random.randn(NB_VAISSEAUX)
class Asteroide():
""" Représentation d'un asteroide """
def __init__(self, id_asteroide):
""" Positionnement initial de l'astéroide decpuis la droite avec un
déplacement de droite à gauche """
self.id = id_asteroide
self.speed = VITESSE_VAISSEAUX
self.center_x = RIGHT_LIMIT
self.center_y = np.random.randint(TOP_LIMIT)
self.change_x = - self.speed
self.change_y = 0
def update(self):
""" Mise à jour dde la position du vaisseau """
# propagation de dynamique
self.center_x -= self.speed
# Si l'astéroide sort de l'écran
if self.center_x > RIGHT_LIMIT:
self.center_x = LEFT_LIMIT
if self.center_x < LEFT_LIMIT:
self.center_x = RIGHT_LIMIT
class Asteroide_inoffensif():
""" Représentation d'un asteroide inoffensif """
def __init__(self, id_asteroide):
""" Positionnement initial de l'astéroide decpuis la droite avec un
déplacement de droite à gauche """
self.id = id_asteroide
self.speed = VITESSE_VAISSEAUX
self.center_x = RIGHT_LIMIT
self.center_y = np.random.randint(TOP_LIMIT)
self.change_x = - self.speed
self.change_y = 0
def update(self):
""" Mise à jour dde la position du vaisseau """
# propagation de dynamique
self.center_x -= self.speed
# Si l'astéroide sort de l'écran
if self.center_x > RIGHT_LIMIT:
self.center_x = LEFT_LIMIT
if self.center_x < LEFT_LIMIT:
self.center_x = RIGHT_LIMIT
def __init__(self, id_asteroide):
""" Positionnement initial de l'astéroide decpuis la droite avec un
déplacement de droite à gauche """
self.id = id_asteroide
self.speed = VITESSE_VAISSEAUX
self.center_x = RIGHT_LIMIT
self.center_y = np.random.randint(TOP_LIMIT)
self.change_x = - self.speed
self.change_y = 0
def update(self):
""" Mise à jour dde la position du vaisseau """
# propagation de dynamique
self.center_x -= self.speed
# Si l'astéroide sort de l'écran
if self.center_x > RIGHT_LIMIT:
self.center_x = LEFT_LIMIT
if self.center_x < LEFT_LIMIT:
self.center_x = RIGHT_LIMIT
class Vaisseau():
""" Représentation d'un vaisseau """
def __init__(self, id_vaisseau):
""" Set up the space ship. """
self.id = id_vaisseau
self.change_dir = 0
self.speed = VITESSE_VAISSEAUX
self.max_speed = VITESSE_VAISSEAUX
self.center_x = LEFT_LIMIT
self.center_y = np.random.randint(SCREEN_HEIGHT)
self.change_x = self.speed
self.change_y = 0
self.angle = 0 # Pointe vers la droite
self.inactif = False
def act(self, action, informer = False):
""" Réalisation d'un ordre commandé """
if action == 0:
# Déplacement en crabe
self.change_dir = +1
if informer:
print("\tVaisseau", self.id, " --> Boost latéral +")
elif action == 1:
self.change_dir = -1
if informer:
print("\tVaisseau", self.id, " --> Boost latéral -")
elif action == 2:
# Rotation trigo
self.angle -= 3
elif action == 3:
# Rotation horaire
self.angle += 3
def update(self):
""" Mise à jour de la position du vaisseau """
# propagation de dynamique
self.center_x += self.change_x
self.center_y += self.change_y
# Application d'un DV latéral plafonné
INCREMENT_VITESSE_LATERAL = 0.5
if self.change_dir == 1:
self.change_y += INCREMENT_VITESSE_LATERAL
elif self.change_dir == -1:
self.change_y -= INCREMENT_VITESSE_LATERAL
# If the ship goes off-screen, move it to the other side of the window
if self.center_y > TOP_LIMIT:
self.center_y = BOTTOM_LIMIT
if self.center_y < BOTTOM_LIMIT:
self.center_y = TOP_LIMIT
#Si le vaisseau sort de l'écran par la droite, il est hors jeu
if self.center_x > RIGHT_LIMIT:
self.inactif = True
class AffectaMeuteEnv(core.Env):
"""
Des vaisseaux venant de la gauche doivent intercepter des astéroides
provenant de la droite par contact direct
Spécificités:
Deplacement uniquement en crabe
Orientation du vaisseau autour de son CdG
Vitesse d'avance constante
Mouvement relatif de corps selon l'axe X de l'écran
Pas de spliting lors de la destruction d'un astéroide
Objectif:
Entrainer un PPO/DQN via la RLLIB
Trouver les hyperparamètre via Tune de RLlib
**STATE:**
L'état est composé pour tous les couples vaisseau-astéroide de:
- miss-distance à l'astéroide (distance suivant Y à l'astéroide)
- Vy, vitesse relative entre asteroide et vaisseau suivant l'axe Y
**ACTIONS:**
Chaque vaisseau peut soit:
- Ne rien faire
- Faire un boost vers le haut
- Faire un boost vers le bas
Dans une future version avec visibilité on aura:
- Tourner autour de son CdG en sens trigonométrique
- Tourner autour de son CdG en sens horaire
"""
metadata = {"render.modes": ["human", "rgb_array"], "video.frames_per_second": 15}
def __init__(self):
# L'état est en position relative:
# - distance à l'astéroide le plus proche (norme de la LOS)
# - angle entre l'axe vaisseau et la LOS (0 quand on pointe sur l'astéroide)
# - Vx, vitesse relative entre asteroide et vaisseau suivant l'axe x
# - Vy, vitesse relative entre asteroide et vaisseau suivant l'axe y
nb_couple_VaisAst = len([x for x in itertools.product(range(NB_VAISSEAUX), range(NB_ASTEROIDES))])
self.dim_vobs = NB_OBSERVATION_UNITAIRE * nb_couple_VaisAst
lemax = np.linalg.norm( [SCREEN_WIDTH,SCREEN_HEIGHT] )
self.observation_space = spaces.Box(low=-lemax, high=lemax, shape=(self.dim_vobs,), dtype=np.float32)
#L'IA peut soit:
# - Ne rien faire
# soit maneuvrer un vaisseau:
# - Faire un boost vers le haut
# - Faire un boost vers le bas
# - (ultérieurement) Tourner autour de son CdG en sens trigonométrique
# - (ultérieurement) Tourner autour de son CdG en sens horaire
#
# Exemple pour un scénario avec 2 vaisseaux:
# [vaisseau 1 boost+, vaisseau 1 boost-, vaisseau 2 boost+, vaisseau 2 boost-, Ne rien faire ]
self.action_space = spaces.Discrete(NB_VAISSEAUX * NB_ACTIONS_POSSIBLES_PAR_VAISSEAU + 1)
# Spécificités Gym
self.viewer = None
self.state = None
self.seed()
def seed(self, seed=None):
self.np_random, seed = seeding.np_random(seed)
return [seed]
def update_status_vaisseaux(self):
""" Mise à jour de l'état de participation des vaisseaux à la meute """
self.ships_alive = [not ship.inactif for ship in self.ships]
def reset(self):
""" Set up the game and initialize the variables. """
self.score = 0
self.frame_count = 0
self.game_over = False
# Initialisation des vaisseaux
# Les vaisseaux sont créés et considérés actifs.
# Si un vaisseau subit une collision, il n'est pas retiré de
# la simulation mais rendu INACTIF. Ceci permet de gérer les
# indices du vecteur d'actions possibles
self.ships = [Vaisseau(id_vaisseau) for id_vaisseau in range(NB_VAISSEAUX)]
self.update_status_vaisseaux()
# Initialisation des astéroides
self.asteroides = [Asteroide(id_asteroide) for id_asteroide in range(NB_ASTEROIDES)]
# Répartition des asteroides sur toute la hauteur de l'écran
#posY_ast = np.random.uniform(low=0.0, high=SCREEN_HEIGHT, size=NB_ASTEROIDES) # Induit que dans certaines configuration deux asteroides peuvent etre proches et on peu alors les détruires avec un seul vaisseau
posY_ast = np.linspace(start=0.0, stop=SCREEN_HEIGHT, num=NB_ASTEROIDES)
for i_ast in range(len(self.asteroides)):
self.asteroides[i_ast].center_y = posY_ast[i_ast]
# Retourne l'état courant
self.state = self.observation()
return self.state
def step(self, action, informer = False):
""" Evolution de l'environnement sur un pas """
######################################################
# Affichage pour l'utilisateur de l'état courant et de
# l'action selectionnée par l'IA
######################################################
if informer:
# -- Status sur les menaces
print("\n", "-"*50, "\nMenace(s) : ", NB_ASTEROIDES, " astéroïde(s) dans le scénario :")
for asteroide in self.asteroides:
print("\tAsteroide", asteroide.id, " Toujours menaçant")
# -- Status sur les Vaisseaux
print("Vaisseau(x):")
for idship in range(len(self.ships)):
ship = self.ships[idship]
if ship.inactif:
print("\tShip", idship, " Dead")
else:
print("\tShip", idship, " Actif")
# -- Observation courante
print("Observation:")
i_info = 0
for i_ship in range(NB_VAISSEAUX):
for i_ast in range(NB_ASTEROIDES):
print("\tShip", i_ship, " Ast", i_ast, ": MDis Y:", self.state[i_info])
i_info += 1
print("\tShip", i_ship, " Ast", i_ast, ": Vrel Y:", self.state[i_info])
i_info += 1
# -- Action selectionnée
print("Action:")
# Réalisation de l'action. On fait le lien entre l'ordre donné par l'IA
# et l'action d'un vaisseau
#
# 1) On souhaite que le vecteur des actions possibles reste constant
# pour toute une simulation. C'est à dire que si le vaisseau 2 a
# été détruit (action=2 et action=3), il faut que ces actions ne
# menent à rien ensuite et qu'elles ne commandent pas le
# vaisseau 3 (action=4 et action=5)
#
# 2) l'action unique d'indice "action_space.n-1" = NE RIEN FAIRE
# L'action demandée est-elle == Ne rien faire ?
if action != (self.action_space.n-1):
# Détermination du vaisseau concerné par l'action
id_ship = int(np.floor(action/NB_ACTIONS_POSSIBLES_PAR_VAISSEAU))
#print("\nAction: ", action, " pour vaisseau id_ship: ", id_ship)
# Si l'action porte sur un vaisseau inactif, on ne fait rien.
# Sinon on agit
if self.ships_alive[id_ship]:
# Détermination de l'action
action_ship = action % NB_ACTIONS_POSSIBLES_PAR_VAISSEAU
# Réalisation de l'action
self.ships[id_ship].act(action_ship, informer)
else:
if informer:
print("\tNE RIEN FAIRE")
# On laisse l'environnement réagir
nouvel_etat, reward, done = self.update()
if informer:
# -- Reward immédiat
print("Reward:", reward)
return (nouvel_etat, reward, done, {})
def random_rollout(self): # A tester !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
""" A rollout is a simulation of a policy in an environment.
The code below performs a random rollout. It takes random
actions until the simulation has finished and returns the
cumulative reward.
"""
state = self.reset()
done = False
cumulative_reward = 0
# Keep looping as long as the simulation has not finished.
while not done:
# Choose a random action
action = np.random.choice(self.action_space)
# Take the action in the environment.
state, reward, done, _ = self.step(action)
# Update the cumulative reward.
cumulative_reward += reward
# Return the cumulative reward.
return cumulative_reward
def update(self):
""" Mise à jour de tout l'environnement avec:
Propagation de la dynamique
Détermination des collisions
"""
fin_execution = False
reward = 0
# --- Propagation des états ---
if (len(self.asteroides) > 0) and (len(self.ships) > 0):
[asteroide.update() for asteroide in self.asteroides]
[ship.update() for ship in self.ships]
self.update_status_vaisseaux()
# --- Gestion des collisions entre vaisseaux et astéroides ---
if np.any(self.ships_alive) and (len(self.asteroides) > 0):
# Calcul des distances entre astéroides et vaisseaux encore actifs
XY_vaisseaux = [(ship.center_x, ship.center_y) for ship in self.ships if ship.inactif == False]
XY_asteroides = [(ast.center_x, ast.center_y) for ast in self.asteroides]
dist = distance_matrix(XY_vaisseaux, XY_asteroides)
# Récupération des couples (vaisseau actif, asteroide) ayant une
# distance relative d'interception
id_ship, id_ast = np.where(dist < DISTANCE_COLLISION)
if len(id_ship) > 0:
# !!!!! -------------- !!!!!
# On kill un astéroide
# WIN +1
# !!!!! -------------- !!!!!
# Cette implémenation est robuste à plusieurs vaisseaux qui
# impactent le meme asteroide. On détruit plusieurs vaisseaux
# mais un seul astéroide
reward += len( np.unique(id_ast) )
# On flag comme inactif le vaisseau qui a été détruit
# Le rendre inactif supprime sa participation dans la meute
vaisseaux_actifs = [ship for ship in self.ships if ship.inactif == False]
ship_a_retirer = [vaisseaux_actifs[i] for i in np.unique(id_ship) ]
for ship_devenu_inactif in ship_a_retirer:
# Désactivation du vaisseau
ship_devenu_inactif.inactif = True
# Mise à jour des vaisseaux actifs
self.update_status_vaisseaux()
# Retrait de l'astéroide détruit
ast_a_retirer = [self.asteroides[i] for i in np.unique(id_ast)]
for ast_retrait in ast_a_retirer:
# Suppression de l'astéroide
self.asteroides.remove(ast_retrait)
# --- Gestion des vaisseaux inactifs ---
# Un vaisseau devient inactif soit :
# 1) Par destruction (cf. ci-dessus)
# 2) Parce qu'il est sorti de l'écran par la droite (cf. Vaisseau.update)
# 3) Parce qu'il a dépassé toutes les menaces (ce qui est fait dans les lignes suivantes)
pos_X_asteroides = np.array([ast.center_x for ast in self.asteroides])
for ship in self.ships:
if np.all( ship.center_x > pos_X_asteroides):
# Suppression du vaisseau de la meute. Mais pas de point de pénalité
ship.inactif = True
# --- Gestion de la fin de simulation ---
if (len(self.asteroides) == 0) or (not np.any(self.ships_alive)):
# Si tous les Astéroides ont été détruits, on a gagné
# Si tous les vaisseaux sont inactifs, on a terminé
fin_execution = True
# Acquisition de l'état
self.state = self.observation()
# Mise à jour du score pour l'affichage
self.score += reward
return (self.state, reward, fin_execution)
def observation(self):
""" Création d'une observation
L'état est composé pour tous les couples vaisseau-astéroide de:
- miss-distance à l'astéroide (distance suivant Y à l'astéroide)
- Vy, vitesse relative entre asteroide et vaisseau suivant l'axe Y
Afin de gérer au mieux la disparition de vaisseau ou d'astéroides,
On crée un vecteur d'observation de taille fixe:
- Toutes les observations relatives à un vaisseau incatif sont mise à 0
- Toutes les observations où intervient un astéroide détruit sont mise à 0
"""
# La taille de l'observation est constante pendant toute la simulation.
# On gère le padding en definissant une observation vide et remplissant
# les éléments existants
obs = np.zeros(self.dim_vobs, dtype=np.float32)
#print("Vaisseau actifs", self.ships_alive)
#print("Astéroides existants:", [asteroide.id for asteroide in self.asteroides])
# Condition de traitement
if (len(self.ships) != 0) and (len(self.asteroides) != 0):
# Réalisation du traitement unitaire pour chaque vaisseau
for id_ship in range(len(self.ships)):
ship = self.ships[id_ship]
# Si le vaisseau est inactif, on laisse toutes ses
# observations à 0 (il ne participe plus dans la meute)
if not ship.inactif:
# Mise à jour des observations par rapport à chaque menace
for men in self.asteroides:
# INFO 1: Distance latérale à l'astéroide (miss-distance)
ligne = id_ship*NB_OBSERVATION_UNITAIRE*NB_ASTEROIDES+ men.id*NB_OBSERVATION_UNITAIRE
#print("ligne:", ligne)
obs[ligne] = men.center_y - ship.center_y
# normalisation par rapport à la hauteur de l'ecran
obs[ligne] = np.interp(obs[ligne] , [-SCREEN_HEIGHT, SCREEN_HEIGHT], [-1, 1])
# INFO 2: Vitesse latérale de rapprochement à l'astéroide
ligne += 1
#print("ligne:", ligne)
obs[ligne] = men.change_y - ship.change_y
obs[ligne] = np.interp(obs[ligne], [-VITESSE_VAISSEAUX, VITESSE_VAISSEAUX], [-1, 1])
#print("\nObservation:", obs)
return obs
def render(self, mode="human"):
""" Affichage de l'environnement """
# Création de la fenetre d'affichage
if self.viewer is None:
self.viewer = rendering.Viewer(SCREEN_WIDTH, SCREEN_HEIGHT)
self.viewer.set_bounds(0, SCREEN_WIDTH, 0, SCREEN_HEIGHT)
# Affichage de tous les astéroides
for asteroide in self.asteroides:
jtransform = rendering.Transform(translation=(asteroide.center_x,
asteroide.center_y))
circ = rendering.make_circle(RAYON_ASTEROIDES)
circ.set_color(COULEUR_ASTEROIDES_R, COULEUR_ASTEROIDES_G, COULEUR_ASTEROIDES_B)
circ.add_attr(jtransform)
self.viewer.add_onetime(circ)
# Affichage de tous les vaisseaux
for i in range(len(self.ships)):
vaisseau = self.ships[i]
jtransform = rendering.Transform(
rotation= np.deg2rad(vaisseau.angle),
translation=(vaisseau.center_x, vaisseau.center_y))
ship = rendering.make_capsule(LONGUEUR_VAISSEAU, LARGEUR_VAISSEAU)
ship.set_color(red[i], green[i], blue[i])
ship.add_attr(jtransform)
self.viewer.add_onetime(ship)
return self.viewer.render(return_rgb_array=mode == "rgb_array")
def close(self):
if self.viewer:
self.viewer.close()
self.viewer = None
# --------------------------------
# Bloc de test de l'environnement
# --------------------------------
if __name__ == '__main__':
mode = 'normal'
if mode == 'normal':
env = AffectaMeuteEnv()
ones = 0
count = 0
for episode in range(10):
observation = env.reset()
for t in range(1000):
# Affichage de l'environnement
# env.render()
# Selection d'une action aléatoire
action = env.action_space.sample()
observation, reward, done, info = env.step(action)
# if env.score==4:
# st()
count += 1
if env.score == 1:
ones += 1
#Traitement final
if done:
print("Episode terminé après {:.0f} itérations SCORE: {:.0f}".format(t, env.score))
break
print("Précision = ", ones/(count))
env.close()