Tensorflow alapozó 9: PyTorch alapozó
A Google által fejlesztett Tensorflow mellett a másik nagy, ugyancsak Python alapú gépi tanulás rendszer a Facebook által fejlesztett PyTorch. Ha csak a GitHub csillagok számát nézzük, a Tensorflow népszerűbbnek mondható, de PyTorch-ot használ például a Tesla vagy az OpenAI. Felmerülhet a kérdés, hogy mit keres a “konkurens” PyTorch ismertetője egy alapvetően Tensorflowról szóló cikksorozatban? Nos, a két rendszer nagyon hasonló, így akinek sikerült megérteni a Tensorflow logikáját, az nagyon könnyen beletanulhat a PyTorch használatába is. Ez pedig nagyon hasznos tudás, hiszen sokszor lehet szükség arra, hogy értelmezzünk egy PyTorch-al készült kódot, vagy akár mi fejlesszünk PyTorch-ban (pl.: hozzá akarunk járulni egy PyTorch-ban készült projekthez, vagy egyszerűen csak jobban megkedveljük ezt a rendszert, mint a Tensorflow-t). Ez a cikk akár tekinthető különálló PyTorch bevezetőnek is, de mivel a Tensorflow esetén már megtanult dolgokra fogok hivatkozni, ezért elengedhetetlen hozzá legalább az első 2 rész elolvasása:
Ennyi bevezető után csapjunk is a lovak közé és lássuk milyen alapelemekből épül fel a PyTorch.
Tenzorok
A Tensorflowhoz hasonlóan a PyTorch is tenzor műveletek sorozatára képezi le a neurális hálózatokat, így ennek a rendszernek is a tenzorok képezik az alapját. A Tensorflowhoz hasonlóan létrehozhatunk új tenzorokat valamilyen kezdő értékkel inicializálva, de konvertálhatunk NumPy tömböket is oda-vissza tenzorokká. A PyTorchban is elérhető minden alap tenzor művelet akár operátor overloading-on keresztül (két tenzor közti szorzás tenzor szorzást jelent, stb.) akár direktben a függvényt hívva. A tenzor létrehozásakor, vagy később a .to() függvény hívásával megadhatjuk, hogy a tenzor a CPU-t vagy a GPU-t használja. Az utóbbi esetben minden tenzor művelet a GPU-n fog futni, ami mint tudjuk sokkal gyorsabb végrehajtást eredményez. Nagy vonalakban talán ennyi elég is a tenzorokról, mivel minden szempontból úgy működnek, mint a Tensorflow tenzorai. (Akit részletesebben érdekel a dolog, olvassa el a PyTorch tutorial tenzorokról szóló fejezetét.)
Deriváltak számítása és backpropagation
Aki olvasta a cikksorozat második részét, az tudja, hogy a neurális hálók tantásának alapja a backpropagation (hiba visszaterjesztés), amikor a hálózat hibája alapján kiszámtjuk a deriváltakat, majd ezek alapján módosítjuk a hálózat súlyait. Tensorflow esetén a GradientTape osztály szolgált arra, hogy rögzítse a tenzor műveleteket, majd a hiba meghatározását követően visszagörgetve kiszámolja a deriváltakat. PyTorch esetén is létezik egy hasonló mechanizmus, aminek autograd a neve de a Tensorflowval ellentétben nem külön osztály valósítja meg, hanem a Tensor osztályba került beépítésre. A Tensor osztálynak van egy requires_grad tulajdonsága. Ha ez True-ra van álltva, a rendszer rögzíti a tenzoron elvégzett műveleteket. Ha egy ilyen követett tenzoron elvégzünk egy összeadást, akkor az eredmény tenzor grad_fn tulajdonságába bekerül egy hivatkozás a műveletet végző függvényre. Tehát minden tenzor ami valamilyen művelet eredményeként jött létre, tartalmazni fog egy hivatkozást az őt létrehozó függvényre, ami pedig a forrás tenzorokra tartalmaz hivatkozásokat. Nézzünk is gyorsan egy példát:
x = torch.ones(2, 2, requires_grad=True)
print(x)
y = x + 2
print(y)
A fenti Python kód létrehoz egy 2x2-es mátrixot (tenzort) aminek be van álltva a requires_grad tulajdonsága, majd ehhez hozzáad kettőt. Ha kiiratjuk a két tenzort, így fog kinézni az output:
tensor([[1., 1.], [1., 1.]], requires_grad=True)
tensor([[3., 3.], [3., 3.]], grad_fn=<AddBackward0>)
Látszik, hogy az y tenzor ami az összeadás eredményeként jött létre, tartalmaz egy visszahivatkozást az őt létrehozó műveletre (AddBackward0). Ezeken a visszahivatkozásokon keresztül visszakövethető a művelet folyam egészen a legelső tenzorig, így ki tudjuk számolni a deriváltakat. A deriváltak számításához az eredmény tenzoron meg kell hívnunk a backward függvényt, ami rekurzívan visszafut a láncon és minden tenzorhoz kiszámolja a deriváltakat, ami a tenzor grad mezőjébe kerül. Nézzünk erre egy példát! A cikksorozat második részében található lineáris modell tantást fogjuk portolni PyTorch-ra:
A kód elején kigeneráljuk az 1000 elemű ponthalmazt, amire a W és b változók által defeiniált egyenest fogjuk illeszteni. A modell változók ebben az esetben 1 elemű tenzorok. Mindkét tenzor esetén a requires_grad tulajdonság értéke True, mivel szeretnénk, ha a rendszer ezekre a tenzorokra kiszámolná a deriváltakat. Az egyszerűség kedvéért definiálunk egy model függvényt, hogy ne kelljen minden esetben kiírni a képletet, majd pyplot segítségével megjelenítjük a ponthalmazt és az egyenest. A lényeg ezután következik a for ciklusban. A model futtatásának eredménye az y_pred tenzorba kerül, majd kiszámoljuk a négyzetes hibát, amit a loss tenzorban tárolunk. A loss tenzor tehát tartalmazni fogja a teljes műveletlistát a W és b tenzorokig visszamenően. A loss.backward() hívással kiszámoltatjuk a deriváltakat, amik a W.grad és b.grad tenzorokba kerülnek. A súlyok módostása a with torch.no_grad() blokkon belül történik. A no_grad() függvény segítségével közölhetjük a rendszerrel, hogy a blokkon belül nincs szükség a tenzor műveletek követésére. Olyan mint ha lokálisan kikapcsolnánk a tenzorok requires_grad tulajdonságát. A következő két sor módosítja a W és b változók értékét a deriváltaknak megfelelően, majd az ezt követő 2 sor nullázza a deriváltakat. Ez utóbbira mindig érdemes odafigyelni. Gyakori hiba, hogy valaki elfelejti nullázni a deriváltakat, így azok összegződnek, így tanítás értelemszerűen nem vezet eredményre. A fenti példából jól látható, hogy hogyan működik a PyTorch “tenzorokba éptett” autograd rendszere. (Akit részletesebben érdekel a dolog, olvassa el a PyTorch tutorial autograd fejezetét.)
Neurális hálózatok
PyTorchban úgy hozhatunk létre neurális hálót, hogy az nn.Module-ból származtatunk egy saját osztályt, ahol a konstruktorban definiáljuk a paraméterezhető rétegeket, a forward metódusban pedig a teljes hálózatot.
class Net(nn.Module):
def __init__(self):
super(Net, self).__init__()
self.conv1 = nn.Conv2d(3, 64, 3)
self.conv2 = nn.Conv2d(64, 64, 3)
self.conv3 = nn.Conv2d(64, 64, 3)
self.fc1 = nn.Linear(1024, 64)
self.fc2 = nn.Linear(64, 10)def forward(self, x):
x = F.max_pool2d(F.relu(self.conv1(x)), (2, 2))
x = F.max_pool2d(F.relu(self.conv2(x)), 2)
x = F.relu(self.conv3(x))
x = x.view(-1, 1024)
x = F.relu(self.fc1(x))
x = F.softmax(self.fc2(x))
return xnet = Net()
print(net)params = list(net.parameters())
print(len(params))
A fenti kód egy egyszerű 3 konvolúciós és 2 teljesen kapcsolt rétegből álló hálózat. A paraméterezhető rétegeket a konstruktorban definiáljuk, ahol minden egyes réteg az objektum egy belső változója (ennek később jelentősége lesz). A forward metódusban definiáljuk a teljes hálózatot függvény hívások sorozataként. Az utolsó pár sorban létrehozunk egy példányt a neurális hálózatunkból és lekérdezzük a paraméterek számát. A neurális háló állítható paramétereit a parameters() függvény adja vissza egy tenzor lista formájában. A függvény úgy működik, hogy végigszalad az objektum belső változóin és innen gyűjti össze a paraméter tenzor hivatkozásokat. Ezért fontos, hogy minden egyes paraméterezhető réteget belső változóként hozzunk létre a konstruktorban. Erről meg is győződhetünk. Ha végigkommenteljük a konstruktorban a belső változó definíciókat, a parameters() visszatérési értéke üres lista lesz. A fenti hálózat esetén egyébként a parameters() 10-et ad vissza, mivel mind az 5 réteghez tartozik egy súly tenzor és külön egy tenzor, ami a bias-t tartalmazza. Ha a rétegeket bias=False módosítóval hoztuk volna létre, úgy a parameters() függvény egy 5 elemű listát adna vissza.
A Tensorflowhoz hasonlóan PyTorch esetén is létezik egy Sequential objektum, amivel egyszerűen definiálhatunk szekvenciális hálózatokat. A fenti mintahálózat például így néz ki a Sequential használatával:
net = nn.Sequential(
nn.Conv2d(3, 6, 5),
nn.MaxPool2d(2, 2),
nn.Conv2d(6, 16, 5),
nn.MaxPool2d(2, 2),
nn.Flatten(),
nn.Linear(16 * 5 * 5, 120),
nn.ReLU(),
nn.Linear(120, 84),
nn.ReLU(),
nn.Linear(84, 10)
)
Ennyi alapozás után lássuk a teljes kódot, ami a szokásos CIFAR10-es mintahalmazon tanul meg képeket felismerni.
A kód első pár sora a torchvision könyvtár segítségével betölti a CIFAR10-es adathalmazt, valamint tenzor formára hozza azt. Mivel 4 elemű batcheket definiáltunk, ezért [4, 3, 32, 32] formájú tenzorok lesznek a hálózat bemenete. A következő pár sor pyplot segítségével kirajzolja az első batch-t (első 4 képet), valamint kiírja a képekhez tartozó címkéket. Ezt követi a hálózat definíciója, amit a fenti minta alapján már könnyen értelmezhetünk. A 68. sorban ellenőrizzük, hogy a futtató hardveren van-e cuda támogatás. Ha van, akkor GPU-n fogjuk futtatni a tanítást, ha nincs, akkor CPU-n. Ahogyan az egyes tenzorokat, úgy a teljes neurális hálót is a .to() metódus hívásával tudjuk GPU-hoz rendelni. Ezt követi a hibafüggvény és a tantó algoritmus definíciója. Hibafüggvénynek az osztályozásnál megszokott CrossEntropyLoss-t használjuk, tanító algoritmusnak pedig a már ismerős Adam-et. A tanító algoritmus paraméterként kapja meg a hálózat paraméter tenzor listáját. Erre azért van szükség, mivel a deriválás után innen lehet majd kiolvasni a tenzorokhoz tartozó deriváltakat, valamint ezen keresztül módosíthatóak maguk a tenzorok. A tantás az egymásba ágyazott for ciklusokban történik. A teljes mintahalmazt kétszer futtatjuk át a hálózaton (külső ciklus). A belső ciklusban végigmegyünk a batch-eken. Minden batch 4 képet és ehhez tartozó 4 cimkét tartalmaz tenzor formában. Mivel a futtatás opcionálisan GPU-n történik, ezért fontos, hogy a bemeneti tenzorok is ugyanahhoz a device-hoz legyenek rendelve, amihez a teljes hálózat. Erre szolgál a 79.-es sorban a .to() hívás a data tömb elemein. Az optimizer.zero_grad() nullázza a deriváltakat, majd lefuttatjuk a hálózatot és kiszámoljuk a hibát. A hiba tenzoron meghívjuk a backward függvényt, ami kiszámolja a deriváltakat, végül az optimizer.step() a tenzoroknál rögzített deriváltak alapján módosítja a tenzorokat. Lényegében ennyi a tanítás. A kód végén még van egy torch.save() hívás, ami menti a hálózat súlyait. (A teljes leírás itt megtalálható: https://pytorch.org/tutorials/beginner/blitz/cifar10_tutorial.html)
Körülbelül ennyit szerettem volna írni a PyTorch-ról a Tensorflow fényében. Látható, hogy a két rendszer lényegében ugyanazt tudja. Mindket rendszer tenzor műveletekre épül és hasonló logika mentén működik. Különbség igazából a deriváltak kezelésében és a neurális hálózatok definiciójában van, de aki érti az egyik rendszer működését, az könnyen bele tud tanulni a másik rendszer használatába is.