并行随机数生成#
有四种主要实现的策略可以用于在多个进程(本地或分布式)中生成可重复的伪随机数。
SeedSequence 孵化#
NumPy 允许您通过其 spawn() 方法孵化新的(极有可能)独立的 BitGenerator 和 Generator 实例。这种孵化是通过用于初始化位生成器随机流的 SeedSequence 实现的。
SeedSequence 实现了一个算法 来处理用户提供的种子,通常是某个大小的整数,并将其转换为 BitGenerator 的初始状态。它使用哈希技术来确保低质量的种子能够(至少极有可能)转化为高质量的初始状态。
例如,MT19937 的状态由 624 个 uint32 整数组成。一种简单的方法是取一个 32 位整数种子,然后将其设置为状态的最后一个元素,其余设置为 0。这对于 MT19937 来说是一个有效状态,但不是一个好的状态。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 不仅可以接受单个整数种子,还可以接受任意长度的(非负)整数序列。如果稍微谨慎一些,可以使用此功能设计临时方案,以获得与孵化具有相似安全保证的安全并行 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。这可能需要并行进程之间的协调。
跳转位生成器状态#
jumped 会推进位生成器的状态,就好像已经生成了大量随机数,并返回一个具有该状态的新实例。具体的生成数量因位生成器而异,范围从 \(2^{64}\) 到 \(2^{128}\)。此外,就好像生成的数量还取决于特定位生成器生成的默认随机数的数量。支持 jumped 的位生成器,连同位生成器的周期、跳转大小以及默认无符号随机数的位数如下表所示。
位生成器 |
周期 |
跳转大小 |
每张图的位数 |
|---|---|---|---|
\(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) 开始,否则它会重新创建相同的流。另一方面,如果您精心构造了流,则保证它们不会重叠。