No Python, No PhD: Train Real ML Models in C# with ML.NET (Regression, Classification, Clustering)
How Mattrx swapped its Python ML service for ML.NET — regression, classification, and clustering trained in C#, in-process, no PhD required.
- Author
- Randhir Jassal
- Published
- Reading time
- 23 min read
- Views
- 21 views
Mattrx ran its predictive features on a Python scikit-learn microservice for two years. We replaced it with ML.NET running in-process inside the existing .NET 9 app — and trained real regression, classification, and clustering models in C# with no separate service, no second language in production, and no implementing gradient descent by hand. Here's the before, the after, the actual pipelines, and the numbers.
TL;DR
If you're a .NET team that "does ML" by shipping a Python sidecar, you're paying a tax most teams never question: a second runtime, a second deploy pipeline, a second on-call surface, a cross-process network hop on every prediction, and a data contract that drifts between two languages. For classical ML — regression, classification, clustering — you usually don't need any of that. ML.NET trains and serves those models inside your existing C# process.
We moved Mattrx's three predictive features (conversion forecasting, churn prediction, tenant segmentation) from a Python/Flask + scikit-learn service into ML.NET. Same models, same accuracy, one language, one deployment, predictions in microseconds instead of a network round trip.
| Dimension | Before (Python sidecar) | After (ML.NET in-process) |
|---|---|---|
| Languages in production | C# and Python | C# only |
| Deploy pipelines | 2 (app + ML service) | 1 |
| Prediction path | HTTP to Flask → scikit-learn | in-memory call |
| Prediction p95 | 45 ms (network + serialization) | 2.8 ms |
| Model artifact | pickle in a container image | 2 MB .zip, loaded by the app |
| On-call surface | app + ML service | app only |
| "Do I need a data scientist?" | felt like it | no — it's a C# pipeline |
| Infra cost | +$160/mo for the ML service | $0 (decommissioned) |
Production metrics (8 weeks before vs after, predictive subsystem):
- Prediction latency p95: 45 ms → 2.8 ms (no cross-process hop).
- Python ML service: 1 × Azure App Service B2 → decommissioned, saving ~$160/month.
- Deploy pipelines for the prediction features: 2 → 1.
- Churn model AUC: 0.86 (precision 0.71 / recall 0.64 at the chosen threshold).
- Conversion-forecast regression: R² 0.78, RMSE 41 conversions on campaigns averaging ~600.
- Tenant segmentation: 5 clusters, average silhouette 0.52 — clean enough to name.
- Nightly retrain on a 200k-row sample: ~70 seconds end to end.
- Cross-language bugs (schema drift between C# DTO and Python feature order): ~1/month → 0.
- Lines of glue code (HTTP client, retries, serialization, the Flask app): ~600 → ~0.
The one sentence to remember: classical ML is a data-and-evaluation problem, not a math problem — and ML.NET lets you solve it in the language your app is already written in.
The one mental shift
The reason .NET teams reach for Python isn't the models — it's a belief that "real ML needs Python and a math background." For deep learning research, fair. For the bread-and-butter business ML that 90% of products actually ship — predict a number, predict a category, group similar things — it's not true.
The shift:
You don't implement algorithms; you compose a pipeline. In ML.NET you describe a sequence of data transforms followed by a trainer, call
Fit(), and callEvaluate(). You never write gradient descent, a decision-tree split, or k-means iteration. The skill that matters is data preparation and honest evaluation — and that skill is language-agnostic.
Once you see a model as data → transforms → trainer → metrics, the question "Python or C#?" becomes "which one is my app already in?" For a .NET shop, the answer is obvious, and the entire Python sidecar evaporates.
The running example: Mattrx's predictive features
Mattrx is a multi-tenant marketing-analytics SaaS — 110k MAU, Angular 19 front end, .NET 9 / ASP.NET Core back end, Clean Architecture + CQRS via MediatR, Azure SQL. Tables in play: Campaigns (~4M rows), Events (~180M), CampaignEvents (~1.2B). Five backend engineers, one SRE.
Mattrx already has an LLM AI layer — Mattrx Help (RAG over docs) and Mattrx Insights (agentic RAG), both on Semantic Kernel + Azure AI Search. This post is not about those. It's about the three classical predictive features that have nothing to do with language models:
- Conversion forecasting (regression) — given a live campaign's first-week signal, predict its end-of-month conversions, so marketers can reallocate budget early.
- Churn prediction (binary classification) — flag tenants likely to downgrade or cancel next cycle, so Customer Success can reach out.
- Tenant segmentation (clustering) — group tenants into behavioral cohorts for onboarding, pricing, and messaging.
For two years these ran on a Python scikit-learn service the .NET app called over HTTP. It worked — and it was more operational weight than the problem deserved.
Before: the architecture we replaced
BEFORE — predictions live in another language, another process
┌──────────────────┐ HTTP/JSON ┌────────────────────────┐
│ ASP.NET Core │ ───────────────────────► │ Flask + scikit-learn │
│ (C#) │ {features...} │ (Python) │
│ │ ◄─────────────────────── │ model.pkl in image │
└──────────────────┘ {prediction} └────────────────────────┘
build pipeline #1 build pipeline #2
on-call surface #1 on-call surface #2
- 45ms per call (network + (de)serialization)
- feature order in JSON must match Python's column order EXACTLY
- separate Dockerfile, separate scaling rules, separate secrets
The glue we had to maintain on the C# side alone:
// BEFORE — typed client + retry + serialization just to ask "will this churn?"
public sealed class PythonChurnClient(HttpClient http)
{
public async Task<double> PredictChurnAsync(ChurnFeatures f, CancellationToken ct)
{
// feature ORDER here must match the Python service's expected column order.
// change one and predictions silently go wrong — no compiler catches it.
using var res = await http.PostAsJsonAsync("/predict/churn", f, ct);
res.EnsureSuccessStatusCode();
var body = await res.Content.ReadFromJsonAsync<ChurnResponse>(ct);
return body!.Probability; // 45ms p95, and a network failure mode per call
}
}
Every prediction was a network call to a service in a language nobody on the backend team wrote daily.
Section 1 — Regression: forecast campaign conversions
The problem
Predict a campaign's end-of-month conversions from early signal (impressions, week-1 clicks, audience size, channel, vertical). A number out → regression.
Before (Python sidecar, abbreviated)
# BEFORE — scikit-learn in a Flask service the C# app calls over HTTP
from sklearn.ensemble import GradientBoostingRegressor
model = GradientBoostingRegressor().fit(X_train, y_train)
# served behind Flask /predict/conversions, pickled into a container image
After (ML.NET, in-process)
The whole thing — load data, build the pipeline, train, evaluate — is C# the team already reads. No math implemented; FastTree is the gradient-boosted tree trainer, you just select it.
// AFTER — ML.NET regression pipeline. No algorithm written by hand.
public sealed class ConversionInput
{
public float Impressions { get; set; }
public float Week1Clicks { get; set; }
public float AudienceSize { get; set; }
public string Channel { get; set; } = ""; // "email", "search", "social"
public string Vertical { get; set; } = "";
[ColumnName("Label")] public float Conversions { get; set; } // what we predict
}
public sealed class ConversionPrediction
{
[ColumnName("Score")] public float PredictedConversions { get; set; }
}
var ml = new MLContext(seed: 42);
IDataView data = ml.Data.LoadFromEnumerable(trainingRows); // from Azure SQL, see §5
var split = ml.Data.TrainTestSplit(data, testFraction: 0.2);
var pipeline = ml.Transforms.Categorical.OneHotEncoding("ChannelEnc", "Channel")
.Append(ml.Transforms.Categorical.OneHotEncoding("VerticalEnc", "Vertical"))
.Append(ml.Transforms.Concatenate("Features",
"Impressions", "Week1Clicks", "AudienceSize", "ChannelEnc", "VerticalEnc"))
.Append(ml.Transforms.NormalizeMinMax("Features"))
.Append(ml.Regression.Trainers.FastTree(labelColumnName: "Label",
featureColumnName: "Features"));
ITransformer model = pipeline.Fit(split.TrainSet);
// Honest evaluation on held-out data — this is the part that actually matters.
var metrics = ml.Regression.Evaluate(model.Transform(split.TestSet));
log.LogInformation("Conversion model: R²={R2:F3} RMSE={RMSE:F1}",
metrics.RSquared, metrics.RootMeanSquaredError);
// diagnostic: try several trainers cheaply, keep the best by R² on the test set
foreach (var trainer in new[] { "sdca", "fasttree", "lightgbm" }) { /* fit + evaluate, compare */ }
Mattrx metric: R² 0.78, RMSE 41 on campaigns averaging ~600 conversions — good enough for the "reallocate budget by day 8" decision it drives. Same accuracy band as the old scikit-learn model, now with no service to call.
Section 2 — Classification: predict tenant churn
The problem
Will this tenant downgrade or cancel next cycle? A category out (yes/no with a probability) → binary classification. The catch every real churn model hits: class imbalance — most tenants don't churn, so a model that always predicts "no" looks 94% accurate and is useless.
Before
A rules engine — "flag if logins dropped >40% AND no campaign in 14 days" — plus, later, the Python service. The rules missed subtle patterns; the Python service added the operational weight from Section 0.
// BEFORE — hand-tuned heuristic. Brittle, and nobody trusted the threshold.
bool atRisk = loginDropPct > 0.40 && daysSinceLastCampaign > 14;
After
ML.NET binary classification with a calibrated probability, evaluated on the metrics that survive imbalance (AUC, precision/recall — not accuracy).
// AFTER — churn classifier; FastTree handles non-linear interactions for free.
public sealed class ChurnInput
{
public float LoginsLast30 { get; set; }
public float LoginDropPct { get; set; }
public float CampaignsLast30 { get; set; }
public float SeatUtilization { get; set; }
public float DaysSinceLastCampaign { get; set; }
public float SupportTicketsLast30 { get; set; }
public string Plan { get; set; } = "";
[ColumnName("Label")] public bool Churned { get; set; }
}
public sealed class ChurnPrediction
{
[ColumnName("PredictedLabel")] public bool WillChurn { get; set; }
public float Probability { get; set; } // calibrated 0..1 — drives the CS work queue
}
var pipeline = ml.Transforms.Categorical.OneHotEncoding("PlanEnc", "Plan")
.Append(ml.Transforms.Concatenate("Features",
"LoginsLast30", "LoginDropPct", "CampaignsLast30", "SeatUtilization",
"DaysSinceLastCampaign", "SupportTicketsLast30", "PlanEnc"))
.Append(ml.Transforms.NormalizeMinMax("Features"))
.Append(ml.BinaryClassification.Trainers.FastTree(
labelColumnName: "Label", featureColumnName: "Features"));
var model = pipeline.Fit(split.TrainSet);
// Evaluate on the RIGHT metrics for imbalanced data.
var m = ml.BinaryClassification.Evaluate(model.Transform(split.TestSet));
log.LogInformation("Churn: AUC={Auc:F3} Precision={P:F2} Recall={R:F2} F1={F1:F2}",
m.AreaUnderRocCurve, m.PositivePrecision, m.PositiveRecall, m.F1Score);
// handle imbalance: weight the rare positives, and choose a threshold for the use case.
// CS can only call ~30 tenants/week → tune the threshold for PRECISION over recall.
var prediction = predictionEngine.Predict(features);
bool flag = prediction.Probability >= 0.62; // threshold chosen from the PR curve, not 0.5
-- diagnostic: sanity-check the label itself before trusting any model
SELECT plan, COUNT(*) total,
AVG(CASE WHEN churned THEN 1.0 ELSE 0 END) AS churn_rate
FROM tenant_training_view GROUP BY plan; -- imbalanced? leaking? wrong join?
Mattrx metric: AUC 0.86, precision 0.71 at the threshold CS actually works (they can contact ~30 tenants/week, so precision matters more than recall). The weekly at-risk list is now model-ranked instead of a brittle if.
Section 3 — Clustering: segment tenants (no labels needed)
The problem
Group tenants into behavioral cohorts without any predefined labels — unsupervised. Output: a cluster id per tenant → clustering (k-means).
Before
Manual SQL buckets — "enterprise vs SMB by seat count" — which captured size, not behavior. Two tenants with identical seat counts behaved nothing alike.
-- BEFORE — segmentation by a human-guessed rule. Misses behavioral cohorts.
SELECT id, CASE WHEN seats > 50 THEN 'enterprise' ELSE 'smb' END AS segment FROM tenants;
After
k-means in ML.NET over normalized behavioral features. You pick k; the trainer does the rest.
// AFTER — k-means clustering. Unsupervised: there is no Label column.
public sealed class TenantFeatures
{
public float CampaignsPerMonth { get; set; }
public float AvgAudienceSize { get; set; }
public float ReportDownloads { get; set; }
public float SeatUtilization { get; set; }
public float ApiCallsPerDay { get; set; }
}
public sealed class TenantCluster
{
[ColumnName("PredictedLabel")] public uint ClusterId { get; set; }
[ColumnName("Score")] public float[] Distances { get; set; } = [];
}
var pipeline = ml.Transforms.Concatenate("Features",
"CampaignsPerMonth", "AvgAudienceSize", "ReportDownloads",
"SeatUtilization", "ApiCallsPerDay")
.Append(ml.Transforms.NormalizeMinMax("Features")) // critical: k-means is scale-sensitive
.Append(ml.Clustering.Trainers.KMeans("Features", numberOfClusters: 5));
var model = pipeline.Fit(data);
var metrics = ml.Clustering.Evaluate(model.Transform(data), scoreColumnName: "Score");
log.LogInformation("Segmentation: AvgDistance={D:F2} DaviesBouldin={DB:F2}",
metrics.AverageDistance, metrics.DaviesBouldinIndex);
// choosing k: sweep 2..8, plot AverageDistance (elbow) + DaviesBouldinIndex (lower=better).
// We landed on 5 — beyond that the clusters stopped being nameable by the product team.
Mattrx metric: 5 clusters, average silhouette 0.52 — distinct enough that the product team named them ("power users," "dormant SMBs," "report-only," etc.) and built onboarding flows per cohort. The size-based SQL never surfaced "report-only," a high-churn group hiding inside "enterprise."
Section 4 — Serving models in production (the part tutorials skip)
A trained ITransformer is not thread-safe to predict from directly — and a web app predicts from many threads at once. The naive fix (new PredictionEngine per request) is slow and allocation-heavy. The right tool is PredictionEnginePool from Microsoft.Extensions.ML.
Before
// BEFORE — a PredictionEngine per request: not thread-safe AND slow under load
var engine = ml.Model.CreatePredictionEngine<ChurnInput, ChurnPrediction>(model);
return engine.Predict(input); // re-created per call; contention + GC churn at scale
After
Register a pooled, hot-reloadable engine in DI. Thread-safe, fast, and it can pick up a freshly retrained model file without a redeploy.
// AFTER — Program.cs: pooled prediction engine, watches the model file for updates
builder.Services.AddPredictionEnginePool<ChurnInput, ChurnPrediction>()
.FromFile(modelName: "churn", filePath: "Models/churn.zip", watchForChanges: true);
// In the handler — one in-memory call, ~microseconds, fully thread-safe:
public sealed class ChurnService(PredictionEnginePool<ChurnInput, ChurnPrediction> pool)
{
public float ScoreTenant(ChurnInput f) => pool.Predict("churn", f).Probability;
}
// the nightly retrain job (a hosted BackgroundService) — train, evaluate, gate, swap
var model = pipeline.Fit(trainSet);
var auc = ml.BinaryClassification.Evaluate(model.Transform(testSet)).AreaUnderRocCurve;
if (auc >= 0.80) // never ship a regression
{
ml.Model.Save(model, trainSet.Schema, "Models/churn.zip"); // pool hot-reloads it
log.LogInformation("Churn model promoted: AUC={Auc:F3}", auc);
}
else log.LogWarning("Retrain rejected: AUC {Auc:F3} below gate", auc);
AFTER — everything in one process
┌─────────────────────────────────────────────────────────┐
│ ASP.NET Core (.NET 9) │
│ │
│ request ─► ChurnService ─► PredictionEnginePool ─► score │ ~2.8ms, in-memory
│ ▲ │
│ nightly BackgroundService ───────┘ trains + saves │
│ Azure SQL ──► IDataView ──► Fit ──► Evaluate ──► .zip │
└─────────────────────────────────────────────────────────┘
one deploy · one language · one on-call surface
Mattrx metric: Prediction p95 45 ms → 2.8 ms; the pool removed per-request engine allocation, and watchForChanges means the nightly retrain promotes a new model with zero downtime.
Section 5 — Getting data in and avoiding the classic mistakes
Two failure modes sink more ML projects than any algorithm choice: loading data wrong and leaking the future into training.
// Load straight from your domain — no CSV export step. Project in SQL to the columns you need.
var rows = await db.Database
.SqlQuery<ChurnInput>($"SELECT * FROM tenant_training_view WHERE as_of <= {cutoff}")
.ToListAsync(ct);
IDataView data = ml.Data.LoadFromEnumerable(rows);
// Cross-validation for an honest estimate (not a single lucky split):
var cv = ml.BinaryClassification.CrossValidate(data, pipeline, numberOfFolds: 5);
var meanAuc = cv.Average(f => f.Metrics.AreaUnderRocCurve);
Data leakage is the silent killer: if tenant_training_view includes a feature that's only known after the outcome (e.g. "cancellation_reason"), your AUC will look amazing in testing and collapse in production. The fix is a discipline, not a library — every feature must be knowable at prediction time (as_of <= cutoff above).
Mattrx metric: Catching one leaked feature (a final_invoice_flag that only existed post-churn) dropped offline AUC from a too-good 0.97 to an honest 0.86 — and that honest model is the one that actually works on live tenants.
Quick reference: which ML.NET trainer?
You pick the trainer; ML.NET implements it. A pragmatic default per problem type:
| Problem | Output | Good default trainer | Reach for instead when |
|---|---|---|---|
| Regression | a number | FastTree (boosted trees) | LightGbm for large tabular; Sdca for fast linear |
| Binary classification | yes/no + probability | FastTree | LightGbm for accuracy; LbfgsLogisticRegression for an interpretable linear baseline |
| Multiclass | one of N labels | SdcaMaximumEntropy | OneVersusAll(FastTree) for non-linear boundaries |
| Clustering | group id (unsupervised) | KMeans | raise k only while clusters stay nameable |
| Recommendation | ranked items | MatrixFactorization | you have user×item interaction data |
| Anomaly detection | normal/outlier | RandomizedPca | time-series spikes → DetectAnomalyBySrCnn |
Rule of thumb we follow at Mattrx: start with FastTree / LightGbm for anything supervised and tabular — they capture non-linear feature interactions without manual feature crosses, tolerate unscaled numerics, and are hard to beat on the kind of business data most SaaS apps have. Drop to a linear trainer only when you need an interpretable baseline (e.g. "which features pushed this tenant's churn score up?").
The point isn't memorizing trainers — swapping one is a one-line change. The pipeline, data prep, and evaluation stay identical, so you can A/B two trainers in an afternoon and keep whichever wins on your held-out metric.
Aggregate Mattrx metrics
| Metric | Before | After | Delta |
|---|---|---|---|
| Languages in production | C# + Python | C# only | −1 runtime |
| Deploy pipelines (predictions) | 2 | 1 | −50% |
| Prediction p95 | 45 ms | 2.8 ms | −94% |
| ML service infra | B2 App Service | decommissioned | −$160/mo |
| Churn model AUC | ~heuristic | 0.86 | model-driven |
| Conversion R² | n/a (rules) | 0.78 | new capability |
| Segmentation clusters | 2 (size buckets) | 5 (behavioral) | richer |
| Nightly retrain time | n/a | ~70 s | automated |
| Cross-language schema bugs | ~1/mo | 0 | eliminated |
| Glue/serialization LOC | ~600 | ~0 | deleted |
The shape: a capability that felt like it needed a separate team and stack turned out to be a few hundred lines of C# in the app the team already owns.
Pre-ship checklist
- The problem is genuinely regression / classification / clustering (not deep learning or NLP — see Honest stuff).
- Features are all knowable at prediction time — no leakage from after the outcome.
- Categorical columns are one-hot (or hash) encoded; numeric features normalized (mandatory for k-means and most linear trainers).
- Evaluated on a held-out set (or cross-validation), on metrics that fit the problem — AUC/precision/recall for imbalanced classification, never raw accuracy.
- Class imbalance addressed (weighting / threshold from the PR curve, not 0.5 by default).
- Several trainers compared cheaply; the best kept by the metric that maps to the business decision.
- Serving uses
PredictionEnginePool(thread-safe), not a per-requestPredictionEngine. - Retrain is automated, gated on a metric floor, and promotes via file swap with
watchForChanges. - The model artifact (
.zip) is versioned and the training data snapshot is reproducible. - A baseline (the old heuristic) is kept to prove the model actually beats
if.
Honest stuff — when ML.NET is the wrong call
-
Deep learning and modern NLP belong in Python (or an API). Transformers, LLMs, computer vision, anything needing PyTorch/TensorFlow and a GPU training loop — ML.NET can consume an ONNX model, but you won't train a state-of-the-art net in it. Mattrx's RAG/LLM features stay on Semantic Kernel + Azure OpenAI for exactly this reason. ML.NET is for classical ML.
-
If your data scientists live in Python, don't fight that. ML.NET wins when the engineering team owns the model and the problem is classical. If a dedicated DS team iterates in notebooks daily, a Python service (or exporting to ONNX) may fit your org better. This is an org decision as much as a tech one.
-
You still need enough labeled data. ML.NET removes the plumbing, not the data requirement. A few hundred churned tenants is workable; a dozen is not. No framework rescues you from too little signal.
-
Classical ML is not always better than a good heuristic. We kept the old rule as a baseline and only shipped the model where it measurably beat it. Sometimes three
ifs are the right answer — and now you can prove it with the same evaluation harness. -
AutoML is a starting point, not the destination. ML.NET's AutoML / Model Builder is great for a first pass, but the production wins came from understanding the features and the evaluation, not from letting AutoML pick a trainer blindly.
-
What we'd do differently: invest in the training view (the SQL that defines features + label) first. 80% of the work and 100% of the leakage risk lives there — the pipeline code is the easy part.
The closing mental model
Classical ML is data engineering with an evaluation step — pick the language your app is already in. Regression, classification, and clustering are
data → transforms → trainer → metrics. ML.NET gives a .NET team all four in C#, in-process, with no second runtime to operate. Reach for Python when you genuinely need deep learning; reach for ML.NET for everything else.
Three habits this leaves you with:
- Evaluate before you celebrate. A number on the training set is meaningless. Held-out metrics, the right metrics, every time — and a baseline to beat.
- Guard against leakage like a security bug. Every feature must be knowable at prediction time. A suspiciously high AUC is a leak until proven otherwise.
- Serve with the pool, retrain on a gate.
PredictionEnginePoolfor thread-safe in-memory predictions; automated retraining that refuses to promote a model worse than the one in production.
Further reading
- Production RAG with Azure OpenAI + Azure SQL — A Complete Guide for .NET Teams — the other AI layer at Mattrx; when the problem is language, not numbers, this is the stack.
- Practical LLM Integration Patterns for Existing .NET Apps — where LLMs fit alongside the classical models in this post.
- Data Access in .NET — EF Core, LINQ, and Dapper — how the training view in Section 5 is queried efficiently.
- Scaling ASP.NET Core APIs to 100,000 Requests Per Minute — serving predictions in-process is part of why the API tier stays fast.
- Clean Architecture in C# — A Complete Guide with Real Code — where the ML service and retrain job sit in the layering.
- ML.NET docs — "How to choose an ML.NET algorithm" and the
Microsoft.Extensions.MLintegration guide.
Sitting on a Python ML sidecar you suspect a .NET shop doesn't need — or stuck on whether your problem is even a classical-ML problem? Email randhir.jassal@gmail.com with the prediction you're trying to make and your data shape, and I'll tell you whether ML.NET fits or you genuinely need Python.
Get the next issue
A short, curated email with the newest posts and questions.