Faza 01 · lecția 13

Stabilitate numerică

Scopul lecției: Aritmetica în virgulă mobilă este o abstracție imperfectă. Vă va crea probleme în timpul antrenării, iar momentul nu va fi deloc evident.

Versiunea curentă AlexBred.com: primele 100 de lecții ale programului în limba română.

Curs
AI Engineering from Scratch
Fază
Fundamente matematice
Lectură
29 min.
Verificat
Cuprinsul lecției
  1. Obiective de învățare
  2. Problema
  3. Conceptul
  4. IEEE 754: cum stochează calculatoarele numerele reale
  5. De ce 0,1 + 0,2 != 0,3
  6. Anularea catastrofală
  7. Overflow și underflow
  8. Tehnica log-sum-exp
  9. De ce softmax are nevoie de tehnica scăderii maximului
  10. NaN și Inf: detectare și prevenire
  11. Verificarea numerică a gradienților
  12. Antrenarea cu precizie mixtă
  13. bfloat16 față de float16: de ce bfloat16 este adesea mai bun pentru antrenare
  14. Limitarea gradienților
  15. Straturile de normalizare ca stabilizatori numerici
  16. Erori numerice frecvente în învățarea automată
  17. Construiți soluția
  18. Pasul 1: demonstrați limitele preciziei în virgulă mobilă
  19. Pasul 2: implementați softmax în varianta naivă și cea stabilă
  20. Pasul 3: implementați log-sum-exp stabil
  21. Pasul 4: implementați entropia încrucișată stabilă
  22. Pasul 5: verificați gradienții
  23. Folosiți soluția
  24. Simularea preciziei mixte
  25. Limitarea gradienților
  26. Detectarea valorilor NaN/Inf
  27. Livrați rezultatul
  28. Exerciții
  29. Termeni-cheie
  30. Lecturi suplimentare

Aritmetica în virgulă mobilă este o abstracție imperfectă. Vă va crea probleme în timpul antrenării, iar momentul nu va fi deloc evident.

Tip: Construire Limbaj: Python Cerințe preliminare: Faza 1, lecțiile 01–04 Durată: ~120 de minute

Obiective de învățare

  • Implementați variante stabile numeric pentru softmax și log-sum-exp folosind tehnica scăderii maximului
  • Identificați overflow-ul, underflow-ul și anularea catastrofală în calculele în virgulă mobilă
  • Verificați gradienții analitici prin comparație cu gradienți numerici calculați cu diferențe finite centrate
  • Explicați de ce bfloat16 este preferat în locul float16 pentru antrenare și cum împiedică scalarea funcției de pierdere underflow-ul gradienților

Problema

Modelul dumneavoastră se antrenează timp de trei ore, apoi funcția de pierdere devine NaN. Adăugați o instrucțiune de afișare. Logit-urile arată normal la pasul 9.000. La pasul 9.001 sunt inf. Până la pasul 9.002, fiecare gradient este nan, iar antrenarea s-a oprit definitiv.

Sau: modelul ajunge la finalul antrenării, dar acuratețea este cu 2% sub cea raportată în lucrare. Verificați totul. Arhitectura coincide. Hiperparametrii coincid. Datele coincid. Problema este că autorii lucrării au folosit float32, iar dumneavoastră ați folosit float16 fără scalarea potrivită. Erorile de rotunjire acumulate v-au redus discret acuratețea.

Sau: implementați de la zero funcția de pierdere entropie încrucișată. Funcționează pentru logit-uri mici. Când acestea depășesc 100, returnează inf. Softmax a produs overflow deoarece exp(100) depășește intervalul reprezentabil în float32. Orice cadru software (framework) de învățare automată rezolvă problema printr-o tehnică de două linii. Dumneavoastră nu știați că această tehnică există.

Stabilitatea numerică nu este o preocupare pur teoretică. Ea face diferența dintre o rulare de antrenare reușită și una care eșuează fără semnale evidente. Multe erori dificile din învățarea automată ajung, în cele din urmă, la particularitățile aritmeticii în virgulă mobilă.

Conceptul

IEEE 754: cum stochează calculatoarele numerele reale

Calculatoarele stochează aproximări ale numerelor reale sub formă de valori în virgulă mobilă, conform standardului IEEE 754. Un număr în virgulă mobilă are trei componente: un bit de semn, un exponent și o mantisă (semnificand).

Structura float32 (32 de biți în total):
[1 bit de semn] [8 biți de exponent] [23 de biți de mantisă]

Valoare = (-1)^semn * 2^(exponent - 127) * 1.mantisă

Mantisa determină precizia, adică numărul de cifre semnificative. Exponentul determină intervalul, adică cât de mare sau de mică poate fi o valoare.

Format     Biți   Exponent  Mantisă  Cifre zecimale  Interval (aprox.)
float64    64     11        52       ~15-16          +/- 1.8e308
float32    32     8         23       ~7-8            +/- 3.4e38
float16    16     5         10       ~3-4            +/- 65,504
bfloat16   16     8         7        ~2-3            +/- 3.4e38

float32 vă oferă aproximativ 7 cifre zecimale de precizie. Aceasta înseamnă că poate deosebi 1,0000001 de 1,0000002, dar nu și 1,00000001 de 1,00000002. După 7 cifre, diferențele sunt dominate de zgomotul de rotunjire.

float16 vă oferă aproximativ 3 cifre. Cea mai mare valoare finită pe care o poate reprezenta este 65.504. Acest prag este îngrijorător de mic pentru învățarea automată, unde logit-urile, gradienții și activările pot depăși uneori această valoare.

bfloat16 este răspunsul Google la problema intervalului limitat al formatului float16. Are același exponent de 8 biți ca float32, deci aproximativ același interval până la 3,4e38, dar numai 7 biți expliciți de mantisă, deci o precizie mai mică decât float16. Pentru antrenarea rețelelor neuronale, intervalul este adesea mai important decât precizia suplimentară, astfel încât bfloat16 este frecvent alegerea mai robustă.

De ce 0,1 + 0,2 != 0,3

Numărul 0,1 nu poate fi reprezentat exact în aritmetica binară în virgulă mobilă. În baza 2, el este o fracție periodică:

0.1 în binar = 0.0001100110011001100110011... (repetare la infinit)

float32 trunchiază această dezvoltare la precizia mantisei sale. Valoarea stocată este aproximativ 0,100000001490116. În mod similar, 0,2 este stocat aproximativ ca 0,200000002980232. Suma lor în float32 este 0,300000004470348, nu exact 0,3.

În Python:
>>> 0.1 + 0.2
0.30000000000000004

>>> 0.1 + 0.2 == 0.3
False

Notă tehnică a traducerii: Exemplul interactiv Python de mai sus folosește, în implementările uzuale, numere float cu precizie binară pe 64 de biți, nu float32. Rezultatul ilustrează aceeași imposibilitate de reprezentare exactă, dar aproximațiile zecimale din paragraful anterior sunt cele pentru float32.

Acest fapt contează în învățarea automată deoarece:

  1. Comparații precum if loss < threshold pot produce rezultate neașteptate în apropierea pragului
  2. Acumularea multor valori mici, de exemplu actualizările gradienților pe parcursul a mii de pași, se abate de la suma exactă
  3. Sumele de control și testele de reproductibilitate eșuează dacă comparați numere în virgulă mobilă cu ==

Soluția: nu comparați numere în virgulă mobilă cu == atunci când urmăriți egalitatea numerică aproximativă. Folosiți abs(a - b) < epsilon sau math.isclose() cu toleranțe potrivite problemei.

Anularea catastrofală

Când scădeți două numere în virgulă mobilă aproape egale, cifrele semnificative se anulează, iar zgomotul de rotunjire rămas ajunge în pozițiile dominante.

a = 1.0000001    (stocat ca 1.00000011920929 în float32)
b = 1.0000000    (stocat ca 1.00000000000000 în float32)

Diferența exactă:     0.0000001
Rezultatul calculat:  0.00000011920929

Eroare relativă: 19.2%

Aceasta este o eroare relativă de 19% produsă de o singură scădere. În învățarea automată, fenomenul apare ori de câte ori:

  • Calculați varianța unor date cu medie mare: E[x^2] - E[x]^2, când E[x] este mare
  • Scădeți log-probabilități aproape egale
  • Calculați gradienți prin diferențe finite cu un epsilon prea mic

Soluția: reformulați expresiile pentru a evita scăderea unor numere mari și aproape egale. Pentru varianță, folosiți algoritmul lui Welford sau centrați mai întâi datele. Pentru log-probabilități, efectuați toate calculele în spațiul logaritmic.

Overflow și underflow

Overflow-ul apare când rezultatul este prea mare pentru a putea fi reprezentat. Underflow-ul apare când rezultatul este prea mic, adică mai apropiat de zero decât cea mai mică valoare pozitivă reprezentabilă.

Limitele float32:
  Maxim:  3.4028235e+38
  Minim pozitiv normal:    1.175e-38
  Minim pozitiv subnormal: 1.401e-45
  Overflow:  orice rezultat > 3.4e38 devine inf
  Underflow: valorile suficient de mici pot deveni subnormale sau 0.0

Funcția exp() este principala sursă de overflow în învățarea automată:

exp(88.7)  = 3.40e+38   (încape la limită în float32)
exp(89.0)  = inf         (overflow)
exp(-87.3) = 1.18e-38   (puțin peste intervalul subnormal)
exp(-104)  = 0.0         (underflow la zero)

Funcția log() întâmpină probleme în direcția opusă:

log(0.0)   = -inf
log(-1.0)  = nan
log(1e-45) = -103.3      (rezultat finit)
log(1e-46) = -inf        (intrarea a devenit 0 prin underflow, apoi log(0) = -inf)

În învățarea automată, exp() apare în softmax, sigmoidă și calculele de probabilitate. log() apare în entropia încrucișată, log-verosimilități și divergența KL. Combinația log(exp(x)) este plină de capcane dacă nu aplicați tehnicile potrivite.

Tehnica log-sum-exp

Calculul direct al expresiei log(sum(exp(x_i))) este periculos numeric. Dacă un x_i este mare, exp(x_i) produce overflow. Dacă toate valorile x_i sunt foarte negative, fiecare exp(x_i) poate produce underflow la zero, iar log(0) devine -inf.

Tehnica constă în scăderea valorii maxime înainte de exponențiere.

log(sum(exp(x_i))) = max(x) + log(sum(exp(x_i - max(x))))

De ce funcționează: după scăderea lui max(x), cel mai mare exponent este exp(0) = 1. Niciun termen exponențial nu mai poate produce overflow. Cel puțin un termen al sumei este 1, deci suma este cel puțin 1, iar log(1) = 0. Astfel, suma nu mai ajunge la zero prin underflow, iar rezultatul nu devine -inf din acest motiv.

Demonstrație:

log(sum(exp(x_i)))
= log(sum(exp(x_i - c + c)))                    (adăugați și scădeți c)
= log(sum(exp(x_i - c) * exp(c)))               (exp(a+b) = exp(a)*exp(b))
= log(exp(c) * sum(exp(x_i - c)))               (scoateți exp(c) în factor comun)
= c + log(sum(exp(x_i - c)))                    (log(a*b) = log(a) + log(b))

Alegeți c = max(x), iar overflow-ul exponențierii este eliminat.

Această tehnică apare peste tot în învățarea automată:

  • Normalizarea softmax
  • Calculul funcției de pierdere entropie încrucișată
  • Însumarea log-probabilităților în modelele secvențiale
  • Amestecuri de distribuții gaussiene
  • Inferență variațională

De ce softmax are nevoie de tehnica scăderii maximului

Softmax transformă logit-urile în probabilități:

softmax(x_i) = exp(x_i) / sum(exp(x_j))

Fără această tehnică, logit-urile [100, 101, 102] produc overflow în float32:

exp(100) = 2.69e43
exp(101) = 7.31e43
exp(102) = 1.99e44
sumă     = 2.99e44

Aceste valori depășesc limita float32, deoarece 2.69e43 > 3.4e38.
Valoarea exp(88.7) se află deja la limita float32.
Prin urmare, exp(100) = inf în float32.

Notă tehnică a traducerii: Blocul original conține inegalitatea autocontradictorie 2.69e43 < 3.4e38. Sensul corect este 2.69e43 > 3.4e38; explicația a fost corectată în traducere.

Cu această tehnică, scădeți max(x) = 102:

exp(100 - 102) = exp(-2) = 0.135
exp(101 - 102) = exp(-1) = 0.368
exp(102 - 102) = exp(0)  = 1.000
sumă = 1.503

softmax = [0.090, 0.245, 0.665]

Probabilitățile sunt identice din punct de vedere matematic. Calculul este sigur. Aceasta nu este o optimizare opțională, ci o cerință pentru corectitudine numerică.

NaN și Inf: detectare și prevenire

nan (Not a Number, „nu este un număr”) și inf (infinit) se propagă agresiv prin calcule. Un singur nan într-o actualizare a gradientului transformă ponderea în nan, iar aceasta face ca toate ieșirile ulterioare dependente de ea să devină nan. Antrenarea poate fi compromisă într-un singur pas.

Cum apare inf:

  • exp() aplicat unei valori pozitive mari
  • Împărțirea la zero în sisteme care urmează semantica IEEE 754 pentru această operație: 1.0 / 0.0
  • Overflow în acumulări float32

Cum apare nan:

  • 0.0 / 0.0
  • inf - inf
  • inf * 0
  • sqrt() aplicat unui număr negativ în aritmetica reală
  • log() aplicat unui număr negativ în aritmetica reală
  • Orice operație aritmetică ce implică deja un nan

Detectare:

import math

math.isnan(x)       # True if x is nan
math.isinf(x)       # True if x is +inf or -inf
math.isfinite(x)    # True if x is neither nan nor inf

Strategii de prevenire:

  1. Dacă domeniul problemei permite, limitați intrările pentru exp(), de exemplu exp(clamp(x, -80, 80))
  2. Stabilizați numitorii printr-o formulă adecvată domeniului; uneori se folosește x / (y + epsilon), dar trebuie controlate semnul și scara lui y
  3. Pentru logaritmi, impuneți explicit domeniul pozitiv sau folosiți o formulare stabilă; log(x + epsilon) este potrivit numai când această regularizare are sens pentru problemă
  4. Folosiți implementări stabile, precum log-sum-exp și softmax stabil
  5. Aplicați limitarea gradienților pentru a preveni explozia ponderilor
  6. În timpul depanării, verificați prezența valorilor nan/inf după fiecare trecere înainte

Notă tehnică a traducerii: Originalul prezintă limitarea și adăugarea constantei 1e-8 drept remedii universale. Aceste intervenții pot modifica funcția calculată, iar y + 1e-8 poate fi în continuare zero. Constanta, domeniul și formula trebuie alese în funcție de tipul numeric și de semnificația mărimilor.

Notă tehnică a traducerii: Comportamentul exact al operațiilor invalide depinde de mediul numeric. De exemplu, funcțiile math.log(0.0) și math.sqrt(-1.0) din CPython ridică în mod obișnuit ValueError, iar math.exp() ridică OverflowError când rezultatul depășește intervalul tipului Python float; biblioteci vectorizate precum NumPy pot returna în schimb valori IEEE -inf, nan sau inf și avertismente.

Verificarea numerică a gradienților

Gradienții analitici calculați prin propagare înapoi pot conține erori de implementare. Verificarea numerică a gradienților îi validează prin aproximări cu diferențe finite.

Formula diferenței centrate:

df/dx ~= (f(x + h) - f(x - h)) / (2h)

Aceasta are o eroare de trunchiere de ordinul O(h²), mult mai bună decât diferența înainte (f(x+h) - f(x)) / h, care are o eroare de ordinul O(h).

Alegerea lui h: dacă este prea mare, aproximația este inexactă. Dacă este prea mic, anularea catastrofală distruge rezultatul. Valorile între h = 1e-5 și 1e-7 sunt uzuale, dar alegerea potrivită depinde de precizia tipului și de scara problemei.

Verificarea constă în calcularea diferenței relative dintre gradientul analitic și cel numeric.

relative_error = |grad_analytical - grad_numerical| / max(|grad_analytical|, |grad_numerical|, 1e-8)

Reguli orientative:

  • relative_error < 1e-7: concordanță excelentă; gradientul este foarte probabil corect
  • relative_error < 1e-5: rezultat acceptabil; gradientul este probabil corect
  • relative_error > 1e-3: probabil există o problemă
  • relative_error > 1: gradientul este complet greșit

Verificați întotdeauna gradienții când implementați un strat sau o funcție de pierdere nouă. PyTorch oferă torch.autograd.gradcheck() în acest scop.

Antrenarea cu precizie mixtă

GPU-urile moderne au hardware specializat, precum Tensor Cores, care poate accelera considerabil înmulțirile de matrice cu precizie redusă față de float32. Antrenarea cu precizie mixtă exploatează acest avantaj:

1. Păstrați o copie principală a ponderilor în float32
2. Efectuați trecerea înainte în float16 (rapid)
3. Calculați funcția de pierdere în float32 (reduce riscul de overflow)
4. Efectuați trecerea înapoi folosind operații cu precizie mixtă
5. Readuceți gradienții scalați la scara inițială
6. Actualizați ponderile principale în float32

Notă tehnică a traducerii: Lista de mai sus este o schemă simplificată și conține o formulare înșelătoare la pasul 5. Implementările AMP moderne aleg tipul separat pentru fiecare operație: unele rulează în float16 sau bfloat16, iar cele sensibile numeric în float32. În cazul float16, se scalează funcția de pierdere înainte de propagarea înapoi, apoi gradienții sunt readuși la scara inițială înaintea actualizării; simpla „scalare a gradienților la float32” nu descrie corect mecanismul.

Problema antrenării exclusiv în float16: gradienții sunt deseori foarte mici, de ordinul 1e-8 sau mai puțin. Cea mai mică valoare subnormală pozitivă float16 este aproximativ 5,96e-8, iar valorile suficient de mici se rotunjesc la zero; unele acceleratoare pot elimina și valorile subnormale. Modelul încetează să învețe dacă actualizările gradienților ajung toate la zero.

Notă tehnică a traducerii: Originalul afirmă că orice valoare sub aproximativ 6e-8 devine zero. Pragul 5,96e-8 este cea mai mică valoare subnormală pozitivă reprezentabilă, nu o frontieră universală și exactă de rotunjire la zero; rezultatul depinde de valoare, de modul de rotunjire și de tratarea subnormalelor de către hardware.

Soluția este scalarea funcției de pierdere:

1. Înmulțiți funcția de pierdere cu un factor mare, de exemplu 1024
2. Trecerea înapoi calculează gradienții expresiei (pierdere * 1024)
3. Gradienții sunt de 1024 de ori mai mari și evită mai ușor underflow-ul float16
4. Împărțiți gradienții la 1024 înainte de actualizarea ponderilor
5. Efectul matematic net este aceeași actualizare, cu un risc mai mic de underflow

Scalarea dinamică a funcției de pierdere ajustează automat factorul. Într-o schemă posibilă, porniți de la o valoare mare, precum 65.536. Dacă gradienții produc overflow la inf, reduceți-o la jumătate. Dacă trec N pași fără overflow, o puteți mări. Strategia concretă este gestionată de cadrul software.

bfloat16 față de float16: de ce bfloat16 este adesea mai bun pentru antrenare

float16:   [1 bit de semn] [5 biți de exponent]  [10 biți de mantisă]
bfloat16:  [1 bit de semn] [8 biți de exponent]  [7 biți de mantisă]

float16 are precizie mai mare, cu 10 biți expliciți de mantisă față de 7, dar un interval limitat, cu maximul în jur de 65.504. bfloat16 are precizie mai mică, însă aproximativ același interval ca float32, cu maximul în jur de 3,4e38.

Pentru antrenarea rețelelor neuronale:

  • Activările și logit-urile pot depăși 65.504 în timpul vârfurilor de antrenare. float16 produce overflow, în timp ce bfloat16 poate reprezenta asemenea ordine de mărime.
  • Scalarea funcției de pierdere este folosită frecvent cu float16, dar este de obicei mai puțin necesară cu bfloat16, deoarece intervalul său acoperă valori mult mai mici și mai mari.
  • Structura bfloat16 păstrează câmpul de exponent al formatului float32 și elimină 16 biți de precizie din semnificand, ceea ce simplifică conversia.

Notă tehnică a traducerii: Conversia reală din float32 în bfloat16 nu este obligatoriu o simplă trunchiere. De exemplu, TPU-urile Google folosesc rotunjirea la cel mai apropiat număr par. Funcția de simulare din secțiunea practică trunchiază intenționat biții și trebuie înțeleasă ca o aproximație didactică.

float16 poate fi preferat pentru unele sarcini de inferență în care valorile sunt controlate și precizia suplimentară a mantisei contează. bfloat16 este adesea preferat pentru antrenare, unde intervalul mai larg poate fi decisiv. Alegerea depinde însă de model, hardware și nucleele disponibile. TPU-urile și GPU-urile NVIDIA moderne, precum A100 și H100, oferă suport nativ pentru bfloat16.

Limitarea gradienților

Explozia gradienților apare când aceștia cresc exponențial de-a lungul multor straturi, fenomen întâlnit în RNN-uri, rețele adânci și modele Transformer. Un singur gradient foarte mare poate corupe toate ponderile într-un singur pas.

Există două tipuri de limitare:

Limitare după valoare: restrângeți independent fiecare element al gradientului.

grad = clamp(grad, -max_val, max_val)

Este simplă, dar poate modifica direcția vectorului gradient.

Limitare după normă: scalați întregul vector gradient astfel încât norma sa să nu depășească un prag.

if ||grad|| > max_norm:
    grad = grad * (max_norm / ||grad||)

Această metodă păstrează direcția gradientului. Este comportamentul funcției torch.nn.utils.clip_grad_norm_() și reprezintă alegerea standard în multe aplicații.

Valori inițiale întâlnite des sunt max_norm=1.0 pentru modele Transformer, max_norm=0.5 pentru învățare prin întărire și max_norm=5.0 pentru rețele mai simple, dar pragul trebuie validat pentru arhitectura și regimul de antrenare concrete.

Limitarea gradienților nu este doar un artificiu. Este un mecanism de siguranță care poate împiedica un singur lot atipic să genereze un gradient suficient de mare pentru a compromite antrenarea.

Straturile de normalizare ca stabilizatori numerici

Normalizarea pe loturi, normalizarea pe strat și normalizarea RMS sunt prezentate de obicei ca tehnici care îmbunătățesc optimizarea și convergența. Ele pot acționa și ca stabilizatori numerici.

Notă tehnică a traducerii: Originalul le numește generic „regularizatori”. Deși normalizarea pe loturi poate avea în anumite regimuri un efect de regularizare, normalizarea pe strat și RMSNorm nu sunt, în general, metode de regularizare; rolul lor principal este normalizarea activărilor și îmbunătățirea dinamicii optimizării.

Fără normalizare, activările pot crește sau scădea exponențial de-a lungul straturilor:

Stratul 1:  valori în [0, 1]
Stratul 5:  valori în [0, 100]
Stratul 10: valori în [0, 10,000]
Stratul 50: valori în [0, inf]

Normalizarea recentrează și rescalează activările la fiecare strat:

LayerNorm(x) = (x - medie(x)) / sqrt(varianță(x) + epsilon) * gamma + beta

Notă tehnică a traducerii: Formula originală adună epsilon după calcularea abaterii standard. Implementările uzuale LayerNorm folosesc sqrt(varianță(x) + epsilon), adică adaugă termenul de stabilizare la varianță înainte de extragerea rădăcinii pătrate.

Valoarea epsilon, de obicei 1e-5, previne împărțirea la zero când toate activările sunt identice. Parametrii învățați gamma și beta permit rețelei să restabilească scara de care are nevoie.

Astfel, valorile rămân într-un interval numeric mai sigur de-a lungul rețelei, reducând riscul de overflow în trecerea înainte și de explozie a gradienților în trecerea înapoi.

Erori numerice frecvente în învățarea automată

Eroare: funcția de pierdere devine NaN după câteva epoci. Cauză: logit-urile au crescut prea mult, iar softmax a produs overflow. Sau rata de învățare este prea mare, iar ponderile au început să diverge. Soluție: folosiți softmax stabil, cu scăderea maximului, reduceți rata de învățare și adăugați limitarea gradienților.

Eroare: funcția de pierdere rămâne blocată la log(num_classes). Cauză: modelul produce probabilități aproape uniforme. Aceasta înseamnă adesea că gradienții dispar sau că modelul nu învață deloc. Soluție: verificați corectitudinea etichetelor, validați funcția de pierdere și căutați neuroni ReLU inactivi.

Eroare: acuratețea de validare este cu 1–3% sub valoarea așteptată. Cauză: precizie mixtă fără o scalare adecvată a funcției de pierdere. Underflow-ul gradienților transformă discret în zero actualizările mici. Soluție: activați scalarea dinamică a funcției de pierdere sau treceți la bfloat16 dacă hardware-ul și modelul îl acceptă.

Eroare: normele gradienților sunt 0,0 pentru unele straturi. Cauză: neuroni ReLU inactivi, ale căror intrări sunt toate negative, sau underflow în float16. Soluție: folosiți LeakyReLU sau GELU, aplicați scalarea funcției de pierdere și verificați inițializarea ponderilor.

Eroare: modelul funcționează pe un GPU, dar produce rezultate diferite pe altul. Cauză: ordinea nedeterministă de acumulare în virgulă mobilă. Reducerile paralele pe GPU pot însuma valorile în ordine diferite pe hardware diferit, iar adunarea în virgulă mobilă nu este asociativă. Soluție: acceptați diferențe mici, de exemplu 1e-6 dacă toleranța aplicației o permite. Într-un mediu software și hardware fix, torch.use_deterministic_algorithms(True) poate reduce sursele de nedeterminism, dar trebuie controlate și semințele, bibliotecile și celelalte setări relevante.

Notă tehnică a traducerii: Originalul sugerează că activarea algoritmilor deterministici rezolvă diferențele dintre GPU-uri. PyTorch precizează că această setare nu este suficientă pentru reproductibilitate completă și că rezultatele identice nu sunt garantate între versiuni, platforme sau execuții CPU/GPU.

Eroare: exp() returnează inf în calculul funcției de pierdere. Cauză: logit-urile brute au fost transmise funcției exp() fără tehnica scăderii maximului. Soluție: folosiți torch.nn.functional.log_softmax(), care aplică intern o formulare stabilă bazată pe log-sum-exp.

Eroare: antrenarea diverge după trecerea de la float32 la float16. Cauză: float16 nu poate reprezenta valori suficient de mici ale gradienților sau activări peste 65.504. Soluție: folosiți precizie mixtă cu scalarea funcției de pierdere, de exemplu AMP, sau utilizați bfloat16.

logsumexp-stability

Construiți soluția

Pasul 1: demonstrați limitele preciziei în virgulă mobilă

print("=== Floating Point Precision ===")
print(f"0.1 + 0.2 = {0.1 + 0.2}")
print(f"0.1 + 0.2 == 0.3? {0.1 + 0.2 == 0.3}")
print(f"Difference: {(0.1 + 0.2) - 0.3:.2e}")

Pasul 2: implementați softmax în varianta naivă și cea stabilă

import math

def softmax_naive(logits):
    exps = [math.exp(z) for z in logits]
    total = sum(exps)
    return [e / total for e in exps]

def softmax_stable(logits):
    max_logit = max(logits)
    exps = [math.exp(z - max_logit) for z in logits]
    total = sum(exps)
    return [e / total for e in exps]

safe_logits = [2.0, 1.0, 0.1]
print(f"Naive:  {softmax_naive(safe_logits)}")
print(f"Stable: {softmax_stable(safe_logits)}")

dangerous_logits = [100.0, 101.0, 102.0]
print(f"Stable: {softmax_stable(dangerous_logits)}")
# softmax_naive(dangerous_logits) would return [nan, nan, nan]

Notă tehnică a traducerii: Comentariul din blocul executabil descrie comportamentul în float32, însă math.exp() operează cu tipul Python float, de regulă binary64. Pentru valorile 100–102, varianta naivă nu produce overflow în acest mediu. Pentru a reproduce problema trebuie să forțați calculele în float32; valorile suficient de mari pentru a depăși binary64 fac ca math.exp() să ridice OverflowError, nu să returneze lista indicată. Blocul a rămas neschimbat pentru fidelitate față de sursă.

Pasul 3: implementați log-sum-exp stabil

def logsumexp_naive(values):
    return math.log(sum(math.exp(v) for v in values))

def logsumexp_stable(values):
    c = max(values)
    return c + math.log(sum(math.exp(v - c) for v in values))

safe = [1.0, 2.0, 3.0]
print(f"Naive:  {logsumexp_naive(safe):.6f}")
print(f"Stable: {logsumexp_stable(safe):.6f}")

large = [500.0, 501.0, 502.0]
print(f"Stable: {logsumexp_stable(large):.6f}")
# logsumexp_naive(large) returns inf

Notă tehnică a traducerii: Și aici comentariul final presupune float32. În Python obișnuit, math.exp(502.0) este încă finit în binary64, astfel încât funcția naivă nu returnează inf pentru această intrare. Formularea stabilă rămâne necesară pentru intrări mai mari și pentru tipuri cu interval mai restrâns.

Pasul 4: implementați entropia încrucișată stabilă

def cross_entropy_naive(true_class, logits):
    probs = softmax_naive(logits)
    return -math.log(probs[true_class])

def cross_entropy_stable(true_class, logits):
    max_logit = max(logits)
    shifted = [z - max_logit for z in logits]
    log_sum_exp = math.log(sum(math.exp(s) for s in shifted))
    log_prob = shifted[true_class] - log_sum_exp
    return -log_prob

logits = [2.0, 5.0, 1.0]
true_class = 1
print(f"Naive:  {cross_entropy_naive(true_class, logits):.6f}")
print(f"Stable: {cross_entropy_stable(true_class, logits):.6f}")

Pasul 5: verificați gradienții

def numerical_gradient(f, x, h=1e-5):
    grad = []
    for i in range(len(x)):
        x_plus = x[:]
        x_minus = x[:]
        x_plus[i] += h
        x_minus[i] -= h
        grad.append((f(x_plus) - f(x_minus)) / (2 * h))
    return grad

def check_gradient(analytical, numerical, tolerance=1e-5):
    for i, (a, n) in enumerate(zip(analytical, numerical)):
        denom = max(abs(a), abs(n), 1e-8)
        rel_error = abs(a - n) / denom
        status = "OK" if rel_error < tolerance else "FAIL"
        print(f"  param {i}: analytical={a:.8f} numerical={n:.8f} "
              f"rel_error={rel_error:.2e} [{status}]")

def f(params):
    x, y = params
    return x**2 + 3*x*y + y**3

def f_grad(params):
    x, y = params
    return [2*x + 3*y, 3*x + 3*y**2]

point = [2.0, 1.0]
analytical = f_grad(point)
numerical = numerical_gradient(f, point)
check_gradient(analytical, numerical)

Folosiți soluția

Simularea preciziei mixte

import struct

def float32_to_float16_round(x):
    packed = struct.pack('f', x)
    f32 = struct.unpack('f', packed)[0]
    packed16 = struct.pack('e', f32)
    return struct.unpack('e', packed16)[0]

def simulate_bfloat16(x):
    packed = struct.pack('f', x)
    as_int = int.from_bytes(packed, 'little')
    truncated = as_int & 0xFFFF0000
    repacked = truncated.to_bytes(4, 'little')
    return struct.unpack('f', repacked)[0]

Limitarea gradienților

def clip_by_norm(gradients, max_norm):
    total_norm = math.sqrt(sum(g**2 for g in gradients))
    if total_norm > max_norm:
        scale = max_norm / total_norm
        return [g * scale for g in gradients]
    return gradients

grads = [10.0, 20.0, 30.0]
clipped = clip_by_norm(grads, max_norm=5.0)
print(f"Original norm: {math.sqrt(sum(g**2 for g in grads)):.2f}")
print(f"Clipped norm:  {math.sqrt(sum(g**2 for g in clipped)):.2f}")
print(f"Direction preserved: {[c/clipped[0] for c in clipped]} == {[g/grads[0] for g in grads]}")

Detectarea valorilor NaN/Inf

def check_tensor(name, values):
    has_nan = any(math.isnan(v) for v in values)
    has_inf = any(math.isinf(v) for v in values)
    if has_nan or has_inf:
        print(f"WARNING {name}: nan={has_nan} inf={has_inf}")
        return False
    return True

check_tensor("good", [1.0, 2.0, 3.0])
check_tensor("bad",  [1.0, float('nan'), 3.0])
check_tensor("ugly", [1.0, float('inf'), 3.0])

Consultați code/numerical.py pentru implementările complete, care demonstrează toate cazurile-limită.

Livrați rezultatul

Această lecție produce:

  • code/numerical.py, cu softmax stabil, log-sum-exp, entropie încrucișată, verificarea gradienților și simularea preciziei mixte
  • outputs/prompt-numerical-debugger.md, pentru diagnosticarea valorilor NaN/Inf și a problemelor numerice din timpul antrenării

Aceste implementări stabile reapar în Faza 3, când construiți bucla de antrenare, și în Faza 4, când implementați mecanismele de atenție.

Exerciții

  1. Anulare catastrofală. Calculați varianța valorilor [1000000.0, 1000001.0, 1000002.0] prin formula naivă E[x^2] - E[x]^2 în float32. Apoi calculați-o prin algoritmul online al lui Welford. Comparați erorile cu varianța reală, 0,6667.

  2. Explorarea preciziei. Determinați epsilonul mașinii pentru float32 ca diferența dintre 1.0 și următorul număr float32 reprezentabil mai mare decât 1.0. Verificați că valoarea coincide cu numpy.finfo(numpy.float32).eps.

Notă tehnică a traducerii: Cerința originală solicită „cea mai mică valoare pozitivă x astfel încât 1.0 + x == 1.0” și o numește epsilonul mașinii. Această definiție este greșită: există numeroase valori pozitive care se rotunjesc astfel, iar epsilonul este diferența dintre 1 și următoarea valoare reprezentabilă mai mare decât 1. Exercițiul a fost corectat în consecință.

  1. Cazuri-limită pentru log-sum-exp. Testați funcția logsumexp_stable cu: (a) toate valorile egale, (b) o valoare mult mai mare decât restul și (c) toate valorile foarte negative, de exemplu -1000. Verificați că oferă rezultate corecte acolo unde varianta naivă eșuează.

  2. Verificarea gradienților unui strat de rețea neuronală. Implementați un singur strat liniar y = Wx + b și trecerea sa analitică înapoi. Folosiți numerical_gradient pentru a verifica rezultatul în cazul unei matrice de ponderi 3×2.

  3. Experiment cu scalarea funcției de pierdere. Simulați antrenarea în float16: creați gradienți aleatori în intervalul [1e-9, 1e-3], convertiți-i la float16 și măsurați proporția celor care devin zero. Apoi aplicați scalarea funcției de pierdere, înmulțind cu 1024, convertiți la float16, reveniți la scara inițială și măsurați din nou proporția valorilor nule.

Termeni-cheie

Termen Cum este numit în conversații Ce înseamnă de fapt
IEEE 754 „Standardul pentru float” Standardul internațional care definește formatele binare în virgulă mobilă, regulile de rotunjire și valorile speciale inf și nan. Este implementat de procesoarele și GPU-urile moderne.
Epsilonul mașinii „Limita preciziei” Diferența dintre 1,0 și următorul număr reprezentabil mai mare decât 1,0 într-un format dat. Pentru float32, este aproximativ 1,19e-7.
Anulare catastrofală „Pierderea preciziei prin scădere” Când scădeți numere în virgulă mobilă aproape egale, cifrele semnificative se anulează, iar zgomotul de rotunjire domină rezultatul.
Overflow „Număr prea mare” Un rezultat depășește valoarea maximă reprezentabilă și devine inf în mediile care folosesc acest comportament IEEE. exp(89) produce overflow în float32.
Underflow „Număr prea mic” Un rezultat este atât de mic încât intră în domeniul subnormal, cu precizie redusă, sau se rotunjește la 0.0. exp(-104) se rotunjește la zero în float32.
Tehnica log-sum-exp „Scădeți mai întâi maximul” Calcularea expresiei log(sum(exp(x))) prin factorizarea lui exp(max(x)), pentru a preveni overflow-ul și underflow-ul. Este folosită în softmax, entropie încrucișată și calculele cu log-probabilități.
Softmax stabil „Softmax care nu explodează” Scăderea valorii maxime a logit-urilor înainte de exponențiere. Rezultatul este identic matematic, iar termenii exponențiali nu produc overflow.
Verificarea gradienților „Verificați propagarea înapoi” Compararea gradienților analitici din propagarea înapoi cu gradienții numerici obținuți prin diferențe finite, pentru a detecta erori de implementare.
Precizie mixtă „Float16 înainte, float32 înapoi” Folosirea tipurilor cu precizie redusă pentru operațiile care beneficiază de viteză și a tipurilor cu precizie mai mare pentru operațiile sensibile numeric; împărțirea concretă este stabilită pe operații de implementarea AMP. Accelerarea depinde de model și hardware.
Scalarea funcției de pierdere „Preveniți underflow-ul gradienților” Înmulțirea funcției de pierdere cu o constantă mare înainte de propagarea înapoi, astfel încât gradienții să rămână în intervalul reprezentabil al float16, urmată de împărțirea la aceeași constantă înaintea actualizării ponderilor.
bfloat16 „Brain floating point” Formatul Google pe 16 biți, cu 8 biți de exponent, deci aproximativ același interval ca float32, și 7 biți expliciți de mantisă, deci o precizie mai mică decât float16. Este adesea preferat pentru antrenare.
Limitarea gradienților „Plafonați norma gradientului” Scalarea vectorului gradient astfel încât norma sa să nu depășească un prag. Împiedică gradienții explozivi să compromită ponderile.
NaN „Not a Number” Valoare specială în virgulă mobilă rezultată din operații nedefinite, precum 0/0, inf-inf sau sqrt(-1) în sisteme cu semantică IEEE. Se propagă prin calculele ulterioare.
Inf „Infinit” Valoare specială produsă de overflow sau, în sisteme cu semantică IEEE, de împărțirea la zero. Se poate combina cu alte valori pentru a produce NaN, de exemplu inf - inf și inf * 0.
Gradient numeric „Derivată prin forță brută” Aproximarea unei derivate prin evaluarea lui f(x+h) și f(x-h) și împărțirea diferenței la 2h. Este lentă, dar utilă pentru verificare.

Lecturi suplimentare


Sursă: Numerical Stability — original

Navigare: înapoi: 01.12 — Operații cu tensori · Faza 1 — Fundamente matematice · Catalog complet · în continuare: 01.14 — Norme și distanțe.