Pubblicato da Abe Haskins (Twitter, Github)
In questo articolo parleremo dell'utilizzo di Unity3D e TensorFlow per addestrare l'intelligenza artificiale a svolgere un semplice compito in-game: fare canestro. Il codice sorgente completo è disponibile su Github ma se hai delle domande, puoi contattarmi su Twitter.
Esiste un gioco in cui i giocatori hanno un obiettivo principale: mettere la palla nel canestro. Non sembra così difficile, ma quando il sangue pompa nelle vene, il cuore va a mille e la folla fa il tifo, beh, allora tirare bene è tutta un'altra storia. Sto parlando del classico gioco americano della pallacanestro? No, non ne ho mai sentito parlare. Mi riferisco al classico videogioco arcade NBA Jam di Midway.
Se hai già giocato a NBA Jam o a qualsiasi gioco a cui si ispira (inclusa la lega NBA vera e propria, che credo sia arrivata dopo NBA Jam), allora sai che la meccanica per tirare una palla dal punto di vista del giocatore è abbastanza semplice: tenere premuto e rilasciare il pulsante di tiro con un tempismo perfetto. Ti sei mai chiesto come si svolga questo tiro dal punto di vista del gioco? Come venga scelto l'arco effettuato dalla palla? Quanto sia difficile tirare la palla? Come faccia il computer a sapere da quale angolo tirare?
Se sei una persona intelligente, portata per la matematica, potresti trovare le risposte giuste usando semplicemente carta e penna, tuttavia l'autore di questo post è stato rimandato in algebra alle medie, quindi per lui le risposte da "cervellone" sono fuori questione. Dovrà risolvere il problema in modo diverso.
Invece di prendere la via più semplice, veloce ed efficiente per i calcoli su come eseguire un tiro, andremo a vedere quanto sia profonda la tana del coniglio, impareremo un po' di semplice TensorFlow e proveremo a fare questi benedetti canestri.
Ecco cosa ci serve per iniziare questo progetto.
Se non sei un esperto di queste tecnologie, non ti preoccupare (neanch'io lo sono!). Farò del mio meglio per spiegare come le diverse parti s'integrano tra di loro. Uno degli svantaggi di usare tante tecnologie diverse è il non poter spiegare tutto nel dettaglio, ma tenterò di offrire risorse educative supplementari!
Non cercherò di ricreare questo progetto passo per passo, quindi suggerisco di utilizzare il codice sorgente disponibile su Github e seguire la mia spiegazione.
Nota: è necessario scaricare un'importazione del pacchetto di asset Unity ML-Agents per Tensorflow da utilizzare in C#. Se ricevi il messaggio di errore che Tensorflow non viene rilevato in Unity, assicurati di seguire le istruzioni nei documenti sull'installazione di Unity per TensorflowSharp.
Per farla semplice, il risultato che ci interessa sarà incredibilmente semplice. Vogliamo sapere con quale forza Y dobbiamo eseguire il tiro se il giocatore è a distanza X dal canestro. Ecco fatto! Non proveremo a centrare la palla o niente del genere. Stiamo solo cercando di capire con quale forza deve lanciare la palla per fare canestro.
Se sei interessato a come realizzare AI più complesse in Unity, consulta ML-Agents, un progetto molto più completo di Unity. I metodi che spiegherò sono ideati per essere semplici, accessibili e non necessariamente indicativi delle best practice (sto imparando anch'io!).
La mia conoscenza di TensorFlow, del machine learning e della matematica è limitata. Quindi prendi tutto con relatività e ricorda che siamo qui per divertirci.
Abbiamo già parlato della parte importante del nostro obiettivo: fare canestro. Per tirare la palla nel canestro, ci vuole un canestro e... una palla. Ed è qui che entra in gioco Unity.
Se non conosci bene Unity, sappi che è un motore di gioco che consente di creare giochi 2D e 3D per tutte le piattaforme. Include built in fisica, modellazione 3D di base e un'eccezionale runtime di scripting Mono che permette di scrivere il gioco in C#.
Non sono un artista, ma ho preso dei blocchi e messo insieme questa scena.
Ovviamente il blocco rosso è il nostro giocatore. I canestri sono stati impostati con dei trigger invisibili per rilevare quando un oggetto (la palla) passa attraverso il canestro.
Nell'editor di Unity puoi vedere i trigger invisibili delineati in verde. Noterai che ce ne sono due perché in questo modo possiamo assicurarci di contare solo i canestri in cui la palla cade dall'alto verso il basso.
Osservando il metodo OnTriggerEnter in /Assets/BallController.cs (lo script che di ogni istanza della palla), noterai che questi due trigger vengono usati insieme.
private void OnTriggerEnter(Collider other) { if (other.name == "TriggerTop") { hasTriggeredTop = true; } else if (other.name == "TriggerBottom") { if (hasTriggeredTop && !hasBeenScored) { GetComponent().material = MaterialBallScored; Debug.Log(String.Format("{0}, {1}, {2}", SuccessCount++, Distance, Force.y)); } hasBeenScored = true; } }
Questa funzione esegue alcune operazioni. In primo luogo, assicura che venga colpito sia il trigger in alto che quello in basso, poi modifica il materiale della palla per mostrare visualmente che è entrata nel canestro e infine registra le due variabili chiave che ci interessano, la distanza e la forza y.
Apri /Assets/BallSpawnerController.cs. Questo è uno script situato nel tiratore che lancia le palle e tenta di fare canestro. Dai un'occhiata a questo snippet nella parte finale del metodo DoShoot().
var ball = Instantiate(PrefabBall, transform.position, Quaternion.identity); var bc = ball.GetComponent(); bc.Force = new Vector3( dir.x * arch * closeness, force, dir.y * arch * closeness ); bc.Distance = dist;
Il codice crea la nuova istanza di una palla, quindi imposta la forza con cui la tireremo e la distanza dall'obiettivo (in modo che possiamo registrarlo più facilmente in seguito, come indicato nell'ultimo snippet).
Se /Assets/BallController.cs è ancora aperto, puoi vedere il metodo Start(). Questo codice viene richiamato quando creiamo una nuova palla.
void Start () { var scaledForce = Vector3.Scale(Scaler, Force); GetComponent().AddForce(scaledForce); StartCoroutine(DoDespawn(30)); }
In altre parole, creiamo una nuova palla, le diamo un po' di forza e poi la distruggiamo automaticamente dopo 30 secondi perché altrimenti avremmo troppe palle da gestire.
Proviamo a eseguire tutto questo e vediamo come si comporta il nostro tiratore d'eccezione. Premi il pulsante ▶️ (Play) nell'editor di Unity per vedere cosa succede.
Il nostro giocatore, che chiameremo affettuosamente "Red", è quasi pronto per affrontare Steph Curry.
E allora perché è una tale schiappa? La risposta si trova nella riga in Assets/BallController.cs che indica la forza fluttuante = 0,2 f. Questa riga afferma che ogni tiro deve essere esattamente lo stesso. Unity prende il concetto di "esattamente lo stesso" letteralmente. Lo stesso oggetto, con la stessa forza, ripetuto all'infinito che rimbalza sempre allo stesso modo. OK.
Ovviamente non è ciò che vogliamo. Non impareremo mai a fare canestro come Lebron se non proviamo nulla di nuovo, quindi introduciamo qualche altro elemento.
Per introdurre del rumore casuale basta attribuire alla forza un valore casuale.
float force = Random.Range(0f, 1f);
Questo varia i tiri così possiamo vedere cosa succede quando facciamo canestro, anche se ci vuole tempo per fare centro.
Red è un po' stupido, può fare canestro ma solo per caso. Va bene però, perché ogni tiro nel canestro è un punto dati che possiamo usare, come vedremo fra poco.
Ma non vogliamo poter tirare solo da una posizione. Vogliamo che Red faccia canestro (quando è fortunato abbastanza) da qualsiasi distanza. In Assets/BallSpawnController.cs, trova queste righe ed elimina il commento MoveToRandomDistance().
yield return new WaitForSeconds(0.3f); // MoveToRandomDistance();
Se lo eseguiamo, vedremo Red saltare di contentezza in campo dopo ogni tiro.
Questa combinazione di movimenti casuali e forze casuali produce un risultato fantastico: i dati. Nella console Unity vedrai i dati registrati per ogni tiro ogni volta che viene fatto canestro.
Ogni tiro riuscito registra il numero di tiri riusciti fino a quel momento, la distanza dal canestro e la forza richiesta per fare canestro. È un processo un po' lento, bisogna ammetterlo. Torna al punto dove abbiamo aggiunto la chiamata MoveToRandomDistance() e modifica 0,3 f (ritardo di 300 millisecondi per tiro) a 0,05 f (ritardo di 50 millisecondi).
yield return new WaitForSeconds(0.05f); MoveToRandomDistance();
Premi Play e guarda quante palle entrano nel canestro.
Ecco, questo è un buon regime di addestramento! Puoi notare dal contatore sullo sfondo che il 6,4% dei tiri va in porto. Certo, non è Steph Curry. Ma parlando di addestramento, stiamo davvero imparando qualcosa da questo? Dov'è TensorFlow? Perché ci interessa? Beh, questo è il passo successivo. Ora siamo pronti a utilizzare i dati ottenuti da Unity e creare un modello per prevedere la forza richiesta.
Controllo dei dati in Fogli Google
Prima di immergerci in TensorFlow, vorrei dare un'occhiata ai dati, quindi lasciamo Unity fino a quando Red non avrà fatto circa 50 canestri. Nella directory principale del progetto Unity puoi vedere il nuovo file successful_shots.csv. Questa è la raw dump di Unity di ogni canestro che abbiamo fatto! Ho impostato Unity in modo che la esporti per analizzarla facilmente in un foglio di calcolo.
Il file .csv include solo tre righe: indice, distanza e forza. Ho importato questo file in Fogli Google e creato un Scatterplot con una trendline che ci permetterà di vedere la distribuzione dei dati.
Wow! Guarda. Davvero, guarda qui! Lo ammetto, all'inizio neanch'io ero sicuro di cosa volesse dire, quindi analizziamo il grafico più nel dettaglio.
Mostra una serie di punti lungo l'asse Y in base alla forza del tiro e lungo l'asse X in base alla distanza da cui è stato effettuato il tiro. Osserviamo una correlazione molto chiara tra la forza richiesta e la distanza da cui viene effettuato il tiro (con alcune eccezioni casuali di rimbalzi strani).
Ciò si traduce in "TensorFlow sarà perfetto per eseguire questo compito".
Sebbene questo caso d'uso sia semplice, uno dei grandi vantaggi offerti da TensorFlow è che consente di creare un modello più complesso, se lo volessimo, usando un codice simile. Ad esempio, in un gioco completo potremmo includere funzionalità come la posizione degli altri giocatori e le statistiche sulla frequenza con cui hanno bloccato i tiri in passato, per decidere se il nostro giocatore debba tirare o passare la palla.
Creazione del modello TensorFlow.js
Apri il file tsjs/index.js nel tuo editor preferito. Questo file non è correlato a Unity, è solo uno script per addestrare il modello in base ai dati in successful_shots.csv.
Ecco l'intero metodo che addestra e salva il nostro modello.
(async () => { /* Load our csv file and get it into a properly shaped array of pairs likes... [ [distanceA, forceB], [distanceB, forceB], ... ] */ var pairs = getPairsFromCSV(); console.log(pairs); /* Train the model using the data. */ var model = tf.sequential(); model.add(tf.layers.dense({units: 1, inputShape: [1]})); model.compile({loss: 'meanSquaredError', optimizer: 'sgd'}); const xs = tf.tensor1d(pairs.map((p) => p[0] / 100)); const ys = tf.tensor1d(pairs.map((p) => p[1])); console.log(`Training ${pairs.length}...`); await model.fit(xs, ys, {epochs: 100}); await model.save("file://../Assets/shots_model"); })();
Come noterai, non c'è molto da fare. Basta caricare i dati dal file.csv e creare una serie di punti X e Y (molto simile al Foglio Google precedente!). Chiediamo al modello di "adattarsi" a questi dati. Dopodiché lo salviamo per utilizzarlo successivamente.
Purtroppo TensorFlowSharp non si aspetta un modello nel formato in cui Tensorflow.js può salvarlo. Quindi dobbiamo fare qualche magia per eseguire il pull del modello in Unity. A questo scopo ho incluso alcune utilities. Il processo generale consiste nel tradurre il modello dal formato TensorFlow.js a quello Keras da cui possiamo creare un checkpoint per unirlo alla definizione Protobuf Graph per ottenere una definizione Frozen Graph che possiamo spostare in Unity.
Se invece vuoi giocare subito, puoi saltare questi passaggi ed eseguire solo tsjs/build.sh. Se non ci sono problemi, effettuerà automaticamente tutte le operazioni e includerà il modello bloccato in Unity.
All'interno di Unity, possiamo sfruttare GetForceFromTensorFlow() in Assets/BallSpawnController.cs per vedere con cosa interagisce il modello.
float GetForceFromTensorFlow(float distance) { var runner = session.GetRunner (); runner.AddInput ( graph["shots_input"][0], new float[1,1]{{distance}} ); runner.Fetch (graph ["shots/BiasAdd"] [0]); float[,] recurrent_tensor = runner.Run () [0].GetValue () as float[,]; var force = recurrent_tensor[0, 0] / 10; Debug.Log(String.Format("{0}, {1}", distance, force)); return force; }
Quando crei la definizione del grafico, stai definendo un sistema complesso a più passaggi. Nel nostro caso abbiamo definito il modello come singolo livello denso (con un livello di input implicito), ossia che prende un singolo input e restituisce un output.
Quando utilizzi model.predict in TensorFlow.js, questo fornirà automaticamente il tuo input al corretto nodo del grafico e fornirà l'output dal nodo corretto dopo aver completato il calcolo. Tuttavia TensorFlowSharp funziona in modo diverso e ci richiede di interagire direttamente con i nodi del grafico tramite il loro nome.
Quindi si tratta di ottenere i dati di input nel formato previsto dal grafico e inviare l'output a Red.
Usando il sistema appena descritto, ho creato alcune varianti del modello. Ecco Red che tira usando un modello addestrato su solo 500 canestri centrati.
Riesce a fare canestro 10 volte di più! Cosa succederebbe se addestrassimo Red per un paio d'ore e facessimo 10.000 o 100.000 canestri? Il nostro gioco migliorerebbe sicuramente! Ma lascio a te questa parte.
Ti consiglio di andare a vedere il codice sorgente su Github e di inviarmi un tweet se riesci a superare una percentuale di successo del 60% (spoiler: superare il 60% è decisamente possibile, basta tornare indietro alla prima gif per vedere come puoi addestrare bene Red!).