2011/05/30

Negamax

Minmaxの実装をみると、playとplayOpponentが最大の評価値となる手を探すか、最小の評価値となる手を探すかを除いて、同じであることが分かる。max{x} = -min{-x} であることに注目すると、これらを一つの関数にまとめることができる。これは実装上のテクニックであって、探索手法としては同じであるが、このテクニックを用いて書いたMinmaxアルゴリズムをNegamaxとよぶことがある。
abstract class NegamaxPlayer[N <: Node[N]](val maxDepth: Int) extends Player[N] {

  override def play(ply: Int, node: N, last: Move): Move = {
    val (m, s) = play(node, maxDepth)
    m
  }

  def play(node: N, depth: Int): (Move, Int) = {
    if (depth == 0 || node.isTerminal) {
      return (Move.empty, score(node))
    }
    val moves = node.possibleMoves()
    var bestMove = Move.empty
    var maxS = Int.MinValue
    for (m <- moves) {
      val n = node.play(m).get
      val s = -play(n, depth - 1)._2
      if (s > maxS) {
        bestMove = m
        maxS = s
      }
    }
    (bestMove, maxS)
  }
  
  def score(node: N): Int
}

もちろん、強さはMinmaxと同じ。
negamax2 D: 48, minmax2 L: 49, -: 3
minmax2 D: 52, negamax2 L: 45, -: 3

2011/05/29

Minmax search

GreedyPlayerもSimpleHeuristicsPlayerも自分の手しか考えていない。これを、次の相手の手も考えて手を打つことにする。いわゆる先読みを行うことを考える。

相手の手を考えるというのは、相手がどの手を打ってくるかを予想することではなく、相手の最善手を考えること。各自分の手に対する相手の手の最善手を考え、その結果、最も有利になる自分の手を調べるということ。この際、相手の最善手もこちらの最善手を考慮してと再帰的に考えることができ、この過程はゲームの最終状態まで続く。これをminmaxアルゴリズム[1]といい、プログラムで表すと次のようになる。

abstract class MinmaxPlayer[N <: Node[N]] extends Player[N] {

  override def play(ply: Int, node: N, last: Move): Move = {
    val (m, s) = play(node)
    m
  }

  def play(node: N): (Move, Int) = {
    if (node.isTerminal) {
      return (Move.empty, score(node))
    }
    val moves = node.possibleMoves()
    var bestMove = Move.empty
    var maxS = Int.MinValue
    for (m <- moves) {
      val n = node.play(m).get
      val s = playOpponent(n)
      if (s > maxS) {
        bestMove = m
        maxS = s
      }
    }
    (bestMove, maxS)
  }

  def playOpponent(node: N): Int = {
    if (node.isTerminal) {
      return score(node)
    }
    val moves = node.possibleMoves()
    var minS = Int.MaxValue
    for (m <- moves) {
      val n = node.play(m).get
      val s = play(n)._2
      if (s < minS) {
        minS = s
      }
    }
    minS
  }

  def score(node: N): Int
}
しかし、このコードはreversiに対しては実際には動かない。このコードはゲームが終了するまでのすべての盤面を展開し、その中で最善となる手を探す。reversiの場合、その盤面数は1058程度といわれている[2]。 そこで、探索の深さを制限し、ある一定の深さまでで探索を打ち切ることによって、次の手を考える。
abstract class MinmaxPlayer[N <: Node[N]](val maxDepth: Int) extends Player[N] {

  override def play(ply: Int, node: N, last: Move): Move = {
    val (m, s) = play(node, maxDepth)
    m
  }

  def play(node: N, depth: Int): (Move, Int) = {
    if (depth == 0 || node.isTerminal) {
      return (Move.empty, score(node))
    }
    val moves = node.possibleMoves()
    var bestMove = Move.empty
    var maxS = Int.MinValue
    for (m <- moves) {
      val n = node.play(m).get
      val s = playOpponent(n, depth - 1)
      if (s > maxS) {
        bestMove = m
        maxS = s
      }
    }
    (bestMove, maxS)
  }

  def playOpponent(node: N, depth: Int): Int = {
    if (depth == 0 || node.isTerminal) {
      return score(node)
    }
    val moves = node.possibleMoves()
    var minS = Int.MaxValue
    for (m <- moves) {
      val n = node.play(m).get
      val s = play(n, depth - 1)._2
      if (s < minS) {
        minS = s
      }
    }
    minS
  }

  def score(node: N): Int
}
打ち切る際の盤面から、最終的な盤面でのscoreをいかに正確に近似できるかが鍵となるが、ここでは単純にそのときの盤面でのマーカーの数の差をそのまま用いると、depth=2での対戦成績は次のようになる。Greedyはちょうど深さ1の探索に相当するため、それには負けないが、Heuristicsを用いたものに負けている。
minmax2 D: 69, random L: 27, -: 4
random D: 29, minmax2 L: 68, -: 3
(28s)

minmax2 D: 68, greedy L: 28, -: 4
greedy D: 22, minmax2 L: 75, -: 3
(31s)

minmax2 D: 37, simple_heuristics L: 61, -: 2
simple_heuristics D: 70, minmax2 L: 29, -: 1
(27s)
depth=3での対戦成績は次のようになる。 深さが奇数の探索は、自分の手番までなのが気になるが、戦績は向上している。
minmax3 D: 77, random L: 21, -: 2
random D: 23, minmax3 L: 75, -: 2
(161s)

minmax3 D: 84, greedy L: 14, -: 2
greedy D: 16, minmax3 L: 78, -: 6
(220s)

minmax3 D: 32, simple_heuristics L: 61, -: 7
simple_heuristics D: 56, minmax3 L: 41, -: 3
(208s)
depth=4での対戦成績は次のようになる。計算時間が指数的に増大するため、10回のみの対戦になっている。 simple heuristicsとやっと互角程度になっている。
minmax4 D: 7, random L: 2, -: 1
random D: 1, minmax4 L: 9, -: 0
(174s)

minmax4 D: 9, greedy L: 1, -: 0
greedy D: 2, minmax4 L: 8, -: 0
(216s)

minmax4 D: 7, simple_heuristics L: 2, -: 1
simple_heuristics D: 5, minmax4 L: 5, -: 0
(198s)
実際の対戦を見てみると、隅を取られながらも勝っていることが多いよう。
60: L-h8
 abcdefgh 
1OOOXXXXO1
2XOOXXXXO2
3XXOOOXXO3
4XXXOXOOO4
5XXOXOOXO5
6XXXXOOXO6
7XXXOXXOO7
8XXXXXXXO8
 abcdefgh 

Dark
直接、depthが異なるもので対戦してみても、探索の深さが増すにつれ強くなっていることが分かる。
minmax2 D: 5, minmax3 L: 4, -: 1
minmax3 D: 7, minmax2 L: 3, -: 0

minmax2 D: 0, minmax4 L: 10, -: 0
minmax4 D: 8, minmax2 L: 2, -: 0

minmax3 D: 2, minmax4 L: 8, -: 0
minmax4 D: 9, minmax3 L: 1, -: 0
[1] Von Neumann, J: Zur Theorie der Gesellschaftsspiele Math. Annalen. 100 (1928) 295-320 [2] Victor Allis (1994). Searching for Solutions in Games and Artificial Intelligence. Ph.D. Thesis, University of Limburg, Maastricht, The Netherlands.

2011/05/23

SimpleHeuristicsPlayer

単純なheuristicsに基づくプレイヤーを実装する。特に、reversiの場合はこれだけでも十分に強いプレイヤーになる。
class SimpleHeuristicsPlayer[N <: Node[N]] extends Player[N] {

  override def play(node: N, last: Move): Move = {
    val moves = node.possibleMoves(marker)
    var nextMove = List[Move]()
    var maxS = Int.MinValue
    for (m <- moves) {
      (m: @unchecked) match {
        case PutMarker(x, y, _) =>
          val s = score(x, y)
          if (s > maxS) {
            nextMove = List(m)
            maxS = s
          } else if (s == maxS) {
            nextMove = m :: nextMove
          }
        case Pass =>
          nextMove = List(Pass)
      }
    }
    nextMove(Random.nextInt(nextMove.length))
  }

  val scores = List( 8,  2,  7,  4,
                     2,  1,  3,  4,
                     7,  3,  6,  5,
                     4,  4,  5,  0)

  def score(x: Int, y: Int): Int =
    if (x < 4) {
      if (y < 4) {
        scores(x + y * 4)
      } else {
        scores(x + (7 - y) * 4)
      }
    } else {
      if (y < 4) {
        scores((7 - x) + y * 4)
      } else {
        scores((7 - x) + (7 - y) * 4)
      }
    }

}

実際に、RandomPlayerとの対戦結果は次のとおり。
D: 802, L: 170, -: 28
D: 185, L: 790, -: 25
先手、後手でも8割近く勝てる。まだ、2割も負けるともいうが。


# 次の手をどこに打つかということをスコアにしているため、このPlayerはreversi専用となっている。

GreedyPlayer

ルールを覚えたてのときの戦略である、次の手を考えたときに、もっとも多くのマーカーがひっくり返せるところに打つプレイヤーを作る。
abstract class GreedyPlayer[N <: Node[N]] extends Player[N] {

  override def play(ply: Int, node: N, last: Move): Move = {
    val moves = node.possibleMoves()
    var bestMove = List[Move]()
    var maxS = Int.MinValue
    for (m <- moves) {
      val n = node.play(m).get
      val s = score(n)
      if (s > maxS) {
        bestMove = List(m)
        maxS = s
      } else if (s == maxS) {
        bestMove = m :: bestMove
      }
    }
    bestMove(Random.nextInt(bestMove.length))
  }

  def score(node: N): Int

}

実は、GreedyPlayer自体はreversiに特化されていなくて、単純にscoreがもっともよくなる次の手を打つというものである。ここで、scoreはゲームによって異なるのでabstractにしてある。単純にコマの数の差をスコアにすると、次のようになる。
trait MarkersScore {
  var marker: Marker
  /**
   * Returns a score for the marker
   */
  def score(node: ReversiNode): Int = {
    val nums = node.board.numOfMarkers
    if (marker == Dark) nums(Dark) - nums(Light)
    else nums(Light) - nums(Dark)
  }
}

RandomPlayerと1000番勝負をしてみると、Greedyが先手(D)で
D: 616, L: 349, -: 35
後手(L)で
D: 398, L: 567, -: 35
と、ちょっと強い程度。

2011/06/25: コード更新

2011/05/22

ScalaでReversi

ReversiをScalaでコーディングしてみる。

MarkerはEnumeration
object Marker extends Enumeration {
  type Marker = Value
  val Blank, Dark, Light = Value
}
import Marker._

ゲームを抽象的に表すためにMarker, Move, Nodeを次のように定義。
class Move()
object Move {
  val empty: Move = new Move
}

case object Pass extends Move
case class PutMarker(x: Int, y: Int) extends Move {
  override def toString: String = {
    val xs = ('a' + x).asInstanceOf[Char]
    val ys = ('1' + y).asInstanceOf[Char]
    "" + xs + ys
  }
}

trait Node[Repr <: Node[Repr]] {
  def play(move: Move): Option[Repr]
  def possibleMoves(): Seq[Move]
  def isTerminal: Boolean

  val marker: Marker = Dark
  lazy val opponentMarker: Marker =
    if (marker == Dark) Light else Dark
}
実際のreversiゲームにはNodeを通してアクセスされることになる。
class ReversiNode (
    override val marker: Marker,
    val board: ListBoard
) extends Node[ReversiNode] {
 
  override def play(move: Move): Option[ReversiNode] =
    (move: @unchecked) match {
      case Pass =>
        Some(new ReversiNode(opponentMarker, board))
      case PutMarker(x, y) =>
        if (board(x, y) != Blank) return None
        val (b, n) = reverse(x, y)
        if (n > 0)
          Some(new ReversiNode(opponentMarker, b))
        else
          None
    }

  private def reverse(x: Int, y: Int): (ListBoard, Int) = {
    var b = board.updated(x, y, marker)
    var n = 0
    for (t <- List((-1, -1), (-1, 0), (-1, 1),
                   (0, -1), (0, 1),
                   (1, -1), (1, 0), (1, 1))) {
      val (b2, dn) = reverseDxDy(b, x, y, t._1, t._2)
      b = b2
      n += dn
    }
    (b, n)
  }

  private def reverseDxDy(b: ListBoard, x: Int, y: Int, dx: Int, dy: Int): (ListBoard, Int) = {
    val n = opponentMarker
    var b2 = b
    var c = 0
    var s = x + dx
    var t = y + dy
    while (s >= 0 && s < 8 && t >= 0 && t < 8 && b2(s, t) == n) {
      b2 = b2.updated(s, t, marker)
      c += 1
      s += dx
      t += dy
    }
    if (c > 0 && s >= 0 && s < 8 && t >= 0 && t < 8 && b2(s, t) == marker) (b2, c)
    else (b, 0)
  }

  def possibleMoves(): Seq[Move] = {
    val moves = 
        for { x <- 0 until 8
              y <- 0 until 8
              if (board(x, y) == Blank)
              (b, n) = reverse(x, y)
              if (n > 0)
        } yield PutMarker(x, y)
    if (moves.isEmpty) List(Pass)
    else moves
  }

  def isTerminal: Boolean = {
    if (board.isFull) return true
    if (possibleMoves == List(Pass)) {
      val next = play(Pass).get
      if (next.possibleMoves == List(Pass)) {
        return true
      }
    }
    false
  }

  def winner: Marker = {
    val nums = board.numOfMarkers
    if (nums(Dark) < nums(Light)) Light
    else if (nums(Light) < nums(Dark)) Dark
    else Blank
  }

  override def toString: String =
    board.toString

}


object ReversiNode {
  val Start: ReversiNode =
      new ReversiNode(Dark,
                      new ListBoard().updated(3, 3, Light)
                                     .updated(4, 4, Light)
                                     .updated(3, 4, Dark)
                                     .updated(4, 3, Dark))
}


trait Board { def apply(x: Int, y: Int): Marker
  def updated(x: Int, y: Int, m: Marker): Board
  def numOfMarkers: Map[Marker, Int]
}

class ListBoard (
  private val list: List[Marker]
) extends Board {

  def this() = this(List.fill(64) { Blank })

  override def apply(x: Int, y: Int): Marker = list(index(x, y))

  override def updated(x: Int, y: Int, m: Marker): ListBoard =
    new ListBoard(list.updated(index(x, y), m))

  private def index(x: Int, y: Int) = x + y * 8
  
  override def numOfMarkers: Map[Marker, Int] = {
    val map = mutable.HashMap.empty ++
        (Marker.values map {_ -> 0})
    for (m <- list) {
      map(m) = map(m) + 1
    }
    map.toMap
  }

  override def toString: String = {
    val map = Map(Blank -> '.', Dark -> 'X', Light -> 'O')
    val ax = " abcdefgh \n"
    val b = for {y <- 0 until 8
                 ay = ('1' + y).asInstanceOf[Char]
                 l = for (x <- 0 until 8) yield map(list(index(x, y)))
    } yield ay + l.mkString + ay + '\n'
    ax + b.mkString + ax
  }

  def isFull: Boolean = list.forall { _ != Blank }

}
Playerは自分のmarkerを持ち、playすることができる。
trait Player[N <: Node[N]] extends NodeCount {
  var marker: Marker = _
  var opponentMarker: Marker = _
  var name: String = ""

  def init(m: Marker) {
    marker = m
  }
  
  def play(ply: Int, node: N, last: Move): Move
}
可能な手の中からランダムに手を選びプレイするRandomPlayer。馬鹿らしいPlayerだけど、これに5割以上で勝てないPlayerもあり得ることに注意。
class RandomPlayer[N <: Node[N]] extends Player[N] {
  override def play(ply: Int, node: N, last: Move): Move = {
    val moves = node.possibleMoves()
    if (moves.length > 0)
      moves(Random.nextInt(moves.length))
    else
      Move.empty
  }
}
Github https://github.com/stn/reversi/blob/master/src/main/scala/

2011/06/07: コードを更新
2011/06/25: コードを更新