Connect(); 2018 特刊

第 33 卷,第 13 期

机器学习 - ML.NET:面向 .NET 开发人员的机器学习框架

作者 James McCaffrey

ML.NET 库仍处于预览阶段。所有信息可能都会发生变更。

ML.NET 库是包含机器学习 (ML) 代码的新开放源代码集合,可用于创建功能强大的预测系统。为了简化编程过程,许多 ML 库都是使用 C++ 和 Python API 编写的。例如,scikit-learn、TensorFlow、CNTK 和 PyTorch。不过,如果你使用基于 Python 的 ML 库来创建预测模型,那么 .NET 应用程序使用已定型模型可能就没那么容易了。幸运的是,ML.NET 库可以在 .NET 应用程序中直接使用。而且,由于 ML.NET 可以在 .NET Core 上运行,因此也可以创建适用于 macOS 和 Linux 的预测系统。

了解本文所述观点的一个好方法是查看图 1 中的演示程序。演示程序创建 ML 模型,以根据年龄、性别和政治倾向(保守、温和、自由)预测一个人的年收入。由于目标是预测数值,因此这是回归问题示例。如果目标是根据年龄、性别和收入预测政治倾向,这就是分类问题示例。

ML.NET 旧版演示程序实际效果
图 1:ML.NET 旧版演示程序实际效果

演示程序使用一组包含 30 个项的虚拟定型数据。定型后,模型应用于源数据,且均方根误差为 1.2630。解释此误差值本身很难,最好使用回归误差比较不同的模型。

最后,演示程序使用已定型模型,以预测一位有保守政治倾向的 40 岁男性的年收入。预测年收入为 72,401.38 美元。图 1 中的演示程序是使用 ML.NET 旧版方法编写的,可作为新手了解 ML.NET 的不错起点。在这篇介绍性文章的第二部分,我将介绍一种更新的方法,虽然有点更难掌握,但却是更好的新开发方法。

若要更好地理解本文,至少必须拥有中等水平的 C# 编程技能,但无需对 ML.NET 库有任何了解。本文提供了演示程序的完整代码和数据,也可以通过下载随附的文件获取代码和数据。在我撰写本文时,ML.NET 库仍处于预览模式,且开发速度非常快。因此,等到大家阅读本文时,本文介绍的部分信息可能已稍有变更。

演示程序

为了创建演示程序,我启动了 Visual Studio 2017。ML.NET 库将与 Visual Studio 2017 的免费 Community 版本或任一商业版结合使用。ML.NET 文档指出,必须使用 Visual Studio 2017;事实上,我无法结合使用演示程序与 Visual Studio 2015。我新建了 C# 控制台应用程序项目,并将它命名为“IncomePredict”。ML.NET 库将与经典 .NET Framework 或 .NET Core 应用程序类型结合使用。

在模板代码加载后,我右键单击了“解决方案资源管理器”窗口中的文件 Program.cs,并将文件重新命名为“IncomeProgram.cs”,然后我允许 Visual Studio 自动为我重新命名 Program 类。接下来,在“解决方案资源管理器”窗口中,我右键单击了“IncomePredict”项目,并选择了“管理 NuGet 包”选项。在 NuGet 窗口中,我选择了“浏览”选项卡,然后在“搜索”字段中输入了“ML.NET”。ML.NET 库位于 Microsoft.ML 包中。我选择了最新版本 (0.7.0),并单击了“安装”按钮。几秒钟后,Visual Studio 返回了响应,并显示消息“已成功将 Microsoft.ML 0.7.0 安装到 IncomePredict”。

此时,我执行了“生成 |重新生成解决方案”并收到了“仅支持 x64 体系结构”错误消息。在“解决方案资源管理器”窗口中,我右键单击了“IncomePredict”项目,并选择了“属性”条目。在“属性”窗口中,我选择了左侧的“生成”选项卡,并将“平台目标”条目从“任何 CPU”更改为“x64”。 我还确保了已定目标到 .NET Framework 版本 4.7。在旧版 Framework 的情况下,我收到了与数学库依赖项相关的错误。我再次执行了“生成 | 重新生成”解决方案,结果成功了。使用 ML.NET 等处于预览模式的库时,应该会遇到不少这样的问题。

演示数据

创建演示程序的框架后,下一步是创建定型数据文件。图 2**** 显示了此数据。如果你在跟着我一起操作,请右键单击“解决方案资源管理器”窗口中的“IncomePredict”项目,依次选择“添加 | 新文件夹”,并将文件夹命名为“数据”。 将数据放入名为“数据”的文件夹虽是可选的,但却是标准做法。右键单击“数据”文件夹,并依次选择“添加 | 新项”。在“新项”对话框窗口中,选择“文本文件”类型,并将它命名为“PeopleData.txt”。

图 2:人员数据

48, +1, 4.40, liberal
60, -1, 7.89, conservative
25, -1, 5.48, moderate
66, -1, 3.41, liberal
40, +1, 8.05, conservative
44, +1, 4.56, liberal
80, -1, 5.91, liberal
52, -1, 6.69, conservative
56, -1, 4.01, moderate
55, -1, 4.48, liberal
72, +1, 5.97, conservative
57, -1, 6.71, conservative
50, -1, 6.40, liberal
80, -1, 6.67, moderate
69, +1, 5.79, liberal
39, -1, 9.42, conservative
68, -1, 7.61, moderate
47, +1, 3.24, conservative
18, +1, 4.29, liberal
79, +1, 7.44, conservative
44, -1, 2.55, liberal
52, +1, 4.71, moderate
55, +1, 5.56, liberal
76, -1, 7.80, conservative
32, -1, 5.94, liberal
46, +1, 5.52, moderate
48, -1, 7.25, conservative
58, +1, 5.71, conservative
44, +1, 2.52, liberal
68, -1, 8.38, conservative

将图 2 中的数据复制并粘贴到编辑器窗口中,请注意不要有任何多余的尾随空行。

这一包含 30 项的数据集是人工数据集。第一列是人员年龄。第二列指明人员性别,已预编码为男性 = -1 且女性 = +1。由于 ML.NET 库包含用于编码文本数据的方法,因此数据可以使用“male”和“female”。 第三列是要预测的年收入值除以 10,000。最后一列指定人员的政治倾向(保守、温和还是自由)。

由于数据有三个预测变量(年龄、性别和政治倾向),因此无法在二维图中显示数据。但可以通过查看图 3**** 中只包含年龄和年收入的图,很好地了解数据结构。此图说明,无法通过年龄本身准确预测收入。

收入数据
图 3:收入数据

在“数据”文件夹中创建定型数据后,应创建用于保留已保存模型的“模型”文件夹,因为演示程序代码假定“模型”文件夹已存在。

程序代码

图 4 显示了完整的演示代码以及少量小幅改动,以节省空间。将模板代码加载到 Visual Studio 后,我在“编辑器”窗口顶部删除了所有命名空间引用,并将它们替换为代码列表中的引用。各种 Microsoft.ML 命名空间存放所有 ML.NET 功能。必须有 Threading.Tasks 命名空间,才能将已定型 ML.NET 旧版模型保存或加载到文件中。

图 4:ML.NET 旧版示例程序

using System;
using Microsoft.ML.Runtime.Api;
using Microsoft.ML.Legacy;
using Microsoft.ML.Legacy.Data;
using Microsoft.ML.Legacy.Transforms;
using Microsoft.ML.Legacy.Trainers;
using Microsoft.ML.Legacy.Models;
using System.Threading.Tasks;
// Microsoft.ML 0.7.0  Framework 4.7 Build x64
namespace IncomePredict
{
  class IncomeProgram
  {
    public class IncomeData {
      [Column("0")] public float Age;
      [Column("1")] public float Sex;
      [Column("2")] public float Income;
      [Column("3")] public string Politic;
    }
    public class IncomePrediction {
      [ColumnName("Score")]
      public float Income;
    }
    static void Main(string[] args)
    {
      Console.WriteLine("Begin ML.NET demo run");
      Console.WriteLine("Income from age, sex, politics");
      var pipeline = new LearningPipeline();
      string dataPath = "..\\..\\Data\\PeopleData.txt";
      pipeline.Add(new TextLoader(dataPath).
        CreateFrom<IncomeData>(separator: ','));
      pipeline.Add(new ColumnCopier(("Income", "Label")));
      pipeline.Add(new CategoricalOneHotVectorizer("Politic"));
      pipeline.Add(new ColumnConcatenator("Features", "Age",
        "Sex", "Politic"));
      var sdcar = new StochasticDualCoordinateAscentRegressor();
      sdcar.MaxIterations = 1000;
      sdcar.NormalizeFeatures = NormalizeOption.Auto;
      pipeline.Add(sdcar);
      // pipeline.N
      Console.WriteLine("\nStarting training \n");
      var model = pipeline.Train<IncomeData, IncomePrediction>();
      Console.WriteLine("\nTraining complete \n");
      string modelPath = "..\\..\\Models\\IncomeModel.zip";
      Task.Run(async () =>
      {
        await model.WriteAsync(modelPath);
      }).GetAwaiter().GetResult();
      var testData = new TextLoader(dataPath).
        CreateFrom<IncomeData>(separator: ',');
      var evaluator = new RegressionEvaluator();
      var metrics = evaluator.Evaluate(model, testData);
      double rms = metrics.Rms;
      Console.WriteLine("Root mean squared error = " +
        rms.ToString("F4"));
      Console.WriteLine("Income age 40 conservative male: ");
      IncomeData newPatient = new IncomeData() { Age = 40.0f,
        Sex = -1f, Politic = "conservative" };
      IncomePrediction prediction = model.Predict(newPatient);
      float predIncome = prediction.Income * 10000;
      Console.WriteLine("Predicted income = $" +
        predIncome.ToString("F2"));
      Console.WriteLine("\nEnd ML.NET demo");
      Console.ReadLine();
    } // Main
  } // Program
} // ns

请注意,大部分命名空间都有“旧版”标识符。演示程序使用所谓的管道 API,此 API 非常简单有效。ML.NET 团队即将新增更灵活的 API,我将稍后予以介绍。

程序定义了嵌套类 IncomeData,用于描述定型数据的内部结构。例如,第一列是:

[Column("0")]
public float Age;

请注意,年龄字段是声明的 float 类型而不是 double 类型。在大部分 ML 系统中,默认数值类型是 float 类型,因为使用 double 类型所能提高的精度很少可以弥补所导致的内存和性能损失。可使用 ColumnName 属性来指定预测变量字段名称。名称是可选的,可根据需要任意命名,例如,[ColumnName(“Age”)]。

演示程序定义了嵌套类 IncomePrediction,用于保留模型预测:

public class IncomePrediction {
  [ColumnName("Score")]
  public float Income;
}

虽然列名称“Score”是必需的,但如图所示,关联的字符串变量标识符不必匹配。

创建和定型模型

演示程序使用下面这些语句来创建未定型 ML 模型:

var pipeline = new LearningPipeline();
string dataPath = "..\\..\\Data\\IncomeData.txt";
pipeline.Add(new TextLoader(dataPath).
  CreateFrom<IncomeData>(separator: ','));

可以将 LearningPipeline 对象视为元容器,用于保留定型数据和定型算法。此范例与其他 ML 库所用范例的区别相当大。接下来,管道执行某数据操作:

pipeline.Add(new ColumnCopier(("Income", "Label")));
pipeline.Add(new CategoricalOneHotVectorizer("Politic"));
pipeline.Add(new ColumnConcatenator("Features", "Age",
  "Sex", "Politic"));

旧版 ML.NET 要求,必须将保留要预测值的列标识为“Label”,以便 ColumnCopier 方法能够创建“Income”列的内存中副本。另一种替代方法是,直接在定义定型数据结构的类定义中,将“Income”列命名为“Label”。

由于定型程序只能使用数值数据,因此必须将“Politic”列的文本值转换为整数。Categorical­OneHotVectorizer 方法将“conservative”、“moderate”和“liberal”分别转换为 (1, 0, 0)、(0, 1, 0) 和 (0, 0, 1)。另一种替代方法是,手动预编码文本数据。

ColumnConcatenator 方法将三个预测变量列合并为一个“Features”列。必须采用此命名方案。定型算法被添加到管道中,模型按如下所示进行定型:

var sdcar = new StochasticDualCoordinateAscentRegressor();
sdcar.MaxIterations = 1000;
sdcar.NormalizeFeatures = NormalizeOption.Auto;
pipeline.Add(sdcar);
var model = pipeline.Train<IncomeData, IncomePrediction>();

随机双坐标上升是用于定型线性形式回归模型的相对简单算法。其他旧版回归定型程序包括 FastForestRegressor、FastTreeRegressor、GeneralizedAdditiveModelRegressor、LightGbmRegressor、OnlineGradientDescentRegressor 和 OrdinaryLeastSquaresRegressor。它们都各有利弊,因此没有用于解决回归问题的最佳算法。理解每种回归量和分类器的区别并不容易,需要相当仔细地研究整篇文档。

在管道对象创建后,模型定型就是一项单语句操作了。如果回头参考图 1**** 中的输出,就会发现,Train 方法为你完成了许多后台工作。由于管道使用的是自动标准化,因此定型程序分析了“Age”和“Income”列,并决定应使用“最小-最大标准化”缩放它们。这会将所有年龄值和收入值都转换为介于 0.0 和 1.0 之间的值,这样相对较大的值(如年龄 52)也不会压倒较小的值(如收入 4.58)。标准化通常可(但不一定)提高所生成模型的准确度。

Train 方法还使用 L1 和 L2 正则化,这是另一项可提高模型准确度的标准 ML 技术。简单地说,正则化阻止模型中有极端权重值,进而阻止模型过度拟合。重申一下,ML.NET 执行所有类型的高级处理,你无需显式配置参数值。很好!

保存和评估模型

定型后,回归模型会保存到磁盘,如下所示:

string modelPath = "..\\..\\Models\\IncomeModel.zip";
Task.Run(async () =>
{
  await model.WriteAsync(modelPath);
}).GetAwaiter().GetResult();

代码假定已有比可执行程序高两级的“模型”目录。另一种替代方法是,对路径进行硬编码。由于 WriteAsync 方法是异步的,因此调用该方法并不容易。您可以使用多种方法来实现此目的。我喜欢的方法是如所示的包装器技术。缺少用来保存 ML.NET 模型的非异步方法有点令人意外,即使对于处于预览模式的库也是如此。

使用以下语句评估模型:

var testData = new TextLoader(dataPath).
  CreateFrom<IncomeData>(separator: ',');
var evaluator = new RegressionEvaluator();
var metrics = evaluator.Evaluate(model, testData);
double rms = metrics.Rms;
Console.WriteLine("Model root mean squared error = " +
  rms.ToString("F4"));

在大多数 ML 方案中,都有两个数据文件:第一个测试数据集直接用于定型,第二个测试数据集仅用于模型计算。为简单起见,此演示程序会重用单个 30 项数据文件进行模型评估。

Evaluate 方法返回聚合对象,其中保留应用于测试数据的已定型模型的均方根值。回归计算器返回的其他指标包括,R 平方(决定系数)和 L1(绝对误差总和)。

对于许多 ML 问题,最有用的指标是预测准确度。对于回归问题,没有固有的准确度定义,因为必须定义正确预测的含义。常用方法是编写以下自定义函数:如果预测值在定型数据中的给定 true 值百分比范围内,就将预测值计为正确。例如,如果将增量百分比设置为 0.10,且 true 收入值为 6.00,那么介于 5.40 和 6.60 之间的预测值就是正确的。

使用已定型的模型

演示程序预测一位有保守政治倾向的 40 岁男性的年收入,如下所示:

Console.WriteLine("Income for age 40 conservative male: ");
IncomeData newPatient = new IncomeData() { Age = 40.0f,
  Sex = -1f, Politic = "conservative" };
IncomePrediction prediction = model.Predict(newPatient);
float predIncome = prediction.Income * 10000;
Console.WriteLine("Predicted income = $" +
  predIncome.ToString("F2"));

请注意,年龄和性别数值文本使用“f”修饰符,因为模型应使用 float 类型值。在本示例中,已定型的模型不可用,因为该程序只需完成了定型。如果你想要从其他程序进行预测,将使用 ReadAsync 方法以及以下几行加载已定型的模型:

PredictionModel<IncomeData, IncomePrediction> model = null;
Task.Run(async () =>
{
  model = await PredictionModel.ReadAsync<IncomeData,
    IncomePrediction>(modelPath);
}).GetAwaiter().GetResult();

将模型加载到内存后,通过调用 Predict 方法来使用它,如前所述。

新版 ML.NET API 方法

旧版管道方法简单有效,提供了一致接口,可便于使用 ML.NET 分类器和回归量。不过,旧版方法的一些结构特征限制了库的扩展性,所以 ML.NET 团队新建了更灵活的方法(最好举例说明)。

假设有完全相同的数据集(年龄、性别、收入、政治倾向),并且要根据其他三个变量来预测政治倾向。图 5 展示了使用新版 ML.NET API 方法创建分类器的演示程序。此程序混合组合使用旧版样式和新版样式,旨在在这两者之间搭建桥梁。我要强调一下,ML.NET 文档中的最新代码示例提供了更复杂的(在某种情况下是更好的)技术。

图 5:分类示例代码列表

using System;
using Microsoft.ML;
using Microsoft.ML.Runtime.Api;
using Microsoft.ML.Runtime.Data;
using Microsoft.ML.Transforms.Conversions;
// Microsoft.ML 0.7.0  Framework 4.7 Build x64
namespace PoliticPredict
{
  class PoliticProgram
  {
    public class PoliticData {
      [Column("0")] public float Age;
      [Column("1")] public float Sex;
      [Column("2")] public float Income;
      [Column("3")]
      [ColumnName("Label")]
      public string Politic;
    }
    public class PoliticPrediction  {
      [ColumnName("PredictedLabel")]
      public string PredictedPolitic;
    }
    static void Main(string[] args)
    {
      var ctx = new MLContext(seed: 1);
      string dataPath = "..\\..\\Data\\PeopleData.txt";
      TextLoader textLoader =
        ctx.Data.TextReader(new TextLoader.Arguments()
      {
        Separator = ",", HasHeader = false,
        Column = new[] {
          new TextLoader.Column("Age", DataKind.R4, 0),
          new TextLoader.Column("Sex", DataKind.R4, 1),
          new TextLoader.Column("Income", DataKind.R4, 2),
          new TextLoader.Column("Label", DataKind.Text, 3)
        }
      });
      var data = textLoader.Read(dataPath);
      var est = ctx.Transforms.Categorical.MapValueToKey("Label")
       .Append(ctx.Transforms.Concatenate("Features", "Age",
         "Sex", "Income"))
       .Append(ctx.MulticlassClassification.Trainers
         .StochasticDualCoordinateAscent("Label", "Features",
         maxIterations: 1000))
       .Append(new KeyToValueEstimator(ctx, "PredictedLabel"));
      var model = est.Fit(data);
      var prediction = model.MakePredictionFunction<PoliticData,
        PoliticPrediction>(ctx).Predict(
          new PoliticData() {
            Age = 40.0f, Sex = -1.0f, Income = 8.55f
          });
      Console.WriteLine("Predicted party is: " +
        prediction.PredictedPolitic);
      Console.ReadLine();
    } // Main
  } // Program
} // ns

粗略地来看,许多 ML 任务都有五个阶段:将定型数据加载到内存中并转换、创建模型、定型模型、计算和保存模型、使用模型。虽然旧版和新版 ML.NET API 都可以执行这些操作,但对于生产系统中的实际 ML 方案,新版方法显然更胜一筹(反正在我看来是这样)。

新版 ML.NET API 的重要功能是 MLContext 类。请注意,ctx 对象用于读取定型数据、创建预测模型和进行预测。

尽管在代码中并不明显,但新版 API 优于旧版方法的另一个地方是,可以读取多个文件中的定型数据。我并非经常遇到这种情况,但如果遇到,读取多个文件可节省大量时间。

新版 API 的另一项功能是,可通过两种不同的方法(称为“静态方法”和“动态方法”)创建预测模型。使用静态方法,可以在开发期间使用完整的 Visual Studio IntelliSense 功能。如果必须在运行时确定数据结构,可使用动态方法。

总结

如果已阅读完本文,且运行和理解了相对简单的演示程序代码,下一步应该是全身心投入到 ML.NET API 当中去。许多开放源代码项目提供的文档都没有说服力或不足,相反 ML.NET 文档就很棒。我建议将 bit.ly/2AVM1oL 中的示例作为绝佳起点。

即使 ML.NET 库是新的,它的起源也要追溯到很多年以前。于 2002 年引入 Microsoft.NET Framework 后不久,Microsoft Research 开始了“文本挖掘搜索和导航 (TMSN)”项目,让软件开发人员能够在 Microsoft 产品和技术中添加 ML 代码。此项目非常成功,多年来在 Microsoft 内部不断扩大规模并得到了广泛的使用。大约在 2011 年,库被重命名为“学习代码 (TLC)”。TLC 在 Microsoft 内广泛使用,当前版本为 3.10。ML.NET 库是 TLC 的后代,其中删除了 Microsoft 专用功能。这两个库我都使用过,ML.NET 子代在许多方面超越了它的父代。

本文只触及了 ML.NET 库的表面。ML.NET 的一项有趣且强大的新功能是,使用其他系统(如 PyTorch 和 CNTK)创建的深度神经网络模型。此互操作性的关键是 Open Neural Network Exchange (ONNX) 标准。但这是今后文章中要讨论的主题了。


Dr.James McCaffrey 供职于华盛顿地区雷蒙德市沃什湾的 Microsoft Research。他参与开发过多个重要 Microsoft 产品(包括 Internet Explorer 和必应)。Dr.可通过 jamccaff@microsoft.com 与 McCaffrey 取得联系。

衷心感谢以下 Microsoft 技术专家对本文的审阅:Ankit Asthana、Chris Lauren、Cesar De la Torre Llorente、Beth Massi、Shahab Moradi、Gal Oshri、Shauheen Zahirazami


在 MSDN 杂志论坛讨论这篇文章