ABテスト用に特定の値(Seed)に基づいて、偏りがない乱数を生成したい(JavaScript)
ABテストを行うために、特定のユーザIDに紐付いて次の特性を持った乱数が必要になります。
- Seed値が同じの場合、同じ値が出力される(参照透過)
- 乱数の分布が一様で特定の値に偏らない
- 規則性がわかりにくい
Seed値だけで再現性がある乱数がほしいのであれば、てっとり早いのは次のライブラリを使うことです。
seedrandom
時間に依存しない、xorshift系の乱数xor4096を使います。
(時間に依存する乱数アルゴリズムの場合は、時間で結果が変わるため再現性が失われます)
const seedrandom = require('seedrandom')
// 再現性があるか
for (let i = 0; i < 3; ++i) {
console.log(`-----------${i}回目-----------`)
for (let j = 0;j < 10;++j) {
const rand = seedrandom.xor4096(j)
console.log(rand.int32())
}
}
// 乱数の分布が偏っていないか?
const count = [0, 0, 0, 0, 0]
for (let i = 0; i < 1000000; ++i) {
const rand = seedrandom.xor4096(i)
count[Math.abs(rand.int32()) % 5]++
}
console.log('------------分布-------------')
console.log(count)
実行結果:
-----------0回目-----------
1097171915
653022955
-391824616
-1021138798
309711012
1247428258
1381367549
476400567
432461370
-1305284757
-----------1回目-----------
1097171915
653022955
-391824616
-1021138798
309711012
1247428258
1381367549
476400567
432461370
-1305284757
------------分布-------------
[ 200437, 200320, 199858, 199663, 199722 ]
まずまずといったところではないでしょうか
uuidv4によるユーザID割当
ユーザIDにはuuidv4を割り振っているとします。
uuidv4は乱数によって生成されたユニークな値です。
参考:UUID(v4) がぶつかる可能性を考えなくていい理由
uuidv4生成には次のライブラリを使用しているものとします。
uuid
const uuidv4 = require('uuid/v4')
const count = [0, 0, 0, 0, 0]
// 乱数の分布が偏っていないか?
for (let i = 0; i < 1000000; i++) {
const instanceId = uuidv4() // ユーザID
const num = parseInt(instanceId.replace(/-/g, '').slice(-12), 16)
count[num % count.length]++
}
console.log(count)
実行結果:
[ 200216, 200129, 199900, 199999, 199756 ]
uuidv4自体が乱数なため、割当した時点で各ユーザIDは均等な分布に存在していることになります。
sliceの桁数を増やすことで精度はあがりますが、int値の表現できる上限があるので12桁にしています。
2^53 - 1 = 9007199254740991
uuidv4とMongoDBのObjectId(ABテストデータID)と組み合わせる
さて、ここからが本題です。
ユニークに振られたユーザIDに対して、単純にテストケースのパターン数の余りを求めるだけでは、同じテスト列に該当してしまいます。
例えば:
テストケースX(パターン数3): A, B, C
テストケースY(パターン数3): D, E, F
としてユニークIDが10だとしたら
10 % 3 = 1 → B
10 % 3 = 1 → E
となり、このユーザIDは2列目が確定してしまいます。
テスト別にもずれて欲しいので、テストケースのid(ObjectId)を足してずらします。
MongoDBのObjectIdは16進数の24桁の文字列です。
const uuidv4 = require('uuid/v4')
const { ObjectID } = require('mongodb')
// ABテストID(テストケースごとにIDは異なる)
const oid = new ObjectID().toString()
const count = [0, 0, 0, 0, 0]
// 乱数の分布が偏っていないか?
for (let i = 0; i < 1000000; i++) {
const instanceId = uuidv4() // ユーザID
const num = parseInt(instanceId.replace(/-/g, '').slice(-12), 16) + parseInt(oid.slice(-12), 16)
count[num % count.length]++
}
console.log(count)
実行結果:
[ 200220, 199821, 199680, 200232, 200047 ]
テストパターンに対して均等にユーザIDが割当されました。
ObjectIdはABテストごとに違うため、パターン数が同じだとしても別のテストでは余りが異なります。(ずれます)
おまけ1:AAテスト
ABテストをやる前にAAテストをやった方が良いです。
理由は同じ条件だとしてもテスト結果が何らかの理由で異なるためです。(テストにあたった客層や実行条件が異なったりするため)
ABテストの前にAAテストをやるべき3つの理由
AAテストの結果の誤差率を加味しないとABテストの優位性は測れません。
あと、計測結果に十分な母数が得られないのだとしたらABテスト自体をやる意味がありません。
おまけ2:自作のxorshift
次のシンプルな自作xorshiftの場合、シード値が同じであれば毎回同じ結果になります(ただし実行環境によって出てくる値は異なる)
class Random {
constructor(seed = 88675123) {
this.x = 123456789
this.y = 362436069
this.z = 521288629
this.w = seed
}
// XorShift
next() {
let t
t = this.x ^ (this.x << 11)
this.x = this.y
this.y = this.z
this.z = this.w
return this.w = (this.w ^ (this.w >>> 19)) ^ (t ^ (t >>> 8))
}
// 負の余りを正の余りに変える
mod(i, j) {
return (i % j) < 0 ? (i % j) + 0 + (j < 0 ? -j : j) : (i % j + 0)
}
// 0以上max未満の乱数を生成する
nextInt(max) {
const r = this.next()
return this.mod(r, max)
}
}
let rand
for (let i = 0; i < 2; ++i) {
console.log(`-------${i}回目-------`)
rand = new Random()
for (let j = 0; j < 10; ++j) {
console.log(rand.next())
}
}
const count = [0, 0, 0, 0, 0]
rand = new Random()
for (let i = 0; i < 1000000; ++i) {
count[rand.nextInt(count.length)]++
}
console.log('------------分布-------------')
console.log(count)
実行結果:
-------0回目-------
-593279510
458299110
-1794094678
-661847888
516391518
-1917697722
-1695017917
717229868
137866584
395339113
-------1回目-------
-593279510
458299110
-1794094678
-661847888
516391518
-1917697722
-1695017917
717229868
137866584
395339113
------------分布-------------
[ 200022, 199793, 200430, 200433, 199322 ]