剰余バイアスでAからHだけが1.2倍出る
ランダム文字列生成ツールは、乱数を文字プールの長さで割った余りで1文字ずつ選んでいる。
ただし余りを取るまえに、割り切れない端に当たった乱数は捨てて引き直している。
この引き直しを外すとどれくらい偏るのか、2000万回引いて数えてみた。答えは乱数の幅しだいだった。
1バイトの乱数なら特定の8文字が1.21倍出る。32bitの乱数なら2000万回では偏りが見えない。
割り切れない端が偏りを作る
実装はsrc/lib/tools/random.tsにある。
export const cryptoRandomInt: RandomInt = (maxExclusive) => {
// 引数の検査と maxExclusive === 1 の分岐は省略
const limit = Math.floor(0x1_0000_0000 / maxExclusive) * maxExclusive
const buffer = new Uint32Array(1)
for (;;) {
crypto.getRandomValues(buffer)
const value = buffer[0] ?? 0
if (value < limit) {
return value % maxExclusive
}
}
}
limitは、乱数の全パターン数4294967296(2の32乗)をmaxExclusiveで割り切れるところまで切り下げた値。それより上に出た乱数は使わずに引き直す。
端を捨てるのは、2の32乗がたいていのmaxExclusiveで割り切れないから。
英大小文字と数字で62文字のプールなら、62 × 69273666 + 4 = 4294967296。
引き直しなしで% 62だけすると、この端の4個が余り0から3に上乗せされる。0から3は69273667通りの乱数から作られ、残りの58文字は69273666通り。この差が剰余バイアス。
1バイトの乱数だと目に見える
同じことを幅の狭い乱数でやると、比が一気に効いてくる。
1バイト(0から255)を% 62すると、62 × 4 + 8 = 256。端が8個ある。先頭8文字は256通り中5通り、残りの54文字は4通り。上と下で1.25倍の差になる。
数えてみた。
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
const counts = new Float64Array(alphabet.length)
const buf = new Uint8Array(65536)
for (let done = 0; done < 20_000_000; done += buf.length) {
crypto.getRandomValues(buf)
for (const b of buf) counts[b % alphabet.length] += 1
}
2000万回引いた結果がこれ。期待値は20000000 / 62 = 322580.6で、比はその期待値に対する倍率。
多い順
D 391976 1.215
G 391826 1.215
E 390871 1.212
H 390797 1.211
C 390788 1.211
B 390609 1.211
A 390570 1.211
F 389394 1.207
少ない順
J 311388 0.965
Y 311302 0.965
4 311254 0.965
f 311246 0.965
0 311089 0.964
chi2 = 132801.1 (df = 61)
上位8件がアルファベットのAからHで、きれいに切れている。プールの先頭8文字だ。
比が1.25ではなく1.211と0.969に割れているのは、期待値が上と下のあいだ(256 / 62 = 4.129)にあるから。5 / 4.129 = 1.211、4 / 4.129 = 0.969。上下の比を取れば1.25で、理屈どおり。
カイ二乗が自由度61に対して13万。偶然では説明がつかない。
32bitだと2000万回では見えない
同じ数え方をUint32Arrayに替えて3回まわした。
trial 1: chi2=65.0 最大比=1.0035 最小比=0.9949
trial 2: chi2=64.8 最大比=1.0041 最小比=0.9954
trial 3: chi2=71.4 最大比=1.0036 最小比=0.9962
自由度61に対してカイ二乗が65前後。ただのばらつきの範囲で、偏りは検出できない。
そのはずで、32bitでの端はたった4個だった。0から3の確率は他の58文字の1.000000014倍。1バイトのときの1.25倍とは桁が違う。
どれくらい引けば見えるのか計算してみた。カイ二乗の期待値は1サンプルあたり1.258e-17しか増えない。自由度ぶん(61)押し上げるのに必要なサンプル数は4.85e+18回。
毎秒100万回引いても15万年かかる。実質的に測れない。
つまり、32bitの乱数を% 62する実装の偏りは、実用上は問題にならない。
それでも引き直しを残す理由
引き直しがどれくらい走るのかも数えた。
max=62 n=5000000 引き直し 0回
max=26 n=5000000 引き直し 0回
max=3000000000 n=1000000 引き直し 432002回 (0.43回/1回の取得)
max=2147483649 n=1000000 引き直し 999406回 (1.00回/1回の取得)
62文字でも26文字でも、500万回引いて1回も引き直していない。当たり前で、端に落ちる確率が5.1e-9とか9.3e-10のオーダーだから。ループはあるが実質1回で抜ける。
つまりこのfor (;;)は、ツールが使う範囲では性能に何も影響していない。
効いてくるのはmaxExclusiveが2の32乗に近づいたとき。2147483649(2の31乗+1)だと端が半分を占めるので、1回取るのに平均2回引く。ここまで来ると剰余バイアスも2倍で、無視できるものではなくなる。
ここが引き直しを残す判断の分かれ目だと思う。文字プールの長さでしか呼ばないと決めれば要らないコードだけど、cryptoRandomIntは範囲を引数で受け取る汎用の入り口として置いてある。呼び出し側が何を渡しても正しくなるほうが、条件付きで正しいより扱いやすい。
代償が500万回で0回の引き直しなら、払っておいていい。
Math.randomは偏っていない、予測できるだけ
「乱数といえばMath.random」を避ける理由も、偏りの話だと思われがちだけど、実測すると違う。
Math.floor(Math.random() * 62): chi2=74.7 df=61 最大比 1.0038 最小比 0.9954
2000万回引いて自由度61にカイ二乗74.7。分布としては何も問題ない。
問題は再現できてしまうこと。V8のMath.randomは内部状態を持つ疑似乱数生成器で、状態が同じなら同じ列が出る(V8のブログにxorshift128+を採用した経緯がある)。
Node.jsは--random_seedでその種を指定できるので、そのまま観察できる。
// pw.mjs
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let s = ''
for (let i = 0; i < 16; i += 1) s += alphabet[Math.floor(Math.random() * alphabet.length)]
console.log(s)
$ node --random_seed=42 pw.mjs
xgV7hew6CvD56S21
$ node --random_seed=42 pw.mjs
xgV7hew6CvD56S21
$ node --random_seed=12345 pw.mjs
4jDd9m7HlMHNskR8
16文字のパスワードが、種を揃えるだけで完全に一致する。
ブラウザで種を指定する手段は無いけれど、「種が分かれば列が決まる」構造そのものは変わらない。パスワードの生成に使うものではない。
crypto.getRandomValuesはここが違う。だから%が偏らない幅でも、まずgetRandomValuesを選ぶ。
整理すると、Math.randomと%はどちらもだめだけど、だめな理由は別々だ。Math.randomは分布ではなく予測可能性で落ちる。%は予測可能性ではなく分布で落ちる。そして%の分布の崩れは、乱数の幅が広ければ測れないほど小さい。
ついでにシャッフルも数えた
同じrandom.tsにはFisher-Yatesのshuffleが入っていて、雑談ペア作成がこれでメンバーを混ぜている。
sortのコンパレータで乱数を返すやり方と比べた。4要素の並べ替えを200万回、24通りの出方を数える。
import { shuffle } from './src/lib/tools/random.ts'
const items = ['A', 'B', 'C', 'D']
const fastInt = (max) => Math.floor(Math.random() * max)
// 比較対象
items.toSorted(() => Math.random() - 0.5)
Fisher-Yatesのほう。
chi2 = 32.5 (df = 23) 最多 ACDB 1.009 最少 DACB 0.991
sortのほう。
chi2 = 2303979.0 (df = 23)
ABCD:4.50 DCBA:4.12 DABC:1.50 ABDC:1.50 ADBC:1.50 CBDA:1.13 CDBA:1.13 CBAD:1.12
BDAC:0.75 BADC:0.75 DBAC:0.75 BACD:0.75 DBCA:0.38 BCAD:0.38 CABD:0.38 CDAB:0.38
BDCA:0.38 DCAB:0.38 BCDA:0.38 ACDB:0.38 ADCB:0.38 DACB:0.38 ACBD:0.37 CADB:0.37
入力のままのABCDが4.5倍、逆順のDCBAが4.1倍。他の22通りは0.37倍から1.5倍にばらけている。
比較関数が一貫していないと、ソートのアルゴリズムが何回どこを比べたかがそのまま結果に残る。剰余バイアスの1.25倍が可愛く見える崩れ方だ。
こっちは実測しなくても壊れていると言えるけど、数えると壊れ方の形まで見える。
まとめ
% maxの偏りは、乱数の幅とmaxの比で決まる。1バイトで62文字を選ぶと1.25倍、32bitなら1.000000014倍- 32bitの偏りを検出するには4.85e+18回引く必要がある。実用上は測れない
- 端を捨てて引き直すコストは、62文字なら500万回で0回。範囲を引数で受け取る関数なら入れておく
Math.randomは分布ではなく予測可能性でだめ。--random_seedを揃えると同じパスワードが出るsortのコンパレータで混ぜるのは、剰余バイアスより桁違いに壊れる