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 函数。即使在同一平台,如果您使用不同的 LAPACK 库链接 NumPy,.multivariate_normal() 可能会返回完全不同的结果。更少见的情况是,在不同的操作系统或 CPU 上构建也可能导致数字流的差异。我们在整数分布的内部使用 C long 整数(当时似乎是个好主意),而这些整数的大小可能因平台而异。分布方法可能会因平台而异地溢出其内部 C long 整数,导致之后的所有随机变数抽取都不同。

即使所有这些都得到控制,我们的政策仍然无法跨版本提供精确的保证。当正确性受到威胁时,我们仍然会进行错误修复。即使我们不这样做,任何非微小的程序都不仅仅是抽取随机数。它们会对这些数字进行计算,并使用 NumPy 中不受如此严格政策约束的数值算法进行转换。出于这些原因,试图维持我们随机数分布的流兼容性并不能帮助可复现的研究。

如今,实现位对位可复现研究的标准做法是固定您的软件栈的所有版本,甚至可能包括操作系统本身。今天实现这一点比 2008 年要容易得多。我们现在有 pip。我们现在有虚拟机。那些需要精确重现模拟结果的人现在可以(也应该)通过使用完全相同的 NumPy 版本来做到这一点。我们无需为了帮助他们而维护 NumPy 版本之间的流兼容性。

我们的流兼容性保证阻碍了我们改进 numpy.random 的能力。许多首次贡献者提交了 PR 来改进分布,通常是通过实现一个比现有算法更快或更准确的算法。不幸的是,大多数这些改进都需要打破流兼容性才能实现。由于政策的限制以及我们无法绕过该政策,许多贡献者最终选择了放弃。

实现#

randomgen 项目中,一项关于新的伪随机数生成器 (PRNG) 子系统的提议工作已经在进行中。新设计的具体细节超出了本 NEP 的范围,仍需大量讨论,但我们将讨论指导所采用代码演进的一般性策略。我们还将概述新系统必须具备的一些要求,以支持本 NEP 中提出的策略。

首先,我们将像对待 NumPy 的其他部分一样,维护 API 的源代码兼容性。如果我们 **必须** 进行破坏性更改,我们将只在提供适当的弃用期和警告后进行。

其次,为了引入新功能或提高性能而打破流兼容性将 **允许** 这样做,但 **需要谨慎**。此类更改将被视为功能,因此其发布速度不会快于标准功能发布周期(即在 X.Y 版本中,绝不会在 X.Y.Z 版本中)。为了此目的,运行缓慢将不被视为错误。破坏流兼容性的正确性错误修复可以像往常一样在 bugfix 版本中进行,但开发者应考虑是否可以等到下一个功能版本。我们鼓励开发者在流兼容性中断给用户带来的痛苦与改进之处之间进行权衡。一个有价值的改进例子是改变算法以显著提高性能,例如,从高斯变数生成的 Box-Muller 变换 方法切换到更快的 Ziggurat 算法。一个不被鼓励的改进例子是略微调整 Ziggurat 表以获得小幅性能提升。

随机子系统的任何新设计都将提供多种核心均匀 PRNG 算法的选择。一个有前景的设计选择是将这些核心均匀 PRNG 自身设计为轻量级对象,仅包含一小组必要的方法(randomgen 将它们称为“BitGenerators”)。更广泛的非均匀分布集合将是一个独立的类,它持有一个指向这些核心均匀 PRNG 对象之一的引用,并在需要均匀随机数时委托给核心均匀 PRNG 对象(randomgen 将其称为 Generator)。借用 randomgen 的一个例子,MT19937 类是一个 BitGenerator,实现了经典的 Mersenne Twister 算法。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;相反,有 bug 的分布应在发现 bug 时发出警告。 RandomState 的目的将记录为提供某些固定的向后兼容功能和稳定的数字,用于单元测试的有限目的,而不是实现整个程序的跨 NumPy 版本可复现性。

为了向后兼容,这个传统的分布类 **必须** 能够通过 numpy.random.RandomState 这个名字访问。所有当前使用给定状态实例化 numpy.random.RandomState 的方式都应该实例化具有相同状态的 Mersenne Twister BitGenerator。这个传统的分布类 **必须** 能够接受其他 BitGenerator。这样做的目的是确保可以编写一个程序,在混合使用已升级和未升级库的情况下,使用一致的 BitGenerator 状态。传统分布类的实例 **必须** 对 isinstance(rg, numpy.random.RandomState) 返回 True,因为当前有一些实用代码依赖于此检查。同样,旧的 numpy.random.RandomState 实例的 pickle 也 **必须** 能够正确 unpickle。

numpy.random.*#

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

尽管如此,人们确实在使用它们,并且使用 numpy.random.seed() 来控制它们底层的状态。要一致且有用地对 API 用途进行分类和计数可能很困难,但在许多情况下,例如在单元测试中,全局状态的问题不太可能出现。

本 NEP 不建议移除这些函数,也不建议将它们更改为使用不太稳定的 Generator 分布实现。未来的 NEP 可能会这样做。

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

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

本 NEP 不建议将这些要求永久保留。在拥有新 PRNG 子系统的经验之后,我们可以,也应该在未来的 NEP 中重新审视这些问题。

替代方案#

版本控制#

很长一段时间以来,我们一直认为在保持数字流的同时允许算法改进的方法是应用某种形式的版本控制。也就是说,每次我们对其中一个分布进行数字流更改时,我们都会在某处增加一个版本号。numpy.random 将保留代码的所有 past 版本,并且会有获取旧版本的方法。

我们 **不会** 这样做。如果需要获取特定 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 类可设置流的新特性。

讨论#