Mert Özen Further With Every Line
Artificial Intelligence Introduction to Machine Learning — #11

Bridging to Python: Solving the Same Problem with scikit-learn and Running It in .NET via ONNX

Mert Özen Nov 15, 2026 11 min 6 views
Bridging to Python: Solving the Same Problem with scikit-learn and Running It in .NET via ONNX

A model trained with scikit-learn in Python can be converted to ONNX together with its preprocessing and run in .NET with ONNX Runtime so that it gives the same result. We build Bookstore's cancellation model with a scikit-learn Pipeline and cross-validation, map the concepts to ML.NET, export it with skl2onnx and score it in C#, showing with real output that the difference stays around 10⁻⁷, and the float type, opset and unconvertible step pitfalls.

Version note: The C# code in this post was run with .NET 10 and Microsoft.ML.OnnxRuntime 1.32.0, the Python code with Python 3.14, scikit-learn 1.9.1, pandas 3.1.1, skl2onnx 1.20.0, onnx 1.23.2 and onnxruntime 1.32.0. The comparisons in earlier posts were made with ML.NET 6.0 and the Microsoft.ML 6.0.0 package. The data is synthetic Bookstore data produced by the series' seeded generator; the numbers here are not the results of a real business.

Guess First

In the last post of the series we solve the cancellation problem from the seventh post for Bookstore, the online bookstore used as the shared example across this site's series, in Python with scikit-learn this time. Then we convert the model to an ONNX file and run it in .NET. How large is the difference between the probability computed in Python with float64 and the one ONNX Runtime computes in .NET? At a 0.06 threshold, how many orders change their decision?

Second question: if you add a simple log transform to the scikit-learn pipeline with FunctionTransformer, what happens to the ONNX conversion?

Why the Python Ecosystem?

In this series we used ML.NET as the main road, because the reader is a .NET developer and the model can be embedded straight into ASP.NET Core. But most of classical machine learning lives in Python. scikit-learn, pandas and NumPy have been the common language of data science teams for years; a new algorithm or explainability method usually appears in Python first. In the ninth post we saw that the AutoML package is still at 0.x and that the mlnet CLI has not been updated since 2024. The data science team at your company most likely uses Python. The goal of this post is not to race the two worlds but to build a bridge between them: the model is trained in Python and the .NET application runs it through ONNX.

The Same Data, the Same Features

We read Bookstore's data with pandas and build the features from the seventh post. The customer's number of earlier orders is computed here with groupby and cumcount; the logic is the same as the counter in C#: orders sorted by time, only the past counted for each customer. The code was run in a virtual environment (venv):

scikit-learn==1.9.1
pandas==3.1.1
numpy==2.5.4
skl2onnx==1.20.0
onnx==1.23.2
onnxruntime==1.32.0
CATEGORICAL = ["PaymentMethod", "ShippingMethod", "Warehouse"]
NUMERIC = ["ItemCount", "OrderTotal", "DiscountRate", "PreviousOrders", "IsCampaign", "HasBackorderItem", "IsFirstOrder"]
# Same features as post 07: PreviousOrders counts only the customer's earlier orders
orders = pd.read_csv("data/orders_clean.csv", parse_dates=["OrderedAtUtc"]).sort_values("OrderedAtUtc", kind="stable")
orders["PreviousOrders"] = orders.groupby("CustomerId").cumcount()
orders["IsFirstOrder"] = orders["PreviousOrders"] == 0
for column in NUMERIC:
    orders[column] = orders[column].astype(np.float32)
X, y, groups = orders[CATEGORICAL + NUMERIC], orders["IsCancelled"], orders["CustomerId"]
# 80/20 split grouped by customer (the counterpart of samplingKeyColumnName)
train_idx, test_idx = next(GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=7).split(X, y, groups))
X_train, X_test, y_train, y_test = X.iloc[train_idx], X.iloc[test_idx], y.iloc[train_idx], y.iloc[test_idx]
print(f"train {len(X_train):,} orders ({y_train.mean():.1%} cancelled), test {len(X_test):,} orders ({y_test.mean():.1%} cancelled)")

GroupShuffleSplit is the counterpart of ML.NET's samplingKeyColumnName parameter: a customer's orders stay on the same side. The two libraries shuffle groups differently, so the test sets are not identical; do not compare the AUC values in this post directly with those in the seventh post.

Pipeline and Cross-Validation

In scikit-learn the feature transforms and the model live in a single Pipeline object. ColumnTransformer applies one-hot encoding to the categorical columns and standardization to the numeric ones; it is the counterpart of the OneHotEncoding and NormalizeMeanVariance steps in ML.NET:

pipeline = Pipeline([
    ("features", ColumnTransformer([
        ("onehot", OneHotEncoder(handle_unknown="ignore"), CATEGORICAL),
        ("scale", StandardScaler(), NUMERIC),
    ])),
    ("model", LogisticRegression(max_iter=1000)),
])
cv = cross_val_score(pipeline, X_train, y_train, groups=groups.iloc[train_idx], cv=GroupKFold(n_splits=5), scoring="roc_auc")
print(f"5-fold CV AUC {cv.mean():.3f} ± {cv.std():.3f}")
pipeline.fit(X_train, y_train)
p_sklearn = pipeline.predict_proba(X_test)[:, 1]
print(f"test AUC {roc_auc_score(y_test, p_sklearn):.3f}, PR-AUC {average_precision_score(y_test, p_sklearn):.3f}")
pip install -r python/requirements.txt
python python/cancel_model.py
train 19,343 orders (4.4% cancelled), test 4,837 orders (4.7% cancelled)
5-fold CV AUC 0.675 ± 0.008
test AUC 0.685, PR-AUC 0.098
models/cancel.onnx: 1,779 bytes, opsets ai.onnx 18, ai.onnx.ml 1
outputs: ['label', 'probabilities']; max |sklearn - onnx| = 9.67e-08

The cross-validation AUC is 0.675, and 0.685 on the test set. In the ninth post the cross-validation result of logistic regression in ML.NET was 0.662. Because the splits differ, this gap is not a quality difference between the two libraries; both sides have the same signal, the same algorithm and a similar result. The concepts map like this:

ML.NETscikit-learn
MLContext(seed: 1)random_state parameters
IDataViewpandas DataFrame, NumPy array
Estimator, ITransformer after Fitestimator, the same object after fit
Transform, PredictionEngine.Predicttransform, predict_proba
A pipeline built with AppendPipeline, ColumnTransformer
TrainTestSplit(samplingKeyColumnName)GroupShuffleSplit
CrossValidatecross_val_score and GroupKFold
BinaryClassification.Evaluateroc_auc_score, average_precision_score
.zip with Model.Save.onnx with skl2onnx

The most important difference is in the philosophy. In ML.NET the estimator and the trained transformer are separate types and IDataView is lazy. In scikit-learn the same object becomes trained after the fit call and the data sits in memory as NumPy arrays. scikit-learn's own way to save a model is pickle or joblib. Because these files can run Python code when they are loaded, the scikit-learn documentation also warns against loading pickles from untrusted sources; besides, they can only be read from Python. ONNX answers both problems.

Converting to ONNX with skl2onnx

ONNX describes a model file as a computation graph made of operators. skl2onnx turns the scikit-learn pipeline into such a graph; one-hot encoding and standardization become part of the graph too. So the "store the preprocessing with the model" rule from the tenth post holds here as well:

# Export: one ONNX input per column, preprocessing included in the graph, plain probability output
inputs = [(c, StringTensorType([None, 1])) for c in CATEGORICAL] + [(c, FloatTensorType([None, 1])) for c in NUMERIC]
onx = to_onnx(pipeline, initial_types=inputs, target_opset=18, options={LogisticRegression: {"zipmap": False}})
os.makedirs("models", exist_ok=True)
with open("models/cancel.onnx", "wb") as f:
    f.write(onx.SerializeToString())
print(f"models/cancel.onnx: {os.path.getsize('models/cancel.onnx'):,} bytes, opsets "
      + ", ".join(sorted(f"{o.domain or 'ai.onnx'} {o.version}" for o in onx.opset_import)))
# The same rows through ONNX Runtime in Python: float32 inside the graph, float64 in scikit-learn
session = ort.InferenceSession("models/cancel.onnx", providers=["CPUExecutionProvider"])
feed = {c: X_test[[c]].to_numpy().astype(str) for c in CATEGORICAL} | {c: X_test[[c]].to_numpy() for c in NUMERIC}
label, probabilities = session.run(None, feed)
p_onnx = probabilities[:, 1]
print(f"outputs: {[o.name for o in session.get_outputs()]}; max |sklearn - onnx| = {np.abs(p_sklearn - p_onnx).max():.2e}")
# Rows and scikit-learn probabilities for the .NET check
out = X_test.copy()
out["SklearnProbability"] = p_sklearn
out.to_csv("models/cancel_test.csv", index=False, float_format="%.9g")

Three settings matter. initial_types defines the inputs of the graph: each column is a separate input, categorical columns are string tensors, numeric columns float32 tensors. target_opset is the version of the operator set to use; the ONNX Runtime version that runs the model must support this opset. Here we picked 18. zipmap: False outputs the probabilities as a plain float array. By default skl2onnx gives the probabilities as a list of dictionaries of class name and value pairs; that structure is awkward to read on the .NET side. The 1,779-byte file in the output is the whole thing: one-hot encoder, scaler and logistic regression.

Inside Python we scored the same test rows with ONNX Runtime: the largest difference from scikit-learn is 9.67 × 10⁻⁸. scikit-learn computes in float64, the ONNX graph in float32; the difference comes from this precision gap and is nowhere near large enough to affect a decision threshold.

Scoring with ONNX Runtime in .NET

On the .NET side a new console project references only the Microsoft.ML.OnnxRuntime package. First we read the model's inputs and outputs, then turn the test rows written by Python into tensors and score them:

using InferenceSession session = new("models/cancel.onnx");
foreach ((string name, NodeMetadata meta) in session.InputMetadata.Where(i => i.Key is "PaymentMethod" or "OrderTotal"))
{
    Console.WriteLine($"input  {name,-14} {meta.ElementType.Name}[{string.Join(", ", meta.Dimensions)}]");
}
foreach ((string name, NodeMetadata meta) in session.OutputMetadata)
{
    Console.WriteLine($"output {name,-14} {meta.ElementType.Name}[{string.Join(", ", meta.Dimensions)}]");
}
// One tensor per column, shape [rows, 1], the same names and types as initial_types in Python
List<NamedOnnxValue> Feed(string[][] batch) =>
[
    .. categorical.Select(c => NamedOnnxValue.CreateFromTensor(c,
        new DenseTensor<string>(batch.Select(r => r[Col(c)]).ToArray(), [batch.Length, 1]))),
    .. numeric.Select(c => NamedOnnxValue.CreateFromTensor(c,
        new DenseTensor<float>(batch.Select(r => float.Parse(r[Col(c)], inv)).ToArray(), [batch.Length, 1]))),
];
using (IDisposableReadOnlyCollection<DisposableNamedOnnxValue> results = session.Run(Feed(rows)))
{
    Tensor<float> probabilities = results.First(r => r.Name == "probabilities").AsTensor<float>();
    double[] onnx = [.. Enumerable.Range(0, rows.Length).Select(i => (double)probabilities[i, 1])];
    double[] sklearn = [.. rows.Select(r => double.Parse(r[Col("SklearnProbability")], inv))];
    double maxDiff = onnx.Zip(sklearn).Max(p => Math.Abs(p.First - p.Second));
    int decisions = onnx.Zip(sklearn).Count(p => p.First >= 0.06 != p.Second >= 0.06);
    Console.WriteLine($"{rows.Length:N0} test rows: max |.NET ONNX - scikit-learn| = {maxDiff:E2}, " +
                      $"different decisions at t = 0.06: {decisions}");
    for (int i = 0; i < 3; i++)
    {
        Console.WriteLine($"  {rows[i][Col("PaymentMethod")],-15} back-order {rows[i][Col("HasBackorderItem")]}  " +
                          $"scikit-learn {sklearn[i]:F6}  .NET {onnx[i]:F6}");
    }
}
dotnet run --project src/Bookstore.ML.Onnx
input  PaymentMethod  String[-1, 1]
input  OrderTotal     Single[-1, 1]
output label          Int64[-1]
output probabilities  Single[-1, 2]
4,837 test rows: max |.NET ONNX - scikit-learn| = 9.70E-008, different decisions at t = 0.06: 0
  CashOnDelivery  back-order 0  scikit-learn 0.117362  .NET 0.117362
  CreditCard      back-order 0  scikit-learn 0.048808  .NET 0.048808
  CashOnDelivery  back-order 0  scikit-learn 0.124222  .NET 0.124222

The answer to the first opening question: across 4,837 test rows the largest difference between the probability in .NET and scikit-learn's is 9.70 × 10⁻⁸, and no order changes its decision at the 0.06 threshold. ONNX Runtime in Python and ONNX Runtime in .NET use the same native library, so the difference comes only from rounding between float32 and float64. Both sides run the same model.

The -1 dimension in the input metadata is the row count we defined as None in the graph: the same model can score one order or 4,837 orders. We also measured single-row calls, the way an API would make them:

single-row Run: 16 µs per call (1,000 calls)

This time is specific to the test environment, but in magnitude it is at the same level as the ML.NET prediction in the tenth post: the prediction itself is small next to the cost of an HTTP request. In this post we used the InferenceSession from a single thread. According to the ONNX Runtime team's answers on GitHub, the same session can be called with Run from several threads, and creating the session once and sharing it is recommended; creating a session is expensive. We did not measure this under load in this post; verify it in your own environment with a load test like the one in the tenth post.

If you want to stay with ML.NET there are two more options: the Microsoft.ML.OnnxTransformer package can run an ONNX model as a step of an ML.NET pipeline, and Microsoft.ML.OnnxConverter (0.24.0) can convert some ML.NET models to ONNX. We did not run either in this series.

The Decision: ML.NET, Python and ONNX, or an LLM?

To close the series, here is a summary table for choosing a road for a problem:

RoadWhen it fitsCost
ML.NETThe team writes .NET, the data is tabular, the model runs inside the applicationNarrower choice of algorithms; AutoML and tooling move slowly; some packages are x64 only
Python and ONNXThe data science team uses Python or the needed algorithm only exists in PythonTwo languages, two CI pipelines; steps the converter does not support; a contract between the training and serving teams is needed
LLMThe input is free text, there is little labelled data, the rules depend on languageCost per token, latency, non-deterministic output; weaker than a classical model on tabular data
Cloud AutoML or an MLOps platformMany models, heavy compute for training, need for a central model registryPlatform lock-in and cost; not used in this series

Use the table to start a conversation, not as a rule. Bookstore's delivery-time and cancellation models were solved comfortably with ML.NET; predicting the topic of a review is the kind of problem where an LLM could also work well if there were little labelled data. What decides it in the end is usually not the technology but who will train the model and who will maintain it.

Watch Out in Production

Write down the contract between Python and .NET explicitly: input names, types, the spelling of categorical values and output names. The C# code in this post works by keeping the column names the same as the lists in Python; if one side renames a column, the error only shows up at run time. Reading the graph's inputs with InputMetadata and comparing them with the expected list at startup is a cheap safeguard.

Pin the versions. The skl2onnx, onnx and ONNX Runtime versions should be written explicitly in the requirements file and the csproj. A new skl2onnx version may produce a different graph, an old ONNX Runtime may not run a new opset.

Make the equivalence test part of CI. The check in this post, which compares the test rows and probabilities written by Python with .NET's ONNX results, should run for every new model version. Decide the acceptable difference up front (below 10⁻⁶ here).

The model card and drift monitoring from the tenth post apply to ONNX models too. Even if the model's format changes, you need to know which data it was trained on and how it behaves in production.

Common Pitfalls

The first pitfall is an opset and version mismatch. Make sure the ONNX Runtime version you run supports the opset you chose during conversion; if it does not, you get an error when the model loads.

The second is mixing up float32 and float64. The graph expects float32; give it a double tensor on the .NET side and ONNX Runtime does not convert it:

// The graph expects float32; double is not converted for you
try
{
    List<NamedOnnxValue> wrong = Feed([rows[0]]);
    wrong[categorical.Length + 1] = NamedOnnxValue.CreateFromTensor("OrderTotal", new DenseTensor<double>(new[] { 389.8 }, [1, 1]));
    session.Run(wrong);
}
catch (OnnxRuntimeException e)
{
    Console.WriteLine($"double input: {e.GetType().Name}: {e.Message.Split('\n')[0]}");
}
double input: OnnxRuntimeException: [ErrorCode:InvalidArgument] Tensor element data type discovered: Double metadata expected: Float

The third is using a step the converter does not know. The answer to the second opening question:

# A custom step that skl2onnx does not know
custom = Pipeline([("log", FunctionTransformer(np.log1p)), ("model", LogisticRegression(max_iter=1000))])
custom.fit(X_train[["OrderTotal"]], y_train)
try:
    to_onnx(custom, initial_types=[("OrderTotal", FloatTensorType([None, 1]))], target_opset=18)
except Exception as e:
    print(f"FunctionTransformer: {type(e).__name__}: {str(e).splitlines()[0][:110]}")
FunctionTransformer: RuntimeError: FunctionTransformer is not supported unless the transform function is None (= identity). You may raise an issu

skl2onnx cannot turn an arbitrary Python function into ONNX operators. The fix is either to express the transform with steps ONNX knows, to write a custom converter for that step, or to apply the transform the same way on both sides before the model; the last one brings back the training-serving skew risk from the tenth post.

The fourth is leaving zipmap on and trying to read the output in .NET. If you want a plain probability tensor instead of the default list of dictionaries, you have to say so during conversion.

A Small Experiment

★☆☆ Regenerate the model with a different target_opset value and run the .NET program again. Do the results and the file size change?

★★☆ Use HistGradientBoostingClassifier instead of logistic regression, compare the cross-validation AUC and try converting the model to ONNX. Does skl2onnx support this model? Is the difference in .NET still below 10⁻⁶?

★★★ Solve the delivery-time regression problem from the sixth post with scikit-learn, convert it to ONNX and add it to the API from the tenth post as a second endpoint. Write a load test that compares the predictions the ML.NET model and the ONNX model give for the same orders.

The series ends here. On synthetic Bookstore data and with real output we went through the problem types of classical machine learning, data and features, train/test splits and leakage, regression and classification metrics, methods for unlabelled data, model selection, taking a model to production and the bridge to Python. Perhaps the most important lesson of the series is this: building the model is a small part of the job; choosing the right metric, preventing leakage and continuing to measure in production are the real work. For problems on the text and document side, you can continue with the LLM Fundamentals, AI with .NET and RAG series.