Backpropagation de la zero: mai întâi motorul, apoi rețeaua
Scrie un motor autodiff de 120 de linii în Python pur, verifică-l cu PyTorch și învață ce face zero_grad ștergându-l.
Pe această pagină
La patru capitole distanță, există o gaură chiar în mijlocul cursului.
Capitolul 3 ne-a dat gradient descent: ca să îmbunătățești un parametru, găsești panta pierderii în raport cu el și faci un pas la vale. Capitolul 4 ne-a dat o pierdere care merită coborâtă. Dar în ambele, derivata a fost calculată de mână — un model, un parametru, o linie de calcul diferențial, și încăpea pe o pagină.
Acum pune două straturi unul peste altul. Ieșirea primului îl alimentează pe al doilea, deci fiecare pondere din primul afectează pierderea prin fiecare neuron din al doilea. O rețea cu două straturi ascunse de câte o sută de unități are cam douăzeci de mii de parametri, iar fiecare are nevoie de propria derivată parțială a aceleiași pierderi. Să faci asta de mână nu este plictisitor; este imposibil, și rămâne imposibil pentru fiecare arhitectură din restul acestui curs.
Ieșirea nu este o notație mai bună. Este realizarea că derivata unei compuneri poate fi calculată mecanic, de un program, din structura calculului însuși — și că, dacă o faci în direcția potrivită, obții toate cele douăzeci de mii de derivate cam la costul calculării pierderii o singură dată.
Mecanismul acesta este diferențierea automată în mod invers. Aplicat unei rețele neuronale se numește backpropagation, iar până la finalul acestui capitol vei fi scris una în aproximativ 120 de linii de Python fără biblioteci, o vei fi verificat cu PyTorch și o vei fi folosit ca să rezolvi problema XOR care a ucis perceptronul în Capitolul 1.
Mai întâi: de ce trebuie să existe o neliniaritate
Link către secțiunea: Mai întâi: de ce trebuie să existe o neliniaritateÎnainte să construim mașinăria, trebuie lămurită o întrebare, fiindcă dacă răspunsul ar fi fost invers nu am mai avea ce construi.
Perceptronul a eșuat pe XOR pentru că o singură linie nu poate separa cele patru puncte. Soluția evidentă este stivuirea: treci intrarea printr-un strat liniar, apoi prin altul. Ajută?
Nu, iar demonstrația are două rânduri. Un strat liniar este . Dă-l altuia, , și substituie:
Compunerea este cu și . Un teanc de straturi liniare este un singur strat liniar. Zece, o mie: tot o linie, tot incapabilă să facă XOR.
Merită să vezi asta întâmplându-se, nu doar să o crezi:
import numpy as np
rng = np.random.default_rng(0)
W1, b1 = rng.normal(size=(3, 2)), rng.normal(size=3)
W2, b2 = rng.normal(size=(1, 3)), rng.normal(size=1)
x = rng.normal(size=2)
two_layers = W2 @ (W1 @ x + b1) + b2
one_layer = (W2 @ W1) @ x + (W2 @ b1 + b2)
print(two_layers[0], one_layer[0], abs(two_layers[0] - one_layer[0]))-4.612963371048 -4.612963371048 0.00e+00Nu aproximativ egale. Identice bit cu bit, pentru că este aceeași aritmetică rearanjată.
Așadar profunzimea nu cumpără nimic de una singură. Ce cumpără ceva este introducerea unei funcții neliniare între straturi — și acesta este întregul motiv pentru care există funcțiile de activare. Nu sunt o înfloritură biologică sau un truc de normalizare. Fără una, al doilea strat este decor.
Regula lanțului, pe hârtie, cu un nod partajat
Link către secțiunea: Regula lanțului, pe hârtie, cu un nod partajatAcum matematica, și este o singură regulă pe care o știi deja, aplicată într-un loc puțin nefamiliar.
Regula lanțului pentru o singură variabilă spune că, dacă depinde de și depinde de , atunci . Derivatele se înmulțesc de-a lungul unui lanț.
Partea care contează aici este ce se întâmplă când o variabilă alimentează mai mult de o cale în aval. Dacă influențează prin și și prin , contribuțiile se adună:
Înmulțește de-a lungul unei căi, adună între căi. Asta este tot backpropagation, iar fiecare detaliu de implementare din restul acestui capitol — inclusiv += din cod și apelul zero_grad() care îi împiedică pe toți cei care scriu prima lor buclă de antrenare — este o consecință directă a acelui al doilea cuvânt.
Ia un circuit concret de cinci operații, cu și :
Observă că apare de trei ori: în , în și direct în . Fă trecerea backward pe hârtie, de la dreapta la stânga, pornind de la :
Prin adunare
Link către secțiunea: Prin adunare, deci , iar calea directă contribuie cu . Adunarea distribuie gradientul primit neschimbat către ambele intrări.
Prin tanh
Link către secțiunea: Prin tanhcu , deci .
Prin înmulțire
Link către secțiunea: Prin înmulțire, deci și . Înmulțirea inversează: gradientul fiecărei intrări este scalat de valoarea celeilalte intrări.
Adună cele trei căi în x
Link către secțiunea: Adună cele trei căi în xPrin : . Prin : . Direct: .
Ține minte numărul acesta. În câteva pagini, un program îl va produce fără să i se spună nimic din toate acestea.
Construirea motorului
Link către secțiunea: Construirea motoruluiIntuiția care face totul programabil: fiecare dintre pașii aceia a fost local. Ca să împingi un gradient prin nodul de înmulțire, ai nevoie de gradientul primit și de cele două valori de intrare stocate — nimic despre restul circuitului. Fiecare operație știe să se diferențieze pe sine.
Așadar fă un număr care își amintește ce l-a produs.
class Value:
"""A number that remembers where it came from."""
def __init__(self, data, _children=(), _op=""):
self.data = data
self.grad = 0.0
self._backward = lambda: None
self._prev = set(_children)
self._op = _opPatru câmpuri. data este valoarea. grad acumulează . _prev este mulțimea de Value din care a fost calculat acesta — muchiile grafului. Iar _backward este o închidere pe care o instalează fiecare operație: ea știe cum să împingă gradientul acestui nod cu un pas înapoi, către intrările lui.
Fiecare operator urmează aceeași formă: calculează ieșirea, înregistrează părinții, instalează regula locală.
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), "+")
def _backward():
self.grad += out.grad
other.grad += out.grad
out._backward = _backward
return out
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), "*")
def _backward():
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
def tanh(self):
t = math.tanh(self.data)
out = Value(t, (self,), "tanh")
def _backward():
self.grad += (1 - t * t) * out.grad
out._backward = _backward
return out
def relu(self):
out = Value(self.data if self.data > 0 else 0.0, (self,), "relu")
def _backward():
self.grad += (1.0 if out.data > 0 else 0.0) * out.grad
out._backward = _backward
return outCitește cele patru corpuri _backward ca pe un tabel și tiparele de curgere din derivarea pe hârtie sunt chiar acolo:
| operație | ce face cu gradientul |
|---|---|
+ | distribuie — același gradient către fiecare intrare |
* | inversează — fiecare intrare scalată de valoarea celeilalte |
relu | rutează — îl lasă să treacă sau îl blochează complet |
tanh | atenuează — scalează cu , care este cel mult 1 și de obicei mai mic |
Fiecare dintre ele folosește += și niciodată =. Aceasta este regula „adună între căi”, codificată. Un nod care alimentează doi consumatori este apelat de două ori, iar cele două contribuții se adună singure.
Apoi driverul, singura parte care are cunoaștere globală:
def backward(self):
order, seen = [], set()
def build(v):
if v in seen:
return
seen.add(v)
for child in v._prev:
build(child)
order.append(v)
build(self)
self.grad = 1.0
for v in reversed(order):
v._backward() build produce o ordonare topologică a grafului: fiecare nod apare după toate intrările sale. Parcurgerea acelei liste în sens invers garantează că, atunci când apelezi _backward al unui nod, propriul lui gradient este deja complet — fiecare consumator din aval a contribuit deja. Greșește ordinea și împingi înapoi un gradient pe jumătate terminat, ceea ce produce un răspuns greșit fără niciun mesaj de eroare.
Se potrivește cu hârtia?
Link către secțiunea: Se potrivește cu hârtia?x = Value(0.5)
y = Value(1.4)
a = x * y
b = x + y
c = a * b
d = c.tanh()
L = d + x
L.backward()
print(x.grad, y.grad)forward: a=0.7000 b=1.9000 c=1.3300 d=0.8692 L=1.3692
backward: dL/dd=1.0000 dL/dc=0.2444 dL/da=0.4644 dL/db=0.1711
dL/dx=1.8212 dL/dy=0.40331.8212. Același număr, venit dintr-un program căruia i s-a spus regula pentru +, regula pentru *, regula pentru tanh și nimic despre acest circuit.
Două verificări independente, fiindcă „se potrivește cu ce am derivat eu” este un test slab când aceeași persoană le-a făcut pe ambele.
Diferențiere numerică. Mișcă puțin intrarea și măsoară. Diferența centrată estimează derivata fără niciun calcul diferențial:
dL/dx: analytic=1.821202805 numeric=1.821202805 |diff|=1.80e-10
dL/dy: analytic=0.403269235 numeric=0.403269235 |diff|=7.64e-12Față de PyTorch, care are un motor autodiff industrial, scris de oameni care fac asta profesionist:
torch dL/dx=1.821202805316 ours=1.821202805316 |diff|=2.22e-16
torch dL/dy=0.403269234753 ours=0.403269234753 |diff|=1.11e-16Acord la , adică epsilonul mașinii pentru un float pe 64 de biți: cele două motoare efectuează aritmetică identică. Păstrează verificarea numerică la îndemână — este unealta pentru depanarea trecerii backward a unui strat nou și este motivul pentru care un gradient greșit poate fi găsit.
Saturația, măsurată
Link către secțiunea: Saturația, măsuratăAcelași circuit, intrări diferite. Setează și , ceea ce face :
x=0.5, y=1.4: dL/dc = 0.244400 three paths into x: 0.6501 + 0.1711 + 1.0000 = 1.8212
x=2.0, y=-3.0: dL/dc = 0.000025 three paths into x: 0.0001 + -0.0001 + 1.0000 = 0.9999Gradientul care traversează nodul a scăzut cu un factor de 9.945. Tot ce este în amonte de el — într-o rețea reală, fiecare strat dinaintea lui — nu primește practic nimic. Cele două căi prin circuit au amuțit; doar conexiunea directă care sare peste mai poartă semnal.
Aceasta este problema vanishing gradient, într-un singur nod. Stivuiește patruzeci de straturi de și înmulțește patruzeci de astfel de factori, iar straturile timpurii încetează complet să învețe. Este și, întâmplător, un argument pentru conexiunile skip pe care îl poți vedea aici în miniatură: calea care a ocolit neliniaritatea este singura care a supraviețuit.
Ce face de fapt zero_grad și de ce bugul se ascunde
Link către secțiunea: Ce face de fapt zero_grad și de ce bugul se ascundeFiecare _backward folosește +=. Este corect — așa se adună căile. Dar are o consecință care îi prinde pe toți: gradientele se acumulează și între apelurile la backward(). Motorul nu are de unde să știe că al doilea tău apel este un nou pas de antrenare, nu încă o cale în același graf.
Deci o buclă de antrenare trebuie să le golească:
for step in range(steps):
ys = [model(x) for x, _ in DATA]
loss = sum((yp - yt) ** 2 for yp, (_, yt) in zip(ys, DATA))
for p in model.parameters():
p.grad = 0.0
loss.backward()
for p in model.parameters():
p.data -= lr * p.gradAcesta este optimizer.zero_grad() în PyTorch, iar sfatul obișnuit este că, dacă îl uiți, strici antrenarea. Așa că hai să ștergem acele două linii și să vedem cât de stricat este. Aceleași seed-uri, totul la fel, 200 de pași de XOR:
| rată de învățare | seed | cu resetare | fără resetare |
|---|---|---|---|
| 0.05 | 1337 | loss 3.255088, 3/4 | loss 0.000000, 4/4 |
| 0.05 | 7 | loss 2.144820, 2/4 | loss 0.000000, 4/4 |
| 0.05 | 42 | loss 2.126074, 2/4 | loss 0.000000, 4/4 |
| 0.1 | 1337 | loss 0.038597, 4/4 | loss 0.000000, 4/4 |
| 0.1 | 7 | loss 2.055048, 2/4 | loss 0.000000, 4/4 |
| 0.1 | 42 | loss 2.049876, 2/4 | loss 0.000073, 4/4 |
| 0.3 | 1337 | loss 4.512310, 2/4 | loss 8.000000, 2/4 |
| 0.3 | 7 | loss 0.015247, 4/4 | loss 4.000000, 3/4 |
| 0.3 | 42 | loss 0.005478, 4/4 | loss 4.000000, 3/4 |
La ratele mici de învățare, versiunea cu bug câștigă fiecare rând. Converge când versiunea corectă se blochează.
Nu este o întâmplare și merită înțeles, pentru că explică de ce acest bug este atât de greu de prins. Dacă nu golești niciodată gradientul, atunci la pasul parametrul este actualizat cu suma tuturor gradientelor calculate până atunci. Pe o pierdere care continuă să indice aproximativ în aceeași direcție, suma aceea crește constant, iar efectul este o rată de învățare care crește singură. La , unde algoritmul corect se târăște, dimensiunea pasului scăpată de sub control arată exact ca o reparație.
Apoi uită-te la ultimele trei rânduri. La același mecanism face modelul să sară în aer — loss 8.0 este scorul unui model prăbușit la o constantă — jumătate din cele 16 puncte pe care le-ar costa patru răspunsuri maxim greșite — — în timp ce versiunea corectă converge acum curat.
Așadar afirmația onestă nu este „apelează întotdeauna zero_grad sau modelul tău nu se va antrena”. Este: fără el, nu mai rulezi gradient descent. Rulezi ceva a cărui dimensiune a pasului alunecă în sus cu o rată pe care nu a ales-o nimeni, și va părea să funcționeze, uneori mai bine decât lucrul real, până când nu va mai funcționa — moment în care vei da vina pe rata de învățare, inițializare sau date. Aceasta este forma celor mai rele buguri din machine learning: nu se prăbușesc, ci transformă algoritmul într-un alt algoritm care ocazional obține un scor mai bun.
Rețeaua și, în sfârșit, XOR
Link către secțiunea: Rețeaua și, în sfârșit, XORCu motorul gata, o rețea neuronală înseamnă foarte puțin cod. Un neuron este un produs scalar, un bias și o activare; un strat este o listă de neuroni; o rețea este o listă de straturi.
class Neuron:
def __init__(self, nin):
self.w = [Value(random.uniform(-1, 1)) for _ in range(nin)]
self.b = Value(0.0)
def __call__(self, x):
act = sum((wi * xi for wi, xi in zip(self.w, x)), self.b)
return act.tanh()
def parameters(self):
return self.w + [self.b]
class Layer:
def __init__(self, nin, nout):
self.neurons = [Neuron(nin) for _ in range(nout)]
def __call__(self, x):
out = [n(x) for n in self.neurons]
return out[0] if len(out) == 1 else out
def parameters(self):
return [p for n in self.neurons for p in n.parameters()]
class MLP:
def __init__(self, nin, nouts):
sizes = [nin] + nouts
self.layers = [Layer(sizes[i], sizes[i + 1]) for i in range(len(nouts))]
def __call__(self, x):
for layer in self.layers:
x = layer(x)
return x
def parameters(self):
return [p for layer in self.layers for p in layer.parameters()]Nu există nicio trecere backward în nimic din toate acestea. Nici măcar o linie. Clasa Value știe deja să diferențieze orice se întâmplă să construiască aceste clase, iar acesta este scopul pentru care am scris-o prima: un motor autodiff nu știe că este folosit pentru o rețea neuronală.
Acum problema din Capitolul 1. Două intrări, două unități ascunse, o ieșire, nouă parametri:
step 1: loss 4.156690
step 10: loss 4.005572
step 50: loss 3.996708
step 100: loss 3.510700
step 200: loss 0.038597
[0, 0] -> -0.9081 (target -1) ok
[0, 1] -> +0.8934 (target +1) ok
[1, 0] -> +0.8906 (target +1) ok
[1, 1] -> -0.9207 (target -1) okPatru din patru. Funcția pe care niciun perceptron nu o poate calcula — demonstrat în Capitolul 1 prin patru inegalități care cereau ca să fie și pozitiv, și negativ — este calculată de nouă numere găsite automat.
Ce a făcut stratul ascuns
Link către secțiunea: Ce a făcut stratul ascunsPartea satisfăcătoare nu este că funcționează. Este faptul că poți vedea cum, pentru că, având două unități ascunse, reprezentarea intermediară este un punct într-un plan și o poți pur și simplu afișa.
Antrenată până la o pierdere de 0.001241, iată unde ajunge fiecare intrare după stratul ascuns și ce face neuronul de ieșire cu ea:
| intrare | ieșirea stratului ascuns | scor de ieșire | etichetă |
|---|---|---|---|
Uită-te la primul și al patrulea rând. Intrările și sunt colțuri diagonal opuse ale pătratului — la distanța maximă posibilă între două puncte în această problemă — iar stratul ascuns le mapează la și . Aproape același punct. Stratul a pliat planul astfel încât cele două colțuri respinse aterizează unul peste altul, iar odată ce sunt în același loc, o singură linie le separă de celelalte două.
Iar neuronul de ieșire este exact acea linie. Parametrii lui învățați sunt , , deci frontiera lui de decizie este
care este o linie dreaptă — un perceptron, același obiect din Capitolul 1, neschimbat. Atunci nu putea rezolva XOR și nu poate nici acum. Ce s-a schimbat este că nu se mai uită la intrare; se uită la un spațiu pe care primul strat l-a construit pentru el, în care problema este separabilă liniar.
Asta este o reprezentare învățată, și merită să fim preciși pentru că expresia va fi folosită lejer în restul acestui curs și în restul domeniului. Nu este o comprimare, un rezumat sau un embedding în vreun sens mistic. Este o schimbare de coordonate, învățată și nu proiectată, al cărei singur scop este să ușureze munca stratului următor.
Teorema aproximării universale și ce nu spune ea
Link către secțiunea: Teorema aproximării universale și ce nu spune eaExistă o teoremă aici, iar de obicei este citată prost.
Cybenko în 1989 și Hornik în 1991 au demonstrat că o rețea feedforward cu un singur strat ascuns și o funcție de activare potrivită poate aproxima orice funcție continuă pe o mulțime compactă, cu orice precizie dorești, dacă are suficiente unități ascunse.34 Este un rezultat real și important: spune că arhitectura nu este limitarea.
Acum citește ce omite. Nu spune câte unități — limita poate fi astronomic de mare. Nu spune că ponderile pot fi găsite; afirmă existența, iar gradient descent pornind aleator nu este un oracol. Și nu spune nimic despre comportamentul pe date pe care nu le-ai văzut, ceea ce este a doua jumătate a Capitolului 6.
Distanța dintre „există” și „poate fi găsit” nu este academică. Iată aceeași problemă XOR, câte 50 de inițializări aleatorii, 1000 de pași, schimbând doar dimensiunea stratului ascuns:
| unități ascunse | inițializări care ajung la 4/4 |
|---|---|
| 2 | 38 / 50 (76 %) |
| 3 | 49 / 50 (98 %) |
| 4 | 50 / 50 (100 %) |
| 8 | 47 / 50 (94 %) |
Cu arhitectura minim viabilă, una din patru rulări nu ajunge niciodată acolo — se așază într-o configurație din care nu poate coborî, exact minimul local pe care Capitolul 3 l-a arătat pe o suprafață unidimensională. Adaugă o unitate și eșecurile aproape dispar, nu pentru că rețeaua a devenit mai expresivă (două unități sunt deja suficiente — 38 de rulări o dovedesc), ci pentru că dimensiunile suplimentare dau coborârii mai multe direcții prin care să scape.
Și apoi opt unități se descurcă puțin mai rău decât patru. La o rată de învățare și un buget de pași fixe, mai multă capacitate nu este monoton mai bună. Oricine îți spune că soluția pentru o rețea blocată este întotdeauna o rețea mai mare extrapolează din mijlocul acelui tabel.
Este aceeași lecție ca teorema convergenței din Capitolul 1 și va fi aceeași lecție în Capitolul 10 despre legile de scalare, în forma pe care i-o dă acel capitol: o predicție a pierderii nu este o predicție a capabilității pentru care plătești, iar distanța dintre cele două este locul unde trăiește ingineria.
Afișează detaliile
Opțional: forma matricială și de ce codul de mai sus nu o folosește.
Totul aici a fost scris scalar cu scalar, care este cea mai clară modalitate de a vedea mecanismul și cea mai lentă modalitate de a-l executa. În practică, un strat este o înmulțire de matrici, iar trecerea backward pentru este
Transpusele nu sunt un truc de memorat; sunt felul în care arată regula sumei peste căi atunci când căile sunt indexate de intrări de matrice. Obiectul general este Jacobianul, matricea tuturor derivatelor parțiale ale tuturor ieșirilor în raport cu toate intrările, iar modul invers este exact calculul unui produs vector-Jacobian fără a forma vreodată Jacobianul — ceea ce contează, pentru că pentru un strat cu 4096 de intrări și 4096 de ieșiri acea matrice are șaisprezece milioane de intrări și nu merită niciodată construită.
Nu ai nevoie de nimic din toate acestea ca să urmărești capitolele următoare; versiunea scalară face tot ce face versiunea matricială, doar mai lent. Devine necesară în Capitolul 9, unde formele încetează să mai fie evidente.
Încotro mergem de aici
Link către secțiunea: Încotro mergem de aiciAcum ai o rețea care se antrenează. Este o realizare mai mică decât pare, pentru că rețeaua pe care o ai se antrenează pe patru exemple și este măsurată pe aceleași patru.
Rulează același cod pe un set de date real și apare un set nou de probleme, dintre care niciuna nu este despre gradient. Pierderea scade o vreme și apoi se oprește. Sau scade pe datele de antrenare și crește pe orice altceva. Sau nu se mișcă deloc de la primul pas, iar cauza se dovedește a fi intervalul ponderilor aleatorii inițiale. Sau intrarea unei unități a alunecat în negativ pe fiecare exemplu din epoca trei și a fost moartă de atunci, în tăcere, luând cu ea o bucată din capacitatea modelului.
Acestea nu sunt eșecuri exotice; sunt starea normală a unei rețele abia scrise, și niciuna nu se anunță singură. Gradientul este corect — l-ai verificat cu PyTorch până la șaisprezece zecimale — și modelul tot nu învață.
Capitolul 6 este despre asta: inițializare, normalizare, overfitting și regularizare, și obiceiul diagnostic de a întreba care dintre ele se întâmplă înainte să schimbi ceva. Este diferența dintre o rețea care rulează și o rețea care funcționează.
Surse și metodă
Link către secțiunea: Surse și metodăClasa Value din acest capitol coboară direct din micrograd al lui Andrej Karpathy, iar videoclipul lui The spelled-out intro to neural networks and backpropagation: building micrograd este cele mai bune trei ore pe care le poți petrece cu acest material dacă vrei să fie explicat a doua oară de altcineva. Articolul lui din 2016, Yes you should understand backprop, susține argumentul pentru a scrie unul singur și este lectură obligatorie în CS224n de la Stanford. Notele CS231n despre backpropagation (cs231n.github.io/optimization-2) sunt tratamentul canonic al tiparelor de curgere tabelate mai sus. Pentru matematică văzută ca analiză pe un graf, nu ca folclor de rețele neuronale, capitolul 5.6 din Mathematics for Machine Learning de Deisenroth, Faisal și Ong este neobișnuit de clar; iar studiul lui Baydin, Pearlmutter, Radul și Siskind, Automatic Differentiation in Machine Learning: a Survey (arXiv:1502.05767), este referința pentru întregul domeniu, inclusiv compromisul forward/reverse discutat mai sus.
Referințe
Link către secțiunea: Referințe-
Linnainmaa, S. The representation of the cumulative rounding error of an algorithm as a Taylor expansion of the local rounding errors. Teză de master, University of Helsinki (1970). Acumularea în mod invers, cu șaisprezece ani înainte să ajungă în acest domeniu și pornind de la o motivație complet diferită. ↩
-
Rumelhart, D. E., Hinton, G. E. and Williams, R. J. Learning representations by back-propagating errors. Nature 323, pp. 533–536 (1986). Lucrarea care a făcut metoda cunoscută și sursa interpretării unităților ascunse ca reprezentări învățate, pe care secțiunea Ce a făcut stratul ascuns din acest capitol își petrece măsurătorile. ↩
-
Cybenko, G. Approximation by superpositions of a sigmoidal function. Mathematics of Control, Signals and Systems 2, pp. 303–314 (1989). ↩
-
Hornik, K. Approximation capabilities of multilayer feedforward networks. Neural Networks 4(2), pp. 251–257 (1991). Generalizează Cybenko: rezultatul depinde de faptul că activarea este nepolinomială, nu de faptul că este sigmoidală. ↩