勝つ形

真ん中が一マス空いた ○_○○○ は、その空きマスを埋めるだけで五つになります。五目並べの用語では飛び四です。一手でこの飛び四と、別の列の連続した三(○○○)を同時に作れば勝ちです。相手は先に四を止めなければならず、その間に三を達四(両端が空いた四)にすれば、もう止める方法はありません。これが四三です。

人はこの位置を簡単に見つけます。ところが今のAIは、自分の石でこの形を作れるのに打ちません。

AIが見逃した四三。点線の位置に打てば横の飛び四と縦の連続した三ができるが、AIは列の左端に打った
AIが見逃した四三。点線の位置に打てば横の飛び四と縦の連続した三ができるが、AIは列の左端に打った

実際の対局では

Web対局24局を再生し、AI(白)の手番で一手で四三を作れた瞬間を探しました。そうした瞬間は7回あり、AIがその位置に打ったのは1回だけです。飛び四で勝てた2回はどちらも見逃しました。

逆に人は同じ形で勝ちました。人が勝った22局のうち11局が四三で終わりました。

人が勝った22局の終わり方。達四 9、四三(連続した三)6、四三(飛び三)5、四四 2
人が勝った22局の終わり方。達四 9、四三(連続した三)6、四三(飛び三)5、四四 2

下はそのうちの一局です。黒が点線の位置に打つと、斜めの飛び四(●●_●●)と横の飛び三(●●_●)が同時にできました。

実際の対局の四三。黒の23手目で四と飛び三が同時にできた
実際の対局の四三。黒の23手目で四と飛び三が同時にできた

ローカルAIのテスト

WebのローカルAIのコードをそのまま移し、15×15 の盤で向きをランダムに変えながらテストしました。自分の四三の位置を見つけた割合は、連続した四(○○○○)と三のときに29%、飛び四(○_○○○)と三のときに27%でした。相手が次の手で四三を作る位置を先に止めた割合も33%にとどまりました。三を一つ止めるだけなら、連続した三で67%、飛び三で100%でした。

ローカルAIのテスト。自分の四三探し 連続した四 29%、飛び四 27%、相手の四三の位置を止める 33%、連続した三を止める 67%、飛び三を止める 100%
ローカルAIのテスト。自分の四三探し 連続した四 29%、飛び四 27%、相手の四三の位置を止める 33%、連続した三を止める 67%、飛び三を止める 100%

なぜ見えないのか

ローカルAIは空きマスごとに四方向の点数を別々に付けて足します。ところが一つの方向については、そのマスから右や下のような前方にだけ5マスを数え、後方は見ません。上の図のように四と三の石が四三の位置の左や上にあると、その位置からは石が一つも見えません。むしろ列の左端から右を見ると石が三つ見えるので、そちらを選びます。

また、一列ずつ点数を足すだけで、二つの列が一点で交わって四三になることは別に見ていません。勝つ手を探すコードは、すぐに五つになる手だけを確認します。そのため四三は、方向が偶然合ったときにしか打ちません。

禁じ手の判定はエンジンが別に行い、エンジンは飛び三が混じった三三もきちんと禁じ手として捉えます。ルールは正しく見えていて、打つ側の判断が足りないのです。

この段階の実際のコード

ローカルAIが一つの空きマスの点数を付ける部分です。前方の5マスだけを数えていることが分かります。

omook-web/lib/ai.tstypescript · 38行
function linePotential(
  board: Stone[],
  r: number,
  c: number,
  dr: number,
  dc: number,
  color: Stone,
  size: number,
): number {
  let streak = 0;
  let blockedNear = false;
  const rr0 = r - dr;
  const cc0 = c - dc;
  if (inBounds(rr0, cc0, size)) {
    const oc = board[idx(rr0, cc0, size)];
    if (oc !== EMPTY && oc !== color) blockedNear = true;
  }

  let rr = r;
  let cc = c;
  for (let k = 0; k < 5 && inBounds(rr, cc, size); k++, rr += dr, cc += dc) {
    const v = board[idx(rr, cc, size)];
    if (v === color) streak++;
    else if (v === EMPTY) {
      /* open */
    } else break;
  }
  let score = streak * streak * 120;
  if (streak >= 4) score += 50000;
  if (blockedNear && streak >= 4) score = Math.floor(score / 8);
  return score;
}

function scorePlacement(board: Stone[], r: number, c: number, who: Stone, size: number): number {
  let s = 0;
  for (const [dr, dc] of DIRS) s += linePotential(board, r, c, dr, dc, who, size);
  return s;
}

次の記事は「4. VLM がヒューリスティックを学び、ヒューリスティックが再び教師になる計画」です。