Précision en virgule flottante : pourquoi 0.1 + 0.2 ≠ 0.3
0.1 + 0.2 renvoie 0.30000000000000004 parce que ni 0.1 ni 0.2 n’existe à l’intérieur d’un nombre binaire à virgule flottante. Un double ne peut contenir que des valeurs de la forme m × 2ⁿ, et un dixième est une fraction périodique infinie en base 2 : le matériel conserve donc le voisin représentable le plus proche. Le double qui se cache derrière 0.1 vaut exactement 0.1000000000000000055511151231257827021181583404541015625. Celui de 0.2 vaut 0.200000000000000011102230246251565404236316680908203125. Additionnez ces deux valeurs stockées et la somme exacte tombe entre deux doubles représentables. IEEE 754 arrondit vers le plus proche des deux, qui se situe juste au-dessus de 0.3.
Voilà toute la réponse. La précision en virgule flottante est finie, les fractions décimales tombent rarement sur la grille binaire, et chaque opération arrondit à nouveau.
Ce n’est ni une bizarrerie de JavaScript ni un défaut du processeur. Le même résultat apparaît en Python, Java, C, Go, Rust, Swift et dans les colonnes FLOAT de SQL, parce que tous s’appuient sur IEEE 754. Collez n’importe quelle valeur dans le convertisseur IEEE 754 gratuit pour voir les bits exacts et la valeur stockée exacte, chiffre par chiffre.
Restent les questions pratiques : d’où vient l’erreur, pourquoi votre langage la masque par moments, comment comparer deux flottants sans ==, et quel type numérique choisir quand la précision se paie.
La précision en virgule flottante en 60 secondes
Presque toutes les surprises se ramènent à trois faits :
- La virgule flottante binaire ne peut représenter que des nombres de la forme m × 2ⁿ, c’est-à-dire une somme de puissances de deux.
- Les fractions décimales comme
0.1,0.2et0.3ne sont pas de cette forme : elles sont donc arrondies dès l’entrée. - Chaque opération arithmétique arrondit à nouveau son résultat vers la valeur représentable la plus proche.
| Décimal | Représentable exactement ? | Pourquoi |
|---|---|---|
| 0.5 | Oui | 2⁻¹ |
| 0.25 | Oui | 2⁻² |
| 0.75 | Oui | 2⁻¹ + 2⁻² |
| 2.5 | Oui | 2 + 2⁻¹ |
| 100 | Oui | Tout entier inférieur à 2⁵³ |
| 0.1 | Non | 1/10 — le dénominateur a un facteur 5 |
| 0.2 | Non | 1/5 — même problème |
| 0.3 | Non | 3/10 — même problème |
Règle empirique : réduisez la fraction ; si le dénominateur est une pure puissance de deux, la valeur est exacte. Tout facteur 5 qui subsiste signifie que la valeur est stockée de façon approchée.
Pourquoi 0.1 ne peut pas être stocké exactement
Les bits situés après la virgule binaire portent les poids 1/2, 1/4, 1/8, 1/16, 1/32, et ainsi de suite. Essayez d’assembler 0.1 à partir d’eux : vous ne tomberez jamais dessus. Vous obtenez 1/16 = 0.0625, puis 1/32 = 0.09375, puis 1/256 = 0.09765625, de plus en plus près, jamais exact. En binaire, un dixième s’écrit 0.0001100110011… avec 0011 qui se répète à l’infini.
Le décimal souffre du même problème, avec d’autres fractions. Écrire 1/3 en base 10 donne 0.333… et aucune suite finie de chiffres ne tombe juste. Personne n’y voit un bug du décimal. La base 2 place simplement la frontière ailleurs, et 1/10 se trouve du mauvais côté. La documentation Python, Arithmétique en virgule flottante : problèmes et limites, déroule le même développement si vous voulez la version longue.
Le binaire n’est que la base 2, et la même arithmétique positionnelle régit l’hexadécimal et l’octal. Le convertisseur binaire, hex et décimal montre comment une valeur passe d’une base à l’autre, et notre guide de conversion de bases traite en profondeur le cas des entiers. C’est avec les fractions que la base 2 commence à faire mal.
Ce qu’un double stocke réellement
Un double 64 bits se découpe en trois champs : 1 bit de signe, 11 bits d’exposant et 52 bits de mantisse. La mantisse porte un 1 initial implicite qui n’est jamais stocké : vous disposez donc de 53 bits de significande, soit environ 15.95 chiffres décimaux de précision en virgule flottante. L’exposant est stocké avec un biais de 1023.
Demandez à n’importe quel langage plus de chiffres qu’il n’en affiche d’ordinaire et l’approximation surgit :
>>> 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) donne la vue honnête : il convertit le double que vous avez déjà, sans réarrondir, et affiche les 55 chiffres. Le panneau de précision du convertisseur IEEE 754 affiche le même développement pour n’importe quel nombre saisi, à côté de l’erreur d’arrondi signée par rapport à ce que vous avez entré.
L’arithmétique exacte derrière 0.30000000000000004
Pourquoi ce nombre précis, plutôt qu’une autre approximation voisine ? Additionnez exactement les deux valeurs stockées, sans aucun arrondi, et vous obtenez :
0.1 → 0.1000000000000000055511151231257827021181583404541015625
0.2 → 0.200000000000000011102230246251565404236316680908203125
sum → 0.3000000000000000166533453693773481063544750213623046875
Cette somme n’est elle-même pas un double représentable. Elle se situe entre deux voisins de la grille binaire :
below: 0.299999999999999988897769753748434595763683319091796875 (prints as 0.3)
above: 0.3000000000000000444089209850062616169452667236328125 (prints as 0.30000000000000004)
Mesurez maintenant les écarts. La somme exacte se trouve 2⁻⁵⁵ ≈ 2.776e-17 au-dessus du voisin inférieur et 2⁻⁵⁵ en dessous du voisin supérieur : elle tombe exactement à mi-chemin, à égalité parfaite entre les deux.
L’arrondi au plus proche n’a ici aucune distance sur laquelle s’appuyer : IEEE 754 applique donc sa règle de départage, l’arrondi au pair le plus proche (round-half-to-even), qui consiste à retenir le candidat dont le dernier bit de mantisse vaut 0. Le significande du voisin inférieur est 5404319552844595, un nombre impair. Celui du voisin supérieur est 5404319552844596, un nombre pair. La valeur paire l’emporte, ce qui donne le motif binaire 0x3FD3333333333334, soit un ULP au-dessus du double auquel correspond le littéral 0.3, et affiché 0.30000000000000004.
Pourquoi privilégier le pair ? Arrondir systématiquement les demis vers le haut biaiserait les longues sommes dans une seule direction. Alterner vers le voisin pair maintient cette dérive proche de zéro sur de nombreuses opérations. C’est l’arrondi du banquier que pratiquent les comptables, normalisé dans le silicium, et c’est pourquoi le résultat se termine par ...04 plutôt que par n’importe quoi d’autre.
Comparez avec une somme qui n’exige aucun arrondi :
0.5 + 0.25 === 0.75; // true
0.1 + 0.2 === 0.3; // false
0.5, 0.25 et 0.75 valent 2⁻¹, 2⁻² et 2⁻¹ + 2⁻². Les trois sont exacts, leur somme est exacte, et l’égalité se comporte comme l’arithmétique de l’école le laisse entendre. Le panneau des voisins du convertisseur affiche les valeurs représentables précédente et suivante autour de ce que vous saisissez, ainsi que l’écart en ULP : vous pouvez donc examiner vous-même la grille autour de 0.3.
Pourquoi votre langage masque parfois l’erreur
Le sujet doit son parfum de maison hantée à un contraste : 0.1 s’affiche 0.1, mais 0.1 + 0.2 s’affiche avec dix-sept chiffres. Le format de stockage est le même, les sorties n’ont rien à voir.
Les environnements d’exécution modernes utilisent le formatage aller-retour le plus court (shortest round-trip) : ils émettent la plus courte chaîne décimale qui se relit en le double identique. "0.1" fait déjà un aller-retour unique vers la valeur stockée pour 0.1, c’est donc ce que vous voyez. La somme 0.1 + 0.2 est un double différent de celui auquel correspond "0.3" : le formateur doit donc ajouter des chiffres jusqu’à ce que la chaîne soit sans ambiguïté, et il en faut dix-sept. Les travaux de David Gay sur l’arrondi correct, en 1990, ont ouvert cette lignée ; Grisu puis Ryu l’ont rendue assez rapide pour toutes les bibliothèques standard. Votre langage ne ment pas. Il affiche la plus courte étiquette qui identifie la valeur de façon unique.
Comportement langage par langage
| Langage | Type flottant par défaut | Affichage de 0.1 + 0.2 | Option décimale exacte |
|---|---|---|---|
| JavaScript | number (double uniquement) | 0.30000000000000004 | Aucune native ; centimes entiers ou bibliothèque |
| Python | float (double) | 0.30000000000000004 | decimal.Decimal, fractions.Fraction |
| Java | double | 0.30000000000000004 | BigDecimal (à construire depuis une chaîne) |
| C# | double | 0.30000000000000004 | decimal, 128 bits en base 10 |
| Go | float64 | 0.30000000000000004 (via des variables) | math/big.Rat |
| Rust | f64 | 0.30000000000000004 | Crate rust_decimal |
| SQL | FLOAT / DOUBLE PRECISION | 0.30000000000000004 | NUMERIC / DECIMAL |
Deux de ces lignes cachent un piège.
Go évalue les constantes en précision arbitraire. Une expression littérale est calculée exactement à la compilation, puis seulement convertie : la constante 0.1 + 0.2 devient donc exactement 0.3 avant même de devenir un float64 :
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
}
Le BigDecimal de Java hérite de l’erreur si vous lui passez un double. Le constructeur convertit fidèlement les bits qu’on lui remet :
System.out.println(new BigDecimal(0.1));
// 0.1000000000000000055511151231257827021181583404541015625
System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2")));
// 0.3
SQL se divise selon le type de colonne plutôt que selon le dialecte :
-- 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)
Le couple SQLite est le plus déroutant : la valeur affichée dit 0.3, la comparaison dit le contraire, et les deux ont raison.
Comparer des flottants : pourquoi == échoue et pourquoi Number.EPSILON n’est pas une tolérance
0.1 + 0.2 === 0.3 est faux parce que le membre de gauche est un autre double. Le conseil habituel est de comparer avec une tolérance, et la version la plus répétée de ce conseil est fausse :
Math.abs(a - b) < Number.EPSILON; // ⚠️ not a general-purpose comparison
Number.EPSILON vaut 2.220446049250313e-16, soit 2⁻⁵². MDN le définit comme la différence entre 1 et le plus petit double supérieur à 1. Autrement dit, c’est le pas de la grille en 1.0, pas un budget d’erreur universel. L’espacement des flottants double à chaque puissance de deux : un seuil constant est donc faux partout, sauf dans le voisinage où il a été mesuré.
Au voisinage de 1.0, cela fonctionne par hasard :
Math.abs((0.1 + 0.2) - 0.3); // 5.551115123125783e-17
Math.abs((0.1 + 0.2) - 0.3) < Number.EPSILON; // true
Multipliez le même calcul par un milliard et tout s’effondre :
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
Autour de 1e9, l’écart entre doubles voisins avoisine 1.19e-7. Comme 1e9 tombe dans [2²⁹, 2³⁰), l’ULP y vaut 2²⁹⁻⁵² = 2⁻²³ ≈ 1.19e-7, soit environ 500 millions de fois plus que Number.EPSILON. À cet ordre de grandeur, la moindre erreur d’arrondi réelle écrase le seuil : le test répond faux dans tous les cas. Tout en bas, vers 1e-20, la même constante devient beaucoup trop généreuse et déclare égales des valeurs manifestement différentes.
Tolérance absolue, relative et en ULP
Il existe trois façons de fixer une tolérance, et elles ne couvrent pas les mêmes cas :
- La tolérance absolue,
|a − b| <= atol, convient quand vous connaissez l’ordre de grandeur à l’avance. C’est aussi la seule qui fonctionne face à zéro, puisqu’une tolérance relative autour de 0 vaut toujours 0. - La tolérance relative,
|a − b| <= rtol × max(|a|, |b|), suit l’échelle des données sur tous les ordres de grandeur, mais dégénère près de zéro. - La distance en ULP compte les valeurs représentables qui séparent les deux motifs binaires. C’est la mesure la plus précise et la moins lisible.
Combinez les deux premières et vous obtenez la forme que retiennent la plupart des bibliothèques numériques :
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 la fournit dans sa bibliothèque standard, et le allclose de NumPy utilise la même 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
Pour la version stricte, projetez les bits sur un ordre entier monotone et soustrayez :
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
L’égalité stricte == n’est d’ailleurs pas toujours une erreur. Elle convient pour les doubles à valeur entière inférieurs à 2⁵³, pour des constantes que vous avez affectées sans jamais les calculer, et pour vérifier qu’une valeur vaut exactement zéro.
Quand la précision coûte cher : choisir le bon type numérique
Les montants monétaires et les flottants binaires font mauvais ménage, et pas parce qu’une erreur isolée serait grande. Les erreurs sont systématiques et reproductibles, et elles restent invisibles jusqu’au jour où un auditeur les trouve :
19.99 * 100; // 1998.9999999999998
Math.round(19.99 * 100); // 1999
(1.005).toFixed(2); // '1.00' — 1.005 is stored as 1.00499999999999989...
La seconde ligne piège des équipes chaque année. toFixed n’a pas mal arrondi. La valeur qu’on lui a remise était déjà inférieure à 1.005.
Unités mineures entières
Stockez des centimes, pas des dollars. 1999 signifie $19.99, l’arithmétique tourne sur des entiers, et vous ne divisez qu’au moment de l’affichage.
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
Deux réserves. Au-delà de Number.MAX_SAFE_INTEGER (9007199254740991, soit 2⁵³ − 1), les entiers JavaScript cessent d’être exacts : utilisez BigInt. Et les entiers ne décident pas de votre politique d’arrondi : divisions, pourcentages et répartitions de taxes réclament toujours une règle explicite pour savoir où atterrit le centime restant.
Types décimaux
Un type décimal stocke des chiffres en base 10 : les fractions décimales tombent donc juste. Construisez-le à partir d’une chaîne, jamais d’un flottant, sinon vous héritez de l’erreur binaire avant même de commencer :
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 propose BigDecimal, C# un decimal natif de 128 bits, SQL un NUMERIC(12,2). Tous corrigent les fractions décimales. Aucun ne corrige 1/3, car un type décimal reste un format à virgule flottante ; il a simplement déplacé la base de 2 à 10.
Matrice de décision
| Cas d’usage | Type recommandé | Pourquoi |
|---|---|---|
| Argent, facturation, taxes | Unités mineures entières ou décimal | Arithmétique décimale exacte, arrondi auditable |
| Calcul scientifique, physique | double (FP64) | 15–16 chiffres suffisent largement ; meilleur support des bibliothèques |
| Géométrie, graphismes | float ou double + tolérance | Déjà approximatif ; comparez avec un epsilon |
| Entraînement ML | bfloat16 | La portée de FP32 pour la moitié de la mémoire |
| Inférence ML, stockage | FP16 | Plus de bits de mantisse quand les valeurs sont normalisées |
| Compteurs, identifiants, clés | Entier 64 bits ou BigInt | N’ont jamais eu leur place dans un flottant |
Accumulation d’erreurs et annulation catastrophique
Une erreur d’arrondi vaut 1e-17 et reste inoffensive. Dix mille d’entre elles font un ticket de support :
let total = 0;
for (let i = 0; i < 10000; i++) total += 0.1;
total; // 1000.0000000001588
total - 1000; // 1.588205122970976e-10
La dérive croît à peu près avec le nombre d’opérations. Dans une boucle de cette taille, elle est invisible ; dans une agrégation nocturne sur des millions de lignes, elle ne l’est plus.
Annulation catastrophique
La panne la plus vicieuse vient de la soustraction. Soustrayez deux nombres presque égaux : leurs chiffres de tête identiques s’annulent et laissent un résultat dominé par l’erreur déjà accumulée. L’erreur absolue n’augmente pas. C’est l’erreur relative qui explose, parce que le résultat est devenu minuscule alors que l’erreur, elle, garde la même taille.
La formule de variance en une passe des manuels, E[x²] − (E[x])², tombe droit dedans :
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
La vraie variance de population vaut 2/3. La formule en une passe renvoie exactement zéro, une réponse sans rapport avec la bonne, parce que les deux opérandes tournaient autour de 1e18 et que leur différence vit sous le dernier bit stocké. L’algorithme en ligne de Welford évite cela en mettant à jour la moyenne et la somme des écarts au carré de façon incrémentale.
La formule du second degré présente la même faiblesse quand b² ≫ 4ac, et se corrige de la même manière :
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
Les deux lignes calculent la même racine. La première soustrait deux nombres presque égaux ; la seconde réorganise l’algèbre pour ne jamais avoir à le faire. Le texte de Goldberg, What Every Computer Scientist Should Know About Floating-Point Arithmetic, reste la référence sur l’annulation et les bornes d’erreur.
Sommation de Kahan
La sommation compensée de Kahan tient la comptabilité des bits de poids faible que chaque addition jette, puis les réinjecte à l’itération suivante :
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 vous l’offre gratuitement. math.fsum([0.1] * 10_000) renvoie 1000.0, et depuis CPython 3.12 la fonction native sum() applique elle aussi la compensation de Neumaier : sum([0.1] * 10_000) est donc exact, tandis qu’une boucle explicite avec += dérive toujours vers 1000.0000000001588.
Sortez la compensation pour les longues sommes, les agrégats financiers et l’intégration numérique. Passez-vous-en pour une poignée de valeurs ou lorsque vous êtes déjà passé à un type exact.
Les valeurs spéciales qui cassent les hypothèses
IEEE 754 réserve des motifs binaires à des valeurs qui n’obéissent pas aux règles attendues :
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 est différent de tout, y compris de lui-même, et c’est voulu : un calcul raté ne peut ainsi jamais se faire passer pour un résultat valide. Testez-le avec Number.isNaN() ou le math.isnan() de Python.
Le dépassement de capacité est plus discret et plus dangereux : il renvoie ±Infinity et poursuit sa route en aval, jusqu’à ce que quelqu’un remarque un graphique rempli de blancs. Le zéro négatif est un motif binaire distinct qui reste pourtant égal à +0 sous === ; il apparaît lorsqu’une valeur négative s’évanouit par underflow, ou à partir de -1 * 0, et seule la division ou Object.is le révèle.
Les nombres dénormalisés (subnormals) comblent l’espace entre zéro et le plus petit nombre normal. Quand le champ d’exposant ne contient que des zéros, le 1 initial implicite disparaît et la mantisse décroît progressivement vers zéro. La différence de deux flottants distincts n’est donc jamais arrondie à exactement zéro. Le prix à payer est une précision qui s’effrite et, sur certains matériels, une chute de performance brutale. Les puces de valeurs spéciales du convertisseur IEEE 754 chargent ±0, ±Infinity, NaN et le plus petit dénormalisé, pour que vous inspectiez chaque motif binaire directement.
float vs double vs FP16 vs bfloat16
Tous ces formats suivent la même norme et répartissent différemment le budget entre la portée et la précision en virgule flottante :
| Format | Bits totaux | Exposant | Mantisse | Chiffres décimaux approx. | Max fini | Usage typique |
|---|---|---|---|---|---|---|
| binary16 (FP16) | 16 | 5 | 10 | ~3.3 | 65504 | Inférence GPU, stockage compact |
| bfloat16 | 16 | 8 | 7 | ~2.4 | ~3.39 × 10³⁸ | Entraînement ML |
| binary32 (float) | 32 | 8 | 23 | ~7.2 | ~3.40 × 10³⁸ | Graphismes, capteurs, GPU |
| binary64 (double) | 64 | 11 | 52 | ~15.9 | ~1.80 × 10³⁰⁸ | Défaut partout ailleurs |
Les bits d’exposant achètent de la portée ; les bits de mantisse achètent de la précision. FP16 dépense son budget en précision et plafonne à 65504, assez près des valeurs de gradient réelles pour que le débordement vers Infinity soit un risque de routine. bfloat16 fait le pari inverse : il conserve les huit bits d’exposant de FP32 et se contente de sept bits de mantisse, si bien que des valeurs qui feraient déborder FP16 passent sans encombre et que l’entraînement a rarement besoin de loss scaling.
Par défaut, prenez double. Descendez vers un format plus étroit après avoir mesuré un goulot d’étranglement en mémoire ou en bande passante, et vérifiez ce que coûte ce rétrécissement en saisissant la même valeur dans chaque format du convertisseur.
Règles pratiques pour la précision en virgule flottante
- N’utilisez jamais
==sur des flottants calculés. Employez une tolérance combinée, absolue et relative, dimensionnée pour vos données. - Ne prenez pas
Number.EPSILONpour un seuil : il décrit la grille en 1.0, et rien d’autre. - Gardez l’argent en unités mineures entières ou dans un type décimal, et construisez toujours les décimaux à partir de chaînes.
- Compensez les longues sommes avec Kahan, Neumaier ou
math.fsum. - Réécrivez les formules qui soustraient des quantités presque égales plutôt que de resserrer les tolérances autour d’elles.
- Formatez explicitement pour les humains avec
toFixed, une f-string ouprintf. Ne livrez jamais unreprpar défaut à vos utilisateurs. - Faites transiter les nombres entre services sous forme de chaînes ou d’entiers. Un aller-retour JSON à travers un double est silencieux et destructeur.
- Choisissez
NUMERIC, pasFLOAT, pour les colonnes monétaires. Corriger cela après le lancement, c’est une migration et une réconciliation. - Raisonnez en bits quand une valeur semble impossible. C’est le même réflexe qui anime notre guide des opérations bit à bit, et il ramène la plupart des mystères de la virgule flottante à de l’arithmétique vérifiable.
FAQ
Les calculs en virgule flottante sont-ils cassés ?
Les calculs en virgule flottante ne sont pas cassés : ils suivent IEEE 754 à la lettre. La norme représente les nombres réels sur un nombre fini de chiffres binaires, et les fractions décimales comme 0.1 n’ont aucune forme binaire exacte : c’est donc la valeur représentable la plus proche qui est stockée. Ce qui est cassé, c’est l’attente que toute décimale rentre dans la boîte.
Pourquoi Python affiche-t-il 0.1 comme 0.1 mais 0.1 + 0.2 comme 0.30000000000000004 ?
Le repr de Python utilise le formatage aller-retour le plus court : il affiche la plus courte chaîne décimale qui se relit en le double identique. "0.1" identifie déjà son double de façon unique. La somme 0.1 + 0.2 est un double différent de celui auquel correspond "0.3" : le formateur doit donc émettre les dix-sept chiffres pour rester sans ambiguïté.
Pourquoi ne faut-il pas utiliser Number.EPSILON pour comparer deux flottants ?
Number.EPSILON (environ 2.22e-16) est l’écart entre 1 et le double suivant, pas un budget d’erreur universel. L’espacement des flottants double à chaque puissance de deux : près de 1e9, les doubles voisins sont distants d’environ 1.19e-7. La moindre erreur d’arrondi véritable y dépasse EPSILON, ce qui rend la comparaison toujours fausse. Utilisez une tolérance relative ou combinée.
Puis-je utiliser la virgule flottante pour l’argent si j’arrondis à la fin ?
Arrondir à la fin ne sauve pas l’argent en virgule flottante. Les erreurs s’accumulent au fil des additions, des multiplications et des répartitions en plusieurs étapes, et le moment où vous arrondissez change le total. 19.99 * 100 donne déjà 1998.9999999999998. Un audit exige une arithmétique exacte et reproductible : centimes entiers, decimal, BigDecimal ou NUMERIC en SQL.
Quels nombres décimaux peuvent être stockés exactement dans un double ?
Uniquement les valeurs exprimables sous la forme m / 2ⁿ, pour des entiers m et n compris dans la précision du format. Cela couvre 0.5, 0.25, 0.125, 0.75 et 2.5, ainsi que tous les entiers jusqu’à 2⁵³. Réduisez d’abord la fraction : tout facteur 5 qui subsiste au dénominateur, comme dans 0.1, 0.2, 0.3 ou 0.7, signifie que la valeur est approchée.
Pourquoi 0.1 + 0.2 === 0.3 est-il faux alors que 0.5 + 0.25 === 0.75 est vrai ?
Parce que 0.5, 0.25 et 0.75 valent 2⁻¹, 2⁻² et 2⁻¹ + 2⁻², toutes exactement représentables, et que leur somme n’exige aucun arrondi. 0.1, 0.2 et 0.3 sont chacune stockées de façon approchée, et l’arrondi de la somme des deux premières atterrit un ULP au-dessus du double auquel correspond 0.3.
Cela se produit-il dans tous les langages de programmation ?
Tout langage bâti sur la virgule flottante binaire IEEE 754 présente le même comportement : JavaScript, Python, Java, C, C++, Go, Rust, Swift et le FLOAT de SQL. Ce qui change, c’est la précision d’affichage par défaut et la présence ou non d’un type décimal exact livré d’origine, comme le decimal de C# ou le module decimal de Python.