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.
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 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 it.
Before vs after
| Dimension | Before (Python sidecar) | After (ML.NET in-process) |
|---|---|---|
| Languages in production | C# and Python | C# only |
| Deploy pipelines | 2 | 1 |
| Prediction path | HTTP to Flask -> scikit-learn | in-memory call |
| Prediction p95 | 45 ms | 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 |
| Infra cost | +$160/mo for the ML service | $0 (decommissioned) |
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 ship — predict a number, predict a category, group similar things — it's not true.
You don't implement algorithms; you compose a pipeline. You describe data -> transforms -> trainer -> metrics, call Fit(), call Evaluate(). You never write gradient descent, a tree split, or a k-means iteration. The skill that matters is data preparation and honest evaluation — and that's language-agnostic.
Regression — forecast campaign conversions
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);
var metrics = ml.Regression.Evaluate(model.Transform(split.TestSet));
FastTree is the gradient-boosted tree trainer — you just select it. Result: R² 0.78, RMSE 41 on campaigns averaging ~600 conversions. Same accuracy band as the old scikit-learn model, now with no service to call.
Classification — predict tenant churn
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. Evaluate on AUC / precision / recall — never raw accuracy.
var m = ml.BinaryClassification.Evaluate(model.Transform(split.TestSet));
// AUC, PositivePrecision, PositiveRecall, F1
// CS can only call ~30 tenants/week -> tune the threshold for PRECISION:
bool flag = prediction.Probability >= 0.62; // from the PR curve, not 0.5
Result: AUC 0.86, precision 0.71 at the threshold CS actually works. The weekly at-risk list is model-ranked instead of a brittle if.
Clustering — segment tenants (no labels)
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));
Result: 5 clusters, silhouette 0.52 — distinct enough the product team named them ("power users," "dormant SMBs," "report-only"). The old size-based SQL never surfaced "report-only," a high-churn group hiding inside "enterprise."
Serving in production (the part tutorials skip)
A trained ITransformer is not thread-safe to predict from directly. Use PredictionEnginePool — thread-safe, fast, hot-reloadable:
builder.Services.AddPredictionEnginePool<ChurnInput, ChurnPrediction>()
.FromFile(modelName: "churn", filePath: "Models/churn.zip", watchForChanges: true);
The nightly retrain job trains, evaluates, and gates on a metric floor before swapping the file — never ship a regression:
if (auc >= 0.80) ml.Model.Save(model, trainSet.Schema, "Models/churn.zip"); // pool hot-reloads
Result: prediction p95 45 ms -> 2.8 ms, zero-downtime model promotion.
The classic mistake: data leakage
Catching one leaked feature (a final_invoice_flag that only existed after churn) dropped offline AUC from a too-good 0.97 to an honest 0.86 — and the honest model is the one that works on live tenants. Every feature must be knowable at prediction time. A suspiciously high AUC is a leak until proven otherwise.
When ML.NET is the wrong call
Deep learning, transformers, LLMs, computer vision belong in Python (or an API) — ML.NET can consume an ONNX model but won't train a state-of-the-art net. If your data scientists live in Python daily, don't fight that. ML.NET wins when the engineering team owns the model and the problem is classical.
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.
The full guide has the before/after architecture diagrams, every pipeline in full, the trainer cheat-sheet, the pre-ship checklist, and the aggregate Mattrx metrics:
https://prepstack.co.in/blog/no-python-no-phd-train-ml-models-csharp-mlnet
Originally published on PrepStack.
Top comments (0)