Skip to content
Terug naar blog
Tutorials

Floating point precisie: waarom 0.1 + 0.2 ≠ 0.3

Waarom floating point precisie 0.1 + 0.2 verstoort, hoe IEEE 754 afrondt en hoe je floats veilig vergelijkt in JS en Python — met gratis online converter.

12 min leestijd

Floating point precisie: waarom 0.1 + 0.2 ≠ 0.3

0.1 + 0.2 geeft 0.30000000000000004 terug omdat 0.1 noch 0.2 bestaat binnen een binair drijvendekommagetal. Een double kan alleen waarden van de vorm m × 2ⁿ bevatten, en een tiende is in grondtal 2 een oneindig repeterende breuk, dus bewaart de hardware in plaats daarvan de dichtstbijzijnde representeerbare buur. De double achter 0.1 is precies 0.1000000000000000055511151231257827021181583404541015625. Die achter 0.2 is 0.200000000000000011102230246251565404236316680908203125. Tel die twee opgeslagen waarden op en de exacte som belandt tussen twee representeerbare doubles. IEEE 754 rondt af naar de dichtstbijzijnde van de twee, en die ligt een haartje boven 0.3.

Dat is het hele antwoord. Floating point precisie is eindig en decimale breuken passen zelden op het binaire raster; elke bewerking rondt het resultaat daarna nog eens af.

Dit is geen eigenaardigheid van JavaScript en geen defect in je processor. Hetzelfde resultaat verschijnt in Python, Java, C, Go, Rust, Swift en de FLOAT-kolommen van SQL, omdat ze allemaal IEEE 754 gebruiken. Plak een willekeurige waarde in de gratis IEEE 754-converter voor drijvende-kommagetallen om de exacte bits en de exacte opgeslagen waarde te zien, cijfer voor cijfer.

De rest van deze handleiding gaat over waar die fout vandaan komt, waarom je taal hem soms verbergt, hoe je floats vergelijkt zonder == en welk numeriek type je pakt wanneer precisie geld kost.

Floating point precisie in 60 seconden

Drie feiten verklaren bijna elke verrassing:

  1. Binair drijvende komma kan alleen getallen van de vorm m × 2ⁿ voorstellen, oftewel sommen van machten van twee.
  2. Decimale breuken zoals 0.1, 0.2 en 0.3 hebben die vorm niet, dus rondt de machine ze bij binnenkomst af.
  3. Elke rekenkundige bewerking rondt het resultaat opnieuw af op de dichtstbijzijnde representeerbare waarde.
DecimaalExact representeerbaar?Waarom
0.5Ja2⁻¹
0.25Ja2⁻²
0.75Ja2⁻¹ + 2⁻²
2.5Ja2 + 2⁻¹
100JaElk geheel getal onder 2⁵³
0.1Nee1/10, noemer bevat een factor 5
0.2Nee1/5, zelfde probleem
0.3Nee3/10, zelfde probleem

Vuistregel: vereenvoudig de breuk; is de noemer een zuivere macht van twee, dan is de waarde exact. Blijft er een factor 5 over, dan slaat de machine de waarde bij benadering op.

Waarom een double 0.1 niet exact kan opslaan

De bits achter een binaire komma dragen de gewichten 1/2, 1/4, 1/8, 1/16, 1/32, enzovoort. Probeer daar 0.1 uit op te bouwen en je komt er nooit precies uit. Je krijgt 1/16 = 0.0625, plus 1/32 = 0.09375, plus 1/256 = 0.09765625, en zo kruip je er telkens dichter naartoe zonder de waarde ooit te raken. In binair is een tiende 0.0001100110011…, waarbij 0011 zich eindeloos herhaalt.

Decimaal heeft precies hetzelfde probleem, alleen met andere breuken. Schrijf 1/3 in grondtal 10 en je krijgt 0.333…; geen enkele eindige rij cijfers raakt hem precies. Niemand noemt dat een bug in het decimale stelsel. Grondtal 2 trekt de grens gewoon ergens anders, en 1/10 valt toevallig aan de verkeerde kant. De Python-documentatie loopt in Floating Point Arithmetic: Issues and Limitations dezelfde uitwerking na, als je de lange versie wilt.

Binair is gewoon grondtal 2, en dezelfde positionele rekenkunde stuurt hex en octaal aan. De omrekenaar voor binair, hex, decimaal en octaal laat zien hoe een waarde tussen talstelsels beweegt, en onze handleiding talstelsel-omrekenen: binary, hex, octal & decimal behandelt de kant van de gehele getallen in detail. Bij breuken begint grondtal 2 pijn te doen.

Wat een double werkelijk opslaat

Een 64-bits double valt uiteen in drie velden: 1 tekenbit, 11 exponentbits en 52 mantissebits. De mantisse draagt een impliciete voorloop-1 die nooit wordt opgeslagen, dus krijg je 53 bits significand, ruwweg 15.95 decimale cijfers aan floating point precisie. De exponent bewaart de machine met een bias van 1023.

Vraag een willekeurige taal om meer cijfers dan ze normaal toont, en de benadering komt tevoorschijn:

>>> 0.1
0.1
>>> f"{0.1:.20f}"
'0.10000000000000000555'
>>> from decimal import Decimal
>>> Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')
(0.1).toFixed(20);      // '0.10000000000000000555'
(0.1).toPrecision(20);  // '0.10000000000000000555'

Decimal(0.1) geeft het eerlijke beeld: het zet de double om die je al hebt, zonder opnieuw af te ronden, en drukt alle 55 cijfers af. Het nauwkeurigheidspaneel in de IEEE 754-converter toont dezelfde uitwerking voor elk getal dat je intypt, naast de afrondingsfout met teken ten opzichte van je invoer.

De exacte rekensom achter 0.30000000000000004

Het resultaat is precies dat getal en geen willekeurige andere bijna-treffer, en dat volgt uit twee stappen: de exacte som, en de regel waarmee IEEE 754 die som afrondt.

Tel de twee opgeslagen waarden exact op, zonder afronding, en je krijgt:

0.1 →  0.1000000000000000055511151231257827021181583404541015625
0.2 →  0.200000000000000011102230246251565404236316680908203125
sum →  0.3000000000000000166533453693773481063544750213623046875

Die som is zelf geen representeerbare double. Hij ligt tussen twee buren op het binaire raster:

below:  0.299999999999999988897769753748434595763683319091796875   (prints as 0.3)
above:  0.3000000000000000444089209850062616169452667236328125     (prints as 0.30000000000000004)

Meet nu de afstanden, dan valt er iets op. De exacte som ligt 2⁻⁵⁵2.776e-17 boven de onderste buur en 2⁻⁵⁵ onder de bovenste. Ze ligt dus exact halverwege, een gelijkspel dat de gewone afrondingsregel niet kan beslechten.

Afronden op de dichtstbijzijnde waarde heeft hier geen afstand om mee te werken, dus past IEEE 754 zijn beslisregel toe: round-half-to-even, oftewel kies de kandidaat waarvan de laatste mantissebit 0 is. De significand van de onderste buur is 5404319552844595, en die is oneven. Die van de bovenste is 5404319552844596, en die is even. De even waarde wint, wat het bitpatroon 0x3FD3333333333334 oplevert. Dat ligt één ULP boven de double die bij de literal 0.3 hoort, en het drukt af als 0.30000000000000004.

Waarom de voorkeur voor even? Halve waarden altijd naar boven afronden zou lange sommen structureel één kant op trekken. Afwisselend naar de even buur schuiven houdt die drift over veel bewerkingen dicht bij nul. Het is dezelfde bankiersafronding die accountants gebruiken, vastgelegd in de hardware, en het is de directe reden dat de bekendste afrondingsfout van het internet op ...04 eindigt.

Zet daar een som tegenover die helemaal geen afronding nodig heeft:

0.5 + 0.25 === 0.75;   // true
0.1 + 0.2 === 0.3;     // false

0.5, 0.25 en 0.75 zijn 2⁻¹, 2⁻² en 2⁻¹ + 2⁻². Alle drie zijn exact, hun som is exact, en gelijkheid gedraagt zich zoals de rekenles belooft. Het burenpaneel van de converter toont de vorige en de volgende representeerbare waarde rond wat je invoert, samen met het ULP-gat, zodat je het raster rond 0.3 zelf kunt bekijken.

Waarom je taal de fout soms verbergt

Hier wordt het verwarrend: 0.1 verschijnt als 0.1, maar 0.1 + 0.2 levert zeventien cijfers op. Hetzelfde opslagformaat, een totaal andere uitvoer.

Moderne runtimes gebruiken formattering volgens shortest round-trip: ze geven de kortste decimale string die weer als exact dezelfde double wordt ingelezen. "0.1" wijst al uniek naar de waarde die voor 0.1 is opgeslagen, dus dat is wat je ziet. De som 0.1 + 0.2 is een andere double dan die bij "0.3" hoort, dus moet de formatter cijfers blijven toevoegen tot de string ondubbelzinnig is, en dat kost er zeventien. Het werk van David Gay uit 1990 over correct afronden zette deze lijn in gang; Grisu en daarna Ryu maakten het snel genoeg voor elke standaardbibliotheek. Je taal liegt dus niet; ze toont het kortste label dat de waarde uniek aanwijst.

Gedrag per taal

TaalStandaard float-type0.1 + 0.2 verschijnt alsExacte decimale optie
JavaScriptnumber (alleen double)0.30000000000000004Niets ingebouwd; hele centen of een bibliotheek
Pythonfloat (double)0.30000000000000004decimal.Decimal, fractions.Fraction
Javadouble0.30000000000000004BigDecimal (bouw het op uit een string)
C#double0.30000000000000004decimal (128 bits, grondtal 10)
Gofloat640.30000000000000004 (via variabelen)math/big.Rat
Rustf640.30000000000000004rust_decimal-crate
SQLFLOAT / DOUBLE PRECISION0.30000000000000004NUMERIC / DECIMAL

Twee van die regels verbergen een valkuil.

Go vouwt constanten op willekeurige precisie. De compiler berekent een letterlijke expressie exact tijdens het compileren en zet die pas daarna om, dus wordt de constante 0.1 + 0.2 exact 0.3 voordat het ooit een float64 wordt:

package main

import "fmt"

func main() {
	fmt.Println(0.1 + 0.2) // 0.3   — constant folded exactly, then converted
	a, b := 0.1, 0.2
	fmt.Println(a + b)      // 0.30000000000000004
	fmt.Println(a+b == 0.3) // false
}

De BigDecimal van Java erft de fout als je er een double in stopt. De constructor zet trouw om welke bits hij ook krijgt aangereikt:

System.out.println(new BigDecimal(0.1));
// 0.1000000000000000055511151231257827021181583404541015625

System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2")));
// 0.3

SQL splitst op kolomtype in plaats van op dialect:

-- PostgreSQL: a bare decimal literal is NUMERIC, which is exact
SELECT 0.1 + 0.2 = 0.3;                          -- t

-- Cast to binary floating point and equality fails
SELECT 0.1::float8 + 0.2::float8 = 0.3::float8;  -- f

-- SQLite: REAL is a double, and the printed value hides it
SELECT 0.1 + 0.2;        -- 0.3
SELECT 0.1 + 0.2 = 0.3;  -- 0  (false)

Staar dat SQLite-paar even aan. Het afgedrukte resultaat zegt 0.3, de vergelijking zegt iets anders, en allebei hebben ze gelijk.

Floats vergelijken: waarom == faalt en Number.EPSILON geen tolerantie is

0.1 + 0.2 === 0.3 is false omdat de linkerkant een andere double is. Het gebruikelijke advies luidt: vergelijk in plaats daarvan met een tolerantie. En de meest herhaalde versie van dat advies klopt niet:

Math.abs(a - b) < Number.EPSILON;   // ⚠️ not a general-purpose comparison

Number.EPSILON is 2.220446049250313e-16, oftewel 2⁻⁵². MDN definieert het als het verschil tussen 1 en de kleinste double groter dan 1. Lees dat nog eens: het is de rasterafstand bij 1.0, geen universeel foutbudget. De afstand tussen floats verdubbelt bij elke macht van twee, dus een constante drempel is overal fout behalve in de buurt waar iemand hem heeft gemeten.

Rond 1.0 werkt het toevallig wel:

Math.abs((0.1 + 0.2) - 0.3);                  // 5.551115123125783e-17
Math.abs((0.1 + 0.2) - 0.3) < Number.EPSILON; // true

Schaal dezelfde berekening met een miljard op en het stort in:

const a = (0.1 + 0.2) * 1e9;
const b = 0.3 * 1e9;

a === b;                          // false
Math.abs(a - b);                  // 5.960464477539063e-8
Math.abs(a - b) < Number.EPSILON; // false — yet a and b are adjacent doubles

Rond 1e9 bedraagt de afstand tussen naburige doubles ongeveer 1.19e-7. Omdat 1e9 in [2²⁹, 2³⁰) valt, is de ULP daar 2²⁹⁻⁵² = 2⁻²³ ≈ 1.19e-7, ongeveer 500 miljoen keer groter dan Number.EPSILON. Elke echte afrondingsfout van die orde overtreft de drempel ruimschoots, dus geeft de controle voor altijd false terug. Helemaal beneden bij 1e-20 is dezelfde constante veel te ruim en verklaart ze duidelijk verschillende waarden gelijk.

Absolute, relatieve en ULP-tolerantie

Welke tolerantie je nodig hebt, hangt vooral af van de vraag of je de grootteorde van je data vooraf kent:

  • Absolute tolerantie, |a − b| <= atol, werkt wanneer je die grootteorde wél kent. Het is ook het enige dat werkt tegenover nul, want een relatieve tolerantie rond 0 is altijd 0.
  • Relatieve tolerantie, |a − b| <= rtol × max(|a|, |b|), schaalt met je data over grootteordes heen, maar valt uit elkaar rond nul.
  • ULP-afstand telt hoeveel representeerbare waarden er tussen de twee bitpatronen liggen. Dat is het nauwkeurigst en tegelijk het slechtst leesbaar.

Combineer de eerste twee en je krijgt de vorm die numerieke bibliotheken standaard gebruiken:

function nearlyEqual(a, b, rtol = 1e-9, atol = 1e-12) {
  if (a === b) return true;            // handles Infinity === Infinity
  const diff = Math.abs(a - b);
  return diff <= Math.max(rtol * Math.max(Math.abs(a), Math.abs(b)), atol);
}

nearlyEqual(0.1 + 0.2, 0.3);                  // true
nearlyEqual((0.1 + 0.2) * 1e9, 0.3 * 1e9);    // true
nearlyEqual(0, 1e-15);                        // true
nearlyEqual(1, 1.0001);                       // false

Python levert dit in de standaardbibliotheek, en de allclose van NumPy gebruikt dezelfde formule:

import math
math.isclose(0.1 + 0.2, 0.3, rel_tol=1e-9, abs_tol=1e-12)  # True
math.isclose(1e9, 1e9 + 1e-7, rel_tol=1e-9)                # True

Voor de strikte versie zet je de bits om naar een monotone volgorde van gehele getallen en trek je die van elkaar af:

const view = new DataView(new ArrayBuffer(8));

function toOrdinal(x) {
  view.setFloat64(0, x);
  const i = view.getBigInt64(0);
  return i >= 0n ? i : -(1n << 63n) - i;   // make negatives order correctly
}

function ulpDistance(a, b) {
  const d = toOrdinal(a) - toOrdinal(b);
  return d < 0n ? -d : d;
}

ulpDistance(0.1 + 0.2, 0.3);                // 1n
ulpDistance((0.1 + 0.2) * 1e9, 0.3 * 1e9);  // 1n
ulpDistance(-0, 0);                         // 0n

Exacte == is trouwens niet altijd fout. Het kan prima voor doubles met gehele waarden onder 2⁵³, voor constanten die je hebt toegekend en waarop je nooit hebt gerekend, en om te controleren of een waarde exact nul is.

Als precisie geld kost: het juiste numerieke type kiezen

Geldbedragen en binaire floats passen slecht bij elkaar, en niet omdat één afzonderlijke fout groot is. De fouten zijn klein en systematisch, en ze blijven onzichtbaar tot een accountant ze vindt:

19.99 * 100;              // 1998.9999999999998
Math.round(19.99 * 100);  // 1999

(1.005).toFixed(2);       // '1.00'  — 1.005 is stored as 1.00499999999999989...

Die tweede pakt elk jaar weer teams. toFixed rondde niet verkeerd af. De waarde die het kreeg aangereikt lag al onder 1.005.

Gehele getallen in de kleinste eenheid

Sla centen op, geen hele eenheden. 1999 betekent $19.99, het rekenwerk gebeurt op gehele getallen, en je deelt pas op het moment van weergave.

const priceCents = 1999;             // $19.99
const subtotal = priceCents * 3;     // 5997 — exact

const totalCents = 1000;             // $10.00 split three ways
const share = Math.floor(totalCents / 3);   // 333
const remainder = totalCents - share * 3;   // 1 cent to allocate

Daar horen twee kanttekeningen bij. Boven Number.MAX_SAFE_INTEGER (9007199254740991, oftewel 2⁵³ − 1) zijn gehele getallen in JavaScript niet meer exact, dus gebruik daar BigInt. En gehele getallen bepalen je afrondingsbeleid niet: delingen, percentages en btw-verdelingen hebben nog steeds een expliciete regel nodig voor waar de overgebleven cent naartoe gaat.

Decimale types

Een decimaal type slaat cijfers in grondtal 10 op, dus komen decimale breuken exact uit. Bouw het op uit een string, nooit uit een float, anders erf je de binaire fout nog voordat je begint:

from decimal import Decimal

Decimal("0.1") + Decimal("0.2") == Decimal("0.3")   # True
Decimal(0.1)                                        # 0.1000000000000000055511151231257827021181583404541015625
Console.WriteLine(0.1m + 0.2m);          // 0.3
Console.WriteLine(0.1m + 0.2m == 0.3m);  // True

Java heeft BigDecimal, C# een ingebouwde 128-bits decimal, SQL een NUMERIC(12,2). Ze lossen allemaal decimale breuken op. Geen enkele lost 1/3 op, want een decimaal type is nog steeds een drijvendekommaformaat; het heeft alleen het grondtal van 2 naar 10 verschoven.

Beslismatrix

ToepassingAanbevolen typeWaarom
Geld, facturatie, btwGehele getallen in de kleinste eenheid of decimalExacte decimale rekenkunde, controleerbare afronding
Wetenschap, natuurkundedouble (FP64)15–16 cijfers is ruim voldoende; beste ondersteuning in bibliotheken
Geometrie, graphicsfloat of double + tolerantieToch al benaderend; vergelijk met epsilon
ML-trainingbfloat16Het bereik van FP32 bij de helft van het geheugen
ML-inferentie, opslagFP16Meer mantissebits als waarden genormaliseerd zijn
Tellers, ID’s, sleutels64-bits integer of BigIntDeze horen niet thuis in een float

Foutopstapeling en catastrofale uitdoving

Eén afrondingsfout is 1e-17 en onschuldig. Tienduizend ervan zijn een supportticket:

let total = 0;
for (let i = 0; i < 10000; i++) total += 0.1;

total;         // 1000.0000000001588
total - 1000;  // 1.588205122970976e-10

De drift groeit ongeveer mee met het aantal bewerkingen. In een lus van deze omvang zie je er niets van; in een nachtelijke aggregatie over miljoenen rijen wel.

Catastrofale uitdoving

De gemenere fout zit in aftrekken. Trek twee bijna gelijke getallen van elkaar af en hun overeenkomende voorste cijfers vallen tegen elkaar weg, waardoor vooral de fout overblijft die zich al had opgestapeld. De absolute fout groeit niet, maar de relatieve fout explodeert, want het resultaat is nu piepklein terwijl de fout even groot bleef.

De klassieke variantieformule in één doorgang, E[x²] − (E[x])², loopt er recht in:

const data = [1e9, 1e9 + 1, 1e9 + 2];
const mean = data.reduce((s, x) => s + x, 0) / data.length;

// One-pass: E[x²] − (E[x])²
data.reduce((s, x) => s + x * x, 0) / data.length - mean * mean;  // 0

// Two-pass: centre the data first
data.reduce((s, x) => s + (x - mean) ** 2, 0) / data.length;      // 0.6666666666666666

De werkelijke populatievariantie is 2/3. De formule in één doorgang geeft exact nul terug, een volstrekt ander antwoord dus, omdat beide operanden rond 1e18 lagen en het verschil ertussen onder de laatst opgeslagen bit leeft. Het online algoritme van Welford omzeilt dit door het gemiddelde en de som van gekwadrateerde afwijkingen stapsgewijs bij te werken.

De abc-formule heeft dezelfde zwakte wanneer b² ≫ 4ac, en dezelfde soort oplossing:

const a = 1, b = 1e8, c = 1;
const disc = Math.sqrt(b * b - 4 * a * c);

(-b + disc) / (2 * a);   // -7.450580596923828e-9  — about 25% wrong
(2 * c) / (-b - disc);   // -1e-8                  — correct

Beide regels berekenen dezelfde wortel. De eerste trekt twee bijna gelijke getallen van elkaar af; de tweede herschikt de algebra zodat dat nooit hoeft. Goldbergs What Every Computer Scientist Should Know About Floating-Point Arithmetic is nog altijd de standaardbehandeling van uitdoving en foutgrenzen.

Kahan-sommatie

De gecompenseerde sommatie van Kahan houdt lopend bij welke lage bits elke optelling weggooit, en voert die bij de volgende iteratie weer terug:

function kahanSum(values) {
  let sum = 0;
  let compensation = 0;
  for (const value of values) {
    const y = value - compensation;
    const t = sum + y;
    compensation = (t - sum) - y;   // the bits that fell off
    sum = t;
  }
  return sum;
}

kahanSum(new Array(10000).fill(0.1));   // 1000  — exactly

Python geeft je dit gratis. math.fsum([0.1] * 10_000) geeft 1000.0 terug, en sinds CPython 3.12 gebruikt de ingebouwde sum() ook Neumaier-compensatie, dus is sum([0.1] * 10_000) exact terwijl een expliciete +=-lus nog steeds afdrijft naar 1000.0000000001588.

Pak compensatie erbij voor lange sommen, financiële aggregaties en numerieke integratie. Sla het over bij een handvol waarden of wanneer je al naar een exact type bent overgestapt.

De speciale waarden die je aannames onderuithalen

IEEE 754 reserveert bitpatronen voor waarden die zich niet houden aan de regels die je verwacht:

NaN === NaN;          // false — mandated by the standard
Number.isNaN(NaN);    // true
[NaN].indexOf(NaN);   // -1   (uses ===)
[NaN].includes(NaN);  // true (uses SameValueZero)

-0 === 0;             // true
Object.is(-0, 0);     // false
1 / -0;               // -Infinity

1e308 * 10;           // Infinity — overflow never throws
Infinity - Infinity;  // NaN
Number.MIN_VALUE;     // 5e-324 — the smallest subnormal double

NaN is per ontwerp ongelijk aan alles, inclusief zichzelf, zodat een mislukte berekening zich nooit kan voordoen als een geldig resultaat. Test erop met Number.isNaN() of met math.isnan() uit Python.

Overflow is stiller en gevaarlijker: het geeft ±Infinity terug en gaat gewoon door, waardoor de waarde stroomafwaarts doorwerkt tot iemand een grafiek vol lege plekken opmerkt. Negatieve nul is een apart bitpatroon dat onder === toch gelijk is aan +0; het duikt op bij underflow van een negatieve waarde of uit -1 * 0, en alleen een deling of Object.is maakt het zichtbaar.

Subnormale getallen vullen het gat tussen nul en het kleinste normale getal. Bestaat het exponentveld helemaal uit nullen, dan vervalt de impliciete voorloop-1 en krimpt de mantisse geleidelijk richting nul. Daardoor rondt het verschil tussen twee ongelijke floats nooit precies op nul af. De prijs is dalende precisie en, op sommige hardware, een steile prestatieval. De knoppen voor speciale waarden in de IEEE 754-converter laden ±0, ±Infinity, NaN en het kleinste subnormale getal, zodat je elk bitpatroon direct kunt bekijken.

float vs double vs FP16 vs bfloat16

Alle vier gebruiken hetzelfde bouwprincipe, maar verdelen hun bits anders over bereik en floating point precisie:

FormaatTotaal bitsExponentMantisseDecimale cijfers bij benaderingGrootste eindige waardeTypisch gebruik
binary16 (FP16)16510~3.365504GPU-inferentie, compacte opslag
bfloat161687~2.4~3.39 × 10³⁸ML-training
binary32 (float)32823~7.2~3.40 × 10³⁸Graphics, sensoren, GPU
binary64 (double)641152~15.9~1.80 × 10³⁰⁸Standaard overal elders

Exponentbits kopen bereik; mantissebits kopen precisie. FP16 steekt zijn budget in precisie en houdt op bij 65504, dicht genoeg bij echte gradiëntwaarden om overflow naar Infinity tot een alledaags risico te maken. bfloat16 maakt de omgekeerde afweging: het houdt de acht exponentbits van FP32 en neemt genoegen met zeven mantissebits, waardoor waarden die FP16 zouden laten overlopen probleemloos passeren en training zelden loss scaling nodig heeft.

Neem standaard double. Stap pas over op een smaller formaat als je een knelpunt in geheugen of bandbreedte hebt gemeten, en controleer wat die versmalling kost door dezelfde waarde in de converter in elk formaat in te voeren.

Praktische regels voor floating point precisie

  • Gebruik nooit == op berekende floats, maar een gecombineerde absolute-plus-relatieve tolerantie die past bij je data.
  • Behandel Number.EPSILON niet als drempel. Het beschrijft het raster bij 1.0 en niets daarbuiten.
  • Houd geld in gehele getallen van de kleinste eenheid of in een decimaal type, en bouw decimals altijd op uit strings.
  • Compenseer lange sommen met Kahan, Neumaier of math.fsum.
  • Herschik formules die bijna gelijke grootheden van elkaar aftrekken, in plaats van de toleranties eromheen strakker te zetten.
  • Formatteer expliciet voor mensen met toFixed, een f-string of printf. Lever nooit een standaard-repr op aan gebruikers.
  • Stuur getallen over servicegrenzen heen als string of als geheel getal. Een gang door JSON heen en terug via een double verliest stilletjes informatie.
  • Kies NUMERIC, niet FLOAT, voor valutakolommen. Dit na de lancering repareren betekent een migratie plus een reconciliatie.
  • Denk in bits wanneer een waarde onmogelijk lijkt. Dezelfde reflex zit achter onze handleiding bitsgewijze bewerkingen uitgelegd: AND, OR, XOR, shifts en maskers, en hij verandert raadsels rond drijvende komma in rekenwerk dat je kunt narekenen.

Veelgestelde vragen

Is rekenen met floating point kapot?

Rekenen met floating point is niet kapot; het volgt IEEE 754 tot op de letter. De standaard stelt reële getallen voor in een eindig aantal binaire cijfers, en decimale breuken zoals 0.1 hebben geen exacte binaire vorm, dus slaat de machine de dichtstbijzijnde representeerbare waarde op. Wat wél kapot is, is de verwachting dat elke decimale breuk past.

Waarom drukt Python 0.1 af als 0.1, maar 0.1 + 0.2 als 0.30000000000000004?

De repr van Python gebruikt formattering volgens shortest round-trip: hij drukt de kortste decimale string af die weer als exact dezelfde double wordt ingelezen. "0.1" wijst zijn double al uniek aan. De som 0.1 + 0.2 is een andere double dan die bij "0.3" hoort, dus moet de formatter alle zeventien cijfers uitschrijven om ondubbelzinnig te blijven.

Waarom kun je Number.EPSILON beter niet gebruiken om twee floats te vergelijken?

Number.EPSILON (ongeveer 2.22e-16) is het gat tussen 1 en de eerstvolgende double, geen universeel foutbudget. De afstand tussen floats verdubbelt bij elke macht van twee, dus liggen naburige doubles rond 1e9 ongeveer 1.19e-7 uit elkaar. Elke echte afrondingsfout daar overtreft EPSILON, waardoor de vergelijking altijd false oplevert. Gebruik een relatieve of gecombineerde tolerantie.

Kan ik floating point voor geld gebruiken als ik pas aan het eind afrond?

Aan het eind afronden redt floating point voor geld niet. Fouten stapelen zich op over optellingen, vermenigvuldigingen en verdelingen in meerdere stappen, en het moment waarop je afrondt verandert het totaal. 19.99 * 100 levert nu al 1998.9999999999998 op. Controleerbaarheid vraagt reproduceerbare exacte rekenkunde: hele centen, decimal, BigDecimal of SQL NUMERIC.

Welke decimale getallen kan een double exact opslaan?

Alleen waarden die je kunt schrijven als m / 2ⁿ met gehele m en n binnen de precisie van het formaat. Daaronder vallen 0.5, 0.25, 0.125, 0.75 en 2.5, plus elk geheel getal tot 2⁵³. Vereenvoudig de breuk eerst: elke overgebleven factor 5 in de noemer, zoals bij 0.1, 0.2, 0.3 of 0.7, betekent dat de waarde bij benadering wordt opgeslagen.

Waarom is 0.1 + 0.2 === 0.3 false, maar 0.5 + 0.25 === 0.75 true?

Omdat 0.5, 0.25 en 0.75 gelijk zijn aan 2⁻¹, 2⁻² en 2⁻¹ + 2⁻²: alle drie zijn exact representeerbaar, en hun som heeft geen afronding nodig. 0.1, 0.2 en 0.3 worden elk bij benadering opgeslagen, en het afronden van de som van de eerste twee belandt één ULP boven de double die bij 0.3 hoort.

Gebeurt dit in elke programmeertaal?

Elke taal die op binair floating point volgens IEEE 754 is gebouwd, laat hetzelfde gedrag zien: JavaScript, Python, Java, C, C++, Go, Rust, Swift en de FLOAT van SQL. Wat verschilt, is de standaard afdrukprecisie en of er een exact decimaal type standaard in zit, zoals de decimal van C# of de decimal-module van Python.

Tags: floating-point ieee-754 javascript python numbers

Gerelateerde artikelen

Alle artikelen bekijken