
Learn how to build a recommendation system in C# using ML.NET collaborative filtering. Step-by-step tutorial with full code — start building today.
Every time Netflix suggests your next binge or Amazon shows you "customers also bought," you're seeing a recommendation system at work. These systems drive an enormous share of engagement and revenue for the world's biggest platforms — and thanks to ML.NET, you can build a production-ready recommendation system in C# without leaving the .NET ecosystem or writing a single line of Python.
In this tutorial, you'll build a movie recommendation engine using collaborative filtering with ML.NET's matrix factorization trainer. We'll cover the theory (just enough to make smart decisions), the full working code, how to evaluate your model, and the pitfalls that trip up most developers on their first attempt.
What Is a Recommendation System and How Does Collaborative Filtering Work?
A recommendation system predicts how much a user will like an item they haven't interacted with yet. There are two dominant approaches:
- Content-based filtering recommends items similar to what a user already liked, based on item attributes (genre, price, keywords). It works from day one but tends to recommend more of the same.
- Collaborative filtering recommends items based on the behavior of similar users. If you and I rate ten movies the same way, and I loved a movie you haven't seen, there's a good chance you'll love it too. No item metadata required.
Collaborative filtering is the approach behind most large-scale recommenders, and it's what ML.NET supports natively through matrix factorization.
Matrix Factorization in Plain English
Imagine a giant spreadsheet: users as rows, movies as columns, ratings in the cells. Most cells are empty — no user has rated everything. Matrix factorization decomposes this sparse matrix into two smaller dense matrices: one describing users, one describing items, each in terms of hidden "latent factors."
You never define these factors — the algorithm learns them. One factor might roughly capture "prefers action over drama," another "likes older films." Multiply a user's factor vector by a movie's factor vector and you get a predicted rating for a cell that was empty. That's the WHY behind the magic: the model learns compressed taste profiles and uses them to fill in the blanks.
Setting Up Your ML.NET Project
You need the .NET SDK (8 or later) and two NuGet packages. Create a console app and add the packages:
dotnet new console -n MovieRecommender
cd MovieRecommender
dotnet add package Microsoft.ML
dotnet add package Microsoft.ML.Recommender
Note that Microsoft.ML.Recommender is a separate package — forgetting it is the number one cause of "MatrixFactorization not found" errors.
For data, we'll use the classic MovieLens format: a CSV of userId,movieId,rating,timestamp. You can download the free MovieLens 100K dataset, or use any of your own user–item–rating data (purchases, clicks, and stars all work).
Defining the Data Models
ML.NET maps your data to strongly typed classes. Define one class for input and one for the prediction output:
using Microsoft.ML.Data;
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 — ML.NET expects "Label"
}
public class MovieRatingPrediction
{
public float Label { get; set; }
public float Score { get; set; } // the predicted rating
}
Two details matter here. First, the rating column is named Label because ML.NET trainers look for that name by convention. Second, the IDs are float even though they're logically integers — the loader reads numeric columns as single-precision floats, and we'll convert them to keys in the pipeline.
How to Build a Recommendation System in C# — the Full Pipeline
Here's the complete training program. We load the data, split it into training and test sets, map raw IDs to keys, and train the matrix factorization model:
using Microsoft.ML;
using Microsoft.ML.Trainers;
var mlContext = new MLContext(seed: 0);
// 1. Load the data
IDataView data = mlContext.Data.LoadFromTextFile<MovieRating>(
"ratings.csv", hasHeader: true, separatorChar: ',');
// 2. Split into train (80%) and test (20%)
var split = mlContext.Data.TrainTestSplit(data, testFraction: 0.2);
// 3. Build the pipeline: IDs must be converted to keys
var options = new MatrixFactorizationTrainer.Options
{
MatrixColumnIndexColumnName = "UserIdKey",
MatrixRowIndexColumnName = "MovieIdKey",
LabelColumnName = "Label",
NumberOfIterations = 30,
ApproximationRank = 64, // number of latent factors
LearningRate = 0.1,
Lambda = 0.05 // regularization
};
var pipeline = mlContext.Transforms.Conversion
.MapValueToKey("UserIdKey", nameof(MovieRating.UserId))
.Append(mlContext.Transforms.Conversion
.MapValueToKey("MovieIdKey", nameof(MovieRating.MovieId)))
.Append(mlContext.Recommendation().Trainers
.MatrixFactorization(options));
// 4. Train
Console.WriteLine("Training the model...");
ITransformer model = pipeline.Fit(split.TrainSet);
// 5. Evaluate on held-out data
var predictions = model.Transform(split.TestSet);
var metrics = mlContext.Regression.Evaluate(predictions);
Console.WriteLine($"RMSE: {metrics.RootMeanSquaredError:F3}");
Console.WriteLine($"R²: {metrics.RSquared:F3}");
Why each step exists:
- MapValueToKey converts arbitrary user and movie IDs into contiguous internal indices. Matrix factorization literally builds a matrix, so it needs row and column indices, not raw IDs like
90210. - TrainTestSplit holds back 20% of ratings the model never sees during training. Evaluating on training data would tell you nothing — any model can memorize what it was shown.
- ApproximationRank is the number of latent factors. More factors capture subtler taste patterns but risk overfitting and slow training. Values between 32 and 128 are a sensible range for most datasets.
- Lambda (regularization) penalizes extreme factor values, which keeps the model from over-explaining noise in sparse data.
Making Predictions and Generating Top-N Recommendations
A single prediction uses the PredictionEngine:
var engine = mlContext.Model
.CreatePredictionEngine<MovieRating, MovieRatingPrediction>(model);
var prediction = engine.Predict(new MovieRating
{
UserId = 6,
MovieId = 25
});
Console.WriteLine($"Predicted rating for user 6, movie 25: {prediction.Score:F2}");
In practice, users don't want one score — they want a ranked list. To build "Top 5 movies for this user," score every candidate movie the user hasn't rated and take the best:
List<(float MovieId, float Score)> RecommendTop5(
PredictionEngine<MovieRating, MovieRatingPrediction> engine,
float userId,
IEnumerable<float> candidateMovieIds)
{
return candidateMovieIds
.Select(movieId => (movieId,
engine.Predict(new MovieRating { UserId = userId, MovieId = movieId }).Score))
.OrderByDescending(x => x.Item2)
.Take(5)
.ToList();
}
For catalogs with millions of items, scoring everything per request is too slow — production systems precompute recommendations in a batch job (nightly, for example) and serve them from a cache or database. ML.NET's batch scoring via model.Transform on an IDataView of all user–item pairs is far faster than looping over Predict.
Saving and Loading the Model
Train once, serve many times. Persist the model to a file and load it in your API or worker service:
// Save after training
mlContext.Model.Save(model, split.TrainSet.Schema, "movie-recommender.zip");
// Load in your web app or service
ITransformer loadedModel = mlContext.Model.Load("movie-recommender.zip", out var schema);
In ASP.NET Core, register a PredictionEnginePool (from the Microsoft.Extensions.ML package) rather than creating engines per request — PredictionEngine is not thread-safe, and the pool handles that for you.
Evaluating Your Model: What Do the Numbers Mean?
Two metrics matter most for rating prediction:
- RMSE (Root Mean Squared Error) — the average prediction error in rating units. An RMSE of 0.9 on a 1–5 scale means predictions are off by slightly under one star on average. Lower is better; on MovieLens 100K, anything under ~1.0 is respectable.
- R² (R-squared) — how much of the rating variance the model explains. Closer to 1 is better; a negative R² means your model is worse than just predicting the average rating for everyone, which usually signals a data or configuration bug.
Remember, though: offline metrics are a proxy. A model with a slightly worse RMSE can still produce better recommendations if it surfaces diverse, relevant items. The gold standard is an online A/B test measuring clicks, watch time, or purchases.
Best Practices and Common Pitfalls
Best Practices
- Set a seed on MLContext (
new MLContext(seed: 0)) during development so runs are reproducible. Remove it in production if you want natural variance. - Tune hyperparameters systematically. Grid-search
ApproximationRank,LearningRate, andLambdaagainst your test-set RMSE instead of guessing. - Use implicit feedback when you lack ratings. Most real applications don't have star ratings — they have clicks and purchases. ML.NET supports one-class matrix factorization (
LossFunctionType.SquareLossOneClass) built exactly for "user interacted / didn't interact" data. - Retrain on a schedule. Tastes drift and new items arrive. A nightly or weekly retraining job keeps recommendations fresh.
- Filter already-consumed items before presenting recommendations — nobody wants to be recommended the movie they watched yesterday.
Common Pitfalls
- The cold-start problem. Collaborative filtering cannot recommend anything for a brand-new user or item — there's no interaction history to factorize. Mitigate it with a hybrid approach: show popular or content-based recommendations until a user has a handful of interactions, then switch to collaborative filtering.
- Skipping MapValueToKey. Feeding raw IDs straight into the trainer either fails or silently produces a bloated, meaningless matrix. Always convert IDs to keys.
- Evaluating on training data. You'll see a fantastic RMSE that evaporates in production. Always hold out a test set.
- Sharing PredictionEngine across threads. It's stateful and not thread-safe. In web apps, use
PredictionEnginePool. - Predicted scores outside the rating range. Matrix factorization can output 5.4 or 0.7 on a 1–5 scale. Clamp scores before displaying them:
Math.Clamp(prediction.Score, 1f, 5f). - Popularity bias. The model naturally favors blockbusters everyone rates highly. If discovery matters to your product, consider re-ranking to inject diversity.
Conclusion: Your Recommendation System in C# Is Just the Beginning
You've now built a complete recommendation system in C#: loading user–item–rating data, training a collaborative filtering model with ML.NET's matrix factorization trainer, evaluating it with RMSE, generating Top-N recommendations, and shipping the model in a serialized file your ASP.NET Core app can serve.
Key takeaways:
- Collaborative filtering learns from user behavior alone — no item metadata needed — by factorizing the sparse ratings matrix into latent taste factors.
- ML.NET makes this a first-class C# workflow:
MapValueToKey→MatrixFactorization→Fit→Evaluate→Save. - Hold out a test set, tune
ApproximationRankandLambda, and judge results by RMSE offline but by user engagement online. - Plan for cold starts with a popularity or content-based fallback, and precompute recommendations at scale instead of scoring per request.
From here, try the one-class trainer on implicit feedback data, wrap your model in a minimal API endpoint, or blend collaborative and content-based signals into a hybrid recommender. The full power of machine learning in C# is only a NuGet package away — grab the MovieLens dataset and start experimenting today.
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