并行随机数生成#

已实现四种主要策略,可用于在多个进程(本地或分布式)中生成可重复的伪随机数。

SeedSequence 生成#

NumPy 允许您通过其 spawn() 方法生成新的(很有可能独立的)BitGeneratorGenerator 实例。此生成由用于初始化位生成器随机流的 SeedSequence 实现。

SeedSequence 实现了一种算法 来处理用户提供的种子(通常是某种大小的整数),并将其转换为 BitGenerator 的初始状态。它使用散列技术来确保将低质量种子转换为高质量初始状态(至少,概率非常高)。

例如,MT19937 的状态由 624 个 uint32 整数组成。一种简单的方法是将 32 位整数种子设置为状态的最后一个元素,并将其余元素保留为 0。这对 MT19937 来说是一个有效状态,但不是一个好的状态。如果 0 太多,梅森旋转算法 会受到影响。类似地,两个相邻的 32 位整数种子(即 1234512346)会产生非常相似的流。

SeedSequence 通过使用具有良好 雪崩特性 的整数散列序列来避免这些问题,以确保输入中翻转任何位都有大约 50% 的概率翻转输出中的任何位。两个非常接近的输入种子将产生彼此非常远的初始状态(概率非常高)。它也以这样一种方式构建,您可以提供任意大小的整数或整数列表。SeedSequence 将获取您提供的所有位并将它们混合在一起,以生成消费 BitGenerator 初始化自身所需的任意位。

这些特性加在一起意味着我们可以安全地将通常用户提供的种子与简单的递增计数器混合在一起,以获得彼此(以非常高的概率)独立的 BitGenerator 状态。我们可以将其包装成一个易于使用且难以误用的 API。请注意,虽然 SeedSequence 试图解决与用户提供的较小种子相关的许多问题,但我们仍然 建议 使用 secrets.randbits 生成具有 128 位熵的种子,以避免人为选择的种子引入的剩余偏差。

from numpy.random import SeedSequence, default_rng

ss = SeedSequence(12345)

# Spawn off 10 child SeedSequences to pass to child processes.
child_seeds = ss.spawn(10)
streams = [default_rng(s) for s in child_seeds]

为了方便起见,无需直接使用 SeedSequence。上述 streams 可以通过 spawn 直接从父生成器生成。

parent_rng = default_rng(12345)
streams = parent_rng.spawn(10)

子对象也可以生成孙对象,依此类推。每个子对象都有一个 SeedSequence,其中包含其在生成的子对象树中的位置与用户提供的种子混合在一起,以生成独立的(概率非常高)流。

grandchildren = streams[0].spawn(4)

此功能允许您在何时以及如何拆分流方面做出本地决策,而无需进程之间进行协调。您不必预先分配空间以避免重叠或从公共全局服务请求流。这种通用的“树散列”方案 并非 numpy 独有,但尚未广泛使用。Python 拥有越来越灵活的并行化机制,并且此方案非常适合此类用途。

使用此方案,如果知道派生的流的数量,则可以估计冲突概率的上限。SeedSequence 默认情况下将其输入(种子和生成树路径)散列到 128 位池中。悲观估计([1])在该池中发生冲突的概率约为 \(n^2*2^{-128}\),其中 n 是生成的流数。如果程序使用激进的百万个流(约 \(2^{20}\)),则至少有一对流相同的概率约为 \(2^{-88}\),这属于完全可以忽略的范围([2])。

整数种子序列#

如上一节所述,SeedSequence 不仅可以获取整数种子,还可以获取任意长度的(非负)整数序列。如果稍加注意,您可以使用此功能设计特定于应用的方案,以获得与生成类似的安全保证的安全并行 PRNG 流。

例如,一个常见的用例是工作进程为整个计算传递一个根种子整数,以及一个整数工作程序 ID(或更细粒度的内容,如作业 ID、批次 ID 或类似内容)。如果这些 ID 是确定性和唯一地创建的,那么可以通过将 ID 和根种子整数组合到列表中来派生可重复的并行 PRNG 流。

# default_rng() and each of the BitGenerators use SeedSequence underneath, so
# they all accept sequences of integers as seeds the same way.
from numpy.random import default_rng

def worker(root_seed, worker_id):
    rng = default_rng([worker_id, root_seed])
    # Do work ...

root_seed = 0x8c3c010cb4754c905776bdac5ee7501
results = [worker(root_seed, worker_id) for worker_id in range(10)]

这可以用来替换过去使用的一些不安全的策略,这些策略试图将根种子和 ID 组合回一个单一的整数种子值。例如,用户经常会将工作程序 ID 添加到根种子中,尤其是在使用旧版 RandomState 代码时。

# UNSAFE! Do not do this!
worker_seed = root_seed + worker_id
rng = np.random.RandomState(worker_seed)

确实,对于以这种方式构建的并行程序的任何一次运行,每个工作程序都将具有不同的流。但是,程序多次调用且种子不同的情况下,很有可能得到重叠的工作程序种子集。更改根种子(仅增加一两次)在作者的亲身经历中并不少见。如果工作程序种子也是通过工作程序 ID 的小增量派生的,那么工作程序的子集将返回相同的结果,从而导致总体结果集出现偏差。

将工作程序 ID 和根种子组合成一个整数列表消除了这种风险。延迟播种实践仍然相当安全。

此方案确实要求额外的 ID 必须是唯一的并且是确定性创建的。这可能需要工作进程之间的协调。建议将不同的 ID 放在不变量根种子之前spawn 附加用户提供的种子之后的整数,因此,如果您可能同时混合使用此特定于应用的机制和生成,或者将对象传递给可能正在生成的库代码,那么在开头添加工作程序 ID 比附加它们更安全,以避免冲突。

# Good.
worker_seed = [worker_id, root_seed]

# Less good. It will *work*, but it's less flexible.
worker_seed = [root_seed, worker_id]

考虑到这些注意事项,防止冲突的安全保证与上一节中讨论的生成大致相同。算法机制是相同的。

独立流#

Philox 是一个基于计数器的 RNG,它通过使用弱密码原语加密递增计数器来生成值。种子确定用于加密的密钥。唯一的密钥创建唯一、独立的流。Philox 允许您绕过播种算法直接设置 128 位密钥。类似但不同的密钥仍将创建独立的流。

import secrets
from numpy.random import Philox

# 128-bit number as a seed
root_seed = secrets.getrandbits(128)
streams = [Philox(key=root_seed + stream_id) for stream_id in range(10)]

此方案确实要求您避免重复使用流 ID。这可能需要并行进程之间的协调。

跳过 BitGenerator 状态#

jumped 会推进 BitGenerator 的状态,就好像已经抽取了大量随机数一样,并返回一个具有此状态的新实例。抽取的具体数量因 BitGenerator 而异,范围从 \(2^{64}\)\(2^{128}\)。此外,“就好像”抽取的数量也取决于特定 BitGenerator 生成的默认随机数的大小。支持 jumped 的 BitGenerator 以及 BitGenerator 的周期、跳跃大小和默认无符号随机数中的位数如下所示。

BitGenerator

周期

跳跃大小

每次抽取的位数

MT19937

\(2^{19937}-1\)

\(2^{128}\)

32

PCG64

\(2^{128}\)

\(~2^{127}\) ([3])

64

PCG64DXSM

\(2^{128}\)

\(~2^{127}\) ([3])

64

Philox

\(2^{256}\)

\(2^{128}\)

64

jumped 可用于生成足够长的块,这些块应该足够长以避免重叠。

import secrets
from numpy.random import PCG64

seed = secrets.getrandbits(128)
blocked_rng = []
rng = PCG64(seed)
for i in range(10):
    blocked_rng.append(rng.jumped(i))

使用 jumped 时,必须注意不要跳到已经使用过的流。在上面的例子中,不能随后使用 blocked_rng[0].jumped(),因为它会与 blocked_rng[1] 重叠。与独立流一样,如果主进程想要通过跳跃分离出 10 个以上的流,则需要从 range(10, 20) 开始,否则它将重新创建相同的流。另一方面,如果仔细构建流,则可以保证流不会重叠。