并行随机数生成#
已实现了四种主要策略,可用于在多个进程(本地或分布式)中生成可重复的伪随机数。
SeedSequence
生成#
NumPy 允许您通过它们的 spawn()
方法生成新的(以非常高的概率)独立的 BitGenerator
和 Generator
实例。此生成过程由用于初始化位生成器随机流的 SeedSequence
实现。
SeedSequence
实现了一种算法,用于处理用户提供的种子(通常是某种大小的整数),并将其转换为 BitGenerator
的初始状态。它使用哈希技术来确保低质量的种子能转化为高质量的初始状态(至少以非常高的概率)。
例如,MT19937
的状态由 624 个 uint32
整数组成。一种简单的处理 32 位整数种子方式是仅将状态的最后一个元素设置为 32 位种子,其余为 0。这对于 MT19937
来说是有效状态,但不是一个好的状态。当存在过多 0 时,Mersenne Twister 算法会 表现不佳。类似地,两个相邻的 32 位整数种子(即 12345
和 12346
)会产生非常相似的流。
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
不仅可以接受整数种子,还可以接受任意长度的(非负)整数序列。如果稍加注意,可以使用此功能设计 临时 方案,以获得与生成(spawning)具有相似安全保证的安全并行 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
在用户提供的种子之后附加整数,因此,如果您可能同时混合使用这种临时机制和生成(spawning),或者将您的对象传递给可能进行生成的库代码,那么将您的工作 ID 前置而不是追加会更安全一些,以避免碰撞。
# Good.
worker_seed = [worker_id, root_seed]
# Less good. It will *work*, but it's less flexible.
worker_seed = [root_seed, worker_id]
考虑到这些注意事项,抗碰撞安全保证与前面讨论的生成(spawning)方案大致相同。算法机制也是一样的。
独立流#
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 的周期、跳转大小和默认无符号随机数的位数,如下所示。
位生成器 |
周期 |
跳转大小 |
每次生成位数 |
---|---|---|---|
\(2^{19937}-1\) |
\(2^{128}\) |
32 |
|
\(2^{128}\) |
\(~2^{127}\) ([3]) |
64 |
|
\(2^{128}\) |
\(~2^{127}\) ([3]) |
64 |
|
\(2^{256}\) |
\(2^{128}\) |
64 |
跳转大小为 \((\phi-1)*2^{128}\),其中 \(\phi\) 是黄金比例。随着跳转在周期内循环,相邻流之间的实际距离将缓慢地比跳转大小减小,但以这种方式使用黄金比例是构建低差异序列的经典方法,该序列能以最佳方式将状态分散在周期周围。您无法进行足够的跳转,使得这些距离小到足以在您的有生之年发生重叠。
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)
开始,否则它将重新创建相同的流。另一方面,如果您仔细构造流,则保证不会出现重叠。