NEP 19 — 随机数生成器策略#

作者:

Robert Kern <robert.kern@gmail.com>

状态:

最终

类型:

标准跟踪

创建:

2018-05-24

更新:

2019-05-21

决议:

https://mail.python.org/pipermail/numpy-discussion/2018-July/078380.html

摘要#

在过去的十年中,NumPy 对其所有随机数分布的数流都采取了严格的向后兼容性策略。与numpy中的其他数值组件(如果结果仍然正确,则通常允许在修改时返回不同的结果)不同,我们要求随机数分布在每个版本中始终产生完全相同的数字。我们的流兼容性保证的目标是为跨 NumPy 版本的模拟提供精确的可重复性,以促进可重复的研究。但是,这项策略使得很难用更快或更精确的算法来增强任何分布。经过十年的经验和周围科学软件生态系统的改进,我们相信现在有更好的方法来实现这些目标。我们建议放宽我们严格的流兼容性策略,以消除阻碍我们接受对随机数生成能力的贡献的障碍。

现状#

我们目前的政策,全文如下

固定的种子和一系列对使用相同参数的RandomState方法的调用将始终产生相同的结果,直到舍入误差为止,除非值不正确。不正确的值将被修复,并在修复的NumPy版本中在相关的文档字符串中注明。只要之前的行为保持不变,允许扩展现有参数范围和添加新参数。

这项政策最初是在2008年11月(本质上;完整的含糊其辞的措辞随着时间的推移而增加)制定的,以回应用户希望确保构成其科学出版物基础的模拟能够在数年后使用当时最新的numpy版本精确地再现。我们热衷于支持可重复的研究,并且那还是numpy.random生命周期的早期阶段。我们没有看到太多改变分布方法的原因。

我们也没有认真考虑我们真正能够承诺的限度(在本节中,“我们”实际上是指Robert Kern,说实话)。尽管有所有含糊其辞的措辞,但我们的政策夸大了兼容性。在不同平台上构建的相同版本的numpy,或者只是以不同的方式构建,都可能导致流的变化,其稀有程度各不相同。.multivariate_normal()方法依赖于numpy.linalg函数,这是最大的问题。即使在同一平台上,如果将numpy与不同的LAPACK链接,.multivariate_normal()也可能返回完全不同的结果。更罕见的是,在不同的操作系统或CPU上构建会导致流中的差异。我们在内部使用C long整数进行整数分布(当时似乎是个好主意),并且其大小取决于平台。分布方法可能会在不同的断点处溢出其内部C longs,这取决于平台,并导致之后的所有随机变量抽取都不同。

即使所有这些都得到控制,我们的策略仍然无法在不同版本之间提供精确的保证。当正确性受到威胁时,我们仍然会应用错误修复。即使我们没有这样做,任何重要的程序也不仅仅是生成随机数。它们对这些数字进行计算,使用numpy中的其他数值算法对其进行转换,而这些算法不受如此严格的策略的约束。出于这些原因,试图维护我们随机数分布的流兼容性无助于可重复的研究。

现在,逐位可重复研究的标准做法是固定软件堆栈的所有代码版本,甚至可能一直到操作系统本身。与2008年相比,如今完成这项工作的环境要容易得多。我们现在有了pip。我们现在有了虚拟机。那些需要精确地重现模拟的人现在可以(也应该)通过使用完全相同的numpy版本来做到这一点。我们不需要维护跨numpy版本的流兼容性来帮助他们。

我们的流兼容性保证阻碍了我们改进numpy.random的能力。几位首次贡献者提交了PR以改进分布,通常是通过实现比当前存在的算法更快或更精确的算法。不幸的是,其中大多数都需要破坏流才能做到这一点。由于受到我们的政策以及我们无法规避该政策的限制,许多贡献者干脆放弃了。

实现#

randomgen项目中,对拟议的新的伪随机数生成器(PRNG)子系统的研究已经开始。新设计的细节不在本NEP的讨论范围之内,而且还有很多讨论,但我们将讨论指导任何被采纳代码演变的通用策略。我们还将概述此类新系统必须具备的一些要求,以支持本NEP中提出的策略。

首先,我们将像对待numpy的其他部分一样维护API源代码兼容性。如果我们必须进行重大更改,我们只会通过适当的弃用期和警告来执行。

其次,为了引入新功能或提高性能而破坏流兼容性将被允许,但需谨慎。此类更改将被视为功能,因此其速度不会快于标准功能发布节奏(即在X.Y版本上,而不是X.Y.Z)。出于此目的,速度慢不会被视为错误。破坏流兼容性的正确性错误修复可以在常规的错误修复版本中进行,但开发人员应考虑他们是否可以等到下一个功能版本。我们鼓励开发人员认真权衡用户因流兼容性中断而造成的痛苦与改进之间的关系。一个值得改进的例子是更改算法以显着提高性能,例如,从高斯变量生成的Box-Muller变换方法转移到更快的Ziggurat算法。一个不鼓励改进的例子是对Ziggurat表进行微调,以获得小的性能改进。

任何针对随机子系统的新设计都将提供多种核心均匀 PRNG 算法的选择。一个有前景的设计选择是将这些核心均匀 PRNG 作为其自身的轻量级对象,并使用最小的函数集(randomgen 将其称为“BitGenerators”)。更广泛的非均匀分布将是它自己的类,该类持有对这些核心均匀 PRNG 对象之一的引用,并在需要均匀随机数时简单地委托给核心均匀 PRNG 对象(randomgen 将此称为 Generator)。从 randomgen 中借用一个例子,类 MT19937 是一个实现经典梅森旋转算法的 BitGenerator。类 Generator 包装 BitGenerator 以提供所有非均匀分布方法。

# This is not the only way to instantiate this object.
# This is just handy for demonstrating the delegation.
>>> bg = MT19937(seed)
>>> rg = Generator(bg)
>>> x = rg.standard_normal(10)

我们将对这些 BitGenerator 对象上的一个选定子集的方法更严格。它们必须保证为指定的一组方法提供流兼容性,这些方法的选择是为了更容易地将它们组合起来构建其他分布,并且需要抽象出各种 BitGenerator 算法的实现细节。具体来说,

  • .bytes()

  • integers()(以前为 .random_integers()

  • random()(以前为 .random_sample()

分布类(Generator)应该具有与 RandomState 相同的所有分布方法,并具有足够接近的函数签名,以便几乎所有当前与 RandomState 实例一起工作的代码都能与 Generator 实例一起工作(忽略精确的流值)。整数分布允许一些差异:为了避免上面描述的一些跨平台问题,这些方法应该被重写为在所有平台上使用 uint64 数字。

支持单元测试#

因为我们在 numpy 的早期就做出了强大的流兼容性保证,所以对流兼容性的依赖已经超越了可重复的模拟。跨 numpy 版本保持流兼容性的一个用例是在单元测试中使用伪随机流生成测试数据。小心谨慎地,可以在小型单元测试的上下文中避免许多跨平台的不稳定性。

新的 PRNG 子系统必须提供第二个旧版分布类,该类使用与当前版本的 numpy.random.RandomState 相同的分布方法实现。此类的方法将具有严格的流兼容性保证,甚至比当前策略更严格。目的是不再修改此类,除非在 numpy 内部发生更改时保持其工作状态。所有新的开发都应该进入主要的分布类。更改流的错误修复不应应用于 RandomState;相反,有错误的分布应该在出现错误时发出警告。 RandomState 的用途将被记录为:为了向后兼容性提供某些固定的功能,以及为单元测试的有限目的提供稳定的数字,而不是使整个程序在 numpy 版本之间可重现。

为了向后兼容性,此旧版分布类必须可以通过名称 numpy.random.RandomState 访问。所有当前使用给定状态实例化 numpy.random.RandomState 的方法都应该使用相同状态实例化梅森旋转 BitGenerator。旧版分布类必须能够接受其他 BitGenerators。这里的目的是确保可以使用一致的 BitGenerator 状态编写程序,其中混合使用可能已或未从 RandomState 升级的库。旧版分布类的实例必须对 isinstance(rg, numpy.random.RandomState) 返回 True,因为当前的实用程序代码依赖于此检查。类似地,numpy.random.RandomState 实例的旧 pickle 必须正确解包。

numpy.random.*#

获取可重复伪随机数的首选最佳实践是使用种子实例化生成器对象并将其传递。 numpy.random.* 便利函数背后的隐式全局 RandomState 会导致问题,尤其是在涉及线程或其他并发形式时。全局状态始终是有问题的。当涉及可重复性时,我们绝对建议避免使用这些便利函数。

也就是说,人们确实使用它们并使用 numpy.random.seed() 来控制它们下面的状态。很难一致且有效地对 API 用法进行分类和计数,但非常常见的用法是在单元测试中,其中全局状态的许多问题不太可能出现。

本 NEP 并不建议删除这些函数或将它们更改为使用不太稳定的 Generator 分布实现。未来的 NEP 可能会有所改变。

具体来说,新的 PRNG 子系统的初始版本应将这些便利函数保留为全局 RandomState 上的方法的别名,该全局 RandomState 使用梅森旋转 BitGenerator 对象初始化。对 numpy.random.seed() 的调用将转发到该 BitGenerator 对象。此外,全局 RandomState 实例必须在此初始版本中通过名称 numpy.random.mtrand._rand 访问:Robert Kern 很久以前就向 scikit-learn 保证这个名称将是稳定的。糟糕。

为了允许某些变通方法,必须能够用任何其他 BitGenerator 对象替换全局 RandomState 下面的 BitGenerator(我们将精确的 API 细节留给新的子系统)。此后调用 numpy.random.seed() 应该只将给定的种子传递给当前的 BitGenerator 对象,而不尝试将 BitGenerator 重置为梅森旋转。 numpy.random.* 便利函数集应保持与当前相同。它们应该是 RandomState 方法的别名,而不是新的不太稳定的分布类(在上面的示例中为 Generator)。想要获得最快、最佳分布的用户可以遵循最佳实践并显式实例化生成器对象。

本 NEP 并不建议这些要求永久保留。在我们使用新的 PRNG 子系统积累经验后,我们可以在未来的 NEP 中重新审视这些问题。

替代方案#

版本控制#

长期以来,我们认为在保持流的同时允许算法改进的方法是应用某种形式的版本控制。也就是说,每当我们在其中一个分布中进行流更改时,我们都会在某个地方递增某个版本号。numpy.random 将保留所有过去的代码版本,并且将有一种方法可以获取旧版本。

我们不会这样做。如果需要从给定版本的 numpy 获取精确的逐位结果,无论是否使用随机数,都应该使用精确版本的 numpy

关于如何进行 RNG 版本控制的建议差异很大,我们不会在这里详尽列出它们。我们花费了数年时间来回讨论这些设计,但未能找到一个足够的设计。让我们将这段时间损失,更重要的是,在我们犹豫不决时损失的贡献者,作为反对该概念的证据。

具体来说,添加版本控制使 numpy.random 的维护变得困难。我们必然会保留许多相同代码的版本。安全地添加新算法仍然相当困难。

但最重要的是,版本控制从根本上难以正确 *使用*。我们希望使获取最新、最快、最佳版本的分布算法变得容易和直接;否则,有什么意义呢?使这变得容易的方法是使最新版本成为默认版本。但是默认版本必然会随着版本的发布而变化,因此用户的代码无论如何都需要更改以指定想要复制的特定版本。

添加版本控制以保持流兼容性仍然只会提供我们目前拥有的相同级别的流兼容性,以及前面描述的所有限制。鉴于此类需求的标准做法是将 numpy 的整体版本固定,因此单独对 RandomState 进行版本控制是多余的。

StableRandom#

NEP 的先前版本建议在弃用期间完全保留 RandomState,并使用新名称构建新的子系统。为了满足单元测试用例,它建议引入一个名为 StableRandom 的小型分布类。它将提供被认为在单元测试中最有用的分布方法的一个小子集,但不是完整集,因此不太可能在测试上下文之外使用。

在讨论该提案的过程中,很明显没有令人满意的子集。至少有一些项目在单元测试中使用了相当广泛的 RandomState 方法。

下游项目所有者将被迫修改其代码以适应新的 PRNG 子系统。有些修改可能只是机械的,但大部分工作将是乏味的重复劳动,而对下游项目没有任何积极的改进,只是避免被破坏。

此外,根据旧提案,我们将会有一个相当长的弃用期,在此期间RandomState将与新的BitGenerator和Generator类系统并存。保持RandomState的实现不变意味着它无法使用新的BitGenerator状态对象。开发同时使用已升级和未升级库的程序将需要管理两套PRNG状态。这名义上是有限制的,但我们打算让弃用期非常长。

目前的提案解决了所有这些问题。所有当前对RandomState的使用将永久有效,尽管文档可能会劝退某些用法。单元测试可以继续使用RandomState的全部方法。混合的RandomState/Generator代码可以安全地共享公共BitGenerator状态。未修改的RandomState代码可以使用替代BitGenerator类可设置流的新特性。

讨论#