Flappy Bot

Last modified: 2022-05-20 18:37:13 - github

Il progetto

L'obiettivo di questo progetto è di realizzare un programma in Python capace di imparare autonomamente a giocare a Flappy Bird utilizzando una forma semplice di neuroevoluzione simile a quella usata nel progetto Percorso a Ostacoli, ma non identica, in quanto questa si basa su un approccio più simile ai Greedy Algorithms.

L'idea del gioco è abbastanza semplice: il quadratino rosso (nella versione originale un uccellino), deve passare attraverso le aperture tra coppie di tubi verdi che si avvicinano posizionate a altezze variabili e sopravvivere il più a lungo possibile. Il quadrato è soggetto alla forza di gravità, e come l'uccellino può darsi una spinta verso l'alto come con un battito d'ali.

Uno dei primi tentativi dell'algoritmo:

Il gioco

Per creare il gioco ho usato il modulo tkinter, con cui è possibile gestire agilmente gli elementi grafici.

I tubi vengono generati ad altezze variabili sul bordo destro della finestra e si avvicinano con una determinata velocità verso sinistra, mentre il quadratino parte a metà altezza della finestra ed è costantemente soggetto alla forza di gravità. Il quadratino muore quando o si scontra con i tubi o tocca il terreno, mentre non muore se raggiunge il limite superiore della finestra, così come nel gioco originale.

Il gioco è completamente gestito dalla classe Game, che contiene diverse funzioni, tra cui:

La funzione move_bird:

def move_bird(self):
    if self.jump_triggered == True and self.bird_speed_y < 0: #inizio nuovo salto
        self.time_count=0
        self.bird_speed_y = 10           #cambio la velocità verticale con una positiva
        self.bird_speed_y0 = self.bird_speed_y
        self.bird_y0 = self.bird_y       #posizione verticale iniziale
        self.jump_triggered = False
    else: #è già in un salto
        self.jump_triggered = False
    
    self.bird_speed_y -= 0.4            #considero accelerazione verso il basso di 0.8m/s^2
    self.new_y = self.bird_y0 - self.bird_speed_y0 * self.time_count + 0.2 * self.time_count * self.time_count

    if self.new_y <= 90:
        self.new_y = 90

    self.movement_y = self.new_y - self.bird_y
    self.w.move(self.bird, 0, self.movement_y)
    self.bird_y = self.new_y
    
    self.time_count+=1
            

Imparare a giocare

Una volta pronto il gioco, il programma deve imparare a giocare. L'algoritmo avrà accesso a due informazioni: la distanza orizzontale che separa il quadratino dai tubi successivi e la distanza verticale dall'apertura. Usando queste informazioni, l'algoritmo dovrà decidere se battere le ali oppure no. Queste informazioni verranno interpretate da reti neurali strutturate in questo modo:

L'algoritmo che ho scritto è una sorta di Greedy Algorithm applicato ai pesi e ai bias delle reti neurali considerate. Il programma inizia a giocare con una rete neurale inizializzata randomicamente, la utilizza su un numero fissato di partite, ad esempio 100, e le assegna come punteggio la media del tempo sopravvissuto in queste partite.

Il programma, dopo aver testato la prima rete, ne crea una copia leggermente mutata e esegue il medesimo test sullo stesso numero di partite. Se il punteggio di questa rete è migliore di quello precedente, la nuova rete neurale prende il posto della prima e verrà usata per le successive mutazioni.

I metodi di Game che gestiscono l'approccio greedy del programma:

def check_score(self):
    if self.num_move % 1 == 0:
        txt_for_print = "G: " + str(self.num_move) + " BEST: " + str(round((self.best_score), 4)) + " | " + str(round((self.last_score), 4))
        print(txt_for_print)

    if self.last_score >= self.best_score:
        self.best_score = self.last_score
        self.accept_move()
        self.propose_move()
    
    else:
        self.move_attempt += 1
        self.propose_move()


            
def accept_move(self):
    self.move_attempt = 0
    self.config.set_weights_as(self.config_test)


def propose_move(self):
    self.num_move += 1

    mutation_rate = 0.02
    self.config_test.set_mutation_of(self.config, rate=mutation_rate)
            

Mutazione e selezione

Le reti neurali sono gestite da una classe apposita NeuralNetwork che, oltre a gestire i Layer, i Neuroni e il calcolo degli output, contiene alcuni metodi dedicati sia all'approccio Greedy che all'approccio degli Algoritmi Genetici. Per esempio:

La funzione set_mutation_of():

def set_mutation_of(self, other, rate=0.10):
    if not isinstance(other, NeuralNetwork) or not np.all(self.layers_sizes == other.layers_sizes):
        raise Exception("This works only with another NN of same size")
    
    self.set_weights_as(other)
    
    for l in range(self.n_layers-1):
        random_ws = np.random.rand(self.layers_sizes[l], self.layers_sizes[l+1]) * 2 - 1
        mask_w = np.random.rand(self.layers_sizes[l], self.layers_sizes[l+1]) < rate
        self.weights[l][mask_w] = random_ws[mask_w]

        random_bs = np.random.rand(self.layers_sizes[l+1]) * 2 - 1
        mask_b = np.random.rand(self.layers_sizes[l+1]) < rate
        self.biases[l][mask_b] = random_bs[mask_b]
            

Esito

La realizzazione di questo progetto è durata, nel complesso, quasi due anni. A causa di vari cambi di approccio, continue interruzioni, bug individuati solo dopo mesi e precedenze date ad altri progetti. Finalmente posso essere soddisfatto: nella sua versione finale, il programma riesce a migliorare drasticamente e rapidamente il proprio punteggio. Alcune delle reti neurali trovate potrebbero potenzialmente sopravvivere all'infinito. Di seguito alcune statistiche.

Il miglioramento dei punteggi medi, minimi e massimi nelle partite di test in un breve allenamento di esempio:

Una rete neurale che ha raggiunto punteggi estremamente alti (circa 500 tubi superati) trovata in sole 74 iterazioni:




🌙 Modalità notte