
Learn how to build an AI recommendation system in C# using ML.NET collaborative filtering and matrix factorization. Full code, best practices — start today.
Every product team eventually gets the same request: "Can we show users what they'll probably like next?" If your stack is .NET, you don't need Python, a separate microservice, or a data science team to answer that. You can build a production-grade recommendation system in C# using ML.NET's matrix factorization trainer — the same collaborative filtering technique that powers the "because you watched…" rails on major streaming platforms. This tutorial walks through the full pipeline: loading rating data, training a model, evaluating it honestly, generating top-N recommendations, and shipping it inside an ASP.NET Core API.
What Collaborative Filtering Actually Does
Collaborative filtering makes a deceptively simple bet: people who agreed in the past will agree in the future. It ignores what an item is — no genres, no tags, no descriptions — and looks only at the interaction matrix of users × items.
That matrix is enormous and mostly empty. With 50,000 users and 10,000 movies you have 500 million cells, and a realistic dataset fills maybe 0.5% of them. Matrix factorization solves this by assuming the matrix is low-rank: it can be approximated by multiplying two much smaller matrices together — a user matrix (users × k) and an item matrix (items × k), where k is typically 50–200.
Each row of those matrices is a latent factor vector. Nobody labels these factors, but after training they tend to encode real concepts: "how much this user likes slow-burn dramas," "how action-heavy this film is." A predicted rating is just the dot product of the user vector and the item vector. That's why matrix factorization is fast at inference time — a prediction is a few dozen multiplications, not a database scan.
ML.NET implements this with LIBMF's stochastic gradient descent solver, which is written in native C++ and parallelised across cores. You get near-Python performance without leaving the .NET runtime.
Why Choose ML.NET Over a Python Service?
- One deployment artifact. The model serialises to a .zip file that ships with your ASP.NET Core app. No Flask sidecar, no gRPC hop, no version drift between two languages.
- Type safety end to end. Your input and output schemas are C# classes, checked at compile time.
- Latency. In-process prediction avoids a network round trip — typically sub-millisecond per scored item.
- Team reality. Most .NET teams can maintain C# forever. A Python service written once by a contractor becomes technical debt the day they leave.
Setting Up the Project
Create a console app and add the two packages you need. Microsoft.ML.Recommender is separate from the core package because it wraps native LIBMF binaries.
// dotnet new console -n RecommenderDemo
// dotnet add package Microsoft.ML
// dotnet add package Microsoft.ML.Recommender
using Microsoft.ML;
using Microsoft.ML.Data;
using Microsoft.ML.Trainers;
Now define your data schema. The key detail that trips up most first-timers: ML.NET's matrix factorization trainer expects the ID columns to be float on the class, because they get converted to key types inside the pipeline.
public class MovieRating
{
[LoadColumn(0)] public float UserId { get; set; }
[LoadColumn(1)] public float MovieId { get; set; }
[LoadColumn(2)] public float Label { get; set; } // the rating, 0.5 - 5.0
}
public class MovieRatingPrediction
{
public float Label { get; set; }
public float Score { get; set; } // the predicted rating
}
The property named Label is a convention, not magic — you can rename it as long as you tell the trainer via LabelColumnName. Score, however, is the fixed output column name that every ML.NET regression-style trainer produces.
Training the Model
Here is the complete training routine. Read the comments carefully — the two MapValueToKey transforms are not optional boilerplate.
var mlContext = new MLContext(seed: 42);
IDataView trainingData = mlContext.Data.LoadFromTextFile<MovieRating>(
path: "data/ratings-train.csv",
hasHeader: true,
separatorChar: ',');
IDataView testData = mlContext.Data.LoadFromTextFile<MovieRating>(
path: "data/ratings-test.csv",
hasHeader: true,
separatorChar: ',');
var options = new MatrixFactorizationTrainer.Options
{
MatrixColumnIndexColumnName = "userIdEncoded",
MatrixRowIndexColumnName = "movieIdEncoded",
LabelColumnName = nameof(MovieRating.Label),
NumberOfIterations = 20,
ApproximationRank = 100, // k: size of each latent vector
LearningRate = 0.1,
Lambda = 0.025, // L2 regularisation
Quiet = false
};
// MapValueToKey turns raw numeric IDs into contiguous key values (0..n-1).
// Matrix factorization indexes directly into arrays, so gaps in your IDs
// would otherwise allocate a huge, mostly-empty factor matrix.
var pipeline = mlContext.Transforms.Conversion.MapValueToKey(
outputColumnName: "userIdEncoded",
inputColumnName: nameof(MovieRating.UserId))
.Append(mlContext.Transforms.Conversion.MapValueToKey(
outputColumnName: "movieIdEncoded",
inputColumnName: nameof(MovieRating.MovieId)))
.Append(mlContext.Recommendation().Trainers.MatrixFactorization(options));
Console.WriteLine("Training...");
ITransformer model = pipeline.Fit(trainingData);
That MapValueToKey point deserves emphasis because it is the single most common source of out-of-memory exceptions in ML.NET recommenders. If your user IDs are database identities starting at 4,000,000, the trainer would try to allocate four million user rows × 100 factors × 4 bytes ≈ 1.6 GB for users who don't exist. Key mapping compresses that to exactly the number of distinct users in your data.
Choosing Hyperparameters
- ApproximationRank (k) — the capacity dial. Start at 100. Higher k captures subtler taste patterns but overfits sparse data. If your training RMSE drops while test RMSE rises, lower it.
- Lambda — L2 regularisation, your main defence against overfitting. Raise it (0.05–0.1) when you have few ratings per user.
- NumberOfIterations — 20 is a reasonable default; watch the per-iteration loss printout and stop increasing when it flattens.
- LearningRate — leave at 0.1 unless training diverges (loss increasing), in which case halve it.
Evaluating Honestly
Matrix factorization on explicit ratings is a regression problem, so you evaluate it with the regression catalogue:
IDataView predictions = model.Transform(testData);
var metrics = mlContext.Regression.Evaluate(
predictions,
labelColumnName: nameof(MovieRating.Label),
scoreColumnName: "Score");
Console.WriteLine($"RMSE: {metrics.RootMeanSquaredError:0.###}");
Console.WriteLine($"R Squared: {metrics.RSquared:0.###}");
Console.WriteLine($"MAE: {metrics.MeanAbsoluteError:0.###}");
On a MovieLens-style dataset, an RMSE around 0.85–0.95 on a 1–5 scale is competitive. But here's the uncomfortable truth every senior engineer should internalise: RMSE measures the wrong thing. Your users never see a predicted rating. They see a list of ten items. A model that predicts every rating slightly wrong but ranks the top ten perfectly beats a model with better RMSE and worse ordering.
Use RMSE to catch regressions during development, then validate what actually matters with ranking metrics (Precision@K, NDCG) computed manually on held-out data — and ultimately with an A/B test measuring click-through and retention.
Critical pitfall: split your data by time, not randomly. A random split lets the model train on a user's June behaviour and be tested on their March behaviour, leaking the future into the past and inflating your metrics. Train on everything before a cutoff date, test on everything after.
Generating Top-N Recommendations
The trained model scores one (user, item) pair at a time. To produce a recommendation list, score the candidate set and sort:
var engine = mlContext.Model
.CreatePredictionEngine<MovieRating, MovieRatingPrediction>(model);
public static IEnumerable<(float MovieId, float Score)> RecommendTopN(
PredictionEngine<MovieRating, MovieRatingPrediction> engine,
float userId,
IEnumerable<float> candidateMovieIds,
HashSet<float> alreadySeen,
int n = 10)
{
return candidateMovieIds
.Where(id => !alreadySeen.Contains(id)) // never recommend what they've consumed
.Select(id => (MovieId: id,
Score: engine.Predict(
new MovieRating { UserId = userId, MovieId = id }).Score))
.OrderByDescending(x => x.Score)
.Take(n);
}
PredictionEngine is not thread-safe. Creating one per request is slow; sharing one across requests corrupts state. In ASP.NET Core, use the pooling abstraction from Microsoft.Extensions.ML:
// Program.cs
builder.Services
.AddPredictionEnginePool<MovieRating, MovieRatingPrediction>()
.FromFile(modelName: "Recommender",
filePath: "model.zip",
watchForChanges: true); // hot-reload on retrain
app.MapGet("/recommendations/{userId:int}", (
int userId,
PredictionEnginePool<MovieRating, MovieRatingPrediction> pool,
ICatalogService catalog) =>
{
var seen = catalog.GetWatchedIds(userId);
var results = catalog.GetActiveMovieIds()
.Where(id => !seen.Contains(id))
.Select(id => new
{
MovieId = id,
Score = pool.Predict("Recommender",
new MovieRating { UserId = userId, MovieId = id }).Score
})
.OrderByDescending(x => x.Score)
.Take(10);
return Results.Ok(results);
});
Save the model after training with mlContext.Model.Save(model, trainingData.Schema, "model.zip"); — the schema must be included so the pipeline can reconstruct its transforms.
Handling Implicit Feedback
Most real applications don't have star ratings. They have clicks, plays, and purchases — signals where you only observe positives. Training a standard regression on "all observed rows have label 1" produces a model that predicts 1 for everything.
ML.NET handles this with one-class matrix factorization:
var implicitOptions = new MatrixFactorizationTrainer.Options
{
MatrixColumnIndexColumnName = "userIdEncoded",
MatrixRowIndexColumnName = "productIdEncoded",
LabelColumnName = "Label", // always 1.0 for observed interactions
LossFunction = MatrixFactorizationTrainer.LossFunctionType.SquareLossOneClass,
Alpha = 0.01, // weight applied to unobserved (implicitly negative) pairs
C = 0.00001, // the target value assumed for those unobserved pairs
NumberOfIterations = 20,
ApproximationRank = 64
};
Internally this treats every unobserved (user, item) pair as a weak negative with target value C and weight Alpha. Tune Alpha first: too high and popular items dominate every list; too low and the model can't distinguish signal from absence.
Best Practices and Common Pitfalls
- Solve cold start separately. Collaborative filtering literally cannot score a user or item it has never seen — you'll get a
ScoreofNaNor 0. Always guard:if (float.IsNaN(score)) return popularityFallback;Serve trending or editorially curated content until a user has 3–5 interactions. - Retrain on a schedule, not on every write. Nightly batch retraining with
watchForChanges: truegives you fresh models with zero downtime. Online updates are rarely worth the complexity. - Filter the candidate set before scoring. Scoring a 500,000-item catalogue per request is wasteful. Pre-filter to in-stock, region-available, age-appropriate items — typically a few thousand — then score.
- Inject diversity. Pure relevance ranking creates filter bubbles. Take the top 30 by score, then apply a re-ranking pass that caps items per category. Measurable engagement almost always improves.
- Set
MLContext(seed: 42)in tests. Without a fixed seed, SGD is non-deterministic and your assertions will flake. - Don't dispose the model per request. Load once at startup; it is immutable and safe to share.
- Watch the popularity bias. Matrix factorization over-recommends blockbusters. If your top-10 lists look identical for every user, raise
Lambdaand consider down-weighting high-frequency items during training.
Key Takeaways
Building a recommendation system in C# with ML.NET is a genuinely pragmatic choice for .NET teams — not a compromise. The full path is: load interaction data, map IDs to keys, fit a MatrixFactorizationTrainer, evaluate with the regression catalogue, and serve predictions through a pooled PredictionEngine in ASP.NET Core.
Remember the four things that separate a demo from a production system: map your IDs to keys or you'll blow up memory; split your evaluation data by time or your metrics will lie; handle cold-start users with an explicit popularity fallback; and judge the model on ranking quality and real engagement, not RMSE alone. Choose SquareLossOneClass when your data is clicks rather than ratings, retrain nightly, and re-rank for diversity before the list reaches the user.
Start with the MovieLens dataset and the code above — you can have a working collaborative filtering model trained and serving recommendations in under an hour, entirely in C#.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment