Õppematerjal · PyTorch
PyTorch nullist MLP-ni
Selle materjali lõpuks oskad kirjutada ja treenida mitmekihilise pertseptroni (MLP), mis eristab andmetes klasse. Liigume väikeste sammudega: kõigepealt tensor, siis väike mudel käsitsi, siis torchi automaatne gradient ja alles siis närvivõrk.
Miks torch, kui sklearnis on MLP olemas?
Kui oled kasutanud MLPClassifierit või MLPRegressorit, oled MLP-d juba treeninud. Sklearn teeb kõik ühe fit()-iga, torch nõuab rohkem koodi. Vaata sama mudelit kõrvuti.
from sklearn.neural_network import MLPClassifier
clf = MLPClassifier(
hidden_layer_sizes=(16, 16),
activation="relu",
solver="adam",
learning_rate_init=0.01,
batch_size=32,
max_iter=50,
)
clf.fit(X_train, y_train) # kogu treening on peidus siin
clf.predict(X_test)model = nn.Sequential(
nn.Linear(2, 16), nn.ReLU(),
nn.Linear(16, 16), nn.ReLU(),
nn.Linear(16, 2),
)
opt = torch.optim.Adam(model.parameters(), lr=0.01)
loss_fn = nn.CrossEntropyLoss()
for epoch in range(50): # see ongi fit()
for xb, yb in train_loader:
loss = loss_fn(model(xb), yb)
opt.zero_grad()
loss.backward()
opt.step()
model(X_test).argmax(dim=1)fit()-i sees peidus, PyTorchis kirjutad need ise ja saad igaüht muuta.See on sama võrk: 2 sisendit, kaks peidetud kihti 16 neuroniga, ReLU, Adam, partii 32 ja 50 epohhi. Täisnäite andmetel (osa 8) said mõlemad testi täpsuseks umbes 99%. Vahe on selles, et sklearn peidab treeningtsükli fit()-i sisse. Torchis kirjutad selle ise ja saad seetõttu kõike muuta.
| Mida tahad teha | sklearn MLP | PyTorch |
|---|---|---|
| Väike tabel, kiire esimene mudel | Väga hea, vähe koodi | Saab, aga koodi on rohkem |
| Pildid, tekst, heli, aegread | Ainult täisühendatud kihid | Konvolutsioon (CNN), RNN, transformer jt |
| Graafikakaart (GPU) | Ainult protsessor | model.to("cuda") |
| Oma kaofunktsioon | Fikseeritud: ruutviga või log-loss | Suvaline avaldis tensoritest |
| Ülesobitamise vastu | L2 (alpha) ja early_stopping | Lisaks Dropout, BatchNorm, oma reeglid |
| Näha ja muuta, mis treeningu ajal toimub | Peidus fit()-i sees | Iga samm on sinu koodis |
| Eeltreenitud mudelid | Pole | Hugging Face, torchvision jt |
| Andmed, mis ei mahu mällu | Kõik peab korraga mälus olema | DataLoader loeb osade kaupa |
Sklearn, kui andmed on väike tabel ja tahad MLP-d kiiresti võrrelda teiste mudelitega (otsustuspuu, random forest, logistiline regressioon) ühes Pipelineis ja GridSearchCV-s. Torch, kui tahad mõista, mis mudeli sees toimub, või kui ülesanne on suurem kui tavaline tabel. Peaaegu kogu tänapäeva süvaõpe, ka keelemudelid, on kirjutatud PyTorchis.
Selle materjali eesmärk: ehitame sama MLP-i, mida sklearn teeb, aga nii, et näeme iga sammu. Pärast seda on pildid ja tekst lihtsalt teised kihid samas tsüklis.
0Alustus
Paigalda PyTorch. Kõik siinsed näited töötavad tavalise protsessoriga, graafikakaarti pole vaja. Google Colabis on torch juba olemas.
pip install torchimport torch
print(torch.__version__) # nt 2.5.1
print(torch.cuda.is_available()) # False on täiesti korras, kui GPU-d poleSoovitus: ära kopeeri plokke korraga. Käivita iga plokk eraldi, vaata väljundit ja muuda mõnda arvu. Nii jääb rohkem meelde.
1Tensor
Tensor on PyTorchi põhiline andmetüüp. See on nagu NumPy massiiv: arvude tabel, millel on kuju (shape) ja tüüp (dtype). Lisaks oskab tensor kaht asja, mida NumPy ei oska: arvutada graafikakaardil ja meeles pidada, kuidas tulemus arvutati. Teisest tuleb gradient (osa 4).
import torch
a = torch.tensor([1.0, 2.0, 3.0])
print(a) # tensor([1., 2., 3.])
print(a.shape) # torch.Size([3])
print(a.dtype) # torch.float32
M = torch.tensor([[1, 2, 3],
[4, 5, 6]])
print(M.shape) # torch.Size([2, 3]) -> 2 rida, 3 veergu
print(M.dtype) # torch.int64 (täisarvud)| Mõõtmeid | Näide | Kuju | Masinõppes |
|---|---|---|---|
| 0 | torch.tensor(3.5) | () | üks arv, nt kaofunktsiooni väärtus |
| 1 | torch.tensor([1., 2., 3.]) | (3,) | üks näide või klassisiltide veerg |
| 2 | torch.zeros(150, 4) | (150, 4) | andmetabel: read = näited, veerud = tunnused |
Masinõppes on andmetabel peaaegu alati kujuga (N, F): N rida on näited, F veergu on tunnused. 150 iirist 4 tunnusega on kujuga (150, 4).
Tensori loomine
torch.zeros(2, 3) # 2×3 nullidest tabel
torch.ones(4) # tensor([1., 1., 1., 1.])
torch.arange(5) # tensor([0, 1, 2, 3, 4])
torch.linspace(0, 1, 5) # [0.00, 0.25, 0.50, 0.75, 1.00]
torch.rand(2, 2) # juhuarvud ühtlasest jaotusest [0, 1)
torch.randn(3, 4) # juhuarvud normaaljaotusest (keskmine 0, sd 1)
torch.manual_seed(0) # pärast seda tulevad juhuarvud iga kord samadNumPy ja tensor
import numpy as np
x = np.array([[1.5, 2.5], [3.0, 4.0]]) # NumPy vaikimisi float64
t = torch.tensor(x, dtype=torch.float32) # mudelid ootavad float32
back = t.numpy() # tagasi NumPy massiiviks
print(t.dtype) # torch.float32- Tunnused (sisend
X) ontorch.float32. - Klassisildid (
yklassifitseerimisel) on täisarvudtorch.long: 0, 1, 2, … - Kui tüüp on vale, annab torch vea, kus on sõnad
Float,DoublevõiLong. Siis vaata esimesena tüüpe.
2Kujud ja tehted
Tehted käivad nagu NumPys. Tähelepanu tasub pöörata kahele märgile: * korrutab elemendiviisi, @ teeb maatrikskorrutise. Närvivõrgu kiht on sisuliselt maatrikskorrutis.
x = torch.tensor([[1., 2.],
[3., 4.]])
x + 10 # [[11., 12.], [13., 14.]]
x * x # elemendiviisi: [[1., 4.], [9., 16.]]
x @ x # maatrikskorrutis: [[7., 10.], [15., 22.]]
x.sum() # tensor(10.)
x.mean(dim=0) # veergude keskmised: tensor([2., 3.])
x.sum(dim=1) # ridade summad: tensor([3., 7.])Mõõde, mille nimetad, kaob ära. Kuju (2, 2) pärast sum(dim=0) on (2,): read liidetakse kokku ja jääb üks arv iga veeru kohta. dim=1 liidab veerud kokku ja jätab ühe arvu iga rea kohta.
Kuju muutmine
t = torch.arange(6) # tensor([0, 1, 2, 3, 4, 5]), kuju (6,)
t.reshape(2, 3) # [[0, 1, 2], [3, 4, 5]]
t.reshape(3, -1) # -1 = "arvuta ise", kuju (3, 2)
t.unsqueeze(1).shape # torch.Size([6, 1]): vektorist veerg
t.reshape(2, 3).T.shape # torch.Size([3, 2]): transponeerimineRidade ja veergude valimine
X = torch.randn(100, 4) # 100 näidet, 4 tunnust
X[0] # esimene rida, kuju (4,)
X[:, 0] # esimene veerg, kuju (100,)
X[:10] # esimesed 10 rida, kuju (10, 4)
X[X[:, 0] > 0] # read, kus esimene tunnus on positiivneBroadcasting: standardiseerimine ühe reaga
Kui kujud ei klapi täpselt, venitab torch väiksema tensori üle suurema. Pisinäide: tabelile, kus on 2 rida, liidame ühe rea [10, 20]. Torch liidab selle rea mõlemale reale:
torch.tensor([[1, 2],
[3, 4]]) + torch.tensor([10, 20])
# [[11, 22],
# [13, 24]]Sama nipiga saab ühe reaga standardiseerida kõik andmed korraga:
mu = X.mean(dim=0) # kuju (4,): iga veeru keskmine
sd = X.std(dim=0) # kuju (4,)
X_std = (X - mu) / sd # (100, 4) ja (4,) -> rakendub igale reale
print(X_std.mean(dim=0)) # ligikaudu [0, 0, 0, 0]mu rea (oranž) mõtteliselt kõigile sajale reale (kriipsjoonega koopiad) ja lahutab siis elemendiviisi.3Esimene mudel käsitsi
Alustame kõige väiksema mudeliga, mis üldse õpib: sirgega. Selles osas teeme kõik ise, tavaliste arvutustega. Torchi automaatset gradienti me veel ei kasuta. Nii näed, mis tegelikult toimub, ja saad aru, mida torch hiljem sinu eest ära teeb.
3.1 Ülesanne: andmed X ja y
Neli õpilast kirjutasid testi. Teame, mitu tundi igaüks õppis ja mitu punkti ta sai:
| Õpilane | Õppetunnid (X) | Punktid (y) |
|---|---|---|
| A | 1 | 3 |
| B | 2 | 5 |
| C | 3 | 7 |
| D | 4 | 9 |
- X on see, mida me teame: sisend ehk tunnus.
- y on see, mida tahame ennustada: sihtmuutuja.
Küsimus: mitu punkti saab õpilane, kes õppis 5 tundi? Selleks tuleb leida reegel, mis seob X-i ja y-t. Vaata tabelit: iga lisatund annab 2 punkti juurde ja 0 tunniga oleks 1 punkt. Reegel on y = 2·X + 1.
Meie nägime seda silmaga. Arvuti peab selle ise leidma, ja seda me nimetamegi õppimiseks. Õige reegli teame ette ainult selleks, et saaksime lõpus kontrollida, kas mudel leidis selle.
import torch
X = torch.tensor([1.0, 2.0, 3.0, 4.0]) # X: õppetunnid
y = torch.tensor([3.0, 5.0, 7.0, 9.0]) # y: testi punktid
print(X)
print(y)tensor([1., 2., 3., 4.])
tensor([3., 5., 7., 9.])3.2 Mudel: kaks arvu, w ja b
Mudel on lihtne valem, mis teeb X-ist ennustuse:
Märk ŷ (loe „y-katus“, koodis y_hat) tähendab ennustust. Nii ei aeta seda segi tegeliku y-ga.
- w (weight, kaal) ütleb, mitu punkti annab üks lisatund. See on sirge tõus.
- b (bias, nihe) ütleb, mitu punkti saab õpilane, kes üldse ei õppinud (X = 0).
Neid kahte arvu me ei tea. Mudel peab need andmetest ise leidma. Neid arve nimetatakse mudeli parameetriteks.
Näide. Kui oleks w = 2 ja b = 1, siis õpilase C (X = 3) ennustus oleks 2·3 + 1 = 7. See on täpselt õige vastus. Aga mudel ei tea seda veel.
Alustame arvamisega: w = 0 ja b = 0. Siis ennustab mudel iga õpilase kohta 0 punkti.
w = torch.tensor(0.0) # tõus: alguses ei tea mudel midagi, paneme 0
b = torch.tensor(0.0) # nihe: samuti 0
y_hat = w * X + b # ennustus kõigile õpilastele korraga
print(y_hat)tensor([0., 0., 0., 0.])Torch korrutab ühe arvu w kõigi X-i elementidega korraga, nagu broadcasting'u näites osas 2.
Närvivõrgus on täpselt samasugused w-d ja b-d, ainult neid on tuhandeid. Kõik, mida siin nende kahega teeme, kehtib ka seal.
3.3 Viga: kui palju mudel eksib?
Mudel ennustas 0, aga õiged vastused on 3, 5, 7 ja 9. Leiame iga õpilase vea: ennustus miinus õige vastus.
| Õpilane | y | ennustus | viga | viga ruudus |
|---|---|---|---|---|
| A | 3 | 0 | −3 | 9 |
| B | 5 | 0 | −5 | 25 |
| C | 7 | 0 | −7 | 49 |
| D | 9 | 0 | −9 | 81 |
Miks ruutu? Mõni ennustus võib olla liiga suur ja mõni liiga väike. Kui paneksime vead lihtsalt kokku, tühistaksid plussid ja miinused üksteist. Näiteks vead −3 ja +3 annaksid keskmiseks 0, kuigi mõlemad ennustused on valed. Ruut kõrvaldab selle probleemi ja teeb suure vea eriti kalliks.
Võtame ruutude keskmise: (9 + 25 + 49 + 81) / 4 = 41. See üks arv on mudeli viga (inglise keeles loss). Mida väiksem, seda parem mudel. Õppimine tähendab: muuda w-d ja b-d nii, et see arv väheneks.
error = y_hat - y # viga igal õpilasel
loss = (error ** 2).mean() # vigade ruutude keskmine
print(error)
print(loss)tensor([-3., -5., -7., -9.])
tensor(41.)Seda viga nimetatakse keskmiseks ruutveaks (MSE, mean squared error).
3.4 Kuidas viga vähendada? Tuletis w jaoks
Mudelil on kaks nuppu, w ja b. Keerame neid ühekaupa. Alustame w-ga ja hoiame b-d seni paigal (b = 0). Küsimus: kummale poole w-d keerata, et viga väheneks? Selle jaoks on vaja ühte uut sõna, tuletist.
Tuletis ütleb, kui palju tulemus muutub, kui sisendit muuta. Vaatame esmalt meie enda reeglit y = 2·X + 1:
| X | y = 2·X + 1 | muutus |
|---|---|---|
| 1 | 3 | |
| 2 | 5 | +2 |
| 3 | 7 | +2 |
| 4 | 9 | +2 |
Iga kord, kui X kasvab 1 võrra, kasvab y 2 võrra. Seega tuletis on 2. (Juurde liidetud 1 ei muuda midagi: see tõstab kõik y-d ühepalju üles, aga muutus sammult sammule jääb samaks.)
Tuletise saab leida jagamisega:
Tabelist: X muutus 1 → 2 ja y muutus 3 → 5, seega (5 − 3) / (2 − 1) = 2.
Teeme nüüd sama meie veaga. Sisend on w ja tulemus on viga. Kõige lihtsam on proovida: nihutame w-d natuke (0 → 0.1), hoiame b-d nulli peal ja vaatame, mis veaga juhtub.
def loss_for(w, b): # viga antud w ja b korral
y_hat = w * X + b
return ((y_hat - y) ** 2).mean().item()
print(round(loss_for(0.0, 0.0), 3)) # praegune viga: w = 0, b = 0
print(round(loss_for(0.1, 0.0), 3)) # w on 0.1 võrra suurem, b jääb 041.0
37.575Viga langes 41-lt 37.575-ni, seega w suurendamine aitab. Jagame samamoodi nagu tabelis:
See arv ütleb: iga ühe w-ühiku kohta muutub viga umbes −34 võrra.
Miks „umbes“? Reegel y = 2·X + 1 oli sirge, aga viga w funktsioonina on kõver, ja kõvera kalle on igas kohas natuke teine. Mida väiksem on nihe, seda täpsemalt saame kalde just sealt, kus praegu oleme. Proovime kolme nihet:
for h in [0.1, 0.01, 0.001]: # h = kui palju w-d nihutame
slope_w = (loss_for(h, 0.0) - loss_for(0.0, 0.0)) / h # b jääb 0
print(h, round(slope_w, 2))0.1 -34.25
0.01 -34.93
0.001 -34.99Arvud lähenevad: vea tuletis w järgi on −35. Mida see tähendab?
- Tuletis on negatiivne (nagu praegu): kui w kasvab, viga väheneb. Seega w-d tuleb suurendada.
- Tuletis on positiivne: kui w kasvab, viga kasvab. Seega w-d tuleb vähendada.
- Tuletis on 0: oleme põhjas ja w-d pole vaja muuta.
Kujuta ette, et seisad udus mäenõlval ja tahad orgu jõuda. Sa ei näe kaugele, aga tunned jalgadega, kuhu poole maapind langeb. Tuletis ongi see kalle jalge all. Joonisel on viga w funktsioonina:
3.5 Tuletis b jaoks
Nüüd teine nupp. Hoiame w nulli peal (w = 0) ja nihutame ainult b-d. Võte on täpselt sama: nihe, vea muutus, jagamine. Kui b läheb 0 → 0.1, langeb viga 41-lt 39.81-ni, seega (39.81 − 41) / 0.1 = −11.9. Väiksemate nihetega:
print(round(loss_for(0.0, 0.1), 3)) # nüüd b on 0.1 võrra suurem, w jääb 0
for h in [0.1, 0.01, 0.001]: # h = kui palju b-d nihutame
slope_b = (loss_for(0.0, h) - loss_for(0.0, 0.0)) / h # w jääb 0
print(h, round(slope_b, 2))39.81
0.1 -11.9
0.01 -11.99
0.001 -12.0Arvud lähenevad: vea tuletis b järgi on −12. Ka see on negatiivne, seega ka b-d tuleb suurendada.
Pane tähele, et tegime need kaks asja eraldi: w tuletise leidmisel hoidsime b-d paigal ja b tuletise leidmisel w-d. Nii tehakse alati, kui parameetreid on mitu: igaühe tuletis leitakse nii, et teised jäävad paigale. (Matemaatikas nimetatakse seda osatuletiseks, aga sõna pole vaja meelde jätta.)
Mõlemad tuletised koos nimetatakse gradiendiks. Meie gradient on praegu (−35, −12). See ütleb, kummale poole kumbagi nuppu keerata.
3.6 Tuletis valemiga
Proovimine (parameetrit nihutada ja vaadata) töötab, aga on aeglane ja ligikaudne. Meie mudeli jaoks on olemas täpne valem:
- tuletis w järgi:
keskmine(2 · viga · X) - tuletis b järgi:
keskmine(2 · viga)
Arvutame selle oma näites käsitsi. Vead on −3, −5, −7 ja −9:
| Õpilane | X | viga | viga · X |
|---|---|---|---|
| A | 1 | −3 | −3 |
| B | 2 | −5 | −10 |
| C | 3 | −7 | −21 |
| D | 4 | −9 | −36 |
- w jaoks: keskmine(viga · X) = (−3 − 10 − 21 − 36) / 4 = −17.5. Korda 2-ga: −35.
- b jaoks: keskmine(viga) = (−3 − 5 − 7 − 9) / 4 = −6. Korda 2-ga: −12.
Täpselt samad arvud, mis proovimisega, ainult nüüd täpselt. Sama koodis:
grad_w = (2 * error * X).mean() # tuletis w järgi
grad_b = (2 * error).mean() # tuletis b järgi
print(grad_w, grad_b)tensor(-35.) tensor(-12.)Kust see valem tuleb?
Ühe õpilase viga on e = w·X + b − y ja tema panus mudeli veasse on e². Kui w kasvab 1 võrra, kasvab ennustus X võrra, seega ka e kasvab X võrra. Ruudu e² tuletis on 2·e, ja see tuleb korrutada e muutusega ehk X-iga. Tulemus on 2·e·X. Kõigi õpilaste peale keskmistades saame valemi.
b jaoks sama, aga b kasvades kasvab e ainult 1 võrra, seega saame 2·e.
Kui mudelit muudad, tuleb valem uuesti välja tuletada. Kahe parameetriga läks see lihtsalt, aga närvivõrgus on tuhandeid parameetreid. Järgmises osas näed, et torch arvutab tuletised ise.
3.7 Üks samm
Nüüd teame, kuhu poole w-d ja b-d liigutada. Reegel on selline:
b = b − lr · tuletis_b
lr (learning rate, õppesamm) on väike arv, mis ütleb, kui suure sammu teeme. Võtame lr = 0.05.
Miks miinus? Meie w tuletis on negatiivne (−35), seega w-d tuleb suurendada. Valem teebki seda, sest miinus korda miinus on pluss:
b = 0 − 0.05 · (−12) = 0 + 0.6 = 0.6
Kui tuletis oleks positiivne, vähendaks sama valem w-d. Tuletise märk otsustab, kummale poole liigume, ja lr otsustab, kui kaugele.
lr = 0.05 # õppesamm: kui suure sammu teeme
w = w - lr * grad_w
b = b - lr * grad_b
print(w, b)tensor(1.7500) tensor(0.6000)Kontrollime, kas ennustused paranesid:
y_hat = w * X + b
loss = ((y_hat - y) ** 2).mean()
print(y_hat)
print(loss)tensor([2.3500, 4.1000, 5.8500, 7.6000])
tensor(1.1288)Ennustused (2.35; 4.1; 5.85; 7.6) on õigetele vastustele (3; 5; 7; 9) nüüd palju lähemal. Viga langes 41-lt 1.13-ni ühe sammuga.
3.8 Kordame, kuni mudel on valmis
Üks samm ei ole veel kõik: ennustused pole veel täpsed. Seepärast kordame sama sammu tuhat korda. Üht täielikku läbimist kõigist andmetest nimetatakse epohhiks. Tsükli neli sammu (numbrid 1–4 koodis) on samad, mida me juba tegime: ennustus, viga, tuletised, samm.
w = torch.tensor(0.0) # alustame uuesti nullist
b = torch.tensor(0.0)
lr = 0.05
for epoch in range(1000):
y_hat = w * X + b # 1) ennustus
error = y_hat - y # 2) viga
loss = (error ** 2).mean()
grad_w = (2 * error * X).mean() # 3) tuletised
grad_b = (2 * error).mean()
if epoch in (0, 1, 2, 10, 100, 999): # prindime ainult mõne epohhi
print(f"epohh {epoch:3d} w = {w:.3f} b = {b:.3f} viga = {loss:.4f}")
w = w - lr * grad_w # 4) samm
b = b - lr * grad_bepohh 0 w = 0.000 b = 0.000 viga = 41.0000
epohh 1 w = 1.750 b = 0.600 viga = 1.1288
epohh 2 w = 2.037 b = 0.703 viga = 0.0433
epohh 10 w = 2.084 b = 0.754 viga = 0.0101
epohh 100 w = 2.022 b = 0.937 viga = 0.0007
epohh 999 w = 2.000 b = 1.000 viga = 0.0000- Epohhil 0 on
w = 0,b = 0ja viga 41. Pärast ühte sammu (epohh 1) onw = 1.75,b = 0.6ja viga 1.13, täpselt nagu käsitsi arvutasime. - Esimesed sammud teevad kõige rohkem tööd. Hiljem on vaja ainult peenhäälestust.
- Epohhiks 999 on
w = 2.000jab = 1.000. Mudel leidis ise üles reegliy = 2·X + 1, mida me talle ei öelnud.
3.9 Ennustamine
Õpitud w ja b ongi mudel. Nendega saab ennustada uusi õpilasi. Mitu punkti saab see, kes õppis 5 tundi?
print(w * 5 + b) # õpilane, kes õppis 5 tunditensor(11.0000)Mudel ennustab 11 punkti. Reegel 2·5 + 1 annab sama.
Muuda lr väärtuseks 0.2. Viga ei vähene, vaid kasvab iga sammuga ja lõpuks tuleb nan, sest samm on nii suur, et hüppame miinimumist üle. Pane lr = 0.0001: mudel õpib, aga nii aeglaselt, et 1000 epohhi järel on w alles umbes 1.7.
- Mudel:
ŷ = w · X + b. w ja b on parameetrid. - Viga: ennustuste ja õigete vastuste erinevuste ruutude keskmine.
- Tuletis: kui palju viga muutub, kui parameetrit natuke muuta (teised jäävad paigale). Kõik tuletised koos on gradient.
- Samm:
w = w − lr · tuletis. Negatiivne tuletis suurendab w-d, positiivne vähendab. - Korda sammu sadu kordi.
Ainus tüütu osa oli tuletise valem. Kahe parameetri juures läks see lihtsalt, aga suure võrgu juures on see lootusetu. Järgmises osas näed, kuidas torch arvutab tuletised ise.
4Gradient: torch arvutab ise
Eelmises osas kirjutasime tuletise valemi ise: (2 * error * X).mean(). Kahe parameetri ja ühe lihtsa mudeli jaoks see õnnestus. Päris närvivõrgus on aga tuhandeid parameetreid ja mitu kihti. Selle valemi käsitsi tuletamine oleks lootusetu ja iga mudelimuudatusega peaks seda uuesti tegema.
Torch arvutab tuletised ise. Seda võimalust nimetatakse autograd.
4.1 Sama gradient ühe käsuga
Torchile tuleb öelda kolm asja:
| Kood | Tähendus lihtsas keeles |
|---|---|
requires_grad=True | „Jälgi seda arvu, mul on vaja selle tuletist.“ |
loss.backward() | „Arvuta nüüd tuletised.“ |
w.grad | „Siin on vastus.“ |
Teeme samad arvutused, mis eelmises osas, aga tuletise valemit me seekord ei kirjuta:
w = torch.tensor(0.0, requires_grad=True) # "jälgi seda"
b = torch.tensor(0.0, requires_grad=True)
y_hat = w * X + b
loss = ((y_hat - y) ** 2).mean()
print(loss)
loss.backward() # "arvuta tuletised"
print(w.grad, b.grad) # "siin on vastus"tensor(41., grad_fn=<MeanBackward0>)
tensor(-35.) tensor(-12.)Saime −35 ja −12, täpselt need arvud, mille eelmises osas ise välja arvutasime. Väljundis olev grad_fn=<MeanBackward0> on märk, et torch jättis meelde, kuidas loss arvutati (viimane tehe oli keskmine). Ilma selle mäluta ei saaks ta tagurpidi minna.
4.2 Kuidas torch seda teeb?
Torch ei tea sinu mudeli valemit. Ta teab ainult iga lihtsa tehte tuletist: korrutamise, liitmise, ruutu tõstmise ja nii edasi. Mudeli arvutamise ajal (forward) jätab ta meelde, milliseid tehteid tehti. Seejärel (backward) käib ta sama tee tagurpidi, kiht-kihilt, ja arvutab igal sammul ühe lihtsa tuletise.
Vaatame seda väikese närvivõrgu peal. Sisendi ja väljundi vahel on peidetud kiht kahe neuroniga (h₁ ja h₂) ning kihtide vahel on neli kaalu, w₁ kuni w₄.
Seletused on joonise all. Joonisel on sammud 0–4 ja joonise all on iga sammu kohta oma lõik, samade numbritega. Vajuta joonisel „↺ Algusest“ ja siis „Edasi“ ning loe samal ajal vastavat lõiku joonise alt. Lõik, mis parasjagu kehtib, tõuseb esile.
Mõtle igale arvule küsimus: kui see arv kasvaks 1 võrra, kui palju muutuks viga? Vastust nimetame arvu mõjuks veale. See ongi tuletis. Backward arvutab mõju iga arvu ja iga kaalu jaoks. Seletused sammude kaupa (numbrid on samad, mis joonisel):
Forward (vasakult paremale). Iga nool korrutab arvu kaaluga ja neuron liidab sissetulevad arvud kokku.
h₁ = w₁·x = 2·1 = 2jah₂ = w₂·x = 1·1 = 1. Väljund:ŷ = w₃·h₁ + w₄·h₂ = 1·2 + 3·1 = 5. Õige vastus ony = 3, seegaviga = (5 − 3)² = 4. Torch jätab need tehted meelde.Backward algab veast. Küsime: kui ŷ kasvaks 1 võrra, kui palju muutuks viga? Viga on
(ŷ − y)²ja ruudu tuletis on2·(ŷ − y) = 2·(5 − 3) = 4. Kirjutame ŷ juurde: mõju 4.Üks kiht tagasi (w₃ ja w₄). Siin arvutame kaks asja.
Kaalude gradiendid. Kui
w₃kasvab 1 võrra, kasvab ŷh₁ = 2võrra, mis annab veale4 × 2 = 8. Samamoodiw₄:4 × h₂ = 4 × 1 = 4.Mõju peidetud neuronitele. Kui
h₁kasvab 1 võrra, kasvab ŷw₃ = 1võrra, mis annab4 × 1 = 4. Samamoodih₂:4 × w₄ = 4 × 3 = 12.Veel üks kiht tagasi (w₁ ja w₂). Sama reegel, nüüd tuleb mõju peidetud neuronitest:
w₁:4 × x = 4 × 1 = 4jaw₂:12 × x = 12 × 1 = 12. Sisendistxedasi pole vaja minna, sest x on andmed, mitte parameeter.Valmis. Igal kaalul on nüüd gradient:
w₁ = 4,w₂ = 12,w₃ = 8,w₄ = 4. Täpselt seda teebkiloss.backward(), ükskõik kui palju kihte on.
Igal kihil kordab torch kahte lihtsat reeglit:
- kaalu gradient = mõju paremal × väärtus vasakul
- mõju edasi vasakule = mõju paremal × kaal
Matemaatikas nimetatakse seda ahelreegliks (chain rule).
Viimane samm käsitsi. Gradiendid on olemas, nüüd liigutame iga kaalu nagu osas 3: w = w − lr · gradient. Võtame lr = 0.01:
w₂ = 1 − 0.01 · 12 = 0.88
w₃ = 1 − 0.01 · 8 = 0.92
w₄ = 3 − 0.01 · 4 = 2.96
Teeme forward uuesti uute kaaludega: h₁ = 1.96, h₂ = 0.88 ja ŷ = 0.92 · 1.96 + 2.96 · 0.88 = 4.408. Viga on nüüd (4.408 − 3)² ≈ 1.98, enne oli 4. Viga langes, seega gradiendid osutasid õigesse suunda. Päris treeningus kordame sama sammu sadu kordi, nagu osas 3.
Sama võrk torchis. Kirjutame ainult forward-arvutuse, kõik muu teeb backward():
x = torch.tensor(1.0) # sisend
target = torch.tensor(3.0) # õige vastus (joonisel y)
w1 = torch.tensor(2.0, requires_grad=True) # neli kaalu
w2 = torch.tensor(1.0, requires_grad=True)
w3 = torch.tensor(1.0, requires_grad=True)
w4 = torch.tensor(3.0, requires_grad=True)
h1 = w1 * x # peidetud kiht
h2 = w2 * x
y_hat = w3 * h1 + w4 * h2 # väljund
loss = (y_hat - target) ** 2 # viga
print(h1.item(), h2.item(), y_hat.item(), loss.item())
loss.backward()
print(w1.grad, w2.grad, w3.grad, w4.grad)2.0 1.0 5.0 4.0
tensor(4.) tensor(12.) tensor(8.) tensor(4.)Need on täpselt pildi arvud: w₁ 4, w₂ 12, w₃ 8 ja w₄ 4.
Soovid näha ka vahearvude mõju?
Vaikimisi jätab torch gradiendi meelde ainult parameetritele (w₁ kuni w₄), sest ainult neid on vaja uuendada. Kui tahad näha ka vahearvude mõju (pildil punased „mõju“ arvud), palu torchil need meelde jätta:
w1.grad = w2.grad = w3.grad = w4.grad = None # nulli eelmine tulemus
h1 = w1 * x
h2 = w2 * x
y_hat = w3 * h1 + w4 * h2
loss = (y_hat - target) ** 2
for t in (h1, h2, y_hat):
t.retain_grad() # jäta ka vahearvu gradient meelde
loss.backward()
print(y_hat.grad, h1.grad, h2.grad)tensor(4.) tensor(4.) tensor(12.)Need on pildi arvud: ŷ mõju 4, h₁ mõju 4 ja h₂ mõju 12.
Pildil jätsime lihtsuse mõttes kihtide vahelt aktivatsiooni (näiteks ReLU) välja. Päris võrgus lisandub kihtide vahele üks tehe veel. Torch teab ka selle tuletise (ReLU puhul on see 1 või 0) ja reeglid jäävad samaks.
requires_grad=True→ jälgi;backward()→ arvuta;.grad→ vastus.- Forward arvutab väärtused vasakult paremale, backward mõjud paremalt vasakule, kiht-kihilt.
- Kaalu gradient = mõju paremal × väärtus vasakul. Mõju edasi = mõju paremal × kaal.
- Kaalu liigutamine:
w = w − lr · gradient. Viga väheneb.
Nüüd on gradient selge: tuletised näitavad, kuhu poole iga kaalu liigutada, ja torch leiab need ise. Järgmises osas paneme sama väikese mudeli kokku torchi valmis klotsidest.
5Sama mudel torchi tööriistadega
Osas 3 tegime kõik ise ja osas 4 nägid, et torch leiab tuletised ise. Torchis on ka valmis klotsid, mis teevad ülejäänud käsitsi töö ära. nn.Linear hoiab parameetreid w ja b, nn.MSELoss arvutab vea ja optim.SGD teeb uuendussammu, täpselt sama, mis osas 3 rida w = w - lr * grad_w, aga kõigile parameetritele korraga. Tsükkel jääb samaks.
Üks pisiasi: nn.Linear tahab sisendit kujuga (N, 1), ehk N rida ja igas reas üks tunnus. Meie X on praegu kujuga (4,), seega muudame kuju reshape-iga (nagu osas 2).
from torch import nn
Xc = X.reshape(-1, 1) # nn.Linear tahab kuju (N, 1)
yc = y.reshape(-1, 1)
model = nn.Linear(1, 1) # 1 sisend, 1 väljund; w ja b on sees
loss_fn = nn.MSELoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.05)
for epoch in range(1000):
y_hat = model(Xc) # 1) forward: ennustus
loss = loss_fn(y_hat, yc) # 2) viga
optimizer.zero_grad() # 3) nulli vanad gradiendid
loss.backward() # 4) backward: uued gradiendid
optimizer.step() # 5) uuenda parameetreid
print(f"w = {model.weight.item():.3f} b = {model.bias.item():.3f}")
with torch.no_grad():
print(model(torch.tensor([[5.0]]))) # õpilane, kes õppis 5 tundiw = 2.000 b = 1.000
tensor([[11.0000]])Need viis rida on iga PyTorchi mudeli treenimise süda. Need jäävad samaks ka suures närvivõrgus:
zero_grad() peab olema enne backward()-i, sest muidu liituvad uued gradiendid vanadele.optimizer.zero_grad():backward()ei kirjuta vana gradienti üle, vaid liidab uue juurde. Kui vanu ei nullita, kasutaks iga samm ka kõigi eelmiste sammude gradiente ja mudel liiguks valesse kohta. Seepärast nullime enne igabackward()-i.with torch.no_grad():tähendab „siin ära jälgi“. Ennustamisel gradienti ei vajata, seega ei pea torch tehteid meelde jätma.
| Samm | Käsitsi (osa 3) | Torchiga (osad 4 ja 5) |
|---|---|---|
| parameetrid | w ja b tavaliste tensoritena | nn.Linear(1, 1) |
| ennustus | w * X + b | model(X) |
| viga | ((y_hat - y) ** 2).mean() | nn.MSELoss() |
| tuletised | valem (2 * error * X).mean() | loss.backward() |
| samm | w = w - lr * grad_w | optimizer.step() |
| gradientide nullimine | pole vaja, arvutame iga kord uuesti | optimizer.zero_grad() |
6Mitmekihiline pertseptron (MLP)
Lineaarne kiht
Osas 3 oli sirge w·x + b üks neuron: võta sisend, korruta kaaluga, liida nihe. Kui sisendeid on mitu, saab iga sisend oma kaalu ja tulemused liidetakse kokku.
Pisinäide. Kaks sisendit x₁ = 1 ja x₂ = 4, kaalud 2 ja 3, nihe 1. Neuron arvutab 2·1 + 3·4 + 1 = 15. Torchis:
from torch import nn
layer = nn.Linear(2, 1) # 2 sisendit, 1 väljund
with torch.no_grad(): # paneme kaalud käsitsi, et tulemust kontrollida
layer.weight[:] = torch.tensor([[2.0, 3.0]])
layer.bias[:] = torch.tensor([1.0])
print(layer(torch.tensor([[1.0, 4.0]]))) # tensor([[15.]], grad_fn=<AddmmBackward0>)Tavaliselt kaale käsitsi ei panda. Torch valib alguses juhuslikud kaalud ja treenimine leiab head väärtused.
nn.Linear(sisse, välja) on mitu sellist neuronit kõrvuti. Näiteks nn.Linear(4, 3) on 3 neuronit ja igaüks neist vaatab kõiki 4 sisendit. Igal neuronil on 4 kaalu ja 1 nihe, seega on parameetreid 3·4 + 3 = 15.
layer = nn.Linear(4, 3)
X = torch.randn(10, 4) # 10 näidet, 4 tunnust
print(layer(X).shape) # torch.Size([10, 3])
print(layer.weight.shape) # torch.Size([3, 4])
print(layer.bias.shape) # torch.Size([3]) -> kokku 12 + 3 = 15 parameetritnn.Linear(4, 3): iga väljund on kõigi nelja sisendi kaalutud summa pluss nihe. Iga joon on üks kaal. Lillad jooned on väljundi h₁ kaalud, kollases kastis on sama valemina.Miks üks kiht ei piisa ja miks kaks lineaarset kihti ka ei piisa
Lineaarne kiht oskab tõmmata ainult sirgjoont. Kui ühe klassi punktid on kaares või ringis, ei saa sirgjoonega neid eraldada.
Kas aitaks, kui paneme kaks lineaarset kihti järjest? Ei aita. Pisinäide: esimene kiht korrutab 2-ga, teine korrutab 3-ga. Kokku on see lihtsalt korrutamine 6-ga, ehk jälle üks sirge. Ükskõik kui palju lineaarseid kihte järjest panna, tulemus on ikka sirge.
Lahendus on panna kihtide vahele väike „murdja“, mida nimetatakse aktivatsioonifunktsiooniks. Kõige levinum on ReLU: ReLU(z) = max(0, z). Reegel on lihtne: negatiivne arv muutub nulliks, positiivne jääb samaks. Nii tekib sirgesse murdekoht ja paljudest murdekohtadest saab kokku kõvera piiri.
relu = nn.ReLU()
print(relu(torch.tensor([-2.0, -0.5, 0.0, 1.5]))) # tensor([0.0000, 0.0000, 0.0000, 1.5000])Võrk kokku: nn.Sequential
model = nn.Sequential(
nn.Linear(2, 16), # sisendkiht: 2 tunnust -> 16 neuronit
nn.ReLU(),
nn.Linear(16, 16), # peidetud kiht
nn.ReLU(),
nn.Linear(16, 2), # väljund: iga klassi skoor (logit)
)
print(model)
n_params = sum(p.numel() for p in model.parameters())
print(n_params) # 354 = (2·16+16) + (16·16+16) + (16·2+2)2 → 16 → 16 → 2. Peidetud kihtides on 16 neuronit, joonisel on neist näha viis. Kriipsjoonega kast näitab, et selle kihi väljund läheb läbi ReLU. All on kuju pärast iga kihti. Parameetreid on kokku 48 + 272 + 34 = 354.Iga Linear kihi esimene arv peab võrduma eelmise kihi teise arvuga. Esimese kihi esimene arv on tunnuste arv (X.shape[1]), viimase kihi teine arv on klasside arv.
Sama mudel klassina
Päris projektides kirjutatakse mudel tihti klassina. Siis saab forward meetodisse panna mida iganes. Mõlemad kirjapildid annavad sama tulemuse.
class MLP(nn.Module):
def __init__(self, n_in, n_hidden, n_out):
super().__init__()
self.net = nn.Sequential(
nn.Linear(n_in, n_hidden),
nn.ReLU(),
nn.Linear(n_hidden, n_hidden),
nn.ReLU(),
nn.Linear(n_hidden, n_out),
)
def forward(self, x):
return self.net(x)
model = MLP(n_in=2, n_hidden=16, n_out=2)Viimane kiht ja kaofunktsioon sõltuvad ülesandest
| Ülesanne | Viimane kiht | Kaofunktsioon | Sildid y |
|---|---|---|---|
| Regressioon (arv) | nn.Linear(h, 1) | nn.MSELoss() | float32, kuju (N, 1) |
| Kaks klassi | nn.Linear(h, 1) | nn.BCEWithLogitsLoss() | float32 0/1, kuju (N, 1) |
| K klassi (ka K = 2) | nn.Linear(h, K) | nn.CrossEntropyLoss() | long 0…K−1, kuju (N,) |
Klassifitseerimisel annab võrk iga klassi kohta ühe arvu, mida nimetatakse logitiks. Mida suurem arv, seda kindlam on võrk selles klassis. Logitid ei ole veel tõenäosused, need võivad olla ka negatiivsed.
Pisinäide. Võrk andis ühe punkti kohta logitid [2.0, 0.5]. Klass 0 sai suurema arvu, seega ennustus on klass 0. Softmax teeb logitidest tõenäosused, mis annavad kokku 1:
logits = torch.tensor([[2.0, 0.5]])
print(torch.softmax(logits, dim=1)) # tensor([[0.8176, 0.1824]]) -> 82% ja 18%
print(logits.argmax(dim=1)) # tensor([0]) -> ennustatud klass 0Treenimisel softmaxi ise ära lisa, sest CrossEntropyLoss teeb selle sisemiselt ise. Viimane kiht on lihtsalt nn.Linear.
7Mänguväljak
Siin treenib brauseris päris väike MLP, sama tsükliga nagu eespool. Taustavärv näitab, millise klassi võrk igas punktis ennustaks. Muuda seadeid ja vaata, kuidas piir muutub. Paremal on sama mudel PyTorchi koodina.
- Vali aktivatsiooniks Puudub. Piir jääb sirgeks, ükskõik kui palju kihte või neuroneid lisad. See ongi põhjus, miks ReLU on vajalik.
- Vali Ring ringi sees ja pane neuroneid 2. Võrk ei suuda ringi ümbritseda. Lisa neuroneid ükshaaval ja leia, millal hakkab välja tulema.
- Pane õppesammuks 0.3. Loss hüppab ega lange ühtlaselt. Pane 0.001: õpib, aga aeglaselt.
8Täisnäide: MLP klassifitseerija
Nüüd kõik koos päris töövoona: andmed, jagamine treening- ja testihulgaks, skaleerimine, partiid, treenimine ja hindamine. Andmete jaoks kasutame scikit-learni (pip install scikit-learn).
import torch
from torch import nn
from torch.utils.data import TensorDataset, DataLoader
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
torch.manual_seed(42)
# 1. Andmed
X, y = make_moons(n_samples=1000, noise=0.2, random_state=42)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y)
scaler = StandardScaler().fit(X_train) # sobita AINULT treeningandmetel
X_train = scaler.transform(X_train)
X_test = scaler.transform(X_test)
# NumPy -> tensor: tunnused float32, sildid long
X_train = torch.tensor(X_train, dtype=torch.float32)
X_test = torch.tensor(X_test, dtype=torch.float32)
y_train = torch.tensor(y_train, dtype=torch.long)
y_test = torch.tensor(y_test, dtype=torch.long)
train_loader = DataLoader(TensorDataset(X_train, y_train),
batch_size=32, shuffle=True)
# 2. Mudel, kaofunktsioon, optimeerija
model = nn.Sequential(
nn.Linear(2, 16), nn.ReLU(),
nn.Linear(16, 16), nn.ReLU(),
nn.Linear(16, 2),
)
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.01)
# 3. Treenimine
for epoch in range(50):
model.train()
total = 0.0
for xb, yb in train_loader: # üks partii korraga
logits = model(xb)
loss = loss_fn(logits, yb)
optimizer.zero_grad()
loss.backward()
optimizer.step()
total += loss.item() * len(xb)
if epoch % 10 == 0:
print(f"epohh {epoch:2d} keskmine loss {total / len(X_train):.3f}")
# 4. Hindamine testihulgal
model.eval()
with torch.no_grad():
pred = model(X_test).argmax(dim=1)
acc = (pred == y_test).float().mean().item()
print(f"testi täpsus: {acc:.1%}") # umbes 97–99%Mida uued sõnad tähendavad
- Epohh: üks täisring üle kõigi treeningandmete.
- Partii (batch): väike tükk andmeid, siin 32 näidet. Parameetreid uuendatakse pärast iga partiid, mitte ainult epohhi lõpus. See on kiirem ja õpib sageli paremini.
- DataLoader: jagab andmed partiideks.
shuffle=Truesegab järjekorra igal epohhil. - Adam: SGD targem sugulane, mis kohandab sammu iga parameetri jaoks ise. Hea vaikevalik.
model.train()jamodel.eval(): lülitavad treeningu ja testimise režiimi. Selles mudelis vahet pole, aga Dropouti ja BatchNormiga on. Harjuta need kohe sisse.
DataLoader lõikab need 32-realisteks partiideks ja iga partii järel uuendatakse parameetreid.Ennusta uut punkti
new = scaler.transform([[0.5, -0.3]]) # sama skaleerimine!
new = torch.tensor(new, dtype=torch.float32)
with torch.no_grad():
probs = torch.softmax(model(new), dim=1)
print(probs) # nt tensor([[0.02, 0.98]])
print(probs.argmax(dim=1).item()) # ennustatud klass: 1Vaheta andmed välja: from sklearn.datasets import load_iris, X, y = load_iris(return_X_y=True). Mida pead mudelis muutma? (Vihje: vaata tunnuste ja klasside arvu, need on 4 ja 3.)
9Levinud vead
| Mida näed | Põhjus | Paranda |
|---|---|---|
mat1 and mat2 shapes cannot be multiplied (32x4 and 2x16) | Esimese kihi sisendite arv ei võrdu tunnuste arvuga. | Esimene kiht peab olema nn.Linear(X.shape[1], …), siin nn.Linear(4, 16). |
Viga, kus on Double ja Float | NumPy andmed jäid float64. | torch.tensor(X, dtype=torch.float32) |
Viga, kus on Long | CrossEntropyLoss silt on vale tüüpi või kujuga. | Sildid torch.long kujuga (N,), mitte (N, 1). |
Loss ei lange või on nan | Liiga suur õppesamm, skaleerimata andmed või puudub zero_grad(). | Vähenda lr, kasuta StandardScalerit, kontrolli 5 sammu. |
| Täpsus kinni umbes klasside osakaalu juures | Viimases kihis on softmax või ReLU, või puudub aktivatsioon kihtide vahel. | Viimane kiht on puhas Linear, ReLU ainult kihtide vahel. |
Can't call numpy() on Tensor that requires grad | Tensor on veel arvutusgraafi küljes. | t.detach().numpy() või arvuta ploki torch.no_grad() sees. |
| Mälu kasvab, treening aeglustub | Kogud loendisse loss-tensoreid koos graafiga. | Salvesta loss.item(). |
Kui midagi ei tööta, prindi kujud: print(xb.shape, yb.shape, logits.shape). Pooled vead selguvad sellest ühest reast.
Kontrolli ennast
Kaheksa küsimust. Vali vastus, kohe näed, kas see oli õige ja miks.
Spikker
torch.tensor(x, dtype=torch.float32)
t.shape, t.dtype
t.reshape(2, -1), t.unsqueeze(1)
t.sum(dim=0), t.mean(dim=1)
a @ b # maatrikskorrutis
t.item() # üks arv Pythoni arvuks
t.detach().numpy()model = nn.Sequential(
nn.Linear(F, H), nn.ReLU(),
nn.Linear(H, K),
)
loss_fn = nn.CrossEntropyLoss()
opt = torch.optim.Adam(
model.parameters(), lr=0.01)model.train()
for xb, yb in loader:
loss = loss_fn(model(xb), yb)
opt.zero_grad()
loss.backward()
opt.step()model.eval()
with torch.no_grad():
pred = model(X_test).argmax(dim=1)
acc = (pred == y_test).float().mean()Mis edasi? Kui MLP on selge, on järgmised sammud ülesobitamise vastu Dropout ja varajane peatamine, mudeli salvestamine (torch.save(model.state_dict(), "mudel.pt")) ning piltide jaoks konvolutsioonivõrk (CNN).