- 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.
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 :
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. »
| écrit | réellement stocké |
|---|---|
| 0,1 en float64 | 0,100000000000000005551115123… |
| 0,1 en float32 | 0,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 :
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. »
| x | ulp(x) | lecture |
|---|---|---|
| 1 | 1,19 · 10−7 | 7 chiffres utiles |
| 1 000 | 6,10 · 10−5 | 4 décimales |
| 106 | 0,0625 | 1 décimale |
| 16 777 216 = 224 | 2 | les impairs n'existent plus |
| 108 | 8 | on 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.
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. »
| méthode | résultat | erreur relative |
|---|---|---|
| boucle séquentielle float32 | 100 958,34 | +0,96 % |
| somme par paires float32 | 100 000,0 | 0 |
| boucle séquentielle float64 | 100 000,000001 | 1,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.
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 :
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. »
| écrit | obtenu en float64 | == |
|---|---|---|
1.1 * 3 | 3,3000000000000003 | False |
0.1 + 0.2 | 0,30000000000000004 | False |
4.35 * 100 | 434,99999999999994 | False |
0.29 * 100 | 28,999999999999996 | False |
2.00 - 1.10 | 0,8999999999999999 | False |
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. »
Le plus grand float32 est 3,4028 · 1038, donc ez déborde dès que z > log(3,4028 · 1038) = 88,7228.
| calcul | float32 |
|---|---|
| e88,7 | 3,326 · 1038 — passe de justesse |
| e89 | inf (la vraie valeur, 4,49 · 1038, existe pourtant en float64) |
| inf / inf | nan |
| nan == nan | False |
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 :
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 = ezi−m / Σj ezj−m.
« 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. »
| naïf | max soustrait | |
|---|---|---|
| exponentielles | e1000 = inf ; e0 = 1 | e0 = 1 ; e−1000 = 0 |
| softmax | (nan ; 0) | (1 ; 0) |
| LSE | log(inf) = inf | 1 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.
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.
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é. »
| chemin | calcul | résultat float32 |
|---|---|---|
| log(softmax) | p2 = e−120/(1+e−120) = 7,7 · 10−53 → 0, puis log 0 | −inf |
| z − LSE | LSE = 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. »
| dtype | mantisse | ε | plus grand |
|---|---|---|---|
| float64 | 53 bits | 2,2 · 10−16 | 1,8 · 10308 |
| float32 | 24 bits | 1,2 · 10−7 | 3,4 · 1038 |
| float16 | 11 bits | 9,8 · 10−4 | 65 504 |
| bfloat16 | 8 bits | 3,9 · 10−3 | 3,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. »
- 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-6est 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é
- Mantisse finie ⇒ erreur relative (ε32 ≈ 10−7, ε64 ≈ 10−16) ⇒ les grands nombres sont grossiers : 16 777 216 = 16 777 217 en float32.
- Somme séquentielle : erreur ≈ n·ε·max. 106 fois 0,1 en float32 donne 100 958,34 ; par paires ou en float64, 100 000.
- Jamais
==: tolérance relative τ·max(1, |a|, |b|) ; l'argent en centimes entiers (int(4.35*100)= 434). - 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.
- LSE : m + log Σ ez−m, identité exacte qui ramène toutes les exponentielles sous 1 ; softmax de (1 000 ; 0) = (1 ; 0) au lieu de (nan ; 0).
log_softmax= z − LSE(z), jamaislog(softmax(z)): d'où une cross-entropy qui prend des logits.- 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.
Chaîne verbalisée — une prise, à voix haute
- 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.
- 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.
- 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.
- 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.
- Pourquoi
F.cross_entropyveut-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.
Ponts et cartes
python::flottants (mantisse finie, erreur relative, ulp, jamais ==) · dl::log-sum-exp (m + log Σ ez−m, identité exacte) · dl::softmax-stabilite (inf → nan, softmax de (1 000 ; 0), log_softmax = z − LSE) · python::dtypes (float32 PyTorch vs float64 NumPy, bfloat16 = plage de float32).
Une carte qui résiste après cette chaîne est une carte à refondre, pas une section à relire.