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を一から作成する その237 強化学習の概要と用語の説明と gui_play の修正

0
Last updated at Posted at 2026-07-19

目次と前回の記事

Python のバージョンとこれまでに作成したモジュール

本記事のプログラムは Python のバージョン 3.13 で実行しています。また、numpy のバージョンは 2.3.5 です。

リンク 説明
marubatsu.py Marubatsu、Marubatsu_GUI クラスの定義
ai.py AI に関する関数
mbtest.py テストに関する関数
util.py ユーティリティ関数の定義
tree.py ゲーム木に関する Node、Mbtree クラスなどの定義
gui.py GUI に関する処理を行う基底クラスとなる GUI クラスの定義

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

今回の記事の内容

以前の記事では強化学習のことを、「プログラムが周囲の環境を観察することでどのように行動すべきを学習するアルゴリズム」のように説明し、強化学習の具体例として原始モンテカルロ法について説明しました。

前回の記事で検証したように、原始モンテカルロ法には大きな欠点があるためあまり強い AI を作成することができません。より強い AI を作るための強化学習の手法について説明するためには、強化学習の概念の理解と用語の知識が必要となるので、今回の記事ではそれらについて説明します。なお、具体的な強化学習のアルゴリズムについては次回以降の記事で説明します。

また、上記の説明の後で gui_playai_pmc を選択できるように修正します。

機械学習の定義

Weblio辞書(デジタル大辞林)では、学習を以下のように説明しています1

「人間も含めて動物が、生後に経験を通じて知識や環境に適応する態度・行動などを身につけていくこと」

上記の説明では主語が「人間や動物」となっていますが、機械学習の定義はこの主語を「コンピューターのアルゴリズムやプログラム」に置き換えたものとほぼ同じになります。例えば Wikipedia の機械学習の項目では機械学習について下記のように説明しており、上記と下記はいずれも「経験」によって「自己を改善する」という趣旨の説明になっています。

「機械学習とは、経験からの学習により自動で改善するコンピュータアルゴリズムもしくはその研究領域で、人工知能の一種であるとみなされている」

このことからわかるように、機械学習で行うことは、人間が行う学習と類似する点が多くあります。そこで、下記の説明では人間の学習と対比させながら機械学習について説明することにします。

機械学習の分類

人間や動物が行う学習には様々な種類があります。例えば学校の勉強のように、教科書や先生の授業などで他人から与えられた知識を学ぶという方法と、攻略法を誰からも教わらずに自分の力だけでゲームを攻略するという方法は学習の仕方が異なります。他にも、自分で目標を立てて行う自発的な学習と、日常生活の様々な出来事の体験から学ぶような自発的ではない学習も学習の仕方が異なります。

同様に機械学習にもいくつかの種類があり、大きく「教師あり学習」、「教師なし学習」、「強化学習」の 3 種類に分類され、扱う学習のテーマや手法が大きく異なるためそれぞれについて説明します。なお、本記事が取り扱うのは「強化学習」なので、それ以外については簡単な説明にとどめます。興味がある方は各自で調べてみて下さい。

教師あり学習

教師あり学習とは、特定のテーマに対して他者である教師から与えられた「教師データ」と呼ばれるデータを学習することで、そのテーマに関する未知の問題の解答を推論できるようにするという学習です。教師データは現実の世界でいうと練習問題とその解答のようなもので、現実世界ではそのテーマに詳しい教師がそのようなデータを作成して生徒に与えることからこの分類の機械学習を教師あり学習と呼びます。

教師あり学習は学習に用いる教師データを用意する必要があります。逆に言えばそのようなデータを用意できないような場合に教師あり学習を行うことはできません。

なお、教師という用語が用いられていることから、学校での授業のように問題の解き方を教えてもらうという学習を想像する人がいるかもしれませんが、一般的な教師あり学習では練習問題とその解答だけが記載された教師データが与えられるだけで、問題の解き方は与えられません。例えば「長方形の面積の計算」というテーマの場合は、小学校の授業では長方形の面積をどのように計算するかの説明が行われ、その説明をもとに「底辺×高さ」という長方形の面積の公式が教えられます。また、その公式を使えば底辺と高さが与えられればどのような長方形の面積でも 100 % 正しく計算することができます。

一方、教師あり学習で「長方形の面積の計算」を学習する場合は、様々な底辺と高さが記載された長方形の計算問題と、それぞれの問題の答えが記載された教師データだけが与えられます。その際にどのようにこの問題を解くかという説明だけでなく、「長方形」、「底辺」、「高さ」、「面積」とは何かという説明さえ行われないこともあります。教師あり学習では、与えられた教師データから自分の力だけで「底辺と高さ」という問題と「長方形の面積」という解答の間の関係を発見するという学習を行います2

教師あり学習の目的は、教師データを学習する事で、そのテーマに関する(教師データには存在しない)未知の問題をできるだけ正確に解くことができるようにすることです。「長方形の面積」の教師あり学習の場合は学習を行った結果、任意の「底辺」と「高さ」の長方形の面積を高い精度で答えられれば学習が成功したことになります。なお、ここで 100 % ではなく「高い精度で」と表現した理由は、一般的に機械学習による学習は確率的な予測・推論を行うため 100 % の精度で推測を行うことができないことが多いからです。

教師あり学習が扱うテーマとしては、与えられたデータから何らかのデータを予測する「回帰」と、与えられたデータが何の分類に属するかを予測する「分類」などがあります。

上記で挙げた「長方形の面積」の学習の場合は「底辺」と「高さ」から「面積」を推測するので「回帰」に分類されます。他の回帰の例としては以下のようなものがあります。

  • 「天気図と気温などの気象情報」を集めた教師データを学習することで、天気図から明日の気温や降水量を予測する
  • 「気温とアイスクリームの売り上げ」を集めた教師データを学習することで、天気予報から得られた明日の気温データを元に明日のアイスクリームの売り上げを予測する

教師あり学習による分類の具体例として画像の分類が挙げられます。例えば「0 ~ 9 までの数字が書かれた画像」と「その画像に何の数字が書かれているか」という教師データを学習することで、任意の数字が書かれた画像に何の数字が書かれているかを正しく推測できるようにするというものがあります。この「MNIST(エムニスト)」と呼ばれる教師データは機械学習の入門用の教材として公開されており、様々な書籍やウェブページで取り上げられているので興味がある方は調べてみると良いでしょう。他にも画像の中に写っている動物の種類の推測、天気図からの天候の推測、CT画像から病気の有無の推測などが分類の例として挙げられるでしょう。画像だけでなく何の言葉をしゃべっているかを識別するという音声の分類も機械学習による分類の一例です。

参考までに下記に Wikipedia の教師あり学習の項目のリンクを示します。

教師なし学習

教師なし学習は正解が含まれる教師データのような学習のためのデータを用いずに、正解が含まれないデータだけから学習を行うことでそのデータの特性を推測するというものです。

教師なし学習が扱うテーマの一つに与えられたデータをいくつかのグループに分ける「クラスタリング」があります。例えば昆虫採集で得られた昆虫に対して、図鑑などの外からの情報を一切用いることなく、得られた昆虫だけを観察することでその特徴を学習し、例えば「足の本数」、「大きさ」、「色」などの共通点を見つけ出してグループ分けを行うというものです。

参考までに下記に Wikipedia の教師なし学習の項目のリンクを示します。

強化学習

強化学習は一言で言えば「経験から学ぶ」という学習で、おおまかに以下のような手順で学習を行います。教師あり学習や教師なし学習はいずれも「外から与えられたデータ」に対して学習を行いますが、強化学習は下記の手順 2 のように「自分が行動を起こすことで得た経験(データ)」を用いて学習を行う点が異なります。つまり、学習の対象となるデータを他者から受動的に得るか、自分で能動的に得るという違いがあります。

  1. 何らかの目標を立てる
  2. 目標を達成するための行動をとる(行動)
  3. 行動によって起きたことを記録する(経験)
  4. 記録したデータから行動が結果に与えた影響を分析し、目標をよりよく達成する結果になるような行動の改善策を検討する(学習)
  5. 十分な学習が行われるまで手順 2 ~ 4 を繰り返す

上記は「行動を行う」→「経験を得る」→「行動の方針を改善する」を繰り返すことで最適な行動を選択できるようにする学習ととらえることができますが、これは人間が日常的に行っている「試行錯誤」による学習とほぼ同じです。参考までにWeblio辞書(デジタル大辞泉)の「試行錯誤」の説明とリンクを下記に示します。

「種々の方法を繰り返し試みて失敗を重ねながら解決方法を追求すること」

上記の手順では最初に「何らかの目標を立てる」と説明しましたが、人間は目標を立てることなく日常生活の中で起きた出来事に対して常に何らかの学習を行っています。例えば小指を壁の角にぶつけた場合は、その際に得られた痛いという経験から壁の角の付近を歩かないようするという学習が行われます。これは、人間の脳にも「痛い思いを避ける」ことを目標とした強化学習とそっくりな仕組みが本能として備わっていると考えることができます。

なお、コンピューターが行う強化学習はその性質から必ず何らかの目標を設定する必要があります。

人間が行う強化学習に相当する学習と、コンピューターが行う強化学習をいくつか例にあげて比較しながら説明します。

最短経路の学習

例えば、目標を「A 地点から B 地点までの徒歩での最短経路を見つける」と設定した場合は以下のような学習を行います。なお、ここでいう最短経路は距離ではなく、所要時間が最も短い経路3を表すことにします。また、事前にその地域の情報を知らされておらず、地図やルート検索アプリなど、他の助けを借りず自分の力だけで学習を行うものとします4。事前知識がないものとするという条件はこの後で紹介する他の例でも同様です。

  1. 目標を「A 地点から B 地点までの徒歩での最短経路を見つける」とする
  2. A 地点から B 地点まで実際に歩く(行動)
  3. 歩いたルートの情報と所要時間を記録する(経験)
  4. 歩いたルートと所要時間を検証し、よりよいルートがないかを検討する(学習)
  5. 最短だと思われる経路が見つかるまで 2 ~ 4 の手順を繰り返す

以後は上記の例を「最短経路の学習」と表記することにします。

上記の手順で学習を行うと、最初は知らない地域なので所要時間の長いルートを歩くことになる可能性が高くなりますが、何度も上記の手順 2 ~ 4 を行うことでどの道を通れば効率が良いかが学習されていくため所要時間が次第に短くなっていくはずです。ただし、本当の最短経路はすべてのルートを試さなければ見つけることはできませんが、経路の数が非常に多い場合すべてのルートをしらみつぶしに試すのは現実的に難しいため、手順 2 ~ 4 を繰り返しても本当の最短経路を見つけることができるとは限りません。このように、強化学習は運が良ければ最善である最適解を得るように学習を行うことができますが、多くの場合は最適解に近い近似解が得られるという手法です。

なお、同じ場所であっても目標の設定を変えると学習の結果が異なることになります。例えば目標を「最短距離」とした場合は異なる学習が行われるでしょう。

高得点の学習

目標を「点数のあるアクションゲームで高得点を取る」と設定した場合は以下のような学習を行います。以後はこの例を「高得点の学習」と表記することにします。

  1. 目標を「ゲームで高得点を取る」とする
  2. ゲームオーバーになるまでゲームを遊ぶ(行動)
  3. ゲームを遊ぶ様子を録画する(経験)
  4. ゲームの録画を検証し、より高得点につながる行動がないかを検討する(学習)
  5. 得られた点数に満足するまで 2 ~ 4 の手順を繰り返す

上記の手順で学習を行うことで、敵を倒す、アイテムを拾うといった行動が高得点に繋がるパターンとして蓄積された結果、ゲームで高得点が得られる行動が学習されていき、次第に高得点が得られるようになります。

同じゲームであっても目標の設定を変えると学習の結果が異なることになります。例えば上記のゲームに対して目標を「できるだけ長時間ゲームを遊ぶ」とした場合は、高得点は取れないがゲームオーバーになるまでの時間が長くなるような学習が行われるでしょう。

〇× ゲームの学習

目標を「〇× ゲームで良い結果を得る」と設定した場合は以下のような学習を行います。この学習を人間ではなくコンピューターのプログラムが行う場合は強化学習そのものになります。対局を行うことができれば経験が得られるので、対局の対戦相手は人間 と AI のどちらでもかまいません。以後はこの例を「〇× ゲームの学習」と表記することにします。

  1. 目標を「〇× ゲームで良い結果を得る」とする
  2. 対局を 1 回行う(行動)
  3. 棋譜(行った着手の一覧)と結果を記録する(経験)
  4. 棋譜と結果を検証し、よりよい結果につながる行動がないかを検討する(学習)
  5. 得られた結果に満足するまで 2 ~ 4 の手順を繰り返す

相手と対戦を行うゲームを学習する場合は、対戦相手の強さによって学習結果が変わることになります。例えばランダムな着手を行う ai2s と対戦した場合に得られる経験と、弱解決の AI である ai14s と対戦した場合に得られる経験は大きく異なります。一般的にはより強い対戦相手と対局を行うほうがより良い経験が得られるため良い学習結果につながります。これは、現実の世界で自分よりもはるかに弱い相手と練習試合を行っても良い経験が得られないためほとんど強くなれないことによく似ています。

現実世界では強すぎる相手と対戦を行っても相手が行う行動の意味がまったくわからないため良い経験が得られないということが良くあります。そのため、実際には実力が均衡しているか、自分よりも少し実力のある相手との対戦が良い経験と学習につながる場合が多いでしょう。

強化学習の一種である原始モンテカルロ法はランダムな着手を行い続けた場合の対戦結果を経験として学習を行うため、ai2s と対戦した場合の経験を元に学習を行います。従って ai2s に対しては最も良い結果が得られるような学習が行われますが、ai14s のような ai2s よりもはるかに強い相手と対戦を行うと良い結果が得られないことになります。

強化学習で用いられる用語の説明

Wikipedia では強化学習について下記のような説明が行われています。

「強化学習は、ある環境内における知的エージェントが、現在の状態を観測し、得られる収益(累積報酬)を最大化するために、どのような行動をとるべきかを決定する機械学習の一分野である」

上記の説明では「環境」、「エージェント」、「状態」、「観測」、「収益」、「累積報酬」のような専門用語が用いられており、これらの用語を初めてみた方は意味がわからないのではないかと思いますが、強化学習を正しく理解するためにはそれらの専門用語を理解することが重要です。そこで、先ほど挙げた3つの具体例(最短経路、高得点、〇×ゲーム)をベースに引き合いに出しながら、それぞれの専門用語が何を意味しているのかを 1 つずつ分かりやすく解説していきます。

エージェントと環境

エージェント(agent)は強化学習において学習を行うプログラムのことを表し、環境(environment)はエージェントが様々な行動の試行錯誤を行い経験を得る対象のことを表します。人間の学習に置き換えると、エージェントは学習を行う人間そのものを、環境は人間が学習を行う現実世界の対象5に相当します。下記は先ほどの例の環境を表す表です。

環境
最短経路の学習 A 地点から B 地点までをつなぐすべての経路
高得点の学習 学習の対象となるコンピューターゲーム
〇× ゲームの学習 〇× ゲームのゲーム盤と対戦相手

エージェントは先ほど紹介した「行動 → 経験 → 学習」のサイクルを自動で繰り返す処理を行うコンピューターのプログラムです。

コンピュータープログラムであるエージェントが現実世界そのものに対して直接行動を起こすことは困難なことが多いので、強化学習における環境もコンピューターのプログラムによって表現されます。例えば「最短経路の学習」を強化学習で行う場合の環境は以下のような機能を持つプログラムで、現実世界の環境をシミュレート(模倣して再現)したプログラムであると考えることができます。

  • A 地点から B 地点までのすべての経路がどのようになっているかの情報を管理する
  • エージェントが現在どこにいるかを管理し、その位置をエージェントに報告する
  • エージェントの行動に従って移動時間を計算してエージェントに報告する

「高得点の学習」の場合は対象がコンピューターのゲームなので環境はそのコンピューターゲームのプログラムそのものです。

「〇× ゲームの学習」を強化学習で行う場合の環境は 〇× ゲームを管理するプログラムと対戦相手の AI のプログラムで、具体的には本記事で実装した Marubatsu クラスと ai2sai14s などの AI のプログラムを環境とみなすことができます。

機械学習ではエージェントは環境に対してどのような行動を行うかを申告し、環境はその行動の結果をエージェントに報告するという、エージェントと環境の相互作用の繰り返しが行われます。一般的には環境側のプログラムにエージェントが行う行動を処理してその結果を返り値で報告するという関数(メソッド)や、結果を記録した変数(属性)を用意します。

「〇× ゲームの学習」の例の場合は、エージェントのプログラムが選択した着手という行動を Marubatsu クラスの move メソッドを呼び出すことで環境に報告してその結果を計算します。その結果、ゲーム盤を表す board 属性とゲームの状態を表す status 属性の値が更新されるのでその値をエージェントが参照することで行動の結果を経験として取得します。

環境をプログラムでシミュレートすることの利点の一つに、現実世界で何度も行えないことを繰り返して行えるというものがあります。例えばビルの爆破を何度も行うことは困難ですが、プログラムでのシミュレーションであれば何度でもビルの爆破を再現することができます。

他にも車の運転や手術の練習など、失敗した場合に交通事故などで人が死んでしまうような取り返しのつかないことであってもシミュレーションであれば安心して何度も練習することができることや、現実世界では長時間がかかることをシミュレーションでは短時間で行えることなどがあります。

行動、エピソード、ステップ

行動(action)とはエージェントが環境に対して行うふるまいのことを表します。これまでの説明ではエージェントの行動を「目標を達成するための行動をとる」のように一まとめにして表記しましたが、多くの場合ではその行動は複数の小さなふるまいの集まりで構成されます。強化学習ではそのような小さな個々のふるまいのことを「行動」と呼びます。

例えば「最短経路の学習」の場合は「交差点ごとに道を選ぶ」という行動を B 地点に到着するまで繰り返します。「〇×ゲームの学習」の場合はゲームの決着が付くまで「合法手を選択する」という行動を繰り返します。

行動の単位

行動はいくらでも小さく分割できる場合があります。例えば、「1 m 東に歩く」という行動は、100 個の「1 cm 東に歩く」という行動に分割することができます。また、歩く距離を短くすることで「1 m 東に歩く」という行動をいくらでも多くの小さな行動に分割することができてしまいます。

行動を小さく分割することによって、行った行動を厳密に記録することができるという利点が得られますが、その分だけ記録するデータの量が増えるという欠点が生じます。記録するデータの量が増えるとデータの分析が複雑になって時間がかかるという欠点が生じるため、「それ以上分割しても分析結果に大きな影響を及ぼすことがない」、「分析に時間がかかりすぎない」などの条件を満たす大きさで行動を分割するのが一般的です。

例えば、「最短経路の学習」の場合は「A 地点から B 地点まで歩く」という行動を、「途中で何を見たか」、「疲れたので歩く速度を変えた」、「店に入ってトイレを借りた」などの小さな行動に分割して記録することもできますが、それらの行動を記録しても最短経路の分析に影響を及ぼすことはほとんどないでしょう。分析に必要な情報が「交差点でどの道を選んだか」と「交差点から交差点までの所要時間」であることを考えると、行動を「交差点で次の道を選択する」に限定することが合理的と言えます。

「高得点の学習」の場合は、ゲームのコントローラーで行った操作を行動として記録すればよいと思うかもしれませんがそうではありません。例えばアクションゲームでは敵が近づいてくるのを待つ場合のように、何も行動しないことが高得点につながる場合があります。そのため、「高得点の学習」の場合は「コントローラの操作を行わない」も行動の一つになります。また、アクションゲームでは一般的に 1 フレーム(= 1/60 秒)ごとにコントローラーの操作を受け付けるものが多いので、「高得点の学習」の場合の行動は「フレームごとのゲーム機のコントローラの操作」となります。

「〇× ゲームの学習」の場合の行動は自分の手番での合法手の選択になります。

このように、強化学習では適切な学習を行うために記録する必要がある「行動」とは何であり、どのタイミングで行うかについて定義を行う必要があります。

下記はそれぞれの例での行動とそのタイミングをまとめた表です。

行動 タイミング
最短経路の学習 交差点での道の選択 交差点に到着した時
高得点の学習 ゲーム機のコントローラーの操作 フレーム(1/60秒)ごと
〇× ゲームの学習 合法手の中の一つを選択する 自分の手番になった時

エピソードとステップ

強化学習で目的を達成するために行った一連の行動の集まりのことをエピソード(episode)と呼びます。「最短経路の学習」の場合は「A 地点から B 地点にたどり着くまでの行動」、「高得点の学習」の場合は「ゲームを開始してからゲームオーバーになったり、制限時間が来た場合などでゲームを終了するまでの行動」、「〇× ゲーム」の場合は「ゲームの開始時から終了時までに行った行動(着手)」がエピソードとなります。このように、エピソードは学習を行う際に設定した「始めから終わりまでの一区切り」で行われた行動を表します。

エピソードの中で行われた一つ一つの行動のことをステップ(step)と呼びます。

上記の例からわかるように、ステップとステップの間の時間は一定であるとは限りません。例えば「最短経路の学習」の場合は次のステップまでの時間は交差点から交差点までの所要時間になるので一定にはなりません。「〇× ゲームの学習」の場合は自分と相手が手番で考えた時間の合計が次のステップまでの時間になるので一定にはなりません。一方「高得点の学習」の場合は次のステップまでの時間は常に 1 フレームなので一定になります。

状態、観測、報酬

エージェントが行動を選択するためには、環境内でエージェントが現在置かれている状況を知る必要があり、強化学習では環境の状況のことを「状態(state)」と呼びます。下記は先ほどのそれぞれの例での状態を表す表です。

状態
最短経路の学習 A 地点から B 地点の間の現在地(どの交差点にいるか)
高得点の学習 ゲーム機から出力される画面、音、コントローラーの振動など
〇× ゲームの学習 手番、ゲーム盤、ゲームの状況(プレイ中または結果)

エージェントは関数呼び出しや変数の値の参照などによって、環境から現在の状態の情報を取得し、その情報を元に次に行う行動を選択します。エージェントが環境から状態の情報を得ることは人間が環境を観察することに相当し、強化学習でもそのことを観測(observation)と呼びます。

一般的に状態はエージェントの行動の結果によって変化します。例えば「最短経路の学習」の場合は行動によってエージェントの位置が次の交差点に移動するため状態が変化します。「〇× ゲームの学習」の場合は行動によって着手が行われるため局面や手番の状態が変化します。従って、エージェントが環境に対して行動を行うための関数呼び出しを行うと、その返り値として変化した状態の情報が得られるように実装されるのが一般的です6

報酬(reward)はエージェントの行動の結果、目標に対してどれほどの成果が得られたかを表すデータで、エージェントが環境に対して行動を行うための関数呼び出しの返り値の一部として、変化した状態と共に得られるように実装されるのが一般的です。また、一般的に報酬は数値で表現され、数値が大きい程得られた成果が高いことを表します。

下記はエージェントと環境が相互作用を行うことで行動、状態、報酬をやり取りする様子を表す図です。強化学習ではエピソードの各ステップでこの相互作用が 1 回ずつ行われます。

即時報酬、累積報酬、収益

報酬には各ステップの行動に対して直接得られる即時報酬と、エピソードの全ステップで得られた報酬の合計を表す累積報酬があります。

「最短経路の学習」の場合は次の交差点にたどり着くまでの所要時間に -1 を乗算して負の値にしたものが各ステップでの即時報酬となります。負の値にする理由は、先程説明したように強化学習では報酬が大きい程得られた成果が高いことを表すため、所要時間が短いほど即時報酬の値が高くなるようにするためです。

「高得点の学習」の場合は各ステップで行った行動によって得られた点数が即時報酬となります。「〇× ゲームの学習」の場合は行動によってゲームの決着が付いていない場合の報酬は 0 となり、決着が付いている場合は勝敗結果に応じた値の報酬が得られます。

累積報酬は各ステップで得られた報酬の合計を表し、「最短経路の学習」の場合は所要時間の合計に -1 を乗算した値、「高得点の学習」の場合は得られた得点、「〇× ゲームの学習」の場合は勝敗結果を表す値になります。

先程紹介した下記の Wikipedia の強化学習の説明にあるように、強化学習の目的は「累積報酬が最も高くなるような行動をとることができる」ような学習を行うことです。また、下記の説明で「収益(累積報酬)」と記載されているように、累積報酬を即時報酬と明確に区別したい場合は累積報酬のことを収益(return)と呼びます。

「強化学習は、ある環境内における知的エージェントが、現在の状態を観測し、得られる収益(累積報酬)を最大化するために、どのような行動をとるべきかを決定する機械学習の一分野である」

上記では収益と累積報酬が同じものであるとして説明を行いましたが、実際の強化学習では将来得られる報酬を割り引いた累積割引報酬(discount return)を収益として学習を行います。累積割引報酬については次回以降の記事で紹介する予定です。

強化学習の目的が即時報酬を最も高くなるような行動を学習することではない理由について疑問に思った方がいるかもしれませんのでその理由を説明します。

即時報酬はエピソードの中で行われた一つの行動によって得られる報酬の事を表しますが、一つの行動で得られた報酬が高くなったとしても、全体で得られる累積報酬が高くなるとは限らないからです。具体例としてマラソンの例を挙げます。

マラソンでスタート地点でいきなり全力で走ることで、最初の数分は最も良いタイムを得ることができるようになります。これを強化学習の用語で表現すると、最初のステップで全速力で走ることで最も高い即時報酬を得ることができることに相当します。しかし、そのような走り方を行うとすぐに息切れをして疲れてしまい、ほぼ確実にゴールした時のタイムは普通に自分のペースを守って走った場合と比べて非常に悪いものになるでしょう。これを強化学習の用語で表現すると、累積報酬が低くなることに相当します。このことから、特定のステップの即時報酬の最大化を行うことが全体の累積報酬の最大化につながるとは限らないことがわかるのではないかと思います。

学習を行うタイミング

上記の性質から、多くの強化学習ではエピソードの各ステップによって得られた経験(状態と即時報酬)を元に学習を行わず、エピソードの終了後にそのエピソードで得られた経験(各ステップの状態と即時報酬)を元に累積報酬が高くなるような行動を学習します。

また、学習の効率を高めるために、一つ一つのエピソードでの経験に対して学習を行うのではなく、複数のエピソードで得られた経験をまとめて学習することもあります。

強化学習の種類によってはエピソードの終了を待たずに、ステップごとに学習を行うものもあります。必要があれば今後の記事で紹介したいと思います。

環境によってはいつまでも行動を行い続けることができる場合があります。例えば時間制限のないアクションゲームで、特定の場所にキャラクターを移動させると敵とぶつかることがないため永遠にゲームが終わらない場合が相当します。

強化学習では基本的にエピソードで得られた経験ごとに学習を行うので、そのような場合は何らかの方法でエピソードを区切るという工夫を行う必要があります。

方策

強化学習ではエージェントが現在の環境の状態で行動を選択する際に指針とする「どのような行動をとるべきか」の方針のことを方策(policy)と呼びます。

一般的に方策は、学習の対象となる環境の「各状態」で取れる「各行動を選択する確率」として表現します。

例えば「最短経路の学習」の場合で道が 3 つに分かれている交差点 X いう状態での方策は、それぞれの道を選択する確率である 0.3、0.5、0.2 のように表現します。

方策は環境がとりうる状態ごと別々に記録する必要があり、例えば道が 5 つに分かれている交差点 Y という状態での方策は上記と異なる 0.3、0.1、0.2、0.3、0.1 のようになります。

なお、方策は確率によって表されるので、各行動の確率の範囲は 0 ~ 1 で、その合計は必ず 1 になります。

原始モンテカルロ法の方策は、すべての局面ですべての合法手を等確率で選択するというものになります。例えば合法手が 2 つの局面での方策を表す確率は 0.5、0.5 になります。

方策は確率で表されるため、エージェントが方策に従って行動を行った結果得られる累積報酬は同じ方策で行動を選択した場合でも確率によって変動した値になります。このことから累積報酬は方策を確率変数とする確率分布とみなすことができ、累積報酬には期待値が存在します。そのため、強化学習の目的は累積報酬の期待値が最も高くなるような方策を求めることであると言い換えることができます。

具体的な方法は次回以降の記事で説明することにし、今回の記事ではそのような方策を求める方法の概要について説明します。

例えば「最短経路の学習」を開始した時点ですべての交差点(状態)での方策として、それぞれの道を選択する確率を均等な確率として設定しておくと、エージェントは最初の頃はすべての交差点で等確率にランダムな道を選択するという行動をとります。

その結果、学習を進めていくことで様々なルートで A 地点から B 地点までの移動を経験することになり、経験から次第にそれぞれの交差点でどの道を選択すれば効率が良いかがわかってくるようになります。具体的にはそれぞれの交差点での方策として最短経路を選択する確率を高くするという学習が行われます。

それぞれの交差点の方策での最短経路を選択する確率が十分に高くなるまで学習を行うことで、最適な行動を取るエージェントが完成したことになります。

強化学習の定義の言い換え

上記の説明から、下記の強化学習の定義、

「強化学習は、ある環境内における知的エージェントが、現在の状態を観測し、得られる収益(累積報酬)を最大化するために、どのような行動をとるべきかを決定する機械学習の一分野である」

という文章は、下記のように言い換えることができます。

強化学習は、エージェントと環境が「方策に従う行動」と「その結果変化した状態と得られた報酬の報告」という相互作用を繰り返して得られた経験を元に方策を改善することで、累積報酬の期待値を最大化するような方策を学習するという仕組みである。

以上で基本的な強化学習の用語の説明は終了します。他にも重要な用語はありますが、それらについては必要に応じて今後の記事で説明することにします。

gui_play の修正

本当は前回の記事で行おうと思っていたのですが、記事が長くなったため今回の記事で gui_play で原始モンテカルロ法で最善手を計算する ai_pmc を選択できるようにプログラムを修正することにします。

gui_play への ai_pmc の追加

原始モンテカルロ法はプレイアウトの回数によって計算するプレイアウトの結果の確率の精度が異なるので、gui_play で選択できる AI の項目に下記の表の回数のプレイアウトを行う ai_pmc の AI を追加することにします。それぞれの場合の ai_pmcai2sai14s との対戦成績については以前の記事を参照して下さい。

項目名 プレイアウトの回数
ai_pmc(5) 5
ai_pmc(10) 10
ai_pmc(100) 100
ai_pmc(1000) 1000
ai_pmc(10000) 10000

下記はそのように gui_play を修正したプログラムで、8、9 行目に Dropdown に上記の AI を選択する項目を追加しました。

 1  from marubatsu import Marubatsu
 2  import ai as ai_module
 3  from ai import ai_gt7, ai_pmc
 4  from util import load_bestmoves
 5  
 6  def gui_play(ai=None, params=None, ai_dict=None, mbparams={}, seed=None):
元と同じなので省略
 7          ai_dict["ai_gtsvrd"] = (ai_gt7, {"bestmoves_and_score_by_board": bestmoves_and_score_by_board_svrd})
 8          for pnum in [5, 10, 100, 1000, 10000]:
 9              ai_dict[f"ai_pmc({pnum})"] = (ai_pmc, {"pnum": pnum})
10  
11      mb = Marubatsu(**mbparams)
12      mb.play(ai=ai, params=params, ai_dict=ai_dict, seed=seed, gui=True)
行番号のないプログラム
from marubatsu import Marubatsu
import ai as ai_module
from ai import ai_gt7, ai_pmc
from util import load_bestmoves

def gui_play(ai=None, params=None, ai_dict=None, mbparams={}, 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})
        bestmoves_and_score_by_board_svrd = load_bestmoves("../data/bestmoves_and_score_by_board_sv_rd.dat")
        ai_dict["ai_gtsvrd"] = (ai_gt7, {"bestmoves_and_score_by_board": bestmoves_and_score_by_board_svrd})
        for pnum in [5, 10, 100, 1000, 10000]:
            ai_dict[f"ai_pmc({pnum})"] = (ai_pmc, {"pnum": pnum})

    mb = Marubatsu(**mbparams)
    mb.play(ai=ai, params=params, ai_dict=ai_dict, seed=seed, gui=True)
修正箇所
from marubatsu import Marubatsu
import ai as ai_module
from ai import ai_gt7, ai_pmc
from util import load_bestmoves

def gui_play(ai=None, params=None, ai_dict=None, mbparams={}, seed=None):
元と同じなので省略
        ai_dict["ai_gtsvrd"] = (ai_gt7, {"bestmoves_and_score_by_board": bestmoves_and_score_by_board_svrd})
+       for pnum in [5, 10, 100, 1000, 10000]:
+           ai_dict[f"ai_pmc({pnum})"] = (ai_pmc, {"pnum": pnum})

    mb = Marubatsu(**mbparams)
    mb.play(ai=ai, params=params, ai_dict=ai_dict, seed=seed, gui=True)

上記の修正後に下記のプログラムで gui_play を実行し、AI に ai_pmc(5)ai_pmc(10000) を選択して対戦させたところ、実行結果のように ai_pmc(10000) が勝利するという結果になりました。

gui_play()

実行結果(ゲーム盤の下のゲーム木の部分は省略しました)

プレイアウトの回数が多い ai_pmc(10000) のほうが勝利することは問題はないのですが、上記の実行結果の図には下記の問題があることがわかりましたので、それらの問題を修正することにします。

  • Dropdown の横幅が狭いため、選択した AI の名前が一部の ai_pmc しか表示されない
  • ゲーム盤の下部に表示される AI の名前がはみ出て一部しか表示されない

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

gui_play で AI を選択する Dropdown の作成処理は Marubatsu_GUI クラスの create_dropdown メソッドで行っているので、下記のプログラムの 12、21 行目のようにその幅を 100 px から 150 px 修正することにします。

 1  from marubatsu import Marubatsu_GUI
 2  import ipywidgets as widgets
 3  
 4  def create_dropdown(self):
元と同じなので省略
 5      for i in range(2):
 6          # Dropdown の description を計算する
 7          description = "" if i == 0 else "×"
 8          self.dropdown_list.append(
 9              widgets.Dropdown(
10                  options=self.ai_dict,
11                  description=description,
12                  layout=widgets.Layout(width="150px"),
13                  style={"description_width": "20px"},
14                  value=select_values[i],
15              )
16          ) 
17      self.status_ai_dict = self.ai_dict.copy()
18      self.status_ai_dict["手番の AI"] = ("Auto", None)
19      self.status_dropdown = widgets.Dropdown(
20          options=self.status_ai_dict,
21          layout=widgets.Layout(width="150px"),
22          style={"description_width": "20px"},
23          value=select_values[0],
24      )   
25  
26  Marubatsu_GUI.create_dropdown = create_dropdown
行番号のないプログラム
from marubatsu import Marubatsu_GUI
import ipywidgets as widgets

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="150px"),
                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="150px"),
        style={"description_width": "20px"},
        value=select_values[0],
    )   
    
Marubatsu_GUI.create_dropdown = create_dropdown
修正箇所
from marubatsu import Marubatsu_GUI
import ipywidgets as widgets

def create_dropdown(self):
元と同じなので省略
    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"),
+               layout=widgets.Layout(width="150px"),
                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"),
+       layout=widgets.Layout(width="150px"),
        style={"description_width": "20px"},
        value=select_values[0],
    )           
    
Marubatsu_GUI.create_dropdown = create_dropdown

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

ゲーム盤の下部の対戦カードの描画処理は Marubatsu_GUI クラスの update_gui メソッドで行っているので、下記のプログラムの 6 行目のようにフォントサイズを 7 * self.size から 4 * self.size に修正しました。この修正によって文字が小さすぎるようになったと思った方は表示位置を工夫するなどの方法で自由に修正して下さい。

1  from copy import deepcopy
2
3  def update_gui(self):
元と同じなので省略
4      # 対戦カードの文字列を計算する
5      ax.text(1.5, 3.5, f"{self.dropdown_list[0].label} VS {self.dropdown_list[1].label}", 
6              fontsize=4*self.size, ha="center")   
元と同じなので省略
7  
8  Marubatsu_GUI.update_gui = update_gui
行番号のないプログラム
from copy import deepcopy

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 == self.mb.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=4*self.size, ha="center")   

    # ゲームの決着がついていない場合は、手番を表示する
    if self.mb.status == self.mb.PLAYING:
        text = "Turn " + self.mb.board.MARK_TABLE[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 == self.mb.DRAW:
        text = "Draw game"
    # 決着がついていれば勝者を表示する
    else:
        text = "Winner " + self.mb.board.MARK_TABLE[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 == self.mb.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 = self.mb.board.move_to_xy(move)
            mb = deepcopy(self.mb)
            mb.move(move)
            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 copy import deepcopy

def update_gui(self):
元と同じなので省略
    # 対戦カードの文字列を計算する
    ax.text(1.5, 3.5, f"{self.dropdown_list[0].label} VS {self.dropdown_list[1].label}", 
-           fontsize=7*self.size, ha="center")   
+           fontsize=4*self.size, ha="center")   
元と同じなので省略
        
Marubatsu_GUI.update_gui = update_gui

上記の修正後に下記のプログラムを実行して両方の AI に最も名前が長い ai_pmc(10000) を選択すると、実行結果のように Dropdwon と対戦カードの両方がうまく表示されるようになったことが確認できます。

gui_play()

実行結果(ゲーム盤の下のゲーム木の部分は省略しました)

評価値の表示に関する修正

上記の修正を行った後で gui_play で様々な操作を行ってみたところ、下記の操作を行うと下記のようなエラーが発生することがわかりました。

  • 人間どうしの対戦に戻してリセットボタンをクリックする
  • 「状況」ボタンをクリックしてその右の Dropdown に ai_pmc の項目を選択して AI がその局面に対して計算した評価値を表示しようとする
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
File c:\Users\ys\Anaconda3\envs\marubatsu313\Lib\site-packages\ipywidgets\widgets\widget.py:773, in Widget._handle_msg(self, msg)
    771         if 'buffer_paths' in data:
    772             _put_buffers(state, data['buffer_paths'], msg['buffers'])
--> 773         self.set_state(state)
    775 # Handle a state request.
    776 elif method == 'request_state':

略

Cell In[4], line 65
     63 if ai is not None:
     64     analyze = ai(self.mb, analyze=True, **params)
---> 65     score_by_move = analyze["score_by_move"]
     66     candidate = analyze["candidate"]
     67 for move in self.mb.calc_legal_moves():

KeyError: 'score_by_move'

エラーメッセージからキーワード引数に analyze=True を記述して呼び出した返り値である dict に "score_by_move" というキーが存在しないことが原因であることがわかります。

また、実際に ai_pmc は下記のプログラムのように analyzeTrue の場合に返り値として返す dict に score_by_move というキーは存在しません。

def ai_pmc(mb, pnum=10000, timelimit=None, debug=False, analyze=False, *args, **kwargs):

    if analyze:
        return {
            "candidate": best_movesxy,
            "ratio_by_move": ratio_by_move,
            "playout num": retval["count"]
        }
    else:
        return choice(best_moves)  

ai_pmc をそのように実装したのは ai_pmc が評価値を計算するのではなく、プレイアウトの結果の確率を計算するからですが、gui_play でエラーが発生するのは望ましくないのでエラーが発生しないように修正することにします。

具体的には、ai_pmc プレイアウトの勝率(または引き分け率)が最も高い合法手を選択するので、その値は評価値と同じ意味を持ちます。そこで、score_by_move のキーの値に評価値の代わりに評価値に相当するプレイアウトの勝率(最高勝率が 0 % の場合は引き分け率)を表示することにします。

下記はそのように ai_pmc を修正したプログラムです。

  • 6 行目ai_pmc が計算した ratio_by_move にはキーを (x, y) という tuple の座標、キーの値をプレイアウトでその座標に着手した場合の (勝率, 引き分け率) とする dict が代入されているので、dict 内包表記を利用して ratio_by_move からキーの値が mb のゲーム盤の座標、キーの値がプレイアウトでその座標に着手した場合の勝率となる dict を計算して score_by_move に代入する。なお、score_by_move に記録する dict のキーは (x, y) の座標ではなく、ゲーム盤の座標とする必要がある点に注意すること
  • 7、8 行目:組み込み関数 max で プレイアウトの勝率を表す score_by_move のキーの値の最大値を計算し、0 の場合はキーの値がプレイアウトでの引き分け率になるように score_by_move を計算し直す
  • 15 行目:計算した score_by_move を返り値の score_by_move のキーの値とする
 1  from ai import dprint
 2  from random import choice
 3  
 4  def ai_pmc(mb, pnum=10000, timelimit=None, debug=False, analyze=False, *args, **kwargs):
元と同じなので省略
 5      if analyze:
 6          score_by_move = {mb.board.xy_to_move(x, y): winratio 
 7                           for (x, y), (winratio, drawratio) in ratio_by_move.items() }
 8          if max(score_by_move.values()) == 0:
 9              score_by_move = {mb.board.xy_to_move(x, y): drawratio 
10                               for (x, y), (winratio, drawratio) in ratio_by_move.items() } 
11          return {
12              "candidate": best_movesxy,
13              "ratio_by_move": ratio_by_move,
14              "playout num": retval["count"],
15              "score_by_move": score_by_move
16          }
17      else:
18          return choice(best_moves)    
行番号のないプログラム
from ai import dprint
from random import choice

def ai_pmc(mb, pnum=10000, timelimit=None, debug=False, analyze=False, *args, **kwargs):
    if mb.move_count == 8:
        best_move = mb.calc_legal_moves()[0]
        if analyze:
            return {
                "candidate": [mb.board.move_to_xy(best_move)],
                "ratio_by_move": {},
                "playout num": 0,
            }
        else:
            return best_move
    retval = mb.playout(pnum, timelimit)
    best_moves = []
    best_movesxy = []
    best_ratio = (-1, 0)
    if analyze:
        ratio_by_move = {}
    for move, count in retval["result"].items():
        totalcount = max(1, sum(count.values()))
        winratio = count[mb.turn] / totalcount
        drawratio = count[mb.DRAW] / totalcount
        movexy = mb.board.move_to_xy(move)
        dprint(debug, "=" * 50)
        dprint(debug, f"move {movexy}")
        dprint(debug, f"ratio      win: {winratio:.3f} draw {drawratio:.3f}")
        dprint(debug, f"best ratio win: {best_ratio[0]:.3f} draw {best_ratio[1]:.3f}", )
        if best_ratio is None or winratio > best_ratio[0] or (winratio == best_ratio[0] and drawratio > best_ratio[1]):
            best_ratio = (winratio, drawratio)
            best_moves = [move]
            best_movesxy = [movexy]
            dprint(debug, "UPDATE")
            dprint(debug, f"  best score {best_ratio}")
            dprint(debug, f"  best moves {best_movesxy}")
        elif winratio == best_ratio[0] and drawratio == best_ratio[1]:
            best_moves.append(move)
            best_movesxy.append(movexy)
            dprint(debug, "APPEND")
            dprint(debug, f"  best moves {best_movesxy}")
        if analyze:
            ratio_by_move[movexy] = (winratio, drawratio)
    if analyze:
        score_by_move = {mb.board.xy_to_move(x, y): winratio 
                         for (x, y), (winratio, drawratio) in ratio_by_move.items() }
        if max(score_by_move.values()) == 0:
            score_by_move = {mb.board.xy_to_move(x, y): drawratio 
                         for (x, y), (winratio, drawratio) in ratio_by_move.items() }         
        return {
            "candidate": best_movesxy,
            "ratio_by_move": ratio_by_move,
            "playout num": retval["count"],
            "score_by_move": score_by_move
        }
    else:
        return choice(best_moves)    
修正箇所
from ai import dprint
from random import choice

def ai_pmc(mb, pnum=10000, timelimit=None, debug=False, analyze=False, *args, **kwargs):
元と同じなので省略
    if analyze:
+       score_by_move = {mb.board.xy_to_move(x, y): winratio 
+                        for (x, y), (winratio, drawratio) in ratio_by_move.items() }
+       if max(score_by_move.values()) == 0:
+           score_by_move = {mb.board.xy_to_move(x, y): drawratio 
+                            for (x, y), (winratio, drawratio) in ratio_by_move.items() }         
        return {
            "candidate": best_movesxy,
            "ratio_by_move": ratio_by_move,
            "playout num": retval["count"],
+           "score_by_move": score_by_move
        }
    else:
        return choice(best_moves)    

上記の修正後に下記のプログラムを実行して ai_pmc(5) が計算する評価値を表示すると、実行結果のようにそれぞれのマスにプレイアウトの勝率が表示されてエラーが発生しなくなったことが確認できます。

gui_play()

実行結果(プレイアウトはランダムな着手を行うので下記とは異なる数値が表示されます)

評価値の表示に関する修正

様々な操作を行った結果、まだいくつかの問題があることがわかりましたので修正することにします。一つ目の問題は、ai_pmc(10000) が計算する評価値を表示すると下図のように勝率が小数点以下 4 桁以上まで計算されるため、隣り合うマスの勝率が重なって表示されてしまうという問題です。

最初は評価値の表示を行う Node クラスの draw_node メソッドの処理を修正するという方法を考えましたが、ai_pmc のほうで score_by_move に記録する値を小数点以下第 3 桁までにする方が簡単だと思いましたので、プログラムのように ai_pmc を修正することにします。

  • 5、8 行目:指定した桁で四捨五入を行う組み込み関数 round を利用してプレイアウトの勝率と引き分け率を小数点以下第 4 桁で四捨五入するように修正した。組み込み関数 round の詳細については下記のリンク先を参照すること

1  from ai import dprint
2  
3  def ai_pmc(mb, pnum=10000, timelimit=None, debug=False, analyze=False, *args, **kwargs):
元と同じなので省略
4      if analyze:
5          score_by_move = {mb.board.xy_to_move(x, y): round(winratio, 3) 
6                           for (x, y), (winratio, drawratio) in ratio_by_move.items() }
7          if max(score_by_move.values()) == 0:
8              score_by_move = {mb.board.xy_to_move(x, y): round(drawratio, 3) 
9                               for (x, y), (winratio, drawratio) in ratio_by_move.items() } 
元と同じなので省略
行番号のないプログラム
from ai import dprint

def ai_pmc(mb, pnum=10000, timelimit=None, debug=False, analyze=False, *args, **kwargs):
    if mb.move_count == 8:
        best_move = mb.calc_legal_moves()[0]
        if analyze:
            return {
                "candidate": [mb.board.move_to_xy(best_move)],
                "ratio_by_move": {},
                "playout num": 0,
            }
        else:
            return best_move
    retval = mb.playout(pnum, timelimit)
    best_moves = []
    best_movesxy = []
    best_ratio = (-1, 0)
    if analyze:
        ratio_by_move = {}
    for move, count in retval["result"].items():
        totalcount = max(1, sum(count.values()))
        winratio = count[mb.turn] / totalcount
        drawratio = count[mb.DRAW] / totalcount
        movexy = mb.board.move_to_xy(move)
        dprint(debug, "=" * 50)
        dprint(debug, f"move {movexy}")
        dprint(debug, f"ratio      win: {winratio:.3f} draw {drawratio:.3f}")
        dprint(debug, f"best ratio win: {best_ratio[0]:.3f} draw {best_ratio[1]:.3f}", )
        if best_ratio is None or winratio > best_ratio[0] or (winratio == best_ratio[0] and drawratio > best_ratio[1]):
            best_ratio = (winratio, drawratio)
            best_moves = [move]
            best_movesxy = [movexy]
            dprint(debug, "UPDATE")
            dprint(debug, f"  best score {best_ratio}")
            dprint(debug, f"  best moves {best_movesxy}")
        elif winratio == best_ratio[0] and drawratio == best_ratio[1]:
            best_moves.append(move)
            best_movesxy.append(movexy)
            dprint(debug, "APPEND")
            dprint(debug, f"  best moves {best_movesxy}")
        if analyze:
            ratio_by_move[movexy] = (winratio, drawratio)
    if analyze:
        score_by_move = {mb.board.xy_to_move(x, y): round(winratio, 3) 
                         for (x, y), (winratio, drawratio) in ratio_by_move.items() }
        if max(score_by_move.values()) == 0:
            score_by_move = {mb.board.xy_to_move(x, y): round(drawratio, 3) 
                             for (x, y), (winratio, drawratio) in ratio_by_move.items() }         
        return {
            "candidate": best_movesxy,
            "ratio_by_move": ratio_by_move,
            "playout num": retval["count"],
            "score_by_move": score_by_move
        }
    else:
        return choice(best_moves)    
修正箇所
from ai import dprint

def ai_pmc(mb, pnum=10000, timelimit=None, debug=False, analyze=False, *args, **kwargs):
元と同じなので省略
    if analyze:
-       score_by_move = {mb.board.xy_to_move(x, y): winratio 
+       score_by_move = {mb.board.xy_to_move(x, y): round(winratio, 3) 
                         for (x, y), (winratio, drawratio) in ratio_by_move.items() }
        if max(score_by_move.values()) == 0:
-           score_by_move = {mb.board.xy_to_move(x, y): drawratio
+           score_by_move = {mb.board.xy_to_move(x, y): round(drawratio, 3) 
                             for (x, y), (winratio, drawratio) in ratio_by_move.items() } 
元と同じなので省略

上記の修正後に下記のプログラムを実行し、ai_pmc(10000) が計算する評価値を表示すると実行結果のように評価値がマスの中に納まるように表示されることが確認できます。

gui_play()

実行結果(プレイアウトはランダムな着手を行うので下記とは異なる数値が表示されます)

8 手目の局面の評価値の表示のバグの修正

もう一つの問題は下図のようにいずれかの ai_pmc の評価値を表示するようにした場合に、8 手目の局面で下図のように空いているマスに評価値が表示されないというものです。

この問題は下記のプログラムのように ai_pmc が仮引数 analyze の値が True で8 手目の局面の着手を計算する際の返り値の dict に score_by_move のキーが存在しないからです。

def ai_pmc(mb, pnum=10000, timelimit=None, debug=False, analyze=False, *args, **kwargs):
    if mb.move_count == 8:
        best_move = mb.calc_legal_moves()[0]
        if analyze:
            return {
                "candidate": [mb.board.move_to_xy(best_move)],
                "ratio_by_move": {},
                "playout num": 0,
            }
        else:
            return best_move
元と同じなので省略

従って、この問題は下記のプログラムの 9 行目のように score_move のキーとその値を追加することで解決することができます。なお 8 手目の局面は合法手が best_move の一つしかないので、best_move の評価値は 1 と設定しました。

 1  def ai_pmc(mb, pnum=10000, timelimit=None, debug=False, analyze=False, *args, **kwargs):
 2      if mb.move_count == 8:
 3          best_move = mb.calc_legal_moves()[0]
 4          if analyze:
 5              return {
 6                  "candidate": [mb.board.move_to_xy(best_move)],
 7                  "ratio_by_move": {},
 8                  "playout num": 0,
 9                  "score_by_move": {best_move: 1},
10          }
11          else:
12              return best_move
元と同じなので省略
行番号のないプログラム
def ai_pmc(mb, pnum=10000, timelimit=None, debug=False, analyze=False, *args, **kwargs):
    if mb.move_count == 8:
        best_move = mb.calc_legal_moves()[0]
        if analyze:
            return {
                "candidate": [mb.board.move_to_xy(best_move)],
                "ratio_by_move": {},
                "playout num": 0,
                "score_by_move": {best_move: 1},                
            }
        else:
            return best_move
    retval = mb.playout(pnum, timelimit)
    best_moves = []
    best_movesxy = []
    best_ratio = (-1, 0)
    if analyze:
        ratio_by_move = {}
    for move, count in retval["result"].items():
        totalcount = max(1, sum(count.values()))
        winratio = count[mb.turn] / totalcount
        drawratio = count[mb.DRAW] / totalcount
        movexy = mb.board.move_to_xy(move)
        dprint(debug, "=" * 50)
        dprint(debug, f"move {movexy}")
        dprint(debug, f"ratio      win: {winratio:.3f} draw {drawratio:.3f}")
        dprint(debug, f"best ratio win: {best_ratio[0]:.3f} draw {best_ratio[1]:.3f}", )
        if best_ratio is None or winratio > best_ratio[0] or (winratio == best_ratio[0] and drawratio > best_ratio[1]):
            best_ratio = (winratio, drawratio)
            best_moves = [move]
            best_movesxy = [movexy]
            dprint(debug, "UPDATE")
            dprint(debug, f"  best score {best_ratio}")
            dprint(debug, f"  best moves {best_movesxy}")
        elif winratio == best_ratio[0] and drawratio == best_ratio[1]:
            best_moves.append(move)
            best_movesxy.append(movexy)
            dprint(debug, "APPEND")
            dprint(debug, f"  best moves {best_movesxy}")
        if analyze:
            ratio_by_move[movexy] = (winratio, drawratio)
    if analyze:
        score_by_move = {mb.board.xy_to_move(x, y): round(winratio, 3) 
                         for (x, y), (winratio, drawratio) in ratio_by_move.items() }
        if max(score_by_move.values()) == 0:
            score_by_move = {mb.board.xy_to_move(x, y): round(drawratio, 3) 
                             for (x, y), (winratio, drawratio) in ratio_by_move.items() }         
        return {
            "candidate": best_movesxy,
            "ratio_by_move": ratio_by_move,
            "playout num": retval["count"],
            "score_by_move": score_by_move
        }
    else:
        return choice(best_moves)  
修正箇所
def ai_pmc(mb, pnum=10000, timelimit=None, debug=False, analyze=False, *args, **kwargs):
    if mb.move_count == 8:
        best_move = mb.calc_legal_moves()[0]
        if analyze:
            return {
                "candidate": [mb.board.move_to_xy(best_move)],
                "ratio_by_move": {},
                "playout num": 0,
+               "score_by_move": {best_move: 1},                
            }
        else:
            return best_move
元と同じなので省略

上記の修正後に下記のプログラムを実行して ai_pmc の評価値を表示するようにして 8 手目の局面を表示すると、実行結果のように空いているマスに評価値が表示されるようになったことが確認できます。

gui_play()

実行結果

決着が付いた局面のバグ

別の問題として、ai_pmc の評価値を表示するようにし、AI どうしで対戦を行って決着が付いた局面を表示した際に、下記のようなエラーが発生するというものがあります。

---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
File c:\Users\ys\ai\marubatsu\237\marubatsu.py:859, in Marubatsu_GUI.create_event_handler.<locals>.on_reset_button_clicked(b)
    857 self.mb.restart()
    858 self.output.clear_output()
--> 859 on_change_button_clicked(b)

略

File c:\Users\ys\ai\marubatsu\237\marubatsu.py:511, in Marubatsu.playout(self, pnum, timelimit)
    509         status = self.board.judge(last_turn, move, move_count)
    510     self.board.load(board)
--> 511     result[firstmove][status] += 1
    512     count += 1
    513 return {
    514     "result": result,
    515     "count": count,
    516 }

KeyError: None

エラーメッセージから、このエラーが Marubatsu クラスの playout メソッド内で、プレイアウトで最初に行われた着手の結果の回数を数えるという result[firstmove][status] += 1 の処理を行おうとした際に dict の None というキーの値を参照しようとして発生したことがわかります。忘れた方は以前の記事を復習して下さい。

firstmoveNone で初期化され、プレイアウトでの最初の着手が代入されますが、ゲームの決着がついた局面でプレイアウトを行っても合法手が存在しないため firstmove には None が代入されたままになります。プレイアウトで着手行っていないにも関わらずプレイアウトで最初に行われた着手の結果を数えるという処理を行っていることが問題なので、下記のプログラムの 6、7 行目のようにゲームの決着が付いた局面の場合はこの処理を行わないように修正することでこの問題を解決することができます。

 1  from time import perf_counter
 2  import random
 3  
 4  def playout(self, pnum, timelimit=None):
元と同じなので省略
 5          self.board.load(board)
 6          if firstmove is not None:
 7              result[firstmove][status] += 1
 8          count += 1
 9      return {
10          "result": result,
11          "count": count,
12      }
13  
14  Marubatsu.playout = playout
行番号のないプログラム
from time import perf_counter
import random

def playout(self, pnum, timelimit=None):
    if timelimit is not None:   
        starttime = perf_counter()
        timelimit_pc = starttime + timelimit        
    result = {}
    for move in self.calc_legal_moves():
        result[move] = {
            self.CIRCLE: 0,
            self.CROSS: 0,
            self.DRAW: 0,
        }
    count = 0
    board = self.board.save()
    for _ in range(pnum):
        if timelimit is not None and perf_counter() > timelimit_pc:
            break
        turn = self.turn
        move_count = self.move_count
        status = self.status
        firstmove = None
        while status == self.PLAYING:
            move = random.choice(self.calc_legal_moves())
            if firstmove is None:
                firstmove = move
            self.board.setmark_by_move(move, turn)
            last_turn = turn
            turn = self.CROSS if turn == self.CIRCLE else self.CIRCLE
            move_count += 1
            status = self.board.judge(last_turn, move, move_count)
        self.board.load(board)
        if firstmove is not None:
            result[firstmove][status] += 1
        count += 1
    return {
        "result": result,
        "count": count,
    }

Marubatsu.playout = playout
修正箇所
from time import perf_counter
import random

def playout(self, pnum, timelimit=None):
元と同じなので省略
        self.board.load(board)
-       result[firstmove][status] += 1
+       if firstmove is not None:
+           result[firstmove][status] += 1
        count += 1
    return {
        "result": result,
        "count": count,
    }

Marubatsu.playout = playout

上記の修正後に下記のプログラムを実行して同じ操作を行うと、実行結果のように別のエラーが発生します。

gui_play()

実行結果

---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
File c:\Users\ys\ai\marubatsu\237\marubatsu.py:859, in Marubatsu_GUI.create_event_handler.<locals>.on_reset_button_clicked(b)
    857 self.mb.restart()
    858 self.output.clear_output()
--> 859 on_change_button_clicked(b)

略

Cell In[10], line 45
     42 if analyze:
     43     score_by_move = {mb.board.xy_to_move(x, y): round(winratio, 3) 
     44                      for (x, y), (winratio, drawratio) in ratio_by_move.items() }
---> 45     if max(score_by_move.values()) == 0:
     46         score_by_move = {mb.board.xy_to_move(x, y): round(drawratio, 3) 
     47                          for (x, y), (winratio, drawratio) in ratio_by_move.items() }         
     48     return {
     49         "candidate": best_movesxy,
     50         "ratio_by_move": ratio_by_move,
     51         "playout num": retval["count"],
     52         "score_by_move": score_by_move
     53     }

ValueError: max() iterable argument is empty

エラーメッセージから ai_pmc の処理で組み込み関数 max で要素が空(empty)のデータの最大値を計算しようとしたことが原因であることが確認できます。

ゲームの決着が付いた局面の場合は score_by_move に代入された dict にはキーが一つも存在しないので、下記のプログラムの 5 行目のように、ゲームの決着が付いた局面でこの処理を行わないように修正します。

1  def ai_pmc(mb, pnum=10000, timelimit=None, debug=False, analyze=False, *args, **kwargs):
元と同じなので省略
2      if analyze:
3          score_by_move = {mb.board.xy_to_move(x, y): round(winratio, 3) 
4                           for (x, y), (winratio, drawratio) in ratio_by_move.items() }
5          if mb.status == mb.PLAYING and max(score_by_move.values()) == 0:
6              score_by_move = {mb.board.xy_to_move(x, y): round(drawratio, 3) 
7                               for (x, y), (winratio, drawratio) in ratio_by_move.items() }  
元と同じなので省略
行番号のないプログラム
def ai_pmc(mb, pnum=10000, timelimit=None, debug=False, analyze=False, *args, **kwargs):
    if mb.move_count == 8:
        best_move = mb.calc_legal_moves()[0]
        if analyze:
            return {
                "candidate": [mb.board.move_to_xy(best_move)],
                "ratio_by_move": {},
                "playout num": 0,
                "score_by_move": {best_move: 1},                
            }
        else:
            return best_move
    retval = mb.playout(pnum, timelimit)
    best_moves = []
    best_movesxy = []
    best_ratio = (-1, 0)
    if analyze:
        ratio_by_move = {}
    for move, count in retval["result"].items():
        totalcount = max(1, sum(count.values()))
        winratio = count[mb.turn] / totalcount
        drawratio = count[mb.DRAW] / totalcount
        movexy = mb.board.move_to_xy(move)
        dprint(debug, "=" * 50)
        dprint(debug, f"move {movexy}")
        dprint(debug, f"ratio      win: {winratio:.3f} draw {drawratio:.3f}")
        dprint(debug, f"best ratio win: {best_ratio[0]:.3f} draw {best_ratio[1]:.3f}", )
        if best_ratio is None or winratio > best_ratio[0] or (winratio == best_ratio[0] and drawratio > best_ratio[1]):
            best_ratio = (winratio, drawratio)
            best_moves = [move]
            best_movesxy = [movexy]
            dprint(debug, "UPDATE")
            dprint(debug, f"  best score {best_ratio}")
            dprint(debug, f"  best moves {best_movesxy}")
        elif winratio == best_ratio[0] and drawratio == best_ratio[1]:
            best_moves.append(move)
            best_movesxy.append(movexy)
            dprint(debug, "APPEND")
            dprint(debug, f"  best moves {best_movesxy}")
        if analyze:
            ratio_by_move[movexy] = (winratio, drawratio)
    if analyze:
        score_by_move = {mb.board.xy_to_move(x, y): round(winratio, 3) 
                         for (x, y), (winratio, drawratio) in ratio_by_move.items() }
        if mb.status == mb.PLAYING and max(score_by_move.values()) == 0:
            score_by_move = {mb.board.xy_to_move(x, y): round(drawratio, 3) 
                             for (x, y), (winratio, drawratio) in ratio_by_move.items() }         
        return {
            "candidate": best_movesxy,
            "ratio_by_move": ratio_by_move,
            "playout num": retval["count"],
            "score_by_move": score_by_move
        }
    else:
        return choice(best_moves)  
修正箇所
def ai_pmc(mb, pnum=10000, timelimit=None, debug=False, analyze=False, *args, **kwargs):
元と同じなので省略
    if analyze:
        score_by_move = {mb.board.xy_to_move(x, y): round(winratio, 3) 
                         for (x, y), (winratio, drawratio) in ratio_by_move.items() }
-       if max(score_by_move.values()) == 0:
+       if mb.status == mb.PLAYING and max(score_by_move.values()) == 0:
            score_by_move = {mb.board.xy_to_move(x, y): round(drawratio, 3) 
                             for (x, y), (winratio, drawratio) in ratio_by_move.items() }       
元と同じなので省略

実行結果は省略しますが、上記の修正後に下記のプログラムを実行して同じ操作を行うとエラーが発生しなくなります。興味がある方は実際に確認してみて下さい。

gui_play()

これでバグが取れたと思いますので、興味がある方は様々な回数のプレイアウトでの ai_pmc どうしの対戦などを行ってその強さを確認してみて下さい。また、様々な局面で ai_pmc がどのような評価値を計算するかを確認してみて下さい。

今回の記事のまとめ

今回の記事では強化学習の概要と用語について説明しました。

また、gui_playai_pmc を選択できるように修正しました。

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

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

次回の記事

近日公開予定です

  1. これはデジタル大辞林の学習の 3 番目の説明で、この説明が最もこの記事に適していると思いましたので引用しました。他の説明について興味がある方はリンク先を参照して下さい

  2. どのように学習するかについては非常に長くなるので本記事では説明しません。興味がある方は調べてみて下さい

  3. 坂道や舗装されていない道のように、道の歩きやすさによって同じ距離を歩いても所要時間が異なる場合があります

  4. ある程度の事前の知識を得た上で強化学習を行うことも可能ですが、この例では事前知識がないものとします

  5. 現実世界全体を環境と考えるのは広すぎるので、一般的には現実世界の中の学習の対象となるものだけに絞ったものを環境と考えます。また、自分自身を成長させるような学習のように、現実世界の対象である環境が自分自身である場合もあります

  6. Marubatsu ゲームの move メソッドは返り値を返さないので board 属性や status 属性を参照して変化した状態をエージェントが取得する必要があります

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?