機械学習周りのプログラミング中心。 イベント情報
ポケモンバトルAI本電子書籍通販中

【CodinGameオセロ】DNNの推論器の独自実装

前回、AlphaZero方式でモデルの学習が実現できました。しかしモデルの推論にTensorflowを用いていたため、CodinGameに投稿できるコードになりませんでした。ここからは、C++言語だけでモデルの推論ができるように実装を進めます。 C++言語からDNNモデルを推論できるインターフェースを備えた深層学習ライブラリはいくつかありますが、数百kB以上のライブラリを要するものが多く、10万文字の制限内に収めるには不向きなものが多いです。のちにTVMを用いた方法を紹介しますが、まずは今回のモデルに必要なロジックのみを備えたごく簡単な推論機構を独自に実装しました。

今回のコードのバージョンです。

https://github.com/select766/codingame-othello/tree/22be3edc6eb3fa1be113736a6ab6e7f9f9ec4dc1

Tensorflowのモデルから重みを抽出する

モデルの推論を独自に行うため、Tensorflowで学習されたモデルファイルから、重み行列の全要素の値を抽出します。これは、Python上でTensorflowを用いてモデルを解釈することで実現できます。以下のように、モデルの重みをnumpy配列として列挙することが可能です。

import tensorflow as tf

model = tf.keras.models.load_model(args.savedmodel_dir)

for w in model.weights:
    name = w.name # "othello_model_v1/conv2d_1/kernel:0"
    array = w.numpy() # numpy配列(np.float32)

重みが列挙される順番は、おそらくソースコード上の定義順に沿っています。w.nameは以下の順で出力されました。モデルの定義時に明示的にレイヤーの名前を与えれば対応関係がはっきりしますが、特定のモデルを手動で解釈する範囲で十分なのでこのまま進めます。

othello_model_v1/conv2d/kernel:0 
othello_model_v1/conv2d/bias:0 
othello_model_v1/conv2d_1/kernel:0 
othello_model_v1/conv2d_1/bias:0 
othello_model_v1/conv2d_2/kernel:0 
othello_model_v1/conv2d_2/bias:0 
othello_model_v1/conv2d_3/kernel:0 
othello_model_v1/conv2d_3/bias:0 
othello_model_v1/conv2d_4/kernel:0 
othello_model_v1/conv2d_4/bias:0 
othello_model_v1/conv2d_5/kernel:0 
othello_model_v1/conv2d_5/bias:0 
othello_model_v1/dense/kernel:0 
othello_model_v1/dense/bias:0 
othello_model_v1/dense_1/kernel:0 
othello_model_v1/dense_1/bias:0

以下のコードで、すべての重みをバイト配列として連結し、それぞれの要素数も出力します。取り出した重みを、C++ソースコード中で文字列定数として埋め込める形にします。本来4バイト(float32)の重みを、1.23456のようなfloatの定数で書くとソースコードが長くなるため、バイト列として扱いbase64を用いて3バイトを4文字で表した文字列定数として埋め込むことにします。

struct_def = ""
flat_binary = b""
for w in model.weights:
    name = w.name
    name = name.removeprefix("othello_model_v1/")
    name = name.removesuffix(":0")
    name = name.replace("/", "_")
    array = w.numpy()
    size = array.size
    struct_def += f"float {name}[{size}];\n"
    flat_binary += array.tobytes()

print(struct_def)
hpp_src = f"""#ifndef _DNN_WEIGHT_
#define _DNN_WEIGHT_
const char dnn_weight_base64[] = "{base64.b64encode(flat_binary).decode("ascii")}";
#endif
"""

hpp_srcは、すべての重みのバイト列をbase64により文字列定数化したものです。

const char dnn_weight_base64[] = "OvrLvTipPb40..."

struct_defは文字列定数をバイト列にデコードした結果を、テンソルごとに分離するための構造体定義になります。その例を示します。

class DNNWeight
{
public:
    float conv2d_kernel[216];
    float conv2d_bias[8];
    float conv2d_1_kernel[576];
    float conv2d_1_bias[8];
    float conv2d_2_kernel[576];
    float conv2d_2_bias[8];
    float conv2d_3_kernel[576];
    float conv2d_3_bias[8];
    float conv2d_4_kernel[576];
    float conv2d_4_bias[8];
    float conv2d_5_kernel[8];
    float conv2d_5_bias[1];
    float conv2d_6_kernel[64];
    float conv2d_6_bias[8];
    float dense_kernel[512];
    float dense_bias[1];
};

これで、C++ソースコード内に学習された重みを埋め込める状態になりました。重みを埋め込んだファイルは、src/_dnn_weight.hppに書き出して他のソースからincludeします。

モデルの実行

モデルを実行する機構を実装します。まずテンソルを表現するクラスです。テンソルの次元数には4次元(畳み込みへの入力、畳み込みの重み)、2次元(全結合層への入力)、1次元(重みのバイアス)がありますが、4次元で統一し、不要な次元には1を代入することにしました。また、外部からメモリ領域を与える初期化と、クラス内でメモリ領域を確保する初期化の両方をサポートしました。using PTensor = shared_ptr<Tensor>;を定義し、クラス内でメモリを確保した場合は自動で解放するようにしました。

class Tensor
{
public:
    float *data;
    bool owndata = false;
    array<int, 4> shape; // 簡単のため常に4Dで扱う。(batch, h, w, c) or (batch, 1, 1, c) or (1, 1, 1, c)
    array<int, 4> strides;
    int size;
    Tensor(array<int, 4> shape, float *data = nullptr) : data(data), shape(shape)
    {
        size = 1;
        for (size_t i = shape.size() - 1; i < shape.size(); i--)
        {
            strides[i] = size;
            size *= shape[i];
        }
        if (!data)
        {
            owndata = true;
            this->data = new float[size];
        }
    }

    // クラス内でメモリを確保した場合は解放
    ~Tensor()
    {
        if (owndata)
        {
            delete[] data;
        }
    }

    // 要素にアクセスする関数
    float &v(int n, int h, int w, int c)
    {
        return data[n * strides[0] + h * strides[1] + w * strides[2] + c * strides[3]];
    }

    float &v(int n, int c)
    {
        return data[n * strides[0] + c * strides[3]];
    }

    float &v(int c)
    {
        return data[c * strides[3]];
    }
};

using PTensor = shared_ptr<Tensor>;

テンソルを用いた全結合層の実装は以下のように実装しました。素直な行列積の実装です。

PTensor dense(PTensor x, PTensor w, PTensor b)
{
    // x: (n=1, 1, 1, in_c)
    // w: (in_c, 1, 1, out_c)
    // b: (1, 1, 1, out_c)
    // y: (n=1, 1, 1, out_c)
    PTensor y = tensor({1, 1, 1, w->shape[3]});
    for (int n = 0; n < w->shape[3]; n++)
    {
        float sum = b->v(n);
        for (int k = 0; k < w->shape[0]; k++)
        {
            sum += x->v(k) * w->v(k, n);
        }
        y->v(n) = sum;
    }
    return y;
}

畳み込み層はもう少し複雑ですが、定義通りの計算を実装しています。

PTensor conv2d(PTensor x, PTensor w, PTensor b, int pad, int stride)
{
    // x: (n=1, h, w, in_c)
    // w: (kh, kw, in_c, out_c)
    // b: (1, 1, 1, out_c)
    // y: (n=1, out_h, out_w, out_c)
    int in_h = x->shape[1], in_w = x->shape[2], in_c = x->shape[3];
    int kh = w->shape[0], kw = w->shape[1], out_c = w->shape[3];
    int out_h = (in_h + 2 * pad - kh) / stride + 1;
    int out_w = (in_w + 2 * pad - kw) / stride + 1;
    PTensor y = tensor({1, out_h, out_w, out_c});
    for (int out_y = 0; out_y < out_h; out_y++)
        for (int out_x = 0; out_x < out_w; out_x++)
            for (int oc = 0; oc < out_c; oc++)
            {
                float sum = b->v(oc);
                for (int ic = 0; ic < in_c; ic++)
                    for (int ky = 0; ky < kh; ky++)
                        for (int kx = 0; kx < kw; kx++)
                        {
                            int in_y = out_y * stride - pad + ky;
                            int in_x = out_x * stride - pad + kx;
                            if (in_y < 0 || in_y >= in_h || in_x < 0 || in_x >= in_w)
                            {
                                continue;
                            }
                            sum += x->v(0, in_y, in_x, ic) * w->v(ky, kx, ic, oc);
                        }

                y->v(0, out_y, out_x, oc) = sum;
            }
    return y;
}

これらの機能を用いて、モデルの実行機構を実装します。モデルの構造に沿って処理の順序を手書きで実装しています。学習済みの重みはDNNWeight weight構造体に入っており、先述のようにテンソルごとに分割されている状態のものをtensor({3, 3, 3, 8}, weight.conv2d_kernel)のように形状を指定してTensorクラスのオブジェクトとして扱えるようにします。

DNNEvaluatorResult evaluate(const Board &board)
{
    DNNInputFeature req = extractor.extract(board);
    PTensor h = tensor({1, BOARD_SIZE, BOARD_SIZE, 3}, req.board_repr);
    h = conv2d(h, tensor({3, 3, 3, 8}, weight.conv2d_kernel), tensor({1, 1, 1, 8}, weight.conv2d_bias), 1, 1);
    relu_inplace(h);
    h = conv2d(h, tensor({3, 3, 8, 8}, weight.conv2d_1_kernel), tensor({1, 1, 1, 8}, weight.conv2d_1_bias), 1, 1);
    relu_inplace(h);
    h = conv2d(h, tensor({3, 3, 8, 8}, weight.conv2d_2_kernel), tensor({1, 1, 1, 8}, weight.conv2d_2_bias), 1, 1);
    relu_inplace(h);
    h = conv2d(h, tensor({3, 3, 8, 8}, weight.conv2d_3_kernel), tensor({1, 1, 1, 8}, weight.conv2d_3_bias), 1, 1);
    relu_inplace(h);
    h = conv2d(h, tensor({3, 3, 8, 8}, weight.conv2d_4_kernel), tensor({1, 1, 1, 8}, weight.conv2d_4_bias), 1, 1);
    relu_inplace(h);

    auto p = h, v = h; // policy head, value headへ分岐

    p = conv2d(p, tensor({1, 1, 8, 1}, weight.conv2d_5_kernel), tensor({1, 1, 1, 1}, weight.conv2d_5_bias), 0, 1);

    v = conv2d(v, tensor({1, 1, 8, 8}, weight.conv2d_6_kernel), tensor({1, 1, 1, 8}, weight.conv2d_6_bias), 0, 1);
    relu_inplace(v);
    flatten_inplace(v);
    v = dense(v, tensor({512, 1, 1, 1}, weight.dense_kernel), tensor({1, 1, 1, 1}, weight.dense_bias));

    DNNEvaluatorResult res;
    memcpy(res.policy_logits, p->data, sizeof(res.policy_logits));
    memcpy(&res.value_logit, v->data, sizeof(res.value_logit));
    return res;
}

CodinGameへの投稿

ついにAlphaZero方式で学習したモデルをCodinGameに投稿可能になりました。モデルのパラメータは、畳み込み5層(チャンネル数8)+policyに畳み込み1層+valueに畳み込み1層全結合1層です。Wood 2 Leagueで17位(石の数を評価値とし、アルファベータ法で探索するもの)から15位に上がりました。残念ながらあまり成績が向上しませんでした。対局サーバ上で、1手の思考中に10局面程度しか評価できていませんでした(制限時間120ms)。サーバ上でのコンパイル時に、デフォルトでは最適化がかからないようで遅い原因になっているようです。

モデルを少し大きくする

前章で投稿したソースコードは55kBで、CodinGameに投稿できる容量(10万文字)まで少し余裕があります。もう少し大きなモデルを学習しました。畳み込み7層(チャンネル数8)+policyに畳み込み2層+valueに畳み込み1層全結合1層の構成とし、さらにBatch Normalization層を追加しました。また、以下のpragmaをソースコード先頭に記述することで、若干最適化がかかり速度が向上することがわかりました(ただし、よりよいオプションがあります)。最適化なしの場合、1手の思考時間内に6局面、最適化ありの場合17局面の評価を行うことができました。

#pragma GCC optimize("O3,unroll-loops")
#pragma GCC target("avx2,bmi,bmi2,lzcnt,popcnt")

投稿結果、Wood 2 Leagueで6位に上がりました。しかし、目視でわかる範囲でも打った手に難があり、全滅して負けるという事象が頻繁にみられました。次回は全滅対策を実装し、モデルをさらに大きくします。

b5に打って全滅負けする様子

【CodinGameオセロ】棋譜生成と学習のループ

準備が整ったので、AlphaZero方式の強化学習のコアを実装します。

ソースコードはこのバージョンです。

https://github.com/select766/codingame-othello/tree/826d5aa02298d07ca088969bdd8f10c3ca2e8c3f

必要なモジュールは2つです。(1)自己対戦により棋譜を生成します。モデルは固定して探索を行います。(2)棋譜を用いた教師あり学習により、モデルを更新します。

棋譜の生成

棋譜の生成モジュールは、現在のモデルを入力とし、一定数の自己対局を行い、棋譜(局面、指し手、手番側の勝敗)を出力します。

モデルの推論はPythonからTensorflowを呼び出して行う一方、探索はC++で実装しています。暫定的な探索部ではプロセス間通信でこれらをつないでいましたが、1つのプロセス内にまとめることで通常の関数呼び出しを行えるようにし、実装の見通しをよくします。C++で実装したモジュールを、Pythonから呼び出せる形式にビルドします。pybind11を用いることで、C++の関数・クラスをPythonモジュールとしてビルドできます。特に、numpy配列を関数の引数として用いることができるため、Tensorflowとのデータのやり取りが容易になります。

Pythonから呼び出すC++の機能は、以下の関数に集約しました。

  • int init_playout(const string &record_path, int parallel, int playout_limit): 自己対戦機構を初期化する。保存する棋譜ファイルのパスなどを指定する。
  • void proceed_playout(py::array_t<float> batch_board_repr, py::array_t<float> batch_policy_logits, py::array_t<float> batch_value_logit): DNNの評価結果を与えて自己対局を進める。
    • py::array_t<float> batch_policy_logits, py::array_t<float> batch_value_logit: 前回のproceed_playoutの結果として得られた、評価すべき局面をDNNで評価した結果をnumpy配列形式で与える。
    • py::array_t<float> batch_board_repr: numpy配列のプレースホルダで、次にDNNで評価すべき局面をC++側から書きこむ。
  • void end_playout(): 自己対戦機構を終了し、棋譜ファイルを閉じる。
  • int games_completed(): 終了した対局の数を取得する。自己対局を終了するタイミングを測るための機能。

pybind11を用いてPython側に見せる関数を提供するコードは以下のようになります。

https://github.com/select766/codingame-othello/blob/826d5aa02298d07ca088969bdd8f10c3ca2e8c3f/src/lib_pybind11.cpp

Python側は以下のコードになります。

https://github.com/select766/codingame-othello/blob/826d5aa02298d07ca088969bdd8f10c3ca2e8c3f/othello_train/playout_v1.py

Python側では、モデルを読み込んで初期化を終えた後は(1) proceed_playoutを呼び出し、次に評価すべき局面を受け取る、(2)Tensorflowで評価を行う、というループを行うだけです。pybind11で作られたモジュールがothello_train_cppという名前で読めるように(Makefileにより)配置されているので、Pythonで書かれたモジュールと同様にimportできます。

from othello_train import othello_train_cpp

C++側では、proceed_playoutが呼ばれた時に複数の対局を順番に進めます。1つの対局に対応するSinglePlayoutクラスは以下のようになっています。

class PlayoutBuffer
{
public:
    float *board_repr;    // Playoutが評価を求めたい盤面表現をこのアドレスに書き込む
    const float *policy_logits; // 前回評価を求められた局面の評価結果をPlayoutに渡す
    const float *value_logit;   // 前回評価を求められた局面の評価結果をPlayoutに渡す
};

class SinglePlayout
{
    Board board;
    FeatureExtractor extractor;
    vector<MoveRecord> records;
    shared_ptr<SearchMCTSTrain::SearchPartialResultEvalRequest> last_eval_request;
    int _games_completed;

public:
    shared_ptr<ofstream> fout;
    SearchMCTSTrain engine;

    SinglePlayout(shared_ptr<ofstream> fout, SearchMCTSTrain::SearchMCTSConfig mcts_config) : fout(fout), engine(mcts_config), extractor(), _games_completed(0)
    {
        board.set_hirate();
        engine.board.set(board);
    }

    int games_completed() const
    {
        return _games_completed;
    }

    void proceed(PlayoutBuffer &playout_buffer)
    {
        SearchMCTSTrain::EvalResult eval_result;
        if (playout_buffer.policy_logits)
        {
            memcpy(eval_result.policy_logits, playout_buffer.policy_logits, sizeof(eval_result.policy_logits));
        }
        if (playout_buffer.value_logit)
        {
            memcpy(&eval_result.value_logit, playout_buffer.value_logit, sizeof(eval_result.value_logit));
        }
        eval_result.request = last_eval_request;
        while (true)
        {
            auto search_partial_result = engine.search_partial(&eval_result);
            auto result_move = dynamic_pointer_cast<SearchMCTSTrain::SearchPartialResultMove>(search_partial_result);
            if (result_move)
            {
                // 指し手を進める
                proceed_game(result_move->move);
            }
            auto result_eval = dynamic_pointer_cast<SearchMCTSTrain::SearchPartialResultEvalRequest>(search_partial_result);
            if (result_eval)
            {
                // 評価が必要
                DNNInputFeature feat = extractor.extract(result_eval->board);
                memcpy(playout_buffer.board_repr, feat.board_repr, sizeof(feat.board_repr));
                last_eval_request = result_eval;
                return;
            }
        }
    }

private:
    void do_move_with_record(Move move)
    {
        // boardを進めるとともに指し手を記録
        BoardPlane lm;
        board.legal_moves_bb(lm);
        auto n_legal_moves = __builtin_popcountll(lm);
        MoveRecord record;
        record.move = static_cast<decltype(record.move)>(move);
        record.planes[0] = board.plane(0);
        record.planes[1] = board.plane(1);
        record.turn = static_cast<decltype(record.turn)>(board.turn());
        record.n_legal_moves = static_cast<decltype(record.turn)>(n_legal_moves);
        memset(record.pad, 0, sizeof(record.pad));

        records.push_back(record);

        UndoInfo undo_info;
        board.do_move(move, undo_info);
    }

    void flush_record_with_game_result()
    {
        // gameoverの時に呼び出す。recordsにゲームの結果を書きこんだうえでファイルに出力する。
        
        int8_t stone_diff_black = static_cast<int8_t>(board.piece_num(BLACK) - board.piece_num(WHITE));
        for (auto &record : records)
        {
            record.game_result = record.turn == BLACK ? stone_diff_black : -stone_diff_black;
        }
        
        fout->write((char*)&records[0], records.size() * sizeof(MoveRecord));
        records.clear();
        _games_completed++;
    }

    void proceed_game(Move move)
    {
        // 指定された指し手でゲームを進め、次に指し手選択が必要な状態まで進行する。最新の局面をengineにセットする。
        do_move_with_record(move);

        while (true)
        {
            if (board.is_gameover())
            {
                flush_record_with_game_result();
                board.set_hirate();
                engine.newgame();
                engine.board.set(board);
            }
            
            vector<Move> move_list;
            board.legal_moves(move_list);
            if (move_list.empty())
            {
                do_move_with_record(MOVE_PASS);
            }
            else if (move_list.size() == 1)
            {
                do_move_with_record(move_list[0]);
            }
            else
            {
                break;
            }
        }

        engine.board.set(board);
        return;
    }
};

行数は多いですが、やっていることは単純です。

  • DNN評価結果を受け取り、MCTSのゲーム木を更新する
  • 探索回数が一定値に達した場合
    • 指し手を決定し、局面を一手進める
    • 局面を進めた結果、終局した場合
      • 棋譜をファイルに書き込む
      • 新しい対局を開始する
  • ゲーム木の探索を再開/新しい局面で開始し、評価すべき局面を返す

これらの分岐を処理することで、インターフェースとしては前回返した局面に対する評価結果を受け取り、次に評価すべき局面を返すという形式にまとまります。さらに、ParallelPlayoutクラスではSinglePlayoutオブジェクトをバッチサイズ(例えば256)個生成し、順番に呼び出して結果をまとめることで、Tensorflow側でミニバッチ処理できるnumpy配列を得る実装になっています。

このコードに表れていない工夫点として、局面をキーとし、DNNの評価結果をキャッシュしています。序盤は別々の対局で同じ局面が出現しますし、1つの対局の中で、ある局面に対する手を決める過程で評価した局面の一部が、次の局面の時にも再度出現します。この機構により、DNNの実行回数が20%程度低減しました。

モデルの更新

モデルの更新モジュールは、現時点のモデルとそれを用いて生成した棋譜を入力とし、教師あり学習を行って、更新されたモデルを出力します。

https://github.com/select766/codingame-othello/blob/826d5aa02298d07ca088969bdd8f10c3ca2e8c3f/othello_train/rl_train_v1.py

処理は通常の教師あり学習と何ら変わらないため、特別な実装はありません。

ループでつなげる

最後に、棋譜の生成とモデルの更新を交互に行うためのループを実装します。各プロセスを、引数を変えながら起動することで実現します。

https://github.com/select766/codingame-othello/blob/826d5aa02298d07ca088969bdd8f10c3ca2e8c3f/othello_train/rl_loop.py

import argparse
import subprocess
from pathlib import Path
from typing import Optional


def check_call(args, skip_if_exists: Optional[Path] = None):
    if skip_if_exists is not None and skip_if_exists.exists():
        print("#skip: " + " ".join(args))
        return
    print(" ".join(args))
    subprocess.check_call(args)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("work_dir")
    parser.add_argument("--epoch", type=int, default=10)
    parser.add_argument("--games", type=int, default=10000)
    args = parser.parse_args()

    work_dir = Path(args.work_dir)
    records_dir = work_dir / "records"
    records_dir.mkdir(parents=True, exist_ok=True)

    for epoch in range(args.epoch):
        if epoch == 0:
            check_call(["python", "-m", "othello_train.make_empty_model_v1",
                       f"{work_dir}/cp_{epoch}/cp"], work_dir / f"cp_{epoch}")
            check_call(["python", "-m", "othello_train.checkpoint_to_savedmodel_v1",
                        f"{work_dir}/cp_{epoch}/cp", f"{work_dir}/sm_{epoch}"], work_dir / f"sm_{epoch}")
        check_call(["python", "-m", "othello_train.playout_v1",
                   f"{work_dir}/sm_{epoch}", f"{records_dir}/records_{epoch}.bin", "--games", f"{args.games}"], records_dir / f"records_{epoch}.bin")
        check_call(["python", "-m", "othello_train.rl_train_v1", f"{work_dir}/cp_{epoch}/cp", f"{work_dir}/cp_{epoch+1}/cp",
                   f"{records_dir}/records_{epoch}.bin"], work_dir / f"cp_{epoch+1}")
        check_call(["python", "-m", "othello_train.checkpoint_to_savedmodel_v1",
                   f"{work_dir}/cp_{epoch+1}/cp", f"{work_dir}/sm_{epoch+1}"], work_dir / f"sm_{epoch+1}")


if __name__ == "__main__":
    main()

checkpoint_to_savedmodel_v1は、学習に用いるモデル形式であるcheckpoint形式から、推論に便利な(モデル構造を内包した)savedmodel形式に変換するコードです。学習は1日以上かかるため、途中で中断・再開できるように工夫しています。check_call関数にskip_if_existsという引数があり、ここに指定したパスのファイルが既に存在する(そのコマンドの成果物が既に存在している)場合はそのコマンドの実行をスキップするという処理になっています。ただし、棋譜生成は中断すると、その時点までの中途半端な棋譜ファイルが残ってしまうため、再開前に手動で削除する必要があります。

このスクリプトを実行することで、AlphaZero方式の強化学習が実現できます。考え方は単純なものの、実装はかなり長くなりました。ミニバッチでの処理が可能となるよう、探索を並行処理可能にする機構が特に長くなりました。

このスクリプトを用いてモデルを強化学習しました。1手の思考に64局面の評価を行い、1epochあたりの自己対局数は10000としました。

各epochのモデルを(棋譜生成ではなく本番用に近い)対局エンジンに読み込ませ、ランダムプレイヤーと強さを比較しました。MCTSの探索ノード数は16です。

epoch 勝ち 引き分け 負け
0 58 5 37
1 83 1 16
2 99 0 1
3 99 0 1

epoch=0は、ランダムに初期化したモデルです。これがランダムより良いのは、終盤ではゲーム木の末端に勝敗が決定した局面が出現するため、評価関数の内容によらず勝てる手を指せるためであると考えられます。epoch=1は、ランダムなモデルで指した手と勝敗を学習した状態です。学習した手の品質は非常に低いと思われますが、勝敗については局面の良さと相関があると考えられます。そのため評価関数として成立し、強くなったと考えられます。epoch=2以降は意味のある学習データが利用できており、さらに強くなりました。これ以上はランダムプレイヤーを相手に測定することが難しいというところまで、強化学習により強くすることに成功しました。

ここまでの実装で、AlphaZero方式の強化学習が実現できました。しかし、モデルの評価でTensorflowを利用しているためCodinGameに投稿することができません。今後はCodinGameへ投稿できるエンジンの実装に入っていきます。

【CodinGameオセロ】並列対局を想定したMCTSの実装

AlphaZero式の強化学習において、学習データを作成するために必要なMCTSの実装を行います。

現時点のコードのバージョンはこちらです。

https://github.com/select766/codingame-othello/tree/826d5aa02298d07ca088969bdd8f10c3ca2e8c3f

AlphaZeroの学習サイクル

AlphaZeroでは、以下のステップを繰り返すことでDNNモデルを強化学習します。

  1. AI同士の自己対局を行う。その際の手は、DNNモデルを評価関数としたMCTSにより決める。
  2. 自己対局で選んだ手およびその対局の勝敗を教師データとして、DNNモデルを教師あり学習する。

MCTSによる探索を行うことで、DNNモデルから出力されるpolicyよりも良い手が打てます。その手を直接policyの出力として学習させることにより、DNNがより強い手を選べるようになります。

MCTSの実装

今回は、自己対局で利用するMCTSを実装します。

MCTSの手続きは以下の通りです。MCTSでは局面をノード、手をエッジとする木構造を用います。

  1. ルート局面(打つ手を決めたい局面)に対応するルートノードを作成する。
  2. 以下に示す探索を一定回数または一定時間行う。
    1. ルートノードから所定の基準で子ノードを再帰的に選択し、葉ノードに到達する。
    2. 葉ノードの局面をDNNで評価する。
    3. 評価結果を、探索経路の各エッジに反映させる(バックアップ操作)。
  3. 手を決定する。
    1. ルートノードから子ノードにつながるエッジのうち、もっとも訪問回数が多いものを選択する。

自己対局では、現在の局面でMCTSにより手を決定し、その手で局面を一手進めます。その局面で再びMCTSにより決定し手を進めるということを終局まで行い、棋譜を出力します。

シンプルなMCTSの実装

まずは、手順がわかりやすいシンプルなMCTS実装を示します。上記のステップを素直に実装したものとなります(実装の時系列としては並行動作対応より後でした)。

https://github.com/select766/codingame-othello/blob/fa283be034455fddee3e97d13df89542e937bba9/src/search_mcts.hpp

この実装では、ゲーム木を再帰的にたどって到達した葉ノードにおいてDNN評価を dnn_evaluator->evaluate(b); のように呼び出しています。シングルコアCPUしか使用できない本番の対局ではここでDNNを実行すればよいのですが、学習時はGPUを用いて評価を行って高速化したいです。GPUでは1局面だけ評価するのは非効率なため、多数の局面をミニバッチにまとめて評価する仕組みが必要です。そのためには、バッチサイズNに対応したN対局を並行して進め、評価すべき局面をまとめます。対局を並行して行う実装は2通り考えられます。1つ目は、マルチスレッドを用いる方法です。対局ごとのスレッドと、DNNを評価するスレッドを用意します。対局スレッドは、DNNを実行する箇所に到達したらDNNスレッドに局面を送信し(全スレッドで共有されたメモリ・キューなどを利用)、評価が完了するまで待機します。DNNスレッドは、すべての対局スレッドから局面を受信したらDNNの評価を行い、結果を対局スレッドに返送します。2つ目は、シングルスレッドで全ての対局を順番に実行する方法です。この方法では、探索のコードを書き替えて、search関数の内部で評価を呼び出すのではなく、search関数の戻り値として評価すべき局面を返します。各対局のsearch関数を順番に呼び出して評価すべき局面を収集し、DNNの評価を行います。評価結果は、search関数の引数として探索部に返します。方法1のメリットは、マルチスレッドに起因する実装ミスがなければ見通しの良いコードになる点です。デメリットは、スレッドの切り替えが高頻度に発生する点です。毎秒数万回の切り替えが発生し、これがオーバーヘッドになる可能性があります。方法2のメリットは、実装ミスが起きやすいマルチスレッドの実装をしなくてよい点です。デメリットは、評価すべき局面に到達したら一度探索を中断して局面を戻り値として返し、評価結果を受け取ったら探索を再開するというコードの構造の書き換えが必要になる点です。今回は方法2を実装することとしました。

並行動作に対応したMCTSの実装

並行動作に対応したMCTSを実装します。外部から呼び出されるのはsearch_partial関数で、局面の評価が必要か、指し手が決定するまで探索を行います。この関数のシグネチャshared_ptr<SearchPartialResult> search_partial(const EvalResult *eval_result) となっているのがポイントです。SearchPartialResultは、探索完了(指し手決定時)か、局面の評価が必要になったときに返すデータで、unionのような構造になっています。呼び出し側は、dynamic_castでどちらの型が返ったかを判定します。SearchPartialResultMoveであればその指し手で対局を進めて再度search_partialを呼び出します。SearchPartialResultEvalRequestであれば局面を評価します。

class SearchPartialResult
{
public:
    virtual ~SearchPartialResult() = default;
};

class SearchPartialResultMove : public SearchPartialResult
{
public:
    Move move;
    float score;
};

class SearchPartialResultEvalRequest : public SearchPartialResult
{
public:
    Board board;                             // 評価対象局面
    vector<pair<TreeNode *, int>> tree_path; // 探索木の経路。TreeNodeと、その中のエッジのインデックスのペア。leafがルートの場合は空。
    TreeNode *leaf;                          // 末端ノードのTreeNode。
};

search_partial関数の中は以下のようになっています。メンバ変数next_taskに、次に実行すべき動作が入っており、これにより内部の関数を呼び分けます。各関数は、next_taskを書き換えたのち戻り値としてshared_ptr<SearchPartialResult>nullptrを返すようになっています。例えば、start_search関数は、探索に関するメンバ変数を初期化し、ルートノードを作成し、next_task = NextTask::ASSIGN_ROOT_EVALを実行し、ルートノードを評価するためのSearchPartialResultEvalRequestを返します。するとsearch_partial関数ではresult != nullptrとなるためループを抜け、呼び出し元にルートノードを評価するようリクエストが返ることになります。呼び出し元は局面を評価し、eval_resultに評価結果を代入した状態でsearch_partialを再度呼び出します。するとnext_task == NextTask::ASSIGN_ROOT_EVALとなっているため、次はルートノードの評価結果をルートノードに代入する処理が実行されます。

少々複雑な仕組みですが、単一スレッドで複数の処理を並行動作させる「コルーチン」の実装は似たようなものになるようです。C++20ではコルーチンをサポートする構文が導入されるため、これを利用すれば実装を簡略化できるかもしれません。

shared_ptr<SearchPartialResult> search_partial(const EvalResult *eval_result)
{
    shared_ptr<SearchPartialResult> result;
    do
    {
        switch (next_task)
        {
        case NextTask::START_SEARCH:
            result = start_search();
            break;
        case NextTask::ASSIGN_ROOT_EVAL:
            result = assign_root_eval(eval_result);
            break;
        case NextTask::SEARCH_TREE:
            result = search_tree();
            break;
        case NextTask::ASSIGN_LEAF_EVAL:
            result = assign_leaf_eval(eval_result);
            break;
        case NextTask::CHOOSE_MOVE:
            result = choose_move();
            break;
        }
    } while (!result);

    // 評価すべき局面として何を返したのか覚えておく
    prev_request = dynamic_pointer_cast<SearchPartialResultEvalRequest>(result);

    return result;
}

並行動作に関する部分だけ抽出しましたが、それでも長いです。

class SearchMCTSTrain : public SearchBase
{
public:
    // 探索完了(指し手決定時)か、局面の評価が必要になったときに返すデータ
    class SearchPartialResult
    {
    public:
        virtual ~SearchPartialResult() = default;
    };

    class SearchPartialResultMove : public SearchPartialResult
    {
    public:
        Move move;
        float score;
    };

    class SearchPartialResultEvalRequest : public SearchPartialResult
    {
    public:
        Board board;                             // 評価対象局面
        vector<pair<TreeNode *, int>> tree_path; // 探索木の経路。TreeNodeと、その中のエッジのインデックスのペア。leafがルートの場合は空。
        TreeNode *leaf;                          // 末端ノードのTreeNode。
    };

    class EvalResult
    {
    public:
        float value_logit;
        float policy_logits[BOARD_AREA];
    };

private:
    // 探索の次のタスクを表す
    enum NextTask
    {
        START_SEARCH,
        ASSIGN_ROOT_EVAL,
        SEARCH_TREE,
        ASSIGN_LEAF_EVAL,
        CHOOSE_MOVE,
    };
    NextTask next_task;
    int playout_count;

    TreeNode *root_node;

    shared_ptr<SearchPartialResultEvalRequest> prev_request;

public:
    void newgame()
    {
        tree_table->clear();
        root_node = nullptr;
        next_task = START_SEARCH;
    }

    // 局面の評価が必要か、指し手が決定するまで探索する
    shared_ptr<SearchPartialResult> search_partial(const EvalResult *eval_result)
    {
        shared_ptr<SearchPartialResult> result;
        do
        {
            switch (next_task)
            {
            case NextTask::START_SEARCH:
                result = start_search();
                break;
            case NextTask::ASSIGN_ROOT_EVAL:
                result = assign_root_eval(eval_result);
                break;
            case NextTask::SEARCH_TREE:
                result = search_tree();
                break;
            case NextTask::ASSIGN_LEAF_EVAL:
                result = assign_leaf_eval(eval_result);
                break;
            case NextTask::CHOOSE_MOVE:
                result = choose_move();
                break;
            }
        } while (!result);

        // 評価すべき局面として何を返したのか覚えておく
        prev_request = dynamic_pointer_cast<SearchPartialResultEvalRequest>(result);

        return result;
    }

private:
    shared_ptr<SearchPartialResult> start_search()
    {
        // 探索木の再利用をしないので、テーブルを初期化する。これによりテーブルのサイズは1手当たりのプレイアウト数+αだけで済む。
        tree_table->clear();
        playout_count = 0;
        return make_root(board);
    }

    shared_ptr<SearchPartialResult> make_root(const Board &b)
    {
        bool mate_found;
        // ルートノードを生成
        root_node = MCTSBase::make_node(b, tree_table.get(), config.mate_1ply, mate_found, mate_move);
        SearchPartialResultEvalRequest *req = new SearchPartialResultEvalRequest();
        req->board.set(b);
        req->leaf = root_node;
        next_task = NextTask::ASSIGN_ROOT_EVAL;
        // ルートノードを評価するようリクエストを返す
        return shared_ptr<SearchPartialResult>(req);
    }

    shared_ptr<SearchPartialResult> assign_root_eval(const EvalResult *eval_result)
    {
        assert(eval_result);
        assert(prev_request);
        // 前回のsearch_partialの戻り値で返したルートノードの評価結果がeval_resultに入っているので、それをルートノードに代入する。
        auto leaf = prev_request->leaf;
        assign_eval_result_to_leaf(leaf, eval_result);

        next_task = NextTask::SEARCH_TREE;
        prev_request = nullptr;
        return nullptr;
    }

    shared_ptr<SearchPartialResult> assign_leaf_eval(const EvalResult *eval_result)
    {
        // 前回のsearch_partialの戻り値で返した葉ノードの評価結果がeval_resultに入っているので、それを葉ノードに代入する。
        assert(eval_result);
        assert(prev_request);
        auto leaf = prev_request->leaf;
        assign_eval_result_to_leaf(leaf, eval_result);
        backup_path(prev_request->tree_path, leaf->score);
        prev_request = nullptr;
        next_task = NextTask::SEARCH_TREE;
        return nullptr;
    }

    float assign_eval_result_to_leaf(TreeNode *leaf, const EvalResult *eval_result)
    {
        // valueは、logitのためtanhで勝=1,負=-1に変換する
        leaf->score = tanh(eval_result->value_logit);
        assert(leaf->n_legal_moves);
        // policyは、logitが入っているためsoftmax計算が必要
        // 省略: leaf->value_pに各指し手のpolicyを代入

        return leaf->score;
    }

    shared_ptr<SearchPartialResult> search_tree()
    {
        if (playout_count >= config.playout_limit)
        {
            // playoutは終わり。指し手を決定する。
            next_task = NextTask::CHOOSE_MOVE;
            return nullptr;
        }

        playout_count++;
        auto result = search_root();
        if (result)
        {
            next_task = NextTask::ASSIGN_LEAF_EVAL;
        }
        else
        {
            next_task = NextTask::SEARCH_TREE;
        }
        return result;
    }

    shared_ptr<SearchPartialResult> search_root()
    {
        vector<pair<TreeNode *, int>> path;
        return search_recursive(board, root_node, path);
    }

    shared_ptr<SearchPartialResult> search_recursive(Board &b, TreeNode *node, vector<pair<TreeNode *, int>> &path)
    {
        if (node->terminal())
        {
            backup_path(path, node->score);
            return nullptr;
        }

        int edge = MCTSBase::select_edge(node, config.c_puct);
        UndoInfo undo_info;
        b.do_move(static_cast<Move>(node->move_list[edge]), undo_info);
        path.push_back({node, edge});
        int child_node_idx = node->children[edge];
        node->value_n[edge]++;
        shared_ptr<SearchPartialResult> result;
        if (child_node_idx)
        {
            result = search_recursive(b, tree_table->at(child_node_idx), path);
        }
        else
        {
            // 子ノードがまだ生成されていない
            bool mate_found;
            Move mate_move;
            TreeNode *child_node = MCTSBase::make_node(b, tree_table.get(), config.mate_1ply, mate_found, mate_move);
            node->children[edge] = tree_table->get_index(child_node);
            if (!child_node->terminal())
            {
                // この場でバックアップできず、局面評価が必要
                SearchPartialResultEvalRequest *req = new SearchPartialResultEvalRequest();
                req->board.set(b);
                req->leaf = child_node;
                req->tree_path = path;
                result = shared_ptr<SearchPartialResult>(req);
            }
            else
            {
                // 終局または詰みが見つかった場合
                backup_path(path, child_node->score);
            }
        }

        b.undo_move(undo_info);
        return result;
    }

    void backup_path(const vector<pair<TreeNode *, int>> &path, float leaf_score)
    {
        float score = leaf_score;
        for (int i = int(path.size()) - 1; i >= 0; i--)
        {
            score = -score;
            path[i].first->value_w[path[i].second] += score;
        }
    }

    shared_ptr<SearchPartialResult> choose_move()
    {
        Move move = MOVE_PASS;
        float score = 0.0F;
        // 省略: 訪問回数に比例した確率で指し手を選択する
        next_task = NextTask::START_SEARCH;
        auto result = new SearchPartialResultMove();
        result->move = move;
        result->score = score;
        return shared_ptr<SearchPartialResult>(result);
    }
};

MCTSを用いた対局実験

ここでは、実装したMCTSと既存の教師あり学習による評価関数を用いて対局した結果を示します。対局相手はランダムプレイヤーです。

MCTS探索ノード数 勝ち 引き分け 負け
1 78 1 21
2 82 2 16
4 79 3 18
8 91 2 7
16 93 2 5
32 95 0 5
64 98 0 2

探索ノード数を増やすと徐々に強くなることが確認できました。AlphaZero式の学習を行うにはさらにpython側での実装が必要なため、次回解説します。

第4回電竜戦TSEC指定局面戦の結果(2023/06/30)

NPO法人AI電竜戦プロジェクト主催 第4回電竜戦TSEC指定局面戦 に将棋AI「ねね将棋」で参加しました。この大会の特徴は、通常の初形からの対局開始ではなく、主催者が指定した局面から対局が開始するという点です。コンピュータ将棋ではあまり登場しない相振り飛車の局面や、棒玉戦法のようなジョーク局面もあります。

ねね将棋の中身は5月の選手権と変わりません。iPad上で思考しています。下記の技術的対応の章に書きましたが、コードの変更は一切不要でした。

select766.hatenablog.com

結果

予選は全30ソフトが参加しました。ねね将棋は20.8勝19.2敗で15位でした。

予選の上位4チームはファイナルに進出し、残りの24チーム(棄権あり)はB級リーグで対局になります。

B級リーグは4部門に分かれており、各6回戦(指定局面ごとに先手と後手を持つソフトを入れ替えて2回対局するため合計12対局)あります。

  • 【第一部相振り部門】 7勝5敗 8位
  • 【第二部ネタ部門】 6勝6敗 11位
  • 【第三部居飛車部門】 6.4勝5.6敗 11位
  • 【第四部対抗系部門】 5勝7敗 16位

対局内容としては、強豪やねうら王に引き分けを得たのがハイライトでしょうか。指定局面に対するコメントとして「角換わりは先手必勝とも言われている。」とあるので、後手のやねうら王がうまく千日手に誘導したというのが妥当な解釈と思われます。棋力差があることがわかっていれば、定跡を捨てて相手を詰ませに行ったほうがやねうら王としては得だったかもしれません。

https://denryu-sen.jp/denryusen/dr4_tsec/dist/#/dr4tsec+buoy_tokumei41_tesc4y1-3-top_48_neneshogi_yaneurao-120-2F+neneshogi+yaneurao+20230630225939

予選3回表 ねね将棋-やねうら王

持ち時間2分+1手ごとの加算2秒という短時間はハードウェア性能が低く読めるノード数が少ない、かつ合議で余計な時間がかかるねね将棋にとってかなり不利ですが、ちゃんと動作してよかったです。

運営、スポンサー、対局者の皆様ありがとうございました。

指定局面戦への技術的対応

主催者が局面を指定する技術的手段は、shogi-serverの"buoy"機能です。telnetでshogi-serverに入り、 %%SETBUOY コマンドを打つと局面が指定できます。

$ telnet localhost 4081
Trying ::1...
Connected to localhost.
Escape character is '^]'.
LOGIN master pass1 x1
LOGIN:master OK
##[LOGIN] +OK x1
%%SETBUOY buoy_foo-1500-0 +1716FU-1314FU
##[SETBUOY] +OK

この例では▲1六歩△1四歩を指した後の局面から対局が始まります。将棋所からは、サーバ通信対局(floodgateではないモード)で、パスワードに上記の buoy_foo-1500-0 を指定します。

将棋所から起動されるUSIエンジンでは、以下のように局面指定が来ます。

position startpos moves 1g1f 1c1d

つまりは通常の対局の途中から始まった形になるだけです。position sfen ...という形式では来ないので、私の場合は追加実装が不要でした。

対局後にログアウトしない拡張モード(floodgateモード)との組み合わせ方につきましては、私は理解しておりませんが、本番では連続対局のために使用されています。結果的には本番でトラブルなく連続対局ができました。

ユウカなりきりLINE Botを作った

技術書典14で発刊された書籍「LINE Botをつくってみよう ~APIを試して学んでしっかりわかる~」をなぞってLINE Botを作りました。ユーザの入力に対してChatGPTを使って応答を生成するのですが、書籍ではユーザの入力をそのままChatGPT (API)に流し込むだけなので普通にChatGPTを使うのと変わりません。そこで会話が面白くなるよう、ゲームのキャラクターになりきって回答する仕組みを加えて作ることにしました。

できたもの

スマホゲーム「ブルーアーカイブ」のキャラクター「ユウカ」になりきるボットを作りました。

ボットとのやりとり

コードはここにあります。残念ながら有料のAPIを使用する都合、動いているボットを公開することはできません。自分でデプロイしてください。

github.com

工夫点

なりきりをさせる

ChatGPTにキャラクターになりきった応答をさせる方法は、深津氏の「ChatGPTにギルガメッシュ王の人格を与えるには?」という記事が参考になりました。

note.com

これをもとに以下のようなプロンプトを作成しました。(解釈違いがあったらすみません)

あなたはChatbotとして、先生を補佐する女子高生であるユウカのロールプレイを行います。
以下の制約条件を厳密に守ってロールプレイを行ってください。

制約条件: 
* Chatbotの自身を示す一人称は、私です。
* Userを示す二人称は、先生です。
* Chatbotの名前は、ユウカです。
* ユウカは女子高生です。
* ユウカは皮肉屋です。
* ユウカの口調は丁寧語を使います。
* ユウカの口調は、「〜ですよ」「〜ください」「〜ですか?」など、丁寧な口調を好みます。
* ユウカはUserに好意を持っています。
* 一人称は「私」を使ってください。
* 応答は短めにしてください。

ユウカのセリフ、口調の例: 
* ようこそ、先生。今から反省会を始めます。あっ、どこに行くんですか?
* 先生。今日も全力であなたをアシストしますね。
* せ~ん~せ~い~。もう少し頑張ってください!
* 先生。今、先生の行動について言いたいことが34個あります。
* 先生。お仕事を始める前にまず、これからの目標と方向性を策定してください。

ユウカの行動指針:
* ユーザーの質問に理知的に答えてください。
* ユーザーに小言を言ってください。
* ユーザーに対して、ユウカの好意を示してください。

ChatGPTのAPIを呼び出す際は、上記のなりきりプロンプトを'role': 'system'で、ユーザからのメッセージを`'role': 'user'で与えるとうまくいきました。

answer_response = openai.ChatCompletion.create(
        model='gpt-3.5-turbo',
        messages=[{'role': 'system', 'content': '<上記のなりきりプロンプト>'}, {'role': 'user', 'content': '<ユーザからのメッセージ>'}]
    )

文脈を与える

書籍記載の方法では、会話の履歴は考慮されず、メッセージ1つごとに独立した会話として扱われてしまうため、複数のメッセージにわたる会話が成立しません。

文脈を考慮してくれない例

この例では、「冷たいのがいい」に対して前のメッセージが考慮されていない応答になっています。これだと面白くないので、簡易的に履歴を残すようにしました。ボットのプログラムはAWS Lambda上で動作するのですが、Pythonグローバル変数上に履歴を保存しておきます。そして、次のメッセージが来たら、ChatGPTのAPIに過去のメッセージを含めて呼び出します。

# チャット履歴を保持するためのリスト。lambda関数の生存期間内は文脈をもった会話ができる。
chat_history = []

@webhook_handler.add(MessageEvent, message=TextMessage)
def handle_message(event):

    # ChatGPTに質問を投げて回答を取得する
    question = event.message.text

    # 新しいチャットの冒頭にはなりきりプロンプトを付与する
    if len(chat_history) == 0:
        chat_history.append({'role': 'system', 'content': system_content})
    chat_history.append({'role': 'user', 'content': question}) # 今回のユーザの質問を付加

    answer_response = openai.ChatCompletion.create(
        model='gpt-3.5-turbo',
        messages=chat_history
    )
    answer = answer_response["choices"][0]["message"]["content"]

    chat_history.append({'role': 'assistant', 'content': answer}) # 次の会話のための履歴に追加

これで、記事冒頭のやり取りができるようになりました。

ボットとのやりとり

この方法は不完全で、数分で履歴が消えてしまいます。データベースに永続化するほうがより望ましいですが、ユーザにとっては何時間も前の会話をChatGPTは直前のものとして扱うことになるので、混乱する可能性もあります。

コスト

一人で使う範囲では、有料となるのはChatGPTのAPI呼び出しコストです。

使用している言語モデルgpt-3.5-turboで、https://openai.com/pricingによるとInput $0.0015 / 1K tokens、Output $0.002 / 1K tokensとなっています。トークン数はhttps://platform.openai.com/tokenizerで数えることができます。なりきりプロンプト部分が692トークンでした。APIの1回の呼び出しで、なりきりプロンプトと会話履歴がInputで、新たに生成される応答がOutputになります。会話の内容が短いとすると、なりきりプロンプトが大部分のコストを占めます。692トークンの入力は約$0.001=0.1円程度ということになります。そのため、1メッセージあたり0.1円程度のコストがかかると考えられます。

感想

抽象的なAIアシスタントでなく、性格のようなものを感じられるとチャットが楽しくなると感じました。私はChatGPTをプログラミングの補助などに使っているのですが、チャットボットは作ったことがなかったのでプログラミングの面でも経験になりました。書籍は180ページあり大変そうに見えますが、スクリーンショットが多用されて細かく解説されているためであり、非常に操作しやすかったです。発刊後すぐのため、各サービスの画面が解説通りであり特に楽でした。

今後の課題として、天気などの時事情報を与えることで毎日違う応答をしてくれると面白いと思います。

【コンピュータ将棋】賞金をいただきました

2023年5月3日に行われた世界コンピュータ将棋選手権にて、私のソフト「ねね将棋」が賞金を頂きました。

ねね将棋は部門「TMOQ(特大もっきゅ)様 提供 :ノートPC単体やタブレット単体での参加者の中で最上位者(TMOQ(特大もっきゅ)様本人を除く)」に該当し、賞金額は2万円です。ノートPC単体やタブレット単体での参加はほかにも何チームかあり、TMOQ様自身が強力なゲーミングノートPCを利用して最も上位となり、次点で私となりました。近い順位のチームはほかにもありますので、対戦カードの運による部分もあろうかと思われます。他のチームは私が知る限りノートPCで思考しているため、タブレット(iPad)を利用しつつ受賞できたことは、コストパフォーマンスの良さを示せたのではないかと思います。

TMOQの山下 隆久様、ありがとうございました!賞金は、今後発売されるであろうiPhone15の購入費に充てさせていただき、次回はそれを使って今回よりも強い将棋AIを見せられればと思っております。

なお強さですが、4月に計測したところ、floodgateでのレートは3918でした。

ねね将棋(WCSC33)のfloodgateレート

第33回世界コンピュータ将棋選手権の結果(2023/05/03)

2023年5月3日~5日にかけて開催された第33回世界コンピュータ将棋選手権に参加ソフト「ねね将棋」で参加しました。

今回のねね将棋はiPad上でNNUE型(計算コストの低い評価関数で大量の局面を読む)とDL型(計算コストの高い評価関数で少量の局面を読む)の合議に挑戦しました。今回注力した部分は特殊なコンパイル環境を整えるという現物合わせの要素が大きいですが、大きなテーマは、モバイル端末の性能をできるだけ生かしてどこまで強い将棋ソフトが作れるかを検証することです。技術的なことは以下の記事をご覧ください。

select766.hatenablog.com

select766.hatenablog.com

アピール文書

大会会場で設営した状態はこのようになります。iPadMacbookをLightningで接続して充電とインターネット共有を行い、Macから対局サーバへ有線LANで接続しています。iPad側で思考していますが画面は地味ですみません。

ねね将棋を大会会場で設営した状態

戦績

一次予選からの参加で、一次予選は28チーム中2位となりました。二次予選では一次予選から上がった10チームとシード18チームの合計28チームで争い、23位となり、ここで敗退しました。

対局内容

優勝争いと異なり、予選では棋力が拮抗する対局はかなり少ないです。その中で、ponkotsu戦、いちびん戦は盛り上がりました。

WCSC33 一次予選 ねね将棋-ponkotsu
WCSC33+L5_27-900-5F+nene+ponkotsu+20230503143529

WCSC33 一次予選 いちびん-ねね将棋

WCSC33+L7_26-900-5F+ichibin+nene+20230503162558

感想

一次予選では、思考エンジンがやねうら王ベースであり好成績を残すことができました。二次予選では、強力なマシンを利用しているチームが多く、さすがにiPadの性能では振るいませんでした。iPad上でのNNUE型エンジンのNPS(1秒間の探索局面数)が160万程度であった一方、クラウド上の強力なマシンを利用したソフトでは1億に達する場合もありました。私の目標は、一般家庭にあるようなハードでどこまで行けるかを検証することですので、それを見せるという点では十分な成績だと考えています。

合議システムの実装ミスでクラッシュ・反則負けになるチームがよくみられるため、ponderを実装しないなど保守的な実装にとどめました。その結果トラブルなくすべての対局を終えることができ、安心しました。

対局してくださった方、運営の皆様に感謝申し上げます。

ソースコードGithubで公開しています。動作させるにはMaciOS/iPadOS環境が必要です。

github.com

github.com

github.com

今後

iPad上でNNUE型とDL型を合議させるというコンセプトですが、現状のハードウェア性能のバランス上NNUE型がDL型より強く、またNNUE型はかなり完成されているため努力で改善できる余地が少なそうです。 DL型の計算に用いるNeural Engineの性能は新しい機種での向上幅が大きいことが期待できます。秋にiPhone15が発売され、その性能が良ければDL型の工夫によって棋力を高められるかもしれません。新機種の性能を確認してから来年の方針を考えたいと思います。