弐寺の譜面レベル推論モデルを作った
はじめに
弐寺のSP譜面レベル(1~12の12段階)を推論するモデルを作りましたので公開します。本記事は音楽ゲームとニューラルネットワークの両方に興味がある人向けです。
要約
- 譜面を時系列データと捉え、LSTMを使ったニューラルネットワークによりレベルの推論を行いました。
- 学習の結果、検証データの正解率は 65.0% となりました。
- 信頼性はさておき、任意のSP譜面にレベルを自動で付与することが可能になりました。
- ソースとモデル(github)
譜面の学習・推論方法
譜面のレベルは曲の最後にどれだけゲージを残しやすいかで決まると考えられます。全体の99%が簡単でも最後の1小節が難しい譜面はクリアも難しい(=高レベル)ですし、初めの方が難しくても残りが簡単であればクリアも易しく(=低レベルに)なります。従ってレベルの判定には、譜面そのものに加えて時間の考慮も必要と考えられます。今回は譜面を時系列データと捉え、LSTMを使ってレベルを推論することにしました。
譜面フォーマット
譜面をネットワークに入力するため、以下のようなnumpy.ndarrayの2次元配列へ落とし込むことにしました。
配列の1次元目はノーツのタイミングを表します。3連符や32分等の細かいノーツ配置に対応するため、タイミングの最小単位を4分音符の48分の1(192分音符相当)としました。
また2次元目は譜面のレーン(ターンテーブル+1~7鍵)とBPMを表します。配列のBPM部分を除く各要素の値は以下のように決めました。
ノーツ無し | ノーツあり | CN・HCN・BSS始点/終点 | CN・HCN・BSS途中 | |
---|---|---|---|---|
値 | 0 | 1 | 1 | 0.5 |
※CN=チャージノート、HCN=ヘルチャージノート、BSS=バックスピンスクラッチ
上記の要領で作成した全小節をひと繋ぎにして譜面データは完成です。(図1において1次元目の実際のインデックスは 62小節*192=11904 を加算した値となります。)
ネットワーク
推論を行うネットワークを以下のようにChainerで実装しました。
model.py
from chainer import Chain import chainer.links as L import chainer.functions as F class Estimator(Chain): def __init__(self): super(Estimator, self).__init__() with self.init_scope(): self.layer1 = L.NStepLSTM(2, 1728, 256, 0.3) self.layer2 = L.Linear(256, 12) def __call__(self, x): _, _, h = self.layer1(None, None, x) h = [t[-1] for t in h] # 後でPCAやるために隠れ層を保持できるようにした self._h = F.stack(h) return self.layer2(self._h)
updater.py
from chainer import reporter from chainer.training import StandardUpdater import chainer.functions as F def concat_batch(batch: list): scores = [] levels = [] for (score, level) in batch: scores.append(score) levels.append(level) return scores, levels class EstimatorUpdater(StandardUpdater): def __init__(self, **kwargs): super(EstimatorUpdater, self).__init__(**kwargs) def update_core(self): # イテレータには譜面データと正解のレベルを格納している scores, levels = concat_batch(self.get_iterator("main").next()) opt = self.get_optimizer("main") est = opt.target(scores) lv = opt.target.xp.array(levels, dtype=int) loss = F.softmax_cross_entropy(est, lv) reporter.report({ "train/loss": loss, "train/acc": F.accuracy(est, lv) }) opt.target.cleargrads() loss.backward() loss.unchain_backward() opt.update()
学習の流れは以下の通りです。
- 読み込んだ譜面データのshapeを予め (n, 9) → (n/192, 192*9) と変形しておく(n=譜面の長さ)→ 4/4拍子換算で1小節ずつLSTMに入力できるようになる
- BPMの値を100で割る ← そのままだとノーツ部分の値が小さすぎるためか学習が進まなかったので、BPMの値をノーツ部分に近づけることにしたため
- (model.py) LSTMに譜面の初めから4/4拍子換算で1小節ずつ入力する
- (model.py) 譜面全体を入力し終えた時のLSTMからの出力をもとに譜面を12レベルに分類する
- (updater.py) 分類結果と正解のレベルの差が小さくなるように学習を進める
譜面の用意
今回の学習に使用する譜面データはTexTage様が公開しているページを参考にして作成しました。学習に使用したのは、現行のAC筐体でプレイ可能かどうかにかかわらず12段階のレベルが付いたことが一度でもある全ての譜面です。譜面の学習データ・検証データ・テストデータへの振り分けは、曲のAC初収録バージョンを基準としました。
曲の収録 | 振り分け | 譜面数 |
---|---|---|
CANNON BALLERS以前・家庭用限定 | 学習データ9:検証データ1 (ランダム) | 学習4228、検証464 |
Rootage以降 | 全てテストデータ | 473 |
結果
途中から過学習の傾向がみられますがちゃんと学習できているようです。検証時のlossが最小だった48epochで検証データの65.0%を正解し、98.0%が誤差1レベル以内に収まりました。この48epoch時点のモデルにテストデータを入力した結果、公式レベルと推論結果が一致したものは315譜面、不一致だったものは158譜面でした。
不一致だった譜面の中にはBEMANI Wiki 2nd2にコメントが書き込まれているものもありました。以下は評価がWikiのコメントと概ね一致した譜面の一例です。
譜面 | 公式のLv | 推論結果のLv | Wikiのコメント |
---|---|---|---|
CODE:∅(N) | 5 | 6 | ☆6中位~上位相当 |
dAuntl3ss(N) | 6 | 5 | ☆5レベル |
グラナダの風(H) | 8 | 7 | ☆7程度の密度 |
Backyard Stars(H) | 8 | 9 | ☆9下位~中位レベル |
Shiva(A) | 11 | 10 | ☆11にしては密度低め |
Necroxis Girl(A) | 11 | 12 | ☆12下位レベル |
Primitive Vibes(A) | 12 | 11 | ☆11上位レベル |
中には個人的に明らかにおかしいと思われた推論結果もあったので紹介します。
譜面 | 公式のLv | 推論結果のLv |
---|---|---|
ONIGOROSHI(N) | 6 | 9 |
IDC feat.REVERBEE(Mo'Cuts Ver)(H) | 9 | 8 |
Silly Love(A) | 10 | 9 |
LADYBIRD(A) | 11 | 10 |
最小三倍完全数(A) | 12 | 11 |
まとめ
譜面をnumpy.ndarray形式に落とし込み、LSTMを利用することによって譜面のレベルを推論することが出来ました。今回の結果をひとつの指標として今後も取り組んでいけたらと思っています。課題としては、
- 過学習対策をする必要がある
- 10小節に満たないような短い譜面や300小節を超えるような長すぎる譜面を学習していないため、そのような譜面のレベルを正しく推論できるか分からない
- そもそも学習に使用した譜面の公式レベル設定に詐称/逆詐称が含まれており、学習データの質の面で改善の余地がある
...といったところでしょうか。このモデルを作るために試行錯誤する間、最新作でレベルの変更された譜面が出たりChainerの開発が終了したりしましたが、私は元気です。
おまけ
個人的にLSTMからの出力の状態が気になったので、テストデータそれぞれを入力したときの隠れ層の状態(model.py のself._h)を集めて主成分分析を行いました。使用したモデルは上述した検証時のlossが最小だったときのものです。
色は推論結果のレベルを表します。(☆1=赤→橙→黄→緑→青→紫=☆12)
多少のばらつきはありますが何らかの規則に従ってキレイに並んでいるように見えます。試しに推定結果☆10(青色)の譜面の中から☆9(薄い青色)付近、中央、☆11(青紫色)付近を抜き出して尤度を比較してみました。
※ここでいう尤度はモデルの出力層にソフトマックスを適用した値を指します。
曲名 | ☆9の尤度 | ☆10の尤度 | ☆11の尤度 |
---|---|---|---|
ToyCube Pf.(RX-Ver.S.P.L.)(A) | 0.477 | 0.501 | 0.014 |
Crank It(A) | 0.112 | 0.868 | 0.017 |
EVANESCENT(H) | 0.028 | 0.922 | 0.048 |
THE DAY OF JUBILATIONS(H) | 0.013 | 0.816 | 0.168 |
ZENDEGI DANCE(A) | 0.008 | 0.511 | 0.471 |
成分が☆9に近い位置から☆11に近い位置へ移るにつれて、尤度のピークも☆9付近から☆11付近へ移動しています。これを利用して曲の間に難しさの序列を付け、地力表みたいな物を作れるのではないかとも思ったりしています。(全てのプレイヤーに当てはまるようなものを作るのは無理だと思いますが...)