No Python, No PhD: Train Real ML Models in C# with ML.NET (Regression, Classification, Clustering) Mattrx replaced its Python scikit-learn microservice with ML.NET running in-process inside its existing .NET 9 application, training regression, classification, and clustering models entirely in C#. The migration cut prediction p95 latency from 45 ms to 2.8 ms, eliminated a second deploy pipeline and on-call surface, and removed $160/month in infrastructure cost, with the regression model matching the prior scikit-learn accuracy band (R² 0.78, RMSE 41). 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. | 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 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. js 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. 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. js 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 . js 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." A trained ITransformer is not thread-safe to predict from directly. Use PredictionEnginePool — thread-safe, fast, hot-reloadable: builder.Services.AddPredictionEnginePool