Skywalker13

Diary of an ex-GeeXboX developer…

Les nombres réels en informatique ~ 🇫🇷

Posted at — Dec 21, 2024

Qui ne s’est jamais confronté aux problèmes d’arrondis avec les nombres réels ? Prenons un exemple tout simple. Dans n’importe quel langage de programmation qui utilise la virgule flottante, l’équation suivante (si simple au premier abord) ne donne pas le résultat espéré.

$$\frac{1}{98}\times98=1$$

Javascript

console.log(`${(1.0 / 98.0) * 98.0}`); // 0.9999999999999999

Langage C

#include <stdio.h>
int
main(void)
{
  double number = 1.0;
  number /= 98.0;
  number *= 98.0;
  printf("%.16lf", number); // 0.9999999999999999
  return 0;
}

Langage C#

double number = 1.0;
number /= 98.0;
number *= 98.0;
Console.WriteLine(number); // 0.9999999999999999

Mais qu’est-ce que le type double

Tous ces résultats seraient très perturbants pour quelqu’un sans connaissance poussée en informatique. D’une certaine manière, c’est même inquiétant si on souhaite utiliser un ordinateur comme calculateur en physique. Il est donc très important de comprendre ce qui se passe en détail dans la machinerie et pourquoi le résultat de cette équation, si triviale, n’est pas exact.

Commençons par comprendre ce qui se passe avec la division :

$$\frac{1}{98}=0.0\overline{102040816326530612244897959183673469387755}$$

Le résultat de cette équation rend un nombre réel avec une partie périodique de 42 décimales. Naïvement, on pourrait penser que le fait qu’il y ait une période, implique forcément que l’ordinateur ne peut pas contenir un nombre réel d’une longueur infinie. Mais cette explication n’est pas complète, car ici on représente les nombres en base 10 et pas en base 2. Prenons un exemple encore plus simple, avec un nombre sans partie périodique et demandons à nos langages de programmation ce qu’il en est.

$$0.1$$

Javascript

console.log(`${0.1}`); // 0.1

Langage C

#include <stdio.h>
int
main(void)
{
  double number = 0.1;
  printf("%.16lf", number); // 0.1000000000000000
  return 0;
}

Langage C#

double number = 0.1;
Console.WriteLine(number); // 0.1

Ouf, ça fonctionne…

Vous pensez vraiment que tout est bon ? Eh bien il y a un piège. Tous ces résultats sont apparemment corrects, mais pourtant ils apparaissent juste uniquement parce qu’on a de la chance avec les arrondis. Demandons à nos langages d’augmenter le nombre de décimales visibles.

Javascript

console.log(`${(0.1).toFixed(32)}`); // 0.10000000000000000555111512312578

Langage C

#include <stdio.h>
int
main(void)
{
  double number = 0.1;
  printf("%.32lf", number); // 0.10000000000000000555111512312578
  return 0;
}

Langage C#

double number = 0.1;
Console.WriteLine(string.Format("{0:F32}", number)); // 0.10000000000000000555111512312578

Comme vous pouvez alors le constater, la vraie valeur 0.1 n’existe pas. Un ordinateur ne peut pas représenter ce chiffre réel de manière exacte.

La virgule flottante

Un nombre réel comme 0.1 doit être converti au format binaire avec une virgule flottante. C’est une représentation numérique limitée par le nombre de bits disponibles. Par exemple le type double utilise 64 bits.

  1. (S) Le signe (1 bit) qui dit si le nombre est positif ou négatif
  2. (E) L’exposant (11 bits) qui est un ensemble de bits représentant la puissance de base à laquelle la mantisse doit être multipliée
  3. (M) La mantisse (52 bits) qui est un ensemble de bits qui représente la partie significative du nombre

La virgule flottante correspond à la notation scientifique qu’on utilise habituellement en mathématiques.

Un nombre à virgule flottant se calcule ainsi :

$$(-1)^{s}\times1.M\times2^{E-\text{Biais}}$$

Pour 0.1 :

$$(-1)^{0}\times1.6\times2^{-4}=0.1$$

Pour trouver 1.6 (la mantisse) il faut effectuer une conversion décimale-binaire en utilisant une méthode assez simple. On multiplie par 2 et on prend la partie entière du résultat, puis on répète ce processus avec la partie fractionnaire restante.

Avec 0.1, ce processus ne se termine jamais et produit une séquence infinie de bits : 0.00011001100110011… 0.1 est alors un nombre avec une partie décimale périodique sous la forme binaire. La mantisse normalisée va ressembler à 1.100110011001100110011… (on décale simplement la virgule comme on le ferait en base 10 avec la notation scientifique)

Il est alors impossible d’avoir une représentation exacte en base 2 car nous avons un nombre limité de bits disponibles. Un nombre qui nous semble très simple en base 10, ne l’est pas forcément en base 2.

Et si on parlait d’argent

La finance est un domaine sujet aux arrondis et donc à de la perte de précision. Le problème vient simplement du centime qui représente l’unité de la monnaie. Que faire quand on doit distribuer de l’argent alors qu’il y a une perte de précision ? Prenons un exemple tout simple. J’ai 3 amis à qui je dois partager un billet de 10 CHF. Voici quelques solutions à cette problématique.

  1. Je découpe le billet en trois parts égales avec une paire de ciseaux.
    Je doute que mes amis soient très contents mais avec cette technique, je peux avoir une très grande précision.
  2. Je donne 3.33 CHF à chacun de mes amis, et je garde 1 centime pour moi.
  3. Je donne 3.33 CHF à deux de mes amis, et à celui que je préfère je lui donne 3.34 CHF.

À part la première solution, les deux autres sont satisfaisantes dans ce contexte car je peux produire des factures à ce moment-là pour figer les transactions; mais compliquons l’exemple.

Pendant 10 mois, il sera comptabilisé 10/3 CHF pour chacun d’eux. Après 10 mois, ils recevront l’argent.

$$10\times\frac{10}{3} = 33.\overline{3}$$

Il n’y a pas de facture intermédiaire. En effet, mes amis vont recevoir uniquement le montant final.

Chaque mois, il y a un montant de 10/3 CHF qui est ajouté à chaque compte. Si je stocke le résultat de l’opération j’ai forcé un arrondi. C’est un problème habituel en finance. On stocke les résultats et pas les équations pour y arriver.

Peut-être avez-vous quelques souvenirs de vos cours de mathématiques. Combien de fois le professeur vous a dit que les résultats ne l’intéressent pas, c’est uniquement l’équation qui importe.

Admettons que je travaille de manière traditionnelle. Mon système stocke les résultats au centime et donc je partage le billet de 10 CHF en parts de 3.33 CHF.

1 Jan. 1 Fév. 1 Mar. 1 Avr. 1 Mai 1 Jun. 1 Jui. 1 Aoû. 1 Sep. 1 Oct. Perte
Ami 1 3.33 6.66 9.99 13.32 16.65 19.98 23.31 26.64 29.97 33.30 0.03
Ami 2 3.33 6.66 9.99 13.32 16.65 19.98 23.31 26.64 29.97 33.30 0.03
Ami 3 3.33 6.66 9.99 13.32 16.65 19.98 23.31 26.64 29.97 33.30 0.03
Total 9.99 19.98 29.97 39.96 49.95 59.94 69.93 79.92 89.91 99.90 0.09

Le 1er octobre, il y a 10 centimes qui n’ont jamais été distribués (chaque mois il y a un centime qui “saute” à cause de l’arrondi). Il faudrait au moins distribuer encore 9 centimes (car 10 n’est pas divisible par 3). Le dernier centime, je peux le garder dans ma poche.

Et si on avait simplement travaillé en double sans forcer d’arrondi au centime ?

Dans ce cas, on aurait trouvé quelque chose comme 33.3333333… car en double on a une bonne précision. Avec l’arrondi final, on a donc bien récupéré les 3 centimes manquants. C’est mieux mais ça ne suffit pas. On ne fait que repousser le problème en augmentant la précision.

Prenons par exemple cette équation :

$$100\times(0.01 + 0.02 + 0.3) = 33$$

On manipule ici 100 fois 33 centimes. Cela n’a rien d’extraordinaire, pourtant même en double on perd trop en précision et notre calcul est faussé à cause des additions.

100 * (0.01 + 0.02 + 0.3) = 32.99999999999999

Bien entendu, avec un arrondi on y arrive, mais arrondir ne permet pas toujours de s’en sortir quand on cumule beaucoup d’opérations. On peut finir par avoir une erreur d’un centime presque impossible à expliquer. Alors pourquoi continuer à travailler avec des nombres à virgule flottante ?

Ne plus jamais perdre en précision

Maintenant que vous savez qu’il ne faut pas se fier aveuglément aux nombres à virgule flottante, comment pouvons-nous régler cette question une bonne fois pour toutes ? Un moyen simple de ne jamais perdre en précision est de tout simplement stocker vos nombres sous la forme de chaînes de caractères. Revenons à notre exemple avec 0.1. Si je stocke "0.1" au lieu du double 0.1, je donne une garantie de précision.

Que dire de notre tout premier exemple avec la division de 1 par 98 ? Nous pouvons aussi tenter une représentation sous forme de chaîne de caractères. Par exemple "0.0(102040816326530612244897959183673469387755)". Cette représentation nous donne exactement le résultat de la division sans perte de précision car la partie périodique est représentée par les parenthèses.

Je ne sais pas pour vous, mais pour moi ce n’est pas complètement satisfaisant. Bien que les nombres soient exacts, c’est surtout le second cas qui me dérange un peu. Et si on utilisait des représentations fractionnaires ? Pour 0.1, on pourrait l’écrire aussi sous la forme d’une chaîne de caractères mais en tant que fraction "1/10". Avec le second cas, on aurait alors simplement "1/98".

Je pense que vous êtes d’accord avec moi. Utiliser "1/98" au lieu de "0.0(102040816326530612244897959183673469387755)" est bien plus pratique, et en plus ça prend beaucoup moins de place. Le second avantage, c’est que dans le cas de nos fractions, si nous considérons supporter uniquement des nombres rationnels, alors le numérateur et le dénominateur seront toujours des entiers. En informatique, il n’y a jamais de perte de précision avec les nombres entiers. Le seul problème vient du nombre de bits à disposition. Mais je vous rassure, avec des nombres entiers de 64 bits, on est tranquille pour toutes nos applications.

Nous avons une notation parfaite pour notre application, reste maintenant la question des calculs…

Une solution : ce sont les fractions rationnelles

Revenons à notre premier cas :

$$\frac{1}{98}\times98=1$$

Nous pouvons donc stocker la division sous la forme d’une chaîne de caractères comme "1/98". En cas de nombres négatifs, il suffit d’y ajouter le signe comme par exemple "-1/98". Vous pouvez aussi envisager de stocker le numérateur et le dénominateur dans deux entiers séparés.

Pour effectuer les calculs, il faudra utiliser une bibliothèque qui comprenne les fractions. Pour le Javascript, je vous invite à aller voir Fraction.js.

Voici ce que cela peut donner :

import Fraction from "fraction.js";

// 1/98
new Fraction(1).div(98).toString(); // '0.0(102040816326530612244897959183673469387755)'
new Fraction(1).div(98).mul(98).toString(); // '1'
new Fraction("1/98").mul(98).toString(); // '1'
new Fraction(1, 98).mul(98).toString(); // '1'

// 100 * (0.01 + 0.02 + 0.3)
new Fraction(100)
  .mul(
    new Fraction("0.01") //
      .add("0.02")
      .add("0.3")
  )
  .toString(); // '33'

Il n’y a plus aucune perte de précision. C’est pas mal du tout, néanmoins je vous propose d’aller encore plus loin dans la réflexion. Toujours en finance, on ne souhaite pas avoir des chiffres avec plus de 2 décimales, étant donné que l’unité est le centime. Sauf que lors des calculs, on ne souhaite pas non plus perdre des centimes à cause des arrondis. Il est donc important de figer le résultat au tout dernier moment. Par exemple, lors de la création de la facture (du document), on peut décider d’appliquer l’arrondi au centime.

Prenons l’exemple suivant où on verse 10 fois 10/3 CHF.

new Fraction(10).mul("10/3").toString(); // '33.(3)'
new Fraction(10).mul("10/3").toFraction(); // '100/3'

Il va falloir demander un arrondi au centime.

new Fraction(10).mul("10/3").round(2).toString(); // '33.33'

À ce moment-là, on a effectivement un montant plausible en centimes. Reste que désormais, on a perdu en précision.

new Fraction(10).mul("10/3").round(2).toFraction(); // '3333/100'

Conserver la valeur exacte tout en ayant l’arrondi

Le résultat avec l’arrondi ne me plaît guère. Il faudrait garder les deux informations. Il nous faut la fraction exacte, ainsi que la valeur arrondie pour le résultat final. En ayant la valeur exacte, on peut retrouver (ou mieux comprendre) d’où viendrait une différence d’un centime. Mais mieux encore, et si on conservait tout l’historique de ce qui s’est passé sur le nombre ?

Pour cela, j’ai imaginé un petit projet qui exploite Fraction.js et qui permet de conserver l’historique des opérations. Voici un exemple :

import Fric from "fric";

let f1 = new Fric("4.2").mul("8.1").div(100);
f1.toFraction(); // '1701/5000'
f1.toString(); // '0.3402'

Ici, il n’y a pas de différence directement visible. La suite des opérations semble produire exactement le même genre de résultat qu’en utilisant directement Fraction.js. Mais détrompez-vous, ici, Fric conserve l’historique de tout ce qui se passe sur le nombre. Je peux ainsi lui demander de sérialiser l’objet afin de récupérer l’équation complète (sous une forme réduite).

f1.serialize(); // '21/5:*81/10:/100'

Ce qu’on peut lire ici, c’est une suite d’opérations sous la forme d’une chaîne de caractères. On y trouve 21/5 qui correspond à 4.2. Le : sert de séparateur pour les opérations. Ensuite, on multiplie 4.2 par 81/10. Pour terminer, on divise ce résultat par 100. Dit autrement, on a calculé les 8.1% de 4.2, ce qui donne bien 0.3402.

Étant donné qu’on veut travailler en centimes, on peut demander un arrondi de cette manière :

f1 = f1.round(2);
f1.serialize(); // '21/5:*81/10:/100:o2'

L’arrondi n’est rien de plus qu’une opération à ajouter à la liste. Le format sérialisé est donc complet en une seule chaîne de caractères.

Cette petite bibliothèque permet bien entendu de faire l’inverse. On peut donner une version sérialisée afin d’y retrouver un objet de type Fric qui nous permet alors de continuer d’y effectuer des opérations.

let f2 = Fric.deserialize("21/5:*81/10:/100:o2");
f2.toFraction(); // '17/50'

Certains langages proposent le type decimal

Il y a effectivement des langages qui intègrent un “nouveau” type pour régler la problématique de la représentation sous la forme de virgule flottante. Mais parfois, ce n’est que repousser le problème en augmentant simplement la précision, comme en C#. Et malheureusement, il est assez facile de tomber sur des cas impossibles à représenter correctement même avec ce genre de type.

Voici un exemple en C# et son type decimal où la représentation est altérée à cause de la perte de précision.

decimal number = 1m / 98m * 98m;
Console.WriteLine(number); // 0.9999999999999999999999999982
Console.WriteLine(number == 1); // False

Il y a également le très populaire Python qui, lui, offre un type decimal très intéressant. Ce n’est peut-être pas étranger à sa grande popularité dans le domaine scientifique. Python s’appuie sur la libmpdec qui mériterait certainement un article approfondi. Je suis aujourd’hui incapable de dire s’il est possible, avec cette bibliothèque, de tomber sur des cas imprécis.

from decimal import Decimal, getcontext

getcontext().prec = 50
a = Decimal('1')
b = Decimal('98')
result = a / b * b
print(result) # 1.0000000000000000000000000000000000000000000000000
print(result == 1) # True

En conclusion

Il faut certainement bien réfléchir aux types à utiliser avant de se lancer dans des calculs financiers ou scientifiques. Le choix dépend aussi des performances. Si les performances ne sont pas indispensables, je pense qu’opter pour une représentation fractionnaire est une très bonne idée. Gardez les virgules flottantes quand les performances sont plus importantes que l’exactitude mathématique.

Pour terminer, j’espère avoir éveillé votre curiosité avec ma proposition de nombre Fric qui conserve l’historique des opérations. Cette petite bibliothèque est (au moment où j’écris cet article) uniquement un PoC.