無作為にレベルを選択し,そのレベルの中から無作為にアイテムを選択するというような機能を実装する必要があった.そこで実装されたコードが次である.
def lootbox_of_item
case $lootbox
when 0..40
rand($level[1])
when 40..60
rand($level[2])
when 60..80
rand($level[3])
when 80..90
rand($level[4])
when 90..100
rand($level[5])
end
end
$level = {
1 => 1..459,
2 => 460..1362,
3 => 1363..1755,
4 => 1756..2101,
5 => 2102..2184
}
$lootbox = rand(0..100)
何これ…きも…
lootbox_of_item
関数でアイテム番号を選択しているが,全てのwhen文にrand関数を適用していたり,ハッシュの値が何を指すのか分からなかったり,とかく酷いコードである.ゴミをGitHubに上げるな.
というわけで,一度実現したい機能を整理することにした.
ほとんどは Rで学ぶ逆変換(逆関数)法 にて述べられているが,一度振り返ってみる.
確率論による解法
条件
問題は問題番号と重要度をもつ.重要度は出題確率をもつ.
確率質量関数
確率変数 $X$ を問題の重要度とする.確率質量関数$P(X)$ を次のように定義する.
$$P(X=x_k) = p_k $$
分布関数 $F(X)$ は次のように定義される.
$$F(X=x) = P(X \leq x) = \sum_{k=1}^x P(X=k) $$
確率変数 $Y$ を問題の番号とする.確率質量関数 $P(Y)$ を次のように定義する.
$$P(Y \mid X = k) = 1/n_k $$
しかるに $n_k$ は,問題の重要度の集合 $C = \{ 1, 2, \cdots, c\}$ に写像される,各重要度に含まれる問題数をもつ数列の項である.
$$\{n_k\}_{k \in C} $$
重要度 $k$ の問題番号の範囲を $s_k$から $e_k$ とすると,これらは次で求められる.
$$s_k=\sum_{l=1}^{k-1} n_l + 1 $$
$$e_k=\sum_{l=1}^{k} n_l $$
よって,次の等式が成り立つ.
$$e_k=s_k+n_k-1 $$
分布関数 $F(Y)$ は,$s_k \leq y\leq e_k$ を満たす $k$ を用いて,次のように定義される.
\begin{aligned} F(Y=y \mid X = k) &= \sum_{l=s_k}^{y} P(Y=l \mid X = k) \\ &= \frac{y-s_k+1}{n_k} \\&= 1 - \frac{e_k-y}{n_k} \end{aligned}
逆変換法
確率変数 $u$ が一様分布 $[0, 1)$ に従うとする.このとき,関数 $F_X^{-1}(U)$ を次のように定義すると,サンプリングを行うことができる.
$$F_X^{-1}(U=u) = \inf{\{X \mid F(X \leq u)\}} $$
$F_Y^{-1}(U)$ についても同様である.
これで幾分か見通しが良くなった気がする.
Scipyで離散分布を扱う
Scipyには独自の離散分布を扱うことができるクラスがある.
確率変数のリストと確率質量関数のリストをそれぞれ定義し, stats.rv_discrete
インスタンスのrvs
(random variates)メソッドを呼べば,以上で述べた $u \sim [0, 1)$ の逆変換法のような機能を作れる.
初めに述べたコードは,次のように可読性を高めることができた.ちなみに,PyCallでScipyのモジュールを呼び出している.
def get_level(levels, probabilities)
pyfrom :scipy, import: :stats
xk = levels
pk = probabilities
custm = stats.rv_discrete.call({ values: [xk, pk] })
custm.rvs
end
def get_problem_id(level, ctgr_attr)
range = ctgr_attr[level]['range']
start = range['start']
end_ = range['end']
rand start..end_
end
ちなみに,レベルと確率分布は別のyamlファイルに分けた.
上で考察したことを吹っ飛ばして機能を実装できてしまった 😲