結局ポテンシャル解放でプロデュースptはどう振り分けるのが正解なのか?(1)
こんばんは、むぅんです。
先月のデレステのアップデートにより、プロデュースptの上限が30から35に上がり、新たに特技発動率にもプロデュースptを割り振れるようになりました。記事を公開するのが遅すぎて先月どころの話ではなくなってしまいました。
それに伴い、これまでのVoDaViそれぞれに10ずつ振る振り方が最適解でなくなってしまったため、どのように振り分ければよいのかが分かりづらくなってしまいました。
そこで、ポテンシャル解放の最適解を求めるためにC++でプログラムを組んでみました。最初はPythonで書いていたのですが、シミュレートに日単位でかかることが判明したのでC++で1から書き直しています。
今回はひとまずパッションプリンセスの強そうなユニットで、各アイドルのVoDaViで一番低いステータスと特技発動率に余った15ptのうちそれぞれ何ptずつ振り分ければよいかを総当たりで調べてみたいと思います。スパークルに関しては未実装なので次回改めて調べてみます。
検証例
後から気付いたのですが、ステータスの振り方はセンター効果依存(基礎スコアは合計アピール値によって決定され、ポテンシャル解放によるステータスの上昇幅はどこに振っても同じなため)でプリンセスの場合どこに振ろうがスコアには影響を与えないんですよね。ですが、例えばVoが一番高いのにVoにプロデュースptを振らないのは違和感がありますし、こう決めておくことで後々トリコロールのスコアを計算するときにも同じ方法が使えるため今回はこのまま進めていきたいと思います。
技術的なお話
ただ書きたかっただけなので読むのが面倒な方は適宜読み飛ばしてください。
Simulation simu(idol); for(int p1=5; p1<=10; p1++){ for(int p2=5; p2<=10; p2++){ for(int p3=5; p3<=10; p3++){ for(int p4=5; p4<=10; p4++){ for(int p5=5; p5<=10; p5++){ vector<int> p{p1, p2, p3, p4, p5}; simu = simulateOneSet(simu, idol, p); } } } } }
今回はこんな感じに頭の悪い総当たりで調べていきます。p1からp5までのカウンタをvectorとして渡していますが、これが特技発動率のプロデュースptの組となります。例えばp1=8のとき、1人目のアイドルの特技発動率に8pt振るのに対し、一番低いステータスに7pt振ることになります。
このプロデュースptの組を元にステータスを計算し、指定した曲を何回かAPしたときのスコア(例えば最大値や平均値など)をプロデュースptとのpairとしてvectorに格納したあと、スコアを元にソートをかけてプロデュースptの組を取り出します。
今回はこのプロデュースptの組をvectorで渡しましたが、整数で渡すことも可能です。例えば、2次元配列のあるインデックス \([i][j]\) は、 \(i\) が取りうる値が \((0 \leq i < n)\) のとき \([i*n+j]\) と計算することで1次元配列に変換できます。これを \(N\) 次元に拡張することを考えます。
インデックスが \(i_1, i_2, ... , i_N\) (ただし要素数はいずれも \(n\) )のとき
\begin{eqnarray}
j=\sum^N_{k=1}i_k n ^{N-k}
\end{eqnarray}
とすれば1次元に変換できます。また、 \(d\) 次元目のインデックス \(i_d\) は
\[
i_d = j \left( \frac{1}{n} \right) ^{N-k+1} \bmod n
\]
と求めることができます。これを用いて、例えば3次元のインデックス \([3][4][2] (n=5)\) は \([3*5*5 + 4*5 + 2]\) 、すなわち \([97]\) と変換でき、逆に \([97/5/5\%5][97/5\%5][97\%5]\) を計算することで \([3][4][2]\) と復元することも可能です。実際に計算するときは5人分のアイドルのプロデュースptを5から10まで総当たりで調べることになるので、 \( n=6, N=5 \) のデータを格納することになります。今回は要素数が一定だったのでこの変換を用いたソートも可能でしたが、もっと複雑なループを行うと途端にわけがわからなくなるので今回は素直にvectorで表現することにしました。
void sortOthers(vector<pair<vector<int>, int>> &vec){ sort( vec.begin(), vec.end(), [](const pair<vector<int>, int>& x, const pair<vector<int>, int>& y){return x.second > y.second;} ); }
地味に苦労したソートの部分です。前述したようにデータはpairで管理しており、ここではsecondの値をもとにソートしています。
void setTable(vector<Idol> idol){ // 5人分(スキブを最優先して計算するためデクリメント) for(int i=4; i>=0; i--){ int cScore, cCombo, mem = 0; double t; // 全ノーツ分 for(int j=0; j<totalNotes; j++){ t = sec[j]; if(idol[i].begin[mem] > t){ continue; } else if(idol[i].end[count[i]-1] < t){ break; } else{ for(int k=mem; k<count[i]; k++){ if(idol[i].end[k] < t){ mem++; } } if(idol[i].begin[mem] <= t && idol[i].end[mem] >= t){ if(i == 4) isBoost[j] = true; // スキブの場合 else{ cScore = skillBonus[isBoost[j]][idol[i].skillName][0]; cCombo = skillBonus[isBoost[j]][idol[i].skillName][1]; bonusTable[j][0] = bonusTable[j][0] < cScore ? cScore : bonusTable[j][0]; bonusTable[j][1] = bonusTable[j][1] < cCombo ? cCombo : bonusTable[j][1]; } } } } } }
そして今回一番苦労した特技倍率をセットする関数です。bonusTableという2×ノーツ数分の要素数の2次元配列を用意し、そこに各ノーツのスコアアップとコンボナの倍率を埋めていく関数ですが(例えば100ノーツ目のスコアアップが17%、コンボナが18%だった場合 bonusTable[99][0] = 17, bonusTable[99][1] = 18 といった具合です)、スキブの扱いが非常に難しく適当に組むとプログラムの実行にとてつもない時間がかかる始末でした。そこで特技がスキブのアイドルを4番目に配置している前提のもとスキブが発動しているかどうかを最優先で調べ、その結果に応じスコアアップ等の倍率を場合分けするという処理を1回のループ中で行うことにしました。スキブの確認とスコア倍率の計算という2つの異なる処理を同一関数の同ループ内で行うあまりに汚いプログラムを書くのは正直気が引けましたが、結果として17倍ほど高速化できたので個人的には大満足です。どうせ自分しか見ないプログラムだし多少はね?
検証結果
今回検証に用いたユニットはこちらになります。
センター | Pa | 限定 | 本田未央 | 9高 | フォーカス |
Pa | 限定 | 片桐早苗 | 6中 | フォーカス | |
Pa | 限定 | 堀裕子 | 7中 | オバロ | |
Pa | 限定 | 高森藍子 | 11中 | コンボナ | |
Pa | フェス限 | 十時愛梨 | 8高 | スキブ | |
ゲスト | Pa | 限定 | 本田未央 | ー | ー |
このユニットで生存本能ヴァルキュリアのMASTER+を1000回APしたときの理論値、最大値、平均値、上位1% / 4% / 15% / 30% / 50%(中央値)スコアをMatplotlibでプロットした結果を以下に示します。縦軸が特技に振ったプロデュースptの5人分の合計、横軸が順位です。ただし、ゲストは特技発動率に振っても意味はないためVoDaViに10ずつ振った状態で固定しています。
まずは平均値から見てみましょう。
見事なまでに特技発動率に振ったほうが平均的に高スコアを叩き出せていることが分かります。相関係数は約-0.887、p値は0.001未満という綺麗な負の相関関係がある結果です。
続いて1000回APした内の最大値、すなわち特技の引きが最も良かったときの結果です。
流石に1000回もAPしただけあり、必ずしも特技発動率に振ったほうが良いとは言えない結果です。発動率の低さを試行回数で稼ぐ形となり、ステータスを伸ばすような振り方をしていてもひたすら繋ぎ続ければやがて高スコアを叩き出すことができるようですね。ただ、Lv30の楽曲を1000回もAPできるかどうかを考えるとやはり特技発動率に振らないのは良い選択とは思えません。
ちなみに、相関係数はおよそ0.034で殆ど相関は見られませんでした。
次に上位1% / 4% / 15% / 30% / 50%スコアを見ていきます。これらのスコアはデレステ計算機さんに準じてみました。
下位のスコアになるほどより強い負の相関があることが分かります。しかし、いずれも特技発動率に振ったほうがより高スコアを叩き出しやすいようです。
最後に理論値を見てみましょう。
流石に理論値だけあって非常に綺麗なグラフになっています。当然全員の特技発動率に10振ったときに最もスコアが低く出ています。
ここまでのまとめです。
相関係数 | p値 | |
---|---|---|
平均値 | -0.887 | <0.001 |
理論値 | 0.529 | 0.000 |
最大値 | 0.034 | 0.002 |
上位1% | -0.629 | <0.001 |
上位4% | -0.750 | <0.001 |
上位15% | -0.817 | <0.001 |
上位30% | -0.846 | <0.001 |
上位50% | -0.870 | <0.001 |
しかし、この結果だけではまだ特技発動率に振るべきとは言えません。ステータス重視の振り方をした場合、確かに理論値以外は大きく順位を落としていましたがそれだけではスコアがどれだけ落ちたか分からないためです。
というわけで、次はスコアの実数値を見ていきたいと思います。情報量が予想より遥かに多くなってしまったため次回に続きます。
おまけ
プログラムの実行にかかった時間の変遷です。先程の検証と同じく生存本能ヴァルキュリアのMASTER+を1000回シミュレートした場合の実行時間ですが、プロデュースptの組はVoDaViに10ずつ振った場合で固定してあります。実際の検証では7776通りの計算を行ったので、おおよそこれらの実行時間を7776倍すれば実際にかかる時間となります。
なお、下記に示す時間はtimeコマンドのrealの値となります。
完成直後(Python3) | 15.759s |
ファイル読み込みをループ外で行うよう変更 | 13.953s |
特技倍率を調べる関数の見直し | 12.043s |
更に変更 | 10.316s |
上記プログラムをC++で書き直す | 1.333s |
-O3オプションありでコンパイル | 0.955s |
特技に関する関数の最適化 | 0.056s |
というわけでPythonが如何に遅いかがよく分かる結果となりました。書きやすさは圧倒的にあちらの方が上だったんですけどね…。
ちなみに、0.056秒という記録を叩き出した最後のプログラムで7776通りのシミュレートを行うと約7分12秒ほどかかりました。最初のプログラムで同じ回数だけ回すと1日以上かかる計算になるので頑張って書き直して良かったです。