![]() |
Jupyter at Bryn Mawr College |
|
|
Public notebooks: /services/public/dblank / CS371 Cognitive Science / 2016-Fall |
CS371: Introduction to Cognitive Science
Bryn Mawr College
Department of Computer Science
Professor Blank, Fall 2016
Goals:
In this notebook we will begin to explore symbolic computation by writing a program to play Chess. Perhaps you don't know how to play Chess... no problem! You don't really need to know much, but a primer on Chess may be useful. Here are some links that might be useful:
Getting Started with Chess:
For these experiments, we will use the python-chess
library. In this notebook, we will define three different sample players. We explore them in some depth here to attempt to understand how each plays chess.
python-chess Reference:
The first thing we need to do is import the chess library:
import chess
We will use the chess library in the following manner:
That's it! Thus we have reduced the playing a valid game of chess into simply selecting a move at each turn. To play a good game of chess, you will want to pick "the best move" at each turn.
A player will be a function that takes a board instance as a argument, and returns a move encoded as a string in Universal Chess Interface format:
def player(board):
### "Thinking" happens here
return move_code
We'll explain this fully below.
The Board class keeps track of whose turn it is, possible moves, and a history of all past moves. This class can undo and redo moves, and keeps track of repeating states.
First, we create a board instance:
board = chess.Board()
The board.turn
value is a boolean indicating whose turn it is. The values are either True for white or False for black.
board.turn
As seen above, the game always begins with white's turn. If you forget which is True, you can ask the chess module:
board.turn == chess.WHITE
The chess.Board class is responsible for generating all possible moves, whose turn it is, keeping track of the placement of all pieces, and making each move. The chess.Board represents a two-dimensional 8 x 8 array. However, the internal representation is optimized for speedy operations.
Here is a visual representation of a chess.Board:
board
You can also get an ASCII board representation (text-only) by converting the board into a string:
print(str(board))
The string representation of the board shows the character representation for each piece. Specifically:
Piece | White | Black |
---|---|---|
Pawn | P | p |
Rook | R | r |
Knight | N | n |
Bishop | B | b |
Queen | Q | q |
King | K | k |
For our uses, you don't really need to know how each piece moves. We discuss game strategy, though, shortly.
The 2-dimensional board is laid out so that each position is indicated by a column letter and row number. However, the internal representation is sequential. Say that we wanted to see what was at location 'c1' we could use:
chess.C1
to get the internal location of the column/row, and then use:
board.piece_at(chess.C1)
Or shown as a character:
str(board.piece_at(chess.C1))
Indeed, there is a white bishop at 'c1'.
At this point, we can as the board instance to generate all of the possible, legal moves:
list(board.legal_moves)
Python Note: board.legal_moves
looks like a normal list of items. But it is really a property that gets lazily generated on the fly. We force it to be a list by wrapping list()
around it.
We can get the first move (index zero):
move = list(board.legal_moves)[0]
The Universal Chess Interface (or uci) is a representation for describing a move from one cell to another (and perhaps additional information as well). We explore the first move:
move
move.uci()
Thus, this is a move from b1 to a3.
What piece is this, and where is it moving on the board? Is this a good move?
The uci string is what each player function will return.
If you know something about Chess, you might know about Standard Algebraic Notation (or san). This is an alternative to uci. You can get a move's san with:
board.san(move)
However, we will always use uci.
There is a useful function in the random module that will select from a a list of choices. This is called random.choice
.
import random
To use it in a function, we simply:
def random_player(board):
move = random.choice(list(board.legal_moves))
return move.uci()
random_player(board)
for i in range(10):
print(random_player(board))
To play a game, we'll write a new function called play_game
that will take two player functions, create a board, and alternatively call the player functions until a win, lose, or draw.
First, we need some additional modules for displaying a game in the notebook:
import time
from IPython.display import display, HTML, clear_output
A useful function for displaying the color of a player:
def who(player):
return "White" if player == chess.WHITE else "Black"
A function for displaying the board as text, or as the nice image (called SVG):
def display_board(board, use_svg):
if use_svg:
return board._repr_svg_()
else:
return "<pre>" + str(board) + "</pre>"
And finally, we can put those together to play a game:
def play_game(player1, player2, visual="svg", pause=0.1):
"""
playerN1, player2: functions that takes board, return uci move
visual: "simple" | "svg" | None
"""
use_svg = (visual == "svg")
board = chess.Board()
try:
while not board.is_game_over(claim_draw=True):
if board.turn == chess.WHITE:
uci = player1(board)
else:
uci = player2(board)
name = who(board.turn)
board.push_uci(uci)
board_stop = display_board(board, use_svg)
html = "<b>Move %s %s, Play '%s':</b><br/>%s" % (
len(board.move_stack), name, uci, board_stop)
if visual is not None:
if visual == "svg":
clear_output(wait=True)
display(HTML(html))
if visual == "svg":
time.sleep(pause)
except KeyboardInterrupt:
msg = "Game interrupted!"
return (None, msg, board)
result = None
if board.is_checkmate():
msg = "checkmate: " + who(not board.turn) + " wins!"
result = not board.turn
elif board.is_stalemate():
msg = "draw: stalemate"
elif board.is_fivefold_repetition():
msg = "draw: 5-fold repetition"
elif board.is_insufficient_material():
msg = "draw: insufficient material"
elif board.can_claim_draw():
msg = "draw: claim"
if visual is not None:
print(msg)
return (result, msg, board)
The function takes to player functions (first white, then black), and an optional argument to indicate representation style.
Let's pit random_player vs. random_player:
play_game(random_player, random_player)
Many times, that will end in a draw.
Do you want to play a game? Here is a way to play:
def human_player(board):
display(board)
uci = get_move("%s's move [q to quit]> " % who(board.turn))
legal_uci_moves = [move.uci() for move in board.legal_moves]
while uci not in legal_uci_moves:
print("Legal moves: " + (",".join(sorted(legal_uci_moves))))
uci = get_move("%s's move[q to quit]> " % who(board.turn))
return uci
And a helper function to handle the input:
def get_move(prompt):
uci = input(prompt)
if uci and uci[0] == "q":
raise KeyboardInterrupt()
try:
chess.Move.from_uci(uci)
except:
uci = None
return uci
Note that you must enter your move in UCI, such as "a2a4", meaning moving the piece at a2 to location a4.
Try you hand at playing chess against the random_player. It is not as easy as it sounds. Did you win? How many turns did it take?
If a random_player plays a random_player many times, how many times would you expect white to win? Black to win? To end in a draw?
Let's try it:
counts = {None: 0, True: 0, False: 0}
for i in range(10):
result, msg, board = play_game(random_player, random_player, visual=None)
counts[result] += 1
print(counts)
counts
The next sample player takes each possible move, applies it to a temporary board and state, and then goes through the board, place by place, in order to compute an evaluation score for each resulting state. The moves are sorted by this score, and the best move is then returned:
def player1(board):
moves = list(board.legal_moves)
for move in moves:
newboard = board.copy()
# go through board and return a score
move.score = staticAnalysis(newboard, move, board.turn)
moves.sort(key=lambda move: move.score, reverse=True) # sort on score
return moves[0].uci()
The actual score is computed by the staticAnalysis function which is designed to evaluate the resulting board after each hypothesized move. To come up with a score for each static snapshot of a board, it will be necessary to know how many of each piece is left, and where they are. You can use the board.pieces()
method for this:
board = chess.Board()
board.pieces(chess.ROOK, True)
If you look at the output as a list, you'll see the 1-D representation of where those pieces are on the game board:
list(board.pieces(chess.ROOK, True))
len(board.pieces(chess.ROOK, True))
There are 2 white rooks.
Now, putting that into a function, checking for each type of piece:
def staticAnalysis(board, move, my_color):
score = 0
## Check some things about this move:
# score += 10 if board.is_capture(move) else 0
# To actually make the move:
board.push(move)
# Now check some other things:
for (piece, value) in [(chess.PAWN, 1),
(chess.BISHOP, 4),
(chess.KING, 0),
(chess.QUEEN, 10),
(chess.KNIGHT, 5),
(chess.ROOK, 3)]:
score += len(board.pieces(piece, my_color)) * value
score -= len(board.pieces(piece, not my_color)) * value
# can also check things about the pieces position here
return score
play_game(player1, random_player)
NOTE: The string representation for the board is in Forsyth-Edwards Notation, or FEN for short. The last number (6th column) is the "full-move count". If the full-move count is 36, then there have been 35 * 2 full-moves, plus 1 if "b" is in second columns, for 71 moves.
That didn't play so well! Why not?
The following is one way around the problem. What does it do differently?
def staticAnalysis(board, move, my_color):
score = random.random()
## Check some things about this move:
# score += 10 if board.is_capture(move) else 0
# To actually make the move:
board.push(move)
# Now check some other things:
for (piece, value) in [(chess.PAWN, 1),
(chess.BISHOP, 4),
(chess.KING, 0),
(chess.QUEEN, 10),
(chess.KNIGHT, 5),
(chess.ROOK, 3)]:
score += len(board.pieces(piece, my_color)) * value
score -= len(board.pieces(piece, not my_color)) * value
# can also check things about the pieces position here
return score
play_game(player1, random_player)
Better! But it still is not very aggressive. What could we add to make it attack?
def staticAnalysis(board, move, my_color):
score = random.random()
## Check some things about this move:
# score += 10 if board.is_capture(move) else 0
# To actually make the move:
board.push(move)
# Now check some other things:
for (piece, value) in [(chess.PAWN, 1),
(chess.BISHOP, 4),
(chess.KING, 0),
(chess.QUEEN, 10),
(chess.KNIGHT, 5),
(chess.ROOK, 3)]:
score += len(board.pieces(piece, my_color)) * value
score -= len(board.pieces(piece, not my_color)) * value
# can also check things about the pieces position here
# Check global things about the board
score += 100 if board.is_checkmate() else 0
return score
play_game(player1, random_player)
Not bad!
This staticAnalysis function makes a much better player than either of the random players, but it still has major issues. How can you improve this static evaluation function?
Pawns get promoted when they get to the back row. Encourage them to get to the back row (eg, the closer they are to the opposite side, the better).
Static analysis on the next move's state can only do so much good. It would be better if you could "look ahead" further and see the results of what your opponent could do, given what your proposed move did. And then see what you could do, then what they would do, etc. This is how real chess programs work. There are many algorithms for finding the best move by looking many moves ahead, such as minimax and alpha-beta pruning. You'll explore these ideas fully in Artificial Intelligence.
Your board evaluation function could change during the game. For example, you might use one evaluation function at the beginning, one in the middle, and another at the end. How can you tell where you are in a game?
There is a nice article on Chess Strategy at wikipedia: http://en.wikipedia.org/wiki/Chess_strategy