15. Aritmetică în virgulă mobilă: probleme și limitări¶
Numerele în virgulă mobilă (de la englezescul floating-point) sunt reprezentate în mașina fizică de calcul ca fracții scrise în baza de numerație 2 (fracții binare). Mai precis, fracția zecimală (adică, scrisă în baza de numerație 10) 0.625
are valoarea (exprimată cu puteri de exponent negativ ale lui 10) 6/10 + 2/100 + 5/1000 în timp ce, folosind același tip de exprimare cu puteri de exponent negativ, fracția binară 0.101
are valoarea 1/2 + 0/4 + 1/8. Acestor două fracții le corespunde, dpdv. numeric, aceeași valoare, singura deosebire dintre ele fiind aceea că prima a fost scrisă cu notații fracționare ale bazei de numerație 10 pe când cea de-a doua cu notații ale bazei de numerație 2.
Din păcate, cele mai multe fracții zecimale nu pot fi reprezentate exact sub formă de fracții binare. O consecință a acestui fapt este că, în general, numerele zecimale în virgulă mobilă pe care le tastați sunt doar aproximații ale numerelor binare în virgulă mobilă care vor fi stocate în mașina de calcul.
Dificultatea este mai ușor de înțeles dacă o abordăm pornind din baza 10. Să luăm în considerare fracția 1/3. O putem aproxima ca fracție în baza 10
0.3
sau, și mai bine,
0.33
sau, și mai bine,
0.333
șamd. Oricât de multe cifre ne-am strădui să tastăm, rezultatul nu va fi niciodată exact 1/3, chiar dacă vom ajunge la aproximații tot mai bune ale lui 1/3.
La fel, oricât de multe cifre în baza de numerație 2 ne-am strădui să întrebuințăm, valoarea zecimală 0.1 nu va putea fi reprezentată exact ca fracție în baza 2. Pentru că, în baza 2, 1/10 este fracția (periodică mixtă) cu număr infinit de cifre la dreapta punctului
0.0001100110011001100110011001100110011001100110011...
Dacă de oprim, din tastat, după indiferent câți biți (cifre binare), tot ce obținem este o aproximație. Pe majoritatea mașinilor de calcul din zilele noastre, numerele în virgulă mobilă (în englezește, ca jargon, ele sunt denumite float-uri) sunt aproximate folosind o fracție binară al cărei numărător utilizează primii 53 de biți, începând cu bitul cel mai semnificativ, iar al cărei numitor este o putere a lui 2. În cazul lui 1/10, fracția binară este 3602879701896397 / 2 ** 55
, adică foarte aproape de valoarea exactă a lui 1/10 însă nu chiar egală cu ea.
Mulți utilizatori nu sesizează aproximația datorită modului în care valorile numerice sunt afișate. Astfel, Python-ul va printa doar o aproximație zecimală a valorii zecimale precise pe care o are aproximarea binară stocată în mașina de calcul. Pe cele mai multe din mașinile de calcul, dacă ar trebui să afișeze valoarea zecimală precisă a aproximării binare stocate a lui 0.1, atunci Python-ul ar avea de afișat:
>>> 0.1
0.1000000000000000055511151231257827021181583404541015625
Dat fiind că au apărut mai multe cifre decât le sunt de folos majorității utilizatorilor, Python-ul menține numărul cifrelor la valori ușor de manageriat afișând, în loc de cele de mai sus, o valoare rotunjită:
>>> 1 / 10
0.1
Să reținem, așadar, că, în pofida faptului că rezultatul afișat arată la fel ca valoarea exactă a lui 1/10, valoarea efectiv stocată este cea mai apropiată (de rezultat) fracție binară pe care mașina de calcul o poate reprezenta.
Ca o curiozitate, există mai multe numere zecimale (așadar, diferite) cu aceeași cea mai bună aproximație printr-o fracție binară. De exemplu, numerele 0.1
și 0.10000000000000001
, precum și 0.1000000000000000055511151231257827021181583404541015625
sunt cu toatele aproximate de 3602879701896397 / 2 ** 55
. Dat fiind că toate aceste valori zecimale au aceeași aproximație, oricare din ele ar putea fi afișată, atunci când se cere, păstrându-se invariantul eval(repr(x)) == x
.
În trecut, funcția predefinită a Python-ului repr()
ar fi ales numărul cu 17 cifre semnificative, și anume pe 0.10000000000000001
. În zilele noastre, începând cu Python 3.1, Python-ul este capabil (pe majoritatea mașinilor de calcul) să aleagă numărul cel mai scurt și să afișeze, pur și simplu, 0.1
.
Rețineți, deci, că situația descrisă mai sus ține de însăși natura numerelor binare în virgulă mobilă: cu alte cuvinte, ea nu este o eroare (de la englezescul bug) a Python-ului și nici o eroare a codului scris de dumneavoastră. Această complicație poate fi întâlnită în toate limbajele de programare care suportă aritmetica în virgulă mobilă a mașinii fizice de calcul (chiar dacă unele limbaje ar putea să nu publice diferențele în mod implicit, și nici în toate modurile de afișare).
Pentru o afișare plăcută ochiului, ați putea utiliza șirurile de formatare ca să produceți un număr restrâns de cifre semnificative:
>>> format(math.pi, '.12g') # printează 12 cifre semnificative
'3.14159265359'
>>> format(math.pi, '.2f') # printează 2 cifre la dreapta punctului
'3.14'
>>> repr(math.pi)
'3.141592653589793'
Merită să înțelegem că aceasta este, de fapt, o iluzie: noi nu facem decât să rotunjim afișajul valorii stocate în mașina de calcul.
Nicio iluzie nu vine de una singură. Astfel, cum 0.1 nu este chiar 1/10, nici suma a trei valori ale lui 0.1 nu va da chiar 0.3:
>>> 0.1 + 0.1 + 0.1 == 0.3
False
De asemeni, cum acest 0.1 nu poate ajunge oricât de aproape de valoarea exactă a lui 1/10 și nici 0.3-ul nu se poate apropia oricât de mult de valoarea exactă a lui 3/10, pre-rotunjirea valorilor bazată pe funcția round()
nu ne va fi de niciun ajutor:
>>> round(0.1, 1) + round(0.1, 1) + round(0.1, 1) == round(0.3, 1)
False
Chiar dacă numerele nu se pot apropia oricât de mult de doritele lor valori exacte, totuși, funcția math.isclose()
ne poate fi de folos la compararea de valori inexacte:
>>> math.isclose(0.1 + 0.1 + 0.1, 0.3)
True
Ca alternativă, funcția round()
este utilă la compararea unor aproximații grosiere:
>>> round(math.pi, ndigits=2) == round(22 / 7, ndigits=2)
True
Aritmetica binară în virgulă mobilă este plină de surprize de acest fel. Dificultatea privitoare la „0.1” va fi explicată detaliat mai jos, în secțiunea „Erori de reprezentare”. Vedeți Exemple de complicații în virgulă mobilă pentru un sumar captivant al modului de funcționare al operațiilor în virgulă mobilă și al tipurilor de dificultăți întâlnite frecvent în practică. Vedeți, de asemeni, Pericolele virgulei mobile pentru o dare de seamă și mai cuprinzătoare a altor suprize des întâlnite.
Și, după cum se spune la finalul acestui din urmă material, „nu există răspunsuri simple.” Însă nu vă lăsați speriați prea ușor de virgula mobilă! Erorile produse de operațiile în virgulă mobilă din Python sunt moștenite de la arhitectura de calcul în virgulă mobilă a mașinii de calcul iar pe cele mai multe din mașini erorile nu vor depăși ordinul de 1 pe 2**53 per operație. Aceasta este mai mult decât potrivit pentru majoritatea activităților, dar nu scăpați din vedere că nu lucrați cu aritmetică zecimală și nici că orice operație în virgulă mobilă poate suferi de propria sa eroare de rotunjire.
Deși cazurile patologice vor continua să existe, utilizările obișnuite ale aritmeticii în virgulă mobilă vor conduce la rezultatul scontat atunci când veți rotunji afișarea rezultatelor finale la numărul de zecimale dorit. Chiar dacă funcția str()
este, de obicei, suficientă, pentru un control mai fin al printării consultați specificatorii de format ai metodei str.format()
din Format String Syntax.
Pentru cazurile de întrebuințare care au nevoie de reprezentări zecimale exacte, vă recomandăm să folosiți modulul decimal
în care este implementată o aritmetică zecimală potrivită atât pentru aplicațiile de contabilitate cât și pentru cele de înaltă precizie (numerică).
Altă formă de aritmetică exactă este oferită de modulul fractions
care implementează o aritmetică bazată pe numere raționale (astfel încât numerele de forma 1/3 să fie reprezentate exact).
În cazul în care sunteți un utilizator asiduu al operațiilor în virgulă mobilă atunci n-ar strica să aruncați o privire asupra pachetului NumPy ori asupra oricăruia din numeroasele pachete de calcul matematic și statistică oferite de către proiectul SciPy. Vezi <https://scipy.org>.
Python-ul vă pune la dispoziție unelte software care vă vor putea fi de folos în rarele ocazii în care vă va interesa cu adevărat valoarea exactă a unui număr în virgulă mobilă. Metoda float.as_integer_ratio()
exprimă valoarea unui număr în virgulă mobilă sub formă de fracție:
>>> x = 3.14159
>>> x.as_integer_ratio()
(3537115888337719, 1125899906842624)
Deoarece fracția este exactă, o putem folosi pentru a reconstitui, fără pierderi, valoarea originală:
>>> x == 3537115888337719 / 1125899906842624
True
Metoda float.hex()
exprimă float-urile în format hexazecimal (adică, în baza de numerație 16), returnând, și ea, exact valoarea stocată în calculatorul dumneavoastră:
>>> x.hex()
'0x1.921f9f01b866ep+1'
Această reprezentare hexazecimală precisă poate fi întrebuințată la reconstrucția fără pierderi a valorii float-ului:
>>> x == float.fromhex('0x1.921f9f01b866ep+1')
True
Fiind o reprezentare exactă, ea este utilă la portarea (transportul, de la englezescul porting) în siguranță a valorilor între diferitele versiuni de Python (oferind, așadar, independența de platforma de calcul) precum și la schimbul de valori dintre Python și alte limbaje de programare care suportă acest format (cum sunt Java și C99).
Altă unealtă software folositoare este funcția sum()
care știe cum să amelioreze pierderile de precizie de pe parcursul sumărilor. Ea se bazează pe precizia extinsă la rotunjirile pe care le efectuează în pașii de calcul intermediari în care valori succesive (termenii sumei) sunt acumulate într-o valoare totală (totalul cumulativ). Această tehnică va conta pentru acuratețea generală, împiedicând acumularea erorilor până la o valoare care să afecteze totalul general:
>>> 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 + 0.1 == 1.0
False
>>> sum([0.1] * 10) == 1.0
True
Funcția math.fsum()
merge chiar mai departe și detectează toate „cifrele pierdute” de pe parcursul adăugirilor la totalul cumulativ, ceea ce conduce la o singură rotunjire a rezultatului final. Deși este mai înceată decât funcția sum()
, ea se dovedește mai precisă în situațiile-limită, acolo unde date de valori mari se anihilează unele pe altele în calcul făcând ca grand totalul să fie aproape zero:
>>> valori_mari = [-0.10430216751806065, -266310978.67179024, 143401161448607.16,
... -143401161400469.7, 266262841.31058735, -0.003244936839808227]
>>> float(sum(map(Fraction, valori_mari))) # Sumare exactă cu o singură rotunjire
8.042173697819788e-13
>>> math.fsum(valori_mari) # O singură rotunjire
8.042173697819788e-13
>>> sum(valori_mari) # Rotunjiri multiple în precizie extinsă
8.042178034628478e-13
>>> totalul = 0.0
>>> for x in valori_mari:
... totalul += x # Rotunjiri multiple în precizie obișnuită
...
>>> # Adunarea directă nu conduce la nici
>>> totalul # măcar o cifră corectă!
-0.0051575902860057365
15.1. Erori de reprezentare¶
Secțiunea de față se ocupă de exemplul „0.1” în detaliu și vă prezintă ce aveți de făcut pentru a realiza o asemenea analiză de caz riguroasă chiar dumneavoastră. Presupunem că dispuneți de o minimă familiaritate cu reprezentările binare în virgulă mobilă ale numerelor.
Eroarea de reprezentare se referă la faptul că anumite (de fapt, majoritatea lor) fracții zecimale nu pot fi reprezentate exact ca fracții binare (în baza 2). Această situație este motivul fundamental pentru care Python-ul (ori Perl-ul, C-ul, C++-ul, Java-ul, Fortran-ul și diverse alte limbaje de programare) nu afișează, în multe cazuri, exact numărul zecimal la care v-ați fi așteptat.
De ce asta? Pentru că 1/10 nu este reprezentabil exact ca fracție binară. Încă din anul 2000, cel puțin, aproape toate mașinile de calcul folosesc aritmetica binară în virgulă mobilă a standardului IEEE 754 iar majoritatea platformelor de calcul mapează float-urile din Python pe valorile binare, date în dubla precizie pe 64 de biți (binary64) a standardului IEEE 754. Valorile binary64 ale lui IEEE 754 conțin 53 de biți de precizie, astfel că, atunci când îl primește pe 0.1 ca dată de intrare, calculatorul va încerca să-l convertească în cea mai apropiată ca valoare fracție de forma J/2**N, unde J este un număr întreg de exact 53 de biți. Rescriind relația
1 / 10 ~= J / (2**N)
drept
J ~= 2**N / 10
și ținând seama de faptul că J are exact 53 de biți (adică, este >= 2**52
dar < 2**53
), deducem că valoarea cea mai bună pentru N este 56:
>>> 2**52 <= 2**56 // 10 < 2**53
True
Cu alte cuvinte, 56 este singura valoare a lui N pentru care J va avea exact 53 de biți. Astfel, cea mai bună alegere a lui J va fi cea dată de rotunjirea câtului:
>>> q, r = divmod(2**56, 10)
>>> r
6
Dat fiind că restul (la împărțirea cu 10) este mai mare decât jumătate din 10, cea mai bună aproximație este cea obținută cu rotunjire prin adaos:
>>> q+1
7205759403792794
Așadar, cea mai bună aproximație a lui 1/10 în dubla precizie oferită de standardul IEEE 754 este:
7205759403792794 / 2 ** 56
Împărțind atât numărătorul cât și numitorul la 2 (adică, simplificând fracția cu 2), fracția devine:
3602879701896397 / 2 ** 55
Remarcați faptul că, deoarece am realizat o rotunjire prin adaos, această fracție este puțin mai mare decât 1/10; dacă nu am fi folosit adaosul, atunci câtul ar fi fost puțin mai mic decât 1/10. Însă, în niciun caz, nu am fi ajuns exact la 1/10!
În concluzie, calculatorul nu-l „vede” pe 1/10 niciodată : tot ce poate vedea este exact fracția dată mai sus, ea fiind cea mai bună aproximație în dublă precizie pe care o poate construi urmând standardul IEEE 754:
>>> 0.1 * 2 ** 55
3602879701896397.0
Dacă înmulțim fracția in cauză cu 10**55, atunci îi vom vedea valoarea scrisă cu 55 de cifre zecimale:
>>> 3602879701896397 * 10 ** 55 // 2 ** 55
1000000000000000055511151231257827021181583404541015625
ceea ce înseamnă că numărul stocat în calculator este egal cu valoarea zecimală 0.1000000000000000055511151231257827021181583404541015625. În loc să afișeze această valoare zecimală în întregime, multe limbaje de programare (și, printre ele, versiunile mai vechi ale Python-ului), o vor rotunji la 17 cifre semnificative:
>>> format(0.1, '.17f')
'0.10000000000000001'
Modulele fractions
și decimal
ușurează aceste calcule:
>>> from decimal import Decimal
>>> from fractions import Fraction
>>> Fraction.from_float(0.1)
Fraction(3602879701896397, 36028797018963968)
>>> (0.1).as_integer_ratio()
(3602879701896397, 36028797018963968)
>>> Decimal.from_float(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')
>>> format(Decimal.from_float(0.1), '.17')
'0.10000000000000001'