2019 年 2 月

第 34 卷,第 2 期

[测试运行]

使用 Infer.NET 评价竞争对手

作者 James McCaffrey

James McCaffreyInfer.NET 是开放源代码的代码库,可用于创建概率性编程系统。我往往会将普通的计算机程序视作,主要基于有指定类型的值的变量(如有值“Q”的 char 变量)。 概率性编程主要基于概率分布,如平均值为 0.0 且标准偏差为 1.0 的高斯分布。

本文通过使用正面交锋的结果计算一组竞争对手的分级,介绍了如何开始使用 Infer.NET 库。若要了解概率性编程以及本文的论述方向,最好查看图 1 中的演示程序。此演示程序设置了六个竞争对手(想想运动队):Angels、Bruins、Comets、Demons、Eagles 和 Flyers。

Infer.NET 分级实际效果
图 1:Infer.NET 分级实际效果

六支运动队相互比赛。每队比赛三场,所以共有九场比赛。此演示程序定义了概率性模型,假定每支运动队都有固有实力,每种固有实力都可以用平均值为 2000 且标准偏差为 200 任意单位的高斯分布(亦称为“正态分布”或“钟形分布”)进行描述。

此演示程序使用输赢数据推断出六支运动队的实力。Angels 三场比赛全胜,它的推断实力为 2256.8 个单位,比假定的 2000 个单位平均实力高出约 1.25 个标准偏差单位。Flyers 三场比赛全败,它的推断实力为 1739.7 个单位。

虽然这里我使用的是“实力”这个词,但你可以将推断数值视为分级。请注意,如果可以推断一组项的分级,就会自动获得这些项的排名。最终排名(按从最好到最差的顺序)是 Angels、Bruins、Comets、Eagles、Demons 和 Flyers。

若要更好地理解本文,至少必须拥有中等水平或更好的 C# 编程技能,但无需对 Infer.NET 或概率性编程有任何了解。Infer.NET 仅支持 C# 和 F#,所以可以视需要将此演示程序重构为 F#。了解概率性编程的基础知识后,便能使用其他许多概率性编程框架(如 Stan 或 Edward)之一重写此演示程序了。

本文展示了此演示程序的完整源代码。也可以在下载的随附文件中找到源代码。为了尽可能地让主要思想清晰明确,已删除所有常见错误检查。

了解随机变量

此演示程序假定每支运动队的实力是高斯分布的随机变量,具有指定的平均值和标准偏差。这究竟是什么意思?这个假定又源自何处?

随机变量分布有很多种类型。每种类型都有一个或多个特征参数。例如,如果随机变量遵循均匀分布,且 a = 2.0、b = 5.0,那么它可以是介于 2.0 和 5.0 之间的任何值,其中每个可能值的可能性都相等。对于此演示程序问题,可以假定运动队实力均匀分布,且 a = 1000、b = 3000。也就是说,此类实力的平均值为 0.5 * (1000 + 3000) = 2000.0 个单位,标准偏差为 sqrt((1/12) * (3000 - 1000)^2) = 577.35 个单位。

此演示程序假定运动队实力为,平均值 = 2000 且标准偏差 = 200 的高斯分布。因此,此类运动队的平均实力约为 2000,并且约 99% 的此类运动队的实力介于 2000 - (3 * 200) = 1400 和 2000 + (3 * 200) = 2600 之间。

此时,你可能会想,“太棒了,所以我必须获得统计分布博士学位,才能使用 Infer.NET 并创建概率性程序。” 这种想法并不完全正确。Infer.NET 支持多种分布,但在实践中,通常只需要了解一小部分。我最常使用的几个分布是,高斯分布、均匀分布、贝塔分布、二项分布、多项分布、伽玛分布和泊松分布。就好比是,.NET Framework 的 System.Collections 命名空间支持的许多类型的数据结构。尽管数据结构有很多,但通常只使用几种(堆栈、队列、字典);遇到需要使用新数据结构解决的问题时,就会学习新数据结构。

平均值是数据平均值,标准偏差是用于度量数据分布情况。方差是标准偏差的平方,精度是方差的倒数。之所以用三个包含完全相同信息的不同术语是因为,在用于某种数学方程时,有时一种形式会比其他形式更方便。Infer.NET 库往往使用方差和精度,而不是标准偏差。

演示程序结构

图 2 展示了此演示程序的完整源代码(为节省空间,进行了少量小幅改动)。

图 2:完整源代码

using System;
using Microsoft.ML.Probabilistic.Models;
using Microsoft.ML.Probabilistic.Algorithms;
using Microsoft.ML.Probabilistic.Distributions;
// VS2017 (Framework 4.7) Infer.NET 0.3.1810.501

namespace InferStrengths
{
  class InferStrengthsProgram
  {
    static void Main(string[] args)
    {
      Console.WriteLine("Begin Infer.NET demo ");

      // ===== Set up teams and win-loss data =====================

      string[] teamNames = new string[] { "Angels", "Bruins",
        "Comets", "Demons", "Eagles", "Flyers" };
      int N = teamNames.Length; 

      int[] winTeamIDs =  new int[] { 0, 2, 1, 0, 1, 3, 0, 2, 4 };
      int[] loseTeamIDs = new int[] { 1, 3, 2, 4, 3, 5, 5, 4, 5 };

      Console.WriteLine("Data: \n");
      for (int i = 0; i < winTeamIDs.Length; ++i) {
        Console.WriteLine("game: " + i + "   winning team: " +
          teamNames[winTeamIDs[i]] + "   losing team: " +
          teamNames[loseTeamIDs[i]]);
      }

      // ===== Define a probabilistic model =======================

      Range teamIDsRange = new Range(N).Named("teamsIDRange");
      Range gameIDsRange =
        new Range(winTeamIDs.Length).Named("gameIDsRange");

      double mean = 2000.0;
      double sd = 200.0;
      double vrnc = sd * sd;
      
      Console.WriteLine("\nDefining Gaussian model mean = " +
        mean.ToString("F1") + " and sd = " + sd.ToString("F1"));
      VariableArray<double> strengths =
        Variable.Array<double>(teamIDsRange).Named("strengths");
      
      strengths[teamIDsRange] =
        Variable.GaussianFromMeanAndVariance(mean,
        vrnc).ForEach(teamIDsRange);

      VariableArray<int> winners =
        Variable.Array<int>(gameIDsRange).Named("winners");
      VariableArray<int> losers =
        Variable.Array<int>(gameIDsRange).Named("losers");

      winners.ObservedValue = winTeamIDs;
      losers.ObservedValue = loseTeamIDs;

      using (Variable.ForEach(gameIDsRange))
      {
        var ws = strengths[winners[gameIDsRange]];
        var ls = strengths[losers[gameIDsRange]];
        Variable<double> winnerPerf =
      Variable.GaussianFromMeanAndVariance(ws, 400).Named("winPerf");
        Variable<double> loserPerf =
      Variable.GaussianFromMeanAndVariance(ls, 400).
        Named("losePerf");

        Variable.ConstrainTrue(winnerPerf > loserPerf);
      }

      // ===== Infer team strengths using win-loss data ===========

      Console.WriteLine("\nInferring strengths from win-loss data");
      var iengine = new InferenceEngine();
      iengine.Algorithm = new ExpectationPropagation();
      iengine.NumberOfIterations = 40;
      // iengine.ShowFactorGraph = true;  // needs Graphviz install

      Gaussian[] inferredStrengths =
        iengine.Infer<Gaussian[]>(strengths);
      Console.WriteLine("Inference complete. Inferred strengths: ");

      // ===== Show results =======================================

      for (int i = 0; i < N; ++i) {
        double strength = inferredStrengths[i].GetMean();
        Console.WriteLine(teamNames[i] + ": " +
          strength.ToString("F1"));
      }

      Console.WriteLine("\nEnd demo ");
      Console.ReadLine();
    } // Main
  }
} // ns

为了创建此演示程序,我启动了 Visual Studio 2017 Community Edition,选择了 .NET Framework 版本 4.7 随附的控制台应用程序模板项目,并将它命名为 InferStrengths。Infer.NET 可以在 .NET Core 应用程序中运行,但我更喜欢在经典 .NET Framework 中运行它。

在模板代码加载后,我右键单击了“解决方案资源管理器”窗口中的 Program.cs 文件,将此文件重命名为“InferStrengthsProgram.cs”,并允许 Visual Studio 自动为我重命名类 Program。

在“解决方案资源管理器”窗口中,我右键单击了项目名称,并选择了“管理 NuGet 包”选项。在“NuGet”窗口中,我选择了“浏览器”选项卡,并搜索了“Infer.NET”。 在结果列表中,我选择了“Microsoft.ML.Probabilistic.Compiler”包(版本 0.3.1810.501),并单击了“安装”按钮。Microsoft 计划在某个时间点将 Infer.NET 迁移到 ML.NET 库中,所以如果找不到 Infer.NET 独立包,请在 ML.NET 包中查找。

设置数据

此演示程序设置六支运动队,如下所示:

string[] teamNames = new string[] { "Angels", "Bruins",
  "Comets", "Demons", "Eagles", "Flyers" };
int N = teamNames.Length;

由于 Infer.NET 主要处理随机变量,因此在许多程序中,所有数据都是严格意义上的数字。纯粹为了提高可读性,本文将运动队名称指定为字符串,而不是整数索引。输赢数据如下:

int[] winTeamIDs =
  new int[]  { 0, 2, 1, 0, 1, 3, 0, 2, 4 };
int[] loseTeamIDs = 
  new int[]  { 1, 3, 2, 4, 3, 5, 5, 4, 5 };

此数据表示,在比赛 [0] 中,运动队 0 (Angels) 击败运动队 1 (Bruins)。在比赛 [1] 中,运动队 2 (Comets) 击败运动队 3 (Demons),依此类推一直到比赛 [8]。通过数值编程,使用这样的并行数组往往是比将数据放入类或结构对象更常见的模式。

请注意,此时演示程序仅使用本机 .NET 数据类型。不过,Infer.NET 有自己的类型系统,数据很快就会转换为 Infer.NET 可使用的类型。此演示程序显示源数据:

for (int i = 0; i < winTeamIDs.Length; ++i) {
  Console.WriteLine("game: " + i + " winning team: " +
    teamNames[winTeamIDs[i]] + " losing team: " +
    teamNames[loseTeamIDs[i]]);
}

此演示程序中的数据非常少。与一些通常需要非常大量的定型数据才能发挥作用的机器学习技术(如神经网络或强化学习)相比,能够处理有限数据是概率性编程的强项。

定义概率性模型

此演示程序使用以下语句准备高斯模型:

Range teamIDsRange = new Range(N).Named("teamsIDRange");
Range gameIDsRange =
  new Range(winTeamIDs.Length).Named("gameIDsRange");
double mean = 2000.0;
double sd = 200.0;
double vrnc = sd * sd;

在 Infer.NET 中,Range 对象的概念是很重要的。与通常使用 for 循环或 foreach 循环进行显式循环访问的标准过程式编程不同,在 Infer.NET 中,更常见的做法是通过 Range 对象应用元操作。这种编码范型可能有点难以习惯。

使用链接到构造函数调用的 Named 方法,几乎可以为所有 Infer.NET 对象提供可选的字符串名称。对象字符串名称不需要与对象标识符名称匹配,但最好保持一致。不久就会发现,Named 方法很实用,因为 Infer.NET 有一种内置方式可用来显示模型的计算图,此图使用字符串名称(若有),而不是库生成的名称(如 vdouble23)。

要推断的运动队实力是使用下面两个语句进行设置:

VariableArray<double> strengths =
  Variable.Array<double>(teamIDsRange).Named("strengths");
strengths[teamIDsRange] =
  Variable.GaussianFromMeanAndVariance(mean,
  vrnc).ForEach(teamIDsRange);

第一个语句将一个对象设置为特殊的 VariableArray 类型,其中包含六个随机 Variable 对象。第二个语句将每个随机变量初始化为,平均值 = 2000 且方差 = 4000(相当于标准偏差 = 200)的高斯分布。可以设置包含各个 Variable 对象的数组,如下所示:

Variable<double>[] arr = new Variable<double>[6];
for (int i = 0; i < 6; ++i)
  arr[i] = Variable.GaussianFromMeanAndVariance(2000, 4000);

不过,为了提升性能,Infer.NET 文档建议使用 VariableArray 方法。接下来,创建 int 类型的随机变量,用于保留输赢运动队的索引,再将本机 .NET int 数据转换为 Infer.NET 对象:

VariableArray<int> winners =
  Variable.Array<int>(gameIDsRange).Named("winners");
VariableArray<int> losers =
  Variable.Array<int>(gameIDsRange).Named("losers");
winners.ObservedValue = winTeamIDs;
losers.ObservedValue = loseTeamIDs;

在这种情况下,随机变量不遵循概率分布;而基本上只是普通的整数值。此时,使用 ObservedValue 属性来设置固定值。

定义概率性模型特征的关键语句如下:

using (Variable.ForEach(gameIDsRange)) {
  var ws = strengths[winners[gameIDsRange]];
  var ls = strengths[losers[gameIDsRange]];
  Variable<double> winnerPerf =
    Variable.GaussianFromMeanAndVariance(ws,
    400.0).Named("winPerf");
  Variable<double> loserPerf =
    Variable.GaussianFromMeanAndVariance(ls,
    400.0).Named("losePerf");

  Variable.ConstrainTrue(winnerPerf > loserPerf);
}

这段代码相当微妙。由于代码位于 Variable.ForEach 块内,因此操作以元方式应用到每支运动队和每个比赛结果。将与每场比赛的输赢运动队的表现相对应的随机变量定义为,以运动队的实力为依据,但这些变量有高斯分布(方差 = 400)的噪声分量。可以认为,在推断运动队的实力时,一支运动队可能会略微表现不佳,而对手可能会胜算略大。噪声方差值 400 必须通过反复试验才能确定。

ConstrainTrue 语句很关键,它添加了允许推理引擎计算每支运动队实力的逻辑。推理引擎使用复杂算法,以对六支运动队中的每支运动队检查不同的平均值和方差,再根据假定的平均值和方差确定观察到的输赢结果的可能性。推理算法寻找与观察数据最匹配的六个平均值和方差。聪明!

推断运动队实力

定义了概率性模型后,便会使用以下语句创建推理引擎:

var iengine = new InferenceEngine();
iengine.Algorithm = new ExpectationPropagation();
iengine.NumberOfIterations = 40;
// iengine.ShowFactorGraph = true;

Infer.NET 支持三种不同的算法:期望传播、变分消息传递和吉布斯采样。不同的算法适用于不同类型的概率性模型。对于此演示程序模型,仅期望传播有效。期望传播是 Infer.NET 特有的算法,最大限度地减少 Kullback-Liebler 散度指标,以近似计算一组观察数据的概率分布。NumberOfIterations 属性的值必须通过反复试验才能确定。

Infer.NET 的一个有趣特性是,可以自动生成模型计算图的可视化表示形式,如图 3**** 中与此演示程序对应的可视化表示形式。此功能依赖开放源代码的 Graphviz 程序。如果你将 ShowFactorGraph 设置为 true,且安装了 Graphviz,图便会以 SVG 格式保存在当前工作目录中,并能在浏览器中显示。

计算图的可视化表示形式
图3:计算图的可视化表示形式

创建推理引擎后,可以使用 Infer 方法轻松计算和显示运动队实力:

Gaussian[] inferredStrengths =
  iengine.Infer<Gaussian[]>(strengths);
for (int i = 0; i < N; ++i) {
  double strength = inferredStrengths[i].GetMean();
  Console.WriteLine(teamNames[i] + ": " + strength);
}

strength 对象传递到 Infer。回想一下,strength 是类型 VariableArray,这是高斯随机变量对象的集合,这些对象与包含输赢数据的输赢运动队 VariableArray 对象相关联。请注意,Infer.NET 模型是松散连接的对象集合,而不是一个顶级对象。对我而言,至少在刚开始接触 Infer.NET 时,我需要一段时间才能适应这个概念。

总结

使用 Infer.NET 进行概率性编程与使用标准过程式语言进行编程截然不同,其中涉及到一条重要的学习曲线。本文中的示例(非严格)基于 Microsoft Research 开发用于 Xbox 安排比赛的 TrueSkill 排名系统。但概率性编程可以应用于除分级和排名之外的许多问题。Infer.NET 有很好的文档,这对起源于研究的代码库来说有点不寻常。若要详细了解概率性编程,我建议学习 bit.ly/2rEr784 上的教程。


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

衷心感谢以下 Microsoft 技术专家对本文的审阅:Chris Lee 和 Ricky Loynd


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