Ce notebook est le jumeau Python de ML-6-ONNX.ipynb (ML.NET OnnxTransformer). Il couvre l’autre bout du pont d’interopérabilité : produire un modèle ONNX en Python (entraînement scikit-learn → export skl2onnx) puis le consommer en Python via onnxruntime. Le jumeau .NET, lui, charge un modèle ONNX dérivé du même RandomForest Iris, mais sérialisé à part dans iris_model.onnx (produit par scripts/ml/generate_iris_onnx.py) ; ce notebook exporte le sien sous iris_model_python.onnx pour ne pas l’écraser.
Objectifs d’apprentissage
À la fin de ce notebook, vous saurez : 1. Comprendre ONNX (Open Neural Network Exchange) comme format d’échange de modèles indépendant du framework 2. Exporter un modèle scikit-learn vers ONNX avec skl2onnx (types d’entrée, target_opset, zipmap) 3. Inspecter un graph ONNX (entrées, sorties, opérateurs, opset) 4. Exécuter une inférence ONNX en Python avec onnxruntime 5. Valider numériquement l’équivalence sklearn ⇄ onnxruntime et optimiser les performances
Prérequis
ML-1 à ML-5 (Python) complétés
Notions de scikit-learn (fit / predict / Pipeline)
Introduction — qu’est-ce qu’ONNX ?
ONNX (Open Neural Network Exchange) est un format de sérialisation de modèles de machine learning, indépendant du framework qui les a entraînés. Un modèle entraîné en scikit-learn, PyTorch ou TensorFlow peut être exporté vers un fichier .onnx, puis consommé par n’importe quel runtime ONNX — en Python (onnxruntime), en .NET (ML.NET OnnxTransformer), en C++, en Java, etc.
Le format décrit le modèle comme un graphe orienté : les nœuds sont des opérateurs (addition, matmul, relu, tree ensemble…) normalisés par un opset (ensemble d’opérateurs disponibles à une version donnée). Les entrées et sorties sont typées (tenseurs float/int64…) et nommées.
Le workflow canonical de ce notebook : 1. Entraîner un RandomForestClassifier scikit-learn sur Iris 2. L’exporter en ONNX (skl2onnx.convert_sklearn) 3. Inspecter le graph produit 4. Exécuter l’inférence avec onnxruntime 5. Vérifier que sklearn et onnxruntime donnent exactement les mêmes prédictions
Le jumeau .NET (ML-6-ONNX.ipynb) recharge, lui, un modèle ONNX équivalent (même RandomForest Iris) produit séparément par scripts/ml/generate_iris_onnx.py dans le fichier iris_model.onnx — distinct de l’export iris_model_python.onnx réalisé à l’étape 2.
Partie 1 : Entraîner un modèle scikit-learn (Iris)
On utilise le jeu de données Iris (4 features longueurs/largeurs de sépale/pétale, 3 classes d’iris). On entraîne un RandomForestClassifier simple (10 arbres) sur 70 % des données et on garde 30 % pour évaluer.
On dispose d’un modèle entraîné en mémoire, dans le format propre à scikit-learn. Pour le rendre portable (consommable hors Python), il faut le sérialiser vers ONNX. C’est l’objet de la partie suivante.
Partie 2 : Export ONNX avec skl2onnx
La fonction convert_sklearn traduit le modèle scikit-learn en graph ONNX. Trois éléments sont essentiels :
initial_types : ONNX est typé statiquement ; il faut déclarer le nom et la forme de l’entrée. Ici une matrice float32 de forme [None, 4] (batch variable, 4 features Iris). None = batch de taille quelconque.
target_opset : version du jeu d’opérateurs ONNX visé. Plus c’est élevé, plus d’opérateurs disponibles — mais le runtime consommateur doit le supporter. On vise l’opset 15 (largement supporté).
options={id(clf): {'zipmap': False}} : par défaut, les classifieurs exportent les probabilités comme une liste de dictionnaires (ZipMap), peu pratique pour le calcul vectoriel. On désactive ZipMap pour récupérer un tenseur[batch, n_classes].
# Export scikit-learn -> ONNXinitial_type = [('input', FloatTensorType([None, 4]))]onx = convert_sklearn( clf, initial_types=initial_type, options={id(clf): {'zipmap': False}}, target_opset=15,)# Sérialiser vers un fichier .onnx (celui que le jumeau .NET rechargera)# Nom distinct du .onnx canonique (produit par scripts/ml/generate_iris_onnx.py,# consommé par le jumeau .NET ML-6-ONNX.ipynb) — on evite de l'ecraser.onnx_path ="iris_model_python.onnx"withopen(onnx_path, "wb") as f: f.write(onx.SerializeToString())import ossize_kb = os.path.getsize(onnx_path) /1024print(f"Modèle ONNX écrit : {onnx_path} ({size_kb:.1f} KB)")print(f"Opset producteur : {onx.opset_import[0].version}")print(f"Domaine : {onx.opset_import[0].domain or'ai.onnx (standard)'}")
Le modèle est désormais un fichier .onnx autonome : il ne dépend plus de scikit-learn, ni même de Python. On peut le copier sur une machine Windows et le charger en ML.NET (jumeau .NET, cellules « Charger un modèle ONNX »), ou le recharger ici-même avec onnxruntime (partie 4).
Partie 3 : Inspection du graph ONNX
Avant d’exécuter un modèle ONNX, on l’inspecte : vérifier sa validité (onnx.checker), lister ses entrées/sorties nommées et typées, et regarder quels opérateurs composent le graph. C’est l’équivalent d’ouvrir le capot.
# Charger le fichier .onnx et inspecter le graphmodel = onnx.load(onnx_path)# 1. Validation structurelle (lève une exception si le graph est mal formé)onnx.checker.check_model(model)print("onnx.checker : graph VALIDE")# 2. Métadonnées producteurprint(f"Producer : {model.producer_name}{model.producer_version}")print(f"IR version : {model.ir_version}")# 3. Entrées / sorties typéesg = model.graphprint("\nEntrées :")for inp in g.input:print(f" - {inp.name} : {inp.type}")print("Sorties :")for out in g.output:print(f" - {out.name} : {out.type}")# 4. Opérateurs utilisés (types de nœuds)op_types =sorted({n.op_type for n in g.node})print(f"\nOpérateurs ({len(g.node)} nœuds) : {op_types}")
Un RandomForestClassifier exporté produit typiquement un nœud TreeEnsembleClassifier (l’ensemble d’arbres) suivi de la sortie label (int64) et probabilities (float [batch, 3]). On retrouve exactement la signature que le jumeau .NET mappe avec [ColumnName("input")] côté ML.NET : les noms d’entrées/sorties ONNX sont le contrat entre les frameworks.
Partie 4 : Inférence avec onnxruntime
onnxruntime est le runtime de référence (optimisé, multi-thread). On crée une InferenceSession depuis le fichier .onnx, on prépare l’entrée au bon format (float32, clé = nom de l’entrée du graph) et on appelle run.
Piège classique : ONNX attend du float32, alors que scikit-learn travaille en float64. Il faut convertir explicitement X.astype(np.float32) sinon l’inférence échoue ou produit des erreurs subtiles.
# Inférence ONNX Runtimesess = ort.InferenceSession(onnx_path)# Vérifier la signature attendue par le runtimeprint("Entrées attendues :", [(i.name, i.shape) for i in sess.get_inputs()])print("Sorties produites :", [(o.name, o.shape) for o in sess.get_outputs()])# Préparer l'entrée : float32 obligatoireX_test_f32 = X_test.astype(np.float32)# run(inputs) renvoie [label, probabilities] (ordre de g.output)outputs = sess.run(None, {'input': X_test_f32})pred_onnx = outputs[0] # etiquettes int64proba_onnx = outputs[1] # probabilites [batch, 3]acc_onnx = (pred_onnx == y_test).mean()print(f"\nAccuracy onnxruntime (test) : {acc_onnx:.4f}")print(f"Premieres predictions : {pred_onnx[:8]}")print(f"Premieres probabilites :\n{proba_onnx[:3]}")
Partie 5 : Validation numérique — sklearn ⇄ onnxruntime
L’export ONNX ne doit pas changer les prédictions : sklearn et onnxruntime doivent coïncider exactement sur les étiquettes et à 1e-6 près sur les probabilités. On quantifie l’écart.
# Comparaison sklearn vs onnxruntimepred_sklearn = clf.predict(X_test.astype(np.float32))proba_sklearn = clf.predict_proba(X_test.astype(np.float32))# 1. Etiquettes : identiques ?labels_match = np.array_equal(pred_sklearn, pred_onnx)print(f"Etiquettes identiques : {labels_match}")# 2. Probabilites : ecart max absolumax_diff = np.max(np.abs(proba_sklearn - proba_onnx))mean_diff = np.mean(np.abs(proba_sklearn - proba_onnx))print(f"Ecart max absolu sur les probabilites : {max_diff:.2e}")print(f"Ecart moyen absolu sur les probabilites : {mean_diff:.2e}")VERDICT ="EQUIVALENT"if labels_match and max_diff <1e-5else"DIVERGENT"print(f"\nVerdict : sklearn == onnxruntime -> {VERDICT}")
Etiquettes identiques : True
Ecart max absolu sur les probabilites : 1.19e-07
Ecart moyen absolu sur les probabilites : 3.78e-08
Verdict : sklearn == onnxruntime -> EQUIVALENT
Interprétation
L’écart (typiquement < 1e-6) vient d’arrondis float32 vs float64 dans l’évaluation des arbres ; il est négligeable et n’affecte jamais l’étiquette prédite. Conclusion : ONNX est une copie fidèle du modèle sklearn, consommable identiquement côté .NET.
Exercice 1 : Exporter un autre estimateur vers ONNX
Le RandomForestClassifier est exporté ci-dessus. Votre tâche : entraîner un LogisticRegression sur Iris, l’exporter vers ONNX (opset 15, zipmap=False), et vérifier que l’accuracy sklearn == accuracy onnxruntime.
Indice : LogisticRegression(max_iter=200) converge sur Iris. Le initial_type ne change pas (même 4 features). N’oubliez pas astype(np.float32) avant le run.
Étapes : 1. Entraîner LogisticRegression(max_iter=200, random_state=42) sur (X_train, y_train) 2. convert_sklearn avec initial_types=initial_type, zipmap=False, target_opset=15 3. Créer une InferenceSession, exécuter sur X_test.astype(np.float32) 4. Comparer les accuracy sklearn vs onnxruntime — doivent coïncider
# Exercice 1 : Exporter un LogisticRegression vers ONNX et valider l'equivalence# TODO etudiant : reprendre le pattern des parties 2+4+5 avec LogisticRegression# Indice : LogisticRegression(max_iter=200, random_state=42).fit(X_train, y_train)# Etape 1 : entrainer le modele# Etape 2 : convert_sklearn (... zipmap False, target_opset 15 ...)# Etape 3 : InferenceSession + run sur X_test float32# Etape 4 : comparer accuracy sklearn vs onnxruntimeresult_ex1 =None# TODO etudiant : (acc_sklearn, acc_onnx, labels_match)print("Exercice 1 a completer")
Exercice 1 a completer
Partie 6 : Performance — options de session et benchmark batch
onnxruntime applique des optimisations de graph (fusion d’opérateurs, élimination de calculs redondants) et exploite le multi-thread. Sur de gros batchs, l’inférence ONNX est souvent plus rapide que clf.predict scikit-learn — c’est l’intérêt opérationnel du format en production.
On compare les deux sur un batch synthétique de 10 000 instances.
Le rapport dépend du modèle et de la taille de batch, mais onnxruntime bénéficie d’un runtime C++ optimisé et des optimisations de graph. Sur un RandomForest à 10 arbres le gain est modeste ; sur des modèles plus larges ou des batchs massifs, l’écart s’élargit. C’est ce qui justifie de déployer en ONNX plutôt que d’embarquer scikit-learn en production.
Exercice 2 : Mesurer l’impact de la taille du modèle sur le speedup
Le benchmark ci-dessus utilise 10 arbres. Votre tâche : varier n_estimators (10, 50, 100) et mesurer comment le speedup onnxruntime/sklearn évolue.
Indice : plus le modèle est gros, plus l’optimisation de graph paye. Bouclez sur les trois tailles, exportez un ONNX par taille, et comparez t_sklearn/t_onnx.
Étapes : 1. Pour n_est dans [10, 50, 100] : entraîner un RandomForestClassifier(n_estimators=n_est, random_state=42) 2. Exporter en ONNX, créer une InferenceSession optimisée 3. Mesurer t_sklearn et t_onnx sur X_big 4. Stocker le speedup et conclure sur la tendance
# Exercice 2 : impact de n_estimators sur le speedup onnxruntime vs sklearn# TODO etudiant : boucler sur n_estimators in [10, 50, 100], mesurer speedup# Indice : reprendre le pattern Partie 6 dans une boucle# Etape 1 : boucle sur [10, 50, 100]# Etape 2 : entrainer + exporter + InferenceSession optimisee pour chaque# Etape 3 : benchmark t_sklearn vs t_onnx sur X_big# Etape 4 : collecter (n_est, speedup) et afficher la tendancespeedups_ex2 =None# TODO etudiant : liste de (n_estimators, speedup)print("Exercice 2 a completer")
Exercice 2 a completer
Exercice 3 : Inspecter les opérateurs d’un second modèle
Votre tâche : exporter un KMeans (clustering, voir ML-8) ou un StandardScaler + classifieur dans une Pipeline, et lister les opérateurs ONNX produits.
Indice : une Pipeline sklearn s’export aussi via convert_sklearn — chaque étape devient un sous-graphe. Le StandardScaler produit un nœud Scaler (soustraction mean / division scale).
Étapes : 1. Construire make_pipeline(StandardScaler(), LogisticRegression(max_iter=200)) 2. L’entraîner, l’exporter en ONNX (opset 15) 3. Lister {n.op_type for n in graph.node} et identifier les nœuds liés au scaling vs à la régression
# Exercice 3 : exporter une Pipeline (StandardScaler + classifieur) et lister ses operateurs# TODO etudiant : construire, entrainer, exporter une Pipeline et inspecter ses operateurs# Indice : from sklearn.pipeline import make_pipeline ; from sklearn.preprocessing import StandardScaler# Etape 1 : pipeline = make_pipeline(StandardScaler(), LogisticRegression(max_iter=200))# Etape 2 : fit + convert_sklearn (target_opset 15)# Etape 3 : extraire {n.op_type for n in onx.graph.node}# Etape 4 : identifier les nœuds de scaling (Scaler) vs regressionops_ex3 =None# TODO etudiant : ensemble des op_types du graphprint("Exercice 3 a completer")
Exercice 3 a completer
Conclusion et points clés
Concept
Description
ONNX
Format d’échange de modèles, indépendant du framework (graph + opset)
skl2onnx.convert_sklearn
Export d’un modèle scikit-learn vers ONNX
initial_types
Déclaration du nom + type + forme des entrées (ex. FloatTensorType([None, 4]))
target_opset
Version du jeu d’opérateurs visé (compatibilité runtime)
zipmap=False
Désactive les dictionnaires de probabilités → tenseur [batch, n_classes]
Prédictions identiques, écarts de proba < 1e-6 (float32 vs float64)
GraphOptimizationLevel
Optimisations de graph (fusion d’opérateurs) → speedup en production
Ce qu’il faut retenir : ONNX est le pont entre l’entraînement (Python/scikit-learn) et la production (.NET/ML.NET, C++, edge). On entraîne où c’est expressif (Python), on déploie où c’est rapide et portable (ONNX Runtime). Le jumeau .NET ML-6-ONNX.ipynb consomme un modèle ONNX équivalent (même RandomForest Iris), mais dans un fichier distinct — iris_model.onnx produit par scripts/ml/generate_iris_onnx.py, indépendamment de l’export iris_model_python.onnx réalisé dans la Partie 2.
Références
ONNX — specification et opérateurs : https://onnx.ai/
skl2onnx — export scikit-learn vers ONNX : https://onnx.ai/sklearn-onnx/