05 - Réels

Le problème fondamental

Nous avons vu comment représenter les entiers en binaire. Mais comment un ordinateur peut-il stocker un nombre comme \(3,14\) ou \(0,1\) ? Le problème est plus subtil qu’il n’y paraît.

Essayons dans Python :

>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False

Le résultat n’est pas \(0,3\) mais \(0,30000000000000004\). Ce n’est pas un bug de Python : c’est une conséquence directe de la façon dont les nombres à virgule sont stockés en mémoire. Pour comprendre d’où vient cette erreur, il faut d’abord comprendre comment on représente les parties décimales en binaire.

Partie décimale en binaire

Rappel : la notation positionnelle

En base 10, l’écriture \(6,625\) signifie :

\[6 \times 10^0 + 6 \times 10^{-1} + 2 \times 10^{-2} + 5 \times 10^{-3}\]

Le même principe s’applique en base 2 : les chiffres après la virgule correspondent aux puissances négatives de 2.

Position\(2^{-1}\)\(2^{-2}\)\(2^{-3}\)\(2^{-4}\)
Valeur0,50,250,1250,0625

Conversion binaire vers décimal

On additionne les puissances de 2 correspondant aux bits à 1.

Exemple. \(110,101_2\) :

\[1 \times 2^2 + 1 \times 2^1 + 0 \times 2^0 + 1 \times 2^{-1} + 0 \times 2^{-2} + 1 \times 2^{-3} = 4 + 2 + 0,5 + 0,125 = 6,625_{10}\]

Conversion décimal vers binaire

Pour la partie entière, on procède par divisions successives par 2 (comme dans le chapitre sur les bases). Pour la partie décimale, on utilise des multiplications successives par 2 :

  • on multiplie la partie décimale par 2 ;
  • la partie entière du résultat donne le bit suivant ;
  • on recommence avec la nouvelle partie décimale.

Exemple. Convertissons \(0,6875_{10}\) en binaire :

0,6875 × 2 = 1,375  → bit : 1
0,375  × 2 = 0,75   → bit : 0
0,75   × 2 = 1,5    → bit : 1
0,5    × 2 = 1,0    → bit : 1

Donc \(0,6875_{10} = 0,1011_2\).

Vérification. \(0,5 + 0,125 + 0,0625 = 0,6875\). C’est correct.

Le piège : des développements infinis

Essayons maintenant avec \(0,1_{10}\) :

0,1  × 2 = 0,2   → bit : 0
0,2  × 2 = 0,4   → bit : 0
0,4  × 2 = 0,8   → bit : 0
0,8  × 2 = 1,6   → bit : 1
0,6  × 2 = 1,2   → bit : 1
0,2  × 2 = 0,4   → bit : 0  ← la séquence recommence !
0,4  × 2 = 0,8   → bit : 0
...

Le calcul ne s’arrête jamais : \(0,1_{10} = 0,0001100110011\ldots{}_2\) (la séquence \(0011\) se répète indéfiniment). Comme l’ordinateur ne dispose que d’un nombre fini de bits, il est obligé de tronquer cette suite : il ne stocke qu’une approximation de \(0,1\).

C’est exactement ce qui explique le résultat surprenant de 0.1 + 0.2 en Python.

Anecdote. Le 25 février 1991, pendant la guerre du Golfe, une batterie américaine de missiles Patriot a échoué à intercepter un missile Scud irakien, causant la mort de 28 soldats. La cause : l’horloge interne comptait le temps en dixièmes de seconde, et \(0,1\) n’a pas d’écriture finie en binaire. Après 100 heures de fonctionnement, l’erreur d’arrondi accumulée atteignait 0,34 seconde, pendant laquelle un Scud parcourt environ 500 mètres.

La norme IEEE 754

Pour que tous les ordinateurs représentent les nombres à virgule de la même façon, la norme IEEE 754 (1985) définit un format standardisé. L’idée est d’utiliser la notation scientifique binaire : tout nombre non nul peut s’écrire sous la forme :

\[(-1)^S \times 1,F \times 2^e\]

où \(S\) est le bit de signe, \(F\) la partie fractionnaire (appelée mantisse) et \(e\) l’exposant.

Structure d’un flottant en simple précision (32 bits)

ChampNombre de bitsRôle
Signe (S)10 = positif, 1 = négatif
Exposant (E)8Exposant biaisé (voir ci-dessous)
Mantisse (F)23Chiffres après la virgule du \(1,\ldots\)

Pourquoi un exposant « biaisé » ?

L’exposant \(e\) peut être positif ou négatif (pour représenter des nombres très petits comme \(0,001\)). Plutôt que d’utiliser un bit de signe séparé pour l’exposant, la norme ajoute un biais de 127 : on stocke \(E = e + 127\).

Ainsi, un exposant effectif de \(-3\) est stocké comme \(E = -3 + 127 = 124\), et un exposant de \(+5\) comme \(E = 5 + 127 = 132\). Les valeurs stockées vont de 1 à 254 (les valeurs 0 et 255 sont réservées pour les cas spéciaux).

La formule complète est :

\[\text{valeur} = (-1)^S \times (1 + F) \times 2^{E - 127}\]

Exemple détaillé : coder \(-6,625\)

Suivons les cinq étapes de la conversion :

1. Signe. Le nombre est négatif, donc \(S = 1\).

2. Valeur absolue en binaire. \(6,625_{10} = 110,101_2\) (on a \(6 = 110_2\) et \(0,625 = 0,101_2\)).

3. Notation normalisée. On déplace la virgule pour obtenir \(1,\ldots \times 2^e\) :

\[110,101_2 = 1,10101_2 \times 2^2\]

La virgule a été décalée de 2 positions vers la gauche, donc \(e = 2\).

4. Exposant biaisé. \(E = 2 + 127 = 129_{10} = 10000001_2\).

5. Mantisse. On prend les chiffres après le \(1,\) et on complète à 23 bits :

\[10101\underbrace{000000000000000000}_{18 \text{ zéros}}\]

Résultat final :

S   Exposant    Mantisse
1   10000001    10101000000000000000000

Deuxième exemple : coder \(0,15625\)

1. Signe. Positif, donc \(S = 0\).

2. Valeur absolue en binaire. \(0,15625 = 0,00101_2\) (car \(0,125 + 0,03125 = 0,15625\), soit \(2^{-3} + 2^{-5}\)).

3. Notation normalisée. \(0,00101_2 = 1,01_2 \times 2^{-3}\).

4. Exposant biaisé. \(E = -3 + 127 = 124_{10} = 01111100_2\).

5. Mantisse. \(01\underbrace{000000000000000000000}_{21 \text{ zéros}}\)

Résultat :

S   Exposant    Mantisse
0   01111100    01000000000000000000000

Cet exemple illustre un cas où l’exposant effectif est négatif : le biais permet de le stocker comme un nombre positif (124).

Simple et double précision

La norme définit deux formats courants :

FormatBitsSigneExposantMantissePrécisionIntervalle approx.
Simple3218 (biais 127)23~7 chiffres\(\pm 3,4 \times 10^{38}\)
Double64111 (biais 1023)52~15 chiffres\(\pm 1,8 \times 10^{308}\)

En Python, le type float utilise toujours la double précision (64 bits).

Conséquences pratiques en Python

Ne jamais comparer des flottants avec ==

Le problème fondamental vu en introduction se manifeste chaque fois que l’on compare des résultats de calculs flottants :

>>> 0.1 + 0.2 == 0.3
False

Solutions

Arrondir avec round() :

>>> round(0.1 + 0.2, 10) == 0.3
True

Comparer avec une tolérance grâce à math.isclose() :

import math
>>> math.isclose(0.1 + 0.2, 0.3)
True

Utiliser le module decimal pour des calculs exacts :

from decimal import Decimal
>>> Decimal('0.1') + Decimal('0.2') == Decimal('0.3')
True

Accumulation d’erreurs

Les erreurs d’arrondi s’accumulent au fil des calculs. Plus on enchaîne d’opérations, plus le résultat peut s’éloigner de la valeur exacte :

i = 0.0
while i < 1000:
    i += 0.01
print(i)
Résultat attenduRésultat obtenu
1000.01000.01

Après 100 000 additions de \(0,01\), l’erreur accumulée est visible.

Pour aller plus loin : valeurs spéciales

La norme IEEE 754 réserve les exposants extrêmes (tout à 0 ou tout à 1) pour des valeurs spéciales :

  • l’infini (float('inf') et float('-inf')) représente un dépassement de capacité ;
  • NaN (Not a Number, float('nan')) signale un résultat indéfini (comme \(0/0\) ou \(\sqrt{-1}\)). Propriété surprenante : NaN != NaN renvoie True ;
  • le zéro signé : \(+0\) et \(-0\) existent mais sont considérés comme égaux par ==.

Résumé

ConceptCe qu’il faut retenir
Problème fondamentalUn nombre décimal fini en base 10 peut être infini en base 2
Norme IEEE 754Format standardisé : signe + exposant biaisé + mantisse
Simple précision32 bits (1 + 8 + 23), ~7 chiffres significatifs
Double précision64 bits (1 + 11 + 52), ~15 chiffres, utilisée par Python
Exposant biaiséOn ajoute 127 (simple) ou 1023 (double) à l’exposant effectif
Notation normaliséeTout nombre s’écrit \(1,\ldots \times 2^e\) (le 1 est implicite)
Comparaison de flottantsNe jamais utiliser == ; préférer math.isclose()
Accumulation d’erreursLes erreurs d’arrondi s’amplifient au fil des calculs