Aller au contenu principal

Module 4 — Vérification de l'équivalence numérique

Un modèle exporté sans vérification numérique est un modèle qu'on suppose correct. C'est la source la plus fréquente de régressions découvertes en production : le fichier .onnx s'ouvre, la session se crée, l'API répond « OK », les métriques hors ligne s'écroulent silencieusement. Ce module systématise le contrôle qui empêche cet incident, sur les deux modèles du fil rouge.

Le principe : un export est présumé cassé

La règle qu'on adopte ici est stricte et pratique. Tant que l'export n'a pas passé la vérification numérique sur un jeu d'entrées varié, il est considéré comme cassé. Ce n'est pas de la méfiance excessive : la conversion introduit systématiquement de petits écarts, et distinguer un écart normal d'un bug demande d'exécuter les deux modèles côte à côte.

Trois classes d'écarts se rencontrent.

  • Écart d'arrondi float32 : ordre de grandeur 1e-6 à 1e-5. C'est la variance de l'ordre des additions dans les kernels : ONNX Runtime, PyTorch et TensorFlow n'appliquent pas les mêmes fusions d'opérations.
  • Écart de convention : ordre de grandeur 1e-2 à 1. Un ratio Batch/Layer Norm oublié, une entrée mal permutée, une valeur de padding différente.
  • Écart catastrophique : le modèle exporté rend n'importe quoi, ou renvoie une constante. C'est souvent un torch.eval() oublié, un dynamic_axes mal déclaré, un traçage sur un branchement fatal.

Le premier écart est acceptable, le deuxième signale un bug de conversion, le troisième invalide l'export.

La vérification de base sur un lot fixé

Pour le ResNet18 du fil rouge, la vérification se fait sur un lot d'entrée aléatoire, entre PyTorch et ONNX Runtime.

import numpy as np
import onnx
import onnxruntime as ort
import torch

# 1. Charger le modèle d'origine et son export
modele_pytorch = charger_resnet18_fashion() # défini par ailleurs
modele_pytorch.eval()

session = ort.InferenceSession(
"resnet18_fashion.onnx",
providers=["CPUExecutionProvider"],
)

# 2. Un lot d'entrée reproductible
generateur = np.random.default_rng(seed=42)
entree_np = generateur.standard_normal((4, 3, 224, 224)).astype(np.float32)
entree_torch = torch.from_numpy(entree_np)

# 3. Deux inférences
with torch.no_grad():
sortie_pytorch = modele_pytorch(entree_torch).cpu().numpy()

sortie_onnx = session.run(["logits"], {"entree": entree_np})[0]

# 4. Comparer
ecart_max = np.abs(sortie_pytorch - sortie_onnx).max()
ecart_moy = np.abs(sortie_pytorch - sortie_onnx).mean()
print(f"écart max : {ecart_max:.2e}")
print(f"écart moyen : {ecart_moy:.2e}")

assert np.allclose(sortie_pytorch, sortie_onnx, atol=1e-4, rtol=1e-4), (
"L'export ONNX ne correspond pas au modèle PyTorch."
)
print("Vérification réussie.")

Deux tolérances plutôt qu'une. atol (tolérance absolue) protège les valeurs proches de zéro : une sortie de 1e-8 autorise un écart de 1e-4 sans que ce soit un bug. rtol (tolérance relative) protège les grandes valeurs : une sortie de 10.0 accepte un écart de 1e-3 en relatif. np.allclose combine les deux : deux valeurs sont proches si |a - b| <= atol + rtol * |b|.

Pour un modèle float32, atol=1e-4 et rtol=1e-4 sont les valeurs par défaut du projet ONNX. C'est le seuil qu'on utilise dans tout le cours.

Jeux d'entrées variés : le point crucial

Une seule entrée aléatoire est très insuffisante. Un modèle peut exporter correctement sur des valeurs randn(...) proches de zéro et se casser sur des images normalisées, ou l'inverse. Trois entrées à toujours tester.

  • Une entrée réaliste issue du jeu de validation : la moyenne et l'écart-type reflètent la distribution de production.
  • Une entrée saturée aux extrêmes : 1.0 partout, -1.0 partout. C'est là que se révèlent les activations qui explosent.
  • Une entrée à valeurs entières encodées en float32 — utile pour un modèle NLP dont les identifiants de tokens sont des entiers.
def verifier_export(modele_pytorch, session, entrees):
"""Compare le modèle et son export sur plusieurs entrées."""
ecarts_max = []
for nom, entree_np in entrees.items():
entree_torch = torch.from_numpy(entree_np)
with torch.no_grad():
s_torch = modele_pytorch(entree_torch).cpu().numpy()
s_onnx = session.run(None, {"entree": entree_np})[0]

e = np.abs(s_torch - s_onnx).max()
ecarts_max.append((nom, e))
assert e < 1e-3, f"{nom} : écart trop grand ({e:.2e})"
return ecarts_max

entrees = {
"gaussienne": generateur.standard_normal((4, 3, 224, 224)).astype(np.float32),
"un_partout": np.ones((4, 3, 224, 224), dtype=np.float32),
"moins_un": -np.ones((4, 3, 224, 224), dtype=np.float32),
"images_reelles": lot_du_jeu_de_validation(),
}
for nom, e in verifier_export(modele_pytorch, session, entrees):
print(f"{nom:20s} écart max = {e:.2e}")

On accepte 1e-3 ici pour absorber les cas saturés, où les activations en fin de réseau atteignent quelques dizaines et amplifient l'erreur d'arrondi. Pour une entrée réaliste, on descend à 1e-4.

Le vérificateur intégré : onnx.checker

Avant même d'exécuter le modèle, onnx.checker.check_model détecte les invariants structurels cassés : un nœud qui référence une entrée inconnue, un tenseur consommé deux fois comme sortie, une forme incohérente propagée. C'est instantané et gratuit.

import onnx

modele = onnx.load("resnet18_fashion.onnx")
onnx.checker.check_model(modele)

Un modèle qui ne passe pas check_model ne se chargera jamais dans ONNX Runtime avec un message clair : l'échec du checker en amont fait gagner du temps de débogage.

La dérive en demi-précision

Un export en float32 qu'on convertit ensuite en float16 pour économiser de la mémoire et gagner en vitesse est une pratique courante. Cette conversion change les tolérances attendues.

  • atol monte à 1e-2, parfois 5e-2 sur les grandes valeurs.
  • Certaines activations qui explosent à quelques centaines en float32 deviennent inf en float16 — l'écart devient infini.
import onnxruntime as ort
from onnxconverter_common import float16

modele = onnx.load("resnet18_fashion.onnx")
modele_fp16 = float16.convert_float_to_float16(modele, keep_io_types=True)
onnx.save(modele_fp16, "resnet18_fashion_fp16.onnx")

session_fp16 = ort.InferenceSession("resnet18_fashion_fp16.onnx", providers=["CPUExecutionProvider"])
sortie_fp16 = session_fp16.run(None, {"entree": entree_np})[0]

ecart = np.abs(sortie_pytorch - sortie_fp16).max()
print(f"écart float16 : {ecart:.2e}")
# Typiquement 1e-2 à 5e-2 sur des sorties raisonnables.

keep_io_types=True garde les tenseurs d'entrée et de sortie en float32 — le modèle convertit en interne, ce qui simplifie l'intégration côté service. C'est la voie qu'on suit dans ce cours.

Le cas de l'encodeur de texte

Le petit encodeur de texte du fil rouge se vérifie de la même manière, avec deux entrées plutôt qu'une.

tokens_np = generateur.integers(0, 30000, (4, 128)).astype(np.int64)
masque_np = np.ones((4, 128), dtype=np.int64)

sortie_onnx = session_texte.run(None, {"tokens": tokens_np, "masque": masque_np})[0]

with torch.no_grad():
sortie_torch = encodeur_texte(
torch.from_numpy(tokens_np),
torch.from_numpy(masque_np),
).cpu().numpy()

print("écart max :", np.abs(sortie_torch - sortie_onnx).max())

Un piège classique sur les transformeurs : la couche LayerNorm de PyTorch et son équivalent ONNX peuvent différer d'un facteur 1e-4 à cause d'un calcul de variance en float32 intermédiaire. On accepte cet ordre, on refuse plus.

Ce qui déclenche un écart catastrophique

Quelques causes fréquentes, à connaître pour les identifier vite.

  • Mode train oublié : le modèle sort du Dropout en test, l'export l'inclut, la sortie est bruitée.
  • BatchNorm mal figé : quand le modèle est en mode train à l'export, la couche utilise la statistique du lot ; sur un lot d'un exemple, la moyenne du lot égale l'exemple lui-même, donc la sortie est identiquement nulle.
  • dynamic_axes mal aligné : PyTorch produit un tenseur (N, 10), l'export attend (N, 1000) parce que l'axe de classes a été rendu dynamique par erreur.
  • Précision d'index cassée : un long PyTorch (int64) est parfois converti en int32 par l'export ; un tenseur d'entrée en int64 côté service se retrouve avec une entrée inattendue.

En résumé

  • Un modèle exporté est présumé cassé tant qu'il n'a pas été comparé numériquement au modèle d'origine sur un jeu d'entrées varié.
  • np.allclose combine tolérance absolue et relative ; pour du float32, atol=1e-4 et rtol=1e-4 sont les seuils raisonnables du projet ONNX.
  • onnx.checker.check_model détecte les invariants structurels avant même toute exécution ; c'est le premier filet, gratuit et instantané.
  • La conversion en float16 relève les tolérances attendues à 1e-2 ou plus ; garder float32 en entrée et sortie (keep_io_types=True) simplifie l'intégration.

Le module suivant part d'un modèle correctement exporté et vérifié, et l'optimise : fusion d'opérateurs, pliage de constantes, choix du niveau d'optimisation d'ONNX Runtime.