はじめに
エニグモ サーバーサイドエンジニアの @gugu です。 この記事はEnigmo Advent Calendar 2018の15日目です。
日頃はBUYMAの機能改修を行っていますが、弊社では月末のプレミアムフライデーは業務と関係ない開発を行って良い日となっています。 そこで、前から興味のあった機械学習で何か作ってみようと思いました。
Chainerを使って「まるばつゲーム」を学習させてみたので、簡単にやったことを書こうと思います。
※ちょくちょくリファクタするかもです。
本題に入る前に
私のスペック
仕様など
- みんな知っているまるばつゲームです。
- 学習させるコンピュータは常に先手
- 0〜8の計9マス。どこに打つかを学習させます。(もう既に打たれたマスにも打つことが可能。当然ルール違反なので負け)
| | 0 | 1 | 2 | | -----+-----+----- | | 3 | 4 | 5 | | -----+-----+----- | | 6 | 7 | 8 | |
- 盤面のデータは誰も打ってないマスは0、○は1、☓は2の値が入ります。
| | | | ○ | | -----+-----+----- | | ☓ | ○ | ○ | | -----+-----+----- | | | ☓ | ☓ | | ↓ ↓ ↓ | | 0 | 0 | 1 | | -----+-----+----- | | 2 | 1 | 1 | | -----+-----+----- | | 0 | 2 | 2 | |
配列だと[0, 0, 1, 2, 1, 1, 0, 2, 2]
- コンピュータと戦わせて学習させます。(完全ランダムに打つ。打たれているマスには打たない。不毛な戦いになるので。)
- 盤上の状態を入力して、出力結果のうちの最大値のマスに打ちます
| | | | ○ | | -----+-----+----- | | ☓ | ○ | ○ | | -----+-----+----- | | | ☓ | ☓ | |
のとき入力値は[0, 0, 1, 2, 1, 1, 0, 2, 2]
出力結果がもし[0.1, 0.2, 0.4, 0.2, 0.1, 0.3, 0.9, 0.1, 0.5]
だったら、数値が最大のマス6に打ちます。
- 勝敗結果から打ったマスが好手だったのか悪手だったのかを教えます
Chainerの基礎知識
ニューラルネットを定義
from chainer import Link, Chain, ChainList import chainer.functions as F import chainer.links as L class MyChain(Chain): def __init__(self): super(MyChain, self).__init__( # 9-20-9の3層(隠れ層の20はなんとなく。) l1 = L.Linear(9, 20), l2 = L.Linear(20, 9) ) def __call__(self, x): # 伝播(sigmoidでも良いがleaky_reluの方が結果が良いような気がする。) h = F.leaky_relu(self.l1(x)) o = self.l2(h) return o
初期設定
# ニューラルネット model = MyChain() # 確率的勾配降下法(Stocastic Gradient Descent)を使用 opt = optimizers.SGD() opt.setup(model)
打つ場所を決める
# 盤上の状態データをfloat32に形成する(chainerがfloat64には対応していないため) x = Variable(np.array([input_data], dtype=np.float32)) # 勾配を0に初期化(chainerのお決まりごと) model.zerograds() # 入力xを変換し出力yへ y = model(x) # 出力の最大値を打つ y.data.argmax()
学習させる
# 教えるデータをfloat32に形成する t = Variable(np.array([teacher_data], dtype=np.float32)) # 出力yと教えるデータtとの差分を算出(平均二乗誤差) loss = F.mean_squared_error(y, t) # 逆伝播 loss.backward() # 最適化 opt.update()
上記の基礎知識を使ってまるばつゲームを学習させていきます。
学習方法
強化学習はよく理解していないので、基礎知識のみで自己流に学習させます。
# result: 勝敗結果 # ドロー:0 # 勝ち:1 # 負け:2 # 既に打たれたマスに打った:3 def learn(self, result): for i, y in enumerate(models): # marks[i]: 打ったマス番号 # y.data[0]: 出力値 teacher = self.teacher(result, marks[i], y.data[0]) t = Variable(np.array([teacher], dtype=np.float32)) # 出力yと正解tとの差分を算出(平均二乗誤差) loss = F.mean_squared_error(y, t) # 逆伝播 loss.backward() # 最適化 opt.update() # 学んだらリセット del models[:] del marks[:]
def teacher(self, result, mark, model): data = [] # draw, win if result == Settings.WIN: for i in range(9): if i == mark: # 打ったマスに値を与える data.append(1) else: # 打ってない箇所は現状維持 data.append(model[i]) # lose or same place elif result == Settings.LOSE or result == Settings.SAME_PLACE: for i in range(9): if i == mark: data.append(-1) else: data.append(model[i]) # draw else: for i in range(9): if i == mark: data.append(0) else: data.append(model[i]) return data
学習方法説明
勝った場合
| | | | ○ | | -----+-----+----- | | ☓ | ○ | ○ | | -----+-----+----- | | ○ | ☓ | ☓ | |
最後の一手の出力結果が[0.1, 0.2, 0.4, 0.2, 0.1, 0.3, 0.9, 0.1, 0.5]
で、マス6に打った場合、
- 出力結果: [0.1, 0.2, 0.4, 0.2, 0.1, 0.3, 0.9, 0.1, 0.5]
- 教えるデータ: [0.1, 0.2, 0.4, 0.2, 0.1, 0.3, 1, 0.1, 0.5]
とマス6の値を1
にします。
同じように3手目が[0.3, 0.1, 0.9, 0.7, 0.5, 0.3, -0.3, -0.8, 0.5]
で、マス2に打っていた場合
- 出力結果: [0.3, 0.1, 0.9, 0.7, 0.5, 0.3, -0.3, -0.8, 0.5]
- 教えるデータ: [0.3, 0.1, 1, 0.7, 0.5, 0.3, -0.3, -0.8, 0.5]
とマス2の値を1
にします。
※これを1手目まで繰り返します。
※上記は勝った例なので1
にデータを変換しましたが、負けた場合は-1
に、ドローは0
にデータになります。
学習させてみた
※データは最後から100戦の勝率
100戦
win: 0.04 lose: 0.04 draw: 0.0 same_place: 0.92
1,000戦
win: 0.22 lose: 0.01 draw: 0.0 same_place: 0.77
10,000戦
win: 0.61 lose: 0.06 draw: 0.0 same_place: 0.33
100,000戦
win: 0.81 lose: 0.08 draw: 0.02 same_place: 0.09
強くなってる!
VS 人間
実際に100,000戦したコンピュータと戦ってみました。 ※コンピュータ:○、 人間:☓
| | 0 | 1 | 2 | | -----+-----+----- | | 3 | ○ | 5 | | -----+-----+----- | | 6 | 7 | 8 | | ↓ ↓ ↓ | | 0 | 1 | x | | -----+-----+----- | | ○ | ○ | 5 | | -----+-----+----- | | 6 | 7 | 8 | | ↓ ↓ ↓ | | ○ | 1 | x | | -----+-----+----- | | ○ | ○ | x | | -----+-----+----- | | 6 | 7 | 8 | | ↓ ↓ ↓ | | ○ | 1 | x | | -----+-----+----- | | ○ | ○ | x | | -----+-----+----- | | 6 | 7 | x | |
まともに戦えるが、弱い。。
学習方法を変えてみた
どう変えたか?
「既に打たれたマスに打った」で負けた場合は、最後の一手のデータを調整するように変更します。
ソース
# result: 勝敗結果 # ドロー:0 # 勝ち:1 # 負け:2 # 既に打たれたマスに打った:3 def learn(self, result): for i, y in enumerate(models): # 既に打たれたマスに打った and 最後の一手 if result == 3 and i == len(models) - 1: # marks[i]: 打ったマス番号 # y.data[0]: 出力値 teacher = self.teacher(result, marks[i], y.data[0], True) else: teacher = self.teacher(result, marks[i], y.data[0], False) t = Variable(np.array([teacher], dtype=np.float32)) # 出力yと正解tとの差分を算出(平均二乗誤差) loss = F.mean_squared_error(y, t) # 逆伝播 loss.backward() # 最適化 opt.update() # 学んだらリセット del models[:] del marks[:]
def teacher(self, result, mark, model, last_flg): data = [] # draw, win if result == Settings.WIN: for i in range(9): if i == mark: # 打ったマスに値を与える data.append(1) else: # 打ってない箇所は現状維持 data.append(model[i]) # lose elif result == Settings.LOSE: for i in range(9): if i == mark: data.append(-1) else: data.append(model[i]) # same plase elif result == Settings.SAME_PLACE: if last_flg == True: for i in range(0, 9): if i == mark: # 最後に打ったマスだけを`-2`に調整する。 data.append(-2) else: data.append(model[i]) else: for i in range(0, 9): data.append(model[i]) # draw else: for i in range(9): if i == mark: data.append(0) else: data.append(model[i]) return data
学習させてみた
10,000戦
win: 0.96 lose: 0.04 draw: 0.0 same_place: 0.0
コンピュータ相手だと9割以上勝てるようになって、打たれているマスには打たなくなりました。
VS 人間 (2)
100,000戦したコンピュータともう一度戦ってみました。 ※コンピュータ:○、 人間:☓
| | 0 | 1 | 2 | | -----+-----+----- | | 3 | ○ | 5 | | -----+-----+----- | | 6 | 7 | 8 | | | | 0 | ○ | 2 | | -----+-----+----- | | 3 | ○ | x | | -----+-----+----- | | 6 | 7 | 8 | | | | 0 | ○ | ○ | | -----+-----+----- | | 3 | ○ | x | | -----+-----+----- | | 6 | x | 8 | | | | x | ○ | ○ | | -----+-----+----- | | 3 | ○ | x | | -----+-----+----- | | ○ | x | 8 | |
強い!まだ最強とまでは言えませんが、2つリーチを作れることを覚えたようです。
まとめ
機械学習はハードルがものすごく高いイメージでしたが、Chainerの基礎的な関数を駆使すれば初心者の私でも簡単な機械学習を作成できることがわかりました。 今後は強化学習(DQNなど)をちゃんと勉強して、負けないレベルに修正できたらと思います。