500 lines or less学习笔记(二)——抽样器(sampler)

本文也属于 500lines 系列中比较简单的一篇文章,讲述了如何利用概率统计知识和 Python 实现一个简单的抽样器,并用于游戏中的一些场景。代码比较简单,但需要有一定的数学知识才能理解文中的介绍。

原文作者

Jessica B.Hamrick。Jess 是加州大学伯克利分校的一名博士生,她通过将机器学习的概率模型与认知科学的行为实验相结合来研究人类认知。在业余时间,杰西是 IPython 和 Jupyter 的核心贡献者。她还拥有麻省理工学院计算机科学学士和硕士学位。

引言

在计算机科学和工程中,我们经常遇到用方程无法解决的问题。这些问题通常涉及复杂系统、噪声输入等。下面是一些没有精确解的现实世界问题的例子:

  1. 你已经建立了一架飞机的计算机模型,并想确定飞机在不同的天气条件下能飞行多久。

  2. 你想要根据地下水扩散模型来确定拟建工厂的化学污水是否会影响附近居民的供水。

  3. 你有一个机器人,它能从相机捕捉到嘈杂的图像,并想恢复这些图像所描绘的物体的三维结构。

  4. 你想计算如果你走了特定的一步后在国际象棋中获胜的概率。

尽管这些类型的问题不能精确地解决,我们通常可以通过蒙特卡罗抽样方法获得近似解。在蒙特卡罗方法中,关键的思想是采集多个样本,这样就可以估计出解决方案。[1]

什么是抽样?

术语抽样意味着从某种概率分布中产生随机值。例如,从投掷一个六面骰子得到数值就是一个样本,洗牌后从牌堆顶部抽出牌就是一个样本,飞镖击中飞镖板的位置也是一个样本。这些不同样本之间的唯一区别是它们是由不同的概率分布生成的。在骰子的情况下,分布在六个值上的权重相等。在卡牌的情况下,分布在 52 个值上的权重相等。在飞镖板的情况下,分布将权重分布在一个圆形区域(尽管它可能不是均匀分布的,这取决于你作为飞镖运动员的技能水平)。

我们通常使用抽样有两种用途。第一种用途只是生成一个随机值供以后使用:例如,在计算机扑克牌游戏中随机抽牌。第二种用途是用于估计。例如,如果你怀疑你的朋友在玩有问题的骰子,你可能会想掷骰子很多次,看看是否有些数字出现的频率比你预期的要多。或者,你可能只想描述一下可能性的范围,就像上面的飞机例子一样。天气是一个相当复杂的系统,这意味着不可能精确计算飞机是否能在特定的天气情况下生存下来。相反,你可以模拟飞机在许多不同天气条件下的行为多次,这样你就可以看到飞机在哪些情况下最容易出故障。

基于抽样和概率的程序设计

与计算机科学中的大多数应用程序一样,在使用影响代码整体清洁度、一致性和正确性的抽样和概率进行编程时,你可以做出设计决策。在本文中,我们将通过一个简单的例子来说明如何在电脑游戏中随机抽取物品。特别是,我们将重点关注与概率有关的设计决策,包括抽样和评估概率的函数,对数的使用,是否能够再现,以及将生成样本的过程与特定应用分离。

关于符号的简要说明

我们将使用像 这样的数学符号来表示 是随机变量的概率密度函数(PDF)或概率质量函数(PMF)。PDF是一个连续函数 ,例如 ,而PMF是一个离散函数 ,例如 ,其中 是所有整数的集合。

在飞镖板的情况下,概率分布将是一个连续的 PDF,而在骰子的情况下,概率分布将是一个离散的 PMF。在这两种情况下,所有 都满足 ,即概率必须为非负。

对于概率分布,我们可能需要做两件事。给定一个值(或位置),我们可能需要计算该位置的概率密度(或质量)。在数学表示中,我们将其写成 (值 的概率密度)。

给定 PDF 或 PMF,我们希望能与概率分布成比例的方式抽样(这样我们就更有可能在概率更高的地方获得样本)。在数学表示中,我们将其写成 ,以表示 以比例 抽样。

抽样魔法物品

下面看一个简单的例子来演示与概率编程相关的各种设计决策,我们假设正在编写一个角色扮演游戏(RPG)。我们想要一种方法来产生随机怪物掉出的魔法物品的额外属性。我们可能会决定,我们希望一个物品获取的最高额外属性是+5,而高属性比低属性的可能性要小。如果是额外属性值上的随机变量,则:

我们还可以指定有六个属性(灵巧、体质、力量、智力、智慧和魅力)来分配额外属性。因此,一个有 +5 额外属性的物品可以将这些点数分布在不同的属性中(例如+2智慧和+3智力),或者集中在一个属性中(例如 +5 魅力)。

我们如何使这个分布随机抽样?最简单的方法可能是先抽样总体物品额外属性,然后再抽样额外属性在统计数据中的分布方式。额外属性的概率分布及其分配方式都是多项式分布的实例。

多项式分布

当你有几个可能的结果,并且你想描述每一个结果发生的概率时,可以使用多项式分布。用来解释多项式分布的经典例子是球和箱子。假设你有一个箱子,里面有不同颜色的球(例如,30%的红色,20%的蓝色,50%的绿色)。你拿出一个球,记录它的颜色,把它放回箱子里,然后重复这个动作多次。在这种情况下,结果对应于拥有特定颜色的球,并且每个结果的概率对应于该颜色的球的比例(例如,对于抽出蓝色球的结果,概率为 )。然后用多项式分布来描述当抽出多个球时结果的可能组合(例如两个绿色和一个蓝色)。

本节中的代码位于文件 multinomial.py 中。

MultinomialDistribution

一般来说,一个分布有两个使用场景:我们可能想要从这个分布中抽样,或者我们可能想要评估一个或多个样本在该分布的 PMF 或 PDF 下的概率。虽然执行这两个功能所需的实际计算不太相同,但它们依赖于一个共同的信息:分布的参数是什么。在多项式分布的情况下,参数是事件概率,(对应于上述球和缸例子中不同颜色球的比例)。

最简单的解决方案是简单地创建两个采用相同参数但其它方面独立的函数。但是,我通常会选择使用一个类来表示我的分布。这样做有几个好处:

  1. 你只需要在创建类时传入一次参数。
  2. 对于一个分布,我们可能还想知道一些其它属性:均值、方差、导数等。一旦我们可以对一个公共对象操作不同的函数,那么使用一个类就更方便了,而不是将相同的参数传递给许多不同的函数。
  3. 检查参数值是否有效是经常需要做的事情(例如,在多项式分布的情况下,事件概率的向量 应和为 1)。在类的构造函数中执行一次检查比每次调用一个函数更加高效。
  4. 有时计算 PMF 或 PDF 涉及到计算常量值(给定参数)。对于类,我们可以在构造函数中预先计算这些常量,而不是每次调用 PMF 或 PD F函数时都要计算它们。

实际上,这是许多统计包的工作方式,包括SciPy自己位于 scipy.stats 模块的分布。然而,当我们使用其它 SciPy 函数时,我们没有使用它们的概率分布,因此这既是为了说明,也是因为 SciPy 中目前没有多项式分布。

下面是类的构造函数代码:

import numpy as np

class MultinomialDistribution(object):

    def __init__(self, p, rso=np.random):
        """初始化多项式随机变量。

        Parameters
        ----------
        p: 长度为 `k` 的 numpy 数组
            结果概率
        rso: numpy RandomState 对象(default: np.random)
            随机数发生器

        """
        # 检查概率总和是否为1。如果不为1,则出了问题!我们使用'np.isclose'而不是精确检查
        # 因为在很多情况下,由于浮点数不一定完全相等。
        if not np.isclose(np.sum(p), 1.0):
            raise ValueError("outcome probabilities do not sum to 1")

        # 保存传入的参数
        self.p = p
        self.rso = rso 
        # 预计算 log 概率,供 log-PMF 使用,用于 `self.p` 的每个元素(函数 `np.log` 运行
        # 在 NumPy数组的每个元素上)
        self.logp = np.log(self.p)

该类以事件概率 和一个名为 rso 的变量作为参数。首先,构造函数检查参数是否有效;即 p 的和为 1。然后它存储传入的参数,并使用事件概率来计算事件 log 概率。(稍后我们将探讨为什么这是必要的)。rso 对象是我们稍后将用来生成随机数的对象。(稍后我们还会详细讨论它是什么)。

在进入类的其余部分之前,让我们先看一下与构造函数相关的两点。

描述性变量名与数学变量名

通常,更建议程序员使用描述性变量名:例如,使用名称 independent_variabledependent_variable 相比使用 xy会被认为是更好的做法。一个标准的经验法则是永远不要使用只有一个或两个字符的变量名。但是,你会注意到,在多项式分布类的构造函数中,我们使用了变量名 p,这违反了典型的命名约定。

虽然我同意这样的命名约定应该适用于几乎所有领域,但有一个例外:数学。编写数学方程式的困难在于,这些方程式通常只有一个字母的变量名:、、 等。因此,如果要将它们直接转换为代码,最简单的变量名应该是 x、y 和 alpha。显然,这些变量名信息不是很多(名称 x 无法传递太多信息),但是描述性的变量名也会使代码和等式之间的切换变得更加困难。

我认为,在编写直接实现公式的代码时,应该使用与公式中相同的变量名。这样就很容易看出代码的哪些部分实现了公式的哪一部分。当然,这会使代码更难单独理解,因此注释需要能够更好地解释各种计算的目标。如果一篇学术论文中列出了方程,则注释应引用方程编号,以便查找。

引入 NumPy

你可能已经注意到我们导入 numpy 模块为 np。这是数值计算领域的标准做法,因为 NumPy 提供了大量有用的函数,其中许多函数甚至可以在单个文件中使用。在本文的简单示例中,我们只使用了 11 个 NumPy 函数,通常数量会高得多:在一个项目中使用大约 40 个不同的 NumPy 函数并不少见!

有几个方法可以导入 NumPy。我们可以使用 from numpy import *,但这通常是一种糟糕的风格,因为这使得很难确定函数来自何处。我们可以使用from numpy import array, log, ... 单独导入函数,但这在函数很多时变得笨重。我们可以只使用 import numpy,但这通常会导致代码更难阅读。以下两个例子都很难阅读,但是使用 np的例子相比 numpy 更清楚:

>>> numpy.sqrt(numpy.sum(numpy.dot(numpy.array(a), numpy.array(b))))
>>> np.sqrt(np.sum(np.dot(np.array(a), np.array(b))))

从多项式分布抽样

从多项式分布中获取样本实际上相当简单,因为NumPy为我们提供了一个函数:np.random.multinomial[2]

尽管这个功能已经存在,但我们可以围绕它做一些设计决策。

播种随机数生成器

即使我们抽取一个随机样本,我们有时也希望我们的结果是可重复的:虽然这些数字看起来是随机的,但如果我们再次运行这个程序,我们希望它使用相同的“随机”数序列。

为了产生这样的“可重复随机”数字,我们需要告诉我们的抽样函数如何生成随机数。我们可以通过使用 NumPy 的RandomState 对象来实现这一点,它本质上是一个可以传入数据的随机数生成器对象。它的大部分功能与 np.random 相同;不同的是我们可以控制随机数的来源。我们创建如下:

>>> import numpy as np
>>> rso = np.random.RandomState(230489)

其中传递给 RandomState 构造函数的数字是随机数生成器的种子。只要我们用相同的种子实例化它,RandomState 对象将以相同的顺序生成相同的“随机”数,从而确保可复制性:

>>> rso.rand()
0.5356709186237074
>>> rso.rand()
0.6190581888276206
>>> rso.rand()
0.23143573416770336
>>> rso.seed(230489)
>>> rso.rand()
0.5356709186237074
>>> rso.rand()
0.6190581888276206

前面,我们看到构造函数使用了一个名为 rso 的参数。这个 rso 变量是一个已经初始化的 RandomState 对象。我喜欢让 RandomState 对象成为一个可选的参数:自己选择是否使用它是很方便的,但是我确实希望可以选择使用它(我做不到只使用 np.random 模块)。

因此,如果没有给出 rso 变量,那么构造函数默认为 np.random.multinomial。否则,它将使用 RandomState 对象本身的多项式采样器[3]

什么是参数?

一旦我们决定是否使用 np.random.multinomial 或者rso.multinomial,抽样只需调用相应函数即可解决。然而,我们可以考虑另一个问题:什么算参数?

前面我说过结果概率 是多项式分布的参数。然而,根据你询问谁,事件的数量 也可以是多项式分布的一个参数。那么,为什么不把 作为构造函数的参数呢?

这个问题虽然在多项式分布中相对特殊,但实际上在处理概率分布时经常出现,答案实际上取决于使用情况。对于一个多项式,你能假设事件的数目总是相同的吗?如果能,那么最好将 作为参数传递给构造函数。如果不是,那么要求在对象创建时指定 会有很大的限制,甚至可能要求你在每次需要抽样时都创建一个新的分发对象!

我通常不喜欢受到代码的限制,因此选择将 作为抽样函数的参数,而不是将其作为构造函数的参数。另一种解决方案是让 作为构造函数的参数,但也包含允许更改 值的方法,从而不必创建一个全新的对象。不过,就我们的目的而言,这个解决方案可能有些过头了,所以我们将坚持把它作为抽样函数的一个参数:

    def sample(self, n):
        """从结果概率 `self.p` 的多项式分布描绘 `n` 个事件的抽样

        Parameters
        ----------
        n: integer
            全部事件的数量

        Returns
        -------
        长度为 `k` 的 numpy 数组
            每个结果的抽样出现次数

        """
        # numpy.random.multinomial 函数用于从多项式中获取样本
        x = self.rso.multinomial(n, self.p)
        return x

计算多项式 PMF

尽管我们不需要显式地计算我们生成的魔法物品的概率,但最好还是编写一个函数来计算分布的概率质量函数(PMF)或概率密度函数(PDF)。为什么?

一个原因是我们可以用它来测试:如果我们用我们的抽样函数获取许多样本,那么它们应该近似于精确的PDF或PMF。如果经过多次抽样后,近似值很差或明显错误,那么我们就知道代码中的某个地方有一个 bug。

实现 PMF 或 PDF 的另一个原因是,通常情况下,你开始可能没意识到,但稍后会实际需要它。例如,我们可能希望将随机生成的项分类为常见项、不常见项和及其罕见项,具体取决于它们生成的可能性。为了确定这一点,我们需要能够计算 PMF。

最后,在许多情况下,你的特定使用场景将要求你从一开始就实现 PMF 或 PDF。

多项式PMF公式

形式上,多项式分布具有以下公式:

是长度为 的向量,指定每个事件发生的次数, 是指定每个事件发生概率的向量。如上所述,事件概率 是分布的参数。

上面方程中的阶乘实际上可以用一个特殊函数 表示,称为伽玛函数。当我们开始编写代码时,使用伽玛函数比使用阶乘更方便、更有效,因此我们将使用重写公式:

使用对数值

在编写代码实现上述公式之前,我要强调一个编写概率相关代码最重要设计决策:使用 log 值。这意味着我们不应该直接使用概率 ,而应该使用对数概率,。这是因为概率会很快变得很小,导致下溢错误。

要引发下溢错误,请考虑概率必须介于 0 和 1 之间(包括0和1)。NumPy 有一个有用的函数 finfo,它将告诉我们系统中浮点值的限制。例如,在 64 位机器上,我们看到最小可用正数(通过 tiny 给出)是:

>>> import numpy as np
>>> np.finfo(float).tiny
2.2250738585072014e-308

虽然这看起来很小,但遇到这种规模甚至更小的概率并不罕见。此外,将概率相乘是一种常见的操作,然而,如果我们尝试以非常小的概率进行此操作,我们会遇到下溢问题:

>>> tiny = np.finfo(float).tiny
>>> # if we multiply numbers that are too small, we lose all precision
>>> tiny * tiny
0.0

然而,使用对数有助于缓解这个问题,因为我们可以用对数表示比正常情况下更大范围的数字。正式来说,log 的范围从 到 0。实际上,它的范围是 finfo返回的 min(这是它可以表示的最小值) 到 0。mintiny 的对数小得多(如果我们不在对数空间中工作,这将是我们的下限):

>>> # this is our lower bound normally
>>> np.log(tiny)
-708.39641853226408
>>> # this is our lower bound when using logs
>>> np.finfo(float).min
-1.7976931348623157e+308

因此,通过使用对数值,我们可以大大扩展可表示数字的范围。此外,我们可以使用对 log 使用加法标识乘法,因为 。因此,如果我们使用 log 进行上述乘法,就不必太过担心由于下溢而导致的精度损失:

>>> # the result of multiplying small probabilities
>>> np.log(tiny * tiny)
-inf
>>> # the result of adding small log probabilities
>>> np.log(tiny) + np.log(tiny)
-1416.7928370645282

当然,这个解决方案不是灵丹妙药。如果我们需要从对数中得出数字(例如,增加概率,而不是将概率相乘),那么我们又回到了下溢:

>>> tiny*tiny
0.0
>>> np.exp(np.log(tiny) + np.log(tiny))
0.0

不过,用 log 进行所有计算可以省去很多麻烦。如果我们需要回到原来的数字,我们可能会被迫损失精度,但我们至少保留了一些关于概率的信息,足以对它们进行比较,否则这些信息也会丢失。

编写 PMF 代码

现在我们已经了解了使用对数的重要性,我们可以编写函数来计算 log-PMF:

    def log_pmf(self, x):
        """计算一个对于 `x` 有结果概率 `self.p`的多项式的log概率函数

        Parameters
        ----------
        x: 长度为 `k` 的 numpy 数组
            每个结果的出现次数

        Returns
        -------
        计算出的`x` 的 log-PMF 

        """
        # 获取事件总数
        n = np.sum(x)

        # 相当于 log(n!)
        log_n_factorial = gammaln(n + 1)
        # 相当于 log(x1! * ... * xk!)
        sum_log_xi_factorial = np.sum(gammaln(x + 1))

        # 如果 self.p 的一个值是0,那么self.logp 的对应值是 -inf。
        # 如果 x 的对应值是0,那么它们相乘会得到 nan,但我们希望它是0.
        log_pi_xi = self.logp * x
        log_pi_xi[x == 0] = 0
        # 相当于 log(p1^x1 * ... * pk^xk)
        sum_log_pi_xi = np.sum(log_pi_xi)

        # 把它们放到一起
        log_pmf = log_n_factorial - sum_log_xi_factorial + sum_log_pi_xi
        return log_pmf

在大多数情况下,这是对上述方多项式 PMF 公式的一个简单实现。gammaln 函数来自 scipy.special 包,并计算 log-gamma 函数 。如上所述,使用 gamma 函数比使用阶乘函数更方便;这是因为SciPy提供了一个 log-gamma 函数,而不是 log-factorial 函数。我们可以自己计算一个对数阶乘,比如:

log_n_factorial = np.sum(np.log(np.arange(1, n + 1)))
sum_log_xi_factorial = np.sum([np.sum(np.log(np.arange(1, i + 1))) for i in x])

但是如果我们使用 SciPy 中已经内置的 gamma 函数,那么它更容易理解,更容易编码,计算效率也更高。

我们需要解决一个边界问题:当一个概率为 0 时。当 时,则 。这在大部分情况下没问题,除了负无穷大乘以 0 的时候:

>>> # it's fine to multiply infinity by integers...
>>> -np.inf * 2.0
-inf
>>> # ...but things break when we try to multiply by zero
>>> -np.inf * 0.0
nan

nan 的意思是“不是一个数字”,处理它是一件痛苦的事情,因为大多数使用 nan 的计算都会导致另一个 nan。所以,如果我们不处理 和 的情况,我们将得到一个 nan。这将与其他数字相加,产生另一个 nan,这样毫无用处。为了处理这个问题,我们专门检查 的情况,并将结果 也设置为零。

让我们回到使用 log 的讨论。即使我们真的只需要 PMF,而不需要log-PMF,通常最好先用对数计算它,再将它指数化:

    def pmf(self, x):
        """计算一个对于 `x` 有结果概率 `self.p`的多项式的概率质量函数

        Parameters
        ----------
        x: 长度为 `k` 的 numpy 数组
                每个结果的出现次数

        Returns
        -------
        计算出的 `x` 的PMF

        """
        pmf = np.exp(self.log_pmf(x))
        return pmf

为了进一步说明使用对数的重要性,我们可以看一个多项式示例:

>>> dist = MultinomialDistribution(np.array([0.25, 0.25, 0.25, 0.25]))
>>> dist.log_pmf(np.array([1000, 0, 0, 0])
-1386.2943611198905
>>> dist.log_pmf(np.array([999, 0, 0, 0])
-1384.9080667587707

在这种情况下,我们得到的概率非常小(你会注意到,它比我们上面讨论的 tiny 值小得多)。这是因为 PMF 中的分数是巨大的:由于溢出,甚至无法计算 1000 个阶乘。但是,阶乘的对数可以是:

>>> from scipy.special import gamma, gammaln
>>> gamma(1000 + 1)
inf
>>> gammaln(1000 + 1)
5912.1281784881639

如果我们尝试使用 gamma 函数来计算 PMF,我们会得到 gamma(1000 + 1) / gamma(1000 + 1),这会产生一个 nan 值(尽管我们可以看到它应该是 1)。但是,因为我们是用对数来计算的,所以这不是一个问题,我们不需要担心!

再来抽样魔法物品

既然我们已经编写了多项式函数,我们就可以用它们来生成我们的魔法物品。为此,我们将在文件 rpg.py 中创建一个名为MagicItemDistribution的类:

class MagicItemDistribution(object):

    # 这是所有魔法属性的名称(和顺序)
    stats_names = ("dexterity", "constitution", "strength",
                   "intelligence", "wisdom", "charisma")

    def __init__(self, bonus_probs, stats_probs, rso=np.random):
        """ 使用参数 `bonus_probs` 和 `stats_probs` 初始化魔法属性概率

        Parameters
        ----------
        bonus_probs: 长度为 m 的 numpy数组
            所有额外属性的概率。数组中的每个索引对应额外属性的数量 
            (e.g.index 0 是 +0, index 1 是 +1,以此类推)

        stats_probs: 长度为 6 的 numpy数组
            所有额外属性在不同数据中如何分布的概率。
             `stats_probs[i]` 对应于第 i 个数据的额外属性的概率,
            i.e. `MagicItemDistribution.stats_names[i]`处的值

        rso: numpy RandomState 对象 (default: np.random)
            随机数生成器

        """
        # 创建我们将使用的多项式分布
        self.bonus_dist = MultinomialDistribution(bonus_probs, rso=rso)
        self.stats_dist = MultinomialDistribution(stats_probs, rso=rso)

MagicItemDistribution 类的构造函数接受参数包括额外属性概率、统计概率和随机数生成器。尽管我们在上面指定了我们想要的额外属性概率,但将概率参数编码为传入的参数是一个好主意。这使得可以在不同的分布下对项目进行抽样。例如,可能额外属性概率会随着玩家等级的提高而改变。我们将 stats 的名称编码为一个类变量 stats_names,尽管这很容易成为构造函数的另一个参数。

如前所述,有两个步骤来抽样一个魔法物品:首先抽样总体额外属性,然后抽样额外属性分布的统计数据。因此,我们将这些步骤编码为两种方法:_sample_bonus_sample_stats

    def _sample_bonus(self):
        """ 采样总体额外属性的值

        Returns
        -------
        integer
            总体额外属性

        """
        # 额外属性本质上是一个多项式分布
        # 例如 n=1 时只发生一个事件
        sample = self.bonus_dist.sample(1)

        # `sample` 是一个 0 组成的数组,同时每个额外属性对应
        # 一个位置,我们要把它变为额外属性的实际值
        bonus = np.argmax(sample)
        return bonus

    def _sample_stats(self):
        """ 采样总额外属性以及它在各个属性上的分布

        Returns
        -------
        长度为 6 的 numpy 数组
            每个属性的额外属性点数

        """

        # 首先,我们需要对总体额外属性进行采样
        bonus = self._sample_bonus()

        # 然后,我们使用不同的多项式分布来采样额外属性的分布
        # bonus 对应事件的数量
        stats = self.stats_dist.sample(bonus)
        return stats

我们本可以把它们作为一个单一的方法,特别是因为 _sample_stats 是唯一依赖 _sample_bonus 的函数,但是我选择将它们分开,因为它使抽样过程更容易理解,并且将它分解成更小的片段使代码更容易测试。

你还注意到这些方法的前缀带有下划线,这表明它们实际上并不打算在类之外使用。我们提供函数 sample 如下:

    def sample(self):
        """ 随机采样魔法物品

        Returns
        -------
        dictionary
            键是统计信息的名称,值是额外属性对应的数据

        """
        stats = self._sample_stats()
        item_stats = dict(zip(self.stats_names, stats))
        return item_stats

sample 函数的作用基本上与 _sample_stats 相同,只是它返回一个使用 stats 的名称作为键的字典。这为抽样项目提供了一个清晰易懂的接口,这样很明显能看出每个属性有多少额外加分,但如果需要采集很多样本并且需要提高效率,它还提供了只使用 _sample_stats 的选项。

我们使用类似的设计来评估物品的概率。同样,我们公开了高级方法 pmflog_pmf,它们采用 sample 生成的字典形式作为参数:

    def log_pmf(self, item):
        """ 计算给定魔法物品的 log 概率。

        Parameters
        ----------
        item: dictionary
            键是统计数据的名称,值是给定数据的额外属性的值。

        Returns
        -------
        float
             log(p(item)) 对应的值

        """

        # 首先为每个属性抽取额外属性点数,然后按顺序传递给 self._stats_log_pmf
        stats = np.array([item[stat] for stat in self.stats_names])
        log_pmf = self._stats_log_pmf(stats)
        return log_pmf

    def pmf(self, item):
        """ 计算给定魔法物品的概率

        Parameters
        ----------
        item: dictionary
            键是属性名称,值是相应属性对应的额外属性点

        Returns
        -------
        float
             p(item) 对应的值

        """
        return np.exp(self.log_pmf(item))

这些方法依赖于 _stats_log_pmf,它使用 stats 而不是字典来计算统计的概率:

    def _stats_log_pmf(self, stats):
        """ 计算不同属性的额外属性给定分布的 log-PMF

        Parameters
        ----------
        stats: 长度为 6 的 numpy 数组
            额外属性的分布

        Returns
        -------
        float
            log(p(stats)) 对应的值

        """ 
        # 这里不再包含剩余的额外属性,因此stats的和就是总的额外属性
        total_bonus = np.sum(stats)

        # 首先计算总额外属性的概率
        logp_bonus = self._bonus_log_pmf(total_bonus)

        # 然后计算 stats 的概率
        logp_stats = self.stats_dist.log_pmf(stats)

        # 然后让他们相乘(用加法,因为这里都是对数)
        log_pmf = logp_bonus + logp_stats
        return log_pmf

方法 _stats_log_pmf 反过来依赖 _bonus_log_pmf,它计算总的额外属性的概率:

    def _bonus_log_pmf(self, bonus):
        """计算给定额外属性的 log-PMF 

        Parameters
        ----------
        bonus: integer
            总的额外属性

        Returns
        -------
        float
            log(p(bonus)) 对应的值

        """

        # 确保传入的值在界限内
        if bonus < 0 or bonus >= len(self.bonus_dist.p):
            return -np.inf

        # 将标量额外属性转换为事件向量
        x = np.zeros(len(self.bonus_dist.p))
        x[bonus] = 1

        return self.bonus_dist.log_pmf(x)

我们现在可以按如下方式创建分布:

>>> import numpy as np
>>> from rpg import MagicItemDistribution
>>> bonus_probs = np.array([0.0, 0.55, 0.25, 0.12, 0.06, 0.02])
>>> stats_probs = np.ones(6) / 6.0
>>> rso = np.random.RandomState(234892)
>>> item_dist = MagicItemDistribution(bonus_probs, stats_probs, rso=rso)

创建后,我们可以使用它生成几个不同的项:

>>> item_dist.sample()
{'dexterity': 0, 'strength': 0, 'constitution': 0, 
 'intelligence': 0, 'wisdom': 0, 'charisma': 1}
>>> item_dist.sample()
{'dexterity': 0, 'strength': 0, 'constitution': 1, 
 'intelligence': 0, 'wisdom': 2, 'charisma': 0}
>>> item_dist.sample()
{'dexterity': 1, 'strength': 0, 'constitution': 1, 
 'intelligence': 0, 'wisdom': 0, 'charisma': 0}

如果我们愿意,我们可以评估抽样一项的概率:

>>> item = item_dist.sample()
>>> item
{'dexterity': 0, 'strength': 0, 'constitution': 0, 
 'intelligence': 0, 'wisdom': 2, 'charisma': 0}
>>> item_dist.log_pmf(item)
-4.9698132995760007
>>> item_dist.pmf(item)
0.0069444444444444441

估计攻击伤害

我们已经看到了抽样的一个应用:生成怪物掉落的随机物品。我在前面提到过,当你想从整体上估计某个分布时,也可以使用抽样,当然,在某些情况下,我们可以使用 MagicItemDistribution 来做到这一点。例如,假设我们的 RPG 中的伤害是通过掷一定数量的十二面骰子造成的。默认情况下,玩家可以掷一个骰子,然后根据自己的力量加成来掷额外的骰子。例如,如果他们有 +2 力量加值,他们可以掷3个骰子。造成的伤害就是骰子的总和。

我们可能想知道玩家在找到一些武器后会造成多大的伤害;例如,作为设置怪物难度的一个因素。假设在收集了两件物品之后,我们希望玩家能够在大约 50% 的战斗中在三次命中怪物后将其击败。怪物应该有多少生命值?

回答这个问题的一种方法是抽样。我们可以使用以下方案:

  1. 随机挑选一件魔法物品。
  2. 根据物品的额外属性,计算攻击时掷骰子的数量。
  3. 根据掷骰子的数量,为三次命中造成的伤害生成一个样本。
  4. 重复步骤1-3多次。这将导致伤害分布的近似值。

实现伤害分布

DamageDistribution(也在 rpg.py)显示了此方案的实现:

class DamageDistribution(object):

    def __init__(self, num_items, item_dist, num_dice_sides=1,
        num_hits=1, rso=np.random):
        """ 初始化攻击伤害分布。这个对象可以在玩家拥有 `num_items` 个属性项
        时根据 `num_hits` 取样造成的伤害值。并且攻击杀害通过掷一个 `num_dice_sides`
        面的骰子计算。

        Parameters
        ----------
        num_items: int
            玩家拥有的属性项
        item_dist: MagicItemDistribution 对象
            魔法属性项的分布
        num_dice_sides: int (default: 12)
            每个骰子的面数
        num_hits: int (default: 1)
            我们要计算的造成伤害的攻击次数
        rso: numpy RandomState 对象 (default: np.random)
            随机数生成器

        """
        # 这是对应单子骰子的整数数组
        self.dice_dist = np.arange(1, num_dice_sides + 1)
        # 创建一个多项式分布,对应于这些骰子。每一面的概率相等。
        self.dice_dist = MultinomialDistribution(
            np.ones(num_dice_sides) / float(num_dice_sides), rso=rso
        )
        self.num_hits = num_hits
        self.num_items = num_items
        self.item_dist = item_dist

    def sample(self):
        """ 采样攻击伤害

        Returns
        -------
        int
            采样的伤害

        """
        # 首先我们需要随机生成 items (传递到构造器中的数量)
        items = [self.item_dist.sample() for i in range(self.num_items)]
        # 根据属性项的数据(特别是力量strength),计算我们可以掷多少骰子
        num_dice = 1 + np.sum([item['strength'] for item in items])
        # 掷骰子并计算所造成的伤害
        dice_rolls = self.dice_dist.sample(self.num_hits * num_dice)
        damage = np.sum(self.dice_sides * dice_rolls)
        return damage

构造器以骰子的面数、我们要计算伤害的命中次数、玩家有多少道具、魔法道具的分布(MagicItemDistribution 类型)和随机状态对象作为参数。默认情况下,我们将num_dice_sides 设置为12,因为它在技术上是一个参数,且不太可能更改。类似地,我们将 num_hits 设置为1作为默认值,因为比较常见的的使用场景是我们只想为一次命中获取一个伤害样本。

然后我们在 sample 中实现实际的抽样逻辑。(注意结构与 MagicItemDistribution 的相似性。)首先,我们生成一组玩家可能拥有的魔法物品。然后,我们看看这些物品的强度统计,并从中计算出掷骰子的数量。最后,我们掷骰子(同样依赖于可靠的多项式函数)并计算由此造成的伤害。

评估概率发生了什么?

评估概率发生了什么?你可能已经注意到,在我们的 DamageDistribution 中没有包含 log_pmfpmf 函数。这是因为我们实际上不知道PMF应该是多少!这将是一个等式:

这个等式所说的是,我们需要计算每一个可能的伤害量的概率,给定每一组可能的 物品。实际上我们可以用暴力来计算,但这不太好。这实际上是一个很好的例子,我们想用抽样来近似解决一个我们无法精确计算(或者很难精确计算)的问题。因此,我们将在下一节中展示如何用许多样本来近似分布,而不是用一种方法来计算PMF。

近似分布

现在我们可以系统地回答前面的问题:如果玩家有两个物品,并且我们希望玩家能够在50%的时间内三次命中击败怪物,那么怪物应该有多少生命值?

首先,我们使用和前面相同的 item_distrso创建分布对象:

>>> from rpg import DamageDistribution
>>> damage_dist = DamageDistribution(2, item_dist, num_hits=3, rso=rso)

现在我们可以抽取一堆样本,并计算第50百分位(大于样本50%的伤害值):

>>> samples = np.array([damage_dist.sample() for i in xrange(100000)])
>>> samples.min()
3
>>> samples.max()
154
>>> np.percentile(samples, 50)
27.0

如果我们要绘制一个直方图,显示每一个伤害值的样本数量,它看起来就像下图。

damage_distribution.png

玩家可能造成的伤害范围相当广,它有一条长尾:第50百分位是27点,这意味着在一半的样本中,玩家造成的伤害不超过27点。因此,如果我们想用这个标准来设置怪物的难度,我们会给它们 27 点生命值。

总结

在本文中,我们将介绍如何编写代码,从非标准概率分布生成样本,以及如何计算这些样本的概率。在本例中,我们介绍了几种适用于一般情况的设计决策:

  1. 用一个类表示概率分布,包括用于采样和评估PMF(或PDF)的函数。
  2. 使用对数计算PMF(或PDF)。
  3. 从随机数生成器对象生成样本以实现可再现的随机性。
  4. 编写输入/输出清晰易懂的函数(例如,使用字典作为 MagicItemDistribution.sample 的输出)同时仍然公开那些函数的不太清晰但更高效的纯数字版本(例如 MagicItemDistribution._sample_stats)。

此外,我们还看到了从概率分布中抽样对于产生单个随机值(例如,打败怪物后生成单个魔法物品)和计算我们不知道的分布信息(例如,发现一个拥有两件物品的玩家可能造成多大的伤害)的用途。几乎你可能遇到的每种类型的抽样都属于这两种类型中的一种;区别只与你从哪个分布中抽样有关,独立于这些分布的代码的一般结构保持不变。


  1. 这里假设你熟悉统计学和概率论。 ↩

  2. NumPy 包含从许多不同类型的分布中提取样本的函数。要查看完整列表,请查看随机采样模块,np.random。 ↩

  3. np.random 中的函数实际上,依赖于一个我们可以控制的随机数生成器:NumPy 的全局随机数生成器。你可以使用 np.seed 设置全局种子。 使用全局生成器与使用局部 RandomState 对象有一个权衡。如果使用全局生成器,则不必到处传递 RandomState 对象。但是,你也会冒着依赖的某些第三方代码会在你不知情的情况下使用全局生成器的风险。如果使用局部对象,则更容易发现是否存在来自你的代码之外的不确定性。 ↩

你可能感兴趣的:(500 lines or less学习笔记(二)——抽样器(sampler))