15. Arithmétique en Nombre à Virgule Flottante : Problèmes et Limites

Les nombres à virgule flottante sont représentés, au niveau matériel, en fractions de nombres binaires (base 2). Par exemple, la fraction décimale :

0.125

a la valeur 1/10 + 2/100 + 5/1000, et de la même manière, la fraction binaire :

0.001

a la valeur 0/2 + 0/4 + 1/8. Ces deux fractions ont une valeur identique, la seule différence est que la première est une fraction décimale, la seconde binaire.

Malheureusement, la plupart des fractions décimales n’ont pas de représentation exacte en fractions binaires. Par conséquent, en général, les nombres à virgule flottante que vous donnez sont seulement approximés en fraction binaire pour être stocké dans la machine.

Le problème est plus simple à aborder en base 10. Prenons par exemple, la fraction 1/3. Vous pouvez l’approximer en une fraction décimale:

0.3

ou, mieux,

0.33

ou, mieux,

0.333

etc… Peu importe le nombre de décimales que vous écrirez, le résultat ne sera jamais exactement 1/3, mais une estimation s’en approchant toujours mieux.

De la même manière, peu importe combien de décimales en base 2 vous utiliserez, la valeur décimale 0.1 ne peut être représentée exactement en fraction binaire. En base 2, 1/10 est le nombre périodique suivant :

0.0001100110011001100110011001100110011001100110011...

En se limitant à une quantité finie de bits, on ne peut obtenir qu’une approximation. Sur la majorité des machines aujourd’hui, les nombres à virgule flottante sont approximés par une fraction binaire avec les 53 premiers bits comme numérateur et une puissance de deux au dénominateur. Dans le cas de 1/10, la fraction binaire est 3602879701896397 / 2 ** 55 qui est proche mais pas exactement 1/10.

Il est facile d’oublier que la valeur stockée est une approximation de la fraction décimale d’origine, du fait de la manière dont les flottants sont affichés dans l’interpréteur. Python n’affiche qu’une approximation décimale de la valeur stockée en binaire. Si Python devait afficher la vraie valeur décimale de l’approximation binaire stockée pour 0,1, il afficherait :

>>> 0.1
0.1000000000000000055511151231257827021181583404541015625

C’est bien plus de décimales que nécessaire, donc Python affiche une valeur arrondie afin d’améliorer la lisibilité :

>>> 1 / 10
0.1

Il faut se rappeler, bien que la valeur affichée ressemble à la valeur exacte de 1/10, que la valeur stockée est la représentation la plus proche en fraction binaire.

Il existe beaucoup de nombres décimaux qui partagent une même approximation en fraction binaire. Par exemple, 0.1, 0.10000000000000001, et 0.1000000000000000055511151231257827021181583404541015625 ont tous pour approximation 3602879701896397 / 2 ** 55. Puisques toutes ces valeurs décimales partagent la même approximation, chacune peut être affichée tout en respectant eval(repr(x)) == x.

Historiquement, le mode interactif de Python et la primitive repr() auraient choisi la version avec 17 décimales significatives, 0.10000000000000001. Python, depuis la version 3.1 (sur la majorité des systèmes) est maintenant capable de choisir la plus courte représentation et n’afficher que 0.1.

Ce comportement est inhérent au comportement des nombres à virgule flottante : ce n’est pas un bug dans Python, et ce n’est pas non plus un bug dans votre code. Vous verrez le même type de comportement dans tous les autres langages utilisant le support matériel pour le calcul des nombres à virgules flottante (bien que certains langages ne rendent pas visible la différence par défaut, ou pas dans tous les modes d’affichage).

Pour obtenir un affichage plus plaisant, les fonctions de formatage de chaînes de caractères peuvent limiter le nombre de décimales significatives affichées:

>>> format(math.pi, '.12g')  # give 12 significant digits
'3.14159265359'

>>> format(math.pi, '.2f')   # give 2 digits after the point
'3.14'

>>> repr(math.pi)
'3.141592653589793'

Il est important de comprendre qu’en réalité, c’est une illusion : Python arrondit simplement, la vraie valeur stockée, à l’affichage.

Une autre conséquence du fait que 0,1 n’est pas exactement stocké 1/10 est que la somme de trois valeurs de 0,1 ne donne pas 0,3 non plus :

>>> .1 + .1 + .1 == .3
False

Aussi, puisque 0,1 ne peut pas être stocké avec une représentation plus proche de sa valeur exacte 1/10, comme 0,3 qui ne peut pas être plus proche de sa valeur exacte 3/10, arrondir avec la fonction round() n’aide en rien :

>>> round(.1, 1) + round(.1, 1) + round(.1, 1) == round(.3, 1)
False

Bien que les nombres ne peuvent se rapprocher plus de la valeur qu’on attend qu’ils aient, la fonction round() peut être utile à posteriori pour arrondir deux valeurs inexactes et les rendre comparables :

>>> round(.1 + .1 + .1, 10) == round(.3, 10)
True

L’arithmétique des nombres binaires à virgule flottante réserve beaucoup de surprises de ce genre. Le problème avec « 0.1 » est expliqué en détails ci-desous, dans la section « Représentation d’Erreur ». Voir The Perils of Floating Point pour une liste plus complète de ce genre de surprises.

Il est vrai qu’il n’existe pas de réponse simple, cependant ne vous méfiez pas trop des nombres à virtule flottante ! Les erreurs, en Python, dans les opérations de nombres à virgule flottante sont dues au matériel sous-jacent, et sur la plupart des machines ne sont pas plus importantes que 1 sur 2**53 par opération. C’est plus que nécessaire pour la plupart des tâches, mais vous devez garder à l’esprit que ce ne sont pas des opérations décimales, et que chaque opération sur des nombres à virgule flottante peut souffrir d’une nouvelle erreur.

Bien que des cas pathologiques existent, pour la plupart des cas d’utilisations courants vous obtiendrez le résultat attendu à la fin et en arrondissant simplement au nombre de décimales désirées à l’affichage avec str(). Pour un contrôle fin sur la manière dont les décimales sont affichées, consultez dans Syntaxe de formatage de chaîne les spécifications de formatage de la méthode str.format().

Pour les cas requérant une représentation décimale exacte, le module decimal peut être utile, il implémente l’arithmétique décimale et peut donc être un choix adapté pour des applications nécessitant une grande précision.

Une autre forme d’arithmétique exacte est implémentée dans le module fractions qui se base sur les nombre rationnels (donc 1/3 peut y être représenté exactement).

Si vous êtes un utilisateur intensif des opérations sur les nombres à virgule flottante, vous devriez regarder le paquet Numerical Python et une série d’autres paquets pour les opérations statistiques et mathématiques fournis par le projet SciPy. Voir <https://scipy.org>.

Python fournit des outils qui peuvent être utils dans les rares occasions ou vous voulez réellement connaître la valeur exacte d’un nombre à virgule flottante. La méthode float.as_integer_ratio() donne la valeur du nombre sous forme de fraction :

>>> x = 3.14159
>>> x.as_integer_ratio()
(3537115888337719, 1125899906842624)

Puisque le ratio est exact, il peut être utilisé pour recréer la valeur originale dans perte :

>>> x == 3537115888337719 / 1125899906842624
True

La méthode float.hex() donne le nombre en hexadécimal (base 16), donnant ici aussi la valeur exacte stockée par la machine :

>>> x.hex()
'0x1.921f9f01b866ep+1'

Cette représentation hexadécimale petit être utilisée pour reconstruire, sans approximation, le float

>>> x == float.fromhex('0x1.921f9f01b866ep+1')
True

Puisque cette représentation est exacte, elle est pratique pour échanger des valeurs entre différentes version de Python (indépendamment de la machine) ou d’autres langages qui comprennent ce format (tel que Java et C99).

Une autre fonction utile est math.fsum(), qui aide à diminuer les pertes de précision lors des additions. Elle surveille les décimales perdues au fur et à mesure que les valeurs sont ajoutées au total. Cela peut faire une différence au niveau de la précision globale car cela empêche les erreurs de s’accumuler jusqu’au point ou le résultat final est affecté:

>>> sum([0.1] * 10) == 1.0
False
>>> math.fsum([0.1] * 10) == 1.0
True

15.1. Erreurs de représentation

Cette section explique en détail l’exemple du « 0.1 », et montre comment vous pouvez effectuer une analyse exacte de ce type de cas par vous-même. Il est supposé que vous êtes déjà familier de la représentation binaire des nombres flottants.

Le terme Representation error signifie que la plupart des fractions décimales ne peuvent être représentées exactement en binaire. C’est la principale raison pour laquelle Python (ou Perl, C, C++, Java, Fortran, et beuacoup d’autres) n’affiche habituellement pas le résultat exact en décimal.

Pourquoi ? 1/10 n’est pas représentable de manière exacte en fractions binaires. Cependant, toutes les machines d’aujourd’hui (Juillet 2010) suivent la norme IEEE-754 en ce qui concerne l’arithmétique des nombres à virgule flottante. et la plupart des plateformes utilisent un « IEEE-754 double precision » pour représenter les floats de Python. Les « IEEE-754 double precision » utilisent 53 bits de précision, donc a la lecture l’ordinateur essaie de convertir 0,1 dans la fraction la plus proche possible de la forme J/2**N avec J un nombre entier d’exactement 53 bits. Réecrire :

1 / 10 ~= J / (2**N)

en :

J ~= 2**N / 10

en se rappelant que J fait exactement 53 bits (donc >= 2**52 mais < 2**53), la meilleur valeur possible pour N est 56:

>>> 2**52 <=  2**56 // 10  < 2**53
True

Donc 56 est la seule valeur possible pour N qui laisse exactement 53 bits pour J. La meilleure valeur possible pour J est donc ce quotient, arrondi:

>>> q, r = divmod(2**56, 10)
>>> r
6

Puisque la retenue est plus grande que la moitié de 10, la meilleure approximation est obtenue en arrondissant par le haut:

>>> q+1
7205759403792794

Par conséquent la meilleure approximation possible pour 1/10 en « IEEE-754 double precision » est cette au desus de 2**56, soit :

7205759403792794 / 2 ** 56

Diviser le numérateur et le dénominateur par deux réduit la fraction à :

3602879701896397 / 2 ** 55

Notez que puisque l’arrondi a été fait vers le haut, le résultat est en réalité légèrement plus grand que 1/10; si nous n’avions pas arrondi par le haut, le quotient aurait été légèrement plus petit que 1/10. Mais dans aucun cas il ne vaut exactement 1/10 !

Donc l’ordinateur ne « voit » jamais 1/10: ce qu’il voit est la fraction exacte donnée ci-dessus, la meilleure approximation utilisant les nombres à virgule flottante double précision de l“« IEEE-754 »

>>> 0.1 * 2 ** 55
3602879701896397.0

Si on multiplie cette fraction par 10**30, on peut observer les valeurs de ses 55 décimales de poid fort:

>>> 3602879701896397 * 10 ** 55 // 2 ** 55
1000000000000000055511151231257827021181583404541015625

la valeur stockée dans l’ordinateur est donc égale à 0,1000000000000000055511151231257827021181583404541015625. Au lieu d’afficher toutes les décimales, beaucoup de langages (dont les vieilles version de Python) arrondissent le résultat à la 17eme décimale significative.

>>> format(0.1, '.17f')
'0.10000000000000001'

Les modules fractions et decimal rendent simples ces calculs :

>>> from decimal import Decimal
>>> from fractions import Fraction

>>> Fraction.from_float(0.1)
Fraction(3602879701896397, 36028797018963968)

>>> (0.1).as_integer_ratio()
(3602879701896397, 36028797018963968)

>>> Decimal.from_float(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')

>>> format(Decimal.from_float(0.1), '.17')
'0.10000000000000001'