見出し画像

ChatGPT APIとゲームAIを連携してチャットでプレイングを教える

ChatGPTはかなり賢くて簡単なテキストベースのゲームをプレイできますが、麻雀のような難しいゲームをうまくプレイすることはまだできません。

この前、麻雀をプレイするエージェントと連携させて、ChatGPTに後付けで実況させることでChatGPTのキャラクターがプレイしている風に見せるデモをつくりました。

これに加えたいアイデアとして「ゲーム用のエージェントの戦略をChatGPTに指示コメントを送ることで変更する」ことを試しています。

  • ユーザーはテキストチャットのみでAIキャラクターに学習指示を行う(学習指示用に特別なUIは用意しない)

  • キャラクターとのチャットの雰囲気を壊さないため、学習指示は自然な表現を受け付ける

  • AIキャラクターはユーザーが学習指示を出した盤面と似た盤面に出会うと、以前学習したことを反映したプレイングを行い、会話でも言及する(「教えてくれた通りにこう打とうかな」など)

作成したものはGitHubとColabで公開しているので、コードはここを見てみてください。

ゲーム環境

最終的には麻雀でやりたいのですが、麻雀はハードルが高いので今回はブラックジャックを使います。
ゲーム用のエージェントは、麻雀の場合はNNなどの機械学習モデルを使いますが、ブラックジャックは盤面のパターンが少ないのでルックアップテーブル(盤面とアクションを直接紐づけた辞書)を使います。

ブラックジャックの環境はGymnasiumのToy Textに含まれています。
今回はこれをベースに、ダブルダウンのアクションの追加と、ゲーム終了時にrenderでディーラーの伏せカードを表示するなど少し改造しました。

改造後のコードはColabのnotebook内で直接実装しています。

全体像

図の①~⑥を順に説明します。

①ゲーム用エージェントのアクション選択

ブラックジャックの各盤面に対する最適な戦略は「ブラックジャック ベーシックストラテジー」で検索すると見つけることができます。
これを(ユーザの手札合計、ディーラーのカード、エースを11として使うか)をキー、アクションを値とした辞書として実装します。
麻雀に応用する場合、ここは辞書でなくNNなどの機械学習モデルになります。

今回は0(ステイ)と1(ヒット)のみを実装しておいて、2(ダブルダウン)はチャット経由で教えられる、という設計にしました。

def initialize_strategy():
    strategy_table = defaultdict(lambda:1)

    # soft hand
    for dealer in range(1, 11):
        for hand in [19, 20, 21]:
            strategy_table[(hand, dealer, True)] = 0
    for key in [(18, 2), (18, 7), (18, 8)]:
        strategy_table[key+(True,)] = 0

    # hard hand
    for dealer in range(1, 11):
        for hand in range(17, 22):
            strategy_table[(hand, dealer, False)] = 0
    for dealer in range(2, 7):
        for hand in range(13, 17):
            strategy_table[(hand, dealer, False)] = 0
    for key in [(12, 4), (12, 5), (12, 6)]:
        strategy_table[key+(False,)] = 0
    return strategy_table

strategy_table = initialize_strategy()

➁盤面のテキスト表現

ChatGPTにゲームの状況やエージェントのとったアクションを伝えるために、Envから取得したObservationやRewardと合わせてテキスト形式でプロンプトに埋め込みます。
例えばこの画像の状況だと、

envから以下のように手札の合計、ディーラーのカード、エースを使えるかなどの情報を引き出せます。

observation, info = env.reset(seed=190)
observation, reward, terminated, _, _ = env.step(action)
observation, reward, terminated
# (11, [10, 5, 10], False), 1.0, True

(オリジナルのToy TextのEnvではディーラーの伏せカードはObservationに入らないのですが、今回はrenderで終了時の場の状況を表示するために改造しています)

ここで得た情報をテンプレートに埋め込んでChatGPTのuserロールで送信します。

game_prompt_tmpl =  """assistantの点数:{hand}
エースがある:{has_ace_str}
ディーラーの点数:{dealer}
勝敗:{status_str}{action_str}
{is_trained_state_str}
"" "

# 例
# assistantの点数:21
# エースがある:yes
# ディーラーの点数:6
# 勝敗:ゲーム中
# assistantのとったアクション:ステイ

③④⑤学習指示、アクション抽出、学習実行

学習指示は「今のところはダブルダウンのほうが良かったかもね」など自然な会話として送信し、ChatGPTがそれを認識するようにします。
systemプロンプトの中で、学習指示のような発言を受けたら次の発言でアクション名を出力させることにしました。

system_settings = """
--- (略) --- 
userがゲームの状況を伝えず、assistantのアクションに対しての
助言やアドバイスをするような発言をした直後の応答では、
userの発言からアクションの文字列を抽出して応答に含めること。
例えば以下の例のように、ステイ、ヒット、ダブルダウンのいずれか一つを
応答に絶対に出力すること。
例:「ステイだね、わかった。覚えておくね。ありがとう!😀」
例:「そうだったのか~。だから負けたのかなあ。次からヒットにしなきゃね😥」
例:「ダブルダウンの方がいいのかなあ...次からそうしてみるよ。😒」
--- (略) --- 

で、ChatGPTの発言を受ける処理で「直前のuserの発言が手入力であること(=盤面状況のプロンプトではない)」「assistantの発言にアクション名が含まれること」の条件を満たしたら(直前の盤面、アクション)ペアを学習する処理を走らせています。

for action_idx, keyword in enumerate(["ステイ", "ヒット", "ダブルダウン"]):
    if keyword in response:
        train_action_idx = action_idx

# 直前のuser発言が手入力の場合のみ学習する
user_messages = [m["content"] for m in past_messages if m["role"] == "user"]
if len(user_messages) == 0 or game_prompt_tmpl[:12] in user_messages[-1]:
    return

# 修正対象の盤面に対してactionを実行して、次の盤面で助言することだけを想定し、
# 現在のstatesに入っているobservationの一つ前のobservationを使う
observation = prev_states["observation"]

# 学習処理
hand = observation[0]
dealer = observation[1][0]
usable_ace = observation[2]
key = (hand, dealer, usable_ace)

strategy_table[key] = train_action_idx

学習の部分は麻雀に応用する場合とかなりギャップがあります。
ブラックジャックの学習はテーブルの書き換えで済みますが、麻雀の場合NNを追加学習する必要があります。
1盤面でNNは学習できなそうなので、今指示されている状況の特徴を保ちつつデータオーグメンテーションするなどの工夫が必要そうですが…具体的なアイデアは思いついていません。

⑥学習済み盤面の検索

エージェントの学習は⑤でできているので、これで教えたとおりにプレイする状態になっています。

ただ、ChatGPTには、学習した盤面に遭遇した時にそれを思い出して発言してほしいですよね。
そのため、学習済みの盤面の集合を現在の盤面で検索し、学習した盤面と一致するかどうかをuserプロンプトに含めることにしました。
➁で記述したプロンプトテンプレートに埋め込む際に、学習済み盤面のsetに現在の盤面があるかを完全一致検索し「学習履歴:あり」の文字列を加えています。

麻雀の場合は完全に同じ盤面が来ることはないので、類似盤面を検索するような工夫が必要そうです。

def create_game_prompt(observation, reward, terminated, action, trained_history=[]):
    --- (略) ---
    if (hand, observation[1][0], observation[2]) in trained_history:
        is_trained_state_str = "学習履歴:あり"
    else:
        is_trained_state_str = ""
    return game_prompt_tmpl.format(
        hand=hand,
        has_ace_str=has_ace_str,
        dealer=dealer,
        action_str=action_str,
        status_str=status_str,
        is_trained_state_str=is_trained_state_str
        )

# assistantの点数:21
# エースがある:yes
# ディーラーの点数:6
# 勝敗:ゲーム中
# assistantのとったアクション:ステイ
# 学習履歴:あり

userプロンプトで「学習履歴:あり」を見つけたら反応できるように、systemプロンプトで指定をしました。

system_settings = """
--- (略) --- 
userがこのようなゲームの状況を示した場合は、ゲームの状況とassistantのとったアクションをもとに会話すること。
例:「ここはヒットにしてみるよ😒」
assistantのとったアクションのみを応答に含め、とらなかったアクションを応答に含めてはならない。

学習履歴:あり、がゲーム状況にある場合、今の状況がuserにアクションを教えられた状況と同じであることと、
覚えていたことを活かして、教えられたアクションを取ったことを発言する。
--- (略) --- 

デモ

①~⑥を実装することでこのような動作ができるようになりました。

この記事が気に入ったらサポートをしてみませんか?