Aller au contenu principal

Les nombres à virgule

Ce que ce chapitre apporte

  • Expliquer pourquoi 0,1 n'a pas d'écriture exacte en machine, et pourquoi ce n'est pas un défaut du langage.
  • Lire les trois champs d'un nombre à virgule : signe, exposant, mantisse.
  • Distinguer trois choses que l'on confond : la valeur écrite, la valeur rangée, la valeur affichée.
  • Situer les deux endroits où la précision se perd dans un calcul, et mesurer chacun.
  • Comparer deux nombres à virgule sans utiliser ==, et choisir la tolérance.
  • Reconnaître les cas où il ne faut pas de nombres à virgule du tout, et dire par quoi les remplacer.
Le chapitre précédent a montré comment un entier et un caractère tiennent dans des bits. Restent les nombres à virgule, et avec eux la surprise la plus connue de la programmation : 0.1 + 0.2 ne donne pas 0.3. Cette bizarrerie est citée partout et expliquée presque nulle part, ce qui laisse une conclusion fausse et tenace, celle que l'ordinateur calculerait mal. Il calcule très bien. C'est l'entrée qui n'existe pas. Ce chapitre ouvre la mémoire et montre, chiffre par chiffre, ce qui s'y trouve à la place.

Une difficulté qui existe déjà en base dix

Personne n'est choqué d'apprendre qu'un tiers ne s'écrit pas exactement en base dix. On écrit 0,333 et on sait que ce n'est pas tout à fait un tiers. La suite des chiffres ne s'arrête jamais, alors il faut bien couper quelque part, et couper veut dire perdre.

La raison tient en une phrase : en base dix, une fraction s'écrit avec un nombre fini de chiffres si, et seulement si, son dénominateur ne contient que des facteurs 2 et 5, les diviseurs de dix. Un demi s'écrit 0,5. Un quart, 0,25. Un cinquième, 0,2. Un tiers, jamais, parce que 3 ne divise aucune puissance de dix.

En base deux, la même règle s'applique avec un seul facteur autorisé : le 2. Un demi s'écrit 0,1. Un quart, 0,01. Un huitième, 0,001. Mais un dixième demande un facteur 5 au dénominateur, et il n'y a pas de 5 dans les puissances de deux. Un dixième est donc, en base deux, exactement ce qu'un tiers est en base dix : une suite infinie et périodique.

Le programme ci-dessous développe plusieurs fractions en base deux, sans passer par les nombres à virgule de Python. Il travaille sur des fractions exactes, et s'arrête tout seul quand le développement se termine.

main.py
Sortie
>_ Prêt à exécuter…

Les quatre premières fractions se terminent. Les trois dernières tournent en rond pour toujours. Un dixième donne 0,000110011001100110011001 et le motif 0011 se répète indéfiniment.

À retenir
Une machine qui doit ranger 0,1 dans un nombre fini de bits n'a pas le choix : elle doit couper. Elle range donc le nombre représentable le plus proche de 0,1, qui n'est pas 0,1.

Ce qui est réellement rangé

Voici ce que la mémoire contient quand un programme écrit 0.1. Les bits se retournent au clic, et la valeur au-dessous suit.

écrit dans le programme0.1binary64 · 64 bits
signe (1)
exposant (11)
mantisse (52)
signe
positif
exposant
1019 − 1023 = -4
nature
normal
hexadécimal
3fb999999999999a
valeur exacte en mémoire

0.100000000000000005551115123125782702118158…

affiché par le langage : 0.1
écart avec 0.1 : 0.0000000000000000055511151231257827021181583404541015625
Ce que la machine range quand le programme écrit 0.1

La ligne à lire est celle qui s'appelle « valeur exacte en mémoire ». Elle ne vaut pas 0,1 : elle vaut

0,1000000000000000055511151231257827021181583404541015625

et ces cinquante-cinq chiffres ne sont pas une approximation de l'approximation. C'est la valeur exacte du nombre qui est dans la mémoire, écrite en entier. Elle se termine, parce qu'un nombre fait de puissances de deux a toujours une écriture décimale finie, et elle est plus longue que ce que l'on imagine, ce qui est précisément la raison pour laquelle aucun langage ne l'imprime.

Python sait la montrer, à condition de la demander.

main.py
Sortie
>_ Prêt à exécuter…

Trois écritures du même nombre. La première est courte et trompeuse, la deuxième est tronquée, la troisième est exacte.

Decimal(0.1) et Decimal("0.1") ne donnent pas la même chose
Decimal(0.1) reçoit un nombre à virgule déjà abîmé, et se contente d'en écrire la valeur exacte. Decimal("0.1") reçoit le texte « 0.1 », et construit le nombre exact un dixième, sans jamais passer par les bits. Les guillemets font toute la différence, et c'est sur eux que repose la dernière section de ce chapitre.

Trois champs, et rien d'autre

Un nombre à virgule est rangé comme la notation scientifique de l'école, mais en base deux. Il tient en trois morceaux :

Définitions

Le signe est un seul bit : 0 pour un nombre positif, 1 pour un nombre négatif.

L'exposant dit de combien de rangs la virgule se déplace. Il est rangé décalé d'une constante, le biais, qui vaut 1023 sur 64 bits, pour qu'un exposant négatif tienne dans un champ de bits ordinaire.

La mantisse porte les chiffres du nombre, après un 1 initial qui n'est pas écrit puisqu'il est toujours là. Cette économie d'un bit s'appelle le bit implicite.

La valeur vaut alors, pour un nombre ordinaire :

(1)signe×1,mantisse×2exposant1023(-1)^{signe} \times 1{,}mantisse \times 2^{exposant - 1023}

Sur la figure précédente, l'exposant affiché se lit 1019 − 1023 = −4, ce qui place la valeur entre 242^{-4} et 232^{-3}, c'est-à-dire entre 0,0625 et 0,125. C'est bien l'intervalle où se trouve 0,1.

Trois manipulations valent mieux qu'un paragraphe. Sur la figure ci-dessus :

  • Retourner le bit de signe, tout à gauche. Seul le signe change, la suite des chiffres reste identique.
  • Retourner le bit de poids faible de l'exposant. La valeur est multipliée ou divisée par deux.
  • Retourner le tout dernier bit de mantisse, à l'extrême droite. La valeur bouge du plus petit écart possible à cet endroit de la droite des nombres.

Ce dernier écart porte un nom, et il est la clé de tout ce qui suit : c'est le pas entre deux nombres représentables voisins. Il n'est pas constant. Près de zéro les nombres représentables sont serrés, et loin de zéro ils sont très espacés.

main.py
Sortie
>_ Prêt à exécuter…

Autour de 101610^{16}, le pas dépasse 1 : il n'y a plus un seul nombre représentable entre deux entiers consécutifs. Cela se vérifie directement.

main.py
Sortie
>_ Prêt à exécuter…

Ajouter 1 à 101610^{16} ne change rien, parce que le résultat n'existe pas et que le plus proche voisin est le point de départ.

Ce qui se range exactement, et ce qui ne se range pas

La règle énoncée plus haut se vérifie d'un coup d'œil. La colonne de droite dit si la valeur écrite dans le programme est celle qui se retrouve en mémoire.

écrit dans le programme0.5binary64 · 64 bits
signe (1)
exposant (11)
mantisse (52)
signe
positif
exposant
1022 − 1023 = -1
nature
normal
hexadécimal
3fe0000000000000
valeur exacte en mémoire

0.5

affiché par le langage : 0.5
0.5 se range sans rien perdre : sa valeur exacte est exactement celle qui était écrite.
écritvaleur exacte en mémoireexact
0.50.5oui
0.250.25oui
0.750.75oui
0.1250.125oui
0.10.1000000000000000055511151231257827021181583404541015625non
0.20.200000000000000011102230246251565404236316680908203125non
0.30.299999999999999988897769753748434595763683319091796875non
Seules les fractions dont le dénominateur est une puissance de deux se rangent sans perte

Un demi, un quart, trois quarts et un huitième passent intacts. Un dixième, deux dixièmes et trois dixièmes ne passent pas. Et il faut noter que 0,3 est rangé en dessous de sa valeur, alors que 0,1 et 0,2 sont rangés au-dessus : c'est déjà la moitié de l'explication de la section suivante.

Pourquoi les prix sont le pire cas possible
Un prix se termine presque toujours par des centimes, donc par des centièmes, donc par un dénominateur qui contient des 5. Aucun montant en euros et centimes ne se range exactement, sauf les multiples de 0,25. C'est la raison pour laquelle aucune comptabilité sérieuse n'utilise de nombres à virgule.

Pourquoi 0,1 + 0,2 ne fait pas 0,3

Voici l'addition, décomposée.

0.1 + 0.2binary64
opérande de gauche

0.1000000000000000055511151231257827021181583404541015625

opérande de droite

0.200000000000000011102230246251565404236316680908203125

leur somme exacte, avant tout arrondi

0.3000000000000000166533453693773481063544750213623046875

ce qui est finalement rangé

0.3000000000000000444089209850062616169452667236328125

affiché par le langage : 0.30000000000000004

signe (1)
0
exposant (11)
01111111101
mantisse (52)
0011001100110011001100110011001100110011001100110100
où la précision se perd
  • à l'écriture des opérandes, avant tout calcul, si l'un des deux n'a pas d'écriture finie en base deux
  • au rangement du résultat : 0.0000000000000000277555756156289135105907917022705078125
  • écart total avec 0.3 : 0.0000000000000000444089209850062616169452667236328125
L'addition la plus célèbre de la programmation, ouverte

Il faut lire cette figure de haut en bas, parce qu'elle sépare deux pertes que tout le monde confond.

La première perte a déjà eu lieu avant la moindre addition. Ni 0,1 ni 0,2 n'ont été rangés tels qu'écrits. Leur somme exacte, celle des deux valeurs réellement en mémoire, ne vaut donc déjà pas 0,3.

La seconde perte est celle du rangement du résultat. Cette somme exacte n'est elle-même pas représentable, et elle doit être arrondie au voisin le plus proche.

Le point intéressant, et contraire à l'intuition, est l'ordre de grandeur des deux : pour cette addition-là, c'est la seconde qui pèse le plus, environ une fois et demie la première. Aucune des deux ne suffit à expliquer le phénomène, et c'est pour cela que l'explication courte, celle qui parle seulement d'un « 0,1 approximatif », laisse toujours un doute.

L'addition, elle, est parfaitement exacte : elle donne le nombre représentable le plus proche de la somme vraie de ses deux entrées. C'est ce qu'on peut demander de mieux à une machine, et c'est exactement ce que la norme lui impose.

À retenir
Le calcul n'est pas faux. Chaque opération rend le représentable le plus proche du résultat vrai. Ce sont les entrées qui n'existaient pas, et la sortie qui n'existe pas non plus.

Une conséquence à laquelle personne ne s'attend

Si chaque opération arrondit, alors changer l'ordre des opérations change le résultat. L'addition des nombres à virgule n'est pas associative.

main.py
Sortie
>_ Prêt à exécuter…

Les mêmes trois nombres, la même opération, deux résultats différents. Ce n'est pas un bogue : chaque somme partielle est arrondie, et les arrondis ne tombent pas au même endroit selon l'ordre.

La même cause produit une dérive quand on additionne longtemps.

main.py
Sortie
>_ Prêt à exécuter…

Dix mille additions font dériver le total d'environ 1,6×10101{,}6 \times 10^{-10}, alors qu'une seule multiplication tombe juste. Moins il y a d'opérations, moins il y a d'arrondis. C'est la première règle pratique du chapitre, et elle vaut pour tous les calculs longs : sommer un million de mesures accumule un million d'arrondis.

Comparer sans le signe égal

De tout ce qui précède découle une consigne simple : ne jamais tester l'égalité de deux nombres à virgule issus de calculs différents.

main.py
Sortie
>_ Prêt à exécuter…

math.isclose compare à une tolérance relative, dont la valeur par défaut est 10910^{-9}, soit environ un milliardième de la taille des nombres comparés. Une tolérance relative est presque toujours ce qu'il faut, parce que le pas entre représentables grandit avec les nombres : un écart acceptable près de 1 est ridicule près de 101610^{16}.

Une exception compte : au voisinage de zéro, une tolérance relative ne veut plus rien dire, puisque tout est relativement loin de zéro. Il faut alors une tolérance absolue, que isclose accepte séparément.

main.py
Sortie
>_ Prêt à exécuter…
Une boucle qui avance par pas de 0,1 ne s'arrête pas où on croit
Écrire while x != 1.0: x = x + 0.1 peut tourner indéfiniment, parce que la valeur 1,0 exacte n'est jamais atteinte. Il faut comparer avec < ou >, ou mieux, compter en entiers et diviser à la fin.

Sur 32 bits, tout se dégrade plus vite

Le format présenté jusqu'ici occupe 64 bits, et c'est celui que Python emploie toujours. Il en existe un autre, sur 32 bits, que l'on rencontre sur les cartes électroniques, dans les images et dans les réseaux de neurones, là où la place et la vitesse comptent plus que la précision.

écrit dans le programme0.1binary32 · 32 bits
signe (1)
exposant (8)
mantisse (23)
signe
positif
exposant
123 − 127 = -4
nature
normal
hexadécimal
3dcccccd
valeur exacte en mémoire

0.100000001490116119384765625

affiché par le langage : 0.10000000149011612
écart avec 0.1 : 0.000000001490116119384765625
Le même 0,1, rangé sur moitié moins de bits

La mantisse tombe de 52 bits à 23, et l'écriture exacte raccourcit d'autant : il reste environ sept chiffres décimaux fiables, contre seize sur 64 bits. Le phénomène est le même, l'échelle change.

Quand il ne faut pas de nombres à virgule

Tous les problèmes de ce chapitre disparaissent en changeant d'outil, et le choix dépend de ce que l'on compte.

Pour de l'argent, compter en entiers. Un prix en centimes est un entier, un entier se range exactement, et rien ne dérive.

main.py
Sortie
>_ Prêt à exécuter…

Pour une arithmétique décimale exacte, le type Decimal. Il calcule en base dix, donc 0,1 y est exactement 0,1. Il est plus lent, et c'est un prix que toute comptabilité accepte.

main.py
Sortie
>_ Prêt à exécuter…

Pour des fractions exactes, le type Fraction. Il garde un numérateur et un dénominateur entiers, et ne perd jamais rien, y compris sur un tiers.

main.py
Sortie
>_ Prêt à exécuter…

Pour des mesures physiques, garder les nombres à virgule. Une température connue à un dixième de degré près n'a que faire d'une précision au millionième : l'erreur de mesure dépasse de très loin l'erreur de représentation. C'est le cas d'usage pour lequel ce format a été conçu, et il y est excellent.

Exercices type

Trouver la valeur exacte rangée pour 0,7. Utiliser Decimal, puis dire si elle est au-dessus ou en dessous de 0,7.

main.py
Sortie
>_ Prêt à exécuter…

Compter, parmi les dixièmes de 0,1 à 0,9, combien se rangent exactement. La réponse se déduit de la règle du dénominateur, et le programme la confirme.

main.py
Sortie
>_ Prêt à exécuter…

Mesurer la dérive d'une somme selon l'ordre des termes. Additionner mille petites valeurs et une grande, dans les deux ordres.

main.py
Sortie
>_ Prêt à exécuter…

Quand le grand nombre vient en premier, chaque petite valeur est absorbée sans laisser de trace, parce qu'elle tombe sous le pas. Quand les petites valeurs s'additionnent entre elles d'abord, elles finissent par peser assez pour compter. Sommer du plus petit au plus grand perd moins.

Vérification

Vérification rapideon peut se reprendre

1.Pourquoi 0,1 ne se range-t-il pas exactement ?

2.Que vaut exactement le nombre rangé quand un programme écrit 0.1 ?

3.Dans 0.1 + 0.2, où la précision se perd-elle ?

4.Comment comparer deux nombres à virgule issus de calculs différents ?

5.Quel type convient pour additionner des montants en euros ?

6.Pourquoi 1e16 + 1 vaut-il encore 1e16 ?

La méthode

Devant un résultat numérique surprenant, dérouler dans cet ordre.

  1. Afficher la valeur exacte avec Decimal(x), et non l'affichage par défaut. La moitié des surprises s'expliquent à cette seule ligne.
  2. Chercher les dénominateurs. Toute fraction dont le dénominateur n'est pas une puissance de deux entre abîmée.
  3. Compter les opérations. Chacune arrondit. Une boucle qui additionne dix mille fois accumule dix mille arrondis.
  4. Remplacer toute égalité entre nombres à virgule par une comparaison à tolérance.
  5. Changer d'outil si le domaine l'exige. Entiers pour de l'argent, Decimal pour du décimal exact, Fraction pour du rationnel exact, nombres à virgule pour des mesures.

Synthèse

  • Une fraction s'écrit avec un nombre fini de chiffres en base deux seulement si son dénominateur est une puissance de deux. 0,1 n'en est pas une, et n'existe donc pas en machine.
  • Ce qui est rangé à la place est le représentable le plus proche, dont l'écriture décimale exacte est finie mais longue : 55 chiffres pour 0,1.
  • Un nombre à virgule tient en trois champs : signe, exposant biaisé, mantisse avec un 1 implicite.
  • L'affichage par défaut est la plus courte écriture qui se relit à l'identique. Il cache la valeur réelle. Decimal(x) la montre.
  • Dans un calcul, la précision se perd à l'écriture des opérandes et à l'arrondi du résultat. Les deux pèsent, et l'opération elle-même est exacte.
  • Le pas entre représentables grandit avec les nombres : au-delà de 101610^{16}, ajouter 1 ne change plus rien.
  • L'addition n'est pas associative : changer l'ordre change le résultat. Sommer du plus petit au plus grand perd moins.
  • Ne jamais comparer avec ==. Employer math.isclose, relative loin de zéro, absolue près de zéro.
  • Pour de l'argent, compter en entiers ou employer Decimal. Pour du rationnel exact, Fraction. Pour des mesures physiques, les nombres à virgule conviennent parfaitement.