1. 为什么在 .NET 里做机器学习
一句话总结: 训练可以留在 Python,但推理放在 .NET 服务里能省掉一次跨语言调用、一次序列化,以及与现有业务逻辑统一的部署与监控链路。
多数团队的模型训练在 Python 完成,这没有问题——生态、库、论文复现都在那边。真正值得讨论的是推理放在哪里。如果模型要服务的业务系统本身就是 .NET 写的,把推理搬到 Python 服务里意味着多一次网络跳转、多一套部署与监控、多一份序列化开销,还要维护跨语言的契约一致性。
在 .NET 里做推理有三条路:ML.NET 自带的算法(适合传统机器学习)、ONNX Runtime(适合从 Python 导出的任意模型)、以及调用外部模型服务(适合超大模型)。
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 表格数据分类回归 | ML.NET 原生算法 | 无需 Python,训练推理一体 |
| 推荐、异常检测 | ML.NET + 自定义 | 内置算法覆盖常见任务 |
| 深度学习推理 | ONNX Runtime | 直接消费 PyTorch/TF 导出模型 |
| 大语言模型 | 外部服务或 ONNX + GPU | 资源需求超出进程内推理 |
| 特征工程复用 | Python 训练 + ONNX 导出 | 保证线上线下特征一致 |
| 快速原型验证 | AutoML(ML.NET CLI) | 少写代码先跑通 |
// 最小示例:进程内训练一个二分类模型并立即推理
var mlContext = new MLContext(seed: 42);
var data = mlContext.Data.LoadFromTextFile<SentimentData>(
"sentiment.csv", hasHeader: true, separatorChar: ',');
var pipeline = mlContext.Transforms.Text
.FeaturizeText("Features", nameof(SentimentData.Text))
.Append(mlContext.BinaryClassification.Trainers.SdcaLogisticRegression(
labelColumnName: nameof(SentimentData.Label)));
var model = pipeline.Fit(data); // 训练完成,可立即用于预测
避坑: 不要因为「.NET 也能训练」就把所有模型训练都搬到 ML.NET。深度学习训练在 .NET 里的生态远不如 PyTorch,强行迁移只会拖慢迭代。合理分工是:训练在 Python,导出为 ONNX,推理在 .NET;只有当模型是传统机器学习(GBDT、线性模型、聚类)时,用 ML.NET 端到端训练才真正省事。
2. ML.NET 训练管线
一句话总结: ML.NET 的管线是「数据加载 → 特征转换 → 训练器 → 评估」的声明式组合,管线本身可序列化,训练与推理共用同一份定义。
管线的核心价值是一致性:训练时怎么处理数据,推理时就怎么处理。把特征转换写进管线并保存下来,就杜绝了「训练用归一化、推理忘了归一化」这类经典事故。
// 完整的训练管线:数据划分 + 特征工程 + 训练 + 评估
public sealed class ModelTrainer
{
public (ITransformer Model, DataViewSchema Schema) Train(string dataPath)
{
var ctx = new MLContext(seed: 42);
var data = ctx.Data.LoadFromTextFile<HousingData>(
dataPath, hasHeader: true, separatorChar: ',');
var split = ctx.Data.TrainTestSplit(data, testFraction: 0.2, seed: 42);
var pipeline = ctx.Transforms.Categorical
.OneHotEncoding("NeighborhoodEncoded", nameof(HousingData.Neighborhood))
.Append(ctx.Transforms.NormalizeMinMax("SizeNorm", nameof(HousingData.Size)))
.Append(ctx.Transforms.Concatenate("Features",
"NeighborhoodEncoded", "SizeNorm", nameof(HousingData.Rooms)))
.Append(ctx.Regression.Trainers.FastTree(
labelColumnName: nameof(HousingData.Price), numberOfLeaves: 32));
var model = pipeline.Fit(split.TrainSet);
return (model, data.Schema);
}
}
// 评估:用留出集计算指标,而不是训练集
var predictions = model.Transform(split.TestSet);
var metrics = ctx.Regression.Evaluate(predictions, labelColumnName: "Price");
Console.WriteLine($"RMSE: {metrics.RootMeanSquaredError:F2}");
Console.WriteLine($"R2: {metrics.RSquared:F3}");
// 分类任务换用对应评估器
var clsMetrics = ctx.BinaryClassification.Evaluate(predictions, labelColumnName: "Label");
Console.WriteLine($"AUC: {clsMetrics.AreaUnderRocCurve:F3}");
Console.WriteLine($"F1: {clsMetrics.F1Score:F3}");
| 任务 | 训练器 | 评估指标 |
|---|---|---|
| 二分类 | SdcaLogisticRegression、FastTree | AUC、F1、准确率 |
| 多分类 | SdcaMaximumEntropy、LbfgsMaximumEntropy | 微平均准确率、对数损失 |
| 回归 | FastTree、Sdca、LbfgsPoissonRegression | RMSE、MAE、R2 |
| 聚类 | KMeans | 平均距离、Davies-Bouldin |
| 异常检测 | RandomizedPca | AUC、检测率 |
| 排序 | LightGbm | NDCG |
避坑:
TrainTestSplit只做一次划分时,如果数据有时间顺序(如按天的日志),随机划分会让模型「用未来预测过去」,评估结果虚高。这类数据必须按时间切分:用早期数据训练、晚期数据评估。另一个坑是NormalizeMinMax等转换器会记住训练集的统计量,如果推理时手工处理数据而没走管线,分布就会对不上——这也是必须保存完整管线而非只保存训练器的原因。
3. 模型评估与调参
一句话总结: 评估要看业务指标而不只是技术指标,调参要有明确的搜索空间与验证策略,否则容易过拟合验证集。
评估阶段最容易被忽略的是基线。一个 AUC 0.85 的模型听起来不错,但如果「全部预测为正」的朴素策略就有 0.80,那这个模型几乎没有价值。先建立基线,再谈提升。
// 交叉验证:比单次划分更稳,尤其适合数据量不大的场景
var cvResults = ctx.BinaryClassification.CrossValidate(
data, pipeline, numberOfFolds: 5, labelColumnName: "Label");
var avgAuc = cvResults.Average(r => r.Metrics.AreaUnderRocCurve);
var stdAuc = Math.Sqrt(cvResults.Average(r =>
Math.Pow(r.Metrics.AreaUnderRocCurve - avgAuc, 2)));
Console.WriteLine($"AUC 均值 {avgAuc:F3},标准差 {stdAuc:F3}");
// 标准差大说明模型对数据划分敏感,需要更多数据或更简单的模型
// 网格搜索:小规模超参搜索,用交叉验证选最优
var options = new[] { (Leaves: 16, MinDocs: 10), (Leaves: 32, MinDocs: 20), (Leaves: 64, MinDocs: 50) };
var best = options
.Select(o => new
{
Option = o,
Score = ctx.Regression.CrossValidate(data, ctx.Transforms
.Concatenate("Features", "SizeNorm", "Rooms")
.Append(ctx.Regression.Trainers.FastTree(
labelColumnName: "Price", numberOfLeaves: o.Leaves,
minimumExampleCountPerLeaf: o.MinDocs)), numberOfFolds: 5)
.Average(r => r.Metrics.RootMeanSquaredError),
})
.OrderBy(r => r.Score) // RMSE 越小越好
.First();
Console.WriteLine($"最优参数:{best.Option},RMSE {best.Score:F2}");
| 评估维度 | 技术指标 | 业务指标 |
|---|---|---|
| 分类 | AUC、F1、精确率召回率 | 拦截率、误伤率、成本节省 |
| 回归 | RMSE、MAE、R2 | 预测偏差的金额影响 |
| 排序 | NDCG、MAP | 点击率、转化率 |
| 异常 | AUC、检测率 | 漏报率、告警噪音比 |
| 通用 | 交叉验证标准差 | 线上 A/B 效果 |
避坑: 反复用同一份验证集调参,本质是在对验证集过拟合——几十次迭代后,验证指标会明显高于真实线上表现。规范做法是划分「训练 / 验证 / 测试」三份,调参只看验证集,最终只在测试集上评估一次。另外,类别不平衡时准确率会严重误导(99% 负样本时全预测为负就有 99% 准确率),必须看 AUC 或 F1。
4. ONNX Runtime 推理
一句话总结: ONNX 是跨框架的模型交换格式,ONNX Runtime 在 .NET 里提供高性能推理,把训练框架与部署环境彻底解耦。
ONNX(Open Neural Network Exchange)把模型表达为计算图,PyTorch、TensorFlow、scikit-learn 都能导出。ONNX Runtime 负责在目标平台上高效执行这张图,支持 CPU、GPU、以及图优化(算子融合、常量折叠)。
// 加载 ONNX 模型并推理
public sealed class OnnxPredictor : IDisposable
{
private readonly InferenceSession _session;
public OnnxPredictor(string modelPath)
{
var options = new SessionOptions
{
GraphOptimizationLevel = GraphOptimizationLevel.ORT_ENABLE_ALL,
IntraOpNumThreads = Environment.ProcessorCount,
};
_session = new InferenceSession(modelPath, options);
}
public float[] Predict(float[] features)
{
// 输入名与形状必须与导出时一致,可用 session.InputMetadata 查询
var input = new DenseTensor<float>(features, new[] { 1, features.Length });
var inputs = new[] { NamedOnnxValue.CreateFromTensor("float_input", input) };
using var results = _session.Run(inputs);
return results.First().AsEnumerable<float>().ToArray();
}
public void Dispose() => _session.Dispose();
}
# 训练侧:用 skl2onnx 把 scikit-learn 模型导出为 ONNX
from skl2onnx import convert_sklearn
from skl2onnx.common.data_types import FloatTensorType
onnx_model = convert_sklearn(
clf,
initial_types=[("float_input", FloatTensorType([None, n_features]))],
target_opset=17)
with open("model.onnx", "wb") as f:
f.write(onnx_model.SerializeToString())
| 导出源 | 工具 | 注意事项 |
|---|---|---|
| PyTorch | torch.onnx.export | 动态轴要显式声明 |
| TensorFlow | tf2onnx | 控制流算子兼容性 |
| scikit-learn | skl2onnx | 自定义转换器需注册 |
| XGBoost/LightGBM | onnxmltools | 树模型算子成熟 |
| HuggingFace | optimum | 大模型注意显存 |
避坑: ONNX 导出最常见的失败是动态形状——训练时 batch 维度是动态的,导出时若固定为 1,推理时传不同 batch 就会报错。导出时必须显式声明动态轴。另一个坑是算子版本:目标 opset 太新,运行时版本太旧会拒绝加载;建议导出时指定一个比运行时支持的 opset 略低的版本。还有,导出后务必用同一批测试数据对比 Python 与 .NET 的输出,确认数值误差在容忍范围内。
5. 模型部署与版本管理
一句话总结: 模型是产物而非代码,必须像管理二进制一样管理它的版本、元数据与回滚能力,否则线上问题无法归因。
模型部署的核心问题是可追溯:线上这次预测用的是哪个版本的模型、哪份特征配置、哪个训练数据集?没有这些元数据,模型效果下降时无从排查。
// 模型注册表:把模型文件与元数据一起管理
public sealed record ModelArtifact(
string Name,
string Version,
string Path,
string Sha256,
DateTimeOffset TrainedAt,
IReadOnlyDictionary<string, double> Metrics,
string FeatureSchemaVersion);
public sealed class ModelRegistry(string root)
{
public ModelArtifact? Resolve(string name, string? version = null)
{
var dir = Path.Combine(root, name);
if (!Directory.Exists(dir)) return null;
var target = version ?? Directory.GetDirectories(dir)
.OrderByDescending(Path.GetFileName).First();
var meta = Path.Combine(target, "metadata.json");
return JsonSerializer.Deserialize<ModelArtifact>(File.ReadAllText(meta));
}
}
// 在 ASP.NET Core 中托管模型:单例加载 + 热更新
builder.Services.AddSingleton<ModelRegistry>(_ => new ModelRegistry("/models"));
builder.Services.AddSingleton<IModelProvider>(sp =>
{
var artifact = sp.GetRequiredService<ModelRegistry>().Resolve("sentiment", "latest")
?? throw new InvalidOperationException("模型未注册");
return new OnnxModelProvider(artifact); // 内部持有 InferenceSession
});
app.MapPost("/predict", (PredictRequest req, IModelProvider provider) =>
Results.Ok(provider.Predict(req.Features)));
| 管理维度 | 做法 | 目的 |
|---|---|---|
| 版本号 | 语义化或时间戳 | 可追溯到具体产物 |
| 校验和 | SHA256 | 检测文件损坏与篡改 |
| 元数据 | 训练时间、数据版本、指标 | 效果下降时归因 |
| 特征契约 | 特征名与顺序版本 | 防止线上线下不一致 |
| 回滚 | 保留上一版并可切换 | 快速止损 |
| 灰度 | 按流量比例切模型 | 新模型小范围验证 |
避坑: 把
.onnx文件直接打包进应用镜像看似方便,但会让「换模型」变成「重新构建并部署整个应用」,既慢又无法独立回滚。更好的做法是把模型放在对象存储或独立卷里,应用启动时加载并校验 SHA256,配合一个轻量的「切换版本」接口。另外,InferenceSession是线程安全的,应当作为单例复用——每次请求都新建会带来巨大的初始化开销(加载图、优化算子)。
6. 与 Python 生态协作
一句话总结: 让 Python 负责训练与实验,.NET 负责服务化与推理,两者通过 ONNX 与特征契约对接,避免在 .NET 里重建整个数据科学工具链。
协作的关键是契约先行:特征的定义、顺序、类型、预处理逻辑必须在两侧一致。最常见的失败是 Python 侧训练时做了缺失值填充与独热编码,.NET 侧推理时忘了其中一步,导致输入分布漂移、预测全错。
# Python 侧导出「特征契约」,与模型一起交付
import json
feature_contract = {
"version": "3",
"features": [
{"name": "size", "type": "float32", "impute": "median", "scale": "minmax"},
{"name": "rooms", "type": "float32", "impute": "median", "scale": "none"},
{"name": "neighborhood", "type": "category",
"categories": ["downtown", "suburb", "rural"]},
],
"target": {"name": "price", "type": "float32"},
}
with open("feature_contract.json", "w", encoding="utf-8") as f:
json.dump(feature_contract, f, ensure_ascii=False, indent=2)
// .NET 侧按契约构建特征向量,顺序与类型严格对齐
public sealed class FeatureBuilder(FeatureContract contract)
{
public float[] Build(HousingInput input)
{
var values = new List<float>(contract.Features.Count);
foreach (var f in contract.Features)
{
values.Add(f.Name switch
{
"size" => (float)((input.Size - f.Min) / (f.Max - f.Min)), // minmax
"rooms" => input.Rooms,
"neighborhood" => f.Categories.IndexOf(input.Neighborhood), // 与训练同序
_ => throw new InvalidOperationException($"未知特征 {f.Name}"),
});
}
return values.ToArray();
}
}
| 协作方式 | 延迟 | 一致性风险 | 适用 |
|---|---|---|---|
| ONNX 导出 + .NET 推理 | 最低 | 中(需校验) | 常规生产部署 |
| Python 服务 + HTTP 调用 | 中 | 低(单一实现) | 复杂预处理 |
| Python.NET 进程内互操作 | 低 | 低 | 原型验证 |
| 消息队列异步批推理 | 高 | 低 | 离线批处理 |
| 双方共用特征存储 | 中 | 最低 | 特征平台成熟时 |
避坑: 特征顺序错位是最隐蔽的一类 bug——模型不报错,只是预测结果莫名其妙。防御手段有三层:把特征名写进契约并在两侧断言顺序、导出后用同一批样本对比 Python 与 .NET 的输出(误差应小于 1e-5)、线上加输入分布监控(特征均值突变即告警)。另外,类别型特征的编码顺序必须与训练时完全一致,不能依赖字典序巧合。
7. 性能与生产注意事项
一句话总结: 推理性能取决于会话复用、批处理、线程配置与内存布局,生产部署还要考虑模型体积、启动时间与 AOT 兼容性。
性能优化的第一原则是复用 InferenceSession,第二原则是批处理——单条推理无法充分利用向量化,把并发请求攒成一批能显著提升吞吐。第三原则是避免无谓的数组拷贝,特征构造直接写入张量缓冲区。
// 批处理推理:攒批 + 单次 Run,吞吐远高于逐条调用
public sealed class BatchPredictor(InferenceSession session) : IDisposable
{
private readonly Channel<(float[][] Input, TaskCompletionSource<float[][]> Tcs)> _queue
= Channel.CreateBounded<(float[][], TaskCompletionSource<float[][]>)>(1000);
public Task<float[][]> PredictAsync(float[][] features)
{
var tcs = new TaskCompletionSource<float[][]>(
TaskCreationOptions.RunContinuationsAsynchronously);
if (!_queue.Writer.TryWrite((features, tcs)))
tcs.SetException(new InvalidOperationException("推理队列已满"));
return tcs.Task;
}
public async Task RunAsync(CancellationToken ct)
{
var buffer = new List<(float[][], TaskCompletionSource<float[][]>)>(64);
while (await _queue.Reader.WaitToReadAsync(ct))
{
buffer.Clear();
while (buffer.Count < 64 && _queue.Reader.TryRead(out var item))
buffer.Add(item);
if (buffer.Count == 0) continue;
var flat = buffer.SelectMany(b => b.Item1).SelectMany(x => x).ToArray();
var tensor = new DenseTensor<float>(flat,
new[] { buffer.Sum(b => b.Item1.Length), buffer[0].Item1[0].Length });
using var results = session.Run(new[]
{
NamedOnnxValue.CreateFromTensor("float_input", tensor),
});
_ = results; // 拆分结果并按序完成各 Task,此处省略
}
}
public void Dispose() => session.Dispose();
}
| 优化点 | 做法 | 预期收益 |
|---|---|---|
| 会话复用 | 单例持有 InferenceSession | 省去初始化开销 |
| 批处理 | 攒批后单次 Run | 吞吐提升数倍 |
| 线程配置 | IntraOpNumThreads 匹配 CPU | 避免过度切换 |
| 张量复用 | 复用 DenseTensor 缓冲 | 减少 GC 压力 |
| 图优化 | ORT_ENABLE_ALL | 算子融合提速 |
| 量化 | 训练后 INT8 量化 | 体积减半、提速 |
| AOT 兼容 | 检查 ORT 与裁剪兼容性 | 减小镜像体积 |
避坑: ONNX Runtime 依赖原生库,在
PublishTrimmed或 Native AOT 下可能因为反射被裁剪而运行时失败,需要显式保留相关程序集。另外,模型文件通常有几十到几百 MB,直接打进容器镜像会显著拖慢启动与拉取,应放在镜像外的挂载卷。最后,GPU 推理需要匹配 CUDA 版本与驱动,容器里还要申请 GPU 资源,复杂度远高于 CPU,只有当延迟要求确实无法用 CPU 满足时才值得引入。
8. 总结
| 环节 | 要点 |
|---|---|
| 定位 | 训练在 Python,推理在 .NET,减少跨语言与跨进程开销 |
| 管线 | ML.NET 管线可序列化,训练与推理共用同一份定义 |
| 评估 | 先建基线,交叉验证看稳定性,调参不碰测试集 |
| ONNX | 跨框架导出,注意动态形状、opset 版本与数值校验 |
| 部署 | 模型是产物,管理版本、校验和、元数据与回滚 |
| 协作 | 特征契约先行,两侧顺序与编码严格对齐 |
| 性能 | 会话复用、批处理、张量复用、按需量化与 AOT 适配 |
在 .NET 里做机器学习的关键不是「重造 Python 生态」,而是找准自己的位置:把训练留在工具最成熟的地方,把推理与业务逻辑放在同一个进程里,用 ONNX 与特征契约做接缝。这样既拿到了 .NET 在部署、监控、类型安全上的优势,又不必与数据科学团队的既有工作流对抗。至此,本批扩展从 API 契约、发布控制、图查询、分布式事务、内存诊断走到了机器学习——它们共同构成了一条从「接口稳定」到「系统可靠」再到「能力扩展」的完整工程链路。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。