Pourquoi la somme de FLOAT ne tombe-t-elle pas juste ? Les erreurs d’arrondi en virgule flottante, expliquées avec des graphiques interactifs

Vous vérifiez les tickets de caisse un par un : tous les montants sont bons… et pourtant, quand vous totalisez les ventes du mois, la somme diffère d’un centime du versement bancaire. Ou bien vous avez stocké prix × taux de TVA dans une colonne FLOAT, et le SUM refuse obstinément de tomber juste. La plupart de ces mystères du type « chaque ligne est bonne, mais le total est faux » ne sont pas des bugs de votre code : ils viennent du fait que les nombres à virgule flottante (FLOAT) ne peuvent stocker la plupart des valeurs que de façon approchée.

Dans cet article, nous allons voir pourquoi les totaux dérivent, avec des formules et des graphiques interactifs que vous pouvez manipuler.

💡 Astuce

Les figures 1, 2 et 3 sont des graphiques vivants, manipulables directement sur cette page. Rien ne vous empêche de jouer d’abord avec les figures et de revenir au texte ensuite.

Si ce que vous cherchez, c’est un guide pour choisir vos types de colonnes, c’est du côté de Bien choisir son type numérique en SQL (le grand classique « de l’argent dans un FLOAT » et un guide de décision FLOAT vs DECIMAL).

Ce que cet article explique

Question
Pourquoi un décimal aussi banal que 0.1 pose-t-il problème ? §1 / fig. 1
Pourquoi une erreur invisible sur une ligne apparaît-elle dans le total ? fig. 2
FLOAT et DECIMAL divergent-ils vraiment sur les mêmes tickets ? fig. 3 / tableau 1
Quel rapport avec le fameux 0.1 + 0.2 ≠ 0.3 ? §5
Que faire concrètement en pratique ? §6 / FAQ

Pour couvrir une plage de nombres gigantesque avec un nombre d’octets fixe, FLOAT / DOUBLE stockent la plupart des fractions décimales sous forme d’approximation arrondie à la valeur représentable la plus proche. Cette erreur n’est pas un défaut : c’est un compromis assumé, la vitesse et la plage d’étendue contre l’exactitude.

C’est précisément pour cela que ces types conviennent mal aux nombres qui doivent tomber juste au centime près : prix, taux de taxe, totaux de facture. Là, c’est DECIMAL (NUMERIC), qui garantit les chiffres décimaux, qui est la bonne réponse. Pour les grandeurs qui charrient de toute façon du bruit de mesure, relevés de capteurs ou statistiques, le minuscule arrondi de FLOAT est en pratique sans conséquence.

Une image mentale utile : une règle graduée à laquelle il manque des graduations. Le décimal le plus ordinaire du monde, 0.1, ne tombe sur aucune graduation de la règle binaire ; il est attiré vers la graduation la plus proche, et les additions répétées empilent ces glissements. Voilà l’anatomie du centime d’écart dans le total mensuel.

1. Pourquoi le 0.1 décimal donne du fil à retordre au binaire

Sur le papier, 0.1 est un décimal fini et propre. Mais dans la machine, FLOAT représente les nombres en binaire (des zéros et des uns), selon la norme IEEE 754. Convertissez 0.1 en binaire : il se répète à l’infini.

\[ 0.1_{10} = 0.0001100110011\ldots_{2} \]

Avec un nombre fini de bits, il faut couper la valeur et l’arrondir à la graduation la plus proche. En notant cette opération \(\mathrm{fl}(x)\), ce qui est réellement stocké n’est pas \(x\) mais \(\mathrm{fl}(x)\).

\[ \mathrm{stock\acute{e}}(x) = \mathrm{fl}(x) \neq x \quad (\text{pour beaucoup de fractions décimales}) \]

Un arrondi isolé est en général minuscule. Les ennuis commencent quand on additionne l’approximation encore et encore.

2. Les graduations que FLOAT32 peut réellement atteindre (fig. 1)

La figure ci-dessous trace, sous forme de points, les valeurs que la simple précision IEEE 754 (FLOAT32) peut réellement représenter autour de la cible de votre choix. Le trait en pointillés est « la valeur décimale que vous voulez » ; le point plein fl est « la graduation la plus proche que FLOAT32 sait retenir ». Chaque fois que le pointillé rate les points, ce ratage est l’erreur d’arrondi.

Figure 1  Les graduations que FLOAT32 peut réellement atteindre (droite numérique et fl le plus proche)

La vue s’ouvre resserrée (8 ULP de chaque côté, soit huit intervalles de graduation). Observez l’écart entre le pointillé (la valeur voulue) et le point bleu fl (la valeur FLOAT32 la plus proche). Élargissez avec le curseur et les graduations se tassent ; resserrez et l’espacement individuel redevient visible.

Dans l’idéal, un total, c’est chaque montant additionné tel quel. En FLOAT, chaque addition arrondit le résultat une nouvelle fois.

\[ s_0 = 0, \qquad s_{k+1} = \mathrm{fl}(s_k + x_k) \]

Chaque \(\mathrm{fl}\) pris isolément est petit, mais à mesure que \(k\) grandit, l’écart entre \(s_n\) et le total idéal peut devenir visible. C’est ainsi qu’une erreur que personne ne remarque sur un ticket isolé fait surface pour la première fois dans le SUM mensuel.

# Le principe (pseudo-code) — x peut varier d’un ticket à l’autre
s = 0
pour chaque montant x_k :
    s = fl(s + x_k)   # arrondi à la graduation la plus proche à chaque fois
# L’idéal est x_1+…+x_n. s peut ne pas y correspondre.

3. L’erreur cumulée à mesure que les tickets s’empilent (fig. 2)

L’axe horizontal est le nombre d’additions \(n\) ; l’axe vertical, l’écart entre le total courant en FLOAT32 et le total décimal idéal. Comme dans une vraie boutique, le montant \(x_k\) suit un cycle au lieu de rester constant (le curseur règle le montant de base).

Le sens de chaque arrondi dépend de la position du total courant entre deux graduations (l’espacement des graduations, l’ULP, varie avec l’ordre de grandeur) et du montant suivant \(x_k\). L’erreur n’est donc pas condamnée à grimper en ligne droite : elle peut passer en négatif, revenir, ou vibrer. Les grandes ondulations apparaissent quand le biais d’arrondi pointe un moment dans la même direction ; les petites dents de scie viennent du sens de l’arrondi qui bascule au gré des montants.

Un point mérite d’être souligné : ces oscillations ne sont pas du hasard. Ajoutez le même montant au même total courant et vous obtiendrez le même résultat, à chaque fois. Les montants de la figure suivent un cycle fixe de multiplicateurs ; aucun dé n’est lancé à l’exécution. Cela a l’air erratique, mais le mécanisme est entièrement déterministe.

Figure 2  L’erreur cumulée au fil des additions (total courant FLOAT32 − idéal)

À retenir : un écart invisible sur une écriture devient visible quand les écritures s’accumulent. Augmentez \(n_{\max}\) et comparez l’ordre de grandeur de l’écart final affiché dans le panneau.

4. Additionner les mêmes tickets en FLOAT et en DECIMAL (fig. 3)

La figure 2 traçait la taille de la dérive. Changeons maintenant de point de vue et regardons comment la même colonne de tickets se scinde en deux totaux, l’un sommé en FLOAT, l’autre en DECIMAL, avec la formule, un tableau et un graphique qui avancent de concert. Les montants suivent le même cycle de multiplicateurs que la figure 2 (base nominale \(0.10\)).

DECIMAL additionne chaque \(x_k\) exactement, en décimal. FLOAT32 intercale \(\mathrm{fl}\) à chaque étape.

\[ S_{\mathrm{DECIMAL}}(n) = x_1 + x_2 + \cdots + x_n \]

\[ S_{\mathrm{FLOAT}}(n) = \mathrm{fl}\bigl(\cdots \mathrm{fl}(\mathrm{fl}(0 + \mathrm{fl}(x_1)) + \mathrm{fl}(x_2)) \cdots + \mathrm{fl}(x_n)\bigr) \]

Écrit en SQL, un exemple minimal ressemble à ceci. Le même SUM ; seuls les types de colonnes changent.

-- Un exemple SQL minimal :
-- les montants varient par ligne, et pourtant le même SUM peut diverger selon le type
SELECT SUM(amount_float)    AS sum_float,     -- FLOAT / REAL
       SUM(amount_decimal)  AS sum_decimal    -- p. ex. DECIMAL(10,2)
FROM sales;

Dans le tableau 1 ci-dessous, les premières lignes ne montrent presque aucune différence, et l’écart ne sort de l’ombre qu’à la dernière ligne, une fois les écritures empilées (le côté DECIMAL est le total exact, calculé à l’échelle entière).

Tableau 1  Totaux courants FLOAT32 vs DECIMAL sur les mêmes tickets (5 premières lignes et ligne finale)
Ligne Ticket (affiché) Total courant FLOAT32 Total courant DECIMAL Écart (F−D)

La figure 3 montre le même processus que le tableau 1, mais avec le total DECIMAL comme ligne de référence (écart zéro). L’axe vertical n’est pas le total lui-même : c’est SUM de FLOAT moins SUM de DECIMAL. Le pointillé orange marque « écart zéro (accord avec DECIMAL) » ; la courbe bleue est l’évolution de cet écart. Les montants varient exactement comme dans la figure 2, donc la fine vibration est de retour (et, encore une fois, ce n’est pas du hasard).

Votre intuition, « les deux totaux montent vers la droite, FLOAT dérivant à peine », est juste. Mais la dérive est si petite au regard des totaux qu’à l’échelle réelle les deux courbes se confondraient en une seule. C’est pourquoi la figure n’agrandit que la différence. Quand la courbe bleue plonge ou remonte, le total ne diminue pas : le sens de l’arrondi a changé en route, poussant le total FLOAT sous le total DECIMAL ou le ramenant vers lui.

Figure 3  SUM de FLOAT − SUM de DECIMAL (évolution de l’écart)

À lire en binôme : tableau 1 = les totaux eux-mêmes ; figure 3 = la différence, agrandie. Déclarer la colonne monétaire en DECIMAL, c’est le choix qui supprime cette dérive bleue avant qu’elle ne commence.

5. Le fameux 0.1 + 0.2 ≠ 0.3, c’est la même histoire

L’exemple ressassé dans les consoles des langages, c’est très exactement la même « règle aux graduations manquantes ». Nous avons ouvert cet article sur des totaux d’argent qui dérivent ; ici, le même cœur se présente sous un autre costume.

\[ \mathrm{fl}(0.1) + \mathrm{fl}(0.2) \;\neq\; \mathrm{fl}(0.3) \quad (\text{dans la plupart des environnements}) \]

Dans le JavaScript de votre navigateur, l’égalité suivante n’est pas stricte (l’arrondi d’affichage peut la masquer).

0.1 + 0.2 === 0.3   // false
0.1 + 0.2           // 0.30000000000000004

Notez que les nombres JavaScript sont en double précision (FLOAT64). Ses graduations sont bien plus fines que les graduations FLOAT32 de la figure 1, mais le tableau est identique : 0.1 ne tombe sur aucune graduation. Basculez la figure 1 entre 0.1, 0.2 et 0.3 et vous verrez chacun rater les graduations dans la même vue. Le total mensuel qui dérive et cette curiosité de console sont la même graduation manquante, vue à deux endroits différents.

6. En pratique

  • Prix, coûts unitaires, taux de taxe, totaux de facture — DECIMAL. Si les données ont été stockées en FLOAT, ré-arrondir après coup ne restitue pas toujours des chiffres déjà perdus.
  • Données de capteurs, statistiques, features de ML — si le bruit de mesure ou de modèle domine de toute façon, FLOAT/DOUBLE suffit largement.
  • Le formatage d’affichage (toFixed et consorts) et le type dans lequel on stocke et on agrège sont deux choses différentes. Un affichage soigné ne change rien : si la colonne est en FLOAT, le SUM peut toujours dériver.

Les tableaux de décision et les exemples de CREATE TABLE sont dans Bien choisir son type numérique en SQL ; cet article, lui, reste concentré sur le mécanisme de la dérive.

En résumé

  • FLOAT retient la plupart des fractions décimales de façon approchée ; c’est le prix d’une plage énorme traitée vite.
  • La cause racine de la dérive : des valeurs décimales aussi propres que 0.1 ne tombent pas sur les graduations binaires.
  • Un arrondi est minuscule ; un arrondi à chaque addition devient visible dans le total.
  • Les mêmes tickets peuvent produire des SUM différents en FLOAT et en DECIMAL (fig. 3).
  • Quand l’argent doit tomber juste, utilisez DECIMAL. L’erreur de FLOAT est voulue par conception ; aucune chasse aux bugs ne la fera disparaître.

FAQ

Q1. Concrètement, quelle est la taille de l’erreur ?

R. FLOAT32 porte environ 7 chiffres décimaux significatifs, DOUBLE (FLOAT64) environ 15–16. « De combien de centimes ça dérivera » dépend des ordres de grandeur, des volumes et de l’ordre des opérations ; la réponse pratique est de faire glisser le compteur de la figure 2 pour se forger une intuition.

Q2. DOUBLE est-il alors acceptable pour l’argent ?

R. Non. Les chiffres supplémentaires rendent seulement la dérive plus difficile à voir ; ils ne garantissent pas que les montants décimaux soient représentés exactement. Pour l’argent, la réponse est DECIMAL.

Q3. Est-ce le même problème que le 0.1+0.2 des langages de programmation ?

R. Même famille. Le FLOAT de SQL et le type flottant par défaut de la plupart des langages partagent l’approximation binaire. C’est une propriété de la représentation, pas un « bug bizarre » propre à la base de données.

Q4. Même table, mais le SUM en FLOAT diffère légèrement d’une exécution à l’autre. Pourquoi ?

R. L’addition flottante peut changer de résultat quand l’ordre des additions change (l’associativité n’est pas strictement respectée). Si une agrégation parallèle ou un autre index modifie l’ordre de lecture des lignes, les derniers chiffres du SUM peuvent bouger sur des données identiques. C’est le même comportement « l’arrondi dépend du total courant » que dans la figure 2. Un SUM en DECIMAL concorde quel que soit l’ordre.

Q5. Nous avons déjà stocké l’argent en FLOAT. On fait quoi ?

R. D’abord, basculez les nouvelles écritures vers DECIMAL. Corriger l’historique exige une décision métier sur les chiffres encore dignes de confiance ; ré-arrondir mécaniquement ne restaure pas toujours la vérité. Voir la section « de l’argent dans un FLOAT » de Bien choisir son type numérique en SQL.

Comments

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *