Nel video Neural Network Driving un'auto gira su una pista mentre, sotto, un grafo di nodi e archi si accende a ogni curva: è una rete neurale che legge sette distanze e decide sterzo e gas. Nessuno le ha mostrato come si guida. Cento auto con pesi casuali provano, quasi tutte sbattono, le poche che vanno più lontano si riproducono con qualche errore di copia, e dopo qualche decina di generazioni una completa il giro. Questa guida smonta il progetto nei suoi pezzi, dal modello fisico dell'auto al ciclo evolutivo, e ti fa toccare ciascuno: alla fine c'è una pista vera in cui far evolvere una popolazione nel browser.
Il progetto ha tre blocchi e un ciclo che gira a ogni frame: il mondo produce le letture dei sensori, la rete le trasforma in comandi, i comandi muovono l'auto nel mondo. L'addestramento sta fuori da questo ciclo: non guida mai, sceglie soltanto quali pesi mettere nella rete.
Conviene costruire i blocchi in quest'ordine, perché ognuno produce qualcosa di visibile prima del successivo, e il primo è il mondo.
Un gioco ha due ritmi. Il disegno va alla frequenza dello schermo, 60 volte al secondo se va bene, meno se il computer è lento. La fisica non può dipendere da questo: se il passo di integrazione cambiasse con il frame rate, la stessa auto con gli stessi comandi farebbe traiettorie diverse su due computer, e confrontare due auto diventerebbe impossibile. Perciò update(dt) gira con un dt fisso (per esempio 1/60 s) tante volte quante servono, e render() disegna lo stato quando può.
Il vantaggio non è solo la riproducibilità: in addestramento si possono fare cento passi di fisica per ogni frame disegnato, o non disegnare affatto, e la simulazione va cento volte più veloce del tempo reale.
La pista è una linea centrale (una polilinea o una spline chiusa) più una larghezza: spostando ogni punto della centrale verso l'interno e verso l'esterno di metà larghezza si ottengono i due bordi, come sequenze di segmenti. Tutto ciò che il mondo deve sapere sulla pista sta in questi segmenti: contro di essi si calcolano le collisioni e le distanze dei sensori.
Lungo la centrale si mettono dei checkpoint ordinati: sono il metro con cui, nella sezione 4, si misurerà quanto un'auto è arrivata lontano. I cordoli bianco-rossi nelle curve sono solo decorazione.
Non serve una simulazione realistica delle quattro ruote. Il modello cinematico a bicicletta riduce l'auto a due ruote in linea, distanti L (il passo): la posteriore spinge, l'anteriore sterza di un angolo δ. Lo stato è posizione, orientamento (heading) e velocità.
A ogni passo l'auto avanza di v·dt nella direzione dell'heading, e l'heading ruota di (v / L)·tan(δ)·dt: a parità di sterzo il raggio della curva resta lo stesso, ma più vai veloce più in fretta ruoti, e da ferma l'auto non ruota affatto.
Calcolo · un passo del modello a bicicletta
A parità di sterzo δ il raggio resta lo stesso, ma la rotazione per passo cresce con v.
Gas e freno cambiano v; un attrito la riduce ogni passo. Il freno a mano riduce l'aderenza posteriore: l'heading continua a ruotare mentre la velocità si conserva più a lungo nella direzione vecchia, e l'auto derapa lasciando le scie.
Muovi sterzo e velocità: la traiettoria dei prossimi due secondi cambia forma, e il raggio della curva segue una formula che vale la pena conoscere.
Modello cinematico puro: niente massa, niente slittamento. È esattamente l'approssimazione usata nel progetto; il freno a mano si aggiunge sopra come riduzione dell'aderenza.
L'auto è un rettangolo (o, per semplicità, un cerchio) e a ogni passo si controlla se interseca uno dei segmenti del bordo. Se sì, l'auto è eliminata: si ferma dov'è, non consuma più calcolo e la sua corsa è finita. Nella sezione 4 questa è la «morte» che l'evoluzione impara a evitare: un'auto che sbatte presto ha percorso poca pista, e la sua fitness è bassa.
Un dettaglio che semplifica tutto: le auto della popolazione non collidono tra loro. Sono copie della stessa pista sovrapposte, una per auto, non tante auto nella stessa gara.
dt, indipendentemente dal frame rate: stessi comandi, stessa traiettoria, su qualunque computer.Una rete neurale non può ricevere «la pista»: riceve un vettore di numeri. Il modo più semplice di darle degli occhi è il raycasting: dal muso dell'auto partono sette raggi, a −65°, −20°, −10°, 0°, +10°, +20° e +65° rispetto alla direzione di marcia, e per ciascuno si misura la distanza al primo bordo incontrato.
Sette raggi sono pochi ma bastano: i due a ±65° dicono quanto spazio c'è ai lati, quello a 0° quanto è lontana la curva, quelli intermedi da che parte gira.
La distanza si trova con un'intersezione segmento-segmento: il raggio è un segmento dal muso alla portata massima dmax, e lo si confronta con ogni segmento del bordo tenendo l'intersezione più vicina.
Poi si normalizza: la lettura è:
Calcolo · la lettura normalizzata di un raggio
L'ottavo input è la velocità in avanti, anch'essa divisa per la velocità massima. Normalizzare non è un vezzo: se un input valesse 300 e un altro 0,4, i pesi della rete dovrebbero compensare scale diverse, e l'evoluzione farebbe molta più fatica a trovarli.
Sposta e ruota l'auto nel tratto di pista: i raggi si accorciano dove trovano il bordo e le otto letture in basso sono esattamente il vettore che entra nella rete.
Portata massima 160 px. L'intersezione è quella reale tra il raggio e i segmenti dei due bordi; la pista è un tratto curvo costruito con lo stesso metodo della sezione 1 (centrale + larghezza).
Spogliata della mistica, la rete di questo progetto è una funzione: prende il vettore degli otto input della sezione 2 e restituisce quattro numeri, da interpretare come comandi. Non ha memoria (è feed-forward: i segnali vanno solo in avanti) e non impara mentre guida. Tutto ciò che «sa» è scritto nei suoi pesi, e cambiare i pesi cambia il modo di guidare. È per questo che l'addestramento può stare fuori dal ciclo di gioco: il suo unico compito è trovare 82 numeri buoni.
Un neurone fa una somma pesata dei suoi input, aggiunge un bias e passa il risultato in una funzione di attivazione.
L'intuizione che aiuta: un neurone è un piccolo rilevatore. Se i pesi verso i raggi di sinistra sono negativi e grandi, il neurone si accende quando quei raggi leggono valori bassi, cioè quando il muro è vicino a sinistra. Sei neuroni nascosti sono sei rilevatori del genere, e i quattro neuroni di uscita combinano i rilevatori in comandi: «muro vicino a sinistra» e «curva lontana davanti» diventano «sterza un po' a destra, tieni il gas».
Il calcolo procede strato per strato: si moltiplica la matrice dei pesi W per il vettore degli input, si somma il vettore dei bias b, si applica l'attivazione σ elemento per elemento, e il risultato è l'input dello strato successivo.
Per lo strato nascosto e per l'uscita si usa la tanh (tangente iperbolica), che schiaccia qualunque numero in [−1, 1]: è l'intervallo naturale per uno sterzo, dove −0,98 significa «quasi tutto a sinistra» e 0 «dritto». Negli strati nascosti va bene anche la ReLU (Rectified Linear Unit, max(0, x)).
Implementarlo a mano con due cicli e un Float32Array, senza librerie, è la parte più didattica del progetto: la formula sotto diventa una decina di righe. Lo stesso calcolo, con la sigmoide e la ReLU al posto della tanh e una rete che impara con il gradiente invece che per evoluzione, è nel capitolo 7 della guida sul machine learning.
Calcolo · il forward pass della rete 8 → 6 → 4
x ∈ ℝ8 · h ∈ ℝ6 · y ∈ ℝ4
Il video mostra una rete con circa cinque strati nascosti, per effetto scenico. Per girare in pista ne basta uno con sei neuroni, e la scelta ha due vantaggi: ognuno dei sei neuroni resta leggibile nel grafo della UI (User Interface, interfaccia utente), e il numero di parametri resta piccolo. Contarli è un'abitudine da prendere subito:
Calcolo · i parametri della rete
Cioè pesi più bias di ogni strato.
Ottantadue numeri da trovare sono un problema che l'evoluzione risolve in minuti; con cinque strati da trenta neuroni sarebbero migliaia, e servirebbero ore.
I quattro output vanno interpretati. Lo sterzo è continuo: il valore in [−1, 1] moltiplicato per l'angolo massimo. Gas, freno e freno a mano sono attivi se il rispettivo output supera una soglia, per esempio 0. È una convenzione arbitraria, e va scritta una volta sola nel codice che traduce output in comandi, così che rete ed evoluzione non la conoscano.
L'ultimo passo prepara la sezione 4: tutti i pesi e i bias vengono appiattiti in un unico vettore di 82 numeri, il genoma. L'evoluzione lavorerà su quel vettore, senza sapere che dentro c'è una rete; la rete lo rilegge nella sua forma a matrici quando deve calcolare.
Gli otto input sono quelli del laboratorio dei sensori (modificali lì e torna qui). Genera pesi casuali e osserva come cambiano attivazioni e comandi; il grafo è quello del video: archi verdi per pesi positivi, rossi per negativi, spessore proporzionale al modulo, nodi riempiti in base all'attivazione.
Forward pass reale su una rete 8 → 6 → 4 con tanh, pesi iniziali uniformi in [−1, 1]. Con pesi casuali i comandi sono arbitrari: è esattamente lo stato di un'auto della generazione 0.
Il modo standard di addestrare una rete, l'apprendimento supervisionato con la discesa del gradiente, ha bisogno di due cose che qui mancano. Servirebbero esempi di «input → output giusto», e nessuno ha un archivio di letture dei sensori con lo sterzo corretto accanto. E servirebbe un errore derivabile rispetto ai pesi; invece l'unico segnale disponibile è «quanto sei arrivato lontano», arriva alla fine della corsa e dipende dai pesi attraverso centinaia di passi di fisica e collisioni: non c'è un gradiente da seguire.
L'apprendimento per rinforzo (RL, Reinforcement Learning) risolve questo con tecniche come PPO (Proximal Policy Optimization); la neuroevoluzione lo risolve rinunciando al gradiente del tutto, e per un problema con 82 parametri è più semplice e spesso altrettanto efficace.
Come funziona la discesa a cui qui si rinuncia, passo per passo su una retta, lo mostra il capitolo 3 della guida sul machine learning.
Una popolazione di N auto (da 50 a 200) parte con genomi casuali, la generazione 0. Ogni auto guida finché non sbatte o non scade il tempo, e alla fine riceve una fitness: i checkpoint superati più la frazione di strada verso il successivo. È il numero che dice «questa guida meglio di quella». Serve un timeout di mancato progresso, altrimenti un'auto ferma o che gira in tondo resta in gara fino alla scadenza, sprecando calcolo senza fare progressi.
E serve diffidare della propria metrica: se la fitness premiasse la distanza percorsa invece dei checkpoint, un'auto che fa avanti e indietro nel primo rettilineo vincerebbe. È il reward hacking: la popolazione ottimizza esattamente ciò che misuri, non ciò che volevi.
Finita la valutazione, si ordina per fitness e si costruisce la generazione successiva. L'elitismo copia intatti i migliori (per esempio i primi 2 o 3), così il campione non si perde mai. Gli altri posti si riempiono con figli: due genitori scelti con un torneo (pesca k a caso, tieni il migliore) o con la roulette (probabilità proporzionale alla fitness), combinati per crossover uniforme (ogni gene preso a caso da uno dei due) o a un punto (prima parte da A, resto da B), e infine mutati: a ogni gene, con probabilità p, si somma rumore gaussiano di ampiezza (deviazione standard) σ.
La mutazione è la sorgente di novità, il crossover ricombina ciò che già funziona, la selezione decide che cosa sopravvive. Ridurre σ col passare delle generazioni fa esplorare all'inizio e rifinire alla fine.
Due genitori di 82 geni. Scegli il tipo di crossover, la probabilità e l'ampiezza della mutazione, e genera un figlio: ogni cella dice da chi viene e se è stata mutata.
Valuta, ordina, seleziona, incrocia, muta, ripeti. Si ferma quando un'auto completa il giro, quando la fitness migliore non cresce più da molte generazioni, o a un massimo prefissato.
Il laboratorio qui sotto è il progetto intero in miniatura: una pista chiusa costruita come nella sezione 1, auto con il modello a bicicletta, cinque raggi, una rete 6 → 5 → 2 (sterzo e gas) e il ciclo evolutivo completo. Le prime generazioni sbattono quasi subito; guarda la fitness migliore e quella media salire, e la traiettoria del campione allungarsi.
Avanza di una o dieci generazioni. In pista vedi le traiettorie dell'ultima generazione (il campione in evidenza); sotto, la fitness migliore e media per generazione.
Generazione 0: pesi casuali. Premi «Avanza» per valutare la popolazione.
Rete 6 → 5 → 2 (47 parametri), cinque raggi, 900 passi di fisica per auto con timeout di progresso, fitness = checkpoint superati + frazione, torneo di 3, crossover uniforme, elitismo. Seed fisso per generazione: stessi parametri, stessa storia. È una versione ridotta del progetto, non il progetto.
Una rete allenata su una sola pista può aver imparato quella pista, non la guida: una sequenza di riflessi che funziona perché le curve arrivano sempre nello stesso ordine. La prova è metterla su un tracciato mai visto.
Per ottenere un pilota e non un memorizzatore si allena su più piste, o con partenze variate (posizione e orientamento iniziali casuali), e si testa su una pista tenuta fuori. È la stessa distinzione tra training set e test set del machine learning classico, e vale anche qui dove non c'è un dataset. Il campione, infine, si salva: genoma e topologia in JSON (JavaScript Object Notation), da ricaricare per la modalità replay, quella del video, con una sola auto a velocità normale.
Ciò che rende il video memorabile non è l'auto che gira: è il grafo sotto, che si accende a ogni curva. La UI (User Interface, interfaccia utente) ha quel compito: rendere visibile il forward pass. Nodi in colonne, uno strato per colonna; archi tra tutti i nodi di strati adiacenti, con il colore che dice il segno del peso (verde positivo, rosso negativo) e lo spessore il suo modulo; il riempimento di ogni nodo che segue l'attivazione corrente, frame dopo frame; le etichette con nome e valore degli input a sinistra (−65° 0.77, VelF 0.53) e dei comandi a destra (Steering −0.98).
Sono 72 archi e 18 nodi da ridisegnare 60 volte al secondo, e con più raggi o più neuroni diventano centinaia: meglio un canvas dedicato che un SVG (Scalable Vector Graphics) con un elemento per ciascuno.
Accanto, il pannello di addestramento: generazione, auto vive, fitness migliore, il grafico migliore/media, la velocità di simulazione (x1, x10, massima senza disegno), e i controlli per cambiare popolazione e mutazione al volo.
Se l'addestramento vive in un notebook Python (NumPy rende la formula della sezione 3 una riga) e la UI in TypeScript con Canvas, la simulazione rischia di esistere due volte, e un'auto allenata in Python guiderebbe diversamente nel browser.
Le opzioni sono tre. A, la consigliata: mondo, rete ed evoluzione in Python; un piccolo server FastAPI manda lo stato via WebSocket a ogni frame (auto, punti d'impatto dei raggi, pesi e attivazioni, generazione, fitness) e riceve i comandi (tasti, pausa, velocità); il browser disegna e basta.
B: tutto in TypeScript, con il kernel Jupyter di Deno che importa gli stessi moduli della UI; un solo linguaggio, ma senza NumPy. C: fisica riscritta in TypeScript e genoma esportato in JSON (JavaScript Object Notation); UI pubblicabile come pagina statica, ma due fisiche da tenere allineate con test di equivalenza: sconsigliata.
In tutti i casi il contratto WebSocket va fissato prima del codice, perché è il confine oltre il quale Python e UI evolvono separati.
Il repo è pensato per essere ripercorso: la storia git diventa il corso. Sette tappe (pista e auto manuale; sensori; rete casuale; grafo della rete; neuroevoluzione; campione e replay; estensioni) diventano sette coppie di tag: step-NN-start contiene boilerplate, commenti TODO(step-NN) che rimandano alla teoria, test rossi, e la soluzione della tappa precedente, così chi si blocca non resta indietro; step-NN-solution è la tappa risolta, da confrontare con la propria.
Il test è la definizione di «tappa completata», e per questo tutto deve essere deterministico: seed fisso per ogni generatore casuale, passo di fisica fisso.
I tag però non si modificano, e un bug nella tappa 1 andrebbe corretto a mano in tutte le successive. La soluzione è un solo branch solution con la logica nei moduli e blocchi # BEGIN SOLUTION / # END SOLUTION; uno script (tools/make_steps.py) sostituisce i blocchi con stub e rigenera tutti i tag; i marcatori riprendono la convenzione che nbgrader usa per i notebook.
I notebook stessi non contengono logica: importano da sim/ e sperimentano, sono abbinati a file .py con Jupytext (diff leggibili) e ripuliti dagli output con nbstripout; la CI (Continuous Integration) li esegue da zero e verifica che sugli starter i test della tappa corrente falliscano e quelli precedenti passino.
Si parte scrivendo la soluzione completa, una tappa per commit; gli starter si ricavano dopo, per sottrazione.
def forward(self, x: np.ndarray) -> np.ndarray:
# TODO(step-03): calcola lo strato nascosto h = tanh(W1 @ x + b1)
# e poi l'output y = tanh(W2 @ h + b2). Formula: sezione 3.
# Verifica: pytest tests/test_step03.py
raise NotImplementedError
step-NN-start (TODO e test rossi, con la soluzione della tappa precedente) e step-NN-solution.| Pezzo | Che cosa fa | La formula o la regola | L'errore tipico |
|---|---|---|---|
| Game loop | Separa fisica e disegno; accelera l'addestramento | update(dt fisso) × n, poi render() | dt legato al frame rate: traiettorie diverse su computer diversi |
| Modello a bicicletta | Fisica dell'auto con due ruote in linea | θ̇ = (v / L)·tan δ; R = L / tan δ | far curvare l'auto da ferma, o rotazione indipendente dalla velocità |
| Sensori | Sette raggi + velocità → otto numeri in [0, 1] | s = min(d / d_max, 1) | input non normalizzati: pesi che devono compensare scale diverse |
| Rete 8 → 6 → 4 | Funzione pura da input a comandi; 82 parametri | h = tanh(W₁x + b₁), y = tanh(W₂h + b₂) | rete troppo grande per effetto scenico: migliaia di parametri da evolvere |
| Genoma | Pesi e bias appiattiti in un vettore | Float32Array(82) | far conoscere all'evoluzione la struttura della rete |
| Fitness | Quanto l'auto è arrivata lontano | checkpoint + frazione, con timeout di progresso | reward hacking: premiare la distanza e ottenere auto che vanno avanti e indietro |
| Selezione e riproduzione | Costruisce la generazione successiva | élite intatta; torneo; crossover; mutazione gaussiana (p, σ) | σ costante: esplora sempre e non rifinisce mai; niente élite: si perde il campione |
| Generalizzazione | Distingue un pilota da un memorizzatore | più piste o partenze variate; test su pista mai vista | dichiarare «impara a guidare» avendo visto una sola pista |
| UI | Rende visibile il forward pass | colore = segno, spessore = modulo, riempimento = attivazione | SVG con centinaia di elementi a 60 fps |
| Repo a tappe | La storia git è il corso | branch solution + marcatori + script che rigenera i tag; test per tappa; seed fissi | tag modificati a mano; notebook con stato nascosto; due fisiche |