并行随机数生成#

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

SeedSequence 生成#

NumPy 允许你通过它们的 spawn() 方法生成新的(概率非常高的)独立的 BitGeneratorGenerator 实例.这种生成是通过用于初始化位生成器随机流的 SeedSequence 实现的.

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

例如,`MT19937` 有一个由 624 个 uint32 整数组成的状态.一种天真的方法是用一个 32 位整数种子,只需将状态的最后一个元素设置为 32 位种子,其余部分设为 0.这对于 MT19937 是一个有效状态,但不是一个好的状态.Mersenne Twister 算法 如果 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 放在 不变的根种子之前.`~SeedSequence.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 是一个基于计数器的随机数生成器,它通过使用弱加密原语加密递增的计数器来生成值.种子决定了用于加密的密钥.唯一的密钥创建唯一且独立的流.`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 的 BitGenerators,以及 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) 开始,否则它会重新创建相同的流.另一方面,如果你仔细构建流,那么你可以保证流不会重叠.