乱数のつくり方
コンピューターは本当の意味でランダムな数を作れません。アプリが使っている「疑似乱数」の仕組み (xorshift32 と mulberry32) と、シード・再現性・ハッシュによる振り分けを、ビット演算のレベルから説明します。
- 疑似乱数とシード (種) の考え方
- xorshift32 の 3 行が何をしているか、なぜ周期が なのか
- 32 ビットの整数を 0〜1 の小数に変える正しい方法
- シードを固定すると何がうれしいのか
- 名前から脚質を決める「ハッシュ」の確率
疑似乱数 — 決まった手順で、ランダムに見える数を作る
コンピューターは決まった手順しか実行できないので、サイコロのような本物の偶然は作れません。そこで「前の数から次の数を計算する」決まった式で、ランダムに 見える 数の列を作ります。これを 疑似乱数 と呼びます。
最初の値 を シード (種) と呼びます。同じシードからは、いつも同じ列が出てきます。これは欠点ではなく、結果を再現できる という長所です。
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 | 左に ビットずらす (はみ出た分は捨てる) | 00010110 << 2 = 01011000 |
x >> k | 右に ビットずらす | 00010110 >> 2 = 00000101 |
a ^ b | ビットごとに「違えば 1、同じなら 0」 | 0110 ^ 0011 = 0101 |
左シフトで上位のビットに、右シフトで下位のビットに情報が混ざり、それを 3 回くり返すことで、32 ビット全体がよくかき混ぜられます。13, 17, 5 という数は、考案者 George Marsaglia が「周期が最大になる組み合わせ」として探し出したものの 1 つです。
なぜ周期が なのか
xorshift の 1 ステップは、32 ビットを 32 次元のベクトル (各成分は 0 か 1) とみなすと、行列の掛け算 で書けます。XOR は「1+1=0 となる足し算」(2 を法とする足し算) なので、
(, はシフトを表す行列、 は単位行列)。適切なシフト量を選ぶと は、0 以外のすべての 32 ビット値 ( 個) を 1 周してから元に戻る、という性質を持ちます。
(全ビットが 0) は、ずらしても XOR しても 0 のままです。乱数がずっと 0 になってしまいます。そのためアプリは、シードに 0 が渡されたら別の値に差し替えています。
rng = if (seed == 0) 0x9E3779B9 else seed;0x9E3779B9 は黄金比 を使った定数 ( の整数部分) で、ビットの 0 と 1 がほどよく混ざっているため、よく初期値に使われます。
さらに深くxorshift の弱点
xorshift32 は速くて実用上十分ですが、統計的な検定 (TestU01 の BigCrush など) をすべて通るわけではありません。特に下位のビットの規則性が弱点として知られています。アプリが下位 8 ビットを捨てて上位 24 ビットだけを使っているのは、次の節で説明する理由に加えて、この弱点を避ける意味もあります。暗号 (パスワードや鍵) には絶対に使ってはいけません。
整数を 0〜1 の小数に変える
rng >> 8 で上位 24 ビットを取り出すと、 〜 の整数になります。これを で割ると
となり、0 以上 1 未満 の値が 通り、等間隔で同じ確率で出ます。
なぜ 32 ビット全部ではなく 24 ビットなのでしょうか。結果を入れる f32 (32 ビットの浮動小数点数) は、仮数部 (有効数字) が 24 ビット (保存される 23 ビット + 暗黙の 1 ビット) しかありません。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;名前の文字コード から
を計算します (| 0 が 32 ビットで切り捨てる操作)。これは Java の String.hashCode() と同じ式です。 がほぼ一様に 0〜99 に散らばるとみなすと、
| 範囲 | 脚質 | 確率 |
|---|---|---|
| 0〜11 | 逃げ | 12% |
| 12〜51 | 先行 | 40% |
| 52〜84 | 差し | 33% |
| 85〜99 | 追込 | 15% |
乱数ではなくハッシュを使うのは、同じ馬はいつも同じ脚質 にするためです。毎回変わると、同じ馬を追加し直しただけで展開が変わってしまいます。
- 疑似乱数は「前の値から次の値を計算する」決まった式。同じシードなら同じ列
- xorshift32 はシフトと XOR の 3 回で周期 。シード 0 は禁止
- 上位 24 ビット ÷ で、
f32で誤差なく表せる の一様乱数になる - 勝率表示はシード固定 (毎回同じ)、3D は毎回変える (リプレイは同じ結果を再利用)
- 名前のハッシュで脚質を決めると、同じ馬はいつも同じ脚質になる
4 ビットの値 に対して s ^= s << 1; s ^= s >> 1 (4 ビットからはみ出た分は捨てる) をくり返すとします。 (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, … となります。小さいビット数でも「ランダムっぽく」動く様子が分かります。
rng (32 ビット) をそのまま で割って f32 にすると、なぜ 1.0 が出ることがあるのでしょうか。
答えを見る
f32 の有効数字は 24 ビットなので、 のような大きな整数は最も近い表せる数 (ここでは ) に丸められます。それを で割るとちょうど 1.0 になります。「1 未満」という約束が破れ、配列の範囲外アクセスなどの原因になります。