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,通常是通过实现比现有算法更快或更准确的算法。不幸的是,其中大多数都需要打破流才能实现。由于受到我们政策的阻碍以及我们无法规避该政策,许多贡献者因此选择放弃。
实现#
新的伪随机数生成器(PRNG)子系统的开发已在 randomgen 项目中进行。新设计的具体细节超出本 NEP 的范围,需要大量讨论,但我们将讨论指导所采用代码演进的通用政策。我们还将概述新系统必须具备的少数几个要求,以支持本 NEP 中提出的政策。
首先,我们将像对待 numpy
的其余部分一样,保持 API 源代码兼容性。如果我们*必须*做出破坏性更改,我们只会通过适当的废弃期和警告来实现。
其次,为了引入新功能或提高性能而打破流兼容性将是*允许*的,但需要*谨慎*。此类更改将被视为功能,因此其发布速度不会快于标准功能发布节奏(即在 X.Y
发布中,绝不是 X.Y.Z
)。为此,慢速不会被视为错误。导致流兼容性中断的正确性错误修复可以照常在错误修复版本中进行,但开发人员应考虑是否可以等到下一个功能版本。我们鼓励开发人员权衡用户因流兼容性中断而带来的痛苦与改进之间的利弊。一个值得改进的例子是改变算法以显著提高性能,例如,从高斯变量生成的Box-Muller 变换方法转向更快的Ziggurat 算法。一个不鼓励改进的例子是为了微小的性能提升而对 Ziggurat 表进行微调。
任何随机子系统的新设计都将提供多种核心均匀 PRNG 算法选择。一个有前景的设计选择是使这些核心均匀 PRNG 成为具有最小方法集(randomgen 称之为“位生成器”)的轻量级对象。更广泛的非均匀分布将是它自己的类,它持有对这些核心均匀 PRNG 对象之一的引用,并在需要均匀随机数时简单地委托给核心均匀 PRNG 对象(randomgen 称之为生成器)。借鉴 randomgen 的一个例子,MT19937
类是一个实现经典 Mersenne Twister 算法的位生成器。Generator
类则封装位生成器以提供所有非均匀分布方法。
# 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
的方式都应使用相同状态实例化 Mersenne Twister BitGenerator。遗留的分布类*必须*能够接受其他 BitGenerator。这里的目的是确保可以通过混合使用可能已升级或尚未升级 RandomState
的库来编写具有一致 BitGenerator 状态的程序。遗留分布类的实例*必须*对 isinstance(rg, numpy.random.RandomState)
返回 True
,因为目前有实用代码依赖于此检查。同样,旧的 numpy.random.RandomState
实例的 pickles *必须*正确反序列化。
numpy.random.*
#
获取可复现伪随机数的首选最佳实践是使用种子实例化一个生成器对象并将其传递。 numpy.random.*
便捷函数背后隐式的全局 RandomState
可能会导致问题,尤其是在涉及线程或其他并发形式时。全局状态总是存在问题。我们明确建议在涉及可复现性时避免使用这些便捷函数。
话说回来,人们确实使用它们,并使用 numpy.random.seed()
来控制其底层的状态。一致且有效地分类和统计 API 使用情况可能很困难,但一个非常常见的用法是在单元测试中,在这些情况下,全局状态的许多问题不太可能出现。
本 NEP 不建议移除这些函数或将其更改为使用不那么稳定的 Generator
分布实现。未来的 NEP 可能会。
具体而言,新的 PRNG 子系统的初始版本*应*将这些便捷函数保留为全局 RandomState
上方法的别名,该 RandomState
使用 Mersenne Twister BitGenerator 对象进行初始化。对 numpy.random.seed()
的调用将被转发到该 BitGenerator 对象。此外,在此初始版本中,全局 RandomState
实例*必须*可通过名称 numpy.random.mtrand._rand
访问:Robert Kern 很久以前就向 scikit-learn
承诺这个名称将是稳定的。哎呀。
为了允许某些变通方案,*必须*能够将全局 RandomState
下的 BitGenerator 替换为任何其他 BitGenerator 对象(我们把具体的 API 细节留给新的子系统)。此后调用 numpy.random.seed()
*应该*只是将给定的种子传递给当前 BitGenerator 对象,而不尝试将 BitGenerator 重置为 Mersenne Twister。 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 类似可设置流的新功能。
讨论#
版权#
本文档已进入公共领域。