ML.NET Tutorial in C#: First Model and Common Errors
Last updated: November 3, 2026 · Tested with .NET 10.0.12 (SDK 10.0.401), Microsoft.ML 5.0.1, Microsoft.ML.FastTree 5.0.1, Microsoft.Extensions.ML 5.0.1, Windows 11
Short answer: a first ML.NET model in C# is five steps: load a CSV with LoadFromTextFile<T> (set hasHeader and separatorChar), build a pipeline that turns your columns into one float vector called Features, call Fit, check the metrics on a held-out test set, and save the model with mlContext.Model.Save. For predictions in a web app, use PredictionEnginePool, not a shared PredictionEngine. Below is the full code for a house-price model, the real metrics, and the errors beginners hit most, each reproduced with its exact message.
This is a rewrite of my earlier ML.NET tutorial; every sample is now built and run. If you want text or images instead of a table of numbers, see NLP with ML.NET and ML.NET image classification.
| Symptom | Cause | Fix |
|---|---|---|
Schema mismatch for feature column 'Features': expected Vector<Single>, got Vector<Int32> | int properties in the input class | Use float, or ConvertType to Single |
Concatenated columns should have the same type | Mixed int and float columns | Same as above |
Could not find input column 'Bedroom' | Name in the pipeline doesn’t match a property | Use nameof() or fix the spelling |
Could not find label column 'Label' | Label property has another name | Pass labelColumnName: |
expected Single, got Int32 (Parameter 'labelCol') | Regression label is an int | Make the label float |
expected Key<UInt32>, got String | Multiclass label not mapped to a key | MapValueToKey + MapKeyToValue |
Training set has 0 instances | Comma CSV loaded with the default tab separator | separatorChar: ',' |
| Prediction is always 0, no error | Output property not mapped to Score | [ColumnName("Score")] |
IndexOutOfRangeException or wrong results under load | One PredictionEngine shared by threads | PredictionEnginePool |
How I Tested It
One console project generates a synthetic dataset, trains and evaluates two models, saves and reloads them, reproduces each error, hammers a PredictionEngine from 8 threads, and then starts a minimal API with PredictionEnginePool and calls it over HTTP. Every output block below is copied from that run. The data is small and made up on purpose: 1,000 houses generated from a formula with random noise, so we know the “true” answer and can judge the model. Timings are from a laptop and are only useful as comparisons.
Packages and Data
<PackageReference Include="Microsoft.ML" Version="5.0.1" />
<PackageReference Include="Microsoft.ML.FastTree" Version="5.0.1" />
<PackageReference Include="Microsoft.Extensions.ML" Version="5.0.1" />
Microsoft.ML is all you need for the first model. FastTree is a separate package, and Microsoft.Extensions.ML gives you PredictionEnginePool. dotnet list package --vulnerable --include-transitive reported no vulnerable packages.
The CSV: price is in thousands of dollars, and Band is used later for the multiclass example.
Size,Bedrooms,Age,Neighborhood,Price,Band
2504,2,12,Suburb,436.9,High
2039,1,76,Downtown,370.1,Mid
The generator’s formula is 40 + 0.15 * size + 8 * beds - 1.2 * age, plus 80 for Downtown, 30 for Suburb, 40 for houses older than 60, and noise of up to ±45.
Step 1: The Input and Output Classes
public sealed class HouseData
{
[LoadColumn(0)] public float Size { get; set; }
[LoadColumn(1)] public float Bedrooms { get; set; }
[LoadColumn(2)] public float Age { get; set; }
[LoadColumn(3)] public string Neighborhood { get; set; } = "";
[LoadColumn(4)] public float Price { get; set; }
}
public sealed class PricePrediction
{
[ColumnName("Score")] public float Price { get; set; }
}
Two rules save most of the errors later. Numbers are float, even bedrooms, because ML.NET trainers work on Single. And the output property must map to the column the trainer writes: Score for regression, PredictedLabel for classification.
Step 2: Load, Split and Train
var mlContext = new MLContext(seed: 1);
IDataView data = mlContext.Data.LoadFromTextFile<HouseData>(dataPath, hasHeader: true, separatorChar: ',');
var split = mlContext.Data.TrainTestSplit(data, testFraction: 0.2);
var features = mlContext.Transforms.Categorical.OneHotEncoding("NeighborhoodEncoded", "Neighborhood")
.Append(mlContext.Transforms.Concatenate("Features", "Size", "Bedrooms", "Age", "NeighborhoodEncoded"))
.Append(mlContext.Transforms.NormalizeMinMax("Features"));
var pipeline = features.Append(mlContext.Regression.Trainers.Sdca(labelColumnName: "Price"));
var sw = Stopwatch.StartNew();
ITransformer model = pipeline.Fit(split.TrainSet);
OneHotEncoding turns the neighborhood text into three 0/1 columns. Concatenate builds the single Features vector every trainer reads. SDCA is a linear trainer, so it needs normalized features; tree trainers don’t care. data.Preview() is the quickest way to check the CSV loaded the way you think, which is a popular Stack Overflow question on its own:
train rows: 789, test rows: 211
preview: Size=2504, Bedrooms=2, Age=12, Neighborhood=Suburb, Price=436.9
The split isn’t exactly 800/200. TrainTestSplit assigns rows randomly by fraction, so expect roughly 20%.
Step 3: Evaluate on Data the Model Hasn’t Seen
var metrics = mlContext.Regression.Evaluate(model.Transform(split.TestSet), labelColumnName: "Price");
SDCA trained in 6818 ms
SDCA trained again (warm): 26 ms
SDCA test set: R2=0.982 RMSE=19.3 MAE=15.7
FastTree trained in 324 ms
FastTree test set: R2=0.981 RMSE=20.1 MAE=15.7
SDCA 5-fold R2: 0.982, 0.986, 0.984, 0.986, 0.984
R² of 0.98 means the model explains 98% of the price variation; MAE says it’s off by about $15.7k on average. That’s close to the noise I put in, so this is about as good as any model can do on this data. Don’t expect numbers like this on real data. FastTree, a gradient-boosted tree, was no better here because the data is mostly linear. Swapping trainers is one line, so try a couple. Cross-validation (5 folds, 0.982 to 0.986) shows the result doesn’t depend on one lucky split.
The first Fit in a process is slow: 6.8 s in one run, 0.3 s in another, against 26 to 105 ms for the second. That’s JIT and assembly loading, not training. Don’t benchmark trainers on the first call.
Step 4: Predict
var engine = mlContext.Model.CreatePredictionEngine<HouseData, PricePrediction>(model);
var house = new HouseData { Size = 1800, Bedrooms = 3, Age = 15, Neighborhood = "Suburb" };
PricePrediction prediction = engine.Predict(house);
Predicted price, 1800 sq ft, 3 bed, 15 years, Suburb: 345.0k (formula without noise: 346.0k)
FastTree, same house: 357.0k
Neighborhood "Lakeside" (not in training data): 351.1k, no exception
Neighborhood "Rural": 314.0k
The linear model landed $1k from the true value. The third line is worth knowing: a category the model never saw doesn’t throw. One-hot encoding gives it all zeros and you get a number that looks plausible. Validate category inputs yourself.
Step 5: Save and Load the Model
mlContext.Model.Save(model, data.Schema, modelPath);
ITransformer loadedModel = loadContext.Model.Load(modelPath, out DataViewSchema inputSchema);
model.zip (SDCA): 5,366 bytes
model-fasttree.zip: 80,433 bytes
Load + CreatePredictionEngine: 117 ms, prediction 345.0k
saved input schema: Size:Single, Bedrooms:Single, Age:Single, Neighborhood:String, Price:Single
from byte[] (5,366 bytes): 345.0k
The zip holds the whole pipeline, including the one-hot dictionary and the min-max ranges, so the loaded model takes raw HouseData. The linear model is 5 KB; FastTree’s 100 trees are 80 KB. To keep a model in a database, save to a MemoryStream and store the bytes:
using var ms = new MemoryStream();
mlContext.Model.Save(model, data.Schema, ms);
byte[] modelBytes = ms.ToArray();
ITransformer fromBytes = mlContext.Model.Load(new MemoryStream(modelBytes), out _);
Microsoft’s page on saving and loading ML.NET models covers the other overloads.
The Errors, Reproduced
Each line below is one deliberate mistake and what ML.NET 5.0.1 said about it:
int feature columns -> ArgumentOutOfRangeException: Schema mismatch for feature column 'Features': expected Vector<Single>, got Vector<Int32> (Parameter 'inputSchema')
int + float feature columns -> InvalidOperationException: Concatenated columns should have the same type. Column 'Bedrooms' has type of Int32, but expected column type is Single.
typo in column name -> ArgumentOutOfRangeException: Could not find input column 'Bedroom' (Parameter 'inputSchema')
label column not named Label, no labelColumnName -> ArgumentOutOfRangeException: Could not find label column 'Label' (Parameter 'inputSchema')
int label for regression -> ArgumentOutOfRangeException: Schema mismatch for label column 'Price': expected Single, got Int32 (Parameter 'labelCol')
The three Stack Overflow questions about schema mismatches on the Features column have over 17,000 views between them. If all your columns are int, Concatenate works and the trainer fails; if only some are, Concatenate fails first. If you can’t change the class, convert the vector:
var fixedPipeline = mlContext.Transforms.Concatenate("Features", "Size", "Bedrooms", "Age")
.Append(mlContext.Transforms.Conversion.ConvertType("Features", outputKind: DataKind.Single))
.Append(mlContext.Regression.Trainers.Sdca(labelColumnName: "Price"));
Column names are checked when you call Fit, not when you build the pipeline, so a typo compiles. Trainers look for a column named Label unless you say otherwise.
CSV loading mistakes are quieter
first row: Size=NaN Bedrooms=0 Neighborhood="" Price=0
CSV loaded without separatorChar -> InvalidOperationException: Training set has 0 instances, aborting training.
first row: Size=NaN Bedrooms=NaN Neighborhood="Neighborhood" Price=NaN
rows: 1001
CSV with header, hasHeader: false -> no exception
first row Price=0
LoadColumn(9) on a 6-column file -> no exception
I assumed LoadFromTextFile would guess commas for a .csv file. It doesn’t: the default separator is tab, so each whole line went into the first column and every row was dropped. The error mentions neither the separator nor the file. Forgetting hasHeader: true made the header a 1,001st row of NaNs, with “Neighborhood” as a category value; training still ran. And a [LoadColumn] index past the end of the file gave no error at all: every Price was 0 and the model trained happily on that. Always Preview() the data once.
Prediction always 0
Price = 0
output class without [ColumnName("Score")] -> no exception
Without the attribute, the engine filled Price from the input’s Price column, which is 0 for a house you want priced. No exception. It’s one common cause of the “predictions always return 0” question on Stack Overflow.
Multiclass: Labels Must Be Keys
string label, multiclass, no MapValueToKey -> ArgumentOutOfRangeException: Schema mismatch for label column 'Band': expected Key<UInt32>, got String (Parameter 'labelCol')
string label, binary classifier -> ArgumentOutOfRangeException: Schema mismatch for label column 'Band': expected Boolean, got String (Parameter 'labelCol')
Binary classifiers need a bool label. Multiclass trainers need a key, so map the text to a key going in and back to text coming out:
var bandPipeline = mlContext.Transforms.Conversion.MapValueToKey("Label", "Band")
.Append(features)
.Append(mlContext.MulticlassClassification.Trainers.SdcaMaximumEntropy())
.Append(mlContext.Transforms.Conversion.MapKeyToValue("PredictedLabel"));
The Score array has one probability per class, but in which order? Read the slot names from the engine’s output schema:
VBuffer<ReadOnlyMemory<char>> slotNames = default;
bandEngine.OutputSchema["Score"].GetSlotNames(ref slotNames);
var labels = slotNames.DenseValues().Select(n => n.ToString()).ToArray();
Band model: MicroAccuracy=0.948 MacroAccuracy=0.947 LogLoss=0.167
Predicted band: Mid
Scores: High=0.040, Mid=0.953, Low=0.008
The order is High, Mid, Low: the order the values first appeared in the training data, not alphabetical. Code that assumes Score[0] is “Low” is wrong.
PredictionEngine Is Not Thread-Safe
I computed 20,000 predictions on one thread, then made the same calls on one shared engine from 8 threads and compared:
shared PredictionEngine, 20,000 parallel calls: 14330 wrong results, 2995 exceptions
first exception: ArgumentOutOfRangeException: Specified argument was out of the range of valid values.
PredictionEnginePool, 20,000 parallel calls: 0 wrong results, 0 exceptions
I expected the odd exception. The wrong results are worse: 72% of calls returned a wrong price, silently. The exception type changed between runs too (IndexOutOfRangeException in another). In ASP.NET Core, register the pool:
builder.Services.AddPredictionEnginePool<HouseData, PricePrediction>()
.FromFile(modelName: "PriceModel", filePath: modelPath, watchForChanges: false);
app.MapPost("/predict", (HouseData input, PredictionEnginePool<HouseData, PricePrediction> pool) =>
Results.Ok(pool.Predict(modelName: "PriceModel", example: input)));
POST /predict -> 200 {"price":345.01285}
200 concurrent POST /predict -> 200 OK
Set watchForChanges: true and the pool reloads the model when the file changes. Microsoft’s deploying a model in a Web API guide uses the same setup with controllers.
How Fast Is a Prediction?
round 3: reused PredictionEngine: 0.35 µs per prediction
round 3: PredictionEnginePool.Predict: 0.43 µs per prediction
new PredictionEngine per call: 938.4 µs per prediction
round 2: model.Transform on 20,000 rows: 5 ms (0.30 µs per row)
The pool costs almost nothing over a single engine. Creating a new engine per request, the usual “fix” for the threading bug, was about 2,700 times slower. For batch scoring, model.Transform on an IDataView was the fastest per row. These are tiny models; a big text or image pipeline will be much slower, but the ratios hold.
Repeatable Results: seed Isn’t Enough for SDCA
I expected new MLContext(seed: 1) to give identical metrics every run. It gives the same split, but not the same model:
run 1, seed 1: SDCA default threads R2=0.98199, SDCA NumberOfThreads = 1 R2=0.98190
run 2, seed 1: SDCA default threads R2=0.98200, SDCA NumberOfThreads = 1 R2=0.98190
run 3, seed 1: SDCA default threads R2=0.98196, SDCA NumberOfThreads = 1 R2=0.98190
A second process gave 0.98198, 0.98210 and 0.98196 for the default. SDCA trains on several threads by default, and the order they update the weights varies. With one thread it was identical every time, across separate processes too:
.Append(ml.Regression.Trainers.Sdca(new SdcaRegressionTrainer.Options
{
LabelColumnName = "Price",
NumberOfThreads = threads
}));
FastTree gave the same prediction (357.0k) in every run. The differences are small, but they matter if a test asserts an exact metric.
Checklist
- Use
floatfor every numeric input and for regression labels;boolfor binary labels. - Pass
hasHeaderandseparatorChartoLoadFromTextFile; the default separator is tab. Preview()the data once and check the values.- Name the label with
labelColumnName:, and usenameof()for column names. - Map output properties to
ScoreorPredictedLabel. - Evaluate on a test split or with cross-validation, never on the training data.
- Wrap multiclass labels in
MapValueToKey/MapKeyToValue, and read the score order from the slot names. - Validate category values; unseen ones don’t throw.
- Save with
mlContext.Model.Save; a stream works for databases. - Use
PredictionEnginePoolin web apps; never share aPredictionEnginebetween threads. - For repeatable SDCA results, set
NumberOfThreads = 1as well as the seed.
Related: NLP with C# and ML.NET · ML.NET image classification · a recommendation system with ML.NET · all AI and .NET tutorials
ML.NET lets you build and deploy ML models entirely in C# with no Python dependency. True