AI Dev Lab
TurnStone

TurnStone ができるまで — リバーシ AI の読みを盤に出す

リバーシ AI 対戦の開発ログ。 評価関数を 1 つの数値に潰さず 位置 ・ 打てる手 ・ 角 ・ 確定石 ・ 石数 の内訳で返す形にし、 置けるマス全部に評価値を重ねた記録です。 全手の点数を正しく出すために、 根での枝刈りを捨てた話も書いています。

TurnStone は、一人で遊べるリバーシ (8×8) です。ただし主役は AI の強さではありません。AI が今どう考えているかを、盤の上に全部出すことに振ったサービスです。

チェス AI (GambitBoard) を作ったときに残った不満がありました。強くはなったが、なぜその手なのかは誰にも見えない。評価関数も探索も自分で書いたのに、外から見えるのは「AI が指した 1 手」だけです。そこで今回は、評価を隠さない側に振りました。

#🎯 「AI と戦う」ではなく「AI の読みを見せる」

リバーシは、初心者と経験者で打ち方が正反対になる珍しいゲームです。初めての人は「たくさん返せる手」を打ちます。経験者は逆に、序盤はできるだけ返さない

この差を言葉で説明しても伝わりません。「石数より打てる手の数が大事」と書いたところで、実際に打つときには目の前の枚数に引っ張られます。

なので、打つ前に全部の候補マスへ評価値を出すことにしました。

  • 置けるマスすべてに、AI が計算した点数を数字で重ねる
  • 点数が高いほど色を濃くする
  • 最善手だけ青で囲む

「8 枚返せるマス」が -12 で、「1 枚しか返らないマス」が +41 になる。数字が目の前にあると、直感に反する定石が体験として入ります

#🧩 評価関数を 1 つの数値に潰さない

普通、評価関数は number を返します。探索はそれしか要らないからです。

でもこのサービスでは、画面に内訳を出したい。そこで戻り値を構造体にしました。

ts
export interface EvalBreakdown {
  position: number  // マスの重みの合計差 (角と危険地帯)
  mobility: number  // 着手可能数の差
  corners: number   // 角をいくつ確保しているかの差
  stable: number    // 二度と返らない石の差
  discs: number     // 石数の差
  total: number     // 上記の重み付き合計
}

探索は total だけを見ます。UI は 5 項目を全部表示します。同じ計算から、2 つの用途に別々の粒度で渡す形です。

これをやると、序盤に石を取りすぎたときに何が起きるかが数字で追えます。discs が増えるのに mobility がマイナスに振れる。「取ったのに不利になった」が、画面の 2 行の動きとして見えるわけです。

#📐 マスの重み ── 角が +120、その斜め隣が -40

リバーシの定石を、そのまま 64 マスの表にしました。

ts
export const SQUARE_WEIGHTS: readonly number[] = [
  120, -20,  20,   5,   5,  20, -20, 120,
  -20, -40,  -5,  -5,  -5,  -5, -40, -20,
   20,  -5,  15,   3,   3,  15,  -5,  20,
  // ...
]

角が最大の +120。理由は単純で、角は四方から挟まれないので一度取ると二度と返らないからです。

強くマイナスなのは 2 種類あります。角の斜め隣 (-40) と、角の縦横隣 (-20)。どちらも相手に角を渡すきっかけになります。特に斜め隣は、そこに打つと角が直接空くので最悪です。

チェスの Piece-Square Table と考え方は同じですが、リバーシのほうが表の効きが露骨でした。駒の種類が 1 つしかないぶん、位置だけがすべてを決めるからです。

#🔀 打てる手の数は、差ではなく比で測る

最初は着手可能数をそのまま引き算していました。これは間違いでした。

序盤に「自分 10 手 / 相手 8 手」で差が 2 なのと、終盤に「自分 3 手 / 相手 1 手」で差が 2 なのは、意味の重さがまったく違います。後者は相手をほぼ詰ませている状態です。

そこで比率に直しました。

ts
const mobility =
  myMoves + foeMoves === 0 ? 0 : (100 * (myMoves - foeMoves)) / (myMoves + foeMoves)

これで -100 〜 +100 に正規化され、終盤で相手の選択肢を奪ったことが正しく高評価になります。分母が 0 のとき (両者とも打てない = 終局) を先に外しておかないとゼロ除算になるので、そこだけ明示的に潰しています。

#🪨 確定石は「少なく数える」

角を取ると、そこから辺に沿って続く石も返らなくなります。これを確定石と呼びます。

厳密に判定しようとすると、盤の内側にある石まで含めて「もうどの方向からも挟めない」ことを確かめる必要があり、かなり重い処理になります。1 手ごとに何千局面も評価する探索の中では回りません。

なので、角を起点にした辺方向だけを数える控えめな定義にしました。

ts
for (const corner of CORNERS) {
  if (board[corner] !== player) continue
  walk(corner, 0, c === 0 ? 1 : -1) // 横の辺に沿って
  walk(corner, r === 0 ? 1 : -1, 0) // 縦の辺に沿って
}

この定義には数え漏らしがあります。実際には確定している内側の石を見逃します。でも逆はありません。確定していない石を確定と数えることはない

評価関数で嘘をつくなら、過小評価の方向に倒すべきです。「返らないはずの石が返る」と AI が判断を誤ると、角を取ったあとの読みが全部崩れます。逆に見逃すぶんには、少し弱くなるだけで済みます。

#🔚 終盤で評価をまるごと切り替える

リバーシで一番効いた工夫がこれです。

序盤・中盤の評価は、位置・手数・角・確定石が中心で、石数はほとんど見ません (係数 0.5)。ところが残り 12 マスを切ると、係数を入れ替えます。

ts
const total = endgame
  ? discs * 100 + corners * 60 + stable * 20 + position * 0.2
  : position * 1 + mobility * 8 + corners * 80 + stable * 12 + discs * 0.5

終盤では discs が 100 倍。勝敗はあくまで最後の石数で決まるので、残りが読み切れる範囲に入った瞬間から、石数だけを追いにいくのが正解です。

この切り替えを入れる前は、最後の数手で妙に緩い手を打っていました。位置の点数を気にして、目の前の勝ち石を取りに行かないのです。

#⚠ 全手のスコアが欲しいと、根では枝刈りできない

実装していて一番意外だったのがここです。

アルファベータ探索は「もうこの手は選ばれない」と分かった時点で打ち切ります。速いのですが、打ち切った手のスコアは正確な値ではなくなります。上限か下限が分かっただけで、本当の点数は計算していません。

普通は問題ありません。最善手さえ分かればいいからです。でもこのサービスは全部のマスに数字を出すのが売りです。嘘の数字を並べるわけにはいきません。

なので、根 (最初の 1 手を選ぶ層) だけは窓を共有せず、1 手ずつ独立に読みます。

ts
for (const [at, flips] of moves) {
  const next = applyMove(board, at, player, flips)
  const score = -negamax(next, opponent(player), depth - 1, -Infinity, Infinity, false)
  out.push({ at, score, flips })
}

-Infinity, Infinity を毎回渡しているのは書き忘れではなく、表示のために枝刈りを捨てているということです。1 つ下の層から先は普通にアルファベータが効くので、遅くなるのは根の分だけで済みます。

「見せる」を仕様に入れると、最適化を 1 つ諦めることになる。この交換は事前に予想していませんでした。

#😓 苦労したところ

盤に出す評価は浅く読む。 人間の手番が来るたびに全合法手を 6 手先まで読むと、1 手ごとに画面が固まります。表示用は深さ 3 で頭打ちにしました (Math.min(depth, 3))。AI 自身の着手はレベル通りの深さで読むので、表示の数字と AI の判断が完全一致するとは限りません。速さを取っています。

探索が同期処理なので、描画を 1 回挟む。 そのまま呼ぶと「AI が読んでいます…」が表示される前に計算が始まり、固まったように見えます。setTimeout を挟んでブラウザに描画の機会を渡してから走らせています。Web Worker への移動は積み残しです。

マスが正方形にならなかった。 375px 幅で 1 マスが 36×29 になり、石が楕円になってはみ出していました。CSS Grid は grid-template-columns だけ書くと行の高さは中身なりになります。列と行の両方を 1fr にして、さらに aspect-ratio: 1 を当てて直しました。

css
.board {
  grid-template-columns: repeat(8, 1fr);
  grid-template-rows: repeat(8, 1fr);
  aspect-ratio: 1;
}

呼び名が使えない。 同じルールのゲームで広く知られている名称は株式会社メガハウスの登録商標です。各社が法的リスクを避けて「リバーシ」を名乗っているので、こちらもサービス名・対局画面・メタ情報のどこにも使わず、一般名のリバーシで通しました。検索需要としては惜しいのですが、ここは譲れない部分です。

実装より先に決めたのはこの 1 点だけでした。名前は後から変えると URL も OGP も検索評価も全部やり直しになるので、書き始める前に確定させています。

#🧪 検算 ── ルールから機械的に確かめられることだけ

AI の強さはテストできません。「良い手を選ぶ」に正解が無いからです。なので、ルールから必ず言える性質だけを 18 項目並べました (scripts/check-turn-stone.ts)。

  • 初期配置の合法手はちょうど 4 通り (d3 / c4 / f5 / e6)
  • 初期配置は点対称なので、4 手の評価値がすべて等しい
  • 1 手ごとに、石の総数は「置いた 1 枚 + 返した枚数」だけ増える
  • 1 枚も返せないマスは合法手にならない
  • 角から連続する石だけが確定石に数えられる
  • 探索は、角が取れる局面で角を最上位に選ぶ

2 番目が効きました。対称な盤で 4 手の点数がずれたら、評価関数のどこかに左右非対称なバグがあるということです。表の書き写しミスをこれで 1 回捕まえています。

#🔭 今後の拡張

  • Web Worker へ探索を逃がして、UI を止めずに深く読む
  • 置換表 (同じ局面の再計算を省く)
  • 定石データベース (序盤の数手)
  • 棋譜の書き出しと読み込み

#💡 このサービスから言えること

評価関数を構造体で返すようにしただけで、サービスの性格が変わりました。 中身は教科書どおりのアルファベータ探索で、特別なことは何もしていません。違うのは、計算した内訳を捨てずに画面まで運んでいるところだけです。

そして**「見せる」を仕様に入れると、性能のための最適化と衝突します**。根での枝刈りを捨てたのがまさにそれでした。速さと説明可能性は、どこかで必ずトレードオフになる。AI の中身を見せるサービスを作るなら、最初にどちらを取るか決めておいたほうがいいと感じました。

[ ./next_action ]

読んだら、 TurnStone を実際に動かす。

この開発ログは TurnStone をどう作ったかの記録です。 読み終わったらそのままサービス本体へ戻って、 実物で価値を確かめてください。

[ ./related_logs ]

関連する開発ログ

all logs →
ToonCast

ToonCast ができるまで — AnimeGANv2 をブラウザで動かす

AnimeGANv2 の小さな ONNX (約9MB) を onnxruntime-web (単一スレッド WASM=COOP/COEP不要、 color-revive で承認済みライブラリの再利用) で実行。 512x512・[-1,1] 正規化で推論し、 結果を元解像度に戻して表示する設計記録。 写真は端末内処理。

read log →
ColorRevive

ColorRevive ができるまで — onnxruntime-web で白黒写真をカラー化

DeOldify の量子化 ONNX を onnxruntime-web (CDN side-load・単一スレッド WASM=COOP/COEP不要) で実行。 256x256 でモデル推論し、 輝度は元写真・色だけ AI を YCbCr で再合成して輪郭を保つ設計記録。 写真は端末内処理。

read log →
PhotoTwin

PhotoTwin ができるまで — CLIP画像埋め込みで似た写真を見つける

CLIP (Xenova/clip-vit-base-patch32) の image-feature-extraction を transformers.js の CDN ESM で side-load し、 各写真を正規化ベクトル化。 cosine 類似度で重複・似た写真をブラウザ内で検出する設計記録 (新ライブラリ追加なし=what-cam と同じ CLIP の再利用)。

read log →
AkinFind

AkinFind ができるまで — ブラウザ内で完結する embeddings 意味検索

多言語の文章埋め込みモデル (Xenova/multilingual-e5-small) を transformers.js の CDN ESM で side-load し、 各文を正規化ベクトル化。 cosine 類似度で意味検索と似ている文ペア検出を全て端末内で行う設計記録。

read log →
WhatCam

WhatCam ができるまで — CLIP のゼロショット画像分類をブラウザで動かす

CLIP (Xenova/clip-vit-base-patch32) を transformers.js の CDN ESM で side-load し、 写真と候補ラベルの近さをブラウザ内で計算。 日本語ラベルを英語プロンプトに変換し、 図鑑と自由入力の両モードで「これ何?」を判定する設計記録。

read log →
DepthCast

DepthCast ができるまで — 1枚の写真をAIの深度推定で立体にする

Depth Anything (transformers.js) を CDN ESM で side-load し、 1枚の写真から深度マップを推定。 WebGL2 フラグメントシェーダで深度に比例した視差 (iterative backward parallax) を作り、 赤青アナグリフ / WebM 書き出しまで端末内で完結させた設計記録。

read log →