かつかれーのメモ帳

実験ノートか勉強記録

機械学習を用いた音ゲーのクリア難度推定 (その1)

音ゲーのクリア難度推定なんかデータサイエンスの入門にいいんじゃないかな?とふと思い立ってやることにしました。3記事くらいに分けて公開します。初回はデータ収集のためのWebスクレイピングについて。

やること

  • LR2IRのデータをもとに、BMSの譜面のクリア難度を推定します。
  • 譜面とプレイヤーの相性が要因として大きいので、Eloレーティングのような1軸評価ではない手法について検討します。

データの準備

LR2IRにはプレー人数の非常に少ない譜面や、アクティブでないアカウントが多く存在します。
そこで、まず集計対象プレイヤーについて「BMS発狂難易度表の収録曲のうち125曲以上スコア登録済み」という基準を設けます。これはStairwayの基準を引用したものです。
そして、「集計対象プレイヤー(現在12000人くらい)のうち、100人以上がスコアをつけている」曲を集計対象曲とします。

import re
from urllib.request import urlopen
from bs4 import BeautifulSoup
#stairwayの集計対象プレイヤーのid一覧を取得
player_ids=[]
bs = BeautifulSoup(urlopen("http://stairway.sakura.ne.jp/bms/LunaticRave2/?contents=player&p=1"))
for player_link in bs.find_all("a", {"href" : re.compile("/?contents=player&page=\d")}):
    player_id = int(player_link["href"].split("=")[-1])
    player_ids.append(player_id)
#ファイルに保存
with open("playerid","w") as f:
    f.write(str(player_ids[0]))
    for player_id in player_ids[1:]:
        f.write(",")
        f.write(str(player_id))

全ユーザーに対してプレー履歴の全取得をするとちょっとサーバーに負荷をかけてしまいそうなので、ユーザーリストはStairwayから拝借しました。
aタグのうち、プレーヤーのmypageへのリンクの形になっているものを選び出し、splitすることでplayer idを得ています。
これは単一ページのパースなのですぐできると思います。
次に、譜面ごとにプレー人数を集計します。譜面ID(bmsid)をkey、プレー人数をvalにした辞書で数えていきます。keyがないとき勝手に0を入れてくれるdefaultdict(int)が便利。負荷をかけないようsleepを入れながら、丸一日くらいで取得しました。
このうち、100人以上がプレーした譜面を残します。意外なことに、10000譜面ちょっとありました。

import re
from collections import defaultdict
from urllib.request import urlopen
from bs4 import BeautifulSoup
#各playerのプレーしたことあるbmsidを全取得、bmsidごとにプレーした人数を数える
num_player = defaultdict(int) #key:val=(bmsid, プレー人数)
for player_id in player_ids:
    for i in range(1, 10000):
        bs=BeautifulSoup(urlopen(f"http://www.dream-pro.info/~lavalse/LR2IR/search.cgi?mode=mylist&playerid={player_id}&sort=recent&filter=7&page={i}"))
        findresult=bs.find_all("a", {"href" : re.compile("search.cgi\?mode=ranking&bmsid=\d")})
        if not findresult:
            break #空のページに至ったらbreak
        for bms_link in findresult:
            bms_id = int(bms_link["href"].split("=")[-1])
            num_player[bms_id]+=1
        time.sleep(0.01)
    print(f"player{player_id} done")
#プレー人数がmin_playcountより多い譜面だけ残してbms_idsにidを集計
bms_ids=[]
for k in num_player.keys():
    if num_player[k]<min_playcount:
        continue:
    else
        bms_ids.append(k)
#ファイルに保存
with open("bmsid","w") as f:
    f.write(str(bms_ids[0]))
    for bms_id in bms_ids[1:]:
        f.write(",")
        f.write(str(bms_id))

最後に、この集計対象譜面について全プレーヤーのスコアを取得し、集計対象ユーザーのものだけ残してファイルに保存します。1譜面1ファイルで書き出しています。
丸二日くらいかかりました。ファイルサイズは合計で770 MBでした。

import re
import time
import os
from collections import defaultdict
from urllib.request import urlopen
from bs4 import BeautifulSoup

with open("playerid","r") as f:
    player_ids=set([int(i) for i in f.read().split(",")])
with open("bmsid","r") as f:
    bms_ids=set([int(i) for i in f.read().split(",")])

for bms_id in bms_ids: #集計対象譜面についてループ
    if os.path.exists(str(bms_id)):
        print(f"bmsid {bms_id} ... skipped")
        continue
    with open(str(bms_id), "w") as f:
        print(f"bmsid {bms_id} ... ",end="")
        for i in range(1, 10000):
            bs=BeautifulSoup(urlopen(f"http://www.dream-pro.info/~lavalse/LR2IR/search.cgi?mode=ranking&page={i}&bmsid={bms_id}"))
            find_result=bs.find_all("a", {"href" : re.compile("search.cgi\?mode=mypage&playerid=\d")})
            if not find_result:
                break #空のページに至ったらbreak
            for player_link in find_result:
                player_id = int(player_link["href"].split("=")[-1])
                if player_id not in player_ids:
                    continue
                playdata = player_link.parent.parent.contents[3:]
                f.write(str(player_id))
                for item in playdata:
                    f.write(",")
                    f.write(item.get_text())
                f.write("\n")
            time.sleep(0.01)
        print("done")

例えばBMS界のAAこと星の器(bmsid=15)に対する出力ファイルの冒頭はこんな感じ。各行で一番左の列はplayer id、残りがプレーデータです。

37188,FULLCOMBO,AAA,4012/4012(100%),2006/2006,0,2006,0,0,0,2,難,乱,KB,LR2
83367,FULLCOMBO,AAA,4011/4012(99.97%),2006/2006,0,2005,1,0,0,0,難,乱,BM,LR2
202,★FULLCOMBO,AAA,4010/4012(99.95%),2006/2006,0,2004,2,0,0,0,難,乱,KB,LR2
105711,FULLCOMBO,AAA,4010/4012(99.95%),2006/2006,0,2004,2,0,0,0,易,乱,KB,LR2
120831,FULLCOMBO,AAA,4010/4012(99.95%),2006/2006,0,2004,2,0,0,0,難,乱,KB,LR2
160637,FULLCOMBO,AAA,4009/4012(99.92%),2006/2006,0,2003,3,0,0,2,難,乱,BM,LR2
15499,FULLCOMBO,AAA,4008/4012(99.9%),2006/2006,2,2002,4,0,0,4,難,乱,BM,LR2
72359,FULLCOMBO,AAA,4008/4012(99.9%),2006/2006,0,2002,4,0,0,0,易,乱,KB,LR2
94774,FULLCOMBO,AAA,4007/4012(99.87%),2006/2006,0,2001,5,0,0,0,難,乱,BM,LR2
3906,FULLCOMBO,AAA,4007/4012(99.87%),2006/2006,0,2001,5,0,0,0,難,乱,BM,LR2
36488,FULLCOMBO,AAA,4007/4012(99.87%),2006/2006,0,2001,5,0,0,0,難,乱,KB,LR2
2725,FULLCOMBO,AAA,4007/4012(99.87%),2006/2006,0,2002,3,0,0,1,難,乱,BM,LR2
4314,FULLCOMBO,AAA,4007/4012(99.87%),2006/2006,0,2002,3,0,0,1,難,乱,BM,LR2
95222,FULLCOMBO,AAA,4007/4012(99.87%),2006/2006,0,2001,5,0,0,0,難,乱,BM,LR2
139053,FULLCOMBO,AAA,4006/4012(99.85%),2006/2006,0,2000,6,0,0,1,難,乱,BM,LR2
22923,FULLCOMBO,AAA,4006/4012(99.85%),2006/2006,0,2000,6,0,0,0,難,乱,KB,LR2
4812,★FULLCOMBO,AAA,4006/4012(99.85%),2006/2006,0,2000,6,0,0,13,難,乱,BM,LR2
51433,FULLCOMBO,AAA,4006/4012(99.85%),2006/2006,0,2000,6,0,0,8,普,乱,BM,LR2
25546,FULLCOMBO,AAA,4005/4012(99.82%),2006/2006,0,2000,5,0,0,4,普,乱,BM,LR2
...

次回はMarix Factorizationという方法を用いて、これらのデータから難度推定を行っていきます。
→すでに同等の成果が公表されていたのでこの件は保留とします。難度の他に推定したい量が思いついたら続けます。