9年前に作ったオセロ強化学習、現代の技術で作り直したら何が変わったか
はじめに
9年前、Deep Q-Network(DQN)を使ってオセロ(リバーシ)を学習するプログラムを作り、Qiita に連載記事として公開しました。当時の最終結果は勝率約 70% でしたが、「なぜか強くなりきれない」という疑問を残したまま、シリーズを終えていました。
その後転職(AI とはほとんど関係のない仕事)などもあり、この記事はそのまま放置していました。
あれから 9 年。強化学習のエコシステムは大きく進化しました。なにより、生成 AI の登場、LLM の登場で、世の中ががらりと変わりました!
ということで、今回 Claude Code で同じ題材を現代の技術スタックで作り直してみたところ、勝率 83% を達成し、9年前の疑問にも答えが出ました。
ここでは、9 年ぶりに自分の記事を振り返って、内容を記述したいと思います。
9年前の実装
技術スタック
| コンポーネント | 当時 |
|---|---|
| 強化学習アルゴリズム | DQN(Deep Q-Network) |
| フレームワーク | Chainer |
| エージェント・環境の通信 | RL-Glue(ソケット通信) |
| ニューラルネット構造 | 入力層1 + 隠れ層8(各1296ユニット)+ 出力層1 |
| 入力 | 9フレーム分の盤面を積み重ね |
対戦相手(ヒューリスティック AI)
自作のルールベースのオセロゲームで、以下の優先順位で手を決めます。めちゃくちゃ単純なロジックで、弱い。
- 四隅に置ける場合は 90% の確率でそこを取る
- それ以外は 80% の確率で最も多く相手の石を返せる場所に置く
- 決まらない場合はランダム
結果
- 6×6 盤: 最終勝率 約 70%
- 8×8 盤: 最終勝率 約 70%(ただし学習中に激しい勝率の波あり)
勝率 70% は非常に弱い。実際に私と、作成したモデルとの間で対戦したけれども、私のほうが割と簡単に勝ててしまい、がっかりした覚えがあります。
学習曲線には episode 2〜3 万付近で勝率が急落するパターンを見ることができ、当時はその原因を「Dying ReLU」「学習率が高すぎる」「一種の過学習」と仮説を立てましたが、結局確信が持てないままでした。
今回の実装
技術スタック
| コンポーネント | 今回 |
|---|---|
| 強化学習アルゴリズム | PPO(Proximal Policy Optimization)+ Action Masking |
| フレームワーク | PyTorch |
| 環境インターフェース | Gymnasium(カスタム環境) |
| ライブラリ | stable-baselines3 / sb3-contrib |
| ニューラルネット構造 | 隠れ層2(各256ユニット)MLP |
| 入力 | 現在の盤面のみ(4レイヤー × 36マス = 144次元) |
Chainer から PyTorch へ
フレームワークが、Chainer から PyTorch に変わりました。
Chainer は 2019 年をもって開発が終了しました。実は Chainer は「Define-by-Run(動的な計算グラフ)」を世界で初めて提唱したフレームワークで、PyTorch はその影響を強く受けて設計されています。Chainer の開発元である Preferred Networks 自身も PyTorch への移行を推奨しており、現在は PyTorch が事実上の後継として広く使われています。
RL-Glue から Gymnasium へ
RL-Glue はエージェント・環境・実験スクリプトを別プロセスとして起動しソケット通信させる設計でした。現代の標準は Gymnasium の env.step(action) / env.reset() によるシンプルな同一プロセス実装で、コード量も大幅に削減できます。
# Gymnasium カスタム環境の骨格
class ReversiEnv(gym.Env):
def reset(self):
self.game.resetBoard()
return self._get_obs(), {}
def step(self, action):
pos = self._action_to_pos(action)
self.game.putStone(pos[0], pos[1], self.game.turn, False)
opp_pos = self._get_opponent_move() # ヒューリスティックまたはモデル
if opp_pos:
self.game.putStone(opp_pos[0], opp_pos[1], -self.game.turn, False)
# 終了判定・報酬計算...
DQN から PPO へ
ここが、今回のリファクタリングのキモになります。問題の原因は大体ここに集約されます。
DQN の不安定さ
DQN の根本的な問題は 2 つあります。
問題1: 「現在の Q 値」と「目標 Q 値」を同じネットワークから計算する
NN の更新はバックプロパゲーションによるパラメータ更新ですが、DQN では現在の Q 値と目標 Q 値を同じ NN から計算するため、パラメータを更新すると目標値も連動して動いてしまいます。
【DQN の更新サイクル】
① 盤面S での Q 値を計算
盤面S → NN → Q(S, この手) = 0.3
② 目標 Q 値を計算(同じ NN から!)
次の盤面S' → 同じNN → 目標 = 報酬 + γ × max Q(S') = 1.0
③ 誤差を最小化 → NN のパラメータをバックプロパゲーションで更新
損失 = (0.3 - 1.0)² → 大きく更新
④ NN が変わる → 目標も変わる(同じ NN だから)
次の盤面S' → 更新後NN → 目標 = 1.5(ずれた!)
⑤ また大きな誤差 → また大きく更新 → また目標がずれる...
↓
発散・不安定
問題2: オセロはスパース報酬(報酬がゲーム終了時にしか得られない)
DQN が安定しやすいのは「試行するたびにすぐ報酬が得られる」状況です。
【DQN が得意な状況】
試行 → 報酬 → 更新 → 試行 → 報酬 → 更新 ...
↑すぐ得られる ↑すぐ得られる
Q値の推定が容易 → 安定して学習できる
しかしオセロでは 30 手近く報酬ゼロが続き、ゲーム終了時にはじめて報酬が得られます。
【オセロの状況】
試行 → 試行 → 試行 → ... → 試行 → 報酬 → 更新
↑ 30手近く報酬ゼロが続く ↑ここでやっと
Q値の推定が困難 → 「どの手が勝因だったのか?」がわからない
→ 目標Q値がぶれやすい → 問題1と合わさって発散しやすい
この「どの手が勝因だったのかを遡って評価する問題」を Credit Assignment Problem(貢献度割り当て問題) と呼びます。
2017 年の記事では決着がつくまでの 9 フレーム分の盤面だけで報酬を決めていましたが、もし仮に、ゲーム開始の一手目から、決着がつくまでのすべての盤面を入力していたら、ある程度このCredit Assignment Problem(貢献度割り当て問題) の改善は可能だったかもしれません。
しかしながら、入力値が爆発的に増えることと、そもそも勝負がつくまでの手数は動的に変化するという問題がありこれは入力層の長さに変化があることを示します。
「RNN」や「Transfomer」は入力層の長さの変化に対応していますが、そもそも入力層のサイズが膨大となることや、後述の「マルコフ性」などから、ボードゲームには適しません。
PPO の解決策
PPO は「一度の更新で方策をどれだけ変えていいか」に上限を設けます。
【PPO の更新サイクル】
① 現在の方策(π_old)で手を選び、結果を記録
盤面S → π_old → 手Aを 30% の確率で選択 → 勝った!
② 「この手をもっと選びたい」 → どのくらい変えていいか?
比率 r = π_new(A|S) / π_old(A|S)
= (更新後にAを選ぶ確率) / (更新前にAを選ぶ確率)
③ 比率をクリップ(切り捨て)で制限
上限 1.2、下限 0.8(ε=0.2 のデフォルト)
r = 1.5 の場合 → 1.2 に切り捨て ← 変えすぎ禁止
r = 0.6 の場合 → 0.8 に切り捨て ← 下げすぎ禁止
④ 制限された範囲内だけで更新
→ 一度の学習で方策が激変しない
⑤ 次の更新も同様にクリップ → 安定した積み重ね
↓
なめらかな学習曲線
並べて比較すると
【DQN】 【PPO】
目標が自分自身 → 追いかけっこ 更新幅に上限 → 着実な改善
更新 → 目標がずれる 更新 → 次も小さく更新
↓ ↓
また更新 → またずれる また更新 → また小さく更新
↓ ↓
また更新 → またずれる また更新 → また小さく更新
↓ ↓
発散・クラッシュ 収束・安定
この「更新幅を近傍(Proximal)に制限する」ことが、アルゴリズム名 Proximal Policy Optimization の由来です。
Action Masking
オセロは「コマを置けないマスには手を打てない」というルールがあります。DQN の時代は合法手の Q 値を手動で取り出す処理を自前で実装していましたが、sb3-contrib の MaskablePPO を使えば、不正なアクションを -∞ にマスクする処理がポリシーレベルで正確に動作します。
def action_masks(self) -> np.ndarray:
mask = np.zeros(self.action_space.n, dtype=bool)
valid = self.game.getPositionAvail(self.game.turn)
if valid:
for p in valid:
mask[p[0] * self.n_cols + p[1]] = True
else:
mask[self._pass_action] = True # 強制パス
return mask
カリキュラム学習 + セルフプレイ
今回の実装の最大の特徴が 2 フェーズの学習戦略です。
Phase 1: ヒューリスティック AI と対戦
まず安定した「先生」として、9年前に作ったヒューリスティック AI と対戦します。直近 200 ゲームの勝率が 60% を超えたら Phase 2 へ移行します。
Phase 2: 過去の自分のプールとセルフプレイ
Phase 2 では 過去モデルのプール(最大 5 個)から対戦相手をランダムに選んでセルフプレイを行います。
プール = [33K step 時点のモデル, 43K step 時点のモデル, ...]
↑ 10,000 ステップごとに現在のモデルが追加される
「現在 vs 現在」にしない理由は、同じ強さ同士だと学習信号が薄くなり、特定戦術への過学習(モード崩壊)が起きやすいためです。AlphaGo / AlphaZero が採用したのと同じ考え方です。
学習経過
| タイミング | ep_rew_mean | 状況 |
|---|---|---|
| Phase 2 移行直前 | 0.065 | ヒューリスティック相手に 60% 勝利 |
| Phase 2 移行直後 | 0.012 | 相手が強くなり一時的に急落(正常) |
| 300K step | 0.884 | セルフプレイで成長 |
| 400K step(打ち切り) | ~0.85 | 頭打ち傾向 |
Phase 2 移行直後の急落は正常な挙動で、「固定された先生」から「成長する自分自身」へと対戦相手が変わったことで一時的に勝てなくなります。その後の回復と上昇がセルフプレイの効果を示しています。
結果比較
python evaluate.py --model checkpoints/reversi_curriculum_400000_steps.zip --episodes 1000
| 9年前(DQN) | 今回(PPO + セルフプレイ) | |
|---|---|---|
| 対ヒューリスティック勝率 | 約 70% | 83.4% |
| 引き分け率 | 不明 | 1.8% |
| 敗率 | 約 30% | 14.8% |
| 平均ゲーム長 | 不明 | 16.8 手 |
| 学習の安定性 | 激しい波あり | 比較的なめらか |
さらに、自分で学習済みモデルと実際に対戦してみると「読めない・難しい」という印象を受けました。単にヒューリスティックの弱点を突くだけでなく、本質的な Reversi 戦略を習得している可能性があります。
9年前、なぜうまくいかなかったのか
当時の連載で「原因不明」だった勝率急落の謎が、今回の実験を通じてほぼ解明できました。
原因1: DQN の発散
DQN の Target Network 更新と学習率(0.00025)の組み合わせが不安定で、特に 8×8 盤では顕著でした。PPO に切り替えることでこの問題は解消されました。
原因2(本命): ヒューリスティックの「悪手」を学習していた
これが根本原因だったと考えられます。
当時のヒューリスティック AI は「最も多く相手の石を返せる場所」を優先する戦略を採用していました。しかしこれは Reversi においては悪手であることが知られています。序盤〜中盤に多く返すと、相手の合法手が増え(mobility が上がり)、終盤に好きな場所を取られてしまいます。
DQN エージェントはこの「悪手」を先生から忠実に学んでしまい、その後に自己修正するために勝率が急落していました。連載第4回で「序盤に枚数を多く取る戦略を学んでしまった」と分析していたのは、まさにこれを指していました。
今回のセルフプレイでこの問題が解消された理由は単純で、自分自身と対戦すると「枚数を多く取ると負ける」ことを自然に学ぶからです。ヒューリスティックという「悪い先生」の影響から卒業できたのが、83% という結果につながりました。
9年前: ヒューリスティックの悪手を学習 → 自己修正 → 70% で収束
今回: ヒューリスティックで基礎習得(Phase 1)
→ セルフプレイで悪手を自己否定(Phase 2)
→ 83% 達成
まとめ
| 観点 | 9年前 | 今回 |
|---|---|---|
| アルゴリズム | DQN | PPO + Action Masking |
| 学習戦略 | ヒューリスティックのみ | カリキュラム学習 + セルフプレイ |
| フレームワーク | Chainer(廃止済み)+ RL-Glue | PyTorch + Gymnasium |
| 勝率 | 約 70% | 83.4% |
| 学習安定性 | 激しい波 | 安定した上昇 |
| 人間との対戦 | 人間側楽勝! | 「読めない・難しい」 |
9年前に「なぜか強くなりきれない」と感じていた原因は、アルゴリズムの不安定さもありましたが、対戦相手のヒューリスティック自体が悪手を教えていたという構造的な問題でした。
強化学習において「誰と対戦するか」はアルゴリズム以上に重要で、セルフプレイという仕組みがその問題を解決しました。AlphaGo がなぜセルフプレイを採用したのか、小さなオセロの実験を通じて体感できた気がします。
さらなる改善
ちなみに今回のリファクタリングコードでは、2017 年と比較したいこともあり MLP つまり全結合 NN を使用しています。
ですがチェス・オセロ・囲碁には共通して「現在の盤面を見れば、そこに至るまでの手順を知らなくても最善手を判断できる」という性質があります。これを「マルコフ性」と呼びます。
「現在の盤面のみ」を入力にすればよいなら、それはある意味画像認識と同じ構造です。盤面という「画像」から次の一手を判断する、という考え方です。
AlphaGo / AlphaZero もこの考え方に基づき、現在の盤面のみを CNN への入力としており、過去の手順は使いません。報酬はオセロと同様にゲーム終了時のみで、そこに CNN による盤面認識とモンテカルロ木探索(MCTS)を組み合わせることで、人間を超える強さを実現しました。
でもこのプロジェクトでは、PPO とセルフプレイである程度の性能が得られたので、それで良しとしたいと思います。
ともあれ、9 年越しの問題が解けて、スッキリしました!
コード
今回のコードは GitHub で公開しています。
- 元の実装(9年前): Learning-Machine-Learning/Reversi
- 今回のリファクタリング + 現代的実装: Learning-Machine-Learning/Reversi/caliculum_selfplay