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

Model Seçimi ve AutoML: Hyperparameter'lar, Cross-Validation, AutoML API ve Feature Importance

Mert Özen 15 Kas 2026 15 dk 7 görüntülenme
Model Seçimi ve AutoML: Hyperparameter'lar, Cross-Validation, AutoML API ve Feature Importance

Hyperparameter araması ve AutoML doğru metric ve leakage'sız veriyle model seçimini hızlandırır, ama zayıf bir sinyali güçlendiremez. Bookstore iptal riskinde FastTree için grid ve random search'ü cross-validation ile, ML.NET AutoML API'sini ve accuracy'ye göre arayan mlnet CLI'ı karşılaştırıyor, leakage'lı bir kolonun nasıl 1,0 AUC ürettiğini, Permutation Feature Importance'ı ve yaş ile semt üzerinden bir fairness kontrolünü 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), Microsoft.ML.FastTree 6.0.0, Microsoft.ML.LightGbm 6.0.0 ve Microsoft.ML.AutoML 0.24.0 ile derlenip çalıştırılmıştır; mlnet CLI sürümü 16.18.2. 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

Yedinci yazıda sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) için sipariş iptal riskini logistic regression ile tahmin etmiş, test setinde ROC (Receiver Operating Characteristic; farklı threshold'larda doğru ve yanlış pozitif oranlarını gösteren eğri) eğrisinin altındaki alan olan ROC-AUC'de 0,724 almıştık. Bu yazıda aynı problemi otomatik model seçimine bırakıyoruz. İlk soru: FastTree'nin hyperparameter'larını 16 kombinasyonluk bir grid'de tarayıp AutoML'e de 20 deneme hakkı verirsek, AUC (eğrinin altındaki alan) ne kadar artar?

İkinci soru: ML.NET'in komut satırı aracı mlnet, aynı veriyle 30 saniyede 91 model deniyor. Bu 91 modelin en iyisinin skoru ile "kimse iptal etmez" diyen modelin skoru arasındaki fark ne kadardır?

Parameter ve Hyperparameter

Parameter, modelin eğitim sırasında veriden öğrendiği değerdir. Logistic regression'da her feature'ın weight'i bir parameter:

// Logistic regression'ın parameter'ları: her feature slot'u için öğrenilmiş bir weight
var logistic = Classification.Features(ml).Append(ml.BinaryClassification.Trainers.LbfgsLogisticRegression(
    new LbfgsLogisticRegressionBinaryTrainer.Options
    {
        LabelColumnName = "IsCancelled", FeatureColumnName = "Features", NumberOfThreads = 1,
    })).Fit(s.Train);
VBuffer<ReadOnlyMemory<char>> names = default;
logistic.Transform(s.Train).Schema["Features"].GetSlotNames(ref names);
float[] weights = [.. logistic.LastTransformer.Model.SubModel.Weights];
Console.WriteLine("logistic regression weights (largest first):");
foreach ((string name, float w) in names.DenseValues().Select(n => n.ToString()).Zip(weights)
             .OrderByDescending(x => Math.Abs(x.Second)).Take(6))
{
    Console.WriteLine($"  {name,-28}{w,8:F3}");
}
dotnet run --project src/Bookstore.ML.Lab -- tune
logistic regression weights (largest first):
  Payment.CreditCard            -0.729
  Payment.BankTransfer          -0.305
  Backorder                      0.204
  Method.Standard               -0.195
  FirstOrder                     0.194
  OrderTotal                     0.166

Bu tablo yedinci yazıdaki bulguyu başka bir yönden gösteriyor: kredi kartı ve havaleyle ödenen siparişlerin weight'i negatif, yani kapıda ödemeye göre daha az riskli. Payment.CashOnDelivery listede yok, çünkü one-hot encoding üç ödeme yönteminin üçü için de kolon üretiyor ve model riski "kapıda ödeme riskli" yerine "diğer ikisi daha az riskli" diye kodlamayı seçmiş. Kolonlar birbirine bağımlı olduğunda weight'leri tek tek okumak yanıltıcı olabilir; birazdan bunun daha güvenilir bir yolunu göreceğiz.

Hyperparameter ise eğitimden önce senin seçtiğin değer: FastTree'de ağaç sayısı, ağaç başına yaprak sayısı, bir yaprakta en az kaç örnek olacağı, learning rate. Model bunları öğrenmez. Yanlış seçilirlerse model ya veriyi ezberler ya da yeterince öğrenemez; üçüncü yazıdaki karmaşıklık eğrisi bunun bir örneğiydi.

Grid Search, Random Search ve Cross-Validation

Hyperparameter seçmenin en basit yolu grid search: her hyperparameter için birkaç değer seç ve bütün kombinasyonları dene. Her kombinasyonu tek bir validation setiyle değil, 5-fold cross-validation ile ölçüyoruz; böylece seçim bir split'in şansına bağlı kalmıyor. Fold'lar yine müşteriye göre gruplu ve test setine hiç dokunulmuyor:

private static (double Mean, double Std) CvAuc(MLContext ml, IDataView data, IEstimator<ITransformer> pipeline)
{
    double[] auc = [.. ml.BinaryClassification.CrossValidateNonCalibrated(data, pipeline, numberOfFolds: 5,
        labelColumnName: "IsCancelled", samplingKeyColumnName: "CustomerId", seed: 3).Select(f => f.Metrics.AreaUnderRocCurve)];
    double mean = auc.Average();
    return (mean, Math.Sqrt(auc.Average(a => (a - mean) * (a - mean))));
}
private static IEstimator<ITransformer> Tree(MLContext ml, int leaves, int minLeaf, int trees, double rate) =>
    Classification.Features(ml).Append(ml.BinaryClassification.Trainers.FastTree(new FastTreeBinaryTrainer.Options
    {
        LabelColumnName = "IsCancelled", FeatureColumnName = "Features",
        NumberOfLeaves = leaves, MinimumExampleCountPerLeaf = minLeaf, NumberOfTrees = trees, LearningRate = rate,
    }));
// Grid search: bütün kombinasyonlar, her biri 5-fold cross-validation ile ölçülür
List<(string Name, double Mean, double Std)> results = [];
Console.WriteLine($"{"FastTree grid (leaves, min leaf, rate)",-42}{"CV AUC",8}{"±",7}");
foreach (int leaves in new[] { 2, 4, 16, 64 })
foreach (int minLeaf in new[] { 10, 200 })
foreach (double rate in new[] { 0.05, 0.2 })
{
    (double mean, double std) = CvAuc(ml, s.TrainAndValidation, Tree(ml, leaves, minLeaf, 100, rate));
    string name = $"leaves {leaves,2}, min leaf {minLeaf,3}, rate {rate:F2}";
    results.Add((name, mean, std));
    Console.WriteLine($"{name,-42}{mean,8:F3}{std,7:F3}");
}
LogisticRegression, 5-fold CV AUC 0.662 ± 0.015
FastTree grid (leaves, min leaf, rate)      CV AUC      ±
leaves  2, min leaf  10, rate 0.05           0.666  0.013
leaves  2, min leaf  10, rate 0.20           0.660  0.012
leaves  2, min leaf 200, rate 0.05           0.666  0.013
leaves  2, min leaf 200, rate 0.20           0.659  0.014
leaves  4, min leaf  10, rate 0.05           0.666  0.016
leaves  4, min leaf  10, rate 0.20           0.661  0.012
leaves  4, min leaf 200, rate 0.05           0.666  0.011
leaves  4, min leaf 200, rate 0.20           0.664  0.011
leaves 16, min leaf  10, rate 0.05           0.660  0.013
leaves 16, min leaf  10, rate 0.20           0.632  0.023
leaves 16, min leaf 200, rate 0.05           0.657  0.013
leaves 16, min leaf 200, rate 0.20           0.643  0.009
leaves 64, min leaf  10, rate 0.05           0.643  0.011
leaves 64, min leaf  10, rate 0.20           0.580  0.014
leaves 64, min leaf 200, rate 0.05           0.651  0.008
leaves 64, min leaf 200, rate 0.20           0.620  0.008

Grid'in en iyi noktası 0,666 AUC ile ağaç başına 2 yaprak, yani her ağacın tek bir soru sorduğu en basit model. Yaprak sayısı ve learning rate arttıkça sonuç kötüleşiyor: 64 yaprak ve 0,2 learning rate ile 0,580. Logistic regression'ın cross-validation AUC'si 0,662. İki yapraklı ağaçların toplamı, her feature'ın etkisini ayrı ayrı ekleyen bir model; yani logistic regression'a çok benzeyen bir yapı. Bu veride iptal riski feature'ların toplamı gibi davrandığı için en iyi ağaç modeli de bu yapıya geri dönüyor.

Grid search'ün sorunu, hyperparameter sayısı arttıkça kombinasyon sayısının katlanarak büyümesi. Random search, aynı bütçeyi daha geniş bir alandaki rastgele noktalara harcar. Bergstra ve Bengio'nun 2012 tarihli makalesinin gösterdiği gibi, hyperparameter'ların çoğu sonucu az etkilediğinde rastgele noktalar önemli olan hyperparameter'ın daha fazla farklı değerini dener:

// Random search: aynı bütçe daha geniş bir alandaki rastgele noktalara harcanır
Random rnd = new(42);
Console.WriteLine($"{"FastTree random (leaves, min leaf, trees, rate)",-50}{"CV AUC",8}");
for (int i = 0; i < 8; i++)
{
    int leaves = 2 << rnd.Next(0, 6), minLeaf = rnd.Next(5, 400), trees = rnd.Next(20, 300);
    double rate = Math.Round(Math.Pow(10, -2.5 + 2 * rnd.NextDouble()), 3);
    (double mean, _) = CvAuc(ml, s.TrainAndValidation, Tree(ml, leaves, minLeaf, trees, rate));
    Console.WriteLine($"{$"leaves {leaves,2}, min leaf {minLeaf,3}, trees {trees,3}, rate {rate:F3}",-50}{mean,8:F3}");
FastTree random (leaves, min leaf, trees, rate)     CV AUC
leaves 32, min leaf  60, trees  55, rate 0.035       0.664
leaves  4, min leaf 108, trees 222, rate 0.034       0.665
leaves  4, min leaf 305, trees  85, rate 0.010       0.665
leaves 16, min leaf 131, trees 126, rate 0.010       0.657
leaves 16, min leaf  18, trees 247, rate 0.045       0.651
leaves  8, min leaf  65, trees  45, rate 0.081       0.664
leaves 32, min leaf 218, trees  32, rate 0.084       0.657
leaves  2, min leaf 362, trees 213, rate 0.034       0.667

Sekiz rastgele nokta, 16 noktalık grid ile aynı sonuca ulaşıyor: en iyisi 0,667, yine 2 yapraklı ağaçlarla. Son adımda grid'in kazananını train ve validation setlerinin birleşimiyle bir kez eğitip test setinde bir kez ölçüyoruz:

best grid point: leaves  2, min leaf  10, rate 0.05 (CV AUC 0.666)
test AUC: FastTree 0.720, LogisticRegression 0.733

Açılıştaki ilk sorunun cevabı: hyperparameter araması logistic regression'ı geçemedi. Cross-validation'da 0,004'lük fark, fold'lar arasındaki ±0,013'lük oynamanın içinde; test setinde ise logistic regression önde. Bir şey daha dikkat çekici: test AUC'leri (0,72–0,73) cross-validation ortalamalarından (0,66) belirgin şekilde yüksek. Model değişmedi; test setindeki müşteriler bu split'te tahmin edilmesi daha kolay bir gruba denk gelmiş. Üçüncü yazıda tek bir split'e güvenmenin riskini konuşmuştuk; burada aynı şeyi tersinden görüyoruz. Model seçimini cross-validation ile yap, test sonucunu da tek bir örnek olarak oku.

ML.NET AutoML API

Microsoft.ML.AutoML paketi bu aramayı otomatikleştiriyor: feature dönüşümlerini, trainer'ı ve trainer'ın hyperparameter'larını birlikte arıyor. Paketin sürüm numarası hâlâ 0.x; ML.NET 6.0 ile birlikte 0.24.0 olarak yayınlandı. Yeni API'de önce kolonların tipini söylüyor, sonra bir "sweepable" pipeline kurup bir experiment'e veriyoruz:

    ColumnInformation columns = new() { LabelColumnName = "IsCancelled" };
    foreach (string c in new[] { "PaymentMethod", "ShippingMethod", "Warehouse" }) columns.CategoricalColumnNames.Add(c);
    foreach (string c in new[] { "ItemCount", "OrderTotal", "DiscountRate", "PreviousOrders" }) columns.NumericColumnNames.Add(c);
    foreach (string c in new[] { "OrderId", "CustomerId", "OrderedAtUtc", "Age", "District" }) columns.IgnoredColumnNames.Add(c);
    // TrackingEventCount ancak kargoya verildikten sonra oluşur: iptal edilen siparişte hep 0
    if (withLeak) columns.NumericColumnNames.Add("TrackingEventCount");
    else columns.IgnoredColumnNames.Add("TrackingEventCount");
    SweepablePipeline pipeline = ml.Auto().Featurizer(s.Train, columns)
        .Append(ml.Auto().BinaryClassification(labelColumnName: "IsCancelled"));
    AutoMLExperiment experiment = ml.Auto().CreateExperiment()
        .SetPipeline(pipeline)
        .SetDataset(s.Train, s.Validation)
        .SetBinaryClassificationMetric(BinaryClassificationMetric.AreaUnderRocCurve, labelColumn: "IsCancelled")
        .SetTrainingTimeInSeconds(60)
        .SetMaxModelToExplore(withLeak ? 4 : 20);
    experiment.SetMonitor(new TrialPrinter(pipeline));
    Console.WriteLine(withLeak ? "AutoML with TrackingEventCount:" : "AutoML, 20 trials at most, 60 s at most:");
    TrialResult best = await experiment.RunAsync();
    Console.WriteLine($"best trial {best.TrialSettings.TrialId}: validation AUC {best.Metric:F4}, " +
                      $"test AUC {Auc(ml, best.Model, s.Test):F4}");
}
dotnet run --project src/Bookstore.ML.Lab -- automl
AutoML, 20 trials at most, 60 s at most:
  trial  0  AUC 0.6822    0.9 s  ReplaceMissingValues=>ConvertType=>OneHotEncoding=>Concatenate=>FastTreeBinary
  trial  1  AUC 0.6738    0.2 s  ReplaceMissingValues=>ConvertType=>OneHotEncoding=>Concatenate=>FastTreeBinary
  trial  2  AUC 0.6823    0.1 s  ReplaceMissingValues=>ConvertType=>OneHotEncoding=>Concatenate=>FastTreeBinary
  trial  3  AUC 0.6701    0.2 s  ReplaceMissingValues=>ConvertType=>OneHotEncoding=>Concatenate=>FastForestBinary
  trial  4  AUC 0.6822    0.2 s  ReplaceMissingValues=>ConvertType=>OneHotHashEncoding=>Concatenate=>FastTreeBinary
  trial  5  AUC 0.6738    0.2 s  ReplaceMissingValues=>ConvertType=>OneHotHashEncoding=>Concatenate=>FastTreeBinary
  trial  6  AUC 0.6701    0.2 s  ReplaceMissingValues=>ConvertType=>OneHotEncoding=>Concatenate=>FastForestBinary
  trial  7  AUC 0.6769    0.2 s  ReplaceMissingValues=>ConvertType=>OneHotEncoding=>Concatenate=>FastForestBinary
  trial  8  AUC 0.6782    0.5 s  ReplaceMissingValues=>ConvertType=>OneHotHashEncoding=>Concatenate=>LightGbmBinary
  trial  9  AUC 0.6521    0.1 s  ReplaceMissingValues=>ConvertType=>OneHotEncoding=>Concatenate=>FastTreeBinary
  trial 10  AUC 0.6701    0.3 s  ReplaceMissingValues=>ConvertType=>OneHotHashEncoding=>Concatenate=>FastForestBinary
  trial 11  AUC 0.6720    0.2 s  ReplaceMissingValues=>ConvertType=>OneHotEncoding=>Concatenate=>FastForestBinary
  trial 12  AUC 0.6782    0.2 s  ReplaceMissingValues=>ConvertType=>OneHotEncoding=>Concatenate=>LightGbmBinary
  trial 13  AUC 0.5955    3.0 s  ReplaceMissingValues=>ConvertType=>OneHotEncoding=>Concatenate=>LightGbmBinary
  trial 14  AUC 0.5133    1.2 s  ReplaceMissingValues=>ConvertType=>OneHotHashEncoding=>Concatenate=>SdcaLogisticRegressionBinary
  trial 15  AUC 0.6701    0.3 s  ReplaceMissingValues=>ConvertType=>OneHotHashEncoding=>Concatenate=>FastForestBinary
  trial 16  AUC 0.6769    0.2 s  ReplaceMissingValues=>ConvertType=>OneHotHashEncoding=>Concatenate=>FastForestBinary
  trial 17  AUC 0.6823    0.2 s  ReplaceMissingValues=>ConvertType=>OneHotHashEncoding=>Concatenate=>FastTreeBinary
  trial 18  AUC 0.6720    0.2 s  ReplaceMissingValues=>ConvertType=>OneHotHashEncoding=>Concatenate=>FastForestBinary
  trial 19  AUC 0.6744    0.2 s  ReplaceMissingValues=>ConvertType=>OneHotHashEncoding=>Concatenate=>FastForestBinary
best trial 2: validation AUC 0.6823, test AUC 0.7181

Kodu kısa tutmak için çıktıdaki deneme satırlarını yazdıran küçük bir IMonitor implementasyonu kullandım; SetMonitor ile experiment'e bağlanıyor. AutoML 20 denemede FastTree, FastForest, LightGBM ve SDCA'yı (Stochastic Dual Coordinate Ascent; her adımda tek bir örnek üzerinden ilerleyen bir optimizasyon algoritması) one-hot ve hashing encoding ile denedi. En iyi deneme validation setinde 0,682, test setinde 0,718 AUC aldı. Bu, elle kurduğumuz logistic regression'ın test sonucundan (0,733) düşük. AutoML kötü bir araç değil; ama bu problemde zor olan algoritma seçimi değil, sinyalin zayıf olması.

İki pratik not. Birincisi, AutoML deterministik değil. Aynı seed ile ikinci çalıştırmada 20 denemenin 19'u aynı çıktı, ama 17. deneme bu kez SDCA'yı denedi. Microsoft Learn'e göre varsayılan tuner Eci Cost Frugal; adındaki "cost", denemelerin eğitim süresi. Süreler her çalıştırmada biraz farklı olduğu için makinenin o anki yükü bile hangi denemenin seçileceğini değiştirebiliyor. İkincisi platform: AutoML paketi de sekizinci yazıdaki Microsoft.ML.TimeSeries gibi Intel MKL (Math Kernel Library; Intel'in optimize edilmiş matematik kütüphanesi) paketine bağlı ve LightGBM'i de deniyor. Bu yüzden AutoML çıktılarını Linux x64 bir container'da aldım. Microsoft Learn'e göre Windows'ta ek kurulum gerekmiyor; Windows'ta çalıştırmadım.

mlnet CLI ve Model Builder

Aynı aramayı kod yazmadan yapan iki araç var. Model Builder, Visual Studio'nun bir extension'ı; grafik arayüzle veri seçip model eğitiyor ve kod üretiyor. Yalnız Windows'taki Visual Studio'da çalışıyor. mlnet ise bir .NET tool. NuGet'te işletim sistemine göre ayrı paketler var: Windows için mlnet-win-x64, Linux için mlnet-linux-x64. Ben Linux paketini container'da, global kurulum yapmadan bir klasöre kurdum:

dotnet tool install mlnet-linux-x64 --version 16.18.2 --tool-path tools
tools/mlnet classification --dataset orders_clean.csv --label-col IsCancelled --has-header true --ignore-cols OrderId CustomerId OrderedAtUtc City District CustomerNote DeliveryDays TrackingEventCount IsReturned ReturnRequestedUtc TotalWeightKg DistanceKm Carrier --train-time 30 --name CancelModel -o out
Start Training
start binary classification
Evaluate Metric: Accuracy
Available Trainers: LGBM,FASTFOREST,FASTTREE,LBFGS,SDCA
Training time in second: 30
Use train validate split with ratio: 0.1
|      Trainer                             Accuracy   Duration       |
|--------------------------------------------------------------------|
|0     FastTreeBinary                      0.9574     0.6930         |
|1     FastTreeBinary                      0.9574     0.1690         |
...
|--------------------------------------------------------------------|
|                          Experiment Results                        |
|--------------------------------------------------------------------|
|                               Summary                              |
|--------------------------------------------------------------------|
|ML Task: binary classification                                      |
|Dataset: /w/cli/orders_clean.csv                                    |
|Label : IsCancelled                                                 |
|Total experiment time :    29.0000 Secs                             |
|Total number of models explored: 91                                 |
|--------------------------------------------------------------------|
|                        Top 5 models explored                       |
|--------------------------------------------------------------------|
|      Trainer                             Accuracy   Duration       |
|--------------------------------------------------------------------|
|35    LightGbmBinary                      0.9578     0.8460         |
|0     FastTreeBinary                      0.9574     0.6930         |
|1     FastTreeBinary                      0.9574     0.1690         |
|2     FastTreeBinary                      0.9574     0.1290         |
|3     FastTreeBinary                      0.9574     0.1870         |
|--------------------------------------------------------------------|
save CancelModel.mbconfig to /w/cli/out/CancelModel
Generating a console project for the best pipeline at location : /w/cli/out/CancelModel

Çıktının ortasındaki 80 satırı kısalttım. Açılıştaki ikinci sorunun cevabı burada: 91 modelin en iyisi 0,9578 accuracy alıyor, kimse iptal etmez diyen model 0,9574. Fark 0,0004. CLI'ın binary classification'da optimize ettiği metric accuracy ve bu sürümde başka bir metric seçmek için bir seçenek yok; Microsoft Learn'deki Model Builder sayfası da binary classification için varsayılan metric'in accuracy olduğunu yazıyor. Yedinci yazıda gördüğümüz gibi, dengesiz veride accuracy'yi optimize eden bir araç aslında çoğunluk sınıfını tahmin eden modeli arıyor. Ayrıca mlnet'in NuGet'teki son sürümü 16.18.2, Şubat 2024 tarihli; ürettiği proje net8.0 ve Microsoft.ML 3.0.1 kullanıyor. Aracı fikir almak için kullan, ürettiği projeyi güncel paketlerle yeniden kur.

Permutation Feature Importance

Model eğitildikten sonra iş ekibinin ilk sorusu "neye bakarak karar veriyor?" olur. Logistic regression'ın weight'leri, birbirine bağlı kolonlar yüzünden bu soruya güvenilir cevap vermiyordu. PFI (Permutation Feature Importance; bir feature'ın değerlerini satırlar arasında karıştırıp metric'in ne kadar kötüleştiğini ölçen yöntem) modelden bağımsız çalışır: karıştırılan feature önemliyse AUC düşer, önemsizse değişmez.

// Her seferinde bir feature slot'unu karıştır ve test AUC'sinin ne kadar düştüğünü ölç
var pfi = ml.BinaryClassification.PermutationFeatureImportance(model.LastTransformer, test,
    labelColumnName: "IsCancelled", permutationCount: 5);
VBuffer<ReadOnlyMemory<char>> names = default;
test.Schema["Features"].GetSlotNames(ref names);
Console.WriteLine($"{"feature slot",-28}{"AUC drop",10}{"±",8}");
foreach ((string name, BinaryClassificationMetricsStatistics m) in names.DenseValues().Select(n => n.ToString())
             .Zip(pfi).OrderBy(x => x.Second.AreaUnderRocCurve.Mean).Take(8))
{
    Console.WriteLine($"{name,-28}{-m.AreaUnderRocCurve.Mean,10:F4}{m.AreaUnderRocCurve.StandardError,8:F4}");
}
dotnet run --project src/Bookstore.ML.Lab -- explain
feature slot                  AUC drop       ±
Payment.CashOnDelivery          0.0681  0.0066
Backorder                       0.0421  0.0011
OrderTotal                      0.0172  0.0025
Payment.CreditCard              0.0160  0.0033
FirstOrder                      0.0144  0.0026
Method.Standard                 0.0141  0.0019
Payment.BankTransfer            0.0138  0.0029
DiscountRate                    0.0070  0.0015

En önemli feature kapıda ödeme: karıştırıldığında AUC 0,068 düşüyor. İkincisi stokta olmayan kitap (0,042), sonra çok daha küçük etkilerle sipariş tutarı, diğer ödeme yöntemleri, ilk sipariş olması ve kargo yöntemi. Weight tablosunda görünmeyen Payment.CashOnDelivery burada birinci sırada. Bu liste iş ekibine anlatılabilir: "İptal riskini en çok ödeme yöntemi ve stok durumu belirliyor, sepet tutarı ve müşterinin ilk siparişi olması biraz ekliyor." Yedinci yazıdaki iki koşullu kuralın modelle neredeyse aynı sonucu vermesinin nedeni de bu tabloda görünüyor.

PFI'ın da sınırları var. İki feature birbirine bağlıysa birini karıştırmak az etki gösterir, çünkü model aynı bilgiyi diğerinden alır. Sonuç bir nedensellik iddiası da değildir: "kapıda ödeme iptale neden olur" demez, yalnız modelin bu bilgiye dayandığını söyler.

Fairness: Hassas Feature'lar ve Proxy'ler

İkinci yazıda doğum yılını hassas bir kolon olarak işaretlemiştik. Bir modelin yaşa göre farklı davranıp davranmadığını iki yoldan kontrol ediyoruz: yaşı feature olarak ekleyince ne değişiyor ve yaş gruplarına göre işaretlenen sipariş oranı ne? Bir de semt (District) ekliyoruz; gerçek hayatta semt, gelir düzeyi ya da etnik köken gibi hassas bilgilerin yerini dolaylı olarak tutabilen tipik bir proxy feature'dır:

(string Name, IEstimator<ITransformer> Pipeline)[] variants =
[
    ("order features", Classification.Features(ml).Append(Classification.Logistic(ml))),
    ("+ Age", Classification.Features(ml, ["Age"]).Append(Classification.Logistic(ml))),
    ("+ District (one-hot)", ml.Transforms.Categorical.OneHotEncoding("DistrictKey", "District")
        .Append(Classification.Features(ml, ["DistrictKey"])).Append(Classification.Logistic(ml))),
];
dotnet run --project src/Bookstore.ML.Lab -- fairness
                       test AUC  flagged under 30     flagged 30-49       flagged 50+
actual cancel rate                          4.0 %             3.9 %             4.6 %
order features            0.733            22.5 %            21.3 %            25.2 %
+ Age                     0.731            22.5 %            21.3 %            25.3 %
+ District (one-hot)      0.610            22.2 %            24.9 %            24.6 %

Yaş eklemek AUC'yi değiştirmiyor (0,733 ve 0,731) ve yaş gruplarına göre işaretlenme oranları neredeyse aynı kalıyor. Bu sentetik veride yaş iptali etkilemiyor; model de onu kullanmıyor. Yine de işaretlenme oranları yaş grupları arasında farklı: 50 yaş üstünde yüzde 25,2, 30–49 arasında yüzde 21,3. Model yaşı hiç görmediği hâlde bu fark var, çünkü bu grupta gerçek iptal oranı da daha yüksek (yüzde 4,6) ve ödeme yöntemi gibi feature'lar gruplar arasında farklı dağılıyor. Hassas kolonu modelden çıkarmak, modelin o gruba farklı davranmayacağını garanti etmez. Bu yüzden fairness kontrolü, kolon listesine bakarak değil, sonuçları gruplara göre ölçerek yapılır.

Semt eklemek ise AUC'yi 0,610'a düşürüyor. Yüzlerce semt için one-hot kolonu, az sayıda iptal örneğiyle birleşince model rastlantısal semt farklarını öğreniyor. Bu yazıda sonuç yalnız kötü bir model; gerçek veride aynı model, belirli semtlerde yaşayanları sistematik olarak "riskli" işaretleyebilir. İptal riski skoru bir müşteriye kapıda ödemeyi kapatmak gibi bir karara dönüşecekse, karar kuralını iş ve hukuk ekibiyle birlikte belirle ve skoru otomatik bir red yerine insan onayına giden bir işaret olarak kullan. KVKK (Kişisel Verilerin Korunması Kanunu) açısından ayrıntılar Veri Gizliliği ve KVKK serisinde.

Üretimde Dikkat

Model seçimini tek bir sayıya indirme. AUC'nin yanında tahmin süresine, model dosyasının boyutuna, açıklanabilirliğe ve platform desteğine bak. Bu yazıdaki sonuçlara göre logistic regression, AutoML'in bulduğu modellerden daha iyi, daha küçük ve daha kolay açıklanabilir; LightGBM gibi native bağımlılığı da yok.

Platformlar arası küçük farklara hazırlıklı ol. tune komutunu Linux x64 container'da da çalıştırdım: FastTree sonuçları birebir aynı çıktı, logistic regression'ın weight'leri ve AUC'leri ise üçüncü ondalıkta farklıydı (test AUC'si 0,733 yerine 0,731). Kayan noktalı hesaplamalar farklı işlemci komutlarıyla farklı yuvarlanabilir. Model dosyasını bir kez eğitip her ortama aynı dosyayı dağıt; her ortamda yeniden eğitme.

AutoML'in seçtiği pipeline'ı kodda sabitle. Her eğitimde AutoML'i yeniden çalıştırırsan, veri değişmese bile farklı bir model ve farklı bir feature dönüşümü üretebilir; bir sonraki yazıda model versiyonlamayı konuşurken bunun neden sorun olduğunu göreceğiz.

PFI ve fairness tablolarını model kartının bir parçası yap: modelin hangi feature'lara dayandığı ve gruplara göre işaretlenme oranları, model her yeniden eğitildiğinde yeniden hesaplanmalı.

Sık Düşülen Tuzaklar

İlk tuzak, AutoML'e leakage içeren veri vermek. TrackingEventCount, siparişin kargodaki hareket sayısı; iptal edilen siparişte her zaman 0. Bu kolonu AutoML'e verince sonuç şu:

dotnet run --project src/Bookstore.ML.Lab -- automl --leak
AutoML with TrackingEventCount:
  trial  0  AUC 1.0000    0.9 s  ReplaceMissingValues=>ConvertType=>OneHotEncoding=>Concatenate=>FastTreeBinary
  trial  1  AUC 1.0000    0.2 s  ReplaceMissingValues=>ConvertType=>OneHotEncoding=>Concatenate=>FastTreeBinary
  trial  2  AUC 1.0000    0.1 s  ReplaceMissingValues=>ConvertType=>OneHotEncoding=>Concatenate=>FastTreeBinary
  trial  3  AUC 1.0000    0.2 s  ReplaceMissingValues=>ConvertType=>OneHotEncoding=>Concatenate=>FastForestBinary
best trial 0: validation AUC 1.0000, test AUC 1.0000

Dört denemenin dördü de 1,0000 AUC alıyor ve test setinde de 1,0000 çıkıyor, çünkü leakage test setinde de var. AutoML bu modeli memnuniyetle seçer. Tek koruma, her kolon için "bu bilgi tahmin anında var mı?" sorusunu sormak. Kusursuz bir skor neredeyse her zaman bir hatanın işaretidir.

İkincisi, aracın varsayılan metric'ine güvenmek. mlnet 91 modeli accuracy'ye göre sıraladı ve hepsi çoğunluk sınıfı baseline'ından ayırt edilemiyordu. Metric'i problem belirler, araç değil.

Üçüncüsü, en yüksek skoru tek kriter yapmak. Cross-validation'daki 0,004'lük fark, fold'lar arasındaki oynamadan küçüktü. Fark gürültü seviyesindeyse daha basit modeli seç.

Dördüncüsü, hassas kolonu silip fairness sorununu çözdüğünü sanmak. Yaşı görmeyen model bile yaş gruplarına farklı oranlarda işaret koydu; semt gibi proxy feature'lar aynı bilgiyi dolaylı olarak taşıyabilir.

Küçük Bir Deneme

★☆☆ AutoML experiment'inde SetMaxModelToExplore değerini 50 yap ve SetTrainingTimeInSeconds değerini 120'ye çıkar. En iyi validation AUC değişiyor mu? Hangi trainer'lar daha sık deneniyor?

★★☆ Explain method'unu FastTree modeli için de çalıştır. Önemli feature'ların sırası logistic regression'dakiyle aynı mı? OrderTotal'ın önemi neden farklı olabilir?

★★★ Fairness tablosuna ödeme yöntemine göre bir kırılım ekle ve yaş gruplarında kapıda ödeme oranını hesapla. 50 yaş üstündeki yüksek işaretlenme oranını bu oran açıklıyor mu? Sonucu, modeli kullanacak ekibe yazacağın iki paragraflık bir notla anlat.

Bir sonraki yazıda modeli üretime alıyoruz: teslim süresi modelini PredictionEnginePool ile bir ASP.NET Core minimal API'ye koyacak, dördüncü yazıdaki thread-safety sorununun pool ile nasıl çözüldüğünü ölçecek, model dosyasını versiyonlayacak, dosyayı değiştirerek modeli yeniden başlatmadan güncelleyecek ve verideki drift'i izleyeceğiz.