ランダムな推しグッズへの抵抗
これは ISer Advent Calendar 2022の12/11の記事として書かれました。
はじめに
3年間も推し活をしていると次のような問題に出会うことがとても多いです。
アイドルの写真集などはランダムで1枚ポストカードが封入されていたりしますし、中身が見えない袋に入った状態でメンバーの人数分のグッズが展開されたりすることも日常茶飯事です。特にこの前の日向坂とココイチのコラボキャンペーンは地獄でした。詳しくはこちらの記事を参照してください。
この記事では、グッズを買うときに便利な確率質量関数を作ろうと思います。具体的には、グッズが種類あって、欲しいグッズが特定の種類のとき、回目で欲しいグッズが揃う確率を求めます。できるだけお金を使わないようにしたいので、グッズは一つずつ買って中身を確認するという戦略を採用することにします(実際は、生写真など5枚組だったりするものもあるので、一概にこの戦略が使えるわけではありません)。もし、たとえば5個一気に買うような場合は、5回目までに揃っている確率()を計算すれば、5個買って揃う確率が出ると思います。
受験数学では場合の数・確率はそこまで得意じゃなかったので、間違いがあったら申し訳ないです。
例題を解いてみる
まずは上に挙げた例題を解いてみましょう。
4回で試行が終わる場合
これは簡単ですね。4回引いて、欲しい4種類が出るということなので、確率はです。4回で欲しい4種が出るほど世の中は甘くありません。
5回で試行が終わる場合
まず5回目で初めて出るグッズを選びます(通り)。そして最初の4個については、欲しいグッズだけで構成されている場合と、欲しくないグッズが含まれている場合があります。
欲しいグッズ(3種類)だけで構成されている場合は、1種類がダブっているので、何がダブるかで通りあり、順列が通りあるので、合計通りあります。
欲しくないグッズが混ざっている場合は、欲しくないグッズの選び方が通りあり、欲しいグッズ3つと欲しくないグッズ1つの順列があるので、合計通りあります。
したがって確率は、
となります。ここまではなんとか計算できます。720は6!ですね。では次は8!でしょうか。残念ながら違います。
6回で試行が終わる場合
6回目で初めて出るグッズは通りです。そして最初の5個については、前回と同様に欲しいグッズだけで構成されている場合と、そうでない場合があります。が、そこはあまり問題ではありません。問題なのは、種類の欲しいグッズを重複ありで個並べる場合の数を求める必要があることです。ただの重複順列ではなく、個のうち、欲しい種類それぞれが少なくとも一つ以上ないとダメなので、少し厄介です。
例として、3種類の欲しいグッズを重複ありで5個並べる場合の数を求めてみましょう。求める場合の数は、1,2,3,4,5と書かれたボールがあり、それらをA,B,Cと書かれた箱に、空箱を作らないように入れる場合の数とも言えます。これはという重複順列から、空箱ができてしまうパターンを除けば良いです。
空箱が出るパターンは、2つの箱にボールが偏るパターン(どの2つの箱かで通り、2つの箱に空箱を作らず5つのボールを入れるので通り)と、1つの箱にボールが偏るパターン(3通り)があります。
よって合計、通りあります。
このような場合の数は、第2種スターリング数に近いです。第2種スターリング数はWikipediaによると、
と定義されているので、グループに番号を振ってあげれば、上で扱った場合の数に一致します。つまり、上の例でいうと、ということです。
今回は、グループを区別する場合の数のほうをよく使うので、
と定義しておきます。
これを使うと、6回で試行が終わる場合の確率が出せます。
欲しくないグッズが含まれている場合は、どの欲しくないグッズを選ぶかとそれをどこに配置するかを決めた後、残ったスペースに欲しいグッズを上の要領で配置します。
7回以降で試行が終わる場合
6回目で試行が終わる場合と同様に計算できます。実は4,5回目で試行が終わる場合に関しても6回目と同じ議論で求められます。
以上の考察により、回グッズを買ったときに試行が終わる確率が求まります。
ためしに以下のようなプログラムで期待値を求めてみましょう。
プログラム
import math
def Sprime(n, m):
res = 0
for k in range(1, m+1):
if (m - k) % 2 == 0:
res += math.comb(m, k) * k ** n
else:
res -= math.comb(m, k) * k ** n
return res
def G(n, m, x):
if n == 1:
return 1
if m == 1:
return 1/n * (1-1/n) ** (x-1)
target = 0
for i in range(x - m + 1):
target += math.comb(x-1, i) * (n-m) ** i * Sprime(x-1-i, m-1)
target *= m
res = target / n ** x
return res
def E(n,m):
res = 0
for x in range(m, n * 10):
res += x * G(n,m,x)
return res
if __name__ == "__main__":
print(E(10,4))
スターリング数はたぶん漸化式で計算したほうがいいんだろうけど、
$ python goods.py
20.820465970094187
だいたい21個です。想像よりかなり多いですね。
一般化
上のプログラムで少し出てきてしまったのですが、一応、一般の場合についてまとめておきます。
グッズが種類あり、特定の種類が欲しいときに、回目で試行が終わる確率は
となります。のときは、グッズが1種類しかないので、1回引けば当たります(2回以降も便宜的に確率は1とします)。のときは、幾何分布の確率質量関数とほぼ同じです。
また第2種スターリング数の一般項は以下のとおりです。
これは、
を用いて、数学的帰納法で証明できます(当初、スターリング数の存在を知らず、自力でスターリング数を見つけて、自力で一般項を導出、証明した私を褒めてください)。
できれば期待値や分散も他の確率分布のようにすっきりした形で書きたいのですが、
のような無限級数を求めることは僕にはできませんでした。スターリングの公式とかを使ったらいけるのかもしません。コンピュータで計算することにします。
確率分布
さて、私達はいまグッズを買う際に、欲しいグッズが手に入る確率を確認したいのでした。一目でだいたい何個ぐらい買うと全部揃うのかを確認できるようなグラフを作ってみます。
プログラムはこちら
スターリング数はたぶん漸化式で計算したほうがいいんだろうけど、めんどくさいので一般項で計算してます。多分結構大きい引数を与えるとオーバーフローします。そこまでグッズ数多いことはあんまりないので、これでいいのです。
import math
import random
import matplotlib.pyplot as plt
import numpy as np
import sys
def Sprime(n, m):
res = 0
for k in range(1, m+1):
if (m - k) % 2 == 0:
res += math.comb(m, k) * k ** n
else:
res -= math.comb(m, k) * k ** n
return res
def G(n, m, x):
if n == 1:
return 1
if m == 1:
return 1/n * (1-1/n) ** (x-1)
target = 0
for i in range(x - m + 1):
target += math.comb(x-1, i) * (n-m) ** i * Sprime(x-1-i, m-1)
target *= m
res = target / n ** x
return res
def E(n, m):
res = 0
for x in range(m, n * 10):
res += x * G(n, m, x)
return res
def V(n,m):
E_X = 0
E_Xsqr = 0
for x in range(m, n * 10):
E_X += x * G(n, m, x)
for x in range(m, n * 10):
E_Xsqr += x**2 * G(n, m, x)
return E_Xsqr - E_X ** 2
if __name__ == "__main__":
args = sys.argv
if len(args) < 3:
print("Two arguments are required")
exit()
n = int(args[1])
m = int(args[2])
limit = int(n) * 10
xs = list(range(1,limit))
ys = []
cums = []
cum = 0
for x in xs:
cums.append(cum)
y = G(n,m,x)
cum += y
ys.append(y)
fig, ax1 = plt.subplots()
ax1.bar(xs, height=ys, width=1.0, label='pmf')
ax1.minorticks_on()
Ex = E(n,m)
Vx = V(n,m)
Sx = math.sqrt(Vx)
ax1.vlines(x=Ex, ymin=0.0, ymax=np.max(ys), color='orange', linestyle='--', label='$E[X]$')
ax1.vlines(x=Ex - Sx, ymin=0.0, ymax=np.max(ys), color='orange', linestyle=':', label='$E[X] - \\sqrt{V[X]}$')
ax1.vlines(x=Ex + Sx, ymin=0.0, ymax=np.max(ys), color='orange', linestyle=':', label='$E[X] + \\sqrt{V[X]}$')
ax1.grid(axis='x', which="major", color="black", alpha=0.5)
ax1.grid(axis='x', which="minor", color="gray", linestyle=":")
ax2 = ax1.twinx()
ax2.plot(xs,cums,color="red", label="cdf")
ax2.minorticks_on()
ax2.set_yticks([0.0, 0.25, 0.50, 0.75, 1.0])
ax2.grid(which="major", color="black", alpha=0.5)
ax2.grid(which="minor", color="gray", linestyle=":")
h1, l1 = ax1.get_legend_handles_labels()
h3, l3 = ax2.get_legend_handles_labels()
ax2.legend(h1 + h3, l1 + l3, loc='lower right')
plt.savefig("goods.png")
上のグラフはとしたときの確率分布と累積確率分布です。他にも期待値や1シグマ範囲を表示しています。
としたのには理由があります。12月17日から始まる日向坂とココイチのコラボキャンペーンで、チキン4つセットを一箱買うと1枚ポストカードがもらえるというキャンペーンがあるのですが、そのポストカードが全部で10種類あり、欲しいポストカードが2種類なのです(きょんことひなちゃんです)。
そして私はいまそのチキンを5箱予約しているので、上のグラフから5個で揃う確率は15%しかないことがわかります。予想より低くて軽く絶望しています。
じゃあもうせめてきょんこだけでもいいので5個で当たる確率が知りたい、となったら、とすればよいです。
おお!40%の確率で当たるぞ!少し希望が持てます。
では、12月20日に発売する金村美玖の写真集のランダムポストカードはどうでしょうか。全部で6種類あり、どうせみんな全種類ほしいと思うので、として画像を生成してみます。
平均で14冊ほど買う必要がありそうですね。これは、各店舗ごとのポストカード特典と限定カバー特典を集めてたら多分このぐらいに到達すると思うので、ちょうどいいですね。
私は今、非常に金欠なので、1冊で十分です。
上のグラフたちを眺めていたら、形がガンマ分布に似ていることに気づきました。なので、ガンマ分布のフィッティングをした結果、かなりの精度でフィットすることがわかりましたが、ガンマ分布の引数とがどう関係しているかがいまいちわからないので、今後の課題とします(しない)。もしかしたらがガンマ分布っぽく書けるのかもしれませんが、どう式変形するのか検討もつかないのでやめておきます。
最後に
常に金欠気味な私にとっては、どのくらいグッズを買うことになるのかを予測することは非常に重要です。今回、求めた確率分布を手軽に計算できる形で表現することはできませんでしたが、多少なりとも使える確率質量関数が作れたのではないかと思います。
今後もランダム性のあるグッズを大量に買うことになると思うので、その都度確率を見積もってから買う癖をつけようと思います。推しが出るかどうかが結果を見るまでわからないというスリルがなんだかんだ好きです。