UnityとゲームAIと将棋

Unity、Pythonを中心にゲーム開発やゲームAI開発の技術メモ等、たまに将棋も

【Unity】UIDocumentとuGUI(Canvas)のSortOrderの関係

結論

uGUIとUIDocumentのSortOrderによる描画順を制御するには
UIDocumentの "Panel Settings" 側にあるSortOrderを変更する必要がある。

概要

uGUI(Canvas)によるUIとUIToolkitで作成したUIの描画順をSortOrderで制御するにはどうしたらいいか調べていたところ、 UIDocumentのインスペクター上にあるSortOrderではなく、"PanelSettings"側のSortOrderを変更する必要があるということを知った。

Panel Settingsのアセット内を見る必要がある

Panel Settings内のSortOrder

【AlphaZeroを理解する】多腕バンディット問題編

概要

囲碁、将棋、チェスなどのボードゲームにおいてルール以外のドメイン知識を利用せずに対戦用AIとしてのState-of-the-art(SOTA)を達成したAlphaZeroを細かく分解しながら理解していこうというシリーズです。今回はモンテカルロ法の基礎となる多腕バンディット問題についてまとめていきます。

※同シリーズの記事

モンテカルロ木探索のベースになっている技術

モンテカルロ木探索はゲームAIの分野でよく用いられる木探索に対してモンテカルロ法を適用することで、AlphaBeta法などで必要な盤面評価がなくても探索を行えるようにしたものです。

最も単純なモンテカルロ法の適用例である多腕バンディット問題から着想を得て原始モンテカルロ法による探索が発案され、探索の効率化を目指してモンテカルロ木探索になったという背景があるので

多腕バンディット問題→原始モンテカルロ法モンテカルロ木探索の順番で3回に分けてまとめていきます。

多腕バンディット問題

当たりの出る確率が異なるスロットが3台あり、スロットを引ける回数が合計100回の時、当たりを引ける回数を最大化するためにはどのような戦略が最適であるか?(スロットの当たりが出る確率はプレイヤーからは分からないものとする)

という問題が多腕バンディット問題と呼ばれおり、強化学習にも登場する探索と利用のバランシング問題の基礎になっています。

この時、当たりを引く確率を最大化するために採れる戦略にはどのようなものがあるでしょうか?

例えば、各スロット最初は20回ずつ均等に引き、合計60回の試行の中で最も当たりの出た回数が多かったスロットに残りの40回を割り当てるという方法があります。これをPythonで実装してみると以下のようになります。

import numpy as np
import random

class Bandit:
    def __init__(self):
        # それぞれのスロットマシンの出る確率は[0.3, 0.5, 0.7]とする
        self.prob = [0.3, 0.5, 0.7]
        # スロットを引ける回数は100回とする
        self.n = 100
        # 各スロットの選ばれた回数
        self.select_counts = [0, 0, 0]
        # 各スロットの当たった回数
        self.hit_counts = [0, 0, 0]
        # 各スロットの報酬の合計
        self.rewards = [0, 0, 0]

    def play(self):
        # スロットを引く
        for i in range(self.n):
            # 最初の各スロットを20回ずつ選択する
            if i < 20:
                slot = 0
            elif i < 40:
                slot = 1
            elif i < 60:
                slot = 2
            else:
                # 当たりが出た回数が最も多いスロットを選択する
                slot = np.argmax(self.hit_counts)
            # スロットを引く
            reward = 1 if random.random() < self.prob[slot] else 0
            # 各スロットの選ばれた回数を更新
            self.select_counts[slot] += 1
            # 各スロットの当たった回数を更新
            self.hit_counts[slot] += reward
            # 各スロットの報酬の合計を更新
            self.rewards[slot] += reward

    def reset(self):
        # 各スロットの選ばれた回数
        self.select_counts = [0, 0, 0]
        # 各スロットの当たった回数
        self.hit_counts = [0, 0, 0]
        # 各スロットの報酬の合計
        self.rewards = [0, 0, 0]

    def show_result(self):
        # 各スロットの選ばれた回数を表示
        print('select_counts:', self.select_counts)
        # 各スロットの当たった回数を表示
        print('hit_counts:', self.hit_counts)
        # 各スロットの報酬の合計を表示
        print('rewards:', self.rewards)
        # 合計報酬を表示
        print('total reward:', sum(self.rewards))

if __name__ == '__main__':
    bandit = UCBBandit()
    # 1000回実行して平均報酬を計算
    total_rewards = []
    for i in range(1000):
        bandit.play()
        if i < 3:
            print("#",i + 1,'回目')
            bandit.show_result()
        total_rewards.append(sum(bandit.rewards))
        bandit.reset()
    print("#1000回の平均報酬")
    print('mean rewards:', np.mean(total_rewards))

上記コードを実行すると

# 1 回目
select_counts: [20, 20, 60]
hit_counts: [8, 7, 38]
rewards: [8, 7, 38]
total reward: 53
# 2 回目
select_counts: [20, 20, 60]
hit_counts: [7, 11, 38]
rewards: [7, 11, 38]
total reward: 56
# 3 回目
select_counts: [20, 60, 20]
hit_counts: [8, 36, 14]
rewards: [8, 36, 14]
total reward: 58
#1000回の平均報酬
mean rewards: 56.962

のように、必ずしも当たりの出る確率が最高のスロットを選べるようになるとは限らないことがわかります。

上記の方法には以下の2つの問題があります。

  • たまたま確率の低いスロットで当たりが多く出てしまった場合、後半の40回の試行で損なスロットを引くことになってしまう
  • 全てのスロットに均等に前半の試行を割り当てており、効率が悪い

これらの問題を解決するために導入されたのがUCB方策です。 UCB方策は各スロットの評価を以下の式で評価し、最も大きいUCB値を持つスロットを引きます。

 UCB = \displaystyle \overline{x_i} + \sqrt{\frac{2\ln{N}}{n_i}}

 \overline{x_i}はi番目のスロットの平均得点、 Nは全スロットを引いた合計回数、 n_iはi番目のスロットを引いた回数です。 このUCB値はスロットを十分な回数引くと最終的に期待値が最大のスロットのUCB値が最大になることが数学的に保証されています。

UCB方策を用いたバンディットをPythonで実装したのが以下のコードです。

import numpy as np
import random

class UCBBandit:
    def __init__(self):
        # それぞれのスロットマシンの出る確率は[0.3, 0.5, 0.9]とする
        self.prob = [0.3, 0.5, 0.7]
        # スロットを引ける回数は100回とする
        self.n = 100
        # 各スロットの選ばれた回数
        self.select_counts = [0, 0, 0]
        # 各スロットの当たった回数
        self.hit_counts = [0, 0, 0]
        # 各スロットの報酬の合計
        self.rewards = [0, 0, 0]
        # 各スロットのUCB値
        self.ucb_values = [0, 0, 0]

    def caluculate_ucb_value(self, i):
        # 各スロットのUCB値を計算
        for j in range(3):
            if self.select_counts[j] == 0:
                # まだ一度も選ばれていないスロットがある場合はそのスロットを大きな値にする
                self.ucb_values[j] = 1e10
            else:
                # 各スロットのUCB値を計算
                self.ucb_values[j] = self.rewards[j] / self.select_counts[j] + np.sqrt(2 * np.log(i) / self.select_counts[j])

    def play(self):
        # スロットを引く
        for i in range(self.n):
            # UCB値が最も大きいスロットを選択する
            slot = np.argmax(self.ucb_values)
            # スロットを引く
            reward = 1 if random.random() < self.prob[slot] else 0
            # 各スロットの選ばれた回数を更新
            self.select_counts[slot] += 1
            # 各スロットの当たった回数を更新
            self.hit_counts[slot] += reward
            # 各スロットの報酬の合計を更新
            self.rewards[slot] += reward
            # 各スロットのUCB値を更新
            self.caluculate_ucb_value(i + 1)

    def reset(self):
        # 各スロットの選ばれた回数
        self.select_counts = [0, 0, 0]
        # 各スロットの当たった回数
        self.hit_counts = [0, 0, 0]
        # 各スロットの報酬の合計
        self.rewards = [0, 0, 0]
        # 各スロットのUCB値
        self.ucb_values = [0, 0, 0]

    def show_result(self):
        # 各スロットの選ばれた回数を表示
        print('select_counts:', self.select_counts)
        # 各スロットの当たった回数を表示
        print('hit_counts:', self.hit_counts)
        # 各スロットの報酬の合計を表示
        print('rewards:', self.rewards)
        # 合計報酬を表示
        print('total reward:', sum(self.rewards))

if __name__ == '__main__':
    bandit = UCBBandit()
    # 1000回実行して平均報酬を計算
    total_rewards = []
    for i in range(1000):
        bandit.play()
        if i < 3:
            print("#",i + 1,'回目')
            bandit.show_result()
        total_rewards.append(sum(bandit.rewards))
        bandit.reset()
    print("#1000回の平均報酬")
    print('mean rewards:', np.mean(total_rewards))

UCB方策を用いたバンディットの実行結果は以下のようになります。

# 1 回目
select_counts: [14, 25, 61]
hit_counts: [3, 11, 39]
rewards: [3, 11, 39]
total reward: 53
# 2 回目
select_counts: [13, 34, 53]
hit_counts: [4, 22, 39]
rewards: [4, 22, 39]
total reward: 65
# 3 回目
select_counts: [17, 17, 66]
hit_counts: [5, 5, 45]
rewards: [5, 5, 45]
total reward: 55
#1000回の平均報酬
mean rewards: 58.735

このようにUCB方策によるバンディットの方が報酬平均が高くなっていることがわかります。

このUCB方策をゲーム木探索に適用したものがUCTアルゴリズムで、モンテカルロ木探索の基礎になっています。

【C#】List<T>とIEnumerable<T>の選択についてのメモ

結論

メソッド引数の場合

基本的にはIEnumerableを利用。IEnumerableの方がメモリ効率が良いため。

Listを使うのは下記のような場合。

  • 素数の取得をする
  • foreach文を2回以上通過する
  • 内部で配列やリストに変換している

メソッド戻り値

基本的にはListなどのより具体的なインタフェースで返す。呼び出し元での利便性を向上させるため。 無理にIEnumerableにしなくて良い。

詳細

基本的には以下の記事を参照。

qiita.com

IEnumerableは遅延評価がサポートされているため、「この変数は遅延評価に関する考慮が必要ですよ」というメッセージが込められることには注意。

ちなみにforeach文を2回以上通過する場合にイテレータが再評価される例は下記。

IEnumerable<int> GetNumbers()
{
    List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };

    foreach (var number in numbers)
    {
        Console.WriteLine(number);
        yield return number;
    }
}

void Main()
{
    var numbers = GetNumbers();

    foreach (var number in numbers)
    {
        Console.WriteLine(number);
    }
}

このメソッドを実行すると以下のように表示される。

1
1
2
2
3
3
4
4
5
5

このように同じ要素が複数回処理され、効率の低下や予期しない結果につながるので注意が必要。

【ゲームAI】オセロの結論が引き分けであることが解析されたらしい

結論

オセロの結論は引き分けということが解析されたらしい(弱解決)

詳細

オセロの結論は引き分けであるということを解析したという論文がArxivに投稿されていた。

arxiv.org

上記の論文を読んでみて気になったのが「解かれたゲーム」というのには

  • ultra-weakly solved game(超弱解決)
  • weakly solved game(弱解決)
  • strongly solved game(強解決)

の3パターンがあるということ。

それぞれ

超弱解決

  • 初期局面からの勝敗が証明されているが最善手は不明

弱解決

  • 初期局面からの勝敗と初期局面から最終局面に至るまでの最善手がわかっている

強解決

  • 全ての局面からの勝敗と手順がわかっている

というもので、今回のオセロの解析は弱解決に当たるようです。

【AlphaZeroを理解する】モンテカルロ法編

概要

囲碁、将棋、チェスなどのボードゲームにおいてルール以外のドメイン知識を利用せずに対戦用AIとしてのState-of-the-art(SOTA)を達成したAlphaZeroを細かく分解しながら理解していこうというシリーズです。今回はAlphaZeroの根底にあるモンテカルロ木探索のさらに基礎となるモンテカルロ法についてまとめていきます。

モンテカルロ法

AlphaZeroの候補手探索のベースになっているのがモンテカルロ木探索で、モンテカルロ木探索が依拠しているのがモンテカルロ法というアルゴリズムです。

モンテカルロ法はシミュレーションや数値計算などを行う際に乱数を用いる手法の総称です。

代表的な例として円周率の近似があります。 辺の長さが1の正方形内にランダムに点を打っていき、正方形内にある半径1の四分円の中に入った点の数を集計します。

四分円の面積は

四分円内にある点の数(n_in) / 正方形内にある点総数(n)

で近似できるので、円周率 \pi

 \pi = \displaystyle \frac{4 \times n_{in}}{n}

という形で近似できます。 上記のシミュレーションをPythonで実装して視覚化してみると下図のようになります。

モンテカルロ法Pythonシミュレーション

円周率は3.172と近似されており概ね正しい値に収束していることがわかります。

このように乱数を用いたサンプリングを数千、数万回と繰り返していくことでシミュレーションや数値計算の解が一定の値に収束していき、サンプリング数が十分な大きさになると近似解が得られます。これがモンテカルロ法の基本的な考え方になります。

次回の記事ではこのモンテカルロ法をゲーム木探索に適用したモンテカルロ木探索についてまとめます。

【GitHub】GitHub Actions でDiscordにWebHookで通知を送りたい時のサンプルコード

結論

name: 'GitHub Notification'

on:
  workflow_dispatch:

jobs:
  notify:
    runs-on: ubuntu-latest
    steps:
      - name: Send Discord notification
        uses: stegzilla/discord-notify@v2
        with:
          webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
          title: GitHub Notification
          message: Completed
          avatar_url: https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png
          username: GitHub Notifier

【Unity】リリースビルドからデバッグ用コードを除外したい時のシンボル

結論

DEVELOPMENT_BUILD || UNITY_EDITORのシンボルで括る

#if DEVELOPMENT_BUILD || UNITY_EDITOR
#endif

詳細

Unityで開発をしていると

  • リリース時はデバッグ用機能を無効、除外したい
  • Developmentモードのビルド時はデバッグ機能有効にしたい
  • Editor上では常にデバッグ機能を有効化しておきたい

という状況があると思います。
そんな時に使えるのが以下のように DEVELOPMENT_BUILD || UNITY_EDITOR のシンボルで括るという方法です。

#if DEVELOPMENT_BUILD || UNITY_EDITOR
#endif

DEBUGというシンボルで括る手もありますが、DEBUGは.NETのシンボルでUnity独自のシンボルではないため上記の方法が推奨されているみたいです。