【麻雀】配牌から和了を判定する【天鳳牌譜解析】

みなさんこんにちは.接点QBです.今回は天鳳の牌譜を解析して,配牌から和了出来るか否かを判定する分類器を作ってみたいと思います.

僕は学部の頃から麻雀をしていて,数理統計学の研究室で機械学習の研究をしていました.そして,今はコンピュータ・サイエンス領域の研究室に在籍しています. そうです.天鳳の牌譜解析にうってつけのバックグラウンドですねw. しかしながら,牌譜解析のプログラムを組むのが意外と面倒で,手を付けていませんでした. 麻雀関連のプログラムってあまりコードが公開されていなくて,公開されていてもCとJavaがほとんどなので,ちょっと僕には敷居が高かったわけです(PythonとRしか使ったことないので). まあ,せっかく就活も終わったので,時間がある時に牌譜解析やってみようかなーと思いまして,手始めにタイトルのようなことをやってみました. 決して,天鳳で八段からの降段が間近だから打つのを日和って時間が出来たからという理由ではありません!

今回の分類器を作ろうと思った理由ですが,学部時代にサークルの知人が「オレは配牌を見て和了れそうにない時は,最初から安牌を大量に抱える!」と言っていまして, 僕としては「上がれるかどうかなんてツモ次第で分からんやろ.みすみす和了の機会を逃すようなことは良くないのでは?」と考えていまして, 「機械学習で配牌から和了出来るかを判定出来るのか?」と気になったからです. まあ,その知人の脳内での処理が今回作成した分類器に匹敵または勝るという保証は全く有りませが…

天鳳の牌譜解析って結構めんどくさくて,「車輪の再発明」を避けるために出来るだけコードも載せていこうと思います. ただし,個々で書くコードが最適でない可能性が結構高いと思うので,その点はご了承下さいm( )m

牌譜ファイルの形式

牌譜の所得

まず天鳳の牌譜なんですが,日本プロ麻雀協会所属近藤千雄さんがご自身のブログで公開して下さっています(麻雀にどんよくです:天鳳牌譜解析をはじめたい人へ - livedoor Blog(ブログ)).

で,ここから適当な年の牌譜をダウンロードしてきて解凍しますと,txtファイルがあると思います.それが天鳳の牌譜です. 僕が解析するときはUTF8に変換してから使っています.

牌譜の形式

牌譜の形式を説明します.とりあえず2015年の牌譜の先頭20行ほどを見てみましょうか.

===== 天鳳 L0000 鳳東喰赤速 開始 2015/12/31 http://tenhou.net/0/?log=2015123123gm-00e1-0000-f2529b5b&tw=0 =====
  持点25000 [1]雷神魔理沙/七段/女 R2082 [2]天照/七段/男 R2199 [3]心の旋律/七段/男 R2176 [4]<>/七段/男 R2069
  東1局 0本場(リーチ0)  雷神魔理沙 -6000 天照 -3000 心の旋律 13000 <> -3000
    跳満ツモ 立直1 門前清自摸和1 平和1 ドラ2 赤ドラ1 裏ドラ0
    [1東]2m6m9m7p3s8s東南西西北発発
    [2南]3m5m7m7m2p4p5p6p7p6s南北中
    [3西]1m3m4p6p8p9p9p3s3s4s7s7s9s
    [4北]4m7m9m2p2p4p3s4s7s南西白発
    [表ドラ]2s [裏ドラ]5m
    * 1G2s 1d9m 2G発 2d中 3G中 3D中 4G6s 4d西 1G東 1d2m 2G1p 2d発 1N発発 1d北
    * 2G1p 2d北 3G8s 3d7s 4G9s 4D9s 1G7s 1d7p 2G9m 2d南 3G1m 3D1m 4G6m 4d9m
    * 1G4m 1D4m 2C3m5m 2d1p 3G1s 3D1s 4G西 4D西 1N西西 1d6m 2G1p 2D1p 3G8m 3D8m
    * 4G4m 4d発 1G3m 1D3m 2G9p 2d9m 3G1s 3d3m 4G5s 4d白 1G5s 1D5s 2G2m 2D2m
    * 3G4s 3d1m 4G6s 4d南 1G7m 1D7m 2N7m7m 2d1p 3G5p 3d8p 4G1p 4D1p 1G6p 1D6p
    * 2G6s 2d9p 3G2s 3R 3d1s 4G2p 4d7s 1G7p 1d南 2G5M 2D5M 3G中 3D中 4G8p
    * 4D8p 1G3p 1d7s 2G4m 2D4m 3G5S 3A

  東2局 0本場(リーチ0)  心の旋律 4900 <> -3900
    30符3900点3飜ロン 対々和2 断幺九1
    [1北]3m4m6m7m8m2p6p7s8s南西白発

大体何が書いてあるかは分かりますよね? この牌譜から必要な情報を抽出するプログラムを組みます.

これからやること

配牌をone-hot-vectorに変換して,上がれたか否か(0 or 1)をラベル付けします. one-hot-vectorって何?という人のために説明しておきますと,たとえば手牌が「123456m234p78s北北」だとすると,one-hot-vectorは $[1,1,1,1,1,1,0,1,1,1,0,0,\cdots,01,1,0,0,\cdots, 1,1,0,\cdots, 0]$となります.これは下の表に対応しています.

1m 2m 3m 4m 5m 6m 7m 1p 2p 3p 4p 5p 6s 7s 8s
1 1 1 1 1 1 0 0 1 1 1 0 0 1 1 0 2 0

このベクトルに対して,和了出来ていたら0,出来ていなかったら1をラベル付けします.

配牌と和了情報を取り出す

ここからは実際に牌譜を処理するコードを書きます. まずは正規表現を書いておきます.

end_pattern1 = re.compile("  ---- 試合結果 ----")
end_pattern2 = re.compile(" *[1-4]位")
end_pattern3 = re.compile('----- 終了 -----')

pattern_start = re.compile('=====*')  # ゲームスタート行の先頭文字列パターン
pattern_player = re.compile(' *持点')  # プレイヤー情報行の先頭文字列パターン
pattern_kyoku = re.compile(' *[東南西][1-4]局')  # 局情報行の先頭文字列パターン
pattern_tehai = re.compile(' *\[[1-4][東南西北]\]')  # 手牌情報行の先頭文字列パターン
pattern_dahai = re.compile(' *\*')  # 打牌情報行の先頭文字列パターン

次に,配牌と局ごとの和了者を取り出します.

game_id = 0
kyoku_id = 0
kyoku_lines = []  # 局情報
tehai_lines = []  # 手配
dahai_lines = []  # 打牌行格納用リスト
winner = []  # 和了者

file = '../data/houton2015_utf8.txt'
f = open(file, errors='ignore')
# line = f.readline()
for line in f:
    if end_pattern1.match(line) or end_pattern2.match(line) or end_pattern3.match(line):
        ''' 試合結果行の処理 '''
        continue
    elif pattern_start.match(line) != None:
        ''' 試合スタート行の処理 '''
        game_id += 1
    ryukyoku_flag = 0  # 流局フラグ初期化
    while line != '\n':
        '''
        1局分の処理
        '''
        if pattern_kyoku.match(line) != None:
            ''' 局スタート行の処理 '''
            kyoku_id += 1  # whileループを抜けるまで固定される
            tmp = line.strip()  # 行頭のスペース削除
            kyoku_lines.append(tmp)

        elif "流局" in line:
            ''' 流局フラグ '''
            ryukyoku_flag = 1

        elif pattern_tehai.match(line) != None:
            ''' 手牌情報行の処理 '''
            tmp = line.strip()  # 行頭のスペース削除
            tmp = re.sub('\[[1-4][東西南北]\]', '', tmp)
            # tehai_lines.append([kyoku_id, tmp])
            tehai_lines.append(tmp)

        elif pattern_dahai.match(line) != None:
            '''
            打牌情報行の処理
            スペース削除作業をしてリストに追加
            '''
            if ryukyoku_flag == 1:
                '''
                流局フラグが立っていたら、打牌情報から和了者を特定しなくてよい
                '''
                result = 'Ryukyoku'
            else:
                tmp = line.strip()  # 行頭のスペース削除
                tmp = tmp.replace("* ", "")  # 行頭のアスタリスクとスペース削除
                # print(tmp)
                if "A" in tmp:
                    result = tmp[-2]  # while を抜けるまで固定
        line = f.readline()
    winner.append([kyoku_id, result])

これでwinneerに局idと和了結果(流局の場合はRyukyoku,誰かが和了した場合は和了プレイヤーの番号)が格納され, tehai_linesには全ての手牌が格納されています. これらのリストから

| P1の配牌 | P2の配牌 | P3の配牌 | P4の配牌| 和了者 |

というデータフレームを作成します.

""" 和了者処理 """
winner_df = pd.DataFrame(winner, columns=['kyoku_id', 'winner'])
winner_df = winner_df.drop_duplicates('kyoku_id')
winner_df = winner_df.set_index('kyoku_id')
winner_df.columns = ['winner']
""" 手牌処理 """
tehai_df = pd.DataFrame([tehai_lines[i:i+4] for i in range(0, len(tehai_lines), 4)])
tehai_df.index = winner_df.index
tehai_df.columns = ['p1', 'p2', 'p3', 'p4']

result = pd.concat([tehai_df, winner_df], axis=1)

これでresultに手牌と和了者のデータフレームを格納出来ました. 次は,各列に入っている手牌をone-hot-vecrtorに変換し,分類器に学習させるためのデータを作成します. 具体的には(配牌のone-hot-vector, 和了判定)という形式のデータを作成します.

def convert_multi(result_part):
    data_y = []
    data_x = result_part[['p1', 'p2', 'p3', 'p4']].applymap(lambda x: mj.convert_tehai(x))
    for i in result_part['winner']:
        if i=='Ryukyoku':
            y_tmp = list(np.zeros(4))
            data_y.append(y_tmp)
        else:
            y_tmp = mj.one_hot_list(int(i)-4, 4)
            data_y.append(y_tmp)
    x_arr = np.vstack(
        [np.array(
            [list(j["p1"].values), list(j["p2"].values),
             list(j["p3"].values), list(j["p4"].values)]) 
         for i,j in data_x.iterrows()])
    y_arr = np.hstack(data_y)
    return (x_arr, y_arr)

processn = 20
step = int(len(result.index)/processn)
dfs = [result[i: i+step] for i in range(0, len(result.index), step)]
p = Pool(processn)  # make process for multi-processing
df = p.map(convert_multi, dfs)
p.close()
x_arr = np.vstack([i for i,j in df])
y_arr = np.hstack([j for i,j in df])

これで,x_arrに配牌のone-hot-vectorが格納され,y_arrに対応する和了判定が格納されした. ちなみに,上記コードの中にあるprocessnは自分の環境に合った数値に設定してください(並行処理のためのプロセス数です).

それでは,いよいよ分類器を作成します.

"""
split dataset
"""
np.random.seed(10)
sampler = np.random.permutation(len(y_arr))
test_size = 100000
train_index = sampler[2*test_size:]
vali_index = sampler[test_size: 2*test_size]
test_index = sampler[:test_size]
''' training set '''
x_train = x_arr[train_index, :]
y_train = y_arr[train_index]
''' validation set '''
x_vali = x_arr[vali_index, :]
y_vali = y_arr[vali_index]
''' test set '''
x_test = x_arr[test_index, :]
y_test = y_arr[test_index]

"""
Random Forest
"""
rfc = RandomForestClassifier(n_jobs=processn)
rfc.fit(x_train, y_train)
print(rfc.score(x_test, y_test))

結果

2015年の鳳東のデータを使った所,スコアは0.777でした. ハイパーパラメータのチューニングは特に何もしていないので,ちゃんとチューニングすればもう少し精度が良くなると思います. また,分類器を他のものに変えても精度が向上するかもしれません. 今回はRandom Forestを使いましたが,往々にしてGradient Boosting Treeの方が性能が良いことが多いです.

さて,confusion matrixを見てみると次のようになりました.

0 1
0 77323 1192
1 21155 330

やはりFalse negativeが多い(本来は和了できているのに,出来ないと予測しているケースが多い)ですねぇ… データの割合的に仕方ない気もしますが,どうなんでしょう. また,True negativeが多い事も目を引きます.和了れない配牌に対して「和了れない」と正しく判定出来るケースが多く, そういった意味では,和了れないと予測された場合に安牌を抱えておくのは割と悪くないのかなぁ*1とも思います.

*1:このような戦略を取った時の局収支がどうなるかを考えないといけないので,今回の解析結果だけからは何とも言えない