学ぶ ・ 入門

乱数のつくり方

コンピューターは本当の意味でランダムな数を作れません。アプリが使っている「疑似乱数」の仕組み (xorshift32 と mulberry32) と、シード・再現性・ハッシュによる振り分けを、ビット演算のレベルから説明します。

このページで学ぶこと
  • 疑似乱数とシード (種) の考え方
  • xorshift32 の 3 行が何をしているか、なぜ周期が 232−12^{32}-1 なのか
  • 32 ビットの整数を 0〜1 の小数に変える正しい方法
  • シードを固定すると何がうれしいのか
  • 名前から脚質を決める「ハッシュ」の確率

疑似乱数 — 決まった手順で、ランダムに見える数を作る

コンピューターは決まった手順しか実行できないので、サイコロのような本物の偶然は作れません。そこで「前の数から次の数を計算する」決まった式で、ランダムに 見える 数の列を作ります。これを 疑似乱数 と呼びます。

s0  →  g    s1  →  g    s2  →  g    ⋯s_0 \;\xrightarrow{\;g\;}\; s_1 \;\xrightarrow{\;g\;}\; s_2 \;\xrightarrow{\;g\;}\; \cdots

最初の値 s0s_0 を シード (種) と呼びます。同じシードからは、いつも同じ列が出てきます。これは欠点ではなく、結果を再現できる という長所です。

xorshift32 — 3 行の乱数

Zig のモンテカルロは xorshift32 という方法を使っています。

fn nextRand() f32 {
    // xorshift32: 速くて十分ランダム (暗号用途ではない)
    rng ^= rng << 13;
    rng ^= rng >> 17;
    rng ^= rng << 5;
    return @as(f32, @floatFromInt(rng >> 8)) / 16777216.0;
}

rng は 32 ビットの符号なし整数です。3 行はすべて「自分を何ビットかずらしたものと、自分の 排他的論理和 (XOR, ^) をとる」操作です。

演算意味例 (8 ビットで)
x << k左に kk ビットずらす (はみ出た分は捨てる)00010110 << 2 = 01011000
x >> k右に kk ビットずらす00010110 >> 2 = 00000101
a ^ bビットごとに「違えば 1、同じなら 0」0110 ^ 0011 = 0101

左シフトで上位のビットに、右シフトで下位のビットに情報が混ざり、それを 3 回くり返すことで、32 ビット全体がよくかき混ぜられます。13, 17, 5 という数は、考案者 George Marsaglia が「周期が最大になる組み合わせ」として探し出したものの 1 つです。

なぜ周期が 232−12^{32} - 1 なのか

xorshift の 1 ステップは、32 ビットを 32 次元のベクトル (各成分は 0 か 1) とみなすと、行列の掛け算 で書けます。XOR は「1+1=0 となる足し算」(2 を法とする足し算) なので、

sk+1=M sk(mod2),M=(I+L5)(I+R17)(I+L13)s_{k+1} = M\, s_k \pmod 2, \qquad M = (I + L^{5})(I + R^{17})(I + L^{13})

(LL, RR はシフトを表す行列、II は単位行列)。適切なシフト量を選ぶと MM は、0 以外のすべての 32 ビット値 (232−12^{32} - 1 個) を 1 周してから元に戻る、という性質を持ちます。

シード 0 は使えない

s=0s = 0 (全ビットが 0) は、ずらしても XOR しても 0 のままです。乱数がずっと 0 になってしまいます。そのためアプリは、シードに 0 が渡されたら別の値に差し替えています。

rng = if (seed == 0) 0x9E3779B9 else seed;

0x9E3779B9 は黄金比 φ\varphi を使った定数 (232/φ2^{32}/\varphi の整数部分) で、ビットの 0 と 1 がほどよく混ざっているため、よく初期値に使われます。

さらに深くxorshift の弱点

xorshift32 は速くて実用上十分ですが、統計的な検定 (TestU01 の BigCrush など) をすべて通るわけではありません。特に下位のビットの規則性が弱点として知られています。アプリが下位 8 ビットを捨てて上位 24 ビットだけを使っているのは、次の節で説明する理由に加えて、この弱点を避ける意味もあります。暗号 (パスワードや鍵) には絶対に使ってはいけません。

整数を 0〜1 の小数に変える

rng >> 8 で上位 24 ビットを取り出すと、00 〜 224−12^{24} - 1 の整数になります。これを 224=167772162^{24} = 16777216 で割ると

U=⌊s/28⌋224∈{0,1224,2224,…,1−1224}U = \frac{\lfloor s / 2^8 \rfloor}{2^{24}} \in \left\{0, \tfrac{1}{2^{24}}, \tfrac{2}{2^{24}}, \dots, 1 - \tfrac{1}{2^{24}}\right\}

となり、0 以上 1 未満 の値が 2242^{24} 通り、等間隔で同じ確率で出ます。

なぜ 32 ビット全部ではなく 24 ビットなのでしょうか。結果を入れる f32 (32 ビットの浮動小数点数) は、仮数部 (有効数字) が 24 ビット (保存される 23 ビット + 暗黙の 1 ビット) しかありません。32 ビットの整数をそのまま 2322^{32} で割ると、丸めによって ちょうど 1.0 が出てしまうことがあります。24 ビットに揃えれば、すべての値が誤差なしに表せて、1.0 は絶対に出ません。

シードを固定する意味

レース予想画面の勝率シミュレーションは、毎回同じシードで計算しています。

z.simulate(list.length, 100000, state.mcPace, 20261109);

シードが固定なので、同じ出馬表・同じペースなら、何度画面を開いても同じ勝率が表示されます。表示が毎回少しずつ変わると、利用者は「何か変わったのか」と混乱するからです。

一方、3D シミュレーションは毎回違う展開にしたいので、シードを毎回変えています。

const seed = (Math.random() * 2 ** 31) | 0;

「リプレイ」では、同じシードで計算した結果 (scores) をそのまま使うので、まったく同じレースが再現されます。

mulberry32 — JavaScript 側の乱数

JavaScript の Math.random() にはシードを指定できません。シードが必要な場面 (Zig を読み込めなかったときの代わり、「名馬でおまかせ」の抽選) では、mulberry32 という別の方法を使っています。

export const seeded = seed => () => {
  seed = (seed + 0x6D2B79F5) | 0;
  let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};

状態に定数を足していく部分 (seed + 0x6D2B79F5) は単純なカウンターで、その値を掛け算と XOR でかき混ぜて出力します。Math.imul は「32 ビット整数として掛けて、はみ出た分を捨てる」掛け算です (普通の * は小数になってしまうため)。

ハッシュで「くじ引き」する — 脚質の初期値

名鑑の馬には脚質のデータがないので、得意距離が分からない馬は 名前から 脚質を決めています。

let hsh = 0;
for (const ch of v.horse.title) hsh = (hsh * 31 + ch.charCodeAt(0)) | 0;
const x = Math.abs(hsh) % 100;
return x < 12 ? 0 : x < 52 ? 1 : x < 85 ? 2 : 3;

名前の文字コード c1,c2,…,cnc_1, c_2, \dots, c_n から

h=(c1⋅31n−1+c2⋅31n−2+⋯+cn) mod 232h = \left(c_1 \cdot 31^{n-1} + c_2 \cdot 31^{n-2} + \cdots + c_n\right) \bmod 2^{32}

を計算します (| 0 が 32 ビットで切り捨てる操作)。これは Java の String.hashCode() と同じ式です。∣h∣ mod 100|h| \bmod 100 がほぼ一様に 0〜99 に散らばるとみなすと、

範囲脚質確率
0〜11逃げ12%
12〜51先行40%
52〜84差し33%
85〜99追込15%

乱数ではなくハッシュを使うのは、同じ馬はいつも同じ脚質 にするためです。毎回変わると、同じ馬を追加し直しただけで展開が変わってしまいます。

まとめ
  • 疑似乱数は「前の値から次の値を計算する」決まった式。同じシードなら同じ列
  • xorshift32 はシフトと XOR の 3 回で周期 232−12^{32}-1。シード 0 は禁止
  • 上位 24 ビット ÷ 2242^{24} で、f32 で誤差なく表せる [0,1)[0, 1) の一様乱数になる
  • 勝率表示はシード固定 (毎回同じ)、3D は毎回変える (リプレイは同じ結果を再利用)
  • 名前のハッシュで脚質を決めると、同じ馬はいつも同じ脚質になる
練習問題4 ビットの xorshift

4 ビットの値 ss に対して s ^= s << 1; s ^= s >> 1 (4 ビットからはみ出た分は捨てる) をくり返すとします。s0=1s_0 = 1 (0001) から始めて、4 回分計算してください。

答えを見る

0001 → s<<1 = 0010 で XOR → 0011 → s>>1 = 0001 で XOR → 0010。 0010 → 0110 → 0101。 0101 → 0101 ^ 1010 = 1111 → 1111 ^ 0111 = 1000。 1000 → 1000 ^ 0000 = 1000 (左シフトは 4 ビットからはみ出る) → 1000 ^ 0100 = 1100。 列は 1, 2, 5, 8, 12, … となります。小さいビット数でも「ランダムっぽく」動く様子が分かります。

練習問題なぜ 24 ビット

rng (32 ビット) をそのまま 2322^{32} で割って f32 にすると、なぜ 1.0 が出ることがあるのでしょうか。

答えを見る

f32 の有効数字は 24 ビットなので、232−12^{32}-1 のような大きな整数は最も近い表せる数 (ここでは 2322^{32}) に丸められます。それを 2322^{32} で割るとちょうど 1.0 になります。「1 未満」という約束が破れ、配列の範囲外アクセスなどの原因になります。