Tensorflow alapozó 10.
Neurális hálózatok tenyésztése genetikus algoritmussal PyGAD és OpenAI Gym használatával
Hogy kontextusba helyezzem a genetikus algoritmusokat, ismételjük kicsit át, hogy hogyan működik a gradient descent és a backpropagation, ami a neurális hálók tanításának általános módszere. Az erről írt cikkemet itt tudjátok elolvasni:
Dióhéjban annyi a lényeg, hogy a gradient descent (gradiens mászás?) segítségével egy függvény minimumát próbáljuk megtalálni. Egy Andrej Karpathytól vett hasonlattal élve olyan ez mintha bekötött szemmel le akarnánk jönni egy hegy véletlenszerűen választott pontjáról. Ilyen esetben a legcélravezetőbb megoldás ha körbetapogatjuk a környezetünket és mindig arra megyünk amerre legjobban lejt a hegy, így jó eséllyel lejuthatunk róla. Azt, hogy egy függvény mennyire “lejt” egy adott pontban, a parciális derivált adja meg. Nincs más dolgunk tehát, mint kiszámolni az adott pontban vett parciális deriváltat, majd lépni egyet az adott irányba mindaddig amíg el nem érjük a minimumot. Ez persze mind szép és jó, de mi köze van ennek a gépi tanuláshoz?
Vegyünk egy Y=f(X,W) függvényt, ahol X a függvény bemeneteit jelöli, W az állítható paramétereket, Y pedig a kimenetet. A gépi tanulás lényege, hogy megtaláljuk azokat a W paramétereket, amelyek esetén az X bemenetekre adott Y kimenet a legközelebb van az Ye elvárt kimenetekhez. Ehhez definiálunk egy L(Y,Ye) hibafüggvényt, ami megadja, hogy az f függvény által adott Y kimenet mennyire van távol az Ye elvárt kimenettől. A cél, hogy a teljes hibát, vagyis a sum(L(f(Xi,W),Yei)) függvény minimumát megkeressük, ahol Xi és Yei az adott minta be és kimenetei (pl. a bemenet egy kép pixeleinek RGB komponensei, a kimenet pedig az, hogy a kép cica vagy nem cica). Így már értelmet nyer a fenti metódus, hiszen a “hegy” minden pontjához a W paraméterek adott konfigurációja tartozik, az adott pont magassága pedig a hibától függ. Azt a W konfigurációt keressük ahol a magasság minimális, azaz le akarunk jönni a hegyről. A bevezetőben említett backpropagation (hiba visszaterjesztés?) pedig egy a deriválás láncszabályára épülő metódus a parciális deriváltak számításához.
Aki figyelmesen olvasta a fentieket, az észrevehette, hogy egyszer sem említettem a neurális hálózatokat. Végig csak deriválható függvényekről beszéltem. Fontos kiemelni, hogy a gradient descent egy általános módszer, ami minden olyan probléma esetén alkalmazható, ahol a probléma megoldására fel tudunk írni parciálisan deriválható függvényt. Valójában a gépi tanulás programkönyvtárak (amilyen a Tensorflow és a PyTorch) elsődleges képessége nem a neurális hálózatok futtatása, hanem tenzorműveletek hatékony végrehajtása (pl. GPU segítségével) valamint a deriváltak számítása (Tensorflow esetén a GradientTape-el, PyTorch esetén pedig az autograd rendszernek köszönhetően). A neurális hálók futtatása már egy következő réteg a tenzoros alap felett. Minél mélyebben beleássa magát az ember a neurális hálók témakörébe, annál gyakrabban találkozik olyan megoldásokkal, ahol igazából már nem csak neuronok hálózatáról beszélünk. Az attention mechanizmusok például külön mátrix szorzások segítségével valósulnak meg, vagy a mostanában divatos kapszula hálózatok esetén ugyan vannak konvolúciós rétegek, de ezen kívűl mátrix szorzások és egy egész speciális tanulási módszernek köszönhetően áll össze az eredmény. Ezek az új megoldások tehát már sokkal többek mint neuronok előre csatolt hálózata, sokkal jobb inkább általánosan, deriválható tenzorfügvényekként felfognunk őket.
Ahogyan a fentiekből látszik, a gradient descent (és az ezt megvalósító backpropagation) alapvető fontosságú a gépi tanulás szempontjából. Enélkül a módszer nélkül aligha tartana ott a gépi tanulás ahol most tart. Van azonban a gradient descentnek pár alapvető korlátja, amiért érdemes lehet más módszereket is megvizsgálnunk. Az egyik legalapvetőbb korlát, hogy amennyiben a problémára nem tudunk parciálisan deriválható függvényt felírni, úgy nem alkalmazható rá a gradient descent. A másik probléma, hogy ha a hibafüggvény rendelkezik lokális minimumokkal, ugy ezekbe beleragadhat az algoritmus. Olyan ez mintha hegyről lemászás közben valahol középen egy gödörbe jutnánk. Itt akármerre is tapogatózunk, minden irányban csak felfelé haladhatunk, így azt gondolhatjuk, hogy lejutottunk a hegyről. Ilyen problémák kiküszöbölésére jöhetnek jól az evolúciós algoritmusok amik kevésbé hatékonyak ugyan, de cserébe sokkal általánosabbak, használhatóak nem deriválható függvények esetén is és immunisak a lokális minimumokra.
Amikor evolúciós algoritmusok segítségével keressük a megoldást, a “manónk” nem csak hogy teljesen vak, de még tapogatózni sem tud (nem ismerjük a deriváltakat), cserébe viszont egyetlen “manó” helyett egy egész populációt használunk, akiket a hibafüggvény véletlenszerű helyeire “dobálunk le”. Minden manóról csak annyit tudunk, hogy mennyire magasan van. Minél alacsonyabban van egy manó, annál életképesebb. Ezt követően megöljük a manók bizonyos százalékát (szelekció). Minél életképesebb egy manó, annál kisebb eséllyel fog meghalni, így annál nagyobb eséllyel kerül át a következő populációba. Ezt követően a megmaradt populációt használva újra feldúsítjuk a manók számát. A genetikus algoritmusok esetén ehhez genetikus operátorokat használunk. Ezek jellemzően a mutáció, amikor egy vagy több ponton véletlenszerűen változtatunk valamit a géneken, vagy a keresztezés, amikor két vagy több szülő génjeit véletlenszerű ponton elvágva felcseréljük így kapva új utódokat. Jól láthatóan a genetikus algoritmusok használata a biológiából ellesett módszerek használatát jelenti. Az egyes populációk egyedei a folyamat során egyre inkább a minimum köré csoportosulnak, majd elég iteráció után meg is találják azt. Nézzük hogy néz ki ez a gyakorlatban…
A genetikus algoritmusok használata esetén mindig szükség van egy DNS-re ami jellemzően egy számlista (egy dimenziós tenzor), valamint egy rátermettségi (fitness) függvényre ami megmondja, hogy az adott DNS-el rendelkező egyed mennyire életképes. Ha ez a két dolog megvan, már indulhat is a tenyésztés. Ha neurális hálózatokat akarunk tenyészteni, akkor a DNS célszerűen a neurális háló súlyainak listája, a rátermettségi függvény pedig a hálózat teljes hibájának reciproka (minél kisebb a hiba, annál rátermettebb az egyed). A cél, hogy megtaláljuk a legrátermettebb egyedet, ami a legjobb DNS-el (legjobb súly kombinációval) rendelkezik.
Ennyi elmélet után lássuk a kódot. Olvastam pár tanulmányt arról, hogy a genetikus algoritmusok a backpropagation-höz mérhető sebességgel, vagy bizonyos esetekben annál gyorsabban (!) megtalálják a megoldást megerősítéses tanulás (reinforcement learning) feladatok esetén, így gondoltam előveszem a reinforcement learningről szóló 4. rész kódját és azt fogom átírni úgy, hogy genetikus algoritmust használjon. Aki esetleg nem olvasta a 4. részt, az itt megteheti:
Szokásos módon a kód most is kipróbálható Google Colabban, így akinek kedve van egy külön böngészőfülön megnyithatja a kódot és olvasás közben követheti annak futását.
A hálózatok tenyésztéséhez a PyGAD nevű programkönyvtárat használjuk, így mindenek előtt ezt kell telepítenünk, valamint a Tensorflow-t és a Gym-et, amit Colabban már eleve telepítve kapunk.
Maga a kód láthatóan nem túl bonyolult. A 37. sortól inicializáljuk a Gym CartPole környezetét, a 41. sortól pedig definiáljuk ugyanazt az egyszerű neurális hálózatot, amit a 4. részben is használtunk. A 49. sorban felparaméterezzük a genetikus algoritmusunkat, amit a 60. sorban le is futtatunk. A paraméterek jelentése megtalálható a dokumentációban, így csak azokra térnék ki, amik a konkrét működés szempontjából fontosak.
Maga a PyGAD egy teljesen általános genetikus algoritmusok futtatására képes rendszer. Ennek a kiterjesztése a KerasGA, ami az általános motor Tensorflow (Keras) neurális hálókon történő futtatását segíti. A 47. sorban létrehozott KerasGA objektum ennek a kiterjesztésnek a része és arra szolgál, hogy a paraméterként átadott modellből a második paraméterben megadott számosságú populációt hozzon létre. Mivel a hálózatunk 386 állítható paraméterrel rendelkezik, ezért a DNS-ünk itt 386 elemből fog állni. A populáció mérete 10 egyed, így a kezdő populációnk egy 10x386 elemű mátrix lesz. Ezt adjuk át az 51. sorban az initial_population paraméterben.
Az egész algoritmus lelke az 52. sorban átadott fitness_func függvény, amit a 9. sortól definiálunk. Ez a függvény adja meg az adott DNS-hez (ez esetben az adott súly konfigurációhoz), hogy mennyire jó. A függvény a solution paraméterben kapja meg a DNS-t, amit a 12-es sorban egy KerasGA függvénnyel alakítunk vissza súly listává, amit a 13. sorban be is állítunk a modellnek. Mivel reinforcement learningről van szó, ezért a modellt úgy teszteljük, hogy lejátszunk vele egy teljes játékot, majd eredményként az összegyűjtött rewardot adjuk vissza. Minél több rewardot sikerült összegyűjteni a játék során, annál sikeresebb a hálózat, így az összes reward pont megfelel a rátermettségi függvény visszatérési értékének.
Dióhéjban ennyi az egész. A kód végén még rajzolunk egy grafikont az egészből, illetve elmentjük a súlyokat. A Colab jegyzetfüzet végén található még egy minimális kód, ami a mentett súlyok alapján le tud játszani egy játékot, így visszaellenőrizhetjük, hogy milyen jó lett a neurális hálónk.
Mivel az evolúciós algoritmusok esetén nagy szerep jut a véletlennek, ezért több futtatás esetén egészen eltérő eredményeket kaphatunk. Én azt tapasztaltam, hogy az esetek többségében sokkal hamarabb és sokkal jobb eredményeket sikerült kihozni, mint Q learninggel. Ebből persze hiba lenne messzemenő következtetéseket levonni, de annyi talán ebből is látszik, hogy a genetikus algoritmusoknak van létjogosultásága, sőt, a fentebb említettek miatt vannak esetek, ahol nincs is nagyon más választásunk (pl. mert nem írható a problémára deriválható függvény). Emellett a genetikus algoritmusok rendelkeznek pár előnyös tulajdonsággal amiért érdemes lehet rájuk odafigyalni. Az egyik ilyen tulajdonság, hogy a populáció méretét a végtelenségig növelehtjük mivel az egy populációban lévő egyedek rátermettségi vizsgálata párhuzamosan elvégezhető. A gradient descent nem ennyire jól párhuzamosítható hiszen az egyes lépések egymásra épülnek, így elképzelhetőnek tartom, hogy megfelelő párhuzamos számítási kapacitás mellett a genetikus algoritmusok képesek sebességben túlteljesíteni a gradient descent-et.
A másik dolog ami miatt nagyon izgalmasak a genetikus algoritmusok (és bármilyen gradiens mentes megoldás), hogy sokkal közelebb állhatnak ahhoz ahogyan a biológiai agy is működik (biologically plausible). El tudok például képzelni egy olyan modellt az emberi agy működésére ahol az agy fő szerkezetét a “makroevolúció” alakítja ki, de ahol ez az egyed szintjén nem áll meg és “mikroevolúció” zajlik az érzékelés és a gondolatok szintjén, így alakítva ki a tudatos gondolkodást. De ez persze csak az én saját bejáratú elméletem…
Bárhogy is legyen, a genetikus algoritmusok hasznos eszközök lehetnek a gépi tanulás eszköztárában, vagy akár önálló problémamegoldó eszközként. Azt sem tartom kizártnak, hogy a jövőben (biológiai számítógépek, stb.) meghatározó területévé válnak majd az informatikának. Minden esetre érdemes ismerni a területet és odafigyelni rá…
Ha tetszett az írás, olvasd el az előző részeket is: