2019 年 1 月

第 34 卷,第 1 期

[.NET]

通过概率性编程的机器学习

作者 Yordan Zaykov | 2019 年 1 月

是概率性编程吗?真是这样吗?那没有多大意义...或者这就是我刚开始研究该领域时的想法。我听从的研究人员并没有将机器学习 (ML) 问题归类的传统观点。而是只以计算机可读的形式表示导致数据生成的实际进程。这就是他们所说的模型及其表示形式 - 概率程序。

这种模式实际上极具吸引力。首先,你不需要学习现有的数百种 ML 算法。而只需要了解如何在概率程序中表达问题。这涉及到一些统计学知识,因为是在模拟真实世界的不确定性。比方说,你想预测一所房子的价格,你决定它是一些特征(位置、大小等等)的线性组合。你的模型是,价格是每个功能的产品和一些权重再加上干扰的总和。这就是所谓的线性回归。在抽象术语中:

For each house:
  score = Weights * house.Features;
  house.Price = score + Noise;

可以在定型期间了解这些权重,然后直接在预测中使用它们。如果通过将最后的干扰性分数阈值设置为 0 以便在模型中进行细微更改,那么新标签就会突然变成两个类中的一个。你刚刚建立了一个二元线性分类器模型,或许你甚至不知道它叫什么。

其次,不需要尝试使问题和数据适应现有的 ML 算法之一。这应该是显而易见的 - 你为问题设计了模型,因此它贴合你的数据。新式概率性编程工具可以使用通用推理方法从指定的模型自动生成 ML 算法。甚至不需要过多地去了解它,因为它已经为你实现了。因此,特定于应用程序的模型结合通用推理方法提供了特定于应用程序的 ML 算法。

最后,这种方法似乎经得起时间的考验。大多数成功的 ML 算法都符合此模式 - 一个表示一组假设的模型,再加上执行计算的推理方法。这种方法随着时间的推移而不断演变。现在,深度神经网络非常流行;该模型由阈值线性函数组成,推理方法称为随机梯度下降法 (SGD)。将网络结构修改为循环的或卷积的;也就是说,允许针对不同的应用程序更改模型。但推理方法可以保持不变 - SGD,如图 1 所示。因此,虽然模型的选择已经演变了多年,但设计模型和应用推理方法的一般方法仍然不变。

不同应用程序的不同模型,都使用相同的推理方法
图 1 不同应用程序的不同模型,都使用相同的推理方法

因此,开发人员的任务是为其应用程序设计一个模型。让我们了解下如何执行此操作。

Hello, Uncertain World!

每个人用一种新语言编写的第一个程序是“Hello world”。 概率性安装程序中的等效项当然是“Hello uncertain world”。 可以将概率程序作为一种模拟或数据采样器。我将从一些参数开始,并使用它们来生成数据。例如,我们有两个完全未知的字符串。也就是说,它们可以是任意字符串,或者用统计术语来说,它们是从均匀分布中抽取的字符串随机变量。我使用中间空格将它们连接起来,并将结果限制为字符串“Hello, unknown world”:

str1 = String.Uniform()
str2 = String.Uniform()
Constrain(str1 + " " + str2 == "Hello, uncertain world")
Print(str1)
Print(str2)

这是一个概率模型 - 我列出了我对数据生成方式的假设。现在可以运行一些推理方法来进行必要的计算。这两个字符串之间的空格可以是以下两个空格中的任意一个 - “Hello”和“uncertain”之间的空格,或者“uncertain”和“world”之间的空格。 所以我不能确定这两个变量的值。因此,结果是一个捕获了关于可能值的不确定性的分发。Str1 同样可能是“Hello”或“Hello uncertain”,而 str2 则是 50% 的“Hello uncertain”和 50% 的“world”。

真实示例

现在让我们看一个更实际的例子。概率性编程可用于解决大量 ML 问题。例如,我的团队在一段时间前开发了一个推荐系统,并将其发布到了 Azure 机器学习中。在此之前,我们在 Exchange 中生成了一个电子邮件分类器。现在,我们正致力于通过升级技能评级系统来改善 Xbox 的玩家配对。我们还在为必应开发一个系统,通过对非结构化文本建模,自动从 Internet 上提取知识。所有这些都是使用相同模式实现的 - 将模型定义为概率程序中表示的一组假设,然后使用通用推理方法进行必要的计算。

技能评级系统(称为“TrueSkill”)展示了概率性编程的许多优点,包括解释系统行为、在模型中集成领域知识,以及在新数据到来时学习的能力。因此,让我们来实现一个简化版的模型,该模型在“光晕”和“战争机器”等畅销游戏的制作中运行。

问题和数据 要解决的问题是在游戏中对玩家进行评级。这有许多用途,如玩家配对(通过匹配拥有类似技能的玩家或团队来实现公平且有趣的游戏)。为简单起见,让我们假设每场比赛只有两个参与者,结果是一胜一负,没有平局。因此,每场比赛的数据将是两名玩家的唯一标识符,并指示谁将获胜。在本文中,我将使用一个小型手工数据集,但此方法在 Xbox 中可以扩展到数亿个匹配项。

我要做的是了解所有玩家的技能,并能够预测未来比赛的赢家。一种幼稚的做法是只统计每个玩家的输赢,但这并没有考虑到这些比赛中对手的实力。有一个更好的方法。

模型设计 让我们先假设数据是如何生成的。首先,每个玩家都有一些从未被直接观察到的隐藏(或潜在)技能,你只看到了他们技能的效果。我假设这是一个实数,但还需要指定它的生成方式。一个合理的选择是,它是从正态分布(或高斯分布)生成的。在更复杂的示例中,此高斯参数是未知且可学习的。为简单起见,我将直接设置它们。

技能模型可以用图形表示,如图 2 最左侧的草图所示,这只是表示随机变量技能都取自正态分布。

双人游戏组合
图 2 双人游戏组合

另一个假设是,在每场比赛中,玩家都有一个表现数字。该数字接近于他们的潜在技能,但可能或高或低,这取决于相比其典型水平,玩家的表现是更好还是更差。换句话说,表现是技能的干扰性版本。干扰通常也被建模为高斯分布,如图 2 的中心关系图所示。这里的表现是从高斯分布中抽取的随机变量,其平均值是玩家技能,方差是固定的,由阴影线干扰变量表示。在更复杂的模型中,我会试着从数据中学习干扰方差,但为简单起见,我在此将其固定为 1。

表现更好的玩家将获胜。在双人比赛中,我可以用图形表示,如图 2**** 最右边的图表所示。我在此表示法中做了一点手脚,具体做法是,让布尔型玩家 1 获胜变量“稍微”影线化。这是因为它的值是在定型期间观察到的,从中给出匹配结果,但不会在预测中观察到。

将此汇总在一起之前,我需要引入了几个新的表示法。第一个表示法称为板块,表示一个 foreach 循环。它是一个矩形,用于捕获在给定范围内(例如,在玩家或游戏中)需要重复的模型部分。其次,我将使用虚线来指示所选内容,就像我在每场游戏中选择两个玩家的技能一样。简化的 TrueSkill 模型如图 3 所示。

简化的 TrueSkill 模型
图 3 简化的 TrueSkill 模型

在此,我在玩家范围内使用一个板块。然后,对于每场游戏,我在游戏中选择两名玩家的潜在技能,添加一些干扰并对他们的表现进行比较。干扰变量不存在于任何板块内,因为假定它的值不会随着玩家或游戏的不同而改变。

在此简单示例中,这种模型的可视化效果(也称为因子图)是一种方便的表示形式。但是,当模型变得更大时,图表就变得混乱且难以维护。这就是开发人员更喜欢用代码表示它的原因 - 作为一个概率程序。

Infer.NET

.NET 的概率性编程框架称为“Infer.NET”。它由 Microsoft Research 开发,几个月前实现了开源化。Infer.NET 提供了一个用于指定统计模型的建模 API,一个用于从用户定义的模型生成 ML 算法的编译器,以及一个在其中执行算法的运行时。

Infer.NET 与 ML.NET 的集成越来越紧密,现在在 Microsoft.ML.Probabilistic 命名空间下。可以通过运行以下命令安装 Infer.NET:

dotnet add package Microsoft.ML.Probabilistic.Compiler

编译器将自动下拉运行时包。请注意,Infer.NET 在 .NET Standard 上运行,因此也在 Windows、Linux 和 macOS 上运行。

让我们使用 C#,并从包括 Infer.NET 命名空间开始:

using Microsoft.ML.Probabilistic.Models;
using Microsoft.ML.Probabilistic.Utilities;
using Microsoft.ML.Probabilistic.Distributions;

然后,我将实现前面定义的模型,并了解如何定型该模型以及如何进行预测。

模型实现 就玩家技能、表现和结果而言,该模型编写得像一个模拟游戏的程序,但此程序实际上不会运行。在后台,它构建表示模型的数据结构。当此数据结构提供给推理引擎时,它将被编译成 ML 代码,然后执行该代码来执行实际计算。让我们分三步来实现此模型:定义覆盖玩家和游戏的板块主干,定义玩家板块的内容,以及定义游戏板块的内容。

板块是使用范围类在 Infer.NET 中实现的。其实例基本上是概率性 foreach 循环中的控制变量。我需要定义这些板块的大小,分别是玩家数量和游戏数量。我并没有提前了解这些概念,因此它们将是用作这些值的占位符的变量。为方便起见,Infer.NET 仅为此目的提供变量类:

var playerCount = Variable.New<int>();
var player = new Range(playerCount);
var gameCount = Variable.New<int>();
var game = new Range(gameCount);

使用定义的板块,以便专注于其内容。对于玩家板块,我需要为每个玩家的技能设置一个双精度随机变量数组。在 Infer.NET 中,可使用 Variable.Array<T> 完成此操作。我还需要一组高斯随机变量来表示玩家技能的先验分布。然后,我将检查玩家,并将他们的技能与先验分布联系起来。这是通过使用 Variable<T>.Random 方法实现的。请注意 Variable.ForEach(Range) 如何提供实现板块内部构件的方法:

var playerSkills = Variable.Array<double>(player);
var playerSkillsPrior = Variable.Array<Gaussian>(player);
using (Variable.ForEach(player))
{
  playerSkills[player] = Variable<double>.Random(playerSkillsPrior[player]);
}

该模型的最后一个部分是游戏板块。我将首先定义将包含定型数据的数组 - 每场游戏的第一名和第二名玩家以及游戏结果。请注意,我是如何专门为数据创建模型的,而不是试图重新调整数据以适应某些算法。数据容器准备就绪后,我需要检查游戏,在每场游戏中选择玩家技能,给他们增加一些干扰,并比较他们的表现来生成游戏结果:

var players1 = Variable.Array<int>(game);
var players2 = Variable.Array<int>(game);
var player1Wins = Variable.Array<bool>(game);
  const double noise = 1.0;
    using (Variable.ForEach(game))
{
  var player1Skill = playerSkills[players1[game]];
  var player1Performance =
    Variable.GaussianFromMeanAndVariance(player1Skill, noise);
  var player2Skill = playerSkills[players2[game]];
  var player2Performance =
    Variable.GaussianFromMeanAndVariance(player2Skill, noise);
      player1Wins[game] = player1Performance > player2Performance;
}

有趣的是,可使用相同的模型进行定型和预测。不同之处在于,观察到的数据是不同的。在定型期间,你知道比赛结果,而在预测中并不知晓。因此,当模型保持不变时,生成的算法将有所不同。幸运的是,Infer.NET 编译器负责处理所有这些事项。

定型 对模型的所有查询(定型、预测等)都经历三个步骤:设置先验、观察数据、运行推理。定型和预测都被称为“推理”,因为它们的基本功能是一样的:它们利用观察到的数据从先验分布移动到后验分布。在定型中,从对技能的广泛先验分布开始,这表明对技能的不确定性较高。我将使用高斯分布来表示先验分布。在观察数据后,我获得了一个较窄的针对技能的高斯后验分布。

对于技能中的先验分布,我将借用从“Halo 5”中学到的参数 - 均值和方差的最佳选择分别是 6.0 和 9.0。通过将这些值分配给保存先验的变量的 ObservedValue 属性来设置这些值。对于四个玩家,代码将如下所示:

const int PlayerCount = 4;
playerSkillsPrior.ObservedValue =
  Enumerable.Repeat(Gaussian.FromMeanAndVariance(6, 9),
  PlayerCount).ToArray();

接下来是数据。对于每场游戏,我都有两个玩家和游戏结果。我们来看一个固定的示例。图 4 显示由四名玩家进行的三场比赛,箭头指示正在进行的比赛,每个箭头指向比赛的输家。

三场比赛的结果
图 4 三场比赛的结果

为了简化代码,我假设每个玩家都分配有一个唯一的 id。在此示例中,第一场是 Alice 和 Bob 之间的比赛,箭头表示 Alice 赢了。在第二场比赛中 Bob 打败了 Charlie,最后 Donna 打败了 Charlie。这里用代码表示,同样使用了 ObservedValue:

playerCount.ObservedValue = PlayerCount;
gameCount.ObservedValue = 3;
players1.ObservedValue = new[] { 0, 1, 2 };
players2.ObservedValue = new[] { 1, 2, 3 };
player1Wins.ObservedValue = new[] { true, true, false };

最后,我通过实例化一个推理引擎并对我想要获得的变量调用推断来运行推理。在这种情况下,我感兴趣的只是后验而不是玩家技能:

var inferenceEngine = new InferenceEngine();
var inferredSkills = inferenceEngine.Infer<Gaussian[]>(playerSkills);

这里的 Infer 语句实际上会执行大量工作,因为它执行两个编译(Infer.NET 和 C#)和一个执行。接下来将发生的是,Infer.NET 编译器跟踪传递到概率模型的 playerSkills 变量,从模型代码中构建一个抽象语法树,并在 C# 中生成一个推理算法。然后动态调用 C# 编译器,并根据观察到的数据执行算法。正因为如此,你可能会发现,即使你在此处运行的数据非常少,对推断的调用也有点慢。Infer.NET 手册说明了如何通过使用预编译的算法加速此进程。也就是说,需要预先执行编译步骤,以便在生产环境中只执行计算部分。

对于本示例,推断的技能包括:

Alice: Gaussian(8.147, 5.685)
Bob: Gaussian(5.722, 4.482)
Charlie: Gaussian(3.067, 4.814)
Donna: Gaussian(7.065, 6.588)

预测 在推断玩家技能后,我现在可以预测未来的比赛。我将遵循定型中的三个相同步骤,但这次我将对推断 player1Wins 变量的后验感兴趣。我喜欢这样考虑问题,在定型中,信息在因子图中向上流动 - 从底部数据到顶部的模型参数。而在预测中,已经有了模型参数(在定型中学习到)和向下的信息流。

在我的数据集中,Alice 和 Donna 都获胜了一次,没有失败。然而,直觉上感觉就像双方之间的比赛,Alice 更有可能获胜,因为她获胜更有意义 - 这是相对 Bob 而言,相比 Charlie,Bob 更有可能是一名强劲的玩家。让我们尝试预测 Alice 和 Donna 之间的比赛结果(请参阅图 5)。

预测比赛结果
图 5 预测比赛结果

在这种情况下,对技能的先验分布是在定型中推断出的后验分布。观察到的数据是玩家 0 (Alice) 和玩家 3 (Donna) 之间的一场游戏,结果未知。为了使结果未知,我需要清除之前观察到的 player1Wins 的值,因为这是我要后验的:

playerSkillsPrior.ObservedValue = inferredSkills;
gameCount.ObservedValue = 1;
players1.ObservedValue = new[] { 0 };
players2.ObservedValue = new[] { 3 };
player1Wins.ClearObservedValue();
var player0Vs3 = inferenceEngine.Infer<Bernoulli[]>(player1Wins).First();

值得一提的是,不确定性通过此模型一直传播到比赛结果。这意味着通过预测结果获得的后验不仅仅是布尔变量,还是一个表示第一名玩家赢得比赛的概率的值。此类分布称为“伯努利分布”。

推断出的变量的值是伯努利值 (0.6127)。这意味着 Alice 有超过 60% 的机会战胜 Donna,这与我的直觉一致。

评估 此示例演示如何生成已知的概率模型 - TrueSkill。在实践中,推出正确的模型需要对其设计进行多次迭代。通过仔细选择一组指标来比较不同的模型,这些指标表明给定数据上的模型性能。

在 ML 中,评估是一个中心主题,在这里涉及的范围过于广泛。它也不是概率性编程所特有的。不过值得一提的是,拥有一个模型可计算一个唯一的“指标”- 模型证据。这是此特定模型生成定型数据的概率。此方法非常适合比较不同的模型 - 无需测试集!

概率性编程的优点

本文所示的方法可能看起来比你以前看到的方法难度更大。例如,Connect(); 特刊杂志 (msdn.com/magazine/mt848634) 向你介绍了 ML.NET,它较少关注模型设计而更注重数据转换。在许多情况下,这是可遵循的正确路径 - 如果数据似乎与现有算法匹配,并且你愿意将模型视为一个黑盒,则从现有内容入手。然而,如果你需要定制模型,那么概率性编程可能是正确选择。 

有一些可能需要使用概率性编程的其他情况。拥有你能够理解的模型的主要优势之一是,改进了解释系统行为的能力。当模型不是黑盒,可以检查其内部结构并查看已学习的参数。这些做法对你来说是有意义的,因为你设计了该模型。例如,在前面的示例中,你可以查看所了解的玩家技能。在更高级的 TrueSkill 版本中,也就是 TrueSkill 2,会对游戏的更多方面进行建模,包括一种游戏模式中的性能如何与另一种游戏模式中的性能相关联。理解这种联系有助于游戏设计师认识到不同的游戏模式有多么相似。ML 系统的可解释性对于调试也是至关重要的。当黑盒模型不能生成期望的结果时,你可能甚至不知道从哪里开始查找问题。

概率性编程的另一个优点是能够将领域知识合并到模型中。这在模型的结构和设置先验的能力中都得到了体现。相比之下,传统方法通常只查看数据,而不允许领域专家告知系统行为。此功能在某些领域是必需的,例如医疗保健领域,在这些领域中可能缺乏强大的领域知识和数据。

Bayesian 方法的一个优点(在 Infer.NET 中得到了很好的支持)是能够在新数据到来时进行学习。这称为在线推理,在与用户数据交互的系统中尤其有用。例如,TrueSkill 需要在每场比赛结束后更新玩家技能,知识提取系统需要在发展过程中不断从 Internet 学习。不过,这一切都很简单,只需将推断出的后验代入新的先验,系统就可以从新数据中学习!

概率性编程自然也适用于具有某些数据特征的问题,如异类数据、稀缺数据、未标记的数据、部分缺失的数据以及收集的带有已知偏差的数据。

下一步要做什么?

成功构建概率模型有两个要素。显然,第一个要素是学习如何建模。在本文中,我介绍了该方法的主要概念和技术,但是,若要了解详细信息,建议参阅免费提供的联机丛书,“基于模型的机器学习”(mbmlbook.com)。它简单地介绍了生产中基于模型的 ML,其目标是针对开发人员(而不是科学家)。

一旦知道如何设计模型,就必须了解如何用代码进行表达。Infer.NET 用户指南是有关这一方面的绝佳资源。此外,还有涵盖了诸多方案的教程和示例,都可以从 github.com/dotnet/infer 的存储库获得,我们也热忱地欢迎你的加入和参与供稿。


Yordan Zaykov 是 Microsoft Research Cambridge 的概率推理开发团队的主要研究软件工程主管。他专门研究基于 Infer.NET 机器学习框架的应用程序,包括电子邮件分类、建议、玩家排名和配对、医疗保健和知识挖掘工作。

衷心感谢以下 Microsoft 技术专家对本文的审阅:John Guiver、James McCaffrey、Tom Minka、John Winn