.NET 机器学习实战

讲解在 .NET 生态中落地机器学习的方法,涵盖 ML.NET 训练管线、模型评估与调参、ONNX Runtime 高性能推理、模型部署与版本管理,以及与 Python 生态的协作方式。

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、FastTreeAUC、F1、准确率
多分类SdcaMaximumEntropy、LbfgsMaximumEntropy微平均准确率、对数损失
回归FastTree、Sdca、LbfgsPoissonRegressionRMSE、MAE、R2
聚类KMeans平均距离、Davies-Bouldin
异常检测RandomizedPcaAUC、检测率
排序LightGbmNDCG

避坑: 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())
导出源工具注意事项
PyTorchtorch.onnx.export动态轴要显式声明
TensorFlowtf2onnx控制流算子兼容性
scikit-learnskl2onnx自定义转换器需注册
XGBoost/LightGBMonnxmltools树模型算子成熟
HuggingFaceoptimum大模型注意显存

避坑: 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 契约、发布控制、图查询、分布式事务、内存诊断走到了机器学习——它们共同构成了一条从「接口稳定」到「系统可靠」再到「能力扩展」的完整工程链路。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. 内存剖析与 dump 分析
  2. 分布式事务与 Saga 编排
  3. GraphQL 服务端开发