Skip to content

Math:rnd(min, max)等の分布が偏っている The Math:rnd(min, max) and its seeded variant have slight biases in distribution #489

Description

@MineCake147E

概要

'Math:rnd': FN_NATIVE(([min, max]) => {
if (min && min.type === 'num' && max && max.type === 'num') {
return NUM(Math.floor(Math.random() * (Math.floor(max.value) - Math.ceil(min.value) + 1) + Math.ceil(min.value)));
}
return NUM(Math.random());
}),
'Math:gen_rng': FN_NATIVE(([seed]) => {
expectAny(seed);
if (seed.type !== 'num' && seed.type !== 'str') return NULL;
const rng = seedrandom(seed.value.toString());
return FN_NATIVE(([min, max]) => {
if (min && min.type === 'num' && max && max.type === 'num') {
return NUM(Math.floor(rng() * (Math.floor(max.value) - Math.ceil(min.value) + 1) + Math.ceil(min.value)));
}
return NUM(rng());
});
}),

現行の実装では、 $[0, 1)$ の乱数を生成し、範囲を $[min, max+1)$ に整形してからfloorするという方法で生成されています。
しかし、この方法では偏りが生じます。

現行実装の問題点

浮動小数点数演算の丸め誤差による偏り

まず、 $[0, 1)$ 内の有効な倍精度浮動小数点数は $4,607,182,418,800,017,408$ ( $2^{52} * 3 * 11 * 31$ )パターン存在します。
Math.random()はこれらの数の中から何らかの分布(選定されたパターンを倍精度浮動小数点数として解釈し観測した場合に一様分布となるような分布)に従ってランダムに値を選定して返します。
これを範囲変換する際に浮動小数点数の乗算や加算を行っています。
浮動小数点数では、四則演算を行う度に丸めが発生します。ここでの丸めは切り捨てや切り上げではなく、Rounding half to even(日本語版)と呼ばれる方法に基づいて行われることが多いです。
また、浮動小数点数はそもそも無限桁の演算を行うことが出来ない為、四則演算は殆どの場合単射ではありません。
ここで偏りが発生します。

内部的に生成されうる全パターンを等分出来ないことによる偏り

まず、 $[0, 1)$ 内の有効な倍精度浮動小数点数 $4,607,182,418,800,017,408$ パターンの内、その約99.8%は $[0, 0.5)$ の範囲内にあり、 $[0.5, 1)$ の範囲内にあるパターン数は $4,503,599,627,370,496$ ( $2^{52}$ )パターンしか存在しません。
Math.random()では $[0, 0.5)$$[0.5, 1)$ の範囲がそれぞれ50%の確率で生成されます。つまり、50%もの確率で $2^{52}$ パターンの中から抽選されることになります。
$2^{52}$ パターンの中から選ばれた $[0.5, 1)$ の範囲内にある数を2の冪ではない整数(range)で乗算し、Math.floor()により丸めを行うと、 $[\lfloor {\frac{range}{2}} \rfloor, range)$ 内の整数のいずれかにたどり着きますが、この範囲の整数はそもそも $2^{52}$ パターンを等分できず、偏りが発生します。
他の範囲についても同様のことが言えます。

提案手法

偏りを除去するためのアルゴリズムは複数存在します。

Rejection Sampling系アルゴリズム

最もシンプルな方法として、"Rejection Sampling"と呼ばれる方法が広く知られています。
以下に示すアルゴリズムはそのうちの一種類です。

  1. $\lceil log_2(range + 1)\rceil$ bitの乱数列を得ることにより、 $[0, 2^{\lceil log_2(range + 1)\rceil})$ の範囲の整数を一様分布で生成する。
  2. 1で生成した数がrangeを
    • 超える場合、1に戻る。
    • 超えない場合、その数をそのまま返す。
TypeScriptでの実装例:

min以上max以下の整数を生成する例(max-minは2^32 以下である必要があります):

const trueMin = Math.ceil(min);
const scale = Math.floor(max) - trueMin + 1;
const array = new Uint32Array(1);
const shift = Math.clz32(scale);
let result: number;
do {
    result = (crypto.getRandomValues(array)[0] ?? -1) >> shift;
} while (result > scale);
return Math.floor(Math.random() * scale + trueMin);

この方法は最も簡潔ですが、maxInclusiveが二の冪(=パターン数が二の冪+1)であるときに約50%もの確率で生成をやり直すことになり、殆どの乱数列が無駄になります。

Lemire's algorithm 及びその変種

.NET RuntimeOpenSSL等で採用された方法です。

詳細な解説は以下にあるのでそれをご確認ください。

どちらも固定小数点数をスケールして範囲に収めていますが、偏りの原因となるパターンが生成されたことを検知して精度を向上した上で乗算する処理が入っており、ループ回数を制限しなければ偏りを取り除くことが出来ます。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions