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

Modeli Production'a Almak: ASP.NET Core'da PredictionEnginePool, Model Versiyonlama ve Drift

Mert Özen 15 Kas 2026 17 dk 7 görüntülenme
Modeli Production'a Almak: ASP.NET Core'da PredictionEnginePool, Model Versiyonlama ve Drift

Bir ML.NET modelini ASP.NET Core'da güvenle servis etmenin yolu PredictionEnginePool, modelle birlikte saklanan bir kart ve gerçek sonuçlarla izlenen drift'tir. Bookstore teslim süresi modelini minimal API'ye koyuyor, paylaşılan PredictionEngine'in yük altında yüzde 12 yanlış cevap verdiğini, modeli yeniden başlatmadan güncellemeyi, latency metric'lerini ve girdiler değişmeden bozulan bir modeli gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Microsoft.ML 6.0.0 (ML.NET 6.0) ve Microsoft.Extensions.ML 6.0.0 ile derlenip çalıştırılmıştır; metric'ler dotnet-counters 10.0 ile okunmuştur. 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

Dördüncü yazıda tek bir PredictionEngine'i birçok thread'den çağırınca yanlış sonuçlar ve exception'lar aldığımızı görmüştük. Bu yazıda sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için teslim süresi modelini bir ASP.NET Core minimal API'ye koyuyoruz. İlk soru: aynı hatayı bir API'de yaparsan, yani bütün request'ler tek bir engine'i paylaşırsa, 16 paralel istemciden gelen 2.000 request'in kaçı yanlış ya da hatalı döner?

İkinci soru drift ile ilgili. Model Şubat 2026'ya kadarki verilerle eğitildi. Haziran 2026'da kargo firmalarından biri yavaşlıyor, ama siparişlerin kendisinde (mesafe, depo, sepet) hiçbir şey değişmiyor. Girdi dağılımını izleyen bir metric bu değişikliği yakalar mı?

Modeli ve Kartını Birlikte Üretmek

Üretime çıkan şey yalnız bir .zip dosyası olmamalı. Altı ay sonra "bu model hangi veriyle eğitildi, ne kadar iyiydi, hangi girdileri hiç görmedi?" sorularına cevap verebilmek için her modelin yanına küçük bir kart (model card) yazıyoruz. Önce paylaşılan tipleri ayrı bir class library'ye taşıdık: DeliveryInput, DeliveryPrediction ve beşinci yazıdaki OrderTimeMappingFactory artık Bookstore.ML.Model projesinde. Beşinci yazıda gördüğümüz gibi, CustomMapping içeren bir model yüklenirken bu sınıfın assembly'sinin uygulamada bulunması gerekiyor; API ile eğitim kodu aynı assembly'yi referans etmeli. Kart da bu projede:

using System.Text.Json;
namespace Bookstore.ML.Model;
/// <summary>
/// Her model zip'inin yanına kaydedilir: hangi veriyle eğitildiği, ne kadar iyi olduğu ve gördüğü girdi aralığı.
/// </summary>
public sealed record ModelCard(
    string Version,
    string Trainer,
    DateOnly TrainedFrom,
    DateOnly TrainedUntil,
    int TrainingRows,
    string DataSha256,
    double HoldoutMae,
    double HoldoutRmse,
    float MaxDistanceKm,
    float MaxItemCount,
    string[] Warehouses,
    string[] Carriers)
{
    private static readonly JsonSerializerOptions Json = new() { WriteIndented = true };
    public static ModelCard Load(string path) =>
        JsonSerializer.Deserialize<ModelCard>(File.ReadAllText(path)) ?? throw new InvalidDataException(path);
    public void Save(string path) => File.WriteAllText(path, JsonSerializer.Serialize(this, Json));
    /// <summary>Girdi eğitim verisine benziyorsa null, benzemiyorsa ilk neden.</summary>
    public string? OutOfRange(DeliveryInput input) =>
        !Warehouses.Contains(input.Warehouse) ? $"unknown warehouse {input.Warehouse}"
        : !Carriers.Contains(input.Carrier) ? $"unknown carrier {input.Carrier}"
        : input.DistanceKm > MaxDistanceKm ? $"distance {input.DistanceKm} km > {MaxDistanceKm}"
        : input.ItemCount > MaxItemCount ? $"{input.ItemCount} items > {MaxItemCount}"
        : null;
}

Eğitim komutu verilen tarihe kadarki siparişlerle SDCA (Stochastic Dual Coordinate Ascent; her adımda tek bir örnek üzerinden ilerleyen bir optimizasyon algoritması) modelini beşinci yazıdaki tam pipeline ile eğitiyor, sonraki 28 günde ölçüyor ve modeli kartıyla birlikte kaydediyor:

/// <summary>[DataStart, until) aralığıyla eğit, sonraki 28 günde ölç, modeli ve kartı models/ altına kaydet.</summary>
public static void Train(DateOnly until, string version)
{
    MLContext ml = new(seed: 1);
    List<DeliveryInput> all = Delivered();
    List<DeliveryInput> train = [.. all.Where(o => o.OrderedAtUtc < Utc(until))];
    List<DeliveryInput> holdout = [.. all.Where(o => o.OrderedAtUtc >= Utc(until) && o.OrderedAtUtc < Utc(until.AddDays(28)))];
    IDataView trainData = ml.Data.LoadFromEnumerable(train);
    ITransformer model = FeatureLab.Full(ml.Regression.Trainers.Sdca(new SdcaRegressionTrainer.Options
    {
        LabelColumnName = "DeliveryDays", FeatureColumnName = "Features", NumberOfThreads = 1,
    })).Fit(trainData);
    RegressionMetrics m = ml.Regression.Evaluate(model.Transform(ml.Data.LoadFromEnumerable(holdout)), labelColumnName: "DeliveryDays");
    ModelCard card = new(version, "SDCA", DataStart, until, train.Count,
        Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes("data/orders_clean.csv")))[..16],
        Math.Round(m.MeanAbsoluteError, 3), Math.Round(m.RootMeanSquaredError, 3),
        train.Max(o => o.DistanceKm), train.Max(o => o.ItemCount),
        [.. train.Select(o => o.Warehouse).Distinct().Order()], [.. train.Select(o => o.Carrier).Distinct().Order()]);
    Directory.CreateDirectory("models");
    ml.Model.Save(model, trainData.Schema, $"models/{version}.zip");
    card.Save($"models/{version}.json");
    Console.WriteLine($"{version}: {train.Count:N0} orders before {until:yyyy-MM-dd}, holdout {holdout.Count:N0} orders, " +
                      $"MAE {card.HoldoutMae:F3}, RMSE {card.HoldoutRmse:F3}, {new FileInfo($"models/{version}.zip").Length / 1024} KB");
}
dotnet run --project src/Bookstore.ML.Lab -- train-delivery 2026-02-01 delivery-v1
dotnet run --project src/Bookstore.ML.Lab -- train-delivery 2026-09-01 delivery-v2
cat models/delivery-v1.json
delivery-v1: 13,554 orders before 2026-02-01, holdout 949 orders, MAE 0.486, RMSE 1.812, 33 KB
delivery-v2: 20,482 orders before 2026-09-01, holdout 1,490 orders, MAE 0.539, RMSE 1.398, 33 KB
{
  "Version": "delivery-v1",
  "Trainer": "SDCA",
  "TrainedFrom": "2024-11-01",
  "TrainedUntil": "2026-02-01",
  "TrainingRows": 13554,
  "DataSha256": "a6cde25b7f4b0278",
  "HoldoutMae": 0.486,
  "HoldoutRmse": 1.812,
  "MaxDistanceKm": 1600,
  "MaxItemCount": 120,
  "Warehouses": [
    "ANK-1",
    "IST-1"
  ],
  "Carriers": [
    "CarrierA",
    "CarrierB",
    "CarrierC"
  ]
}

Kart, model versiyonlamanın en basit hâli: modelin adı, eğitim verisinin tarih aralığı ve satır sayısı, veri dosyasının hash'inin ilk 16 karakteri, holdout metric'leri ve eğitimde görülen girdi aralığı. İlk modelin eğitim verisinde yalnız iki depo var (ANK-1 ve IST-1), çünkü İzmir deposu Mart 2026'da açılıyor. Bu bilgi birazdan işe yarayacak. Azure Machine Learning ya da MLflow gibi MLOps platformları aynı şeyi bir model registry ile yapar: model dosyası, metric'ler, eğitim verisinin referansı ve sürüm geçmişi tek yerde durur. Küçük bir ekip için dosya sistemi ve bir JSON dosyası da aynı işi görür; önemli olan modelin kartsız deploy edilmemesi.

Minimal API ve PredictionEnginePool

API projesi yalnız Microsoft.Extensions.ML paketini ve model kütüphanesini referans ediyor. AddPredictionEnginePool, PredictionEngine nesnelerini bir object pool'da tutuyor: her request pool'dan bir engine alıyor, tahmini yapıyor ve engine'i geri bırakıyor. Hiçbir engine aynı anda iki thread tarafından kullanılmıyor:

using System.Diagnostics;
using Bookstore.ML.Api;
using Bookstore.ML.Model;
using Microsoft.Extensions.ML;
using Microsoft.ML;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
string modelPath = builder.Configuration["Model:Path"] ?? throw new InvalidOperationException("Model:Path is not set");
// Her model için bir pool; zip değişince file watcher modeli yeniden yükler
builder.Services.AddPredictionEnginePool<DeliveryInput, DeliveryPrediction>()
    .FromFile(modelName: "delivery", filePath: modelPath, watchForChanges: true);
builder.Services.AddSingleton(new ModelCardCache(Path.ChangeExtension(modelPath, ".json")));
builder.Services.AddSingleton<PredictionMetrics>();
WebApplication app = builder.Build();
app.MapPost("/predict/delivery-time", (DeliveryRequest request, PredictionEnginePool<DeliveryInput, DeliveryPrediction> pool,
    ModelCardCache cards, PredictionMetrics metrics) =>
{
    long start = Stopwatch.GetTimestamp();
    DeliveryInput input = request.ToInput();
    float days = pool.Predict(modelName: "delivery", example: input).PredictedDays;
    ModelCard card = cards.Current;
    string? warning = card.OutOfRange(input);
    metrics.Record(Stopwatch.GetElapsedTime(start), card.Version, warning is not null);
    return new DeliveryResponse(MathF.Round(days, 2), card.Version, warning);
});
app.MapGet("/model", (ModelCardCache cards) => cards.Current);
if (app.Environment.IsDevelopment())
{
    // Bunu yapma: bütün request'lerin paylaştığı tek bir PredictionEngine (yazı 04). Yalnız zararı ölçmek için duruyor.
    MLContext ml = new();
    PredictionEngine<DeliveryInput, DeliveryPrediction> shared =
        ml.Model.CreatePredictionEngine<DeliveryInput, DeliveryPrediction>(ml.Model.Load(modelPath, out _));
    app.MapPost("/demo/shared-engine", (DeliveryRequest request, ModelCardCache cards) =>
        new DeliveryResponse(MathF.Round(shared.Predict(request.ToInput()).PredictedDays, 2), cards.Current.Version, null));
}
app.Run();

Request tipi kasıtlı olarak yalnız ham sipariş alanlarını içeriyor. Sipariş saatinden "işleme başlamaya kalan süre"yi hesaplamak, notu metin feature'larına çevirmek, eksik ağırlığı ortalamayla doldurmak; bunların hepsi kaydedilmiş pipeline'ın içinde. API tarafında yalnız eksik ağırlığı NaN olarak işaretliyoruz:

/// <summary>HTTP request'i: yalnız ham sipariş alanları. Bütün feature'lar kaydedilmiş model pipeline'ında hesaplanır.</summary>
public sealed record DeliveryRequest(
    DateTime OrderedAtUtc, string City, string District, string Warehouse, float DistanceKm, string Carrier,
    string ShippingMethod, int ItemCount, float? TotalWeightKg, float OrderTotal, bool IsCampaign,
    string PaymentMethod, bool HasBackorderItem, string CustomerNote)
{
    public DeliveryInput ToInput() => new()
    {
        OrderedAtUtc = OrderedAtUtc, City = City, District = District, Warehouse = Warehouse, DistanceKm = DistanceKm,
        Carrier = Carrier, ShippingMethod = ShippingMethod, ItemCount = ItemCount,
        TotalWeightKg = TotalWeightKg ?? float.NaN, OrderTotal = OrderTotal, IsCampaign = IsCampaign,
        PaymentMethod = PaymentMethod, HasBackorderItem = HasBackorderItem, CustomerNote = CustomerNote,
    };

Beşinci yazıdaki training-serving skew tuzağının çözümü bu: preprocessing'i API'de elle yeniden yazarsan, eğitimdeki dönüşümle bire bir aynı olduğunu garanti edemezsin. Pipeline'ı modelle birlikte kaydedersen API yalnız ham veriyi geçirir. İlk modeli yayına alıp API'yi başlatıyoruz ve İzmir deposundan bir sipariş soruyoruz:

dotnet run --project src/Bookstore.ML.Lab -- deploy delivery-v1 --atomic
ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/Bookstore.ML.Api --urls http://127.0.0.1:52710 > api.log 2>&1 &
curl -s -i -X POST http://127.0.0.1:52710/predict/delivery-time -H "Content-Type: application/json" -d @req.json
{
  "orderedAtUtc": "2026-10-15T11:10:00Z",
  "city": "Izmir",
  "district": "IZM-07",
  "warehouse": "IZM-1",
  "distanceKm": 20,
  "carrier": "CarrierB",
  "shippingMethod": "Standard",
  "itemCount": 2,
  "totalWeightKg": 0.9,
  "orderTotal": 389.80,
  "isCampaign": false,
  "paymentMethod": "CreditCard",
  "hasBackorderItem": false,
  "customerNote": ""
}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Server: Kestrel
Transfer-Encoding: chunked
{"predictedDays":1.11,"modelVersion":"delivery-v1","warning":"unknown warehouse IZM-1"}

Model bir tahmin dönüyor (1,11 gün), ama IZM-1 deposunu eğitimde hiç görmediği için yanıtta bir uyarı da var. Model bu depo için one-hot kolonlarının hepsini sıfır görüyor ve tahmini mesafe gibi diğer feature'lardan yapıyor. Yanlış olmak zorunda değil, ama güvenilirliği bilinmiyor. Bu uyarıyı istemciye dönmek ve metric olarak saymak, altıncı yazıdaki extrapolation sorununun üretimdeki karşılığı.

Thread Safety: Pool ile Paylaşılan Engine

Yük testi, Eylül ve Ekim 2026 siparişlerini paralel olarak API'ye gönderiyor ve her cevabı, aynı modelle tek thread'de yapılmış yerel bir tahminle karşılaştırıyor:

await Parallel.ForEachAsync(Enumerable.Range(0, requests), new ParallelOptions { MaxDegreeOfParallelism = parallel },
    async (n, ct) =>
    {
        int i = n % orders.Length;
        long start = Stopwatch.GetTimestamp();
        using HttpResponseMessage response = await http.PostAsJsonAsync(path, DeliveryRequest.From(orders[i]), ct);
        latencies.Add(Stopwatch.GetElapsedTime(start).TotalMilliseconds);
        if (!response.IsSuccessStatusCode)
        {
            Interlocked.Increment(ref failed);
            return;
        }
        DeliveryResponse body = (await response.Content.ReadFromJsonAsync<DeliveryResponse>(ct))!;
        versions.AddOrUpdate(body.ModelVersion, 1, (_, c) => c + 1);
        if (body.PredictedDays != expectedByVersion[body.ModelVersion][i]) Interlocked.Increment(ref wrong);
    });
dotnet run --project src/Bookstore.ML.Lab -- load-test http://127.0.0.1:52710 /predict/delivery-time delivery-v1 2000 16
dotnet run --project src/Bookstore.ML.Lab -- load-test http://127.0.0.1:52710 /predict/delivery-time delivery-v1 2000 16
dotnet run --project src/Bookstore.ML.Lab -- load-test http://127.0.0.1:52710 /demo/shared-engine delivery-v1 2000 16
/predict/delivery-time: 2,000 requests, 16 in parallel -> 0 wrong, 0 failed; latency p50 0.8 ms, p95 1.9 ms, p99 65.8 ms; versions: delivery-v1 x2000
/predict/delivery-time: 2,000 requests, 16 in parallel -> 0 wrong, 0 failed; latency p50 0.5 ms, p95 1.6 ms, p99 169.0 ms; versions: delivery-v1 x2000
/demo/shared-engine: 2,000 requests, 16 in parallel -> 241 wrong, 40 failed; latency p50 0.4 ms, p95 3.4 ms, p99 651.5 ms; versions: delivery-v1 x1960
grep -A2 "fail:" api.log | grep -o "System\.[A-Za-z.]*Exception" | sort | uniq -c
      4 System.ArgumentException
      1 System.ArgumentOutOfRangeException
     35 System.IndexOutOfRangeException

Açılıştaki ilk sorunun cevabı: tek bir engine'i paylaşan endpoint 2.000 request'in 241'ine yanlış sayı, 40'ına HTTP 500 dönüyor. Hataların çoğu IndexOutOfRangeException: iki thread aynı engine'in iç buffer'larına aynı anda yazıyor. Daha tehlikeli olan yanlış cevaplar, çünkü onlar hiçbir log'da görünmez; istemci geçerli görünen ama başka bir siparişin feature'larıyla karışmış bir sayı alır. Pool kullanan endpoint iki ölçümde de 0 yanlış, 0 hata veriyor. Microsoft Learn'deki "Deploy a model in an ASP.NET Core Web API" sayfası da PredictionEngine'in thread-safe olmadığını ve pool'un bu yüzden önerildiğini yazıyor. Latency değerleri bu yazının test ortamına özgü; p99'daki 65–169 ms'lik değerler birkaç yavaş request'ten geliyor ve ölçümler arasında oynuyor. Kendi donanımında ölç.

Modeli Yeniden Başlatmadan Güncellemek

watchForChanges: true, pool'un model dosyasını bir FileSystemWatcher ile izlemesini sağlıyor; dosya değişince model yeniden yükleniyor. Yeni sürümü yayına almak için dosyayı değiştirmek yetiyor:

/// <summary>Bir sürümü API'nin okuduğu yere koy: models/current/delivery.zip ve .json.</summary>
public static void Deploy(string version, bool atomic)
{
    Directory.CreateDirectory("models/current");
    foreach (string ext in new[] { "zip", "json" })
    {
        string target = $"models/current/delivery.{ext}";
        if (atomic)
        {
            // Hedefin yanına yaz, sonra yeniden adlandır: okuyan taraf yarım yazılmış dosya görmez
            File.Copy($"models/{version}.{ext}", target + ".tmp", overwrite: true);
            File.Move(target + ".tmp", target, overwrite: true);
        }
        else
        {
            File.Copy($"models/{version}.{ext}", target, overwrite: true);
        }
    }
    Console.WriteLine($"deployed {version} ({(atomic ? "copy + rename" : "copy over")})");
}

Bunu yük altında denedik: 1,5 milyon request'lik bir test sürerken ikinci modeli (delivery-v2) dosyanın üzerine kopyaladık:

dotnet run --project src/Bookstore.ML.Lab -- load-test http://127.0.0.1:52710 /predict/delivery-time delivery-v1,delivery-v2 1500000 8 &
sleep 3
dotnet run --project src/Bookstore.ML.Lab -- deploy delivery-v2
wait
dotnet run --project src/Bookstore.ML.Lab -- load-test http://127.0.0.1:52710 /predict/delivery-time delivery-v1,delivery-v2 2000 8
deployed delivery-v2 (copy over)
/predict/delivery-time: 1,500,000 requests, 8 in parallel -> 949 wrong, 0 failed; latency p50 0.1 ms, p95 0.3 ms, p99 0.7 ms; versions: delivery-v1 x31812, delivery-v2 x1468188
info: Microsoft.Extensions.ML.FileModelLoader[101]
      Reloading file '../../models/current/delivery.zip' completed after 38.0647ms
info: Microsoft.Extensions.ML.FileModelLoader[101]
      Reloading file '../../models/current/delivery.zip' completed after 15.1507ms
info: Microsoft.Extensions.ML.FileModelLoader[101]
      Reloading file '../../models/current/delivery.zip' completed after 8.051ms
info: Microsoft.Extensions.ML.FileModelLoader[101]
      Reloading file '../../models/current/delivery.zip' completed after 3.8015ms
info: Microsoft.Extensions.ML.FileModelLoader[101]
      Reloading file '../../models/current/delivery.zip' completed after 13.6252ms
/predict/delivery-time: 2,000 requests, 8 in parallel -> 0 wrong, 0 failed; latency p50 0.3 ms, p95 0.6 ms, p99 6.6 ms; versions: delivery-v2 x2000

Üç sonuç var. Birincisi iyi haber: hiçbir request başarısız olmadı ve kopyalamadan sonraki testte bütün cevaplar yeni modelden geldi. İkincisi, tek bir kopyalama beş ayrı yeniden yükleme tetikledi. Paketin kaynak kodunda watcher yalnız Changed event'ine bağlı ve bir dosya yazılırken birden fazla Changed event'i gelebiliyor; kodda bununla ilgili bir TODO (sonra yapılacak iş) notu da var. Modelimiz 33 KB olduğu için bu birkaç milisaniye sürdü; yüzlerce megabaytlık bir modelde yarım yazılmış bir dosyayı okuma riski artar. Üçüncüsü, 949 cevapta yanıttaki sürüm adı ile cevabı üreten model birbirini tutmadı. Kart ve model iki ayrı dosya, iki ayrı mekanizmayla yeniden okunuyor; geçiş anında kısa bir süre yanıttaki sürüm adı ile tahmini üreten model farklı olabiliyor. Sürüm bilgisini loglarda ve A/B analizinde kullanacaksan, onu modelle aynı anda yüklenen tek bir nesneden okumalısın; örneğin her sürümü ayrı bir modelName ile kaydedip hangi adın aktif olduğunu tek bir yerde tutarak.

Yarım yazılmış dosya riskini azaltmak için --atomic seçeneği dosyayı önce yanına kopyalayıp sonra yeniden adlandırıyor. Bu testi Linux x64 bir container'da yaptım ve yeniden adlandırma da yeniden yüklemeyi tetikledi:

dotnet run --project src/Bookstore.ML.Lab -- deploy delivery-v1 --atomic
dotnet run --project src/Bookstore.ML.Lab -- load-test http://127.0.0.1:52710 /predict/delivery-time delivery-v1,delivery-v2 2000 8
deployed delivery-v1 (copy + rename)
info: Microsoft.Extensions.ML.FileModelLoader[101]
      Reloading file '../../models/current/delivery.zip' completed after 43.7068ms
info: Microsoft.Extensions.ML.FileModelLoader[101]
      Reloading file '../../models/current/delivery.zip' completed after 5.2715ms
/predict/delivery-time: 2,000 requests, 8 in parallel -> 0 wrong, 0 failed; latency p50 0.4 ms, p95 1.1 ms, p99 5.8 ms; versions: delivery-v1 x2000

FileSystemWatcher'ın hangi dosya işleminde hangi event'i ürettiği işletim sistemine göre değişiyor; bu davranışı Windows'ta denemedim. Deploy yöntemini hedef ortamında, yük altında bir kez test et ve log'da "Reloading file" satırını gör.

Rollback da aynı mekanizma: önceki sürümün dosyasını tekrar yerine koymak. Kartlar ve eski zip dosyaları sakladığın sürece geri dönüş bir komut. A/B testi ve shadow deployment da bunun üzerine kurulur: A/B testinde trafiğin bir kısmı yeni modele gider ve iki modelin sonuçları karşılaştırılır; shadow deployment'ta yeni model her request'i görür ama cevabı istemciye dönmez, yalnız loglanır. İkisini de bu yazıda çalıştırmadık; ikisi için de pool'a farklı modelName'lerle iki model kaydetmek yeterli.

Latency'yi Metric Olarak Yayınlamak

Tahmin süresini ve eğitim aralığı dışındaki istekleri System.Diagnostics.Metrics ile ölçüyoruz. Bu API, OpenTelemetry'nin .NET'te metric toplamak için kullandığı standart yol; bir OpenTelemetry exporter eklediğinde aynı instrument'lar Prometheus'a ya da başka bir backend'e gider:

/// <summary>Tahmin süresi ve aralık dışı girdiler, System.Diagnostics.Metrics instrument'ları olarak (OpenTelemetry dışarı aktarabilir).</summary>
public sealed class PredictionMetrics
{
    public const string MeterName = "Bookstore.ML";
    private readonly Histogram<double> _duration;
    private readonly Counter<long> _outOfRange;
    public PredictionMetrics(IMeterFactory meterFactory)
    {
        Meter meter = meterFactory.Create(MeterName);
        _duration = meter.CreateHistogram<double>("bookstore.ml.prediction.duration", unit: "ms",
            description: "Time spent in PredictionEnginePool.Predict and the input checks");
        _outOfRange = meter.CreateCounter<long>("bookstore.ml.prediction.out_of_range",
            description: "Requests whose input is outside the range the model was trained on");
    }
    public void Record(TimeSpan elapsed, string modelVersion, bool outOfRange)
    {
        KeyValuePair<string, object?> version = new("model.version", modelVersion);
        _duration.Record(elapsed.TotalMilliseconds, version);
        if (outOfRange) _outOfRange.Add(1, version);
    }
}

Exporter kurmadan doğrulamak için dotnet-counters aracını global kurulum yapmadan bir klasöre kurup çalışan API'ye bağladık ve yük testi sürerken altı saniye kayıt aldık:

dotnet tool install dotnet-counters --tool-path tools
tools/dotnet-counters collect --process-id <api-process-id> --counters Bookstore.ML --format csv -o counters.csv --duration 00:00:00:06 &
dotnet run --project src/Bookstore.ML.Lab -- load-test http://127.0.0.1:52710 /predict/delivery-time delivery-v1 20000 8
head -5 counters.csv
Timestamp,Provider,Counter Name,Counter Type,Mean/Increment
11/15/2026 02:36:58,Bookstore.ML,bookstore.ml.prediction.out_of_range (Count / 1 sec)[model.version=delivery-v1],Rate,1226
11/15/2026 02:36:58,Bookstore.ML,bookstore.ml.prediction.duration (ms)[model.version=delivery-v1;Percentile=50],Metric,0.0102996826171875
11/15/2026 02:36:58,Bookstore.ML,bookstore.ml.prediction.duration (ms)[model.version=delivery-v1;Percentile=95],Metric,0.041259765625
11/15/2026 02:36:58,Bookstore.ML,bookstore.ml.prediction.duration (ms)[model.version=delivery-v1;Percentile=99],Metric,0.0770263671875

Sunucu tarafında tahminin kendisi medyanda 0,01 ms sürüyor; istemcinin gördüğü 0,2–0,8 ms'nin neredeyse tamamı HTTP ve JSON maliyeti. Saniyede 1.226 istek eğitim aralığının dışında: yük testindeki siparişlerin yaklaşık yüzde 15'i İzmir deposundan. Bu sayaç için bir alarm kurmak, yeni depo açıldığında modelin yeniden eğitilmesi gerektiğini kimse söylemeden fark etmeni sağlar.

Drift: Model Ne Zaman Eskir?

10-model-lifecycle-tr.png

Gerçek teslim süresi siparişten günler sonra belli oluyor. O geldiğinde modeli geriye dönük ölçebiliriz. drift komutu her ay için hatayı, ortalama sapmayı (bias), eğitim aralığı dışındaki siparişleri ve mesafe dağılımının eğitim verisine göre ne kadar kaydığını hesaplıyor. Son ölçü PSI (Population Stability Index; bir değişkenin iki dönemdeki dağılımlarının ne kadar farklı olduğunu tek bir sayıyla ifade eden ölçü):

/// <summary>Eğitimden sonra ay ay: hata, bias, görülmemiş girdiler ve mesafe dağılımının ne kadar kaydığı (PSI).</summary>
public static void Drift(string version)
{
    MLContext ml = new(seed: 1);
    ModelCard card = ModelCard.Load($"models/{version}.json");
    ITransformer model = ml.Model.Load($"models/{version}.zip", out _);
    List<DeliveryInput> all = Delivered();
    float[] edges = [50, 200, 400, 600, 800, 1000, 1300];
    double[] reference = Shares(all.Where(o => o.OrderedAtUtc < Utc(card.TrainedUntil)), edges);
    Console.WriteLine($"{version}, trained before {card.TrainedUntil:yyyy-MM-dd}, holdout MAE {card.HoldoutMae:F3}");
    Console.WriteLine($"{"month",-9}{"orders",7}{"MAE",7}{"bias",7}{"CarrierB MAE",14}{"out of range",14}{"PSI distance",14}");
    for (DateOnly month = new(card.TrainedUntil.Year, card.TrainedUntil.Month, 1); month < new DateOnly(2026, 11, 1); month = month.AddMonths(1))
    {
        List<DeliveryInput> rows = [.. all.Where(o => o.OrderedAtUtc >= Utc(month) && o.OrderedAtUtc < Utc(month.AddMonths(1)))];
        float[] predicted = [.. model.Transform(ml.Data.LoadFromEnumerable(rows)).GetColumn<float>("Score")];
        double mae = rows.Select((o, i) => Math.Abs(predicted[i] - o.DeliveryDays)).Average();
        double bias = rows.Select((o, i) => predicted[i] - o.DeliveryDays).Average();
        double maeB = rows.Select((o, i) => (o, i)).Where(x => x.o.Carrier == "CarrierB")
            .Average(x => Math.Abs(predicted[x.i] - x.o.DeliveryDays));
        int unseen = rows.Count(o => card.OutOfRange(o) is not null);
        double[] now = Shares(rows, edges);
        double psi = now.Zip(reference).Sum(p => (p.First - p.Second) * Math.Log(p.First / p.Second));
        Console.WriteLine($"{month:yyyy-MM}  {rows.Count,7:N0}{mae,7:F3}{bias,7:F2}{maeB,14:F3}{unseen,14:N0}{psi,14:F3}");
    }
}
dotnet run --project src/Bookstore.ML.Lab -- drift delivery-v1
dotnet run --project src/Bookstore.ML.Lab -- drift delivery-v2
delivery-v1, trained before 2026-02-01, holdout MAE 0.486
month     orders    MAE   bias  CarrierB MAE  out of range  PSI distance
2026-02      949  0.486  -0.05         0.423             0         0.007
2026-03      916  0.401   0.08         0.390           153         0.026
2026-04      969  0.476   0.04         0.415           155         0.039
2026-05    1,004  0.488   0.03         0.424           156         0.046
2026-06      982  0.638  -0.30         0.803           141         0.052
2026-07    1,061  0.618  -0.26         0.897           170         0.067
2026-08    1,047  0.584  -0.24         0.896           184         0.036
2026-09    1,554  0.639  -0.27         0.866           218         0.049
2026-10    1,069  0.623  -0.32         0.811           187         0.069
delivery-v2, trained before 2026-09-01, holdout MAE 0.539
month     orders    MAE   bias  CarrierB MAE  out of range  PSI distance
2026-09    1,554  0.534  -0.14         0.598             0         0.028
2026-10    1,069  0.487  -0.20         0.489             0         0.044

Tabloda iki ayrı drift var. Mart 2026'dan itibaren her ay 150–220 sipariş eğitim aralığının dışında: İzmir deposu açılmış. Bu bir data drift (girdi dağılımının değişmesi) ve kart sayesinde doğrudan sayılabiliyor. İlginç olan, bu siparişler yüzünden MAE (ortalama mutlak hata) artmıyor; Mart'ta düşüyor bile, çünkü İzmir'deki müşteriler artık 20 km uzaklıktan besleniyor ve mesafe feature'ı bunu yakalıyor.

Asıl sorun Haziran'da başlıyor. MAE 0,49'dan 0,64'e, CarrierB'nin MAE'si 0,42'den 0,80'e çıkıyor ve bias -0,30'a iniyor: model teslim süresini sistematik olarak kısa tahmin ediyor. Mesafe dağılımının PSI'ı ise 0,03 ile 0,07 arasında kalıyor. PSI için yaygın kullanılan eşikler 0,1 altını "kararlı", 0,25 üstünü "belirgin değişiklik" sayar; bu ölçüye bakan bir alarm hiçbir şey söylemezdi. Açılıştaki ikinci sorunun cevabı bu: girdiler değişmedi, girdiyle sonuç arasındaki ilişki değişti. Buna concept drift denir ve yalnız gerçek sonuçlar geldiğinde, hata metric'leriyle görülebilir.

Eylül'e kadarki veriyle eğitilen ikinci model daha iyi (Ekim'de MAE 0,487) ama bias'ı hâlâ -0,20. Eğitim verisinin yalnız üç ayı yavaşlamadan sonra; model eski ve yeni dönemi ortalıyor. Retraining tetikleyicisi olarak üç sinyal kullanılabilir: takvim (her ay), girdi drift'i (bu tablodaki eğitim aralığı dışı sayısı ya da PSI) ve performans drift'i (gerçek sonuçlarla hesaplanan MAE ve bias). Bu örnekte işe yarayan üçüncüsü oldu. Yeniden eğitirken yakın dönemi daha ağır tartmak ya da kargo firmasının sözleşme tarihi gibi bir feature eklemek de denenebilir.

Güvenlik: Model Dosyası Güvenilir Kaynaktan Gelmeli

Microsoft Learn'deki aynı sayfa, pool'a yalnız güvenilir kaynaklardan model eklenmesi gerektiğini, güvenilmeyen bir kaynaktan model yüklemenin bir güvenlik riski olduğunu belirtiyor. Bir ML.NET zip dosyası yalnız weight'lerden oluşmuyor; içinde hangi transformer'ların hangi assembly'lerden yükleneceği de yazıyor. Bu yüzden model dosyasını kod gibi ele al: CI/CD pipeline'ından gelsin, hash'i kartta dursun, model klasörüne yazma yetkisi yalnız deploy hesabında olsun. API'nin model dosyasını bir request'ten ya da kullanıcının yüklediği bir dosyadan alması hiçbir zaman kabul edilmemeli. FromUri ile uzak bir adresten model çekiyorsan, o adresin sahibi uygulamanın davranışını değiştirebilir; adresi HTTPS ve erişim kontrolüyle koru.

Üretimde Dikkat

Modeli request başına yükleme. 33 KB'lık bu modelin yeniden yüklenmesi bile log'da 4 ile 44 milisaniye arasında sürdü; pool modeli bir kez yükler ve engine'leri yeniden kullanır.

Eğitim kodu ile API aynı model kütüphanesini kullanmalı. CustomMapping içeren bir model, mapping sınıfının assembly'si yanında olmadan yüklenmez; assembly adı değişirse eski modeller de yüklenemez. Bu yazıda tipleri taşıdığımız için bütün modelleri yeniden eğittik.

Gerçek sonuçları tahminle eşleştirebilmek için her tahmini sipariş ID'si ve model sürümüyle logla. Drift tablosu bu eşleştirme olmadan hesaplanamaz.

Container image'ında model dosyasını image'ın içine mi koyacağına, yoksa bir volume'dan mı okuyacağına baştan karar ver. Image içinde olursa her model güncellemesi yeni bir deploy demektir; volume'dan okunursa yukarıdaki yeniden yükleme mekanizması devreye girer ama dosyayı kimin değiştirebildiği güvenlik sorusu olur.

Sık Düşülen Tuzaklar

İlk tuzak, PredictionEngine'i singleton olarak kaydetmek. Kod derlenir, düşük trafikte testler geçer, yük altında her sekiz cevaptan biri yanlış olur ve bunların çoğu log'a düşmez.

İkincisi, eğitimdeki preprocessing'i API'de elle yeniden yazmak. Saat dilimi, eksik değer ya da metin temizleme gibi küçük bir fark, beşinci yazıdaki gibi sessiz bir training-serving skew yaratır. Pipeline'ı modelle birlikte kaydet.

Üçüncüsü, yalnız girdi dağılımını izlemek. Bu yazıdaki en büyük bozulma, PSI'ın kararlı dediği aylarda oldu. Gerçek sonuçları toplayıp hata metric'lerini de izle.

Dördüncüsü, yanıttaki sürüm bilgisine körü körüne güvenmek. Kart ve model ayrı yüklendiğinde geçiş anında birbirini tutmayabiliyor; 949 yanıtta bunu gördük.

Beşincisi, model dosyasını kullanıcı girdisi gibi kabul etmek. Model dosyası koddur; yalnız güvenilir bir pipeline'dan gelmeli.

Küçük Bir Deneme

★☆☆ Yük testini 16 yerine 1 paralel istemciyle /demo/shared-engine endpoint'ine karşı çalıştır. Kaç yanlış cevap alıyorsun? Sonucu thread safety açısından açıkla.

★★☆ Drift method'una kargo firması başına bias ekle ve Haziran 2026'dan sonraki değişimin yalnız CarrierB'de olup olmadığını göster.

★★★ Sürüm uyumsuzluğunu düzelt: her sürümü pool'a ayrı bir modelName ile kaydet, aktif sürümün adını tek bir yerde tut ve yanıttaki sürümü tahmini yapan modelin adından al. Ardından swap testini tekrarla ve yanlış sayısının 0'a indiğini göster.

Serinin son yazısında aynı problemi Python ekosisteminde, scikit-learn ile çözeceğiz: Pipeline, train_test_split ve cross-validation'ın ML.NET'teki karşılıklarını eşleştirecek, modeli ONNX (Open Neural Network Exchange; farklı framework'lerin modellerini taşımak için kullanılan açık model formatı) formatına çevirip .NET'te çalıştıracak ve iki tarafın aynı sonucu verdiğini doğrulayacağız.