Unikaj liczb zmiennoprzecinkowych w Python


Czy wiedziałeś, że liczby zmiennoprzecinkowe są dla procesora  znacznie trudniejsze niż liczby naturalne? Zobacz dlaczego powinieneś unikać liczb zmiennoprzecinkowych.


Pierwszy raz z systemami binarnymi spotkałem się jakieś 12 lat temu. Wtedy myślałem, że to tylko teoretyczne rozważania i nie mają żadnego odzwierciedlenia w rzeczywistości. Nie zmieniłem zdania nawet na studiach, gdzie uczyłem się szybkiej zamiany między różnymi systemami. Dopiero niedawno jak robiliśmy pasek postępu zauważyłem coś dziwnego. To skłoniło mnie do poszukiwania informacji i dopiero takie połączenie zapaliło u mnie wszystkie lampki i styki, dzięki czemu wiem, żeby unikać liczb zmiennoprzecinkowych kiedy to nie jest niezbędne.

System binarny

Jeśli zajmujesz się programowaniem to jest duża szansa, że spotkałeś się z systemem binarnym, zwanym też systemem dwójkowym. Jest to prosty system w którym podstawą jest liczba 2, a do zapisu używa się jedynie cyfr 0 i 1.

System dziesiętny

Jest to podstawowy system, z którego korzystamy codziennie. Podstawą tego systemu jest liczba 10, a do zapisu używa się cyfr od 0 do 9. Ten system jest dla nas naturalny, komputer operuje na liczbach binarnych, dlatego zanim przejdziemy dalej musimy nauczyć się zamieniac system dziesiętny na binarny i odwrotnie.

Zmiana systemu binarnego na system dziesiętny

Zamiana z liczby binarnej na dziesiętną jest stosunkowo prosta i w dużym uproszczeniu polega na dodaniu kolejnych potęg dwójki licząc od prawej, jeśli cyfra jest jedynką. Rozumiem, że taki teoretyczny opis może być trudny do zrozumienia, dlatego przejdźmy do przykładu.

1011 = 1*2^0 + 1*2^1 + 0*2^2 + 1*2^3 = 1*1 + 1*2 + 0*4 + 1*8 = 1 + 2 + 8 = 11

Dwie kwestie o których ludzie często zapominają (a przynajmniej my zapomnieliśmy w szkole i na studiach ;) )

  • idziemy od prawej do lewej
  • dowolna liczba do potęgi 0 daje 1

Zróbmy zatem jeszcze kilka przykładów

1000 = 0*2^0 + 0*2^1 + 0*2^2 + 1*2^3 = 8
1100 = 0*2^0 + 0*2^1 + 1*2^2 + 1*2^3 = 4 + 8 = 12
1110 = 0*2^0 + 1*2^1 + 1*2^2 + 1*2^3 = 2 + 4 + 8 = 14
1111 = 1*2^0 + 1*2^1 + 1*2^2 + 1*2^3 = 1 + 2 + 4 + 8 = 15

Zmiana systemu dziesiętnego na system binarny

Żeby skutecznie zamieniać liczby z systemu dziesiętnego na system binarny musimy cofnąć się pamięcią do szkoły podstawowej w której uczyliśmy się dzielenia z resztą. Zmiana polega bowiem na dzieleniu liczby przez dwa i zapisywaniu reszty. Całość powtarzamy aż nie będzie czego dzielić. Praktyka ponownie jest znacznie łatwiejsza niż teoria. Dlatego przejdźmy do kilku praktycznych przykładów

11 : 2 = 5 r 1
5  : 2 = 2 r 1
2  : 2 = 1 r 0
1  : 2 = 0 r 1
= 1011

Teraz idąc od dołu zapisujemy wynik, przez co otrzymujemy wynik 1011.

Tu dwie ważne uwagi

  • Wynik z dzielenia jest kolejną liczbą, którą będziemy dzielić
  • wynik czytamy od dołu do góry

Przejdźmy do kolejnego przykładu i sprawdźmy czy wyliczona wcześniej 14 faktycznie była poprawnie zamieniona

14 : 2 = 7 r 0
7  : 2 = 3 r 1
3  : 2 = 1 r 1
1  : 2 = 0 r 1
= 1110

Zamiana liczb rzeczywistych z binarnego na dziesiętny

Jeśli zrozumiałeś dokładnie zamianę liczb naturalnych, to możemy przejść dalej. Tu będzie nieco więcej zabawy. Zamiana z systemy binarnego, do systemu dziesiętnego jest analogiczna ale tu idziemy od lewej do prawej i mnożymy przez kolejne ujemne ułamki liczby dwa. Ja zawsze rozbijam operację liczenia oddzielnie na część całkowitą i część po przecinku. Zatem przejdźmy do przykładu

10.011

10. = 0 * 2^1 + 1*2^1 = 0 + 2 = 2.
.011 = 0 * 2^(-1) + 1 * 2^(-2) + 1 * 2(-3) = 0 + 1/4 + 1/8 = 3/8 = .375

To w rezultacie daje nam wynik 2.375.

Z ważnych uwag

  • Ujemne potęgi liczby możemy potraktować jako odwrotny ułamek, czyli 2^(-2) = 1/(2^2) = 1/4
  • Tu liczmy od lewej do prawej

Zróbmy jeszcze z jeden przykład. Dla uproszczenia pomińmy część całkowitą

.101 = 1*2^(-1) + 0*2^(-2) + 1*2^(-3) = 1/2 + 1/8 = 4/8 + 1/8 = 5/8 = 0.625 

Zamiana liczb rzeczywistych z dziesiętnego na binarny

To była ta operacja, którą najtrudniej było mi z początku zrozumieć. Musimy bowiem część po przecinku potraktować oddzielnie i kolejne liczby mnożyć. Zobaczmy na przykładzie

.625 * 2 = 1.25  # część całkowita nas nie interesuje
.25  * 2 = 0.5
.5   * 2 = 1.0

Idąc od góry do dołu mamy wynik 101

Z ważnej uwagi mogę jedynie dodać, że tu idziemy od góry do dołu, w przeciwieństwie do zamiany liczb całkowitych w których wynik zbieraliśmy od dołu. Sprawdźmy może kolejny przykład

.375 * 2 = 0.75
.75  * 2 = 1.5
.5   * 2 = 1.0

Wynik zgadza się z tym co było wcześniej, czyli .375 dziesiętnie to .011 binarnie.

Cykle

Jak pewnie zauważyłeś zamiana liczb rzeczywistych z systemu dziesiętnego na binarny nie jest taka prosta. A to jeszcze nie wszystko. Problem pojawia się jeśli wynik nie będzie chciał się tak ładnie zakończyć. Weźmy liczbę, który dla nas wygląda bardzo prosto, czyli 0.2

.2 * 2 = 0.4
.4 * 2 = 0.8
.8 * 2 = 1.6  # część dziesiętna nas nie interesuje
.6 * 2 = 1.2
.2 * 2 = 0.4  # cykl
.4 * 2 = 0.8
.8 * 2 = 1.6  # część dziesiętna nas nie interesuje
.6 * 2 = 1.2
...

Jak widać taka prosta liczba jest trudna do przeliczenia. Jak zatem to zapisać? Sięgnijmy do okresowych rozwinięć, czyli 0.2 zamieniona z systemu dziesiętnego na system binarny daje 0.(0011). Przeliczmy zatem kilka takich cykli

.0011 = 0 + 0 + 1/8 + 1/16 = 3/16 = 0.1875
.00110011 = 0 + 0 + 1/8 + 1/16 + 0 + 0 + 1/128 + 1/256 = 51/256 = 0.19921875
.001100110011 = 1/8 + 1/16 + 1/128 + 1/256 + 1/2048 + 1/4096 = 819/4096 = 0.199951171875

Jak widzimy operacje przy 12 bitach są już trudne do łatwego liczenia. Jest to równocześnie dość dobre przybliżenie. No dobra ale jak to ma się do praktyki i programowania, a tym bardziej programowania w Python? Zobaczmy na przykładzie.

Operacje matematyczne w Python

3/10  # 0.3
2/10  # 0.2
1/10  # 0/1
0.2 + 0.1  # 0.30000000000000004

Ten przykład podawałem we wpisie o dziwnych przypadkach w Python . Co tu się dokładnie stało? A no stało się właśnie przybliżenie. Domyślnie liczby zmiennoprzecinkowe są typu float, które mają aż albo tylko 64 bity do reprezentacji liczby.

Pasek postępu

Mamy za sobą masę teorii i trochę praktyki. Teraz chciałbym pokazać Wam co stało się, że postanowiłem napisać ten wpis. Otóż podczas pisania prostego procentowego paska postępu zauważyłem, że pojawiały się jakieś dziwne cyfry. Zbliżyłem temat i napisałem skrypt, który widzimy poniżej.

x = 0
while x!= 10:
    x += 1
    print(x/10)

Skrypt wydawał się w porządku ale irytowało nas dzielenie przez 10, więc zamieniliśmy to właśnie na liczby rzeczywiste

x = 0
while x!= 1.0:
    x += .1
    print(x)

Okazało się, że skrypt wpadł w nieskończoną pętlę, bo wynik nigdy nie był równy 1.0.

#  0.1
#  0.2
#  0.30000000000000004
#  0.4
#  0.5
#  0.6
#  0.7
#  0.7999999999999999
#  0.8999999999999999
#  0.9999999999999999
#  1.0999999999999999
#  1.2
#  1.3
#  1.4000000000000001
#  1.5000000000000002
#  1.6000000000000003
#  1.7000000000000004
#  1.8000000000000005
#  1.9000000000000006
#  2.0000000000000004

Oczywiście prostym poprawieniem była zmiana znaku z != na <= , ale świadomy programista wyciąga lekcje ze swoich błędów. Dlatego zacząłem to badać i wypisałem więcej cyfr niż tylko te widoczne na ekranie. Całość ułatwiły mi f-stringy

x = 0.0                                                                         
while x<= 1.0:                                                                  
    print(f"{x:.50f}")                                                          
    x += 0.1

# 0.00000000000000000000000000000000000000000000000000
# 0.10000000000000000555111512312578270211815834045410
# 0.20000000000000001110223024625156540423631668090820
# 0.30000000000000004440892098500626161694526672363281
# 0.40000000000000002220446049250313080847263336181641
# 0.50000000000000000000000000000000000000000000000000
# 0.59999999999999997779553950749686919152736663818359
# 0.69999999999999995559107901499373838305473327636719
# 0.79999999999999993338661852249060757458209991455078
# 0.89999999999999991118215802998747676610946655273438
# 0.99999999999999988897769753748434595763683319091797

Jak widzimy wyniki nie są zbyt dokładne, dodatkowo zauważyłem że coś za długi ten wynik. Miało być 10 powtórzeń. Dodałem więc jeszcze zmienną pomocniczą.

x = 0.0
i = 0                                                                       
while x<= 1.0:                                                                  
    print(f"{x:.50f}")                                                          
    x += 0.1
    i += 1

# 0      0.00000000000000000000000000000000000000000000000000
# 1      0.10000000000000000555111512312578270211815834045410
# 2      0.20000000000000001110223024625156540423631668090820
# 3      0.30000000000000004440892098500626161694526672363281
# 4      0.40000000000000002220446049250313080847263336181641
# 5      0.50000000000000000000000000000000000000000000000000
# 6      0.59999999999999997779553950749686919152736663818359
# 7      0.69999999999999995559107901499373838305473327636719
# 8      0.79999999999999993338661852249060757458209991455078
# 9      0.89999999999999991118215802998747676610946655273438
# 10     0.99999999999999988897769753748434595763683319091797

Zwróćmy uwagę na wiersze 5 i 6. Te przybliżenie było dla mnie największą zagadką. Dlatego właśnie powinniśmy korzystać z Decimal, który jest znacznie dokładniejszy. Nie jest to idealne przybliżenie, ale dwa razy więcej bitów pozwalają na dużo precyzyjniejsze wypisanie liczby. Poniżej zaprezentuję Wam poprawiony skrypt o uwzględniony właśnie Decimal

from decimal import Decimal

i = 0
x = Decimal(0.0)
while x<= 1.0:
    print(f"# {i} \t {x:.50f}")
    x += Decimal(0.1)
    i += 1


# 0      0.00000000000000000000000000000000000000000000000000
# 1      0.10000000000000000555111512310000000000000000000000
# 2      0.20000000000000001110223024620000000000000000000000
# 3      0.30000000000000001665334536930000000000000000000000
# 4      0.40000000000000002220446049240000000000000000000000
# 5      0.50000000000000002775557561550000000000000000000000
# 6      0.60000000000000003330669073860000000000000000000000
# 7      0.70000000000000003885780586170000000000000000000000
# 8      0.80000000000000004440892098480000000000000000000000
# 9      0.90000000000000004996003610790000000000000000000000

Jak widzimy przybliżenia są już znacznie dokładniejsze i najważniejsze mamy dokładnie 10 iteracji, tak jak oczekiwaliśmy.

Podsumowanie

To był bardzo szybki, aczkolwiek bardzo wyczerpujący i treściwy wpis. Liczę się z tym, że wielu czytelników będzie miało problem z dotarciem do samego końca. Jeśli Ty nie zrozumiałeś za dużo, to nic nie szkodzi. Wróć tu za jakiś czas i daj znać czy po jakimś czasie jest lepiej. Do pełnego zrozumienia tego wpisu potrzebna jest ogólna wiedza z zakresu działania komputerów oraz pewne zacięcie, które sam widzę stosunkowo rzadko.

Jeśli miałbym wyciągnąć z tego wpisu trzy najważniejsze kwestie, to byłyby to:

  • Teoria i ogólna wiedza informatyczna jest bardziej przydatna niż wygląda to na pierwszy rzut oka
  • Jeśli nie musisz, nie używaj typów zmiennoprzecinkowych
  • Jeśli musisz korzystać z liczb rzeczywistych i zależy Ci na precyzji to zapoznaj się z Decimal
Kwi 27, 2020

Najnowsze wpisy

Zobacz wszystkie