15. Floating Point Arithmetic: Issues and Limitations

Floating-point numbers are represented in computer hardware as base 2 (binary) fractions. For example, the decimal fraction 0.125 has value 1/10 + 2/100 + 5/1000, and in the same way the binary fraction 0.001 has value 0/2 + 0/4 + 1/8. These two fractions have identical values, the only real difference being that the first is written in base 10 fractional notation, and the second in base 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.

Many users are not aware of the approximation because of the way values are displayed. Python only prints a decimal approximation to the true decimal value of the binary approximation stored by the machine. On most machines, if Python were to print the true decimal value of the binary approximation stored for 0.1, it would have to display

>>> 0.1
0.1000000000000000055511151231257827021181583404541015625

That is more digits than most people find useful, so Python keeps the number of digits manageable by displaying a rounded value instead

>>> 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.

Note that this is in the very nature of binary floating-point: this is not a bug in Python, and it is not a bug in your code either. You’ll see the same kind of thing in all languages that support your hardware’s floating-point arithmetic (although some languages may not display the difference by default, or in all output modes).

For more pleasant output, you may wish to use string formatting to produce a limited number of significant digits:

>>> format(math.pi, '.12g')  # give 12 significant digits
'3.14159265359'

>>> format(math.pi, '.2f')   # give 2 digits after the point
'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.

One illusion may beget another. For example, since 0.1 is not exactly 1/10, summing three values of 0.1 may not yield exactly 0.3, either:

>>> .1 + .1 + .1 == .3
False

Also, since the 0.1 cannot get any closer to the exact value of 1/10 and 0.3 cannot get any closer to the exact value of 3/10, then pre-rounding with round() function cannot help:

>>> round(.1, 1) + round(.1, 1) + round(.1, 1) == round(.3, 1)
False

Though the numbers cannot be made closer to their intended exact values, the round() function can be useful for post-rounding so that results with inexact values become comparable to one another:

>>> round(.1 + .1 + .1, 10) == round(.3, 10)
True

Binary floating-point arithmetic holds many surprises like this. The problem with „0.1” is explained in precise detail below, in the „Representation Error” section. See Examples of Floating Point Problems for a pleasant summary of how binary floating-point works and the kinds of problems commonly encountered in practice. Also see The Perils of Floating Point for a more complete account of other common surprises.

As that says near the end, „there are no easy answers.” Still, don’t be unduly wary of floating-point! The errors in Python float operations are inherited from the floating-point hardware, and on most machines are on the order of no more than 1 part in 2**53 per operation. That’s more than adequate for most tasks, but you do need to keep in mind that it’s not decimal arithmetic and that every float operation can suffer a new rounding error.

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 Sintaxa șirurilor de format.

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 provides tools that may help on those rare occasions when you really do want to know the exact value of a float. The float.as_integer_ratio() method expresses the value of a float as a fraction:

>>> x = 3.14159
>>> x.as_integer_ratio()
(3537115888337719, 1125899906842624)

Since the ratio is exact, it can be used to losslessly recreate the original value:

>>> x == 3537115888337719 / 1125899906842624
True

The float.hex() method expresses a float in hexadecimal (base 16), again giving the exact value stored by your computer:

>>> x.hex()
'0x1.921f9f01b866ep+1'

This precise hexadecimal representation can be used to reconstruct the float value exactly:

>>> 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).

Another helpful tool is the math.fsum() function which helps mitigate loss-of-precision during summation. It tracks „lost digits” as values are added onto a running total. That can make a difference in overall accuracy so that the errors do not accumulate to the point where they affect the final total:

>>> sum([0.1] * 10) == 1.0
False
>>> math.fsum([0.1] * 10) == 1.0
True

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

and recalling that J has exactly 53 bits (is >= 2**52 but < 2**53), the best value for N is 56:

>>> 2**52 <=  2**56 // 10  < 2**53
True

That is, 56 is the only value for N that leaves J with exactly 53 bits. The best possible value for J is then that quotient rounded:

>>> q, r = divmod(2**56, 10)
>>> r
6

Since the remainder is more than half of 10, the best approximation is obtained by rounding up:

>>> 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

If we multiply that fraction by 10**55, we can see the value out to 55 decimal digits:

>>> 3602879701896397 * 10 ** 55 // 2 ** 55
1000000000000000055511151231257827021181583404541015625

meaning that the exact number stored in the computer is equal to the decimal value 0.1000000000000000055511151231257827021181583404541015625. Instead of displaying the full decimal value, many languages (including older versions of Python), round the result to 17 significant digits:

>>> format(0.1, '.17f')
'0.10000000000000001'

The fractions and decimal modules make these calculations easy:

>>> 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'