Les nombres dans MathALEA - mathalea/mathalea GitHub Wiki

0. Résumé

Parce qu'on a un système de numération décimale et que 10 est divisible par 1, 2, 5 et 10, l'écriture décimale des 1/1^n, 1/2^n, 1/5^n, 1/10^n est finie mais dès qu'on veut diviser l'unité par autre chose qu'une puissance de 2, 5 ou 10, on se retrouve avec une infinité de décimales.

Comme les ordinateurs sont basées sur une numération binaire, il n'y a que les 1/2^n qui ont un résultat exact, ce qui fait qu'un simple "0,1" qui s'écrit simplement en numération décimale n'a pas de correspondance exacte en binaire. Si on affecte la valeur 0.1 à une variable, elle sera traitée comme étant 0.100000000000000005551115123126...

Pour palier à ce problème, un gros travail a été fait pour se limiter aux 15 chiffres significatifs qui sont safe et c'est ce qui a donné lieu à l'épopée du texNombre début 2022.

Suite à ce travail, on est aujourd'hui en mesure de correctement afficher des nombres tant qu'on utilise texNombre entre des $, stringNombre hors des $ et qu'on calibre correctement ces fonctions à l'aide du paramètre précision en deuxième position (nombre de chiffres max suffisants dans la partie décimale).

Mais il y a un autre problème. Si la réponse attendue à une question est "0.1" et que vous saisissez cette réponse, il peut arriver qu'elle soit comptée comme incorrecte car la comparaison de nombres se fait avec 18 décimales et dans ce cas la réponse vraiment attendue est "0.100000000000000006". En travaillant avec des nombres décimaux (opérations, comparaison etc.) on se trimbale forcément des scories comme ce "6" à la fin qui sera additionné, multiplié, comparé etc.

Pour travailler avec des 'vrais décimaux', il y a une librairie decimal.js qui propose d'utiliser une classe Decimal() dont les instances (qu'on appellera à partir d'ici : les décimaux) ne contiennent que les chiffres significatifs du nombre et pas de scorie !

Remarque : la librairie mathjs.js permet aussi de travailler avec ces décimaux qu'elle a redéfini en BigNumber.

math.bignumber('0.1') retourne un décimal et comme mathjs se base sur le type de ses variables pour définir le type du résultat, on peut utiliser toutes les fonctions de mathjs avec des BigNumber (décimaux) et obtenir d'autres décimaux. On peut bien sûr faire la même chose avec la classe Decimal().

En haut de l'exercice, il faudra ajouter la ligne

import Decimal from 'decimal.js'

Puis lorsqu'on aura besoin de travailler avec des nombres décimaux, ce sera un peu différent mais pas très compliqué.

Par exemple, au lieu de faire :

let a = randint(1, 9)
a = a / 10

il suffira de faire :

let a = new Decimal(randint(1, 9))
a = a.div(10)

De la même façon, on pourra faire a.plus(10), a.minus(10), a.mul(10) par exemple. Plus d'exemples sur la page Comment gérer des nombres décimaux ? de la FAQ.

Les fonctions setReponse et verifQuestionMathLive prennent déjà en compte les décimaux et au fur et à mesure toutes les fonctions que vous utilisez habituellement seront modifiées pour être utilisables de la même façon avec des décimaux (vous pouvez demander l'adaptation d'une fonction sur le canal #javascript du Slack).

Récapitulatif :

Si la réponse attendue dans votre exercice est une valeur calculée avec des opérations sur des entiers (uniquement addition, multiplication ou soustraction) donnant assurément un entier (un quotient même entier peut réserver des surprises) ou alors une chaîne de caractères (comme '3x+5' par exemple) alors vous n'avez pas besoin de décimaux.

Dans tous les autres cas, il est sage d'utiliser des décimaux pour éviter les erreurs (un mode d'emploi se trouve dans la FAQ).

Attention ! n'utilisez pas des décimaux comme paramètres d'une fonction à moins d'être certain que la fonction les supporte (les fonctions outils.js ou 2d.js ... n'ont pas été conçues pour ça ) !

Exemple : texNombre(), stringNombre(), setReponse() acceptent des décimaux, mais ce n'est pas le cas de troncature() par exemple (mais peu importe puisque le décimal a en lui la méthode trunc() qui renvoie un décimal bien sûr ! Et si vous avez besoin de vous servir de votre décimal dans une fonction qui ne prend que des flottants, il y a la méthode .toNumber() qui retourne un nombre standard à virgule flottante.

1. L'objet Number

les nombres sont des primitives du langage javascript. Ils sont stockés en utilisant le format IEEE-754 qui code un nombre en binaire avec 64 bits. Le format IEEE-754 permet de représenter des valeurs entre ±2^−1022 et ±2^+1023, ce qui correspond à des valeurs entre ±10^−308 et ±10^+308 avec une précision sur 53 bits.

Pour faire simple, un tel nombre permet de récupérer un nombre sous forme décimale avec 18 chiffres au maximum.

De plus, l'utilisation du binaire pour stocker les nombres à virgule (float) peut conduire à des erreurs de calculs.

Le classique exemple est :

0.1 + 0.2 // 0.30000000000000004

C'est tout a fait normal compte tenu de la façon dont sont stockés les nombres. Si le sujet vous intéresse, il y a cette vidéo qui explique très bien pourquoi : https://youtu.be/CDYiwshriWw

Nous verrons dans la prochaine partie quelle stratégie nous utilisons dans MathALEA pour éviter ces problèmes.

Les nombres entiers relatifs appartenant à l'intervalle [-2^53 + 1; 2^53 − 1] peuvent être représentés sans erreur.

L'objet Number possède des propriétés et des méthodes bien utiles :

Ces méthodes renvoient des chaines de caractères :

  • Number(nombre).toFixed(n) permet d'arrondir le nombre avec n chiffres après la virgule :
Number(1.234e-23).toFixed(30) // '0.000000000000000000000012340000'

Attention, toFixed fait des erreurs même avec n petit. Exemple,

Number(2.55).toFixed(1)` // '2.5' au lieu de '2.6
  • Number(nombre).toLocaleString('FR-fr') permet de formater le nombre en fonction de la syntaxe Française de France, donc avec le séparateur décimal ',' et les séparateurs de classe ' '. Si vous ne mettez pas de paramètre, la méthode toLocaleString ira chercher les préférences linguistiques de votre navigateur, récupérera la locale 'FR-fr' et vous renverra le formatage en "Français de France", si votre navigateur est réglé de cette façon.

  • Number(nombre).toString(n) convertit le nombre en base n (>=2). Exemple,

Number(12.34).toString(5) // '22.1322222222222222222222'

Par défaut n = 10, donc

Number(12.34).toString() // '12.34'
  • Number(nombre).toPrecision(n) produit une chaine de caractère représentant le nombre avec n chiffres significatifs.
Number(123.456).toPrecision(7) // '123.4560'
Number(123.456).toPrecision(6) // '123.456'
Number(123.456).toPrecision(5) // '123.46'
Number(123.456).toPrecision(2) // '1.2e+2' ici deux chiffres significatifs sont insuffisants pour aller jusqu'à la virgule, d'où la notation scientifique.
Number(0.0456).toPrecision(5) // '0.045600' ici les 0 de gauche ne sont pas comptés comme des chiffres significatifs (ce qui n'est pas le cas des 0 de droites) donc la méthode ajoutera deux 0 à droite afin d'obtenir 5 chiffres significatifs.
  • Number().toExponential(n) retourne une chaine contenant le nombre arrondi en notation scientifique arrondi avec n décimales.
Number(123.456).toExponential(3) // '1.235e+2'
Number(123.456).toExponential(7) // '1.2345600e+2'
Number(0.123456).toExponential(7) // '1.2345600e-1'

2. Produire des calculs exacts

Comme nous l'avons vu, le format IEEE-754 possède des limitations pour le stockage de certains nombres réels.

Depuis le début de l'aventure MathALEA, nous avons essayé de rectifier ça.

La fonction calcul() (qui se trouve dans mathalea/src/js/modules/outils.js) était censée demander à Algebrite (une librairie de calcul formel puissante) d'effectuer les calculs. Or pour cela, il aurait fallu comme c'était prévu au départ, passer ce calcul sous la forme d'une chaine de caractères afin que javascript ne l'évalue pas avant Algebrite.

Or dans les faits, nous avons utilisé la fonction calcul() avec des calculs effectués en javascript. Dés lors, l'usage d'Algebrite a été complètement dénaturé .

En effet : calcul(1/10 + 2/10) est tout simplement un calcul(0.30000000000000004) implicite!

Pire, on s'est aperçu qu'Algebrite arrondissait alors le nombre à 10^-6, ce qui dans ce cas est convenable, mais dans des cas où la précision nécessaire était plus importante, c'était la catastrophe.

Depuis, la fonction calcul() est devenue tout simplement une fonction qui arrondit le nombre à la précision souhaitée, qui ne peut cependant pas dépasser 10^-13 sans voir apparaitre des erreurs d'arrondis.

Mais cette solution a un talon d'Achille, car elle retourne un nombre en virgule flottante, qu'il faut stocker, et donc qui possède toujours des décimales erronées.

Par exemple : calcul(0.1+0.1, 1) semble retourner 0.2 mais si on y regarde d'un peu plus près : calcul(0.1+0.1, 1).toFixed(18) retourne 0.200000000000000011 car Number(0.2).toFixed(18) renvoie '0.200000000000000011'!

Moralités :

  • calcul(expressionCalculeeParJavascript) ne sert à rien ! (enfin si : à arrondir à 13 chiffres significatifs, pour avoir des chiffres inattendus dés le 16e !)
  • calcul(expressionCalculeeParJavascript, 3) vous garantit qu'après la 3ème décimale, il y a des zéros... jusqu'au 16ème chiffre significatif où les erreurs de conversion commencent.

Bien sûr, nous n'avons pas besoin de 18 chiffres significatifs pour MathALEA, d'ailleurs, quand de tels nombres surgissent à l'affichage, notre première réaction est d'aller tout de suite ajouter un arrondi à un rang nécessaire et suffisant pour rectifier l'erreur visible.

Peu importe en fait que le nombre possède des décimales indésirables à partir de la 12e ou 13e décimale, si on n'en affiche que 2 !

La solution, ici, c'est de limiter l'affichage aux seuls chiffres significatifs du résultat, c'est à dire 1 seul pour notre exemple.

Et pour cela, il n'y a vraiment pas besoin de la fonction calcul :

Number(0.1+0.2).toLocaleString('FR-fr', {maximumFractionDigits: 1}) // -> '0,3'

avec maximumFractionDigits, le nombre maximal de décimales (de 0 à20) à utiliser pour représenter la partie décimale.

Ainsi, pour la plupart des nombres à produire en sortie html ou LaTeX, la fonction calcul est inutile, seule une fonction limitant le nombre de chiffres significatifs est suffisante.

Pour des calculs nécessitant plus de 13 chiffres significatifs on aura un problème puisqu'on approche de la zone de turbulences de la conversion binaire -> décimal.

Il faudra alors employer les grands moyens : on passera par l'usage de la librairie decimal.js qui permet de travailler avec un format de stockage des nombres décimaux sous la forme d'un tableau de chiffres, et qui permet de réaliser tous les calculs avec autant de chiffres significatifs que nécessaire et qui sera développée dans la partie 4.

3. Affichage

Située également dans le module mathalea/src/js/modules/outils.js, la fonction texNombre(nombre,precision) est la fonction de MathALEA qui s'occupera de formater les nombres en chaine de caractères exploitable en LaTeX. A ce titre, elle contient souvent des commandes LaTeX comme \numprint ou \thickspace qui sont du plus mauvais effet en html si la sortie de texNombre() ne passe pas par le transpileur LaTeX -> html grâce à la fonction Katex.render() de la librairie KaTeX. Pour ce faire, elle doit impérativement s'utiliser à l'intérieur des délimiteurs $codeLaTeX$ qui encadrent toute expression LaTeX.

L'alternative en texte brut (non interprété par Katex.render()) est la fonction stringNombre() du module mathalea/src/js/modules/outils.js.

Au départ, texNombre(nb) s'occupait de remplacer le séparateur décimal . par la virgule, et d'ajouter des espaces (\thickspace) comme séparateur de classe et d’enchâsser la virgule dans des accolades pour éviter l’effet typographique ajoutant un espace après celle-ci.

Une évolution de texNombre va, en plus, permettre de limiter le nombre de décimales. Usage: texNombre(nombre, nombreDeChiffresAprèsLaVirgule)

> texNombre(0.1+0.2, 1) // '0,3'
> texNombre(Math.pi, 3) // '3,142'

Pour les appels à texNombre sans deuxième argument (l'essentiel de ce qui existe actuellement), la précision a été fixée arbitrairement à 8.

Attention de garder à l'esprit que le nombre maximum de chiffres significatifs pour un flottant est de 18, auxquels il faut retirer 3 chiffres pour les arrondis de conversion, et auxquels il faut retirer les chiffres déjà présent dans la partie entière.

Donc, pour un nombre comme 324 586,138 ça passe : 6 chiffres dans la partie entière, vous pouvez demander à texNombre une précision de 9 chiffres après la virgule, pas 12 ! car 12 + 6 = 18, vous aurez alors les chiffres qui composent l'erreur d'arrondi et tous les zéros intermédiaires.

Mais si vous multipliez ce nombre par 9 234 576,7 : on grimpe à 13 chiffres dans la partie entière et 4 dans la partie décimale... donc 17 chiffres ! il y a fort à parier que le chiffre des dix-millièmes ne soit pas 6 comme prévu.

C'est bien sûr une situation peu fréquente.

Autre exemple :

texNombre(Math.PI*11**15, 6) // '13\\thickspace 123\\thickspace 212\\thickspace 161\\thickspace 257\\thickspace 620'

Le résultat affiché est un nombre entier car il n'y a pas assez de chiffres significatifs pour avoir 6 chiffres après la virgule !

4. La classe Decimal : une alternative aux erreurs de conversion des flottants

Un guide plus pratique de la classe Decimal est accessible via ce lien : Comment gérer les nombres décimaux ?

La librairie decimal.js fournit une classe Decimal avec ses propriétés et ses méthodes de classe, et surtout un format de stockage plus adapté que le binaire qui permet de travailler avec une précision paramétrable.

Decimal.precision = 30 // Les instances de la classe Decimal stockeront une mantisse à 30 chiffres ! (on peut monter beaucoup plus haut, mais chaque calcul va solliciter fortement le processeur)
const pi = Decimal.acos(-1).valueOf() // '3.14159265358979323846264338328' joli, non ?

Vous remarquerez que pour l'instant, nous n'avons pas créé d'instance (nous n'avons pas fait de new Decimal(...)) mais simplement fait appel directement aux propriétés et méthodes de classe.

La classe Decimal pourra être utilisée dés que Number ne sera pas assez performant en terme de précision. Mais cette fois-ci, il faudra créer des instances de la classe Decimal.

const a = new Decimal(0.7)
a.toString() // '0.7'

Attention, a n'est pas un Number ou une string mais un littéral composé d'une fonction constructeur, de deux propriétés qui sont des Number et d'un tableau qui peut être composé de plusieurs Number qui représentent ses chiffres :

image

Ainsi, si vous aviez eu l'idée (mauvaise) de faire :

const a = new Decimal(0.7)
const b = new Decimal(0.1)
const s = new Decimal(a + b) // error

Cela ne fonctionnera heureusement pas puisque dans cet exemple JS commence par appeller toString sur les instances de Decimal a et b, il obtient deux string puis les "additionne" donc les concatène (juxtaposition). Il obtient donc '0.70.1' ce qui déclanche une erreur. Attention, si la concaténation produit une écriture correcte, nous aurons au final une concaténation au lieu du bon résultat :

const a = new Decimal(7)
const b = new Decimal(0.1)
const s = new Decimal(a + b) // '70.1'

En effet, new Decimal(a + b) deviendra new Decimal('70.1') donc donnera '70.1' au final.

Pour obtenir la somme voulue, on utilisera les méthodes d'instances :

const S = a.add(b) // '7.1' Ici c'est la méthode d'instance qui est convoquée.

Là encore, S n'est pas un Number mais un littéral.

image

Une autre syntaxe moins POO (Programmation Orienté Objet) mais plus fonctionnelle est possible. Cette fois-ci nous utiliserons une méthode de classe :

const somme = Decimal.add(a,b) // ici, c'est la méthode de classe qui est utilisée.
const produit = a.mul(b) ou Decimal.mul(a,b)

Une modification de texNombre() et stringNombre() est en cours pour supporter les instances de la classe Decimal.

Pour tester les possibilités de la classe Decimal, je remets ici le lien https://mikemcl.github.io/decimal.js/ qui vous permet d'utiliser la classe Decimal directement dans la console car la classe est définie dans le code de la page html.

Il faut savoir que decimal.js est inclus dans les dépendances de mathjs.js en version 7.1.1 mais dans MathALEA en version 10.3.1

Pour utiliser la classe Decimal dans vos exercices :

import { Decimal } from 'decimal.js'

Un exemple :

Dans 4C30-2.js, la classe Decimal est utilisée pour définir les réponses décimales équivalentes à 10^(-n). Il est à noter que les instances de la classe Decimal sont parfaitement compatibles avec le format interactif 'calcul' sans aucune adaptation. Ainsi

setReponse(this, i, Decimal.pow(10, -n))

sera compatible avec la saisie utilisateur "0,00000....1".

Un autre exemple :

const d = randint(1,9)
const c = randint(1,9)
const m = randint(1,9)
const a = (new Decimal(d*100+c*10+m)).div(1000) // a = 0.dcm
const b = (new Decimal((10-m)*100+(10-d)*10+10-c)).div(1000)
const somme = a.add(b) // somme est instance de Decimal
const produit = a.mul(b) // produit aussi
const angle = Decimal.acos(a).div(Decimal.acos(-1).mul(180)).round() // angle entier en degré dont le cosinus s'approche de a.
if (somme.lt(produit)) { // méthode lessThan
    result = `$${texNombre(somme)} < ${texNombre(produit)}` 
}
result2 = `$\\cos(${texNombre(angle)}) \\approx ${texNombre(a)}`