アイテムを全コンプするのに必要なガチャ回数の期待値を計算するアプリを作りました。
内部ではシミュレーションや近似等は行っておらず、厳密な期待値が計算可能な手法を採用しています。
この記事では、この手法に関して以下の流れで解説していきます:
この記事の構成
この記事は以下のようなパートから構成されています:
問題のモデル化:
数式を使った解析:
数値計算に落とし込む:
このアプリを作るまでの流れ/経緯
話の本筋とは関係ないですが、このアプリを作った経緯は以下のようになります:
プランナーさんがガチャを企画していて、適当な確率配分を探るために、アイテム全取得のために必要なガチャの回数の期待値が計算できると嬉しい(要約)という依頼が来た。→ 計算できるコードを書いた。
せっかくなので、誰でも使える形で公開しよう(ついでアルゴリズムも改良しコードも大幅に書き直して…)→ できたものがコレ
また、今回どうして解説記事を書こうと思ったかというと、このアプリを作る上で使った道具群、特にマルコフ連鎖は基本的であると同時に応用上も非常に重要で、応用数学や数値計算のネタ(入門)の題材としてはわりと良いんじゃないかなと感じたからです。
ガチャを数学的にどう表現するか
まずガチャの問題をどう定式化し、どのような数理モデル(数式表現)に当てはめるのかを考えます。
ガチャでアイテムをコンプするとは?
ガチャではクジを回してアイテムを集めていきます。毎回クジを引くごとに、複数種類の中からアイテムを一つ貰えます。アイテムをコンプするとは、くじを引き続け、全種類のアイテムを取得する事です。
具体的には毎回クジを引くごとに、I種類のアイテム[1,…,i,…,I]の中から一つのアイテムiを確率p(i)>0で取得していきます。
ちなみにp(i)が互いにすべて等しい場合はCoupon collector’s problemと呼ばれ、アイテムを全て取得するのに必要なクジを引く回数の期待値はI∑In=11/nであることが知られています。
ガチャを表現する数学的モデル
ガチャの問題を記述するもっとも適当なモデルはマルコフ連鎖です。マルコフ連鎖とはマルコフ過程の一種であり、マルコフ過程は確率過程の一種です。まず確率過程の説明からしていきます。
確率過程(離散時間上での)とは、各時刻(ステップ)ごとに、状態が確率的に変化していくような対象の数学的なモデルです。確率過程は確率分布の一種であり、時刻(ステップ数)を表す添字t∈Nのついた確率変数Xtを用いて、
P(X0=x0,X1=x1,⋯,Xt=xt,⋯)
として表現されます。ここで
xt∈Sは確率変数
Xtの実現値であり、
Sは状態全体の集合を指します。
マルコフ過程とは、次の時刻にどの状態に移るかの確率が現時刻での状態にのみに依存するような確率過程を指します。これは数式を使うと、以下のように左辺のような
条件付き確率が右辺のような条件付き確率として書き表せることに相当します:
P(Xt=xt|Xt−1=xt−1,⋯,X0=x0)=P(Xt=xt|Xt−1=xt−1).
マルコフ連鎖とは、状態の集合
Sが有限集合(一般には
可算集合)の場合を指します。マルコフ連鎖は
遷移行列Rによってパラメトライズ(=モデルを記述)することができます。
ここで遷移行列R=[rsu]とは、各ステップで状態s∈Sから状態u∈Sへ遷移する確率P(Xt+1=u|Xt=s)=rsuを行列の形で並べたものです。マルコフ連鎖は数学的に扱いやすく(=線形代数と相性が良く)、様々な分野への応用があります。
マルコフ連鎖は以下のような状態遷移図の形で表現されることもあります:
ここで各ノードが状態を表し、各エッジに割り振られた値が状態間の遷移確率を表します。マルコフ性により、次にどの状態に遷移するかの確率が、これまで辿ってきた状態の履歴に依らず今いる状態のみによって決定されるためこのような表現が可能となります。
マルコフ連鎖をガチャの問題に当てはめると、それぞれの文字はこのように対応します: Xtがガチャをt回引いた後でのアイテムの所持状態の確率変数、xtがその実現値、Sがアイテムの所持状態の全体の集合、Rsuがガチャを一回引いた時のアイテムの所持状態sからuへの遷移確率となります。
マルコフ連鎖の特徴と応用
マルコフ連鎖の数学的特徴
マルコフ連鎖の数学的に嬉しい所は線形代数との相性が良い点です:
- 状態の確率分布(πt)の時間発展を、遷移行列の積を使って表現できる: πt=π0Rt。これはチャップマン=コルモゴロフ方程式が行列の積として表現されることに相当する。
- 行列計算に関する学問である線形代数での豊富な道具/議論が使える。例えば定常分布πを固有値問題:πR=πを解くことで求めることができる。
マルコフ連鎖の応用事例
マルコフ連鎖はその扱いやすさからか様々な分野への応用があります:
待ち行列理論(数学者アーラン(プログラミング言語Erlangの名前の元ネタ)が開拓した分野)
- 元は交換機のある電話回線を記述するために作られた
- 今風な言い方をすると、キューに入ったタスクを捌き切るのに必要な、workerの個数や性能を数学的に解析できる
GoogleのPageRankアルゴリズム
- 各ページ間をリンクを辿りランダムウォークしたときの定常分布を各ページに対する評価とする
- 定常分布はリンクの繋がりを表す隣接行列から作られた遷移行列の固有値問題を解くことで計算できる
その他沢山の応用事例
ガチャの問題で状態空間と遷移行列をどう表現するか
ガチャの問題をマルコフ連鎖を使ってモデリングするには、その状態空間と遷移確率を具体的に与える必要があります。
状態空間Sの表現
ガチャにおいて各時刻での状態の集合(状態空間)Sを具体的にどう表現するかを考えます。
I種類のアイテム[1,…,i,…,I]を各ステップごとに1つずつ取得していくので、 各アイテムの所持数を並べたもの(k0,⋯,ki,⋯,kI),ki∈Nをアイテムの所持状態として使うのが最も素朴なやり方ではあります。
しかしこの時、状態の集合S={(k0,⋯,ki,⋯,kI)}はNのI個の組み合わせ(=直積)NIとなりますが、これは集合のサイズとしては無限(加算無限)であるため、この集合の上での確率分布を考えるのは計算機で数値的に取り扱うのが困難です。
なので代わりに、各アイテムを持っているか/いないかを表す変数hi∈{0,1}を並べたものでアイテムの所持状態を表現することを考えます。
hiはアイテムiを所持していない(ki=0)時に0の値をとり、1つ以上所持している時(ki>0)に1の値を取ります。このときS={(h0,⋯,hi,⋯,hI)}={0,1}Iとなり、状態空間の大きさは|S|=2Iとなります。
遷移確率rsuの計算
状態sから状態uへの遷移確率rsu=P(Xt+1=u|Xt=s)がどのような値になるかを考えます。 アイテムの所持状態の遷移は次の2パターンに分類されます:
(1) 新しい種類のアイテムiを取得する (2) 既に出たアイテムがドロップし(=ダブリ)、所持状態は変化しない。
状態s,uを具体的にs=(hs0,⋯,hsi,⋯,hsI)、u=(hu0,⋯,hui,⋯,huI)とした時に、(1)のパターンでの状態遷移s→uは以下のような状況に相当します:
{hsj=hujhsj=0,huj=1j≠i,j=i.
このとき遷移確率
rsuは
rsu=p(i)
となります。(2)の場合、つまり
s=uの場合の遷移確率は、既に取得しているアイテムがドロップする確率なので
rss=P(Xt+1=s|Xt=s)=∑i=1Ihsip(i)
となります。
(1)および(2)以外のケースでは遷移確率は
0となります。これは例えば、一回のガチャで複数個のアイテムがドロップしたり、持ってるアイテムが消えたりするようなケースはありえないこと(=確率が
0)に対応しています。
状態空間を圧縮する
ここまでの議論で定義された状態空間だとその大きさ|S|は2Iとなっていますが、同一のドロップ確率のアイテムが複数ある場合には、空間の大きさを圧縮し、計算量を削減することができます。
同一のドロップ確率のアイテムが複数ある場合とは具体的にはアイテムにいくつかの種類のレア度があり、各レア度でのドロップ確率が等しいという様な状況をここでは指します。
レア度の存在するアイテムガチャの定義は以下のようになります:
まずレア度を[1,…,j…,J]として、レア度jのアイテムの個数をMj、ドロップ確率をprare(j)とします。ここで確率の総和は1なので∑Jj=1Mjprare(j)=1が成り立っています。
状態空間Sの表現は、同一レア度のアイテムは、ドロップ確率が等しく互いに区別する必要がないことから、「各レア度のアイテムを何種類持っているか」を表す量mj∈[0,…,Mj]を使って表現できます。
具体的にはSは[0,…,Mj],j∈[1,…,j…,J]の直積になります:
S=[0,…,M1]×⋯×[0,…,Mj]×⋯×[0,…,MJ].
この新しい表現での状態空間のサイズは
|S|=∏Jj=1(Mj+1)となっています。
これは例えば、同一ドロップ確率のアイテムが10種類ある場合、元の状態空間の表現ではサイズが
1024=210となってるものが、新しい表現だと
11=10+1になっています。
遷移確率
rsuは、元の(圧縮されてない)状態空間の場合と同様の考え方で計算することができます。
具体的には、
msj,muj∈[0,…,Mj]、
s=(ms0,⋯,msj,⋯,msJ)、
u=(mu0,⋯,muj,⋯,muJ)として
rsu=⎧⎩⎨⎪⎪1–msjprare(j)/Mj∑jj=0msjprare(j)/Mj0[msl=mul(l≠j),msj+1=muj],msj=muj,else.
となります。
状態空間Sを自然数にエンコードする関数ϕ
rsuを行列の形で並べたものが遷移行列Rですが、「並べる」ためには状態空間Sを適当な全順序集合、例えば区間L=[0,…,|S|−1]と一対一対応させる必要があります。この対応写像ϕ−1:S→Lを以下のように定義します:
ϕ−1(s)=∑j=1Jmsj∏l=1j−1(Ml+1).
この定義は通常の基数を使った自然数の表現(
Mjが互いにすべて等しい場合)の自然な拡張になっています。
ϕ−1及びその逆写像
ϕが一対一対応(
全単射)になっていることは、
Lと
Sの集合サイズ(
濃度)が等しいこと、
ϕ−1(S)⊆Lとなっていること、
ϕ−1の出力が重複しない(
単射)なことから確認できます。
アイテムコンプの期待値の表現と計算
アイテムコンプに必要なガチャ回数の期待値を確率を使って計算/表現してみます。さらにその期待値表現を行列を使った計算に置き換えます。
期待値を計算してみる
c=(M1,M2,⋯,MJ)∈Sをアイテムを全て取得している状態とします。Tをアイテムコンプを達成した最初の時刻tを表す確率変数とすると、[T=t]↔[Xt=c,Xt−1≠c]が成り立つことからTの期待値Sは以下のようになります:
S=E[T]=∑t=1∞tP(Xt=c,Xt−1≠c).
Sはさらに
S=∑t=1∞tP(Xt=c,Xt−1≠c)=∑t=1∞t∑s≠cP(Xt=c|Xt−1=s)P(Xt−1=s)
と書き表せます。
P(Xt=c|Xt−1=s)=rscなので、
P(Xt−1=s)を計算できれば期待値が評価できることが分かります。これはマルコフ連鎖で各時刻での状態の確率分布を求めることに対応します。
行列を使った期待値の計算(1)
マルコフ連鎖では遷移行列を使うことで、各時刻での分布P(Xt=s)を計算していくことができます。まず時刻tでの確率分布をベクトルπt∈[0,1]|S|で表現します。πtは具体的には以下のように時刻tでの各状態の確率を並べたものとなります:
πt=[P(Xt=ϕ(0)),P(Xt=ϕ(1)),⋯,P(Xt=c=ϕ(|S|−1))].
この表現を使うと
πtは初期分布
π0を使い、以下のように書き表せます:
πt=π0Rt.
ここで遷移行列
Rは
n+1行
m+1列成分に遷移確率
rϕ(n)ϕ(m)を並べたものです。この
πtの表現を使うと期待値は以下のように計算されます:
S=∑t=1∞tπ0Rt−1e.
ここで
eはある状態
s≠cから状態
cへ遷移する確率(
rsc)を並べたベクトルです:
e=[rϕ(0)c,rϕ(1)c,⋯,rϕ(|S|−2)c,0]T.
eを右からかけることは
∑s≠cP(Xt=c|Xt−1=s)での計算に対応しており、最後の成分が
0なのは
∑s≠cが
s=cの場合を除外していることに相当します。
行列を使った期待値の計算(2)
後々の議論のために、∑∞t=1tπ0Rt−1eをさらに変形します。まず、Rを以下のような部分行列に分解します:
[Q0η1]=R.
ここで
rcc=1であることに注意してください。
Rtに関して以下が成り立ちます:
Rt=[Qt0∗1].
e=[ηT,0]Tである事に注意すると、期待値は最終的に以下のように計算できます:
S=∑t=1∞tπ′0Qt−1η.
ここで
π′0∈R|S|−1は
π0から末尾を除いたものです:
[π′0∗]=π0.
初期分布
π0及び
π′0はどのような値でも良いのですが、「アイテムを持っていない状態から始めること」に相当する
π0=[1,0,⋯,0]を実装では採用しました。
無限級数の総和の評価方法
S=∑∞t=1tπ′0Qt−1ηの計算方法について考えます。Sは無限級数の総和なので(総和を途中で打ち切るなどの近似をしないと)このままでは数値的に評価できません。そのために問題を一般化してみて、適当な行列Aに対して定義された以下のような級数和G(A)の計算方法を考えてみます:
G(A)=∑n=1∞nAn−1.
この総和は行列
Aのレゾルベントを使うと計算することができることが分かります。
レゾルベントとは
行列(一般には線形作用素A)に対して、レゾルベントR(z,A)は以下のように定義されます:
R(z,A)=(A−zI)−1.
ここで
z∈Cは(
Aが行列の場合は)
z∈{z:det(A−zI)≠0}を満たす値です。さらに
Aの
スペクトル半径)を
ρ(A)として
|z|>ρ(A)が満たされる時、以下のような級数和と等しくなります:
R(z,A)=∑n=0∞Anzn+1.
この式は、スペクトル半径の性質から
limn→∞(A/z)n=0↔|z|>ρ(A)が成り立つことに注意しつつ右辺に
(A−zI)をかけて具体的に計算してみることで証明できます。
R(z,A)の級数和での表示は
ローラン級数展開の形になっており、従って
R(z,A)は
|z|>ρ(A)で
正則なので、微分することができます。
R(z,A)微分して級数の評価に適用する
G(A)はR(z,A)の2つの表現での微分を考えることで計算できます。 まず、∑∞n=0An/zn+1を微分してz=1を代入すると以下が得られます:
dR(z,A)dz∣∣∣z=1=ddz∑n=0∞Anzn+1∣∣∣z=1=–∑n=0∞(n+1)An.
ここで
G(A)の定義と見比べてみると以下が分かります:
G(A)=∑n=1∞nAn=∑n=0∞(n+1)An=–dR(z,A)dz∣∣∣z=1.
同様にして
(A−zI)−1の
z=1での微分は逆行列の微分の公式
ddc(A+cB)−1∣∣∣c=0=–ABA
を使うことで以下のように計算できます:
ddz(A−zI)−1∣∣∣z=1=ddz(A−I–zI)−1∣∣∣z=0=(A−I)−2.
以上の2つの微分の結果を合わせると以下が得られます:
G(A)=–(A−I)−2.
無限級数の総和
Gを逆行列を使って表現することが出来ました。
Sの評価に適用
以上の結果を使うことでSは以下のように計算できます:
S=∑t=1∞tπ′0Qt−1η=π′0(∑t=1∞tQt−1)η=π′0G(Q)η=–π′0(Q−I)−2η.
ここで
ρ(Q)<1が成り立っている必要があることに注意して下さい。
Rの場合だと
ρ(R)=1となってしまい、このような議論を適用することはできません。期待値評価として、
∑∞t=1tπ0Rt−1eを使わなかったのはここに理由があります。
ρ(Q)<1が成り立っていることは
おまけにて証明します。
−π′0(Q−I)−2ηはもう少し簡略化することができます。
e∈R|S|−1を
e=[1,1,⋯,1]Tとすると、
Rの各行の総和が
1(遷移行列の性質より)であることから、
e=Re=Qe+ηが成り立ちます。ここから、
(I−Q)−1η=eが得られ、これを使うと最終的に以下が得られます:
S=π′0(I−Q)−1e.
実装にあたって
数値計算コード/Webアプリとして実装する際に行った工夫などについて簡単に紹介します。
疎行列表現
RやQやI−Qは、殆どの成分が0の行列です。 このような行列は疎行列と呼ばれ、通常の行列よりも数値的に効率良く扱うことができます。Pythonの数値計算ライブラリであるSciPyにはscipy.sparseという疎行列専用のライブラリがあり、実装ではこれを用いました。
逆行列は計算したらダメ
逆行列の計算にはO(N3)の計算量が必要となります。 今回のケースの様に、もし必要な値が逆行列に何らかのベクトルをかけた結果なら、代わりに連立一次方程式を解く問題に置き換えることで計算量をO(N2)まで抑えることが出来ます。具体的には、A−1bを求めることは、以下の連立1次方程式をxについて解くことと等価です:
Ax=b.
疎行列が絡む連立一次方程式を解くには、例えばscipy.sparse.linalg.spsolveなどが使えます。
アプリとしてデプロイ
今回のアプリは最終的にはSPA(というほど大層なものではありませんが)の形でまとめました。 フロントエンドはVue.jsを使用しており、バックエンドはPython 3/Google App Engineを使用しています。
特にApp Engineはデプロイ等も簡単で、またSciPyの利用もテンプレート的なものが用意されていたので非常に楽でした。
おまけ
数値計算コードを素早く開発するには
今回作ったもののプロトタイプ(状態空間の圧縮表現を使っていないバーション)は約一日で完成させました。これまでの経験から、数値計算コードを素早く開発するには「デバッグ時間の最小化」が最も重要でありそのためには以下が有効であると考えています:
- テストを書く(Pythonのdoctestは簡単に使えて便利)
- assertionを使い防御的なプログラミングに徹する
- 別ルートで正解を計算しておいて、結果の正しさを検証できるようにしておく
ρ(Q)<1であることの証明
ρ(Q)<1↔limn→∞Qn=0及び適当な自然数kで∀k>0[limn→∞Qn=limn→∞(Qk)n]が成り立つので、∀k>0[ρ(Q)<1↔ρ(Qk)<1]が言える。なので適当なkに関してρ(Qk)<1が成り立つことを言えば良い。スペクトル半径は作用素ノルム(||⋅||op)で上から押さえられるので、適当なkに関して||Qk||op<1が言えれば充分である。L1-ノルムからL1-ノルムへの作用素ノルムを考える。Qは全ての成分が正なので全ての成分が正であるベクトルxにQkを作用させた時に、そのL1-ノルム||xTQk||1が||x||1より小さくなくことを言えば良い。またxは特に、||x||1=1の場合を考えれば充分である。つまり||xTQk||1<1を言えば良い。||xTQk||1はQの構成法から、適当な初期分布(最初からアイテムコンプである確率は0として)から始めた時に、kステップ後に「アイテムコンプ状態でない」確率を表す。なので、∀n[p(n)>0]より、どのような初期分布から始めても、適当なkを取れば||xTQk||1<1が成り立つ。