并行随机数生成#
已实现四种主要策略,可用于在多个进程(本地或分布式)中生成可重复的伪随机数。
SeedSequence
生成#
NumPy 允许您通过其 spawn()
方法生成新的(概率极高)独立的 BitGenerator
和 Generator
实例。此生成由用于初始化位生成器随机流的 SeedSequence
实现。
SeedSequence
实现了一种算法 来处理用户提供的种子(通常是某种大小的整数),并将其转换为 BitGenerator
的初始状态。它使用哈希技术来确保将低质量种子转换为高质量初始状态(至少,概率极高)。
例如,MT19937
的状态由 624 个 uint32
整数组成。一种简单的使用 32 位整数种子方法是将状态的最后一个元素设置为 32 位种子,其余元素保留为 0。这对于 MT19937
来说是一个有效状态,但不是一个好的状态。如果 0 太多,梅森旋转算法 会受到影响。类似地,两个相邻的 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 是生成的流的数量。如果程序使用 100 万个流(约 \(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 |
周期 |
跳跃大小 |
每次抽取的位数 |
---|---|---|---|
\(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)
开始,否则它会重新创建相同的流。另一方面,如果仔细构建流,则保证不会重叠的流。