Skip to main content

Build an AI Recommendation System in C# with ML.NET

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 Score of NaN or 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: true gives 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 Lambda and 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#.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

.NET MAUI Tutorial 2026: Build Cross-Platform Apps in C#

Learn .NET MAUI in 2026 to build iOS, Android, Windows & Mac apps from one C# codebase. Start this cross-platform tutorial with code examples today. .NET MAUI (Multi-platform App UI) is Microsoft's framework for building native iOS, Android, Windows, and macOS apps from a single C# codebase . If you've ever wanted to ship a mobile app without learning Swift, Kotlin, and Win32 separately, this .NET MAUI tutorial for 2026 is your starting point. In this guide you'll learn what .NET MAUI is, why it matters for cross-platform app development in C#, and how to build your first working app — with runnable code examples and the best practices senior engineers actually use in production. What Is .NET MAUI and Why Use It in 2026? .NET MAUI is the evolution of Xamarin.Forms, fully integrated into the modern .NET runtime. With one project and one language — C# — you target four platforms. The framework compiles to native UI controls on each device, so a button on iOS...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...