0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Pythonで〇×ゲームのAIを一から作成する その134 評価値を表示するAIの自動選択とゲーム木を利用するAIの評価値の一覧の計算

Last updated at Posted at 2024-11-24

目次と前回の記事

これまでに作成したモジュール

以下のリンクから、これまでに作成したモジュールを見ることができます。

リンク 説明
marubatsu.py Marubatsu、Marubatsu_GUI クラスの定義
ai.py AI に関する関数
test.py テストに関する関数
util.py ユーティリティ関数の定義。現在は gui_play のみ定義されている
tree.py ゲーム木に関する Node、Mbtree クラスの定義
gui.py GUI に関する処理を行う基底クラスとなる GUI クラスの定義

AI の一覧とこれまでに作成したデータファイルについては、下記の記事を参照して下さい。

今回の記事の内容と用語の定義

前回の記事では、Dropdown によって、任意の AI が計算した候補手または評価値のゲーム盤への表示できるようにしました。今回の記事では、この機能と ai_gt6 の改良を行うことにします。

用語の定義

説明を短くするために、以後の記事で用いるいくつかの用語を定義することにします。

AI の評価値の表示

「AI が計算した候補手または評価値のゲーム盤への表示」という表記は長いので、以後は「AI の評価値の表示」と記述することにします。「評価値の表示」としか表記しませんが、評価値を計算しない AI の場合は候補手を表示する点に注意して下さい。

評価値の Dropdown と 手番の Dropdown

現状では、Marubatsu_GUI クラスは上にある 評価値の表示を行う AI を選択 する Dropdown と、下に 2 つある 手番を担当する AI を選択 する Dropdown を作成します。以後は、前者の Dropdown を「評価値の Dropdown」、後者の Dropdwon を「手番の Dropdown」と表記することにします。

バグの修正

今回の話を始める前に、util.py にバグ がある事が判明したので修正します。

具体的には、以前の記事gui_play の下記の 4、5 行目のプログラムを追加して、手番の Dropdown に ai_gtsv の項目を追加 したのですが、その 次の回からの util.py にその内容が反映されていない ことが判明しました。

1  def gui_play(ai=None, params=None, ai_dict=None, seed=None):

2          bestmoves_by_board = load_bestmoves("../data/bestmoves_by_board.dat")
3          ai_dict["ai_gt6"] = (ai_gt6, {"bestmoves_by_board": bestmoves_by_board})
4          bestmoves_by_board_sv = load_bestmoves("../data/bestmoves_by_board_shortest_victory.dat")
5          ai_dict["ai_gtsv"] = (ai_gt6, {"bestmoves_by_board": bestmoves_by_board_sv})

このバグによって、その回以降から前回の記事までで、gui_play() を実行した際に ai_gtsv が項目に登録されない というバグが発生しています。ただし、このバグをさかのぼって修正すると、記事の画像と、実際のプログラムの実行が合わなくなる点と、このバグによって記事の趣旨が大きく変わることはないので、過去の記事をさかのぼってプログラムを修正しないことにし、今回の記事の util.py から修正 することにします。

評価値を表示する AI の自動選択

現状では評価値の Dropdown で選択された AI が計算した評価値が表示されますが、AI どうしの対戦結果リプレイモードで検証 する際には、それぞれの局面で、その 手番を担当する AI が計算した評価値自動的に表示 されると便利です。

そのような機能を追加するための ユーザーインターフェース(UI) について少し考えてみて下さい。

UI の検討

簡単に思いつく方法としては、ON にする ことで 手番を担当する AI が評価値の計算を行う ようになる Button、Checkbox、Togglebutton などのウィジェットを追加 するという方法があるでしょう。ただし、ウィジェットを追加すると、その ウィジェットを表示するための場所が必要 になります。また、ウィジェットを増やすと UI の見た目が複雑 になり、操作方法がわかりづらくなる という問題も発生します。

他の方法として、評価値の Dropdown の 項目に、現在の手番の AI を利用するという意味を表す 「手番の AI」という項目を追加する という方法が考えられます。こちらの方法であれば、新しいウィジェットが増えない ので、ウィジェットを追加する方法でおきる問題は発生しません。一方、Dropdown は、普段は複数ある項目の中から 1 つの項目しか表示しない ので、「手番の AI」という項目の存在が気づかれなくなる 可能性が高いという欠点があります。その点、Button などのウィジェットは、常に表示されている ので、そのような機能が存在することが常にわかるようになります。

上記の 2 種類の UI の どちらを採用するか は、それぞれの 利点と欠点を考慮して検討する 必要があります。本記事 では、後者の新しいウィジェットを配置する必要がなく、場所をとらないという利点を重視して、Dropdown に項目を追加するという方法を採用 することにします。前者の方法がふさわしいと思った方や、他にももっとふさわしい UI を思いついた人はその UI をぜひ実装してみて下さい。

Marubatsu_GUI クラスの create_dropdown メソッドの修正

Dropdown に「手番の AI」という項目を追加する際には、Dropdown を作成する際に記述する 項目のデータを表す dict に「手番の AI」の 項目のデータを設定 する必要があります。どのようなデータを設定すればよいかについて少し考えてみて下さい。

「手番の AI」の項目の値の検討

他の項目では、項目の値 に以下のようなデータを設定しています。

  • (AI の関数, AI のパラメーター) という tuple を設定する
  • ただし、手番を人間が担当する場合は (None, None) という tuple を設定する

「手番の AI」 の場合は、AI の関数を直接設定することはできない ので、人間の項目のように、設定する tuple をどのように設定するかを考える必要があります。

tuple の 0 番の要素 に設定する内容は、他の項目の 0 番の要素の値と区別 できれば、どのような値を設定してもかまいません。そこで本記事では 自動的(auto) に手番の AI を利用するということから、"Auto" という文字列を設定 することにします。他のデータを設定したい人は自由に変更して下さい。

「手番の AI」の場合は、AI のパラメータの情報を直接設定することはできない ので、tuple の 1 番の要素 には、人間が担当する場合と同様に None を設定 することにします。なお、tuple の 1 番の要素に設定するデータは 実際のプログラムで利用することはない のでどのような値を設定してもかまいません。

上記から、「手番の AI」の項目の値 には ("Auto", None) を設定することにします。

間違った項目の設定方法

現状では、評価値の Dropdown手番の Dropdown項目の内容は同じ なので、create_dropdown では、self.ai_dictDropdown の項目を計算 し、それをそのまま使って 下記のプログラムの 5、10 行目のように 両者の Dropdown を作成 しています。

 1  def create_dropdown(self):
ここで self.ai_dict の内容を計算している
 2      for i in range(2):

 3          self.dropdown_list.append(
 4              widgets.Dropdown(
 5                  options=self.ai_dict,

 6              )
 7          ) 
 8              
 9      self.status_dropdown = widgets.Dropdown(
10          options=self.ai_dict,

11      )   

評価値の Dropdown に「手番の AI」という項目を追加するために、下記のプログラムのように、create_dropdown メソッドを修正すればよいと思う人がいるかもしれません。

  • 13 行目:手番の Dropdown を作成した後で、self.ai_dict"手番の AI" というキーの値に ("Auto", None) を追加する
  • 15 行目:上記の self.ai_dict を利用して評価値の Dropdown を作成する。この部分のプログラムには 修正を行っていない
 1  from marubatsu import Marubatsu_GUI
 2  import ipywidgets as widgets
 3  from copy import deepcopy
 4
 5  def create_dropdown(self):
元と同じなので省略
 6      for i in range(2):
元と同じなので省略
 7          self.dropdown_list.append(
 8              widgets.Dropdown(
 9                  options=self.ai_dict,
元と同じなので省略
10              )
11          ) 
12              
13      self.ai_dict["手番の AI"] = ("Auto", None)
14      self.status_dropdown = widgets.Dropdown(
15          options=self.ai_dict,
16          layout=widgets.Layout(width="100px"),
17          style={"description_width": "20px"},
18          value=select_values[0],
19      )   
20   
21  Marubatsu_GUI.create_dropdown = create_dropdown
行番号のないプログラム
from marubatsu import Marubatsu_GUI
import ipywidgets as widgets
from copy import deepcopy

def create_dropdown(self):
    # それぞれの手番の担当を表す Dropdown の項目の値を記録する list を初期化する
    select_values = []
    # 〇 と × の Dropdown を格納する list
    self.dropdown_list = []
    # ai に代入されている内容を ai_dict に追加する
    for i in range(2):
        value = ( self.mb.ai[i], self.params[i] )
        # value を select_values に常に登録する
        select_values.append(value)
        # value が ai_values に登録済かどうかを判定する
        if value not in self.ai_dict.values():
            # 項目を登録する
            self.ai_dict[self.names[i]] = value

    for i in range(2):
        # Dropdown の description を計算する
        description = "" if i == 0 else "×"
        self.dropdown_list.append(
            widgets.Dropdown(
                options=self.ai_dict,
                description=description,
                layout=widgets.Layout(width="100px"),
                style={"description_width": "20px"},
                value=select_values[i],
            )
        ) 
            
    self.ai_dict["手番の AI"] = ("Auto", None)
    self.ai_dict["ai1s"] = None
    self.status_dropdown = widgets.Dropdown(
        options=self.ai_dict,
        layout=widgets.Layout(width="100px"),
        style={"description_width": "20px"},
        value=select_values[0],
    )   
   
Marubatsu_GUI.create_dropdown = create_dropdown
修正箇所
from marubatsu import Marubatsu_GUI
import ipywidgets as widgets
from copy import deepcopy

def create_dropdown(self):
元と同じなので省略
    for i in range(2):
元と同じなので省略
        self.dropdown_list.append(
            widgets.Dropdown(
                options=self.ai_dict,
元と同じなので省略
            )
        ) 
            
+   self.ai_dict["手番の AI"] = ("Auto", None)
    self.status_dropdown = widgets.Dropdown(
        options=self.ai_dict,
        layout=widgets.Layout(width="100px"),
        style={"description_width": "20px"},
        value=select_values[0],
    )   
   
Marubatsu_GUI.create_dropdown = create_dropdown

プログラムにある程度慣れた人は、手番の Dropdown と、評価値の Dropdown が 同じ self.ai_dict を共有する ので、上記のように、self.ai_dict を修正すると、手番の Dropdown にも「手番の AI」という 項目が追加されてしまう と思うかもしれません。

しかし、以前の記事で説明したように、Dropdown は、項目のデータを表す options 属性 の内容を Dropdown の作成後に変更しても項目の内容が変わらない という性質を持つので、上記の修正を行っても、手番の Dropdown の項目は変化しません

そのことは、下記のプログラムを実行することで確認できます。実行結果の左図のように、評価値の Dropdown には最後に 「手番の AI」という項目が登録 されていますが、手番の Dropdown には 登録されていません

from util import gui_play

gui_play()

実行結果

 

問題が発生する状況とその原因

上記のように、意図したとおりに Dropdown が作成されるので、上記のプログラムで問題はないと思う人がいるかもしれませんが、実は 上記の修正には問題があります。それは、ファイルから Dropdown に登録されていない AI が対戦したファイルを読み込む と、手番の Dropdown に「手番の AI」という項目が登録されてしまう という問題です。

下図は、どちらの Dropdown にも登録されていない ai1 VS ai2 の対戦結果をファイル1から読み込んだ場合の手番の Dropdown の図で、「手番の AI」という項目 が、ファイルを読み込んだ際に新しく登録された ai1ai2 の項目の上に 存在しています

これは、ファイルから対戦結果を読み込んだ際に、Dropdown の項目を更新する ために、以下のような処理を行っているからです。なお、下記のプログラムについて忘れた方は、以前の記事を復習して下さい。

  • 6 行目:手番の Dropdown の options 属性をコピーし、options という変数に代入する
  • 7 ~ 10 行目:対戦結果のファイルに Dropdown に登録されていない AI が存在した場合に、options にその AI の項目を登録する
  • 11 ~ 13 行目options を手番の Dropdown の options 属性に代入することで、下の Dropdown の項目を新しい options の内容に更新する

6 行目 でコピーする 手番の Dropdownoptions 属性 は、先程説明したように 評価値の Dropdownoptions 属性と 同じ dict を共有 しているので 「手番の AI」という項目が登録されています。そのため、上記の処理を行うと、手番の Dropdown に「手番の AI」という項目が追加 されてしまうことになります。

 1  def on_load_button_clicked(b=None):
 2      path = filedialog.askopenfilename(filetypes=[("〇×ゲーム", "*.mbsav")],
 3                                      initialdir="save")
 4      if path != "":
 5          with open(path, "rb") as f:

 6              options = self.dropdown_list[0].options.copy()
 7              for i in range(2):
 8                   value = (self.mb.ai[i], self.params[i]) 
 9                   if not value in options.values():
10                       options[names[i]] = value
11              for i in range(2):
12                  self.dropdown_list[i].options = options
13                  self.dropdown_list[i].value = (self.mb.ai[i], self.params[i])            

問題の修正

上記の問題は、評価値の Dropdown と 手番の Dropdown が、今回の記事の修正によって 異なる項目を持つ ようになったにも関わらず、両方の Dropdown の options 属性同じ dict を共有 していることが原因です。

このような場合は、下記のプログラムのように、create_dropdown評価値の Dropdown を作成 する際に、手番の Dropdown の項目を表す options 属性の値をコピー したデータに対して、「手番の AI」という項目を追加 する必要があります。そうすることによって、評価値の Dropdown と 手番の Dropdown の options 属性の値が独立する ことになるので、先程のような問題は発生しなくなります。

  • 2 行目self.ai_dict をコピーしたデータを self.status_ai_dict に代入する
  • 3 行目:コピーした self.status_ai_dict に対して「手番の AI」の項目を追加する
  • 5 行目:コピーした self.status_ai_dict を使って手番のの Dropdown を作成する
 1  def create_dropdown(self):
元と同じなので省略
 2      self.status_ai_dict = self.ai_dict.copy()
 3      self.status_ai_dict["手番の AI"] = ("Auto", None)
 4      self.status_dropdown = widgets.Dropdown(
 5          options=self.status_ai_dict,
 6          layout=widgets.Layout(width="100px"),
 7          style={"description_width": "20px"},
 8          value=select_values[0],
 9      )   
10   
11  Marubatsu_GUI.create_dropdown = create_dropdown
行番号のないプログラム
def create_dropdown(self):
    # それぞれの手番の担当を表す Dropdown の項目の値を記録する list を初期化する
    select_values = []
    # 〇 と × の Dropdown を格納する list
    self.dropdown_list = []
    # ai に代入されている内容を ai_dict に追加する
    for i in range(2):
        value = ( self.mb.ai[i], self.params[i] )
        # value を select_values に常に登録する
        select_values.append(value)
        # value が ai_values に登録済かどうかを判定する
        if value not in self.ai_dict.values():
            # 項目を登録する
            self.ai_dict[self.names[i]] = value

    for i in range(2):
        # Dropdown の description を計算する
        description = "" if i == 0 else "×"
        self.dropdown_list.append(
            widgets.Dropdown(
                options=self.ai_dict,
                description=description,
                layout=widgets.Layout(width="100px"),
                style={"description_width": "20px"},
                value=select_values[i],
            )
        ) 
            
    self.status_ai_dict = self.ai_dict.copy()
    self.status_ai_dict["手番の AI"] = ("Auto", None)
    self.status_dropdown = widgets.Dropdown(
        options=self.status_ai_dict,
        layout=widgets.Layout(width="100px"),
        style={"description_width": "20px"},
        value=select_values[0],
    )   
   
Marubatsu_GUI.create_dropdown = create_dropdown
修正箇所
def create_dropdown(self):
元と同じなので省略
+   self.status_ai_dict = self.ai_dict.copy()
-   self.ai_dict["手番の AI"] = ("Auto", None)
+   self.status_ai_dict["手番の AI"] = ("Auto", None)
    self.status_dropdown = widgets.Dropdown(
-       options=self.ai_dict,
+       options=self.status_ai_dict,
        layout=widgets.Layout(width="100px"),
        style={"description_width": "20px"},
        value=select_values[0],
    )   
   
Marubatsu_GUI.create_dropdown = create_dropdown

一度作成した Dropdown の項目を後から変更しない場合は、上記の修正を行わなくても問題は発生しません。ただし、異なる項目を持つ複数の Dropdown の options 属性が同じ dict を共有しても問題がないというのは、ipywidgets の Dropdown がたまたまそのように設計されているからであり、一般的ではないと思います。

また、最初は Dropdown の項目を後から変更しないと思っていても、後から変更するような修正を行うことがあるかもしれないので、異なる項目を持つ Dropdown を作成する場合は、options 属性に同じ dict を共有すべきではないでしょう。

上記の修正後に、下記のプログラムを実行して ai1 VS ai2 のファイルを読み込むと、実行結果の左図のように、手番の Dropdown に「手番の AI」という項目が登録されなくなります。一方で、右図のように、評価値の Dropdown に ai1ai2 が登録されなくなる という問題が発生します。

gui_play()

実行結果

 

Marubatsu_GUI クラスの create_event_handler の修正

上記の問題は、評価値と手番の Dropdown の options 属性が異なるデータになったことが原因 なので、下記のプログラムのように on_load_button_clicked 内で、評価値の Dropdown の項目を更新する処理 を記述する必要があります。下記の修正で行っている処理は、create_dropdown と同様 に、手番の Dropdown の項目を表す options 属性をコピー し、そのデータに「AI の手番」の項目を追加する というものです。

なお、今回の修正とは別に、13 行目の mb.ai[i] が間違っていた 点に気が付きましたので、self.mb.ai[i] のように修正しました。

  • 17 行目:手番の Dropdown の項目のデータを表す options をコピーして status_options に代入する
  • 18、19 行目status_options に「AI の手番」の項目を追加し、評価値の Dropdown の options 属性に status_options の値を代入して更新する
 1  import math
 2  import pickle
 3  from tkinter import Tk, filedialog
 4
 5  def create_event_handler(self):
元と同じなので省略
 6      # 開く、保存ボタンのイベントハンドラを定義する
 7      def on_load_button_clicked(b=None):
元と同じなので省略
 8          if path != "":
 9              with open(path, "rb") as f:
元と同じなので省略
10                  if "names" in data:
11                      names = data["names"]
12                  else:
13                      names = [ "人間" if self.mb.ai[i] is None else self.mb.ai[i].__name__ for i in range(2)]                       
元と同じなので省略
14                  for i in range(2):
15                      self.dropdown_list[i].options = options
16                      self.dropdown_list[i].value = (self.mb.ai[i], self.params[i])            
17                  status_options = options.copy()
18                  status_options["手番の AI"] = ("Auto", None)
19                  self.status_dropdown.options = status_options
元と同じなので省略
20
21  Marubatsu_GUI.create_event_handler = create_event_handler
行番号のないプログラム
import math
import pickle
from tkinter import Tk, filedialog

def create_event_handler(self):
    # 乱数の種のチェックボックスのイベントハンドラを定義する
    def on_checkbox_changed(changed):
        self.update_widgets_status()
        
    self.checkbox.observe(on_checkbox_changed, names="value")

    # 開く、保存ボタンのイベントハンドラを定義する
    def on_load_button_clicked(b=None):
        path = filedialog.askopenfilename(filetypes=[("〇×ゲーム", "*.mbsav")],
                                        initialdir="save")
        if path != "":
            with open(path, "rb") as f:
                data = pickle.load(f)
                self.mb.records = data["records"]
                self.mb.ai = data["ai"]
                self.params = data["params"] if "params" in data else [ {}, {} ]
                if "names" in data:
                    names = data["names"]
                else:
                    names = [ "人間" if self.mb.ai[i] is None else self.mb.ai[i].__name__ for i in range(2)]                       
                options = self.dropdown_list[0].options.copy()
                for i in range(2):
                    value = (self.mb.ai[i], self.params[i]) 
                    if not value in options.values():
                        options[names[i]] = value
                for i in range(2):
                    self.dropdown_list[i].options = options
                    self.dropdown_list[i].value = (self.mb.ai[i], self.params[i])            
                status_options = options.copy()
                status_options["手番の AI"] = ("Auto", None)
                self.status_dropdown.options = status_options
                change_step(data["move_count"])
                if data["seed"] is not None:                   
                    self.checkbox.value = True
                    self.inttext.value = data["seed"]
                else:
                    self.checkbox.value = False
                    
    def on_save_button_clicked(b=None):
        names = [ self.dropdown_list[i].label for i in range(2) ]     
        timestr = datetime.now().strftime("%Y年%m月%d日 %H時%M分%S秒")
        fname = f"{names[0]} VS {names[1]} {timestr}"
        path = filedialog.asksaveasfilename(filetypes=[("〇×ゲーム", "*.mbsav")],
                                            initialdir="save", initialfile=fname,
                                            defaultextension="mbsav")
        if path != "":
            with open(path, "wb") as f:
                data = {
                    "records": self.mb.records,
                    "move_count": self.mb.move_count,
                    "ai": self.mb.ai,
                    "params": self.params,
                    "names": names,
                    "seed": self.inttext.value if self.checkbox.value else None
                }
                pickle.dump(data, f)
                
    def on_show_tree_button_clicked(b=None):
        self.show_subtree = not self.show_subtree
        self.mbtree_gui.vbox.layout.display = None if self.show_subtree else "none"
        self.update_gui()
        
    def on_reset_tree_button_clicked(b=None):
        self.update_gui()
                
    def on_help_button_clicked(b=None):
        self.help.layout.display = "none" if self.help.layout.display is None else None

    self.load_button.on_click(on_load_button_clicked)
    self.save_button.on_click(on_save_button_clicked)
    self.show_tree_button.on_click(on_show_tree_button_clicked)
    self.reset_tree_button.on_click(on_reset_tree_button_clicked)
    self.help_button.on_click(on_help_button_clicked)
    
    def on_show_status_button_clicked(b=None):
        self.show_status = not self.show_status
        self.update_gui()

    def on_status_dropdown_changed(changed):
        self.update_gui()

    def on_size_slider_changed(changed):
        self.size = changed["new"]
        self.fig.set_figwidth(self.size)
        self.fig.set_figheight(self.size)
        self.update_gui()

    self.show_status_button.on_click(on_show_status_button_clicked)
    self.status_dropdown.observe(on_status_dropdown_changed, names="value")
    self.size_slider.observe(on_size_slider_changed, names="value")

    # 変更ボタンのイベントハンドラを定義する
    def on_change_button_clicked(b):
        for i in range(2):
            self.mb.ai[i], self.params[i] = self.dropdown_list[i].value
        self.mb.play_loop(self, self.params)

    # リセットボタンのイベントハンドラを定義する
    def on_reset_button_clicked(b=None):
        # 乱数の種のチェックボックスが ON の場合に、乱数の種の処理を行う
        if self.checkbox.value:
            random.seed(self.inttext.value)
        self.mb.restart()
        self.output.clear_output()
        on_change_button_clicked(b)

    # 待ったボタンのイベントハンドラを定義する
    def on_undo_button_clicked(b=None):
        if self.mb.move_count >= 2 and self.mb.move_count == len(self.mb.records) - 1:
            self.mb.move_count -= 2
            self.mb.records = self.mb.records[0:self.mb.move_count+1]
            self.mb.change_step(self.mb.move_count)
            self.update_gui()
        
    # イベントハンドラをボタンに結びつける
    self.change_button.on_click(on_change_button_clicked)
    self.reset_button.on_click(on_reset_button_clicked)   
    self.undo_button.on_click(on_undo_button_clicked)   

    # step 手目の局面に移動する
    def change_step(step):
        self.mb.change_step(step)
        # 描画を更新する
        self.update_gui()        

    def on_first_button_clicked(b=None):
        change_step(0)

    def on_prev_button_clicked(b=None):
        change_step(self.mb.move_count - 1)

    def on_next_button_clicked(b=None):
        change_step(self.mb.move_count + 1)
        
    def on_last_button_clicked(b=None):
        change_step(len(self.mb.records) - 1)

    def on_slider_changed(changed):
        if self.mb.move_count != changed["new"]:
            change_step(changed["new"])
        
    self.first_button.on_click(on_first_button_clicked)
    self.prev_button.on_click(on_prev_button_clicked)
    self.next_button.on_click(on_next_button_clicked)
    self.last_button.on_click(on_last_button_clicked)
    self.slider.observe(on_slider_changed, names="value")

    # ゲーム盤の上でマウスを押した場合のイベントハンドラ
    def on_mouse_down(event):
        # Axes の上でマウスを押していた場合のみ処理を行う
        if event.inaxes and self.mb.status == Marubatsu.PLAYING:
            x = math.floor(event.xdata)
            y = math.floor(event.ydata)
            with self.output:
                self.mb.move(x, y)                
            # 次の手番の処理を行うメソッドを呼び出す
                self.mb.play_loop(self, self.params)

    # ゲーム盤を選択した状態でキーを押した場合のイベントハンドラ
    def on_key_press(event):
        keymap = {
            "up": on_first_button_clicked,
            "left": on_prev_button_clicked,
            "right": on_next_button_clicked,
            "down": on_last_button_clicked,
            "0": on_undo_button_clicked,
            "enter": on_reset_button_clicked,            
            "-": on_load_button_clicked,            
            "l": on_load_button_clicked,            
            "+": on_save_button_clicked,            
            "s": on_save_button_clicked,            
            "*": on_help_button_clicked,            
            "h": on_help_button_clicked,            
        }
        if event.key in keymap:
            keymap[event.key]()
        else:
            try:
                num = int(event.key) - 1
                event.inaxes = True
                event.xdata = num % 3
                event.ydata = 2 - (num // 3)
                on_mouse_down(event)
            except:
                pass
            
    # fig の画像イベントハンドラを結び付ける
    self.fig.canvas.mpl_connect("button_press_event", on_mouse_down)     
    self.fig.canvas.mpl_connect("key_press_event", on_key_press)       
    
Marubatsu_GUI.create_event_handler = create_event_handler
修正箇所
import math
import pickle
from tkinter import Tk, filedialog

def create_event_handler(self):
元と同じなので省略
    # 開く、保存ボタンのイベントハンドラを定義する
    def on_load_button_clicked(b=None):
元と同じなので省略
        if path != "":
            with open(path, "rb") as f:
元と同じなので省略
                if "names" in data:
                    names = data["names"]
                else:
-                   names = [ "人間" if mb.ai[i] is None else mb.ai[i].__name__ for i in range(2)]                       
+                   names = [ "人間" if self.mb.ai[i] is None else self.mb.ai[i].__name__ for i in range(2)]                       
元と同じなので省略
                for i in range(2):
                    self.dropdown_list[i].options = options
                    self.dropdown_list[i].value = (self.mb.ai[i], self.params[i])            
+               status_options = options.copy()
+               status_options["手番の AI"] = ("Auto", None)
+               self.status_dropdown.options = status_options
元と同じなので省略

Marubatsu_GUI.create_event_handler = create_event_handler

上記の修正後に、下記のプログラムを実行して ai1 VS ai2 のファイルを読み込むと、実行結果のように、評価値の Dropdown に ai1ai2 が登録される ことが確認できます。

gui_play()

実行結果

Marubatsu_GUI クラスの update_gui メソッドの修正

次に 「手番の AI」が選択されていた場合の処理 を下記のプログラムのように update_gui に記述 する必要があります。手番の AI の情報Marubatsu クラスのインスタンスの ai 属性 に、AI のパラメータの情報Marubatsu_GUI クラスの params 属性 に代入されているので、それらを利用します。

なお、ゲーム盤のマスに評価値の値の表示が収まらな_場合がある ことがわかったので、17 行目で 評価値のフォントサイズ を 5 から 4.5 に 少し小さくしました

  • 7 ~ 10 行目ai の値が "Auto" の場合は、8 行目で現在の手番を表すインデックスを計算し、9、10 行目で手番の AI と パラメーターを計算して aiparams に代入する
 1  from marubatsu import Marubatsu
 2
 3  def update_gui(self):
元と同じなので省略
 4      if self.show_status:
 5          bestmoves = self.score_table[self.mb.board_to_str()]["bestmoves"]
 6          ai, params = self.status_dropdown.value
 7          if ai == "Auto":
 8              index = 0 if self.mb.turn == Marubatsu.CIRCLE else 1
 9              ai = self.mb.ai[index]
10              params = self.params[index]
11          if ai is not None:
12              analyze = ai(self.mb, analyze=True, **params)
13              score_by_move = analyze["score_by_move"]
14              candidate = analyze["candidate"]
元と同じなので省略
15                  if score_by_move is not None:
16                      color = "red" if move in candidate else "black"
17                      ax.text(x + 0.1, y + 0.65, score_by_move[move], fontsize=4.5*self.size, c=color)
元と同じなので省略
18        
19  Marubatsu_GUI.update_gui = update_gui
行番号のないプログラム
from marubatsu import Marubatsu

def update_gui(self):
    def calc_status_txt(score):
        if score > 0:
            return ""
        elif score == 0:
            return ""
        else:
            return "×"
    
    ax = self.ax

    # Axes の内容をクリアして、これまでの描画内容を削除する
    ax.clear()

    # y 軸を反転させる
    ax.invert_yaxis()

    # 枠と目盛りを表示しないようにする
    ax.axis("off")   

    # リプレイ中、ゲームの決着がついていた場合は背景色を変更する
    is_replay =  self.mb.move_count < len(self.mb.records) - 1 
    if self.mb.status == Marubatsu.PLAYING:
        facecolor = "lightcyan" if is_replay else "white"
    else:
        facecolor = "lightyellow"

    ax.figure.set_facecolor(facecolor)
        
    # 上部のメッセージを描画する
    # 対戦カードの文字列を計算する
    ax.text(1.5, 3.5, f"{self.dropdown_list[0].label} VS {self.dropdown_list[1].label}", 
            fontsize=7*self.size, ha="center")   

    # ゲームの決着がついていない場合は、手番を表示する
    if self.mb.status == Marubatsu.PLAYING:
        text = "Turn " + self.mb.turn
        score = self.score_table[self.mb.board_to_str()]["score"]
        if self.show_status:
            text += " 状況 " + calc_status_txt(score)
    # 引き分けの場合
    elif self.mb.status == Marubatsu.DRAW:
        text = "Draw game"
    # 決着がついていれば勝者を表示する
    else:
        text = "Winner " + self.mb.status
    # リプレイ中の場合は "Replay" を表示する
    if is_replay:
        text += " Replay"
    ax.text(1.5, -0.2, text, fontsize=7*self.size, ha="center")

    self.draw_board(ax, self.mb, lw=0.7*self.size)
    
    if self.show_status:
        bestmoves = self.score_table[self.mb.board_to_str()]["bestmoves"]
        ai, params = self.status_dropdown.value
        if ai == "Auto":
            index = 0 if self.mb.turn == Marubatsu.CIRCLE else 1
            ai = self.mb.ai[index]
            params = self.params[index]
        if ai is not None:
            analyze = ai(self.mb, analyze=True, **params)
            score_by_move = analyze["score_by_move"]
            candidate = analyze["candidate"]
        for move in self.mb.calc_legal_moves():
            x, y = move
            mb = deepcopy(self.mb)
            mb.move(x, y)
            score = self.score_table[mb.board_to_str()]["score"]
            color = "red" if move in bestmoves else "black"
            text = calc_status_txt(score)
            ax.text(x + 0.1, y + 0.35, text, fontsize=5*self.size, c=color)
            if ai is not None:
                if score_by_move is not None:
                    color = "red" if move in candidate else "black"
                    ax.text(x + 0.1, y + 0.65, score_by_move[move], fontsize=5*self.size, c=color)
                elif move in candidate:
                    ax.text(x + 0.1, y + 0.65, "候補手", fontsize=4.8*self.size)
                
    self.update_widgets_status()

    if hasattr(self, "mbtree_gui"):
        from tree import Node

        self.mbtree_gui.selectednode = Node(self.mb, depth=self.mb.move_count)
        self.mbtree_gui.update_gui()
        
Marubatsu_GUI.update_gui = update_gui
修正箇所
from marubatsu import Marubatsu

def update_gui(self):
元と同じなので省略
    if self.show_status:
        bestmoves = self.score_table[self.mb.board_to_str()]["bestmoves"]
        ai, params = self.status_dropdown.value
+       if ai == "Auto":
+           index = 0 if self.mb.turn == Marubatsu.CIRCLE else 1
+           ai = self.mb.ai[index]
+           params = self.params[index]
        if ai is not None:
            analyze = ai(self.mb, analyze=True, **params)
            score_by_move = analyze["score_by_move"]
            candidate = analyze["candidate"]
元と同じなので省略
                if score_by_move is not None:
                    color = "red" if move in candidate else "black"
-                   ax.text(x + 0.1, y + 0.65, score_by_move[move], fontsize=5*self.size, c=color)
+                   ax.text(x + 0.1, y + 0.65, score_by_move[move], fontsize=4.5*self.size, c=color)
元と同じなので省略

Marubatsu_GUI.update_gui = update_gui

上記の修正後に、下記のプログラムを実行して下記の処理を行うことで、正しく処理が行われるかどうかを確認します。

  1. ai1s VS ai14s で対戦を行う
  2. 評価値の Dropdown に「手番の AI」を選択し、「状況」ボタンをクリックする
  3. リプレイボタンをクリックしてゲーム開始時の局面と 1 手目の局面を表示する

実行結果の左図のように、ゲーム開始時の局面 では ai1s が計算した評価値 が、右図の 1 手目の局面 では、ai14s が計算した評価値が表示 されることが確認できます。

gui_play()

実行結果

 

ゲーム木を利用した AI の評価値の表示

現状では、ゲーム木を利用 して着手を選択する AI である ai_gt6 は、下図のように 候補手を表示 します。

これは、ai_gt6 の仮引数 bestmoves_by_board には、評価値の情報が記録されていない ためです。しかし、実際には bestmoves_by_board の元となった、ゲーム木の中には評価値が計算済 なので、ゲーム木を利用した AI評価値を表示することが可能 です。

そのために必要となる、ゲーム木の局面と最善手・評価値の対応表 のデータは、bestmoves_and_score_by_board.dat という ファイルに保存済 なので、それを利用して analyze=True を実引数に記述して呼び出した際に、それぞれの合法手を着手した際の評価値を計算 して返す ai_gt7 という AI の関数を定義する事にします。どのように定義すればよいかについて少し考えてみて下さい。

ai_gt7 の定義の方法

ai_gt7 は、ゲーム木の局面と最善手・評価値の対応表のデータを使って、局面の情報から直接最善手の一覧を計算 する処理を行います。そのため、局面の情報から評価値を計算して返す関数に対して利用できる ai_by_score のデコレーターを 利用することはできません

ai_by_candidate のデコレーターは、下記のプログラムの 5 行目のように、評価値の一覧のデータを返すように作られてい ないため、ai_by_candidate利用することもできません

1  def ai_by_candidate(func):

2          if analyze:
3              return {
4                  "candidate": candidate,
5                  "score_by_move": None
6              }

ai_gt7ai_by_candidate 修正する方法

一つの方法として、以下のように ai_gt7 を定義 し、ai_by_candidate を修正する という方法が考えられます。

  • ai_gt7返り値 として、候補手の一覧 と、それぞれの合法手に着手を行った際の局面の 評価値の一覧 を返すように定義する
  • ai_by_candidateai_gt7 の返り値を利用して、評価値の一覧を返すように修正 する

しかし、この方法には大きな欠点があります。それは、上記の方法では ai_gt7必ず評価値の一覧を計算する必要 がある点です。

ai_gt7着手を選択する際 に、それぞれの合法手に着手を行った際の局面の 評価値の情報は必要ありません。それにも関わらず、必ずその情報を計算する ように ai_gt7 を定義してしまうと、ai_gt7 の処理が 必要のない処理を行う分だけ遅くなってしまいます

そのため、本記事ではこの方法は採用しません。

デコレーターを利用しない方法

もう一つの方法は、デコレーターを利用せずai_gt7ラッパー関数によって追加する処理を含めた 処理を行うようにする方法です。この方法であれば、仮引数 analyzeTrue が代入されている場合のみ、評価値の一覧を計算することができるようになります。

下記は、そのように ai_gt7 を定義したプログラムです。

  • 5 行目:仮引数に着手を選択するために必要な mborigbestmoves_and_score_by_board と、ラッパー関数に記述していた debugrandanalyze を全て持つように関数を定義する。局面のデータはコピーする場合があるので、仮引数の名前を mborig とした
  • 6 行目:候補手の一覧を bestmoves_and_score_by_board"bestmoves" 属性から取り出して、candidate に代入する
  • 7 行目:ラッパー関数で行っていた、候補手のデバッグ表示を行う
  • 8 ~ 20 行目analyzeTrue の場合は、それぞれの合法手に着手を行った場合の評価値を計算する
  • 9 行目:合法手の一覧を計算する
  • 10 行目:それぞれの合法手に着手を行った場合の局面の評価値を記録する score_by_move を空の dict で初期化する
  • 11 ~ 15 行目:それぞれの合法手に対する繰り返し処理を行う
  • 12 ~ 14 行目:現在の局面を表す mborig をコピーして mb に代入し、その mb に対して合法手の着手を行う
  • 15 行目mb の局面の評価値を bestmoves_and_score_by_board"score" 属性から取り出して、score_by_move に記録する
  • 16 行目:評価値の一覧のデバッグ表示を行う
  • 17 ~ 20 行目:候補手の一覧と評価値の一覧を表す dict を返す
  • 21 ~ 25 行目analyzeFalse の場合の処理を行う。この部分は ai_by_candidate のラッパー関数で行う処理と全く同じである
 1  from ai import dprint
 2  from copy import deepcopy
 3  from random import choice
 4
 5  def ai_gt7(mborig, debug=False, bestmoves_and_score_by_board=None, rand=True, analyze=False):
 6      candidate = bestmoves_and_score_by_board[mborig.board_to_str()]["bestmoves"]
 7      dprint(debug, "candidate", candidate)
 8      if analyze:
 9          legal_moves = mborig.calc_legal_moves()
10          score_by_move = {}
11          for move in legal_moves:
12              mb = deepcopy(mborig)
13              x, y = move
14              mb.move(x, y)
15              score_by_move[move] = bestmoves_and_score_by_board[mb.board_to_str()]["score"]
16          dprint(debug, "score_by_move", score_by_move) 
17          return {
18              "candidate": candidate, 
19              "score_by_move": score_by_move
20          }
21      else:
22          if rand:
23              return choice(candidate)
24          else:
25              return candidate[0]
行番号のないプログラム
from ai import dprint
from copy import deepcopy
from random import choice

def ai_gt7(mborig, debug=False, bestmoves_and_score_by_board=None, rand=True, analyze=False):
    candidate = bestmoves_and_score_by_board[mborig.board_to_str()]["bestmoves"]
    dprint(debug, "candidate", candidate)
    if analyze:
        legal_moves = mborig.calc_legal_moves()
        score_by_move = {}
        for move in legal_moves:
            mb = deepcopy(mborig)
            x, y = move
            mb.move(x, y)
            score_by_move[move] = bestmoves_and_score_by_board[mb.board_to_str()]["score"]
        dprint(debug, "score_by_move", score_by_move)   
        return {
            "candidate": candidate, 
            "score_by_move": score_by_move
        }
    else:
        if rand:
            return choice(candidate)
        else:
            return candidate[0]

上記の実行後に、下記のプログラムを実行すると、実行結果のように着手が選択されることが確認できます。なお、ゲーム開始時の局面では、すべての合法手が最善手なので、表示される結果にはすべてのマスが表示される可能性があります。

from util import load_bestmoves

mb = Marubatsu()
bestmoves_and_score_by_board = load_bestmoves("../data/bestmoves_and_score_by_board.dat")
print(ai_gt7(mb, bestmoves_and_score_by_board=bestmoves_and_score_by_board))

実行結果(実行結果はランダムなので下記と異なる場合があります)

(0, 0)

また、下記のプログラムのように、実引数に analyze=True を記述して ai_gt7 を呼び出すと、実行結果のように候補手の一覧と評価値の一覧が表示されることが確認できます。

from pprint import pprint

pprint(ai_gt7(mb, bestmoves_and_score_by_board=bestmoves_and_score_by_board, analyze=True))

実行結果

{'candidate': [(0, 0),
               (1, 0),
               (2, 0),
               (0, 1),
               (1, 1),
               (2, 1),
               (0, 2),
               (1, 2),
               (2, 2)],
 'score_by_move': {(0, 0): 0,
                   (0, 1): 0,
                   (0, 2): 0,
                   (1, 0): 0,
                   (1, 1): 0,
                   (1, 2): 0,
                   (2, 0): 0,
                   (2, 1): 0,
                   (2, 2): 0}}

gui_play の修正

次に、下記のプログラムのように gui_playAI の項目に ai_gt7 を利用した AI を登録 するように修正します。

  • 4 行目:bestmoves_and_score_by_board.dat を読み込むように修正する
  • 5 行目:項目の名前を ai_gt7 に修正し、4 行目のデータをパラメータとして利用するように修正する
  • 6 行目:bestmoves_and_score_by_board_shortest_victory.dat を読み込むように修正する
  • 7 行目:6 行目のデータをパラメータとして利用するように修正する。項目の名前は ai_gtsv のまま変更しない
1  import ai as ai_module
2
3  def gui_play(ai=None, params=None, ai_dict=None, seed=None):
元と同じなので省略
4          bestmoves_and_score_by_board = load_bestmoves("../data/bestmoves_and_score_by_board.dat")
5          ai_dict["ai_gt7"] = (ai_gt7, {"bestmoves_and_score_by_board": bestmoves_and_score_by_board})
6          bestmoves_and_score_by_board_sv = load_bestmoves("../data/bestmoves_and_score_by_board_shortest_victory.dat")
7          ai_dict["ai_gtsv"] = (ai_gt7, {"bestmoves_and_score_by_board": bestmoves_and_score_by_board_sv})
元と同じなので省略 
行番号のないプログラム
import ai as ai_module

def gui_play(ai=None, params=None, ai_dict=None, seed=None):
    # ai が None の場合は、人間どうしの対戦を行う
    if ai is None:
        ai = [None, None]
    if params is None:
        params = [{}, {}]
    # ai_dict が None の場合は、ai1s ~ ai14s の Dropdown を作成するためのデータを計算する
    if ai_dict is None:
        ai_dict = { "人間": ( None, {} ) }
        for i in range(1, 15):
            ai_name = f"ai{i}s"  
            ai_dict[ai_name] = (getattr(ai_module, ai_name), {})
        bestmoves_and_score_by_board = load_bestmoves("../data/bestmoves_and_score_by_board.dat")
        ai_dict["ai_gt7"] = (ai_gt7, {"bestmoves_and_score_by_board": bestmoves_and_score_by_board})
        bestmoves_and_score_by_board_sv = load_bestmoves("../data/bestmoves_and_score_by_board_shortest_victory.dat")
        ai_dict["ai_gtsv"] = (ai_gt7, {"bestmoves_and_score_by_board": bestmoves_and_score_by_board_sv})
            
    mb = Marubatsu()
    mb.play(ai=ai, params=params, ai_dict=ai_dict, seed=seed, gui=True)
修正箇所
import ai as ai_module

def gui_play(ai=None, params=None, ai_dict=None, seed=None):
元と同じなので省略
-       bestmoves_by_board = load_bestmoves("../data/bestmoves_by_board.dat")
+       bestmoves_and_score_by_board = load_bestmoves("../data/bestmoves_and_score_by_board.dat")
-       ai_dict["ai_gt6"] = (ai_gt6, {"bestmoves_by_board": bestmoves_by_board})
+       ai_dict["ai_gt7"] = (ai_gt7, {"bestmoves_and_score_by_board": bestmoves_and_score_by_board})
-       bestmoves_by_board_sv = load_bestmoves("../data/bestmoves__by_board_shortest_victory.dat")
+       bestmoves_and_score_by_board_sv = load_bestmoves("../data/bestmoves_and_score_by_board_shortest_victory.dat")
-       ai_dict["ai_gtsv"] = (ai_gt6, {"bestmoves_by_board": bestmoves_by_board_sv})
+       ai_dict["ai_gtsv"] = (ai_gt7, {"bestmoves_and_score_by_board": bestmoves_and_score_by_board_sv})
元と同じなので省略 

上記の修正後に、下記のプログラムを実行して (1, 1) に着手を行った後で、上の Dropdown に ai_gt7 を選択して「状況」ボタンをクリックすると、実行結果の左図のようにゲーム盤のマスに評価値が表示されるようになったことが確認できます。なお、(1, 1) に着手を行ったのは、ゲーム開始時の局面では、ai_gt7ai_gtsv で表示が変化しないからです。

次に ai_gtsv を選択すると、実行結果の右図のように、左図とは異なる評価値が表示されることが確認できます。

gui_play()

実行結果

 

本記事では図示しませんが、興味がある方は ai_gt7ai_gtsv のそれぞれについて、ゲーム盤に表示 される評価値と下の ゲーム木に表示 される 評価値が一致する ことを確認してみて下さい。

今回の記事のまとめ

今回の記事では、評価値を表示する AI に手番の AI が自動的に選択する改良と、ゲーム木を利用した AI が評価値の一覧を計算できるようにする改良を行いました。

本記事で入力したプログラム

リンク 説明
marubatsu.ipynb 本記事で入力して実行した JupyterLab のファイル
marubatsu.py 本記事で更新した marubatsu_new.py
ai.py 本記事で更新した ai_new.py
util.py 本記事で更新した util_new.py

次回の記事

  1. そのファイルは今回の記事の github の save フォルダ内に ai1 VS ai2 2024年09月11日 15時56分34秒.mbsav という名前で保存しておきましたので、興味がある方は試してみて下さい

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?