平行隨機數生成#
實作了四種主要策略,可用於在多個程序(本地或分散式)中產生可重複的偽隨機數。
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
不僅可以採用整數種子,還可以採用任意長度的(非負)整數序列。如果稍加注意,就可以使用此功能來設計特設方案,以獲得安全的平行 PRNG 串流,並具有與衍生相似的安全保證。
例如,一種常見的用例是,工作程序會傳遞整個計算的根種子整數,以及整數工作程序 ID(或更細粒度的 ID,例如工作 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)
開始,否則它將重新建立相同的串流。另一方面,如果您仔細建構串流,則保證您的串流不會重疊。