Mert Özen Her Satırda Daha İleri
Yapay Zeka Makine Öğrenmesine Giriş — #11

Python ile Köprü: Aynı Problemi scikit-learn ile Çözmek ve ONNX ile .NET'te Çalıştırmak

Mert Özen 15 Kas 2026 11 dk 5 görüntülenme
Python ile Köprü: Aynı Problemi scikit-learn ile Çözmek ve ONNX ile .NET'te Çalıştırmak

Python'da scikit-learn ile eğitilen bir model, preprocessing'iyle birlikte ONNX'e çevrilip .NET'te ONNX Runtime ile aynı sonucu verecek şekilde çalıştırılabilir. Bookstore iptal modelini scikit-learn Pipeline ve cross-validation ile kuruyor, kavramları ML.NET ile eşliyor, skl2onnx ile export edip C#'ta skorluyor; iki taraf arasındaki farkın 10⁻⁷ seviyesinde kaldığını, float tipi, opset ve dönüştürülemeyen adım tuzaklarını gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki C# kodları .NET 10 ve Microsoft.ML.OnnxRuntime 1.32.0 ile, Python kodları Python 3.14, scikit-learn 1.9.1, pandas 3.1.1, skl2onnx 1.20.0, onnx 1.23.2 ve onnxruntime 1.32.0 ile çalıştırılmıştır. Önceki yazılardaki karşılaştırmalar Microsoft.ML 6.0.0 (ML.NET 6.0) ile yapıldı. Veri, serinin seed'li generator'ıyla üretilmiş sentetik Bookstore verisidir; buradaki sayılar gerçek bir işletmenin sonuçları değildir.

Önce Tahmin Et

Serinin son yazısında sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için yedinci yazıdaki iptal tahmini problemini bu kez Python'da, scikit-learn ile çözüyoruz. Sonra modeli ONNX (Open Neural Network Exchange; farklı framework'lerde eğitilen modelleri taşımak için kullanılan açık model formatı) dosyasına çevirip .NET'te çalıştıracağız. Python'da float64 ile hesaplanan olasılık ile .NET'te ONNX Runtime'ın hesapladığı olasılık arasında ne kadar fark çıkar? 0,06 threshold'unda kaç siparişin kararı değişir?

İkinci soru: scikit-learn pipeline'ına basit bir log dönüşümünü FunctionTransformer ile eklersen ONNX'e çevirme ne olur?

Neden Python Ekosistemi?

Bu seride ML.NET'i ana yol olarak kullandık, çünkü okurun .NET geliştiricisi ve model doğrudan ASP.NET Core'a gömülebiliyor. Ama klasik machine learning'in büyük kısmı Python'da yaşıyor. scikit-learn, pandas ve NumPy (Python'un sayısal dizi kütüphanesi) yıllardır veri bilimi ekiplerinin ortak dili; yeni bir algoritma ya da açıklanabilirlik yöntemi çoğu zaman önce Python'da çıkıyor. Dokuzuncu yazıda AutoML paketinin hâlâ 0.x sürümünde olduğunu, mlnet CLI'ın 2024'ten beri güncellenmediğini görmüştük. Şirketindeki veri bilimi ekibi büyük ihtimalle Python kullanıyor. Bu yazının amacı iki dünyayı yarıştırmak değil, aralarında köprü kurmak: model Python'da eğitilsin, .NET uygulaması onu ONNX ile çalıştırsın.

Aynı Veri, Aynı Feature'lar

Bookstore verisini pandas ile okuyup yedinci yazıdaki feature'ları üretiyoruz. Müşterinin önceki sipariş sayısı burada groupby ve cumcount ile hesaplanıyor; mantık C#'taki sayaçla aynı: siparişler zamana göre sıralı, her müşteri için yalnız geçmiş sayılıyor. Kodlar bir sanal ortamda (venv) çalıştırıldı:

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"]
# Yazı 07'deki feature'lar: PreviousOrders yalnız müşterinin önceki siparişlerini sayar
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"]
# Müşteriye göre gruplu 80/20 split (samplingKeyColumnName'in karşılığı)
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, ML.NET'teki samplingKeyColumnName parametresinin karşılığı: aynı müşterinin siparişleri aynı tarafta kalıyor. İki kütüphane grupları farklı yöntemle karıştırdığı için test setleri birebir aynı değil; bu yazıdaki AUC (ROC eğrisinin altındaki alan; rastgele bir iptalin rastgele bir normal siparişten daha yüksek olasılık alma ihtimali) değerlerini yedinci yazıdakilerle doğrudan karşılaştırma.

Pipeline ve Cross-Validation

scikit-learn'de feature dönüşümü ve model tek bir Pipeline nesnesinde duruyor. ColumnTransformer kategorik kolonlara one-hot encoding, sayısal kolonlara standardization uyguluyor; ML.NET'teki OneHotEncoding ve NormalizeMeanVariance adımlarının karşılığı:

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

Cross-validation AUC'si 0,675, test setinde 0,685. Dokuzuncu yazıda ML.NET'teki logistic regression'ın cross-validation sonucu 0,662 idi. Split'ler farklı olduğu için bu fark iki kütüphane arasındaki bir kalite farkı değil; iki tarafta da aynı sinyal, aynı algoritma ve benzer bir sonuç var. Kavramların eşleşmesi şöyle:

ML.NETscikit-learn
MLContext(seed: 1)random_state parametreleri
IDataViewpandas DataFrame, NumPy dizisi
Estimator, Fit sonrası ITransformerestimator, fit sonrası aynı nesne
Transform, PredictionEngine.Predicttransform, predict_proba
Append ile kurulan pipelinePipeline, ColumnTransformer
TrainTestSplit(samplingKeyColumnName)GroupShuffleSplit
CrossValidatecross_val_score ve GroupKFold
BinaryClassification.Evaluateroc_auc_score, average_precision_score
Model.Save ile .zipskl2onnx ile .onnx

En önemli fark felsefede. ML.NET'te estimator ile eğitilmiş transformer ayrı tiplerdir ve IDataView lazy çalışır. scikit-learn'de aynı nesne fit çağrısından sonra eğitilmiş hâle gelir ve veri bellekte NumPy dizileri olarak durur. Modeli kaydetmek için scikit-learn'ün kendi yolu pickle ya da joblib. Bu dosyalar yüklenirken Python kodu çalıştırabildiği için scikit-learn dokümantasyonu da güvenilmeyen kaynaktan pickle yüklenmemesi konusunda uyarıyor; ayrıca yalnız Python'dan okunabiliyorlar. ONNX bu iki soruna da cevap veriyor.

skl2onnx ile ONNX'e Çevirmek

ONNX bir model dosyasını, operatörlerden oluşan bir hesap grafiği olarak tarif ediyor. skl2onnx, scikit-learn pipeline'ını bu grafiğe çeviriyor; one-hot encoding ve standardization da grafiğin parçası oluyor. Böylece onuncu yazıdaki "preprocessing modelle birlikte saklanmalı" kuralı burada da korunuyor:

# Export: her kolon için bir ONNX girdisi, preprocessing grafiğin içinde, düz olasılık çıktısı
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)))
# Aynı satırlar Python'da ONNX Runtime ile: grafiğin içinde float32, scikit-learn'de float64
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}")
# .NET kontrolü için satırlar ve scikit-learn olasılıkları
out = X_test.copy()
out["SklearnProbability"] = p_sklearn
out.to_csv("models/cancel_test.csv", index=False, float_format="%.9g")

Üç ayar önemli. initial_types, grafiğin girdilerini tanımlıyor: her kolon ayrı bir girdi, kategorik kolonlar string, sayısal kolonlar float32 tensörü. target_opset, kullanılacak operatör setinin sürümü; modeli çalıştıracak ONNX Runtime sürümünün bu opset'i desteklemesi gerekiyor. Burada 18'i seçtik. zipmap: False ise olasılıkları basit bir float dizisi olarak çıkarıyor. Varsayılan ayarda skl2onnx olasılıkları sınıf adı ile değer çiftlerinden oluşan bir sözlük listesi olarak verir; bu yapıyı .NET tarafında okumak zahmetli. Çıktıdaki 1.779 baytlık dosya, one-hot kodlayıcı, ölçekleyici ve logistic regression'ın tamamı.

Python içinde aynı test satırlarını ONNX Runtime ile skorladık: scikit-learn ile en büyük fark 9,67 × 10⁻⁸. scikit-learn float64 ile, ONNX grafiği float32 ile hesaplıyor; fark bu hassasiyet farkından geliyor ve bir karar threshold'unu etkileyecek büyüklükte değil.

.NET'te ONNX Runtime ile Skorlamak

.NET tarafında yeni bir console projesi yalnız Microsoft.ML.OnnxRuntime paketini referans ediyor. Önce modelin girdi ve çıktılarını okuyoruz, sonra Python'un yazdığı test satırlarını tensörlere çevirip skorluyoruz:

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)}]");
}
// Her kolon için bir tensör, şekli [satır, 1]; adlar ve tipler Python'daki initial_types ile aynı
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

Açılıştaki ilk sorunun cevabı: 4.837 test satırında .NET'teki olasılık ile scikit-learn'ün olasılığı arasındaki en büyük fark 9,70 × 10⁻⁸ ve 0,06 threshold'unda hiçbir siparişin kararı değişmiyor. Python'daki ONNX Runtime ile .NET'teki ONNX Runtime aynı native kütüphaneyi kullandığı için fark yalnız float32 ile float64 arasındaki yuvarlamadan geliyor. İki taraf aynı modeli çalıştırıyor.

Girdi metadata'sındaki -1 boyutu, grafikte None olarak tanımladığımız satır sayısı: aynı model tek bir siparişi de 4.837 siparişi de skorlayabiliyor. API'nin yapacağı gibi tek satırlık çağrıları da ölçtük:

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

Bu süre test ortamına özgü ama büyüklük olarak onuncu yazıdaki ML.NET tahminiyle aynı seviyede: tahminin kendisi, HTTP isteğinin maliyeti yanında küçük. Bu yazıda InferenceSession'ı tek thread'den kullandık. ONNX Runtime ekibinin GitHub'daki cevaplarına göre aynı session birden fazla thread'den Run ile çağrılabiliyor ve session'ı bir kez oluşturup paylaşmak öneriliyor; session oluşturmak pahalı. Bunu bu yazıda yük altında ölçmedik; onuncu yazıdaki gibi bir yük testiyle kendi ortamında doğrula.

ML.NET kullanmaya devam etmek istersen iki seçenek daha var: Microsoft.ML.OnnxTransformer paketi ONNX modelini ML.NET pipeline'ının bir adımı olarak çalıştırabiliyor, Microsoft.ML.OnnxConverter (0.24.0) ise bazı ML.NET modellerini ONNX'e çevirebiliyor. İkisini de bu seride çalıştırmadık.

Karar: ML.NET mi, Python ve ONNX mi, LLM mi?

Seriyi bitirirken bir problem için hangi yolu seçeceğine dair bir özet tablo:

YolNe zaman uygunBedeli
ML.NETEkip .NET yazıyor, veri tablo şeklinde, model uygulamanın içinde çalışacakAlgoritma seçeneği daha dar; AutoML ve araçlar yavaş gelişiyor; bazı paketler yalnız x64'te
Python ve ONNXVeri bilimi ekibi Python kullanıyor ya da gereken algoritma yalnız Python'da varİki dil, iki CI hattı; dönüştürücünün desteklemediği adımlar; eğitim ve servis ekipleri arasında sözleşme gerekir
LLMGirdi serbest metin, etiketli veri az, kurallar dile bağlıToken başına maliyet, latency, deterministik olmayan çıktı; tablo verisinde klasik modelden zayıf
Bulut AutoML ya da MLOps platformuÇok sayıda model, eğitim için büyük hesaplama gücü, merkezi model registry ihtiyacıPlatforma bağımlılık ve maliyet; bu seride kullanılmadı

Tabloyu bir kural gibi değil, konuşmaya başlamak için kullan. Bookstore'un teslim süresi ve iptal modelleri ML.NET ile rahatça çözüldü; yorum konusu tahmini ise etiketli veri az olsaydı LLM'in de iyi çalışabileceği türden bir problem. Asıl belirleyici çoğu zaman teknoloji değil, modeli kimin eğiteceği ve kimin bakımını yapacağı.

Üretimde Dikkat

Python ile .NET arasındaki sözleşmeyi açıkça yaz: girdi adları, tipleri, kategorik değerlerin yazımı ve çıktı adları. Bu yazıdaki C# kodu kolon adlarını Python'daki listelerle aynı tutarak çalışıyor; bir taraf kolon adını değiştirirse hata ancak çalışma anında çıkar. Grafiğin girdilerini InputMetadata ile okuyup uygulama açılırken beklenen listeyle karşılaştırmak ucuz bir koruma.

Sürümleri sabitle. skl2onnx, onnx ve ONNX Runtime sürümleri requirements dosyasında ve csproj'da açıkça yazılı olsun. Yeni bir skl2onnx sürümü farklı bir grafik üretebilir, eski bir ONNX Runtime yeni bir opset'i çalıştıramayabilir.

Eşdeğerlik testini CI'ın parçası yap. Python'un yazdığı test satırları ve olasılıklar ile .NET'in ONNX sonuçlarını karşılaştıran bu yazıdaki kontrol, her yeni model sürümünde çalışmalı. Kabul edilebilir farkı (burada 10⁻⁶ altı) baştan belirle.

Onuncu yazıdaki model kartı ve drift izleme ONNX modelleri için de geçerli. Modelin formatı değişse de hangi veriyle eğitildiğini ve üretimde nasıl davrandığını bilmen gerekiyor.

Sık Düşülen Tuzaklar

İlk tuzak, opset ve sürüm uyumsuzluğu. Modeli dönüştürürken seçtiğin opset'i, çalıştıracağın ONNX Runtime sürümünün desteklediğinden emin ol; desteklemiyorsa model yüklenirken hata alırsın.

İkincisi, float32 ile float64'ü karıştırmak. Grafik float32 bekliyor; .NET tarafında double tensör verirsen ONNX Runtime dönüşüm yapmıyor:

// Grafik float32 bekler; double senin için dönüştürülmez
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

Üçüncüsü, dönüştürücünün tanımadığı bir adım kullanmak. Açılıştaki ikinci sorunun cevabı:

# skl2onnx'in tanımadığı özel bir adım
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 rastgele bir Python fonksiyonunu ONNX operatörlerine çeviremiyor. Çözüm ya dönüşümü ONNX'in bildiği adımlarla yeniden ifade etmek, ya o adım için özel bir converter yazmak ya da dönüşümü modelden önce iki tarafta da aynı şekilde yapmak; sonuncusu onuncu yazıdaki training-serving skew riskini geri getirir.

Dördüncüsü, zipmap'i açık bırakıp çıktıyı .NET'te okumaya çalışmak. Varsayılan sözlük listesi çıktısı yerine düz bir olasılık tensörü istiyorsan bunu dönüştürme sırasında söylemelisin.

Küçük Bir Deneme

★☆☆ target_opset değerini değiştirerek modeli yeniden üret ve .NET programını tekrar çalıştır. Sonuçlar ve dosya boyutu değişiyor mu?

★★☆ Logistic regression yerine HistGradientBoostingClassifier kullan, cross-validation AUC'sini karşılaştır ve modeli ONNX'e çevirmeyi dene. skl2onnx bu modeli destekliyor mu? .NET'teki fark yine 10⁻⁶ altında mı?

★★★ Altıncı yazıdaki teslim süresi regression problemini scikit-learn ile çöz, ONNX'e çevir ve onuncu yazıdaki API'ye ikinci bir endpoint olarak ekle. ML.NET modelinin ve ONNX modelinin aynı siparişlere verdiği tahminleri karşılaştıran bir yük testi yaz.

Seri burada bitiyor. Klasik machine learning'in problem tiplerini, veriyi ve feature'ları, train/test ayrımını ve leakage'ı, regression ve classification metric'lerini, label'sız verideki yöntemleri, model seçimini, üretime almayı ve Python ile köprüyü sentetik Bookstore verisi üzerinde, gerçek çıktılarla gördük. Serinin en önemli dersi belki de şu: model kurmak işin küçük bir kısmı, doğru metric'i seçmek, sızıntıyı önlemek ve üretimde ölçmeye devam etmek asıl iş. Metin ve doküman tarafındaki problemler için LLM Temelleri, .NET ile Yapay Zeka ve RAG (Retrieval-Augmented Generation; modelin cevap üretmeden önce ilgili dokümanları bulup kullandığı yöntem) serilerine devam edebilirsin.