FichesCarte › Partie 08 · Calcul, numérique, tenseurs › chaîne 02

Flottants et log-sum-exp

Pourquoi 0,1 + 0,2 ≠ 0,3, pourquoi e1 000 ne vaut ni 0 ni une erreur mais inf, et pourquoi tout code de softmax soustrait le max. Une seule cause : la mantisse est finie, donc l'erreur est relative — tout le reste en découle, la dérive des sommes, les nan silencieux et l'astuce du log-sum-exp. Fil rouge : accumuler 0,1 un million de fois en float32, et calculer le softmax de (1 000 ; 0).

Ce que cette chaîne suppose acquis
  • La notation scientifique. Un nombre s'écrit x = ±m × 2e, une mantisse m ∈ [1, 2[ et un exposant entier e. C'est la seule chose à savoir de la norme IEEE 754 pour cette chaîne.
  • Exponentielle et logarithme. ea+b = eaeb et log(ab) = log a + log b. Le log-sum-exp du pas 6 n'est que cette identité, utilisée pour déplacer un facteur commun hors d'une somme.
  • Softmax et logits. pi = ezi / Σj ezj transforme des scores réels (logits) en probabilités. Ici on ne redérive rien : on regarde ce que cette formule devient en flottants.
  • Le terme −log p. Déjà rencontré comme facteur du gradient d'un neurone logistique (p08-01, pas 4). Le pas 7 montre qu'on ne le calcule jamais en formant p d'abord.
Hypothèses posées
H1Un flottant a une mantisse finie : 24 bits en float32 (≈ 7 chiffres décimaux), 53 en float64 (≈ 16). L'erreur d'arrondi est donc relative : ε32 = 2−23 = 1,19 · 10−7, ε64 = 2−52 = 2,22 · 10−16. H2Les valeurs spéciales existent et se propagent : inf, −inf, nan. Elles ne lèvent aucune exception, elles voyagent. Et nan ≠ nan : un test d'égalité ne les détecte pas, isnan si. H3PyTorch calcule en float32 par défaut ; NumPy et Python en float64. Un même code ne donne pas le même résultat dans les deux — neuf ordres de grandeur d'écart sur l'epsilon. C'est le pas 8.

La chaîne

Le décor : des fractions binaires, pas des réels

Les nombres d'un programme forment un ensemble fini de fractions binaires. Une valeur est stockée comme mantisse tronquée à 24 bits fois une puissance de 2 :

x = ±(1,b1b2b23)2 × 2e

0,1 n'a pas de développement binaire fini, exactement comme 1/3 n'en a pas en base 10. Il est remplacé par le flottant le plus proche à l'instant où on l'écrit. Tout ce qui suit est une conséquence de cette substitution.

« La mantisse a un nombre fixe de bits, donc seules certaines fractions binaires existent, donc 0,1 est remplacé dès l'écriture par son voisin le plus proche, donc l'arithmétique porte sur ces voisins et pas sur les nombres écrits. »

Application — ce que vaut vraiment 0,1
écritréellement stocké
0,1 en float640,100000000000000005551115123…
0,1 en float320,100000001490116119384765625

D'où le classique : 0.1 + 0.2 vaut 0,30000000000000004 en float64, et 0.1 + 0.2 == 0.3 est False. Ce n'est pas un bug de Python : c'est la somme exacte de deux voisins qui ne sont pas 0,1 et 0,2, arrondie au voisin de 0,3 le plus proche… et qui tombe à côté.

L'erreur est relative, pas absolue tronc

La mantisse a un nombre fixe de bits quelle que soit l'octave. L'écart entre deux flottants consécutifs (l'ulp) est donc proportionnel à la taille du nombre :

ulp(x) = 2⌊log2 x × 2−23x · ε32

Un flottant représente x à ε près en proportion. Les grands nombres sont mesurés à la louche : au-delà de 224 = 16 777 216, float32 ne sait plus compter de 1 en 1.

« La mantisse a un nombre fixe de bits, donc l'écart entre deux flottants consécutifs est proportionnel à leur taille, donc l'erreur est relative, donc les grands nombres sont mesurés à la louche. »

Application — la résolution de float32
xulp(x)lecture
11,19 · 10−77 chiffres utiles
1 0006,10 · 10−54 décimales
1060,06251 décimale
16 777 216 = 2242les impairs n'existent plus
1088on compte de 8 en 8

Conséquence directe : en float32, 16 777 216 et 16 777 217 sont le même nombre. Un compteur d'entiers stocké en float32 se met à sauter des valeurs passé 16 millions — sans le moindre avertissement.

Figure 1 — l'espacement des flottants, en log-log

L'escalier bleu est l'écart réel entre deux flottants float32 consécutifs ; la droite ocre est l'approximation x · ε32. En log-log, une erreur relative constante est une droite de pente 1 : c'est toute l'hypothèse H1, dessinée. Fais glisser x : le troisième readout ajoute 1 à x en float32 puis retranche x — il reste 1, ou bien rien du tout. À 224 (point rouge) l'ulp vaut 2 ; à 108 il vaut 8, et 108 + 1 = 108.

Additionner un petit à un grand perd le petit

Si le terme ajouté est plus petit que la moitié de l'ulp de l'accumulateur, la somme ne bouge pas : 108 + 1 = 108 en float32. Dans une somme séquentielle, l'accumulateur grandit à chaque tour, donc sa résolution se dégrade, donc chaque terme est arrondi plus grossièrement que le précédent. Le budget d'erreur vaut ≈ n · ε · max|somme partielle|.

Trois remèdes : sommer par paires (arbre binaire, erreur en log n — c'est ce que fait np.sum), sommer dans un accumulateur float64, ou compenser à la Kahan.

« L'accumulateur grandit, donc sa résolution baisse, donc chaque terme ajouté est de plus en plus arrondi, donc l'erreur totale croît en n fois epsilon fois l'échelle atteinte. »

Application — un million de fois 0,1, en float32
méthoderésultaterreur relative
boucle séquentielle float32100 958,34+0,96 %
somme par paires float32100 000,00
boucle séquentielle float64100 000,0000011,3 · 10−11

Le budget théorique n·ε·max = 106 × 1,19·10−7 × 105 = 11 920 est une borne ; on en observe 958, les arrondis se compensant partiellement. Passé un accumulateur de 0,1 × 224 = 1 677 722, ajouter 0,1 ne changerait plus rien du tout : la somme se figerait.

Le même piège en deep learning : une loss moyennée sur un gros batch, un compteur de tokens, une norme de gradient accumulée — toujours en float64 ou par paires.

Figure 2 — la dérive d'une somme séquentielle

Erreur relative de la somme séquentielle de 0,1 en float32, en fonction du nombre de termes (échelle log en x). L'axe horizontal est la somme exacte n · 0,1 : s'en écarter, c'est dériver. Le trait rouge épais est la part déjà accumulée, le trait gris ce qui reste à venir ; le bouton « accumuler » rejoue la course. Tant que l'accumulateur reste sous ≈ 104 l'erreur ne dépasse pas 0,07 % et change de signe ; au-delà elle part vite et ne revient plus : +0,96 % quand l'accumulateur atteint 105.

Jamais == sur des flottants

Deux calculs mathématiquement égaux passent par des arrondis différents : l'égalité exacte est un accident, pas une propriété. On compare à une tolérance relative, c'est-à-dire proportionnelle à l'échelle des nombres comparés :

|ab| ≤ τ · max(1, |a|, |b|)

Pour l'argent, on ne tolère rien du tout : on compte en centimes entiers, ou en Decimal. Un centime perdu par arrondi est une erreur comptable, pas une imprécision.

« Deux chemins de calcul arrondissent différemment, donc l'égalité exacte est un accident, donc on compare à une tolérance proportionnelle à l'échelle, donc l'argent se compte en entiers où il n'y a pas d'arrondi du tout. »

Application — cinq égalités qui échouent
écritobtenu en float64==
1.1 * 33,3000000000000003False
0.1 + 0.20,30000000000000004False
4.35 * 100434,99999999999994False
0.29 * 10028,999999999999996False
2.00 - 1.100,8999999999999999False

Le piège comptable : int(4.35 * 100) vaut 434, pas 435. Un centime disparaît par troncature — et sur un million de lignes, c'est un écart de caisse.

Le cas classique : 1.1 * 3 donne 3,3000000000000003, donc 1.1 * 3 == 3.3 est False. Ni 1,1 ni 3,3 n'ont d'écriture binaire finie ; le produit des deux arrondis n'a aucune raison de tomber sur l'arrondi du produit. C'est la même mécanique que la ligne comptable : l'argent se compte en centimes entiers, où il n'y a pas d'arrondi du tout.

Tolérance fixe, le mauvais réflexe : abs(a-b) < 1e-6 est ridiculement lâche pour des nombres de l'ordre de 10−9 et impossible à satisfaire pour des nombres de l'ordre de 109, dont l'ulp float64 vaut déjà 2 · 10−7.

Débordement : inf d'abord, nan ensuite tronc

Au-delà du plus grand flottant (3,40 · 1038 en float32), un résultat devient inf. Pas 0, pas une exception, pas un message : inf. Les opérations sur inf produisent inf ou nan — inf − inf, inf/inf, 0 · inf donnent nan — et un nan contamine tout ce qu'il touche.

D'où la règle de diagnostic : une loss nan à l'itération 340 vient d'un inf à l'itération 339. On ne cherche pas le nan, on remonte au premier inf.

« Dépasser le maximum donne inf, donc une division d'inf par inf donne nan, donc un nan en sortie est la trace d'un inf en amont, et on remonte le calcul pour le trouver. »

Application — le mur de l'exponentielle

Le plus grand float32 est 3,4028 · 1038, donc ez déborde dès que z > log(3,4028 · 1038) = 88,7228.

calculfloat32
e88,73,326 · 1038 — passe de justesse
e89inf (la vraie valeur, 4,49 · 1038, existe pourtant en float64)
inf / infnan
nan == nanFalse

Softmax naïf de (1 000 ; 0) : le numérateur e1 000 = inf, le dénominateur inf + 1 = inf, donc p1 = inf/inf = nan et p2 = 1/inf = 0. La sortie est (nan ; 0) : la classe manifestement gagnante reçoit nan, la perdante reçoit une vraie probabilité.

Le garde-fou qui ne coûte rien : torch.isfinite(loss) à chaque pas, et on sait à quelle itération le premier inf est né.

Log-sum-exp : soustraire le max tronc

Avec m = maxi zi, mettre em en facteur dans la somme donne une identité exacte, vraie en mathématiques comme en flottants :

LSE(z) = log Σi ezi = m + log Σi ezim

Tous les exposants sont désormais ≤ 0, donc toutes les exponentielles sont dans ]0, 1] : plus rien ne peut déborder, et le plus grand terme vaut exactement 1, ce qui garantit que la somme est ≥ 1 et que son log est fini. Softmax stable : pi = ezim / Σj ezjm.

« Multiplier numérateur et dénominateur par e puissance moins m ne change pas le quotient, donc on peut décaler tous les logits par leur max, donc plus aucune exponentielle ne dépasse 1, donc plus de inf. »

Application — (1 000 ; 0), les deux versions
naïfmax soustrait
exponentiellese1000 = inf ; e0 = 1e0 = 1 ; e−1000 = 0
softmax(nan ; 0)(1 ; 0)
LSElog(inf) = inf1 000 + log(1 + 0) = 1 000

Le e−1000 underflow à 0, et c'est sans conséquence : il aurait pesé 10−434 dans un résultat de l'ordre de 1. L'underflow est bénin ici parce que l'erreur qu'il commet est relative au plus grand terme, qui vaut 1 — contrairement à l'overflow, qui détruit tout.

La soustraction du max n'est donc pas une approximation : c'est une réécriture algébrique exacte qui choisit l'ordre de grandeur où le calcul est faisable.

Figure 3 — le mur de ez, et ce que le max soustrait y change

En ordonnée, log10 de ez calculé en float32. La courbe bleue monte puis s'arrête net à z = 88,72 : au-delà, la valeur n'est pas grande, elle est inf — le trait ocre est le plafond 3,40 · 1038. Pousse le curseur au-delà du mur : le readout « softmax naïf » vire au nan pendant que le readout « softmax stable » continue de donner 1. Les deux calculent la même quantité mathématique ; un seul survit.

Composer log et softmax est un piège

Écrire log(softmax(z)) forme d'abord une probabilité minuscule, qui underflow à 0, puis prend son log : −inf. Or la quantité cherchée est parfaitement représentable — c'est un nombre de l'ordre de la dizaine.

log_softmax(z)i = zi − LSE(z)

Cette forme ne construit jamais p : une soustraction, pas un quotient d'exponentielles. C'est pourquoi F.cross_entropy prend des logits et fait le log-sum-exp elle-même — lui passer des probabilités, c'est lui retirer précisément ce qu'elle sait faire (p08-03).

« log de softmax forme d'abord une probabilité minuscule, donc elle tombe à zéro par underflow, donc son log vaut moins l'infini, donc on écrit log_softmax = z moins LSE, qui ne forme jamais la probabilité. »

Application — z = (0 ; −120), classe cible = 2
chemincalculrésultat float32
log(softmax)p2 = e−120/(1+e−120) = 7,7 · 10−53 → 0, puis log 0−inf
z − LSELSE = 0 + log(1 + e−120) = 0 ; −120 − 0−120

Le plus petit float32 normal vaut 1,18 · 10−38 : 7,7 · 10−53 est très en dessous, donc 0. La loss du premier chemin est +inf, et le gradient qui en sort est nan ; celle du second est 120, parfaitement utilisable.

Corollaire pratique : pas de softmax dans le modèle. Le réseau sort des logits, la loss fait le reste. Le softmax explicite n'apparaît qu'au moment d'afficher une probabilité à un humain.

Les dtypes : float32 en PyTorch, float64 ailleurs

Un tenseur créé depuis une liste Python est en float32 ; depuis un tableau NumPy, il hérite du float64. Neuf ordres de grandeur séparent les deux epsilons : le même code ne donne pas le même résultat, et les mélanger promeut silencieusement ou lève une erreur de type.

bfloat16 : 8 bits de mantisse seulement, mais le même exposant que float32. On y entraîne des réseaux parce que le gradient est déjà bruité et tolère un arrondi grossier — pas parce que c'est précis. C'est un choix de plage, pas de précision : float16, lui, plafonne à 65 504 et déborde en entraînement.

« Un tenseur créé depuis une liste Python est en float32, donc son epsilon vaut 10 puissance moins 7 et non 10 puissance moins 16, donc un même code ne donne pas le même résultat sous NumPy et sous PyTorch, donc on vérifie le dtype avant de s'étonner d'un écart. »

Application — les quatre dtypes à connaître
dtypemantisseεplus grand
float6453 bits2,2 · 10−161,8 · 10308
float3224 bits1,2 · 10−73,4 · 1038
float1611 bits9,8 · 10−465 504
bfloat168 bits3,9 · 10−33,4 · 1038

Lecture de la dernière ligne : bfloat16 tient environ 2 à 3 chiffres décimaux. Deux logits de 8,42 et 8,45 y deviennent tous deux 8,4375 : ils se confondent. Le classement des classes peut donc changer d'un dtype à l'autre, à modèle identique.

Réflexe : tenseur.dtype avant de comparer deux résultats, et un accumulateur en float32 au moins pour tout ce qui se somme sur un batch.

Où ça casse casse

La cause unique — mantisse finie — n'a pas de remède, seulement des déplacements. Chaque « solution » achète des ordres de grandeur, jamais l'exactitude.

« Passer en float64 multiplie l'epsilon par 10 puissance moins 9, donc le problème recule de neuf ordres de grandeur, donc il ne disparaît pas, donc une somme assez longue ou une exponentielle assez grande le retrouve. »

Quatre limites
  • float64 ne règle rien, il repousse. ε64 = 2,2 · 10−16 contre 1,2 · 10−7 : neuf ordres de grandeur gagnés, pas l'exactitude. Une somme séquentielle de 109 termes en float64 dérive exactement pour la même raison, avec le même budget n·ε·max. Et l'exponentielle y déborde à z > 709,78 au lieu de 88,72 — un mur plus loin, un mur quand même.
  • bfloat16 en inférence. Résolution relative 4 · 10−3 : des logits séparés de moins de 1 % se confondent (8,42 et 8,45 deviennent le même 8,4375). Les métriques de classement — top-k, MRR, taux d'accord — peuvent changer d'un dtype à l'autre sans qu'aucun poids n'ait bougé. Un écart de top-1 entre deux runs se vérifie d'abord côté dtype.
  • Tolérance fixe. abs(a - b) < 1e-6 est trop lâche autour de 10−9 (tout passe) et inatteignable autour de 109 (rien ne passe, l'ulp float64 y vaut déjà 2 · 10−7). Il faut une tolérance relative, celle du pas 4 — et pour l'argent, pas de tolérance du tout : des entiers.
  • Le nan silencieux. PyTorch ne lève aucune erreur : un inf naît, se propage, devient nan, et l'entraînement continue en produisant des poids nan pendant des heures. torch.isfinite(loss) à chaque pas coûte un test et donne l'itération exacte du premier débordement — le seul endroit où le bug est encore lisible.

Résumé

À retenir
  1. Mantisse finie ⇒ erreur relative (ε32 ≈ 10−7, ε64 ≈ 10−16) ⇒ les grands nombres sont grossiers : 16 777 216 = 16 777 217 en float32.
  2. Somme séquentielle : erreur ≈ n·ε·max. 106 fois 0,1 en float32 donne 100 958,34 ; par paires ou en float64, 100 000.
  3. Jamais == : tolérance relative τ·max(1, |a|, |b|) ; l'argent en centimes entiers (int(4.35*100) = 434).
  4. Dépasser le maximum donne inf, pas 0 et pas une erreur ; inf/inf = nan ; un nan en aval est la trace d'un inf en amont.
  5. LSE : m + log Σ ezm, identité exacte qui ramène toutes les exponentielles sous 1 ; softmax de (1 000 ; 0) = (1 ; 0) au lieu de (nan ; 0).
  6. log_softmax = z − LSE(z), jamais log(softmax(z)) : d'où une cross-entropy qui prend des logits.
  7. PyTorch float32 par défaut, NumPy float64 ; bfloat16 = même plage que float32, 2 à 3 chiffres — pour l'entraînement, parce que le gradient tolère le bruit.
« Un flottant a une mantisse finie, donc son erreur est relative et les grands nombres sont mesurés grossièrement ; une somme séquentielle dérive en n fois epsilon fois l'échelle atteinte. Dépasser le maximum donne inf, pas zéro, et inf sur inf donne nan : un nan est la trace d'un inf en amont. Le log-sum-exp soustrait le max, identité exacte qui ramène toutes les exponentielles sous 1 — c'est pourquoi la cross-entropy prend des logits et fait le log-softmax elle-même. »

Chaîne verbalisée — une prise, à voix haute

5 maillons · clique pour révéler après avoir dit
  1. Pourquoi 0,1 + 0,2 ≠ 0,3 ?
    0,1 a un développement binaire infini ; la mantisse est finie, donc il est remplacé par son voisin dès l'écriture, et la somme de deux voisins arrondie tombe à côté de 0,3.
  2. Pourquoi la somme séquentielle de 0,1 dérive-t-elle, et de combien ?
    Chaque ajout est arrondi à l'échelle de l'accumulateur, qui grandit : budget ≈ n·ε·max. À 106 termes en float32 : 100 958,34 au lieu de 100 000, soit +0,96 %. Par paires ou en float64 : 100 000.
  3. Que donne e1 000 en float32, puis e1 000/e1 000 ?
    inf (le mur est à z = 88,72, au-delà de 3,40·1038), puis inf/inf = nan. Pas 0, pas une exception : inf, qui voyage.
  4. Pourquoi soustraire le max dans un softmax ?
    Mettre em en facteur est une identité exacte ; tous les exposants deviennent ≤ 0, donc toutes les exp sont dans ]0,1], donc plus de débordement — et le plus grand terme vaut exactement 1.
  5. Pourquoi F.cross_entropy veut-elle des logits ?
    Elle calcule log_softmax = z − LSE(z) sans jamais former p. Passer par log(softmax(z)) sous-déborde à 0 puis donne −inf ; sur z = (0 ; −120), −inf au lieu de −120.