麻雀AIの昨今

ツモ!ハイテイのみ!してますか?

麻雀のAI利用について、今年からいろいろ調べていました。
手役を推測する麻雀AI作ってみた
あがりの形を推測する麻雀AI作ってみた
打牌を推測する麻雀AI作ってみた

そんなこの秋にビッグなニュースが飛び込んできました。
麻雀シミュレータ用のライブラリ、mjxが登場。Gymのような使い方で強化学習モデルも作れるそうです。
さらに、このようなライブラリを使って作った麻雀AI同士が戦う、AI雀荘が爆誕。響きだけで楽しげですね。
どちらも引き続き開発中とのことで、今後の展開が楽しみです。
私も最強のAIを引き連れて参戦したい!最強の手役といえば七対子です。1トイツあったら七対子を狙っていいらしいので、どんな配牌からも七対子を目指すモデルを作ったら最強になれるはずです。

既にすばらしい解説記事が出ているので、隅々まで参考にさせていただきました。
麻雀の強化学習環境Mjx(v0.1.0)を触る(1/3)ライブラリ機能の確認編

七対子エージェント

自分が七対子の手組みをする時をイメージしてルールベースのモデルを作っていきます。

  • あがりアクション(ツモ、ロン)ができる場合、あがる
  • リーチできる場合、リーチする
  • 鳴き(チー、ポン、大明槓)ができる場合、鳴かない
  • 切り順が回ってきた時、以下の優先順位で処理する
    1. 手牌に2枚の牌があるとき、それを切らない
    2. 手牌に3枚以上の牌があるとき、それを切る(候補が複数ある場合はランダム)
    3. 手牌に1枚の牌があるとき、河に出ている数をカウントして最もたくさん出ている牌を切る(候補が複数ある場合はランダム)

コードはこんな感じです。河の情報は最新バーションのmjxでは少し違う形で見られるようなのですが(参考)、v0.1.0を参考にしています。

class ToitsuAgent(Agent):
    def __init__(self) -> None:
        super().__init__()

    def act(self, observation: Observation) -> Action:
        legal_actions = observation.legal_actions()
        # 選びようがないとき、それをする
        if len(legal_actions) == 1:
            return legal_actions[0]

        # あがれるとき、あがる
        win_actions = [a for a in legal_actions if a.type() in [ActionType.TSUMO, ActionType.RON]]
        if len(win_actions) >= 1:
            assert len(win_actions) == 1
            return win_actions[0]

        # リーチできるとき、リーチする
        riichi_actions = [a for a in legal_actions if a.type() == ActionType.RIICHI]
        if len(riichi_actions) >= 1:
            assert len(riichi_actions) == 1
            return riichi_actions[0]

        # 鳴きができるとき、パスを選択する
        steal_actions = [
            a for a in legal_actions
            if a.type() in [ActionType.CHI, ActionType.PON, ActionType, ActionType.OPEN_KAN]
        ]
        if len(steal_actions) >= 1:
            pass_action = [a for a in legal_actions if a.type() == ActionType.PASS][0]
            return pass_action

        # 切る
        legal_discards = [
            a for a in legal_actions if a.type() in [ActionType.DISCARD, ActionType.TSUMOGIRI]
        ]

        # 手牌に2枚の牌があるとき、それを切らない
        feature_tehai_2 = observation.to_features("han22-v0")[1]
        feature_tehai_3 = observation.to_features("han22-v0")[2]
        feature_tehai_toitsu = [all(i) for i in zip(*[feature_tehai_2, [not i for i in feature_tehai_3]])]
        feature_tehai_toitsu_true = np.flatnonzero(feature_tehai_toitsu)
        space_discards = [i for i in legal_discards if i.tile().id()//4 not in set(feature_tehai_toitsu_true)]
        space_discards_id = [i.tile().id()//4 for i in space_discards]

        # 手牌に3枚以上の牌があるとき、それを切る
        feature_tehai_morethan3_true = np.flatnonzero(feature_tehai_3)
        space_discards_3or4 = [i for i in space_discards if i.tile().id()//4 in set(feature_tehai_morethan3_true)]
        if len(space_discards_3or4) >= 1:
            return random.choice(space_discards_3or4) 

        # 手牌に1枚の牌があるとき、河に出ている数をカウントして最もたくさん出ている牌を切る
        obs_index = [30+i*10+j for i in range(4) for j in range(4)]
        feature_kawa = np.array(observation.to_features("han22-v0")[obs_index], dtype=int)
        feature_kawa = np.sum(feature_kawa, axis=0)
        kawa_maisu_list = feature_kawa[space_discards_id]
        kawa_maisu_max = np.flatnonzero(kawa_maisu_list == max(kawa_maisu_list))
        space_discards_kawa = [j for i, j in enumerate(legal_discards) if i in set(kawa_maisu_max)]
        return random.choice(space_discards_kawa)

これがトイツエージェント「Kosho-kun」だ!
早速このエージェント同士で対戦させてみます。

おっ!ちゃんと七対子っぽい手牌に進んでいます。しかし見たことのないような終局図ですね。

AI雀荘にSubmitする

ShantenAgentの例があるので簡単です(参考)。
あれ?エラーが出て引っかかっているようです。
どれどれ……。
case4:ツモ切りエージェントに負けてる!

ツモ切りエージェントと10戦ほど対戦させてみます。

agent0 = ToitsuAgent()
agent1 = TsumogiriAgent()
agent2 = TsumogiriAgent()
agent3 = TsumogiriAgent()

pl_ag_dict = dict(zip(['player_0', 'player_1', 'player_2', 'player_3'], [agent0, agent1, agent2, agent3]))

rank_counter = Counter()
env = mjx.MjxEnv()

for i in tqdm.tqdm(range(10)):
    obs_dict = env.reset()
    while not env.done():
        actions = {player_id: pl_ag_dict[player_id].act(obs) for player_id, obs in obs_dict.items()}
        obs_dict = env.step(actions)
    r = env.rewards("game_tenhou_7dan")['player_0']
    rank_counter[r] += 1
    env.state()

rank_counter
# Counter({-135: 4, 45: 4, 0: 2})

game_tenhou_7danでは、rにポイントが入るのですが、4着が-135ポイントです。ということは4回も4着を引いている……。
さっきの対戦でも西4局11本場まで進んでいたということは、終局までにあがりが間に合っていないみたいですね。1トイツもない時は無理しない、などのルールを追加すればデビューできるかもしれない……でも七対子が最強だし……。

次は強化学習に挑戦したいと思います。Kosho-kunの今後の活躍にご期待ください!