A lebegőpontos számábrázolás – avagy: amikor a fal adja a másikat

Az alapok

Az alapokat nem írom le. Rengeteg helyen elmagyarázzák. Jómagam pl. innen értettem meg – bevallom, nagyon nehezen ment, mert kicsit idegen a mindennapok világától a lebegőpontos számábrázolás szemlélete, s ha szükség van rá, bármikor belezavarodok magam is: https://gyires.inf.unideb.hu/GyBITT/31/ch05s05.html

A lényeg az, hogy a JavaScript a 64 bit hosszúságú, ún. dupla pontosságú lebegőpontos számábrázolást (double precision floating point format) használja, ahol a felosztás: 1 bit előjel – 11 bit kitevő – 53 bit mantissza. Viszont 1 + 11 + 53 = 65, ami 1-gyel több, mint 64. A mantissza ui. mindig egy 1-es rejtett bittel kezdődik, amit nem írunk ki. A kitevők ábrázolása viszont csak 1024-ig lehetséges (210) a feszített előjeles ábrázolás miatt.

Speciális számok

ElőjelKarakterisztikaMantissza
denormalizált szám ±0tetszőleges szám ≠ 0
nulla ±00
végtelen ±111…1110
NaN ±111…111tetszőleges szám ≠ 0
  • Látható, hogy két nulla van, pozitív és negatív nulla, ahogyan a végtelenből (Infinity) is.
  • A +0 és a -0 között egyetlen különbség van, ha ezzel osztunk egy nem nulla véges számot: 1/+0 == +Infinity, ellenben 1/-0 == -Infinity. Minden más esetben pontosan ugyanúgy viselkednek, és -0 === +0.
  • A NaN a ‘Not a Number‘ rövidítése, olyan értéket takar, amit akkor kapunk, ha a számolás kivezet a (valós) számkörből, pl. Math.sqrt(-1) vagy 0/0. A NaN semmivel sem – így önmagával sem – egyenlő.
  • A denormalizált számról később lesz szó.
  • Lényeges, hogy ezek a ‘számok’ a JavaScriptben number típusú primitívek, azaz a typeof-juk number, tehát számnak minősülnek, mint ahogyan az ábrázolásuk is lebegőpontosan történik.

Példák az egész számok ábrázolására

Ezen az oldalon tudunk átalakítani decimális alakot lebegőpontossá. Újratöltéshez használjuk a dec to bin 64bit gombot. (Update: ez a konverter néha hibázik, pl. 0.1-re.)

232-1 = 4 294 967 295(10) = 1111 1111 1111 1111 1111 1111 1111 1111(2) = 1.1111 1111 1111 1111 1111 1111 1111 111(2) × 231 = 0 -100 0001 1110 – 1111111111111111111111111111111000000000000000000000 – a szám egész része 32 bit hosszúságú, igy az exponens 31 lesz, amiből a kettes számrendszerben a feszített előjeles ábrázolás miatt 1023+31 = 1054 lesz, azaz: 100 0001 1110(2)  = 1024+0+0+0+0+0+16+8+4+2+0 = 1054; a mantisszából az első 1-es számjegy nem látszik, az lesz a rejtett bit; a kettedespontot követő kettedes jegyeket feltöltik az 52 bitnyi helyre úgy, hogy az utolsó egyest követő helyekre 0-kat írnak.

232 = 4 294 967 296(10) =1 0000 0000 0000 0000 0000 0000 0000 0000(2) = 1.0000 0000 0000 0000 0000 0000 0000 0001(2) × 232 = 0 -100 0001 1111 – 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 – a szám egész része itt 33 bit, így a kitevő 32 lesz, ebből a kettes számrendszerben a feszített előjeles ábrázolás miatt 1023+32 = 1025 lesz, azaz 100 0001 1111(2) = 1024 +0+0+0+0+0+016+8+4+2+1 = 1025; a mantisszában csak egy darab 1-es lenne, az első, de az lesz a rejtett bit, így csak 52 nulla sorakozik egymás után.

232+1 = 4 294 967 297(10) = 1 0000 0000 0000 0000 0000 0000 0000 0001(2) = 1.0000 0000 0000 0000 0000 0000 0000 0001(2) × 232 = 0 -10000011111 – 0000 0000 0000 0000 0000 0000 0000 0001 0000 0000 0000 0000 0000 – az 1-es számjegy egy 33 számjegyű szám utolsó számjegye. A normál alakban az lesz a 32. kettedes jegy a kettedes pont után, így a lebegőpontos ábrázolás mantisszájában ez lesz a 32. számjegy.

232+2 = 4 294 967 298(10)  = 1 0000 0000 0000 0000 0000 0000 0000 0010(2) = 1.0000 0000 0000 0000 0000 0000 0000 0010(2) × 232 = 0 – 100 0001 1111 – 0000 0000 0000 0000 0000 0000 0000 0010 0000 0000 0000 0000 0000

232+3 = 4 294 967 299(10)  = 1 0000 0000 0000 0000 0000 0000 0000 0011(2) = 1.0000 0000 0000 0000 0000 0000 0000 0011(2) × 232 = 0 -10000011111 – 0000000000000000000000000000001100000000000000000000


MAX_SAFE_INTEGER

A Number objectum értéke ezen a kulcson a Number.MAX_SAFE_INTEGER = 9007199254740991, amely a legnagyobb biztonságosan ábrázolható egész szám a 64 bites lebegőpontos számábrázolás esetén, ami nem más, mint a 253-1.

A mantissza 52 látható és 1 rejtett bitből, azaz összesen 53 bitből áll. 53 biten pedig összesen 253 darab szám ábrázolható, s mivel a sort a 0-val kezdjuk, így a legnagyobb 53 bites szám a 253 -1 (= 9007199254740991) lesz, ami így néz ki: 0 -10000110011 – 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111, s ez lesz az a bizonyos nagy szám.

A Number.MIN_SAFE_INTEGER pedig ennek az ellentettje, a legkisebb biztonságosan ábrázolható (negatív) egész szám.

Élet a MAX_SAFE_INTEGER-en túl

Nézzük meg, mi történik, ha hozzáadok 1-et! Ekkor 253 = 9007199254740992, ami kettes számrendszerben felírt normál alakban: 1.0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0(2) × 253. A mantisszában csak 52 kettedes jegy lehet, így az utolsó – az 53. – kettedes jegy itt túlcsordulással eltűnik a lebegőpontos felírásban: 0 -10000110100 – 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

Adjunk hozzá ismét 1-et! Ekkor 253+1 = 9007199254740993, ami 1.0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 1(2) × 253, ahol túlcsordulás miatt az utolsó kettedes számjegy eltűnik: 0 -10000110100 – 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000, így ugyanazt a számot kaptuk meg, mint az előbb. Ezért a JavaScriptben 9007199254740992 + 1 = 9007199254740992, azaz a 2**53==2**53+1 logikai kifejezés a true értéket fogja kapni, azaz 9007199254740992 és 9007199254740993 ugyanaz az egész szám. Sőt! 9007199254740993 + 1 = 9007199254740992.

Beugrató kvízkérdésnek sem rossz:

          Mennyi 9007199254740993 + 1 a JavaScriptben?

          A. 9007199254740991
          B. 9007199254740992
          C. 9007199254740993
          D. egyik sem

A határszám nagyságrendjét úgy tudjuk megjegyezni, hogy 253 -1 közelítve: 250 = (210)5 = 10245, ami közelítve: 10005, ez pedig 3×5, azaz 15 db. nulla, s maga a szám pedig: 9.007199254740991e+15, azaz nagyságrendileg 9×1015 .

A következő szám a 9007199254740994, ami 1.0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0001 0(2) × 253, ami lebegőpontosan: 0 -10000110100 – 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0001. Ez viszont már egy létező szám lesz, ahol a 9007199254740992-t a 9007199254740994 követi és 9007199254740992 + 2 = 9007199254740994.

Ugyanígy a következő 9007199254740995 az 1.0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0001 1(2) × 253, ami 0 – 10000110100 – 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0001 – s ez ugyanaz, mint az előző – 4-re végződő – szám. Ha viszont beírjuk a konzolba, hogy 9007199254740995 és nyomunk egy entert, akkor azt kapjuk, hogy 9007199254740996.

9007199254740996 viszont a 1.0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0010 0(2) × 253, ami 0 – 10000110100 – 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0010.

Az előbb az eggyel alacsonyabb, most viszont az eggyel magasabb értékkel lesz egyenlő az adott – egyébként nem tárolt szám. Azt várnánk, hogy itt a 9007199254740995-ös szám a lebegőpontos felírásának megfelelő 9007199254740994-es számon tárolódik el, helyette viszont az – eltárolásában 1-gyel nagyobb mantisszát tartalmazó – 9007199254740996-os számon tárolódik el. Miért? Erre egy stackoverflow.com fórumon találtam választ:

>> When the number can’t be represented by the format, it is rounded to the nearest representable value; if it is exactly halfway between two such values, it is rounded to the nearest “even” number, which is the one with a 0 in the last place of the significand. <<

Azaz: amikor egy számot nem lehet a lebegőpontos formában ábrázolni, akkor a számot kerekítjük a legközelebbi ábrázolható szám értékére. Ha viszont a szám pont a kettő között van félúton, akkor a legközelebbi (binárisan) ‘páros’ számra kerekítjük, arra, amelyiknek az utolsó (bináris) számjegye a 0.

Azaz az el nem tárolt 9007199254740995-ös szám egyenlő távolságra van az eltárolt 9007199254740994-es és a 9007199254740996-os számoktól, így az az eltárolt szám fogja reprezentálni, amelyik a tárolt bináris formátumában páros, azaz a lebegőpontos ábrázolásban a mantisszája 0-ra végződik. (Tehát a kettes számrendszerben legyen páros, ne a tizes számbendszerben!)

A szám tehát, amit kerekítenünk kell 0001 1(2) -re végződik. Két szomszédja, amelyek között pont félúton van, az a páros 0010(2) végű és a páratlan 0001(2) végű szám. Ezek közül a páros szomszédjára kerekítünk, ez pedig a 9007199254740996.

Továbbá látható, hogy itt mindig két lépésenként növekszik 1-gyel az utolsó látható számjegy, azaz a növekvés így néz ki: 000, 001, 010, 011,100,101,110,111 …, s emiatt az ábrázolható számok ebben a nagyságrendben (253) kettesével növekednek.

Elvileg tehát mondjuk egy 255 nagyságrendű szám esetében 55-52=3, azaz az utolsó három bit marad le, amin a következő számok ábrázolhatóak: 000(2) = 0 -tól 111(2) =4 + 2 + 1 = 7 -ig, azaz nyolcanként kell ugrálni a számoknak. Ami ugyan igaz is, de nem úgy, ahogyan mi azt várnánk.

Nézzük tehát 255 = 10000000000000000000000000000000000000000000000000000000 -től, ahol pontosan 55 db nulla van.

Exponenciális alakValódi érték JavaScript érték
25536,028,797,018,963,96836 028 797 018 963 970
255 + 836,028,797,018,963,97636 028 797 018 963 976
255 + 1636,028,797,018,963,98436 028 797 018 963 980
255 + 2436,028,797,018,963,99236 028 797 018 963 990
255 + 3236,028,797,018,964,00036 028 797 018 964 000
255 + 4036,028,797,018,964,00836 028 797 018 964 010

Ennek az okát csak valószínűsíteni tudom, ui. úgy tűnik, hogy itt egy újabb szabály jön be, s a kerekítések úgy történnek, hogy – amennyiben lehetséges – a szám csak a tizes helyiértéken ugorjon egyet, s az egyesek helyén pedig 0 legyen. Ezt akár ellenőrizhetjük is mondjuk a 264 nagyságrendű számokkal, ahol 64 – 52 = 12 alapján, mivel 212 = 4096, a számok már három nullára lesznek kerekítve. S úgy tűnik, hogy ez így is van.

Exponenciális alakValódi értékJavaScript érték
26418,446,744,073,709,551,61618 446 744 073 709 552 000
264 + 21218,446,744,073,709,555,71218 446 744 073 709 556 000
264 + 2×21218,446,744,073,709,559,80818 446 744 073 709 560 000

MAX_VALUE

A kitevő – a szám karakterisztikája – 11 bites. Mivel a kitevőnek is van előjele, azt is ábrázolni kell, ezt azonban nem úgy teszik, hogy veszik itt is az első bitet, s ott helyezik el az előjelet, hanem veszik az 11 1111 1111(2) = 210 – 1= 1023 konstanst, s ehhez adják hozzá a kitevőt. Azaz a 0 kitevőt az 1023-as szám fogja tárolni, az 52-es kitevőt az 1023 + 52 = 1075 szám, a -20-as kitevőt pedig az 1023-20 = 1003-as szám tárolja.

A karakterisztika legnagyobb értéke a 111 1111 1111(2) = 211 – 1 = 2047 lehet, s mivel 2047 – 1023 = 1024, így elvileg a legnagyobb kitevő az 1024, a legkisebb kitevő pedig a -1023 lenne. Azonban:

  • Az 111 1111 1111 karakterisztika a végtelennek és a NaN-nak van fenntartva.
  • A 000 0000 0000 karakterisztika pedig a nullának és a denormalizált számoknak.

Így a legnagyobb karakterisztika az 1023, a legkisebb pedig a -1022. Azaz a legnagyobb ábrázolható szám lebegőpontos alakja: 0 11111111110 1111111111111111111111111111111111111111111111111111, aminek a decimális alakja: 1.7976931348623157e+308 – azaz ez egy 308 jegyű szám, aminek 16 értékes jegye van. Ezt a számot a Number objektum a MAX_VALUE kulcs alatt tárolja.

A legkisebb ábrázolható szám lebegőpontos alakja: 0 00000000001 000000000000000000000000000000000000000000000000000 – itt a feszítetten ábrázolt karakterisztika: 1, ami alapján az ábrázolt kitevő az a szám, amit 1023-hoz kell hozzáadnunk ahhoz, hogy 1-et kapjunk, ez pedig az 1022. Ne felejtsük el, hogy a mantissza elején a rejtett bitben ott van egy 1-es, így az ábrázolt szám az 1×2-1022 = 2.2250738585072014e-308.

Egy 1996-os JavaScript Reference Specification tanúsága szerint: “MIN_VALUE : The smallest number representable in JavaScript, 2.2250738585072014e-308.” Ugyanakkor, ha beírjuk a konzolra, hogy Number.MIN_VALUE, akkor ezt kapjuk: 5e-324. Ennek a magyarázata a developer.mozilla.org oldalán:

>> Number.MIN_VALUE is the smallest positive number (not the most negative number) that can be represented within float precision — in other words, the number closest to 0. That’s approximately 5E-324. The ECMAScript spec doesn’t define a precise value that implementations are required to support — instead the spec says, “must be the smallest non-zero positive value that can actually be represented by the implementation”. But in practice, its precise value in browsers and in Node.js is 2^-1074. <<

A mantissza 53 bites, így 253 – 1 számot képes tárolni. A legkisebb még lengőpontosan ábrázolható szám a 1×2-1022, ahol a feszített exponens 1. Viszont ha a teljes exponens részt 0-ra vesszük a lebegőpontos ábrázolásban úgy, hogy a mantissza nem nulla, akkor még mindig tudunk ábrázolni 252 -1 értéket, s ezek lesznek a denormalizált számok. A JavaScript így a denormalizált számokkal ábrázolja a 2-1022-nál kisebb számokat, s mivel 1022 + 52 = 1074, ezért a 2-1074 lesz a legkisebb ábrázolható szám. Ennek a lebegőpontos alakja: 0 – 000 0000 0000 – 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0001

EPSILON

A Number.EPSILON azt az értéket tárolja, amely az 1 és a legkisebb 1-nél nagyobb lebegőpontos szám közötti különbséget tárolja. Mivel a mantissza 52 bit hosszúságú, ezért az értéke 2-52 = 2.2204460492503130808472633361816E-16.

Kicsit részletesebben a Round-Half-Even (Banker’s Rounding) kerekítésről

A mindennapi életben Round-Half-Up kerekítést használjuk, azaz, ha egy érték pont félúton van, akkor azt felfelé kerekítjük. Pl. 5.5 ≈ 6. Képek forrása, ill. kerekítések további ismertetése: www.eetimes.com.

Viszont kérdés, hogy mit értünk az alatt, hogy ‘felfelé‘ kerekítünk. UI. ha 5.5 ≈ 6, akkor ‘jó érzéssel’ azt várnánk, hogy -5.5 ≈ -6 legyen, azonban ekkor valójában lefelé kerekítjük a negatív számot, hiszen a ‘felfelé‘ a számegyenesen mindig a jobb felé mutató irány. Ezért külön kerekítés a Round-Half-Up kerekítésen belül a szimmetrikus (-5.5 ≈ -6) és az asszimetrikus (-5.5 ≈ -5) kiterjesztés a negatív számok kerekítésére. A JavaScript Math objektuma ennek a kerekítésnek az asszimetrikus kiterjesztését használja: Math.round(-6.5) = -6; ugyanakkor a .toFixed() metódus pedig a szimmetrikus kiterjesztést: (-6.5).toFixed() a ‘-7’ stringet adja vissza.

kerekítés szimmetrikus kiterjesztése a negatív számokra
kerekítés aszimmetrikus kiterjesztése a negatív számokra

A Round-Half-Up kerekítésnél az a lehetséges probléma, hogy mivel félúton mindig felfelé kerekít, ezért minél többször használjuk egy adathalmazra, annál több torzulást eredményezhet az állandó felfelé kerekítés.

Nem túl életszerű példa és nem is ezt a torzulás illusztrálja, de pl. ha egyenként veszek meg 10 darab 104 forintos valamit, akkor összesen 1050 forintot fogok fizetni, mert minden egyes fizetésnél a pénztárgép felfelé kerekít. Míg, ha egyszerre veszem meg, akkor csak 1040 forintot fogok fizetni, mivel ekkor 10 db kerekítés helyett csak 1 kerekítésre lenne lehetőség.

Ezt úgy lehet megoldani, hogy, ha az érték pont félúton van, akkor néha felefelé, néha pedig lefelé kerekítünk. Erre kínál megoldást a Round-Half-Even kerekítés. Ennél a kerekítésnél ui. ha a kerekítendő érték pont középen van, akkor mindig a (legközelebbi) páros (even) szomszéd értékére kerekít. Pl. 5.5 ≈ 6, de 6.5 ≈ 6. Itt a negatív számokra való kiterjesztéssel nincsen probléma, ui. -5.5 ≈ -6 és -6.5 ≈ -6. Ezt a kiterjesztést ‘Bankers Rounding‘-nak is nevezik, ui. pénzügyi területen használják leginkább.

Lényeges, hogy ez a kerekítés a bináris számrendszerben ugyanúgy használatos, ott ugyanis párosnak tekintjük a 0-ra, páratlannak pedig az 1-re végződő bináris számot.

A 0.1 + 0.2 = 0.30000000000000004 bug a JavaScriptben

Ha beírjuk a konzolra, hogy 0.1 + 0.2 == 0.3, akkor ‘false‘ értéket fogunk kapni. Az ilyen típusú hibáknak a lebegőpontos számábrázolás az oka. A 0.1 ugyanis lebegőpontosan: 0 – 01111111011 – 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010. Láthatjuk, hogy míg a 0.1 a tizes számrendszerben egy véges tizedes tört, addig a kettes számrendszerben egy végtelen szakaszos tizedes tört. Az exponens 1023 – 1019 = 4, azaz a szám 0.0001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010(2)  = 1.1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 x 2-4

A 0.2 pedig: 0 – 01111111100 – 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010, ahol az exponens 1023 – 1020 = 3, azaz a szám 0.0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 010(2)  = 1.1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010 x 2-3.

Mindkét szám esetén a lebegőpontos ábrázolás utolsó két bitje kerekített érték. Ennek a módjáról lentebb még szó lesz.

Mivel a két exponens különböző, ezért a 0.1-et 3-as exponensűvé alakítjuk: 0.1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1101 0 x 2-3

   0.1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1100 1101 0
 + 1.1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1001 1010  
  10.0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0111 0

Ezt a számot normalizáljuk: 1.0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 1 x 2-2. Viszont itt túlcsordulás történik, s a 0011 1(2) pont félúton van a 0011(2) és a 0100 (2) között, s mivel az a szabály, hogy ha a szám pont félúton van két ábrázolható szám között, akkor a páros kettedes jegyre végződő szomszédjához kerekítjük. Így a lebegőpontos alakja: 0 – 011 1111 1101 – 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0011 0100 lesz.

Ez a szám pedig decimálisan a 0.300 000 000 000 000 044 408 920 985 006 261 616 945 266 723 632 812 5, s ez 17 tizedes jeggyel ábrázolva: 0.30000000000000004.

Forrás: indepth.dev, stackoverflow.com, betterprogramming.pub

Egy megjegyzés: két különböző Decimal to Floating-Point konverter a 0.1-re az utolsó két bitre nézve két különböző értéket ad, ami esetleg bezavarhat.

a hibás: binary-system.base-conversion.ro 
0-01111111011-1001100110011001100110011001100110011001100110011001
a (szerintem) helyes: exploringbinary.com 
0 01111111011 1001100110011001100110011001100110011001100110011010

Úgy tűnik, a kerekítéssel van gond. Az első konverter nem kerekíti le az 1001 [1001 ...] végződést. Az 1001[1] kerekítése: mivel az 1001[1] pont a 1001[0] és az 1010[0] között van félúton, ezért a páros szomszédjára kerekít, ami a 1010[0].

A mantissza tehát csak véges számú számjegyet tud tárolni, ezért a lebegőpontos ábrázolás a végtelen szakaszos tizedes törteket csak közelített értékkel tudja tárolni, ezért – amire mindenhol felhívják a figyelmet

“az egyik legelső dolog volt, amit álmunkból felrázva is tudnunk kellett: LEBEGŐPONTOS SZÁMOKAT SOHA NEM VIZSGÁLUNK EGYENLŐSÉGGEL!

Az EPSILON használata

A developer.mozilla.org oldalon – meg még mindenhol – a következő eljárást javasolják annak eldöntésére, hogy egy művelet eredménye azonos-e egy adott értékkel:

0.1+0.2
0.30000000000000004
0.1+0.2==0.3
false
Math.abs(0.1+0.2-0.3) < Number.EPSILON
true

Viszont ezt az eljárást egyelőre ne vegyük készpénznek, ui. van vele egy kis probléma, amire rögtön vissza is térünk.

A számok között lévő közök

Mint láttuk, az epszilon az a szám, amely az 1 és az 1-nél nagyobb legkisebb szám közötti különbséget reprezentálja. Az értéke tkp. az a köz, aminél közelebb nem lehet egymáshoz két szomszédos tizedes tört a lebegőpontos ábrázolásban. Tehát ez egy köz, ami meghatározza, hogy mi az a távolság, aminél nem lehet közelebb két, egynél nagyobb szám. Fontos, hogy az ábrázolás ennél a köznél kisebb számokat is ki tud fejezni, azaz ne keverjük az EPSILON és a MIN_VALUE fogalmát. Nézzük meg még egyszer, hogy mi is az az epszilon.

Az 1-es számot így ábrázoljuk: 1 = 1*20 , a szám pozitív, így az előjelbit: 0; a karakterisztika (feszítve) 1023+0=102310=1 1111 11112 ; ez 11 biten: 01 1111 1111; az ábrázolandó szám mantisszája 53 biten: 1.00…002, ahol az első bit a rejtett bit, így a mantissza 52 biten: 00…00; azaz a szám: 0 – 01111111111 – 0000000000000000000000000000000000000000000000000000

Ez azt jelenti, hogy az 1-es számban 52 szabad bitünk van a további tizedesjegyek ábrázolására, így a legkisebb azt követő szám a 0 – 01111111111 – 0000000000000000000000000000000000000000000000000001, amely 1 + 2-52 értéket vesz fel, így ez az 1-est követő legközelebbi ábrázolható szám.

Kis gyakorlásnak a 39 ábrázolása: 3910=10 01112; normál alakja: 1.00111x 25, hiszen az utolsó számjegytől 5-tel kell előre tolni a kettedes vesszőt. A karakterisztika (feszítetten) 1023+5 = 102810=100 0000 01002; a mantissza pedig (elhagyva a rejtett bitben tárolt első 1-est): 0011100000…00; azaz a szám: 0 – 10000000100 – 0011100000000000000000000000000000000000000000000000

Látható, hogy itt már csak 52 – 5 = 47 bitünk van a tizedesjegyek számára, így a 39 + 2-47 még ábrázolható, de 39 + 2-48 már nem. A konzolon:

39+2**-46
39.000000000000014
39+2**-47
39.00000000000001
39+2**-48
39

S ahogyan növekszik az 1-nél nagyobb szám karakterisztikája, úgy lesz az egymást követő ábrázolható értékek között lévő rés egyre kisebb. S ez ugyanaz a folyamat, amely oda vezet, hogy a MAX_SAFE_INTEGER-nél nagyobb értékek esetén már 2-esével, majd 4-esével, majd 8-asával … kezdenek ugrani az egymást követő egész számok. (Nyilván ott már nem csak a tizedes jegyeket nem tudjuk ábrázolni, de már az utolsó számjegyek is kezdenek ‘beragadni’ 0-ra.)

Egészen más a helyzet viszont a 0 közelében, azaz a -1 és 1 közé eső számoknál. Fentebb szó volt arról, hogy a denormalizált számok segítségével a legkisebb ábrázolható pozitív szám a MIN_VALUE, a 2-1074. A lebegőpontos ábrázolás az ennél kisebb számokat 0-nak látja.

Az 1-nél kisebb számok ábrázolásához a számítás ui. úgy kezdődik, hogy az adott nulla-egész-valamennyi számot az algoritmus elkezdi 2-esekkel szorozni, amíg el nem jut egy egy-egész-valamennyi számig, s ebből állapítja meg, hogy mekkora lesz az exponens. Erre egy példa a binary-system.base-conversion.ro oldalról:

Itt a 0.01 számot alakítjuk lebegőpontos formátummá. Az algoritmus elkezdi 2-sel szorozni a számot újra és újra. Látható, hogy a 7. szorzásra kapunk olyan számot, amelynek az egészrésze 1, azaz a normált alakban a kitevő -7 lesz. Ezután még 52 lépésben ismétlődik ez a szorzás, hogy megkapjuk a mantissza 52 bitjét. Ha viszont a szorzás az elején eléri az 1073-at és még mindig nulla az egészrész, akkor az ábrázolás 0-nak tekinti a számot, ui. -1073-nál kisebb kitevőt már nem tud ábrázolni a rendelkezésre álló biteken.

Most pedig nézzük meg, hogy mi az 1-nél kisebb számok közül a legnagyobb, azaz az 1-es szám bal oldali tizedestört szomszédja. Ha beírjuk a converterbe, hogy 0.9999, s annyi 9-essel, amennyit csak enged, akkor ezt kapjuk: 0 – 01111111110 – 1111111111111111111111111111111111111111111111111111; itt a kitevő 011111111102 – 102310 = 1022 – 1023 = -1 lesz. Ez a szám úgy néz ki, hogy 1.1111111111111111111111111111111111111111111111111111 x 2-1 = 0.11111111111111111111111111111111111111111111111111111, ahol a nem sárga a rejtett bit. Ez pedig azt jelenti, hogy 53 bitünk van a kettedes pontot követő kettedes jegyek tárolására. Azaz a 2-53 még értelmezhető távolság a következő (alulról) szomszédos számhoz. Nézzük a konzolon:

1-2**-53
0.9999999999999999
1-2**-54
1

Azaz itt 2-53 -asával tudunk lépkedni a 0 felé a szomszédos számokon. Viszont, ha a pozitív (nagy) egész számok felől közelítünk a 0 felé, akkor azt látjuk, hogy a szomszédos kettedes törtek közötti hézagok egyre kisebbek lesznek, s ez a tendencia folytatódik akkor is, ha átlépjük az 1-et és tovább közeledünk a 0 felé. 1-nek a legközelebbi alsó szomszédja 2-53 távolságra van, de egy idő után egyre és egyre közelebb lesznek egymáshoz.

Nézzük pl. az 2-10 = 1/1024 ≈ 0.0009765625 szám környezetét. A konzolon ez így néz ki:

1/1024-2**-62
0.0009765624999999998
1/1024-2**-63
0.0009765624999999999
1/1024-2**-64
0.0009765625
1/1024-2**-65
0.0009765625
1/1024-2**-66
0.0009765625

Azaz a 2-10 szám legközelebbi szomszédja lefelé 2-63 távolságra van tőle, s annál közelebbi szomszédja nincs. Felfelé pedig 2-62 távolságra lesz a szomszédja. Azaz pl. 2-10 egy olyan érték, amelynél lefelé haladva 1 bittel nőni fog az ábrázolás pontossága. Miért?

A 2-10 felső szomszédja a 2-10 + 2-62 ≈ 0.0009765625000000002. Ennek a számnak a lebegőpontos alakja: 0 – 01111110101 – 0000000000000000000000000000000000000000000000000001, azaz az exponens 1013 – 1023 = -10; a szám = 1.0000000000000000000000000000000000000000000000000001 x 2-10 = 0.00000000010000000000000000000000000000000000000000000000000001, ahol a sárga jelzi a mantissza ábrázolásban szereplő bitjeit. Azaz itt az 52 értékes számjegyből már 62 értékes számjegy lesz, mert az eredetileg tárolt 52 bithez hozzájön a 10 számjegynyi eltolás, azaz megnőtt az ábrázolás pontossága. – A pontosság tehát mindig az 52 és az exponens abszolút értékének az összege.

A legkisebb – még normált alakkal – ábrázolható szám nagyságrendje 2-1023, így az ahhoz tartozó szomszéd, mivel 1023 + 52 = 1075, de mivel a legkisebb ábrázolható valós pozitív szám a 2-1074, így a számok közötti rések itt már 2-1074 méretűek lesznek. S ez tartani fog az ábrázolható legkisebb számig, a 2-1074-ig, amelytől a felső szomszédja ugyanekkora – 2-1074 – távolságra van.

Ez a rész a számegyenesen az, ahol a lebegőpontos ábrázolás már a denormalizált alakot használja. Azaz a denormalizált alakkal ábrázolt számok távolsága mindig 2-1074.

A lebegőpontos számok eloszlása a számegyenesen

Erről itt van egy elég jó szemléltetés a courses.engr.illinois.edu oldaláról:

A képen jól látható, hogyan sűrűsödnek be a számok a 0 felé közeledve. A számegyenes a normált lebegőpontos számok eloszlását mutatja. A képen az underflow (alulcsordulás) tartománya az, amit a denormalizált lebegőpontos számok töltenek ki. A denormalizált számok nélkül egy elég nagy lyuk lenne a 0 körül.

Ugyanez a gyires.inf.unideb.hu szavaival: “Egy másik könnyen belátható következménye a lebegőpontos ábrázolásnak, hogy a valós számok tartományának nullához közeli tartományában sokkal több racionális szám ábrázolható pontosan, mint amikor a szám abszolút értéke távolabb van a számegyenesen a nullától. Ezt úgy szokták mondani, hogy a nullához közeli tartományban több az egységnyi szakaszra eső reprezentánsok száma, mint távolabb, vagyis minél nagyobb a szám, annál kisebb az elvárható pontosság.

Még pár szó az EPSILON-ról

A geeksforgeeks.org és még rengeteg oldal ajánlása szerint: “Number.EPSILON property is used to check whether floating-point numbers are equal or not.” Illetve a developer.mozilla.org is azt javasolja a felhasználására, hogy “Testing equality“. Illetve: “Number.EPSILON specifies a reasonable margin of error when comparing floating point numbers. It provides a better way to compare floating point values“.

Fentebb láttunk példát a használatára. Például az a+b=c egyenlőséget lebegőpontos értékekre úgy lehet ellenőrizni, hogy megnézzük, hogy mekkora a+b és c távolsága, s ha ez az érték kisebb mint a Number.EPSILON, akkor az egyenlőség fennáll. Azaz az Math.abs(a+b-c)<Number.EPSILON kifejezés logikai értéke true lesz. Pl. Math.abs(0.1+0.2-0.3<Number.EPSILON)-ra a konzol true értéket ad.

Nézzünk néhány példát arra, amikor ez az eljárás csődöt mond!

Az 1/2-2**-53 értéke a konzolon 0.4999999999999999, s ennek a számnak a lebegőpontos alakja 0 01111111101 1111111111111111111111111111111111111111111111111110, míg az 1/2 lebegőpontos alakja 0 01111111110 0000000000000000000000000000000000000000000000000000, s látható, hogy a két szám eltér egymástól, tehát két különböző lebegőpontos számról van szó, a fenti eljárás viszont egyenlőnek tekintené ezt a két számot, hiszen a távolságuk kisebb, mint az epszilon (2-52).

Math.abs(0.5-0.4999999999999999)<Number.EPSILON
true

Továbbá a stackoverflow.com-on találtam:

This is generally very bad idea. Try to run this one: Math.abs(1.1 + 2.2 - 3.3) < Number.EPSILON The result will be false, because the floating point error here is actually bigger than Number.EPSILON. – Samuel Hapak  Feb 27 ’19 at 15:09 

“it can be used to test for the (approximate) equality of floating-point numbers” Not quite. In a sense, the machine epsilon is like the “significant figure” – the accuracy of a number depends on the mathematical operations you perform and the exponent component in your number. Also see Loss of significance on how inaccuracies can accumulate. – Derek 朕會功夫  Oct 4 ’20 at 22:01

Illetve egy eléggé lesújtó vélemény ugyanott [mere-mortal = akinek nincs meg valamihez a megfelelő szakértelme]:

Q1: What is Number.Epsilon supposed to be used for?

Short answer: it’s just something that uber-nerdy computer scientists might make use of in their calculations.

It is not for mere-mortal programmers. Number.Epsilon is a measure of “approximation error”. To get any practical use out of it, you need to scale it according to the size of the numbers you’re working with.

If you’re not intimately familiar with all the internal workings of floating point numbers, then Number.EPSILON is not for you (frankly I’ve not found a use for it yet in anything I’ve done, so I count myself among the “mere mortals”).

Miért olyan fontos, hogy tisztában legyünk a lebegőpontos ábrázolás korlátaival?

Például azért, hogy ne járjunk úgy, mint az ARIANE 5 hordozórakéta tervezői, amikor is egy 7 milliárd dollár költséggel megépített hordozórakéta 1996-ban a kilövés utáni 40. másodpercben felrobbant, amikor is egy hiba csúszott abba, ahogyan egy 64 bites lebegőpontos számot konvertált a repülést irányító számítógép egy 16 bites egész számmá:

The internal SRI software exception was caused during execution of a data conversion from 64-bit floating point to 16-bit signed integer value. The floating point number which was converted had a value greater than what could be represented by a 16-bit signed integer. This resulted in an Operand Error. The data conversion instructions (in Ada code) were not protected from causing an Operand Error, although other conversions of comparable variables in the same place in the code were protected.” Forrás: www-users.cse.umn.edu