ML-6 (Python) : Intégration de modèles ONNX (skl2onnx + onnxruntime)

Navigation : Index | << ML-5-TimeSeries | Jumeau .NET (ML.NET) | Suivant >> ML-7-Recommendation

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.

# Imports : scikit-learn (modèle), skl2onnx (export), onnxruntime (inférence), onnx (inspection)
import warnings
warnings.filterwarnings('ignore')

import numpy as np
import time
import sklearn
from sklearn.datasets import load_iris
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split

import skl2onnx
from skl2onnx import convert_sklearn
from skl2onnx.common.data_types import FloatTensorType

import onnx
import onnxruntime as ort

print(f"scikit-learn {sklearn.__version__} | skl2onnx {skl2onnx.__version__} | "
      f"onnxruntime {ort.__version__} | onnx {onnx.__version__}")
scikit-learn 1.8.0 | skl2onnx 1.20.0 | onnxruntime 1.27.0 | onnx 1.22.0

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.

# Charger Iris, splitter train/test, entraîner un RandomForest
X, y = load_iris(return_X_y=True)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.3, random_state=42
)

clf = RandomForestClassifier(n_estimators=10, random_state=42)
clf.fit(X_train, y_train)

acc_sklearn = clf.score(X_test, y_test)
print(f"Dimensions X_train : {X_train.shape} | X_test : {X_test.shape}")
print(f"Classes : {clf.classes_}")
print(f"Accuracy scikit-learn (test) : {acc_sklearn:.4f}")
Dimensions X_train : (105, 4) | X_test : (45, 4)
Classes : [0 1 2]
Accuracy scikit-learn (test) : 1.0000

Interprétation

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 -> ONNX
initial_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"
with open(onnx_path, "wb") as f:
    f.write(onx.SerializeToString())

import os
size_kb = os.path.getsize(onnx_path) / 1024
print(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)'}")
Modèle ONNX écrit : iris_model_python.onnx (8.2 KB)
Opset producteur : 1
Domaine : ai.onnx.ml

Interprétation

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 graph
model = 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 producteur
print(f"Producer : {model.producer_name} {model.producer_version}")
print(f"IR version : {model.ir_version}")

# 3. Entrées / sorties typées
g = model.graph
print("\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}")
onnx.checker : graph VALIDE
Producer : skl2onnx 1.20.0
IR version : 8

Entrées :
  - input : tensor_type {
  elem_type: 1
  shape {
    dim {
    }
    dim {
      dim_value: 4
    }
  }
}

Sorties :
  - label : tensor_type {
  elem_type: 7
  shape {
    dim {
    }
  }
}

  - probabilities : tensor_type {
  elem_type: 1
  shape {
    dim {
    }
    dim {
      dim_value: 3
    }
  }
}


Opérateurs (1 nœuds) : ['TreeEnsembleClassifier']

Interprétation

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 Runtime
sess = ort.InferenceSession(onnx_path)

# Vérifier la signature attendue par le runtime
print("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 obligatoire
X_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 int64
proba_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]}")
Entrées attendues : [('input', [None, 4])]
Sorties produites  : [('label', [None]), ('probabilities', [None, 3])]

Accuracy onnxruntime (test) : 1.0000
Premieres predictions : [1 0 2 1 1 0 1 2]
Premieres probabilites :
[[0.        1.0000001 0.       ]
 [1.0000001 0.        0.       ]
 [0.        0.        1.0000001]]

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 onnxruntime
pred_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 absolu
max_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-5 else "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 onnxruntime
result_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.

# Benchmark sklearn.predict vs onnxruntime sur un gros batch
X_big = np.random.RandomState(0).rand(10_000, 4).astype(np.float32)

# Session avec optimisations maximales
so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
sess_opt = ort.InferenceSession(onnx_path, sess_options=so)

# Echauffement (premier run compile/optimise le graph)
_ = clf.predict(X_big[:100])
_ = sess_opt.run(None, {'input': X_big[:100]})

N_REPEAT = 20
t0 = time.perf_counter()
for _ in range(N_REPEAT):
    _ = clf.predict(X_big)
t_sklearn = (time.perf_counter() - t0) / N_REPEAT

t0 = time.perf_counter()
for _ in range(N_REPEAT):
    _ = sess_opt.run(None, {'input': X_big})
t_onnx = (time.perf_counter() - t0) / N_REPEAT

speedup = t_sklearn / t_onnx
print(f"Batch size : {X_big.shape[0]} x {X_big.shape[1]}")
print(f"scikit-learn predict : {t_sklearn*1000:.2f} ms/call")
print(f"onnxruntime run      : {t_onnx*1000:.2f} ms/call")
print(f"Speedup onnxruntime / sklearn : x{speedup:.2f}")
Batch size : 10000 x 4
scikit-learn predict : 1.65 ms/call
onnxruntime run      : 0.64 ms/call
Speedup onnxruntime / sklearn : x2.57

Interprétation

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 tendance
speedups_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 regression
ops_ex3 = None  # TODO etudiant : ensemble des op_types du graph
print("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]
onnx.checker Validation structurelle d’un graph ONNX
InferenceSession Session d’inférence onnxruntime (entrée float32, run)
Équivalence sklearn ⇄ ONNX 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/
  • ONNX Runtime — runtime d’inférence optimisé : https://onnxruntime.ai/
  • ML-6-ONNX.ipynb (jumeau .NET) — consommation d’un modèle ONNX équivalent (iris_model.onnx) côté ML.NET
  • Série Hands-On AI Trading, Jared Broad — chapitre interopérabilité modèles ML
Retour au sommet