C_PUCTの調整

要約

 C_{PUCT}は2.5としたとき一番性能が良かった。

背景

 今までMiacisは探索の選択ステップにおいてScience版AlphaZeroと同様の係数C(s)を用いていた。

$$ a_t = \mathrm{argmax}_a \left( Q(s_t, a) + C(s) P(s, a) \frac{\sqrt{N(s)}}{1 + N(s, a)} \right) $$

$$ C(s) = \log\left(\frac{1 + N(s) + c_{base}}{c_{base}}\right) + c_{init} $$

 しかし実際にはQ(s_t, a)の代わりに違う値を用いているのもあり、係数も調整し直した方が良い可能性がある。

 さらに以前の記事

では、初期局面について探索を行うと探索回数が偏っていることがわかった。根拠のない感覚的な話だが、初期局面におけるPolicy,Valueの値の感じからするともう少し探索回数はバラける方が自然に思えたため、係数Cを現在の値よりも多少大きい値に変更して実験を行った。

実験

実験設定

 係数Cを変更してAlphaZeroと同様の強化学習を100kステップ行い、学習した際の値と同じCのまま技巧(深さ2)と検証対局を行った。

 係数Cとして次の3通りを試した。

  • C_s : AlphaZero(Science版)と同様の式
  • C_PUCT=2.5 : AlphaZero(Arxiv版)、AlphaGoZeroと同様にCを定数として扱い、その値を2.5としたもの
  • C_PUCT=5 : 上に同じで値を5としたもの

実験結果

検証損失

 floodgateの棋譜を用いた検証損失は次のようになった。

f:id:tokumini:20190527145905p:plainf:id:tokumini:20190527145907p:plain
検証損失 左:Policy損失 右:Value損失

 C_PUCT=2.5と5ではあまり差が見られないが、C_sではPolicyの損失があまり下がっていない。

学習損失

 普段学習損失は重視していないのだが、今回は顕著な差が見られたので学習損失も示す。

f:id:tokumini:20190527152838p:plainf:id:tokumini:20190527152841p:plain
学習損失 左:Policy損失 右:Value損失

 C_sにおけるPolicy損失の下がり方が急激過ぎるように見える。また学習損失が下がっている間にはあまり検証損失が下がっておらず、むしろ800kステップあたりで学習損失が上がりつつ検証損失が下がっていることから、良くない指し手に行動が偏ってしまっているのではないかと思われる。

検証対局

 技巧2(深さ2)と1手1秒で300局対局を行った結果は次のようになった。

係数 勝率 Eloレート差
C_s 51.2% +8.3
C_PUCT=2.5 64.5% +103.7
C_PUCT=5 56.7% +46.6

 C_PUCT=2.5が一番勝率が高かった。C_sと比べてEloレートに換算すると約+100ほどの伸びとなっている。

 初期局面の分布を見ると

係数 ▲2六歩(Policy) ▲7六歩(Policy) ▲2六歩(探索後) ▲7六歩(探索後)
C_PUCT=2.5 45.2% 48.9% 66.7% 32.9%
C_PUCT=5 47.7% 38.9% 59.1% 25.4%

となっており、ほぼ▲2六歩と▲7六歩で確率の大部分を占める形になっていた。バラけさせる効果が有効に働いていたかどうかはわからず、C_sとの性能差は上手く▲2六歩と▲7六歩を見つけられるかという偶然性による可能性もある。強化学習なのでそれぞれ1回の試行で結論付けるのも怖いところではあるが、一応今後はC_PUCT=2.5でやっていこうと思う。

AtCoder Beginner Contest 128

結果

 E問題までの5完。思ったよりレートが伸びて1級になった。

f:id:tokumini:20190527092602p:plain

A問題

 足す部分を分けてしまったが別に1行で済むなぁと思いながら直すのも面倒だったので。

#include"bits/stdc++.h"
using namespace std;
using ll = int64_t;

int main() {
    ll A, P;
    cin >> A >> P;
    P += 3 * A;
    cout << P / 2 << endl;
}

B問題

 restaurantの綴りに自信がなくて型の名前がRになった。構造体を作ってソートするやつって上手いことスニペットにできたりするんだろうか。

#include"bits/stdc++.h"
using namespace std;
using ll = int64_t;

int main() {
    ll N;
    cin >> N;

    struct R {
        ll P, index;
        string S;
    };
    vector<R> rs(N);
    for (ll i = 0; i < N; i++) {
        cin >> rs[i].S >> rs[i].P;
        rs[i].index = i + 1;
    }

    sort(rs.begin(), rs.end(), [](R& lhs, R& rhs) {
        return lhs.S != rhs.S ? lhs.S < rhs.S : lhs.P > rhs.P;
    });

    for (const R& r : rs) {
        cout << r.index << endl;
    }
}

C問題

 ネストが深くなって脳が混乱した。計算量もちゃんとは考えてないけどC問題ならこれで通るだろうというメタ読みをするなど。

#include"bits/stdc++.h"
using namespace std;
using ll = int64_t;

int main() {
    ll N, M;
    cin >> N >> M;
    vector<ll> k(M), p(M);
    vector<vector<ll>> s(M);
    for (ll i = 0; i < M; i++) {
        cin >> k[i];
        for (ll j = 0; j < k[i]; j++) {
            ll t;
            cin >> t;
            s[i].push_back(t - 1);
        }
    }
    for (ll i = 0; i < M; i++) {
        cin >> p[i];
    }

    ll ans = 0;
    for (ll i = 0; i < (1 << N); i++) {
        bool ok = true;
        for (ll j = 0; j < M; j++) {
            ll num = 0;
            for (ll k : s[j]) {
                if (i & (1 << k)) {
                    num++;
                }
            }
            if (num % 2 != p[j]) {
                ok = false;
                break;
            }
        }

        if (ok) {
            ans++;
        }
    }

    cout << ans << endl;
}

D問題

 ちょっと悩んだけど操作が4種類というのが多いのでいくらか不要になりそうということを考えてみると手持ちのものを詰めていくのは最後にまとめて捨てるのと同義になるし、そうなると左右からどれだけ取るかの全探索で終わり。もう少し上手い実装はありそう。かなり力技っぽい書き方になってしまった。最後マイナスの要素を捨てていくところでこんなにif文まみれにしてしまうなんて。

#include"bits/stdc++.h"
using namespace std;
using ll = int64_t;

int main() {
    ll N, K;
    cin >> N >> K;
    vector<ll> V(N);
    for (ll i = 0; i < N; i++) {
        cin >> V[i];
    }

    ll ans = 0;
    for (ll i = 0; i <= K; i++) {
        for (ll j = 0; j <= K - i; j++) {
            vector<ll> copy = V;
            vector<ll> picked;
            for (ll k = 0; k < i; k++) {
                if (copy.empty()) {
                    break;
                }
                picked.push_back(copy.front());
                copy.erase(copy.begin());
            }
            for (ll k = 0; k < j; k++) {
                if (copy.empty()) {
                    break;
                }
                picked.push_back(copy.back());
                copy.pop_back();
            }

            sort(picked.begin(), picked.end());
            ll tmp = 0;
            ll rem = K - i - j;
            for (ll e : picked) {
                if (e >= 0) {
                    tmp += e;
                } else {
                    if (rem > 0) {
                        rem--;
                    } else {
                        tmp += e;
                    }
                }
            }

            ans = max(ans, tmp);
        }
    }

    cout << ans << endl;
}

E問題

 最初は遅延セグメント木に投げればなんとかなったりしないかと手を抜きたい思考が出ていたけど、ちゃんと考えて解説PDFそのままな感じの思考過程でなんとかなった。

 本番では不要な部分が多い汚いコードを書いてしまったので書き直し()。最初は工事だけでソートできなきゃいけないんじゃないかとかいろいろ迷走していたので無用な構造体ばかりになっていた。setも値を引数にとっての削除ができるんだからpriority_queuemapを使って管理するなんてことをしなくて良かったんだった。種々の制約のおかげでかなり書きやすいし解いてて楽しい問題。

#include"bits/stdc++.h"
using namespace std;
using ll = int64_t;

int main() {
    ll N, Q;
    cin >> N >> Q;

    enum Type {
        STOP_END, STOP_START, WALK_START
    };
    struct Query {
        Type type;
        ll time, pos;
        bool operator<(const Query& rhs) const {
            return (time != rhs.time ? time > rhs.time : type > rhs.type);
        }
    };
    priority_queue<Query> pq;

    for (ll i = 0; i < N; i++) {
        ll S, T, X;
        cin >> S >> T >> X;
        pq.push({ STOP_START, S - X, X });
        pq.push({ STOP_END,   T - X, X });
    }

    for (ll i = 0; i < Q; i++) {
        ll D;
        cin >> D;
        pq.push({ WALK_START, D, -1 });
    }

    set<ll> st;
    while (!pq.empty()) {
        auto top = pq.top();
        pq.pop();

        if (top.type == STOP_START) {
            st.insert(top.pos);
        } else if (top.type == STOP_END) {
            st.erase(top.pos);
        } else {
            cout << (st.empty() ? -1 : *st.begin()) << endl;
        }
    }
}

F問題

 45分くらい残った状態で入れたのでなんとか通したかったが、ダメだった。中央から見て対称な位置を踏むような経路は取れそうとは思っていたけど詰めきれなかった。解説を読んでもよくわからず、他の人のコードを読んでなんとか通るコードは書いたがまた微妙に理解が怪しい。r < Cだったらbreakという処理を入れないとBが負の場合を考慮してしまうあたりを今ようやくわかり始めてきた。

 下の実装、実はN回のループ内でvector<bool> (N, false)を初期化しているんだけど108msで普通に通る。こういうものの初期化なら問題ないのか。上手くやるには訪問かどうかのフラグvector<ll> (N)を使ってCを書き込んでいき、いま訪問したものがCと一致していないかどうかを判定すれば良いようだ。

#include"bits/stdc++.h"
using namespace std;
using ll = int64_t;

int main() {
    ll N;
    cin >> N;
    vector<ll> S(N);
    for (ll i = 0; i < N; i++) {
        cin >> S[i];
    }

    auto isOK = [N](ll x) {
        return 0 <= x && x < N;
    };

    ll ans = 0;
    for (ll C = 1; C < N; C++) {
        vector<bool> visit(N, false);
        ll l = 0, r = N - 1;
        ll score = 0;
        while (true) {
            l += C;
            r -= C;
            if (!isOK(l) || !isOK(r) || visit[l] || visit[r] || l == r || r < C) {
                break;
            }
            visit[l] = visit[r] = true;
            score += S[l] + S[r];
            ans = max(ans, score);
        }
    }

    cout << ans << endl;
}

AtCoder Beginner Contest 127

結果

 E問題までの5完で311位。パフォーマンスは1832でレート変動は1752→1760(+8)だった。E問題を解けてそこそこかなと思ったけど、パフォーマンスは思ったより低かった。慣れている人にとってはFが簡単だったらしいという影響もあったのかもしれない。

A問題

 普通にif文で分岐。coutを並べるのがダサく思えるお年頃なので三項演算子を使いたくなってしまうが、3つ以上に分かれるときは混乱しがちなのでこれでいいと思う。

#include"bits/stdc++.h"
using namespace std;
using ll = int64_t;

int main() {
    ll A, B;
    cin >> A >> B;
    if (A <= 5) {
        cout << 0 << endl;
    } else if (A <= 12) {
        cout << B / 2 << endl;
    } else {
        cout << B << endl;
    }
}

B問題

 語ることなし。

#include"bits/stdc++.h"
using namespace std;
using ll = int64_t;

int main() {
    ll r, D, x;
    cin >> r >> D >> x;
    for (ll i = 1; i <= 10; i++) {
        x = r * x - D;
        cout << x << endl;
    }
}

C問題

 パッと見で「左端の最大値と右端の最小値だけ考えれば良い」と判断したけど確信が持てなくて理屈を考えているうちに少し時間がたって、WA出てから考えようということで証明しきれないまま提出。負になる場合にそのまま出力してしまっていたので1WA。すぐ気づいて修正できたけど、ジャッジが詰まっていたりしたらちょっとまずかったかも。

#include"bits/stdc++.h"
using namespace std;
using ll = int64_t;

int main() {
    ll N, M;
    cin >> N >> M;
    vector<ll> L(M), R(M);
    for (ll i = 0; i < M; i++) {
        cin >> L[i] >> R[i];
    }

    ll ans = *min_element(R.begin(), R.end()) - *max_element(L.begin(), L.end()) + 1;
    cout << max(ans, (ll)0) << endl;
}

D問題

 とりあえず問題文を読んだ段階で順番は関係なさそうと思ったので配列も操作列もソートして、あとは小さい方から貪欲に処理していって良さそうという感覚を信じて出して通った。std::pairの使用をすぐ避けてしまう。

#include"bits/stdc++.h"
using namespace std;
using ll = int64_t;

int main() {
    ll N, M;
    cin >> N >> M;
    vector<ll> A(N);
    for (ll i = 0; i < N; i++) {
        cin >> A[i];
    }

    struct Op {
        ll B, C;
    };
    vector<Op> ops(M);
    for (ll i = 0; i < M; i++) {
        cin >> ops[i].B >> ops[i].C;
    }

    sort(A.begin(), A.end());
    sort(ops.begin(), ops.end(), [](Op& lhs, Op& rhs) {
        return lhs.C > rhs.C;
    });

    ll index = 0;
    for (const Op& op : ops) {
        for (ll i = 0; i < op.B; i++) {
            if (A[index] < op.C) {
                A[index++] = op.C;
            } else {
                break;
            }
        }
    }

    cout << accumulate(A.begin(), A.end(), (ll)0) << endl;
}

E問題

 制約を完全にN, M \le 2 \times10^{5}と勘違いしていた。それだと K \le N \times Mの\という制約もとんでもないことになるんだけど、そこも見間違えていてO(N + M + K)で解く問題だと思い込んで解いていた。

 まず2駒の配置だけを考えて最終的にそれを{}_{NM - 2}C_{K - 2}倍すれば良いということにはすぐ気づく。なので2駒の配置についてのコストの和をO(N)で求める問題だと思った。しかしこれが結構難しい。

 2駒について片方の駒を固定してもう片方の駒を自由に動かすと、固定している方の上下左右について「「公差1の等差数列の和」を公差とする等差数列の和」を考えれば式が立つ(合ってるかは知らない)。

f:id:tokumini:20190526092451p:plain

f:id:tokumini:20190526092539j:plain

 つまり片方を固定した場合についてはO(1)で求まることはわかったが、固定するマスの位置を全探索するとO(NM)なので制約の勘違いによりダメだと思い込んでいた。

 なんとかして計算量を落とすフェーズに入る。理屈から言えば上の式を展開してなんやかんや公式をぶちこめば総和自体もO(1)で求められそうだが上の式を展開するのは地獄なのでやりたくない。こういう場合はたいてい差分計算すればなんとかなると信じて、固定マスを列方向に1ずらしたときを考えていく。

f:id:tokumini:20190526092819j:plain

すると直前までいた列までの数字は全て+1になって、それより右は全て-1になることに気づく。つまり今i列目にいるとするとNi - N(M-i)が差分になる。差分がiに関する1次式になっているので、i回ずらしたときの値はiに関する2次式になる。それの総和を求めることは二乗の総和を計算する公式をググってO(1)で計算可能。

f:id:tokumini:20190526092923j:plain

 結局一番下の式(Nが抜けていてxを足す数が1回少ないので正確には \displaystyle Mx + N\sum_{i = 1}^{M - 1} i^2 - N(M - 1)\sum_{i}^{M - 1}i)を計算すればよい。駒が左端にいるときの初期値xだけは頑張ってO(1)で求めれば、それを右にずらしていった結果もこの式でO(1)で求まるので、後は初期位置を各行についてO(N)で考えれば良い。

 2駒について各位置関係を2回足しているので2で割って、あとは{}_{NM - 2}C_{K - 2}倍して、ようやく答え。結構難しいと思ったんだけど、ただの制約見間違えだったとは……。

#include"bits/stdc++.h"
using namespace std;
using ll = int64_t;

constexpr ll MOD = 1e9 + 7;

ll MODpow(ll n, ll m) {
    ll result = 1;
    while (m) {
        if (m % 2 == 1) {
            result *= n;
            result %= MOD;
        }

        m /= 2;
        n *= n;
        n %= MOD;
    }

    return result;
}

int main() {
    ll N, M, K;
    cin >> N >> M >> K;

    ll ans = 0;
    for (ll i = 0; i < N; i++) {
        ll tmp = 0;
        ll x = 0;
        (x += i * (i + 1) / 2 % MOD) %= MOD;
        (x += (N - i - 1) * (N - i) / 2 % MOD) % MOD;
        ll sum = M * (M - 1) / 2 % MOD;
        ll sum_u = ((sum + M - 1) + (sum + i * (M - 1))) * i / 2 % MOD;
        ll sum_d = (sum + (sum + (N - i - 1) * (M - 1))) * (N - i) / 2 % MOD;
        (x += sum_u) %= MOD;
        (x += sum_d) %= MOD;

        (tmp += M * x % MOD) %= MOD;

        ll pow2 = (M - 1) * M * (2 * M - 1) / 6 % MOD;
        (tmp += N * pow2 % MOD) %= MOD;

        ll sub = (M - 1) * (M - 1) * M / 2 % MOD * N % MOD;
        tmp = (tmp + MOD - sub) % MOD;

        (ans += tmp) %= MOD;
    }
    (ans *= MODpow(2, MOD - 2)) %= MOD;

    for (ll i = 0; i < K - 2; i++) {
        (ans *= N * M - 2 - i) %= MOD;
    }
    for (ll i = 1; i <= K - 2; i++) {
        (ans *= MODpow(i, MOD - 2)) %= MOD;
    }

    cout << ans << endl;
}

F問題

 本番中はE問題で時間と体力を奪われたのもあってあまりまともに考察できなかった。中央値を見ればよいというのにも気づいていなかったし、中央値はpriority_queue2本で管理できるという話も知らなかった。解説PDFを読んで、確かにそんな感じで解けそうということで適当に書いて通した。AtCoderしかやってないとこういう典型? があんまり身についていない説がある? よくわからないけどこういう問題が出てくれるならそれはそれで楽しい。

#include"bits/stdc++.h"
using namespace std;
using ll = int64_t;

int main() {
    ll Q;
    cin >> Q;
    
    priority_queue<ll> left_pq;
    priority_queue<ll, vector<ll>, greater<>> right_pq;
    left_pq.push(LLONG_MIN);
    right_pq.push(LLONG_MAX);

    ll sum_b = 0;
    ll min_v = 0;

    for (ll i = 0; i < Q; i++) {
        ll op;
        cin >> op;
        if (op == 1) {
            ll a, b;
            cin >> a >> b;
            sum_b += b;

            if (left_pq.size() == right_pq.size()) {
                if (a < left_pq.top()) {
                    min_v += left_pq.top() - a;
                }
                if (a > right_pq.top()) {
                    min_v += a - right_pq.top();
                }
            } else {
                min_v += abs(left_pq.top() - a);
            }

            if (a >= right_pq.top()) {
                right_pq.push(a);
            } else {
                left_pq.push(a);
            }

            if (right_pq.size() > left_pq.size()) {
                left_pq.push(right_pq.top());
                right_pq.pop();
            } else if (left_pq.size() > right_pq.size() + 1) {
                right_pq.push(left_pq.top());
                left_pq.pop();
            }
        } else {
            cout << left_pq.top() << " " << min_v + sum_b << endl;
        }
    }
}

MCTSにおける価値の漸進的更新

結論

 MCTSの行動価値を漸進的に更新する実装で、総和を保持して平均化する実装と同程度の性能を達成できた。

背景

 以前、MCTSにおいて行動価値を漸進的に更新する方法について記事を書いたが、性能が悪化してしまった。この記事で述べた通り、原因はおそらくバーチャルロスを探索した回数Nへ直接足しており、正しい探索回数になっていないためだと思われる。よってバーチャルロスを探索した回数Nとは別に保持するように変更した。

実装

置換表エントリ

 各行動についてバーチャルロスを保持する変数を置換表エントリに加える。単純にこの変数を増やしただけだと使用メモリ量が増えてしまうが、行動価値を漸進的に更新する場合、子孫ノードの価値の総和Wを保持する必要がなくなる。結局使用メモリ量はほぼ同じとなる。置換表エントリの主要なデータは次のようになる。std::vectorで保持されているもののサイズは合法手の数と一致する。

struct UctHashEntry {
    int32_t sum_N;                    //この局面から探索した回数
    int32_t virtual_sum_N;            //バーチャルロスの合計
    std::vector<Move> moves;          //この局面の合法手
    std::vector<Index> child_indices; //子局面の置換表におけるインデックス
    std::vector<int32_t> N;           //各行動を探索した回数
    std::vector<int32_t> virtual_N;   //各行動へのバーチャルロス
    std::vector<CalcType> nn_policy;  //ニューラルネットワークの出力Policy
    ValueType value;                  //状態価値:漸進的に更新される
};

行動価値の計算

 行動価値は次状態の価値を参照することで得る。次状態の価値を参照する際に、手番が変わるので反転させる必要があることに注意。

ValueType Searcher::QfromNextValue(const UctHashEntry& node, int32_t i) const {
    return -hash_table_[node.child_indices[i]].value;
}

選択ステップ

 選択ステップでは探索回数Nとバーチャルロスの和を用いてUCB値を計算する。

int32_t Searcher::selectMaxUcbChild(const UctHashEntry& node) {
    constexpr double C_PCUT = 1.0;
    const int32_t sum = node.sum_N + node.virtual_sum_N;

    int32_t max_index = -1;
    double max_value = MIN_SCORE - 1;
    for (int32_t i = 0; i < node.moves.size(); i++) {
        int32_t visit_num = node.N[i] + node.virtual_N[i];

        double Q = QfromNextValue(node, i);
        double U = std::sqrt(sum + 1) / (visit_num + 1);
        double ucb = Q + C_PUCT * node.nn_policy[i] * U;

        if (ucb > max_value) {
            max_value = ucb;
            max_index = i;
        }
    }
    return max_index;
}

バックアップ

 バックアップ時にバーチャルロスを0へと戻していく。実際の探索回数であるNやその総和は1足すだけで良い。ここで価値を漸進的に更新する。更新幅は探索回数 + 1の逆数となる。

void SearcherForPlay::backup(std::stack<int32_t>& indices, 
                             std::stack<int32_t>& actions) {
    int32_t leaf = indices.top();
    indices.pop();
    float leaf_value = hash_table_[leaf].value;

    while (!actions.empty()) {
        int32_t index = indices.top();
        indices.pop();
        int32_t action = actions.top();
        actions.pop();
        UctHashEntry& node = hash_table_[index];

        //手番が変わるので価値を反転
        leaf_value = MAX_SCORE + MIN_SCORE - leaf_value;

        //探索回数の更新
        node.N[action]++;
        node.sum_N++;
        node.virtual_sum_N -= node.virtual_N[action];
        node.virtual_N[action] = 0;

        //漸進的更新
        float alpha = 1.0f / (node.sum_N + 1);
        node.value += alpha * (leaf_value - node.value);
    }
}

気づいたこと

 以前の実装のようにバーチャルロスを直接Nに足していると、同じバッチ内で次にそのノードへ到達したときNが本来よりも大きくなっているのでQ = \frac{W}{N}が小さく評価されてしまう。今回のようにバーチャルロスを別に保持することでこの欠点も直すことができる。もっともこの欠点は探索回数が多くなれば無視できるほど小さい影響しかもたらさないだろうし、むしろ同じノードが選ばれにくくなるため良い効果があるかもしれない。

実験結果

 漸進的な更新へと変えた実装の探索部を用いて技巧2(深さ1)と1手1秒で200局対局させた。

探索手法 勝率
総和を保持(従来の実装) 81.0%
漸進的更新(今回の実装) 86.5%

 誤差の範囲内かもしれないが、漸進的更新の方がやや強くなる結果となった。強くなる要因としては、同一局面へ違う進行で合流した際に良い価値を返すことができている可能性が考えられる。従来の実装では保存しておいたニューラルネットワークの出力のValueをそのままバックアップしていくことになるが、今回の実装では漸進的に更新をしているため単なるニューラルネットワークの出力値よりは良い推定値になっていると考えられる。しかしそこまで大幅な性能向上でもなく、弱くなっていないことが確認できたことが重要となる。

補足

 置換表エントリにはバーチャルロスをint32_t型で確保したが、この変数は最大でも探索バッチ数までしか増えないので4Byteも使う必要はない。1Byteでは不安だが、2Byteあれば十分だと思われる。そうすることで従来の実装では価値の総和として4Byteの浮動小数点型を確保する必要があったのに対して多少のメモリ消費量削減となる。

 (個人的に)もっと重要な点は、Categorical分布を保持するときのメモリ消費量が抑えられることである。Miacisの手法では51分割していたためWを保持するために通常のAlphaZeroよりも約51倍のメモリが必要になっていた。これにより秒数制限よりも置換表のサイズ制限の方へ速く到達してしまうような状況だったのだが、今回の実装によって使用メモリ量がだいたい1/50となったため問題なくなった。今までは学習部においてもメモリ消費量の問題からActorの並列数を増やせなかったのだが、今後は並列数を増やすことで多少データ生成速度が上がるのではないかと思われる。

Policyの教師信号を分布にする

要約

 Policyの教師信号を探索回数の正規化した分布とした方が性能が向上した。

背景

 AlphaZero型の学習においてPolicyの教師信号にはルートノードから各行動について探索した回数をその総和で割った分布を利用している。MiacisではCPUのメモリ容量が足りないかと思って、実際に取った行動をOnehotベクトルとしたものを教師信号としていた。しかしちゃんと計算してみると問題はなかった。

 具体的には、Miacisでは通常2^{20} (=1M)局面分のサイズを持つリプレイバッファを用いて学習をしている。1局面分のPolicyの教師データが、今までは指した指し手のラベルを一つ持っていたので4Byteであったのが、合法手の数だけラベルとその行動の確率のペアとして保持すると合法手の数 × 8Byteとなる。合法手が平均100手とすると増加分は800MByteとなる。Policyの出力次元(81 × 27 = 2187)全て保持しておかなければならないかと思いこんでいたが、合法手のところだけ持っておき学習する時点で2187次元ベクトルに直せば良いだけの話であった。

実験

実験設定

 前回と同じ。

実験結果

 floodgateの棋譜に対する損失は次のようになった。教師信号を分布としたものがpropotionalである。

f:id:tokumini:20190519134937p:plainf:id:tokumini:20190519134943p:plain
左:Policy損失 右:Value損失

 技巧2(深さ1)と1手1秒で200局対局させた結果は次のようになった。

教師信号 勝率
onehot 79.0%
propotinal 81.0%

 ほとんど変わらないようにも思えるが、Policyの教師信号を探索回数を正規化した分布とした方が多少良い結果のように思える。

 また初期局面でのPolicyの出力分布を示す。

順位 onehot propotional
1 ▲2六歩(18.1%) ▲2六歩(32.2%)
2 ▲3八飛車(13.9%) ▲7八金(22.0%)
3 ▲5八玉(10.8%) ▲5八玉(17.0%)
4 ▲5八飛車(9.7%) ▲5八飛車(9.0%)
5 ▲2六歩(7.8%) ▲5六歩(6.0%)
その他 39.7% 13.8%

 教師信号を分布としたほうが手がばらけるかと思ったがこと初期局面に関しては単純にそうなるわけではなかった。またpropotinalは技巧との対局では中飛車が多くなったのだが、特にその傾向もこのPolicyからだけでは見られない。

 1秒探索した後の探索回数を正規化した分布についても調べた。

順位 onehot propotinal
1 ▲5八玉(52.5%) ▲7八金(74.0%)
2 ▲4八銀(21.2%) ▲2六歩(11.0%)
3 ▲7六歩(17.5%) ▲5八玉(5.5%)
4 ▲3八飛車(3.3%) ▲5八飛車(5.3%)
5 ▲5八飛車(0.9%) ▲5六歩(1.3%)
その他 4.6% 2.9%

 propotinalの▲7八金の後は読み筋で▲5八飛車と出るので、実質80%ほどが中飛車となると予想され、これは対局を見ていて受けた印象とも近い。

 また両者ともPolicyの偏りに比べて探索結果の偏りが大きすぎるのではないかと思われる。これはC(s)の値が小さいためだと考えられ、以前上げた記事では、AlphaZeroはValueを[0, 1]の範囲に直して探索しているという情報があり、そのままのC(s)では[-1,1]の範囲で探索しているMiacisにとっては小さすぎるのかもしれない。二つパラメータを調整するのは大変なのでC_{PUCT}に戻して実験してみたい。

バッチサイズとステップあたりの学習速度の関係〜強化学習編〜

結論

 強化学習でもバッチサイズとステップあたりの学習速度は比例しそうだ。あるデータ生成速度に対して学習可能な範囲でバッチサイズを上げていくことが学習の高速化に繋がるかもしれない。

前書き

 前回教師あり学習において、バッチサイズとステップあたりの学習速度には比例関係があるのではないかという結果を得た。今回は強化学習でもこの関係が成り立つかどうかを検証していく。

実験

実験設定

 Miacis(categorical)でAlphaZero方式の学習を行った。

項目 内容
バッチサイズ 32と64の二通り
ステップ数 200,000
リプレイバッファサイズ 2^{20}局面
学習率の初期値 0.01
学習率の減衰 100,000ステップ後に1/10
データ生成速度 1ステップあたり約30局面

実験結果

 floodgate2016年の棋譜を用いて計算した検証損失のグラフを次に示す。

f:id:tokumini:20190516152716p:plain

 今までの結果から損失はあまり棋力に相関しないと思われるが、このグラフにおいてもバッチサイズが大きいと学習が速いことがわかる。

 バッチサイズ32,64で学習されたパラメータを用いて技巧2Depth1と1手1秒で200局対局を行い、性能を評価した。

パラメータ 技巧2(D1)に対する勝率 相対Eloレート
バッチサイズ32 200kステップ時点 52.5% +17.4
バッチサイズ64 100kステップ時点 49.0% -6.9
バッチサイズ64 200kステップ時点 79.0% +230.2

 バッチサイズ32で200kステップ学習したパラメータの方がバッチサイズ64で100kステップ学習したパラメータよりやや強かったが、ほぼ同等と言える性能であった。そしてバッチサイズ64で200kステップ学習したパラメータの方がより高い性能となった。やはりバッチサイズが大きい方がステップあたりの学習速度が上がっているように思われる。

考察

 前回、計算時間がバッチサイズによらない条件として次の三つを導入した。

  1. Actorの数を\frac{1}{n}倍するならば、バッチサイズを\frac{1}{n}倍しなければならない
  2. バッチサイズを\frac{1}{n}倍するならば、ステップ数をn倍しなければならない
  3. バッチサイズによらず1ステップあたりにかかる時間は一定

 Learnerを1ステップごとにスリープさせている都合上(3)はほぼ成り立ってしまい、前回および今回の実験結果から(2)も成り立ちそうだが、(1)は多少制限が緩い可能性がある。

 まずAlphaZeroの1ステップあたりのデータ生成量を計算すると、以前山岡さんのブログで34285.71 ゲーム/チェックポイントと計算されていた。チェックポイントは1000ステップごとなので34.28571 ゲーム/ステップであり、AlphaZeroは早期に投了するようにしていることから1ゲーム平均112.5手(根拠の詳細は後述)とすると3857 局面/ステップとなる。個人的には、この値がバッチサイズ4096となかなか近い値であると判断できるのではないかと感じる。つまりだいたい1ステップあたりの生成量≒バッチサイズを目安として、バッチサイズあるいはスリープ時間を決定するのが良いのではないか。

 そして今回の実験では30局面/ステップほどの生成速度のままバッチサイズを64にしても(レートにして-25ほどの悪影響はあったかもしれないが)ほぼ問題なく学習できている。以前同じ設定でバッチサイズ4096にした場合上手く学習できなかったのでいくらでも大きくできるということはないと思われるが、できるだけ大きくすることで多少学習速度を上げることができる可能性はある。

補足:AlphaZeroの1ゲームあたりの平均手数

 AlphaZeroの論文のSupplementary MaterialsにあるTable S3に生成した総ゲーム数と1局面あたりの思考時間が記載されている。

f:id:tokumini:20190516155854p:plain

 24,000,000ゲームを5000基、12時間、1局面の思考時間約0.08秒で生成したことから1ゲームの平均手数は

\displaystyle 5000 \times 12 \times 3600 \div 0.08 \div 24000000 = 112.5

と求まる。

バッチサイズとステップあたりの学習速度の関係

要約

 バッチサイズとステップあたりの学習速度は比例関係にある(?)ため、強化学習の高速化としてバッチサイズを小さくすることは意味がない可能性がある。

前書き

 前回はLR Range Testによる学習率の決定法について書いた。これをもとに複数のバッチサイズで教師あり学習の実験を行い、そこからAlphaZeroアルゴリズムでのバッチサイズを検討したい。

実験

実験設定

  • データ
    floodgate2015年~2018年の棋譜から両対局ソフトのレートが2800以上、かつ手数が50手以上のもの計10,939,651局面(そのうち9割を学習データ、残りを検証データとして使用)
  • バッチサイズ
    4096, 512, 64, 32の4通り
  • 損失
    Policy損失とValue損失の1:1での和
  • 学習率減衰
    1エポックごとに検証データ全てを使って損失を計算し、前回までの検証損失の最小値より小さくなっていなければ学習率を0.9倍
  • 学習終了
    最小値を更新できなかったことが6回続いたタイミングで学習を打ち切り
  • 学習率の初期値
    各バッチサイズについて学習率の初期値をLR Range Testにより決定
バッチサイズ 学習率の初期値
4096 0.03
512 0.025
64 0.015
32 0.01

実験結果

 横軸にエポック数を取ったグラフを次に示す。

f:id:tokumini:20190515112200p:plain

 各バッチサイズについて損失最小値を次に示す。

バッチサイズ エポック 損失 Policy損失 Value損失
4096 44 2.18 1.76 0.422
512 38 2.14 1.73 0.413
64 40 2.12 1.71 0.410
32 62 2.11 1.71 0.405

 バッチサイズは小さい方が汎化性能が上がるという話1と一致した(この話には反論もあるようだが2)。バッチサイズ32のときだけ損失が最小となる際のエポック数が大きいが、グラフを見ると40エポックほどでもほとんど同程度の損失値になっており、同程度の性能に至るまでに必要なエポック数は同等と見なすことができると考えられる。

 バッチサイズによらず同じエポック数ではほぼ同等の性能と仮定すると、バッチサイズを\frac{1}{n}倍したとき1エポックあたりのステップ数はn倍になるため、ステップ数あたりの学習速度は\frac{1}{n}倍になるということになる。わかりやすく横軸にステップ数を取ったグラフを次に示す。

f:id:tokumini:20190515112216p:plain

考察:強化学習における学習時間

 強化学習でのステップ数と教師あり学習でのステップ数を同一視して良いものかはわからないが、とりあえず同一視できるものとする。今回の実験からは、バッチサイズを\frac{1}{n}倍した場合n倍のステップ数が必要になる可能性が示唆されている。

 AlphaZeroのようなActorとLearnerを並列に動かす手法では、バッチサイズとデータ生成速度のバランスが重要であると勝手に思っている(もちろんActorの数は可能ならば多ければ多いほど良い3)。AlphaZeroは第1世代TPUを5000基用いてデータ生成を行っているが、たとえばこれの\frac{1}{100}倍である50基しかマシンを用意できなかった場合に、バッチサイズを4096のまま学習させると同じデータを過剰に何度も学習することになりかねない。おそらくバッチサイズもActorの減る割合に合わせて\frac{1}{100}倍するのが良いのではないかと思われる(ここが線形かどうかは不明)。

 しかし今回の結果ではバッチサイズを\frac{1}{100}倍すると同じ性能に至るまで100倍のステップ数がかかることになる。バッチサイズによらず1ステップあたりにかかる時間が一定であるとすると100倍の時間がかかることになる。

 結局、次の三つの仮定

  1. Actorの数を\frac{1}{n}倍するならば、バッチサイズを\frac{1}{n}倍しなければならない
  2. バッチサイズを\frac{1}{n}倍するならば、ステップ数をn倍しなければならない
  3. バッチサイズによらず1ステップあたりにかかる時間は一定

を認めるならば、Actor-Learner型の手法で一定の性能を満たすのにかかる実時間はバッチサイズによらないことになる。バッチサイズをいじることによる小手先の高速化などは考えない方が良さそうだ。

補足

 3つ目の仮定について補足を加えると、当然ながらバッチサイズが大きい方が1ステップ学習するのにも時間がかかる。実際に今回の実験(RTX 2080ti)では以下のようであった。

バッチサイズ 1ステップあたりにかかる時間(秒)
4096 0.378
512 0.039
64 0.011
32 0.010

 しかしActor-Learner型の強化学習で、Learnerを1ステップごとに数秒スリープさせることで擬似的にActorの数を増やす場合、無視できる差になると思われる。Actorの数が少ない(計算資源が足りない)環境では1ステップあたりにかかる時間の差がそこまで効いてくることはなさそうだ。

LR Range Testによる学習率の決定

要約

 LR Range Testを行って損失が最小となるときの学習率を初期値として決定して良さそう。

前書き

 山岡さんの『ディープラーニングによる将棋AIの作り方3』を読んでいて、floodgateの2017年、2018年の棋譜http://wdoor.c.u-tokyo.ac.jp/shogi/x/から入手できることを知った。2015年から2018年までの棋譜をダウンロードし、R2800以上のソフト同士が投了で終了したものだけを残したところ約1千万局面ほどになった。もちろんデータ数としては足りないのだが、簡単な検証をする分には十分な量なのではないかと思ったのでしばらく教師あり学習で遊んでみたい。

 まずどのような実験をするにしても学習率を自動決定できれば嬉しい。Cyclical Learning Rate for Training Neural Networkという論文があり、主題は「学習率を上げたり下げたりしながら学習させることで学習が高速に進む」ことなのだが、その学習率の範囲を決定する方法として行われているLR Range Testが学習率の決定に利用できるのではないかと思ったので実装したみた。実のところ元論文はほとんど読んでおらず、

を読んでそれっぽい実装をしてみただけとなる。普通の教師あり学習を、10^{-5}という小さな学習率から始めて1ステップ学習が終わるごとに学習率を\alpha倍していき、学習時の損失を記録してくものとなっている。コードはMiacisのlearn.cppにある通りとなる。

実験

実験1:\alpha=1.1

 倍率を\alpha=1.1として各バッチサイズについてそれぞれ10回試行して平均を取った結果を示す。

f:id:tokumini:20190511113850p:plain

 上の記事では最下点における学習率よりも小さい値の方が良いとあるが、まず単純に最も損失が低くなっているときの学習率を見ていくと

バッチサイズ 最下点での学習率
8 0.000882
64 0.029991
512 0.032999
4096 0.020484

となっている。この結果をそのまま採用するならバッチサイズが小さくなったときに最適な学習率が大きくなるということになってしまうが、個人的には違和感がある。最下点あたりでの誤差で多少ぶれがあるのでもう少し試行回数を増やした方が良いかもしれない。

 また、経験的にはバッチサイズ4096ならば学習率を0.1〜0.2にしても学習は進む。学習率の上げ方が細かすぎて、良い学習率に到達する前に学習が進みすぎているのかもしれないと考え、学習率の上昇率を1.2倍にして同じ実験を行った。

実験2:\alpha=1.2

 倍率を\alpha=1.2として各バッチサイズについてそれぞれ15回以上試行して平均を取った結果を示す。

f:id:tokumini:20190511123306p:plain

 大きく結果が変わるわけではなかった。最も損失が低くなっているときの学習率は

バッチサイズ 最下点での学習率
8 0.0034182
64 0.0146977
512 0.0253977
4096 0.0304772

となった。試行回数を増やした影響かバッチサイズの大小と最下点での学習率の大小が連動しており、こちらのほうがもっともらしい結果だと感じる。

 最下点での学習率を良い学習率として採用して良いのかという問題はあるが、みたところ最下点での学習率もそこまで大きいものではないし、学習中にいくらか学習率は減衰させるので初期学習率は(発散しない程度ならば)多少大きくても良いのではないかと勝手に考えるものとして、とりあえずは最下点での学習率を初期値としてやっていこうと思う。気が向けばCyclical Learning Rateの手法自体も実装してみたい。

LibTorchにおける半精度浮動小数点演算

記事の要約

 LibTorchを使って半精度浮動小数点演算(FP16)を行うことで探索は速くなったが、学習は上手くいかなかった。どうもBatch Normalizationの部分はFP32で計算しなければならないようだ。

LibTorchによる半精度浮動小数点演算

 深層学習では厳密な精度よりも計算速度が求められる場合が多い。特に近頃のGPUTensorコアなるものによって半精度(16bit)の浮動小数点演算を高速に行うことができるようで、FP16で計算を行うプログラムを書くと高速になるとのことである。

 LibTorchではモデルをGPUに転送する際にtorch::kHalfを指定することで半精度浮動小数点演算ができるようだ。自作の将棋ソフト『Miacis』のneural_network.cppから、モデルをGPUへ転送する部分のコードを示す。以下示すコードはUSE_HALF_FLOATシンボルを立てている場合に半精度浮動小数点演算を行うようになっている。

    device_ = torch::Device(torch::kCUDA, gpu_id);
#ifdef USE_HALF_FLOAT
    to(device_, torch::kHalf);
#else
    to(device_);
#endif

 入力特徴量もGPUへ転送する際に同様の指定を行う。

std::pair<torch::Tensor, torch::Tensor> NeuralNetworkImpl::forward(const std::vector<float>& inputs) {
#ifdef USE_HALF_FLOAT
    torch::Tensor x = torch::tensor(inputs).to(device_, torch::kHalf);
#else
    torch::Tensor x = torch::tensor(inputs).to(device_);
#endif
    //以下ネットワークによる処理

    return { policy, value };
}

 ネットワークの出力をCPUで受け取る場合はtorch::Half型を用いる。たとえばbatch_size分のvaluestd::vector<float>で受け取るコードは次のようになる。

    auto y = forward(inputs);

    //CPUに持ってくる
    torch::Tensor value = y.second.cpu();

    //float型のstd::vectorで受け取る
    std::vector<float> values(batch_size);
#ifdef USE_HALF_FLOAT
    std::copy(value.data<torch::Half>(), value.data<torch::Half>() + batch_size, values.begin());
#else
    std::copy(value.data<float>(), value.data<float>() + batch_size, values.begin());
#endif

実験

 上記の改良により本当に速くなるか、精度の問題はないかを検証した。

探索速度

 初期局面を1GPU(2080ti)、2スレッド、512バッチで探索した。GPU処理部分を重くして差がわかりやすくなるようにResidualブロックにおけるCNNのチャネル数を256にして実験を行った。探索速度は次のようになった。

FP32 FP16
7322 NPS 11284 NPS

 FP16にすることで1.5倍ほどのNPSになった。

教師あり学習

 RTX2080tiを用いてfloodgate2016年の棋譜をもとに教師あり学習を行った。損失の推移を次に示す。

f:id:tokumini:20190509202901p:plainf:id:tokumini:20190509202910p:plain
左:Policy損失 右:Value損失

 FP16では上手く学習できていない。

 少し調べてみるとBatch Normalizationは32bitで計算しければいけないとの話があった。指数移動平均のところが原因と言われていたりもするが、どこまで信用できるものかはわからない。Qiitaにも同内容の記事があったので少なくともBatch NormalizationはFP32で計算しなければならないという説の信憑性は高そうだ。山岡さんのブログでもそのようにしていた。やはりBatch Normalizationの部分が問題であるようだ。

結論

 FP16によって探索は速くなったが学習での精度が低下してしまった。今後はモデルの一部だけ(Batch Normalization以外)をFP16で計算する方法などを探っていきたい。

持ち駒の正規化

記事の要約

 持ち駒は正規化した方が良さそう。

前書き

 WCSC29会場にて山岡さんから『ディープラーニングを使った将棋AIの作り方3』を購入させていただいた。AlphaZero的な強化学習ということで大枠は変わらないが、読んでいるといくらかMiacisの実装と異なる点があることに気づいた。計算資源との兼ね合いもあるが、簡単に検証できるものはできるだけしていきたい。

 今回は持ち駒の正規化について検証を行った。持ち駒の正規化とは、ニューラルネットワークへに対して持ち駒の枚数を入力するとき、各持ち駒の数を直接入力するのではなく最大枚数で割ることによって0から1の範囲に正規化することを指す。駒種による最大枚数の違いや、盤上の駒の有無を0,1で表現していることを考えると正規化する方が自然であると思われる。

 以前正規化を試したときは性能が悪化した記憶があり、ソースコード中にもそのような記述があったが、データが残っておらず信用できないと判断したためもう一度検証する。

実験

 上記の本では歩の枚数だけ例外的に最大枚数ではなく8で割った値を入力とすると記述されていたが、個人的な好みから歩も最大枚数である18で割った数を入力とした。

 floodgate2016年の棋譜を用いて教師あり学習を行った。主要な設定は

  • バッチサイズは4096、学習率は0.1
  • 学習データの1/10を検証データとし、1エポックごとに検証データに対して損失を計算。前回に比べて下がっていなかった場合には学習率を1/2に減衰
  • 6回連続で損失が下がらなかった時点で学習を終了

である。両手法ともランダムに初期化した同一のパラメータを初期値とし、乱数シードも固定し学習データのシャッフル方法も同一であるようにした。

 検証データに対する損失推移は以下のようになった。

f:id:tokumini:20190507180057p:plainf:id:tokumini:20190507180104p:plain
左:Policy損失 右:Value損失

 一番損失が低いエポックでの結果は次のようになった。

手法 Policy損失 Value損失
正規化あり 1.895 0.3868
正規化なし 1.909 0.3963

 1回の実験ではっきりしたことは言えないが、やや正規化ありの方が良い結果である。

 気になる点として、学習率を0.2にすると正規化なしでは学習できるのに対して正規化ありでは学習できなかった(value損失が常にほぼ2になってしまった。これはvalueが常に1または-1を出力するようになったことを示している。policyの学習は進んでいた)。入力を正規化するとそれに合わせて適切なパラメータも小さくなるはずであり、相対的に学習率が大きくなっているのかもしれない。ただの偶然であった可能性もある。