From da837dcf2de26ac2373c9c83661cf2a63075ab76 Mon Sep 17 00:00:00 2001 From: "Thouis (Ray) Jones" Date: Tue, 5 Apr 2016 09:33:38 -0700 Subject: [PATCH 001/191] python3 compatibility changes --- AlphaGo/go.py | 2 +- AlphaGo/preprocessing/game_converter.py | 16 ++++++++-------- AlphaGo/preprocessing/preprocessing.py | 2 +- AlphaGo/util.py | 8 +++++--- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/AlphaGo/go.py b/AlphaGo/go.py index 5dfd01f23..93a1eb915 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -87,7 +87,7 @@ def _neighbors(self, position): the given (x,y) position. Basically it handles edges and corners. """ (x, y) = position - return filter(self._on_board, [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)]) + return [xy for xy in [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)] if self._on_board(xy)] def _diagonals(self, position): """Like _neighbors but for diagonal positions diff --git a/AlphaGo/preprocessing/game_converter.py b/AlphaGo/preprocessing/game_converter.py index b42e752a7..d5f0b6ee0 100644 --- a/AlphaGo/preprocessing/game_converter.py +++ b/AlphaGo/preprocessing/game_converter.py @@ -88,12 +88,12 @@ def sgfs_to_hdf5(self, sgf_files, hdf5_file, bd_size=19, ignore_errors=True, ver file_offsets = h5f.require_group('file_offsets') if verbose: - print "created HDF5 dataset in", tmp_file + print("created HDF5 dataset in {}".format(tmp_file)) next_idx = 0 for file_name in sgf_files: if verbose: - print file_name + print(file_name) # count number of state/action pairs yielded by this game n_pairs = 0 file_start_idx = next_idx @@ -115,7 +115,7 @@ def sgfs_to_hdf5(self, sgf_files, hdf5_file, bd_size=19, ignore_errors=True, ver except Exception as e: # catch everything else if ignore_errors: - warnings.warn("Unkown exception with file %s\n\t%s" % (file_name, e)) + warnings.warn("Unkown exception with file %s\n\t%s" % (file_name, e), stacklevel=2) else: raise e finally: @@ -124,16 +124,16 @@ def sgfs_to_hdf5(self, sgf_files, hdf5_file, bd_size=19, ignore_errors=True, ver file_name_key = file_name.replace('/', ':') file_offsets[file_name_key] = [file_start_idx, n_pairs] if verbose: - print "\t%d state/action pairs extracted" % n_pairs + print("\t%d state/action pairs extracted" % n_pairs) elif verbose: - print "\t-no usable data-" + print("\t-no usable data-") except Exception as e: - print "sgfs_to_hdf5 failed" + print("sgfs_to_hdf5 failed") os.remove(tmp_file) raise e if verbose: - print "finished. renaming %s to %s" % (tmp_file, hdf5_file) + print("finished. renaming %s to %s" % (tmp_file, hdf5_file)) # processing complete; rename tmp_file to hdf5_file h5f.close() @@ -180,7 +180,7 @@ def run_game_converter(cmd_line_args=None): feature_list = args.features.split(",") if args.verbose: - print "using features", feature_list + print("using features", feature_list) converter = game_converter(feature_list) diff --git a/AlphaGo/preprocessing/preprocessing.py b/AlphaGo/preprocessing/preprocessing.py index c62a3b360..4a792f46f 100644 --- a/AlphaGo/preprocessing/preprocessing.py +++ b/AlphaGo/preprocessing/preprocessing.py @@ -82,7 +82,7 @@ def get_capture_size(state, maximum=8): # (note suicide and ko are not an issue because they are not # legal moves) (gx, gy) = next(iter(neighbor_group)) - if state.liberty_counts[gx][gy] == 1: + if (state.liberty_counts[gx][gy] == 1) and (state.board[gx, gy] != state.current_player): n_captured += len(state.group_sets[gx][gy]) planes[x, y, min(n_captured, maximum - 1)] = 1 return planes diff --git a/AlphaGo/util.py b/AlphaGo/util.py index 453ed622c..0a7d6460e 100644 --- a/AlphaGo/util.py +++ b/AlphaGo/util.py @@ -1,7 +1,9 @@ import sgf -import string from AlphaGo import go +# for board location indexing +LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' + def _parse_sgf_move(node_value): """Given a well-formed move string, return either PASS_MOVE or the (x, y) position @@ -9,8 +11,8 @@ def _parse_sgf_move(node_value): if node_value == '' or node_value == 'tt': return go.PASS_MOVE else: - row = string.letters.index(node_value[1]) - col = string.letters.index(node_value[0]) + row = LETTERS.index(node_value[1].upper()) + col = LETTERS.index(node_value[0].upper()) # GameState expects (x, y) where x is column and y is row return (col, row) From 59c7d642c49557372a70b256a2ba093862d12aec Mon Sep 17 00:00:00 2001 From: cjbates-mac Date: Wed, 23 Mar 2016 14:02:21 -0400 Subject: [PATCH 002/191] start to reinforcement_policy_trainer.py; does self-play while extracting training tuples. Reinforcement training part still remains to be implemented. For testing purposes, modified GreedyPolicyPlayer to return only sensible moves, rather than merely legal moves. Sensible moves are legal plus not playing in own eye. --- AlphaGo/ai.py | 21 +++++++++++ AlphaGo/go.py | 2 ++ .../training/reinforcement_policy_trainer.py | 36 +++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/AlphaGo/ai.py b/AlphaGo/ai.py index e69de29bb..ef5fd874e 100644 --- a/AlphaGo/ai.py +++ b/AlphaGo/ai.py @@ -0,0 +1,21 @@ +class GreedyPolicyPlayer(object): + """A CNN player that uses a greedy policy (i.e. chooses the highest probability + move at each point) + """ + def __init__(self, policy_function): + self.policy = policy_function + + def get_move(self, state): + action_probs = self.policy.eval_state(state) + if len(action_probs) > 0: + sensible_actions = [a for a in action_probs if not state.is_eye( + a[0], state.current_player)] + if len(sensible_actions) > 0: + max_prob = max(sensible_actions, key=lambda (a, p): p) + return max_prob[0] + else: + # No legal moves available, do so pass move + return None + else: + # No legal moves available, do so pass move + return None diff --git a/AlphaGo/go.py b/AlphaGo/go.py index 5dfd01f23..e7042ada7 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -270,6 +270,7 @@ def do_move(self, action, color=None): if self.is_legal(action): # reset ko self.ko = None + # is_sensible = True if action is not PASS_MOVE: (x, y) = action self.board[x][y] = color @@ -300,6 +301,7 @@ def do_move(self, action, color=None): self.current_player = -color self.turns_played += 1 self.history.append(action) + # return is_sensible else: raise IllegalMove(str(action)) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index e69de29bb..4822d6831 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -0,0 +1,36 @@ +from AlphaGo.ai import GreedyPolicyPlayer +from AlphaGo.models.policy import CNNPolicy +from interface.Play import play_match +import numpy as np + + +class train_policy(object): + """Train reinforcement learning policy. First, extract training tuples from + game (`get_training_pairs`), then use those tuples for reinforcement learning. + Call `train_policy.train()` to first extract training pairs and then run + training. Initialize object with any two policy players (must have `get_move` + function that returns a single move tuple given board state array). + """ + + def __init__(self, player1, player2): + self.player1 = player1 + self.player2 = player2 + self.match = play_match(player1, player2, 'test') + self.training_pairs = [] + + def get_training_pairs(self): + while True: + end_of_game = self.match.play() + print "turns played:", self.match.state.turns_played + # Append training pair + self.training_pairs.append((self.match.state.board.copy(), self.match.state.history[-1])) + # if self.match.state.turns_played % 1 == 0: + # print self.match.state.board + if end_of_game: + break + return self.training_pairs + + def train(self): + training_pairs = self.get_training_pairs() + # TODO... + return From 755d4535e4a8b8db936f43872548da8cbbc06d39 Mon Sep 17 00:00:00 2001 From: wrongu Date: Fri, 8 Apr 2016 12:03:32 -0400 Subject: [PATCH 003/191] test_policy tests GreedyPolicyPlayer --- tests/test_policy.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_policy.py b/tests/test_policy.py index 86bd44c55..479aedb9c 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -1,5 +1,6 @@ from AlphaGo.models.policy import CNNPolicy from AlphaGo.go import GameState +from AlphaGo.ai import GreedyPolicyPlayer import unittest import os @@ -35,5 +36,17 @@ def test_save_load(self): os.remove(model_file) os.remove(weights_file) + +class TestPlayers(unittest.TestCase): + + def test_greedy_player(self): + gs = GameState() + policy = CNNPolicy(["board", "ones", "turns_since"]) + player = GreedyPolicyPlayer(policy) + for i in range(20): + move = player.get_move(gs) + self.assertIsNotNone(move) + gs.do_move(move) + if __name__ == '__main__': unittest.main() From b3c3c72a7b09ed9abc0ca1d5b22dcb8fe1a4d61b Mon Sep 17 00:00:00 2001 From: wrongu Date: Fri, 8 Apr 2016 11:51:09 -0400 Subject: [PATCH 004/191] shortened GreedyPolicyPlayer, added SamplingPolicyPlayer --- AlphaGo/ai.py | 57 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/AlphaGo/ai.py b/AlphaGo/ai.py index ef5fd874e..3c598bbe2 100644 --- a/AlphaGo/ai.py +++ b/AlphaGo/ai.py @@ -1,21 +1,48 @@ +import numpy as np +from AlphaGo import go + + class GreedyPolicyPlayer(object): - """A CNN player that uses a greedy policy (i.e. chooses the highest probability - move at each point) + """A player that uses a greedy policy (i.e. chooses the highest probability + move each turn) """ + def __init__(self, policy_function): self.policy = policy_function def get_move(self, state): - action_probs = self.policy.eval_state(state) - if len(action_probs) > 0: - sensible_actions = [a for a in action_probs if not state.is_eye( - a[0], state.current_player)] - if len(sensible_actions) > 0: - max_prob = max(sensible_actions, key=lambda (a, p): p) - return max_prob[0] - else: - # No legal moves available, do so pass move - return None - else: - # No legal moves available, do so pass move - return None + sensible_moves = [move for move in state.get_legal_moves() if not state.is_eye(move, state.current_player)] + if len(sensible_moves) > 0: + move_probs = self.policy.eval_state(state, sensible_moves) + max_prob = max(move_probs, key=lambda (a, p): p) + return max_prob[0] + # No 'sensible' moves available, so do pass move + return go.PASS_MOVE + + +class SamplingPolicyPlayer(object): + """A player that samples a move in proportion to the probability given by the + policy. + + By manipulating the 'temperature', moves can be pushed towards totally random + (high temperature) or towards greedy play (low temperature) + """ + + def __init__(self, policy_function, temperature=1.0): + assert(temperature > 0.0) + self.policy = policy_function + self.exp = 1.0 / temperature + + def get_move(self, state): + sensible_moves = [move for move in state.get_legal_moves() if not state.is_eye(move, state.current_player)] + if len(sensible_moves) > 0: + move_probs = self.policy.eval_state(state, sensible_moves) + # zip(*list) is like the 'transpose' of zip; zip(*zip([1,2,3], [4,5,6])) is [(1,2,3), (4,5,6)] + moves, probabilities = zip(*move_probs) + probabilities = np.array(probabilities) + probabilities = probabilities ** self.exp + probabilities = probabilities / probabilities.sum() + # numpy interprets a list of tuples as 2D, so we must choose an _index_ of moves then apply it in 2 steps + choice_idx = np.random.choice(range(len(moves)), p=probabilities) + return moves[choice_idx] + return go.PASS_MOVE From 45c8842af8f7a4eafcbc31e05eb47a8f0a5663e0 Mon Sep 17 00:00:00 2001 From: wrongu Date: Fri, 8 Apr 2016 13:03:03 -0400 Subject: [PATCH 005/191] testing sampling player --- tests/test_policy.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/test_policy.py b/tests/test_policy.py index 479aedb9c..4a9e26ad3 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -1,6 +1,6 @@ from AlphaGo.models.policy import CNNPolicy from AlphaGo.go import GameState -from AlphaGo.ai import GreedyPolicyPlayer +from AlphaGo.ai import GreedyPolicyPlayer, SamplingPolicyPlayer import unittest import os @@ -48,5 +48,14 @@ def test_greedy_player(self): self.assertIsNotNone(move) gs.do_move(move) + def test_sampling_player(self): + gs = GameState() + policy = CNNPolicy(["board", "ones", "turns_since"]) + player = SamplingPolicyPlayer(policy) + for i in range(20): + move = player.get_move(gs) + self.assertIsNotNone(move) + gs.do_move(move) + if __name__ == '__main__': unittest.main() From d8f5a664212d9a5371283116606f5c1b236efbdb Mon Sep 17 00:00:00 2001 From: cjbates-mac Date: Tue, 29 Mar 2016 15:54:07 -0400 Subject: [PATCH 006/191] reinforcement_policy_trainer.py in progress. Still not functional. Conflicts: AlphaGo/go.py AlphaGo/training/reinforcement_policy_trainer.py --- AlphaGo/go.py | 45 ++++- .../training/reinforcement_policy_trainer.py | 160 ++++++++++++++++-- 2 files changed, 184 insertions(+), 21 deletions(-) diff --git a/AlphaGo/go.py b/AlphaGo/go.py index e7042ada7..43bfbfea1 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -10,7 +10,7 @@ class GameState(object): """State of a game of Go and some basic functions to interact with it """ - def __init__(self, size=19): + def __init__(self, size=19, komi=5): self.board = np.zeros((size, size)) self.board.fill(EMPTY) self.size = size @@ -20,6 +20,10 @@ def __init__(self, size=19): self.history = [] self.num_black_prisoners = 0 self.num_white_prisoners = 0 + self.komi = komi # Komi is number of extra points WHITE gets for going 2nd + # Each pass move by a player subtracts a point + self.passes_white = 0 + self.passes_black = 0 # `self.liberty_sets` is a 2D array with the same indexes as `board` # each entry points to a set of tuples - the liberties of a stone's # connected block. By caching liberties in this way, we can directly @@ -260,6 +264,36 @@ def get_legal_moves(self): moves.append((x, y)) return moves + def get_winner(self): + """Calculate score of board state and return player ID (1, -1, or 0 for tie) + corresponding to winner. Uses 'Area scoring'. + """ + # Count number of positions filled by each player, and add 1 for each eye + # Assumes that only empty spaces left on board are eyes (i.e. game fully + # played out) + score_white = np.sum(self.board == WHITE) + score_black = np.sum(self.board == BLACK) + eyes = np.where(self.board == 0) + eyes = zip(eyes[0], eyes[1]) + for eye in eyes: + # Check that all surrounding points are of one color + neighbors = self._neighbors((eye[0], eye[1])) + if np.all([self.board[n] == BLACK for n in neighbors]): + score_black += 1 + if np.all([self.board[n] == WHITE for n in neighbors]): + score_white += 1 + score_white += self.komi + score_white -= self.passes_white + score_black -= self.passes_black + if score_black > score_white: + winner = BLACK + elif score_white > score_black: + winner = WHITE + else: + # Tie + winner = None + return winner + def do_move(self, action, color=None): """Play stone at action=(x,y). If color is not specified, current_player is used @@ -270,12 +304,10 @@ def do_move(self, action, color=None): if self.is_legal(action): # reset ko self.ko = None - # is_sensible = True if action is not PASS_MOVE: (x, y) = action self.board[x][y] = color self._update_neighbors(action) - # check neighboring groups' liberties for captures for (nx, ny) in self._neighbors(action): if self.board[nx, ny] == -color and len(self.liberty_sets[nx][ny]) == 0: @@ -297,11 +329,16 @@ def do_move(self, action, color=None): if would_recapture and recapture_size_is_1: # note: (nx,ny) is the stone that was captured self.ko = (nx, ny) + else: + # Subtract point for pass move + if self.current_player == WHITE: + self.passes_white += 1 + else: + self.passes_black += 1 # next turn self.current_player = -color self.turns_played += 1 self.history.append(action) - # return is_sensible else: raise IllegalMove(str(action)) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 4822d6831..d8f9d9c22 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -1,10 +1,49 @@ +import argparse from AlphaGo.ai import GreedyPolicyPlayer +import AlphaGo.go as go from AlphaGo.models.policy import CNNPolicy +from AlphaGo.preprocessing.preprocessing import Preprocess from interface.Play import play_match +from keras.optimizers import SGD import numpy as np +import os -class train_policy(object): +def make_training_pairs(match, feature_list): + while True: + training_pairs = [] + preprocessor = Preprocess(feature_list) + bsize = match.state.board.shape[0] + while True: + # Cached copy of previous board state, so that training pairs are + # between latest move and the board state when it was being considered. + state = match.state.copy() + # Do move + end_of_game = match.play() + print "turns played:", match.state.turns_played + move = match.state.history[-1] + # Only add training pairs and tensors for BLACK moves and not pass moves + if match.state.current_player != go.BLACK and \ + move is not go.PASS_MOVE: + # Convert move to one-hot + move_1hot = np.zeros((1, bsize, bsize)) + move_1hot[0][move] = 1 + # Add training pairs + training_pairs.append((preprocessor.state_to_tensor(state), + move_1hot)) + # Print out board states for debugging purposes + if match.state.turns_played % 1 == 0: + print match.state.board + # End game prematurely for debugging + if match.state.turns_played > 10: + break + # Detect end of game + if end_of_game: + break + yield training_pairs + + +class RL_policy_trainer(object): """Train reinforcement learning policy. First, extract training tuples from game (`get_training_pairs`), then use those tuples for reinforcement learning. Call `train_policy.train()` to first extract training pairs and then run @@ -12,25 +51,112 @@ class train_policy(object): function that returns a single move tuple given board state array). """ - def __init__(self, player1, player2): + def __init__(self, player1, player2, feature_list, learning_rate=0.01): self.player1 = player1 self.player2 = player2 self.match = play_match(player1, player2, 'test') - self.training_pairs = [] + # Player1 is by convention the network model to be updated by reinforcement + self.model = player1.policy.model # Keras model, inherited from supervised training phase + self.learning_rate = learning_rate # TODO + self.preprocessor = Preprocess(feature_list) - def get_training_pairs(self): - while True: - end_of_game = self.match.play() - print "turns played:", self.match.state.turns_played - # Append training pair - self.training_pairs.append((self.match.state.board.copy(), self.match.state.history[-1])) - # if self.match.state.turns_played % 1 == 0: - # print self.match.state.board - if end_of_game: - break - return self.training_pairs + def save_model(self): + # TODO + pass + # self.fname = self.save_dir + # self.model.save_weights('fname.h5') + # self.model.save_json('fname.json') - def train(self): - training_pairs = self.get_training_pairs() - # TODO... + def train(self, training_pairs): + # Make training tuples + self.make_training_pairs() + # Calculate which player won + self.winner = self.match.state.get_winner() + # Concatenate input and target tensors + input_tensors = [] + target_tensors = [] + for t in training_pairs: + input_tensors.append(t[0]) + target_tensors.append(t[1]) + input_tensors = np.concatenate(input_tensors, axis=0) + target_tensors = np.concatenate(target_tensors, axis=0) + # Initialize SGD and load keras net + sgd = SGD(lr=self.learning_rate) + self.model.compile(loss='binary_crossentropy', optimizer=sgd) + # Update weights in + direction if player won, and - direction if player lost. + # Setting sample_weight negative is hack for negative weights update. + # states = np.array([s[0] for s in samples if s[1] is not None]) + if self.winner == 1: + sw = np.ones(len(input_tensors)) + else: + sw = np.ones(len(input_tensors)) * -1 + self.model.fit(input_tensors, target_tensors, nb_epoch=1, + batch_size=len(input_tensors), sample_weight=sw) + # Save new weights + # TODO + self.save_model() return + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Perform reinforcement learning ' + 'to improve given policy network. Second phase of pipeline.') + parser.add_argument("initial_weights", help="Path to file with weights to start from.") + parser.add_argument("initial_json", help="Path to folder of training samples") + parser.add_argument("--model_folder", help="Path to folder where the model " + "params will be saved after each epoch. Default: None", default=None) + parser.add_argument( + "--learning_rate", help="Keras learning rate (Default: .03)", type=float, + default=.03) + parser.add_argument( + "--nb_worker", help="Number of threads to use when training in parallel. " + "Requires appropriately set Theano flags.", type=int, default=1) + parser.add_argument( + "--save_every", help="Save policy every n mini-batches (Default: 500)", + type=int, default=500) + parser.add_argument( + "--game_batch_size", help="Number of games per mini-batch (Default: 10)", + type=int, default=10) + # game batch size + # Baseline function (TODO) default lambda state: 0 (receives either file + # paths to JSON and weights or None, in which case it uses default baseline 0) + args = parser.parse_args() + trainer = RL_policy_trainer + + + # from ipdb import set_trace as BP + # import pickle + # np.set_printoptions(linewidth=160) + # features = ["board", "ones", "turns_since", "liberties", "capture_size", + # "self_atari_size", "liberties_after", + # "sensibleness", "zeros"] + # policy1 = CNNPolicy(features) + # policy2 = CNNPolicy(features) + # player1 = GreedyPolicyPlayer(policy1) + # player2 = GreedyPolicyPlayer(policy2) + # train = train_policy(player1, player2, features) + +# Test get_training_pairs: +# train.make_training_pairs() +# with open('game.pkl', 'wb') as fid: +# pickle.dump(train.training_pairs, fid) +# with open('tensors.pkl', 'wb') as fid: +# pickle.dump(train.tensors, fid) +# with open('history.pkl', 'wb') as fid: +# pickle.dump(train.match.state.history, fid) +# training_pairs = train.train() +# with open('board.pkl', 'wb') as fid: +# pickle.dump(train.match.state.board, fid) +# np.save('board.npy', train.match.state.board) + +# BP() +# with open('game.pkl', 'rb') as fid: +# train.training_pairs = pickle.load(fid) +# with open('tensors.pkl', 'rb') as fid: +# train.tensors = pickle.load(fid) +# with open('history.pkl', 'rb') as fid: +# train.match.state.history = pickle.load(fid) +# # with open('board.pkl', 'rb') as fid: +# # train.match.state.board = pickle.load(fid) +# train.match.state.board = np.load('board.npy') +# train.train() From d2a43a8f862629e8a9e41340d9ded3c072e2e830 Mon Sep 17 00:00:00 2001 From: cjbates-mac Date: Sun, 3 Apr 2016 15:14:04 -0400 Subject: [PATCH 007/191] Minimal working version of reinforcement_policy_trainer. Currently not yet implemented for parallelization. Needs testing. Added a ProbabilisticPolicyPlayer. Conflicts: AlphaGo/ai.py AlphaGo/training/reinforcement_policy_trainer.py interface/Play.py --- AlphaGo/ai.py | 11 +- .../training/reinforcement_policy_trainer.py | 326 ++++++++++++------ interface/Play.py | 35 ++ tests/test_policy.py | 4 +- 4 files changed, 257 insertions(+), 119 deletions(-) create mode 100644 interface/Play.py diff --git a/AlphaGo/ai.py b/AlphaGo/ai.py index 3c598bbe2..2a4a73d53 100644 --- a/AlphaGo/ai.py +++ b/AlphaGo/ai.py @@ -1,5 +1,6 @@ -import numpy as np +"""Policy players""" from AlphaGo import go +import numpy as np class GreedyPolicyPlayer(object): @@ -20,7 +21,7 @@ def get_move(self, state): return go.PASS_MOVE -class SamplingPolicyPlayer(object): +class ProbabilisticPolicyPlayer(object): """A player that samples a move in proportion to the probability given by the policy. @@ -31,7 +32,7 @@ class SamplingPolicyPlayer(object): def __init__(self, policy_function, temperature=1.0): assert(temperature > 0.0) self.policy = policy_function - self.exp = 1.0 / temperature + self.beta = 1.0 / temperature def get_move(self, state): sensible_moves = [move for move in state.get_legal_moves() if not state.is_eye(move, state.current_player)] @@ -40,9 +41,9 @@ def get_move(self, state): # zip(*list) is like the 'transpose' of zip; zip(*zip([1,2,3], [4,5,6])) is [(1,2,3), (4,5,6)] moves, probabilities = zip(*move_probs) probabilities = np.array(probabilities) - probabilities = probabilities ** self.exp + probabilities = probabilities ** self.beta probabilities = probabilities / probabilities.sum() # numpy interprets a list of tuples as 2D, so we must choose an _index_ of moves then apply it in 2 steps - choice_idx = np.random.choice(range(len(moves)), p=probabilities) + choice_idx = np.random.choice(len(moves), p=probabilities) return moves[choice_idx] return go.PASS_MOVE diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index d8f9d9c22..3a81c7523 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -1,77 +1,89 @@ import argparse -from AlphaGo.ai import GreedyPolicyPlayer +from AlphaGo.ai import GreedyPolicyPlayer, ProbabilisticPolicyPlayer import AlphaGo.go as go from AlphaGo.models.policy import CNNPolicy from AlphaGo.preprocessing.preprocessing import Preprocess from interface.Play import play_match from keras.optimizers import SGD +from keras.models import model_from_json import numpy as np import os -def make_training_pairs(match, feature_list): +def _make_training_pairs(match, feature_list): + """Make training pairs for single match. + + Args: + match -- play_match object between player and opponent + feature_list -- game features to be one-hot encoded + + Return: + training_pairs -- list of tuples. All player's moves in game, paired with + the board state just before (both 1-hot encoded). + """ + training_pairs = [] + preprocessor = Preprocess(feature_list) + bsize = match.state.board.shape[0] + # Play out game while True: - training_pairs = [] - preprocessor = Preprocess(feature_list) - bsize = match.state.board.shape[0] - while True: - # Cached copy of previous board state, so that training pairs are - # between latest move and the board state when it was being considered. - state = match.state.copy() - # Do move - end_of_game = match.play() - print "turns played:", match.state.turns_played - move = match.state.history[-1] - # Only add training pairs and tensors for BLACK moves and not pass moves - if match.state.current_player != go.BLACK and \ - move is not go.PASS_MOVE: - # Convert move to one-hot - move_1hot = np.zeros((1, bsize, bsize)) - move_1hot[0][move] = 1 - # Add training pairs - training_pairs.append((preprocessor.state_to_tensor(state), - move_1hot)) - # Print out board states for debugging purposes - if match.state.turns_played % 1 == 0: - print match.state.board - # End game prematurely for debugging - if match.state.turns_played > 10: - break - # Detect end of game - if end_of_game: - break - yield training_pairs - - -class RL_policy_trainer(object): - """Train reinforcement learning policy. First, extract training tuples from - game (`get_training_pairs`), then use those tuples for reinforcement learning. - Call `train_policy.train()` to first extract training pairs and then run - training. Initialize object with any two policy players (must have `get_move` - function that returns a single move tuple given board state array). + # Cached copy of previous board state, so that training pairs are + # between latest move and the board state when it was being considered. + state = match.state.copy() + # Do move + end_of_game = match.play() + print "turns played:", match.state.turns_played + move = match.state.history[-1] + # Only add training pairs and tensors for BLACK moves and not pass moves + if match.state.current_player != go.BLACK and \ + move is not go.PASS_MOVE: + # Convert move to one-hot + move_1hot = np.zeros((1, bsize, bsize)) + move_1hot[0][move] = 1 + state_1hot = preprocessor.state_to_tensor(state) + # Add training pairs + training_pairs.append((state_1hot, + move_1hot)) + # Print out board states for debugging purposes + if match.state.turns_played % 1 == 0: + print match.state.board + # End game prematurely for debugging + if match.state.turns_played > 10: + break + # Detect end of game + if end_of_game: + break + return training_pairs + + +def make_training_pairs(player, opp, mini_batch_size, sgd, features): + """Make all n training pairs for a single batch, by playing n games + against fixed opponent. """ + # Make training pairs + winners = [] + training_pairs_list = [] + for i in xrange(mini_batch_size): + match = play_match(player, opp) + training_pairs_list.append(_make_training_pairs(match, features)) + winners.append(match.state.get_winner()) + return training_pairs_list, winners - def __init__(self, player1, player2, feature_list, learning_rate=0.01): - self.player1 = player1 - self.player2 = player2 - self.match = play_match(player1, player2, 'test') - # Player1 is by convention the network model to be updated by reinforcement - self.model = player1.policy.model # Keras model, inherited from supervised training phase - self.learning_rate = learning_rate # TODO - self.preprocessor = Preprocess(feature_list) - def save_model(self): - # TODO - pass - # self.fname = self.save_dir - # self.model.save_weights('fname.h5') - # self.model.save_json('fname.json') - - def train(self, training_pairs): - # Make training tuples - self.make_training_pairs() - # Calculate which player won - self.winner = self.match.state.get_winner() +def train_batch(player, training_pairs_list, winners): + """Given the outcomes of a mini-batch of play against a fixed opponent, + update the weights with reinforcement learning. + + Args: + player -- player object with policy weights to be updated + training_pairs_list -- List of one-hot encoded state-action pairs. + winners -- List of winners corresponding to each item in + training_pairs_list + + Return: + player -- same player, with updated weights. + """ + player.policy.model.compile(loss='binary_crossentropy', optimizer=sgd) + for training_pairs, winner in zip(training_pairs_list, winners): # Concatenate input and target tensors input_tensors = [] target_tensors = [] @@ -80,29 +92,39 @@ def train(self, training_pairs): target_tensors.append(t[1]) input_tensors = np.concatenate(input_tensors, axis=0) target_tensors = np.concatenate(target_tensors, axis=0) - # Initialize SGD and load keras net - sgd = SGD(lr=self.learning_rate) - self.model.compile(loss='binary_crossentropy', optimizer=sgd) # Update weights in + direction if player won, and - direction if player lost. # Setting sample_weight negative is hack for negative weights update. # states = np.array([s[0] for s in samples if s[1] is not None]) - if self.winner == 1: + if winner == 1: sw = np.ones(len(input_tensors)) else: sw = np.ones(len(input_tensors)) * -1 - self.model.fit(input_tensors, target_tensors, nb_epoch=1, - batch_size=len(input_tensors), sample_weight=sw) - # Save new weights - # TODO - self.save_model() - return + player.policy.model.fit(input_tensors, target_tensors, nb_epoch=1, + batch_size=len(input_tensors), sample_weight=sw) + return player + + +def run(player, args, opponents, sgd, features): + for i_iter in xrange(args.iterations): + # Train mini-batches + for i_batch in xrange(args.save_every): + # Randomly choose opponent from pool + opp = np.random.choice(opponents) + # Make training pairs and do RL + training_pairs_list, winners = make_training_pairs( + player, opp, args.game_batch_size, sgd, features) + player = train_batch(player, training_pairs_list, winners) + # TODO: Save policy to model folder + # Add snapshot of player to pool of opponents + opponents.append(player.copy()) + # from ipdb import set_trace as BP; BP() if __name__ == '__main__': parser = argparse.ArgumentParser(description='Perform reinforcement learning ' 'to improve given policy network. Second phase of pipeline.') parser.add_argument("initial_weights", help="Path to file with weights to start from.") - parser.add_argument("initial_json", help="Path to folder of training samples") + parser.add_argument("initial_json", help="Path to file with initial network params.") parser.add_argument("--model_folder", help="Path to folder where the model " "params will be saved after each epoch. Default: None", default=None) parser.add_argument( @@ -115,48 +137,128 @@ def train(self, training_pairs): "--save_every", help="Save policy every n mini-batches (Default: 500)", type=int, default=500) parser.add_argument( - "--game_batch_size", help="Number of games per mini-batch (Default: 10)", - type=int, default=10) + "--game_batch_size", help="Number of games per mini-batch (Default: 20)", + type=int, default=20) + parser.add_argument( + "--iterations", help="Number of training iterations (i.e. mini-batch) " + "(Default: 20)", + type=int, default=20) # game batch size # Baseline function (TODO) default lambda state: 0 (receives either file # paths to JSON and weights or None, in which case it uses default baseline 0) args = parser.parse_args() - trainer = RL_policy_trainer - - - # from ipdb import set_trace as BP - # import pickle - # np.set_printoptions(linewidth=160) - # features = ["board", "ones", "turns_since", "liberties", "capture_size", - # "self_atari_size", "liberties_after", - # "sensibleness", "zeros"] - # policy1 = CNNPolicy(features) - # policy2 = CNNPolicy(features) - # player1 = GreedyPolicyPlayer(policy1) - # player2 = GreedyPolicyPlayer(policy2) - # train = train_policy(player1, player2, features) - -# Test get_training_pairs: -# train.make_training_pairs() -# with open('game.pkl', 'wb') as fid: -# pickle.dump(train.training_pairs, fid) -# with open('tensors.pkl', 'wb') as fid: -# pickle.dump(train.tensors, fid) -# with open('history.pkl', 'wb') as fid: -# pickle.dump(train.match.state.history, fid) -# training_pairs = train.train() -# with open('board.pkl', 'wb') as fid: -# pickle.dump(train.match.state.board, fid) -# np.save('board.npy', train.match.state.board) - -# BP() -# with open('game.pkl', 'rb') as fid: -# train.training_pairs = pickle.load(fid) -# with open('tensors.pkl', 'rb') as fid: -# train.tensors = pickle.load(fid) -# with open('history.pkl', 'rb') as fid: -# train.match.state.history = pickle.load(fid) -# # with open('board.pkl', 'rb') as fid: -# # train.match.state.board = pickle.load(fid) -# train.match.state.board = np.load('board.npy') -# train.train() + # Load policy from file + # policy = model_from_json(open(args.initial_json).read()) + # policy.load_weights(args.initial_weights) + # player = ProbabilisticPolicyPlayer(model) + ############################################# + # Just for now, while we get the model directories set up... + features = ["board", "ones", "turns_since", "liberties", "capture_size", + "self_atari_size", "liberties_after", + "sensibleness", "zeros"] + policy = CNNPolicy(features) + player = ProbabilisticPolicyPlayer(policy) + ############################################# + # Load opponent pool + opponents = [] + if args.model_folder is not None: + # TODO + opponent_files = next(os.walk(args.model_folder))[2] + if len(args.model_folder) == 0: + # No opponents yet, so play against self + opponents = [player] + else: + # TODO + pass + else: + opponents = [player] + # Set SGD + sgd = SGD(lr=args.learning_rate) + run(player, args, opponents, sgd, features) + + + + +# def make_training_pairs(match, feature_list): +# while True: +# training_pairs = [] +# preprocessor = Preprocess(feature_list) +# bsize = match.state.board.shape[0] +# while True: +# # Cached copy of previous board state, so that training pairs are +# # between latest move and the board state when it was being considered. +# state = match.state.copy() +# # Do move +# end_of_game = match.play() +# print "turns played:", match.state.turns_played +# move = match.state.history[-1] +# # Only add training pairs and tensors for BLACK moves and not pass moves +# if match.state.current_player != go.BLACK and \ +# move is not go.PASS_MOVE: +# # Convert move to one-hot +# move_1hot = np.zeros((1, bsize, bsize)) +# move_1hot[0][move] = 1 +# # Add training pairs +# training_pairs.append((preprocessor.state_to_tensor(state), +# move_1hot)) +# # Print out board states for debugging purposes +# if match.state.turns_played % 1 == 0: +# print match.state.board +# # End game prematurely for debugging +# if match.state.turns_played > 10: +# break +# # Detect end of game +# if end_of_game: +# break +# yield training_pairs + +# class RL_policy_trainer(object): +# """Train reinforcement learning policy. First, extract training tuples from +# game (`get_training_pairs`), then use those tuples for reinforcement learning. +# Call `train_policy.train()` to first extract training pairs and then run +# training. Initialize object with any two policy players (must have `get_move` +# function that returns a single move tuple given board state array). +# """ +# def __init__(self, player1, player2, feature_list, learning_rate=0.01): +# self.player1 = player1 +# self.player2 = player2 +# self.match = play_match(player1, player2, 'test') +# # Player1 is by convention the network model to be updated by reinforcement +# self.model = player1.policy.model # Keras model, inherited from supervised training phase +# self.learning_rate = learning_rate # TODO +# self.preprocessor = Preprocess(feature_list) + +# def save_model(self): +# # TODO +# pass +# # self.fname = self.save_dir +# # self.model.save_weights('fname.h5') +# # self.model.save_json('fname.json') + +# def train(self, training_pairs): +# # Calculate which player won +# self.winner = self.match.state.get_winner() +# # Concatenate input and target tensors +# input_tensors = [] +# target_tensors = [] +# for t in training_pairs: +# input_tensors.append(t[0]) +# target_tensors.append(t[1]) +# input_tensors = np.concatenate(input_tensors, axis=0) +# target_tensors = np.concatenate(target_tensors, axis=0) +# # Initialize SGD and load keras net +# sgd = SGD(lr=self.learning_rate) +# self.model.compile(loss='binary_crossentropy', optimizer=sgd) +# # Update weights in + direction if player won, and - direction if player lost. +# # Setting sample_weight negative is hack for negative weights update. +# # states = np.array([s[0] for s in samples if s[1] is not None]) +# if self.winner == 1: +# sw = np.ones(len(input_tensors)) +# else: +# sw = np.ones(len(input_tensors)) * -1 +# self.model.fit(input_tensors, target_tensors, nb_epoch=1, +# batch_size=len(input_tensors), sample_weight=sw) +# # Save new weights +# # TODO +# self.save_model() +# return diff --git a/interface/Play.py b/interface/Play.py new file mode 100644 index 000000000..8df03a0c1 --- /dev/null +++ b/interface/Play.py @@ -0,0 +1,35 @@ +"""Interface for AlphaGo self-play""" +from AlphaGo.go import GameState + + +class play_match(object): + """Interface to handle play between two players.""" + def __init__(self, player1, player2, save_dir=None, size=19): + # super(ClassName, self).__init__() + self.player1 = player1 + self.player2 = player2 + self.state = GameState(size=size) + # I Propose that GameState should take a top-level save directory, + # then automatically generate the specific file name + + def _play(self, player): + move = player.get_move(self.state) + # TODO: Fix is_eye? + self.state.do_move(move) # Return max prob sensible legal move + # self.state.write_to_disk() + if len(self.state.history) > 1: + if self.state.history[-1] is None and self.state.history[-2] is None \ + and self.state.current_player == -1: + end_of_game = True + else: + end_of_game = False + else: + end_of_game = False + return end_of_game + + def play(self): + """Play one turn, update game state, save to disk""" + end_of_game = self._play(self.player1) + # if not end_of_game: + # end_of_game = self._play(self.player2) + return end_of_game diff --git a/tests/test_policy.py b/tests/test_policy.py index 4a9e26ad3..07e461bcd 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -1,6 +1,6 @@ from AlphaGo.models.policy import CNNPolicy from AlphaGo.go import GameState -from AlphaGo.ai import GreedyPolicyPlayer, SamplingPolicyPlayer +from AlphaGo.ai import GreedyPolicyPlayer, ProbabilisticPolicyPlayer import unittest import os @@ -51,7 +51,7 @@ def test_greedy_player(self): def test_sampling_player(self): gs = GameState() policy = CNNPolicy(["board", "ones", "turns_since"]) - player = SamplingPolicyPlayer(policy) + player = ProbabilisticPolicyPlayer(policy) for i in range(20): move = player.get_move(gs) self.assertIsNotNone(move) From 7c4994667bc2ae6a5cdd06fabeddf286c36318e4 Mon Sep 17 00:00:00 2001 From: wrongu Date: Fri, 8 Apr 2016 15:35:58 -0400 Subject: [PATCH 008/191] CNNPolicy.batch_eval_state implemented --- AlphaGo/models/policy.py | 49 ++++++++++++++++++++++++++++------------ tests/test_policy.py | 6 +++++ 2 files changed, 40 insertions(+), 15 deletions(-) diff --git a/AlphaGo/models/policy.py b/AlphaGo/models/policy.py index 42430a38c..9e47f6288 100644 --- a/AlphaGo/models/policy.py +++ b/AlphaGo/models/policy.py @@ -5,6 +5,7 @@ import keras.backend as K from AlphaGo.preprocessing.preprocessing import Preprocess from AlphaGo.util import flatten_idx +import numpy as np import json @@ -42,14 +43,40 @@ def _model_forward(self): # the first [0] gets the front tensor. return lambda inpt: forward_function([inpt])[0] - def batch_eval_state(self, state_gen, batch=16): - """Given a stream of states in state_gen, evaluates them in batches - to make best use of GPU resources. + def _select_moves_and_normalize(self, nn_output, moves, size): + """helper function to normalize a distribution over the given list of moves + and return a list of (move, prob) tuples + """ + move_indices = [flatten_idx(m, size) for m in moves] + # get network activations at legal move locations + distribution = nn_output[move_indices] + distribution = distribution / distribution.sum() + return zip(moves, distribution) + + def batch_eval_state(self, states, moves_lists=None): + """Given a list of states, evaluates them all at once to make best use of GPU + batching capabilities. + + Analogous to [eval_state(s) for s in states] - Returns: TBD (stream of results? that would break zip(). - streaming pairs of pre-zipped (state, result)?) + Returns: a parallel list of move distributions as in eval_state """ - raise NotImplementedError() + n_states = len(states) + if n_states == 0: + return [] + state_size = states[0].size + if not all([st.size == state_size for st in states]): + raise ValueError("all states must have the same size") + # concatenate together all one-hot encoded states along the 'batch' dimension + nn_input = np.concatenate([self.preprocessor.state_to_tensor(s) for s in states], axis=0) + # pass all input through the network at once (backend makes use of batches if len(states) is large) + network_output = self.forward(nn_input) + # default move lists to all legal moves + moves_lists = moves_lists or [st.get_legal_moves() for st in states] + results = [None] * n_states + for i in range(n_states): + results[i] = self._select_moves_and_normalize(network_output[i], moves_lists[i], state_size) + return results def eval_state(self, state, moves=None): """Given a GameState object, returns a list of (action, probability) pairs @@ -58,18 +85,10 @@ def eval_state(self, state, moves=None): If a list of moves is specified, only those moves are kept in the distribution """ tensor = self.preprocessor.state_to_tensor(state) - # run the tensor through the network network_output = self.forward(tensor) - moves = moves or state.get_legal_moves() - move_indices = [flatten_idx(m, state.size) for m in moves] - - # get network activations at legal move locations - # note: may not be a proper distribution by ignoring illegal moves - distribution = network_output[0][move_indices] - distribution = distribution / distribution.sum() - return zip(moves, distribution) + return self._select_moves_and_normalize(network_output[0], moves, state.size) @staticmethod def create_network(**kwargs): diff --git a/tests/test_policy.py b/tests/test_policy.py index 07e461bcd..41e3e5cfa 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -12,6 +12,12 @@ def test_default_policy(self): policy.eval_state(GameState()) # just hope nothing breaks + def test_batch_eval_state(self): + policy = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"]) + results = policy.batch_eval_state([GameState(), GameState()]) + self.assertEqual(len(results), 2) # one result per GameState + self.assertEqual(len(results[0]), 361) # each one has 361 (move,prob) pairs + def test_output_size(self): policy19 = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"], board=19) output = policy19.forward(policy19.preprocessor.state_to_tensor(GameState(19))) From a3605a4e057ce857efbf6166ddf8390bf04001c6 Mon Sep 17 00:00:00 2001 From: wrongu Date: Fri, 8 Apr 2016 15:55:24 -0400 Subject: [PATCH 009/191] batch get_moves in ProbabilisticPolicyPlayer --- AlphaGo/ai.py | 19 +++++++++++++++++++ AlphaGo/models/policy.py | 2 ++ 2 files changed, 21 insertions(+) diff --git a/AlphaGo/ai.py b/AlphaGo/ai.py index 2a4a73d53..c159465b1 100644 --- a/AlphaGo/ai.py +++ b/AlphaGo/ai.py @@ -47,3 +47,22 @@ def get_move(self, state): choice_idx = np.random.choice(len(moves), p=probabilities) return moves[choice_idx] return go.PASS_MOVE + + def get_moves(self, states): + """Batch version of get_move. A list of moves is returned (one per state) + """ + sensible_move_lists = [[move for move in st.get_legal_moves() if not st.is_eye(move, st.current_player)] for st in states] + all_moves_distributions = self.policy.batch_eval_state(states, sensible_move_lists) + move_list = [None] * len(states) + for i, move_probs in enumerate(all_moves_distributions): + if len(move_probs) == 0: + move_list[i] = go.PASS_MOVE + else: + # this 'else' clause is identical to ProbabilisticPolicyPlayer.get_move + moves, probabilities = zip(*move_probs) + probabilities = np.array(probabilities) + probabilities = probabilities ** self.beta + probabilities = probabilities / probabilities.sum() + choice_idx = np.random.choice(len(moves), p=probabilities) + move_list[i] = moves[choice_idx] + return move_list diff --git a/AlphaGo/models/policy.py b/AlphaGo/models/policy.py index 9e47f6288..72fec7b06 100644 --- a/AlphaGo/models/policy.py +++ b/AlphaGo/models/policy.py @@ -47,6 +47,8 @@ def _select_moves_and_normalize(self, nn_output, moves, size): """helper function to normalize a distribution over the given list of moves and return a list of (move, prob) tuples """ + if len(moves) == 0: + return [] move_indices = [flatten_idx(m, size) for m in moves] # get network activations at legal move locations distribution = nn_output[move_indices] From 00be7ba92104f1191302e05778b6015f472dbc95 Mon Sep 17 00:00:00 2001 From: cjbates-mac Date: Sat, 9 Apr 2016 14:37:22 -0400 Subject: [PATCH 010/191] Reimplemented RL training for fancy GPU batching. --- AlphaGo/go.py | 7 + .../training/reinforcement_policy_trainer.py | 219 ++++++++++++------ interface/Play.py | 5 +- tests/test_policy.py | 29 ++- 4 files changed, 191 insertions(+), 69 deletions(-) diff --git a/AlphaGo/go.py b/AlphaGo/go.py index 43bfbfea1..f1ff3b7ab 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -18,6 +18,7 @@ def __init__(self, size=19, komi=5): self.current_player = BLACK self.ko = None self.history = [] + self.is_end_of_game = False self.num_black_prisoners = 0 self.num_white_prisoners = 0 self.komi = komi # Komi is number of extra points WHITE gets for going 2nd @@ -341,6 +342,12 @@ def do_move(self, action, color=None): self.history.append(action) else: raise IllegalMove(str(action)) + # Check for end of game + if len(self.history) > 1: + if self.history[-1] is None and self.history[-2] is None \ + and self.current_player == WHITE: + self.is_end_of_game = True + return self.is_end_of_game class IllegalMove(Exception): diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 3a81c7523..b4ba4c805 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -1,75 +1,153 @@ import argparse from AlphaGo.ai import GreedyPolicyPlayer, ProbabilisticPolicyPlayer import AlphaGo.go as go +from AlphaGo.go import GameState from AlphaGo.models.policy import CNNPolicy from AlphaGo.preprocessing.preprocessing import Preprocess from interface.Play import play_match +# from joblib import Parallel, delayed from keras.optimizers import SGD -from keras.models import model_from_json +# from keras.models import model_from_json +# from multiprocessing import Pool +# from multiprocessing.dummy import Pool as ThreadPool import numpy as np +# np.set_printoptions(linewidth=160) import os +# import threading +# +from ipdb import set_trace as BP -def _make_training_pairs(match, feature_list): - """Make training pairs for single match. +def _make_training_pairs(player, opp, features, mini_batch_size): + """Helper function to make_training_pairs. Make training pairs for batch of matches, + utilizing player.get_moves, which calls `CNNPolicy.batch_eval_state`. Args: - match -- play_match object between player and opponent + player -- player that we're always updating + opp -- batch opponent feature_list -- game features to be one-hot encoded + mini_batch_size -- number of games in mini-batch Return: - training_pairs -- list of tuples. All player's moves in game, paired with - the board state just before (both 1-hot encoded). + X_list -- list of 1-hot board states associated with moves. + y_list -- list of 1-hot moves associated with board states. + winners -- list of winners associated with each game in batch """ - training_pairs = [] - preprocessor = Preprocess(feature_list) - bsize = match.state.board.shape[0] - # Play out game + + def do_move(states, states_prev, moves, X_list, y_list): + for st, st_prev, mv, X, y in zip(states, states_prev, moves, X_list, y_list): + if not st.is_end_of_game: + # Only do more moves if not end of game already + st.do_move(mv) + if st.current_player != go.BLACK and mv is not go.PASS_MOVE: + # Convert move to one-hot + move_1hot = np.zeros((1, bsize, bsize)) + move_1hot[0][mv] = 1 + state_1hot = preprocessor.state_to_tensor(st_prev) + X.append(state_1hot) + y.append(move_1hot) + return states, X_list, y_list + + # Lists of game training pairs (1-hot) + X_list = [list()] * mini_batch_size + y_list = [list()] * mini_batch_size + preprocessor = Preprocess(features) + bsize = player.policy.model.input_shape[-1] + states = [GameState() for i in xrange(mini_batch_size)] while True: - # Cached copy of previous board state, so that training pairs are - # between latest move and the board state when it was being considered. - state = match.state.copy() - # Do move - end_of_game = match.play() - print "turns played:", match.state.turns_played - move = match.state.history[-1] - # Only add training pairs and tensors for BLACK moves and not pass moves - if match.state.current_player != go.BLACK and \ - move is not go.PASS_MOVE: - # Convert move to one-hot - move_1hot = np.zeros((1, bsize, bsize)) - move_1hot[0][move] = 1 - state_1hot = preprocessor.state_to_tensor(state) - # Add training pairs - training_pairs.append((state_1hot, - move_1hot)) - # Print out board states for debugging purposes - if match.state.turns_played % 1 == 0: - print match.state.board - # End game prematurely for debugging - if match.state.turns_played > 10: + # Cache states before moves + states_prev = [st.copy() for st in states] + # Get moves (batch) + moves = player.get_moves(states) + # Do moves (player) + states, X_list, y_list = do_move(states, states_prev, moves, X_list, y_list) + # Do moves (opponent) + moves_opp = opp.get_moves(states) + states, X_list, y_list = do_move(states, states_prev, moves_opp, X_list, y_list) + # If all games have ended, we're done. Get winners. + done = [st.is_end_of_game for st in states] + if all(done): break - # Detect end of game - if end_of_game: - break - return training_pairs + winners = [st.get_winner() for st in states] + # Concatenate tensors with each game + for i in xrange(mini_batch_size): + X_list[i] = np.concatenate(X_list[i], axis=0) + y_list[i] = np.concatenate(y_list[i], axis=0) + BP() + # X = [] + # y = [] + # match = play_match(player, opp) + # bsize = match.state.board.shape[0] + # matches = [play_match(player, opp) for i in xrange(mini_batch_size)] + # # Play out game + # while True: + # # Cached copy of previous board state, so that training pairs are + # # between latest move and the board state when it was being considered. + # state = match.state.copy() + # # Do move + # end_of_game = match.play() + # print "turns played:", match.state.turns_played + # move = match.state.history[-1] + # # Only add training pairs and tensors for BLACK moves and not pass moves + # if match.state.current_player != go.BLACK and \ + # move is not go.PASS_MOVE: + # # Convert move to one-hot + # move_1hot = np.zeros((1, bsize, bsize)) + # move_1hot[0][move] = 1 + # state_1hot = preprocessor.state_to_tensor(state) + # X.append(state_1hot) + # y.append(move_1hot) + # # Add training pairs + # # training_pairs.append((state_1hot, + # # move_1hot)) + # # Print out board states for debugging purposes + # # if match.state.turns_played % 1 == 0: + # # print match.state.board + # # End game prematurely for debugging + # if match.state.turns_played > 400: + # break + # # Detect end of game + # if end_of_game: + # break + # winner = match.state.get_winner() + # X = np.concatenate(X, axis=0) + # y = np.concatenate(y, axis=0) + return X_list, y_list, winners -def make_training_pairs(player, opp, mini_batch_size, sgd, features): +# def gen_training_pairs(player, opp, features): +# """Generator that yields training pairs, by playing games +# against fixed opponent. +# """ +# # Make training pairs +# while True: +# X, y, winner = _make_training_pairs(player, opp, features) +# n_turns = len(X) +# # Make sample weights vector +# if winner == 1: +# sw = np.ones(n_turns) +# else: +# sw = np.ones(n_turns) * -1 +# for turn in game: +# yield (X, y, sw) + + +def make_training_pairs(player, opp, mini_batch_size, features, + nb_workers): """Make all n training pairs for a single batch, by playing n games against fixed opponent. """ + # Make training pairs winners = [] training_pairs_list = [] - for i in xrange(mini_batch_size): - match = play_match(player, opp) - training_pairs_list.append(_make_training_pairs(match, features)) - winners.append(match.state.get_winner()) + X, y, winner = _make_training_pairs(player, opp, features, mini_batch_size) + training_pairs_list.append((X, y)) + winners.append(winner) return training_pairs_list, winners -def train_batch(player, training_pairs_list, winners): +def train_batch(player, training_pairs_list, winners, lr): """Given the outcomes of a mini-batch of play against a fixed opponent, update the weights with reinforcement learning. @@ -78,33 +156,40 @@ def train_batch(player, training_pairs_list, winners): training_pairs_list -- List of one-hot encoded state-action pairs. winners -- List of winners corresponding to each item in training_pairs_list + lr -- Keras learning rate Return: player -- same player, with updated weights. """ - player.policy.model.compile(loss='binary_crossentropy', optimizer=sgd) - for training_pairs, winner in zip(training_pairs_list, winners): + # player.policy.model.compile(loss='binary_crossentropy', optimizer=sgd) + # for training_pairs, winner in zip(training_pairs_list, winners): # Concatenate input and target tensors - input_tensors = [] - target_tensors = [] - for t in training_pairs: - input_tensors.append(t[0]) - target_tensors.append(t[1]) - input_tensors = np.concatenate(input_tensors, axis=0) - target_tensors = np.concatenate(target_tensors, axis=0) + # input_tensors = [] + # target_tensors = [] + # for t in training_pairs: + # input_tensors.append(t[0]) + # target_tensors.append(t[1]) + # input_tensors = np.concatenate(input_tensors, axis=0) + # target_tensors = np.concatenate(target_tensors, axis=0) + # if winner == 1: + # sw = np.ones(len(input_tensors)) + # else: + # sw = np.ones(len(input_tensors)) * -1 + # player.policy.model.fit(input_tensors, target_tensors, nb_epoch=1, + # batch_size=len(input_tensors), sample_weight=sw) + for X, y, winner in zip(training_pairs_list, winners): # Update weights in + direction if player won, and - direction if player lost. # Setting sample_weight negative is hack for negative weights update. - # states = np.array([s[0] for s in samples if s[1] is not None]) - if winner == 1: - sw = np.ones(len(input_tensors)) - else: - sw = np.ones(len(input_tensors)) * -1 - player.policy.model.fit(input_tensors, target_tensors, nb_epoch=1, - batch_size=len(input_tensors), sample_weight=sw) + if winner == -1: + player.policy.model.optimizer.lr.set_value(-lr) + player.policy.model.fit(X, y, nb_epoch=1, batch_size=len(X)) return player -def run(player, args, opponents, sgd, features): +def run(player, args, opponents, features): + # Set SGD and compile + # sgd = SGD(lr=args.learning_rate) + # player.policy.model.compile(loss='binary_crossentropy', optimizer=sgd) for i_iter in xrange(args.iterations): # Train mini-batches for i_batch in xrange(args.save_every): @@ -112,12 +197,18 @@ def run(player, args, opponents, sgd, features): opp = np.random.choice(opponents) # Make training pairs and do RL training_pairs_list, winners = make_training_pairs( - player, opp, args.game_batch_size, sgd, features) + player, opp, args.game_batch_size, features, args.nb_workers) player = train_batch(player, training_pairs_list, winners) + + # Parallelize with Keras + # gen = gen_training_pairs(player.copy(), opp, features) + # player.policy.model.fit_generator(gen, args.game_batch_size, 1, + # nb_worker=args.nb_workers) + BP() # TODO: Save policy to model folder # Add snapshot of player to pool of opponents opponents.append(player.copy()) - # from ipdb import set_trace as BP; BP() + return opponents if __name__ == '__main__': @@ -131,7 +222,7 @@ def run(player, args, opponents, sgd, features): "--learning_rate", help="Keras learning rate (Default: .03)", type=float, default=.03) parser.add_argument( - "--nb_worker", help="Number of threads to use when training in parallel. " + "--nb_workers", help="Number of threads to use when training in parallel. " "Requires appropriately set Theano flags.", type=int, default=1) parser.add_argument( "--save_every", help="Save policy every n mini-batches (Default: 500)", @@ -172,9 +263,7 @@ def run(player, args, opponents, sgd, features): pass else: opponents = [player] - # Set SGD - sgd = SGD(lr=args.learning_rate) - run(player, args, opponents, sgd, features) + opponents = run(player, args, opponents, features) diff --git a/interface/Play.py b/interface/Play.py index 8df03a0c1..bc408ad07 100644 --- a/interface/Play.py +++ b/interface/Play.py @@ -1,7 +1,7 @@ """Interface for AlphaGo self-play""" from AlphaGo.go import GameState - +# Deprecated? class play_match(object): """Interface to handle play between two players.""" def __init__(self, player1, player2, save_dir=None, size=19): @@ -30,6 +30,5 @@ def _play(self, player): def play(self): """Play one turn, update game state, save to disk""" end_of_game = self._play(self.player1) - # if not end_of_game: - # end_of_game = self._play(self.player2) + # This is incorrect. return end_of_game diff --git a/tests/test_policy.py b/tests/test_policy.py index 07e461bcd..cf1c0b352 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -1,6 +1,8 @@ from AlphaGo.models.policy import CNNPolicy +from AlphaGo import go from AlphaGo.go import GameState from AlphaGo.ai import GreedyPolicyPlayer, ProbabilisticPolicyPlayer +import numpy as np import unittest import os @@ -48,7 +50,7 @@ def test_greedy_player(self): self.assertIsNotNone(move) gs.do_move(move) - def test_sampling_player(self): + def test_probabilistic_player(self): gs = GameState() policy = CNNPolicy(["board", "ones", "turns_since"]) player = ProbabilisticPolicyPlayer(policy) @@ -57,5 +59,30 @@ def test_sampling_player(self): self.assertIsNotNone(move) gs.do_move(move) + def test_sensible_probabilistic(self): + gs = GameState() + policy = CNNPolicy(["board", "ones", "turns_since"]) + player = ProbabilisticPolicyPlayer(policy) + empty = (10, 10) + for x in range(19): + for y in range(19): + if (x, y) != empty: + gs.do_move((x, y), go.BLACK) + gs.current_player = go.BLACK + self.assertIsNone(player.get_move(gs)) + + def test_sensible_greedy(self): + gs = GameState() + policy = CNNPolicy(["board", "ones", "turns_since"]) + player = GreedyPolicyPlayer(policy) + empty = (10, 10) + for x in range(19): + for y in range(19): + if (x, y) != empty: + gs.do_move((x, y), go.BLACK) + gs.current_player = go.BLACK + self.assertIsNone(player.get_move(gs)) + + if __name__ == '__main__': unittest.main() From ee78e059b8451fda220a77418e94c6aa99ba79f0 Mon Sep 17 00:00:00 2001 From: cjbates-mac Date: Sat, 9 Apr 2016 15:49:56 -0400 Subject: [PATCH 011/191] Partially cleaned up RL trainer. --- .../training/reinforcement_policy_trainer.py | 231 ++---------------- 1 file changed, 26 insertions(+), 205 deletions(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index b4ba4c805..c36a097d9 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -4,23 +4,18 @@ from AlphaGo.go import GameState from AlphaGo.models.policy import CNNPolicy from AlphaGo.preprocessing.preprocessing import Preprocess -from interface.Play import play_match -# from joblib import Parallel, delayed +from AlphaGo.util import flatten_idx from keras.optimizers import SGD -# from keras.models import model_from_json -# from multiprocessing import Pool -# from multiprocessing.dummy import Pool as ThreadPool import numpy as np # np.set_printoptions(linewidth=160) import os -# import threading # -from ipdb import set_trace as BP +# from ipdb import set_trace as BP -def _make_training_pairs(player, opp, features, mini_batch_size): - """Helper function to make_training_pairs. Make training pairs for batch of matches, - utilizing player.get_moves, which calls `CNNPolicy.batch_eval_state`. +def make_training_pairs(player, opp, features, mini_batch_size): + """Make training pairs for batch of matches, utilizing player.get_moves (parallel form of + player.get_move), which calls `CNNPolicy.batch_eval_state`. Args: player -- player that we're always updating @@ -35,15 +30,16 @@ def _make_training_pairs(player, opp, features, mini_batch_size): """ def do_move(states, states_prev, moves, X_list, y_list): + bsize_flat = bsize * bsize for st, st_prev, mv, X, y in zip(states, states_prev, moves, X_list, y_list): if not st.is_end_of_game: # Only do more moves if not end of game already st.do_move(mv) if st.current_player != go.BLACK and mv is not go.PASS_MOVE: # Convert move to one-hot - move_1hot = np.zeros((1, bsize, bsize)) - move_1hot[0][mv] = 1 state_1hot = preprocessor.state_to_tensor(st_prev) + move_1hot = np.zeros(bsize_flat) + move_1hot[flatten_idx(mv, bsize)] = 1 X.append(state_1hot) y.append(move_1hot) return states, X_list, y_list @@ -69,85 +65,14 @@ def do_move(states, states_prev, moves, X_list, y_list): if all(done): break winners = [st.get_winner() for st in states] - # Concatenate tensors with each game + # Concatenate tensors across turns within each game for i in xrange(mini_batch_size): X_list[i] = np.concatenate(X_list[i], axis=0) - y_list[i] = np.concatenate(y_list[i], axis=0) - BP() - # X = [] - # y = [] - # match = play_match(player, opp) - # bsize = match.state.board.shape[0] - # matches = [play_match(player, opp) for i in xrange(mini_batch_size)] - # # Play out game - # while True: - # # Cached copy of previous board state, so that training pairs are - # # between latest move and the board state when it was being considered. - # state = match.state.copy() - # # Do move - # end_of_game = match.play() - # print "turns played:", match.state.turns_played - # move = match.state.history[-1] - # # Only add training pairs and tensors for BLACK moves and not pass moves - # if match.state.current_player != go.BLACK and \ - # move is not go.PASS_MOVE: - # # Convert move to one-hot - # move_1hot = np.zeros((1, bsize, bsize)) - # move_1hot[0][move] = 1 - # state_1hot = preprocessor.state_to_tensor(state) - # X.append(state_1hot) - # y.append(move_1hot) - # # Add training pairs - # # training_pairs.append((state_1hot, - # # move_1hot)) - # # Print out board states for debugging purposes - # # if match.state.turns_played % 1 == 0: - # # print match.state.board - # # End game prematurely for debugging - # if match.state.turns_played > 400: - # break - # # Detect end of game - # if end_of_game: - # break - # winner = match.state.get_winner() - # X = np.concatenate(X, axis=0) - # y = np.concatenate(y, axis=0) + y_list[i] = np.vstack(y_list[i]) return X_list, y_list, winners -# def gen_training_pairs(player, opp, features): -# """Generator that yields training pairs, by playing games -# against fixed opponent. -# """ -# # Make training pairs -# while True: -# X, y, winner = _make_training_pairs(player, opp, features) -# n_turns = len(X) -# # Make sample weights vector -# if winner == 1: -# sw = np.ones(n_turns) -# else: -# sw = np.ones(n_turns) * -1 -# for turn in game: -# yield (X, y, sw) - - -def make_training_pairs(player, opp, mini_batch_size, features, - nb_workers): - """Make all n training pairs for a single batch, by playing n games - against fixed opponent. - """ - - # Make training pairs - winners = [] - training_pairs_list = [] - X, y, winner = _make_training_pairs(player, opp, features, mini_batch_size) - training_pairs_list.append((X, y)) - winners.append(winner) - return training_pairs_list, winners - - -def train_batch(player, training_pairs_list, winners, lr): +def train_batch(player, X_list, y_list, winners, lr): """Given the outcomes of a mini-batch of play against a fixed opponent, update the weights with reinforcement learning. @@ -161,25 +86,10 @@ def train_batch(player, training_pairs_list, winners, lr): Return: player -- same player, with updated weights. """ - # player.policy.model.compile(loss='binary_crossentropy', optimizer=sgd) - # for training_pairs, winner in zip(training_pairs_list, winners): - # Concatenate input and target tensors - # input_tensors = [] - # target_tensors = [] - # for t in training_pairs: - # input_tensors.append(t[0]) - # target_tensors.append(t[1]) - # input_tensors = np.concatenate(input_tensors, axis=0) - # target_tensors = np.concatenate(target_tensors, axis=0) - # if winner == 1: - # sw = np.ones(len(input_tensors)) - # else: - # sw = np.ones(len(input_tensors)) * -1 - # player.policy.model.fit(input_tensors, target_tensors, nb_epoch=1, - # batch_size=len(input_tensors), sample_weight=sw) - for X, y, winner in zip(training_pairs_list, winners): + + for X, y, winner in zip(X_list, y_list, winners): # Update weights in + direction if player won, and - direction if player lost. - # Setting sample_weight negative is hack for negative weights update. + # Setting learning rate negative is hack for negative weights update. if winner == -1: player.policy.model.optimizer.lr.set_value(-lr) player.policy.model.fit(X, y, nb_epoch=1, batch_size=len(X)) @@ -188,23 +98,21 @@ def train_batch(player, training_pairs_list, winners, lr): def run(player, args, opponents, features): # Set SGD and compile - # sgd = SGD(lr=args.learning_rate) - # player.policy.model.compile(loss='binary_crossentropy', optimizer=sgd) + sgd = SGD(lr=args.learning_rate) + player.policy.model.compile(loss='binary_crossentropy', optimizer=sgd) + player_wins_per_batch = [] for i_iter in xrange(args.iterations): # Train mini-batches for i_batch in xrange(args.save_every): # Randomly choose opponent from pool opp = np.random.choice(opponents) # Make training pairs and do RL - training_pairs_list, winners = make_training_pairs( - player, opp, args.game_batch_size, features, args.nb_workers) - player = train_batch(player, training_pairs_list, winners) - - # Parallelize with Keras - # gen = gen_training_pairs(player.copy(), opp, features) - # player.policy.model.fit_generator(gen, args.game_batch_size, 1, - # nb_worker=args.nb_workers) - BP() + X_list, y_list, winners = make_training_pairs( + player, opp, features, args.game_batch_size) + n_wins = np.sum(np.array(winners) == 1) + player_wins_per_batch.append(n_wins) + print 'Number of wins this batch: {}/{}'.format(n_wins, args.game_batch_size) + player = train_batch(player, X_list, y_list, winners, args.learning_rate) # TODO: Save policy to model folder # Add snapshot of player to pool of opponents opponents.append(player.copy()) @@ -221,9 +129,6 @@ def run(player, args, opponents, features): parser.add_argument( "--learning_rate", help="Keras learning rate (Default: .03)", type=float, default=.03) - parser.add_argument( - "--nb_workers", help="Number of threads to use when training in parallel. " - "Requires appropriately set Theano flags.", type=int, default=1) parser.add_argument( "--save_every", help="Save policy every n mini-batches (Default: 500)", type=int, default=500) @@ -234,6 +139,9 @@ def run(player, args, opponents, features): "--iterations", help="Number of training iterations (i.e. mini-batch) " "(Default: 20)", type=int, default=20) + # parser.add_argument( + # "--nb_workers", help="Number of threads to use when training in parallel. " + # "Requires appropriately set Theano flags.", type=int, default=1) # game batch size # Baseline function (TODO) default lambda state: 0 (receives either file # paths to JSON and weights or None, in which case it uses default baseline 0) @@ -264,90 +172,3 @@ def run(player, args, opponents, features): else: opponents = [player] opponents = run(player, args, opponents, features) - - - - -# def make_training_pairs(match, feature_list): -# while True: -# training_pairs = [] -# preprocessor = Preprocess(feature_list) -# bsize = match.state.board.shape[0] -# while True: -# # Cached copy of previous board state, so that training pairs are -# # between latest move and the board state when it was being considered. -# state = match.state.copy() -# # Do move -# end_of_game = match.play() -# print "turns played:", match.state.turns_played -# move = match.state.history[-1] -# # Only add training pairs and tensors for BLACK moves and not pass moves -# if match.state.current_player != go.BLACK and \ -# move is not go.PASS_MOVE: -# # Convert move to one-hot -# move_1hot = np.zeros((1, bsize, bsize)) -# move_1hot[0][move] = 1 -# # Add training pairs -# training_pairs.append((preprocessor.state_to_tensor(state), -# move_1hot)) -# # Print out board states for debugging purposes -# if match.state.turns_played % 1 == 0: -# print match.state.board -# # End game prematurely for debugging -# if match.state.turns_played > 10: -# break -# # Detect end of game -# if end_of_game: -# break -# yield training_pairs - -# class RL_policy_trainer(object): -# """Train reinforcement learning policy. First, extract training tuples from -# game (`get_training_pairs`), then use those tuples for reinforcement learning. -# Call `train_policy.train()` to first extract training pairs and then run -# training. Initialize object with any two policy players (must have `get_move` -# function that returns a single move tuple given board state array). -# """ -# def __init__(self, player1, player2, feature_list, learning_rate=0.01): -# self.player1 = player1 -# self.player2 = player2 -# self.match = play_match(player1, player2, 'test') -# # Player1 is by convention the network model to be updated by reinforcement -# self.model = player1.policy.model # Keras model, inherited from supervised training phase -# self.learning_rate = learning_rate # TODO -# self.preprocessor = Preprocess(feature_list) - -# def save_model(self): -# # TODO -# pass -# # self.fname = self.save_dir -# # self.model.save_weights('fname.h5') -# # self.model.save_json('fname.json') - -# def train(self, training_pairs): -# # Calculate which player won -# self.winner = self.match.state.get_winner() -# # Concatenate input and target tensors -# input_tensors = [] -# target_tensors = [] -# for t in training_pairs: -# input_tensors.append(t[0]) -# target_tensors.append(t[1]) -# input_tensors = np.concatenate(input_tensors, axis=0) -# target_tensors = np.concatenate(target_tensors, axis=0) -# # Initialize SGD and load keras net -# sgd = SGD(lr=self.learning_rate) -# self.model.compile(loss='binary_crossentropy', optimizer=sgd) -# # Update weights in + direction if player won, and - direction if player lost. -# # Setting sample_weight negative is hack for negative weights update. -# # states = np.array([s[0] for s in samples if s[1] is not None]) -# if self.winner == 1: -# sw = np.ones(len(input_tensors)) -# else: -# sw = np.ones(len(input_tensors)) * -1 -# self.model.fit(input_tensors, target_tensors, nb_epoch=1, -# batch_size=len(input_tensors), sample_weight=sw) -# # Save new weights -# # TODO -# self.save_model() -# return From 0e63ee8d7c8d66213d9b6e0b7b926373aec1a010 Mon Sep 17 00:00:00 2001 From: cjbates-mac Date: Mon, 11 Apr 2016 21:02:51 -0400 Subject: [PATCH 012/191] Fixed some errors in reinforcement_policy_trainer.py --- .../training/reinforcement_policy_trainer.py | 47 ++++++++++--------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index c36a097d9..346b79d0b 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -1,5 +1,5 @@ import argparse -from AlphaGo.ai import GreedyPolicyPlayer, ProbabilisticPolicyPlayer +from AlphaGo.ai import ProbabilisticPolicyPlayer import AlphaGo.go as go from AlphaGo.go import GameState from AlphaGo.models.policy import CNNPolicy @@ -29,13 +29,14 @@ def make_training_pairs(player, opp, features, mini_batch_size): winners -- list of winners associated with each game in batch """ - def do_move(states, states_prev, moves, X_list, y_list): + def do_move(states, states_prev, moves, X_list, y_list, player_color): bsize_flat = bsize * bsize - for st, st_prev, mv, X, y in zip(states, states_prev, moves, X_list, y_list): + for st, st_prev, mv, X, y in zip(states, states_prev, moves, X_list, + y_list): if not st.is_end_of_game: # Only do more moves if not end of game already st.do_move(mv) - if st.current_player != go.BLACK and mv is not go.PASS_MOVE: + if st.current_player != player_color and mv is not go.PASS_MOVE: # Convert move to one-hot state_1hot = preprocessor.state_to_tensor(st_prev) move_1hot = np.zeros(bsize_flat) @@ -45,21 +46,27 @@ def do_move(states, states_prev, moves, X_list, y_list): return states, X_list, y_list # Lists of game training pairs (1-hot) - X_list = [list()] * mini_batch_size - y_list = [list()] * mini_batch_size + X_list = [list() for _ in xrange(mini_batch_size)] + y_list = [list() for _ in xrange(mini_batch_size)] preprocessor = Preprocess(features) bsize = player.policy.model.input_shape[-1] states = [GameState() for i in xrange(mini_batch_size)] + # Randomly choose who goes first (i.e. color of 'player') + player_color = np.random.choice([go.BLACK, go.WHITE]) + player1, player2 = (player, opp) if player_color == go.BLACK else \ + (opp, player) while True: # Cache states before moves states_prev = [st.copy() for st in states] # Get moves (batch) - moves = player.get_moves(states) - # Do moves (player) - states, X_list, y_list = do_move(states, states_prev, moves, X_list, y_list) - # Do moves (opponent) - moves_opp = opp.get_moves(states) - states, X_list, y_list = do_move(states, states_prev, moves_opp, X_list, y_list) + moves_black = player1.get_moves(states) + # Do moves (black) + states, X_list, y_list = do_move(states, states_prev, moves_black, + X_list, y_list, player_color) + # Do moves (white) + moves_white = player2.get_moves(states) + states, X_list, y_list = do_move(states, states_prev, moves_white, + X_list, y_list, player_color) # If all games have ended, we're done. Get winners. done = [st.is_end_of_game for st in states] if all(done): @@ -78,7 +85,8 @@ def train_batch(player, X_list, y_list, winners, lr): Args: player -- player object with policy weights to be updated - training_pairs_list -- List of one-hot encoded state-action pairs. + X_list -- List of one-hot encoded states. + y_list -- List of one-hot encoded actions (to pair with X_list). winners -- List of winners corresponding to each item in training_pairs_list lr -- Keras learning rate @@ -92,6 +100,8 @@ def train_batch(player, X_list, y_list, winners, lr): # Setting learning rate negative is hack for negative weights update. if winner == -1: player.policy.model.optimizer.lr.set_value(-lr) + else: + player.policy.model.optimizer.lr.set_value(lr) player.policy.model.fit(X, y, nb_epoch=1, batch_size=len(X)) return player @@ -105,7 +115,7 @@ def run(player, args, opponents, features): # Train mini-batches for i_batch in xrange(args.save_every): # Randomly choose opponent from pool - opp = np.random.choice(opponents) + opp = np.random.choice(opponents) # TODO: change to file-loading scheme # Make training pairs and do RL X_list, y_list, winners = make_training_pairs( player, opp, features, args.game_batch_size) @@ -113,9 +123,8 @@ def run(player, args, opponents, features): player_wins_per_batch.append(n_wins) print 'Number of wins this batch: {}/{}'.format(n_wins, args.game_batch_size) player = train_batch(player, X_list, y_list, winners, args.learning_rate) - # TODO: Save policy to model folder - # Add snapshot of player to pool of opponents - opponents.append(player.copy()) + # TODO: Save policy to model folder and add snapshot of player to pool of opponents + # opponent_pths.append(player_pth) # Something like this? return opponents @@ -139,10 +148,6 @@ def run(player, args, opponents, features): "--iterations", help="Number of training iterations (i.e. mini-batch) " "(Default: 20)", type=int, default=20) - # parser.add_argument( - # "--nb_workers", help="Number of threads to use when training in parallel. " - # "Requires appropriately set Theano flags.", type=int, default=1) - # game batch size # Baseline function (TODO) default lambda state: 0 (receives either file # paths to JSON and weights or None, in which case it uses default baseline 0) args = parser.parse_args() From c7279a35241cb503ccd786cfac88f6714ef89f93 Mon Sep 17 00:00:00 2001 From: Tyler Trine Date: Mon, 18 Apr 2016 08:12:13 -0400 Subject: [PATCH 013/191] Add save/load functionality to reinforcement policy trainer --- .../training/reinforcement_policy_trainer.py | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 346b79d0b..eba26064b 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -1,4 +1,5 @@ import argparse +from keras.callbacks import ModelCheckpoint from AlphaGo.ai import ProbabilisticPolicyPlayer import AlphaGo.go as go from AlphaGo.go import GameState @@ -81,7 +82,7 @@ def do_move(states, states_prev, moves, X_list, y_list, player_color): def train_batch(player, X_list, y_list, winners, lr): """Given the outcomes of a mini-batch of play against a fixed opponent, - update the weights with reinforcement learning. + update the weights with reinforcement learning in place. Args: player -- player object with policy weights to be updated @@ -90,9 +91,6 @@ def train_batch(player, X_list, y_list, winners, lr): winners -- List of winners corresponding to each item in training_pairs_list lr -- Keras learning rate - - Return: - player -- same player, with updated weights. """ for X, y, winner in zip(X_list, y_list, winners): @@ -103,10 +101,9 @@ def train_batch(player, X_list, y_list, winners, lr): else: player.policy.model.optimizer.lr.set_value(lr) player.policy.model.fit(X, y, nb_epoch=1, batch_size=len(X)) - return player -def run(player, args, opponents, features): +def run(player, args, opponents, features, model_folder): # Set SGD and compile sgd = SGD(lr=args.learning_rate) player.policy.model.compile(loss='binary_crossentropy', optimizer=sgd) @@ -115,18 +112,19 @@ def run(player, args, opponents, features): # Train mini-batches for i_batch in xrange(args.save_every): # Randomly choose opponent from pool - opp = np.random.choice(opponents) # TODO: change to file-loading scheme + opp_filepath = np.random.choice(os.listdir(model_folder)) + opp_path = os.path.join(model_folder,opp_filepath) + opp = CNNPolicy.create_network().load_weights(opp_path) # Make training pairs and do RL X_list, y_list, winners = make_training_pairs( player, opp, features, args.game_batch_size) n_wins = np.sum(np.array(winners) == 1) player_wins_per_batch.append(n_wins) print 'Number of wins this batch: {}/{}'.format(n_wins, args.game_batch_size) - player = train_batch(player, X_list, y_list, winners, args.learning_rate) - # TODO: Save policy to model folder and add snapshot of player to pool of opponents - # opponent_pths.append(player_pth) # Something like this? - return opponents - + train_batch(player, X_list, y_list, winners, args.learning_rate) + # Save intermediate models + model_path = os.path.join(model_folder,str(i_iter)) + player.policy.model.save_weights(model_path) if __name__ == '__main__': parser = argparse.ArgumentParser(description='Perform reinforcement learning ' @@ -148,7 +146,7 @@ def run(player, args, opponents, features): "--iterations", help="Number of training iterations (i.e. mini-batch) " "(Default: 20)", type=int, default=20) - # Baseline function (TODO) default lambda state: 0 (receives either file + # Baseline function (TODO) default lambda state: 0 (receives either file # paths to JSON and weights or None, in which case it uses default baseline 0) args = parser.parse_args() # Load policy from file From ac4267e5ba45ebbf763fc2d9f676574e0a7e25fc Mon Sep 17 00:00:00 2001 From: wrongu Date: Mon, 18 Apr 2016 09:27:19 -0400 Subject: [PATCH 014/191] adding include_eyes arg to get_legal_moves() i.e. when include_eyes=False, it's like a get_sensible_moves function --- AlphaGo/go.py | 4 ++-- AlphaGo/preprocessing/preprocessing.py | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/AlphaGo/go.py b/AlphaGo/go.py index dcda743e0..8ceed95bc 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -252,11 +252,11 @@ def is_eye(self, position, owner, stack=[]): return False return True - def get_legal_moves(self): + def get_legal_moves(self, include_eyes=True): moves = [] for x in range(self.size): for y in range(self.size): - if self.is_legal((x, y)): + if self.is_legal((x, y)) and (include_eyes or not self.is_eye((x, y), self.current_player)): moves.append((x, y)) return moves diff --git a/AlphaGo/preprocessing/preprocessing.py b/AlphaGo/preprocessing/preprocessing.py index 4a792f46f..3500e3c71 100644 --- a/AlphaGo/preprocessing/preprocessing.py +++ b/AlphaGo/preprocessing/preprocessing.py @@ -156,9 +156,8 @@ def get_sensibleness(state): """A move is 'sensible' if it is legal and if it does not fill the current_player's own eye """ feature = np.zeros((state.size, state.size)) - for (x, y) in state.get_legal_moves(): - if not state.is_eye((x, y), state.current_player): - feature[x, y] = 1 + for (x, y) in state.get_legal_moves(include_eyes=False): + feature[x, y] = 1 return feature # named features and their sizes are defined here From 47ecd9cb0b5f0d620087329f65822346fd6adf71 Mon Sep 17 00:00:00 2001 From: yuewang0319 Date: Thu, 18 Feb 2016 07:58:47 -0500 Subject: [PATCH 015/191] TreeNode Class Added --- AlphaGo/mcts.py | 59 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/AlphaGo/mcts.py b/AlphaGo/mcts.py index d23ed32b6..73e310169 100644 --- a/AlphaGo/mcts.py +++ b/AlphaGo/mcts.py @@ -1,6 +1,61 @@ -class MCTS(object): - pass +import numpy as np +import policy +import value +import shallow_policy +import random +import go + +SIMULATIONS = 100 +LAMBDA = 0.5 +L = 20 + +class TreeNode(object): + + def __init__(self): + self.nVisits = 0 + self.toValue = 0 + self.children = [] + # get the number of legal moves from a given state + self.nActions = + + + def selectAction(self): + visited=[] + + def selection(self): + # select among children nodes with maximum value + maxValue = 0 + selected = TreeNode() + for treenode in self.children: + if(treenode.toValue > maxValue): + selected = treenode + maxValue = treenode.toValue + return selected + def expansion(self): + # expand children nodes + for i in range(0, nActions): + self.children.append(TreeNode()) + + def isLeaf(self): + # check if reaches leaf state + return self.children == [] + + def updateStats(self, value): + # update the number of visits and values + self.nVisits += 1 + self.toValue += value + + def evaluation(self, value, rollout): + #each treenode is evaluated using a weighted average between value network and fast rollout policy + return (1-LAMBDA)*value+LAMBDA*rollout + + def backup(self): + + +class MCTS(object): + pass + class ParallelMCTS(MCTS): pass From a4578568c7a3db5065aea10d29f680b240713820 Mon Sep 17 00:00:00 2001 From: yuewang0319 Date: Thu, 18 Feb 2016 21:33:13 -0500 Subject: [PATCH 016/191] backup and DFS added --- AlphaGo/mcts.py | 45 ++++++++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/AlphaGo/mcts.py b/AlphaGo/mcts.py index 73e310169..914dbc3fb 100644 --- a/AlphaGo/mcts.py +++ b/AlphaGo/mcts.py @@ -14,13 +14,23 @@ class TreeNode(object): def __init__(self): self.nVisits = 0 self.toValue = 0 - self.children = [] - # get the number of legal moves from a given state - self.nActions = - - - def selectAction(self): - visited=[] + self.children = [] + + + def DFS(self, Depth): + # need to move this part to MCTS class + visited = [] + visited.insert(0,self) + for n in range(0,Depth): + self.expansion(nActions) + self = self.selection() + visited.insert(0,self) + for treenode in visited: + treenode.updateVisits() + if(treenode.isLeaf()==False): + treenode.backup() + else: + def selection(self): # select among children nodes with maximum value @@ -32,7 +42,7 @@ def selection(self): maxValue = treenode.toValue return selected - def expansion(self): + def expansion(self, nActions): # expand children nodes for i in range(0, nActions): self.children.append(TreeNode()) @@ -41,17 +51,22 @@ def isLeaf(self): # check if reaches leaf state return self.children == [] - def updateStats(self, value): - # update the number of visits and values + def updateVisits(self): + # update the number of visits self.nVisits += 1 + + def updateValues(self, value): + # update values self.toValue += value - - def evaluation(self, value, rollout): - #each treenode is evaluated using a weighted average between value network and fast rollout policy - return (1-LAMBDA)*value+LAMBDA*rollout def backup(self): - + # Backpropagate values by averaging values of subtree + sumValue = 0 + for treenode in self.children: + sumValue += treenode.toValue + self.toValue = sumValue/len(self.children) + + class MCTS(object): pass From 44fbe293a945215a370839cedaeb00f3d8c2c8a4 Mon Sep 17 00:00:00 2001 From: yuewang0319 Date: Sun, 21 Feb 2016 18:52:58 -0500 Subject: [PATCH 017/191] MCTS Tree Search Class updated --- AlphaGo/mcts.py | 149 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 114 insertions(+), 35 deletions(-) diff --git a/AlphaGo/mcts.py b/AlphaGo/mcts.py index 914dbc3fb..63355e4e3 100644 --- a/AlphaGo/mcts.py +++ b/AlphaGo/mcts.py @@ -1,3 +1,4 @@ +from __future__ import division import numpy as np import policy import value @@ -5,47 +6,37 @@ import random import go -SIMULATIONS = 100 + LAMBDA = 0.5 -L = 20 + + class TreeNode(object): def __init__(self): + self.nVisits = 0 - self.toValue = 0 + self.Q_value = 0 + self.u_value = 0 self.children = [] - def DFS(self, Depth): - # need to move this part to MCTS class - visited = [] - visited.insert(0,self) - for n in range(0,Depth): - self.expansion(nActions) - self = self.selection() - visited.insert(0,self) - for treenode in visited: - treenode.updateVisits() - if(treenode.isLeaf()==False): - treenode.backup() - else: - - def selection(self): # select among children nodes with maximum value maxValue = 0 - selected = TreeNode() - for treenode in self.children: - if(treenode.toValue > maxValue): - selected = treenode - maxValue = treenode.toValue - return selected + action = (0,0) + selectednode = TreeNode() + for child in self.children: + if(child[1].toValue() > maxValue): + selectednode = child[1] + maxValue = child[1].toValue() + action = child[0] + return selectednode, action - def expansion(self, nActions): + def expansion(self, probs): # expand children nodes - for i in range(0, nActions): - self.children.append(TreeNode()) + for prob in probs: + self.children.append((prob[0],TreeNode())) def isLeaf(self): # check if reaches leaf state @@ -55,22 +46,110 @@ def updateVisits(self): # update the number of visits self.nVisits += 1 - def updateValues(self, value): - # update values - self.toValue += value + def updateLeafStats(self, value): + + # update Q value and counts of visits for leaf state + self.Q_value = self.Q_value * self.nVisits + value + self.nVisits += 1 + self.Q_value = self.Q_value / self.nVisits + + def updateBonus(self, probs): + + # update u value from a list of prior probability from policy function + for index in range(0, len(self.children)): + self.children[index].u_value = probs[index][1] / (1 + self.children[index].nVisits) - def backup(self): + def backUp(self): # Backpropagate values by averaging values of subtree sumValue = 0 for treenode in self.children: - sumValue += treenode.toValue + sumValue += treenode.Q_value self.toValue = sumValue/len(self.children) - + def toValue(self): + + # evaluate value of treenode from both Q value and u value + return self.Q_value + self.u_value + + class MCTS(object): - pass + def __init__(self, state): + + self.state = state + self.treenode = TreeNode() + + + def DFS(self, nDepth, traversed): + # Depth First Search Tree Traverse from start state over certain depths, keep track and update statistics for all of the traversed edges: (state, action, treenode)pair + visited = [] + treenode = self.treenode + state = self.state + + for index in range(0, nDepth): + + probs=priorProb(state) + treenode.expansion(probs) + treenode.updateBonus(probs) + treenode, action = treenode.selection() + # need do_move(action) function to return updated state + state = state.do_move(action) + visited.insert(0, (state, action, treenode)) + + for index in range(0, nDepth): + + if(visited[index][2].isLeaf() == False): + for item in traversed[index]: + #need function from GameState class to check if two state equals + if(item[0].equalstate(visited[index][0]): + item[2].updateVisits() + item[2].backUp() + + visited[index][2].updatedVisits() + visited[index][2].backUp() + traversed[index].append(visited[index]) + else: + value = self.leaf_evaluation(visited[index][0]) + for item in traversed[index]: + #need function from GameState class to check if two state equals + if(item[0].equalstate(visited[index][0]): + item[2].updateLeafStats(value) + + visited[index][2].updateLeafStats(value) + traversed[index].append(visited[index]) + + + def leafEvaluation(self, state): + # return weighted average between ramdom rollout by fast policy and value function + z = somerolloutfunction(state) + v = somevaluefunction(state) + return (1-LAMBDA)*v+LAMBDA*z + + def priorProb(self, state): + + # return a list of action, prior probality of from policy function of a given state + probs = somepolicyfunction(state) + return probs + + def getMove(self, nSimulations, nDepth = 20): + + # run MSTC simulations for a number of times and return the best move + traversed = [] + + for index in range(0, nDepth): + sublist = [] + traversed.append(sublist) + + for n in range(0, nSimulations): + self.DFS(nDepth, traversed) + + treenode, action = self.treenode.selection() + return action + + + + class ParallelMCTS(MCTS): - pass + pass From 406956673bfa8d4e8f880f463d1b46b909515bae Mon Sep 17 00:00:00 2001 From: yuewang0319 Date: Sat, 5 Mar 2016 14:19:58 -0500 Subject: [PATCH 018/191] Logical changes to MCTS to better match DeepMind's implementation --- AlphaGo/mcts.py | 164 ++++++++++++++++++++++-------------------------- 1 file changed, 76 insertions(+), 88 deletions(-) diff --git a/AlphaGo/mcts.py b/AlphaGo/mcts.py index 63355e4e3..b27e0334c 100644 --- a/AlphaGo/mcts.py +++ b/AlphaGo/mcts.py @@ -18,61 +18,55 @@ def __init__(self): self.nVisits = 0 self.Q_value = 0 self.u_value = 0 - self.children = [] + self.children = {} def selection(self): - # select among children nodes with maximum value - maxValue = 0 - action = (0,0) - selectednode = TreeNode() - for child in self.children: + # select among children nodes with maximum value, return selected treenode and action + selectednode = self.children.values()[0] + action = self.children.keys()[0] + maxValue = selectednode.toValue() + + for child in self.children.items(): if(child[1].toValue() > maxValue): selectednode = child[1] maxValue = child[1].toValue() action = child[0] return selectednode, action - def expansion(self, probs): - # expand children nodes - for prob in probs: - self.children.append((prob[0],TreeNode())) + def expansion(self, actions): + # expand children nodes to a dict with action(x, y) as keys, and TreeNode object as values + for action in actions: + self.children[actions[0]] = TreeNode() def isLeaf(self): # check if reaches leaf state - return self.children == [] + return self.children == {} def updateVisits(self): # update the number of visits self.nVisits += 1 - def updateLeafStats(self, value): + def updateQ_value(self, value): + # update Q value for leaf state + self.Q_value = value - # update Q value and counts of visits for leaf state - self.Q_value = self.Q_value * self.nVisits + value - self.nVisits += 1 - self.Q_value = self.Q_value / self.nVisits - - def updateBonus(self, probs): - + def updateU_value(self, actions): # update u value from a list of prior probability from policy function for index in range(0, len(self.children)): - self.children[index].u_value = probs[index][1] / (1 + self.children[index].nVisits) + self.children.values()[index].u_value = actions[index][1] / (1 + self.children.values()[index].nVisits) def backUp(self): # Backpropagate values by averaging values of subtree sumValue = 0 - for treenode in self.children: - sumValue += treenode.Q_value - self.toValue = sumValue/len(self.children) + for child in self.children.items(): + sumValue += child[1].Q_value + self.Q_value = sumValue / len(self.children) def toValue(self): - - # evaluate value of treenode from both Q value and u value + # evaluate the value of treenode with both Q value and u value return self.Q_value + self.u_value - - class MCTS(object): def __init__(self, state): @@ -80,76 +74,70 @@ def __init__(self, state): self.state = state self.treenode = TreeNode() - - def DFS(self, nDepth, traversed): - # Depth First Search Tree Traverse from start state over certain depths, keep track and update statistics for all of the traversed edges: (state, action, treenode)pair + def DFS(self, nDepth, treenode, state): + # Depth First Search Tree Traverse from start state over certain depths, keep track and update statistics for childrenlist of (state, treenode) pair visited = [] - treenode = self.treenode - state = self.state - - for index in range(0, nDepth): - - probs=priorProb(state) - treenode.expansion(probs) - treenode.updateBonus(probs) + visited.insert(0, (state, treenode)) + + for index in range(0, nDepth-1): + #actions = self.priorProb(state) + actions = self.priorProb() + treenode.expansion(actions) + treenode.updateU_value(actions) treenode, action = treenode.selection() - # need do_move(action) function to return updated state - state = state.do_move(action) - visited.insert(0, (state, action, treenode)) + state = state.do_move(action).copy() + visited.insert(0, (state, treenode)) - for index in range(0, nDepth): - - if(visited[index][2].isLeaf() == False): - for item in traversed[index]: - #need function from GameState class to check if two state equals - if(item[0].equalstate(visited[index][0]): - item[2].updateVisits() - item[2].backUp() - - visited[index][2].updatedVisits() - visited[index][2].backUp() - traversed[index].append(visited[index]) - else: - value = self.leaf_evaluation(visited[index][0]) - for item in traversed[index]: - #need function from GameState class to check if two state equals - if(item[0].equalstate(visited[index][0]): - item[2].updateLeafStats(value) - - visited[index][2].updateLeafStats(value) - traversed[index].append(visited[index]) - - - def leafEvaluation(self, state): - # return weighted average between ramdom rollout by fast policy and value function - z = somerolloutfunction(state) - v = somevaluefunction(state) - return (1-LAMBDA)*v+LAMBDA*z + # value = self.leafEvaluation(visited[0][0]) + value = self.leafEvaluation() + for index in range(1, len(visited)): + value /= len(visited[index][1].children) + # if(visited[index][1].isLeaf() == True): + #value = self.leafEvaluation(visited[index][0]) + # value = self.leafEvaluation() + # visited[index][1].updateQ_value(value) + # else: + # visited[index][1].backUp() + visited[-1][1].updateQ_value(value) + visited[-1][1].updateVisits() + return visited[-1][1] + + def leafEvaluation(self): #state): + # return weighted average between rollout and value function + # z = somerolloutfunction(state) + # for testing purposes, use random rollout instead + z = np.random.randint(2) + # v = somevaluefunction(state) + # for testing purposes, use random values instead + v = random.uniform(0, 1) + return (1-LAMBDA) * v + LAMBDA * z - def priorProb(self, state): - - # return a list of action, prior probality of from policy function of a given state - probs = somepolicyfunction(state) - return probs + def priorProb(self): #state): + # return a list of (action, prior probality) pair from policy function of a given state + #actions = somepolicyfunction(state) + # for testing purposes, use random values instead + actions = [] + for i in range(0, random.randrange(50, 300)): + actions.append(((np.random.randint(18), np.random.randint(18)), random.uniform(0, 1))) + return actions def getMove(self, nSimulations, nDepth = 20): - # run MSTC simulations for a number of times and return the best move - traversed = [] - - for index in range(0, nDepth): - sublist = [] - traversed.append(sublist) + #actions = self.priorProb(self.state) + actions = self.priorProb() + self.treenode.expansion(actions) - for n in range(0, nSimulations): - self.DFS(nDepth, traversed) - - treenode, action = self.treenode.selection() + for n in range(0, nSimulations): + + self.treenode.updateU_value(actions) + treenode, action = self.treenode.selection() + state = state.do_move(action).copy() + treenode = self.DFS(nDepth, treenode, state) + self.treenode.children[action] = treenode + + self.treenode.updateU_value(actions) + treenode, action = self.treenode.selection() return action - - - - class ParallelMCTS(MCTS): pass From 7e85580d47639e42c76a3515dd050f5e43117a37 Mon Sep 17 00:00:00 2001 From: yuewang0319 Date: Thu, 10 Mar 2016 20:08:41 -0500 Subject: [PATCH 019/191] mcts.py added --- AlphaGo/mcts.py | 194 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 134 insertions(+), 60 deletions(-) diff --git a/AlphaGo/mcts.py b/AlphaGo/mcts.py index b27e0334c..ec365a714 100644 --- a/AlphaGo/mcts.py +++ b/AlphaGo/mcts.py @@ -1,10 +1,8 @@ -from __future__ import division +from AlphaGo.go import GameState import numpy as np -import policy -import value -import shallow_policy +from AlphaGo.models.policy import CNNPolicy +from AlphaGo.models.value import value_trainer import random -import go LAMBDA = 0.5 @@ -12,7 +10,8 @@ class TreeNode(object): - +"""Tree Representation of MCTS that covers Selection, Expansion, Evaluation, Backup +""" def __init__(self): self.nVisits = 0 @@ -21,122 +20,197 @@ def __init__(self): self.children = {} + def expansion(self, actions): + """Expand subtree --a dictionary with a tuple of (x,y) position as keys, TreeNode object as values + + Keyword arguments: + Output from policy function-- a list of tuples of (x, y) position and prior probability + + Return: + None + + """ + for action in actions: + self.children[actions[0]] = TreeNode() + def selection(self): - # select among children nodes with maximum value, return selected treenode and action + """Select among subtree to get the position that gives maximum action value Q plus bonus u(P) + + Keyword arguments: + None. + + Return: + action -- a tuple of (x, y) + treenode object + + + """ selectednode = self.children.values()[0] - action = self.children.keys()[0] + selectedaction = self.children.keys()[0] maxValue = selectednode.toValue() for child in self.children.items(): if(child[1].toValue() > maxValue): selectednode = child[1] maxValue = child[1].toValue() - action = child[0] - return selectednode, action + selectedaction = child[0] + + return selectednode, selectedaction + - def expansion(self, actions): - # expand children nodes to a dict with action(x, y) as keys, and TreeNode object as values - for action in actions: - self.children[actions[0]] = TreeNode() def isLeaf(self): - # check if reaches leaf state + """Check if leaf state is reached + """ + return self.children == {} def updateVisits(self): - # update the number of visits + """Update the count of visit times + """ self.nVisits += 1 def updateQ_value(self, value): - # update Q value for leaf state - self.Q_value = value + """Update the action value Q + """ + (self.Q_value * self.nVisits + value) / (self.nVisits + 1) def updateU_value(self, actions): - # update u value from a list of prior probability from policy function + + """Update the bonus value u(P)--proportional to the prior probability but decays with the number of visits to encourage exploration + + Keyword arguments: + Output from policy function-- a list of tuples of (x, y) position and prior probability + + Return: + None + + """ + for index in range(0, len(self.children)): self.children.values()[index].u_value = actions[index][1] / (1 + self.children.values()[index].nVisits) - def backUp(self): - # Backpropagate values by averaging values of subtree - sumValue = 0 - for child in self.children.items(): - sumValue += child[1].Q_value - self.Q_value = sumValue / len(self.children) + def backUp(self, value): + + """Track the mean value of evaluations in the subtrees + + Keyword arguments: + value of traversed subtree evaluation each simulation + + Return: + Mean value + + """ + return value / len(self.children) + def toValue(self): - # evaluate the value of treenode with both Q value and u value + """Return action value Q plus bonus u(P) + """ return self.Q_value + self.u_value class MCTS(object): - + """Monte Carlo tree search, takes an input of game state, outputs an action after lookahead search is complete. + """ + def __init__(self, state): - self.state = state + self.state = GameState() self.treenode = TreeNode() - def DFS(self, nDepth, treenode, state): - # Depth First Search Tree Traverse from start state over certain depths, keep track and update statistics for childrenlist of (state, treenode) pair + def DFS(self, nDepth = 20, treenode, state): + """Monte Carlo tree search over a certain depth per simulation, at the end of simulation, + the action values and visits of counts of traversed treenode are updated. + + Keyword arguments: + Initial GameState object + Initial TreeNode object + Search Depth + + Return: + TreeNode object with updated statistics(visit count N, action value Q) + """ + visited = [] visited.insert(0, (state, treenode)) for index in range(0, nDepth-1): - #actions = self.priorProb(state) - actions = self.priorProb() + actions = self.priorProb(state) treenode.expansion(actions) treenode.updateU_value(actions) treenode, action = treenode.selection() state = state.do_move(action).copy() visited.insert(0, (state, treenode)) - # value = self.leafEvaluation(visited[0][0]) - value = self.leafEvaluation() - for index in range(1, len(visited)): - value /= len(visited[index][1].children) - # if(visited[index][1].isLeaf() == True): - #value = self.leafEvaluation(visited[index][0]) - # value = self.leafEvaluation() - # visited[index][1].updateQ_value(value) - # else: - # visited[index][1].backUp() + for index in range(0, len(visited)-1): + if(visited[index][1].isLeaf() == True): + value = self.leafEvaluation(visited[index][0]) + else: + value = visited[index][1].backUp(value) + visited[-1][1].updateQ_value(value) visited[-1][1].updateVisits() return visited[-1][1] - def leafEvaluation(self): #state): - # return weighted average between rollout and value function - # z = somerolloutfunction(state) - # for testing purposes, use random rollout instead + def leafEvaluation(self, state): + """Calculate leaf evaluation, a weighted average using a mixing parameter LAMBDA, combined outcome z + of a random rollout using the fast rollout policy and value network output v. + + Keyword arguments: + GameState object + + Return: + value + """ + + """ + Use random generated values for now + """ z = np.random.randint(2) - # v = somevaluefunction(state) - # for testing purposes, use random values instead v = random.uniform(0, 1) return (1-LAMBDA) * v + LAMBDA * z - def priorProb(self): #state): - # return a list of (action, prior probality) pair from policy function of a given state - #actions = somepolicyfunction(state) - # for testing purposes, use random values instead - actions = [] - for i in range(0, random.randrange(50, 300)): - actions.append(((np.random.randint(18), np.random.randint(18)), random.uniform(0, 1))) + def priorProb(self, state): + """Get a list of (action, probability) pairs according to policy network outputs + + Keyword arguments: + GameState object + + Return: + list of tuples ((x,y), probability) + """ + + policy = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"]) + actions = policy.eval_state(state) + return actions - def getMove(self, nSimulations, nDepth = 20): - # run MSTC simulations for a number of times and return the best move - #actions = self.priorProb(self.state) - actions = self.priorProb() + def getMove(self, nSimulations): + + """After running simulations for a certain number of times, when the search is complete, an action is selected + from root state + + Keyword arguments: + Number of Simulations + + Return: + action -- a tuple of (x, y) + """ + + actions = self.priorProb(self.state) self.treenode.expansion(actions) for n in range(0, nSimulations): self.treenode.updateU_value(actions) treenode, action = self.treenode.selection() - state = state.do_move(action).copy() + state = self.state.do_move(action).copy() treenode = self.DFS(nDepth, treenode, state) self.treenode.children[action] = treenode self.treenode.updateU_value(actions) treenode, action = self.treenode.selection() + return action class ParallelMCTS(MCTS): From 8b3ccd0f66347bffd8f95896afd801f99cbe3586 Mon Sep 17 00:00:00 2001 From: yuewang0319 Date: Thu, 10 Mar 2016 21:01:18 -0500 Subject: [PATCH 020/191] test_mcts.py added. associated fixes and comments --- AlphaGo/mcts.py | 175 +++++++++++++++++++++++---------------------- tests/test_mcts.py | 37 ++++++++++ 2 files changed, 127 insertions(+), 85 deletions(-) create mode 100644 tests/test_mcts.py diff --git a/AlphaGo/mcts.py b/AlphaGo/mcts.py index ec365a714..6a32c65d6 100644 --- a/AlphaGo/mcts.py +++ b/AlphaGo/mcts.py @@ -1,7 +1,5 @@ -from AlphaGo.go import GameState +from go import GameState import numpy as np -from AlphaGo.models.policy import CNNPolicy -from AlphaGo.models.value import value_trainer import random @@ -10,17 +8,17 @@ class TreeNode(object): -"""Tree Representation of MCTS that covers Selection, Expansion, Evaluation, Backup -""" - def __init__(self): + """Tree Representation of MCTS that covers Selection, Expansion, Evaluation, Backup + """ + def __init__(self): - self.nVisits = 0 - self.Q_value = 0 - self.u_value = 0 - self.children = {} + self.nVisits = 0 + self.Q_value = 0 + self.u_value = 0 + self.children = {} - def expansion(self, actions): + def expansion(self, actions): """Expand subtree --a dictionary with a tuple of (x,y) position as keys, TreeNode object as values Keyword arguments: @@ -30,10 +28,10 @@ def expansion(self, actions): None """ - for action in actions: - self.children[actions[0]] = TreeNode() + for action in actions: + self.children[action[0]] = TreeNode() - def selection(self): + def selection(self): """Select among subtree to get the position that gives maximum action value Q plus bonus u(P) Keyword arguments: @@ -45,37 +43,36 @@ def selection(self): """ - selectednode = self.children.values()[0] - selectedaction = self.children.keys()[0] - maxValue = selectednode.toValue() + selectednode = self.children.values()[0] + selectedaction = self.children.keys()[0] + maxValue = selectednode.toValue() - for child in self.children.items(): - if(child[1].toValue() > maxValue): - selectednode = child[1] - maxValue = child[1].toValue() - selectedaction = child[0] - - return selectednode, selectedaction + for child in self.children.items(): + if(child[1].toValue() > maxValue): + selectednode = child[1] + maxValue = child[1].toValue() + selectedaction = child[0] + return selectednode, selectedaction - def isLeaf(self): + def isLeaf(self): """Check if leaf state is reached """ - return self.children == {} + return self.children == {} - def updateVisits(self): + def updateVisits(self): """Update the count of visit times """ - self.nVisits += 1 + self.nVisits += 1 - def updateQ_value(self, value): + def updateQ_value(self, value): """Update the action value Q """ - (self.Q_value * self.nVisits + value) / (self.nVisits + 1) + self.Q_value = (self.Q_value * self.nVisits + value) / (self.nVisits + 1) - def updateU_value(self, actions): + def updateU_value(self, actions): """Update the bonus value u(P)--proportional to the prior probability but decays with the number of visits to encourage exploration @@ -87,10 +84,10 @@ def updateU_value(self, actions): """ - for index in range(0, len(self.children)): - self.children.values()[index].u_value = actions[index][1] / (1 + self.children.values()[index].nVisits) + for index in range(0, len(self.children)): + self.children[actions[index][0]].u_value = actions[index][1] / (1 + self.children[actions[index][0]].nVisits) - def backUp(self, value): + def backUp(self, value): """Track the mean value of evaluations in the subtrees @@ -101,24 +98,24 @@ def backUp(self, value): Mean value """ - return value / len(self.children) + return value / len(self.children) - def toValue(self): + def toValue(self): """Return action value Q plus bonus u(P) """ - return self.Q_value + self.u_value + return self.Q_value + self.u_value class MCTS(object): - """Monte Carlo tree search, takes an input of game state, outputs an action after lookahead search is complete. - """ + """Monte Carlo tree search, takes an input of game state, outputs an action after lookahead search is complete. + """ - def __init__(self, state): + def __init__(self, state): - self.state = GameState() - self.treenode = TreeNode() + self.state = GameState() + self.treenode = TreeNode() - def DFS(self, nDepth = 20, treenode, state): + def DFS(self, nDepth, treenode, state): """Monte Carlo tree search over a certain depth per simulation, at the end of simulation, the action values and visits of counts of traversed treenode are updated. @@ -131,28 +128,27 @@ def DFS(self, nDepth = 20, treenode, state): TreeNode object with updated statistics(visit count N, action value Q) """ - visited = [] - visited.insert(0, (state, treenode)) + visited = [] + visited.insert(0, (state, treenode)) - for index in range(0, nDepth-1): - actions = self.priorProb(state) - treenode.expansion(actions) - treenode.updateU_value(actions) - treenode, action = treenode.selection() - state = state.do_move(action).copy() - visited.insert(0, (state, treenode)) + for index in range(0, nDepth-1): + actions = self.priorProb(state) + treenode.expansion(actions) + treenode.updateU_value(actions) + treenode, action = treenode.selection() + state = state.do_move(action).copy() + visited.insert(0, (state, treenode)) - for index in range(0, len(visited)-1): - if(visited[index][1].isLeaf() == True): - value = self.leafEvaluation(visited[index][0]) - else: - value = visited[index][1].backUp(value) - - visited[-1][1].updateQ_value(value) - visited[-1][1].updateVisits() - return visited[-1][1] - - def leafEvaluation(self, state): + for index in range(0, len(visited)-1): + if(visited[index][1].isLeaf() == True): + value = self.leafEvaluation(visited[index][0]) + else: + value = visited[index][1].backUp(value) + visited[-1][1].updateQ_value(value) + visited[-1][1].updateVisits() + return visited[-1][1] + + def leafEvaluation(self, state): """Calculate leaf evaluation, a weighted average using a mixing parameter LAMBDA, combined outcome z of a random rollout using the fast rollout policy and value network output v. @@ -166,11 +162,11 @@ def leafEvaluation(self, state): """ Use random generated values for now """ - z = np.random.randint(2) - v = random.uniform(0, 1) - return (1-LAMBDA) * v + LAMBDA * z + z = np.random.randint(2) + v = random.uniform(0, 1) + return (1-LAMBDA) * v + LAMBDA * z - def priorProb(self, state): + def priorProb(self, state): """Get a list of (action, probability) pairs according to policy network outputs Keyword arguments: @@ -178,14 +174,23 @@ def priorProb(self, state): Return: list of tuples ((x,y), probability) - """ + + + policy = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"]) + actions = policy.eval_state(state) + - policy = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"]) - actions = policy.eval_state(state) + Use random generated values for now + + """ + actions = [] + for i in range(0, 10): + actions.append(((i, i+1), random.uniform(0, 1))) + + return actions - return actions - def getMove(self, nSimulations): + def getMove(self, nDepth, nSimulations): """After running simulations for a certain number of times, when the search is complete, an action is selected from root state @@ -197,21 +202,21 @@ def getMove(self, nSimulations): action -- a tuple of (x, y) """ - actions = self.priorProb(self.state) - self.treenode.expansion(actions) + actions = self.priorProb(self.state) + self.treenode.expansion(actions) - for n in range(0, nSimulations): - - self.treenode.updateU_value(actions) - treenode, action = self.treenode.selection() - state = self.state.do_move(action).copy() - treenode = self.DFS(nDepth, treenode, state) - self.treenode.children[action] = treenode + for n in range(0, nSimulations): - self.treenode.updateU_value(actions) - treenode, action = self.treenode.selection() - - return action + self.treenode.updateU_value(actions) + treenode, action = self.treenode.selection() + state = self.state.do_move(action).copy() + treenode = self.DFS(nDepth, treenode, state) + self.treenode.children[action] = treenode + + self.treenode.updateU_value(actions) + treenode, action = self.treenode.selection() + return action class ParallelMCTS(MCTS): pass + diff --git a/tests/test_mcts.py b/tests/test_mcts.py new file mode 100644 index 000000000..136d55595 --- /dev/null +++ b/tests/test_mcts.py @@ -0,0 +1,37 @@ +from AlphaGo.training.go import GameState +from AlphaGo.training.mcts import MCTS +from AlphaGo.training.mcts import TreeNode +import numpy as np +import unittest + + +class TestMCTS(unittest.TestCase): + + def setUp(self): + self.s = GameState() + self.mcts = MCTS() + self.treenode = TreeNode() + + + def test_treenode_selection(self): + actions = self.mcts.priorProb(self.s) + self.treenode.expansion(actions) + self.treenode.updateU_value(actions) + selectednode, selectedaction = self.treenode.selection() + self.assertEqual(max(actions, key = lambda x:x[1]), selectednode.toValue(), 'incorrect node selected') + + def test_mcts_DFS(self): + treenode = self.mcts.DFS(3, self.treenode, self.s) + self.assertEqual(1, treenode.nVisits, 'incorrect visit count') + + + def test_mcts_getMove(self): + action = self.mcts.getMove(2, 3) + self.assertIsNotNone(action,'no output action') + + + +if __name__ == '__main__': + unittest.main() + + From 001a9a8249404dbe01a3b6401177029cfe9561bd Mon Sep 17 00:00:00 2001 From: yuewang0319 Date: Sun, 13 Mar 2016 01:58:23 -0500 Subject: [PATCH 021/191] changed constructor parameters to be more generic --- AlphaGo/mcts.py | 52 ++++++++++++++++++++-------------------------- tests/test_mcts.py | 30 +++++++++++++++++++------- 2 files changed, 45 insertions(+), 37 deletions(-) diff --git a/AlphaGo/mcts.py b/AlphaGo/mcts.py index 6a32c65d6..d467fba69 100644 --- a/AlphaGo/mcts.py +++ b/AlphaGo/mcts.py @@ -1,7 +1,3 @@ -from go import GameState -import numpy as np -import random - LAMBDA = 0.5 @@ -107,13 +103,16 @@ def toValue(self): return self.Q_value + self.u_value class MCTS(object): - """Monte Carlo tree search, takes an input of game state, outputs an action after lookahead search is complete. + """Monte Carlo tree search, takes an input of game state, value network function, policy network function, rollout policy function, outputs an action after lookahead search is complete. """ - def __init__(self, state): + def __init__(self, state, value_network, policy_network, rollout_policy): - self.state = GameState() + self.state = state self.treenode = TreeNode() + self._value = value_network + self._policy = policy_network + self._rollout = rollout_policy def DFS(self, nDepth, treenode, state): """Monte Carlo tree search over a certain depth per simulation, at the end of simulation, @@ -136,21 +135,22 @@ def DFS(self, nDepth, treenode, state): treenode.expansion(actions) treenode.updateU_value(actions) treenode, action = treenode.selection() - state = state.do_move(action).copy() + state.do_move(action) visited.insert(0, (state, treenode)) - for index in range(0, len(visited)-1): + for index in range(0, len(visited)): if(visited[index][1].isLeaf() == True): value = self.leafEvaluation(visited[index][0]) else: value = visited[index][1].backUp(value) + visited[-1][1].updateQ_value(value) visited[-1][1].updateVisits() return visited[-1][1] def leafEvaluation(self, state): """Calculate leaf evaluation, a weighted average using a mixing parameter LAMBDA, combined outcome z - of a random rollout using the fast rollout policy and value network output v. + of fast rollout policy function and value network function output v. Keyword arguments: GameState object @@ -158,16 +158,12 @@ def leafEvaluation(self, state): Return: value """ - - """ - Use random generated values for now - """ - z = np.random.randint(2) - v = random.uniform(0, 1) + z = self._rollout(state) + v = self._value(state) return (1-LAMBDA) * v + LAMBDA * z def priorProb(self, state): - """Get a list of (action, probability) pairs according to policy network outputs + """Get a list of (action, probability) pairs according to policy network function outputs Keyword arguments: GameState object @@ -175,18 +171,9 @@ def priorProb(self, state): Return: list of tuples ((x,y), probability) - - policy = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"]) - actions = policy.eval_state(state) - - - Use random generated values for now - """ - actions = [] - for i in range(0, 10): - actions.append(((i, i+1), random.uniform(0, 1))) - + actions = self._policy(state) + return actions @@ -209,7 +196,8 @@ def getMove(self, nDepth, nSimulations): self.treenode.updateU_value(actions) treenode, action = self.treenode.selection() - state = self.state.do_move(action).copy() + state = self.state.copy() + state.do_move(action) treenode = self.DFS(nDepth, treenode, state) self.treenode.children[action] = treenode @@ -218,5 +206,9 @@ def getMove(self, nDepth, nSimulations): return action class ParallelMCTS(MCTS): - pass + pass + + + + diff --git a/tests/test_mcts.py b/tests/test_mcts.py index 136d55595..3d14b7bda 100644 --- a/tests/test_mcts.py +++ b/tests/test_mcts.py @@ -1,6 +1,7 @@ -from AlphaGo.training.go import GameState -from AlphaGo.training.mcts import MCTS -from AlphaGo.training.mcts import TreeNode +from AlphaGo.go import GameState +from AlphaGo.mcts import MCTS +from AlphaGo.mcts import TreeNode +import random import numpy as np import unittest @@ -9,7 +10,7 @@ class TestMCTS(unittest.TestCase): def setUp(self): self.s = GameState() - self.mcts = MCTS() + self.mcts = MCTS(s, value_network, policy_network, rollout_policy) self.treenode = TreeNode() @@ -19,18 +20,33 @@ def test_treenode_selection(self): self.treenode.updateU_value(actions) selectednode, selectedaction = self.treenode.selection() self.assertEqual(max(actions, key = lambda x:x[1]), selectednode.toValue(), 'incorrect node selected') - + self.assertEqual(max(actions, key = lambda x:x[0]), selectedaction, 'incorrect action selected') + def test_mcts_DFS(self): + value = self.mcts.leafEvaluation(self.s) treenode = self.mcts.DFS(3, self.treenode, self.s) self.assertEqual(1, treenode.nVisits, 'incorrect visit count') - + self.assertEqual(value/2*9, treenode.Q_value, 'incorrect Q_value') def test_mcts_getMove(self): action = self.mcts.getMove(2, 3) self.assertIsNotNone(action,'no output action') + print("getMove checked") +def policy_network(state): + actions = [] + for i in range(0, 10): + actions.append(((i, i+1), random.uniform(0, 1))) + return actions + +def value_network(state): + + return 0.5 + +def rollout_policy(state): + + return 1 - if __name__ == '__main__': unittest.main() From bfdaee778afe6498a0de34820bd20f1b7f55ad53 Mon Sep 17 00:00:00 2001 From: yuewang0319 Date: Mon, 14 Mar 2016 12:03:34 -0400 Subject: [PATCH 022/191] spaces to tabs --- AlphaGo/mcts.py | 348 ++++++++++++++++++++++++------------------------ 1 file changed, 174 insertions(+), 174 deletions(-) diff --git a/AlphaGo/mcts.py b/AlphaGo/mcts.py index d467fba69..7f822ee2c 100644 --- a/AlphaGo/mcts.py +++ b/AlphaGo/mcts.py @@ -4,209 +4,209 @@ class TreeNode(object): - """Tree Representation of MCTS that covers Selection, Expansion, Evaluation, Backup - """ - def __init__(self): - - self.nVisits = 0 - self.Q_value = 0 - self.u_value = 0 - self.children = {} + """Tree Representation of MCTS that covers Selection, Expansion, Evaluation, Backup + """ + def __init__(self): + + self.nVisits = 0 + self.Q_value = 0 + self.u_value = 0 + self.children = {} - def expansion(self, actions): - """Expand subtree --a dictionary with a tuple of (x,y) position as keys, TreeNode object as values + def expansion(self, actions): + """Expand subtree --a dictionary with a tuple of (x,y) position as keys, TreeNode object as values - Keyword arguments: - Output from policy function-- a list of tuples of (x, y) position and prior probability + Keyword arguments: + Output from policy function-- a list of tuples of (x, y) position and prior probability - Return: - None + Return: + None - """ - for action in actions: - self.children[action[0]] = TreeNode() + """ + for action in actions: + self.children[action[0]] = TreeNode() - def selection(self): - """Select among subtree to get the position that gives maximum action value Q plus bonus u(P) + def selection(self): + """Select among subtree to get the position that gives maximum action value Q plus bonus u(P) - Keyword arguments: - None. + Keyword arguments: + None. - Return: - action -- a tuple of (x, y) - treenode object - + Return: + action -- a tuple of (x, y) + treenode object + - """ - selectednode = self.children.values()[0] - selectedaction = self.children.keys()[0] - maxValue = selectednode.toValue() - - for child in self.children.items(): - if(child[1].toValue() > maxValue): - selectednode = child[1] - maxValue = child[1].toValue() - selectedaction = child[0] - return selectednode, selectedaction + """ + selectednode = self.children.values()[0] + selectedaction = self.children.keys()[0] + maxValue = selectednode.toValue() + + for child in self.children.items(): + if(child[1].toValue() > maxValue): + selectednode = child[1] + maxValue = child[1].toValue() + selectedaction = child[0] + return selectednode, selectedaction - - def isLeaf(self): - """Check if leaf state is reached - """ + + def isLeaf(self): + """Check if leaf state is reached + """ - return self.children == {} + return self.children == {} - def updateVisits(self): - """Update the count of visit times - """ - self.nVisits += 1 + def updateVisits(self): + """Update the count of visit times + """ + self.nVisits += 1 - def updateQ_value(self, value): - """Update the action value Q - """ - self.Q_value = (self.Q_value * self.nVisits + value) / (self.nVisits + 1) - - def updateU_value(self, actions): + def updateQ_value(self, value): + """Update the action value Q + """ + self.Q_value = (self.Q_value * self.nVisits + value) / (self.nVisits + 1) + + def updateU_value(self, actions): - """Update the bonus value u(P)--proportional to the prior probability but decays with the number of visits to encourage exploration + """Update the bonus value u(P)--proportional to the prior probability but decays with the number of visits to encourage exploration - Keyword arguments: - Output from policy function-- a list of tuples of (x, y) position and prior probability + Keyword arguments: + Output from policy function-- a list of tuples of (x, y) position and prior probability - Return: - None + Return: + None - """ + """ - for index in range(0, len(self.children)): - self.children[actions[index][0]].u_value = actions[index][1] / (1 + self.children[actions[index][0]].nVisits) + for index in range(0, len(self.children)): + self.children[actions[index][0]].u_value = actions[index][1] / (1 + self.children[actions[index][0]].nVisits) - def backUp(self, value): + def backUp(self, value): - """Track the mean value of evaluations in the subtrees + """Track the mean value of evaluations in the subtrees - Keyword arguments: - value of traversed subtree evaluation each simulation + Keyword arguments: + value of traversed subtree evaluation each simulation - Return: - Mean value + Return: + Mean value - """ - return value / len(self.children) + """ + return value / len(self.children) - - def toValue(self): - """Return action value Q plus bonus u(P) - """ - return self.Q_value + self.u_value + + def toValue(self): + """Return action value Q plus bonus u(P) + """ + return self.Q_value + self.u_value class MCTS(object): - """Monte Carlo tree search, takes an input of game state, value network function, policy network function, rollout policy function, outputs an action after lookahead search is complete. - """ - - def __init__(self, state, value_network, policy_network, rollout_policy): - - self.state = state - self.treenode = TreeNode() - self._value = value_network - self._policy = policy_network - self._rollout = rollout_policy - - def DFS(self, nDepth, treenode, state): - """Monte Carlo tree search over a certain depth per simulation, at the end of simulation, - the action values and visits of counts of traversed treenode are updated. - - Keyword arguments: - Initial GameState object - Initial TreeNode object - Search Depth - - Return: - TreeNode object with updated statistics(visit count N, action value Q) - """ - - visited = [] - visited.insert(0, (state, treenode)) - - for index in range(0, nDepth-1): - actions = self.priorProb(state) - treenode.expansion(actions) - treenode.updateU_value(actions) - treenode, action = treenode.selection() - state.do_move(action) - visited.insert(0, (state, treenode)) - - for index in range(0, len(visited)): - if(visited[index][1].isLeaf() == True): - value = self.leafEvaluation(visited[index][0]) - else: - value = visited[index][1].backUp(value) - - visited[-1][1].updateQ_value(value) - visited[-1][1].updateVisits() - return visited[-1][1] - - def leafEvaluation(self, state): - """Calculate leaf evaluation, a weighted average using a mixing parameter LAMBDA, combined outcome z - of fast rollout policy function and value network function output v. - - Keyword arguments: - GameState object - - Return: - value - """ - z = self._rollout(state) - v = self._value(state) - return (1-LAMBDA) * v + LAMBDA * z - - def priorProb(self, state): - """Get a list of (action, probability) pairs according to policy network function outputs - - Keyword arguments: - GameState object - - Return: - list of tuples ((x,y), probability) - - """ - actions = self._policy(state) - - return actions - - - def getMove(self, nDepth, nSimulations): - - """After running simulations for a certain number of times, when the search is complete, an action is selected - from root state - - Keyword arguments: - Number of Simulations - - Return: - action -- a tuple of (x, y) - """ - - actions = self.priorProb(self.state) - self.treenode.expansion(actions) - - for n in range(0, nSimulations): - - self.treenode.updateU_value(actions) - treenode, action = self.treenode.selection() - state = self.state.copy() - state.do_move(action) - treenode = self.DFS(nDepth, treenode, state) - self.treenode.children[action] = treenode - - self.treenode.updateU_value(actions) - treenode, action = self.treenode.selection() - return action + """Monte Carlo tree search, takes an input of game state, value network function, policy network function, rollout policy function, outputs an action after lookahead search is complete. + """ + + def __init__(self, state, value_network, policy_network, rollout_policy): + + self.state = state + self.treenode = TreeNode() + self._value = value_network + self._policy = policy_network + self._rollout = rollout_policy + + def DFS(self, nDepth, treenode, state): + """Monte Carlo tree search over a certain depth per simulation, at the end of simulation, + the action values and visits of counts of traversed treenode are updated. + + Keyword arguments: + Initial GameState object + Initial TreeNode object + Search Depth + + Return: + TreeNode object with updated statistics(visit count N, action value Q) + """ + + visited = [] + visited.insert(0, (state, treenode)) + + for index in range(0, nDepth-1): + actions = self.priorProb(state) + treenode.expansion(actions) + treenode.updateU_value(actions) + treenode, action = treenode.selection() + state.do_move(action) + visited.insert(0, (state, treenode)) + + for index in range(0, len(visited)): + if(visited[index][1].isLeaf() == True): + value = self.leafEvaluation(visited[index][0]) + else: + value = visited[index][1].backUp(value) + + visited[-1][1].updateQ_value(value) + visited[-1][1].updateVisits() + return visited[-1][1] + + def leafEvaluation(self, state): + """Calculate leaf evaluation, a weighted average using a mixing parameter LAMBDA, combined outcome z + of fast rollout policy function and value network function output v. + + Keyword arguments: + GameState object + + Return: + value + """ + z = self._rollout(state) + v = self._value(state) + return (1-LAMBDA) * v + LAMBDA * z + + def priorProb(self, state): + """Get a list of (action, probability) pairs according to policy network function outputs + + Keyword arguments: + GameState object + + Return: + list of tuples ((x,y), probability) + + """ + actions = self._policy(state) + + return actions + + + def getMove(self, nDepth, nSimulations): + + """After running simulations for a certain number of times, when the search is complete, an action is selected + from root state + + Keyword arguments: + Number of Simulations + + Return: + action -- a tuple of (x, y) + """ + + actions = self.priorProb(self.state) + self.treenode.expansion(actions) + + for n in range(0, nSimulations): + + self.treenode.updateU_value(actions) + treenode, action = self.treenode.selection() + state = self.state.copy() + state.do_move(action) + treenode = self.DFS(nDepth, treenode, state) + self.treenode.children[action] = treenode + + self.treenode.updateU_value(actions) + treenode, action = self.treenode.selection() + return action class ParallelMCTS(MCTS): - pass + pass From 7b5e3030a926d5c0f6362aeba98b8e781ff234d6 Mon Sep 17 00:00:00 2001 From: yuewang0319 Date: Mon, 14 Mar 2016 16:48:36 -0400 Subject: [PATCH 023/191] use get_legal_moves to append legal actions for testing purposes --- tests/test_mcts.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/tests/test_mcts.py b/tests/test_mcts.py index 3d14b7bda..4a1a20cb9 100644 --- a/tests/test_mcts.py +++ b/tests/test_mcts.py @@ -10,7 +10,7 @@ class TestMCTS(unittest.TestCase): def setUp(self): self.s = GameState() - self.mcts = MCTS(s, value_network, policy_network, rollout_policy) + self.mcts = MCTS(self.s, value_network, policy_network, rollout_policy) self.treenode = TreeNode() @@ -19,24 +19,29 @@ def test_treenode_selection(self): self.treenode.expansion(actions) self.treenode.updateU_value(actions) selectednode, selectedaction = self.treenode.selection() - self.assertEqual(max(actions, key = lambda x:x[1]), selectednode.toValue(), 'incorrect node selected') - self.assertEqual(max(actions, key = lambda x:x[0]), selectedaction, 'incorrect action selected') + self.assertEqual(max(actions, key = lambda x:x[1])[1], selectednode.toValue(), 'incorrect node selected') + self.assertEqual(max(actions, key = lambda x:x[1])[0], selectedaction, 'incorrect action selected') def test_mcts_DFS(self): - value = self.mcts.leafEvaluation(self.s) treenode = self.mcts.DFS(3, self.treenode, self.s) self.assertEqual(1, treenode.nVisits, 'incorrect visit count') - self.assertEqual(value/2*9, treenode.Q_value, 'incorrect Q_value') + def test_mcts_getMove(self): - action = self.mcts.getMove(2, 3) + action = self.mcts.getMove(3, 10) self.assertIsNotNone(action,'no output action') - print("getMove checked") + action = self.mcts.getMove(4, 5) + self.assertIsNotNone(action,'no output action') + action = self.mcts.getMove(6, 8) + self.assertIsNotNone(action,'no output action') + def policy_network(state): + s = GameState() + moves = s.get_legal_moves() actions = [] - for i in range(0, 10): - actions.append(((i, i+1), random.uniform(0, 1))) + for move in moves: + actions.append((move, random.uniform(0, 1))) return actions def value_network(state): From 545486361b269311cbfb3678d3213a575e9f5b50 Mon Sep 17 00:00:00 2001 From: wrongu Date: Tue, 29 Mar 2016 15:55:11 -0400 Subject: [PATCH 024/191] whitespace cleanup (passing flake8) --- AlphaGo/mcts.py | 88 ++++++++++++++++++++-------------------------- tests/test_mcts.py | 76 +++++++++++++++++++-------------------- 2 files changed, 75 insertions(+), 89 deletions(-) diff --git a/AlphaGo/mcts.py b/AlphaGo/mcts.py index 7f822ee2c..9a781c4e7 100644 --- a/AlphaGo/mcts.py +++ b/AlphaGo/mcts.py @@ -2,17 +2,14 @@ LAMBDA = 0.5 - class TreeNode(object): """Tree Representation of MCTS that covers Selection, Expansion, Evaluation, Backup """ def __init__(self): - self.nVisits = 0 self.Q_value = 0 self.u_value = 0 - self.children = {} - + self.children = {} def expansion(self, actions): """Expand subtree --a dictionary with a tuple of (x,y) position as keys, TreeNode object as values @@ -24,34 +21,32 @@ def expansion(self, actions): None """ - for action in actions: + for action in actions: self.children[action[0]] = TreeNode() def selection(self): """Select among subtree to get the position that gives maximum action value Q plus bonus u(P) Keyword arguments: - None. + None. Return: action -- a tuple of (x, y) treenode object - + """ selectednode = self.children.values()[0] selectedaction = self.children.keys()[0] maxValue = selectednode.toValue() - + for child in self.children.items(): - if(child[1].toValue() > maxValue): + if child[1].toValue() > maxValue: selectednode = child[1] maxValue = child[1].toValue() selectedaction = child[0] return selectednode, selectedaction - - def isLeaf(self): """Check if leaf state is reached """ @@ -59,16 +54,16 @@ def isLeaf(self): return self.children == {} def updateVisits(self): - """Update the count of visit times + """Update the count of visit times """ self.nVisits += 1 def updateQ_value(self, value): - """Update the action value Q + """Update the action value Q """ self.Q_value = (self.Q_value * self.nVisits + value) / (self.nVisits + 1) - - def updateU_value(self, actions): + + def updateU_value(self, actions): """Update the bonus value u(P)--proportional to the prior probability but decays with the number of visits to encourage exploration @@ -80,7 +75,7 @@ def updateU_value(self, actions): """ - for index in range(0, len(self.children)): + for index in range(0, len(self.children)): self.children[actions[index][0]].u_value = actions[index][1] / (1 + self.children[actions[index][0]].nVisits) def backUp(self, value): @@ -96,54 +91,54 @@ def backUp(self, value): """ return value / len(self.children) - def toValue(self): """Return action value Q plus bonus u(P) """ return self.Q_value + self.u_value + class MCTS(object): """Monte Carlo tree search, takes an input of game state, value network function, policy network function, rollout policy function, outputs an action after lookahead search is complete. """ - def __init__(self, state, value_network, policy_network, rollout_policy): - + def __init__(self, state, value_network, policy_network, rollout_policy): + self.state = state self.treenode = TreeNode() self._value = value_network self._policy = policy_network self._rollout = rollout_policy - + def DFS(self, nDepth, treenode, state): - """Monte Carlo tree search over a certain depth per simulation, at the end of simulation, + """Monte Carlo tree search over a certain depth per simulation, at the end of simulation, the action values and visits of counts of traversed treenode are updated. Keyword arguments: Initial GameState object - Initial TreeNode object + Initial TreeNode object Search Depth Return: TreeNode object with updated statistics(visit count N, action value Q) """ - + visited = [] visited.insert(0, (state, treenode)) - - for index in range(0, nDepth-1): + + for index in range(0, nDepth - 1): actions = self.priorProb(state) treenode.expansion(actions) treenode.updateU_value(actions) - treenode, action = treenode.selection() + treenode, action = treenode.selection() state.do_move(action) - visited.insert(0, (state, treenode)) - - for index in range(0, len(visited)): - if(visited[index][1].isLeaf() == True): + visited.insert(0, (state, treenode)) + + for index in range(0, len(visited)): + if visited[index][1].isLeaf(): value = self.leafEvaluation(visited[index][0]) - else: + else: value = visited[index][1].backUp(value) - + visited[-1][1].updateQ_value(value) visited[-1][1].updateVisits() return visited[-1][1] @@ -159,9 +154,9 @@ def leafEvaluation(self, state): value """ z = self._rollout(state) - v = self._value(state) - return (1-LAMBDA) * v + LAMBDA * z - + v = self._value(state) + return (1 - LAMBDA) * v + LAMBDA * z + def priorProb(self, state): """Get a list of (action, probability) pairs according to policy network function outputs @@ -170,16 +165,15 @@ def priorProb(self, state): Return: list of tuples ((x,y), probability) - + """ actions = self._policy(state) - return actions - + return actions def getMove(self, nDepth, nSimulations): - - """After running simulations for a certain number of times, when the search is complete, an action is selected + + """After running simulations for a certain number of times, when the search is complete, an action is selected from root state Keyword arguments: @@ -192,8 +186,8 @@ def getMove(self, nDepth, nSimulations): actions = self.priorProb(self.state) self.treenode.expansion(actions) - for n in range(0, nSimulations): - + for n in range(0, nSimulations): + self.treenode.updateU_value(actions) treenode, action = self.treenode.selection() state = self.state.copy() @@ -201,14 +195,10 @@ def getMove(self, nDepth, nSimulations): treenode = self.DFS(nDepth, treenode, state) self.treenode.children[action] = treenode - self.treenode.updateU_value(actions) + self.treenode.updateU_value(actions) treenode, action = self.treenode.selection() return action -class ParallelMCTS(MCTS): - pass - - - - +class ParallelMCTS(MCTS): + pass diff --git a/tests/test_mcts.py b/tests/test_mcts.py index 4a1a20cb9..20cf4a82f 100644 --- a/tests/test_mcts.py +++ b/tests/test_mcts.py @@ -2,57 +2,53 @@ from AlphaGo.mcts import MCTS from AlphaGo.mcts import TreeNode import random -import numpy as np import unittest class TestMCTS(unittest.TestCase): - - def setUp(self): - self.s = GameState() - self.mcts = MCTS(self.s, value_network, policy_network, rollout_policy) - self.treenode = TreeNode() - - - def test_treenode_selection(self): - actions = self.mcts.priorProb(self.s) - self.treenode.expansion(actions) - self.treenode.updateU_value(actions) - selectednode, selectedaction = self.treenode.selection() - self.assertEqual(max(actions, key = lambda x:x[1])[1], selectednode.toValue(), 'incorrect node selected') - self.assertEqual(max(actions, key = lambda x:x[1])[0], selectedaction, 'incorrect action selected') - - def test_mcts_DFS(self): - treenode = self.mcts.DFS(3, self.treenode, self.s) - self.assertEqual(1, treenode.nVisits, 'incorrect visit count') - - - def test_mcts_getMove(self): - action = self.mcts.getMove(3, 10) - self.assertIsNotNone(action,'no output action') - action = self.mcts.getMove(4, 5) - self.assertIsNotNone(action,'no output action') - action = self.mcts.getMove(6, 8) - self.assertIsNotNone(action,'no output action') + + def setUp(self): + self.s = GameState() + self.mcts = MCTS(self.s, value_network, policy_network, rollout_policy) + self.treenode = TreeNode() + + def test_treenode_selection(self): + actions = self.mcts.priorProb(self.s) + self.treenode.expansion(actions) + self.treenode.updateU_value(actions) + selectednode, selectedaction = self.treenode.selection() + self.assertEqual(max(actions, key=lambda x: x[1])[1], selectednode.toValue(), 'incorrect node selected') + self.assertEqual(max(actions, key=lambda x: x[1])[0], selectedaction, 'incorrect action selected') + + def test_mcts_DFS(self): + treenode = self.mcts.DFS(3, self.treenode, self.s) + self.assertEqual(1, treenode.nVisits, 'incorrect visit count') + + def test_mcts_getMove(self): + action = self.mcts.getMove(3, 10) + self.assertIsNotNone(action, 'no output action') + action = self.mcts.getMove(4, 5) + self.assertIsNotNone(action, 'no output action') + action = self.mcts.getMove(6, 8) + self.assertIsNotNone(action, 'no output action') def policy_network(state): - s = GameState() - moves = s.get_legal_moves() - actions = [] - for move in moves: - actions.append((move, random.uniform(0, 1))) - return actions - + s = GameState() + moves = s.get_legal_moves() + actions = [] + for move in moves: + actions.append((move, random.uniform(0, 1))) + return actions + + def value_network(state): + return 0.5 - return 0.5 def rollout_policy(state): + return 1 - return 1 if __name__ == '__main__': - unittest.main() - - + unittest.main() From 420af6e3ceeb3d9bd7cb7fea7edd9529c2ce0baf Mon Sep 17 00:00:00 2001 From: wrongu Date: Mon, 18 Apr 2016 12:47:14 -0400 Subject: [PATCH 025/191] cleaning up mcts. rollout works the same as policy now. getMove --> get_move(state) it appears to be working. --- AlphaGo/go.py | 36 +++++++- AlphaGo/mcts.py | 225 ++++++++++++++++++++------------------------- tests/test_mcts.py | 53 +++++------ 3 files changed, 158 insertions(+), 156 deletions(-) diff --git a/AlphaGo/go.py b/AlphaGo/go.py index 8ceed95bc..d25a76b66 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -10,16 +10,19 @@ class GameState(object): """State of a game of Go and some basic functions to interact with it """ - def __init__(self, size=19): + def __init__(self, size=19, komi=7.5): self.board = np.zeros((size, size)) self.board.fill(EMPTY) self.size = size self.turns_played = 0 self.current_player = BLACK self.ko = None + self.komi = komi self.history = [] self.num_black_prisoners = 0 self.num_white_prisoners = 0 + self.passes_white = 0 + self.passes_black = 0 # `self.liberty_sets` is a 2D array with the same indexes as `board` # each entry points to a set of tuples - the liberties of a stone's # connected block. By caching liberties in this way, we can directly @@ -260,6 +263,32 @@ def get_legal_moves(self, include_eyes=True): moves.append((x, y)) return moves + def get_winner(self): + """Calculate score of board state and return player ID (1, -1, or 0 for tie) + corresponding to winner. Uses 'Area scoring'. + """ + # Count number of positions filled by each player, plus 1 for each eye-ish space owned + score_white = np.sum(self.board == WHITE) + score_black = np.sum(self.board == BLACK) + empties = zip(*np.where(self.board == EMPTY)) + for empty in empties: + # Check that all surrounding points are of one color + if self.is_eyeish(empty, BLACK): + score_black += 1 + elif self.is_eyeish(empty, WHITE): + score_white += 1 + score_white += self.komi + score_white -= self.passes_white + score_black -= self.passes_black + if score_black > score_white: + winner = BLACK + elif score_white > score_black: + winner = WHITE + else: + # Tie + winner = 0 + return winner + def do_move(self, action, color=None): """Play stone at action=(x,y). If color is not specified, current_player is used @@ -296,6 +325,11 @@ def do_move(self, action, color=None): if would_recapture and recapture_size_is_1: # note: (nx,ny) is the stone that was captured self.ko = (nx, ny) + else: + if color == BLACK: + self.passes_black += 1 + if color == WHITE: + self.passes_white += 1 # next turn self.current_player = -color self.turns_played += 1 diff --git a/AlphaGo/mcts.py b/AlphaGo/mcts.py index 9a781c4e7..72788d89c 100644 --- a/AlphaGo/mcts.py +++ b/AlphaGo/mcts.py @@ -1,28 +1,29 @@ - -LAMBDA = 0.5 +import numpy as np class TreeNode(object): - """Tree Representation of MCTS that covers Selection, Expansion, Evaluation, Backup + """Tree Representation of MCTS that covers Selection, Expansion, Evaluation, and backUp (aka 'update()') """ - def __init__(self): + def __init__(self, parent, prior_p): + self.parent = parent self.nVisits = 0 self.Q_value = 0 - self.u_value = 0 + self.u_value = prior_p self.children = {} + self.P = prior_p def expansion(self, actions): - """Expand subtree --a dictionary with a tuple of (x,y) position as keys, TreeNode object as values + """Expand subtree - a dictionary with a tuple of (x,y) position as keys, TreeNode object as values Keyword arguments: - Output from policy function-- a list of tuples of (x, y) position and prior probability + Output from policy function - a list of tuples of (x, y) position and prior probability - Return: + Returns: None - """ - for action in actions: - self.children[action[0]] = TreeNode() + for action, prob in actions: + if action not in self.children: + self.children[action] = TreeNode(self, prob) def selection(self): """Select among subtree to get the position that gives maximum action value Q plus bonus u(P) @@ -30,66 +31,32 @@ def selection(self): Keyword arguments: None. - Return: - action -- a tuple of (x, y) - treenode object - - + Returns: + a tuple of (action, next_node) """ - selectednode = self.children.values()[0] - selectedaction = self.children.keys()[0] - maxValue = selectednode.toValue() - - for child in self.children.items(): - if child[1].toValue() > maxValue: - selectednode = child[1] - maxValue = child[1].toValue() - selectedaction = child[0] - return selectednode, selectedaction + return max(self.children.iteritems(), key=lambda (a, n): n.toValue()) def isLeaf(self): - """Check if leaf state is reached + """Check if leaf node (i.e. no nodes below this have been expanded) """ - return self.children == {} - def updateVisits(self): - """Update the count of visit times - """ - self.nVisits += 1 - - def updateQ_value(self, value): - """Update the action value Q - """ - self.Q_value = (self.Q_value * self.nVisits + value) / (self.nVisits + 1) - - def updateU_value(self, actions): - - """Update the bonus value u(P)--proportional to the prior probability but decays with the number of visits to encourage exploration + def update(self, leaf_value, c_puct): + """Update node values from leaf evaluation - Keyword arguments: - Output from policy function-- a list of tuples of (x, y) position and prior probability + Arguments: + value of traversed subtree evaluation - Return: + Returns: None - """ - - for index in range(0, len(self.children)): - self.children[actions[index][0]].u_value = actions[index][1] / (1 + self.children[actions[index][0]].nVisits) - - def backUp(self, value): - - """Track the mean value of evaluations in the subtrees - - Keyword arguments: - value of traversed subtree evaluation each simulation - - Return: - Mean value - - """ - return value / len(self.children) + # count visit + self.nVisits += 1 + # update Q + mean_V = self.Q_value * (self.nVisits - 1) + self.Q_value = (mean_V + leaf_value) / self.nVisits + # update u (note that u is not normalized to be a distribution) + self.u_value = c_puct * self.P * np.sqrt(self.parent.nVisits) / (1 + self.nVisits) def toValue(self): """Return action value Q plus bonus u(P) @@ -98,18 +65,30 @@ def toValue(self): class MCTS(object): - """Monte Carlo tree search, takes an input of game state, value network function, policy network function, rollout policy function, outputs an action after lookahead search is complete. - """ + """Monte Carlo tree search, takes an input of game state, value network function, policy network function, + rollout policy function. get_move outputs an action after lookahead search is complete. - def __init__(self, state, value_network, policy_network, rollout_policy): + The value function should take in a state and output a number in [-1, 1] + The policy and rollout functions should take in a state and output a list of (action,prob) tuples where + action is an (x,y) tuple - self.state = state - self.treenode = TreeNode() + lmbda and c_puct are hyperparameters. 0 <= lmbda <= 1 controls the relative weight of the value network and fast + rollouts in determining the value of a leaf node. 0 < c_puct < inf controls how quickly exploration converges + to the maximum-value policy + """ + + def __init__(self, state, value_network, policy_network, rollout_policy, lmbda=0.5, c_puct=5, rollout_limit=500, playout_depth=20, n_search=10000): + self.root = TreeNode(None, 1.0) self._value = value_network self._policy = policy_network self._rollout = rollout_policy + self._lmbda = lmbda + self._c_puct = c_puct + self._rollout_limit = rollout_limit + self._L = playout_depth + self._n_search = n_search - def DFS(self, nDepth, treenode, state): + def _DFS(self, nDepth, treenode, state): """Monte Carlo tree search over a certain depth per simulation, at the end of simulation, the action values and visits of counts of traversed treenode are updated. @@ -118,86 +97,78 @@ def DFS(self, nDepth, treenode, state): Initial TreeNode object Search Depth - Return: - TreeNode object with updated statistics(visit count N, action value Q) + Returns: + None """ - visited = [] - visited.insert(0, (state, treenode)) + visited = [None] * nDepth - for index in range(0, nDepth - 1): - actions = self.priorProb(state) - treenode.expansion(actions) - treenode.updateU_value(actions) - treenode, action = treenode.selection() + # Playout to nDepth moves using the full policy network + for index in xrange(nDepth): + action_probs = self._policy(state) + # check for end of game + if len(action_probs) == 0: + break + treenode.expansion(action_probs) + action, treenode = treenode.selection() state.do_move(action) - visited.insert(0, (state, treenode)) - - for index in range(0, len(visited)): - if visited[index][1].isLeaf(): - value = self.leafEvaluation(visited[index][0]) - else: - value = visited[index][1].backUp(value) - - visited[-1][1].updateQ_value(value) - visited[-1][1].updateVisits() - return visited[-1][1] - - def leafEvaluation(self, state): - """Calculate leaf evaluation, a weighted average using a mixing parameter LAMBDA, combined outcome z - of fast rollout policy function and value network function output v. - - Keyword arguments: - GameState object + visited[index] = treenode - Return: - value - """ - z = self._rollout(state) + # leaf evaluation v = self._value(state) - return (1 - LAMBDA) * v + LAMBDA * z - - def priorProb(self, state): - """Get a list of (action, probability) pairs according to policy network function outputs + z = self._evaluate_rollout(state, self._rollout_limit) + leaf_value = (1 - self._lmbda) * v + self._lmbda * z - Keyword arguments: - GameState object - - Return: - list of tuples ((x,y), probability) + # update value and visit count of nodes in this traversal + # Note: it is important that this happens from the root downward + # so that 'parent' visit counts are correct + for node in visited: + node.update(leaf_value, self._c_puct) + def _evaluate_rollout(self, state, limit): + """Use the rollout policy to play until the end of the game, get the winner (or 0 if tie) """ - actions = self._policy(state) - - return actions - - def getMove(self, nDepth, nSimulations): - + for i in xrange(limit): + action_probs = self._rollout(state) + if len(action_probs) == 0: + break + max_action = max(action_probs, key=lambda (a, p): p)[0] + state.do_move(max_action) + else: + # if no break from the loop + print "WARNING: rollout reached move limit" + return state.get_winner() + + def get_move(self, state): """After running simulations for a certain number of times, when the search is complete, an action is selected from root state Keyword arguments: Number of Simulations - Return: + Returns: action -- a tuple of (x, y) """ + action_probs = self._policy(state) + self.root.expansion(action_probs) - actions = self.priorProb(self.state) - self.treenode.expansion(actions) + for n in xrange(0, self._n_search): + state_copy = state.copy() + self._DFS(self._L, self.root, state_copy) - for n in range(0, nSimulations): + # chosen action is the *most visited child*, not the highest-value + # (note that they are the same as self._n_search gets large) + return max(self.root.children.iteritems(), key=lambda (a, n): n.nVisits)[0] - self.treenode.updateU_value(actions) - treenode, action = self.treenode.selection() - state = self.state.copy() - state.do_move(action) - treenode = self.DFS(nDepth, treenode, state) - self.treenode.children[action] = treenode - - self.treenode.updateU_value(actions) - treenode, action = self.treenode.selection() - return action + def update_with_move(self, last_move): + """step forward in the tree and discard everything that isn't still reachable + """ + if last_move in self.root.children: + self.root = self.root.children[last_move] + self.root.parent = None + # siblings of root will be garbage-collected because they are no longer reachable + else: + self.root = TreeNode(None, 1.0) class ParallelMCTS(MCTS): diff --git a/tests/test_mcts.py b/tests/test_mcts.py index 20cf4a82f..1e5e24ede 100644 --- a/tests/test_mcts.py +++ b/tests/test_mcts.py @@ -1,53 +1,50 @@ from AlphaGo.go import GameState -from AlphaGo.mcts import MCTS -from AlphaGo.mcts import TreeNode -import random +from AlphaGo.mcts import MCTS, TreeNode +import numpy as np import unittest class TestMCTS(unittest.TestCase): def setUp(self): - self.s = GameState() - self.mcts = MCTS(self.s, value_network, policy_network, rollout_policy) - self.treenode = TreeNode() + self.gs = GameState() + self.mcts = MCTS(self.gs, value_network, policy_network, rollout_policy, n_search=2) def test_treenode_selection(self): - actions = self.mcts.priorProb(self.s) - self.treenode.expansion(actions) - self.treenode.updateU_value(actions) - selectednode, selectedaction = self.treenode.selection() - self.assertEqual(max(actions, key=lambda x: x[1])[1], selectednode.toValue(), 'incorrect node selected') - self.assertEqual(max(actions, key=lambda x: x[1])[0], selectedaction, 'incorrect action selected') + treenode = TreeNode(None, 1.0) + treenode.expansion(policy_network(self.gs)) + action, node = treenode.selection() + self.assertEqual(action, (18, 18)) # according to the policy below + self.assertIsNotNone(node) def test_mcts_DFS(self): - treenode = self.mcts.DFS(3, self.treenode, self.s) - self.assertEqual(1, treenode.nVisits, 'incorrect visit count') + treenode = TreeNode(None, 1.0) + self.mcts._DFS(8, treenode, self.gs.copy()) + self.assertEqual(1, treenode.children[(18, 18)].nVisits, 'DFS visits incorrect') def test_mcts_getMove(self): - action = self.mcts.getMove(3, 10) - self.assertIsNotNone(action, 'no output action') - action = self.mcts.getMove(4, 5) - self.assertIsNotNone(action, 'no output action') - action = self.mcts.getMove(6, 8) - self.assertIsNotNone(action, 'no output action') + move = self.mcts.get_move(self.gs) + self.mcts.update_with_move(move) + # success if no errors def policy_network(state): - s = GameState() - moves = s.get_legal_moves() - actions = [] - for move in moves: - actions.append((move, random.uniform(0, 1))) - return actions + moves = state.get_legal_moves(include_eyes=False) + # 'random' distribution over positions that is smallest + # at (0,0) and largest at (18,18) + probs = np.arange(361, dtype=np.float) + probs = probs / probs.sum() + return zip(moves, probs) def value_network(state): - return 0.5 + # it's not very confident + return 0.0 def rollout_policy(state): - return 1 + # just another policy network + return policy_network(state) if __name__ == '__main__': From f27d94a7b73d615dc098f810747dbfc5ddb07cf3 Mon Sep 17 00:00:00 2001 From: wrongu Date: Tue, 19 Apr 2016 12:50:14 -0400 Subject: [PATCH 026/191] preprocessing functions use correct theano dims closes #88 --- AlphaGo/preprocessing/preprocessing.py | 55 +++++++++++--------------- 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/AlphaGo/preprocessing/preprocessing.py b/AlphaGo/preprocessing/preprocessing.py index 3500e3c71..dca883d62 100644 --- a/AlphaGo/preprocessing/preprocessing.py +++ b/AlphaGo/preprocessing/preprocessing.py @@ -10,10 +10,10 @@ def get_board(state): """A feature encoding WHITE BLACK and EMPTY on separate planes, but plane 0 always refers to the current player and plane 1 to the opponent """ - planes = np.zeros((state.size, state.size, 3)) - planes[:, :, 0] = state.board == state.current_player # own stone - planes[:, :, 1] = state.board == -state.current_player # opponent stone - planes[:, :, 2] = state.board == go.EMPTY # empty space + planes = np.zeros((3, state.size, state.size)) + planes[0, :, :] = state.board == state.current_player # own stone + planes[1, :, :] = state.board == -state.current_player # opponent stone + planes[2, :, :] = state.board == go.EMPTY # empty space return planes @@ -24,7 +24,7 @@ def get_turns_since(state, maximum=8): - the [maximum-1] plane is used for any stone with age greater than or equal to maximum - EMPTY locations are all-zero features """ - planes = np.zeros((state.size, state.size, maximum)) + planes = np.zeros((maximum, state.size, state.size)) depth = 0 # loop backwards over history and place a 1 in plane 0 # for the most recent move, a 1 in plane 1 for two moves ago, etc.. @@ -34,8 +34,8 @@ def get_turns_since(state, maximum=8): if state.board[move] != go.EMPTY: (x, y) = move # check that a newer move isn't occupying (x,y) - if np.sum(planes[x, y, :]) == 0: - planes[x, y, depth] = 1 + if np.sum(planes[:, x, y]) == 0: + planes[depth, x, y] = 1 # increment depth if there are more planes available # (the last plane serves as the "maximum-1 or more" feature) if depth < maximum - 1: @@ -52,12 +52,12 @@ def get_liberties(state, maximum=8): - the [maximum-1] plane is used for any stone with liberties greater than or equal to maximum - EMPTY locations are all-zero features """ - planes = np.zeros((state.size, state.size, maximum)) + planes = np.zeros((maximum, state.size, state.size)) for i in range(maximum): # single liberties in plane zero (groups won't have zero), double liberties in plane one, etc - planes[state.liberty_counts == i + 1, i] = 1 + planes[i, state.liberty_counts == i + 1] = 1 # the "maximum-or-more" case on the backmost plane - planes[state.liberty_counts >= maximum, maximum - 1] = 1 + planes[maximum - 1, state.liberty_counts >= maximum] = 1 return planes @@ -71,7 +71,7 @@ def get_capture_size(state, maximum=8): - the 0th plane is used for legal moves that would not result in capture - illegal move locations are all-zero features """ - planes = np.zeros((state.size, state.size, maximum)) + planes = np.zeros((maximum, state.size, state.size)) for (x, y) in state.get_legal_moves(): # multiple disconnected groups may be captured. hence we loop over # groups and count sizes if captured. @@ -84,14 +84,14 @@ def get_capture_size(state, maximum=8): (gx, gy) = next(iter(neighbor_group)) if (state.liberty_counts[gx][gy] == 1) and (state.board[gx, gy] != state.current_player): n_captured += len(state.group_sets[gx][gy]) - planes[x, y, min(n_captured, maximum - 1)] = 1 + planes[min(n_captured, maximum - 1), x, y] = 1 return planes def get_self_atari_size(state, maximum=8): """A feature encoding the size of the own-stone group that is put into atari by playing at a location """ - planes = np.zeros((state.size, state.size, maximum)) + planes = np.zeros((maximum, state.size, state.size)) for (x, y) in state.get_legal_moves(): # make a copy of the liberty/group sets at (x,y) so we can manipulate them @@ -111,7 +111,7 @@ def get_self_atari_size(state, maximum=8): if len(lib_set_after) == 1: group_size = len(group_set_after) # 0th plane used for size=1, so group_size-1 is the index - planes[x, y, min(group_size - 1, maximum - 1)] = 1 + planes[min(group_size - 1, maximum - 1), x, y] = 1 return planes @@ -124,7 +124,7 @@ def get_liberties_after(state, maximum=8): - the [maximum-1] plane is used for any stone with liberties greater than or equal to maximum - illegal move locations are all-zero features """ - feature = np.zeros((state.size, state.size, maximum)) + planes = np.zeros((maximum, state.size, state.size)) # note - left as all zeros if not a legal move for (x, y) in state.get_legal_moves(): # make a copy of the set of liberties at (x,y) so we can add to it @@ -140,8 +140,8 @@ def get_liberties_after(state, maximum=8): # since it's clearly not a liberty after playing there if (x, y) in lib_set_after: lib_set_after.remove((x, y)) - feature[x, y, min(maximum - 1, len(lib_set_after) - 1)] = 1 - return feature + planes[min(maximum - 1, len(lib_set_after) - 1), x, y] = 1 + return planes def get_ladder_capture(state): @@ -155,9 +155,9 @@ def get_ladder_escape(state): def get_sensibleness(state): """A move is 'sensible' if it is legal and if it does not fill the current_player's own eye """ - feature = np.zeros((state.size, state.size)) + feature = np.zeros((1, state.size, state.size)) for (x, y) in state.get_legal_moves(include_eyes=False): - feature[x, y] = 1 + feature[0, x, y] = 1 return feature # named features and their sizes are defined here @@ -168,7 +168,7 @@ def get_sensibleness(state): }, "ones": { "size": 1, - "function": lambda state: np.ones((state.size, state.size)) + "function": lambda state: np.ones((1, state.size, state.size)) }, "turns_since": { "size": 8, @@ -204,7 +204,7 @@ def get_sensibleness(state): }, "zeros": { "size": 1, - "function": lambda state: np.zeros((state.size, state.size)) + "function": lambda state: np.zeros((1, state.size, state.size)) } } @@ -240,17 +240,6 @@ def state_to_tensor(self, state): """ feat_tensors = [proc(state) for proc in self.processors] - # TODO - make features smarter so they don't have to be transposed and reshaped, - # just stacked, and this loop could be avoided - for i, feat in enumerate(feat_tensors): - # reshape (width,height,depth) to (depth,width,height) - if feat.ndim == 2: - (w, h) = feat.shape - d = 1 - else: - (w, h, d) = feat.shape - feat_tensors[i] = feat.reshape((w, h, d)).transpose((2, 0, 1)) - - # concatenate along feature dimension then add in a singleton 'batch' dimensino + # concatenate along feature dimension then add in a singleton 'batch' dimension f, s = self.output_dim, state.size return np.concatenate(feat_tensors).reshape((1, f, s, s)) From a8f82b00ad707cf7b8bbeaef1b77ff729a5142d2 Mon Sep 17 00:00:00 2001 From: wrongu Date: Tue, 19 Apr 2016 13:04:21 -0400 Subject: [PATCH 027/191] self-atari feature had a typo. fixes #83 --- AlphaGo/preprocessing/preprocessing.py | 2 +- tests/test_preprocessing.py | 45 ++++++++++++++++++++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/AlphaGo/preprocessing/preprocessing.py b/AlphaGo/preprocessing/preprocessing.py index dca883d62..5685d8253 100644 --- a/AlphaGo/preprocessing/preprocessing.py +++ b/AlphaGo/preprocessing/preprocessing.py @@ -104,7 +104,7 @@ def get_self_atari_size(state, maximum=8): (gx, gy) = next(iter(neighbor_group)) if state.board[gx, gy] == state.current_player: lib_set_after |= state.liberty_sets[gx][gy] - group_set_after |= state.liberty_sets[gx][gy] + group_set_after |= state.group_sets[gx][gy] if (x, y) in lib_set_after: lib_set_after.remove((x, y)) # check if this move resulted in atari diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index a96bd4cad..55c9c480b 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -41,6 +41,40 @@ def simple_board(): return gs +def self_atari_board(): + # another tiny board for testing self-atari specifically. + # positions marked with 'a' are self-atari for black + # + # X + # 0 1 2 3 4 5 6 + # a W . . . W B 0 + # . . . . . . . 1 + # . . . . . . . 2 + # Y . . W . W . . 3 + # . W B a B W . 4 + # . . W W W . . 5 + # . . . . . . . 6 + # + # current_player = black + gs = go.GameState(size=7) + + gs.do_move((2, 4), go.BLACK) + gs.do_move((4, 4), go.BLACK) + gs.do_move((6, 0), go.BLACK) + + gs.do_move((1, 0), go.WHITE) + gs.do_move((5, 0), go.WHITE) + gs.do_move((2, 3), go.WHITE) + gs.do_move((4, 3), go.WHITE) + gs.do_move((1, 4), go.WHITE) + gs.do_move((5, 4), go.WHITE) + gs.do_move((2, 5), go.WHITE) + gs.do_move((3, 5), go.WHITE) + gs.do_move((4, 5), go.WHITE) + + return gs + + class TestPreprocessingFeatures(unittest.TestCase): """Test the functions in preprocessing.py @@ -147,12 +181,17 @@ def test_get_capture_size(self): "bad expectation: capturing %d stones" % i) def test_get_self_atari_size(self): - # TODO - at the moment there is no imminent self-atari for white - gs = simple_board() + gs = self_atari_board() pp = Preprocess(["self_atari_size"]) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) - self.assertTrue(np.all(feature == np.zeros((gs.size, gs.size, 8)))) + one_hot_self_atari = np.zeros((gs.size, gs.size, 8)) + # self atari of size 1 at position 0,0 + one_hot_self_atari[0, 0, 0] = 1 + # self atari of size 3 at position 3,4 + one_hot_self_atari[3, 4, 2] = 1 + + self.assertTrue(np.all(feature == one_hot_self_atari)) def test_get_liberties_after(self): gs = simple_board() From 22565a7901a59cb7eff02263c78594a4ff5f90f5 Mon Sep 17 00:00:00 2001 From: wrongu Date: Wed, 20 Apr 2016 10:36:20 -0400 Subject: [PATCH 028/191] readme more clearly points to wiki --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 568961ef1..4f165e865 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,10 @@ This project is a replication/reference implementation of DeepMind's 2016 Nature [![Build Status](https://travis-ci.org/Rochester-NRT/AlphaGo.svg?branch=develop)](https://travis-ci.org/Rochester-NRT/AlphaGo) [![Gitter](https://badges.gitter.im/Rochester-NRT/AlphaGo.svg)](https://gitter.im/Rochester-NRT/AlphaGo?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) +# Documentation + +See the [project wiki](https://github.com/Rochester-NRT/AlphaGo/wiki). + # Current project status _This is not yet a full implementation of AlphaGo_. Development is being carried out on the `develop` branch. From abb96498448f5a1b25977581745f7141c73b193e Mon Sep 17 00:00:00 2001 From: wrongu Date: Wed, 20 Apr 2016 10:45:39 -0400 Subject: [PATCH 029/191] Further readme updates in accordance with renamed repository --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4f165e865..8be2d2399 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ -# AlphaGo Replication +# RocAlphaGo -This project is a replication/reference implementation of DeepMind's 2016 Nature publication, "Mastering the game of Go with deep neural networks and tree search," details of which can be found [on their website](http://deepmind.com/alpha-go.html). This implementation uses Python and Keras - a decision to prioritize code clarity, at least in the early stages. +(Previously known just as "AlphaGo," renamed to clarify that we are not affiliated with DeepMind) + +This project is a student-led replication/reference implementation of DeepMind's 2016 Nature publication, "Mastering the game of Go with deep neural networks and tree search," details of which can be found [on their website](http://deepmind.com/alpha-go.html). This implementation uses Python and Keras - a decision to prioritize code clarity, at least in the early stages. [![Build Status](https://travis-ci.org/Rochester-NRT/AlphaGo.svg?branch=develop)](https://travis-ci.org/Rochester-NRT/AlphaGo) [![Gitter](https://badges.gitter.im/Rochester-NRT/AlphaGo.svg)](https://gitter.im/Rochester-NRT/AlphaGo?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) @@ -13,11 +15,9 @@ See the [project wiki](https://github.com/Rochester-NRT/AlphaGo/wiki). _This is not yet a full implementation of AlphaGo_. Development is being carried out on the `develop` branch. -We are still early in development. There are quite a few pieces to AlphaGo that can be written in parallel. We are currently focusing our efforts on the supervised and "self-play" parts of the training pipeline because the training itself may take a very long time.. +Selected data (i.e. trained models) are released in our [data repository](http://github.com/Rochester-NRT/RocAlphaGo.data). -> Updates were applied asynchronously on 50 GPUs... Training took around 3 weeks for 340 million training steps -> -> -Silver et al. (page 8) +This project has primarily focused on the neural network training aspect of DeepMind's AlphaGo. We also have a simple single-threaded implementation of their tree search algorithm, though it is not fast enough to be competitive yet. See the wiki page on the [training pipeline](https://github.com/Rochester-NRT/AlphaGo/wiki/Neural-Networks-and-Training) for information on how to run the training commands. From 8c5f4afd741223ec138801922ce6162dec9fb140 Mon Sep 17 00:00:00 2001 From: Tyler Trine Date: Sat, 23 Apr 2016 16:08:35 -0400 Subject: [PATCH 030/191] work done on RL policy trainer --- .../training/reinforcement_policy_trainer.py | 71 +++++++------------ 1 file changed, 25 insertions(+), 46 deletions(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index eba26064b..46bfd7bf3 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -1,18 +1,12 @@ -import argparse -from keras.callbacks import ModelCheckpoint +import os, argparse, json +import numpy as np +from keras.optimizers import SGD from AlphaGo.ai import ProbabilisticPolicyPlayer import AlphaGo.go as go from AlphaGo.go import GameState from AlphaGo.models.policy import CNNPolicy from AlphaGo.preprocessing.preprocessing import Preprocess from AlphaGo.util import flatten_idx -from keras.optimizers import SGD -import numpy as np -# np.set_printoptions(linewidth=160) -import os -# -# from ipdb import set_trace as BP - def make_training_pairs(player, opp, features, mini_batch_size): """Make training pairs for batch of matches, utilizing player.get_moves (parallel form of @@ -79,7 +73,6 @@ def do_move(states, states_prev, moves, X_list, y_list, player_color): y_list[i] = np.vstack(y_list[i]) return X_list, y_list, winners - def train_batch(player, X_list, y_list, winners, lr): """Given the outcomes of a mini-batch of play against a fixed opponent, update the weights with reinforcement learning in place. @@ -102,7 +95,6 @@ def train_batch(player, X_list, y_list, winners, lr): player.policy.model.optimizer.lr.set_value(lr) player.policy.model.fit(X, y, nb_epoch=1, batch_size=len(X)) - def run(player, args, opponents, features, model_folder): # Set SGD and compile sgd = SGD(lr=args.learning_rate) @@ -131,47 +123,34 @@ def run(player, args, opponents, features, model_folder): 'to improve given policy network. Second phase of pipeline.') parser.add_argument("initial_weights", help="Path to file with weights to start from.") parser.add_argument("initial_json", help="Path to file with initial network params.") - parser.add_argument("--model_folder", help="Path to folder where the model " - "params will be saved after each epoch. Default: None", default=None) + parser.add_argument("model_folder", help="Path to folder where the model" + " params will be saved after each epoch.") parser.add_argument( - "--learning_rate", help="Keras learning rate (Default: .03)", type=float, - default=.03) + "--learning_rate", help="Keras learning rate (Default: .03)", + type=float, default=.03) parser.add_argument( "--save_every", help="Save policy every n mini-batches (Default: 500)", - type=int, default=500) + type=int, default=500) parser.add_argument( "--game_batch_size", help="Number of games per mini-batch (Default: 20)", - type=int, default=20) + type=int, default=20) parser.add_argument( "--iterations", help="Number of training iterations (i.e. mini-batch) " - "(Default: 20)", - type=int, default=20) - # Baseline function (TODO) default lambda state: 0 (receives either file - # paths to JSON and weights or None, in which case it uses default baseline 0) + "(Default: 20)", type=int, default=20) args = parser.parse_args() - # Load policy from file - # policy = model_from_json(open(args.initial_json).read()) - # policy.load_weights(args.initial_weights) - # player = ProbabilisticPolicyPlayer(model) - ############################################# - # Just for now, while we get the model directories set up... - features = ["board", "ones", "turns_since", "liberties", "capture_size", - "self_atari_size", "liberties_after", - "sensibleness", "zeros"] - policy = CNNPolicy(features) - player = ProbabilisticPolicyPlayer(policy) - ############################################# - # Load opponent pool - opponents = [] - if args.model_folder is not None: - # TODO - opponent_files = next(os.walk(args.model_folder))[2] - if len(args.model_folder) == 0: - # No opponents yet, so play against self - opponents = [player] - else: - # TODO - pass - else: + + # Set initial conditions + policy = CNNPolicy.load_model(args.initial_json) + policy.model.load_weights(args.initial_weights) + player = ProbabilisticPolicyPlayer(policy.model) + + opponent_files = os.listdir(args.model_folder) + if len(opponent_files) == 0: # Start new RL training session opponents = [player] - opponents = run(player, args, opponents, features) + else: # Resume existing RL training session + opponents = opponent_files + + with open(args.initial_json) as j: + features = json.load(j).feature_list + + run(player, args, opponents, features, args.model_folder) From d35b907081ccc3e9c225960c765a988578047dcc Mon Sep 17 00:00:00 2001 From: wrongu Date: Tue, 26 Apr 2016 08:10:10 -0400 Subject: [PATCH 031/191] whitespace and stylistic changes --- .../training/reinforcement_policy_trainer.py | 33 +++++++++---------- tests/test_policy.py | 5 ++- tox.ini | 2 +- 3 files changed, 18 insertions(+), 22 deletions(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 346b79d0b..323cd3590 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -31,8 +31,7 @@ def make_training_pairs(player, opp, features, mini_batch_size): def do_move(states, states_prev, moves, X_list, y_list, player_color): bsize_flat = bsize * bsize - for st, st_prev, mv, X, y in zip(states, states_prev, moves, X_list, - y_list): + for st, st_prev, mv, X, y in zip(states, states_prev, moves, X_list, y_list): if not st.is_end_of_game: # Only do more moves if not end of game already st.do_move(mv) @@ -61,12 +60,10 @@ def do_move(states, states_prev, moves, X_list, y_list, player_color): # Get moves (batch) moves_black = player1.get_moves(states) # Do moves (black) - states, X_list, y_list = do_move(states, states_prev, moves_black, - X_list, y_list, player_color) + states, X_list, y_list = do_move(states, states_prev, moves_black, X_list, y_list, player_color) # Do moves (white) moves_white = player2.get_moves(states) - states, X_list, y_list = do_move(states, states_prev, moves_white, - X_list, y_list, player_color) + states, X_list, y_list = do_move(states, states_prev, moves_white, X_list, y_list, player_color) # If all games have ended, we're done. Get winners. done = [st.is_end_of_game for st in states] if all(done): @@ -81,18 +78,18 @@ def do_move(states, states_prev, moves, X_list, y_list, player_color): def train_batch(player, X_list, y_list, winners, lr): """Given the outcomes of a mini-batch of play against a fixed opponent, - update the weights with reinforcement learning. + update the weights with reinforcement learning. - Args: - player -- player object with policy weights to be updated - X_list -- List of one-hot encoded states. - y_list -- List of one-hot encoded actions (to pair with X_list). - winners -- List of winners corresponding to each item in - training_pairs_list - lr -- Keras learning rate + Args: + player -- player object with policy weights to be updated + X_list -- List of one-hot encoded states. + y_list -- List of one-hot encoded actions (to pair with X_list). + winners -- List of winners corresponding to each item in + training_pairs_list + lr -- Keras learning rate - Return: - player -- same player, with updated weights. + Return: + player -- same player, with updated weights. """ for X, y, winner in zip(X_list, y_list, winners): @@ -130,7 +127,7 @@ def run(player, args, opponents, features): if __name__ == '__main__': parser = argparse.ArgumentParser(description='Perform reinforcement learning ' - 'to improve given policy network. Second phase of pipeline.') + 'to improve given policy network. Second phase of pipeline.') parser.add_argument("initial_weights", help="Path to file with weights to start from.") parser.add_argument("initial_json", help="Path to file with initial network params.") parser.add_argument("--model_folder", help="Path to folder where the model " @@ -148,7 +145,7 @@ def run(player, args, opponents, features): "--iterations", help="Number of training iterations (i.e. mini-batch) " "(Default: 20)", type=int, default=20) - # Baseline function (TODO) default lambda state: 0 (receives either file + # Baseline function (TODO) default lambda state: 0 (receives either file # paths to JSON and weights or None, in which case it uses default baseline 0) args = parser.parse_args() # Load policy from file diff --git a/tests/test_policy.py b/tests/test_policy.py index 5a6f2ae8d..dab7ff4b8 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -2,7 +2,6 @@ from AlphaGo import go from AlphaGo.go import GameState from AlphaGo.ai import GreedyPolicyPlayer, ProbabilisticPolicyPlayer -import numpy as np import unittest import os @@ -17,8 +16,8 @@ def test_default_policy(self): def test_batch_eval_state(self): policy = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"]) results = policy.batch_eval_state([GameState(), GameState()]) - self.assertEqual(len(results), 2) # one result per GameState - self.assertEqual(len(results[0]), 361) # each one has 361 (move,prob) pairs + self.assertEqual(len(results), 2) # one result per GameState + self.assertEqual(len(results[0]), 361) # each one has 361 (move,prob) pairs def test_output_size(self): policy19 = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"], board=19) diff --git a/tox.ini b/tox.ini index c0c48a2db..bc4f040b4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,3 +1,3 @@ [flake8] -ignore = W191,E266,E501 +ignore = W191,E266,E501,E128 exclude = src/keras,src/theano,interface/opponents/pachi/pachi From e60aea4a4d5750a5323a44495df9396633f1487a Mon Sep 17 00:00:00 2001 From: wrongu Date: Wed, 20 Apr 2016 21:50:02 -0400 Subject: [PATCH 032/191] 'pluggable' branch of wrongu/gtp as a submodule --- .gitmodules | 3 +++ gtp | 1 + 2 files changed, 4 insertions(+) create mode 160000 gtp diff --git a/.gitmodules b/.gitmodules index 11fba82bc..d09dad9a9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "interface/opponents/pachi/pachi"] path = interface/opponents/pachi/pachi url = https://github.com/pasky/pachi +[submodule "gtp"] + path = gtp + url = git@github.com:wrongu/gtp.git diff --git a/gtp b/gtp new file mode 160000 index 000000000..0384bed0f --- /dev/null +++ b/gtp @@ -0,0 +1 @@ +Subproject commit 0384bed0f51b126d5caf14ebf8bdcffc5247783b From cd2fca8dd40ff8fc9d30c8add6fdbdd969c0f296 Mon Sep 17 00:00:00 2001 From: wrongu Date: Thu, 21 Apr 2016 15:55:04 -0400 Subject: [PATCH 033/191] removed interface/server directory --- interface/server/go.html | 46 - interface/server/goServer.py | 269 --- .../server/wgo/basicplayer.commentbox.js | 194 --- interface/server/wgo/basicplayer.component.js | 54 - interface/server/wgo/basicplayer.control.js | 538 ------ interface/server/wgo/basicplayer.infobox.js | 176 -- interface/server/wgo/basicplayer.js | 501 ------ interface/server/wgo/kifu.js | 622 ------- interface/server/wgo/player.editable.js | 150 -- interface/server/wgo/player.js | 691 -------- interface/server/wgo/player.permalink.js | 76 - interface/server/wgo/scoremode.js | 225 --- interface/server/wgo/sgfparser.js | 159 -- interface/server/wgo/wgo.js | 1531 ----------------- interface/server/wgo/wgo.min.js | 1 - interface/server/wgo/wgo.player.css | 695 -------- interface/server/wgo/wgo.player.min.js | 1 - interface/server/wgo/wood1.jpg | Bin 2459 -> 0 bytes 18 files changed, 5929 deletions(-) delete mode 100644 interface/server/go.html delete mode 100644 interface/server/goServer.py delete mode 100644 interface/server/wgo/basicplayer.commentbox.js delete mode 100644 interface/server/wgo/basicplayer.component.js delete mode 100644 interface/server/wgo/basicplayer.control.js delete mode 100644 interface/server/wgo/basicplayer.infobox.js delete mode 100644 interface/server/wgo/basicplayer.js delete mode 100644 interface/server/wgo/kifu.js delete mode 100644 interface/server/wgo/player.editable.js delete mode 100644 interface/server/wgo/player.js delete mode 100644 interface/server/wgo/player.permalink.js delete mode 100644 interface/server/wgo/scoremode.js delete mode 100644 interface/server/wgo/sgfparser.js delete mode 100644 interface/server/wgo/wgo.js delete mode 100644 interface/server/wgo/wgo.min.js delete mode 100644 interface/server/wgo/wgo.player.css delete mode 100644 interface/server/wgo/wgo.player.min.js delete mode 100644 interface/server/wgo/wood1.jpg diff --git a/interface/server/go.html b/interface/server/go.html deleted file mode 100644 index 2366d677d..000000000 --- a/interface/server/go.html +++ /dev/null @@ -1,46 +0,0 @@ - - - Go - - - - - -
- - -
-
- Sorry, your browser doesn't support WGo.js. Download SGF directly. -
- - - - - - \ No newline at end of file diff --git a/interface/server/goServer.py b/interface/server/goServer.py deleted file mode 100644 index 883fb28aa..000000000 --- a/interface/server/goServer.py +++ /dev/null @@ -1,269 +0,0 @@ -import os -import posixpath -import BaseHTTPServer -import urllib -import cgi -import shutil -import mimetypes -import re - -__all__ = ["SimpleHTTPRequestHandler"] - -try: - from cStringIO import StringIO -except ImportError: - from StringIO import StringIO - - -class SimpleHTTPRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler): - - """Simple HTTP request handler with GET/HEAD/POST commands. - - This serves files from the current directory and any of its - subdirectories. The MIME type for files is determined by - calling the .guess_type() method. And can reveive file uploaded - by client. - - The GET/HEAD/POST requests are identical except that the HEAD - request omits the actual contents of the file. - - """ - - server_version = "SimpleHTTPWithUpload/" - - def do_GET(self): - f = self.send_head() - if f: - self.copyfile(f, self.wfile) - f.close() - - def do_HEAD(self): - f = self.send_head() - if f: - f.close() - - def do_POST(self): - r, info = self.deal_post_data() - print r, info, "by: ", self.client_address - - fileName = os.path.basename(info) - fileName = fileName.split('.')[0] - - self.send_response(301) - self.send_header('Location', 'http://localhost:8000/go.html?file=' + fileName) - self.end_headers() - - def deal_post_data(self): - boundary = self.headers.plisttext.split("=")[1] - remainbytes = int(self.headers['content-length']) - line = self.rfile.readline() - remainbytes -= len(line) - if boundary not in line: - return (False, "Content NOT begin with boundary") - line = self.rfile.readline() - remainbytes -= len(line) - fn = re.findall(r'Content-Disposition.*name="file"; filename="(.*)"', line) - if not fn: - return (False, "Can't find out file name...") - path = self.translate_path(self.path) - if not os.path.exists(path): - os.makedirs(path) - fn = os.path.join(path, fn[0]) - line = self.rfile.readline() - remainbytes -= len(line) - line = self.rfile.readline() - remainbytes -= len(line) - try: - out = open(fn, 'wb') - except IOError: - return (False, "Can't create file to write, do you have permission to write?") - - preline = self.rfile.readline() - remainbytes -= len(preline) - while remainbytes > 0: - line = self.rfile.readline() - remainbytes -= len(line) - if boundary in line: - preline = preline[0:-1] - if preline.endswith('\r'): - preline = preline[0:-1] - out.write(preline) - out.close() - return (True, "File '%s' upload success!" % fn) - else: - out.write(preline) - preline = line - return (False, "Unexpect Ends of data.") - - def send_head(self): - """Common code for GET and HEAD commands. - - This sends the response code and MIME headers. - - Return value is either a file object (which has to be copied - to the outputfile by the caller unless the command was HEAD, - and must be closed by the caller under all circumstances), or - None, in which case the caller has nothing further to do. - - """ - path = self.translate_path(self.path) - f = None - if os.path.isdir(path): - if not self.path.endswith('/'): - # redirect browser - doing basically what apache does - self.send_response(301) - self.send_header("Location", self.path + "/") - self.end_headers() - return None - for index in "index.html", "index.htm": - index = os.path.join(path, index) - if os.path.exists(index): - path = index - break - else: - return self.list_directory(path) - ctype = self.guess_type(path) - try: - # Always read in binary mode. Opening files in text mode may cause - # newline translations, making the actual size of the content - # transmitted *less* than the content-length! - f = open(path, 'rb') - except IOError: - self.send_error(404, "File not found") - return None - self.send_response(200) - self.send_header("Content-type", ctype) - fs = os.fstat(f.fileno()) - self.send_header("Content-Length", str(fs[6])) - self.send_header("Last-Modified", self.date_time_string(fs.st_mtime)) - self.end_headers() - return f - - def list_directory(self, path): - """Helper to produce a directory listing (absent index.html). - - Return value is either a file object, or None (indicating an - error). In either case, the headers are sent, making the - interface the same as for send_head(). - - """ - try: - list = os.listdir(path) - except os.error: - self.send_error(404, "No permission to list directory") - return None - - list.sort(key=lambda a: a.lower()) - f = StringIO() - displaypath = cgi.escape(urllib.unquote(self.path)) - - f.write('') - f.write("\nDirectory listing for %s\n" % displaypath) - f.write("\n

Directory listing for %s

\n" % displaypath) - f.write("
\n") - f.write("
") - f.write("") - f.write("
\n") - f.write("
\n
    \n") - - for name in list: - fullname = os.path.join(path, name) - displayname = linkname = name - # Append / for directories or @ for symbolic links - if os.path.isdir(fullname): - displayname = name + "/" - linkname = name + "/" - if os.path.islink(fullname): - displayname = name + "@" - # Note: a link to a directory displays with @ and links with / - f.write('
  • %s\n' - % (urllib.quote(linkname), cgi.escape(displayname))) - f.write("
\n
\n\n\n") - length = f.tell() - f.seek(0) - self.send_response(200) - self.send_header("Content-type", "text/html") - self.send_header("Content-Length", str(length)) - self.end_headers() - return f - - def translate_path(self, path): - """Translate a /-separated PATH to the local filename syntax. - - Components that mean special things to the local file system - (e.g. drive or directory names) are ignored. (XXX They should - probably be diagnosed.) - - """ - # abandon query parameters - path = path.split('?', 1)[0] - path = path.split('#', 1)[0] - path = posixpath.normpath(urllib.unquote(path)) - words = path.split('/') - words = filter(None, words) - path = os.getcwd() - for word in words: - drive, word = os.path.splitdrive(word) - head, word = os.path.split(word) - if word in (os.curdir, os.pardir): - continue - path = os.path.join(path, word) - return path - - def copyfile(self, source, outputfile): - """Copy all data between two file objects. - - The SOURCE argument is a file object open for reading - (or anything with a read() method) and the DESTINATION - argument is a file object open for writing (or - anything with a write() method). - - The only reason for overriding this would be to change - the block size or perhaps to replace newlines by CRLF - -- note however that this the default server uses this - to copy binary data as well. - - """ - shutil.copyfileobj(source, outputfile) - - def guess_type(self, path): - """Guess the type of a file. - - Argument is a PATH (a filename). - - Return value is a string of the form type/subtype, - usable for a MIME Content-type header. - - The default implementation looks the file's extension - up in the table self.extensions_map, using application/octet-stream - as a default; however it would be permissible (if - slow) to look inside the data to make a better guess. - - """ - - base, ext = posixpath.splitext(path) - if ext in self.extensions_map: - return self.extensions_map[ext] - ext = ext.lower() - if ext in self.extensions_map: - return self.extensions_map[ext] - else: - return self.extensions_map[''] - - if not mimetypes.inited: - mimetypes.init() # try to read system mime.types - extensions_map = mimetypes.types_map.copy() - extensions_map.update({ - '': 'application/octet-stream', # Default - '.py': 'text/plain', - '.c': 'text/plain', - '.h': 'text/plain', - }) - - -def test(HandlerClass=SimpleHTTPRequestHandler, - ServerClass=BaseHTTPServer.HTTPServer): - BaseHTTPServer.test(HandlerClass, ServerClass) - -if __name__ == '__main__': - test() diff --git a/interface/server/wgo/basicplayer.commentbox.js b/interface/server/wgo/basicplayer.commentbox.js deleted file mode 100644 index 81891682a..000000000 --- a/interface/server/wgo/basicplayer.commentbox.js +++ /dev/null @@ -1,194 +0,0 @@ -(function(WGo, undefined){ - -"use strict"; - -var prepare_dom = function() { - this.box = document.createElement("div"); - this.box.className = "wgo-box-wrapper wgo-comments-wrapper"; - this.element.appendChild(this.box); - - this.comments_title = document.createElement("div"); - this.comments_title.className = "wgo-box-title"; - this.comments_title.innerHTML = WGo.t("comments"); - this.box.appendChild(this.comments_title); - - this.comments = document.createElement("div"); - this.comments.className = "wgo-comments-content"; - this.box.appendChild(this.comments); - - this.help = document.createElement("div"); - this.help.className = "wgo-help"; - this.help.style.display = "none"; - this.comments.appendChild(this.help); - - this.notification = document.createElement("div"); - this.notification.className = "wgo-notification"; - this.notification.style.display = "none"; - this.comments.appendChild(this.notification); - - this.comment_text = document.createElement("div"); - this.comment_text.className = "wgo-comment-text"; - this.comments.appendChild(this.comment_text); -} - -var mark = function(move) { - var x,y; - - x = move.charCodeAt(0)-'a'.charCodeAt(0); - if(x < 0) x += 'a'.charCodeAt(0)-'A'.charCodeAt(0); - if(x > 7) x--; - y = (move.charCodeAt(1)-'0'.charCodeAt(0)); - if(move.length > 2) y = y*10+(move.charCodeAt(2)-'0'.charCodeAt(0)); - y = this.kifuReader.game.size-y; - - this._tmp_mark = {type:'MA', x:x, y:y}; - this.board.addObject(this._tmp_mark); -} - -var unmark = function() { - this.board.removeObject(this._tmp_mark); - delete this._tmp_mark; -} - -var search_nodes = function(nodes, player) { - for(var i in nodes) { - if(nodes[i].className && nodes[i].className == "wgo-move-link") { - nodes[i].addEventListener("mouseover", mark.bind(player, nodes[i].innerHTML)); - nodes[i].addEventListener("mouseout", unmark.bind(player)); - } - else if(nodes[i].childNodes && nodes[i].childNodes.length) search_nodes(nodes[i].childNodes, player); - } -} - -var format_info = function(info, title) { - var ret = '
'; - if(title) ret += '
'+WGo.t("gameinfo")+'
'; - for(var key in info) { - ret += '
'+key+''+info[key]+'
'; - } - ret += '
'; - return ret; -} - -/** - * Implements box for comments and game informations. - */ - -var CommentBox = WGo.extendClass(WGo.BasicPlayer.component.Component, function(player) { - this.super(player); - this.player = player; - - this.element.className = "wgo-commentbox"; - - prepare_dom.call(this); - - player.addEventListener("kifuLoaded", function(e) { - if(e.kifu.hasComments()) { - this.comments_title.innerHTML = WGo.t("comments"); - this.element.className = "wgo-commentbox"; - - this._update = function(e) { - this.setComments(e); - }.bind(this); - - player.addEventListener("update", this._update); - } - else { - this.comments_title.innerHTML = WGo.t("gameinfo"); - this.element.className = "wgo-commentbox wgo-gameinfo"; - - if(this._update) { - player.removeEventListener("update", this._update); - delete this._update; - } - this.comment_text.innerHTML = format_info(e.target.getGameInfo()); - } - }.bind(this)); - - player.notification = function(text) { - if(text) { - this.notification.style.display = "block"; - this.notification.innerHTML = text; - this.is_notification = true; - } - else { - this.notification.style.display = "none"; - this.is_notification = false; - } - - if(this.is_notification || this.is_help) this.comment_text.style.display = "none"; - else this.comment_text.style.display = "block"; - - }.bind(this); - - player.help = function(text) { - if(text) { - this.help.style.display = "block"; - this.help.innerHTML = text; - this.is_help = true; - } - else { - this.help.style.display = "none"; - this.is_help = false; - } - - if(this.is_notification || this.is_help) this.comment_text.style.display = "none"; - else this.comment_text.style.display = "block"; - }.bind(this); -}); - -CommentBox.prototype.setComments = function(e) { - if(this.player._tmp_mark) unmark.call(this.player); - - var msg = ""; - if(!e.node.parent) { - msg = format_info(e.target.getGameInfo(), true); - } - - this.comment_text.innerHTML = msg+this.getCommentText(e.node.comment, this.player.config.formatNicks, this.player.config.formatMoves); - - if(this.player.config.formatMoves) { - if(this.comment_text.childNodes && this.comment_text.childNodes.length) search_nodes(this.comment_text.childNodes, this.player); - } -}; - -CommentBox.prototype.getCommentText = function(comment, formatNicks, formatMoves) { - // to avoid XSS we must transform < and > to entities, it is highly recomanded not to change it - //.replace(//g,">") : ""; - if(comment) { - var comm = "

"+WGo.filterHTML(comment).replace(/\n/g, "

")+"

"; - if(formatNicks) comm = comm.replace(/(

)([^:]{3,}:)\s/g, '

$2 '); - if(formatMoves) comm = comm.replace(/\b[a-zA-Z]1?\d\b/g, '$&'); - return comm; - } - return ""; -}; - -/** - * Adding 2 configuration to BasicPlayer: - * - * - formatNicks: tries to highlight nicknames in comments (default: true) - * - formatMoves: tries to highlight coordinates in comments (default: true) - */ - -WGo.BasicPlayer.default.formatNicks = true; -WGo.BasicPlayer.default.formatMoves = true; - -WGo.BasicPlayer.attributes["data-wgo-formatnicks"] = function(value) { - if(value.toLowerCase() == "false") this.formatNicks = false; -} - -WGo.BasicPlayer.attributes["data-wgo-formatmoves"] = function(value) { - if(value.toLowerCase() == "false") this.formatMoves = false; -} - -WGo.BasicPlayer.layouts["right_top"].right.push("CommentBox"); -WGo.BasicPlayer.layouts["right"].right.push("CommentBox"); -WGo.BasicPlayer.layouts["one_column"].bottom.push("CommentBox"); - -WGo.i18n.en["comments"] = "Comments"; -WGo.i18n.en["gameinfo"] = "Game info"; - -WGo.BasicPlayer.component.CommentBox = CommentBox - -})(WGo); diff --git a/interface/server/wgo/basicplayer.component.js b/interface/server/wgo/basicplayer.component.js deleted file mode 100644 index ff85a4de7..000000000 --- a/interface/server/wgo/basicplayer.component.js +++ /dev/null @@ -1,54 +0,0 @@ -(function(WGo, undefined) { - -"use strict"; - -/** - * Base class for BasicPlayer's component. Each component should implement this interface. - */ - -var Component = function() { - this.element = document.createElement("div"); -} - -Component.prototype = { - constructor: Component, - - /** - * Append component to element. - */ - - appendTo: function(target) { - target.appendChild(this.element); - }, - - /** - * Compute and return width of component. - */ - - getWidth: function() { - var css = window.getComputedStyle(this.element); - return parseInt(css.width); - }, - - /** - * Compute and return height of component. - */ - - getHeight: function() { - var css = window.getComputedStyle(this.element); - return parseInt(css.height); - }, - - /** - * Update component. Actually dimensions are defined and cannot be changed in this method, - * but you can change for example font size according to new dimensions. - */ - - updateDimensions: function() { - - } -} - -WGo.BasicPlayer.component.Component = Component; - -})(WGo); \ No newline at end of file diff --git a/interface/server/wgo/basicplayer.control.js b/interface/server/wgo/basicplayer.control.js deleted file mode 100644 index ddba44309..000000000 --- a/interface/server/wgo/basicplayer.control.js +++ /dev/null @@ -1,538 +0,0 @@ -(function(WGo, undefined) { - -"use strict"; - -var compare_widgets = function(a,b) { - if(a.weight < b.weight) return -1; - else if(a.weight > b.weight) return 1; - else return 0; -} - -var prepare_dom = function(player) { - - this.iconBar = document.createElement("div"); - this.iconBar.className = "wgo-control-wrapper"; - this.element.appendChild(this.iconBar); - - var widget; - - for(var w in Control.widgets) { - widget = new Control.widgets[w].constructor(player, Control.widgets[w].args); - widget.appendTo(this.iconBar); - this.widgets.push(widget); - } -} - -var Control = WGo.extendClass(WGo.BasicPlayer.component.Component, function(player) { - this.super(player); - - this.widgets = []; - this.element.className = "wgo-player-control"; - - prepare_dom.call(this, player); -}); - -Control.prototype.updateDimensions = function() { - if(this.element.clientWidth < 340) this.element.className = "wgo-player-control wgo-340"; - else if(this.element.clientWidth < 440) this.element.className = "wgo-player-control wgo-440"; - else this.element.className = "wgo-player-control"; -} - -var control = WGo.BasicPlayer.control = {}; - -var butupd_first = function(e) { - if(!e.node.parent && !this.disabled) this.disable(); - else if(e.node.parent && this.disabled) this.enable(); -} - -var butupd_last = function(e) { - if(!e.node.children.length && !this.disabled) this.disable(); - else if(e.node.children.length && this.disabled) this.enable(); -} - -var but_frozen = function(e) { - this._disabled = this.disabled; - if(!this.disabled) this.disable(); -} - -var but_unfrozen = function(e) { - if(!this._disabled) this.enable(); - delete this._disabled; -} - -/** - * Control.Widget base class. It is used for implementing buttons and other widgets. - * First parameter is BasicPlayer object, second can be configuratioon object. - * - * args = { - * name: String, // required - it is used for class name - * init: Function, // other initialization code can be here - * disabled: BOOLEAN, // default false - * } - */ - -control.Widget = function(player, args) { - this.element = this.element || document.createElement(args.type || "div"); - this.element.className = "wgo-widget-"+args.name; - this.init(player, args); -} - -control.Widget.prototype = { - constructor: control.Widget, - - /** - * Initialization function. - */ - - init: function(player, args) { - if(!args) return; - if(args.disabled) this.disable(); - if(args.init) args.init.call(this, player); - }, - - /** - * Append to element. - */ - - appendTo: function(target) { - target.appendChild(this.element); - }, - - /** - * Make button disabled - eventual click listener mustn't be working. - */ - - disable: function() { - this.disabled = true; - if(this.element.className.search("wgo-disabled") == -1) { - this.element.className += " wgo-disabled"; - } - }, - - /** - * Make button working - */ - - enable: function() { - this.disabled = false; - this.element.className = this.element.className.replace(" wgo-disabled",""); - this.element.disabled = ""; - }, - -} - -/** - * Group of widgets - */ - -control.Group = WGo.extendClass(control.Widget, function(player, args) { - this.element = document.createElement("div"); - this.element.className = "wgo-ctrlgroup wgo-ctrlgroup-"+args.name; - - var widget; - for(var w in args.widgets) { - widget = new args.widgets[w].constructor(player, args.widgets[w].args); - widget.appendTo(this.element); - } -}); - -/** - * Clickable widget - for example button. It has click action. - * - * args = { - * title: String, // required - * init: Function, // other initialization code can be here - * click: Function, // required *** onclick event - * togglable: BOOLEAN, // default false - * selected: BOOLEAN, // default false - * disabled: BOOLEAN, // default false - * multiple: BOOLEAN - * } -*/ - -control.Clickable = WGo.extendClass(control.Widget, function(player, args) { - this.super(player, args); -}); - -control.Clickable.prototype.init = function(player, args) { - var fn, _this = this; - - if(args.togglable) { - fn = function() { - if(_this.disabled) return; - - if(args.click.call(_this, player)) _this.select(); - else _this.unselect(); - }; - } - else { - fn = function() { - if(_this.disabled) return; - args.click.call(_this, player); - }; - } - - this.element.addEventListener("click", fn); - this.element.addEventListener("touchstart", function(e){ - e.preventDefault(); - fn(); - if(args.multiple) { - _this._touch_i = 0; - _this._touch_int = window.setInterval(function(){ - if(_this._touch_i > 500) { - fn(); - } - _this._touch_i += 100; - }, 100); - } - return false; - }); - - if(args.multiple) { - this.element.addEventListener("touchend", function(e){ - window.clearInterval(_this._touch_int); - }); - } - - if(args.disabled) this.disable(); - if(args.init) args.init.call(this, player); -}; - -control.Clickable.prototype.select = function() { - this.selected = true; - if(this.element.className.search("wgo-selected") == -1) this.element.className += " wgo-selected"; -}; - -control.Clickable.prototype.unselect = function() { - this.selected = false; - this.element.className = this.element.className.replace(" wgo-selected",""); -}; - -/** - * Widget of button with image icon. - */ - -control.Button = WGo.extendClass(control.Clickable, function(player, args) { - var elem = this.element = document.createElement("button"); - elem.className = "wgo-button wgo-button-"+args.name; - elem.title = WGo.t(args.name); - - this.init(player, args); -}); - -control.Button.prototype.disable = function() { - control.Button.prototype.super.prototype.disable.call(this); - this.element.disabled = "disabled"; -} - -control.Button.prototype.enable = function() { - control.Button.prototype.super.prototype.enable.call(this); - this.element.disabled = ""; -} - -/** - * Widget used in menu - */ - -control.MenuItem = WGo.extendClass(control.Clickable, function(player, args) { - var elem = this.element = document.createElement("div"); - elem.className = "wgo-menu-item wgo-menu-item-"+args.name; - elem.title = WGo.t(args.name); - elem.innerHTML = elem.title; - - this.init(player, args); -}); - -/** - * Widget for move counter. - */ - -control.MoveNumber = WGo.extendClass(control.Widget, function(player) { - this.element = document.createElement("form"); - this.element.className = "wgo-player-mn-wrapper"; - - var move = this.move = document.createElement("input"); - move.type = "text"; - move.value = "0"; - move.maxlength = 3; - move.className = "wgo-player-mn-value"; - //move.disabled = "disabled"; - this.element.appendChild(move); - - this.element.onsubmit = move.onchange = function(player) { - player.goTo(this.getValue()); - return false; - }.bind(this, player); - - player.addEventListener("update", function(e) { - this.setValue(e.path.m); - }.bind(this)); - - player.addEventListener("kifuLoaded", this.enable.bind(this)); - player.addEventListener("frozen", this.disable.bind(this)); - player.addEventListener("unfrozen", this.enable.bind(this)); -}); - -control.MoveNumber.prototype.disable = function() { - control.MoveNumber.prototype.super.prototype.disable.call(this); - this.move.disabled = "disabled"; -}; - -control.MoveNumber.prototype.enable = function() { - control.MoveNumber.prototype.super.prototype.enable.call(this); - this.move.disabled = ""; -}; - -control.MoveNumber.prototype.setValue = function(n) { - this.move.value = n; -}; - -control.MoveNumber.prototype.getValue = function() { - return parseInt(this.move.value); -}; - -// display menu -var player_menu = function(player) { - - if(player._menu_tmp) { - delete player._menu_tmp; - return; - } - if(!player.menu) { - player.menu = document.createElement("div"); - player.menu.className = "wgo-player-menu"; - player.menu.style.position = "absolute"; - player.menu.style.display = "none"; - player.element.appendChild(player.menu); - - var widget; - for(var i in Control.menu) { - widget = new Control.menu[i].constructor(player, Control.menu[i].args, true); - widget.appendTo(player.menu); - } - } - - if(player.menu.style.display != "none") { - player.menu.style.display = "none"; - - document.removeEventListener("click", player._menu_ev); - //document.removeEventListener("touchstart", player._menu_ev); - delete player._menu_ev; - - this.unselect(); - return false; - } - else { - player.menu.style.display = "block"; - - var top = 0; - var left = 0; - var obj = this.element; - - while (obj && obj.tagName != 'BODY') { - top += obj.offsetTop; - left += obj.offsetLeft; - obj = obj.offsetParent; - } - - // kinda dirty syntax, but working well - if(this.element.parentElement.parentElement.parentElement.parentElement == player.regions.bottom.wrapper) { - player.menu.style.left = left+"px"; - player.menu.style.top = (top-player.menu.offsetHeight+1)+"px"; - } - else { - player.menu.style.left = left+"px"; - player.menu.style.top = (top+this.element.offsetHeight)+"px"; - } - - player._menu_ev = player_menu.bind(this, player) - player._menu_tmp = true; - - document.addEventListener("click", player._menu_ev); - //document.addEventListener("touchstart", player._menu_ev); - - return true; - } -} - -/** - * List of widgets (probably MenuItem objects) to be displayed in drop-down menu. - */ - -Control.menu = [{ - constructor: control.MenuItem, - args: { - name: "switch-coo", - togglable: true, - click: function(player) { - player.setCoordinates(!player.coordinates); - return player.coordinates; - }, - init: function(player) { - if(player.coordinates) this.select(); - } - } -}]; - -/** - * List of widgets (probably Button objects) to be displayed. - * - * widget = { - * constructor: Function, // construct a instance of widget - * args: Object, - * } -*/ - -Control.widgets = [ { - constructor: control.Group, - args: { - name: "left", - widgets: [{ - constructor: control.Button, - args: { - name: "menu", - togglable: true, - click: player_menu, - } - }] - } -}, { - constructor: control.Group, - args: { - name: "right", - widgets: [{ - constructor: control.Button, - args: { - name: "about", - click: function(player) { - player.showMessage(WGo.t("about-text")); - }, - } - }] - } -}, { - constructor: control.Group, - args: { - name: "control", - widgets: [{ - constructor: control.Button, - args: { - name: "first", - disabled: true, - init: function(player) { - player.addEventListener("update", butupd_first.bind(this)); - player.addEventListener("frozen", but_frozen.bind(this)); - player.addEventListener("unfrozen", but_unfrozen.bind(this)); - }, - click: function(player) { - player.first(); - }, - } - }, { - constructor: control.Button, - args: { - name: "multiprev", - disabled: true, - multiple: true, - init: function(player) { - player.addEventListener("update", butupd_first.bind(this)); - player.addEventListener("frozen", but_frozen.bind(this)); - player.addEventListener("unfrozen", but_unfrozen.bind(this)); - }, - click: function(player) { - var p = WGo.clone(player.kifuReader.path); - p.m -= 10; - player.goTo(p); - }, - } - },{ - constructor: control.Button, - args: { - name: "previous", - disabled: true, - multiple: true, - init: function(player) { - player.addEventListener("update", butupd_first.bind(this)); - player.addEventListener("frozen", but_frozen.bind(this)); - player.addEventListener("unfrozen", but_unfrozen.bind(this)); - }, - click: function(player) { - player.previous(); - }, - } - }, { - constructor: control.MoveNumber, - }, { - constructor: control.Button, - args: { - name: "next", - disabled: true, - multiple: true, - init: function(player) { - player.addEventListener("update", butupd_last.bind(this)); - player.addEventListener("frozen", but_frozen.bind(this)); - player.addEventListener("unfrozen", but_unfrozen.bind(this)); - }, - click: function(player) { - player.next(); - }, - } - }, { - constructor: control.Button, - args: { - name: "multinext", - disabled: true, - multiple: true, - init: function(player) { - player.addEventListener("update", butupd_last.bind(this)); - player.addEventListener("frozen", but_frozen.bind(this)); - player.addEventListener("unfrozen", but_unfrozen.bind(this)); - }, - click: function(player) { - var p = WGo.clone(player.kifuReader.path); - p.m += 10; - player.goTo(p); - }, - } - }, { - constructor: control.Button, - args: { - name: "last", - disabled: true, - init: function(player) { - player.addEventListener("update", butupd_last.bind(this)); - player.addEventListener("frozen", but_frozen.bind(this)); - player.addEventListener("unfrozen", but_unfrozen.bind(this)); - }, - click: function(player) { - player.last() - }, - } - }] - } -}]; - -var bp_layouts = WGo.BasicPlayer.layouts; -bp_layouts["right_top"].top.push("Control"); -bp_layouts["right"].right.push("Control"); -bp_layouts["one_column"].top.push("Control"); -bp_layouts["no_comment"].bottom.push("Control"); -bp_layouts["minimal"].bottom.push("Control"); - -var player_terms = { - "about": "About", - "first": "First", - "multiprev": "10 moves back", - "previous": "Previous", - "next": "Next", - "multinext": "10 moves forward", - "last": "Last", - "switch-coo": "Display coordinates", - "menu": "Menu", -}; - -for(var key in player_terms) WGo.i18n.en[key] = player_terms[key]; - -WGo.BasicPlayer.component.Control = Control; - -})(WGo); diff --git a/interface/server/wgo/basicplayer.infobox.js b/interface/server/wgo/basicplayer.infobox.js deleted file mode 100644 index 31d82f607..000000000 --- a/interface/server/wgo/basicplayer.infobox.js +++ /dev/null @@ -1,176 +0,0 @@ -(function() { - -"use strict"; - -var prepare_dom = function() { - prepare_dom_box.call(this,"white"); - prepare_dom_box.call(this,"black"); - this.element.appendChild(this.white.box); - this.element.appendChild(this.black.box); -} - -var prepare_dom_box = function(type) { - this[type] = {}; - var t = this[type]; - t.box = document.createElement("div"); - t.box.className = "wgo-box-wrapper wgo-player-wrapper wgo-"+type; - - t.name = document.createElement("div"); - t.name.className = "wgo-box-title"; - t.name.innerHTML = type; - t.box.appendChild(t.name); - - var info_wrapper; - info_wrapper = document.createElement("div"); - info_wrapper.className = "wgo-player-info"; - t.box.appendChild(info_wrapper); - - t.info = {}; - t.info.rank = prepare_dom_info("rank"); - t.info.rank.val.innerHTML = "-"; - t.info.caps = prepare_dom_info("caps"); - t.info.caps.val.innerHTML = "0"; - t.info.time = prepare_dom_info("time"); - t.info.time.val.innerHTML = "--:--"; - info_wrapper.appendChild(t.info.rank.wrapper); - info_wrapper.appendChild(t.info.caps.wrapper); - info_wrapper.appendChild(t.info.time.wrapper); -} - -var prepare_dom_info = function(type) { - var res = {}; - res.wrapper = document.createElement("div"); - res.wrapper.className = "wgo-player-info-box-wrapper"; - - res.box = document.createElement("div"); - res.box.className = "wgo-player-info-box"; - res.wrapper.appendChild(res.box); - - res.title = document.createElement("div"); - res.title.className = "wgo-player-info-title"; - res.title.innerHTML = WGo.t(type); - res.box.appendChild(res.title); - - res.val = document.createElement("div"); - res.val.className = "wgo-player-info-value"; - res.box.appendChild(res.val); - - return res; -} - -var kifu_loaded = function(e) { - var info = e.kifu.info || {}; - - if(info.black) { - this.black.name.innerHTML = WGo.filterHTML(info.black.name) || WGo.t("Black"); - this.black.info.rank.val.innerHTML = WGo.filterHTML(info.black.rank) || "-"; - } - else { - this.black.name.innerHTML = WGo.t("Black"); - this.black.info.rank.val.innerHTML = "-"; - } - if(info.white) { - this.white.name.innerHTML = WGo.filterHTML(info.white.name) || WGo.t("White"); - this.white.info.rank.val.innerHTML = WGo.filterHTML(info.white.rank) || "-"; - } - else { - this.white.name.innerHTML = WGo.t("White"); - this.white.info.rank.val.innerHTML = "-"; - } - - this.black.info.caps.val.innerHTML = "0"; - this.white.info.caps.val.innerHTML = "0"; - - if(info.TM) { - this.setPlayerTime("black", info.TM); - this.setPlayerTime("white", info.TM); - } - else { - this.black.info.time.val.innerHTML = "--:--"; - this.white.info.time.val.innerHTML = "--:--"; - } - - this.updateDimensions(); -} - -var modify_font_size = function(elem) { - var css, max, size; - - if(elem.style.fontSize) { - var size = parseInt(elem.style.fontSize); - elem.style.fontSize = ""; - css = window.getComputedStyle(elem); - max = parseInt(css.fontSize); - elem.style.fontSize = size+"px"; - } - else { - css = window.getComputedStyle(elem); - max = size = parseInt(css.fontSize); - } - - if(size == max && elem.scrollHeight <= elem.offsetHeight) return; - else if(elem.scrollHeight > elem.offsetHeight) { - size -= 2; - while(elem.scrollHeight > elem.offsetHeight && size > 1) { - elem.style.fontSize = size+"px"; - size -= 2; - } - } - else if(size < max) { - size += 2; - while(elem.scrollHeight <= elem.offsetHeight && size <= max) { - elem.style.fontSize = size+"px"; - size += 2; - } - if(elem.scrollHeight > elem.offsetHeight) { - elem.style.fontSize = (size-4)+"px"; - } - } -} - -var update = function(e) { - if(e.node.BL) this.setPlayerTime("black", e.node.BL); - if(e.node.WL) this.setPlayerTime("white", e.node.WL); - if(e.position.capCount.black !== undefined) this.black.info.caps.val.innerHTML = e.position.capCount.black; - if(e.position.capCount.white !== undefined) this.white.info.caps.val.innerHTML = e.position.capCount.white; -} - -/** - * Implements box with basic informations about go players. - */ - -var InfoBox = WGo.extendClass(WGo.BasicPlayer.component.Component, function(player) { - this.super(player); - this.element.className = "wgo-infobox"; - - prepare_dom.call(this); - - player.addEventListener("kifuLoaded", kifu_loaded.bind(this)); - player.addEventListener("update", update.bind(this)); - -}); - -InfoBox.prototype.setPlayerTime = function(color, time) { - var min = Math.floor(time/60); - var sec = Math.round(time)%60; - this[color].info.time.val.innerHTML = min+":"+((sec < 10) ? "0"+sec : sec); -}; - -InfoBox.prototype.updateDimensions = function() { - modify_font_size(this.black.name); - modify_font_size(this.white.name); -}; - -var bp_layouts = WGo.BasicPlayer.layouts; -bp_layouts["right_top"].right.push("InfoBox"); -bp_layouts["right"].right.push("InfoBox"); -bp_layouts["one_column"].top.push("InfoBox"); -bp_layouts["no_comment"].top.push("InfoBox"); - -WGo.i18n.en["rank"] = "Rank"; -WGo.i18n.en["caps"] = "Caps"; -WGo.i18n.en["time"] = "Time"; - -WGo.BasicPlayer.component.InfoBox = InfoBox; - -})(WGo); diff --git a/interface/server/wgo/basicplayer.js b/interface/server/wgo/basicplayer.js deleted file mode 100644 index 168d4ecf7..000000000 --- a/interface/server/wgo/basicplayer.js +++ /dev/null @@ -1,501 +0,0 @@ - -(function(WGo){ - -"use strict"; - -// player counter - for creating unique ids -var pl_count = 0; - -// generate DOM of region -var playerBlock = function(name, parent, visible) { - var e = {}; - e.element = document.createElement("div"); - e.element.className = "wgo-player-"+name; - e.wrapper = document.createElement("div"); - e.wrapper.className = "wgo-player-"+name+"-wrapper"; - e.element.appendChild(e.wrapper); - parent.appendChild(e.element); - if(!visible) e.element.style.display = "none"; - return e; -} - -// generate all DOM of player -var BPgenerateDom = function() { - // wrapper object for common DOM - this.dom = {}; - - // center element - this.dom.center = document.createElement("div"); - this.dom.center.className = "wgo-player-center"; - - // board wrapper element - this.dom.board = document.createElement("div"); - this.dom.board.className = "wgo-player-board"; - - // object wrapper for regions (left, right, top, bottom) - this.regions = {}; - - /* - pseudo DOM structure: -

- -
- - - -
- -
- */ - - this.regions.left = playerBlock("left", this.element); - this.element.appendChild(this.dom.center); - this.regions.right = playerBlock("right", this.element); - - this.regions.top = playerBlock("top", this.dom.center); - this.dom.center.appendChild(this.dom.board); - this.regions.bottom = playerBlock("bottom", this.dom.center); -} - -var getCurrentLayout = function() { - var cl = this.config.layout; - if(cl.constructor != Array) return cl; - - var bh = this.height || this.maxHeight; - for(var i = 0; i < cl.length; i++) { - - if(!cl[i].conditions || ( - (!cl[i].conditions.minWidth || cl[i].conditions.minWidth <= this.width) && - (!cl[i].conditions.minHeight || !bh || cl[i].conditions.minHeight <= bh) && - (!cl[i].conditions.maxWidth || cl[i].conditions.maxWidth >= this.width) && - (!cl[i].conditions.maxHeight || !bh || cl[i].conditions.maxHeight >= bh) && - (!cl[i].conditions.custom || cl[i].conditions.custom.call(this)) - )) { - return cl[i]; - } - } -} - -var appendComponents = function(area) { - var components; - - if(this.currentLayout.layout) components = this.currentLayout.layout[area]; - else components = this.currentLayout[area]; - - if(components) { - this.regions[area].element.style.display = "block"; - - for(var i in components) { - if(!this.components[components[i]]) this.components[components[i]] = new BasicPlayer.component[components[i]](this); - - this.components[components[i]].appendTo(this.regions[area].wrapper); - - // remove detach flag - this.components[components[i]]._detachFromPlayer = false; - } - } - else { - this.regions[area].element.style.display = "none"; - } - -} - -var manageComponents = function() { - // add detach flags to every widget - for(var key in this.components) { - this.components[key]._detachFromPlayer = true; - } - - appendComponents.call(this, "left"); - appendComponents.call(this, "right"); - appendComponents.call(this, "top"); - appendComponents.call(this, "bottom"); - - // detach all invisible components - for(var key in this.components) { - if(this.components[key]._detachFromPlayer && this.components[key].element.parentNode) this.components[key].element.parentNode.removeChild(this.components[key].element); - } -} - -/** - * Main object of player, it binds all magic together and produces visible player. - * It inherits some functionality from WGo.PlayerView, but full html structure is done here. - * - * Layout of player can be set. It can be even dynamic according to screen resolution. - * There are 5 areas - left, right, top and bottom, and there is special region for board. - * You can put BasicPlayer.Component objects to these regions. Basic components are: - * - BasicPlayer.CommentBox - box with comments and game informations - * - BasicPlayer.InfoBox - box with information about players - * - BasicPlayer.Control - buttons and staff for control - * - * Possible configurations: - * - sgf: sgf string (default: undefined) - * - json: kifu stored in json/jgo (default: undefined) - * - sgfFile: sgf file path (default: undefined) - * - board: configuration object of board (default: {}) - * - enableWheel: allow player to be controlled by mouse wheel (default: true) - * - lockScroll: disable window scrolling while hovering player (default: true) - * - enableKeys: allow player to be controlled by arrow keys (default: true) - * - kifuLoaded: extra Player's kifuLoaded event listener (default: undefined) - * - update: extra Player's update event listener (default: undefined) - * - frozen: extra Player's frozen event listener (default: undefined) - * - unfrozen: extra Player's unfrozen event listener (default: undefined) - * - layout: layout object. Look below how to define your own layout (default: BasicPlayer.dynamicLayout) - * - * You also must specify main DOMElement of player. - */ - -var BasicPlayer = WGo.extendClass(WGo.Player, function(elem, config) { - this.config = config; - - // add default configuration of BasicPlayer - for(var key in BasicPlayer.default) if(this.config[key] === undefined && BasicPlayer.default[key] !== undefined) this.config[key] = BasicPlayer.default[key]; - // add default configuration of Player class - for(var key in WGo.Player.default) if(this.config[key] === undefined && WGo.Player.default[key] !== undefined) this.config[key] = WGo.Player.default[key]; - - this.element = elem - this.element.innerHTML = ""; - this.classes = (this.element.className ? this.element.className+" " : "")+"wgo-player-main" ; - this.element.className = this.classes; - if(!this.element.id) this.element.id = "wgo_"+(pl_count++); - - BPgenerateDom.call(this); - - this.board = new WGo.Board(this.dom.board, this.config.board); - - this.init(); - - this.components = {}; - - window.addEventListener("resize", function() { - if(!this.noresize) { - this.updateDimensions(); - } - - }.bind(this)); - - this.updateDimensions(); - - this.initGame(); -}); - -/** - * Append player to different element. - */ - -BasicPlayer.prototype.appendTo = function(elem) { - elem.appendChild(this.element); - this.updateDimensions(); -} - -/** - * Set right dimensions of all elements. - */ - -BasicPlayer.prototype.updateDimensions = function() { - var css = window.getComputedStyle(this.element); - - var els = []; - while(this.element.firstChild) { - els.push(this.element.firstChild); - this.element.removeChild(this.element.firstChild); - } - - var tmp_w = parseInt(css.width); - var tmp_h = parseInt(css.height); - var tmp_mh = parseInt(css.maxHeight) || 0; - - for(var i = 0; i < els.length; i++) { - this.element.appendChild(els[i]); - } - - if(tmp_w == this.width && tmp_h == this.height && tmp_mh == this.maxHeight) return; - - this.width = tmp_w; - this.height = tmp_h; - this.maxHeight = tmp_mh; - - this.currentLayout = getCurrentLayout.call(this); - - if(this.currentLayout && this.lastLayout != this.currentLayout) { - if(this.currentLayout.className) this.element.className = this.classes+" "+this.currentLayout.className; - else this.element.className = this.classes; - manageComponents.call(this); - this.lastLayout = this.currentLayout; - } - - //var bw = this.width - this.regions.left.element.clientWidth - this.regions.right.element.clientWidth; - var bw = this.dom.board.clientWidth; - var bh = this.height || this.maxHeight; - - if(bh) { - bh -= this.regions.top.element.offsetHeight + this.regions.bottom.element.offsetHeight; - } - - if(bh && bh < bw) { - if(bh != this.board.height) this.board.setHeight(bh); - } - else { - if(bw != this.board.width) this.board.setWidth(bw); - } - - var diff = bh - bw; - - if(diff > 0) { - this.dom.board.style.height = bh+"px"; - this.dom.board.style.paddingTop = (diff/2)+"px"; - } - else { - this.dom.board.style.height = "auto"; - this.dom.board.style.paddingTop = "0"; - } - - this.regions.left.element.style.height = this.dom.center.offsetHeight+"px"; - this.regions.right.element.style.height = this.dom.center.offsetHeight+"px"; - - for(var i in this.components) { - if(this.components[i].updateDimensions) this.components[i].updateDimensions(); - } -} - -/** - * Layout contains built-in info box, for displaying of text(html) messages. - * You can use this method to display a message. - * - * @param text or html to display - * @param closeCallback optional callback function which is called when message is closed - */ - -BasicPlayer.prototype.showMessage = function(text, closeCallback, permanent) { - this.info_overlay = document.createElement("div"); - this.info_overlay.style.width = this.element.offsetWidth+"px"; - this.info_overlay.style.height = this.element.offsetHeight+"px"; - this.info_overlay.className = "wgo-info-overlay"; - this.element.appendChild(this.info_overlay); - - var info_message = document.createElement("div"); - info_message.className = "wgo-info-message"; - info_message.innerHTML = text; - - var close_info = document.createElement("div"); - close_info.className = "wgo-info-close"; - if(!permanent) close_info.innerHTML = WGo.t("BP:closemsg"); - - info_message.appendChild(close_info); - - this.info_overlay.appendChild(info_message); - - if(closeCallback) { - this.info_overlay.addEventListener("click",function(e) { - closeCallback(e); - }); - } - else if(!permanent) { - this.info_overlay.addEventListener("click",function(e) { - this.hideMessage(); - }.bind(this)); - } - - this.setFrozen(true); -} - -/** - * Hide a message box. - */ - -BasicPlayer.prototype.hideMessage = function() { - this.element.removeChild(this.info_overlay); - this.setFrozen(false); -} - -/** - * Error handling - */ - -BasicPlayer.prototype.error = function(err) { - if(!WGo.ERROR_REPORT) throw err; - - var url = "#"; - - switch(err.name) { - case "InvalidMoveError": - this.showMessage("

"+err.name+"

"+err.message+"

If this message isn't correct, please report it by clicking here, otherwise contact maintainer of this site.

"); - break; - case "FileError": - this.showMessage("

"+err.name+"

"+err.message+"

Please contact maintainer of this site. Note: it is possible to read files only from this host.

"); - break; - default: - this.showMessage("

"+err.name+"

"+err.message+"

"+err.stacktrace+"

Please contact maintainer of this site. You can also report it here.

"); - } -} - -BasicPlayer.component = {}; - -/** - * Preset layouts - * They have defined regions as arrays, which can contain components. For each of these layouts each component specifies where it is placed. - * You can create your own layout in same manners, but you must specify components manually. - */ - -BasicPlayer.layouts = { - "one_column": { - top: [], - bottom: [], - }, - "no_comment": { - top: [], - bottom: [], - }, - "right_top": { - top: [], - right: [], - }, - "right": { - right: [], - }, - "minimal": { - bottom: [] - }, -}; - -/** - * WGo player can have more layouts. It allows responsive design of the player. - * Possible layouts are defined as array of object with this structure: - * - * layout = { - * Object layout, // layout as specified above - * Object conditions, // conditions that has to be valid to apply this layout - * String className // custom classnames - * } - * - * possible conditions: - * - minWidth - minimal width of player in px - * - maxWidth - maximal width of player in px - * - minHeight - minimal height of player in px - * - maxHeight - maximal height of player in px - * - custom - function which is called in template context, must return true or false - * - * Player's template evaluates layouts step by step and first layout that matches the conditions is applied. - * - * Look below at the default dynamic layout. Layouts are tested after every window resize. - */ - -BasicPlayer.dynamicLayout = [ - { - conditions: { - minWidth: 650, - }, - layout: BasicPlayer.layouts["right_top"], - className: "wgo-twocols wgo-large", - }, - { - conditions: { - minWidth: 550, - minHeight: 600, - }, - layout: BasicPlayer.layouts["one_column"], - className: "wgo-medium" - }, - { - conditions: { - minWidth: 350, - }, - layout: BasicPlayer.layouts["no_comment"], - className: "wgo-small" - }, - { // if conditions object is omitted, layout is applied - layout: BasicPlayer.layouts["no_comment"], - className: "wgo-xsmall", - }, -]; - -// default settings, they are merged with user settings in constructor. -BasicPlayer.default = { - layout: BasicPlayer.dynamicLayout, -} - -WGo.i18n.en["BP:closemsg"] = "click anywhere to close this window"; - -//--- Handling
with HTML5 data attributes ----------------------------------------------------------------- - -BasicPlayer.attributes = { - "data-wgo": function(value) { - if(value) { - if(value[0] == "(") this.sgf = value; - else this.sgfFile = value; - } - }, - - "data-wgo-board": function(value) { - // using eval to parse strings like "stoneStyle: 'painted'" - this.board = eval("({"+value+"})"); - }, - - "data-wgo-onkifuload": function(value) { - this.kifuLoaded = new Function(value); - }, - - "data-wgo-onupdate": function(value) { - this.update = new Function(value); - }, - - "data-wgo-onfrozen": function(value) { - this.frozen = new Function(value); - }, - - "data-wgo-onunfrozen": function(value) { - this.unfrozen = new Function(value); - }, - - "data-wgo-layout": function(value) { - this.layout = eval("({"+value+"})"); - }, - - "data-wgo-enablewheel": function(value) { - if(value.toLowerCase() == "false") this.enableWheel = false; - }, - - "data-wgo-lockscroll": function(value) { - if(value.toLowerCase() == "false") this.lockScroll = false; - }, - - "data-wgo-enablekeys": function(value) { - if(value.toLowerCase() == "false") this.enableKeys = false; - }, - - "data-wgo-rememberpath": function(value) { - if(value.toLowerCase() == "false") this.rememberPath = false; - }, - - "data-wgo-move": function(value) { - var m = parseInt(value); - if(m) this.move = m; - else this.move = eval("({"+value+"})"); - }, -} - -var player_from_tag = function(elem) { - var att, config, pl; - - config = {}; - - for(var a = 0; a < elem.attributes.length; a++) { - att = elem.attributes[a]; - if(BasicPlayer.attributes[att.name]) BasicPlayer.attributes[att.name].call(config, att.value, att.name); - } - - pl = new BasicPlayer(elem, config); - elem._wgo_player = pl; -} - -WGo.BasicPlayer = BasicPlayer; - -window.addEventListener("load", function() { - var pl_elems = document.querySelectorAll("[data-wgo]"); - - for(var i = 0; i < pl_elems.length; i++) { - player_from_tag(pl_elems[i]); - } -}); - -})(WGo); diff --git a/interface/server/wgo/kifu.js b/interface/server/wgo/kifu.js deleted file mode 100644 index e59110b93..000000000 --- a/interface/server/wgo/kifu.js +++ /dev/null @@ -1,622 +0,0 @@ - -/** - * This extension handles go game records(kifu). In WGo kifu is stored in JSON. Kifu structure example: - * - * JGO proposal = { - * size: 19, - * info: { - * black: {name:"Lee Chang-Ho", rank:"9p"}, - * white: {name:"Lee Sedol", rank:"9p"}, - * komi: 6.5, - * }, - * game: [ - * {B:"mm"}, - * {W:"nn"}, - * {B:"cd"}, - * {}, - * ] - * } - * - */ - -(function (WGo, undefined) { - -"use strict"; - -var recursive_clone = function(node) { - var n = new KNode(JSON.parse(JSON.stringify(node.getProperties()))); - for(var ch in node.children) { - n.appendChild(recursive_clone(node.children[ch])); - } - return n; -} - -var find_property = function(prop, node) { - var res; - if(node[prop] !== undefined) return node[prop]; - for(var ch in node.children) { - res = find_property(prop, node.children[ch]) - if(res) return res; - } - return false; -} - -var recursive_save = function(gameTree, node) { - gameTree.push(JSON.parse(JSON.stringify(node.getProperties()))); - if(node.children.length > 1) { - var nt = []; - for(var i = 0; i < node.children.length; i++) { - var t = []; - recursive_save(t, node.children[i]); - nt.push(t); - } - gameTree.push(nt); - } - else if(node.children.length) { - recursive_save(gameTree, node.children[0]); - } -} - -var recursive_save2 = function(gameTree, node) { - var anode = node; - var tnode; - - for(var i = 1; i < gameTree.length; i++) { - if(gameTree[i].constructor == Array) { - for(var j = 0; j < gameTree[i].length; j++) { - tnode = new KNode(gameTree[i][j][0]); - anode.appendChild(tnode); - recursive_save2(gameTree[i][j], tnode); - } - } - else { - tnode = new KNode(gameTree[i]); - anode.insertAfter(tnode); - anode = tnode; - } - } -} - -/** - * Kifu class - for storing go game record and easy manipulation with it - */ - -var Kifu = function() { - this.size = 19; - this.info = {}; - this.root = new KNode(); - this.nodeCount = 0; - this.propertyCount = 0; -} - -Kifu.prototype ={ - constructor: Kifu, - clone: function() { - var clone = new Kifu(); - clone.size = this.size; - clone.info = JSON.parse(JSON.stringify(this.info)); - clone.root = recursive_clone(this.root); - clone.nodeCount = this.nodeCount; - clone.propertyCount = this.propertyCount; - return clone; - }, - hasComments: function() { - return !!find_property("comment", this.root); - }, -} - -/** - * Create kifu object from SGF string - */ - -Kifu.fromSgf = function(sgf) { - return WGo.SGF.parse(sgf); -} - -/** - * Create kifu object from JGO - */ - -Kifu.fromJGO = function(arg) { - var jgo = typeof arg == "string" ? JSON.parse(arg) : arg; - var kifu = new Kifu(); - kifu.info = JSON.parse(JSON.stringify(jgo.info)); - kifu.size = jgo.size; - kifu.nodeCount = jgo.nodeCount; - kifu.propertyCount = jgo.propertyCount; - - kifu.root = new KNode(jgo.game[0]); - recursive_save2(jgo.game, kifu.root); - - return kifu; -} - -/** - * Return SGF string from kifu object - */ - -Kifu.prototype.toSgf = function() { - // not implemented yet -} - -/** - * Return JGO from kifu object - */ - -Kifu.prototype.toJGO = function(stringify) { - var jgo = {}; - jgo.size = this.size; - jgo.info = JSON.parse(JSON.stringify(this.info)); - jgo.nodeCount = this.nodeCount; - jgo.propertyCount = this.propertyCount; - jgo.game = []; - recursive_save(jgo.game, this.root); - if(stringify) return JSON.stringify(jgo); - else return jgo; -} - -var player_formatter = function(value) { - var str; - if(value.name) { - str = WGo.filterHTML(value.name); - if(value.rank) str += " ("+WGo.filterHTML(value.rank)+")"; - if(value.team) str += ", "+WGo.filterHTML(value.team); - } - else { - if(value.team) str = WGo.filterHTML(value.team); - if(value.rank) str += " ("+WGo.filterHTML(value.rank)+")"; - } - return str; -} - -/** - * Game information formatters. Each formatter is a function which somehow formats input text. - */ - -Kifu.infoFormatters = { - black: player_formatter, - white: player_formatter, - TM: function(time) { - if(time == 0) return WGo.t("none"); - - var res, t = Math.floor(time/60); - - if(t == 1) res = "1 "+WGo.t("minute"); - else if(t > 1) res = t+" "+WGo.t("minutes"); - - t = time%60; - if(t == 1) res += " 1 "+WGo.t("second"); - else if(t > 1) res += " "+t+" "+WGo.t("seconds"); - - return res; - }, - RE: function(res) { - return ''+WGo.t('show')+''; - }, -} - -/** - * List of game information properties - */ - -Kifu.infoList = ["black", "white", "AN", "CP", "DT", "EV", "GN", "GC", "ON", "OT", "RE", "RO", "RU", "SO", "TM", "PC", "KM"]; - -WGo.Kifu = Kifu; - -var no_add = function(arr, obj, key) { - for(var i = 0; i < arr.length; i++) { - if(arr[i].x == obj.x && arr[i].y == obj.y) { - arr[i][key] = obj[key]; - return; - } - } - arr.push(obj); -} - -var no_remove = function(arr, obj) { - if(!arr) remove; - for(var i = 0; i < arr.length; i++) { - if(arr[i].x == obj.x && arr[i].y == obj.y) { - arr.splice(i,1); - return; - } - } -} - -/** - * Node class of kifu game tree. It can contain move, setup or markup properties. - * - * @param {object} properties - * @param {KNode} parent (null for root node) - */ - -var KNode = function(properties, parent) { - this.parent = parent || null; - this.children = []; - // save all properties - if(properties) for(var key in properties) this[key] = properties[key]; -} - -KNode.prototype = { - constructor: KNode, - - /** - * Get node's children specified by index. If it doesn't exist, method returns null. - */ - - getChild: function(ch) { - var i = ch || 0; - if(this.children[i]) return this.children[i]; - else return null; - }, - - /** - * Add setup property. - * - * @param {object} setup object with structure: {x:, y:, c:} - */ - - addSetup: function(setup) { - this.setup = this.setup || []; - no_add(this.setup, setup, "c"); - return this; - }, - - /** - * Remove setup property. - * - * @param {object} setup object with structure: {x:, y:} - */ - - removeSetup: function(setup) { - no_remove(this.setup, setup); - return this; - }, - - /** - * Add markup property. - * - * @param {object} markup object with structure: {x:, y:, type:} - */ - - addMarkup: function(markup) { - this.markup = this.markup || []; - no_add(this.markup, markup, "type"); - return this; - }, - - /** - * Remove markup property. - * - * @param {object} markup object with structure: {x:, y:} - */ - - removeMarkup: function(markup) { - no_remove(this.markup, markup); - return this; - }, - - /** - * Remove this node. - * Node is removed from its parent and children are passed to parent. - */ - - remove: function() { - var p = this.parent; - if(!p) throw new Exception("Root node cannot be removed"); - for(var i in p.children) { - if(p.children[i] == this) { - p.children.splice(i,1); - break; - } - } - p.children = p.children.concat(this.children); - this.parent = null; - return p; - }, - - /** - * Insert node after this node. All children are passed to new node. - */ - - insertAfter: function(node) { - node.children = node.children.concat(this.children); - node.parent = this; - this.children = [node]; - return node; - }, - - /** - * Append child node to this node. - */ - - appendChild: function(node) { - node.parent = this; - this.children.push(node); - return node; - }, - - /** - * Get properties as object. - */ - - getProperties: function() { - var props = {}; - for(var key in this) { - if(this.hasOwnProperty(key) && key != "children" && key != "parent") props[key] = this[key]; - } - return props; - } -} - -WGo.KNode = KNode; - -var pos_diff = function(old_p, new_p) { - var size = old_p.size, add = [], remove = []; - - for(var i = 0; i < size*size; i++) { - if(old_p.schema[i] && !new_p.schema[i]) remove.push({x:Math.floor(i/size),y:i%size}); - else if(old_p.schema[i] != new_p.schema[i]) add.push({x:Math.floor(i/size),y:i%size,c:new_p.schema[i]}); - } - - return { - add: add, - remove: remove - } -} - -/** - * KifuReader object is capable of reading a kifu nodes and executing them. It contains Game object with actual position. - * Variable change contains last changes of position. - * If parameter rememberPath is set, KifuReader will remember last selected child of all nodes. - */ - -var KifuReader = function(kifu, rememberPath) { - this.kifu = kifu; - this.node = this.kifu.root; - this.game = new WGo.Game(this.kifu.size); - this.path = {m:0}; - - this.change = exec_node(this.game, this.node, true); - if(this.kifu.info["HA"] && this.kifu.info["HA"] > 1) this.game.turn = WGo.W; - - if(rememberPath) this.rememberPath = true; - else this.rememberPath = false; -} - -var set_subtract = function(a, b) { - var n = [], q; - for(var i in a) { - q = true; - for(var j in b) { - if(a[i].x == b[j].x && a[i].y == b[j].y) { - q = false; - break; - } - } - if(q) n.push(a[i]); - } - return n; -} - -var concat_changes = function(ch_orig, ch_new) { - ch_orig.add = set_subtract(ch_orig.add, ch_new.remove).concat(ch_new.add); - ch_orig.remove = set_subtract(ch_orig.remove, ch_new.add).concat(ch_new.remove); -} - -// change game object according to node, return changes -var exec_node = function(game, node, first) { - if(node.parent) node.parent._last_selected = node.parent.children.indexOf(node); - - if(node.move != undefined) { - if(node.move.pass) { - game.pass(node.move.c); - return {add:[], remove:[]}; - } - else { - var res = game.play(node.move.x, node.move.y, node.move.c); - if(typeof res == "number") throw new InvalidMoveError(res, node); - return { - add: [node.move], - remove: res - } - } - } - else if(node.setup != undefined) { - if(!first) game.pushPosition(); - - var add = [], remove = []; - - for(var i in node.setup) { - if(node.setup[i].c) { - game.addStone(node.setup[i].x, node.setup[i].y, node.setup[i].c); - add.push(node.setup[i]); - } - else { - game.removeStone(node.setup[i].x, node.setup[i].y); - remove.push(node.setup[i]); - } - } - - // TODO: check & test handling of turns - if(node.turn) game.turn = node.turn; - - return { - add: add, - remove: remove - }; - } - else if(!first) { - game.pushPosition(); - } - return {add:[], remove:[]}; -} - -var exec_next = function(i) { - if(i === undefined && this.rememberPath) i = this.node._last_selected; - i = i || 0; - var node = this.node.children[i]; - - if(!node) return false; - - var ch = exec_node(this.game, node); - - this.path.m++; - if(this.node.children.length > 1) this.path[this.path.m] = i; - - this.node = node; - return ch; -} - -var exec_previous = function() { - if(!this.node.parent) return false; - - this.node = this.node.parent; - - this.game.popPosition(); - - this.path.m--; - if(this.path[this.path.m] !== undefined) delete this.path[this.path.m]; - - return true; -} - -var exec_first = function() { - //if(!this.node.parent) return; - - this.game.firstPosition(); - this.node = this.kifu.root; - - this.path = {m: 0}; - - this.change = exec_node(this.game, this.node, true); - if(this.kifu.info["HA"] && this.kifu.info["HA"] > 1) this.game.turn = WGo.W; -} - -KifuReader.prototype = { - constructor: KifuReader, - - /** - * Go to next node and if there is a move play it. - */ - - next: function(i) { - this.change = exec_next.call(this, i); - return this; - }, - - /** - * Execute all nodes till the end. - */ - - last: function() { - var ch; - this.change = { - add: [], - remove: [] - } - while(ch = exec_next.call(this)) concat_changes(this.change, ch); - return this; - }, - - /** - * Return to the previous position (redo actual node) - */ - - previous: function() { - var old_pos = this.game.getPosition(); - exec_previous.call(this); - this.change = pos_diff(old_pos, this.game.getPosition()); - return this; - }, - - /** - * Go to the initial position - */ - - first: function() { - var old_pos = this.game.getPosition(); - exec_first.call(this); - this.change = pos_diff(old_pos, this.game.getPosition()); - return this; - }, - - /** - * Go to position specified by path object - */ - - goTo: function(path) { - if(path === undefined) return this; - - var old_pos = this.game.getPosition(); - - exec_first.call(this); - - var r; - - for(var i = 0; i < path.m; i++) { - if(!exec_next.call(this, path[i+1])) { - break; - } - } - - this.change = pos_diff(old_pos, this.game.getPosition()); - return this; - }, - - /** - * Go to previous fork (a node with more than one child) - */ - - previousFork: function() { - var old_pos = this.game.getPosition(); - while(exec_previous.call(this) && this.node.children.length == 1); - this.change = pos_diff(old_pos, this.game.getPosition()); - return this; - }, - - /** - * Shortcut. Get actual position object. - */ - - getPosition: function() { - return this.game.getPosition(); - } -} - -WGo.KifuReader = KifuReader; - -// Class handling invalid moves in kifu -var InvalidMoveError = function(code, node) { - this.name = "InvalidMoveError"; - this.message = "Invalid move in kifu detected. "; - - if(node.move && node.move.c !== undefined && node.move.x !== undefined && node.move.y !== undefined) this.message += "Trying to play "+(node.move.c == WGo.WHITE ? "white" : "black")+" move on "+String.fromCharCode(node.move.x+65)+""+(19-node.move.y); - else this.message += "Move object doesn't contain arbitrary attributes."; - - if(code) { - switch(code) { - case 1: - this.message += ", but these coordinates are not on board."; - break; - case 2: - this.message += ", but there already is a stone."; - break; - case 3: - this.message += ", but this move is a suicide."; - case 4: - this.message += ", but this position already occured."; - break; - } - } - else this.message += "." -} -InvalidMoveError.prototype = new Error(); -InvalidMoveError.prototype.constructor = InvalidMoveError; - -WGo.InvalidMoveError = InvalidMoveError; - -WGo.i18n.en["show"] = "show"; -WGo.i18n.en["res-show-tip"] = "Click to show result."; - -})(WGo); diff --git a/interface/server/wgo/player.editable.js b/interface/server/wgo/player.editable.js deleted file mode 100644 index cdd69fa07..000000000 --- a/interface/server/wgo/player.editable.js +++ /dev/null @@ -1,150 +0,0 @@ - -(function(WGo) { - -// board mousemove callback for edit move - adds highlighting -var edit_board_mouse_move = function(x,y) { - if(this.player.frozen || (this._lastX == x && this._lastY == y)) return; - - this._lastX = x; - this._lastY = y; - - if(this._last_mark) { - this.board.removeObject(this._last_mark); - } - - if(x != -1 && y != -1 && this.player.kifuReader.game.isValid(x,y)) { - this._last_mark = { - type: "outline", - x: x, - y: y, - c: this.player.kifuReader.game.turn - }; - this.board.addObject(this._last_mark); - } - else { - delete this._last_mark; - } -} - -// board mouseout callback for edit move -var edit_board_mouse_out = function() { - if(this._last_mark) { - this.board.removeObject(this._last_mark); - delete this._last_mark; - delete this._lastX; - delete this._lastY; - } -} - -// get differences of two positions as a change object (TODO create a better solution, without need of this function) -var pos_diff = function(old_p, new_p) { - var size = old_p.size, add = [], remove = []; - - for(var i = 0; i < size*size; i++) { - if(old_p.schema[i] && !new_p.schema[i]) remove.push({x:Math.floor(i/size),y:i%size}); - else if(old_p.schema[i] != new_p.schema[i]) add.push({x:Math.floor(i/size),y:i%size,c:new_p.schema[i]}); - } - - return { - add: add, - remove: remove - } -} - -WGo.Player.Editable = {}; - -/** - * Toggle edit mode. - */ - -WGo.Player.Editable = function(player, board) { - this.player = player; - this.board = board; - this.editMode = false; -} - -WGo.Player.Editable.prototype.set = function(set) { - if(!this.editMode && set) { - // save original kifu reader - this.originalReader = this.player.kifuReader; - - // create new reader with cloned kifu - this.player.kifuReader = new WGo.KifuReader(this.player.kifu.clone(), this.originalReader.rememberPath); - - // go to current position - this.player.kifuReader.goTo(this.originalReader.path); - - // register edit listeners - this._ev_click = this._ev_click || this.play.bind(this); - this._ev_move = this._ev_move || edit_board_mouse_move.bind(this); - this._ev_out = this._ev_out || edit_board_mouse_out.bind(this); - - this.board.addEventListener("click", this._ev_click); - this.board.addEventListener("mousemove", this._ev_move); - this.board.addEventListener("mouseout", this._ev_out); - - this.editMode = true; - } - else if(this.editMode && !set) { - // go to the last original position - this.originalReader.goTo(this.player.kifuReader.path); - - // change object isn't actual - update it, not elegant solution, but simple - this.originalReader.change = pos_diff(this.player.kifuReader.getPosition(), this.originalReader.getPosition()); - - // update kifu reader - this.player.kifuReader = this.originalReader; - this.player.update(true); - - // remove edit listeners - this.board.removeEventListener("click", this._ev_click); - this.board.removeEventListener("mousemove", this._ev_move); - this.board.removeEventListener("mouseout", this._ev_out); - - this.editMode = false; - } -} - -WGo.Player.Editable.prototype.play = function(x,y) { - if(this.player.frozen || !this.player.kifuReader.game.isValid(x, y)) return; - - this.player.kifuReader.node.appendChild(new WGo.KNode({ - move: { - x: x, - y: y, - c: this.player.kifuReader.game.turn - }, - edited: true - })); - this.player.next(this.player.kifuReader.node.children.length-1); -} - -if(WGo.BasicPlayer && WGo.BasicPlayer.component.Control) { - WGo.BasicPlayer.component.Control.menu.push({ - constructor: WGo.BasicPlayer.control.MenuItem, - args: { - name: "editmode", - togglable: true, - click: function(player) { - this._editable = this._editable || new WGo.Player.Editable(player, player.board); - this._editable.set(!this._editable.editMode); - return this._editable.editMode; - }, - init: function(player) { - var _this = this; - player.addEventListener("frozen", function(e) { - _this._disabled = _this.disabled; - if(!_this.disabled) _this.disable(); - }); - player.addEventListener("unfrozen", function(e) { - if(!_this._disabled) _this.enable(); - delete _this._disabled; - }); - }, - } - }); -} - -WGo.i18n.en["editmode"] = "Edit mode"; - -})(WGo); diff --git a/interface/server/wgo/player.js b/interface/server/wgo/player.js deleted file mode 100644 index 5e8d65f6e..000000000 --- a/interface/server/wgo/player.js +++ /dev/null @@ -1,691 +0,0 @@ - -(function(WGo){ - -"use strict"; - -var FileError = function(path, code) { - this.name = "FileError"; - - if(code == 1) this.message = "File '"+path+"' is empty."; - else if(code == 2) this.message = "Network error. It is not possible to read '"+path+"'."; - else this.message = "File '"+path+"' hasn't been found on server."; -} - -FileError.prototype = new Error(); -FileError.prototype.constructor = FileError; - -WGo.FileError = FileError; - -// ajax function for loading of files -var loadFromUrl = WGo.loadFromUrl = function(url, callback) { - - var xmlhttp = new XMLHttpRequest(); - xmlhttp.onreadystatechange = function() { - if (xmlhttp.readyState == 4) { - if(xmlhttp.status == 200) { - if(xmlhttp.responseText.length == 0) { - throw new FileError(url, 1); - } - else { - callback(xmlhttp.responseText); - } - } - else { - throw new FileError(url); - } - } - } - - try { - xmlhttp.open("GET", url, true); - xmlhttp.send(); - } - catch(err) { - throw new FileError(url, 2); - } - -} - -// basic updating function - handles board changes -var update_board = function(e) { - if(!e.change) return; - - // update board's position - this.board.update(e.change); - - // remove old markers from the board - if(this.temp_marks) this.board.removeObject(this.temp_marks); - - // init array for new objects - var add = []; - - // add current move marker - if(e.node.move) { - if(e.node.move.pass) this.setMessage({ - text: WGo.t((e.node.move.c == WGo.B ? "b" : "w")+"pass"), - type: "notification" - }); - else add.push({ - type: "CR", - x: e.node.move.x, - y: e.node.move.y - }); - } - - // add variantion letters - if(e.node.children.length > 1) { - for(var i = 0; i < e.node.children.length; i++) { - if(e.node.children[i].move) add.push({ - type: "LB", - text: String.fromCharCode(65+i), - x: e.node.children[i].move.x, - y: e.node.children[i].move.y - }); - } - } - - // add other markup - if(e.node.markup) { - for(var i in e.node.markup) { - for(var j = 0; j < add.length; j++) { - if(e.node.markup[i].x == add[j].x && e.node.markup[i].y == add[j].y) { - add.splice(j,1); - j--; - } - } - } - add = add.concat(e.node.markup); - } - - // add new markers on the board - this.temp_marks = add; - this.board.addObject(add); -} - -// preparing board -var prepare_board = function(e) { - // set board size - this.board.setSize(e.kifu.size); - - // remove old objects - this.board.removeAllObjects(); - - // activate wheel - if(this.config.enableWheel) this.setWheel(true); -} - -// detecting scrolling of element - e.g. when we are scrolling text in comment box, we want to be aware. -var detect_scrolling = function(node, bp) { - if(node == bp.element || node == bp.element) return false; - else if(node._wgo_scrollable || (node.scrollHeight > node.offsetHeight)) return true; - else return detect_scrolling(node.parentNode, bp); -} - -// mouse wheel event callback, for replaying a game -var wheel_lis = function(e) { - var delta = e.wheelDelta || e.detail*(-1); - - // if there is scrolling in progress within an element, don't change position - if(detect_scrolling(e.target, this)) return true; - - if(delta < 0) { - this.next(); - if(this.config.lockScroll && e.preventDefault) e.preventDefault(); - return !this.config.lockScroll; - } - else if(delta > 0) { - this.previous(); - if(this.config.lockScroll && e.preventDefault) e.preventDefault(); - return !this.config.lockScroll; - } - return true; -}; - -// keyboard click callback, for replaying a game -var key_lis = function(e) { - switch(e.keyCode) { - case 39: this.next(); break; - case 37: this.previous(); break; - //case 40: this.selectAlternativeVariation(); break; - default: return true; - } - if(this.config.lockScroll && e.preventDefault) e.preventDefault() - return !this.config.lockScroll; -}; - -// function handling board clicks in normal mode -var board_click_default = function(x,y) { - if(!this.kifuReader || !this.kifuReader.node) return false; - for(var i in this.kifuReader.node.children) { - if(this.kifuReader.node.children[i].move && this.kifuReader.node.children[i].move.x == x && this.kifuReader.node.children[i].move.y == y) { - this.next(i); - return; - } - } -} - -// coordinates drawing handler - adds coordinates on the board -var coordinates = { - grid: { - draw: function(args, board) { - var ch, t, xright, xleft, ytop, ybottom; - - this.fillStyle = "rgba(0,0,0,0.7)"; - this.textBaseline="middle"; - this.textAlign="center"; - this.font = board.stoneRadius+"px "+(board.font || ""); - - xright = board.getX(-0.75); - xleft = board.getX(board.size-0.25); - ytop = board.getY(-0.75); - ybottom = board.getY(board.size-0.25); - - for(var i = 0; i < board.size; i++) { - ch = i+"A".charCodeAt(0); - if(ch >= "I".charCodeAt(0)) ch++; - - t = board.getY(i); - this.fillText(board.size-i, xright, t); - this.fillText(board.size-i, xleft, t); - - t = board.getX(i); - this.fillText(String.fromCharCode(ch), t, ytop); - this.fillText(String.fromCharCode(ch), t, ybottom); - } - - this.fillStyle = "black"; - } - } -} - -/** - * We can say this class is abstract, stand alone it doesn't do anything. - * However it is useful skelet for building actual player's GUI. Extend this class to create custom player template. - * It controls board and inputs from mouse and keyboard, but everything can be overriden. - * - * Possible configurations: - * - sgf: sgf string (default: undefined) - * - json: kifu stored in json/jgo (default: undefined) - * - sgfFile: sgf file path (default: undefined) - * - board: configuration object of board (default: {}) - * - enableWheel: allow player to be controlled by mouse wheel (default: true) - * - lockScroll: disable window scrolling while hovering player (default: true), - * - enableKeys: allow player to be controlled by arrow keys (default: true), - * - * @param {object} config object if form: {key1: value1, key2: value2, ...} - */ - -var Player = function(config) { - this.config = config; - - // add default configuration - for(var key in PlayerView.default) if(this.config[key] === undefined && PlayerView.default[key] !== undefined) this.config[key] = PlayerView.default[key]; - - this.element = document.createElement("div"); - this.board = new WGo.Board(this.element, this.config.board); - - this.init(); - this.initGame(); -} - -Player.prototype = { - constructor: Player, - - /** - * Init player. If you want to call this method PlayerView object must have these properties: - * - player - WGo.Player object - * - board - WGo.Board object (or other board renderer) - * - element - main DOMElement of player - */ - - init: function() { - // declare kifu - this.kifu = null; - - // creating listeners - this.listeners = { - kifuLoaded: [prepare_board.bind(this)], - update: [update_board.bind(this)], - frozen: [], - unfrozen: [], - }; - - if(this.config.kifuLoaded) this.addEventListener("kifuLoaded", this.config.kifuLoaded); - if(this.config.update) this.addEventListener("update", this.config.update); - if(this.config.frozen) this.addEventListener("frozen", this.config.frozen); - if(this.config.unfrozen) this.addEventListener("unfrozen", this.config.unfrozen); - - this.board.addEventListener("click", board_click_default.bind(this)); - this.element.addEventListener("click", this.focus.bind(this)); - - this.focus(); - }, - - initGame: function() { - // try to load game passed in configuration - if(this.config.sgf) { - this.loadSgf(this.config.sgf, this.config.move); - } - else if(this.config.json) { - this.loadJSON(this.config.json, this.config.move); - } - else if(this.config.sgfFile) { - this.loadSgfFromFile(this.config.sgfFile, this.config.move); - } - - }, - - /** - * Create update event and dispatch it. It is called after position's changed. - * - * @param {string} op an operation that produced update (e.g. next, previous...) - */ - - update: function(op) { - if(!this.kifuReader || !this.kifuReader.change) return; - - var ev = { - type: "update", - op: op, - target: this, - node: this.kifuReader.node, - position: this.kifuReader.getPosition(), - path: this.kifuReader.path, - change: this.kifuReader.change, - } - - //if(!this.kifuReader.node.parent) ev.msg = this.getGameInfo(); - - this.dispatchEvent(ev); - }, - - /** - * Prepare kifu for replaying. Event 'kifuLoaded' is triggered. - * - * @param {WGo.Kifu} kifu object - * @param {Array} path array - */ - - loadKifu: function(kifu, path) { - this.kifu = kifu; - - // kifu is replayed by KifuReader, it manipulates a Kifu object and gets all changes - this.kifuReader = new WGo.KifuReader(this.kifu, this.config.rememberPath); - - // fire kifu loaded event - this.dispatchEvent({ - type: "kifuLoaded", - target: this, - kifu: this.kifu, - }); - - // handle permalink - /*if(this.config.permalinks) { - if(!permalinks.active) init_permalinks(); - if(permalinks.query.length && permalinks.query[0] == this.view.element.id) { - handle_hash(this); - } - }*/ - - if(path) { - this.goTo(path); - } - else { - // update player - initial position in kifu doesn't have to be an empty board - this.update("init"); - } - - /*if(this.kifu.nodeCount === 0) this.error(""); - else if(this.kifu.propertyCount === 0)*/ - - }, - - /** - * Load go kifu from sgf string. - * - * @param {string} sgf - */ - - loadSgf: function(sgf, path) { - try { - this.loadKifu(WGo.Kifu.fromSgf(sgf), path); - } - catch(err) { - this.error(err); - } - }, - - /** - * Load go kifu from JSON object. - */ - - loadJSON: function(json) { - try { - this.loadKifu(WGo.Kifu.fromJGO(json), path); - } - catch(err) { - this.error(err); - } - }, - - /** - * Load kifu from sgf file specified with path. AJAX is used to load sgf content. - */ - - loadSgfFromFile: function(file_path, game_path) { - var _this = this; - try { - loadFromUrl(file_path, function(sgf) { - _this.loadSgf(sgf, game_path); - }); - } - catch(err) { - this.error(err); - } - }, - - /** - * Implementation of EventTarget interface, though it's a little bit simplified. - * You need to save listener if you would like to remove it later. - * - * @param {string} type of listeners - * @param {Function} listener callback function - */ - - addEventListener: function(type, listener) { - this.listeners[type] = this.listeners[type] || []; - this.listeners[type].push(listener); - }, - - /** - * Remove event listener previously added with addEventListener. - * - * @param {string} type of listeners - * @param {Function} listener function - */ - - removeEventListener: function(type, listener) { - if(!this.listeners[type]) return; - var i = this.listeners[type].indexOf(listener); - if(i != -1) this.listeners[type].splice(i,1); - }, - - /** - * Dispatch an event. In default there are two events: "kifuLoaded" and "update" - * - * @param {string} evt event - */ - - dispatchEvent: function(evt) { - if(!this.listeners[evt.type]) return; - for(var l in this.listeners[evt.type]) this.listeners[evt.type][l](evt); - }, - - /** - * Output function for notifications. - */ - - notification: function(text) { - if(console) console.log(text); - }, - - /** - * Output function for helps. - */ - - help: function(text) { - if(console) console.log(text); - }, - - /** - * Output function for errors. TODO: reporting of errors - by cross domain AJAX - */ - - error: function(err) { - if(!WGo.ERROR_REPORT) throw err; - - if(console) console.log(err); - - }, - - /** - * Play next move. - * - * @param {number} i if there is more option, you can specify it by index - */ - - next: function(i) { - if(this.frozen || !this.kifu) return; - - try { - this.kifuReader.next(i); - this.update(); - } - catch(err) { - this.error(err); - } - }, - - /** - * Get previous position. - */ - - previous: function() { - if(this.frozen || !this.kifu) return; - - try{ - this.kifuReader.previous(); - this.update(); - } - catch(err) { - this.error(err); - } - }, - - /** - * Play all moves and get last position. - */ - - last: function() { - if(this.frozen || !this.kifu) return; - - try { - this.kifuReader.last(); - this.update(); - } - catch(err) { - this.error(err); - } - }, - - /** - * Get a first position. - */ - - first: function() { - if(this.frozen || !this.kifu) return; - - try { - this.kifuReader.first(); - this.update(); - } - catch(err) { - this.error(err); - } - }, - - /** - * Go to a specified move. - * - * @param {number|Array} move number of move, or path array - */ - - goTo: function(move) { - if(this.frozen || !this.kifu) return; - var path; - if(typeof move == "function") move = move.call(this); - - if(typeof move == "number") { - path = WGo.clone(this.kifuReader.path); - path.m = move || 0; - } - else path = move; - - try { - this.kifuReader.goTo(path); - this.update(); - } - catch(err) { - this.error(err); - } - }, - - /** - * Get information about actual game(kifu) - * - * @return {Object} game info - */ - - getGameInfo: function() { - if(!this.kifu) return null; - var info = {}; - for(var key in this.kifu.info) { - if(WGo.Kifu.infoList.indexOf(key) == -1) continue; - if(WGo.Kifu.infoFormatters[key]) { - info[WGo.t(key)] = WGo.Kifu.infoFormatters[key](this.kifu.info[key]); - } - else info[WGo.t(key)] = WGo.filterHTML(this.kifu.info[key]); - } - return info; - }, - - /** - * Freeze or onfreeze player. In frozen state methods: next, previous etc. don't work. - */ - - setFrozen: function(frozen) { - this.frozen = frozen; - this.dispatchEvent({ - type: this.frozen ? "frozen" : "unfrozen", - target: this, - }); - }, - - /** - * Append player to given element. - */ - - appendTo: function(elem) { - elem.appendChild(this.element); - }, - - /** - * Get focus on the player - */ - - focus: function() { - if(this.config.enableKeys) this.setKeys(true); - }, - - /** - * Set controlling of player by arrow keys. - */ - - setKeys: function(b) { - if(b) { - if(WGo.mozilla) document.onkeypress = key_lis.bind(this); - else document.onkeydown = key_lis.bind(this); - } - else { - if(WGo.mozilla) document.onkeypress = null; - else document.onkeydown = null; - } - }, - - /** - * Set controlling of player by mouse wheel. - */ - - setWheel: function(b) { - if(!this._wheel_listener && b) { - this._wheel_listener = wheel_lis.bind(this); - var type = WGo.mozilla ? "DOMMouseScroll" : "mousewheel"; - this.element.addEventListener(type, this._wheel_listener); - } - else if(this._wheel_listener && !b) { - var type = WGo.mozilla ? "DOMMouseScroll" : "mousewheel"; - this.element.removeEventListener(type, this._wheel_listener); - delete this._wheel_listener; - } - }, - - /** - * Toggle coordinates around the board. - */ - - setCoordinates: function(b) { - if(!this.coordinates && b) { - this.board.setSection(-0.5, -0.5, -0.5, -0.5); - this.board.addCustomObject(coordinates); - } - else if(this.coordinates && !b) { - this.board.setSection(0, 0, 0, 0); - this.board.removeCustomObject(coordinates); - } - this.coordinates = b; - }, - -} - -Player.default = { - sgf: undefined, - json: undefined, - sgfFile: undefined, - move: undefined, - board: {}, - enableWheel: true, - lockScroll: true, - enableKeys: true, - rememberPath: true, - kifuLoaded: undefined, - update: undefined, - frozen: undefined, - unfrozen: undefined, -} - -WGo.Player = Player; - -//--- i18n support ------------------------------------------------------------------------------------------ - -/** - * For another language support, extend this object with similiar object. - */ - -var player_terms = { - "about-text": "

WGo.js Player 2.0

" - + "

WGo.js Player is extension of WGo.js, HTML5 library for purposes of game of go. It allows to replay go game records and it has many features like score counting. It is also designed to be easily extendable.

" - + "

WGo.js is open source licensed under MIT license. You can use and modify any code from this project.

" - + "

You can find more information at wgo.waltheri.net/player

" - + "

Copyright © 2013 Jan Prokop

", - "black": "Black", - "white": "White", - "DT": "Date", - "KM": "Komi", - "HA": "Handicap", - "AN": "Annotations", - "CP": "Copyright", - "OT": "Overtime", - "TM": "Basic time", - "RE": "Result", - "RU": "Rules", - "PC": "Place", - "EV": "Event", - "SO": "Source", - "none": "none", - "bpass": "Black passed.", - "wpass": "White passed.", -}; - -for(var key in player_terms) WGo.i18n.en[key] = player_terms[key]; - -})(WGo); diff --git a/interface/server/wgo/player.permalink.js b/interface/server/wgo/player.permalink.js deleted file mode 100644 index 07cd4b1bc..000000000 --- a/interface/server/wgo/player.permalink.js +++ /dev/null @@ -1,76 +0,0 @@ -(function(WGo, undefined) { - -"use strict"; - -var permalink = { - active: true, - query: {}, -}; - -var handle_hash = function(player) { - try { - permalink.query = JSON.parse('{"'+window.location.hash.substr(1).replace('=', '":')+'}'); - } - catch(e) { - permalink.query = {}; - } -} - -// add hashchange event -window.addEventListener("hashchange", function() { - if(window.location.hash != "" && permalink.active) { - handle_hash(); - - for(var key in permalink.query) { - var p_el = document.getElementById(key); - if(p_el && p_el._wgo_player) p_el._wgo_player.goTo(move_from_hash); - } - } -}); - -// save hash query (after DOM is loaded - you can turn this off by setting WGo.Player.permalink.active = false; -window.addEventListener("DOMContentLoaded", function() { - if(window.location.hash != "" && permalink.active) { - handle_hash(); - } -}); - -// scroll into view of the board -window.addEventListener("load", function() { - if(window.location.hash != "" && permalink.active) { - for(var key in permalink.query) { - var p_el = document.getElementById(key); - if(p_el && p_el._wgo_player) { - p_el.scrollIntoView(); - break; - } - } - } -}); - -var move_from_hash = function() { - if(permalink.query[this.element.id]) { - return permalink.query[this.element.id].goto; - } -} - -WGo.Player.default.move = move_from_hash; - -// add menu item -if(WGo.BasicPlayer && WGo.BasicPlayer.component.Control) { - WGo.BasicPlayer.component.Control.menu.push({ - constructor: WGo.BasicPlayer.control.MenuItem, - args: { - name: "permalink", - click: function(player) { - var link = location.href.split("#")[0]+'#'+player.element.id+'={"goto":'+JSON.stringify(player.kifuReader.path)+'}'; - player.showMessage('

'+WGo.t('permalink')+'

'); - }, - } - }); -} - -WGo.Player.permalink = permalink; -WGo.i18n.en["permalink"] = "Permanent link"; - -})(WGo); \ No newline at end of file diff --git a/interface/server/wgo/scoremode.js b/interface/server/wgo/scoremode.js deleted file mode 100644 index 2f534c09b..000000000 --- a/interface/server/wgo/scoremode.js +++ /dev/null @@ -1,225 +0,0 @@ - -(function(WGo){ - -var ScoreMode = function(position, board, komi, output) { - this.originalPosition = position; - this.position = position.clone(); - this.board = board; - this.komi = komi; - this.output = output; -} - -var state = ScoreMode.state = { - UNKNOWN: 0, - BLACK_STONE: 1, // must be equal to WGo.B - WHITE_STONE: -1, // must be equal to WGo.W - BLACK_CANDIDATE: 2, - WHITE_CANDIDATE: -2, - BLACK_NEUTRAL: 3, - WHITE_NEUTRAL: -3, - NEUTRAL: 4, -} - -var territory_set = function(pos, x, y, color, margin) { - var p = pos.get(x, y); - if(p === undefined || p == color || p == margin) return; - - pos.set(x, y, color); - - territory_set(pos, x-1, y, color, margin); - territory_set(pos, x, y-1, color, margin); - territory_set(pos, x+1, y, color, margin); - territory_set(pos, x, y+1, color, margin); -} - -var territory_reset = function(pos, orig, x, y, margin) { - var o = orig.get(x, y); - if(pos.get(x, y) == o) return; - - pos.set(x, y, o); - territory_reset(pos, orig, x-1, y, margin); - territory_reset(pos, orig, x, y-1, margin); - territory_reset(pos, orig, x+1, y, margin); - territory_reset(pos, orig, x, y+1, margin); -} - -ScoreMode.prototype.start = function() { - this.calculate(); - this.saved_state = this.board.getState(); - this.displayScore(); - - this._click = (function(x,y) { - var c = this.originalPosition.get(x,y); - - if(c == WGo.W) { - if(this.position.get(x, y) == state.WHITE_STONE) territory_set(this.position, x, y, state.BLACK_CANDIDATE, state.BLACK_STONE); - else { - territory_reset(this.position, this.originalPosition, x, y, state.BLACK_STONE); - this.calculate(); - } - } - else if(c == WGo.B) { - if(this.position.get(x, y) == state.BLACK_STONE) territory_set(this.position, x, y, state.WHITE_CANDIDATE, state.WHITE_STONE); - else { - territory_reset(this.position, this.originalPosition, x, y, state.WHITE_STONE); - this.calculate(); - } - } - else { - var p = this.position.get(x, y); - - if(p == state.BLACK_CANDIDATE) this.position.set(x, y, state.BLACK_NEUTRAL); - else if(p == state.WHITE_CANDIDATE) this.position.set(x, y, state.WHITE_NEUTRAL); - else if(p == state.BLACK_NEUTRAL) this.position.set(x, y, state.BLACK_CANDIDATE); - else if(p == state.WHITE_NEUTRAL) this.position.set(x, y, state.WHITE_CANDIDATE); - } - - this.board.restoreState({objects: WGo.clone(this.saved_state.objects)}); - this.displayScore(); - }).bind(this); - - this.board.addEventListener("click", this._click); -} - -ScoreMode.prototype.end = function() { - this.board.restoreState({objects: WGo.clone(this.saved_state.objects)}); - this.board.removeEventListener("click", this._click); -} - -ScoreMode.prototype.displayScore = function() { - var score = { - black: [], - white: [], - neutral: [], - black_captured: [], - white_captured: [], - } - - for(var i = 0; i < this.position.size; i++) { - for(var j = 0; j < this.position.size; j++) { - s = this.position.get(i,j); - t = this.originalPosition.get(i,j); - - if(s == state.BLACK_CANDIDATE) score.black.push({x: i, y: j, type: "mini", c: WGo.B}); - else if(s == state.WHITE_CANDIDATE) score.white.push({x: i, y: j, type: "mini", c: WGo.W}); - else if(s == state.NEUTRAL) score.neutral.push({x: i, y: j}); - - if(t == WGo.W && s != state.WHITE_STONE) score.white_captured.push({x: i, y: j, type: "outline", c: WGo.W}); - else if(t == WGo.B && s != state.BLACK_STONE) score.black_captured.push({x: i, y: j, type: "outline", c: WGo.B}); - } - } - - for(var i = 0; i < score.black_captured.length; i++) { - this.board.removeObjectsAt(score.black_captured[i].x, score.black_captured[i].y); - } - - for(var i = 0; i < score.white_captured.length; i++) { - this.board.removeObjectsAt(score.white_captured[i].x, score.white_captured[i].y); - } - - this.board.addObject(score.white_captured); - this.board.addObject(score.black_captured); - this.board.addObject(score.black); - this.board.addObject(score.white); - - var msg = "

"+WGo.t("RE")+"

"; - - var sb = score.black.length+score.white_captured.length+this.originalPosition.capCount.black; - var sw = score.white.length+score.black_captured.length+this.originalPosition.capCount.white+parseFloat(this.komi); - - msg += "

"+WGo.t("black")+": "+score.black.length+" + "+(score.white_captured.length+this.originalPosition.capCount.black)+" = "+sb+"
"; - msg += WGo.t("white")+": "+score.white.length+" + "+(score.black_captured.length+this.originalPosition.capCount.white)+" + "+this.komi+" = "+sw+"

"; - - if(sb > sw) msg += "

"+WGo.t("bwin", sb-sw)+"

"; - else msg += "

"+WGo.t("wwin", sw-sb)+"

"; - - this.output(msg); -} - -ScoreMode.prototype.calculate = function() { - var p, s, t, b, w, change; - - // 1. create testing position, empty fields has flag ScoreMode.UNKNOWN - p = this.position; - - // 2. repeat until there is some change of state: - change = true; - while(change) { - change = false; - - // go through the whole position - for(var i = 0; i < p.size; i++) { - //var str = ""; - for(var j = 0; j < p.size; j++) { - s = p.get(j,i); - - if(s == state.UNKNOWN || s == state.BLACK_CANDIDATE || s == state.WHITE_CANDIDATE) { - // get new state - t = [p.get(j-1, i), p.get(j, i-1), p.get(j+1, i), p.get(j, i+1)]; - b = false; - w = false; - - for(var k = 0; k < 4; k++) { - if(t[k] == state.BLACK_STONE || t[k] == state.BLACK_CANDIDATE) b = true; - else if(t[k] == state.WHITE_STONE || t[k] == state.WHITE_CANDIDATE) w = true; - else if(t[k] == state.NEUTRAL) { - b = true; - w = true; - } - } - - t = false; - - if(b && w) t = state.NEUTRAL; - else if(b) t = state.BLACK_CANDIDATE; - else if(w) t = state.WHITE_CANDIDATE; - - if(t && s != t) { - change = true; - p.set(j, i, t); - } - } - //str += (p.get(j,i)+5)+" "; - } - //console.log(str); - } - //console.log("------------------------------------------------------------"); - } -} - -WGo.ScoreMode = ScoreMode; - -if(WGo.BasicPlayer && WGo.BasicPlayer.component.Control) { - WGo.BasicPlayer.component.Control.menu.push({ - constructor: WGo.BasicPlayer.control.MenuItem, - args: { - name: "scoremode", - togglable: true, - click: function(player) { - if(this.selected) { - player.setFrozen(false); - this._score_mode.end(); - delete this._score_mode; - player.notification(); - player.help(); - return false; - } - else { - player.setFrozen(true); - player.help("

"+WGo.t("help_score")+"

"); - this._score_mode = new WGo.ScoreMode(player.kifuReader.game.position, player.board, player.kifu.info.KM || 0.5, player.notification); - this._score_mode.start(); - return true; - } - }, - } - }); -} - -WGo.i18n.en["scoremode"] = "Count score"; -WGo.i18n.en["score"] = "Score"; -WGo.i18n.en["bwin"] = "Black wins by $ points."; -WGo.i18n.en["wwin"] = "White wins by $ points."; -WGo.i18n.en["help_score"] = "Click on stones to mark them dead or alive. You can also set and unset territory points by clicking on them. Territories must be completely bordered."; - -})(WGo); diff --git a/interface/server/wgo/sgfparser.js b/interface/server/wgo/sgfparser.js deleted file mode 100644 index eeccc31d6..000000000 --- a/interface/server/wgo/sgfparser.js +++ /dev/null @@ -1,159 +0,0 @@ - -(function(WGo, undefined){ - -WGo.SGF = {}; - -var to_num = function(str, i) { - return str.charCodeAt(i)-97; -} - -var sgf_player_info = function(type, black, kifu, node, value, ident) { - var c = ident == black ? "black" : "white"; - kifu.info[c] = kifu.info[c] || {}; - kifu.info[c][type] = value[0]; -} - -// handling properties specifically -var properties = WGo.SGF.properties = {} - -// Move properties -properties["B"] = properties["W"] = function(kifu, node, value, ident) { - if(!value[0] || (kifu.size <= 19 && value[0] == "tt")) node.move = { - pass: true, - c: ident == "B" ? WGo.B : WGo.W - }; - else node.move = { - x: to_num(value[0], 0), - y: to_num(value[0], 1), - c: ident == "B" ? WGo.B : WGo.W - }; -} - -// Setup properties -properties["AB"] = properties["AW"] = function(kifu, node, value, ident) { - for(var i in value) { - node.addSetup({ - x: to_num(value[i], 0), - y: to_num(value[i], 1), - c: ident == "AB" ? WGo.B : WGo.W - }); - } -} -properties["AE"] = function(kifu, node, value) { - for(var i in value) { - node.addSetup({ - x: to_num(value[i], 0), - y: to_num(value[i], 1), - }); - } -} -properties["PL"] = function(kifu, node, value) { - node.turn = value[0] == "b" ? WGo.B : WGo.W; -} - -// Node annotation properties -properties["C"] = function(kifu, node, value) { - node.comment = value.join(); -} - -// Markup properties -properties["LB"] = function(kifu, node, value) { - for(var i in value) { - node.addMarkup({ - x: to_num(value[i],0), - y: to_num(value[i],1), - type: "LB", - text: value[i].substr(3) - }); - } -} -properties["CR"] = properties["SQ"] = properties["TR"] = properties["SL"] = properties["MA"] = function(kifu, node, value, ident) { - for(var i in value) { - node.addMarkup({ - x: to_num(value[i],0), - y: to_num(value[i],1), - type: ident - }); - } -} - -// Root properties -properties["SZ"] = function(kifu, node, value) { - kifu.size = parseInt(value[0]); -} - -// Game info properties -properties["BR"] = properties["WR"] = sgf_player_info.bind(this, "rank", "BR"); -properties["PB"] = properties["PW"] = sgf_player_info.bind(this, "name", "PB"); -properties["BT"] = properties["WT"] = sgf_player_info.bind(this, "team", "BT"); -properties["TM"] = function(kifu, node, value, ident) { - kifu.info[ident] = value[0]; - node.BL = value[0]; - node.WL = value[0]; -} - -var reg_seq = /\(|\)|(;(\s*[A-Z]+\s*((\[\])|(\[(.|\s)*?([^\\]\])))+)*)/g; -var reg_node = /[A-Z]+\s*((\[\])|(\[(.|\s)*?([^\\]\])))+/g; -var reg_ident = /[A-Z]+/; -var reg_props = /(\[\])|(\[(.|\s)*?([^\\]\]))/g; - -// parse SGF string, return WGo.Kifu object -WGo.SGF.parse = function(str) { - - var stack = [], - sequence, props, vals, ident, - kifu = new WGo.Kifu(), - node = null; - - // make sequence of elements and process it - sequence = str.match(reg_seq); - - for(var i in sequence) { - // push stack, if new variant - if(sequence[i] == "(") stack.push(node); - - // pop stack at the end of variant - else if(sequence[i] == ")") node = stack.pop(); - - // reading node (string starting with ';') - else { - // create node or use root - if(node) kifu.nodeCount++; - node = node ? node.appendChild(new WGo.KNode()) : kifu.root; - - // make array of properties - props = sequence[i].match(reg_node); - kifu.propertyCount += props.length; - - // insert all properties to node - for(var j in props) { - // get property's identificator - ident = reg_ident.exec(props[j])[0]; - - // separate property's values - vals = props[j].match(reg_props); - - // remove additional braces [ and ] - for(var k in vals) vals[k] = vals[k].substring(1, vals[k].length-1).replace(/\\(?!\\)/g, ""); - - // call property handler if any - if(WGo.SGF.properties[ident]) WGo.SGF.properties[ident](kifu, node, vals, ident); - else { - // if there is only one property, strip array - if(vals.length <= 1) vals = vals[0]; - - // default node property saving - if(node.parent) node[ident] = vals; - - // default root property saving - else { - kifu.info[ident] = vals; - } - } - } - } - } - - return kifu; -} -})(WGo); \ No newline at end of file diff --git a/interface/server/wgo/wgo.js b/interface/server/wgo/wgo.js deleted file mode 100644 index c858a1790..000000000 --- a/interface/server/wgo/wgo.js +++ /dev/null @@ -1,1531 +0,0 @@ -/*! - * Copyright (c) 2013 Jan Prokop - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of this - * software and associated documentation files (the "Software"), to deal in the Software - * without restriction, including without limitation the rights to use, copy, modify, merge, - * publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons - * to whom the Software is furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all copies or - * substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING - * BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, - * DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -(function(window, undefined) { - -"use strict"; - -var scripts= document.getElementsByTagName('script'); -var path= scripts[scripts.length-1].src.split('?')[0]; // remove any ?query -var mydir= path.split('/').slice(0, -1).join('/')+'/'; - -/** - * Main namespace - it initializes WGo in first run and then execute main function. - * You must call WGo.init() if you want to use library, without calling WGo. - */ - -var WGo = { - // basic information - version: "2.0", - - // constants for colors (rather use WGo.B or WGo.W) - B: 1, - W: -1, - - // if true errors will be shown in dialog window, otherwise they will be ignored - ERROR_REPORT: true, - DIR: mydir, - - // Language of player, you can change this global variable any time. Object WGo.i18n. must exist. - lang: "en", - - // Add terms for each language here - i18n: { - en: {} - } -} - -// browser detection - can be handy -WGo.opera = navigator.userAgent.search(/(opera)(?:.*version)?[ \/]([\w.]+)/i) != -1; -WGo.webkit = navigator.userAgent.search(/(webkit)[ \/]([\w.]+)/i) != -1; -WGo.msie = navigator.userAgent.search(/(msie) ([\w.]+)/i) != -1; -WGo.mozilla = navigator.userAgent.search(/(mozilla)(?:.*? rv:([\w.]+))?/i) != -1 && !WGo.webkit && !WGo.msie; - -// translating function -WGo.t = function(str) { - var loc = WGo.i18n[WGo.lang][str] || WGo.i18n.en[str]; - if(loc) { - for(var i = 1; i < arguments.length; i++) { - loc = loc.replace("$", arguments[i]); - } - return loc; - } - return str; -} - -// helping function for class inheritance -WGo.extendClass = function(parent, child) { - child.prototype = Object.create(parent.prototype); - child.prototype.constructor = child; - child.prototype.super = parent; - - return child; -}; - -// helping function for class inheritance -WGo.abstractMethod = function() { - throw Error('unimplemented abstract method'); -}; - -// helping function for deep cloning of simple objects, -WGo.clone = function(obj) { - if(obj && typeof obj == "object") { - var n_obj = obj.constructor == Array ? [] : {}; - - for(var key in obj) { - if(obj[key] == obj) n_obj[key] = obj; - else n_obj[key] = WGo.clone(obj[key]); - } - - return n_obj; - } - else return obj; -} - -// filter html to avoid XSS -WGo.filterHTML = function(text) { - if(!text || typeof text != "string") return text; - return text.replace("<", "<").replace(">", ">"); -} - -//---------------------- WGo.Board ----------------------------------------------------------------------------- - -/** - * Board class constructor - it creates a canvas board - * - * @param elem DOM element to put in - * @param config configuration object. It is object with "key: value" structure. Possible configurations are: - * - * - size: number - size of the board (default: 19) - * - width: number - width of the board (default: 0) - * - height: number - height of the board (default: 0) - * - font: string - font of board writings (default: "Calibri") - * - lineWidth: number - line width of board drawings - * - starPoints: Object - star points coordinates, defined for various board sizes. Look at Board.default for more info. - * - stoneHandler: Board.DrawHandler - stone drawing handler (default: Board.drawHandlers.NORMAL) - * - starSize: number - size of star points (default: 1). Radius of stars is dynamic, however you can modify it by given constant. - * - stoneSize: number - size of stone (default: 1). Radius of stone is dynamic, however you can modify it by given constant. - * - shadowSize: number - size of stone shadow (default: 1). Radius of shadow is dynamic, however you can modify it by given constant. - * - background: string - background of the board, it can be either color (#RRGGBB) or url. Empty string means no background. (default: WGo.DIR+"wood1.jpg") - * - section: { - * top: number, - * right: number, - * bottom: number, - * left: number - * } - * It defines a section of board to be displayed. You can set a number of rows(or cols) to be skipped on each side. - * Numbers can be negative, in that case there will be more empty space. In default all values are zeros. - */ - -var Board = function(elem, config) { - var config = config || {}; - - // set user configuration - for(var key in config) this[key] = config[key]; - - // add default configuration - for(var key in WGo.Board.default) if(this[key] === undefined) this[key] = WGo.Board.default[key]; - - // set section if set - this.tx = this.section.left; - this.ty = this.section.top; - this.bx = this.size-1-this.section.right; - this.by = this.size-1-this.section.bottom; - - // init board - this.init(); - - // append to element - elem.appendChild(this.element); - - // set initial dimensions - if(this.width && this.height) this.setDimensions(this.width, this.height); - else if(this.width) this.setWidth(this.width); - else if(this.height) this.setHeight(this.height); -} - -var shadow_handler = { - draw: function(args, board) { - var xr = board.getX(args.x), - yr = board.getY(args.y), - sr = board.stoneRadius; - - this.beginPath(); - this.fillStyle = 'rgba(32,32,32,0.5)'; - this.arc(xr-0.5, yr-0.5, sr-0.5, 0, 2*Math.PI, true); - this.fill(); - } -} - -var get_markup_color = function(board, x, y) { - if(board.obj_arr[x][y][0].c == WGo.B) return "white"; - return "black"; -} - -var redraw_layer = function(board, layer) { - var handler; - - board[layer].clear(); - board[layer].draw(board); - - for(var x = 0; x < board.size; x++) { - for(var y = 0; y < board.size; y++) { - for(var key in board.obj_arr[x][y]) { - if(!board.obj_arr[x][y][key].type) handler = board.stoneHandler; - else if(typeof board.obj_arr[x][y][key].type == "string") handler = Board.drawHandlers[board.obj_arr[x][y][key].type]; - else handler = board.obj_arr[x][y][key].type; - - if(handler[layer]) handler[layer].draw.call(board[layer].context, board.obj_arr[x][y][key], board); - } - } - } - - for(var key in board.obj_list) { - var handler = board.obj_list[key].handler; - - if(handler[layer]) handler[layer].draw.call(board[layer].context, board.obj_list[key].args, board); - } -} - -// drawing handlers - -Board.drawHandlers = { - // handler for normal stones - NORMAL: { - // draw handler for stone layer - stone: { - // drawing function - args object contain info about drawing object, board is main board object - // this function is called from canvas2D context - draw: function(args, board) { - var xr = board.getX(args.x), - yr = board.getY(args.y), - sr = board.stoneRadius, - radgrad; - - // set stone texture - if(args.c == WGo.W) { - radgrad = this.createRadialGradient(xr-2*sr/5,yr-2*sr/5,sr/3,xr-sr/5,yr-sr/5,5*sr/5); - radgrad.addColorStop(0, '#fff'); - //radgrad.addColorStop(1, '#d4d4d4'); - radgrad.addColorStop(1, '#aaa'); - } - else { - radgrad = this.createRadialGradient(xr-2*sr/5,yr-2*sr/5,1,xr-sr/5,yr-sr/5,4*sr/5); - radgrad.addColorStop(0, '#666'); - radgrad.addColorStop(1, '#000'); - } - - // paint stone - this.beginPath(); - this.fillStyle = radgrad; - this.arc(xr-0.5, yr-0.5, sr-0.5, 0, 2*Math.PI, true); - this.fill(); - } - }, - // adding shadow handler - shadow: shadow_handler, - }, - - PAINTED: { - stone: { - draw: function(args, board) { - var xr = board.getX(args.x), - yr = board.getY(args.y), - sr = board.stoneRadius, - radgrad; - - if(args.c == WGo.W) { - radgrad = this.createRadialGradient(xr-2*sr/5,yr-2*sr/5,2,xr-sr/5,yr-sr/5,4*sr/5); - radgrad.addColorStop(0, '#fff'); - radgrad.addColorStop(1, '#ddd'); - } - else { - radgrad = this.createRadialGradient(xr-2*sr/5,yr-2*sr/5,1,xr-sr/5,yr-sr/5,4*sr/5); - radgrad.addColorStop(0, '#111'); - radgrad.addColorStop(1, '#000'); - } - - this.beginPath(); - this.fillStyle = radgrad; - this.arc(xr-0.5, yr-0.5, sr-0.5, 0, 2*Math.PI, true); - this.fill(); - - this.beginPath(); - this.lineWidth = sr/6; - - if(args.c == WGo.W) { - this.strokeStyle = '#999'; - this.arc(xr+sr/8, yr+sr/8, sr/2, 0, Math.PI/2, false); - } - else { - this.strokeStyle = '#ccc'; - this.arc(xr-sr/8, yr-sr/8, sr/2, Math.PI, 1.5*Math.PI); - } - - this.stroke(); - } - }, - shadow: shadow_handler, - }, - - GLOW: { - stone: { - draw: function(args, board) { - var xr = board.getX(args.x), - yr = board.getY(args.y), - sr = board.stoneRadius; - - var radgrad; - if(args.c == WGo.W) { - radgrad = this.createRadialGradient(xr-2*sr/5,yr-2*sr/5,sr/3,xr-sr/5,yr-sr/5,8*sr/5); - radgrad.addColorStop(0, '#fff'); - radgrad.addColorStop(1, '#666'); - } - else { - radgrad = this.createRadialGradient(xr-2*sr/5,yr-2*sr/5,1,xr-sr/5,yr-sr/5,3*sr/5); - radgrad.addColorStop(0, '#555'); - radgrad.addColorStop(1, '#000'); - } - - this.beginPath(); - this.fillStyle = radgrad; - this.arc(xr-0.5, yr-0.5, sr-0.5, 0, 2*Math.PI, true); - this.fill(); - }, - }, - shadow: shadow_handler, - }, - - MONO: { - stone: { - draw: function(args, board) { - var xr = board.getX(args.x), - yr = board.getY(args.y), - sr = board.stoneRadius, - lw = board.lineWidth || 1; - - if(args.c == WGo.W) this.fillStyle = "white"; - else this.fillStyle = "black"; - - this.beginPath(); - this.arc(xr, yr, sr-lw, 0, 2*Math.PI, true); - this.fill(); - - this.lineWidth = lw; - this.strokeStyle = "black"; - this.stroke(); - } - }, - }, - - CR: { - stone: { - draw: function(args, board) { - var xr = board.getX(args.x), - yr = board.getY(args.y), - sr = board.stoneRadius; - - this.strokeStyle = args.c || get_markup_color(board, args.x, args.y); - this.lineWidth = args.lineWidth || board.lineWidth || 1; - this.beginPath(); - this.arc(xr-0.5, yr-0.5, sr/2, 0, 2*Math.PI, true); - this.stroke(); - }, - }, - }, - - // Label drawing handler - LB: { - stone: { - draw: function(args, board) { - var xr = board.getX(args.x), - yr = board.getY(args.y), - sr = board.stoneRadius, - font = args.font || board.font || ""; - - if(board.obj_arr[args.x][args.y][0].c == WGo.W) this.fillStyle = "black"; - else if(board.obj_arr[args.x][args.y][0].c == WGo.B) this.fillStyle = "white"; - else this.fillStyle = "#1C1C1C"; - - if(args.text.length == 1) this.font = Math.round(sr*1.5)+"px "+font; - else if(args.text.length == 2) this.font = Math.round(sr*1.2)+"px "+font; - else this.font = Math.round(sr)+"px "+font; - - this.beginPath(); - this.textBaseline="middle"; - this.textAlign="center"; - this.fillText(args.text, xr, yr, 2*sr); - - }, - }, - - // modifies grid layer too - grid: { - draw: function(args, board) { - if(!board.obj_arr[args.x][args.y][0].c && !args._nodraw) { - var xr = board.getX(args.x), - yr = board.getY(args.y), - sr = board.stoneRadius; - this.clearRect(xr-sr,yr-sr,2*sr,2*sr); - } - }, - clear: function(args, board) { - if(!board.obj_arr[args.x][args.y][0].c) { - args._nodraw = true; - redraw_layer(board, "grid"); - delete args._nodraw; - } - } - }, - }, - - SQ: { - stone: { - draw: function(args, board) { - var xr = board.getX(args.x), - yr = board.getY(args.y), - sr = Math.round(board.stoneRadius); - - this.strokeStyle = args.c || get_markup_color(board, args.x, args.y); - this.lineWidth = args.lineWidth || board.lineWidth || 1; - this.beginPath(); - this.rect(Math.round(xr-sr/2)-0.5, Math.round(yr-sr/2)-0.5, sr, sr); - this.stroke(); - } - } - }, - - TR: { - stone: { - draw: function(args, board) { - var xr = board.getX(args.x), - yr = board.getY(args.y), - sr = board.stoneRadius; - - this.strokeStyle = args.c || get_markup_color(board, args.x, args.y); - this.lineWidth = args.lineWidth || board.lineWidth || 1; - this.beginPath(); - this.moveTo(xr-0.5, yr-0.5-Math.round(sr/2)); - this.lineTo(Math.round(xr-sr/2)-0.5, Math.round(yr+sr/3)+0.5); - this.lineTo(Math.round(xr+sr/2)+0.5, Math.round(yr+sr/3)+0.5); - this.closePath(); - this.stroke(); - } - } - }, - - MA: { - stone: { - draw: function(args, board) { - var xr = board.getX(args.x), - yr = board.getY(args.y), - sr = board.stoneRadius; - - this.strokeStyle = args.c || get_markup_color(board, args.x, args.y); - this.lineWidth = (args.lineWidth || board.lineWidth || 1) * 2; - this.beginPath(); - this.moveTo(Math.round(xr-sr/2), Math.round(yr-sr/2)); - this.lineTo(Math.round(xr+sr/2), Math.round(yr+sr/2)); - this.moveTo(Math.round(xr+sr/2)-1, Math.round(yr-sr/2)); - this.lineTo(Math.round(xr-sr/2)-1, Math.round(yr+sr/2)); - this.stroke(); - } - } - }, - - SL: { - stone: { - draw: function(args, board) { - var xr = board.getX(args.x), - yr = board.getY(args.y), - sr = board.stoneRadius; - - this.fillStyle = args.c || get_markup_color(board, args.x, args.y); - this.beginPath(); - this.rect(xr-sr/2, yr-sr/2, sr, sr); - this.fill(); - } - } - }, - - SM: { - stone: { - draw: function(args, board) { - var xr = board.getX(args.x), - yr = board.getY(args.y), - sr = board.stoneRadius; - - this.strokeStyle = args.c || get_markup_color(board, args.x, args.y); - this.lineWidth = (args.lineWidth || board.lineWidth || 1)*2; - this.beginPath(); - this.arc(xr-sr/3, yr-sr/3, sr/6, 0, 2*Math.PI, true); - this.stroke(); - this.beginPath(); - this.arc(xr+sr/3, yr-sr/3, sr/6, 0, 2*Math.PI, true); - this.stroke(); - this.beginPath(); - this.moveTo(xr-sr/1.5,yr); - this.bezierCurveTo(xr-sr/1.5,yr+sr/2,xr+sr/1.5,yr+sr/2,xr+sr/1.5,yr); - this.stroke(); - } - } - }, - - outline: { - stone: { - draw: function(args, board) { - this.globalAlpha = 0.3; - if(args.stoneStyle) Board.drawHandlers[args.stoneStyle].stone.draw.call(this, args, board); - else board.stoneHandler.stone.draw.call(this, args, board); - this.globalAlpha = 1; - } - } - }, - - mini: { - stone: { - draw: function(args, board) { - board.stoneRadius = board.stoneRadius/2; - if(args.stoneStyle) Board.drawHandlers[args.stoneStyle].stone.draw.call(this, args, board); - else board.stoneHandler.stone.draw.call(this, args, board); - board.stoneRadius = board.stoneRadius*2; - } - } - }, -} - -Board.CanvasLayer = function() { - this.element = document.createElement('canvas'); - this.context = this.element.getContext('2d') -} - -Board.CanvasLayer.prototype = { - constructor: Board.CanvasLayer, - - setDimensions: function(width, height) { - this.element.width = width; - this.element.height = height; - }, - - draw: function(board) { }, - - clear: function() { - this.context.clearRect(0,0,this.element.width,this.element.height); - } -} - -Board.GridLayer = WGo.extendClass(Board.CanvasLayer, function() { - this.super.call(this); -}); - -Board.GridLayer.prototype.draw = function(board) { - // draw grid - var tmp; - - this.context.beginPath(); - this.context.lineWidth = 1; - this.context.strokeStyle = "#000"; - - var tx = Math.round(board.left), - ty = Math.round(board.top), - bw = Math.round(board.fieldWidth*(board.size-1)), - bh = Math.round(board.fieldHeight*(board.size-1)); - - this.context.strokeRect(tx-0.5, ty-0.5, bw, bh); - - for(var i = 1; i < board.size-1; i++) { - tmp = Math.round(board.getX(i))-0.5; - this.context.moveTo(tmp, ty); - this.context.lineTo(tmp, ty+bh); - - tmp = Math.round(board.getY(i))-0.5; - this.context.moveTo(tx, tmp); - this.context.lineTo(tx+bw, tmp); - } - - this.context.stroke(); - - // draw stars - this.context.fillStyle = "#000"; - - if(board.starPoints[board.size]) { - for(var key in board.starPoints[board.size]) { - this.context.beginPath(); - this.context.arc(board.getX(board.starPoints[board.size][key].x)-0.5, board.getY(board.starPoints[board.size][key].y)-0.5, board.starSize*((board.width/300)+1), 0, 2*Math.PI,true); - this.context.fill(); - } - } -} - -Board.ShadowLayer = WGo.extendClass(Board.CanvasLayer, function(shadowSize) { - this.super.call(this); - this.shadowSize = shadowSize === undefined ? 1 : shadowSize; -}); - -Board.ShadowLayer.prototype.setDimensions = function(width, height) { - this.super.prototype.setDimensions.call(this, width, height); - this.context.setTransform(1,0,0,1,Math.round(this.shadowSize*width/300),Math.round(this.shadowSize*height/300)); -} - -var default_field_clear = function(args, board) { - var xr = board.getX(args.x), - yr = board.getY(args.y), - sr = board.stoneRadius; - this.clearRect(xr-sr-0.5,yr-sr-0.5, 2*sr, 2*sr); - this.clearRect(xr-sr-0.5,yr-sr-0.5, 2*sr, 2*sr); -} - -// Private methods of WGo.Board - -var calcLeftMargin = function() { - return (3*this.width)/(4*(this.bx+1-this.tx)+2) - this.fieldWidth*this.tx; -} - -var calcTopMargin = function() { - return (3*this.height)/(4*(this.by+1-this.ty)+2) - this.fieldHeight*this.ty; -} - -var calcFieldWidth = function() { - return (4*this.width)/(4*(this.bx+1-this.tx)+2); -} - -var calcFieldHeight = function() { - return (4*this.height)/(4*(this.by+1-this.ty)+2); -} - -var clearField = function(x,y) { - var handler; - for(var key in this.obj_arr[x][y]) { - if(!this.obj_arr[x][y][key].type) handler = this.stoneHandler; - else if(typeof this.obj_arr[x][y][key].type == "string") handler = Board.drawHandlers[this.obj_arr[x][y][key].type]; - else handler = this.obj_arr[x][y][key].type; - - for(var layer in handler) { - if(handler[layer].clear) handler[layer].clear.call(this[layer].context, this.obj_arr[x][y][key], this); - else default_field_clear.call(this[layer].context, this.obj_arr[x][y][key], this); - } - } -} - -var drawField = function(x,y) { - var handler; - for(var key in this.obj_arr[x][y]) { - if(!this.obj_arr[x][y][key].type) handler = this.stoneHandler; - else if(typeof this.obj_arr[x][y][key].type == "string") handler = Board.drawHandlers[this.obj_arr[x][y][key].type]; - else handler = this.obj_arr[x][y][key].type; - - for(var layer in handler) { - handler[layer].draw.call(this[layer].context, this.obj_arr[x][y][key], this); - } - } -} - -var getMousePos = function(e) { - var top = 0, - left = 0, - obj = this.grid.element, - x, y; - - while (obj && obj.tagName != 'BODY') { - top += obj.offsetTop; - left += obj.offsetLeft; - obj = obj.offsetParent; - } - - x = Math.round((e.pageX-left-this.left)/this.fieldWidth); - y = Math.round((e.pageY-top-this.top)/this.fieldHeight); - - return { - x: x >= this.size ? -1 : x, - y: y >= this.size ? -1 : y - }; -} - -var updateDim = function() { - this.element.style.width = this.width+"px"; - this.element.style.height = this.height+"px"; - - this.stoneRadius = this.stoneSize*Math.min(this.fieldWidth, this.fieldHeight)/2; - - for(var key in this.layers) { - this.layers[key].setDimensions(this.width, this.height); - } -} - -// Public methods are in the prototype: - -Board.prototype = { - constructor: Board, - - /** - * Initialization method, it is called in constructor. You shouldn't call it, but you can alter it. - */ - - init: function() { - - // placement of objects (in 3D array) - this.obj_arr = []; - for(var i = 0; i < this.size; i++) { - this.obj_arr[i] = []; - for(var j = 0; j < this.size; j++) this.obj_arr[i][j] = []; - } - - // other objects, stored in list - this.obj_list = []; - - // layers - this.layers = []; - - // event listeners, binded to board - this.listeners = []; - - this.element = document.createElement('div'); - this.element.className = 'wgo-board'; - - if(this.background) { - if(this.background[0] == "#") this.element.style.backgroundColor = this.background; - else { - this.element.style.backgroundImage = "url('"+this.background+"')"; - /*this.element.style.backgroundRepeat = "repeat";*/ - } - } - - this.grid = new Board.GridLayer(); - this.shadow = new Board.ShadowLayer(this.shadowSize); - this.stone = new Board.CanvasLayer(); - - this.addLayer(this.grid, 100); - this.addLayer(this.shadow, 200); - this.addLayer(this.stone, 300); - }, - - /** - * Set new width of board, height is computed to keep aspect ratio. - * - * @param {number} width - */ - - setWidth: function(width) { - this.width = width; - this.fieldHeight = this.fieldWidth = calcFieldWidth.call(this); - this.left = calcLeftMargin.call(this); - - this.height = (this.by-this.ty+1.5)*this.fieldHeight; - this.top = calcTopMargin.call(this); - - updateDim.call(this); - this.redraw(); - }, - - /** - * Set new height of board, width is computed to keep aspect ratio. - * - * @param {number} height - */ - - setHeight: function(height) { - this.height = height; - this.fieldWidth = this.fieldHeight = calcFieldHeight.call(this); - this.top = calcTopMargin.call(this); - - this.width = (this.bx-this.tx+1.5)*this.fieldWidth; - this.left = calcLeftMargin.call(this); - - updateDim.call(this); - this.redraw(); - }, - - /** - * Set both dimensions. - * - * @param {number} width - * @param {number} height - */ - - setDimensions: function(width, height) { - this.width = width || this.width; - this.height = height || this.height; - - this.fieldWidth = calcFieldWidth.call(this); - this.fieldHeight = calcFieldHeight.call(this); - this.left = calcLeftMargin.call(this); - this.top = calcTopMargin.call(this); - - updateDim.call(this); - this.redraw(); - }, - - /** - * Get currently visible section of the board - */ - - getSection: function() { - return this.section; - }, - - /** - * Set section of the board to be displayed - */ - - setSection: function(section_or_top, right, bottom, left) { - if(typeof section_or_top == "object") { - this.section = section_or_top; - } - else { - this.section = { - top: section_or_top, - right: right, - bottom: bottom, - left: left, - } - } - - this.tx = this.section.left; - this.ty = this.section.top; - this.bx = this.size-1-this.section.right; - this.by = this.size-1-this.section.bottom; - - this.setDimensions(); - }, - - /** - * Set board size (eg: 9, 13 or 19), this will clear board's objects. - */ - - setSize: function(size) { - var size = size || 19; - - if(size != this.size) { - this.size = size; - - this.obj_arr = []; - for(var i = 0; i < this.size; i++) { - this.obj_arr[i] = []; - for(var j = 0; j < this.size; j++) this.obj_arr[i][j] = []; - } - - this.bx = this.size-1-this.section.right; - this.by = this.size-1-this.section.bottom; - this.setDimensions(); - } - }, - - /** - * Redraw everything. - */ - - redraw: function() { - this.grid.clear(); - this.stone.clear(); - this.shadow.clear(); - this.grid.draw(this); - for(var i = 0; i < this.size; i++) { - for(var j = 0; j < this.size; j++) { - drawField.call(this, i, j); - } - } - for(var key in this.obj_list) { - var handler = this.obj_list[key].handler; - - for(var layer in handler) { - handler[layer].draw.call(this[layer].context, this.obj_list[key].args, this); - } - } - }, - - /** - * Get absolute X coordinate - * - * @param {number} x relative coordinate - */ - - getX: function(x) { - return this.left+x*this.fieldWidth; - }, - - /** - * Get absolute Y coordinate - * - * @param {number} y relative coordinate - */ - - getY: function(y) { - return this.top+y*this.fieldHeight; - }, - - /** - * Add layer to the board. It is meant to be only for canvas layers. - * - * @param {Board.CanvasLayer} layer to add - * @param {number} weight layer with biggest weight is on the top - */ - - addLayer: function(layer, weight) { - layer.element.style.position = 'absolute'; - layer.element.style.zIndex = weight; - layer.setDimensions(this.width, this.height); - this.element.appendChild(layer.element); - this.layers.push(layer); - }, - - /** - * Remove layer from the board. - * - * @param {Board.CanvasLayer} layer to remove - */ - - removeLayer: function(layer) { - var i = this.layers.indexOf(layer); - if(i >= 0) { - this.layers.splice(i,1); - this.element.removeChild(layer.element); - } - }, - - update: function(changes) { - if(changes.remove && changes.remove == "all") this.removeAllObjects(); - else if(changes.remove) { - for(var key in changes.remove) this.removeObject(changes.remove[key]); - } - - if(changes.add) { - for(var key in changes.add) this.addObject(changes.add[key]); - } - }, - - addObject: function(obj) { - // handling multiple objects - if(obj.constructor == Array) { - for(var key in obj) this.addObject(obj[key]); - return; - } - - // clear all objects on object's coordinates - clearField.call(this, obj.x, obj.y); - - // if object of this type is on the board, replace it - for(var key in this.obj_arr[obj.x][obj.y]) { - if(this.obj_arr[obj.x][obj.y][key].type == obj.type) { - this.obj_arr[obj.x][obj.y][key] = obj; - drawField.call(this, obj.x, obj.y); - return; - } - } - - // if object is a stone, add it at the beginning, otherwise at the end - if(!obj.type) this.obj_arr[obj.x][obj.y].unshift(obj); - else this.obj_arr[obj.x][obj.y].push(obj); - - // draw all objects - drawField.call(this, obj.x, obj.y); - }, - - removeObject: function(obj) { - // handling multiple objects - if(obj.constructor == Array) { - for(var key in obj) this.removeObject(obj[key]); - return; - } - - var i; - for(var j = 0; j < this.obj_arr[obj.x][obj.y].length; j++) { - if(this.obj_arr[obj.x][obj.y][j].type == obj.type) { - i = j; - break; - } - } - if(i === undefined) return; - - // clear all objects on object's coordinates - clearField.call(this, obj.x, obj.y); - - this.obj_arr[obj.x][obj.y].splice(i,1); - - drawField.call(this, obj.x, obj.y); - }, - - removeObjectsAt: function(x, y) { - if(!this.obj_arr[x][y].length) return; - - clearField.call(this, x, y); - this.obj_arr[x][y] = []; - }, - - removeAllObjects: function() { - this.obj_arr = []; - for(var i = 0; i < this.size; i++) { - this.obj_arr[i] = []; - for(var j = 0; j < this.size; j++) this.obj_arr[i][j] = []; - } - this.redraw(); - }, - - addCustomObject: function(handler, args) { - this.obj_list.push({handler: handler, args: args}); - this.redraw(); - }, - - removeCustomObject: function(handler, args) { - for(var key in this.obj_list) { - if(this.obj_list[key].handler == handler && this.obj_list[key].args == args) { - delete this.obj_list[key]; - this.redraw(); - return true; - } - } - return false; - }, - - addEventListener: function(type, callback) { - var _this = this, - evListener = { - type: type, - callback: callback, - handleEvent: function(e) { - var coo = getMousePos.call(_this, e); - callback(coo.x, coo.y, e); - } - }; - - this.element.addEventListener(type, evListener, true); - this.listeners.push(evListener); - }, - - removeEventListener: function(type, callback) { - for(var key in this.listeners) { - if(this.listeners[key].type == type && this.listeners[key].callback == callback) { - this.element.removeEventListener(this.listeners[key].type, this.listeners[key], true); - delete this.listeners[key]; - return true; - } - } - return false; - }, - - getState: function() { - return { - objects: WGo.clone(this.obj_arr), - custom: WGo.clone(this.obj_list) - }; - }, - - restoreState: function(state) { - this.obj_arr = state.objects || this.obj_arr; - this.obj_list = state.custom || this.obj_list; - - this.redraw(); - } -} - -Board.default = { - size: 19, - width: 0, - height: 0, - font: "Calibri", - lineWidth: 1, - starPoints: { - 19:[{x:3, y:3 }, - {x:9, y:3 }, - {x:15,y:3 }, - {x:3, y:9 }, - {x:9, y:9 }, - {x:15,y:9 }, - {x:3, y:15}, - {x:9, y:15}, - {x:15,y:15}], - 13:[{x:3, y:3}, - {x:9, y:3}, - {x:3, y:9}, - {x:9, y:9}], - 9:[{x:4, y:4}], - }, - stoneHandler: Board.drawHandlers.NORMAL, - starSize: 1, - shadowSize: 1, - stoneSize: 1, - section: { - top: 0, - right: 0, - bottom: 0, - left: 0, - }, - background: WGo.DIR+"wood1.jpg" -} - -// save Board -WGo.Board = Board; - -//-------- WGo.Game --------------------------------------------------------------------------- - -/** - * Creates instance of position object. - * - * @class - *

WGo.Position is simple object storing position of go game. It is implemented as matrix size x size with values WGo.BLACK, WGo.WHITE or 0. It can be used by any extension.

- * - * @param {number} size of the board - */ - -var Position = function(size) { - this.size = size; - this.schema = []; - for(var i = 0; i < size*size; i++) { - this.schema[i] = 0; - } -} - -Position.prototype = { - constructor: WGo.Position, - - /** - * Returns value of given coordinates. - * - * @param {number} x coordinate - * @param {number} y coordinate - * @return {(WGo.BLACK|WGo.WHITE|0)} color - */ - - get: function(x,y) { - if(x < 0 || y < 0 || x >= this.size || y >= this.size) return undefined; - return this.schema[x*this.size+y]; - }, - - /** - * Sets value of given coordinates. - * - * @param {number} x coordinate - * @param {number} y coordinate - * @param {(WGo.B|WGo.W|0)} c color - */ - - set: function(x,y,c) { - this.schema[x*this.size+y] = c; - return this; - }, - - /** - * Clears the whole position (every value is set to 0). - */ - - clear: function() { - for(var i = 0; i < this.size*this.size; i++) this.schema[i] = 0; - return this; - }, - - /** - * Clones the whole position. - * - * @return {WGo.Position} copy of position - */ - - clone: function() { - var clone = new Position(this.size); - clone.schema = this.schema.slice(0); - return clone; - }, -} - -WGo.Position = Position; - -/** - * Creates instance of game class. - * - * @class - * This class implements game logic. It basically analyses given moves and returns capture stones. - * WGo.Game also stores every position from beginning, so it has ability to check repeating positions - * and it can effectively restore old positions.

- * - * @param {number} size of the board - * @param {"KO"|"ALL"|"NONE"} repeat (optional, default is "KO") - how to handle repeated position: - * - * KO - ko is properly handled - position cannot be same like previous position - * ALL - position cannot be same like any previous position - e.g. it forbids triple ko - * NONE - position can be repeated - */ - -var Game = function(size, repeat) { - this.size = size || 19; - this.repeating = repeat === undefined ? "KO" : repeat; // possible values: KO, ALL or nothing - this.stack = []; - this.stack[0] = new Position(size); - this.stack[0].capCount = {black:0, white:0}; - this.turn = WGo.B; - - Object.defineProperty(this, "position", { - get : function(){ return this.stack[this.stack.length-1]; }, - set : function(pos){ this[this.stack.length-1] = pos; } - }); -} - -// function for stone capturing -var do_capture = function(position, captured, x, y, c) { - if(x >= 0 && x < position.size && y >= 0 && y < position.size && position.get(x,y) == c) { - position.set(x,y,0); - captured.push({x:x, y:y}); - - do_capture(position, captured, x, y-1, c); - do_capture(position, captured, x, y+1, c); - do_capture(position, captured, x-1, y, c); - do_capture(position, captured, x+1, y, c); - } -} - -// looking at liberties -var check_liberties = function(position, testing, x, y, c) { - // out of the board there aren't liberties - if(x < 0 || x >= position.size || y < 0 || y >= position.size) return true; - // however empty field means liberty - if(position.get(x,y) == 0) return false; - // already tested field or stone of enemy isn't giving us a liberty. - if(testing.get(x,y) == true || position.get(x,y) == -c) return true; - - // set this field as tested - testing.set(x,y,true); - - // in this case we are checking our stone, if we get 4 trues, it has no liberty - return check_liberties(position, testing, x, y-1, c) && - check_liberties(position, testing, x, y+1, c) && - check_liberties(position, testing, x-1, y, c) && - check_liberties(position, testing, x+1, y, c); -} - -// analysing function - modifies original position, if there are some capturing, and returns array of captured stones -var check_capturing = function(position, x, y, c) { - var captured = []; - // is there a stone possible to capture? - if(x >= 0 && x < position.size && y >= 0 && y < position.size && position.get(x,y) == c) { - // create testing map - var testing = new Position(position.size); - // if it has zero liberties capture it - if(check_liberties(position, testing, x, y, c)) { - // capture stones from game - do_capture(position, captured, x, y, c); - } - } - return captured; -} - -// analysing history -var checkHistory = function(position, x, y) { - var flag, stop; - - if(this.repeating == "KO" && this.stack.length-2 >= 0) stop = this.stack.length-2; - else if(this.repeating == "ALL") stop = 0; - else return true; - - for(var i = this.stack.length-2; i >= stop; i--) { - if(this.stack[i].get(x,y) == position.get(x,y)) { - flag = true; - for(var j = 0; j < this.size*this.size; j++) { - if(this.stack[i].schema[j] != position.schema[j]) { - flag = false; - break; - } - } - if(flag) return false; - } - } - - return true; -} - -Game.prototype = { - - constructor: Game, - - /** - * Gets actual position. - * - * @return {WGo.Position} actual position - */ - - getPosition: function() { - return this.stack[this.stack.length-1]; - }, - - /** - * Play move. - * - * @param {number} x coordinate - * @param {number} y coordinate - * @param {(WGo.B|WGo.W)} c color - * @param {boolean} noplay - if true, move isn't played. Used by WGo.Game.isValid. - * @return {number} code of error, if move isn't valid. If it is valid, function returns array of captured stones. - * - * Error codes: - * 1 - given coordinates are not on board - * 2 - on given coordinates already is a stone - * 3 - suicide (currently they are forbbiden) - * 4 - repeated position - */ - - play: function(x,y,c,noplay) { - //check coordinates validity - if(!this.isOnBoard(x,y)) return 1; - if(this.position.get(x,y) != 0) return 2; - - // clone position - if(!c) c = this.turn; - - var new_pos = this.position.clone(); - new_pos.set(x,y,c); - - // check capturing - var captured = check_capturing(new_pos, x-1, y, -c).concat(check_capturing(new_pos, x+1, y, -c), check_capturing(new_pos, x, y-1, -c), check_capturing(new_pos, x, y+1, -c)); - - // check suicide - if(!captured.length) { - var testing = new Position(this.size); - if(check_liberties(new_pos, testing, x, y, c)) return 3; - } - - // check history - if(this.repeating && !checkHistory.call(this, new_pos, x, y)) { - return 4; - } - - if(noplay) return false; - - // update position info - new_pos.color = c; - new_pos.capCount = { - black: this.position.capCount.black, - white: this.position.capCount.white - }; - if(c == WGo.B) new_pos.capCount.black += captured.length; - else new_pos.capCount.white += captured.length; - - // save position - this.pushPosition(new_pos); - - // reverse turn - this.turn = -c; - - return captured; - - }, - - /** - * Play pass. - * - * @param {(WGo.B|WGo.W)} c color - */ - - pass: function(c) { - if(c) this.turn = -c; - else this.turn = -this.turn; - - this.pushPosition(); - this.position.color = -this.position.color; - }, - - /** - * Finds out validity of the move. - * - * @param {number} x coordinate - * @param {number} y coordinate - * @param {(WGo.B|WGo.W)} c color - * @return {boolean} true if move can be played. - */ - - isValid: function(x,y,c) { - return typeof this.play(x,y,c,true) != "number"; - }, - - /** - * Controls position of the move. - * - * @param {number} x coordinate - * @param {number} y coordinate - * @return {boolean} true if move is on board. - */ - - isOnBoard: function(x,y) { - return x >= 0 && y >= 0 && x < this.size && y < this.size; - }, - - /** - * Inserts move into current position. Use for setting position, for example in handicap game. Field must be empty. - * - * @param {number} x coordinate - * @param {number} y coordinate - * @param {(WGo.B|WGo.W)} c color - * @return {boolean} true if operation is successfull. - */ - - addStone: function(x,y,c) { - if(this.isOnBoard(x,y) && this.position.get(x,y) == 0) { - this.position.set(x,y,c || 0); - return true; - } - return false; - }, - - /** - * Removes move from current position. - * - * @param {number} x coordinate - * @param {number} y coordinate - * @return {boolean} true if operation is successfull. - */ - - removeStone: function(x,y) { - if(this.isOnBoard(x,y) && this.position.get(x,y) != 0) { - this.position.set(x,y,0); - return true; - } - return false; - }, - - /** - * Set or insert move of current position. - * - * @param {number} x coordinate - * @param {number} y coordinate - * @param {(WGo.B|WGo.W)} c color - * @return {boolean} true if operation is successfull. - */ - - setStone: function(x,y,c) { - if(this.isOnBoard(x,y)) { - this.position.set(x,y,c || 0); - return true; - } - return false; - }, - - /** - * Get stone on given position. - * - * @param {number} x coordinate - * @param {number} y coordinate - * @return {(WGo.B|WGo.W|0)} color - */ - - getStone: function(x,y) { - if(this.isOnBoard(x,y)) { - return this.position.get(x,y); - } - return 0; - }, - - /** - * Add position to stack. If position isn't specified current position is cloned and stacked. - * Pointer of actual position is moved to the new position. - * - * @param {WGo.Position} tmp position (optional) - */ - - pushPosition: function(pos) { - if(!pos) { - var pos = this.position.clone(); - pos.capCount = { - black: this.position.capCount.black, - white: this.position.capCount.white - }; - pos.color = this.position.color; - } - this.stack.push(pos); - return this; - }, - - /** - * Remove current position from stack. Pointer of actual position is moved to the previous position. - */ - - popPosition: function() { - var old = null; - if(this.stack.length > 0) { - old = this.stack.pop(); - - if(this.stack.length == 0) this.turn = WGo.B; - else if(this.position.color) this.turn = -this.position.color; - else this.turn = -this.turn; - } - return old; - }, - - /** - * Removes all positions. - */ - - firstPosition: function() { - this.stack = []; - this.stack[0] = new Position(this.size); - this.stack[0].capCount = {black:0, white:0}; - this.turn = WGo.B; - return this; - }, - - /** - * Gets count of captured stones. - * - * @param {(WGo.BLACK|WGo.WHITE)} color - * @return {number} count - */ - - getCaptureCount: function(color) { - return color == WGo.B ? this.position.capCount.black : this.position.capCount.white; - }, - - /** - * Validate postion. Position is tested from 0:0 to size:size, if there are some moves, that should be captured, they will be removed. - * You can use this, after insertion of more stones. - * - * @return array removed stones - */ - - validatePosition: function() { - var c, p, - white = 0, - black = 0, - captured = [], - new_pos = this.position.clone(); - - for(var x = 0; x < this.size; x++) { - for(var y = 0; y < this.size; y++) { - c = this.position.get(x,y); - if(c) { - p = captured.length; - captured = captured.concat(check_capturing(new_pos, x-1, y, -c), - check_capturing(new_pos, x+1, y, -c), - check_capturing(new_pos, x, y-1, -c), - check_capturing(new_pos, x, y+1, -c)); - - if(c == WGo.B) black += captured-p; - else white += captured-p; - } - } - } - this.position.capCount.black += black; - this.position.capCount.white += white; - this.position.schema = new_pos.schema; - - return captured; - }, -}; - -// save Game -WGo.Game = Game; - -// register WGo -window.WGo = WGo; - -})(window); - \ No newline at end of file diff --git a/interface/server/wgo/wgo.min.js b/interface/server/wgo/wgo.min.js deleted file mode 100644 index 471a3af18..000000000 --- a/interface/server/wgo/wgo.min.js +++ /dev/null @@ -1 +0,0 @@ -/*! MIT license, more info: wgo.waltheri.net */ (function(e,t){"use strict";var n=document.getElementsByTagName("script");var r=n[n.length-1].src.split("?")[0];var i=r.split("/").slice(0,-1).join("/")+"/";var s={version:"2.0",B:1,W:-1,ERROR_REPORT:true,DIR:i,lang:"en",i18n:{en:{}}};s.opera=navigator.userAgent.search(/(opera)(?:.*version)?[ \/]([\w.]+)/i)!=-1;s.webkit=navigator.userAgent.search(/(webkit)[ \/]([\w.]+)/i)!=-1;s.msie=navigator.userAgent.search(/(msie) ([\w.]+)/i)!=-1;s.mozilla=navigator.userAgent.search(/(mozilla)(?:.*? rv:([\w.]+))?/i)!=-1&&!s.webkit&&!s.msie;s.t=function(e){var t=s.i18n[s.lang][e]||s.i18n.en[e];if(t){for(var n=1;n",">")};var o=function(e,n){var n=n||{};for(var r in n)this[r]=n[r];for(var r in s.Board.default)if(this[r]===t)this[r]=s.Board.default[r];this.tx=this.section.left;this.ty=this.section.top;this.bx=this.size-1-this.section.right;this.by=this.size-1-this.section.bottom;this.init();e.appendChild(this.element);if(this.width&&this.height)this.setDimensions(this.width,this.height);else if(this.width)this.setWidth(this.width);else if(this.height)this.setHeight(this.height)};var u={draw:function(e,t){var n=t.getX(e.x),r=t.getY(e.y),i=t.stoneRadius;this.beginPath();this.fillStyle="rgba(32,32,32,0.5)";this.arc(n-.5,r-.5,i-.5,0,2*Math.PI,true);this.fill()}};var a=function(e,t,n){if(e.obj_arr[t][n][0].c==s.B)return"white";return"black"};var f=function(e,t){var n;e[t].clear();e[t].draw(e);for(var r=0;r=this.size?-1:i,y:s>=this.size?-1:s}};var y=function(){this.element.style.width=this.width+"px";this.element.style.height=this.height+"px";this.stoneRadius=this.stoneSize*Math.min(this.fieldWidth,this.fieldHeight)/2;for(var e in this.layers){this.layers[e].setDimensions(this.width,this.height)}};o.prototype={constructor:o,init:function(){this.obj_arr=[];for(var e=0;e=0){this.layers.splice(t,1);this.element.removeChild(e.element)}},update:function(e){if(e.remove&&e.remove=="all")this.removeAllObjects();else if(e.remove){for(var t in e.remove)this.removeObject(e.remove[t])}if(e.add){for(var t in e.add)this.addObject(e.add[t])}},addObject:function(e){if(e.constructor==Array){for(var t in e)this.addObject(e[t]);return}v.call(this,e.x,e.y);for(var t in this.obj_arr[e.x][e.y]){if(this.obj_arr[e.x][e.y][t].type==e.type){this.obj_arr[e.x][e.y][t]=e;m.call(this,e.x,e.y);return}}if(!e.type)this.obj_arr[e.x][e.y].unshift(e);else this.obj_arr[e.x][e.y].push(e);m.call(this,e.x,e.y)},removeObject:function(e){if(e.constructor==Array){for(var n in e)this.removeObject(e[n]);return}var r;for(var i=0;i=this.size||n>=this.size)return t;return this.schema[e*this.size+n]},set:function(e,t,n){this.schema[e*this.size+t]=n;return this},clear:function(){for(var e=0;e=0&&n=0&&r=e.size||r<0||r>=e.size)return true;if(e.get(n,r)==0)return false;if(t.get(n,r)==true||e.get(n,r)==-i)return true;t.set(n,r,true);return S(e,t,n,r-1,i)&&S(e,t,n,r+1,i)&&S(e,t,n-1,r,i)&&S(e,t,n+1,r,i)};var x=function(e,t,n,r){var i=[];if(t>=0&&t=0&&n=0)i=this.stack.length-2;else if(this.repeating=="ALL")i=0;else return true;for(var s=this.stack.length-2;s>=i;s--){if(this.stack[s].get(t,n)==e.get(t,n)){r=true;for(var o=0;o=0&&t>=0&&e0){e=this.stack.pop();if(this.stack.length==0)this.turn=s.B;else if(this.position.color)this.turn=-this.position.color;else this.turn=-this.turn}return e},firstPosition:function(){this.stack=[];this.stack[0]=new b(this.size);this.stack[0].capCount={black:0,white:0};this.turn=s.B;return this},getCaptureCount:function(e){return e==s.B?this.position.capCount.black:this.position.capCount.white},validatePosition:function(){var e,t,n=0,r=0,i=[],o=this.position.clone();for(var u=0;u1){var n=[];for(var r=0;r1)n=r+" "+e.t("minutes");r=t%60;if(r==1)n+=" 1 "+e.t("second");else if(r>1)n+=" "+r+" "+e.t("seconds");return n},RE:function(t){return''+e.t("show")+""}};o.infoList=["black","white","AN","CP","DT","EV","GN","GC","ON","OT","RE","RO","RU","SO","TM","PC","KM"];e.Kifu=o;var a=function(e,t,n){for(var r=0;r1)this.game.turn=e.W;if(n)this.rememberPath=true;else this.rememberPath=false};var p=function(e,t){var n=[],r;for(var i in e){r=true;for(var s in t){if(e[i].x==t[s].x&&e[i].y==t[s].y){r=false;break}}if(r)n.push(e[i])}return n};var d=function(e,t){e.add=p(e.add,t.remove).concat(t.add);e.remove=p(e.remove,t.add).concat(t.remove)};var v=function(e,n,r){if(n.parent)n.parent._last_selected=n.parent.children.indexOf(n);if(n.move!=t){if(n.move.pass){e.pass(n.move.c);return{add:[],remove:[]}}else{var i=e.play(n.move.x,n.move.y,n.move.c);if(typeof i=="number")throw new b(i,n);return{add:[n.move],remove:i}}}else if(n.setup!=t){if(!r)e.pushPosition();var s=[],o=[];for(var u in n.setup){if(n.setup[u].c){e.addStone(n.setup[u].x,n.setup[u].y,n.setup[u].c);s.push(n.setup[u])}else{e.removeStone(n.setup[u].x,n.setup[u].y);o.push(n.setup[u])}}if(n.turn)e.turn=n.turn;return{add:s,remove:o}}else if(!r){e.pushPosition()}return{add:[],remove:[]}};var m=function(e){if(e===t&&this.rememberPath)e=this.node._last_selected;e=e||0;var n=this.node.children[e];if(!n)return false;var r=v(this.game,n);this.path.m++;if(this.node.children.length>1)this.path[this.path.m]=e;this.node=n;return r};var g=function(){if(!this.node.parent)return false;this.node=this.node.parent;this.game.popPosition();this.path.m--;if(this.path[this.path.m]!==t)delete this.path[this.path.m];return true};var y=function(){this.game.firstPosition();this.node=this.kifu.root;this.path={m:0};this.change=v(this.game,this.node,true);if(this.kifu.info["HA"]&&this.kifu.info["HA"]>1)this.game.turn=e.W};h.prototype={constructor:h,next:function(e){this.change=m.call(this,e);return this},last:function(){var e;this.change={add:[],remove:[]};while(e=m.call(this))d(this.change,e);return this},previous:function(){var e=this.game.getPosition();g.call(this);this.change=c(e,this.game.getPosition());return this},first:function(){var e=this.game.getPosition();y.call(this);this.change=c(e,this.game.getPosition());return this},goTo:function(e){if(e===t)return this;var n=this.game.getPosition();y.call(this);var r;for(var i=0;i1){for(var r=0;re.offsetHeight)return true;else return s(e.parentNode,t)};var o=function(e){var t=e.wheelDelta||e.detail*-1;if(s(e.target,this))return true;if(t<0){this.next();if(this.config.lockScroll&&e.preventDefault)e.preventDefault();return!this.config.lockScroll}else if(t>0){this.previous();if(this.config.lockScroll&&e.preventDefault)e.preventDefault();return!this.config.lockScroll}return true};var u=function(e){switch(e.keyCode){case 39:this.next();break;case 37:this.previous();break;default:return true}if(this.config.lockScroll&&e.preventDefault)e.preventDefault();return!this.config.lockScroll};var a=function(e,t){if(!this.kifuReader||!this.kifuReader.node)return false;for(var n in this.kifuReader.node.children){if(this.kifuReader.node.children[n].move&&this.kifuReader.node.children[n].move.x==e&&this.kifuReader.node.children[n].move.y==t){this.next(n);return}}};var f={grid:{draw:function(e,t){var n,r,i,s,o,u;this.fillStyle="rgba(0,0,0,0.7)";this.textBaseline="middle";this.textAlign="center";this.font=t.stoneRadius+"px "+(t.font||"");i=t.getX(-.75);s=t.getX(t.size-.25);o=t.getY(-.75);u=t.getY(t.size-.25);for(var a=0;a="I".charCodeAt(0))n++;r=t.getY(a);this.fillText(t.size-a,i,r);this.fillText(t.size-a,s,r);r=t.getX(a);this.fillText(String.fromCharCode(n),r,o);this.fillText(String.fromCharCode(n),r,u)}this.fillStyle="black"}}};var l=function(t){this.config=t;for(var n in PlayerView.default)if(this.config[n]===undefined&&PlayerView.default[n]!==undefined)this.config[n]=PlayerView.default[n];this.element=document.createElement("div");this.board=new e.Board(this.element,this.config.board);this.init();this.initGame()};l.prototype={constructor:l,init:function(){this.kifu=null;this.listeners={kifuLoaded:[i.bind(this)],update:[r.bind(this)],frozen:[],unfrozen:[]};if(this.config.kifuLoaded)this.addEventListener("kifuLoaded",this.config.kifuLoaded);if(this.config.update)this.addEventListener("update",this.config.update);if(this.config.frozen)this.addEventListener("frozen",this.config.frozen);if(this.config.unfrozen)this.addEventListener("unfrozen",this.config.unfrozen);this.board.addEventListener("click",a.bind(this));this.element.addEventListener("click",this.focus.bind(this));this.focus()},initGame:function(){if(this.config.sgf){this.loadSgf(this.config.sgf,this.config.move)}else if(this.config.json){this.loadJSON(this.config.json,this.config.move)}else if(this.config.sgfFile){this.loadSgfFromFile(this.config.sgfFile,this.config.move)}},update:function(e){if(!this.kifuReader||!this.kifuReader.change)return;var t={type:"update",op:e,target:this,node:this.kifuReader.node,position:this.kifuReader.getPosition(),path:this.kifuReader.path,change:this.kifuReader.change};this.dispatchEvent(t)},loadKifu:function(t,n){this.kifu=t;this.kifuReader=new e.KifuReader(this.kifu,this.config.rememberPath);this.dispatchEvent({type:"kifuLoaded",target:this,kifu:this.kifu});if(n){this.goTo(n)}else{this.update("init")}},loadSgf:function(t,n){try{this.loadKifu(e.Kifu.fromSgf(t),n)}catch(r){this.error(r)}},loadJSON:function(t){try{this.loadKifu(e.Kifu.fromJGO(t),path)}catch(n){this.error(n)}},loadSgfFromFile:function(e,t){var r=this;try{n(e,function(e){r.loadSgf(e,t)})}catch(i){this.error(i)}},addEventListener:function(e,t){this.listeners[e]=this.listeners[e]||[];this.listeners[e].push(t)},removeEventListener:function(e,t){if(!this.listeners[e])return;var n=this.listeners[e].indexOf(t);if(n!=-1)this.listeners[e].splice(n,1)},dispatchEvent:function(e){if(!this.listeners[e.type])return;for(var t in this.listeners[e.type])this.listeners[e.type][t](e)},notification:function(e){if(console)console.log(e)},help:function(e){if(console)console.log(e)},error:function(t){if(!e.ERROR_REPORT)throw t;if(console)console.log(t)},next:function(e){if(this.frozen||!this.kifu)return;try{this.kifuReader.next(e);this.update()}catch(t){this.error(t)}},previous:function(){if(this.frozen||!this.kifu)return;try{this.kifuReader.previous();this.update()}catch(e){this.error(e)}},last:function(){if(this.frozen||!this.kifu)return;try{this.kifuReader.last();this.update()}catch(e){this.error(e)}},first:function(){if(this.frozen||!this.kifu)return;try{this.kifuReader.first();this.update()}catch(e){this.error(e)}},goTo:function(t){if(this.frozen||!this.kifu)return;var n;if(typeof t=="function")t=t.call(this);if(typeof t=="number"){n=e.clone(this.kifuReader.path);n.m=t||0}else n=t;try{this.kifuReader.goTo(n);this.update()}catch(r){this.error(r)}},getGameInfo:function(){if(!this.kifu)return null;var t={};for(var n in this.kifu.info){if(e.Kifu.infoList.indexOf(n)==-1)continue;if(e.Kifu.infoFormatters[n]){t[e.t(n)]=e.Kifu.infoFormatters[n](this.kifu.info[n])}else t[e.t(n)]=e.filterHTML(this.kifu.info[n])}return t},setFrozen:function(e){this.frozen=e;this.dispatchEvent({type:this.frozen?"frozen":"unfrozen",target:this})},appendTo:function(e){e.appendChild(this.element)},focus:function(){if(this.config.enableKeys)this.setKeys(true)},setKeys:function(t){if(t){if(e.mozilla)document.onkeypress=u.bind(this);else document.onkeydown=u.bind(this)}else{if(e.mozilla)document.onkeypress=null;else document.onkeydown=null}},setWheel:function(t){if(!this._wheel_listener&&t){this._wheel_listener=o.bind(this);var n=e.mozilla?"DOMMouseScroll":"mousewheel";this.element.addEventListener(n,this._wheel_listener)}else if(this._wheel_listener&&!t){var n=e.mozilla?"DOMMouseScroll":"mousewheel";this.element.removeEventListener(n,this._wheel_listener);delete this._wheel_listener}},setCoordinates:function(e){if(!this.coordinates&&e){this.board.setSection(-.5,-.5,-.5,-.5);this.board.addCustomObject(f)}else if(this.coordinates&&!e){this.board.setSection(0,0,0,0);this.board.removeCustomObject(f)}this.coordinates=e}};l.default={sgf:undefined,json:undefined,sgfFile:undefined,move:undefined,board:{},enableWheel:true,lockScroll:true,enableKeys:true,rememberPath:true,kifuLoaded:undefined,update:undefined,frozen:undefined,unfrozen:undefined};e.Player=l;var c={"about-text":"

WGo.js Player 2.0

"+"

WGo.js Player is extension of WGo.js, HTML5 library for purposes of game of go. It allows to replay go game records and it has many features like score counting. It is also designed to be easily extendable.

"+"

WGo.js is open source licensed under MIT license. You can use and modify any code from this project.

"+"

You can find more information at wgo.waltheri.net/player

"+"

Copyright © 2013 Jan Prokop

",black:"Black",white:"White",DT:"Date",KM:"Komi",HA:"Handicap",AN:"Annotations",CP:"Copyright",OT:"Overtime",TM:"Basic time",RE:"Result",RU:"Rules",PC:"Place",EV:"Event",SO:"Source",none:"none",bpass:"Black passed.",wpass:"White passed."};for(var h in c)e.i18n.en[h]=c[h]})(WGo);(function(WGo){"use strict";var pl_count=0;var playerBlock=function(e,t,n){var r={};r.element=document.createElement("div");r.element.className="wgo-player-"+e;r.wrapper=document.createElement("div");r.wrapper.className="wgo-player-"+e+"-wrapper";r.element.appendChild(r.wrapper);t.appendChild(r.element);if(!n)r.element.style.display="none";return r};var BPgenerateDom=function(){this.dom={};this.dom.center=document.createElement("div");this.dom.center.className="wgo-player-center";this.dom.board=document.createElement("div");this.dom.board.className="wgo-player-board";this.regions={};this.regions.left=playerBlock("left",this.element);this.element.appendChild(this.dom.center);this.regions.right=playerBlock("right",this.element);this.regions.top=playerBlock("top",this.dom.center);this.dom.center.appendChild(this.dom.board);this.regions.bottom=playerBlock("bottom",this.dom.center)};var getCurrentLayout=function(){var e=this.config.layout;if(e.constructor!=Array)return e;var t=this.height||this.maxHeight;for(var n=0;n=this.width)&&(!e[n].conditions.maxHeight||!t||e[n].conditions.maxHeight>=t)&&(!e[n].conditions.custom||e[n].conditions.custom.call(this))){return e[n]}}};var appendComponents=function(e){var t;if(this.currentLayout.layout)t=this.currentLayout.layout[e];else t=this.currentLayout[e];if(t){this.regions[e].element.style.display="block";for(var n in t){if(!this.components[t[n]])this.components[t[n]]=new BasicPlayer.component[t[n]](this);this.components[t[n]].appendTo(this.regions[e].wrapper);this.components[t[n]]._detachFromPlayer=false}}else{this.regions[e].element.style.display="none"}};var manageComponents=function(){for(var e in this.components){this.components[e]._detachFromPlayer=true}appendComponents.call(this,"left");appendComponents.call(this,"right");appendComponents.call(this,"top");appendComponents.call(this,"bottom");for(var e in this.components){if(this.components[e]._detachFromPlayer&&this.components[e].element.parentNode)this.components[e].element.parentNode.removeChild(this.components[e].element)}};var BasicPlayer=WGo.extendClass(WGo.Player,function(e,t){this.config=t;for(var n in BasicPlayer.default)if(this.config[n]===undefined&&BasicPlayer.default[n]!==undefined)this.config[n]=BasicPlayer.default[n];for(var n in WGo.Player.default)if(this.config[n]===undefined&&WGo.Player.default[n]!==undefined)this.config[n]=WGo.Player.default[n];this.element=e;this.element.innerHTML="";this.classes=(this.element.className?this.element.className+" ":"")+"wgo-player-main";this.element.className=this.classes;if(!this.element.id)this.element.id="wgo_"+pl_count++;BPgenerateDom.call(this);this.board=new WGo.Board(this.dom.board,this.config.board);this.init();this.components={};window.addEventListener("resize",function(){if(!this.noresize){this.updateDimensions()}}.bind(this));this.updateDimensions();this.initGame()});BasicPlayer.prototype.appendTo=function(e){e.appendChild(this.element);this.updateDimensions()};BasicPlayer.prototype.updateDimensions=function(){var e=window.getComputedStyle(this.element);var t=[];while(this.element.firstChild){t.push(this.element.firstChild);this.element.removeChild(this.element.firstChild)}var n=parseInt(e.width);var r=parseInt(e.height);var i=parseInt(e.maxHeight)||0;for(var s=0;s0){this.dom.board.style.height=u+"px";this.dom.board.style.paddingTop=a/2+"px"}else{this.dom.board.style.height="auto";this.dom.board.style.paddingTop="0"}this.regions.left.element.style.height=this.dom.center.offsetHeight+"px";this.regions.right.element.style.height=this.dom.center.offsetHeight+"px";for(var s in this.components){if(this.components[s].updateDimensions)this.components[s].updateDimensions()}};BasicPlayer.prototype.showMessage=function(e,t,n){this.info_overlay=document.createElement("div");this.info_overlay.style.width=this.element.offsetWidth+"px";this.info_overlay.style.height=this.element.offsetHeight+"px";this.info_overlay.className="wgo-info-overlay";this.element.appendChild(this.info_overlay);var r=document.createElement("div");r.className="wgo-info-message";r.innerHTML=e;var i=document.createElement("div");i.className="wgo-info-close";if(!n)i.innerHTML=WGo.t("BP:closemsg");r.appendChild(i);this.info_overlay.appendChild(r);if(t){this.info_overlay.addEventListener("click",function(e){t(e)})}else if(!n){this.info_overlay.addEventListener("click",function(e){this.hideMessage()}.bind(this))}this.setFrozen(true)};BasicPlayer.prototype.hideMessage=function(){this.element.removeChild(this.info_overlay);this.setFrozen(false)};BasicPlayer.prototype.error=function(e){if(!WGo.ERROR_REPORT)throw e;var t="#";switch(e.name){case"InvalidMoveError":this.showMessage("

"+e.name+"

"+e.message+"

If this message isn't correct, please report it by clicking here, otherwise contact maintainer of this site.

');break;case"FileError":this.showMessage("

"+e.name+"

"+e.message+"

Please contact maintainer of this site. Note: it is possible to read files only from this host.

");break;default:this.showMessage("

"+e.name+"

"+e.message+"

"+e.stacktrace+'

Please contact maintainer of this site. You can also report it here.

')}};BasicPlayer.component={};BasicPlayer.layouts={one_column:{top:[],bottom:[]},no_comment:{top:[],bottom:[]},right_top:{top:[],right:[]},right:{right:[]},minimal:{bottom:[]}};BasicPlayer.dynamicLayout=[{conditions:{minWidth:650},layout:BasicPlayer.layouts["right_top"],className:"wgo-twocols wgo-large"},{conditions:{minWidth:550,minHeight:600},layout:BasicPlayer.layouts["one_column"],className:"wgo-medium"},{conditions:{minWidth:350},layout:BasicPlayer.layouts["no_comment"],className:"wgo-small"},{layout:BasicPlayer.layouts["no_comment"],className:"wgo-xsmall"}];BasicPlayer.default={layout:BasicPlayer.dynamicLayout};WGo.i18n.en["BP:closemsg"]="click anywhere to close this window";BasicPlayer.attributes={"data-wgo":function(e){if(e){if(e[0]=="(")this.sgf=e;else this.sgfFile=e}},"data-wgo-board":function(value){this.board=eval("({"+value+"})")},"data-wgo-onkifuload":function(e){this.kifuLoaded=new Function(e)},"data-wgo-onupdate":function(e){this.update=new Function(e)},"data-wgo-onfrozen":function(e){this.frozen=new Function(e)},"data-wgo-onunfrozen":function(e){this.unfrozen=new Function(e)},"data-wgo-layout":function(value){this.layout=eval("({"+value+"})")},"data-wgo-enablewheel":function(e){if(e.toLowerCase()=="false")this.enableWheel=false},"data-wgo-lockscroll":function(e){if(e.toLowerCase()=="false")this.lockScroll=false},"data-wgo-enablekeys":function(e){if(e.toLowerCase()=="false")this.enableKeys=false},"data-wgo-rememberpath":function(e){if(e.toLowerCase()=="false")this.rememberPath=false},"data-wgo-move":function(value){var m=parseInt(value);if(m)this.move=m;else this.move=eval("({"+value+"})")}};var player_from_tag=function(e){var t,n,r;n={};for(var i=0;ie.offsetHeight){r-=2;while(e.scrollHeight>e.offsetHeight&&r>1){e.style.fontSize=r+"px";r-=2}}else if(re.offsetHeight){e.style.fontSize=r-4+"px"}}};var s=function(e){if(e.node.BL)this.setPlayerTime("black",e.node.BL);if(e.node.WL)this.setPlayerTime("white",e.node.WL);if(e.position.capCount.black!==undefined)this.black.info.caps.val.innerHTML=e.position.capCount.black;if(e.position.capCount.white!==undefined)this.white.info.caps.val.innerHTML=e.position.capCount.white};var o=WGo.extendClass(WGo.BasicPlayer.component.Component,function(t){this.super(t);this.element.className="wgo-infobox";e.call(this);t.addEventListener("kifuLoaded",r.bind(this));t.addEventListener("update",s.bind(this))});o.prototype.setPlayerTime=function(e,t){var n=Math.floor(t/60);var r=Math.round(t)%60;this[e].info.time.val.innerHTML=n+":"+(r<10?"0"+r:r)};o.prototype.updateDimensions=function(){i(this.black.name);i(this.white.name)};var u=WGo.BasicPlayer.layouts;u["right_top"].right.push("InfoBox");u["right"].right.push("InfoBox");u["one_column"].top.push("InfoBox");u["no_comment"].top.push("InfoBox");WGo.i18n.en["rank"]="Rank";WGo.i18n.en["caps"]="Caps";WGo.i18n.en["time"]="Time";WGo.BasicPlayer.component.InfoBox=o})(WGo);(function(e,t){"use strict";var n=function(e,t){if(e.weightt.weight)return 1;else return 0};var r=function(e){this.iconBar=document.createElement("div");this.iconBar.className="wgo-control-wrapper";this.element.appendChild(this.iconBar);var t;for(var n in i.widgets){t=new i.widgets[n].constructor(e,i.widgets[n].args);t.appendTo(this.iconBar);this.widgets.push(t)}};var i=e.extendClass(e.BasicPlayer.component.Component,function(e){this.super(e);this.widgets=[];this.element.className="wgo-player-control";r.call(this,e)});i.prototype.updateDimensions=function(){if(this.element.clientWidth<340)this.element.className="wgo-player-control wgo-340";else if(this.element.clientWidth<440)this.element.className="wgo-player-control wgo-440";else this.element.className="wgo-player-control"};var s=e.BasicPlayer.control={};var o=function(e){if(!e.node.parent&&!this.disabled)this.disable();else if(e.node.parent&&this.disabled)this.enable()};var u=function(e){if(!e.node.children.length&&!this.disabled)this.disable();else if(e.node.children.length&&this.disabled)this.enable()};var a=function(e){this._disabled=this.disabled;if(!this.disabled)this.disable()};var f=function(e){if(!this._disabled)this.enable();delete this._disabled};s.Widget=function(e,t){this.element=this.element||document.createElement(t.type||"div");this.element.className="wgo-widget-"+t.name;this.init(e,t)};s.Widget.prototype={constructor:s.Widget,init:function(e,t){if(!t)return;if(t.disabled)this.disable();if(t.init)t.init.call(this,e)},appendTo:function(e){e.appendChild(this.element)},disable:function(){this.disabled=true;if(this.element.className.search("wgo-disabled")==-1){this.element.className+=" wgo-disabled"}},enable:function(){this.disabled=false;this.element.className=this.element.className.replace(" wgo-disabled","");this.element.disabled=""}};s.Group=e.extendClass(s.Widget,function(e,t){this.element=document.createElement("div");this.element.className="wgo-ctrlgroup wgo-ctrlgroup-"+t.name;var n;for(var r in t.widgets){n=new t.widgets[r].constructor(e,t.widgets[r].args);n.appendTo(this.element)}});s.Clickable=e.extendClass(s.Widget,function(e,t){this.super(e,t)});s.Clickable.prototype.init=function(e,t){var n,r=this;if(t.togglable){n=function(){if(r.disabled)return;if(t.click.call(r,e))r.select();else r.unselect()}}else{n=function(){if(r.disabled)return;t.click.call(r,e)}}this.element.addEventListener("click",n);this.element.addEventListener("touchstart",function(e){e.preventDefault();n();if(t.multiple){r._touch_i=0;r._touch_int=window.setInterval(function(){if(r._touch_i>500){n()}r._touch_i+=100},100)}return false});if(t.multiple){this.element.addEventListener("touchend",function(e){window.clearInterval(r._touch_int)})}if(t.disabled)this.disable();if(t.init)t.init.call(this,e)};s.Clickable.prototype.select=function(){this.selected=true;if(this.element.className.search("wgo-selected")==-1)this.element.className+=" wgo-selected"};s.Clickable.prototype.unselect=function(){this.selected=false;this.element.className=this.element.className.replace(" wgo-selected","")};s.Button=e.extendClass(s.Clickable,function(t,n){var r=this.element=document.createElement("button");r.className="wgo-button wgo-button-"+n.name;r.title=e.t(n.name);this.init(t,n)});s.Button.prototype.disable=function(){s.Button.prototype.super.prototype.disable.call(this);this.element.disabled="disabled"};s.Button.prototype.enable=function(){s.Button.prototype.super.prototype.enable.call(this);this.element.disabled=""};s.MenuItem=e.extendClass(s.Clickable,function(t,n){var r=this.element=document.createElement("div");r.className="wgo-menu-item wgo-menu-item-"+n.name;r.title=e.t(n.name);r.innerHTML=r.title;this.init(t,n)});s.MoveNumber=e.extendClass(s.Widget,function(e){this.element=document.createElement("form");this.element.className="wgo-player-mn-wrapper";var t=this.move=document.createElement("input");t.type="text";t.value="0";t.maxlength=3;t.className="wgo-player-mn-value";this.element.appendChild(t);this.element.onsubmit=t.onchange=function(e){e.goTo(this.getValue());return false}.bind(this,e);e.addEventListener("update",function(e){this.setValue(e.path.m)}.bind(this));e.addEventListener("kifuLoaded",this.enable.bind(this));e.addEventListener("frozen",this.disable.bind(this));e.addEventListener("unfrozen",this.enable.bind(this))});s.MoveNumber.prototype.disable=function(){s.MoveNumber.prototype.super.prototype.disable.call(this);this.move.disabled="disabled"};s.MoveNumber.prototype.enable=function(){s.MoveNumber.prototype.super.prototype.enable.call(this);this.move.disabled=""};s.MoveNumber.prototype.setValue=function(e){this.move.value=e};s.MoveNumber.prototype.getValue=function(){return parseInt(this.move.value)};var l=function(e){if(e._menu_tmp){delete e._menu_tmp;return}if(!e.menu){e.menu=document.createElement("div");e.menu.className="wgo-player-menu";e.menu.style.position="absolute";e.menu.style.display="none";e.element.appendChild(e.menu);var t;for(var n in i.menu){t=new i.menu[n].constructor(e,i.menu[n].args,true);t.appendTo(e.menu)}}if(e.menu.style.display!="none"){e.menu.style.display="none";document.removeEventListener("click",e._menu_ev);delete e._menu_ev;this.unselect();return false}else{e.menu.style.display="block";var r=0;var s=0;var o=this.element;while(o&&o.tagName!="BODY"){r+=o.offsetTop;s+=o.offsetLeft;o=o.offsetParent}if(this.element.parentElement.parentElement.parentElement.parentElement==e.regions.bottom.wrapper){e.menu.style.left=s+"px";e.menu.style.top=r-e.menu.offsetHeight+1+"px"}else{e.menu.style.left=s+"px";e.menu.style.top=r+this.element.offsetHeight+"px"}e._menu_ev=l.bind(this,e);e._menu_tmp=true;document.addEventListener("click",e._menu_ev);return true}};i.menu=[{constructor:s.MenuItem,args:{name:"switch-coo",togglable:true,click:function(e){e.setCoordinates(!e.coordinates);return e.coordinates},init:function(e){if(e.coordinates)this.select()}}}];i.widgets=[{constructor:s.Group,args:{name:"left",widgets:[{constructor:s.Button,args:{name:"menu",togglable:true,click:l}}]}},{constructor:s.Group,args:{name:"right",widgets:[{constructor:s.Button,args:{name:"about",click:function(t){t.showMessage(e.t("about-text"))}}}]}},{constructor:s.Group,args:{name:"control",widgets:[{constructor:s.Button,args:{name:"first",disabled:true,init:function(e){e.addEventListener("update",o.bind(this));e.addEventListener("frozen",a.bind(this));e.addEventListener("unfrozen",f.bind(this))},click:function(e){e.first()}}},{constructor:s.Button,args:{name:"multiprev",disabled:true,multiple:true,init:function(e){e.addEventListener("update",o.bind(this));e.addEventListener("frozen",a.bind(this));e.addEventListener("unfrozen",f.bind(this))},click:function(t){var n=e.clone(t.kifuReader.path);n.m-=10;t.goTo(n)}}},{constructor:s.Button,args:{name:"previous",disabled:true,multiple:true,init:function(e){e.addEventListener("update",o.bind(this));e.addEventListener("frozen",a.bind(this));e.addEventListener("unfrozen",f.bind(this))},click:function(e){e.previous()}}},{constructor:s.MoveNumber},{constructor:s.Button,args:{name:"next",disabled:true,multiple:true,init:function(e){e.addEventListener("update",u.bind(this));e.addEventListener("frozen",a.bind(this));e.addEventListener("unfrozen",f.bind(this))},click:function(e){e.next()}}},{constructor:s.Button,args:{name:"multinext",disabled:true,multiple:true,init:function(e){e.addEventListener("update",u.bind(this));e.addEventListener("frozen",a.bind(this));e.addEventListener("unfrozen",f.bind(this))},click:function(t){var n=e.clone(t.kifuReader.path);n.m+=10;t.goTo(n)}}},{constructor:s.Button,args:{name:"last",disabled:true,init:function(e){e.addEventListener("update",u.bind(this));e.addEventListener("frozen",a.bind(this));e.addEventListener("unfrozen",f.bind(this))},click:function(e){e.last()}}}]}}];var c=e.BasicPlayer.layouts;c["right_top"].top.push("Control");c["right"].right.push("Control");c["one_column"].top.push("Control");c["no_comment"].bottom.push("Control");c["minimal"].bottom.push("Control");var h={about:"About",first:"First",multiprev:"10 moves back",previous:"Previous",next:"Next",multinext:"10 moves forward",last:"Last","switch-coo":"Display coordinates",menu:"Menu"};for(var p in h)e.i18n.en[p]=h[p];e.BasicPlayer.component.Control=i})(WGo);(function(e,t){"use strict";var n=function(){this.box=document.createElement("div");this.box.className="wgo-box-wrapper wgo-comments-wrapper";this.element.appendChild(this.box);this.comments_title=document.createElement("div");this.comments_title.className="wgo-box-title";this.comments_title.innerHTML=e.t("comments");this.box.appendChild(this.comments_title);this.comments=document.createElement("div");this.comments.className="wgo-comments-content";this.box.appendChild(this.comments);this.help=document.createElement("div");this.help.className="wgo-help";this.help.style.display="none";this.comments.appendChild(this.help);this.notification=document.createElement("div");this.notification.className="wgo-notification";this.notification.style.display="none";this.comments.appendChild(this.notification);this.comment_text=document.createElement("div");this.comment_text.className="wgo-comment-text";this.comments.appendChild(this.comment_text)};var r=function(e){var t,n;t=e.charCodeAt(0)-"a".charCodeAt(0);if(t<0)t+="a".charCodeAt(0)-"A".charCodeAt(0);if(t>7)t--;n=e.charCodeAt(1)-"0".charCodeAt(0);if(e.length>2)n=n*10+(e.charCodeAt(2)-"0".charCodeAt(0));n=this.kifuReader.game.size-n;this._tmp_mark={type:"MA",x:t,y:n};this.board.addObject(this._tmp_mark)};var i=function(){this.board.removeObject(this._tmp_mark);delete this._tmp_mark};var s=function(e,t){for(var n in e){if(e[n].className&&e[n].className=="wgo-move-link"){e[n].addEventListener("mouseover",r.bind(t,e[n].innerHTML));e[n].addEventListener("mouseout",i.bind(t))}else if(e[n].childNodes&&e[n].childNodes.length)s(e[n].childNodes,t)}};var o=function(t,n){var r='
';if(n)r+='
'+e.t("gameinfo")+"
";for(var i in t){r+='
'+i+''+t[i]+"
"}r+="
";return r};var u=e.extendClass(e.BasicPlayer.component.Component,function(t){this.super(t);this.player=t;this.element.className="wgo-commentbox";n.call(this);t.addEventListener("kifuLoaded",function(n){if(n.kifu.hasComments()){this.comments_title.innerHTML=e.t("comments");this.element.className="wgo-commentbox";this._update=function(e){this.setComments(e)}.bind(this);t.addEventListener("update",this._update)}else{this.comments_title.innerHTML=e.t("gameinfo");this.element.className="wgo-commentbox wgo-gameinfo";if(this._update){t.removeEventListener("update",this._update);delete this._update}this.comment_text.innerHTML=o(n.target.getGameInfo())}}.bind(this));t.notification=function(e){if(e){this.notification.style.display="block";this.notification.innerHTML=e;this.is_notification=true}else{this.notification.style.display="none";this.is_notification=false}if(this.is_notification||this.is_help)this.comment_text.style.display="none";else this.comment_text.style.display="block"}.bind(this);t.help=function(e){if(e){this.help.style.display="block";this.help.innerHTML=e;this.is_help=true}else{this.help.style.display="none";this.is_help=false}if(this.is_notification||this.is_help)this.comment_text.style.display="none";else this.comment_text.style.display="block"}.bind(this)});u.prototype.setComments=function(e){if(this.player._tmp_mark)i.call(this.player);var t="";if(!e.node.parent){t=o(e.target.getGameInfo(),true)}this.comment_text.innerHTML=t+this.getCommentText(e.node.comment,this.player.config.formatNicks,this.player.config.formatMoves);if(this.player.config.formatMoves){if(this.comment_text.childNodes&&this.comment_text.childNodes.length)s(this.comment_text.childNodes,this.player)}};u.prototype.getCommentText=function(t,n,r){if(t){var i="

"+e.filterHTML(t).replace(/\n/g,"

")+"

";if(n)i=i.replace(/(

)([^:]{3,}:)\s/g,'

$2 ');if(r)i=i.replace(/\b[a-zA-Z]1?\d\b/g,'$&');return i}return""};e.BasicPlayer.default.formatNicks=true;e.BasicPlayer.default.formatMoves=true;e.BasicPlayer.attributes["data-wgo-formatnicks"]=function(e){if(e.toLowerCase()=="false")this.formatNicks=false};e.BasicPlayer.attributes["data-wgo-formatmoves"]=function(e){if(e.toLowerCase()=="false")this.formatMoves=false};e.BasicPlayer.layouts["right_top"].right.push("CommentBox");e.BasicPlayer.layouts["right"].right.push("CommentBox");e.BasicPlayer.layouts["one_column"].bottom.push("CommentBox");e.i18n.en["comments"]="Comments";e.i18n.en["gameinfo"]="Game info";e.BasicPlayer.component.CommentBox=u})(WGo);(function(e){var t=function(e,t){if(this.player.frozen||this._lastX==e&&this._lastY==t)return;this._lastX=e;this._lastY=t;if(this._last_mark){this.board.removeObject(this._last_mark)}if(e!=-1&&t!=-1&&this.player.kifuReader.game.isValid(e,t)){this._last_mark={type:"outline",x:e,y:t,c:this.player.kifuReader.game.turn};this.board.addObject(this._last_mark)}else{delete this._last_mark}};var n=function(){if(this._last_mark){this.board.removeObject(this._last_mark);delete this._last_mark;delete this._lastX;delete this._lastY}};var r=function(e,t){var n=e.size,r=[],i=[];for(var s=0;s";var a=n.black.length+n.white_captured.length+this.originalPosition.capCount.black;var f=n.white.length+n.black_captured.length+this.originalPosition.capCount.white+parseFloat(this.komi);u+="

"+e.t("black")+": "+n.black.length+" + "+(n.white_captured.length+this.originalPosition.capCount.black)+" = "+a+"
";u+=e.t("white")+": "+n.white.length+" + "+(n.black_captured.length+this.originalPosition.capCount.white)+" + "+this.komi+" = "+f+"

";if(a>f)u+="

"+e.t("bwin",a-f)+"

";else u+="

"+e.t("wwin",f-a)+"

";this.output(u)};n.prototype.calculate=function(){var e,t,n,i,s,o;e=this.position;o=true;while(o){o=false;for(var u=0;u"+e.t("help_score")+"

");this._score_mode=new e.ScoreMode(t.kifuReader.game.position,t.board,t.kifu.info.KM||.5,t.notification);this._score_mode.start();return true}}}})}e.i18n.en["scoremode"]="Count score";e.i18n.en["score"]="Score";e.i18n.en["bwin"]="Black wins by $ points.";e.i18n.en["wwin"]="White wins by $ points.";e.i18n.en["help_score"]="Click on stones to mark them dead or alive. You can also set and unset territory points by clicking on them. Territories must be completely bordered."})(WGo);(function(e,t){"use strict";var n={active:true,query:{}};var r=function(e){try{n.query=JSON.parse('{"'+window.location.hash.substr(1).replace("=",'":')+"}")}catch(t){n.query={}}};window.addEventListener("hashchange",function(){if(window.location.hash!=""&&n.active){r();for(var e in n.query){var t=document.getElementById(e);if(t&&t._wgo_player)t._wgo_player.goTo(i)}}});window.addEventListener("DOMContentLoaded",function(){if(window.location.hash!=""&&n.active){r()}});window.addEventListener("load",function(){if(window.location.hash!=""&&n.active){for(var e in n.query){var t=document.getElementById(e);if(t&&t._wgo_player){t.scrollIntoView();break}}}});var i=function(){if(n.query[this.element.id]){return n.query[this.element.id].goto}};e.Player.default.move=i;if(e.BasicPlayer&&e.BasicPlayer.component.Control){e.BasicPlayer.component.Control.menu.push({constructor:e.BasicPlayer.control.MenuItem,args:{name:"permalink",click:function(t){var n=location.href.split("#")[0]+"#"+t.element.id+'={"goto":'+JSON.stringify(t.kifuReader.path)+"}";t.showMessage("

"+e.t("permalink")+'

')}}})}e.Player.permalink=n;e.i18n.en["permalink"]="Permanent link"})(WGo) \ No newline at end of file diff --git a/interface/server/wgo/wood1.jpg b/interface/server/wgo/wood1.jpg deleted file mode 100644 index 6c8ae24a06f7d0f810a380d92f22526952f7d2d5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2459 zcmbW1X;f257RM`_Kp-T+1f-h=*|bF)42vCP2_U-&vbx}cf+CAOgoq$2QPC!DAd5f? zg3yQv(;aDKbC?DZd7vU{*cDmAB19x12H|CS9cJeAoHL(h=GLt*Rdwt9e^vKhWB?fj zByV>wcYs2nfTwZ*w2|1uO>sd1Hxsv2Hhd7zdA&?pQBjm4~}R-Qhq+y_`PPRo$yylS=o zAr&Jg#qxY+iK?+n^DTmS6_Gi#eI-_vckZgX{W_we-c z4h#wo*|mGm-tdScMqQm7IF<(vNAJ%U80pb8hD5<^Oc6^yjj><@YM? zSN>M>u(qzgq481cleVYrP)FypH@u$SzW#y1p|NrP#N^cUhnZQ?g7~v!@ypUOyuyV7 zn15pZN%kLHWF;3Gi^X77R=7~;MCHVgu{cB8DlKP!l|#(cMwaJQDK42M)sNMTtpbG9 z!||{2+9uXxrlJ+Je~|rmU>E)`vOmH8%{2rxFev5cVaR|1;EX`j-M{JDYCkC&n67oJ ztXo@PuUMoli2~nePYsi-6xXbvzN+YI#ia0*RXcMDuQ;7M4}VI@&^b+#kG=~ls6)Uj zvv2bi6Uk)`ep2^xFgluAP!7q(JKI`1qc3XYaI!O4KK0e66;D!cLtTG)@+GH@LvXZ@ z!){`W#=IiWJ5(z^zljvG0#+fwi9=kTcvAf3Qiw>=#F_Ox_jOkz0&=s`?Sy7_xQ*7| z&=##;I;h=CEodFy%qwExxBKXD!WH|8R6>mW6!le23Nbr{)k#KxchwnIVx_pSr7&3( z!gbz!;Ot=om=N^$?!}_=pRa|MbP*76@RT|%1A6$)^7WOWL@KDD9aJ zsPQ=^6$%|M{Y!^gMa`nc5tv|!(-bm0cAsMeMD32xw?%;0jZt=^-$Mgy z54~|MTbSoNX{nkQTX#_}({5U!2s-vOb}p@woWAsq(OP~z-nixQo_*6i%OL-Vy-uv` zNlf>B=8me32-pv5bh~)T3IxFZk&0T{Q1==^;Zy+`9Tf+}?ob{re_G&hyt-=R6aoTR+2rwFuw_ z=t!2xUa1LmI-@B~PtIt~A}*_j4~5Yr6rYh9mSo~F^!u?i%uc#M`hMV(8TXgwUmkz- zJ2`kuhC;w59mX%_6?fVjThK|J^D_!6wZmxIAM&eNN9%81>KVGs2p^smMJR$T1#CfF zhD&K!c;Vq$H~SN&Q8)Cgof6uN7qGnLkTZn{xJ!d%=$|ir@dF=dx-ww@=5nm(I}T}@ zo?Upl^NJzoDt$8%&QYf0QxDSHXuP-~wVPWQ{UYotor67U%2+}G0`_tf7Ige8RUhU3 z>Q`jT@XmSx zcZ){00ew^R1>WNZ=Piu7|Z+0YmaTyg`S1!(;W zw!oTEqKIL`Z(OqtCLw|U0A@Ngj=}Hzjr~=3y<~t_l03%xuFuzrG@dI0JvNw(<&y@_ zvL?ni_CZ27H$J{?Kqzqf-ank!-!$me`*YjS7$!Csq zLbdB|-wZM?+^e@Zzem}JGWV+|db{d0EnYL=$)e2J6UJwfFdrMNr4tBnYwpmy_~oOS z+V3%4K76}FLD1NW5@lVs-Q-F>Y-j9ge0hKjjcq4xP1DbkARsBg?};z%n!RyGYZ>S0 zQfAMlGM3`i6qhS`S9EDd5XmA#cgr(YG0Xjd-(YA30zA`7;GlZW(V{6wAT<*UIx8gE zIe9}#HE>W*%t1jTb4Gr0qkH_aN^xBS0wA-emFN0~y^Ji`^SPC;Xd2BDiJ@|t0ry5j z*lk6v7INHuC@%MD@Yi#7{L%-I-*POY{oX=#q`{%zoKz8T27hI(hMkA*rhCecg@5q% z@luO@8vrC7`&oarE(3cFXE7d(8M<@1Hx~ZD`V6J?zsY6{jaX%HT>%tDHt=UdFR0 QD+Hg+=9EvjpNI_p0b!Yt`Tzg` From ed3a4196947d6b3827d825d6361f0438209fe143 Mon Sep 17 00:00:00 2001 From: wrongu Date: Thu, 21 Apr 2016 17:04:23 -0400 Subject: [PATCH 034/191] CNNPolicy.save_model will also save weights if specified --- AlphaGo/models/policy.py | 10 +++++++++- tests/test_policy.py | 13 +++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/AlphaGo/models/policy.py b/AlphaGo/models/policy.py index 486e6a2ff..8d75202a2 100644 --- a/AlphaGo/models/policy.py +++ b/AlphaGo/models/policy.py @@ -163,11 +163,16 @@ def load_model(json_file): object_specs = json.load(f) new_policy = CNNPolicy(object_specs['feature_list']) new_policy.model = model_from_json(object_specs['keras_model']) + if 'weights_file' in object_specs: + new_policy.model.load_weights(object_specs['weights_file']) new_policy.forward = new_policy._model_forward() return new_policy - def save_model(self, json_file): + def save_model(self, json_file, weights_file=None): """write the network model and preprocessing features to the specified file + + If a weights_file (.hdf5 extension) is also specified, model weights are also + saved to that file and will be reloaded automatically in a call to load_model """ # this looks odd because we are serializing a model with json as a string # then making that the value of an object which is then serialized as @@ -180,6 +185,9 @@ def save_model(self, json_file): 'keras_model': self.model.to_json(), 'feature_list': self.preprocessor.feature_list } + if weights_file is not None: + self.model.save_weights(weights_file) + object_specs['weights_file'] = weights_file # use the json module to write object_specs to file with open(json_file, 'w') as f: json.dump(object_specs, f) diff --git a/tests/test_policy.py b/tests/test_policy.py index dab7ff4b8..5e3240efc 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -2,6 +2,7 @@ from AlphaGo import go from AlphaGo.go import GameState from AlphaGo.ai import GreedyPolicyPlayer, ProbabilisticPolicyPlayer +import numpy as np import unittest import os @@ -33,15 +34,27 @@ def test_save_load(self): model_file = 'TESTPOLICY.json' weights_file = 'TESTWEIGHTS.h5' + model_file2 = 'TESTPOLICY2.json' + weights_file2 = 'TESTWEIGHTS2.h5' + # test saving model/weights separately policy.save_model(model_file) policy.model.save_weights(weights_file) + # test saving them together + policy.save_model(model_file2, weights_file2) copypolicy = CNNPolicy.load_model(model_file) copypolicy.model.load_weights(weights_file) + copypolicy2 = CNNPolicy.load_model(model_file2) + + for w1, w2 in zip(copypolicy.model.get_weights(), copypolicy2.model.get_weights()): + self.assertTrue(np.all(w1 == w2)) + os.remove(model_file) os.remove(weights_file) + os.remove(model_file2) + os.remove(weights_file2) class TestPlayers(unittest.TestCase): From 50faa0366d1b998a715cf7f2a7e40749c570ce8a Mon Sep 17 00:00:00 2001 From: wrongu Date: Fri, 22 Apr 2016 11:10:53 -0400 Subject: [PATCH 035/191] interface.gtp_wrapper can run any player object as a GTP engine --- interface/__init__.py | 0 interface/gtp_wrapper.py | 46 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 interface/__init__.py create mode 100644 interface/gtp_wrapper.py diff --git a/interface/__init__.py b/interface/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/interface/gtp_wrapper.py b/interface/gtp_wrapper.py new file mode 100644 index 000000000..982f51308 --- /dev/null +++ b/interface/gtp_wrapper.py @@ -0,0 +1,46 @@ +from AlphaGo import go +import gtp +import sys + + +class GTPGameConnector(object): + """A class implementing the functions of a 'game' object required by the GTP + Engine by wrapping a GameState and Player instance + """ + + def __init__(self, player): + self._state = go.GameState() + self._player = player + + def clear(self): + self._state = go.GameState(self._state.size) + + def make_move(self, color, vertex): + # vertex in GTP language is 1-indexed, whereas GameState's are zero-indexed + (x, y) = vertex + self._state.do_move((x - 1, y - 1), color) + + def set_size(self, n): + self._state = go.GameState(n) + + def set_komi(self, k): + self._state.komi = k + + def get_move(self, color): + self._state.current_player = color + move = self._player.get_move(self._state) + if move == go.PASS_MOVE: + return gtp.PASS + else: + (x, y) = move + return (x + 1, y + 1) + + +def run_gtp(player_obj): + gtp_game = GTPGameConnector(player_obj) + gtp_engine = gtp.Engine(gtp_game) + + while not gtp_engine.disconnect: + engine_reply = gtp_engine.send(raw_input()) + sys.stdout.write(engine_reply) + sys.stdout.flush() From f602a83298df2542b55fb611259e25838dbba4ed Mon Sep 17 00:00:00 2001 From: wrongu Date: Fri, 22 Apr 2016 12:19:28 -0400 Subject: [PATCH 036/191] test_gtp_wrapper, and associated changes to gtp_wrapper --- interface/gtp_wrapper.py | 25 +++++++++++++++++++------ tests/test_gtp_wrapper.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 tests/test_gtp_wrapper.py diff --git a/interface/gtp_wrapper.py b/interface/gtp_wrapper.py index 982f51308..91c1480ab 100644 --- a/interface/gtp_wrapper.py +++ b/interface/gtp_wrapper.py @@ -17,8 +17,11 @@ def clear(self): def make_move(self, color, vertex): # vertex in GTP language is 1-indexed, whereas GameState's are zero-indexed - (x, y) = vertex - self._state.do_move((x - 1, y - 1), color) + if vertex == gtp.PASS: + self._state.do_move(go.PASS_MOVE) + else: + (x, y) = vertex + self._state.do_move((x - 1, y - 1), color) def set_size(self, n): self._state = go.GameState(n) @@ -36,11 +39,21 @@ def get_move(self, color): return (x + 1, y + 1) -def run_gtp(player_obj): +def run_gtp(player_obj, inpt_fn=None): gtp_game = GTPGameConnector(player_obj) gtp_engine = gtp.Engine(gtp_game) + if inpt_fn is None: + inpt_fn = raw_input while not gtp_engine.disconnect: - engine_reply = gtp_engine.send(raw_input()) - sys.stdout.write(engine_reply) - sys.stdout.flush() + inpt = inpt_fn() + # handle either single lines at a time + # or multiple commands separated by '\n' + try: + cmd_list = inpt.split("\n") + except: + cmd_list = [inpt] + for cmd in cmd_list: + engine_reply = gtp_engine.send(cmd) + sys.stdout.write(engine_reply) + sys.stdout.flush() diff --git a/tests/test_gtp_wrapper.py b/tests/test_gtp_wrapper.py new file mode 100644 index 000000000..5ab4ddb7f --- /dev/null +++ b/tests/test_gtp_wrapper.py @@ -0,0 +1,30 @@ +from interface.gtp_wrapper import run_gtp +from multiprocessing import Process +from AlphaGo import go +import unittest + + +class PassPlayer(object): + def get_move(self, state): + return go.PASS_MOVE + + +class TestGTPProcess(unittest.TestCase): + + def test_run_commands(self): + def stdin_simulator(): + return "\n".join([ + "1 name", + "2 boardsize 19", + "3 clear_board", + "4 genmove black", + "5 genmove white", + "99 quit"]) + + gtp_proc = Process(target=run_gtp, args=(PassPlayer(), stdin_simulator)) + gtp_proc.start() + gtp_proc.join(timeout=1) + + +if __name__ == '__main__': + unittest.main() From d1596518d701636ef2f4d4c778248d9816aabc27 Mon Sep 17 00:00:00 2001 From: wrongu Date: Mon, 25 Apr 2016 10:48:55 -0400 Subject: [PATCH 037/191] gtp_wrapper checks for illegal moves and returns true for legal ones --- interface/gtp_wrapper.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/interface/gtp_wrapper.py b/interface/gtp_wrapper.py index 91c1480ab..2c057d22f 100644 --- a/interface/gtp_wrapper.py +++ b/interface/gtp_wrapper.py @@ -17,11 +17,15 @@ def clear(self): def make_move(self, color, vertex): # vertex in GTP language is 1-indexed, whereas GameState's are zero-indexed - if vertex == gtp.PASS: - self._state.do_move(go.PASS_MOVE) - else: - (x, y) = vertex - self._state.do_move((x - 1, y - 1), color) + try: + if vertex == gtp.PASS: + self._state.do_move(go.PASS_MOVE) + else: + (x, y) = vertex + self._state.do_move((x - 1, y - 1), color) + return True + except go.IllegalMove: + return False def set_size(self, n): self._state = go.GameState(n) From 57433bed645120b6eb0eea856b3c5f6dec37d04f Mon Sep 17 00:00:00 2001 From: wrongu Date: Tue, 26 Apr 2016 07:54:39 -0400 Subject: [PATCH 038/191] pygtp updated (removed submodule, installed version 0.2) --- .gitmodules | 3 --- .travis.yml | 2 +- gtp | 1 - requirements.txt | 1 + 4 files changed, 2 insertions(+), 5 deletions(-) delete mode 160000 gtp diff --git a/.gitmodules b/.gitmodules index d09dad9a9..11fba82bc 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ [submodule "interface/opponents/pachi/pachi"] path = interface/opponents/pachi/pachi url = https://github.com/pasky/pachi -[submodule "gtp"] - path = gtp - url = git@github.com:wrongu/gtp.git diff --git a/.travis.yml b/.travis.yml index 080c73a15..d2663cad9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,7 +14,7 @@ before_install: # Install packages install: - conda install --yes python=2.7 Cython=0.24 h5py=2.6.0 numpy=1.11.0 scipy=0.17.0 PyYAML=3.11 matplotlib pandas pytest - - pip install --user --no-deps Theano==0.8.1 sgf==0.5 keras==1.0.0 + - pip install --user --no-deps Theano==0.8.1 sgf==0.5 keras==1.0.0 gtp==0.2 - pip install --user flake8 # run flake8 and unit tests diff --git a/gtp b/gtp deleted file mode 160000 index 0384bed0f..000000000 --- a/gtp +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 0384bed0f51b126d5caf14ebf8bdcffc5247783b diff --git a/requirements.txt b/requirements.txt index e1ab76373..24511f19c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ Cython==0.24 h5py==2.6.0 Keras==1.0.0 numpy==1.11.0 +pygtp==0.2 PyYAML==3.11 scipy==0.17.0 sgf==0.5 From 8dc260ebb6e160be8065bcbce864d31fbfa61b4d Mon Sep 17 00:00:00 2001 From: wrongu Date: Tue, 26 Apr 2016 11:16:24 -0400 Subject: [PATCH 039/191] readme update with new name --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8be2d2399..d6ee97ccb 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,12 @@ This project is a student-led replication/reference implementation of DeepMind's 2016 Nature publication, "Mastering the game of Go with deep neural networks and tree search," details of which can be found [on their website](http://deepmind.com/alpha-go.html). This implementation uses Python and Keras - a decision to prioritize code clarity, at least in the early stages. -[![Build Status](https://travis-ci.org/Rochester-NRT/AlphaGo.svg?branch=develop)](https://travis-ci.org/Rochester-NRT/AlphaGo) -[![Gitter](https://badges.gitter.im/Rochester-NRT/AlphaGo.svg)](https://gitter.im/Rochester-NRT/AlphaGo?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) +[![Build Status](https://travis-ci.org/Rochester-NRT/RocAlphaGo.svg?branch=develop)](https://travis-ci.org/Rochester-NRT/RocAlphaGo) +[![Gitter](https://badges.gitter.im/Rochester-NRT/RocAlphaGo.svg)](https://gitter.im/Rochester-NRT/RocAlphaGo?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) # Documentation -See the [project wiki](https://github.com/Rochester-NRT/AlphaGo/wiki). +See the [project wiki](https://github.com/Rochester-NRT/RocAlphaGo/wiki). # Current project status @@ -19,8 +19,8 @@ Selected data (i.e. trained models) are released in our [data repository](http:/ This project has primarily focused on the neural network training aspect of DeepMind's AlphaGo. We also have a simple single-threaded implementation of their tree search algorithm, though it is not fast enough to be competitive yet. -See the wiki page on the [training pipeline](https://github.com/Rochester-NRT/AlphaGo/wiki/Neural-Networks-and-Training) for information on how to run the training commands. +See the wiki page on the [training pipeline](https://github.com/Rochester-NRT/RocAlphaGo/wiki/Neural-Networks-and-Training) for information on how to run the training commands. # How to contribute -See the ['Contributing'](CONTRIBUTING.md) document and join the [Gitter chat](https://gitter.im/Rochester-NRT/AlphaGo?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge). +See the ['Contributing'](CONTRIBUTING.md) document and join the [Gitter chat](https://gitter.im/Rochester-NRT/RocAlphaGo). From 000642d4a068792089a5126180f1a0d18a6a96e8 Mon Sep 17 00:00:00 2001 From: wrongu Date: Tue, 26 Apr 2016 11:17:40 -0400 Subject: [PATCH 040/191] whitespace in Play.py --- interface/Play.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/interface/Play.py b/interface/Play.py index bc408ad07..57d750cc9 100644 --- a/interface/Play.py +++ b/interface/Play.py @@ -1,7 +1,7 @@ """Interface for AlphaGo self-play""" from AlphaGo.go import GameState -# Deprecated? + class play_match(object): """Interface to handle play between two players.""" def __init__(self, player1, player2, save_dir=None, size=19): From 0123ecae10d5b588de145914ab27e8857864369e Mon Sep 17 00:00:00 2001 From: wrongu Date: Tue, 26 Apr 2016 14:25:08 -0400 Subject: [PATCH 041/191] run_gtp prints a useful message when done initializing --- interface/gtp_wrapper.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/interface/gtp_wrapper.py b/interface/gtp_wrapper.py index 2c057d22f..22838e107 100644 --- a/interface/gtp_wrapper.py +++ b/interface/gtp_wrapper.py @@ -49,6 +49,8 @@ def run_gtp(player_obj, inpt_fn=None): if inpt_fn is None: inpt_fn = raw_input + sys.stderr.write("GTP engine ready\n") + sys.stderr.flush() while not gtp_engine.disconnect: inpt = inpt_fn() # handle either single lines at a time From f95d0930312c05480e1ef913d5f91cb0a771f282 Mon Sep 17 00:00:00 2001 From: wrongu Date: Thu, 28 Apr 2016 11:22:12 -0400 Subject: [PATCH 042/191] typo --- AlphaGo/training/supervised_policy_trainer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AlphaGo/training/supervised_policy_trainer.py b/AlphaGo/training/supervised_policy_trainer.py index 27b591d70..2c9ca3a60 100644 --- a/AlphaGo/training/supervised_policy_trainer.py +++ b/AlphaGo/training/supervised_policy_trainer.py @@ -146,7 +146,7 @@ def run_training(cmd_line_args=None): with open(meta_file, "r") as f: meta_writer.metadata = json.load(f) if args.verbose: - print "previous metadata loadeda: %d epochs. new epochs will be appended." % len(meta_writer.metadata["epochs"]) + print "previous metadata loaded: %d epochs. new epochs will be appended." % len(meta_writer.metadata["epochs"]) elif args.verbose: print "starting with empty metadata" # the MetadataWriterCallback only sets 'epoch' and 'best_epoch'. We can add in anything else we like here From 2f1e200337207e742f51f446ed10ed3d635a5b50 Mon Sep 17 00:00:00 2001 From: wrongu Date: Thu, 28 Apr 2016 11:26:33 -0400 Subject: [PATCH 043/191] in training, MetadataWriterCallback fixed best_epoch for resuming training --- AlphaGo/training/supervised_policy_trainer.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AlphaGo/training/supervised_policy_trainer.py b/AlphaGo/training/supervised_policy_trainer.py index 2c9ca3a60..7a2996eb7 100644 --- a/AlphaGo/training/supervised_policy_trainer.py +++ b/AlphaGo/training/supervised_policy_trainer.py @@ -50,6 +50,9 @@ def __init__(self, path): } def on_epoch_end(self, epoch, logs={}): + # in case appending to logs (resuming training), get epoch number ourselves + epoch = len(self.metadata["epochs"]) + self.metadata["epochs"].append(logs) if "val_loss" in logs: From 9e758fe2ec80ac30bdfd1d00e11ecab8081e4bcb Mon Sep 17 00:00:00 2001 From: BeierZhu Date: Fri, 29 Apr 2016 16:53:48 +0200 Subject: [PATCH 044/191] fix a bug in _remove_group There is a problem in counting the liberties when you remove a group of stone whose size is strictly bigger than 1 --- AlphaGo/go.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/AlphaGo/go.py b/AlphaGo/go.py index e333ac0f2..924215ce4 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -136,9 +136,9 @@ def _update_neighbors(self, position): self.liberty_counts[gx][gy] = count_merged_libs def _remove_group(self, group): - """A private helper function to take a group off the board (due to capture), - updating group sets and liberties along the way - """ + """A private helper function to take a group off the board (due to capture), + updating group sets and liberties along the way + """ for (x, y) in group: self.board[x, y] = EMPTY for (x, y) in group: @@ -150,11 +150,11 @@ def _remove_group(self, group): if self.board[nx, ny] == EMPTY: # add empty neighbors of (x,y) to its liberties self.liberty_sets[x][y].add((nx, ny)) - self.liberty_counts[x][y] += 1 else: # add (x,y) to the liberties of its nonempty neighbors self.liberty_sets[nx][ny].add((x, y)) - self.liberty_counts[nx][ny] += 1 + for (gx,gy) in self.group_sets[nx][ny]: + self.liberty_counts[gx][gy] += 1 def copy(self): """get a copy of this Game state From 64dd8e127edea47af1e339d1f4db874244938206 Mon Sep 17 00:00:00 2001 From: wrongu Date: Fri, 29 Apr 2016 11:29:14 -0400 Subject: [PATCH 045/191] PASS_MOVE constant fixed in do_move --- AlphaGo/go.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AlphaGo/go.py b/AlphaGo/go.py index e333ac0f2..52fe67298 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -340,7 +340,7 @@ def do_move(self, action, color=None): raise IllegalMove(str(action)) # Check for end of game if len(self.history) > 1: - if self.history[-1] is None and self.history[-2] is None \ + if self.history[-1] is PASS_MOVE and self.history[-2] is PASS_MOVE \ and self.current_player == WHITE: self.is_end_of_game = True return self.is_end_of_game From 4270f965fa59ade4f4cfb9de29d23fe6fe9e4fb0 Mon Sep 17 00:00:00 2001 From: wrongu Date: Fri, 29 Apr 2016 11:45:02 -0400 Subject: [PATCH 046/191] test in test_gamestate revealing problem in _remove_group --- tests/test_gamestate.py | 41 +++++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/tests/test_gamestate.py b/tests/test_gamestate.py index 6c644bb3e..9d98b948a 100644 --- a/tests/test_gamestate.py +++ b/tests/test_gamestate.py @@ -1,4 +1,5 @@ from AlphaGo.go import GameState +import numpy as np import AlphaGo.go as go import unittest @@ -35,9 +36,9 @@ def test_snapback_is_not_ko(self): # . . . . . # . . . . . # here, imagine black plays at 'o' capturing - # the white stone at (2,0). White may play - # again at (2,0) to capture the black stones - # at (0,0), (1,0). this is 'snapback' not 'ko' + # the white stone at (2, 0). White may play + # again at (2, 0) to capture the black stones + # at (0, 0), (1, 0). this is 'snapback' not 'ko' # since it doesn't return the game to a # previous position B = [(0, 0), (2, 1), (3, 0)] @@ -61,7 +62,7 @@ class TestEye(unittest.TestCase): def test_simple_eye(self): - # create a black eye in top left (1,1), white in bottom right (5,5) + # create a black eye in top left (1, 1), white in bottom right (5, 5) gs = GameState(size=7) gs.do_move((1, 0)) # B @@ -92,11 +93,11 @@ def test_true_eye(self): gs.do_move((1, 0), go.BLACK) gs.do_move((0, 1), go.BLACK) - # false eye at 0,0 + # false eye at 0, 0 self.assertTrue(gs.is_eyeish((0, 0), go.BLACK)) self.assertFalse(gs.is_eye((0, 0), go.BLACK)) - # make it a true eye by turning the corner (1,1) into an eye itself + # make it a true eye by turning the corner (1, 1) into an eye itself gs.do_move((1, 2), go.BLACK) gs.do_move((2, 1), go.BLACK) gs.do_move((2, 2), go.BLACK) @@ -116,5 +117,33 @@ def test_eye_recursion(self): gs.do_move((x, y), go.BLACK) self.assertTrue(gs.is_eye((0, 0), go.BLACK)) + def test_liberties_after_capture(self): + # creates 3x3 black group in the middle, that is then all captured + # ...then an assertion is made that the resulting liberties after + # capture are the same as if the group had never been there + gs_capture = GameState(7) + gs_reference = GameState(7) + # add in 3x3 black stones + for x in range(2, 5): + for y in range(2, 5): + gs_capture.do_move((x, y), go.BLACK) + # surround the black group with white stones + # and set the same white stones in gs_reference + for x in range(2, 5): + gs_capture.do_move((x, 1), go.WHITE) + gs_capture.do_move((x, 5), go.WHITE) + gs_reference.do_move((x, 1), go.WHITE) + gs_reference.do_move((x, 5), go.WHITE) + for y in range(2, 5): + gs_capture.do_move((1, y), go.WHITE) + gs_capture.do_move((5, y), go.WHITE) + gs_reference.do_move((1, y), go.WHITE) + gs_reference.do_move((5, y), go.WHITE) + + # board configuration and liberties of gs_capture and of gs_reference should be identical + self.assertTrue(np.all(gs_reference.board == gs_capture.board)) + self.assertTrue(np.all(gs_reference.liberty_counts == gs_capture.liberty_counts)) + + if __name__ == '__main__': unittest.main() From f824c293ecd37d61bd9ac70e3bcdffa146725516 Mon Sep 17 00:00:00 2001 From: wrongu Date: Fri, 29 Apr 2016 11:50:31 -0400 Subject: [PATCH 047/191] _remove_groups sets liberty_counts to -1 --- AlphaGo/go.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AlphaGo/go.py b/AlphaGo/go.py index c2c04e38d..14584ef80 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -145,7 +145,7 @@ def _remove_group(self, group): # clear group_sets for all positions in 'group' self.group_sets[x][y] = set() self.liberty_sets[x][y] = set() - self.liberty_counts[x][y] = 0 + self.liberty_counts[x][y] = -1 for (nx, ny) in self._neighbors((x, y)): if self.board[nx, ny] == EMPTY: # add empty neighbors of (x,y) to its liberties From 0697b3f6e0d4b49215ff2b058e700c439282f4a8 Mon Sep 17 00:00:00 2001 From: wrongu Date: Fri, 29 Apr 2016 11:50:57 -0400 Subject: [PATCH 048/191] do_move gets around current_player and hand-coded color issues fixes #95 --- AlphaGo/go.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AlphaGo/go.py b/AlphaGo/go.py index 14584ef80..2d542e20a 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -299,6 +299,8 @@ def do_move(self, action, color=None): If not, an IllegalMove exception is raised """ color = color or self.current_player + reset_player = self.current_player + self.current_player = color if self.is_legal(action): # reset ko self.ko = None @@ -337,6 +339,7 @@ def do_move(self, action, color=None): self.turns_played += 1 self.history.append(action) else: + self.current_player = reset_player raise IllegalMove(str(action)) # Check for end of game if len(self.history) > 1: From 30968596f7c1ea756b4c7ce613b5f81b9e92318f Mon Sep 17 00:00:00 2001 From: wrongu Date: Fri, 29 Apr 2016 11:57:41 -0400 Subject: [PATCH 049/191] travis gtp was wrong library --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index d2663cad9..0728f0e35 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,7 +14,7 @@ before_install: # Install packages install: - conda install --yes python=2.7 Cython=0.24 h5py=2.6.0 numpy=1.11.0 scipy=0.17.0 PyYAML=3.11 matplotlib pandas pytest - - pip install --user --no-deps Theano==0.8.1 sgf==0.5 keras==1.0.0 gtp==0.2 + - pip install --user --no-deps Theano==0.8.1 sgf==0.5 keras==1.0.0 pygtp==0.2 - pip install --user flake8 # run flake8 and unit tests From bb14eab232c29b52546d4fe020e46c3a7e2e818d Mon Sep 17 00:00:00 2001 From: BeierZhu Date: Fri, 29 Apr 2016 18:14:41 +0200 Subject: [PATCH 050/191] a bug in _remove_group Sorry, the last code that I pulled has error in line 157. --- AlphaGo/go.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AlphaGo/go.py b/AlphaGo/go.py index 2d542e20a..fa6ec93b4 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -154,7 +154,7 @@ def _remove_group(self, group): # add (x,y) to the liberties of its nonempty neighbors self.liberty_sets[nx][ny].add((x, y)) for (gx, gy) in self.group_sets[nx][ny]: - self.liberty_counts[gx][gy] += 1 + self.liberty_counts[gx][gy] = len(self.liberty_sets[nx][ny]) def copy(self): """get a copy of this Game state From 217abacb70125964af107027a6c0acc19c3c1973 Mon Sep 17 00:00:00 2001 From: wrongu Date: Sat, 30 Apr 2016 19:26:39 -0400 Subject: [PATCH 051/191] updated test_gamestate; reveals further bugginess in remove_group --- tests/test_gamestate.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_gamestate.py b/tests/test_gamestate.py index 9d98b948a..348911f1e 100644 --- a/tests/test_gamestate.py +++ b/tests/test_gamestate.py @@ -134,6 +134,8 @@ def test_liberties_after_capture(self): gs_capture.do_move((x, 5), go.WHITE) gs_reference.do_move((x, 1), go.WHITE) gs_reference.do_move((x, 5), go.WHITE) + gs_capture.do_move((1, 1), go.WHITE) + gs_reference.do_move((1, 1), go.WHITE) for y in range(2, 5): gs_capture.do_move((1, y), go.WHITE) gs_capture.do_move((5, y), go.WHITE) From 963fca44f90708b06eee8b3b1a3a5a799f4b79c9 Mon Sep 17 00:00:00 2001 From: wrongu Date: Fri, 6 May 2016 10:02:01 -0400 Subject: [PATCH 052/191] rotations/reflections set by command line in training --- AlphaGo/training/supervised_policy_trainer.py | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/AlphaGo/training/supervised_policy_trainer.py b/AlphaGo/training/supervised_policy_trainer.py index 7a2996eb7..d7a1e85a5 100644 --- a/AlphaGo/training/supervised_policy_trainer.py +++ b/AlphaGo/training/supervised_policy_trainer.py @@ -68,16 +68,16 @@ def on_epoch_end(self, epoch, logs={}): json.dump(self.metadata, f) -BOARD_TRANSFORMATIONS = [ - lambda feature: feature, - lambda feature: np.rot90(feature, 1), - lambda feature: np.rot90(feature, 2), - lambda feature: np.rot90(feature, 3), - lambda feature: np.fliplr(feature), - lambda feature: np.flipud(feature), - lambda feature: np.transpose(feature), - lambda feature: np.fliplr(np.rot90(feature, 1)) -] +BOARD_TRANSFORMATIONS = { + "noop": lambda feature: feature, + "rot90": lambda feature: np.rot90(feature, 1), + "rot180": lambda feature: np.rot90(feature, 2), + "rot270": lambda feature: np.rot90(feature, 3), + "fliplr": lambda feature: np.fliplr(feature), + "flipud": lambda feature: np.flipud(feature), + "diag1": lambda feature: np.transpose(feature), + "diag2": lambda feature: np.fliplr(np.rot90(feature, 1)) +} def run_training(cmd_line_args=None): @@ -99,6 +99,7 @@ def run_training(cmd_line_args=None): # slightly fancier args parser.add_argument("--weights", help="Name of a .h5 weights file (in the output directory) to load to resume training", default=None) parser.add_argument("--train-val-test", help="Fraction of data to use for training/val/test. Must sum to 1. Invalid if restarting training", nargs=3, type=float, default=[0.93, .05, .02]) + parser.add_argument("--symmetries", help="Comma-separated list of transforms, subset of noop,rot90,rot180,rot270,fliplr,flipud,diag1,diag2", default='noop,rot90,rot180,rot270,fliplr,flipud,diag1,diag2') # TODO - an argument to specify which transformations to use, put it in metadata if cmd_line_args is None: @@ -182,19 +183,21 @@ def run_training(cmd_line_args=None): val_indices = shuffle_indices[n_train_data:n_train_data + n_val_data] # test_indices = shuffle_indices[n_train_data + n_val_data:] + symmetries = [BOARD_TRANSFORMATIONS[name] for name in args.symmetries.strip().split(",")] + # create dataset generators train_data_generator = shuffled_hdf5_batch_generator( dataset["states"], dataset["actions"], train_indices, args.minibatch, - BOARD_TRANSFORMATIONS) + symmetries) val_data_generator = shuffled_hdf5_batch_generator( dataset["states"], dataset["actions"], val_indices, args.minibatch, - BOARD_TRANSFORMATIONS) + symmetries) sgd = SGD(lr=args.learning_rate, decay=args.decay) model.compile(loss='categorical_crossentropy', optimizer=sgd, metrics=["accuracy"]) From 2bfce2b3b214311f88ad5ac8fd489e912ce3bcde Mon Sep 17 00:00:00 2001 From: wrongu Date: Fri, 6 May 2016 15:36:22 -0400 Subject: [PATCH 053/191] fixes bug in usage of one_hot_action, supervised training this caused 2 rows of the target to be set to 1 rather than a single x,y point --- AlphaGo/training/supervised_policy_trainer.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AlphaGo/training/supervised_policy_trainer.py b/AlphaGo/training/supervised_policy_trainer.py index d7a1e85a5..de712f203 100644 --- a/AlphaGo/training/supervised_policy_trainer.py +++ b/AlphaGo/training/supervised_policy_trainer.py @@ -31,7 +31,9 @@ def shuffled_hdf5_batch_generator(state_dataset, action_dataset, indices, batch_ # get state from dataset and transform it. # loop comprehension is used so that the transformation acts on the 3rd and 4th dimensions state = np.array([transform(plane) for plane in state_dataset[data_idx]]) - action = transform(one_hot_action(action_dataset[data_idx], game_size)) + # must be cast to a tuple so that it is interpreted as (x,y) not [(x,:), (y,:)] + action_xy = tuple(action_dataset[data_idx]) + action = transform(one_hot_action(action_xy, game_size)) Xbatch[batch_idx] = state Ybatch[batch_idx] = action.flatten() batch_idx += 1 From 045464e5c8236aad6b6599ac6f5fcf3239f1f8f6 Mon Sep 17 00:00:00 2001 From: wrongu Date: Tue, 10 May 2016 08:21:11 -0400 Subject: [PATCH 054/191] comment typo --- AlphaGo/preprocessing/preprocessing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AlphaGo/preprocessing/preprocessing.py b/AlphaGo/preprocessing/preprocessing.py index 5685d8253..682c3a9af 100644 --- a/AlphaGo/preprocessing/preprocessing.py +++ b/AlphaGo/preprocessing/preprocessing.py @@ -62,7 +62,7 @@ def get_liberties(state, maximum=8): def get_capture_size(state, maximum=8): - """A feature encoding the number of opponent stones that would be captured by planing at each location, + """A feature encoding the number of opponent stones that would be captured by playing at each location, up to 'maximum' Note: From 2163f90efff4480572b23d396c60bc8042454a7b Mon Sep 17 00:00:00 2001 From: wrongu Date: Tue, 10 May 2016 08:31:53 -0400 Subject: [PATCH 055/191] preprocessing tests to expose bugs in features after capture --- tests/test_preprocessing.py | 71 +++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 3 deletions(-) diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index 55c9c480b..85b3005f2 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -75,6 +75,34 @@ def self_atari_board(): return gs +def capture_board(): + # another small board, this one with imminent captures + # + # X + # 0 1 2 3 4 5 6 + # . . B B . . . 0 + # . B W W B . . 1 + # . B W . . . . 2 + # Y . . B . . . . 3 + # . . . . W B . 4 + # . . . W . W B 5 + # . . . . W B . 6 + # + # current_player = black + gs = go.GameState(size=7) + + black = [(2, 0), (3, 0), (1, 1), (4, 1), (1, 2), (2, 3), (5, 4), (6, 5), (5, 6)] + white = [(2, 1), (3, 1), (2, 2), (4, 4), (3, 5), (5, 5), (4, 6)] + + for B in black: + gs.do_move(B, go.BLACK) + for W in white: + gs.do_move(W, go.WHITE) + gs.current_player = go.BLACK + + return gs + + class TestPreprocessingFeatures(unittest.TestCase): """Test the functions in preprocessing.py @@ -165,15 +193,18 @@ def test_get_liberties(self): "bad expectation: stones with %d liberties" % (i + 1)) def test_get_capture_size(self): - # TODO - at the moment there is no imminent capture - gs = simple_board() + gs = capture_board() pp = Preprocess(["capture_size"]) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) + score_before = gs.num_white_prisoners one_hot_capture = np.zeros((gs.size, gs.size, 8)) # there is no capture available; all legal moves are zero-capture for (x, y) in gs.get_legal_moves(): - one_hot_capture[x, y, 0] = 1 + copy = gs.copy() + copy.do_move((x, y)) + num_captured = copy.num_white_prisoners - score_before + one_hot_capture[x, y, min(7, num_captured)] = 1 for i in range(8): self.assertTrue( @@ -193,6 +224,20 @@ def test_get_self_atari_size(self): self.assertTrue(np.all(feature == one_hot_self_atari)) + def test_get_self_atari_size_cap(self): + gs = capture_board() + pp = Preprocess(["self_atari_size"]) + feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) + + one_hot_self_atari = np.zeros((gs.size, gs.size, 8)) + # self atari of size 1 at the ko position and just below it + one_hot_self_atari[4, 5, 0] = 1 + one_hot_self_atari[3, 6, 0] = 1 + # self atari of size 3 at bottom corner + one_hot_self_atari[6, 6, 2] = 1 + + self.assertTrue(np.all(feature == one_hot_self_atari)) + def test_get_liberties_after(self): gs = simple_board() pp = Preprocess(["liberties_after"]) @@ -215,6 +260,26 @@ def test_get_liberties_after(self): np.all(feature[:, :, i] == one_hot_liberties[:, :, i]), "bad expectation: stones with %d liberties after move" % (i + 1)) + def test_get_liberties_after_cap(self): + """A copy of test_get_liberties_after but where captures are imminent + """ + gs = capture_board() + pp = Preprocess(["liberties_after"]) + feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) + + one_hot_liberties = np.zeros((gs.size, gs.size, 8)) + + for (x, y) in gs.get_legal_moves(): + copy = gs.copy() + copy.do_move((x, y)) + libs = copy.liberty_counts[x, y] + one_hot_liberties[x, y, min(libs - 1, 7)] = 1 + + for i in range(8): + self.assertTrue( + np.all(feature[:, :, i] == one_hot_liberties[:, :, i]), + "bad expectation: stones with %d liberties after move" % (i + 1)) + def test_get_ladder_capture(self): pass From fc80a881e9c04db438f6ac93aef6e3f49c2ae9a7 Mon Sep 17 00:00:00 2001 From: wrongu Date: Tue, 10 May 2016 08:49:12 -0400 Subject: [PATCH 056/191] fixes feature bug when stones captured (liberties_after and self_atari_size) --- AlphaGo/preprocessing/preprocessing.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/AlphaGo/preprocessing/preprocessing.py b/AlphaGo/preprocessing/preprocessing.py index 682c3a9af..aa2894559 100644 --- a/AlphaGo/preprocessing/preprocessing.py +++ b/AlphaGo/preprocessing/preprocessing.py @@ -98,6 +98,7 @@ def get_self_atari_size(state, maximum=8): lib_set_after = set(state.liberty_sets[x][y]) group_set_after = set() group_set_after.add((x, y)) + captured_stones = set() for neighbor_group in state.get_groups_around((x, y)): # if the neighboring group is of the same color as the current player # then playing here will connect this stone to that group @@ -105,6 +106,16 @@ def get_self_atari_size(state, maximum=8): if state.board[gx, gy] == state.current_player: lib_set_after |= state.liberty_sets[gx][gy] group_set_after |= state.group_sets[gx][gy] + # if instead neighboring group is opponent *and about to be captured* + # then we might gain new liberties + elif state.liberty_counts[gx][gy] == 1: + captured_stones |= state.group_sets[gx][gy] + # add captured stones to liberties if they are neighboring the 'group_set_after' + # i.e. if they will become liberties once capture is resolved + if len(captured_stones) > 0: + for (gx, gy) in group_set_after: + # intersection of group's neighbors and captured stones will become liberties + lib_set_after |= set(state._neighbors((gx, gy))) & captured_stones if (x, y) in lib_set_after: lib_set_after.remove((x, y)) # check if this move resulted in atari @@ -129,6 +140,9 @@ def get_liberties_after(state, maximum=8): for (x, y) in state.get_legal_moves(): # make a copy of the set of liberties at (x,y) so we can add to it lib_set_after = set(state.liberty_sets[x][y]) + group_set_after = set() + group_set_after.add((x, y)) + captured_stones = set() for neighbor_group in state.get_groups_around((x, y)): # if the neighboring group is of the same color as the current player # then playing here will connect this stone to that group and @@ -136,6 +150,17 @@ def get_liberties_after(state, maximum=8): (gx, gy) = next(iter(neighbor_group)) if state.board[gx, gy] == state.current_player: lib_set_after |= state.liberty_sets[gx][gy] + group_set_after |= state.group_sets[gx][gy] + # if instead neighboring group is opponent *and about to be captured* + # then we might gain new liberties + elif state.liberty_counts[gx][gy] == 1: + captured_stones |= state.group_sets[gx][gy] + # add captured stones to liberties if they are neighboring the 'group_set_after' + # i.e. if they will become liberties once capture is resolved + if len(captured_stones) > 0: + for (gx, gy) in group_set_after: + # intersection of group's neighbors and captured stones will become liberties + lib_set_after |= set(state._neighbors((gx, gy))) & captured_stones # (x,y) itself may have made its way back in, but shouldn't count # since it's clearly not a liberty after playing there if (x, y) in lib_set_after: From dee153208fd2631117b3d41651f40be3cf73fc56 Mon Sep 17 00:00:00 2001 From: wrongu Date: Tue, 10 May 2016 09:31:54 -0400 Subject: [PATCH 057/191] slight modification to sgf_iter_states to handle AB and AW-only files --- AlphaGo/preprocessing/game_converter.py | 2 +- AlphaGo/util.py | 36 ++++++++++++++----------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/AlphaGo/preprocessing/game_converter.py b/AlphaGo/preprocessing/game_converter.py index 50ca3a86d..de606aabd 100644 --- a/AlphaGo/preprocessing/game_converter.py +++ b/AlphaGo/preprocessing/game_converter.py @@ -30,7 +30,7 @@ def convert_game(self, file_name, bd_size): """ with open(file_name, 'r') as file_object: - state_action_iterator = sgf_iter_states(file_object.read()) + state_action_iterator = sgf_iter_states(file_object.read(), include_end=False) for (state, move, player) in state_action_iterator: if state.size != bd_size: diff --git a/AlphaGo/util.py b/AlphaGo/util.py index ab6603ef7..646b7cd9b 100644 --- a/AlphaGo/util.py +++ b/AlphaGo/util.py @@ -53,33 +53,37 @@ def sgf_to_gamestate(sgf_string): """Creates a GameState object from the first game in the given collection """ # Don't Repeat Yourself; parsing handled by sgf_iter_states - for (gs, move, player) in sgf_iter_states(sgf_string): + for (gs, move, player) in sgf_iter_states(sgf_string, True): pass # gs has been updated in-place to the final state by the time # sgf_iter_states returns return gs -def sgf_iter_states(sgf_string): +def sgf_iter_states(sgf_string, include_end=True): """Iterates over (GameState, move, player) tuples in the first game of the given SGF file. Ignores variations - only the main line is returned. + The state object is modified in-place, so don't try to, for example, keep track of it through time - Note that the final tuple returned is the penultimate state, - but because 'gs' is modified in-place the state is at the final - position after iteration completes. See sgf_to_gamestate + If include_end is False, the final tuple yielded is the penultimate state, but the state + will still be left in the final position at the end of iteration because 'gs' is modified + in-place the state. See sgf_to_gamestate """ collection = sgf.parse(sgf_string) game = collection[0] gs = _sgf_init_gamestate(game.root) - for node in game.rest: - props = node.properties - if 'W' in props: - move = _parse_sgf_move(props['W'][0]) - player = go.WHITE - elif 'B' in props: - move = _parse_sgf_move(props['B'][0]) - player = go.BLACK - yield (gs, move, player) - # update state to n+1 - gs.do_move(move, player) + if game.rest is not None: + for node in game.rest: + props = node.properties + if 'W' in props: + move = _parse_sgf_move(props['W'][0]) + player = go.WHITE + elif 'B' in props: + move = _parse_sgf_move(props['B'][0]) + player = go.BLACK + yield (gs, move, player) + # update state to n+1 + gs.do_move(move, player) + if include_end: + yield (gs, None, None) From 41d46d7e26eba3a97924d5d286ba73988792eb52 Mon Sep 17 00:00:00 2001 From: wrongu Date: Wed, 11 May 2016 10:01:49 -0400 Subject: [PATCH 058/191] ResnetPolicy added to policy.py --- AlphaGo/models/policy.py | 132 ++++++++++++++++++++++++++++++++++++++- tests/test_policy.py | 46 +++++++++++++- 2 files changed, 173 insertions(+), 5 deletions(-) diff --git a/AlphaGo/models/policy.py b/AlphaGo/models/policy.py index 8d75202a2..6d0a9f76a 100644 --- a/AlphaGo/models/policy.py +++ b/AlphaGo/models/policy.py @@ -1,6 +1,6 @@ -from keras.models import Sequential +from keras.models import Sequential, Model from keras.models import model_from_json -from keras.layers import convolutional +from keras.layers import convolutional, merge, Input from keras.layers.core import Activation, Flatten import keras.backend as K from AlphaGo.preprocessing.preprocessing import Preprocess @@ -20,7 +20,10 @@ def __init__(self, feature_list, **kwargs): """ self.preprocessor = Preprocess(feature_list) kwargs["input_dim"] = self.preprocessor.output_dim - self.model = CNNPolicy.create_network(**kwargs) + # Using self.__class__ rather than explicitly CNNPolicy + # so that, introspectively, this works with sublcasses + # just as well + self.model = self.__class__.create_network(**kwargs) self.forward = self._model_forward() def _model_forward(self): @@ -191,3 +194,126 @@ def save_model(self, json_file, weights_file=None): # use the json module to write object_specs to file with open(json_file, 'w') as f: json.dump(object_specs, f) + + +class ResnetPolicy(CNNPolicy): + """Residual network architecture as per He at al. 2015 + """ + @staticmethod + def create_network(**kwargs): + """construct a convolutional neural network with Resnet-style skip connections. + Arguments are the same as with the default CNNPolicy network, except the default + number of layers is 20 plus a new n_skip parameter + + Keword Arguments: + - input_dim: depth of features to be processed by first layer (no default) + - board: width of the go board to be processed (default 19) + - filters_per_layer: number of filters used on every layer (default 128) + - layers: number of convolutional steps (default 20) + - filter_width_K: (where K is between 1 and ) width of filter on + layer K (default 3 except 1st layer which defaults to 5). + Must be odd. + - n_skip_K: (where K is as in filter_width_K) number of convolutional + layers to skip with the linear path starting at K. Only valid + at K >= 1. (Each layer defaults to 1) + + Note that n_skip_1=s means that the next valid value of n_skip_* is 3 + + A diagram may help explain (numbers indicate layer): + + 1 2 3 4 5 6 + I--C -- R -- C -- R -- C -- M -- R -- C -- R -- C -- R -- C -- M ... M -- R -- F -- O + \______________________/ \________________________________/ \ ... / + [n_skip_1 = 2] [n_skip_3 = 3] + + I - input + R - ReLU + C - Conv2D + F - Flatten + O - output + M - merge + + The input is always passed through a Conv2D layer, the output of which layer is counted as '1'. + Each subsequent [R -- C] block is counted as one 'layer'. The 'merge' layer isn't counted; hence + if n_skip_1 is 2, the next valid skip parameter is n_skip_3, which will start at the output + of the merge + """ + defaults = { + "board": 19, + "filters_per_layer": 128, + "layers": 20, + "filter_width_1": 5 + } + # copy defaults, but override with anything in kwargs + params = defaults + params.update(kwargs) + + # create the network using Keras' functional API, + # since this isn't 'Sequential' + model_input = Input(shape=(params["input_dim"], params["board"], params["board"])) + + # create first layer + convolution_path = convolutional.Convolution2D( + input_shape=(), + nb_filter=params["filters_per_layer"], + nb_row=params["filter_width_1"], + nb_col=params["filter_width_1"], + init='uniform', + activation='linear', # relu activations done inside resnet modules + border_mode='same')(model_input) + + def add_resnet_unit(path, K, **params): + """Add a resnet unit to path starting at layer 'K', + adding as many (ReLU + Conv2D) modules as specified by n_skip_K + + Returns new path and next layer index, i.e. K + n_skip_K, in a tuple + """ + # loosely based on https://github.com/keunwoochoi/residual_block_keras + # (see also keras docs here: http://keras.io/getting-started/functional-api-guide/#all-models-are-callable-just-like-layers) + + block_input = path + # use n_skip_K if it is there, default to 1 + skip_key = "n_skip_%d" % K + n_skip = params.get(skip_key, 1) + for i in range(n_skip): + layer = K + i + # add ReLU + path = Activation('relu')(path) + # use filter_width_K if it is there, otherwise use 3 + filter_key = "filter_width_%d" % layer + filter_width = params.get(filter_key, 3) + # add Conv2D + path = convolutional.Convolution2D( + nb_filter=params["filters_per_layer"], + nb_row=filter_width, + nb_col=filter_width, + init='uniform', + activation='linear', + border_mode='same')(path) + # Merge 'input layer' with the path + path = merge([block_input, path], mode='sum') + return path, K + n_skip + + # create all other layers + layer = 1 + while layer < params['layers']: + convolution_path, layer = add_resnet_unit(convolution_path, layer, **params) + if layer > params['layers']: + print "Due to skipping, ended with {} layers instead of {}".format(layer, params['layers']) + + # since each layer's activation was linear, need one more ReLu + convolution_path = Activation('relu')(convolution_path) + + # the last layer maps each featuer to a number + convolution_path = convolutional.Convolution2D( + nb_filter=1, + nb_row=1, + nb_col=1, + init='uniform', + border_mode='same')(convolution_path) + # flatten output + network_output = Flatten()(convolution_path) + # softmax makes it into a probability distribution + network_output = Activation('softmax')(network_output) + + return Model(input=[model_input], output=[network_output]) diff --git a/tests/test_policy.py b/tests/test_policy.py index 5e3240efc..ffa4a708d 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -1,4 +1,4 @@ -from AlphaGo.models.policy import CNNPolicy +from AlphaGo.models.policy import CNNPolicy, ResnetPolicy from AlphaGo import go from AlphaGo.go import GameState from AlphaGo.ai import GreedyPolicyPlayer, ProbabilisticPolicyPlayer @@ -39,7 +39,7 @@ def test_save_load(self): # test saving model/weights separately policy.save_model(model_file) - policy.model.save_weights(weights_file) + policy.model.save_weights(weights_file, overwrite=True) # test saving them together policy.save_model(model_file2, weights_file2) @@ -57,6 +57,48 @@ def test_save_load(self): os.remove(weights_file2) +class TestResnetPolicy(unittest.TestCase): + def test_default_policy(self): + policy = ResnetPolicy(["board", "liberties", "sensibleness", "capture_size"]) + policy.eval_state(GameState()) + # just hope nothing breaks + + def test_batch_eval_state(self): + policy = ResnetPolicy(["board", "liberties", "sensibleness", "capture_size"]) + results = policy.batch_eval_state([GameState(), GameState()]) + self.assertEqual(len(results), 2) # one result per GameState + self.assertEqual(len(results[0]), 361) # each one has 361 (move,prob) pairs + + def test_save_load(self): + """Identical to above test_save_load + """ + policy = ResnetPolicy(["board", "liberties", "sensibleness", "capture_size"]) + + model_file = 'TESTPOLICY.json' + weights_file = 'TESTWEIGHTS.h5' + model_file2 = 'TESTPOLICY2.json' + weights_file2 = 'TESTWEIGHTS2.h5' + + # test saving model/weights separately + policy.save_model(model_file) + policy.model.save_weights(weights_file, overwrite=True) + # test saving them together + policy.save_model(model_file2, weights_file2) + + copypolicy = ResnetPolicy.load_model(model_file) + copypolicy.model.load_weights(weights_file) + + copypolicy2 = ResnetPolicy.load_model(model_file2) + + for w1, w2 in zip(copypolicy.model.get_weights(), copypolicy2.model.get_weights()): + self.assertTrue(np.all(w1 == w2)) + + os.remove(model_file) + os.remove(weights_file) + os.remove(model_file2) + os.remove(weights_file2) + + class TestPlayers(unittest.TestCase): def test_greedy_player(self): From 2cd7a07e907a4767d9aaa6acc6e7948a23631419 Mon Sep 17 00:00:00 2001 From: wrongu Date: Fri, 13 May 2016 09:55:48 -0400 Subject: [PATCH 059/191] policy.py save/load saves class as well --- AlphaGo/models/policy.py | 10 +++++++++- tests/test_policy.py | 3 +++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/AlphaGo/models/policy.py b/AlphaGo/models/policy.py index 6d0a9f76a..ae015b974 100644 --- a/AlphaGo/models/policy.py +++ b/AlphaGo/models/policy.py @@ -164,7 +164,14 @@ def load_model(json_file): """ with open(json_file, 'r') as f: object_specs = json.load(f) - new_policy = CNNPolicy(object_specs['feature_list']) + + # Create object; may be a subclass of CNNPolicy saved in specs['class'] + policy_class = object_specs.get('class', 'CNNPolicy') + if policy_class == 'CNNPolicy': + new_policy = CNNPolicy(object_specs['feature_list']) + elif policy_class == 'ResnetPolicy': + new_policy = ResnetPolicy(object_specs['feature_list']) + new_policy.model = model_from_json(object_specs['keras_model']) if 'weights_file' in object_specs: new_policy.model.load_weights(object_specs['weights_file']) @@ -185,6 +192,7 @@ def save_model(self, json_file, weights_file=None): # entry in the saved file. Keras just happens to serialize models with JSON # as well. Note how this format makes load_model fairly clean as well. object_specs = { + 'class': self.__class__.__name__, 'keras_model': self.model.to_json(), 'feature_list': self.preprocessor.feature_list } diff --git a/tests/test_policy.py b/tests/test_policy.py index ffa4a708d..ddca1de6a 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -93,6 +93,9 @@ def test_save_load(self): for w1, w2 in zip(copypolicy.model.get_weights(), copypolicy2.model.get_weights()): self.assertTrue(np.all(w1 == w2)) + # check that save/load keeps the ResnetPolicy class + self.assertTrue(type(policy) == type(copypolicy)) + os.remove(model_file) os.remove(weights_file) os.remove(model_file2) From 84acfa57ac9737871c463894b6a9968474c145a5 Mon Sep 17 00:00:00 2001 From: Tyler Trine Date: Sat, 23 Apr 2016 16:38:56 -0400 Subject: [PATCH 060/191] Work continued on RL policy trainer --- .../training/reinforcement_policy_trainer.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 46bfd7bf3..08dbf5aed 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -104,12 +104,16 @@ def run(player, args, opponents, features, model_folder): # Train mini-batches for i_batch in xrange(args.save_every): # Randomly choose opponent from pool - opp_filepath = np.random.choice(os.listdir(model_folder)) - opp_path = os.path.join(model_folder,opp_filepath) - opp = CNNPolicy.create_network().load_weights(opp_path) + if len(os.listdir(model_folder))==0: + opp = player + else: + opp_filepath = np.random.choice(os.listdir(model_folder)) + opp_path = os.path.join(model_folder,opp_filepath) + policy = CNNPolicy.create_network() + policy.model.load_weights(opp_path) + opp = ProbabilisticPolicyPlayer(policy) # Make training pairs and do RL - X_list, y_list, winners = make_training_pairs( - player, opp, features, args.game_batch_size) + X_list, y_list, winners = make_training_pairs(player, opp, features, args.game_batch_size) n_wins = np.sum(np.array(winners) == 1) player_wins_per_batch.append(n_wins) print 'Number of wins this batch: {}/{}'.format(n_wins, args.game_batch_size) @@ -142,7 +146,7 @@ def run(player, args, opponents, features, model_folder): # Set initial conditions policy = CNNPolicy.load_model(args.initial_json) policy.model.load_weights(args.initial_weights) - player = ProbabilisticPolicyPlayer(policy.model) + player = ProbabilisticPolicyPlayer(policy) opponent_files = os.listdir(args.model_folder) if len(opponent_files) == 0: # Start new RL training session @@ -151,6 +155,6 @@ def run(player, args, opponents, features, model_folder): opponents = opponent_files with open(args.initial_json) as j: - features = json.load(j).feature_list + features = json.load(j)['feature_list'] run(player, args, opponents, features, args.model_folder) From cccdd9a91b326e5498767c7d17c9ae8c36bf3624 Mon Sep 17 00:00:00 2001 From: wrongu Date: Fri, 13 May 2016 10:24:08 -0400 Subject: [PATCH 061/191] whitespace/formatting in reinforcement_policy_trainer --- .../training/reinforcement_policy_trainer.py | 99 +++++++++---------- 1 file changed, 48 insertions(+), 51 deletions(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 42ba0d65d..51241b3cb 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -1,4 +1,6 @@ -import os, argparse, json +import os +import argparse +import json import numpy as np from keras.optimizers import SGD from AlphaGo.ai import ProbabilisticPolicyPlayer @@ -8,6 +10,7 @@ from AlphaGo.preprocessing.preprocessing import Preprocess from AlphaGo.util import flatten_idx + def make_training_pairs(player, opp, features, mini_batch_size): """Make training pairs for batch of matches, utilizing player.get_moves (parallel form of player.get_move), which calls `CNNPolicy.batch_eval_state`. @@ -70,6 +73,7 @@ def do_move(states, states_prev, moves, X_list, y_list, player_color): y_list[i] = np.vstack(y_list[i]) return X_list, y_list, winners + def train_batch(player, X_list, y_list, winners, lr): """Given the outcomes of a mini-batch of play against a fixed opponent, update the weights with reinforcement learning. @@ -95,68 +99,61 @@ def train_batch(player, X_list, y_list, winners, lr): player.policy.model.optimizer.lr.set_value(lr) player.policy.model.fit(X, y, nb_epoch=1, batch_size=len(X)) -def run(player, args, opponents, features, model_folder): - # Set SGD and compile - sgd = SGD(lr=args.learning_rate) - player.policy.model.compile(loss='binary_crossentropy', optimizer=sgd) - player_wins_per_batch = [] - for i_iter in xrange(args.iterations): - # Train mini-batches - for i_batch in xrange(args.save_every): - # Randomly choose opponent from pool - if len(os.listdir(model_folder))==0: - opp = player - else: - opp_filepath = np.random.choice(os.listdir(model_folder)) - opp_path = os.path.join(model_folder,opp_filepath) - policy = CNNPolicy.create_network() - policy.model.load_weights(opp_path) - opp = ProbabilisticPolicyPlayer(policy) - # Make training pairs and do RL - X_list, y_list, winners = make_training_pairs(player, opp, features, args.game_batch_size) - n_wins = np.sum(np.array(winners) == 1) - player_wins_per_batch.append(n_wins) - print 'Number of wins this batch: {}/{}'.format(n_wins, args.game_batch_size) - train_batch(player, X_list, y_list, winners, args.learning_rate) - # Save intermediate models - model_path = os.path.join(model_folder,str(i_iter)) - player.policy.model.save_weights(model_path) -if __name__ == '__main__': - parser = argparse.ArgumentParser(description='Perform reinforcement learning ' - 'to improve given policy network. Second phase of pipeline.') - parser.add_argument("initial_weights", help="Path to file with weights to start from.") - parser.add_argument("initial_json", help="Path to file with initial network params.") - parser.add_argument("model_folder", help="Path to folder where the model" - " params will be saved after each epoch.") - parser.add_argument( - "--learning_rate", help="Keras learning rate (Default: .03)", - type=float, default=.03) - parser.add_argument( - "--save_every", help="Save policy every n mini-batches (Default: 500)", - type=int, default=500) - parser.add_argument( - "--game_batch_size", help="Number of games per mini-batch (Default: 20)", - type=int, default=20) - parser.add_argument( - "--iterations", help="Number of training iterations (i.e. mini-batch) " - "(Default: 20)", type=int, default=20) +def run_training(cmd_line_args=None): + parser = argparse.ArgumentParser(description='Perform reinforcement learning to improve given policy network. Second phase of pipeline.') + parser.add_argument("model_json", help="Path to policy model JSON.") + parser.add_argument("initial_weights", help="Path to HDF5 file with inital weights.") + parser.add_argument("out_directory", help="Path to folder where the model params and metadata will be saved after each epoch.") + parser.add_argument("--learning-rate", help="Keras learning rate (Default: .03)", type=float, default=.03) + parser.add_argument("--save-every", help="Save policy as a new opponent every n batches (Default: 500)", type=int, default=500) + parser.add_argument("--game-batch", help="Number of games per mini-batch (Default: 20)", type=int, default=20) + parser.add_argument("--iterations", help="Number of training batches/iterations (Default: 10000)", type=int, default=1e4) # Baseline function (TODO) default lambda state: 0 (receives either file # paths to JSON and weights or None, in which case it uses default baseline 0) - args = parser.parse_args() + if cmd_line_args is None: + args = parser.parse_args() + else: + args = parser.parse_args(cmd_line_args) # Set initial conditions policy = CNNPolicy.load_model(args.initial_json) policy.model.load_weights(args.initial_weights) player = ProbabilisticPolicyPlayer(policy) - opponent_files = os.listdir(args.model_folder) - if len(opponent_files) == 0: # Start new RL training session + opp_policy = CNNPolicy.load_model(args.initial_json) + opponent = ProbabilisticPolicyPlayer(opp_policy) + + opponent_files = os.listdir(args.out_directory) + if len(opponent_files) == 0: # Start new RL training session opponents = [player] - else: # Resume existing RL training session + else: # Resume existing RL training session opponents = opponent_files with open(args.initial_json) as j: features = json.load(j)['feature_list'] - run(player, args, opponents, features, args.model_folder) + # Set SGD and compile + sgd = SGD(lr=args.learning_rate) + player.policy.model.compile(loss='binary_crossentropy', optimizer=sgd) + player_wins_per_batch = [] + for i_iter in xrange(args.iterations): + # Train mini-batches + for i_batch in xrange(args.save_every): + # Randomly choose opponent from pool (possibly self) + opp_filepath = np.random.choice(opponents) + opp_path = os.path.join(args.out_directory, opp_filepath) + # load new weights into opponent, but otherwise its the same + opponent.policy.model.load_weights(opp_path) + # Make training pairs and do RL + X_list, y_list, winners = make_training_pairs(player, opponent, features, args.game_batch) + n_wins = np.sum(np.array(winners) == 1) + player_wins_per_batch.append(n_wins) + print 'Number of wins this batch: {}/{}'.format(n_wins, args.game_batch) + train_batch(player, X_list, y_list, winners, args.learning_rate) + # Save intermediate models + model_path = os.path.join(args.out_directory, str(i_iter)) + player.policy.model.save_weights(model_path) + +if __name__ == '__main__': + run_training() From cab9fde0478fb7646c7ef883adad4db906d3bfde Mon Sep 17 00:00:00 2001 From: wrongu Date: Fri, 13 May 2016 11:28:18 -0400 Subject: [PATCH 062/191] reinforcement_policy_trainer interface updates, more like supervised_.. --- .../training/reinforcement_policy_trainer.py | 110 +++++++++++++----- 1 file changed, 79 insertions(+), 31 deletions(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 51241b3cb..41f8760a6 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -2,6 +2,7 @@ import argparse import json import numpy as np +from shutil import copyfile from keras.optimizers import SGD from AlphaGo.ai import ProbabilisticPolicyPlayer import AlphaGo.go as go @@ -103,12 +104,15 @@ def train_batch(player, X_list, y_list, winners, lr): def run_training(cmd_line_args=None): parser = argparse.ArgumentParser(description='Perform reinforcement learning to improve given policy network. Second phase of pipeline.') parser.add_argument("model_json", help="Path to policy model JSON.") - parser.add_argument("initial_weights", help="Path to HDF5 file with inital weights.") + parser.add_argument("initial_weights", help="Path to HDF5 file with inital weights (i.e. result of supervised training).") parser.add_argument("out_directory", help="Path to folder where the model params and metadata will be saved after each epoch.") parser.add_argument("--learning-rate", help="Keras learning rate (Default: .03)", type=float, default=.03) + parser.add_argument("--policy-temp", help="Distribution temperature of players using policies (Default: 0.67)", type=float, default=0.67) parser.add_argument("--save-every", help="Save policy as a new opponent every n batches (Default: 500)", type=int, default=500) parser.add_argument("--game-batch", help="Number of games per mini-batch (Default: 20)", type=int, default=20) parser.add_argument("--iterations", help="Number of training batches/iterations (Default: 10000)", type=int, default=1e4) + parser.add_argument("--resume", help="Load latest weights in out_directory and resume", default=False, action="store_true") + parser.add_argument("--verbose", "-v", help="Turn on verbose mode", default=False, action="store_true") # Baseline function (TODO) default lambda state: 0 (receives either file # paths to JSON and weights or None, in which case it uses default baseline 0) if cmd_line_args is None: @@ -116,44 +120,88 @@ def run_training(cmd_line_args=None): else: args = parser.parse_args(cmd_line_args) - # Set initial conditions - policy = CNNPolicy.load_model(args.initial_json) - policy.model.load_weights(args.initial_weights) - player = ProbabilisticPolicyPlayer(policy) + ZEROTH_FILE = "weights.00000.hdf5" + + if args.resume: + if not os.path.exists(os.path.join(args.out_directory, "metadata.json")): + raise ValueError("Cannot resume without existing output directory") + + if not os.path.exists(args.out_directory): + if args.verbose: + print "creating output directory {}".format(args.out_directory) + os.makedirs(args.out_directory) - opp_policy = CNNPolicy.load_model(args.initial_json) - opponent = ProbabilisticPolicyPlayer(opp_policy) + if not args.resume: + # make a copy of weights file, "weights.00000.hdf5" in the output directory + copyfile(args.initial_weights, os.path.join(args.out_directory, ZEROTH_FILE)) + if args.verbose: + print "copied {} to {}".format(args.initial_weights, os.path.join(args.out_directory, ZEROTH_FILE)) + player_weights = ZEROTH_FILE + else: + # if resuming, we expect initial_weights to be just a "weights.#####.hdf5" file, not a full path + args.initial_weights = os.path.join(args.out_directory, os.path.basename(args.initial_weights)) + if not os.path.exists(args.initial_weights): + raise ValueError("Cannot resume; weights {} do not exist".format(args.initial_weights)) + elif args.verbose: + print "Resuming with weights {}".format(args.initial_weights) + player_weights = os.path.basename(args.initial_weights) - opponent_files = os.listdir(args.out_directory) - if len(opponent_files) == 0: # Start new RL training session - opponents = [player] - else: # Resume existing RL training session - opponents = opponent_files + # Set initial conditions + policy = CNNPolicy.load_model(args.model_json) + policy.model.load_weights(args.initial_weights) + player = ProbabilisticPolicyPlayer(policy, temperature=args.policy_temp) + features = policy.preprocessor.feature_list + + # different opponents come from simply changing the weights of + # opponent.policy.model "behind the scenes" + opp_policy = CNNPolicy.load_model(args.model_json) + opponent = ProbabilisticPolicyPlayer(opp_policy, temperature=args.policy_temp) + + if args.verbose: + print "created player and opponent with temperature {}".format(args.policy_temp) + + if not args.resume: + metadata = { + "model_file": args.model_json, + "init_weights": args.model_json, + "learning_rate": args.learning_rate, + "temperature": args.policy_temp, + "game_batch": args.game_batch, + "opponents": [ZEROTH_FILE], # which weights from which to sample an opponent each batch + "win_ratio": {} # map from player to tuple of (opponent, win ratio) Useful for validating in lieu of 'accuracy/loss' + } + else: + with open(os.path.join(args.out_directory, "metadata.json"), "r") as f: + metadata = json.load(f) - with open(args.initial_json) as j: - features = json.load(j)['feature_list'] + def save_metadata(): + with open(os.path.join(args.out_directory, "metadata.json"), "w") as f: + json.dump(metadata, f) # Set SGD and compile sgd = SGD(lr=args.learning_rate) player.policy.model.compile(loss='binary_crossentropy', optimizer=sgd) - player_wins_per_batch = [] - for i_iter in xrange(args.iterations): - # Train mini-batches - for i_batch in xrange(args.save_every): - # Randomly choose opponent from pool (possibly self) - opp_filepath = np.random.choice(opponents) - opp_path = os.path.join(args.out_directory, opp_filepath) - # load new weights into opponent, but otherwise its the same - opponent.policy.model.load_weights(opp_path) - # Make training pairs and do RL - X_list, y_list, winners = make_training_pairs(player, opponent, features, args.game_batch) - n_wins = np.sum(np.array(winners) == 1) - player_wins_per_batch.append(n_wins) - print 'Number of wins this batch: {}/{}'.format(n_wins, args.game_batch) - train_batch(player, X_list, y_list, winners, args.learning_rate) + for i_iter in xrange(1, args.iterations + 1): + # Train mini-batches by randomly choosing opponent from pool (possibly self) + # and playing game_batch games against them + opp_weights = np.random.choice(metadata["opponents"]) + opp_path = os.path.join(args.out_directory, opp_weights) + # load new weights into opponent, but otherwise its the same + opponent.policy.model.load_weights(opp_path) + if args.verbose: + print "Batch {}\tsampled opponent is {}".format(i_iter, opp_weights) + # Make training pairs and do RL + X_list, y_list, winners = make_training_pairs(player, opponent, features, args.game_batch) + win_ratio = np.sum(np.array(winners) == 1) / float(args.game_batch) + metadata["win_ratio"][player_weights] = (opp_weights, win_ratio) + train_batch(player, X_list, y_list, winners, args.learning_rate) # Save intermediate models - model_path = os.path.join(args.out_directory, str(i_iter)) - player.policy.model.save_weights(model_path) + player_weights = "weights.%05d.hdf5" % i_iter + player.policy.model.save_weights(os.path.join(args.out_directory, player_weights)) + # add player to batch of oppenents once in a while + if i_iter % args.save_every == 0: + metadata["oppenents"].append(player_weights) + save_metadata() if __name__ == '__main__': run_training() From 691a627acc3a51fb11b26e1321c01e96530d35df Mon Sep 17 00:00:00 2001 From: wrongu Date: Fri, 13 May 2016 11:37:09 -0400 Subject: [PATCH 063/191] test of reinforcement_policy_trainer --- .../hdf5/random_minimodel_weights.hdf5 | Bin 0 -> 71856 bytes tests/test_reinforcement_policy_trainer.py | 20 ++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 tests/test_data/hdf5/random_minimodel_weights.hdf5 create mode 100644 tests/test_reinforcement_policy_trainer.py diff --git a/tests/test_data/hdf5/random_minimodel_weights.hdf5 b/tests/test_data/hdf5/random_minimodel_weights.hdf5 new file mode 100644 index 0000000000000000000000000000000000000000..5d619cb9da3a55bbbf990565fe370b8cfea99169 GIT binary patch literal 71856 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 z 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

WatcZ#-jtXh^;9HOWG6fsbQDhb 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 literal 0 HcmV?d00001 diff --git a/tests/test_reinforcement_policy_trainer.py b/tests/test_reinforcement_policy_trainer.py new file mode 100644 index 000000000..ecda3a5fe --- /dev/null +++ b/tests/test_reinforcement_policy_trainer.py @@ -0,0 +1,20 @@ +import os +from AlphaGo.training.reinforcement_policy_trainer import run_training +import unittest + + +class TestReinforcementPolicyTrainer(unittest.TestCase): + def testTrain(self): + model = 'tests/test_data/minimodel.json' + init_weights = 'tests/test_data/hdf5/random_minimodel_weights.hdf5' + output = 'tests/test_data/.tmp.rl.training/' + args = [model, init_weights, output, '--game-batch', '1', '--iterations', '1'] + run_training(args) + + os.remove(os.path.join(output, 'metadata.json')) + os.remove(os.path.join(output, 'weights.00000.hdf5')) + os.remove(os.path.join(output, 'weights.00001.hdf5')) + os.rmdir(output) + +if __name__ == '__main__': + unittest.main() From a6d51ca000db4e02162e069cab24b1ca8735300d Mon Sep 17 00:00:00 2001 From: wrongu Date: Fri, 13 May 2016 12:13:27 -0400 Subject: [PATCH 064/191] fixed to inconsequential typos, reinforcement_policy_trainer.py --- AlphaGo/training/reinforcement_policy_trainer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 41f8760a6..06543ebc0 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -110,7 +110,7 @@ def run_training(cmd_line_args=None): parser.add_argument("--policy-temp", help="Distribution temperature of players using policies (Default: 0.67)", type=float, default=0.67) parser.add_argument("--save-every", help="Save policy as a new opponent every n batches (Default: 500)", type=int, default=500) parser.add_argument("--game-batch", help="Number of games per mini-batch (Default: 20)", type=int, default=20) - parser.add_argument("--iterations", help="Number of training batches/iterations (Default: 10000)", type=int, default=1e4) + parser.add_argument("--iterations", help="Number of training batches/iterations (Default: 10000)", type=int, default=10000) parser.add_argument("--resume", help="Load latest weights in out_directory and resume", default=False, action="store_true") parser.add_argument("--verbose", "-v", help="Turn on verbose mode", default=False, action="store_true") # Baseline function (TODO) default lambda state: 0 (receives either file @@ -163,7 +163,7 @@ def run_training(cmd_line_args=None): if not args.resume: metadata = { "model_file": args.model_json, - "init_weights": args.model_json, + "init_weights": player_weights, "learning_rate": args.learning_rate, "temperature": args.policy_temp, "game_batch": args.game_batch, From 840075c84c3cbf0548bae711b9bcf06a167e3954 Mon Sep 17 00:00:00 2001 From: wrongu Date: Sat, 14 May 2016 18:24:50 -0400 Subject: [PATCH 065/191] (re) fixed initial weights metadata in reinforcement_policy_trainer --- AlphaGo/training/reinforcement_policy_trainer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 06543ebc0..52e971074 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -163,7 +163,7 @@ def run_training(cmd_line_args=None): if not args.resume: metadata = { "model_file": args.model_json, - "init_weights": player_weights, + "init_weights": args.initial_weights, "learning_rate": args.learning_rate, "temperature": args.policy_temp, "game_batch": args.game_batch, From db405c09c68644ff0d1c41ea8b36c4eb101c2589 Mon Sep 17 00:00:00 2001 From: wrongu Date: Sat, 14 May 2016 18:26:31 -0400 Subject: [PATCH 066/191] policy.py able to support Dropout and BatchNormalization briefly: the 'forward function' needs to map a second 'learning phase' input to 0 --- AlphaGo/models/policy.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AlphaGo/models/policy.py b/AlphaGo/models/policy.py index ae015b974..67e0d0f17 100644 --- a/AlphaGo/models/policy.py +++ b/AlphaGo/models/policy.py @@ -38,11 +38,11 @@ def _model_forward(self): c.f. https://github.com/fchollet/keras/issues/1426 """ - forward_function = K.function([self.model.input], [self.model.output]) + forward_function = K.function([self.model.input, K.learning_phase()], [self.model.output]) # the forward_function returns a list of tensors # the first [0] gets the front tensor. - return lambda inpt: forward_function([inpt])[0] + return lambda inpt: forward_function([inpt, 0])[0] def _select_moves_and_normalize(self, nn_output, moves, size): """helper function to normalize a distribution over the given list of moves From b40532c56f3a4ab48aaf876e479c71a839c0d88e Mon Sep 17 00:00:00 2001 From: wrongu Date: Sat, 14 May 2016 18:30:09 -0400 Subject: [PATCH 067/191] miniscule typo --- AlphaGo/models/policy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AlphaGo/models/policy.py b/AlphaGo/models/policy.py index 67e0d0f17..5a85bced9 100644 --- a/AlphaGo/models/policy.py +++ b/AlphaGo/models/policy.py @@ -144,7 +144,7 @@ def create_network(**kwargs): activation='relu', border_mode='same')) - # the last layer maps each featuer to a number + # the last layer maps each feature to a number network.add(convolutional.Convolution2D( nb_filter=1, nb_row=1, From 6c592e7acf5151ed0c8b5a7ecd3984861b5aab83 Mon Sep 17 00:00:00 2001 From: wrongu Date: Sat, 14 May 2016 18:39:27 -0400 Subject: [PATCH 068/191] updated ResnetPolicy to use BatchNormalization before ReLU --- AlphaGo/models/policy.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/AlphaGo/models/policy.py b/AlphaGo/models/policy.py index 5a85bced9..0c2c4f8e5 100644 --- a/AlphaGo/models/policy.py +++ b/AlphaGo/models/policy.py @@ -1,6 +1,6 @@ from keras.models import Sequential, Model from keras.models import model_from_json -from keras.layers import convolutional, merge, Input +from keras.layers import convolutional, merge, Input, BatchNormalization from keras.layers.core import Activation, Flatten import keras.backend as K from AlphaGo.preprocessing.preprocessing import Preprocess @@ -229,12 +229,13 @@ def create_network(**kwargs): A diagram may help explain (numbers indicate layer): - 1 2 3 4 5 6 - I--C -- R -- C -- R -- C -- M -- R -- C -- R -- C -- R -- C -- M ... M -- R -- F -- O - \______________________/ \________________________________/ \ ... / - [n_skip_1 = 2] [n_skip_3 = 3] + 1 2 3 4 5 6 + I--C -- B -- R -- C -- B -- R -- C -- M -- B -- R -- C -- B -- R -- C -- B -- R -- C -- M ... M -- R -- F -- O + \___________________________/ \____________________________________________________/ \ ... / + [n_skip_1 = 2] [n_skip_3 = 3] I - input + B - BatchNormalization R - ReLU C - Conv2D F - Flatten @@ -285,6 +286,8 @@ def add_resnet_unit(path, K, **params): n_skip = params.get(skip_key, 1) for i in range(n_skip): layer = K + i + # add BatchNorm + path = BatchNormalization()(path) # add ReLU path = Activation('relu')(path) # use filter_width_K if it is there, otherwise use 3 From 010a96cce55c8cd12497b8bea4ad5289714e15cf Mon Sep 17 00:00:00 2001 From: wrongu Date: Sat, 14 May 2016 19:05:51 -0400 Subject: [PATCH 069/191] adding Bias layer to policy networks --- AlphaGo/models/nn_util.py | 19 +++++++++++++++++++ AlphaGo/models/policy.py | 5 +++++ 2 files changed, 24 insertions(+) create mode 100644 AlphaGo/models/nn_util.py diff --git a/AlphaGo/models/nn_util.py b/AlphaGo/models/nn_util.py new file mode 100644 index 000000000..3421d9a3b --- /dev/null +++ b/AlphaGo/models/nn_util.py @@ -0,0 +1,19 @@ +from keras import backend as K +from keras.engine.topology import Layer + + +class Bias(Layer): + """Custom keras layer that simply adds a scalar bias to each location in the input + + Largely copied from the keras docs: + http://keras.io/layers/writing-your-own-keras-layers/#writing-your-own-keras-layers + """ + def __init__(self, **kwargs): + super(Bias, self).__init__(**kwargs) + + def build(self, input_shape): + self.W = K.zeros(input_shape[1:]) + self.trainable_weights = [self.W] + + def call(self, x, mask=None): + return x + self.W diff --git a/AlphaGo/models/policy.py b/AlphaGo/models/policy.py index 0c2c4f8e5..3bab69c6f 100644 --- a/AlphaGo/models/policy.py +++ b/AlphaGo/models/policy.py @@ -5,6 +5,7 @@ import keras.backend as K from AlphaGo.preprocessing.preprocessing import Preprocess from AlphaGo.util import flatten_idx +from AlphaGo.models.nn_util import Bias import numpy as np import json @@ -153,6 +154,8 @@ def create_network(**kwargs): border_mode='same')) # reshape output to be board x board network.add(Flatten()) + # add a bias to each board location + network.add(Bias()) # softmax makes it into a probability distribution network.add(Activation('softmax')) @@ -324,6 +327,8 @@ def add_resnet_unit(path, K, **params): border_mode='same')(convolution_path) # flatten output network_output = Flatten()(convolution_path) + # add a bias to each board location + network_output = Bias()(network_output) # softmax makes it into a probability distribution network_output = Activation('softmax')(network_output) From 79f4935e00d2b1521d65f03e276caab96c979532 Mon Sep 17 00:00:00 2001 From: wrongu Date: Sat, 14 May 2016 19:32:39 -0400 Subject: [PATCH 070/191] fixed loading Bias layer using Keras' model_from_json --- AlphaGo/models/policy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AlphaGo/models/policy.py b/AlphaGo/models/policy.py index 3bab69c6f..98224d601 100644 --- a/AlphaGo/models/policy.py +++ b/AlphaGo/models/policy.py @@ -175,7 +175,7 @@ def load_model(json_file): elif policy_class == 'ResnetPolicy': new_policy = ResnetPolicy(object_specs['feature_list']) - new_policy.model = model_from_json(object_specs['keras_model']) + new_policy.model = model_from_json(object_specs['keras_model'], custom_objects={'Bias': Bias}) if 'weights_file' in object_specs: new_policy.model.load_weights(object_specs['weights_file']) new_policy.forward = new_policy._model_forward() From 069b4892bff3c631205f2e422cdb531bca1d3b58 Mon Sep 17 00:00:00 2001 From: wrongu Date: Thu, 19 May 2016 09:48:31 -0400 Subject: [PATCH 071/191] reinforcement_policy_trainer takes policy network's board size into account --- AlphaGo/training/reinforcement_policy_trainer.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 52e971074..60a33d65f 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -12,7 +12,7 @@ from AlphaGo.util import flatten_idx -def make_training_pairs(player, opp, features, mini_batch_size): +def make_training_pairs(player, opp, features, mini_batch_size, board_size=19): """Make training pairs for batch of matches, utilizing player.get_moves (parallel form of player.get_move), which calls `CNNPolicy.batch_eval_state`. @@ -48,7 +48,7 @@ def do_move(states, states_prev, moves, X_list, y_list, player_color): y_list = [list() for _ in xrange(mini_batch_size)] preprocessor = Preprocess(features) bsize = player.policy.model.input_shape[-1] - states = [GameState() for i in xrange(mini_batch_size)] + states = [GameState(size=board_size) for i in xrange(mini_batch_size)] # Randomly choose who goes first (i.e. color of 'player') player_color = np.random.choice([go.BLACK, go.WHITE]) player1, player2 = (player, opp) if player_color == go.BLACK else \ @@ -181,6 +181,7 @@ def save_metadata(): # Set SGD and compile sgd = SGD(lr=args.learning_rate) player.policy.model.compile(loss='binary_crossentropy', optimizer=sgd) + board_size = player.policy.model.input_shape[-1] for i_iter in xrange(1, args.iterations + 1): # Train mini-batches by randomly choosing opponent from pool (possibly self) # and playing game_batch games against them @@ -191,7 +192,7 @@ def save_metadata(): if args.verbose: print "Batch {}\tsampled opponent is {}".format(i_iter, opp_weights) # Make training pairs and do RL - X_list, y_list, winners = make_training_pairs(player, opponent, features, args.game_batch) + X_list, y_list, winners = make_training_pairs(player, opponent, features, args.game_batch, board_size) win_ratio = np.sum(np.array(winners) == 1) / float(args.game_batch) metadata["win_ratio"][player_weights] = (opp_weights, win_ratio) train_batch(player, X_list, y_list, winners, args.learning_rate) From 6e7771095b56af0c67d8590611e87cfe29027375 Mon Sep 17 00:00:00 2001 From: wrongu Date: Thu, 19 May 2016 10:04:01 -0400 Subject: [PATCH 072/191] fixed typo in reinforcement_policy_trainer: oppenents-->opponents --- AlphaGo/training/reinforcement_policy_trainer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 60a33d65f..d189ba4b1 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -201,7 +201,7 @@ def save_metadata(): player.policy.model.save_weights(os.path.join(args.out_directory, player_weights)) # add player to batch of oppenents once in a while if i_iter % args.save_every == 0: - metadata["oppenents"].append(player_weights) + metadata["opponents"].append(player_weights) save_metadata() if __name__ == '__main__': From e94f924c314147353071a79498342f0eb599049b Mon Sep 17 00:00:00 2001 From: wrongu Date: Thu, 19 May 2016 10:07:13 -0400 Subject: [PATCH 073/191] benchmark for RL policy trainer --- .gitignore | 1 + ...reinforcement_policy_training_benchmark.py | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 benchmarks/reinforcement_policy_training_benchmark.py diff --git a/.gitignore b/.gitignore index 886bc5a6c..798aaaaf1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ !tests/test_data/hdf5/*.h5 !tests/test_data/hdf5/*.hdf5 src/ +benchmarks/data/ diff --git a/benchmarks/reinforcement_policy_training_benchmark.py b/benchmarks/reinforcement_policy_training_benchmark.py new file mode 100644 index 000000000..c016a42ec --- /dev/null +++ b/benchmarks/reinforcement_policy_training_benchmark.py @@ -0,0 +1,27 @@ +from AlphaGo.training.reinforcement_policy_trainer import run_training +from AlphaGo.models.policy import CNNPolicy +import os +from cProfile import Profile + +# make a miniature model for playing on a miniature 7x7 board +architecture = {'filters_per_layer': 32, 'layers': 4, 'board': 7} +features = ['board', 'ones', 'turns_since', 'liberties', 'capture_size', 'self_atari_size', 'liberties_after', 'sensibleness'] +policy = CNNPolicy(features, **architecture) + +datadir = os.path.join('benchmarks', 'data') +modelfile = os.path.join(datadir, 'mini_rl_model.json') +weights = os.path.join(datadir, 'init_weights.hdf5') +outdir = os.path.join(datadir, 'rl_output') +stats_file = os.path.join(datadir, 'reinforcement_policy_trainer.prof') + +if not os.path.exists(datadir): + os.makedirs(datadir) +if not os.path.exists(weights): + policy.model.save_weights(weights) +policy.save_model(modelfile) + +profile = Profile() +arguments = (modelfile, weights, outdir, '--learning-rate', '0.001', '--save-every', '2', '--game-batch', '20', '--iterations', '10', '--verbose') + +profile.runcall(run_training, arguments) +profile.dump_stats(stats_file) From baa2c8156c9f2ae84a0486386b2b4a2febcdf329 Mon Sep 17 00:00:00 2001 From: wrongu Date: Fri, 20 May 2016 10:23:51 -0400 Subject: [PATCH 074/191] A few caching optimizations to go.py Feature processing is slightly less of a bottleneck --- AlphaGo/go.py | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/AlphaGo/go.py b/AlphaGo/go.py index ff5d6dee5..59bf846c3 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -10,6 +10,10 @@ class GameState(object): """State of a game of Go and some basic functions to interact with it """ + # Looking up positions adjacent to a given position takes a surprising + # amount of time, hence this shared lookup table {boardsize: {position: [neighbors]}} + __NEIGHBORS_CACHE = {} + def __init__(self, size=19, komi=7.5): self.board = np.zeros((size, size)) self.board.fill(EMPTY) @@ -31,6 +35,7 @@ def __init__(self, size=19, komi=7.5): # connected block. By caching liberties in this way, we can directly # optimize update functions (e.g. do_move) and in doing so indirectly # speed up any function that queries liberties + self._create_neighbors_cache() self.liberty_sets = [[set() for _ in range(size)] for _ in range(size)] for x in range(size): for y in range(size): @@ -43,6 +48,8 @@ def __init__(self, size=19, komi=7.5): # similarly to `liberty_sets`, `group_sets[x][y]` points to a set of tuples # containing all (x',y') pairs in the group connected to (x,y) self.group_sets = [[set() for _ in range(size)] for _ in range(size)] + # cache of list of legal moves + self.__legal_move_cache = None def get_group(self, position): """Get the group of connected same-color stones to the given position @@ -70,11 +77,9 @@ def get_groups_around(self, position): """ groups = [] for (nx, ny) in self._neighbors(position): - if self.board[nx][ny] != EMPTY: - group = self.group_sets[nx][ny] - group_member = next(iter(group)) # pick any stone - if not any(group_member in g for g in groups): - groups.append(group) + group = self.group_sets[nx][ny] + if len(group) > 0 and group not in groups: + groups.append(self.group_sets[nx][ny]) return groups def _on_board(self, position): @@ -83,12 +88,19 @@ def _on_board(self, position): (x, y) = position return x >= 0 and y >= 0 and x < self.size and y < self.size + def _create_neighbors_cache(self): + if self.size not in GameState.__NEIGHBORS_CACHE: + GameState.__NEIGHBORS_CACHE[self.size] = {} + for x in xrange(self.size): + for y in xrange(self.size): + neighbors = [xy for xy in [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)] if self._on_board(xy)] + GameState.__NEIGHBORS_CACHE[self.size][(x, y)] = neighbors + def _neighbors(self, position): """A private helper function that simply returns a list of positions neighboring the given (x,y) position. Basically it handles edges and corners. """ - (x, y) = position - return [xy for xy in [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)] if self._on_board(xy)] + return GameState.__NEIGHBORS_CACHE[self.size][position] def _diagonals(self, position): """Like _neighbors but for diagonal positions @@ -252,12 +264,14 @@ def is_eye(self, position, owner, stack=[]): return True def get_legal_moves(self, include_eyes=True): - moves = [] + if self.__legal_move_cache is not None: + return self.__legal_move_cache + self.__legal_move_cache = [] for x in range(self.size): for y in range(self.size): if self.is_legal((x, y)) and (include_eyes or not self.is_eye((x, y), self.current_player)): - moves.append((x, y)) - return moves + self.__legal_move_cache.append((x, y)) + return self.__legal_move_cache def get_winner(self): """Calculate score of board state and return player ID (1, -1, or 0 for tie) @@ -331,6 +345,7 @@ def do_move(self, action, color=None): self.current_player = -color self.turns_played += 1 self.history.append(action) + self.__legal_move_cache = None else: self.current_player = reset_player raise IllegalMove(str(action)) From f08d52d8835427178e2c991f062b4cbdf9216015 Mon Sep 17 00:00:00 2001 From: wrongu Date: Sat, 21 May 2016 10:05:35 -0400 Subject: [PATCH 075/191] NeuralNetBase class in nn_util handling generic policy/value things --- AlphaGo/models/nn_util.py | 97 +++++++++++++++++++++++++++++++++++++++ AlphaGo/models/policy.py | 83 +-------------------------------- 2 files changed, 99 insertions(+), 81 deletions(-) diff --git a/AlphaGo/models/nn_util.py b/AlphaGo/models/nn_util.py index 3421d9a3b..1f90844e3 100644 --- a/AlphaGo/models/nn_util.py +++ b/AlphaGo/models/nn_util.py @@ -1,5 +1,102 @@ from keras import backend as K +from keras.models import model_from_json from keras.engine.topology import Layer +from AlphaGo.preprocessing.preprocessing import Preprocess +import json + + +class NeuralNetBase(object): + """Base class for neural network classes handling feature processing, construction + of a 'forward' function, etc. + """ + + def __init__(self, feature_list, **kwargs): + """create a neural net object that preprocesses according to feature_list and uses + a neural network specified by keyword arguments (using subclass' create_network()) + + optional argument: init_network (boolean). If set to False, skips initializing + self.model and self.forward and the calling function should set them. + """ + self.preprocessor = Preprocess(feature_list) + kwargs["input_dim"] = self.preprocessor.output_dim + + if kwargs.get('init_network', True): + # self.__class__ refers to the subclass so that subclasses only + # need to override create_network() + self.model = self.__class__.create_network(**kwargs) + # self.forward is a lambda function wrapping a Keras function + self.forward = self._model_forward() + + def _model_forward(self): + """Construct a function using the current keras backend that, when given a batch + of inputs, simply processes them forward and returns the output + + This is as opposed to model.compile(), which takes a loss function + and training method. + + c.f. https://github.com/fchollet/keras/issues/1426 + """ + # The uses_learning_phase property is True if the model contains layers that behave + # differently during training and testing, e.g. Dropout or BatchNormalization. + # In these cases, K.learning_phase() is a reference to a backend variable that should + # be set to 0 when using the network in prediction mode and is automatically set to 1 + # during training. + if self.model.uses_learning_phase: + forward_function = K.function([self.model.input, K.learning_phase()], [self.model.output]) + + # the forward_function returns a list of tensors + # the first [0] gets the front tensor. + return lambda inpt: forward_function([inpt, 0])[0] + else: + # identical but without a second input argument for the learning phase + forward_function = K.function([self.model.input], [self.model.output]) + return lambda inpt: forward_function([inpt])[0] + + @staticmethod + def load_model(json_file): + """create a new neural net object from the architecture specified in json_file + """ + from policy import CNNPolicy, ResnetPolicy + with open(json_file, 'r') as f: + object_specs = json.load(f) + + # Create object; may be a subclass of networks saved in specs['class'] + network_class = object_specs.get('class', 'CNNPolicy') + if network_class == 'CNNPolicy': + new_net = CNNPolicy(object_specs['feature_list'], init_network=False) + elif network_class == 'ResnetPolicy': + new_net = ResnetPolicy(object_specs['feature_list'], init_network=False) + + new_net.model = model_from_json(object_specs['keras_model'], custom_objects={'Bias': Bias}) + if 'weights_file' in object_specs: + new_net.model.load_weights(object_specs['weights_file']) + new_net.forward = new_net._model_forward() + return new_net + + def save_model(self, json_file, weights_file=None): + """write the network model and preprocessing features to the specified file + + If a weights_file (.hdf5 extension) is also specified, model weights are also + saved to that file and will be reloaded automatically in a call to load_model + """ + # this looks odd because we are serializing a model with json as a string + # then making that the value of an object which is then serialized as + # json again. + # It's not as crazy as it looks. A Network has 2 moving parts - the + # feature preprocessing and the neural net, each of which gets a top-level + # entry in the saved file. Keras just happens to serialize models with JSON + # as well. Note how this format makes load_model fairly clean as well. + object_specs = { + 'class': self.__class__.__name__, + 'keras_model': self.model.to_json(), + 'feature_list': self.preprocessor.feature_list + } + if weights_file is not None: + self.model.save_weights(weights_file) + object_specs['weights_file'] = weights_file + # use the json module to write object_specs to file + with open(json_file, 'w') as f: + json.dump(object_specs, f) class Bias(Layer): diff --git a/AlphaGo/models/policy.py b/AlphaGo/models/policy.py index 98224d601..2e26cec22 100644 --- a/AlphaGo/models/policy.py +++ b/AlphaGo/models/policy.py @@ -1,50 +1,16 @@ from keras.models import Sequential, Model -from keras.models import model_from_json from keras.layers import convolutional, merge, Input, BatchNormalization from keras.layers.core import Activation, Flatten -import keras.backend as K -from AlphaGo.preprocessing.preprocessing import Preprocess from AlphaGo.util import flatten_idx -from AlphaGo.models.nn_util import Bias +from AlphaGo.models.nn_util import Bias, NeuralNetBase import numpy as np -import json -class CNNPolicy(object): +class CNNPolicy(NeuralNetBase): """uses a convolutional neural network to evaluate the state of the game and compute a probability distribution over the next action """ - def __init__(self, feature_list, **kwargs): - """create a policy object that preprocesses according to feature_list and uses - a neural network specified by keyword arguments (see create_network()) - """ - self.preprocessor = Preprocess(feature_list) - kwargs["input_dim"] = self.preprocessor.output_dim - # Using self.__class__ rather than explicitly CNNPolicy - # so that, introspectively, this works with sublcasses - # just as well - self.model = self.__class__.create_network(**kwargs) - self.forward = self._model_forward() - - def _model_forward(self): - """Construct a function using the current keras backend that, when given a batch - of inputs, simply processes them forward and returns the output - - The output has size (batch x 361) for 19x19 boards (i.e. the output is a batch - of distributions over flattened boards. See AlphaGo.util#flatten_idx) - - This is as opposed to model.compile(), which takes a loss function - and training method. - - c.f. https://github.com/fchollet/keras/issues/1426 - """ - forward_function = K.function([self.model.input, K.learning_phase()], [self.model.output]) - - # the forward_function returns a list of tensors - # the first [0] gets the front tensor. - return lambda inpt: forward_function([inpt, 0])[0] - def _select_moves_and_normalize(self, nn_output, moves, size): """helper function to normalize a distribution over the given list of moves and return a list of (move, prob) tuples @@ -161,51 +127,6 @@ def create_network(**kwargs): return network - @staticmethod - def load_model(json_file): - """create a new CNNPolicy object from the architecture specified in json_file - """ - with open(json_file, 'r') as f: - object_specs = json.load(f) - - # Create object; may be a subclass of CNNPolicy saved in specs['class'] - policy_class = object_specs.get('class', 'CNNPolicy') - if policy_class == 'CNNPolicy': - new_policy = CNNPolicy(object_specs['feature_list']) - elif policy_class == 'ResnetPolicy': - new_policy = ResnetPolicy(object_specs['feature_list']) - - new_policy.model = model_from_json(object_specs['keras_model'], custom_objects={'Bias': Bias}) - if 'weights_file' in object_specs: - new_policy.model.load_weights(object_specs['weights_file']) - new_policy.forward = new_policy._model_forward() - return new_policy - - def save_model(self, json_file, weights_file=None): - """write the network model and preprocessing features to the specified file - - If a weights_file (.hdf5 extension) is also specified, model weights are also - saved to that file and will be reloaded automatically in a call to load_model - """ - # this looks odd because we are serializing a model with json as a string - # then making that the value of an object which is then serialized as - # json again. - # It's not as crazy as it looks. A CNNPolicy has 2 moving parts - the - # feature preprocessing and the neural net, each of which gets a top-level - # entry in the saved file. Keras just happens to serialize models with JSON - # as well. Note how this format makes load_model fairly clean as well. - object_specs = { - 'class': self.__class__.__name__, - 'keras_model': self.model.to_json(), - 'feature_list': self.preprocessor.feature_list - } - if weights_file is not None: - self.model.save_weights(weights_file) - object_specs['weights_file'] = weights_file - # use the json module to write object_specs to file - with open(json_file, 'w') as f: - json.dump(object_specs, f) - class ResnetPolicy(CNNPolicy): """Residual network architecture as per He at al. 2015 From 7e81f194ac335ff552358464cea8c71a85fb024b Mon Sep 17 00:00:00 2001 From: wrongu Date: Sat, 21 May 2016 10:37:04 -0400 Subject: [PATCH 076/191] cleaner introspection with a NeuralNetBase registration decorator --- AlphaGo/models/nn_util.py | 25 +++++++++++++++++++------ AlphaGo/models/policy.py | 4 +++- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/AlphaGo/models/nn_util.py b/AlphaGo/models/nn_util.py index 1f90844e3..cc2b1fb5e 100644 --- a/AlphaGo/models/nn_util.py +++ b/AlphaGo/models/nn_util.py @@ -10,6 +10,10 @@ class NeuralNetBase(object): of a 'forward' function, etc. """ + # keep track of subclasses to make generic saving/loading cleaner. + # subclasses can be 'registered' with the @neuralnet decorator + subclasses = {} + def __init__(self, feature_list, **kwargs): """create a neural net object that preprocesses according to feature_list and uses a neural network specified by keyword arguments (using subclass' create_network()) @@ -56,16 +60,18 @@ def _model_forward(self): def load_model(json_file): """create a new neural net object from the architecture specified in json_file """ - from policy import CNNPolicy, ResnetPolicy with open(json_file, 'r') as f: object_specs = json.load(f) # Create object; may be a subclass of networks saved in specs['class'] - network_class = object_specs.get('class', 'CNNPolicy') - if network_class == 'CNNPolicy': - new_net = CNNPolicy(object_specs['feature_list'], init_network=False) - elif network_class == 'ResnetPolicy': - new_net = ResnetPolicy(object_specs['feature_list'], init_network=False) + class_name = object_specs.get('class', 'CNNPolicy') + try: + network_class = NeuralNetBase.subclasses[class_name] + except KeyError: + raise ValueError("Unknown neural network type in json file: {}\n(was it registered with the @neuralnet decorator?)".format(network_class)) + + # create new object + new_net = network_class(object_specs['feature_list'], init_network=False) new_net.model = model_from_json(object_specs['keras_model'], custom_objects={'Bias': Bias}) if 'weights_file' in object_specs: @@ -99,6 +105,13 @@ def save_model(self, json_file, weights_file=None): json.dump(object_specs, f) +def neuralnet(cls): + """Class decorator for registering subclasses of NeuralNetBase + """ + NeuralNetBase.subclasses[cls.__name__] = cls + return cls + + class Bias(Layer): """Custom keras layer that simply adds a scalar bias to each location in the input diff --git a/AlphaGo/models/policy.py b/AlphaGo/models/policy.py index 2e26cec22..bf65200be 100644 --- a/AlphaGo/models/policy.py +++ b/AlphaGo/models/policy.py @@ -2,10 +2,11 @@ from keras.layers import convolutional, merge, Input, BatchNormalization from keras.layers.core import Activation, Flatten from AlphaGo.util import flatten_idx -from AlphaGo.models.nn_util import Bias, NeuralNetBase +from AlphaGo.models.nn_util import Bias, NeuralNetBase, neuralnet import numpy as np +@neuralnet class CNNPolicy(NeuralNetBase): """uses a convolutional neural network to evaluate the state of the game and compute a probability distribution over the next action @@ -128,6 +129,7 @@ def create_network(**kwargs): return network +@neuralnet class ResnetPolicy(CNNPolicy): """Residual network architecture as per He at al. 2015 """ From b1f07716b93adcbeea93c4c8a6425ae528d6ceda Mon Sep 17 00:00:00 2001 From: wrongu Date: Sat, 21 May 2016 11:01:38 -0400 Subject: [PATCH 077/191] "legal" feature added to preprocessing.py --- AlphaGo/preprocessing/preprocessing.py | 14 ++++++++++++++ tests/test_preprocessing.py | 10 ++++++++++ 2 files changed, 24 insertions(+) diff --git a/AlphaGo/preprocessing/preprocessing.py b/AlphaGo/preprocessing/preprocessing.py index aa2894559..5a4f0babc 100644 --- a/AlphaGo/preprocessing/preprocessing.py +++ b/AlphaGo/preprocessing/preprocessing.py @@ -185,6 +185,16 @@ def get_sensibleness(state): feature[0, x, y] = 1 return feature + +def get_legal(state): + """Zero at all illegal moves, one at all legal moves. Unlike sensibleness, no eye check is done + """ + feature = np.zeros((1, state.size, state.size)) + for (x, y) in state.get_legal_moves(): + feature[0, x, y] = 1 + return feature + + # named features and their sizes are defined here FEATURES = { "board": { @@ -230,6 +240,10 @@ def get_sensibleness(state): "zeros": { "size": 1, "function": lambda state: np.zeros((1, state.size, state.size)) + }, + "legal": { + "size": 1, + "function": get_legal } } diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index 85b3005f2..509abeb26 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -299,6 +299,16 @@ def test_get_sensibleness(self): expectation[x, y] = 1 self.assertTrue(np.all(expectation == feature)) + def test_get_legal(self): + gs = simple_board() + pp = Preprocess(["legal"]) + feature = pp.state_to_tensor(gs)[0, 0] # 1D tensor; no need to transpose + + expectation = np.zeros((gs.size, gs.size)) + for (x, y) in gs.get_legal_moves(): + expectation[x, y] = 1 + self.assertTrue(np.all(expectation == feature)) + def test_feature_concatenation(self): gs = simple_board() pp = Preprocess(["board", "sensibleness", "capture_size"]) From 520b5bd00e46266962071c55c00a45ecb86ce4cf Mon Sep 17 00:00:00 2001 From: wrongu Date: Mon, 23 May 2016 20:42:38 -0400 Subject: [PATCH 078/191] stone_age feature in GameState to make turns_since feature faster --- AlphaGo/go.py | 6 ++++++ AlphaGo/preprocessing/preprocessing.py | 19 ++++--------------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/AlphaGo/go.py b/AlphaGo/go.py index 59bf846c3..c72b61a2b 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -50,6 +50,8 @@ def __init__(self, size=19, komi=7.5): self.group_sets = [[set() for _ in range(size)] for _ in range(size)] # cache of list of legal moves self.__legal_move_cache = None + # on-the-fly record of 'age' of each stone + self.stone_ages = np.zeros((size, size), dtype=np.int) - 1 def get_group(self, position): """Get the group of connected same-color stones to the given position @@ -153,6 +155,7 @@ def _remove_group(self, group): self.group_sets[x][y] = set() self.liberty_sets[x][y] = set() self.liberty_counts[x][y] = -1 + self.stone_ages[x][y] = -1 for (nx, ny) in self._neighbors((x, y)): if self.board[nx, ny] == EMPTY: # add empty neighbors of (x,y) to its liberties @@ -310,10 +313,13 @@ def do_move(self, action, color=None): if self.is_legal(action): # reset ko self.ko = None + # increment age of stones by 1 + self.stone_ages[self.stone_ages >= 0] += 1 if action is not PASS_MOVE: (x, y) = action self.board[x][y] = color self._update_neighbors(action) + self.stone_ages[x][y] = 0 # check neighboring groups' liberties for captures for (nx, ny) in self._neighbors(action): diff --git a/AlphaGo/preprocessing/preprocessing.py b/AlphaGo/preprocessing/preprocessing.py index 5a4f0babc..60732e3f5 100644 --- a/AlphaGo/preprocessing/preprocessing.py +++ b/AlphaGo/preprocessing/preprocessing.py @@ -25,21 +25,10 @@ def get_turns_since(state, maximum=8): - EMPTY locations are all-zero features """ planes = np.zeros((maximum, state.size, state.size)) - depth = 0 - # loop backwards over history and place a 1 in plane 0 - # for the most recent move, a 1 in plane 1 for two moves ago, etc.. - for move in state.history[::-1]: - if move is not go.PASS_MOVE: - # check that this stone wasn't captured - if state.board[move] != go.EMPTY: - (x, y) = move - # check that a newer move isn't occupying (x,y) - if np.sum(planes[:, x, y]) == 0: - planes[depth, x, y] = 1 - # increment depth if there are more planes available - # (the last plane serves as the "maximum-1 or more" feature) - if depth < maximum - 1: - depth += 1 + for x in range(state.size): + for y in range(state.size): + if state.stone_ages[x][y] >= 0: + planes[min(state.stone_ages[x][y], maximum - 1), x, y] = 1 return planes From b6bf8195d65544f1ab8741a8977aa33cf88a2af6 Mon Sep 17 00:00:00 2001 From: wrongu Date: Mon, 23 May 2016 21:39:53 -0400 Subject: [PATCH 079/191] fixed is_legal caching bug when include_eyes changes --- AlphaGo/go.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/AlphaGo/go.py b/AlphaGo/go.py index c72b61a2b..ce324ead8 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -48,8 +48,9 @@ def __init__(self, size=19, komi=7.5): # similarly to `liberty_sets`, `group_sets[x][y]` points to a set of tuples # containing all (x',y') pairs in the group connected to (x,y) self.group_sets = [[set() for _ in range(size)] for _ in range(size)] - # cache of list of legal moves + # cache of list of legal moves (actually 'sensible' moves, with a separate list for eye-moves on request) self.__legal_move_cache = None + self.__legal_eyes_cache = None # on-the-fly record of 'age' of each stone self.stone_ages = np.zeros((size, size), dtype=np.int) - 1 @@ -268,13 +269,20 @@ def is_eye(self, position, owner, stack=[]): def get_legal_moves(self, include_eyes=True): if self.__legal_move_cache is not None: - return self.__legal_move_cache + if include_eyes: + return self.__legal_move_cache + self.__legal_eyes_cache + else: + return self.__legal_move_cache self.__legal_move_cache = [] + self.__legal_eyes_cache = [] for x in range(self.size): for y in range(self.size): - if self.is_legal((x, y)) and (include_eyes or not self.is_eye((x, y), self.current_player)): - self.__legal_move_cache.append((x, y)) - return self.__legal_move_cache + if self.is_legal((x, y)): + if not self.is_eye((x, y), self.current_player): + self.__legal_move_cache.append((x, y)) + else: + self.__legal_eyes_cache.append((x, y)) + return self.get_legal_moves(include_eyes) def get_winner(self): """Calculate score of board state and return player ID (1, -1, or 0 for tie) From f1d013cdbb576c89984d53aae9258f31aa80c558 Mon Sep 17 00:00:00 2001 From: wrongu Date: Tue, 12 Jul 2016 07:52:29 -0700 Subject: [PATCH 080/191] Using gtp library version 0.3 fixes #119 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 24511f19c..73ca61bba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ Cython==0.24 h5py==2.6.0 Keras==1.0.0 numpy==1.11.0 -pygtp==0.2 +pygtp==0.3 PyYAML==3.11 scipy==0.17.0 sgf==0.5 From f112c34f7bd06c3cf85ae01adbd67b86e81eedca Mon Sep 17 00:00:00 2001 From: Robert Waite Date: Fri, 15 Jul 2016 00:14:39 -0700 Subject: [PATCH 081/191] Upgrading keras to 1.0.5 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 73ca61bba..f9bd1e31a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ Cython==0.24 h5py==2.6.0 -Keras==1.0.0 +Keras==1.0.5 numpy==1.11.0 pygtp==0.3 PyYAML==3.11 From 488f607dde8f7fef9c0bc4c22b06a3316665163c Mon Sep 17 00:00:00 2001 From: Robert Waite Date: Sun, 17 Jul 2016 15:35:38 -0700 Subject: [PATCH 082/191] making copy of history list --- AlphaGo/go.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AlphaGo/go.py b/AlphaGo/go.py index ce324ead8..c95a98a28 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -175,7 +175,7 @@ def copy(self): other.turns_played = self.turns_played other.current_player = self.current_player other.ko = self.ko - other.history = self.history + other.history = list(self.history) other.num_black_prisoners = self.num_black_prisoners other.num_white_prisoners = self.num_white_prisoners From 864ff1329764894aa0df8352390606f5c994ceaa Mon Sep 17 00:00:00 2001 From: Robert Waite Date: Mon, 18 Jul 2016 23:24:33 -0700 Subject: [PATCH 083/191] upgrading keras in .travis file --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 0728f0e35..252394687 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,7 +14,7 @@ before_install: # Install packages install: - conda install --yes python=2.7 Cython=0.24 h5py=2.6.0 numpy=1.11.0 scipy=0.17.0 PyYAML=3.11 matplotlib pandas pytest - - pip install --user --no-deps Theano==0.8.1 sgf==0.5 keras==1.0.0 pygtp==0.2 + - pip install --user --no-deps Theano==0.8.1 sgf==0.5 keras==1.0.5 pygtp==0.2 - pip install --user flake8 # run flake8 and unit tests From 693fd77df3d08421a0d14fa44f19e7ea28407d57 Mon Sep 17 00:00:00 2001 From: James Tauber Date: Sun, 24 Jul 2016 16:39:24 -0400 Subject: [PATCH 084/191] added Python 3 to Travis config and fixed typo --- .travis.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 252394687..c8efc08e5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,9 @@ -languate: python +language: python python: -- "2.7" + - "2.7" + - "3.3" + - "3.4" + - "3.5" # Setup anaconda before_install: - wget http://repo.continuum.io/miniconda/Miniconda-latest-Linux-x86_64.sh -O miniconda.sh From 67e62dcc2abb6612505287aa4ef9a61ff53393ea Mon Sep 17 00:00:00 2001 From: Robert Waite Date: Sun, 24 Jul 2016 16:06:07 -0700 Subject: [PATCH 085/191] Don't need to make state copies if you reorder getting the one-hot state before you move --- AlphaGo/training/reinforcement_policy_trainer.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index d189ba4b1..0b7094b69 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -28,15 +28,15 @@ def make_training_pairs(player, opp, features, mini_batch_size, board_size=19): winners -- list of winners associated with each game in batch """ - def do_move(states, states_prev, moves, X_list, y_list, player_color): + def do_move(states, moves, X_list, y_list, player_color): bsize_flat = bsize * bsize - for st, st_prev, mv, X, y in zip(states, states_prev, moves, X_list, y_list): + for st, mv, X, y in zip(states, moves, X_list, y_list): + # Only do more moves if not end of game already if not st.is_end_of_game: - # Only do more moves if not end of game already + state_1hot = preprocessor.state_to_tensor(st) st.do_move(mv) if st.current_player != player_color and mv is not go.PASS_MOVE: # Convert move to one-hot - state_1hot = preprocessor.state_to_tensor(st_prev) move_1hot = np.zeros(bsize_flat) move_1hot[flatten_idx(mv, bsize)] = 1 X.append(state_1hot) @@ -54,15 +54,13 @@ def do_move(states, states_prev, moves, X_list, y_list, player_color): player1, player2 = (player, opp) if player_color == go.BLACK else \ (opp, player) while True: - # Cache states before moves - states_prev = [st.copy() for st in states] # Get moves (batch) moves_black = player1.get_moves(states) # Do moves (black) - states, X_list, y_list = do_move(states, states_prev, moves_black, X_list, y_list, player_color) + states, X_list, y_list = do_move(states, moves_black, X_list, y_list, player_color) # Do moves (white) moves_white = player2.get_moves(states) - states, X_list, y_list = do_move(states, states_prev, moves_white, X_list, y_list, player_color) + states, X_list, y_list = do_move(states, moves_white, X_list, y_list, player_color) # If all games have ended, we're done. Get winners. done = [st.is_end_of_game for st in states] if all(done): From dd15a78f89d673fb649bc6a79eff6e3162ad7709 Mon Sep 17 00:00:00 2001 From: Robert Waite Date: Sun, 24 Jul 2016 17:42:56 -0700 Subject: [PATCH 086/191] Dont need to make the state_1hot if it isn't our move. Changed logic around to clean up. --- AlphaGo/training/reinforcement_policy_trainer.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 0b7094b69..22011f3dc 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -33,9 +33,11 @@ def do_move(states, moves, X_list, y_list, player_color): for st, mv, X, y in zip(states, moves, X_list, y_list): # Only do more moves if not end of game already if not st.is_end_of_game: - state_1hot = preprocessor.state_to_tensor(st) + is_my_move = st.current_player == player_color + if is_my_move: + state_1hot = preprocessor.state_to_tensor(st) st.do_move(mv) - if st.current_player != player_color and mv is not go.PASS_MOVE: + if is_my_move and mv is not go.PASS_MOVE: # Convert move to one-hot move_1hot = np.zeros(bsize_flat) move_1hot[flatten_idx(mv, bsize)] = 1 From 15e620b79450489bd6f371b1b966668354f0530e Mon Sep 17 00:00:00 2001 From: Robert Waite Date: Sun, 24 Jul 2016 19:50:21 -0700 Subject: [PATCH 087/191] Another refactor --- AlphaGo/training/reinforcement_policy_trainer.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 22011f3dc..ea9f08fa7 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -33,16 +33,14 @@ def do_move(states, moves, X_list, y_list, player_color): for st, mv, X, y in zip(states, moves, X_list, y_list): # Only do more moves if not end of game already if not st.is_end_of_game: - is_my_move = st.current_player == player_color - if is_my_move: - state_1hot = preprocessor.state_to_tensor(st) - st.do_move(mv) - if is_my_move and mv is not go.PASS_MOVE: + if st.current_player == player_color and mv is not go.PASS_MOVE: # Convert move to one-hot + state_1hot = preprocessor.state_to_tensor(st) move_1hot = np.zeros(bsize_flat) move_1hot[flatten_idx(mv, bsize)] = 1 X.append(state_1hot) y.append(move_1hot) + st.do_move(mv) return states, X_list, y_list # Lists of game training pairs (1-hot) From d782dc9a215faedcb5165136bf82b067def86f94 Mon Sep 17 00:00:00 2001 From: Robert Waite Date: Thu, 28 Jul 2016 11:30:58 -0700 Subject: [PATCH 088/191] Currently the winners array assumes the player is always black. Player randomly is black or white. --- .../training/reinforcement_policy_trainer.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index ea9f08fa7..9e16074cf 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -65,15 +65,15 @@ def do_move(states, moves, X_list, y_list, player_color): done = [st.is_end_of_game for st in states] if all(done): break - winners = [st.get_winner() for st in states] + won_game_list = [st.get_winner() == player_color for st in states] # Concatenate tensors across turns within each game for i in xrange(mini_batch_size): X_list[i] = np.concatenate(X_list[i], axis=0) y_list[i] = np.vstack(y_list[i]) - return X_list, y_list, winners + return X_list, y_list, won_game_list -def train_batch(player, X_list, y_list, winners, lr): +def train_batch(player, X_list, y_list, won_game_list, lr): """Given the outcomes of a mini-batch of play against a fixed opponent, update the weights with reinforcement learning. @@ -89,13 +89,13 @@ def train_batch(player, X_list, y_list, winners, lr): player -- same player, with updated weights. """ - for X, y, winner in zip(X_list, y_list, winners): + for X, y, won_game in zip(X_list, y_list, won_game_list): # Update weights in + direction if player won, and - direction if player lost. # Setting learning rate negative is hack for negative weights update. - if winner == -1: - player.policy.model.optimizer.lr.set_value(-lr) - else: + if won_game: player.policy.model.optimizer.lr.set_value(lr) + else: + player.policy.model.optimizer.lr.set_value(-lr) player.policy.model.fit(X, y, nb_epoch=1, batch_size=len(X)) @@ -190,10 +190,10 @@ def save_metadata(): if args.verbose: print "Batch {}\tsampled opponent is {}".format(i_iter, opp_weights) # Make training pairs and do RL - X_list, y_list, winners = make_training_pairs(player, opponent, features, args.game_batch, board_size) - win_ratio = np.sum(np.array(winners) == 1) / float(args.game_batch) + X_list, y_list, won_game_list = make_training_pairs(player, opponent, features, args.game_batch, board_size) + win_ratio = np.sum(won_game_list) / float(args.game_batch) metadata["win_ratio"][player_weights] = (opp_weights, win_ratio) - train_batch(player, X_list, y_list, winners, args.learning_rate) + train_batch(player, X_list, y_list, won_game_list, args.learning_rate) # Save intermediate models player_weights = "weights.%05d.hdf5" % i_iter player.policy.model.save_weights(os.path.join(args.out_directory, player_weights)) From c0761a55b976263bfe3c1b5cd1101baa457fbb23 Mon Sep 17 00:00:00 2001 From: Robert Waite Date: Thu, 28 Jul 2016 18:05:32 -0700 Subject: [PATCH 089/191] Add pycharm project directory to ignore file --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 798aaaaf1..6cce45ad3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ !tests/test_data/hdf5/*.hdf5 src/ benchmarks/data/ +/.idea From b8c3c00e8382d0f96fba493844da20a82bc40e52 Mon Sep 17 00:00:00 2001 From: Robert Waite Date: Thu, 28 Jul 2016 18:08:30 -0700 Subject: [PATCH 090/191] Metadata is hard to read when not indented or sorted --- AlphaGo/training/reinforcement_policy_trainer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index ea9f08fa7..9affcbb72 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -174,7 +174,7 @@ def run_training(cmd_line_args=None): def save_metadata(): with open(os.path.join(args.out_directory, "metadata.json"), "w") as f: - json.dump(metadata, f) + json.dump(metadata, f, sort_keys=True, indent=4) # Set SGD and compile sgd = SGD(lr=args.learning_rate) From 4d3c289d4259967d96de1fa76765bddf73b4bab3 Mon Sep 17 00:00:00 2001 From: Robert Waite Date: Thu, 28 Jul 2016 01:20:37 -0700 Subject: [PATCH 091/191] Make sure minibatch split between player being white and black --- .../training/reinforcement_policy_trainer.py | 63 ++++++++++++++----- 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 3a7f62498..c3bdc789f 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -2,6 +2,7 @@ import argparse import json import numpy as np +import itertools from shutil import copyfile from keras.optimizers import SGD from AlphaGo.ai import ProbabilisticPolicyPlayer @@ -28,18 +29,33 @@ def make_training_pairs(player, opp, features, mini_batch_size, board_size=19): winners -- list of winners associated with each game in batch """ - def do_move(states, moves, X_list, y_list, player_color): + def record_training_pair(st, mv, X, y): + # Convert move to one-hot bsize_flat = bsize * bsize + state_1hot = preprocessor.state_to_tensor(st) + move_1hot = np.zeros(bsize_flat) + move_1hot[flatten_idx(mv, bsize)] = 1 + X.append(state_1hot) + y.append(move_1hot) + + # First we want to prep the states so that half of the boards get a move. + # The other half we want to leave alone so that the second player makes the first move (being black). + # Decided to alternate every board because this is how humans would play a match. + def play_half_of_boards(states, moves, X_list, y_list, player_color): + for st, mv, X, y, should_move in zip(states, moves, X_list, y_list, itertools.cycle([True, False])): + if should_move: + if st.current_player == player_color: + record_training_pair(st, mv, X, y) + st.do_move(mv) + return states, X_list, y_list + + def do_move(states, moves, X_list, y_list, player_color): for st, mv, X, y in zip(states, moves, X_list, y_list): # Only do more moves if not end of game already if not st.is_end_of_game: + # Only want to record moves by the 'player', not the opponent if st.current_player == player_color and mv is not go.PASS_MOVE: - # Convert move to one-hot - state_1hot = preprocessor.state_to_tensor(st) - move_1hot = np.zeros(bsize_flat) - move_1hot[flatten_idx(mv, bsize)] = 1 - X.append(state_1hot) - y.append(move_1hot) + record_training_pair(st, mv, X, y) st.do_move(mv) return states, X_list, y_list @@ -51,21 +67,34 @@ def do_move(states, moves, X_list, y_list, player_color): states = [GameState(size=board_size) for i in xrange(mini_batch_size)] # Randomly choose who goes first (i.e. color of 'player') player_color = np.random.choice([go.BLACK, go.WHITE]) - player1, player2 = (player, opp) if player_color == go.BLACK else \ - (opp, player) + player1, player2 = (player, opp) if player_color == go.BLACK else (opp, player) + # We let player1 move first for half of the boards in the minibatch + # The other half of the boards will be empty... waiting for a 'black' player + moves_player1 = player1.get_moves(states) + states, X_list, y_list = play_half_of_boards(states, moves_player1, X_list, y_list, player_color) + # Now player2 can move and will act as white for half and black for half + moves_player2 = player2.get_moves(states) + states, X_list, y_list = do_move(states, moves_player2, X_list, y_list, player_color) + # Now the game can continue.. each player acting as white and black split across the boards while True: - # Get moves (batch) - moves_black = player1.get_moves(states) - # Do moves (black) - states, X_list, y_list = do_move(states, moves_black, X_list, y_list, player_color) - # Do moves (white) - moves_white = player2.get_moves(states) - states, X_list, y_list = do_move(states, moves_white, X_list, y_list, player_color) + # Get moves (batch) for player1 + moves_player1 = player1.get_moves(states) + states, X_list, y_list = do_move(states, moves_player1, X_list, y_list, player_color) + # Get moves for player2 + moves_player2 = player2.get_moves(states) + states, X_list, y_list = do_move(states, moves_player2, X_list, y_list, player_color) # If all games have ended, we're done. Get winners. done = [st.is_end_of_game for st in states] if all(done): break - won_game_list = [st.get_winner() == player_color for st in states] + won_game_list = [] + # If player was black, every even board is black, odd board white + if player_color == go.BLACK: + for st, game_color in zip(states, itertools.cycle([go.BLACK, go.WHITE])): + won_game_list.append(st.get_winner() == game_color) + else: + for st, game_color in zip(states, itertools.cycle([go.WHITE, go.BLACK])): + won_game_list.append(st.get_winner() == game_color) # Concatenate tensors across turns within each game for i in xrange(mini_batch_size): X_list[i] = np.concatenate(X_list[i], axis=0) From b1fdf38d453b59d17ffe4c87f119e4a06379aaa4 Mon Sep 17 00:00:00 2001 From: Robert Waite Date: Mon, 1 Aug 2016 23:10:07 -0700 Subject: [PATCH 092/191] Adding MCTSPlayer to ai --- AlphaGo/ai.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/AlphaGo/ai.py b/AlphaGo/ai.py index c159465b1..71035be26 100644 --- a/AlphaGo/ai.py +++ b/AlphaGo/ai.py @@ -1,6 +1,7 @@ """Policy players""" -from AlphaGo import go import numpy as np +from AlphaGo import go +from AlphaGo import mcts class GreedyPolicyPlayer(object): @@ -66,3 +67,18 @@ def get_moves(self, states): choice_idx = np.random.choice(len(moves), p=probabilities) move_list[i] = moves[choice_idx] return move_list + + +class MCTSPlayer(object): + def __init__(self, policy_function, value_function, rollout_function, lmbda=.5, c_puct=5, rollout_limit=500, playout_depth=40, n_search=100): + self.mcts = mcts.MCTS(value_function, policy_function, rollout_function, lmbda, c_puct, + rollout_limit, playout_depth, n_search) + + def get_move(self, state): + sensible_moves = [move for move in state.get_legal_moves() if not state.is_eye(move, state.current_player)] + if len(sensible_moves) > 0: + move = self.mcts.get_move(state) + self.mcts.update_with_move(move) + return move + # No 'sensible' moves available, so do pass move + return go.PASS_MOVE From 7b2d7245f73fe3d030ff23c21e62b393d4029a19 Mon Sep 17 00:00:00 2001 From: Robert Waite Date: Mon, 1 Aug 2016 23:13:10 -0700 Subject: [PATCH 093/191] The training data needs to be a multiple of the minibatch size or you get a warning from keras then mentions possible accuracy loss --- AlphaGo/training/supervised_policy_trainer.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AlphaGo/training/supervised_policy_trainer.py b/AlphaGo/training/supervised_policy_trainer.py index de712f203..6103116cc 100644 --- a/AlphaGo/training/supervised_policy_trainer.py +++ b/AlphaGo/training/supervised_policy_trainer.py @@ -131,7 +131,9 @@ def run_training(cmd_line_args=None): dataset = h5.File(args.train_data) n_total_data = len(dataset["states"]) n_train_data = int(args.train_val_test[0] * n_total_data) - n_val_data = int(args.train_val_test[1] * n_total_data) + # Need to make sure training data is divisible by minibatch size or get warning mentioning accuracy from keras + n_train_data = n_train_data - (n_train_data % args.minibatch) + n_val_data = n_total_data - n_train_data # n_test_data = n_total_data - (n_train_data + n_val_data) if args.verbose: From 946da8f9ecbf052f2a235e090758965cc39a1371 Mon Sep 17 00:00:00 2001 From: Robert Waite Date: Thu, 28 Jul 2016 11:46:33 -0700 Subject: [PATCH 094/191] Using itertools (+1 squashed commits) Squashed commits: [aa7f223] Created gamestate to sgf converter. Also switched a couple statements to reflect col then row order. --- AlphaGo/util.py | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/AlphaGo/util.py b/AlphaGo/util.py index 646b7cd9b..394c29a0b 100644 --- a/AlphaGo/util.py +++ b/AlphaGo/util.py @@ -1,3 +1,5 @@ +import os +import itertools import sgf from AlphaGo import go @@ -21,9 +23,9 @@ def _parse_sgf_move(node_value): if node_value == '' or node_value == 'tt': return go.PASS_MOVE else: - row = LETTERS.index(node_value[1].upper()) - col = LETTERS.index(node_value[0].upper()) # GameState expects (x, y) where x is column and y is row + col = LETTERS.index(node_value[0].upper()) + row = LETTERS.index(node_value[1].upper()) return (col, row) @@ -60,6 +62,30 @@ def sgf_to_gamestate(sgf_string): return gs +def save_gamestate_to_sgf(gamestate, path, filename, black_player_name='Unknown', white_player_name='Unknown', size=19, komi=7.5): + """Creates a simplified sgf for viewing playouts or positions + """ + str_list = [] + # Game info + str_list.append('(;GM[1]FF[4]CA[UTF-8]') + str_list.append('SZ[{}]'.format(size)) + str_list.append('KM[{}]'.format(komi)) + str_list.append('PB[{}]'.format(black_player_name)) + str_list.append('PW[{}]'.format(white_player_name)) + # Move list + for move, color in zip(gamestate.history, itertools.cycle('BW')): + # Move color prefix + str_list.append(';{}'.format(color)) + # Move coordinates + if move is None: + str_list.append('[tt]') + else: + str_list.append('[{}{}]'.format(LETTERS[move[0]].lower(), LETTERS[move[1]].lower())) + str_list.append(')') + with open(os.path.join(path, filename), "w") as f: + f.write(''.join(str_list)) + + def sgf_iter_states(sgf_string, include_end=True): """Iterates over (GameState, move, player) tuples in the first game of the given SGF file. From 85a8d16f94bb93fdfab2e974739cf114327c741b Mon Sep 17 00:00:00 2001 From: wrongu Date: Mon, 29 Aug 2016 22:46:33 -0400 Subject: [PATCH 095/191] Cleaning up single-threaded MCTS class. Simplified some functions, as well as some stylistic changes to make it more consistent with the rest of the project. --- AlphaGo/ai.py | 4 +- AlphaGo/go.py | 5 + AlphaGo/mcts.py | 252 ++++++++++++++++++++++++++------------------- tests/test_mcts.py | 36 +++---- 4 files changed, 172 insertions(+), 125 deletions(-) diff --git a/AlphaGo/ai.py b/AlphaGo/ai.py index 71035be26..cd31a0e8a 100644 --- a/AlphaGo/ai.py +++ b/AlphaGo/ai.py @@ -70,9 +70,9 @@ def get_moves(self, states): class MCTSPlayer(object): - def __init__(self, policy_function, value_function, rollout_function, lmbda=.5, c_puct=5, rollout_limit=500, playout_depth=40, n_search=100): + def __init__(self, value_function, policy_function, rollout_function, lmbda=.5, c_puct=5, rollout_limit=500, playout_depth=40, n_playout=100): self.mcts = mcts.MCTS(value_function, policy_function, rollout_function, lmbda, c_puct, - rollout_limit, playout_depth, n_search) + rollout_limit, playout_depth, n_playout) def get_move(self, state): sensible_moves = [move for move in state.get_legal_moves() if not state.is_eye(move, state.current_player)] diff --git a/AlphaGo/go.py b/AlphaGo/go.py index c95a98a28..31db94fa3 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -310,6 +310,11 @@ def get_winner(self): winner = 0 return winner + def get_current_player(self): + """Returns the color of the player who will make the next move. + """ + return self.current_player + def do_move(self, action, color=None): """Play stone at action=(x,y). If color is not specified, current_player is used If it is a legal move, current_player switches to the opposite color diff --git a/AlphaGo/mcts.py b/AlphaGo/mcts.py index 72788d89c..d2d572e2a 100644 --- a/AlphaGo/mcts.py +++ b/AlphaGo/mcts.py @@ -1,174 +1,216 @@ +"""Monte Carlo Tree Search, as described in Silver et al 2015. + +This is a "pure" implementation of the AlphaGo MCTS algorithm in that it is not specific to the +game of Go; everything in this file is implemented generically with respect to some state, actions, +policy function, and value function. +""" import numpy as np class TreeNode(object): - """Tree Representation of MCTS that covers Selection, Expansion, Evaluation, and backUp (aka 'update()') + """A node in the MCTS tree. Each node keeps track of its own value Q, prior probability P, and + its visit-count-adjusted prior score u. """ def __init__(self, parent, prior_p): - self.parent = parent - self.nVisits = 0 - self.Q_value = 0 - self.u_value = prior_p - self.children = {} - self.P = prior_p - - def expansion(self, actions): - """Expand subtree - a dictionary with a tuple of (x,y) position as keys, TreeNode object as values + self._parent = parent + self._children = {} # a map from action to TreeNode + self._n_visits = 0 + self._Q = 0 + # This value for u will be overwritten in the first call to update(), but is useful for + # choosing the first action from this node. + self._u = prior_p + self._P = prior_p + + def expand(self, action_priors): + """Expand tree by creating new children. - Keyword arguments: - Output from policy function - a list of tuples of (x, y) position and prior probability + Arguments: + action_priors -- output from policy function - a list of tuples of actions and their prior + probability according to the policy function. Returns: None """ - for action, prob in actions: - if action not in self.children: - self.children[action] = TreeNode(self, prob) + for action, prob in action_priors: + if action not in self._children: + self._children[action] = TreeNode(self, prob) - def selection(self): - """Select among subtree to get the position that gives maximum action value Q plus bonus u(P) - - Keyword arguments: - None. + def select(self): + """Select action among children that gives maximum action value, Q plus bonus u(P). Returns: - a tuple of (action, next_node) - """ - return max(self.children.iteritems(), key=lambda (a, n): n.toValue()) - - def isLeaf(self): - """Check if leaf node (i.e. no nodes below this have been expanded) + A tuple of (action, next_node) """ - return self.children == {} + return max(self._children.iteritems(), key=lambda (action, node): node.get_value()) def update(self, leaf_value, c_puct): - """Update node values from leaf evaluation + """Update node values from leaf evaluation. Arguments: - value of traversed subtree evaluation + leaf_value -- the value of subtree evaluation from the current player's perspective. + c_puct -- a number in (0, inf) controlling the relative impact of values, Q, and + prior probability, P, on this node's score. Returns: None """ - # count visit - self.nVisits += 1 - # update Q - mean_V = self.Q_value * (self.nVisits - 1) - self.Q_value = (mean_V + leaf_value) / self.nVisits - # update u (note that u is not normalized to be a distribution) - self.u_value = c_puct * self.P * np.sqrt(self.parent.nVisits) / (1 + self.nVisits) - - def toValue(self): - """Return action value Q plus bonus u(P) + # Count visit. + self._n_visits += 1 + # Update Q, a running average of values for all visits. + self._Q += (leaf_value - self._Q) / self._n_visits + # Update u, the prior weighted by an exploration hyperparameter c_puct and the number of + # visits. Note that u is not normalized to be a distribution. + if not self.is_root(): + self._u = c_puct * self._P * np.sqrt(self._parent._n_visits) / (1 + self._n_visits) + + def update_recursive(self, leaf_value, c_puct): + """Like a call to update(), but applied recursively for all ancestors. + + Note: it is important that this happens from the root downward so that 'parent' visit + counts are correct. + """ + # If it is not root, this node's parent should be updated first. + if self._parent: + self._parent.update_recursive(leaf_value, c_puct) + self.update(leaf_value, c_puct) + + def get_value(self): + """Calculate and return the value for this node: a combination of leaf evaluations, Q, and + this node's prior adjusted for its visit count, u """ - return self.Q_value + self.u_value + return self._Q + self._u + def is_leaf(self): + """Check if leaf node (i.e. no nodes below this have been expanded). + """ + return self._children == {} -class MCTS(object): - """Monte Carlo tree search, takes an input of game state, value network function, policy network function, - rollout policy function. get_move outputs an action after lookahead search is complete. + def is_root(self): + return self._parent is None - The value function should take in a state and output a number in [-1, 1] - The policy and rollout functions should take in a state and output a list of (action,prob) tuples where - action is an (x,y) tuple - lmbda and c_puct are hyperparameters. 0 <= lmbda <= 1 controls the relative weight of the value network and fast - rollouts in determining the value of a leaf node. 0 < c_puct < inf controls how quickly exploration converges - to the maximum-value policy +class MCTS(object): + """A simple (and slow) single-threaded implementation of Monte Carlo Tree Search. + + Search works by exploring moves randomly according to the given policy up to a certain + depth, which is relatively small given the search space. "Leaves" at this depth are assigned a + value comprising a weighted combination of (1) the value function evaluated at that leaf, and + (2) the result of finishing the game from that leaf according to the 'rollout' policy. The + probability of revisiting a node changes over the course of the many playouts according to its + estimated value. Ultimately the most visited node is returned as the next action, not the most + valued node. + + The term "playout" refers to a single search from the root, whereas "rollout" refers to the + fast evaluation from leaf nodes to the end of the game. """ - def __init__(self, state, value_network, policy_network, rollout_policy, lmbda=0.5, c_puct=5, rollout_limit=500, playout_depth=20, n_search=10000): - self.root = TreeNode(None, 1.0) - self._value = value_network - self._policy = policy_network - self._rollout = rollout_policy + def __init__(self, value_fn, policy_fn, rollout_policy_fn, lmbda=0.5, c_puct=5, rollout_limit=500, playout_depth=20, n_playout=10000): + """Arguments: + value_fn -- a function that takes in a state and ouputs a score in [-1, 1], i.e. the + expected value of the end game score from the current player's perspective. + policy_fn -- a function that takes in a state and outputs a list of (action, probability) + tuples for the current player. + rollout_policy_fn -- a coarse, fast version of policy_fn used in the rollout phase. + lmbda -- controls the relative weight of the value network and fast rollout policy result + in determining the value of a leaf node. lmbda must be in [0, 1], where 0 means use only + the value network and 1 means use only the result from the rollout. + c_puct -- a number in (0, inf) that controls how quickly exploration converges to the maximum- + value policy, where a higher value means relying on the prior more, and should be used only + in conjunction with a large value for n_playout. + """ + self._root = TreeNode(None, 1.0) + self._value = value_fn + self._policy = policy_fn + self._rollout = rollout_policy_fn self._lmbda = lmbda self._c_puct = c_puct self._rollout_limit = rollout_limit self._L = playout_depth - self._n_search = n_search + self._n_playout = n_playout - def _DFS(self, nDepth, treenode, state): - """Monte Carlo tree search over a certain depth per simulation, at the end of simulation, - the action values and visits of counts of traversed treenode are updated. + def _playout(self, state, leaf_depth): + """Run a single playout from the root to the given depth, getting a value at the leaf and + propagating it back through its parents. State is modified in-place, so a copy must be + provided. - Keyword arguments: - Initial GameState object - Initial TreeNode object - Search Depth + Arguments: + state -- a copy of the state. + leaf_depth -- after this many moves, leaves are evaluated. Returns: None """ - - visited = [None] * nDepth - - # Playout to nDepth moves using the full policy network - for index in xrange(nDepth): - action_probs = self._policy(state) - # check for end of game - if len(action_probs) == 0: - break - treenode.expansion(action_probs) - action, treenode = treenode.selection() + node = self._root + for i in range(leaf_depth): + # Only expand node if it has not already been done. Existing nodes already know their + # prior. + if node.is_leaf(): + action_probs = self._policy(state) + # Check for end of game. + if len(action_probs) == 0: + break + node.expand(action_probs) + # Greedily select next move. + action, node = node.select() state.do_move(action) - visited[index] = treenode - # leaf evaluation - v = self._value(state) - z = self._evaluate_rollout(state, self._rollout_limit) + # Evaluate the leaf using a weighted combination of the value network, v, and the game's + # winner, z, according to the rollout policy. If lmbda is equal to 0 or 1, only one of + # these contributes and the other may be skipped. Both v and z are from the perspective + # of the current player (+1 is good, -1 is bad). + v = self._value(state) if self._lmbda < 1 else 0 + z = self._evaluate_rollout(state, self._rollout_limit) if self._lmbda > 0 else 0 leaf_value = (1 - self._lmbda) * v + self._lmbda * z - # update value and visit count of nodes in this traversal - # Note: it is important that this happens from the root downward - # so that 'parent' visit counts are correct - for node in visited: - node.update(leaf_value, self._c_puct) + # Update value and visit count of nodes in this traversal. + node.update_recursive(leaf_value, self._c_puct) def _evaluate_rollout(self, state, limit): - """Use the rollout policy to play until the end of the game, get the winner (or 0 if tie) + """Use the rollout policy to play until the end of the game, returning +1 if the current + player wins, -1 if the opponent wins, and 0 if it is a tie. """ - for i in xrange(limit): + player = state.get_current_player() + for i in range(limit): action_probs = self._rollout(state) if len(action_probs) == 0: break max_action = max(action_probs, key=lambda (a, p): p)[0] state.do_move(max_action) else: - # if no break from the loop + # If no break from the loop, issue a warning. print "WARNING: rollout reached move limit" - return state.get_winner() + winner = state.get_winner() + if winner == 0: + return 0 + else: + return 1 if winner == player else -1 def get_move(self, state): - """After running simulations for a certain number of times, when the search is complete, an action is selected - from root state + """Runs all playouts sequentially and returns the most visited action. - Keyword arguments: - Number of Simulations + Arguments: + state -- the current state, including both game state and the current player. Returns: - action -- a tuple of (x, y) + the selected action """ - action_probs = self._policy(state) - self.root.expansion(action_probs) - - for n in xrange(0, self._n_search): + for n in range(self._n_playout): state_copy = state.copy() - self._DFS(self._L, self.root, state_copy) + self._playout(state_copy, self._L) - # chosen action is the *most visited child*, not the highest-value - # (note that they are the same as self._n_search gets large) - return max(self.root.children.iteritems(), key=lambda (a, n): n.nVisits)[0] + # chosen action is the *most visited child*, not the highest-value one + # (they are the same as self._n_playout gets large). + return max(self._root._children.iteritems(), key=lambda (a, n): n._n_visits)[0] def update_with_move(self, last_move): - """step forward in the tree and discard everything that isn't still reachable + """Step forward in the tree, keeping everything we already know about the subtree, assuming + that get_move() has been called already. Siblings of the new root will be garbage-collected. """ - if last_move in self.root.children: - self.root = self.root.children[last_move] - self.root.parent = None - # siblings of root will be garbage-collected because they are no longer reachable + if last_move in self._root._children: + self._root = self._root._children[last_move] + self._root._parent = None else: - self.root = TreeNode(None, 1.0) + self._root = TreeNode(None, 1.0) class ParallelMCTS(MCTS): diff --git a/tests/test_mcts.py b/tests/test_mcts.py index 1e5e24ede..b311f0308 100644 --- a/tests/test_mcts.py +++ b/tests/test_mcts.py @@ -8,43 +8,43 @@ class TestMCTS(unittest.TestCase): def setUp(self): self.gs = GameState() - self.mcts = MCTS(self.gs, value_network, policy_network, rollout_policy, n_search=2) + self.mcts = MCTS(dummy_value, dummy_policy, dummy_rollout, n_playout=2) def test_treenode_selection(self): treenode = TreeNode(None, 1.0) - treenode.expansion(policy_network(self.gs)) - action, node = treenode.selection() - self.assertEqual(action, (18, 18)) # according to the policy below + treenode.expand(dummy_policy(self.gs)) + action, node = treenode.select() + self.assertEqual(action, (18, 18)) # according to the dummy policy below self.assertIsNotNone(node) - def test_mcts_DFS(self): - treenode = TreeNode(None, 1.0) - self.mcts._DFS(8, treenode, self.gs.copy()) - self.assertEqual(1, treenode.children[(18, 18)].nVisits, 'DFS visits incorrect') + def test_mcts_playout(self): + self.mcts._playout(self.gs.copy(), 8) + self.assertEqual(1, self.mcts._root._children[(18, 18)]._n_visits, 'playout visits incorrect') - def test_mcts_getMove(self): + def test_mcts_get_move(self): move = self.mcts.get_move(self.gs) self.mcts.update_with_move(move) # success if no errors -def policy_network(state): +# A distribution over positions that is smallest at (0,0) and largest at (18,18) +dummy_distribution = np.arange(361, dtype=np.float) +dummy_distribution = dummy_distribution / dummy_distribution.sum() + + +def dummy_policy(state): moves = state.get_legal_moves(include_eyes=False) - # 'random' distribution over positions that is smallest - # at (0,0) and largest at (18,18) - probs = np.arange(361, dtype=np.float) - probs = probs / probs.sum() - return zip(moves, probs) + return zip(moves, dummy_distribution) -def value_network(state): +def dummy_value(state): # it's not very confident return 0.0 -def rollout_policy(state): +def dummy_rollout(state): # just another policy network - return policy_network(state) + return dummy_policy(state) if __name__ == '__main__': From cff35e49e84a48da554757671a9de581e0eef4e0 Mon Sep 17 00:00:00 2001 From: wrongu Date: Tue, 30 Aug 2016 00:23:35 -0400 Subject: [PATCH 096/191] Updating test_mcts.py. Separating TreeNode and MCTS tests, adding more of each. --- tests/test_mcts.py | 112 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 97 insertions(+), 15 deletions(-) diff --git a/tests/test_mcts.py b/tests/test_mcts.py index b311f0308..c6ab6f803 100644 --- a/tests/test_mcts.py +++ b/tests/test_mcts.py @@ -4,28 +4,112 @@ import unittest -class TestMCTS(unittest.TestCase): +class TestTreeNode(unittest.TestCase): def setUp(self): self.gs = GameState() - self.mcts = MCTS(dummy_value, dummy_policy, dummy_rollout, n_playout=2) + self.node = TreeNode(None, 1.0) - def test_treenode_selection(self): - treenode = TreeNode(None, 1.0) - treenode.expand(dummy_policy(self.gs)) - action, node = treenode.select() + def test_selection(self): + self.node.expand(dummy_policy(self.gs)) + action, next_node = self.node.select() self.assertEqual(action, (18, 18)) # according to the dummy policy below - self.assertIsNotNone(node) + self.assertIsNotNone(next_node) + + def test_expansion(self): + self.assertEqual(0, len(self.node._children)) + self.node.expand(dummy_policy(self.gs)) + self.assertEqual(19 * 19, len(self.node._children)) + for a, p in dummy_policy(self.gs): + self.assertEqual(p, self.node._children[a]._P) + + def test_update(self): + self.node.expand(dummy_policy(self.gs)) + child = self.node._children[(18, 18)] + # Note: the root must be updated first for the visit count to work. + self.node.update(leaf_value=1.0, c_puct=5.0) + child.update(leaf_value=1.0, c_puct=5.0) + expected_score = 1.0 + 5.0 * dummy_distribution[-1] * 0.5 + self.assertEqual(expected_score, child.get_value()) + # After a second update, the Q value should be the average of the two, and the u value + # should be multiplied by sqrt(parent visits) / (node visits + 1) (which was simply equal + # to 0.5 before) + self.node.update(leaf_value=0.0, c_puct=5.0) + child.update(leaf_value=0.0, c_puct=5.0) + expected_score = 0.5 + 5.0 * dummy_distribution[-1] * np.sqrt(2.0) / 3.0 + self.assertEqual(expected_score, child.get_value()) + + def test_update_recursive(self): + # Assertions are identical to test_treenode_update. + self.node.expand(dummy_policy(self.gs)) + child = self.node._children[(18, 18)] + child.update_recursive(leaf_value=1.0, c_puct=5.0) + expected_score = 1.0 + 5.0 * dummy_distribution[-1] / 2.0 + self.assertEqual(expected_score, child.get_value()) + child.update_recursive(leaf_value=0.0, c_puct=5.0) + expected_score = 0.5 + 5.0 * dummy_distribution[-1] * np.sqrt(2.0) / 3.0 + self.assertEqual(expected_score, child.get_value()) + + +class TestMCTS(unittest.TestCase): + + def setUp(self): + self.gs = GameState() + self.mcts = MCTS(dummy_value, dummy_policy, dummy_rollout, n_playout=2) - def test_mcts_playout(self): + def _count_expansions(self): + """Helper function to count the number of expansions past the root using the dummy policy + """ + node = self.mcts._root + expansions = 0 + # Loop over actions in decreasing probability. + for action, _ in sorted(dummy_policy(self.gs), key=lambda (a, p): p, reverse=True): + if action in node._children: + expansions += 1 + node = node._children[action] + else: + break + return expansions + + def test_playout(self): + self.mcts._playout(self.gs.copy(), 8) + # Assert that the most likely child was visited (according to the dummy policy below). + self.assertEqual(1, self.mcts._root._children[(18, 18)]._n_visits) + # Assert that the search depth expanded nodes 8 times. + self.assertEqual(8, self._count_expansions()) + + def test_playout_with_pass(self): + # Test that playout handles the end of the game (i.e. passing/no moves). Mock this by + # creating a policy that returns nothing after 4 moves. + def stop_early_policy(state): + if len(state.history) <= 4: + return dummy_policy(state) + else: + return [] + self.mcts = MCTS(dummy_value, stop_early_policy, stop_early_policy, n_playout=2) self.mcts._playout(self.gs.copy(), 8) - self.assertEqual(1, self.mcts._root._children[(18, 18)]._n_visits, 'playout visits incorrect') + # Assert that (18, 18) and (18, 17) are still only visited once. + self.assertEqual(1, self.mcts._root._children[(18, 18)]._n_visits) + # Assert that no expansions happened after reaching the "end" in 4 moves. + self.assertEqual(5, self._count_expansions()) - def test_mcts_get_move(self): + def test_get_move(self): move = self.mcts.get_move(self.gs) self.mcts.update_with_move(move) # success if no errors + def test_update_with_move(self): + move = self.mcts.get_move(self.gs) + self.gs.do_move(move) + self.mcts.update_with_move(move) + # Assert that the new root still has children. + self.assertTrue(len(self.mcts._root._children) > 0) + # Assert that the new root has no parent (the rest of the tree will be garbage collected). + self.assertIsNone(self.mcts._root._parent) + # Assert that the next best move according to the root is (18, 17), according to the + # dummy policy below. + self.assertEqual((18, 17), self.mcts._root.select()[0]) + # A distribution over positions that is smallest at (0,0) and largest at (18,18) dummy_distribution = np.arange(361, dtype=np.float) @@ -36,16 +120,14 @@ def dummy_policy(state): moves = state.get_legal_moves(include_eyes=False) return zip(moves, dummy_distribution) +# Rollout is a clone of the policy function. +dummy_rollout = dummy_policy + def dummy_value(state): # it's not very confident return 0.0 -def dummy_rollout(state): - # just another policy network - return dummy_policy(state) - - if __name__ == '__main__': unittest.main() From 55e9d45f2fe86bc77276ee93092ba42d99d9e48a Mon Sep 17 00:00:00 2001 From: wrongu Date: Tue, 30 Aug 2016 08:25:13 -0400 Subject: [PATCH 097/191] SL and RL metadata pretty printing with 2-space indents --- AlphaGo/training/reinforcement_policy_trainer.py | 2 +- AlphaGo/training/supervised_policy_trainer.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 3a7f62498..0d4e3ba3f 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -174,7 +174,7 @@ def run_training(cmd_line_args=None): def save_metadata(): with open(os.path.join(args.out_directory, "metadata.json"), "w") as f: - json.dump(metadata, f, sort_keys=True, indent=4) + json.dump(metadata, f, sort_keys=True, indent=2) # Set SGD and compile sgd = SGD(lr=args.learning_rate) diff --git a/AlphaGo/training/supervised_policy_trainer.py b/AlphaGo/training/supervised_policy_trainer.py index 6103116cc..849bcc6b5 100644 --- a/AlphaGo/training/supervised_policy_trainer.py +++ b/AlphaGo/training/supervised_policy_trainer.py @@ -67,7 +67,7 @@ def on_epoch_end(self, epoch, logs={}): self.metadata["best_epoch"] = epoch with open(self.file, "w") as f: - json.dump(self.metadata, f) + json.dump(self.metadata, f, indent=2) BOARD_TRANSFORMATIONS = { From ad3c5f7dc917c6742b930e268e6e048184c970ad Mon Sep 17 00:00:00 2001 From: wrongu Date: Tue, 30 Aug 2016 08:39:46 -0400 Subject: [PATCH 098/191] Komi included in GameState.copy() --- AlphaGo/go.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AlphaGo/go.py b/AlphaGo/go.py index 31db94fa3..c7aacba14 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -170,7 +170,7 @@ def _remove_group(self, group): def copy(self): """get a copy of this Game state """ - other = GameState(self.size) + other = GameState(self.size, self.komi) other.board = self.board.copy() other.turns_played = self.turns_played other.current_player = self.current_player From 7e6d9752638935ac040d4cca1859bc2b93a38dd9 Mon Sep 17 00:00:00 2001 From: wrongu Date: Tue, 13 Sep 2016 08:07:03 -0400 Subject: [PATCH 099/191] Fixed link to wiki in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d6ee97ccb..7943991d0 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Selected data (i.e. trained models) are released in our [data repository](http:/ This project has primarily focused on the neural network training aspect of DeepMind's AlphaGo. We also have a simple single-threaded implementation of their tree search algorithm, though it is not fast enough to be competitive yet. -See the wiki page on the [training pipeline](https://github.com/Rochester-NRT/RocAlphaGo/wiki/Neural-Networks-and-Training) for information on how to run the training commands. +See the wiki page on the [training pipeline](https://github.com/Rochester-NRT/RocAlphaGo/wiki/04.-Neural-Networks-and-Training) for information on how to run the training commands. # How to contribute From 470ec1b2357da1a73befa5b14a7d6b15f45f5472 Mon Sep 17 00:00:00 2001 From: wrongu Date: Thu, 15 Sep 2016 10:12:48 -0400 Subject: [PATCH 100/191] Removing unused variable turns_played from GameState --- AlphaGo/go.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/AlphaGo/go.py b/AlphaGo/go.py index c7aacba14..77b5a6708 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -18,7 +18,6 @@ def __init__(self, size=19, komi=7.5): self.board = np.zeros((size, size)) self.board.fill(EMPTY) self.size = size - self.turns_played = 0 self.current_player = BLACK self.ko = None self.komi = komi @@ -172,7 +171,6 @@ def copy(self): """ other = GameState(self.size, self.komi) other.board = self.board.copy() - other.turns_played = self.turns_played other.current_player = self.current_player other.ko = self.ko other.history = list(self.history) @@ -362,7 +360,6 @@ def do_move(self, action, color=None): self.passes_white += 1 # next turn self.current_player = -color - self.turns_played += 1 self.history.append(action) self.__legal_move_cache = None else: From f32552632f63b0089a4b9b35914cffdfd2f7cb3f Mon Sep 17 00:00:00 2001 From: wrongu Date: Fri, 16 Sep 2016 15:05:09 -0400 Subject: [PATCH 101/191] SL and RL training scripts save all history of command line args in metadata --- AlphaGo/training/reinforcement_policy_trainer.py | 3 +++ AlphaGo/training/supervised_policy_trainer.py | 2 ++ 2 files changed, 5 insertions(+) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index a82e2c204..0e208b446 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -201,6 +201,9 @@ def run_training(cmd_line_args=None): with open(os.path.join(args.out_directory, "metadata.json"), "r") as f: metadata = json.load(f) + # Append args of current run to history of full command args. + metadata["cmd_line_args"] = metadata.get("cmd_line_args", []).append(vars(args)) + def save_metadata(): with open(os.path.join(args.out_directory, "metadata.json"), "w") as f: json.dump(metadata, f, sort_keys=True, indent=2) diff --git a/AlphaGo/training/supervised_policy_trainer.py b/AlphaGo/training/supervised_policy_trainer.py index 849bcc6b5..7e04dd6e5 100644 --- a/AlphaGo/training/supervised_policy_trainer.py +++ b/AlphaGo/training/supervised_policy_trainer.py @@ -161,6 +161,8 @@ def run_training(cmd_line_args=None): # TODO - model and train_data are saved in meta_file; check that they match (and make args optional when restarting?) meta_writer.metadata["training_data"] = args.train_data meta_writer.metadata["model_file"] = args.model + # Record all command line args in a list so that all args are recorded even when training is stopped and resumed. + meta_writer.metadata["cmd_line_args"] = meta_writer.metadata.get("cmd_line_args", []).append(vars(args)) # create ModelCheckpoint to save weights every epoch checkpoint_template = os.path.join(args.out_directory, "weights.{epoch:05d}.hdf5") From 3e5e40f131b9797782feea2866699480811abc80 Mon Sep 17 00:00:00 2001 From: Robert Waite Date: Sat, 3 Sep 2016 23:14:51 -0700 Subject: [PATCH 102/191] Gtp Enhancements --- AlphaGo/ai.py | 12 ++++- AlphaGo/go.py | 13 +++++- AlphaGo/util.py | 10 ++++- interface/gtp_wrapper.py | 96 ++++++++++++++++++++++++++++++++++++++-- 4 files changed, 122 insertions(+), 9 deletions(-) diff --git a/AlphaGo/ai.py b/AlphaGo/ai.py index cd31a0e8a..f8c695732 100644 --- a/AlphaGo/ai.py +++ b/AlphaGo/ai.py @@ -9,10 +9,14 @@ class GreedyPolicyPlayer(object): move each turn) """ - def __init__(self, policy_function): + def __init__(self, policy_function, pass_when_offered=False): self.policy = policy_function + self.pass_when_offered = pass_when_offered def get_move(self, state): + if self.pass_when_offered: + if len(state.history) > 100 and state.history[-1] == go.PASS_MOVE: + return go.PASS_MOVE sensible_moves = [move for move in state.get_legal_moves() if not state.is_eye(move, state.current_player)] if len(sensible_moves) > 0: move_probs = self.policy.eval_state(state, sensible_moves) @@ -30,12 +34,16 @@ class ProbabilisticPolicyPlayer(object): (high temperature) or towards greedy play (low temperature) """ - def __init__(self, policy_function, temperature=1.0): + def __init__(self, policy_function, temperature=1.0, pass_when_offered=False): assert(temperature > 0.0) self.policy = policy_function self.beta = 1.0 / temperature + self.pass_when_offered = pass_when_offered def get_move(self, state): + if self.pass_when_offered: + if len(state.history) > 100 and state.history[-1] == go.PASS_MOVE: + return go.PASS_MOVE sensible_moves = [move for move in state.get_legal_moves() if not state.is_eye(move, state.current_player)] if len(sensible_moves) > 0: move_probs = self.policy.eval_state(state, sensible_moves) diff --git a/AlphaGo/go.py b/AlphaGo/go.py index 77b5a6708..6c45c7f82 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -20,12 +20,12 @@ def __init__(self, size=19, komi=7.5): self.size = size self.current_player = BLACK self.ko = None - self.komi = komi + self.komi = komi # Komi is number of extra points WHITE gets for going 2nd + self.handicaps = [] self.history = [] self.num_black_prisoners = 0 self.num_white_prisoners = 0 self.is_end_of_game = False - self.komi = komi # Komi is number of extra points WHITE gets for going 2nd # Each pass move by a player subtracts a point self.passes_white = 0 self.passes_black = 0 @@ -173,6 +173,7 @@ def copy(self): other.board = self.board.copy() other.current_player = self.current_player other.ko = self.ko + other.handicaps = list(self.handicaps) other.history = list(self.history) other.num_black_prisoners = self.num_black_prisoners other.num_white_prisoners = self.num_white_prisoners @@ -308,6 +309,14 @@ def get_winner(self): winner = 0 return winner + def place_handicaps(self, actions): + if len(self.history) > 0: + raise IllegalMove("Cannot place handicap on a started game") + self.handicaps.extend(actions) + for action in actions: + self.do_move(action, BLACK) + self.history = [] + def get_current_player(self): """Returns the color of the player who will make the next move. """ diff --git a/AlphaGo/util.py b/AlphaGo/util.py index 394c29a0b..b801a397f 100644 --- a/AlphaGo/util.py +++ b/AlphaGo/util.py @@ -72,8 +72,16 @@ def save_gamestate_to_sgf(gamestate, path, filename, black_player_name='Unknown' str_list.append('KM[{}]'.format(komi)) str_list.append('PB[{}]'.format(black_player_name)) str_list.append('PW[{}]'.format(white_player_name)) + cycle_string = 'BW' + # Handle handicaps + if len(gamestate.handicaps) > 0: + cycle_string = 'WB' + str_list.append('HA[{}]'.format(len(gamestate.handicaps))) + str_list.append(';AB') + for handicap in gamestate.handicaps: + str_list.append('[{}{}]'.format(LETTERS[handicap[0]].lower(), LETTERS[handicap[1]].lower())) # Move list - for move, color in zip(gamestate.history, itertools.cycle('BW')): + for move, color in zip(gamestate.history, itertools.cycle(cycle_string)): # Move color prefix str_list.append(';{}'.format(color)) # Move coordinates diff --git a/interface/gtp_wrapper.py b/interface/gtp_wrapper.py index 22838e107..ebce1b911 100644 --- a/interface/gtp_wrapper.py +++ b/interface/gtp_wrapper.py @@ -1,6 +1,81 @@ -from AlphaGo import go -import gtp import sys +import multiprocessing +import gtp +from AlphaGo import go +from AlphaGo.util import save_gamestate_to_sgf + + +def run_gnugo(sgf_file_name, command): + from distutils import spawn + if spawn.find_executable('gnugo'): + from subprocess import Popen, PIPE + p = Popen(['gnugo', '--chinese-rules', '--mode', 'gtp', '-l', sgf_file_name], stdout=PIPE, stdin=PIPE, stderr=PIPE) + out_bytes = p.communicate(input=command)[0] + return out_bytes.decode('utf-8')[2:] + else: + return '' + + +class ExtendedGtpEngine(gtp.Engine): + + recommended_handicaps = { + 2: "D4 Q16", + 3: "D4 Q16 D16", + 4: "D4 Q16 D16 Q4", + 5: "D4 Q16 D16 Q4 K10", + 6: "D4 Q16 D16 Q4 D10 Q10", + 7: "D4 Q16 D16 Q4 D10 Q10 K10", + 8: "D4 Q16 D16 Q4 D10 Q10 K4 K16", + 9: "D4 Q16 D16 Q4 D10 Q10 K4 K16 K10" + } + + def call_gnugo(self, sgf_file_name, command): + try: + pool = multiprocessing.Pool(processes=1) + result = pool.apply_async(run_gnugo, (sgf_file_name, command)) + output = result.get(timeout=10) + pool.close() + return output + except multiprocessing.TimeoutError: + pool.terminate() + # if can't get answer from GnuGo, return no result + return '' + + def cmd_time_left(self, arguments): + pass + + def cmd_place_free_handicap(self, arguments): + try: + number_of_stones = int(arguments) + except Exception: + raise ValueError('Number of handicaps could not be parsed: {}'.format(arguments)) + if number_of_stones < 2 or number_of_stones > 9: + raise ValueError('Invalid number of handicap stones: {}'.format(number_of_stones)) + vertex_string = ExtendedGtpEngine.recommended_handicaps[number_of_stones] + self.cmd_set_free_handicap(vertex_string) + return vertex_string + + def cmd_set_free_handicap(self, arguments): + vertices = arguments.strip().split() + moves = [gtp.parse_vertex(vertex) for vertex in vertices] + self._game.place_handicaps(moves) + + def cmd_final_score(self, arguments): + sgf_file_name = self._game.get_current_state_as_sgf() + return self.call_gnugo(sgf_file_name, 'final_score\n') + + def cmd_final_status_list(self, arguments): + sgf_file_name = self._game.get_current_state_as_sgf() + return self.call_gnugo(sgf_file_name, 'final_status_list {}\n'.format(arguments)) + + def cmd_load_sgf(self, arguments): + pass + + def cmd_save_sgf(self, arguments): + pass + + # def cmd_kgs_genmove_cleanup(self, arguments): + # return self.cmd_genmove(arguments) class GTPGameConnector(object): @@ -42,10 +117,23 @@ def get_move(self, color): (x, y) = move return (x + 1, y + 1) + def get_current_state_as_sgf(self): + from tempfile import NamedTemporaryFile + temp_file = NamedTemporaryFile(delete=False) + save_gamestate_to_sgf(self._state, '', temp_file.name) + return temp_file.name + + def place_handicaps(self, vertices): + actions = [] + for vertex in vertices: + (x, y) = vertex + actions.append((x - 1, y - 1)) + self._state.place_handicaps(actions) + -def run_gtp(player_obj, inpt_fn=None): +def run_gtp(player_obj, inpt_fn=None, name="Gtp Player", version="0.0"): gtp_game = GTPGameConnector(player_obj) - gtp_engine = gtp.Engine(gtp_game) + gtp_engine = ExtendedGtpEngine(gtp_game, name, version) if inpt_fn is None: inpt_fn = raw_input From f9c78f6490fa55e579fa08825287577153a9be90 Mon Sep 17 00:00:00 2001 From: wrongu Date: Sat, 17 Sep 2016 14:16:23 -0400 Subject: [PATCH 103/191] fixed error message in NeuralNetBase.load_model --- AlphaGo/models/nn_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AlphaGo/models/nn_util.py b/AlphaGo/models/nn_util.py index cc2b1fb5e..62696acd2 100644 --- a/AlphaGo/models/nn_util.py +++ b/AlphaGo/models/nn_util.py @@ -68,7 +68,7 @@ def load_model(json_file): try: network_class = NeuralNetBase.subclasses[class_name] except KeyError: - raise ValueError("Unknown neural network type in json file: {}\n(was it registered with the @neuralnet decorator?)".format(network_class)) + raise ValueError("Unknown neural network type in json file: {}\n(was it registered with the @neuralnet decorator?)".format(class_name)) # create new object new_net = network_class(object_specs['feature_list'], init_network=False) From 1353a86e58800f6b0030b9cf37ae506e1a9ebdc8 Mon Sep 17 00:00:00 2001 From: wrongu Date: Sat, 17 Sep 2016 16:30:12 -0400 Subject: [PATCH 104/191] updated requirements --- .travis.yml | 4 ++-- requirements.txt | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.travis.yml b/.travis.yml index c8efc08e5..3fff4cac1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,8 +16,8 @@ before_install: - sudo ln -s /run/shm /dev/shm # Install packages install: - - conda install --yes python=2.7 Cython=0.24 h5py=2.6.0 numpy=1.11.0 scipy=0.17.0 PyYAML=3.11 matplotlib pandas pytest - - pip install --user --no-deps Theano==0.8.1 sgf==0.5 keras==1.0.5 pygtp==0.2 + - conda install --yes python=2.7 Cython=0.24 h5py=2.6.0 numpy=1.11.1 scipy=0.18.0 PyYAML=3.12 matplotlib pandas pytest + - pip install --user --no-deps Theano==0.8.2 sgf==0.5 keras==1.0.8 pygtp==0.3 - pip install --user flake8 # run flake8 and unit tests diff --git a/requirements.txt b/requirements.txt index f9bd1e31a..f5c033186 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ Cython==0.24 h5py==2.6.0 -Keras==1.0.5 -numpy==1.11.0 +Keras==1.0.8 +numpy==1.11.1 pygtp==0.3 -PyYAML==3.11 -scipy==0.17.0 +PyYAML==3.12 +scipy==0.18.0 sgf==0.5 six==1.10.0 -Theano==0.8.1 +Theano==0.8.2 From 215988b248239ec15d5d6057b5ad05333309f959 Mon Sep 17 00:00:00 2001 From: Robert Waite Date: Wed, 14 Sep 2016 00:09:46 -0700 Subject: [PATCH 105/191] Adding positional superko check --- AlphaGo/go.py | 64 +++++++++++++++++++++++++++++++++++----- interface/gtp_wrapper.py | 6 ++-- tests/test_gamestate.py | 13 ++++++++ 3 files changed, 73 insertions(+), 10 deletions(-) diff --git a/AlphaGo/go.py b/AlphaGo/go.py index 6c45c7f82..67b6c7269 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -14,7 +14,7 @@ class GameState(object): # amount of time, hence this shared lookup table {boardsize: {position: [neighbors]}} __NEIGHBORS_CACHE = {} - def __init__(self, size=19, komi=7.5): + def __init__(self, size=19, komi=7.5, enforce_superko=False): self.board = np.zeros((size, size)) self.board.fill(EMPTY) self.size = size @@ -53,6 +53,15 @@ def __init__(self, size=19, komi=7.5): # on-the-fly record of 'age' of each stone self.stone_ages = np.zeros((size, size), dtype=np.int) - 1 + # setup Zobrist hash to keep track of board state + self.enforce_superko = enforce_superko + rng = np.random.RandomState(0) + self.hash_lookup = { + WHITE: rng.randint(np.iinfo(np.uint64).max, size=(size, size), dtype='uint64'), + BLACK: rng.randint(np.iinfo(np.uint64).max, size=(size, size), dtype='uint64')} + self.current_hash = np.uint64(0) + self.previous_hashes = set() + def get_group(self, position): """Get the group of connected same-color stones to the given position Keyword arguments: @@ -144,11 +153,16 @@ def _update_neighbors(self, position): self.liberty_sets[gx][gy] = merged_libs self.liberty_counts[gx][gy] = count_merged_libs + def _update_hash(self, action, color): + (x, y) = action + self.current_hash = np.bitwise_xor(self.current_hash, self.hash_lookup[color][x][y]) + def _remove_group(self, group): """A private helper function to take a group off the board (due to capture), updating group sets and liberties along the way """ for (x, y) in group: + self._update_hash((x, y), self.board[x, y]) self.board[x, y] = EMPTY for (x, y) in group: # clear group_sets for all positions in 'group' @@ -177,6 +191,9 @@ def copy(self): other.history = list(self.history) other.num_black_prisoners = self.num_black_prisoners other.num_white_prisoners = self.num_white_prisoners + other.enforce_superko = self.enforce_superko + other.current_hash = self.current_hash.copy() + other.previous_hashes = self.previous_hashes.copy() # update liberty and group sets. Note: calling set(a) on another set # copies the entries (any iterable as an argument would work so @@ -211,18 +228,48 @@ def is_suicide(self, action): return True return False + def is_positional_superko(self, action): + """Find all actions that the current_player has done in the past, taking into account the fact that + history starts with BLACK when there are no handicaps or with WHITE when there are. + """ + if len(self.handicaps) == 0 and self.current_player == BLACK: + player_history = self.history[0::2] + elif len(self.handicaps) > 0 and self.current_player == WHITE: + player_history = self.history[0::2] + else: + player_history = self.history[1::2] + + if action not in self.handicaps and action not in player_history: + return False + + state_copy = self.copy() + state_copy.enforce_superko = False + state_copy.do_move(action) + + if state_copy.current_hash in self.previous_hashes: + return True + else: + return False + def is_legal(self, action): """determine if the given action (x,y tuple) is a legal move note: we only check ko, not superko at this point (TODO?) """ - # passing move + # passing is always legal if action is PASS_MOVE: return True (x, y) = action - empty = self.board[x][y] == EMPTY - suicide = self.is_suicide(action) - ko = action == self.ko - return self._on_board(action) and (not suicide) and (not ko) and empty + if not self._on_board(action): + return False + if self.board[x][y] != EMPTY: + return False + if self.is_suicide(action): + return False + if action == self.ko: + return False + if self.enforce_superko and self.is_positional_superko(action): + return False + return True def is_eyeish(self, position, owner): """returns whether the position is empty and is surrounded by all stones of 'owner' @@ -233,7 +280,7 @@ def is_eyeish(self, position, owner): for (nx, ny) in self._neighbors(position): if self.board[nx, ny] != owner: - return False + return False return True def is_eye(self, position, owner, stack=[]): @@ -338,6 +385,7 @@ def do_move(self, action, color=None): if action is not PASS_MOVE: (x, y) = action self.board[x][y] = color + self._update_hash(action, color) self._update_neighbors(action) self.stone_ages[x][y] = 0 @@ -362,6 +410,8 @@ def do_move(self, action, color=None): if would_recapture and recapture_size_is_1: # note: (nx,ny) is the stone that was captured self.ko = (nx, ny) + # _remove_group has finished updating the hash + self.previous_hashes.add(self.current_hash) else: if color == BLACK: self.passes_black += 1 diff --git a/interface/gtp_wrapper.py b/interface/gtp_wrapper.py index ebce1b911..437aa4ff8 100644 --- a/interface/gtp_wrapper.py +++ b/interface/gtp_wrapper.py @@ -84,11 +84,11 @@ class GTPGameConnector(object): """ def __init__(self, player): - self._state = go.GameState() + self._state = go.GameState(enforce_superko=True) self._player = player def clear(self): - self._state = go.GameState(self._state.size) + self._state = go.GameState(self._state.size, enforce_superko=True) def make_move(self, color, vertex): # vertex in GTP language is 1-indexed, whereas GameState's are zero-indexed @@ -103,7 +103,7 @@ def make_move(self, color, vertex): return False def set_size(self, n): - self._state = go.GameState(n) + self._state = go.GameState(n, enforce_superko=True) def set_komi(self, k): self._state.komi = k diff --git a/tests/test_gamestate.py b/tests/test_gamestate.py index 348911f1e..9f3e9a7f9 100644 --- a/tests/test_gamestate.py +++ b/tests/test_gamestate.py @@ -57,6 +57,19 @@ def test_snapback_is_not_ko(self): self.assertEqual(gs.num_black_prisoners, 2) self.assertEqual(gs.num_white_prisoners, 1) + def test_positional_superko(self): + move_list = [(0, 3), (0, 4), (1, 3), (1, 4), (2, 3), (2, 4), (2, 2), (3, 4), (2, 1), (3, 3), (3, 1), (3, 2), (3, 0), (4, 2), (1, 1), (4, 1), (8, 0), (4, 0), (8, 1), (0, 2), (8, 2), (0, 1), (8, 3), (1, 0), (8, 4), (2, 0), (0, 0)] + + gs = GameState(size=9) + for move in move_list: + gs.do_move(move) + self.assertTrue(gs.is_legal((1, 0))) + + gs = GameState(size=9, enforce_superko=True) + for move in move_list: + gs.do_move(move) + self.assertFalse(gs.is_legal((1, 0))) + class TestEye(unittest.TestCase): From c3644fbdb5af202409d037ffbe797d5d9af8f219 Mon Sep 17 00:00:00 2001 From: wrongu Date: Fri, 16 Sep 2016 15:10:28 -0400 Subject: [PATCH 106/191] Massive refactor of reinforcement_policy_trainer, using custom keras optimizer --- AlphaGo/ai.py | 21 +- .../training/reinforcement_policy_trainer.py | 322 ++++++++++-------- tests/test_reinforcement_policy_trainer.py | 121 ++++++- 3 files changed, 317 insertions(+), 147 deletions(-) diff --git a/AlphaGo/ai.py b/AlphaGo/ai.py index f8c695732..593e856da 100644 --- a/AlphaGo/ai.py +++ b/AlphaGo/ai.py @@ -9,15 +9,18 @@ class GreedyPolicyPlayer(object): move each turn) """ - def __init__(self, policy_function, pass_when_offered=False): + def __init__(self, policy_function, pass_when_offered=False, move_limit=None): self.policy = policy_function self.pass_when_offered = pass_when_offered + self.move_limit = move_limit def get_move(self, state): + if self.move_limit is not None and len(state.history) > self.move_limit: + return go.PASS_MOVE if self.pass_when_offered: if len(state.history) > 100 and state.history[-1] == go.PASS_MOVE: return go.PASS_MOVE - sensible_moves = [move for move in state.get_legal_moves() if not state.is_eye(move, state.current_player)] + sensible_moves = [move for move in state.get_legal_moves(include_eyes=False)] if len(sensible_moves) > 0: move_probs = self.policy.eval_state(state, sensible_moves) max_prob = max(move_probs, key=lambda (a, p): p) @@ -34,17 +37,21 @@ class ProbabilisticPolicyPlayer(object): (high temperature) or towards greedy play (low temperature) """ - def __init__(self, policy_function, temperature=1.0, pass_when_offered=False): + def __init__(self, policy_function, temperature=1.0, pass_when_offered=False, move_limit=None): assert(temperature > 0.0) self.policy = policy_function + self.move_limit = move_limit self.beta = 1.0 / temperature self.pass_when_offered = pass_when_offered + self.move_limit = move_limit def get_move(self, state): + if self.move_limit is not None and len(state.history) > self.move_limit: + return go.PASS_MOVE if self.pass_when_offered: if len(state.history) > 100 and state.history[-1] == go.PASS_MOVE: return go.PASS_MOVE - sensible_moves = [move for move in state.get_legal_moves() if not state.is_eye(move, state.current_player)] + sensible_moves = [move for move in state.get_legal_moves(include_eyes=False)] if len(sensible_moves) > 0: move_probs = self.policy.eval_state(state, sensible_moves) # zip(*list) is like the 'transpose' of zip; zip(*zip([1,2,3], [4,5,6])) is [(1,2,3), (4,5,6)] @@ -60,11 +67,11 @@ def get_move(self, state): def get_moves(self, states): """Batch version of get_move. A list of moves is returned (one per state) """ - sensible_move_lists = [[move for move in st.get_legal_moves() if not st.is_eye(move, st.current_player)] for st in states] + sensible_move_lists = [[move for move in st.get_legal_moves(include_eyes=False)] for st in states] all_moves_distributions = self.policy.batch_eval_state(states, sensible_move_lists) move_list = [None] * len(states) for i, move_probs in enumerate(all_moves_distributions): - if len(move_probs) == 0: + if len(move_probs) == 0 or len(states[i].history) > self.move_limit: move_list[i] = go.PASS_MOVE else: # this 'else' clause is identical to ProbabilisticPolicyPlayer.get_move @@ -83,7 +90,7 @@ def __init__(self, value_function, policy_function, rollout_function, lmbda=.5, rollout_limit, playout_depth, n_playout) def get_move(self, state): - sensible_moves = [move for move in state.get_legal_moves() if not state.is_eye(move, state.current_player)] + sensible_moves = [move for move in state.get_legal_moves(include_eyes=False)] if len(sensible_moves) > 0: move = self.mcts.get_move(state) self.mcts.update_with_move(move) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 0e208b446..da5766b89 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -1,142 +1,193 @@ import os -import argparse import json import numpy as np -import itertools from shutil import copyfile -from keras.optimizers import SGD +from keras.optimizers import Optimizer +import keras.backend as K from AlphaGo.ai import ProbabilisticPolicyPlayer import AlphaGo.go as go from AlphaGo.go import GameState from AlphaGo.models.policy import CNNPolicy -from AlphaGo.preprocessing.preprocessing import Preprocess from AlphaGo.util import flatten_idx -def make_training_pairs(player, opp, features, mini_batch_size, board_size=19): - """Make training pairs for batch of matches, utilizing player.get_moves (parallel form of - player.get_move), which calls `CNNPolicy.batch_eval_state`. - - Args: - player -- player that we're always updating - opp -- batch opponent - feature_list -- game features to be one-hot encoded - mini_batch_size -- number of games in mini-batch - - Return: - X_list -- list of 1-hot board states associated with moves. - y_list -- list of 1-hot moves associated with board states. - winners -- list of winners associated with each game in batch - """ - - def record_training_pair(st, mv, X, y): - # Convert move to one-hot - bsize_flat = bsize * bsize - state_1hot = preprocessor.state_to_tensor(st) - move_1hot = np.zeros(bsize_flat) - move_1hot[flatten_idx(mv, bsize)] = 1 - X.append(state_1hot) - y.append(move_1hot) - - # First we want to prep the states so that half of the boards get a move. - # The other half we want to leave alone so that the second player makes the first move (being black). - # Decided to alternate every board because this is how humans would play a match. - def play_half_of_boards(states, moves, X_list, y_list, player_color): - for st, mv, X, y, should_move in zip(states, moves, X_list, y_list, itertools.cycle([True, False])): - if should_move: - if st.current_player == player_color: - record_training_pair(st, mv, X, y) - st.do_move(mv) - return states, X_list, y_list - - def do_move(states, moves, X_list, y_list, player_color): - for st, mv, X, y in zip(states, moves, X_list, y_list): - # Only do more moves if not end of game already - if not st.is_end_of_game: - # Only want to record moves by the 'player', not the opponent - if st.current_player == player_color and mv is not go.PASS_MOVE: - record_training_pair(st, mv, X, y) - st.do_move(mv) - return states, X_list, y_list - - # Lists of game training pairs (1-hot) - X_list = [list() for _ in xrange(mini_batch_size)] - y_list = [list() for _ in xrange(mini_batch_size)] - preprocessor = Preprocess(features) - bsize = player.policy.model.input_shape[-1] - states = [GameState(size=board_size) for i in xrange(mini_batch_size)] - # Randomly choose who goes first (i.e. color of 'player') - player_color = np.random.choice([go.BLACK, go.WHITE]) - player1, player2 = (player, opp) if player_color == go.BLACK else (opp, player) - # We let player1 move first for half of the boards in the minibatch - # The other half of the boards will be empty... waiting for a 'black' player - moves_player1 = player1.get_moves(states) - states, X_list, y_list = play_half_of_boards(states, moves_player1, X_list, y_list, player_color) - # Now player2 can move and will act as white for half and black for half - moves_player2 = player2.get_moves(states) - states, X_list, y_list = do_move(states, moves_player2, X_list, y_list, player_color) - # Now the game can continue.. each player acting as white and black split across the boards - while True: - # Get moves (batch) for player1 - moves_player1 = player1.get_moves(states) - states, X_list, y_list = do_move(states, moves_player1, X_list, y_list, player_color) - # Get moves for player2 - moves_player2 = player2.get_moves(states) - states, X_list, y_list = do_move(states, moves_player2, X_list, y_list, player_color) - # If all games have ended, we're done. Get winners. - done = [st.is_end_of_game for st in states] - if all(done): - break - won_game_list = [] - # If player was black, every even board is black, odd board white - if player_color == go.BLACK: - for st, game_color in zip(states, itertools.cycle([go.BLACK, go.WHITE])): - won_game_list.append(st.get_winner() == game_color) - else: - for st, game_color in zip(states, itertools.cycle([go.WHITE, go.BLACK])): - won_game_list.append(st.get_winner() == game_color) - # Concatenate tensors across turns within each game - for i in xrange(mini_batch_size): - X_list[i] = np.concatenate(X_list[i], axis=0) - y_list[i] = np.vstack(y_list[i]) - return X_list, y_list, won_game_list - - -def train_batch(player, X_list, y_list, won_game_list, lr): - """Given the outcomes of a mini-batch of play against a fixed opponent, - update the weights with reinforcement learning. - - Args: - player -- player object with policy weights to be updated - X_list -- List of one-hot encoded states. - y_list -- List of one-hot encoded actions (to pair with X_list). - winners -- List of winners corresponding to each item in - training_pairs_list - lr -- Keras learning rate - - Return: - player -- same player, with updated weights. - """ - - for X, y, won_game in zip(X_list, y_list, won_game_list): - # Update weights in + direction if player won, and - direction if player lost. - # Setting learning rate negative is hack for negative weights update. - if won_game: - player.policy.model.optimizer.lr.set_value(lr) - else: - player.policy.model.optimizer.lr.set_value(-lr) - player.policy.model.fit(X, y, nb_epoch=1, batch_size=len(X)) +class BatchedReinforcementLearningSGD(Optimizer): + '''A Keras Optimizer that sums gradients together for each game, applying them only once the + winner is known. + + It is the responsibility of the calling code to call set_current_game() before each example to + tell the optimizer for which game gradients should be accumulated, and to call set_result() to + tell the optimizer what the sign of the gradient for each game should be and when all games are + over. + + Arguments + lr: float >= 0. Learning rate. + ng: int > 0. Number of games played in parallel. Each one has its own cumulative gradient. + ''' + def __init__(self, lr=0.01, ng=20, **kwargs): + super(BatchedReinforcementLearningSGD, self).__init__(**kwargs) + self.__dict__.update(locals()) + self.lr = K.variable(lr) + self.cumulative_gradients = [] + self.num_games = ng + self.game_idx = K.variable(0) # which gradient to accumulate in the next batch. + self.gradient_sign = [K.variable(0) for _ in range(ng)] + self.running_games = K.variable(self.num_games) + + def set_current_game(self, game_idx): + K.set_value(self.game_idx, game_idx) + + def set_result(self, game_idx, won_game): + '''Mark the outcome of the game at index game_idx. Once all games are complete, updates + are automatically triggered in the next call to a keras fit function. + ''' + K.set_value(self.gradient_sign[game_idx], +1 if won_game else -1) + # Note: using '-= 1' would create a new variable, which would invalidate the dependencies + # in get_updates(). + K.set_value(self.running_games, K.get_value(self.running_games) - 1) + + def get_updates(self, params, constraints, loss): + # Note: get_updates is called *once* by keras. Its job is to return a set of 'update + # operations' to any K.variable (e.g. model weights or self.num_games). Updates are applied + # whenever Keras' train_function is evaluated, i.e. in every batch. Model.fit_on_batch() + # will trigger exactly one update. All updates use the 'old' value of parameters - there is + # no dependency on the order of the list of updates. + self.updates = [] + # Get expressions for gradients of model parameters. + grads = self.get_gradients(loss, params) + # Create a set of accumulated gradients, one for each game. + shapes = [K.get_variable_shape(p) for p in params] + self.cumulative_gradients = [[K.zeros(shape) for shape in shapes] for _ in range(self.num_games)] + + def conditional_update(cond, variable, new_value): + '''Helper function to create updates that only happen when cond is True. Writes to + self.updates and returns the new variable. + + Note: K.update(x, x) is cheap, but K.update_add(x, K.zeros_like(x)) can be expensive. + ''' + maybe_new_value = K.switch(cond, new_value, variable) + self.updates.append(K.update(variable, maybe_new_value)) + return maybe_new_value + + # Update cumulative gradient at index game_idx. This is done by returning an update for all + # gradients that is a no-op everywhere except for the game_idx'th one. When game_idx is + # changed by a call to set_current_game(), it will change the gradient that is getting + # accumulated. + # new_cumulative_gradients keeps references to the updated variables for use below in + # updating parameters with the freshly-accumulated gradients. + new_cumulative_gradients = [[None] * len(cgs) for cgs in self.cumulative_gradients] + for i, cgs in enumerate(self.cumulative_gradients): + for j, (g, cg) in enumerate(zip(grads, cgs)): + new_gradient = conditional_update(K.equal(self.game_idx, i), cg, cg + g) + new_cumulative_gradients[i][j] = new_gradient + + # Compute the net update to parameters, taking into account the sign of each cumulative + # gradient. + net_grads = [K.zeros_like(g) for g in grads] + for i, cgs in enumerate(new_cumulative_gradients): + for j, cg in enumerate(cgs): + net_grads[j] += self.gradient_sign[i] * cg + + # Trigger a full update when all games have finished. + self.trigger_update = K.lesser_equal(self.running_games, 0) + + # Update model parameters conditional on trigger_update. + for p, g in zip(params, net_grads): + new_p = p + g * self.lr + if p in constraints: + c = constraints[p] + new_p = c(new_p) + conditional_update(self.trigger_update, p, new_p) + + # 'reset' game counter and gradient signs when parameters are updated. + for sign in self.gradient_sign: + conditional_update(self.trigger_update, sign, K.variable(0)) + conditional_update(self.trigger_update, self.running_games, K.variable(self.num_games)) + return self.updates + + def get_config(self): + config = { + 'lr': float(K.get_value(self.lr)), + 'ng': self.num_games} + base_config = super(BatchedReinforcementLearningSGD, self).get_config() + return dict(list(base_config.items()) + list(config.items())) + + +def _make_training_pair(st, mv, preprocessor): + # Convert move to one-hot + st_tensor = preprocessor.state_to_tensor(st) + mv_tensor = np.zeros((1, st.size * st.size)) + mv_tensor[(0, flatten_idx(mv, st.size))] = 1 + return (st_tensor, mv_tensor) + + +def run_n_games(optimizer, learner, opponent, num_games): + '''Run num_games games to completion, calling train_batch() on each position the learner sees. + + (Note: optimizer only accumulates gradients in its update function until all games have finished) + ''' + board_size = learner.policy.model.input_shape[-1] + states = [GameState(size=board_size) for _ in range(num_games)] + learner_net = learner.policy.model + + # Start all odd games with moves by 'opponent'. Even games will have 'learner' black. + learner_color = [go.BLACK if i % 2 == 0 else go.WHITE for i in range(num_games)] + odd_states = states[1::2] + moves = opponent.get_moves(odd_states) + for st, mv in zip(odd_states, moves): + st.do_move(mv) + + current = learner + other = opponent + # Need to keep track of the index of unfinished states so that we can communicate which one is + # being updated to the optimizer. + idxs_to_unfinished_states = {i: states[i] for i in range(num_games)} + while len(idxs_to_unfinished_states) > 0: + # Get next moves by current player for all unfinished states. + moves = current.get_moves(idxs_to_unfinished_states.values()) + just_finished = [] + # Do each move to each state in order. + for (idx, state), mv in zip(idxs_to_unfinished_states.iteritems(), moves): + # Order is important here. We must first get the training pair on the unmodified state. + # Next, the state is updated and checked to see if the game is over. If it is over, the + # optimizer is notified via set_result. Finally, train_on_batch is called, which + # will trigger an update of all parameters only if set_result() has been called + # for all games already (so set_result must come before train_on_batch). + is_learnable = current is learner and mv is not go.PASS_MOVE + if is_learnable: + (X, y) = _make_training_pair(state, mv, learner.policy.preprocessor) + state.do_move(mv) + if state.is_end_of_game: + learner_is_winner = state.get_winner() == learner_color[idx] + optimizer.set_result(idx, learner_is_winner) + just_finished.append(idx) + if is_learnable: + optimizer.set_current_game(idx) + learner_net.train_on_batch(X, y) + + # Remove games that have finished from dict. + for idx in just_finished: + del idxs_to_unfinished_states[idx] + + # Swap 'current' and 'other' for next turn. + current, other = other, current + + # Return the win ratio. + wins = sum(state.get_winner == pc for (state, pc) in zip(states, learner_color)) + return float(wins) / num_games def run_training(cmd_line_args=None): + import argparse parser = argparse.ArgumentParser(description='Perform reinforcement learning to improve given policy network. Second phase of pipeline.') parser.add_argument("model_json", help="Path to policy model JSON.") parser.add_argument("initial_weights", help="Path to HDF5 file with inital weights (i.e. result of supervised training).") parser.add_argument("out_directory", help="Path to folder where the model params and metadata will be saved after each epoch.") - parser.add_argument("--learning-rate", help="Keras learning rate (Default: .03)", type=float, default=.03) + parser.add_argument("--learning-rate", help="Keras learning rate (Default: 0.001)", type=float, default=0.001) parser.add_argument("--policy-temp", help="Distribution temperature of players using policies (Default: 0.67)", type=float, default=0.67) parser.add_argument("--save-every", help="Save policy as a new opponent every n batches (Default: 500)", type=int, default=500) parser.add_argument("--game-batch", help="Number of games per mini-batch (Default: 20)", type=int, default=20) + parser.add_argument("--move-limit", help="Maximum number of moves per game", type=int, default=500) parser.add_argument("--iterations", help="Number of training batches/iterations (Default: 10000)", type=int, default=10000) parser.add_argument("--resume", help="Load latest weights in out_directory and resume", default=False, action="store_true") parser.add_argument("--verbose", "-v", help="Turn on verbose mode", default=False, action="store_true") @@ -176,13 +227,12 @@ def run_training(cmd_line_args=None): # Set initial conditions policy = CNNPolicy.load_model(args.model_json) policy.model.load_weights(args.initial_weights) - player = ProbabilisticPolicyPlayer(policy, temperature=args.policy_temp) - features = policy.preprocessor.feature_list + player = ProbabilisticPolicyPlayer(policy, temperature=args.policy_temp, move_limit=args.move_limit) - # different opponents come from simply changing the weights of - # opponent.policy.model "behind the scenes" + # different opponents come from simply changing the weights of 'opponent.policy.model'. That + # is, only 'opp_policy' needs to be changed, and 'opponent' will change. opp_policy = CNNPolicy.load_model(args.model_json) - opponent = ProbabilisticPolicyPlayer(opp_policy, temperature=args.policy_temp) + opponent = ProbabilisticPolicyPlayer(opp_policy, temperature=args.policy_temp, move_limit=args.move_limit) if args.verbose: print "created player and opponent with temperature {}".format(args.policy_temp) @@ -208,28 +258,28 @@ def save_metadata(): with open(os.path.join(args.out_directory, "metadata.json"), "w") as f: json.dump(metadata, f, sort_keys=True, indent=2) - # Set SGD and compile - sgd = SGD(lr=args.learning_rate) - player.policy.model.compile(loss='binary_crossentropy', optimizer=sgd) - board_size = player.policy.model.input_shape[-1] + optimizer = BatchedReinforcementLearningSGD(lr=args.learning_rate, ng=args.game_batch) + player.policy.model.compile(loss='categorical_crossentropy', optimizer=optimizer) for i_iter in xrange(1, args.iterations + 1): - # Train mini-batches by randomly choosing opponent from pool (possibly self) - # and playing game_batch games against them + # Randomly choose opponent from pool (possibly self), and playing game_batch games against + # them. opp_weights = np.random.choice(metadata["opponents"]) opp_path = os.path.join(args.out_directory, opp_weights) - # load new weights into opponent, but otherwise its the same + + # Load new weights into opponent's network, but keep the same opponent object. opponent.policy.model.load_weights(opp_path) if args.verbose: print "Batch {}\tsampled opponent is {}".format(i_iter, opp_weights) - # Make training pairs and do RL - X_list, y_list, won_game_list = make_training_pairs(player, opponent, features, args.game_batch, board_size) - win_ratio = np.sum(won_game_list) / float(args.game_batch) + + # Run games (and learn from results). Keep track of the win ratio vs each opponent over time. + win_ratio = run_n_games(optimizer, player, opponent, args.game_batch) metadata["win_ratio"][player_weights] = (opp_weights, win_ratio) - train_batch(player, X_list, y_list, won_game_list, args.learning_rate) - # Save intermediate models + + # Save all intermediate models. player_weights = "weights.%05d.hdf5" % i_iter player.policy.model.save_weights(os.path.join(args.out_directory, player_weights)) - # add player to batch of oppenents once in a while + + # Add player to batch of oppenents once in a while. if i_iter % args.save_every == 0: metadata["opponents"].append(player_weights) save_metadata() diff --git a/tests/test_reinforcement_policy_trainer.py b/tests/test_reinforcement_policy_trainer.py index ecda3a5fe..702d25332 100644 --- a/tests/test_reinforcement_policy_trainer.py +++ b/tests/test_reinforcement_policy_trainer.py @@ -1,13 +1,19 @@ import os -from AlphaGo.training.reinforcement_policy_trainer import run_training +from AlphaGo.training.reinforcement_policy_trainer import run_training, _make_training_pair, BatchedReinforcementLearningSGD import unittest +import numpy as np +import numpy.testing as npt +import keras.backend as K +from AlphaGo.models.policy import CNNPolicy +from AlphaGo.go import GameState class TestReinforcementPolicyTrainer(unittest.TestCase): + def testTrain(self): - model = 'tests/test_data/minimodel.json' - init_weights = 'tests/test_data/hdf5/random_minimodel_weights.hdf5' - output = 'tests/test_data/.tmp.rl.training/' + model = os.path.join('tests', 'test_data', 'minimodel.json') + init_weights = os.path.join('tests', 'test_data', 'hdf5', 'random_minimodel_weights.hdf5') + output = os.path.join('tests', 'test_data', '.tmp.rl.training/') args = [model, init_weights, output, '--game-batch', '1', '--iterations', '1'] run_training(args) @@ -16,5 +22,112 @@ def testTrain(self): os.remove(os.path.join(output, 'weights.00001.hdf5')) os.rmdir(output) + +class TestOptimizer(unittest.TestCase): + + def testApplyAndResetOnGamesFinished(self): + policy = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + state = GameState(size=19) + optimizer = BatchedReinforcementLearningSGD(lr=0.01, ng=2) + policy.model.compile(loss='categorical_crossentropy', optimizer=optimizer) + + # Helper to check initial conditions of the optimizer. + def assertOptimizerInitialConditions(): + for v in optimizer.gradient_sign: + self.assertEqual(K.eval(v), 0) + self.assertEqual(K.eval(optimizer.running_games), 2) + + initial_parameters = policy.model.get_weights() + + def assertModelEffect(changed): + any_change = False + for cur, init in zip(policy.model.get_weights(), initial_parameters): + if not np.allclose(init, cur): + any_change = True + break + self.assertEqual(any_change, changed) + + assertOptimizerInitialConditions() + + # Make moves on the state and get trainable (state, action) pairs from them. + state_tensors = [] + action_tensors = [] + moves = [(2, 2), (16, 16), (3, 17), (16, 2), (4, 10), (10, 3)] + for m in moves: + (st_tensor, mv_tensor) = _make_training_pair(state, m, policy.preprocessor) + state_tensors.append(st_tensor) + action_tensors.append(mv_tensor) + state.do_move(m) + + for i, (s, a) in enumerate(zip(state_tensors, action_tensors)): + # Even moves in game 0, odd moves in game 1 + game_idx = i % 2 + optimizer.set_current_game(game_idx) + is_last_move = i + 2 >= len(moves) + if is_last_move: + # Mark game 0 as a win and game 1 as a loss. + optimizer.set_result(game_idx, game_idx == 0) + else: + # Games not finished yet; assert no change to optimizer state. + assertOptimizerInitialConditions() + # train_on_batch accumulates gradients, and should only cause a change to parameters + # on the first call after the final set_result() call + policy.model.train_on_batch(s, a) + if i + 1 < len(moves): + assertModelEffect(changed=False) + else: + assertModelEffect(changed=True) + # Once both games finished, the last call to train_on_batch() should have triggered a reset + # to the optimizer parameters back to initial conditions. + assertOptimizerInitialConditions() + + def testGradientDirectionChangesWithGameResult(self): + + def run_and_get_new_weights(init_weights, win0, win1): + state = GameState(size=19) + policy = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + policy.model.set_weights(init_weights) + optimizer = BatchedReinforcementLearningSGD(lr=0.01, ng=2) + policy.model.compile(loss='categorical_crossentropy', optimizer=optimizer) + + # Make moves on the state and get trainable (state, action) pairs from them. + moves = [(2, 2), (16, 16), (3, 17), (16, 2), (4, 10), (10, 3)] + state_tensors = [] + action_tensors = [] + for m in moves: + (st_tensor, mv_tensor) = _make_training_pair(state, m, policy.preprocessor) + state_tensors.append(st_tensor) + action_tensors.append(mv_tensor) + state.do_move(m) + + for i, (s, a) in enumerate(zip(state_tensors, action_tensors)): + # Put even state/action pairs in game 0, odd ones in game 1. + game_idx = i % 2 + optimizer.set_current_game(game_idx) + is_last_move = i + 2 >= len(moves) + if is_last_move: + if game_idx == 0: + optimizer.set_result(game_idx, win0) + else: + optimizer.set_result(game_idx, win1) + # train_on_batch accumulates gradients, and should only cause a change to parameters + # on the first call after the final set_result() call + policy.model.train_on_batch(s, a) + return policy.model.get_weights() + + policy = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + initial_parameters = policy.model.get_weights() + # Cases 1 and 2 have identical starting models and identical (state, action) pairs, + # but they differ in who won the games. + parameters1 = run_and_get_new_weights(initial_parameters, True, False) + parameters2 = run_and_get_new_weights(initial_parameters, False, True) + + # Changes in case 1 should be equal and opposite to changes in case 2. Allowing 0.1% + # difference in precision. + for (i, p1, p2) in zip(initial_parameters, parameters1, parameters2): + diff1 = p1 - i + diff2 = p2 - i + npt.assert_allclose(diff1, -diff2, rtol=1e-3) + if __name__ == '__main__': unittest.main() From 8cdd4aa1074f0dec9edecfdb578462f243f9ccf9 Mon Sep 17 00:00:00 2001 From: wrongu Date: Wed, 21 Sep 2016 19:55:19 -0400 Subject: [PATCH 107/191] typo in reinforcement_policy_trainer; fixes #161 --- AlphaGo/training/reinforcement_policy_trainer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index da5766b89..b4ab67771 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -173,7 +173,7 @@ def run_n_games(optimizer, learner, opponent, num_games): current, other = other, current # Return the win ratio. - wins = sum(state.get_winner == pc for (state, pc) in zip(states, learner_color)) + wins = sum(state.get_winner() == pc for (state, pc) in zip(states, learner_color)) return float(wins) / num_games From 8cd09fe3a8be2dedd95686c786b27961b73f59e3 Mon Sep 17 00:00:00 2001 From: "Thouis (Ray) Jones" Date: Thu, 22 Sep 2016 09:29:04 -0400 Subject: [PATCH 108/191] global replace of tab with 4-spaces --- AlphaGo/ai.py | 158 ++-- AlphaGo/go.py | 848 +++++++++--------- AlphaGo/mcts.py | 402 ++++----- AlphaGo/models/nn_util.py | 224 ++--- AlphaGo/models/policy.py | 488 +++++----- AlphaGo/preprocessing/game_converter.py | 392 ++++---- AlphaGo/preprocessing/preprocessing.py | 452 +++++----- .../training/reinforcement_policy_trainer.py | 526 +++++------ AlphaGo/training/supervised_policy_trainer.py | 388 ++++---- AlphaGo/util.py | 190 ++-- benchmarks/preprocessing_benchmark.py | 4 +- ...reinforcement_policy_training_benchmark.py | 4 +- .../supervised_policy_training_benchmark.py | 2 +- interface/Play.py | 54 +- interface/gtp_wrapper.py | 270 +++--- tests/test_game_converter.py | 24 +- tests/test_gamestate.py | 298 +++--- tests/test_gtp_wrapper.py | 30 +- tests/test_liberties.py | 70 +- tests/test_mcts.py | 208 ++--- tests/test_policy.py | 222 ++--- tests/test_preprocessing.py | 636 ++++++------- tests/test_reinforcement_policy_trainer.py | 228 ++--- tests/test_supervised_policy_trainer.py | 22 +- 24 files changed, 3070 insertions(+), 3070 deletions(-) diff --git a/AlphaGo/ai.py b/AlphaGo/ai.py index 593e856da..437759960 100644 --- a/AlphaGo/ai.py +++ b/AlphaGo/ai.py @@ -5,95 +5,95 @@ class GreedyPolicyPlayer(object): - """A player that uses a greedy policy (i.e. chooses the highest probability - move each turn) - """ + """A player that uses a greedy policy (i.e. chooses the highest probability + move each turn) + """ - def __init__(self, policy_function, pass_when_offered=False, move_limit=None): - self.policy = policy_function - self.pass_when_offered = pass_when_offered - self.move_limit = move_limit + def __init__(self, policy_function, pass_when_offered=False, move_limit=None): + self.policy = policy_function + self.pass_when_offered = pass_when_offered + self.move_limit = move_limit - def get_move(self, state): - if self.move_limit is not None and len(state.history) > self.move_limit: - return go.PASS_MOVE - if self.pass_when_offered: - if len(state.history) > 100 and state.history[-1] == go.PASS_MOVE: - return go.PASS_MOVE - sensible_moves = [move for move in state.get_legal_moves(include_eyes=False)] - if len(sensible_moves) > 0: - move_probs = self.policy.eval_state(state, sensible_moves) - max_prob = max(move_probs, key=lambda (a, p): p) - return max_prob[0] - # No 'sensible' moves available, so do pass move - return go.PASS_MOVE + def get_move(self, state): + if self.move_limit is not None and len(state.history) > self.move_limit: + return go.PASS_MOVE + if self.pass_when_offered: + if len(state.history) > 100 and state.history[-1] == go.PASS_MOVE: + return go.PASS_MOVE + sensible_moves = [move for move in state.get_legal_moves(include_eyes=False)] + if len(sensible_moves) > 0: + move_probs = self.policy.eval_state(state, sensible_moves) + max_prob = max(move_probs, key=lambda (a, p): p) + return max_prob[0] + # No 'sensible' moves available, so do pass move + return go.PASS_MOVE class ProbabilisticPolicyPlayer(object): - """A player that samples a move in proportion to the probability given by the - policy. + """A player that samples a move in proportion to the probability given by the + policy. - By manipulating the 'temperature', moves can be pushed towards totally random - (high temperature) or towards greedy play (low temperature) - """ + By manipulating the 'temperature', moves can be pushed towards totally random + (high temperature) or towards greedy play (low temperature) + """ - def __init__(self, policy_function, temperature=1.0, pass_when_offered=False, move_limit=None): - assert(temperature > 0.0) - self.policy = policy_function - self.move_limit = move_limit - self.beta = 1.0 / temperature - self.pass_when_offered = pass_when_offered - self.move_limit = move_limit + def __init__(self, policy_function, temperature=1.0, pass_when_offered=False, move_limit=None): + assert(temperature > 0.0) + self.policy = policy_function + self.move_limit = move_limit + self.beta = 1.0 / temperature + self.pass_when_offered = pass_when_offered + self.move_limit = move_limit - def get_move(self, state): - if self.move_limit is not None and len(state.history) > self.move_limit: - return go.PASS_MOVE - if self.pass_when_offered: - if len(state.history) > 100 and state.history[-1] == go.PASS_MOVE: - return go.PASS_MOVE - sensible_moves = [move for move in state.get_legal_moves(include_eyes=False)] - if len(sensible_moves) > 0: - move_probs = self.policy.eval_state(state, sensible_moves) - # zip(*list) is like the 'transpose' of zip; zip(*zip([1,2,3], [4,5,6])) is [(1,2,3), (4,5,6)] - moves, probabilities = zip(*move_probs) - probabilities = np.array(probabilities) - probabilities = probabilities ** self.beta - probabilities = probabilities / probabilities.sum() - # numpy interprets a list of tuples as 2D, so we must choose an _index_ of moves then apply it in 2 steps - choice_idx = np.random.choice(len(moves), p=probabilities) - return moves[choice_idx] - return go.PASS_MOVE + def get_move(self, state): + if self.move_limit is not None and len(state.history) > self.move_limit: + return go.PASS_MOVE + if self.pass_when_offered: + if len(state.history) > 100 and state.history[-1] == go.PASS_MOVE: + return go.PASS_MOVE + sensible_moves = [move for move in state.get_legal_moves(include_eyes=False)] + if len(sensible_moves) > 0: + move_probs = self.policy.eval_state(state, sensible_moves) + # zip(*list) is like the 'transpose' of zip; zip(*zip([1,2,3], [4,5,6])) is [(1,2,3), (4,5,6)] + moves, probabilities = zip(*move_probs) + probabilities = np.array(probabilities) + probabilities = probabilities ** self.beta + probabilities = probabilities / probabilities.sum() + # numpy interprets a list of tuples as 2D, so we must choose an _index_ of moves then apply it in 2 steps + choice_idx = np.random.choice(len(moves), p=probabilities) + return moves[choice_idx] + return go.PASS_MOVE - def get_moves(self, states): - """Batch version of get_move. A list of moves is returned (one per state) - """ - sensible_move_lists = [[move for move in st.get_legal_moves(include_eyes=False)] for st in states] - all_moves_distributions = self.policy.batch_eval_state(states, sensible_move_lists) - move_list = [None] * len(states) - for i, move_probs in enumerate(all_moves_distributions): - if len(move_probs) == 0 or len(states[i].history) > self.move_limit: - move_list[i] = go.PASS_MOVE - else: - # this 'else' clause is identical to ProbabilisticPolicyPlayer.get_move - moves, probabilities = zip(*move_probs) - probabilities = np.array(probabilities) - probabilities = probabilities ** self.beta - probabilities = probabilities / probabilities.sum() - choice_idx = np.random.choice(len(moves), p=probabilities) - move_list[i] = moves[choice_idx] - return move_list + def get_moves(self, states): + """Batch version of get_move. A list of moves is returned (one per state) + """ + sensible_move_lists = [[move for move in st.get_legal_moves(include_eyes=False)] for st in states] + all_moves_distributions = self.policy.batch_eval_state(states, sensible_move_lists) + move_list = [None] * len(states) + for i, move_probs in enumerate(all_moves_distributions): + if len(move_probs) == 0 or len(states[i].history) > self.move_limit: + move_list[i] = go.PASS_MOVE + else: + # this 'else' clause is identical to ProbabilisticPolicyPlayer.get_move + moves, probabilities = zip(*move_probs) + probabilities = np.array(probabilities) + probabilities = probabilities ** self.beta + probabilities = probabilities / probabilities.sum() + choice_idx = np.random.choice(len(moves), p=probabilities) + move_list[i] = moves[choice_idx] + return move_list class MCTSPlayer(object): - def __init__(self, value_function, policy_function, rollout_function, lmbda=.5, c_puct=5, rollout_limit=500, playout_depth=40, n_playout=100): - self.mcts = mcts.MCTS(value_function, policy_function, rollout_function, lmbda, c_puct, - rollout_limit, playout_depth, n_playout) + def __init__(self, value_function, policy_function, rollout_function, lmbda=.5, c_puct=5, rollout_limit=500, playout_depth=40, n_playout=100): + self.mcts = mcts.MCTS(value_function, policy_function, rollout_function, lmbda, c_puct, + rollout_limit, playout_depth, n_playout) - def get_move(self, state): - sensible_moves = [move for move in state.get_legal_moves(include_eyes=False)] - if len(sensible_moves) > 0: - move = self.mcts.get_move(state) - self.mcts.update_with_move(move) - return move - # No 'sensible' moves available, so do pass move - return go.PASS_MOVE + def get_move(self, state): + sensible_moves = [move for move in state.get_legal_moves(include_eyes=False)] + if len(sensible_moves) > 0: + move = self.mcts.get_move(state) + self.mcts.update_with_move(move) + return move + # No 'sensible' moves available, so do pass move + return go.PASS_MOVE diff --git a/AlphaGo/go.py b/AlphaGo/go.py index 67b6c7269..48b696fda 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -7,430 +7,430 @@ class GameState(object): - """State of a game of Go and some basic functions to interact with it - """ - - # Looking up positions adjacent to a given position takes a surprising - # amount of time, hence this shared lookup table {boardsize: {position: [neighbors]}} - __NEIGHBORS_CACHE = {} - - def __init__(self, size=19, komi=7.5, enforce_superko=False): - self.board = np.zeros((size, size)) - self.board.fill(EMPTY) - self.size = size - self.current_player = BLACK - self.ko = None - self.komi = komi # Komi is number of extra points WHITE gets for going 2nd - self.handicaps = [] - self.history = [] - self.num_black_prisoners = 0 - self.num_white_prisoners = 0 - self.is_end_of_game = False - # Each pass move by a player subtracts a point - self.passes_white = 0 - self.passes_black = 0 - # `self.liberty_sets` is a 2D array with the same indexes as `board` - # each entry points to a set of tuples - the liberties of a stone's - # connected block. By caching liberties in this way, we can directly - # optimize update functions (e.g. do_move) and in doing so indirectly - # speed up any function that queries liberties - self._create_neighbors_cache() - self.liberty_sets = [[set() for _ in range(size)] for _ in range(size)] - for x in range(size): - for y in range(size): - self.liberty_sets[x][y] = set(self._neighbors((x, y))) - # separately cache the 2D numpy array of the _size_ of liberty sets - # at each board position - self.liberty_counts = np.zeros((size, size), dtype=np.int) - self.liberty_counts.fill(-1) - # initialize liberty_sets of empty board: the set of neighbors of each position - # similarly to `liberty_sets`, `group_sets[x][y]` points to a set of tuples - # containing all (x',y') pairs in the group connected to (x,y) - self.group_sets = [[set() for _ in range(size)] for _ in range(size)] - # cache of list of legal moves (actually 'sensible' moves, with a separate list for eye-moves on request) - self.__legal_move_cache = None - self.__legal_eyes_cache = None - # on-the-fly record of 'age' of each stone - self.stone_ages = np.zeros((size, size), dtype=np.int) - 1 - - # setup Zobrist hash to keep track of board state - self.enforce_superko = enforce_superko - rng = np.random.RandomState(0) - self.hash_lookup = { - WHITE: rng.randint(np.iinfo(np.uint64).max, size=(size, size), dtype='uint64'), - BLACK: rng.randint(np.iinfo(np.uint64).max, size=(size, size), dtype='uint64')} - self.current_hash = np.uint64(0) - self.previous_hashes = set() - - def get_group(self, position): - """Get the group of connected same-color stones to the given position - Keyword arguments: - position -- a tuple of (x, y) - x being the column index of the starting position of the search - y being the row index of the starting position of the search - Return: - a set of tuples consist of (x, y)s which are the same-color cluster - which contains the input single position. len(group) is size of the cluster, can be large. - """ - (x, y) = position - # given that this is already cached, it is a fast lookup - return self.group_sets[x][y] - - def get_groups_around(self, position): - """returns a list of the unique groups adjacent to position - 'unique' means that, for example in this position: - . . . . . - . B W . . - . W W . . - . . . . . - . . . . . - only the one white group would be returned on get_groups_around((1,1)) - """ - groups = [] - for (nx, ny) in self._neighbors(position): - group = self.group_sets[nx][ny] - if len(group) > 0 and group not in groups: - groups.append(self.group_sets[nx][ny]) - return groups - - def _on_board(self, position): - """simply return True iff position is within the bounds of [0, self.size) - """ - (x, y) = position - return x >= 0 and y >= 0 and x < self.size and y < self.size - - def _create_neighbors_cache(self): - if self.size not in GameState.__NEIGHBORS_CACHE: - GameState.__NEIGHBORS_CACHE[self.size] = {} - for x in xrange(self.size): - for y in xrange(self.size): - neighbors = [xy for xy in [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)] if self._on_board(xy)] - GameState.__NEIGHBORS_CACHE[self.size][(x, y)] = neighbors - - def _neighbors(self, position): - """A private helper function that simply returns a list of positions neighboring - the given (x,y) position. Basically it handles edges and corners. - """ - return GameState.__NEIGHBORS_CACHE[self.size][position] - - def _diagonals(self, position): - """Like _neighbors but for diagonal positions - """ - (x, y) = position - return filter(self._on_board, [(x - 1, y - 1), (x + 1, y + 1), (x + 1, y - 1), (x - 1, y + 1)]) - - def _update_neighbors(self, position): - """A private helper function to update self.group_sets and self.liberty_sets - given that a stone was just played at `position` - """ - (x, y) = position - - merged_group = set() - merged_group.add(position) - merged_libs = self.liberty_sets[x][y] - for (nx, ny) in self._neighbors(position): - # remove (x,y) from liberties of neighboring positions - self.liberty_sets[nx][ny] -= set([position]) - # if neighbor was opponent, update group's liberties count - # (current_player's groups will be updated below regardless) - if self.board[nx][ny] == -self.current_player: - new_liberty_count = len(self.liberty_sets[nx][ny]) - for (gx, gy) in self.group_sets[nx][ny]: - self.liberty_counts[gx][gy] = new_liberty_count - # MERGE group/liberty sets if neighbor is the same color - # note: this automatically takes care of merging two separate - # groups that just became connected through (x,y) - elif self.board[x][y] == self.board[nx][ny]: - merged_group |= self.group_sets[nx][ny] - merged_libs |= self.liberty_sets[nx][ny] - - # now that we have one big 'merged' set for groups and liberties, loop - # over every member of the same-color group to update them - # Note: neighboring opponent groups are already updated in the previous loop - count_merged_libs = len(merged_libs) - for (gx, gy) in merged_group: - self.group_sets[gx][gy] = merged_group - self.liberty_sets[gx][gy] = merged_libs - self.liberty_counts[gx][gy] = count_merged_libs - - def _update_hash(self, action, color): - (x, y) = action - self.current_hash = np.bitwise_xor(self.current_hash, self.hash_lookup[color][x][y]) - - def _remove_group(self, group): - """A private helper function to take a group off the board (due to capture), - updating group sets and liberties along the way - """ - for (x, y) in group: - self._update_hash((x, y), self.board[x, y]) - self.board[x, y] = EMPTY - for (x, y) in group: - # clear group_sets for all positions in 'group' - self.group_sets[x][y] = set() - self.liberty_sets[x][y] = set() - self.liberty_counts[x][y] = -1 - self.stone_ages[x][y] = -1 - for (nx, ny) in self._neighbors((x, y)): - if self.board[nx, ny] == EMPTY: - # add empty neighbors of (x,y) to its liberties - self.liberty_sets[x][y].add((nx, ny)) - else: - # add (x,y) to the liberties of its nonempty neighbors - self.liberty_sets[nx][ny].add((x, y)) - for (gx, gy) in self.group_sets[nx][ny]: - self.liberty_counts[gx][gy] = len(self.liberty_sets[nx][ny]) - - def copy(self): - """get a copy of this Game state - """ - other = GameState(self.size, self.komi) - other.board = self.board.copy() - other.current_player = self.current_player - other.ko = self.ko - other.handicaps = list(self.handicaps) - other.history = list(self.history) - other.num_black_prisoners = self.num_black_prisoners - other.num_white_prisoners = self.num_white_prisoners - other.enforce_superko = self.enforce_superko - other.current_hash = self.current_hash.copy() - other.previous_hashes = self.previous_hashes.copy() - - # update liberty and group sets. Note: calling set(a) on another set - # copies the entries (any iterable as an argument would work so - # set(list(a)) is unnecessary) - for x in range(self.size): - for y in range(self.size): - other.group_sets[x][y] = set(self.group_sets[x][y]) - other.liberty_sets[x][y] = set(self.liberty_sets[x][y]) - other.liberty_counts = self.liberty_counts.copy() - return other - - def is_suicide(self, action): - """return true if having current_player play at would be suicide - """ - (x, y) = action - num_liberties_here = len(self.liberty_sets[x][y]) - if num_liberties_here == 0: - # no liberties here 'immediately' - # but this may still connect to another group of the same color - for (nx, ny) in self._neighbors(action): - # check if we're saved by attaching to a friendly group that has - # liberties elsewhere - is_friendly_group = self.board[nx, ny] == self.current_player - group_has_other_liberties = len(self.liberty_sets[nx][ny] - set([action])) > 0 - if is_friendly_group and group_has_other_liberties: - return False - # check if we're killing an unfriendly group - is_enemy_group = self.board[nx, ny] == -self.current_player - if is_enemy_group and (not group_has_other_liberties): - return False - # checked all the neighbors, and it doesn't look good. - return True - return False - - def is_positional_superko(self, action): - """Find all actions that the current_player has done in the past, taking into account the fact that - history starts with BLACK when there are no handicaps or with WHITE when there are. - """ - if len(self.handicaps) == 0 and self.current_player == BLACK: - player_history = self.history[0::2] - elif len(self.handicaps) > 0 and self.current_player == WHITE: - player_history = self.history[0::2] - else: - player_history = self.history[1::2] - - if action not in self.handicaps and action not in player_history: - return False - - state_copy = self.copy() - state_copy.enforce_superko = False - state_copy.do_move(action) - - if state_copy.current_hash in self.previous_hashes: - return True - else: - return False - - def is_legal(self, action): - """determine if the given action (x,y tuple) is a legal move - note: we only check ko, not superko at this point (TODO?) - """ - # passing is always legal - if action is PASS_MOVE: - return True - (x, y) = action - if not self._on_board(action): - return False - if self.board[x][y] != EMPTY: - return False - if self.is_suicide(action): - return False - if action == self.ko: - return False - if self.enforce_superko and self.is_positional_superko(action): - return False - return True - - def is_eyeish(self, position, owner): - """returns whether the position is empty and is surrounded by all stones of 'owner' - """ - (x, y) = position - if self.board[x, y] != EMPTY: - return False - - for (nx, ny) in self._neighbors(position): - if self.board[nx, ny] != owner: - return False - return True - - def is_eye(self, position, owner, stack=[]): - """returns whether the position is a true eye of 'owner' - Requires a recursive call; empty spaces diagonal to 'position' are fine - as long as they themselves are eyes - """ - if not self.is_eyeish(position, owner): - return False - # (as in Fuego/Michi/etc) ensure that num "bad" diagonals is 0 (edges) or 1 - # where a bad diagonal is an opponent stone or an empty non-eye space - num_bad_diagonal = 0 - # if in middle of board, 1 bad neighbor is allowable; zero for edges and corners - allowable_bad_diagonal = 1 if len(self._neighbors(position)) == 4 else 0 - - for d in self._diagonals(position): - # opponent stones count against this being eye - if self.board[d] == -owner: - num_bad_diagonal += 1 - # empty spaces (that aren't themselves eyes) count against it too - # the 'stack' keeps track of where we've already been to prevent - # infinite loops of recursion - elif self.board[d] == EMPTY and d not in stack: - stack.append(position) - if not self.is_eye(d, owner, stack): - num_bad_diagonal += 1 - stack.pop() - # at any point, if we've surpassed # allowable, we can stop - if num_bad_diagonal > allowable_bad_diagonal: - return False - return True - - def get_legal_moves(self, include_eyes=True): - if self.__legal_move_cache is not None: - if include_eyes: - return self.__legal_move_cache + self.__legal_eyes_cache - else: - return self.__legal_move_cache - self.__legal_move_cache = [] - self.__legal_eyes_cache = [] - for x in range(self.size): - for y in range(self.size): - if self.is_legal((x, y)): - if not self.is_eye((x, y), self.current_player): - self.__legal_move_cache.append((x, y)) - else: - self.__legal_eyes_cache.append((x, y)) - return self.get_legal_moves(include_eyes) - - def get_winner(self): - """Calculate score of board state and return player ID (1, -1, or 0 for tie) - corresponding to winner. Uses 'Area scoring'. - """ - # Count number of positions filled by each player, plus 1 for each eye-ish space owned - score_white = np.sum(self.board == WHITE) - score_black = np.sum(self.board == BLACK) - empties = zip(*np.where(self.board == EMPTY)) - for empty in empties: - # Check that all surrounding points are of one color - if self.is_eyeish(empty, BLACK): - score_black += 1 - elif self.is_eyeish(empty, WHITE): - score_white += 1 - score_white += self.komi - score_white -= self.passes_white - score_black -= self.passes_black - if score_black > score_white: - winner = BLACK - elif score_white > score_black: - winner = WHITE - else: - # Tie - winner = 0 - return winner - - def place_handicaps(self, actions): - if len(self.history) > 0: - raise IllegalMove("Cannot place handicap on a started game") - self.handicaps.extend(actions) - for action in actions: - self.do_move(action, BLACK) - self.history = [] - - def get_current_player(self): - """Returns the color of the player who will make the next move. - """ - return self.current_player - - def do_move(self, action, color=None): - """Play stone at action=(x,y). If color is not specified, current_player is used - If it is a legal move, current_player switches to the opposite color - If not, an IllegalMove exception is raised - """ - color = color or self.current_player - reset_player = self.current_player - self.current_player = color - if self.is_legal(action): - # reset ko - self.ko = None - # increment age of stones by 1 - self.stone_ages[self.stone_ages >= 0] += 1 - if action is not PASS_MOVE: - (x, y) = action - self.board[x][y] = color - self._update_hash(action, color) - self._update_neighbors(action) - self.stone_ages[x][y] = 0 - - # check neighboring groups' liberties for captures - for (nx, ny) in self._neighbors(action): - if self.board[nx, ny] == -color and len(self.liberty_sets[nx][ny]) == 0: - # capture occurred! - captured_group = self.group_sets[nx][ny] - num_captured = len(captured_group) - self._remove_group(captured_group) - if color == BLACK: - self.num_white_prisoners += num_captured - else: - self.num_black_prisoners += num_captured - # check for ko - if num_captured == 1: - # it is a ko iff, were the opponent to play at the captured position, - # it would recapture (x,y) only - # (a bigger group containing xy may be captured - this is 'snapback') - would_recapture = len(self.liberty_sets[x][y]) == 1 - recapture_size_is_1 = len(self.group_sets[x][y]) == 1 - if would_recapture and recapture_size_is_1: - # note: (nx,ny) is the stone that was captured - self.ko = (nx, ny) - # _remove_group has finished updating the hash - self.previous_hashes.add(self.current_hash) - else: - if color == BLACK: - self.passes_black += 1 - if color == WHITE: - self.passes_white += 1 - # next turn - self.current_player = -color - self.history.append(action) - self.__legal_move_cache = None - else: - self.current_player = reset_player - raise IllegalMove(str(action)) - # Check for end of game - if len(self.history) > 1: - if self.history[-1] is PASS_MOVE and self.history[-2] is PASS_MOVE \ - and self.current_player == WHITE: - self.is_end_of_game = True - return self.is_end_of_game + """State of a game of Go and some basic functions to interact with it + """ + + # Looking up positions adjacent to a given position takes a surprising + # amount of time, hence this shared lookup table {boardsize: {position: [neighbors]}} + __NEIGHBORS_CACHE = {} + + def __init__(self, size=19, komi=7.5, enforce_superko=False): + self.board = np.zeros((size, size)) + self.board.fill(EMPTY) + self.size = size + self.current_player = BLACK + self.ko = None + self.komi = komi # Komi is number of extra points WHITE gets for going 2nd + self.handicaps = [] + self.history = [] + self.num_black_prisoners = 0 + self.num_white_prisoners = 0 + self.is_end_of_game = False + # Each pass move by a player subtracts a point + self.passes_white = 0 + self.passes_black = 0 + # `self.liberty_sets` is a 2D array with the same indexes as `board` + # each entry points to a set of tuples - the liberties of a stone's + # connected block. By caching liberties in this way, we can directly + # optimize update functions (e.g. do_move) and in doing so indirectly + # speed up any function that queries liberties + self._create_neighbors_cache() + self.liberty_sets = [[set() for _ in range(size)] for _ in range(size)] + for x in range(size): + for y in range(size): + self.liberty_sets[x][y] = set(self._neighbors((x, y))) + # separately cache the 2D numpy array of the _size_ of liberty sets + # at each board position + self.liberty_counts = np.zeros((size, size), dtype=np.int) + self.liberty_counts.fill(-1) + # initialize liberty_sets of empty board: the set of neighbors of each position + # similarly to `liberty_sets`, `group_sets[x][y]` points to a set of tuples + # containing all (x',y') pairs in the group connected to (x,y) + self.group_sets = [[set() for _ in range(size)] for _ in range(size)] + # cache of list of legal moves (actually 'sensible' moves, with a separate list for eye-moves on request) + self.__legal_move_cache = None + self.__legal_eyes_cache = None + # on-the-fly record of 'age' of each stone + self.stone_ages = np.zeros((size, size), dtype=np.int) - 1 + + # setup Zobrist hash to keep track of board state + self.enforce_superko = enforce_superko + rng = np.random.RandomState(0) + self.hash_lookup = { + WHITE: rng.randint(np.iinfo(np.uint64).max, size=(size, size), dtype='uint64'), + BLACK: rng.randint(np.iinfo(np.uint64).max, size=(size, size), dtype='uint64')} + self.current_hash = np.uint64(0) + self.previous_hashes = set() + + def get_group(self, position): + """Get the group of connected same-color stones to the given position + Keyword arguments: + position -- a tuple of (x, y) + x being the column index of the starting position of the search + y being the row index of the starting position of the search + Return: + a set of tuples consist of (x, y)s which are the same-color cluster + which contains the input single position. len(group) is size of the cluster, can be large. + """ + (x, y) = position + # given that this is already cached, it is a fast lookup + return self.group_sets[x][y] + + def get_groups_around(self, position): + """returns a list of the unique groups adjacent to position + 'unique' means that, for example in this position: + . . . . . + . B W . . + . W W . . + . . . . . + . . . . . + only the one white group would be returned on get_groups_around((1,1)) + """ + groups = [] + for (nx, ny) in self._neighbors(position): + group = self.group_sets[nx][ny] + if len(group) > 0 and group not in groups: + groups.append(self.group_sets[nx][ny]) + return groups + + def _on_board(self, position): + """simply return True iff position is within the bounds of [0, self.size) + """ + (x, y) = position + return x >= 0 and y >= 0 and x < self.size and y < self.size + + def _create_neighbors_cache(self): + if self.size not in GameState.__NEIGHBORS_CACHE: + GameState.__NEIGHBORS_CACHE[self.size] = {} + for x in xrange(self.size): + for y in xrange(self.size): + neighbors = [xy for xy in [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)] if self._on_board(xy)] + GameState.__NEIGHBORS_CACHE[self.size][(x, y)] = neighbors + + def _neighbors(self, position): + """A private helper function that simply returns a list of positions neighboring + the given (x,y) position. Basically it handles edges and corners. + """ + return GameState.__NEIGHBORS_CACHE[self.size][position] + + def _diagonals(self, position): + """Like _neighbors but for diagonal positions + """ + (x, y) = position + return filter(self._on_board, [(x - 1, y - 1), (x + 1, y + 1), (x + 1, y - 1), (x - 1, y + 1)]) + + def _update_neighbors(self, position): + """A private helper function to update self.group_sets and self.liberty_sets + given that a stone was just played at `position` + """ + (x, y) = position + + merged_group = set() + merged_group.add(position) + merged_libs = self.liberty_sets[x][y] + for (nx, ny) in self._neighbors(position): + # remove (x,y) from liberties of neighboring positions + self.liberty_sets[nx][ny] -= set([position]) + # if neighbor was opponent, update group's liberties count + # (current_player's groups will be updated below regardless) + if self.board[nx][ny] == -self.current_player: + new_liberty_count = len(self.liberty_sets[nx][ny]) + for (gx, gy) in self.group_sets[nx][ny]: + self.liberty_counts[gx][gy] = new_liberty_count + # MERGE group/liberty sets if neighbor is the same color + # note: this automatically takes care of merging two separate + # groups that just became connected through (x,y) + elif self.board[x][y] == self.board[nx][ny]: + merged_group |= self.group_sets[nx][ny] + merged_libs |= self.liberty_sets[nx][ny] + + # now that we have one big 'merged' set for groups and liberties, loop + # over every member of the same-color group to update them + # Note: neighboring opponent groups are already updated in the previous loop + count_merged_libs = len(merged_libs) + for (gx, gy) in merged_group: + self.group_sets[gx][gy] = merged_group + self.liberty_sets[gx][gy] = merged_libs + self.liberty_counts[gx][gy] = count_merged_libs + + def _update_hash(self, action, color): + (x, y) = action + self.current_hash = np.bitwise_xor(self.current_hash, self.hash_lookup[color][x][y]) + + def _remove_group(self, group): + """A private helper function to take a group off the board (due to capture), + updating group sets and liberties along the way + """ + for (x, y) in group: + self._update_hash((x, y), self.board[x, y]) + self.board[x, y] = EMPTY + for (x, y) in group: + # clear group_sets for all positions in 'group' + self.group_sets[x][y] = set() + self.liberty_sets[x][y] = set() + self.liberty_counts[x][y] = -1 + self.stone_ages[x][y] = -1 + for (nx, ny) in self._neighbors((x, y)): + if self.board[nx, ny] == EMPTY: + # add empty neighbors of (x,y) to its liberties + self.liberty_sets[x][y].add((nx, ny)) + else: + # add (x,y) to the liberties of its nonempty neighbors + self.liberty_sets[nx][ny].add((x, y)) + for (gx, gy) in self.group_sets[nx][ny]: + self.liberty_counts[gx][gy] = len(self.liberty_sets[nx][ny]) + + def copy(self): + """get a copy of this Game state + """ + other = GameState(self.size, self.komi) + other.board = self.board.copy() + other.current_player = self.current_player + other.ko = self.ko + other.handicaps = list(self.handicaps) + other.history = list(self.history) + other.num_black_prisoners = self.num_black_prisoners + other.num_white_prisoners = self.num_white_prisoners + other.enforce_superko = self.enforce_superko + other.current_hash = self.current_hash.copy() + other.previous_hashes = self.previous_hashes.copy() + + # update liberty and group sets. Note: calling set(a) on another set + # copies the entries (any iterable as an argument would work so + # set(list(a)) is unnecessary) + for x in range(self.size): + for y in range(self.size): + other.group_sets[x][y] = set(self.group_sets[x][y]) + other.liberty_sets[x][y] = set(self.liberty_sets[x][y]) + other.liberty_counts = self.liberty_counts.copy() + return other + + def is_suicide(self, action): + """return true if having current_player play at would be suicide + """ + (x, y) = action + num_liberties_here = len(self.liberty_sets[x][y]) + if num_liberties_here == 0: + # no liberties here 'immediately' + # but this may still connect to another group of the same color + for (nx, ny) in self._neighbors(action): + # check if we're saved by attaching to a friendly group that has + # liberties elsewhere + is_friendly_group = self.board[nx, ny] == self.current_player + group_has_other_liberties = len(self.liberty_sets[nx][ny] - set([action])) > 0 + if is_friendly_group and group_has_other_liberties: + return False + # check if we're killing an unfriendly group + is_enemy_group = self.board[nx, ny] == -self.current_player + if is_enemy_group and (not group_has_other_liberties): + return False + # checked all the neighbors, and it doesn't look good. + return True + return False + + def is_positional_superko(self, action): + """Find all actions that the current_player has done in the past, taking into account the fact that + history starts with BLACK when there are no handicaps or with WHITE when there are. + """ + if len(self.handicaps) == 0 and self.current_player == BLACK: + player_history = self.history[0::2] + elif len(self.handicaps) > 0 and self.current_player == WHITE: + player_history = self.history[0::2] + else: + player_history = self.history[1::2] + + if action not in self.handicaps and action not in player_history: + return False + + state_copy = self.copy() + state_copy.enforce_superko = False + state_copy.do_move(action) + + if state_copy.current_hash in self.previous_hashes: + return True + else: + return False + + def is_legal(self, action): + """determine if the given action (x,y tuple) is a legal move + note: we only check ko, not superko at this point (TODO?) + """ + # passing is always legal + if action is PASS_MOVE: + return True + (x, y) = action + if not self._on_board(action): + return False + if self.board[x][y] != EMPTY: + return False + if self.is_suicide(action): + return False + if action == self.ko: + return False + if self.enforce_superko and self.is_positional_superko(action): + return False + return True + + def is_eyeish(self, position, owner): + """returns whether the position is empty and is surrounded by all stones of 'owner' + """ + (x, y) = position + if self.board[x, y] != EMPTY: + return False + + for (nx, ny) in self._neighbors(position): + if self.board[nx, ny] != owner: + return False + return True + + def is_eye(self, position, owner, stack=[]): + """returns whether the position is a true eye of 'owner' + Requires a recursive call; empty spaces diagonal to 'position' are fine + as long as they themselves are eyes + """ + if not self.is_eyeish(position, owner): + return False + # (as in Fuego/Michi/etc) ensure that num "bad" diagonals is 0 (edges) or 1 + # where a bad diagonal is an opponent stone or an empty non-eye space + num_bad_diagonal = 0 + # if in middle of board, 1 bad neighbor is allowable; zero for edges and corners + allowable_bad_diagonal = 1 if len(self._neighbors(position)) == 4 else 0 + + for d in self._diagonals(position): + # opponent stones count against this being eye + if self.board[d] == -owner: + num_bad_diagonal += 1 + # empty spaces (that aren't themselves eyes) count against it too + # the 'stack' keeps track of where we've already been to prevent + # infinite loops of recursion + elif self.board[d] == EMPTY and d not in stack: + stack.append(position) + if not self.is_eye(d, owner, stack): + num_bad_diagonal += 1 + stack.pop() + # at any point, if we've surpassed # allowable, we can stop + if num_bad_diagonal > allowable_bad_diagonal: + return False + return True + + def get_legal_moves(self, include_eyes=True): + if self.__legal_move_cache is not None: + if include_eyes: + return self.__legal_move_cache + self.__legal_eyes_cache + else: + return self.__legal_move_cache + self.__legal_move_cache = [] + self.__legal_eyes_cache = [] + for x in range(self.size): + for y in range(self.size): + if self.is_legal((x, y)): + if not self.is_eye((x, y), self.current_player): + self.__legal_move_cache.append((x, y)) + else: + self.__legal_eyes_cache.append((x, y)) + return self.get_legal_moves(include_eyes) + + def get_winner(self): + """Calculate score of board state and return player ID (1, -1, or 0 for tie) + corresponding to winner. Uses 'Area scoring'. + """ + # Count number of positions filled by each player, plus 1 for each eye-ish space owned + score_white = np.sum(self.board == WHITE) + score_black = np.sum(self.board == BLACK) + empties = zip(*np.where(self.board == EMPTY)) + for empty in empties: + # Check that all surrounding points are of one color + if self.is_eyeish(empty, BLACK): + score_black += 1 + elif self.is_eyeish(empty, WHITE): + score_white += 1 + score_white += self.komi + score_white -= self.passes_white + score_black -= self.passes_black + if score_black > score_white: + winner = BLACK + elif score_white > score_black: + winner = WHITE + else: + # Tie + winner = 0 + return winner + + def place_handicaps(self, actions): + if len(self.history) > 0: + raise IllegalMove("Cannot place handicap on a started game") + self.handicaps.extend(actions) + for action in actions: + self.do_move(action, BLACK) + self.history = [] + + def get_current_player(self): + """Returns the color of the player who will make the next move. + """ + return self.current_player + + def do_move(self, action, color=None): + """Play stone at action=(x,y). If color is not specified, current_player is used + If it is a legal move, current_player switches to the opposite color + If not, an IllegalMove exception is raised + """ + color = color or self.current_player + reset_player = self.current_player + self.current_player = color + if self.is_legal(action): + # reset ko + self.ko = None + # increment age of stones by 1 + self.stone_ages[self.stone_ages >= 0] += 1 + if action is not PASS_MOVE: + (x, y) = action + self.board[x][y] = color + self._update_hash(action, color) + self._update_neighbors(action) + self.stone_ages[x][y] = 0 + + # check neighboring groups' liberties for captures + for (nx, ny) in self._neighbors(action): + if self.board[nx, ny] == -color and len(self.liberty_sets[nx][ny]) == 0: + # capture occurred! + captured_group = self.group_sets[nx][ny] + num_captured = len(captured_group) + self._remove_group(captured_group) + if color == BLACK: + self.num_white_prisoners += num_captured + else: + self.num_black_prisoners += num_captured + # check for ko + if num_captured == 1: + # it is a ko iff, were the opponent to play at the captured position, + # it would recapture (x,y) only + # (a bigger group containing xy may be captured - this is 'snapback') + would_recapture = len(self.liberty_sets[x][y]) == 1 + recapture_size_is_1 = len(self.group_sets[x][y]) == 1 + if would_recapture and recapture_size_is_1: + # note: (nx,ny) is the stone that was captured + self.ko = (nx, ny) + # _remove_group has finished updating the hash + self.previous_hashes.add(self.current_hash) + else: + if color == BLACK: + self.passes_black += 1 + if color == WHITE: + self.passes_white += 1 + # next turn + self.current_player = -color + self.history.append(action) + self.__legal_move_cache = None + else: + self.current_player = reset_player + raise IllegalMove(str(action)) + # Check for end of game + if len(self.history) > 1: + if self.history[-1] is PASS_MOVE and self.history[-2] is PASS_MOVE \ + and self.current_player == WHITE: + self.is_end_of_game = True + return self.is_end_of_game class IllegalMove(Exception): - pass + pass diff --git a/AlphaGo/mcts.py b/AlphaGo/mcts.py index d2d572e2a..962b4aabe 100644 --- a/AlphaGo/mcts.py +++ b/AlphaGo/mcts.py @@ -8,210 +8,210 @@ class TreeNode(object): - """A node in the MCTS tree. Each node keeps track of its own value Q, prior probability P, and - its visit-count-adjusted prior score u. - """ - def __init__(self, parent, prior_p): - self._parent = parent - self._children = {} # a map from action to TreeNode - self._n_visits = 0 - self._Q = 0 - # This value for u will be overwritten in the first call to update(), but is useful for - # choosing the first action from this node. - self._u = prior_p - self._P = prior_p - - def expand(self, action_priors): - """Expand tree by creating new children. - - Arguments: - action_priors -- output from policy function - a list of tuples of actions and their prior - probability according to the policy function. - - Returns: - None - """ - for action, prob in action_priors: - if action not in self._children: - self._children[action] = TreeNode(self, prob) - - def select(self): - """Select action among children that gives maximum action value, Q plus bonus u(P). - - Returns: - A tuple of (action, next_node) - """ - return max(self._children.iteritems(), key=lambda (action, node): node.get_value()) - - def update(self, leaf_value, c_puct): - """Update node values from leaf evaluation. - - Arguments: - leaf_value -- the value of subtree evaluation from the current player's perspective. - c_puct -- a number in (0, inf) controlling the relative impact of values, Q, and - prior probability, P, on this node's score. - - Returns: - None - """ - # Count visit. - self._n_visits += 1 - # Update Q, a running average of values for all visits. - self._Q += (leaf_value - self._Q) / self._n_visits - # Update u, the prior weighted by an exploration hyperparameter c_puct and the number of - # visits. Note that u is not normalized to be a distribution. - if not self.is_root(): - self._u = c_puct * self._P * np.sqrt(self._parent._n_visits) / (1 + self._n_visits) - - def update_recursive(self, leaf_value, c_puct): - """Like a call to update(), but applied recursively for all ancestors. - - Note: it is important that this happens from the root downward so that 'parent' visit - counts are correct. - """ - # If it is not root, this node's parent should be updated first. - if self._parent: - self._parent.update_recursive(leaf_value, c_puct) - self.update(leaf_value, c_puct) - - def get_value(self): - """Calculate and return the value for this node: a combination of leaf evaluations, Q, and - this node's prior adjusted for its visit count, u - """ - return self._Q + self._u - - def is_leaf(self): - """Check if leaf node (i.e. no nodes below this have been expanded). - """ - return self._children == {} - - def is_root(self): - return self._parent is None + """A node in the MCTS tree. Each node keeps track of its own value Q, prior probability P, and + its visit-count-adjusted prior score u. + """ + def __init__(self, parent, prior_p): + self._parent = parent + self._children = {} # a map from action to TreeNode + self._n_visits = 0 + self._Q = 0 + # This value for u will be overwritten in the first call to update(), but is useful for + # choosing the first action from this node. + self._u = prior_p + self._P = prior_p + + def expand(self, action_priors): + """Expand tree by creating new children. + + Arguments: + action_priors -- output from policy function - a list of tuples of actions and their prior + probability according to the policy function. + + Returns: + None + """ + for action, prob in action_priors: + if action not in self._children: + self._children[action] = TreeNode(self, prob) + + def select(self): + """Select action among children that gives maximum action value, Q plus bonus u(P). + + Returns: + A tuple of (action, next_node) + """ + return max(self._children.iteritems(), key=lambda (action, node): node.get_value()) + + def update(self, leaf_value, c_puct): + """Update node values from leaf evaluation. + + Arguments: + leaf_value -- the value of subtree evaluation from the current player's perspective. + c_puct -- a number in (0, inf) controlling the relative impact of values, Q, and + prior probability, P, on this node's score. + + Returns: + None + """ + # Count visit. + self._n_visits += 1 + # Update Q, a running average of values for all visits. + self._Q += (leaf_value - self._Q) / self._n_visits + # Update u, the prior weighted by an exploration hyperparameter c_puct and the number of + # visits. Note that u is not normalized to be a distribution. + if not self.is_root(): + self._u = c_puct * self._P * np.sqrt(self._parent._n_visits) / (1 + self._n_visits) + + def update_recursive(self, leaf_value, c_puct): + """Like a call to update(), but applied recursively for all ancestors. + + Note: it is important that this happens from the root downward so that 'parent' visit + counts are correct. + """ + # If it is not root, this node's parent should be updated first. + if self._parent: + self._parent.update_recursive(leaf_value, c_puct) + self.update(leaf_value, c_puct) + + def get_value(self): + """Calculate and return the value for this node: a combination of leaf evaluations, Q, and + this node's prior adjusted for its visit count, u + """ + return self._Q + self._u + + def is_leaf(self): + """Check if leaf node (i.e. no nodes below this have been expanded). + """ + return self._children == {} + + def is_root(self): + return self._parent is None class MCTS(object): - """A simple (and slow) single-threaded implementation of Monte Carlo Tree Search. - - Search works by exploring moves randomly according to the given policy up to a certain - depth, which is relatively small given the search space. "Leaves" at this depth are assigned a - value comprising a weighted combination of (1) the value function evaluated at that leaf, and - (2) the result of finishing the game from that leaf according to the 'rollout' policy. The - probability of revisiting a node changes over the course of the many playouts according to its - estimated value. Ultimately the most visited node is returned as the next action, not the most - valued node. - - The term "playout" refers to a single search from the root, whereas "rollout" refers to the - fast evaluation from leaf nodes to the end of the game. - """ - - def __init__(self, value_fn, policy_fn, rollout_policy_fn, lmbda=0.5, c_puct=5, rollout_limit=500, playout_depth=20, n_playout=10000): - """Arguments: - value_fn -- a function that takes in a state and ouputs a score in [-1, 1], i.e. the - expected value of the end game score from the current player's perspective. - policy_fn -- a function that takes in a state and outputs a list of (action, probability) - tuples for the current player. - rollout_policy_fn -- a coarse, fast version of policy_fn used in the rollout phase. - lmbda -- controls the relative weight of the value network and fast rollout policy result - in determining the value of a leaf node. lmbda must be in [0, 1], where 0 means use only - the value network and 1 means use only the result from the rollout. - c_puct -- a number in (0, inf) that controls how quickly exploration converges to the maximum- - value policy, where a higher value means relying on the prior more, and should be used only - in conjunction with a large value for n_playout. - """ - self._root = TreeNode(None, 1.0) - self._value = value_fn - self._policy = policy_fn - self._rollout = rollout_policy_fn - self._lmbda = lmbda - self._c_puct = c_puct - self._rollout_limit = rollout_limit - self._L = playout_depth - self._n_playout = n_playout - - def _playout(self, state, leaf_depth): - """Run a single playout from the root to the given depth, getting a value at the leaf and - propagating it back through its parents. State is modified in-place, so a copy must be - provided. - - Arguments: - state -- a copy of the state. - leaf_depth -- after this many moves, leaves are evaluated. - - Returns: - None - """ - node = self._root - for i in range(leaf_depth): - # Only expand node if it has not already been done. Existing nodes already know their - # prior. - if node.is_leaf(): - action_probs = self._policy(state) - # Check for end of game. - if len(action_probs) == 0: - break - node.expand(action_probs) - # Greedily select next move. - action, node = node.select() - state.do_move(action) - - # Evaluate the leaf using a weighted combination of the value network, v, and the game's - # winner, z, according to the rollout policy. If lmbda is equal to 0 or 1, only one of - # these contributes and the other may be skipped. Both v and z are from the perspective - # of the current player (+1 is good, -1 is bad). - v = self._value(state) if self._lmbda < 1 else 0 - z = self._evaluate_rollout(state, self._rollout_limit) if self._lmbda > 0 else 0 - leaf_value = (1 - self._lmbda) * v + self._lmbda * z - - # Update value and visit count of nodes in this traversal. - node.update_recursive(leaf_value, self._c_puct) - - def _evaluate_rollout(self, state, limit): - """Use the rollout policy to play until the end of the game, returning +1 if the current - player wins, -1 if the opponent wins, and 0 if it is a tie. - """ - player = state.get_current_player() - for i in range(limit): - action_probs = self._rollout(state) - if len(action_probs) == 0: - break - max_action = max(action_probs, key=lambda (a, p): p)[0] - state.do_move(max_action) - else: - # If no break from the loop, issue a warning. - print "WARNING: rollout reached move limit" - winner = state.get_winner() - if winner == 0: - return 0 - else: - return 1 if winner == player else -1 - - def get_move(self, state): - """Runs all playouts sequentially and returns the most visited action. - - Arguments: - state -- the current state, including both game state and the current player. - - Returns: - the selected action - """ - for n in range(self._n_playout): - state_copy = state.copy() - self._playout(state_copy, self._L) - - # chosen action is the *most visited child*, not the highest-value one - # (they are the same as self._n_playout gets large). - return max(self._root._children.iteritems(), key=lambda (a, n): n._n_visits)[0] - - def update_with_move(self, last_move): - """Step forward in the tree, keeping everything we already know about the subtree, assuming - that get_move() has been called already. Siblings of the new root will be garbage-collected. - """ - if last_move in self._root._children: - self._root = self._root._children[last_move] - self._root._parent = None - else: - self._root = TreeNode(None, 1.0) + """A simple (and slow) single-threaded implementation of Monte Carlo Tree Search. + + Search works by exploring moves randomly according to the given policy up to a certain + depth, which is relatively small given the search space. "Leaves" at this depth are assigned a + value comprising a weighted combination of (1) the value function evaluated at that leaf, and + (2) the result of finishing the game from that leaf according to the 'rollout' policy. The + probability of revisiting a node changes over the course of the many playouts according to its + estimated value. Ultimately the most visited node is returned as the next action, not the most + valued node. + + The term "playout" refers to a single search from the root, whereas "rollout" refers to the + fast evaluation from leaf nodes to the end of the game. + """ + + def __init__(self, value_fn, policy_fn, rollout_policy_fn, lmbda=0.5, c_puct=5, rollout_limit=500, playout_depth=20, n_playout=10000): + """Arguments: + value_fn -- a function that takes in a state and ouputs a score in [-1, 1], i.e. the + expected value of the end game score from the current player's perspective. + policy_fn -- a function that takes in a state and outputs a list of (action, probability) + tuples for the current player. + rollout_policy_fn -- a coarse, fast version of policy_fn used in the rollout phase. + lmbda -- controls the relative weight of the value network and fast rollout policy result + in determining the value of a leaf node. lmbda must be in [0, 1], where 0 means use only + the value network and 1 means use only the result from the rollout. + c_puct -- a number in (0, inf) that controls how quickly exploration converges to the maximum- + value policy, where a higher value means relying on the prior more, and should be used only + in conjunction with a large value for n_playout. + """ + self._root = TreeNode(None, 1.0) + self._value = value_fn + self._policy = policy_fn + self._rollout = rollout_policy_fn + self._lmbda = lmbda + self._c_puct = c_puct + self._rollout_limit = rollout_limit + self._L = playout_depth + self._n_playout = n_playout + + def _playout(self, state, leaf_depth): + """Run a single playout from the root to the given depth, getting a value at the leaf and + propagating it back through its parents. State is modified in-place, so a copy must be + provided. + + Arguments: + state -- a copy of the state. + leaf_depth -- after this many moves, leaves are evaluated. + + Returns: + None + """ + node = self._root + for i in range(leaf_depth): + # Only expand node if it has not already been done. Existing nodes already know their + # prior. + if node.is_leaf(): + action_probs = self._policy(state) + # Check for end of game. + if len(action_probs) == 0: + break + node.expand(action_probs) + # Greedily select next move. + action, node = node.select() + state.do_move(action) + + # Evaluate the leaf using a weighted combination of the value network, v, and the game's + # winner, z, according to the rollout policy. If lmbda is equal to 0 or 1, only one of + # these contributes and the other may be skipped. Both v and z are from the perspective + # of the current player (+1 is good, -1 is bad). + v = self._value(state) if self._lmbda < 1 else 0 + z = self._evaluate_rollout(state, self._rollout_limit) if self._lmbda > 0 else 0 + leaf_value = (1 - self._lmbda) * v + self._lmbda * z + + # Update value and visit count of nodes in this traversal. + node.update_recursive(leaf_value, self._c_puct) + + def _evaluate_rollout(self, state, limit): + """Use the rollout policy to play until the end of the game, returning +1 if the current + player wins, -1 if the opponent wins, and 0 if it is a tie. + """ + player = state.get_current_player() + for i in range(limit): + action_probs = self._rollout(state) + if len(action_probs) == 0: + break + max_action = max(action_probs, key=lambda (a, p): p)[0] + state.do_move(max_action) + else: + # If no break from the loop, issue a warning. + print "WARNING: rollout reached move limit" + winner = state.get_winner() + if winner == 0: + return 0 + else: + return 1 if winner == player else -1 + + def get_move(self, state): + """Runs all playouts sequentially and returns the most visited action. + + Arguments: + state -- the current state, including both game state and the current player. + + Returns: + the selected action + """ + for n in range(self._n_playout): + state_copy = state.copy() + self._playout(state_copy, self._L) + + # chosen action is the *most visited child*, not the highest-value one + # (they are the same as self._n_playout gets large). + return max(self._root._children.iteritems(), key=lambda (a, n): n._n_visits)[0] + + def update_with_move(self, last_move): + """Step forward in the tree, keeping everything we already know about the subtree, assuming + that get_move() has been called already. Siblings of the new root will be garbage-collected. + """ + if last_move in self._root._children: + self._root = self._root._children[last_move] + self._root._parent = None + else: + self._root = TreeNode(None, 1.0) class ParallelMCTS(MCTS): - pass + pass diff --git a/AlphaGo/models/nn_util.py b/AlphaGo/models/nn_util.py index 62696acd2..2f29211ae 100644 --- a/AlphaGo/models/nn_util.py +++ b/AlphaGo/models/nn_util.py @@ -6,124 +6,124 @@ class NeuralNetBase(object): - """Base class for neural network classes handling feature processing, construction - of a 'forward' function, etc. - """ - - # keep track of subclasses to make generic saving/loading cleaner. - # subclasses can be 'registered' with the @neuralnet decorator - subclasses = {} - - def __init__(self, feature_list, **kwargs): - """create a neural net object that preprocesses according to feature_list and uses - a neural network specified by keyword arguments (using subclass' create_network()) - - optional argument: init_network (boolean). If set to False, skips initializing - self.model and self.forward and the calling function should set them. - """ - self.preprocessor = Preprocess(feature_list) - kwargs["input_dim"] = self.preprocessor.output_dim - - if kwargs.get('init_network', True): - # self.__class__ refers to the subclass so that subclasses only - # need to override create_network() - self.model = self.__class__.create_network(**kwargs) - # self.forward is a lambda function wrapping a Keras function - self.forward = self._model_forward() - - def _model_forward(self): - """Construct a function using the current keras backend that, when given a batch - of inputs, simply processes them forward and returns the output - - This is as opposed to model.compile(), which takes a loss function - and training method. - - c.f. https://github.com/fchollet/keras/issues/1426 - """ - # The uses_learning_phase property is True if the model contains layers that behave - # differently during training and testing, e.g. Dropout or BatchNormalization. - # In these cases, K.learning_phase() is a reference to a backend variable that should - # be set to 0 when using the network in prediction mode and is automatically set to 1 - # during training. - if self.model.uses_learning_phase: - forward_function = K.function([self.model.input, K.learning_phase()], [self.model.output]) - - # the forward_function returns a list of tensors - # the first [0] gets the front tensor. - return lambda inpt: forward_function([inpt, 0])[0] - else: - # identical but without a second input argument for the learning phase - forward_function = K.function([self.model.input], [self.model.output]) - return lambda inpt: forward_function([inpt])[0] - - @staticmethod - def load_model(json_file): - """create a new neural net object from the architecture specified in json_file - """ - with open(json_file, 'r') as f: - object_specs = json.load(f) - - # Create object; may be a subclass of networks saved in specs['class'] - class_name = object_specs.get('class', 'CNNPolicy') - try: - network_class = NeuralNetBase.subclasses[class_name] - except KeyError: - raise ValueError("Unknown neural network type in json file: {}\n(was it registered with the @neuralnet decorator?)".format(class_name)) - - # create new object - new_net = network_class(object_specs['feature_list'], init_network=False) - - new_net.model = model_from_json(object_specs['keras_model'], custom_objects={'Bias': Bias}) - if 'weights_file' in object_specs: - new_net.model.load_weights(object_specs['weights_file']) - new_net.forward = new_net._model_forward() - return new_net - - def save_model(self, json_file, weights_file=None): - """write the network model and preprocessing features to the specified file - - If a weights_file (.hdf5 extension) is also specified, model weights are also - saved to that file and will be reloaded automatically in a call to load_model - """ - # this looks odd because we are serializing a model with json as a string - # then making that the value of an object which is then serialized as - # json again. - # It's not as crazy as it looks. A Network has 2 moving parts - the - # feature preprocessing and the neural net, each of which gets a top-level - # entry in the saved file. Keras just happens to serialize models with JSON - # as well. Note how this format makes load_model fairly clean as well. - object_specs = { - 'class': self.__class__.__name__, - 'keras_model': self.model.to_json(), - 'feature_list': self.preprocessor.feature_list - } - if weights_file is not None: - self.model.save_weights(weights_file) - object_specs['weights_file'] = weights_file - # use the json module to write object_specs to file - with open(json_file, 'w') as f: - json.dump(object_specs, f) + """Base class for neural network classes handling feature processing, construction + of a 'forward' function, etc. + """ + + # keep track of subclasses to make generic saving/loading cleaner. + # subclasses can be 'registered' with the @neuralnet decorator + subclasses = {} + + def __init__(self, feature_list, **kwargs): + """create a neural net object that preprocesses according to feature_list and uses + a neural network specified by keyword arguments (using subclass' create_network()) + + optional argument: init_network (boolean). If set to False, skips initializing + self.model and self.forward and the calling function should set them. + """ + self.preprocessor = Preprocess(feature_list) + kwargs["input_dim"] = self.preprocessor.output_dim + + if kwargs.get('init_network', True): + # self.__class__ refers to the subclass so that subclasses only + # need to override create_network() + self.model = self.__class__.create_network(**kwargs) + # self.forward is a lambda function wrapping a Keras function + self.forward = self._model_forward() + + def _model_forward(self): + """Construct a function using the current keras backend that, when given a batch + of inputs, simply processes them forward and returns the output + + This is as opposed to model.compile(), which takes a loss function + and training method. + + c.f. https://github.com/fchollet/keras/issues/1426 + """ + # The uses_learning_phase property is True if the model contains layers that behave + # differently during training and testing, e.g. Dropout or BatchNormalization. + # In these cases, K.learning_phase() is a reference to a backend variable that should + # be set to 0 when using the network in prediction mode and is automatically set to 1 + # during training. + if self.model.uses_learning_phase: + forward_function = K.function([self.model.input, K.learning_phase()], [self.model.output]) + + # the forward_function returns a list of tensors + # the first [0] gets the front tensor. + return lambda inpt: forward_function([inpt, 0])[0] + else: + # identical but without a second input argument for the learning phase + forward_function = K.function([self.model.input], [self.model.output]) + return lambda inpt: forward_function([inpt])[0] + + @staticmethod + def load_model(json_file): + """create a new neural net object from the architecture specified in json_file + """ + with open(json_file, 'r') as f: + object_specs = json.load(f) + + # Create object; may be a subclass of networks saved in specs['class'] + class_name = object_specs.get('class', 'CNNPolicy') + try: + network_class = NeuralNetBase.subclasses[class_name] + except KeyError: + raise ValueError("Unknown neural network type in json file: {}\n(was it registered with the @neuralnet decorator?)".format(class_name)) + + # create new object + new_net = network_class(object_specs['feature_list'], init_network=False) + + new_net.model = model_from_json(object_specs['keras_model'], custom_objects={'Bias': Bias}) + if 'weights_file' in object_specs: + new_net.model.load_weights(object_specs['weights_file']) + new_net.forward = new_net._model_forward() + return new_net + + def save_model(self, json_file, weights_file=None): + """write the network model and preprocessing features to the specified file + + If a weights_file (.hdf5 extension) is also specified, model weights are also + saved to that file and will be reloaded automatically in a call to load_model + """ + # this looks odd because we are serializing a model with json as a string + # then making that the value of an object which is then serialized as + # json again. + # It's not as crazy as it looks. A Network has 2 moving parts - the + # feature preprocessing and the neural net, each of which gets a top-level + # entry in the saved file. Keras just happens to serialize models with JSON + # as well. Note how this format makes load_model fairly clean as well. + object_specs = { + 'class': self.__class__.__name__, + 'keras_model': self.model.to_json(), + 'feature_list': self.preprocessor.feature_list + } + if weights_file is not None: + self.model.save_weights(weights_file) + object_specs['weights_file'] = weights_file + # use the json module to write object_specs to file + with open(json_file, 'w') as f: + json.dump(object_specs, f) def neuralnet(cls): - """Class decorator for registering subclasses of NeuralNetBase - """ - NeuralNetBase.subclasses[cls.__name__] = cls - return cls + """Class decorator for registering subclasses of NeuralNetBase + """ + NeuralNetBase.subclasses[cls.__name__] = cls + return cls class Bias(Layer): - """Custom keras layer that simply adds a scalar bias to each location in the input + """Custom keras layer that simply adds a scalar bias to each location in the input - Largely copied from the keras docs: - http://keras.io/layers/writing-your-own-keras-layers/#writing-your-own-keras-layers - """ - def __init__(self, **kwargs): - super(Bias, self).__init__(**kwargs) + Largely copied from the keras docs: + http://keras.io/layers/writing-your-own-keras-layers/#writing-your-own-keras-layers + """ + def __init__(self, **kwargs): + super(Bias, self).__init__(**kwargs) - def build(self, input_shape): - self.W = K.zeros(input_shape[1:]) - self.trainable_weights = [self.W] + def build(self, input_shape): + self.W = K.zeros(input_shape[1:]) + self.trainable_weights = [self.W] - def call(self, x, mask=None): - return x + self.W + def call(self, x, mask=None): + return x + self.W diff --git a/AlphaGo/models/policy.py b/AlphaGo/models/policy.py index bf65200be..c16058cc2 100644 --- a/AlphaGo/models/policy.py +++ b/AlphaGo/models/policy.py @@ -8,251 +8,251 @@ @neuralnet class CNNPolicy(NeuralNetBase): - """uses a convolutional neural network to evaluate the state of the game - and compute a probability distribution over the next action - """ - - def _select_moves_and_normalize(self, nn_output, moves, size): - """helper function to normalize a distribution over the given list of moves - and return a list of (move, prob) tuples - """ - if len(moves) == 0: - return [] - move_indices = [flatten_idx(m, size) for m in moves] - # get network activations at legal move locations - distribution = nn_output[move_indices] - distribution = distribution / distribution.sum() - return zip(moves, distribution) - - def batch_eval_state(self, states, moves_lists=None): - """Given a list of states, evaluates them all at once to make best use of GPU - batching capabilities. - - Analogous to [eval_state(s) for s in states] - - Returns: a parallel list of move distributions as in eval_state - """ - n_states = len(states) - if n_states == 0: - return [] - state_size = states[0].size - if not all([st.size == state_size for st in states]): - raise ValueError("all states must have the same size") - # concatenate together all one-hot encoded states along the 'batch' dimension - nn_input = np.concatenate([self.preprocessor.state_to_tensor(s) for s in states], axis=0) - # pass all input through the network at once (backend makes use of batches if len(states) is large) - network_output = self.forward(nn_input) - # default move lists to all legal moves - moves_lists = moves_lists or [st.get_legal_moves() for st in states] - results = [None] * n_states - for i in range(n_states): - results[i] = self._select_moves_and_normalize(network_output[i], moves_lists[i], state_size) - return results - - def eval_state(self, state, moves=None): - """Given a GameState object, returns a list of (action, probability) pairs - according to the network outputs - - If a list of moves is specified, only those moves are kept in the distribution - """ - tensor = self.preprocessor.state_to_tensor(state) - # run the tensor through the network - network_output = self.forward(tensor) - moves = moves or state.get_legal_moves() - return self._select_moves_and_normalize(network_output[0], moves, state.size) - - @staticmethod - def create_network(**kwargs): - """construct a convolutional neural network. - - Keword Arguments: - - input_dim: depth of features to be processed by first layer (no default) - - board: width of the go board to be processed (default 19) - - filters_per_layer: number of filters used on every layer (default 128) - - layers: number of convolutional steps (default 12) - - filter_width_K: (where K is between 1 and ) width of filter on - layer K (default 3 except 1st layer which defaults to 5). - Must be odd. - """ - defaults = { - "board": 19, - "filters_per_layer": 128, - "layers": 12, - "filter_width_1": 5 - } - # copy defaults, but override with anything in kwargs - params = defaults - params.update(kwargs) - - # create the network: - # a series of zero-paddings followed by convolutions - # such that the output dimensions are also board x board - network = Sequential() - - # create first layer - network.add(convolutional.Convolution2D( - input_shape=(params["input_dim"], params["board"], params["board"]), - nb_filter=params["filters_per_layer"], - nb_row=params["filter_width_1"], - nb_col=params["filter_width_1"], - init='uniform', - activation='relu', - border_mode='same')) - - # create all other layers - for i in range(2, params["layers"] + 1): - # use filter_width_K if it is there, otherwise use 3 - filter_key = "filter_width_%d" % i - filter_width = params.get(filter_key, 3) - network.add(convolutional.Convolution2D( - nb_filter=params["filters_per_layer"], - nb_row=filter_width, - nb_col=filter_width, - init='uniform', - activation='relu', - border_mode='same')) - - # the last layer maps each feature to a number - network.add(convolutional.Convolution2D( - nb_filter=1, - nb_row=1, - nb_col=1, - init='uniform', - border_mode='same')) - # reshape output to be board x board - network.add(Flatten()) - # add a bias to each board location - network.add(Bias()) - # softmax makes it into a probability distribution - network.add(Activation('softmax')) - - return network + """uses a convolutional neural network to evaluate the state of the game + and compute a probability distribution over the next action + """ + + def _select_moves_and_normalize(self, nn_output, moves, size): + """helper function to normalize a distribution over the given list of moves + and return a list of (move, prob) tuples + """ + if len(moves) == 0: + return [] + move_indices = [flatten_idx(m, size) for m in moves] + # get network activations at legal move locations + distribution = nn_output[move_indices] + distribution = distribution / distribution.sum() + return zip(moves, distribution) + + def batch_eval_state(self, states, moves_lists=None): + """Given a list of states, evaluates them all at once to make best use of GPU + batching capabilities. + + Analogous to [eval_state(s) for s in states] + + Returns: a parallel list of move distributions as in eval_state + """ + n_states = len(states) + if n_states == 0: + return [] + state_size = states[0].size + if not all([st.size == state_size for st in states]): + raise ValueError("all states must have the same size") + # concatenate together all one-hot encoded states along the 'batch' dimension + nn_input = np.concatenate([self.preprocessor.state_to_tensor(s) for s in states], axis=0) + # pass all input through the network at once (backend makes use of batches if len(states) is large) + network_output = self.forward(nn_input) + # default move lists to all legal moves + moves_lists = moves_lists or [st.get_legal_moves() for st in states] + results = [None] * n_states + for i in range(n_states): + results[i] = self._select_moves_and_normalize(network_output[i], moves_lists[i], state_size) + return results + + def eval_state(self, state, moves=None): + """Given a GameState object, returns a list of (action, probability) pairs + according to the network outputs + + If a list of moves is specified, only those moves are kept in the distribution + """ + tensor = self.preprocessor.state_to_tensor(state) + # run the tensor through the network + network_output = self.forward(tensor) + moves = moves or state.get_legal_moves() + return self._select_moves_and_normalize(network_output[0], moves, state.size) + + @staticmethod + def create_network(**kwargs): + """construct a convolutional neural network. + + Keword Arguments: + - input_dim: depth of features to be processed by first layer (no default) + - board: width of the go board to be processed (default 19) + - filters_per_layer: number of filters used on every layer (default 128) + - layers: number of convolutional steps (default 12) + - filter_width_K: (where K is between 1 and ) width of filter on + layer K (default 3 except 1st layer which defaults to 5). + Must be odd. + """ + defaults = { + "board": 19, + "filters_per_layer": 128, + "layers": 12, + "filter_width_1": 5 + } + # copy defaults, but override with anything in kwargs + params = defaults + params.update(kwargs) + + # create the network: + # a series of zero-paddings followed by convolutions + # such that the output dimensions are also board x board + network = Sequential() + + # create first layer + network.add(convolutional.Convolution2D( + input_shape=(params["input_dim"], params["board"], params["board"]), + nb_filter=params["filters_per_layer"], + nb_row=params["filter_width_1"], + nb_col=params["filter_width_1"], + init='uniform', + activation='relu', + border_mode='same')) + + # create all other layers + for i in range(2, params["layers"] + 1): + # use filter_width_K if it is there, otherwise use 3 + filter_key = "filter_width_%d" % i + filter_width = params.get(filter_key, 3) + network.add(convolutional.Convolution2D( + nb_filter=params["filters_per_layer"], + nb_row=filter_width, + nb_col=filter_width, + init='uniform', + activation='relu', + border_mode='same')) + + # the last layer maps each feature to a number + network.add(convolutional.Convolution2D( + nb_filter=1, + nb_row=1, + nb_col=1, + init='uniform', + border_mode='same')) + # reshape output to be board x board + network.add(Flatten()) + # add a bias to each board location + network.add(Bias()) + # softmax makes it into a probability distribution + network.add(Activation('softmax')) + + return network @neuralnet class ResnetPolicy(CNNPolicy): - """Residual network architecture as per He at al. 2015 - """ - @staticmethod - def create_network(**kwargs): - """construct a convolutional neural network with Resnet-style skip connections. - Arguments are the same as with the default CNNPolicy network, except the default - number of layers is 20 plus a new n_skip parameter - - Keword Arguments: - - input_dim: depth of features to be processed by first layer (no default) - - board: width of the go board to be processed (default 19) - - filters_per_layer: number of filters used on every layer (default 128) - - layers: number of convolutional steps (default 20) - - filter_width_K: (where K is between 1 and ) width of filter on - layer K (default 3 except 1st layer which defaults to 5). - Must be odd. - - n_skip_K: (where K is as in filter_width_K) number of convolutional - layers to skip with the linear path starting at K. Only valid - at K >= 1. (Each layer defaults to 1) - - Note that n_skip_1=s means that the next valid value of n_skip_* is 3 - - A diagram may help explain (numbers indicate layer): - - 1 2 3 4 5 6 - I--C -- B -- R -- C -- B -- R -- C -- M -- B -- R -- C -- B -- R -- C -- B -- R -- C -- M ... M -- R -- F -- O - \___________________________/ \____________________________________________________/ \ ... / - [n_skip_1 = 2] [n_skip_3 = 3] - - I - input - B - BatchNormalization - R - ReLU - C - Conv2D - F - Flatten - O - output - M - merge - - The input is always passed through a Conv2D layer, the output of which layer is counted as '1'. - Each subsequent [R -- C] block is counted as one 'layer'. The 'merge' layer isn't counted; hence - if n_skip_1 is 2, the next valid skip parameter is n_skip_3, which will start at the output - of the merge - """ - defaults = { - "board": 19, - "filters_per_layer": 128, - "layers": 20, - "filter_width_1": 5 - } - # copy defaults, but override with anything in kwargs - params = defaults - params.update(kwargs) - - # create the network using Keras' functional API, - # since this isn't 'Sequential' - model_input = Input(shape=(params["input_dim"], params["board"], params["board"])) - - # create first layer - convolution_path = convolutional.Convolution2D( - input_shape=(), - nb_filter=params["filters_per_layer"], - nb_row=params["filter_width_1"], - nb_col=params["filter_width_1"], - init='uniform', - activation='linear', # relu activations done inside resnet modules - border_mode='same')(model_input) - - def add_resnet_unit(path, K, **params): - """Add a resnet unit to path starting at layer 'K', - adding as many (ReLU + Conv2D) modules as specified by n_skip_K - - Returns new path and next layer index, i.e. K + n_skip_K, in a tuple - """ - # loosely based on https://github.com/keunwoochoi/residual_block_keras - # (see also keras docs here: http://keras.io/getting-started/functional-api-guide/#all-models-are-callable-just-like-layers) - - block_input = path - # use n_skip_K if it is there, default to 1 - skip_key = "n_skip_%d" % K - n_skip = params.get(skip_key, 1) - for i in range(n_skip): - layer = K + i - # add BatchNorm - path = BatchNormalization()(path) - # add ReLU - path = Activation('relu')(path) - # use filter_width_K if it is there, otherwise use 3 - filter_key = "filter_width_%d" % layer - filter_width = params.get(filter_key, 3) - # add Conv2D - path = convolutional.Convolution2D( - nb_filter=params["filters_per_layer"], - nb_row=filter_width, - nb_col=filter_width, - init='uniform', - activation='linear', - border_mode='same')(path) - # Merge 'input layer' with the path - path = merge([block_input, path], mode='sum') - return path, K + n_skip - - # create all other layers - layer = 1 - while layer < params['layers']: - convolution_path, layer = add_resnet_unit(convolution_path, layer, **params) - if layer > params['layers']: - print "Due to skipping, ended with {} layers instead of {}".format(layer, params['layers']) - - # since each layer's activation was linear, need one more ReLu - convolution_path = Activation('relu')(convolution_path) - - # the last layer maps each featuer to a number - convolution_path = convolutional.Convolution2D( - nb_filter=1, - nb_row=1, - nb_col=1, - init='uniform', - border_mode='same')(convolution_path) - # flatten output - network_output = Flatten()(convolution_path) - # add a bias to each board location - network_output = Bias()(network_output) - # softmax makes it into a probability distribution - network_output = Activation('softmax')(network_output) - - return Model(input=[model_input], output=[network_output]) + """Residual network architecture as per He at al. 2015 + """ + @staticmethod + def create_network(**kwargs): + """construct a convolutional neural network with Resnet-style skip connections. + Arguments are the same as with the default CNNPolicy network, except the default + number of layers is 20 plus a new n_skip parameter + + Keword Arguments: + - input_dim: depth of features to be processed by first layer (no default) + - board: width of the go board to be processed (default 19) + - filters_per_layer: number of filters used on every layer (default 128) + - layers: number of convolutional steps (default 20) + - filter_width_K: (where K is between 1 and ) width of filter on + layer K (default 3 except 1st layer which defaults to 5). + Must be odd. + - n_skip_K: (where K is as in filter_width_K) number of convolutional + layers to skip with the linear path starting at K. Only valid + at K >= 1. (Each layer defaults to 1) + + Note that n_skip_1=s means that the next valid value of n_skip_* is 3 + + A diagram may help explain (numbers indicate layer): + + 1 2 3 4 5 6 + I--C -- B -- R -- C -- B -- R -- C -- M -- B -- R -- C -- B -- R -- C -- B -- R -- C -- M ... M -- R -- F -- O + \___________________________/ \____________________________________________________/ \ ... / + [n_skip_1 = 2] [n_skip_3 = 3] + + I - input + B - BatchNormalization + R - ReLU + C - Conv2D + F - Flatten + O - output + M - merge + + The input is always passed through a Conv2D layer, the output of which layer is counted as '1'. + Each subsequent [R -- C] block is counted as one 'layer'. The 'merge' layer isn't counted; hence + if n_skip_1 is 2, the next valid skip parameter is n_skip_3, which will start at the output + of the merge + """ + defaults = { + "board": 19, + "filters_per_layer": 128, + "layers": 20, + "filter_width_1": 5 + } + # copy defaults, but override with anything in kwargs + params = defaults + params.update(kwargs) + + # create the network using Keras' functional API, + # since this isn't 'Sequential' + model_input = Input(shape=(params["input_dim"], params["board"], params["board"])) + + # create first layer + convolution_path = convolutional.Convolution2D( + input_shape=(), + nb_filter=params["filters_per_layer"], + nb_row=params["filter_width_1"], + nb_col=params["filter_width_1"], + init='uniform', + activation='linear', # relu activations done inside resnet modules + border_mode='same')(model_input) + + def add_resnet_unit(path, K, **params): + """Add a resnet unit to path starting at layer 'K', + adding as many (ReLU + Conv2D) modules as specified by n_skip_K + + Returns new path and next layer index, i.e. K + n_skip_K, in a tuple + """ + # loosely based on https://github.com/keunwoochoi/residual_block_keras + # (see also keras docs here: http://keras.io/getting-started/functional-api-guide/#all-models-are-callable-just-like-layers) + + block_input = path + # use n_skip_K if it is there, default to 1 + skip_key = "n_skip_%d" % K + n_skip = params.get(skip_key, 1) + for i in range(n_skip): + layer = K + i + # add BatchNorm + path = BatchNormalization()(path) + # add ReLU + path = Activation('relu')(path) + # use filter_width_K if it is there, otherwise use 3 + filter_key = "filter_width_%d" % layer + filter_width = params.get(filter_key, 3) + # add Conv2D + path = convolutional.Convolution2D( + nb_filter=params["filters_per_layer"], + nb_row=filter_width, + nb_col=filter_width, + init='uniform', + activation='linear', + border_mode='same')(path) + # Merge 'input layer' with the path + path = merge([block_input, path], mode='sum') + return path, K + n_skip + + # create all other layers + layer = 1 + while layer < params['layers']: + convolution_path, layer = add_resnet_unit(convolution_path, layer, **params) + if layer > params['layers']: + print "Due to skipping, ended with {} layers instead of {}".format(layer, params['layers']) + + # since each layer's activation was linear, need one more ReLu + convolution_path = Activation('relu')(convolution_path) + + # the last layer maps each featuer to a number + convolution_path = convolutional.Convolution2D( + nb_filter=1, + nb_row=1, + nb_col=1, + init='uniform', + border_mode='same')(convolution_path) + # flatten output + network_output = Flatten()(convolution_path) + # add a bias to each board location + network_output = Bias()(network_output) + # softmax makes it into a probability distribution + network_output = Activation('softmax')(network_output) + + return Model(input=[model_input], output=[network_output]) diff --git a/AlphaGo/preprocessing/game_converter.py b/AlphaGo/preprocessing/game_converter.py index de606aabd..6324d254b 100644 --- a/AlphaGo/preprocessing/game_converter.py +++ b/AlphaGo/preprocessing/game_converter.py @@ -10,209 +10,209 @@ class SizeMismatchError(Exception): - pass + pass class game_converter: - def __init__(self, features): - self.feature_processor = Preprocess(features) - self.n_features = self.feature_processor.output_dim - - def convert_game(self, file_name, bd_size): - """Read the given SGF file into an iterable of (input,output) pairs - for neural network training - - Each input is a GameState converted into one-hot neural net features - Each output is an action as an (x,y) pair (passes are skipped) - - If this game's size does not match bd_size, a SizeMismatchError is raised - """ - - with open(file_name, 'r') as file_object: - state_action_iterator = sgf_iter_states(file_object.read(), include_end=False) - - for (state, move, player) in state_action_iterator: - if state.size != bd_size: - raise SizeMismatchError() - if move != go.PASS_MOVE: - nn_input = self.feature_processor.state_to_tensor(state) - yield (nn_input, move) - - def sgfs_to_hdf5(self, sgf_files, hdf5_file, bd_size=19, ignore_errors=True, verbose=False): - """Convert all files in the iterable sgf_files into an hdf5 group to be stored in hdf5_file - - Arguments: - - sgf_files : an iterable of relative or absolute paths to SGF files - - hdf5_file : the name of the HDF5 where features will be saved - - bd_size : side length of board of games that are loaded - - ignore_errors : if True, issues a Warning when there is an unknown exception rather than halting. Note - that sgf.ParseException and go.IllegalMove exceptions are always skipped - - The resulting file has the following properties: - states : dataset with shape (n_data, n_features, board width, board height) - actions : dataset with shape (n_data, 2) (actions are stored as x,y tuples of where the move was played) - file_offsets : group mapping from filenames to tuples of (index, length) - - For example, to find what positions in the dataset come from 'test.sgf': - index, length = file_offsets['test.sgf'] - test_states = states[index:index+length] - test_actions = actions[index:index+length] - """ - # TODO - also save feature list - - # make a hidden temporary file in case of a crash. - # on success, this is renamed to hdf5_file - tmp_file = os.path.join(os.path.dirname(hdf5_file), ".tmp." + os.path.basename(hdf5_file)) - h5f = h5.File(tmp_file, 'w') - - try: - # see http://docs.h5py.org/en/latest/high/group.html#Group.create_dataset - states = h5f.require_dataset( - 'states', - dtype=np.uint8, - shape=(1, self.n_features, bd_size, bd_size), - maxshape=(None, self.n_features, bd_size, bd_size), # 'None' dimension allows it to grow arbitrarily - exact=False, # allow non-uint8 datasets to be loaded, coerced to uint8 - chunks=(64, self.n_features, bd_size, bd_size), # approximately 1MB chunks - compression="lzf") - actions = h5f.require_dataset( - 'actions', - dtype=np.uint8, - shape=(1, 2), - maxshape=(None, 2), - exact=False, - chunks=(1024, 2), - compression="lzf") - # 'file_offsets' is an HDF5 group so that 'file_name in file_offsets' is fast - file_offsets = h5f.require_group('file_offsets') - - if verbose: - print("created HDF5 dataset in {}".format(tmp_file)) - - next_idx = 0 - for file_name in sgf_files: - if verbose: - print(file_name) - # count number of state/action pairs yielded by this game - n_pairs = 0 - file_start_idx = next_idx - try: - for state, move in self.convert_game(file_name, bd_size): - if next_idx >= len(states): - states.resize((next_idx + 1, self.n_features, bd_size, bd_size)) - actions.resize((next_idx + 1, 2)) - states[next_idx] = state - actions[next_idx] = move - n_pairs += 1 - next_idx += 1 - except go.IllegalMove: - warnings.warn("Illegal Move encountered in %s\n\tdropping the remainder of the game" % file_name) - except sgf.ParseException: - warnings.warn("Could not parse %s\n\tdropping game" % file_name) - except SizeMismatchError: - warnings.warn("Skipping %s; wrong board size" % file_name) - except Exception as e: - # catch everything else - if ignore_errors: - warnings.warn("Unkown exception with file %s\n\t%s" % (file_name, e), stacklevel=2) - else: - raise e - finally: - if n_pairs > 0: - # '/' has special meaning in HDF5 key names, so they are replaced with ':' here - file_name_key = file_name.replace('/', ':') - file_offsets[file_name_key] = [file_start_idx, n_pairs] - if verbose: - print("\t%d state/action pairs extracted" % n_pairs) - elif verbose: - print("\t-no usable data-") - except Exception as e: - print("sgfs_to_hdf5 failed") - os.remove(tmp_file) - raise e - - if verbose: - print("finished. renaming %s to %s" % (tmp_file, hdf5_file)) - - # processing complete; rename tmp_file to hdf5_file - h5f.close() - os.rename(tmp_file, hdf5_file) + def __init__(self, features): + self.feature_processor = Preprocess(features) + self.n_features = self.feature_processor.output_dim + + def convert_game(self, file_name, bd_size): + """Read the given SGF file into an iterable of (input,output) pairs + for neural network training + + Each input is a GameState converted into one-hot neural net features + Each output is an action as an (x,y) pair (passes are skipped) + + If this game's size does not match bd_size, a SizeMismatchError is raised + """ + + with open(file_name, 'r') as file_object: + state_action_iterator = sgf_iter_states(file_object.read(), include_end=False) + + for (state, move, player) in state_action_iterator: + if state.size != bd_size: + raise SizeMismatchError() + if move != go.PASS_MOVE: + nn_input = self.feature_processor.state_to_tensor(state) + yield (nn_input, move) + + def sgfs_to_hdf5(self, sgf_files, hdf5_file, bd_size=19, ignore_errors=True, verbose=False): + """Convert all files in the iterable sgf_files into an hdf5 group to be stored in hdf5_file + + Arguments: + - sgf_files : an iterable of relative or absolute paths to SGF files + - hdf5_file : the name of the HDF5 where features will be saved + - bd_size : side length of board of games that are loaded + - ignore_errors : if True, issues a Warning when there is an unknown exception rather than halting. Note + that sgf.ParseException and go.IllegalMove exceptions are always skipped + + The resulting file has the following properties: + states : dataset with shape (n_data, n_features, board width, board height) + actions : dataset with shape (n_data, 2) (actions are stored as x,y tuples of where the move was played) + file_offsets : group mapping from filenames to tuples of (index, length) + + For example, to find what positions in the dataset come from 'test.sgf': + index, length = file_offsets['test.sgf'] + test_states = states[index:index+length] + test_actions = actions[index:index+length] + """ + # TODO - also save feature list + + # make a hidden temporary file in case of a crash. + # on success, this is renamed to hdf5_file + tmp_file = os.path.join(os.path.dirname(hdf5_file), ".tmp." + os.path.basename(hdf5_file)) + h5f = h5.File(tmp_file, 'w') + + try: + # see http://docs.h5py.org/en/latest/high/group.html#Group.create_dataset + states = h5f.require_dataset( + 'states', + dtype=np.uint8, + shape=(1, self.n_features, bd_size, bd_size), + maxshape=(None, self.n_features, bd_size, bd_size), # 'None' dimension allows it to grow arbitrarily + exact=False, # allow non-uint8 datasets to be loaded, coerced to uint8 + chunks=(64, self.n_features, bd_size, bd_size), # approximately 1MB chunks + compression="lzf") + actions = h5f.require_dataset( + 'actions', + dtype=np.uint8, + shape=(1, 2), + maxshape=(None, 2), + exact=False, + chunks=(1024, 2), + compression="lzf") + # 'file_offsets' is an HDF5 group so that 'file_name in file_offsets' is fast + file_offsets = h5f.require_group('file_offsets') + + if verbose: + print("created HDF5 dataset in {}".format(tmp_file)) + + next_idx = 0 + for file_name in sgf_files: + if verbose: + print(file_name) + # count number of state/action pairs yielded by this game + n_pairs = 0 + file_start_idx = next_idx + try: + for state, move in self.convert_game(file_name, bd_size): + if next_idx >= len(states): + states.resize((next_idx + 1, self.n_features, bd_size, bd_size)) + actions.resize((next_idx + 1, 2)) + states[next_idx] = state + actions[next_idx] = move + n_pairs += 1 + next_idx += 1 + except go.IllegalMove: + warnings.warn("Illegal Move encountered in %s\n\tdropping the remainder of the game" % file_name) + except sgf.ParseException: + warnings.warn("Could not parse %s\n\tdropping game" % file_name) + except SizeMismatchError: + warnings.warn("Skipping %s; wrong board size" % file_name) + except Exception as e: + # catch everything else + if ignore_errors: + warnings.warn("Unkown exception with file %s\n\t%s" % (file_name, e), stacklevel=2) + else: + raise e + finally: + if n_pairs > 0: + # '/' has special meaning in HDF5 key names, so they are replaced with ':' here + file_name_key = file_name.replace('/', ':') + file_offsets[file_name_key] = [file_start_idx, n_pairs] + if verbose: + print("\t%d state/action pairs extracted" % n_pairs) + elif verbose: + print("\t-no usable data-") + except Exception as e: + print("sgfs_to_hdf5 failed") + os.remove(tmp_file) + raise e + + if verbose: + print("finished. renaming %s to %s" % (tmp_file, hdf5_file)) + + # processing complete; rename tmp_file to hdf5_file + h5f.close() + os.rename(tmp_file, hdf5_file) def run_game_converter(cmd_line_args=None): - """Run conversions. command-line args may be passed in as a list - """ - import argparse - import sys - - parser = argparse.ArgumentParser( - description='Prepare SGF Go game files for training the neural network model.', - epilog="Available features are: board, ones, turns_since, liberties,\ - capture_size, self_atari_size, liberties_after, sensibleness, and zeros.\ - Ladder features are not currently implemented") - parser.add_argument("--features", "-f", help="Comma-separated list of features to compute and store or 'all'", default='all') - parser.add_argument("--outfile", "-o", help="Destination to write data (hdf5 file)", required=True) - parser.add_argument("--recurse", "-R", help="Set to recurse through directories searching for SGF files", default=False, action="store_true") - parser.add_argument("--directory", "-d", help="Directory containing SGF files to process. if not present, expects files from stdin", default=None) - parser.add_argument("--size", "-s", help="Size of the game board. SGFs not matching this are discarded with a warning", type=int, default=19) - parser.add_argument("--verbose", "-v", help="Turn on verbose mode", default=False, action="store_true") - - if cmd_line_args is None: - args = parser.parse_args() - else: - args = parser.parse_args(cmd_line_args) - - if args.features.lower() == 'all': - feature_list = [ - "board", - "ones", - "turns_since", - "liberties", - "capture_size", - "self_atari_size", - "liberties_after", - # "ladder_capture", - # "ladder_escape", - "sensibleness", - "zeros"] - else: - feature_list = args.features.split(",") - - if args.verbose: - print("using features", feature_list) - - converter = game_converter(feature_list) - - def _is_sgf(fname): - return fname.strip()[-4:] == ".sgf" - - def _walk_all_sgfs(root): - """a helper function/generator to get all SGF files in subdirectories of root - """ - for (dirpath, dirname, files) in os.walk(root): - for filename in files: - if _is_sgf(filename): - # yield the full (relative) path to the file - yield os.path.join(dirpath, filename) - - def _list_sgfs(path): - """helper function to get all SGF files in a directory (does not recurse) - """ - files = os.listdir(path) - return (os.path.join(path, f) for f in files if _is_sgf(f)) - - # get an iterator of SGF files according to command line args - if args.directory: - if args.recurse: - files = _walk_all_sgfs(args.directory) - else: - files = _list_sgfs(args.directory) - else: - files = (f.strip() for f in sys.stdin if _is_sgf(f)) - - converter.sgfs_to_hdf5(files, args.outfile, bd_size=args.size, verbose=args.verbose) + """Run conversions. command-line args may be passed in as a list + """ + import argparse + import sys + + parser = argparse.ArgumentParser( + description='Prepare SGF Go game files for training the neural network model.', + epilog="Available features are: board, ones, turns_since, liberties,\ + capture_size, self_atari_size, liberties_after, sensibleness, and zeros.\ + Ladder features are not currently implemented") + parser.add_argument("--features", "-f", help="Comma-separated list of features to compute and store or 'all'", default='all') + parser.add_argument("--outfile", "-o", help="Destination to write data (hdf5 file)", required=True) + parser.add_argument("--recurse", "-R", help="Set to recurse through directories searching for SGF files", default=False, action="store_true") + parser.add_argument("--directory", "-d", help="Directory containing SGF files to process. if not present, expects files from stdin", default=None) + parser.add_argument("--size", "-s", help="Size of the game board. SGFs not matching this are discarded with a warning", type=int, default=19) + parser.add_argument("--verbose", "-v", help="Turn on verbose mode", default=False, action="store_true") + + if cmd_line_args is None: + args = parser.parse_args() + else: + args = parser.parse_args(cmd_line_args) + + if args.features.lower() == 'all': + feature_list = [ + "board", + "ones", + "turns_since", + "liberties", + "capture_size", + "self_atari_size", + "liberties_after", + # "ladder_capture", + # "ladder_escape", + "sensibleness", + "zeros"] + else: + feature_list = args.features.split(",") + + if args.verbose: + print("using features", feature_list) + + converter = game_converter(feature_list) + + def _is_sgf(fname): + return fname.strip()[-4:] == ".sgf" + + def _walk_all_sgfs(root): + """a helper function/generator to get all SGF files in subdirectories of root + """ + for (dirpath, dirname, files) in os.walk(root): + for filename in files: + if _is_sgf(filename): + # yield the full (relative) path to the file + yield os.path.join(dirpath, filename) + + def _list_sgfs(path): + """helper function to get all SGF files in a directory (does not recurse) + """ + files = os.listdir(path) + return (os.path.join(path, f) for f in files if _is_sgf(f)) + + # get an iterator of SGF files according to command line args + if args.directory: + if args.recurse: + files = _walk_all_sgfs(args.directory) + else: + files = _list_sgfs(args.directory) + else: + files = (f.strip() for f in sys.stdin if _is_sgf(f)) + + converter.sgfs_to_hdf5(files, args.outfile, bd_size=args.size, verbose=args.verbose) if __name__ == '__main__': - run_game_converter() + run_game_converter() diff --git a/AlphaGo/preprocessing/preprocessing.py b/AlphaGo/preprocessing/preprocessing.py index 60732e3f5..12f9269ea 100644 --- a/AlphaGo/preprocessing/preprocessing.py +++ b/AlphaGo/preprocessing/preprocessing.py @@ -7,267 +7,267 @@ def get_board(state): - """A feature encoding WHITE BLACK and EMPTY on separate planes, but plane 0 - always refers to the current player and plane 1 to the opponent - """ - planes = np.zeros((3, state.size, state.size)) - planes[0, :, :] = state.board == state.current_player # own stone - planes[1, :, :] = state.board == -state.current_player # opponent stone - planes[2, :, :] = state.board == go.EMPTY # empty space - return planes + """A feature encoding WHITE BLACK and EMPTY on separate planes, but plane 0 + always refers to the current player and plane 1 to the opponent + """ + planes = np.zeros((3, state.size, state.size)) + planes[0, :, :] = state.board == state.current_player # own stone + planes[1, :, :] = state.board == -state.current_player # opponent stone + planes[2, :, :] = state.board == go.EMPTY # empty space + return planes def get_turns_since(state, maximum=8): - """A feature encoding the age of the stone at each location up to 'maximum' + """A feature encoding the age of the stone at each location up to 'maximum' - Note: - - the [maximum-1] plane is used for any stone with age greater than or equal to maximum - - EMPTY locations are all-zero features - """ - planes = np.zeros((maximum, state.size, state.size)) - for x in range(state.size): - for y in range(state.size): - if state.stone_ages[x][y] >= 0: - planes[min(state.stone_ages[x][y], maximum - 1), x, y] = 1 - return planes + Note: + - the [maximum-1] plane is used for any stone with age greater than or equal to maximum + - EMPTY locations are all-zero features + """ + planes = np.zeros((maximum, state.size, state.size)) + for x in range(state.size): + for y in range(state.size): + if state.stone_ages[x][y] >= 0: + planes[min(state.stone_ages[x][y], maximum - 1), x, y] = 1 + return planes def get_liberties(state, maximum=8): - """A feature encoding the number of liberties of the group connected to the stone at - each location - - Note: - - there is no zero-liberties plane; the 0th plane indicates groups in atari - - the [maximum-1] plane is used for any stone with liberties greater than or equal to maximum - - EMPTY locations are all-zero features - """ - planes = np.zeros((maximum, state.size, state.size)) - for i in range(maximum): - # single liberties in plane zero (groups won't have zero), double liberties in plane one, etc - planes[i, state.liberty_counts == i + 1] = 1 - # the "maximum-or-more" case on the backmost plane - planes[maximum - 1, state.liberty_counts >= maximum] = 1 - return planes + """A feature encoding the number of liberties of the group connected to the stone at + each location + + Note: + - there is no zero-liberties plane; the 0th plane indicates groups in atari + - the [maximum-1] plane is used for any stone with liberties greater than or equal to maximum + - EMPTY locations are all-zero features + """ + planes = np.zeros((maximum, state.size, state.size)) + for i in range(maximum): + # single liberties in plane zero (groups won't have zero), double liberties in plane one, etc + planes[i, state.liberty_counts == i + 1] = 1 + # the "maximum-or-more" case on the backmost plane + planes[maximum - 1, state.liberty_counts >= maximum] = 1 + return planes def get_capture_size(state, maximum=8): - """A feature encoding the number of opponent stones that would be captured by playing at each location, - up to 'maximum' - - Note: - - we currently *do* treat the 0th plane as "capturing zero stones" - - the [maximum-1] plane is used for any capturable group of size greater than or equal to maximum-1 - - the 0th plane is used for legal moves that would not result in capture - - illegal move locations are all-zero features - """ - planes = np.zeros((maximum, state.size, state.size)) - for (x, y) in state.get_legal_moves(): - # multiple disconnected groups may be captured. hence we loop over - # groups and count sizes if captured. - n_captured = 0 - for neighbor_group in state.get_groups_around((x, y)): - # if the neighboring group is opponent stones and they have - # one liberty, it must be (x,y) and we are capturing them - # (note suicide and ko are not an issue because they are not - # legal moves) - (gx, gy) = next(iter(neighbor_group)) - if (state.liberty_counts[gx][gy] == 1) and (state.board[gx, gy] != state.current_player): - n_captured += len(state.group_sets[gx][gy]) - planes[min(n_captured, maximum - 1), x, y] = 1 - return planes + """A feature encoding the number of opponent stones that would be captured by playing at each location, + up to 'maximum' + + Note: + - we currently *do* treat the 0th plane as "capturing zero stones" + - the [maximum-1] plane is used for any capturable group of size greater than or equal to maximum-1 + - the 0th plane is used for legal moves that would not result in capture + - illegal move locations are all-zero features + """ + planes = np.zeros((maximum, state.size, state.size)) + for (x, y) in state.get_legal_moves(): + # multiple disconnected groups may be captured. hence we loop over + # groups and count sizes if captured. + n_captured = 0 + for neighbor_group in state.get_groups_around((x, y)): + # if the neighboring group is opponent stones and they have + # one liberty, it must be (x,y) and we are capturing them + # (note suicide and ko are not an issue because they are not + # legal moves) + (gx, gy) = next(iter(neighbor_group)) + if (state.liberty_counts[gx][gy] == 1) and (state.board[gx, gy] != state.current_player): + n_captured += len(state.group_sets[gx][gy]) + planes[min(n_captured, maximum - 1), x, y] = 1 + return planes def get_self_atari_size(state, maximum=8): - """A feature encoding the size of the own-stone group that is put into atari by playing at a location - """ - planes = np.zeros((maximum, state.size, state.size)) - - for (x, y) in state.get_legal_moves(): - # make a copy of the liberty/group sets at (x,y) so we can manipulate them - lib_set_after = set(state.liberty_sets[x][y]) - group_set_after = set() - group_set_after.add((x, y)) - captured_stones = set() - for neighbor_group in state.get_groups_around((x, y)): - # if the neighboring group is of the same color as the current player - # then playing here will connect this stone to that group - (gx, gy) = next(iter(neighbor_group)) - if state.board[gx, gy] == state.current_player: - lib_set_after |= state.liberty_sets[gx][gy] - group_set_after |= state.group_sets[gx][gy] - # if instead neighboring group is opponent *and about to be captured* - # then we might gain new liberties - elif state.liberty_counts[gx][gy] == 1: - captured_stones |= state.group_sets[gx][gy] - # add captured stones to liberties if they are neighboring the 'group_set_after' - # i.e. if they will become liberties once capture is resolved - if len(captured_stones) > 0: - for (gx, gy) in group_set_after: - # intersection of group's neighbors and captured stones will become liberties - lib_set_after |= set(state._neighbors((gx, gy))) & captured_stones - if (x, y) in lib_set_after: - lib_set_after.remove((x, y)) - # check if this move resulted in atari - if len(lib_set_after) == 1: - group_size = len(group_set_after) - # 0th plane used for size=1, so group_size-1 is the index - planes[min(group_size - 1, maximum - 1), x, y] = 1 - return planes + """A feature encoding the size of the own-stone group that is put into atari by playing at a location + """ + planes = np.zeros((maximum, state.size, state.size)) + + for (x, y) in state.get_legal_moves(): + # make a copy of the liberty/group sets at (x,y) so we can manipulate them + lib_set_after = set(state.liberty_sets[x][y]) + group_set_after = set() + group_set_after.add((x, y)) + captured_stones = set() + for neighbor_group in state.get_groups_around((x, y)): + # if the neighboring group is of the same color as the current player + # then playing here will connect this stone to that group + (gx, gy) = next(iter(neighbor_group)) + if state.board[gx, gy] == state.current_player: + lib_set_after |= state.liberty_sets[gx][gy] + group_set_after |= state.group_sets[gx][gy] + # if instead neighboring group is opponent *and about to be captured* + # then we might gain new liberties + elif state.liberty_counts[gx][gy] == 1: + captured_stones |= state.group_sets[gx][gy] + # add captured stones to liberties if they are neighboring the 'group_set_after' + # i.e. if they will become liberties once capture is resolved + if len(captured_stones) > 0: + for (gx, gy) in group_set_after: + # intersection of group's neighbors and captured stones will become liberties + lib_set_after |= set(state._neighbors((gx, gy))) & captured_stones + if (x, y) in lib_set_after: + lib_set_after.remove((x, y)) + # check if this move resulted in atari + if len(lib_set_after) == 1: + group_size = len(group_set_after) + # 0th plane used for size=1, so group_size-1 is the index + planes[min(group_size - 1, maximum - 1), x, y] = 1 + return planes def get_liberties_after(state, maximum=8): - """A feature encoding what the number of liberties *would be* of the group connected to - the stone *if* played at a location - - Note: - - there is no zero-liberties plane; the 0th plane indicates groups in atari - - the [maximum-1] plane is used for any stone with liberties greater than or equal to maximum - - illegal move locations are all-zero features - """ - planes = np.zeros((maximum, state.size, state.size)) - # note - left as all zeros if not a legal move - for (x, y) in state.get_legal_moves(): - # make a copy of the set of liberties at (x,y) so we can add to it - lib_set_after = set(state.liberty_sets[x][y]) - group_set_after = set() - group_set_after.add((x, y)) - captured_stones = set() - for neighbor_group in state.get_groups_around((x, y)): - # if the neighboring group is of the same color as the current player - # then playing here will connect this stone to that group and - # therefore add in all that group's liberties - (gx, gy) = next(iter(neighbor_group)) - if state.board[gx, gy] == state.current_player: - lib_set_after |= state.liberty_sets[gx][gy] - group_set_after |= state.group_sets[gx][gy] - # if instead neighboring group is opponent *and about to be captured* - # then we might gain new liberties - elif state.liberty_counts[gx][gy] == 1: - captured_stones |= state.group_sets[gx][gy] - # add captured stones to liberties if they are neighboring the 'group_set_after' - # i.e. if they will become liberties once capture is resolved - if len(captured_stones) > 0: - for (gx, gy) in group_set_after: - # intersection of group's neighbors and captured stones will become liberties - lib_set_after |= set(state._neighbors((gx, gy))) & captured_stones - # (x,y) itself may have made its way back in, but shouldn't count - # since it's clearly not a liberty after playing there - if (x, y) in lib_set_after: - lib_set_after.remove((x, y)) - planes[min(maximum - 1, len(lib_set_after) - 1), x, y] = 1 - return planes + """A feature encoding what the number of liberties *would be* of the group connected to + the stone *if* played at a location + + Note: + - there is no zero-liberties plane; the 0th plane indicates groups in atari + - the [maximum-1] plane is used for any stone with liberties greater than or equal to maximum + - illegal move locations are all-zero features + """ + planes = np.zeros((maximum, state.size, state.size)) + # note - left as all zeros if not a legal move + for (x, y) in state.get_legal_moves(): + # make a copy of the set of liberties at (x,y) so we can add to it + lib_set_after = set(state.liberty_sets[x][y]) + group_set_after = set() + group_set_after.add((x, y)) + captured_stones = set() + for neighbor_group in state.get_groups_around((x, y)): + # if the neighboring group is of the same color as the current player + # then playing here will connect this stone to that group and + # therefore add in all that group's liberties + (gx, gy) = next(iter(neighbor_group)) + if state.board[gx, gy] == state.current_player: + lib_set_after |= state.liberty_sets[gx][gy] + group_set_after |= state.group_sets[gx][gy] + # if instead neighboring group is opponent *and about to be captured* + # then we might gain new liberties + elif state.liberty_counts[gx][gy] == 1: + captured_stones |= state.group_sets[gx][gy] + # add captured stones to liberties if they are neighboring the 'group_set_after' + # i.e. if they will become liberties once capture is resolved + if len(captured_stones) > 0: + for (gx, gy) in group_set_after: + # intersection of group's neighbors and captured stones will become liberties + lib_set_after |= set(state._neighbors((gx, gy))) & captured_stones + # (x,y) itself may have made its way back in, but shouldn't count + # since it's clearly not a liberty after playing there + if (x, y) in lib_set_after: + lib_set_after.remove((x, y)) + planes[min(maximum - 1, len(lib_set_after) - 1), x, y] = 1 + return planes def get_ladder_capture(state): - raise NotImplementedError() + raise NotImplementedError() def get_ladder_escape(state): - raise NotImplementedError() + raise NotImplementedError() def get_sensibleness(state): - """A move is 'sensible' if it is legal and if it does not fill the current_player's own eye - """ - feature = np.zeros((1, state.size, state.size)) - for (x, y) in state.get_legal_moves(include_eyes=False): - feature[0, x, y] = 1 - return feature + """A move is 'sensible' if it is legal and if it does not fill the current_player's own eye + """ + feature = np.zeros((1, state.size, state.size)) + for (x, y) in state.get_legal_moves(include_eyes=False): + feature[0, x, y] = 1 + return feature def get_legal(state): - """Zero at all illegal moves, one at all legal moves. Unlike sensibleness, no eye check is done - """ - feature = np.zeros((1, state.size, state.size)) - for (x, y) in state.get_legal_moves(): - feature[0, x, y] = 1 - return feature + """Zero at all illegal moves, one at all legal moves. Unlike sensibleness, no eye check is done + """ + feature = np.zeros((1, state.size, state.size)) + for (x, y) in state.get_legal_moves(): + feature[0, x, y] = 1 + return feature # named features and their sizes are defined here FEATURES = { - "board": { - "size": 3, - "function": get_board - }, - "ones": { - "size": 1, - "function": lambda state: np.ones((1, state.size, state.size)) - }, - "turns_since": { - "size": 8, - "function": get_turns_since - }, - "liberties": { - "size": 8, - "function": get_liberties - }, - "capture_size": { - "size": 8, - "function": get_capture_size - }, - "self_atari_size": { - "size": 8, - "function": get_self_atari_size - }, - "liberties_after": { - "size": 8, - "function": get_liberties_after - }, - "ladder_capture": { - "size": 1, - "function": get_ladder_capture - }, - "ladder_escape": { - "size": 1, - "function": get_ladder_escape - }, - "sensibleness": { - "size": 1, - "function": get_sensibleness - }, - "zeros": { - "size": 1, - "function": lambda state: np.zeros((1, state.size, state.size)) - }, - "legal": { - "size": 1, - "function": get_legal - } + "board": { + "size": 3, + "function": get_board + }, + "ones": { + "size": 1, + "function": lambda state: np.ones((1, state.size, state.size)) + }, + "turns_since": { + "size": 8, + "function": get_turns_since + }, + "liberties": { + "size": 8, + "function": get_liberties + }, + "capture_size": { + "size": 8, + "function": get_capture_size + }, + "self_atari_size": { + "size": 8, + "function": get_self_atari_size + }, + "liberties_after": { + "size": 8, + "function": get_liberties_after + }, + "ladder_capture": { + "size": 1, + "function": get_ladder_capture + }, + "ladder_escape": { + "size": 1, + "function": get_ladder_escape + }, + "sensibleness": { + "size": 1, + "function": get_sensibleness + }, + "zeros": { + "size": 1, + "function": lambda state: np.zeros((1, state.size, state.size)) + }, + "legal": { + "size": 1, + "function": get_legal + } } DEFAULT_FEATURES = [ - "board", "ones", "turns_since", "liberties", "capture_size", - "self_atari_size", "liberties_after", "ladder_capture", "ladder_escape", - "sensibleness", "zeros"] + "board", "ones", "turns_since", "liberties", "capture_size", + "self_atari_size", "liberties_after", "ladder_capture", "ladder_escape", + "sensibleness", "zeros"] class Preprocess(object): - """a class to convert from AlphaGo GameState objects to tensors of one-hot - features for NN inputs - """ - - def __init__(self, feature_list=DEFAULT_FEATURES): - """create a preprocessor object that will concatenate together the - given list of features - """ - - self.output_dim = 0 - self.feature_list = feature_list - self.processors = [None] * len(feature_list) - for i in range(len(feature_list)): - feat = feature_list[i].lower() - if feat in FEATURES: - self.processors[i] = FEATURES[feat]["function"] - self.output_dim += FEATURES[feat]["size"] - else: - raise ValueError("uknown feature: %s" % feat) - - def state_to_tensor(self, state): - """Convert a GameState to a Theano-compatible tensor - """ - feat_tensors = [proc(state) for proc in self.processors] - - # concatenate along feature dimension then add in a singleton 'batch' dimension - f, s = self.output_dim, state.size - return np.concatenate(feat_tensors).reshape((1, f, s, s)) + """a class to convert from AlphaGo GameState objects to tensors of one-hot + features for NN inputs + """ + + def __init__(self, feature_list=DEFAULT_FEATURES): + """create a preprocessor object that will concatenate together the + given list of features + """ + + self.output_dim = 0 + self.feature_list = feature_list + self.processors = [None] * len(feature_list) + for i in range(len(feature_list)): + feat = feature_list[i].lower() + if feat in FEATURES: + self.processors[i] = FEATURES[feat]["function"] + self.output_dim += FEATURES[feat]["size"] + else: + raise ValueError("uknown feature: %s" % feat) + + def state_to_tensor(self, state): + """Convert a GameState to a Theano-compatible tensor + """ + feat_tensors = [proc(state) for proc in self.processors] + + # concatenate along feature dimension then add in a singleton 'batch' dimension + f, s = self.output_dim, state.size + return np.concatenate(feat_tensors).reshape((1, f, s, s)) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index b4ab67771..8bf36cc94 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -12,277 +12,277 @@ class BatchedReinforcementLearningSGD(Optimizer): - '''A Keras Optimizer that sums gradients together for each game, applying them only once the - winner is known. - - It is the responsibility of the calling code to call set_current_game() before each example to - tell the optimizer for which game gradients should be accumulated, and to call set_result() to - tell the optimizer what the sign of the gradient for each game should be and when all games are - over. - - Arguments - lr: float >= 0. Learning rate. - ng: int > 0. Number of games played in parallel. Each one has its own cumulative gradient. - ''' - def __init__(self, lr=0.01, ng=20, **kwargs): - super(BatchedReinforcementLearningSGD, self).__init__(**kwargs) - self.__dict__.update(locals()) - self.lr = K.variable(lr) - self.cumulative_gradients = [] - self.num_games = ng - self.game_idx = K.variable(0) # which gradient to accumulate in the next batch. - self.gradient_sign = [K.variable(0) for _ in range(ng)] - self.running_games = K.variable(self.num_games) - - def set_current_game(self, game_idx): - K.set_value(self.game_idx, game_idx) - - def set_result(self, game_idx, won_game): - '''Mark the outcome of the game at index game_idx. Once all games are complete, updates - are automatically triggered in the next call to a keras fit function. - ''' - K.set_value(self.gradient_sign[game_idx], +1 if won_game else -1) - # Note: using '-= 1' would create a new variable, which would invalidate the dependencies - # in get_updates(). - K.set_value(self.running_games, K.get_value(self.running_games) - 1) - - def get_updates(self, params, constraints, loss): - # Note: get_updates is called *once* by keras. Its job is to return a set of 'update - # operations' to any K.variable (e.g. model weights or self.num_games). Updates are applied - # whenever Keras' train_function is evaluated, i.e. in every batch. Model.fit_on_batch() - # will trigger exactly one update. All updates use the 'old' value of parameters - there is - # no dependency on the order of the list of updates. - self.updates = [] - # Get expressions for gradients of model parameters. - grads = self.get_gradients(loss, params) - # Create a set of accumulated gradients, one for each game. - shapes = [K.get_variable_shape(p) for p in params] - self.cumulative_gradients = [[K.zeros(shape) for shape in shapes] for _ in range(self.num_games)] - - def conditional_update(cond, variable, new_value): - '''Helper function to create updates that only happen when cond is True. Writes to - self.updates and returns the new variable. - - Note: K.update(x, x) is cheap, but K.update_add(x, K.zeros_like(x)) can be expensive. - ''' - maybe_new_value = K.switch(cond, new_value, variable) - self.updates.append(K.update(variable, maybe_new_value)) - return maybe_new_value - - # Update cumulative gradient at index game_idx. This is done by returning an update for all - # gradients that is a no-op everywhere except for the game_idx'th one. When game_idx is - # changed by a call to set_current_game(), it will change the gradient that is getting - # accumulated. - # new_cumulative_gradients keeps references to the updated variables for use below in - # updating parameters with the freshly-accumulated gradients. - new_cumulative_gradients = [[None] * len(cgs) for cgs in self.cumulative_gradients] - for i, cgs in enumerate(self.cumulative_gradients): - for j, (g, cg) in enumerate(zip(grads, cgs)): - new_gradient = conditional_update(K.equal(self.game_idx, i), cg, cg + g) - new_cumulative_gradients[i][j] = new_gradient - - # Compute the net update to parameters, taking into account the sign of each cumulative - # gradient. - net_grads = [K.zeros_like(g) for g in grads] - for i, cgs in enumerate(new_cumulative_gradients): - for j, cg in enumerate(cgs): - net_grads[j] += self.gradient_sign[i] * cg - - # Trigger a full update when all games have finished. - self.trigger_update = K.lesser_equal(self.running_games, 0) - - # Update model parameters conditional on trigger_update. - for p, g in zip(params, net_grads): - new_p = p + g * self.lr - if p in constraints: - c = constraints[p] - new_p = c(new_p) - conditional_update(self.trigger_update, p, new_p) - - # 'reset' game counter and gradient signs when parameters are updated. - for sign in self.gradient_sign: - conditional_update(self.trigger_update, sign, K.variable(0)) - conditional_update(self.trigger_update, self.running_games, K.variable(self.num_games)) - return self.updates - - def get_config(self): - config = { - 'lr': float(K.get_value(self.lr)), - 'ng': self.num_games} - base_config = super(BatchedReinforcementLearningSGD, self).get_config() - return dict(list(base_config.items()) + list(config.items())) + '''A Keras Optimizer that sums gradients together for each game, applying them only once the + winner is known. + + It is the responsibility of the calling code to call set_current_game() before each example to + tell the optimizer for which game gradients should be accumulated, and to call set_result() to + tell the optimizer what the sign of the gradient for each game should be and when all games are + over. + + Arguments + lr: float >= 0. Learning rate. + ng: int > 0. Number of games played in parallel. Each one has its own cumulative gradient. + ''' + def __init__(self, lr=0.01, ng=20, **kwargs): + super(BatchedReinforcementLearningSGD, self).__init__(**kwargs) + self.__dict__.update(locals()) + self.lr = K.variable(lr) + self.cumulative_gradients = [] + self.num_games = ng + self.game_idx = K.variable(0) # which gradient to accumulate in the next batch. + self.gradient_sign = [K.variable(0) for _ in range(ng)] + self.running_games = K.variable(self.num_games) + + def set_current_game(self, game_idx): + K.set_value(self.game_idx, game_idx) + + def set_result(self, game_idx, won_game): + '''Mark the outcome of the game at index game_idx. Once all games are complete, updates + are automatically triggered in the next call to a keras fit function. + ''' + K.set_value(self.gradient_sign[game_idx], +1 if won_game else -1) + # Note: using '-= 1' would create a new variable, which would invalidate the dependencies + # in get_updates(). + K.set_value(self.running_games, K.get_value(self.running_games) - 1) + + def get_updates(self, params, constraints, loss): + # Note: get_updates is called *once* by keras. Its job is to return a set of 'update + # operations' to any K.variable (e.g. model weights or self.num_games). Updates are applied + # whenever Keras' train_function is evaluated, i.e. in every batch. Model.fit_on_batch() + # will trigger exactly one update. All updates use the 'old' value of parameters - there is + # no dependency on the order of the list of updates. + self.updates = [] + # Get expressions for gradients of model parameters. + grads = self.get_gradients(loss, params) + # Create a set of accumulated gradients, one for each game. + shapes = [K.get_variable_shape(p) for p in params] + self.cumulative_gradients = [[K.zeros(shape) for shape in shapes] for _ in range(self.num_games)] + + def conditional_update(cond, variable, new_value): + '''Helper function to create updates that only happen when cond is True. Writes to + self.updates and returns the new variable. + + Note: K.update(x, x) is cheap, but K.update_add(x, K.zeros_like(x)) can be expensive. + ''' + maybe_new_value = K.switch(cond, new_value, variable) + self.updates.append(K.update(variable, maybe_new_value)) + return maybe_new_value + + # Update cumulative gradient at index game_idx. This is done by returning an update for all + # gradients that is a no-op everywhere except for the game_idx'th one. When game_idx is + # changed by a call to set_current_game(), it will change the gradient that is getting + # accumulated. + # new_cumulative_gradients keeps references to the updated variables for use below in + # updating parameters with the freshly-accumulated gradients. + new_cumulative_gradients = [[None] * len(cgs) for cgs in self.cumulative_gradients] + for i, cgs in enumerate(self.cumulative_gradients): + for j, (g, cg) in enumerate(zip(grads, cgs)): + new_gradient = conditional_update(K.equal(self.game_idx, i), cg, cg + g) + new_cumulative_gradients[i][j] = new_gradient + + # Compute the net update to parameters, taking into account the sign of each cumulative + # gradient. + net_grads = [K.zeros_like(g) for g in grads] + for i, cgs in enumerate(new_cumulative_gradients): + for j, cg in enumerate(cgs): + net_grads[j] += self.gradient_sign[i] * cg + + # Trigger a full update when all games have finished. + self.trigger_update = K.lesser_equal(self.running_games, 0) + + # Update model parameters conditional on trigger_update. + for p, g in zip(params, net_grads): + new_p = p + g * self.lr + if p in constraints: + c = constraints[p] + new_p = c(new_p) + conditional_update(self.trigger_update, p, new_p) + + # 'reset' game counter and gradient signs when parameters are updated. + for sign in self.gradient_sign: + conditional_update(self.trigger_update, sign, K.variable(0)) + conditional_update(self.trigger_update, self.running_games, K.variable(self.num_games)) + return self.updates + + def get_config(self): + config = { + 'lr': float(K.get_value(self.lr)), + 'ng': self.num_games} + base_config = super(BatchedReinforcementLearningSGD, self).get_config() + return dict(list(base_config.items()) + list(config.items())) def _make_training_pair(st, mv, preprocessor): - # Convert move to one-hot - st_tensor = preprocessor.state_to_tensor(st) - mv_tensor = np.zeros((1, st.size * st.size)) - mv_tensor[(0, flatten_idx(mv, st.size))] = 1 - return (st_tensor, mv_tensor) + # Convert move to one-hot + st_tensor = preprocessor.state_to_tensor(st) + mv_tensor = np.zeros((1, st.size * st.size)) + mv_tensor[(0, flatten_idx(mv, st.size))] = 1 + return (st_tensor, mv_tensor) def run_n_games(optimizer, learner, opponent, num_games): - '''Run num_games games to completion, calling train_batch() on each position the learner sees. - - (Note: optimizer only accumulates gradients in its update function until all games have finished) - ''' - board_size = learner.policy.model.input_shape[-1] - states = [GameState(size=board_size) for _ in range(num_games)] - learner_net = learner.policy.model - - # Start all odd games with moves by 'opponent'. Even games will have 'learner' black. - learner_color = [go.BLACK if i % 2 == 0 else go.WHITE for i in range(num_games)] - odd_states = states[1::2] - moves = opponent.get_moves(odd_states) - for st, mv in zip(odd_states, moves): - st.do_move(mv) - - current = learner - other = opponent - # Need to keep track of the index of unfinished states so that we can communicate which one is - # being updated to the optimizer. - idxs_to_unfinished_states = {i: states[i] for i in range(num_games)} - while len(idxs_to_unfinished_states) > 0: - # Get next moves by current player for all unfinished states. - moves = current.get_moves(idxs_to_unfinished_states.values()) - just_finished = [] - # Do each move to each state in order. - for (idx, state), mv in zip(idxs_to_unfinished_states.iteritems(), moves): - # Order is important here. We must first get the training pair on the unmodified state. - # Next, the state is updated and checked to see if the game is over. If it is over, the - # optimizer is notified via set_result. Finally, train_on_batch is called, which - # will trigger an update of all parameters only if set_result() has been called - # for all games already (so set_result must come before train_on_batch). - is_learnable = current is learner and mv is not go.PASS_MOVE - if is_learnable: - (X, y) = _make_training_pair(state, mv, learner.policy.preprocessor) - state.do_move(mv) - if state.is_end_of_game: - learner_is_winner = state.get_winner() == learner_color[idx] - optimizer.set_result(idx, learner_is_winner) - just_finished.append(idx) - if is_learnable: - optimizer.set_current_game(idx) - learner_net.train_on_batch(X, y) - - # Remove games that have finished from dict. - for idx in just_finished: - del idxs_to_unfinished_states[idx] - - # Swap 'current' and 'other' for next turn. - current, other = other, current - - # Return the win ratio. - wins = sum(state.get_winner() == pc for (state, pc) in zip(states, learner_color)) - return float(wins) / num_games + '''Run num_games games to completion, calling train_batch() on each position the learner sees. + + (Note: optimizer only accumulates gradients in its update function until all games have finished) + ''' + board_size = learner.policy.model.input_shape[-1] + states = [GameState(size=board_size) for _ in range(num_games)] + learner_net = learner.policy.model + + # Start all odd games with moves by 'opponent'. Even games will have 'learner' black. + learner_color = [go.BLACK if i % 2 == 0 else go.WHITE for i in range(num_games)] + odd_states = states[1::2] + moves = opponent.get_moves(odd_states) + for st, mv in zip(odd_states, moves): + st.do_move(mv) + + current = learner + other = opponent + # Need to keep track of the index of unfinished states so that we can communicate which one is + # being updated to the optimizer. + idxs_to_unfinished_states = {i: states[i] for i in range(num_games)} + while len(idxs_to_unfinished_states) > 0: + # Get next moves by current player for all unfinished states. + moves = current.get_moves(idxs_to_unfinished_states.values()) + just_finished = [] + # Do each move to each state in order. + for (idx, state), mv in zip(idxs_to_unfinished_states.iteritems(), moves): + # Order is important here. We must first get the training pair on the unmodified state. + # Next, the state is updated and checked to see if the game is over. If it is over, the + # optimizer is notified via set_result. Finally, train_on_batch is called, which + # will trigger an update of all parameters only if set_result() has been called + # for all games already (so set_result must come before train_on_batch). + is_learnable = current is learner and mv is not go.PASS_MOVE + if is_learnable: + (X, y) = _make_training_pair(state, mv, learner.policy.preprocessor) + state.do_move(mv) + if state.is_end_of_game: + learner_is_winner = state.get_winner() == learner_color[idx] + optimizer.set_result(idx, learner_is_winner) + just_finished.append(idx) + if is_learnable: + optimizer.set_current_game(idx) + learner_net.train_on_batch(X, y) + + # Remove games that have finished from dict. + for idx in just_finished: + del idxs_to_unfinished_states[idx] + + # Swap 'current' and 'other' for next turn. + current, other = other, current + + # Return the win ratio. + wins = sum(state.get_winner() == pc for (state, pc) in zip(states, learner_color)) + return float(wins) / num_games def run_training(cmd_line_args=None): - import argparse - parser = argparse.ArgumentParser(description='Perform reinforcement learning to improve given policy network. Second phase of pipeline.') - parser.add_argument("model_json", help="Path to policy model JSON.") - parser.add_argument("initial_weights", help="Path to HDF5 file with inital weights (i.e. result of supervised training).") - parser.add_argument("out_directory", help="Path to folder where the model params and metadata will be saved after each epoch.") - parser.add_argument("--learning-rate", help="Keras learning rate (Default: 0.001)", type=float, default=0.001) - parser.add_argument("--policy-temp", help="Distribution temperature of players using policies (Default: 0.67)", type=float, default=0.67) - parser.add_argument("--save-every", help="Save policy as a new opponent every n batches (Default: 500)", type=int, default=500) - parser.add_argument("--game-batch", help="Number of games per mini-batch (Default: 20)", type=int, default=20) - parser.add_argument("--move-limit", help="Maximum number of moves per game", type=int, default=500) - parser.add_argument("--iterations", help="Number of training batches/iterations (Default: 10000)", type=int, default=10000) - parser.add_argument("--resume", help="Load latest weights in out_directory and resume", default=False, action="store_true") - parser.add_argument("--verbose", "-v", help="Turn on verbose mode", default=False, action="store_true") - # Baseline function (TODO) default lambda state: 0 (receives either file - # paths to JSON and weights or None, in which case it uses default baseline 0) - if cmd_line_args is None: - args = parser.parse_args() - else: - args = parser.parse_args(cmd_line_args) - - ZEROTH_FILE = "weights.00000.hdf5" - - if args.resume: - if not os.path.exists(os.path.join(args.out_directory, "metadata.json")): - raise ValueError("Cannot resume without existing output directory") - - if not os.path.exists(args.out_directory): - if args.verbose: - print "creating output directory {}".format(args.out_directory) - os.makedirs(args.out_directory) - - if not args.resume: - # make a copy of weights file, "weights.00000.hdf5" in the output directory - copyfile(args.initial_weights, os.path.join(args.out_directory, ZEROTH_FILE)) - if args.verbose: - print "copied {} to {}".format(args.initial_weights, os.path.join(args.out_directory, ZEROTH_FILE)) - player_weights = ZEROTH_FILE - else: - # if resuming, we expect initial_weights to be just a "weights.#####.hdf5" file, not a full path - args.initial_weights = os.path.join(args.out_directory, os.path.basename(args.initial_weights)) - if not os.path.exists(args.initial_weights): - raise ValueError("Cannot resume; weights {} do not exist".format(args.initial_weights)) - elif args.verbose: - print "Resuming with weights {}".format(args.initial_weights) - player_weights = os.path.basename(args.initial_weights) - - # Set initial conditions - policy = CNNPolicy.load_model(args.model_json) - policy.model.load_weights(args.initial_weights) - player = ProbabilisticPolicyPlayer(policy, temperature=args.policy_temp, move_limit=args.move_limit) - - # different opponents come from simply changing the weights of 'opponent.policy.model'. That - # is, only 'opp_policy' needs to be changed, and 'opponent' will change. - opp_policy = CNNPolicy.load_model(args.model_json) - opponent = ProbabilisticPolicyPlayer(opp_policy, temperature=args.policy_temp, move_limit=args.move_limit) - - if args.verbose: - print "created player and opponent with temperature {}".format(args.policy_temp) - - if not args.resume: - metadata = { - "model_file": args.model_json, - "init_weights": args.initial_weights, - "learning_rate": args.learning_rate, - "temperature": args.policy_temp, - "game_batch": args.game_batch, - "opponents": [ZEROTH_FILE], # which weights from which to sample an opponent each batch - "win_ratio": {} # map from player to tuple of (opponent, win ratio) Useful for validating in lieu of 'accuracy/loss' - } - else: - with open(os.path.join(args.out_directory, "metadata.json"), "r") as f: - metadata = json.load(f) - - # Append args of current run to history of full command args. - metadata["cmd_line_args"] = metadata.get("cmd_line_args", []).append(vars(args)) - - def save_metadata(): - with open(os.path.join(args.out_directory, "metadata.json"), "w") as f: - json.dump(metadata, f, sort_keys=True, indent=2) - - optimizer = BatchedReinforcementLearningSGD(lr=args.learning_rate, ng=args.game_batch) - player.policy.model.compile(loss='categorical_crossentropy', optimizer=optimizer) - for i_iter in xrange(1, args.iterations + 1): - # Randomly choose opponent from pool (possibly self), and playing game_batch games against - # them. - opp_weights = np.random.choice(metadata["opponents"]) - opp_path = os.path.join(args.out_directory, opp_weights) - - # Load new weights into opponent's network, but keep the same opponent object. - opponent.policy.model.load_weights(opp_path) - if args.verbose: - print "Batch {}\tsampled opponent is {}".format(i_iter, opp_weights) - - # Run games (and learn from results). Keep track of the win ratio vs each opponent over time. - win_ratio = run_n_games(optimizer, player, opponent, args.game_batch) - metadata["win_ratio"][player_weights] = (opp_weights, win_ratio) - - # Save all intermediate models. - player_weights = "weights.%05d.hdf5" % i_iter - player.policy.model.save_weights(os.path.join(args.out_directory, player_weights)) - - # Add player to batch of oppenents once in a while. - if i_iter % args.save_every == 0: - metadata["opponents"].append(player_weights) - save_metadata() + import argparse + parser = argparse.ArgumentParser(description='Perform reinforcement learning to improve given policy network. Second phase of pipeline.') + parser.add_argument("model_json", help="Path to policy model JSON.") + parser.add_argument("initial_weights", help="Path to HDF5 file with inital weights (i.e. result of supervised training).") + parser.add_argument("out_directory", help="Path to folder where the model params and metadata will be saved after each epoch.") + parser.add_argument("--learning-rate", help="Keras learning rate (Default: 0.001)", type=float, default=0.001) + parser.add_argument("--policy-temp", help="Distribution temperature of players using policies (Default: 0.67)", type=float, default=0.67) + parser.add_argument("--save-every", help="Save policy as a new opponent every n batches (Default: 500)", type=int, default=500) + parser.add_argument("--game-batch", help="Number of games per mini-batch (Default: 20)", type=int, default=20) + parser.add_argument("--move-limit", help="Maximum number of moves per game", type=int, default=500) + parser.add_argument("--iterations", help="Number of training batches/iterations (Default: 10000)", type=int, default=10000) + parser.add_argument("--resume", help="Load latest weights in out_directory and resume", default=False, action="store_true") + parser.add_argument("--verbose", "-v", help="Turn on verbose mode", default=False, action="store_true") + # Baseline function (TODO) default lambda state: 0 (receives either file + # paths to JSON and weights or None, in which case it uses default baseline 0) + if cmd_line_args is None: + args = parser.parse_args() + else: + args = parser.parse_args(cmd_line_args) + + ZEROTH_FILE = "weights.00000.hdf5" + + if args.resume: + if not os.path.exists(os.path.join(args.out_directory, "metadata.json")): + raise ValueError("Cannot resume without existing output directory") + + if not os.path.exists(args.out_directory): + if args.verbose: + print "creating output directory {}".format(args.out_directory) + os.makedirs(args.out_directory) + + if not args.resume: + # make a copy of weights file, "weights.00000.hdf5" in the output directory + copyfile(args.initial_weights, os.path.join(args.out_directory, ZEROTH_FILE)) + if args.verbose: + print "copied {} to {}".format(args.initial_weights, os.path.join(args.out_directory, ZEROTH_FILE)) + player_weights = ZEROTH_FILE + else: + # if resuming, we expect initial_weights to be just a "weights.#####.hdf5" file, not a full path + args.initial_weights = os.path.join(args.out_directory, os.path.basename(args.initial_weights)) + if not os.path.exists(args.initial_weights): + raise ValueError("Cannot resume; weights {} do not exist".format(args.initial_weights)) + elif args.verbose: + print "Resuming with weights {}".format(args.initial_weights) + player_weights = os.path.basename(args.initial_weights) + + # Set initial conditions + policy = CNNPolicy.load_model(args.model_json) + policy.model.load_weights(args.initial_weights) + player = ProbabilisticPolicyPlayer(policy, temperature=args.policy_temp, move_limit=args.move_limit) + + # different opponents come from simply changing the weights of 'opponent.policy.model'. That + # is, only 'opp_policy' needs to be changed, and 'opponent' will change. + opp_policy = CNNPolicy.load_model(args.model_json) + opponent = ProbabilisticPolicyPlayer(opp_policy, temperature=args.policy_temp, move_limit=args.move_limit) + + if args.verbose: + print "created player and opponent with temperature {}".format(args.policy_temp) + + if not args.resume: + metadata = { + "model_file": args.model_json, + "init_weights": args.initial_weights, + "learning_rate": args.learning_rate, + "temperature": args.policy_temp, + "game_batch": args.game_batch, + "opponents": [ZEROTH_FILE], # which weights from which to sample an opponent each batch + "win_ratio": {} # map from player to tuple of (opponent, win ratio) Useful for validating in lieu of 'accuracy/loss' + } + else: + with open(os.path.join(args.out_directory, "metadata.json"), "r") as f: + metadata = json.load(f) + + # Append args of current run to history of full command args. + metadata["cmd_line_args"] = metadata.get("cmd_line_args", []).append(vars(args)) + + def save_metadata(): + with open(os.path.join(args.out_directory, "metadata.json"), "w") as f: + json.dump(metadata, f, sort_keys=True, indent=2) + + optimizer = BatchedReinforcementLearningSGD(lr=args.learning_rate, ng=args.game_batch) + player.policy.model.compile(loss='categorical_crossentropy', optimizer=optimizer) + for i_iter in xrange(1, args.iterations + 1): + # Randomly choose opponent from pool (possibly self), and playing game_batch games against + # them. + opp_weights = np.random.choice(metadata["opponents"]) + opp_path = os.path.join(args.out_directory, opp_weights) + + # Load new weights into opponent's network, but keep the same opponent object. + opponent.policy.model.load_weights(opp_path) + if args.verbose: + print "Batch {}\tsampled opponent is {}".format(i_iter, opp_weights) + + # Run games (and learn from results). Keep track of the win ratio vs each opponent over time. + win_ratio = run_n_games(optimizer, player, opponent, args.game_batch) + metadata["win_ratio"][player_weights] = (opp_weights, win_ratio) + + # Save all intermediate models. + player_weights = "weights.%05d.hdf5" % i_iter + player.policy.model.save_weights(os.path.join(args.out_directory, player_weights)) + + # Add player to batch of oppenents once in a while. + if i_iter % args.save_every == 0: + metadata["opponents"].append(player_weights) + save_metadata() if __name__ == '__main__': - run_training() + run_training() diff --git a/AlphaGo/training/supervised_policy_trainer.py b/AlphaGo/training/supervised_policy_trainer.py index 7e04dd6e5..ec247812c 100644 --- a/AlphaGo/training/supervised_policy_trainer.py +++ b/AlphaGo/training/supervised_policy_trainer.py @@ -8,218 +8,218 @@ def one_hot_action(action, size=19): - """Convert an (x,y) action into a size x size array of zeros with a 1 at x,y - """ - categorical = np.zeros((size, size)) - categorical[action] = 1 - return categorical + """Convert an (x,y) action into a size x size array of zeros with a 1 at x,y + """ + categorical = np.zeros((size, size)) + categorical[action] = 1 + return categorical def shuffled_hdf5_batch_generator(state_dataset, action_dataset, indices, batch_size, transforms=[]): - """A generator of batches of training data for use with the fit_generator function - of Keras. Data is accessed in the order of the given indices for shuffling. - """ - state_batch_shape = (batch_size,) + state_dataset.shape[1:] - game_size = state_batch_shape[-1] - Xbatch = np.zeros(state_batch_shape) - Ybatch = np.zeros((batch_size, game_size * game_size)) - batch_idx = 0 - while True: - for data_idx in indices: - # choose a random transformation of the data (rotations/reflections of the board) - transform = np.random.choice(transforms) - # get state from dataset and transform it. - # loop comprehension is used so that the transformation acts on the 3rd and 4th dimensions - state = np.array([transform(plane) for plane in state_dataset[data_idx]]) - # must be cast to a tuple so that it is interpreted as (x,y) not [(x,:), (y,:)] - action_xy = tuple(action_dataset[data_idx]) - action = transform(one_hot_action(action_xy, game_size)) - Xbatch[batch_idx] = state - Ybatch[batch_idx] = action.flatten() - batch_idx += 1 - if batch_idx == batch_size: - batch_idx = 0 - yield (Xbatch, Ybatch) + """A generator of batches of training data for use with the fit_generator function + of Keras. Data is accessed in the order of the given indices for shuffling. + """ + state_batch_shape = (batch_size,) + state_dataset.shape[1:] + game_size = state_batch_shape[-1] + Xbatch = np.zeros(state_batch_shape) + Ybatch = np.zeros((batch_size, game_size * game_size)) + batch_idx = 0 + while True: + for data_idx in indices: + # choose a random transformation of the data (rotations/reflections of the board) + transform = np.random.choice(transforms) + # get state from dataset and transform it. + # loop comprehension is used so that the transformation acts on the 3rd and 4th dimensions + state = np.array([transform(plane) for plane in state_dataset[data_idx]]) + # must be cast to a tuple so that it is interpreted as (x,y) not [(x,:), (y,:)] + action_xy = tuple(action_dataset[data_idx]) + action = transform(one_hot_action(action_xy, game_size)) + Xbatch[batch_idx] = state + Ybatch[batch_idx] = action.flatten() + batch_idx += 1 + if batch_idx == batch_size: + batch_idx = 0 + yield (Xbatch, Ybatch) class MetadataWriterCallback(Callback): - def __init__(self, path): - self.file = path - self.metadata = { - "epochs": [], - "best_epoch": 0 - } + def __init__(self, path): + self.file = path + self.metadata = { + "epochs": [], + "best_epoch": 0 + } - def on_epoch_end(self, epoch, logs={}): - # in case appending to logs (resuming training), get epoch number ourselves - epoch = len(self.metadata["epochs"]) + def on_epoch_end(self, epoch, logs={}): + # in case appending to logs (resuming training), get epoch number ourselves + epoch = len(self.metadata["epochs"]) - self.metadata["epochs"].append(logs) + self.metadata["epochs"].append(logs) - if "val_loss" in logs: - key = "val_loss" - else: - key = "loss" + if "val_loss" in logs: + key = "val_loss" + else: + key = "loss" - best_loss = self.metadata["epochs"][self.metadata["best_epoch"]][key] - if logs.get(key) < best_loss: - self.metadata["best_epoch"] = epoch + best_loss = self.metadata["epochs"][self.metadata["best_epoch"]][key] + if logs.get(key) < best_loss: + self.metadata["best_epoch"] = epoch - with open(self.file, "w") as f: - json.dump(self.metadata, f, indent=2) + with open(self.file, "w") as f: + json.dump(self.metadata, f, indent=2) BOARD_TRANSFORMATIONS = { - "noop": lambda feature: feature, - "rot90": lambda feature: np.rot90(feature, 1), - "rot180": lambda feature: np.rot90(feature, 2), - "rot270": lambda feature: np.rot90(feature, 3), - "fliplr": lambda feature: np.fliplr(feature), - "flipud": lambda feature: np.flipud(feature), - "diag1": lambda feature: np.transpose(feature), - "diag2": lambda feature: np.fliplr(np.rot90(feature, 1)) + "noop": lambda feature: feature, + "rot90": lambda feature: np.rot90(feature, 1), + "rot180": lambda feature: np.rot90(feature, 2), + "rot270": lambda feature: np.rot90(feature, 3), + "fliplr": lambda feature: np.fliplr(feature), + "flipud": lambda feature: np.flipud(feature), + "diag1": lambda feature: np.transpose(feature), + "diag2": lambda feature: np.fliplr(np.rot90(feature, 1)) } def run_training(cmd_line_args=None): - """Run training. command-line args may be passed in as a list - """ - import argparse - parser = argparse.ArgumentParser(description='Perform supervised training on a policy network.') - # required args - parser.add_argument("model", help="Path to a JSON model file (i.e. from CNNPolicy.save_model())") - parser.add_argument("train_data", help="A .h5 file of training data") - parser.add_argument("out_directory", help="directory where metadata and weights will be saved") - # frequently used args - parser.add_argument("--minibatch", "-B", help="Size of training data minibatches. Default: 16", type=int, default=16) - parser.add_argument("--epochs", "-E", help="Total number of iterations on the data. Default: 10", type=int, default=10) - parser.add_argument("--epoch-length", "-l", help="Number of training examples considered 'one epoch'. Default: # training data", type=int, default=None) - parser.add_argument("--learning-rate", "-r", help="Learning rate - how quickly the model learns at first. Default: .03", type=float, default=.03) - parser.add_argument("--decay", "-d", help="The rate at which learning decreases. Default: .0001", type=float, default=.0001) - parser.add_argument("--verbose", "-v", help="Turn on verbose mode", default=False, action="store_true") - # slightly fancier args - parser.add_argument("--weights", help="Name of a .h5 weights file (in the output directory) to load to resume training", default=None) - parser.add_argument("--train-val-test", help="Fraction of data to use for training/val/test. Must sum to 1. Invalid if restarting training", nargs=3, type=float, default=[0.93, .05, .02]) - parser.add_argument("--symmetries", help="Comma-separated list of transforms, subset of noop,rot90,rot180,rot270,fliplr,flipud,diag1,diag2", default='noop,rot90,rot180,rot270,fliplr,flipud,diag1,diag2') - # TODO - an argument to specify which transformations to use, put it in metadata - - if cmd_line_args is None: - args = parser.parse_args() - else: - args = parser.parse_args(cmd_line_args) - - # TODO - what follows here should be refactored into a series of small functions - - resume = args.weights is not None - - if args.verbose: - if resume: - print "trying to resume from %s with weights %s" % (args.out_directory, os.path.join(args.out_directory, args.weights)) - else: - if os.path.exists(args.out_directory): - print "directory %s exists. any previous data will be overwritten" % args.out_directory - else: - print "starting fresh output directory %s" % args.out_directory - - # load model from json spec - model = CNNPolicy.load_model(args.model).model - if resume: - model.load_weights(os.path.join(args.out_directory, args.weights)) - - # TODO - (waiting on game_converter) verify that features of model match features of training data - dataset = h5.File(args.train_data) - n_total_data = len(dataset["states"]) - n_train_data = int(args.train_val_test[0] * n_total_data) - # Need to make sure training data is divisible by minibatch size or get warning mentioning accuracy from keras - n_train_data = n_train_data - (n_train_data % args.minibatch) - n_val_data = n_total_data - n_train_data - # n_test_data = n_total_data - (n_train_data + n_val_data) - - if args.verbose: - print "datset loaded" - print "\t%d total samples" % n_total_data - print "\t%d training samples" % n_train_data - print "\t%d validaion samples" % n_val_data - - # ensure output directory is available - if not os.path.exists(args.out_directory): - os.makedirs(args.out_directory) - - # create metadata file and the callback object that will write to it - meta_file = os.path.join(args.out_directory, "metadata.json") - meta_writer = MetadataWriterCallback(meta_file) - # load prior data if it already exists - if os.path.exists(meta_file) and resume: - with open(meta_file, "r") as f: - meta_writer.metadata = json.load(f) - if args.verbose: - print "previous metadata loaded: %d epochs. new epochs will be appended." % len(meta_writer.metadata["epochs"]) - elif args.verbose: - print "starting with empty metadata" - # the MetadataWriterCallback only sets 'epoch' and 'best_epoch'. We can add in anything else we like here - # TODO - model and train_data are saved in meta_file; check that they match (and make args optional when restarting?) - meta_writer.metadata["training_data"] = args.train_data - meta_writer.metadata["model_file"] = args.model - # Record all command line args in a list so that all args are recorded even when training is stopped and resumed. - meta_writer.metadata["cmd_line_args"] = meta_writer.metadata.get("cmd_line_args", []).append(vars(args)) - - # create ModelCheckpoint to save weights every epoch - checkpoint_template = os.path.join(args.out_directory, "weights.{epoch:05d}.hdf5") - checkpointer = ModelCheckpoint(checkpoint_template) - - # load precomputed random-shuffle indices or create them - # TODO - save each train/val/test indices separately so there's no danger of - # changing args.train_val_test when resuming - shuffle_file = os.path.join(args.out_directory, "shuffle.npz") - if os.path.exists(shuffle_file) and resume: - with open(shuffle_file, "r") as f: - shuffle_indices = np.load(f) - if args.verbose: - print "loading previous data shuffling indices" - else: - # create shuffled indices - shuffle_indices = np.random.permutation(n_total_data) - with open(shuffle_file, "w") as f: - np.save(f, shuffle_indices) - if args.verbose: - print "created new data shuffling indices" - # training indices are the first consecutive set of shuffled indices, val next, then test gets the remainder - train_indices = shuffle_indices[0:n_train_data] - val_indices = shuffle_indices[n_train_data:n_train_data + n_val_data] - # test_indices = shuffle_indices[n_train_data + n_val_data:] - - symmetries = [BOARD_TRANSFORMATIONS[name] for name in args.symmetries.strip().split(",")] - - # create dataset generators - train_data_generator = shuffled_hdf5_batch_generator( - dataset["states"], - dataset["actions"], - train_indices, - args.minibatch, - symmetries) - val_data_generator = shuffled_hdf5_batch_generator( - dataset["states"], - dataset["actions"], - val_indices, - args.minibatch, - symmetries) - - sgd = SGD(lr=args.learning_rate, decay=args.decay) - model.compile(loss='categorical_crossentropy', optimizer=sgd, metrics=["accuracy"]) - - samples_per_epoch = args.epoch_length or n_train_data - - if args.verbose: - print "STARTING TRAINING" - - model.fit_generator( - generator=train_data_generator, - samples_per_epoch=samples_per_epoch, - nb_epoch=args.epochs, - callbacks=[checkpointer, meta_writer], - validation_data=val_data_generator, - nb_val_samples=n_val_data) + """Run training. command-line args may be passed in as a list + """ + import argparse + parser = argparse.ArgumentParser(description='Perform supervised training on a policy network.') + # required args + parser.add_argument("model", help="Path to a JSON model file (i.e. from CNNPolicy.save_model())") + parser.add_argument("train_data", help="A .h5 file of training data") + parser.add_argument("out_directory", help="directory where metadata and weights will be saved") + # frequently used args + parser.add_argument("--minibatch", "-B", help="Size of training data minibatches. Default: 16", type=int, default=16) + parser.add_argument("--epochs", "-E", help="Total number of iterations on the data. Default: 10", type=int, default=10) + parser.add_argument("--epoch-length", "-l", help="Number of training examples considered 'one epoch'. Default: # training data", type=int, default=None) + parser.add_argument("--learning-rate", "-r", help="Learning rate - how quickly the model learns at first. Default: .03", type=float, default=.03) + parser.add_argument("--decay", "-d", help="The rate at which learning decreases. Default: .0001", type=float, default=.0001) + parser.add_argument("--verbose", "-v", help="Turn on verbose mode", default=False, action="store_true") + # slightly fancier args + parser.add_argument("--weights", help="Name of a .h5 weights file (in the output directory) to load to resume training", default=None) + parser.add_argument("--train-val-test", help="Fraction of data to use for training/val/test. Must sum to 1. Invalid if restarting training", nargs=3, type=float, default=[0.93, .05, .02]) + parser.add_argument("--symmetries", help="Comma-separated list of transforms, subset of noop,rot90,rot180,rot270,fliplr,flipud,diag1,diag2", default='noop,rot90,rot180,rot270,fliplr,flipud,diag1,diag2') + # TODO - an argument to specify which transformations to use, put it in metadata + + if cmd_line_args is None: + args = parser.parse_args() + else: + args = parser.parse_args(cmd_line_args) + + # TODO - what follows here should be refactored into a series of small functions + + resume = args.weights is not None + + if args.verbose: + if resume: + print "trying to resume from %s with weights %s" % (args.out_directory, os.path.join(args.out_directory, args.weights)) + else: + if os.path.exists(args.out_directory): + print "directory %s exists. any previous data will be overwritten" % args.out_directory + else: + print "starting fresh output directory %s" % args.out_directory + + # load model from json spec + model = CNNPolicy.load_model(args.model).model + if resume: + model.load_weights(os.path.join(args.out_directory, args.weights)) + + # TODO - (waiting on game_converter) verify that features of model match features of training data + dataset = h5.File(args.train_data) + n_total_data = len(dataset["states"]) + n_train_data = int(args.train_val_test[0] * n_total_data) + # Need to make sure training data is divisible by minibatch size or get warning mentioning accuracy from keras + n_train_data = n_train_data - (n_train_data % args.minibatch) + n_val_data = n_total_data - n_train_data + # n_test_data = n_total_data - (n_train_data + n_val_data) + + if args.verbose: + print "datset loaded" + print "\t%d total samples" % n_total_data + print "\t%d training samples" % n_train_data + print "\t%d validaion samples" % n_val_data + + # ensure output directory is available + if not os.path.exists(args.out_directory): + os.makedirs(args.out_directory) + + # create metadata file and the callback object that will write to it + meta_file = os.path.join(args.out_directory, "metadata.json") + meta_writer = MetadataWriterCallback(meta_file) + # load prior data if it already exists + if os.path.exists(meta_file) and resume: + with open(meta_file, "r") as f: + meta_writer.metadata = json.load(f) + if args.verbose: + print "previous metadata loaded: %d epochs. new epochs will be appended." % len(meta_writer.metadata["epochs"]) + elif args.verbose: + print "starting with empty metadata" + # the MetadataWriterCallback only sets 'epoch' and 'best_epoch'. We can add in anything else we like here + # TODO - model and train_data are saved in meta_file; check that they match (and make args optional when restarting?) + meta_writer.metadata["training_data"] = args.train_data + meta_writer.metadata["model_file"] = args.model + # Record all command line args in a list so that all args are recorded even when training is stopped and resumed. + meta_writer.metadata["cmd_line_args"] = meta_writer.metadata.get("cmd_line_args", []).append(vars(args)) + + # create ModelCheckpoint to save weights every epoch + checkpoint_template = os.path.join(args.out_directory, "weights.{epoch:05d}.hdf5") + checkpointer = ModelCheckpoint(checkpoint_template) + + # load precomputed random-shuffle indices or create them + # TODO - save each train/val/test indices separately so there's no danger of + # changing args.train_val_test when resuming + shuffle_file = os.path.join(args.out_directory, "shuffle.npz") + if os.path.exists(shuffle_file) and resume: + with open(shuffle_file, "r") as f: + shuffle_indices = np.load(f) + if args.verbose: + print "loading previous data shuffling indices" + else: + # create shuffled indices + shuffle_indices = np.random.permutation(n_total_data) + with open(shuffle_file, "w") as f: + np.save(f, shuffle_indices) + if args.verbose: + print "created new data shuffling indices" + # training indices are the first consecutive set of shuffled indices, val next, then test gets the remainder + train_indices = shuffle_indices[0:n_train_data] + val_indices = shuffle_indices[n_train_data:n_train_data + n_val_data] + # test_indices = shuffle_indices[n_train_data + n_val_data:] + + symmetries = [BOARD_TRANSFORMATIONS[name] for name in args.symmetries.strip().split(",")] + + # create dataset generators + train_data_generator = shuffled_hdf5_batch_generator( + dataset["states"], + dataset["actions"], + train_indices, + args.minibatch, + symmetries) + val_data_generator = shuffled_hdf5_batch_generator( + dataset["states"], + dataset["actions"], + val_indices, + args.minibatch, + symmetries) + + sgd = SGD(lr=args.learning_rate, decay=args.decay) + model.compile(loss='categorical_crossentropy', optimizer=sgd, metrics=["accuracy"]) + + samples_per_epoch = args.epoch_length or n_train_data + + if args.verbose: + print "STARTING TRAINING" + + model.fit_generator( + generator=train_data_generator, + samples_per_epoch=samples_per_epoch, + nb_epoch=args.epochs, + callbacks=[checkpointer, meta_writer], + validation_data=val_data_generator, + nb_val_samples=n_val_data) if __name__ == '__main__': - run_training() + run_training() diff --git a/AlphaGo/util.py b/AlphaGo/util.py index b801a397f..6acb5062d 100644 --- a/AlphaGo/util.py +++ b/AlphaGo/util.py @@ -8,116 +8,116 @@ def flatten_idx(position, size): - (x, y) = position - return x * size + y + (x, y) = position + return x * size + y def unflatten_idx(idx, size): - x, y = divmod(idx, size) - return (x, y) + x, y = divmod(idx, size) + return (x, y) def _parse_sgf_move(node_value): - """Given a well-formed move string, return either PASS_MOVE or the (x, y) position - """ - if node_value == '' or node_value == 'tt': - return go.PASS_MOVE - else: - # GameState expects (x, y) where x is column and y is row - col = LETTERS.index(node_value[0].upper()) - row = LETTERS.index(node_value[1].upper()) - return (col, row) + """Given a well-formed move string, return either PASS_MOVE or the (x, y) position + """ + if node_value == '' or node_value == 'tt': + return go.PASS_MOVE + else: + # GameState expects (x, y) where x is column and y is row + col = LETTERS.index(node_value[0].upper()) + row = LETTERS.index(node_value[1].upper()) + return (col, row) def _sgf_init_gamestate(sgf_root): - """Helper function to set up a GameState object from the root node - of an SGF file - """ - props = sgf_root.properties - s_size = props.get('SZ', ['19'])[0] - s_player = props.get('PL', ['B'])[0] - # init board with specified size - gs = go.GameState(int(s_size)) - # handle 'add black' property - if 'AB' in props: - for stone in props['AB']: - gs.do_move(_parse_sgf_move(stone), go.BLACK) - # handle 'add white' property - if 'AW' in props: - for stone in props['AW']: - gs.do_move(_parse_sgf_move(stone), go.WHITE) - # setup done; set player according to 'PL' property - gs.current_player = go.BLACK if s_player == 'B' else go.WHITE - return gs + """Helper function to set up a GameState object from the root node + of an SGF file + """ + props = sgf_root.properties + s_size = props.get('SZ', ['19'])[0] + s_player = props.get('PL', ['B'])[0] + # init board with specified size + gs = go.GameState(int(s_size)) + # handle 'add black' property + if 'AB' in props: + for stone in props['AB']: + gs.do_move(_parse_sgf_move(stone), go.BLACK) + # handle 'add white' property + if 'AW' in props: + for stone in props['AW']: + gs.do_move(_parse_sgf_move(stone), go.WHITE) + # setup done; set player according to 'PL' property + gs.current_player = go.BLACK if s_player == 'B' else go.WHITE + return gs def sgf_to_gamestate(sgf_string): - """Creates a GameState object from the first game in the given collection - """ - # Don't Repeat Yourself; parsing handled by sgf_iter_states - for (gs, move, player) in sgf_iter_states(sgf_string, True): - pass - # gs has been updated in-place to the final state by the time - # sgf_iter_states returns - return gs + """Creates a GameState object from the first game in the given collection + """ + # Don't Repeat Yourself; parsing handled by sgf_iter_states + for (gs, move, player) in sgf_iter_states(sgf_string, True): + pass + # gs has been updated in-place to the final state by the time + # sgf_iter_states returns + return gs def save_gamestate_to_sgf(gamestate, path, filename, black_player_name='Unknown', white_player_name='Unknown', size=19, komi=7.5): - """Creates a simplified sgf for viewing playouts or positions - """ - str_list = [] - # Game info - str_list.append('(;GM[1]FF[4]CA[UTF-8]') - str_list.append('SZ[{}]'.format(size)) - str_list.append('KM[{}]'.format(komi)) - str_list.append('PB[{}]'.format(black_player_name)) - str_list.append('PW[{}]'.format(white_player_name)) - cycle_string = 'BW' - # Handle handicaps - if len(gamestate.handicaps) > 0: - cycle_string = 'WB' - str_list.append('HA[{}]'.format(len(gamestate.handicaps))) - str_list.append(';AB') - for handicap in gamestate.handicaps: - str_list.append('[{}{}]'.format(LETTERS[handicap[0]].lower(), LETTERS[handicap[1]].lower())) - # Move list - for move, color in zip(gamestate.history, itertools.cycle(cycle_string)): - # Move color prefix - str_list.append(';{}'.format(color)) - # Move coordinates - if move is None: - str_list.append('[tt]') - else: - str_list.append('[{}{}]'.format(LETTERS[move[0]].lower(), LETTERS[move[1]].lower())) - str_list.append(')') - with open(os.path.join(path, filename), "w") as f: - f.write(''.join(str_list)) + """Creates a simplified sgf for viewing playouts or positions + """ + str_list = [] + # Game info + str_list.append('(;GM[1]FF[4]CA[UTF-8]') + str_list.append('SZ[{}]'.format(size)) + str_list.append('KM[{}]'.format(komi)) + str_list.append('PB[{}]'.format(black_player_name)) + str_list.append('PW[{}]'.format(white_player_name)) + cycle_string = 'BW' + # Handle handicaps + if len(gamestate.handicaps) > 0: + cycle_string = 'WB' + str_list.append('HA[{}]'.format(len(gamestate.handicaps))) + str_list.append(';AB') + for handicap in gamestate.handicaps: + str_list.append('[{}{}]'.format(LETTERS[handicap[0]].lower(), LETTERS[handicap[1]].lower())) + # Move list + for move, color in zip(gamestate.history, itertools.cycle(cycle_string)): + # Move color prefix + str_list.append(';{}'.format(color)) + # Move coordinates + if move is None: + str_list.append('[tt]') + else: + str_list.append('[{}{}]'.format(LETTERS[move[0]].lower(), LETTERS[move[1]].lower())) + str_list.append(')') + with open(os.path.join(path, filename), "w") as f: + f.write(''.join(str_list)) def sgf_iter_states(sgf_string, include_end=True): - """Iterates over (GameState, move, player) tuples in the first game of the given SGF file. - - Ignores variations - only the main line is returned. - The state object is modified in-place, so don't try to, for example, keep track of it through time - - If include_end is False, the final tuple yielded is the penultimate state, but the state - will still be left in the final position at the end of iteration because 'gs' is modified - in-place the state. See sgf_to_gamestate - """ - collection = sgf.parse(sgf_string) - game = collection[0] - gs = _sgf_init_gamestate(game.root) - if game.rest is not None: - for node in game.rest: - props = node.properties - if 'W' in props: - move = _parse_sgf_move(props['W'][0]) - player = go.WHITE - elif 'B' in props: - move = _parse_sgf_move(props['B'][0]) - player = go.BLACK - yield (gs, move, player) - # update state to n+1 - gs.do_move(move, player) - if include_end: - yield (gs, None, None) + """Iterates over (GameState, move, player) tuples in the first game of the given SGF file. + + Ignores variations - only the main line is returned. + The state object is modified in-place, so don't try to, for example, keep track of it through time + + If include_end is False, the final tuple yielded is the penultimate state, but the state + will still be left in the final position at the end of iteration because 'gs' is modified + in-place the state. See sgf_to_gamestate + """ + collection = sgf.parse(sgf_string) + game = collection[0] + gs = _sgf_init_gamestate(game.root) + if game.rest is not None: + for node in game.rest: + props = node.properties + if 'W' in props: + move = _parse_sgf_move(props['W'][0]) + player = go.WHITE + elif 'B' in props: + move = _parse_sgf_move(props['B'][0]) + player = go.BLACK + yield (gs, move, player) + # update state to n+1 + gs.do_move(move, player) + if include_end: + yield (gs, None, None) diff --git a/benchmarks/preprocessing_benchmark.py b/benchmarks/preprocessing_benchmark.py index 922c681ce..635c0fd55 100644 --- a/benchmarks/preprocessing_benchmark.py +++ b/benchmarks/preprocessing_benchmark.py @@ -9,8 +9,8 @@ def run_convert_game(): - for traindata in gc.convert_game(*args): - pass + for traindata in gc.convert_game(*args): + pass prof.runcall(run_convert_game) prof.dump_stats('bench_results.prof') diff --git a/benchmarks/reinforcement_policy_training_benchmark.py b/benchmarks/reinforcement_policy_training_benchmark.py index c016a42ec..4fc66407c 100644 --- a/benchmarks/reinforcement_policy_training_benchmark.py +++ b/benchmarks/reinforcement_policy_training_benchmark.py @@ -15,9 +15,9 @@ stats_file = os.path.join(datadir, 'reinforcement_policy_trainer.prof') if not os.path.exists(datadir): - os.makedirs(datadir) + os.makedirs(datadir) if not os.path.exists(weights): - policy.model.save_weights(weights) + policy.model.save_weights(weights) policy.save_model(modelfile) profile = Profile() diff --git a/benchmarks/supervised_policy_training_benchmark.py b/benchmarks/supervised_policy_training_benchmark.py index 0293f7af9..d84676611 100644 --- a/benchmarks/supervised_policy_training_benchmark.py +++ b/benchmarks/supervised_policy_training_benchmark.py @@ -14,7 +14,7 @@ def run_supervised_policy_training(): - run_training(*arguments) + run_training(*arguments) profile.runcall(run_supervised_policy_training) profile.dump_stats('supervised_policy_training_bench_results.prof') diff --git a/interface/Play.py b/interface/Play.py index 57d750cc9..58e3fe56d 100644 --- a/interface/Play.py +++ b/interface/Play.py @@ -3,32 +3,32 @@ class play_match(object): - """Interface to handle play between two players.""" - def __init__(self, player1, player2, save_dir=None, size=19): - # super(ClassName, self).__init__() - self.player1 = player1 - self.player2 = player2 - self.state = GameState(size=size) - # I Propose that GameState should take a top-level save directory, - # then automatically generate the specific file name + """Interface to handle play between two players.""" + def __init__(self, player1, player2, save_dir=None, size=19): + # super(ClassName, self).__init__() + self.player1 = player1 + self.player2 = player2 + self.state = GameState(size=size) + # I Propose that GameState should take a top-level save directory, + # then automatically generate the specific file name - def _play(self, player): - move = player.get_move(self.state) - # TODO: Fix is_eye? - self.state.do_move(move) # Return max prob sensible legal move - # self.state.write_to_disk() - if len(self.state.history) > 1: - if self.state.history[-1] is None and self.state.history[-2] is None \ - and self.state.current_player == -1: - end_of_game = True - else: - end_of_game = False - else: - end_of_game = False - return end_of_game + def _play(self, player): + move = player.get_move(self.state) + # TODO: Fix is_eye? + self.state.do_move(move) # Return max prob sensible legal move + # self.state.write_to_disk() + if len(self.state.history) > 1: + if self.state.history[-1] is None and self.state.history[-2] is None \ + and self.state.current_player == -1: + end_of_game = True + else: + end_of_game = False + else: + end_of_game = False + return end_of_game - def play(self): - """Play one turn, update game state, save to disk""" - end_of_game = self._play(self.player1) - # This is incorrect. - return end_of_game + def play(self): + """Play one turn, update game state, save to disk""" + end_of_game = self._play(self.player1) + # This is incorrect. + return end_of_game diff --git a/interface/gtp_wrapper.py b/interface/gtp_wrapper.py index 437aa4ff8..89d35f242 100644 --- a/interface/gtp_wrapper.py +++ b/interface/gtp_wrapper.py @@ -6,148 +6,148 @@ def run_gnugo(sgf_file_name, command): - from distutils import spawn - if spawn.find_executable('gnugo'): - from subprocess import Popen, PIPE - p = Popen(['gnugo', '--chinese-rules', '--mode', 'gtp', '-l', sgf_file_name], stdout=PIPE, stdin=PIPE, stderr=PIPE) - out_bytes = p.communicate(input=command)[0] - return out_bytes.decode('utf-8')[2:] - else: - return '' + from distutils import spawn + if spawn.find_executable('gnugo'): + from subprocess import Popen, PIPE + p = Popen(['gnugo', '--chinese-rules', '--mode', 'gtp', '-l', sgf_file_name], stdout=PIPE, stdin=PIPE, stderr=PIPE) + out_bytes = p.communicate(input=command)[0] + return out_bytes.decode('utf-8')[2:] + else: + return '' class ExtendedGtpEngine(gtp.Engine): - recommended_handicaps = { - 2: "D4 Q16", - 3: "D4 Q16 D16", - 4: "D4 Q16 D16 Q4", - 5: "D4 Q16 D16 Q4 K10", - 6: "D4 Q16 D16 Q4 D10 Q10", - 7: "D4 Q16 D16 Q4 D10 Q10 K10", - 8: "D4 Q16 D16 Q4 D10 Q10 K4 K16", - 9: "D4 Q16 D16 Q4 D10 Q10 K4 K16 K10" - } - - def call_gnugo(self, sgf_file_name, command): - try: - pool = multiprocessing.Pool(processes=1) - result = pool.apply_async(run_gnugo, (sgf_file_name, command)) - output = result.get(timeout=10) - pool.close() - return output - except multiprocessing.TimeoutError: - pool.terminate() - # if can't get answer from GnuGo, return no result - return '' - - def cmd_time_left(self, arguments): - pass - - def cmd_place_free_handicap(self, arguments): - try: - number_of_stones = int(arguments) - except Exception: - raise ValueError('Number of handicaps could not be parsed: {}'.format(arguments)) - if number_of_stones < 2 or number_of_stones > 9: - raise ValueError('Invalid number of handicap stones: {}'.format(number_of_stones)) - vertex_string = ExtendedGtpEngine.recommended_handicaps[number_of_stones] - self.cmd_set_free_handicap(vertex_string) - return vertex_string - - def cmd_set_free_handicap(self, arguments): - vertices = arguments.strip().split() - moves = [gtp.parse_vertex(vertex) for vertex in vertices] - self._game.place_handicaps(moves) - - def cmd_final_score(self, arguments): - sgf_file_name = self._game.get_current_state_as_sgf() - return self.call_gnugo(sgf_file_name, 'final_score\n') - - def cmd_final_status_list(self, arguments): - sgf_file_name = self._game.get_current_state_as_sgf() - return self.call_gnugo(sgf_file_name, 'final_status_list {}\n'.format(arguments)) - - def cmd_load_sgf(self, arguments): - pass - - def cmd_save_sgf(self, arguments): - pass - - # def cmd_kgs_genmove_cleanup(self, arguments): - # return self.cmd_genmove(arguments) + recommended_handicaps = { + 2: "D4 Q16", + 3: "D4 Q16 D16", + 4: "D4 Q16 D16 Q4", + 5: "D4 Q16 D16 Q4 K10", + 6: "D4 Q16 D16 Q4 D10 Q10", + 7: "D4 Q16 D16 Q4 D10 Q10 K10", + 8: "D4 Q16 D16 Q4 D10 Q10 K4 K16", + 9: "D4 Q16 D16 Q4 D10 Q10 K4 K16 K10" + } + + def call_gnugo(self, sgf_file_name, command): + try: + pool = multiprocessing.Pool(processes=1) + result = pool.apply_async(run_gnugo, (sgf_file_name, command)) + output = result.get(timeout=10) + pool.close() + return output + except multiprocessing.TimeoutError: + pool.terminate() + # if can't get answer from GnuGo, return no result + return '' + + def cmd_time_left(self, arguments): + pass + + def cmd_place_free_handicap(self, arguments): + try: + number_of_stones = int(arguments) + except Exception: + raise ValueError('Number of handicaps could not be parsed: {}'.format(arguments)) + if number_of_stones < 2 or number_of_stones > 9: + raise ValueError('Invalid number of handicap stones: {}'.format(number_of_stones)) + vertex_string = ExtendedGtpEngine.recommended_handicaps[number_of_stones] + self.cmd_set_free_handicap(vertex_string) + return vertex_string + + def cmd_set_free_handicap(self, arguments): + vertices = arguments.strip().split() + moves = [gtp.parse_vertex(vertex) for vertex in vertices] + self._game.place_handicaps(moves) + + def cmd_final_score(self, arguments): + sgf_file_name = self._game.get_current_state_as_sgf() + return self.call_gnugo(sgf_file_name, 'final_score\n') + + def cmd_final_status_list(self, arguments): + sgf_file_name = self._game.get_current_state_as_sgf() + return self.call_gnugo(sgf_file_name, 'final_status_list {}\n'.format(arguments)) + + def cmd_load_sgf(self, arguments): + pass + + def cmd_save_sgf(self, arguments): + pass + + # def cmd_kgs_genmove_cleanup(self, arguments): + # return self.cmd_genmove(arguments) class GTPGameConnector(object): - """A class implementing the functions of a 'game' object required by the GTP - Engine by wrapping a GameState and Player instance - """ - - def __init__(self, player): - self._state = go.GameState(enforce_superko=True) - self._player = player - - def clear(self): - self._state = go.GameState(self._state.size, enforce_superko=True) - - def make_move(self, color, vertex): - # vertex in GTP language is 1-indexed, whereas GameState's are zero-indexed - try: - if vertex == gtp.PASS: - self._state.do_move(go.PASS_MOVE) - else: - (x, y) = vertex - self._state.do_move((x - 1, y - 1), color) - return True - except go.IllegalMove: - return False - - def set_size(self, n): - self._state = go.GameState(n, enforce_superko=True) - - def set_komi(self, k): - self._state.komi = k - - def get_move(self, color): - self._state.current_player = color - move = self._player.get_move(self._state) - if move == go.PASS_MOVE: - return gtp.PASS - else: - (x, y) = move - return (x + 1, y + 1) - - def get_current_state_as_sgf(self): - from tempfile import NamedTemporaryFile - temp_file = NamedTemporaryFile(delete=False) - save_gamestate_to_sgf(self._state, '', temp_file.name) - return temp_file.name - - def place_handicaps(self, vertices): - actions = [] - for vertex in vertices: - (x, y) = vertex - actions.append((x - 1, y - 1)) - self._state.place_handicaps(actions) + """A class implementing the functions of a 'game' object required by the GTP + Engine by wrapping a GameState and Player instance + """ + + def __init__(self, player): + self._state = go.GameState(enforce_superko=True) + self._player = player + + def clear(self): + self._state = go.GameState(self._state.size, enforce_superko=True) + + def make_move(self, color, vertex): + # vertex in GTP language is 1-indexed, whereas GameState's are zero-indexed + try: + if vertex == gtp.PASS: + self._state.do_move(go.PASS_MOVE) + else: + (x, y) = vertex + self._state.do_move((x - 1, y - 1), color) + return True + except go.IllegalMove: + return False + + def set_size(self, n): + self._state = go.GameState(n, enforce_superko=True) + + def set_komi(self, k): + self._state.komi = k + + def get_move(self, color): + self._state.current_player = color + move = self._player.get_move(self._state) + if move == go.PASS_MOVE: + return gtp.PASS + else: + (x, y) = move + return (x + 1, y + 1) + + def get_current_state_as_sgf(self): + from tempfile import NamedTemporaryFile + temp_file = NamedTemporaryFile(delete=False) + save_gamestate_to_sgf(self._state, '', temp_file.name) + return temp_file.name + + def place_handicaps(self, vertices): + actions = [] + for vertex in vertices: + (x, y) = vertex + actions.append((x - 1, y - 1)) + self._state.place_handicaps(actions) def run_gtp(player_obj, inpt_fn=None, name="Gtp Player", version="0.0"): - gtp_game = GTPGameConnector(player_obj) - gtp_engine = ExtendedGtpEngine(gtp_game, name, version) - if inpt_fn is None: - inpt_fn = raw_input - - sys.stderr.write("GTP engine ready\n") - sys.stderr.flush() - while not gtp_engine.disconnect: - inpt = inpt_fn() - # handle either single lines at a time - # or multiple commands separated by '\n' - try: - cmd_list = inpt.split("\n") - except: - cmd_list = [inpt] - for cmd in cmd_list: - engine_reply = gtp_engine.send(cmd) - sys.stdout.write(engine_reply) - sys.stdout.flush() + gtp_game = GTPGameConnector(player_obj) + gtp_engine = ExtendedGtpEngine(gtp_game, name, version) + if inpt_fn is None: + inpt_fn = raw_input + + sys.stderr.write("GTP engine ready\n") + sys.stderr.flush() + while not gtp_engine.disconnect: + inpt = inpt_fn() + # handle either single lines at a time + # or multiple commands separated by '\n' + try: + cmd_list = inpt.split("\n") + except: + cmd_list = [inpt] + for cmd in cmd_list: + engine_reply = gtp_engine.send(cmd) + sys.stdout.write(engine_reply) + sys.stdout.flush() diff --git a/tests/test_game_converter.py b/tests/test_game_converter.py index 0d75c11e5..e0f4963f7 100644 --- a/tests/test_game_converter.py +++ b/tests/test_game_converter.py @@ -5,22 +5,22 @@ class TestSGFLoading(unittest.TestCase): - def test_ab_aw(self): - with open('tests/test_data/sgf/ab_aw.sgf', 'r') as f: - sgf_to_gamestate(f.read()) + def test_ab_aw(self): + with open('tests/test_data/sgf/ab_aw.sgf', 'r') as f: + sgf_to_gamestate(f.read()) class TestCmdlineConverter(unittest.TestCase): - def test_directory_conversion(self): - args = ['--features', 'board,ones,turns_since', '--outfile', '.tmp.testing.h5', '--directory', 'tests/test_data/sgf/'] - run_game_converter(args) - os.remove('.tmp.testing.h5') + def test_directory_conversion(self): + args = ['--features', 'board,ones,turns_since', '--outfile', '.tmp.testing.h5', '--directory', 'tests/test_data/sgf/'] + run_game_converter(args) + os.remove('.tmp.testing.h5') - def test_directory_walk(self): - args = ['--features', 'board,ones,turns_since', '--outfile', '.tmp.testing.h5', '--directory', 'tests/test_data', '--recurse'] - run_game_converter(args) - os.remove('.tmp.testing.h5') + def test_directory_walk(self): + args = ['--features', 'board,ones,turns_since', '--outfile', '.tmp.testing.h5', '--directory', 'tests/test_data', '--recurse'] + run_game_converter(args) + os.remove('.tmp.testing.h5') if __name__ == '__main__': - unittest.main() + unittest.main() diff --git a/tests/test_gamestate.py b/tests/test_gamestate.py index 9f3e9a7f9..f1f28818b 100644 --- a/tests/test_gamestate.py +++ b/tests/test_gamestate.py @@ -6,159 +6,159 @@ class TestKo(unittest.TestCase): - def test_standard_ko(self): - gs = GameState(size=9) - gs.do_move((1, 0)) # B - gs.do_move((2, 0)) # W - gs.do_move((0, 1)) # B - gs.do_move((3, 1)) # W - gs.do_move((1, 2)) # B - gs.do_move((2, 2)) # W - gs.do_move((2, 1)) # B - - gs.do_move((1, 1)) # W trigger capture and ko - - self.assertEqual(gs.num_black_prisoners, 1) - self.assertEqual(gs.num_white_prisoners, 0) - - self.assertFalse(gs.is_legal((2, 1))) - - gs.do_move((5, 5)) - gs.do_move((5, 6)) - - self.assertTrue(gs.is_legal((2, 1))) - - def test_snapback_is_not_ko(self): - gs = GameState(size=5) - # B o W B . - # W W B . . - # . . . . . - # . . . . . - # . . . . . - # here, imagine black plays at 'o' capturing - # the white stone at (2, 0). White may play - # again at (2, 0) to capture the black stones - # at (0, 0), (1, 0). this is 'snapback' not 'ko' - # since it doesn't return the game to a - # previous position - B = [(0, 0), (2, 1), (3, 0)] - W = [(0, 1), (1, 1), (2, 0)] - for (b, w) in zip(B, W): - gs.do_move(b) - gs.do_move(w) - # do the capture of the single white stone - gs.do_move((1, 0)) - # there should be no ko - self.assertIsNone(gs.ko) - self.assertTrue(gs.is_legal((2, 0))) - # now play the snapback - gs.do_move((2, 0)) - # check that the numbers worked out - self.assertEqual(gs.num_black_prisoners, 2) - self.assertEqual(gs.num_white_prisoners, 1) - - def test_positional_superko(self): - move_list = [(0, 3), (0, 4), (1, 3), (1, 4), (2, 3), (2, 4), (2, 2), (3, 4), (2, 1), (3, 3), (3, 1), (3, 2), (3, 0), (4, 2), (1, 1), (4, 1), (8, 0), (4, 0), (8, 1), (0, 2), (8, 2), (0, 1), (8, 3), (1, 0), (8, 4), (2, 0), (0, 0)] - - gs = GameState(size=9) - for move in move_list: - gs.do_move(move) - self.assertTrue(gs.is_legal((1, 0))) - - gs = GameState(size=9, enforce_superko=True) - for move in move_list: - gs.do_move(move) - self.assertFalse(gs.is_legal((1, 0))) + def test_standard_ko(self): + gs = GameState(size=9) + gs.do_move((1, 0)) # B + gs.do_move((2, 0)) # W + gs.do_move((0, 1)) # B + gs.do_move((3, 1)) # W + gs.do_move((1, 2)) # B + gs.do_move((2, 2)) # W + gs.do_move((2, 1)) # B + + gs.do_move((1, 1)) # W trigger capture and ko + + self.assertEqual(gs.num_black_prisoners, 1) + self.assertEqual(gs.num_white_prisoners, 0) + + self.assertFalse(gs.is_legal((2, 1))) + + gs.do_move((5, 5)) + gs.do_move((5, 6)) + + self.assertTrue(gs.is_legal((2, 1))) + + def test_snapback_is_not_ko(self): + gs = GameState(size=5) + # B o W B . + # W W B . . + # . . . . . + # . . . . . + # . . . . . + # here, imagine black plays at 'o' capturing + # the white stone at (2, 0). White may play + # again at (2, 0) to capture the black stones + # at (0, 0), (1, 0). this is 'snapback' not 'ko' + # since it doesn't return the game to a + # previous position + B = [(0, 0), (2, 1), (3, 0)] + W = [(0, 1), (1, 1), (2, 0)] + for (b, w) in zip(B, W): + gs.do_move(b) + gs.do_move(w) + # do the capture of the single white stone + gs.do_move((1, 0)) + # there should be no ko + self.assertIsNone(gs.ko) + self.assertTrue(gs.is_legal((2, 0))) + # now play the snapback + gs.do_move((2, 0)) + # check that the numbers worked out + self.assertEqual(gs.num_black_prisoners, 2) + self.assertEqual(gs.num_white_prisoners, 1) + + def test_positional_superko(self): + move_list = [(0, 3), (0, 4), (1, 3), (1, 4), (2, 3), (2, 4), (2, 2), (3, 4), (2, 1), (3, 3), (3, 1), (3, 2), (3, 0), (4, 2), (1, 1), (4, 1), (8, 0), (4, 0), (8, 1), (0, 2), (8, 2), (0, 1), (8, 3), (1, 0), (8, 4), (2, 0), (0, 0)] + + gs = GameState(size=9) + for move in move_list: + gs.do_move(move) + self.assertTrue(gs.is_legal((1, 0))) + + gs = GameState(size=9, enforce_superko=True) + for move in move_list: + gs.do_move(move) + self.assertFalse(gs.is_legal((1, 0))) class TestEye(unittest.TestCase): - def test_simple_eye(self): - - # create a black eye in top left (1, 1), white in bottom right (5, 5) - - gs = GameState(size=7) - gs.do_move((1, 0)) # B - gs.do_move((5, 4)) # W - gs.do_move((2, 1)) # B - gs.do_move((6, 5)) # W - gs.do_move((1, 2)) # B - gs.do_move((5, 6)) # W - gs.do_move((0, 1)) # B - gs.do_move((4, 5)) # W - - # test black eye top left - self.assertTrue(gs.is_eyeish((1, 1), go.BLACK)) - self.assertFalse(gs.is_eyeish((1, 1), go.WHITE)) - - # test white eye bottom right - self.assertTrue(gs.is_eyeish((5, 5), go.WHITE)) - self.assertFalse(gs.is_eyeish((5, 5), go.BLACK)) - - # test no eye in other random positions - self.assertFalse(gs.is_eyeish((1, 0), go.BLACK)) - self.assertFalse(gs.is_eyeish((1, 0), go.WHITE)) - self.assertFalse(gs.is_eyeish((2, 2), go.BLACK)) - self.assertFalse(gs.is_eyeish((2, 2), go.WHITE)) - - def test_true_eye(self): - gs = GameState(size=7) - gs.do_move((1, 0), go.BLACK) - gs.do_move((0, 1), go.BLACK) - - # false eye at 0, 0 - self.assertTrue(gs.is_eyeish((0, 0), go.BLACK)) - self.assertFalse(gs.is_eye((0, 0), go.BLACK)) - - # make it a true eye by turning the corner (1, 1) into an eye itself - gs.do_move((1, 2), go.BLACK) - gs.do_move((2, 1), go.BLACK) - gs.do_move((2, 2), go.BLACK) - gs.do_move((0, 2), go.BLACK) - - self.assertTrue(gs.is_eyeish((0, 0), go.BLACK)) - self.assertTrue(gs.is_eye((0, 0), go.BLACK)) - self.assertTrue(gs.is_eye((1, 1), go.BLACK)) - - def test_eye_recursion(self): - # a checkerboard pattern of black is 'technically' all true eyes - # mutually supporting each other - gs = GameState(7) - for x in range(gs.size): - for y in range(gs.size): - if (x + y) % 2 == 1: - gs.do_move((x, y), go.BLACK) - self.assertTrue(gs.is_eye((0, 0), go.BLACK)) - - def test_liberties_after_capture(self): - # creates 3x3 black group in the middle, that is then all captured - # ...then an assertion is made that the resulting liberties after - # capture are the same as if the group had never been there - gs_capture = GameState(7) - gs_reference = GameState(7) - # add in 3x3 black stones - for x in range(2, 5): - for y in range(2, 5): - gs_capture.do_move((x, y), go.BLACK) - # surround the black group with white stones - # and set the same white stones in gs_reference - for x in range(2, 5): - gs_capture.do_move((x, 1), go.WHITE) - gs_capture.do_move((x, 5), go.WHITE) - gs_reference.do_move((x, 1), go.WHITE) - gs_reference.do_move((x, 5), go.WHITE) - gs_capture.do_move((1, 1), go.WHITE) - gs_reference.do_move((1, 1), go.WHITE) - for y in range(2, 5): - gs_capture.do_move((1, y), go.WHITE) - gs_capture.do_move((5, y), go.WHITE) - gs_reference.do_move((1, y), go.WHITE) - gs_reference.do_move((5, y), go.WHITE) - - # board configuration and liberties of gs_capture and of gs_reference should be identical - self.assertTrue(np.all(gs_reference.board == gs_capture.board)) - self.assertTrue(np.all(gs_reference.liberty_counts == gs_capture.liberty_counts)) + def test_simple_eye(self): + + # create a black eye in top left (1, 1), white in bottom right (5, 5) + + gs = GameState(size=7) + gs.do_move((1, 0)) # B + gs.do_move((5, 4)) # W + gs.do_move((2, 1)) # B + gs.do_move((6, 5)) # W + gs.do_move((1, 2)) # B + gs.do_move((5, 6)) # W + gs.do_move((0, 1)) # B + gs.do_move((4, 5)) # W + + # test black eye top left + self.assertTrue(gs.is_eyeish((1, 1), go.BLACK)) + self.assertFalse(gs.is_eyeish((1, 1), go.WHITE)) + + # test white eye bottom right + self.assertTrue(gs.is_eyeish((5, 5), go.WHITE)) + self.assertFalse(gs.is_eyeish((5, 5), go.BLACK)) + + # test no eye in other random positions + self.assertFalse(gs.is_eyeish((1, 0), go.BLACK)) + self.assertFalse(gs.is_eyeish((1, 0), go.WHITE)) + self.assertFalse(gs.is_eyeish((2, 2), go.BLACK)) + self.assertFalse(gs.is_eyeish((2, 2), go.WHITE)) + + def test_true_eye(self): + gs = GameState(size=7) + gs.do_move((1, 0), go.BLACK) + gs.do_move((0, 1), go.BLACK) + + # false eye at 0, 0 + self.assertTrue(gs.is_eyeish((0, 0), go.BLACK)) + self.assertFalse(gs.is_eye((0, 0), go.BLACK)) + + # make it a true eye by turning the corner (1, 1) into an eye itself + gs.do_move((1, 2), go.BLACK) + gs.do_move((2, 1), go.BLACK) + gs.do_move((2, 2), go.BLACK) + gs.do_move((0, 2), go.BLACK) + + self.assertTrue(gs.is_eyeish((0, 0), go.BLACK)) + self.assertTrue(gs.is_eye((0, 0), go.BLACK)) + self.assertTrue(gs.is_eye((1, 1), go.BLACK)) + + def test_eye_recursion(self): + # a checkerboard pattern of black is 'technically' all true eyes + # mutually supporting each other + gs = GameState(7) + for x in range(gs.size): + for y in range(gs.size): + if (x + y) % 2 == 1: + gs.do_move((x, y), go.BLACK) + self.assertTrue(gs.is_eye((0, 0), go.BLACK)) + + def test_liberties_after_capture(self): + # creates 3x3 black group in the middle, that is then all captured + # ...then an assertion is made that the resulting liberties after + # capture are the same as if the group had never been there + gs_capture = GameState(7) + gs_reference = GameState(7) + # add in 3x3 black stones + for x in range(2, 5): + for y in range(2, 5): + gs_capture.do_move((x, y), go.BLACK) + # surround the black group with white stones + # and set the same white stones in gs_reference + for x in range(2, 5): + gs_capture.do_move((x, 1), go.WHITE) + gs_capture.do_move((x, 5), go.WHITE) + gs_reference.do_move((x, 1), go.WHITE) + gs_reference.do_move((x, 5), go.WHITE) + gs_capture.do_move((1, 1), go.WHITE) + gs_reference.do_move((1, 1), go.WHITE) + for y in range(2, 5): + gs_capture.do_move((1, y), go.WHITE) + gs_capture.do_move((5, y), go.WHITE) + gs_reference.do_move((1, y), go.WHITE) + gs_reference.do_move((5, y), go.WHITE) + + # board configuration and liberties of gs_capture and of gs_reference should be identical + self.assertTrue(np.all(gs_reference.board == gs_capture.board)) + self.assertTrue(np.all(gs_reference.liberty_counts == gs_capture.liberty_counts)) if __name__ == '__main__': - unittest.main() + unittest.main() diff --git a/tests/test_gtp_wrapper.py b/tests/test_gtp_wrapper.py index 5ab4ddb7f..345b09afa 100644 --- a/tests/test_gtp_wrapper.py +++ b/tests/test_gtp_wrapper.py @@ -5,26 +5,26 @@ class PassPlayer(object): - def get_move(self, state): - return go.PASS_MOVE + def get_move(self, state): + return go.PASS_MOVE class TestGTPProcess(unittest.TestCase): - def test_run_commands(self): - def stdin_simulator(): - return "\n".join([ - "1 name", - "2 boardsize 19", - "3 clear_board", - "4 genmove black", - "5 genmove white", - "99 quit"]) + def test_run_commands(self): + def stdin_simulator(): + return "\n".join([ + "1 name", + "2 boardsize 19", + "3 clear_board", + "4 genmove black", + "5 genmove white", + "99 quit"]) - gtp_proc = Process(target=run_gtp, args=(PassPlayer(), stdin_simulator)) - gtp_proc.start() - gtp_proc.join(timeout=1) + gtp_proc = Process(target=run_gtp, args=(PassPlayer(), stdin_simulator)) + gtp_proc.start() + gtp_proc.join(timeout=1) if __name__ == '__main__': - unittest.main() + unittest.main() diff --git a/tests/test_liberties.py b/tests/test_liberties.py index 4ea0bb728..3bcca7a6b 100644 --- a/tests/test_liberties.py +++ b/tests/test_liberties.py @@ -4,40 +4,40 @@ class TestLiberties(unittest.TestCase): - def setUp(self): - self.s = GameState() - self.s.do_move((4, 5)) - self.s.do_move((5, 5)) - self.s.do_move((5, 6)) - self.s.do_move((10, 10)) - self.s.do_move((4, 6)) - self.s.do_move((10, 11)) - self.s.do_move((6, 6)) - self.s.do_move((9, 10)) - - def test_curr_liberties(self): - self.assertEqual(self.s.liberty_counts[5][5], 2) - self.assertEqual(self.s.liberty_counts[4][5], 8) - self.assertEqual(self.s.liberty_counts[5][6], 8) - - def test_neighbors_edge_cases(self): - - st = GameState() - st.do_move((0, 0)) # B B . . . . . - st.do_move((5, 5)) # B W . . . . . - st.do_move((0, 1)) # . . . . . . . - st.do_move((6, 6)) # . . . . . . . - st.do_move((1, 0)) # . . . . . W . - st.do_move((1, 1)) # . . . . . . W - - # get_group in the corner - self.assertEqual(len(st.get_group((0, 0))), 3, "group size in corner") - - # get_group of an empty space - self.assertEqual(len(st.get_group((4, 4))), 0, "group size of empty space") - - # get_group of a single piece - self.assertEqual(len(st.get_group((5, 5))), 1, "group size of single piece") + def setUp(self): + self.s = GameState() + self.s.do_move((4, 5)) + self.s.do_move((5, 5)) + self.s.do_move((5, 6)) + self.s.do_move((10, 10)) + self.s.do_move((4, 6)) + self.s.do_move((10, 11)) + self.s.do_move((6, 6)) + self.s.do_move((9, 10)) + + def test_curr_liberties(self): + self.assertEqual(self.s.liberty_counts[5][5], 2) + self.assertEqual(self.s.liberty_counts[4][5], 8) + self.assertEqual(self.s.liberty_counts[5][6], 8) + + def test_neighbors_edge_cases(self): + + st = GameState() + st.do_move((0, 0)) # B B . . . . . + st.do_move((5, 5)) # B W . . . . . + st.do_move((0, 1)) # . . . . . . . + st.do_move((6, 6)) # . . . . . . . + st.do_move((1, 0)) # . . . . . W . + st.do_move((1, 1)) # . . . . . . W + + # get_group in the corner + self.assertEqual(len(st.get_group((0, 0))), 3, "group size in corner") + + # get_group of an empty space + self.assertEqual(len(st.get_group((4, 4))), 0, "group size of empty space") + + # get_group of a single piece + self.assertEqual(len(st.get_group((5, 5))), 1, "group size of single piece") if __name__ == '__main__': - unittest.main() + unittest.main() diff --git a/tests/test_mcts.py b/tests/test_mcts.py index c6ab6f803..3537515a9 100644 --- a/tests/test_mcts.py +++ b/tests/test_mcts.py @@ -6,109 +6,109 @@ class TestTreeNode(unittest.TestCase): - def setUp(self): - self.gs = GameState() - self.node = TreeNode(None, 1.0) - - def test_selection(self): - self.node.expand(dummy_policy(self.gs)) - action, next_node = self.node.select() - self.assertEqual(action, (18, 18)) # according to the dummy policy below - self.assertIsNotNone(next_node) - - def test_expansion(self): - self.assertEqual(0, len(self.node._children)) - self.node.expand(dummy_policy(self.gs)) - self.assertEqual(19 * 19, len(self.node._children)) - for a, p in dummy_policy(self.gs): - self.assertEqual(p, self.node._children[a]._P) - - def test_update(self): - self.node.expand(dummy_policy(self.gs)) - child = self.node._children[(18, 18)] - # Note: the root must be updated first for the visit count to work. - self.node.update(leaf_value=1.0, c_puct=5.0) - child.update(leaf_value=1.0, c_puct=5.0) - expected_score = 1.0 + 5.0 * dummy_distribution[-1] * 0.5 - self.assertEqual(expected_score, child.get_value()) - # After a second update, the Q value should be the average of the two, and the u value - # should be multiplied by sqrt(parent visits) / (node visits + 1) (which was simply equal - # to 0.5 before) - self.node.update(leaf_value=0.0, c_puct=5.0) - child.update(leaf_value=0.0, c_puct=5.0) - expected_score = 0.5 + 5.0 * dummy_distribution[-1] * np.sqrt(2.0) / 3.0 - self.assertEqual(expected_score, child.get_value()) - - def test_update_recursive(self): - # Assertions are identical to test_treenode_update. - self.node.expand(dummy_policy(self.gs)) - child = self.node._children[(18, 18)] - child.update_recursive(leaf_value=1.0, c_puct=5.0) - expected_score = 1.0 + 5.0 * dummy_distribution[-1] / 2.0 - self.assertEqual(expected_score, child.get_value()) - child.update_recursive(leaf_value=0.0, c_puct=5.0) - expected_score = 0.5 + 5.0 * dummy_distribution[-1] * np.sqrt(2.0) / 3.0 - self.assertEqual(expected_score, child.get_value()) + def setUp(self): + self.gs = GameState() + self.node = TreeNode(None, 1.0) + + def test_selection(self): + self.node.expand(dummy_policy(self.gs)) + action, next_node = self.node.select() + self.assertEqual(action, (18, 18)) # according to the dummy policy below + self.assertIsNotNone(next_node) + + def test_expansion(self): + self.assertEqual(0, len(self.node._children)) + self.node.expand(dummy_policy(self.gs)) + self.assertEqual(19 * 19, len(self.node._children)) + for a, p in dummy_policy(self.gs): + self.assertEqual(p, self.node._children[a]._P) + + def test_update(self): + self.node.expand(dummy_policy(self.gs)) + child = self.node._children[(18, 18)] + # Note: the root must be updated first for the visit count to work. + self.node.update(leaf_value=1.0, c_puct=5.0) + child.update(leaf_value=1.0, c_puct=5.0) + expected_score = 1.0 + 5.0 * dummy_distribution[-1] * 0.5 + self.assertEqual(expected_score, child.get_value()) + # After a second update, the Q value should be the average of the two, and the u value + # should be multiplied by sqrt(parent visits) / (node visits + 1) (which was simply equal + # to 0.5 before) + self.node.update(leaf_value=0.0, c_puct=5.0) + child.update(leaf_value=0.0, c_puct=5.0) + expected_score = 0.5 + 5.0 * dummy_distribution[-1] * np.sqrt(2.0) / 3.0 + self.assertEqual(expected_score, child.get_value()) + + def test_update_recursive(self): + # Assertions are identical to test_treenode_update. + self.node.expand(dummy_policy(self.gs)) + child = self.node._children[(18, 18)] + child.update_recursive(leaf_value=1.0, c_puct=5.0) + expected_score = 1.0 + 5.0 * dummy_distribution[-1] / 2.0 + self.assertEqual(expected_score, child.get_value()) + child.update_recursive(leaf_value=0.0, c_puct=5.0) + expected_score = 0.5 + 5.0 * dummy_distribution[-1] * np.sqrt(2.0) / 3.0 + self.assertEqual(expected_score, child.get_value()) class TestMCTS(unittest.TestCase): - def setUp(self): - self.gs = GameState() - self.mcts = MCTS(dummy_value, dummy_policy, dummy_rollout, n_playout=2) - - def _count_expansions(self): - """Helper function to count the number of expansions past the root using the dummy policy - """ - node = self.mcts._root - expansions = 0 - # Loop over actions in decreasing probability. - for action, _ in sorted(dummy_policy(self.gs), key=lambda (a, p): p, reverse=True): - if action in node._children: - expansions += 1 - node = node._children[action] - else: - break - return expansions - - def test_playout(self): - self.mcts._playout(self.gs.copy(), 8) - # Assert that the most likely child was visited (according to the dummy policy below). - self.assertEqual(1, self.mcts._root._children[(18, 18)]._n_visits) - # Assert that the search depth expanded nodes 8 times. - self.assertEqual(8, self._count_expansions()) - - def test_playout_with_pass(self): - # Test that playout handles the end of the game (i.e. passing/no moves). Mock this by - # creating a policy that returns nothing after 4 moves. - def stop_early_policy(state): - if len(state.history) <= 4: - return dummy_policy(state) - else: - return [] - self.mcts = MCTS(dummy_value, stop_early_policy, stop_early_policy, n_playout=2) - self.mcts._playout(self.gs.copy(), 8) - # Assert that (18, 18) and (18, 17) are still only visited once. - self.assertEqual(1, self.mcts._root._children[(18, 18)]._n_visits) - # Assert that no expansions happened after reaching the "end" in 4 moves. - self.assertEqual(5, self._count_expansions()) - - def test_get_move(self): - move = self.mcts.get_move(self.gs) - self.mcts.update_with_move(move) - # success if no errors - - def test_update_with_move(self): - move = self.mcts.get_move(self.gs) - self.gs.do_move(move) - self.mcts.update_with_move(move) - # Assert that the new root still has children. - self.assertTrue(len(self.mcts._root._children) > 0) - # Assert that the new root has no parent (the rest of the tree will be garbage collected). - self.assertIsNone(self.mcts._root._parent) - # Assert that the next best move according to the root is (18, 17), according to the - # dummy policy below. - self.assertEqual((18, 17), self.mcts._root.select()[0]) + def setUp(self): + self.gs = GameState() + self.mcts = MCTS(dummy_value, dummy_policy, dummy_rollout, n_playout=2) + + def _count_expansions(self): + """Helper function to count the number of expansions past the root using the dummy policy + """ + node = self.mcts._root + expansions = 0 + # Loop over actions in decreasing probability. + for action, _ in sorted(dummy_policy(self.gs), key=lambda (a, p): p, reverse=True): + if action in node._children: + expansions += 1 + node = node._children[action] + else: + break + return expansions + + def test_playout(self): + self.mcts._playout(self.gs.copy(), 8) + # Assert that the most likely child was visited (according to the dummy policy below). + self.assertEqual(1, self.mcts._root._children[(18, 18)]._n_visits) + # Assert that the search depth expanded nodes 8 times. + self.assertEqual(8, self._count_expansions()) + + def test_playout_with_pass(self): + # Test that playout handles the end of the game (i.e. passing/no moves). Mock this by + # creating a policy that returns nothing after 4 moves. + def stop_early_policy(state): + if len(state.history) <= 4: + return dummy_policy(state) + else: + return [] + self.mcts = MCTS(dummy_value, stop_early_policy, stop_early_policy, n_playout=2) + self.mcts._playout(self.gs.copy(), 8) + # Assert that (18, 18) and (18, 17) are still only visited once. + self.assertEqual(1, self.mcts._root._children[(18, 18)]._n_visits) + # Assert that no expansions happened after reaching the "end" in 4 moves. + self.assertEqual(5, self._count_expansions()) + + def test_get_move(self): + move = self.mcts.get_move(self.gs) + self.mcts.update_with_move(move) + # success if no errors + + def test_update_with_move(self): + move = self.mcts.get_move(self.gs) + self.gs.do_move(move) + self.mcts.update_with_move(move) + # Assert that the new root still has children. + self.assertTrue(len(self.mcts._root._children) > 0) + # Assert that the new root has no parent (the rest of the tree will be garbage collected). + self.assertIsNone(self.mcts._root._parent) + # Assert that the next best move according to the root is (18, 17), according to the + # dummy policy below. + self.assertEqual((18, 17), self.mcts._root.select()[0]) # A distribution over positions that is smallest at (0,0) and largest at (18,18) @@ -117,17 +117,17 @@ def test_update_with_move(self): def dummy_policy(state): - moves = state.get_legal_moves(include_eyes=False) - return zip(moves, dummy_distribution) + moves = state.get_legal_moves(include_eyes=False) + return zip(moves, dummy_distribution) # Rollout is a clone of the policy function. dummy_rollout = dummy_policy def dummy_value(state): - # it's not very confident - return 0.0 + # it's not very confident + return 0.0 if __name__ == '__main__': - unittest.main() + unittest.main() diff --git a/tests/test_policy.py b/tests/test_policy.py index ddca1de6a..9494f4765 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -9,143 +9,143 @@ class TestCNNPolicy(unittest.TestCase): - def test_default_policy(self): - policy = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"]) - policy.eval_state(GameState()) - # just hope nothing breaks + def test_default_policy(self): + policy = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"]) + policy.eval_state(GameState()) + # just hope nothing breaks - def test_batch_eval_state(self): - policy = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"]) - results = policy.batch_eval_state([GameState(), GameState()]) - self.assertEqual(len(results), 2) # one result per GameState - self.assertEqual(len(results[0]), 361) # each one has 361 (move,prob) pairs + def test_batch_eval_state(self): + policy = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"]) + results = policy.batch_eval_state([GameState(), GameState()]) + self.assertEqual(len(results), 2) # one result per GameState + self.assertEqual(len(results[0]), 361) # each one has 361 (move,prob) pairs - def test_output_size(self): - policy19 = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"], board=19) - output = policy19.forward(policy19.preprocessor.state_to_tensor(GameState(19))) - self.assertEqual(output.shape, (1, 19 * 19)) + def test_output_size(self): + policy19 = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"], board=19) + output = policy19.forward(policy19.preprocessor.state_to_tensor(GameState(19))) + self.assertEqual(output.shape, (1, 19 * 19)) - policy13 = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"], board=13) - output = policy13.forward(policy13.preprocessor.state_to_tensor(GameState(13))) - self.assertEqual(output.shape, (1, 13 * 13)) + policy13 = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"], board=13) + output = policy13.forward(policy13.preprocessor.state_to_tensor(GameState(13))) + self.assertEqual(output.shape, (1, 13 * 13)) - def test_save_load(self): - policy = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"]) + def test_save_load(self): + policy = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"]) - model_file = 'TESTPOLICY.json' - weights_file = 'TESTWEIGHTS.h5' - model_file2 = 'TESTPOLICY2.json' - weights_file2 = 'TESTWEIGHTS2.h5' + model_file = 'TESTPOLICY.json' + weights_file = 'TESTWEIGHTS.h5' + model_file2 = 'TESTPOLICY2.json' + weights_file2 = 'TESTWEIGHTS2.h5' - # test saving model/weights separately - policy.save_model(model_file) - policy.model.save_weights(weights_file, overwrite=True) - # test saving them together - policy.save_model(model_file2, weights_file2) + # test saving model/weights separately + policy.save_model(model_file) + policy.model.save_weights(weights_file, overwrite=True) + # test saving them together + policy.save_model(model_file2, weights_file2) - copypolicy = CNNPolicy.load_model(model_file) - copypolicy.model.load_weights(weights_file) + copypolicy = CNNPolicy.load_model(model_file) + copypolicy.model.load_weights(weights_file) - copypolicy2 = CNNPolicy.load_model(model_file2) + copypolicy2 = CNNPolicy.load_model(model_file2) - for w1, w2 in zip(copypolicy.model.get_weights(), copypolicy2.model.get_weights()): - self.assertTrue(np.all(w1 == w2)) + for w1, w2 in zip(copypolicy.model.get_weights(), copypolicy2.model.get_weights()): + self.assertTrue(np.all(w1 == w2)) - os.remove(model_file) - os.remove(weights_file) - os.remove(model_file2) - os.remove(weights_file2) + os.remove(model_file) + os.remove(weights_file) + os.remove(model_file2) + os.remove(weights_file2) class TestResnetPolicy(unittest.TestCase): - def test_default_policy(self): - policy = ResnetPolicy(["board", "liberties", "sensibleness", "capture_size"]) - policy.eval_state(GameState()) - # just hope nothing breaks + def test_default_policy(self): + policy = ResnetPolicy(["board", "liberties", "sensibleness", "capture_size"]) + policy.eval_state(GameState()) + # just hope nothing breaks - def test_batch_eval_state(self): - policy = ResnetPolicy(["board", "liberties", "sensibleness", "capture_size"]) - results = policy.batch_eval_state([GameState(), GameState()]) - self.assertEqual(len(results), 2) # one result per GameState - self.assertEqual(len(results[0]), 361) # each one has 361 (move,prob) pairs + def test_batch_eval_state(self): + policy = ResnetPolicy(["board", "liberties", "sensibleness", "capture_size"]) + results = policy.batch_eval_state([GameState(), GameState()]) + self.assertEqual(len(results), 2) # one result per GameState + self.assertEqual(len(results[0]), 361) # each one has 361 (move,prob) pairs - def test_save_load(self): - """Identical to above test_save_load - """ - policy = ResnetPolicy(["board", "liberties", "sensibleness", "capture_size"]) + def test_save_load(self): + """Identical to above test_save_load + """ + policy = ResnetPolicy(["board", "liberties", "sensibleness", "capture_size"]) - model_file = 'TESTPOLICY.json' - weights_file = 'TESTWEIGHTS.h5' - model_file2 = 'TESTPOLICY2.json' - weights_file2 = 'TESTWEIGHTS2.h5' + model_file = 'TESTPOLICY.json' + weights_file = 'TESTWEIGHTS.h5' + model_file2 = 'TESTPOLICY2.json' + weights_file2 = 'TESTWEIGHTS2.h5' - # test saving model/weights separately - policy.save_model(model_file) - policy.model.save_weights(weights_file, overwrite=True) - # test saving them together - policy.save_model(model_file2, weights_file2) + # test saving model/weights separately + policy.save_model(model_file) + policy.model.save_weights(weights_file, overwrite=True) + # test saving them together + policy.save_model(model_file2, weights_file2) - copypolicy = ResnetPolicy.load_model(model_file) - copypolicy.model.load_weights(weights_file) + copypolicy = ResnetPolicy.load_model(model_file) + copypolicy.model.load_weights(weights_file) - copypolicy2 = ResnetPolicy.load_model(model_file2) + copypolicy2 = ResnetPolicy.load_model(model_file2) - for w1, w2 in zip(copypolicy.model.get_weights(), copypolicy2.model.get_weights()): - self.assertTrue(np.all(w1 == w2)) + for w1, w2 in zip(copypolicy.model.get_weights(), copypolicy2.model.get_weights()): + self.assertTrue(np.all(w1 == w2)) - # check that save/load keeps the ResnetPolicy class - self.assertTrue(type(policy) == type(copypolicy)) + # check that save/load keeps the ResnetPolicy class + self.assertTrue(type(policy) == type(copypolicy)) - os.remove(model_file) - os.remove(weights_file) - os.remove(model_file2) - os.remove(weights_file2) + os.remove(model_file) + os.remove(weights_file) + os.remove(model_file2) + os.remove(weights_file2) class TestPlayers(unittest.TestCase): - def test_greedy_player(self): - gs = GameState() - policy = CNNPolicy(["board", "ones", "turns_since"]) - player = GreedyPolicyPlayer(policy) - for i in range(20): - move = player.get_move(gs) - self.assertIsNotNone(move) - gs.do_move(move) - - def test_probabilistic_player(self): - gs = GameState() - policy = CNNPolicy(["board", "ones", "turns_since"]) - player = ProbabilisticPolicyPlayer(policy) - for i in range(20): - move = player.get_move(gs) - self.assertIsNotNone(move) - gs.do_move(move) - - def test_sensible_probabilistic(self): - gs = GameState() - policy = CNNPolicy(["board", "ones", "turns_since"]) - player = ProbabilisticPolicyPlayer(policy) - empty = (10, 10) - for x in range(19): - for y in range(19): - if (x, y) != empty: - gs.do_move((x, y), go.BLACK) - gs.current_player = go.BLACK - self.assertIsNone(player.get_move(gs)) - - def test_sensible_greedy(self): - gs = GameState() - policy = CNNPolicy(["board", "ones", "turns_since"]) - player = GreedyPolicyPlayer(policy) - empty = (10, 10) - for x in range(19): - for y in range(19): - if (x, y) != empty: - gs.do_move((x, y), go.BLACK) - gs.current_player = go.BLACK - self.assertIsNone(player.get_move(gs)) + def test_greedy_player(self): + gs = GameState() + policy = CNNPolicy(["board", "ones", "turns_since"]) + player = GreedyPolicyPlayer(policy) + for i in range(20): + move = player.get_move(gs) + self.assertIsNotNone(move) + gs.do_move(move) + + def test_probabilistic_player(self): + gs = GameState() + policy = CNNPolicy(["board", "ones", "turns_since"]) + player = ProbabilisticPolicyPlayer(policy) + for i in range(20): + move = player.get_move(gs) + self.assertIsNotNone(move) + gs.do_move(move) + + def test_sensible_probabilistic(self): + gs = GameState() + policy = CNNPolicy(["board", "ones", "turns_since"]) + player = ProbabilisticPolicyPlayer(policy) + empty = (10, 10) + for x in range(19): + for y in range(19): + if (x, y) != empty: + gs.do_move((x, y), go.BLACK) + gs.current_player = go.BLACK + self.assertIsNone(player.get_move(gs)) + + def test_sensible_greedy(self): + gs = GameState() + policy = CNNPolicy(["board", "ones", "turns_since"]) + player = GreedyPolicyPlayer(policy) + empty = (10, 10) + for x in range(19): + for y in range(19): + if (x, y) != empty: + gs.do_move((x, y), go.BLACK) + gs.current_player = go.BLACK + self.assertIsNone(player.get_move(gs)) if __name__ == '__main__': - unittest.main() + unittest.main() diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index 509abeb26..a21e1851d 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -5,332 +5,332 @@ def simple_board(): - # make a tiny board for the sake of testing and hand-coding expected results - # - # X - # 0 1 2 3 4 5 6 - # B W . . . . . 0 - # B W . . . . . 1 - # B . . . B . . 2 - # Y . . . B k B . 3 - # . . . W B W . 4 - # . . . . W . . 5 - # . . . . . . . 6 - # - # where k is a ko position (white was just captured) - - gs = go.GameState(size=7) - - # ladder-looking thing in the top-left - gs.do_move((0, 0)) # B - gs.do_move((1, 0)) # W - gs.do_move((0, 1)) # B - gs.do_move((1, 1)) # W - gs.do_move((0, 2)) # B - - # ko position in the middle - gs.do_move((3, 4)) # W - gs.do_move((3, 3)) # B - gs.do_move((4, 5)) # W - gs.do_move((4, 2)) # B - gs.do_move((5, 4)) # W - gs.do_move((5, 3)) # B - gs.do_move((4, 3)) # W - the ko position - gs.do_move((4, 4)) # B - does the capture - - return gs + # make a tiny board for the sake of testing and hand-coding expected results + # + # X + # 0 1 2 3 4 5 6 + # B W . . . . . 0 + # B W . . . . . 1 + # B . . . B . . 2 + # Y . . . B k B . 3 + # . . . W B W . 4 + # . . . . W . . 5 + # . . . . . . . 6 + # + # where k is a ko position (white was just captured) + + gs = go.GameState(size=7) + + # ladder-looking thing in the top-left + gs.do_move((0, 0)) # B + gs.do_move((1, 0)) # W + gs.do_move((0, 1)) # B + gs.do_move((1, 1)) # W + gs.do_move((0, 2)) # B + + # ko position in the middle + gs.do_move((3, 4)) # W + gs.do_move((3, 3)) # B + gs.do_move((4, 5)) # W + gs.do_move((4, 2)) # B + gs.do_move((5, 4)) # W + gs.do_move((5, 3)) # B + gs.do_move((4, 3)) # W - the ko position + gs.do_move((4, 4)) # B - does the capture + + return gs def self_atari_board(): - # another tiny board for testing self-atari specifically. - # positions marked with 'a' are self-atari for black - # - # X - # 0 1 2 3 4 5 6 - # a W . . . W B 0 - # . . . . . . . 1 - # . . . . . . . 2 - # Y . . W . W . . 3 - # . W B a B W . 4 - # . . W W W . . 5 - # . . . . . . . 6 - # - # current_player = black - gs = go.GameState(size=7) - - gs.do_move((2, 4), go.BLACK) - gs.do_move((4, 4), go.BLACK) - gs.do_move((6, 0), go.BLACK) - - gs.do_move((1, 0), go.WHITE) - gs.do_move((5, 0), go.WHITE) - gs.do_move((2, 3), go.WHITE) - gs.do_move((4, 3), go.WHITE) - gs.do_move((1, 4), go.WHITE) - gs.do_move((5, 4), go.WHITE) - gs.do_move((2, 5), go.WHITE) - gs.do_move((3, 5), go.WHITE) - gs.do_move((4, 5), go.WHITE) - - return gs + # another tiny board for testing self-atari specifically. + # positions marked with 'a' are self-atari for black + # + # X + # 0 1 2 3 4 5 6 + # a W . . . W B 0 + # . . . . . . . 1 + # . . . . . . . 2 + # Y . . W . W . . 3 + # . W B a B W . 4 + # . . W W W . . 5 + # . . . . . . . 6 + # + # current_player = black + gs = go.GameState(size=7) + + gs.do_move((2, 4), go.BLACK) + gs.do_move((4, 4), go.BLACK) + gs.do_move((6, 0), go.BLACK) + + gs.do_move((1, 0), go.WHITE) + gs.do_move((5, 0), go.WHITE) + gs.do_move((2, 3), go.WHITE) + gs.do_move((4, 3), go.WHITE) + gs.do_move((1, 4), go.WHITE) + gs.do_move((5, 4), go.WHITE) + gs.do_move((2, 5), go.WHITE) + gs.do_move((3, 5), go.WHITE) + gs.do_move((4, 5), go.WHITE) + + return gs def capture_board(): - # another small board, this one with imminent captures - # - # X - # 0 1 2 3 4 5 6 - # . . B B . . . 0 - # . B W W B . . 1 - # . B W . . . . 2 - # Y . . B . . . . 3 - # . . . . W B . 4 - # . . . W . W B 5 - # . . . . W B . 6 - # - # current_player = black - gs = go.GameState(size=7) - - black = [(2, 0), (3, 0), (1, 1), (4, 1), (1, 2), (2, 3), (5, 4), (6, 5), (5, 6)] - white = [(2, 1), (3, 1), (2, 2), (4, 4), (3, 5), (5, 5), (4, 6)] - - for B in black: - gs.do_move(B, go.BLACK) - for W in white: - gs.do_move(W, go.WHITE) - gs.current_player = go.BLACK - - return gs + # another small board, this one with imminent captures + # + # X + # 0 1 2 3 4 5 6 + # . . B B . . . 0 + # . B W W B . . 1 + # . B W . . . . 2 + # Y . . B . . . . 3 + # . . . . W B . 4 + # . . . W . W B 5 + # . . . . W B . 6 + # + # current_player = black + gs = go.GameState(size=7) + + black = [(2, 0), (3, 0), (1, 1), (4, 1), (1, 2), (2, 3), (5, 4), (6, 5), (5, 6)] + white = [(2, 1), (3, 1), (2, 2), (4, 4), (3, 5), (5, 5), (4, 6)] + + for B in black: + gs.do_move(B, go.BLACK) + for W in white: + gs.do_move(W, go.WHITE) + gs.current_player = go.BLACK + + return gs class TestPreprocessingFeatures(unittest.TestCase): - """Test the functions in preprocessing.py - - note that the hand-coded features look backwards from what is depicted - in simple_board() because of the x/y column/row transpose thing (i.e. - numpy is typically thought of as indexing rows first, but we use (x,y) - indexes, so a numpy row is like a go column and vice versa) - """ - - def test_get_board(self): - gs = simple_board() - pp = Preprocess(["board"]) - feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) - - white_pos = np.asarray([ - [0, 0, 0, 0, 0, 0, 0], - [1, 1, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 1, 0, 0], - [0, 0, 0, 0, 0, 1, 0], - [0, 0, 0, 0, 1, 0, 0], - [0, 0, 0, 0, 0, 0, 0]]) - black_pos = np.asarray([ - [1, 1, 1, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0], - [0, 0, 0, 1, 0, 0, 0], - [0, 0, 1, 0, 1, 0, 0], - [0, 0, 0, 1, 0, 0, 0], - [0, 0, 0, 0, 0, 0, 0]]) - empty_pos = np.ones((gs.size, gs.size)) - (white_pos + black_pos) - - # check number of planes - self.assertEqual(feature.shape, (gs.size, gs.size, 3)) - # check return value against hand-coded expectation - # (given that current_player is white) - self.assertTrue(np.all(feature == np.dstack((white_pos, black_pos, empty_pos)))) - - def test_get_turns_since(self): - gs = simple_board() - pp = Preprocess(["turns_since"]) - feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) - - one_hot_turns = np.zeros((gs.size, gs.size, 8)) - - rev_moves = gs.history[::-1] - - for x in range(gs.size): - for y in range(gs.size): - if gs.board[x, y] != go.EMPTY: - # find most recent move at x, y - age = rev_moves.index((x, y)) - one_hot_turns[x, y, min(age, 7)] = 1 - - self.assertTrue(np.all(feature == one_hot_turns)) - - def test_get_liberties(self): - gs = simple_board() - pp = Preprocess(["liberties"]) - feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) - - # todo - test liberties when > 8 - - one_hot_liberties = np.zeros((gs.size, gs.size, 8)) - # black piece at (4,4) has a single liberty: (4,3) - one_hot_liberties[4, 4, 0] = 1 - - # the black group in the top left corner has 2 liberties - one_hot_liberties[0, 0:3, 1] = 1 - # .. as do the white pieces on the left and right of the eye - one_hot_liberties[3, 4, 1] = 1 - one_hot_liberties[5, 4, 1] = 1 - - # the white group in the top left corner has 3 liberties - one_hot_liberties[1, 0:2, 2] = 1 - # ...as does the white piece at (4,5) - one_hot_liberties[4, 5, 2] = 1 - # ...and the black pieces on the sides of the eye - one_hot_liberties[3, 3, 2] = 1 - one_hot_liberties[5, 3, 2] = 1 - - # the black piece at (4,2) has 4 liberties - one_hot_liberties[4, 2, 3] = 1 - - for i in range(8): - self.assertTrue( - np.all(feature[:, :, i] == one_hot_liberties[:, :, i]), - "bad expectation: stones with %d liberties" % (i + 1)) - - def test_get_capture_size(self): - gs = capture_board() - pp = Preprocess(["capture_size"]) - feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) - - score_before = gs.num_white_prisoners - one_hot_capture = np.zeros((gs.size, gs.size, 8)) - # there is no capture available; all legal moves are zero-capture - for (x, y) in gs.get_legal_moves(): - copy = gs.copy() - copy.do_move((x, y)) - num_captured = copy.num_white_prisoners - score_before - one_hot_capture[x, y, min(7, num_captured)] = 1 - - for i in range(8): - self.assertTrue( - np.all(feature[:, :, i] == one_hot_capture[:, :, i]), - "bad expectation: capturing %d stones" % i) - - def test_get_self_atari_size(self): - gs = self_atari_board() - pp = Preprocess(["self_atari_size"]) - feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) - - one_hot_self_atari = np.zeros((gs.size, gs.size, 8)) - # self atari of size 1 at position 0,0 - one_hot_self_atari[0, 0, 0] = 1 - # self atari of size 3 at position 3,4 - one_hot_self_atari[3, 4, 2] = 1 - - self.assertTrue(np.all(feature == one_hot_self_atari)) - - def test_get_self_atari_size_cap(self): - gs = capture_board() - pp = Preprocess(["self_atari_size"]) - feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) - - one_hot_self_atari = np.zeros((gs.size, gs.size, 8)) - # self atari of size 1 at the ko position and just below it - one_hot_self_atari[4, 5, 0] = 1 - one_hot_self_atari[3, 6, 0] = 1 - # self atari of size 3 at bottom corner - one_hot_self_atari[6, 6, 2] = 1 - - self.assertTrue(np.all(feature == one_hot_self_atari)) - - def test_get_liberties_after(self): - gs = simple_board() - pp = Preprocess(["liberties_after"]) - feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) - - one_hot_liberties = np.zeros((gs.size, gs.size, 8)) - - # TODO (?) hand-code? - for (x, y) in gs.get_legal_moves(): - copy = gs.copy() - copy.do_move((x, y)) - libs = copy.liberty_counts[x, y] - if libs < 7: - one_hot_liberties[x, y, libs - 1] = 1 - else: - one_hot_liberties[x, y, 7] = 1 - - for i in range(8): - self.assertTrue( - np.all(feature[:, :, i] == one_hot_liberties[:, :, i]), - "bad expectation: stones with %d liberties after move" % (i + 1)) - - def test_get_liberties_after_cap(self): - """A copy of test_get_liberties_after but where captures are imminent - """ - gs = capture_board() - pp = Preprocess(["liberties_after"]) - feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) - - one_hot_liberties = np.zeros((gs.size, gs.size, 8)) - - for (x, y) in gs.get_legal_moves(): - copy = gs.copy() - copy.do_move((x, y)) - libs = copy.liberty_counts[x, y] - one_hot_liberties[x, y, min(libs - 1, 7)] = 1 - - for i in range(8): - self.assertTrue( - np.all(feature[:, :, i] == one_hot_liberties[:, :, i]), - "bad expectation: stones with %d liberties after move" % (i + 1)) - - def test_get_ladder_capture(self): - pass - - def test_get_ladder_escape(self): - pass - - def test_get_sensibleness(self): - # TODO - there are no legal eyes at the moment - - gs = simple_board() - pp = Preprocess(["sensibleness"]) - feature = pp.state_to_tensor(gs)[0, 0] # 1D tensor; no need to transpose - - expectation = np.zeros((gs.size, gs.size)) - for (x, y) in gs.get_legal_moves(): - if not (gs.is_eye((x, y), go.WHITE)): - expectation[x, y] = 1 - self.assertTrue(np.all(expectation == feature)) - - def test_get_legal(self): - gs = simple_board() - pp = Preprocess(["legal"]) - feature = pp.state_to_tensor(gs)[0, 0] # 1D tensor; no need to transpose - - expectation = np.zeros((gs.size, gs.size)) - for (x, y) in gs.get_legal_moves(): - expectation[x, y] = 1 - self.assertTrue(np.all(expectation == feature)) - - def test_feature_concatenation(self): - gs = simple_board() - pp = Preprocess(["board", "sensibleness", "capture_size"]) - feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) - - expectation = np.zeros((gs.size, gs.size, 3 + 1 + 8)) - - # first three planes: board - expectation[:, :, 0] = (gs.board == go.WHITE) * 1 - expectation[:, :, 1] = (gs.board == go.BLACK) * 1 - expectation[:, :, 2] = (gs.board == go.EMPTY) * 1 - - # 4th plane: sensibleness (as in test_get_sensibleness) - for (x, y) in gs.get_legal_moves(): - if not (gs.is_eye((x, y), go.WHITE)): - expectation[x, y, 3] = 1 - - # 5th through 12th plane: capture size (all zero-capture) - for (x, y) in gs.get_legal_moves(): - expectation[x, y, 4] = 1 - - self.assertTrue(np.all(expectation == feature)) + """Test the functions in preprocessing.py + + note that the hand-coded features look backwards from what is depicted + in simple_board() because of the x/y column/row transpose thing (i.e. + numpy is typically thought of as indexing rows first, but we use (x,y) + indexes, so a numpy row is like a go column and vice versa) + """ + + def test_get_board(self): + gs = simple_board() + pp = Preprocess(["board"]) + feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) + + white_pos = np.asarray([ + [0, 0, 0, 0, 0, 0, 0], + [1, 1, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 1, 0, 0], + [0, 0, 0, 0, 0, 1, 0], + [0, 0, 0, 0, 1, 0, 0], + [0, 0, 0, 0, 0, 0, 0]]) + black_pos = np.asarray([ + [1, 1, 1, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 1, 0, 0, 0], + [0, 0, 1, 0, 1, 0, 0], + [0, 0, 0, 1, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0]]) + empty_pos = np.ones((gs.size, gs.size)) - (white_pos + black_pos) + + # check number of planes + self.assertEqual(feature.shape, (gs.size, gs.size, 3)) + # check return value against hand-coded expectation + # (given that current_player is white) + self.assertTrue(np.all(feature == np.dstack((white_pos, black_pos, empty_pos)))) + + def test_get_turns_since(self): + gs = simple_board() + pp = Preprocess(["turns_since"]) + feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) + + one_hot_turns = np.zeros((gs.size, gs.size, 8)) + + rev_moves = gs.history[::-1] + + for x in range(gs.size): + for y in range(gs.size): + if gs.board[x, y] != go.EMPTY: + # find most recent move at x, y + age = rev_moves.index((x, y)) + one_hot_turns[x, y, min(age, 7)] = 1 + + self.assertTrue(np.all(feature == one_hot_turns)) + + def test_get_liberties(self): + gs = simple_board() + pp = Preprocess(["liberties"]) + feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) + + # todo - test liberties when > 8 + + one_hot_liberties = np.zeros((gs.size, gs.size, 8)) + # black piece at (4,4) has a single liberty: (4,3) + one_hot_liberties[4, 4, 0] = 1 + + # the black group in the top left corner has 2 liberties + one_hot_liberties[0, 0:3, 1] = 1 + # .. as do the white pieces on the left and right of the eye + one_hot_liberties[3, 4, 1] = 1 + one_hot_liberties[5, 4, 1] = 1 + + # the white group in the top left corner has 3 liberties + one_hot_liberties[1, 0:2, 2] = 1 + # ...as does the white piece at (4,5) + one_hot_liberties[4, 5, 2] = 1 + # ...and the black pieces on the sides of the eye + one_hot_liberties[3, 3, 2] = 1 + one_hot_liberties[5, 3, 2] = 1 + + # the black piece at (4,2) has 4 liberties + one_hot_liberties[4, 2, 3] = 1 + + for i in range(8): + self.assertTrue( + np.all(feature[:, :, i] == one_hot_liberties[:, :, i]), + "bad expectation: stones with %d liberties" % (i + 1)) + + def test_get_capture_size(self): + gs = capture_board() + pp = Preprocess(["capture_size"]) + feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) + + score_before = gs.num_white_prisoners + one_hot_capture = np.zeros((gs.size, gs.size, 8)) + # there is no capture available; all legal moves are zero-capture + for (x, y) in gs.get_legal_moves(): + copy = gs.copy() + copy.do_move((x, y)) + num_captured = copy.num_white_prisoners - score_before + one_hot_capture[x, y, min(7, num_captured)] = 1 + + for i in range(8): + self.assertTrue( + np.all(feature[:, :, i] == one_hot_capture[:, :, i]), + "bad expectation: capturing %d stones" % i) + + def test_get_self_atari_size(self): + gs = self_atari_board() + pp = Preprocess(["self_atari_size"]) + feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) + + one_hot_self_atari = np.zeros((gs.size, gs.size, 8)) + # self atari of size 1 at position 0,0 + one_hot_self_atari[0, 0, 0] = 1 + # self atari of size 3 at position 3,4 + one_hot_self_atari[3, 4, 2] = 1 + + self.assertTrue(np.all(feature == one_hot_self_atari)) + + def test_get_self_atari_size_cap(self): + gs = capture_board() + pp = Preprocess(["self_atari_size"]) + feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) + + one_hot_self_atari = np.zeros((gs.size, gs.size, 8)) + # self atari of size 1 at the ko position and just below it + one_hot_self_atari[4, 5, 0] = 1 + one_hot_self_atari[3, 6, 0] = 1 + # self atari of size 3 at bottom corner + one_hot_self_atari[6, 6, 2] = 1 + + self.assertTrue(np.all(feature == one_hot_self_atari)) + + def test_get_liberties_after(self): + gs = simple_board() + pp = Preprocess(["liberties_after"]) + feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) + + one_hot_liberties = np.zeros((gs.size, gs.size, 8)) + + # TODO (?) hand-code? + for (x, y) in gs.get_legal_moves(): + copy = gs.copy() + copy.do_move((x, y)) + libs = copy.liberty_counts[x, y] + if libs < 7: + one_hot_liberties[x, y, libs - 1] = 1 + else: + one_hot_liberties[x, y, 7] = 1 + + for i in range(8): + self.assertTrue( + np.all(feature[:, :, i] == one_hot_liberties[:, :, i]), + "bad expectation: stones with %d liberties after move" % (i + 1)) + + def test_get_liberties_after_cap(self): + """A copy of test_get_liberties_after but where captures are imminent + """ + gs = capture_board() + pp = Preprocess(["liberties_after"]) + feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) + + one_hot_liberties = np.zeros((gs.size, gs.size, 8)) + + for (x, y) in gs.get_legal_moves(): + copy = gs.copy() + copy.do_move((x, y)) + libs = copy.liberty_counts[x, y] + one_hot_liberties[x, y, min(libs - 1, 7)] = 1 + + for i in range(8): + self.assertTrue( + np.all(feature[:, :, i] == one_hot_liberties[:, :, i]), + "bad expectation: stones with %d liberties after move" % (i + 1)) + + def test_get_ladder_capture(self): + pass + + def test_get_ladder_escape(self): + pass + + def test_get_sensibleness(self): + # TODO - there are no legal eyes at the moment + + gs = simple_board() + pp = Preprocess(["sensibleness"]) + feature = pp.state_to_tensor(gs)[0, 0] # 1D tensor; no need to transpose + + expectation = np.zeros((gs.size, gs.size)) + for (x, y) in gs.get_legal_moves(): + if not (gs.is_eye((x, y), go.WHITE)): + expectation[x, y] = 1 + self.assertTrue(np.all(expectation == feature)) + + def test_get_legal(self): + gs = simple_board() + pp = Preprocess(["legal"]) + feature = pp.state_to_tensor(gs)[0, 0] # 1D tensor; no need to transpose + + expectation = np.zeros((gs.size, gs.size)) + for (x, y) in gs.get_legal_moves(): + expectation[x, y] = 1 + self.assertTrue(np.all(expectation == feature)) + + def test_feature_concatenation(self): + gs = simple_board() + pp = Preprocess(["board", "sensibleness", "capture_size"]) + feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) + + expectation = np.zeros((gs.size, gs.size, 3 + 1 + 8)) + + # first three planes: board + expectation[:, :, 0] = (gs.board == go.WHITE) * 1 + expectation[:, :, 1] = (gs.board == go.BLACK) * 1 + expectation[:, :, 2] = (gs.board == go.EMPTY) * 1 + + # 4th plane: sensibleness (as in test_get_sensibleness) + for (x, y) in gs.get_legal_moves(): + if not (gs.is_eye((x, y), go.WHITE)): + expectation[x, y, 3] = 1 + + # 5th through 12th plane: capture size (all zero-capture) + for (x, y) in gs.get_legal_moves(): + expectation[x, y, 4] = 1 + + self.assertTrue(np.all(expectation == feature)) if __name__ == '__main__': - unittest.main() + unittest.main() diff --git a/tests/test_reinforcement_policy_trainer.py b/tests/test_reinforcement_policy_trainer.py index 702d25332..03b041696 100644 --- a/tests/test_reinforcement_policy_trainer.py +++ b/tests/test_reinforcement_policy_trainer.py @@ -10,124 +10,124 @@ class TestReinforcementPolicyTrainer(unittest.TestCase): - def testTrain(self): - model = os.path.join('tests', 'test_data', 'minimodel.json') - init_weights = os.path.join('tests', 'test_data', 'hdf5', 'random_minimodel_weights.hdf5') - output = os.path.join('tests', 'test_data', '.tmp.rl.training/') - args = [model, init_weights, output, '--game-batch', '1', '--iterations', '1'] - run_training(args) + def testTrain(self): + model = os.path.join('tests', 'test_data', 'minimodel.json') + init_weights = os.path.join('tests', 'test_data', 'hdf5', 'random_minimodel_weights.hdf5') + output = os.path.join('tests', 'test_data', '.tmp.rl.training/') + args = [model, init_weights, output, '--game-batch', '1', '--iterations', '1'] + run_training(args) - os.remove(os.path.join(output, 'metadata.json')) - os.remove(os.path.join(output, 'weights.00000.hdf5')) - os.remove(os.path.join(output, 'weights.00001.hdf5')) - os.rmdir(output) + os.remove(os.path.join(output, 'metadata.json')) + os.remove(os.path.join(output, 'weights.00000.hdf5')) + os.remove(os.path.join(output, 'weights.00001.hdf5')) + os.rmdir(output) class TestOptimizer(unittest.TestCase): - def testApplyAndResetOnGamesFinished(self): - policy = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) - state = GameState(size=19) - optimizer = BatchedReinforcementLearningSGD(lr=0.01, ng=2) - policy.model.compile(loss='categorical_crossentropy', optimizer=optimizer) - - # Helper to check initial conditions of the optimizer. - def assertOptimizerInitialConditions(): - for v in optimizer.gradient_sign: - self.assertEqual(K.eval(v), 0) - self.assertEqual(K.eval(optimizer.running_games), 2) - - initial_parameters = policy.model.get_weights() - - def assertModelEffect(changed): - any_change = False - for cur, init in zip(policy.model.get_weights(), initial_parameters): - if not np.allclose(init, cur): - any_change = True - break - self.assertEqual(any_change, changed) - - assertOptimizerInitialConditions() - - # Make moves on the state and get trainable (state, action) pairs from them. - state_tensors = [] - action_tensors = [] - moves = [(2, 2), (16, 16), (3, 17), (16, 2), (4, 10), (10, 3)] - for m in moves: - (st_tensor, mv_tensor) = _make_training_pair(state, m, policy.preprocessor) - state_tensors.append(st_tensor) - action_tensors.append(mv_tensor) - state.do_move(m) - - for i, (s, a) in enumerate(zip(state_tensors, action_tensors)): - # Even moves in game 0, odd moves in game 1 - game_idx = i % 2 - optimizer.set_current_game(game_idx) - is_last_move = i + 2 >= len(moves) - if is_last_move: - # Mark game 0 as a win and game 1 as a loss. - optimizer.set_result(game_idx, game_idx == 0) - else: - # Games not finished yet; assert no change to optimizer state. - assertOptimizerInitialConditions() - # train_on_batch accumulates gradients, and should only cause a change to parameters - # on the first call after the final set_result() call - policy.model.train_on_batch(s, a) - if i + 1 < len(moves): - assertModelEffect(changed=False) - else: - assertModelEffect(changed=True) - # Once both games finished, the last call to train_on_batch() should have triggered a reset - # to the optimizer parameters back to initial conditions. - assertOptimizerInitialConditions() - - def testGradientDirectionChangesWithGameResult(self): - - def run_and_get_new_weights(init_weights, win0, win1): - state = GameState(size=19) - policy = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) - policy.model.set_weights(init_weights) - optimizer = BatchedReinforcementLearningSGD(lr=0.01, ng=2) - policy.model.compile(loss='categorical_crossentropy', optimizer=optimizer) - - # Make moves on the state and get trainable (state, action) pairs from them. - moves = [(2, 2), (16, 16), (3, 17), (16, 2), (4, 10), (10, 3)] - state_tensors = [] - action_tensors = [] - for m in moves: - (st_tensor, mv_tensor) = _make_training_pair(state, m, policy.preprocessor) - state_tensors.append(st_tensor) - action_tensors.append(mv_tensor) - state.do_move(m) - - for i, (s, a) in enumerate(zip(state_tensors, action_tensors)): - # Put even state/action pairs in game 0, odd ones in game 1. - game_idx = i % 2 - optimizer.set_current_game(game_idx) - is_last_move = i + 2 >= len(moves) - if is_last_move: - if game_idx == 0: - optimizer.set_result(game_idx, win0) - else: - optimizer.set_result(game_idx, win1) - # train_on_batch accumulates gradients, and should only cause a change to parameters - # on the first call after the final set_result() call - policy.model.train_on_batch(s, a) - return policy.model.get_weights() - - policy = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) - initial_parameters = policy.model.get_weights() - # Cases 1 and 2 have identical starting models and identical (state, action) pairs, - # but they differ in who won the games. - parameters1 = run_and_get_new_weights(initial_parameters, True, False) - parameters2 = run_and_get_new_weights(initial_parameters, False, True) - - # Changes in case 1 should be equal and opposite to changes in case 2. Allowing 0.1% - # difference in precision. - for (i, p1, p2) in zip(initial_parameters, parameters1, parameters2): - diff1 = p1 - i - diff2 = p2 - i - npt.assert_allclose(diff1, -diff2, rtol=1e-3) + def testApplyAndResetOnGamesFinished(self): + policy = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + state = GameState(size=19) + optimizer = BatchedReinforcementLearningSGD(lr=0.01, ng=2) + policy.model.compile(loss='categorical_crossentropy', optimizer=optimizer) + + # Helper to check initial conditions of the optimizer. + def assertOptimizerInitialConditions(): + for v in optimizer.gradient_sign: + self.assertEqual(K.eval(v), 0) + self.assertEqual(K.eval(optimizer.running_games), 2) + + initial_parameters = policy.model.get_weights() + + def assertModelEffect(changed): + any_change = False + for cur, init in zip(policy.model.get_weights(), initial_parameters): + if not np.allclose(init, cur): + any_change = True + break + self.assertEqual(any_change, changed) + + assertOptimizerInitialConditions() + + # Make moves on the state and get trainable (state, action) pairs from them. + state_tensors = [] + action_tensors = [] + moves = [(2, 2), (16, 16), (3, 17), (16, 2), (4, 10), (10, 3)] + for m in moves: + (st_tensor, mv_tensor) = _make_training_pair(state, m, policy.preprocessor) + state_tensors.append(st_tensor) + action_tensors.append(mv_tensor) + state.do_move(m) + + for i, (s, a) in enumerate(zip(state_tensors, action_tensors)): + # Even moves in game 0, odd moves in game 1 + game_idx = i % 2 + optimizer.set_current_game(game_idx) + is_last_move = i + 2 >= len(moves) + if is_last_move: + # Mark game 0 as a win and game 1 as a loss. + optimizer.set_result(game_idx, game_idx == 0) + else: + # Games not finished yet; assert no change to optimizer state. + assertOptimizerInitialConditions() + # train_on_batch accumulates gradients, and should only cause a change to parameters + # on the first call after the final set_result() call + policy.model.train_on_batch(s, a) + if i + 1 < len(moves): + assertModelEffect(changed=False) + else: + assertModelEffect(changed=True) + # Once both games finished, the last call to train_on_batch() should have triggered a reset + # to the optimizer parameters back to initial conditions. + assertOptimizerInitialConditions() + + def testGradientDirectionChangesWithGameResult(self): + + def run_and_get_new_weights(init_weights, win0, win1): + state = GameState(size=19) + policy = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + policy.model.set_weights(init_weights) + optimizer = BatchedReinforcementLearningSGD(lr=0.01, ng=2) + policy.model.compile(loss='categorical_crossentropy', optimizer=optimizer) + + # Make moves on the state and get trainable (state, action) pairs from them. + moves = [(2, 2), (16, 16), (3, 17), (16, 2), (4, 10), (10, 3)] + state_tensors = [] + action_tensors = [] + for m in moves: + (st_tensor, mv_tensor) = _make_training_pair(state, m, policy.preprocessor) + state_tensors.append(st_tensor) + action_tensors.append(mv_tensor) + state.do_move(m) + + for i, (s, a) in enumerate(zip(state_tensors, action_tensors)): + # Put even state/action pairs in game 0, odd ones in game 1. + game_idx = i % 2 + optimizer.set_current_game(game_idx) + is_last_move = i + 2 >= len(moves) + if is_last_move: + if game_idx == 0: + optimizer.set_result(game_idx, win0) + else: + optimizer.set_result(game_idx, win1) + # train_on_batch accumulates gradients, and should only cause a change to parameters + # on the first call after the final set_result() call + policy.model.train_on_batch(s, a) + return policy.model.get_weights() + + policy = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + initial_parameters = policy.model.get_weights() + # Cases 1 and 2 have identical starting models and identical (state, action) pairs, + # but they differ in who won the games. + parameters1 = run_and_get_new_weights(initial_parameters, True, False) + parameters2 = run_and_get_new_weights(initial_parameters, False, True) + + # Changes in case 1 should be equal and opposite to changes in case 2. Allowing 0.1% + # difference in precision. + for (i, p1, p2) in zip(initial_parameters, parameters1, parameters2): + diff1 = p1 - i + diff2 = p2 - i + npt.assert_allclose(diff1, -diff2, rtol=1e-3) if __name__ == '__main__': - unittest.main() + unittest.main() diff --git a/tests/test_supervised_policy_trainer.py b/tests/test_supervised_policy_trainer.py index fea59f361..6ff12baa0 100644 --- a/tests/test_supervised_policy_trainer.py +++ b/tests/test_supervised_policy_trainer.py @@ -4,17 +4,17 @@ class TestSupervisedPolicyTrainer(unittest.TestCase): - def testTrain(self): - model = 'tests/test_data/minimodel.json' - data = 'tests/test_data/hdf5/alphago-vs-lee-sedol-features.hdf5' - output = 'tests/test_data/.tmp.training/' - args = [model, data, output, '--epochs', '1'] - run_training(args) + def testTrain(self): + model = 'tests/test_data/minimodel.json' + data = 'tests/test_data/hdf5/alphago-vs-lee-sedol-features.hdf5' + output = 'tests/test_data/.tmp.training/' + args = [model, data, output, '--epochs', '1'] + run_training(args) - os.remove(os.path.join(output, 'metadata.json')) - os.remove(os.path.join(output, 'shuffle.npz')) - os.remove(os.path.join(output, 'weights.00000.hdf5')) - os.rmdir(output) + os.remove(os.path.join(output, 'metadata.json')) + os.remove(os.path.join(output, 'shuffle.npz')) + os.remove(os.path.join(output, 'weights.00000.hdf5')) + os.rmdir(output) if __name__ == '__main__': - unittest.main() + unittest.main() From 2f089be6ce702b934d0bdd3d63c5a878d503eec6 Mon Sep 17 00:00:00 2001 From: "Thouis (Ray) Jones" Date: Thu, 22 Sep 2016 09:30:09 -0400 Subject: [PATCH 109/191] autopep8 -r . -i --max-line-length 1000 --ignore E309 --- AlphaGo/ai.py | 2 +- AlphaGo/go.py | 2 +- AlphaGo/mcts.py | 1 + AlphaGo/models/nn_util.py | 1 + AlphaGo/preprocessing/preprocessing.py | 2 +- AlphaGo/training/reinforcement_policy_trainer.py | 1 + interface/Play.py | 3 ++- 7 files changed, 8 insertions(+), 4 deletions(-) diff --git a/AlphaGo/ai.py b/AlphaGo/ai.py index 437759960..0c2157c8b 100644 --- a/AlphaGo/ai.py +++ b/AlphaGo/ai.py @@ -87,7 +87,7 @@ def get_moves(self, states): class MCTSPlayer(object): def __init__(self, value_function, policy_function, rollout_function, lmbda=.5, c_puct=5, rollout_limit=500, playout_depth=40, n_playout=100): self.mcts = mcts.MCTS(value_function, policy_function, rollout_function, lmbda, c_puct, - rollout_limit, playout_depth, n_playout) + rollout_limit, playout_depth, n_playout) def get_move(self, state): sensible_moves = [move for move in state.get_legal_moves(include_eyes=False)] diff --git a/AlphaGo/go.py b/AlphaGo/go.py index 48b696fda..f56df4c16 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -427,7 +427,7 @@ def do_move(self, action, color=None): # Check for end of game if len(self.history) > 1: if self.history[-1] is PASS_MOVE and self.history[-2] is PASS_MOVE \ - and self.current_player == WHITE: + and self.current_player == WHITE: self.is_end_of_game = True return self.is_end_of_game diff --git a/AlphaGo/mcts.py b/AlphaGo/mcts.py index 962b4aabe..09f9439b8 100644 --- a/AlphaGo/mcts.py +++ b/AlphaGo/mcts.py @@ -11,6 +11,7 @@ class TreeNode(object): """A node in the MCTS tree. Each node keeps track of its own value Q, prior probability P, and its visit-count-adjusted prior score u. """ + def __init__(self, parent, prior_p): self._parent = parent self._children = {} # a map from action to TreeNode diff --git a/AlphaGo/models/nn_util.py b/AlphaGo/models/nn_util.py index 2f29211ae..dcaabfd3e 100644 --- a/AlphaGo/models/nn_util.py +++ b/AlphaGo/models/nn_util.py @@ -118,6 +118,7 @@ class Bias(Layer): Largely copied from the keras docs: http://keras.io/layers/writing-your-own-keras-layers/#writing-your-own-keras-layers """ + def __init__(self, **kwargs): super(Bias, self).__init__(**kwargs) diff --git a/AlphaGo/preprocessing/preprocessing.py b/AlphaGo/preprocessing/preprocessing.py index 12f9269ea..5455e3e9b 100644 --- a/AlphaGo/preprocessing/preprocessing.py +++ b/AlphaGo/preprocessing/preprocessing.py @@ -2,7 +2,7 @@ import AlphaGo.go as go ## -## individual feature functions (state --> tensor) begin here +# individual feature functions (state --> tensor) begin here ## diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 8bf36cc94..18fac78a3 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -24,6 +24,7 @@ class BatchedReinforcementLearningSGD(Optimizer): lr: float >= 0. Learning rate. ng: int > 0. Number of games played in parallel. Each one has its own cumulative gradient. ''' + def __init__(self, lr=0.01, ng=20, **kwargs): super(BatchedReinforcementLearningSGD, self).__init__(**kwargs) self.__dict__.update(locals()) diff --git a/interface/Play.py b/interface/Play.py index 58e3fe56d..059a5e06e 100644 --- a/interface/Play.py +++ b/interface/Play.py @@ -4,6 +4,7 @@ class play_match(object): """Interface to handle play between two players.""" + def __init__(self, player1, player2, save_dir=None, size=19): # super(ClassName, self).__init__() self.player1 = player1 @@ -19,7 +20,7 @@ def _play(self, player): # self.state.write_to_disk() if len(self.state.history) > 1: if self.state.history[-1] is None and self.state.history[-2] is None \ - and self.state.current_player == -1: + and self.state.current_player == -1: end_of_game = True else: end_of_game = False From b5f398686317c60c6c5844c4eed07cdae1ed5b42 Mon Sep 17 00:00:00 2001 From: "Thouis (Ray) Jones" Date: Wed, 21 Sep 2016 12:41:36 -0400 Subject: [PATCH 110/191] break long lines --- AlphaGo/ai.py | 12 ++++-- AlphaGo/go.py | 15 +++++--- AlphaGo/mcts.py | 9 +++-- AlphaGo/models/nn_util.py | 6 ++- AlphaGo/models/policy.py | 16 +++++--- AlphaGo/preprocessing/game_converter.py | 25 ++++++++----- AlphaGo/preprocessing/preprocessing.py | 18 ++++++--- .../training/reinforcement_policy_trainer.py | 35 ++++++++++++------ AlphaGo/training/supervised_policy_trainer.py | 37 +++++++++++++------ AlphaGo/util.py | 18 +++++---- benchmarks/preprocessing_benchmark.py | 3 +- ...reinforcement_policy_training_benchmark.py | 6 ++- interface/gtp_wrapper.py | 3 +- tests/test_game_converter.py | 8 +++- tests/test_gamestate.py | 4 +- 15 files changed, 141 insertions(+), 74 deletions(-) diff --git a/AlphaGo/ai.py b/AlphaGo/ai.py index 0c2157c8b..960c21d9a 100644 --- a/AlphaGo/ai.py +++ b/AlphaGo/ai.py @@ -54,12 +54,14 @@ def get_move(self, state): sensible_moves = [move for move in state.get_legal_moves(include_eyes=False)] if len(sensible_moves) > 0: move_probs = self.policy.eval_state(state, sensible_moves) - # zip(*list) is like the 'transpose' of zip; zip(*zip([1,2,3], [4,5,6])) is [(1,2,3), (4,5,6)] + # zip(*list) is like the 'transpose' of zip; + # zip(*zip([1,2,3], [4,5,6])) is [(1,2,3), (4,5,6)] moves, probabilities = zip(*move_probs) probabilities = np.array(probabilities) probabilities = probabilities ** self.beta probabilities = probabilities / probabilities.sum() - # numpy interprets a list of tuples as 2D, so we must choose an _index_ of moves then apply it in 2 steps + # numpy interprets a list of tuples as 2D, so we must choose an + # _index_ of moves then apply it in 2 steps choice_idx = np.random.choice(len(moves), p=probabilities) return moves[choice_idx] return go.PASS_MOVE @@ -67,7 +69,8 @@ def get_move(self, state): def get_moves(self, states): """Batch version of get_move. A list of moves is returned (one per state) """ - sensible_move_lists = [[move for move in st.get_legal_moves(include_eyes=False)] for st in states] + sensible_move_lists = [[move for move in st.get_legal_moves(include_eyes=False)] + for st in states] all_moves_distributions = self.policy.batch_eval_state(states, sensible_move_lists) move_list = [None] * len(states) for i, move_probs in enumerate(all_moves_distributions): @@ -85,7 +88,8 @@ def get_moves(self, states): class MCTSPlayer(object): - def __init__(self, value_function, policy_function, rollout_function, lmbda=.5, c_puct=5, rollout_limit=500, playout_depth=40, n_playout=100): + def __init__(self, value_function, policy_function, rollout_function, lmbda=.5, c_puct=5, + rollout_limit=500, playout_depth=40, n_playout=100): self.mcts = mcts.MCTS(value_function, policy_function, rollout_function, lmbda, c_puct, rollout_limit, playout_depth, n_playout) diff --git a/AlphaGo/go.py b/AlphaGo/go.py index f56df4c16..89f328b85 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -47,7 +47,8 @@ def __init__(self, size=19, komi=7.5, enforce_superko=False): # similarly to `liberty_sets`, `group_sets[x][y]` points to a set of tuples # containing all (x',y') pairs in the group connected to (x,y) self.group_sets = [[set() for _ in range(size)] for _ in range(size)] - # cache of list of legal moves (actually 'sensible' moves, with a separate list for eye-moves on request) + # cache of list of legal moves (actually 'sensible' moves, with a + # separate list for eye-moves on request) self.__legal_move_cache = None self.__legal_eyes_cache = None # on-the-fly record of 'age' of each stone @@ -104,7 +105,8 @@ def _create_neighbors_cache(self): GameState.__NEIGHBORS_CACHE[self.size] = {} for x in xrange(self.size): for y in xrange(self.size): - neighbors = [xy for xy in [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)] if self._on_board(xy)] + neighbors = [xy for xy in [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)] + if self._on_board(xy)] GameState.__NEIGHBORS_CACHE[self.size][(x, y)] = neighbors def _neighbors(self, position): @@ -117,7 +119,8 @@ def _diagonals(self, position): """Like _neighbors but for diagonal positions """ (x, y) = position - return filter(self._on_board, [(x - 1, y - 1), (x + 1, y + 1), (x + 1, y - 1), (x - 1, y + 1)]) + return filter(self._on_board, [(x - 1, y - 1), (x + 1, y + 1), + (x + 1, y - 1), (x - 1, y + 1)]) def _update_neighbors(self, position): """A private helper function to update self.group_sets and self.liberty_sets @@ -229,8 +232,10 @@ def is_suicide(self, action): return False def is_positional_superko(self, action): - """Find all actions that the current_player has done in the past, taking into account the fact that - history starts with BLACK when there are no handicaps or with WHITE when there are. + """Find all actions that the current_player has done in the past, taking into + account the fact that history starts with BLACK when there are no + handicaps or with WHITE when there are. + """ if len(self.handicaps) == 0 and self.current_player == BLACK: player_history = self.history[0::2] diff --git a/AlphaGo/mcts.py b/AlphaGo/mcts.py index 09f9439b8..479e56fd8 100644 --- a/AlphaGo/mcts.py +++ b/AlphaGo/mcts.py @@ -105,7 +105,8 @@ class MCTS(object): fast evaluation from leaf nodes to the end of the game. """ - def __init__(self, value_fn, policy_fn, rollout_policy_fn, lmbda=0.5, c_puct=5, rollout_limit=500, playout_depth=20, n_playout=10000): + def __init__(self, value_fn, policy_fn, rollout_policy_fn, lmbda=0.5, c_puct=5, + rollout_limit=500, playout_depth=20, n_playout=10000): """Arguments: value_fn -- a function that takes in a state and ouputs a score in [-1, 1], i.e. the expected value of the end game score from the current player's perspective. @@ -115,9 +116,9 @@ def __init__(self, value_fn, policy_fn, rollout_policy_fn, lmbda=0.5, c_puct=5, lmbda -- controls the relative weight of the value network and fast rollout policy result in determining the value of a leaf node. lmbda must be in [0, 1], where 0 means use only the value network and 1 means use only the result from the rollout. - c_puct -- a number in (0, inf) that controls how quickly exploration converges to the maximum- - value policy, where a higher value means relying on the prior more, and should be used only - in conjunction with a large value for n_playout. + c_puct -- a number in (0, inf) that controls how quickly exploration converges to the + maximum-value policy, where a higher value means relying on the prior more, and + should be used only in conjunction with a large value for n_playout. """ self._root = TreeNode(None, 1.0) self._value = value_fn diff --git a/AlphaGo/models/nn_util.py b/AlphaGo/models/nn_util.py index dcaabfd3e..9fe0eaa0f 100644 --- a/AlphaGo/models/nn_util.py +++ b/AlphaGo/models/nn_util.py @@ -46,7 +46,8 @@ def _model_forward(self): # be set to 0 when using the network in prediction mode and is automatically set to 1 # during training. if self.model.uses_learning_phase: - forward_function = K.function([self.model.input, K.learning_phase()], [self.model.output]) + forward_function = K.function([self.model.input, K.learning_phase()], + [self.model.output]) # the forward_function returns a list of tensors # the first [0] gets the front tensor. @@ -68,7 +69,8 @@ def load_model(json_file): try: network_class = NeuralNetBase.subclasses[class_name] except KeyError: - raise ValueError("Unknown neural network type in json file: {}\n(was it registered with the @neuralnet decorator?)".format(class_name)) + raise ValueError("Unknown neural network type in json file: {}\n" + "(was it registered with the @neuralnet decorator?)".format(class_name)) # create new object new_net = network_class(object_specs['feature_list'], init_network=False) diff --git a/AlphaGo/models/policy.py b/AlphaGo/models/policy.py index c16058cc2..2b3a802f0 100644 --- a/AlphaGo/models/policy.py +++ b/AlphaGo/models/policy.py @@ -40,13 +40,15 @@ def batch_eval_state(self, states, moves_lists=None): raise ValueError("all states must have the same size") # concatenate together all one-hot encoded states along the 'batch' dimension nn_input = np.concatenate([self.preprocessor.state_to_tensor(s) for s in states], axis=0) - # pass all input through the network at once (backend makes use of batches if len(states) is large) + # pass all input through the network at once (backend makes use of + # batches if len(states) is large) network_output = self.forward(nn_input) # default move lists to all legal moves moves_lists = moves_lists or [st.get_legal_moves() for st in states] results = [None] * n_states for i in range(n_states): - results[i] = self._select_moves_and_normalize(network_output[i], moves_lists[i], state_size) + results[i] = self._select_moves_and_normalize(network_output[i], moves_lists[i], + state_size) return results def eval_state(self, state, moves=None): @@ -168,10 +170,12 @@ def create_network(**kwargs): O - output M - merge - The input is always passed through a Conv2D layer, the output of which layer is counted as '1'. - Each subsequent [R -- C] block is counted as one 'layer'. The 'merge' layer isn't counted; hence - if n_skip_1 is 2, the next valid skip parameter is n_skip_3, which will start at the output - of the merge + The input is always passed through a Conv2D layer, the output of which + layer is counted as '1'. Each subsequent [R -- C] block is counted as + one 'layer'. The 'merge' layer isn't counted; hence if n_skip_1 is 2, + the next valid skip parameter is n_skip_3, which will start at the + output of the merge + """ defaults = { "board": 19, diff --git a/AlphaGo/preprocessing/game_converter.py b/AlphaGo/preprocessing/game_converter.py index 6324d254b..77bde6905 100644 --- a/AlphaGo/preprocessing/game_converter.py +++ b/AlphaGo/preprocessing/game_converter.py @@ -46,18 +46,22 @@ def sgfs_to_hdf5(self, sgf_files, hdf5_file, bd_size=19, ignore_errors=True, ver - sgf_files : an iterable of relative or absolute paths to SGF files - hdf5_file : the name of the HDF5 where features will be saved - bd_size : side length of board of games that are loaded - - ignore_errors : if True, issues a Warning when there is an unknown exception rather than halting. Note - that sgf.ParseException and go.IllegalMove exceptions are always skipped + + - ignore_errors : if True, issues a Warning when there is an unknown + exception rather than halting. Note that sgf.ParseException and + go.IllegalMove exceptions are always skipped The resulting file has the following properties: states : dataset with shape (n_data, n_features, board width, board height) - actions : dataset with shape (n_data, 2) (actions are stored as x,y tuples of where the move was played) + actions : dataset with shape (n_data, 2) (actions are stored as x,y tuples of + where the move was played) file_offsets : group mapping from filenames to tuples of (index, length) For example, to find what positions in the dataset come from 'test.sgf': index, length = file_offsets['test.sgf'] test_states = states[index:index+length] test_actions = actions[index:index+length] + """ # TODO - also save feature list @@ -72,9 +76,9 @@ def sgfs_to_hdf5(self, sgf_files, hdf5_file, bd_size=19, ignore_errors=True, ver 'states', dtype=np.uint8, shape=(1, self.n_features, bd_size, bd_size), - maxshape=(None, self.n_features, bd_size, bd_size), # 'None' dimension allows it to grow arbitrarily - exact=False, # allow non-uint8 datasets to be loaded, coerced to uint8 - chunks=(64, self.n_features, bd_size, bd_size), # approximately 1MB chunks + maxshape=(None, self.n_features, bd_size, bd_size), # 'None' == arbitrary size + exact=False, # allow non-uint8 datasets to be loaded, coerced to uint8 + chunks=(64, self.n_features, bd_size, bd_size), # approximately 1MB chunks compression="lzf") actions = h5f.require_dataset( 'actions', @@ -107,7 +111,8 @@ def sgfs_to_hdf5(self, sgf_files, hdf5_file, bd_size=19, ignore_errors=True, ver n_pairs += 1 next_idx += 1 except go.IllegalMove: - warnings.warn("Illegal Move encountered in %s\n\tdropping the remainder of the game" % file_name) + warnings.warn("Illegal Move encountered in %s\n" + "\tdropping the remainder of the game" % file_name) except sgf.ParseException: warnings.warn("Could not parse %s\n\tdropping game" % file_name) except SizeMismatchError: @@ -115,12 +120,14 @@ def sgfs_to_hdf5(self, sgf_files, hdf5_file, bd_size=19, ignore_errors=True, ver except Exception as e: # catch everything else if ignore_errors: - warnings.warn("Unkown exception with file %s\n\t%s" % (file_name, e), stacklevel=2) + warnings.warn("Unkown exception with file %s\n\t%s" % (file_name, e), + stacklevel=2) else: raise e finally: if n_pairs > 0: - # '/' has special meaning in HDF5 key names, so they are replaced with ':' here + # '/' has special meaning in HDF5 key names, so they + # are replaced with ':' here file_name_key = file_name.replace('/', ':') file_offsets[file_name_key] = [file_start_idx, n_pairs] if verbose: diff --git a/AlphaGo/preprocessing/preprocessing.py b/AlphaGo/preprocessing/preprocessing.py index 5455e3e9b..17690a1b3 100644 --- a/AlphaGo/preprocessing/preprocessing.py +++ b/AlphaGo/preprocessing/preprocessing.py @@ -43,7 +43,8 @@ def get_liberties(state, maximum=8): """ planes = np.zeros((maximum, state.size, state.size)) for i in range(maximum): - # single liberties in plane zero (groups won't have zero), double liberties in plane one, etc + # single liberties in plane zero (groups won't have zero), double + # liberties in plane one, etc planes[i, state.liberty_counts == i + 1] = 1 # the "maximum-or-more" case on the backmost plane planes[maximum - 1, state.liberty_counts >= maximum] = 1 @@ -51,14 +52,16 @@ def get_liberties(state, maximum=8): def get_capture_size(state, maximum=8): - """A feature encoding the number of opponent stones that would be captured by playing at each location, - up to 'maximum' + """A feature encoding the number of opponent stones that would be captured by + playing at each location, up to 'maximum' Note: - we currently *do* treat the 0th plane as "capturing zero stones" - - the [maximum-1] plane is used for any capturable group of size greater than or equal to maximum-1 + - the [maximum-1] plane is used for any capturable group of size + greater than or equal to maximum-1 - the 0th plane is used for legal moves that would not result in capture - illegal move locations are all-zero features + """ planes = np.zeros((maximum, state.size, state.size)) for (x, y) in state.get_legal_moves(): @@ -71,14 +74,17 @@ def get_capture_size(state, maximum=8): # (note suicide and ko are not an issue because they are not # legal moves) (gx, gy) = next(iter(neighbor_group)) - if (state.liberty_counts[gx][gy] == 1) and (state.board[gx, gy] != state.current_player): + if (state.liberty_counts[gx][gy] == 1) and \ + (state.board[gx, gy] != state.current_player): n_captured += len(state.group_sets[gx][gy]) planes[min(n_captured, maximum - 1), x, y] = 1 return planes def get_self_atari_size(state, maximum=8): - """A feature encoding the size of the own-stone group that is put into atari by playing at a location + """A feature encoding the size of the own-stone group that is put into atari by + playing at a location + """ planes = np.zeros((maximum, state.size, state.size)) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 18fac78a3..69242105a 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -58,7 +58,8 @@ def get_updates(self, params, constraints, loss): grads = self.get_gradients(loss, params) # Create a set of accumulated gradients, one for each game. shapes = [K.get_variable_shape(p) for p in params] - self.cumulative_gradients = [[K.zeros(shape) for shape in shapes] for _ in range(self.num_games)] + self.cumulative_gradients = [[K.zeros(shape) for shape in shapes] + for _ in range(self.num_games)] def conditional_update(cond, variable, new_value): '''Helper function to create updates that only happen when cond is True. Writes to @@ -123,9 +124,12 @@ def _make_training_pair(st, mv, preprocessor): def run_n_games(optimizer, learner, opponent, num_games): - '''Run num_games games to completion, calling train_batch() on each position the learner sees. + '''Run num_games games to completion, calling train_batch() on each position + the learner sees. + + (Note: optimizer only accumulates gradients in its update function until + all games have finished) - (Note: optimizer only accumulates gradients in its update function until all games have finished) ''' board_size = learner.policy.model.input_shape[-1] states = [GameState(size=board_size) for _ in range(num_games)] @@ -214,11 +218,14 @@ def run_training(cmd_line_args=None): # make a copy of weights file, "weights.00000.hdf5" in the output directory copyfile(args.initial_weights, os.path.join(args.out_directory, ZEROTH_FILE)) if args.verbose: - print "copied {} to {}".format(args.initial_weights, os.path.join(args.out_directory, ZEROTH_FILE)) + print "copied {} to {}".format(args.initial_weights, + os.path.join(args.out_directory, ZEROTH_FILE)) player_weights = ZEROTH_FILE else: - # if resuming, we expect initial_weights to be just a "weights.#####.hdf5" file, not a full path - args.initial_weights = os.path.join(args.out_directory, os.path.basename(args.initial_weights)) + # if resuming, we expect initial_weights to be just a + # "weights.#####.hdf5" file, not a full path + args.initial_weights = os.path.join(args.out_directory, + os.path.basename(args.initial_weights)) if not os.path.exists(args.initial_weights): raise ValueError("Cannot resume; weights {} do not exist".format(args.initial_weights)) elif args.verbose: @@ -228,12 +235,14 @@ def run_training(cmd_line_args=None): # Set initial conditions policy = CNNPolicy.load_model(args.model_json) policy.model.load_weights(args.initial_weights) - player = ProbabilisticPolicyPlayer(policy, temperature=args.policy_temp, move_limit=args.move_limit) + player = ProbabilisticPolicyPlayer(policy, temperature=args.policy_temp, + move_limit=args.move_limit) # different opponents come from simply changing the weights of 'opponent.policy.model'. That # is, only 'opp_policy' needs to be changed, and 'opponent' will change. opp_policy = CNNPolicy.load_model(args.model_json) - opponent = ProbabilisticPolicyPlayer(opp_policy, temperature=args.policy_temp, move_limit=args.move_limit) + opponent = ProbabilisticPolicyPlayer(opp_policy, temperature=args.policy_temp, + move_limit=args.move_limit) if args.verbose: print "created player and opponent with temperature {}".format(args.policy_temp) @@ -246,7 +255,8 @@ def run_training(cmd_line_args=None): "temperature": args.policy_temp, "game_batch": args.game_batch, "opponents": [ZEROTH_FILE], # which weights from which to sample an opponent each batch - "win_ratio": {} # map from player to tuple of (opponent, win ratio) Useful for validating in lieu of 'accuracy/loss' + "win_ratio": {} # map from player to tuple of (opponent, win ratio) Useful for + # validating in lieu of 'accuracy/loss' } else: with open(os.path.join(args.out_directory, "metadata.json"), "r") as f: @@ -262,8 +272,8 @@ def save_metadata(): optimizer = BatchedReinforcementLearningSGD(lr=args.learning_rate, ng=args.game_batch) player.policy.model.compile(loss='categorical_crossentropy', optimizer=optimizer) for i_iter in xrange(1, args.iterations + 1): - # Randomly choose opponent from pool (possibly self), and playing game_batch games against - # them. + # Randomly choose opponent from pool (possibly self), and playing + # game_batch games against them. opp_weights = np.random.choice(metadata["opponents"]) opp_path = os.path.join(args.out_directory, opp_weights) @@ -272,7 +282,8 @@ def save_metadata(): if args.verbose: print "Batch {}\tsampled opponent is {}".format(i_iter, opp_weights) - # Run games (and learn from results). Keep track of the win ratio vs each opponent over time. + # Run games (and learn from results). Keep track of the win ratio vs + # each opponent over time. win_ratio = run_n_games(optimizer, player, opponent, args.game_batch) metadata["win_ratio"][player_weights] = (opp_weights, win_ratio) diff --git a/AlphaGo/training/supervised_policy_trainer.py b/AlphaGo/training/supervised_policy_trainer.py index ec247812c..5fc12942d 100644 --- a/AlphaGo/training/supervised_policy_trainer.py +++ b/AlphaGo/training/supervised_policy_trainer.py @@ -15,7 +15,8 @@ def one_hot_action(action, size=19): return categorical -def shuffled_hdf5_batch_generator(state_dataset, action_dataset, indices, batch_size, transforms=[]): +def shuffled_hdf5_batch_generator(state_dataset, action_dataset, + indices, batch_size, transforms=[]): """A generator of batches of training data for use with the fit_generator function of Keras. Data is accessed in the order of the given indices for shuffling. """ @@ -29,7 +30,8 @@ def shuffled_hdf5_batch_generator(state_dataset, action_dataset, indices, batch_ # choose a random transformation of the data (rotations/reflections of the board) transform = np.random.choice(transforms) # get state from dataset and transform it. - # loop comprehension is used so that the transformation acts on the 3rd and 4th dimensions + # loop comprehension is used so that the transformation acts on the + # 3rd and 4th dimensions state = np.array([transform(plane) for plane in state_dataset[data_idx]]) # must be cast to a tuple so that it is interpreted as (x,y) not [(x,:), (y,:)] action_xy = tuple(action_dataset[data_idx]) @@ -115,10 +117,12 @@ def run_training(cmd_line_args=None): if args.verbose: if resume: - print "trying to resume from %s with weights %s" % (args.out_directory, os.path.join(args.out_directory, args.weights)) + print("trying to resume from %s with weights %s" % + (args.out_directory, os.path.join(args.out_directory, args.weights))) else: if os.path.exists(args.out_directory): - print "directory %s exists. any previous data will be overwritten" % args.out_directory + print("directory %s exists. any previous data will be overwritten" % + args.out_directory) else: print "starting fresh output directory %s" % args.out_directory @@ -127,11 +131,13 @@ def run_training(cmd_line_args=None): if resume: model.load_weights(os.path.join(args.out_directory, args.weights)) - # TODO - (waiting on game_converter) verify that features of model match features of training data + # TODO - (waiting on game_converter) verify that features of model match + # features of training data dataset = h5.File(args.train_data) n_total_data = len(dataset["states"]) n_train_data = int(args.train_val_test[0] * n_total_data) - # Need to make sure training data is divisible by minibatch size or get warning mentioning accuracy from keras + # Need to make sure training data is divisible by minibatch size or get + # warning mentioning accuracy from keras n_train_data = n_train_data - (n_train_data % args.minibatch) n_val_data = n_total_data - n_train_data # n_test_data = n_total_data - (n_train_data + n_val_data) @@ -154,15 +160,21 @@ def run_training(cmd_line_args=None): with open(meta_file, "r") as f: meta_writer.metadata = json.load(f) if args.verbose: - print "previous metadata loaded: %d epochs. new epochs will be appended." % len(meta_writer.metadata["epochs"]) + print("previous metadata loaded: %d epochs. new epochs will be appended." % + len(meta_writer.metadata["epochs"])) elif args.verbose: print "starting with empty metadata" - # the MetadataWriterCallback only sets 'epoch' and 'best_epoch'. We can add in anything else we like here - # TODO - model and train_data are saved in meta_file; check that they match (and make args optional when restarting?) + # the MetadataWriterCallback only sets 'epoch' and 'best_epoch'. We can add + # in anything else we like here + # + # TODO - model and train_data are saved in meta_file; check that they match + # (and make args optional when restarting?) meta_writer.metadata["training_data"] = args.train_data meta_writer.metadata["model_file"] = args.model - # Record all command line args in a list so that all args are recorded even when training is stopped and resumed. - meta_writer.metadata["cmd_line_args"] = meta_writer.metadata.get("cmd_line_args", []).append(vars(args)) + # Record all command line args in a list so that all args are recorded even + # when training is stopped and resumed. + meta_writer.metadata["cmd_line_args"] \ + = meta_writer.metadata.get("cmd_line_args", []).append(vars(args)) # create ModelCheckpoint to save weights every epoch checkpoint_template = os.path.join(args.out_directory, "weights.{epoch:05d}.hdf5") @@ -184,7 +196,8 @@ def run_training(cmd_line_args=None): np.save(f, shuffle_indices) if args.verbose: print "created new data shuffling indices" - # training indices are the first consecutive set of shuffled indices, val next, then test gets the remainder + # training indices are the first consecutive set of shuffled indices, val + # next, then test gets the remainder train_indices = shuffle_indices[0:n_train_data] val_indices = shuffle_indices[n_train_data:n_train_data + n_val_data] # test_indices = shuffle_indices[n_train_data + n_val_data:] diff --git a/AlphaGo/util.py b/AlphaGo/util.py index 6acb5062d..e2e918b03 100644 --- a/AlphaGo/util.py +++ b/AlphaGo/util.py @@ -62,7 +62,8 @@ def sgf_to_gamestate(sgf_string): return gs -def save_gamestate_to_sgf(gamestate, path, filename, black_player_name='Unknown', white_player_name='Unknown', size=19, komi=7.5): +def save_gamestate_to_sgf(gamestate, path, filename, black_player_name='Unknown', + white_player_name='Unknown', size=19, komi=7.5): """Creates a simplified sgf for viewing playouts or positions """ str_list = [] @@ -79,7 +80,8 @@ def save_gamestate_to_sgf(gamestate, path, filename, black_player_name='Unknown' str_list.append('HA[{}]'.format(len(gamestate.handicaps))) str_list.append(';AB') for handicap in gamestate.handicaps: - str_list.append('[{}{}]'.format(LETTERS[handicap[0]].lower(), LETTERS[handicap[1]].lower())) + str_list.append('[{}{}]'.format(LETTERS[handicap[0]].lower(), + LETTERS[handicap[1]].lower())) # Move list for move, color in zip(gamestate.history, itertools.cycle(cycle_string)): # Move color prefix @@ -97,12 +99,14 @@ def save_gamestate_to_sgf(gamestate, path, filename, black_player_name='Unknown' def sgf_iter_states(sgf_string, include_end=True): """Iterates over (GameState, move, player) tuples in the first game of the given SGF file. - Ignores variations - only the main line is returned. - The state object is modified in-place, so don't try to, for example, keep track of it through time + Ignores variations - only the main line is returned. The state object is + modified in-place, so don't try to, for example, keep track of it through + time + + If include_end is False, the final tuple yielded is the penultimate state, + but the state will still be left in the final position at the end of + iteration because 'gs' is modified in-place the state. See sgf_to_gamestate - If include_end is False, the final tuple yielded is the penultimate state, but the state - will still be left in the final position at the end of iteration because 'gs' is modified - in-place the state. See sgf_to_gamestate """ collection = sgf.parse(sgf_string) game = collection[0] diff --git a/benchmarks/preprocessing_benchmark.py b/benchmarks/preprocessing_benchmark.py index 635c0fd55..8774a1ff7 100644 --- a/benchmarks/preprocessing_benchmark.py +++ b/benchmarks/preprocessing_benchmark.py @@ -3,7 +3,8 @@ prof = Profile() -test_features = ["board", "turns_since", "liberties", "capture_size", "self_atari_size", "liberties_after", "sensibleness", "zeros"] +test_features = ["board", "turns_since", "liberties", "capture_size", "self_atari_size", + "liberties_after", "sensibleness", "zeros"] gc = game_converter(test_features) args = ('tests/test_data/sgf/Lee-Sedol-vs-AlphaGo-20160309.sgf', 19) diff --git a/benchmarks/reinforcement_policy_training_benchmark.py b/benchmarks/reinforcement_policy_training_benchmark.py index 4fc66407c..6573c1f62 100644 --- a/benchmarks/reinforcement_policy_training_benchmark.py +++ b/benchmarks/reinforcement_policy_training_benchmark.py @@ -5,7 +5,8 @@ # make a miniature model for playing on a miniature 7x7 board architecture = {'filters_per_layer': 32, 'layers': 4, 'board': 7} -features = ['board', 'ones', 'turns_since', 'liberties', 'capture_size', 'self_atari_size', 'liberties_after', 'sensibleness'] +features = ['board', 'ones', 'turns_since', 'liberties', 'capture_size', + 'self_atari_size', 'liberties_after', 'sensibleness'] policy = CNNPolicy(features, **architecture) datadir = os.path.join('benchmarks', 'data') @@ -21,7 +22,8 @@ policy.save_model(modelfile) profile = Profile() -arguments = (modelfile, weights, outdir, '--learning-rate', '0.001', '--save-every', '2', '--game-batch', '20', '--iterations', '10', '--verbose') +arguments = (modelfile, weights, outdir, '--learning-rate', '0.001', '--save-every', '2', + '--game-batch', '20', '--iterations', '10', '--verbose') profile.runcall(run_training, arguments) profile.dump_stats(stats_file) diff --git a/interface/gtp_wrapper.py b/interface/gtp_wrapper.py index 89d35f242..1dca11510 100644 --- a/interface/gtp_wrapper.py +++ b/interface/gtp_wrapper.py @@ -9,7 +9,8 @@ def run_gnugo(sgf_file_name, command): from distutils import spawn if spawn.find_executable('gnugo'): from subprocess import Popen, PIPE - p = Popen(['gnugo', '--chinese-rules', '--mode', 'gtp', '-l', sgf_file_name], stdout=PIPE, stdin=PIPE, stderr=PIPE) + p = Popen(['gnugo', '--chinese-rules', '--mode', 'gtp', '-l', sgf_file_name], + stdout=PIPE, stdin=PIPE, stderr=PIPE) out_bytes = p.communicate(input=command)[0] return out_bytes.decode('utf-8')[2:] else: diff --git a/tests/test_game_converter.py b/tests/test_game_converter.py index e0f4963f7..d38bf96fd 100644 --- a/tests/test_game_converter.py +++ b/tests/test_game_converter.py @@ -13,12 +13,16 @@ def test_ab_aw(self): class TestCmdlineConverter(unittest.TestCase): def test_directory_conversion(self): - args = ['--features', 'board,ones,turns_since', '--outfile', '.tmp.testing.h5', '--directory', 'tests/test_data/sgf/'] + args = ['--features', 'board,ones,turns_since', + '--outfile', '.tmp.testing.h5', + '--directory', 'tests/test_data/sgf/'] run_game_converter(args) os.remove('.tmp.testing.h5') def test_directory_walk(self): - args = ['--features', 'board,ones,turns_since', '--outfile', '.tmp.testing.h5', '--directory', 'tests/test_data', '--recurse'] + args = ['--features', 'board,ones,turns_since', + '--outfile', '.tmp.testing.h5', + '--directory', 'tests/test_data', '--recurse'] run_game_converter(args) os.remove('.tmp.testing.h5') diff --git a/tests/test_gamestate.py b/tests/test_gamestate.py index f1f28818b..59863f4e5 100644 --- a/tests/test_gamestate.py +++ b/tests/test_gamestate.py @@ -58,7 +58,9 @@ def test_snapback_is_not_ko(self): self.assertEqual(gs.num_white_prisoners, 1) def test_positional_superko(self): - move_list = [(0, 3), (0, 4), (1, 3), (1, 4), (2, 3), (2, 4), (2, 2), (3, 4), (2, 1), (3, 3), (3, 1), (3, 2), (3, 0), (4, 2), (1, 1), (4, 1), (8, 0), (4, 0), (8, 1), (0, 2), (8, 2), (0, 1), (8, 3), (1, 0), (8, 4), (2, 0), (0, 0)] + move_list = [(0, 3), (0, 4), (1, 3), (1, 4), (2, 3), (2, 4), (2, 2), (3, 4), (2, 1), (3, 3), + (3, 1), (3, 2), (3, 0), (4, 2), (1, 1), (4, 1), (8, 0), (4, 0), (8, 1), (0, 2), + (8, 2), (0, 1), (8, 3), (1, 0), (8, 4), (2, 0), (0, 0)] gs = GameState(size=9) for move in move_list: From f716f8309d0d59cbdb8ec53caaccb49ff8215a9f Mon Sep 17 00:00:00 2001 From: "Thouis (Ray) Jones" Date: Thu, 22 Sep 2016 09:58:48 -0400 Subject: [PATCH 111/191] fix set copy bug in GameState.copy() self.group_sets and self.liberty_sets should share the underlying sets between stones in the same group. This patch makes this the case for BoardState copies. --- AlphaGo/go.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/AlphaGo/go.py b/AlphaGo/go.py index 89f328b85..8049beeae 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -198,13 +198,21 @@ def copy(self): other.current_hash = self.current_hash.copy() other.previous_hashes = self.previous_hashes.copy() - # update liberty and group sets. Note: calling set(a) on another set - # copies the entries (any iterable as an argument would work so - # set(list(a)) is unnecessary) + # update liberty and group sets. + # + # group_sets and liberty_sets are shared between stones in the same + # group. We need to make sure this is the case in the copy, as well. + # + # we store set copies indexed by original id() in set_copies + def get_copy(s, set_copies={}): + if id(s) not in set_copies: + set_copies[id(s)] = set(s) # makes a copy of s + return set_copies[id(s)] + for x in range(self.size): for y in range(self.size): - other.group_sets[x][y] = set(self.group_sets[x][y]) - other.liberty_sets[x][y] = set(self.liberty_sets[x][y]) + other.group_sets[x][y] = get_copy(self.group_sets[x][y]) + other.liberty_sets[x][y] = get_copy(self.liberty_sets[x][y]) other.liberty_counts = self.liberty_counts.copy() return other From 66c734ff2696ee3bebb563b2b36b810d60d5fab8 Mon Sep 17 00:00:00 2001 From: "Thouis (Ray) Jones" Date: Thu, 22 Sep 2016 10:25:47 -0400 Subject: [PATCH 112/191] turn back on all flake8 warnings/errors, fix a few more long lines and comments --- AlphaGo/models/nn_util.py | 3 ++- AlphaGo/models/policy.py | 14 +++++++----- AlphaGo/models/value.py | 2 +- AlphaGo/preprocessing/game_converter.py | 12 +++++----- .../training/reinforcement_policy_trainer.py | 22 +++++++++---------- AlphaGo/training/supervised_policy_trainer.py | 20 ++++++++--------- tests/test_reinforcement_policy_trainer.py | 3 ++- tox.ini | 1 - 8 files changed, 40 insertions(+), 37 deletions(-) diff --git a/AlphaGo/models/nn_util.py b/AlphaGo/models/nn_util.py index 9fe0eaa0f..f3c67e727 100644 --- a/AlphaGo/models/nn_util.py +++ b/AlphaGo/models/nn_util.py @@ -70,7 +70,8 @@ def load_model(json_file): network_class = NeuralNetBase.subclasses[class_name] except KeyError: raise ValueError("Unknown neural network type in json file: {}\n" - "(was it registered with the @neuralnet decorator?)".format(class_name)) + "(was it registered with the @neuralnet decorator?)" + .format(class_name)) # create new object new_net = network_class(object_specs['feature_list'], init_network=False) diff --git a/AlphaGo/models/policy.py b/AlphaGo/models/policy.py index 2b3a802f0..64cb0e9c9 100644 --- a/AlphaGo/models/policy.py +++ b/AlphaGo/models/policy.py @@ -157,10 +157,10 @@ def create_network(**kwargs): A diagram may help explain (numbers indicate layer): - 1 2 3 4 5 6 - I--C -- B -- R -- C -- B -- R -- C -- M -- B -- R -- C -- B -- R -- C -- B -- R -- C -- M ... M -- R -- F -- O - \___________________________/ \____________________________________________________/ \ ... / - [n_skip_1 = 2] [n_skip_3 = 3] + 1 2 3 4 5 6 + I--C--B--R--C--B--R--C--M--B--R--C--B--R--C--B--R--C--M ... M --R--F--O + \__________________/ \___________________________/ \ ... / + [n_skip_1 = 2] [n_skip_3 = 3] I - input B - BatchNormalization @@ -208,7 +208,8 @@ def add_resnet_unit(path, K, **params): Returns new path and next layer index, i.e. K + n_skip_K, in a tuple """ # loosely based on https://github.com/keunwoochoi/residual_block_keras - # (see also keras docs here: http://keras.io/getting-started/functional-api-guide/#all-models-are-callable-just-like-layers) + # see also # keras docs here: + # http://keras.io/getting-started/functional-api-guide/#all-models-are-callable-just-like-layers block_input = path # use n_skip_K if it is there, default to 1 @@ -240,7 +241,8 @@ def add_resnet_unit(path, K, **params): while layer < params['layers']: convolution_path, layer = add_resnet_unit(convolution_path, layer, **params) if layer > params['layers']: - print "Due to skipping, ended with {} layers instead of {}".format(layer, params['layers']) + print ("Due to skipping, ended with {} layers instead of {}" + .format(layer, params['layers'])) # since each layer's activation was linear, need one more ReLu convolution_path = Activation('relu')(convolution_path) diff --git a/AlphaGo/models/value.py b/AlphaGo/models/value.py index f23f18205..df0b762bc 100644 --- a/AlphaGo/models/value.py +++ b/AlphaGo/models/value.py @@ -3,7 +3,7 @@ from keras.layers.core import Dense, Flatten # from SGD_exponential_decay import SGD_exponential_decay as SGD -### Parameters obtained from paper ### +# Parameters obtained from paper K = 152 # depth of convolutional layers LEARNING_RATE = .003 # initial learning rate DECAY = 8.664339379294006e-08 # rate of exponential learning_rate decay diff --git a/AlphaGo/preprocessing/game_converter.py b/AlphaGo/preprocessing/game_converter.py index 77bde6905..5decbb07a 100644 --- a/AlphaGo/preprocessing/game_converter.py +++ b/AlphaGo/preprocessing/game_converter.py @@ -158,12 +158,12 @@ def run_game_converter(cmd_line_args=None): epilog="Available features are: board, ones, turns_since, liberties,\ capture_size, self_atari_size, liberties_after, sensibleness, and zeros.\ Ladder features are not currently implemented") - parser.add_argument("--features", "-f", help="Comma-separated list of features to compute and store or 'all'", default='all') - parser.add_argument("--outfile", "-o", help="Destination to write data (hdf5 file)", required=True) - parser.add_argument("--recurse", "-R", help="Set to recurse through directories searching for SGF files", default=False, action="store_true") - parser.add_argument("--directory", "-d", help="Directory containing SGF files to process. if not present, expects files from stdin", default=None) - parser.add_argument("--size", "-s", help="Size of the game board. SGFs not matching this are discarded with a warning", type=int, default=19) - parser.add_argument("--verbose", "-v", help="Turn on verbose mode", default=False, action="store_true") + parser.add_argument("--features", "-f", help="Comma-separated list of features to compute and store or 'all'", default='all') # noqa: E501 + parser.add_argument("--outfile", "-o", help="Destination to write data (hdf5 file)", required=True) # noqa: E501 + parser.add_argument("--recurse", "-R", help="Set to recurse through directories searching for SGF files", default=False, action="store_true") # noqa: E501 + parser.add_argument("--directory", "-d", help="Directory containing SGF files to process. if not present, expects files from stdin", default=None) # noqa: E501 + parser.add_argument("--size", "-s", help="Size of the game board. SGFs not matching this are discarded with a warning", type=int, default=19) # noqa: E501 + parser.add_argument("--verbose", "-v", help="Turn on verbose mode", default=False, action="store_true") # noqa: E501 if cmd_line_args is None: args = parser.parse_args() diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 69242105a..d6e7aa4d4 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -184,18 +184,18 @@ def run_n_games(optimizer, learner, opponent, num_games): def run_training(cmd_line_args=None): import argparse - parser = argparse.ArgumentParser(description='Perform reinforcement learning to improve given policy network. Second phase of pipeline.') + parser = argparse.ArgumentParser(description='Perform reinforcement learning to improve given policy network. Second phase of pipeline.') # noqa: E501 parser.add_argument("model_json", help="Path to policy model JSON.") - parser.add_argument("initial_weights", help="Path to HDF5 file with inital weights (i.e. result of supervised training).") - parser.add_argument("out_directory", help="Path to folder where the model params and metadata will be saved after each epoch.") - parser.add_argument("--learning-rate", help="Keras learning rate (Default: 0.001)", type=float, default=0.001) - parser.add_argument("--policy-temp", help="Distribution temperature of players using policies (Default: 0.67)", type=float, default=0.67) - parser.add_argument("--save-every", help="Save policy as a new opponent every n batches (Default: 500)", type=int, default=500) - parser.add_argument("--game-batch", help="Number of games per mini-batch (Default: 20)", type=int, default=20) - parser.add_argument("--move-limit", help="Maximum number of moves per game", type=int, default=500) - parser.add_argument("--iterations", help="Number of training batches/iterations (Default: 10000)", type=int, default=10000) - parser.add_argument("--resume", help="Load latest weights in out_directory and resume", default=False, action="store_true") - parser.add_argument("--verbose", "-v", help="Turn on verbose mode", default=False, action="store_true") + parser.add_argument("initial_weights", help="Path to HDF5 file with inital weights (i.e. result of supervised training).") # noqa: E501 + parser.add_argument("out_directory", help="Path to folder where the model params and metadata will be saved after each epoch.") # noqa: E501 + parser.add_argument("--learning-rate", help="Keras learning rate (Default: 0.001)", type=float, default=0.001) # noqa: E501 + parser.add_argument("--policy-temp", help="Distribution temperature of players using policies (Default: 0.67)", type=float, default=0.67) # noqa: E501 + parser.add_argument("--save-every", help="Save policy as a new opponent every n batches (Default: 500)", type=int, default=500) # noqa: E501 + parser.add_argument("--game-batch", help="Number of games per mini-batch (Default: 20)", type=int, default=20) # noqa: E501 + parser.add_argument("--move-limit", help="Maximum number of moves per game", type=int, default=500) # noqa: E501 + parser.add_argument("--iterations", help="Number of training batches/iterations (Default: 10000)", type=int, default=10000) # noqa: E501 + parser.add_argument("--resume", help="Load latest weights in out_directory and resume", default=False, action="store_true") # noqa: E501 + parser.add_argument("--verbose", "-v", help="Turn on verbose mode", default=False, action="store_true") # noqa: E501 # Baseline function (TODO) default lambda state: 0 (receives either file # paths to JSON and weights or None, in which case it uses default baseline 0) if cmd_line_args is None: diff --git a/AlphaGo/training/supervised_policy_trainer.py b/AlphaGo/training/supervised_policy_trainer.py index 5fc12942d..2d20614f5 100644 --- a/AlphaGo/training/supervised_policy_trainer.py +++ b/AlphaGo/training/supervised_policy_trainer.py @@ -90,20 +90,20 @@ def run_training(cmd_line_args=None): import argparse parser = argparse.ArgumentParser(description='Perform supervised training on a policy network.') # required args - parser.add_argument("model", help="Path to a JSON model file (i.e. from CNNPolicy.save_model())") + parser.add_argument("model", help="Path to a JSON model file (i.e. from CNNPolicy.save_model())") # noqa: E501 parser.add_argument("train_data", help="A .h5 file of training data") parser.add_argument("out_directory", help="directory where metadata and weights will be saved") # frequently used args - parser.add_argument("--minibatch", "-B", help="Size of training data minibatches. Default: 16", type=int, default=16) - parser.add_argument("--epochs", "-E", help="Total number of iterations on the data. Default: 10", type=int, default=10) - parser.add_argument("--epoch-length", "-l", help="Number of training examples considered 'one epoch'. Default: # training data", type=int, default=None) - parser.add_argument("--learning-rate", "-r", help="Learning rate - how quickly the model learns at first. Default: .03", type=float, default=.03) - parser.add_argument("--decay", "-d", help="The rate at which learning decreases. Default: .0001", type=float, default=.0001) - parser.add_argument("--verbose", "-v", help="Turn on verbose mode", default=False, action="store_true") + parser.add_argument("--minibatch", "-B", help="Size of training data minibatches. Default: 16", type=int, default=16) # noqa: E501 + parser.add_argument("--epochs", "-E", help="Total number of iterations on the data. Default: 10", type=int, default=10) # noqa: E501 + parser.add_argument("--epoch-length", "-l", help="Number of training examples considered 'one epoch'. Default: # training data", type=int, default=None) # noqa: E501 + parser.add_argument("--learning-rate", "-r", help="Learning rate - how quickly the model learns at first. Default: .03", type=float, default=.03) # noqa: E501 + parser.add_argument("--decay", "-d", help="The rate at which learning decreases. Default: .0001", type=float, default=.0001) # noqa: E501 + parser.add_argument("--verbose", "-v", help="Turn on verbose mode", default=False, action="store_true") # noqa: E501 # slightly fancier args - parser.add_argument("--weights", help="Name of a .h5 weights file (in the output directory) to load to resume training", default=None) - parser.add_argument("--train-val-test", help="Fraction of data to use for training/val/test. Must sum to 1. Invalid if restarting training", nargs=3, type=float, default=[0.93, .05, .02]) - parser.add_argument("--symmetries", help="Comma-separated list of transforms, subset of noop,rot90,rot180,rot270,fliplr,flipud,diag1,diag2", default='noop,rot90,rot180,rot270,fliplr,flipud,diag1,diag2') + parser.add_argument("--weights", help="Name of a .h5 weights file (in the output directory) to load to resume training", default=None) # noqa: E501 + parser.add_argument("--train-val-test", help="Fraction of data to use for training/val/test. Must sum to 1. Invalid if restarting training", nargs=3, type=float, default=[0.93, .05, .02]) # noqa: E501 + parser.add_argument("--symmetries", help="Comma-separated list of transforms, subset of noop,rot90,rot180,rot270,fliplr,flipud,diag1,diag2", default='noop,rot90,rot180,rot270,fliplr,flipud,diag1,diag2') # noqa: E501 # TODO - an argument to specify which transformations to use, put it in metadata if cmd_line_args is None: diff --git a/tests/test_reinforcement_policy_trainer.py b/tests/test_reinforcement_policy_trainer.py index 03b041696..e2fc79373 100644 --- a/tests/test_reinforcement_policy_trainer.py +++ b/tests/test_reinforcement_policy_trainer.py @@ -1,5 +1,6 @@ import os -from AlphaGo.training.reinforcement_policy_trainer import run_training, _make_training_pair, BatchedReinforcementLearningSGD +from AlphaGo.training.reinforcement_policy_trainer import \ + run_training, _make_training_pair, BatchedReinforcementLearningSGD import unittest import numpy as np import numpy.testing as npt diff --git a/tox.ini b/tox.ini index bc4f040b4..ceac349ba 100644 --- a/tox.ini +++ b/tox.ini @@ -1,3 +1,2 @@ [flake8] -ignore = W191,E266,E501,E128 exclude = src/keras,src/theano,interface/opponents/pachi/pachi From c38cd05d0829e5b0d12b05b97b7f39f5eafe37ba Mon Sep 17 00:00:00 2001 From: "Thouis (Ray) Jones" Date: Thu, 22 Sep 2016 10:49:13 -0400 Subject: [PATCH 113/191] set max-line-length to 100 --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index ceac349ba..6de08aac1 100644 --- a/tox.ini +++ b/tox.ini @@ -1,2 +1,3 @@ [flake8] exclude = src/keras,src/theano,interface/opponents/pachi/pachi +max-line-length=100 From 88938f12c18e7171f60c554f195add84baba50c2 Mon Sep 17 00:00:00 2001 From: Robert Waite Date: Thu, 22 Sep 2016 20:26:35 -0700 Subject: [PATCH 114/191] Plotting network output as board in matplotlib --- AlphaGo/util.py | 104 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/AlphaGo/util.py b/AlphaGo/util.py index e2e918b03..4b67d0674 100644 --- a/AlphaGo/util.py +++ b/AlphaGo/util.py @@ -1,5 +1,6 @@ import os import itertools +import numpy as np import sgf from AlphaGo import go @@ -125,3 +126,106 @@ def sgf_iter_states(sgf_string, include_end=True): gs.do_move(move, player) if include_end: yield (gs, None, None) + + +def plot_network_output(scores, board, history, out_directory, output_file, + should_plot=False, western_column_notation=True): + try: + import matplotlib + import matplotlib.pyplot as plt + import matplotlib.cm as cm + except ImportError as e: + print( + 'Failed to import matplotlib. This is an optional dependency of ' + + 'the RocAlphaGo project, so it is not included in the requirements file. ' + + 'You must install matplotlib yourself to use the plotting functions.') + raise e + + from distutils.version import StrictVersion + matplotlib_version = matplotlib.__version__ + if StrictVersion(matplotlib_version) < StrictVersion('1.5.1'): + print('Your version of matplotlib might not support our use of it') + + # Initial matplotlib setup + fig, ax = plt.subplots(figsize=(10, 10)) + plt.xlim([0, board.size + 1]) + plt.ylim([0, board.size + 1]) + + # Wooden background color + ax.set_axis_bgcolor('#fec97b') + plt.gca().invert_yaxis() + + # Setup ticks + ax.tick_params(axis='both', length=0, width=0) + # Western notation has the origin at the lower-left + if western_column_notation: + plt.xticks(range(1, board.size + 1), range(1, board.size + 1)) + plt.yticks(range(1, board.size + 1), reversed(range(1, board.size + 1))) + # Traditional has the origin at the upper-left and uses letters minus 'I' along the top + else: + ax.xaxis.tick_top() + plt.xticks(range(1, board.size + 1), [x for x in LETTERS[:board.size + 1] if x != 'I']) + plt.yticks(range(1, board.size + 1), range(1, board.size + 1)) + + # Draw grid + for i in range(board.size): + plt.plot([1, board.size], [i + 1, i + 1], lw=1, color='k', zorder=0) + for i in range(board.size): + plt.plot([i + 1, i + 1], [1, board.size], lw=1, color='k', zorder=0) + + # Display network heat plots + reshaped = np.reshape(scores, (board.size, board.size)) + score_x_coords = [] + score_y_coords = [] + score_values = [] + for i in range(board.size): + for j in range(board.size): + if reshaped[i][j] * 100 >= 0.1: + score_x_coords.append(i + 1) + score_y_coords.append(j + 1) + score_values.append(reshaped[i][j]) + min_seen = np.amin(scores) + max_seen = np.amax(scores) + norm = matplotlib.colors.Normalize(vmin=min_seen, vmax=max_seen) + coloring = cm.ScalarMappable(norm=norm, cmap=cm.cool).to_rgba(score_values) + plt.scatter(score_x_coords, score_y_coords, marker='o', s=700, + c=coloring, edgecolor='k', zorder=1) + + # Display network scores on heat plots + for i, txt in enumerate(score_values): + ax.annotate('{0:.1f}'.format(txt * 100), (score_x_coords[i], score_y_coords[i]), + color='k', ha='center', + va='center', size=10, zorder=3) + + # Display stones already played + stone_x_coords = [] + stone_y_coords = [] + stone_colors = [] + for i in range(board.size): + for j in range(board.size): + if board[i][j] != go.EMPTY: + stone_x_coords.append(i + 1) + stone_y_coords.append(j + 1) + if board[i][j] == go.BLACK: + stone_colors.append(plt.to_rgb('black')) + else: + stone_colors.append(plt.to_rgb('white')) + plt.scatter(stone_x_coords, stone_y_coords, marker='o', edgecolors='k', + s=700, c=stone_colors, zorder=4) + + # Place red marker on last move if it exists + if len(history) != 0: + # If last move was not pass + if history[-1] != go.PASS_MOVE: + last_move = history[-1] + x_coord = last_move[0] + 1 + y_coord = last_move[1] + 1 + last_move = (x_coord, y_coord) + plt.scatter(last_move[0], last_move[1], marker='s', color='r', + edgecolors='k', s=100, zorder=5) + + if output_file is not None: + plt.savefig(os.path.join(out_directory, output_file), bbox_inches='tight') + if should_plot: + plt.show() + plt.close() From 2950370afbc58ac4c6fbb2e608a8548630207826 Mon Sep 17 00:00:00 2001 From: wrongu Date: Fri, 23 Sep 2016 08:27:21 -0400 Subject: [PATCH 115/191] Adding test in test_gamestate demonstrating bug in GameState.copy() This bug was fixed in the previous commit. --- tests/test_gamestate.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_gamestate.py b/tests/test_gamestate.py index 59863f4e5..efd6239a5 100644 --- a/tests/test_gamestate.py +++ b/tests/test_gamestate.py @@ -132,6 +132,9 @@ def test_eye_recursion(self): gs.do_move((x, y), go.BLACK) self.assertTrue(gs.is_eye((0, 0), go.BLACK)) + +class TestCacheSets(unittest.TestCase): + def test_liberties_after_capture(self): # creates 3x3 black group in the middle, that is then all captured # ...then an assertion is made that the resulting liberties after @@ -161,6 +164,19 @@ def test_liberties_after_capture(self): self.assertTrue(np.all(gs_reference.board == gs_capture.board)) self.assertTrue(np.all(gs_reference.liberty_counts == gs_capture.liberty_counts)) + def test_copy_maintains_shared_sets(self): + gs = GameState(7) + gs.do_move((4, 4), go.BLACK) + gs.do_move((4, 5), go.BLACK) + + # assert that gs has *the same object* referenced by group/liberty sets + self.assertTrue(gs.group_sets[4][5] is gs.group_sets[4][4]) + self.assertTrue(gs.liberty_sets[4][5] is gs.liberty_sets[4][4]) + + gs_copy = gs.copy() + self.assertTrue(gs_copy.group_sets[4][5] is gs_copy.group_sets[4][4]) + self.assertTrue(gs_copy.liberty_sets[4][5] is gs_copy.liberty_sets[4][4]) + if __name__ == '__main__': unittest.main() From daa1255793e4e53d996cb5a0b5ba74305f11138a Mon Sep 17 00:00:00 2001 From: "Thouis (Ray) Jones" Date: Mon, 19 Sep 2016 13:35:35 -0400 Subject: [PATCH 116/191] Add ladder capture and escape features, tests for same. Ladder features are computed by is_ladder_capture() and is_ladder_escape() in GameState. A ladder capture is defined as a move by one player (the "hunter" in the code) that leads to a forced capture of an adjacent enemy group (the "prey") with two liberties before the move. A ladder escape is defined as a move (by the prey) that brings a group with one liberty to 3 or more liberties, or to 2 liberties neither of which is a ladder_capture for the hunter against that prey group. The two functions call each other recursively. The prey group can be specified in the call to each function, but the default is to treat any adjacent group as potential prey. Several tests, adapted from Fuego's code (LGPL license) are included, along with a helper function in tests/parser_board.py to simplify specifying tests via ascii boards. --- AlphaGo/go.py | 118 ++++++++++++++++++++++++++++++++++ tests/parseboard.py | 31 +++++++++ tests/test_ladders.py | 145 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 294 insertions(+) create mode 100644 tests/parseboard.py create mode 100644 tests/test_ladders.py diff --git a/AlphaGo/go.py b/AlphaGo/go.py index 8049beeae..7bbb73941 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -326,6 +326,124 @@ def is_eye(self, position, owner, stack=[]): return False return True + def is_ladder_capture(self, action, prey=None): + """Check if moving at action results in a ladder capture, defined as being next + to an enemy group with two liberties, and with no ladder_escape move afterward + for the other player. + + If prey is None, check all adjacent groups, otherwise only the prey + group is checked. In the (prey is None) case, if this move is a ladder + capture for any adjance group, it's considered a ladder capture. + + """ + + # ignore illegal moves + if not self.is_legal(action): + return False + + hunter_player = self.current_player + prey_player = - self.current_player + + if prey is None: + # default case is to check all adjacent prey_player groups that + # have 2 liberties + neighbor_groups_stones = [next(iter(group)) for group in self.get_groups_around(action)] + potential_prey = [(nx, ny) for (nx, ny) in neighbor_groups_stones + if (self.board[nx][ny] == prey_player and + self.liberty_counts[nx][ny] == 2)] + else: + # we are checking a specific group (called from is_ladder_escape) + potential_prey = [prey] + + for (prey_x, prey_y) in potential_prey: + # attempt to capture the group at prey_x, prey_y in a ladder + tmp = self.copy() + tmp.do_move(action) + + # we only want to check a limited set of possible escape moves: + # - extensions from the remaining liberty of the prey group. + # - captures of enemy groups adjacent to the prey group. + possible_escapes = tmp.liberty_sets[prey_x][prey_y].copy() + + # Check if any hunter groups adjacent to the prey groups + # are in atari. Capturing these groups are potential escapes. + # + # TODO: make this more efficient, possibly by adding an + # "adjacent_groups" cache + for prey_stone in tmp.group_sets[prey_x][prey_y]: + for (nx, ny) in tmp._neighbors(prey_stone): + if (tmp.board[nx][ny] == hunter_player) and (tmp.liberty_counts[nx][ny] == 1): + possible_escapes |= tmp.liberty_sets[nx][ny] + + if not any(tmp.is_ladder_escape((escape_x, escape_y), prey=(prey_x, prey_y)) + for (escape_x, escape_y) in possible_escapes): + # we found at least one group that could be captured in a + # ladder, so this move is a ladder capture. + return True + + # no ladder captures were found + return False + + def is_ladder_escape(self, action, prey=None): + """Check if moving at action results in a ladder escape, defined as being next + to a current player's group with one liberty, with no ladder captures + afterward. Going from 1 to >= 3 liberties is counted as escape, or a + move giving two liberties without a subsequent ladder capture. + + If prey is None, check all adjacent groups, otherwise only the prey + group is checked. In the (prey is None) case, if this move is a ladder + escape for any adjacent group, this move is a ladder escape. + + """ + + # ignore illegal moves + if not self.is_legal(action): + return False + + prey_player = self.current_player + + if prey is None: + # default case is to check all adjacent groups that might be in a + # ladder (i.e., with one liberty) + neighbor_groups_stones = [next(iter(group)) for group in self.get_groups_around(action)] + potential_prey = [(nx, ny) for (nx, ny) in neighbor_groups_stones + if (self.board[nx][ny] == prey_player and + self.liberty_counts[nx][ny] == 1)] + else: + # we are checking a specific group (called from is_ladder_capture) + potential_prey = [prey] + + # This move is an escape if it's an escape for any of the potential_prey + for (prey_x, prey_y) in potential_prey: + # make the move, see if the group at (prey_x, prey_y) has escaped, + # defined as having >= 3 liberties, or 2 liberties and not + # ladder_capture() being true when played on either of those + # liberties. + tmp = self.copy() + tmp.do_move(action) + + # if we have >= 3 liberties, we've escaped + if tmp.liberty_counts[prey_x][prey_y] >= 3: + return True + + # if we only have 1 liberty, we've failed + if tmp.liberty_counts[prey_x][prey_y] == 1: + # not an escape - check next group + continue + + # The current group has two liberties. It may still be in a ladder. + # Check both liberties to see if they are ladder captures + if any(tmp.is_ladder_capture(possible_capture, prey=(prey_x, prey_y)) + for possible_capture in tmp.liberty_sets[prey_x][prey_y]): + # not an escape - check next group + continue + + # reached two liberties that were no longer ladder-capturable + return True + + # no ladder escape found + return False + def get_legal_moves(self, include_eyes=True): if self.__legal_move_cache is not None: if include_eyes: diff --git a/tests/parseboard.py b/tests/parseboard.py new file mode 100644 index 000000000..95359d302 --- /dev/null +++ b/tests/parseboard.py @@ -0,0 +1,31 @@ +from AlphaGo.go import GameState, BLACK, WHITE + + +def parse(boardstr): + '''Parses a board into a gamestate, and returns the location of any moves + marked with anything other than 'X', 'O', or '.' + + Rows are separated by '|', spaces are ignored. + + ''' + + boardstr = boardstr.replace(' ', '') + board_size = max(boardstr.index('|'), boardstr.count('|')) + + st = GameState(size=board_size) + moves = {} + + for row, rowstr in enumerate(boardstr.split('|')): + for col, c in enumerate(rowstr): + if c == '.': + continue # ignore empty spaces + elif c in 'BX#': + st.do_move((row, col), color=BLACK) + elif c in 'WO': + st.do_move((row, col), color=WHITE) + else: + # move reference + assert c not in moves, "{} already used as a move marker".format(c) + moves[c] = (row, col) + + return st, moves diff --git a/tests/test_ladders.py b/tests/test_ladders.py new file mode 100644 index 000000000..a417b10c0 --- /dev/null +++ b/tests/test_ladders.py @@ -0,0 +1,145 @@ +from AlphaGo.go import BLACK, WHITE +import unittest + +import parseboard + + +class TestLadder(unittest.TestCase): + def test_captured_1(self): + st, moves = parseboard.parse("d b c . . . .|" + "B W a . . . .|" + ". B . . . . .|" + ". . . . . . .|" + ". . . . . . .|" + ". . . . . W .|") + st.current_player = BLACK + + # 'a' should catch white in a ladder, but not 'b' + self.assertTrue(st.is_ladder_capture(moves['a'])) + self.assertFalse(st.is_ladder_capture(moves['b'])) + + # 'b' should not be an escape move for white after 'a' + st.do_move(moves['a']) + self.assertFalse(st.is_ladder_escape(moves['b'])) + + # W at 'b', check 'c' and 'd' + st.do_move(moves['b']) + self.assertTrue(st.is_ladder_capture(moves['c'])) + self.assertFalse(st.is_ladder_capture(moves['d'])) # self-atari + + def test_breaker_1(self): + st, moves = parseboard.parse(". B . . . . .|" + "B W a . . W .|" + "B b . . . . .|" + ". c . . . . .|" + ". . . . . . .|" + ". . . . . W .|" + ". . . . . . .|") + st.current_player = BLACK + + # 'a' should not be a ladder capture, nor 'b' + self.assertFalse(st.is_ladder_capture(moves['a'])) + self.assertFalse(st.is_ladder_capture(moves['b'])) + + # after 'a', 'b' should be an escape + st.do_move(moves['a']) + self.assertTrue(st.is_ladder_escape(moves['b'])) + + # after 'b', 'c' should not be a capture + st.do_move(moves['b']) + self.assertFalse(st.is_ladder_capture(moves['c'])) + + def test_missing_ladder_breaker_1(self): + st, moves = parseboard.parse(". B . . . . .|" + "B W B . . W .|" + "B a c . . . .|" + ". b . . . . .|" + ". . . . . . .|" + ". W . . . . .|" + ". . . . . . .|") + st.current_player = WHITE + + # a should not be an escape move for white + self.assertFalse(st.is_ladder_escape(moves['a'])) + + # after 'a', 'b' should still be a capture ... + st.do_move(moves['a']) + self.assertTrue(st.is_ladder_capture(moves['b'])) + # ... but 'c' should not + self.assertFalse(st.is_ladder_capture(moves['c'])) + + def test_capture_to_escape_1(self): + st, moves = parseboard.parse(". O X . . .|" + ". X O X . .|" + ". . O X . .|" + ". . a . . .|" + ". O . . . .|" + ". . . . . .|") + st.current_player = BLACK + + # 'a' is not a capture because of ataris + self.assertFalse(st.is_ladder_capture(moves['a'])) + + def test_throw_in_1(self): + st, moves = parseboard.parse("X a O X . .|" + "b O O X . .|" + "O O X X . .|" + "X X . . . .|" + ". . . . . .|" + ". . . O . .|") + st.current_player = BLACK + + # 'a' or 'b' will capture + self.assertTrue(st.is_ladder_capture(moves['a'])) + self.assertTrue(st.is_ladder_capture(moves['b'])) + + # after 'a', 'b' doesn't help white escape + st.do_move(moves['a']) + self.assertFalse(st.is_ladder_escape(moves['b'])) + + def test_snapback_1(self): + st, moves = parseboard.parse(". . . . . . . . .|" + ". . . . . . . . .|" + ". . X X X . . . .|" + ". . O . . . . . .|" + ". . O X . . . . .|" + ". . X O a . . . .|" + ". . X O X . . . .|" + ". . . X . . . . .|" + ". . . . . . . . .|") + st.current_player = WHITE + + # 'a' is not an escape for white + self.assertFalse(st.is_ladder_escape(moves['a'])) + + def test_two_captures(self): + st, moves = parseboard.parse(". . . . . .|" + ". . . . . .|" + ". . a b . .|" + ". X O O X .|" + ". . X X . .|" + ". . . . . .|") + st.current_player = BLACK + + # both 'a' and 'b' should be ladder captures + self.assertTrue(st.is_ladder_capture(moves['a'])) + self.assertTrue(st.is_ladder_capture(moves['b'])) + + def test_two_escapes(self): + st, moves = parseboard.parse(". . X . . .|" + ". X O a . .|" + ". X c X . .|" + ". O X b . .|" + ". . O . . .|" + ". . . . . .|") + + # place a white stone at c, and reset player to white + st.do_move(moves['c'], color=WHITE) + st.current_player = WHITE + + # both 'a' and 'b' should be considered escape moves for white after 'O' at c + self.assertTrue(st.is_ladder_escape(moves['a'])) + self.assertTrue(st.is_ladder_escape(moves['b'], prey=moves['c'])) + +if __name__ == '__main__': + unittest.main() From d4d9da4f6f77737caebe500b88ab64fc319ed8cf Mon Sep 17 00:00:00 2001 From: wrongu Date: Sun, 25 Sep 2016 08:17:39 -0400 Subject: [PATCH 117/191] Correct loss function in RL policy trainer. fixes #166 --- AlphaGo/training/reinforcement_policy_trainer.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index d6e7aa4d4..ff3a20a44 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -182,6 +182,13 @@ def run_n_games(optimizer, learner, opponent, num_games): return float(wins) / num_games +def log_loss(y_true, y_pred): + '''Keras 'loss' function for the REINFORCE algorithm, where y_true is the action that was + taken, and updates with the positive gradient will make that action more likely. + ''' + return y_true * K.log(K.clip(y_pred, K.epsilon(), 1.0 - K.epsilon())) + + def run_training(cmd_line_args=None): import argparse parser = argparse.ArgumentParser(description='Perform reinforcement learning to improve given policy network. Second phase of pipeline.') # noqa: E501 @@ -270,7 +277,7 @@ def save_metadata(): json.dump(metadata, f, sort_keys=True, indent=2) optimizer = BatchedReinforcementLearningSGD(lr=args.learning_rate, ng=args.game_batch) - player.policy.model.compile(loss='categorical_crossentropy', optimizer=optimizer) + player.policy.model.compile(loss=log_loss, optimizer=optimizer) for i_iter in xrange(1, args.iterations + 1): # Randomly choose opponent from pool (possibly self), and playing # game_batch games against them. From 8e85564ef1dd1cd3805b3b8b9150e521079a6667 Mon Sep 17 00:00:00 2001 From: wrongu Date: Sun, 25 Sep 2016 14:40:42 -0400 Subject: [PATCH 118/191] Updated test_reinforcement_policy_trainer to use log_loss --- tests/test_reinforcement_policy_trainer.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_reinforcement_policy_trainer.py b/tests/test_reinforcement_policy_trainer.py index e2fc79373..4317949e3 100644 --- a/tests/test_reinforcement_policy_trainer.py +++ b/tests/test_reinforcement_policy_trainer.py @@ -1,6 +1,6 @@ import os from AlphaGo.training.reinforcement_policy_trainer import \ - run_training, _make_training_pair, BatchedReinforcementLearningSGD + run_training, _make_training_pair, BatchedReinforcementLearningSGD, log_loss import unittest import numpy as np import numpy.testing as npt @@ -30,7 +30,7 @@ def testApplyAndResetOnGamesFinished(self): policy = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) state = GameState(size=19) optimizer = BatchedReinforcementLearningSGD(lr=0.01, ng=2) - policy.model.compile(loss='categorical_crossentropy', optimizer=optimizer) + policy.model.compile(loss=log_loss, optimizer=optimizer) # Helper to check initial conditions of the optimizer. def assertOptimizerInitialConditions(): @@ -89,7 +89,7 @@ def run_and_get_new_weights(init_weights, win0, win1): policy = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) policy.model.set_weights(init_weights) optimizer = BatchedReinforcementLearningSGD(lr=0.01, ng=2) - policy.model.compile(loss='categorical_crossentropy', optimizer=optimizer) + policy.model.compile(loss=log_loss, optimizer=optimizer) # Make moves on the state and get trainable (state, action) pairs from them. moves = [(2, 2), (16, 16), (3, 17), (16, 2), (4, 10), (10, 3)] From 2acd68798b524004f9b483ade6602ae741f74a3b Mon Sep 17 00:00:00 2001 From: "Thouis (Ray) Jones" Date: Mon, 26 Sep 2016 09:36:13 -0400 Subject: [PATCH 119/191] fix comment for other stone labels. --- tests/parseboard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/parseboard.py b/tests/parseboard.py index 95359d302..431947301 100644 --- a/tests/parseboard.py +++ b/tests/parseboard.py @@ -3,7 +3,7 @@ def parse(boardstr): '''Parses a board into a gamestate, and returns the location of any moves - marked with anything other than 'X', 'O', or '.' + marked with anything other than 'B', 'X', '#', 'W', 'O', or '.' Rows are separated by '|', spaces are ignored. From a86f67417b98255fcbf9ce93900c215093f7df14 Mon Sep 17 00:00:00 2001 From: wrongu Date: Mon, 12 Sep 2016 22:35:01 -0400 Subject: [PATCH 120/191] Applying temperature in log space in ProbabilisticPolicyPlayer fixes #153 --- AlphaGo/ai.py | 16 +++++++++++++--- tests/test_players.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 tests/test_players.py diff --git a/AlphaGo/ai.py b/AlphaGo/ai.py index 960c21d9a..95eb80cb2 100644 --- a/AlphaGo/ai.py +++ b/AlphaGo/ai.py @@ -45,6 +45,17 @@ def __init__(self, policy_function, temperature=1.0, pass_when_offered=False, mo self.pass_when_offered = pass_when_offered self.move_limit = move_limit + def apply_temperature(self, distribution): + log_probabilities = np.log(distribution) + # apply beta exponent to probabilities (in log space) + log_probabilities = log_probabilities * self.beta + # scale probabilities to a more numerically stable range (in log space) + log_probabilities = log_probabilities - log_probabilities.max() + # convert back from log space + probabilities = np.exp(log_probabilities) + # re-normalize the distribution + return probabilities / probabilities.sum() + def get_move(self, state): if self.move_limit is not None and len(state.history) > self.move_limit: return go.PASS_MOVE @@ -57,9 +68,8 @@ def get_move(self, state): # zip(*list) is like the 'transpose' of zip; # zip(*zip([1,2,3], [4,5,6])) is [(1,2,3), (4,5,6)] moves, probabilities = zip(*move_probs) - probabilities = np.array(probabilities) - probabilities = probabilities ** self.beta - probabilities = probabilities / probabilities.sum() + # apply 'temperature' to the distribution + probabilities = self.apply_temperature(probabilities) # numpy interprets a list of tuples as 2D, so we must choose an # _index_ of moves then apply it in 2 steps choice_idx = np.random.choice(len(moves), p=probabilities) diff --git a/tests/test_players.py b/tests/test_players.py new file mode 100644 index 000000000..497034c3d --- /dev/null +++ b/tests/test_players.py @@ -0,0 +1,37 @@ +from AlphaGo.ai import ProbabilisticPolicyPlayer +import numpy as np +import unittest + + +class TestProbabilisticPolicyPlayer(unittest.TestCase): + + def test_temperature_increases_entropy(self): + # helper function to get the entropy of a distribution + def entropy(distribution): + distribution = np.array(distribution).flatten() + return -np.dot(np.log(distribution), distribution.T) + player_low = ProbabilisticPolicyPlayer(None, temperature=0.9) + player_high = ProbabilisticPolicyPlayer(None, temperature=1.1) + + distribution = np.random.random(361) + distribution = distribution / distribution.sum() + + base_entropy = entropy(distribution) + high_entropy = entropy(player_high.apply_temperature(distribution)) + low_entropy = entropy(player_low.apply_temperature(distribution)) + + self.assertGreater(high_entropy, base_entropy) + self.assertLess(low_entropy, base_entropy) + + def test_extreme_temperature_is_numerically_stable(self): + player_low = ProbabilisticPolicyPlayer(None, temperature=1e-12) + player_high = ProbabilisticPolicyPlayer(None, temperature=1e+12) + + distribution = np.random.random(361) + distribution = distribution / distribution.sum() + + self.assertFalse(any(np.isnan(player_low.apply_temperature(distribution)))) + self.assertFalse(any(np.isnan(player_high.apply_temperature(distribution)))) + +if __name__ == '__main__': + unittest.main() From f3ba88a1f464b9b609c38ee4243d63e5cfd55a48 Mon Sep 17 00:00:00 2001 From: wrongu Date: Sat, 29 Oct 2016 09:45:56 -0400 Subject: [PATCH 121/191] ladder features put in preprocessing. only two small tests added; ladders are tested extensively in test_ladders --- AlphaGo/preprocessing/preprocessing.py | 14 ++++++++++-- tests/test_preprocessing.py | 31 ++++++++++++++++++++++++-- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/AlphaGo/preprocessing/preprocessing.py b/AlphaGo/preprocessing/preprocessing.py index 17690a1b3..3120e5d0c 100644 --- a/AlphaGo/preprocessing/preprocessing.py +++ b/AlphaGo/preprocessing/preprocessing.py @@ -165,11 +165,21 @@ def get_liberties_after(state, maximum=8): def get_ladder_capture(state): - raise NotImplementedError() + """A feature wrapping GameState.is_ladder_capture(). + """ + feature = np.zeros((1, state.size, state.size)) + for (x, y) in state.get_legal_moves(): + feature[0, x, y] = state.is_ladder_capture((x, y)) + return feature def get_ladder_escape(state): - raise NotImplementedError() + """A feature wrapping GameState.is_ladder_escape(). + """ + feature = np.zeros((1, state.size, state.size)) + for (x, y) in state.get_legal_moves(): + feature[0, x, y] = state.is_ladder_escape((x, y)) + return feature def get_sensibleness(state): diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index a21e1851d..5d0cbf67a 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -2,6 +2,7 @@ import AlphaGo.go as go import numpy as np import unittest +import parseboard def simple_board(): @@ -281,10 +282,36 @@ def test_get_liberties_after_cap(self): "bad expectation: stones with %d liberties after move" % (i + 1)) def test_get_ladder_capture(self): - pass + gs, moves = parseboard.parse(". . . . . . .|" + "B W a . . . .|" + ". B . . . . .|" + ". . . . . . .|" + ". . . . . . .|" + ". . . . . W .|") + pp = Preprocess(["ladder_capture"]) + feature = pp.state_to_tensor(gs)[0, 0] # 1D tensor; no need to transpose + + expectation = np.zeros((gs.size, gs.size)) + expectation[moves['a']] = 1 + + self.assertTrue(np.all(expectation == feature)) def test_get_ladder_escape(self): - pass + # On this board, playing at 'a' is ladder escape because there is a breaker on the right. + gs, moves = parseboard.parse(". B B . . . .|" + "B W a . . . .|" + ". B . . . . .|" + ". . . . . W .|" + ". . . . . . .|" + ". . . . . . .|") + pp = Preprocess(["ladder_escape"]) + gs.current_player = go.WHITE + feature = pp.state_to_tensor(gs)[0, 0] # 1D tensor; no need to transpose + + expectation = np.zeros((gs.size, gs.size)) + expectation[moves['a']] = 1 + + self.assertTrue(np.all(expectation == feature)) def test_get_sensibleness(self): # TODO - there are no legal eyes at the moment From 7d7f53dd22b5fe2d6b05d27af780adbb0c99608b Mon Sep 17 00:00:00 2001 From: "Thouis (Ray) Jones" Date: Wed, 16 Nov 2016 10:29:09 -0500 Subject: [PATCH 122/191] Add maximum depth limit to ladder features. --- AlphaGo/go.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/AlphaGo/go.py b/AlphaGo/go.py index 7bbb73941..f98a450cb 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -326,7 +326,7 @@ def is_eye(self, position, owner, stack=[]): return False return True - def is_ladder_capture(self, action, prey=None): + def is_ladder_capture(self, action, prey=None, remaining_attempts=30): """Check if moving at action results in a ladder capture, defined as being next to an enemy group with two liberties, and with no ladder_escape move afterward for the other player. @@ -335,12 +335,20 @@ def is_ladder_capture(self, action, prey=None): group is checked. In the (prey is None) case, if this move is a ladder capture for any adjance group, it's considered a ladder capture. + Recursion depth between is_ladder_capture() and is_ladder_escape() is + controlled by the remaining_attempts argument. If it reaches 0, the + move is assumed not to be a ladder capture. + """ # ignore illegal moves if not self.is_legal(action): return False + # if we haven't found a capture by a certain number of moves, give up. + if remaining_attempts <= 0: + return False + hunter_player = self.current_player prey_player = - self.current_player @@ -375,7 +383,8 @@ def is_ladder_capture(self, action, prey=None): if (tmp.board[nx][ny] == hunter_player) and (tmp.liberty_counts[nx][ny] == 1): possible_escapes |= tmp.liberty_sets[nx][ny] - if not any(tmp.is_ladder_escape((escape_x, escape_y), prey=(prey_x, prey_y)) + if not any(tmp.is_ladder_escape((escape_x, escape_y), prey=(prey_x, prey_y), + remaining_attempts=(remaining_attempts - 1)) for (escape_x, escape_y) in possible_escapes): # we found at least one group that could be captured in a # ladder, so this move is a ladder capture. @@ -384,7 +393,7 @@ def is_ladder_capture(self, action, prey=None): # no ladder captures were found return False - def is_ladder_escape(self, action, prey=None): + def is_ladder_escape(self, action, prey=None, remaining_attempts=30): """Check if moving at action results in a ladder escape, defined as being next to a current player's group with one liberty, with no ladder captures afterward. Going from 1 to >= 3 liberties is counted as escape, or a @@ -394,12 +403,20 @@ def is_ladder_escape(self, action, prey=None): group is checked. In the (prey is None) case, if this move is a ladder escape for any adjacent group, this move is a ladder escape. + Recursion depth between is_ladder_capture() and is_ladder_escape() is + controlled by the remaining_attempts argument. If it reaches 0, the + move is assumed not to be a ladder capture. + """ # ignore illegal moves if not self.is_legal(action): return False + # if we haven't found an escape by a certain number of moves, give up. + if remaining_attempts <= 0: + return False + prey_player = self.current_player if prey is None: @@ -433,7 +450,8 @@ def is_ladder_escape(self, action, prey=None): # The current group has two liberties. It may still be in a ladder. # Check both liberties to see if they are ladder captures - if any(tmp.is_ladder_capture(possible_capture, prey=(prey_x, prey_y)) + if any(tmp.is_ladder_capture(possible_capture, prey=(prey_x, prey_y), + remaining_attempts=(remaining_attempts - 1)) for possible_capture in tmp.liberty_sets[prey_x][prey_y]): # not an escape - check next group continue From b7b55953fcb0a1e36391ae0facad1fe5ae07f533 Mon Sep 17 00:00:00 2001 From: "Thouis (Ray) Jones" Date: Thu, 17 Nov 2016 08:55:28 -0500 Subject: [PATCH 123/191] Change depth to 40, make ladder captures True by default at maximum depth. --- AlphaGo/go.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/AlphaGo/go.py b/AlphaGo/go.py index f98a450cb..e832fad5d 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -326,7 +326,7 @@ def is_eye(self, position, owner, stack=[]): return False return True - def is_ladder_capture(self, action, prey=None, remaining_attempts=30): + def is_ladder_capture(self, action, prey=None, remaining_attempts=40): """Check if moving at action results in a ladder capture, defined as being next to an enemy group with two liberties, and with no ladder_escape move afterward for the other player. @@ -345,9 +345,9 @@ def is_ladder_capture(self, action, prey=None, remaining_attempts=30): if not self.is_legal(action): return False - # if we haven't found a capture by a certain number of moves, give up. + # if we haven't found a capture by a certain number of moves, assume it's worked. if remaining_attempts <= 0: - return False + return True hunter_player = self.current_player prey_player = - self.current_player @@ -393,7 +393,7 @@ def is_ladder_capture(self, action, prey=None, remaining_attempts=30): # no ladder captures were found return False - def is_ladder_escape(self, action, prey=None, remaining_attempts=30): + def is_ladder_escape(self, action, prey=None, remaining_attempts=40): """Check if moving at action results in a ladder escape, defined as being next to a current player's group with one liberty, with no ladder captures afterward. Going from 1 to >= 3 liberties is counted as escape, or a From 80de8302f65bf2f8b165dae6672da5d3580aaecd Mon Sep 17 00:00:00 2001 From: wrongu Date: Thu, 17 Nov 2016 09:42:51 -0500 Subject: [PATCH 124/191] Python3 style compatibility --- AlphaGo/ai.py | 3 ++- AlphaGo/go.py | 4 ++-- AlphaGo/mcts.py | 9 +++++---- AlphaGo/models/policy.py | 4 ++-- AlphaGo/models/value.py | 1 + .../training/reinforcement_policy_trainer.py | 15 ++++++++------- AlphaGo/training/supervised_policy_trainer.py | 19 ++++++++++--------- benchmarks/preprocessing_benchmark.py | 1 + .../supervised_policy_training_benchmark.py | 1 + tests/test_game_converter.py | 1 + tests/test_ladders.py | 1 + tests/test_liberties.py | 1 + tests/test_mcts.py | 4 +++- tests/test_players.py | 1 + tests/test_preprocessing.py | 1 + tests/test_reinforcement_policy_trainer.py | 1 + tests/test_supervised_policy_trainer.py | 1 + 17 files changed, 42 insertions(+), 26 deletions(-) diff --git a/AlphaGo/ai.py b/AlphaGo/ai.py index 95eb80cb2..a2a43d25c 100644 --- a/AlphaGo/ai.py +++ b/AlphaGo/ai.py @@ -1,5 +1,6 @@ """Policy players""" import numpy as np +from operator import itemgetter from AlphaGo import go from AlphaGo import mcts @@ -23,7 +24,7 @@ def get_move(self, state): sensible_moves = [move for move in state.get_legal_moves(include_eyes=False)] if len(sensible_moves) > 0: move_probs = self.policy.eval_state(state, sensible_moves) - max_prob = max(move_probs, key=lambda (a, p): p) + max_prob = max(move_probs, key=itemgetter(1)) return max_prob[0] # No 'sensible' moves available, so do pass move return go.PASS_MOVE diff --git a/AlphaGo/go.py b/AlphaGo/go.py index e832fad5d..23152b181 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -103,8 +103,8 @@ def _on_board(self, position): def _create_neighbors_cache(self): if self.size not in GameState.__NEIGHBORS_CACHE: GameState.__NEIGHBORS_CACHE[self.size] = {} - for x in xrange(self.size): - for y in xrange(self.size): + for x in range(self.size): + for y in range(self.size): neighbors = [xy for xy in [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)] if self._on_board(xy)] GameState.__NEIGHBORS_CACHE[self.size][(x, y)] = neighbors diff --git a/AlphaGo/mcts.py b/AlphaGo/mcts.py index 479e56fd8..de30b0ef7 100644 --- a/AlphaGo/mcts.py +++ b/AlphaGo/mcts.py @@ -5,6 +5,7 @@ policy function, and value function. """ import numpy as np +from operator import itemgetter class TreeNode(object): @@ -42,7 +43,7 @@ def select(self): Returns: A tuple of (action, next_node) """ - return max(self._children.iteritems(), key=lambda (action, node): node.get_value()) + return max(self._children.iteritems(), key=lambda act_node: act_node[1].get_value()) def update(self, leaf_value, c_puct): """Update node values from leaf evaluation. @@ -176,11 +177,11 @@ def _evaluate_rollout(self, state, limit): action_probs = self._rollout(state) if len(action_probs) == 0: break - max_action = max(action_probs, key=lambda (a, p): p)[0] + max_action = max(action_probs, key=itemgetter(1))[0] state.do_move(max_action) else: # If no break from the loop, issue a warning. - print "WARNING: rollout reached move limit" + print("WARNING: rollout reached move limit") winner = state.get_winner() if winner == 0: return 0 @@ -202,7 +203,7 @@ def get_move(self, state): # chosen action is the *most visited child*, not the highest-value one # (they are the same as self._n_playout gets large). - return max(self._root._children.iteritems(), key=lambda (a, n): n._n_visits)[0] + return max(self._root._children.iteritems(), key=lambda act_node: act_node[1]._n_visits)[0] def update_with_move(self, last_move): """Step forward in the tree, keeping everything we already know about the subtree, assuming diff --git a/AlphaGo/models/policy.py b/AlphaGo/models/policy.py index 64cb0e9c9..6a59b9f65 100644 --- a/AlphaGo/models/policy.py +++ b/AlphaGo/models/policy.py @@ -241,8 +241,8 @@ def add_resnet_unit(path, K, **params): while layer < params['layers']: convolution_path, layer = add_resnet_unit(convolution_path, layer, **params) if layer > params['layers']: - print ("Due to skipping, ended with {} layers instead of {}" - .format(layer, params['layers'])) + print("Due to skipping, ended with {} layers instead of {}" + .format(layer, params['layers'])) # since each layer's activation was linear, need one more ReLu convolution_path = Activation('relu')(convolution_path) diff --git a/AlphaGo/models/value.py b/AlphaGo/models/value.py index df0b762bc..43482f34f 100644 --- a/AlphaGo/models/value.py +++ b/AlphaGo/models/value.py @@ -38,6 +38,7 @@ def train(self): # TODO use self.model.fit_generator to train from data source pass + if __name__ == '__main__': trainer = value_trainer() # TODO command line instantiation diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index ff3a20a44..15552dac2 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -218,15 +218,15 @@ def run_training(cmd_line_args=None): if not os.path.exists(args.out_directory): if args.verbose: - print "creating output directory {}".format(args.out_directory) + print("creating output directory {}".format(args.out_directory)) os.makedirs(args.out_directory) if not args.resume: # make a copy of weights file, "weights.00000.hdf5" in the output directory copyfile(args.initial_weights, os.path.join(args.out_directory, ZEROTH_FILE)) if args.verbose: - print "copied {} to {}".format(args.initial_weights, - os.path.join(args.out_directory, ZEROTH_FILE)) + print("copied {} to {}".format(args.initial_weights, + os.path.join(args.out_directory, ZEROTH_FILE))) player_weights = ZEROTH_FILE else: # if resuming, we expect initial_weights to be just a @@ -236,7 +236,7 @@ def run_training(cmd_line_args=None): if not os.path.exists(args.initial_weights): raise ValueError("Cannot resume; weights {} do not exist".format(args.initial_weights)) elif args.verbose: - print "Resuming with weights {}".format(args.initial_weights) + print("Resuming with weights {}".format(args.initial_weights)) player_weights = os.path.basename(args.initial_weights) # Set initial conditions @@ -252,7 +252,7 @@ def run_training(cmd_line_args=None): move_limit=args.move_limit) if args.verbose: - print "created player and opponent with temperature {}".format(args.policy_temp) + print("created player and opponent with temperature {}".format(args.policy_temp)) if not args.resume: metadata = { @@ -278,7 +278,7 @@ def save_metadata(): optimizer = BatchedReinforcementLearningSGD(lr=args.learning_rate, ng=args.game_batch) player.policy.model.compile(loss=log_loss, optimizer=optimizer) - for i_iter in xrange(1, args.iterations + 1): + for i_iter in range(1, args.iterations + 1): # Randomly choose opponent from pool (possibly self), and playing # game_batch games against them. opp_weights = np.random.choice(metadata["opponents"]) @@ -287,7 +287,7 @@ def save_metadata(): # Load new weights into opponent's network, but keep the same opponent object. opponent.policy.model.load_weights(opp_path) if args.verbose: - print "Batch {}\tsampled opponent is {}".format(i_iter, opp_weights) + print("Batch {}\tsampled opponent is {}".format(i_iter, opp_weights)) # Run games (and learn from results). Keep track of the win ratio vs # each opponent over time. @@ -303,5 +303,6 @@ def save_metadata(): metadata["opponents"].append(player_weights) save_metadata() + if __name__ == '__main__': run_training() diff --git a/AlphaGo/training/supervised_policy_trainer.py b/AlphaGo/training/supervised_policy_trainer.py index 2d20614f5..603a4ad89 100644 --- a/AlphaGo/training/supervised_policy_trainer.py +++ b/AlphaGo/training/supervised_policy_trainer.py @@ -124,7 +124,7 @@ def run_training(cmd_line_args=None): print("directory %s exists. any previous data will be overwritten" % args.out_directory) else: - print "starting fresh output directory %s" % args.out_directory + print("starting fresh output directory %s" % args.out_directory) # load model from json spec model = CNNPolicy.load_model(args.model).model @@ -143,10 +143,10 @@ def run_training(cmd_line_args=None): # n_test_data = n_total_data - (n_train_data + n_val_data) if args.verbose: - print "datset loaded" - print "\t%d total samples" % n_total_data - print "\t%d training samples" % n_train_data - print "\t%d validaion samples" % n_val_data + print("datset loaded") + print("\t%d total samples" % n_total_data) + print("\t%d training samples" % n_train_data) + print("\t%d validaion samples" % n_val_data) # ensure output directory is available if not os.path.exists(args.out_directory): @@ -163,7 +163,7 @@ def run_training(cmd_line_args=None): print("previous metadata loaded: %d epochs. new epochs will be appended." % len(meta_writer.metadata["epochs"])) elif args.verbose: - print "starting with empty metadata" + print("starting with empty metadata") # the MetadataWriterCallback only sets 'epoch' and 'best_epoch'. We can add # in anything else we like here # @@ -188,14 +188,14 @@ def run_training(cmd_line_args=None): with open(shuffle_file, "r") as f: shuffle_indices = np.load(f) if args.verbose: - print "loading previous data shuffling indices" + print("loading previous data shuffling indices") else: # create shuffled indices shuffle_indices = np.random.permutation(n_total_data) with open(shuffle_file, "w") as f: np.save(f, shuffle_indices) if args.verbose: - print "created new data shuffling indices" + print("created new data shuffling indices") # training indices are the first consecutive set of shuffled indices, val # next, then test gets the remainder train_indices = shuffle_indices[0:n_train_data] @@ -224,7 +224,7 @@ def run_training(cmd_line_args=None): samples_per_epoch = args.epoch_length or n_train_data if args.verbose: - print "STARTING TRAINING" + print("STARTING TRAINING") model.fit_generator( generator=train_data_generator, @@ -234,5 +234,6 @@ def run_training(cmd_line_args=None): validation_data=val_data_generator, nb_val_samples=n_val_data) + if __name__ == '__main__': run_training() diff --git a/benchmarks/preprocessing_benchmark.py b/benchmarks/preprocessing_benchmark.py index 8774a1ff7..72abacf33 100644 --- a/benchmarks/preprocessing_benchmark.py +++ b/benchmarks/preprocessing_benchmark.py @@ -13,5 +13,6 @@ def run_convert_game(): for traindata in gc.convert_game(*args): pass + prof.runcall(run_convert_game) prof.dump_stats('bench_results.prof') diff --git a/benchmarks/supervised_policy_training_benchmark.py b/benchmarks/supervised_policy_training_benchmark.py index d84676611..28d7ed301 100644 --- a/benchmarks/supervised_policy_training_benchmark.py +++ b/benchmarks/supervised_policy_training_benchmark.py @@ -16,5 +16,6 @@ def run_supervised_policy_training(): run_training(*arguments) + profile.runcall(run_supervised_policy_training) profile.dump_stats('supervised_policy_training_bench_results.prof') diff --git a/tests/test_game_converter.py b/tests/test_game_converter.py index d38bf96fd..1d6c119d2 100644 --- a/tests/test_game_converter.py +++ b/tests/test_game_converter.py @@ -26,5 +26,6 @@ def test_directory_walk(self): run_game_converter(args) os.remove('.tmp.testing.h5') + if __name__ == '__main__': unittest.main() diff --git a/tests/test_ladders.py b/tests/test_ladders.py index a417b10c0..bc4ce2237 100644 --- a/tests/test_ladders.py +++ b/tests/test_ladders.py @@ -141,5 +141,6 @@ def test_two_escapes(self): self.assertTrue(st.is_ladder_escape(moves['a'])) self.assertTrue(st.is_ladder_escape(moves['b'], prey=moves['c'])) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_liberties.py b/tests/test_liberties.py index 3bcca7a6b..66599a15a 100644 --- a/tests/test_liberties.py +++ b/tests/test_liberties.py @@ -39,5 +39,6 @@ def test_neighbors_edge_cases(self): # get_group of a single piece self.assertEqual(len(st.get_group((5, 5))), 1, "group size of single piece") + if __name__ == '__main__': unittest.main() diff --git a/tests/test_mcts.py b/tests/test_mcts.py index 3537515a9..d31d6de46 100644 --- a/tests/test_mcts.py +++ b/tests/test_mcts.py @@ -1,5 +1,6 @@ from AlphaGo.go import GameState from AlphaGo.mcts import MCTS, TreeNode +from operator import itemgetter import numpy as np import unittest @@ -63,7 +64,7 @@ def _count_expansions(self): node = self.mcts._root expansions = 0 # Loop over actions in decreasing probability. - for action, _ in sorted(dummy_policy(self.gs), key=lambda (a, p): p, reverse=True): + for action, _ in sorted(dummy_policy(self.gs), key=itemgetter(1), reverse=True): if action in node._children: expansions += 1 node = node._children[action] @@ -120,6 +121,7 @@ def dummy_policy(state): moves = state.get_legal_moves(include_eyes=False) return zip(moves, dummy_distribution) + # Rollout is a clone of the policy function. dummy_rollout = dummy_policy diff --git a/tests/test_players.py b/tests/test_players.py index 497034c3d..ea0c7de4e 100644 --- a/tests/test_players.py +++ b/tests/test_players.py @@ -33,5 +33,6 @@ def test_extreme_temperature_is_numerically_stable(self): self.assertFalse(any(np.isnan(player_low.apply_temperature(distribution)))) self.assertFalse(any(np.isnan(player_high.apply_temperature(distribution)))) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index 5d0cbf67a..d81102051 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -359,5 +359,6 @@ def test_feature_concatenation(self): self.assertTrue(np.all(expectation == feature)) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_reinforcement_policy_trainer.py b/tests/test_reinforcement_policy_trainer.py index 4317949e3..49f280d4f 100644 --- a/tests/test_reinforcement_policy_trainer.py +++ b/tests/test_reinforcement_policy_trainer.py @@ -130,5 +130,6 @@ def run_and_get_new_weights(init_weights, win0, win1): diff2 = p2 - i npt.assert_allclose(diff1, -diff2, rtol=1e-3) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_supervised_policy_trainer.py b/tests/test_supervised_policy_trainer.py index 6ff12baa0..dc170e1a2 100644 --- a/tests/test_supervised_policy_trainer.py +++ b/tests/test_supervised_policy_trainer.py @@ -16,5 +16,6 @@ def testTrain(self): os.remove(os.path.join(output, 'weights.00000.hdf5')) os.rmdir(output) + if __name__ == '__main__': unittest.main() From 8938a4da28d86760a2601c2ffbca0c5f83bac8e3 Mon Sep 17 00:00:00 2001 From: wrongu Date: Sun, 27 Nov 2016 09:27:22 -0500 Subject: [PATCH 125/191] ladder features in default of game_converter --- AlphaGo/preprocessing/game_converter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AlphaGo/preprocessing/game_converter.py b/AlphaGo/preprocessing/game_converter.py index 5decbb07a..fa80c0e23 100644 --- a/AlphaGo/preprocessing/game_converter.py +++ b/AlphaGo/preprocessing/game_converter.py @@ -179,8 +179,8 @@ def run_game_converter(cmd_line_args=None): "capture_size", "self_atari_size", "liberties_after", - # "ladder_capture", - # "ladder_escape", + "ladder_capture", + "ladder_escape", "sensibleness", "zeros"] else: From acf34927c9389e387593c05519a3660bc60698ca Mon Sep 17 00:00:00 2001 From: wrongu Date: Sun, 27 Nov 2016 09:45:20 -0500 Subject: [PATCH 126/191] game_converter storing features in comma-separated string --- AlphaGo/preprocessing/game_converter.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/AlphaGo/preprocessing/game_converter.py b/AlphaGo/preprocessing/game_converter.py index fa80c0e23..baaf032bb 100644 --- a/AlphaGo/preprocessing/game_converter.py +++ b/AlphaGo/preprocessing/game_converter.py @@ -13,7 +13,7 @@ class SizeMismatchError(Exception): pass -class game_converter: +class GameConverter: def __init__(self, features): self.feature_processor = Preprocess(features) @@ -63,7 +63,6 @@ def sgfs_to_hdf5(self, sgf_files, hdf5_file, bd_size=19, ignore_errors=True, ver test_actions = actions[index:index+length] """ - # TODO - also save feature list # make a hidden temporary file in case of a crash. # on success, this is renamed to hdf5_file @@ -88,9 +87,14 @@ def sgfs_to_hdf5(self, sgf_files, hdf5_file, bd_size=19, ignore_errors=True, ver exact=False, chunks=(1024, 2), compression="lzf") + # 'file_offsets' is an HDF5 group so that 'file_name in file_offsets' is fast file_offsets = h5f.require_group('file_offsets') + # Store comma-separated list of feature planes in the scalar field 'features'. The + # string can be retrieved using h5py's scalar indexing: h5f['features'][()] + h5f['features'] = np.string_(','.join(self.feature_processor.feature_list)) + if verbose: print("created HDF5 dataset in {}".format(tmp_file)) @@ -189,7 +193,7 @@ def run_game_converter(cmd_line_args=None): if args.verbose: print("using features", feature_list) - converter = game_converter(feature_list) + converter = GameConverter(feature_list) def _is_sgf(fname): return fname.strip()[-4:] == ".sgf" From 16364b327f75a3a3cd540cbc26bb943813ae68ad Mon Sep 17 00:00:00 2001 From: wrongu Date: Sun, 27 Nov 2016 10:10:06 -0500 Subject: [PATCH 127/191] supervised policy trainer verifies feature match with dataset --- AlphaGo/training/supervised_policy_trainer.py | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/AlphaGo/training/supervised_policy_trainer.py b/AlphaGo/training/supervised_policy_trainer.py index 603a4ad89..8041f6fbe 100644 --- a/AlphaGo/training/supervised_policy_trainer.py +++ b/AlphaGo/training/supervised_policy_trainer.py @@ -5,6 +5,7 @@ from keras.optimizers import SGD from keras.callbacks import ModelCheckpoint, Callback from AlphaGo.models.policy import CNNPolicy +from AlphaGo.preprocessing.preprocessing import Preprocess def one_hot_action(action, size=19): @@ -127,13 +128,40 @@ def run_training(cmd_line_args=None): print("starting fresh output directory %s" % args.out_directory) # load model from json spec - model = CNNPolicy.load_model(args.model).model + policy = CNNPolicy.load_model(args.model) + model_features = policy.preprocessor.feature_list + model = policy.model if resume: model.load_weights(os.path.join(args.out_directory, args.weights)) - # TODO - (waiting on game_converter) verify that features of model match # features of training data dataset = h5.File(args.train_data) + + # Verify that dataset's features match the model's expected features. + if 'features' in dataset: + dataset_features = dataset['features'][()] + dataset_features = dataset_features.split(",") + if len(dataset_features) != len(model_features) or \ + any(df != mf for (df, mf) in zip(dataset_features, model_features)): + raise ValueError("Model JSON file expects features \n\t%s\n" + "But dataset contains \n\t%s" % ("\n\t".join(model_features), + "\n\t".join(dataset_features))) + elif args.verbose: + print("Verified that dataset features and model features exactly match.") + else: + # Cannot check each feature, but can check number of planes. + n_dataset_planes = dataset["states"].shape[1] + tmp_preprocess = Preprocess(model_features) + n_model_planes = tmp_preprocess.output_dim + if n_dataset_planes != n_model_planes: + raise ValueError("Model JSON file expects a total of %d planes from features \n\t%s\n" + "But dataset contains %d planes" % (n_model_planes, + "\n\t".join(model_features), + n_dataset_planes)) + elif args.verbose: + print("Verified agreement of number of model and dataset feature planes, but cannot " + "verify exact match using old dataset format.") + n_total_data = len(dataset["states"]) n_train_data = int(args.train_val_test[0] * n_total_data) # Need to make sure training data is divisible by minibatch size or get From 5949f4e7b9b6bd1b5dc69a8c5be2dfa1b02a0581 Mon Sep 17 00:00:00 2001 From: wrongu Date: Mon, 28 Nov 2016 10:19:42 -0500 Subject: [PATCH 128/191] exposing bug in RL policy trainer: no updates using run_n_games --- tests/test_reinforcement_policy_trainer.py | 49 ++++++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/tests/test_reinforcement_policy_trainer.py b/tests/test_reinforcement_policy_trainer.py index 49f280d4f..35971a113 100644 --- a/tests/test_reinforcement_policy_trainer.py +++ b/tests/test_reinforcement_policy_trainer.py @@ -1,12 +1,26 @@ import os from AlphaGo.training.reinforcement_policy_trainer import \ - run_training, _make_training_pair, BatchedReinforcementLearningSGD, log_loss + run_training, _make_training_pair, BatchedReinforcementLearningSGD, log_loss, run_n_games import unittest import numpy as np import numpy.testing as npt import keras.backend as K +import AlphaGo.go as go from AlphaGo.models.policy import CNNPolicy -from AlphaGo.go import GameState +from AlphaGo.util import sgf_iter_states + + +class MockPlayer(object): + + def __init__(self, policy): + with open("tests/test_data/sgf/20160312-Lee-Sedol-vs-AlphaGo.sgf", "r") as f: + sgf_game = f.read() + self.moves = [move for (_, move, _) in sgf_iter_states(sgf_game)] + self.policy = policy + + def get_moves(self, states): + indices = [len(state.history) for state in states] + return [self.moves[i] if i < len(self.moves) else go.PASS_MOVE for i in indices] class TestReinforcementPolicyTrainer(unittest.TestCase): @@ -28,7 +42,7 @@ class TestOptimizer(unittest.TestCase): def testApplyAndResetOnGamesFinished(self): policy = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) - state = GameState(size=19) + state = go.GameState(size=19) optimizer = BatchedReinforcementLearningSGD(lr=0.01, ng=2) policy.model.compile(loss=log_loss, optimizer=optimizer) @@ -85,7 +99,7 @@ def assertModelEffect(changed): def testGradientDirectionChangesWithGameResult(self): def run_and_get_new_weights(init_weights, win0, win1): - state = GameState(size=19) + state = go.GameState(size=19) policy = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) policy.model.set_weights(init_weights) optimizer = BatchedReinforcementLearningSGD(lr=0.01, ng=2) @@ -123,6 +137,14 @@ def run_and_get_new_weights(init_weights, win0, win1): parameters1 = run_and_get_new_weights(initial_parameters, True, False) parameters2 = run_and_get_new_weights(initial_parameters, False, True) + # Assert that some parameters changed. + any_change_1 = any(not np.array_equal(i, p1) for (i, p1) in zip(initial_parameters, + parameters1)) + any_change_2 = any(not np.array_equal(i, p2) for (i, p2) in zip(initial_parameters, + parameters2)) + self.assertTrue(any_change_1) + self.assertTrue(any_change_2) + # Changes in case 1 should be equal and opposite to changes in case 2. Allowing 0.1% # difference in precision. for (i, p1, p2) in zip(initial_parameters, parameters1, parameters2): @@ -130,6 +152,25 @@ def run_and_get_new_weights(init_weights, win0, win1): diff2 = p2 - i npt.assert_allclose(diff1, -diff2, rtol=1e-3) + def testRunNGamesUpdatesWeights(self): + policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + learner = MockPlayer(policy1) + opponent = MockPlayer(policy2) + init_weights = policy1.model.get_weights() + optimizer = BatchedReinforcementLearningSGD(lr=0.01, ng=2) + policy1.model.compile(loss=log_loss, optimizer=optimizer) + + # Run RL training + run_n_games(optimizer, learner, opponent, 2) + + # Get new weights for comparison + trained_weights = policy1.model.get_weights() + + # Assert that some parameters changed. + any_change = any(not np.array_equal(i, t) for (i, t) in zip(init_weights, trained_weights)) + self.assertTrue(any_change) + if __name__ == '__main__': unittest.main() From 9a9cb07ccf62bc9796123191ada60eb832f47671 Mon Sep 17 00:00:00 2001 From: MaMiFreak Date: Mon, 5 Dec 2016 14:30:36 +0100 Subject: [PATCH 129/191] Update recursive ladder depth changed 40 to 80 Test on random sgf subset resulted in 59 as max ladder depth ( excluding ko etc. ) --- AlphaGo/go.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AlphaGo/go.py b/AlphaGo/go.py index 23152b181..9019c3f2e 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go.py @@ -326,7 +326,7 @@ def is_eye(self, position, owner, stack=[]): return False return True - def is_ladder_capture(self, action, prey=None, remaining_attempts=40): + def is_ladder_capture(self, action, prey=None, remaining_attempts=80): """Check if moving at action results in a ladder capture, defined as being next to an enemy group with two liberties, and with no ladder_escape move afterward for the other player. @@ -393,7 +393,7 @@ def is_ladder_capture(self, action, prey=None, remaining_attempts=40): # no ladder captures were found return False - def is_ladder_escape(self, action, prey=None, remaining_attempts=40): + def is_ladder_escape(self, action, prey=None, remaining_attempts=80): """Check if moving at action results in a ladder escape, defined as being next to a current player's group with one liberty, with no ladder captures afterward. Going from 1 to >= 3 liberties is counted as escape, or a From fe156d6f822ea07ea8feb4d42c86b71848cc615b Mon Sep 17 00:00:00 2001 From: wrongu Date: Mon, 5 Dec 2016 11:30:22 -0500 Subject: [PATCH 130/191] Updated keras to 1.1.2; numpy and scipy updated accordingly --- .travis.yml | 4 ++-- requirements.txt | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 3fff4cac1..9a97944b3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,8 +16,8 @@ before_install: - sudo ln -s /run/shm /dev/shm # Install packages install: - - conda install --yes python=2.7 Cython=0.24 h5py=2.6.0 numpy=1.11.1 scipy=0.18.0 PyYAML=3.12 matplotlib pandas pytest - - pip install --user --no-deps Theano==0.8.2 sgf==0.5 keras==1.0.8 pygtp==0.3 + - conda install --yes python=2.7 Cython=0.24 h5py=2.6.0 numpy=1.11.2 scipy=0.18.1 PyYAML=3.12 matplotlib pandas pytest + - pip install --user --no-deps Theano==0.8.2 sgf==0.5 keras==1.1.2 pygtp==0.3 - pip install --user flake8 # run flake8 and unit tests diff --git a/requirements.txt b/requirements.txt index f5c033186..b609137b3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ Cython==0.24 h5py==2.6.0 -Keras==1.0.8 -numpy==1.11.1 +Keras==1.1.2 +numpy==1.11.2 pygtp==0.3 PyYAML==3.12 -scipy==0.18.0 +scipy==0.18.1 sgf==0.5 six==1.10.0 Theano==0.8.2 From 5a24d07195d685d2a47a369fe725530dc786889d Mon Sep 17 00:00:00 2001 From: wrongu Date: Tue, 6 Dec 2016 09:17:39 -0500 Subject: [PATCH 131/191] explicitly setting travis to use theano; TF is the new keras default --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 9a97944b3..78ed1f133 100644 --- a/.travis.yml +++ b/.travis.yml @@ -23,4 +23,4 @@ install: # run flake8 and unit tests script: - flake8 - - THEANO_FLAGS="gcc.cxxflags='-march=core2'" python -m unittest discover + - THEANO_FLAGS="gcc.cxxflags='-march=core2'" KERAS_BACKEND=theano python -m unittest discover From 49a9e42cfc64aeaef587883a20ece0b87349e4ee Mon Sep 17 00:00:00 2001 From: wrongu Date: Tue, 6 Dec 2016 13:33:32 -0500 Subject: [PATCH 132/191] setting image_dim_ordering to 'th' in preprocessing.py --- AlphaGo/preprocessing/preprocessing.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AlphaGo/preprocessing/preprocessing.py b/AlphaGo/preprocessing/preprocessing.py index 3120e5d0c..5c6b4cd11 100644 --- a/AlphaGo/preprocessing/preprocessing.py +++ b/AlphaGo/preprocessing/preprocessing.py @@ -1,5 +1,10 @@ import numpy as np import AlphaGo.go as go +import keras.backend as K + +# This file is used anywhere that neural net features are used; setting the keras dimension ordering +# here makes it universal to the project. +K.set_image_dim_ordering('th') ## # individual feature functions (state --> tensor) begin here From 98b0fc729f7a9202fadcb426b07b8ace4c09694b Mon Sep 17 00:00:00 2001 From: wrongu Date: Mon, 5 Dec 2016 21:50:02 -0500 Subject: [PATCH 133/191] Removing overly-complicated (and broken) keras optimizer from RL. --- .../training/reinforcement_policy_trainer.py | 163 ++++-------------- tests/test_reinforcement_policy_trainer.py | 130 ++++---------- 2 files changed, 70 insertions(+), 223 deletions(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 15552dac2..3d8917b9a 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -2,119 +2,14 @@ import json import numpy as np from shutil import copyfile -from keras.optimizers import Optimizer +from keras.optimizers import SGD import keras.backend as K from AlphaGo.ai import ProbabilisticPolicyPlayer import AlphaGo.go as go -from AlphaGo.go import GameState from AlphaGo.models.policy import CNNPolicy from AlphaGo.util import flatten_idx -class BatchedReinforcementLearningSGD(Optimizer): - '''A Keras Optimizer that sums gradients together for each game, applying them only once the - winner is known. - - It is the responsibility of the calling code to call set_current_game() before each example to - tell the optimizer for which game gradients should be accumulated, and to call set_result() to - tell the optimizer what the sign of the gradient for each game should be and when all games are - over. - - Arguments - lr: float >= 0. Learning rate. - ng: int > 0. Number of games played in parallel. Each one has its own cumulative gradient. - ''' - - def __init__(self, lr=0.01, ng=20, **kwargs): - super(BatchedReinforcementLearningSGD, self).__init__(**kwargs) - self.__dict__.update(locals()) - self.lr = K.variable(lr) - self.cumulative_gradients = [] - self.num_games = ng - self.game_idx = K.variable(0) # which gradient to accumulate in the next batch. - self.gradient_sign = [K.variable(0) for _ in range(ng)] - self.running_games = K.variable(self.num_games) - - def set_current_game(self, game_idx): - K.set_value(self.game_idx, game_idx) - - def set_result(self, game_idx, won_game): - '''Mark the outcome of the game at index game_idx. Once all games are complete, updates - are automatically triggered in the next call to a keras fit function. - ''' - K.set_value(self.gradient_sign[game_idx], +1 if won_game else -1) - # Note: using '-= 1' would create a new variable, which would invalidate the dependencies - # in get_updates(). - K.set_value(self.running_games, K.get_value(self.running_games) - 1) - - def get_updates(self, params, constraints, loss): - # Note: get_updates is called *once* by keras. Its job is to return a set of 'update - # operations' to any K.variable (e.g. model weights or self.num_games). Updates are applied - # whenever Keras' train_function is evaluated, i.e. in every batch. Model.fit_on_batch() - # will trigger exactly one update. All updates use the 'old' value of parameters - there is - # no dependency on the order of the list of updates. - self.updates = [] - # Get expressions for gradients of model parameters. - grads = self.get_gradients(loss, params) - # Create a set of accumulated gradients, one for each game. - shapes = [K.get_variable_shape(p) for p in params] - self.cumulative_gradients = [[K.zeros(shape) for shape in shapes] - for _ in range(self.num_games)] - - def conditional_update(cond, variable, new_value): - '''Helper function to create updates that only happen when cond is True. Writes to - self.updates and returns the new variable. - - Note: K.update(x, x) is cheap, but K.update_add(x, K.zeros_like(x)) can be expensive. - ''' - maybe_new_value = K.switch(cond, new_value, variable) - self.updates.append(K.update(variable, maybe_new_value)) - return maybe_new_value - - # Update cumulative gradient at index game_idx. This is done by returning an update for all - # gradients that is a no-op everywhere except for the game_idx'th one. When game_idx is - # changed by a call to set_current_game(), it will change the gradient that is getting - # accumulated. - # new_cumulative_gradients keeps references to the updated variables for use below in - # updating parameters with the freshly-accumulated gradients. - new_cumulative_gradients = [[None] * len(cgs) for cgs in self.cumulative_gradients] - for i, cgs in enumerate(self.cumulative_gradients): - for j, (g, cg) in enumerate(zip(grads, cgs)): - new_gradient = conditional_update(K.equal(self.game_idx, i), cg, cg + g) - new_cumulative_gradients[i][j] = new_gradient - - # Compute the net update to parameters, taking into account the sign of each cumulative - # gradient. - net_grads = [K.zeros_like(g) for g in grads] - for i, cgs in enumerate(new_cumulative_gradients): - for j, cg in enumerate(cgs): - net_grads[j] += self.gradient_sign[i] * cg - - # Trigger a full update when all games have finished. - self.trigger_update = K.lesser_equal(self.running_games, 0) - - # Update model parameters conditional on trigger_update. - for p, g in zip(params, net_grads): - new_p = p + g * self.lr - if p in constraints: - c = constraints[p] - new_p = c(new_p) - conditional_update(self.trigger_update, p, new_p) - - # 'reset' game counter and gradient signs when parameters are updated. - for sign in self.gradient_sign: - conditional_update(self.trigger_update, sign, K.variable(0)) - conditional_update(self.trigger_update, self.running_games, K.variable(self.num_games)) - return self.updates - - def get_config(self): - config = { - 'lr': float(K.get_value(self.lr)), - 'ng': self.num_games} - base_config = super(BatchedReinforcementLearningSGD, self).get_config() - return dict(list(base_config.items()) + list(config.items())) - - def _make_training_pair(st, mv, preprocessor): # Convert move to one-hot st_tensor = preprocessor.state_to_tensor(st) @@ -123,18 +18,27 @@ def _make_training_pair(st, mv, preprocessor): return (st_tensor, mv_tensor) -def run_n_games(optimizer, learner, opponent, num_games): - '''Run num_games games to completion, calling train_batch() on each position - the learner sees. - - (Note: optimizer only accumulates gradients in its update function until - all games have finished) +def run_n_games(optimizer, learner, opponent, num_games, mock_states=[]): + '''Run num_games games to completion, keeping track of each position and move of the learner. + (Note: learning cannot happen until all games have completed) ''' + board_size = learner.policy.model.input_shape[-1] - states = [GameState(size=board_size) for _ in range(num_games)] + states = [go.GameState(size=board_size) for _ in range(num_games)] learner_net = learner.policy.model + # Allowing injection of a mock state object for testing purposes + if mock_states: + states = mock_states + + # Create one list of features (aka state tensors) and one of moves for each game being played. + state_tensors = [[] for _ in range(num_games)] + move_tensors = [[] for _ in range(num_games)] + + # List of booleans indicating whether the 'learner' player won. + learner_won = [None] * num_games + # Start all odd games with moves by 'opponent'. Even games will have 'learner' black. learner_color = [go.BLACK if i % 2 == 0 else go.WHITE for i in range(num_games)] odd_states = states[1::2] @@ -144,8 +48,6 @@ def run_n_games(optimizer, learner, opponent, num_games): current = learner other = opponent - # Need to keep track of the index of unfinished states so that we can communicate which one is - # being updated to the optimizer. idxs_to_unfinished_states = {i: states[i] for i in range(num_games)} while len(idxs_to_unfinished_states) > 0: # Get next moves by current player for all unfinished states. @@ -153,22 +55,17 @@ def run_n_games(optimizer, learner, opponent, num_games): just_finished = [] # Do each move to each state in order. for (idx, state), mv in zip(idxs_to_unfinished_states.iteritems(), moves): - # Order is important here. We must first get the training pair on the unmodified state. - # Next, the state is updated and checked to see if the game is over. If it is over, the - # optimizer is notified via set_result. Finally, train_on_batch is called, which - # will trigger an update of all parameters only if set_result() has been called - # for all games already (so set_result must come before train_on_batch). + # Order is important here. We must get the training pair on the unmodified state before + # updating it with do_move. is_learnable = current is learner and mv is not go.PASS_MOVE if is_learnable: - (X, y) = _make_training_pair(state, mv, learner.policy.preprocessor) + (st_tensor, mv_tensor) = _make_training_pair(state, mv, learner.policy.preprocessor) + state_tensors[idx].append(st_tensor) + move_tensors[idx].append(mv_tensor) state.do_move(mv) if state.is_end_of_game: - learner_is_winner = state.get_winner() == learner_color[idx] - optimizer.set_result(idx, learner_is_winner) + learner_won[idx] = state.get_winner() == learner_color[idx] just_finished.append(idx) - if is_learnable: - optimizer.set_current_game(idx) - learner_net.train_on_batch(X, y) # Remove games that have finished from dict. for idx in just_finished: @@ -177,6 +74,14 @@ def run_n_games(optimizer, learner, opponent, num_games): # Swap 'current' and 'other' for next turn. current, other = other, current + # Train on each game's results, setting the learning rate negative to 'unlearn' positions from + # games where the learner lost. + base_lr = optimizer.lr.get_value() + for (st_tensor, mv_tensor, won) in zip(state_tensors, move_tensors, learner_won): + optimizer.lr.set_value(base_lr * (+1 if won else -1)) + learner_net.train_on_batch(np.concatenate(st_tensor, axis=0), + np.concatenate(mv_tensor, axis=0)) + # Return the win ratio. wins = sum(state.get_winner() == pc for (state, pc) in zip(states, learner_color)) return float(wins) / num_games @@ -276,7 +181,7 @@ def save_metadata(): with open(os.path.join(args.out_directory, "metadata.json"), "w") as f: json.dump(metadata, f, sort_keys=True, indent=2) - optimizer = BatchedReinforcementLearningSGD(lr=args.learning_rate, ng=args.game_batch) + optimizer = SGD(lr=args.learning_rate) player.policy.model.compile(loss=log_loss, optimizer=optimizer) for i_iter in range(1, args.iterations + 1): # Randomly choose opponent from pool (possibly self), and playing @@ -289,8 +194,8 @@ def save_metadata(): if args.verbose: print("Batch {}\tsampled opponent is {}".format(i_iter, opp_weights)) - # Run games (and learn from results). Keep track of the win ratio vs - # each opponent over time. + # Run games (and learn from results). Keep track of the win ratio vs each opponent over + # time. win_ratio = run_n_games(optimizer, player, opponent, args.game_batch) metadata["win_ratio"][player_weights] = (opp_weights, win_ratio) diff --git a/tests/test_reinforcement_policy_trainer.py b/tests/test_reinforcement_policy_trainer.py index 35971a113..91da94699 100644 --- a/tests/test_reinforcement_policy_trainer.py +++ b/tests/test_reinforcement_policy_trainer.py @@ -1,11 +1,10 @@ import os -from AlphaGo.training.reinforcement_policy_trainer import \ - run_training, _make_training_pair, BatchedReinforcementLearningSGD, log_loss, run_n_games +from AlphaGo.training.reinforcement_policy_trainer import run_training, log_loss, run_n_games import unittest import numpy as np import numpy.testing as npt -import keras.backend as K import AlphaGo.go as go +from keras.optimizers import SGD from AlphaGo.models.policy import CNNPolicy from AlphaGo.util import sgf_iter_states @@ -23,6 +22,22 @@ def get_moves(self, states): return [self.moves[i] if i < len(self.moves) else go.PASS_MOVE for i in indices] +class MockState(go.GameState): + + def __init__(self, predetermined_winner, length, *args, **kwargs): + super(MockState, self).__init__(*args, **kwargs) + self.predetermined_winner = predetermined_winner + self.length = length + + def do_move(self, *args, **kwargs): + super(MockState, self).do_move(*args, **kwargs) + if len(self.history) > self.length: + self.is_end_of_game = True + + def get_winner(self): + return self.predetermined_winner + + class TestReinforcementPolicyTrainer(unittest.TestCase): def testTrain(self): @@ -37,105 +52,32 @@ def testTrain(self): os.remove(os.path.join(output, 'weights.00001.hdf5')) os.rmdir(output) + def testGradientDirectionChangesWithGameResult(self): -class TestOptimizer(unittest.TestCase): - - def testApplyAndResetOnGamesFinished(self): - policy = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) - state = go.GameState(size=19) - optimizer = BatchedReinforcementLearningSGD(lr=0.01, ng=2) - policy.model.compile(loss=log_loss, optimizer=optimizer) + def run_and_get_new_weights(init_weights, winners): + # Create "mock" states that end after 50 moves with a predetermined winner. + states = [MockState(winner, 50, size=19) for winner in winners] - # Helper to check initial conditions of the optimizer. - def assertOptimizerInitialConditions(): - for v in optimizer.gradient_sign: - self.assertEqual(K.eval(v), 0) - self.assertEqual(K.eval(optimizer.running_games), 2) + policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + policy1.model.set_weights(init_weights) + optimizer = SGD(lr=0.001) + policy1.model.compile(loss=log_loss, optimizer=optimizer) - initial_parameters = policy.model.get_weights() + learner = MockPlayer(policy1) + opponent = MockPlayer(policy2) - def assertModelEffect(changed): - any_change = False - for cur, init in zip(policy.model.get_weights(), initial_parameters): - if not np.allclose(init, cur): - any_change = True - break - self.assertEqual(any_change, changed) - - assertOptimizerInitialConditions() - - # Make moves on the state and get trainable (state, action) pairs from them. - state_tensors = [] - action_tensors = [] - moves = [(2, 2), (16, 16), (3, 17), (16, 2), (4, 10), (10, 3)] - for m in moves: - (st_tensor, mv_tensor) = _make_training_pair(state, m, policy.preprocessor) - state_tensors.append(st_tensor) - action_tensors.append(mv_tensor) - state.do_move(m) - - for i, (s, a) in enumerate(zip(state_tensors, action_tensors)): - # Even moves in game 0, odd moves in game 1 - game_idx = i % 2 - optimizer.set_current_game(game_idx) - is_last_move = i + 2 >= len(moves) - if is_last_move: - # Mark game 0 as a win and game 1 as a loss. - optimizer.set_result(game_idx, game_idx == 0) - else: - # Games not finished yet; assert no change to optimizer state. - assertOptimizerInitialConditions() - # train_on_batch accumulates gradients, and should only cause a change to parameters - # on the first call after the final set_result() call - policy.model.train_on_batch(s, a) - if i + 1 < len(moves): - assertModelEffect(changed=False) - else: - assertModelEffect(changed=True) - # Once both games finished, the last call to train_on_batch() should have triggered a reset - # to the optimizer parameters back to initial conditions. - assertOptimizerInitialConditions() - - def testGradientDirectionChangesWithGameResult(self): + # Run RL training + run_n_games(optimizer, learner, opponent, 2, mock_states=states) - def run_and_get_new_weights(init_weights, win0, win1): - state = go.GameState(size=19) - policy = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) - policy.model.set_weights(init_weights) - optimizer = BatchedReinforcementLearningSGD(lr=0.01, ng=2) - policy.model.compile(loss=log_loss, optimizer=optimizer) - - # Make moves on the state and get trainable (state, action) pairs from them. - moves = [(2, 2), (16, 16), (3, 17), (16, 2), (4, 10), (10, 3)] - state_tensors = [] - action_tensors = [] - for m in moves: - (st_tensor, mv_tensor) = _make_training_pair(state, m, policy.preprocessor) - state_tensors.append(st_tensor) - action_tensors.append(mv_tensor) - state.do_move(m) - - for i, (s, a) in enumerate(zip(state_tensors, action_tensors)): - # Put even state/action pairs in game 0, odd ones in game 1. - game_idx = i % 2 - optimizer.set_current_game(game_idx) - is_last_move = i + 2 >= len(moves) - if is_last_move: - if game_idx == 0: - optimizer.set_result(game_idx, win0) - else: - optimizer.set_result(game_idx, win1) - # train_on_batch accumulates gradients, and should only cause a change to parameters - # on the first call after the final set_result() call - policy.model.train_on_batch(s, a) - return policy.model.get_weights() + return policy1.model.get_weights() policy = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) initial_parameters = policy.model.get_weights() # Cases 1 and 2 have identical starting models and identical (state, action) pairs, # but they differ in who won the games. - parameters1 = run_and_get_new_weights(initial_parameters, True, False) - parameters2 = run_and_get_new_weights(initial_parameters, False, True) + parameters1 = run_and_get_new_weights(initial_parameters, [go.BLACK, go.WHITE]) + parameters2 = run_and_get_new_weights(initial_parameters, [go.WHITE, go.BLACK]) # Assert that some parameters changed. any_change_1 = any(not np.array_equal(i, p1) for (i, p1) in zip(initial_parameters, @@ -150,15 +92,15 @@ def run_and_get_new_weights(init_weights, win0, win1): for (i, p1, p2) in zip(initial_parameters, parameters1, parameters2): diff1 = p1 - i diff2 = p2 - i - npt.assert_allclose(diff1, -diff2, rtol=1e-3) + npt.assert_allclose(diff1, -diff2, rtol=1e-3, atol=1e-11) def testRunNGamesUpdatesWeights(self): policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) learner = MockPlayer(policy1) opponent = MockPlayer(policy2) + optimizer = SGD() init_weights = policy1.model.get_weights() - optimizer = BatchedReinforcementLearningSGD(lr=0.01, ng=2) policy1.model.compile(loss=log_loss, optimizer=optimizer) # Run RL training From a76b863045048a7806033c5ab97323163002d423 Mon Sep 17 00:00:00 2001 From: wrongu Date: Wed, 7 Dec 2016 13:31:22 -0500 Subject: [PATCH 134/191] Testing direction of change in RL; fixing associated bug It no longer actively gets more stupid! --- .../training/reinforcement_policy_trainer.py | 5 +- tests/test_reinforcement_policy_trainer.py | 77 +++++++++++++++++-- 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 3d8917b9a..33471943f 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -89,9 +89,10 @@ def run_n_games(optimizer, learner, opponent, num_games, mock_states=[]): def log_loss(y_true, y_pred): '''Keras 'loss' function for the REINFORCE algorithm, where y_true is the action that was - taken, and updates with the positive gradient will make that action more likely. + taken, and updates with the negative gradient will make that action more likely. We use the + negative gradient because keras expects training data to minimize a loss function. ''' - return y_true * K.log(K.clip(y_pred, K.epsilon(), 1.0 - K.epsilon())) + return -y_true * K.log(K.clip(y_pred, K.epsilon(), 1.0 - K.epsilon())) def run_training(cmd_line_args=None): diff --git a/tests/test_reinforcement_policy_trainer.py b/tests/test_reinforcement_policy_trainer.py index 91da94699..a1c1c3349 100644 --- a/tests/test_reinforcement_policy_trainer.py +++ b/tests/test_reinforcement_policy_trainer.py @@ -9,10 +9,27 @@ from AlphaGo.util import sgf_iter_states +MOCK_GAME = "tests/test_data/sgf/20160312-Lee-Sedol-vs-AlphaGo.sgf" + + +def get_sgf_move_probs(sgf_game, policy, player): + with open(sgf_game, "r") as f: + sgf_game = f.read() + + def get_single_prob(move, move_probs): + for (mv, prob) in move_probs: + if move == mv: + return prob + return 0 + + return [(move, get_single_prob(move, policy.eval_state(state))) + for (state, move, pl) in sgf_iter_states(sgf_game) if pl == player] + + class MockPlayer(object): - def __init__(self, policy): - with open("tests/test_data/sgf/20160312-Lee-Sedol-vs-AlphaGo.sgf", "r") as f: + def __init__(self, policy, sgf_game): + with open(sgf_game, "r") as f: sgf_game = f.read() self.moves = [move for (_, move, _) in sgf_iter_states(sgf_game)] self.policy = policy @@ -64,8 +81,8 @@ def run_and_get_new_weights(init_weights, winners): optimizer = SGD(lr=0.001) policy1.model.compile(loss=log_loss, optimizer=optimizer) - learner = MockPlayer(policy1) - opponent = MockPlayer(policy2) + learner = MockPlayer(policy1, MOCK_GAME) + opponent = MockPlayer(policy2, MOCK_GAME) # Run RL training run_n_games(optimizer, learner, opponent, 2, mock_states=states) @@ -97,8 +114,8 @@ def run_and_get_new_weights(init_weights, winners): def testRunNGamesUpdatesWeights(self): policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) - learner = MockPlayer(policy1) - opponent = MockPlayer(policy2) + learner = MockPlayer(policy1, MOCK_GAME) + opponent = MockPlayer(policy2, MOCK_GAME) optimizer = SGD() init_weights = policy1.model.get_weights() policy1.model.compile(loss=log_loss, optimizer=optimizer) @@ -113,6 +130,54 @@ def testRunNGamesUpdatesWeights(self): any_change = any(not np.array_equal(i, t) for (i, t) in zip(init_weights, trained_weights)) self.assertTrue(any_change) + def testWinIncreasesMoveProbability(self): + # Create "mock" state that ends after 20 moves with the learner winnning + win_state = [MockState(go.BLACK, 20, size=19)] + policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + learner = MockPlayer(policy1, MOCK_GAME) + opponent = MockPlayer(policy2, MOCK_GAME) + optimizer = SGD() + policy1.model.compile(loss=log_loss, optimizer=optimizer) + + # Get initial (before learning) move probabilities for all moves made by black + init_move_probs = get_sgf_move_probs(MOCK_GAME, policy1, go.BLACK) + init_probs = [prob for (mv, prob) in init_move_probs] + + # Run RL training + run_n_games(optimizer, learner, opponent, 1, mock_states=win_state) + + # Get new move probabilities for black's moves having finished 1 round of training + new_move_probs = get_sgf_move_probs(MOCK_GAME, policy1, go.BLACK) + new_probs = [prob for (mv, prob) in new_move_probs] + + # Assert that, on average, move probabilities for black increased having won. + self.assertTrue(sum((new_probs[i] - init_probs[i]) for i in range(10)) > 0) + + def testLoseDecreasesMoveProbability(self): + # Create "mock" state that ends after 20 moves with the learner losing + lose_state = [MockState(go.WHITE, 20, size=19)] + policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + learner = MockPlayer(policy1, MOCK_GAME) + opponent = MockPlayer(policy2, MOCK_GAME) + optimizer = SGD() + policy1.model.compile(loss=log_loss, optimizer=optimizer) + + # Get initial (before learning) move probabilities for all moves made by black + init_move_probs = get_sgf_move_probs(MOCK_GAME, policy1, go.BLACK) + init_probs = [prob for (mv, prob) in init_move_probs] + + # Run RL training + run_n_games(optimizer, learner, opponent, 1, mock_states=lose_state) + + # Get new move probabilities for black's moves having finished 1 round of training + new_move_probs = get_sgf_move_probs(MOCK_GAME, policy1, go.BLACK) + new_probs = [prob for (mv, prob) in new_move_probs] + + # Assert that, on average, move probabilities for black decreased having lost. + self.assertTrue(sum((new_probs[i] - init_probs[i]) for i in range(10)) < 0) + if __name__ == '__main__': unittest.main() From 0cb6fe90d3929e0145f80e2281b15c6a9124e17d Mon Sep 17 00:00:00 2001 From: wrongu Date: Wed, 7 Dec 2016 13:32:07 -0500 Subject: [PATCH 135/191] updated minimodel and its weights to make dependent tests less flakey --- .../hdf5/random_minimodel_weights.hdf5 | Bin 71856 -> 73584 bytes tests/test_data/minimodel.json | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_data/hdf5/random_minimodel_weights.hdf5 b/tests/test_data/hdf5/random_minimodel_weights.hdf5 index 5d619cb9da3a55bbbf990565fe370b8cfea99169..0314a47e50270c6cb4d91a2291cb2ce4b5a0ec31 100644 GIT binary patch literal 73584 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 )6Mx?}<@c^H5hzrA32fE=xBR>AkV9XyS4r_-)I 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 z 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

WatcZ#-jtXh^;9HOWG6fsbQDhb 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 diff --git a/tests/test_data/minimodel.json b/tests/test_data/minimodel.json index cbaeae20e..3bb0f9426 100644 --- a/tests/test_data/minimodel.json +++ b/tests/test_data/minimodel.json @@ -1 +1 @@ -{"keras_model": "{\"class_name\": \"Sequential\", \"config\": [{\"class_name\": \"Convolution2D\", \"config\": {\"b_regularizer\": null, \"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_1\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 5, \"subsample\": [1, 1], \"init\": \"uniform\", \"nb_filter\": 16, \"input_dtype\": \"float32\", \"border_mode\": \"same\", \"batch_input_shape\": [null, 12, 19, 19], \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 5}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_2\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_3\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_4\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_5\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_6\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 1, \"subsample\": [1, 1], \"init\": \"uniform\", \"nb_filter\": 1, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"linear\", \"nb_row\": 1}}, {\"class_name\": \"Flatten\", \"config\": {\"trainable\": true, \"name\": \"flatten_1\"}}, {\"class_name\": \"Activation\", \"config\": {\"activation\": \"softmax\", \"trainable\": true, \"name\": \"activation_1\"}}]}", "feature_list": ["board", "ones", "turns_since"]} \ No newline at end of file +{"weights_file": "tests/test_data/hdf5/random_minimodel_weights.hdf5", "keras_model": "{\"class_name\": \"Sequential\", \"keras_version\": \"1.1.2\", \"config\": [{\"class_name\": \"Convolution2D\", \"config\": {\"b_regularizer\": null, \"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_1\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 5, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"input_dtype\": \"float32\", \"border_mode\": \"same\", \"batch_input_shape\": [null, 12, 19, 19], \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 5}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_2\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_3\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_4\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_5\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_6\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 1, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 1, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"linear\", \"nb_row\": 1}}, {\"class_name\": \"Flatten\", \"config\": {\"trainable\": true, \"name\": \"flatten_1\"}}, {\"class_name\": \"Bias\", \"config\": {\"trainable\": true, \"name\": \"bias_1\"}}, {\"class_name\": \"Activation\", \"config\": {\"activation\": \"softmax\", \"trainable\": true, \"name\": \"activation_1\"}}]}", "class": "CNNPolicy", "feature_list": ["board", "ones", "turns_since"]} \ No newline at end of file From eebbdb1c04dd5c48638095f858b1885873047e22 Mon Sep 17 00:00:00 2001 From: wrongu Date: Thu, 8 Dec 2016 09:05:17 -0500 Subject: [PATCH 136/191] K.abs() and tensorflow-compatible learning rate in RL policy trainer --- AlphaGo/training/reinforcement_policy_trainer.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 33471943f..a41cfd9ba 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -76,9 +76,8 @@ def run_n_games(optimizer, learner, opponent, num_games, mock_states=[]): # Train on each game's results, setting the learning rate negative to 'unlearn' positions from # games where the learner lost. - base_lr = optimizer.lr.get_value() for (st_tensor, mv_tensor, won) in zip(state_tensors, move_tensors, learner_won): - optimizer.lr.set_value(base_lr * (+1 if won else -1)) + optimizer.lr = K.abs(optimizer.lr) * (+1 if won else -1) learner_net.train_on_batch(np.concatenate(st_tensor, axis=0), np.concatenate(mv_tensor, axis=0)) From f6267db966b626221372efda170a4f7053c99197 Mon Sep 17 00:00:00 2001 From: MaMiFreak Date: Sun, 11 Dec 2016 13:59:06 +0100 Subject: [PATCH 137/191] added Tensorflow backend check --- .travis.yml | 59 ++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/.travis.yml b/.travis.yml index 78ed1f133..c15ea56e1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,26 +1,53 @@ +sudo: required +dist: trusty language: python -python: - - "2.7" - - "3.3" - - "3.4" - - "3.5" -# Setup anaconda -before_install: - - wget http://repo.continuum.io/miniconda/Miniconda-latest-Linux-x86_64.sh -O miniconda.sh - - chmod +x miniconda.sh - - ./miniconda.sh -b - - export PATH=/home/travis/miniconda2/bin:$PATH - - conda update --yes conda +# tested python language / keras backends matrix +matrix: + include: + - python: 2.7 + env: KERAS_BACKEND=theano + - python: 2.7 + env: KERAS_BACKEND=tensorflow + +# Install packages +install: + - wget https://repo.continuum.io/miniconda/Miniconda-latest-Linux-x86_64.sh -O miniconda.sh; + + - bash miniconda.sh -b -p $HOME/miniconda + - export PATH="$HOME/miniconda/bin:$PATH" + - hash -r + - conda config --set always_yes yes --set changeps1 no + - conda update -q conda + # Useful for debugging any issues with conda + - conda info -a + + # create and activiate environment with python=$TRAVIS_PYTHON_VERSION + - conda create -q -n RocAlphaGo-test python=$TRAVIS_PYTHON_VERSION + - source activate RocAlphaGo-test + # The next couple lines fix a crash with multiprocessing on Travis and are not specific to using Miniconda - sudo rm -rf /dev/shm - sudo ln -s /run/shm /dev/shm -# Install packages -install: - - conda install --yes python=2.7 Cython=0.24 h5py=2.6.0 numpy=1.11.2 scipy=0.18.1 PyYAML=3.12 matplotlib pandas pytest + + # install requirements + - conda install --yes Cython=0.24 h5py=2.6.0 numpy=1.11.2 scipy=0.18.1 PyYAML=3.12 matplotlib pandas pytest - pip install --user --no-deps Theano==0.8.2 sgf==0.5 keras==1.1.2 pygtp==0.3 - pip install --user flake8 + # install TensorFlow if needed + # tensorflow does not have a python3.3 wheel + - if [[ "$KERAS_BACKEND" == "tensorflow" ]]; then + if [[ "$TRAVIS_PYTHON_VERSION" == "2.7" ]]; then + pip install https://storage.googleapis.com/tensorflow/linux/cpu/tensorflow-0.11.0-cp27-none-linux_x86_64.whl; + elif [[ "$TRAVIS_PYTHON_VERSION" == "3.4" ]]; then + pip install https://storage.googleapis.com/tensorflow/linux/cpu/tensorflow-0.11.0-cp34-cp34m-linux_x86_64.whl; + elif [[ "$TRAVIS_PYTHON_VERSION" == "3.5" ]]; then + pip install https://storage.googleapis.com/tensorflow/linux/cpu/tensorflow-0.11.0-cp35-cp35m-linux_x86_64.whl; + fi + fi # run flake8 and unit tests script: - flake8 - - THEANO_FLAGS="gcc.cxxflags='-march=core2'" KERAS_BACKEND=theano python -m unittest discover + + # run unit test + - KERAS_BACKEND=$KERAS_BACKEND python -m unittest discover From deaf2da8a8c97733dd992a7af5e40be1e3d17fa0 Mon Sep 17 00:00:00 2001 From: MaMiFreak Date: Mon, 12 Dec 2016 19:05:22 +0100 Subject: [PATCH 138/191] travis added comments --- .travis.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index c15ea56e1..13d4142aa 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ -sudo: required -dist: trusty +sudo: required # https://docs.travis-ci.com/user/trusty-ci-environment/ +dist: trusty # both needed to install tensorflow correctly language: python # tested python language / keras backends matrix matrix: @@ -16,6 +16,7 @@ install: - bash miniconda.sh -b -p $HOME/miniconda - export PATH="$HOME/miniconda/bin:$PATH" - hash -r + # http://conda.pydata.org/docs/commands.html - conda config --set always_yes yes --set changeps1 no - conda update -q conda # Useful for debugging any issues with conda @@ -50,4 +51,4 @@ script: - flake8 # run unit test - - KERAS_BACKEND=$KERAS_BACKEND python -m unittest discover + - python -m unittest discover From b9bdb6765dd0207b479df144bda7dc9f2fd6e072 Mon Sep 17 00:00:00 2001 From: MaMiFreak Date: Mon, 19 Dec 2016 11:29:37 +0100 Subject: [PATCH 139/191] sgf file relocation --- tests/test_data/{sgf => sgf_with_handicap}/0196-00-00.sgf | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/test_data/{sgf => sgf_with_handicap}/0196-00-00.sgf (100%) diff --git a/tests/test_data/sgf/0196-00-00.sgf b/tests/test_data/sgf_with_handicap/0196-00-00.sgf similarity index 100% rename from tests/test_data/sgf/0196-00-00.sgf rename to tests/test_data/sgf_with_handicap/0196-00-00.sgf From 7b3bf81b3bc4904fa84835e3c010f5fec4f86109 Mon Sep 17 00:00:00 2001 From: MaMiFreak Date: Mon, 19 Dec 2016 11:30:11 +0100 Subject: [PATCH 140/191] sgf file relocation --- tests/test_data/{sgf => sgf_with_handicap}/ab_aw.sgf | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/test_data/{sgf => sgf_with_handicap}/ab_aw.sgf (100%) diff --git a/tests/test_data/sgf/ab_aw.sgf b/tests/test_data/sgf_with_handicap/ab_aw.sgf similarity index 100% rename from tests/test_data/sgf/ab_aw.sgf rename to tests/test_data/sgf_with_handicap/ab_aw.sgf From f6661555b7377a94a8deb01a5e49a863caa04508 Mon Sep 17 00:00:00 2001 From: MaMiFreak Date: Mon, 19 Dec 2016 11:31:09 +0100 Subject: [PATCH 141/191] sgf file relocation --- tests/test_game_converter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_game_converter.py b/tests/test_game_converter.py index 1d6c119d2..c277cf850 100644 --- a/tests/test_game_converter.py +++ b/tests/test_game_converter.py @@ -6,7 +6,7 @@ class TestSGFLoading(unittest.TestCase): def test_ab_aw(self): - with open('tests/test_data/sgf/ab_aw.sgf', 'r') as f: + with open('tests/test_data/sgf_with_handicap/ab_aw.sgf', 'r') as f: sgf_to_gamestate(f.read()) From 99cc0314cb3898e2be6a8d90b849ab512e0a5fbf Mon Sep 17 00:00:00 2001 From: MaMiFreak Date: Mon, 19 Dec 2016 11:32:02 +0100 Subject: [PATCH 142/191] reinforcement training test update --- tests/test_reinforcement_policy_trainer.py | 189 ++++++++++++--------- 1 file changed, 108 insertions(+), 81 deletions(-) diff --git a/tests/test_reinforcement_policy_trainer.py b/tests/test_reinforcement_policy_trainer.py index a1c1c3349..48f41ec30 100644 --- a/tests/test_reinforcement_policy_trainer.py +++ b/tests/test_reinforcement_policy_trainer.py @@ -8,8 +8,18 @@ from AlphaGo.models.policy import CNNPolicy from AlphaGo.util import sgf_iter_states +SGF_FOLDER = "tests/test_data/sgf/" -MOCK_GAME = "tests/test_data/sgf/20160312-Lee-Sedol-vs-AlphaGo.sgf" + +def _is_sgf(fname): + return fname.strip()[-4:] == ".sgf" + + +def _list_mock_games(path): + """helper function to get all SGF files in a directory (does not recurse) + """ + files = os.listdir(path) + return (os.path.join(path, f) for f in files if _is_sgf(f)) def get_sgf_move_probs(sgf_game, policy, player): @@ -71,9 +81,9 @@ def testTrain(self): def testGradientDirectionChangesWithGameResult(self): - def run_and_get_new_weights(init_weights, winners): - # Create "mock" states that end after 50 moves with a predetermined winner. - states = [MockState(winner, 50, size=19) for winner in winners] + def run_and_get_new_weights(init_weights, winners, game): + # Create "mock" states that end after 2 moves with a predetermined winner. + states = [MockState(winner, 2, size=19) for winner in winners] policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) @@ -81,102 +91,119 @@ def run_and_get_new_weights(init_weights, winners): optimizer = SGD(lr=0.001) policy1.model.compile(loss=log_loss, optimizer=optimizer) - learner = MockPlayer(policy1, MOCK_GAME) - opponent = MockPlayer(policy2, MOCK_GAME) + learner = MockPlayer(policy1, game) + opponent = MockPlayer(policy2, game) # Run RL training run_n_games(optimizer, learner, opponent, 2, mock_states=states) return policy1.model.get_weights() - policy = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) - initial_parameters = policy.model.get_weights() - # Cases 1 and 2 have identical starting models and identical (state, action) pairs, - # but they differ in who won the games. - parameters1 = run_and_get_new_weights(initial_parameters, [go.BLACK, go.WHITE]) - parameters2 = run_and_get_new_weights(initial_parameters, [go.WHITE, go.BLACK]) - - # Assert that some parameters changed. - any_change_1 = any(not np.array_equal(i, p1) for (i, p1) in zip(initial_parameters, - parameters1)) - any_change_2 = any(not np.array_equal(i, p2) for (i, p2) in zip(initial_parameters, - parameters2)) - self.assertTrue(any_change_1) - self.assertTrue(any_change_2) - - # Changes in case 1 should be equal and opposite to changes in case 2. Allowing 0.1% - # difference in precision. - for (i, p1, p2) in zip(initial_parameters, parameters1, parameters2): - diff1 = p1 - i - diff2 = p2 - i - npt.assert_allclose(diff1, -diff2, rtol=1e-3, atol=1e-11) + def test_game_gradient(game): + policy = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + initial_parameters = policy.model.get_weights() + # Cases 1 and 2 have identical starting models and identical (state, action) pairs, + # but they differ in who won the games. + parameters1 = run_and_get_new_weights(initial_parameters, [go.BLACK, go.WHITE], game) + parameters2 = run_and_get_new_weights(initial_parameters, [go.WHITE, go.BLACK], game) + + # Assert that some parameters changed. + any_change_1 = any(not np.array_equal(i, p1) for (i, p1) in zip(initial_parameters, + parameters1)) + any_change_2 = any(not np.array_equal(i, p2) for (i, p2) in zip(initial_parameters, + parameters2)) + self.assertTrue(any_change_1) + self.assertTrue(any_change_2) + + # Changes in case 1 should be equal and opposite to changes in case 2. Allowing 0.1% + # difference in precision. + for (i, p1, p2) in zip(initial_parameters, parameters1, parameters2): + diff1 = p1 - i + diff2 = p2 - i + npt.assert_allclose(diff1, -diff2, rtol=1e-3, atol=1e-11) + + for f in _list_mock_games(SGF_FOLDER): + test_game_gradient(f) def testRunNGamesUpdatesWeights(self): - policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) - policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) - learner = MockPlayer(policy1, MOCK_GAME) - opponent = MockPlayer(policy2, MOCK_GAME) - optimizer = SGD() - init_weights = policy1.model.get_weights() - policy1.model.compile(loss=log_loss, optimizer=optimizer) + def test_game_run_N(game): + policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + learner = MockPlayer(policy1, game) + opponent = MockPlayer(policy2, game) + optimizer = SGD() + init_weights = policy1.model.get_weights() + policy1.model.compile(loss=log_loss, optimizer=optimizer) - # Run RL training - run_n_games(optimizer, learner, opponent, 2) + # Run RL training + run_n_games(optimizer, learner, opponent, 2) + + # Get new weights for comparison + trained_weights = policy1.model.get_weights() - # Get new weights for comparison - trained_weights = policy1.model.get_weights() + # Assert that some parameters changed. + any_change = any(not np.array_equal(i, t) + for (i, t) in zip(init_weights, trained_weights)) + self.assertTrue(any_change) - # Assert that some parameters changed. - any_change = any(not np.array_equal(i, t) for (i, t) in zip(init_weights, trained_weights)) - self.assertTrue(any_change) + for f in _list_mock_games(SGF_FOLDER): + test_game_run_N(f) def testWinIncreasesMoveProbability(self): - # Create "mock" state that ends after 20 moves with the learner winnning - win_state = [MockState(go.BLACK, 20, size=19)] - policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) - policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) - learner = MockPlayer(policy1, MOCK_GAME) - opponent = MockPlayer(policy2, MOCK_GAME) - optimizer = SGD() - policy1.model.compile(loss=log_loss, optimizer=optimizer) + def test_game_increase(game): + # Create "mock" state that ends after 20 moves with the learner winnning + win_state = [MockState(go.BLACK, 20, size=19)] + policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + learner = MockPlayer(policy1, game) + opponent = MockPlayer(policy2, game) + optimizer = SGD() + policy1.model.compile(loss=log_loss, optimizer=optimizer) - # Get initial (before learning) move probabilities for all moves made by black - init_move_probs = get_sgf_move_probs(MOCK_GAME, policy1, go.BLACK) - init_probs = [prob for (mv, prob) in init_move_probs] + # Get initial (before learning) move probabilities for all moves made by black + init_move_probs = get_sgf_move_probs(game, policy1, go.BLACK) + init_probs = [prob for (mv, prob) in init_move_probs] - # Run RL training - run_n_games(optimizer, learner, opponent, 1, mock_states=win_state) + # Run RL training + run_n_games(optimizer, learner, opponent, 1, mock_states=win_state) + + # Get new move probabilities for black's moves having finished 1 round of training + new_move_probs = get_sgf_move_probs(game, policy1, go.BLACK) + new_probs = [prob for (mv, prob) in new_move_probs] - # Get new move probabilities for black's moves having finished 1 round of training - new_move_probs = get_sgf_move_probs(MOCK_GAME, policy1, go.BLACK) - new_probs = [prob for (mv, prob) in new_move_probs] + # Assert that, on average, move probabilities for black increased having won. + self.assertTrue(sum((new_probs[i] - init_probs[i]) for i in range(10)) > 0) - # Assert that, on average, move probabilities for black increased having won. - self.assertTrue(sum((new_probs[i] - init_probs[i]) for i in range(10)) > 0) + for f in _list_mock_games(SGF_FOLDER): + test_game_increase(f) def testLoseDecreasesMoveProbability(self): - # Create "mock" state that ends after 20 moves with the learner losing - lose_state = [MockState(go.WHITE, 20, size=19)] - policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) - policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) - learner = MockPlayer(policy1, MOCK_GAME) - opponent = MockPlayer(policy2, MOCK_GAME) - optimizer = SGD() - policy1.model.compile(loss=log_loss, optimizer=optimizer) - - # Get initial (before learning) move probabilities for all moves made by black - init_move_probs = get_sgf_move_probs(MOCK_GAME, policy1, go.BLACK) - init_probs = [prob for (mv, prob) in init_move_probs] - - # Run RL training - run_n_games(optimizer, learner, opponent, 1, mock_states=lose_state) - - # Get new move probabilities for black's moves having finished 1 round of training - new_move_probs = get_sgf_move_probs(MOCK_GAME, policy1, go.BLACK) - new_probs = [prob for (mv, prob) in new_move_probs] - - # Assert that, on average, move probabilities for black decreased having lost. - self.assertTrue(sum((new_probs[i] - init_probs[i]) for i in range(10)) < 0) + def test_game_decrease(game): + # Create "mock" state that ends after 20 moves with the learner losing + lose_state = [MockState(go.WHITE, 20, size=19)] + policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + learner = MockPlayer(policy1, game) + opponent = MockPlayer(policy2, game) + optimizer = SGD() + policy1.model.compile(loss=log_loss, optimizer=optimizer) + + # Get initial (before learning) move probabilities for all moves made by black + init_move_probs = get_sgf_move_probs(game, policy1, go.BLACK) + init_probs = [prob for (mv, prob) in init_move_probs] + + # Run RL training + run_n_games(optimizer, learner, opponent, 1, mock_states=lose_state) + + # Get new move probabilities for black's moves having finished 1 round of training + new_move_probs = get_sgf_move_probs(game, policy1, go.BLACK) + new_probs = [prob for (mv, prob) in new_move_probs] + + # Assert that, on average, move probabilities for black decreased having lost. + self.assertTrue(sum((new_probs[i] - init_probs[i]) for i in range(10)) < 0) + + for f in _list_mock_games(SGF_FOLDER): + test_game_decrease(f) if __name__ == '__main__': From 7e8ded161441bc98957a1b36ff48fe2889353129 Mon Sep 17 00:00:00 2001 From: MaMiFreak Date: Mon, 19 Dec 2016 16:04:53 +0100 Subject: [PATCH 143/191] added os.path.join --- tests/test_reinforcement_policy_trainer.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_reinforcement_policy_trainer.py b/tests/test_reinforcement_policy_trainer.py index 48f41ec30..c75ae5212 100644 --- a/tests/test_reinforcement_policy_trainer.py +++ b/tests/test_reinforcement_policy_trainer.py @@ -8,8 +8,7 @@ from AlphaGo.models.policy import CNNPolicy from AlphaGo.util import sgf_iter_states -SGF_FOLDER = "tests/test_data/sgf/" - +SGF_FOLDER = os.path.join('tests', 'test_data', 'sgf/') def _is_sgf(fname): return fname.strip()[-4:] == ".sgf" From eda1c1a3293cd83400bb42cc7e35b3c8e371dcbd Mon Sep 17 00:00:00 2001 From: MaMiFreak Date: Mon, 19 Dec 2016 16:11:36 +0100 Subject: [PATCH 144/191] added blank line --- tests/test_reinforcement_policy_trainer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_reinforcement_policy_trainer.py b/tests/test_reinforcement_policy_trainer.py index c75ae5212..59d3e642a 100644 --- a/tests/test_reinforcement_policy_trainer.py +++ b/tests/test_reinforcement_policy_trainer.py @@ -10,6 +10,7 @@ SGF_FOLDER = os.path.join('tests', 'test_data', 'sgf/') + def _is_sgf(fname): return fname.strip()[-4:] == ".sgf" From 9c3b62a3bd15cbcc1fc8de77822f0df351a573ca Mon Sep 17 00:00:00 2001 From: MaMiFreak Date: Tue, 20 Dec 2016 21:38:45 +0100 Subject: [PATCH 145/191] Travis update to Keras 1.2.0 --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 13d4142aa..8b76f93c1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -32,7 +32,7 @@ install: # install requirements - conda install --yes Cython=0.24 h5py=2.6.0 numpy=1.11.2 scipy=0.18.1 PyYAML=3.12 matplotlib pandas pytest - - pip install --user --no-deps Theano==0.8.2 sgf==0.5 keras==1.1.2 pygtp==0.3 + - pip install --user --no-deps Theano==0.8.2 sgf==0.5 keras==1.2.0 pygtp==0.3 - pip install --user flake8 # install TensorFlow if needed From 1d0f9788524c6e1c92c0b164aedf32468baf5c5c Mon Sep 17 00:00:00 2001 From: MaMiFreak Date: Tue, 20 Dec 2016 21:39:17 +0100 Subject: [PATCH 146/191] requirements update to Keras 1.2.0 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b609137b3..f08033af6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ Cython==0.24 h5py==2.6.0 -Keras==1.1.2 +Keras==1.2.0 numpy==1.11.2 pygtp==0.3 PyYAML==3.12 From 781d27f025c39923d896dca268498422673ec38f Mon Sep 17 00:00:00 2001 From: Jon Shao Date: Wed, 21 Dec 2016 07:33:11 -0800 Subject: [PATCH 147/191] Fixed SL/RL saving of command line args --- AlphaGo/training/reinforcement_policy_trainer.py | 3 ++- AlphaGo/training/supervised_policy_trainer.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index a41cfd9ba..74bd62005 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -175,7 +175,8 @@ def run_training(cmd_line_args=None): metadata = json.load(f) # Append args of current run to history of full command args. - metadata["cmd_line_args"] = metadata.get("cmd_line_args", []).append(vars(args)) + metadata["cmd_line_args"] = metadata.get("cmd_line_args", []) + metadata["cmd_line_args"].append(vars(args)) def save_metadata(): with open(os.path.join(args.out_directory, "metadata.json"), "w") as f: diff --git a/AlphaGo/training/supervised_policy_trainer.py b/AlphaGo/training/supervised_policy_trainer.py index 8041f6fbe..f23f5a8f4 100644 --- a/AlphaGo/training/supervised_policy_trainer.py +++ b/AlphaGo/training/supervised_policy_trainer.py @@ -201,8 +201,8 @@ def run_training(cmd_line_args=None): meta_writer.metadata["model_file"] = args.model # Record all command line args in a list so that all args are recorded even # when training is stopped and resumed. - meta_writer.metadata["cmd_line_args"] \ - = meta_writer.metadata.get("cmd_line_args", []).append(vars(args)) + meta_writer.metadata["cmd_line_args"] = meta_writer.metadata.get("cmd_line_args", []) + meta_writer.metadata["cmd_line_args"].append(vars(args)) # create ModelCheckpoint to save weights every epoch checkpoint_template = os.path.join(args.out_directory, "weights.{epoch:05d}.hdf5") From 392a5d35a13ef28dbab0a661408719334e4f5efd Mon Sep 17 00:00:00 2001 From: MaMiFreak Date: Tue, 3 Jan 2017 17:36:04 +0100 Subject: [PATCH 148/191] Added filer_per_layer_K argument added possibility to have different amount of filters per layer --- AlphaGo/models/policy.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/AlphaGo/models/policy.py b/AlphaGo/models/policy.py index 6a59b9f65..edf93c4ec 100644 --- a/AlphaGo/models/policy.py +++ b/AlphaGo/models/policy.py @@ -71,10 +71,12 @@ def create_network(**kwargs): - input_dim: depth of features to be processed by first layer (no default) - board: width of the go board to be processed (default 19) - filters_per_layer: number of filters used on every layer (default 128) + - filters_per_layer_K: (where K is between 1 and ) number of filters + used on layer K (default #filters_per_layer) - layers: number of convolutional steps (default 12) - filter_width_K: (where K is between 1 and ) width of filter on - layer K (default 3 except 1st layer which defaults to 5). - Must be odd. + layer K (default 3 except 1st layer which defaults to 5). + Must be odd. """ defaults = { "board": 19, @@ -106,8 +108,13 @@ def create_network(**kwargs): # use filter_width_K if it is there, otherwise use 3 filter_key = "filter_width_%d" % i filter_width = params.get(filter_key, 3) + + # use filters_per_layer_K if it is there, otherwise use default value + filter_count_key = "filters_per_layer_%d" % i + filter_nb = params.get(filter_count_key, params["filters_per_layer"]) + network.add(convolutional.Convolution2D( - nb_filter=params["filters_per_layer"], + nb_filter=filter_nb, nb_row=filter_width, nb_col=filter_width, init='uniform', From 5c9cac1ef0cd8ac921d93cf18ed58521860382cb Mon Sep 17 00:00:00 2001 From: wrongu Date: Wed, 4 Jan 2017 11:49:25 -0500 Subject: [PATCH 149/191] filters_per_layer_1 handled properly in policy.py --- AlphaGo/models/policy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AlphaGo/models/policy.py b/AlphaGo/models/policy.py index edf93c4ec..9ea81f0a9 100644 --- a/AlphaGo/models/policy.py +++ b/AlphaGo/models/policy.py @@ -96,7 +96,7 @@ def create_network(**kwargs): # create first layer network.add(convolutional.Convolution2D( input_shape=(params["input_dim"], params["board"], params["board"]), - nb_filter=params["filters_per_layer"], + nb_filter=params.get("filters_per_layer_1", params["filters_per_layer"]), nb_row=params["filter_width_1"], nb_col=params["filter_width_1"], init='uniform', From c8e88e55189e2c81bbc0eec122535ec1964c54a5 Mon Sep 17 00:00:00 2001 From: Paul Prescod Date: Sun, 15 Jan 2017 15:33:43 -0800 Subject: [PATCH 150/191] Change classname so benchmarking works properly. --- benchmarks/preprocessing_benchmark.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/benchmarks/preprocessing_benchmark.py b/benchmarks/preprocessing_benchmark.py index 72abacf33..bfc798e05 100644 --- a/benchmarks/preprocessing_benchmark.py +++ b/benchmarks/preprocessing_benchmark.py @@ -1,11 +1,18 @@ -from AlphaGo.preprocessing.game_converter import game_converter +import os +import sys from cProfile import Profile +p = os.path +parentddir = p.abspath(p.join(p.dirname(__file__), "..")) +sys.path.append(parentddir) + +from AlphaGo.preprocessing.game_converter import GameConverter # noqa: E402 + prof = Profile() test_features = ["board", "turns_since", "liberties", "capture_size", "self_atari_size", "liberties_after", "sensibleness", "zeros"] -gc = game_converter(test_features) +gc = GameConverter(test_features) args = ('tests/test_data/sgf/Lee-Sedol-vs-AlphaGo-20160309.sgf', 19) From d1401b4bb83d61c27a7978653eac232f0faa5063 Mon Sep 17 00:00:00 2001 From: MaMiFreak Date: Thu, 19 Jan 2017 22:33:05 +0100 Subject: [PATCH 151/191] greedy_start option for ProbabilisticPolicyPlayer; fixed apply_temperature bug --- AlphaGo/ai.py | 83 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 59 insertions(+), 24 deletions(-) diff --git a/AlphaGo/ai.py b/AlphaGo/ai.py index a2a43d25c..c296fdcb5 100644 --- a/AlphaGo/ai.py +++ b/AlphaGo/ai.py @@ -1,13 +1,13 @@ """Policy players""" import numpy as np -from operator import itemgetter from AlphaGo import go from AlphaGo import mcts +from operator import itemgetter class GreedyPolicyPlayer(object): """A player that uses a greedy policy (i.e. chooses the highest probability - move each turn) + move each turn) """ def __init__(self, policy_function, pass_when_offered=False, move_limit=None): @@ -16,35 +16,43 @@ def __init__(self, policy_function, pass_when_offered=False, move_limit=None): self.move_limit = move_limit def get_move(self, state): + # check move limit if self.move_limit is not None and len(state.history) > self.move_limit: return go.PASS_MOVE + + # check if pass was offered and we want to pass if self.pass_when_offered: if len(state.history) > 100 and state.history[-1] == go.PASS_MOVE: return go.PASS_MOVE + + # list with sensible moves sensible_moves = [move for move in state.get_legal_moves(include_eyes=False)] + + # check if there are sensible moves left to do if len(sensible_moves) > 0: move_probs = self.policy.eval_state(state, sensible_moves) max_prob = max(move_probs, key=itemgetter(1)) return max_prob[0] + # No 'sensible' moves available, so do pass move return go.PASS_MOVE class ProbabilisticPolicyPlayer(object): """A player that samples a move in proportion to the probability given by the - policy. - - By manipulating the 'temperature', moves can be pushed towards totally random - (high temperature) or towards greedy play (low temperature) + policy. + By manipulating the 'temperature', moves can be pushed towards totally random + (high temperature) or towards greedy play (low temperature) """ - def __init__(self, policy_function, temperature=1.0, pass_when_offered=False, move_limit=None): + def __init__(self, policy_function, temperature=1.0, pass_when_offered=False, + move_limit=None, greedy_start=None): assert(temperature > 0.0) self.policy = policy_function self.move_limit = move_limit self.beta = 1.0 / temperature self.pass_when_offered = pass_when_offered - self.move_limit = move_limit + self.greedy_start = greedy_start def apply_temperature(self, distribution): log_probabilities = np.log(distribution) @@ -58,23 +66,42 @@ def apply_temperature(self, distribution): return probabilities / probabilities.sum() def get_move(self, state): + # check move limit if self.move_limit is not None and len(state.history) > self.move_limit: return go.PASS_MOVE + + # check if pass was offered and we want to pass if self.pass_when_offered: if len(state.history) > 100 and state.history[-1] == go.PASS_MOVE: return go.PASS_MOVE + + # list with 'sensible' moves sensible_moves = [move for move in state.get_legal_moves(include_eyes=False)] + + # check if there are 'sensible' moves left to do if len(sensible_moves) > 0: + move_probs = self.policy.eval_state(state, sensible_moves) - # zip(*list) is like the 'transpose' of zip; - # zip(*zip([1,2,3], [4,5,6])) is [(1,2,3), (4,5,6)] - moves, probabilities = zip(*move_probs) - # apply 'temperature' to the distribution - probabilities = self.apply_temperature(probabilities) - # numpy interprets a list of tuples as 2D, so we must choose an - # _index_ of moves then apply it in 2 steps - choice_idx = np.random.choice(len(moves), p=probabilities) - return moves[choice_idx] + + if self.greedy_start is not None and len(state.history) >= self.greedy_start: + # greedy + + max_prob = max(move_probs, key=itemgetter(1)) + return max_prob[0] + else: + # probabilistic + + # zip(*list) is like the 'transpose' of zip; + # zip(*zip([1,2,3], [4,5,6])) is [(1,2,3), (4,5,6)] + moves, probabilities = zip(*move_probs) + # apply 'temperature' to the distribution + probabilities = self.apply_temperature(probabilities) + # numpy interprets a list of tuples as 2D, so we must choose an + # _index_ of moves then apply it in 2 steps + choice_idx = np.random.choice(len(moves), p=probabilities) + return moves[choice_idx] + + # No 'sensible' moves available, so do pass move return go.PASS_MOVE def get_moves(self, states): @@ -88,13 +115,21 @@ def get_moves(self, states): if len(move_probs) == 0 or len(states[i].history) > self.move_limit: move_list[i] = go.PASS_MOVE else: - # this 'else' clause is identical to ProbabilisticPolicyPlayer.get_move - moves, probabilities = zip(*move_probs) - probabilities = np.array(probabilities) - probabilities = probabilities ** self.beta - probabilities = probabilities / probabilities.sum() - choice_idx = np.random.choice(len(moves), p=probabilities) - move_list[i] = moves[choice_idx] + if self.greedy_start is not None and len(states[i].history) >= self.greedy_start: + # greedy + + max_prob = max(move_probs, key=itemgetter(1)) + move_list[i] = max_prob[0] + else: + # probabilistic + + moves, probabilities = zip(*move_probs) + # apply 'temperature' to the distribution + probabilities = self.apply_temperature(probabilities) + # numpy interprets a list of tuples as 2D, so we must choose an + # _index_ of moves then apply it in 2 steps + choice_idx = np.random.choice(len(moves), p=probabilities) + move_list[i] = moves[choice_idx] return move_list From cba72484bc5bb60328219a433dd4f37d537fdd7c Mon Sep 17 00:00:00 2001 From: MaMiFreak Date: Sun, 14 May 2017 11:05:52 +0100 Subject: [PATCH 152/191] cython-v1-BETA --- AlphaGo/ai.py | 202 +- AlphaGo/go.pxd | 145 ++ AlphaGo/go.pyx | 2184 +++++++++++++++++ AlphaGo/go_data.pxd | 89 + AlphaGo/go_data.pyx | 416 ++++ AlphaGo/{go.py => go_python.py} | 186 +- AlphaGo/go_root.pxd | 49 + AlphaGo/go_root.pyx | 360 +++ AlphaGo/models/nn_util.py | 10 +- AlphaGo/models/policy.py | 6 +- AlphaGo/models/rollout.py | 75 + AlphaGo/models/value.py | 163 +- AlphaGo/preprocessing/game_converter.py | 31 +- .../preprocessing/generate_value_training.py | 326 +++ AlphaGo/preprocessing/preprocessing.pxd | 79 + AlphaGo/preprocessing/preprocessing.pyx | 747 ++++++ ...eprocessing.py => preprocessing_python.py} | 13 +- .../training/reinforcement_policy_trainer.py | 24 +- .../training/reinforcement_value_trainer.py | 814 ++++++ AlphaGo/training/supervised_policy_trainer.py | 906 +++++-- .../training/supervised_rollout_trainer.py | 833 +++++++ AlphaGo/util.py | 108 +- interface/Play.py | 11 +- interface/gtp_wrapper.py | 22 +- setup.py | 19 + tests/parseboard.py | 13 +- .../hdf5/policy_training_features.hdf5 | Bin 0 -> 516375 bytes ...5 => random_minimodel_policy_weights.hdf5} | Bin .../hdf5/random_minimodel_value_weights.hdf5 | Bin 0 -> 520008 bytes .../hdf5/value_training_features.hdf5 | Bin 0 -> 1697442 bytes tests/test_data/minimodel.json | 1 - tests/test_data/minimodel_policy.json | 1 + tests/test_data/minimodel_value.json | 1 + tests/test_game_converter.py | 12 +- tests/test_gamestate.py | 103 +- tests/test_gtp_wrapper.py | 8 +- tests/test_ladders.py | 72 +- tests/test_liberties.py | 44 - tests/test_mcts.py | 18 +- tests/test_players.py | 4 +- tests/test_policy.py | 82 +- tests/test_preprocessing.py | 222 +- tests/test_reinforcement_policy_trainer.py | 75 +- tests/test_reinforcement_value_trainer.py | 125 + tests/test_supervised_policy_trainer.py | 97 +- tests/test_value.py | 133 + 46 files changed, 8242 insertions(+), 587 deletions(-) create mode 100644 AlphaGo/go.pxd create mode 100644 AlphaGo/go.pyx create mode 100644 AlphaGo/go_data.pxd create mode 100644 AlphaGo/go_data.pyx rename AlphaGo/{go.py => go_python.py} (79%) create mode 100644 AlphaGo/go_root.pxd create mode 100644 AlphaGo/go_root.pyx create mode 100644 AlphaGo/models/rollout.py create mode 100644 AlphaGo/preprocessing/generate_value_training.py create mode 100644 AlphaGo/preprocessing/preprocessing.pxd create mode 100644 AlphaGo/preprocessing/preprocessing.pyx rename AlphaGo/preprocessing/{preprocessing.py => preprocessing_python.py} (97%) create mode 100644 AlphaGo/training/supervised_rollout_trainer.py create mode 100644 setup.py create mode 100644 tests/test_data/hdf5/policy_training_features.hdf5 rename tests/test_data/hdf5/{random_minimodel_weights.hdf5 => random_minimodel_policy_weights.hdf5} (100%) create mode 100644 tests/test_data/hdf5/random_minimodel_value_weights.hdf5 create mode 100644 tests/test_data/hdf5/value_training_features.hdf5 delete mode 100644 tests/test_data/minimodel.json create mode 100644 tests/test_data/minimodel_policy.json create mode 100644 tests/test_data/minimodel_value.json delete mode 100644 tests/test_liberties.py create mode 100644 tests/test_reinforcement_value_trainer.py create mode 100644 tests/test_value.py diff --git a/AlphaGo/ai.py b/AlphaGo/ai.py index c296fdcb5..163888bd4 100644 --- a/AlphaGo/ai.py +++ b/AlphaGo/ai.py @@ -17,13 +17,13 @@ def __init__(self, policy_function, pass_when_offered=False, move_limit=None): def get_move(self, state): # check move limit - if self.move_limit is not None and len(state.history) > self.move_limit: - return go.PASS_MOVE + if self.move_limit is not None and len(state.get_history()) > self.move_limit: + return go.PASS # check if pass was offered and we want to pass if self.pass_when_offered: - if len(state.history) > 100 and state.history[-1] == go.PASS_MOVE: - return go.PASS_MOVE + if len(state.get_history()) > 100 and state.get_history()[-1] == go.PASS: + return go.PASS # list with sensible moves sensible_moves = [move for move in state.get_legal_moves(include_eyes=False)] @@ -35,7 +35,7 @@ def get_move(self, state): return max_prob[0] # No 'sensible' moves available, so do pass move - return go.PASS_MOVE + return go.PASS class ProbabilisticPolicyPlayer(object): @@ -67,13 +67,13 @@ def apply_temperature(self, distribution): def get_move(self, state): # check move limit - if self.move_limit is not None and len(state.history) > self.move_limit: - return go.PASS_MOVE + if self.move_limit is not None and len(state.get_history()) > self.move_limit: + return go.PASS # check if pass was offered and we want to pass if self.pass_when_offered: - if len(state.history) > 100 and state.history[-1] == go.PASS_MOVE: - return go.PASS_MOVE + if len(state.get_history()) > 100 and state.get_history()[-1] == go.PASS: + return go.PASS # list with 'sensible' moves sensible_moves = [move for move in state.get_legal_moves(include_eyes=False)] @@ -83,7 +83,7 @@ def get_move(self, state): move_probs = self.policy.eval_state(state, sensible_moves) - if self.greedy_start is not None and len(state.history) >= self.greedy_start: + if self.greedy_start is not None and len(state.get_history()) >= self.greedy_start: # greedy max_prob = max(move_probs, key=itemgetter(1)) @@ -102,7 +102,7 @@ def get_move(self, state): return moves[choice_idx] # No 'sensible' moves available, so do pass move - return go.PASS_MOVE + return go.PASS def get_moves(self, states): """Batch version of get_move. A list of moves is returned (one per state) @@ -112,10 +112,10 @@ def get_moves(self, states): all_moves_distributions = self.policy.batch_eval_state(states, sensible_move_lists) move_list = [None] * len(states) for i, move_probs in enumerate(all_moves_distributions): - if len(move_probs) == 0 or len(states[i].history) > self.move_limit: - move_list[i] = go.PASS_MOVE + if len(move_probs) == 0 or len(states[i].get_history()) > self.move_limit: + move_list[i] = go.PASS else: - if self.greedy_start is not None and len(states[i].history) >= self.greedy_start: + if self.greedy_start is not None and len(states[i].get_history()) >= self.greedy_start: # greedy max_prob = max(move_probs, key=itemgetter(1)) @@ -133,6 +133,178 @@ def get_moves(self, states): return move_list +class RolloutPlayer(object): + """A player that samples a move in proportion to the probability given by the + policy. + By manipulating the 'temperature', moves can be pushed towards totally random + (high temperature) or towards greedy play (low temperature) + """ + + def __init__(self, rollout_function, temperature=1.0, pass_when_offered=False, + move_limit=None, greedy_start=None): + assert(temperature > 0.0) + self.policy = rollout_function + self.move_limit = move_limit + self.beta = 1.0 / temperature + self.pass_when_offered = pass_when_offered + self.greedy_start = greedy_start + + def apply_temperature(self, distribution): + log_probabilities = np.log(distribution) + # apply beta exponent to probabilities (in log space) + log_probabilities = log_probabilities * self.beta + # scale probabilities to a more numerically stable range (in log space) + log_probabilities = log_probabilities - log_probabilities.max() + # convert back from log space + probabilities = np.exp(log_probabilities) + # re-normalize the distribution + return probabilities / probabilities.sum() + + def get_move(self, state): + # check move limit + if self.move_limit is not None and len(state.get_history()) > self.move_limit: + return go.PASS + + # check if pass was offered and we want to pass + if self.pass_when_offered: + if len(state.get_history()) > 100 and state.get_history()[-1] == go.PASS: + return go.PASS + + # list with 'sensible' moves + sensible_moves = [move for move in state.get_legal_moves(include_eyes=False)] + + # check if there are 'sensible' moves left to do + if len(sensible_moves) > 0: + + move_probs = self.policy.eval_state(state, sensible_moves) + + if self.greedy_start is not None and len(state.get_history()) >= self.greedy_start: + # greedy + + max_prob = max(move_probs, key=itemgetter(1)) + return max_prob[0] + else: + # probabilistic + + # zip(*list) is like the 'transpose' of zip; + # zip(*zip([1,2,3], [4,5,6])) is [(1,2,3), (4,5,6)] + moves, probabilities = zip(*move_probs) + # apply 'temperature' to the distribution + probabilities = self.apply_temperature(probabilities) + # numpy interprets a list of tuples as 2D, so we must choose an + # _index_ of moves then apply it in 2 steps + choice_idx = np.random.choice(len(moves), p=probabilities) + return moves[choice_idx] + + # No 'sensible' moves available, so do pass move + return go.PASS + + def get_moves(self, states): + """Batch version of get_move. A list of moves is returned (one per state) + """ + sensible_move_lists = [[move for move in st.get_legal_moves(include_eyes=False)] + for st in states] + all_moves_distributions = self.policy.batch_eval_state(states, sensible_move_lists) + move_list = [None] * len(states) + for i, move_probs in enumerate(all_moves_distributions): + if len(move_probs) == 0 or len(states[i].get_history()) > self.move_limit: + move_list[i] = go.PASS + else: + if self.greedy_start is not None and len(states[i].get_history()) >= self.greedy_start: + # greedy + + max_prob = max(move_probs, key=itemgetter(1)) + move_list[i] = max_prob[0] + else: + # probabilistic + + moves, probabilities = zip(*move_probs) + # apply 'temperature' to the distribution + probabilities = self.apply_temperature(probabilities) + # numpy interprets a list of tuples as 2D, so we must choose an + # _index_ of moves then apply it in 2 steps + choice_idx = np.random.choice(len(moves), p=probabilities) + move_list[i] = moves[choice_idx] + return move_list + + +class ValuePlayer(object): + """A player that samples a move in proportion to the probability given by the + value policy. + + By manipulating the 'temperature', moves can be pushed towards totally random + (high temperature) or towards greedy play (low temperature) + + greedy_start can be used to force greedy play as of move #greedy_start + """ + + def __init__(self, value_function, temperature=1.0, pass_when_offered=False, + move_limit=None, greedy_start=None): + assert(temperature > 0.0) + self.pass_when_offered = pass_when_offered + self.greedy_start = greedy_start + self.beta = 1.0 / temperature + self.move_limit = move_limit + self.value = value_function + + def apply_temperature(self, distribution): + log_probabilities = np.log(distribution) + # apply beta exponent to probabilities (in log space) + log_probabilities = log_probabilities * self.beta + # scale probabilities to a more numerically stable range (in log space) + log_probabilities = log_probabilities - log_probabilities.max() + # convert back from log space + probabilities = np.exp(log_probabilities) + # re-normalize the distribution + return probabilities / probabilities.sum() + + def get_move(self, state): + # check move limit + if self.move_limit is not None and len(state.get_history()) > self.move_limit: + return go.PASS + + # check if pass was offered and we want to pass + if self.pass_when_offered: + if len(state.get_history()) > 100 and state.get_history()[-1] == go.PASS: + return go.PASS + + # list with 'sensible' moves + sensible_moves = [move for move in state.get_legal_moves(include_eyes=False)] + + # check if there are 'sensible' moves left to do + if len(sensible_moves) > 0: + # list with legal moves + legal_moves = [move for move in state.get_legal_moves()] + + # generate all possible next states + state_list = [state.copy() for _ in legal_moves] + for st, mv in zip(state_list, legal_moves): + st.do_move(mv) + + # evaluate all possble states + probabilities = [self.value.eval_state(next_state) for next_state in state_list] + + if self.greedy_start is not None and len(state.get_history()) >= self.greedy_start: + # greedy play + + move_probs = zip(legal_moves, probabilities) + max_prob = max(move_probs, key=itemgetter(1)) + return max_prob[0] + else: + # probabilistic play + + # apply 'temperature' to the distribution + probabilities = self.apply_temperature(probabilities) + + # numpy interprets a list of tuples as 2D, so we must choose an + # _index_ of moves then apply it in 2 steps + choice_idx = np.random.choice(len(legal_moves), p=probabilities) + return legal_moves[choice_idx] + + # No 'sensible' moves available, so do pass move + return go.PASS + + class MCTSPlayer(object): def __init__(self, value_function, policy_function, rollout_function, lmbda=.5, c_puct=5, rollout_limit=500, playout_depth=40, n_playout=100): @@ -146,4 +318,4 @@ def get_move(self, state): self.mcts.update_with_move(move) return move # No 'sensible' moves available, so do pass move - return go.PASS_MOVE + return go.PASS diff --git a/AlphaGo/go.pxd b/AlphaGo/go.pxd new file mode 100644 index 000000000..e8305167b --- /dev/null +++ b/AlphaGo/go.pxd @@ -0,0 +1,145 @@ +import numpy as np +cimport numpy as np +from AlphaGo.go_data cimport * + + +cdef class GameState: + + ############################################################################ + # variables declarations # + # # + ############################################################################ + + # amount of locations on one side + cdef char size + # amount of locations on board, size * size + cdef short board_size + + # possible ko location + cdef short ko + + # list with all groups + cdef Groups_List *groups_list + # pointer to empty group + cdef Group *group_empty + + # list representing board locations as groups + # a Group contains all group stone locations and group liberty locations + cdef Group **board_groups + + cdef char player_current + cdef char player_opponent + + cdef short capture_black + cdef short capture_white + + cdef short passes_black + cdef short passes_white + + # TODO replace list with struct + cdef list history + cdef Locations_List *moves_history + # list with legal moves + cdef Locations_List *moves_legal + + # array, keep track of 3x3 pattern hashes + cdef long *hash3x3 + + # arrays, neighbor arrays pointers + cdef short *neighbor + cdef short *neighbor3x3 + cdef short *neighbor12d + + # zobrist + cdef dict hash_lookup + cdef int current_hash + cdef set previous_hashes + + ############################################################################ + # init functions # + # # + ############################################################################ + + cdef void initialize_duplicate( self, GameState copyState ) + + ############################################################################ + # private cdef functions used for game-play # + # # + ############################################################################ + + cdef bint is_legal_move( self, short location, Group **board, short ko ) + cdef bint has_liberty_after( self, short location, Group **board ) + cdef short calculate_board_location( self, char x, char y ) + cdef tuple calculate_tuple_location( self, short location ) + cdef void set_moves_legal_list( self, Locations_List *moves_legal ) + + cdef void combine_groups( self, Group* group_keep, Group* group_remove, Group **board ) + cdef void remove_group( self, Group* group_remove, Group **board, short* ko ) + cdef void update_hashes( self, Group* group ) + cdef void add_to_group( self, short location, Group **board, short* ko, short* count_captures ) + + ############################################################################ + # private cdef functions used for feature generation # + # # + ############################################################################ + + cdef long generate_12d_hash( self, short centre ) + cdef long generate_3x3_hash( self, short centre ) + cdef void get_group_after( self, short* groups_after, char* locations, char* captures, short location ) + cdef bint is_true_eye( self, short location, Locations_List* eyes, char owner ) + + ############################################################################ + # private cdef Ladder functions # + # # + ############################################################################ + + cdef Groups_List* add_ladder_move( self, short location, Group **board, short* ko ) + cdef void unremove_group( self, Group* group_remove, Group **board ) + cdef dict get_capture_moves( self, Group* group, char color, Group **board ) + cdef void get_removed_groups( self, short location, Groups_List* removed_groups, Group **board, short* ko ) + cdef void undo_ladder_move( self, short location, Groups_List* removed_groups, short ko, Group **board, short* ko ) + cdef bint is_ladder_escape_move( self, Group **board, short* ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase ) + cdef bint is_ladder_capture_move( self, Group **board, short* ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase ) + + ############################################################################ + # public cdef functions used by preprocessing # + # # + ############################################################################ + + cdef short* get_groups_after( self ) + cdef Locations_List* get_sensible_moves( self ) + cdef list get_neighbor_locations( self ) + cdef long get_hash_12d( self, short centre ) + cdef long get_hash_3x3( self, short location ) + cdef char* get_ladder_escapes( self, int maxDepth ) + cdef char* get_ladder_captures( self, int maxDepth ) + cdef char get_board_feature( self, short location ) + + ############################################################################ + # public cdef functions used for game play # + # # + ############################################################################ + + cdef void add_move( self, short location ) + cdef GameState new_state_add_move( self, short location ) + cdef char get_winner_colour( self, int komi ) + + ############################################################################ + # public def functions used for game play (Python) # + # # + ############################################################################ + + #def do_move( self, action ) + #def get_next_state( self, action ) + #def place_handicap( self, handicap ) + #def get_winner( self, char komi ) + #def get_legal_moves( self, include_eyes = True ) + #def is_legal( self, action ) + + ############################################################################ + # tests # + # # + ############################################################################ + + cdef test( self ) + cdef test_cpp_fast( self ) diff --git a/AlphaGo/go.pyx b/AlphaGo/go.pyx new file mode 100644 index 000000000..6d1def572 --- /dev/null +++ b/AlphaGo/go.pyx @@ -0,0 +1,2184 @@ +#cython: profile=True +#cython: linetrace=True +import sys +import time +cimport cython +import numpy as np +cimport numpy as np +from libc.stdlib cimport malloc, free +from libc.string cimport memcpy, memset + +# expose variables to python +PASS = _PASS +BLACK = _BLACK +WHITE = _WHITE +EMPTY = _EMPTY + +cdef class GameState: + + ############################################################################ + # all variables are declared in the .pxd file # + # # + ############################################################################ + + """ -> variables, declared in go.pxd + + # amount of locations on one side + cdef char size + # amount of locations on board, size * size + cdef short board_size + + # possible ko location + cdef short ko + + # list with all groups + cdef Groups_List *groups_list + # pointer to empty group + cdef Group *group_empty + + # list representing board locations as groups + # a Group contains all group stone locations and group liberty locations + cdef Group **board_groups + + cdef char player_current + cdef char player_opponent + + cdef short capture_black + cdef short capture_white + + # TODO replace list with struct + cdef list history + cdef Locations_List *moves_history + # list with legal moves + cdef Locations_List *moves_legal + + # array, keep track of 3x3 pattern hashes + cdef long *hash3x3 + + # arrays, neighbor arrays pointers + cdef short *neighbor + cdef short *neighbor3x3 + cdef short *neighbor12d + + # zobrist + cdef dict hash_lookup + cdef int current_hash + cdef set previous_hashes + + -> variables, declared in go.pxd + """ + + ############################################################################ + # init functions # + # # + ############################################################################ + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef void initialize_duplicate( self, GameState copy_state ): + """ + Initialize all variables as a copy of copy_state + """ + + cdef int i + cdef short location + cdef Group* group_pointer + cdef Group* group + + # !!! do not copy !!! -> these do not need a deep copy as they are static + self.neighbor = copy_state.neighbor + self.neighbor3x3 = copy_state.neighbor3x3 + self.neighbor12d = copy_state.neighbor12d + + # empty group + self.group_empty = copy_state.group_empty + + # pattern dictionary + + # zobrist + # self.hash_lookup = copy_state.hash_lookup + + # set values + self.ko = copy_state.ko + self.capture_black = copy_state.capture_black + self.capture_white = copy_state.capture_white + self.passes_black = copy_state.passes_black + self.passes_white = copy_state.passes_white + self.size = copy_state.size + self.board_size = copy_state.board_size + self.player_current = copy_state.player_current + self.player_opponent = copy_state.player_opponent + # self.current_hash = copy_state.current_hash + + # copy values + self.history = list( copy_state.history ) + # self.previous_hashes = list( copy_state.previous_hashes ) + + # create array/list + + self.hash3x3 = malloc( ( self.board_size ) * sizeof( long ) ) + if not self.hash3x3: + raise MemoryError() + + memcpy( self.hash3x3, copy_state.hash3x3, ( self.board_size ) * sizeof( long ) ) + + self.moves_legal = malloc( sizeof( Locations_List ) ) + if not self.moves_legal: + raise MemoryError() + + self.moves_legal.locations = malloc( self.board_size * sizeof( short ) ) + if not self.moves_legal.locations: + raise MemoryError() + + memcpy( self.moves_legal.locations, copy_state.moves_legal.locations, self.board_size * sizeof( short ) ) + self.moves_legal.count = copy_state.moves_legal.count + + self.groups_list = malloc( sizeof( Groups_List ) ) + if not self.groups_list: + raise MemoryError() + + self.groups_list.board_groups = malloc( self.board_size * sizeof( Group* ) ) + if not self.groups_list.board_groups: + raise MemoryError() + + self.groups_list.count_groups = 0 + + self.board_groups = malloc( ( self.board_size + 1 ) * sizeof( Group* ) ) + if not self.board_groups: + raise MemoryError() + + # empty group and border group stay the same, the others will be reset + memcpy( self.board_groups, copy_state.board_groups, ( self.board_size + 1 ) * sizeof( Group* ) ) + + + # copy all groups and set groupsList + for i in range( copy_state.groups_list.count_groups ): + + group = copy_state.groups_list.board_groups[ i ] + + group_pointer = group_duplicate( group, self.board_size ) + groups_list_add( group_pointer, self.groups_list ) + + # loop over all group locations and set group + for location in range( self.board_size ): + + if group.locations[ location ] == _STONE: + + self.board_groups[ location ] = group_pointer + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def __init__( self, char size = 19, GameState copyState = None ): + """ + d create new instance of GameState + """ + + if copyState is not None: + + # create copy of given state + self.initialize_duplicate( copyState ) + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def __dealloc__(self): + """ + this function is called when this object is destroyed + + Prevent memory leaks by freeing all arrays created with malloc + + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + do not fee neighbor, neighbor3x3, neighbor12d, + group_empty or group_border + + RootState will handle those! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + """ + + cdef int i + + # free hash3x3 + if self.hash3x3 is not NULL: + + free( self.hash3x3 ) + + # free board_groups + if self.board_groups is not NULL: + + free( self.board_groups ) + + # free moves_legal and moves_legal.locations + if self.moves_legal is not NULL: + + if self.moves_legal.locations is not NULL: + + free( self.moves_legal.locations ) + + free( self.moves_legal ) + + # free groups_list all groups in groups_list.board_groups and groups_list.board_groups + if self.groups_list is not NULL: + + # loop over all groups and free them + for i in range( self.groups_list.count_groups ): + + group_destroy( self.groups_list.board_groups[ i ] ) + + # free groups_list.board_groups + if self.groups_list.board_groups is not NULL: + + free( self.groups_list.board_groups ) + + free( self.groups_list ) + + + ############################################################################ + # private cdef functions used for game-play # + # # + ############################################################################ + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef bint is_legal_move( self, short location, Group **board, short ko ): + """ + check if playing at location is a legal move to make + """ + + # check if it is empty + if board[ location ].colour != _EMPTY: + return 0 + + # check ko + if location == ko: + return 0 + + # check if it has liberty after + if not self.has_liberty_after( location, board ): + return 0 + # super-ko + + return 1 + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef bint has_liberty_after( self, short location, Group **board ): + """ + check if a play at location results in an alive group + - has liberty + - conects to group with >= 2 liberty + - captures enemy group + """ + cdef int i + cdef char board_value + cdef short count_liberty + cdef short neighbor_location + cdef Group* group_temp + + # loop over all four neighbors + for i in range( 4 ): + + # get neighbor location + neighbor_location = self.neighbor[ location * 4 + i ] + board_value = board[ neighbor_location ].colour + + # check empty location -> liberty + if board_value == _EMPTY: + + return 1 + + # get neighbor group + group_temp = board[ neighbor_location ] + count_liberty = group_temp.count_liberty + + # if there is a player_current group + if board_value == self.player_current: + + # if it has at least 2 liberty + if count_liberty >= 2: + + return 1 + + # if is a player_opponent group and has only one liberty + elif board_value == self.player_opponent and count_liberty == 1: + + return 1 + + return 0 + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef short calculate_board_location( self, char x, char y ): + """ + return location on board + no checks on outside board + x = columns + y = rows + """ + + # return board location + return x + ( y * self.size ) + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef tuple calculate_tuple_location( self, short location ): + """ + return location on board + no checks on outside board + """ + + # return board location + return ( location / self.size, location % self.size ) + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef void set_moves_legal_list( self, Locations_List *moves_legal ): + """ + generate moves_legal list + """ + + cdef short i + + moves_legal.count = 0 + for i in range( self.board_size ): + + if self.is_legal_move( i, self.board_groups, self.ko ): + + moves_legal.locations[ moves_legal.count ] = i + moves_legal.count += 1 + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef void combine_groups( self, Group* group_keep, Group* group_remove, Group **board ): + """ + combine two groups and remove one + """ + + cdef int i + cdef char value + + # combine stones, liberty and set groups + for i in range( self.board_size ): + + value = group_remove.locations[ i ] + + if value == _STONE: + + group_add_stone( group_keep, i ) + board[ i ] = group_keep + elif value == _LIBERTY: + + group_add_liberty( group_keep, i ) + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef void remove_group( self, Group* group_remove, Group **board, short* ko ): + """ + remove group from board + """ + + cdef short location + cdef short neighbor_location + cdef Group* group_temp + cdef char board_value + cdef int i + # empty group is always in border location + cdef Group* group_empty = self.group_empty + + # if groupsize == 1, possible ko + if group_remove.count_stones == 1: + + ko[ 0 ] = group_location_stone( group_remove, self.board_size ) + + # loop over all group stone locations + for location in range( self.board_size ): + + if group_remove.locations[ location ] == _STONE: + + # set location to empty group + board[ location ] = group_empty + + # update liberty of neighbors + # loop over all four neighbors + for i in range( 4 ): + + # get neighbor location + neighbor_location = self.neighbor[ location * 4 + i ] + + # only current_player groups can be next to a killed group + # check if there is a group + board_value = board[ neighbor_location ].colour + if board_value == self.player_current: + + # add liberty + group_temp = board[ neighbor_location ] + group_add_liberty( group_temp, location ) + + # update all 3x3 hashes in update_hash_locations + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef void update_hashes( self, Group* group ): + """ + update all locations affected by removal of group + """ + + cdef short i, a, location, location_array + + # loop over all stones in group + for i in range( self.board_size ): + + if group.locations[ i ] == _STONE: + + # update hash location + self.hash3x3[ i ] = self.generate_3x3_hash( i ) + + location_array = i * 8 + + # loop over diagonal + for a in range( 4, 8 ): + + location = self.neighbor3x3[ location_array + a ] + if self.board_groups[ location ].colour == _EMPTY: + + self.hash3x3[ location ] = self.generate_3x3_hash( location ) + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef void add_to_group( self, short location, Group **board, short* ko, short* count_captures ): + """ + add location to group or create new one + """ + + cdef Group* newGroup = NULL + cdef Group* tempGroup + cdef Group* changes + cdef short neighborLocation, location_array + cdef char boardValue + cdef char group_removed = 0 + cdef int i + + # find friendly stones + # loop over all four neighbors + for i in range( 4 ): + + # get neighbor location and value + neighborLocation = self.neighbor[ location * 4 + i ] + boardValue = board[ neighborLocation ].colour + + # check if neighbor is friendly stone + if boardValue == self.player_current: + + # check if this is the first friendly neighbor we found + if newGroup is NULL: + + # first friendly neighbor + newGroup = board[ neighborLocation ] + else: + + # another friendly group, if they are different combine them + tempGroup = board[ neighborLocation ] + if tempGroup != newGroup: + + self.combine_groups( newGroup, tempGroup, board ) + # remove temp_group from groupList and destroy + groups_list_remove( tempGroup, self.groups_list ) + group_destroy( tempGroup ) + + elif boardValue == self.player_opponent: + + # remove liberty from enemy group + tempGroup = board[ neighborLocation ] + group_remove_liberty( tempGroup, location ) + + # remove group + if tempGroup.count_liberty == 0: + + count_captures[ 0 ] += tempGroup.count_stones + self.remove_group( tempGroup, board, ko ) + self.update_hashes( tempGroup ) + # remove tempGroup from groupList and destroy + groups_list_remove( tempGroup, self.groups_list ) + group_destroy( tempGroup ) + group_removed += 1 + + # check if a group was found or create one + if newGroup is NULL: + + newGroup = group_new( self.player_current, self.board_size ) + groups_list_add( newGroup, self.groups_list ) + else: + + group_remove_liberty( newGroup, location ) + + # add stone + group_add_stone( newGroup, location ) + # set location group + board[ location ] = newGroup + + location_array = location * 8 + + # add new liberty + # loop over all four neighbors + for i in range( 4 ): + + # get neighbor location + neighborLocation = self.neighbor3x3[ location_array + i ] + + if board[ neighborLocation ].colour == _EMPTY: + + group_add_liberty( newGroup, neighborLocation ) + self.hash3x3[ neighborLocation ] = self.generate_3x3_hash( neighborLocation ) + + # loop over all four diagonals + for i in range( 4, 8 ): + + # get neighbor location + neighborLocation = self.neighbor3x3[ location_array + i ] + + if board[ neighborLocation ].colour == _EMPTY: + + self.hash3x3[ neighborLocation ] = self.generate_3x3_hash( neighborLocation ) + + # if two groups died there is no ko + # if newGroup has more than 1 stone there is no ko + if group_removed >= 2 or newGroup.count_stones > 1: + ko[ 0 ] = _PASS + + + ############################################################################ + # private cdef functions used for feature generation # + # # + ############################################################################ + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef long generate_12d_hash( self, short centre ): + """ + generate 12d hash around centre location + """ + + cdef int i + cdef long hash = _HASHVALUE + cdef Group* group + + centre *= 12 + + # hash colour and liberty of all locations + for i in range( 12 ): + + # get group + group = self.board_groups[ self.neighbor12d[ centre + i ] ] + + # hash colour + hash += group.colour + hash *= _HASHVALUE + + # hash liberty + hash += min( group.count_liberty, 3 ) + hash *= _HASHVALUE + + return hash + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef long generate_3x3_hash( self, short centre ): + """ + generate 3x3 hash around centre location + """ + + cdef int i + cdef long hash = _HASHVALUE + cdef Group* group + + centre *= 8 + + # hash colour and liberty of all locations + for i in range( 8 ): + + # get group + group = self.board_groups[ self.neighbor3x3[ centre + i ] ] + + # hash colour + hash += group.colour + hash *= _HASHVALUE + + # hash liberty + hash += min( group.count_liberty, 3 ) + hash *= _HASHVALUE + + return hash + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef void get_group_after( self, short* groups_after, char* locations, char* captures, short location ): + """ + return group as it is after playing at location + """ + + cdef short neighbor_location + cdef short temp_location + cdef char board_value + cdef Group* temp_group + cdef int i, a + cdef int location_array = location * 3 + cdef short stones, liberty, capture + + # find friendly stones + # loop over all four neighbors + for i in range( 4 ): + + # get neighbor location and value + neighbor_location = self.neighbor[ location * 4 + i ] + temp_group = self.board_groups[ neighbor_location ] + board_value = temp_group.colour + + # check if neighbor is friendly stone + if board_value == _EMPTY: + + locations[ neighbor_location ] = _LIBERTY + elif board_value == self.player_current: + + # found friendly group + for a in range( self.board_size ): + + if temp_group.locations[ a ] != _FREE: + + locations[ a ] = temp_group.locations[ a ] + + elif board_value == self.player_opponent: + + # get enemy group + # if it has one liberty it wil be killed -> add potential liberty + if temp_group.count_liberty == 1: + + for a in range( self.board_size ): + + if temp_group.locations[ a ] == _STONE: + + captures[ a ] = _CAPTURE + + # add stone + locations[ location ] = _STONE + + for neighbor_location in range( self.board_size ): + + if captures[ neighbor_location ] == _CAPTURE: + + # loop over all four neighbors + for i in range( 4 ): + + # get neighbor location and value + temp_location = self.neighbor[ neighbor_location * 4 + i ] + if temp_location < self.board_size and locations[ temp_location ] == _STONE: + + locations[ neighbor_location ] = _LIBERTY + + + # remove location as liberty + locations[ location ] = _STONE + + stones = 0 + liberty = 0 + capture = 0 + + for i in range( self.board_size ): + + if locations[ i ] == _STONE: + + stones += 1 + elif locations[ i ] == _LIBERTY: + + liberty += 1 + if captures[ i ] == _CAPTURE: + + capture += 1 + + groups_after[ location_array ] = stones + + location_array += 1 + groups_after[ location_array ] = liberty + + location_array += 1 + groups_after[ location_array ] = capture + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef bint is_true_eye( self, short location, Locations_List* eyes, char owner ): + """ + + """ + + cdef int i + cdef int eyes_lenght = eyes.count + cdef char board_value, max_bad_diagonal + cdef char count_bad_diagonal = 0 + cdef char count_border = 0 + cdef short location_neighbor + cdef Locations_List* empty_diag + + # TODO benchmark what is faster? first dict lookup then neighbor check or other way around + + if eyes_lenght > 70: + print "pretty big" + str( eyes_lenght ) + + # check if it is a known eye + for i in range( eyes.count ): + + if location == eyes.locations[ i ]: + + return 1 + + # loop over neighbor + for i in range( 4 ): + + location_neighbor = self.neighbor3x3[ location * 8 + i ] + board_value = self.board_groups[ location_neighbor ].colour + + if board_value == _BORDER: + + count_border += 1 + elif not board_value == owner: + + # empty location or enemy stone + return 0 + + empty_diag = locations_list_new( 4 ) + + # loop over diagonals + for i in range( 4, 8 ): + + location_neighbor = self.neighbor3x3[ location * 8 + i ] + board_value = self.board_groups[ location_neighbor ].colour + + if board_value == _EMPTY: + + #locations_list_add_location( empty_diag, location_neighbor ) + empty_diag.locations[ empty_diag.count ] = location_neighbor + empty_diag.count += 1 + count_bad_diagonal += 1 + elif board_value == _BORDER: + + count_border += 1 + elif board_value != owner: + + # enemy stone + count_bad_diagonal += 1 + + # assume location is an eye + #locations_list_add_location( eyes, location ) + eyes.locations[ eyes.count ] = location + eyes.count += 1 + + max_bad_diagonal = 1 if count_border == 0 else 0 + + if count_bad_diagonal <= max_bad_diagonal: + + # one bad diagonal is allowed in the middle + locations_list_destroy( empty_diag ) + return 1 + + for i in range( empty_diag.count ): + + location_neighbor = empty_diag.locations[ i ] + + if self.is_true_eye( location_neighbor, eyes, owner ): + + count_bad_diagonal -= 1 + + locations_list_destroy( empty_diag ) + + if count_bad_diagonal <= max_bad_diagonal: + + return 1 + + # not an eye + eyes.count = eyes_lenght + return 0 + + + ############################################################################ + # private cdef Ladder functions # + # # + ############################################################################ + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef Groups_List* add_ladder_move( self, short location, Group **board, short* ko ): + """ + + """ + + # assume legal move! + cdef Groups_List* removed_groups + + removed_groups = malloc( sizeof( Groups_List ) ) + if not removed_groups: + raise MemoryError() + + removed_groups.board_groups = malloc( 4 * sizeof( Group* ) ) + if not removed_groups.board_groups: + raise MemoryError() + + removed_groups.count_groups = 0 + + ko[ 0 ] = _PASS + + self.get_removed_groups( location, removed_groups, board, ko ) + + # change player colour + self.player_current = self.player_opponent + self.player_opponent = ( _BLACK if self.player_current == _WHITE else _WHITE ) + + return removed_groups + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef void unremove_group( self, Group* group_unremove, Group **board ): + """ + unremove group from board + """ + + cdef short location + cdef short neighbor_location + cdef Group* group_temp + cdef int i + + # loop over all group stone locations + for location in range( self.board_size ): + + if group_unremove.locations[ location ] == _STONE: + + # set location to group_unremove + board[ location ] = group_unremove + + # update liberty of neighbors + # loop over all four neighbors + for i in range( 4 ): + + # get neighbor location + neighbor_location = self.neighbor[ location * 4 + i ] + + # only current_player groups can be next to a killed group + # check if there is a group + if board[ neighbor_location ].colour == self.player_current: + + # remove liberty + group_temp = board[ neighbor_location ] + group_remove_liberty( group_temp, location ) + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef dict get_capture_moves( self, Group* group, char color, Group **board ): + """ + + """ + + cdef int i, location, location_neighbor, location_array + cdef Group* group_neighbor + cdef dict capture = {} + + # find all moves capturing an enemy group + for location in range( self.board_size ): + + if group.locations[ location ] == _STONE: + + # calculate array location + location_array = location * 4 + + # loop over neighbor + for i in range( 4 ): + + # calculate neighbor location + location_neighbor = self.neighbor[ location_array + i ] + + # if location has opponent stone + if board[ location_neighbor ].colour == color: + + # get opponent group + group_neighbor = board[ location_neighbor ] + + # if liberty count == 1 + if group_neighbor.count_liberty == 1: + + # add potential capture move + location_neighbor = group_location_liberty( group_neighbor, self.board_size ) + capture[ location_neighbor ] = location_neighbor + + return capture + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef void get_removed_groups( self, short location, Groups_List* removed_groups, Group **board, short* ko ): + """ + add location to group or create new one + """ + + cdef Group* newGroup = group_new( self.player_current, self.board_size ) + cdef Group* tempGroup + cdef short neighborLocation + cdef char boardValue + cdef char group_removed = 0 + cdef int i + + # find friendly stones + # loop over all four neighbors + for i in range( 4 ): + + # get neighbor location and value + neighborLocation = self.neighbor[ location * 4 + i ] + boardValue = board[ neighborLocation ].colour + + # check if neighbor is friendly stone + if boardValue == self.player_current: + + # another friendly group, if they are different combine them + tempGroup = board[ neighborLocation ] + if tempGroup != newGroup: + + self.combine_groups( newGroup, tempGroup, board ) + # add tempGroup to removed_groups + groups_list_add( tempGroup, removed_groups ) + + elif boardValue == self.player_opponent: + + # remove liberty from enemy group + tempGroup = board[ neighborLocation ] + group_remove_liberty( tempGroup, location ) + + # remove group + if tempGroup.count_liberty == 0: + + self.remove_group( tempGroup, board, ko ) + # add tempGroup to removed_groups + groups_list_add( tempGroup, removed_groups ) + group_removed += 1 + + # remove liberty + group_remove_liberty( newGroup, location ) + + # add stone + group_add_stone( newGroup, location ) + + # set location group + board[ location ] = newGroup + + # add new liberty + # loop over all four neighbors + for i in range( 4 ): + + # get neighbor location + neighborLocation = self.neighbor[ location * 4 + i ] + + if board[ neighborLocation ].colour == _EMPTY: + + group_add_liberty( newGroup, neighborLocation ) + + # if two groups died there is no ko + # if newGroup has more than 1 stone there is no ko + if group_removed >= 2 or newGroup.count_stones > 1: + ko[ 0 ] = _PASS + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef void undo_ladder_move( self, short location, Groups_List* removed_groups, short removed_ko, Group **board, short* ko ): + """ + + """ + + cdef short i, b, location_neighbor + cdef Group* group + cdef Group* group_remove = board[ location ] + + ko[ 0 ] = removed_ko + + # change player colour + self.player_current = self.player_opponent + self.player_opponent = ( _BLACK if self.player_current == _WHITE else _WHITE ) + + # set group to empty group + board[ location ] = self.group_empty + + # undo group removals + for i in range( removed_groups.count_groups ): + + group = removed_groups.board_groups[ removed_groups.count_groups - i - 1 ] + + if board[ group_location_stone( group, self.board_size ) ].colour == _EMPTY: + + # opponent group was removed + self.unremove_group( group, board ) + else: + + # set all board_groups locations to group + for b in range( self.board_size ): + + if group.locations[ b ] == _STONE: + + board[ b ] = group + + # add liberty to neighbor groups -> check if needed + for i in range( 4 ): + + location_neighbor = self.neighbor[ location * 4 + i ] + if board[ location_neighbor ].colour > _EMPTY: + + group_add_liberty( board[ location_neighbor ], location ) + + # destroy group + group_destroy( group_remove ) + + # free removed_groups + if removed_groups is not NULL: + + if removed_groups.board_groups is not NULL: + + free( removed_groups.board_groups ) + + free( removed_groups ) + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef bint is_ladder_escape_move( self, Group **board, short* ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase ): + """ + + """ + + cdef int i + cdef short ko_value + cdef bint result + cdef Group* group + cdef Group* group_capture + cdef Groups_List* removed_groups + cdef short location_neighbor, location_stone + + if maxDepth <= 0: + return 0 + + if not self.is_legal_move( location, board, ko[ 0 ] ): + + return 0 + + # do move + ko_value = ko[ 0 ] + removed_groups = self.add_ladder_move( location, board, ko ) + + # check group liberty + group = board[ location_group ] + i = group.count_liberty + if i < 2: + + # no escape + result = 0 + elif i > 2: + + # escape + result = 1 + else: + + # find all moves capturing an enemy group + for location_stone in range( self.board_size ): + + if group.locations[ location_stone ] == _STONE: + + # loop over neighbor + for i in range( 4 ): + + # calculate neighbor location + location_neighbor = self.neighbor[ location_stone * 4 + i ] + + # if location has opponent stone + if board[ location_neighbor ].colour == colour_chase: + + # get opponent group + group_capture = board[ location_neighbor ] + + # if liberty count == 1 + if group_capture.count_liberty == 1: + + # add potential capture move + location_neighbor = group_location_liberty( group_capture, self.board_size ) + capture[ location_neighbor ] = location_neighbor + + # try to capture group by playing at one of the two liberty locations + for location_neighbor in range( self.board_size ): + + if group.locations[ location_neighbor ] == _LIBERTY: + + if self.is_ladder_capture_move( board, ko, location_group, capture.copy(), location_neighbor, maxDepth - 1, colour_group, colour_chase ): + + self.undo_ladder_move( location, removed_groups, ko_value, board, ko ) + return 0 + + # escaped + result = 1 + + # undo move + self.undo_ladder_move( location, removed_groups, ko_value, board, ko ) + + # return result + return result + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef bint is_ladder_capture_move( self, Group **board, short* ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase ): + """ + + """ + + cdef short i + cdef short ko_value + cdef Group* group + cdef dict capture_copy + cdef short location_next + cdef Groups_List* removed_groups + + # if we haven't found a capture by a certain number of moves, assume it's worked. + if maxDepth <= 0: + + return 1 + + if not self.is_legal_move( location, board, ko[ 0 ] ): + + return 0 + + ko_value = ko[ 0 ] + removed_groups = self.add_ladder_move( location, board, ko ) + + group = board[ location ] + if group.count_liberty == 1: + + i = group_location_liberty( group, self.board_size ) + capture[ i ] = i + + # try a capture move + for location_next in capture: + + capture_copy = capture.copy() + capture_copy.pop( location_next ) + if self.is_ladder_escape_move( board, ko, location_group, capture_copy, location_next, maxDepth - 1, colour_group, colour_chase ): + + self.undo_ladder_move( location, removed_groups, ko_value, board, ko ) + return 0 + + group = board[ location_group ] + + # try an escape move + for location_next in range( self.board_size ): + + if group.locations[ location_next ] == _LIBERTY: + + if self.is_ladder_escape_move( board, ko, location_group, capture.copy(), location_next, maxDepth - 1, colour_group, colour_chase ): + + self.undo_ladder_move( location, removed_groups, ko_value, board, ko ) + return 0 + + # no ladder escape found -> group is captured + self.undo_ladder_move( location, removed_groups, ko_value, board, ko ) + return 1 + + + ############################################################################ + # public cdef functions used by preprocessing # + # # + ############################################################################ + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef short* get_groups_after( self ): + """ + + """ + + cdef short i, location + cdef short *groups_after = malloc( self.board_size * 3 * sizeof( short ) ) + if not groups_after: + raise MemoryError() + + memset( groups_after, 0, self.board_size * 3 * sizeof( short ) ) + + cdef char *locations = malloc( self.board_size * sizeof( char ) ) + if not locations: + raise MemoryError() + + cdef char *captures = malloc( self.board_size * sizeof( char ) ) + if not captures: + raise MemoryError() + + # create groups for all legal moves + for location in range( self.moves_legal.count ): + + memset( locations, _FREE, self.board_size * sizeof( char ) ) + memset( captures, _FREE, self.board_size * sizeof( char ) ) + + self.get_group_after( groups_after, locations, captures, self.moves_legal.locations[ location ] ) + + free( locations ) + free( captures ) + + return groups_after + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef Locations_List* get_sensible_moves( self ): + """ + + """ + + # TODO validate usage of struct is actually faster + + # create list with at least #moves_legal.count locations + # there can never be more sensible moves + cdef Locations_List* sensible_moves = locations_list_new( self.moves_legal.count ) + + # checking all games in the KGS database found a max of 17eyes in one state + # 25 seems a safe bet + cdef Locations_List* eyes = locations_list_new( 80 ) + cdef int i + cdef short location + + for i in range( self.moves_legal.count ): + + location = self.moves_legal.locations[ i ] + + if not self.is_true_eye( location, eyes, self.player_current ): + + # TODO find out why locations_list_add_location is 2x slower + #locations_list_add_location( sensible_moves, location ) + + sensible_moves.locations[ sensible_moves.count ] = location + sensible_moves.count += 1 + + locations_list_destroy( eyes ) + + return sensible_moves + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef list get_neighbor_locations( self ): + """ + generate list with 3x3 neighbor locations + 0,1,2,3 are direct neighbor + 4,5,6,7 are diagonal neighbor + where -1 if it is a border location or non empty location + """ + + return [] + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef long get_hash_12d( self, short centre ): + """ + return hash for 12d star pattern around location + """ + + return self.generate_12d_hash( centre ) + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef long get_hash_3x3( self, short location ): + """ + return 3x3 pattern hash + current player + """ + + # 3x3 hash patterns are updated every move + # get 3x3 hash value and add current player + + return self.hash3x3[ location ] + self.player_current + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef char* get_ladder_escapes( self, int maxDepth ): + """ + + """ + + cdef short i, location_group, location_move + cdef Group* group + cdef dict move_capture + cdef dict move_capture_copy + cdef Group **board = NULL + cdef short ko = self.ko + cdef char* escapes = malloc( self.board_size ) + if not escapes: + raise MemoryError() + + memset( escapes, _FREE, self.board_size ) + + # loop over all groups on board + for i in range( self.groups_list.count_groups ): + + group = self.groups_list.board_groups[ i ] + + # get liberty count + if group.count_liberty == 1: + + # check if group has one liberty and is owned by current + if group.colour == self.player_current: + + if board is NULL: + + board = malloc( ( self.board_size + 1 ) * sizeof( Group* ) ) + if not self.board_groups: + raise MemoryError() + + # empty group and border group stay the same, the others will be reset + memcpy( board, self.board_groups, ( self.board_size + 1 ) * sizeof( Group* ) ) + + move_capture = self.get_capture_moves( group, self.player_opponent, board ) + location_group = group_location_stone( group, self.board_size ) + + for location_move in range( self.board_size ): + + if group.locations[ location_move ] == _LIBERTY and escapes[ location_move ] == _FREE: + + if self.is_ladder_escape_move( board, &ko, location_group, move_capture.copy(), location_move, maxDepth, self.player_current, self.player_opponent ): + + escapes[ location_move ] = _STONE + + for location_move in move_capture: + + if escapes[ location_move ] == _FREE: + + move_capture_copy = move_capture.copy() + move_capture_copy.pop( location_move ) + + if self.is_ladder_escape_move( board, &ko, location_group, move_capture_copy, location_move, maxDepth, self.player_current, self.player_opponent ): + + escapes[ location_move ] = _STONE + + + if board is not NULL: + + free( board ) + + return escapes + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef char* get_ladder_captures( self, int maxDepth ): + """ + + """ + + cdef short i, location_group, location_move + cdef Group* group + cdef dict move_capture + cdef Group **board = NULL + cdef short ko = self.ko + cdef char* captures = malloc( self.board_size ) + if not captures: + raise MemoryError() + + memset( captures, _FREE, self.board_size ) + + # loop over all groups on board + for i in range( self.groups_list.count_groups ): + + group = self.groups_list.board_groups[ i ] + + # get liberty count + if group.count_liberty == 2: + + # check if group is owned by opponent + if group.colour == self.player_opponent: + + if board is NULL: + + board = malloc( ( self.board_size + 1 ) * sizeof( Group* ) ) + if not self.board_groups: + raise MemoryError() + + # empty group and border group stay the same, the others will be reset + memcpy( board, self.board_groups, ( self.board_size + 1 ) * sizeof( Group* ) ) + + + move_capture = self.get_capture_moves( group, self.player_current, board ) + location_group = group_location_stone( group, self.board_size ) + + # loop over liberty + for location_move in range( self.board_size ): + + if group.locations[ location_move ] == _LIBERTY and captures[ location_move ] == _FREE: + + if self.is_ladder_capture_move( board, &ko, location_group, move_capture.copy(), location_move, maxDepth, self.player_opponent, self.player_current ): + + captures[ location_move ] = _STONE + + if board is not NULL: + + free( board ) + + return captures + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef char get_board_feature( self, short location ): + """ + return correct board feature value + - 0 active player stone + - 1 opponent stone + - 2 empty location + """ + + cdef char value = self.board_groups[ location ].colour + + if value == _EMPTY: + return 2 + + if value == self.player_current: + return 0 + + return 1 + + + ############################################################################ + # public cdef functions used for game play # + # # + ############################################################################ + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef void add_move( self, short location ): + """ + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + Move should be legal! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + + play move on location, move should be legal! + + update player_current, history and moves_legal + """ + + # reset ko + self.ko = _PASS + + # where captures should be added, black captures white stones + # white captures black stones + cdef short* captures = ( &self.capture_white if ( self.player_current == _BLACK ) else &self.capture_black ) + + # add move + self.add_to_group( location, self.board_groups, &self.ko, captures ) + + # change player colour + self.player_current = self.player_opponent + self.player_opponent = ( _BLACK if self.player_current == _WHITE else _WHITE ) + + # add to history + self.history.append( location ) + + # set moves legal + self.set_moves_legal_list( self.moves_legal ) + + # TODO + # update zobrist + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef GameState new_state_add_move( self, short location ): + """ + copy this gamestate and play move at location + """ + + # create new gamestate, copy all data of self + state = GameState( copyState = self ) + + # place move + state.add_move( location ) + + return state + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef char get_winner_colour( self, int komi ): + """ + Calculate score of board state and return player ID (1, -1, or 0 for tie) + corresponding to winner. Uses 'Area scoring'. + + http://senseis.xmp.net/?Passing#1 + """ + + cdef short location + cdef char board_value + cdef int score_white = komi + cdef int score_black = 0 + cdef Locations_List* eyes_white = locations_list_new( self.board_size ) + cdef Locations_List* eyes_black = locations_list_new( self.board_size ) + + for location in range( self.size * self.size ): + + board_value = self.board_groups[ location ].colour + if board_value == _WHITE: + + # white stone + score_white += 1 + elif board_value == _BLACK: + + # black stone + score_black += 1 + else: + + # empty location, check if it is an eye for black/white + if self.is_true_eye( location, eyes_black, _BLACK ): + + score_black += 1 + elif self.is_true_eye( location, eyes_white, _WHITE ): + + score_white += 1 + + # free eyes_black and eyes_white + locations_list_destroy( eyes_black ) + locations_list_destroy( eyes_white ) + + # substract passes + # http://senseis.xmp.net/?Passing#1 + score_black -= self.passes_black + score_white -= self.passes_white + + # check if black has won, tie -> white wins + if score_black > score_white: + + # black wins + return _BLACK + + # white wins + return _WHITE + + + ############################################################################ + # public def functions used for game play (Python) # + # # + ############################################################################ + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def do_move( self, action, color=None ): + """ + Play stone at action=(x,y). + If it is a legal move, current_player switches to the opposite color + If not, an IllegalMove exception is raised + """ + + if action is _PASS: + + self.history.append( _PASS ) + + if self.player_opponent == _BLACK: + + self.passes_black += 1 + else: + + self.passes_white += 1 + + # change player colour + self.player_current = self.player_opponent + self.player_opponent = ( _BLACK if self.player_current == _WHITE else _WHITE ) + + # legal moves have to be recalculated + self.set_moves_legal_list( self.moves_legal ) + return + + if color is not None: + + self.player_current = color + self.player_opponent = ( _BLACK if self.player_current == _WHITE else _WHITE ) + + # legal moves have to be recalculated + self.set_moves_legal_list( self.moves_legal ) + + cdef int x, y, i + cdef short location + ( x, y ) = action + location = self.calculate_board_location( y, x ) + + # check if move is legal + if not self.is_legal_move( location, self.board_groups, self.ko ): + + print( self.player_current ) + print( location ) + print( self.get_legal_moves(include_eyes=True) ) + print( "" ) + print( self.get_legal_moves(include_eyes=False) ) + self.printer() + raise IllegalMove( str( action ) ) + + # add move + self.add_move( location ) + + return True + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def get_legal_moves( self, include_eyes = True ): + """ + return a list with all legal moves ( in/excluding eyes ) + """ + + cdef int i + cdef list moves = [] + cdef Locations_List* moves_list + + if include_eyes: + + moves_list = self.moves_legal + else: + + moves_list = self.get_sensible_moves() + + + for i in range( moves_list.count ): + + moves.append( self.calculate_tuple_location( moves_list.locations[ i ] ) ) + + if not include_eyes: + + # free sensible_moves + locations_list_destroy( moves_list ) + + return moves + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def get_winner( self, char komi = 6 ): + """ + Calculate score of board state and return player ID ( 1, -1, or 0 for tie ) + corresponding to winner. Uses 'Area scoring'. + """ + + return self.get_winner_colour( komi ) + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def place_handicap_stone( self, action, color=_BLACK ): + """ + add handicap stones given by a list of tuples in list handicap + """ + + cdef short fake_capture + + self.player_current = color + self.player_opponent = ( _BLACK if self.player_current == _WHITE else _WHITE ) + + cdef char x, y + cdef short location + ( x, y ) = action + location = self.calculate_board_location( y, x ) + + # add move + self.add_to_group( location, self.board_groups, &self.ko, &fake_capture ) + + # set legal moves + self.set_moves_legal_list( self.moves_legal ) + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def place_handicaps( self, list handicap ): + """ + TODO save handicap stones list?? -> seems not usefull as we also have to copy them + add handicap stones given by a list of tuples in list handicap + """ + + cdef char x, y + cdef short location + cdef short fake_capture + + if len( self.history ) > 0: + raise IllegalMove("Cannot place handicap on a started game") + + for action in handicap: + + ( x, y ) = action + location = self.calculate_board_location( y, x ) + self.add_to_group( location, self.board_groups, &self.ko, &fake_capture ) + + # active player colour reverses + self.player_current = _WHITE + self.player_opponent = _BLACK + + # set legal moves + self.set_moves_legal_list( self.moves_legal ) + + + def is_end_of_game( self ): + """ + + """ + if len( self.history ) > 1: + + if self.history[-1] is _PASS and self.history[-2] is _PASS and self.player_current == _WHITE: + + return True + + return False + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def is_legal( self, action ): + """ + determine if the given action (x,y tuple) is a legal move + note: we only check ko, not superko at this point (TODO!) + """ + + cdef int i + cdef char x, y + cdef short location + ( x, y ) = action + + # check outside board + if x < 0 or y < 0 or x >= self.size or y >= self.size: + return False + + # calculate location + location = self.calculate_board_location( y, x ) + + if self.is_legal_move( location, self.board_groups, self.ko ): + + return True + + return False + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def copy( self ): + """ + get a copy of this Game state + """ + + return GameState( copyState = self ) + + + ############################################################################ + # public def functions used for unittests # + # # + ############################################################################ + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def get_current_player(self): + """ + Returns the color of the player who will make the next move. + """ + + return self.player_current + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def set_current_player( self, colour ): + """ + change current player colour + """ + + self.player_current = colour + self.player_opponent = ( _BLACK if self.player_current == _WHITE else _WHITE ) + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def get_history( self ): + """ + return history as a list of tuples + """ + + history = [] + + for location in self.history: + + if location != _PASS: + + history.append( self.calculate_tuple_location( location ) ) + else: + + history.append( _PASS ) + + + return history + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def get_captures_black( self ): + """ + return amount of black stones captures + """ + + return self.capture_black + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def get_captures_white( self ): + """ + return amount of white stones captured + """ + + return self.capture_white + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def get_ko_location( self ): + """ + return ko location + """ + + if self.ko == _PASS: + return None + + return self.ko + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def is_board_equal( self, GameState state ): + """ + verify that self and state board layout are the same + """ + + for x in range(self.board_size): + + if self.board_groups[ x ].colour != state.board_groups[ x ].colour: + + return False + + return True + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def is_liberty_equal( self, GameState state ): + """ + verify that self and state liberty counts are the same + """ + + for x in range(self.board_size): + + if self.board_groups[ x ].count_liberty != state.board_groups[ x ].count_liberty: + + return False + + return True + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def is_ladder_escape( self, action ): + """ + check if playing action is a ladder escape + """ + value = False + + cdef char x, y + cdef short location + + ( x, y ) = action + location = self.calculate_board_location( y, x ) + + cdef char* escapes = self.get_ladder_escapes( 80 ) + + if escapes[ location ] != _FREE: + + value = True + + # free escapes + free( escapes ) + + return value + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def is_ladder_capture( self, action ): + """ + check if playing action is a ladder capture + """ + value = False + + cdef char x, y + cdef short location + + ( x, y ) = action + location = self.calculate_board_location( y, x ) + + cdef char* captures = self.get_ladder_captures( 80 ) + + if captures[ location ] != _FREE: + + value = True + + # free captures + free( captures ) + + return value + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def is_eye( self, action, color ): + """ + check if location action is a eye for player color + """ + value = False + + cdef char x, y + cdef short location + + ( x, y ) = action + location = self.calculate_board_location( y, x ) + + # checking all games in the KGS database found a max of 15eyes in one state + # 25 seems a safe bet + cdef Locations_List* eyes = locations_list_new( 80 ) + + if self.is_true_eye( location, eyes, color ): + + value = True + + locations_list_destroy( eyes ) + + return value + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def get_liberty( self ): + """ + get numpy array with all liberty counts + """ + + liberty = np.zeros((self.size, self.size), dtype=np.int) + + for x in range(self.size): + + for y in range(self.size): + + location = self.calculate_board_location( y, x ) + + liberty[x, y] = self.board_groups[location].count_liberty + + return liberty + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def get_board( self ): + """ + get numpy array with board locations + """ + + board = np.zeros((self.size, self.size), dtype=np.int) + + for x in range(self.size): + + for y in range(self.size): + + location = self.calculate_board_location( y, x ) + + board[x, y] = self.board_groups[location].colour + + return board + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def get_size( self ): + """ + return size + """ + + return self.size + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def get_handicap( self ): + """ + TODO + return list with handicap stones placed + """ + + return [] + + + ############################################################################ + # tests # + # # + ############################################################################ + + def validate_equal( self, GameState state ): + + cdef int i + value = True + + for i in range( self.board_size ): + + if self.ko != state.ko: + + print( "ko " + str( self.ko )) + return False + + if self.board_groups[ i ].colour != state.board_groups[ i ].colour: + + print( "board " + str( i )) + value = False + + if self.board_groups[ i ].count_stones != state.board_groups[ i ].count_stones: + + print( "stones " + str( i )) + print( str( self.board_groups[ i ].count_stones ) + " " + str( state.board_groups[ i ].count_stones )) + value = False + + if self.board_groups[ i ].count_liberty != state.board_groups[ i ].count_liberty: + + print( "liberty " + str( i ) + " " + str( state.board_groups[ i ].colour ) + " " + str( state.player_current ) ) + print( str( self.board_groups[ i ].count_liberty ) + " " + str( state.board_groups[ i ].count_liberty ) ) + value = False + + return value + + def printer( self ): + print( "" ) + for i in range( self.size ): + A = str( i ) + " " + for j in range( self.size ): + + B = 0 + if self.board_groups[ j + i * self.size ].colour == _BLACK: + B = 'B' + elif self.board_groups[ j + i * self.size ].colour == _WHITE: + B = 'W' + A += str( B ) + " " + print( A ) + + + # do move, throw exception when outside the board + # action has to be a ( x, y ) tuple + # this function should be used from Python environment, + # use add_move from C environment for speed + def do_ladder_move( self, action ): + """ + + """ + + # do move, return true if legal, return false if not + + cdef int x, y + cdef short location + ( x, y ) = action + location = self.calculate_board_location( y, x ) + self.add_ladder_move( location, self.board_groups, &self.ko ) + + self.set_moves_legal_list( self.moves_legal ) + + return True + + + # do move, throw exception when outside the board + # action has to be a ( x, y ) tuple + # this function should be used from Python environment, + # use add_move from C environment for speed + def do_and_undo_ladder_move( self, action ): + """ + + """ + + # do move, return true if legal, return false if not + + cdef int x, y + cdef short location, ko + ( x, y ) = action + location = self.calculate_board_location( y, x ) + + cdef Groups_List* removed_groups + + ko = self.ko + removed_groups = self.add_ladder_move( location, self.board_groups, &self.ko ) + + self.undo_ladder_move( location, removed_groups, ko, self.board_groups, &self.ko ) + + + return True + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef test( self ): + + print( "empty" ) + + + cdef test_cpp_fast( self ): + + print( "empty" ) + + + def test_cpp( self ): + + print( "cpp" ) + self.test_cpp_fast() + print( "cpp" ) + + def test_game_speed( self, list moves ): + + cdef short location + + for location in moves: + self.add_move( location ) + + def convert_moves( self, list moves ): + cdef list converted_moves = [] + cdef int x, y + cdef short location + + for loc in moves: + + ( x, y ) = loc + location = self.calculate_board_location( y, x ) + converted_moves.append( location ) + + return converted_moves + + def test_stuff( self ): + + print( "test stuff" ) + self.test() + + +class IllegalMove(Exception): + pass diff --git a/AlphaGo/go_data.pxd b/AlphaGo/go_data.pxd new file mode 100644 index 000000000..d1438ee14 --- /dev/null +++ b/AlphaGo/go_data.pxd @@ -0,0 +1,89 @@ + +############################################################################ +# constants # +# # +############################################################################ + +# TODO find out if these are really used as compile time-constant + +# value for PASS move +cdef char _PASS + +# observe: stones > EMPTY +# border < EMPTY +# be aware you should NOT use != EMPTY as this includes border locations +cdef char _BORDER +cdef char _EMPTY +cdef char _WHITE +cdef char _BLACK + +# used for group stone and liberty locations +cdef char _FREE +cdef char _STONE +cdef char _LIBERTY +cdef char _CAPTURE + +# value used to generate pattern hashes +cdef char _HASHVALUE + + +############################################################################ +# Structs # +# # +############################################################################ + + +# struct to store group information +cdef struct Group: + char *locations + short count_stones + short count_liberty + char colour + +# struct to store a list of Group +cdef struct Groups_List: + Group **board_groups + short count_groups + +# struct to store a list of short ( board locations ) +cdef struct Locations_List: + short *locations + short count + + +############################################################################ +# group functions # +# # +############################################################################ + +cdef Group* group_new( char colour, short size ) +cdef Group* group_duplicate( Group* group, short size ) +cdef void group_destroy( Group* group ) + +cdef void group_add_stone( Group* group, short location ) +cdef void group_remove_stone( Group* group, short location ) +cdef short group_location_stone( Group* group, short size ) + +cdef void group_add_liberty( Group* group, short location ) +cdef void group_remove_liberty( Group* group, short location ) +cdef short group_location_liberty( Group* group, short size ) + +############################################################################ +# Groups_List functions # +# # +############################################################################ + +cdef void groups_list_add( Group* group, Groups_List* groups_list ) +cdef void groups_list_add_unique( Group* group, Groups_List* groups_list ) +cdef void groups_list_remove( Group* group, Groups_List* groups_list ) + +############################################################################ +# Locations_List functions # +# # +############################################################################ + +cdef Locations_List* locations_list_new( short size ) +cdef void locations_list_destroy( Locations_List* locations_list ) +cdef void locations_list_remove_location( Locations_List* locations_list, short location ) +cdef void locations_list_add_location( Locations_List* locations_list, short location ) +cdef void locations_list_add_location_unique( Locations_List* locations_list, short location ) diff --git a/AlphaGo/go_data.pyx b/AlphaGo/go_data.pyx new file mode 100644 index 000000000..507da1b04 --- /dev/null +++ b/AlphaGo/go_data.pyx @@ -0,0 +1,416 @@ +cimport cython +from libc.stdlib cimport malloc, free +from libc.string cimport memcpy, memset, memchr + +############################################################################ +# constants # +# # +############################################################################ + + +# value for PASS move +_PASS = -1 + +# observe: stones > EMPTY +# border < EMPTY +# be aware you should NOT use != EMPTY as this includes border locations +_BORDER = 1 +_EMPTY = 2 +_WHITE = 3 +_BLACK = 4 + +# used for group stone and liberty locations +_FREE = 3 +_STONE = 0 +_LIBERTY = 1 +_CAPTURE = 2 + +# value used to generate pattern hashes +_HASHVALUE = 33 + + +############################################################################ +# Structs # +# # +############################################################################ + +""" -> structs, declared in go_data.pxd + +# struct to store group information +cdef struct Group: + char *locations + short count_stones + short count_liberty + char colour + +# struct to store a list of Group +cdef struct Groups_List: + Group **board_groups + short count_groups + +# struct to store a list of short ( board locations ) +cdef struct Locations_List: + short *locations + short count +""" + +############################################################################ +# group functions # +# # +############################################################################ + + +@cython.boundscheck( False ) +@cython.wraparound( False ) +cdef Group* group_new( char colour, short size ): + """ + create new struct Group + with locations #size char long initialized to FREE + """ + + cdef int i + + # allocate memory for Group + cdef Group *group = malloc( sizeof( Group ) ) + if not group: + raise MemoryError() + + # allocate memory for array locations + group.locations = malloc( size ) + if not group.locations: + raise MemoryError() + + # set counts to 0 and colour to colour + group.count_stones = 0 + group.count_liberty = 0 + group.colour = colour + + # initialize locations with FREE + memset( group.locations, _FREE, size ) + + return group + + +@cython.boundscheck( False ) +@cython.wraparound( False ) +cdef Group* group_duplicate( Group* group, short size ): + """ + create new struct Group initialized as a duplicate of group + """ + + cdef int i + + # allocate memory for Group + cdef Group *duplicate = malloc( sizeof( Group ) ) + if not duplicate: + raise MemoryError() + + # allocate memory for array locations + duplicate.locations = malloc( size ) + if not duplicate.locations: + raise MemoryError() + + # set counts and colour values + duplicate.count_stones = group.count_stones + duplicate.count_liberty = group.count_liberty + duplicate.colour = group.colour + + # duplicate locations array in memory + # memcpy is optimized to do this quickly + memcpy( duplicate.locations, group.locations, size ) + + return duplicate + + +@cython.boundscheck( False ) +@cython.wraparound( False ) +cdef void group_destroy( Group* group ): + """ + free memory location of group and locations + """ + + # check if group exists + if group is not NULL: + + # check if locations exists + if group.locations is not NULL: + + # free locations + free( group.locations ) + + # free group + free( group ) + + +@cython.boundscheck( False ) +@cython.wraparound( False ) +cdef void group_add_stone( Group* group, short location ): + """ + update location as STONE + update liberty count if it was a liberty location + + n.b. stone count is not incremented if a stone was present already + """ + + # check if locations is a liberty + if group.locations[ location ] == _FREE: + + # locations is FREE, increment stone count + group.count_stones += 1 + elif group.locations[ location ] == _LIBERTY: + + # locations is LIBERTY, increment stone count and decrement liberty count + group.count_stones += 1 + group.count_liberty -= 1 + + # set STONE + group.locations[ location ] = _STONE + + +@cython.boundscheck( False ) +@cython.wraparound( False ) +cdef void group_remove_stone( Group* group, short location ): + """ + update location as FREE + update stone count if it was a stone location + """ + + # check if a stone is present + if group.locations[ location ] == _STONE: + + # stone present, decrement stone count and set location to FREE + group.count_stones -= 1 + group.locations[ location ] = _FREE + + +@cython.boundscheck( False ) +@cython.wraparound( False ) +cdef short group_location_stone( Group* group, short size ): + """ + return location where a STONE is located + """ + + return ( memchr( group.locations, _STONE, size ) - group.locations ) + + +@cython.boundscheck( False ) +@cython.wraparound( False ) +cdef void group_add_liberty( Group* group, short location ): + """ + update location as LIBERTY + update liberty count if it was a FREE location + + n.b. liberty count is not incremented if a stone was present already + """ + + # check if location is FREE + if group.locations[ location ] == _FREE: + + # increment liberty count, set location to LIBERTY + group.count_liberty += 1 + group.locations[ location ] = _LIBERTY + + +@cython.boundscheck( False ) +@cython.wraparound( False ) +cdef void group_remove_liberty( Group* group, short location ): + """ + update location as FREE + update liberty count if it was a LIBERTY location + + n.b. liberty count is not decremented if location is a FREE location + """ + + # check if location is LIBERTY + if group.locations[ location ] == _LIBERTY: + + # decrement liberty count, set location to FREE + group.count_liberty -= 1 + group.locations[ location ] = _FREE + + +@cython.boundscheck( False ) +@cython.wraparound( False ) +cdef short group_location_liberty( Group* group, short size ): + """ + return location where a LIBERTY is located + """ + + return ( memchr(group.locations, _LIBERTY, size ) - group.locations ) + + +############################################################################ +# Groups_List functions # +# # +############################################################################ + + +@cython.boundscheck( False ) +@cython.wraparound( False ) +cdef void groups_list_add( Group* group, Groups_List* groups_list ): + """ + add group to list and increment groups count + """ + + groups_list.board_groups[ groups_list.count_groups ] = group + groups_list.count_groups += 1 + + +@cython.boundscheck( False ) +@cython.wraparound( False ) +cdef void groups_list_add_unique( Group* group, Groups_List* groups_list ): + """ + check if a group is already in the list, return if so + add group to list if not + """ + + cdef int i + + # loop over array + for i in range( groups_list.count_groups ): + + # check if group is present + if group == groups_list.board_groups[ i ]: + + # group is present, return + return + + # group is not present, add to group + groups_list.board_groups[ groups_list.count_groups ] = group + groups_list.count_groups += 1 + + +@cython.boundscheck( False ) +@cython.wraparound( False ) +cdef void groups_list_remove( Group* group, Groups_List* groups_list ): + """ + remove group from list and decrement groups count + """ + + cdef int i + + # loop over array + for i in range( groups_list.count_groups ): + + # check if group is present + if groups_list.board_groups[ i ] == group: + + # group is present, move last group to this location + # and decrement groups count + groups_list.count_groups -= 1 + groups_list.board_groups[ i ] = groups_list.board_groups[ groups_list.count_groups ] + return + + # TODO this should not happen, create error for this?? + print( "Group not found!!!!!!!!!!!!!!" ) + + +############################################################################ +# Locations_List functions # +# # +############################################################################ + + +@cython.boundscheck( False ) +@cython.wraparound( False ) +cdef Locations_List* locations_list_new( short size ): + """ + create new struct Locations_List + with locations #size short long and count set to 0 + """ + + cdef Locations_List* list_new + + # allocate memory for Group + list_new = malloc( sizeof( Locations_List ) ) + if not list_new: + raise MemoryError() + + # allocate memory for locations + list_new.locations = malloc( size * sizeof( short ) ) + if not list_new.locations: + raise MemoryError() + + # set count to 0 + list_new.count = 0 + + return list_new + +@cython.boundscheck( False ) +@cython.wraparound( False ) +cdef void locations_list_destroy( Locations_List* locations_list ): + """ + free memory location of locations_list and locations + """ + + # check if locations_list exists + if locations_list is not NULL: + + # check if locations exists + if locations_list.locations is not NULL: + + # free locations + free( locations_list.locations ) + + # free locations_list + free( locations_list ) + +@cython.boundscheck( False ) +@cython.wraparound( False ) +cdef void locations_list_remove_location( Locations_List* locations_list, short location ): + """ + remove location from list + """ + + cdef int i + + # loop over array + for i in range( locations_list.count ): + + # check if [ i ] == location + if locations_list.locations[ i ] == location: + + # location found, move last value to this location + # and decrement count + locations_list.count -= 1 + locations_list.locations[ i ] = locations_list.locations[ locations_list.count ] + return + + # TODO this should not happen, create error for this?? + print( "location not found!!!!!!!!!!!!!!" ) + + +@cython.boundscheck( False ) +@cython.wraparound( False ) +cdef void locations_list_add_location( Locations_List* locations_list, short location ): + """ + add location to list and increment count + """ + + locations_list.locations[ locations_list.count ] = location + locations_list.count += 1 + + +@cython.boundscheck( False ) +@cython.wraparound( False ) +@cython.nonecheck( False ) +cdef void locations_list_add_location_unique( Locations_List* locations_list, short location ): + """ + check if location is present in list, return if so + add location to list if not + """ + + cdef int i + + # loop over array + for i in range( locations_list.count ): + + # check if location is present + if location == locations_list.locations[ i ]: + + # location found, do nothing -> return + return + + # add location to list and increment count + locations_list.locations[ locations_list.count ] = location + locations_list.count += 1 diff --git a/AlphaGo/go.py b/AlphaGo/go_python.py similarity index 79% rename from AlphaGo/go.py rename to AlphaGo/go_python.py index 9019c3f2e..e24693efb 100644 --- a/AlphaGo/go.py +++ b/AlphaGo/go_python.py @@ -15,7 +15,7 @@ class GameState(object): __NEIGHBORS_CACHE = {} def __init__(self, size=19, komi=7.5, enforce_superko=False): - self.board = np.zeros((size, size)) + self.board = np.zeros((size, size), dtype=int) self.board.fill(EMPTY) self.size = size self.current_player = BLACK @@ -41,7 +41,7 @@ def __init__(self, size=19, komi=7.5, enforce_superko=False): self.liberty_sets[x][y] = set(self._neighbors((x, y))) # separately cache the 2D numpy array of the _size_ of liberty sets # at each board position - self.liberty_counts = np.zeros((size, size), dtype=np.int) + self.liberty_counts = np.zeros((size, size), dtype=int) self.liberty_counts.fill(-1) # initialize liberty_sets of empty board: the set of neighbors of each position # similarly to `liberty_sets`, `group_sets[x][y]` points to a set of tuples @@ -580,6 +580,188 @@ def do_move(self, action, color=None): self.is_end_of_game = True return self.is_end_of_game + def get_pattern_non_response_3x3(self, position): + + (x, y) = position + if x < 1 or y < 1 or x >= self.size - 1 or y >= self.size - 1: + # position is to close to the edge + return -1 + + # active player colour + pattern_hash = 2 + pattern_hash += long(self.current_player) + pattern_hash *= 10 + + # 8 surrounding position colours + pattern_hash += self.board[x - 1][y - 1] + 2 + pattern_hash *= 10 + pattern_hash += self.board[x - 1][y ] + 2 + pattern_hash *= 10 + pattern_hash += self.board[x - 1][y + 1] + 2 + pattern_hash *= 10 + pattern_hash += self.board[x ][y - 1] + 2 + pattern_hash *= 10 + pattern_hash += self.board[x ][y - 1] + 2 + pattern_hash *= 10 + pattern_hash += self.board[x + 1][y - 1] + 2 + pattern_hash *= 10 + pattern_hash += self.board[x + 1][y ] + 2 + pattern_hash *= 10 + pattern_hash += self.board[x + 1][y + 1] + 2 + pattern_hash *= 10 + + # 8 surrounding position liberties + if self.board[x - 1][y - 1] != EMPTY: + pattern_hash += min( self.liberty_counts[x - 1][y - 1], 3 ) + pattern_hash *= 10 + if self.board[x - 1][y ] != EMPTY: + pattern_hash += min( self.liberty_counts[x - 1][y ], 3 ) + pattern_hash *= 10 + if self.board[x - 1][y + 1] != EMPTY: + pattern_hash += min( self.liberty_counts[x - 1][y + 1], 3 ) + pattern_hash *= 10 + if self.board[x ][y - 1] != EMPTY: + pattern_hash += min( self.liberty_counts[x ][y - 1], 3 ) + pattern_hash *= 10 + if self.board[x ][y + 1] != EMPTY: + pattern_hash += min( self.liberty_counts[x ][y + 1], 3 ) + pattern_hash *= 10 + if self.board[x + 1][y - 1] != EMPTY: + pattern_hash += min( self.liberty_counts[x + 1][y - 1], 3 ) + pattern_hash *= 10 + if self.board[x + 1][y ] != EMPTY: + pattern_hash += min( self.liberty_counts[x + 1][y ], 3 ) + pattern_hash *= 10 + if self.board[x + 1][y + 1] != EMPTY: + pattern_hash += min( self.liberty_counts[x + 1][y + 1], 3 ) + + return pattern_hash + + def get_pattern_nakade(self, position): + return -1 + + def get_pattern_response_12d(self, position): + + if len( self.history ) < 1: + return -1 + + (xNew, yNew) = position + (x, y) = self.history[-1] + xDis = x - xNew + yDis = y - yNew + + if x < 2 or y < 2 or x >= self.size - 2 or y >= self.size - 2: + # position is to close to the edge + return -1 + + # move is not part of 12d pattern + # -> manhattan distance > 2 + if abs(xDis) + abs(yDis) > 2: + return -1 + + # hash pattern + + # location + pattern_hash = xDis + 3L + pattern_hash *= 10 + pattern_hash += yDis + 3 + + # stones + pattern_hash *= 10 + pattern_hash += self.board[x - 2][y ] + 2 + + pattern_hash *= 10 + pattern_hash += self.board[x - 1][y - 1] + 2 + pattern_hash *= 10 + pattern_hash += self.board[x - 1][y ] + 2 + pattern_hash *= 10 + pattern_hash += self.board[x - 1][y + 1] + 2 + + pattern_hash *= 10 + pattern_hash += self.board[x ][y - 2] + 2 + pattern_hash *= 10 + pattern_hash += self.board[x ][y - 1] + 2 + pattern_hash *= 10 + pattern_hash += self.board[x ][y ] + 2 + pattern_hash *= 10 + pattern_hash += self.board[x ][y + 1] + 2 + pattern_hash *= 10 + pattern_hash += self.board[x ][y + 2] + 2 + + pattern_hash *= 10 + pattern_hash += self.board[x + 1][y - 1] + 2 + pattern_hash *= 10 + pattern_hash += self.board[x + 1][y ] + 2 + pattern_hash *= 10 + pattern_hash += self.board[x + 1][y + 1] + 2 + + pattern_hash *= 10 + pattern_hash += self.board[x + 2][y ] + 2 + + # liberties + pattern_hash *= 10 + if self.board[x -2][y ] != EMPTY: + pattern_hash += min( self.liberty_counts[x - 2][y ], 3 ) + + pattern_hash *= 10 + if self.board[x - 1][y - 1] != EMPTY: + pattern_hash += min( self.liberty_counts[x - 1][y - 1], 3 ) + pattern_hash *= 10 + if self.board[x - 1][y ] != EMPTY: + pattern_hash += min( self.liberty_counts[x - 1][y ], 3 ) + pattern_hash *= 10 + if self.board[x - 1][y + 1] != EMPTY: + pattern_hash += min( self.liberty_counts[x - 1][y + 1], 3 ) + + pattern_hash *= 10 + if self.board[x ][y - 2] != EMPTY: + pattern_hash += min( self.liberty_counts[x ][y - 2], 3 ) + pattern_hash *= 10 + if self.board[x ][y - 1] != EMPTY: + pattern_hash += min( self.liberty_counts[x ][y - 1], 3 ) + pattern_hash *= 10 + if self.board[x ][y ] != EMPTY: + pattern_hash += min( self.liberty_counts[x ][y ], 3 ) + pattern_hash *= 10 + if self.board[x ][y + 1] != EMPTY: + pattern_hash += min( self.liberty_counts[x ][y + 1], 3 ) + pattern_hash *= 10 + if self.board[x ][y + 2] != EMPTY: + pattern_hash += min( self.liberty_counts[x ][y + 2], 3 ) + + pattern_hash *= 10 + if self.board[x + 1][y - 1] != EMPTY: + pattern_hash += min( self.liberty_counts[x + 1][y - 1], 3 ) + pattern_hash *= 10 + if self.board[x + 1][y ] != EMPTY: + pattern_hash += min( self.liberty_counts[x + 1][y ], 3 ) + pattern_hash *= 10 + if self.board[x + 1][y + 1] != EMPTY: + pattern_hash += min( self.liberty_counts[x + 1][y + 1], 3 ) + + pattern_hash *= 10 + if self.board[x + 2][y ] != EMPTY: + pattern_hash += min( self.liberty_counts[x + 2][y ], 3 ) + + return pattern_hash + + def get_8_connected(self, position): + + (x, y) = position + (xLast, yLast) = self.history[-1] + xDis = x - xLast + yDis = y - yLast + + # check if last move is near this one + if xDis < -1 or xDis > 1 or yDis < -1 or yDis > 1: + return -1 + + value = xDis + 1 + (yDis + 1) * 3 + + # return 0-1 to indicate: + # 0 -> diagonal neighbor + # 1 -> horzontal neighbor + return value % 2 class IllegalMove(Exception): pass diff --git a/AlphaGo/go_root.pxd b/AlphaGo/go_root.pxd new file mode 100644 index 000000000..36c4a7439 --- /dev/null +++ b/AlphaGo/go_root.pxd @@ -0,0 +1,49 @@ +from AlphaGo.go cimport GameState +from AlphaGo.go_data cimport _BORDER, _EMPTY, _BLACK, _WHITE, _PASS, Group, Groups_List, Locations_List, group_new + +cdef class RootState: + + ############################################################################ + # variables declarations # + # # + ############################################################################ + + cdef short size + cdef short board_size + + # empty group + cdef Group *group_empty + + # border group + cdef Group *group_border + + # arrays, neighbor arrays pointers + cdef short *neighbor + cdef short *neighbor3x3 + cdef short *neighbor12d + + ############################################################################ + # cdef init functions # + # # + ############################################################################ + + cdef short calculate_board_location( self, char x, char y ) + cdef short calculate_board_location_or_border( self, char x, char y ) + + cdef void set_neighbors( self, int size ) + cdef void set_3x3_neighbors( self, int size ) + cdef void set_12d_neighbors( self, int size ) + + ############################################################################ + # public functions ( c only ) # + # # + ############################################################################ + + cdef GameState get_root_state( self ) + + ############################################################################ + # public functions # + # # + ############################################################################ + + # def get_root_game_state( self ) \ No newline at end of file diff --git a/AlphaGo/go_root.pyx b/AlphaGo/go_root.pyx new file mode 100644 index 000000000..6c9bca08c --- /dev/null +++ b/AlphaGo/go_root.pyx @@ -0,0 +1,360 @@ +cimport cython +from libc.stdlib cimport malloc, free + +cdef class RootState: + + ############################################################################ + # variables declarations # + # # + ############################################################################ + + """ -> variables, declared in go_root.pxd + + cdef short size + + # arrays, neighbor arrays pointers + cdef short *neighbor + cdef short *neighbor3x3 + cdef short *neighbor12d + + """ + + ############################################################################ + # cdef init functions # + # # + ############################################################################ + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef short calculate_board_location( self, char x, char y ): + """ + return location on board + no checks on outside board + x = columns + y = rows + """ + + # return board location + return x + ( y * self.size ) + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef short calculate_board_location_or_border( self, char x, char y ): + """ + return location on board or borderlocation + board locations = [ 0, size * size ) + border location = size * size + x = columns + y = rows + """ + + # check if x or y are outside board + if x < 0 or y < 0 or x >= self.size or y >= self.size: + # return border location + return self.board_size + + # return board location + return self.calculate_board_location( x, y ) + + + cdef void set_neighbors( self, int size ): + """ + create array for every board location with all 4 direct neighbour locations + neighbor order: left - right - above - below + + -1 x + x x + +1 x + + order: + -1 2 + 0 1 + +1 3 + + TODO neighbors is obsolete as neighbor3x3 contains the same values + """ + + # create array + self.neighbor = malloc( size * size * 4 * sizeof( short ) ) + if not self.neighbor: + raise MemoryError() + + cdef short location + cdef char x, y + + # add all direct neighbors to every board location + for y in range( size ): + for x in range( size ): + location = ( x + ( y * size ) ) * 4 + self.neighbor[ location + 0 ] = self.calculate_board_location_or_border( x - 1, y ) + self.neighbor[ location + 1 ] = self.calculate_board_location_or_border( x + 1, y ) + self.neighbor[ location + 2 ] = self.calculate_board_location_or_border( x , y - 1 ) + self.neighbor[ location + 3 ] = self.calculate_board_location_or_border( x , y + 1 ) + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef void set_3x3_neighbors( self, int size ): + """ + create for every board location array with all 8 surrounding neighbour locations + neighbor order: above middle - middle left - middle right - below middle + above left - above right - below left - below right + this order is more useful as it separates neighbors and then diagonals + -1 xxx + x x + +1 xxx + + order: + -1 405 + 1 2 + +1 637 + + 0-3 contains neighbors + 4-7 contains diagonals + """ + + # create array + self.neighbor3x3 = malloc( size * size * 8 * sizeof( short ) ) + if not self.neighbor3x3: + raise MemoryError() + + cdef short location + cdef char x, y + + # add all surrounding neighbors to every board location + for x in range( size ): + for y in range( size ): + location = ( x + ( y * size ) ) * 8 + self.neighbor3x3[ location + 0 ] = self.calculate_board_location_or_border( x , y - 1 ) + self.neighbor3x3[ location + 1 ] = self.calculate_board_location_or_border( x - 1, y ) + self.neighbor3x3[ location + 2 ] = self.calculate_board_location_or_border( x + 1, y ) + self.neighbor3x3[ location + 3 ] = self.calculate_board_location_or_border( x , y + 1 ) + + self.neighbor3x3[ location + 4 ] = self.calculate_board_location_or_border( x - 1, y - 1 ) + self.neighbor3x3[ location + 5 ] = self.calculate_board_location_or_border( x + 1, y - 1 ) + self.neighbor3x3[ location + 6 ] = self.calculate_board_location_or_border( x - 1, y + 1 ) + self.neighbor3x3[ location + 7 ] = self.calculate_board_location_or_border( x + 1, y + 1 ) + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef void set_12d_neighbors( self, int size ): + """ + create array for every board location with 12d star neighbour locations + neighbor order: top star tip + above left - above middle - above right + left star tip - left - right - right star tip + below left - below middle - below right + below star tip + + -2 x + -1 xxx + xx xx + +1 xxx + +2 x + + order: + -2 0 + -1 123 + 45 67 + +1 89a + +2 b + """ + + # create array + self.neighbor12d = malloc( size * size * 12 * sizeof( short ) ) + if not self.neighbor12d: + raise MemoryError() + + cdef short location + cdef char x, y + + # add all 12d neighbors to every board location + for x in range( size ): + for y in range( size ): + location = ( x + ( y * size ) ) * 12 + self.neighbor12d[ location + 4 ] = self.calculate_board_location_or_border( x , y - 2 ) + + self.neighbor12d[ location + 1 ] = self.calculate_board_location_or_border( x - 1, y - 1 ) + self.neighbor12d[ location + 5 ] = self.calculate_board_location_or_border( x , y - 1 ) + self.neighbor12d[ location + 8 ] = self.calculate_board_location_or_border( x + 1, y - 1 ) + + self.neighbor12d[ location + 0 ] = self.calculate_board_location_or_border( x - 2, y ) + self.neighbor12d[ location + 2 ] = self.calculate_board_location_or_border( x - 1, y ) + self.neighbor12d[ location + 9 ] = self.calculate_board_location_or_border( x + 1, y ) + self.neighbor12d[ location + 11 ] = self.calculate_board_location_or_border( x + 2, y ) + + self.neighbor12d[ location + 3 ] = self.calculate_board_location_or_border( x - 1, y + 1 ) + self.neighbor12d[ location + 6 ] = self.calculate_board_location_or_border( x , y + 1 ) + self.neighbor12d[ location + 10 ] = self.calculate_board_location_or_border( x + 1, y + 1 ) + + self.neighbor12d[ location + 7 ] = self.calculate_board_location_or_border( x , y + 2 ) + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def __init__( self, char size = 19 ): + """ + RootState initializes all neighbor arrays + when destoyed all arrays are freed in order to prevent a memory leak + """ + + # set size + self.size = size + self.board_size = size * size + + # initialize neighbor locations + self.set_neighbors( size ) + self.set_3x3_neighbors( size ) + self.set_12d_neighbors( size ) + + # initialize EMPTY and BORDER group + self.group_empty = group_new( _EMPTY, self.board_size ) + self.group_border = group_new( _BORDER, self.board_size ) + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def __dealloc__(self): + """ + this function is called when this object is destroyed + + Prevent memory leaks by freeing all arrays created with malloc + """ + + # free neighbor + if self.neighbor is not NULL: + free( self.neighbor ) + + # free neighbor3x3 + if self.neighbor3x3 is not NULL: + free( self.neighbor3x3 ) + + # free neighbor12d + if self.neighbor12d is not NULL: + free( self.neighbor12d ) + + # free border and empty group + if self.group_empty is not NULL: + + free( self.group_empty ) + + if self.group_border is not NULL: + + free( self.group_border ) + + + ############################################################################ + # public functions ( c only ) # + # # + ############################################################################ + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef GameState get_root_state( self ): + """ + + """ + + cdef GameState game_state = GameState() + + cdef short i + + # set pointer to neighbor locations + game_state.neighbor = self.neighbor + game_state.neighbor3x3 = self.neighbor3x3 + game_state.neighbor12d = self.neighbor12d + + # initialize size and board_size + game_state.size = self.size + game_state.board_size = self.size * self.size + + # create history list + game_state.history = [] + + # initialize player colours + game_state.player_current = _BLACK + game_state.player_opponent = _WHITE + + game_state.ko = _PASS + game_state.capture_black = 0 + game_state.capture_white = 0 + game_state.passes_black = 0 + game_state.passes_white = 0 + + # create arrays and lists + # +1 on board_size is used as an border location used for all borders + + # create board_groups array able to hold all groups on the board and border ( board_size +1 ) + game_state.board_groups = malloc( ( self.board_size + 1 ) * sizeof( Group* ) ) + if not game_state.board_groups: + raise MemoryError() + + # create 3x3 hash array + game_state.hash3x3 = malloc( ( self.board_size ) * sizeof( long ) ) + if not game_state.hash3x3: + raise MemoryError() + + # create Locations_List able to hold all legal moves ( == board_size ) + game_state.moves_legal = malloc( sizeof( Locations_List ) ) + if not game_state.moves_legal: + raise MemoryError() + + game_state.moves_legal.locations = malloc( self.board_size * sizeof( short ) ) + if not game_state.moves_legal.locations: + raise MemoryError() + + game_state.moves_legal.count = self.board_size + + # create groups_list array to hold all groups on the board ( == board_size ) + # TODO estimate a good lower bound + game_state.groups_list = malloc( sizeof( Groups_List ) ) + if not game_state.groups_list: + raise MemoryError() + + game_state.groups_list.board_groups = malloc( self.board_size * sizeof( Group* ) ) + if not game_state.groups_list.board_groups: + raise MemoryError() + + game_state.groups_list.count_groups = 0 + + # create empty location group + game_state.group_empty = self.group_empty + + # initialize board + for i in range( game_state.board_size ): + + game_state.hash3x3[ i ] = 0 + game_state.board_groups[ i ] = game_state.group_empty + game_state.moves_legal.locations[ i ] = i + + # initialize border location + game_state.board_groups[ self.board_size ] = self.group_border + + # initialize zobrist hash + # TODO optimize? + # rng = np.random.RandomState(0) + # self.hash_lookup = { + # WHITE: rng.randint(np.iinfo(np.uint64).max, size=(size, size), dtype='uint64'), + # BLACK: rng.randint(np.iinfo(np.uint64).max, size=(size, size), dtype='uint64')} + # self.current_hash = np.uint64(0) + # self.previous_hashes = set() + + return game_state + + ############################################################################ + # public functions # + # # + ############################################################################ + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def get_root_game_state( self, enforce_superko=False ): + """ + return new GameState initialized as a new game + """ + + return self.get_root_state() \ No newline at end of file diff --git a/AlphaGo/models/nn_util.py b/AlphaGo/models/nn_util.py index f3c67e727..d08cc5122 100644 --- a/AlphaGo/models/nn_util.py +++ b/AlphaGo/models/nn_util.py @@ -21,8 +21,12 @@ def __init__(self, feature_list, **kwargs): optional argument: init_network (boolean). If set to False, skips initializing self.model and self.forward and the calling function should set them. """ - self.preprocessor = Preprocess(feature_list) - kwargs["input_dim"] = self.preprocessor.output_dim + defaults = { + "board": 19 + } + defaults.update( kwargs ) + self.preprocessor = Preprocess(feature_list, size=defaults["board"]) + kwargs["input_dim"] = self.preprocessor.get_output_dimension() if kwargs.get('init_network', True): # self.__class__ refers to the subclass so that subclasses only @@ -98,7 +102,7 @@ def save_model(self, json_file, weights_file=None): object_specs = { 'class': self.__class__.__name__, 'keras_model': self.model.to_json(), - 'feature_list': self.preprocessor.feature_list + 'feature_list': self.preprocessor.get_feature_list() } if weights_file is not None: self.model.save_weights(weights_file) diff --git a/AlphaGo/models/policy.py b/AlphaGo/models/policy.py index 9ea81f0a9..5933d4b2d 100644 --- a/AlphaGo/models/policy.py +++ b/AlphaGo/models/policy.py @@ -35,8 +35,8 @@ def batch_eval_state(self, states, moves_lists=None): n_states = len(states) if n_states == 0: return [] - state_size = states[0].size - if not all([st.size == state_size for st in states]): + state_size = states[0].get_size() + if not all([st.get_size() == state_size for st in states]): raise ValueError("all states must have the same size") # concatenate together all one-hot encoded states along the 'batch' dimension nn_input = np.concatenate([self.preprocessor.state_to_tensor(s) for s in states], axis=0) @@ -61,7 +61,7 @@ def eval_state(self, state, moves=None): # run the tensor through the network network_output = self.forward(tensor) moves = moves or state.get_legal_moves() - return self._select_moves_and_normalize(network_output[0], moves, state.size) + return self._select_moves_and_normalize(network_output[0], moves, state.get_size()) @staticmethod def create_network(**kwargs): diff --git a/AlphaGo/models/rollout.py b/AlphaGo/models/rollout.py new file mode 100644 index 000000000..85a22e035 --- /dev/null +++ b/AlphaGo/models/rollout.py @@ -0,0 +1,75 @@ +from keras.models import Sequential +from AlphaGo.util import flatten_idx +from keras.layers import convolutional +from keras.layers.core import Activation, Flatten +from nn_util import Bias, NeuralNetBase, neuralnet + + +@neuralnet +class CNNRollout(NeuralNetBase): + """A convolutional neural network to compute a probability + distribution over the next action + """ + + def _select_moves_and_normalize(self, nn_output, moves, size): + """helper function to normalize a distribution over the given list of moves + and return a list of (move, prob) tuples + """ + if len(moves) == 0: + return [] + move_indices = [flatten_idx(m, size) for m in moves] + # get network activations at legal move locations + distribution = nn_output[move_indices] + distribution = distribution / distribution.sum() + return zip(moves, distribution) + + def eval_state(self, state, moves=None): + """Given a GameState object, returns a list of (action, probability) pairs + according to the network outputs + + If a list of moves is specified, only those moves are kept in the distribution + """ + tensor = self.preprocessor.state_to_tensor(state) + + # run the tensor through the network + network_output = self.forward(tensor) + + moves = moves or state.get_legal_moves() + return self._select_moves_and_normalize(network_output[0], moves, state.size) + + @staticmethod + def create_network(**kwargs): + """construct a fast rollout neural network. + Keword Arguments: + - input_dim: depth of features to be processed by first layer (no default) + - board: width of the go board to be processed (default 19) + """ + + defaults = { + "board": 19 + } + # copy defaults, but override with anything in kwargs + params = defaults + params.update(kwargs) + + # create the network: + network = Sequential() + + # create one convolutional layer + network.add(convolutional.Convolution2D( + input_shape=(params["input_dim"], params["board"], params["board"]), + nb_filter=1, + nb_row=1, + nb_col=1, + init='uniform', + activation='relu', + border_mode='same')) + + # reshape output to be board x board + network.add(Flatten()) + # add a bias to each board location + network.add(Bias()) + # softmax makes it into a probability distribution + network.add(Activation('softmax')) + + return network diff --git a/AlphaGo/models/value.py b/AlphaGo/models/value.py index 43482f34f..22b8a1593 100644 --- a/AlphaGo/models/value.py +++ b/AlphaGo/models/value.py @@ -1,44 +1,121 @@ +import numpy as np from keras.models import Sequential -from keras.layers import convolutional -from keras.layers.core import Dense, Flatten -# from SGD_exponential_decay import SGD_exponential_decay as SGD - -# Parameters obtained from paper -K = 152 # depth of convolutional layers -LEARNING_RATE = .003 # initial learning rate -DECAY = 8.664339379294006e-08 # rate of exponential learning_rate decay - - -class value_trainer: - def __init__(self): - self.model = Sequential() - self.model.add(convolutional.Convolution2D( - input_shape=(49, 19, 19), nb_filter=K, nb_row=5, nb_col=5, - init='uniform', activation='relu', border_mode='same')) - for i in range(2, 13): - self.model.add(convolutional.Convolution2D( - nb_filter=K, nb_row=3, nb_col=3, - init='uniform', activation='relu', border_mode='same')) - - self.model.add(convolutional.Convolution2D( - nb_filter=1, nb_row=1, nb_col=1, - init='uniform', activation='linear', border_mode='same')) - self.model.add(Flatten()) - self.model.add(Dense(256, init='uniform')) - self.model.add(Dense(1, init='uniform', activation="tanh")) - - # sgd = SGD(lr=LEARNING_RATE, decay=DECAY) - # self.model.compile(loss='mean_squared_error', optimizer=sgd) - - def get_samples(self): - # TODO non-terminating loop that draws training samples uniformly at random - pass - - def train(self): - # TODO use self.model.fit_generator to train from data source - pass - - -if __name__ == '__main__': - trainer = value_trainer() - # TODO command line instantiation +from keras.layers.core import Flatten +from nn_util import NeuralNetBase, neuralnet +from keras.layers import convolutional, Dense + + +@neuralnet +class CNNValue(NeuralNetBase): + """A convolutional neural network to guess the reward at the end of the + game for a given board state, under the optimal policy. + """ + + def normalize(self, nn_output, percentage): + # value network has tanh output (-1/1) + # convert to (0/1) or (0/100) + if percentage: + # convert to range (0/100) + values = [(value[0] + 1.) / 2. * 100 for value in nn_output] + else: + # convert to range (0/1) + values = [(value[0] + 1.) / 2. for value in nn_output] + + return values + + def eval_state(self, state, percentage=False): + """Given a GameState object, returns a value + in range (0/1) or if percentage (0/100) + """ + tensor = self.preprocessor.state_to_tensor(state) + + # run the tensor through the network + network_output = self.forward(tensor) + + return self.normalize(network_output, percentage)[0] + + def batch_eval_state(self, states, percentage=False): + """Given a list with GameState objects, returns a list values + in range (0/1) or if percentage (0/100) + """ + # concatenate together all one-hot encoded states along the 'batch' dimension + nn_input = np.concatenate([self.preprocessor.state_to_tensor(s) for s in states], axis=0) + + # pass all input through the network at once (backend makes use of + # batches if len(states) is large) + network_output = self.forward(nn_input) + + return self.normalize(network_output, percentage) + + @staticmethod + def create_network(**kwargs): + """construct a convolutional neural network. + Keword Arguments: + - input_dim: depth of features to be processed by first layer (no default) + - board: width of the go board to be processed (default 19) + - filters_per_layer: number of filters used on every layer (default 128) + - filters_per_layer_K: (where K is between 1 and ) number of filters + used on layer K (default #filters_per_layer) + - layers: number of convolutional steps (default 12) + - dense: number of neurons in dense layer (default 256) + - filter_width_K: (where K is between 1 and ) width of filter on + layer K (default 3 except 1st layer which defaults to 5). + Must be odd. + """ + + defaults = { + "board": 19, + "filters_per_layer": 128, + "layers": 13, # layers 2-12 are identical to policy net + "filter_width_1": 5, + "dense": 256 + } + # copy defaults, but override with anything in kwargs + params = defaults + params.update(kwargs) + + # create the network: + # a series of zero-paddings followed by convolutions + # such that the output dimensions are also board x board + network = Sequential() + + # create first layer + network.add(convolutional.Convolution2D( + input_shape=(params["input_dim"], params["board"], params["board"]), + nb_filter=params.get("filters_per_layer_1", params["filters_per_layer"]), + nb_row=params["filter_width_1"], + nb_col=params["filter_width_1"], + init='uniform', + activation='relu', + border_mode='same')) + + for i in range(2, params["layers"] + 1): + # use filter_width_K if it is there, otherwise use 3 + filter_width_key = "filter_width_%d" % i + filter_width = params.get(filter_width_key, 3) + + # use filters_per_layer_K if it is there, otherwise use #filters_per_layer + filter_count_key = "filters_per_layer_%d" % i + filter_nb = params.get(filter_count_key, params["filters_per_layer"]) + + network.add(convolutional.Convolution2D( + nb_filter=filter_nb, + nb_row=filter_width, + nb_col=filter_width, + init='uniform', + activation='relu', + border_mode='same')) + + # the last layer maps each feature to a number + network.add(convolutional.Convolution2D( + nb_filter=1, + nb_row=1, + nb_col=1, + init='uniform', + activation='relu', + border_mode='same')) + + network.add(Flatten()) + network.add(Dense(params["dense"], init='uniform', activation='relu')) + network.add(Dense(1, init='uniform', activation="tanh")) + return network diff --git a/AlphaGo/preprocessing/game_converter.py b/AlphaGo/preprocessing/game_converter.py index baaf032bb..6e07155ad 100644 --- a/AlphaGo/preprocessing/game_converter.py +++ b/AlphaGo/preprocessing/game_converter.py @@ -1,12 +1,13 @@ #!/usr/bin/env python -import numpy as np -from AlphaGo.preprocessing.preprocessing import Preprocess -from AlphaGo.util import sgf_iter_states -import AlphaGo.go as go import os -import warnings import sgf +import warnings import h5py as h5 +import numpy as np +import AlphaGo.go as go +from AlphaGo.go_root import RootState +from AlphaGo.preprocessing.preprocessing import Preprocess +from AlphaGo.util import sgf_iter_states class SizeMismatchError(Exception): @@ -17,9 +18,9 @@ class GameConverter: def __init__(self, features): self.feature_processor = Preprocess(features) - self.n_features = self.feature_processor.output_dim + self.n_features = self.feature_processor.get_output_dimension() - def convert_game(self, file_name, bd_size): + def convert_game(self, root, file_name, bd_size): """Read the given SGF file into an iterable of (input,output) pairs for neural network training @@ -30,16 +31,16 @@ def convert_game(self, file_name, bd_size): """ with open(file_name, 'r') as file_object: - state_action_iterator = sgf_iter_states(file_object.read(), include_end=False) + state_action_iterator = sgf_iter_states(root, file_object.read(), include_end=False) for (state, move, player) in state_action_iterator: - if state.size != bd_size: + if state.get_size() != bd_size: raise SizeMismatchError() - if move != go.PASS_MOVE: + if move != go.PASS: nn_input = self.feature_processor.state_to_tensor(state) yield (nn_input, move) - def sgfs_to_hdf5(self, sgf_files, hdf5_file, bd_size=19, ignore_errors=True, verbose=False): + def sgfs_to_hdf5(self, root, sgf_files, hdf5_file, bd_size=19, ignore_errors=True, verbose=False): """Convert all files in the iterable sgf_files into an hdf5 group to be stored in hdf5_file Arguments: @@ -93,7 +94,7 @@ def sgfs_to_hdf5(self, sgf_files, hdf5_file, bd_size=19, ignore_errors=True, ver # Store comma-separated list of feature planes in the scalar field 'features'. The # string can be retrieved using h5py's scalar indexing: h5f['features'][()] - h5f['features'] = np.string_(','.join(self.feature_processor.feature_list)) + h5f['features'] = np.string_(','.join(self.feature_processor.get_feature_list())) if verbose: print("created HDF5 dataset in {}".format(tmp_file)) @@ -106,7 +107,7 @@ def sgfs_to_hdf5(self, sgf_files, hdf5_file, bd_size=19, ignore_errors=True, ver n_pairs = 0 file_start_idx = next_idx try: - for state, move in self.convert_game(file_name, bd_size): + for state, move in self.convert_game(root, file_name, bd_size): if next_idx >= len(states): states.resize((next_idx + 1, self.n_features, bd_size, bd_size)) actions.resize((next_idx + 1, 2)) @@ -193,6 +194,8 @@ def run_game_converter(cmd_line_args=None): if args.verbose: print("using features", feature_list) + root = RootState() + converter = GameConverter(feature_list) def _is_sgf(fname): @@ -222,7 +225,7 @@ def _list_sgfs(path): else: files = (f.strip() for f in sys.stdin if _is_sgf(f)) - converter.sgfs_to_hdf5(files, args.outfile, bd_size=args.size, verbose=args.verbose) + converter.sgfs_to_hdf5(root, files, args.outfile, bd_size=args.size, verbose=args.verbose) if __name__ == '__main__': diff --git a/AlphaGo/preprocessing/generate_value_training.py b/AlphaGo/preprocessing/generate_value_training.py new file mode 100644 index 000000000..a1c9745a8 --- /dev/null +++ b/AlphaGo/preprocessing/generate_value_training.py @@ -0,0 +1,326 @@ +import os +import sys +import h5py +import warnings +import numpy as np +from AlphaGo.go import WHITE +from AlphaGo.go import BLACK +from AlphaGo.go_root import RootState +from AlphaGo.models.policy import CNNPolicy +from AlphaGo.util import save_gamestate_to_sgf +from AlphaGo.ai import ProbabilisticPolicyPlayer +from AlphaGo.preprocessing.preprocessing import Preprocess + +# default settings +DEFAULT_N_TRAINING_PAIRS = 30000000 +DEFAULT_MAX_GAME_DEPTH = 500 +# play more random +DEFAULT_TEMPERATURE_SL = 1.4 +# play greedy +DEFAULT_TEMPERATURE_RL = .0001 +DEFAULT_BATCH_SIZE = 10 +DEAULT_RANDOM_MOVE = 450 +DEFAULT_FILE_NAME = "value_planes.hdf5" + +# output values for win and lose +WIN = 1 +LOSE = -1 + + +def init_hdf5(h5f, n_features, bd_size): + try: + states = h5f.require_dataset( + 'states', + dtype=np.uint8, + shape=(1, n_features, bd_size, bd_size), + maxshape=(None, n_features, bd_size, bd_size), + # 'None' dimension allows it to grow arbitrarily + exact=False, + # allow non-uint8 datasets to be loaded, coerced to uint8 + # TODO chunk size influences speed a lot, find out what high and low values do exactly + chunks=(1, n_features, bd_size, bd_size), + # approximately 10MB chunks (bigger for more compression, + # OK because accessed *in order*) + compression="lzf") + winners = h5f.require_dataset( + 'winners', + dtype=np.int8, + shape=(1, 1), + maxshape=(None, 1), + exact=False, + chunks=(1024, 1), + compression="lzf") + except Exception as e: + raise e + return states, winners + + +def play_batch(root, player_RL, player_SL, batch_size, features, i_rand_move, next_idx, sgf_path): + """Play a batch of games in parallel and return one training pair from each game. + + As described in Silver et al, the method for generating value net training data is as follows: + + * pick a number between 1 and 450 + * use the supervised-learning policy to play a game against itself up to that number of moves. + * now go off-policy and pick a totally random move + * play out the rest of the game with the reinforcement-learning policy + * save the state that occurred *right after* the random move, + * and the end result of the game, as the training pair + """ + + def do_move(states, moves): + for st, mv in zip(states, moves): + if not st.is_end_of_game: + # Only do more moves if not end of game already + st.do_move(mv) + return states + + def do_rand_move(states): + """Do a uniform-random move over legal moves and record info for + training. Only gets called once per game. + """ + + # get legal moves and play one at random + legal_moves = [st.get_legal_moves() for st in states] + rand_moves = [lm[np.random.choice(len(lm))] for lm in legal_moves] + states = do_move(states, rand_moves) + + # copy all states, these are the generated training data + training_state_list = [st.copy() for st in states] # For later 1hot preprocessing + return training_state_list, states + + def convert(state_list, preprocessor): + """Convert states to 1-hot and concatenate. X's are game state objects. + """ + + states = np.concatenate( + [preprocessor.state_to_tensor(state) for state in state_list], axis=0) + return states + + # Lists of game training pairs (1-hot) + preprocessor = Preprocess(features) + states = [root.get_root_game_state() for _ in xrange(batch_size)] + + # play player_SL moves + for _ in xrange(i_rand_move - 1): + # Get moves (batch) + batch_moves = player_SL.get_moves(states) + # Do moves (black) + states = do_move(states, batch_moves) + + # remove games that are finished + states = [state for state in states if not state.is_end_of_game()] + + # Make random move + states_list, states = do_rand_move(states) + + # color is random move player color + color = WHITE if i_rand_move % 2 == 0 else BLACK + + # play moves with player_RL till game ends + while True: + # Get moves (batch) + batch_moves = player_RL.get_moves(states) + # Do moves (black) + states = do_move(states, batch_moves) + + # check if all games are finished + done = [st.is_end_of_game() for st in states] + + if all(done): + break + + if sgf_path is not None: + # number different sgf + sgf_id = next_idx + + for gm in states: + # add leading '0' + file_name = str(sgf_id) + while len(file_name) < 10: + file_name = '0' + file_name + + # determine winner + winner_game = 'WHITE' if gm.get_winner() == WHITE else 'BLACK' + random_player = 'WHITE' if color == WHITE else 'BLACK' + + # generate file name + file_name += '_winner_' + winner_game + '_active-player_' + \ + random_player + '_move_' + str(i_rand_move) + '.sgf' + # save sgf + save_gamestate_to_sgf(gm, sgf_path, file_name, + result=winner_game + ' ' + str(i_rand_move)) + # increment sgf id count + sgf_id += 1 + + # Concatenate training examples + training_states = convert(states_list, preprocessor) + + # get winners list relative to 'random move' player color (color) + # winner BLACK & color Black -> WIN + # winner WHITE & color WHITE -> WIN + # winner BLACK & color WHITE -> LOSE + # winner WHITE & color Black -> LOSE + actual_batch_size = len(states) + winners = np.array([WIN if st.get_winner() == color else + LOSE for st in states]).reshape(actual_batch_size, 1) + return training_states, winners + + +def generate_data(root, player_RL, player_SL, hdf5_file, n_training_pairs, + batch_size, bd_size, features, verbose, sgf_path): + # used features + n_features = Preprocess(features).output_dim + # temporary hdf5 file + tmp_file = os.path.join(os.path.dirname(hdf5_file), ".tmp." + os.path.basename(hdf5_file)) + # open hdf5 file + h5f = h5py.File(tmp_file, 'w') + # initialize a new hdf5 file + h5_states, h5_winners = init_hdf5(h5f, n_features, bd_size) + + # random move distribution administration + distribution = {key: 0 for key in range(DEAULT_RANDOM_MOVE)} + + if verbose: + print(str(hdf5_file) + " file initialized.") + max_value = str(n_training_pairs) + + next_idx = 0 + while True: + # Randomly choose turn to play uniform random. Move prior will be from SL + # policy. Moves after will be from RL policy. + i_rand_move = np.random.choice(range(DEAULT_RANDOM_MOVE)) + + # play games + states, winners = play_batch(root, player_RL, player_SL, batch_size, features, + i_rand_move, next_idx, sgf_path) + + if states is not None: + try: + # get actual batch size in case any pair was removed + actual_batch_size = len(states) + # increment random distribution + distribution[i_rand_move] += actual_batch_size + + # add states and winners to hdf5 file + h5_states.resize((next_idx + actual_batch_size, n_features, bd_size, bd_size)) + h5_winners.resize((next_idx + actual_batch_size, 1)) + h5_states[next_idx:] = states + h5_winners[next_idx:] = winners + + # count saved pairs + next_idx += actual_batch_size + except Exception as e: + warnings.warn("Unknown error occured during batch save to HDF5 file: {}".format(hdf5_file)) # noqa: E501 + raise e + + if verbose: + # primitive progress indication + current = str(next_idx) + while len(current) < len(max_value): + current = ' ' + current + + line = 'Progress: ' + current + '/' + max_value + + sys.stdout.write('\b' * len(line)) + sys.stdout.write('\r') + sys.stdout.write(line) + sys.stdout.flush() + + # stop data generation when at least n_trainings_pairs have been created + if n_training_pairs <= next_idx: + break + + # processing complete: rename tmp_file to hdf5_file + h5f.close() + os.rename(tmp_file, hdf5_file) + if verbose: + print("Value training data succesfull created.") + + # show random move distribution + print("\nRandom move distribution:") + for key in range(DEAULT_RANDOM_MOVE): + print("Random move: " + str(key) + " " + str(distribution[key])) + + +def handle_arguments(cmd_line_args=None): + """Run generate data. command-line args may be passed in as a list + """ + + import argparse + parser = argparse.ArgumentParser(description='Play games used for training' + 'value network (third phase of pipeline). ' + 'The final policy from the RL phase plays ' + 'against itself and training pairs for value ' + 'network are generated from the outcome in each ' + 'games, following an off-policy, uniform random move') + # required arguments + parser.add_argument("SL_weights_path", help="Path to file with supervised learning policy weights.") # noqa: E501 + parser.add_argument("RL_weights_path", help="Path to file with reinforcement learning policy weights.") # noqa: E501 + parser.add_argument("model_path", help="Path to network architecture file.") + # optional arguments + parser.add_argument("--verbose", "-v", help="Turn on verbose mode", default=False, action="store_true") # noqa: E501 + parser.add_argument("--outfile", "-o", help="Destination to write data (hdf5 file) Default: " + DEFAULT_FILE_NAME, default=DEFAULT_FILE_NAME) # noqa: E501 + parser.add_argument("--sgf-path", help="If set all sgf will be saved here. Default: None", default=None) # noqa: E501 + parser.add_argument("--n-training-pairs", help="Number of training pairs to generate. Default: " + str(DEFAULT_N_TRAINING_PAIRS), type=int, default=DEFAULT_N_TRAINING_PAIRS) # noqa: E501 + parser.add_argument("--batch-size", help="Number of games to run in parallel. Default: " + str(DEFAULT_BATCH_SIZE), type=int, default=DEFAULT_BATCH_SIZE) # noqa: E501 + parser.add_argument("--features", "-f", help="Comma-separated list of features to compute and store or 'all'. Default: all", default='all') # noqa: E501 + parser.add_argument("--sl-temperature", help="Distribution temperature of players using SL policies. Default: " + str(DEFAULT_TEMPERATURE_SL), type=float, default=DEFAULT_TEMPERATURE_SL) # noqa: E501 + parser.add_argument("--rl-temperature", help="Distribution temperature of players using RL policies. Default: " + str(DEFAULT_TEMPERATURE_RL), type=float, default=DEFAULT_TEMPERATURE_RL) # noqa: E501 + + root = RootState() + + # show help or parse arguments + if cmd_line_args is None: + args = parser.parse_args() + else: + args = parser.parse_args(cmd_line_args) + + # list with features used for value network + # features = policy_SL.preprocessor.feature_list + if args.features.lower() == 'all': + features = [ + "board", + "ones", + "turns_since", + "liberties", + "capture_size", + "self_atari_size", + "liberties_after", + "ladder_capture", + "ladder_escape", + "sensibleness", + "zeros"] + else: + features = args.features.split(",") + + # always add colour feature + if "color" not in features: + features.append("color") + + # Load SL architecture and weights from file + policy_SL = CNNPolicy.load_model(args.model_path) + policy_SL.model.load_weights(args.SL_weights_path) + # create SL player + player_SL = ProbabilisticPolicyPlayer(policy_SL, temperature=args.sl_temperature, + move_limit=DEFAULT_MAX_GAME_DEPTH) + + # Load RL architecture and weights from file + policy_RL = CNNPolicy.load_model(args.model_path) + policy_RL.model.load_weights(args.RL_weights_path) + # Create RL player + # TODO is it better to use greedy player? + player_RL = ProbabilisticPolicyPlayer(policy_RL, temperature=args.rl_temperature, + move_limit=DEFAULT_MAX_GAME_DEPTH) + + # check if folder exists + if args.sgf_path is not None and not os.path.exists(args.sgf_path): + os.makedirs(args.sgf_path) + + # generate data + generate_data(root, player_RL, player_SL, args.outfile, args.n_training_pairs, args.batch_size, + policy_SL.model.input_shape[-1], features, args.verbose, args.sgf_path) + + +if __name__ == '__main__': + handle_arguments() diff --git a/AlphaGo/preprocessing/preprocessing.pxd b/AlphaGo/preprocessing/preprocessing.pxd new file mode 100644 index 000000000..915582c8a --- /dev/null +++ b/AlphaGo/preprocessing/preprocessing.pxd @@ -0,0 +1,79 @@ +import ast +import time +import numpy as np +cimport numpy as np +from numpy cimport ndarray +from libc.stdlib cimport malloc, free +from AlphaGo.go cimport GameState +from AlphaGo.go_data cimport _BLACK, _EMPTY, _STONE, _LIBERTY, _CAPTURE, _FREE, Group, Locations_List, locations_list_destroy + +ctypedef char tensor_type +ctypedef int (*preprocess_method)( Preprocess, GameState, tensor_type[ :, ::1 ], short*, int ) + + +cdef class Preprocess: + + ############################################################################ + # variables declarations # + # # + ############################################################################ + + # all feature processors + # TODO find correct type so an array can be used + cdef preprocess_method *processors + + # list with all features used currently + # TODO find correct type so an array can be used + cdef list feature_list + + # output tensor size + cdef int output_dim + + # board size + cdef char size + cdef short board_size + + # pattern dictionaries + cdef dict pattern_nakade + cdef dict pattern_response_12d + cdef dict pattern_non_response_3x3 + + # pattern dictionary sizes + cdef int pattern_nakade_size + cdef int pattern_response_12d_size + cdef int pattern_non_response_3x3_size + + ############################################################################ + # Tensor generating functions # + # # + ############################################################################ + + cdef int get_board( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) + cdef int get_turns_since( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) + cdef int get_liberties( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) + cdef int get_capture_size( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) + cdef int get_self_atari_size( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) + cdef int get_liberties_after( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) + cdef int get_ladder_capture( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) + cdef int get_ladder_escape( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) + cdef int get_sensibleness( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) + cdef int get_legal( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) + cdef int get_response( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) + cdef int get_save_atari( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) + cdef int get_neighbor( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) + cdef int get_nakade( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) + cdef int get_nakade_offset( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) + cdef int get_response_12d( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) + cdef int get_response_12d_offset( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) + cdef int zeros( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) + cdef int ones( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) + cdef int colour( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) + cdef int get_non_response_3x3( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) + cdef int get_non_response_3x3_offset( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) + + ############################################################################ + # public cdef function # + # # + ############################################################################ + + cdef np.ndarray[ tensor_type, ndim=4 ] generate_tensor( self, GameState state ) diff --git a/AlphaGo/preprocessing/preprocessing.pyx b/AlphaGo/preprocessing/preprocessing.pyx new file mode 100644 index 000000000..9e35e5d18 --- /dev/null +++ b/AlphaGo/preprocessing/preprocessing.pyx @@ -0,0 +1,747 @@ +#cython: profile=True +#cython: linetrace=True +cimport cython +import numpy as np +cimport numpy as np + +cdef class Preprocess: + + ############################################################################ + # all variables are declared in the .pxd file # + # # + ############################################################################ + + """ -> variables, declared in preprocessing.pxd + + # all feature processors + # TODO find correct type so an array can be used + cdef list processors + + # list with all features used currently + # TODO find correct type so an array can be used + cdef list feature_list + + # output tensor size + cdef int output_dim + + # board size + cdef char size + cdef short board_size + + # pattern dictionaries + cdef dict pattern_nakade + cdef dict pattern_response_12d + cdef dict pattern_non_response_3x3 + + # pattern dictionary sizes + cdef int pattern_nakade_size + cdef int pattern_response_12d_size + cdef int pattern_non_response_3x3_size + + -> variables, declared in preprocessing.pxd + """ + + ############################################################################ + # Tensor generating functions # + # # + ############################################################################ + + @cython.boundscheck( False ) + @cython.wraparound( False ) + @cython.nonecheck( False ) + cdef int get_board( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + """ + A feature encoding WHITE BLACK and EMPTY on separate planes. + plane 0 always refers to the current player stones + plane 1 to the opponent stones + plane 2 to empty locations + """ + + cdef short location + + # loop over all locations on board + for location in range( self.board_size ): + + tensor[ offSet + state.get_board_feature( location ), location ] = 1 + + return offSet + 3 + + @cython.boundscheck( False ) + @cython.wraparound( False ) + @cython.nonecheck( False ) + cdef int get_turns_since( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + """ + A feature encoding the age of the stone at each location up to 'maximum' + + Note: + - the [maximum-1] plane is used for any stone with age greater than or equal to maximum + - EMPTY locations are all-zero features + """ + + cdef short location + cdef int age = offSet + 7 + cdef int i = len( state.history ) - 1 + cdef dict agesSet = {} + + # set all stones to max age + for location in state.history: + if state.board_groups[ location ].colour > _EMPTY: + tensor[ age, location ] = 1 + + age = 0 + + # loop over history backwards + while age < 7 and i >= 0: + + location = state.history[ i ] + + # if age has not been set yet + if not location in agesSet and state.board_groups[ location ].colour > _EMPTY: + + tensor[ offSet + age, location ] = 1 + tensor[ offSet + 7, location ] = 0 + agesSet[ location ] = location + + i -= 1 + age += 1 + + return offSet + 8 + + @cython.boundscheck( False ) + @cython.wraparound( False ) + @cython.nonecheck( False ) + cdef int get_liberties( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + """ + A feature encoding the number of liberties of the group connected to the stone at + each location + + Note: + - there is no zero-liberties plane; the 0th plane indicates groups in atari + - the [maximum-1] plane is used for any stone with liberties greater than or equal to maximum + - EMPTY locations are all-zero features + """ + + cdef int i + cdef Group* group + cdef short location + cdef short groupLiberty + + # loop over all groups on board + for i in range( state.groups_list.count_groups ): + + group = state.groups_list.board_groups[ i ] + + # get liberty count + groupLiberty = group.count_liberty - 1 + + # check max liberty count + if groupLiberty > 7: + + groupLiberty = 7 + + # loop over all group stones and set liberty count + for location in range( state.board_size ): + + if group.locations[ location ] == _STONE: + + tensor[ offSet + groupLiberty, location ] = 1 + + return offSet + 8 + + @cython.boundscheck( False ) + @cython.wraparound( False ) + @cython.nonecheck( False ) + cdef int get_capture_size( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + """ + A feature encoding the number of opponent stones that would be captured by + playing at each location, up to 'maximum' + + Note: + - we currently *do* treat the 0th plane as "capturing zero stones" + - the [maximum-1] plane is used for any capturable group of size + greater than or equal to maximum-1 + - the 0th plane is used for legal moves that would not result in capture + - illegal move locations are all-zero features + """ + + cdef short i, location, capture_size + + # loop over all legal moves and set get capture size + for i in range( state.moves_legal.count ): + + location = state.moves_legal.locations[ i ] + + capture_size = groups_after[ location * 3 + _CAPTURE ] + + if capture_size > 7: + capture_size = 7 + + tensor[ offSet + capture_size, location ] = 1 + + return offSet + 8 + + @cython.boundscheck( False ) + @cython.wraparound( False ) + @cython.nonecheck( False ) + cdef int get_self_atari_size( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + """ + A feature encoding the size of the own-stone group that is put into atari by + playing at a location + + """ + + cdef short i, location, group_liberty + + # loop over all groups on board + for i in range( state.moves_legal.count ): + + location = state.moves_legal.locations[ i ] + + group_liberty = groups_after[ location * 3 + _LIBERTY ] + + if group_liberty == 1: + + group_liberty = groups_after[ location * 3 + _STONE ] - 1 + if group_liberty > 7: + group_liberty = 7 + + tensor[ offSet + group_liberty, location ] = 1 + + return offSet + 8 + + @cython.boundscheck( False ) + @cython.wraparound( False ) + @cython.nonecheck( False ) + cdef int get_liberties_after( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + """ + A feature encoding what the number of liberties *would be* of the group connected to + the stone *if* played at a location + + Note: + - there is no zero-liberties plane; the 0th plane indicates groups in atari + - the [maximum-1] plane is used for any stone with liberties greater than or equal to maximum + - illegal move locations are all-zero features + """ + + cdef short i, location, liberty + + # loop over all legal moves + for i in range( state.moves_legal.count ): + + location = state.moves_legal.locations[ i ] + + liberty = groups_after[ location * 3 + _LIBERTY ] - 1 + + if liberty > 7: + liberty = 7 + + if liberty >= 0: + + tensor[ offSet + liberty, location ] = 1 + + return offSet + 8 + + @cython.boundscheck( False ) + @cython.wraparound( False ) + @cython.nonecheck( False ) + cdef int get_ladder_capture( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + """ + A feature wrapping GameState.is_ladder_capture(). + check if an opponent group can be captured in a ladder + """ + + cdef int location + cdef char* captures = state.get_ladder_captures( 80 ) + + # loop over all groups on board + for location in range( state.board_size ): + + if captures[ location ] != _FREE: + + tensor[ offSet, location ] = 1 + + # free captures + free( captures ) + + return offSet + 1 + + @cython.boundscheck( False ) + @cython.wraparound( False ) + @cython.nonecheck( False ) + cdef int get_ladder_escape( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + """ + A feature wrapping GameState.is_ladder_escape(). + check if player_current group can escape ladder + """ + + cdef int location + cdef char* escapes = state.get_ladder_escapes( 80 ) + + # loop over all groups on board + for location in range( state.board_size ): + + if escapes[ location ] != _FREE: + + tensor[ offSet, location ] = 1 + + # free escapes + free( escapes ) + + return offSet + 1 + + @cython.boundscheck( False ) + @cython.wraparound( False ) + @cython.nonecheck( False ) + cdef int get_sensibleness( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + """ + A move is 'sensible' if it is legal and if it does not fill the current_player's own eye + """ + + cdef short i + cdef Locations_List* sensible_moves = state.get_sensible_moves() + + # loop over all sensible moves and set to 1 + for i in range( sensible_moves.count ): + + tensor[ offSet, sensible_moves.locations[ i ] ] = 1 + + # free sensible_moves + locations_list_destroy( sensible_moves ) + + return offSet + 1 + + @cython.boundscheck( False ) + @cython.wraparound( False ) + @cython.nonecheck( False ) + cdef int get_legal( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + """ + Zero at all illegal moves, one at all legal moves. Unlike sensibleness, no eye check is done + not used?? + """ + + cdef short location + + # loop over all legal moves and set to one + for location in range( state.moves_legal.count ): + + tensor[ offSet, state.moves_legal.locations[ location ] ] = 1 + + return offSet + 1 + + @cython.boundscheck( False ) + @cython.wraparound( False ) + @cython.nonecheck( False ) + cdef int get_response( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + """ + Fast rollout feature + """ + + return offSet + 1 + + @cython.boundscheck( False ) + @cython.wraparound( False ) + @cython.nonecheck( False ) + cdef int get_save_atari( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + """ + Fast rollout feature + """ + + return offSet + 1 + + @cython.boundscheck( False ) + @cython.wraparound( False ) + @cython.nonecheck( False ) + cdef int get_neighbor( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + """ + Fast rollout feature + """ + + cdef short location + cdef int i = 0 + cdef int plane = offSet + cdef list neighbor = state.get_neighbor_locations() + + # loop over neighbor + # 0,1,2,3 are direct neighbor + # 4,5,6,7 are diagonal neighbor + for location in neighbor: + + # -1 means border location or aleady occupied + if location >= 0: + + tensor[ plane, location ] = 1 + + i += 1 + + if i == 4: + plane += 1 + + return offSet + 2 + + @cython.boundscheck( False ) + @cython.wraparound( False ) + @cython.nonecheck( False ) + cdef int get_nakade( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + """ + Fast rollout feature + """ + + return offSet + 1 + + @cython.boundscheck( False ) + @cython.wraparound( False ) + @cython.nonecheck( False ) + cdef int get_nakade_offset( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + """ + Fast rollout feature + """ + + return offSet + self.pattern_nakade_size + + @cython.boundscheck( False ) + @cython.wraparound( False ) + @cython.nonecheck( False ) + cdef int get_response_12d( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + """ + Fast rollout feature + """ + + # get last move location + # check for pass + + return offSet + 1 + + @cython.boundscheck( False ) + @cython.wraparound( False ) + @cython.nonecheck( False ) + cdef int get_response_12d_offset( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + """ + Fast rollout feature + """ + + # get last move location + # check for pass + + return offSet + self.pattern_response_12d_size + + @cython.boundscheck( False ) + @cython.wraparound( False ) + @cython.nonecheck( False ) + cdef int get_non_response_3x3( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + """ + Fast rollout feature + """ + + return offSet + 1 + + @cython.boundscheck( False ) + @cython.wraparound( False ) + @cython.nonecheck( False ) + cdef int get_non_response_3x3_offset( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + """ + Fast rollout feature + """ + + return offSet + self.pattern_non_response_3x3_size + + @cython.boundscheck( False ) + @cython.wraparound( False ) + @cython.nonecheck( False ) + cdef int zeros( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + """ + Plane filled with zeros + """ + + ######################################################### + # strange things happen if a function does no do anything + # do not remove next line without extensive testing!!!!!! + tensor[ offSet, 0 ] = 0 + + return offSet + 1 + + @cython.boundscheck( False ) + @cython.wraparound( False ) + @cython.nonecheck( False ) + cdef int ones( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + """ + Plane filled with ones + """ + + cdef short location + + for location in range( 0, self.board_size ): + + tensor[ offSet, location ] = 1 + return offSet + 1 + + @cython.boundscheck( False ) + @cython.wraparound( False ) + @cython.nonecheck( False ) + cdef int colour( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + """ + Value net feature, plane with ones if active_player is black else zeros + """ + + cdef short location + + # if player_current is white + if state.player_current == _BLACK: + + for location in range( 0, self.board_size ): + + tensor[ offSet, location ] = 1 + + return offSet + 1 + + + ############################################################################ + # init function # + # # + ############################################################################ + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def __init__( self, list feature_list, char size=19, dict_nakade=None, dict_3x3=None, dict_12d=None, verbose=False ): + """ + """ + + self.size = size + self.board_size = size * size + + cdef int i + cdef preprocess_method processor + self.processors = malloc( len( feature_list ) * sizeof( preprocess_method ) ) + + if not self.processors: + raise MemoryError() + + # load nakade patterns + self.pattern_nakade = {} + self.pattern_nakade_size = 0 + if dict_nakade is not None: + with open(dict_nakade, 'r') as f: + s = f.read() + self.pattern_nakade = ast.literal_eval(s) + self.pattern_nakade_size = max(self.pattern_nakade.values()) + 1 + + # load 12d response patterns + self.pattern_response_12d = {} + self.pattern_response_12d_size = 0 + if dict_12d is not None: + with open(dict_12d, 'r') as f: + s = f.read() + self.pattern_response_12d = ast.literal_eval(s) + self.pattern_response_12d_size = max(self.pattern_response_12d.values()) + 1 + + # load 3x3 non response patterns + self.pattern_non_response_3x3 = {} + self.pattern_non_response_3x3_size = 0 + if dict_3x3 is not None: + with open(dict_3x3, 'r') as f: + s = f.read() + self.pattern_non_response_3x3 = ast.literal_eval(s) + self.pattern_non_response_3x3_size = max(self.pattern_non_response_3x3.values()) + 1 + + if verbose: + print("loaded " + str(self.pattern_nakade_size) + " nakade patterns") + print("loaded " + str(self.pattern_response_12d_size) + " 12d patterns") + print("loaded " + str(self.pattern_non_response_3x3_size) + " 3x3 patterns") + + self.feature_list = feature_list + self.output_dim = 0 + + for i in range( len( feature_list ) ): + feat = feature_list[ i ].lower() + if feat == "board": + processor = self.get_board + self.output_dim += 3 + + elif feat == "ones": + processor = self.ones + self.output_dim += 1 + + elif feat == "turns_since": + processor = self.get_turns_since + self.output_dim += 8 + + elif feat == "liberties": + processor = self.get_liberties + self.output_dim += 8 + + elif feat == "capture_size": + processor = self.get_capture_size + self.output_dim += 8 + + elif feat == "self_atari_size": + processor = self.get_self_atari_size + self.output_dim += 8 + + elif feat == "liberties_after": + processor = self.get_liberties_after + self.output_dim += 8 + + elif feat == "ladder_capture": + processor = self.get_ladder_capture + self.output_dim += 1 + + elif feat == "ladder_escape": + processor = self.get_ladder_escape + self.output_dim += 1 + + elif feat == "sensibleness": + processor = self.get_sensibleness + self.output_dim += 1 + + elif feat == "zeros": + processor = self.zeros + self.output_dim += 1 + + elif feat == "legal": + processor = self.get_legal + self.output_dim += 1 + + elif feat == "response": + processor = self.get_response + self.output_dim += 1 + + elif feat == "save_atari": + processor = self.get_save_atari + self.output_dim += 1 + + elif feat == "neighbor": + processor = self.get_neighbor + self.output_dim += 2 + + elif feat == "nakade": + processor = self.get_nakade + self.output_dim += self.pattern_nakade_size + + elif feat == "response_12d": + processor = self.get_response_12d + self.output_dim += self.pattern_response_12d_size + + elif feat == "non_response_3x3": + processor = self.get_non_response_3x3 + self.output_dim += self.pattern_non_response_3x3_size + + elif feat == "color": + processor = self.colour + self.output_dim += 1 + else: + raise ValueError( "uknown feature: %s" % feat ) + + self.processors[ i ] = processor + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def __dealloc__(self): + """ + Prevent memory leaks by freeing all arrays created with malloc + """ + + if self.processors is not NULL: + free( self.processors ) + + ############################################################################ + # public cdef function # + # # + ############################################################################ + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + @cython.nonecheck( False ) + cdef np.ndarray[ tensor_type, ndim=4 ] generate_tensor( self, GameState state ): + """ + Convert a GameState to a Theano-compatible tensor + """ + + cdef int i + cdef preprocess_method proc + + # create complete array now instead of concatenate later + cdef np.ndarray[ tensor_type, ndim=2 ] np_tensor = np.zeros( ( self.output_dim, self.board_size ), dtype=np.int8 ) + cdef tensor_type[ :, ::1 ] tensor = np_tensor + + cdef int offSet = 0 + + cdef short *groups_after = state.get_groups_after() + # TODO create array with all nextmoves information + + for i in range( len( self.feature_list ) ): + + proc = self.processors[ i ] + offSet = proc( self, state, tensor, groups_after, offSet ) + + # free groups_after + free( groups_after ) + + # create a singleton 'batch' dimension + return np_tensor.reshape( ( 1, self.output_dim, self.size, self.size ) ) + + + ############################################################################ + # public def function (Python) # + # # + ############################################################################ + + + # this function should be used from Python environment, + # use generate_tensor from C environment for speed + def state_to_tensor( self, GameState state ): + """ + Convert a GameState to a Theano-compatible tensor + """ + + return self.generate_tensor( state ) + + def get_output_dimension( self ): + """ + + """ + + return self.output_dim + + def get_feature_list( self ): + """ + + """ + + return self.feature_list + + ############################################################################ + # test # + # # + ############################################################################ + def test( self, GameState state, int amount ): + cdef char size = state.size + self.board_size = state.size * state.size + + import time + t = time.time() + + cdef int i + + for i in range( amount ): + self.generate_tensor( state ) + + print "proc " + str( time.time() - t ) + + def timed_test( self, GameState state, int amount ): + + cdef int i + + for i in range( amount ): + + self.generate_tensor( state ) + + + def test_game_speed( self, GameState state, list moves ): + + cdef short location + + for location in moves: + + state.add_move( location ) + self.generate_tensor( state ) \ No newline at end of file diff --git a/AlphaGo/preprocessing/preprocessing.py b/AlphaGo/preprocessing/preprocessing_python.py similarity index 97% rename from AlphaGo/preprocessing/preprocessing.py rename to AlphaGo/preprocessing/preprocessing_python.py index 5c6b4cd11..1bd0d5ad6 100644 --- a/AlphaGo/preprocessing/preprocessing.py +++ b/AlphaGo/preprocessing/preprocessing_python.py @@ -1,5 +1,5 @@ import numpy as np -import AlphaGo.go as go +import AlphaGo.go_python as go import keras.backend as K # This file is used anywhere that neural net features are used; setting the keras dimension ordering @@ -254,6 +254,11 @@ def get_legal(state): "legal": { "size": 1, "function": get_legal + }, + "color": { + "size": 1, + "function": lambda state: np.ones((1, state.size, state.size)) * + (state.current_player == go.BLACK) } } @@ -291,4 +296,8 @@ def state_to_tensor(self, state): # concatenate along feature dimension then add in a singleton 'batch' dimension f, s = self.output_dim, state.size - return np.concatenate(feat_tensors).reshape((1, f, s, s)) + + tensor = np.concatenate(feat_tensors).reshape((1, f, s, s)) + tensor = tensor.astype(np.int8) + + return tensor diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 74bd62005..733eaaa6b 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -1,31 +1,30 @@ import os import json import numpy as np +import AlphaGo.go as go +import keras.backend as K from shutil import copyfile from keras.optimizers import SGD -import keras.backend as K -from AlphaGo.ai import ProbabilisticPolicyPlayer -import AlphaGo.go as go -from AlphaGo.models.policy import CNNPolicy from AlphaGo.util import flatten_idx - +from AlphaGo.go_root import RootState +from AlphaGo.models.policy import CNNPolicy +from AlphaGo.ai import ProbabilisticPolicyPlayer def _make_training_pair(st, mv, preprocessor): # Convert move to one-hot st_tensor = preprocessor.state_to_tensor(st) - mv_tensor = np.zeros((1, st.size * st.size)) - mv_tensor[(0, flatten_idx(mv, st.size))] = 1 + mv_tensor = np.zeros((1, st.get_size() * st.get_size())) + mv_tensor[(0, flatten_idx(mv, st.get_size()))] = 1 return (st_tensor, mv_tensor) -def run_n_games(optimizer, learner, opponent, num_games, mock_states=[]): +def run_n_games(optimizer, root, learner, opponent, num_games, mock_states=[]): '''Run num_games games to completion, keeping track of each position and move of the learner. (Note: learning cannot happen until all games have completed) ''' - board_size = learner.policy.model.input_shape[-1] - states = [go.GameState(size=board_size) for _ in range(num_games)] + states = [root.get_root_game_state() for _ in range(num_games)] learner_net = learner.policy.model # Allowing injection of a mock state object for testing purposes @@ -57,7 +56,7 @@ def run_n_games(optimizer, learner, opponent, num_games, mock_states=[]): for (idx, state), mv in zip(idxs_to_unfinished_states.iteritems(), moves): # Order is important here. We must get the training pair on the unmodified state before # updating it with do_move. - is_learnable = current is learner and mv is not go.PASS_MOVE + is_learnable = current is learner and mv is not go.PASS if is_learnable: (st_tensor, mv_tensor) = _make_training_pair(state, mv, learner.policy.preprocessor) state_tensors[idx].append(st_tensor) @@ -184,6 +183,7 @@ def save_metadata(): optimizer = SGD(lr=args.learning_rate) player.policy.model.compile(loss=log_loss, optimizer=optimizer) + root = RootState( player.policy.model.input_shape[-1] ) for i_iter in range(1, args.iterations + 1): # Randomly choose opponent from pool (possibly self), and playing # game_batch games against them. @@ -197,7 +197,7 @@ def save_metadata(): # Run games (and learn from results). Keep track of the win ratio vs each opponent over # time. - win_ratio = run_n_games(optimizer, player, opponent, args.game_batch) + win_ratio = run_n_games(optimizer, root, player, opponent, args.game_batch) metadata["win_ratio"][player_weights] = (opp_weights, win_ratio) # Save all intermediate models. diff --git a/AlphaGo/training/reinforcement_value_trainer.py b/AlphaGo/training/reinforcement_value_trainer.py index e69de29bb..a7067c270 100644 --- a/AlphaGo/training/reinforcement_value_trainer.py +++ b/AlphaGo/training/reinforcement_value_trainer.py @@ -0,0 +1,814 @@ +import os +import json +import threading +import h5py as h5 +import numpy as np +from keras import backend as K +from AlphaGo.util import confirm +from keras.optimizers import SGD +from keras.callbacks import Callback +from AlphaGo.models.value import CNNValue +from AlphaGo.preprocessing.preprocessing import Preprocess + +# default settings +DEFAULT_MAX_VALIDATION = 1000000000 +DEFAULT_TRAIN_VAL_TEST = [.95, .05, .0] +DEFAULT_LEARNING_RATE = .003 +DEFAULT_BATCH_SIZE = 32 +DEFAULT_DECAY = .0000000125 +DEFAULT_EPOCH = 10 + +# metdata file +FILE_METADATA = 'metadata_value_reinforce.json' +# weight folder +FOLDER_WEIGHT = os.path.join('value_reinforce_weights') + +# shuffle files +FILE_VALIDATE = 'shuffle_value_validate.npz' +FILE_TRAIN = 'shuffle_value_train.npz' +FILE_TEST = 'shuffle_value_test.npz' + +TRANSFORMATION_INDICES = { + "noop": 0, + "rot90": 1, + "rot180": 2, + "rot270": 3, + "fliplr": 4, + "flipud": 5, + "diag1": 6, + "diag2": 7 +} + +BOARD_TRANSFORMATIONS = { + 0: lambda feature: feature, + 1: lambda feature: np.rot90(feature, 1), + 2: lambda feature: np.rot90(feature, 2), + 3: lambda feature: np.rot90(feature, 3), + 4: lambda feature: np.fliplr(feature), + 5: lambda feature: np.flipud(feature), + 6: lambda feature: np.transpose(feature), + 7: lambda feature: np.fliplr(np.rot90(feature, 1)) +} + + +class threading_shuffled_hdf5_batch_generator: + """A generator of batches of training data for use with the fit_generator function + of Keras. Data is accessed in the order of the given indices for shuffling. + + it is threading safe but not multiprocessing therefore only use it with + pickle_safe=False when using multiple workers + """ + + def shuffle_indices(self, seed=None, idx=0): + # set generator_sample to idx + self.metadata['generator_sample'] = idx + + # check if seed is provided or generate random + if seed is None: + # create random seed + self.metadata['generator_seed'] = np.random.random_integers(4294967295) + + # feed numpy.random with seed in order to continue with certain batch + np.random.seed(self.metadata['generator_seed']) + # shuffle indices according to seed + if not self.validation: + np.random.shuffle(self.indices) + + def __init__(self, state_dataset, action_dataset, indices, batch_size, metadata=None, + validation=False): + self.action_dataset = action_dataset + self.state_dataset = state_dataset + # lock used for multithreaded workers + self.data_lock = threading.Lock() + self.indices_max = len(indices) + self.validation = validation + self.batch_size = batch_size + self.indices = indices + + if metadata is not None: + self.metadata = metadata + else: + # create metadata object + self.metadata = { + "generator_seed": None, + "generator_sample": 0 + } + + # shuffle indices + # when restarting generator_seed and generator_batch will + # reset generator to the same point as before + self.shuffle_indices(self.metadata['generator_seed'], self.metadata['generator_sample']) + + def __iter__(self): + return self + + def next_indice(self): + # use lock to prevent double hdf5 acces and incorrect generator_sample increment + with self.data_lock: + + # get next training sample + training_sample = self.indices[self.metadata['generator_sample'], :] + # get state + state = self.state_dataset[training_sample[0]] + # get action + action = self.action_dataset[training_sample[0]] + + # increment generator_sample + self.metadata['generator_sample'] += 1 + # shuffle indices when all have been used + if self.metadata['generator_sample'] >= self.indices_max: + self.shuffle_indices() + + # return state, action and transformation + return state, action, training_sample[1] + + def next(self): + state_batch_shape = (self.batch_size,) + self.state_dataset.shape[1:] + Xbatch = np.zeros(state_batch_shape) + Ybatch = np.zeros(self.batch_size) + + for batch_idx in xrange(self.batch_size): + state, action, transformation = self.next_indice() + + # get rotation symmetry belonging to state + transform = BOARD_TRANSFORMATIONS[transformation] + + # get state from dataset and transform it. + # loop comprehension is used so that the transformation acts on the + # 3rd and 4th dimensions + state_transform = np.array([transform(plane) for plane in state]) + + Xbatch[batch_idx] = state_transform + Ybatch[batch_idx] = action + + return (Xbatch, Ybatch) + + +class LrDecayCallback(Callback): + """Set learning rate every batch according to: + initial_learning_rate * (1. / (1. + self.decay * curent_batch)) + """ + + def __init__(self, learning_rate, decay): + super(LrDecayCallback, self).__init__() + self.learning_rate = learning_rate + self.decay = decay + + def set_lr(self): + # calculate learning rate + batch = self.model.optimizer.current_batch + new_lr = self.learning_rate * (1. / (1. + self.decay * batch)) + + # set new learning rate + K.set_value(self.model.optimizer.lr, new_lr) + + def on_train_begin(self, logs={}): + # set initial learning rate + self.set_lr() + + def on_batch_begin(self, batch, logs={}): + # using batch is not usefull as it starts at 0 every epoch + + # set new learning rate + self.set_lr() + + # increment current_batch + # increment current_batch in LrDecayCallback because order of activation + # can differ and incorrect current_batch would be used + self.model.optimizer.current_batch += 1 + + +class LrStepDecayCallback(Callback): + """Set learning rate every decay_every batches according to: + initial_learning_rate * (decay ^ (current_batch / decay every)) + """ + + def __init__(self, learning_rate, decay_every, decay, verbose): + super(LrStepDecayCallback, self).__init__() + self.learning_rate = learning_rate + self.decay_every = decay_every + self.verbose = verbose + self.decay = decay + + def set_lr(self): + # calculate learning rate + n_decay = int(self.model.optimizer.current_batch / self.decay_every) + new_lr = self.learning_rate * (self.decay ** n_decay) + + # set new learning rate + K.set_value(self.model.optimizer.lr, new_lr) + + # print new learning rate if verbose + if self.verbose: + print("\nBatch: " + str(self.model.optimizer.current_batch) + + " New learning rate: " + str(new_lr)) + + def on_train_begin(self, logs={}): + # set initial learning rate + self.set_lr() + + def on_batch_begin(self, batch, logs={}): + # using batch is not usefull as it starts at 0 every epoch + + # check if learning rate has to change + # - if we reach a new decay_every batch + if self.model.optimizer.current_batch % self.decay_every == 0: + # change learning rate + self.set_lr() + + # increment current_batch + # increment current_batch in LrSchedulerCallback because order of activation + # can differ and incorrect current_batch would be used + self.model.optimizer.current_batch += 1 + + +class EpochDataSaverCallback(Callback): + """Set current batch at training start + Save metadata and Model after every epoch + """ + + def __init__(self, path, root, metadata): + super(EpochDataSaverCallback, self).__init__() + self.file = path + self.root = root + self.metadata = metadata + + def on_train_begin(self, logs={}): + # set current_batch from metadata + self.model.optimizer.current_batch = self.metadata['current_batch'] + + def on_epoch_end(self, epoch, logs={}): + # in case appending to logs (resuming training), get epoch number ourselves + epoch = len(self.metadata["epoch_logs"]) + + # append log to metadata + self.metadata["epoch_logs"].append(logs) + # save current_batch to metadata + self.metadata["current_batch"] = self.model.optimizer.current_batch + # save current epoch + self.metadata["current_epoch"] = epoch + + if "val_loss" in logs: + key = "val_loss" + else: + key = "loss" + + best_loss = self.metadata["epoch_logs"][self.metadata["best_epoch"]][key] + if logs.get(key) < best_loss: + self.metadata["best_epoch"] = epoch + + # save meta to file + with open(self.file, "w") as f: + json.dump(self.metadata, f, indent=2) + + # save model to file with correct epoch + save_file = os.path.join(self.root, FOLDER_WEIGHT, + "weights.{epoch:05d}.hdf5".format(epoch=epoch)) + self.model.save(save_file) + + +def validate_feature_planes(verbose, dataset, model_features): + """Verify that dataset's features match the model's expected features. + """ + + if 'features' in dataset: + dataset_features = dataset['features'][()] + dataset_features = dataset_features.split(",") + if len(dataset_features) != len(model_features) or \ + any(df != mf for (df, mf) in zip(dataset_features, model_features)): + raise ValueError("Model JSON file expects features \n\t%s\n" + "But dataset contains \n\t%s" % ("\n\t".join(model_features), + "\n\t".join(dataset_features))) + elif verbose: + print("Verified that dataset features and model features exactly match.") + else: + # Cannot check each feature, but can check number of planes. + n_dataset_planes = dataset["states"].shape[1] + tmp_preprocess = Preprocess(model_features) + n_model_planes = tmp_preprocess.get_output_dimension() + if n_dataset_planes != n_model_planes: + raise ValueError("Model JSON file expects a total of %d planes from features \n\t%s\n" + "But dataset contains %d planes" % (n_model_planes, + "\n\t".join(model_features), + n_dataset_planes)) + elif verbose: + print("Verified agreement of number of model and dataset feature planes, but cannot " + "verify exact match using old dataset format.") + + +def load_indices_from_file(shuffle_file): + # load indices from shuffle_file + with open(shuffle_file, "r") as f: + indices = np.load(f) + + return indices + + +def save_indices_to_file(shuffle_file, indices): + # save indices to shuffle_file + with open(shuffle_file, "w") as f: + np.save(f, indices) + + +def remove_unused_symmetries(indices, symmetries): + # remove all rows with a symmetry not in symmetries + remove = [] + + # find all rows with incorrect symmetries + for row in range(len(indices)): + if not indices[row][1] in symmetries: + remove.append(row) + + # remove rows and return new array + return np.delete(indices, remove, 0) + + +def create_and_save_shuffle_indices(train_val_test, max_validation, + n_total_data_size, shuffle_file_train, + shuffle_file_val, shuffle_file_test): + """ create an array with all unique state and symmetry pairs, + calculate test/validation/training set sizes, + seperate those sets and save them to seperate files. + """ + + symmetries = TRANSFORMATION_INDICES.values() + + # Create an array with a unique row for each combination of a training example + # and a symmetry. + # shuffle_indices[i][0] is an index into training examples, + # shuffle_indices[i][1] is the index (from 0 to 7) of the symmetry transformation to apply + shuffle_indices = np.empty(shape=[n_total_data_size * len(symmetries), 2], dtype=int) + for dataset_idx in range(n_total_data_size): + for symmetry_idx in range(len(symmetries)): + shuffle_indices[dataset_idx * len(symmetries) + symmetry_idx][0] = dataset_idx + shuffle_indices[dataset_idx * len(symmetries) + + symmetry_idx][1] = symmetries[symmetry_idx] + + # shuffle rows without affecting x,y pairs + np.random.shuffle(shuffle_indices) + + # validation set size + n_val_data = int(train_val_test[1] * len(shuffle_indices)) + # limit validation set to --max-validation + if n_val_data > max_validation: + n_val_data = max_validation + + # test set size + n_test_data = int(train_val_test[2] * len(shuffle_indices)) + + # train set size + n_train_data = len(shuffle_indices) - n_val_data - n_test_data + + # create training set and save to file shuffle_file_train + train_indices = shuffle_indices[0:n_train_data] + save_indices_to_file(shuffle_file_train, train_indices) + + # create validation set and save to file shuffle_file_val + val_indices = shuffle_indices[n_train_data:n_train_data + n_val_data] + save_indices_to_file(shuffle_file_val, val_indices) + + # create test set and save to file shuffle_file_test + test_indices = shuffle_indices[n_train_data + n_val_data: + n_train_data + n_val_data + n_test_data] + save_indices_to_file(shuffle_file_test, test_indices) + + +def load_train_val_test_indices(verbose, arg_symmetries, dataset_length, batch_size, directory): + """Load indices from .npz files + Remove unwanted symmerties + Make Train set dividable by batch_size + Return train/val/test set + """ + # shuffle file locations for train/validation/test set + shuffle_file_train = os.path.join(directory, FILE_TRAIN) + shuffle_file_val = os.path.join(directory, FILE_VALIDATE) + shuffle_file_test = os.path.join(directory, FILE_TEST) + + # load from .npz files + train_indices = load_indices_from_file(shuffle_file_train) + val_indices = load_indices_from_file(shuffle_file_val) + test_indices = load_indices_from_file(shuffle_file_test) + + # used symmetries + if arg_symmetries == "all": + # add all symmetries + symmetries = TRANSFORMATION_INDICES.values() + elif arg_symmetries == "none": + # only add standart orientation + symmetries = [TRANSFORMATION_INDICES["noop"]] + else: + # add specified symmetries + symmetries = [TRANSFORMATION_INDICES[name] for name in arg_symmetries.strip().split(",")] + + if verbose: + print("Used symmetries: " + arg_symmetries) + + # remove symmetries not used during current run + if len(symmetries) != len(TRANSFORMATION_INDICES): + train_indices = remove_unused_symmetries(train_indices, symmetries) + test_indices = remove_unused_symmetries(test_indices, symmetries) + val_indices = remove_unused_symmetries(val_indices, symmetries) + + # Need to make sure training data is dividable by minibatch size or get + # warning mentioning accuracy from keras + if len(train_indices) % batch_size != 0: + # remove first len(train_indices) % args.minibatch rows + train_indices = np.delete(train_indices, [row for row in range(len(train_indices) + % batch_size)], 0) + + if verbose: + print("dataset loaded") + print("\t%d total positions" % dataset_length) + print("\t%d total samples" % (dataset_length * len(symmetries))) + print("\t%d total samples check" % (len(train_indices) + + len(val_indices) + len(test_indices))) + print("\t%d training samples" % len(train_indices)) + print("\t%d validation samples" % len(val_indices)) + print("\t%d test samples" % len(test_indices)) + + return train_indices, val_indices, test_indices + + +def set_training_settings(resume, args, metadata, dataset_length): + """ save all args to metadata, + check if critical settings have been changed and prompt user about it. + create new shuffle files if needed. + """ + + # shuffle file locations for train/validation/test set + shuffle_file_train = os.path.join(args.out_directory, FILE_TRAIN) + shuffle_file_val = os.path.join(args.out_directory, FILE_VALIDATE) + shuffle_file_test = os.path.join(args.out_directory, FILE_TEST) + + # determine if new shuffle files have to be created + save_new_shuffle_indices = not resume + + # save current symmetries to metadata + metadata["symmetries"] = args.symmetries + + if resume: + # check if argument model and meta model are the same + if metadata["model_file"] != args.model: + # verify if user really wants to use new model file + print("the model file is different from the model file used last run: " + + metadata["model_file"] + ". It might be different than the old one.") + if args.override or not confirm("Are you sure you want to use the new model?", False): # noqa: E501 + raise ValueError("User abort after mismatch model files.") + + # check if decay_every is the same + if args.decay_every is not None and metadata["decay_every"] != args.decay_every: + print("Setting a new --decay-every might result in a different learning rate, restarting training might be advisable.") # noqa: E501 + if args.override or confirm("Are you sure you want to use new decay every setting?", False): # noqa: E501 + metadata["decay_every"] = args.decay_every + + # check if learning_rate is the same + if args.learning_rate is not None and metadata["learning_rate"] != args.learning_rate: + print("Setting a new --learning-rate might result in a different learning rate, restarting training might be advisable.") # noqa: E501 + if args.override or confirm("Are you sure you want to use new learning rate setting?", False): # noqa: E501 + metadata["learning_rate"] = args.learning_rate + + # check if decay is the same + if args.decay is not None and metadata["decay"] != args.decay: + print("Setting a new --decay might result in a different learning rate, restarting training might be advisable.") # noqa: E501 + if args.override or confirm("Are you sure you want to use new decay setting?", False): # noqa: E501 + metadata["decay"] = args.decay + + # check if batch_size is the same + if args.minibatch is not None and metadata["batch_size"] != args.minibatch: + print("current_batch will be recalculated, restarting training might be advisable.") + if args.override or confirm("Are you sure you want to use new minibatch setting?", False): # noqa: E501 + metadata["current_batch"] = int((metadata["current_batch"] * + metadata["batch_size"]) / args.minibatch) + metadata["batch_size"] = args.minibatch + + # check if max_validation is the same + if args.max_validation is not None and metadata["max_validation"] != args.max_validation: + print("Training set and validation set should be stricktly separated, setting a new fraction will generate a new validation set that might include data from the current traing set.") # noqa: E501 + print("We reccommend you not to use the new max-validation setting.") + if args.override or confirm("Are you sure you want to use new max-validation setting?", False): # noqa: E501 + metadata["max_validation"] = args.max_validation + # new shuffle files have to be created + save_new_shuffle_indices = True + + # check if train_val_test is the same + if args.train_val_test is not None and metadata["train_val_test"] != args.train_val_test: + print("Training set and validation set should be stricktly separated, setting a new fraction will generate a new validation set that might include data from the current traing set.") # noqa: E501 + print("We reccommend you not to use the new fraction.") + if args.override or confirm("Are you sure you want to use new train-val-test fraction setting?", False): # noqa: E501 + metadata["train_val_test"] = args.train_val_test + # new shuffle files have to be created + save_new_shuffle_indices = True + + # check if epochs is the same + if args.epochs is not None and metadata["epochs"] != args.epochs: + metadata["epochs"] = args.epochs + + # check if epoch_length is the same + if args.epoch_length is not None and metadata["epoch_length"] != args.epoch_length: + metadata["epoch_length"] = args.epoch_length + + # check if shuffle files exist and data file is the same + if not os.path.exists(shuffle_file_train) or not os.path.exists(shuffle_file_val) or \ + not os.path.exists(shuffle_file_test) or \ + metadata["training_data"] != args.train_data or \ + metadata["available_states"] != dataset_length: + # shuffle files do not exist or training data file has changed + print("WARNING! shuffle files have to be recreated.") + save_new_shuffle_indices = True + else: + # save all argument or default settings to metadata + + # save used model file to metadata + metadata["model_file"] = args.model + + # save decay_every to metadata + metadata["decay_every"] = args.decay_every + + # save learning_rate to metadata + if args.learning_rate is not None: + metadata["learning_rate"] = args.learning_rate + else: + metadata["learning_rate"] = DEFAULT_LEARNING_RATE + + # save decay to metadata + if args.decay is not None: + metadata["decay"] = args.decay + else: + metadata["decay"] = DEFAULT_DECAY + + # save batch_size to metadata + if args.minibatch is not None: + metadata["batch_size"] = args.minibatch + else: + metadata["batch_size"] = DEFAULT_BATCH_SIZE + + # save max_validation to metadata + if args.max_validation is not None: + metadata["max_validation"] = args.max_validation + else: + metadata["max_validation"] = DEFAULT_MAX_VALIDATION + + # save train_val_test to metadata + if args.train_val_test is not None: + metadata["train_val_test"] = args.train_val_test + else: + metadata["train_val_test"] = DEFAULT_TRAIN_VAL_TEST + + # save epochs to metadata + if args.epochs is not None: + metadata["epochs"] = args.epochs + else: + metadata["epochs"] = DEFAULT_EPOCH + + # save epoch_length to metadata + if args.epoch_length is not None: + metadata["epoch_length"] = args.epoch_length + else: + metadata["epoch_length"] = dataset_length + + # Record all command line args in a list so that all args are recorded even + # when training is stopped and resumed. + # TODO remove function argument from args... or do not save args to metadata + # meta_args_data = metadata.get("cmd_line_args", []) + # meta_args_data.append(vars(args)) + # metadata["cmd_line_args"] = meta_args_data + + # create and save new shuffle indices if needed + if save_new_shuffle_indices: + # create and save new shuffle indices to file + create_and_save_shuffle_indices( + metadata["train_val_test"], metadata["max_validation"], dataset_length, + shuffle_file_train, shuffle_file_val, shuffle_file_test) + + # save total amount of states to metadata + metadata["available_states"] = dataset_length + + # save training data file name to metadata + metadata["training_data"] = args.train_data + + if args.verbose: + print("created new data shuffling indices") + + +def train(metadata, out_directory, verbose, weight_file, meta_file): + # set resume + resume = weight_file is not None + + # load model from json spec + policy = CNNValue.load_model(metadata["model_file"]) + model_features = policy.preprocessor.get_feature_list() + model = policy.model + # load weights + if resume: + model.load_weights(os.path.join(out_directory, FOLDER_WEIGHT, weight_file)) + + # features of training data + dataset = h5.File(metadata["training_data"]) + + # Verify that dataset's features match the model's expected features. + validate_feature_planes(verbose, dataset, model_features) + + # create metadata file and the callback object that will write to it + # and saves model at the same time + # the MetadataWriterCallback only sets 'epoch', 'best_epoch' and 'current_batch'. + # We can add in anything else we like here + meta_writer = EpochDataSaverCallback(meta_file, out_directory, metadata) + + # get train/validation/test indices + train_indices, val_indices, test_indices \ + = load_train_val_test_indices(verbose, metadata['symmetries'], len(dataset["states"]), + metadata["batch_size"], out_directory) + + # create dataset generators + train_data_generator = threading_shuffled_hdf5_batch_generator( + dataset["states"], + dataset["winners"], + train_indices, + metadata["batch_size"], + metadata) + val_data_generator = threading_shuffled_hdf5_batch_generator( + dataset["states"], + dataset["winners"], + val_indices, + metadata["batch_size"], + validation=True) + + # check if step decay has to be applied + if metadata["decay_every"] is None: + # use normal decay without momentum + lr_scheduler_callback = LrDecayCallback(metadata["learning_rate"], + metadata["decay"]) + else: + # use step decay + lr_scheduler_callback = LrStepDecayCallback(metadata["learning_rate"], + metadata["decay_every"], + metadata["decay"], verbose) + + sgd = SGD(lr=metadata["learning_rate"]) + model.compile(loss='mean_squared_error', optimizer=sgd, metrics=["accuracy"]) + + if verbose: + print("STARTING TRAINING") + + # check that remaining epoch > 0 + if metadata["epochs"] <= len(metadata["epoch_logs"]): + raise ValueError("No more epochs to train!") + + model.fit_generator( + generator=train_data_generator, + samples_per_epoch=metadata["epoch_length"], + nb_epoch=(metadata["epochs"] - len(metadata["epoch_logs"])), + callbacks=[meta_writer, lr_scheduler_callback], + validation_data=val_data_generator, + nb_val_samples=len(val_indices)) + + +def start_training(args): + # set resume + resume = args.weights is not None + + if args.verbose: + if resume: + print("trying to resume from %s with weights %s" % + (args.out_directory, + os.path.join(args.out_directory, FOLDER_WEIGHT, args.weights))) + else: + if os.path.exists(args.out_directory): + print("directory %s exists. any previous data will be overwritten" % + args.out_directory) + else: + print("starting fresh output directory %s" % args.out_directory) + + # create all directories + # main folder + if not os.path.exists(args.out_directory): + os.makedirs(args.out_directory) + + # create supervised weight file folder + weight_folder = os.path.join(args.out_directory, FOLDER_WEIGHT) + if not os.path.exists(weight_folder): + os.makedirs(weight_folder) + + # metadata json file location + meta_file = os.path.join(args.out_directory, FILE_METADATA) + + # create or load metadata from json file + if resume and os.path.exists(meta_file): + # load metadata + with open(meta_file, "r") as f: + metadata = json.load(f) + + if args.verbose: + print("previous metadata loaded: %d epochs. new epochs will be appended." % + len(metadata["epoch_logs"])) + else: + # create new metadata + metadata = { + "epoch_logs": [], + "current_batch": 0, + "current_epoch": 0, + "best_epoch": 0, + "generator_seed": None, + "generator_sample": 0 + } + + if args.verbose: + print("starting with empty metadata") + + # features of training data + dataset = h5.File(args.train_data) + + # set all settings: default, from args or from metadata + # generate new shuffle files if needed + set_training_settings(resume, args, metadata, len(dataset["states"])) + + # start training + train(metadata, args.out_directory, args.verbose, None, meta_file) + + +def resume_training(args): + # metadata json file location + meta_file = os.path.join(args.out_directory, FILE_METADATA) + + # load data from json file + if os.path.exists(meta_file): + with open(meta_file, "r") as f: + metadata = json.load(f) + else: + raise ValueError("Metadata file not found!") + + # determine what weight file to use + if args.weights is None: + # newest epoch weight file from json + weight_file = "weights.{epoch:05d}.hdf5".format(epoch=metadata["current_epoch"]) + else: + # user weight argument + weight_file = args.weights + + # check if training epochs has changed + if args.epochs is not None: + metadata["epochs"] = args.epochs + + if args.verbose: + print("trying to resume training from %s with weights %s" % + (meta_file, os.path.join(args.out_directory, FOLDER_WEIGHT, weight_file))) + + # start training + train(metadata, args.out_directory, args.verbose, weight_file, meta_file) + + +def handle_arguments(cmd_line_args=None): + """Run training. command-line args may be passed in as a list + """ + + import argparse + parser = argparse.ArgumentParser(description='Perform reinforcement training on a value network.') # noqa: E501 + # subparser is always first argument + subparsers = parser.add_subparsers(help='sub-command help') + + # sub parser start training + train = subparsers.add_parser('train', help='Start or resume reinforcement training on a value network.') # noqa: E501 + # required arguments + train.add_argument("out_directory", help="directory where metadata and weights will be saved") # noqa: E501 + train.add_argument("model", help="Path to a JSON model file (i.e. from CNNValue.save_model())") # noqa: E501 + train.add_argument("train_data", help="A .h5 file of training data") + # frequently used args + train.add_argument("--verbose", "-v", help="Turn on verbose mode", default=False, action="store_true") # noqa: E501 + train.add_argument("--minibatch", "-B", help="Size of training data minibatches. Default: " + str(DEFAULT_BATCH_SIZE), type=int, default=None) # noqa: E501 + train.add_argument("--epochs", "-E", help="Total number of iterations on the data. Default: " + str(DEFAULT_EPOCH), type=int, default=None) # noqa: E501 + train.add_argument("--epoch-length", "-l", help="Number of training examples considered 'one epoch'. Default: # training data", type=int, default=None) # noqa: E501 + train.add_argument("--learning-rate", "-r", help="Learning rate - how quickly the model learns at first. Default: " + str(DEFAULT_LEARNING_RATE), type=float, default=None) # noqa: E501 + train.add_argument("--decay", "-d", help=("The rate at which learning decreases. Default: " + str(DEFAULT_DECAY)), type=float, default=None) # noqa: E501 + train.add_argument("--decay-every", "-de", help="Use step-decay: decay --learning-rate with --decay every --decay-every batches. Default: None", type=int, default=None) # noqa: E501 + train.add_argument("--override", help="Turn on prompt override mode", default=False, action="store_true") # noqa: E501 + # slightly fancier args + train.add_argument("--weights", help="Name of a .h5 weights file (in the output directory) to load to resume training", default=None) # noqa: E501 + train.add_argument("--train-val-test", help="Fraction of data to use for training/val/test. Must sum to 1. Default: " + str(DEFAULT_TRAIN_VAL_TEST), nargs=3, type=float, default=None) # noqa: E501 + train.add_argument("--max-validation", help="maximum validation set size. default: " + str(DEFAULT_MAX_VALIDATION), type=int, default=None) # noqa: E501 + train.add_argument("--symmetries", help="none, all or comma-separated list of transforms, subset of: noop,rot90,rot180,rot270,fliplr,flipud,diag1,diag2. Default: all", default='all') # noqa: E501 + # function to call when start training + train.set_defaults(func=start_training) + + # sub parser resume training + resume = subparsers.add_parser('resume', help='Resume reinforcement training on a value network. (Settings are loaded from savefile.)') # noqa: E501 + # required arguments + resume.add_argument("out_directory", help="directory where metadata and weight files where stored during previous session.") # noqa: E501 + # optional argument + resume.add_argument("--verbose", "-v", help="Turn on verbose mode", default=False, action="store_true") # noqa: E501 + resume.add_argument("--weights", help="Name of a .h5 weights file (in the output directory) to load to resume training. Default: #Newest weight file.", default=None) # noqa: E501 + resume.add_argument("--epochs", "-E", help="Total number of iterations on the data. Defaukt: #Epochs set on previous run", type=int, default=None) # noqa: E501 + # function to call when resume training + resume.set_defaults(func=resume_training) + + # show help or parse arguments + if cmd_line_args is None: + args = parser.parse_args() + else: + args = parser.parse_args(cmd_line_args) + + # execute function (train or resume) + args.func(args) + + +if __name__ == '__main__': + handle_arguments() diff --git a/AlphaGo/training/supervised_policy_trainer.py b/AlphaGo/training/supervised_policy_trainer.py index f23f5a8f4..aff4462cd 100644 --- a/AlphaGo/training/supervised_policy_trainer.py +++ b/AlphaGo/training/supervised_policy_trainer.py @@ -1,143 +1,296 @@ -import numpy as np import os -import h5py as h5 import json +import threading +import h5py as h5 +import numpy as np +from keras import backend as K +from AlphaGo.util import confirm from keras.optimizers import SGD -from keras.callbacks import ModelCheckpoint, Callback +from keras.callbacks import Callback from AlphaGo.models.policy import CNNPolicy from AlphaGo.preprocessing.preprocessing import Preprocess +# default settings +DEFAULT_MAX_VALIDATION = 1000000000 +DEFAULT_TRAIN_VAL_TEST = [.95, .05, .0] +DEFAULT_LEARNING_RATE = .003 +DEFAULT_BATCH_SIZE = 16 +DEFAULT_DECAY = .0000000125 +DEFAULT_EPOCH = 10 + +# metdata file +FILE_METADATA = 'metadata_policy_supervised.json' +# weight folder +FOLDER_WEIGHT = os.path.join('policy_supervised_weights') + +# shuffle files +FILE_VALIDATE = 'shuffle_policy_validate.npz' +FILE_TRAIN = 'shuffle_policy_train.npz' +FILE_TEST = 'shuffle_policy_test.npz' + +TRANSFORMATION_INDICES = { + "noop": 0, + "rot90": 1, + "rot180": 2, + "rot270": 3, + "fliplr": 4, + "flipud": 5, + "diag1": 6, + "diag2": 7 +} + +BOARD_TRANSFORMATIONS = { + 0: lambda feature: feature, + 1: lambda feature: np.rot90(feature, 1), + 2: lambda feature: np.rot90(feature, 2), + 3: lambda feature: np.rot90(feature, 3), + 4: lambda feature: np.fliplr(feature), + 5: lambda feature: np.flipud(feature), + 6: lambda feature: np.transpose(feature), + 7: lambda feature: np.fliplr(np.rot90(feature, 1)) +} + def one_hot_action(action, size=19): """Convert an (x,y) action into a size x size array of zeros with a 1 at x,y """ + categorical = np.zeros((size, size)) categorical[action] = 1 return categorical -def shuffled_hdf5_batch_generator(state_dataset, action_dataset, - indices, batch_size, transforms=[]): +class threading_shuffled_hdf5_batch_generator: """A generator of batches of training data for use with the fit_generator function - of Keras. Data is accessed in the order of the given indices for shuffling. + of Keras. Data is accessed in the order of the given indices for shuffling. + + it is threading safe but not multiprocessing therefore only use it with + pickle_safe=False when using multiple workers """ - state_batch_shape = (batch_size,) + state_dataset.shape[1:] - game_size = state_batch_shape[-1] - Xbatch = np.zeros(state_batch_shape) - Ybatch = np.zeros((batch_size, game_size * game_size)) - batch_idx = 0 - while True: - for data_idx in indices: - # choose a random transformation of the data (rotations/reflections of the board) - transform = np.random.choice(transforms) + + def shuffle_indices(self, seed=None, idx=0): + # set generator_sample to idx + self.idx = idx + + # check if seed is provided or generate random + if seed is None: + # create random seed + self.metadata['generator_seed'] = np.random.random_integers(4294967295) + + # feed numpy.random with seed in order to continue with certain batch + np.random.seed(self.metadata['generator_seed']) + # shuffle indices according to seed + if not self.validation: + np.random.shuffle(self.indices) + + def __init__(self, state_dataset, action_dataset, indices, batch_size, metadata=None, + validation=False): + self.action_dataset = action_dataset + self.state_dataset = state_dataset + # lock used for multithreaded workers + self.data_lock = threading.Lock() + self.indices_max = len(indices) + self.validation = validation + self.batch_size = batch_size + self.indices = indices + + if metadata is not None: + self.metadata = metadata + + # calculate generator idx + # total amount of processed samples + # current_batch * batch size + samples_processed = metadata['current_batch'] * metadata['batch_size'] + + # calculate current sample in current data epoch + # samples_processed modulo total amount of samples + idx = samples_processed % len(self.state_dataset) + else: + # create metadata object + self.metadata = { + "generator_seed": None + } + idx = 0 + + # shuffle indices + # when restarting generator_seed and generator_batch will + # reset generator to the same point as before + self.shuffle_indices(self.metadata['generator_seed'], idx) + + def __iter__(self): + return self + + def next_indice(self): + # use lock to prevent double hdf5 acces and incorrect generator_sample increment + with self.data_lock: + + # get next training sample + training_sample = self.indices[self.idx, :] + # get state + state = self.state_dataset[training_sample[0]] + # get action + # must be cast to a tuple so that it is interpreted as (x,y) not [(x,:), (y,:)] + action = tuple(self.action_dataset[training_sample[0]]) + + # increment generator_sample + self.idx += 1 + # shuffle indices when all have been used + if self.idx >= self.indices_max: + self.shuffle_indices() + + # return state, action and transformation + return state, action, training_sample[1] + + def next(self): + state_batch_shape = (self.batch_size,) + self.state_dataset.shape[1:] + game_size = state_batch_shape[-1] + Xbatch = np.zeros(state_batch_shape) + Ybatch = np.zeros((self.batch_size, game_size * game_size)) + + for batch_idx in xrange(self.batch_size): + state, action, transformation = self.next_indice() + + # get rotation symmetry belonging to state + transform = BOARD_TRANSFORMATIONS[transformation] + # get state from dataset and transform it. # loop comprehension is used so that the transformation acts on the # 3rd and 4th dimensions - state = np.array([transform(plane) for plane in state_dataset[data_idx]]) - # must be cast to a tuple so that it is interpreted as (x,y) not [(x,:), (y,:)] - action_xy = tuple(action_dataset[data_idx]) - action = transform(one_hot_action(action_xy, game_size)) - Xbatch[batch_idx] = state - Ybatch[batch_idx] = action.flatten() - batch_idx += 1 - if batch_idx == batch_size: - batch_idx = 0 - yield (Xbatch, Ybatch) + state_transform = np.array([transform(plane) for plane in state]) + action_transform = transform(one_hot_action(action, game_size)) + + Xbatch[batch_idx] = state_transform + Ybatch[batch_idx] = action_transform.flatten() + + return (Xbatch, Ybatch) + + +class LrDecayCallback(Callback): + """Set learning rate every batch according to: + initial_learning_rate * (1. / (1. + self.decay * curent_batch)) + """ + def __init__(self, metadata): + super(LrDecayCallback, self).__init__() + self.learning_rate = metadata["learning_rate"] + self.decay = metadata["decay"] + self.metadata = metadata -class MetadataWriterCallback(Callback): + def set_lr(self): + # calculate learning rate + new_lr = self.learning_rate * (1. / (1. + self.decay * self.metadata["current_batch"])) - def __init__(self, path): + # set new learning rate + K.set_value(self.model.optimizer.lr, new_lr) + + def on_train_begin(self, logs={}): + # set initial learning rate + self.set_lr() + + def on_batch_begin(self, batch, logs={}): + # using batch is not usefull as it starts at 0 every epoch + + # set new learning rate + self.set_lr() + + # increment current_batch + # increment current_batch in LrDecayCallback because order of activation + # can differ and incorrect current_batch would be used + self.metadata["current_batch"] += 1 + + +class LrStepDecayCallback(Callback): + """Set learning rate every decay_every batches according to: + initial_learning_rate * (decay ^ (current_batch / decay every)) + """ + + def __init__(self, metadata, verbose): + super(LrStepDecayCallback, self).__init__() + self.learning_rate = metadata["learning_rate"] + self.decay_every = metadata["decay_every"] + self.decay = metadata["decay"] + self.metadata = metadata + self.verbose = verbose + + def set_lr(self): + # calculate learning rate + n_decay = int(self.metadata["current_batch"] / self.decay_every) + new_lr = self.learning_rate * (self.decay ** n_decay) + + # set new learning rate + K.set_value(self.model.optimizer.lr, new_lr) + + # print new learning rate if verbose + if self.verbose: + print("\nBatch: " + str(self.metadata["current_batch"]) + + " New learning rate: " + str(new_lr)) + + def on_train_begin(self, logs={}): + # set initial learning rate + self.set_lr() + + def on_batch_begin(self, batch, logs={}): + # using batch is not usefull as it starts at 0 every epoch + + # check if learning rate has to change + # - if we reach a new decay_every batch + if self.metadata["current_batch"] % self.decay_every == 0: + # change learning rate + self.set_lr() + + # increment current_batch + # increment current_batch in LrSchedulerCallback because order of activation + # can differ and incorrect current_batch would be used + self.metadata["current_batch"] += 1 + + +class EpochDataSaverCallback(Callback): + """Set current batch at training start + Save metadata and Model after every epoch + """ + + def __init__(self, path, root, metadata): + super(EpochDataSaverCallback, self).__init__() self.file = path - self.metadata = { - "epochs": [], - "best_epoch": 0 - } + self.root = root + self.metadata = metadata + + def on_train_begin(self, logs={}): + # set current_batch from metadata + self.model.optimizer.current_batch = self.metadata['current_batch'] def on_epoch_end(self, epoch, logs={}): # in case appending to logs (resuming training), get epoch number ourselves - epoch = len(self.metadata["epochs"]) + epoch = len(self.metadata["epoch_logs"]) - self.metadata["epochs"].append(logs) + # append log to metadata + self.metadata["epoch_logs"].append(logs) + # save current epoch + self.metadata["current_epoch"] = epoch if "val_loss" in logs: key = "val_loss" else: key = "loss" - best_loss = self.metadata["epochs"][self.metadata["best_epoch"]][key] + best_loss = self.metadata["epoch_logs"][self.metadata["best_epoch"]][key] if logs.get(key) < best_loss: self.metadata["best_epoch"] = epoch + # save meta to file with open(self.file, "w") as f: json.dump(self.metadata, f, indent=2) - -BOARD_TRANSFORMATIONS = { - "noop": lambda feature: feature, - "rot90": lambda feature: np.rot90(feature, 1), - "rot180": lambda feature: np.rot90(feature, 2), - "rot270": lambda feature: np.rot90(feature, 3), - "fliplr": lambda feature: np.fliplr(feature), - "flipud": lambda feature: np.flipud(feature), - "diag1": lambda feature: np.transpose(feature), - "diag2": lambda feature: np.fliplr(np.rot90(feature, 1)) -} + # save model to file with correct epoch + save_file = os.path.join(self.root, FOLDER_WEIGHT, + "weights.{epoch:05d}.hdf5".format(epoch=epoch)) + self.model.save(save_file) -def run_training(cmd_line_args=None): - """Run training. command-line args may be passed in as a list +def validate_feature_planes(verbose, dataset, model_features): + """Verify that dataset's features match the model's expected features. """ - import argparse - parser = argparse.ArgumentParser(description='Perform supervised training on a policy network.') - # required args - parser.add_argument("model", help="Path to a JSON model file (i.e. from CNNPolicy.save_model())") # noqa: E501 - parser.add_argument("train_data", help="A .h5 file of training data") - parser.add_argument("out_directory", help="directory where metadata and weights will be saved") - # frequently used args - parser.add_argument("--minibatch", "-B", help="Size of training data minibatches. Default: 16", type=int, default=16) # noqa: E501 - parser.add_argument("--epochs", "-E", help="Total number of iterations on the data. Default: 10", type=int, default=10) # noqa: E501 - parser.add_argument("--epoch-length", "-l", help="Number of training examples considered 'one epoch'. Default: # training data", type=int, default=None) # noqa: E501 - parser.add_argument("--learning-rate", "-r", help="Learning rate - how quickly the model learns at first. Default: .03", type=float, default=.03) # noqa: E501 - parser.add_argument("--decay", "-d", help="The rate at which learning decreases. Default: .0001", type=float, default=.0001) # noqa: E501 - parser.add_argument("--verbose", "-v", help="Turn on verbose mode", default=False, action="store_true") # noqa: E501 - # slightly fancier args - parser.add_argument("--weights", help="Name of a .h5 weights file (in the output directory) to load to resume training", default=None) # noqa: E501 - parser.add_argument("--train-val-test", help="Fraction of data to use for training/val/test. Must sum to 1. Invalid if restarting training", nargs=3, type=float, default=[0.93, .05, .02]) # noqa: E501 - parser.add_argument("--symmetries", help="Comma-separated list of transforms, subset of noop,rot90,rot180,rot270,fliplr,flipud,diag1,diag2", default='noop,rot90,rot180,rot270,fliplr,flipud,diag1,diag2') # noqa: E501 - # TODO - an argument to specify which transformations to use, put it in metadata - - if cmd_line_args is None: - args = parser.parse_args() - else: - args = parser.parse_args(cmd_line_args) - - # TODO - what follows here should be refactored into a series of small functions - resume = args.weights is not None - - if args.verbose: - if resume: - print("trying to resume from %s with weights %s" % - (args.out_directory, os.path.join(args.out_directory, args.weights))) - else: - if os.path.exists(args.out_directory): - print("directory %s exists. any previous data will be overwritten" % - args.out_directory) - else: - print("starting fresh output directory %s" % args.out_directory) - - # load model from json spec - policy = CNNPolicy.load_model(args.model) - model_features = policy.preprocessor.feature_list - model = policy.model - if resume: - model.load_weights(os.path.join(args.out_directory, args.weights)) - - # features of training data - dataset = h5.File(args.train_data) - - # Verify that dataset's features match the model's expected features. if 'features' in dataset: dataset_features = dataset['features'][()] dataset_features = dataset_features.split(",") @@ -146,122 +299,525 @@ def run_training(cmd_line_args=None): raise ValueError("Model JSON file expects features \n\t%s\n" "But dataset contains \n\t%s" % ("\n\t".join(model_features), "\n\t".join(dataset_features))) - elif args.verbose: + elif verbose: print("Verified that dataset features and model features exactly match.") else: # Cannot check each feature, but can check number of planes. n_dataset_planes = dataset["states"].shape[1] tmp_preprocess = Preprocess(model_features) - n_model_planes = tmp_preprocess.output_dim + n_model_planes = tmp_preprocess.get_output_dimension() if n_dataset_planes != n_model_planes: raise ValueError("Model JSON file expects a total of %d planes from features \n\t%s\n" "But dataset contains %d planes" % (n_model_planes, "\n\t".join(model_features), n_dataset_planes)) - elif args.verbose: + elif verbose: print("Verified agreement of number of model and dataset feature planes, but cannot " "verify exact match using old dataset format.") - n_total_data = len(dataset["states"]) - n_train_data = int(args.train_val_test[0] * n_total_data) - # Need to make sure training data is divisible by minibatch size or get - # warning mentioning accuracy from keras - n_train_data = n_train_data - (n_train_data % args.minibatch) - n_val_data = n_total_data - n_train_data - # n_test_data = n_total_data - (n_train_data + n_val_data) - if args.verbose: - print("datset loaded") - print("\t%d total samples" % n_total_data) - print("\t%d training samples" % n_train_data) - print("\t%d validaion samples" % n_val_data) +def load_indices_from_file(shuffle_file): + # load indices from shuffle_file + with open(shuffle_file, "r") as f: + indices = np.load(f) - # ensure output directory is available - if not os.path.exists(args.out_directory): - os.makedirs(args.out_directory) + return indices + + +def save_indices_to_file(shuffle_file, indices): + # save indices to shuffle_file + with open(shuffle_file, "w") as f: + np.save(f, indices) + + +def remove_unused_symmetries(indices, symmetries): + # remove all rows with a symmetry not in symmetries + remove = [] + + # find all rows with incorrect symmetries + for row in range(len(indices)): + if not indices[row][1] in symmetries: + remove.append(row) + + # remove rows and return new array + return np.delete(indices, remove, 0) + + +def create_and_save_shuffle_indices(train_val_test, max_validation, + n_total_data_size, shuffle_file_train, + shuffle_file_val, shuffle_file_test): + """ create an array with all unique state and symmetry pairs, + calculate test/validation/training set sizes, + seperate those sets and save them to seperate files. + """ + + symmetries = TRANSFORMATION_INDICES.values() + + # Create an array with a unique row for each combination of a training example + # and a symmetry. + # shuffle_indices[i][0] is an index into training examples, + # shuffle_indices[i][1] is the index (from 0 to 7) of the symmetry transformation to apply + shuffle_indices = np.empty(shape=[n_total_data_size * len(symmetries), 2], dtype=int) + for dataset_idx in range(n_total_data_size): + for symmetry_idx in range(len(symmetries)): + shuffle_indices[dataset_idx * len(symmetries) + symmetry_idx][0] = dataset_idx + shuffle_indices[dataset_idx * len(symmetries) + + symmetry_idx][1] = symmetries[symmetry_idx] + + # shuffle rows without affecting x,y pairs + np.random.shuffle(shuffle_indices) + + # validation set size + n_val_data = int(train_val_test[1] * len(shuffle_indices)) + # limit validation set to --max-validation + if n_val_data > max_validation: + n_val_data = max_validation + + # test set size + n_test_data = int(train_val_test[2] * len(shuffle_indices)) + + # train set size + n_train_data = len(shuffle_indices) - n_val_data - n_test_data + + # create training set and save to file shuffle_file_train + train_indices = shuffle_indices[0:n_train_data] + save_indices_to_file(shuffle_file_train, train_indices) + + # create validation set and save to file shuffle_file_val + val_indices = shuffle_indices[n_train_data:n_train_data + n_val_data] + save_indices_to_file(shuffle_file_val, val_indices) + + # create test set and save to file shuffle_file_test + test_indices = shuffle_indices[n_train_data + n_val_data: + n_train_data + n_val_data + n_test_data] + save_indices_to_file(shuffle_file_test, test_indices) + + +def load_train_val_test_indices(verbose, arg_symmetries, dataset_length, batch_size, directory): + """Load indices from .npz files + Remove unwanted symmerties + Make Train set dividable by batch_size + Return train/val/test set + """ + # shuffle file locations for train/validation/test set + shuffle_file_train = os.path.join(directory, FILE_TRAIN) + shuffle_file_val = os.path.join(directory, FILE_VALIDATE) + shuffle_file_test = os.path.join(directory, FILE_TEST) + + # load from .npz files + train_indices = load_indices_from_file(shuffle_file_train) + val_indices = load_indices_from_file(shuffle_file_val) + test_indices = load_indices_from_file(shuffle_file_test) + + # used symmetries + if arg_symmetries == "all": + # add all symmetries + symmetries = TRANSFORMATION_INDICES.values() + elif arg_symmetries == "none": + # only add standart orientation + symmetries = [TRANSFORMATION_INDICES["noop"]] + else: + # add specified symmetries + symmetries = [TRANSFORMATION_INDICES[name] for name in arg_symmetries.strip().split(",")] + + if verbose: + print("Used symmetries: " + arg_symmetries) + + # remove symmetries not used during current run + if len(symmetries) != len(TRANSFORMATION_INDICES): + train_indices = remove_unused_symmetries(train_indices, symmetries) + test_indices = remove_unused_symmetries(test_indices, symmetries) + val_indices = remove_unused_symmetries(val_indices, symmetries) + + if verbose: + print("dataset loaded") + print("\t%d total positions" % dataset_length) + print("\t%d total samples" % (dataset_length * len(symmetries))) + print("\t%d total samples check" % (len(train_indices) + + len(val_indices) + len(test_indices))) + print("\t%d training samples" % len(train_indices)) + print("\t%d validation samples" % len(val_indices)) + print("\t%d test samples" % len(test_indices)) + + return train_indices, val_indices, test_indices + + +def set_training_settings(resume, args, metadata, dataset_length): + """ save all args to metadata, + check if critical settings have been changed and prompt user about it. + create new shuffle files if needed. + """ + + # shuffle file locations for train/validation/test set + shuffle_file_train = os.path.join(args.out_directory, FILE_TRAIN) + shuffle_file_val = os.path.join(args.out_directory, FILE_VALIDATE) + shuffle_file_test = os.path.join(args.out_directory, FILE_TEST) + + # determine if new shuffle files have to be created + save_new_shuffle_indices = not resume + + # save current symmetries to metadata + metadata["symmetries"] = args.symmetries + + if resume: + # check if argument model and meta model are the same + if metadata["model_file"] != args.model: + # verify if user really wants to use new model file + print("the model file is different from the model file used last run: " + + metadata["model_file"] + ". It might be different than the old one.") + if args.override or not confirm("Are you sure you want to use the new model?", False): # noqa: E501 + raise ValueError("User abort after mismatch model files.") + + # check if decay_every is the same + if args.decay_every is not None and metadata["decay_every"] != args.decay_every: + print("Setting a new --decay-every might result in a different learning rate, restarting training might be advisable.") # noqa: E501 + if args.override or confirm("Are you sure you want to use new decay every setting?", False): # noqa: E501 + metadata["decay_every"] = args.decay_every + + # check if learning_rate is the same + if args.learning_rate is not None and metadata["learning_rate"] != args.learning_rate: + print("Setting a new --learning-rate might result in a different learning rate, restarting training might be advisable.") # noqa: E501 + if args.override or confirm("Are you sure you want to use new learning rate setting?", False): # noqa: E501 + metadata["learning_rate"] = args.learning_rate + + # check if decay is the same + if args.decay is not None and metadata["decay"] != args.decay: + print("Setting a new --decay might result in a different learning rate, restarting training might be advisable.") # noqa: E501 + if args.override or confirm("Are you sure you want to use new decay setting?", False): # noqa: E501 + metadata["decay"] = args.decay + + # check if batch_size is the same + if args.minibatch is not None and metadata["batch_size"] != args.minibatch: + print("current_batch will be recalculated, restarting training might be advisable.") + if args.override or confirm("Are you sure you want to use new minibatch setting?", False): # noqa: E501 + metadata["current_batch"] = int((metadata["current_batch"] * + metadata["batch_size"]) / args.minibatch) + metadata["batch_size"] = args.minibatch + + # check if max_validation is the same + if args.max_validation is not None and metadata["max_validation"] != args.max_validation: + print("Training set and validation set should be stricktly separated, setting a new fraction will generate a new validation set that might include data from the current traing set.") # noqa: E501 + print("We reccommend you not to use the new max-validation setting.") + if args.override or confirm("Are you sure you want to use new max-validation setting?", False): # noqa: E501 + metadata["max_validation"] = args.max_validation + # new shuffle files have to be created + save_new_shuffle_indices = True + + # check if train_val_test is the same + if args.train_val_test is not None and metadata["train_val_test"] != args.train_val_test: + print("Training set and validation set should be stricktly separated, setting a new fraction will generate a new validation set that might include data from the current traing set.") # noqa: E501 + print("We reccommend you not to use the new fraction.") + if args.override or confirm("Are you sure you want to use new train-val-test fraction setting?", False): # noqa: E501 + metadata["train_val_test"] = args.train_val_test + # new shuffle files have to be created + save_new_shuffle_indices = True + + # check if epochs is the same + if args.epochs is not None and metadata["epochs"] != args.epochs: + metadata["epochs"] = args.epochs + + # check if epoch_length is the same + if args.epoch_length is not None and metadata["epoch_length"] != args.epoch_length: + metadata["epoch_length"] = args.epoch_length + + # check if shuffle files exist and data file is the same + if not os.path.exists(shuffle_file_train) or not os.path.exists(shuffle_file_val) or \ + not os.path.exists(shuffle_file_test) or \ + metadata["training_data"] != args.train_data or \ + metadata["available_states"] != dataset_length: + # shuffle files do not exist or training data file has changed + print("WARNING! shuffle files have to be recreated.") + save_new_shuffle_indices = True + else: + # save all argument or default settings to metadata + + # save used model file to metadata + metadata["model_file"] = args.model + + # save decay_every to metadata + metadata["decay_every"] = args.decay_every + + # save learning_rate to metadata + if args.learning_rate is not None: + metadata["learning_rate"] = args.learning_rate + else: + metadata["learning_rate"] = DEFAULT_LEARNING_RATE + + # save decay to metadata + if args.decay is not None: + metadata["decay"] = args.decay + else: + metadata["decay"] = DEFAULT_DECAY + + # save batch_size to metadata + if args.minibatch is not None: + metadata["batch_size"] = args.minibatch + else: + metadata["batch_size"] = DEFAULT_BATCH_SIZE + + # save max_validation to metadata + if args.max_validation is not None: + metadata["max_validation"] = args.max_validation + else: + metadata["max_validation"] = DEFAULT_MAX_VALIDATION + + # save train_val_test to metadata + if args.train_val_test is not None: + metadata["train_val_test"] = args.train_val_test + else: + metadata["train_val_test"] = DEFAULT_TRAIN_VAL_TEST + + # save epochs to metadata + if args.epochs is not None: + metadata["epochs"] = args.epochs + else: + metadata["epochs"] = DEFAULT_EPOCH + + # save epoch_length to metadata + if args.epoch_length is not None: + metadata["epoch_length"] = args.epoch_length + else: + metadata["epoch_length"] = dataset_length - # create metadata file and the callback object that will write to it - meta_file = os.path.join(args.out_directory, "metadata.json") - meta_writer = MetadataWriterCallback(meta_file) - # load prior data if it already exists - if os.path.exists(meta_file) and resume: - with open(meta_file, "r") as f: - meta_writer.metadata = json.load(f) - if args.verbose: - print("previous metadata loaded: %d epochs. new epochs will be appended." % - len(meta_writer.metadata["epochs"])) - elif args.verbose: - print("starting with empty metadata") - # the MetadataWriterCallback only sets 'epoch' and 'best_epoch'. We can add - # in anything else we like here - # - # TODO - model and train_data are saved in meta_file; check that they match - # (and make args optional when restarting?) - meta_writer.metadata["training_data"] = args.train_data - meta_writer.metadata["model_file"] = args.model # Record all command line args in a list so that all args are recorded even # when training is stopped and resumed. - meta_writer.metadata["cmd_line_args"] = meta_writer.metadata.get("cmd_line_args", []) - meta_writer.metadata["cmd_line_args"].append(vars(args)) - - # create ModelCheckpoint to save weights every epoch - checkpoint_template = os.path.join(args.out_directory, "weights.{epoch:05d}.hdf5") - checkpointer = ModelCheckpoint(checkpoint_template) - - # load precomputed random-shuffle indices or create them - # TODO - save each train/val/test indices separately so there's no danger of - # changing args.train_val_test when resuming - shuffle_file = os.path.join(args.out_directory, "shuffle.npz") - if os.path.exists(shuffle_file) and resume: - with open(shuffle_file, "r") as f: - shuffle_indices = np.load(f) - if args.verbose: - print("loading previous data shuffling indices") - else: - # create shuffled indices - shuffle_indices = np.random.permutation(n_total_data) - with open(shuffle_file, "w") as f: - np.save(f, shuffle_indices) + # TODO remove function argument from args... or do not save args to metadata + # meta_args_data = metadata.get("cmd_line_args", []) + # meta_args_data.append(vars(args)) + # metadata["cmd_line_args"] = meta_args_data + + # create and save new shuffle indices if needed + if save_new_shuffle_indices: + # create and save new shuffle indices to file + create_and_save_shuffle_indices( + metadata["train_val_test"], metadata["max_validation"], dataset_length, + shuffle_file_train, shuffle_file_val, shuffle_file_test) + + # save total amount of states to metadata + metadata["available_states"] = dataset_length + + # save training data file name to metadata + metadata["training_data"] = args.train_data + if args.verbose: print("created new data shuffling indices") - # training indices are the first consecutive set of shuffled indices, val - # next, then test gets the remainder - train_indices = shuffle_indices[0:n_train_data] - val_indices = shuffle_indices[n_train_data:n_train_data + n_val_data] - # test_indices = shuffle_indices[n_train_data + n_val_data:] - symmetries = [BOARD_TRANSFORMATIONS[name] for name in args.symmetries.strip().split(",")] + +def train(metadata, out_directory, verbose, weight_file, meta_file): + # set resume + resume = weight_file is not None + + # load model from json spec + policy = CNNPolicy.load_model(metadata["model_file"]) + model_features = policy.preprocessor.get_feature_list() + model = policy.model + # load weights + if resume: + model.load_weights(os.path.join(out_directory, FOLDER_WEIGHT, weight_file)) + + # features of training data + dataset = h5.File(metadata["training_data"]) + + # Verify that dataset's features match the model's expected features. + validate_feature_planes(verbose, dataset, model_features) + + # create metadata file and the callback object that will write to it + # and saves model at the same time + # the MetadataWriterCallback only sets 'epoch', 'best_epoch' and 'current_batch'. + # We can add in anything else we like here + meta_writer = EpochDataSaverCallback(meta_file, out_directory, metadata) + + # get train/validation/test indices + train_indices, val_indices, test_indices \ + = load_train_val_test_indices(verbose, metadata['symmetries'], len(dataset["states"]), + metadata["batch_size"], out_directory) # create dataset generators - train_data_generator = shuffled_hdf5_batch_generator( + train_data_generator = threading_shuffled_hdf5_batch_generator( dataset["states"], dataset["actions"], train_indices, - args.minibatch, - symmetries) - val_data_generator = shuffled_hdf5_batch_generator( + metadata["batch_size"], + metadata) + val_data_generator = threading_shuffled_hdf5_batch_generator( dataset["states"], dataset["actions"], val_indices, - args.minibatch, - symmetries) + metadata["batch_size"], + validation=True) - sgd = SGD(lr=args.learning_rate, decay=args.decay) - model.compile(loss='categorical_crossentropy', optimizer=sgd, metrics=["accuracy"]) + # check if step decay has to be applied + if metadata["decay_every"] is None: + # use normal decay without momentum + lr_scheduler_callback = LrDecayCallback(metadata) + else: + # use step decay + lr_scheduler_callback = LrStepDecayCallback(metadata, verbose) - samples_per_epoch = args.epoch_length or n_train_data + sgd = SGD(lr=metadata["learning_rate"]) + model.compile(loss='categorical_crossentropy', optimizer=sgd, metrics=["accuracy"]) - if args.verbose: + if verbose: print("STARTING TRAINING") + # check that remaining epoch > 0 + if metadata["epochs"] <= len(metadata["epoch_logs"]): + raise ValueError("No more epochs to train!") + model.fit_generator( generator=train_data_generator, - samples_per_epoch=samples_per_epoch, - nb_epoch=args.epochs, - callbacks=[checkpointer, meta_writer], + samples_per_epoch=metadata["epoch_length"], + nb_epoch=(metadata["epochs"] - len(metadata["epoch_logs"])), + callbacks=[meta_writer, lr_scheduler_callback], validation_data=val_data_generator, - nb_val_samples=n_val_data) + nb_val_samples=len(val_indices)) + + +def start_training(args): + # set resume + resume = args.weights is not None + + if args.verbose: + if resume: + print("trying to resume from %s with weights %s" % + (args.out_directory, + os.path.join(args.out_directory, FOLDER_WEIGHT, args.weights))) + else: + if os.path.exists(args.out_directory): + print("directory %s exists. any previous data will be overwritten" % + args.out_directory) + else: + print("starting fresh output directory %s" % args.out_directory) + + # create all directories + # main folder + if not os.path.exists(args.out_directory): + os.makedirs(args.out_directory) + + # create supervised weight file folder + weight_folder = os.path.join(args.out_directory, FOLDER_WEIGHT) + if not os.path.exists(weight_folder): + os.makedirs(weight_folder) + + # metadata json file location + meta_file = os.path.join(args.out_directory, FILE_METADATA) + + # create or load metadata from json file + if resume and os.path.exists(meta_file): + # load metadata + with open(meta_file, "r") as f: + metadata = json.load(f) + + if args.verbose: + print("previous metadata loaded: %d epochs. new epochs will be appended." % + len(metadata["epoch_logs"])) + else: + # create new metadata + metadata = { + "epoch_logs": [], + "current_batch": 0, + "current_epoch": 0, + "best_epoch": 0, + "generator_seed": None + } + + if args.verbose: + print("starting with empty metadata") + + # features of training data + dataset = h5.File(args.train_data) + + # set all settings: default, from args or from metadata + # generate new shuffle files if needed + set_training_settings(resume, args, metadata, len(dataset["states"])) + + # start training + train(metadata, args.out_directory, args.verbose, None, meta_file) + + +def resume_training(args): + # metadata json file location + meta_file = os.path.join(args.out_directory, FILE_METADATA) + + # load data from json file + if os.path.exists(meta_file): + with open(meta_file, "r") as f: + metadata = json.load(f) + else: + raise ValueError("Metadata file not found!") + + # determine what weight file to use + if args.weights is None: + # newest epoch weight file from json + weight_file = "weights.{epoch:05d}.hdf5".format(epoch=metadata["current_epoch"]) + else: + # user weight argument + weight_file = args.weights + + # check if training epochs has changed + if args.epochs is not None: + metadata["epochs"] = args.epochs + + if args.verbose: + print("trying to resume training from %s with weights %s" % + (meta_file, os.path.join(args.out_directory, FOLDER_WEIGHT, weight_file))) + + # start training + train(metadata, args.out_directory, args.verbose, weight_file, meta_file) + + +def handle_arguments(cmd_line_args=None): + """Run training. command-line args may be passed in as a list + """ + + import argparse + parser = argparse.ArgumentParser(description='Perform supervised training on a policy network.') + # subparser is always first argument + subparsers = parser.add_subparsers(help='sub-command help') + + # sub parser start training + train = subparsers.add_parser('train', help='Start or resume supervised training on a policy network.') # noqa: E501 + # required arguments + train.add_argument("out_directory", help="directory where metadata and weights will be saved") # noqa: E501 + train.add_argument("model", help="Path to a JSON model file (i.e. from CNNPolicy.save_model())") # noqa: E501 + train.add_argument("train_data", help="A .h5 file of training data") + # frequently used args + train.add_argument("--verbose", "-v", help="Turn on verbose mode", default=False, action="store_true") # noqa: E501 + train.add_argument("--minibatch", "-B", help="Size of training data minibatches. Default: " + str(DEFAULT_BATCH_SIZE), type=int, default=None) # noqa: E501 + train.add_argument("--epochs", "-E", help="Total number of iterations on the data. Default: " + str(DEFAULT_EPOCH), type=int, default=None) # noqa: E501 + train.add_argument("--epoch-length", "-l", help="Number of training examples considered 'one epoch'. Default: # training data", type=int, default=None) # noqa: E501 + train.add_argument("--learning-rate", "-r", help="Learning rate - how quickly the model learns at first. Default: " + str(DEFAULT_LEARNING_RATE), type=float, default=None) # noqa: E501 + train.add_argument("--decay", "-d", help=("The rate at which learning decreases. Default: " + str(DEFAULT_DECAY)), type=float, default=None) # noqa: E501 + train.add_argument("--decay-every", "-de", help="Use step-decay: decay --learning-rate with --decay every --decay-every batches. Default: None", type=int, default=None) # noqa: E501 + train.add_argument("--override", help="Turn on prompt override mode", default=False, action="store_true") # noqa: E501 + # slightly fancier args + train.add_argument("--weights", help="Name of a .h5 weights file (in the output directory) to load to resume training", default=None) # noqa: E501 + train.add_argument("--train-val-test", help="Fraction of data to use for training/val/test. Must sum to 1. Default: " + str(DEFAULT_TRAIN_VAL_TEST), nargs=3, type=float, default=None) # noqa: E501 + train.add_argument("--max-validation", help="maximum validation set size. default: " + str(DEFAULT_MAX_VALIDATION), type=int, default=None) # noqa: E501 + train.add_argument("--symmetries", help="none, all or comma-separated list of transforms, subset of: noop,rot90,rot180,rot270,fliplr,flipud,diag1,diag2. Default: all", default='all') # noqa: E501 + # function to call when start training + train.set_defaults(func=start_training) + + # sub parser resume training + resume = subparsers.add_parser('resume', help='Resume supervised training on a policy network. (Settings are loaded from savefile.)') # noqa: E501 + # required arguments + resume.add_argument("out_directory", help="directory where metadata and weight files where stored during previous session.") # noqa: E501 + # optional argument + resume.add_argument("--verbose", "-v", help="Turn on verbose mode", default=False, action="store_true") # noqa: E501 + resume.add_argument("--weights", help="Name of a .h5 weights file (in the output directory) to load to resume training. Default: #Newest weight file.", default=None) # noqa: E501 + resume.add_argument("--epochs", "-E", help="Total number of iterations on the data. Defaukt: #Epochs set on previous run", type=int, default=None) # noqa: E501 + # function to call when resume training + resume.set_defaults(func=resume_training) + + # show help or parse arguments + if cmd_line_args is None: + args = parser.parse_args() + else: + args = parser.parse_args(cmd_line_args) + + # execute function (train or resume) + args.func(args) if __name__ == '__main__': - run_training() + handle_arguments() diff --git a/AlphaGo/training/supervised_rollout_trainer.py b/AlphaGo/training/supervised_rollout_trainer.py new file mode 100644 index 000000000..ef46a2b8e --- /dev/null +++ b/AlphaGo/training/supervised_rollout_trainer.py @@ -0,0 +1,833 @@ +import os +import json +import threading +import h5py as h5 +import numpy as np +from keras import backend as K +from AlphaGo.util import confirm +from keras.optimizers import Adam +from keras.callbacks import Callback +from AlphaGo.models.rollout import CNNRollout +from AlphaGo.preprocessing.preprocessing import Preprocess + +# default settings +DEFAULT_MAX_VALIDATION = 1000000000 +DEFAULT_TRAIN_VAL_TEST = [.95, .05, .0] +DEFAULT_LEARNING_RATE = .003 +DEFAULT_BATCH_SIZE = 16 +DEFAULT_DECAY = .0000000125 +DEFAULT_EPOCH = 10 + +# metdata file +FILE_METADATA = 'metadata_policy_supervised.json' +# weight folder +FOLDER_WEIGHT = os.path.join('policy_supervised_weights') + +# shuffle files +FILE_VALIDATE = 'shuffle_policy_validate.npz' +FILE_TRAIN = 'shuffle_policy_train.npz' +FILE_TEST = 'shuffle_policy_test.npz' + +TRANSFORMATION_INDICES = { + "noop": 0, + "rot90": 1, + "rot180": 2, + "rot270": 3, + "fliplr": 4, + "flipud": 5, + "diag1": 6, + "diag2": 7 +} + +BOARD_TRANSFORMATIONS = { + 0: lambda feature: feature, + 1: lambda feature: np.rot90(feature, 1), + 2: lambda feature: np.rot90(feature, 2), + 3: lambda feature: np.rot90(feature, 3), + 4: lambda feature: np.fliplr(feature), + 5: lambda feature: np.flipud(feature), + 6: lambda feature: np.transpose(feature), + 7: lambda feature: np.fliplr(np.rot90(feature, 1)) +} + + +def one_hot_action(action, size=19): + """Convert an (x,y) action into a size x size array of zeros with a 1 at x,y + """ + + categorical = np.zeros((size, size)) + categorical[action] = 1 + return categorical + + +class threading_shuffled_hdf5_batch_generator: + """A generator of batches of training data for use with the fit_generator function + of Keras. Data is accessed in the order of the given indices for shuffling. + it is threading safe but not multiprocessing therefore only use it with + pickle_safe=False when using multiple workers + """ + + def shuffle_indices(self, seed=None, idx=0): + # set generator_sample to idx + self.metadata['generator_sample'] = idx + + # check if seed is provided or generate random + if seed is None: + # create random seed + self.metadata['generator_seed'] = np.random.random_integers(4294967295) + + #print( ) + #print("shuffle " + str(self.validation)) + # feed numpy.random with seed in order to continue with certain batch + np.random.seed(self.metadata['generator_seed']) + # shuffle indices according to seed + if not self.validation: + np.random.shuffle(self.indices) + + def __init__(self, state_dataset, action_dataset, indices, batch_size, metadata=None, + validation=False): + self.action_dataset = action_dataset + self.state_dataset = state_dataset + # lock used for multithreaded workers + self.data_lock = threading.Lock() + self.indices_max = len(indices) + self.validation = validation + self.batch_size = batch_size + self.indices = indices + + if metadata is not None: + self.metadata = metadata + else: + # create metadata object + self.metadata = { + "generator_seed": None, + "generator_sample": 0 + } + + # shuffle indices + # when restarting generator_seed and generator_batch will + # reset generator to the same point as before + self.shuffle_indices(self.metadata['generator_seed'], self.metadata['generator_sample']) + + def __iter__(self): + return self + + def next_indice(self): + # use lock to prevent double hdf5 acces and incorrect generator_sample increment + with self.data_lock: + + # get next training sample + training_sample = self.indices[self.metadata['generator_sample'], :] + # get state + state = self.state_dataset[training_sample[0]] + # get action + # must be cast to a tuple so that it is interpreted as (x,y) not [(x,:), (y,:)] + action = tuple(self.action_dataset[training_sample[0]]) + + # increment generator_sample + self.metadata['generator_sample'] += 1 + # shuffle indices when all have been used + if self.metadata['generator_sample'] >= self.indices_max: + self.shuffle_indices() + + # return state, action and transformation + return state, action, training_sample[1] + + def next(self): + state_batch_shape = (self.batch_size,) + self.state_dataset.shape[1:] + game_size = state_batch_shape[-1] + Xbatch = np.zeros(state_batch_shape) + Ybatch = np.zeros((self.batch_size, game_size * game_size)) + + for batch_idx in xrange(self.batch_size): + state, action, transformation = self.next_indice() + + # get rotation symmetry belonging to state + transform = BOARD_TRANSFORMATIONS[transformation] + + # get state from dataset and transform it. + # loop comprehension is used so that the transformation acts on the + # 3rd and 4th dimensions + state_transform = np.array([transform(plane) for plane in state]) + action_transform = transform(one_hot_action(action, game_size)) + + Xbatch[batch_idx] = state_transform + Ybatch[batch_idx] = action_transform.flatten() + + return (Xbatch, Ybatch) + + +class LrDecayCallback(Callback): + """Set learning rate every batch according to: + initial_learning_rate * (1. / (1. + self.decay * curent_batch)) + """ + + def __init__(self, metadata): + super(LrDecayCallback, self).__init__() + self.learning_rate = metadata["learning_rate"] + self.decay = metadata["decay"] + self.metadata = metadata + + def set_lr(self): + # calculate learning rate + new_lr = self.learning_rate * (1. / (1. + self.decay * self.metadata["current_batch"])) + + # set new learning rate + K.set_value(self.model.optimizer.lr, new_lr) + + def on_train_begin(self, logs={}): + # set initial learning rate + self.set_lr() + + def on_batch_begin(self, batch, logs={}): + # using batch is not usefull as it starts at 0 every epoch + + # set new learning rate + self.set_lr() + + # increment current_batch + # increment current_batch in LrDecayCallback because order of activation + # can differ and incorrect current_batch would be used + self.metadata["current_batch"] += 1 + + +class LrStepDecayCallback(Callback): + """Set learning rate every decay_every batches according to: + initial_learning_rate * (decay ^ (current_batch / decay every)) + """ + + def __init__(self, metadata, verbose): + super(LrStepDecayCallback, self).__init__() + self.learning_rate = metadata["learning_rate"] + self.decay_every = metadata["decay_every"] + self.decay = metadata["decay"] + self.metadata = metadata + self.verbose = verbose + + def set_lr(self): + # calculate learning rate + n_decay = int(self.metadata["current_batch"] / self.decay_every) + new_lr = self.learning_rate * (self.decay ** n_decay) + + # set new learning rate + K.set_value(self.model.optimizer.lr, new_lr) + + # print new learning rate if verbose + if self.verbose: + print("\nBatch: " + str(self.metadata["current_batch"]) + + " New learning rate: " + str(new_lr)) + + def on_train_begin(self, logs={}): + # set initial learning rate + self.set_lr() + + def on_batch_begin(self, batch, logs={}): + # using batch is not usefull as it starts at 0 every epoch + + # check if learning rate has to change + # - if we reach a new decay_every batch + if self.metadata["current_batch"] % self.decay_every == 0: + # change learning rate + self.set_lr() + + # increment current_batch + # increment current_batch in LrSchedulerCallback because order of activation + # can differ and incorrect current_batch would be used + self.metadata["current_batch"] += 1 + + +class EpochDataSaverCallback(Callback): + """Set current batch at training start + Save metadata and Model after every epoch + """ + + def __init__(self, path, root, metadata): + super(EpochDataSaverCallback, self).__init__() + self.file = path + self.root = root + self.metadata = metadata + + def on_train_begin(self, logs={}): + # set current_batch from metadata + self.model.optimizer.current_batch = self.metadata['current_batch'] + + def on_epoch_end(self, epoch, logs={}): + # in case appending to logs (resuming training), get epoch number ourselves + epoch = len(self.metadata["epoch_logs"]) + + # append log to metadata + self.metadata["epoch_logs"].append(logs) + # save current epoch + self.metadata["current_epoch"] = epoch + + if "val_loss" in logs: + key = "val_loss" + else: + key = "loss" + + best_loss = self.metadata["epoch_logs"][self.metadata["best_epoch"]][key] + if logs.get(key) < best_loss: + self.metadata["best_epoch"] = epoch + + # save meta to file + with open(self.file, "w") as f: + json.dump(self.metadata, f, indent=2) + + # save model to file with correct epoch + save_file = os.path.join(self.root, FOLDER_WEIGHT, + "weights.{epoch:05d}.hdf5".format(epoch=epoch)) + self.model.save(save_file) + + +def validate_feature_planes(verbose, dataset, model_features): + """Verify that dataset's features match the model's expected features. + """ + + if 'features' in dataset: + dataset_features = dataset['features'][()] + dataset_features = dataset_features.split(",") + if len(dataset_features) != len(model_features) or \ + any(df != mf for (df, mf) in zip(dataset_features, model_features)): + raise ValueError("Model JSON file expects features \n\t%s\n" + "But dataset contains \n\t%s" % ("\n\t".join(model_features), + "\n\t".join(dataset_features))) + elif verbose: + print("Verified that dataset features and model features exactly match.") + else: + # Cannot check each feature, but can check number of planes. + n_dataset_planes = dataset["states"].shape[1] + tmp_preprocess = Preprocess(model_features) + n_model_planes = tmp_preprocess.output_dim + if n_dataset_planes != n_model_planes: + raise ValueError("Model JSON file expects a total of %d planes from features \n\t%s\n" + "But dataset contains %d planes" % (n_model_planes, + "\n\t".join(model_features), + n_dataset_planes)) + elif verbose: + print("Verified agreement of number of model and dataset feature planes, but cannot " + "verify exact match using old dataset format.") + + +def load_indices_from_file(shuffle_file): + # load indices from shuffle_file + with open(shuffle_file, "r") as f: + indices = np.load(f) + + return indices + + +def save_indices_to_file(shuffle_file, indices): + # save indices to shuffle_file + with open(shuffle_file, "w") as f: + np.save(f, indices) + + +def remove_unused_symmetries(indices, symmetries): + # remove all rows with a symmetry not in symmetries + remove = [] + + # find all rows with incorrect symmetries + for row in range(len(indices)): + if not indices[row][1] in symmetries: + remove.append(row) + + # remove rows and return new array + return np.delete(indices, remove, 0) + + +def create_and_save_shuffle_indices(train_val_test, max_validation, + n_total_data_size, shuffle_file_train, + shuffle_file_val, shuffle_file_test): + """ create an array with all unique state and symmetry pairs, + calculate test/validation/training set sizes, + seperate those sets and save them to seperate files. + """ + + symmetries = TRANSFORMATION_INDICES.values() + + # Create an array with a unique row for each combination of a training example + # and a symmetry. + # shuffle_indices[i][0] is an index into training examples, + # shuffle_indices[i][1] is the index (from 0 to 7) of the symmetry transformation to apply + shuffle_indices = np.empty(shape=[n_total_data_size * len(symmetries), 2], dtype=int) + for dataset_idx in range(n_total_data_size): + for symmetry_idx in range(len(symmetries)): + shuffle_indices[dataset_idx * len(symmetries) + symmetry_idx][0] = dataset_idx + shuffle_indices[dataset_idx * len(symmetries) + + symmetry_idx][1] = symmetries[symmetry_idx] + + # shuffle rows without affecting x,y pairs + np.random.shuffle(shuffle_indices) + + # validation set size + n_val_data = int(train_val_test[1] * len(shuffle_indices)) + # limit validation set to --max-validation + if n_val_data > max_validation: + n_val_data = max_validation + + # test set size + n_test_data = int(train_val_test[2] * len(shuffle_indices)) + + # train set size + n_train_data = len(shuffle_indices) - n_val_data - n_test_data + + # create training set and save to file shuffle_file_train + train_indices = shuffle_indices[0:n_train_data] + save_indices_to_file(shuffle_file_train, train_indices) + + # create validation set and save to file shuffle_file_val + val_indices = shuffle_indices[n_train_data:n_train_data + n_val_data] + save_indices_to_file(shuffle_file_val, val_indices) + + # create test set and save to file shuffle_file_test + test_indices = shuffle_indices[n_train_data + n_val_data: + n_train_data + n_val_data + n_test_data] + save_indices_to_file(shuffle_file_test, test_indices) + + +def load_train_val_test_indices(verbose, arg_symmetries, dataset_length, batch_size, directory): + """Load indices from .npz files + Remove unwanted symmerties + Make Train set dividable by batch_size + Return train/val/test set + """ + # shuffle file locations for train/validation/test set + shuffle_file_train = os.path.join(directory, FILE_TRAIN) + shuffle_file_val = os.path.join(directory, FILE_VALIDATE) + shuffle_file_test = os.path.join(directory, FILE_TEST) + + # load from .npz files + train_indices = load_indices_from_file(shuffle_file_train) + val_indices = load_indices_from_file(shuffle_file_val) + test_indices = load_indices_from_file(shuffle_file_test) + + # used symmetries + if arg_symmetries == "all": + # add all symmetries + symmetries = TRANSFORMATION_INDICES.values() + elif arg_symmetries == "none": + # only add standart orientation + symmetries = [TRANSFORMATION_INDICES["noop"]] + else: + # add specified symmetries + symmetries = [TRANSFORMATION_INDICES[name] for name in arg_symmetries.strip().split(",")] + + if verbose: + print("Used symmetries: " + arg_symmetries) + + # remove symmetries not used during current run + if len(symmetries) != len(TRANSFORMATION_INDICES): + train_indices = remove_unused_symmetries(train_indices, symmetries) + test_indices = remove_unused_symmetries(test_indices, symmetries) + val_indices = remove_unused_symmetries(val_indices, symmetries) + + # Need to make sure training data is dividable by minibatch size or get + # warning mentioning accuracy from keras + if len(train_indices) % batch_size != 0: + # remove first len(train_indices) % args.minibatch rows + train_indices = np.delete(train_indices, [row for row in range(len(train_indices) + % batch_size)], 0) + + if verbose: + print("dataset loaded") + print("\t%d total positions" % dataset_length) + print("\t%d total samples" % (dataset_length * len(symmetries))) + print("\t%d total samples check" % (len(train_indices) + + len(val_indices) + len(test_indices))) + print("\t%d training samples" % len(train_indices)) + print("\t%d validation samples" % len(val_indices)) + print("\t%d test samples" % len(test_indices)) + + return train_indices, val_indices, test_indices + + +def set_training_settings(resume, args, metadata, dataset_length): + """ save all args to metadata, + check if critical settings have been changed and prompt user about it. + create new shuffle files if needed. + """ + + # shuffle file locations for train/validation/test set + shuffle_file_train = os.path.join(args.out_directory, FILE_TRAIN) + shuffle_file_val = os.path.join(args.out_directory, FILE_VALIDATE) + shuffle_file_test = os.path.join(args.out_directory, FILE_TEST) + + # determine if new shuffle files have to be created + save_new_shuffle_indices = not resume + + # save current symmetries to metadata + metadata["symmetries"] = args.symmetries + + if resume: + # check if argument model and meta model are the same + if metadata["model_file"] != args.model: + # verify if user really wants to use new model file + print("the model file is different from the model file used last run: " + + metadata["model_file"] + ". It might be different than the old one.") + if args.override or not confirm("Are you sure you want to use the new model?", False): # noqa: E501 + raise ValueError("User abort after mismatch model files.") + + # check if decay_every is the same + if args.decay_every is not None and metadata["decay_every"] != args.decay_every: + print("Setting a new --decay-every might result in a different learning rate, restarting training might be advisable.") # noqa: E501 + if args.override or confirm("Are you sure you want to use new decay every setting?", False): # noqa: E501 + metadata["decay_every"] = args.decay_every + + # check if learning_rate is the same + if args.learning_rate is not None and metadata["learning_rate"] != args.learning_rate: + print("Setting a new --learning-rate might result in a different learning rate, restarting training might be advisable.") # noqa: E501 + if args.override or confirm("Are you sure you want to use new learning rate setting?", False): # noqa: E501 + metadata["learning_rate"] = args.learning_rate + + # check if decay is the same + if args.decay is not None and metadata["decay"] != args.decay: + print("Setting a new --decay might result in a different learning rate, restarting training might be advisable.") # noqa: E501 + if args.override or confirm("Are you sure you want to use new decay setting?", False): # noqa: E501 + metadata["decay"] = args.decay + + # check if batch_size is the same + if args.minibatch is not None and metadata["batch_size"] != args.minibatch: + print("current_batch will be recalculated, restarting training might be advisable.") + if args.override or confirm("Are you sure you want to use new minibatch setting?", False): # noqa: E501 + metadata["current_batch"] = int((metadata["current_batch"] * + metadata["batch_size"]) / args.minibatch) + metadata["batch_size"] = args.minibatch + + # check if max_validation is the same + if args.max_validation is not None and metadata["max_validation"] != args.max_validation: + print("Training set and validation set should be stricktly separated, setting a new fraction will generate a new validation set that might include data from the current traing set.") # noqa: E501 + print("We reccommend you not to use the new max-validation setting.") + if args.override or confirm("Are you sure you want to use new max-validation setting?", False): # noqa: E501 + metadata["max_validation"] = args.max_validation + # new shuffle files have to be created + save_new_shuffle_indices = True + + # check if train_val_test is the same + if args.train_val_test is not None and metadata["train_val_test"] != args.train_val_test: + print("Training set and validation set should be stricktly separated, setting a new fraction will generate a new validation set that might include data from the current traing set.") # noqa: E501 + print("We reccommend you not to use the new fraction.") + if args.override or confirm("Are you sure you want to use new train-val-test fraction setting?", False): # noqa: E501 + metadata["train_val_test"] = args.train_val_test + # new shuffle files have to be created + save_new_shuffle_indices = True + + # check if epochs is the same + if args.epochs is not None and metadata["epochs"] != args.epochs: + metadata["epochs"] = args.epochs + + # check if epoch_length is the same + if args.epoch_length is not None and metadata["epoch_length"] != args.epoch_length: + metadata["epoch_length"] = args.epoch_length + + # check if shuffle files exist and data file is the same + if not os.path.exists(shuffle_file_train) or not os.path.exists(shuffle_file_val) or \ + not os.path.exists(shuffle_file_test) or \ + metadata["training_data"] != args.train_data or \ + metadata["available_states"] != dataset_length: + # shuffle files do not exist or training data file has changed + print("WARNING! shuffle files have to be recreated.") + save_new_shuffle_indices = True + else: + # save all argument or default settings to metadata + + # save used model file to metadata + metadata["model_file"] = args.model + + # save decay_every to metadata + metadata["decay_every"] = args.decay_every + + # save learning_rate to metadata + if args.learning_rate is not None: + metadata["learning_rate"] = args.learning_rate + else: + metadata["learning_rate"] = DEFAULT_LEARNING_RATE + + # save decay to metadata + if args.decay is not None: + metadata["decay"] = args.decay + else: + metadata["decay"] = DEFAULT_DECAY + + # save batch_size to metadata + if args.minibatch is not None: + metadata["batch_size"] = args.minibatch + else: + metadata["batch_size"] = DEFAULT_BATCH_SIZE + + # save max_validation to metadata + if args.max_validation is not None: + metadata["max_validation"] = args.max_validation + else: + metadata["max_validation"] = DEFAULT_MAX_VALIDATION + + # save train_val_test to metadata + if args.train_val_test is not None: + metadata["train_val_test"] = args.train_val_test + else: + metadata["train_val_test"] = DEFAULT_TRAIN_VAL_TEST + + # save epochs to metadata + if args.epochs is not None: + metadata["epochs"] = args.epochs + else: + metadata["epochs"] = DEFAULT_EPOCH + + # save epoch_length to metadata + if args.epoch_length is not None: + metadata["epoch_length"] = args.epoch_length + else: + metadata["epoch_length"] = dataset_length + + # Record all command line args in a list so that all args are recorded even + # when training is stopped and resumed. + # TODO remove function argument from args... or do not save args to metadata + # meta_args_data = metadata.get("cmd_line_args", []) + # meta_args_data.append(vars(args)) + # metadata["cmd_line_args"] = meta_args_data + + # create and save new shuffle indices if needed + if save_new_shuffle_indices: + # create and save new shuffle indices to file + create_and_save_shuffle_indices( + metadata["train_val_test"], metadata["max_validation"], dataset_length, + shuffle_file_train, shuffle_file_val, shuffle_file_test) + + # save total amount of states to metadata + metadata["available_states"] = dataset_length + + # save training data file name to metadata + metadata["training_data"] = args.train_data + + if args.verbose: + print("created new data shuffling indices") + + +def train(metadata, out_directory, verbose, weight_file, meta_file): + # set resume + resume = weight_file is not None + + # load model from json spec + policy = CNNRollout.load_model(metadata["model_file"]) + model_features = policy.preprocessor.feature_list + model = policy.model + # load weights + if resume: + model.load_weights(os.path.join(out_directory, FOLDER_WEIGHT, weight_file)) + + # features of training data + dataset = h5.File(metadata["training_data"]) + + # Verify that dataset's features match the model's expected features. + validate_feature_planes(verbose, dataset, model_features) + + # create metadata file and the callback object that will write to it + # and saves model at the same time + # the MetadataWriterCallback only sets 'epoch', 'best_epoch' and 'current_batch'. + # We can add in anything else we like here + meta_writer = EpochDataSaverCallback(meta_file, out_directory, metadata) + + # get train/validation/test indices + train_indices, val_indices, test_indices \ + = load_train_val_test_indices(verbose, metadata['symmetries'], len(dataset["states"]), + metadata["batch_size"], out_directory) + + # create dataset generators + train_data_generator = threading_shuffled_hdf5_batch_generator( + dataset["states"], + dataset["actions"], + train_indices, + metadata["batch_size"], + metadata) + val_data_generator = threading_shuffled_hdf5_batch_generator( + dataset["states"], + dataset["actions"], + val_indices, + metadata["batch_size"], + validation=True) + + # check if step decay has to be applied + if metadata["decay_every"] is None: + # use normal decay without momentum + lr_scheduler_callback = LrDecayCallback(metadata) + else: + # use step decay + lr_scheduler_callback = LrStepDecayCallback(metadata, verbose) + + #sgd = SGD(lr=metadata["learning_rate"]) + sgd = Adam(lr=0.001, beta_1=0.99, beta_2=0.999, epsilon=1e-08, decay=0.0) + model.compile(loss='categorical_crossentropy', optimizer=sgd, metrics=["accuracy"]) + + if verbose: + print("STARTING TRAINING") + + # check that remaining epoch > 0 + if metadata["epochs"] <= len(metadata["epoch_logs"]): + raise ValueError("No more epochs to train!") + + model.fit_generator( + generator=train_data_generator, + samples_per_epoch=metadata["epoch_length"], + nb_epoch=(metadata["epochs"] - len(metadata["epoch_logs"])), + callbacks=[meta_writer], + validation_data=val_data_generator, + nb_val_samples=len(val_indices), + max_q_size = 5) + + +def start_training(args): + # set resume + resume = args.weights is not None + + if args.verbose: + if resume: + print("trying to resume from %s with weights %s" % + (args.out_directory, + os.path.join(args.out_directory, FOLDER_WEIGHT, args.weights))) + else: + if os.path.exists(args.out_directory): + print("directory %s exists. any previous data will be overwritten" % + args.out_directory) + else: + print("starting fresh output directory %s" % args.out_directory) + + # create all directories + # main folder + if not os.path.exists(args.out_directory): + os.makedirs(args.out_directory) + + # create supervised weight file folder + weight_folder = os.path.join(args.out_directory, FOLDER_WEIGHT) + if not os.path.exists(weight_folder): + os.makedirs(weight_folder) + + # metadata json file location + meta_file = os.path.join(args.out_directory, FILE_METADATA) + + # create or load metadata from json file + if resume and os.path.exists(meta_file): + # load metadata + with open(meta_file, "r") as f: + metadata = json.load(f) + + if args.verbose: + print("previous metadata loaded: %d epochs. new epochs will be appended." % + len(metadata["epoch_logs"])) + else: + # create new metadata + metadata = { + "epoch_logs": [], + "current_batch": 0, + "current_epoch": 0, + "best_epoch": 0, + "generator_seed": None, + "generator_sample": 0, + "dict_3x3": args.dict_3x3, + "dict_12d": args.dict_12d, + "dict_nakade": args.dict_nakade + } + + if args.verbose: + print("starting with empty metadata") + + # features of training data + dataset = h5.File(args.train_data) + + # set all settings: default, from args or from metadata + # generate new shuffle files if needed + set_training_settings(resume, args, metadata, len(dataset["states"])) + + # start training + train(metadata, args.out_directory, args.verbose, None, meta_file) + + +def resume_training(args): + # metadata json file location + meta_file = os.path.join(args.out_directory, FILE_METADATA) + + # load data from json file + if os.path.exists(meta_file): + with open(meta_file, "r") as f: + metadata = json.load(f) + else: + raise ValueError("Metadata file not found!") + + # determine what weight file to use + if args.weights is None: + # newest epoch weight file from json + weight_file = "weights.{epoch:05d}.hdf5".format(epoch=metadata["current_epoch"]) + else: + # user weight argument + weight_file = args.weights + + # check if training epochs has changed + if args.epochs is not None: + metadata["epochs"] = args.epochs + + if args.verbose: + print("trying to resume training from %s with weights %s" % + (meta_file, os.path.join(args.out_directory, FOLDER_WEIGHT, weight_file))) + + # start training + train(metadata, args.out_directory, args.verbose, weight_file, meta_file) + + +def handle_arguments(cmd_line_args=None): + """Run training. command-line args may be passed in as a list + """ + + import argparse + parser = argparse.ArgumentParser(description='Perform supervised training on a policy network.') + # subparser is always first argument + subparsers = parser.add_subparsers(help='sub-command help') + + # sub parser start training + train = subparsers.add_parser('train', help='Start or resume supervised training on a policy network.') # noqa: E501 + # required arguments + train.add_argument("out_directory", help="directory where metadata and weights will be saved") # noqa: E501 + train.add_argument("model", help="Path to a JSON model file (i.e. from CNNPolicy.save_model())") # noqa: E501 + train.add_argument("train_data", help="A .h5 file of training data") + # frequently used args + train.add_argument("--verbose", "-v", help="Turn on verbose mode", default=False, action="store_true") # noqa: E501 + train.add_argument("--minibatch", "-B", help="Size of training data minibatches. Default: " + str(DEFAULT_BATCH_SIZE), type=int, default=None) # noqa: E501 + train.add_argument("--epochs", "-E", help="Total number of iterations on the data. Default: " + str(DEFAULT_EPOCH), type=int, default=None) # noqa: E501 + train.add_argument("--epoch-length", "-l", help="Number of training examples considered 'one epoch'. Default: # training data", type=int, default=None) # noqa: E501 + train.add_argument("--learning-rate", "-r", help="Learning rate - how quickly the model learns at first. Default: " + str(DEFAULT_LEARNING_RATE), type=float, default=None) # noqa: E501 + train.add_argument("--decay", "-d", help=("The rate at which learning decreases. Default: " + str(DEFAULT_DECAY)), type=float, default=None) # noqa: E501 + train.add_argument("--decay-every", "-de", help="Use step-decay: decay --learning-rate with --decay every --decay-every batches. Default: None", type=int, default=None) # noqa: E501 + train.add_argument("--override", help="Turn on prompt override mode", default=False, action="store_true") # noqa: E501 + # slightly fancier args + train.add_argument("--weights", help="Name of a .h5 weights file (in the output directory) to load to resume training", default=None) # noqa: E501 + train.add_argument("--train-val-test", help="Fraction of data to use for training/val/test. Must sum to 1. Default: " + str(DEFAULT_TRAIN_VAL_TEST), nargs=3, type=float, default=None) # noqa: E501 + train.add_argument("--max-validation", help="maximum validation set size. default: " + str(DEFAULT_MAX_VALIDATION), type=int, default=None) # noqa: E501 + train.add_argument("--symmetries", help="none, all or comma-separated list of transforms, subset of: noop,rot90,rot180,rot270,fliplr,flipud,diag1,diag2. Default: all", default='all') # noqa: E501 + # dictionary args + train.add_argument("--dict-3x3", help="Path to 3x3 pattern dictionary. Default: None", default=None) # noqa: E501 + train.add_argument("--dict-12d", help="Path to 12d pattern dictionary. Default: None", default='all') # noqa: E501 + train.add_argument("--dict-nakade", help="Path to nakade pattern dictionary. Default: None", default='all') # noqa: E501 + + # function to call when start training + train.set_defaults(func=start_training) + + # sub parser resume training + resume = subparsers.add_parser('resume', help='Resume supervised training on a policy network. (Settings are loaded from savefile.)') # noqa: E501 + # required arguments + resume.add_argument("out_directory", help="directory where metadata and weight files where stored during previous session.") # noqa: E501 + # optional argument + resume.add_argument("--verbose", "-v", help="Turn on verbose mode", default=False, action="store_true") # noqa: E501 + resume.add_argument("--weights", help="Name of a .h5 weights file (in the output directory) to load to resume training. Default: #Newest weight file.", default=None) # noqa: E501 + resume.add_argument("--epochs", "-E", help="Total number of iterations on the data. Defaukt: #Epochs set on previous run", type=int, default=None) # noqa: E501 + # function to call when resume training + resume.set_defaults(func=resume_training) + + # show help or parse arguments + if cmd_line_args is None: + args = parser.parse_args() + else: + args = parser.parse_args(cmd_line_args) + + # execute function (train or resume) + args.func(args) + + +if __name__ == '__main__': + handle_arguments() diff --git a/AlphaGo/util.py b/AlphaGo/util.py index 4b67d0674..e2a715219 100644 --- a/AlphaGo/util.py +++ b/AlphaGo/util.py @@ -8,21 +8,62 @@ LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' +def confirm(prompt=None, resp=False): + """ + prompts for yes or no response from the user. Returns True for yes and + False for no. + 'resp' should be set to the default value assumed by the caller when + user simply types ENTER. + created by: + http://code.activestate.com/recipes/541096-prompt-the-user-for-confirmation/ + """ + + if prompt is None: + prompt = 'Confirm' + + if resp: + prompt = '%s [%s]|%s: ' % (prompt, 'y', 'n') + else: + prompt = '%s [%s]|%s: ' % (prompt, 'n', 'y') + + while True: + ans = raw_input(prompt) + if not ans: + return resp + if ans not in ['y', 'Y', 'n', 'N']: + print 'please enter y or n.' + continue + if ans == 'y' or ans == 'Y': + return True + if ans == 'n' or ans == 'N': + return False + + def flatten_idx(position, size): + """ + + """ + (x, y) = position return x * size + y def unflatten_idx(idx, size): + """ + + """ + x, y = divmod(idx, size) return (x, y) def _parse_sgf_move(node_value): - """Given a well-formed move string, return either PASS_MOVE or the (x, y) position """ + Given a well-formed move string, return either PASS_MOVE or the (x, y) position + """ + if node_value == '' or node_value == 'tt': - return go.PASS_MOVE + return go.PASS else: # GameState expects (x, y) where x is column and y is row col = LETTERS.index(node_value[0].upper()) @@ -30,33 +71,37 @@ def _parse_sgf_move(node_value): return (col, row) -def _sgf_init_gamestate(sgf_root): - """Helper function to set up a GameState object from the root node - of an SGF file +def _sgf_init_gamestate(root, sgf_root): + """ + Helper function to set up a GameState object from the root node + of an SGF file """ + props = sgf_root.properties s_size = props.get('SZ', ['19'])[0] s_player = props.get('PL', ['B'])[0] # init board with specified size - gs = go.GameState(int(s_size)) + gs = root.get_root_game_state() # handle 'add black' property if 'AB' in props: for stone in props['AB']: - gs.do_move(_parse_sgf_move(stone), go.BLACK) + gs.place_handicap_stone(_parse_sgf_move(stone), go.BLACK) # handle 'add white' property if 'AW' in props: for stone in props['AW']: - gs.do_move(_parse_sgf_move(stone), go.WHITE) + gs.place_handicap_stone(_parse_sgf_move(stone), go.WHITE) # setup done; set player according to 'PL' property - gs.current_player = go.BLACK if s_player == 'B' else go.WHITE + gs.set_current_player( go.BLACK if s_player == 'B' else go.WHITE ) return gs -def sgf_to_gamestate(sgf_string): - """Creates a GameState object from the first game in the given collection +def sgf_to_gamestate(root, sgf_string): """ + Creates a GameState object from the first game in the given collection + """ + # Don't Repeat Yourself; parsing handled by sgf_iter_states - for (gs, move, player) in sgf_iter_states(sgf_string, True): + for (gs, move, player) in sgf_iter_states(root, sgf_string, True): pass # gs has been updated in-place to the final state by the time # sgf_iter_states returns @@ -64,9 +109,11 @@ def sgf_to_gamestate(sgf_string): def save_gamestate_to_sgf(gamestate, path, filename, black_player_name='Unknown', - white_player_name='Unknown', size=19, komi=7.5): - """Creates a simplified sgf for viewing playouts or positions + white_player_name='Unknown', size=19, komi=7.5, result=None): + """ + Creates a simplified sgf for viewing playouts or positions """ + str_list = [] # Game info str_list.append('(;GM[1]FF[4]CA[UTF-8]') @@ -74,17 +121,21 @@ def save_gamestate_to_sgf(gamestate, path, filename, black_player_name='Unknown' str_list.append('KM[{}]'.format(komi)) str_list.append('PB[{}]'.format(black_player_name)) str_list.append('PW[{}]'.format(white_player_name)) + + if result is not None: + str_list.append('RE[{}]'.format(result)) + cycle_string = 'BW' # Handle handicaps - if len(gamestate.handicaps) > 0: + if len(gamestate.get_handicaps()) > 0: cycle_string = 'WB' - str_list.append('HA[{}]'.format(len(gamestate.handicaps))) + str_list.append('HA[{}]'.format(len(gamestate.get_handicaps()))) str_list.append(';AB') - for handicap in gamestate.handicaps: + for handicap in gamestate.get_handicaps(): str_list.append('[{}{}]'.format(LETTERS[handicap[0]].lower(), LETTERS[handicap[1]].lower())) # Move list - for move, color in zip(gamestate.history, itertools.cycle(cycle_string)): + for move, color in zip(gamestate.get_history(), itertools.cycle(cycle_string)): # Move color prefix str_list.append(';{}'.format(color)) # Move coordinates @@ -97,21 +148,22 @@ def save_gamestate_to_sgf(gamestate, path, filename, black_player_name='Unknown' f.write(''.join(str_list)) -def sgf_iter_states(sgf_string, include_end=True): - """Iterates over (GameState, move, player) tuples in the first game of the given SGF file. - - Ignores variations - only the main line is returned. The state object is - modified in-place, so don't try to, for example, keep track of it through - time +def sgf_iter_states(root, sgf_string, include_end=True): + """ + Iterates over (GameState, move, player) tuples in the first game of the given SGF file. - If include_end is False, the final tuple yielded is the penultimate state, - but the state will still be left in the final position at the end of - iteration because 'gs' is modified in-place the state. See sgf_to_gamestate + Ignores variations - only the main line is returned. The state object is + modified in-place, so don't try to, for example, keep track of it through + time + If include_end is False, the final tuple yielded is the penultimate state, + but the state will still be left in the final position at the end of + iteration because 'gs' is modified in-place the state. See sgf_to_gamestate """ + collection = sgf.parse(sgf_string) game = collection[0] - gs = _sgf_init_gamestate(game.root) + gs = _sgf_init_gamestate(root, game.root) if game.rest is not None: for node in game.rest: props = node.properties diff --git a/interface/Play.py b/interface/Play.py index 059a5e06e..3a61eb92f 100644 --- a/interface/Play.py +++ b/interface/Play.py @@ -1,6 +1,6 @@ """Interface for AlphaGo self-play""" -from AlphaGo.go import GameState - +from AlphaGo.go_root import RootState +from AlphaGo.go import PASS class play_match(object): """Interface to handle play between two players.""" @@ -9,7 +9,8 @@ def __init__(self, player1, player2, save_dir=None, size=19): # super(ClassName, self).__init__() self.player1 = player1 self.player2 = player2 - self.state = GameState(size=size) + self.root = RootState(size=size) + self.state = self.root.get_root_game_state() # I Propose that GameState should take a top-level save directory, # then automatically generate the specific file name @@ -18,8 +19,8 @@ def _play(self, player): # TODO: Fix is_eye? self.state.do_move(move) # Return max prob sensible legal move # self.state.write_to_disk() - if len(self.state.history) > 1: - if self.state.history[-1] is None and self.state.history[-2] is None \ + if len(self.state.get_history()) > 1: + if self.state.get_history()[-1] is PASS and self.state.get_history()[-2] is PASS \ and self.state.current_player == -1: end_of_game = True else: diff --git a/interface/gtp_wrapper.py b/interface/gtp_wrapper.py index 1dca11510..2688920f6 100644 --- a/interface/gtp_wrapper.py +++ b/interface/gtp_wrapper.py @@ -1,7 +1,8 @@ import sys -import multiprocessing import gtp from AlphaGo import go +import multiprocessing +from AlphaGo.go_root import RootState from AlphaGo.util import save_gamestate_to_sgf @@ -85,17 +86,19 @@ class GTPGameConnector(object): """ def __init__(self, player): - self._state = go.GameState(enforce_superko=True) - self._player = player + self._root = RootState()#enforce_superko=True + self._state = self._root.get_root_game_state() + self._player = player + self._komi = 0 def clear(self): - self._state = go.GameState(self._state.size, enforce_superko=True) + self._state = self._root.get_root_game_state() def make_move(self, color, vertex): # vertex in GTP language is 1-indexed, whereas GameState's are zero-indexed try: if vertex == gtp.PASS: - self._state.do_move(go.PASS_MOVE) + self._state.do_move(go.PASS) else: (x, y) = vertex self._state.do_move((x - 1, y - 1), color) @@ -104,15 +107,16 @@ def make_move(self, color, vertex): return False def set_size(self, n): - self._state = go.GameState(n, enforce_superko=True) + self._root = RootState(size=n)#enforce_superko=True + self._state = self._root.get_root_game_state() def set_komi(self, k): - self._state.komi = k + self._komi = k def get_move(self, color): - self._state.current_player = color + self._state.set_current_player(color) move = self._player.get_move(self._state) - if move == go.PASS_MOVE: + if move == go.PASS: return gtp.PASS else: (x, y) = move diff --git a/setup.py b/setup.py new file mode 100644 index 000000000..817fbd62a --- /dev/null +++ b/setup.py @@ -0,0 +1,19 @@ +import numpy +import os +from distutils.core import setup +from Cython.Build import cythonize + +setup( + + name = 'RocAlphaGo', + # list with files to be cythonized + ext_modules = cythonize( [ "AlphaGo/go.pyx", "AlphaGo/go_root.pyx", "AlphaGo/go_data.pyx", "AlphaGo/preprocessing/preprocessing.pyx" ] ), + # include numpy + include_dirs=[numpy.get_include(), + os.path.join(numpy.get_include(), 'numpy')] +) + +# run setup with command +# python setup.py build_ext --inplace + +# be aware cython uses a depricaped version of numpy this results in a lot of warnings \ No newline at end of file diff --git a/tests/parseboard.py b/tests/parseboard.py index 431947301..5a1659462 100644 --- a/tests/parseboard.py +++ b/tests/parseboard.py @@ -1,7 +1,7 @@ -from AlphaGo.go import GameState, BLACK, WHITE +from AlphaGo.go import BLACK, WHITE -def parse(boardstr): +def parse(state, boardstr): '''Parses a board into a gamestate, and returns the location of any moves marked with anything other than 'B', 'X', '#', 'W', 'O', or '.' @@ -10,9 +10,8 @@ def parse(boardstr): ''' boardstr = boardstr.replace(' ', '') - board_size = max(boardstr.index('|'), boardstr.count('|')) + # board_size = max(boardstr.index('|'), boardstr.count('|')) - st = GameState(size=board_size) moves = {} for row, rowstr in enumerate(boardstr.split('|')): @@ -20,12 +19,12 @@ def parse(boardstr): if c == '.': continue # ignore empty spaces elif c in 'BX#': - st.do_move((row, col), color=BLACK) + state.do_move((row, col), color=BLACK) elif c in 'WO': - st.do_move((row, col), color=WHITE) + state.do_move((row, col), color=WHITE) else: # move reference assert c not in moves, "{} already used as a move marker".format(c) moves[c] = (row, col) - return st, moves + return moves diff --git a/tests/test_data/hdf5/policy_training_features.hdf5 b/tests/test_data/hdf5/policy_training_features.hdf5 new file mode 100644 index 0000000000000000000000000000000000000000..8dc12fac4805b62fb30d83982834f3c1b25092dd GIT binary patch literal 516375 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

-8EHtCBl^(<}ye;oz=;p3J-DF=AqMnj% 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¬8!3CI^rV8f}xN$@@U4c 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�?uPA!vysmYTce1c&!cFg$1 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%0A=%>c2s6&?QvvZxRoBRDs#Vhhpmi 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 literal 0 HcmV?d00001 diff --git a/tests/test_data/hdf5/value_training_features.hdf5 b/tests/test_data/hdf5/value_training_features.hdf5 new file mode 100644 index 0000000000000000000000000000000000000000..3c7f2f153533eac0e9440897febf031be2d09b42 GIT binary patch literal 1697442 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⁡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�>0wZ+TY!y%M<|_nsGF3b77x{Gx(>mDcM9i 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 z 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�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 z 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 La|qn;I-L8z;FtDy literal 0 HcmV?d00001 diff --git a/tests/test_data/minimodel.json b/tests/test_data/minimodel.json deleted file mode 100644 index 3bb0f9426..000000000 --- a/tests/test_data/minimodel.json +++ /dev/null @@ -1 +0,0 @@ -{"weights_file": "tests/test_data/hdf5/random_minimodel_weights.hdf5", "keras_model": "{\"class_name\": \"Sequential\", \"keras_version\": \"1.1.2\", \"config\": [{\"class_name\": \"Convolution2D\", \"config\": {\"b_regularizer\": null, \"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_1\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 5, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"input_dtype\": \"float32\", \"border_mode\": \"same\", \"batch_input_shape\": [null, 12, 19, 19], \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 5}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_2\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_3\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_4\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_5\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_6\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 1, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 1, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"linear\", \"nb_row\": 1}}, {\"class_name\": \"Flatten\", \"config\": {\"trainable\": true, \"name\": \"flatten_1\"}}, {\"class_name\": \"Bias\", \"config\": {\"trainable\": true, \"name\": \"bias_1\"}}, {\"class_name\": \"Activation\", \"config\": {\"activation\": \"softmax\", \"trainable\": true, \"name\": \"activation_1\"}}]}", "class": "CNNPolicy", "feature_list": ["board", "ones", "turns_since"]} \ No newline at end of file diff --git a/tests/test_data/minimodel_policy.json b/tests/test_data/minimodel_policy.json new file mode 100644 index 000000000..3aa8182e3 --- /dev/null +++ b/tests/test_data/minimodel_policy.json @@ -0,0 +1 @@ +{"weights_file": "tests/test_data/hdf5/random_minimodel_policy_weights.hdf5", "keras_model": "{\"class_name\": \"Sequential\", \"keras_version\": \"1.1.2\", \"config\": [{\"class_name\": \"Convolution2D\", \"config\": {\"b_regularizer\": null, \"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_1\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 5, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"input_dtype\": \"float32\", \"border_mode\": \"same\", \"batch_input_shape\": [null, 12, 19, 19], \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 5}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_2\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_3\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_4\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_5\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_6\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 1, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 1, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"linear\", \"nb_row\": 1}}, {\"class_name\": \"Flatten\", \"config\": {\"trainable\": true, \"name\": \"flatten_1\"}}, {\"class_name\": \"Bias\", \"config\": {\"trainable\": true, \"name\": \"bias_1\"}}, {\"class_name\": \"Activation\", \"config\": {\"activation\": \"softmax\", \"trainable\": true, \"name\": \"activation_1\"}}]}", "class": "CNNPolicy", "feature_list": ["board", "ones", "turns_since"]} diff --git a/tests/test_data/minimodel_value.json b/tests/test_data/minimodel_value.json new file mode 100644 index 000000000..3bb60c90d --- /dev/null +++ b/tests/test_data/minimodel_value.json @@ -0,0 +1 @@ +{"weights_file": "tests/test_data/hdf5/random_minimodel_value_weights.hdf5", "keras_model": "{\"class_name\": \"Sequential\", \"keras_version\": \"1.2.0\", \"config\": [{\"class_name\": \"Convolution2D\", \"config\": {\"b_regularizer\": null, \"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_1\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 5, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"input_dtype\": \"float32\", \"border_mode\": \"same\", \"batch_input_shape\": [null, 49, 19, 19], \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 5}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_2\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_3\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_4\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_5\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_6\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_7\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 1, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 1, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 1}}, {\"class_name\": \"Flatten\", \"config\": {\"trainable\": true, \"name\": \"flatten_1\"}}, {\"class_name\": \"Dense\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"dense_1\", \"activity_regularizer\": null, \"trainable\": true, \"init\": \"uniform\", \"bias\": true, \"input_dim\": 361, \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"output_dim\": 256}}, {\"class_name\": \"Dense\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"dense_2\", \"activity_regularizer\": null, \"trainable\": true, \"init\": \"uniform\", \"bias\": true, \"input_dim\": 256, \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"tanh\", \"output_dim\": 1}}]}", "class": "CNNValue", "feature_list": ["board", "ones", "turns_since", "liberties", "capture_size", "self_atari_size", "liberties_after", "ladder_capture", "ladder_escape", "sensibleness", "zeros", "color"]} diff --git a/tests/test_game_converter.py b/tests/test_game_converter.py index c277cf850..5ea44d35e 100644 --- a/tests/test_game_converter.py +++ b/tests/test_game_converter.py @@ -1,13 +1,17 @@ -from AlphaGo.preprocessing.game_converter import run_game_converter -from AlphaGo.util import sgf_to_gamestate -import unittest import os +import unittest +from AlphaGo.go_root import RootState +from AlphaGo.util import sgf_to_gamestate +from AlphaGo.preprocessing.game_converter import run_game_converter class TestSGFLoading(unittest.TestCase): def test_ab_aw(self): + + rootState = RootState(size=19) + with open('tests/test_data/sgf_with_handicap/ab_aw.sgf', 'r') as f: - sgf_to_gamestate(f.read()) + sgf_to_gamestate(rootState, f.read()) class TestCmdlineConverter(unittest.TestCase): diff --git a/tests/test_gamestate.py b/tests/test_gamestate.py index efd6239a5..b2d7a66e9 100644 --- a/tests/test_gamestate.py +++ b/tests/test_gamestate.py @@ -1,13 +1,16 @@ -from AlphaGo.go import GameState +import unittest import numpy as np import AlphaGo.go as go -import unittest +from AlphaGo.go_root import RootState class TestKo(unittest.TestCase): def test_standard_ko(self): - gs = GameState(size=9) + + rootState = RootState(size=9) + gs = rootState.get_root_game_state() + gs.do_move((1, 0)) # B gs.do_move((2, 0)) # W gs.do_move((0, 1)) # B @@ -18,8 +21,8 @@ def test_standard_ko(self): gs.do_move((1, 1)) # W trigger capture and ko - self.assertEqual(gs.num_black_prisoners, 1) - self.assertEqual(gs.num_white_prisoners, 0) + self.assertEqual(gs.get_captures_black(), 1) + self.assertEqual(gs.get_captures_white(), 0) self.assertFalse(gs.is_legal((2, 1))) @@ -29,7 +32,10 @@ def test_standard_ko(self): self.assertTrue(gs.is_legal((2, 1))) def test_snapback_is_not_ko(self): - gs = GameState(size=5) + + rootState = RootState(size=5) + gs = rootState.get_root_game_state() + # B o W B . # W W B . . # . . . . . @@ -49,25 +55,29 @@ def test_snapback_is_not_ko(self): # do the capture of the single white stone gs.do_move((1, 0)) # there should be no ko - self.assertIsNone(gs.ko) + self.assertIsNone(gs.get_ko_location()) self.assertTrue(gs.is_legal((2, 0))) # now play the snapback gs.do_move((2, 0)) # check that the numbers worked out - self.assertEqual(gs.num_black_prisoners, 2) - self.assertEqual(gs.num_white_prisoners, 1) + self.assertEqual(gs.get_captures_black(), 2) + self.assertEqual(gs.get_captures_white(), 1) def test_positional_superko(self): + + rootState = RootState(size=9) + gs = rootState.get_root_game_state() + move_list = [(0, 3), (0, 4), (1, 3), (1, 4), (2, 3), (2, 4), (2, 2), (3, 4), (2, 1), (3, 3), (3, 1), (3, 2), (3, 0), (4, 2), (1, 1), (4, 1), (8, 0), (4, 0), (8, 1), (0, 2), (8, 2), (0, 1), (8, 3), (1, 0), (8, 4), (2, 0), (0, 0)] - gs = GameState(size=9) for move in move_list: gs.do_move(move) self.assertTrue(gs.is_legal((1, 0))) - gs = GameState(size=9, enforce_superko=True) + # gs = GameState(size=9, enforce_superko=True) super ko is not handled yet + gs = rootState.get_root_game_state() for move in move_list: gs.do_move(move) self.assertFalse(gs.is_legal((1, 0))) @@ -75,41 +85,15 @@ def test_positional_superko(self): class TestEye(unittest.TestCase): - def test_simple_eye(self): - - # create a black eye in top left (1, 1), white in bottom right (5, 5) - - gs = GameState(size=7) - gs.do_move((1, 0)) # B - gs.do_move((5, 4)) # W - gs.do_move((2, 1)) # B - gs.do_move((6, 5)) # W - gs.do_move((1, 2)) # B - gs.do_move((5, 6)) # W - gs.do_move((0, 1)) # B - gs.do_move((4, 5)) # W - - # test black eye top left - self.assertTrue(gs.is_eyeish((1, 1), go.BLACK)) - self.assertFalse(gs.is_eyeish((1, 1), go.WHITE)) - - # test white eye bottom right - self.assertTrue(gs.is_eyeish((5, 5), go.WHITE)) - self.assertFalse(gs.is_eyeish((5, 5), go.BLACK)) - - # test no eye in other random positions - self.assertFalse(gs.is_eyeish((1, 0), go.BLACK)) - self.assertFalse(gs.is_eyeish((1, 0), go.WHITE)) - self.assertFalse(gs.is_eyeish((2, 2), go.BLACK)) - self.assertFalse(gs.is_eyeish((2, 2), go.WHITE)) - def test_true_eye(self): - gs = GameState(size=7) + + rootState = RootState(size=7) + gs = rootState.get_root_game_state() + gs.do_move((1, 0), go.BLACK) gs.do_move((0, 1), go.BLACK) # false eye at 0, 0 - self.assertTrue(gs.is_eyeish((0, 0), go.BLACK)) self.assertFalse(gs.is_eye((0, 0), go.BLACK)) # make it a true eye by turning the corner (1, 1) into an eye itself @@ -118,18 +102,21 @@ def test_true_eye(self): gs.do_move((2, 2), go.BLACK) gs.do_move((0, 2), go.BLACK) - self.assertTrue(gs.is_eyeish((0, 0), go.BLACK)) + # is eyeish function does not exist self.assertTrue(gs.is_eye((0, 0), go.BLACK)) self.assertTrue(gs.is_eye((1, 1), go.BLACK)) def test_eye_recursion(self): # a checkerboard pattern of black is 'technically' all true eyes # mutually supporting each other - gs = GameState(7) - for x in range(gs.size): - for y in range(gs.size): + + rootState = RootState(size=7) + + gs = rootState.get_root_game_state() + for x in range(gs.get_size()): + for y in range(gs.get_size()): if (x + y) % 2 == 1: - gs.do_move((x, y), go.BLACK) + gs.do_move((x, y), color=go.BLACK) self.assertTrue(gs.is_eye((0, 0), go.BLACK)) @@ -139,8 +126,11 @@ def test_liberties_after_capture(self): # creates 3x3 black group in the middle, that is then all captured # ...then an assertion is made that the resulting liberties after # capture are the same as if the group had never been there - gs_capture = GameState(7) - gs_reference = GameState(7) + + rootState = RootState(size=7) + + gs_capture = rootState.get_root_game_state() + gs_reference = rootState.get_root_game_state() # add in 3x3 black stones for x in range(2, 5): for y in range(2, 5): @@ -161,21 +151,8 @@ def test_liberties_after_capture(self): gs_reference.do_move((5, y), go.WHITE) # board configuration and liberties of gs_capture and of gs_reference should be identical - self.assertTrue(np.all(gs_reference.board == gs_capture.board)) - self.assertTrue(np.all(gs_reference.liberty_counts == gs_capture.liberty_counts)) - - def test_copy_maintains_shared_sets(self): - gs = GameState(7) - gs.do_move((4, 4), go.BLACK) - gs.do_move((4, 5), go.BLACK) - - # assert that gs has *the same object* referenced by group/liberty sets - self.assertTrue(gs.group_sets[4][5] is gs.group_sets[4][4]) - self.assertTrue(gs.liberty_sets[4][5] is gs.liberty_sets[4][4]) - - gs_copy = gs.copy() - self.assertTrue(gs_copy.group_sets[4][5] is gs_copy.group_sets[4][4]) - self.assertTrue(gs_copy.liberty_sets[4][5] is gs_copy.liberty_sets[4][4]) + self.assertTrue( gs_reference.is_board_equal(gs_capture )) + self.assertTrue( gs_reference.is_liberty_equal(gs_capture )) if __name__ == '__main__': diff --git a/tests/test_gtp_wrapper.py b/tests/test_gtp_wrapper.py index 345b09afa..db2c88c90 100644 --- a/tests/test_gtp_wrapper.py +++ b/tests/test_gtp_wrapper.py @@ -1,12 +1,12 @@ -from interface.gtp_wrapper import run_gtp -from multiprocessing import Process -from AlphaGo import go import unittest +from AlphaGo import go +from multiprocessing import Process +from interface.gtp_wrapper import run_gtp class PassPlayer(object): def get_move(self, state): - return go.PASS_MOVE + return go.PASS class TestGTPProcess(unittest.TestCase): diff --git a/tests/test_ladders.py b/tests/test_ladders.py index bc4ce2237..da498aaa8 100644 --- a/tests/test_ladders.py +++ b/tests/test_ladders.py @@ -1,18 +1,22 @@ -from AlphaGo.go import BLACK, WHITE import unittest - import parseboard +from AlphaGo.go import BLACK, WHITE +from AlphaGo.go_root import RootState class TestLadder(unittest.TestCase): def test_captured_1(self): - st, moves = parseboard.parse("d b c . . . .|" + + rootState = RootState(size=7) + st = rootState.get_root_game_state() + + moves = parseboard.parse(st, "d b c . . . .|" "B W a . . . .|" ". B . . . . .|" ". . . . . . .|" ". . . . . . .|" ". . . . . W .|") - st.current_player = BLACK + st.set_current_player( BLACK ) # 'a' should catch white in a ladder, but not 'b' self.assertTrue(st.is_ladder_capture(moves['a'])) @@ -28,14 +32,18 @@ def test_captured_1(self): self.assertFalse(st.is_ladder_capture(moves['d'])) # self-atari def test_breaker_1(self): - st, moves = parseboard.parse(". B . . . . .|" + + rootState = RootState(size=7) + st = rootState.get_root_game_state() + + moves = parseboard.parse(st, ". B . . . . .|" "B W a . . W .|" "B b . . . . .|" ". c . . . . .|" ". . . . . . .|" ". . . . . W .|" ". . . . . . .|") - st.current_player = BLACK + st.set_current_player( BLACK ) # 'a' should not be a ladder capture, nor 'b' self.assertFalse(st.is_ladder_capture(moves['a'])) @@ -50,14 +58,18 @@ def test_breaker_1(self): self.assertFalse(st.is_ladder_capture(moves['c'])) def test_missing_ladder_breaker_1(self): - st, moves = parseboard.parse(". B . . . . .|" + + rootState = RootState(size=7) + st = rootState.get_root_game_state() + + moves = parseboard.parse(st, ". B . . . . .|" "B W B . . W .|" "B a c . . . .|" ". b . . . . .|" ". . . . . . .|" ". W . . . . .|" ". . . . . . .|") - st.current_player = WHITE + st.set_current_player( WHITE ) # a should not be an escape move for white self.assertFalse(st.is_ladder_escape(moves['a'])) @@ -69,25 +81,33 @@ def test_missing_ladder_breaker_1(self): self.assertFalse(st.is_ladder_capture(moves['c'])) def test_capture_to_escape_1(self): - st, moves = parseboard.parse(". O X . . .|" + + rootState = RootState(size=7) + st = rootState.get_root_game_state() + + moves = parseboard.parse(st, ". O X . . .|" ". X O X . .|" ". . O X . .|" ". . a . . .|" ". O . . . .|" ". . . . . .|") - st.current_player = BLACK + st.set_current_player( BLACK ) # 'a' is not a capture because of ataris self.assertFalse(st.is_ladder_capture(moves['a'])) def test_throw_in_1(self): - st, moves = parseboard.parse("X a O X . .|" + + rootState = RootState(size=7) + st = rootState.get_root_game_state() + + moves = parseboard.parse(st, "X a O X . .|" "b O O X . .|" "O O X X . .|" "X X . . . .|" ". . . . . .|" ". . . O . .|") - st.current_player = BLACK + st.set_current_player( BLACK ) # 'a' or 'b' will capture self.assertTrue(st.is_ladder_capture(moves['a'])) @@ -98,7 +118,11 @@ def test_throw_in_1(self): self.assertFalse(st.is_ladder_escape(moves['b'])) def test_snapback_1(self): - st, moves = parseboard.parse(". . . . . . . . .|" + + rootState = RootState(size=9) + st = rootState.get_root_game_state() + + moves = parseboard.parse(st, ". . . . . . . . .|" ". . . . . . . . .|" ". . X X X . . . .|" ". . O . . . . . .|" @@ -107,26 +131,36 @@ def test_snapback_1(self): ". . X O X . . . .|" ". . . X . . . . .|" ". . . . . . . . .|") - st.current_player = WHITE + + st.set_current_player( WHITE ) # 'a' is not an escape for white self.assertFalse(st.is_ladder_escape(moves['a'])) + def test_two_captures(self): - st, moves = parseboard.parse(". . . . . .|" + + rootState = RootState(size=7) + st = rootState.get_root_game_state() + + moves = parseboard.parse(st, ". . . . . .|" ". . . . . .|" ". . a b . .|" ". X O O X .|" ". . X X . .|" ". . . . . .|") - st.current_player = BLACK + st.set_current_player( BLACK ) # both 'a' and 'b' should be ladder captures self.assertTrue(st.is_ladder_capture(moves['a'])) self.assertTrue(st.is_ladder_capture(moves['b'])) def test_two_escapes(self): - st, moves = parseboard.parse(". . X . . .|" + + rootState = RootState(size=7) + st = rootState.get_root_game_state() + + moves = parseboard.parse(st, ". . X . . .|" ". X O a . .|" ". X c X . .|" ". O X b . .|" @@ -135,11 +169,11 @@ def test_two_escapes(self): # place a white stone at c, and reset player to white st.do_move(moves['c'], color=WHITE) - st.current_player = WHITE + st.set_current_player( WHITE ) # both 'a' and 'b' should be considered escape moves for white after 'O' at c self.assertTrue(st.is_ladder_escape(moves['a'])) - self.assertTrue(st.is_ladder_escape(moves['b'], prey=moves['c'])) + self.assertTrue(st.is_ladder_escape(moves['b'])) if __name__ == '__main__': diff --git a/tests/test_liberties.py b/tests/test_liberties.py deleted file mode 100644 index 66599a15a..000000000 --- a/tests/test_liberties.py +++ /dev/null @@ -1,44 +0,0 @@ -from AlphaGo.go import GameState -import unittest - - -class TestLiberties(unittest.TestCase): - - def setUp(self): - self.s = GameState() - self.s.do_move((4, 5)) - self.s.do_move((5, 5)) - self.s.do_move((5, 6)) - self.s.do_move((10, 10)) - self.s.do_move((4, 6)) - self.s.do_move((10, 11)) - self.s.do_move((6, 6)) - self.s.do_move((9, 10)) - - def test_curr_liberties(self): - self.assertEqual(self.s.liberty_counts[5][5], 2) - self.assertEqual(self.s.liberty_counts[4][5], 8) - self.assertEqual(self.s.liberty_counts[5][6], 8) - - def test_neighbors_edge_cases(self): - - st = GameState() - st.do_move((0, 0)) # B B . . . . . - st.do_move((5, 5)) # B W . . . . . - st.do_move((0, 1)) # . . . . . . . - st.do_move((6, 6)) # . . . . . . . - st.do_move((1, 0)) # . . . . . W . - st.do_move((1, 1)) # . . . . . . W - - # get_group in the corner - self.assertEqual(len(st.get_group((0, 0))), 3, "group size in corner") - - # get_group of an empty space - self.assertEqual(len(st.get_group((4, 4))), 0, "group size of empty space") - - # get_group of a single piece - self.assertEqual(len(st.get_group((5, 5))), 1, "group size of single piece") - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/test_mcts.py b/tests/test_mcts.py index d31d6de46..4fddc1979 100644 --- a/tests/test_mcts.py +++ b/tests/test_mcts.py @@ -1,14 +1,15 @@ -from AlphaGo.go import GameState -from AlphaGo.mcts import MCTS, TreeNode -from operator import itemgetter -import numpy as np import unittest +import numpy as np +from operator import itemgetter +from AlphaGo.go_root import RootState +from AlphaGo.mcts import MCTS, TreeNode class TestTreeNode(unittest.TestCase): def setUp(self): - self.gs = GameState() + self.root = RootState() + self.gs = self.root.get_root_game_state() self.node = TreeNode(None, 1.0) def test_selection(self): @@ -55,7 +56,8 @@ def test_update_recursive(self): class TestMCTS(unittest.TestCase): def setUp(self): - self.gs = GameState() + self.root = RootState() + self.gs = self.root.get_root_game_state() self.mcts = MCTS(dummy_value, dummy_policy, dummy_rollout, n_playout=2) def _count_expansions(self): @@ -83,7 +85,7 @@ def test_playout_with_pass(self): # Test that playout handles the end of the game (i.e. passing/no moves). Mock this by # creating a policy that returns nothing after 4 moves. def stop_early_policy(state): - if len(state.history) <= 4: + if len(state.get_history()) <= 4: return dummy_policy(state) else: return [] @@ -132,4 +134,4 @@ def dummy_value(state): if __name__ == '__main__': - unittest.main() + unittest.main() \ No newline at end of file diff --git a/tests/test_players.py b/tests/test_players.py index ea0c7de4e..fadc47f9e 100644 --- a/tests/test_players.py +++ b/tests/test_players.py @@ -1,6 +1,6 @@ -from AlphaGo.ai import ProbabilisticPolicyPlayer -import numpy as np import unittest +import numpy as np +from AlphaGo.ai import ProbabilisticPolicyPlayer class TestProbabilisticPolicyPlayer(unittest.TestCase): diff --git a/tests/test_policy.py b/tests/test_policy.py index 9494f4765..6888e2fe1 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -1,35 +1,48 @@ -from AlphaGo.models.policy import CNNPolicy, ResnetPolicy +import os +import unittest +import numpy as np from AlphaGo import go -from AlphaGo.go import GameState +from AlphaGo.go_root import RootState +from AlphaGo.models.policy import CNNPolicy, ResnetPolicy from AlphaGo.ai import GreedyPolicyPlayer, ProbabilisticPolicyPlayer -import numpy as np -import unittest -import os class TestCNNPolicy(unittest.TestCase): def test_default_policy(self): + + rootState = RootState(size=19) + state = rootState.get_root_game_state() + policy = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"]) - policy.eval_state(GameState()) + policy.eval_state(state) # just hope nothing breaks def test_batch_eval_state(self): + + rootState = RootState(size=19) + policy = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"]) - results = policy.batch_eval_state([GameState(), GameState()]) + results = policy.batch_eval_state([rootState.get_root_game_state(), rootState.get_root_game_state()]) self.assertEqual(len(results), 2) # one result per GameState self.assertEqual(len(results[0]), 361) # each one has 361 (move,prob) pairs def test_output_size(self): + + rootState = RootState(size=19) + policy19 = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"], board=19) - output = policy19.forward(policy19.preprocessor.state_to_tensor(GameState(19))) + output = policy19.forward(policy19.preprocessor.state_to_tensor(rootState.get_root_game_state())) self.assertEqual(output.shape, (1, 19 * 19)) + rootState = RootState(size=13) + policy13 = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"], board=13) - output = policy13.forward(policy13.preprocessor.state_to_tensor(GameState(13))) + output = policy13.forward(policy13.preprocessor.state_to_tensor(rootState.get_root_game_state())) self.assertEqual(output.shape, (1, 13 * 13)) def test_save_load(self): + policy = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"]) model_file = 'TESTPOLICY.json' @@ -59,19 +72,27 @@ def test_save_load(self): class TestResnetPolicy(unittest.TestCase): def test_default_policy(self): + + rootState = RootState(size=19) + policy = ResnetPolicy(["board", "liberties", "sensibleness", "capture_size"]) - policy.eval_state(GameState()) + policy.eval_state(rootState.get_root_game_state()) # just hope nothing breaks def test_batch_eval_state(self): + + rootState = RootState(size=19) + policy = ResnetPolicy(["board", "liberties", "sensibleness", "capture_size"]) - results = policy.batch_eval_state([GameState(), GameState()]) + results = policy.batch_eval_state([rootState.get_root_game_state(), rootState.get_root_game_state()]) self.assertEqual(len(results), 2) # one result per GameState self.assertEqual(len(results[0]), 361) # each one has 361 (move,prob) pairs def test_save_load(self): - """Identical to above test_save_load """ + Identical to above test_save_load + """ + policy = ResnetPolicy(["board", "liberties", "sensibleness", "capture_size"]) model_file = 'TESTPOLICY.json' @@ -105,25 +126,34 @@ def test_save_load(self): class TestPlayers(unittest.TestCase): def test_greedy_player(self): - gs = GameState() + + rootState = RootState(size=19) + + gs = rootState.get_root_game_state() policy = CNNPolicy(["board", "ones", "turns_since"]) player = GreedyPolicyPlayer(policy) - for i in range(20): + for _ in range(20): move = player.get_move(gs) - self.assertIsNotNone(move) + self.assertNotEqual(move, go.PASS) gs.do_move(move) def test_probabilistic_player(self): - gs = GameState() + + rootState = RootState(size=19) + + gs = rootState.get_root_game_state() policy = CNNPolicy(["board", "ones", "turns_since"]) player = ProbabilisticPolicyPlayer(policy) - for i in range(20): + for _ in range(20): move = player.get_move(gs) - self.assertIsNotNone(move) + self.assertNotEqual(move, go.PASS) gs.do_move(move) def test_sensible_probabilistic(self): - gs = GameState() + + rootState = RootState(size=19) + + gs = rootState.get_root_game_state() policy = CNNPolicy(["board", "ones", "turns_since"]) player = ProbabilisticPolicyPlayer(policy) empty = (10, 10) @@ -131,11 +161,14 @@ def test_sensible_probabilistic(self): for y in range(19): if (x, y) != empty: gs.do_move((x, y), go.BLACK) - gs.current_player = go.BLACK - self.assertIsNone(player.get_move(gs)) + gs.set_current_player( go.BLACK ) + self.assertEqual(player.get_move(gs), go.PASS) def test_sensible_greedy(self): - gs = GameState() + + rootState = RootState(size=19) + + gs = rootState.get_root_game_state() policy = CNNPolicy(["board", "ones", "turns_since"]) player = GreedyPolicyPlayer(policy) empty = (10, 10) @@ -143,8 +176,9 @@ def test_sensible_greedy(self): for y in range(19): if (x, y) != empty: gs.do_move((x, y), go.BLACK) - gs.current_player = go.BLACK - self.assertIsNone(player.get_move(gs)) + + gs.set_current_player( go.BLACK ) + self.assertEqual(player.get_move(gs), go.PASS) if __name__ == '__main__': diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index d81102051..3a6790eec 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -1,11 +1,12 @@ -from AlphaGo.preprocessing.preprocessing import Preprocess -import AlphaGo.go as go -import numpy as np import unittest import parseboard +import numpy as np +import AlphaGo.go as go +from AlphaGo.go_root import RootState +from AlphaGo.preprocessing.preprocessing import Preprocess -def simple_board(): +def simple_board(gs): # make a tiny board for the sake of testing and hand-coding expected results # # X @@ -20,8 +21,6 @@ def simple_board(): # # where k is a ko position (white was just captured) - gs = go.GameState(size=7) - # ladder-looking thing in the top-left gs.do_move((0, 0)) # B gs.do_move((1, 0)) # W @@ -39,10 +38,8 @@ def simple_board(): gs.do_move((4, 3)) # W - the ko position gs.do_move((4, 4)) # B - does the capture - return gs - -def self_atari_board(): +def self_atari_board(gs): # another tiny board for testing self-atari specifically. # positions marked with 'a' are self-atari for black # @@ -57,7 +54,6 @@ def self_atari_board(): # . . . . . . . 6 # # current_player = black - gs = go.GameState(size=7) gs.do_move((2, 4), go.BLACK) gs.do_move((4, 4), go.BLACK) @@ -73,10 +69,8 @@ def self_atari_board(): gs.do_move((3, 5), go.WHITE) gs.do_move((4, 5), go.WHITE) - return gs - -def capture_board(): +def capture_board(gs): # another small board, this one with imminent captures # # X @@ -90,7 +84,6 @@ def capture_board(): # . . . . W B . 6 # # current_player = black - gs = go.GameState(size=7) black = [(2, 0), (3, 0), (1, 1), (4, 1), (1, 2), (2, 3), (5, 4), (6, 5), (5, 6)] white = [(2, 1), (3, 1), (2, 2), (4, 4), (3, 5), (5, 5), (4, 6)] @@ -99,9 +92,7 @@ def capture_board(): gs.do_move(B, go.BLACK) for W in white: gs.do_move(W, go.WHITE) - gs.current_player = go.BLACK - - return gs + gs.set_current_player( go.BLACK ) class TestPreprocessingFeatures(unittest.TestCase): @@ -114,8 +105,12 @@ class TestPreprocessingFeatures(unittest.TestCase): """ def test_get_board(self): - gs = simple_board() - pp = Preprocess(["board"]) + + rootState = RootState(size=7) + gs = rootState.get_root_game_state() + + simple_board(gs) + pp = Preprocess(["board"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) white_pos = np.asarray([ @@ -134,26 +129,33 @@ def test_get_board(self): [0, 0, 1, 0, 1, 0, 0], [0, 0, 0, 1, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0]]) - empty_pos = np.ones((gs.size, gs.size)) - (white_pos + black_pos) + empty_pos = np.ones((gs.get_size(), gs.get_size())) - (white_pos + black_pos) # check number of planes - self.assertEqual(feature.shape, (gs.size, gs.size, 3)) + self.assertEqual(feature.shape, (gs.get_size(), gs.get_size(), 3)) # check return value against hand-coded expectation # (given that current_player is white) self.assertTrue(np.all(feature == np.dstack((white_pos, black_pos, empty_pos)))) def test_get_turns_since(self): - gs = simple_board() - pp = Preprocess(["turns_since"]) + + rootState = RootState(size=7) + gs = rootState.get_root_game_state() + + simple_board(gs) + pp = Preprocess(["turns_since"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) - one_hot_turns = np.zeros((gs.size, gs.size, 8)) + one_hot_turns = np.zeros((gs.get_size(), gs.get_size(), 8)) - rev_moves = gs.history[::-1] + rev_moves = list(gs.get_history()) + rev_moves = rev_moves[::-1] + + board = gs.get_board() - for x in range(gs.size): - for y in range(gs.size): - if gs.board[x, y] != go.EMPTY: + for x in range(gs.get_size()): + for y in range(gs.get_size()): + if board[x, y] != go.EMPTY: # find most recent move at x, y age = rev_moves.index((x, y)) one_hot_turns[x, y, min(age, 7)] = 1 @@ -161,13 +163,17 @@ def test_get_turns_since(self): self.assertTrue(np.all(feature == one_hot_turns)) def test_get_liberties(self): - gs = simple_board() - pp = Preprocess(["liberties"]) + + rootState = RootState(size=7) + gs = rootState.get_root_game_state() + + simple_board(gs) + pp = Preprocess(["liberties"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) # todo - test liberties when > 8 - one_hot_liberties = np.zeros((gs.size, gs.size, 8)) + one_hot_liberties = np.zeros((gs.get_size(), gs.get_size(), 8)) # black piece at (4,4) has a single liberty: (4,3) one_hot_liberties[4, 4, 0] = 1 @@ -194,17 +200,21 @@ def test_get_liberties(self): "bad expectation: stones with %d liberties" % (i + 1)) def test_get_capture_size(self): - gs = capture_board() - pp = Preprocess(["capture_size"]) + + rootState = RootState(size=7) + gs = rootState.get_root_game_state() + + capture_board(gs) + pp = Preprocess(["capture_size"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) - score_before = gs.num_white_prisoners - one_hot_capture = np.zeros((gs.size, gs.size, 8)) + score_before = gs.get_captures_white() + one_hot_capture = np.zeros((gs.get_size(), gs.get_size(), 8)) # there is no capture available; all legal moves are zero-capture for (x, y) in gs.get_legal_moves(): copy = gs.copy() copy.do_move((x, y)) - num_captured = copy.num_white_prisoners - score_before + num_captured = copy.get_captures_white() - score_before one_hot_capture[x, y, min(7, num_captured)] = 1 for i in range(8): @@ -213,11 +223,15 @@ def test_get_capture_size(self): "bad expectation: capturing %d stones" % i) def test_get_self_atari_size(self): - gs = self_atari_board() - pp = Preprocess(["self_atari_size"]) + + rootState = RootState(size=7) + gs = rootState.get_root_game_state() + + self_atari_board(gs) + pp = Preprocess(["self_atari_size"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) - one_hot_self_atari = np.zeros((gs.size, gs.size, 8)) + one_hot_self_atari = np.zeros((gs.get_size(), gs.get_size(), 8)) # self atari of size 1 at position 0,0 one_hot_self_atari[0, 0, 0] = 1 # self atari of size 3 at position 3,4 @@ -226,11 +240,15 @@ def test_get_self_atari_size(self): self.assertTrue(np.all(feature == one_hot_self_atari)) def test_get_self_atari_size_cap(self): - gs = capture_board() - pp = Preprocess(["self_atari_size"]) + + rootState = RootState(size=7) + gs = rootState.get_root_game_state() + + capture_board(gs) + pp = Preprocess(["self_atari_size"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) - one_hot_self_atari = np.zeros((gs.size, gs.size, 8)) + one_hot_self_atari = np.zeros((gs.get_size(), gs.get_size(), 8)) # self atari of size 1 at the ko position and just below it one_hot_self_atari[4, 5, 0] = 1 one_hot_self_atari[3, 6, 0] = 1 @@ -240,17 +258,24 @@ def test_get_self_atari_size_cap(self): self.assertTrue(np.all(feature == one_hot_self_atari)) def test_get_liberties_after(self): - gs = simple_board() - pp = Preprocess(["liberties_after"]) + + rootState = RootState(size=7) + gs = rootState.get_root_game_state() + + simple_board(gs) + pp = Preprocess(["liberties_after"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) - one_hot_liberties = np.zeros((gs.size, gs.size, 8)) + one_hot_liberties = np.zeros((gs.get_size(), gs.get_size(), 8)) # TODO (?) hand-code? for (x, y) in gs.get_legal_moves(): copy = gs.copy() copy.do_move((x, y)) - libs = copy.liberty_counts[x, y] + + liberty = copy.get_liberty() + + libs = liberty[x, y] if libs < 7: one_hot_liberties[x, y, libs - 1] = 1 else: @@ -264,16 +289,23 @@ def test_get_liberties_after(self): def test_get_liberties_after_cap(self): """A copy of test_get_liberties_after but where captures are imminent """ - gs = capture_board() - pp = Preprocess(["liberties_after"]) + + rootState = RootState(size=7) + gs = rootState.get_root_game_state() + + capture_board(gs) + pp = Preprocess(["liberties_after"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) - one_hot_liberties = np.zeros((gs.size, gs.size, 8)) + one_hot_liberties = np.zeros((gs.get_size(), gs.get_size(), 8)) for (x, y) in gs.get_legal_moves(): copy = gs.copy() copy.do_move((x, y)) - libs = copy.liberty_counts[x, y] + + liberty = copy.get_liberty() + + libs = liberty[x, y] one_hot_liberties[x, y, min(libs - 1, 7)] = 1 for i in range(8): @@ -282,71 +314,122 @@ def test_get_liberties_after_cap(self): "bad expectation: stones with %d liberties after move" % (i + 1)) def test_get_ladder_capture(self): - gs, moves = parseboard.parse(". . . . . . .|" + + rootState = RootState(size=7) + gs = rootState.get_root_game_state() + + moves = parseboard.parse(gs, ". . . . . . .|" "B W a . . . .|" ". B . . . . .|" ". . . . . . .|" ". . . . . . .|" ". . . . . W .|") - pp = Preprocess(["ladder_capture"]) + pp = Preprocess(["ladder_capture"], size=7) feature = pp.state_to_tensor(gs)[0, 0] # 1D tensor; no need to transpose - expectation = np.zeros((gs.size, gs.size)) + expectation = np.zeros((gs.get_size(), gs.get_size())) expectation[moves['a']] = 1 self.assertTrue(np.all(expectation == feature)) def test_get_ladder_escape(self): + + rootState = RootState(size=7) + gs = rootState.get_root_game_state() + # On this board, playing at 'a' is ladder escape because there is a breaker on the right. - gs, moves = parseboard.parse(". B B . . . .|" + moves = parseboard.parse(gs, ". B B . . . .|" "B W a . . . .|" ". B . . . . .|" ". . . . . W .|" ". . . . . . .|" ". . . . . . .|") - pp = Preprocess(["ladder_escape"]) - gs.current_player = go.WHITE + pp = Preprocess(["ladder_escape"], size=7) + gs.set_current_player( go.WHITE ) feature = pp.state_to_tensor(gs)[0, 0] # 1D tensor; no need to transpose - expectation = np.zeros((gs.size, gs.size)) + expectation = np.zeros((gs.get_size(), gs.get_size())) expectation[moves['a']] = 1 self.assertTrue(np.all(expectation == feature)) + def test_two_escapes(self): + + rootState = RootState(size=7) + gs = rootState.get_root_game_state() + + moves = parseboard.parse(gs, ". . X . . .|" + ". X O a . .|" + ". X c X . .|" + ". O X b . .|" + ". . O . . .|" + ". . . . . .|") + + # place a white stone at c, and reset player to white + gs.do_move(moves['c'], color=go.WHITE) + gs.set_current_player( go.WHITE ) + + pp = Preprocess(["ladder_escape"], size=7) + gs.set_current_player( go.WHITE ) + feature = pp.state_to_tensor(gs)[0, 0] # 1D tensor; no need to transpose + + # both 'a' and 'b' should be considered escape moves for white after 'O' at c + + expectation = np.zeros((gs.get_size(), gs.get_size())) + expectation[moves['a']] = 1 + expectation[moves['b']] = 1 + + self.assertTrue(np.all(expectation == feature)) + + def test_get_sensibleness(self): + + rootState = RootState(size=7) + gs = rootState.get_root_game_state() + # TODO - there are no legal eyes at the moment - gs = simple_board() - pp = Preprocess(["sensibleness"]) + simple_board(gs) + pp = Preprocess(["sensibleness"], size=7) feature = pp.state_to_tensor(gs)[0, 0] # 1D tensor; no need to transpose - expectation = np.zeros((gs.size, gs.size)) + expectation = np.zeros((gs.get_size(), gs.get_size())) for (x, y) in gs.get_legal_moves(): if not (gs.is_eye((x, y), go.WHITE)): expectation[x, y] = 1 self.assertTrue(np.all(expectation == feature)) def test_get_legal(self): - gs = simple_board() - pp = Preprocess(["legal"]) + + rootState = RootState(size=7) + gs = rootState.get_root_game_state() + + simple_board(gs) + pp = Preprocess(["legal"], size=7) feature = pp.state_to_tensor(gs)[0, 0] # 1D tensor; no need to transpose - expectation = np.zeros((gs.size, gs.size)) + expectation = np.zeros((gs.get_size(), gs.get_size())) for (x, y) in gs.get_legal_moves(): expectation[x, y] = 1 self.assertTrue(np.all(expectation == feature)) def test_feature_concatenation(self): - gs = simple_board() - pp = Preprocess(["board", "sensibleness", "capture_size"]) + + rootState = RootState(size=7) + gs = rootState.get_root_game_state() + + simple_board(gs) + pp = Preprocess(["board", "sensibleness", "capture_size"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) - expectation = np.zeros((gs.size, gs.size, 3 + 1 + 8)) + expectation = np.zeros((gs.get_size(), gs.get_size(), 3 + 1 + 8)) + + board = gs.get_board() # first three planes: board - expectation[:, :, 0] = (gs.board == go.WHITE) * 1 - expectation[:, :, 1] = (gs.board == go.BLACK) * 1 - expectation[:, :, 2] = (gs.board == go.EMPTY) * 1 + expectation[:, :, 0] = (board == go.WHITE) * 1 + expectation[:, :, 1] = (board == go.BLACK) * 1 + expectation[:, :, 2] = (board == go.EMPTY) * 1 # 4th plane: sensibleness (as in test_get_sensibleness) for (x, y) in gs.get_legal_moves(): @@ -358,6 +441,7 @@ def test_feature_concatenation(self): expectation[x, y, 4] = 1 self.assertTrue(np.all(expectation == feature)) + if __name__ == '__main__': diff --git a/tests/test_reinforcement_policy_trainer.py b/tests/test_reinforcement_policy_trainer.py index 59d3e642a..321ecd73d 100644 --- a/tests/test_reinforcement_policy_trainer.py +++ b/tests/test_reinforcement_policy_trainer.py @@ -1,12 +1,13 @@ import os -from AlphaGo.training.reinforcement_policy_trainer import run_training, log_loss, run_n_games import unittest import numpy as np -import numpy.testing as npt import AlphaGo.go as go +import numpy.testing as npt from keras.optimizers import SGD -from AlphaGo.models.policy import CNNPolicy +from AlphaGo.go_root import RootState from AlphaGo.util import sgf_iter_states +from AlphaGo.models.policy import CNNPolicy +from AlphaGo.training.reinforcement_policy_trainer import run_training, log_loss, run_n_games SGF_FOLDER = os.path.join('tests', 'test_data', 'sgf/') @@ -23,6 +24,9 @@ def _list_mock_games(path): def get_sgf_move_probs(sgf_game, policy, player): + + root = RootState() + with open(sgf_game, "r") as f: sgf_game = f.read() @@ -33,20 +37,23 @@ def get_single_prob(move, move_probs): return 0 return [(move, get_single_prob(move, policy.eval_state(state))) - for (state, move, pl) in sgf_iter_states(sgf_game) if pl == player] + for (state, move, pl) in sgf_iter_states(root, sgf_game) if pl == player] class MockPlayer(object): def __init__(self, policy, sgf_game): + + self.root = RootState() + with open(sgf_game, "r") as f: sgf_game = f.read() - self.moves = [move for (_, move, _) in sgf_iter_states(sgf_game)] + self.moves = [move for (_, move, _) in sgf_iter_states(self.root, sgf_game)] self.policy = policy def get_moves(self, states): - indices = [len(state.history) for state in states] - return [self.moves[i] if i < len(self.moves) else go.PASS_MOVE for i in indices] + indices = [len(state.get_history()) for state in states] + return [self.moves[i] if i < len(self.moves) else go.PASS for i in indices] class MockState(go.GameState): @@ -55,21 +62,28 @@ def __init__(self, predetermined_winner, length, *args, **kwargs): super(MockState, self).__init__(*args, **kwargs) self.predetermined_winner = predetermined_winner self.length = length + self.count = 0 + self.should_end = False + def do_move(self, *args, **kwargs): super(MockState, self).do_move(*args, **kwargs) - if len(self.history) > self.length: + self.count += 1 + if self.count > self.length: self.is_end_of_game = True def get_winner(self): return self.predetermined_winner + + def is_end_of_game(self): + return self.should_end class TestReinforcementPolicyTrainer(unittest.TestCase): def testTrain(self): - model = os.path.join('tests', 'test_data', 'minimodel.json') - init_weights = os.path.join('tests', 'test_data', 'hdf5', 'random_minimodel_weights.hdf5') + model = os.path.join('tests', 'test_data', 'minimodel_policy.json') + init_weights = os.path.join('tests', 'test_data', 'hdf5', 'random_minimodel_policy_weights.hdf5') output = os.path.join('tests', 'test_data', '.tmp.rl.training/') args = [model, init_weights, output, '--game-batch', '1', '--iterations', '1'] run_training(args) @@ -82,11 +96,14 @@ def testTrain(self): def testGradientDirectionChangesWithGameResult(self): def run_and_get_new_weights(init_weights, winners, game): + + root = RootState() + # Create "mock" states that end after 2 moves with a predetermined winner. states = [MockState(winner, 2, size=19) for winner in winners] - policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) - policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel_policy.json')) + policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel_policy.json')) policy1.model.set_weights(init_weights) optimizer = SGD(lr=0.001) policy1.model.compile(loss=log_loss, optimizer=optimizer) @@ -95,12 +112,15 @@ def run_and_get_new_weights(init_weights, winners, game): opponent = MockPlayer(policy2, game) # Run RL training - run_n_games(optimizer, learner, opponent, 2, mock_states=states) + run_n_games(optimizer, root, learner, opponent, 2, mock_states=states) return policy1.model.get_weights() def test_game_gradient(game): - policy = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + + root = RootState() + + policy = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel_policy.json')) initial_parameters = policy.model.get_weights() # Cases 1 and 2 have identical starting models and identical (state, action) pairs, # but they differ in who won the games. @@ -127,8 +147,11 @@ def test_game_gradient(game): def testRunNGamesUpdatesWeights(self): def test_game_run_N(game): - policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) - policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + + root = RootState() + + policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel_policy.json')) + policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel_policy.json')) learner = MockPlayer(policy1, game) opponent = MockPlayer(policy2, game) optimizer = SGD() @@ -136,7 +159,7 @@ def test_game_run_N(game): policy1.model.compile(loss=log_loss, optimizer=optimizer) # Run RL training - run_n_games(optimizer, learner, opponent, 2) + run_n_games(optimizer, root, learner, opponent, 2) # Get new weights for comparison trained_weights = policy1.model.get_weights() @@ -151,10 +174,13 @@ def test_game_run_N(game): def testWinIncreasesMoveProbability(self): def test_game_increase(game): + + root = RootState() + # Create "mock" state that ends after 20 moves with the learner winnning win_state = [MockState(go.BLACK, 20, size=19)] - policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) - policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel_policy.json')) + policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel_policy.json')) learner = MockPlayer(policy1, game) opponent = MockPlayer(policy2, game) optimizer = SGD() @@ -165,7 +191,7 @@ def test_game_increase(game): init_probs = [prob for (mv, prob) in init_move_probs] # Run RL training - run_n_games(optimizer, learner, opponent, 1, mock_states=win_state) + run_n_games(optimizer, root, learner, opponent, 1, mock_states=win_state) # Get new move probabilities for black's moves having finished 1 round of training new_move_probs = get_sgf_move_probs(game, policy1, go.BLACK) @@ -179,10 +205,13 @@ def test_game_increase(game): def testLoseDecreasesMoveProbability(self): def test_game_decrease(game): + + root = RootState() + # Create "mock" state that ends after 20 moves with the learner losing lose_state = [MockState(go.WHITE, 20, size=19)] - policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) - policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel.json')) + policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel_policy.json')) + policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel_policy.json')) learner = MockPlayer(policy1, game) opponent = MockPlayer(policy2, game) optimizer = SGD() @@ -193,7 +222,7 @@ def test_game_decrease(game): init_probs = [prob for (mv, prob) in init_move_probs] # Run RL training - run_n_games(optimizer, learner, opponent, 1, mock_states=lose_state) + run_n_games(optimizer, root, learner, opponent, 1, mock_states=lose_state) # Get new move probabilities for black's moves having finished 1 round of training new_move_probs = get_sgf_move_probs(game, policy1, go.BLACK) diff --git a/tests/test_reinforcement_value_trainer.py b/tests/test_reinforcement_value_trainer.py new file mode 100644 index 000000000..00762504e --- /dev/null +++ b/tests/test_reinforcement_value_trainer.py @@ -0,0 +1,125 @@ +import os +import unittest +import numpy as np +from AlphaGo.go_root import RootState +from AlphaGo.models.value import CNNValue +from AlphaGo.training.reinforcement_value_trainer import FILE_TEST +from AlphaGo.training.reinforcement_value_trainer import FILE_TRAIN +from AlphaGo.training.reinforcement_value_trainer import FILE_METADATA +from AlphaGo.training.reinforcement_value_trainer import FOLDER_WEIGHT +from AlphaGo.training.reinforcement_value_trainer import FILE_VALIDATE +from AlphaGo.training.reinforcement_value_trainer import handle_arguments +from AlphaGo.training.reinforcement_value_trainer import load_indices_from_file +from AlphaGo.training.reinforcement_value_trainer import create_and_save_shuffle_indices + + +class TestCNNValue(unittest.TestCase): + + def setUp(self): + self.value = CNNValue(['board', 'ones', 'turns_since']) + + def test_save_load(self): + self.value.save_model('.tmp.value.json') + copy = CNNValue.load_model('.tmp.value.json') + + # test that loaded class is also an instance of CNNValue + self.assertTrue(isinstance(copy, CNNValue)) + os.remove('.tmp.value.json') + + # test shape + def test_ouput_shape(self): + + rootState = RootState(size=19) + gs = rootState.get_root_game_state() + + val = self.value.eval_state(gs) + self.assertTrue(isinstance(val, np.float64)) + + def testTrain(self): + model = 'tests/test_data/minimodel_value.json' + data = 'tests/test_data/hdf5/value_training_features.hdf5' + output = 'tests/test_data/.tmp.training/' + args = ['train', output, model, data, '--epochs', '1', '-l', '160'] + handle_arguments(args) + + # remove temporary files + os.remove(os.path.join(output, FILE_METADATA)) + os.remove(os.path.join(output, FILE_TRAIN)) + os.remove(os.path.join(output, FILE_VALIDATE)) + os.remove(os.path.join(output, FILE_TEST)) + os.remove(os.path.join(output, FOLDER_WEIGHT, 'weights.00000.hdf5')) + os.rmdir(os.path.join(output, FOLDER_WEIGHT)) + os.rmdir(output) + + def testResumeLearning(self): + model = 'tests/test_data/minimodel_value.json' + data = 'tests/test_data/hdf5/value_training_features.hdf5' + output = 'tests/test_data/.tmp.resume-training/' + args = ['train', output, model, data, '--epochs', '1', '-l', '160'] + handle_arguments(args) + + # resume learning + args = ['train', output, model, data, '--epochs', '2', '-l', '160', + '--weights', 'weights.00000.hdf5'] + handle_arguments(args) + + # remove temporary files + os.remove(os.path.join(output, FILE_METADATA)) + os.remove(os.path.join(output, FILE_TRAIN)) + os.remove(os.path.join(output, FILE_VALIDATE)) + os.remove(os.path.join(output, FILE_TEST)) + os.remove(os.path.join(output, FOLDER_WEIGHT, 'weights.00000.hdf5')) + os.remove(os.path.join(output, FOLDER_WEIGHT, 'weights.00001.hdf5')) + os.rmdir(os.path.join(output, FOLDER_WEIGHT)) + os.rmdir(output) + + def testStateAmount(self): + output = 'tests/test_data/.tmp.state-amount/' + # create folder + if not os.path.exists(output): + os.makedirs(output) + + # shuffle file locations for train/validation/test set + shuffle_file_train = os.path.join(output, FILE_TRAIN) + shuffle_file_val = os.path.join(output, FILE_VALIDATE) + shuffle_file_test = os.path.join(output, FILE_TEST) + + # create shuffle files + create_and_save_shuffle_indices([.9, .05, .05], 1000000000, + 1033, shuffle_file_train, + shuffle_file_val, shuffle_file_test) + + # load from .npz files + # load training set + train_indices = load_indices_from_file(shuffle_file_train) + # count unique rows + unique_indices = len(np.vstack({tuple(row) for row in train_indices})) + self.assertTrue(unique_indices == 7438) + + # load validation set + val_indices = load_indices_from_file(shuffle_file_val) + # count unique rows + unique_indices = len(np.vstack({tuple(row) for row in val_indices})) + self.assertTrue(unique_indices == 413) + + # load training set + test_indices = load_indices_from_file(shuffle_file_test) + # count unique rows + unique_indices = len(np.vstack({tuple(row) for row in test_indices})) + self.assertTrue(unique_indices == 413) + + # combine all set rows + combined_indices = np.concatenate((train_indices, val_indices, test_indices), axis=0) + # count unique rows + unique_indices = len(np.vstack({tuple(row) for row in combined_indices})) + self.assertTrue(unique_indices == (7438 + 413 + 413)) + + # remove temporary files + os.remove(shuffle_file_train) + os.remove(shuffle_file_val) + os.remove(shuffle_file_test) + os.rmdir(output) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_supervised_policy_trainer.py b/tests/test_supervised_policy_trainer.py index dc170e1a2..e97b53234 100644 --- a/tests/test_supervised_policy_trainer.py +++ b/tests/test_supervised_policy_trainer.py @@ -1,21 +1,102 @@ import os -from AlphaGo.training.supervised_policy_trainer import run_training import unittest +import numpy as np +from AlphaGo.training.supervised_policy_trainer import FILE_TEST +from AlphaGo.training.supervised_policy_trainer import FILE_TRAIN +from AlphaGo.training.supervised_policy_trainer import FILE_METADATA +from AlphaGo.training.supervised_policy_trainer import FOLDER_WEIGHT +from AlphaGo.training.supervised_policy_trainer import FILE_VALIDATE +from AlphaGo.training.supervised_policy_trainer import handle_arguments +from AlphaGo.training.supervised_policy_trainer import load_indices_from_file +from AlphaGo.training.supervised_policy_trainer import create_and_save_shuffle_indices class TestSupervisedPolicyTrainer(unittest.TestCase): def testTrain(self): - model = 'tests/test_data/minimodel.json' + model = 'tests/test_data/minimodel_policy.json' data = 'tests/test_data/hdf5/alphago-vs-lee-sedol-features.hdf5' output = 'tests/test_data/.tmp.training/' - args = [model, data, output, '--epochs', '1'] - run_training(args) + args = ['train', output, model, data, '--epochs', '1', '-l', '160'] + handle_arguments(args) - os.remove(os.path.join(output, 'metadata.json')) - os.remove(os.path.join(output, 'shuffle.npz')) - os.remove(os.path.join(output, 'weights.00000.hdf5')) + # remove temporary files + os.remove(os.path.join(output, FILE_METADATA)) + os.remove(os.path.join(output, FILE_TRAIN)) + os.remove(os.path.join(output, FILE_VALIDATE)) + os.remove(os.path.join(output, FILE_TEST)) + os.remove(os.path.join(output, FOLDER_WEIGHT, 'weights.00000.hdf5')) + os.rmdir(os.path.join(output, FOLDER_WEIGHT)) + os.rmdir(output) + + def testResumeLearning(self): + model = 'tests/test_data/minimodel_policy.json' + data = 'tests/test_data/hdf5/alphago-vs-lee-sedol-features.hdf5' + output = 'tests/test_data/.tmp.resume-training/' + args = ['train', output, model, data, '--epochs', '1', '-l', '160'] + handle_arguments(args) + + # resume learning + args = ['train', output, model, data, '--epochs', '2', '-l', '160', + '--weights', 'weights.00000.hdf5'] + handle_arguments(args) + + # remove temporary files + os.remove(os.path.join(output, FILE_METADATA)) + os.remove(os.path.join(output, FILE_TRAIN)) + os.remove(os.path.join(output, FILE_VALIDATE)) + os.remove(os.path.join(output, FILE_TEST)) + os.remove(os.path.join(output, FOLDER_WEIGHT, 'weights.00000.hdf5')) + os.remove(os.path.join(output, FOLDER_WEIGHT, 'weights.00001.hdf5')) + os.rmdir(os.path.join(output, FOLDER_WEIGHT)) + os.rmdir(output) + + def testStateAmount(self): + output = 'tests/test_data/.tmp.state-amount/' + # create folder + if not os.path.exists(output): + os.makedirs(output) + + # shuffle file locations for train/validation/test set + shuffle_file_train = os.path.join(output, "shuffle_train.npz") + shuffle_file_val = os.path.join(output, "shuffle_validate.npz") + shuffle_file_test = os.path.join(output, "shuffle_test.npz") + + # create shuffle files + create_and_save_shuffle_indices([.9, .05, .05], 1000000000, + 1033, shuffle_file_train, + shuffle_file_val, shuffle_file_test) + + # load from .npz files + # load training set + train_indices = load_indices_from_file(shuffle_file_train) + # count unique rows + unique_indices = len(np.vstack({tuple(row) for row in train_indices})) + self.assertTrue(unique_indices == 7438) + + # load validation set + val_indices = load_indices_from_file(shuffle_file_val) + # count unique rows + unique_indices = len(np.vstack({tuple(row) for row in val_indices})) + self.assertTrue(unique_indices == 413) + + # load training set + test_indices = load_indices_from_file(shuffle_file_test) + # count unique rows + unique_indices = len(np.vstack({tuple(row) for row in test_indices})) + self.assertTrue(unique_indices == 413) + + # combine all set rows + combined_indices = np.concatenate((train_indices, val_indices, test_indices), axis=0) + # count unique rows + unique_indices = len(np.vstack({tuple(row) for row in combined_indices})) + self.assertTrue(unique_indices == (7438 + 413 + 413)) + + # remove temporary files + os.remove(shuffle_file_train) + os.remove(shuffle_file_val) + os.remove(shuffle_file_test) os.rmdir(output) if __name__ == '__main__': - unittest.main() + unittest.main() \ No newline at end of file diff --git a/tests/test_value.py b/tests/test_value.py new file mode 100644 index 000000000..5ec81acb7 --- /dev/null +++ b/tests/test_value.py @@ -0,0 +1,133 @@ +import os +import unittest +import numpy as np +from AlphaGo import go +from AlphaGo.ai import ValuePlayer +from AlphaGo.go_root import RootState +from AlphaGo.models.value import CNNValue + + +class TestCNNValue(unittest.TestCase): + + def test_default_value(self): + + rootState = RootState(size=19) + state = rootState.get_root_game_state() + + value = CNNValue(["board", "liberties", "sensibleness", "capture_size"]) + value.eval_state(state) + # just hope nothing breaks + + def test_batch_eval_state(self): + + rootState = RootState(size=19) + + value = CNNValue(["board", "liberties", "sensibleness", "capture_size"]) + results = value.batch_eval_state([rootState.get_root_game_state(), rootState.get_root_game_state()]) + self.assertEqual(len(results), 2) # one result per GameState + self.assertTrue(isinstance(results[0], np.float64)) + self.assertTrue(isinstance(results[1], np.float64)) + + def test_output_size(self): + + rootState = RootState(size=19) + state = rootState.get_root_game_state() + + value19 = CNNValue(["board", "liberties", "sensibleness", "capture_size"], board=19) + output = value19.forward(value19.preprocessor.state_to_tensor(state)) + self.assertEqual(output.shape, (1, 1)) + + rootState = RootState(size=13) + state = rootState.get_root_game_state() + + value13 = CNNValue(["board", "liberties", "sensibleness", "capture_size"], board=13) + output = value13.forward(value13.preprocessor.state_to_tensor(state)) + self.assertEqual(output.shape, (1, 1)) + + def test_save_load(self): + value = CNNValue(["board", "liberties", "sensibleness", "capture_size"]) + + model_file = 'TESTVALUE.json' + weights_file = 'TESTWEIGHTS.h5' + model_file2 = 'TESTVALUE2.json' + weights_file2 = 'TESTWEIGHTS2.h5' + + # test saving model/weights separately + value.save_model(model_file) + value.model.save_weights(weights_file, overwrite=True) + # test saving them together + value.save_model(model_file2, weights_file2) + + copyvalue = CNNValue.load_model(model_file) + copyvalue.model.load_weights(weights_file) + + copyvalue2 = CNNValue.load_model(model_file2) + + for w1, w2 in zip(copyvalue.model.get_weights(), copyvalue2.model.get_weights()): + self.assertTrue(np.all(w1 == w2)) + + os.remove(model_file) + os.remove(weights_file) + os.remove(model_file2) + os.remove(weights_file2) + + +class TestValuePlayers(unittest.TestCase): + + def test_greedy_player(self): + + rootState = RootState(size=9) + gs = rootState.get_root_game_state() + + value = CNNValue(["board", "ones", "turns_since"], board=9) + player = ValuePlayer(value, greedy_start=0) + for i in range(10): + move = player.get_move(gs) + self.assertNotEqual(move, go.PASS) + gs.do_move(move) + + def test_probabilistic_player(self): + + rootState = RootState(size=9) + gs = rootState.get_root_game_state() + + value = CNNValue(["board", "ones", "turns_since"], board=9) + player = ValuePlayer(value) + for i in range(10): + move = player.get_move(gs) + self.assertNotEqual(move, go.PASS) + gs.do_move(move) + + def test_sensible_probabilistic(self): + + rootState = RootState(size=19) + gs = rootState.get_root_game_state() + + value = CNNValue(["board", "ones", "turns_since"]) + player = ValuePlayer(value) + empty = (10, 10) + for x in range(19): + for y in range(19): + if (x, y) != empty: + gs.do_move((x, y), go.BLACK) + gs.set_current_player( go.BLACK ) + self.assertEqual(player.get_move(gs), go.PASS) + + def test_sensible_greedy(self): + + rootState = RootState(size=19) + gs = rootState.get_root_game_state() + + value = CNNValue(["board", "ones", "turns_since"]) + player = ValuePlayer(value, greedy_start=0) + empty = (10, 10) + for x in range(19): + for y in range(19): + if (x, y) != empty: + gs.do_move((x, y), go.BLACK) + gs.set_current_player( go.BLACK ) + self.assertEqual(player.get_move(gs), go.PASS) + + +if __name__ == '__main__': + unittest.main() From 5787548b6488f4eef34b3d6727d8cfc003402ba0 Mon Sep 17 00:00:00 2001 From: MaMiFreak Date: Mon, 15 May 2017 15:10:01 +0100 Subject: [PATCH 153/191] added boardsize warning --- AlphaGo/go_root.pyx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/AlphaGo/go_root.pyx b/AlphaGo/go_root.pyx index 6c9bca08c..20eb1551b 100644 --- a/AlphaGo/go_root.pyx +++ b/AlphaGo/go_root.pyx @@ -201,6 +201,9 @@ cdef class RootState: when destoyed all arrays are freed in order to prevent a memory leak """ + if size > 19: + raise IllegalBoardSize( "board size >19 not yet supported" ) + # set size self.size = size self.board_size = size * size @@ -357,4 +360,8 @@ cdef class RootState: return new GameState initialized as a new game """ - return self.get_root_state() \ No newline at end of file + return self.get_root_state() + + +class IllegalBoardSize(Exception): + pass From 5cb0aba3d6ff0fdced89ac6a151e446c34536ca7 Mon Sep 17 00:00:00 2001 From: wrongu Date: Wed, 17 May 2017 12:22:18 -0400 Subject: [PATCH 154/191] readme points to cython branch --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7943991d0..f46b9bf54 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ See the [project wiki](https://github.com/Rochester-NRT/RocAlphaGo/wiki). # Current project status -_This is not yet a full implementation of AlphaGo_. Development is being carried out on the `develop` branch. +_This is not yet a full implementation of AlphaGo_. Development is being carried out on the `develop` branch. The current emphasis is on speed optimizations, which are necessary to complete training of the value-network and to create feasible tree-search. See the `cython-optimization` branch for more. Selected data (i.e. trained models) are released in our [data repository](http://github.com/Rochester-NRT/RocAlphaGo.data). From 59d96cc8c69b5b4407aa25c56bbe52831e9dd6be Mon Sep 17 00:00:00 2001 From: MaMiFreak Date: Fri, 19 May 2017 18:05:59 +0100 Subject: [PATCH 155/191] BETA v2 removed RootState, all unittest except super-ko pass, small speed improvements and additional commenting --- AlphaGo/go.pxd | 279 +++++- AlphaGo/go.pyx | 855 +++++++++++++----- AlphaGo/go_data.pxd | 224 ++++- AlphaGo/go_data.pyx | 277 +++++- AlphaGo/go_root.pxd | 49 - AlphaGo/go_root.pyx | 367 -------- AlphaGo/preprocessing/game_converter.py | 13 +- .../preprocessing/generate_value_training.py | 18 +- AlphaGo/preprocessing/preprocessing.pxd | 139 ++- AlphaGo/preprocessing/preprocessing.pyx | 202 +++-- .../training/reinforcement_policy_trainer.py | 25 +- AlphaGo/util.py | 21 +- interface/Play.py | 8 +- interface/gtp_wrapper.py | 10 +- requirements.txt | 2 +- setup.py | 19 +- tests/parseboard.py | 11 +- tests/test_game_converter.py | 7 +- tests/test_gamestate.py | 27 +- tests/test_ladders.py | 43 +- tests/test_mcts.py | 8 +- tests/test_policy.py | 59 +- tests/test_preprocessing.py | 160 ++-- tests/test_reinforcement_policy_trainer.py | 54 +- tests/test_reinforcement_value_trainer.py | 5 +- tests/test_value.py | 29 +- 26 files changed, 1811 insertions(+), 1100 deletions(-) delete mode 100644 AlphaGo/go_root.pxd delete mode 100644 AlphaGo/go_root.pyx diff --git a/AlphaGo/go.pxd b/AlphaGo/go.pxd index e8305167b..b8319339b 100644 --- a/AlphaGo/go.pxd +++ b/AlphaGo/go.pxd @@ -30,15 +30,19 @@ cdef class GameState: cdef char player_current cdef char player_opponent + # amount of black stones captured cdef short capture_black + # amount of white stones captured cdef short capture_white + # amount of passes by black cdef short passes_black + # amount of passes by white cdef short passes_white - # TODO replace list with struct - cdef list history + # list with move history cdef Locations_List *moves_history + # list with legal moves cdef Locations_List *moves_legal @@ -60,81 +64,271 @@ cdef class GameState: # # ############################################################################ - cdef void initialize_duplicate( self, GameState copyState ) + cdef void initialize_new( self, char size ) + """ + initialize this state as empty state + """ + + cdef void initialize_duplicate( self, GameState copyState ) + """ + Initialize all variables as a copy of copy_state + """ + ############################################################################ # private cdef functions used for game-play # # # ############################################################################ - cdef bint is_legal_move( self, short location, Group **board, short ko ) - cdef bint has_liberty_after( self, short location, Group **board ) - cdef short calculate_board_location( self, char x, char y ) - cdef tuple calculate_tuple_location( self, short location ) + cdef bint is_legal_move( self, short location, Group **board, short ko ) + """ + check if playing at location is a legal move to make + """ + + cdef bint has_liberty_after( self, short location, Group **board ) + """ + check if a play at location results in an alive group + - has liberty + - conects to group with >= 2 liberty + - captures enemy group + """ + + cdef short calculate_board_location( self, char x, char y ) + """ + return location on board + no checks on outside board + x = columns + y = rows + """ + + cdef tuple calculate_tuple_location( self, short location ) + """ + return location on board as a tupple + no checks on outside board + """ + cdef void set_moves_legal_list( self, Locations_List *moves_legal ) + """ + generate moves_legal list + """ + + cdef void combine_groups( self, Group* group_keep, Group* group_remove, Group **board ) + """ + combine group_keep and group_remove and replace group_remove on the board + """ + + cdef void remove_group( self, Group* group_remove, Group **board, short* ko ) + """ + remove group from board -> set all locations to group_empty + """ - cdef void combine_groups( self, Group* group_keep, Group* group_remove, Group **board ) - cdef void remove_group( self, Group* group_remove, Group **board, short* ko ) - cdef void update_hashes( self, Group* group ) - cdef void add_to_group( self, short location, Group **board, short* ko, short* count_captures ) + cdef void update_hashes( self, Group* group ) + """ + update all locations affected by removal of group + """ + + cdef void add_to_group( self, short location, Group **board, short* ko, short* count_captures ) + """ + check if a stone on location is connected to a group, kills a group + or is a new group on the board + """ ############################################################################ # private cdef functions used for feature generation # # # ############################################################################ - cdef long generate_12d_hash( self, short centre ) - cdef long generate_3x3_hash( self, short centre ) - cdef void get_group_after( self, short* groups_after, char* locations, char* captures, short location ) - cdef bint is_true_eye( self, short location, Locations_List* eyes, char owner ) + cdef long generate_12d_hash( self, short centre ) + """ + generate 12d hash around centre location + """ + + cdef long generate_3x3_hash( self, short centre ) + """ + generate 3x3 hash around centre location + """ + + cdef void get_group_after( self, char* groups_after, char* locations, char* captures, short location ) + """ + groups_after is a board_size * 3 array representing STONES, LIBERTY, CAPTURE for every location + + calculate group after a play on location and set + groups_after[ location * 3 + ] to stone count + groups_after[ location * 3 + 1 ] to liberty count + groups_after[ location * 3 + 2 ] to capture count + """ + + cdef bint is_true_eye( self, short location, Locations_List* eyes, char owner ) + """ + check if location is a real eye + """ ############################################################################ # private cdef Ladder functions # # # ############################################################################ - cdef Groups_List* add_ladder_move( self, short location, Group **board, short* ko ) - cdef void unremove_group( self, Group* group_remove, Group **board ) - cdef dict get_capture_moves( self, Group* group, char color, Group **board ) - cdef void get_removed_groups( self, short location, Groups_List* removed_groups, Group **board, short* ko ) - cdef void undo_ladder_move( self, short location, Groups_List* removed_groups, short ko, Group **board, short* ko ) - cdef bint is_ladder_escape_move( self, Group **board, short* ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase ) - cdef bint is_ladder_capture_move( self, Group **board, short* ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase ) + """ + Ladder evaluation consumes a lot of time duplicating data, the original + version (still can be found in go_python.py) made a copy of the whole + GameState for every move played. + + This version only duplicates self.board_groups ( so the list with pointers to groups ) + the add_ladder_move playes a move like the add_to_group function but it + does not change the original groups and creates a list with groups removed + + with this groups removed list undo_ladder_move will return the board state to + be the same as before add_ladder_move was called + + get_removed_groups and unremove_group are being used my add/undo_ladder_move + + nb. + duplicating self.board_groups is not neccisary stricktly speaking but + it is safer to do so in a threaded environment. as soon as mcts is + implemented this duplication could be removed if the mcts ensures a + GameState is not accesed while preforming a ladder evaluation + + TODO validate no changes are being made! + + TODO self.player colour is used, should become a pointer + """ + + cdef Groups_List* add_ladder_move( self, short location, Group **board, short* ko ) + """ + create a new group for location move and add all connected groups to it + + similar to add_to_group except no groups are changed or killed and a list + with groups removed is returned so the board can be restored to original + position + """ + + cdef void undo_ladder_move( self, short location, Groups_List* removed_groups, short ko, Group **board, short* ko ) + """ + Use removed_groups list to return board state to be the same as before + add_ladder_move was used + """ + + cdef void unremove_group( self, Group* group_remove, Group **board ) + """ + unremove group from board + loop over all stones in this group and set board to group_unremove + remove liberty from neigbor locations + """ + + cdef dict get_capture_moves( self, Group* group, char color, Group **board ) + """ + create a dict with al moves that capture a group surrounding group + """ + + cdef void get_removed_groups( self, short location, Groups_List* removed_groups, Group **board, short* ko ) + """ + create a new group for location move and add all connected groups to it + + similar to add_to_group except no groups are changed or killed + all changes to the board are stored in removed_groups + """ + + cdef bint is_ladder_escape_move( self, Group **board, short* ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase ) + """ + play a ladder move on location, check if group has escaped, + if the group has 2 liberty it is undetermined -> + try to capture it by playing at both liberty + """ + + cdef bint is_ladder_capture_move( self, Group **board, short* ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase ) + """ + play a ladder move on location, try capture and escape moves + and see if the group is able to escape ladder + """ ############################################################################ # public cdef functions used by preprocessing # # # ############################################################################ - cdef short* get_groups_after( self ) - cdef Locations_List* get_sensible_moves( self ) - cdef list get_neighbor_locations( self ) - cdef long get_hash_12d( self, short centre ) - cdef long get_hash_3x3( self, short location ) - cdef char* get_ladder_escapes( self, int maxDepth ) - cdef char* get_ladder_captures( self, int maxDepth ) - cdef char get_board_feature( self, short location ) + cdef char* get_groups_after( self ) + """ + return a short array of size board_size * 3 representing + STONES, LIBERTY, CAPTURE for every board location + + max count values are 100 + + loop over all legal moves and determine stone count, liberty count and + capture count of a play on that location + """ + + cdef list get_neighbor_locations( self ) + """ + generate list with 3x3 neighbor locations + 0,1,2,3 are direct neighbor + 4,5,6,7 are diagonal neighbor + where -1 if it is a border location or non empty location + """ + + cdef long get_hash_12d( self, short centre ) + """ + return hash for 12d star pattern around location + """ + + cdef long get_hash_3x3( self, short location ) + """ + return 3x3 pattern hash + current player + """ + + cdef char* get_ladder_escapes( self, int maxDepth ) + """ + return char array with size board_size + every location represents a location on the board where: + _FREE = no ladder escape + _STONE = ladder escape + """ + + cdef char* get_ladder_captures( self, int maxDepth ) + """ + return char array with size board_size + every location represents a location on the board where: + _FREE = no ladder capture + _STONE = ladder capture + """ ############################################################################ # public cdef functions used for game play # # # ############################################################################ - cdef void add_move( self, short location ) - cdef GameState new_state_add_move( self, short location ) - cdef char get_winner_colour( self, int komi ) + cdef void add_move( self, short location ) + """ + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + Move should be legal! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + + play move on location, move should be legal! + + update player_current, history and moves_legal + """ + + cdef GameState new_state_add_move( self, short location ) + """ + copy this gamestate and play move at location + """ + + cdef char get_winner_colour( self, int komi ) + """ + Calculate score of board state and return player ID (1, -1, or 0 for tie) + corresponding to winner. Uses 'Area scoring'. + + http://senseis.xmp.net/?Passing#1 + """ ############################################################################ # public def functions used for game play (Python) # # # ############################################################################ - #def do_move( self, action ) - #def get_next_state( self, action ) - #def place_handicap( self, handicap ) - #def get_winner( self, char komi ) - #def get_legal_moves( self, include_eyes = True ) - #def is_legal( self, action ) + cdef Locations_List* get_sensible_moves( self ) + """ + only used for def get_legal_moves + """ ############################################################################ # tests # @@ -142,4 +336,11 @@ cdef class GameState: ############################################################################ cdef test( self ) + """ + test function + """ + cdef test_cpp_fast( self ) + """ + test function + """ diff --git a/AlphaGo/go.pyx b/AlphaGo/go.pyx index 6d1def572..bc6c8b117 100644 --- a/AlphaGo/go.pyx +++ b/AlphaGo/go.pyx @@ -7,6 +7,21 @@ import numpy as np cimport numpy as np from libc.stdlib cimport malloc, free from libc.string cimport memcpy, memset +from datetime import datetime +from datetime import timedelta + +# global +# global empty group +cdef Group *group_empty + +# global border group +cdef Group *group_border + +# global arrays, neighbor arrays pointers +cdef short *neighbor +cdef short *neighbor3x3 +cdef short *neighbor12d +cdef char neighbor_size # expose variables to python PASS = _PASS @@ -43,12 +58,19 @@ cdef class GameState: cdef char player_current cdef char player_opponent + # amount of black stones captured cdef short capture_black + # amount of white stones captured cdef short capture_white - # TODO replace list with struct - cdef list history + # amount of passes by black + cdef short passes_black + # amount of passes by white + cdef short passes_white + + # list with move history cdef Locations_List *moves_history + # list with legal moves cdef Locations_List *moves_legal @@ -74,6 +96,97 @@ cdef class GameState: ############################################################################ + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef void initialize_new( self, char size ): + """ + initialize this state as empty state + """ + + cdef short i + + # set pointer to neighbor locations + # neighbor, neighbor3x3, neighbor12d are global + self.neighbor = neighbor + self.neighbor3x3 = neighbor3x3 + self.neighbor12d = neighbor12d + + # initialize size and board_size + self.size = size + self.board_size = size * size + + # create history list + self.moves_history = locations_list_new( 10 ) + + # initialize player colours + self.player_current = _BLACK + self.player_opponent = _WHITE + + self.ko = _PASS + self.capture_black = 0 + self.capture_white = 0 + self.passes_black = 0 + self.passes_white = 0 + + # create arrays and lists + # +1 on board_size is used as an border location used for all borders + + # create Group pointer array ( Group **) + # this array represent the board, every group contains colour, stone-locations + # and liberty locations + # border location is included, therefore the array size is board_size +1 + self.board_groups = malloc( ( self.board_size + 1 ) * sizeof( Group* ) ) + if not self.board_groups: + raise MemoryError() + + # create 3x3 hash array, these are updated after every move + self.hash3x3 = malloc( ( self.board_size ) * sizeof( long ) ) + if not self.hash3x3: + raise MemoryError() + + # create Locations_List as legal_moves + # after every move this list will be updated to contain all legal moves + # max amount of legal moves is board_size + self.moves_legal = locations_list_new( self.board_size ) + + # create groups_list as groups_list + # this list will contain all alive groups + # we do not need to set the theoretical max amount of groups as the list + # will be incremented in group_list_add + self.groups_list = groups_list_new( self.board_size ) + + # get global group_empty reference -> used when removing groups + self.group_empty = group_empty + + # initialize board, set all locations to group empty and add all + # locations as move_legal + for i in range( self.board_size ): + + self.board_groups[ i ] = group_empty + self.moves_legal.locations[ i ] = i + + # on an empty board board_size == amount of legal moves + # set the moves_legal count to board_size + self.moves_legal.count = self.board_size + + # initialize border location to group_border + self.board_groups[ self.board_size ] = group_border + + # initialize all 3x3 hashes + for i in range( self.board_size ): + + self.hash3x3[ i ] = self.generate_3x3_hash( i ) + + # initialize zobrist hash + # TODO optimize? + # rng = np.random.RandomState(0) + # self.hash_lookup = { + # WHITE: rng.randint(np.iinfo(np.uint64).max, size=(size, size), dtype='uint64'), + # BLACK: rng.randint(np.iinfo(np.uint64).max, size=(size, size), dtype='uint64')} + # self.current_hash = np.uint64(0) + # self.previous_hashes = set() + + @cython.boundscheck( False ) @cython.wraparound( False ) cdef void initialize_duplicate( self, GameState copy_state ): @@ -86,7 +199,8 @@ cdef class GameState: cdef Group* group_pointer cdef Group* group - # !!! do not copy !!! -> these do not need a deep copy as they are static + # !!! do not copy !!! + # these do not need a deep copy as they are static self.neighbor = copy_state.neighbor self.neighbor3x3 = copy_state.neighbor3x3 self.neighbor12d = copy_state.neighbor12d @@ -99,7 +213,9 @@ cdef class GameState: # zobrist # self.hash_lookup = copy_state.hash_lookup - # set values + # !!! deep copy !!! + + # set all values self.ko = copy_state.ko self.capture_black = copy_state.capture_black self.capture_white = copy_state.capture_white @@ -111,58 +227,64 @@ cdef class GameState: self.player_opponent = copy_state.player_opponent # self.current_hash = copy_state.current_hash - # copy values - self.history = list( copy_state.history ) - # self.previous_hashes = list( copy_state.previous_hashes ) + # create history list + self.moves_history = locations_list_new( copy_state.moves_history.size ) + self.moves_history.count = copy_state.moves_history.count + # copy all history moves in copy_state + memcpy( self.moves_history.locations, copy_state.moves_history.locations, copy_state.moves_history.count * sizeof( short ) ) - # create array/list + # self.previous_hashes = list( copy_state.previous_hashes ) + # create 3x3 hash array, these are updated after every move self.hash3x3 = malloc( ( self.board_size ) * sizeof( long ) ) if not self.hash3x3: raise MemoryError() - + # copy all 3x3 hashes from copy_state memcpy( self.hash3x3, copy_state.hash3x3, ( self.board_size ) * sizeof( long ) ) - self.moves_legal = malloc( sizeof( Locations_List ) ) - if not self.moves_legal: - raise MemoryError() - - self.moves_legal.locations = malloc( self.board_size * sizeof( short ) ) - if not self.moves_legal.locations: - raise MemoryError() - - memcpy( self.moves_legal.locations, copy_state.moves_legal.locations, self.board_size * sizeof( short ) ) - self.moves_legal.count = copy_state.moves_legal.count - - self.groups_list = malloc( sizeof( Groups_List ) ) - if not self.groups_list: - raise MemoryError() - - self.groups_list.board_groups = malloc( self.board_size * sizeof( Group* ) ) - if not self.groups_list.board_groups: - raise MemoryError() - - self.groups_list.count_groups = 0 - + # create Locations_List as legal_moves + # after every move this list will be updated to contain all legal moves + # max amount of legal moves is board_size + self.moves_legal = locations_list_new( self.board_size ) + self.moves_legal.count = copy_state.moves_legal.count + # copy all legal moves from copy_state + memcpy( self.moves_legal.locations, copy_state.moves_legal.locations, copy_state.moves_legal.count * sizeof( short ) ) + + # create groups_list as groups_list + # this list will contain all alive groups + # we do not need to set the theoretical max amount of groups as the list + # will be incremented in group_list_add + self.groups_list = groups_list_new( self.board_size ) + + # create Group pointer array ( Group **) + # this array represent the board, every group contains colour, stone-locations + # and liberty locations + # border location is included, therefore the array size is board_size +1 self.board_groups = malloc( ( self.board_size + 1 ) * sizeof( Group* ) ) if not self.board_groups: raise MemoryError() - # empty group and border group stay the same, the others will be reset + # copy all group pointers from copy_state + # all Groups will be duplicated and overwritten but all group_empty pointers stay the same memcpy( self.board_groups, copy_state.board_groups, ( self.board_size + 1 ) * sizeof( Group* ) ) - - # copy all groups and set groupsList + # loop over all groups in copy_state.groups_list + # duplicate them and set all Group pointers of this groups stone-locations + # to the new group for i in range( copy_state.groups_list.count_groups ): + # get group group = copy_state.groups_list.board_groups[ i ] + # duplicate group group_pointer = group_duplicate( group, self.board_size ) + # add new group to groups_list groups_list_add( group_pointer, self.groups_list ) - # loop over all group locations and set group + # loop over all group locations for location in range( self.board_size ): + # if group has a stone on this location, set board_groups group pointer if group.locations[ location ] == _STONE: self.board_groups[ location ] = group_pointer @@ -172,13 +294,46 @@ cdef class GameState: @cython.wraparound( False ) def __init__( self, char size = 19, GameState copyState = None ): """ - d create new instance of GameState + create new instance of GameState """ if copyState is not None: # create copy of given state self.initialize_duplicate( copyState ) + else: + + # check if neighbor arrays exist or size has changed + if not neighbor or size != neighbor_size: + + + # calculate board size + self.board_size = size * size + + # set globals so they can be changed + global neighbor + global neighbor3x3 + global neighbor12d + global neighbor_size + global group_empty + global group_border + + # TODO if size has changed, free all globals + + # set size + neighbor_size = size + + # set neighbor arrays + neighbor = get_neighbors( size ) + neighbor3x3 = get_3x3_neighbors( size ) + neighbor12d = get_12d_neighbors( size ) + + # initialize EMPTY and BORDER group + group_empty = group_new( _EMPTY, self.board_size ) + group_border = group_new( _BORDER, self.board_size ) + + # create new root state + self.initialize_new( size ) @cython.boundscheck( False ) @@ -209,6 +364,9 @@ cdef class GameState: free( self.board_groups ) + # free history + locations_list_destroy( self.moves_history ) + # free moves_legal and moves_legal.locations if self.moves_legal is not NULL: @@ -256,9 +414,10 @@ cdef class GameState: return 0 # check if it has liberty after - if not self.has_liberty_after( location, board ): + if 0 == self.has_liberty_after( location, board ): return 0 - # super-ko + + # TODO check super-ko return 1 @@ -272,6 +431,7 @@ cdef class GameState: - conects to group with >= 2 liberty - captures enemy group """ + cdef int i cdef char board_value cdef short count_liberty @@ -285,12 +445,13 @@ cdef class GameState: neighbor_location = self.neighbor[ location * 4 + i ] board_value = board[ neighbor_location ].colour - # check empty location -> liberty + # if empty location -> liberty -> legal move if board_value == _EMPTY: return 1 # get neighbor group + # ( group_border has zero libery and is wrong colour ) group_temp = board[ neighbor_location ] count_liberty = group_temp.count_liberty @@ -300,11 +461,14 @@ cdef class GameState: # if it has at least 2 liberty if count_liberty >= 2: + # this move removes a liberty + # if group has >2 liberty -> legal move return 1 # if is a player_opponent group and has only one liberty elif board_value == self.player_opponent and count_liberty == 1: + # group killed and thus legal return 1 return 0 @@ -328,7 +492,7 @@ cdef class GameState: @cython.wraparound( False ) cdef tuple calculate_tuple_location( self, short location ): """ - return location on board + return location on board as a tupple no checks on outside board """ @@ -345,11 +509,17 @@ cdef class GameState: cdef short i + # reset moves_legal count moves_legal.count = 0 + + # TODO? keep empty locations list? + # loop over all board locations and check if a move is legal for i in range( self.board_size ): + # check if a move is legal if self.is_legal_move( i, self.board_groups, self.ko ): + # add to moves_legal moves_legal.locations[ moves_legal.count ] = i moves_legal.count += 1 @@ -358,23 +528,26 @@ cdef class GameState: @cython.wraparound( False ) cdef void combine_groups( self, Group* group_keep, Group* group_remove, Group **board ): """ - combine two groups and remove one + combine group_keep and group_remove and replace group_remove on the board """ cdef int i cdef char value - # combine stones, liberty and set groups + # loop over all board locations for i in range( self.board_size ): value = group_remove.locations[ i ] if value == _STONE: + # group_remove has a stone, add to group_keep + # and set board location to group_keep group_add_stone( group_keep, i ) board[ i ] = group_keep elif value == _LIBERTY: + # add liberty group_add_liberty( group_keep, i ) @@ -382,7 +555,7 @@ cdef class GameState: @cython.wraparound( False ) cdef void remove_group( self, Group* group_remove, Group **board, short* ko ): """ - remove group from board + remove group from board -> set all locations to group_empty """ cdef short location @@ -390,8 +563,6 @@ cdef class GameState: cdef Group* group_temp cdef char board_value cdef int i - # empty group is always in border location - cdef Group* group_empty = self.group_empty # if groupsize == 1, possible ko if group_remove.count_stones == 1: @@ -404,7 +575,7 @@ cdef class GameState: if group_remove.locations[ location ] == _STONE: # set location to empty group - board[ location ] = group_empty + board[ location ] = self.group_empty # update liberty of neighbors # loop over all four neighbors @@ -422,8 +593,6 @@ cdef class GameState: group_temp = board[ neighbor_location ] group_add_liberty( group_temp, location ) - # update all 3x3 hashes in update_hash_locations - @cython.boundscheck( False ) @cython.wraparound( False ) @@ -445,18 +614,22 @@ cdef class GameState: location_array = i * 8 # loop over diagonal + # this group is killed -> neighbors are enemy stones for a in range( 4, 8 ): + # TODO add check for this group location location = self.neighbor3x3[ location_array + a ] if self.board_groups[ location ].colour == _EMPTY: self.hash3x3[ location ] = self.generate_3x3_hash( location ) + @cython.boundscheck( False ) @cython.wraparound( False ) cdef void add_to_group( self, short location, Group **board, short* ko, short* count_captures ): """ - add location to group or create new one + check if a stone on location is connected to a group, kills a group + or is a new group on the board """ cdef Group* newGroup = NULL @@ -467,7 +640,6 @@ cdef class GameState: cdef char group_removed = 0 cdef int i - # find friendly stones # loop over all four neighbors for i in range( 4 ): @@ -490,7 +662,8 @@ cdef class GameState: if tempGroup != newGroup: self.combine_groups( newGroup, tempGroup, board ) - # remove temp_group from groupList and destroy + + # remove temp_group from groupList and destroy it groups_list_remove( tempGroup, self.groups_list ) group_destroy( tempGroup ) @@ -500,40 +673,50 @@ cdef class GameState: tempGroup = board[ neighborLocation ] group_remove_liberty( tempGroup, location ) - # remove group + # check liberty count and remove if 0 if tempGroup.count_liberty == 0: + # increment capture count count_captures[ 0 ] += tempGroup.count_stones + + # remove group and update hashes self.remove_group( tempGroup, board, ko ) self.update_hashes( tempGroup ) + # TODO hashes of locations next to a group where liberty change also have to be updated + # remove tempGroup from groupList and destroy groups_list_remove( tempGroup, self.groups_list ) group_destroy( tempGroup ) + + # increment group_removed count group_removed += 1 - # check if a group was found or create one + # check if no connected group is found if newGroup is NULL: + # create new group and add to groups_list newGroup = group_new( self.player_current, self.board_size ) groups_list_add( newGroup, self.groups_list ) else: + # remove liberty from group group_remove_liberty( newGroup, location ) - # add stone + # add stone to group group_add_stone( newGroup, location ) - # set location group + # set board location to group board[ location ] = newGroup + # calculate location in neighbor array location_array = location * 8 - # add new liberty # loop over all four neighbors for i in range( 4 ): # get neighbor location neighborLocation = self.neighbor3x3[ location_array + i ] + # if neighbor location is empty add liberty and update hash if board[ neighborLocation ].colour == _EMPTY: group_add_liberty( newGroup, neighborLocation ) @@ -545,10 +728,12 @@ cdef class GameState: # get neighbor location neighborLocation = self.neighbor3x3[ location_array + i ] + # if diagonal is empty update hash if board[ neighborLocation ].colour == _EMPTY: self.hash3x3[ neighborLocation ] = self.generate_3x3_hash( neighborLocation ) + # check if there is really a ko # if two groups died there is no ko # if newGroup has more than 1 stone there is no ko if group_removed >= 2 or newGroup.count_stones > 1: @@ -572,6 +757,7 @@ cdef class GameState: cdef long hash = _HASHVALUE cdef Group* group + # calculate location in neighbor12d array centre *= 12 # hash colour and liberty of all locations @@ -602,6 +788,7 @@ cdef class GameState: cdef long hash = _HASHVALUE cdef Group* group + # calculate location in neighbor3x3 array centre *= 8 # hash colour and liberty of all locations @@ -622,9 +809,14 @@ cdef class GameState: @cython.boundscheck( False ) @cython.wraparound( False ) - cdef void get_group_after( self, short* groups_after, char* locations, char* captures, short location ): + cdef void get_group_after( self, char* groups_after, char* locations, char* captures, short location ): """ - return group as it is after playing at location + groups_after is a board_size * 3 array representing STONES, LIBERTY, CAPTURE for every location + + calculate group after a play on location and set + groups_after[ location * 3 + ] to stone count + groups_after[ location * 3 + 1 ] to liberty count + groups_after[ location * 3 + 2 ] to capture count """ cdef short neighbor_location @@ -635,7 +827,6 @@ cdef class GameState: cdef int location_array = location * 3 cdef short stones, liberty, capture - # find friendly stones # loop over all four neighbors for i in range( 4 ): @@ -693,6 +884,7 @@ cdef class GameState: liberty = 0 capture = 0 + # count all values for i in range( self.board_size ): if locations[ i ] == _STONE: @@ -705,20 +897,27 @@ cdef class GameState: capture += 1 - groups_after[ location_array ] = stones + # check max + if stones > 100: + stones = 100 + + if liberty > 100: + liberty = 100 - location_array += 1 - groups_after[ location_array ] = liberty + if capture > 100: + capture = 100 - location_array += 1 - groups_after[ location_array ] = capture + # set values + groups_after[ location_array ] = stones + groups_after[ location_array + 1 ] = liberty + groups_after[ location_array + 2 ] = capture @cython.boundscheck( False ) @cython.wraparound( False ) cdef bint is_true_eye( self, short location, Locations_List* eyes, char owner ): """ - + check if location is a real eye """ cdef int i @@ -778,9 +977,9 @@ cdef class GameState: count_bad_diagonal += 1 # assume location is an eye - #locations_list_add_location( eyes, location ) - eyes.locations[ eyes.count ] = location - eyes.count += 1 + locations_list_add_location_increment( eyes, location ) + #eyes.locations[ eyes.count ] = location + #eyes.count += 1 max_bad_diagonal = 1 if count_border == 0 else 0 @@ -814,29 +1013,50 @@ cdef class GameState: # # ############################################################################ + """ + Ladder evaluation consumes a lot of time duplicating data, the original + version (still can be found in go_python.py) made a copy of the whole + GameState for every move played. + + This version only duplicates self.board_groups ( so the list with pointers to groups ) + the add_ladder_move playes a move like the add_to_group function but it + does not change the original groups and creates a list with groups removed + + with this groups removed list undo_ladder_move will return the board state to + be the same as before add_ladder_move was called + + get_removed_groups and unremove_group are being used my add/undo_ladder_move + + nb. + duplicating self.board_groups is not neccisary stricktly speaking but + it is safer to do so in a threaded environment. as soon as mcts is + implemented this duplication could be removed if the mcts ensures a + GameState is not accesed while preforming a ladder evaluation + + TODO validate no changes are being made! + + TODO self.player colour is used, should become a pointer + """ + @cython.boundscheck( False ) @cython.wraparound( False ) cdef Groups_List* add_ladder_move( self, short location, Group **board, short* ko ): """ - - """ + create a new group for location move and add all connected groups to it - # assume legal move! - cdef Groups_List* removed_groups - - removed_groups = malloc( sizeof( Groups_List ) ) - if not removed_groups: - raise MemoryError() - - removed_groups.board_groups = malloc( 4 * sizeof( Group* ) ) - if not removed_groups.board_groups: - raise MemoryError() + similar to add_to_group except no groups are changed or killed and a list + with groups removed is returned so the board can be restored to original + position + """ - removed_groups.count_groups = 0 + # create Group_List able to hold up to 4 changed/removed groups + cdef Groups_List* removed_groups = groups_list_new( 4 ) + # ko is a pointer -> add [ 0 ] to acces the actual value ko[ 0 ] = _PASS + # play move at location and add removed groups to removed_groups list self.get_removed_groups( location, removed_groups, board, ko ) # change player colour @@ -846,11 +1066,81 @@ cdef class GameState: return removed_groups + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef void undo_ladder_move( self, short location, Groups_List* removed_groups, short removed_ko, Group **board, short* ko ): + """ + Use removed_groups list to return board state to be the same as before + add_ladder_move was used + """ + + cdef short i, b, location_neighbor + cdef Group* group + cdef Group* group_remove = board[ location ] + + # reset ko to old value + # ko is a pointer -> add [ 0 ] to acces the actual value + ko[ 0 ] = removed_ko + + # change player colour + self.player_current = self.player_opponent + self.player_opponent = ( _BLACK if self.player_current == _WHITE else _WHITE ) + + # undo move set location to empty group + board[ location ] = self.group_empty + + # undo group removals + for i in range( removed_groups.count_groups ): + + # do group unremovals in reversed order!!! + # this is important in order to get correct liberty counts + group = removed_groups.board_groups[ removed_groups.count_groups - i - 1 ] + + # check group colour and determine what happened + # player_current -> groups have been combined, set board locations to group + # player_opponent -> groups have been removed, unremove them + if group.colour == self.player_opponent: + + # opponent group was removed from the board -> unremove it + self.unremove_group( group, board ) + else: + + # set all board_groups locations to group + # liberty have not been changed + for b in range( self.board_size ): + + if group.locations[ b ] == _STONE: + + board[ b ] = group + + # add liberty to neighbor groups + for i in range( 4 ): + + location_neighbor = self.neighbor[ location * 4 + i ] + if board[ location_neighbor ].colour > _EMPTY: + + group_add_liberty( board[ location_neighbor ], location ) + + # destroy group + group_destroy( group_remove ) + + # free removed_groups + if removed_groups is not NULL: + + if removed_groups.board_groups is not NULL: + + free( removed_groups.board_groups ) + + free( removed_groups ) + + @cython.boundscheck( False ) @cython.wraparound( False ) cdef void unremove_group( self, Group* group_unremove, Group **board ): """ unremove group from board + loop over all stones in this group and set board to group_unremove + remove liberty from neigbor locations """ cdef short location @@ -861,6 +1151,7 @@ cdef class GameState: # loop over all group stone locations for location in range( self.board_size ): + # check if this has a stone on location if group_unremove.locations[ location ] == _STONE: # set location to group_unremove @@ -874,19 +1165,18 @@ cdef class GameState: neighbor_location = self.neighbor[ location * 4 + i ] # only current_player groups can be next to a killed group - # check if there is a group - if board[ neighbor_location ].colour == self.player_current: + # check if neighbor_location does not belong to this group + if group_unremove.locations[ neighbor_location ] != _STONE: # remove liberty - group_temp = board[ neighbor_location ] - group_remove_liberty( group_temp, location ) + group_remove_liberty( board[ neighbor_location ], location ) @cython.boundscheck( False ) @cython.wraparound( False ) cdef dict get_capture_moves( self, Group* group, char color, Group **board ): """ - + create a dict with al moves that capture a group surrounding group """ cdef int i, location, location_neighbor, location_array @@ -927,9 +1217,13 @@ cdef class GameState: @cython.wraparound( False ) cdef void get_removed_groups( self, short location, Groups_List* removed_groups, Group **board, short* ko ): """ - add location to group or create new one + create a new group for location move and add all connected groups to it + + similar to add_to_group except no groups are changed or killed + all changes to the board are stored in removed_groups """ + # create new group ( it is not added to groups_list as in add_to_group ) cdef Group* newGroup = group_new( self.player_current, self.board_size ) cdef Group* tempGroup cdef short neighborLocation @@ -937,7 +1231,6 @@ cdef class GameState: cdef char group_removed = 0 cdef int i - # find friendly stones # loop over all four neighbors for i in range( 4 ): @@ -968,6 +1261,8 @@ cdef class GameState: self.remove_group( tempGroup, board, ko ) # add tempGroup to removed_groups groups_list_add( tempGroup, removed_groups ) + + # increment group_removed count group_removed += 1 # remove liberty @@ -976,90 +1271,34 @@ cdef class GameState: # add stone group_add_stone( newGroup, location ) - # set location group + # set location to newGroup board[ location ] = newGroup - # add new liberty # loop over all four neighbors for i in range( 4 ): # get neighbor location neighborLocation = self.neighbor[ location * 4 + i ] + # check is neighbor is empty, add liberty if so if board[ neighborLocation ].colour == _EMPTY: group_add_liberty( newGroup, neighborLocation ) + # check if there is really a ko # if two groups died there is no ko # if newGroup has more than 1 stone there is no ko if group_removed >= 2 or newGroup.count_stones > 1: ko[ 0 ] = _PASS - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef void undo_ladder_move( self, short location, Groups_List* removed_groups, short removed_ko, Group **board, short* ko ): - """ - - """ - - cdef short i, b, location_neighbor - cdef Group* group - cdef Group* group_remove = board[ location ] - - ko[ 0 ] = removed_ko - - # change player colour - self.player_current = self.player_opponent - self.player_opponent = ( _BLACK if self.player_current == _WHITE else _WHITE ) - - # set group to empty group - board[ location ] = self.group_empty - - # undo group removals - for i in range( removed_groups.count_groups ): - - group = removed_groups.board_groups[ removed_groups.count_groups - i - 1 ] - - if board[ group_location_stone( group, self.board_size ) ].colour == _EMPTY: - - # opponent group was removed - self.unremove_group( group, board ) - else: - - # set all board_groups locations to group - for b in range( self.board_size ): - - if group.locations[ b ] == _STONE: - - board[ b ] = group - - # add liberty to neighbor groups -> check if needed - for i in range( 4 ): - - location_neighbor = self.neighbor[ location * 4 + i ] - if board[ location_neighbor ].colour > _EMPTY: - - group_add_liberty( board[ location_neighbor ], location ) - - # destroy group - group_destroy( group_remove ) - - # free removed_groups - if removed_groups is not NULL: - - if removed_groups.board_groups is not NULL: - - free( removed_groups.board_groups ) - - free( removed_groups ) - - @cython.boundscheck( False ) @cython.wraparound( False ) cdef bint is_ladder_escape_move( self, Group **board, short* ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase ): """ - + play a ladder move on location, check if group has escaped, + if the group has 2 liberty it is undetermined -> + try to capture it by playing at both liberty """ cdef int i @@ -1067,17 +1306,20 @@ cdef class GameState: cdef bint result cdef Group* group cdef Group* group_capture + cdef dict capture_copy cdef Groups_List* removed_groups cdef short location_neighbor, location_stone + # check if max exploration depth has been reached if maxDepth <= 0: return 0 + # check if move is legal if not self.is_legal_move( location, board, ko[ 0 ] ): return 0 - # do move + # do ladder move and save ko location ko_value = ko[ 0 ] removed_groups = self.add_ladder_move( location, board, ko ) @@ -1094,6 +1336,11 @@ cdef class GameState: result = 1 else: + # 2 liberty, fate undetermined + + # TODO now we have to walk over all locations, somehow let do_ladder_move + # do this -> saves computation time + # find all moves capturing an enemy group for location_stone in range( self.board_size ): @@ -1118,13 +1365,14 @@ cdef class GameState: location_neighbor = group_location_liberty( group_capture, self.board_size ) capture[ location_neighbor ] = location_neighbor - # try to capture group by playing at one of the two liberty locations + # try to catch group by playing at one of the two liberty locations for location_neighbor in range( self.board_size ): if group.locations[ location_neighbor ] == _LIBERTY: if self.is_ladder_capture_move( board, ko, location_group, capture.copy(), location_neighbor, maxDepth - 1, colour_group, colour_chase ): + # undo move self.undo_ladder_move( location, removed_groups, ko_value, board, ko ) return 0 @@ -1142,7 +1390,8 @@ cdef class GameState: @cython.wraparound( False ) cdef bint is_ladder_capture_move( self, Group **board, short* ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase ): """ - + play a ladder move on location, try capture and escape moves + and see if the group is able to escape ladder """ cdef short i @@ -1164,6 +1413,7 @@ cdef class GameState: ko_value = ko[ 0 ] removed_groups = self.add_ladder_move( location, board, ko ) + # check if the group at location can be captured group = board[ location ] if group.count_liberty == 1: @@ -1175,8 +1425,9 @@ cdef class GameState: capture_copy = capture.copy() capture_copy.pop( location_next ) - if self.is_ladder_escape_move( board, ko, location_group, capture_copy, location_next, maxDepth - 1, colour_group, colour_chase ): + if self.is_ladder_escape_move( board, ko, location_group, capture.copy(), location_next, maxDepth - 1, colour_group, colour_chase ): + # undo move self.undo_ladder_move( location, removed_groups, ko_value, board, ko ) return 0 @@ -1187,12 +1438,17 @@ cdef class GameState: if group.locations[ location_next ] == _LIBERTY: + capture_copy = capture.copy() + if location_next in capture_copy: + capture_copy.pop( location_next ) if self.is_ladder_escape_move( board, ko, location_group, capture.copy(), location_next, maxDepth - 1, colour_group, colour_chase ): + # undo move self.undo_ladder_move( location, removed_groups, ko_value, board, ko ) return 0 # no ladder escape found -> group is captured + # undo move self.undo_ladder_move( location, removed_groups, ko_value, board, ko ) return 1 @@ -1205,22 +1461,32 @@ cdef class GameState: @cython.boundscheck( False ) @cython.wraparound( False ) - cdef short* get_groups_after( self ): + cdef char* get_groups_after( self ): """ - + return a short array of size board_size * 3 representing + STONES, LIBERTY, CAPTURE for every board location + + max count values are 100 + + loop over all legal moves and determine stone count, liberty count and + capture count of a play on that location """ cdef short i, location - cdef short *groups_after = malloc( self.board_size * 3 * sizeof( short ) ) + + # initialize groups_after array + cdef char *groups_after = malloc( self.board_size * 3 * sizeof( char ) ) if not groups_after: raise MemoryError() - memset( groups_after, 0, self.board_size * 3 * sizeof( short ) ) + #memset( groups_after, 0, self.board_size * 3 * sizeof( char ) ) + # create locations dictionary cdef char *locations = malloc( self.board_size * sizeof( char ) ) if not locations: raise MemoryError() + # create captures dictionary cdef char *captures = malloc( self.board_size * sizeof( char ) ) if not captures: raise MemoryError() @@ -1228,6 +1494,7 @@ cdef class GameState: # create groups for all legal moves for location in range( self.moves_legal.count ): + # initialize both dictionaries to _FREE memset( locations, _FREE, self.board_size * sizeof( char ) ) memset( captures, _FREE, self.board_size * sizeof( char ) ) @@ -1239,42 +1506,6 @@ cdef class GameState: return groups_after - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef Locations_List* get_sensible_moves( self ): - """ - - """ - - # TODO validate usage of struct is actually faster - - # create list with at least #moves_legal.count locations - # there can never be more sensible moves - cdef Locations_List* sensible_moves = locations_list_new( self.moves_legal.count ) - - # checking all games in the KGS database found a max of 17eyes in one state - # 25 seems a safe bet - cdef Locations_List* eyes = locations_list_new( 80 ) - cdef int i - cdef short location - - for i in range( self.moves_legal.count ): - - location = self.moves_legal.locations[ i ] - - if not self.is_true_eye( location, eyes, self.player_current ): - - # TODO find out why locations_list_add_location is 2x slower - #locations_list_add_location( sensible_moves, location ) - - sensible_moves.locations[ sensible_moves.count ] = location - sensible_moves.count += 1 - - locations_list_destroy( eyes ) - - return sensible_moves - - @cython.boundscheck( False ) @cython.wraparound( False ) cdef list get_neighbor_locations( self ): @@ -1295,7 +1526,9 @@ cdef class GameState: return hash for 12d star pattern around location """ - return self.generate_12d_hash( centre ) + # get 12d hash value and add current player colour + + return self.generate_12d_hash( centre ) + self.player_current @cython.boundscheck( False ) @@ -1306,7 +1539,7 @@ cdef class GameState: """ # 3x3 hash patterns are updated every move - # get 3x3 hash value and add current player + # get 3x3 hash value and add current player colour return self.hash3x3[ location ] + self.player_current @@ -1315,7 +1548,10 @@ cdef class GameState: @cython.wraparound( False ) cdef char* get_ladder_escapes( self, int maxDepth ): """ - + return char array with size board_size + every location represents a location on the board where: + _FREE = no ladder escape + _STONE = ladder escape """ cdef short i, location_group, location_move @@ -1324,10 +1560,12 @@ cdef class GameState: cdef dict move_capture_copy cdef Group **board = NULL cdef short ko = self.ko + + # create char array representing the board cdef char* escapes = malloc( self.board_size ) if not escapes: raise MemoryError() - + # set all locations to _FREE memset( escapes, _FREE, self.board_size ) # loop over all groups on board @@ -1341,26 +1579,36 @@ cdef class GameState: # check if group has one liberty and is owned by current if group.colour == self.player_current: + # the first time a possible ladder location is found, board + # is duplicated, technically this is not neccisary but it is + # safer when we start using a multi threaded mcts if board is NULL: + # create new Group pointer array as board board = malloc( ( self.board_size + 1 ) * sizeof( Group* ) ) if not self.board_groups: raise MemoryError() - # empty group and border group stay the same, the others will be reset + # as the ladder search does not change any excisting groups we can safely + # duplicate the whole pointer array without duplicating all groups memcpy( board, self.board_groups, ( self.board_size + 1 ) * sizeof( Group* ) ) + # get a dictionary with all possible capture groups -> surrounding groups + # the ladder group can kill in order to escape ladder move_capture = self.get_capture_moves( group, self.player_opponent, board ) location_group = group_location_stone( group, self.board_size ) + # check if any of the moves is an escape move for location_move in range( self.board_size ): if group.locations[ location_move ] == _LIBERTY and escapes[ location_move ] == _FREE: + # check if group can escape ladder by playing move if self.is_ladder_escape_move( board, &ko, location_group, move_capture.copy(), location_move, maxDepth, self.player_current, self.player_opponent ): escapes[ location_move ] = _STONE + # check if any of the capture moves is an escape move for location_move in move_capture: if escapes[ location_move ] == _FREE: @@ -1368,11 +1616,12 @@ cdef class GameState: move_capture_copy = move_capture.copy() move_capture_copy.pop( location_move ) + # check if group can escape ladder by playing capture move if self.is_ladder_escape_move( board, &ko, location_group, move_capture_copy, location_move, maxDepth, self.player_current, self.player_opponent ): escapes[ location_move ] = _STONE - + # free temporary board if board is not NULL: free( board ) @@ -1384,7 +1633,10 @@ cdef class GameState: @cython.wraparound( False ) cdef char* get_ladder_captures( self, int maxDepth ): """ - + return char array with size board_size + every location represents a location on the board where: + _FREE = no ladder capture + _STONE = ladder capture """ cdef short i, location_group, location_move @@ -1392,10 +1644,12 @@ cdef class GameState: cdef dict move_capture cdef Group **board = NULL cdef short ko = self.ko + + # create char array representing the board cdef char* captures = malloc( self.board_size ) if not captures: raise MemoryError() - + # set all locations to _FREE memset( captures, _FREE, self.board_size ) # loop over all groups on board @@ -1409,28 +1663,36 @@ cdef class GameState: # check if group is owned by opponent if group.colour == self.player_opponent: + # the first time a possible ladder location is found, board + # is duplicated, technically this is not neccisary but it is + # safer when we start using a multi threaded mcts if board is NULL: + # create new Group pointer array as board board = malloc( ( self.board_size + 1 ) * sizeof( Group* ) ) if not self.board_groups: raise MemoryError() - # empty group and border group stay the same, the others will be reset + # as the ladder search does not change any excisting groups we can safely + # duplicate the whole pointer array without duplicating all groups memcpy( board, self.board_groups, ( self.board_size + 1 ) * sizeof( Group* ) ) - + # get a dictionary with all possible capture groups -> surrounding groups + # the ladder group can kill in order to escape ladder move_capture = self.get_capture_moves( group, self.player_current, board ) location_group = group_location_stone( group, self.board_size ) - # loop over liberty + # loop over all liberty for location_move in range( self.board_size ): if group.locations[ location_move ] == _LIBERTY and captures[ location_move ] == _FREE: + # check if move is ladder capture if self.is_ladder_capture_move( board, &ko, location_group, move_capture.copy(), location_move, maxDepth, self.player_opponent, self.player_current ): captures[ location_move ] = _STONE + # free temporary board if board is not NULL: free( board ) @@ -1438,27 +1700,6 @@ cdef class GameState: return captures - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef char get_board_feature( self, short location ): - """ - return correct board feature value - - 0 active player stone - - 1 opponent stone - - 2 empty location - """ - - cdef char value = self.board_groups[ location ].colour - - if value == _EMPTY: - return 2 - - if value == self.player_current: - return 0 - - return 1 - - ############################################################################ # public cdef functions used for game play # # # @@ -1481,21 +1722,22 @@ cdef class GameState: # reset ko self.ko = _PASS - # where captures should be added, black captures white stones - # white captures black stones + # detemine where captures should be added, black captures -> white stones + # white captures -> black stones + # ( probably better to think of it as black stones captured, and white stones captured ) cdef short* captures = ( &self.capture_white if ( self.player_current == _BLACK ) else &self.capture_black ) - # add move + # add move to board self.add_to_group( location, self.board_groups, &self.ko, captures ) - # change player colour + # switch player colour self.player_current = self.player_opponent self.player_opponent = ( _BLACK if self.player_current == _WHITE else _WHITE ) - # add to history - self.history.append( location ) + # add move to history + locations_list_add_location_increment( self.moves_history, location ) - # set moves legal + # set moves_legal self.set_moves_legal_list( self.moves_legal ) # TODO @@ -1512,7 +1754,7 @@ cdef class GameState: # create new gamestate, copy all data of self state = GameState( copyState = self ) - # place move + # do move state.add_move( location ) return state @@ -1532,12 +1774,17 @@ cdef class GameState: cdef char board_value cdef int score_white = komi cdef int score_black = 0 + + # lists to keep track of black and white eyes cdef Locations_List* eyes_white = locations_list_new( self.board_size ) cdef Locations_List* eyes_black = locations_list_new( self.board_size ) - for location in range( self.size * self.size ): + # loop over whole board + for location in range( self.board_size ): + # get location colour board_value = self.board_groups[ location ].colour + if board_value == _WHITE: # white stone @@ -1592,7 +1839,7 @@ cdef class GameState: if action is _PASS: - self.history.append( _PASS ) + locations_list_add_location_increment( self.moves_history, _PASS ) if self.player_opponent == _BLACK: @@ -1717,7 +1964,7 @@ cdef class GameState: cdef short location cdef short fake_capture - if len( self.history ) > 0: + if self.moves_history.count > 0: raise IllegalMove("Cannot place handicap on a started game") for action in handicap: @@ -1734,13 +1981,15 @@ cdef class GameState: self.set_moves_legal_list( self.moves_legal ) + @cython.boundscheck( False ) + @cython.wraparound( False ) def is_end_of_game( self ): """ """ - if len( self.history ) > 1: + if self.moves_history.count > 1: - if self.history[-1] is _PASS and self.history[-2] is _PASS and self.player_current == _WHITE: + if self.moves_history.locations[ self.moves_history.count - 1 ] == _PASS and self.moves_history.locations[ self.moves_history.count - 2 ] == _PASS and self.player_current == _WHITE: return True @@ -1817,9 +2066,13 @@ cdef class GameState: return history as a list of tuples """ - history = [] + cdef int i + cdef short location + cdef list history = [] + + for i in range( self.moves_history.count ): - for location in self.history: + location = self.moves_history.locations[ i ] if location != _PASS: @@ -2037,6 +2290,42 @@ cdef class GameState: return [] + @cython.boundscheck( False ) + @cython.wraparound( False ) + cdef Locations_List* get_sensible_moves( self ): + """ + only used for def get_legal_moves + """ + + # TODO validate usage of struct is actually faster + + # create list with at least #moves_legal.count locations + # there can never be more sensible moves + cdef Locations_List* sensible_moves = locations_list_new( self.moves_legal.count ) + + # checking all games in the KGS database found a max of 17eyes in one state + # 25 seems a safe bet + cdef Locations_List* eyes = locations_list_new( 80 ) + cdef int i + cdef short location + + for i in range( self.moves_legal.count ): + + location = self.moves_legal.locations[ i ] + + if not self.is_true_eye( location, eyes, self.player_current ): + + # TODO find out why locations_list_add_location is 2x slower + #locations_list_add_location( sensible_moves, location ) + + sensible_moves.locations[ sensible_moves.count ] = location + sensible_moves.count += 1 + + locations_list_destroy( eyes ) + + return sensible_moves + + ############################################################################ # tests # # # @@ -2174,10 +2463,92 @@ cdef class GameState: return converted_moves + def millis( self, start_time ): + dt = datetime.now() - start_time + ms = (dt.days * 24 * 60 * 60 + dt.seconds) * 1000 + dt.microseconds / 1000.0 + return ms + def test_stuff( self ): + cdef long a, h, i, j, k + cdef short b, e + + if not neighbor: + print("NOT") + + for i in range( self.board_size * 4 ): + if neighbor[ i ] != self.neighbor[ i ]: + print("NOT EQUAL") + return + + if not neighbor3x3: + print("NOT") + + for i in range( self.board_size * 8 ): + if neighbor3x3[ i ] != self.neighbor3x3[ i ]: + print("NOT EQUAL") + return + + if not neighbor12d: + print("NOT") + + for i in range( self.board_size * 12 ): + if neighbor12d[ i ] != self.neighbor12d[ i ]: + print("NOT EQUAL") + return + + cdef long amount = 1000 + + e = 1 + b = 0 + start = datetime.now() + + for h in range( amount ): + for i in range( amount ): + + for a in range( self.board_size * 12 ): + + b = neighbor12d[ a ] + + if neighbor12d[ a ] > 100: + e = neighbor12d[ a ] + if b * e > 0: + b = 1 + + end = datetime.now() + dt = end - start + start = dt.microseconds + + print( "glob " + str( start ) + " " + str( b ) ) + + + e = 1 + b = 0 + start = datetime.now() + + for h in range( amount ): + for i in range( amount ): + + for a in range( self.board_size * 12 ): + + b = self.neighbor12d[ a ] + + if self.neighbor12d[ a ] > 100: + e = self.neighbor12d[ a ] + if b * e > 0: + b = 1 + + end = datetime.now() + dt = end - start + start = dt.microseconds + + print( "self " + str( start ) + " " + str( b ) ) + + + @cython.boundscheck( False ) + @cython.wraparound( False ) + def set_stuff( self ): - print( "test stuff" ) - self.test() + self.get_sensible_moves() class IllegalMove(Exception): diff --git a/AlphaGo/go_data.pxd b/AlphaGo/go_data.pxd index d1438ee14..ebe8bbfea 100644 --- a/AlphaGo/go_data.pxd +++ b/AlphaGo/go_data.pxd @@ -4,7 +4,7 @@ # # ############################################################################ -# TODO find out if these are really used as compile time-constant +# TODO find out if these are really used as compile time-constants # value for PASS move cdef char _PASS @@ -17,11 +17,13 @@ cdef char _EMPTY cdef char _WHITE cdef char _BLACK -# used for group stone and liberty locations +# used for group stone, liberty locations, legal move and eye locations cdef char _FREE cdef char _STONE cdef char _LIBERTY cdef char _CAPTURE +cdef char _LEGAL +cdef char _EYE # value used to generate pattern hashes cdef char _HASHVALUE @@ -32,6 +34,20 @@ cdef char _HASHVALUE # # ############################################################################ +""" + a struct has the advantage of being completely C, no python wrapper so + no python overhead. + + compared to a cdef class a struct has some advantages: + - C only, no python overhead + - able to get a pointer to it + - smaller in size + + drawbacks + - have to be Malloc created and freed after use -> memory leak + - no convenient functions available + - no boundchecks +""" # struct to store group information cdef struct Group: @@ -44,11 +60,13 @@ cdef struct Group: cdef struct Groups_List: Group **board_groups short count_groups + short size # struct to store a list of short ( board locations ) cdef struct Locations_List: short *locations short count + short size ############################################################################ @@ -56,34 +74,204 @@ cdef struct Locations_List: # # ############################################################################ -cdef Group* group_new( char colour, short size ) -cdef Group* group_duplicate( Group* group, short size ) -cdef void group_destroy( Group* group ) +cdef Group* group_new( char colour, short size ) +""" + create new struct Group + with locations #size char long initialized to FREE +""" + +cdef Group* group_duplicate( Group* group, short size ) +""" + create new struct Group initialized as a duplicate of group +""" + +cdef void group_destroy( Group* group ) +""" + free memory location of group and locations +""" + +cdef void group_add_stone( Group* group, short location ) +""" + update location as STONE + update liberty count if it was a liberty location + + n.b. stone count is not incremented if a stone was present already +""" + +cdef void group_remove_stone( Group* group, short location ) +""" + update location as FREE + update stone count if it was a stone location +""" + +cdef short group_location_stone( Group* group, short size ) +""" + return first location where a STONE is located +""" + +cdef void group_add_liberty( Group* group, short location ) +""" + update location as LIBERTY + update liberty count if it was a FREE location -cdef void group_add_stone( Group* group, short location ) -cdef void group_remove_stone( Group* group, short location ) -cdef short group_location_stone( Group* group, short size ) + n.b. liberty count is not incremented if a stone was present already +""" -cdef void group_add_liberty( Group* group, short location ) -cdef void group_remove_liberty( Group* group, short location ) -cdef short group_location_liberty( Group* group, short size ) +cdef void group_remove_liberty( Group* group, short location ) +""" + update location as FREE + update liberty count if it was a LIBERTY location + + n.b. liberty count is not decremented if location is a FREE location +""" + +cdef short group_location_liberty( Group* group, short size ) +""" + return location where a LIBERTY is located +""" ############################################################################ # Groups_List functions # # # ############################################################################ -cdef void groups_list_add( Group* group, Groups_List* groups_list ) -cdef void groups_list_add_unique( Group* group, Groups_List* groups_list ) -cdef void groups_list_remove( Group* group, Groups_List* groups_list ) +cdef Groups_List* groups_list_new( short size ) +""" + create new struct Groups_List + with locations #size Group* long and count_groups set to 0 +""" + +cdef void groups_list_add( Group* group, Groups_List* groups_list ) +""" + add group to list and increment groups count +""" + +cdef void groups_list_add_unique( Group* group, Groups_List* groups_list ) +""" + check if a group is already in the list, return if so + add group to list if not +""" + +cdef void groups_list_remove( Group* group, Groups_List* groups_list ) +""" + remove group from list and decrement groups count +""" ############################################################################ # Locations_List functions # # # ############################################################################ -cdef Locations_List* locations_list_new( short size ) -cdef void locations_list_destroy( Locations_List* locations_list ) -cdef void locations_list_remove_location( Locations_List* locations_list, short location ) -cdef void locations_list_add_location( Locations_List* locations_list, short location ) +cdef Locations_List* locations_list_new( short size ) +""" + create new struct Locations_List + with locations #size short long and count set to 0 +""" + +cdef void locations_list_destroy( Locations_List* locations_list ) +""" + free memory location of locations_list and locations +""" + +cdef void locations_list_remove_location( Locations_List* locations_list, short location ) +""" + remove location from list +""" + +cdef void locations_list_add_location( Locations_List* locations_list, short location ) +""" + add location to list and increment count +""" + +cdef void locations_list_add_location_increment( Locations_List* locations_list, short location ) +""" + check if list can hold one more location, resize list if not + add location to list and increment count +""" + cdef void locations_list_add_location_unique( Locations_List* locations_list, short location ) +""" + check if location is present in list, return if so + add location to list if not +""" + +############################################################################ +# neighbor creation functions # +# # +############################################################################ + +cdef short calculate_board_location( char x, char y, char size ) +""" + return location on board + no checks on outside board + x = columns + y = rows +""" + +cdef short calculate_board_location_or_border( char x, char y, char size ) +""" + return location on board or borderlocation + board locations = [ 0, size * size ) + border location = size * size + x = columns + y = rows +""" + +cdef short* get_neighbors( char size ) +""" + create array for every board location with all 4 direct neighbour locations + neighbor order: left - right - above - below + + -1 x + x x + +1 x + + order: + -1 2 + 0 1 + +1 3 + + TODO neighbors is obsolete as neighbor3x3 contains the same values +""" + +cdef short* get_3x3_neighbors( char size ) +""" + create for every board location array with all 8 surrounding neighbour locations + neighbor order: above middle - middle left - middle right - below middle + above left - above right - below left - below right + this order is more useful as it separates neighbors and then diagonals + -1 xxx + x x + +1 xxx + + order: + -1 405 + 1 2 + +1 637 + + 0-3 contains neighbors + 4-7 contains diagonals +""" + +cdef short* get_12d_neighbors( char size ) +""" + create array for every board location with 12d star neighbour locations + neighbor order: top star tip + above left - above middle - above right + left star tip - left - right - right star tip + below left - below middle - below right + below star tip + + -2 x + -1 xxx + xx xx + +1 xxx + +2 x + + order: + -2 0 + -1 123 + 45 67 + +1 89a + +2 b +""" diff --git a/AlphaGo/go_data.pyx b/AlphaGo/go_data.pyx index 507da1b04..7d8f3f0ed 100644 --- a/AlphaGo/go_data.pyx +++ b/AlphaGo/go_data.pyx @@ -1,7 +1,24 @@ cimport cython -from libc.stdlib cimport malloc, free +from libc.stdlib cimport malloc, free, realloc from libc.string cimport memcpy, memset, memchr +""" + Future speedups, right now the usage of C dicts and List is copied from original + Java implementation. not all usages have been tested for max performance. + + possible speedups could be swapping certain dicts for lists and vice versa. + more testing should be done where this might apply. + + some notes: + - using list for Group stone&liberty locations? + - do we need to consider 25*25 boards? + - dict for moves_legal instead of list? + - create mixed short+char arrays to store location+value in one array? + - implement dict+list struct to get fast lookup and fast looping over all elements + - store one liberty&stone location in group for fast lookup of group location/liberty + - implement faster loop over all elements for dict using memchr and offset pointer +""" + ############################################################################ # constants # # # @@ -9,21 +26,23 @@ from libc.string cimport memcpy, memset, memchr # value for PASS move -_PASS = -1 +_PASS = -1 # observe: stones > EMPTY # border < EMPTY # be aware you should NOT use != EMPTY as this includes border locations -_BORDER = 1 -_EMPTY = 2 -_WHITE = 3 -_BLACK = 4 +_BORDER = 1 +_EMPTY = 2 +_WHITE = 3 +_BLACK = 4 -# used for group stone and liberty locations +# used for group stone, liberty locations, legal move and sensible move _FREE = 3 _STONE = 0 _LIBERTY = 1 _CAPTURE = 2 +_LEGAL = 4 +_EYE = 5 # value used to generate pattern hashes _HASHVALUE = 33 @@ -187,9 +206,14 @@ cdef void group_remove_stone( Group* group, short location ): @cython.wraparound( False ) cdef short group_location_stone( Group* group, short size ): """ - return location where a STONE is located + return first location where a STONE is located """ + # memchr is a in memory search function, it starts searching at + # pointer location #group.locations for a max of size continous bytes untill + # a location with value _STONE is found -> returns a pointer to this location + # when this pointer location is substracted with pointer #group.locations + # the location is calculated where a stone is return ( memchr( group.locations, _STONE, size ) - group.locations ) @@ -236,6 +260,11 @@ cdef short group_location_liberty( Group* group, short size ): return location where a LIBERTY is located """ + # memchr is a in memory search function, it starts searching at + # pointer location #group.locations for a max of size continous bytes untill + # a location with value _LIBERTY is found -> returns a pointer to this location + # when this pointer location is substracted with pointer #group.locations + # the location is calculated where a liberty is return ( memchr(group.locations, _LIBERTY, size ) - group.locations ) @@ -245,6 +274,29 @@ cdef short group_location_liberty( Group* group, short size ): ############################################################################ +@cython.boundscheck( False ) +@cython.wraparound( False ) +cdef Groups_List* groups_list_new( short size ): + """ + create new struct Groups_List + with locations #size Group* long and count_groups set to 0 + """ + + cdef Groups_List* list_new + + list_new = malloc( sizeof( Groups_List ) ) + if not list_new: + raise MemoryError() + + list_new.board_groups = malloc( size * sizeof( Group* ) ) + if not list_new.board_groups: + raise MemoryError() + + list_new.count_groups = 0 + + return list_new + + @cython.boundscheck( False ) @cython.wraparound( False ) cdef void groups_list_add( Group* group, Groups_List* groups_list ): @@ -334,6 +386,9 @@ cdef Locations_List* locations_list_new( short size ): # set count to 0 list_new.count = 0 + # set size + list_new.size = size + return list_new @cython.boundscheck( False ) @@ -391,6 +446,27 @@ cdef void locations_list_add_location( Locations_List* locations_list, short loc locations_list.count += 1 +@cython.boundscheck( False ) +@cython.wraparound( False ) +cdef void locations_list_add_location_increment( Locations_List* locations_list, short location ): + """ + check if list can hold one more location, resize list if not + add location to list and increment count + """ + + if locations_list.count == locations_list.size: + + locations_list.size += 10 + locations_list.locations = realloc( locations_list.locations, locations_list.size * sizeof( short ) ) + if not locations_list.locations: + print("MEM ERROR") + raise MemoryError() + + + locations_list.locations[ locations_list.count ] = location + locations_list.count += 1 + + @cython.boundscheck( False ) @cython.wraparound( False ) @cython.nonecheck( False ) @@ -414,3 +490,188 @@ cdef void locations_list_add_location_unique( Locations_List* locations_list, sh # add location to list and increment count locations_list.locations[ locations_list.count ] = location locations_list.count += 1 + + +############################################################################ +# neighbor creation functions # +# # +############################################################################ + + +@cython.boundscheck( False ) +@cython.wraparound( False ) +cdef short calculate_board_location( char x, char y, char size ): + """ + return location on board + no checks on outside board + x = columns + y = rows + """ + + # return board location + return x + ( y * size ) + + +@cython.boundscheck( False ) +@cython.wraparound( False ) +cdef short calculate_board_location_or_border( char x, char y, char size ): + """ + return location on board or borderlocation + board locations = [ 0, size * size ) + border location = size * size + x = columns + y = rows + """ + + # check if x or y are outside board + if x < 0 or y < 0 or x >= size or y >= size: + + # return border location + return size * size + + # return board location + return calculate_board_location( x, y, size ) + + +cdef short* get_neighbors( char size ): + """ + create array for every board location with all 4 direct neighbor locations + neighbor order: left - right - above - below + + -1 x + x x + +1 x + + order: + -1 2 + 0 1 + +1 3 + + TODO neighbors is obsolete as neighbor3x3 contains the same values + """ + + # create array + cdef short* neighbor = malloc( size * size * 4 * sizeof( short ) ) + if not neighbor: + raise MemoryError() + + cdef short location + cdef char x, y + + # add all direct neighbors to every board location + for y in range( size ): + + for x in range( size ): + + location = ( x + ( y * size ) ) * 4 + neighbor[ location + 0 ] = calculate_board_location_or_border( x - 1, y , size ) + neighbor[ location + 1 ] = calculate_board_location_or_border( x + 1, y , size ) + neighbor[ location + 2 ] = calculate_board_location_or_border( x , y - 1, size ) + neighbor[ location + 3 ] = calculate_board_location_or_border( x , y + 1, size ) + + return neighbor + +@cython.boundscheck( False ) +@cython.wraparound( False ) +cdef short* get_3x3_neighbors( char size ): + """ + create for every board location array with all 8 surrounding neighbor locations + neighbor order: above middle - middle left - middle right - below middle + above left - above right - below left - below right + this order is more useful as it separates neighbors and then diagonals + -1 xxx + x x + +1 xxx + + order: + -1 405 + 1 2 + +1 637 + + 0-3 contains neighbors + 4-7 contains diagonals + """ + + # create array + cdef short* neighbor3x3 = malloc( size * size * 8 * sizeof( short ) ) + if not neighbor3x3: + raise MemoryError() + + cdef short location + cdef char x, y + + # add all surrounding neighbors to every board location + for x in range( size ): + + for y in range( size ): + + location = ( x + ( y * size ) ) * 8 + neighbor3x3[ location + 0 ] = calculate_board_location_or_border( x , y - 1, size ) + neighbor3x3[ location + 1 ] = calculate_board_location_or_border( x - 1, y , size ) + neighbor3x3[ location + 2 ] = calculate_board_location_or_border( x + 1, y , size ) + neighbor3x3[ location + 3 ] = calculate_board_location_or_border( x , y + 1, size ) + + neighbor3x3[ location + 4 ] = calculate_board_location_or_border( x - 1, y - 1, size ) + neighbor3x3[ location + 5 ] = calculate_board_location_or_border( x + 1, y - 1, size ) + neighbor3x3[ location + 6 ] = calculate_board_location_or_border( x - 1, y + 1, size ) + neighbor3x3[ location + 7 ] = calculate_board_location_or_border( x + 1, y + 1, size ) + + return neighbor3x3 + +@cython.boundscheck( False ) +@cython.wraparound( False ) +cdef short* get_12d_neighbors( char size ): + """ + create array for every board location with 12d star neighbor locations + neighbor order: top star tip + above left - above middle - above right + left star tip - left - right - right star tip + below left - below middle - below right + below star tip + + -2 x + -1 xxx + xx xx + +1 xxx + +2 x + + order: + -2 0 + -1 123 + 45 67 + +1 89a + +2 b + """ + + # create array + cdef short* neighbor12d = malloc( size * size * 12 * sizeof( short ) ) + if not neighbor12d: + raise MemoryError() + + cdef short location + cdef char x, y + + # add all 12d neighbors to every board location + for x in range( size ): + + for y in range( size ): + + location = ( x + ( y * size ) ) * 12 + neighbor12d[ location + 4 ] = calculate_board_location_or_border( x , y - 2, size ) + + neighbor12d[ location + 1 ] = calculate_board_location_or_border( x - 1, y - 1, size ) + neighbor12d[ location + 5 ] = calculate_board_location_or_border( x , y - 1, size ) + neighbor12d[ location + 8 ] = calculate_board_location_or_border( x + 1, y - 1, size ) + + neighbor12d[ location + 0 ] = calculate_board_location_or_border( x - 2, y , size ) + neighbor12d[ location + 2 ] = calculate_board_location_or_border( x - 1, y , size ) + neighbor12d[ location + 9 ] = calculate_board_location_or_border( x + 1, y , size ) + neighbor12d[ location + 11 ] = calculate_board_location_or_border( x + 2, y , size ) + + neighbor12d[ location + 3 ] = calculate_board_location_or_border( x - 1, y + 1, size ) + neighbor12d[ location + 6 ] = calculate_board_location_or_border( x , y + 1, size ) + neighbor12d[ location + 10 ] = calculate_board_location_or_border( x + 1, y + 1, size ) + + neighbor12d[ location + 7 ] = calculate_board_location_or_border( x , y + 2, size ) + + return neighbor12d \ No newline at end of file diff --git a/AlphaGo/go_root.pxd b/AlphaGo/go_root.pxd deleted file mode 100644 index 36c4a7439..000000000 --- a/AlphaGo/go_root.pxd +++ /dev/null @@ -1,49 +0,0 @@ -from AlphaGo.go cimport GameState -from AlphaGo.go_data cimport _BORDER, _EMPTY, _BLACK, _WHITE, _PASS, Group, Groups_List, Locations_List, group_new - -cdef class RootState: - - ############################################################################ - # variables declarations # - # # - ############################################################################ - - cdef short size - cdef short board_size - - # empty group - cdef Group *group_empty - - # border group - cdef Group *group_border - - # arrays, neighbor arrays pointers - cdef short *neighbor - cdef short *neighbor3x3 - cdef short *neighbor12d - - ############################################################################ - # cdef init functions # - # # - ############################################################################ - - cdef short calculate_board_location( self, char x, char y ) - cdef short calculate_board_location_or_border( self, char x, char y ) - - cdef void set_neighbors( self, int size ) - cdef void set_3x3_neighbors( self, int size ) - cdef void set_12d_neighbors( self, int size ) - - ############################################################################ - # public functions ( c only ) # - # # - ############################################################################ - - cdef GameState get_root_state( self ) - - ############################################################################ - # public functions # - # # - ############################################################################ - - # def get_root_game_state( self ) \ No newline at end of file diff --git a/AlphaGo/go_root.pyx b/AlphaGo/go_root.pyx deleted file mode 100644 index 20eb1551b..000000000 --- a/AlphaGo/go_root.pyx +++ /dev/null @@ -1,367 +0,0 @@ -cimport cython -from libc.stdlib cimport malloc, free - -cdef class RootState: - - ############################################################################ - # variables declarations # - # # - ############################################################################ - - """ -> variables, declared in go_root.pxd - - cdef short size - - # arrays, neighbor arrays pointers - cdef short *neighbor - cdef short *neighbor3x3 - cdef short *neighbor12d - - """ - - ############################################################################ - # cdef init functions # - # # - ############################################################################ - - - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef short calculate_board_location( self, char x, char y ): - """ - return location on board - no checks on outside board - x = columns - y = rows - """ - - # return board location - return x + ( y * self.size ) - - - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef short calculate_board_location_or_border( self, char x, char y ): - """ - return location on board or borderlocation - board locations = [ 0, size * size ) - border location = size * size - x = columns - y = rows - """ - - # check if x or y are outside board - if x < 0 or y < 0 or x >= self.size or y >= self.size: - # return border location - return self.board_size - - # return board location - return self.calculate_board_location( x, y ) - - - cdef void set_neighbors( self, int size ): - """ - create array for every board location with all 4 direct neighbour locations - neighbor order: left - right - above - below - - -1 x - x x - +1 x - - order: - -1 2 - 0 1 - +1 3 - - TODO neighbors is obsolete as neighbor3x3 contains the same values - """ - - # create array - self.neighbor = malloc( size * size * 4 * sizeof( short ) ) - if not self.neighbor: - raise MemoryError() - - cdef short location - cdef char x, y - - # add all direct neighbors to every board location - for y in range( size ): - for x in range( size ): - location = ( x + ( y * size ) ) * 4 - self.neighbor[ location + 0 ] = self.calculate_board_location_or_border( x - 1, y ) - self.neighbor[ location + 1 ] = self.calculate_board_location_or_border( x + 1, y ) - self.neighbor[ location + 2 ] = self.calculate_board_location_or_border( x , y - 1 ) - self.neighbor[ location + 3 ] = self.calculate_board_location_or_border( x , y + 1 ) - - - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef void set_3x3_neighbors( self, int size ): - """ - create for every board location array with all 8 surrounding neighbour locations - neighbor order: above middle - middle left - middle right - below middle - above left - above right - below left - below right - this order is more useful as it separates neighbors and then diagonals - -1 xxx - x x - +1 xxx - - order: - -1 405 - 1 2 - +1 637 - - 0-3 contains neighbors - 4-7 contains diagonals - """ - - # create array - self.neighbor3x3 = malloc( size * size * 8 * sizeof( short ) ) - if not self.neighbor3x3: - raise MemoryError() - - cdef short location - cdef char x, y - - # add all surrounding neighbors to every board location - for x in range( size ): - for y in range( size ): - location = ( x + ( y * size ) ) * 8 - self.neighbor3x3[ location + 0 ] = self.calculate_board_location_or_border( x , y - 1 ) - self.neighbor3x3[ location + 1 ] = self.calculate_board_location_or_border( x - 1, y ) - self.neighbor3x3[ location + 2 ] = self.calculate_board_location_or_border( x + 1, y ) - self.neighbor3x3[ location + 3 ] = self.calculate_board_location_or_border( x , y + 1 ) - - self.neighbor3x3[ location + 4 ] = self.calculate_board_location_or_border( x - 1, y - 1 ) - self.neighbor3x3[ location + 5 ] = self.calculate_board_location_or_border( x + 1, y - 1 ) - self.neighbor3x3[ location + 6 ] = self.calculate_board_location_or_border( x - 1, y + 1 ) - self.neighbor3x3[ location + 7 ] = self.calculate_board_location_or_border( x + 1, y + 1 ) - - - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef void set_12d_neighbors( self, int size ): - """ - create array for every board location with 12d star neighbour locations - neighbor order: top star tip - above left - above middle - above right - left star tip - left - right - right star tip - below left - below middle - below right - below star tip - - -2 x - -1 xxx - xx xx - +1 xxx - +2 x - - order: - -2 0 - -1 123 - 45 67 - +1 89a - +2 b - """ - - # create array - self.neighbor12d = malloc( size * size * 12 * sizeof( short ) ) - if not self.neighbor12d: - raise MemoryError() - - cdef short location - cdef char x, y - - # add all 12d neighbors to every board location - for x in range( size ): - for y in range( size ): - location = ( x + ( y * size ) ) * 12 - self.neighbor12d[ location + 4 ] = self.calculate_board_location_or_border( x , y - 2 ) - - self.neighbor12d[ location + 1 ] = self.calculate_board_location_or_border( x - 1, y - 1 ) - self.neighbor12d[ location + 5 ] = self.calculate_board_location_or_border( x , y - 1 ) - self.neighbor12d[ location + 8 ] = self.calculate_board_location_or_border( x + 1, y - 1 ) - - self.neighbor12d[ location + 0 ] = self.calculate_board_location_or_border( x - 2, y ) - self.neighbor12d[ location + 2 ] = self.calculate_board_location_or_border( x - 1, y ) - self.neighbor12d[ location + 9 ] = self.calculate_board_location_or_border( x + 1, y ) - self.neighbor12d[ location + 11 ] = self.calculate_board_location_or_border( x + 2, y ) - - self.neighbor12d[ location + 3 ] = self.calculate_board_location_or_border( x - 1, y + 1 ) - self.neighbor12d[ location + 6 ] = self.calculate_board_location_or_border( x , y + 1 ) - self.neighbor12d[ location + 10 ] = self.calculate_board_location_or_border( x + 1, y + 1 ) - - self.neighbor12d[ location + 7 ] = self.calculate_board_location_or_border( x , y + 2 ) - - - @cython.boundscheck( False ) - @cython.wraparound( False ) - def __init__( self, char size = 19 ): - """ - RootState initializes all neighbor arrays - when destoyed all arrays are freed in order to prevent a memory leak - """ - - if size > 19: - raise IllegalBoardSize( "board size >19 not yet supported" ) - - # set size - self.size = size - self.board_size = size * size - - # initialize neighbor locations - self.set_neighbors( size ) - self.set_3x3_neighbors( size ) - self.set_12d_neighbors( size ) - - # initialize EMPTY and BORDER group - self.group_empty = group_new( _EMPTY, self.board_size ) - self.group_border = group_new( _BORDER, self.board_size ) - - - @cython.boundscheck( False ) - @cython.wraparound( False ) - def __dealloc__(self): - """ - this function is called when this object is destroyed - - Prevent memory leaks by freeing all arrays created with malloc - """ - - # free neighbor - if self.neighbor is not NULL: - free( self.neighbor ) - - # free neighbor3x3 - if self.neighbor3x3 is not NULL: - free( self.neighbor3x3 ) - - # free neighbor12d - if self.neighbor12d is not NULL: - free( self.neighbor12d ) - - # free border and empty group - if self.group_empty is not NULL: - - free( self.group_empty ) - - if self.group_border is not NULL: - - free( self.group_border ) - - - ############################################################################ - # public functions ( c only ) # - # # - ############################################################################ - - - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef GameState get_root_state( self ): - """ - - """ - - cdef GameState game_state = GameState() - - cdef short i - - # set pointer to neighbor locations - game_state.neighbor = self.neighbor - game_state.neighbor3x3 = self.neighbor3x3 - game_state.neighbor12d = self.neighbor12d - - # initialize size and board_size - game_state.size = self.size - game_state.board_size = self.size * self.size - - # create history list - game_state.history = [] - - # initialize player colours - game_state.player_current = _BLACK - game_state.player_opponent = _WHITE - - game_state.ko = _PASS - game_state.capture_black = 0 - game_state.capture_white = 0 - game_state.passes_black = 0 - game_state.passes_white = 0 - - # create arrays and lists - # +1 on board_size is used as an border location used for all borders - - # create board_groups array able to hold all groups on the board and border ( board_size +1 ) - game_state.board_groups = malloc( ( self.board_size + 1 ) * sizeof( Group* ) ) - if not game_state.board_groups: - raise MemoryError() - - # create 3x3 hash array - game_state.hash3x3 = malloc( ( self.board_size ) * sizeof( long ) ) - if not game_state.hash3x3: - raise MemoryError() - - # create Locations_List able to hold all legal moves ( == board_size ) - game_state.moves_legal = malloc( sizeof( Locations_List ) ) - if not game_state.moves_legal: - raise MemoryError() - - game_state.moves_legal.locations = malloc( self.board_size * sizeof( short ) ) - if not game_state.moves_legal.locations: - raise MemoryError() - - game_state.moves_legal.count = self.board_size - - # create groups_list array to hold all groups on the board ( == board_size ) - # TODO estimate a good lower bound - game_state.groups_list = malloc( sizeof( Groups_List ) ) - if not game_state.groups_list: - raise MemoryError() - - game_state.groups_list.board_groups = malloc( self.board_size * sizeof( Group* ) ) - if not game_state.groups_list.board_groups: - raise MemoryError() - - game_state.groups_list.count_groups = 0 - - # create empty location group - game_state.group_empty = self.group_empty - - # initialize board - for i in range( game_state.board_size ): - - game_state.hash3x3[ i ] = 0 - game_state.board_groups[ i ] = game_state.group_empty - game_state.moves_legal.locations[ i ] = i - - # initialize border location - game_state.board_groups[ self.board_size ] = self.group_border - - # initialize zobrist hash - # TODO optimize? - # rng = np.random.RandomState(0) - # self.hash_lookup = { - # WHITE: rng.randint(np.iinfo(np.uint64).max, size=(size, size), dtype='uint64'), - # BLACK: rng.randint(np.iinfo(np.uint64).max, size=(size, size), dtype='uint64')} - # self.current_hash = np.uint64(0) - # self.previous_hashes = set() - - return game_state - - ############################################################################ - # public functions # - # # - ############################################################################ - - @cython.boundscheck( False ) - @cython.wraparound( False ) - def get_root_game_state( self, enforce_superko=False ): - """ - return new GameState initialized as a new game - """ - - return self.get_root_state() - - -class IllegalBoardSize(Exception): - pass diff --git a/AlphaGo/preprocessing/game_converter.py b/AlphaGo/preprocessing/game_converter.py index 6e07155ad..d09ea12b3 100644 --- a/AlphaGo/preprocessing/game_converter.py +++ b/AlphaGo/preprocessing/game_converter.py @@ -5,7 +5,6 @@ import h5py as h5 import numpy as np import AlphaGo.go as go -from AlphaGo.go_root import RootState from AlphaGo.preprocessing.preprocessing import Preprocess from AlphaGo.util import sgf_iter_states @@ -20,7 +19,7 @@ def __init__(self, features): self.feature_processor = Preprocess(features) self.n_features = self.feature_processor.get_output_dimension() - def convert_game(self, root, file_name, bd_size): + def convert_game(self, file_name, bd_size): """Read the given SGF file into an iterable of (input,output) pairs for neural network training @@ -31,7 +30,7 @@ def convert_game(self, root, file_name, bd_size): """ with open(file_name, 'r') as file_object: - state_action_iterator = sgf_iter_states(root, file_object.read(), include_end=False) + state_action_iterator = sgf_iter_states(file_object.read(), include_end=False) for (state, move, player) in state_action_iterator: if state.get_size() != bd_size: @@ -40,7 +39,7 @@ def convert_game(self, root, file_name, bd_size): nn_input = self.feature_processor.state_to_tensor(state) yield (nn_input, move) - def sgfs_to_hdf5(self, root, sgf_files, hdf5_file, bd_size=19, ignore_errors=True, verbose=False): + def sgfs_to_hdf5(self, sgf_files, hdf5_file, bd_size=19, ignore_errors=True, verbose=False): """Convert all files in the iterable sgf_files into an hdf5 group to be stored in hdf5_file Arguments: @@ -107,7 +106,7 @@ def sgfs_to_hdf5(self, root, sgf_files, hdf5_file, bd_size=19, ignore_errors=Tru n_pairs = 0 file_start_idx = next_idx try: - for state, move in self.convert_game(root, file_name, bd_size): + for state, move in self.convert_game(file_name, bd_size): if next_idx >= len(states): states.resize((next_idx + 1, self.n_features, bd_size, bd_size)) actions.resize((next_idx + 1, 2)) @@ -194,8 +193,6 @@ def run_game_converter(cmd_line_args=None): if args.verbose: print("using features", feature_list) - root = RootState() - converter = GameConverter(feature_list) def _is_sgf(fname): @@ -225,7 +222,7 @@ def _list_sgfs(path): else: files = (f.strip() for f in sys.stdin if _is_sgf(f)) - converter.sgfs_to_hdf5(root, files, args.outfile, bd_size=args.size, verbose=args.verbose) + converter.sgfs_to_hdf5(files, args.outfile, bd_size=args.size, verbose=args.verbose) if __name__ == '__main__': diff --git a/AlphaGo/preprocessing/generate_value_training.py b/AlphaGo/preprocessing/generate_value_training.py index a1c9745a8..bb6779fb8 100644 --- a/AlphaGo/preprocessing/generate_value_training.py +++ b/AlphaGo/preprocessing/generate_value_training.py @@ -5,7 +5,7 @@ import numpy as np from AlphaGo.go import WHITE from AlphaGo.go import BLACK -from AlphaGo.go_root import RootState +from AlphaGo.go import GameState from AlphaGo.models.policy import CNNPolicy from AlphaGo.util import save_gamestate_to_sgf from AlphaGo.ai import ProbabilisticPolicyPlayer @@ -55,7 +55,7 @@ def init_hdf5(h5f, n_features, bd_size): return states, winners -def play_batch(root, player_RL, player_SL, batch_size, features, i_rand_move, next_idx, sgf_path): +def play_batch(player_RL, player_SL, batch_size, features, i_rand_move, next_idx, sgf_path): """Play a batch of games in parallel and return one training pair from each game. As described in Silver et al, the method for generating value net training data is as follows: @@ -70,7 +70,7 @@ def play_batch(root, player_RL, player_SL, batch_size, features, i_rand_move, ne def do_move(states, moves): for st, mv in zip(states, moves): - if not st.is_end_of_game: + if not st.is_end_of_game(): # Only do more moves if not end of game already st.do_move(mv) return states @@ -99,7 +99,7 @@ def convert(state_list, preprocessor): # Lists of game training pairs (1-hot) preprocessor = Preprocess(features) - states = [root.get_root_game_state() for _ in xrange(batch_size)] + states = [GameState() for _ in xrange(batch_size)] # play player_SL moves for _ in xrange(i_rand_move - 1): @@ -167,10 +167,10 @@ def convert(state_list, preprocessor): return training_states, winners -def generate_data(root, player_RL, player_SL, hdf5_file, n_training_pairs, +def generate_data(player_RL, player_SL, hdf5_file, n_training_pairs, batch_size, bd_size, features, verbose, sgf_path): # used features - n_features = Preprocess(features).output_dim + n_features = Preprocess(features).get_output_dimension() # temporary hdf5 file tmp_file = os.path.join(os.path.dirname(hdf5_file), ".tmp." + os.path.basename(hdf5_file)) # open hdf5 file @@ -192,7 +192,7 @@ def generate_data(root, player_RL, player_SL, hdf5_file, n_training_pairs, i_rand_move = np.random.choice(range(DEAULT_RANDOM_MOVE)) # play games - states, winners = play_batch(root, player_RL, player_SL, batch_size, features, + states, winners = play_batch(player_RL, player_SL, batch_size, features, i_rand_move, next_idx, sgf_path) if states is not None: @@ -268,8 +268,6 @@ def handle_arguments(cmd_line_args=None): parser.add_argument("--sl-temperature", help="Distribution temperature of players using SL policies. Default: " + str(DEFAULT_TEMPERATURE_SL), type=float, default=DEFAULT_TEMPERATURE_SL) # noqa: E501 parser.add_argument("--rl-temperature", help="Distribution temperature of players using RL policies. Default: " + str(DEFAULT_TEMPERATURE_RL), type=float, default=DEFAULT_TEMPERATURE_RL) # noqa: E501 - root = RootState() - # show help or parse arguments if cmd_line_args is None: args = parser.parse_args() @@ -318,7 +316,7 @@ def handle_arguments(cmd_line_args=None): os.makedirs(args.sgf_path) # generate data - generate_data(root, player_RL, player_SL, args.outfile, args.n_training_pairs, args.batch_size, + generate_data(player_RL, player_SL, args.outfile, args.n_training_pairs, args.batch_size, policy_SL.model.input_shape[-1], features, args.verbose, args.sgf_path) diff --git a/AlphaGo/preprocessing/preprocessing.pxd b/AlphaGo/preprocessing/preprocessing.pxd index 915582c8a..0ff61f26b 100644 --- a/AlphaGo/preprocessing/preprocessing.pxd +++ b/AlphaGo/preprocessing/preprocessing.pxd @@ -5,10 +5,14 @@ cimport numpy as np from numpy cimport ndarray from libc.stdlib cimport malloc, free from AlphaGo.go cimport GameState -from AlphaGo.go_data cimport _BLACK, _EMPTY, _STONE, _LIBERTY, _CAPTURE, _FREE, Group, Locations_List, locations_list_destroy +from AlphaGo.go_data cimport _BLACK, _EMPTY, _STONE, _LIBERTY, _CAPTURE, _FREE, _PASS, Group, Locations_List, locations_list_destroy, locations_list_new +# type of tensor created +# char works but float might be needed later ctypedef char tensor_type -ctypedef int (*preprocess_method)( Preprocess, GameState, tensor_type[ :, ::1 ], short*, int ) + +# type defining cdef function +ctypedef int (*preprocess_method)( Preprocess, GameState, tensor_type[ :, ::1 ], char*, int ) cdef class Preprocess: @@ -48,28 +52,112 @@ cdef class Preprocess: # # ############################################################################ - cdef int get_board( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) - cdef int get_turns_since( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) - cdef int get_liberties( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) - cdef int get_capture_size( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) - cdef int get_self_atari_size( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) - cdef int get_liberties_after( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) - cdef int get_ladder_capture( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) - cdef int get_ladder_escape( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) - cdef int get_sensibleness( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) - cdef int get_legal( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) - cdef int get_response( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) - cdef int get_save_atari( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) - cdef int get_neighbor( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) - cdef int get_nakade( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) - cdef int get_nakade_offset( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) - cdef int get_response_12d( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) - cdef int get_response_12d_offset( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) - cdef int zeros( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) - cdef int ones( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) - cdef int colour( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) - cdef int get_non_response_3x3( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) - cdef int get_non_response_3x3_offset( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ) + cdef int get_board( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + """ + A feature encoding WHITE BLACK and EMPTY on separate planes. + plane 0 always refers to the current player stones + plane 1 to the opponent stones + plane 2 to empty locations + """ + + cdef int get_turns_since( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + """ + A feature encoding the age of the stone at each location up to 'maximum' + + Note: + - the [maximum-1] plane is used for any stone with age greater than or equal to maximum + - EMPTY locations are all-zero features + """ + + cdef int get_liberties( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + """ + A feature encoding the number of liberties of the group connected to the stone at + each location + + Note: + - there is no zero-liberties plane; the 0th plane indicates groups in atari + - the [maximum-1] plane is used for any stone with liberties greater than or equal to maximum + - EMPTY locations are all-zero features + """ + + cdef int get_capture_size( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + """ + A feature encoding the number of opponent stones that would be captured by + playing at each location, up to 'maximum' + + Note: + - we currently *do* treat the 0th plane as "capturing zero stones" + - the [maximum-1] plane is used for any capturable group of size + greater than or equal to maximum-1 + - the 0th plane is used for legal moves that would not result in capture + - illegal move locations are all-zero features + """ + + cdef int get_self_atari_size( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + """ + A feature encoding the size of the own-stone group that is put into atari by + playing at a location + + """ + + cdef int get_liberties_after( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + """ + A feature encoding what the number of liberties *would be* of the group connected to + the stone *if* played at a location + + Note: + - there is no zero-liberties plane; the 0th plane indicates groups in atari + - the [maximum-1] plane is used for any stone with liberties greater than or equal to maximum + - illegal move locations are all-zero features + """ + + cdef int get_ladder_capture( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + """ + A feature wrapping GameState.is_ladder_capture(). + check if an opponent group can be captured in a ladder + """ + + cdef int get_ladder_escape( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + """ + A feature wrapping GameState.is_ladder_escape(). + check if player_current group can escape ladder + """ + + cdef int get_sensibleness( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + """ + A move is 'sensible' if it is legal and if it does not fill the current_player's own eye + """ + + cdef int get_legal( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + """ + Zero at all illegal moves, one at all legal moves. Unlike sensibleness, no eye check is done + not used?? + """ + + cdef int zeros( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + """ + Plane filled with zeros + """ + + cdef int ones( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + """ + Plane filled with ones + """ + + cdef int colour( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + """ + Value net feature, plane with ones if active_player is black else zeros + """ + + cdef int get_response( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + cdef int get_save_atari( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + cdef int get_neighbor( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + cdef int get_nakade( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + cdef int get_nakade_offset( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + cdef int get_response_12d( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + cdef int get_response_12d_offset( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + cdef int get_non_response_3x3( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + cdef int get_non_response_3x3_offset( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) ############################################################################ # public cdef function # @@ -77,3 +165,6 @@ cdef class Preprocess: ############################################################################ cdef np.ndarray[ tensor_type, ndim=4 ] generate_tensor( self, GameState state ) + """ + Convert a GameState to a Theano-compatible tensor + """ diff --git a/AlphaGo/preprocessing/preprocessing.pyx b/AlphaGo/preprocessing/preprocessing.pyx index 9e35e5d18..c7aa2af6a 100644 --- a/AlphaGo/preprocessing/preprocessing.pyx +++ b/AlphaGo/preprocessing/preprocessing.pyx @@ -4,6 +4,7 @@ cimport cython import numpy as np cimport numpy as np + cdef class Preprocess: ############################################################################ @@ -11,6 +12,7 @@ cdef class Preprocess: # # ############################################################################ + """ -> variables, declared in preprocessing.pxd # all feature processors @@ -41,15 +43,17 @@ cdef class Preprocess: -> variables, declared in preprocessing.pxd """ + ############################################################################ # Tensor generating functions # # # ############################################################################ + @cython.boundscheck( False ) @cython.wraparound( False ) @cython.nonecheck( False ) - cdef int get_board( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + cdef int get_board( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): """ A feature encoding WHITE BLACK and EMPTY on separate planes. plane 0 always refers to the current player stones @@ -57,19 +61,35 @@ cdef class Preprocess: plane 2 to empty locations """ - cdef short location + cdef short location + cdef Group* group + cdef int plane + cdef char opponent = state.player_opponent # loop over all locations on board for location in range( self.board_size ): - tensor[ offSet + state.get_board_feature( location ), location ] = 1 + group = state.board_groups[ location ] + + if group.colour == _EMPTY: + + plane = offSet + 2 + elif group.colour == opponent: + + plane = offSet + 1 + else: + + plane = offSet + + tensor[ plane, location ] = 1 return offSet + 3 + @cython.boundscheck( False ) @cython.wraparound( False ) @cython.nonecheck( False ) - cdef int get_turns_since( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + cdef int get_turns_since( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): """ A feature encoding the age of the stone at each location up to 'maximum' @@ -79,38 +99,46 @@ cdef class Preprocess: """ cdef short location + cdef Locations_List *history = state.moves_history cdef int age = offSet + 7 - cdef int i = len( state.history ) - 1 cdef dict agesSet = {} + cdef int i # set all stones to max age - for location in state.history: - if state.board_groups[ location ].colour > _EMPTY: + for i in range( history.count ): + + location = history.locations[ i ] + + if location != _PASS and state.board_groups[ location ].colour > _EMPTY: + tensor[ age, location ] = 1 + # start with newest stone + i = history.count - 1 age = 0 # loop over history backwards while age < 7 and i >= 0: - location = state.history[ i ] + location = history.locations[ i ] # if age has not been set yet - if not location in agesSet and state.board_groups[ location ].colour > _EMPTY: + if location != _PASS and not location in agesSet and state.board_groups[ location ].colour > _EMPTY: - tensor[ offSet + age, location ] = 1 - tensor[ offSet + 7, location ] = 0 - agesSet[ location ] = location + tensor[ offSet + age, location ] = 1 + tensor[ offSet + 7, location ] = 0 + agesSet[ location ] = location i -= 1 age += 1 return offSet + 8 + @cython.boundscheck( False ) @cython.wraparound( False ) @cython.nonecheck( False ) - cdef int get_liberties( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + cdef int get_liberties( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): """ A feature encoding the number of liberties of the group connected to the stone at each location @@ -121,37 +149,34 @@ cdef class Preprocess: - EMPTY locations are all-zero features """ - cdef int i + cdef int i, groupLiberty cdef Group* group cdef short location - cdef short groupLiberty - # loop over all groups on board - for i in range( state.groups_list.count_groups ): + for location in range( self.board_size ): - group = state.groups_list.board_groups[ i ] + group = state.board_groups[ location ] - # get liberty count - groupLiberty = group.count_liberty - 1 + if group.colour > _EMPTY: - # check max liberty count - if groupLiberty > 7: + groupLiberty = group.count_liberty - 1 - groupLiberty = 7 + # check max liberty count + if groupLiberty > 7: - # loop over all group stones and set liberty count - for location in range( state.board_size ): + groupLiberty = 7 - if group.locations[ location ] == _STONE: + groupLiberty += offSet + + tensor[ groupLiberty, location ] = 1 - tensor[ offSet + groupLiberty, location ] = 1 - return offSet + 8 + @cython.boundscheck( False ) @cython.wraparound( False ) @cython.nonecheck( False ) - cdef int get_capture_size( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + cdef int get_capture_size( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): """ A feature encoding the number of opponent stones that would be captured by playing at each location, up to 'maximum' @@ -180,17 +205,18 @@ cdef class Preprocess: return offSet + 8 + @cython.boundscheck( False ) @cython.wraparound( False ) @cython.nonecheck( False ) - cdef int get_self_atari_size( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + cdef int get_self_atari_size( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): """ A feature encoding the size of the own-stone group that is put into atari by playing at a location """ - cdef short i, location, group_liberty + cdef short i, location, group_liberty # loop over all groups on board for i in range( state.moves_legal.count ): @@ -209,10 +235,11 @@ cdef class Preprocess: return offSet + 8 + @cython.boundscheck( False ) @cython.wraparound( False ) @cython.nonecheck( False ) - cdef int get_liberties_after( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + cdef int get_liberties_after( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): """ A feature encoding what the number of liberties *would be* of the group connected to the stone *if* played at a location @@ -241,10 +268,11 @@ cdef class Preprocess: return offSet + 8 + @cython.boundscheck( False ) @cython.wraparound( False ) @cython.nonecheck( False ) - cdef int get_ladder_capture( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + cdef int get_ladder_capture( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): """ A feature wrapping GameState.is_ladder_capture(). check if an opponent group can be captured in a ladder @@ -265,10 +293,11 @@ cdef class Preprocess: return offSet + 1 + @cython.boundscheck( False ) @cython.wraparound( False ) @cython.nonecheck( False ) - cdef int get_ladder_escape( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + cdef int get_ladder_escape( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): """ A feature wrapping GameState.is_ladder_escape(). check if player_current group can escape ladder @@ -289,31 +318,55 @@ cdef class Preprocess: return offSet + 1 + @cython.boundscheck( False ) @cython.wraparound( False ) @cython.nonecheck( False ) - cdef int get_sensibleness( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + cdef int get_sensibleness( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): """ A move is 'sensible' if it is legal and if it does not fill the current_player's own eye """ - cdef short i - cdef Locations_List* sensible_moves = state.get_sensible_moves() + cdef int i + cdef short location + cdef Group* group - # loop over all sensible moves and set to 1 - for i in range( sensible_moves.count ): + # set all legal moves to 1 + for i in range( state.moves_legal.count ): + + tensor[ offSet, state.moves_legal.locations[ i ] ] = 1 + + # list can increment but a big enough starting value is important + cdef Locations_List* eyes = locations_list_new( 15 ) + + # loop over all board groups + for i in range( state.groups_list.count_groups ): + + group = state.groups_list.board_groups[ i ] + + # if group is current player + if group.colour == state.player_current: + + # loop over liberties because they are possible eyes + for location in range( self.board_size ): - tensor[ offSet, sensible_moves.locations[ i ] ] = 1 + # check liberty location as possible eye + if group.locations[ location ] == _LIBERTY: - # free sensible_moves - locations_list_destroy( sensible_moves ) + # check if location is an eye + if state.is_true_eye( location, eyes, state.player_current ): + + tensor[ offSet, location ] = 0 + + locations_list_destroy( eyes ) return offSet + 1 + @cython.boundscheck( False ) @cython.wraparound( False ) @cython.nonecheck( False ) - cdef int get_legal( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + cdef int get_legal( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): """ Zero at all illegal moves, one at all legal moves. Unlike sensibleness, no eye check is done not used?? @@ -328,30 +381,33 @@ cdef class Preprocess: return offSet + 1 + @cython.boundscheck( False ) @cython.wraparound( False ) @cython.nonecheck( False ) - cdef int get_response( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + cdef int get_response( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): """ Fast rollout feature """ return offSet + 1 + @cython.boundscheck( False ) @cython.wraparound( False ) @cython.nonecheck( False ) - cdef int get_save_atari( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + cdef int get_save_atari( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): """ Fast rollout feature """ return offSet + 1 + @cython.boundscheck( False ) @cython.wraparound( False ) @cython.nonecheck( False ) - cdef int get_neighbor( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + cdef int get_neighbor( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): """ Fast rollout feature """ @@ -378,30 +434,33 @@ cdef class Preprocess: return offSet + 2 + @cython.boundscheck( False ) @cython.wraparound( False ) @cython.nonecheck( False ) - cdef int get_nakade( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + cdef int get_nakade( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): """ Fast rollout feature """ return offSet + 1 + @cython.boundscheck( False ) @cython.wraparound( False ) @cython.nonecheck( False ) - cdef int get_nakade_offset( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + cdef int get_nakade_offset( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): """ Fast rollout feature """ return offSet + self.pattern_nakade_size + @cython.boundscheck( False ) @cython.wraparound( False ) @cython.nonecheck( False ) - cdef int get_response_12d( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + cdef int get_response_12d( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): """ Fast rollout feature """ @@ -411,10 +470,11 @@ cdef class Preprocess: return offSet + 1 + @cython.boundscheck( False ) @cython.wraparound( False ) @cython.nonecheck( False ) - cdef int get_response_12d_offset( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + cdef int get_response_12d_offset( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): """ Fast rollout feature """ @@ -424,30 +484,33 @@ cdef class Preprocess: return offSet + self.pattern_response_12d_size + @cython.boundscheck( False ) @cython.wraparound( False ) @cython.nonecheck( False ) - cdef int get_non_response_3x3( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + cdef int get_non_response_3x3( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): """ Fast rollout feature """ return offSet + 1 + @cython.boundscheck( False ) @cython.wraparound( False ) @cython.nonecheck( False ) - cdef int get_non_response_3x3_offset( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + cdef int get_non_response_3x3_offset( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): """ Fast rollout feature """ return offSet + self.pattern_non_response_3x3_size + @cython.boundscheck( False ) @cython.wraparound( False ) @cython.nonecheck( False ) - cdef int zeros( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + cdef int zeros( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): """ Plane filled with zeros """ @@ -459,10 +522,11 @@ cdef class Preprocess: return offSet + 1 + @cython.boundscheck( False ) @cython.wraparound( False ) @cython.nonecheck( False ) - cdef int ones( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + cdef int ones( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): """ Plane filled with ones """ @@ -474,10 +538,11 @@ cdef class Preprocess: tensor[ offSet, location ] = 1 return offSet + 1 + @cython.boundscheck( False ) @cython.wraparound( False ) @cython.nonecheck( False ) - cdef int colour( self, GameState state, tensor_type[ :, ::1 ] tensor, short *groups_after, int offSet ): + cdef int colour( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): """ Value net feature, plane with ones if active_player is black else zeros """ @@ -509,7 +574,12 @@ cdef class Preprocess: self.board_size = size * size cdef int i + + # preprocess_method is a function pointer: + # ctypedef int (*preprocess_method)( Preprocess, GameState, tensor_type[ :, ::1 ], char*, int ) cdef preprocess_method processor + + # create a list with function pointers self.processors = malloc( len( feature_list ) * sizeof( preprocess_method ) ) if not self.processors: @@ -550,6 +620,8 @@ cdef class Preprocess: self.feature_list = feature_list self.output_dim = 0 + # loop over feature_list add the corresponding function + # and increment output_dim accordingly for i in range( len( feature_list ) ): feat = feature_list[ i ].lower() if feat == "board": @@ -628,6 +700,8 @@ cdef class Preprocess: processor = self.colour self.output_dim += 1 else: + + # incorrect feature input raise ValueError( "uknown feature: %s" % feat ) self.processors[ i ] = processor @@ -661,14 +735,16 @@ cdef class Preprocess: cdef preprocess_method proc # create complete array now instead of concatenate later + # TODO check if we can use a Malloc array somehow.. faster!! cdef np.ndarray[ tensor_type, ndim=2 ] np_tensor = np.zeros( ( self.output_dim, self.board_size ), dtype=np.int8 ) cdef tensor_type[ :, ::1 ] tensor = np_tensor cdef int offSet = 0 - cdef short *groups_after = state.get_groups_after() - # TODO create array with all nextmoves information + # get char array with next move information + cdef char *groups_after = state.get_groups_after() + # loop over all processors and generate tensor for i in range( len( self.feature_list ) ): proc = self.processors[ i ] @@ -687,8 +763,6 @@ cdef class Preprocess: ############################################################################ - # this function should be used from Python environment, - # use generate_tensor from C environment for speed def state_to_tensor( self, GameState state ): """ Convert a GameState to a Theano-compatible tensor @@ -696,24 +770,29 @@ cdef class Preprocess: return self.generate_tensor( state ) + def get_output_dimension( self ): """ - + return output_dim, the amount of planes an output tensor will have """ return self.output_dim + def get_feature_list( self ): """ - + return feature list """ return self.feature_list + ############################################################################ # test # # # ############################################################################ + + def test( self, GameState state, int amount ): cdef char size = state.size self.board_size = state.size * state.size @@ -728,6 +807,7 @@ cdef class Preprocess: print "proc " + str( time.time() - t ) + def timed_test( self, GameState state, int amount ): cdef int i diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 733eaaa6b..1ed7aaf29 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -6,7 +6,6 @@ from shutil import copyfile from keras.optimizers import SGD from AlphaGo.util import flatten_idx -from AlphaGo.go_root import RootState from AlphaGo.models.policy import CNNPolicy from AlphaGo.ai import ProbabilisticPolicyPlayer @@ -18,13 +17,15 @@ def _make_training_pair(st, mv, preprocessor): return (st_tensor, mv_tensor) -def run_n_games(optimizer, root, learner, opponent, num_games, mock_states=[]): - '''Run num_games games to completion, keeping track of each position and move of the learner. +def run_n_games(optimizer, learner, opponent, num_games, mock_states=[]): + ''' + Run num_games games to completion, keeping track of each position and move of the learner. - (Note: learning cannot happen until all games have completed) + (Note: learning cannot happen until all games have completed) ''' - states = [root.get_root_game_state() for _ in range(num_games)] + board_size = learner.policy.model.input_shape[-1] + states = [go.GameState(size=board_size) for _ in range(num_games)] learner_net = learner.policy.model # Allowing injection of a mock state object for testing purposes @@ -61,8 +62,9 @@ def run_n_games(optimizer, root, learner, opponent, num_games, mock_states=[]): (st_tensor, mv_tensor) = _make_training_pair(state, mv, learner.policy.preprocessor) state_tensors[idx].append(st_tensor) move_tensors[idx].append(mv_tensor) + state.do_move(mv) - if state.is_end_of_game: + if state.is_end_of_game(): learner_won[idx] = state.get_winner() == learner_color[idx] just_finished.append(idx) @@ -86,10 +88,12 @@ def run_n_games(optimizer, root, learner, opponent, num_games, mock_states=[]): def log_loss(y_true, y_pred): - '''Keras 'loss' function for the REINFORCE algorithm, where y_true is the action that was - taken, and updates with the negative gradient will make that action more likely. We use the - negative gradient because keras expects training data to minimize a loss function. ''' + Keras 'loss' function for the REINFORCE algorithm, where y_true is the action that was + taken, and updates with the negative gradient will make that action more likely. We use the + negative gradient because keras expects training data to minimize a loss function. + ''' + return -y_true * K.log(K.clip(y_pred, K.epsilon(), 1.0 - K.epsilon())) @@ -183,7 +187,6 @@ def save_metadata(): optimizer = SGD(lr=args.learning_rate) player.policy.model.compile(loss=log_loss, optimizer=optimizer) - root = RootState( player.policy.model.input_shape[-1] ) for i_iter in range(1, args.iterations + 1): # Randomly choose opponent from pool (possibly self), and playing # game_batch games against them. @@ -197,7 +200,7 @@ def save_metadata(): # Run games (and learn from results). Keep track of the win ratio vs each opponent over # time. - win_ratio = run_n_games(optimizer, root, player, opponent, args.game_batch) + win_ratio = run_n_games(optimizer, player, opponent, args.game_batch) metadata["win_ratio"][player_weights] = (opp_weights, win_ratio) # Save all intermediate models. diff --git a/AlphaGo/util.py b/AlphaGo/util.py index e2a715219..3af05658a 100644 --- a/AlphaGo/util.py +++ b/AlphaGo/util.py @@ -1,8 +1,9 @@ import os +import sgf import itertools import numpy as np -import sgf from AlphaGo import go +from AlphaGo.go import GameState # for board location indexing LETTERS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' @@ -71,17 +72,17 @@ def _parse_sgf_move(node_value): return (col, row) -def _sgf_init_gamestate(root, sgf_root): +def _sgf_init_gamestate(sgf_root): """ Helper function to set up a GameState object from the root node of an SGF file """ props = sgf_root.properties - s_size = props.get('SZ', ['19'])[0] + s_size = int( props.get('SZ', ['19'])[0] ) s_player = props.get('PL', ['B'])[0] # init board with specified size - gs = root.get_root_game_state() + gs = GameState( size=s_size ) # handle 'add black' property if 'AB' in props: for stone in props['AB']: @@ -95,13 +96,13 @@ def _sgf_init_gamestate(root, sgf_root): return gs -def sgf_to_gamestate(root, sgf_string): +def sgf_to_gamestate(sgf_string): """ Creates a GameState object from the first game in the given collection """ # Don't Repeat Yourself; parsing handled by sgf_iter_states - for (gs, move, player) in sgf_iter_states(root, sgf_string, True): + for (gs, move, player) in sgf_iter_states(sgf_string, True): pass # gs has been updated in-place to the final state by the time # sgf_iter_states returns @@ -139,7 +140,7 @@ def save_gamestate_to_sgf(gamestate, path, filename, black_player_name='Unknown' # Move color prefix str_list.append(';{}'.format(color)) # Move coordinates - if move is None: + if move is go.PASS: str_list.append('[tt]') else: str_list.append('[{}{}]'.format(LETTERS[move[0]].lower(), LETTERS[move[1]].lower())) @@ -148,7 +149,7 @@ def save_gamestate_to_sgf(gamestate, path, filename, black_player_name='Unknown' f.write(''.join(str_list)) -def sgf_iter_states(root, sgf_string, include_end=True): +def sgf_iter_states(sgf_string, include_end=True): """ Iterates over (GameState, move, player) tuples in the first game of the given SGF file. @@ -163,7 +164,7 @@ def sgf_iter_states(root, sgf_string, include_end=True): collection = sgf.parse(sgf_string) game = collection[0] - gs = _sgf_init_gamestate(root, game.root) + gs = _sgf_init_gamestate(game.root) if game.rest is not None: for node in game.rest: props = node.properties @@ -177,7 +178,7 @@ def sgf_iter_states(root, sgf_string, include_end=True): # update state to n+1 gs.do_move(move, player) if include_end: - yield (gs, None, None) + yield (gs, go.PASS, None) def plot_network_output(scores, board, history, out_directory, output_file, diff --git a/interface/Play.py b/interface/Play.py index 3a61eb92f..884db8d41 100644 --- a/interface/Play.py +++ b/interface/Play.py @@ -1,6 +1,5 @@ """Interface for AlphaGo self-play""" -from AlphaGo.go_root import RootState -from AlphaGo.go import PASS +from AlphaGo.go import PASS, WHITE, GameState class play_match(object): """Interface to handle play between two players.""" @@ -9,8 +8,7 @@ def __init__(self, player1, player2, save_dir=None, size=19): # super(ClassName, self).__init__() self.player1 = player1 self.player2 = player2 - self.root = RootState(size=size) - self.state = self.root.get_root_game_state() + self.state = GameState(size=size) # I Propose that GameState should take a top-level save directory, # then automatically generate the specific file name @@ -21,7 +19,7 @@ def _play(self, player): # self.state.write_to_disk() if len(self.state.get_history()) > 1: if self.state.get_history()[-1] is PASS and self.state.get_history()[-2] is PASS \ - and self.state.current_player == -1: + and self.state.get_current_player() == WHITE: end_of_game = True else: end_of_game = False diff --git a/interface/gtp_wrapper.py b/interface/gtp_wrapper.py index 2688920f6..791322462 100644 --- a/interface/gtp_wrapper.py +++ b/interface/gtp_wrapper.py @@ -2,7 +2,7 @@ import gtp from AlphaGo import go import multiprocessing -from AlphaGo.go_root import RootState +from AlphaGo.go import GameState from AlphaGo.util import save_gamestate_to_sgf @@ -86,13 +86,12 @@ class GTPGameConnector(object): """ def __init__(self, player): - self._root = RootState()#enforce_superko=True - self._state = self._root.get_root_game_state() + self._state = GameState()#enforce_superko=True self._player = player self._komi = 0 def clear(self): - self._state = self._root.get_root_game_state() + self._state = GameState() def make_move(self, color, vertex): # vertex in GTP language is 1-indexed, whereas GameState's are zero-indexed @@ -107,8 +106,7 @@ def make_move(self, color, vertex): return False def set_size(self, n): - self._root = RootState(size=n)#enforce_superko=True - self._state = self._root.get_root_game_state() + self._state = GameState(size=n)#enforce_superko=True def set_komi(self, k): self._komi = k diff --git a/requirements.txt b/requirements.txt index f08033af6..492f805ef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Cython==0.24 +Cython==0.25.2 h5py==2.6.0 Keras==1.2.0 numpy==1.11.2 diff --git a/setup.py b/setup.py index 817fbd62a..11046ae99 100644 --- a/setup.py +++ b/setup.py @@ -7,13 +7,24 @@ name = 'RocAlphaGo', # list with files to be cythonized - ext_modules = cythonize( [ "AlphaGo/go.pyx", "AlphaGo/go_root.pyx", "AlphaGo/go_data.pyx", "AlphaGo/preprocessing/preprocessing.pyx" ] ), + ext_modules = cythonize( [ "AlphaGo/go.pyx", "AlphaGo/go_data.pyx", "AlphaGo/preprocessing/preprocessing.pyx" ] ), # include numpy include_dirs=[numpy.get_include(), os.path.join(numpy.get_include(), 'numpy')] ) -# run setup with command -# python setup.py build_ext --inplace +""" + install all necessary dependencies using: + pip install -r requirements.txt -# be aware cython uses a depricaped version of numpy this results in a lot of warnings \ No newline at end of file + run setup with command: + python setup.py build_ext --inplace + + be aware cython uses a depricaped version of numpy this results in a lot of warnings + + you can run all unittests to verify everything works as it should: + python -m unittest discover + + nb. right now one test will fail: Super-ko + +""" \ No newline at end of file diff --git a/tests/parseboard.py b/tests/parseboard.py index 5a1659462..b575b3c75 100644 --- a/tests/parseboard.py +++ b/tests/parseboard.py @@ -1,7 +1,7 @@ -from AlphaGo.go import BLACK, WHITE +from AlphaGo.go import BLACK, WHITE, GameState -def parse(state, boardstr): +def parse(boardstr): '''Parses a board into a gamestate, and returns the location of any moves marked with anything other than 'B', 'X', '#', 'W', 'O', or '.' @@ -9,8 +9,9 @@ def parse(state, boardstr): ''' - boardstr = boardstr.replace(' ', '') - # board_size = max(boardstr.index('|'), boardstr.count('|')) + boardstr = boardstr.replace(' ', '') + board_size = max(boardstr.index('|'), boardstr.count('|')) + state = GameState( size = board_size ) moves = {} @@ -27,4 +28,4 @@ def parse(state, boardstr): assert c not in moves, "{} already used as a move marker".format(c) moves[c] = (row, col) - return moves + return state, moves diff --git a/tests/test_game_converter.py b/tests/test_game_converter.py index 5ea44d35e..51533277b 100644 --- a/tests/test_game_converter.py +++ b/tests/test_game_converter.py @@ -1,17 +1,14 @@ import os import unittest -from AlphaGo.go_root import RootState from AlphaGo.util import sgf_to_gamestate from AlphaGo.preprocessing.game_converter import run_game_converter class TestSGFLoading(unittest.TestCase): def test_ab_aw(self): - - rootState = RootState(size=19) - + with open('tests/test_data/sgf_with_handicap/ab_aw.sgf', 'r') as f: - sgf_to_gamestate(rootState, f.read()) + sgf_to_gamestate(f.read()) class TestCmdlineConverter(unittest.TestCase): diff --git a/tests/test_gamestate.py b/tests/test_gamestate.py index b2d7a66e9..8552b5fc0 100644 --- a/tests/test_gamestate.py +++ b/tests/test_gamestate.py @@ -1,15 +1,14 @@ import unittest import numpy as np import AlphaGo.go as go -from AlphaGo.go_root import RootState +from AlphaGo.go import GameState class TestKo(unittest.TestCase): def test_standard_ko(self): - rootState = RootState(size=9) - gs = rootState.get_root_game_state() + gs = GameState( size = 9 ) gs.do_move((1, 0)) # B gs.do_move((2, 0)) # W @@ -33,8 +32,7 @@ def test_standard_ko(self): def test_snapback_is_not_ko(self): - rootState = RootState(size=5) - gs = rootState.get_root_game_state() + gs = GameState( size = 5 ) # B o W B . # W W B . . @@ -65,8 +63,7 @@ def test_snapback_is_not_ko(self): def test_positional_superko(self): - rootState = RootState(size=9) - gs = rootState.get_root_game_state() + gs = GameState( size = 9 ) move_list = [(0, 3), (0, 4), (1, 3), (1, 4), (2, 3), (2, 4), (2, 2), (3, 4), (2, 1), (3, 3), (3, 1), (3, 2), (3, 0), (4, 2), (1, 1), (4, 1), (8, 0), (4, 0), (8, 1), (0, 2), @@ -77,7 +74,7 @@ def test_positional_superko(self): self.assertTrue(gs.is_legal((1, 0))) # gs = GameState(size=9, enforce_superko=True) super ko is not handled yet - gs = rootState.get_root_game_state() + gs = GameState( size = 9 ) for move in move_list: gs.do_move(move) self.assertFalse(gs.is_legal((1, 0))) @@ -87,8 +84,7 @@ class TestEye(unittest.TestCase): def test_true_eye(self): - rootState = RootState(size=7) - gs = rootState.get_root_game_state() + gs = GameState( size = 7 ) gs.do_move((1, 0), go.BLACK) gs.do_move((0, 1), go.BLACK) @@ -110,9 +106,8 @@ def test_eye_recursion(self): # a checkerboard pattern of black is 'technically' all true eyes # mutually supporting each other - rootState = RootState(size=7) + gs = GameState( size = 7 ) - gs = rootState.get_root_game_state() for x in range(gs.get_size()): for y in range(gs.get_size()): if (x + y) % 2 == 1: @@ -126,11 +121,9 @@ def test_liberties_after_capture(self): # creates 3x3 black group in the middle, that is then all captured # ...then an assertion is made that the resulting liberties after # capture are the same as if the group had never been there - - rootState = RootState(size=7) - - gs_capture = rootState.get_root_game_state() - gs_reference = rootState.get_root_game_state() + + gs_capture = GameState( size = 7 ) + gs_reference = GameState( size = 7 ) # add in 3x3 black stones for x in range(2, 5): for y in range(2, 5): diff --git a/tests/test_ladders.py b/tests/test_ladders.py index da498aaa8..3d552d12b 100644 --- a/tests/test_ladders.py +++ b/tests/test_ladders.py @@ -1,16 +1,12 @@ import unittest import parseboard from AlphaGo.go import BLACK, WHITE -from AlphaGo.go_root import RootState class TestLadder(unittest.TestCase): def test_captured_1(self): - - rootState = RootState(size=7) - st = rootState.get_root_game_state() - - moves = parseboard.parse(st, "d b c . . . .|" + + st, moves = parseboard.parse("d b c . . . .|" "B W a . . . .|" ". B . . . . .|" ". . . . . . .|" @@ -33,10 +29,7 @@ def test_captured_1(self): def test_breaker_1(self): - rootState = RootState(size=7) - st = rootState.get_root_game_state() - - moves = parseboard.parse(st, ". B . . . . .|" + st, moves = parseboard.parse(". B . . . . .|" "B W a . . W .|" "B b . . . . .|" ". c . . . . .|" @@ -59,10 +52,7 @@ def test_breaker_1(self): def test_missing_ladder_breaker_1(self): - rootState = RootState(size=7) - st = rootState.get_root_game_state() - - moves = parseboard.parse(st, ". B . . . . .|" + st, moves = parseboard.parse(". B . . . . .|" "B W B . . W .|" "B a c . . . .|" ". b . . . . .|" @@ -82,10 +72,7 @@ def test_missing_ladder_breaker_1(self): def test_capture_to_escape_1(self): - rootState = RootState(size=7) - st = rootState.get_root_game_state() - - moves = parseboard.parse(st, ". O X . . .|" + st, moves = parseboard.parse(". O X . . .|" ". X O X . .|" ". . O X . .|" ". . a . . .|" @@ -98,10 +85,7 @@ def test_capture_to_escape_1(self): def test_throw_in_1(self): - rootState = RootState(size=7) - st = rootState.get_root_game_state() - - moves = parseboard.parse(st, "X a O X . .|" + st, moves = parseboard.parse("X a O X . .|" "b O O X . .|" "O O X X . .|" "X X . . . .|" @@ -119,10 +103,7 @@ def test_throw_in_1(self): def test_snapback_1(self): - rootState = RootState(size=9) - st = rootState.get_root_game_state() - - moves = parseboard.parse(st, ". . . . . . . . .|" + st, moves = parseboard.parse(". . . . . . . . .|" ". . . . . . . . .|" ". . X X X . . . .|" ". . O . . . . . .|" @@ -140,10 +121,7 @@ def test_snapback_1(self): def test_two_captures(self): - rootState = RootState(size=7) - st = rootState.get_root_game_state() - - moves = parseboard.parse(st, ". . . . . .|" + st, moves = parseboard.parse(". . . . . .|" ". . . . . .|" ". . a b . .|" ". X O O X .|" @@ -157,10 +135,7 @@ def test_two_captures(self): def test_two_escapes(self): - rootState = RootState(size=7) - st = rootState.get_root_game_state() - - moves = parseboard.parse(st, ". . X . . .|" + st, moves = parseboard.parse(". . X . . .|" ". X O a . .|" ". X c X . .|" ". O X b . .|" diff --git a/tests/test_mcts.py b/tests/test_mcts.py index 4fddc1979..e5d34592c 100644 --- a/tests/test_mcts.py +++ b/tests/test_mcts.py @@ -1,15 +1,14 @@ import unittest import numpy as np from operator import itemgetter -from AlphaGo.go_root import RootState +from AlphaGo.go import GameState from AlphaGo.mcts import MCTS, TreeNode class TestTreeNode(unittest.TestCase): def setUp(self): - self.root = RootState() - self.gs = self.root.get_root_game_state() + self.gs = GameState() self.node = TreeNode(None, 1.0) def test_selection(self): @@ -56,8 +55,7 @@ def test_update_recursive(self): class TestMCTS(unittest.TestCase): def setUp(self): - self.root = RootState() - self.gs = self.root.get_root_game_state() + self.gs = GameState() self.mcts = MCTS(dummy_value, dummy_policy, dummy_rollout, n_playout=2) def _count_expansions(self): diff --git a/tests/test_policy.py b/tests/test_policy.py index 6888e2fe1..edbaf788c 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -2,7 +2,7 @@ import unittest import numpy as np from AlphaGo import go -from AlphaGo.go_root import RootState +from AlphaGo.go import GameState from AlphaGo.models.policy import CNNPolicy, ResnetPolicy from AlphaGo.ai import GreedyPolicyPlayer, ProbabilisticPolicyPlayer @@ -10,35 +10,26 @@ class TestCNNPolicy(unittest.TestCase): def test_default_policy(self): - - rootState = RootState(size=19) - state = rootState.get_root_game_state() - + policy = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"]) - policy.eval_state(state) + policy.eval_state(GameState()) # just hope nothing breaks def test_batch_eval_state(self): - - rootState = RootState(size=19) - + policy = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"]) - results = policy.batch_eval_state([rootState.get_root_game_state(), rootState.get_root_game_state()]) + results = policy.batch_eval_state([GameState(), GameState()]) self.assertEqual(len(results), 2) # one result per GameState self.assertEqual(len(results[0]), 361) # each one has 361 (move,prob) pairs def test_output_size(self): - - rootState = RootState(size=19) - + policy19 = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"], board=19) - output = policy19.forward(policy19.preprocessor.state_to_tensor(rootState.get_root_game_state())) + output = policy19.forward(policy19.preprocessor.state_to_tensor(GameState())) self.assertEqual(output.shape, (1, 19 * 19)) - rootState = RootState(size=13) - policy13 = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"], board=13) - output = policy13.forward(policy13.preprocessor.state_to_tensor(rootState.get_root_game_state())) + output = policy13.forward(policy13.preprocessor.state_to_tensor(GameState( size = 13 ))) self.assertEqual(output.shape, (1, 13 * 13)) def test_save_load(self): @@ -72,19 +63,15 @@ def test_save_load(self): class TestResnetPolicy(unittest.TestCase): def test_default_policy(self): - - rootState = RootState(size=19) - + policy = ResnetPolicy(["board", "liberties", "sensibleness", "capture_size"]) - policy.eval_state(rootState.get_root_game_state()) + policy.eval_state(GameState()) # just hope nothing breaks def test_batch_eval_state(self): - - rootState = RootState(size=19) - + policy = ResnetPolicy(["board", "liberties", "sensibleness", "capture_size"]) - results = policy.batch_eval_state([rootState.get_root_game_state(), rootState.get_root_game_state()]) + results = policy.batch_eval_state([GameState(), GameState()]) self.assertEqual(len(results), 2) # one result per GameState self.assertEqual(len(results[0]), 361) # each one has 361 (move,prob) pairs @@ -126,10 +113,8 @@ def test_save_load(self): class TestPlayers(unittest.TestCase): def test_greedy_player(self): - - rootState = RootState(size=19) - - gs = rootState.get_root_game_state() + + gs = GameState() policy = CNNPolicy(["board", "ones", "turns_since"]) player = GreedyPolicyPlayer(policy) for _ in range(20): @@ -138,10 +123,8 @@ def test_greedy_player(self): gs.do_move(move) def test_probabilistic_player(self): - - rootState = RootState(size=19) - - gs = rootState.get_root_game_state() + + gs = GameState() policy = CNNPolicy(["board", "ones", "turns_since"]) player = ProbabilisticPolicyPlayer(policy) for _ in range(20): @@ -150,10 +133,8 @@ def test_probabilistic_player(self): gs.do_move(move) def test_sensible_probabilistic(self): - - rootState = RootState(size=19) - - gs = rootState.get_root_game_state() + + gs = GameState() policy = CNNPolicy(["board", "ones", "turns_since"]) player = ProbabilisticPolicyPlayer(policy) empty = (10, 10) @@ -166,9 +147,7 @@ def test_sensible_probabilistic(self): def test_sensible_greedy(self): - rootState = RootState(size=19) - - gs = rootState.get_root_game_state() + gs = GameState() policy = CNNPolicy(["board", "ones", "turns_since"]) player = GreedyPolicyPlayer(policy) empty = (10, 10) diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index 3a6790eec..582ceda61 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -2,11 +2,17 @@ import parseboard import numpy as np import AlphaGo.go as go -from AlphaGo.go_root import RootState +from AlphaGo.go import GameState from AlphaGo.preprocessing.preprocessing import Preprocess -def simple_board(gs): +def simple_board(): + """ + + """ + + gs = GameState( size = 7 ) + # make a tiny board for the sake of testing and hand-coding expected results # # X @@ -37,9 +43,17 @@ def simple_board(gs): gs.do_move((5, 3)) # B gs.do_move((4, 3)) # W - the ko position gs.do_move((4, 4)) # B - does the capture + + return gs -def self_atari_board(gs): +def self_atari_board(): + """ + + """ + + gs = GameState( size = 7 ) + # another tiny board for testing self-atari specifically. # positions marked with 'a' are self-atari for black # @@ -69,8 +83,15 @@ def self_atari_board(gs): gs.do_move((3, 5), go.WHITE) gs.do_move((4, 5), go.WHITE) + return gs -def capture_board(gs): +def capture_board(): + """ + + """ + + gs = GameState( size = 7 ) + # another small board, this one with imminent captures # # X @@ -93,6 +114,8 @@ def capture_board(gs): for W in white: gs.do_move(W, go.WHITE) gs.set_current_player( go.BLACK ) + + return gs class TestPreprocessingFeatures(unittest.TestCase): @@ -105,11 +128,8 @@ class TestPreprocessingFeatures(unittest.TestCase): """ def test_get_board(self): - - rootState = RootState(size=7) - gs = rootState.get_root_game_state() - - simple_board(gs) + + gs = simple_board() pp = Preprocess(["board"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) @@ -138,11 +158,11 @@ def test_get_board(self): self.assertTrue(np.all(feature == np.dstack((white_pos, black_pos, empty_pos)))) def test_get_turns_since(self): - - rootState = RootState(size=7) - gs = rootState.get_root_game_state() - - simple_board(gs) + """ + + """ + + gs = simple_board() pp = Preprocess(["turns_since"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) @@ -163,11 +183,11 @@ def test_get_turns_since(self): self.assertTrue(np.all(feature == one_hot_turns)) def test_get_liberties(self): - - rootState = RootState(size=7) - gs = rootState.get_root_game_state() - - simple_board(gs) + """ + + """ + + gs = simple_board() pp = Preprocess(["liberties"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) @@ -200,11 +220,11 @@ def test_get_liberties(self): "bad expectation: stones with %d liberties" % (i + 1)) def test_get_capture_size(self): - - rootState = RootState(size=7) - gs = rootState.get_root_game_state() - - capture_board(gs) + """ + + """ + + gs = capture_board() pp = Preprocess(["capture_size"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) @@ -223,11 +243,11 @@ def test_get_capture_size(self): "bad expectation: capturing %d stones" % i) def test_get_self_atari_size(self): - - rootState = RootState(size=7) - gs = rootState.get_root_game_state() - - self_atari_board(gs) + """ + + """ + + gs = self_atari_board() pp = Preprocess(["self_atari_size"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) @@ -240,11 +260,11 @@ def test_get_self_atari_size(self): self.assertTrue(np.all(feature == one_hot_self_atari)) def test_get_self_atari_size_cap(self): - - rootState = RootState(size=7) - gs = rootState.get_root_game_state() - - capture_board(gs) + """ + + """ + + gs = capture_board() pp = Preprocess(["self_atari_size"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) @@ -258,11 +278,11 @@ def test_get_self_atari_size_cap(self): self.assertTrue(np.all(feature == one_hot_self_atari)) def test_get_liberties_after(self): - - rootState = RootState(size=7) - gs = rootState.get_root_game_state() - - simple_board(gs) + """ + + """ + + gs = simple_board() pp = Preprocess(["liberties_after"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) @@ -287,13 +307,11 @@ def test_get_liberties_after(self): "bad expectation: stones with %d liberties after move" % (i + 1)) def test_get_liberties_after_cap(self): - """A copy of test_get_liberties_after but where captures are imminent """ - - rootState = RootState(size=7) - gs = rootState.get_root_game_state() - - capture_board(gs) + A copy of test_get_liberties_after but where captures are imminent + """ + + gs = capture_board() pp = Preprocess(["liberties_after"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) @@ -314,11 +332,11 @@ def test_get_liberties_after_cap(self): "bad expectation: stones with %d liberties after move" % (i + 1)) def test_get_ladder_capture(self): - - rootState = RootState(size=7) - gs = rootState.get_root_game_state() - - moves = parseboard.parse(gs, ". . . . . . .|" + """ + + """ + + gs, moves = parseboard.parse(". . . . . . .|" "B W a . . . .|" ". B . . . . .|" ". . . . . . .|" @@ -333,12 +351,12 @@ def test_get_ladder_capture(self): self.assertTrue(np.all(expectation == feature)) def test_get_ladder_escape(self): - - rootState = RootState(size=7) - gs = rootState.get_root_game_state() + """ + + """ # On this board, playing at 'a' is ladder escape because there is a breaker on the right. - moves = parseboard.parse(gs, ". B B . . . .|" + gs, moves = parseboard.parse(". B B . . . .|" "B W a . . . .|" ". B . . . . .|" ". . . . . W .|" @@ -354,11 +372,11 @@ def test_get_ladder_escape(self): self.assertTrue(np.all(expectation == feature)) def test_two_escapes(self): + """ + + """ - rootState = RootState(size=7) - gs = rootState.get_root_game_state() - - moves = parseboard.parse(gs, ". . X . . .|" + gs, moves = parseboard.parse(". . X . . .|" ". X O a . .|" ". X c X . .|" ". O X b . .|" @@ -369,7 +387,7 @@ def test_two_escapes(self): gs.do_move(moves['c'], color=go.WHITE) gs.set_current_player( go.WHITE ) - pp = Preprocess(["ladder_escape"], size=7) + pp = Preprocess(["ladder_escape"], size=6) gs.set_current_player( go.WHITE ) feature = pp.state_to_tensor(gs)[0, 0] # 1D tensor; no need to transpose @@ -383,13 +401,13 @@ def test_two_escapes(self): def test_get_sensibleness(self): - - rootState = RootState(size=7) - gs = rootState.get_root_game_state() + """ + + """ # TODO - there are no legal eyes at the moment - simple_board(gs) + gs = simple_board() pp = Preprocess(["sensibleness"], size=7) feature = pp.state_to_tensor(gs)[0, 0] # 1D tensor; no need to transpose @@ -400,11 +418,11 @@ def test_get_sensibleness(self): self.assertTrue(np.all(expectation == feature)) def test_get_legal(self): + """ + + """ - rootState = RootState(size=7) - gs = rootState.get_root_game_state() - - simple_board(gs) + gs = simple_board() pp = Preprocess(["legal"], size=7) feature = pp.state_to_tensor(gs)[0, 0] # 1D tensor; no need to transpose @@ -414,11 +432,11 @@ def test_get_legal(self): self.assertTrue(np.all(expectation == feature)) def test_feature_concatenation(self): + """ + + """ - rootState = RootState(size=7) - gs = rootState.get_root_game_state() - - simple_board(gs) + gs = simple_board() pp = Preprocess(["board", "sensibleness", "capture_size"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) diff --git a/tests/test_reinforcement_policy_trainer.py b/tests/test_reinforcement_policy_trainer.py index 321ecd73d..0755c645d 100644 --- a/tests/test_reinforcement_policy_trainer.py +++ b/tests/test_reinforcement_policy_trainer.py @@ -4,7 +4,6 @@ import AlphaGo.go as go import numpy.testing as npt from keras.optimizers import SGD -from AlphaGo.go_root import RootState from AlphaGo.util import sgf_iter_states from AlphaGo.models.policy import CNNPolicy from AlphaGo.training.reinforcement_policy_trainer import run_training, log_loss, run_n_games @@ -24,9 +23,7 @@ def _list_mock_games(path): def get_sgf_move_probs(sgf_game, policy, player): - - root = RootState() - + with open(sgf_game, "r") as f: sgf_game = f.read() @@ -37,18 +34,16 @@ def get_single_prob(move, move_probs): return 0 return [(move, get_single_prob(move, policy.eval_state(state))) - for (state, move, pl) in sgf_iter_states(root, sgf_game) if pl == player] + for (state, move, pl) in sgf_iter_states(sgf_game) if pl == player] class MockPlayer(object): def __init__(self, policy, sgf_game): - - self.root = RootState() - + with open(sgf_game, "r") as f: sgf_game = f.read() - self.moves = [move for (_, move, _) in sgf_iter_states(self.root, sgf_game)] + self.moves = [move for (_, move, _) in sgf_iter_states(sgf_game)] self.policy = policy def get_moves(self, states): @@ -62,22 +57,15 @@ def __init__(self, predetermined_winner, length, *args, **kwargs): super(MockState, self).__init__(*args, **kwargs) self.predetermined_winner = predetermined_winner self.length = length - self.count = 0 - self.should_end = False - - def do_move(self, *args, **kwargs): - super(MockState, self).do_move(*args, **kwargs) - self.count += 1 - if self.count > self.length: - self.is_end_of_game = True - def get_winner(self): return self.predetermined_winner - + def is_end_of_game(self): - return self.should_end - + if len(self.get_history()) > self.length: + return True + return False + class TestReinforcementPolicyTrainer(unittest.TestCase): @@ -96,9 +84,7 @@ def testTrain(self): def testGradientDirectionChangesWithGameResult(self): def run_and_get_new_weights(init_weights, winners, game): - - root = RootState() - + # Create "mock" states that end after 2 moves with a predetermined winner. states = [MockState(winner, 2, size=19) for winner in winners] @@ -112,14 +98,12 @@ def run_and_get_new_weights(init_weights, winners, game): opponent = MockPlayer(policy2, game) # Run RL training - run_n_games(optimizer, root, learner, opponent, 2, mock_states=states) + run_n_games(optimizer, learner, opponent, 2, mock_states=states) return policy1.model.get_weights() def test_game_gradient(game): - - root = RootState() - + policy = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel_policy.json')) initial_parameters = policy.model.get_weights() # Cases 1 and 2 have identical starting models and identical (state, action) pairs, @@ -148,8 +132,6 @@ def test_game_gradient(game): def testRunNGamesUpdatesWeights(self): def test_game_run_N(game): - root = RootState() - policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel_policy.json')) policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel_policy.json')) learner = MockPlayer(policy1, game) @@ -159,7 +141,7 @@ def test_game_run_N(game): policy1.model.compile(loss=log_loss, optimizer=optimizer) # Run RL training - run_n_games(optimizer, root, learner, opponent, 2) + run_n_games(optimizer, learner, opponent, 2) # Get new weights for comparison trained_weights = policy1.model.get_weights() @@ -175,8 +157,6 @@ def test_game_run_N(game): def testWinIncreasesMoveProbability(self): def test_game_increase(game): - root = RootState() - # Create "mock" state that ends after 20 moves with the learner winnning win_state = [MockState(go.BLACK, 20, size=19)] policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel_policy.json')) @@ -191,7 +171,7 @@ def test_game_increase(game): init_probs = [prob for (mv, prob) in init_move_probs] # Run RL training - run_n_games(optimizer, root, learner, opponent, 1, mock_states=win_state) + run_n_games(optimizer, learner, opponent, 1, mock_states=win_state) # Get new move probabilities for black's moves having finished 1 round of training new_move_probs = get_sgf_move_probs(game, policy1, go.BLACK) @@ -205,9 +185,7 @@ def test_game_increase(game): def testLoseDecreasesMoveProbability(self): def test_game_decrease(game): - - root = RootState() - + # Create "mock" state that ends after 20 moves with the learner losing lose_state = [MockState(go.WHITE, 20, size=19)] policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel_policy.json')) @@ -222,7 +200,7 @@ def test_game_decrease(game): init_probs = [prob for (mv, prob) in init_move_probs] # Run RL training - run_n_games(optimizer, root, learner, opponent, 1, mock_states=lose_state) + run_n_games(optimizer, learner, opponent, 1, mock_states=lose_state) # Get new move probabilities for black's moves having finished 1 round of training new_move_probs = get_sgf_move_probs(game, policy1, go.BLACK) diff --git a/tests/test_reinforcement_value_trainer.py b/tests/test_reinforcement_value_trainer.py index 00762504e..540c2aa26 100644 --- a/tests/test_reinforcement_value_trainer.py +++ b/tests/test_reinforcement_value_trainer.py @@ -1,7 +1,7 @@ import os import unittest import numpy as np -from AlphaGo.go_root import RootState +from AlphaGo.go import GameState from AlphaGo.models.value import CNNValue from AlphaGo.training.reinforcement_value_trainer import FILE_TEST from AlphaGo.training.reinforcement_value_trainer import FILE_TRAIN @@ -29,8 +29,7 @@ def test_save_load(self): # test shape def test_ouput_shape(self): - rootState = RootState(size=19) - gs = rootState.get_root_game_state() + gs = GameState() val = self.value.eval_state(gs) self.assertTrue(isinstance(val, np.float64)) diff --git a/tests/test_value.py b/tests/test_value.py index 5ec81acb7..72ff1f641 100644 --- a/tests/test_value.py +++ b/tests/test_value.py @@ -3,7 +3,7 @@ import numpy as np from AlphaGo import go from AlphaGo.ai import ValuePlayer -from AlphaGo.go_root import RootState +from AlphaGo.go import GameState from AlphaGo.models.value import CNNValue @@ -11,34 +11,29 @@ class TestCNNValue(unittest.TestCase): def test_default_value(self): - rootState = RootState(size=19) - state = rootState.get_root_game_state() + state = GameState() value = CNNValue(["board", "liberties", "sensibleness", "capture_size"]) value.eval_state(state) # just hope nothing breaks def test_batch_eval_state(self): - - rootState = RootState(size=19) - + value = CNNValue(["board", "liberties", "sensibleness", "capture_size"]) - results = value.batch_eval_state([rootState.get_root_game_state(), rootState.get_root_game_state()]) + results = value.batch_eval_state([GameState(), GameState()]) self.assertEqual(len(results), 2) # one result per GameState self.assertTrue(isinstance(results[0], np.float64)) self.assertTrue(isinstance(results[1], np.float64)) def test_output_size(self): - rootState = RootState(size=19) - state = rootState.get_root_game_state() + state = GameState() value19 = CNNValue(["board", "liberties", "sensibleness", "capture_size"], board=19) output = value19.forward(value19.preprocessor.state_to_tensor(state)) self.assertEqual(output.shape, (1, 1)) - rootState = RootState(size=13) - state = rootState.get_root_game_state() + state = GameState( size=13 ) value13 = CNNValue(["board", "liberties", "sensibleness", "capture_size"], board=13) output = value13.forward(value13.preprocessor.state_to_tensor(state)) @@ -76,8 +71,7 @@ class TestValuePlayers(unittest.TestCase): def test_greedy_player(self): - rootState = RootState(size=9) - gs = rootState.get_root_game_state() + gs = GameState( size = 9 ) value = CNNValue(["board", "ones", "turns_since"], board=9) player = ValuePlayer(value, greedy_start=0) @@ -88,8 +82,7 @@ def test_greedy_player(self): def test_probabilistic_player(self): - rootState = RootState(size=9) - gs = rootState.get_root_game_state() + gs = GameState( size = 9 ) value = CNNValue(["board", "ones", "turns_since"], board=9) player = ValuePlayer(value) @@ -100,8 +93,7 @@ def test_probabilistic_player(self): def test_sensible_probabilistic(self): - rootState = RootState(size=19) - gs = rootState.get_root_game_state() + gs = GameState() value = CNNValue(["board", "ones", "turns_since"]) player = ValuePlayer(value) @@ -115,8 +107,7 @@ def test_sensible_probabilistic(self): def test_sensible_greedy(self): - rootState = RootState(size=19) - gs = rootState.get_root_game_state() + gs = GameState() value = CNNValue(["board", "ones", "turns_since"]) player = ValuePlayer(value, greedy_start=0) From 33ba41a8c875bbc7f0c88fe07565183c7ad69c3b Mon Sep 17 00:00:00 2001 From: MaMiFreak Date: Mon, 22 May 2017 12:42:12 +0200 Subject: [PATCH 156/191] travis compatibility with cython changed cython 0.24 to 0.25.2 added build command --- .travis.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 8b76f93c1..14adc7f99 100644 --- a/.travis.yml +++ b/.travis.yml @@ -31,10 +31,13 @@ install: - sudo ln -s /run/shm /dev/shm # install requirements - - conda install --yes Cython=0.24 h5py=2.6.0 numpy=1.11.2 scipy=0.18.1 PyYAML=3.12 matplotlib pandas pytest + - conda install --yes Cython=0.25.2 h5py=2.6.0 numpy=1.11.2 scipy=0.18.1 PyYAML=3.12 matplotlib pandas pytest - pip install --user --no-deps Theano==0.8.2 sgf==0.5 keras==1.2.0 pygtp==0.3 - pip install --user flake8 + # compile cython code + - python setup.py build_ext --inplace + # install TensorFlow if needed # tensorflow does not have a python3.3 wheel - if [[ "$KERAS_BACKEND" == "tensorflow" ]]; then From 66231bcb578286a6e5fe3f3fe227c8d54e433336 Mon Sep 17 00:00:00 2001 From: wrongu Date: Mon, 29 May 2017 11:25:46 -0400 Subject: [PATCH 157/191] better 'restart' in reinforcement_policy_trainer --- AlphaGo/training/reinforcement_policy_trainer.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 74bd62005..186d26ce9 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -1,5 +1,6 @@ import os import json +import re import numpy as np from shutil import copyfile from keras.optimizers import SGD @@ -133,9 +134,12 @@ def run_training(cmd_line_args=None): print("copied {} to {}".format(args.initial_weights, os.path.join(args.out_directory, ZEROTH_FILE))) player_weights = ZEROTH_FILE + iter_start = 1 else: # if resuming, we expect initial_weights to be just a # "weights.#####.hdf5" file, not a full path + if not re.match(r"weights\.\d{5}\.hdf5", args.initial_weights): + raise ValueError("Expected to resume from weights file with name 'weights.#####.hdf5'") args.initial_weights = os.path.join(args.out_directory, os.path.basename(args.initial_weights)) if not os.path.exists(args.initial_weights): @@ -143,6 +147,7 @@ def run_training(cmd_line_args=None): elif args.verbose: print("Resuming with weights {}".format(args.initial_weights)) player_weights = os.path.basename(args.initial_weights) + iter_start = 1 + int(player_weights[8:13]) # Set initial conditions policy = CNNPolicy.load_model(args.model_json) @@ -184,7 +189,7 @@ def save_metadata(): optimizer = SGD(lr=args.learning_rate) player.policy.model.compile(loss=log_loss, optimizer=optimizer) - for i_iter in range(1, args.iterations + 1): + for i_iter in range(iter_start, args.iterations + 1): # Randomly choose opponent from pool (possibly self), and playing # game_batch games against them. opp_weights = np.random.choice(metadata["opponents"]) From 3253d7a00d8179a4fab343080f74d9e36591d8b7 Mon Sep 17 00:00:00 2001 From: wrongu Date: Mon, 29 May 2017 11:34:57 -0400 Subject: [PATCH 158/191] --record-every arg to RL training to save disk space for intermediate models --- AlphaGo/training/reinforcement_policy_trainer.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 186d26ce9..6723b69f9 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -104,6 +104,7 @@ def run_training(cmd_line_args=None): parser.add_argument("--learning-rate", help="Keras learning rate (Default: 0.001)", type=float, default=0.001) # noqa: E501 parser.add_argument("--policy-temp", help="Distribution temperature of players using policies (Default: 0.67)", type=float, default=0.67) # noqa: E501 parser.add_argument("--save-every", help="Save policy as a new opponent every n batches (Default: 500)", type=int, default=500) # noqa: E501 + parser.add_argument("--record-every", help="Save learner's weights every n batches (Default: 1)", type=int, default=1) # noqa: E501 parser.add_argument("--game-batch", help="Number of games per mini-batch (Default: 20)", type=int, default=20) # noqa: E501 parser.add_argument("--move-limit", help="Maximum number of moves per game", type=int, default=500) # noqa: E501 parser.add_argument("--iterations", help="Number of training batches/iterations (Default: 10000)", type=int, default=10000) # noqa: E501 @@ -205,9 +206,10 @@ def save_metadata(): win_ratio = run_n_games(optimizer, player, opponent, args.game_batch) metadata["win_ratio"][player_weights] = (opp_weights, win_ratio) - # Save all intermediate models. - player_weights = "weights.%05d.hdf5" % i_iter - player.policy.model.save_weights(os.path.join(args.out_directory, player_weights)) + # Save intermediate models. + if i_iter % args.record_every == 0: + player_weights = "weights.%05d.hdf5" % i_iter + player.policy.model.save_weights(os.path.join(args.out_directory, player_weights)) # Add player to batch of oppenents once in a while. if i_iter % args.save_every == 0: From dcaf960b5df4b35e547e2342ed95c7aa8e51b4e6 Mon Sep 17 00:00:00 2001 From: wrongu Date: Wed, 31 May 2017 14:20:50 -0400 Subject: [PATCH 159/191] Fixed RL metadata: player weights updated each iteration --- AlphaGo/training/reinforcement_policy_trainer.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 6723b69f9..924f9ace2 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -191,6 +191,11 @@ def save_metadata(): optimizer = SGD(lr=args.learning_rate) player.policy.model.compile(loss=log_loss, optimizer=optimizer) for i_iter in range(iter_start, args.iterations + 1): + # Note that player_weights will only be saved as a file every args.record_every iterations. + # Regardless, player_weights enters into the metadata to keep track of the win ratio over + # time. + player_weights = "weights.%05d.hdf5" % i_iter + # Randomly choose opponent from pool (possibly self), and playing # game_batch games against them. opp_weights = np.random.choice(metadata["opponents"]) @@ -208,7 +213,6 @@ def save_metadata(): # Save intermediate models. if i_iter % args.record_every == 0: - player_weights = "weights.%05d.hdf5" % i_iter player.policy.model.save_weights(os.path.join(args.out_directory, player_weights)) # Add player to batch of oppenents once in a while. From 00003dcf167c7524533eca8fa42c3a9318d79fa4 Mon Sep 17 00:00:00 2001 From: MaMiFreak Date: Sun, 18 Jun 2017 22:03:30 +0100 Subject: [PATCH 160/191] Style update and keras 2 coding style update bugfixes keras 2 update --- .travis.yml | 14 +- AlphaGo/go.pxd | 95 +- AlphaGo/go.pyx | 1237 +++++++++-------- AlphaGo/go_data.pxd | 82 +- AlphaGo/go_data.pyx | 296 ++-- AlphaGo/models/policy.py | 149 +- AlphaGo/models/rollout.py | 20 +- AlphaGo/models/value.py | 96 +- AlphaGo/preprocessing/preprocessing.pxd | 55 +- AlphaGo/preprocessing/preprocessing.pyx | 317 +++-- .../training/reinforcement_policy_trainer.py | 15 +- .../training/reinforcement_value_trainer.py | 7 +- AlphaGo/training/supervised_policy_trainer.py | 6 +- AlphaGo/util.py | 18 +- requirements.txt | 2 +- setup.py | 4 +- 16 files changed, 1362 insertions(+), 1051 deletions(-) diff --git a/.travis.yml b/.travis.yml index 14adc7f99..85b42149b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -32,21 +32,23 @@ install: # install requirements - conda install --yes Cython=0.25.2 h5py=2.6.0 numpy=1.11.2 scipy=0.18.1 PyYAML=3.12 matplotlib pandas pytest - - pip install --user --no-deps Theano==0.8.2 sgf==0.5 keras==1.2.0 pygtp==0.3 + - pip install --user --no-deps Theano==0.8.2 sgf==0.5 keras==2.0.4 pygtp==0.3 - pip install --user flake8 - + # compile cython code - python setup.py build_ext --inplace - + # install TensorFlow if needed # tensorflow does not have a python3.3 wheel - if [[ "$KERAS_BACKEND" == "tensorflow" ]]; then if [[ "$TRAVIS_PYTHON_VERSION" == "2.7" ]]; then - pip install https://storage.googleapis.com/tensorflow/linux/cpu/tensorflow-0.11.0-cp27-none-linux_x86_64.whl; + pip install https://storage.googleapis.com/tensorflow/linux/cpu/tensorflow-1.1.0-cp27-none-linux_x86_64.whl; elif [[ "$TRAVIS_PYTHON_VERSION" == "3.4" ]]; then - pip install https://storage.googleapis.com/tensorflow/linux/cpu/tensorflow-0.11.0-cp34-cp34m-linux_x86_64.whl; + pip install https://storage.googleapis.com/tensorflow/linux/cpu/tensorflow-1.1.0-cp34-cp34m-linux_x86_64.whl; elif [[ "$TRAVIS_PYTHON_VERSION" == "3.5" ]]; then - pip install https://storage.googleapis.com/tensorflow/linux/cpu/tensorflow-0.11.0-cp35-cp35m-linux_x86_64.whl; + pip install https://storage.googleapis.com/tensorflow/linux/cpu/tensorflow-1.1.0-cp35-cp35m-linux_x86_64.whl; + elif [[ "$TRAVIS_PYTHON_VERSION" == "3.6" ]]; then + pip install https://storage.googleapis.com/tensorflow/linux/cpu/tensorflow-1.1.0-cp36-cp36m-linux_x86_64.whl; fi fi # run flake8 and unit tests diff --git a/AlphaGo/go.pxd b/AlphaGo/go.pxd index b8319339b..e91beaa51 100644 --- a/AlphaGo/go.pxd +++ b/AlphaGo/go.pxd @@ -16,7 +16,7 @@ cdef class GameState: cdef short board_size # possible ko location - cdef short ko + cdef short ko # list with all groups cdef Groups_List *groups_list @@ -64,12 +64,12 @@ cdef class GameState: # # ############################################################################ - cdef void initialize_new( self, char size ) + cdef void initialize_new(self, char size) """ initialize this state as empty state """ - cdef void initialize_duplicate( self, GameState copyState ) + cdef void initialize_duplicate(self, GameState copyState) """ Initialize all variables as a copy of copy_state """ @@ -80,12 +80,12 @@ cdef class GameState: # # ############################################################################ - cdef bint is_legal_move( self, short location, Group **board, short ko ) + cdef bint is_legal_move(self, short location, Group **board, short ko) """ check if playing at location is a legal move to make """ - cdef bint has_liberty_after( self, short location, Group **board ) + cdef bint has_liberty_after(self, short location, Group **board) """ check if a play at location results in an alive group - has liberty @@ -93,7 +93,7 @@ cdef class GameState: - captures enemy group """ - cdef short calculate_board_location( self, char x, char y ) + cdef short calculate_board_location(self, char x, char y) """ return location on board no checks on outside board @@ -101,35 +101,35 @@ cdef class GameState: y = rows """ - cdef tuple calculate_tuple_location( self, short location ) + cdef tuple calculate_tuple_location(self, short location) """ return location on board as a tupple no checks on outside board """ - cdef void set_moves_legal_list( self, Locations_List *moves_legal ) + cdef void set_moves_legal_list(self, Locations_List *moves_legal) """ generate moves_legal list """ - cdef void combine_groups( self, Group* group_keep, Group* group_remove, Group **board ) + cdef void combine_groups(self, Group* group_keep, Group* group_remove, Group **board) """ - combine group_keep and group_remove and replace group_remove on the board + combine group_keep and group_remove and replace group_remove on the board """ - cdef void remove_group( self, Group* group_remove, Group **board, short* ko ) + cdef void remove_group(self, Group* group_remove, Group **board, short* ko) """ remove group from board -> set all locations to group_empty """ - cdef void update_hashes( self, Group* group ) + cdef void update_hashes(self, Group* group) """ update all locations affected by removal of group """ - cdef void add_to_group( self, short location, Group **board, short* ko, short* count_captures ) + cdef void add_to_group(self, short location, Group **board, short* ko, short* count_captures) """ - check if a stone on location is connected to a group, kills a group + check if a stone on location is connected to a group, kills a group or is a new group on the board """ @@ -138,17 +138,18 @@ cdef class GameState: # # ############################################################################ - cdef long generate_12d_hash( self, short centre ) + cdef long generate_12d_hash(self, short centre) """ generate 12d hash around centre location """ - cdef long generate_3x3_hash( self, short centre ) + cdef long generate_3x3_hash(self, short centre) """ generate 3x3 hash around centre location """ - cdef void get_group_after( self, char* groups_after, char* locations, char* captures, short location ) + cdef void get_group_after_pointer(self, short* stones, short* liberty, short* capture, char* locations, char* captures, short location) + cdef void get_group_after(self, char* groups_after, char* locations, char* captures, short location) """ groups_after is a board_size * 3 array representing STONES, LIBERTY, CAPTURE for every location @@ -158,7 +159,7 @@ cdef class GameState: groups_after[ location * 3 + 2 ] to capture count """ - cdef bint is_true_eye( self, short location, Locations_List* eyes, char owner ) + cdef bint is_true_eye(self, short location, Locations_List* eyes, char owner) """ check if location is a real eye """ @@ -173,11 +174,11 @@ cdef class GameState: version (still can be found in go_python.py) made a copy of the whole GameState for every move played. - This version only duplicates self.board_groups ( so the list with pointers to groups ) + This version only duplicates self.board_groups (so the list with pointers to groups) the add_ladder_move playes a move like the add_to_group function but it does not change the original groups and creates a list with groups removed - with this groups removed list undo_ladder_move will return the board state to + with this groups removed list undo_ladder_move will return the board state to be the same as before add_ladder_move was called get_removed_groups and unremove_group are being used my add/undo_ladder_move @@ -193,7 +194,7 @@ cdef class GameState: TODO self.player colour is used, should become a pointer """ - cdef Groups_List* add_ladder_move( self, short location, Group **board, short* ko ) + cdef Groups_List* add_ladder_move(self, short location, Group **board, short* ko) """ create a new group for location move and add all connected groups to it @@ -202,40 +203,40 @@ cdef class GameState: position """ - cdef void undo_ladder_move( self, short location, Groups_List* removed_groups, short ko, Group **board, short* ko ) + cdef void undo_ladder_move(self, short location, Groups_List* removed_groups, short ko, Group **board, short* ko) """ Use removed_groups list to return board state to be the same as before add_ladder_move was used """ - cdef void unremove_group( self, Group* group_remove, Group **board ) + cdef void unremove_group(self, Group* group_remove, Group **board) """ unremove group from board loop over all stones in this group and set board to group_unremove remove liberty from neigbor locations """ - cdef dict get_capture_moves( self, Group* group, char color, Group **board ) + cdef dict get_capture_moves(self, Group* group, char color, Group **board) """ create a dict with al moves that capture a group surrounding group """ - cdef void get_removed_groups( self, short location, Groups_List* removed_groups, Group **board, short* ko ) + cdef void get_removed_groups(self, short location, Groups_List* removed_groups, Group **board, short* ko) """ create a new group for location move and add all connected groups to it - similar to add_to_group except no groups are changed or killed + similar to add_to_group except no groups are changed or killed all changes to the board are stored in removed_groups """ - cdef bint is_ladder_escape_move( self, Group **board, short* ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase ) + cdef bint is_ladder_escape_move(self, Group **board, short* ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase) """ play a ladder move on location, check if group has escaped, if the group has 2 liberty it is undetermined -> try to capture it by playing at both liberty """ - cdef bint is_ladder_capture_move( self, Group **board, short* ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase ) + cdef bint is_ladder_capture_move(self, Group **board, short* ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase) """ play a ladder move on location, try capture and escape moves and see if the group is able to escape ladder @@ -246,9 +247,9 @@ cdef class GameState: # # ############################################################################ - cdef char* get_groups_after( self ) + cdef char* get_groups_after(self) """ - return a short array of size board_size * 3 representing + return a short array of size board_size * 3 representing STONES, LIBERTY, CAPTURE for every board location max count values are 100 @@ -257,7 +258,7 @@ cdef class GameState: capture count of a play on that location """ - cdef list get_neighbor_locations( self ) + cdef list get_neighbor_locations(self) """ generate list with 3x3 neighbor locations 0,1,2,3 are direct neighbor @@ -265,25 +266,25 @@ cdef class GameState: where -1 if it is a border location or non empty location """ - cdef long get_hash_12d( self, short centre ) + cdef long get_hash_12d(self, short centre) """ return hash for 12d star pattern around location """ - cdef long get_hash_3x3( self, short location ) + cdef long get_hash_3x3(self, short location) """ return 3x3 pattern hash + current player """ - cdef char* get_ladder_escapes( self, int maxDepth ) + cdef char* get_ladder_escapes(self, int maxDepth) """ return char array with size board_size every location represents a location on the board where: _FREE = no ladder escape - _STONE = ladder escape + _STONE = ladder escape """ - cdef char* get_ladder_captures( self, int maxDepth ) + cdef char* get_ladder_captures(self, int maxDepth) """ return char array with size board_size every location represents a location on the board where: @@ -296,7 +297,7 @@ cdef class GameState: # # ############################################################################ - cdef void add_move( self, short location ) + cdef void add_move(self, short location) """ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! Move should be legal! @@ -307,12 +308,22 @@ cdef class GameState: update player_current, history and moves_legal """ - cdef GameState new_state_add_move( self, short location ) + cdef GameState new_state_add_move(self, short location) """ copy this gamestate and play move at location """ - cdef char get_winner_colour( self, int komi ) + cdef float get_score(self, float komi) + """ + Calculate score of board state. Uses 'Area scoring'. + + http://senseis.xmp.net/?Passing#1 + + negative value indicates black win + positive value indicates white win + """ + + cdef char get_winner_colour(self, float komi) """ Calculate score of board state and return player ID (1, -1, or 0 for tie) corresponding to winner. Uses 'Area scoring'. @@ -325,7 +336,7 @@ cdef class GameState: # # ############################################################################ - cdef Locations_List* get_sensible_moves( self ) + cdef Locations_List* get_sensible_moves(self) """ only used for def get_legal_moves """ @@ -335,12 +346,12 @@ cdef class GameState: # # ############################################################################ - cdef test( self ) + cdef test(self) """ test function """ - cdef test_cpp_fast( self ) + cdef test_cpp_fast(self) """ test function """ diff --git a/AlphaGo/go.pyx b/AlphaGo/go.pyx index bc6c8b117..bc15a2495 100644 --- a/AlphaGo/go.pyx +++ b/AlphaGo/go.pyx @@ -44,7 +44,7 @@ cdef class GameState: cdef short board_size # possible ko location - cdef short ko + cdef short ko # list with all groups cdef Groups_List *groups_list @@ -96,9 +96,9 @@ cdef class GameState: ############################################################################ - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef void initialize_new( self, char size ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef void initialize_new(self, char size): """ initialize this state as empty state """ @@ -116,7 +116,7 @@ cdef class GameState: self.board_size = size * size # create history list - self.moves_history = locations_list_new( 10 ) + self.moves_history = locations_list_new(10) # initialize player colours self.player_current = _BLACK @@ -131,36 +131,36 @@ cdef class GameState: # create arrays and lists # +1 on board_size is used as an border location used for all borders - # create Group pointer array ( Group **) + # create Group pointer array (Group **) # this array represent the board, every group contains colour, stone-locations # and liberty locations # border location is included, therefore the array size is board_size +1 - self.board_groups = malloc( ( self.board_size + 1 ) * sizeof( Group* ) ) + self.board_groups = malloc((self.board_size + 1) * sizeof(Group*)) if not self.board_groups: raise MemoryError() # create 3x3 hash array, these are updated after every move - self.hash3x3 = malloc( ( self.board_size ) * sizeof( long ) ) + self.hash3x3 = malloc((self.board_size) * sizeof(long)) if not self.hash3x3: raise MemoryError() # create Locations_List as legal_moves # after every move this list will be updated to contain all legal moves # max amount of legal moves is board_size - self.moves_legal = locations_list_new( self.board_size ) + self.moves_legal = locations_list_new(self.board_size) # create groups_list as groups_list # this list will contain all alive groups - # we do not need to set the theoretical max amount of groups as the list + # we do not need to set the theoretical max amount of groups as the list # will be incremented in group_list_add - self.groups_list = groups_list_new( self.board_size ) + self.groups_list = groups_list_new(self.board_size) # get global group_empty reference -> used when removing groups self.group_empty = group_empty # initialize board, set all locations to group empty and add all # locations as move_legal - for i in range( self.board_size ): + for i in range(self.board_size): self.board_groups[ i ] = group_empty self.moves_legal.locations[ i ] = i @@ -173,10 +173,10 @@ cdef class GameState: self.board_groups[ self.board_size ] = group_border # initialize all 3x3 hashes - for i in range( self.board_size ): + for i in range(self.board_size): + + self.hash3x3[ i ] = self.generate_3x3_hash(i) - self.hash3x3[ i ] = self.generate_3x3_hash( i ) - # initialize zobrist hash # TODO optimize? # rng = np.random.RandomState(0) @@ -187,9 +187,9 @@ cdef class GameState: # self.previous_hashes = set() - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef void initialize_duplicate( self, GameState copy_state ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef void initialize_duplicate(self, GameState copy_state): """ Initialize all variables as a copy of copy_state """ @@ -199,7 +199,7 @@ cdef class GameState: cdef Group* group_pointer cdef Group* group - # !!! do not copy !!! + # !!! do not copy !!! # these do not need a deep copy as they are static self.neighbor = copy_state.neighbor self.neighbor3x3 = copy_state.neighbor3x3 @@ -228,71 +228,71 @@ cdef class GameState: # self.current_hash = copy_state.current_hash # create history list - self.moves_history = locations_list_new( copy_state.moves_history.size ) + self.moves_history = locations_list_new(copy_state.moves_history.size) self.moves_history.count = copy_state.moves_history.count # copy all history moves in copy_state - memcpy( self.moves_history.locations, copy_state.moves_history.locations, copy_state.moves_history.count * sizeof( short ) ) + memcpy(self.moves_history.locations, copy_state.moves_history.locations, copy_state.moves_history.count * sizeof(short)) - # self.previous_hashes = list( copy_state.previous_hashes ) + # self.previous_hashes = list(copy_state.previous_hashes) # create 3x3 hash array, these are updated after every move - self.hash3x3 = malloc( ( self.board_size ) * sizeof( long ) ) + self.hash3x3 = malloc((self.board_size) * sizeof(long)) if not self.hash3x3: raise MemoryError() # copy all 3x3 hashes from copy_state - memcpy( self.hash3x3, copy_state.hash3x3, ( self.board_size ) * sizeof( long ) ) + memcpy(self.hash3x3, copy_state.hash3x3, (self.board_size) * sizeof(long)) # create Locations_List as legal_moves # after every move this list will be updated to contain all legal moves # max amount of legal moves is board_size - self.moves_legal = locations_list_new( self.board_size ) + self.moves_legal = locations_list_new(self.board_size) self.moves_legal.count = copy_state.moves_legal.count # copy all legal moves from copy_state - memcpy( self.moves_legal.locations, copy_state.moves_legal.locations, copy_state.moves_legal.count * sizeof( short ) ) + memcpy(self.moves_legal.locations, copy_state.moves_legal.locations, copy_state.moves_legal.count * sizeof(short)) # create groups_list as groups_list # this list will contain all alive groups - # we do not need to set the theoretical max amount of groups as the list + # we do not need to set the theoretical max amount of groups as the list # will be incremented in group_list_add - self.groups_list = groups_list_new( self.board_size ) + self.groups_list = groups_list_new(self.board_size) - # create Group pointer array ( Group **) + # create Group pointer array (Group **) # this array represent the board, every group contains colour, stone-locations # and liberty locations # border location is included, therefore the array size is board_size +1 - self.board_groups = malloc( ( self.board_size + 1 ) * sizeof( Group* ) ) + self.board_groups = malloc((self.board_size + 1) * sizeof(Group*)) if not self.board_groups: raise MemoryError() # copy all group pointers from copy_state # all Groups will be duplicated and overwritten but all group_empty pointers stay the same - memcpy( self.board_groups, copy_state.board_groups, ( self.board_size + 1 ) * sizeof( Group* ) ) + memcpy(self.board_groups, copy_state.board_groups, (self.board_size + 1) * sizeof(Group*)) # loop over all groups in copy_state.groups_list # duplicate them and set all Group pointers of this groups stone-locations # to the new group - for i in range( copy_state.groups_list.count_groups ): + for i in range(copy_state.groups_list.count_groups): # get group - group = copy_state.groups_list.board_groups[ i ] + group = copy_state.groups_list.board_groups[ i ] # duplicate group - group_pointer = group_duplicate( group, self.board_size ) + group_pointer = group_duplicate(group, self.board_size) # add new group to groups_list - groups_list_add( group_pointer, self.groups_list ) + groups_list_add(group_pointer, self.groups_list) # loop over all group locations - for location in range( self.board_size ): + for location in range(self.board_size): - # if group has a stone on this location, set board_groups group pointer + # if group has a stone on this location, set board_groups group pointer if group.locations[ location ] == _STONE: self.board_groups[ location ] = group_pointer - @cython.boundscheck( False ) - @cython.wraparound( False ) - def __init__( self, char size = 19, GameState copyState = None ): + @cython.boundscheck(False) + @cython.wraparound(False) + def __init__(self, char size = 19, GameState copyState = None): """ create new instance of GameState """ @@ -300,13 +300,12 @@ cdef class GameState: if copyState is not None: # create copy of given state - self.initialize_duplicate( copyState ) + self.initialize_duplicate(copyState) else: # check if neighbor arrays exist or size has changed if not neighbor or size != neighbor_size: - # calculate board size self.board_size = size * size @@ -318,26 +317,33 @@ cdef class GameState: global group_empty global group_border - # TODO if size has changed, free all globals + # free arrays if they already existed + if neighbor: + + free(neighbor) + free(neighbor3x3) + free(neighbor12d) # set size neighbor_size = size # set neighbor arrays - neighbor = get_neighbors( size ) - neighbor3x3 = get_3x3_neighbors( size ) - neighbor12d = get_12d_neighbors( size ) + neighbor = get_neighbors(size) + neighbor3x3 = get_3x3_neighbors(size) + neighbor12d = get_12d_neighbors(size) # initialize EMPTY and BORDER group - group_empty = group_new( _EMPTY, self.board_size ) - group_border = group_new( _BORDER, self.board_size ) + if not group_empty: + group_empty = group_new(_EMPTY, self.board_size) + if not group_border: + group_border = group_new(_BORDER, self.board_size) # create new root state - self.initialize_new( size ) + self.initialize_new(size) - @cython.boundscheck( False ) - @cython.wraparound( False ) + @cython.boundscheck(False) + @cython.wraparound(False) def __dealloc__(self): """ this function is called when this object is destroyed @@ -345,7 +351,7 @@ cdef class GameState: Prevent memory leaks by freeing all arrays created with malloc !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - do not fee neighbor, neighbor3x3, neighbor12d, + do not fee neighbor, neighbor3x3, neighbor12d, group_empty or group_border RootState will handle those! @@ -357,39 +363,39 @@ cdef class GameState: # free hash3x3 if self.hash3x3 is not NULL: - free( self.hash3x3 ) + free(self.hash3x3) # free board_groups if self.board_groups is not NULL: - free( self.board_groups ) + free(self.board_groups) # free history - locations_list_destroy( self.moves_history ) + locations_list_destroy(self.moves_history) # free moves_legal and moves_legal.locations if self.moves_legal is not NULL: if self.moves_legal.locations is not NULL: - free( self.moves_legal.locations ) + free(self.moves_legal.locations) - free( self.moves_legal ) + free(self.moves_legal) # free groups_list all groups in groups_list.board_groups and groups_list.board_groups if self.groups_list is not NULL: # loop over all groups and free them - for i in range( self.groups_list.count_groups ): + for i in range(self.groups_list.count_groups): - group_destroy( self.groups_list.board_groups[ i ] ) + group_destroy(self.groups_list.board_groups[ i ]) # free groups_list.board_groups if self.groups_list.board_groups is not NULL: - free( self.groups_list.board_groups ) + free(self.groups_list.board_groups) - free( self.groups_list ) + free(self.groups_list) ############################################################################ @@ -398,9 +404,9 @@ cdef class GameState: ############################################################################ - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef bint is_legal_move( self, short location, Group **board, short ko ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef bint is_legal_move(self, short location, Group **board, short ko): """ check if playing at location is a legal move to make """ @@ -414,7 +420,7 @@ cdef class GameState: return 0 # check if it has liberty after - if 0 == self.has_liberty_after( location, board ): + if 0 == self.has_liberty_after(location, board): return 0 # TODO check super-ko @@ -422,9 +428,9 @@ cdef class GameState: return 1 - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef bint has_liberty_after( self, short location, Group **board ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef bint has_liberty_after(self, short location, Group **board): """ check if a play at location results in an alive group - has liberty @@ -439,7 +445,7 @@ cdef class GameState: cdef Group* group_temp # loop over all four neighbors - for i in range( 4 ): + for i in range(4): # get neighbor location neighbor_location = self.neighbor[ location * 4 + i ] @@ -451,13 +457,13 @@ cdef class GameState: return 1 # get neighbor group - # ( group_border has zero libery and is wrong colour ) + # (group_border has zero libery and is wrong colour) group_temp = board[ neighbor_location ] count_liberty = group_temp.count_liberty # if there is a player_current group if board_value == self.player_current: - + # if it has at least 2 liberty if count_liberty >= 2: @@ -470,13 +476,13 @@ cdef class GameState: # group killed and thus legal return 1 - + return 0 - - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef short calculate_board_location( self, char x, char y ): + + @cython.boundscheck(False) + @cython.wraparound(False) + cdef short calculate_board_location(self, char x, char y): """ return location on board no checks on outside board @@ -485,24 +491,24 @@ cdef class GameState: """ # return board location - return x + ( y * self.size ) - + return x + (y * self.size) - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef tuple calculate_tuple_location( self, short location ): + + @cython.boundscheck(False) + @cython.wraparound(False) + cdef tuple calculate_tuple_location(self, short location): """ return location on board as a tupple no checks on outside board """ # return board location - return ( location / self.size, location % self.size ) + return (location / self.size, location % self.size) - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef void set_moves_legal_list( self, Locations_List *moves_legal ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef void set_moves_legal_list(self, Locations_List *moves_legal): """ generate moves_legal list """ @@ -514,28 +520,28 @@ cdef class GameState: # TODO? keep empty locations list? # loop over all board locations and check if a move is legal - for i in range( self.board_size ): + for i in range(self.board_size): # check if a move is legal - if self.is_legal_move( i, self.board_groups, self.ko ): + if self.is_legal_move(i, self.board_groups, self.ko): # add to moves_legal moves_legal.locations[ moves_legal.count ] = i moves_legal.count += 1 - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef void combine_groups( self, Group* group_keep, Group* group_remove, Group **board ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef void combine_groups(self, Group* group_keep, Group* group_remove, Group **board): """ - combine group_keep and group_remove and replace group_remove on the board + combine group_keep and group_remove and replace group_remove on the board """ cdef int i cdef char value # loop over all board locations - for i in range( self.board_size ): + for i in range(self.board_size): value = group_remove.locations[ i ] @@ -543,17 +549,17 @@ cdef class GameState: # group_remove has a stone, add to group_keep # and set board location to group_keep - group_add_stone( group_keep, i ) + group_add_stone(group_keep, i) board[ i ] = group_keep elif value == _LIBERTY: # add liberty - group_add_liberty( group_keep, i ) + group_add_liberty(group_keep, i) - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef void remove_group( self, Group* group_remove, Group **board, short* ko ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef void remove_group(self, Group* group_remove, Group **board, short* ko): """ remove group from board -> set all locations to group_empty """ @@ -567,10 +573,10 @@ cdef class GameState: # if groupsize == 1, possible ko if group_remove.count_stones == 1: - ko[ 0 ] = group_location_stone( group_remove, self.board_size ) + ko[ 0 ] = group_location_stone(group_remove, self.board_size) # loop over all group stone locations - for location in range( self.board_size ): + for location in range(self.board_size): if group_remove.locations[ location ] == _STONE: @@ -579,7 +585,7 @@ cdef class GameState: # update liberty of neighbors # loop over all four neighbors - for i in range( 4 ): + for i in range(4): # get neighbor location neighbor_location = self.neighbor[ location * 4 + i ] @@ -591,12 +597,12 @@ cdef class GameState: # add liberty group_temp = board[ neighbor_location ] - group_add_liberty( group_temp, location ) + group_add_liberty(group_temp, location) - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef void update_hashes( self, Group* group ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef void update_hashes(self, Group* group): """ update all locations affected by removal of group """ @@ -604,31 +610,31 @@ cdef class GameState: cdef short i, a, location, location_array # loop over all stones in group - for i in range( self.board_size ): + for i in range(self.board_size): if group.locations[ i ] == _STONE: # update hash location - self.hash3x3[ i ] = self.generate_3x3_hash( i ) + self.hash3x3[ i ] = self.generate_3x3_hash(i) location_array = i * 8 # loop over diagonal # this group is killed -> neighbors are enemy stones - for a in range( 4, 8 ): + for a in range(4, 8): # TODO add check for this group location location = self.neighbor3x3[ location_array + a ] if self.board_groups[ location ].colour == _EMPTY: - self.hash3x3[ location ] = self.generate_3x3_hash( location ) + self.hash3x3[ location ] = self.generate_3x3_hash(location) - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef void add_to_group( self, short location, Group **board, short* ko, short* count_captures ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef void add_to_group(self, short location, Group **board, short* ko, short* count_captures): """ - check if a stone on location is connected to a group, kills a group + check if a stone on location is connected to a group, kills a group or is a new group on the board """ @@ -641,7 +647,7 @@ cdef class GameState: cdef int i # loop over all four neighbors - for i in range( 4 ): + for i in range(4): # get neighbor location and value neighborLocation = self.neighbor[ location * 4 + i ] @@ -661,17 +667,17 @@ cdef class GameState: tempGroup = board[ neighborLocation ] if tempGroup != newGroup: - self.combine_groups( newGroup, tempGroup, board ) + self.combine_groups(newGroup, tempGroup, board) # remove temp_group from groupList and destroy it - groups_list_remove( tempGroup, self.groups_list ) - group_destroy( tempGroup ) + groups_list_remove(tempGroup, self.groups_list) + group_destroy(tempGroup) elif boardValue == self.player_opponent: # remove liberty from enemy group tempGroup = board[ neighborLocation ] - group_remove_liberty( tempGroup, location ) + group_remove_liberty(tempGroup, location) # check liberty count and remove if 0 if tempGroup.count_liberty == 0: @@ -680,30 +686,30 @@ cdef class GameState: count_captures[ 0 ] += tempGroup.count_stones # remove group and update hashes - self.remove_group( tempGroup, board, ko ) - self.update_hashes( tempGroup ) + self.remove_group(tempGroup, board, ko) + self.update_hashes(tempGroup) # TODO hashes of locations next to a group where liberty change also have to be updated # remove tempGroup from groupList and destroy - groups_list_remove( tempGroup, self.groups_list ) - group_destroy( tempGroup ) + groups_list_remove(tempGroup, self.groups_list) + group_destroy(tempGroup) # increment group_removed count group_removed += 1 # check if no connected group is found - if newGroup is NULL: + if newGroup is NULL: # create new group and add to groups_list - newGroup = group_new( self.player_current, self.board_size ) - groups_list_add( newGroup, self.groups_list ) + newGroup = group_new(self.player_current, self.board_size) + groups_list_add(newGroup, self.groups_list) else: # remove liberty from group - group_remove_liberty( newGroup, location ) + group_remove_liberty(newGroup, location) # add stone to group - group_add_stone( newGroup, location ) + group_add_stone(newGroup, location) # set board location to group board[ location ] = newGroup @@ -711,7 +717,7 @@ cdef class GameState: location_array = location * 8 # loop over all four neighbors - for i in range( 4 ): + for i in range(4): # get neighbor location neighborLocation = self.neighbor3x3[ location_array + i ] @@ -719,11 +725,11 @@ cdef class GameState: # if neighbor location is empty add liberty and update hash if board[ neighborLocation ].colour == _EMPTY: - group_add_liberty( newGroup, neighborLocation ) - self.hash3x3[ neighborLocation ] = self.generate_3x3_hash( neighborLocation ) + group_add_liberty(newGroup, neighborLocation) + self.hash3x3[ neighborLocation ] = self.generate_3x3_hash(neighborLocation) # loop over all four diagonals - for i in range( 4, 8 ): + for i in range(4, 8): # get neighbor location neighborLocation = self.neighbor3x3[ location_array + i ] @@ -731,8 +737,8 @@ cdef class GameState: # if diagonal is empty update hash if board[ neighborLocation ].colour == _EMPTY: - self.hash3x3[ neighborLocation ] = self.generate_3x3_hash( neighborLocation ) - + self.hash3x3[ neighborLocation ] = self.generate_3x3_hash(neighborLocation) + # check if there is really a ko # if two groups died there is no ko # if newGroup has more than 1 stone there is no ko @@ -746,9 +752,9 @@ cdef class GameState: ############################################################################ - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef long generate_12d_hash( self, short centre ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef long generate_12d_hash(self, short centre): """ generate 12d hash around centre location """ @@ -761,7 +767,7 @@ cdef class GameState: centre *= 12 # hash colour and liberty of all locations - for i in range( 12 ): + for i in range(12): # get group group = self.board_groups[ self.neighbor12d[ centre + i ] ] @@ -771,15 +777,15 @@ cdef class GameState: hash *= _HASHVALUE # hash liberty - hash += min( group.count_liberty, 3 ) + hash += min(group.count_liberty, 3) hash *= _HASHVALUE return hash - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef long generate_3x3_hash( self, short centre ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef long generate_3x3_hash(self, short centre): """ generate 3x3 hash around centre location """ @@ -792,7 +798,7 @@ cdef class GameState: centre *= 8 # hash colour and liberty of all locations - for i in range( 8 ): + for i in range(8): # get group group = self.board_groups[ self.neighbor3x3[ centre + i ] ] @@ -802,14 +808,14 @@ cdef class GameState: hash *= _HASHVALUE # hash liberty - hash += min( group.count_liberty, 3 ) + hash += min(group.count_liberty, 3) hash *= _HASHVALUE return hash - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef void get_group_after( self, char* groups_after, char* locations, char* captures, short location ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef void get_group_after(self, char* groups_after, char* locations, char* captures, short location): """ groups_after is a board_size * 3 array representing STONES, LIBERTY, CAPTURE for every location @@ -828,7 +834,7 @@ cdef class GameState: cdef short stones, liberty, capture # loop over all four neighbors - for i in range( 4 ): + for i in range(4): # get neighbor location and value neighbor_location = self.neighbor[ location * 4 + i ] @@ -842,9 +848,9 @@ cdef class GameState: elif board_value == self.player_current: # found friendly group - for a in range( self.board_size ): + for a in range(self.board_size): - if temp_group.locations[ a ] != _FREE: + if temp_group.locations[ a ] != _FREE: locations[ a ] = temp_group.locations[ a ] @@ -854,38 +860,38 @@ cdef class GameState: # if it has one liberty it wil be killed -> add potential liberty if temp_group.count_liberty == 1: - for a in range( self.board_size ): + for a in range(self.board_size): - if temp_group.locations[ a ] == _STONE: + if temp_group.locations[ a ] == _STONE: captures[ a ] = _CAPTURE # add stone locations[ location ] = _STONE - for neighbor_location in range( self.board_size ): + for neighbor_location in range(self.board_size): if captures[ neighbor_location ] == _CAPTURE: # loop over all four neighbors - for i in range( 4 ): + for i in range(4): # get neighbor location and value temp_location = self.neighbor[ neighbor_location * 4 + i ] if temp_location < self.board_size and locations[ temp_location ] == _STONE: locations[ neighbor_location ] = _LIBERTY - + # remove location as liberty locations[ location ] = _STONE - stones = 0 + stones = 0 liberty = 0 capture = 0 # count all values - for i in range( self.board_size ): + for i in range(self.board_size): if locations[ i ] == _STONE: @@ -896,26 +902,120 @@ cdef class GameState: if captures[ i ] == _CAPTURE: capture += 1 - + # check max if stones > 100: - stones = 100 + stones = 100 if liberty > 100: - liberty = 100 + liberty = 100 if capture > 100: capture = 100 - # set values - groups_after[ location_array ] = stones + # set values + groups_after[ location_array ] = stones groups_after[ location_array + 1 ] = liberty groups_after[ location_array + 2 ] = capture - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef bint is_true_eye( self, short location, Locations_List* eyes, char owner ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef void get_group_after_pointer(self, short* stones, short* liberty, short* capture, char* locations, char* captures, short location): + """ + groups_after is a board_size * 3 array representing STONES, LIBERTY, CAPTURE for every location + + calculate group after a play on location and set + stones[ 0 ] to stone count + liberty[0 ] to liberty count + capture[0 ] to capture count + """ + + cdef short neighbor_location + cdef short temp_location + cdef char board_value + cdef Group* temp_group + cdef int i, a, b, c + cdef int location_array = location * 3 + + # loop over all four neighbors + for i in range(4): + + # get neighbor location and value + neighbor_location = self.neighbor[location * 4 + i] + temp_group = self.board_groups[neighbor_location] + board_value = temp_group.colour + + # check if neighbor is friendly stone + if board_value == _EMPTY: + + locations[neighbor_location] = _LIBERTY + elif board_value == self.player_current: + + # found friendly group + for a in range(self.board_size): + + if temp_group.locations[a] != _FREE: + + locations[a] = temp_group.locations[a] + + elif board_value == self.player_opponent: + + # get enemy group + # if it has one liberty it wil be killed -> add potential liberty + if temp_group.count_liberty == 1: + + for a in range(self.board_size): + + if temp_group.locations[a] == _STONE: + + captures[a] = _CAPTURE + + # add stone + locations[location] = _STONE + + for neighbor_location in range(self.board_size): + + if captures[neighbor_location] == _CAPTURE: + + # loop over all four neighbors + for i in range(4): + + # get neighbor location and value + temp_location = self.neighbor[neighbor_location * 4 + i] + if temp_location < self.board_size and locations[temp_location] == _STONE: + + locations[neighbor_location] = _LIBERTY + + + # remove location as liberty + locations[location] = _STONE + + a = 0 + b = 0 + c = 0 + + # count all values + for i in range(self.board_size): + + if locations[i] == _STONE: + + a += 1 + elif locations[i] == _LIBERTY: + + b += 1 + if captures[i] == _CAPTURE: + + c += 1 + + stones[0] = a + liberty[0] = b + capture[0] = c + + + @cython.boundscheck(False) + @cython.wraparound(False) + cdef bint is_true_eye(self, short location, Locations_List* eyes, char owner): """ check if location is a real eye """ @@ -927,21 +1027,21 @@ cdef class GameState: cdef char count_border = 0 cdef short location_neighbor cdef Locations_List* empty_diag - + # TODO benchmark what is faster? first dict lookup then neighbor check or other way around if eyes_lenght > 70: - print "pretty big" + str( eyes_lenght ) + print "pretty big" + str(eyes_lenght) # check if it is a known eye - for i in range( eyes.count ): + for i in range(eyes.count): if location == eyes.locations[ i ]: return 1 # loop over neighbor - for i in range( 4 ): + for i in range(4): location_neighbor = self.neighbor3x3[ location * 8 + i ] board_value = self.board_groups[ location_neighbor ].colour @@ -954,17 +1054,17 @@ cdef class GameState: # empty location or enemy stone return 0 - empty_diag = locations_list_new( 4 ) + empty_diag = locations_list_new(4) # loop over diagonals - for i in range( 4, 8 ): + for i in range(4, 8): location_neighbor = self.neighbor3x3[ location * 8 + i ] board_value = self.board_groups[ location_neighbor ].colour if board_value == _EMPTY: - #locations_list_add_location( empty_diag, location_neighbor ) + #locations_list_add_location(empty_diag, location_neighbor) empty_diag.locations[ empty_diag.count ] = location_neighbor empty_diag.count += 1 count_bad_diagonal += 1 @@ -977,7 +1077,7 @@ cdef class GameState: count_bad_diagonal += 1 # assume location is an eye - locations_list_add_location_increment( eyes, location ) + locations_list_add_location_increment(eyes, location) #eyes.locations[ eyes.count ] = location #eyes.count += 1 @@ -986,18 +1086,18 @@ cdef class GameState: if count_bad_diagonal <= max_bad_diagonal: # one bad diagonal is allowed in the middle - locations_list_destroy( empty_diag ) + locations_list_destroy(empty_diag) return 1 - for i in range( empty_diag.count ): + for i in range(empty_diag.count): location_neighbor = empty_diag.locations[ i ] - if self.is_true_eye( location_neighbor, eyes, owner ): + if self.is_true_eye(location_neighbor, eyes, owner): count_bad_diagonal -= 1 - locations_list_destroy( empty_diag ) + locations_list_destroy(empty_diag) if count_bad_diagonal <= max_bad_diagonal: @@ -1007,7 +1107,7 @@ cdef class GameState: eyes.count = eyes_lenght return 0 - + ############################################################################ # private cdef Ladder functions # # # @@ -1018,11 +1118,11 @@ cdef class GameState: version (still can be found in go_python.py) made a copy of the whole GameState for every move played. - This version only duplicates self.board_groups ( so the list with pointers to groups ) + This version only duplicates self.board_groups (so the list with pointers to groups) the add_ladder_move playes a move like the add_to_group function but it does not change the original groups and creates a list with groups removed - with this groups removed list undo_ladder_move will return the board state to + with this groups removed list undo_ladder_move will return the board state to be the same as before add_ladder_move was called get_removed_groups and unremove_group are being used my add/undo_ladder_move @@ -1039,9 +1139,9 @@ cdef class GameState: """ - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef Groups_List* add_ladder_move( self, short location, Group **board, short* ko ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef Groups_List* add_ladder_move(self, short location, Group **board, short* ko): """ create a new group for location move and add all connected groups to it @@ -1051,31 +1151,31 @@ cdef class GameState: """ # create Group_List able to hold up to 4 changed/removed groups - cdef Groups_List* removed_groups = groups_list_new( 4 ) + cdef Groups_List* removed_groups = groups_list_new(4) # ko is a pointer -> add [ 0 ] to acces the actual value ko[ 0 ] = _PASS # play move at location and add removed groups to removed_groups list - self.get_removed_groups( location, removed_groups, board, ko ) + self.get_removed_groups(location, removed_groups, board, ko) # change player colour self.player_current = self.player_opponent - self.player_opponent = ( _BLACK if self.player_current == _WHITE else _WHITE ) + self.player_opponent = (_BLACK if self.player_current == _WHITE else _WHITE) return removed_groups - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef void undo_ladder_move( self, short location, Groups_List* removed_groups, short removed_ko, Group **board, short* ko ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef void undo_ladder_move(self, short location, Groups_List* removed_groups, short removed_ko, Group **board, short* ko): """ Use removed_groups list to return board state to be the same as before add_ladder_move was used """ cdef short i, b, location_neighbor - cdef Group* group + cdef Group* group cdef Group* group_remove = board[ location ] # reset ko to old value @@ -1084,13 +1184,13 @@ cdef class GameState: # change player colour self.player_current = self.player_opponent - self.player_opponent = ( _BLACK if self.player_current == _WHITE else _WHITE ) + self.player_opponent = (_BLACK if self.player_current == _WHITE else _WHITE) # undo move set location to empty group board[ location ] = self.group_empty # undo group removals - for i in range( removed_groups.count_groups ): + for i in range(removed_groups.count_groups): # do group unremovals in reversed order!!! # this is important in order to get correct liberty counts @@ -1102,41 +1202,41 @@ cdef class GameState: if group.colour == self.player_opponent: # opponent group was removed from the board -> unremove it - self.unremove_group( group, board ) + self.unremove_group(group, board) else: # set all board_groups locations to group # liberty have not been changed - for b in range( self.board_size ): + for b in range(self.board_size): if group.locations[ b ] == _STONE: board[ b ] = group # add liberty to neighbor groups - for i in range( 4 ): + for i in range(4): location_neighbor = self.neighbor[ location * 4 + i ] if board[ location_neighbor ].colour > _EMPTY: - group_add_liberty( board[ location_neighbor ], location ) + group_add_liberty(board[ location_neighbor ], location) # destroy group - group_destroy( group_remove ) + group_destroy(group_remove) # free removed_groups if removed_groups is not NULL: if removed_groups.board_groups is not NULL: - free( removed_groups.board_groups ) + free(removed_groups.board_groups) - free( removed_groups ) + free(removed_groups) - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef void unremove_group( self, Group* group_unremove, Group **board ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef void unremove_group(self, Group* group_unremove, Group **board): """ unremove group from board loop over all stones in this group and set board to group_unremove @@ -1149,7 +1249,7 @@ cdef class GameState: cdef int i # loop over all group stone locations - for location in range( self.board_size ): + for location in range(self.board_size): # check if this has a stone on location if group_unremove.locations[ location ] == _STONE: @@ -1159,7 +1259,7 @@ cdef class GameState: # update liberty of neighbors # loop over all four neighbors - for i in range( 4 ): + for i in range(4): # get neighbor location neighbor_location = self.neighbor[ location * 4 + i ] @@ -1169,12 +1269,12 @@ cdef class GameState: if group_unremove.locations[ neighbor_location ] != _STONE: # remove liberty - group_remove_liberty( board[ neighbor_location ], location ) + group_remove_liberty(board[ neighbor_location ], location) - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef dict get_capture_moves( self, Group* group, char color, Group **board ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef dict get_capture_moves(self, Group* group, char color, Group **board): """ create a dict with al moves that capture a group surrounding group """ @@ -1184,7 +1284,7 @@ cdef class GameState: cdef dict capture = {} # find all moves capturing an enemy group - for location in range( self.board_size ): + for location in range(self.board_size): if group.locations[ location ] == _STONE: @@ -1192,7 +1292,7 @@ cdef class GameState: location_array = location * 4 # loop over neighbor - for i in range( 4 ): + for i in range(4): # calculate neighbor location location_neighbor = self.neighbor[ location_array + i ] @@ -1207,24 +1307,24 @@ cdef class GameState: if group_neighbor.count_liberty == 1: # add potential capture move - location_neighbor = group_location_liberty( group_neighbor, self.board_size ) + location_neighbor = group_location_liberty(group_neighbor, self.board_size) capture[ location_neighbor ] = location_neighbor return capture - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef void get_removed_groups( self, short location, Groups_List* removed_groups, Group **board, short* ko ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef void get_removed_groups(self, short location, Groups_List* removed_groups, Group **board, short* ko): """ create a new group for location move and add all connected groups to it - similar to add_to_group except no groups are changed or killed + similar to add_to_group except no groups are changed or killed all changes to the board are stored in removed_groups """ - # create new group ( it is not added to groups_list as in add_to_group ) - cdef Group* newGroup = group_new( self.player_current, self.board_size ) + # create new group (it is not added to groups_list as in add_to_group) + cdef Group* newGroup = group_new(self.player_current, self.board_size) cdef Group* tempGroup cdef short neighborLocation cdef char boardValue @@ -1232,7 +1332,7 @@ cdef class GameState: cdef int i # loop over all four neighbors - for i in range( 4 ): + for i in range(4): # get neighbor location and value neighborLocation = self.neighbor[ location * 4 + i ] @@ -1245,37 +1345,37 @@ cdef class GameState: tempGroup = board[ neighborLocation ] if tempGroup != newGroup: - self.combine_groups( newGroup, tempGroup, board ) + self.combine_groups(newGroup, tempGroup, board) # add tempGroup to removed_groups - groups_list_add( tempGroup, removed_groups ) + groups_list_add(tempGroup, removed_groups) elif boardValue == self.player_opponent: # remove liberty from enemy group tempGroup = board[ neighborLocation ] - group_remove_liberty( tempGroup, location ) + group_remove_liberty(tempGroup, location) # remove group if tempGroup.count_liberty == 0: - self.remove_group( tempGroup, board, ko ) + self.remove_group(tempGroup, board, ko) # add tempGroup to removed_groups - groups_list_add( tempGroup, removed_groups ) + groups_list_add(tempGroup, removed_groups) # increment group_removed count group_removed += 1 # remove liberty - group_remove_liberty( newGroup, location ) + group_remove_liberty(newGroup, location) # add stone - group_add_stone( newGroup, location ) + group_add_stone(newGroup, location) # set location to newGroup board[ location ] = newGroup # loop over all four neighbors - for i in range( 4 ): + for i in range(4): # get neighbor location neighborLocation = self.neighbor[ location * 4 + i ] @@ -1283,8 +1383,8 @@ cdef class GameState: # check is neighbor is empty, add liberty if so if board[ neighborLocation ].colour == _EMPTY: - group_add_liberty( newGroup, neighborLocation ) - + group_add_liberty(newGroup, neighborLocation) + # check if there is really a ko # if two groups died there is no ko # if newGroup has more than 1 stone there is no ko @@ -1292,9 +1392,9 @@ cdef class GameState: ko[ 0 ] = _PASS - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef bint is_ladder_escape_move( self, Group **board, short* ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef bint is_ladder_escape_move(self, Group **board, short* ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase): """ play a ladder move on location, check if group has escaped, if the group has 2 liberty it is undetermined -> @@ -1315,13 +1415,13 @@ cdef class GameState: return 0 # check if move is legal - if not self.is_legal_move( location, board, ko[ 0 ] ): + if not self.is_legal_move(location, board, ko[ 0 ]): return 0 # do ladder move and save ko location ko_value = ko[ 0 ] - removed_groups = self.add_ladder_move( location, board, ko ) + removed_groups = self.add_ladder_move(location, board, ko) # check group liberty group = board[ location_group ] @@ -1342,12 +1442,12 @@ cdef class GameState: # do this -> saves computation time # find all moves capturing an enemy group - for location_stone in range( self.board_size ): + for location_stone in range(self.board_size): if group.locations[ location_stone ] == _STONE: # loop over neighbor - for i in range( 4 ): + for i in range(4): # calculate neighbor location location_neighbor = self.neighbor[ location_stone * 4 + i ] @@ -1362,33 +1462,33 @@ cdef class GameState: if group_capture.count_liberty == 1: # add potential capture move - location_neighbor = group_location_liberty( group_capture, self.board_size ) + location_neighbor = group_location_liberty(group_capture, self.board_size) capture[ location_neighbor ] = location_neighbor # try to catch group by playing at one of the two liberty locations - for location_neighbor in range( self.board_size ): + for location_neighbor in range(self.board_size): if group.locations[ location_neighbor ] == _LIBERTY: - if self.is_ladder_capture_move( board, ko, location_group, capture.copy(), location_neighbor, maxDepth - 1, colour_group, colour_chase ): + if self.is_ladder_capture_move(board, ko, location_group, capture.copy(), location_neighbor, maxDepth - 1, colour_group, colour_chase): # undo move - self.undo_ladder_move( location, removed_groups, ko_value, board, ko ) + self.undo_ladder_move(location, removed_groups, ko_value, board, ko) return 0 # escaped result = 1 # undo move - self.undo_ladder_move( location, removed_groups, ko_value, board, ko ) + self.undo_ladder_move(location, removed_groups, ko_value, board, ko) # return result return result - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef bint is_ladder_capture_move( self, Group **board, short* ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef bint is_ladder_capture_move(self, Group **board, short* ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase): """ play a ladder move on location, try capture and escape moves and see if the group is able to escape ladder @@ -1406,50 +1506,50 @@ cdef class GameState: return 1 - if not self.is_legal_move( location, board, ko[ 0 ] ): + if not self.is_legal_move(location, board, ko[ 0 ]): return 0 ko_value = ko[ 0 ] - removed_groups = self.add_ladder_move( location, board, ko ) + removed_groups = self.add_ladder_move(location, board, ko) # check if the group at location can be captured group = board[ location ] if group.count_liberty == 1: - i = group_location_liberty( group, self.board_size ) + i = group_location_liberty(group, self.board_size) capture[ i ] = i # try a capture move for location_next in capture: capture_copy = capture.copy() - capture_copy.pop( location_next ) - if self.is_ladder_escape_move( board, ko, location_group, capture.copy(), location_next, maxDepth - 1, colour_group, colour_chase ): + capture_copy.pop(location_next) + if self.is_ladder_escape_move(board, ko, location_group, capture.copy(), location_next, maxDepth - 1, colour_group, colour_chase): # undo move - self.undo_ladder_move( location, removed_groups, ko_value, board, ko ) + self.undo_ladder_move(location, removed_groups, ko_value, board, ko) return 0 group = board[ location_group ] # try an escape move - for location_next in range( self.board_size ): + for location_next in range(self.board_size): if group.locations[ location_next ] == _LIBERTY: capture_copy = capture.copy() if location_next in capture_copy: - capture_copy.pop( location_next ) - if self.is_ladder_escape_move( board, ko, location_group, capture.copy(), location_next, maxDepth - 1, colour_group, colour_chase ): + capture_copy.pop(location_next) + if self.is_ladder_escape_move(board, ko, location_group, capture.copy(), location_next, maxDepth - 1, colour_group, colour_chase): # undo move - self.undo_ladder_move( location, removed_groups, ko_value, board, ko ) + self.undo_ladder_move(location, removed_groups, ko_value, board, ko) return 0 # no ladder escape found -> group is captured # undo move - self.undo_ladder_move( location, removed_groups, ko_value, board, ko ) + self.undo_ladder_move(location, removed_groups, ko_value, board, ko) return 1 @@ -1459,11 +1559,11 @@ cdef class GameState: ############################################################################ - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef char* get_groups_after( self ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef char* get_groups_after(self): """ - return a short array of size board_size * 3 representing + return a short array of size board_size * 3 representing STONES, LIBERTY, CAPTURE for every board location max count values are 100 @@ -1475,40 +1575,40 @@ cdef class GameState: cdef short i, location # initialize groups_after array - cdef char *groups_after = malloc( self.board_size * 3 * sizeof( char ) ) + cdef char *groups_after = malloc(self.board_size * 3 * sizeof(char)) if not groups_after: raise MemoryError() - #memset( groups_after, 0, self.board_size * 3 * sizeof( char ) ) + #memset(groups_after, 0, self.board_size * 3 * sizeof(char)) # create locations dictionary - cdef char *locations = malloc( self.board_size * sizeof( char ) ) + cdef char *locations = malloc(self.board_size * sizeof(char)) if not locations: raise MemoryError() # create captures dictionary - cdef char *captures = malloc( self.board_size * sizeof( char ) ) + cdef char *captures = malloc(self.board_size * sizeof(char)) if not captures: raise MemoryError() # create groups for all legal moves - for location in range( self.moves_legal.count ): - + for location in range(self.moves_legal.count): + # initialize both dictionaries to _FREE - memset( locations, _FREE, self.board_size * sizeof( char ) ) - memset( captures, _FREE, self.board_size * sizeof( char ) ) + memset(locations, _FREE, self.board_size * sizeof(char)) + memset(captures, _FREE, self.board_size * sizeof(char)) - self.get_group_after( groups_after, locations, captures, self.moves_legal.locations[ location ] ) + self.get_group_after(groups_after, locations, captures, self.moves_legal.locations[ location ]) - free( locations ) - free( captures ) + free(locations) + free(captures) return groups_after - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef list get_neighbor_locations( self ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef list get_neighbor_locations(self): """ generate list with 3x3 neighbor locations 0,1,2,3 are direct neighbor @@ -1519,21 +1619,21 @@ cdef class GameState: return [] - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef long get_hash_12d( self, short centre ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef long get_hash_12d(self, short centre): """ return hash for 12d star pattern around location """ # get 12d hash value and add current player colour - return self.generate_12d_hash( centre ) + self.player_current + return self.generate_12d_hash(centre) + self.player_current - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef long get_hash_3x3( self, short location ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef long get_hash_3x3(self, short location): """ return 3x3 pattern hash + current player """ @@ -1544,14 +1644,14 @@ cdef class GameState: return self.hash3x3[ location ] + self.player_current - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef char* get_ladder_escapes( self, int maxDepth ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef char* get_ladder_escapes(self, int maxDepth): """ return char array with size board_size every location represents a location on the board where: _FREE = no ladder escape - _STONE = ladder escape + _STONE = ladder escape """ cdef short i, location_group, location_move @@ -1562,14 +1662,14 @@ cdef class GameState: cdef short ko = self.ko # create char array representing the board - cdef char* escapes = malloc( self.board_size ) + cdef char* escapes = malloc(self.board_size) if not escapes: raise MemoryError() # set all locations to _FREE - memset( escapes, _FREE, self.board_size ) + memset(escapes, _FREE, self.board_size) # loop over all groups on board - for i in range( self.groups_list.count_groups ): + for i in range(self.groups_list.count_groups): group = self.groups_list.board_groups[ i ] @@ -1585,26 +1685,26 @@ cdef class GameState: if board is NULL: # create new Group pointer array as board - board = malloc( ( self.board_size + 1 ) * sizeof( Group* ) ) + board = malloc((self.board_size + 1) * sizeof(Group*)) if not self.board_groups: raise MemoryError() # as the ladder search does not change any excisting groups we can safely # duplicate the whole pointer array without duplicating all groups - memcpy( board, self.board_groups, ( self.board_size + 1 ) * sizeof( Group* ) ) + memcpy(board, self.board_groups, (self.board_size + 1) * sizeof(Group*)) - # get a dictionary with all possible capture groups -> surrounding groups + # get a dictionary with all possible capture groups -> surrounding groups # the ladder group can kill in order to escape ladder - move_capture = self.get_capture_moves( group, self.player_opponent, board ) - location_group = group_location_stone( group, self.board_size ) + move_capture = self.get_capture_moves(group, self.player_opponent, board) + location_group = group_location_stone(group, self.board_size) # check if any of the moves is an escape move - for location_move in range( self.board_size ): + for location_move in range(self.board_size): if group.locations[ location_move ] == _LIBERTY and escapes[ location_move ] == _FREE: # check if group can escape ladder by playing move - if self.is_ladder_escape_move( board, &ko, location_group, move_capture.copy(), location_move, maxDepth, self.player_current, self.player_opponent ): + if self.is_ladder_escape_move(board, &ko, location_group, move_capture.copy(), location_move, maxDepth, self.player_current, self.player_opponent): escapes[ location_move ] = _STONE @@ -1614,24 +1714,24 @@ cdef class GameState: if escapes[ location_move ] == _FREE: move_capture_copy = move_capture.copy() - move_capture_copy.pop( location_move ) + move_capture_copy.pop(location_move) # check if group can escape ladder by playing capture move - if self.is_ladder_escape_move( board, &ko, location_group, move_capture_copy, location_move, maxDepth, self.player_current, self.player_opponent ): + if self.is_ladder_escape_move(board, &ko, location_group, move_capture_copy, location_move, maxDepth, self.player_current, self.player_opponent): escapes[ location_move ] = _STONE # free temporary board if board is not NULL: - free( board ) + free(board) return escapes - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef char* get_ladder_captures( self, int maxDepth ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef char* get_ladder_captures(self, int maxDepth): """ return char array with size board_size every location represents a location on the board where: @@ -1646,14 +1746,14 @@ cdef class GameState: cdef short ko = self.ko # create char array representing the board - cdef char* captures = malloc( self.board_size ) + cdef char* captures = malloc(self.board_size) if not captures: raise MemoryError() # set all locations to _FREE - memset( captures, _FREE, self.board_size ) + memset(captures, _FREE, self.board_size) # loop over all groups on board - for i in range( self.groups_list.count_groups ): + for i in range(self.groups_list.count_groups): group = self.groups_list.board_groups[ i ] @@ -1669,33 +1769,33 @@ cdef class GameState: if board is NULL: # create new Group pointer array as board - board = malloc( ( self.board_size + 1 ) * sizeof( Group* ) ) + board = malloc((self.board_size + 1) * sizeof(Group*)) if not self.board_groups: raise MemoryError() # as the ladder search does not change any excisting groups we can safely # duplicate the whole pointer array without duplicating all groups - memcpy( board, self.board_groups, ( self.board_size + 1 ) * sizeof( Group* ) ) + memcpy(board, self.board_groups, (self.board_size + 1) * sizeof(Group*)) - # get a dictionary with all possible capture groups -> surrounding groups + # get a dictionary with all possible capture groups -> surrounding groups # the ladder group can kill in order to escape ladder - move_capture = self.get_capture_moves( group, self.player_current, board ) - location_group = group_location_stone( group, self.board_size ) + move_capture = self.get_capture_moves(group, self.player_current, board) + location_group = group_location_stone(group, self.board_size) # loop over all liberty - for location_move in range( self.board_size ): + for location_move in range(self.board_size): if group.locations[ location_move ] == _LIBERTY and captures[ location_move ] == _FREE: # check if move is ladder capture - if self.is_ladder_capture_move( board, &ko, location_group, move_capture.copy(), location_move, maxDepth, self.player_opponent, self.player_current ): + if self.is_ladder_capture_move(board, &ko, location_group, move_capture.copy(), location_move, maxDepth, self.player_opponent, self.player_current): captures[ location_move ] = _STONE # free temporary board if board is not NULL: - free( board ) + free(board) return captures @@ -1706,9 +1806,9 @@ cdef class GameState: ############################################################################ - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef void add_move( self, short location ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef void add_move(self, short location): """ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! Move should be legal! @@ -1724,63 +1824,65 @@ cdef class GameState: # detemine where captures should be added, black captures -> white stones # white captures -> black stones - # ( probably better to think of it as black stones captured, and white stones captured ) - cdef short* captures = ( &self.capture_white if ( self.player_current == _BLACK ) else &self.capture_black ) + # (probably better to think of it as black stones captured, and white stones captured) + cdef short* captures = (&self.capture_white if (self.player_current == _BLACK) else &self.capture_black) # add move to board - self.add_to_group( location, self.board_groups, &self.ko, captures ) + self.add_to_group(location, self.board_groups, &self.ko, captures) # switch player colour self.player_current = self.player_opponent - self.player_opponent = ( _BLACK if self.player_current == _WHITE else _WHITE ) + self.player_opponent = (_BLACK if self.player_current == _WHITE else _WHITE) # add move to history - locations_list_add_location_increment( self.moves_history, location ) + locations_list_add_location_increment(self.moves_history, location) # set moves_legal - self.set_moves_legal_list( self.moves_legal ) + self.set_moves_legal_list(self.moves_legal) # TODO # update zobrist - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef GameState new_state_add_move( self, short location ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef GameState new_state_add_move(self, short location): """ copy this gamestate and play move at location """ # create new gamestate, copy all data of self - state = GameState( copyState = self ) + state = GameState(copyState = self) # do move - state.add_move( location ) + state.add_move(location) return state - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef char get_winner_colour( self, int komi ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef float get_score(self, float komi): """ - Calculate score of board state and return player ID (1, -1, or 0 for tie) - corresponding to winner. Uses 'Area scoring'. + Calculate score of board state. Uses 'Area scoring'. http://senseis.xmp.net/?Passing#1 + + negative value indicates black win + positive value indicates white win """ cdef short location cdef char board_value - cdef int score_white = komi - cdef int score_black = 0 + cdef float score + score = -komi # lists to keep track of black and white eyes - cdef Locations_List* eyes_white = locations_list_new( self.board_size ) - cdef Locations_List* eyes_black = locations_list_new( self.board_size ) + cdef Locations_List* eyes_white = locations_list_new(self.board_size) + cdef Locations_List* eyes_black = locations_list_new(self.board_size) # loop over whole board - for location in range( self.board_size ): + for location in range(self.board_size): # get location colour board_value = self.board_groups[ location ].colour @@ -1788,36 +1890,53 @@ cdef class GameState: if board_value == _WHITE: # white stone - score_white += 1 + score -= 1 elif board_value == _BLACK: # black stone - score_black += 1 + score += 1 else: # empty location, check if it is an eye for black/white - if self.is_true_eye( location, eyes_black, _BLACK ): + if self.is_true_eye(location, eyes_black, _BLACK): - score_black += 1 - elif self.is_true_eye( location, eyes_white, _WHITE ): + score += 1 + elif self.is_true_eye(location, eyes_white, _WHITE): - score_white += 1 + score -= 1 # free eyes_black and eyes_white - locations_list_destroy( eyes_black ) - locations_list_destroy( eyes_white ) - + locations_list_destroy(eyes_black) + locations_list_destroy(eyes_white) + # substract passes # http://senseis.xmp.net/?Passing#1 - score_black -= self.passes_black - score_white -= self.passes_white + score -= self.passes_black + score += self.passes_white + + return score + + + @cython.boundscheck(False) + @cython.wraparound(False) + cdef char get_winner_colour(self, float komi): + """ + Calculate score of board state and return player ID (1, -1, or 0 for tie) + corresponding to winner. Uses 'Area scoring'. + + http://senseis.xmp.net/?Passing#1 + """ + + cdef float score + + score = self.get_score(komi) # check if black has won, tie -> white wins - if score_black > score_white: + if score > 0: # black wins return _BLACK - + # white wins return _WHITE @@ -1828,9 +1947,9 @@ cdef class GameState: ############################################################################ - @cython.boundscheck( False ) - @cython.wraparound( False ) - def do_move( self, action, color=None ): + @cython.boundscheck(False) + @cython.wraparound(False) + def do_move(self, action, color=None): """ Play stone at action=(x,y). If it is a legal move, current_player switches to the opposite color @@ -1839,7 +1958,7 @@ cdef class GameState: if action is _PASS: - locations_list_add_location_increment( self.moves_history, _PASS ) + locations_list_add_location_increment(self.moves_history, _PASS) if self.player_opponent == _BLACK: @@ -1850,47 +1969,48 @@ cdef class GameState: # change player colour self.player_current = self.player_opponent - self.player_opponent = ( _BLACK if self.player_current == _WHITE else _WHITE ) + self.player_opponent = (_BLACK if self.player_current == _WHITE else _WHITE) # legal moves have to be recalculated - self.set_moves_legal_list( self.moves_legal ) + self.ko = _PASS + self.set_moves_legal_list(self.moves_legal) return if color is not None: self.player_current = color - self.player_opponent = ( _BLACK if self.player_current == _WHITE else _WHITE ) + self.player_opponent = (_BLACK if self.player_current == _WHITE else _WHITE) # legal moves have to be recalculated - self.set_moves_legal_list( self.moves_legal ) + self.set_moves_legal_list(self.moves_legal) cdef int x, y, i cdef short location - ( x, y ) = action - location = self.calculate_board_location( y, x ) + (x, y) = action + location = self.calculate_board_location(y, x) # check if move is legal - if not self.is_legal_move( location, self.board_groups, self.ko ): + if not self.is_legal_move(location, self.board_groups, self.ko): - print( self.player_current ) - print( location ) - print( self.get_legal_moves(include_eyes=True) ) - print( "" ) - print( self.get_legal_moves(include_eyes=False) ) + print(self.player_current) + print(location) + print(self.get_legal_moves(include_eyes=True)) + print("") + print(self.get_legal_moves(include_eyes=False)) self.printer() - raise IllegalMove( str( action ) ) + raise IllegalMove(str(action)) # add move - self.add_move( location ) + self.add_move(location) return True - @cython.boundscheck( False ) - @cython.wraparound( False ) - def get_legal_moves( self, include_eyes = True ): + @cython.boundscheck(False) + @cython.wraparound(False) + def get_legal_moves(self, include_eyes = True): """ - return a list with all legal moves ( in/excluding eyes ) + return a list with all legal moves (in/excluding eyes) """ cdef int i @@ -1905,32 +2025,45 @@ cdef class GameState: moves_list = self.get_sensible_moves() - for i in range( moves_list.count ): + for i in range(moves_list.count): - moves.append( self.calculate_tuple_location( moves_list.locations[ i ] ) ) + moves.append(self.calculate_tuple_location(moves_list.locations[ i ])) if not include_eyes: # free sensible_moves - locations_list_destroy( moves_list ) + locations_list_destroy(moves_list) return moves - @cython.boundscheck( False ) - @cython.wraparound( False ) - def get_winner( self, char komi = 6 ): + @cython.boundscheck(False) + @cython.wraparound(False) + def get_winner(self, float komi = 7.5): """ - Calculate score of board state and return player ID ( 1, -1, or 0 for tie ) + Calculate score of board state and return player ID (1, -1, or 0 for tie) corresponding to winner. Uses 'Area scoring'. """ - return self.get_winner_colour( komi ) + return self.get_winner_colour(komi) + + + @cython.boundscheck(False) + @cython.wraparound(False) + def get_board_count(self, float komi = 7.5): + """ + Calculate score of board state + + where negative value indicates white win + and positive value indicates black win + """ + + return self.get_score(komi) - @cython.boundscheck( False ) - @cython.wraparound( False ) - def place_handicap_stone( self, action, color=_BLACK ): + @cython.boundscheck(False) + @cython.wraparound(False) + def place_handicap_stone(self, action, color=_BLACK): """ add handicap stones given by a list of tuples in list handicap """ @@ -1938,23 +2071,23 @@ cdef class GameState: cdef short fake_capture self.player_current = color - self.player_opponent = ( _BLACK if self.player_current == _WHITE else _WHITE ) + self.player_opponent = (_BLACK if self.player_current == _WHITE else _WHITE) cdef char x, y cdef short location - ( x, y ) = action - location = self.calculate_board_location( y, x ) + (x, y) = action + location = self.calculate_board_location(y, x) # add move - self.add_to_group( location, self.board_groups, &self.ko, &fake_capture ) + self.add_to_group(location, self.board_groups, &self.ko, &fake_capture) # set legal moves - self.set_moves_legal_list( self.moves_legal ) + self.set_moves_legal_list(self.moves_legal) - @cython.boundscheck( False ) - @cython.wraparound( False ) - def place_handicaps( self, list handicap ): + @cython.boundscheck(False) + @cython.wraparound(False) + def place_handicaps(self, list handicap): """ TODO save handicap stones list?? -> seems not usefull as we also have to copy them add handicap stones given by a list of tuples in list handicap @@ -1969,23 +2102,23 @@ cdef class GameState: for action in handicap: - ( x, y ) = action - location = self.calculate_board_location( y, x ) - self.add_to_group( location, self.board_groups, &self.ko, &fake_capture ) + (x, y) = action + location = self.calculate_board_location(y, x) + self.add_to_group(location, self.board_groups, &self.ko, &fake_capture) # active player colour reverses self.player_current = _WHITE self.player_opponent = _BLACK - + # set legal moves - self.set_moves_legal_list( self.moves_legal ) + self.set_moves_legal_list(self.moves_legal) - @cython.boundscheck( False ) - @cython.wraparound( False ) - def is_end_of_game( self ): + @cython.boundscheck(False) + @cython.wraparound(False) + def is_end_of_game(self): """ - + """ if self.moves_history.count > 1: @@ -1996,9 +2129,9 @@ cdef class GameState: return False - @cython.boundscheck( False ) - @cython.wraparound( False ) - def is_legal( self, action ): + @cython.boundscheck(False) + @cython.wraparound(False) + def is_legal(self, action): """ determine if the given action (x,y tuple) is a legal move note: we only check ko, not superko at this point (TODO!) @@ -2007,30 +2140,30 @@ cdef class GameState: cdef int i cdef char x, y cdef short location - ( x, y ) = action + (x, y) = action # check outside board if x < 0 or y < 0 or x >= self.size or y >= self.size: return False # calculate location - location = self.calculate_board_location( y, x ) + location = self.calculate_board_location(y, x) - if self.is_legal_move( location, self.board_groups, self.ko ): + if self.is_legal_move(location, self.board_groups, self.ko): return True return False - @cython.boundscheck( False ) - @cython.wraparound( False ) - def copy( self ): + @cython.boundscheck(False) + @cython.wraparound(False) + def copy(self): """ get a copy of this Game state """ - return GameState( copyState = self ) + return GameState(copyState = self) ############################################################################ @@ -2038,8 +2171,8 @@ cdef class GameState: # # ############################################################################ - @cython.boundscheck( False ) - @cython.wraparound( False ) + @cython.boundscheck(False) + @cython.wraparound(False) def get_current_player(self): """ Returns the color of the player who will make the next move. @@ -2048,20 +2181,20 @@ cdef class GameState: return self.player_current - @cython.boundscheck( False ) - @cython.wraparound( False ) - def set_current_player( self, colour ): + @cython.boundscheck(False) + @cython.wraparound(False) + def set_current_player(self, colour): """ change current player colour """ self.player_current = colour - self.player_opponent = ( _BLACK if self.player_current == _WHITE else _WHITE ) + self.player_opponent = (_BLACK if self.player_current == _WHITE else _WHITE) - @cython.boundscheck( False ) - @cython.wraparound( False ) - def get_history( self ): + @cython.boundscheck(False) + @cython.wraparound(False) + def get_history(self): """ return history as a list of tuples """ @@ -2070,24 +2203,31 @@ cdef class GameState: cdef short location cdef list history = [] - for i in range( self.moves_history.count ): + for i in range(self.moves_history.count): location = self.moves_history.locations[ i ] if location != _PASS: - - history.append( self.calculate_tuple_location( location ) ) - else: - history.append( _PASS ) + history.append(self.calculate_tuple_location(location)) + else: + history.append(_PASS) return history + @cython.boundscheck(False) + @cython.wraparound(False) + def get_history_size(self): + """ + return history size + """ - @cython.boundscheck( False ) - @cython.wraparound( False ) - def get_captures_black( self ): + return self.moves_history.count + + @cython.boundscheck(False) + @cython.wraparound(False) + def get_captures_black(self): """ return amount of black stones captures """ @@ -2095,9 +2235,9 @@ cdef class GameState: return self.capture_black - @cython.boundscheck( False ) - @cython.wraparound( False ) - def get_captures_white( self ): + @cython.boundscheck(False) + @cython.wraparound(False) + def get_captures_white(self): """ return amount of white stones captured """ @@ -2105,9 +2245,9 @@ cdef class GameState: return self.capture_white - @cython.boundscheck( False ) - @cython.wraparound( False ) - def get_ko_location( self ): + @cython.boundscheck(False) + @cython.wraparound(False) + def get_ko_location(self): """ return ko location """ @@ -2118,9 +2258,9 @@ cdef class GameState: return self.ko - @cython.boundscheck( False ) - @cython.wraparound( False ) - def is_board_equal( self, GameState state ): + @cython.boundscheck(False) + @cython.wraparound(False) + def is_board_equal(self, GameState state): """ verify that self and state board layout are the same """ @@ -2134,11 +2274,11 @@ cdef class GameState: return True - @cython.boundscheck( False ) - @cython.wraparound( False ) - def is_liberty_equal( self, GameState state ): + @cython.boundscheck(False) + @cython.wraparound(False) + def is_liberty_equal(self, GameState state): """ - verify that self and state liberty counts are the same + verify that self and state liberty counts are the same """ for x in range(self.board_size): @@ -2150,9 +2290,9 @@ cdef class GameState: return True - @cython.boundscheck( False ) - @cython.wraparound( False ) - def is_ladder_escape( self, action ): + @cython.boundscheck(False) + @cython.wraparound(False) + def is_ladder_escape(self, action): """ check if playing action is a ladder escape """ @@ -2161,24 +2301,24 @@ cdef class GameState: cdef char x, y cdef short location - ( x, y ) = action - location = self.calculate_board_location( y, x ) + (x, y) = action + location = self.calculate_board_location(y, x) - cdef char* escapes = self.get_ladder_escapes( 80 ) + cdef char* escapes = self.get_ladder_escapes(80) if escapes[ location ] != _FREE: value = True # free escapes - free( escapes ) + free(escapes) return value - @cython.boundscheck( False ) - @cython.wraparound( False ) - def is_ladder_capture( self, action ): + @cython.boundscheck(False) + @cython.wraparound(False) + def is_ladder_capture(self, action): """ check if playing action is a ladder capture """ @@ -2187,24 +2327,24 @@ cdef class GameState: cdef char x, y cdef short location - ( x, y ) = action - location = self.calculate_board_location( y, x ) + (x, y) = action + location = self.calculate_board_location(y, x) - cdef char* captures = self.get_ladder_captures( 80 ) + cdef char* captures = self.get_ladder_captures(80) if captures[ location ] != _FREE: value = True # free captures - free( captures ) + free(captures) return value - @cython.boundscheck( False ) - @cython.wraparound( False ) - def is_eye( self, action, color ): + @cython.boundscheck(False) + @cython.wraparound(False) + def is_eye(self, action, color): """ check if location action is a eye for player color """ @@ -2213,25 +2353,25 @@ cdef class GameState: cdef char x, y cdef short location - ( x, y ) = action - location = self.calculate_board_location( y, x ) + (x, y) = action + location = self.calculate_board_location(y, x) # checking all games in the KGS database found a max of 15eyes in one state # 25 seems a safe bet - cdef Locations_List* eyes = locations_list_new( 80 ) + cdef Locations_List* eyes = locations_list_new(80) - if self.is_true_eye( location, eyes, color ): + if self.is_true_eye(location, eyes, color): value = True - locations_list_destroy( eyes ) + locations_list_destroy(eyes) return value - @cython.boundscheck( False ) - @cython.wraparound( False ) - def get_liberty( self ): + @cython.boundscheck(False) + @cython.wraparound(False) + def get_liberty(self): """ get numpy array with all liberty counts """ @@ -2242,16 +2382,16 @@ cdef class GameState: for y in range(self.size): - location = self.calculate_board_location( y, x ) + location = self.calculate_board_location(y, x) liberty[x, y] = self.board_groups[location].count_liberty return liberty - @cython.boundscheck( False ) - @cython.wraparound( False ) - def get_board( self ): + @cython.boundscheck(False) + @cython.wraparound(False) + def get_board(self): """ get numpy array with board locations """ @@ -2262,16 +2402,16 @@ cdef class GameState: for y in range(self.size): - location = self.calculate_board_location( y, x ) + location = self.calculate_board_location(y, x) board[x, y] = self.board_groups[location].colour return board - @cython.boundscheck( False ) - @cython.wraparound( False ) - def get_size( self ): + @cython.boundscheck(False) + @cython.wraparound(False) + def get_size(self): """ return size """ @@ -2279,9 +2419,20 @@ cdef class GameState: return self.size - @cython.boundscheck( False ) - @cython.wraparound( False ) - def get_handicap( self ): + @cython.boundscheck(False) + @cython.wraparound(False) + def get_handicaps(self): + """ + TODO + return list with handicap stones placed + """ + + return [] + + + @cython.boundscheck(False) + @cython.wraparound(False) + def get_handicap(self): """ TODO return list with handicap stones placed @@ -2290,9 +2441,9 @@ cdef class GameState: return [] - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef Locations_List* get_sensible_moves( self ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef Locations_List* get_sensible_moves(self): """ only used for def get_legal_moves """ @@ -2301,27 +2452,27 @@ cdef class GameState: # create list with at least #moves_legal.count locations # there can never be more sensible moves - cdef Locations_List* sensible_moves = locations_list_new( self.moves_legal.count ) + cdef Locations_List* sensible_moves = locations_list_new(self.moves_legal.count) # checking all games in the KGS database found a max of 17eyes in one state # 25 seems a safe bet - cdef Locations_List* eyes = locations_list_new( 80 ) + cdef Locations_List* eyes = locations_list_new(80) cdef int i cdef short location - for i in range( self.moves_legal.count ): + for i in range(self.moves_legal.count): location = self.moves_legal.locations[ i ] - if not self.is_true_eye( location, eyes, self.player_current ): + if not self.is_true_eye(location, eyes, self.player_current): # TODO find out why locations_list_add_location is 2x slower - #locations_list_add_location( sensible_moves, location ) + #locations_list_add_location(sensible_moves, location) sensible_moves.locations[ sensible_moves.count ] = location sensible_moves.count += 1 - locations_list_destroy( eyes ) + locations_list_destroy(eyes) return sensible_moves @@ -2331,151 +2482,151 @@ cdef class GameState: # # ############################################################################ - def validate_equal( self, GameState state ): + def validate_equal(self, GameState state): cdef int i value = True - for i in range( self.board_size ): + for i in range(self.board_size): if self.ko != state.ko: - print( "ko " + str( self.ko )) + print("ko " + str(self.ko)) return False if self.board_groups[ i ].colour != state.board_groups[ i ].colour: - print( "board " + str( i )) + print("board " + str(i)) value = False if self.board_groups[ i ].count_stones != state.board_groups[ i ].count_stones: - print( "stones " + str( i )) - print( str( self.board_groups[ i ].count_stones ) + " " + str( state.board_groups[ i ].count_stones )) + print("stones " + str(i)) + print(str(self.board_groups[ i ].count_stones) + " " + str(state.board_groups[ i ].count_stones)) value = False if self.board_groups[ i ].count_liberty != state.board_groups[ i ].count_liberty: - print( "liberty " + str( i ) + " " + str( state.board_groups[ i ].colour ) + " " + str( state.player_current ) ) - print( str( self.board_groups[ i ].count_liberty ) + " " + str( state.board_groups[ i ].count_liberty ) ) + print("liberty " + str(i) + " " + str(state.board_groups[ i ].colour) + " " + str(state.player_current)) + print(str(self.board_groups[ i ].count_liberty) + " " + str(state.board_groups[ i ].count_liberty)) value = False return value - def printer( self ): - print( "" ) - for i in range( self.size ): - A = str( i ) + " " - for j in range( self.size ): + def printer(self): + print("") + for i in range(self.size): + A = str(i) + " " + for j in range(self.size): B = 0 if self.board_groups[ j + i * self.size ].colour == _BLACK: B = 'B' elif self.board_groups[ j + i * self.size ].colour == _WHITE: B = 'W' - A += str( B ) + " " - print( A ) + A += str(B) + " " + print(A) # do move, throw exception when outside the board - # action has to be a ( x, y ) tuple - # this function should be used from Python environment, + # action has to be a (x, y) tuple + # this function should be used from Python environment, # use add_move from C environment for speed - def do_ladder_move( self, action ): + def do_ladder_move(self, action): """ - + """ # do move, return true if legal, return false if not cdef int x, y cdef short location - ( x, y ) = action - location = self.calculate_board_location( y, x ) - self.add_ladder_move( location, self.board_groups, &self.ko ) + (x, y) = action + location = self.calculate_board_location(y, x) + self.add_ladder_move(location, self.board_groups, &self.ko) - self.set_moves_legal_list( self.moves_legal ) + self.set_moves_legal_list(self.moves_legal) return True # do move, throw exception when outside the board - # action has to be a ( x, y ) tuple - # this function should be used from Python environment, + # action has to be a (x, y) tuple + # this function should be used from Python environment, # use add_move from C environment for speed - def do_and_undo_ladder_move( self, action ): + def do_and_undo_ladder_move(self, action): """ - + """ # do move, return true if legal, return false if not cdef int x, y cdef short location, ko - ( x, y ) = action - location = self.calculate_board_location( y, x ) + (x, y) = action + location = self.calculate_board_location(y, x) cdef Groups_List* removed_groups ko = self.ko - removed_groups = self.add_ladder_move( location, self.board_groups, &self.ko ) + removed_groups = self.add_ladder_move(location, self.board_groups, &self.ko) - self.undo_ladder_move( location, removed_groups, ko, self.board_groups, &self.ko ) + self.undo_ladder_move(location, removed_groups, ko, self.board_groups, &self.ko) return True - @cython.boundscheck( False ) - @cython.wraparound( False ) - cdef test( self ): + @cython.boundscheck(False) + @cython.wraparound(False) + cdef test(self): - print( "empty" ) + print("empty") - cdef test_cpp_fast( self ): + cdef test_cpp_fast(self): - print( "empty" ) + print("empty") - def test_cpp( self ): + def test_cpp(self): - print( "cpp" ) + print("cpp") self.test_cpp_fast() - print( "cpp" ) + print("cpp") - def test_game_speed( self, list moves ): + def test_game_speed(self, list moves): cdef short location for location in moves: - self.add_move( location ) + self.add_move(location) - def convert_moves( self, list moves ): + def convert_moves(self, list moves): cdef list converted_moves = [] cdef int x, y cdef short location for loc in moves: - ( x, y ) = loc - location = self.calculate_board_location( y, x ) - converted_moves.append( location ) + (x, y) = loc + location = self.calculate_board_location(y, x) + converted_moves.append(location) return converted_moves - def millis( self, start_time ): + def millis(self, start_time): dt = datetime.now() - start_time ms = (dt.days * 24 * 60 * 60 + dt.seconds) * 1000 + dt.microseconds / 1000.0 return ms - def test_stuff( self ): + def test_stuff(self): cdef long a, h, i, j, k cdef short b, e if not neighbor: print("NOT") - for i in range( self.board_size * 4 ): + for i in range(self.board_size * 4): if neighbor[ i ] != self.neighbor[ i ]: print("NOT EQUAL") return @@ -2483,7 +2634,7 @@ cdef class GameState: if not neighbor3x3: print("NOT") - for i in range( self.board_size * 8 ): + for i in range(self.board_size * 8): if neighbor3x3[ i ] != self.neighbor3x3[ i ]: print("NOT EQUAL") return @@ -2491,7 +2642,7 @@ cdef class GameState: if not neighbor12d: print("NOT") - for i in range( self.board_size * 12 ): + for i in range(self.board_size * 12): if neighbor12d[ i ] != self.neighbor12d[ i ]: print("NOT EQUAL") return @@ -2502,10 +2653,10 @@ cdef class GameState: b = 0 start = datetime.now() - for h in range( amount ): - for i in range( amount ): + for h in range(amount): + for i in range(amount): - for a in range( self.board_size * 12 ): + for a in range(self.board_size * 12): b = neighbor12d[ a ] @@ -2518,17 +2669,17 @@ cdef class GameState: dt = end - start start = dt.microseconds - print( "glob " + str( start ) + " " + str( b ) ) + print("glob " + str(start) + " " + str(b)) e = 1 b = 0 start = datetime.now() - for h in range( amount ): - for i in range( amount ): + for h in range(amount): + for i in range(amount): - for a in range( self.board_size * 12 ): + for a in range(self.board_size * 12): b = self.neighbor12d[ a ] @@ -2541,12 +2692,12 @@ cdef class GameState: dt = end - start start = dt.microseconds - print( "self " + str( start ) + " " + str( b ) ) - + print("self " + str(start) + " " + str(b)) + - @cython.boundscheck( False ) - @cython.wraparound( False ) - def set_stuff( self ): + @cython.boundscheck(False) + @cython.wraparound(False) + def set_stuff(self): self.get_sensible_moves() diff --git a/AlphaGo/go_data.pxd b/AlphaGo/go_data.pxd index ebe8bbfea..98e240afc 100644 --- a/AlphaGo/go_data.pxd +++ b/AlphaGo/go_data.pxd @@ -22,7 +22,7 @@ cdef char _FREE cdef char _STONE cdef char _LIBERTY cdef char _CAPTURE -cdef char _LEGAL +cdef char _LEGAL cdef char _EYE # value used to generate pattern hashes @@ -36,7 +36,7 @@ cdef char _HASHVALUE """ a struct has the advantage of being completely C, no python wrapper so - no python overhead. + no python overhead. compared to a cdef class a struct has some advantages: - C only, no python overhead @@ -44,7 +44,7 @@ cdef char _HASHVALUE - smaller in size drawbacks - - have to be Malloc created and freed after use -> memory leak + - have to be Malloc created and freed after use -> memory leak - no convenient functions available - no boundchecks """ @@ -62,7 +62,7 @@ cdef struct Groups_List: short count_groups short size -# struct to store a list of short ( board locations ) +# struct to store a list of short (board locations) cdef struct Locations_List: short *locations short count @@ -74,23 +74,23 @@ cdef struct Locations_List: # # ############################################################################ -cdef Group* group_new( char colour, short size ) +cdef Group* group_new(char colour, short size) """ create new struct Group with locations #size char long initialized to FREE """ -cdef Group* group_duplicate( Group* group, short size ) +cdef Group* group_duplicate(Group* group, short size) """ create new struct Group initialized as a duplicate of group """ -cdef void group_destroy( Group* group ) +cdef void group_destroy(Group* group) """ free memory location of group and locations """ -cdef void group_add_stone( Group* group, short location ) +cdef void group_add_stone(Group* group, short location) """ update location as STONE update liberty count if it was a liberty location @@ -98,18 +98,18 @@ cdef void group_add_stone( Group* group, short location ) n.b. stone count is not incremented if a stone was present already """ -cdef void group_remove_stone( Group* group, short location ) +cdef void group_remove_stone(Group* group, short location) """ update location as FREE update stone count if it was a stone location """ -cdef short group_location_stone( Group* group, short size ) +cdef short group_location_stone(Group* group, short size) """ return first location where a STONE is located """ -cdef void group_add_liberty( Group* group, short location ) +cdef void group_add_liberty(Group* group, short location) """ update location as LIBERTY update liberty count if it was a FREE location @@ -117,7 +117,7 @@ cdef void group_add_liberty( Group* group, short location ) n.b. liberty count is not incremented if a stone was present already """ -cdef void group_remove_liberty( Group* group, short location ) +cdef void group_remove_liberty(Group* group, short location) """ update location as FREE update liberty count if it was a LIBERTY location @@ -125,7 +125,7 @@ cdef void group_remove_liberty( Group* group, short location ) n.b. liberty count is not decremented if location is a FREE location """ -cdef short group_location_liberty( Group* group, short size ) +cdef short group_location_liberty(Group* group, short size) """ return location where a LIBERTY is located """ @@ -135,24 +135,24 @@ cdef short group_location_liberty( Group* group, short size ) # # ############################################################################ -cdef Groups_List* groups_list_new( short size ) +cdef Groups_List* groups_list_new(short size) """ create new struct Groups_List with locations #size Group* long and count_groups set to 0 """ -cdef void groups_list_add( Group* group, Groups_List* groups_list ) +cdef void groups_list_add(Group* group, Groups_List* groups_list) """ add group to list and increment groups count """ -cdef void groups_list_add_unique( Group* group, Groups_List* groups_list ) +cdef void groups_list_add_unique(Group* group, Groups_List* groups_list) """ check if a group is already in the list, return if so add group to list if not """ -cdef void groups_list_remove( Group* group, Groups_List* groups_list ) +cdef void groups_list_remove(Group* group, Groups_List* groups_list) """ remove group from list and decrement groups count """ @@ -162,34 +162,34 @@ cdef void groups_list_remove( Group* group, Groups_List* groups_list ) # # ############################################################################ -cdef Locations_List* locations_list_new( short size ) +cdef Locations_List* locations_list_new(short size) """ create new struct Locations_List with locations #size short long and count set to 0 """ -cdef void locations_list_destroy( Locations_List* locations_list ) +cdef void locations_list_destroy(Locations_List* locations_list) """ free memory location of locations_list and locations """ -cdef void locations_list_remove_location( Locations_List* locations_list, short location ) +cdef void locations_list_remove_location(Locations_List* locations_list, short location) """ remove location from list """ -cdef void locations_list_add_location( Locations_List* locations_list, short location ) +cdef void locations_list_add_location(Locations_List* locations_list, short location) """ add location to list and increment count """ -cdef void locations_list_add_location_increment( Locations_List* locations_list, short location ) +cdef void locations_list_add_location_increment(Locations_List* locations_list, short location) """ check if list can hold one more location, resize list if not add location to list and increment count """ -cdef void locations_list_add_location_unique( Locations_List* locations_list, short location ) +cdef void locations_list_add_location_unique(Locations_List* locations_list, short location) """ check if location is present in list, return if so add location to list if not @@ -200,41 +200,41 @@ cdef void locations_list_add_location_unique( Locations_List* locations_list, sh # # ############################################################################ -cdef short calculate_board_location( char x, char y, char size ) +cdef short calculate_board_location(char x, char y, char size) """ return location on board no checks on outside board x = columns - y = rows + y = rows """ -cdef short calculate_board_location_or_border( char x, char y, char size ) +cdef short calculate_board_location_or_border(char x, char y, char size) """ return location on board or borderlocation - board locations = [ 0, size * size ) + board locations = [ 0, size * size) border location = size * size x = columns y = rows """ -cdef short* get_neighbors( char size ) +cdef short* get_neighbors(char size) """ create array for every board location with all 4 direct neighbour locations neighbor order: left - right - above - below - -1 x + -1 x x x - +1 x + +1 x order: - -1 2 + -1 2 0 1 - +1 3 + +1 3 - TODO neighbors is obsolete as neighbor3x3 contains the same values + TODO neighbors is obsolete as neighbor3x3 contains the same values """ -cdef short* get_3x3_neighbors( char size ) +cdef short* get_3x3_neighbors(char size) """ create for every board location array with all 8 surrounding neighbour locations neighbor order: above middle - middle left - middle right - below middle @@ -242,18 +242,18 @@ cdef short* get_3x3_neighbors( char size ) this order is more useful as it separates neighbors and then diagonals -1 xxx x x - +1 xxx + +1 xxx order: -1 405 1 2 - +1 637 + +1 637 0-3 contains neighbors 4-7 contains diagonals """ -cdef short* get_12d_neighbors( char size ) +cdef short* get_12d_neighbors(char size) """ create array for every board location with 12d star neighbour locations neighbor order: top star tip @@ -262,16 +262,16 @@ cdef short* get_12d_neighbors( char size ) below left - below middle - below right below star tip - -2 x + -2 x -1 xxx xx xx +1 xxx - +2 x + +2 x order: - -2 0 + -2 0 -1 123 45 67 +1 89a - +2 b + +2 b """ diff --git a/AlphaGo/go_data.pyx b/AlphaGo/go_data.pyx index 7d8f3f0ed..87e779194 100644 --- a/AlphaGo/go_data.pyx +++ b/AlphaGo/go_data.pyx @@ -67,7 +67,7 @@ cdef struct Groups_List: Group **board_groups short count_groups -# struct to store a list of short ( board locations ) +# struct to store a list of short (board locations) cdef struct Locations_List: short *locations short count @@ -79,9 +79,9 @@ cdef struct Locations_List: ############################################################################ -@cython.boundscheck( False ) -@cython.wraparound( False ) -cdef Group* group_new( char colour, short size ): +@cython.boundscheck(False) +@cython.wraparound(False) +cdef Group* group_new(char colour, short size): """ create new struct Group with locations #size char long initialized to FREE @@ -90,12 +90,12 @@ cdef Group* group_new( char colour, short size ): cdef int i # allocate memory for Group - cdef Group *group = malloc( sizeof( Group ) ) + cdef Group *group = malloc(sizeof(Group)) if not group: raise MemoryError() # allocate memory for array locations - group.locations = malloc( size ) + group.locations = malloc(size) if not group.locations: raise MemoryError() @@ -105,14 +105,14 @@ cdef Group* group_new( char colour, short size ): group.colour = colour # initialize locations with FREE - memset( group.locations, _FREE, size ) + memset(group.locations, _FREE, size) return group -@cython.boundscheck( False ) -@cython.wraparound( False ) -cdef Group* group_duplicate( Group* group, short size ): +@cython.boundscheck(False) +@cython.wraparound(False) +cdef Group* group_duplicate(Group* group, short size): """ create new struct Group initialized as a duplicate of group """ @@ -120,12 +120,12 @@ cdef Group* group_duplicate( Group* group, short size ): cdef int i # allocate memory for Group - cdef Group *duplicate = malloc( sizeof( Group ) ) + cdef Group *duplicate = malloc(sizeof(Group)) if not duplicate: raise MemoryError() # allocate memory for array locations - duplicate.locations = malloc( size ) + duplicate.locations = malloc(size) if not duplicate.locations: raise MemoryError() @@ -136,14 +136,14 @@ cdef Group* group_duplicate( Group* group, short size ): # duplicate locations array in memory # memcpy is optimized to do this quickly - memcpy( duplicate.locations, group.locations, size ) + memcpy(duplicate.locations, group.locations, size) return duplicate -@cython.boundscheck( False ) -@cython.wraparound( False ) -cdef void group_destroy( Group* group ): +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void group_destroy(Group* group): """ free memory location of group and locations """ @@ -155,15 +155,15 @@ cdef void group_destroy( Group* group ): if group.locations is not NULL: # free locations - free( group.locations ) + free(group.locations) # free group - free( group ) + free(group) -@cython.boundscheck( False ) -@cython.wraparound( False ) -cdef void group_add_stone( Group* group, short location ): +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void group_add_stone(Group* group, short location): """ update location as STONE update liberty count if it was a liberty location @@ -186,9 +186,9 @@ cdef void group_add_stone( Group* group, short location ): group.locations[ location ] = _STONE -@cython.boundscheck( False ) -@cython.wraparound( False ) -cdef void group_remove_stone( Group* group, short location ): +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void group_remove_stone(Group* group, short location): """ update location as FREE update stone count if it was a stone location @@ -202,24 +202,24 @@ cdef void group_remove_stone( Group* group, short location ): group.locations[ location ] = _FREE -@cython.boundscheck( False ) -@cython.wraparound( False ) -cdef short group_location_stone( Group* group, short size ): +@cython.boundscheck(False) +@cython.wraparound(False) +cdef short group_location_stone(Group* group, short size): """ return first location where a STONE is located """ - # memchr is a in memory search function, it starts searching at + # memchr is a in memory search function, it starts searching at # pointer location #group.locations for a max of size continous bytes untill # a location with value _STONE is found -> returns a pointer to this location # when this pointer location is substracted with pointer #group.locations # the location is calculated where a stone is - return ( memchr( group.locations, _STONE, size ) - group.locations ) + return (memchr(group.locations, _STONE, size) - group.locations) -@cython.boundscheck( False ) -@cython.wraparound( False ) -cdef void group_add_liberty( Group* group, short location ): +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void group_add_liberty(Group* group, short location): """ update location as LIBERTY update liberty count if it was a FREE location @@ -235,9 +235,9 @@ cdef void group_add_liberty( Group* group, short location ): group.locations[ location ] = _LIBERTY -@cython.boundscheck( False ) -@cython.wraparound( False ) -cdef void group_remove_liberty( Group* group, short location ): +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void group_remove_liberty(Group* group, short location): """ update location as FREE update liberty count if it was a LIBERTY location @@ -253,19 +253,19 @@ cdef void group_remove_liberty( Group* group, short location ): group.locations[ location ] = _FREE -@cython.boundscheck( False ) -@cython.wraparound( False ) -cdef short group_location_liberty( Group* group, short size ): +@cython.boundscheck(False) +@cython.wraparound(False) +cdef short group_location_liberty(Group* group, short size): """ return location where a LIBERTY is located """ - # memchr is a in memory search function, it starts searching at + # memchr is a in memory search function, it starts searching at # pointer location #group.locations for a max of size continous bytes untill # a location with value _LIBERTY is found -> returns a pointer to this location # when this pointer location is substracted with pointer #group.locations # the location is calculated where a liberty is - return ( memchr(group.locations, _LIBERTY, size ) - group.locations ) + return (memchr(group.locations, _LIBERTY, size) - group.locations) ############################################################################ @@ -274,9 +274,9 @@ cdef short group_location_liberty( Group* group, short size ): ############################################################################ -@cython.boundscheck( False ) -@cython.wraparound( False ) -cdef Groups_List* groups_list_new( short size ): +@cython.boundscheck(False) +@cython.wraparound(False) +cdef Groups_List* groups_list_new(short size): """ create new struct Groups_List with locations #size Group* long and count_groups set to 0 @@ -284,11 +284,11 @@ cdef Groups_List* groups_list_new( short size ): cdef Groups_List* list_new - list_new = malloc( sizeof( Groups_List ) ) + list_new = malloc(sizeof(Groups_List)) if not list_new: raise MemoryError() - list_new.board_groups = malloc( size * sizeof( Group* ) ) + list_new.board_groups = malloc(size * sizeof(Group*)) if not list_new.board_groups: raise MemoryError() @@ -297,9 +297,9 @@ cdef Groups_List* groups_list_new( short size ): return list_new -@cython.boundscheck( False ) -@cython.wraparound( False ) -cdef void groups_list_add( Group* group, Groups_List* groups_list ): +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void groups_list_add(Group* group, Groups_List* groups_list): """ add group to list and increment groups count """ @@ -308,9 +308,9 @@ cdef void groups_list_add( Group* group, Groups_List* groups_list ): groups_list.count_groups += 1 -@cython.boundscheck( False ) -@cython.wraparound( False ) -cdef void groups_list_add_unique( Group* group, Groups_List* groups_list ): +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void groups_list_add_unique(Group* group, Groups_List* groups_list): """ check if a group is already in the list, return if so add group to list if not @@ -319,7 +319,7 @@ cdef void groups_list_add_unique( Group* group, Groups_List* groups_list ): cdef int i # loop over array - for i in range( groups_list.count_groups ): + for i in range(groups_list.count_groups): # check if group is present if group == groups_list.board_groups[ i ]: @@ -332,9 +332,9 @@ cdef void groups_list_add_unique( Group* group, Groups_List* groups_list ): groups_list.count_groups += 1 -@cython.boundscheck( False ) -@cython.wraparound( False ) -cdef void groups_list_remove( Group* group, Groups_List* groups_list ): +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void groups_list_remove(Group* group, Groups_List* groups_list): """ remove group from list and decrement groups count """ @@ -342,7 +342,7 @@ cdef void groups_list_remove( Group* group, Groups_List* groups_list ): cdef int i # loop over array - for i in range( groups_list.count_groups ): + for i in range(groups_list.count_groups): # check if group is present if groups_list.board_groups[ i ] == group: @@ -354,7 +354,7 @@ cdef void groups_list_remove( Group* group, Groups_List* groups_list ): return # TODO this should not happen, create error for this?? - print( "Group not found!!!!!!!!!!!!!!" ) + print("Group not found!!!!!!!!!!!!!!") ############################################################################ @@ -363,9 +363,9 @@ cdef void groups_list_remove( Group* group, Groups_List* groups_list ): ############################################################################ -@cython.boundscheck( False ) -@cython.wraparound( False ) -cdef Locations_List* locations_list_new( short size ): +@cython.boundscheck(False) +@cython.wraparound(False) +cdef Locations_List* locations_list_new(short size): """ create new struct Locations_List with locations #size short long and count set to 0 @@ -374,12 +374,12 @@ cdef Locations_List* locations_list_new( short size ): cdef Locations_List* list_new # allocate memory for Group - list_new = malloc( sizeof( Locations_List ) ) + list_new = malloc(sizeof(Locations_List)) if not list_new: raise MemoryError() # allocate memory for locations - list_new.locations = malloc( size * sizeof( short ) ) + list_new.locations = malloc(size * sizeof(short)) if not list_new.locations: raise MemoryError() @@ -391,9 +391,9 @@ cdef Locations_List* locations_list_new( short size ): return list_new -@cython.boundscheck( False ) -@cython.wraparound( False ) -cdef void locations_list_destroy( Locations_List* locations_list ): +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void locations_list_destroy(Locations_List* locations_list): """ free memory location of locations_list and locations """ @@ -405,14 +405,14 @@ cdef void locations_list_destroy( Locations_List* locations_list ): if locations_list.locations is not NULL: # free locations - free( locations_list.locations ) + free(locations_list.locations) # free locations_list - free( locations_list ) + free(locations_list) -@cython.boundscheck( False ) -@cython.wraparound( False ) -cdef void locations_list_remove_location( Locations_List* locations_list, short location ): +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void locations_list_remove_location(Locations_List* locations_list, short location): """ remove location from list """ @@ -420,7 +420,7 @@ cdef void locations_list_remove_location( Locations_List* locations_list, short cdef int i # loop over array - for i in range( locations_list.count ): + for i in range(locations_list.count): # check if [ i ] == location if locations_list.locations[ i ] == location: @@ -432,12 +432,12 @@ cdef void locations_list_remove_location( Locations_List* locations_list, short return # TODO this should not happen, create error for this?? - print( "location not found!!!!!!!!!!!!!!" ) + print("location not found!!!!!!!!!!!!!!") -@cython.boundscheck( False ) -@cython.wraparound( False ) -cdef void locations_list_add_location( Locations_List* locations_list, short location ): +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void locations_list_add_location(Locations_List* locations_list, short location): """ add location to list and increment count """ @@ -446,9 +446,9 @@ cdef void locations_list_add_location( Locations_List* locations_list, short loc locations_list.count += 1 -@cython.boundscheck( False ) -@cython.wraparound( False ) -cdef void locations_list_add_location_increment( Locations_List* locations_list, short location ): +@cython.boundscheck(False) +@cython.wraparound(False) +cdef void locations_list_add_location_increment(Locations_List* locations_list, short location): """ check if list can hold one more location, resize list if not add location to list and increment count @@ -457,7 +457,7 @@ cdef void locations_list_add_location_increment( Locations_List* locations_list, if locations_list.count == locations_list.size: locations_list.size += 10 - locations_list.locations = realloc( locations_list.locations, locations_list.size * sizeof( short ) ) + locations_list.locations = realloc(locations_list.locations, locations_list.size * sizeof(short)) if not locations_list.locations: print("MEM ERROR") raise MemoryError() @@ -467,10 +467,10 @@ cdef void locations_list_add_location_increment( Locations_List* locations_list, locations_list.count += 1 -@cython.boundscheck( False ) -@cython.wraparound( False ) -@cython.nonecheck( False ) -cdef void locations_list_add_location_unique( Locations_List* locations_list, short location ): +@cython.boundscheck(False) +@cython.wraparound(False) +@cython.nonecheck(False) +cdef void locations_list_add_location_unique(Locations_List* locations_list, short location): """ check if location is present in list, return if so add location to list if not @@ -479,7 +479,7 @@ cdef void locations_list_add_location_unique( Locations_List* locations_list, sh cdef int i # loop over array - for i in range( locations_list.count ): + for i in range(locations_list.count): # check if location is present if location == locations_list.locations[ i ]: @@ -498,26 +498,26 @@ cdef void locations_list_add_location_unique( Locations_List* locations_list, sh ############################################################################ -@cython.boundscheck( False ) -@cython.wraparound( False ) -cdef short calculate_board_location( char x, char y, char size ): +@cython.boundscheck(False) +@cython.wraparound(False) +cdef short calculate_board_location(char x, char y, char size): """ return location on board no checks on outside board x = columns - y = rows + y = rows """ # return board location - return x + ( y * size ) + return x + (y * size) -@cython.boundscheck( False ) -@cython.wraparound( False ) -cdef short calculate_board_location_or_border( char x, char y, char size ): +@cython.boundscheck(False) +@cython.wraparound(False) +cdef short calculate_board_location_or_border(char x, char y, char size): """ return location on board or borderlocation - board locations = [ 0, size * size ) + board locations = [ 0, size * size) border location = size * size x = columns y = rows @@ -530,28 +530,28 @@ cdef short calculate_board_location_or_border( char x, char y, char size ): return size * size # return board location - return calculate_board_location( x, y, size ) + return calculate_board_location(x, y, size) -cdef short* get_neighbors( char size ): +cdef short* get_neighbors(char size): """ create array for every board location with all 4 direct neighbor locations neighbor order: left - right - above - below - -1 x + -1 x x x - +1 x + +1 x order: - -1 2 + -1 2 0 1 - +1 3 + +1 3 - TODO neighbors is obsolete as neighbor3x3 contains the same values + TODO neighbors is obsolete as neighbor3x3 contains the same values """ # create array - cdef short* neighbor = malloc( size * size * 4 * sizeof( short ) ) + cdef short* neighbor = malloc(size * size * 4 * sizeof(short)) if not neighbor: raise MemoryError() @@ -559,21 +559,21 @@ cdef short* get_neighbors( char size ): cdef char x, y # add all direct neighbors to every board location - for y in range( size ): + for y in range(size): - for x in range( size ): + for x in range(size): - location = ( x + ( y * size ) ) * 4 - neighbor[ location + 0 ] = calculate_board_location_or_border( x - 1, y , size ) - neighbor[ location + 1 ] = calculate_board_location_or_border( x + 1, y , size ) - neighbor[ location + 2 ] = calculate_board_location_or_border( x , y - 1, size ) - neighbor[ location + 3 ] = calculate_board_location_or_border( x , y + 1, size ) + location = (x + (y * size)) * 4 + neighbor[ location + 0 ] = calculate_board_location_or_border(x - 1, y , size) + neighbor[ location + 1 ] = calculate_board_location_or_border(x + 1, y , size) + neighbor[ location + 2 ] = calculate_board_location_or_border(x , y - 1, size) + neighbor[ location + 3 ] = calculate_board_location_or_border(x , y + 1, size) return neighbor -@cython.boundscheck( False ) -@cython.wraparound( False ) -cdef short* get_3x3_neighbors( char size ): +@cython.boundscheck(False) +@cython.wraparound(False) +cdef short* get_3x3_neighbors(char size): """ create for every board location array with all 8 surrounding neighbor locations neighbor order: above middle - middle left - middle right - below middle @@ -581,19 +581,19 @@ cdef short* get_3x3_neighbors( char size ): this order is more useful as it separates neighbors and then diagonals -1 xxx x x - +1 xxx + +1 xxx order: -1 405 1 2 - +1 637 + +1 637 0-3 contains neighbors 4-7 contains diagonals """ # create array - cdef short* neighbor3x3 = malloc( size * size * 8 * sizeof( short ) ) + cdef short* neighbor3x3 = malloc(size * size * 8 * sizeof(short)) if not neighbor3x3: raise MemoryError() @@ -601,26 +601,26 @@ cdef short* get_3x3_neighbors( char size ): cdef char x, y # add all surrounding neighbors to every board location - for x in range( size ): + for x in range(size): - for y in range( size ): + for y in range(size): - location = ( x + ( y * size ) ) * 8 - neighbor3x3[ location + 0 ] = calculate_board_location_or_border( x , y - 1, size ) - neighbor3x3[ location + 1 ] = calculate_board_location_or_border( x - 1, y , size ) - neighbor3x3[ location + 2 ] = calculate_board_location_or_border( x + 1, y , size ) - neighbor3x3[ location + 3 ] = calculate_board_location_or_border( x , y + 1, size ) + location = (x + (y * size)) * 8 + neighbor3x3[ location + 0 ] = calculate_board_location_or_border(x , y - 1, size) + neighbor3x3[ location + 1 ] = calculate_board_location_or_border(x - 1, y , size) + neighbor3x3[ location + 2 ] = calculate_board_location_or_border(x + 1, y , size) + neighbor3x3[ location + 3 ] = calculate_board_location_or_border(x , y + 1, size) - neighbor3x3[ location + 4 ] = calculate_board_location_or_border( x - 1, y - 1, size ) - neighbor3x3[ location + 5 ] = calculate_board_location_or_border( x + 1, y - 1, size ) - neighbor3x3[ location + 6 ] = calculate_board_location_or_border( x - 1, y + 1, size ) - neighbor3x3[ location + 7 ] = calculate_board_location_or_border( x + 1, y + 1, size ) + neighbor3x3[ location + 4 ] = calculate_board_location_or_border(x - 1, y - 1, size) + neighbor3x3[ location + 5 ] = calculate_board_location_or_border(x + 1, y - 1, size) + neighbor3x3[ location + 6 ] = calculate_board_location_or_border(x - 1, y + 1, size) + neighbor3x3[ location + 7 ] = calculate_board_location_or_border(x + 1, y + 1, size) return neighbor3x3 -@cython.boundscheck( False ) -@cython.wraparound( False ) -cdef short* get_12d_neighbors( char size ): +@cython.boundscheck(False) +@cython.wraparound(False) +cdef short* get_12d_neighbors(char size): """ create array for every board location with 12d star neighbor locations neighbor order: top star tip @@ -629,22 +629,22 @@ cdef short* get_12d_neighbors( char size ): below left - below middle - below right below star tip - -2 x + -2 x -1 xxx xx xx +1 xxx - +2 x + +2 x order: - -2 0 + -2 0 -1 123 45 67 +1 89a - +2 b + +2 b """ # create array - cdef short* neighbor12d = malloc( size * size * 12 * sizeof( short ) ) + cdef short* neighbor12d = malloc(size * size * 12 * sizeof(short)) if not neighbor12d: raise MemoryError() @@ -652,26 +652,26 @@ cdef short* get_12d_neighbors( char size ): cdef char x, y # add all 12d neighbors to every board location - for x in range( size ): + for x in range(size): - for y in range( size ): + for y in range(size): - location = ( x + ( y * size ) ) * 12 - neighbor12d[ location + 4 ] = calculate_board_location_or_border( x , y - 2, size ) + location = (x + (y * size)) * 12 + neighbor12d[ location + 4 ] = calculate_board_location_or_border(x , y - 2, size) - neighbor12d[ location + 1 ] = calculate_board_location_or_border( x - 1, y - 1, size ) - neighbor12d[ location + 5 ] = calculate_board_location_or_border( x , y - 1, size ) - neighbor12d[ location + 8 ] = calculate_board_location_or_border( x + 1, y - 1, size ) + neighbor12d[ location + 1 ] = calculate_board_location_or_border(x - 1, y - 1, size) + neighbor12d[ location + 5 ] = calculate_board_location_or_border(x , y - 1, size) + neighbor12d[ location + 8 ] = calculate_board_location_or_border(x + 1, y - 1, size) - neighbor12d[ location + 0 ] = calculate_board_location_or_border( x - 2, y , size ) - neighbor12d[ location + 2 ] = calculate_board_location_or_border( x - 1, y , size ) - neighbor12d[ location + 9 ] = calculate_board_location_or_border( x + 1, y , size ) - neighbor12d[ location + 11 ] = calculate_board_location_or_border( x + 2, y , size ) + neighbor12d[ location + 0 ] = calculate_board_location_or_border(x - 2, y , size) + neighbor12d[ location + 2 ] = calculate_board_location_or_border(x - 1, y , size) + neighbor12d[ location + 9 ] = calculate_board_location_or_border(x + 1, y , size) + neighbor12d[ location + 11 ] = calculate_board_location_or_border(x + 2, y , size) - neighbor12d[ location + 3 ] = calculate_board_location_or_border( x - 1, y + 1, size ) - neighbor12d[ location + 6 ] = calculate_board_location_or_border( x , y + 1, size ) - neighbor12d[ location + 10 ] = calculate_board_location_or_border( x + 1, y + 1, size ) + neighbor12d[ location + 3 ] = calculate_board_location_or_border(x - 1, y + 1, size) + neighbor12d[ location + 6 ] = calculate_board_location_or_border(x , y + 1, size) + neighbor12d[ location + 10 ] = calculate_board_location_or_border(x + 1, y + 1, size) - neighbor12d[ location + 7 ] = calculate_board_location_or_border( x , y + 2, size ) + neighbor12d[ location + 7 ] = calculate_board_location_or_border(x , y + 2, size) return neighbor12d \ No newline at end of file diff --git a/AlphaGo/models/policy.py b/AlphaGo/models/policy.py index 5933d4b2d..d3f6e2529 100644 --- a/AlphaGo/models/policy.py +++ b/AlphaGo/models/policy.py @@ -1,10 +1,12 @@ from keras.models import Sequential, Model -from keras.layers import convolutional, merge, Input, BatchNormalization +from keras.layers import Input, BatchNormalization, Conv2D +from keras.layers.merge import add from keras.layers.core import Activation, Flatten from AlphaGo.util import flatten_idx from AlphaGo.models.nn_util import Bias, NeuralNetBase, neuralnet import numpy as np +import datetime @neuralnet class CNNPolicy(NeuralNetBase): @@ -38,11 +40,22 @@ def batch_eval_state(self, states, moves_lists=None): state_size = states[0].get_size() if not all([st.get_size() == state_size for st in states]): raise ValueError("all states must have the same size") + + time_preprocess = datetime.datetime.now() + # concatenate together all one-hot encoded states along the 'batch' dimension nn_input = np.concatenate([self.preprocessor.state_to_tensor(s) for s in states], axis=0) + time_preprocess = datetime.datetime.now() - time_preprocess + + print( "time preprocess: " + str( time_preprocess ) ) + + time_forward = datetime.datetime.now() # pass all input through the network at once (backend makes use of # batches if len(states) is large) network_output = self.forward(nn_input) + time_forward = datetime.datetime.now() - time_forward + + print( "time preprocess: " + str( time_preprocess ) + " forward: " + str( time_forward ) ) # default move lists to all legal moves moves_lists = moves_lists or [st.get_legal_moves() for st in states] results = [None] * n_states @@ -94,14 +107,23 @@ def create_network(**kwargs): network = Sequential() # create first layer - network.add(convolutional.Convolution2D( + network.add(Conv2D( input_shape=(params["input_dim"], params["board"], params["board"]), - nb_filter=params.get("filters_per_layer_1", params["filters_per_layer"]), - nb_row=params["filter_width_1"], - nb_col=params["filter_width_1"], - init='uniform', + filters=params.get("filters_per_layer_1", params["filters_per_layer"]), + kernel_size=(params["filter_width_1"], params["filter_width_1"]), + kernel_initializer='uniform', activation='relu', - border_mode='same')) + padding='same', + kernel_constraint=None, + activity_regularizer=None, + trainable=True, + strides=[1, 1], + use_bias=True, + bias_regularizer=None, + bias_constraint=None, + data_format="channels_first", + kernel_regularizer=None)) + # create all other layers for i in range(2, params["layers"] + 1): @@ -113,21 +135,38 @@ def create_network(**kwargs): filter_count_key = "filters_per_layer_%d" % i filter_nb = params.get(filter_count_key, params["filters_per_layer"]) - network.add(convolutional.Convolution2D( - nb_filter=filter_nb, - nb_row=filter_width, - nb_col=filter_width, - init='uniform', + network.add(Conv2D( + filters=filter_nb, + kernel_size=(filter_width, filter_width), + kernel_initializer='uniform', activation='relu', - border_mode='same')) - + padding='same', + kernel_constraint=None, + activity_regularizer=None, + trainable=True, + strides=[1, 1], + use_bias=True, + bias_regularizer=None, + bias_constraint=None, + data_format="channels_first", + kernel_regularizer=None)) + # the last layer maps each feature to a number - network.add(convolutional.Convolution2D( - nb_filter=1, - nb_row=1, - nb_col=1, - init='uniform', - border_mode='same')) + network.add(Conv2D( + filters=1, + kernel_size=(1, 1), + kernel_initializer='uniform', + padding='same', + kernel_constraint=None, + activity_regularizer=None, + trainable=True, + strides=[1, 1], + use_bias=True, + bias_regularizer=None, + bias_constraint=None, + data_format="channels_first", + kernel_regularizer=None)) + # reshape output to be board x board network.add(Flatten()) # add a bias to each board location @@ -199,15 +238,23 @@ def create_network(**kwargs): model_input = Input(shape=(params["input_dim"], params["board"], params["board"])) # create first layer - convolution_path = convolutional.Convolution2D( + convolution_path = Conv2D( input_shape=(), - nb_filter=params["filters_per_layer"], - nb_row=params["filter_width_1"], - nb_col=params["filter_width_1"], - init='uniform', + filters=params["filters_per_layer"], + kernel_size=(params["filter_width_1"],params["filter_width_1"]), + kernel_initializer='uniform', activation='linear', # relu activations done inside resnet modules - border_mode='same')(model_input) - + padding='same', + kernel_constraint=None, + activity_regularizer=None, + trainable=True, + strides=[1, 1], + use_bias=True, + bias_regularizer=None, + bias_constraint=None, + data_format="channels_first", + kernel_regularizer=None)(model_input) + def add_resnet_unit(path, K, **params): """Add a resnet unit to path starting at layer 'K', adding as many (ReLU + Conv2D) modules as specified by n_skip_K @@ -232,15 +279,24 @@ def add_resnet_unit(path, K, **params): filter_key = "filter_width_%d" % layer filter_width = params.get(filter_key, 3) # add Conv2D - path = convolutional.Convolution2D( - nb_filter=params["filters_per_layer"], - nb_row=filter_width, - nb_col=filter_width, - init='uniform', + path = Conv2D( + filters=params["filters_per_layer"], + kernel_size=(filter_width, filter_width), + kernel_initializer='uniform', activation='linear', - border_mode='same')(path) + padding='same', + kernel_constraint=None, + activity_regularizer=None, + trainable=True, + strides=[1, 1], + use_bias=True, + bias_regularizer=None, + bias_constraint=None, + data_format="channels_first", + kernel_regularizer=None)(path) + # Merge 'input layer' with the path - path = merge([block_input, path], mode='sum') + path = add([block_input, path]) return path, K + n_skip # create all other layers @@ -255,12 +311,23 @@ def add_resnet_unit(path, K, **params): convolution_path = Activation('relu')(convolution_path) # the last layer maps each featuer to a number - convolution_path = convolutional.Convolution2D( - nb_filter=1, - nb_row=1, - nb_col=1, - init='uniform', - border_mode='same')(convolution_path) + convolution_path = Conv2D( + filters=1, + kernel_size=( 1, 1 ), + kernel_initializer='uniform', + name="policy_conv_last", + padding='same', + activation="linear", + kernel_constraint=None, + activity_regularizer=None, + trainable=True, + strides=[1, 1], + use_bias=True, + bias_regularizer=None, + bias_constraint=None, + data_format="channels_first", + kernel_regularizer=None)(convolution_path) + # flatten output network_output = Flatten()(convolution_path) # add a bias to each board location @@ -268,4 +335,4 @@ def add_resnet_unit(path, K, **params): # softmax makes it into a probability distribution network_output = Activation('softmax')(network_output) - return Model(input=[model_input], output=[network_output]) + return Model(inputs=[model_input], outputs=[network_output]) diff --git a/AlphaGo/models/rollout.py b/AlphaGo/models/rollout.py index 85a22e035..ef4dee145 100644 --- a/AlphaGo/models/rollout.py +++ b/AlphaGo/models/rollout.py @@ -56,14 +56,22 @@ def create_network(**kwargs): network = Sequential() # create one convolutional layer - network.add(convolutional.Convolution2D( + network.add(convolutional.Conv2D( input_shape=(params["input_dim"], params["board"], params["board"]), - nb_filter=1, - nb_row=1, - nb_col=1, - init='uniform', + filters=1, + kernel_size=(1, 1), + kernel_initializer='uniform', activation='relu', - border_mode='same')) + padding='same', + kernel_constraint=None, + activity_regularizer=None, + trainable=True, + strides=[1, 1], + use_bias=True, + bias_regularizer=None, + bias_constraint=None, + data_format="channels_first", + kernel_regularizer=None)) # reshape output to be board x board network.add(Flatten()) diff --git a/AlphaGo/models/value.py b/AlphaGo/models/value.py index 22b8a1593..3ff8500e1 100644 --- a/AlphaGo/models/value.py +++ b/AlphaGo/models/value.py @@ -2,7 +2,7 @@ from keras.models import Sequential from keras.layers.core import Flatten from nn_util import NeuralNetBase, neuralnet -from keras.layers import convolutional, Dense +from keras.layers import Dense, convolutional @neuralnet @@ -80,14 +80,23 @@ def create_network(**kwargs): network = Sequential() # create first layer - network.add(convolutional.Convolution2D( + network.add(convolutional.Conv2D( input_shape=(params["input_dim"], params["board"], params["board"]), - nb_filter=params.get("filters_per_layer_1", params["filters_per_layer"]), - nb_row=params["filter_width_1"], - nb_col=params["filter_width_1"], - init='uniform', + filters=params.get("filters_per_layer_1", params["filters_per_layer"]), + kernel_size=(params["filter_width_1"], params["filter_width_1"]), + kernel_initializer='uniform', activation='relu', - border_mode='same')) + padding='same', + kernel_constraint=None, + activity_regularizer=None, + trainable=True, + strides=[1, 1], + use_bias=True, + bias_regularizer=None, + bias_constraint=None, + data_format="channels_first", + kernel_regularizer=None, + name="con_first")) for i in range(2, params["layers"] + 1): # use filter_width_K if it is there, otherwise use 3 @@ -98,24 +107,67 @@ def create_network(**kwargs): filter_count_key = "filters_per_layer_%d" % i filter_nb = params.get(filter_count_key, params["filters_per_layer"]) - network.add(convolutional.Convolution2D( - nb_filter=filter_nb, - nb_row=filter_width, - nb_col=filter_width, - init='uniform', + network.add(convolutional.Conv2D( + filters=filter_nb, + kernel_size=(filter_width, filter_width), + kernel_initializer='uniform', activation='relu', - border_mode='same')) + padding='same', + kernel_constraint=None, + activity_regularizer=None, + trainable=True, + strides=[1, 1], + use_bias=True, + bias_regularizer=None, + bias_constraint=None, + data_format="channels_first", + kernel_regularizer=None, + name="con_" + str( i ))) # the last layer maps each feature to a number - network.add(convolutional.Convolution2D( - nb_filter=1, - nb_row=1, - nb_col=1, - init='uniform', + network.add(convolutional.Conv2D( + filters=1, + kernel_size=(1, 1), + kernel_initializer='uniform', activation='relu', - border_mode='same')) - + padding='same', + kernel_constraint=None, + activity_regularizer=None, + trainable=True, + strides=[1, 1], + use_bias=True, + bias_regularizer=None, + bias_constraint=None, + data_format="channels_first", + kernel_regularizer=None, + name="con_last")) + network.add(Flatten()) - network.add(Dense(params["dense"], init='uniform', activation='relu')) - network.add(Dense(1, init='uniform', activation="tanh")) + + network.add(Dense(input_dim=params["board"] * params["board"], + units=params["dense"], + kernel_initializer='uniform', + activation='relu', + bias_regularizer=None, + bias_constraint=None, + activity_regularizer=None, + trainable=True, + kernel_constraint=None, + kernel_regularizer=None, + use_bias=True, + name="dense_1")) + + network.add(Dense(input_dim=params["dense"], + units=1, + kernel_initializer='uniform', + activation="tanh", + bias_regularizer=None, + bias_constraint=None, + activity_regularizer=None, + trainable=True, + kernel_constraint=None, + kernel_regularizer=None, + use_bias=True, + name="dense_2")) + return network diff --git a/AlphaGo/preprocessing/preprocessing.pxd b/AlphaGo/preprocessing/preprocessing.pxd index 0ff61f26b..f4c85c2fe 100644 --- a/AlphaGo/preprocessing/preprocessing.pxd +++ b/AlphaGo/preprocessing/preprocessing.pxd @@ -12,7 +12,7 @@ from AlphaGo.go_data cimport _BLACK, _EMPTY, _STONE, _LIBERTY, _CAPTURE, _FREE, ctypedef char tensor_type # type defining cdef function -ctypedef int (*preprocess_method)( Preprocess, GameState, tensor_type[ :, ::1 ], char*, int ) +ctypedef int (*preprocess_method)(Preprocess, GameState, tensor_type[ :, ::1 ], char*, int) cdef class Preprocess: @@ -52,7 +52,7 @@ cdef class Preprocess: # # ############################################################################ - cdef int get_board( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + cdef int get_board(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) """ A feature encoding WHITE BLACK and EMPTY on separate planes. plane 0 always refers to the current player stones @@ -60,7 +60,7 @@ cdef class Preprocess: plane 2 to empty locations """ - cdef int get_turns_since( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + cdef int get_turns_since(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) """ A feature encoding the age of the stone at each location up to 'maximum' @@ -69,7 +69,7 @@ cdef class Preprocess: - EMPTY locations are all-zero features """ - cdef int get_liberties( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + cdef int get_liberties(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) """ A feature encoding the number of liberties of the group connected to the stone at each location @@ -80,7 +80,7 @@ cdef class Preprocess: - EMPTY locations are all-zero features """ - cdef int get_capture_size( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + cdef int get_capture_size(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) """ A feature encoding the number of opponent stones that would be captured by playing at each location, up to 'maximum' @@ -93,14 +93,14 @@ cdef class Preprocess: - illegal move locations are all-zero features """ - cdef int get_self_atari_size( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + cdef int get_self_atari_size(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) """ A feature encoding the size of the own-stone group that is put into atari by playing at a location """ - cdef int get_liberties_after( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + cdef int get_liberties_after(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) """ A feature encoding what the number of liberties *would be* of the group connected to the stone *if* played at a location @@ -111,60 +111,65 @@ cdef class Preprocess: - illegal move locations are all-zero features """ - cdef int get_ladder_capture( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + cdef int get_ladder_capture(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) """ A feature wrapping GameState.is_ladder_capture(). check if an opponent group can be captured in a ladder """ - cdef int get_ladder_escape( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + cdef int get_ladder_escape(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) """ A feature wrapping GameState.is_ladder_escape(). check if player_current group can escape ladder """ - cdef int get_sensibleness( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + cdef int get_sensibleness(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) """ A move is 'sensible' if it is legal and if it does not fill the current_player's own eye """ - cdef int get_legal( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + cdef int get_legal(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) """ Zero at all illegal moves, one at all legal moves. Unlike sensibleness, no eye check is done not used?? """ - cdef int zeros( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + cdef int zeros(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) """ Plane filled with zeros """ - - cdef int ones( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + + cdef int ones(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) """ Plane filled with ones """ - cdef int colour( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + cdef int colour(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) """ Value net feature, plane with ones if active_player is black else zeros """ - cdef int get_response( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) - cdef int get_save_atari( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) - cdef int get_neighbor( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) - cdef int get_nakade( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) - cdef int get_nakade_offset( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) - cdef int get_response_12d( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) - cdef int get_response_12d_offset( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) - cdef int get_non_response_3x3( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) - cdef int get_non_response_3x3_offset( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ) + cdef int ko(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) + """ + ko + """ + + cdef int get_response(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) + cdef int get_save_atari(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) + cdef int get_neighbor(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) + cdef int get_nakade(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) + cdef int get_nakade_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) + cdef int get_response_12d(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) + cdef int get_response_12d_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) + cdef int get_non_response_3x3(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) + cdef int get_non_response_3x3_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) ############################################################################ # public cdef function # # # ############################################################################ - cdef np.ndarray[ tensor_type, ndim=4 ] generate_tensor( self, GameState state ) + cdef np.ndarray[ tensor_type, ndim=4 ] generate_tensor(self, GameState state) """ Convert a GameState to a Theano-compatible tensor """ diff --git a/AlphaGo/preprocessing/preprocessing.pyx b/AlphaGo/preprocessing/preprocessing.pyx index c7aa2af6a..54246bb78 100644 --- a/AlphaGo/preprocessing/preprocessing.pyx +++ b/AlphaGo/preprocessing/preprocessing.pyx @@ -50,10 +50,10 @@ cdef class Preprocess: ############################################################################ - @cython.boundscheck( False ) - @cython.wraparound( False ) - @cython.nonecheck( False ) - cdef int get_board( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.nonecheck(False) + cdef int get_board(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ A feature encoding WHITE BLACK and EMPTY on separate planes. plane 0 always refers to the current player stones @@ -67,7 +67,7 @@ cdef class Preprocess: cdef char opponent = state.player_opponent # loop over all locations on board - for location in range( self.board_size ): + for location in range(self.board_size): group = state.board_groups[ location ] @@ -86,10 +86,10 @@ cdef class Preprocess: return offSet + 3 - @cython.boundscheck( False ) - @cython.wraparound( False ) - @cython.nonecheck( False ) - cdef int get_turns_since( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.nonecheck(False) + cdef int get_turns_since(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ A feature encoding the age of the stone at each location up to 'maximum' @@ -105,7 +105,7 @@ cdef class Preprocess: cdef int i # set all stones to max age - for i in range( history.count ): + for i in range(history.count): location = history.locations[ i ] @@ -135,10 +135,10 @@ cdef class Preprocess: return offSet + 8 - @cython.boundscheck( False ) - @cython.wraparound( False ) - @cython.nonecheck( False ) - cdef int get_liberties( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.nonecheck(False) + cdef int get_liberties(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ A feature encoding the number of liberties of the group connected to the stone at each location @@ -153,7 +153,7 @@ cdef class Preprocess: cdef Group* group cdef short location - for location in range( self.board_size ): + for location in range(self.board_size): group = state.board_groups[ location ] @@ -173,10 +173,10 @@ cdef class Preprocess: return offSet + 8 - @cython.boundscheck( False ) - @cython.wraparound( False ) - @cython.nonecheck( False ) - cdef int get_capture_size( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.nonecheck(False) + cdef int get_capture_size(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ A feature encoding the number of opponent stones that would be captured by playing at each location, up to 'maximum' @@ -192,7 +192,7 @@ cdef class Preprocess: cdef short i, location, capture_size # loop over all legal moves and set get capture size - for i in range( state.moves_legal.count ): + for i in range(state.moves_legal.count): location = state.moves_legal.locations[ i ] @@ -202,14 +202,14 @@ cdef class Preprocess: capture_size = 7 tensor[ offSet + capture_size, location ] = 1 - + return offSet + 8 - @cython.boundscheck( False ) - @cython.wraparound( False ) - @cython.nonecheck( False ) - cdef int get_self_atari_size( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.nonecheck(False) + cdef int get_self_atari_size(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ A feature encoding the size of the own-stone group that is put into atari by playing at a location @@ -219,7 +219,7 @@ cdef class Preprocess: cdef short i, location, group_liberty # loop over all groups on board - for i in range( state.moves_legal.count ): + for i in range(state.moves_legal.count): location = state.moves_legal.locations[ i ] @@ -232,14 +232,14 @@ cdef class Preprocess: group_liberty = 7 tensor[ offSet + group_liberty, location ] = 1 - + return offSet + 8 - @cython.boundscheck( False ) - @cython.wraparound( False ) - @cython.nonecheck( False ) - cdef int get_liberties_after( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.nonecheck(False) + cdef int get_liberties_after(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ A feature encoding what the number of liberties *would be* of the group connected to the stone *if* played at a location @@ -253,7 +253,7 @@ cdef class Preprocess: cdef short i, location, liberty # loop over all legal moves - for i in range( state.moves_legal.count ): + for i in range(state.moves_legal.count): location = state.moves_legal.locations[ i ] @@ -269,60 +269,60 @@ cdef class Preprocess: return offSet + 8 - @cython.boundscheck( False ) - @cython.wraparound( False ) - @cython.nonecheck( False ) - cdef int get_ladder_capture( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.nonecheck(False) + cdef int get_ladder_capture(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ A feature wrapping GameState.is_ladder_capture(). check if an opponent group can be captured in a ladder """ cdef int location - cdef char* captures = state.get_ladder_captures( 80 ) + cdef char* captures = state.get_ladder_captures(80) # loop over all groups on board - for location in range( state.board_size ): + for location in range(state.board_size): if captures[ location ] != _FREE: tensor[ offSet, location ] = 1 # free captures - free( captures ) + free(captures) return offSet + 1 - @cython.boundscheck( False ) - @cython.wraparound( False ) - @cython.nonecheck( False ) - cdef int get_ladder_escape( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.nonecheck(False) + cdef int get_ladder_escape(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ A feature wrapping GameState.is_ladder_escape(). check if player_current group can escape ladder """ cdef int location - cdef char* escapes = state.get_ladder_escapes( 80 ) + cdef char* escapes = state.get_ladder_escapes(80) # loop over all groups on board - for location in range( state.board_size ): + for location in range(state.board_size): if escapes[ location ] != _FREE: tensor[ offSet, location ] = 1 # free escapes - free( escapes ) + free(escapes) return offSet + 1 - @cython.boundscheck( False ) - @cython.wraparound( False ) - @cython.nonecheck( False ) - cdef int get_sensibleness( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.nonecheck(False) + cdef int get_sensibleness(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ A move is 'sensible' if it is legal and if it does not fill the current_player's own eye """ @@ -332,15 +332,15 @@ cdef class Preprocess: cdef Group* group # set all legal moves to 1 - for i in range( state.moves_legal.count ): + for i in range(state.moves_legal.count): tensor[ offSet, state.moves_legal.locations[ i ] ] = 1 # list can increment but a big enough starting value is important - cdef Locations_List* eyes = locations_list_new( 15 ) + cdef Locations_List* eyes = locations_list_new(15) # loop over all board groups - for i in range( state.groups_list.count_groups ): + for i in range(state.groups_list.count_groups): group = state.groups_list.board_groups[ i ] @@ -348,25 +348,25 @@ cdef class Preprocess: if group.colour == state.player_current: # loop over liberties because they are possible eyes - for location in range( self.board_size ): + for location in range(self.board_size): # check liberty location as possible eye if group.locations[ location ] == _LIBERTY: # check if location is an eye - if state.is_true_eye( location, eyes, state.player_current ): + if state.is_true_eye(location, eyes, state.player_current): tensor[ offSet, location ] = 0 - locations_list_destroy( eyes ) + locations_list_destroy(eyes) return offSet + 1 - @cython.boundscheck( False ) - @cython.wraparound( False ) - @cython.nonecheck( False ) - cdef int get_legal( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.nonecheck(False) + cdef int get_legal(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ Zero at all illegal moves, one at all legal moves. Unlike sensibleness, no eye check is done not used?? @@ -375,17 +375,17 @@ cdef class Preprocess: cdef short location # loop over all legal moves and set to one - for location in range( state.moves_legal.count ): + for location in range(state.moves_legal.count): tensor[ offSet, state.moves_legal.locations[ location ] ] = 1 return offSet + 1 - @cython.boundscheck( False ) - @cython.wraparound( False ) - @cython.nonecheck( False ) - cdef int get_response( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.nonecheck(False) + cdef int get_response(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ Fast rollout feature """ @@ -393,10 +393,10 @@ cdef class Preprocess: return offSet + 1 - @cython.boundscheck( False ) - @cython.wraparound( False ) - @cython.nonecheck( False ) - cdef int get_save_atari( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.nonecheck(False) + cdef int get_save_atari(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ Fast rollout feature """ @@ -404,10 +404,10 @@ cdef class Preprocess: return offSet + 1 - @cython.boundscheck( False ) - @cython.wraparound( False ) - @cython.nonecheck( False ) - cdef int get_neighbor( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.nonecheck(False) + cdef int get_neighbor(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ Fast rollout feature """ @@ -435,10 +435,10 @@ cdef class Preprocess: return offSet + 2 - @cython.boundscheck( False ) - @cython.wraparound( False ) - @cython.nonecheck( False ) - cdef int get_nakade( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.nonecheck(False) + cdef int get_nakade(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ Fast rollout feature """ @@ -446,10 +446,10 @@ cdef class Preprocess: return offSet + 1 - @cython.boundscheck( False ) - @cython.wraparound( False ) - @cython.nonecheck( False ) - cdef int get_nakade_offset( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.nonecheck(False) + cdef int get_nakade_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ Fast rollout feature """ @@ -457,10 +457,10 @@ cdef class Preprocess: return offSet + self.pattern_nakade_size - @cython.boundscheck( False ) - @cython.wraparound( False ) - @cython.nonecheck( False ) - cdef int get_response_12d( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.nonecheck(False) + cdef int get_response_12d(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ Fast rollout feature """ @@ -471,10 +471,10 @@ cdef class Preprocess: return offSet + 1 - @cython.boundscheck( False ) - @cython.wraparound( False ) - @cython.nonecheck( False ) - cdef int get_response_12d_offset( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.nonecheck(False) + cdef int get_response_12d_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ Fast rollout feature """ @@ -485,10 +485,10 @@ cdef class Preprocess: return offSet + self.pattern_response_12d_size - @cython.boundscheck( False ) - @cython.wraparound( False ) - @cython.nonecheck( False ) - cdef int get_non_response_3x3( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.nonecheck(False) + cdef int get_non_response_3x3(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ Fast rollout feature """ @@ -496,10 +496,10 @@ cdef class Preprocess: return offSet + 1 - @cython.boundscheck( False ) - @cython.wraparound( False ) - @cython.nonecheck( False ) - cdef int get_non_response_3x3_offset( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.nonecheck(False) + cdef int get_non_response_3x3_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ Fast rollout feature """ @@ -507,14 +507,14 @@ cdef class Preprocess: return offSet + self.pattern_non_response_3x3_size - @cython.boundscheck( False ) - @cython.wraparound( False ) - @cython.nonecheck( False ) - cdef int zeros( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.nonecheck(False) + cdef int zeros(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ Plane filled with zeros """ - + ######################################################### # strange things happen if a function does no do anything # do not remove next line without extensive testing!!!!!! @@ -523,26 +523,26 @@ cdef class Preprocess: return offSet + 1 - @cython.boundscheck( False ) - @cython.wraparound( False ) - @cython.nonecheck( False ) - cdef int ones( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.nonecheck(False) + cdef int ones(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ Plane filled with ones """ cdef short location - for location in range( 0, self.board_size ): + for location in range(0, self.board_size): tensor[ offSet, location ] = 1 return offSet + 1 - @cython.boundscheck( False ) - @cython.wraparound( False ) - @cython.nonecheck( False ) - cdef int colour( self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet ): + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.nonecheck(False) + cdef int colour(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ Value net feature, plane with ones if active_player is black else zeros """ @@ -552,21 +552,36 @@ cdef class Preprocess: # if player_current is white if state.player_current == _BLACK: - for location in range( 0, self.board_size ): + for location in range(0, self.board_size): tensor[ offSet, location ] = 1 return offSet + 1 + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.nonecheck(False) + cdef int ko(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): + """ + ko + """ + + if state.ko is not _PASS: + + tensor[ offSet, state.ko ] = 1 + + return offSet + 1 + + ############################################################################ # init function # # # ############################################################################ - @cython.boundscheck( False ) - @cython.wraparound( False ) - def __init__( self, list feature_list, char size=19, dict_nakade=None, dict_3x3=None, dict_12d=None, verbose=False ): + @cython.boundscheck(False) + @cython.wraparound(False) + def __init__(self, list feature_list, char size=19, dict_nakade=None, dict_3x3=None, dict_12d=None, verbose=False): """ """ @@ -575,12 +590,12 @@ cdef class Preprocess: cdef int i - # preprocess_method is a function pointer: - # ctypedef int (*preprocess_method)( Preprocess, GameState, tensor_type[ :, ::1 ], char*, int ) + # preprocess_method is a function pointer: + # ctypedef int (*preprocess_method)(Preprocess, GameState, tensor_type[ :, ::1 ], char*, int) cdef preprocess_method processor # create a list with function pointers - self.processors = malloc( len( feature_list ) * sizeof( preprocess_method ) ) + self.processors = malloc(len(feature_list) * sizeof(preprocess_method)) if not self.processors: raise MemoryError() @@ -593,7 +608,7 @@ cdef class Preprocess: s = f.read() self.pattern_nakade = ast.literal_eval(s) self.pattern_nakade_size = max(self.pattern_nakade.values()) + 1 - + # load 12d response patterns self.pattern_response_12d = {} self.pattern_response_12d_size = 0 @@ -611,7 +626,7 @@ cdef class Preprocess: s = f.read() self.pattern_non_response_3x3 = ast.literal_eval(s) self.pattern_non_response_3x3_size = max(self.pattern_non_response_3x3.values()) + 1 - + if verbose: print("loaded " + str(self.pattern_nakade_size) + " nakade patterns") print("loaded " + str(self.pattern_response_12d_size) + " 12d patterns") @@ -622,7 +637,7 @@ cdef class Preprocess: # loop over feature_list add the corresponding function # and increment output_dim accordingly - for i in range( len( feature_list ) ): + for i in range(len(feature_list)): feat = feature_list[ i ].lower() if feat == "board": processor = self.get_board @@ -699,23 +714,27 @@ cdef class Preprocess: elif feat == "color": processor = self.colour self.output_dim += 1 + + elif feat == "ko": + processor = self.ko + self.output_dim += 1 else: # incorrect feature input - raise ValueError( "uknown feature: %s" % feat ) + raise ValueError("uknown feature: %s" % feat) self.processors[ i ] = processor - @cython.boundscheck( False ) - @cython.wraparound( False ) + @cython.boundscheck(False) + @cython.wraparound(False) def __dealloc__(self): """ Prevent memory leaks by freeing all arrays created with malloc """ if self.processors is not NULL: - free( self.processors ) + free(self.processors) ############################################################################ # public cdef function # @@ -723,10 +742,10 @@ cdef class Preprocess: ############################################################################ - @cython.boundscheck( False ) - @cython.wraparound( False ) - @cython.nonecheck( False ) - cdef np.ndarray[ tensor_type, ndim=4 ] generate_tensor( self, GameState state ): + @cython.boundscheck(False) + @cython.wraparound(False) + @cython.nonecheck(False) + cdef np.ndarray[ tensor_type, ndim=4 ] generate_tensor(self, GameState state): """ Convert a GameState to a Theano-compatible tensor """ @@ -736,7 +755,7 @@ cdef class Preprocess: # create complete array now instead of concatenate later # TODO check if we can use a Malloc array somehow.. faster!! - cdef np.ndarray[ tensor_type, ndim=2 ] np_tensor = np.zeros( ( self.output_dim, self.board_size ), dtype=np.int8 ) + cdef np.ndarray[ tensor_type, ndim=2 ] np_tensor = np.zeros((self.output_dim, self.board_size), dtype=np.int8) cdef tensor_type[ :, ::1 ] tensor = np_tensor cdef int offSet = 0 @@ -745,16 +764,16 @@ cdef class Preprocess: cdef char *groups_after = state.get_groups_after() # loop over all processors and generate tensor - for i in range( len( self.feature_list ) ): + for i in range(len(self.feature_list)): proc = self.processors[ i ] - offSet = proc( self, state, tensor, groups_after, offSet ) + offSet = proc(self, state, tensor, groups_after, offSet) # free groups_after - free( groups_after ) + free(groups_after) # create a singleton 'batch' dimension - return np_tensor.reshape( ( 1, self.output_dim, self.size, self.size ) ) + return np_tensor.reshape((1, self.output_dim, self.size, self.size)) ############################################################################ @@ -763,15 +782,15 @@ cdef class Preprocess: ############################################################################ - def state_to_tensor( self, GameState state ): + def state_to_tensor(self, GameState state): """ Convert a GameState to a Theano-compatible tensor """ - return self.generate_tensor( state ) + return self.generate_tensor(state) - def get_output_dimension( self ): + def get_output_dimension(self): """ return output_dim, the amount of planes an output tensor will have """ @@ -779,7 +798,7 @@ cdef class Preprocess: return self.output_dim - def get_feature_list( self ): + def get_feature_list(self): """ return feature list """ @@ -793,35 +812,35 @@ cdef class Preprocess: ############################################################################ - def test( self, GameState state, int amount ): + def test(self, GameState state, int amount): cdef char size = state.size self.board_size = state.size * state.size import time t = time.time() - + cdef int i - for i in range( amount ): - self.generate_tensor( state ) + for i in range(amount): + self.generate_tensor(state) - print "proc " + str( time.time() - t ) + print "proc " + str(time.time() - t) - def timed_test( self, GameState state, int amount ): + def timed_test(self, GameState state, int amount): cdef int i - for i in range( amount ): + for i in range(amount): - self.generate_tensor( state ) + self.generate_tensor(state) - def test_game_speed( self, GameState state, list moves ): + def test_game_speed(self, GameState state, list moves): cdef short location for location in moves: - state.add_move( location ) - self.generate_tensor( state ) \ No newline at end of file + state.add_move(location) + self.generate_tensor(state) \ No newline at end of file diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 1ed7aaf29..29dd2beac 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -4,6 +4,7 @@ import AlphaGo.go as go import keras.backend as K from shutil import copyfile +from AlphaGo.go import GameState from keras.optimizers import SGD from AlphaGo.util import flatten_idx from AlphaGo.models.policy import CNNPolicy @@ -18,10 +19,9 @@ def _make_training_pair(st, mv, preprocessor): def run_n_games(optimizer, learner, opponent, num_games, mock_states=[]): - ''' - Run num_games games to completion, keeping track of each position and move of the learner. + '''Run num_games games to completion, keeping track of each position and move of the learner. - (Note: learning cannot happen until all games have completed) + (Note: learning cannot happen until all games have completed) ''' board_size = learner.policy.model.input_shape[-1] @@ -62,7 +62,6 @@ def run_n_games(optimizer, learner, opponent, num_games, mock_states=[]): (st_tensor, mv_tensor) = _make_training_pair(state, mv, learner.policy.preprocessor) state_tensors[idx].append(st_tensor) move_tensors[idx].append(mv_tensor) - state.do_move(mv) if state.is_end_of_game(): learner_won[idx] = state.get_winner() == learner_color[idx] @@ -88,12 +87,10 @@ def run_n_games(optimizer, learner, opponent, num_games, mock_states=[]): def log_loss(y_true, y_pred): + '''Keras 'loss' function for the REINFORCE algorithm, where y_true is the action that was + taken, and updates with the negative gradient will make that action more likely. We use the + negative gradient because keras expects training data to minimize a loss function. ''' - Keras 'loss' function for the REINFORCE algorithm, where y_true is the action that was - taken, and updates with the negative gradient will make that action more likely. We use the - negative gradient because keras expects training data to minimize a loss function. - ''' - return -y_true * K.log(K.clip(y_pred, K.epsilon(), 1.0 - K.epsilon())) diff --git a/AlphaGo/training/reinforcement_value_trainer.py b/AlphaGo/training/reinforcement_value_trainer.py index a7067c270..bae16e580 100644 --- a/AlphaGo/training/reinforcement_value_trainer.py +++ b/AlphaGo/training/reinforcement_value_trainer.py @@ -656,12 +656,11 @@ def train(metadata, out_directory, verbose, weight_file, meta_file): model.fit_generator( generator=train_data_generator, - samples_per_epoch=metadata["epoch_length"], - nb_epoch=(metadata["epochs"] - len(metadata["epoch_logs"])), + steps_per_epoch=( metadata["epoch_length"] / metadata["batch_size"] ), + epochs=(metadata["epochs"] - len(metadata["epoch_logs"])), callbacks=[meta_writer, lr_scheduler_callback], validation_data=val_data_generator, - nb_val_samples=len(val_indices)) - + validation_steps=( len(val_indices) / metadata["batch_size"] ) ) def start_training(args): # set resume diff --git a/AlphaGo/training/supervised_policy_trainer.py b/AlphaGo/training/supervised_policy_trainer.py index aff4462cd..482495738 100644 --- a/AlphaGo/training/supervised_policy_trainer.py +++ b/AlphaGo/training/supervised_policy_trainer.py @@ -666,11 +666,11 @@ def train(metadata, out_directory, verbose, weight_file, meta_file): model.fit_generator( generator=train_data_generator, - samples_per_epoch=metadata["epoch_length"], - nb_epoch=(metadata["epochs"] - len(metadata["epoch_logs"])), + steps_per_epoch=( metadata["epoch_length"] / metadata["batch_size"] ), + epochs=(metadata["epochs"] - len(metadata["epoch_logs"])), callbacks=[meta_writer, lr_scheduler_callback], validation_data=val_data_generator, - nb_val_samples=len(val_indices)) + validation_steps=( len(val_indices) / metadata["batch_size"] ) ) def start_training(args): diff --git a/AlphaGo/util.py b/AlphaGo/util.py index 3af05658a..40a494d89 100644 --- a/AlphaGo/util.py +++ b/AlphaGo/util.py @@ -42,18 +42,18 @@ def confirm(prompt=None, resp=False): def flatten_idx(position, size): """ - + """ - + (x, y) = position return x * size + y def unflatten_idx(idx, size): """ - + """ - + x, y = divmod(idx, size) return (x, y) @@ -62,7 +62,7 @@ def _parse_sgf_move(node_value): """ Given a well-formed move string, return either PASS_MOVE or the (x, y) position """ - + if node_value == '' or node_value == 'tt': return go.PASS else: @@ -77,7 +77,7 @@ def _sgf_init_gamestate(sgf_root): Helper function to set up a GameState object from the root node of an SGF file """ - + props = sgf_root.properties s_size = int( props.get('SZ', ['19'])[0] ) s_player = props.get('PL', ['B'])[0] @@ -100,7 +100,7 @@ def sgf_to_gamestate(sgf_string): """ Creates a GameState object from the first game in the given collection """ - + # Don't Repeat Yourself; parsing handled by sgf_iter_states for (gs, move, player) in sgf_iter_states(sgf_string, True): pass @@ -161,7 +161,7 @@ def sgf_iter_states(sgf_string, include_end=True): but the state will still be left in the final position at the end of iteration because 'gs' is modified in-place the state. See sgf_to_gamestate """ - + collection = sgf.parse(sgf_string) game = collection[0] gs = _sgf_init_gamestate(game.root) @@ -269,7 +269,7 @@ def plot_network_output(scores, board, history, out_directory, output_file, # Place red marker on last move if it exists if len(history) != 0: # If last move was not pass - if history[-1] != go.PASS_MOVE: + if history[-1] != go.PASS: last_move = history[-1] x_coord = last_move[0] + 1 y_coord = last_move[1] + 1 diff --git a/requirements.txt b/requirements.txt index 492f805ef..b3155854d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ Cython==0.25.2 h5py==2.6.0 -Keras==1.2.0 +Keras==2.0.4 numpy==1.11.2 pygtp==0.3 PyYAML==3.12 diff --git a/setup.py b/setup.py index 11046ae99..d37c51e7b 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from Cython.Build import cythonize setup( - + name = 'RocAlphaGo', # list with files to be cythonized ext_modules = cythonize( [ "AlphaGo/go.pyx", "AlphaGo/go_data.pyx", "AlphaGo/preprocessing/preprocessing.pyx" ] ), @@ -24,7 +24,7 @@ you can run all unittests to verify everything works as it should: python -m unittest discover - + nb. right now one test will fail: Super-ko """ \ No newline at end of file From 3eeef07894367d58bbdb5008b84b2f5f221ce5dc Mon Sep 17 00:00:00 2001 From: MaMiFreak Date: Wed, 21 Jun 2017 11:53:17 +0100 Subject: [PATCH 161/191] code style update code style update added rollout features(part) bug fixes --- AlphaGo/ai.py | 33 +++--- AlphaGo/go_python.py | 97 ++++++++-------- AlphaGo/models/nn_util.py | 2 +- AlphaGo/models/policy.py | 63 +++++------ AlphaGo/models/rollout.py | 6 +- AlphaGo/models/value.py | 64 +++++------ AlphaGo/preprocessing/preprocessing_python.py | 4 +- .../training/reinforcement_value_trainer.py | 5 +- AlphaGo/training/supervised_policy_trainer.py | 4 +- .../training/supervised_rollout_trainer.py | 22 ++-- AlphaGo/util.py | 6 +- interface/Play.py | 3 +- interface/gtp_wrapper.py | 8 +- tests/parseboard.py | 11 +- tests/test_data/minimodel_policy.json | 2 +- tests/test_data/minimodel_value.json | 2 +- tests/test_game_converter.py | 2 +- tests/test_gamestate.py | 42 ++++--- tests/test_ladders.py | 33 +++--- tests/test_mcts.py | 6 +- tests/test_policy.py | 30 ++--- tests/test_preprocessing.py | 105 +++++++++--------- tests/test_reinforcement_policy_trainer.py | 59 ++++++---- tests/test_reinforcement_value_trainer.py | 2 +- tests/test_supervised_policy_trainer.py | 2 +- tests/test_value.py | 30 ++--- 26 files changed, 323 insertions(+), 320 deletions(-) diff --git a/AlphaGo/ai.py b/AlphaGo/ai.py index 163888bd4..b66d0abb9 100644 --- a/AlphaGo/ai.py +++ b/AlphaGo/ai.py @@ -17,12 +17,12 @@ def __init__(self, policy_function, pass_when_offered=False, move_limit=None): def get_move(self, state): # check move limit - if self.move_limit is not None and len(state.get_history()) > self.move_limit: + if self.move_limit is not None and state.get_history_size() > self.move_limit: return go.PASS # check if pass was offered and we want to pass if self.pass_when_offered: - if len(state.get_history()) > 100 and state.get_history()[-1] == go.PASS: + if state.get_history_size() > 100 and state.get_history()[-1] == go.PASS: return go.PASS # list with sensible moves @@ -67,12 +67,12 @@ def apply_temperature(self, distribution): def get_move(self, state): # check move limit - if self.move_limit is not None and len(state.get_history()) > self.move_limit: + if self.move_limit is not None and state.get_history_size() > self.move_limit: return go.PASS # check if pass was offered and we want to pass if self.pass_when_offered: - if len(state.get_history()) > 100 and state.get_history()[-1] == go.PASS: + if state.get_history_size() > 100 and state.get_history()[-1] == go.PASS: return go.PASS # list with 'sensible' moves @@ -83,7 +83,7 @@ def get_move(self, state): move_probs = self.policy.eval_state(state, sensible_moves) - if self.greedy_start is not None and len(state.get_history()) >= self.greedy_start: + if self.greedy_start is not None and state.get_history_size() >= self.greedy_start: # greedy max_prob = max(move_probs, key=itemgetter(1)) @@ -112,10 +112,11 @@ def get_moves(self, states): all_moves_distributions = self.policy.batch_eval_state(states, sensible_move_lists) move_list = [None] * len(states) for i, move_probs in enumerate(all_moves_distributions): - if len(move_probs) == 0 or len(states[i].get_history()) > self.move_limit: + if len(move_probs) == 0 or states[i].get_history_size() > self.move_limit: move_list[i] = go.PASS else: - if self.greedy_start is not None and len(states[i].get_history()) >= self.greedy_start: + if self.greedy_start is not None and \ + states[i].get_history_size() >= self.greedy_start: # greedy max_prob = max(move_probs, key=itemgetter(1)) @@ -162,12 +163,12 @@ def apply_temperature(self, distribution): def get_move(self, state): # check move limit - if self.move_limit is not None and len(state.get_history()) > self.move_limit: + if self.move_limit is not None and state.get_history_size() > self.move_limit: return go.PASS # check if pass was offered and we want to pass if self.pass_when_offered: - if len(state.get_history()) > 100 and state.get_history()[-1] == go.PASS: + if state.get_history_size() > 100 and state.get_history()[-1] == go.PASS: return go.PASS # list with 'sensible' moves @@ -178,7 +179,8 @@ def get_move(self, state): move_probs = self.policy.eval_state(state, sensible_moves) - if self.greedy_start is not None and len(state.get_history()) >= self.greedy_start: + if self.greedy_start is not None and \ + state.get_history_size() >= self.greedy_start: # greedy max_prob = max(move_probs, key=itemgetter(1)) @@ -207,10 +209,11 @@ def get_moves(self, states): all_moves_distributions = self.policy.batch_eval_state(states, sensible_move_lists) move_list = [None] * len(states) for i, move_probs in enumerate(all_moves_distributions): - if len(move_probs) == 0 or len(states[i].get_history()) > self.move_limit: + if len(move_probs) == 0 or states[i].get_history_size() > self.move_limit: move_list[i] = go.PASS else: - if self.greedy_start is not None and len(states[i].get_history()) >= self.greedy_start: + if self.greedy_start is not None and \ + states[i].get_history_size() >= self.greedy_start: # greedy max_prob = max(move_probs, key=itemgetter(1)) @@ -260,12 +263,12 @@ def apply_temperature(self, distribution): def get_move(self, state): # check move limit - if self.move_limit is not None and len(state.get_history()) > self.move_limit: + if self.move_limit is not None and state.get_history_size() > self.move_limit: return go.PASS # check if pass was offered and we want to pass if self.pass_when_offered: - if len(state.get_history()) > 100 and state.get_history()[-1] == go.PASS: + if state.get_history_size() > 100 and state.get_history()[-1] == go.PASS: return go.PASS # list with 'sensible' moves @@ -284,7 +287,7 @@ def get_move(self, state): # evaluate all possble states probabilities = [self.value.eval_state(next_state) for next_state in state_list] - if self.greedy_start is not None and len(state.get_history()) >= self.greedy_start: + if self.greedy_start is not None and state.get_history_size() >= self.greedy_start: # greedy play move_probs = zip(legal_moves, probabilities) diff --git a/AlphaGo/go_python.py b/AlphaGo/go_python.py index e24693efb..b597a76ba 100644 --- a/AlphaGo/go_python.py +++ b/AlphaGo/go_python.py @@ -595,45 +595,45 @@ def get_pattern_non_response_3x3(self, position): # 8 surrounding position colours pattern_hash += self.board[x - 1][y - 1] + 2 pattern_hash *= 10 - pattern_hash += self.board[x - 1][y ] + 2 + pattern_hash += self.board[x - 1][y] + 2 pattern_hash *= 10 pattern_hash += self.board[x - 1][y + 1] + 2 pattern_hash *= 10 - pattern_hash += self.board[x ][y - 1] + 2 + pattern_hash += self.board[x][y - 1] + 2 pattern_hash *= 10 - pattern_hash += self.board[x ][y - 1] + 2 + pattern_hash += self.board[x][y - 1] + 2 pattern_hash *= 10 pattern_hash += self.board[x + 1][y - 1] + 2 pattern_hash *= 10 - pattern_hash += self.board[x + 1][y ] + 2 + pattern_hash += self.board[x + 1][y] + 2 pattern_hash *= 10 pattern_hash += self.board[x + 1][y + 1] + 2 pattern_hash *= 10 # 8 surrounding position liberties if self.board[x - 1][y - 1] != EMPTY: - pattern_hash += min( self.liberty_counts[x - 1][y - 1], 3 ) + pattern_hash += min(self.liberty_counts[x - 1][y - 1], 3) pattern_hash *= 10 - if self.board[x - 1][y ] != EMPTY: - pattern_hash += min( self.liberty_counts[x - 1][y ], 3 ) + if self.board[x - 1][y] != EMPTY: + pattern_hash += min(self.liberty_counts[x - 1][y], 3) pattern_hash *= 10 if self.board[x - 1][y + 1] != EMPTY: - pattern_hash += min( self.liberty_counts[x - 1][y + 1], 3 ) + pattern_hash += min(self.liberty_counts[x - 1][y + 1], 3) pattern_hash *= 10 - if self.board[x ][y - 1] != EMPTY: - pattern_hash += min( self.liberty_counts[x ][y - 1], 3 ) + if self.board[x][y - 1] != EMPTY: + pattern_hash += min(self.liberty_counts[x][y - 1], 3) pattern_hash *= 10 - if self.board[x ][y + 1] != EMPTY: - pattern_hash += min( self.liberty_counts[x ][y + 1], 3 ) + if self.board[x][y + 1] != EMPTY: + pattern_hash += min(self.liberty_counts[x][y + 1], 3) pattern_hash *= 10 if self.board[x + 1][y - 1] != EMPTY: - pattern_hash += min( self.liberty_counts[x + 1][y - 1], 3 ) + pattern_hash += min(self.liberty_counts[x + 1][y - 1], 3) pattern_hash *= 10 - if self.board[x + 1][y ] != EMPTY: - pattern_hash += min( self.liberty_counts[x + 1][y ], 3 ) + if self.board[x + 1][y] != EMPTY: + pattern_hash += min(self.liberty_counts[x + 1][y], 3) pattern_hash *= 10 if self.board[x + 1][y + 1] != EMPTY: - pattern_hash += min( self.liberty_counts[x + 1][y + 1], 3 ) + pattern_hash += min(self.liberty_counts[x + 1][y + 1], 3) return pattern_hash @@ -642,7 +642,7 @@ def get_pattern_nakade(self, position): def get_pattern_response_12d(self, position): - if len( self.history ) < 1: + if len(self.history) < 1: return -1 (xNew, yNew) = position @@ -668,80 +668,80 @@ def get_pattern_response_12d(self, position): # stones pattern_hash *= 10 - pattern_hash += self.board[x - 2][y ] + 2 + pattern_hash += self.board[x - 2][y] + 2 pattern_hash *= 10 pattern_hash += self.board[x - 1][y - 1] + 2 pattern_hash *= 10 - pattern_hash += self.board[x - 1][y ] + 2 + pattern_hash += self.board[x - 1][y] + 2 pattern_hash *= 10 pattern_hash += self.board[x - 1][y + 1] + 2 pattern_hash *= 10 - pattern_hash += self.board[x ][y - 2] + 2 + pattern_hash += self.board[x][y - 2] + 2 pattern_hash *= 10 - pattern_hash += self.board[x ][y - 1] + 2 + pattern_hash += self.board[x][y - 1] + 2 pattern_hash *= 10 - pattern_hash += self.board[x ][y ] + 2 + pattern_hash += self.board[x][y] + 2 pattern_hash *= 10 - pattern_hash += self.board[x ][y + 1] + 2 + pattern_hash += self.board[x][y + 1] + 2 pattern_hash *= 10 - pattern_hash += self.board[x ][y + 2] + 2 + pattern_hash += self.board[x][y + 2] + 2 pattern_hash *= 10 pattern_hash += self.board[x + 1][y - 1] + 2 pattern_hash *= 10 - pattern_hash += self.board[x + 1][y ] + 2 + pattern_hash += self.board[x + 1][y] + 2 pattern_hash *= 10 pattern_hash += self.board[x + 1][y + 1] + 2 pattern_hash *= 10 - pattern_hash += self.board[x + 2][y ] + 2 + pattern_hash += self.board[x + 2][y] + 2 # liberties pattern_hash *= 10 - if self.board[x -2][y ] != EMPTY: - pattern_hash += min( self.liberty_counts[x - 2][y ], 3 ) + if self.board[x - 2][y] != EMPTY: + pattern_hash += min(self.liberty_counts[x - 2][y], 3) pattern_hash *= 10 if self.board[x - 1][y - 1] != EMPTY: - pattern_hash += min( self.liberty_counts[x - 1][y - 1], 3 ) + pattern_hash += min(self.liberty_counts[x - 1][y - 1], 3) pattern_hash *= 10 - if self.board[x - 1][y ] != EMPTY: - pattern_hash += min( self.liberty_counts[x - 1][y ], 3 ) + if self.board[x - 1][y] != EMPTY: + pattern_hash += min(self.liberty_counts[x - 1][y], 3) pattern_hash *= 10 if self.board[x - 1][y + 1] != EMPTY: - pattern_hash += min( self.liberty_counts[x - 1][y + 1], 3 ) + pattern_hash += min(self.liberty_counts[x - 1][y + 1], 3) pattern_hash *= 10 - if self.board[x ][y - 2] != EMPTY: - pattern_hash += min( self.liberty_counts[x ][y - 2], 3 ) + if self.board[x][y - 2] != EMPTY: + pattern_hash += min(self.liberty_counts[x][y - 2], 3) pattern_hash *= 10 - if self.board[x ][y - 1] != EMPTY: - pattern_hash += min( self.liberty_counts[x ][y - 1], 3 ) + if self.board[x][y - 1] != EMPTY: + pattern_hash += min(self.liberty_counts[x][y - 1], 3) pattern_hash *= 10 - if self.board[x ][y ] != EMPTY: - pattern_hash += min( self.liberty_counts[x ][y ], 3 ) + if self.board[x][y] != EMPTY: + pattern_hash += min(self.liberty_counts[x][y], 3) pattern_hash *= 10 - if self.board[x ][y + 1] != EMPTY: - pattern_hash += min( self.liberty_counts[x ][y + 1], 3 ) + if self.board[x][y + 1] != EMPTY: + pattern_hash += min(self.liberty_counts[x][y + 1], 3) pattern_hash *= 10 - if self.board[x ][y + 2] != EMPTY: - pattern_hash += min( self.liberty_counts[x ][y + 2], 3 ) + if self.board[x][y + 2] != EMPTY: + pattern_hash += min(self.liberty_counts[x][y + 2], 3) pattern_hash *= 10 if self.board[x + 1][y - 1] != EMPTY: - pattern_hash += min( self.liberty_counts[x + 1][y - 1], 3 ) + pattern_hash += min(self.liberty_counts[x + 1][y - 1], 3) pattern_hash *= 10 - if self.board[x + 1][y ] != EMPTY: - pattern_hash += min( self.liberty_counts[x + 1][y ], 3 ) + if self.board[x + 1][y] != EMPTY: + pattern_hash += min(self.liberty_counts[x + 1][y], 3) pattern_hash *= 10 if self.board[x + 1][y + 1] != EMPTY: - pattern_hash += min( self.liberty_counts[x + 1][y + 1], 3 ) + pattern_hash += min(self.liberty_counts[x + 1][y + 1], 3) pattern_hash *= 10 - if self.board[x + 2][y ] != EMPTY: - pattern_hash += min( self.liberty_counts[x + 2][y ], 3 ) + if self.board[x + 2][y] != EMPTY: + pattern_hash += min(self.liberty_counts[x + 2][y], 3) return pattern_hash @@ -763,5 +763,6 @@ def get_8_connected(self, position): # 1 -> horzontal neighbor return value % 2 + class IllegalMove(Exception): pass diff --git a/AlphaGo/models/nn_util.py b/AlphaGo/models/nn_util.py index d08cc5122..23fe7c5f2 100644 --- a/AlphaGo/models/nn_util.py +++ b/AlphaGo/models/nn_util.py @@ -24,7 +24,7 @@ def __init__(self, feature_list, **kwargs): defaults = { "board": 19 } - defaults.update( kwargs ) + defaults.update(kwargs) self.preprocessor = Preprocess(feature_list, size=defaults["board"]) kwargs["input_dim"] = self.preprocessor.get_output_dimension() diff --git a/AlphaGo/models/policy.py b/AlphaGo/models/policy.py index d3f6e2529..ba8d78a30 100644 --- a/AlphaGo/models/policy.py +++ b/AlphaGo/models/policy.py @@ -6,7 +6,6 @@ from AlphaGo.models.nn_util import Bias, NeuralNetBase, neuralnet import numpy as np -import datetime @neuralnet class CNNPolicy(NeuralNetBase): @@ -40,22 +39,11 @@ def batch_eval_state(self, states, moves_lists=None): state_size = states[0].get_size() if not all([st.get_size() == state_size for st in states]): raise ValueError("all states must have the same size") - - time_preprocess = datetime.datetime.now() - # concatenate together all one-hot encoded states along the 'batch' dimension nn_input = np.concatenate([self.preprocessor.state_to_tensor(s) for s in states], axis=0) - time_preprocess = datetime.datetime.now() - time_preprocess - - print( "time preprocess: " + str( time_preprocess ) ) - - time_forward = datetime.datetime.now() # pass all input through the network at once (backend makes use of # batches if len(states) is large) network_output = self.forward(nn_input) - time_forward = datetime.datetime.now() - time_forward - - print( "time preprocess: " + str( time_preprocess ) + " forward: " + str( time_forward ) ) # default move lists to all legal moves moves_lists = moves_lists or [st.get_legal_moves() for st in states] results = [None] * n_states @@ -115,15 +103,14 @@ def create_network(**kwargs): activation='relu', padding='same', kernel_constraint=None, - activity_regularizer=None, + activity_regularizer=None, trainable=True, strides=[1, 1], use_bias=True, - bias_regularizer=None, - bias_constraint=None, + bias_regularizer=None, + bias_constraint=None, data_format="channels_first", kernel_regularizer=None)) - # create all other layers for i in range(2, params["layers"] + 1): @@ -142,15 +129,15 @@ def create_network(**kwargs): activation='relu', padding='same', kernel_constraint=None, - activity_regularizer=None, + activity_regularizer=None, trainable=True, strides=[1, 1], use_bias=True, - bias_regularizer=None, - bias_constraint=None, + bias_regularizer=None, + bias_constraint=None, data_format="channels_first", kernel_regularizer=None)) - + # the last layer maps each feature to a number network.add(Conv2D( filters=1, @@ -158,15 +145,15 @@ def create_network(**kwargs): kernel_initializer='uniform', padding='same', kernel_constraint=None, - activity_regularizer=None, + activity_regularizer=None, trainable=True, strides=[1, 1], use_bias=True, - bias_regularizer=None, - bias_constraint=None, + bias_regularizer=None, + bias_constraint=None, data_format="channels_first", kernel_regularizer=None)) - + # reshape output to be board x board network.add(Flatten()) # add a bias to each board location @@ -241,20 +228,20 @@ def create_network(**kwargs): convolution_path = Conv2D( input_shape=(), filters=params["filters_per_layer"], - kernel_size=(params["filter_width_1"],params["filter_width_1"]), + kernel_size=(params["filter_width_1"], params["filter_width_1"]), kernel_initializer='uniform', activation='linear', # relu activations done inside resnet modules padding='same', kernel_constraint=None, - activity_regularizer=None, + activity_regularizer=None, trainable=True, strides=[1, 1], use_bias=True, - bias_regularizer=None, - bias_constraint=None, + bias_regularizer=None, + bias_constraint=None, data_format="channels_first", kernel_regularizer=None)(model_input) - + def add_resnet_unit(path, K, **params): """Add a resnet unit to path starting at layer 'K', adding as many (ReLU + Conv2D) modules as specified by n_skip_K @@ -286,15 +273,15 @@ def add_resnet_unit(path, K, **params): activation='linear', padding='same', kernel_constraint=None, - activity_regularizer=None, + activity_regularizer=None, trainable=True, strides=[1, 1], use_bias=True, - bias_regularizer=None, - bias_constraint=None, + bias_regularizer=None, + bias_constraint=None, data_format="channels_first", kernel_regularizer=None)(path) - + # Merge 'input layer' with the path path = add([block_input, path]) return path, K + n_skip @@ -313,21 +300,21 @@ def add_resnet_unit(path, K, **params): # the last layer maps each featuer to a number convolution_path = Conv2D( filters=1, - kernel_size=( 1, 1 ), + kernel_size=(1, 1), kernel_initializer='uniform', name="policy_conv_last", padding='same', activation="linear", kernel_constraint=None, - activity_regularizer=None, + activity_regularizer=None, trainable=True, strides=[1, 1], use_bias=True, - bias_regularizer=None, - bias_constraint=None, + bias_regularizer=None, + bias_constraint=None, data_format="channels_first", kernel_regularizer=None)(convolution_path) - + # flatten output network_output = Flatten()(convolution_path) # add a bias to each board location diff --git a/AlphaGo/models/rollout.py b/AlphaGo/models/rollout.py index ef4dee145..75607a41f 100644 --- a/AlphaGo/models/rollout.py +++ b/AlphaGo/models/rollout.py @@ -64,12 +64,12 @@ def create_network(**kwargs): activation='relu', padding='same', kernel_constraint=None, - activity_regularizer=None, + activity_regularizer=None, trainable=True, strides=[1, 1], use_bias=True, - bias_regularizer=None, - bias_constraint=None, + bias_regularizer=None, + bias_constraint=None, data_format="channels_first", kernel_regularizer=None)) diff --git a/AlphaGo/models/value.py b/AlphaGo/models/value.py index 3ff8500e1..47486b4d2 100644 --- a/AlphaGo/models/value.py +++ b/AlphaGo/models/value.py @@ -88,12 +88,12 @@ def create_network(**kwargs): activation='relu', padding='same', kernel_constraint=None, - activity_regularizer=None, + activity_regularizer=None, trainable=True, strides=[1, 1], use_bias=True, - bias_regularizer=None, - bias_constraint=None, + bias_regularizer=None, + bias_constraint=None, data_format="channels_first", kernel_regularizer=None, name="con_first")) @@ -114,15 +114,15 @@ def create_network(**kwargs): activation='relu', padding='same', kernel_constraint=None, - activity_regularizer=None, + activity_regularizer=None, trainable=True, strides=[1, 1], use_bias=True, - bias_regularizer=None, - bias_constraint=None, + bias_regularizer=None, + bias_constraint=None, data_format="channels_first", kernel_regularizer=None, - name="con_" + str( i ))) + name="con_" + str(i))) # the last layer maps each feature to a number network.add(convolutional.Conv2D( @@ -132,42 +132,42 @@ def create_network(**kwargs): activation='relu', padding='same', kernel_constraint=None, - activity_regularizer=None, + activity_regularizer=None, trainable=True, strides=[1, 1], use_bias=True, - bias_regularizer=None, - bias_constraint=None, + bias_regularizer=None, + bias_constraint=None, data_format="channels_first", kernel_regularizer=None, name="con_last")) - + network.add(Flatten()) - - network.add(Dense(input_dim=params["board"] * params["board"], - units=params["dense"], - kernel_initializer='uniform', - activation='relu', - bias_regularizer=None, - bias_constraint=None, - activity_regularizer=None, + + network.add(Dense(input_dim=params["board"] * params["board"], + units=params["dense"], + kernel_initializer='uniform', + activation='relu', + bias_regularizer=None, + bias_constraint=None, + activity_regularizer=None, trainable=True, - kernel_constraint=None, - kernel_regularizer=None, + kernel_constraint=None, + kernel_regularizer=None, use_bias=True, name="dense_1")) - - network.add(Dense(input_dim=params["dense"], - units=1, - kernel_initializer='uniform', - activation="tanh", - bias_regularizer=None, - bias_constraint=None, - activity_regularizer=None, + + network.add(Dense(input_dim=params["dense"], + units=1, + kernel_initializer='uniform', + activation="tanh", + bias_regularizer=None, + bias_constraint=None, + activity_regularizer=None, trainable=True, - kernel_constraint=None, - kernel_regularizer=None, + kernel_constraint=None, + kernel_regularizer=None, use_bias=True, name="dense_2")) - + return network diff --git a/AlphaGo/preprocessing/preprocessing_python.py b/AlphaGo/preprocessing/preprocessing_python.py index 1bd0d5ad6..7391063b0 100644 --- a/AlphaGo/preprocessing/preprocessing_python.py +++ b/AlphaGo/preprocessing/preprocessing_python.py @@ -296,8 +296,8 @@ def state_to_tensor(self, state): # concatenate along feature dimension then add in a singleton 'batch' dimension f, s = self.output_dim, state.size - + tensor = np.concatenate(feat_tensors).reshape((1, f, s, s)) tensor = tensor.astype(np.int8) - + return tensor diff --git a/AlphaGo/training/reinforcement_value_trainer.py b/AlphaGo/training/reinforcement_value_trainer.py index bae16e580..99912d172 100644 --- a/AlphaGo/training/reinforcement_value_trainer.py +++ b/AlphaGo/training/reinforcement_value_trainer.py @@ -656,11 +656,12 @@ def train(metadata, out_directory, verbose, weight_file, meta_file): model.fit_generator( generator=train_data_generator, - steps_per_epoch=( metadata["epoch_length"] / metadata["batch_size"] ), + steps_per_epoch=(metadata["epoch_length"] / metadata["batch_size"]), epochs=(metadata["epochs"] - len(metadata["epoch_logs"])), callbacks=[meta_writer, lr_scheduler_callback], validation_data=val_data_generator, - validation_steps=( len(val_indices) / metadata["batch_size"] ) ) + validation_steps=(len(val_indices) / metadata["batch_size"])) + def start_training(args): # set resume diff --git a/AlphaGo/training/supervised_policy_trainer.py b/AlphaGo/training/supervised_policy_trainer.py index 482495738..19927a98d 100644 --- a/AlphaGo/training/supervised_policy_trainer.py +++ b/AlphaGo/training/supervised_policy_trainer.py @@ -666,11 +666,11 @@ def train(metadata, out_directory, verbose, weight_file, meta_file): model.fit_generator( generator=train_data_generator, - steps_per_epoch=( metadata["epoch_length"] / metadata["batch_size"] ), + steps_per_epoch=(metadata["epoch_length"] / metadata["batch_size"]), epochs=(metadata["epochs"] - len(metadata["epoch_logs"])), callbacks=[meta_writer, lr_scheduler_callback], validation_data=val_data_generator, - validation_steps=( len(val_indices) / metadata["batch_size"] ) ) + validation_steps=(len(val_indices) / metadata["batch_size"])) def start_training(args): diff --git a/AlphaGo/training/supervised_rollout_trainer.py b/AlphaGo/training/supervised_rollout_trainer.py index ef46a2b8e..96e22d652 100644 --- a/AlphaGo/training/supervised_rollout_trainer.py +++ b/AlphaGo/training/supervised_rollout_trainer.py @@ -76,8 +76,8 @@ def shuffle_indices(self, seed=None, idx=0): # create random seed self.metadata['generator_seed'] = np.random.random_integers(4294967295) - #print( ) - #print("shuffle " + str(self.validation)) + # print() + # print("shuffle " + str(self.validation)) # feed numpy.random with seed in order to continue with certain batch np.random.seed(self.metadata['generator_seed']) # shuffle indices according to seed @@ -646,14 +646,14 @@ def train(metadata, out_directory, verbose, weight_file, meta_file): validation=True) # check if step decay has to be applied - if metadata["decay_every"] is None: - # use normal decay without momentum - lr_scheduler_callback = LrDecayCallback(metadata) - else: - # use step decay - lr_scheduler_callback = LrStepDecayCallback(metadata, verbose) - - #sgd = SGD(lr=metadata["learning_rate"]) + # if metadata["decay_every"] is None: + # use normal decay without momentum + # lr_scheduler_callback = LrDecayCallback(metadata) + # else: + # use step decay + # lr_scheduler_callback = LrStepDecayCallback(metadata, verbose) + + # sgd = SGD(lr=metadata["learning_rate"]) sgd = Adam(lr=0.001, beta_1=0.99, beta_2=0.999, epsilon=1e-08, decay=0.0) model.compile(loss='categorical_crossentropy', optimizer=sgd, metrics=["accuracy"]) @@ -671,7 +671,7 @@ def train(metadata, out_directory, verbose, weight_file, meta_file): callbacks=[meta_writer], validation_data=val_data_generator, nb_val_samples=len(val_indices), - max_q_size = 5) + max_q_size=5) def start_training(args): diff --git a/AlphaGo/util.py b/AlphaGo/util.py index 40a494d89..5b535b6e1 100644 --- a/AlphaGo/util.py +++ b/AlphaGo/util.py @@ -79,10 +79,10 @@ def _sgf_init_gamestate(sgf_root): """ props = sgf_root.properties - s_size = int( props.get('SZ', ['19'])[0] ) + s_size = int(props.get('SZ', ['19'])[0]) s_player = props.get('PL', ['B'])[0] # init board with specified size - gs = GameState( size=s_size ) + gs = GameState(size=s_size) # handle 'add black' property if 'AB' in props: for stone in props['AB']: @@ -92,7 +92,7 @@ def _sgf_init_gamestate(sgf_root): for stone in props['AW']: gs.place_handicap_stone(_parse_sgf_move(stone), go.WHITE) # setup done; set player according to 'PL' property - gs.set_current_player( go.BLACK if s_player == 'B' else go.WHITE ) + gs.set_current_player(go.BLACK if s_player == 'B' else go.WHITE) return gs diff --git a/interface/Play.py b/interface/Play.py index 884db8d41..f74cca451 100644 --- a/interface/Play.py +++ b/interface/Play.py @@ -1,6 +1,7 @@ """Interface for AlphaGo self-play""" from AlphaGo.go import PASS, WHITE, GameState + class play_match(object): """Interface to handle play between two players.""" @@ -8,7 +9,7 @@ def __init__(self, player1, player2, save_dir=None, size=19): # super(ClassName, self).__init__() self.player1 = player1 self.player2 = player2 - self.state = GameState(size=size) + self.state = GameState(size=size) # I Propose that GameState should take a top-level save directory, # then automatically generate the specific file name diff --git a/interface/gtp_wrapper.py b/interface/gtp_wrapper.py index 791322462..ca74ce8b7 100644 --- a/interface/gtp_wrapper.py +++ b/interface/gtp_wrapper.py @@ -86,9 +86,9 @@ class GTPGameConnector(object): """ def __init__(self, player): - self._state = GameState()#enforce_superko=True - self._player = player - self._komi = 0 + self._state = GameState() # enforce_superko=True + self._player = player + self._komi = 0 def clear(self): self._state = GameState() @@ -106,7 +106,7 @@ def make_move(self, color, vertex): return False def set_size(self, n): - self._state = GameState(size=n)#enforce_superko=True + self._state = GameState(size=n) # enforce_superko=True def set_komi(self, k): self._komi = k diff --git a/tests/parseboard.py b/tests/parseboard.py index b575b3c75..517817088 100644 --- a/tests/parseboard.py +++ b/tests/parseboard.py @@ -2,16 +2,17 @@ def parse(boardstr): - '''Parses a board into a gamestate, and returns the location of any moves - marked with anything other than 'B', 'X', '#', 'W', 'O', or '.' + ''' + Parses a board into a gamestate, and returns the location of any moves + marked with anything other than 'B', 'X', '#', 'W', 'O', or '.' - Rows are separated by '|', spaces are ignored. + Rows are separated by '|', spaces are ignored. ''' - boardstr = boardstr.replace(' ', '') + boardstr = boardstr.replace(' ', '') board_size = max(boardstr.index('|'), boardstr.count('|')) - state = GameState( size = board_size ) + state = GameState(size=board_size) moves = {} diff --git a/tests/test_data/minimodel_policy.json b/tests/test_data/minimodel_policy.json index 3aa8182e3..cf17e8f95 100644 --- a/tests/test_data/minimodel_policy.json +++ b/tests/test_data/minimodel_policy.json @@ -1 +1 @@ -{"weights_file": "tests/test_data/hdf5/random_minimodel_policy_weights.hdf5", "keras_model": "{\"class_name\": \"Sequential\", \"keras_version\": \"1.1.2\", \"config\": [{\"class_name\": \"Convolution2D\", \"config\": {\"b_regularizer\": null, \"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_1\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 5, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"input_dtype\": \"float32\", \"border_mode\": \"same\", \"batch_input_shape\": [null, 12, 19, 19], \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 5}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_2\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_3\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_4\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_5\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_6\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 1, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 1, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"linear\", \"nb_row\": 1}}, {\"class_name\": \"Flatten\", \"config\": {\"trainable\": true, \"name\": \"flatten_1\"}}, {\"class_name\": \"Bias\", \"config\": {\"trainable\": true, \"name\": \"bias_1\"}}, {\"class_name\": \"Activation\", \"config\": {\"activation\": \"softmax\", \"trainable\": true, \"name\": \"activation_1\"}}]}", "class": "CNNPolicy", "feature_list": ["board", "ones", "turns_since"]} +{"weights_file": "tests/test_data/hdf5/random_minimodel_policy_weights.hdf5", "keras_model": "{\"class_name\": \"Sequential\", \"keras_version\": \"2.0.4\", \"config\": [{\"class_name\": \"Conv2D\", \"config\": {\"kernel_initializer\": {\"class_name\": \"RandomUniform\", \"config\": {\"maxval\": 0.05, \"seed\": null, \"minval\": -0.05}}, \"name\": \"conv2d_1\", \"kernel_constraint\": null, \"bias_regularizer\": null, \"bias_constraint\": null, \"dtype\": \"float32\", \"activation\": \"relu\", \"trainable\": true, \"data_format\": \"channels_first\", \"filters\": 16, \"padding\": \"same\", \"strides\": [1, 1], \"dilation_rate\": [1, 1], \"kernel_regularizer\": null, \"bias_initializer\": {\"class_name\": \"Zeros\", \"config\": {}}, \"batch_input_shape\": [null, 12, 19, 19], \"use_bias\": true, \"activity_regularizer\": null, \"kernel_size\": [5, 5]}}, {\"class_name\": \"Conv2D\", \"config\": {\"kernel_constraint\": null, \"kernel_initializer\": {\"class_name\": \"RandomUniform\", \"config\": {\"maxval\": 0.05, \"seed\": null, \"minval\": -0.05}}, \"name\": \"conv2d_2\", \"bias_regularizer\": null, \"bias_constraint\": null, \"activation\": \"relu\", \"trainable\": true, \"data_format\": \"channels_first\", \"padding\": \"same\", \"strides\": [1, 1], \"dilation_rate\": [1, 1], \"kernel_regularizer\": null, \"filters\": 16, \"bias_initializer\": {\"class_name\": \"Zeros\", \"config\": {}}, \"use_bias\": true, \"activity_regularizer\": null, \"kernel_size\": [3, 3]}}, {\"class_name\": \"Conv2D\", \"config\": {\"kernel_constraint\": null, \"kernel_initializer\": {\"class_name\": \"RandomUniform\", \"config\": {\"maxval\": 0.05, \"seed\": null, \"minval\": -0.05}}, \"name\": \"conv2d_3\", \"bias_regularizer\": null, \"bias_constraint\": null, \"activation\": \"relu\", \"trainable\": true, \"data_format\": \"channels_first\", \"padding\": \"same\", \"strides\": [1, 1], \"dilation_rate\": [1, 1], \"kernel_regularizer\": null, \"filters\": 16, \"bias_initializer\": {\"class_name\": \"Zeros\", \"config\": {}}, \"use_bias\": true, \"activity_regularizer\": null, \"kernel_size\": [3, 3]}}, {\"class_name\": \"Conv2D\", \"config\": {\"kernel_constraint\": null, \"kernel_initializer\": {\"class_name\": \"RandomUniform\", \"config\": {\"maxval\": 0.05, \"seed\": null, \"minval\": -0.05}}, \"name\": \"conv2d_4\", \"bias_regularizer\": null, \"bias_constraint\": null, \"activation\": \"relu\", \"trainable\": true, \"data_format\": \"channels_first\", \"padding\": \"same\", \"strides\": [1, 1], \"dilation_rate\": [1, 1], \"kernel_regularizer\": null, \"filters\": 16, \"bias_initializer\": {\"class_name\": \"Zeros\", \"config\": {}}, \"use_bias\": true, \"activity_regularizer\": null, \"kernel_size\": [3, 3]}}, {\"class_name\": \"Conv2D\", \"config\": {\"kernel_constraint\": null, \"kernel_initializer\": {\"class_name\": \"RandomUniform\", \"config\": {\"maxval\": 0.05, \"seed\": null, \"minval\": -0.05}}, \"name\": \"conv2d_5\", \"bias_regularizer\": null, \"bias_constraint\": null, \"activation\": \"relu\", \"trainable\": true, \"data_format\": \"channels_first\", \"padding\": \"same\", \"strides\": [1, 1], \"dilation_rate\": [1, 1], \"kernel_regularizer\": null, \"filters\": 16, \"bias_initializer\": {\"class_name\": \"Zeros\", \"config\": {}}, \"use_bias\": true, \"activity_regularizer\": null, \"kernel_size\": [3, 3]}}, {\"class_name\": \"Conv2D\", \"config\": {\"kernel_constraint\": null, \"kernel_initializer\": {\"class_name\": \"RandomUniform\", \"config\": {\"maxval\": 0.05, \"seed\": null, \"minval\": -0.05}}, \"name\": \"conv2d_6\", \"bias_regularizer\": null, \"bias_constraint\": null, \"activation\": \"linear\", \"trainable\": true, \"data_format\": \"channels_first\", \"padding\": \"same\", \"strides\": [1, 1], \"dilation_rate\": [1, 1], \"kernel_regularizer\": null, \"filters\": 1, \"bias_initializer\": {\"class_name\": \"Zeros\", \"config\": {}}, \"use_bias\": true, \"activity_regularizer\": null, \"kernel_size\": [1, 1]}}, {\"class_name\": \"Flatten\", \"config\": {\"trainable\": true, \"name\": \"flatten_1\"}}, {\"class_name\": \"Bias\", \"config\": {\"trainable\": true, \"name\": \"bias_1\"}}, {\"class_name\": \"Activation\", \"config\": {\"activation\": \"softmax\", \"trainable\": true, \"name\": \"activation_1\"}}], \"backend\": \"tensorflow\"}", "class": "CNNPolicy", "feature_list": ["board", "ones", "turns_since"]} \ No newline at end of file diff --git a/tests/test_data/minimodel_value.json b/tests/test_data/minimodel_value.json index 3bb60c90d..4273513d1 100644 --- a/tests/test_data/minimodel_value.json +++ b/tests/test_data/minimodel_value.json @@ -1 +1 @@ -{"weights_file": "tests/test_data/hdf5/random_minimodel_value_weights.hdf5", "keras_model": "{\"class_name\": \"Sequential\", \"keras_version\": \"1.2.0\", \"config\": [{\"class_name\": \"Convolution2D\", \"config\": {\"b_regularizer\": null, \"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_1\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 5, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"input_dtype\": \"float32\", \"border_mode\": \"same\", \"batch_input_shape\": [null, 49, 19, 19], \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 5}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_2\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_3\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_4\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_5\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_6\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 3, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 16, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 3}}, {\"class_name\": \"Convolution2D\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"convolution2d_7\", \"activity_regularizer\": null, \"trainable\": true, \"dim_ordering\": \"th\", \"nb_col\": 1, \"subsample\": [1, 1], \"init\": \"uniform\", \"bias\": true, \"nb_filter\": 1, \"border_mode\": \"same\", \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"nb_row\": 1}}, {\"class_name\": \"Flatten\", \"config\": {\"trainable\": true, \"name\": \"flatten_1\"}}, {\"class_name\": \"Dense\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"dense_1\", \"activity_regularizer\": null, \"trainable\": true, \"init\": \"uniform\", \"bias\": true, \"input_dim\": 361, \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"relu\", \"output_dim\": 256}}, {\"class_name\": \"Dense\", \"config\": {\"W_constraint\": null, \"b_constraint\": null, \"name\": \"dense_2\", \"activity_regularizer\": null, \"trainable\": true, \"init\": \"uniform\", \"bias\": true, \"input_dim\": 256, \"b_regularizer\": null, \"W_regularizer\": null, \"activation\": \"tanh\", \"output_dim\": 1}}]}", "class": "CNNValue", "feature_list": ["board", "ones", "turns_since", "liberties", "capture_size", "self_atari_size", "liberties_after", "ladder_capture", "ladder_escape", "sensibleness", "zeros", "color"]} +{"keras_model": "{\"class_name\": \"Sequential\", \"keras_version\": \"2.0.4\", \"config\": [{\"class_name\": \"Conv2D\", \"config\": {\"kernel_initializer\": {\"class_name\": \"RandomUniform\", \"config\": {\"maxval\": 0.05, \"seed\": null, \"minval\": -0.05}}, \"name\": \"con_first\", \"kernel_constraint\": null, \"bias_regularizer\": null, \"bias_constraint\": null, \"dtype\": \"float32\", \"activation\": \"relu\", \"trainable\": true, \"data_format\": \"channels_first\", \"filters\": 16, \"padding\": \"same\", \"strides\": [1, 1], \"dilation_rate\": [1, 1], \"kernel_regularizer\": null, \"bias_initializer\": {\"class_name\": \"Zeros\", \"config\": {}}, \"batch_input_shape\": [null, 49, 19, 19], \"use_bias\": true, \"activity_regularizer\": null, \"kernel_size\": [5, 5]}}, {\"class_name\": \"Conv2D\", \"config\": {\"kernel_constraint\": null, \"kernel_initializer\": {\"class_name\": \"RandomUniform\", \"config\": {\"maxval\": 0.05, \"seed\": null, \"minval\": -0.05}}, \"name\": \"con_2\", \"bias_regularizer\": null, \"bias_constraint\": null, \"activation\": \"relu\", \"trainable\": true, \"data_format\": \"channels_first\", \"padding\": \"same\", \"strides\": [1, 1], \"dilation_rate\": [1, 1], \"kernel_regularizer\": null, \"filters\": 16, \"bias_initializer\": {\"class_name\": \"Zeros\", \"config\": {}}, \"use_bias\": true, \"activity_regularizer\": null, \"kernel_size\": [3, 3]}}, {\"class_name\": \"Conv2D\", \"config\": {\"kernel_constraint\": null, \"kernel_initializer\": {\"class_name\": \"RandomUniform\", \"config\": {\"maxval\": 0.05, \"seed\": null, \"minval\": -0.05}}, \"name\": \"con_3\", \"bias_regularizer\": null, \"bias_constraint\": null, \"activation\": \"relu\", \"trainable\": true, \"data_format\": \"channels_first\", \"padding\": \"same\", \"strides\": [1, 1], \"dilation_rate\": [1, 1], \"kernel_regularizer\": null, \"filters\": 16, \"bias_initializer\": {\"class_name\": \"Zeros\", \"config\": {}}, \"use_bias\": true, \"activity_regularizer\": null, \"kernel_size\": [3, 3]}}, {\"class_name\": \"Conv2D\", \"config\": {\"kernel_constraint\": null, \"kernel_initializer\": {\"class_name\": \"RandomUniform\", \"config\": {\"maxval\": 0.05, \"seed\": null, \"minval\": -0.05}}, \"name\": \"con_4\", \"bias_regularizer\": null, \"bias_constraint\": null, \"activation\": \"relu\", \"trainable\": true, \"data_format\": \"channels_first\", \"padding\": \"same\", \"strides\": [1, 1], \"dilation_rate\": [1, 1], \"kernel_regularizer\": null, \"filters\": 16, \"bias_initializer\": {\"class_name\": \"Zeros\", \"config\": {}}, \"use_bias\": true, \"activity_regularizer\": null, \"kernel_size\": [3, 3]}}, {\"class_name\": \"Conv2D\", \"config\": {\"kernel_constraint\": null, \"kernel_initializer\": {\"class_name\": \"RandomUniform\", \"config\": {\"maxval\": 0.05, \"seed\": null, \"minval\": -0.05}}, \"name\": \"con_5\", \"bias_regularizer\": null, \"bias_constraint\": null, \"activation\": \"relu\", \"trainable\": true, \"data_format\": \"channels_first\", \"padding\": \"same\", \"strides\": [1, 1], \"dilation_rate\": [1, 1], \"kernel_regularizer\": null, \"filters\": 16, \"bias_initializer\": {\"class_name\": \"Zeros\", \"config\": {}}, \"use_bias\": true, \"activity_regularizer\": null, \"kernel_size\": [3, 3]}}, {\"class_name\": \"Conv2D\", \"config\": {\"kernel_constraint\": null, \"kernel_initializer\": {\"class_name\": \"RandomUniform\", \"config\": {\"maxval\": 0.05, \"seed\": null, \"minval\": -0.05}}, \"name\": \"con_6\", \"bias_regularizer\": null, \"bias_constraint\": null, \"activation\": \"relu\", \"trainable\": true, \"data_format\": \"channels_first\", \"padding\": \"same\", \"strides\": [1, 1], \"dilation_rate\": [1, 1], \"kernel_regularizer\": null, \"filters\": 16, \"bias_initializer\": {\"class_name\": \"Zeros\", \"config\": {}}, \"use_bias\": true, \"activity_regularizer\": null, \"kernel_size\": [3, 3]}}, {\"class_name\": \"Conv2D\", \"config\": {\"kernel_constraint\": null, \"kernel_initializer\": {\"class_name\": \"RandomUniform\", \"config\": {\"maxval\": 0.05, \"seed\": null, \"minval\": -0.05}}, \"name\": \"con_last\", \"bias_regularizer\": null, \"bias_constraint\": null, \"activation\": \"relu\", \"trainable\": true, \"data_format\": \"channels_first\", \"padding\": \"same\", \"strides\": [1, 1], \"dilation_rate\": [1, 1], \"kernel_regularizer\": null, \"filters\": 1, \"bias_initializer\": {\"class_name\": \"Zeros\", \"config\": {}}, \"use_bias\": true, \"activity_regularizer\": null, \"kernel_size\": [1, 1]}}, {\"class_name\": \"Flatten\", \"config\": {\"trainable\": true, \"name\": \"flatten_1\"}}, {\"class_name\": \"Dense\", \"config\": {\"kernel_initializer\": {\"class_name\": \"RandomUniform\", \"config\": {\"maxval\": 0.05, \"seed\": null, \"minval\": -0.05}}, \"name\": \"dense_1\", \"kernel_constraint\": null, \"bias_regularizer\": null, \"bias_constraint\": null, \"dtype\": \"float32\", \"activation\": \"relu\", \"trainable\": true, \"kernel_regularizer\": null, \"bias_initializer\": {\"class_name\": \"Zeros\", \"config\": {}}, \"units\": 256, \"batch_input_shape\": [null, 361], \"use_bias\": true, \"activity_regularizer\": null}}, {\"class_name\": \"Dense\", \"config\": {\"kernel_initializer\": {\"class_name\": \"RandomUniform\", \"config\": {\"maxval\": 0.05, \"seed\": null, \"minval\": -0.05}}, \"name\": \"dense_2\", \"kernel_constraint\": null, \"bias_regularizer\": null, \"bias_constraint\": null, \"dtype\": \"float32\", \"activation\": \"tanh\", \"trainable\": true, \"kernel_regularizer\": null, \"bias_initializer\": {\"class_name\": \"Zeros\", \"config\": {}}, \"units\": 1, \"batch_input_shape\": [null, 256], \"use_bias\": true, \"activity_regularizer\": null}}], \"backend\": \"tensorflow\"}", "class": "CNNValue", "feature_list": ["board", "ones", "turns_since", "liberties", "capture_size", "self_atari_size", "liberties_after", "ladder_capture", "ladder_escape", "sensibleness", "zeros", "color"]} \ No newline at end of file diff --git a/tests/test_game_converter.py b/tests/test_game_converter.py index 51533277b..8d661558c 100644 --- a/tests/test_game_converter.py +++ b/tests/test_game_converter.py @@ -6,7 +6,7 @@ class TestSGFLoading(unittest.TestCase): def test_ab_aw(self): - + with open('tests/test_data/sgf_with_handicap/ab_aw.sgf', 'r') as f: sgf_to_gamestate(f.read()) diff --git a/tests/test_gamestate.py b/tests/test_gamestate.py index 8552b5fc0..d0c055771 100644 --- a/tests/test_gamestate.py +++ b/tests/test_gamestate.py @@ -1,5 +1,4 @@ import unittest -import numpy as np import AlphaGo.go as go from AlphaGo.go import GameState @@ -7,9 +6,9 @@ class TestKo(unittest.TestCase): def test_standard_ko(self): - - gs = GameState( size = 9 ) - + + gs = GameState(size=9) + gs.do_move((1, 0)) # B gs.do_move((2, 0)) # W gs.do_move((0, 1)) # B @@ -31,9 +30,9 @@ def test_standard_ko(self): self.assertTrue(gs.is_legal((2, 1))) def test_snapback_is_not_ko(self): - - gs = GameState( size = 5 ) - + + gs = GameState(size=5) + # B o W B . # W W B . . # . . . . . @@ -62,9 +61,9 @@ def test_snapback_is_not_ko(self): self.assertEqual(gs.get_captures_white(), 1) def test_positional_superko(self): - - gs = GameState( size = 9 ) - + + gs = GameState(size=9) + move_list = [(0, 3), (0, 4), (1, 3), (1, 4), (2, 3), (2, 4), (2, 2), (3, 4), (2, 1), (3, 3), (3, 1), (3, 2), (3, 0), (4, 2), (1, 1), (4, 1), (8, 0), (4, 0), (8, 1), (0, 2), (8, 2), (0, 1), (8, 3), (1, 0), (8, 4), (2, 0), (0, 0)] @@ -74,7 +73,6 @@ def test_positional_superko(self): self.assertTrue(gs.is_legal((1, 0))) # gs = GameState(size=9, enforce_superko=True) super ko is not handled yet - gs = GameState( size = 9 ) for move in move_list: gs.do_move(move) self.assertFalse(gs.is_legal((1, 0))) @@ -83,9 +81,9 @@ def test_positional_superko(self): class TestEye(unittest.TestCase): def test_true_eye(self): - - gs = GameState( size = 7 ) - + + gs = GameState(size=7) + gs.do_move((1, 0), go.BLACK) gs.do_move((0, 1), go.BLACK) @@ -105,9 +103,9 @@ def test_true_eye(self): def test_eye_recursion(self): # a checkerboard pattern of black is 'technically' all true eyes # mutually supporting each other - - gs = GameState( size = 7 ) - + + gs = GameState(size=7) + for x in range(gs.get_size()): for y in range(gs.get_size()): if (x + y) % 2 == 1: @@ -121,9 +119,9 @@ def test_liberties_after_capture(self): # creates 3x3 black group in the middle, that is then all captured # ...then an assertion is made that the resulting liberties after # capture are the same as if the group had never been there - - gs_capture = GameState( size = 7 ) - gs_reference = GameState( size = 7 ) + + gs_capture = GameState(size=7) + gs_reference = GameState(size=7) # add in 3x3 black stones for x in range(2, 5): for y in range(2, 5): @@ -144,8 +142,8 @@ def test_liberties_after_capture(self): gs_reference.do_move((5, y), go.WHITE) # board configuration and liberties of gs_capture and of gs_reference should be identical - self.assertTrue( gs_reference.is_board_equal(gs_capture )) - self.assertTrue( gs_reference.is_liberty_equal(gs_capture )) + self.assertTrue(gs_reference.is_board_equal(gs_capture)) + self.assertTrue(gs_reference.is_liberty_equal(gs_capture)) if __name__ == '__main__': diff --git a/tests/test_ladders.py b/tests/test_ladders.py index 3d552d12b..d358ea8f5 100644 --- a/tests/test_ladders.py +++ b/tests/test_ladders.py @@ -5,14 +5,14 @@ class TestLadder(unittest.TestCase): def test_captured_1(self): - + st, moves = parseboard.parse("d b c . . . .|" "B W a . . . .|" ". B . . . . .|" ". . . . . . .|" ". . . . . . .|" ". . . . . W .|") - st.set_current_player( BLACK ) + st.set_current_player(BLACK) # 'a' should catch white in a ladder, but not 'b' self.assertTrue(st.is_ladder_capture(moves['a'])) @@ -28,7 +28,7 @@ def test_captured_1(self): self.assertFalse(st.is_ladder_capture(moves['d'])) # self-atari def test_breaker_1(self): - + st, moves = parseboard.parse(". B . . . . .|" "B W a . . W .|" "B b . . . . .|" @@ -36,7 +36,7 @@ def test_breaker_1(self): ". . . . . . .|" ". . . . . W .|" ". . . . . . .|") - st.set_current_player( BLACK ) + st.set_current_player(BLACK) # 'a' should not be a ladder capture, nor 'b' self.assertFalse(st.is_ladder_capture(moves['a'])) @@ -51,7 +51,7 @@ def test_breaker_1(self): self.assertFalse(st.is_ladder_capture(moves['c'])) def test_missing_ladder_breaker_1(self): - + st, moves = parseboard.parse(". B . . . . .|" "B W B . . W .|" "B a c . . . .|" @@ -59,7 +59,7 @@ def test_missing_ladder_breaker_1(self): ". . . . . . .|" ". W . . . . .|" ". . . . . . .|") - st.set_current_player( WHITE ) + st.set_current_player(WHITE) # a should not be an escape move for white self.assertFalse(st.is_ladder_escape(moves['a'])) @@ -71,27 +71,27 @@ def test_missing_ladder_breaker_1(self): self.assertFalse(st.is_ladder_capture(moves['c'])) def test_capture_to_escape_1(self): - + st, moves = parseboard.parse(". O X . . .|" ". X O X . .|" ". . O X . .|" ". . a . . .|" ". O . . . .|" ". . . . . .|") - st.set_current_player( BLACK ) + st.set_current_player(BLACK) # 'a' is not a capture because of ataris self.assertFalse(st.is_ladder_capture(moves['a'])) def test_throw_in_1(self): - + st, moves = parseboard.parse("X a O X . .|" "b O O X . .|" "O O X X . .|" "X X . . . .|" ". . . . . .|" ". . . O . .|") - st.set_current_player( BLACK ) + st.set_current_player(BLACK) # 'a' or 'b' will capture self.assertTrue(st.is_ladder_capture(moves['a'])) @@ -102,7 +102,7 @@ def test_throw_in_1(self): self.assertFalse(st.is_ladder_escape(moves['b'])) def test_snapback_1(self): - + st, moves = parseboard.parse(". . . . . . . . .|" ". . . . . . . . .|" ". . X X X . . . .|" @@ -113,28 +113,27 @@ def test_snapback_1(self): ". . . X . . . . .|" ". . . . . . . . .|") - st.set_current_player( WHITE ) + st.set_current_player(WHITE) # 'a' is not an escape for white self.assertFalse(st.is_ladder_escape(moves['a'])) - def test_two_captures(self): - + st, moves = parseboard.parse(". . . . . .|" ". . . . . .|" ". . a b . .|" ". X O O X .|" ". . X X . .|" ". . . . . .|") - st.set_current_player( BLACK ) + st.set_current_player(BLACK) # both 'a' and 'b' should be ladder captures self.assertTrue(st.is_ladder_capture(moves['a'])) self.assertTrue(st.is_ladder_capture(moves['b'])) def test_two_escapes(self): - + st, moves = parseboard.parse(". . X . . .|" ". X O a . .|" ". X c X . .|" @@ -144,7 +143,7 @@ def test_two_escapes(self): # place a white stone at c, and reset player to white st.do_move(moves['c'], color=WHITE) - st.set_current_player( WHITE ) + st.set_current_player(WHITE) # both 'a' and 'b' should be considered escape moves for white after 'O' at c self.assertTrue(st.is_ladder_escape(moves['a'])) diff --git a/tests/test_mcts.py b/tests/test_mcts.py index e5d34592c..7ca8e316b 100644 --- a/tests/test_mcts.py +++ b/tests/test_mcts.py @@ -8,7 +8,7 @@ class TestTreeNode(unittest.TestCase): def setUp(self): - self.gs = GameState() + self.gs = GameState() self.node = TreeNode(None, 1.0) def test_selection(self): @@ -55,7 +55,7 @@ def test_update_recursive(self): class TestMCTS(unittest.TestCase): def setUp(self): - self.gs = GameState() + self.gs = GameState() self.mcts = MCTS(dummy_value, dummy_policy, dummy_rollout, n_playout=2) def _count_expansions(self): @@ -132,4 +132,4 @@ def dummy_value(state): if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main() diff --git a/tests/test_policy.py b/tests/test_policy.py index edbaf788c..088a794cb 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -10,30 +10,30 @@ class TestCNNPolicy(unittest.TestCase): def test_default_policy(self): - + policy = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"]) policy.eval_state(GameState()) # just hope nothing breaks def test_batch_eval_state(self): - + policy = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"]) results = policy.batch_eval_state([GameState(), GameState()]) self.assertEqual(len(results), 2) # one result per GameState self.assertEqual(len(results[0]), 361) # each one has 361 (move,prob) pairs def test_output_size(self): - + policy19 = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"], board=19) output = policy19.forward(policy19.preprocessor.state_to_tensor(GameState())) self.assertEqual(output.shape, (1, 19 * 19)) policy13 = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"], board=13) - output = policy13.forward(policy13.preprocessor.state_to_tensor(GameState( size = 13 ))) + output = policy13.forward(policy13.preprocessor.state_to_tensor(GameState(size=13))) self.assertEqual(output.shape, (1, 13 * 13)) def test_save_load(self): - + policy = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"]) model_file = 'TESTPOLICY.json' @@ -63,13 +63,13 @@ def test_save_load(self): class TestResnetPolicy(unittest.TestCase): def test_default_policy(self): - + policy = ResnetPolicy(["board", "liberties", "sensibleness", "capture_size"]) policy.eval_state(GameState()) # just hope nothing breaks def test_batch_eval_state(self): - + policy = ResnetPolicy(["board", "liberties", "sensibleness", "capture_size"]) results = policy.batch_eval_state([GameState(), GameState()]) self.assertEqual(len(results), 2) # one result per GameState @@ -79,7 +79,7 @@ def test_save_load(self): """ Identical to above test_save_load """ - + policy = ResnetPolicy(["board", "liberties", "sensibleness", "capture_size"]) model_file = 'TESTPOLICY.json' @@ -113,7 +113,7 @@ def test_save_load(self): class TestPlayers(unittest.TestCase): def test_greedy_player(self): - + gs = GameState() policy = CNNPolicy(["board", "ones", "turns_since"]) player = GreedyPolicyPlayer(policy) @@ -123,7 +123,7 @@ def test_greedy_player(self): gs.do_move(move) def test_probabilistic_player(self): - + gs = GameState() policy = CNNPolicy(["board", "ones", "turns_since"]) player = ProbabilisticPolicyPlayer(policy) @@ -133,7 +133,7 @@ def test_probabilistic_player(self): gs.do_move(move) def test_sensible_probabilistic(self): - + gs = GameState() policy = CNNPolicy(["board", "ones", "turns_since"]) player = ProbabilisticPolicyPlayer(policy) @@ -142,11 +142,11 @@ def test_sensible_probabilistic(self): for y in range(19): if (x, y) != empty: gs.do_move((x, y), go.BLACK) - gs.set_current_player( go.BLACK ) + gs.set_current_player(go.BLACK) self.assertEqual(player.get_move(gs), go.PASS) def test_sensible_greedy(self): - + gs = GameState() policy = CNNPolicy(["board", "ones", "turns_since"]) player = GreedyPolicyPlayer(policy) @@ -155,8 +155,8 @@ def test_sensible_greedy(self): for y in range(19): if (x, y) != empty: gs.do_move((x, y), go.BLACK) - - gs.set_current_player( go.BLACK ) + + gs.set_current_player(go.BLACK) self.assertEqual(player.get_move(gs), go.PASS) diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index 582ceda61..27705cf46 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -8,11 +8,11 @@ def simple_board(): """ - + """ - - gs = GameState( size = 7 ) - + + gs = GameState(size=7) + # make a tiny board for the sake of testing and hand-coding expected results # # X @@ -43,17 +43,17 @@ def simple_board(): gs.do_move((5, 3)) # B gs.do_move((4, 3)) # W - the ko position gs.do_move((4, 4)) # B - does the capture - + return gs def self_atari_board(): """ - + """ - - gs = GameState( size = 7 ) - + + gs = GameState(size=7) + # another tiny board for testing self-atari specifically. # positions marked with 'a' are self-atari for black # @@ -85,13 +85,14 @@ def self_atari_board(): return gs + def capture_board(): """ - + """ - - gs = GameState( size = 7 ) - + + gs = GameState(size=7) + # another small board, this one with imminent captures # # X @@ -113,8 +114,8 @@ def capture_board(): gs.do_move(B, go.BLACK) for W in white: gs.do_move(W, go.WHITE) - gs.set_current_player( go.BLACK ) - + gs.set_current_player(go.BLACK) + return gs @@ -128,7 +129,7 @@ class TestPreprocessingFeatures(unittest.TestCase): """ def test_get_board(self): - + gs = simple_board() pp = Preprocess(["board"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) @@ -159,9 +160,9 @@ def test_get_board(self): def test_get_turns_since(self): """ - + """ - + gs = simple_board() pp = Preprocess(["turns_since"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) @@ -170,7 +171,7 @@ def test_get_turns_since(self): rev_moves = list(gs.get_history()) rev_moves = rev_moves[::-1] - + board = gs.get_board() for x in range(gs.get_size()): @@ -184,9 +185,9 @@ def test_get_turns_since(self): def test_get_liberties(self): """ - + """ - + gs = simple_board() pp = Preprocess(["liberties"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) @@ -221,9 +222,9 @@ def test_get_liberties(self): def test_get_capture_size(self): """ - + """ - + gs = capture_board() pp = Preprocess(["capture_size"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) @@ -244,9 +245,9 @@ def test_get_capture_size(self): def test_get_self_atari_size(self): """ - + """ - + gs = self_atari_board() pp = Preprocess(["self_atari_size"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) @@ -261,9 +262,9 @@ def test_get_self_atari_size(self): def test_get_self_atari_size_cap(self): """ - + """ - + gs = capture_board() pp = Preprocess(["self_atari_size"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) @@ -279,9 +280,9 @@ def test_get_self_atari_size_cap(self): def test_get_liberties_after(self): """ - + """ - + gs = simple_board() pp = Preprocess(["liberties_after"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) @@ -292,9 +293,9 @@ def test_get_liberties_after(self): for (x, y) in gs.get_legal_moves(): copy = gs.copy() copy.do_move((x, y)) - + liberty = copy.get_liberty() - + libs = liberty[x, y] if libs < 7: one_hot_liberties[x, y, libs - 1] = 1 @@ -310,7 +311,7 @@ def test_get_liberties_after_cap(self): """ A copy of test_get_liberties_after but where captures are imminent """ - + gs = capture_board() pp = Preprocess(["liberties_after"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) @@ -320,9 +321,9 @@ def test_get_liberties_after_cap(self): for (x, y) in gs.get_legal_moves(): copy = gs.copy() copy.do_move((x, y)) - + liberty = copy.get_liberty() - + libs = liberty[x, y] one_hot_liberties[x, y, min(libs - 1, 7)] = 1 @@ -333,9 +334,9 @@ def test_get_liberties_after_cap(self): def test_get_ladder_capture(self): """ - + """ - + gs, moves = parseboard.parse(". . . . . . .|" "B W a . . . .|" ". B . . . . .|" @@ -352,9 +353,9 @@ def test_get_ladder_capture(self): def test_get_ladder_escape(self): """ - + """ - + # On this board, playing at 'a' is ladder escape because there is a breaker on the right. gs, moves = parseboard.parse(". B B . . . .|" "B W a . . . .|" @@ -363,7 +364,7 @@ def test_get_ladder_escape(self): ". . . . . . .|" ". . . . . . .|") pp = Preprocess(["ladder_escape"], size=7) - gs.set_current_player( go.WHITE ) + gs.set_current_player(go.WHITE) feature = pp.state_to_tensor(gs)[0, 0] # 1D tensor; no need to transpose expectation = np.zeros((gs.get_size(), gs.get_size())) @@ -373,9 +374,9 @@ def test_get_ladder_escape(self): def test_two_escapes(self): """ - + """ - + gs, moves = parseboard.parse(". . X . . .|" ". X O a . .|" ". X c X . .|" @@ -385,10 +386,10 @@ def test_two_escapes(self): # place a white stone at c, and reset player to white gs.do_move(moves['c'], color=go.WHITE) - gs.set_current_player( go.WHITE ) - + gs.set_current_player(go.WHITE) + pp = Preprocess(["ladder_escape"], size=6) - gs.set_current_player( go.WHITE ) + gs.set_current_player(go.WHITE) feature = pp.state_to_tensor(gs)[0, 0] # 1D tensor; no need to transpose # both 'a' and 'b' should be considered escape moves for white after 'O' at c @@ -398,13 +399,12 @@ def test_two_escapes(self): expectation[moves['b']] = 1 self.assertTrue(np.all(expectation == feature)) - - + def test_get_sensibleness(self): """ - + """ - + # TODO - there are no legal eyes at the moment gs = simple_board() @@ -419,9 +419,9 @@ def test_get_sensibleness(self): def test_get_legal(self): """ - + """ - + gs = simple_board() pp = Preprocess(["legal"], size=7) feature = pp.state_to_tensor(gs)[0, 0] # 1D tensor; no need to transpose @@ -433,9 +433,9 @@ def test_get_legal(self): def test_feature_concatenation(self): """ - + """ - + gs = simple_board() pp = Preprocess(["board", "sensibleness", "capture_size"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) @@ -459,7 +459,6 @@ def test_feature_concatenation(self): expectation[x, y, 4] = 1 self.assertTrue(np.all(expectation == feature)) - if __name__ == '__main__': diff --git a/tests/test_reinforcement_policy_trainer.py b/tests/test_reinforcement_policy_trainer.py index 0755c645d..b12da0acf 100644 --- a/tests/test_reinforcement_policy_trainer.py +++ b/tests/test_reinforcement_policy_trainer.py @@ -23,7 +23,7 @@ def _list_mock_games(path): def get_sgf_move_probs(sgf_game, policy, player): - + with open(sgf_game, "r") as f: sgf_game = f.read() @@ -40,7 +40,7 @@ def get_single_prob(move, move_probs): class MockPlayer(object): def __init__(self, policy, sgf_game): - + with open(sgf_game, "r") as f: sgf_game = f.read() self.moves = [move for (_, move, _) in sgf_iter_states(sgf_game)] @@ -57,23 +57,25 @@ def __init__(self, predetermined_winner, length, *args, **kwargs): super(MockState, self).__init__(*args, **kwargs) self.predetermined_winner = predetermined_winner self.length = length - + def get_winner(self): return self.predetermined_winner - + def is_end_of_game(self): if len(self.get_history()) > self.length: return True return False - + class TestReinforcementPolicyTrainer(unittest.TestCase): def testTrain(self): model = os.path.join('tests', 'test_data', 'minimodel_policy.json') - init_weights = os.path.join('tests', 'test_data', 'hdf5', 'random_minimodel_policy_weights.hdf5') + init_weights = os.path.join('tests', 'test_data', 'hdf5', + 'random_minimodel_policy_weights.hdf5') output = os.path.join('tests', 'test_data', '.tmp.rl.training/') - args = [model, init_weights, output, '--game-batch', '1', '--iterations', '1'] + args = [model, init_weights, output, '--game-batch', '1', '--iterations', + '1', '--record-every', '1'] run_training(args) os.remove(os.path.join(output, 'metadata.json')) @@ -84,12 +86,14 @@ def testTrain(self): def testGradientDirectionChangesWithGameResult(self): def run_and_get_new_weights(init_weights, winners, game): - + # Create "mock" states that end after 2 moves with a predetermined winner. states = [MockState(winner, 2, size=19) for winner in winners] - policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel_policy.json')) - policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel_policy.json')) + policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', + 'minimodel_policy.json')) + policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', + 'minimodel_policy.json')) policy1.model.set_weights(init_weights) optimizer = SGD(lr=0.001) policy1.model.compile(loss=log_loss, optimizer=optimizer) @@ -103,13 +107,16 @@ def run_and_get_new_weights(init_weights, winners, game): return policy1.model.get_weights() def test_game_gradient(game): - - policy = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel_policy.json')) + + policy = CNNPolicy.load_model(os.path.join('tests', 'test_data', + 'minimodel_policy.json')) initial_parameters = policy.model.get_weights() # Cases 1 and 2 have identical starting models and identical (state, action) pairs, # but they differ in who won the games. - parameters1 = run_and_get_new_weights(initial_parameters, [go.BLACK, go.WHITE], game) - parameters2 = run_and_get_new_weights(initial_parameters, [go.WHITE, go.BLACK], game) + parameters1 = run_and_get_new_weights(initial_parameters, + [go.BLACK, go.WHITE], game) + parameters2 = run_and_get_new_weights(initial_parameters, + [go.WHITE, go.BLACK], game) # Assert that some parameters changed. any_change_1 = any(not np.array_equal(i, p1) for (i, p1) in zip(initial_parameters, @@ -131,9 +138,11 @@ def test_game_gradient(game): def testRunNGamesUpdatesWeights(self): def test_game_run_N(game): - - policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel_policy.json')) - policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel_policy.json')) + + policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', + 'minimodel_policy.json')) + policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', + 'minimodel_policy.json')) learner = MockPlayer(policy1, game) opponent = MockPlayer(policy2, game) optimizer = SGD() @@ -156,11 +165,13 @@ def test_game_run_N(game): def testWinIncreasesMoveProbability(self): def test_game_increase(game): - + # Create "mock" state that ends after 20 moves with the learner winnning win_state = [MockState(go.BLACK, 20, size=19)] - policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel_policy.json')) - policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel_policy.json')) + policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', + 'minimodel_policy.json')) + policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', + 'minimodel_policy.json')) learner = MockPlayer(policy1, game) opponent = MockPlayer(policy2, game) optimizer = SGD() @@ -185,11 +196,13 @@ def test_game_increase(game): def testLoseDecreasesMoveProbability(self): def test_game_decrease(game): - + # Create "mock" state that ends after 20 moves with the learner losing lose_state = [MockState(go.WHITE, 20, size=19)] - policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel_policy.json')) - policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', 'minimodel_policy.json')) + policy1 = CNNPolicy.load_model(os.path.join('tests', 'test_data', + 'minimodel_policy.json')) + policy2 = CNNPolicy.load_model(os.path.join('tests', 'test_data', + 'minimodel_policy.json')) learner = MockPlayer(policy1, game) opponent = MockPlayer(policy2, game) optimizer = SGD() diff --git a/tests/test_reinforcement_value_trainer.py b/tests/test_reinforcement_value_trainer.py index 540c2aa26..760a0c5d7 100644 --- a/tests/test_reinforcement_value_trainer.py +++ b/tests/test_reinforcement_value_trainer.py @@ -28,7 +28,7 @@ def test_save_load(self): # test shape def test_ouput_shape(self): - + gs = GameState() val = self.value.eval_state(gs) diff --git a/tests/test_supervised_policy_trainer.py b/tests/test_supervised_policy_trainer.py index e97b53234..c1d8577ac 100644 --- a/tests/test_supervised_policy_trainer.py +++ b/tests/test_supervised_policy_trainer.py @@ -99,4 +99,4 @@ def testStateAmount(self): if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main() diff --git a/tests/test_value.py b/tests/test_value.py index 72ff1f641..a1ba3e471 100644 --- a/tests/test_value.py +++ b/tests/test_value.py @@ -10,15 +10,15 @@ class TestCNNValue(unittest.TestCase): def test_default_value(self): - + state = GameState() - + value = CNNValue(["board", "liberties", "sensibleness", "capture_size"]) value.eval_state(state) # just hope nothing breaks def test_batch_eval_state(self): - + value = CNNValue(["board", "liberties", "sensibleness", "capture_size"]) results = value.batch_eval_state([GameState(), GameState()]) self.assertEqual(len(results), 2) # one result per GameState @@ -26,15 +26,15 @@ def test_batch_eval_state(self): self.assertTrue(isinstance(results[1], np.float64)) def test_output_size(self): - + state = GameState() - + value19 = CNNValue(["board", "liberties", "sensibleness", "capture_size"], board=19) output = value19.forward(value19.preprocessor.state_to_tensor(state)) self.assertEqual(output.shape, (1, 1)) - state = GameState( size=13 ) - + state = GameState(size=13) + value13 = CNNValue(["board", "liberties", "sensibleness", "capture_size"], board=13) output = value13.forward(value13.preprocessor.state_to_tensor(state)) self.assertEqual(output.shape, (1, 1)) @@ -71,8 +71,8 @@ class TestValuePlayers(unittest.TestCase): def test_greedy_player(self): - gs = GameState( size = 9 ) - + gs = GameState(size=9) + value = CNNValue(["board", "ones", "turns_since"], board=9) player = ValuePlayer(value, greedy_start=0) for i in range(10): @@ -82,8 +82,8 @@ def test_greedy_player(self): def test_probabilistic_player(self): - gs = GameState( size = 9 ) - + gs = GameState(size=9) + value = CNNValue(["board", "ones", "turns_since"], board=9) player = ValuePlayer(value) for i in range(10): @@ -94,7 +94,7 @@ def test_probabilistic_player(self): def test_sensible_probabilistic(self): gs = GameState() - + value = CNNValue(["board", "ones", "turns_since"]) player = ValuePlayer(value) empty = (10, 10) @@ -102,13 +102,13 @@ def test_sensible_probabilistic(self): for y in range(19): if (x, y) != empty: gs.do_move((x, y), go.BLACK) - gs.set_current_player( go.BLACK ) + gs.set_current_player(go.BLACK) self.assertEqual(player.get_move(gs), go.PASS) def test_sensible_greedy(self): gs = GameState() - + value = CNNValue(["board", "ones", "turns_since"]) player = ValuePlayer(value, greedy_start=0) empty = (10, 10) @@ -116,7 +116,7 @@ def test_sensible_greedy(self): for y in range(19): if (x, y) != empty: gs.do_move((x, y), go.BLACK) - gs.set_current_player( go.BLACK ) + gs.set_current_player(go.BLACK) self.assertEqual(player.get_move(gs), go.PASS) From 9ba8d01a7aedb5382d8a71ec72f855efdd4ce358 Mon Sep 17 00:00:00 2001 From: MaMiFreak Date: Wed, 21 Jun 2017 13:18:00 +0100 Subject: [PATCH 162/191] code style update --- AlphaGo/training/reinforcement_policy_trainer.py | 2 +- setup.py | 7 ++++--- tests/test_gamestate.py | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 29dd2beac..46b01b7f2 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -4,12 +4,12 @@ import AlphaGo.go as go import keras.backend as K from shutil import copyfile -from AlphaGo.go import GameState from keras.optimizers import SGD from AlphaGo.util import flatten_idx from AlphaGo.models.policy import CNNPolicy from AlphaGo.ai import ProbabilisticPolicyPlayer + def _make_training_pair(st, mv, preprocessor): # Convert move to one-hot st_tensor = preprocessor.state_to_tensor(st) diff --git a/setup.py b/setup.py index d37c51e7b..ff007817d 100644 --- a/setup.py +++ b/setup.py @@ -5,9 +5,10 @@ setup( - name = 'RocAlphaGo', + name='RocAlphaGo', # list with files to be cythonized - ext_modules = cythonize( [ "AlphaGo/go.pyx", "AlphaGo/go_data.pyx", "AlphaGo/preprocessing/preprocessing.pyx" ] ), + ext_modules=cythonize(["AlphaGo/go.pyx", "AlphaGo/go_data.pyx", + "AlphaGo/preprocessing/preprocessing.pyx"]), # include numpy include_dirs=[numpy.get_include(), os.path.join(numpy.get_include(), 'numpy')] @@ -27,4 +28,4 @@ nb. right now one test will fail: Super-ko -""" \ No newline at end of file +""" diff --git a/tests/test_gamestate.py b/tests/test_gamestate.py index d0c055771..4f84ddc07 100644 --- a/tests/test_gamestate.py +++ b/tests/test_gamestate.py @@ -73,6 +73,7 @@ def test_positional_superko(self): self.assertTrue(gs.is_legal((1, 0))) # gs = GameState(size=9, enforce_superko=True) super ko is not handled yet + gs = GameState(size=9) for move in move_list: gs.do_move(move) self.assertFalse(gs.is_legal((1, 0))) From ce8a512ac36527a5a64431d130777286839e77f1 Mon Sep 17 00:00:00 2001 From: MaMiFreak Date: Wed, 28 Jun 2017 22:25:17 +0100 Subject: [PATCH 163/191] superko & gtp bugfix added several rollout features added superko (general & ladder) fixed gtp bug --- AlphaGo/go.pxd | 24 ++- AlphaGo/go.pyx | 197 +++++++++++++++++++++--- AlphaGo/preprocessing/preprocessing.pyx | 67 +++++++- interface/gtp_wrapper.py | 12 +- tests/test_gamestate.py | 10 +- 5 files changed, 270 insertions(+), 40 deletions(-) diff --git a/AlphaGo/go.pxd b/AlphaGo/go.pxd index e91beaa51..1a2e472b4 100644 --- a/AlphaGo/go.pxd +++ b/AlphaGo/go.pxd @@ -55,8 +55,9 @@ cdef class GameState: cdef short *neighbor12d # zobrist + cdef object current_hash + cdef bint enforce_superko cdef dict hash_lookup - cdef int current_hash cdef set previous_hashes ############################################################################ @@ -80,11 +81,28 @@ cdef class GameState: # # ############################################################################ + cdef void update_hash(self, short location, char colour) + """ + xor current hash with location + colour action value + """ + + cdef bint is_positional_superko(self, short location, Group **board) + """ + Find all actions that the current_player has done in the past, taking into + account the fact that history starts with BLACK when there are no + handicaps or with WHITE when there are. + """ + cdef bint is_legal_move(self, short location, Group **board, short ko) """ check if playing at location is a legal move to make """ + cdef bint is_legal_move_superko(self, short location, Group **board, short ko) + """ + check if playing at location is a legal move to make + """ + cdef bint has_liberty_after(self, short location, Group **board) """ check if a play at location results in an alive group @@ -229,14 +247,14 @@ cdef class GameState: all changes to the board are stored in removed_groups """ - cdef bint is_ladder_escape_move(self, Group **board, short* ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase) + cdef bint is_ladder_escape_move(self, Group **board, short* ko, Locations_List *list_ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase) """ play a ladder move on location, check if group has escaped, if the group has 2 liberty it is undetermined -> try to capture it by playing at both liberty """ - cdef bint is_ladder_capture_move(self, Group **board, short* ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase) + cdef bint is_ladder_capture_move(self, Group **board, short* ko, Locations_List *list_ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase) """ play a ladder move on location, try capture and escape moves and see if the group is able to escape ladder diff --git a/AlphaGo/go.pyx b/AlphaGo/go.pyx index bc15a2495..1fbe1de2a 100644 --- a/AlphaGo/go.pyx +++ b/AlphaGo/go.pyx @@ -178,13 +178,12 @@ cdef class GameState: self.hash3x3[ i ] = self.generate_3x3_hash(i) # initialize zobrist hash - # TODO optimize? - # rng = np.random.RandomState(0) - # self.hash_lookup = { - # WHITE: rng.randint(np.iinfo(np.uint64).max, size=(size, size), dtype='uint64'), - # BLACK: rng.randint(np.iinfo(np.uint64).max, size=(size, size), dtype='uint64')} - # self.current_hash = np.uint64(0) - # self.previous_hashes = set() + rng = np.random.RandomState(0) + self.hash_lookup = { + _WHITE: rng.randint(np.iinfo(np.uint64).max, size=self.board_size, dtype='uint64'), + _BLACK: rng.randint(np.iinfo(np.uint64).max, size=self.board_size, dtype='uint64')} + self.current_hash = np.uint64(0) + self.previous_hashes = set() @cython.boundscheck(False) @@ -211,7 +210,7 @@ cdef class GameState: # pattern dictionary # zobrist - # self.hash_lookup = copy_state.hash_lookup + self.hash_lookup = copy_state.hash_lookup # !!! deep copy !!! @@ -225,7 +224,9 @@ cdef class GameState: self.board_size = copy_state.board_size self.player_current = copy_state.player_current self.player_opponent = copy_state.player_opponent - # self.current_hash = copy_state.current_hash + self.current_hash = copy_state.current_hash.copy() + self.enforce_superko = copy_state.enforce_superko + self.previous_hashes = copy_state.previous_hashes.copy() # create history list self.moves_history = locations_list_new(copy_state.moves_history.size) @@ -233,8 +234,6 @@ cdef class GameState: # copy all history moves in copy_state memcpy(self.moves_history.locations, copy_state.moves_history.locations, copy_state.moves_history.count * sizeof(short)) - # self.previous_hashes = list(copy_state.previous_hashes) - # create 3x3 hash array, these are updated after every move self.hash3x3 = malloc((self.board_size) * sizeof(long)) if not self.hash3x3: @@ -292,7 +291,7 @@ cdef class GameState: @cython.boundscheck(False) @cython.wraparound(False) - def __init__(self, char size = 19, GameState copyState = None): + def __init__(self, char size = 19, GameState copyState = None, enforce_superko=False): """ create new instance of GameState """ @@ -303,6 +302,11 @@ cdef class GameState: self.initialize_duplicate(copyState) else: + self.enforce_superko = 0 + if enforce_superko: + + self.enforce_superko = 1 + # check if neighbor arrays exist or size has changed if not neighbor or size != neighbor_size: @@ -404,6 +408,66 @@ cdef class GameState: ############################################################################ + @cython.boundscheck(False) + @cython.wraparound(False) + cdef void update_hash(self, short location, char colour): + """ + xor current hash with location + colour action value + """ + + self.current_hash = np.bitwise_xor(self.current_hash, self.hash_lookup[colour][location]) + + + @cython.boundscheck(False) + @cython.wraparound(False) + cdef bint is_positional_superko(self, short location, Group **board): + """ + Find all actions that the current_player has done in the past, taking into + account the fact that history starts with BLACK when there are no + handicaps or with WHITE when there are. + """ + + cdef int i + cdef bint played + played = 0 + + # TODO correction for handicap + if self.player_current == _BLACK: + + # move zero is black move + i = 0 + else: + + # move one is white move + i = 1 + + # check all moves by player if a move + # at location was played already + while i < self.moves_history.count: + + # check if move was played aleady + if self.moves_history.locations[ i ] == location: + + played = 1 + break + + i += 2 + + # if not played, no superko + if not played: + return 0 + + # TODO inefficient!!! + # duplicate state and play move + cdef GameState copy_state + copy_state = self.new_state_add_move(location) + + # check if hash already exists + if copy_state.current_hash in self.previous_hashes: + return 1 + + return 0 + @cython.boundscheck(False) @cython.wraparound(False) cdef bint is_legal_move(self, short location, Group **board, short ko): @@ -423,7 +487,30 @@ cdef class GameState: if 0 == self.has_liberty_after(location, board): return 0 - # TODO check super-ko + return 1 + + @cython.boundscheck(False) + @cython.wraparound(False) + cdef bint is_legal_move_superko(self, short location, Group **board, short ko): + """ + check if playing at location is a legal move to make + """ + + # check if it is empty + if board[ location ].colour != _EMPTY: + return 0 + + # check ko + if location == ko: + return 0 + + # check if it has liberty after + if 0 == self.has_liberty_after(location, board): + return 0 + + # if we have to enforce superko, check superko + if self.enforce_superko and self.is_positional_superko(location, board): + return 0 return 1 @@ -523,7 +610,7 @@ cdef class GameState: for i in range(self.board_size): # check if a move is legal - if self.is_legal_move(i, self.board_groups, self.ko): + if self.is_legal_move_superko(i, self.board_groups, self.ko): # add to moves_legal moves_legal.locations[ moves_legal.count ] = i @@ -583,6 +670,9 @@ cdef class GameState: # set location to empty group board[ location ] = self.group_empty + # update hash + self.update_hash(location, group_remove.colour) + # update liberty of neighbors # loop over all four neighbors for i in range(4): @@ -646,6 +736,8 @@ cdef class GameState: cdef char group_removed = 0 cdef int i + self.update_hash(location, self.player_current) + # loop over all four neighbors for i in range(4): @@ -1394,7 +1486,7 @@ cdef class GameState: @cython.boundscheck(False) @cython.wraparound(False) - cdef bint is_ladder_escape_move(self, Group **board, short* ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase): + cdef bint is_ladder_escape_move(self, Group **board, short* ko, Locations_List *list_ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase): """ play a ladder move on location, check if group has escaped, if the group has 2 liberty it is undetermined -> @@ -1409,6 +1501,7 @@ cdef class GameState: cdef dict capture_copy cdef Groups_List* removed_groups cdef short location_neighbor, location_stone + cdef short ko_count = list_ko.count # check if max exploration depth has been reached if maxDepth <= 0: @@ -1423,6 +1516,18 @@ cdef class GameState: ko_value = ko[ 0 ] removed_groups = self.add_ladder_move(location, board, ko) + # check if it is a possible ko move + if ko[0] != _PASS: + locations_list_add_location_unique(list_ko, ko[0]) + + if list_ko.count >= 2: + # undo move + self.undo_ladder_move(location, removed_groups, ko_value, board, ko) + + # decrement list_ko count + list_ko.count = ko_count + return 0 + # check group liberty group = board[ location_group ] i = group.count_liberty @@ -1470,10 +1575,11 @@ cdef class GameState: if group.locations[ location_neighbor ] == _LIBERTY: - if self.is_ladder_capture_move(board, ko, location_group, capture.copy(), location_neighbor, maxDepth - 1, colour_group, colour_chase): + if self.is_ladder_capture_move(board, ko, list_ko, location_group, capture.copy(), location_neighbor, maxDepth - 1, colour_group, colour_chase): # undo move self.undo_ladder_move(location, removed_groups, ko_value, board, ko) + list_ko.count = ko_count return 0 # escaped @@ -1481,6 +1587,7 @@ cdef class GameState: # undo move self.undo_ladder_move(location, removed_groups, ko_value, board, ko) + list_ko.count = ko_count # return result return result @@ -1488,7 +1595,7 @@ cdef class GameState: @cython.boundscheck(False) @cython.wraparound(False) - cdef bint is_ladder_capture_move(self, Group **board, short* ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase): + cdef bint is_ladder_capture_move(self, Group **board, short* ko, Locations_List *list_ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase): """ play a ladder move on location, try capture and escape moves and see if the group is able to escape ladder @@ -1500,6 +1607,7 @@ cdef class GameState: cdef dict capture_copy cdef short location_next cdef Groups_List* removed_groups + cdef short ko_count = list_ko.count # if we haven't found a capture by a certain number of moves, assume it's worked. if maxDepth <= 0: @@ -1513,6 +1621,18 @@ cdef class GameState: ko_value = ko[ 0 ] removed_groups = self.add_ladder_move(location, board, ko) + # check if it is a possible ko move + if ko[0] != _PASS: + locations_list_add_location_unique(list_ko, ko[0]) + + if list_ko.count >= 2: + # undo move + self.undo_ladder_move(location, removed_groups, ko_value, board, ko) + + # decrement list_ko count + list_ko.count = ko_count + return 1 + # check if the group at location can be captured group = board[ location ] if group.count_liberty == 1: @@ -1525,10 +1645,11 @@ cdef class GameState: capture_copy = capture.copy() capture_copy.pop(location_next) - if self.is_ladder_escape_move(board, ko, location_group, capture.copy(), location_next, maxDepth - 1, colour_group, colour_chase): + if self.is_ladder_escape_move(board, ko, list_ko, location_group, capture.copy(), location_next, maxDepth - 1, colour_group, colour_chase): # undo move self.undo_ladder_move(location, removed_groups, ko_value, board, ko) + list_ko.count = ko_count return 0 group = board[ location_group ] @@ -1541,15 +1662,17 @@ cdef class GameState: capture_copy = capture.copy() if location_next in capture_copy: capture_copy.pop(location_next) - if self.is_ladder_escape_move(board, ko, location_group, capture.copy(), location_next, maxDepth - 1, colour_group, colour_chase): + if self.is_ladder_escape_move(board, ko, list_ko, location_group, capture.copy(), location_next, maxDepth - 1, colour_group, colour_chase): # undo move self.undo_ladder_move(location, removed_groups, ko_value, board, ko) + list_ko.count = ko_count return 0 # no ladder escape found -> group is captured # undo move self.undo_ladder_move(location, removed_groups, ko_value, board, ko) + list_ko.count = ko_count return 1 @@ -1660,6 +1783,7 @@ cdef class GameState: cdef dict move_capture_copy cdef Group **board = NULL cdef short ko = self.ko + cdef Locations_List *list_ko # create char array representing the board cdef char* escapes = malloc(self.board_size) @@ -1668,6 +1792,9 @@ cdef class GameState: # set all locations to _FREE memset(escapes, _FREE, self.board_size) + # create Locations_List list_ko able to hold all ko locations for detecting superko + list_ko = locations_list_new(3) + # loop over all groups on board for i in range(self.groups_list.count_groups): @@ -1704,7 +1831,7 @@ cdef class GameState: if group.locations[ location_move ] == _LIBERTY and escapes[ location_move ] == _FREE: # check if group can escape ladder by playing move - if self.is_ladder_escape_move(board, &ko, location_group, move_capture.copy(), location_move, maxDepth, self.player_current, self.player_opponent): + if self.is_ladder_escape_move(board, &ko, list_ko, location_group, move_capture.copy(), location_move, maxDepth, self.player_current, self.player_opponent): escapes[ location_move ] = _STONE @@ -1717,7 +1844,7 @@ cdef class GameState: move_capture_copy.pop(location_move) # check if group can escape ladder by playing capture move - if self.is_ladder_escape_move(board, &ko, location_group, move_capture_copy, location_move, maxDepth, self.player_current, self.player_opponent): + if self.is_ladder_escape_move(board, &ko, list_ko, location_group, move_capture_copy, location_move, maxDepth, self.player_current, self.player_opponent): escapes[ location_move ] = _STONE @@ -1726,6 +1853,8 @@ cdef class GameState: free(board) + locations_list_destroy(list_ko) + return escapes @@ -1744,6 +1873,7 @@ cdef class GameState: cdef dict move_capture cdef Group **board = NULL cdef short ko = self.ko + cdef Locations_List *list_ko # create char array representing the board cdef char* captures = malloc(self.board_size) @@ -1752,6 +1882,9 @@ cdef class GameState: # set all locations to _FREE memset(captures, _FREE, self.board_size) + # create Locations_List list_ko able to hold all ko locations for detecting superko + list_ko = locations_list_new(3) + # loop over all groups on board for i in range(self.groups_list.count_groups): @@ -1788,7 +1921,7 @@ cdef class GameState: if group.locations[ location_move ] == _LIBERTY and captures[ location_move ] == _FREE: # check if move is ladder capture - if self.is_ladder_capture_move(board, &ko, location_group, move_capture.copy(), location_move, maxDepth, self.player_opponent, self.player_current): + if self.is_ladder_capture_move(board, &ko, list_ko, location_group, move_capture.copy(), location_move, maxDepth, self.player_opponent, self.player_current): captures[ location_move ] = _STONE @@ -1797,6 +1930,8 @@ cdef class GameState: free(board) + locations_list_destroy(list_ko) + return captures @@ -1840,8 +1975,8 @@ cdef class GameState: # set moves_legal self.set_moves_legal_list(self.moves_legal) - # TODO # update zobrist + self.previous_hashes.add(self.current_hash) @cython.boundscheck(False) @@ -1990,7 +2125,7 @@ cdef class GameState: location = self.calculate_board_location(y, x) # check if move is legal - if not self.is_legal_move(location, self.board_groups, self.ko): + if not self.is_legal_move_superko(location, self.board_groups, self.ko): print(self.player_current) print(location) @@ -2149,7 +2284,7 @@ cdef class GameState: # calculate location location = self.calculate_board_location(y, x) - if self.is_legal_move(location, self.board_groups, self.ko): + if self.is_legal_move_superko(location, self.board_groups, self.ko): return True @@ -2587,6 +2722,18 @@ cdef class GameState: print("empty") + cdef unsigned long long a + cdef unsigned long long b + cdef unsigned long long i + + a = 1024 + i = 500 + + for i in range(501): + b = a^i + + print str(b) + " " + str(i) + def test_cpp(self): diff --git a/AlphaGo/preprocessing/preprocessing.pyx b/AlphaGo/preprocessing/preprocessing.pyx index 54246bb78..9f7ea8f41 100644 --- a/AlphaGo/preprocessing/preprocessing.pyx +++ b/AlphaGo/preprocessing/preprocessing.pyx @@ -398,9 +398,23 @@ cdef class Preprocess: @cython.nonecheck(False) cdef int get_save_atari(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ - Fast rollout feature + A feature wrapping GameState.is_ladder_escape(). + check if player_current group can escape atari for at least one turn """ + cdef int location + cdef char* escapes = state.get_ladder_escapes(1) + + # loop over all groups on board + for location in range(state.board_size): + + if escapes[ location ] != _FREE: + + tensor[ offSet, location ] = 1 + + # free escapes + free(escapes) + return offSet + 1 @@ -440,9 +454,18 @@ cdef class Preprocess: @cython.nonecheck(False) cdef int get_nakade(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ - Fast rollout feature + A nakade pattern is a 12d pattern on a location a stone was captured before + it is unclear if a max size of the captured group has to be considered and + how recent the capture event should have been + + the 12d pattern can be encoded without stone colour and liberty count + unclear if a border location should be considered a stone or liberty + + pattern lookup value is being set instead of 1 """ + # TODO tensor type has to be float + return offSet + 1 @@ -451,7 +474,14 @@ cdef class Preprocess: @cython.nonecheck(False) cdef int get_nakade_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ - Fast rollout feature + A nakade pattern is a 12d pattern on a location a stone was captured before + it is unclear if a max size of the captured group has to be considered and + how recent the capture event should have been + + the 12d pattern can be encoded without stone colour and liberty count + unclear if a border location should be considered a stone or liberty + + #pattern_id is offset """ return offSet + self.pattern_nakade_size @@ -462,7 +492,8 @@ cdef class Preprocess: @cython.nonecheck(False) cdef int get_response_12d(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ - Fast rollout feature + Set 12d hash pattern for 12d shape around last move + pattern lookup value is being set instead of 1 """ # get last move location @@ -476,7 +507,8 @@ cdef class Preprocess: @cython.nonecheck(False) cdef int get_response_12d_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ - Fast rollout feature + Set 12d hash pattern for 12d shape around last move where + #pattern_id is offset """ # get last move location @@ -490,9 +522,12 @@ cdef class Preprocess: @cython.nonecheck(False) cdef int get_non_response_3x3(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ - Fast rollout feature + Set 3x3 hash pattern for every legal location where + pattern lookup value is being set instead of 1 """ + # TODO tensor type has to be float + return offSet + 1 @@ -501,9 +536,25 @@ cdef class Preprocess: @cython.nonecheck(False) cdef int get_non_response_3x3_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ - Fast rollout feature + Set 3x3 hash pattern for every legal location where + #pattern_id is offset """ + cdef short i, location + cdef int id + + # loop over all legal moves and set to one + for i in range(state.moves_legal.count): + + # get location + location = state.moves_legal.locations[ i ] + # get location hash and dict lookup + id = self.pattern_non_response_3x3.get( state.get_3x3_hash( location ) ) + + if id >= 0: + + tensor[ offSet + id, location ] = 1 + return offSet + self.pattern_non_response_3x3_size @@ -564,7 +615,7 @@ cdef class Preprocess: @cython.nonecheck(False) cdef int ko(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ - ko + ko feature """ if state.ko is not _PASS: diff --git a/interface/gtp_wrapper.py b/interface/gtp_wrapper.py index ca74ce8b7..80275cb82 100644 --- a/interface/gtp_wrapper.py +++ b/interface/gtp_wrapper.py @@ -86,7 +86,7 @@ class GTPGameConnector(object): """ def __init__(self, player): - self._state = GameState() # enforce_superko=True + self._state = GameState(enforce_superko=True) self._player = player self._komi = 0 @@ -95,6 +95,10 @@ def clear(self): def make_move(self, color, vertex): # vertex in GTP language is 1-indexed, whereas GameState's are zero-indexed + if color == gtp.BLACK: + color = go.BLACK + else: + color = go.WHITE try: if vertex == gtp.PASS: self._state.do_move(go.PASS) @@ -106,12 +110,16 @@ def make_move(self, color, vertex): return False def set_size(self, n): - self._state = GameState(size=n) # enforce_superko=True + self._state = GameState(size=n, enforce_superko=True) def set_komi(self, k): self._komi = k def get_move(self, color): + if color == gtp.BLACK: + color = go.BLACK + else: + color = go.WHITE self._state.set_current_player(color) move = self._player.get_move(self._state) if move == go.PASS: diff --git a/tests/test_gamestate.py b/tests/test_gamestate.py index 4f84ddc07..7a8ffc51b 100644 --- a/tests/test_gamestate.py +++ b/tests/test_gamestate.py @@ -72,12 +72,18 @@ def test_positional_superko(self): gs.do_move(move) self.assertTrue(gs.is_legal((1, 0))) - # gs = GameState(size=9, enforce_superko=True) super ko is not handled yet - gs = GameState(size=9) + # test with enforce_superko=True + gs = GameState(size=9, enforce_superko=True) for move in move_list: gs.do_move(move) self.assertFalse(gs.is_legal((1, 0))) + # test with enforce_superko=False + gs = GameState(size=9, enforce_superko=False) + for move in move_list: + gs.do_move(move) + self.assertTrue(gs.is_legal((1, 0))) + class TestEye(unittest.TestCase): From 5a1cf680a9182b9ffbd92296028792043b318681 Mon Sep 17 00:00:00 2001 From: wrongu Date: Wed, 5 Jul 2017 19:36:13 -0400 Subject: [PATCH 164/191] Policy RL sets learning rate using `K.set_value`. Fixes slowdown. Previously, `optimizer.lr = K.abs(optimizer.lr) * ...` added another call to `K.abs()` in the computation graph each iteration, slowing down training as time went on. --- AlphaGo/training/reinforcement_policy_trainer.py | 6 +++--- tests/test_reinforcement_policy_trainer.py | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 4725e3397..131465560 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -19,7 +19,7 @@ def _make_training_pair(st, mv, preprocessor): return (st_tensor, mv_tensor) -def run_n_games(optimizer, learner, opponent, num_games, mock_states=[]): +def run_n_games(optimizer, lr, learner, opponent, num_games, mock_states=[]): '''Run num_games games to completion, keeping track of each position and move of the learner. (Note: learning cannot happen until all games have completed) @@ -78,7 +78,7 @@ def run_n_games(optimizer, learner, opponent, num_games, mock_states=[]): # Train on each game's results, setting the learning rate negative to 'unlearn' positions from # games where the learner lost. for (st_tensor, mv_tensor, won) in zip(state_tensors, move_tensors, learner_won): - optimizer.lr = K.abs(optimizer.lr) * (+1 if won else -1) + K.set_value(optimizer.lr, abs(lr) * (+1 if won else -1)) learner_net.train_on_batch(np.concatenate(st_tensor, axis=0), np.concatenate(mv_tensor, axis=0)) @@ -208,7 +208,7 @@ def save_metadata(): # Run games (and learn from results). Keep track of the win ratio vs each opponent over # time. - win_ratio = run_n_games(optimizer, player, opponent, args.game_batch) + win_ratio = run_n_games(optimizer, args.learning_rate, player, opponent, args.game_batch) metadata["win_ratio"][player_weights] = (opp_weights, win_ratio) # Save intermediate models. diff --git a/tests/test_reinforcement_policy_trainer.py b/tests/test_reinforcement_policy_trainer.py index b12da0acf..6c4259a8d 100644 --- a/tests/test_reinforcement_policy_trainer.py +++ b/tests/test_reinforcement_policy_trainer.py @@ -102,7 +102,7 @@ def run_and_get_new_weights(init_weights, winners, game): opponent = MockPlayer(policy2, game) # Run RL training - run_n_games(optimizer, learner, opponent, 2, mock_states=states) + run_n_games(optimizer, 0.001, learner, opponent, 2, mock_states=states) return policy1.model.get_weights() @@ -145,12 +145,12 @@ def test_game_run_N(game): 'minimodel_policy.json')) learner = MockPlayer(policy1, game) opponent = MockPlayer(policy2, game) - optimizer = SGD() + optimizer = SGD(lr=0.001) init_weights = policy1.model.get_weights() policy1.model.compile(loss=log_loss, optimizer=optimizer) # Run RL training - run_n_games(optimizer, learner, opponent, 2) + run_n_games(optimizer, 0.001, learner, opponent, 2) # Get new weights for comparison trained_weights = policy1.model.get_weights() @@ -174,7 +174,7 @@ def test_game_increase(game): 'minimodel_policy.json')) learner = MockPlayer(policy1, game) opponent = MockPlayer(policy2, game) - optimizer = SGD() + optimizer = SGD(lr=0.001) policy1.model.compile(loss=log_loss, optimizer=optimizer) # Get initial (before learning) move probabilities for all moves made by black @@ -182,7 +182,7 @@ def test_game_increase(game): init_probs = [prob for (mv, prob) in init_move_probs] # Run RL training - run_n_games(optimizer, learner, opponent, 1, mock_states=win_state) + run_n_games(optimizer, 0.001, learner, opponent, 1, mock_states=win_state) # Get new move probabilities for black's moves having finished 1 round of training new_move_probs = get_sgf_move_probs(game, policy1, go.BLACK) @@ -205,7 +205,7 @@ def test_game_decrease(game): 'minimodel_policy.json')) learner = MockPlayer(policy1, game) opponent = MockPlayer(policy2, game) - optimizer = SGD() + optimizer = SGD(lr=0.001) policy1.model.compile(loss=log_loss, optimizer=optimizer) # Get initial (before learning) move probabilities for all moves made by black @@ -213,7 +213,7 @@ def test_game_decrease(game): init_probs = [prob for (mv, prob) in init_move_probs] # Run RL training - run_n_games(optimizer, learner, opponent, 1, mock_states=lose_state) + run_n_games(optimizer, 0.001, learner, opponent, 1, mock_states=lose_state) # Get new move probabilities for black's moves having finished 1 round of training new_move_probs = get_sgf_move_probs(game, policy1, go.BLACK) From c0bb671413e711ca6b66279cf338c50df8de8eba Mon Sep 17 00:00:00 2001 From: MaMiFreak Date: Thu, 6 Jul 2017 18:35:58 +0100 Subject: [PATCH 165/191] bugfixes and preprocessing rollout - implemented super-ko with cython and bugfix where ladder changed zobrist hash - created preprocessing rollout & added 12d pattern hash, response pattern and improved neigbor - removed 3x3 hash updates - removed unused code and comments - added print(GameState) and str(GameState) support (prints board position) --- AlphaGo/go.pxd | 42 +- AlphaGo/go.pyx | 459 +++------- AlphaGo/go_data.pxd | 49 +- AlphaGo/go_data.pyx | 83 +- AlphaGo/preprocessing/preprocessing.pyx | 161 ++-- .../preprocessing/preprocessing_rollout.pxd | 202 +++++ .../preprocessing/preprocessing_rollout.pyx | 834 ++++++++++++++++++ setup.py | 6 +- 8 files changed, 1335 insertions(+), 501 deletions(-) create mode 100644 AlphaGo/preprocessing/preprocessing_rollout.pxd create mode 100644 AlphaGo/preprocessing/preprocessing_rollout.pyx diff --git a/AlphaGo/go.pxd b/AlphaGo/go.pxd index 1a2e472b4..a5c524475 100644 --- a/AlphaGo/go.pxd +++ b/AlphaGo/go.pxd @@ -46,18 +46,16 @@ cdef class GameState: # list with legal moves cdef Locations_List *moves_legal - # array, keep track of 3x3 pattern hashes - cdef long *hash3x3 - # arrays, neighbor arrays pointers cdef short *neighbor cdef short *neighbor3x3 cdef short *neighbor12d # zobrist - cdef object current_hash + cdef unsigned long long zobrist_current + cdef unsigned long long *zobrist_lookup + cdef bint enforce_superko - cdef dict hash_lookup cdef set previous_hashes ############################################################################ @@ -140,11 +138,6 @@ cdef class GameState: remove group from board -> set all locations to group_empty """ - cdef void update_hashes(self, Group* group) - """ - update all locations affected by removal of group - """ - cdef void add_to_group(self, short location, Group **board, short* ko, short* count_captures) """ check if a stone on location is connected to a group, kills a group @@ -221,6 +214,12 @@ cdef class GameState: position """ + cdef void remove_ladder_group(self, Group* group_remove, Group **board, short* ko) + """ + remove group from board -> set all locations to group_empty + does not update zobrist hash + """ + cdef void undo_ladder_move(self, short location, Groups_List* removed_groups, short ko, Group **board, short* ko) """ Use removed_groups list to return board state to be the same as before @@ -276,14 +275,6 @@ cdef class GameState: capture count of a play on that location """ - cdef list get_neighbor_locations(self) - """ - generate list with 3x3 neighbor locations - 0,1,2,3 are direct neighbor - 4,5,6,7 are diagonal neighbor - where -1 if it is a border location or non empty location - """ - cdef long get_hash_12d(self, short centre) """ return hash for 12d star pattern around location @@ -358,18 +349,3 @@ cdef class GameState: """ only used for def get_legal_moves """ - - ############################################################################ - # tests # - # # - ############################################################################ - - cdef test(self) - """ - test function - """ - - cdef test_cpp_fast(self) - """ - test function - """ diff --git a/AlphaGo/go.pyx b/AlphaGo/go.pyx index 1fbe1de2a..a1bc05a50 100644 --- a/AlphaGo/go.pyx +++ b/AlphaGo/go.pyx @@ -1,14 +1,13 @@ -#cython: profile=True -#cython: linetrace=True -import sys -import time +# cython: profile=True +# cython: linetrace=True +# cython: wraparound=False +# cython: boundscheck=False +# cython: initializedcheck=False cimport cython import numpy as np cimport numpy as np from libc.stdlib cimport malloc, free from libc.string cimport memcpy, memset -from datetime import datetime -from datetime import timedelta # global # global empty group @@ -23,6 +22,9 @@ cdef short *neighbor3x3 cdef short *neighbor12d cdef char neighbor_size +# global array for zobrist lookup +cdef unsigned long long *zobrist_lookup + # expose variables to python PASS = _PASS BLACK = _BLACK @@ -74,17 +76,12 @@ cdef class GameState: # list with legal moves cdef Locations_List *moves_legal - # array, keep track of 3x3 pattern hashes - cdef long *hash3x3 - # arrays, neighbor arrays pointers cdef short *neighbor cdef short *neighbor3x3 cdef short *neighbor12d # zobrist - cdef dict hash_lookup - cdef int current_hash cdef set previous_hashes -> variables, declared in go.pxd @@ -106,10 +103,11 @@ cdef class GameState: cdef short i # set pointer to neighbor locations - # neighbor, neighbor3x3, neighbor12d are global - self.neighbor = neighbor - self.neighbor3x3 = neighbor3x3 - self.neighbor12d = neighbor12d + # neighbor, neighbor3x3, neighbor12d and zobrist_lookup are global + self.neighbor = neighbor + self.neighbor3x3 = neighbor3x3 + self.neighbor12d = neighbor12d + self.zobrist_lookup = zobrist_lookup # initialize size and board_size self.size = size @@ -139,11 +137,6 @@ cdef class GameState: if not self.board_groups: raise MemoryError() - # create 3x3 hash array, these are updated after every move - self.hash3x3 = malloc((self.board_size) * sizeof(long)) - if not self.hash3x3: - raise MemoryError() - # create Locations_List as legal_moves # after every move this list will be updated to contain all legal moves # max amount of legal moves is board_size @@ -172,18 +165,9 @@ cdef class GameState: # initialize border location to group_border self.board_groups[ self.board_size ] = group_border - # initialize all 3x3 hashes - for i in range(self.board_size): - - self.hash3x3[ i ] = self.generate_3x3_hash(i) - - # initialize zobrist hash - rng = np.random.RandomState(0) - self.hash_lookup = { - _WHITE: rng.randint(np.iinfo(np.uint64).max, size=self.board_size, dtype='uint64'), - _BLACK: rng.randint(np.iinfo(np.uint64).max, size=self.board_size, dtype='uint64')} - self.current_hash = np.uint64(0) + # set zobrist self.previous_hashes = set() + self.zobrist_current = 0 @cython.boundscheck(False) @@ -210,7 +194,7 @@ cdef class GameState: # pattern dictionary # zobrist - self.hash_lookup = copy_state.hash_lookup + self.zobrist_lookup = copy_state.zobrist_lookup # !!! deep copy !!! @@ -224,7 +208,7 @@ cdef class GameState: self.board_size = copy_state.board_size self.player_current = copy_state.player_current self.player_opponent = copy_state.player_opponent - self.current_hash = copy_state.current_hash.copy() + self.zobrist_current = copy_state.zobrist_current self.enforce_superko = copy_state.enforce_superko self.previous_hashes = copy_state.previous_hashes.copy() @@ -234,13 +218,6 @@ cdef class GameState: # copy all history moves in copy_state memcpy(self.moves_history.locations, copy_state.moves_history.locations, copy_state.moves_history.count * sizeof(short)) - # create 3x3 hash array, these are updated after every move - self.hash3x3 = malloc((self.board_size) * sizeof(long)) - if not self.hash3x3: - raise MemoryError() - # copy all 3x3 hashes from copy_state - memcpy(self.hash3x3, copy_state.hash3x3, (self.board_size) * sizeof(long)) - # create Locations_List as legal_moves # after every move this list will be updated to contain all legal moves # max amount of legal moves is board_size @@ -291,9 +268,10 @@ cdef class GameState: @cython.boundscheck(False) @cython.wraparound(False) - def __init__(self, char size = 19, GameState copyState = None, enforce_superko=False): + def __init__(self, char size = 19, GameState copyState = None, enforce_superko=True): """ create new instance of GameState + """ if copyState is not None: @@ -302,10 +280,7 @@ cdef class GameState: self.initialize_duplicate(copyState) else: - self.enforce_superko = 0 - if enforce_superko: - - self.enforce_superko = 1 + self.enforce_superko = enforce_superko # check if neighbor arrays exist or size has changed if not neighbor or size != neighbor_size: @@ -320,6 +295,7 @@ cdef class GameState: global neighbor_size global group_empty global group_border + global zobrist_lookup # free arrays if they already existed if neighbor: @@ -327,14 +303,16 @@ cdef class GameState: free(neighbor) free(neighbor3x3) free(neighbor12d) + free(zobrist_lookup) # set size neighbor_size = size # set neighbor arrays - neighbor = get_neighbors(size) - neighbor3x3 = get_3x3_neighbors(size) - neighbor12d = get_12d_neighbors(size) + neighbor = get_neighbors(size) + neighbor3x3 = get_3x3_neighbors(size) + neighbor12d = get_12d_neighbors(size) + zobrist_lookup = get_zobrist_lookup(size) # initialize EMPTY and BORDER group if not group_empty: @@ -364,11 +342,6 @@ cdef class GameState: cdef int i - # free hash3x3 - if self.hash3x3 is not NULL: - - free(self.hash3x3) - # free board_groups if self.board_groups is not NULL: @@ -415,7 +388,10 @@ cdef class GameState: xor current hash with location + colour action value """ - self.current_hash = np.bitwise_xor(self.current_hash, self.hash_lookup[colour][location]) + if colour == _BLACK: + location += self.board_size + + self.zobrist_current = self.zobrist_current ^ self.zobrist_lookup[location] @cython.boundscheck(False) @@ -449,7 +425,6 @@ cdef class GameState: if self.moves_history.locations[ i ] == location: played = 1 - break i += 2 @@ -460,10 +435,14 @@ cdef class GameState: # TODO inefficient!!! # duplicate state and play move cdef GameState copy_state - copy_state = self.new_state_add_move(location) + copy_state = GameState(copyState=self) + copy_state.enforce_superko = 0 + + # do move + copy_state.add_move(location) # check if hash already exists - if copy_state.current_hash in self.previous_hashes: + if copy_state.zobrist_current in self.previous_hashes: return 1 return 0 @@ -509,8 +488,9 @@ cdef class GameState: return 0 # if we have to enforce superko, check superko - if self.enforce_superko and self.is_positional_superko(location, board): - return 0 + if self.enforce_superko: + if self.is_positional_superko(location, board): + return 0 return 1 @@ -690,36 +670,6 @@ cdef class GameState: group_add_liberty(group_temp, location) - @cython.boundscheck(False) - @cython.wraparound(False) - cdef void update_hashes(self, Group* group): - """ - update all locations affected by removal of group - """ - - cdef short i, a, location, location_array - - # loop over all stones in group - for i in range(self.board_size): - - if group.locations[ i ] == _STONE: - - # update hash location - self.hash3x3[ i ] = self.generate_3x3_hash(i) - - location_array = i * 8 - - # loop over diagonal - # this group is killed -> neighbors are enemy stones - for a in range(4, 8): - - # TODO add check for this group location - location = self.neighbor3x3[ location_array + a ] - if self.board_groups[ location ].colour == _EMPTY: - - self.hash3x3[ location ] = self.generate_3x3_hash(location) - - @cython.boundscheck(False) @cython.wraparound(False) cdef void add_to_group(self, short location, Group **board, short* ko, short* count_captures): @@ -779,7 +729,7 @@ cdef class GameState: # remove group and update hashes self.remove_group(tempGroup, board, ko) - self.update_hashes(tempGroup) + # TODO hashes of locations next to a group where liberty change also have to be updated # remove tempGroup from groupList and destroy @@ -818,18 +768,6 @@ cdef class GameState: if board[ neighborLocation ].colour == _EMPTY: group_add_liberty(newGroup, neighborLocation) - self.hash3x3[ neighborLocation ] = self.generate_3x3_hash(neighborLocation) - - # loop over all four diagonals - for i in range(4, 8): - - # get neighbor location - neighborLocation = self.neighbor3x3[ location_array + i ] - - # if diagonal is empty update hash - if board[ neighborLocation ].colour == _EMPTY: - - self.hash3x3[ neighborLocation ] = self.generate_3x3_hash(neighborLocation) # check if there is really a ko # if two groups died there is no ko @@ -1122,9 +1060,6 @@ cdef class GameState: # TODO benchmark what is faster? first dict lookup then neighbor check or other way around - if eyes_lenght > 70: - print "pretty big" + str(eyes_lenght) - # check if it is a known eye for i in range(eyes.count): @@ -1258,6 +1193,50 @@ cdef class GameState: return removed_groups + @cython.boundscheck(False) + @cython.wraparound(False) + cdef void remove_ladder_group(self, Group* group_remove, Group **board, short* ko): + """ + remove group from board -> set all locations to group_empty + does not update zobrist hash + """ + + cdef short location + cdef short neighbor_location + cdef Group* group_temp + cdef char board_value + cdef int i + + # if groupsize == 1, possible ko + if group_remove.count_stones == 1: + + ko[ 0 ] = group_location_stone(group_remove, self.board_size) + + # loop over all group stone locations + for location in range(self.board_size): + + if group_remove.locations[ location ] == _STONE: + + # set location to empty group + board[ location ] = self.group_empty + + # update liberty of neighbors + # loop over all four neighbors + for i in range(4): + + # get neighbor location + neighbor_location = self.neighbor[ location * 4 + i ] + + # only current_player groups can be next to a killed group + # check if there is a group + board_value = board[ neighbor_location ].colour + if board_value == self.player_current: + + # add liberty + group_temp = board[ neighbor_location ] + group_add_liberty(group_temp, location) + + @cython.boundscheck(False) @cython.wraparound(False) cdef void undo_ladder_move(self, short location, Groups_List* removed_groups, short removed_ko, Group **board, short* ko): @@ -1450,7 +1429,7 @@ cdef class GameState: # remove group if tempGroup.count_liberty == 0: - self.remove_group(tempGroup, board, ko) + self.remove_ladder_group(tempGroup, board, ko) # add tempGroup to removed_groups groups_list_add(tempGroup, removed_groups) @@ -1729,19 +1708,6 @@ cdef class GameState: return groups_after - @cython.boundscheck(False) - @cython.wraparound(False) - cdef list get_neighbor_locations(self): - """ - generate list with 3x3 neighbor locations - 0,1,2,3 are direct neighbor - 4,5,6,7 are diagonal neighbor - where -1 if it is a border location or non empty location - """ - - return [] - - @cython.boundscheck(False) @cython.wraparound(False) cdef long get_hash_12d(self, short centre): @@ -1749,9 +1715,9 @@ cdef class GameState: return hash for 12d star pattern around location """ - # get 12d hash value and add current player colour + # generate 12d hash value and add current player colour - return self.generate_12d_hash(centre) + self.player_current + return ( self.generate_12d_hash(centre) + self.player_current ) * _HASHVALUE @cython.boundscheck(False) @@ -1761,10 +1727,9 @@ cdef class GameState: return 3x3 pattern hash + current player """ - # 3x3 hash patterns are updated every move - # get 3x3 hash value and add current player colour + # generate 3x3 hash value and add current player colour - return self.hash3x3[ location ] + self.player_current + return self.generate_3x3_hash( location ) + self.player_current @cython.boundscheck(False) @@ -1972,11 +1937,12 @@ cdef class GameState: # add move to history locations_list_add_location_increment(self.moves_history, location) + # update zobrist + self.previous_hashes.add(self.zobrist_current) + # set moves_legal self.set_moves_legal_list(self.moves_legal) - # update zobrist - self.previous_hashes.add(self.current_hash) @cython.boundscheck(False) @@ -2127,12 +2093,6 @@ cdef class GameState: # check if move is legal if not self.is_legal_move_superko(location, self.board_groups, self.ko): - print(self.player_current) - print(location) - print(self.get_legal_moves(include_eyes=True)) - print("") - print(self.get_legal_moves(include_eyes=False)) - self.printer() raise IllegalMove(str(action)) # add move @@ -2269,7 +2229,6 @@ cdef class GameState: def is_legal(self, action): """ determine if the given action (x,y tuple) is a legal move - note: we only check ko, not superko at this point (TODO!) """ cdef int i @@ -2558,7 +2517,7 @@ cdef class GameState: @cython.wraparound(False) def get_handicaps(self): """ - TODO + TODO ? return list with handicap stones placed """ @@ -2569,7 +2528,7 @@ cdef class GameState: @cython.wraparound(False) def get_handicap(self): """ - TODO + TODO ? return list with handicap stones placed """ @@ -2581,6 +2540,7 @@ cdef class GameState: cdef Locations_List* get_sensible_moves(self): """ only used for def get_legal_moves + return a list with sensible legal moves """ # TODO validate usage of struct is actually faster @@ -2612,44 +2572,14 @@ cdef class GameState: return sensible_moves - ############################################################################ - # tests # - # # - ############################################################################ - - def validate_equal(self, GameState state): - - cdef int i - value = True - - for i in range(self.board_size): - - if self.ko != state.ko: - - print("ko " + str(self.ko)) - return False - - if self.board_groups[ i ].colour != state.board_groups[ i ].colour: - - print("board " + str(i)) - value = False - - if self.board_groups[ i ].count_stones != state.board_groups[ i ].count_stones: - - print("stones " + str(i)) - print(str(self.board_groups[ i ].count_stones) + " " + str(state.board_groups[ i ].count_stones)) - value = False - - if self.board_groups[ i ].count_liberty != state.board_groups[ i ].count_liberty: - - print("liberty " + str(i) + " " + str(state.board_groups[ i ].colour) + " " + str(state.player_current)) - print(str(self.board_groups[ i ].count_liberty) + " " + str(state.board_groups[ i ].count_liberty)) - value = False - - return value + @cython.boundscheck(False) + @cython.wraparound(False) + def get_print_board_layout(self): + """ + print current board state + """ - def printer(self): - print("") + line = "\n" for i in range(self.size): A = str(i) + " " for j in range(self.size): @@ -2660,193 +2590,24 @@ cdef class GameState: elif self.board_groups[ j + i * self.size ].colour == _WHITE: B = 'W' A += str(B) + " " - print(A) + line += A + "\n" + return line - # do move, throw exception when outside the board - # action has to be a (x, y) tuple - # this function should be used from Python environment, - # use add_move from C environment for speed - def do_ladder_move(self, action): + def __repr__(self): """ - + enable python: print GameState """ - # do move, return true if legal, return false if not + return self.get_print_board_layout() - cdef int x, y - cdef short location - (x, y) = action - location = self.calculate_board_location(y, x) - self.add_ladder_move(location, self.board_groups, &self.ko) - - self.set_moves_legal_list(self.moves_legal) - - return True - - # do move, throw exception when outside the board - # action has to be a (x, y) tuple - # this function should be used from Python environment, - # use add_move from C environment for speed - def do_and_undo_ladder_move(self, action): + def __str__(self): """ - + enable python: str(GameState) """ - # do move, return true if legal, return false if not - - cdef int x, y - cdef short location, ko - (x, y) = action - location = self.calculate_board_location(y, x) - - cdef Groups_List* removed_groups - - ko = self.ko - removed_groups = self.add_ladder_move(location, self.board_groups, &self.ko) - - self.undo_ladder_move(location, removed_groups, ko, self.board_groups, &self.ko) - - - return True - - @cython.boundscheck(False) - @cython.wraparound(False) - cdef test(self): - - print("empty") - - - cdef test_cpp_fast(self): - - print("empty") - - cdef unsigned long long a - cdef unsigned long long b - cdef unsigned long long i - - a = 1024 - i = 500 - - for i in range(501): - b = a^i - - print str(b) + " " + str(i) - - - def test_cpp(self): - - print("cpp") - self.test_cpp_fast() - print("cpp") - - def test_game_speed(self, list moves): - - cdef short location - - for location in moves: - self.add_move(location) - - def convert_moves(self, list moves): - cdef list converted_moves = [] - cdef int x, y - cdef short location - - for loc in moves: - - (x, y) = loc - location = self.calculate_board_location(y, x) - converted_moves.append(location) - - return converted_moves - - def millis(self, start_time): - dt = datetime.now() - start_time - ms = (dt.days * 24 * 60 * 60 + dt.seconds) * 1000 + dt.microseconds / 1000.0 - return ms - - def test_stuff(self): - cdef long a, h, i, j, k - cdef short b, e - - if not neighbor: - print("NOT") - - for i in range(self.board_size * 4): - if neighbor[ i ] != self.neighbor[ i ]: - print("NOT EQUAL") - return - - if not neighbor3x3: - print("NOT") - - for i in range(self.board_size * 8): - if neighbor3x3[ i ] != self.neighbor3x3[ i ]: - print("NOT EQUAL") - return - - if not neighbor12d: - print("NOT") - - for i in range(self.board_size * 12): - if neighbor12d[ i ] != self.neighbor12d[ i ]: - print("NOT EQUAL") - return - - cdef long amount = 1000 - - e = 1 - b = 0 - start = datetime.now() - - for h in range(amount): - for i in range(amount): - - for a in range(self.board_size * 12): - - b = neighbor12d[ a ] - - if neighbor12d[ a ] > 100: - e = neighbor12d[ a ] - if b * e > 0: - b = 1 - - end = datetime.now() - dt = end - start - start = dt.microseconds - - print("glob " + str(start) + " " + str(b)) - - - e = 1 - b = 0 - start = datetime.now() - - for h in range(amount): - for i in range(amount): - - for a in range(self.board_size * 12): - - b = self.neighbor12d[ a ] - - if self.neighbor12d[ a ] > 100: - e = self.neighbor12d[ a ] - if b * e > 0: - b = 1 - - end = datetime.now() - dt = end - start - start = dt.microseconds - - print("self " + str(start) + " " + str(b)) - - - @cython.boundscheck(False) - @cython.wraparound(False) - def set_stuff(self): - - self.get_sensible_moves() + return self.get_print_board_layout() class IllegalMove(Exception): diff --git a/AlphaGo/go_data.pxd b/AlphaGo/go_data.pxd index 98e240afc..e2ecd8417 100644 --- a/AlphaGo/go_data.pxd +++ b/AlphaGo/go_data.pxd @@ -1,3 +1,5 @@ +import numpy as np +cimport numpy as np ############################################################################ # constants # @@ -49,20 +51,50 @@ cdef char _HASHVALUE - no boundchecks """ -# struct to store group information +""" + struct to store group stone and liberty locations + + locations is a char pointer array of size board_size and initialized + to _FREE. after adding a stone/liberty that location is set to + _STONE/_LIBERTY and count_stones/count_liberty is incremented + + note that a stone location can never be a liberty location, + if a stone is placed on a liberty location liberty_count is decremented + + it works as a dictionary so lookup time for a location is O(1) + looping over all stone/liberty location could be optimized by adding + two lists containing stone/liberty locations + + TODO check if this dictionary implementation is faster on average + use as a two list implementation +""" cdef struct Group: char *locations short count_stones short count_liberty char colour -# struct to store a list of Group +""" + struct to store a list of Group + + board_groups is a Group pointer array of size #size and containing + #count_groups groups + + TODO convert to c++ list? +""" cdef struct Groups_List: Group **board_groups short count_groups short size -# struct to store a list of short (board locations) +""" + struct to store a list of short (board locations) + + locations is a short pointer array of size #size and containing + #count locations + + TODO convert to c++ list and/or set +""" cdef struct Locations_List: short *locations short count @@ -275,3 +307,14 @@ cdef short* get_12d_neighbors(char size) +1 89a +2 b """ + +############################################################################ +# zobrist creation functions # +# # +############################################################################ + + +cdef unsigned long long* get_zobrist_lookup(char size) +""" + +""" diff --git a/AlphaGo/go_data.pyx b/AlphaGo/go_data.pyx index 87e779194..9b2b5533a 100644 --- a/AlphaGo/go_data.pyx +++ b/AlphaGo/go_data.pyx @@ -1,4 +1,6 @@ cimport cython +import numpy as np +cimport numpy as np from libc.stdlib cimport malloc, free, realloc from libc.string cimport memcpy, memset, memchr @@ -55,22 +57,67 @@ _HASHVALUE = 33 """ -> structs, declared in go_data.pxd -# struct to store group information +# a struct has the advantage of being completely C, no python wrapper so +# no python overhead. +# +# compared to a cdef class a struct has some advantages: +# - C only, no python overhead +# - able to get a pointer to it +# - smaller in size +# +# drawbacks +# - have to be Malloc created and freed after use -> memory leak +# - no convenient functions available +# - no boundchecks + + +# struct to store group stone and liberty locations +# +# locations is a char pointer array of size board_size and initialized +# to _FREE. after adding a stone/liberty that location is set to +# _STONE/_LIBERTY and count_stones/count_liberty is incremented +# +# note that a stone location can never be a liberty location, +# if a stone is placed on a liberty location liberty_count is decremented +# +# it works as a dictionary so lookup time for a location is O(1) +# looping over all stone/liberty location could be optimized by adding +# two lists containing stone/liberty locations +# +# TODO check if this dictionary implementation is faster on average +# use as a two list implementation + cdef struct Group: char *locations short count_stones short count_liberty char colour -# struct to store a list of Group + +# struct to store a list of Group +# +# board_groups is a Group pointer array of size #size and containing +# #count_groups groups +# +# TODO convert to c++ list? + cdef struct Groups_List: Group **board_groups short count_groups + short size + + +# struct to store a list of short (board locations) +# +# locations is a short pointer array of size #size and containing +# #count locations + + TODO convert to c++ list and/or set -# struct to store a list of short (board locations) cdef struct Locations_List: short *locations short count + short size """ ############################################################################ @@ -533,6 +580,8 @@ cdef short calculate_board_location_or_border(char x, char y, char size): return calculate_board_location(x, y, size) +@cython.boundscheck(False) +@cython.wraparound(False) cdef short* get_neighbors(char size): """ create array for every board location with all 4 direct neighbor locations @@ -674,4 +723,30 @@ cdef short* get_12d_neighbors(char size): neighbor12d[ location + 7 ] = calculate_board_location_or_border(x , y + 2, size) - return neighbor12d \ No newline at end of file + return neighbor12d + + +############################################################################ +# zobrist creation functions # +# # +############################################################################ + + +@cython.boundscheck(False) +@cython.wraparound(False) +cdef unsigned long long* get_zobrist_lookup(char size): + """ + generate zobrist lookup array for boardsize size + """ + + cdef unsigned long long* zobrist_lookup + + zobrist_lookup = malloc((size * size * 2) * sizeof(unsigned long long)) + if not zobrist_lookup: + raise MemoryError() + + # initialize all zobrist hash lookup values + for i in range(size * size * 2): + zobrist_lookup[i] = np.random.randint(np.iinfo(np.uint64).max, dtype='uint64') + + return zobrist_lookup \ No newline at end of file diff --git a/AlphaGo/preprocessing/preprocessing.pyx b/AlphaGo/preprocessing/preprocessing.pyx index 9f7ea8f41..07eb708d7 100644 --- a/AlphaGo/preprocessing/preprocessing.pyx +++ b/AlphaGo/preprocessing/preprocessing.pyx @@ -1,5 +1,8 @@ -#cython: profile=True -#cython: linetrace=True +# cython: profile=True +# cython: linetrace=True +# cython: wraparound=False +# cython: boundscheck=False +# cython: initializedcheck=False cimport cython import numpy as np cimport numpy as np @@ -50,8 +53,6 @@ cdef class Preprocess: ############################################################################ - @cython.boundscheck(False) - @cython.wraparound(False) @cython.nonecheck(False) cdef int get_board(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ @@ -86,8 +87,6 @@ cdef class Preprocess: return offSet + 3 - @cython.boundscheck(False) - @cython.wraparound(False) @cython.nonecheck(False) cdef int get_turns_since(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ @@ -135,8 +134,6 @@ cdef class Preprocess: return offSet + 8 - @cython.boundscheck(False) - @cython.wraparound(False) @cython.nonecheck(False) cdef int get_liberties(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ @@ -173,8 +170,6 @@ cdef class Preprocess: return offSet + 8 - @cython.boundscheck(False) - @cython.wraparound(False) @cython.nonecheck(False) cdef int get_capture_size(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ @@ -206,8 +201,6 @@ cdef class Preprocess: return offSet + 8 - @cython.boundscheck(False) - @cython.wraparound(False) @cython.nonecheck(False) cdef int get_self_atari_size(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ @@ -236,8 +229,6 @@ cdef class Preprocess: return offSet + 8 - @cython.boundscheck(False) - @cython.wraparound(False) @cython.nonecheck(False) cdef int get_liberties_after(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ @@ -269,8 +260,6 @@ cdef class Preprocess: return offSet + 8 - @cython.boundscheck(False) - @cython.wraparound(False) @cython.nonecheck(False) cdef int get_ladder_capture(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ @@ -294,8 +283,6 @@ cdef class Preprocess: return offSet + 1 - @cython.boundscheck(False) - @cython.wraparound(False) @cython.nonecheck(False) cdef int get_ladder_escape(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ @@ -319,8 +306,6 @@ cdef class Preprocess: return offSet + 1 - @cython.boundscheck(False) - @cython.wraparound(False) @cython.nonecheck(False) cdef int get_sensibleness(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ @@ -363,8 +348,6 @@ cdef class Preprocess: return offSet + 1 - @cython.boundscheck(False) - @cython.wraparound(False) @cython.nonecheck(False) cdef int get_legal(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ @@ -387,14 +370,18 @@ cdef class Preprocess: @cython.nonecheck(False) cdef int get_response(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ - Fast rollout feature + single feature plane encoding whether this location matches any of the response + patterns, for now it only checks the 12d response patterns as we do not use the + 3x3 response patterns. + + TODO + - decide if we consider nakade patterns response patterns as well + - optimization? 12d response patterns are calculated twice.. """ return offSet + 1 - @cython.boundscheck(False) - @cython.wraparound(False) @cython.nonecheck(False) cdef int get_save_atari(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ @@ -418,39 +405,60 @@ cdef class Preprocess: return offSet + 1 - @cython.boundscheck(False) - @cython.wraparound(False) @cython.nonecheck(False) cdef int get_neighbor(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ - Fast rollout feature + encode last move neighbor positions in two planes: + - horizontal & vertical / direct neighbor + - diagonal neighbor """ - cdef short location - cdef int i = 0 - cdef int plane = offSet - cdef list neighbor = state.get_neighbor_locations() + cdef short location, last_move + cdef int i, plane + cdef short *neighbor3x3 = state.neighbor3x3 + + # get last move + last_move = state.moves_history.locations[ state.moves_history.count - 1 ] + + # check if last move is not _PASS + if last_move != _PASS: + + # last_move location in neighbor3x3 array + last_move *= 8 + + # direct neighbor plane is plane offset + plane = offSet + + # loop over direct neighbor + # 0,1,2,3 are direct neighbor locations + for i in range(4): + + # get neighbor location + location = neighbor3x3[ last_move + i ] + + # check if location is empty + if state.board_groups[ location ].colour == _EMPTY: - # loop over neighbor - # 0,1,2,3 are direct neighbor - # 4,5,6,7 are diagonal neighbor - for location in neighbor: + tensor[ plane, location ] = 1 - # -1 means border location or aleady occupied - if location >= 0: + # diagonal neighbor plane is plane offset + 1 + plane = offSet + 1 - tensor[ plane, location ] = 1 + # loop over diagonal neighbor + # 4,5,6,7 are diagonal neighbor locations + for i in range(4, 8): - i += 1 + # get neighbor location + location = neighbor3x3[ last_move + i ] - if i == 4: - plane += 1 + # check if location is empty + if state.board_groups[ location ].colour == _EMPTY: + + tensor[ plane, location ] = 1 return offSet + 2 - @cython.boundscheck(False) - @cython.wraparound(False) @cython.nonecheck(False) cdef int get_nakade(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ @@ -469,8 +477,6 @@ cdef class Preprocess: return offSet + 1 - @cython.boundscheck(False) - @cython.wraparound(False) @cython.nonecheck(False) cdef int get_nakade_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ @@ -487,8 +493,6 @@ cdef class Preprocess: return offSet + self.pattern_nakade_size - @cython.boundscheck(False) - @cython.wraparound(False) @cython.nonecheck(False) cdef int get_response_12d(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ @@ -502,8 +506,6 @@ cdef class Preprocess: return offSet + 1 - @cython.boundscheck(False) - @cython.wraparound(False) @cython.nonecheck(False) cdef int get_response_12d_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ @@ -517,8 +519,6 @@ cdef class Preprocess: return offSet + self.pattern_response_12d_size - @cython.boundscheck(False) - @cython.wraparound(False) @cython.nonecheck(False) cdef int get_non_response_3x3(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ @@ -531,8 +531,6 @@ cdef class Preprocess: return offSet + 1 - @cython.boundscheck(False) - @cython.wraparound(False) @cython.nonecheck(False) cdef int get_non_response_3x3_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ @@ -558,8 +556,6 @@ cdef class Preprocess: return offSet + self.pattern_non_response_3x3_size - @cython.boundscheck(False) - @cython.wraparound(False) @cython.nonecheck(False) cdef int zeros(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ @@ -574,8 +570,6 @@ cdef class Preprocess: return offSet + 1 - @cython.boundscheck(False) - @cython.wraparound(False) @cython.nonecheck(False) cdef int ones(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ @@ -590,8 +584,6 @@ cdef class Preprocess: return offSet + 1 - @cython.boundscheck(False) - @cython.wraparound(False) @cython.nonecheck(False) cdef int colour(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ @@ -610,8 +602,6 @@ cdef class Preprocess: return offSet + 1 - @cython.boundscheck(False) - @cython.wraparound(False) @cython.nonecheck(False) cdef int ko(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): """ @@ -630,8 +620,7 @@ cdef class Preprocess: # # ############################################################################ - @cython.boundscheck(False) - @cython.wraparound(False) + def __init__(self, list feature_list, char size=19, dict_nakade=None, dict_3x3=None, dict_12d=None, verbose=False): """ """ @@ -777,8 +766,6 @@ cdef class Preprocess: self.processors[ i ] = processor - @cython.boundscheck(False) - @cython.wraparound(False) def __dealloc__(self): """ Prevent memory leaks by freeing all arrays created with malloc @@ -793,8 +780,6 @@ cdef class Preprocess: ############################################################################ - @cython.boundscheck(False) - @cython.wraparound(False) @cython.nonecheck(False) cdef np.ndarray[ tensor_type, ndim=4 ] generate_tensor(self, GameState state): """ @@ -855,43 +840,3 @@ cdef class Preprocess: """ return self.feature_list - - - ############################################################################ - # test # - # # - ############################################################################ - - - def test(self, GameState state, int amount): - cdef char size = state.size - self.board_size = state.size * state.size - - import time - t = time.time() - - cdef int i - - for i in range(amount): - self.generate_tensor(state) - - print "proc " + str(time.time() - t) - - - def timed_test(self, GameState state, int amount): - - cdef int i - - for i in range(amount): - - self.generate_tensor(state) - - - def test_game_speed(self, GameState state, list moves): - - cdef short location - - for location in moves: - - state.add_move(location) - self.generate_tensor(state) \ No newline at end of file diff --git a/AlphaGo/preprocessing/preprocessing_rollout.pxd b/AlphaGo/preprocessing/preprocessing_rollout.pxd new file mode 100644 index 000000000..374fc56c2 --- /dev/null +++ b/AlphaGo/preprocessing/preprocessing_rollout.pxd @@ -0,0 +1,202 @@ +import ast +import time +import numpy as np +cimport numpy as np +from numpy cimport ndarray +from libc.stdlib cimport malloc, free +from AlphaGo.go cimport GameState +from AlphaGo.go_data cimport _BLACK, _EMPTY, _STONE, _LIBERTY, _CAPTURE, _FREE, _PASS, _HASHVALUE, Group, Locations_List, locations_list_destroy, locations_list_new + +# type of tensor created +# char works but float might be needed later +ctypedef char tensor_type + +# type defining cdef function +ctypedef int (*preprocess_method)(Preprocess, GameState, tensor_type[ :, ::1 ], int) + + +cdef class Preprocess: + + ############################################################################ + # variables declarations # + # # + ############################################################################ + + # all feature processors + # TODO find correct type so an array can be used + cdef preprocess_method *processors + + # list with all features used currently + # TODO find correct type so an array can be used + cdef list feature_list + + # output tensor size + cdef int output_dim + + # board size + cdef char size + cdef short board_size + + # pattern dictionaries + cdef dict pattern_nakade + cdef dict pattern_response_12d + cdef dict pattern_non_response_3x3 + + # pattern dictionary sizes + cdef int pattern_nakade_size + cdef int pattern_response_12d_size + cdef int pattern_non_response_3x3_size + + ############################################################################ + # Tensor generating functions # + # # + ############################################################################ + + cdef int get_board(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) + """ + A feature encoding WHITE BLACK and EMPTY on separate planes. + plane 0 always refers to the current player stones + plane 1 to the opponent stones + plane 2 to empty locations + """ + + cdef int get_turns_since(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) + """ + A feature encoding the age of the stone at each location up to 'maximum' + + Note: + - the [maximum-1] plane is used for any stone with age greater than or equal to maximum + - EMPTY locations are all-zero features + """ + + cdef int get_liberties(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) + """ + A feature encoding the number of liberties of the group connected to the stone at + each location + + Note: + - there is no zero-liberties plane; the 0th plane indicates groups in atari + - the [maximum-1] plane is used for any stone with liberties greater than or equal to maximum + - EMPTY locations are all-zero features + """ + + cdef int get_ladder_capture(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) + """ + A feature wrapping GameState.is_ladder_capture(). + check if an opponent group can be captured in a ladder + """ + + cdef int get_ladder_escape(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) + """ + A feature wrapping GameState.is_ladder_escape(). + check if player_current group can escape ladder + """ + + cdef int get_sensibleness(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) + """ + A move is 'sensible' if it is legal and if it does not fill the current_player's own eye + """ + + cdef int get_legal(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) + """ + Zero at all illegal moves, one at all legal moves. Unlike sensibleness, no eye check is done + not used?? + """ + + cdef int zeros(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) + """ + Plane filled with zeros + """ + + cdef int ones(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) + """ + Plane filled with ones + """ + + cdef int colour(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) + """ + Value net feature, plane with ones if active_player is black else zeros + """ + + cdef int ko(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) + """ + Single plane encoding ko location + """ + + cdef int get_response(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) + """ + single feature plane encoding whether this location matches any of the response + patterns, for now it only checks the 12d response patterns as we do not use the + 3x3 response patterns. + """ + + cdef int get_save_atari(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) + """ + A feature wrapping GameState.is_ladder_escape(). + check if player_current group can escape atari for at least one turn + """ + + cdef int get_neighbor(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) + """ + encode last move neighbor positions in two planes: + - horizontal & vertical / direct neighbor + - diagonal neighbor + """ + + cdef int get_nakade(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) + """ + A nakade pattern is a 12d pattern on a location a stone was captured before + it is unclear if a max size of the captured group has to be considered and + how recent the capture event should have been + + the 12d pattern can be encoded without stone colour and liberty count + unclear if a border location should be considered a stone or liberty + + pattern lookup value is being set instead of 1 + """ + + cdef int get_nakade_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) + """ + A nakade pattern is a 12d pattern on a location a stone was captured before + it is unclear if a max size of the captured group has to be considered and + how recent the capture event should have been + + the 12d pattern can be encoded without stone colour and liberty count + unclear if a border location should be considered a stone or liberty + + #pattern_id is offset + """ + + cdef int get_response_12d(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) + """ + Set 12d hash pattern for 12d shape around last move + pattern lookup value is being set instead of 1 + """ + + cdef int get_response_12d_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) + """ + Set 12d hash pattern for 12d shape around last move where + #pattern_id is offset + """ + + cdef int get_non_response_3x3(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) + """ + Set 3x3 hash pattern for every legal location where + pattern lookup value is being set instead of 1 + """ + + cdef int get_non_response_3x3_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) + """ + Set 3x3 hash pattern for every legal location where + #pattern_id is offset + """ + + ############################################################################ + # public cdef function # + # # + ############################################################################ + + cdef np.ndarray[ tensor_type, ndim=4 ] generate_tensor(self, GameState state) + """ + Convert a GameState to a Theano-compatible tensor + """ diff --git a/AlphaGo/preprocessing/preprocessing_rollout.pyx b/AlphaGo/preprocessing/preprocessing_rollout.pyx new file mode 100644 index 000000000..410e46dc8 --- /dev/null +++ b/AlphaGo/preprocessing/preprocessing_rollout.pyx @@ -0,0 +1,834 @@ +# cython: profile=True +# cython: linetrace=True +# cython: wraparound=False +# cython: boundscheck=False +# cython: initializedcheck=False +cimport cython +import numpy as np +cimport numpy as np + + +cdef class Preprocess: + + ############################################################################ + # all variables are declared in the .pxd file # + # # + ############################################################################ + + + """ -> variables, declared in preprocessing.pxd + + # all feature processors + # TODO find correct type so an array can be used + cdef list processors + + # list with all features used currently + # TODO find correct type so an array can be used + cdef list feature_list + + # output tensor size + cdef int output_dim + + # board size + cdef char size + cdef short board_size + + # pattern dictionaries + cdef dict pattern_nakade + cdef dict pattern_response_12d + cdef dict pattern_non_response_3x3 + + # pattern dictionary sizes + cdef int pattern_nakade_size + cdef int pattern_response_12d_size + cdef int pattern_non_response_3x3_size + + -> variables, declared in preprocessing.pxd + """ + + + ############################################################################ + # Tensor generating functions # + # # + ############################################################################ + + + @cython.nonecheck(False) + cdef int get_board(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): + """ + A feature encoding WHITE BLACK and EMPTY on separate planes. + plane 0 always refers to the current player stones + plane 1 to the opponent stones + plane 2 to empty locations + """ + + cdef short location + cdef Group* group + cdef int plane + cdef char opponent = state.player_opponent + + # loop over all locations on board + for location in range(self.board_size): + + group = state.board_groups[ location ] + + if group.colour == _EMPTY: + + plane = offSet + 2 + elif group.colour == opponent: + + plane = offSet + 1 + else: + + plane = offSet + + tensor[ plane, location ] = 1 + + return offSet + 3 + + + @cython.nonecheck(False) + cdef int get_turns_since(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): + """ + A feature encoding the age of the stone at each location up to 'maximum' + + Note: + - the [maximum-1] plane is used for any stone with age greater than or equal to maximum + - EMPTY locations are all-zero features + """ + + cdef short location + cdef Locations_List *history = state.moves_history + cdef int age = offSet + 7 + cdef dict agesSet = {} + cdef int i + + # set all stones to max age + for i in range(history.count): + + location = history.locations[ i ] + + if location != _PASS and state.board_groups[ location ].colour > _EMPTY: + + tensor[ age, location ] = 1 + + # start with newest stone + i = history.count - 1 + age = 0 + + # loop over history backwards + while age < 7 and i >= 0: + + location = history.locations[ i ] + + # if age has not been set yet + if location != _PASS and not location in agesSet and state.board_groups[ location ].colour > _EMPTY: + + tensor[ offSet + age, location ] = 1 + tensor[ offSet + 7, location ] = 0 + agesSet[ location ] = location + + i -= 1 + age += 1 + + return offSet + 8 + + + @cython.nonecheck(False) + cdef int get_liberties(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): + """ + A feature encoding the number of liberties of the group connected to the stone at + each location + + Note: + - there is no zero-liberties plane; the 0th plane indicates groups in atari + - the [maximum-1] plane is used for any stone with liberties greater than or equal to maximum + - EMPTY locations are all-zero features + """ + + cdef int i, groupLiberty + cdef Group* group + cdef short location + + for location in range(self.board_size): + + group = state.board_groups[ location ] + + if group.colour > _EMPTY: + + groupLiberty = group.count_liberty - 1 + + # check max liberty count + if groupLiberty > 7: + + groupLiberty = 7 + + groupLiberty += offSet + + tensor[ groupLiberty, location ] = 1 + + return offSet + 8 + + + @cython.nonecheck(False) + cdef int get_ladder_capture(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): + """ + A feature wrapping GameState.is_ladder_capture(). + check if an opponent group can be captured in a ladder + """ + + cdef int location + cdef char* captures = state.get_ladder_captures(80) + + # loop over all groups on board + for location in range(state.board_size): + + if captures[ location ] != _FREE: + + tensor[ offSet, location ] = 1 + + # free captures + free(captures) + + return offSet + 1 + + + @cython.nonecheck(False) + cdef int get_ladder_escape(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): + """ + A feature wrapping GameState.is_ladder_escape(). + check if player_current group can escape ladder + """ + + cdef int location + cdef char* escapes = state.get_ladder_escapes(80) + + # loop over all groups on board + for location in range(state.board_size): + + if escapes[ location ] != _FREE: + + tensor[ offSet, location ] = 1 + + # free escapes + free(escapes) + + return offSet + 1 + + + @cython.nonecheck(False) + cdef int get_sensibleness(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): + """ + A move is 'sensible' if it is legal and if it does not fill the current_player's own eye + """ + + cdef int i + cdef short location + cdef Group* group + + # set all legal moves to 1 + for i in range(state.moves_legal.count): + + tensor[ offSet, state.moves_legal.locations[ i ] ] = 1 + + # list can increment but a big enough starting value is important + cdef Locations_List* eyes = locations_list_new(15) + + # loop over all board groups + for i in range(state.groups_list.count_groups): + + group = state.groups_list.board_groups[ i ] + + # if group is current player + if group.colour == state.player_current: + + # loop over liberties because they are possible eyes + for location in range(self.board_size): + + # check liberty location as possible eye + if group.locations[ location ] == _LIBERTY: + + # check if location is an eye + if state.is_true_eye(location, eyes, state.player_current): + + tensor[ offSet, location ] = 0 + + locations_list_destroy(eyes) + + return offSet + 1 + + + @cython.nonecheck(False) + cdef int get_legal(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): + """ + Zero at all illegal moves, one at all legal moves. Unlike sensibleness, no eye check is done + not used?? + """ + + cdef short location + + # loop over all legal moves and set to one + for location in range(state.moves_legal.count): + + tensor[ offSet, state.moves_legal.locations[ location ] ] = 1 + + return offSet + 1 + + + @cython.nonecheck(False) + cdef int get_response(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): + """ + single feature plane encoding whether this location matches any of the response + patterns, for now it only checks the 12d response patterns as we do not use the + 3x3 response patterns. + + TODO + - decide if we consider nakade patterns response patterns as well + - optimization? 12d response patterns are calculated twice.. + """ + + cdef short location, location_x, location_y, last_move, last_move_x, last_move_y + cdef int i, plane, id + cdef long hash_base, hash_pattern + cdef short *neighbor12d = state.neighbor12d + + # get last move + last_move = state.moves_history.locations[ state.moves_history.count - 1 ] + + # check if last move is not _PASS + if last_move != _PASS: + + # get 12d pattern hash of last move location and colour + hash_base = state.get_hash_12d(last_move) + + # calculate last_move x and y + last_move_x = last_move / state.size + last_move_y = last_move % state.size + + # last_move location in neighbor12d array + last_move *= 12 + + # loop over all locations in 12d shape + for i in range(12): + + # get location + location = neighbor12d[last_move + i] + + # check if location is empty + if state.board_groups[ location ].colour == _EMPTY: + + # calculate location x and y + location_x = (location / state.size) - last_move_x + location_y = (location % state.size) - last_move_y + + # calculate 12d response pattern hash + hash_pattern = hash_base + location_x + hash_pattern *= _HASHVALUE + hash_pattern += location_y + + # dictionary lookup + id = self.pattern_response_12d.get( hash_pattern ) + + if id >= 0: + + tensor[ offSet, location ] = 1 + + return offSet + 1 + + + @cython.nonecheck(False) + cdef int get_save_atari(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): + """ + A feature wrapping GameState.is_ladder_escape(). + check if player_current group can escape atari for at least one turn + """ + + cdef int location + cdef char* escapes = state.get_ladder_escapes(1) + + # loop over all groups on board + for location in range(state.board_size): + + if escapes[ location ] != _FREE: + + tensor[ offSet, location ] = 1 + + # free escapes + free(escapes) + + return offSet + 1 + + + @cython.nonecheck(False) + cdef int get_neighbor(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): + """ + encode last move neighbor positions in two planes: + - horizontal & vertical / direct neighbor + - diagonal neighbor + """ + + cdef short location, last_move + cdef int i, plane + cdef short *neighbor3x3 = state.neighbor3x3 + + # get last move + last_move = state.moves_history.locations[ state.moves_history.count - 1 ] + + # check if last move is not _PASS + if last_move != _PASS: + + # last_move location in neighbor3x3 array + last_move *= 8 + + # direct neighbor plane is plane offset + plane = offSet + + # loop over direct neighbor + # 0,1,2,3 are direct neighbor locations + for i in range(4): + + # get neighbor location + location = neighbor3x3[ last_move + i ] + + # check if location is empty + if state.board_groups[ location ].colour == _EMPTY: + + tensor[ plane, location ] = 1 + + # diagonal neighbor plane is plane offset + 1 + plane = offSet + 1 + + # loop over diagonal neighbor + # 4,5,6,7 are diagonal neighbor locations + for i in range(4, 8): + + # get neighbor location + location = neighbor3x3[ last_move + i ] + + # check if location is empty + if state.board_groups[ location ].colour == _EMPTY: + + tensor[ plane, location ] = 1 + + return offSet + 2 + + + @cython.nonecheck(False) + cdef int get_nakade(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): + """ + A nakade pattern is a 12d pattern on a location a stone was captured before + it is unclear if a max size of the captured group has to be considered and + how recent the capture event should have been + + the 12d pattern can be encoded without stone colour and liberty count + unclear if a border location should be considered a stone or liberty + + pattern lookup value is being set instead of 1 + """ + + # TODO tensor type has to be float + + return offSet + 1 + + + @cython.nonecheck(False) + cdef int get_nakade_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): + """ + A nakade pattern is a 12d pattern on a location a stone was captured before + it is unclear if a max size of the captured group has to be considered and + how recent the capture event should have been + + the 12d pattern can be encoded without stone colour and liberty count + unclear if a border location should be considered a stone or liberty + + #pattern_id is offset + """ + + return offSet + self.pattern_nakade_size + + + @cython.nonecheck(False) + cdef int get_response_12d(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): + """ + Set 12d hash pattern for 12d shape around last move + pattern lookup value is being set instead of 1 + """ + + # get last move location + # check for pass + + return offSet + 1 + + + @cython.nonecheck(False) + cdef int get_response_12d_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): + """ + Check all empty locations in a 12d shape around the last move for being a 12d response + pattern match + #pattern_id is offset + + base hash is 12d pattern hash of last move location + colour + add relative position of every empty location in a 12d shape to get 12d response pattern hash + + c hash x y + ... location a has: state.get_hash_12d(x), -1, 0 + .ax.. location b has: state.get_hash_12d(x), +1, -1 + ..b location c has: state.get_hash_12d(x), 0, +2 + . + + 12d response pattern hash value is calculated by: + ( ( hash + x ) * _HASHVALUE ) + y + """ + + cdef short location, location_x, location_y, last_move, last_move_x, last_move_y + cdef int i, plane, id + cdef long hash_base, hash_pattern + cdef short *neighbor12d = state.neighbor12d + + # get last move + last_move = state.moves_history.locations[ state.moves_history.count - 1 ] + + # check if last move is not _PASS + if last_move != _PASS: + + # get 12d pattern hash of last move location and colour + hash_base = state.get_hash_12d(last_move) + + # calculate last_move x and y + last_move_x = last_move / state.size + last_move_y = last_move % state.size + + # last_move location in neighbor12d array + last_move *= 12 + + # loop over all locations in 12d shape + for i in range(12): + + # get location + location = neighbor12d[last_move + i] + + # check if location is empty + if state.board_groups[ location ].colour == _EMPTY: + + # calculate location x and y + location_x = (location / state.size) - last_move_x + location_y = (location % state.size) - last_move_y + + # calculate 12d response pattern hash + hash_pattern = hash_base + location_x + hash_pattern *= _HASHVALUE + hash_pattern += location_y + + # dictionary lookup + id = self.pattern_response_12d.get( hash_pattern ) + + if id >= 0: + + tensor[ offSet + id, location ] = 1 + + return offSet + self.pattern_response_12d_size + + + @cython.nonecheck(False) + cdef int get_non_response_3x3(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): + """ + Set 3x3 hash pattern for every legal location where + pattern lookup value is being set instead of 1 + """ + + # TODO tensor type has to be float + + return offSet + 1 + + + @cython.nonecheck(False) + cdef int get_non_response_3x3_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): + """ + Set 3x3 hash pattern for every legal location where + #pattern_id is offset + """ + + cdef short i, location + cdef int id + + # loop over all legal moves and set to one + for i in range(state.moves_legal.count): + + # get location + location = state.moves_legal.locations[ i ] + # get location hash and dict lookup + id = self.pattern_non_response_3x3.get( state.get_3x3_hash( location ) ) + + if id >= 0: + + tensor[ offSet + id, location ] = 1 + + return offSet + self.pattern_non_response_3x3_size + + + @cython.nonecheck(False) + cdef int zeros(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): + """ + Plane filled with zeros + """ + + ########################################################## + # strange things happen if a function does not do anything + # do not remove next line without extensive testing!!!!!!! + tensor[ offSet, 0 ] = 0 + + return offSet + 1 + + + @cython.nonecheck(False) + cdef int ones(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): + """ + Plane filled with ones + """ + + cdef short location + + for location in range(0, self.board_size): + + tensor[ offSet, location ] = 1 + return offSet + 1 + + + @cython.nonecheck(False) + cdef int colour(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): + """ + Value net feature, plane with ones if active_player is black else zeros + """ + + cdef short location + + # if player_current is white + if state.player_current == _BLACK: + + for location in range(0, self.board_size): + + tensor[ offSet, location ] = 1 + + return offSet + 1 + + + @cython.nonecheck(False) + cdef int ko(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): + """ + Single plane encoding ko location + """ + + if state.ko is not _PASS: + + tensor[ offSet, state.ko ] = 1 + + return offSet + 1 + + + ############################################################################ + # init function # + # # + ############################################################################ + + + def __init__(self, list feature_list, char size=19, dict_nakade=None, dict_3x3=None, dict_12d=None, verbose=False): + """ + """ + + self.size = size + self.board_size = size * size + + cdef int i + + # preprocess_method is a function pointer: + # ctypedef int (*preprocess_method)(Preprocess, GameState, tensor_type[ :, ::1 ], char*, int) + cdef preprocess_method processor + + # create a list with function pointers + self.processors = malloc(len(feature_list) * sizeof(preprocess_method)) + + if not self.processors: + raise MemoryError() + + # load nakade patterns + self.pattern_nakade = {} + self.pattern_nakade_size = 0 + if dict_nakade is not None: + with open(dict_nakade, 'r') as f: + s = f.read() + self.pattern_nakade = ast.literal_eval(s) + self.pattern_nakade_size = max(self.pattern_nakade.values()) + 1 + + # load 12d response patterns + self.pattern_response_12d = {} + self.pattern_response_12d_size = 0 + if dict_12d is not None: + with open(dict_12d, 'r') as f: + s = f.read() + self.pattern_response_12d = ast.literal_eval(s) + self.pattern_response_12d_size = max(self.pattern_response_12d.values()) + 1 + + # load 3x3 non response patterns + self.pattern_non_response_3x3 = {} + self.pattern_non_response_3x3_size = 0 + if dict_3x3 is not None: + with open(dict_3x3, 'r') as f: + s = f.read() + self.pattern_non_response_3x3 = ast.literal_eval(s) + self.pattern_non_response_3x3_size = max(self.pattern_non_response_3x3.values()) + 1 + + if verbose: + print("loaded " + str(self.pattern_nakade_size) + " nakade patterns") + print("loaded " + str(self.pattern_response_12d_size) + " 12d patterns") + print("loaded " + str(self.pattern_non_response_3x3_size) + " 3x3 patterns") + + self.feature_list = feature_list + self.output_dim = 0 + + # loop over feature_list add the corresponding function + # and increment output_dim accordingly + for i in range(len(feature_list)): + feat = feature_list[ i ].lower() + if feat == "board": + processor = self.get_board + self.output_dim += 3 + + elif feat == "ones": + processor = self.ones + self.output_dim += 1 + + elif feat == "turns_since": + processor = self.get_turns_since + self.output_dim += 8 + + elif feat == "liberties": + processor = self.get_liberties + self.output_dim += 8 + + elif feat == "ladder_capture": + processor = self.get_ladder_capture + self.output_dim += 1 + + elif feat == "ladder_escape": + processor = self.get_ladder_escape + self.output_dim += 1 + + elif feat == "sensibleness": + processor = self.get_sensibleness + self.output_dim += 1 + + elif feat == "zeros": + processor = self.zeros + self.output_dim += 1 + + elif feat == "legal": + processor = self.get_legal + self.output_dim += 1 + + elif feat == "response": + processor = self.get_response + self.output_dim += 1 + + elif feat == "save_atari": + processor = self.get_save_atari + self.output_dim += 1 + + elif feat == "neighbor": + processor = self.get_neighbor + self.output_dim += 2 + + elif feat == "nakade": + processor = self.get_nakade + self.output_dim += self.pattern_nakade_size + + elif feat == "response_12d": + processor = self.get_response_12d + self.output_dim += self.pattern_response_12d_size + + elif feat == "non_response_3x3": + processor = self.get_non_response_3x3 + self.output_dim += self.pattern_non_response_3x3_size + + elif feat == "color": + processor = self.colour + self.output_dim += 1 + + elif feat == "ko": + processor = self.ko + self.output_dim += 1 + else: + + # incorrect feature input + raise ValueError("uknown feature: %s" % feat) + + self.processors[ i ] = processor + + + def __dealloc__(self): + """ + Prevent memory leaks by freeing all arrays created with malloc + """ + + if self.processors is not NULL: + free(self.processors) + + ############################################################################ + # public cdef function # + # # + ############################################################################ + + + @cython.nonecheck(False) + cdef np.ndarray[ tensor_type, ndim=4 ] generate_tensor(self, GameState state): + """ + Convert a GameState to a Theano-compatible tensor + """ + + cdef int i + cdef preprocess_method proc + + # create complete array now instead of concatenate later + # TODO check if we can use a Malloc array somehow.. faster!! + cdef np.ndarray[ tensor_type, ndim=2 ] np_tensor = np.zeros((self.output_dim, self.board_size), dtype=np.int8) + cdef tensor_type[ :, ::1 ] tensor = np_tensor + + cdef int offSet = 0 + + # loop over all processors and generate tensor + for i in range(len(self.feature_list)): + + proc = self.processors[ i ] + offSet = proc(self, state, tensor, offSet) + + # create a singleton 'batch' dimension + return np_tensor.reshape((1, self.output_dim, self.size, self.size)) + + + ############################################################################ + # public def function (Python) # + # # + ############################################################################ + + + def state_to_tensor(self, GameState state): + """ + Convert a GameState to a Theano-compatible tensor + """ + + return self.generate_tensor(state) + + + def get_output_dimension(self): + """ + return output_dim, the amount of planes an output tensor will have + """ + + return self.output_dim + + + def get_feature_list(self): + """ + return feature list + """ + + return self.feature_list diff --git a/setup.py b/setup.py index ff007817d..ce73d67ba 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,8 @@ name='RocAlphaGo', # list with files to be cythonized ext_modules=cythonize(["AlphaGo/go.pyx", "AlphaGo/go_data.pyx", - "AlphaGo/preprocessing/preprocessing.pyx"]), + "AlphaGo/preprocessing/preprocessing.pyx", + "AlphaGo/preprocessing/preprocessing_rollout.pyx"]), # include numpy include_dirs=[numpy.get_include(), os.path.join(numpy.get_include(), 'numpy')] @@ -25,7 +26,4 @@ you can run all unittests to verify everything works as it should: python -m unittest discover - - nb. right now one test will fail: Super-ko - """ From 0e663d6f818a6b7896ca58626c350be21b4b38b0 Mon Sep 17 00:00:00 2001 From: MaMiFreak Date: Thu, 6 Jul 2017 18:52:38 +0100 Subject: [PATCH 166/191] Superko test updated enforce_superko is set to True by default, updated the test to handle this correctly --- tests/test_gamestate.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/tests/test_gamestate.py b/tests/test_gamestate.py index 7a8ffc51b..5a919b2d7 100644 --- a/tests/test_gamestate.py +++ b/tests/test_gamestate.py @@ -62,7 +62,8 @@ def test_snapback_is_not_ko(self): def test_positional_superko(self): - gs = GameState(size=9) + # test with enforce_superko=False + gs = GameState(size=9, enforce_superko=False) move_list = [(0, 3), (0, 4), (1, 3), (1, 4), (2, 3), (2, 4), (2, 2), (3, 4), (2, 1), (3, 3), (3, 1), (3, 2), (3, 0), (4, 2), (1, 1), (4, 1), (8, 0), (4, 0), (8, 1), (0, 2), @@ -78,12 +79,6 @@ def test_positional_superko(self): gs.do_move(move) self.assertFalse(gs.is_legal((1, 0))) - # test with enforce_superko=False - gs = GameState(size=9, enforce_superko=False) - for move in move_list: - gs.do_move(move) - self.assertTrue(gs.is_legal((1, 0))) - class TestEye(unittest.TestCase): From e0f2af11bbddc04813af26fef932d05e8a16f0cc Mon Sep 17 00:00:00 2001 From: wrongu Date: Tue, 11 Jul 2017 11:22:20 -0400 Subject: [PATCH 167/191] update to h5py, numpy, scipy, theano --- requirements.txt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index b3155854d..81bf38b5c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ Cython==0.25.2 -h5py==2.6.0 +h5py==2.7.0 Keras==2.0.4 -numpy==1.11.2 +numpy==1.13.1 pygtp==0.3 PyYAML==3.12 -scipy==0.18.1 +scipy==0.19.0 sgf==0.5 six==1.10.0 -Theano==0.8.2 +Theano==0.9.0 From 2e0b3df6066bd05f6d2fc9a9b2281c92471fa834 Mon Sep 17 00:00:00 2001 From: wrongu Date: Thu, 20 Jul 2017 10:57:02 -0400 Subject: [PATCH 168/191] Changing last plane of default value net features from 'zeros' to 'color'x --- AlphaGo/preprocessing/generate_value_training.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AlphaGo/preprocessing/generate_value_training.py b/AlphaGo/preprocessing/generate_value_training.py index bb6779fb8..9dcf15f09 100644 --- a/AlphaGo/preprocessing/generate_value_training.py +++ b/AlphaGo/preprocessing/generate_value_training.py @@ -288,7 +288,7 @@ def handle_arguments(cmd_line_args=None): "ladder_capture", "ladder_escape", "sensibleness", - "zeros"] + "color"] else: features = args.features.split(",") From 22ca97d5839e3b6eee7c6a464f254277eedc1ec4 Mon Sep 17 00:00:00 2001 From: wrongu Date: Mon, 30 Oct 2017 13:46:57 -0400 Subject: [PATCH 169/191] cython-generated extensions in gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 6cce45ad3..fdd8550be 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ *.hdf5 *.sgf *.pkl +*.cpp +*.so +build/ !tests/test_data/sgf/*.sgf !tests/test_data/hdf5/*.h5 !tests/test_data/hdf5/*.hdf5 From 73c3fa4c54ad4aecdcfceb9f208d961cc906d5cb Mon Sep 17 00:00:00 2001 From: wrongu Date: Mon, 30 Oct 2017 12:15:26 -0400 Subject: [PATCH 170/191] Cython compiled with C++ and numpy arrays available. --- setup.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/setup.py b/setup.py index ce73d67ba..77bf58e20 100644 --- a/setup.py +++ b/setup.py @@ -1,19 +1,21 @@ import numpy -import os from distutils.core import setup +from distutils.extension import Extension from Cython.Build import cythonize -setup( +extensions = [ + Extension("AlphaGo.go", ["AlphaGo/go.pyx"], + include_dirs=[numpy.get_include()], language="c++"), + Extension("AlphaGo.go_data", ["AlphaGo/go_data.pyx"], + include_dirs=[numpy.get_include()], language="c++"), + Extension("AlphaGo.preprocessing.preprocessing", ["AlphaGo/preprocessing/preprocessing.pyx"], + include_dirs=[numpy.get_include()], language="c++"), + Extension("AlphaGo.preprocessing.preprocessing_rollout", + ["AlphaGo/preprocessing/preprocessing_rollout.pyx"], + include_dirs=[numpy.get_include()], language="c++"), +] - name='RocAlphaGo', - # list with files to be cythonized - ext_modules=cythonize(["AlphaGo/go.pyx", "AlphaGo/go_data.pyx", - "AlphaGo/preprocessing/preprocessing.pyx", - "AlphaGo/preprocessing/preprocessing_rollout.pyx"]), - # include numpy - include_dirs=[numpy.get_include(), - os.path.join(numpy.get_include(), 'numpy')] -) +setup(name="RocAlphaGo", ext_modules=cythonize(extensions)) """ install all necessary dependencies using: From c0933d1b742648a41f6402b13fc97932f1c1ff51 Mon Sep 17 00:00:00 2001 From: wrongu Date: Mon, 30 Oct 2017 13:41:54 -0400 Subject: [PATCH 171/191] flake8 tests for cython files --- .flake8.cython | 5 +++++ .travis.yml | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 .flake8.cython diff --git a/.flake8.cython b/.flake8.cython new file mode 100644 index 000000000..14b8d5dd7 --- /dev/null +++ b/.flake8.cython @@ -0,0 +1,5 @@ +[flake8] +filename = *.pyx,*.px* +exclude = .eggs,*.egg,build +max-line-length = 100 +ignore = E211,E901,E999,E225,E226,E227,E402 diff --git a/.travis.yml b/.travis.yml index 85b42149b..bf972c3a2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,7 +21,7 @@ install: - conda update -q conda # Useful for debugging any issues with conda - conda info -a - + # create and activiate environment with python=$TRAVIS_PYTHON_VERSION - conda create -q -n RocAlphaGo-test python=$TRAVIS_PYTHON_VERSION - source activate RocAlphaGo-test @@ -34,7 +34,7 @@ install: - conda install --yes Cython=0.25.2 h5py=2.6.0 numpy=1.11.2 scipy=0.18.1 PyYAML=3.12 matplotlib pandas pytest - pip install --user --no-deps Theano==0.8.2 sgf==0.5 keras==2.0.4 pygtp==0.3 - pip install --user flake8 - + # compile cython code - python setup.py build_ext --inplace @@ -54,6 +54,7 @@ install: # run flake8 and unit tests script: - flake8 - + - flake8 --config=.flake8.cython + # run unit test - python -m unittest discover From dd98cec8d45198f6395ef3f926295c1db0753950 Mon Sep 17 00:00:00 2001 From: wrongu Date: Mon, 30 Oct 2017 13:42:22 -0400 Subject: [PATCH 172/191] Flake8 and other minor style changes to preprocessing.pxd and .pyx --- AlphaGo/preprocessing/preprocessing.pxd | 130 +++--- AlphaGo/preprocessing/preprocessing.pyx | 597 +++++++++--------------- 2 files changed, 282 insertions(+), 445 deletions(-) diff --git a/AlphaGo/preprocessing/preprocessing.pxd b/AlphaGo/preprocessing/preprocessing.pxd index f4c85c2fe..93f5e43ce 100644 --- a/AlphaGo/preprocessing/preprocessing.pxd +++ b/AlphaGo/preprocessing/preprocessing.pxd @@ -1,18 +1,20 @@ import ast import time -import numpy as np -cimport numpy as np -from numpy cimport ndarray from libc.stdlib cimport malloc, free from AlphaGo.go cimport GameState -from AlphaGo.go_data cimport _BLACK, _EMPTY, _STONE, _LIBERTY, _CAPTURE, _FREE, _PASS, Group, Locations_List, locations_list_destroy, locations_list_new +from AlphaGo.go_data cimport _BLACK, _EMPTY, _STONE, _LIBERTY, _CAPTURE, \ + _FREE, _PASS, Group, Locations_List, locations_list_destroy, \ + locations_list_new +from numpy cimport ndarray +import numpy as np +cimport numpy as np # type of tensor created # char works but float might be needed later ctypedef char tensor_type # type defining cdef function -ctypedef int (*preprocess_method)(Preprocess, GameState, tensor_type[ :, ::1 ], char*, int) +ctypedef int (*preprocess_method)(Preprocess, GameState, tensor_type[:, ::1], char*, int) # noqa: E501 cdef class Preprocess: @@ -52,27 +54,26 @@ cdef class Preprocess: # # ############################################################################ - cdef int get_board(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) - """ - A feature encoding WHITE BLACK and EMPTY on separate planes. - plane 0 always refers to the current player stones - plane 1 to the opponent stones - plane 2 to empty locations - """ + cdef int get_board(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + """A feature encoding WHITE BLACK and EMPTY on separate planes. - cdef int get_turns_since(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) + Note: + - plane 0 always refers to the current player stones + - plane 1 to the opponent stones + - plane 2 to empty locations """ - A feature encoding the age of the stone at each location up to 'maximum' + + cdef int get_turns_since(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + """A feature encoding the age of the stone at each location up to 'maximum' Note: - the [maximum-1] plane is used for any stone with age greater than or equal to maximum - EMPTY locations are all-zero features """ - cdef int get_liberties(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) - """ - A feature encoding the number of liberties of the group connected to the stone at - each location + cdef int get_liberties(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + """A feature encoding the number of liberties of the group connected to the stone at each + location Note: - there is no zero-liberties plane; the 0th plane indicates groups in atari @@ -80,30 +81,26 @@ cdef class Preprocess: - EMPTY locations are all-zero features """ - cdef int get_capture_size(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) - """ - A feature encoding the number of opponent stones that would be captured by - playing at each location, up to 'maximum' + cdef int get_capture_size(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + """A feature encoding the number of opponent stones that would be captured by playing at each + location, up to 'maximum' Note: - we currently *do* treat the 0th plane as "capturing zero stones" - - the [maximum-1] plane is used for any capturable group of size - greater than or equal to maximum-1 + - the [maximum-1] plane is used for any capturable group of size greater than or equal to + maximum-1 - the 0th plane is used for legal moves that would not result in capture - illegal move locations are all-zero features """ - cdef int get_self_atari_size(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) + cdef int get_self_atari_size(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + """A feature encoding the size of the own-stone group that is put into atari by playing at a + location """ - A feature encoding the size of the own-stone group that is put into atari by - playing at a location - """ - - cdef int get_liberties_after(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) - """ - A feature encoding what the number of liberties *would be* of the group connected to - the stone *if* played at a location + cdef int get_liberties_after(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + """A feature encoding what the number of liberties *would be* of the group connected to the + stone *if* played at a location Note: - there is no zero-liberties plane; the 0th plane indicates groups in atari @@ -111,65 +108,56 @@ cdef class Preprocess: - illegal move locations are all-zero features """ - cdef int get_ladder_capture(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) - """ - A feature wrapping GameState.is_ladder_capture(). - check if an opponent group can be captured in a ladder + cdef int get_ladder_capture(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + """A feature wrapping GameState.is_ladder_capture(). Check if an opponent group can be captured + in a ladder """ - cdef int get_ladder_escape(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) - """ - A feature wrapping GameState.is_ladder_escape(). - check if player_current group can escape ladder + cdef int get_ladder_escape(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + """A feature wrapping GameState.is_ladder_escape(). Check if player_current group can escape + ladder """ - cdef int get_sensibleness(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) - """ - A move is 'sensible' if it is legal and if it does not fill the current_player's own eye + cdef int get_sensibleness(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + """A move is 'sensible' if it is legal and if it does not fill the current_player's own eye """ - cdef int get_legal(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) - """ - Zero at all illegal moves, one at all legal moves. Unlike sensibleness, no eye check is done + cdef int get_legal(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + """Zero at all illegal moves, one at all legal moves. Unlike sensibleness, no eye check is done not used?? """ - cdef int zeros(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) - """ - Plane filled with zeros + cdef int zeros(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + """Plane filled with zeros """ - cdef int ones(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) - """ - Plane filled with ones + cdef int ones(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + """Plane filled with ones """ - cdef int colour(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) - """ - Value net feature, plane with ones if active_player is black else zeros + cdef int colour(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + """Value net feature, plane with ones if active_player is black else zeros """ - cdef int ko(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) - """ - ko + cdef int ko(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + """Ko positions """ - cdef int get_response(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) - cdef int get_save_atari(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) - cdef int get_neighbor(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) - cdef int get_nakade(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) - cdef int get_nakade_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) - cdef int get_response_12d(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) - cdef int get_response_12d_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) - cdef int get_non_response_3x3(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) - cdef int get_non_response_3x3_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet) + cdef int get_response(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + cdef int get_save_atari(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + cdef int get_neighbor(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + cdef int get_nakade(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + cdef int get_nakade_offset(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + cdef int get_response_12d(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + cdef int get_response_12d_offset(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + cdef int get_non_response_3x3(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + cdef int get_non_response_3x3_offset(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 ############################################################################ # public cdef function # # # ############################################################################ - cdef np.ndarray[ tensor_type, ndim=4 ] generate_tensor(self, GameState state) - """ - Convert a GameState to a Theano-compatible tensor + cdef np.ndarray[tensor_type, ndim=4] generate_tensor(self, GameState state) + """Convert a GameState to a Theano-compatible tensor """ diff --git a/AlphaGo/preprocessing/preprocessing.pyx b/AlphaGo/preprocessing/preprocessing.pyx index 07eb708d7..2917fcac8 100644 --- a/AlphaGo/preprocessing/preprocessing.pyx +++ b/AlphaGo/preprocessing/preprocessing.pyx @@ -1,8 +1,7 @@ -# cython: profile=True -# cython: linetrace=True # cython: wraparound=False # cython: boundscheck=False # cython: initializedcheck=False +# cython: nonecheck=False cimport cython import numpy as np cimport numpy as np @@ -15,9 +14,7 @@ cdef class Preprocess: # # ############################################################################ - - """ -> variables, declared in preprocessing.pxd - + """ # all feature processors # TODO find correct type so an array can be used cdef list processors @@ -42,24 +39,20 @@ cdef class Preprocess: cdef int pattern_nakade_size cdef int pattern_response_12d_size cdef int pattern_non_response_3x3_size - - -> variables, declared in preprocessing.pxd """ - ############################################################################ # Tensor generating functions # # # ############################################################################ + cdef int get_board(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + """A feature encoding WHITE BLACK and EMPTY on separate planes. - @cython.nonecheck(False) - cdef int get_board(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): - """ - A feature encoding WHITE BLACK and EMPTY on separate planes. - plane 0 always refers to the current player stones - plane 1 to the opponent stones - plane 2 to empty locations + Note: + - plane 0 always refers to the current player stones + - plane 1 to the opponent stones + - plane 2 to empty locations """ cdef short location @@ -70,27 +63,21 @@ cdef class Preprocess: # loop over all locations on board for location in range(self.board_size): - group = state.board_groups[ location ] - + # Get color of stone from its group + group = state.board_groups[location] if group.colour == _EMPTY: - - plane = offSet + 2 + plane = offset + 2 elif group.colour == opponent: - - plane = offSet + 1 + plane = offset + 1 else: + plane = offset - plane = offSet - - tensor[ plane, location ] = 1 + tensor[plane, location] = 1 - return offSet + 3 + return offset + 3 - - @cython.nonecheck(False) - cdef int get_turns_since(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): - """ - A feature encoding the age of the stone at each location up to 'maximum' + cdef int get_turns_since(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + """A feature encoding the age of the stone at each location up to 'maximum' Note: - the [maximum-1] plane is used for any stone with age greater than or equal to maximum @@ -99,81 +86,61 @@ cdef class Preprocess: cdef short location cdef Locations_List *history = state.moves_history - cdef int age = offSet + 7 - cdef dict agesSet = {} - cdef int i + cdef int i, age = offset + 7 + cdef dict agesSet = {} # set all stones to max age for i in range(history.count): - - location = history.locations[ i ] - - if location != _PASS and state.board_groups[ location ].colour > _EMPTY: - - tensor[ age, location ] = 1 + location = history.locations[i] + if location != _PASS and state.board_groups[location].colour > _EMPTY: + tensor[age, location] = 1 # start with newest stone - i = history.count - 1 + i = history.count - 1 age = 0 # loop over history backwards while age < 7 and i >= 0: - - location = history.locations[ i ] - + location = history.locations[i] # if age has not been set yet - if location != _PASS and not location in agesSet and state.board_groups[ location ].colour > _EMPTY: - - tensor[ offSet + age, location ] = 1 - tensor[ offSet + 7, location ] = 0 - agesSet[ location ] = location + if location != _PASS and location not in agesSet and \ + state.board_groups[location].colour > _EMPTY: + tensor[offset + age, location] = 1 + tensor[offset + 7, location] = 0 + agesSet[location] = location - i -= 1 + i -= 1 age += 1 - return offSet + 8 + return offset + 8 - - @cython.nonecheck(False) - cdef int get_liberties(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): - """ - A feature encoding the number of liberties of the group connected to the stone at + cdef int get_liberties(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + """A feature encoding the number of liberties of the group connected to the stone at each location Note: - there is no zero-liberties plane; the 0th plane indicates groups in atari - - the [maximum-1] plane is used for any stone with liberties greater than or equal to maximum + - the [maximum-1] plane is used for any stone with liberties greater than or equal to + maximum - EMPTY locations are all-zero features """ - cdef int i, groupLiberty + cdef int i, groupLiberty cdef Group* group - cdef short location + cdef short location for location in range(self.board_size): - group = state.board_groups[ location ] - + # Get liberty count from group structure directly + group = state.board_groups[location] if group.colour > _EMPTY: + groupLiberty = min(group.count_liberty - 1, 7) + tensor[offset + groupLiberty, location] = 1 - groupLiberty = group.count_liberty - 1 - - # check max liberty count - if groupLiberty > 7: + return offset + 8 - groupLiberty = 7 - - groupLiberty += offSet - - tensor[ groupLiberty, location ] = 1 - - return offSet + 8 - - - @cython.nonecheck(False) - cdef int get_capture_size(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): - """ - A feature encoding the number of opponent stones that would be captured by + cdef int get_capture_size(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + """A feature encoding the number of opponent stones that would be captured by playing at each location, up to 'maximum' Note: @@ -186,148 +153,109 @@ cdef class Preprocess: cdef short i, location, capture_size - # loop over all legal moves and set get capture size + # Loop over all legal moves and set get capture size for i in range(state.moves_legal.count): + location = state.moves_legal.locations[i] + capture_size = min(groups_after[location * 3 + _CAPTURE], 7) + tensor[offset + capture_size, location] = 1 - location = state.moves_legal.locations[ i ] - - capture_size = groups_after[ location * 3 + _CAPTURE ] - - if capture_size > 7: - capture_size = 7 + return offset + 8 - tensor[ offSet + capture_size, location ] = 1 - - return offSet + 8 - - - @cython.nonecheck(False) - cdef int get_self_atari_size(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): - """ - A feature encoding the size of the own-stone group that is put into atari by + cdef int get_self_atari_size(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + """A feature encoding the size of the own-stone group that is put into atari by playing at a location """ cdef short i, location, group_liberty - # loop over all groups on board + # Loop over all groups on board for i in range(state.moves_legal.count): - - location = state.moves_legal.locations[ i ] - - group_liberty = groups_after[ location * 3 + _LIBERTY ] - + location = state.moves_legal.locations[i] + group_liberty = groups_after[location * 3 + _LIBERTY] + # This group is in atari if it has exactly 1 liberty if group_liberty == 1: + group_liberty = min(groups_after[location * 3 + _STONE] - 1, 7) + tensor[offset + group_liberty, location] = 1 - group_liberty = groups_after[ location * 3 + _STONE ] - 1 - if group_liberty > 7: - group_liberty = 7 - - tensor[ offSet + group_liberty, location ] = 1 + return offset + 8 - return offSet + 8 - - - @cython.nonecheck(False) - cdef int get_liberties_after(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): - """ - A feature encoding what the number of liberties *would be* of the group connected to + cdef int get_liberties_after(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + """A feature encoding what the number of liberties *would be* of the group connected to the stone *if* played at a location Note: - there is no zero-liberties plane; the 0th plane indicates groups in atari - - the [maximum-1] plane is used for any stone with liberties greater than or equal to maximum + - the [maximum-1] plane is used for any stone with liberties greater than or equal to + maximum - illegal move locations are all-zero features """ cdef short i, location, liberty - # loop over all legal moves + # Loop over all legal moves for i in range(state.moves_legal.count): - - location = state.moves_legal.locations[ i ] - - liberty = groups_after[ location * 3 + _LIBERTY ] - 1 - - if liberty > 7: - liberty = 7 - + location = state.moves_legal.locations[i] if liberty >= 0: + liberty = min(groups_after[location * 3 + _LIBERTY] - 1, 7) + tensor[offset + liberty, location] = 1 - tensor[ offSet + liberty, location ] = 1 - - return offSet + 8 + return offset + 8 - - @cython.nonecheck(False) - cdef int get_ladder_capture(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): - """ - A feature wrapping GameState.is_ladder_capture(). + cdef int get_ladder_capture(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + """A feature wrapping GameState.is_ladder_capture(). check if an opponent group can be captured in a ladder """ - cdef int location + cdef int location cdef char* captures = state.get_ladder_captures(80) - # loop over all groups on board + # Loop over all groups on board for location in range(state.board_size): - - if captures[ location ] != _FREE: - - tensor[ offSet, location ] = 1 + if captures[location] != _FREE: + tensor[offset, location] = 1 # free captures free(captures) - return offSet + 1 - + return offset + 1 - @cython.nonecheck(False) - cdef int get_ladder_escape(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): - """ - A feature wrapping GameState.is_ladder_escape(). + cdef int get_ladder_escape(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + """A feature wrapping GameState.is_ladder_escape(). check if player_current group can escape ladder """ - cdef int location + cdef int location cdef char* escapes = state.get_ladder_escapes(80) - # loop over all groups on board + # Loop over all groups on board for location in range(state.board_size): - - if escapes[ location ] != _FREE: - - tensor[ offSet, location ] = 1 + if escapes[location] != _FREE: + tensor[offset, location] = 1 # free escapes free(escapes) - return offSet + 1 + return offset + 1 - - @cython.nonecheck(False) - cdef int get_sensibleness(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): - """ - A move is 'sensible' if it is legal and if it does not fill the current_player's own eye + cdef int get_sensibleness(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + """A move is 'sensible' if it is legal and if it does not fill the current_player's own eye """ - cdef int i + cdef int i cdef short location cdef Group* group - # set all legal moves to 1 + # Set all legal moves to 1 for i in range(state.moves_legal.count): + tensor[offset, state.moves_legal.locations[i]] = 1 - tensor[ offSet, state.moves_legal.locations[ i ] ] = 1 - - # list can increment but a big enough starting value is important - cdef Locations_List* eyes = locations_list_new(15) + # List can increment but a big enough starting value is important + cdef Locations_List* eyes = locations_list_new(15) - # loop over all board groups + # Loop over all board groups to unmark own-eyes for i in range(state.groups_list.count_groups): - - group = state.groups_list.board_groups[ i ] + group = state.groups_list.board_groups[i] # if group is current player if group.colour == state.player_current: @@ -336,89 +264,71 @@ cdef class Preprocess: for location in range(self.board_size): # check liberty location as possible eye - if group.locations[ location ] == _LIBERTY: + if group.locations[location] == _LIBERTY: # check if location is an eye if state.is_true_eye(location, eyes, state.player_current): - - tensor[ offSet, location ] = 0 + tensor[offset, location] = 0 locations_list_destroy(eyes) - return offSet + 1 + return offset + 1 - - @cython.nonecheck(False) - cdef int get_legal(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): - """ - Zero at all illegal moves, one at all legal moves. Unlike sensibleness, no eye check is done + cdef int get_legal(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + """Zero at all illegal moves, one at all legal moves. Unlike sensibleness, no eye check is done not used?? """ cdef short location - # loop over all legal moves and set to one + # Loop over all legal moves and set to one for location in range(state.moves_legal.count): + tensor[offset, state.moves_legal.locations[location]] = 1 - tensor[ offSet, state.moves_legal.locations[ location ] ] = 1 - - return offSet + 1 - + return offset + 1 - @cython.boundscheck(False) - @cython.wraparound(False) - @cython.nonecheck(False) - cdef int get_response(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): - """ - single feature plane encoding whether this location matches any of the response + cdef int get_response(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + """single feature plane encoding whether this location matches any of the response patterns, for now it only checks the 12d response patterns as we do not use the 3x3 response patterns. - TODO + TODO - decide if we consider nakade patterns response patterns as well - - optimization? 12d response patterns are calculated twice.. + - optimization? 12d response patterns are calculated twice.. """ - return offSet + 1 - + return offset + 1 - @cython.nonecheck(False) - cdef int get_save_atari(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): - """ - A feature wrapping GameState.is_ladder_escape(). + cdef int get_save_atari(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + """A feature wrapping GameState.is_ladder_escape(). check if player_current group can escape atari for at least one turn """ - cdef int location + cdef int location cdef char* escapes = state.get_ladder_escapes(1) # loop over all groups on board for location in range(state.board_size): - - if escapes[ location ] != _FREE: - - tensor[ offSet, location ] = 1 + if escapes[location] != _FREE: + tensor[offset, location] = 1 # free escapes free(escapes) - return offSet + 1 - + return offset + 1 - @cython.nonecheck(False) - cdef int get_neighbor(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): - """ - encode last move neighbor positions in two planes: + cdef int get_neighbor(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + """encode last move neighbor positions in two planes: - horizontal & vertical / direct neighbor - diagonal neighbor """ cdef short location, last_move - cdef int i, plane + cdef int i, plane cdef short *neighbor3x3 = state.neighbor3x3 # get last move - last_move = state.moves_history.locations[ state.moves_history.count - 1 ] + last_move = state.moves_history.locations[state.moves_history.count - 1] # check if last move is not _PASS if last_move != _PASS: @@ -427,42 +337,37 @@ cdef class Preprocess: last_move *= 8 # direct neighbor plane is plane offset - plane = offSet + plane = offset # loop over direct neighbor # 0,1,2,3 are direct neighbor locations for i in range(4): # get neighbor location - location = neighbor3x3[ last_move + i ] + location = neighbor3x3[last_move + i] # check if location is empty - if state.board_groups[ location ].colour == _EMPTY: - - tensor[ plane, location ] = 1 + if state.board_groups[location].colour == _EMPTY: + tensor[plane, location] = 1 # diagonal neighbor plane is plane offset + 1 - plane = offSet + 1 + plane = offset + 1 # loop over diagonal neighbor # 4,5,6,7 are diagonal neighbor locations for i in range(4, 8): # get neighbor location - location = neighbor3x3[ last_move + i ] + location = neighbor3x3[last_move + i] # check if location is empty - if state.board_groups[ location ].colour == _EMPTY: + if state.board_groups[location].colour == _EMPTY: + tensor[plane, location] = 1 - tensor[ plane, location ] = 1 + return offset + 2 - return offSet + 2 - - - @cython.nonecheck(False) - cdef int get_nakade(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): - """ - A nakade pattern is a 12d pattern on a location a stone was captured before + cdef int get_nakade(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + """A nakade pattern is a 12d pattern on a location a stone was captured before it is unclear if a max size of the captured group has to be considered and how recent the capture event should have been @@ -473,14 +378,10 @@ cdef class Preprocess: """ # TODO tensor type has to be float + return offset + 1 - return offSet + 1 - - - @cython.nonecheck(False) - cdef int get_nakade_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): - """ - A nakade pattern is a 12d pattern on a location a stone was captured before + cdef int get_nakade_offset(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + """A nakade pattern is a 12d pattern on a location a stone was captured before it is unclear if a max size of the captured group has to be considered and how recent the capture event should have been @@ -490,152 +391,115 @@ cdef class Preprocess: #pattern_id is offset """ - return offSet + self.pattern_nakade_size + return offset + self.pattern_nakade_size - - @cython.nonecheck(False) - cdef int get_response_12d(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): - """ - Set 12d hash pattern for 12d shape around last move + cdef int get_response_12d(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + """Set 12d hash pattern for 12d shape around last move pattern lookup value is being set instead of 1 """ # get last move location # check for pass - return offSet + 1 + return offset + 1 - - @cython.nonecheck(False) - cdef int get_response_12d_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): - """ - Set 12d hash pattern for 12d shape around last move where + cdef int get_response_12d_offset(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + """Set 12d hash pattern for 12d shape around last move where #pattern_id is offset """ # get last move location # check for pass - return offSet + self.pattern_response_12d_size + return offset + self.pattern_response_12d_size - - @cython.nonecheck(False) - cdef int get_non_response_3x3(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): - """ - Set 3x3 hash pattern for every legal location where + cdef int get_non_response_3x3(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + """Set 3x3 hash pattern for every legal location where pattern lookup value is being set instead of 1 """ # TODO tensor type has to be float - return offSet + 1 + return offset + 1 - - @cython.nonecheck(False) - cdef int get_non_response_3x3_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): - """ - Set 3x3 hash pattern for every legal location where + cdef int get_non_response_3x3_offset(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + """Set 3x3 hash pattern for every legal location where #pattern_id is offset """ cdef short i, location - cdef int id + cdef int pattern_id # loop over all legal moves and set to one for i in range(state.moves_legal.count): # get location - location = state.moves_legal.locations[ i ] - # get location hash and dict lookup - id = self.pattern_non_response_3x3.get( state.get_3x3_hash( location ) ) - - if id >= 0: + location = state.moves_legal.locations[i] - tensor[ offSet + id, location ] = 1 - - return offSet + self.pattern_non_response_3x3_size + # get location hash and dict lookup + pattern_id = self.pattern_non_response_3x3.get(state.get_3x3_hash(location)) + if pattern_id >= 0: + tensor[offset + pattern_id, location] = 1 + return offset + self.pattern_non_response_3x3_size - @cython.nonecheck(False) - cdef int zeros(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): - """ - Plane filled with zeros + cdef int zeros(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + """Plane filled with zeros """ - ######################################################### - # strange things happen if a function does no do anything - # do not remove next line without extensive testing!!!!!! - tensor[ offSet, 0 ] = 0 + cdef short location - return offSet + 1 + for location in range(0, self.board_size): + tensor[offset, location] = 0 + return offset + 1 - @cython.nonecheck(False) - cdef int ones(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): - """ - Plane filled with ones + cdef int ones(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + """Plane filled with ones """ cdef short location for location in range(0, self.board_size): + tensor[offset, location] = 1 - tensor[ offSet, location ] = 1 - return offSet + 1 + return offset + 1 - - @cython.nonecheck(False) - cdef int colour(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): - """ - Value net feature, plane with ones if active_player is black else zeros + cdef int colour(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + """Value net feature, plane with ones if active_player is black else zeros """ - cdef short location - - # if player_current is white if state.player_current == _BLACK: + return self.ones(state, tensor, groups_after, offset) + else: + return self.zeros(state, tensor, groups_after, offset) - for location in range(0, self.board_size): - - tensor[ offSet, location ] = 1 - - return offSet + 1 - - - @cython.nonecheck(False) - cdef int ko(self, GameState state, tensor_type[ :, ::1 ] tensor, char *groups_after, int offSet): - """ - ko feature + cdef int ko(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + """Ko feature """ if state.ko is not _PASS: + tensor[offset, state.ko] = 1 - tensor[ offSet, state.ko ] = 1 - - return offSet + 1 - + return offset + 1 ############################################################################ # init function # # # ############################################################################ - - def __init__(self, list feature_list, char size=19, dict_nakade=None, dict_3x3=None, dict_12d=None, verbose=False): - """ - """ - - self.size = size + def __init__(self, list feature_list, char size=19, dict_nakade=None, dict_3x3=None, dict_12d=None, verbose=False): # noqa: E501 + self.size = size self.board_size = size * size cdef int i # preprocess_method is a function pointer: - # ctypedef int (*preprocess_method)(Preprocess, GameState, tensor_type[ :, ::1 ], char*, int) + # ctypedef int (*preprocess_method)(Preprocess, GameState, tensor_type[:, ::1], char*, int) cdef preprocess_method processor # create a list with function pointers - self.processors = malloc(len(feature_list) * sizeof(preprocess_method)) + self.processors = malloc(len(feature_list) * sizeof(preprocess_method)) if not self.processors: raise MemoryError() @@ -678,97 +542,95 @@ cdef class Preprocess: # loop over feature_list add the corresponding function # and increment output_dim accordingly for i in range(len(feature_list)): - feat = feature_list[ i ].lower() + feat = feature_list[i].lower() if feat == "board": - processor = self.get_board - self.output_dim += 3 + processor = self.get_board + self.output_dim += 3 elif feat == "ones": - processor = self.ones - self.output_dim += 1 + processor = self.ones + self.output_dim += 1 elif feat == "turns_since": - processor = self.get_turns_since - self.output_dim += 8 + processor = self.get_turns_since + self.output_dim += 8 elif feat == "liberties": - processor = self.get_liberties - self.output_dim += 8 + processor = self.get_liberties + self.output_dim += 8 elif feat == "capture_size": - processor = self.get_capture_size - self.output_dim += 8 + processor = self.get_capture_size + self.output_dim += 8 elif feat == "self_atari_size": - processor = self.get_self_atari_size - self.output_dim += 8 + processor = self.get_self_atari_size + self.output_dim += 8 elif feat == "liberties_after": - processor = self.get_liberties_after - self.output_dim += 8 + processor = self.get_liberties_after + self.output_dim += 8 elif feat == "ladder_capture": - processor = self.get_ladder_capture - self.output_dim += 1 + processor = self.get_ladder_capture + self.output_dim += 1 elif feat == "ladder_escape": - processor = self.get_ladder_escape - self.output_dim += 1 + processor = self.get_ladder_escape + self.output_dim += 1 elif feat == "sensibleness": - processor = self.get_sensibleness - self.output_dim += 1 + processor = self.get_sensibleness + self.output_dim += 1 elif feat == "zeros": - processor = self.zeros - self.output_dim += 1 + processor = self.zeros + self.output_dim += 1 elif feat == "legal": - processor = self.get_legal - self.output_dim += 1 + processor = self.get_legal + self.output_dim += 1 elif feat == "response": - processor = self.get_response - self.output_dim += 1 + processor = self.get_response + self.output_dim += 1 elif feat == "save_atari": - processor = self.get_save_atari - self.output_dim += 1 + processor = self.get_save_atari + self.output_dim += 1 elif feat == "neighbor": - processor = self.get_neighbor - self.output_dim += 2 + processor = self.get_neighbor + self.output_dim += 2 elif feat == "nakade": - processor = self.get_nakade - self.output_dim += self.pattern_nakade_size + processor = self.get_nakade + self.output_dim += self.pattern_nakade_size elif feat == "response_12d": - processor = self.get_response_12d - self.output_dim += self.pattern_response_12d_size + processor = self.get_response_12d + self.output_dim += self.pattern_response_12d_size elif feat == "non_response_3x3": - processor = self.get_non_response_3x3 - self.output_dim += self.pattern_non_response_3x3_size + processor = self.get_non_response_3x3 + self.output_dim += self.pattern_non_response_3x3_size elif feat == "color": - processor = self.colour - self.output_dim += 1 + processor = self.colour + self.output_dim += 1 elif feat == "ko": - processor = self.ko - self.output_dim += 1 + processor = self.ko + self.output_dim += 1 else: # incorrect feature input raise ValueError("uknown feature: %s" % feat) - self.processors[ i ] = processor - + self.processors[i] = processor def __dealloc__(self): - """ - Prevent memory leaks by freeing all arrays created with malloc + """Prevent memory leaks by freeing all arrays created with malloc """ if self.processors is not NULL: @@ -779,11 +641,8 @@ cdef class Preprocess: # # ############################################################################ - - @cython.nonecheck(False) - cdef np.ndarray[ tensor_type, ndim=4 ] generate_tensor(self, GameState state): - """ - Convert a GameState to a Theano-compatible tensor + cdef np.ndarray[tensor_type, ndim=4] generate_tensor(self, GameState state): + """Convert a GameState to a Theano-compatible tensor """ cdef int i @@ -791,19 +650,19 @@ cdef class Preprocess: # create complete array now instead of concatenate later # TODO check if we can use a Malloc array somehow.. faster!! - cdef np.ndarray[ tensor_type, ndim=2 ] np_tensor = np.zeros((self.output_dim, self.board_size), dtype=np.int8) - cdef tensor_type[ :, ::1 ] tensor = np_tensor + cdef np.ndarray[tensor_type, ndim=2] np_tensor = \ + np.zeros((self.output_dim, self.board_size), dtype=np.int8) + cdef tensor_type[:, ::1] tensor = np_tensor - cdef int offSet = 0 + cdef int offset = 0 # get char array with next move information cdef char *groups_after = state.get_groups_after() # loop over all processors and generate tensor for i in range(len(self.feature_list)): - - proc = self.processors[ i ] - offSet = proc(self, state, tensor, groups_after, offSet) + proc = self.processors[i] + offset = proc(self, state, tensor, groups_after, offset) # free groups_after free(groups_after) @@ -811,32 +670,22 @@ cdef class Preprocess: # create a singleton 'batch' dimension return np_tensor.reshape((1, self.output_dim, self.size, self.size)) - ############################################################################ # public def function (Python) # # # ############################################################################ - def state_to_tensor(self, GameState state): + """Convert a GameState to a Theano-compatible tensor """ - Convert a GameState to a Theano-compatible tensor - """ - return self.generate_tensor(state) - def get_output_dimension(self): + """return output_dim, the amount of planes an output tensor will have """ - return output_dim, the amount of planes an output tensor will have - """ - return self.output_dim - def get_feature_list(self): + """return feature list """ - return feature list - """ - return self.feature_list From 342f861bf904e4e30e36d60cd5f02c9f9a518b60 Mon Sep 17 00:00:00 2001 From: wrongu Date: Tue, 31 Oct 2017 10:06:35 -0400 Subject: [PATCH 173/191] Flake8 and other style changes to go.pxd and .pyx --- AlphaGo/go.pxd | 163 +++----- AlphaGo/go.pyx | 1085 +++++++++++++++++------------------------------- 2 files changed, 456 insertions(+), 792 deletions(-) diff --git a/AlphaGo/go.pxd b/AlphaGo/go.pxd index a5c524475..3ea736c1d 100644 --- a/AlphaGo/go.pxd +++ b/AlphaGo/go.pxd @@ -21,7 +21,7 @@ cdef class GameState: # list with all groups cdef Groups_List *groups_list # pointer to empty group - cdef Group *group_empty + cdef Group *group_empty # list representing board locations as groups # a Group contains all group stone locations and group liberty locations @@ -55,24 +55,21 @@ cdef class GameState: cdef unsigned long long zobrist_current cdef unsigned long long *zobrist_lookup - cdef bint enforce_superko - cdef set previous_hashes + cdef bint enforce_superko + cdef set previous_hashes ############################################################################ # init functions # # # ############################################################################ - cdef void initialize_new(self, char size) - """ - initialize this state as empty state + cdef void initialize_new(self, char size, bint enforce_superko) + """Initialize this state as empty state """ cdef void initialize_duplicate(self, GameState copyState) + """Initialize all variables as a copy of copy_state """ - Initialize all variables as a copy of copy_state - """ - ############################################################################ # private cdef functions used for game-play # @@ -80,68 +77,63 @@ cdef class GameState: ############################################################################ cdef void update_hash(self, short location, char colour) - """ - xor current hash with location + colour action value + """xor current hash with location + colour action value """ cdef bint is_positional_superko(self, short location, Group **board) - """ - Find all actions that the current_player has done in the past, taking into - account the fact that history starts with BLACK when there are no - handicaps or with WHITE when there are. + """Find all actions that the current_player has done in the past. + + This takes into account the fact that history starts with BLACK when there are no handicaps or + with WHITE when there are. """ cdef bint is_legal_move(self, short location, Group **board, short ko) - """ - check if playing at location is a legal move to make + """Check if playing at location is a legal move """ cdef bint is_legal_move_superko(self, short location, Group **board, short ko) - """ - check if playing at location is a legal move to make + """Check if playing at location is a legal move, taking superko into account """ cdef bint has_liberty_after(self, short location, Group **board) - """ - check if a play at location results in an alive group - - has liberty - - conects to group with >= 2 liberty - - captures enemy group + """Check if a play at location results in an alive group + + True if any of the following is true: + - has liberty + - connects to group with >= 2 liberty + - captures enemy group """ cdef short calculate_board_location(self, char x, char y) - """ - return location on board - no checks on outside board - x = columns - y = rows + """2D tuple location to 1d index. Inverse of calculate_tuple_location() + + No sanity checks on bounds. + + - x is column + - y is row """ cdef tuple calculate_tuple_location(self, short location) - """ - return location on board as a tupple - no checks on outside board - """ + """1d index to 2d tuple location. Inverse of calculate_board_location() - cdef void set_moves_legal_list(self, Locations_List *moves_legal) + No sanity checks on bounds. """ - generate moves_legal list + + cdef void set_moves_legal_list(self, Locations_List *moves_legal) + """Generate moves_legal list """ cdef void combine_groups(self, Group* group_keep, Group* group_remove, Group **board) - """ - combine group_keep and group_remove and replace group_remove on the board + """Combine group_keep and group_remove and replace group_remove on the board """ cdef void remove_group(self, Group* group_remove, Group **board, short* ko) - """ - remove group from board -> set all locations to group_empty + """Remove group from board -> set all locations to group_empty """ cdef void add_to_group(self, short location, Group **board, short* ko, short* count_captures) - """ - check if a stone on location is connected to a group, kills a group - or is a new group on the board + """Check if a stone on location is connected to a group, kills a group or is a new group on the + board """ ############################################################################ @@ -150,29 +142,25 @@ cdef class GameState: ############################################################################ cdef long generate_12d_hash(self, short centre) - """ - generate 12d hash around centre location + """Generate 12d hash around centre location """ cdef long generate_3x3_hash(self, short centre) - """ - generate 3x3 hash around centre location + """Generate 3x3 hash around centre location """ - cdef void get_group_after_pointer(self, short* stones, short* liberty, short* capture, char* locations, char* captures, short location) - cdef void get_group_after(self, char* groups_after, char* locations, char* captures, short location) - """ - groups_after is a board_size * 3 array representing STONES, LIBERTY, CAPTURE for every location + cdef void get_group_after_pointer(self, short* stones, short* liberty, short* capture, char* locations, char* captures, short location) # noqa: E501 + cdef void get_group_after(self, char* groups_after, char* locations, char* captures, short location) # noqa: E501 + """Groups_after is a board_size * 3 array representing STONES, LIBERTY, CAPTURE for every location calculate group after a play on location and set - groups_after[ location * 3 + ] to stone count - groups_after[ location * 3 + 1 ] to liberty count - groups_after[ location * 3 + 2 ] to capture count + groups_after[location * 3 + ] to stone count + groups_after[location * 3 + 1] to liberty count + groups_after[location * 3 + 2] to capture count """ cdef bint is_true_eye(self, short location, Locations_List* eyes, char owner) - """ - check if location is a real eye + """Check if location is a real eye """ ############################################################################ @@ -180,8 +168,7 @@ cdef class GameState: # # ############################################################################ - """ - Ladder evaluation consumes a lot of time duplicating data, the original + """Ladder evaluation consumes a lot of time duplicating data, the original version (still can be found in go_python.py) made a copy of the whole GameState for every move played. @@ -206,8 +193,7 @@ cdef class GameState: """ cdef Groups_List* add_ladder_move(self, short location, Group **board, short* ko) - """ - create a new group for location move and add all connected groups to it + """Create a new group for location move and add all connected groups to it similar to add_to_group except no groups are changed or killed and a list with groups removed is returned so the board can be restored to original @@ -215,47 +201,40 @@ cdef class GameState: """ cdef void remove_ladder_group(self, Group* group_remove, Group **board, short* ko) - """ - remove group from board -> set all locations to group_empty + """Remove group from board -> set all locations to group_empty does not update zobrist hash """ - cdef void undo_ladder_move(self, short location, Groups_List* removed_groups, short ko, Group **board, short* ko) - """ - Use removed_groups list to return board state to be the same as before + cdef void undo_ladder_move(self, short location, Groups_List* removed_groups, short ko, Group **board, short* ko) # noqa: E501 + """Use removed_groups list to return board state to be the same as before add_ladder_move was used """ cdef void unremove_group(self, Group* group_remove, Group **board) - """ - unremove group from board + """Unremove group from board loop over all stones in this group and set board to group_unremove remove liberty from neigbor locations """ cdef dict get_capture_moves(self, Group* group, char color, Group **board) - """ - create a dict with al moves that capture a group surrounding group + """Create a dict with al moves that capture a group surrounding group """ - cdef void get_removed_groups(self, short location, Groups_List* removed_groups, Group **board, short* ko) - """ - create a new group for location move and add all connected groups to it + cdef void get_removed_groups(self, short location, Groups_List* removed_groups, Group **board, short* ko) # noqa: E501 + """Create a new group for location move and add all connected groups to it similar to add_to_group except no groups are changed or killed all changes to the board are stored in removed_groups """ - cdef bint is_ladder_escape_move(self, Group **board, short* ko, Locations_List *list_ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase) - """ - play a ladder move on location, check if group has escaped, + cdef bint is_ladder_escape_move(self, Group **board, short* ko, Locations_List *list_ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase) # noqa: E501 + """Play a ladder move on location, check if group has escaped, if the group has 2 liberty it is undetermined -> try to capture it by playing at both liberty """ - cdef bint is_ladder_capture_move(self, Group **board, short* ko, Locations_List *list_ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase) - """ - play a ladder move on location, try capture and escape moves + cdef bint is_ladder_capture_move(self, Group **board, short* ko, Locations_List *list_ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase) # noqa: E501 + """Play a ladder move on location, try capture and escape moves and see if the group is able to escape ladder """ @@ -265,8 +244,7 @@ cdef class GameState: ############################################################################ cdef char* get_groups_after(self) - """ - return a short array of size board_size * 3 representing + """Return a short array of size board_size * 3 representing STONES, LIBERTY, CAPTURE for every board location max count values are 100 @@ -276,26 +254,22 @@ cdef class GameState: """ cdef long get_hash_12d(self, short centre) - """ - return hash for 12d star pattern around location + """Return hash for 12d star pattern around location """ cdef long get_hash_3x3(self, short location) - """ - return 3x3 pattern hash + current player + """Return 3x3 pattern hash + current player """ cdef char* get_ladder_escapes(self, int maxDepth) - """ - return char array with size board_size + """Return char array with size board_size every location represents a location on the board where: _FREE = no ladder escape _STONE = ladder escape """ cdef char* get_ladder_captures(self, int maxDepth) - """ - return char array with size board_size + """Return char array with size board_size every location represents a location on the board where: _FREE = no ladder capture _STONE = ladder capture @@ -307,8 +281,7 @@ cdef class GameState: ############################################################################ cdef void add_move(self, short location) - """ - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + """!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! Move should be legal! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! @@ -318,13 +291,11 @@ cdef class GameState: """ cdef GameState new_state_add_move(self, short location) - """ - copy this gamestate and play move at location + """Copy this gamestate and play move at location """ cdef float get_score(self, float komi) - """ - Calculate score of board state. Uses 'Area scoring'. + """Calculate score of board state. Uses 'Area scoring'. http://senseis.xmp.net/?Passing#1 @@ -333,8 +304,7 @@ cdef class GameState: """ cdef char get_winner_colour(self, float komi) - """ - Calculate score of board state and return player ID (1, -1, or 0 for tie) + """Calculate score of board state and return player ID (1, -1, or 0 for tie) corresponding to winner. Uses 'Area scoring'. http://senseis.xmp.net/?Passing#1 @@ -346,6 +316,5 @@ cdef class GameState: ############################################################################ cdef Locations_List* get_sensible_moves(self) - """ - only used for def get_legal_moves + """Only used for def get_legal_moves """ diff --git a/AlphaGo/go.pyx b/AlphaGo/go.pyx index a1bc05a50..811767b9e 100644 --- a/AlphaGo/go.pyx +++ b/AlphaGo/go.pyx @@ -1,15 +1,13 @@ -# cython: profile=True -# cython: linetrace=True # cython: wraparound=False # cython: boundscheck=False # cython: initializedcheck=False +# cython: nonecheck=False cimport cython import numpy as np cimport numpy as np from libc.stdlib cimport malloc, free from libc.string cimport memcpy, memset -# global # global empty group cdef Group *group_empty @@ -26,7 +24,7 @@ cdef char neighbor_size cdef unsigned long long *zobrist_lookup # expose variables to python -PASS = _PASS +PASS = _PASS BLACK = _BLACK WHITE = _WHITE EMPTY = _EMPTY @@ -38,8 +36,7 @@ cdef class GameState: # # ############################################################################ - """ -> variables, declared in go.pxd - + """ # amount of locations on one side cdef char size # amount of locations on board, size * size @@ -51,7 +48,7 @@ cdef class GameState: # list with all groups cdef Groups_List *groups_list # pointer to empty group - cdef Group *group_empty + cdef Group *group_empty # list representing board locations as groups # a Group contains all group stone locations and group liberty locations @@ -82,9 +79,11 @@ cdef class GameState: cdef short *neighbor12d # zobrist - cdef set previous_hashes + cdef unsigned long long zobrist_current + cdef unsigned long long *zobrist_lookup - -> variables, declared in go.pxd + cdef bint enforce_superko + cdef set previous_hashes """ ############################################################################ @@ -92,39 +91,35 @@ cdef class GameState: # # ############################################################################ - - @cython.boundscheck(False) - @cython.wraparound(False) - cdef void initialize_new(self, char size): - """ - initialize this state as empty state + cdef void initialize_new(self, char size, bint enforce_superko): + """Initialize this state as empty state """ cdef short i # set pointer to neighbor locations # neighbor, neighbor3x3, neighbor12d and zobrist_lookup are global - self.neighbor = neighbor - self.neighbor3x3 = neighbor3x3 - self.neighbor12d = neighbor12d + self.neighbor = neighbor + self.neighbor3x3 = neighbor3x3 + self.neighbor12d = neighbor12d self.zobrist_lookup = zobrist_lookup # initialize size and board_size - self.size = size - self.board_size = size * size + self.size = size + self.board_size = size * size # create history list self.moves_history = locations_list_new(10) # initialize player colours - self.player_current = _BLACK + self.player_current = _BLACK self.player_opponent = _WHITE - self.ko = _PASS - self.capture_black = 0 - self.capture_white = 0 - self.passes_black = 0 - self.passes_white = 0 + self.ko = _PASS + self.capture_black = 0 + self.capture_white = 0 + self.passes_black = 0 + self.passes_white = 0 # create arrays and lists # +1 on board_size is used as an border location used for all borders @@ -140,7 +135,7 @@ cdef class GameState: # create Locations_List as legal_moves # after every move this list will be updated to contain all legal moves # max amount of legal moves is board_size - self.moves_legal = locations_list_new(self.board_size) + self.moves_legal = locations_list_new(self.board_size) # create groups_list as groups_list # this list will contain all alive groups @@ -148,83 +143,70 @@ cdef class GameState: # will be incremented in group_list_add self.groups_list = groups_list_new(self.board_size) - # get global group_empty reference -> used when removing groups - self.group_empty = group_empty - # initialize board, set all locations to group empty and add all # locations as move_legal for i in range(self.board_size): - - self.board_groups[ i ] = group_empty - self.moves_legal.locations[ i ] = i + self.board_groups[i] = group_empty + self.moves_legal.locations[i] = i # on an empty board board_size == amount of legal moves # set the moves_legal count to board_size self.moves_legal.count = self.board_size # initialize border location to group_border - self.board_groups[ self.board_size ] = group_border + self.board_groups[self.board_size] = group_border - # set zobrist + # set zobrist self.previous_hashes = set() self.zobrist_current = 0 + self.enforce_superko = enforce_superko - - @cython.boundscheck(False) - @cython.wraparound(False) cdef void initialize_duplicate(self, GameState copy_state): - """ - Initialize all variables as a copy of copy_state + """Initialize all variables as a copy of copy_state """ - cdef int i - cdef short location + cdef int i + cdef short location cdef Group* group_pointer cdef Group* group - # !!! do not copy !!! - # these do not need a deep copy as they are static - self.neighbor = copy_state.neighbor - self.neighbor3x3 = copy_state.neighbor3x3 - self.neighbor12d = copy_state.neighbor12d - - # empty group - self.group_empty = copy_state.group_empty - - # pattern dictionary - - # zobrist - self.zobrist_lookup = copy_state.zobrist_lookup + # neighbor, neighbor3x3, neighbor12d and zobrist_lookup are global + self.neighbor = neighbor + self.neighbor3x3 = neighbor3x3 + self.neighbor12d = neighbor12d + self.zobrist_lookup = zobrist_lookup # !!! deep copy !!! # set all values - self.ko = copy_state.ko - self.capture_black = copy_state.capture_black - self.capture_white = copy_state.capture_white - self.passes_black = copy_state.passes_black - self.passes_white = copy_state.passes_white - self.size = copy_state.size - self.board_size = copy_state.board_size - self.player_current = copy_state.player_current + self.ko = copy_state.ko + self.capture_black = copy_state.capture_black + self.capture_white = copy_state.capture_white + self.passes_black = copy_state.passes_black + self.passes_white = copy_state.passes_white + self.size = copy_state.size + self.board_size = copy_state.board_size + self.player_current = copy_state.player_current self.player_opponent = copy_state.player_opponent self.zobrist_current = copy_state.zobrist_current self.enforce_superko = copy_state.enforce_superko self.previous_hashes = copy_state.previous_hashes.copy() # create history list - self.moves_history = locations_list_new(copy_state.moves_history.size) + self.moves_history = locations_list_new(copy_state.moves_history.size) self.moves_history.count = copy_state.moves_history.count # copy all history moves in copy_state - memcpy(self.moves_history.locations, copy_state.moves_history.locations, copy_state.moves_history.count * sizeof(short)) + memcpy(self.moves_history.locations, copy_state.moves_history.locations, + copy_state.moves_history.count * sizeof(short)) # create Locations_List as legal_moves # after every move this list will be updated to contain all legal moves # max amount of legal moves is board_size - self.moves_legal = locations_list_new(self.board_size) + self.moves_legal = locations_list_new(self.board_size) self.moves_legal.count = copy_state.moves_legal.count # copy all legal moves from copy_state - memcpy(self.moves_legal.locations, copy_state.moves_legal.locations, copy_state.moves_legal.count * sizeof(short)) + memcpy(self.moves_legal.locations, copy_state.moves_legal.locations, + copy_state.moves_legal.count * sizeof(short)) # create groups_list as groups_list # this list will contain all alive groups @@ -236,7 +218,7 @@ cdef class GameState: # this array represent the board, every group contains colour, stone-locations # and liberty locations # border location is included, therefore the array size is board_size +1 - self.board_groups = malloc((self.board_size + 1) * sizeof(Group*)) + self.board_groups = malloc((self.board_size + 1) * sizeof(Group*)) if not self.board_groups: raise MemoryError() @@ -248,103 +230,70 @@ cdef class GameState: # duplicate them and set all Group pointers of this groups stone-locations # to the new group for i in range(copy_state.groups_list.count_groups): - # get group - group = copy_state.groups_list.board_groups[ i ] + group = copy_state.groups_list.board_groups[i] # duplicate group group_pointer = group_duplicate(group, self.board_size) + # add new group to groups_list groups_list_add(group_pointer, self.groups_list) # loop over all group locations for location in range(self.board_size): - # if group has a stone on this location, set board_groups group pointer - if group.locations[ location ] == _STONE: - - self.board_groups[ location ] = group_pointer - - - @cython.boundscheck(False) - @cython.wraparound(False) - def __init__(self, char size = 19, GameState copyState = None, enforce_superko=True): - """ - create new instance of GameState + if group.locations[location] == _STONE: + self.board_groups[location] = group_pointer + def __init__(self, char size=19, GameState copy=None, enforce_superko=True): + """Create new instance of GameState """ - if copyState is not None: - - # create copy of given state - self.initialize_duplicate(copyState) - else: - - self.enforce_superko = enforce_superko - - # check if neighbor arrays exist or size has changed - if not neighbor or size != neighbor_size: - - # calculate board size - self.board_size = size * size - - # set globals so they can be changed + if copy is None: + if neighbor_size == 0 or neighbor_size != size: + # This is the first GameState object - initialize global variables global neighbor global neighbor3x3 global neighbor12d + global zobrist_lookup global neighbor_size global group_empty global group_border - global zobrist_lookup - - # free arrays if they already existed - if neighbor: - free(neighbor) - free(neighbor3x3) - free(neighbor12d) - free(zobrist_lookup) + neighbor = get_neighbors(size) + neighbor3x3 = get_3x3_neighbors(size) + neighbor12d = get_12d_neighbors(size) + zobrist_lookup = get_zobrist_lookup(size) - # set size + # Set global size neighbor_size = size - # set neighbor arrays - neighbor = get_neighbors(size) - neighbor3x3 = get_3x3_neighbors(size) - neighbor12d = get_12d_neighbors(size) - zobrist_lookup = get_zobrist_lookup(size) - # initialize EMPTY and BORDER group - if not group_empty: - group_empty = group_new(_EMPTY, self.board_size) - if not group_border: - group_border = group_new(_BORDER, self.board_size) - - # create new root state - self.initialize_new(size) + group_empty = group_new(_EMPTY, self.board_size) + group_border = group_new(_BORDER, self.board_size) + # elif neighbor_size != size: + # raise ValueError("Due to global variables, all GameState objects must have the " + # "same board size.") - @cython.boundscheck(False) - @cython.wraparound(False) - def __dealloc__(self): - """ - this function is called when this object is destroyed + # Initialize state of this object + self.initialize_new(size, enforce_superko) - Prevent memory leaks by freeing all arrays created with malloc + else: + # create copy of given state + self.initialize_duplicate(copy) - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - do not fee neighbor, neighbor3x3, neighbor12d, - group_empty or group_border + def __dealloc__(self): + """This function is called when this object is destroyed - RootState will handle those! - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + Prevent memory leaks by freeing all arrays created with malloc. Global objects should not + be destroyed """ cdef int i # free board_groups if self.board_groups is not NULL: - free(self.board_groups) # free history @@ -365,7 +314,7 @@ cdef class GameState: # loop over all groups and free them for i in range(self.groups_list.count_groups): - group_destroy(self.groups_list.board_groups[ i ]) + group_destroy(self.groups_list.board_groups[i]) # free groups_list.board_groups if self.groups_list.board_groups is not NULL: @@ -374,18 +323,13 @@ cdef class GameState: free(self.groups_list) - ############################################################################ # private cdef functions used for game-play # # # ############################################################################ - - @cython.boundscheck(False) - @cython.wraparound(False) cdef void update_hash(self, short location, char colour): - """ - xor current hash with location + colour action value + """Xor current hash with location + colour action value """ if colour == _BLACK: @@ -393,12 +337,8 @@ cdef class GameState: self.zobrist_current = self.zobrist_current ^ self.zobrist_lookup[location] - - @cython.boundscheck(False) - @cython.wraparound(False) cdef bint is_positional_superko(self, short location, Group **board): - """ - Find all actions that the current_player has done in the past, taking into + """Find all actions that the current_player has done in the past, taking into account the fact that history starts with BLACK when there are no handicaps or with WHITE when there are. """ @@ -422,7 +362,7 @@ cdef class GameState: while i < self.moves_history.count: # check if move was played aleady - if self.moves_history.locations[ i ] == location: + if self.moves_history.locations[i] == location: played = 1 @@ -435,7 +375,7 @@ cdef class GameState: # TODO inefficient!!! # duplicate state and play move cdef GameState copy_state - copy_state = GameState(copyState=self) + copy_state = GameState(copy=self) copy_state.enforce_superko = 0 # do move @@ -447,15 +387,12 @@ cdef class GameState: return 0 - @cython.boundscheck(False) - @cython.wraparound(False) cdef bint is_legal_move(self, short location, Group **board, short ko): - """ - check if playing at location is a legal move to make + """Check if playing at location is a legal move to make """ # check if it is empty - if board[ location ].colour != _EMPTY: + if board[location].colour != _EMPTY: return 0 # check ko @@ -466,17 +403,17 @@ cdef class GameState: if 0 == self.has_liberty_after(location, board): return 0 + if self.enforce_superko and self.is_positional_superko(location, board): + return 0 + return 1 - @cython.boundscheck(False) - @cython.wraparound(False) cdef bint is_legal_move_superko(self, short location, Group **board, short ko): - """ - check if playing at location is a legal move to make + """Check if playing at location is a legal move to make """ # check if it is empty - if board[ location ].colour != _EMPTY: + if board[location].colour != _EMPTY: return 0 # check ko @@ -488,18 +425,13 @@ cdef class GameState: return 0 # if we have to enforce superko, check superko - if self.enforce_superko: - if self.is_positional_superko(location, board): - return 0 + if self.enforce_superko and self.is_positional_superko(location, board): + return 0 return 1 - - @cython.boundscheck(False) - @cython.wraparound(False) cdef bint has_liberty_after(self, short location, Group **board): - """ - check if a play at location results in an alive group + """Check if a play at location results in an alive group - has liberty - conects to group with >= 2 liberty - captures enemy group @@ -515,8 +447,8 @@ cdef class GameState: for i in range(4): # get neighbor location - neighbor_location = self.neighbor[ location * 4 + i ] - board_value = board[ neighbor_location ].colour + neighbor_location = self.neighbor[location * 4 + i] + board_value = board[neighbor_location].colour # if empty location -> liberty -> legal move if board_value == _EMPTY: @@ -525,7 +457,7 @@ cdef class GameState: # get neighbor group # (group_border has zero libery and is wrong colour) - group_temp = board[ neighbor_location ] + group_temp = board[neighbor_location] count_liberty = group_temp.count_liberty # if there is a player_current group @@ -546,38 +478,27 @@ cdef class GameState: return 0 - - @cython.boundscheck(False) - @cython.wraparound(False) cdef short calculate_board_location(self, char x, char y): - """ - return location on board - no checks on outside board - x = columns - y = rows + """2D tuple location to 1d index. Inverse of calculate_tuple_location() + + No sanity checks on bounds. + + - x is column + - y is row """ - # return board location return x + (y * self.size) - - @cython.boundscheck(False) - @cython.wraparound(False) cdef tuple calculate_tuple_location(self, short location): - """ - return location on board as a tupple - no checks on outside board + """1d index to 2d tuple location. Inverse of calculate_board_location() + + No sanity checks on bounds. """ - # return board location return (location / self.size, location % self.size) - - @cython.boundscheck(False) - @cython.wraparound(False) cdef void set_moves_legal_list(self, Locations_List *moves_legal): - """ - generate moves_legal list + """Generate moves_legal list """ cdef short i @@ -593,15 +514,11 @@ cdef class GameState: if self.is_legal_move_superko(i, self.board_groups, self.ko): # add to moves_legal - moves_legal.locations[ moves_legal.count ] = i + moves_legal.locations[moves_legal.count] = i moves_legal.count += 1 - - @cython.boundscheck(False) - @cython.wraparound(False) cdef void combine_groups(self, Group* group_keep, Group* group_remove, Group **board): - """ - combine group_keep and group_remove and replace group_remove on the board + """Combine group_keep and group_remove and replace group_remove on the board """ cdef int i @@ -610,25 +527,21 @@ cdef class GameState: # loop over all board locations for i in range(self.board_size): - value = group_remove.locations[ i ] + value = group_remove.locations[i] if value == _STONE: # group_remove has a stone, add to group_keep # and set board location to group_keep group_add_stone(group_keep, i) - board[ i ] = group_keep + board[i] = group_keep elif value == _LIBERTY: # add liberty group_add_liberty(group_keep, i) - - @cython.boundscheck(False) - @cython.wraparound(False) cdef void remove_group(self, Group* group_remove, Group **board, short* ko): - """ - remove group from board -> set all locations to group_empty + """Remove group from board -> set all locations to group_empty """ cdef short location @@ -640,15 +553,15 @@ cdef class GameState: # if groupsize == 1, possible ko if group_remove.count_stones == 1: - ko[ 0 ] = group_location_stone(group_remove, self.board_size) + ko[0] = group_location_stone(group_remove, self.board_size) # loop over all group stone locations for location in range(self.board_size): - if group_remove.locations[ location ] == _STONE: + if group_remove.locations[location] == _STONE: # set location to empty group - board[ location ] = self.group_empty + board[location] = group_empty # update hash self.update_hash(location, group_remove.colour) @@ -658,23 +571,19 @@ cdef class GameState: for i in range(4): # get neighbor location - neighbor_location = self.neighbor[ location * 4 + i ] + neighbor_location = self.neighbor[location * 4 + i] # only current_player groups can be next to a killed group # check if there is a group - board_value = board[ neighbor_location ].colour + board_value = board[neighbor_location].colour if board_value == self.player_current: # add liberty - group_temp = board[ neighbor_location ] + group_temp = board[neighbor_location] group_add_liberty(group_temp, location) - - @cython.boundscheck(False) - @cython.wraparound(False) cdef void add_to_group(self, short location, Group **board, short* ko, short* count_captures): - """ - check if a stone on location is connected to a group, kills a group + """Check if a stone on location is connected to a group, kills a group or is a new group on the board """ @@ -692,8 +601,8 @@ cdef class GameState: for i in range(4): # get neighbor location and value - neighborLocation = self.neighbor[ location * 4 + i ] - boardValue = board[ neighborLocation ].colour + neighborLocation = self.neighbor[location * 4 + i] + boardValue = board[neighborLocation].colour # check if neighbor is friendly stone if boardValue == self.player_current: @@ -702,11 +611,11 @@ cdef class GameState: if newGroup is NULL: # first friendly neighbor - newGroup = board[ neighborLocation ] + newGroup = board[neighborLocation] else: # another friendly group, if they are different combine them - tempGroup = board[ neighborLocation ] + tempGroup = board[neighborLocation] if tempGroup != newGroup: self.combine_groups(newGroup, tempGroup, board) @@ -718,19 +627,20 @@ cdef class GameState: elif boardValue == self.player_opponent: # remove liberty from enemy group - tempGroup = board[ neighborLocation ] + tempGroup = board[neighborLocation] group_remove_liberty(tempGroup, location) # check liberty count and remove if 0 if tempGroup.count_liberty == 0: # increment capture count - count_captures[ 0 ] += tempGroup.count_stones + count_captures[0] += tempGroup.count_stones # remove group and update hashes self.remove_group(tempGroup, board, ko) - # TODO hashes of locations next to a group where liberty change also have to be updated + # TODO hashes of locations next to a group where liberty change also have to be + # updated # remove tempGroup from groupList and destroy groups_list_remove(tempGroup, self.groups_list) @@ -753,7 +663,7 @@ cdef class GameState: # add stone to group group_add_stone(newGroup, location) # set board location to group - board[ location ] = newGroup + board[location] = newGroup # calculate location in neighbor array location_array = location * 8 @@ -762,10 +672,10 @@ cdef class GameState: for i in range(4): # get neighbor location - neighborLocation = self.neighbor3x3[ location_array + i ] + neighborLocation = self.neighbor3x3[location_array + i] # if neighbor location is empty add liberty and update hash - if board[ neighborLocation ].colour == _EMPTY: + if board[neighborLocation].colour == _EMPTY: group_add_liberty(newGroup, neighborLocation) @@ -773,20 +683,15 @@ cdef class GameState: # if two groups died there is no ko # if newGroup has more than 1 stone there is no ko if group_removed >= 2 or newGroup.count_stones > 1: - ko[ 0 ] = _PASS - + ko[0] = _PASS ############################################################################ # private cdef functions used for feature generation # # # ############################################################################ - - @cython.boundscheck(False) - @cython.wraparound(False) cdef long generate_12d_hash(self, short centre): - """ - generate 12d hash around centre location + """Generate 12d hash around centre location """ cdef int i @@ -800,7 +705,7 @@ cdef class GameState: for i in range(12): # get group - group = self.board_groups[ self.neighbor12d[ centre + i ] ] + group = self.board_groups[self.neighbor12d[centre + i]] # hash colour hash += group.colour @@ -812,12 +717,8 @@ cdef class GameState: return hash - - @cython.boundscheck(False) - @cython.wraparound(False) cdef long generate_3x3_hash(self, short centre): - """ - generate 3x3 hash around centre location + """Generate 3x3 hash around centre location """ cdef int i @@ -831,7 +732,7 @@ cdef class GameState: for i in range(8): # get group - group = self.board_groups[ self.neighbor3x3[ centre + i ] ] + group = self.board_groups[self.neighbor3x3[centre + i]] # hash colour hash += group.colour @@ -843,46 +744,44 @@ cdef class GameState: return hash - @cython.boundscheck(False) - @cython.wraparound(False) - cdef void get_group_after(self, char* groups_after, char* locations, char* captures, short location): - """ - groups_after is a board_size * 3 array representing STONES, LIBERTY, CAPTURE for every location + cdef void get_group_after(self, char* groups_after, char* locations, char* captures, short location): # noqa: E501 + """Groups_after is a board_size * 3 array representing STONES, LIBERTY, CAPTURE for every + location - calculate group after a play on location and set - groups_after[ location * 3 + ] to stone count - groups_after[ location * 3 + 1 ] to liberty count - groups_after[ location * 3 + 2 ] to capture count + calculate group after a play on location and set + - groups_after[location * 3 +] to stone count + - groups_after[location * 3 + 1] to liberty count + - groups_after[location * 3 + 2] to capture count """ - cdef short neighbor_location - cdef short temp_location - cdef char board_value - cdef Group* temp_group - cdef int i, a - cdef int location_array = location * 3 - cdef short stones, liberty, capture + cdef short neighbor_location + cdef short temp_location + cdef char board_value + cdef Group* temp_group + cdef int i, a + cdef int location_array = location * 3 + cdef short stones, liberty, capture # loop over all four neighbors for i in range(4): # get neighbor location and value - neighbor_location = self.neighbor[ location * 4 + i ] - temp_group = self.board_groups[ neighbor_location ] - board_value = temp_group.colour + neighbor_location = self.neighbor[location * 4 + i] + temp_group = self.board_groups[neighbor_location] + board_value = temp_group.colour # check if neighbor is friendly stone if board_value == _EMPTY: - locations[ neighbor_location ] = _LIBERTY + locations[neighbor_location] = _LIBERTY elif board_value == self.player_current: # found friendly group for a in range(self.board_size): - if temp_group.locations[ a ] != _FREE: + if temp_group.locations[a] != _FREE: - locations[ a ] = temp_group.locations[ a ] + locations[a] = temp_group.locations[a] elif board_value == self.player_opponent: @@ -892,89 +791,83 @@ cdef class GameState: for a in range(self.board_size): - if temp_group.locations[ a ] == _STONE: + if temp_group.locations[a] == _STONE: - captures[ a ] = _CAPTURE + captures[a] = _CAPTURE # add stone - locations[ location ] = _STONE + locations[location] = _STONE for neighbor_location in range(self.board_size): - if captures[ neighbor_location ] == _CAPTURE: + if captures[neighbor_location] == _CAPTURE: # loop over all four neighbors for i in range(4): # get neighbor location and value - temp_location = self.neighbor[ neighbor_location * 4 + i ] - if temp_location < self.board_size and locations[ temp_location ] == _STONE: - - locations[ neighbor_location ] = _LIBERTY + temp_location = self.neighbor[neighbor_location * 4 + i] + if temp_location < self.board_size and locations[temp_location] == _STONE: + locations[neighbor_location] = _LIBERTY # remove location as liberty - locations[ location ] = _STONE + locations[location] = _STONE - stones = 0 + stones = 0 liberty = 0 capture = 0 # count all values for i in range(self.board_size): - if locations[ i ] == _STONE: + if locations[i] == _STONE: stones += 1 - elif locations[ i ] == _LIBERTY: + elif locations[i] == _LIBERTY: liberty += 1 - if captures[ i ] == _CAPTURE: + if captures[i] == _CAPTURE: capture += 1 # check max if stones > 100: - stones = 100 + stones = 100 if liberty > 100: - liberty = 100 + liberty = 100 if capture > 100: - capture = 100 + capture = 100 # set values - groups_after[ location_array ] = stones - groups_after[ location_array + 1 ] = liberty - groups_after[ location_array + 2 ] = capture + groups_after[location_array] = stones + groups_after[location_array + 1] = liberty + groups_after[location_array + 2] = capture - - @cython.boundscheck(False) - @cython.wraparound(False) - cdef void get_group_after_pointer(self, short* stones, short* liberty, short* capture, char* locations, char* captures, short location): - """ - groups_after is a board_size * 3 array representing STONES, LIBERTY, CAPTURE for every location + cdef void get_group_after_pointer(self, short* stones, short* liberty, short* capture, char* locations, char* captures, short location): # noqa: E501 + """Groups_after is a board_size * 3 array representing STONES, LIBERTY, CAPTURE for every location calculate group after a play on location and set - stones[ 0 ] to stone count - liberty[0 ] to liberty count - capture[0 ] to capture count + stones[0] to stone count + liberty[0] to liberty count + capture[0] to capture count """ - - cdef short neighbor_location - cdef short temp_location - cdef char board_value - cdef Group* temp_group - cdef int i, a, b, c - cdef int location_array = location * 3 + cdef short neighbor_location + cdef short temp_location + cdef char board_value + cdef Group* temp_group + cdef int i, a, b, c + cdef int location_array = location * 3 # loop over all four neighbors for i in range(4): # get neighbor location and value neighbor_location = self.neighbor[location * 4 + i] - temp_group = self.board_groups[neighbor_location] - board_value = temp_group.colour + temp_group = self.board_groups[neighbor_location] + board_value = temp_group.colour # check if neighbor is friendly stone if board_value == _EMPTY: @@ -1017,7 +910,6 @@ cdef class GameState: locations[neighbor_location] = _LIBERTY - # remove location as liberty locations[location] = _STONE @@ -1042,12 +934,8 @@ cdef class GameState: liberty[0] = b capture[0] = c - - @cython.boundscheck(False) - @cython.wraparound(False) cdef bint is_true_eye(self, short location, Locations_List* eyes, char owner): - """ - check if location is a real eye + """Check if location is a real eye """ cdef int i @@ -1063,15 +951,15 @@ cdef class GameState: # check if it is a known eye for i in range(eyes.count): - if location == eyes.locations[ i ]: + if location == eyes.locations[i]: return 1 # loop over neighbor for i in range(4): - location_neighbor = self.neighbor3x3[ location * 8 + i ] - board_value = self.board_groups[ location_neighbor ].colour + location_neighbor = self.neighbor3x3[location * 8 + i] + board_value = self.board_groups[location_neighbor].colour if board_value == _BORDER: @@ -1086,13 +974,12 @@ cdef class GameState: # loop over diagonals for i in range(4, 8): - location_neighbor = self.neighbor3x3[ location * 8 + i ] - board_value = self.board_groups[ location_neighbor ].colour + location_neighbor = self.neighbor3x3[location * 8 + i] + board_value = self.board_groups[location_neighbor].colour if board_value == _EMPTY: - - #locations_list_add_location(empty_diag, location_neighbor) - empty_diag.locations[ empty_diag.count ] = location_neighbor + # locations_list_add_location(empty_diag, location_neighbor) + empty_diag.locations[empty_diag.count] = location_neighbor empty_diag.count += 1 count_bad_diagonal += 1 elif board_value == _BORDER: @@ -1105,8 +992,8 @@ cdef class GameState: # assume location is an eye locations_list_add_location_increment(eyes, location) - #eyes.locations[ eyes.count ] = location - #eyes.count += 1 + # eyes.locations[eyes.count] = location + # eyes.count += 1 max_bad_diagonal = 1 if count_border == 0 else 0 @@ -1118,7 +1005,7 @@ cdef class GameState: for i in range(empty_diag.count): - location_neighbor = empty_diag.locations[ i ] + location_neighbor = empty_diag.locations[i] if self.is_true_eye(location_neighbor, eyes, owner): @@ -1134,7 +1021,6 @@ cdef class GameState: eyes.count = eyes_lenght return 0 - ############################################################################ # private cdef Ladder functions # # # @@ -1165,12 +1051,8 @@ cdef class GameState: TODO self.player colour is used, should become a pointer """ - - @cython.boundscheck(False) - @cython.wraparound(False) cdef Groups_List* add_ladder_move(self, short location, Group **board, short* ko): - """ - create a new group for location move and add all connected groups to it + """Create a new group for location move and add all connected groups to it similar to add_to_group except no groups are changed or killed and a list with groups removed is returned so the board can be restored to original @@ -1180,24 +1062,20 @@ cdef class GameState: # create Group_List able to hold up to 4 changed/removed groups cdef Groups_List* removed_groups = groups_list_new(4) - # ko is a pointer -> add [ 0 ] to acces the actual value - ko[ 0 ] = _PASS + # ko is a pointer -> add [0] to acces the actual value + ko[0] = _PASS # play move at location and add removed groups to removed_groups list self.get_removed_groups(location, removed_groups, board, ko) # change player colour - self.player_current = self.player_opponent + self.player_current = self.player_opponent self.player_opponent = (_BLACK if self.player_current == _WHITE else _WHITE) return removed_groups - - @cython.boundscheck(False) - @cython.wraparound(False) cdef void remove_ladder_group(self, Group* group_remove, Group **board, short* ko): - """ - remove group from board -> set all locations to group_empty + """Remove group from board -> set all locations to group_empty does not update zobrist hash """ @@ -1210,106 +1088,89 @@ cdef class GameState: # if groupsize == 1, possible ko if group_remove.count_stones == 1: - ko[ 0 ] = group_location_stone(group_remove, self.board_size) + ko[0] = group_location_stone(group_remove, self.board_size) # loop over all group stone locations for location in range(self.board_size): - if group_remove.locations[ location ] == _STONE: + if group_remove.locations[location] == _STONE: # set location to empty group - board[ location ] = self.group_empty + board[location] = group_empty # update liberty of neighbors # loop over all four neighbors for i in range(4): # get neighbor location - neighbor_location = self.neighbor[ location * 4 + i ] + neighbor_location = self.neighbor[location * 4 + i] # only current_player groups can be next to a killed group # check if there is a group - board_value = board[ neighbor_location ].colour + board_value = board[neighbor_location].colour if board_value == self.player_current: # add liberty - group_temp = board[ neighbor_location ] + group_temp = board[neighbor_location] group_add_liberty(group_temp, location) - - @cython.boundscheck(False) - @cython.wraparound(False) - cdef void undo_ladder_move(self, short location, Groups_List* removed_groups, short removed_ko, Group **board, short* ko): - """ - Use removed_groups list to return board state to be the same as before + cdef void undo_ladder_move(self, short location, Groups_List* removed_groups, short removed_ko, Group **board, short* ko): # noqa: E501 + """Use removed_groups list to return board state to be the same as before add_ladder_move was used """ cdef short i, b, location_neighbor cdef Group* group - cdef Group* group_remove = board[ location ] + cdef Group* group_remove = board[location] # reset ko to old value - # ko is a pointer -> add [ 0 ] to acces the actual value - ko[ 0 ] = removed_ko + # ko is a pointer -> add [0] to acces the actual value + ko[0] = removed_ko # change player colour - self.player_current = self.player_opponent + self.player_current = self.player_opponent self.player_opponent = (_BLACK if self.player_current == _WHITE else _WHITE) # undo move set location to empty group - board[ location ] = self.group_empty + board[location] = group_empty # undo group removals for i in range(removed_groups.count_groups): # do group unremovals in reversed order!!! # this is important in order to get correct liberty counts - group = removed_groups.board_groups[ removed_groups.count_groups - i - 1 ] + group = removed_groups.board_groups[removed_groups.count_groups - i - 1] # check group colour and determine what happened # player_current -> groups have been combined, set board locations to group # player_opponent -> groups have been removed, unremove them if group.colour == self.player_opponent: - # opponent group was removed from the board -> unremove it self.unremove_group(group, board) else: - - # set all board_groups locations to group - # liberty have not been changed - for b in range(self.board_size): - - if group.locations[ b ] == _STONE: - - board[ b ] = group + # set all board_groups locations to group + # liberty have not been changed + for b in range(self.board_size): + if group.locations[b] == _STONE: + board[b] = group # add liberty to neighbor groups for i in range(4): - - location_neighbor = self.neighbor[ location * 4 + i ] - if board[ location_neighbor ].colour > _EMPTY: - - group_add_liberty(board[ location_neighbor ], location) + location_neighbor = self.neighbor[location * 4 + i] + if board[location_neighbor].colour > _EMPTY: + group_add_liberty(board[location_neighbor], location) # destroy group group_destroy(group_remove) # free removed_groups if removed_groups is not NULL: - if removed_groups.board_groups is not NULL: - free(removed_groups.board_groups) - free(removed_groups) - - @cython.boundscheck(False) - @cython.wraparound(False) cdef void unremove_group(self, Group* group_unremove, Group **board): - """ - unremove group from board + """Unremove group from board loop over all stones in this group and set board to group_unremove remove liberty from neigbor locations """ @@ -1323,31 +1184,27 @@ cdef class GameState: for location in range(self.board_size): # check if this has a stone on location - if group_unremove.locations[ location ] == _STONE: + if group_unremove.locations[location] == _STONE: # set location to group_unremove - board[ location ] = group_unremove + board[location] = group_unremove # update liberty of neighbors # loop over all four neighbors for i in range(4): # get neighbor location - neighbor_location = self.neighbor[ location * 4 + i ] + neighbor_location = self.neighbor[location * 4 + i] # only current_player groups can be next to a killed group # check if neighbor_location does not belong to this group - if group_unremove.locations[ neighbor_location ] != _STONE: + if group_unremove.locations[neighbor_location] != _STONE: # remove liberty - group_remove_liberty(board[ neighbor_location ], location) + group_remove_liberty(board[neighbor_location], location) - - @cython.boundscheck(False) - @cython.wraparound(False) cdef dict get_capture_moves(self, Group* group, char color, Group **board): - """ - create a dict with al moves that capture a group surrounding group + """Create a dict with al moves that capture a group surrounding group """ cdef int i, location, location_neighbor, location_array @@ -1357,7 +1214,7 @@ cdef class GameState: # find all moves capturing an enemy group for location in range(self.board_size): - if group.locations[ location ] == _STONE: + if group.locations[location] == _STONE: # calculate array location location_array = location * 4 @@ -1366,29 +1223,26 @@ cdef class GameState: for i in range(4): # calculate neighbor location - location_neighbor = self.neighbor[ location_array + i ] + location_neighbor = self.neighbor[location_array + i] # if location has opponent stone - if board[ location_neighbor ].colour == color: + if board[location_neighbor].colour == color: # get opponent group - group_neighbor = board[ location_neighbor ] + group_neighbor = board[location_neighbor] # if liberty count == 1 if group_neighbor.count_liberty == 1: # add potential capture move - location_neighbor = group_location_liberty(group_neighbor, self.board_size) - capture[ location_neighbor ] = location_neighbor + location_neighbor = group_location_liberty(group_neighbor, + self.board_size) + capture[location_neighbor] = location_neighbor return capture - - @cython.boundscheck(False) - @cython.wraparound(False) - cdef void get_removed_groups(self, short location, Groups_List* removed_groups, Group **board, short* ko): - """ - create a new group for location move and add all connected groups to it + cdef void get_removed_groups(self, short location, Groups_List* removed_groups, Group **board, short* ko): # noqa: E501 + """Create a new group for location move and add all connected groups to it similar to add_to_group except no groups are changed or killed all changes to the board are stored in removed_groups @@ -1406,14 +1260,14 @@ cdef class GameState: for i in range(4): # get neighbor location and value - neighborLocation = self.neighbor[ location * 4 + i ] - boardValue = board[ neighborLocation ].colour + neighborLocation = self.neighbor[location * 4 + i] + boardValue = board[neighborLocation].colour # check if neighbor is friendly stone if boardValue == self.player_current: # another friendly group, if they are different combine them - tempGroup = board[ neighborLocation ] + tempGroup = board[neighborLocation] if tempGroup != newGroup: self.combine_groups(newGroup, tempGroup, board) @@ -1423,7 +1277,7 @@ cdef class GameState: elif boardValue == self.player_opponent: # remove liberty from enemy group - tempGroup = board[ neighborLocation ] + tempGroup = board[neighborLocation] group_remove_liberty(tempGroup, location) # remove group @@ -1443,16 +1297,16 @@ cdef class GameState: group_add_stone(newGroup, location) # set location to newGroup - board[ location ] = newGroup + board[location] = newGroup # loop over all four neighbors for i in range(4): # get neighbor location - neighborLocation = self.neighbor[ location * 4 + i ] + neighborLocation = self.neighbor[location * 4 + i] # check is neighbor is empty, add liberty if so - if board[ neighborLocation ].colour == _EMPTY: + if board[neighborLocation].colour == _EMPTY: group_add_liberty(newGroup, neighborLocation) @@ -1460,14 +1314,10 @@ cdef class GameState: # if two groups died there is no ko # if newGroup has more than 1 stone there is no ko if group_removed >= 2 or newGroup.count_stones > 1: - ko[ 0 ] = _PASS + ko[0] = _PASS - - @cython.boundscheck(False) - @cython.wraparound(False) - cdef bint is_ladder_escape_move(self, Group **board, short* ko, Locations_List *list_ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase): - """ - play a ladder move on location, check if group has escaped, + cdef bint is_ladder_escape_move(self, Group **board, short* ko, Locations_List *list_ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase): # noqa: E501 + """Play a ladder move on location, check if group has escaped, if the group has 2 liberty it is undetermined -> try to capture it by playing at both liberty """ @@ -1487,12 +1337,12 @@ cdef class GameState: return 0 # check if move is legal - if not self.is_legal_move(location, board, ko[ 0 ]): + if not self.is_legal_move(location, board, ko[0]): return 0 # do ladder move and save ko location - ko_value = ko[ 0 ] + ko_value = ko[0] removed_groups = self.add_ladder_move(location, board, ko) # check if it is a possible ko move @@ -1508,7 +1358,7 @@ cdef class GameState: return 0 # check group liberty - group = board[ location_group ] + group = board[location_group] i = group.count_liberty if i < 2: @@ -1528,34 +1378,36 @@ cdef class GameState: # find all moves capturing an enemy group for location_stone in range(self.board_size): - if group.locations[ location_stone ] == _STONE: + if group.locations[location_stone] == _STONE: # loop over neighbor for i in range(4): # calculate neighbor location - location_neighbor = self.neighbor[ location_stone * 4 + i ] + location_neighbor = self.neighbor[location_stone * 4 + i] # if location has opponent stone - if board[ location_neighbor ].colour == colour_chase: + if board[location_neighbor].colour == colour_chase: # get opponent group - group_capture = board[ location_neighbor ] + group_capture = board[location_neighbor] # if liberty count == 1 if group_capture.count_liberty == 1: # add potential capture move - location_neighbor = group_location_liberty(group_capture, self.board_size) - capture[ location_neighbor ] = location_neighbor + location_neighbor = group_location_liberty(group_capture, + self.board_size) + capture[location_neighbor] = location_neighbor # try to catch group by playing at one of the two liberty locations for location_neighbor in range(self.board_size): - if group.locations[ location_neighbor ] == _LIBERTY: - - if self.is_ladder_capture_move(board, ko, list_ko, location_group, capture.copy(), location_neighbor, maxDepth - 1, colour_group, colour_chase): + if group.locations[location_neighbor] == _LIBERTY: + if self.is_ladder_capture_move(board, ko, list_ko, location_group, + capture.copy(), location_neighbor, maxDepth - 1, + colour_group, colour_chase): # undo move self.undo_ladder_move(location, removed_groups, ko_value, board, ko) list_ko.count = ko_count @@ -1571,12 +1423,8 @@ cdef class GameState: # return result return result - - @cython.boundscheck(False) - @cython.wraparound(False) - cdef bint is_ladder_capture_move(self, Group **board, short* ko, Locations_List *list_ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase): - """ - play a ladder move on location, try capture and escape moves + cdef bint is_ladder_capture_move(self, Group **board, short* ko, Locations_List *list_ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase): # noqa: E501 + """Play a ladder move on location, try capture and escape moves and see if the group is able to escape ladder """ @@ -1593,11 +1441,11 @@ cdef class GameState: return 1 - if not self.is_legal_move(location, board, ko[ 0 ]): + if not self.is_legal_move(location, board, ko[0]): return 0 - ko_value = ko[ 0 ] + ko_value = ko[0] removed_groups = self.add_ladder_move(location, board, ko) # check if it is a possible ko move @@ -1613,35 +1461,37 @@ cdef class GameState: return 1 # check if the group at location can be captured - group = board[ location ] + group = board[location] if group.count_liberty == 1: i = group_location_liberty(group, self.board_size) - capture[ i ] = i + capture[i] = i # try a capture move for location_next in capture: capture_copy = capture.copy() capture_copy.pop(location_next) - if self.is_ladder_escape_move(board, ko, list_ko, location_group, capture.copy(), location_next, maxDepth - 1, colour_group, colour_chase): - + if self.is_ladder_escape_move(board, ko, list_ko, location_group, capture.copy(), + location_next, maxDepth - 1, colour_group, colour_chase): # undo move self.undo_ladder_move(location, removed_groups, ko_value, board, ko) list_ko.count = ko_count return 0 - group = board[ location_group ] + group = board[location_group] # try an escape move for location_next in range(self.board_size): - if group.locations[ location_next ] == _LIBERTY: + if group.locations[location_next] == _LIBERTY: capture_copy = capture.copy() if location_next in capture_copy: capture_copy.pop(location_next) - if self.is_ladder_escape_move(board, ko, list_ko, location_group, capture.copy(), location_next, maxDepth - 1, colour_group, colour_chase): + if self.is_ladder_escape_move(board, ko, list_ko, location_group, capture.copy(), + location_next, maxDepth - 1, colour_group, + colour_chase): # undo move self.undo_ladder_move(location, removed_groups, ko_value, board, ko) @@ -1654,18 +1504,13 @@ cdef class GameState: list_ko.count = ko_count return 1 - ############################################################################ # public cdef functions used by preprocessing # # # ############################################################################ - - @cython.boundscheck(False) - @cython.wraparound(False) cdef char* get_groups_after(self): - """ - return a short array of size board_size * 3 representing + """Return a short array of size board_size * 3 representing STONES, LIBERTY, CAPTURE for every board location max count values are 100 @@ -1681,15 +1526,15 @@ cdef class GameState: if not groups_after: raise MemoryError() - #memset(groups_after, 0, self.board_size * 3 * sizeof(char)) + # memset(groups_after, 0, self.board_size * 3 * sizeof(char)) # create locations dictionary - cdef char *locations = malloc(self.board_size * sizeof(char)) + cdef char *locations = malloc(self.board_size * sizeof(char)) if not locations: raise MemoryError() # create captures dictionary - cdef char *captures = malloc(self.board_size * sizeof(char)) + cdef char *captures = malloc(self.board_size * sizeof(char)) if not captures: raise MemoryError() @@ -1698,60 +1543,49 @@ cdef class GameState: # initialize both dictionaries to _FREE memset(locations, _FREE, self.board_size * sizeof(char)) - memset(captures, _FREE, self.board_size * sizeof(char)) + memset(captures, _FREE, self.board_size * sizeof(char)) - self.get_group_after(groups_after, locations, captures, self.moves_legal.locations[ location ]) + self.get_group_after(groups_after, locations, captures, + self.moves_legal.locations[location]) free(locations) free(captures) return groups_after - - @cython.boundscheck(False) - @cython.wraparound(False) cdef long get_hash_12d(self, short centre): - """ - return hash for 12d star pattern around location + """Return hash for 12d star pattern around location """ # generate 12d hash value and add current player colour - return ( self.generate_12d_hash(centre) + self.player_current ) * _HASHVALUE + return (self.generate_12d_hash(centre) + self.player_current) * _HASHVALUE - - @cython.boundscheck(False) - @cython.wraparound(False) cdef long get_hash_3x3(self, short location): - """ - return 3x3 pattern hash + current player + """Return 3x3 pattern hash + current player """ # generate 3x3 hash value and add current player colour - return self.generate_3x3_hash( location ) + self.player_current + return self.generate_3x3_hash(location) + self.player_current - - @cython.boundscheck(False) - @cython.wraparound(False) cdef char* get_ladder_escapes(self, int maxDepth): - """ - return char array with size board_size + """Return char array with size board_size every location represents a location on the board where: - _FREE = no ladder escape + _FREE = no ladder escape _STONE = ladder escape """ - cdef short i, location_group, location_move - cdef Group* group - cdef dict move_capture - cdef dict move_capture_copy - cdef Group **board = NULL - cdef short ko = self.ko - cdef Locations_List *list_ko + cdef short i, location_group, location_move + cdef Group* group + cdef dict move_capture + cdef dict move_capture_copy + cdef Group** board = NULL + cdef short ko = self.ko + cdef Locations_List* list_ko # create char array representing the board - cdef char* escapes = malloc(self.board_size) + cdef char* escapes = malloc(self.board_size) if not escapes: raise MemoryError() # set all locations to _FREE @@ -1763,7 +1597,7 @@ cdef class GameState: # loop over all groups on board for i in range(self.groups_list.count_groups): - group = self.groups_list.board_groups[ i ] + group = self.groups_list.board_groups[i] # get liberty count if group.count_liberty == 1: @@ -1793,25 +1627,31 @@ cdef class GameState: # check if any of the moves is an escape move for location_move in range(self.board_size): - if group.locations[ location_move ] == _LIBERTY and escapes[ location_move ] == _FREE: - + if group.locations[location_move] == _LIBERTY and \ + escapes[location_move] == _FREE: # check if group can escape ladder by playing move - if self.is_ladder_escape_move(board, &ko, list_ko, location_group, move_capture.copy(), location_move, maxDepth, self.player_current, self.player_opponent): + if self.is_ladder_escape_move(board, &ko, list_ko, location_group, + move_capture.copy(), location_move, + maxDepth, self.player_current, + self.player_opponent): - escapes[ location_move ] = _STONE + escapes[location_move] = _STONE # check if any of the capture moves is an escape move for location_move in move_capture: - if escapes[ location_move ] == _FREE: + if escapes[location_move] == _FREE: move_capture_copy = move_capture.copy() move_capture_copy.pop(location_move) # check if group can escape ladder by playing capture move - if self.is_ladder_escape_move(board, &ko, list_ko, location_group, move_capture_copy, location_move, maxDepth, self.player_current, self.player_opponent): + if self.is_ladder_escape_move(board, &ko, list_ko, location_group, + move_capture_copy, location_move, + maxDepth, self.player_current, + self.player_opponent): - escapes[ location_move ] = _STONE + escapes[location_move] = _STONE # free temporary board if board is not NULL: @@ -1822,26 +1662,22 @@ cdef class GameState: return escapes - - @cython.boundscheck(False) - @cython.wraparound(False) cdef char* get_ladder_captures(self, int maxDepth): - """ - return char array with size board_size + """Return char array with size board_size every location represents a location on the board where: - _FREE = no ladder capture + _FREE = no ladder capture _STONE = ladder capture """ - cdef short i, location_group, location_move - cdef Group* group - cdef dict move_capture - cdef Group **board = NULL - cdef short ko = self.ko - cdef Locations_List *list_ko + cdef short i, location_group, location_move + cdef Group* group + cdef dict move_capture + cdef Group** board = NULL + cdef short ko = self.ko + cdef Locations_List* list_ko # create char array representing the board - cdef char* captures = malloc(self.board_size) + cdef char* captures = malloc(self.board_size) if not captures: raise MemoryError() # set all locations to _FREE @@ -1853,7 +1689,7 @@ cdef class GameState: # loop over all groups on board for i in range(self.groups_list.count_groups): - group = self.groups_list.board_groups[ i ] + group = self.groups_list.board_groups[i] # get liberty count if group.count_liberty == 2: @@ -1883,12 +1719,14 @@ cdef class GameState: # loop over all liberty for location_move in range(self.board_size): - if group.locations[ location_move ] == _LIBERTY and captures[ location_move ] == _FREE: - + if group.locations[location_move] == _LIBERTY and \ + captures[location_move] == _FREE: # check if move is ladder capture - if self.is_ladder_capture_move(board, &ko, list_ko, location_group, move_capture.copy(), location_move, maxDepth, self.player_opponent, self.player_current): - - captures[ location_move ] = _STONE + if self.is_ladder_capture_move(board, &ko, list_ko, location_group, + move_capture.copy(), location_move, + maxDepth, self.player_opponent, + self.player_current): + captures[location_move] = _STONE # free temporary board if board is not NULL: @@ -1899,18 +1737,13 @@ cdef class GameState: return captures - ############################################################################ # public cdef functions used for game play # # # ############################################################################ - - @cython.boundscheck(False) - @cython.wraparound(False) cdef void add_move(self, short location): - """ - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + """!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! Move should be legal! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! @@ -1925,7 +1758,8 @@ cdef class GameState: # detemine where captures should be added, black captures -> white stones # white captures -> black stones # (probably better to think of it as black stones captured, and white stones captured) - cdef short* captures = (&self.capture_white if (self.player_current == _BLACK) else &self.capture_black) + cdef short* captures = &self.capture_white if (self.player_current == _BLACK) else \ + &self.capture_black # add move to board self.add_to_group(location, self.board_groups, &self.ko, captures) @@ -1943,29 +1777,20 @@ cdef class GameState: # set moves_legal self.set_moves_legal_list(self.moves_legal) - - - @cython.boundscheck(False) - @cython.wraparound(False) cdef GameState new_state_add_move(self, short location): - """ - copy this gamestate and play move at location + """Copy this gamestate and play move at location """ # create new gamestate, copy all data of self - state = GameState(copyState = self) + state = GameState(copy=self) # do move state.add_move(location) return state - - @cython.boundscheck(False) - @cython.wraparound(False) cdef float get_score(self, float komi): - """ - Calculate score of board state. Uses 'Area scoring'. + """Calculate score of board state. Uses 'Area scoring'. http://senseis.xmp.net/?Passing#1 @@ -1986,7 +1811,7 @@ cdef class GameState: for location in range(self.board_size): # get location colour - board_value = self.board_groups[ location ].colour + board_value = self.board_groups[location].colour if board_value == _WHITE: @@ -2017,12 +1842,8 @@ cdef class GameState: return score - - @cython.boundscheck(False) - @cython.wraparound(False) cdef char get_winner_colour(self, float komi): - """ - Calculate score of board state and return player ID (1, -1, or 0 for tie) + """Calculate score of board state and return player ID (1, -1, or 0 for tie) corresponding to winner. Uses 'Area scoring'. http://senseis.xmp.net/?Passing#1 @@ -2041,31 +1862,23 @@ cdef class GameState: # white wins return _WHITE - ############################################################################ # public def functions used for game play (Python) # # # ############################################################################ - - @cython.boundscheck(False) - @cython.wraparound(False) def do_move(self, action, color=None): - """ - Play stone at action=(x,y). + """Play stone at action=(x,y). If it is a legal move, current_player switches to the opposite color If not, an IllegalMove exception is raised """ if action is _PASS: - locations_list_add_location_increment(self.moves_history, _PASS) if self.player_opponent == _BLACK: - self.passes_black += 1 else: - self.passes_white += 1 # change player colour @@ -2078,7 +1891,6 @@ cdef class GameState: return if color is not None: - self.player_current = color self.player_opponent = (_BLACK if self.player_current == _WHITE else _WHITE) @@ -2092,7 +1904,6 @@ cdef class GameState: # check if move is legal if not self.is_legal_move_superko(location, self.board_groups, self.ko): - raise IllegalMove(str(action)) # add move @@ -2100,12 +1911,8 @@ cdef class GameState: return True - - @cython.boundscheck(False) - @cython.wraparound(False) - def get_legal_moves(self, include_eyes = True): - """ - return a list with all legal moves (in/excluding eyes) + def get_legal_moves(self, include_eyes=True): + """Return a list with all legal moves (in/excluding eyes) """ cdef int i @@ -2113,41 +1920,28 @@ cdef class GameState: cdef Locations_List* moves_list if include_eyes: - moves_list = self.moves_legal else: - moves_list = self.get_sensible_moves() - for i in range(moves_list.count): - - moves.append(self.calculate_tuple_location(moves_list.locations[ i ])) + moves.append(self.calculate_tuple_location(moves_list.locations[i])) if not include_eyes: - # free sensible_moves locations_list_destroy(moves_list) return moves - - @cython.boundscheck(False) - @cython.wraparound(False) - def get_winner(self, float komi = 7.5): - """ - Calculate score of board state and return player ID (1, -1, or 0 for tie) + def get_winner(self, float komi=7.5): + """Calculate score of board state and return player ID (1, -1, or 0 for tie) corresponding to winner. Uses 'Area scoring'. """ return self.get_winner_colour(komi) - - @cython.boundscheck(False) - @cython.wraparound(False) - def get_board_count(self, float komi = 7.5): - """ - Calculate score of board state + def get_board_count(self, float komi=7.5): + """Calculate score of board state where negative value indicates white win and positive value indicates black win @@ -2155,17 +1949,13 @@ cdef class GameState: return self.get_score(komi) - - @cython.boundscheck(False) - @cython.wraparound(False) def place_handicap_stone(self, action, color=_BLACK): - """ - add handicap stones given by a list of tuples in list handicap + """Add handicap stones given by a list of tuples in list handicap """ cdef short fake_capture - self.player_current = color + self.player_current = color self.player_opponent = (_BLACK if self.player_current == _WHITE else _WHITE) cdef char x, y @@ -2179,12 +1969,8 @@ cdef class GameState: # set legal moves self.set_moves_legal_list(self.moves_legal) - - @cython.boundscheck(False) - @cython.wraparound(False) def place_handicaps(self, list handicap): - """ - TODO save handicap stones list?? -> seems not usefull as we also have to copy them + """Todo save handicap stones list?? -> seems not usefull as we also have to copy them add handicap stones given by a list of tuples in list handicap """ @@ -2202,33 +1988,25 @@ cdef class GameState: self.add_to_group(location, self.board_groups, &self.ko, &fake_capture) # active player colour reverses - self.player_current = _WHITE + self.player_current = _WHITE self.player_opponent = _BLACK # set legal moves self.set_moves_legal_list(self.moves_legal) - - @cython.boundscheck(False) - @cython.wraparound(False) def is_end_of_game(self): """ """ if self.moves_history.count > 1: - - if self.moves_history.locations[ self.moves_history.count - 1 ] == _PASS and self.moves_history.locations[ self.moves_history.count - 2 ] == _PASS and self.player_current == _WHITE: - + if self.moves_history.locations[self.moves_history.count - 1] == _PASS and \ + self.moves_history.locations[self.moves_history.count - 2] == _PASS and \ + self.player_current == _WHITE: return True - return False - - @cython.boundscheck(False) - @cython.wraparound(False) def is_legal(self, action): - """ - determine if the given action (x,y tuple) is a legal move + """Determine if the given action (x,y tuple) is a legal move """ cdef int i @@ -2244,62 +2022,45 @@ cdef class GameState: location = self.calculate_board_location(y, x) if self.is_legal_move_superko(location, self.board_groups, self.ko): - return True return False - - @cython.boundscheck(False) - @cython.wraparound(False) def copy(self): - """ - get a copy of this Game state + """Get a copy of this Game state """ - return GameState(copyState = self) - + return GameState(copy=self) ############################################################################ # public def functions used for unittests # # # ############################################################################ - @cython.boundscheck(False) - @cython.wraparound(False) def get_current_player(self): - """ - Returns the color of the player who will make the next move. + """Returns the color of the player who will make the next move. """ return self.player_current - - @cython.boundscheck(False) - @cython.wraparound(False) def set_current_player(self, colour): - """ - change current player colour + """Change current player colour """ - self.player_current = colour + self.player_current = colour self.player_opponent = (_BLACK if self.player_current == _WHITE else _WHITE) - - @cython.boundscheck(False) - @cython.wraparound(False) def get_history(self): - """ - return history as a list of tuples + """Return history as a list of tuples """ cdef int i cdef short location - cdef list history = [] + cdef list history = [] for i in range(self.moves_history.count): - location = self.moves_history.locations[ i ] + location = self.moves_history.locations[i] if location != _PASS: @@ -2310,40 +2071,26 @@ cdef class GameState: return history - @cython.boundscheck(False) - @cython.wraparound(False) def get_history_size(self): - """ - return history size + """Return history size """ return self.moves_history.count - @cython.boundscheck(False) - @cython.wraparound(False) def get_captures_black(self): - """ - return amount of black stones captures + """Return amount of black stones captures """ return self.capture_black - - @cython.boundscheck(False) - @cython.wraparound(False) def get_captures_white(self): - """ - return amount of white stones captured + """Return amount of white stones captured """ return self.capture_white - - @cython.boundscheck(False) - @cython.wraparound(False) def get_ko_location(self): - """ - return ko location + """Return ko location """ if self.ko == _PASS: @@ -2351,44 +2098,32 @@ cdef class GameState: return self.ko - - @cython.boundscheck(False) - @cython.wraparound(False) def is_board_equal(self, GameState state): - """ - verify that self and state board layout are the same + """Verify that self and state board layout are the same """ for x in range(self.board_size): - if self.board_groups[ x ].colour != state.board_groups[ x ].colour: + if self.board_groups[x].colour != state.board_groups[x].colour: return False return True - - @cython.boundscheck(False) - @cython.wraparound(False) def is_liberty_equal(self, GameState state): - """ - verify that self and state liberty counts are the same + """Verify that self and state liberty counts are the same """ for x in range(self.board_size): - if self.board_groups[ x ].count_liberty != state.board_groups[ x ].count_liberty: + if self.board_groups[x].count_liberty != state.board_groups[x].count_liberty: return False return True - - @cython.boundscheck(False) - @cython.wraparound(False) def is_ladder_escape(self, action): - """ - check if playing action is a ladder escape + """Check if playing action is a ladder escape """ value = False @@ -2400,7 +2135,7 @@ cdef class GameState: cdef char* escapes = self.get_ladder_escapes(80) - if escapes[ location ] != _FREE: + if escapes[location] != _FREE: value = True @@ -2409,12 +2144,8 @@ cdef class GameState: return value - - @cython.boundscheck(False) - @cython.wraparound(False) def is_ladder_capture(self, action): - """ - check if playing action is a ladder capture + """Check if playing action is a ladder capture """ value = False @@ -2426,7 +2157,7 @@ cdef class GameState: cdef char* captures = self.get_ladder_captures(80) - if captures[ location ] != _FREE: + if captures[location] != _FREE: value = True @@ -2435,12 +2166,8 @@ cdef class GameState: return value - - @cython.boundscheck(False) - @cython.wraparound(False) def is_eye(self, action, color): - """ - check if location action is a eye for player color + """Check if location action is a eye for player color """ value = False @@ -2452,7 +2179,7 @@ cdef class GameState: # checking all games in the KGS database found a max of 15eyes in one state # 25 seems a safe bet - cdef Locations_List* eyes = locations_list_new(80) + cdef Locations_List* eyes = locations_list_new(80) if self.is_true_eye(location, eyes, color): @@ -2462,12 +2189,8 @@ cdef class GameState: return value - - @cython.boundscheck(False) - @cython.wraparound(False) def get_liberty(self): - """ - get numpy array with all liberty counts + """Get numpy array with all liberty counts """ liberty = np.zeros((self.size, self.size), dtype=np.int) @@ -2482,12 +2205,8 @@ cdef class GameState: return liberty - - @cython.boundscheck(False) - @cython.wraparound(False) def get_board(self): - """ - get numpy array with board locations + """Get numpy array with board locations """ board = np.zeros((self.size, self.size), dtype=np.int) @@ -2502,44 +2221,28 @@ cdef class GameState: return board - - @cython.boundscheck(False) - @cython.wraparound(False) def get_size(self): - """ - return size + """Return size """ return self.size - - @cython.boundscheck(False) - @cython.wraparound(False) def get_handicaps(self): - """ - TODO ? + """Todo ? return list with handicap stones placed """ return [] - - @cython.boundscheck(False) - @cython.wraparound(False) def get_handicap(self): - """ - TODO ? + """Todo ? return list with handicap stones placed """ return [] - - @cython.boundscheck(False) - @cython.wraparound(False) cdef Locations_List* get_sensible_moves(self): - """ - only used for def get_legal_moves + """Only used for def get_legal_moves return a list with sensible legal moves """ @@ -2551,32 +2254,28 @@ cdef class GameState: # checking all games in the KGS database found a max of 17eyes in one state # 25 seems a safe bet - cdef Locations_List* eyes = locations_list_new(80) + cdef Locations_List* eyes = locations_list_new(80) cdef int i cdef short location for i in range(self.moves_legal.count): - location = self.moves_legal.locations[ i ] + location = self.moves_legal.locations[i] if not self.is_true_eye(location, eyes, self.player_current): # TODO find out why locations_list_add_location is 2x slower - #locations_list_add_location(sensible_moves, location) + # locations_list_add_location(sensible_moves, location) - sensible_moves.locations[ sensible_moves.count ] = location + sensible_moves.locations[sensible_moves.count] = location sensible_moves.count += 1 locations_list_destroy(eyes) return sensible_moves - - @cython.boundscheck(False) - @cython.wraparound(False) def get_print_board_layout(self): - """ - print current board state + """Print current board state """ line = "\n" @@ -2585,26 +2284,22 @@ cdef class GameState: for j in range(self.size): B = 0 - if self.board_groups[ j + i * self.size ].colour == _BLACK: + if self.board_groups[j + i * self.size].colour == _BLACK: B = 'B' - elif self.board_groups[ j + i * self.size ].colour == _WHITE: + elif self.board_groups[j + i * self.size].colour == _WHITE: B = 'W' A += str(B) + " " line += A + "\n" return line - def __repr__(self): - """ - enable python: print GameState + """Enable python: print GameState """ return self.get_print_board_layout() - def __str__(self): - """ - enable python: str(GameState) + """Enable python: str(GameState) """ return self.get_print_board_layout() From e2cba20d59079567342db30f508c4a68c462e3e2 Mon Sep 17 00:00:00 2001 From: wrongu Date: Tue, 31 Oct 2017 18:26:54 -0400 Subject: [PATCH 174/191] Flake8 and other style changes to go_data.pxd and .pyx --- AlphaGo/go_data.pxd | 50 +++--- AlphaGo/go_data.pyx | 409 ++++++++++++++++---------------------------- 2 files changed, 171 insertions(+), 288 deletions(-) diff --git a/AlphaGo/go_data.pxd b/AlphaGo/go_data.pxd index e2ecd8417..d0643e638 100644 --- a/AlphaGo/go_data.pxd +++ b/AlphaGo/go_data.pxd @@ -36,20 +36,19 @@ cdef char _HASHVALUE # # ############################################################################ -""" - a struct has the advantage of being completely C, no python wrapper so - no python overhead. - - compared to a cdef class a struct has some advantages: - - C only, no python overhead - - able to get a pointer to it - - smaller in size - - drawbacks - - have to be Malloc created and freed after use -> memory leak - - no convenient functions available - - no boundchecks -""" +# Notes on use of structs over 'extension types': +# +# A struct has the advantage of being completely C, no python wrapper so no python overhead. +# +# compared to a cdef class a struct has some advantages: +# - C only, no python overhead +# - able to get a pointer to it +# - smaller in size +# +# drawbacks +# - have to be Malloc created and freed after use -> memory leak +# - no convenient functions available +# - no boundchecks """ struct to store group stone and liberty locations @@ -62,17 +61,17 @@ cdef char _HASHVALUE if a stone is placed on a liberty location liberty_count is decremented it works as a dictionary so lookup time for a location is O(1) - looping over all stone/liberty location could be optimized by adding + looping over all stone/liberty location could be optimized by adding two lists containing stone/liberty locations TODO check if this dictionary implementation is faster on average use as a two list implementation """ cdef struct Group: - char *locations - short count_stones - short count_liberty - char colour + char* locations + short count_stones + short count_liberty + char colour """ struct to store a list of Group @@ -83,7 +82,7 @@ cdef struct Group: TODO convert to c++ list? """ cdef struct Groups_List: - Group **board_groups + Group** board_groups short count_groups short size @@ -96,9 +95,9 @@ cdef struct Groups_List: TODO convert to c++ list and/or set """ cdef struct Locations_List: - short *locations - short count - short size + short* locations + short count + short size ############################################################################ @@ -106,7 +105,7 @@ cdef struct Locations_List: # # ############################################################################ -cdef Group* group_new(char colour, short size) +cdef Group* group_new(char colour, short size) """ create new struct Group with locations #size char long initialized to FREE @@ -315,6 +314,3 @@ cdef short* get_12d_neighbors(char size) cdef unsigned long long* get_zobrist_lookup(char size) -""" - -""" diff --git a/AlphaGo/go_data.pyx b/AlphaGo/go_data.pyx index 9b2b5533a..2f4487d23 100644 --- a/AlphaGo/go_data.pyx +++ b/AlphaGo/go_data.pyx @@ -1,3 +1,7 @@ +# cython: wraparound=False +# cython: boundscheck=False +# cython: initializedcheck=False +# cython: nonecheck=False cimport cython import numpy as np cimport numpy as np @@ -28,96 +32,82 @@ from libc.string cimport memcpy, memset, memchr # value for PASS move -_PASS = -1 +_PASS = -1 # observe: stones > EMPTY # border < EMPTY # be aware you should NOT use != EMPTY as this includes border locations -_BORDER = 1 -_EMPTY = 2 -_WHITE = 3 -_BLACK = 4 +_BORDER = 1 +_EMPTY = 2 +_WHITE = 3 +_BLACK = 4 # used for group stone, liberty locations, legal move and sensible move -_FREE = 3 -_STONE = 0 +_FREE = 3 +_STONE = 0 _LIBERTY = 1 _CAPTURE = 2 -_LEGAL = 4 -_EYE = 5 +_LEGAL = 4 +_EYE = 5 # value used to generate pattern hashes _HASHVALUE = 33 ############################################################################ -# Structs # +# Structs defined in go_data.pxd # # # ############################################################################ -""" -> structs, declared in go_data.pxd - -# a struct has the advantage of being completely C, no python wrapper so -# no python overhead. -# -# compared to a cdef class a struct has some advantages: -# - C only, no python overhead -# - able to get a pointer to it -# - smaller in size -# -# drawbacks -# - have to be Malloc created and freed after use -> memory leak -# - no convenient functions available -# - no boundchecks - - -# struct to store group stone and liberty locations +""" +# struct to store group stone and liberty locations # -# locations is a char pointer array of size board_size and initialized -# to _FREE. after adding a stone/liberty that location is set to -# _STONE/_LIBERTY and count_stones/count_liberty is incremented +# locations is a char pointer array of size board_size and initialized +# to _FREE. after adding a stone/liberty that location is set to +# _STONE/_LIBERTY and count_stones/count_liberty is incremented # -# note that a stone location can never be a liberty location, -# if a stone is placed on a liberty location liberty_count is decremented +# note that a stone location can never be a liberty location, +# if a stone is placed on a liberty location liberty_count is decremented # -# it works as a dictionary so lookup time for a location is O(1) -# looping over all stone/liberty location could be optimized by adding -# two lists containing stone/liberty locations +# it works as a dictionary so lookup time for a location is O(1) +# looping over all stone/liberty location could be optimized by adding +# two lists containing stone/liberty locations # -# TODO check if this dictionary implementation is faster on average -# use as a two list implementation +# TODO check if this dictionary implementation is faster on average +# use as a two list implementation -cdef struct Group: - char *locations - short count_stones - short count_liberty - char colour +cdef struct Group: + char* locations + short count_stones + short count_liberty + char colour -# struct to store a list of Group +# struct to store a list of Group # -# board_groups is a Group pointer array of size #size and containing -# #count_groups groups +# board_groups is a Group pointer array of size #size and containing +# #count_groups groups # -# TODO convert to c++ list? +# TODO convert to c++ list? + cdef struct Groups_List: - Group **board_groups + Group** board_groups short count_groups short size - -# struct to store a list of short (board locations) +# struct to store a list of short (board locations) +# +# locations is a short pointer array of size #size and containing +# #count locations # -# locations is a short pointer array of size #size and containing -# #count locations +# TODO convert to c++ list and/or set - TODO convert to c++ list and/or set cdef struct Locations_List: - short *locations - short count - short size + short* locations + short count + short size """ ############################################################################ @@ -126,12 +116,8 @@ cdef struct Locations_List: ############################################################################ -@cython.boundscheck(False) -@cython.wraparound(False) cdef Group* group_new(char colour, short size): - """ - create new struct Group - with locations #size char long initialized to FREE + """Create new struct Group with locations set to FREE """ cdef int i @@ -142,14 +128,14 @@ cdef Group* group_new(char colour, short size): raise MemoryError() # allocate memory for array locations - group.locations = malloc(size) + group.locations = malloc(size) if not group.locations: raise MemoryError() # set counts to 0 and colour to colour - group.count_stones = 0 + group.count_stones = 0 group.count_liberty = 0 - group.colour = colour + group.colour = colour # initialize locations with FREE memset(group.locations, _FREE, size) @@ -157,11 +143,8 @@ cdef Group* group_new(char colour, short size): return group -@cython.boundscheck(False) -@cython.wraparound(False) cdef Group* group_duplicate(Group* group, short size): - """ - create new struct Group initialized as a duplicate of group + """Create new struct Group initialized as a duplicate of group """ cdef int i @@ -172,14 +155,14 @@ cdef Group* group_duplicate(Group* group, short size): raise MemoryError() # allocate memory for array locations - duplicate.locations = malloc(size) + duplicate.locations = malloc(size) if not duplicate.locations: raise MemoryError() # set counts and colour values - duplicate.count_stones = group.count_stones + duplicate.count_stones = group.count_stones duplicate.count_liberty = group.count_liberty - duplicate.colour = group.colour + duplicate.colour = group.colour # duplicate locations array in memory # memcpy is optimized to do this quickly @@ -188,72 +171,56 @@ cdef Group* group_duplicate(Group* group, short size): return duplicate -@cython.boundscheck(False) -@cython.wraparound(False) cdef void group_destroy(Group* group): - """ - free memory location of group and locations + """Free memory location of group and locations """ # check if group exists if group is not NULL: - # check if locations exists if group.locations is not NULL: - # free locations free(group.locations) - # free group free(group) -@cython.boundscheck(False) -@cython.wraparound(False) cdef void group_add_stone(Group* group, short location): - """ - update location as STONE - update liberty count if it was a liberty location + """Update location as STONE - n.b. stone count is not incremented if a stone was present already + update liberty count if it was a liberty location + n.b. stone count is not incremented if a stone was present already """ # check if locations is a liberty - if group.locations[ location ] == _FREE: - + if group.locations[location] == _FREE: # locations is FREE, increment stone count group.count_stones += 1 - elif group.locations[ location ] == _LIBERTY: + elif group.locations[location] == _LIBERTY: # locations is LIBERTY, increment stone count and decrement liberty count - group.count_stones += 1 + group.count_stones += 1 group.count_liberty -= 1 # set STONE - group.locations[ location ] = _STONE + group.locations[location] = _STONE -@cython.boundscheck(False) -@cython.wraparound(False) cdef void group_remove_stone(Group* group, short location): - """ - update location as FREE - update stone count if it was a stone location + """Update location as FREE + + update stone count if it was a stone location """ # check if a stone is present - if group.locations[ location ] == _STONE: - + if group.locations[location] == _STONE: # stone present, decrement stone count and set location to FREE group.count_stones -= 1 - group.locations[ location ] = _FREE + group.locations[location] = _FREE -@cython.boundscheck(False) -@cython.wraparound(False) cdef short group_location_stone(Group* group, short size): - """ - return first location where a STONE is located + """Return first location where a STONE is located """ # memchr is a in memory search function, it starts searching at @@ -264,47 +231,36 @@ cdef short group_location_stone(Group* group, short size): return (memchr(group.locations, _STONE, size) - group.locations) -@cython.boundscheck(False) -@cython.wraparound(False) cdef void group_add_liberty(Group* group, short location): - """ - update location as LIBERTY - update liberty count if it was a FREE location + """Update location as LIBERTY - n.b. liberty count is not incremented if a stone was present already + update liberty count if it was a FREE location + n.b. liberty count is not incremented if a stone was present already """ # check if location is FREE - if group.locations[ location ] == _FREE: - + if group.locations[location] == _FREE: # increment liberty count, set location to LIBERTY group.count_liberty += 1 - group.locations[ location ] = _LIBERTY + group.locations[location] = _LIBERTY -@cython.boundscheck(False) -@cython.wraparound(False) cdef void group_remove_liberty(Group* group, short location): - """ - update location as FREE - update liberty count if it was a LIBERTY location + """Update location as FREE - n.b. liberty count is not decremented if location is a FREE location + update liberty count if it was a LIBERTY location + n.b. liberty count is not decremented if location is a FREE location """ # check if location is LIBERTY - if group.locations[ location ] == _LIBERTY: - + if group.locations[location] == _LIBERTY: # decrement liberty count, set location to FREE group.count_liberty -= 1 - group.locations[ location ] = _FREE + group.locations[location] = _FREE -@cython.boundscheck(False) -@cython.wraparound(False) cdef short group_location_liberty(Group* group, short size): - """ - return location where a LIBERTY is located + """Return location where a LIBERTY is located """ # memchr is a in memory search function, it starts searching at @@ -321,17 +277,13 @@ cdef short group_location_liberty(Group* group, short size): ############################################################################ -@cython.boundscheck(False) -@cython.wraparound(False) cdef Groups_List* groups_list_new(short size): - """ - create new struct Groups_List - with locations #size Group* long and count_groups set to 0 + """Create new empty Groups_List """ cdef Groups_List* list_new - list_new = malloc(sizeof(Groups_List)) + list_new = malloc(sizeof(Groups_List)) if not list_new: raise MemoryError() @@ -344,64 +296,48 @@ cdef Groups_List* groups_list_new(short size): return list_new -@cython.boundscheck(False) -@cython.wraparound(False) cdef void groups_list_add(Group* group, Groups_List* groups_list): - """ - add group to list and increment groups count + """Add group to list and increment groups count """ - groups_list.board_groups[ groups_list.count_groups ] = group + groups_list.board_groups[groups_list.count_groups] = group groups_list.count_groups += 1 -@cython.boundscheck(False) -@cython.wraparound(False) cdef void groups_list_add_unique(Group* group, Groups_List* groups_list): - """ - check if a group is already in the list, return if so - add group to list if not + """Check if a group is already in the list, return if so, add group to list if not """ cdef int i # loop over array for i in range(groups_list.count_groups): - # check if group is present - if group == groups_list.board_groups[ i ]: - + if group == groups_list.board_groups[i]: # group is present, return return # group is not present, add to group - groups_list.board_groups[ groups_list.count_groups ] = group + groups_list.board_groups[groups_list.count_groups] = group groups_list.count_groups += 1 -@cython.boundscheck(False) -@cython.wraparound(False) cdef void groups_list_remove(Group* group, Groups_List* groups_list): - """ - remove group from list and decrement groups count + """Remove group from list and decrement groups count """ cdef int i # loop over array for i in range(groups_list.count_groups): - # check if group is present - if groups_list.board_groups[ i ] == group: - - # group is present, move last group to this location - # and decrement groups count + if groups_list.board_groups[i] == group: + # group is present, move last group to this location and decrement groups count groups_list.count_groups -= 1 - groups_list.board_groups[ i ] = groups_list.board_groups[ groups_list.count_groups ] + groups_list.board_groups[i] = groups_list.board_groups[groups_list.count_groups] return - # TODO this should not happen, create error for this?? - print("Group not found!!!!!!!!!!!!!!") + raise RuntimeError("Removing nonexistent group!") ############################################################################ @@ -410,18 +346,14 @@ cdef void groups_list_remove(Group* group, Groups_List* groups_list): ############################################################################ -@cython.boundscheck(False) -@cython.wraparound(False) cdef Locations_List* locations_list_new(short size): - """ - create new struct Locations_List - with locations #size short long and count set to 0 + """Create new empty Locations_List """ cdef Locations_List* list_new # allocate memory for Group - list_new = malloc(sizeof(Locations_List)) + list_new = malloc(sizeof(Locations_List)) if not list_new: raise MemoryError() @@ -431,95 +363,74 @@ cdef Locations_List* locations_list_new(short size): raise MemoryError() # set count to 0 - list_new.count = 0 + list_new.count = 0 # set size - list_new.size = size + list_new.size = size return list_new -@cython.boundscheck(False) -@cython.wraparound(False) + cdef void locations_list_destroy(Locations_List* locations_list): - """ - free memory location of locations_list and locations + """Free memory location of locations_list and locations """ # check if locations_list exists if locations_list is not NULL: - # check if locations exists if locations_list.locations is not NULL: - # free locations free(locations_list.locations) - # free locations_list free(locations_list) -@cython.boundscheck(False) -@cython.wraparound(False) + cdef void locations_list_remove_location(Locations_List* locations_list, short location): - """ - remove location from list + """Remove location from list """ cdef int i # loop over array for i in range(locations_list.count): - - # check if [ i ] == location - if locations_list.locations[ i ] == location: - + # check if [i] == location + if locations_list.locations[i] == location: # location found, move last value to this location # and decrement count locations_list.count -= 1 - locations_list.locations[ i ] = locations_list.locations[ locations_list.count ] + locations_list.locations[i] = locations_list.locations[locations_list.count] return - # TODO this should not happen, create error for this?? - print("location not found!!!!!!!!!!!!!!") + raise RuntimeError("Removing nonexistent location!") -@cython.boundscheck(False) -@cython.wraparound(False) cdef void locations_list_add_location(Locations_List* locations_list, short location): - """ - add location to list and increment count + """Add location to list and increment count """ - locations_list.locations[ locations_list.count ] = location + locations_list.locations[locations_list.count] = location locations_list.count += 1 -@cython.boundscheck(False) -@cython.wraparound(False) cdef void locations_list_add_location_increment(Locations_List* locations_list, short location): - """ - check if list can hold one more location, resize list if not + """Check if list can hold one more location, resize list if not add location to list and increment count """ if locations_list.count == locations_list.size: locations_list.size += 10 - locations_list.locations = realloc(locations_list.locations, locations_list.size * sizeof(short)) + locations_list.locations = realloc(locations_list.locations, + locations_list.size * sizeof(short)) if not locations_list.locations: - print("MEM ERROR") raise MemoryError() - - locations_list.locations[ locations_list.count ] = location + locations_list.locations[locations_list.count] = location locations_list.count += 1 -@cython.boundscheck(False) -@cython.wraparound(False) -@cython.nonecheck(False) cdef void locations_list_add_location_unique(Locations_List* locations_list, short location): - """ - check if location is present in list, return if so + """Check if location is present in list, return if so add location to list if not """ @@ -527,15 +438,13 @@ cdef void locations_list_add_location_unique(Locations_List* locations_list, sho # loop over array for i in range(locations_list.count): - # check if location is present - if location == locations_list.locations[ i ]: - + if location == locations_list.locations[i]: # location found, do nothing -> return return # add location to list and increment count - locations_list.locations[ locations_list.count ] = location + locations_list.locations[locations_list.count] = location locations_list.count += 1 @@ -545,11 +454,8 @@ cdef void locations_list_add_location_unique(Locations_List* locations_list, sho ############################################################################ -@cython.boundscheck(False) -@cython.wraparound(False) cdef short calculate_board_location(char x, char y, char size): - """ - return location on board + """Return location on board no checks on outside board x = columns y = rows @@ -559,12 +465,9 @@ cdef short calculate_board_location(char x, char y, char size): return x + (y * size) -@cython.boundscheck(False) -@cython.wraparound(False) cdef short calculate_board_location_or_border(char x, char y, char size): - """ - return location on board or borderlocation - board locations = [ 0, size * size) + """Return location on board or borderlocation + board locations = [0, size * size) border location = size * size x = columns y = rows @@ -572,7 +475,6 @@ cdef short calculate_board_location_or_border(char x, char y, char size): # check if x or y are outside board if x < 0 or y < 0 or x >= size or y >= size: - # return border location return size * size @@ -580,11 +482,8 @@ cdef short calculate_board_location_or_border(char x, char y, char size): return calculate_board_location(x, y, size) -@cython.boundscheck(False) -@cython.wraparound(False) cdef short* get_neighbors(char size): - """ - create array for every board location with all 4 direct neighbor locations + """Create array for every board location with all 4 direct neighbor locations neighbor order: left - right - above - below -1 x @@ -609,22 +508,18 @@ cdef short* get_neighbors(char size): # add all direct neighbors to every board location for y in range(size): - for x in range(size): - location = (x + (y * size)) * 4 - neighbor[ location + 0 ] = calculate_board_location_or_border(x - 1, y , size) - neighbor[ location + 1 ] = calculate_board_location_or_border(x + 1, y , size) - neighbor[ location + 2 ] = calculate_board_location_or_border(x , y - 1, size) - neighbor[ location + 3 ] = calculate_board_location_or_border(x , y + 1, size) + neighbor[location + 0] = calculate_board_location_or_border(x - 1, y, size) + neighbor[location + 1] = calculate_board_location_or_border(x + 1, y, size) + neighbor[location + 2] = calculate_board_location_or_border(x, y - 1, size) + neighbor[location + 3] = calculate_board_location_or_border(x, y + 1, size) return neighbor -@cython.boundscheck(False) -@cython.wraparound(False) + cdef short* get_3x3_neighbors(char size): - """ - create for every board location array with all 8 surrounding neighbor locations + """Create for every board location array with all 8 surrounding neighbor locations neighbor order: above middle - middle left - middle right - below middle above left - above right - below left - below right this order is more useful as it separates neighbors and then diagonals @@ -651,27 +546,24 @@ cdef short* get_3x3_neighbors(char size): # add all surrounding neighbors to every board location for x in range(size): - for y in range(size): - location = (x + (y * size)) * 8 - neighbor3x3[ location + 0 ] = calculate_board_location_or_border(x , y - 1, size) - neighbor3x3[ location + 1 ] = calculate_board_location_or_border(x - 1, y , size) - neighbor3x3[ location + 2 ] = calculate_board_location_or_border(x + 1, y , size) - neighbor3x3[ location + 3 ] = calculate_board_location_or_border(x , y + 1, size) - - neighbor3x3[ location + 4 ] = calculate_board_location_or_border(x - 1, y - 1, size) - neighbor3x3[ location + 5 ] = calculate_board_location_or_border(x + 1, y - 1, size) - neighbor3x3[ location + 6 ] = calculate_board_location_or_border(x - 1, y + 1, size) - neighbor3x3[ location + 7 ] = calculate_board_location_or_border(x + 1, y + 1, size) + # Cardinal directions + neighbor3x3[location + 0] = calculate_board_location_or_border(x, y - 1, size) + neighbor3x3[location + 1] = calculate_board_location_or_border(x - 1, y, size) + neighbor3x3[location + 2] = calculate_board_location_or_border(x + 1, y, size) + neighbor3x3[location + 3] = calculate_board_location_or_border(x, y + 1, size) + # Diagonal directions + neighbor3x3[location + 4] = calculate_board_location_or_border(x - 1, y - 1, size) + neighbor3x3[location + 5] = calculate_board_location_or_border(x + 1, y - 1, size) + neighbor3x3[location + 6] = calculate_board_location_or_border(x - 1, y + 1, size) + neighbor3x3[location + 7] = calculate_board_location_or_border(x + 1, y + 1, size) return neighbor3x3 -@cython.boundscheck(False) -@cython.wraparound(False) + cdef short* get_12d_neighbors(char size): - """ - create array for every board location with 12d star neighbor locations + """Create array for every board location with 12d star neighbor locations neighbor order: top star tip above left - above middle - above right left star tip - left - right - right star tip @@ -702,26 +594,24 @@ cdef short* get_12d_neighbors(char size): # add all 12d neighbors to every board location for x in range(size): - for y in range(size): - location = (x + (y * size)) * 12 - neighbor12d[ location + 4 ] = calculate_board_location_or_border(x , y - 2, size) + neighbor12d[location + 4] = calculate_board_location_or_border(x, y - 2, size) - neighbor12d[ location + 1 ] = calculate_board_location_or_border(x - 1, y - 1, size) - neighbor12d[ location + 5 ] = calculate_board_location_or_border(x , y - 1, size) - neighbor12d[ location + 8 ] = calculate_board_location_or_border(x + 1, y - 1, size) + neighbor12d[location + 1] = calculate_board_location_or_border(x - 1, y - 1, size) + neighbor12d[location + 5] = calculate_board_location_or_border(x, y - 1, size) + neighbor12d[location + 8] = calculate_board_location_or_border(x + 1, y - 1, size) - neighbor12d[ location + 0 ] = calculate_board_location_or_border(x - 2, y , size) - neighbor12d[ location + 2 ] = calculate_board_location_or_border(x - 1, y , size) - neighbor12d[ location + 9 ] = calculate_board_location_or_border(x + 1, y , size) - neighbor12d[ location + 11 ] = calculate_board_location_or_border(x + 2, y , size) + neighbor12d[location + 0] = calculate_board_location_or_border(x - 2, y, size) + neighbor12d[location + 2] = calculate_board_location_or_border(x - 1, y, size) + neighbor12d[location + 9] = calculate_board_location_or_border(x + 1, y, size) + neighbor12d[location + 11] = calculate_board_location_or_border(x + 2, y, size) - neighbor12d[ location + 3 ] = calculate_board_location_or_border(x - 1, y + 1, size) - neighbor12d[ location + 6 ] = calculate_board_location_or_border(x , y + 1, size) - neighbor12d[ location + 10 ] = calculate_board_location_or_border(x + 1, y + 1, size) + neighbor12d[location + 3] = calculate_board_location_or_border(x - 1, y + 1, size) + neighbor12d[location + 6] = calculate_board_location_or_border(x, y + 1, size) + neighbor12d[location + 10] = calculate_board_location_or_border(x + 1, y + 1, size) - neighbor12d[ location + 7 ] = calculate_board_location_or_border(x , y + 2, size) + neighbor12d[location + 7] = calculate_board_location_or_border(x, y + 2, size) return neighbor12d @@ -732,16 +622,13 @@ cdef short* get_12d_neighbors(char size): ############################################################################ -@cython.boundscheck(False) -@cython.wraparound(False) cdef unsigned long long* get_zobrist_lookup(char size): - """ - generate zobrist lookup array for boardsize size + """Generate zobrist lookup array for boardsize size """ cdef unsigned long long* zobrist_lookup - zobrist_lookup = malloc((size * size * 2) * sizeof(unsigned long long)) + zobrist_lookup = malloc((size * size * 2) * sizeof(unsigned long long)) if not zobrist_lookup: raise MemoryError() @@ -749,4 +636,4 @@ cdef unsigned long long* get_zobrist_lookup(char size): for i in range(size * size * 2): zobrist_lookup[i] = np.random.randint(np.iinfo(np.uint64).max, dtype='uint64') - return zobrist_lookup \ No newline at end of file + return zobrist_lookup From 89fc8db989d0898f8f3335cf6495f657410d212e Mon Sep 17 00:00:00 2001 From: wrongu Date: Tue, 31 Oct 2017 18:57:32 -0400 Subject: [PATCH 175/191] Manual merge of all preprocessing_rollout into preprocessing Preprocessing is now aware of whether 'groups_after' is necessary or not --- AlphaGo/preprocessing/preprocessing.pxd | 71 +- AlphaGo/preprocessing/preprocessing.pyx | 117 ++- .../preprocessing/preprocessing_rollout.pxd | 202 ----- .../preprocessing/preprocessing_rollout.pyx | 834 ------------------ setup.py | 3 - 5 files changed, 170 insertions(+), 1057 deletions(-) delete mode 100644 AlphaGo/preprocessing/preprocessing_rollout.pxd delete mode 100644 AlphaGo/preprocessing/preprocessing_rollout.pyx diff --git a/AlphaGo/preprocessing/preprocessing.pxd b/AlphaGo/preprocessing/preprocessing.pxd index 93f5e43ce..68f248b27 100644 --- a/AlphaGo/preprocessing/preprocessing.pxd +++ b/AlphaGo/preprocessing/preprocessing.pxd @@ -4,7 +4,7 @@ from libc.stdlib cimport malloc, free from AlphaGo.go cimport GameState from AlphaGo.go_data cimport _BLACK, _EMPTY, _STONE, _LIBERTY, _CAPTURE, \ _FREE, _PASS, Group, Locations_List, locations_list_destroy, \ - locations_list_new + locations_list_new, _HASHVALUE from numpy cimport ndarray import numpy as np cimport numpy as np @@ -28,6 +28,9 @@ cdef class Preprocess: # TODO find correct type so an array can be used cdef preprocess_method *processors + # flag whether or not any features require 'lookahead' to groups_after + cdef bint requires_groups_after + # list with all features used currently # TODO find correct type so an array can be used cdef list feature_list @@ -143,16 +146,64 @@ cdef class Preprocess: """Ko positions """ - cdef int get_response(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 - cdef int get_save_atari(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 - cdef int get_neighbor(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 - cdef int get_nakade(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 - cdef int get_nakade_offset(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 - cdef int get_response_12d(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 - cdef int get_response_12d_offset(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 - cdef int get_non_response_3x3(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 - cdef int get_non_response_3x3_offset(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + cdef int get_response(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa:E501 + """Single feature plane encoding whether this location matches any of the response + patterns, for now it only checks the 12d response patterns as we do not use the + 3x3 response patterns. + """ + + cdef int get_save_atari(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa:E501 + """A feature wrapping GameState.is_ladder_escape(). + check if player_current group can escape atari for at least one turn + """ + + cdef int get_neighbor(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa:E501 + """Encode last move neighbor positions in two planes: + - horizontal & vertical / direct neighbor + - diagonal neighbor + """ + + cdef int get_nakade(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa:E501 + """A nakade pattern is a 12d pattern on a location a stone was captured before + it is unclear if a max size of the captured group has to be considered and + how recent the capture event should have been + + the 12d pattern can be encoded without stone colour and liberty count + unclear if a border location should be considered a stone or liberty + + pattern lookup value is being set instead of 1 + """ + + cdef int get_nakade_offset(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa:E501 + """A nakade pattern is a 12d pattern on a location a stone was captured before + it is unclear if a max size of the captured group has to be considered and + how recent the capture event should have been + the 12d pattern can be encoded without stone colour and liberty count + unclear if a border location should be considered a stone or liberty + + #pattern_id is offset + """ + + cdef int get_response_12d(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa:E501 + """Set 12d hash pattern for 12d shape around last move + pattern lookup value is being set instead of 1 + """ + + cdef int get_response_12d_offset(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa:E501 + """Set 12d hash pattern for 12d shape around last move where + #pattern_id is offset + """ + + cdef int get_non_response_3x3(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa:E501 + """Set 3x3 hash pattern for every legal location where + pattern lookup value is being set instead of 1 + """ + + cdef int get_non_response_3x3_offset(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + """Set 3x3 hash pattern for every legal location where + #pattern_id is offset + """ ############################################################################ # public cdef function # # # diff --git a/AlphaGo/preprocessing/preprocessing.pyx b/AlphaGo/preprocessing/preprocessing.pyx index 2917fcac8..69283083d 100644 --- a/AlphaGo/preprocessing/preprocessing.pyx +++ b/AlphaGo/preprocessing/preprocessing.pyx @@ -297,6 +297,47 @@ cdef class Preprocess: - optimization? 12d response patterns are calculated twice.. """ + cdef short location, location_x, location_y, last_move, last_move_x, last_move_y + cdef int i, plane, id + cdef long hash_base, hash_pattern + cdef short* neighbor12d = state.neighbor12d + + # get last move + last_move = state.moves_history.locations[state.moves_history.count - 1] + + # check if last move is not _PASS + if last_move != _PASS: + # get 12d pattern hash of last move location and colour + hash_base = state.get_hash_12d(last_move) + + # calculate last_move x and y + last_move_x = last_move / state.size + last_move_y = last_move % state.size + + # last_move location in neighbor12d array + last_move *= 12 + + # loop over all locations in 12d shape + for i in range(12): + # get location + location = neighbor12d[last_move + i] + + # check if location is empty + if state.board_groups[location].colour == _EMPTY: + # calculate location x and y + location_x = (location / state.size) - last_move_x + location_y = (location % state.size) - last_move_y + + # calculate 12d response pattern hash + hash_pattern = hash_base + location_x + hash_pattern *= _HASHVALUE + hash_pattern += location_y + + # dictionary lookup + pattern_id = self.pattern_response_12d.get(hash_pattern) + if pattern_id >= 0: + tensor[offset, location] = 1 + return offset + 1 cdef int get_save_atari(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 @@ -332,7 +373,6 @@ cdef class Preprocess: # check if last move is not _PASS if last_move != _PASS: - # last_move location in neighbor3x3 array last_move *= 8 @@ -342,7 +382,6 @@ cdef class Preprocess: # loop over direct neighbor # 0,1,2,3 are direct neighbor locations for i in range(4): - # get neighbor location location = neighbor3x3[last_move + i] @@ -356,7 +395,6 @@ cdef class Preprocess: # loop over diagonal neighbor # 4,5,6,7 are diagonal neighbor locations for i in range(4, 8): - # get neighbor location location = neighbor3x3[last_move + i] @@ -404,12 +442,69 @@ cdef class Preprocess: return offset + 1 cdef int get_response_12d_offset(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 - """Set 12d hash pattern for 12d shape around last move where + """Check all empty locations in a 12d shape around the last move for being a 12d response + pattern match #pattern_id is offset + + base hash is 12d pattern hash of last move location + colour + add relative position of every empty location in a 12d shape to get 12d response pattern + hash + + c hash x y + ... location a has: state.get_hash_12d(x), -1, 0 + .ax.. location b has: state.get_hash_12d(x), +1, -1 + ..b location c has: state.get_hash_12d(x), 0, +2 + . + + 12d response pattern hash value is calculated by: + ((hash + x) * _HASHVALUE) + y """ - # get last move location - # check for pass + cdef short location, location_x, location_y, last_move, last_move_x, last_move_y + cdef int i, plane, id + cdef long hash_base, hash_pattern + cdef short* neighbor12d = state.neighbor12d + + # get last move + last_move = state.moves_history.locations[state.moves_history.count - 1] + + # check if last move is not _PASS + if last_move != _PASS: + + # get 12d pattern hash of last move location and colour + hash_base = state.get_hash_12d(last_move) + + # calculate last_move x and y + last_move_x = last_move / state.size + last_move_y = last_move % state.size + + # last_move location in neighbor12d array + last_move *= 12 + + # loop over all locations in 12d shape + for i in range(12): + + # get location + location = neighbor12d[last_move + i] + + # check if location is empty + if state.board_groups[location].colour == _EMPTY: + + # calculate location x and y + location_x = (location / state.size) - last_move_x + location_y = (location % state.size) - last_move_y + + # calculate 12d response pattern hash + hash_pattern = hash_base + location_x + hash_pattern *= _HASHVALUE + hash_pattern += location_y + + # dictionary lookup + id = self.pattern_response_12d.get(hash_pattern) + + if id >= 0: + + tensor[offset + id, location] = 1 return offset + self.pattern_response_12d_size @@ -501,6 +596,8 @@ cdef class Preprocess: # create a list with function pointers self.processors = malloc(len(feature_list) * sizeof(preprocess_method)) + self.requires_groups_after = False + if not self.processors: raise MemoryError() @@ -562,14 +659,17 @@ cdef class Preprocess: elif feat == "capture_size": processor = self.get_capture_size self.output_dim += 8 + self.requires_groups_after = True elif feat == "self_atari_size": processor = self.get_self_atari_size self.output_dim += 8 + self.requires_groups_after = True elif feat == "liberties_after": processor = self.get_liberties_after self.output_dim += 8 + self.requires_groups_after = True elif feat == "ladder_capture": processor = self.get_ladder_capture @@ -657,7 +757,7 @@ cdef class Preprocess: cdef int offset = 0 # get char array with next move information - cdef char *groups_after = state.get_groups_after() + cdef char *groups_after = state.get_groups_after() if self.requires_groups_after else NULL # loop over all processors and generate tensor for i in range(len(self.feature_list)): @@ -665,7 +765,8 @@ cdef class Preprocess: offset = proc(self, state, tensor, groups_after, offset) # free groups_after - free(groups_after) + if self.requires_groups_after: + free(groups_after) # create a singleton 'batch' dimension return np_tensor.reshape((1, self.output_dim, self.size, self.size)) diff --git a/AlphaGo/preprocessing/preprocessing_rollout.pxd b/AlphaGo/preprocessing/preprocessing_rollout.pxd deleted file mode 100644 index 374fc56c2..000000000 --- a/AlphaGo/preprocessing/preprocessing_rollout.pxd +++ /dev/null @@ -1,202 +0,0 @@ -import ast -import time -import numpy as np -cimport numpy as np -from numpy cimport ndarray -from libc.stdlib cimport malloc, free -from AlphaGo.go cimport GameState -from AlphaGo.go_data cimport _BLACK, _EMPTY, _STONE, _LIBERTY, _CAPTURE, _FREE, _PASS, _HASHVALUE, Group, Locations_List, locations_list_destroy, locations_list_new - -# type of tensor created -# char works but float might be needed later -ctypedef char tensor_type - -# type defining cdef function -ctypedef int (*preprocess_method)(Preprocess, GameState, tensor_type[ :, ::1 ], int) - - -cdef class Preprocess: - - ############################################################################ - # variables declarations # - # # - ############################################################################ - - # all feature processors - # TODO find correct type so an array can be used - cdef preprocess_method *processors - - # list with all features used currently - # TODO find correct type so an array can be used - cdef list feature_list - - # output tensor size - cdef int output_dim - - # board size - cdef char size - cdef short board_size - - # pattern dictionaries - cdef dict pattern_nakade - cdef dict pattern_response_12d - cdef dict pattern_non_response_3x3 - - # pattern dictionary sizes - cdef int pattern_nakade_size - cdef int pattern_response_12d_size - cdef int pattern_non_response_3x3_size - - ############################################################################ - # Tensor generating functions # - # # - ############################################################################ - - cdef int get_board(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) - """ - A feature encoding WHITE BLACK and EMPTY on separate planes. - plane 0 always refers to the current player stones - plane 1 to the opponent stones - plane 2 to empty locations - """ - - cdef int get_turns_since(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) - """ - A feature encoding the age of the stone at each location up to 'maximum' - - Note: - - the [maximum-1] plane is used for any stone with age greater than or equal to maximum - - EMPTY locations are all-zero features - """ - - cdef int get_liberties(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) - """ - A feature encoding the number of liberties of the group connected to the stone at - each location - - Note: - - there is no zero-liberties plane; the 0th plane indicates groups in atari - - the [maximum-1] plane is used for any stone with liberties greater than or equal to maximum - - EMPTY locations are all-zero features - """ - - cdef int get_ladder_capture(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) - """ - A feature wrapping GameState.is_ladder_capture(). - check if an opponent group can be captured in a ladder - """ - - cdef int get_ladder_escape(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) - """ - A feature wrapping GameState.is_ladder_escape(). - check if player_current group can escape ladder - """ - - cdef int get_sensibleness(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) - """ - A move is 'sensible' if it is legal and if it does not fill the current_player's own eye - """ - - cdef int get_legal(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) - """ - Zero at all illegal moves, one at all legal moves. Unlike sensibleness, no eye check is done - not used?? - """ - - cdef int zeros(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) - """ - Plane filled with zeros - """ - - cdef int ones(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) - """ - Plane filled with ones - """ - - cdef int colour(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) - """ - Value net feature, plane with ones if active_player is black else zeros - """ - - cdef int ko(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) - """ - Single plane encoding ko location - """ - - cdef int get_response(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) - """ - single feature plane encoding whether this location matches any of the response - patterns, for now it only checks the 12d response patterns as we do not use the - 3x3 response patterns. - """ - - cdef int get_save_atari(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) - """ - A feature wrapping GameState.is_ladder_escape(). - check if player_current group can escape atari for at least one turn - """ - - cdef int get_neighbor(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) - """ - encode last move neighbor positions in two planes: - - horizontal & vertical / direct neighbor - - diagonal neighbor - """ - - cdef int get_nakade(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) - """ - A nakade pattern is a 12d pattern on a location a stone was captured before - it is unclear if a max size of the captured group has to be considered and - how recent the capture event should have been - - the 12d pattern can be encoded without stone colour and liberty count - unclear if a border location should be considered a stone or liberty - - pattern lookup value is being set instead of 1 - """ - - cdef int get_nakade_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) - """ - A nakade pattern is a 12d pattern on a location a stone was captured before - it is unclear if a max size of the captured group has to be considered and - how recent the capture event should have been - - the 12d pattern can be encoded without stone colour and liberty count - unclear if a border location should be considered a stone or liberty - - #pattern_id is offset - """ - - cdef int get_response_12d(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) - """ - Set 12d hash pattern for 12d shape around last move - pattern lookup value is being set instead of 1 - """ - - cdef int get_response_12d_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) - """ - Set 12d hash pattern for 12d shape around last move where - #pattern_id is offset - """ - - cdef int get_non_response_3x3(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) - """ - Set 3x3 hash pattern for every legal location where - pattern lookup value is being set instead of 1 - """ - - cdef int get_non_response_3x3_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet) - """ - Set 3x3 hash pattern for every legal location where - #pattern_id is offset - """ - - ############################################################################ - # public cdef function # - # # - ############################################################################ - - cdef np.ndarray[ tensor_type, ndim=4 ] generate_tensor(self, GameState state) - """ - Convert a GameState to a Theano-compatible tensor - """ diff --git a/AlphaGo/preprocessing/preprocessing_rollout.pyx b/AlphaGo/preprocessing/preprocessing_rollout.pyx deleted file mode 100644 index 410e46dc8..000000000 --- a/AlphaGo/preprocessing/preprocessing_rollout.pyx +++ /dev/null @@ -1,834 +0,0 @@ -# cython: profile=True -# cython: linetrace=True -# cython: wraparound=False -# cython: boundscheck=False -# cython: initializedcheck=False -cimport cython -import numpy as np -cimport numpy as np - - -cdef class Preprocess: - - ############################################################################ - # all variables are declared in the .pxd file # - # # - ############################################################################ - - - """ -> variables, declared in preprocessing.pxd - - # all feature processors - # TODO find correct type so an array can be used - cdef list processors - - # list with all features used currently - # TODO find correct type so an array can be used - cdef list feature_list - - # output tensor size - cdef int output_dim - - # board size - cdef char size - cdef short board_size - - # pattern dictionaries - cdef dict pattern_nakade - cdef dict pattern_response_12d - cdef dict pattern_non_response_3x3 - - # pattern dictionary sizes - cdef int pattern_nakade_size - cdef int pattern_response_12d_size - cdef int pattern_non_response_3x3_size - - -> variables, declared in preprocessing.pxd - """ - - - ############################################################################ - # Tensor generating functions # - # # - ############################################################################ - - - @cython.nonecheck(False) - cdef int get_board(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): - """ - A feature encoding WHITE BLACK and EMPTY on separate planes. - plane 0 always refers to the current player stones - plane 1 to the opponent stones - plane 2 to empty locations - """ - - cdef short location - cdef Group* group - cdef int plane - cdef char opponent = state.player_opponent - - # loop over all locations on board - for location in range(self.board_size): - - group = state.board_groups[ location ] - - if group.colour == _EMPTY: - - plane = offSet + 2 - elif group.colour == opponent: - - plane = offSet + 1 - else: - - plane = offSet - - tensor[ plane, location ] = 1 - - return offSet + 3 - - - @cython.nonecheck(False) - cdef int get_turns_since(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): - """ - A feature encoding the age of the stone at each location up to 'maximum' - - Note: - - the [maximum-1] plane is used for any stone with age greater than or equal to maximum - - EMPTY locations are all-zero features - """ - - cdef short location - cdef Locations_List *history = state.moves_history - cdef int age = offSet + 7 - cdef dict agesSet = {} - cdef int i - - # set all stones to max age - for i in range(history.count): - - location = history.locations[ i ] - - if location != _PASS and state.board_groups[ location ].colour > _EMPTY: - - tensor[ age, location ] = 1 - - # start with newest stone - i = history.count - 1 - age = 0 - - # loop over history backwards - while age < 7 and i >= 0: - - location = history.locations[ i ] - - # if age has not been set yet - if location != _PASS and not location in agesSet and state.board_groups[ location ].colour > _EMPTY: - - tensor[ offSet + age, location ] = 1 - tensor[ offSet + 7, location ] = 0 - agesSet[ location ] = location - - i -= 1 - age += 1 - - return offSet + 8 - - - @cython.nonecheck(False) - cdef int get_liberties(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): - """ - A feature encoding the number of liberties of the group connected to the stone at - each location - - Note: - - there is no zero-liberties plane; the 0th plane indicates groups in atari - - the [maximum-1] plane is used for any stone with liberties greater than or equal to maximum - - EMPTY locations are all-zero features - """ - - cdef int i, groupLiberty - cdef Group* group - cdef short location - - for location in range(self.board_size): - - group = state.board_groups[ location ] - - if group.colour > _EMPTY: - - groupLiberty = group.count_liberty - 1 - - # check max liberty count - if groupLiberty > 7: - - groupLiberty = 7 - - groupLiberty += offSet - - tensor[ groupLiberty, location ] = 1 - - return offSet + 8 - - - @cython.nonecheck(False) - cdef int get_ladder_capture(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): - """ - A feature wrapping GameState.is_ladder_capture(). - check if an opponent group can be captured in a ladder - """ - - cdef int location - cdef char* captures = state.get_ladder_captures(80) - - # loop over all groups on board - for location in range(state.board_size): - - if captures[ location ] != _FREE: - - tensor[ offSet, location ] = 1 - - # free captures - free(captures) - - return offSet + 1 - - - @cython.nonecheck(False) - cdef int get_ladder_escape(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): - """ - A feature wrapping GameState.is_ladder_escape(). - check if player_current group can escape ladder - """ - - cdef int location - cdef char* escapes = state.get_ladder_escapes(80) - - # loop over all groups on board - for location in range(state.board_size): - - if escapes[ location ] != _FREE: - - tensor[ offSet, location ] = 1 - - # free escapes - free(escapes) - - return offSet + 1 - - - @cython.nonecheck(False) - cdef int get_sensibleness(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): - """ - A move is 'sensible' if it is legal and if it does not fill the current_player's own eye - """ - - cdef int i - cdef short location - cdef Group* group - - # set all legal moves to 1 - for i in range(state.moves_legal.count): - - tensor[ offSet, state.moves_legal.locations[ i ] ] = 1 - - # list can increment but a big enough starting value is important - cdef Locations_List* eyes = locations_list_new(15) - - # loop over all board groups - for i in range(state.groups_list.count_groups): - - group = state.groups_list.board_groups[ i ] - - # if group is current player - if group.colour == state.player_current: - - # loop over liberties because they are possible eyes - for location in range(self.board_size): - - # check liberty location as possible eye - if group.locations[ location ] == _LIBERTY: - - # check if location is an eye - if state.is_true_eye(location, eyes, state.player_current): - - tensor[ offSet, location ] = 0 - - locations_list_destroy(eyes) - - return offSet + 1 - - - @cython.nonecheck(False) - cdef int get_legal(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): - """ - Zero at all illegal moves, one at all legal moves. Unlike sensibleness, no eye check is done - not used?? - """ - - cdef short location - - # loop over all legal moves and set to one - for location in range(state.moves_legal.count): - - tensor[ offSet, state.moves_legal.locations[ location ] ] = 1 - - return offSet + 1 - - - @cython.nonecheck(False) - cdef int get_response(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): - """ - single feature plane encoding whether this location matches any of the response - patterns, for now it only checks the 12d response patterns as we do not use the - 3x3 response patterns. - - TODO - - decide if we consider nakade patterns response patterns as well - - optimization? 12d response patterns are calculated twice.. - """ - - cdef short location, location_x, location_y, last_move, last_move_x, last_move_y - cdef int i, plane, id - cdef long hash_base, hash_pattern - cdef short *neighbor12d = state.neighbor12d - - # get last move - last_move = state.moves_history.locations[ state.moves_history.count - 1 ] - - # check if last move is not _PASS - if last_move != _PASS: - - # get 12d pattern hash of last move location and colour - hash_base = state.get_hash_12d(last_move) - - # calculate last_move x and y - last_move_x = last_move / state.size - last_move_y = last_move % state.size - - # last_move location in neighbor12d array - last_move *= 12 - - # loop over all locations in 12d shape - for i in range(12): - - # get location - location = neighbor12d[last_move + i] - - # check if location is empty - if state.board_groups[ location ].colour == _EMPTY: - - # calculate location x and y - location_x = (location / state.size) - last_move_x - location_y = (location % state.size) - last_move_y - - # calculate 12d response pattern hash - hash_pattern = hash_base + location_x - hash_pattern *= _HASHVALUE - hash_pattern += location_y - - # dictionary lookup - id = self.pattern_response_12d.get( hash_pattern ) - - if id >= 0: - - tensor[ offSet, location ] = 1 - - return offSet + 1 - - - @cython.nonecheck(False) - cdef int get_save_atari(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): - """ - A feature wrapping GameState.is_ladder_escape(). - check if player_current group can escape atari for at least one turn - """ - - cdef int location - cdef char* escapes = state.get_ladder_escapes(1) - - # loop over all groups on board - for location in range(state.board_size): - - if escapes[ location ] != _FREE: - - tensor[ offSet, location ] = 1 - - # free escapes - free(escapes) - - return offSet + 1 - - - @cython.nonecheck(False) - cdef int get_neighbor(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): - """ - encode last move neighbor positions in two planes: - - horizontal & vertical / direct neighbor - - diagonal neighbor - """ - - cdef short location, last_move - cdef int i, plane - cdef short *neighbor3x3 = state.neighbor3x3 - - # get last move - last_move = state.moves_history.locations[ state.moves_history.count - 1 ] - - # check if last move is not _PASS - if last_move != _PASS: - - # last_move location in neighbor3x3 array - last_move *= 8 - - # direct neighbor plane is plane offset - plane = offSet - - # loop over direct neighbor - # 0,1,2,3 are direct neighbor locations - for i in range(4): - - # get neighbor location - location = neighbor3x3[ last_move + i ] - - # check if location is empty - if state.board_groups[ location ].colour == _EMPTY: - - tensor[ plane, location ] = 1 - - # diagonal neighbor plane is plane offset + 1 - plane = offSet + 1 - - # loop over diagonal neighbor - # 4,5,6,7 are diagonal neighbor locations - for i in range(4, 8): - - # get neighbor location - location = neighbor3x3[ last_move + i ] - - # check if location is empty - if state.board_groups[ location ].colour == _EMPTY: - - tensor[ plane, location ] = 1 - - return offSet + 2 - - - @cython.nonecheck(False) - cdef int get_nakade(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): - """ - A nakade pattern is a 12d pattern on a location a stone was captured before - it is unclear if a max size of the captured group has to be considered and - how recent the capture event should have been - - the 12d pattern can be encoded without stone colour and liberty count - unclear if a border location should be considered a stone or liberty - - pattern lookup value is being set instead of 1 - """ - - # TODO tensor type has to be float - - return offSet + 1 - - - @cython.nonecheck(False) - cdef int get_nakade_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): - """ - A nakade pattern is a 12d pattern on a location a stone was captured before - it is unclear if a max size of the captured group has to be considered and - how recent the capture event should have been - - the 12d pattern can be encoded without stone colour and liberty count - unclear if a border location should be considered a stone or liberty - - #pattern_id is offset - """ - - return offSet + self.pattern_nakade_size - - - @cython.nonecheck(False) - cdef int get_response_12d(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): - """ - Set 12d hash pattern for 12d shape around last move - pattern lookup value is being set instead of 1 - """ - - # get last move location - # check for pass - - return offSet + 1 - - - @cython.nonecheck(False) - cdef int get_response_12d_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): - """ - Check all empty locations in a 12d shape around the last move for being a 12d response - pattern match - #pattern_id is offset - - base hash is 12d pattern hash of last move location + colour - add relative position of every empty location in a 12d shape to get 12d response pattern hash - - c hash x y - ... location a has: state.get_hash_12d(x), -1, 0 - .ax.. location b has: state.get_hash_12d(x), +1, -1 - ..b location c has: state.get_hash_12d(x), 0, +2 - . - - 12d response pattern hash value is calculated by: - ( ( hash + x ) * _HASHVALUE ) + y - """ - - cdef short location, location_x, location_y, last_move, last_move_x, last_move_y - cdef int i, plane, id - cdef long hash_base, hash_pattern - cdef short *neighbor12d = state.neighbor12d - - # get last move - last_move = state.moves_history.locations[ state.moves_history.count - 1 ] - - # check if last move is not _PASS - if last_move != _PASS: - - # get 12d pattern hash of last move location and colour - hash_base = state.get_hash_12d(last_move) - - # calculate last_move x and y - last_move_x = last_move / state.size - last_move_y = last_move % state.size - - # last_move location in neighbor12d array - last_move *= 12 - - # loop over all locations in 12d shape - for i in range(12): - - # get location - location = neighbor12d[last_move + i] - - # check if location is empty - if state.board_groups[ location ].colour == _EMPTY: - - # calculate location x and y - location_x = (location / state.size) - last_move_x - location_y = (location % state.size) - last_move_y - - # calculate 12d response pattern hash - hash_pattern = hash_base + location_x - hash_pattern *= _HASHVALUE - hash_pattern += location_y - - # dictionary lookup - id = self.pattern_response_12d.get( hash_pattern ) - - if id >= 0: - - tensor[ offSet + id, location ] = 1 - - return offSet + self.pattern_response_12d_size - - - @cython.nonecheck(False) - cdef int get_non_response_3x3(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): - """ - Set 3x3 hash pattern for every legal location where - pattern lookup value is being set instead of 1 - """ - - # TODO tensor type has to be float - - return offSet + 1 - - - @cython.nonecheck(False) - cdef int get_non_response_3x3_offset(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): - """ - Set 3x3 hash pattern for every legal location where - #pattern_id is offset - """ - - cdef short i, location - cdef int id - - # loop over all legal moves and set to one - for i in range(state.moves_legal.count): - - # get location - location = state.moves_legal.locations[ i ] - # get location hash and dict lookup - id = self.pattern_non_response_3x3.get( state.get_3x3_hash( location ) ) - - if id >= 0: - - tensor[ offSet + id, location ] = 1 - - return offSet + self.pattern_non_response_3x3_size - - - @cython.nonecheck(False) - cdef int zeros(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): - """ - Plane filled with zeros - """ - - ########################################################## - # strange things happen if a function does not do anything - # do not remove next line without extensive testing!!!!!!! - tensor[ offSet, 0 ] = 0 - - return offSet + 1 - - - @cython.nonecheck(False) - cdef int ones(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): - """ - Plane filled with ones - """ - - cdef short location - - for location in range(0, self.board_size): - - tensor[ offSet, location ] = 1 - return offSet + 1 - - - @cython.nonecheck(False) - cdef int colour(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): - """ - Value net feature, plane with ones if active_player is black else zeros - """ - - cdef short location - - # if player_current is white - if state.player_current == _BLACK: - - for location in range(0, self.board_size): - - tensor[ offSet, location ] = 1 - - return offSet + 1 - - - @cython.nonecheck(False) - cdef int ko(self, GameState state, tensor_type[ :, ::1 ] tensor, int offSet): - """ - Single plane encoding ko location - """ - - if state.ko is not _PASS: - - tensor[ offSet, state.ko ] = 1 - - return offSet + 1 - - - ############################################################################ - # init function # - # # - ############################################################################ - - - def __init__(self, list feature_list, char size=19, dict_nakade=None, dict_3x3=None, dict_12d=None, verbose=False): - """ - """ - - self.size = size - self.board_size = size * size - - cdef int i - - # preprocess_method is a function pointer: - # ctypedef int (*preprocess_method)(Preprocess, GameState, tensor_type[ :, ::1 ], char*, int) - cdef preprocess_method processor - - # create a list with function pointers - self.processors = malloc(len(feature_list) * sizeof(preprocess_method)) - - if not self.processors: - raise MemoryError() - - # load nakade patterns - self.pattern_nakade = {} - self.pattern_nakade_size = 0 - if dict_nakade is not None: - with open(dict_nakade, 'r') as f: - s = f.read() - self.pattern_nakade = ast.literal_eval(s) - self.pattern_nakade_size = max(self.pattern_nakade.values()) + 1 - - # load 12d response patterns - self.pattern_response_12d = {} - self.pattern_response_12d_size = 0 - if dict_12d is not None: - with open(dict_12d, 'r') as f: - s = f.read() - self.pattern_response_12d = ast.literal_eval(s) - self.pattern_response_12d_size = max(self.pattern_response_12d.values()) + 1 - - # load 3x3 non response patterns - self.pattern_non_response_3x3 = {} - self.pattern_non_response_3x3_size = 0 - if dict_3x3 is not None: - with open(dict_3x3, 'r') as f: - s = f.read() - self.pattern_non_response_3x3 = ast.literal_eval(s) - self.pattern_non_response_3x3_size = max(self.pattern_non_response_3x3.values()) + 1 - - if verbose: - print("loaded " + str(self.pattern_nakade_size) + " nakade patterns") - print("loaded " + str(self.pattern_response_12d_size) + " 12d patterns") - print("loaded " + str(self.pattern_non_response_3x3_size) + " 3x3 patterns") - - self.feature_list = feature_list - self.output_dim = 0 - - # loop over feature_list add the corresponding function - # and increment output_dim accordingly - for i in range(len(feature_list)): - feat = feature_list[ i ].lower() - if feat == "board": - processor = self.get_board - self.output_dim += 3 - - elif feat == "ones": - processor = self.ones - self.output_dim += 1 - - elif feat == "turns_since": - processor = self.get_turns_since - self.output_dim += 8 - - elif feat == "liberties": - processor = self.get_liberties - self.output_dim += 8 - - elif feat == "ladder_capture": - processor = self.get_ladder_capture - self.output_dim += 1 - - elif feat == "ladder_escape": - processor = self.get_ladder_escape - self.output_dim += 1 - - elif feat == "sensibleness": - processor = self.get_sensibleness - self.output_dim += 1 - - elif feat == "zeros": - processor = self.zeros - self.output_dim += 1 - - elif feat == "legal": - processor = self.get_legal - self.output_dim += 1 - - elif feat == "response": - processor = self.get_response - self.output_dim += 1 - - elif feat == "save_atari": - processor = self.get_save_atari - self.output_dim += 1 - - elif feat == "neighbor": - processor = self.get_neighbor - self.output_dim += 2 - - elif feat == "nakade": - processor = self.get_nakade - self.output_dim += self.pattern_nakade_size - - elif feat == "response_12d": - processor = self.get_response_12d - self.output_dim += self.pattern_response_12d_size - - elif feat == "non_response_3x3": - processor = self.get_non_response_3x3 - self.output_dim += self.pattern_non_response_3x3_size - - elif feat == "color": - processor = self.colour - self.output_dim += 1 - - elif feat == "ko": - processor = self.ko - self.output_dim += 1 - else: - - # incorrect feature input - raise ValueError("uknown feature: %s" % feat) - - self.processors[ i ] = processor - - - def __dealloc__(self): - """ - Prevent memory leaks by freeing all arrays created with malloc - """ - - if self.processors is not NULL: - free(self.processors) - - ############################################################################ - # public cdef function # - # # - ############################################################################ - - - @cython.nonecheck(False) - cdef np.ndarray[ tensor_type, ndim=4 ] generate_tensor(self, GameState state): - """ - Convert a GameState to a Theano-compatible tensor - """ - - cdef int i - cdef preprocess_method proc - - # create complete array now instead of concatenate later - # TODO check if we can use a Malloc array somehow.. faster!! - cdef np.ndarray[ tensor_type, ndim=2 ] np_tensor = np.zeros((self.output_dim, self.board_size), dtype=np.int8) - cdef tensor_type[ :, ::1 ] tensor = np_tensor - - cdef int offSet = 0 - - # loop over all processors and generate tensor - for i in range(len(self.feature_list)): - - proc = self.processors[ i ] - offSet = proc(self, state, tensor, offSet) - - # create a singleton 'batch' dimension - return np_tensor.reshape((1, self.output_dim, self.size, self.size)) - - - ############################################################################ - # public def function (Python) # - # # - ############################################################################ - - - def state_to_tensor(self, GameState state): - """ - Convert a GameState to a Theano-compatible tensor - """ - - return self.generate_tensor(state) - - - def get_output_dimension(self): - """ - return output_dim, the amount of planes an output tensor will have - """ - - return self.output_dim - - - def get_feature_list(self): - """ - return feature list - """ - - return self.feature_list diff --git a/setup.py b/setup.py index 77bf58e20..87b9b28c5 100644 --- a/setup.py +++ b/setup.py @@ -10,9 +10,6 @@ include_dirs=[numpy.get_include()], language="c++"), Extension("AlphaGo.preprocessing.preprocessing", ["AlphaGo/preprocessing/preprocessing.pyx"], include_dirs=[numpy.get_include()], language="c++"), - Extension("AlphaGo.preprocessing.preprocessing_rollout", - ["AlphaGo/preprocessing/preprocessing_rollout.pyx"], - include_dirs=[numpy.get_include()], language="c++"), ] setup(name="RocAlphaGo", ext_modules=cythonize(extensions)) From 856e233a8a182f95906ccbe8337d0d2a3b445f96 Mon Sep 17 00:00:00 2001 From: wrongu Date: Wed, 1 Nov 2017 16:05:18 -0400 Subject: [PATCH 176/191] Minor flake8 fix in gtp_wrapper --- interface/gtp_wrapper.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/interface/gtp_wrapper.py b/interface/gtp_wrapper.py index 1dca11510..57f9e9879 100644 --- a/interface/gtp_wrapper.py +++ b/interface/gtp_wrapper.py @@ -144,10 +144,7 @@ def run_gtp(player_obj, inpt_fn=None, name="Gtp Player", version="0.0"): inpt = inpt_fn() # handle either single lines at a time # or multiple commands separated by '\n' - try: - cmd_list = inpt.split("\n") - except: - cmd_list = [inpt] + cmd_list = inpt.split("\n") for cmd in cmd_list: engine_reply = gtp_engine.send(cmd) sys.stdout.write(engine_reply) From f3721c677d90027383521eb74e650b672ab4759d Mon Sep 17 00:00:00 2001 From: wrongu Date: Sat, 4 Nov 2017 11:01:01 -0400 Subject: [PATCH 177/191] go_data constants as enums, consistent references to their underscore names --- AlphaGo/go.pyx | 18 +++---- AlphaGo/go_data.pxd | 64 +++++++++++++----------- AlphaGo/go_data.pyx | 66 +++++++------------------ AlphaGo/preprocessing/preprocessing.pxd | 6 +-- AlphaGo/preprocessing/preprocessing.pyx | 6 +-- 5 files changed, 67 insertions(+), 93 deletions(-) diff --git a/AlphaGo/go.pyx b/AlphaGo/go.pyx index 811767b9e..efb9a5c01 100644 --- a/AlphaGo/go.pyx +++ b/AlphaGo/go.pyx @@ -23,7 +23,7 @@ cdef char neighbor_size # global array for zobrist lookup cdef unsigned long long *zobrist_lookup -# expose variables to python +# Expose constants to python PASS = _PASS BLACK = _BLACK WHITE = _WHITE @@ -268,7 +268,7 @@ cdef class GameState: # Set global size neighbor_size = size - # initialize EMPTY and BORDER group + # initialize _EMPTY and _BORDER group group_empty = group_new(_EMPTY, self.board_size) group_border = group_new(_BORDER, self.board_size) @@ -294,6 +294,7 @@ cdef class GameState: # free board_groups if self.board_groups is not NULL: + free(self.board_groups) # free history @@ -310,10 +311,8 @@ cdef class GameState: # free groups_list all groups in groups_list.board_groups and groups_list.board_groups if self.groups_list is not NULL: - # loop over all groups and free them for i in range(self.groups_list.count_groups): - group_destroy(self.groups_list.board_groups[i]) # free groups_list.board_groups @@ -339,8 +338,8 @@ cdef class GameState: cdef bint is_positional_superko(self, short location, Group **board): """Find all actions that the current_player has done in the past, taking into - account the fact that history starts with BLACK when there are no - handicaps or with WHITE when there are. + account the fact that history starts with _BLACK when there are no + handicaps or with _WHITE when there are. """ cdef int i @@ -745,8 +744,7 @@ cdef class GameState: return hash cdef void get_group_after(self, char* groups_after, char* locations, char* captures, short location): # noqa: E501 - """Groups_after is a board_size * 3 array representing STONES, LIBERTY, CAPTURE for every - location + """Groups_after is a board_size * 3 array representing STONES, _LIBERTY, _CAPTURE for every location calculate group after a play on location and set - groups_after[location * 3 +] to stone count @@ -847,7 +845,7 @@ cdef class GameState: groups_after[location_array + 2] = capture cdef void get_group_after_pointer(self, short* stones, short* liberty, short* capture, char* locations, char* captures, short location): # noqa: E501 - """Groups_after is a board_size * 3 array representing STONES, LIBERTY, CAPTURE for every location + """Groups_after is a board_size * 3 array representing STONES, _LIBERTY, _CAPTURE for every location calculate group after a play on location and set stones[0] to stone count @@ -1511,7 +1509,7 @@ cdef class GameState: cdef char* get_groups_after(self): """Return a short array of size board_size * 3 representing - STONES, LIBERTY, CAPTURE for every board location + STONES, _LIBERTY, _CAPTURE for every board location max count values are 100 diff --git a/AlphaGo/go_data.pxd b/AlphaGo/go_data.pxd index d0643e638..dd1e13497 100644 --- a/AlphaGo/go_data.pxd +++ b/AlphaGo/go_data.pxd @@ -2,33 +2,37 @@ import numpy as np cimport numpy as np ############################################################################ -# constants # +# Global constants # # # ############################################################################ -# TODO find out if these are really used as compile time-constants +# Note that "enum" is the best way to create a compile-time constant in cython. -# value for PASS move -cdef char _PASS +cpdef enum GAME: + # value for _PASS move + _PASS = -1 -# observe: stones > EMPTY -# border < EMPTY -# be aware you should NOT use != EMPTY as this includes border locations -cdef char _BORDER -cdef char _EMPTY -cdef char _WHITE -cdef char _BLACK +cpdef enum STONES: + # observe: stones > _EMPTY + # border < _EMPTY + # be aware you should NOT use != _EMPTY as this includes border locations + _BORDER = 1 + _EMPTY = 2 + _WHITE = 3 + _BLACK = 4 -# used for group stone, liberty locations, legal move and eye locations -cdef char _FREE -cdef char _STONE -cdef char _LIBERTY -cdef char _CAPTURE -cdef char _LEGAL -cdef char _EYE +cpdef enum PATTERNS: + # value used to generate pattern hashes + _HASHVALUE = 33 -# value used to generate pattern hashes -cdef char _HASHVALUE +cpdef enum GROUP: + # used for group stone, liberty locations, legal move and sensible move + _FREE = 3 + _STONE = 0 + _LIBERTY = 1 + _CAPTURE = 2 + _LEGAL = 4 + _EYE = 5 ############################################################################ @@ -108,7 +112,7 @@ cdef struct Locations_List: cdef Group* group_new(char colour, short size) """ create new struct Group - with locations #size char long initialized to FREE + with locations #size char long initialized to _FREE """ cdef Group* group_duplicate(Group* group, short size) @@ -123,7 +127,7 @@ cdef void group_destroy(Group* group) cdef void group_add_stone(Group* group, short location) """ - update location as STONE + update location as _STONE update liberty count if it was a liberty location n.b. stone count is not incremented if a stone was present already @@ -131,34 +135,34 @@ cdef void group_add_stone(Group* group, short location) cdef void group_remove_stone(Group* group, short location) """ - update location as FREE + update location as _FREE update stone count if it was a stone location """ cdef short group_location_stone(Group* group, short size) """ - return first location where a STONE is located + return first location where a _STONE is located """ cdef void group_add_liberty(Group* group, short location) """ - update location as LIBERTY - update liberty count if it was a FREE location + update location as _LIBERTY + update liberty count if it was a _FREE location n.b. liberty count is not incremented if a stone was present already """ cdef void group_remove_liberty(Group* group, short location) """ - update location as FREE - update liberty count if it was a LIBERTY location + update location as _FREE + update liberty count if it was a _LIBERTY location - n.b. liberty count is not decremented if location is a FREE location + n.b. liberty count is not decremented if location is a _FREE location """ cdef short group_location_liberty(Group* group, short size) """ - return location where a LIBERTY is located + return location where a _LIBERTY is located """ ############################################################################ diff --git a/AlphaGo/go_data.pyx b/AlphaGo/go_data.pyx index 2f4487d23..ba7c2f796 100644 --- a/AlphaGo/go_data.pyx +++ b/AlphaGo/go_data.pyx @@ -25,34 +25,6 @@ from libc.string cimport memcpy, memset, memchr - implement faster loop over all elements for dict using memchr and offset pointer """ -############################################################################ -# constants # -# # -############################################################################ - - -# value for PASS move -_PASS = -1 - -# observe: stones > EMPTY -# border < EMPTY -# be aware you should NOT use != EMPTY as this includes border locations -_BORDER = 1 -_EMPTY = 2 -_WHITE = 3 -_BLACK = 4 - -# used for group stone, liberty locations, legal move and sensible move -_FREE = 3 -_STONE = 0 -_LIBERTY = 1 -_CAPTURE = 2 -_LEGAL = 4 -_EYE = 5 - -# value used to generate pattern hashes -_HASHVALUE = 33 - ############################################################################ # Structs defined in go_data.pxd # @@ -117,7 +89,7 @@ cdef struct Locations_List: cdef Group* group_new(char colour, short size): - """Create new struct Group with locations set to FREE + """Create new struct Group with locations set to _FREE """ cdef int i @@ -137,7 +109,7 @@ cdef Group* group_new(char colour, short size): group.count_liberty = 0 group.colour = colour - # initialize locations with FREE + # initialize locations with _FREE memset(group.locations, _FREE, size) return group @@ -186,7 +158,7 @@ cdef void group_destroy(Group* group): cdef void group_add_stone(Group* group, short location): - """Update location as STONE + """Update location as _STONE update liberty count if it was a liberty location n.b. stone count is not incremented if a stone was present already @@ -194,33 +166,33 @@ cdef void group_add_stone(Group* group, short location): # check if locations is a liberty if group.locations[location] == _FREE: - # locations is FREE, increment stone count + # locations is _FREE, increment stone count group.count_stones += 1 elif group.locations[location] == _LIBERTY: - # locations is LIBERTY, increment stone count and decrement liberty count + # locations is _LIBERTY, increment stone count and decrement liberty count group.count_stones += 1 group.count_liberty -= 1 - # set STONE + # set _STONE group.locations[location] = _STONE cdef void group_remove_stone(Group* group, short location): - """Update location as FREE + """Update location as _FREE update stone count if it was a stone location """ # check if a stone is present if group.locations[location] == _STONE: - # stone present, decrement stone count and set location to FREE + # stone present, decrement stone count and set location to _FREE group.count_stones -= 1 group.locations[location] = _FREE cdef short group_location_stone(Group* group, short size): - """Return first location where a STONE is located + """Return first location where a _STONE is located """ # memchr is a in memory search function, it starts searching at @@ -232,35 +204,35 @@ cdef short group_location_stone(Group* group, short size): cdef void group_add_liberty(Group* group, short location): - """Update location as LIBERTY + """Update location as _LIBERTY - update liberty count if it was a FREE location + update liberty count if it was a _FREE location n.b. liberty count is not incremented if a stone was present already """ - # check if location is FREE + # check if location is _FREE if group.locations[location] == _FREE: - # increment liberty count, set location to LIBERTY + # increment liberty count, set location to _LIBERTY group.count_liberty += 1 group.locations[location] = _LIBERTY cdef void group_remove_liberty(Group* group, short location): - """Update location as FREE + """Update location as _FREE - update liberty count if it was a LIBERTY location - n.b. liberty count is not decremented if location is a FREE location + update liberty count if it was a _LIBERTY location + n.b. liberty count is not decremented if location is a _FREE location """ - # check if location is LIBERTY + # check if location is _LIBERTY if group.locations[location] == _LIBERTY: - # decrement liberty count, set location to FREE + # decrement liberty count, set location to _FREE group.count_liberty -= 1 group.locations[location] = _FREE cdef short group_location_liberty(Group* group, short size): - """Return location where a LIBERTY is located + """Return location where a _LIBERTY is located """ # memchr is a in memory search function, it starts searching at diff --git a/AlphaGo/preprocessing/preprocessing.pxd b/AlphaGo/preprocessing/preprocessing.pxd index 68f248b27..50b5367e3 100644 --- a/AlphaGo/preprocessing/preprocessing.pxd +++ b/AlphaGo/preprocessing/preprocessing.pxd @@ -58,7 +58,7 @@ cdef class Preprocess: ############################################################################ cdef int get_board(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 - """A feature encoding WHITE BLACK and EMPTY on separate planes. + """A feature encoding _WHITE _BLACK and _EMPTY on separate planes. Note: - plane 0 always refers to the current player stones @@ -71,7 +71,7 @@ cdef class Preprocess: Note: - the [maximum-1] plane is used for any stone with age greater than or equal to maximum - - EMPTY locations are all-zero features + - _EMPTY locations are all-zero features """ cdef int get_liberties(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 @@ -81,7 +81,7 @@ cdef class Preprocess: Note: - there is no zero-liberties plane; the 0th plane indicates groups in atari - the [maximum-1] plane is used for any stone with liberties greater than or equal to maximum - - EMPTY locations are all-zero features + - _EMPTY locations are all-zero features """ cdef int get_capture_size(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 diff --git a/AlphaGo/preprocessing/preprocessing.pyx b/AlphaGo/preprocessing/preprocessing.pyx index 69283083d..da149ba94 100644 --- a/AlphaGo/preprocessing/preprocessing.pyx +++ b/AlphaGo/preprocessing/preprocessing.pyx @@ -47,7 +47,7 @@ cdef class Preprocess: ############################################################################ cdef int get_board(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 - """A feature encoding WHITE BLACK and EMPTY on separate planes. + """A feature encoding _WHITE _BLACK and _EMPTY on separate planes. Note: - plane 0 always refers to the current player stones @@ -81,7 +81,7 @@ cdef class Preprocess: Note: - the [maximum-1] plane is used for any stone with age greater than or equal to maximum - - EMPTY locations are all-zero features + - _EMPTY locations are all-zero features """ cdef short location @@ -122,7 +122,7 @@ cdef class Preprocess: - there is no zero-liberties plane; the 0th plane indicates groups in atari - the [maximum-1] plane is used for any stone with liberties greater than or equal to maximum - - EMPTY locations are all-zero features + - _EMPTY locations are all-zero features """ cdef int i, groupLiberty From 41037b643772ff2b2c913ce4025be4fbc733d5a1 Mon Sep 17 00:00:00 2001 From: wrongu Date: Sun, 5 Nov 2017 11:11:12 -0500 Subject: [PATCH 178/191] Beginning of AlphaGo/go/ submodule refactor --- AlphaGo/{go.pxd => go/game_state.pxd} | 2 +- AlphaGo/{go.pyx => go/game_state.pyx} | 8 +------- AlphaGo/{ => go}/go_data.pxd | 0 AlphaGo/{ => go}/go_data.pyx | 0 AlphaGo/preprocessing/preprocessing.pxd | 4 ++-- setup.py | 4 ++-- 6 files changed, 6 insertions(+), 12 deletions(-) rename AlphaGo/{go.pxd => go/game_state.pxd} (99%) rename AlphaGo/{go.pyx => go/game_state.pyx} (99%) rename AlphaGo/{ => go}/go_data.pxd (100%) rename AlphaGo/{ => go}/go_data.pyx (100%) diff --git a/AlphaGo/go.pxd b/AlphaGo/go/game_state.pxd similarity index 99% rename from AlphaGo/go.pxd rename to AlphaGo/go/game_state.pxd index 3ea736c1d..21cee3890 100644 --- a/AlphaGo/go.pxd +++ b/AlphaGo/go/game_state.pxd @@ -1,6 +1,6 @@ import numpy as np cimport numpy as np -from AlphaGo.go_data cimport * +from AlphaGo.go.go_data cimport * cdef class GameState: diff --git a/AlphaGo/go.pyx b/AlphaGo/go/game_state.pyx similarity index 99% rename from AlphaGo/go.pyx rename to AlphaGo/go/game_state.pyx index efb9a5c01..1ee17d281 100644 --- a/AlphaGo/go.pyx +++ b/AlphaGo/go/game_state.pyx @@ -23,12 +23,6 @@ cdef char neighbor_size # global array for zobrist lookup cdef unsigned long long *zobrist_lookup -# Expose constants to python -PASS = _PASS -BLACK = _BLACK -WHITE = _WHITE -EMPTY = _EMPTY - cdef class GameState: ############################################################################ @@ -1871,7 +1865,7 @@ cdef class GameState: If not, an IllegalMove exception is raised """ - if action is _PASS: + if action == _PASS: locations_list_add_location_increment(self.moves_history, _PASS) if self.player_opponent == _BLACK: diff --git a/AlphaGo/go_data.pxd b/AlphaGo/go/go_data.pxd similarity index 100% rename from AlphaGo/go_data.pxd rename to AlphaGo/go/go_data.pxd diff --git a/AlphaGo/go_data.pyx b/AlphaGo/go/go_data.pyx similarity index 100% rename from AlphaGo/go_data.pyx rename to AlphaGo/go/go_data.pyx diff --git a/AlphaGo/preprocessing/preprocessing.pxd b/AlphaGo/preprocessing/preprocessing.pxd index 50b5367e3..98e14cfc4 100644 --- a/AlphaGo/preprocessing/preprocessing.pxd +++ b/AlphaGo/preprocessing/preprocessing.pxd @@ -1,8 +1,8 @@ import ast import time from libc.stdlib cimport malloc, free -from AlphaGo.go cimport GameState -from AlphaGo.go_data cimport _BLACK, _EMPTY, _STONE, _LIBERTY, _CAPTURE, \ +from AlphaGo.go.game_state cimport GameState +from AlphaGo.go.go_data cimport _BLACK, _EMPTY, _STONE, _LIBERTY, _CAPTURE, \ _FREE, _PASS, Group, Locations_List, locations_list_destroy, \ locations_list_new, _HASHVALUE from numpy cimport ndarray diff --git a/setup.py b/setup.py index 87b9b28c5..f67c92569 100644 --- a/setup.py +++ b/setup.py @@ -4,9 +4,9 @@ from Cython.Build import cythonize extensions = [ - Extension("AlphaGo.go", ["AlphaGo/go.pyx"], + Extension("AlphaGo.go.game_state", ["AlphaGo/go/game_state.pyx"], include_dirs=[numpy.get_include()], language="c++"), - Extension("AlphaGo.go_data", ["AlphaGo/go_data.pyx"], + Extension("AlphaGo.go.go_data", ["AlphaGo/go/go_data.pyx"], include_dirs=[numpy.get_include()], language="c++"), Extension("AlphaGo.preprocessing.preprocessing", ["AlphaGo/preprocessing/preprocessing.pyx"], include_dirs=[numpy.get_include()], language="c++"), From ab2cb1072ea434156169cf0e7927145a796ae920 Mon Sep 17 00:00:00 2001 From: wrongu Date: Sun, 5 Nov 2017 11:19:44 -0500 Subject: [PATCH 179/191] American imperialism: global replace of "colour" to "color" --- AlphaGo/go/game_state.pxd | 12 +- AlphaGo/go/game_state.pyx | 118 +++++++++--------- AlphaGo/go/go_data.pxd | 4 +- AlphaGo/go/go_data.pyx | 12 +- AlphaGo/go_python.py | 4 +- .../preprocessing/generate_value_training.py | 2 +- AlphaGo/preprocessing/preprocessing.pxd | 6 +- AlphaGo/preprocessing/preprocessing.pyx | 34 ++--- 8 files changed, 96 insertions(+), 96 deletions(-) diff --git a/AlphaGo/go/game_state.pxd b/AlphaGo/go/game_state.pxd index 21cee3890..eb93c593d 100644 --- a/AlphaGo/go/game_state.pxd +++ b/AlphaGo/go/game_state.pxd @@ -76,8 +76,8 @@ cdef class GameState: # # ############################################################################ - cdef void update_hash(self, short location, char colour) - """xor current hash with location + colour action value + cdef void update_hash(self, short location, char color) + """xor current hash with location + color action value """ cdef bint is_positional_superko(self, short location, Group **board) @@ -189,7 +189,7 @@ cdef class GameState: TODO validate no changes are being made! - TODO self.player colour is used, should become a pointer + TODO self.player color is used, should become a pointer """ cdef Groups_List* add_ladder_move(self, short location, Group **board, short* ko) @@ -227,13 +227,13 @@ cdef class GameState: all changes to the board are stored in removed_groups """ - cdef bint is_ladder_escape_move(self, Group **board, short* ko, Locations_List *list_ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase) # noqa: E501 + cdef bint is_ladder_escape_move(self, Group **board, short* ko, Locations_List *list_ko, short location_group, dict capture, short location, int maxDepth, char color_group, char color_chase) # noqa: E501 """Play a ladder move on location, check if group has escaped, if the group has 2 liberty it is undetermined -> try to capture it by playing at both liberty """ - cdef bint is_ladder_capture_move(self, Group **board, short* ko, Locations_List *list_ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase) # noqa: E501 + cdef bint is_ladder_capture_move(self, Group **board, short* ko, Locations_List *list_ko, short location_group, dict capture, short location, int maxDepth, char color_group, char color_chase) # noqa: E501 """Play a ladder move on location, try capture and escape moves and see if the group is able to escape ladder """ @@ -303,7 +303,7 @@ cdef class GameState: positive value indicates white win """ - cdef char get_winner_colour(self, float komi) + cdef char get_winner_color(self, float komi) """Calculate score of board state and return player ID (1, -1, or 0 for tie) corresponding to winner. Uses 'Area scoring'. diff --git a/AlphaGo/go/game_state.pyx b/AlphaGo/go/game_state.pyx index 1ee17d281..597bf00d7 100644 --- a/AlphaGo/go/game_state.pyx +++ b/AlphaGo/go/game_state.pyx @@ -105,7 +105,7 @@ cdef class GameState: # create history list self.moves_history = locations_list_new(10) - # initialize player colours + # initialize player colors self.player_current = _BLACK self.player_opponent = _WHITE @@ -119,7 +119,7 @@ cdef class GameState: # +1 on board_size is used as an border location used for all borders # create Group pointer array (Group **) - # this array represent the board, every group contains colour, stone-locations + # this array represent the board, every group contains color, stone-locations # and liberty locations # border location is included, therefore the array size is board_size +1 self.board_groups = malloc((self.board_size + 1) * sizeof(Group*)) @@ -209,7 +209,7 @@ cdef class GameState: self.groups_list = groups_list_new(self.board_size) # create Group pointer array (Group **) - # this array represent the board, every group contains colour, stone-locations + # this array represent the board, every group contains color, stone-locations # and liberty locations # border location is included, therefore the array size is board_size +1 self.board_groups = malloc((self.board_size + 1) * sizeof(Group*)) @@ -321,11 +321,11 @@ cdef class GameState: # # ############################################################################ - cdef void update_hash(self, short location, char colour): - """Xor current hash with location + colour action value + cdef void update_hash(self, short location, char color): + """Xor current hash with location + color action value """ - if colour == _BLACK: + if color == _BLACK: location += self.board_size self.zobrist_current = self.zobrist_current ^ self.zobrist_lookup[location] @@ -385,7 +385,7 @@ cdef class GameState: """ # check if it is empty - if board[location].colour != _EMPTY: + if board[location].color != _EMPTY: return 0 # check ko @@ -406,7 +406,7 @@ cdef class GameState: """ # check if it is empty - if board[location].colour != _EMPTY: + if board[location].color != _EMPTY: return 0 # check ko @@ -441,7 +441,7 @@ cdef class GameState: # get neighbor location neighbor_location = self.neighbor[location * 4 + i] - board_value = board[neighbor_location].colour + board_value = board[neighbor_location].color # if empty location -> liberty -> legal move if board_value == _EMPTY: @@ -449,7 +449,7 @@ cdef class GameState: return 1 # get neighbor group - # (group_border has zero libery and is wrong colour) + # (group_border has zero libery and is wrong color) group_temp = board[neighbor_location] count_liberty = group_temp.count_liberty @@ -557,7 +557,7 @@ cdef class GameState: board[location] = group_empty # update hash - self.update_hash(location, group_remove.colour) + self.update_hash(location, group_remove.color) # update liberty of neighbors # loop over all four neighbors @@ -568,7 +568,7 @@ cdef class GameState: # only current_player groups can be next to a killed group # check if there is a group - board_value = board[neighbor_location].colour + board_value = board[neighbor_location].color if board_value == self.player_current: # add liberty @@ -595,7 +595,7 @@ cdef class GameState: # get neighbor location and value neighborLocation = self.neighbor[location * 4 + i] - boardValue = board[neighborLocation].colour + boardValue = board[neighborLocation].color # check if neighbor is friendly stone if boardValue == self.player_current: @@ -668,7 +668,7 @@ cdef class GameState: neighborLocation = self.neighbor3x3[location_array + i] # if neighbor location is empty add liberty and update hash - if board[neighborLocation].colour == _EMPTY: + if board[neighborLocation].color == _EMPTY: group_add_liberty(newGroup, neighborLocation) @@ -694,14 +694,14 @@ cdef class GameState: # calculate location in neighbor12d array centre *= 12 - # hash colour and liberty of all locations + # hash color and liberty of all locations for i in range(12): # get group group = self.board_groups[self.neighbor12d[centre + i]] - # hash colour - hash += group.colour + # hash color + hash += group.color hash *= _HASHVALUE # hash liberty @@ -721,14 +721,14 @@ cdef class GameState: # calculate location in neighbor3x3 array centre *= 8 - # hash colour and liberty of all locations + # hash color and liberty of all locations for i in range(8): # get group group = self.board_groups[self.neighbor3x3[centre + i]] - # hash colour - hash += group.colour + # hash color + hash += group.color hash *= _HASHVALUE # hash liberty @@ -760,7 +760,7 @@ cdef class GameState: # get neighbor location and value neighbor_location = self.neighbor[location * 4 + i] temp_group = self.board_groups[neighbor_location] - board_value = temp_group.colour + board_value = temp_group.color # check if neighbor is friendly stone if board_value == _EMPTY: @@ -859,7 +859,7 @@ cdef class GameState: # get neighbor location and value neighbor_location = self.neighbor[location * 4 + i] temp_group = self.board_groups[neighbor_location] - board_value = temp_group.colour + board_value = temp_group.color # check if neighbor is friendly stone if board_value == _EMPTY: @@ -951,7 +951,7 @@ cdef class GameState: for i in range(4): location_neighbor = self.neighbor3x3[location * 8 + i] - board_value = self.board_groups[location_neighbor].colour + board_value = self.board_groups[location_neighbor].color if board_value == _BORDER: @@ -967,7 +967,7 @@ cdef class GameState: for i in range(4, 8): location_neighbor = self.neighbor3x3[location * 8 + i] - board_value = self.board_groups[location_neighbor].colour + board_value = self.board_groups[location_neighbor].color if board_value == _EMPTY: # locations_list_add_location(empty_diag, location_neighbor) @@ -1040,7 +1040,7 @@ cdef class GameState: TODO validate no changes are being made! - TODO self.player colour is used, should become a pointer + TODO self.player color is used, should become a pointer """ cdef Groups_List* add_ladder_move(self, short location, Group **board, short* ko): @@ -1060,7 +1060,7 @@ cdef class GameState: # play move at location and add removed groups to removed_groups list self.get_removed_groups(location, removed_groups, board, ko) - # change player colour + # change player color self.player_current = self.player_opponent self.player_opponent = (_BLACK if self.player_current == _WHITE else _WHITE) @@ -1099,7 +1099,7 @@ cdef class GameState: # only current_player groups can be next to a killed group # check if there is a group - board_value = board[neighbor_location].colour + board_value = board[neighbor_location].color if board_value == self.player_current: # add liberty @@ -1119,7 +1119,7 @@ cdef class GameState: # ko is a pointer -> add [0] to acces the actual value ko[0] = removed_ko - # change player colour + # change player color self.player_current = self.player_opponent self.player_opponent = (_BLACK if self.player_current == _WHITE else _WHITE) @@ -1133,10 +1133,10 @@ cdef class GameState: # this is important in order to get correct liberty counts group = removed_groups.board_groups[removed_groups.count_groups - i - 1] - # check group colour and determine what happened + # check group color and determine what happened # player_current -> groups have been combined, set board locations to group # player_opponent -> groups have been removed, unremove them - if group.colour == self.player_opponent: + if group.color == self.player_opponent: # opponent group was removed from the board -> unremove it self.unremove_group(group, board) else: @@ -1149,7 +1149,7 @@ cdef class GameState: # add liberty to neighbor groups for i in range(4): location_neighbor = self.neighbor[location * 4 + i] - if board[location_neighbor].colour > _EMPTY: + if board[location_neighbor].color > _EMPTY: group_add_liberty(board[location_neighbor], location) # destroy group @@ -1218,7 +1218,7 @@ cdef class GameState: location_neighbor = self.neighbor[location_array + i] # if location has opponent stone - if board[location_neighbor].colour == color: + if board[location_neighbor].color == color: # get opponent group group_neighbor = board[location_neighbor] @@ -1253,7 +1253,7 @@ cdef class GameState: # get neighbor location and value neighborLocation = self.neighbor[location * 4 + i] - boardValue = board[neighborLocation].colour + boardValue = board[neighborLocation].color # check if neighbor is friendly stone if boardValue == self.player_current: @@ -1298,7 +1298,7 @@ cdef class GameState: neighborLocation = self.neighbor[location * 4 + i] # check is neighbor is empty, add liberty if so - if board[neighborLocation].colour == _EMPTY: + if board[neighborLocation].color == _EMPTY: group_add_liberty(newGroup, neighborLocation) @@ -1308,7 +1308,7 @@ cdef class GameState: if group_removed >= 2 or newGroup.count_stones > 1: ko[0] = _PASS - cdef bint is_ladder_escape_move(self, Group **board, short* ko, Locations_List *list_ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase): # noqa: E501 + cdef bint is_ladder_escape_move(self, Group **board, short* ko, Locations_List *list_ko, short location_group, dict capture, short location, int maxDepth, char color_group, char color_chase): # noqa: E501 """Play a ladder move on location, check if group has escaped, if the group has 2 liberty it is undetermined -> try to capture it by playing at both liberty @@ -1379,7 +1379,7 @@ cdef class GameState: location_neighbor = self.neighbor[location_stone * 4 + i] # if location has opponent stone - if board[location_neighbor].colour == colour_chase: + if board[location_neighbor].color == color_chase: # get opponent group group_capture = board[location_neighbor] @@ -1399,7 +1399,7 @@ cdef class GameState: if self.is_ladder_capture_move(board, ko, list_ko, location_group, capture.copy(), location_neighbor, maxDepth - 1, - colour_group, colour_chase): + color_group, color_chase): # undo move self.undo_ladder_move(location, removed_groups, ko_value, board, ko) list_ko.count = ko_count @@ -1415,7 +1415,7 @@ cdef class GameState: # return result return result - cdef bint is_ladder_capture_move(self, Group **board, short* ko, Locations_List *list_ko, short location_group, dict capture, short location, int maxDepth, char colour_group, char colour_chase): # noqa: E501 + cdef bint is_ladder_capture_move(self, Group **board, short* ko, Locations_List *list_ko, short location_group, dict capture, short location, int maxDepth, char color_group, char color_chase): # noqa: E501 """Play a ladder move on location, try capture and escape moves and see if the group is able to escape ladder """ @@ -1465,7 +1465,7 @@ cdef class GameState: capture_copy = capture.copy() capture_copy.pop(location_next) if self.is_ladder_escape_move(board, ko, list_ko, location_group, capture.copy(), - location_next, maxDepth - 1, colour_group, colour_chase): + location_next, maxDepth - 1, color_group, color_chase): # undo move self.undo_ladder_move(location, removed_groups, ko_value, board, ko) list_ko.count = ko_count @@ -1482,8 +1482,8 @@ cdef class GameState: if location_next in capture_copy: capture_copy.pop(location_next) if self.is_ladder_escape_move(board, ko, list_ko, location_group, capture.copy(), - location_next, maxDepth - 1, colour_group, - colour_chase): + location_next, maxDepth - 1, color_group, + color_chase): # undo move self.undo_ladder_move(location, removed_groups, ko_value, board, ko) @@ -1549,7 +1549,7 @@ cdef class GameState: """Return hash for 12d star pattern around location """ - # generate 12d hash value and add current player colour + # generate 12d hash value and add current player color return (self.generate_12d_hash(centre) + self.player_current) * _HASHVALUE @@ -1557,7 +1557,7 @@ cdef class GameState: """Return 3x3 pattern hash + current player """ - # generate 3x3 hash value and add current player colour + # generate 3x3 hash value and add current player color return self.generate_3x3_hash(location) + self.player_current @@ -1595,7 +1595,7 @@ cdef class GameState: if group.count_liberty == 1: # check if group has one liberty and is owned by current - if group.colour == self.player_current: + if group.color == self.player_current: # the first time a possible ladder location is found, board # is duplicated, technically this is not neccisary but it is @@ -1687,7 +1687,7 @@ cdef class GameState: if group.count_liberty == 2: # check if group is owned by opponent - if group.colour == self.player_opponent: + if group.color == self.player_opponent: # the first time a possible ladder location is found, board # is duplicated, technically this is not neccisary but it is @@ -1756,7 +1756,7 @@ cdef class GameState: # add move to board self.add_to_group(location, self.board_groups, &self.ko, captures) - # switch player colour + # switch player color self.player_current = self.player_opponent self.player_opponent = (_BLACK if self.player_current == _WHITE else _WHITE) @@ -1802,8 +1802,8 @@ cdef class GameState: # loop over whole board for location in range(self.board_size): - # get location colour - board_value = self.board_groups[location].colour + # get location color + board_value = self.board_groups[location].color if board_value == _WHITE: @@ -1834,7 +1834,7 @@ cdef class GameState: return score - cdef char get_winner_colour(self, float komi): + cdef char get_winner_color(self, float komi): """Calculate score of board state and return player ID (1, -1, or 0 for tie) corresponding to winner. Uses 'Area scoring'. @@ -1873,7 +1873,7 @@ cdef class GameState: else: self.passes_white += 1 - # change player colour + # change player color self.player_current = self.player_opponent self.player_opponent = (_BLACK if self.player_current == _WHITE else _WHITE) @@ -1930,7 +1930,7 @@ cdef class GameState: corresponding to winner. Uses 'Area scoring'. """ - return self.get_winner_colour(komi) + return self.get_winner_color(komi) def get_board_count(self, float komi=7.5): """Calculate score of board state @@ -1979,7 +1979,7 @@ cdef class GameState: location = self.calculate_board_location(y, x) self.add_to_group(location, self.board_groups, &self.ko, &fake_capture) - # active player colour reverses + # active player color reverses self.player_current = _WHITE self.player_opponent = _BLACK @@ -2035,11 +2035,11 @@ cdef class GameState: return self.player_current - def set_current_player(self, colour): - """Change current player colour + def set_current_player(self, color): + """Change current player color """ - self.player_current = colour + self.player_current = color self.player_opponent = (_BLACK if self.player_current == _WHITE else _WHITE) def get_history(self): @@ -2096,7 +2096,7 @@ cdef class GameState: for x in range(self.board_size): - if self.board_groups[x].colour != state.board_groups[x].colour: + if self.board_groups[x].color != state.board_groups[x].color: return False @@ -2209,7 +2209,7 @@ cdef class GameState: location = self.calculate_board_location(y, x) - board[x, y] = self.board_groups[location].colour + board[x, y] = self.board_groups[location].color return board @@ -2276,9 +2276,9 @@ cdef class GameState: for j in range(self.size): B = 0 - if self.board_groups[j + i * self.size].colour == _BLACK: + if self.board_groups[j + i * self.size].color == _BLACK: B = 'B' - elif self.board_groups[j + i * self.size].colour == _WHITE: + elif self.board_groups[j + i * self.size].color == _WHITE: B = 'W' A += str(B) + " " line += A + "\n" diff --git a/AlphaGo/go/go_data.pxd b/AlphaGo/go/go_data.pxd index dd1e13497..515536e8c 100644 --- a/AlphaGo/go/go_data.pxd +++ b/AlphaGo/go/go_data.pxd @@ -75,7 +75,7 @@ cdef struct Group: char* locations short count_stones short count_liberty - char colour + char color """ struct to store a list of Group @@ -109,7 +109,7 @@ cdef struct Locations_List: # # ############################################################################ -cdef Group* group_new(char colour, short size) +cdef Group* group_new(char color, short size) """ create new struct Group with locations #size char long initialized to _FREE diff --git a/AlphaGo/go/go_data.pyx b/AlphaGo/go/go_data.pyx index ba7c2f796..366673551 100644 --- a/AlphaGo/go/go_data.pyx +++ b/AlphaGo/go/go_data.pyx @@ -53,7 +53,7 @@ cdef struct Group: char* locations short count_stones short count_liberty - char colour + char color # struct to store a list of Group # @@ -88,7 +88,7 @@ cdef struct Locations_List: ############################################################################ -cdef Group* group_new(char colour, short size): +cdef Group* group_new(char color, short size): """Create new struct Group with locations set to _FREE """ @@ -104,10 +104,10 @@ cdef Group* group_new(char colour, short size): if not group.locations: raise MemoryError() - # set counts to 0 and colour to colour + # set counts to 0 and color to color group.count_stones = 0 group.count_liberty = 0 - group.colour = colour + group.color = color # initialize locations with _FREE memset(group.locations, _FREE, size) @@ -131,10 +131,10 @@ cdef Group* group_duplicate(Group* group, short size): if not duplicate.locations: raise MemoryError() - # set counts and colour values + # set counts and color values duplicate.count_stones = group.count_stones duplicate.count_liberty = group.count_liberty - duplicate.colour = group.colour + duplicate.color = group.color # duplicate locations array in memory # memcpy is optimized to do this quickly diff --git a/AlphaGo/go_python.py b/AlphaGo/go_python.py index b597a76ba..f58b8a491 100644 --- a/AlphaGo/go_python.py +++ b/AlphaGo/go_python.py @@ -587,12 +587,12 @@ def get_pattern_non_response_3x3(self, position): # position is to close to the edge return -1 - # active player colour + # active player color pattern_hash = 2 pattern_hash += long(self.current_player) pattern_hash *= 10 - # 8 surrounding position colours + # 8 surrounding position colors pattern_hash += self.board[x - 1][y - 1] + 2 pattern_hash *= 10 pattern_hash += self.board[x - 1][y] + 2 diff --git a/AlphaGo/preprocessing/generate_value_training.py b/AlphaGo/preprocessing/generate_value_training.py index 9dcf15f09..06ca8ab4c 100644 --- a/AlphaGo/preprocessing/generate_value_training.py +++ b/AlphaGo/preprocessing/generate_value_training.py @@ -292,7 +292,7 @@ def handle_arguments(cmd_line_args=None): else: features = args.features.split(",") - # always add colour feature + # always add color feature if "color" not in features: features.append("color") diff --git a/AlphaGo/preprocessing/preprocessing.pxd b/AlphaGo/preprocessing/preprocessing.pxd index 98e14cfc4..1e727db40 100644 --- a/AlphaGo/preprocessing/preprocessing.pxd +++ b/AlphaGo/preprocessing/preprocessing.pxd @@ -138,7 +138,7 @@ cdef class Preprocess: """Plane filled with ones """ - cdef int colour(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + cdef int color(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 """Value net feature, plane with ones if active_player is black else zeros """ @@ -168,7 +168,7 @@ cdef class Preprocess: it is unclear if a max size of the captured group has to be considered and how recent the capture event should have been - the 12d pattern can be encoded without stone colour and liberty count + the 12d pattern can be encoded without stone color and liberty count unclear if a border location should be considered a stone or liberty pattern lookup value is being set instead of 1 @@ -179,7 +179,7 @@ cdef class Preprocess: it is unclear if a max size of the captured group has to be considered and how recent the capture event should have been - the 12d pattern can be encoded without stone colour and liberty count + the 12d pattern can be encoded without stone color and liberty count unclear if a border location should be considered a stone or liberty #pattern_id is offset diff --git a/AlphaGo/preprocessing/preprocessing.pyx b/AlphaGo/preprocessing/preprocessing.pyx index da149ba94..131799180 100644 --- a/AlphaGo/preprocessing/preprocessing.pyx +++ b/AlphaGo/preprocessing/preprocessing.pyx @@ -65,9 +65,9 @@ cdef class Preprocess: # Get color of stone from its group group = state.board_groups[location] - if group.colour == _EMPTY: + if group.color == _EMPTY: plane = offset + 2 - elif group.colour == opponent: + elif group.color == opponent: plane = offset + 1 else: plane = offset @@ -92,7 +92,7 @@ cdef class Preprocess: # set all stones to max age for i in range(history.count): location = history.locations[i] - if location != _PASS and state.board_groups[location].colour > _EMPTY: + if location != _PASS and state.board_groups[location].color > _EMPTY: tensor[age, location] = 1 # start with newest stone @@ -104,7 +104,7 @@ cdef class Preprocess: location = history.locations[i] # if age has not been set yet if location != _PASS and location not in agesSet and \ - state.board_groups[location].colour > _EMPTY: + state.board_groups[location].color > _EMPTY: tensor[offset + age, location] = 1 tensor[offset + 7, location] = 0 agesSet[location] = location @@ -133,7 +133,7 @@ cdef class Preprocess: # Get liberty count from group structure directly group = state.board_groups[location] - if group.colour > _EMPTY: + if group.color > _EMPTY: groupLiberty = min(group.count_liberty - 1, 7) tensor[offset + groupLiberty, location] = 1 @@ -258,7 +258,7 @@ cdef class Preprocess: group = state.groups_list.board_groups[i] # if group is current player - if group.colour == state.player_current: + if group.color == state.player_current: # loop over liberties because they are possible eyes for location in range(self.board_size): @@ -307,7 +307,7 @@ cdef class Preprocess: # check if last move is not _PASS if last_move != _PASS: - # get 12d pattern hash of last move location and colour + # get 12d pattern hash of last move location and color hash_base = state.get_hash_12d(last_move) # calculate last_move x and y @@ -323,7 +323,7 @@ cdef class Preprocess: location = neighbor12d[last_move + i] # check if location is empty - if state.board_groups[location].colour == _EMPTY: + if state.board_groups[location].color == _EMPTY: # calculate location x and y location_x = (location / state.size) - last_move_x location_y = (location % state.size) - last_move_y @@ -386,7 +386,7 @@ cdef class Preprocess: location = neighbor3x3[last_move + i] # check if location is empty - if state.board_groups[location].colour == _EMPTY: + if state.board_groups[location].color == _EMPTY: tensor[plane, location] = 1 # diagonal neighbor plane is plane offset + 1 @@ -399,7 +399,7 @@ cdef class Preprocess: location = neighbor3x3[last_move + i] # check if location is empty - if state.board_groups[location].colour == _EMPTY: + if state.board_groups[location].color == _EMPTY: tensor[plane, location] = 1 return offset + 2 @@ -409,7 +409,7 @@ cdef class Preprocess: it is unclear if a max size of the captured group has to be considered and how recent the capture event should have been - the 12d pattern can be encoded without stone colour and liberty count + the 12d pattern can be encoded without stone color and liberty count unclear if a border location should be considered a stone or liberty pattern lookup value is being set instead of 1 @@ -423,7 +423,7 @@ cdef class Preprocess: it is unclear if a max size of the captured group has to be considered and how recent the capture event should have been - the 12d pattern can be encoded without stone colour and liberty count + the 12d pattern can be encoded without stone color and liberty count unclear if a border location should be considered a stone or liberty #pattern_id is offset @@ -446,7 +446,7 @@ cdef class Preprocess: pattern match #pattern_id is offset - base hash is 12d pattern hash of last move location + colour + base hash is 12d pattern hash of last move location + color add relative position of every empty location in a 12d shape to get 12d response pattern hash @@ -471,7 +471,7 @@ cdef class Preprocess: # check if last move is not _PASS if last_move != _PASS: - # get 12d pattern hash of last move location and colour + # get 12d pattern hash of last move location and color hash_base = state.get_hash_12d(last_move) # calculate last_move x and y @@ -488,7 +488,7 @@ cdef class Preprocess: location = neighbor12d[last_move + i] # check if location is empty - if state.board_groups[location].colour == _EMPTY: + if state.board_groups[location].color == _EMPTY: # calculate location x and y location_x = (location / state.size) - last_move_x @@ -560,7 +560,7 @@ cdef class Preprocess: return offset + 1 - cdef int colour(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + cdef int color(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 """Value net feature, plane with ones if active_player is black else zeros """ @@ -716,7 +716,7 @@ cdef class Preprocess: self.output_dim += self.pattern_non_response_3x3_size elif feat == "color": - processor = self.colour + processor = self.color self.output_dim += 1 elif feat == "ko": From a39cac2ccc390768236e8720e2b8ca5ad933056b Mon Sep 17 00:00:00 2001 From: wrongu Date: Tue, 7 Nov 2017 09:00:22 -0500 Subject: [PATCH 180/191] gtp_wrapper python 3 compatible (removed raw_input) --- interface/gtp_wrapper.py | 3 ++- requirements.txt | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/interface/gtp_wrapper.py b/interface/gtp_wrapper.py index 57f9e9879..cd52c788d 100644 --- a/interface/gtp_wrapper.py +++ b/interface/gtp_wrapper.py @@ -3,6 +3,7 @@ import gtp from AlphaGo import go from AlphaGo.util import save_gamestate_to_sgf +from builtins import input def run_gnugo(sgf_file_name, command): @@ -136,7 +137,7 @@ def run_gtp(player_obj, inpt_fn=None, name="Gtp Player", version="0.0"): gtp_game = GTPGameConnector(player_obj) gtp_engine = ExtendedGtpEngine(gtp_game, name, version) if inpt_fn is None: - inpt_fn = raw_input + inpt_fn = input sys.stderr.write("GTP engine ready\n") sys.stderr.flush() diff --git a/requirements.txt b/requirements.txt index f08033af6..004545d4a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ Cython==0.24 +future==0.16.0 h5py==2.6.0 Keras==1.2.0 numpy==1.11.2 From 1da585c1939dffcdf4e45d820bebd32f0c723fbb Mon Sep 17 00:00:00 2001 From: wrongu Date: Mon, 1 Jan 2018 15:13:09 -0500 Subject: [PATCH 181/191] Major refactor and C++ redesign of AlphaGo.go module. - AlphaGo/go/ directory contains modules: - constants: just enum constants - coordinates: helper functions for working with tuple/short coordinates, and pattern functions - game_state: contains GameState class - group_logic: contains Groups class and associated functions (todo: move Groups to pure C++ with instance methods?) - ladders: contains all ladders logic - zobrist: contains all zobrist hashing logic - all memory management is done c++ style; removed all malloc() and free() calls - group objects as a cppclass with unordered_map for locations for fast iteration - shared_ptr wrapping all group objects - lots of *_t typedefs for consistent readable names - removed pattern features from preprocessing (todo: add them back in) - moved 'groups_after' logic to inside preprocessing --- AlphaGo/ai.py | 30 +- AlphaGo/go/__init__.py | 9 + AlphaGo/go/constants.pxd | 28 + AlphaGo/go/constants.pyx | 1 + AlphaGo/go/coordinates.pxd | 111 + AlphaGo/go/coordinates.pyx | 205 ++ AlphaGo/go/game_state.pxd | 352 +-- AlphaGo/go/game_state.pyx | 2672 +++++------------ AlphaGo/go/go_data.pxd | 320 -- AlphaGo/go/go_data.pyx | 611 ---- AlphaGo/go/group_logic.pxd | 82 + AlphaGo/go/group_logic.pyx | 138 + AlphaGo/go/ladders.pxd | 57 + AlphaGo/go/ladders.pyx | 172 ++ AlphaGo/go/zobrist.pxd | 24 + AlphaGo/go/zobrist.pyx | 46 + AlphaGo/go_python.py | 2 +- AlphaGo/mcts.py | 6 +- .../preprocessing/generate_value_training.py | 4 +- AlphaGo/preprocessing/preprocessing.pxd | 181 +- AlphaGo/preprocessing/preprocessing.pyx | 785 ++--- .../training/reinforcement_policy_trainer.py | 4 +- AlphaGo/util.py | 11 +- setup.py | 10 +- tests/test_gamestate.py | 33 +- tests/test_ladders.py | 60 +- tests/test_preprocessing.py | 98 +- tests/test_reinforcement_policy_trainer.py | 2 +- 28 files changed, 2247 insertions(+), 3807 deletions(-) create mode 100644 AlphaGo/go/__init__.py create mode 100644 AlphaGo/go/constants.pxd create mode 100644 AlphaGo/go/constants.pyx create mode 100644 AlphaGo/go/coordinates.pxd create mode 100644 AlphaGo/go/coordinates.pyx delete mode 100644 AlphaGo/go/go_data.pxd delete mode 100644 AlphaGo/go/go_data.pyx create mode 100644 AlphaGo/go/group_logic.pxd create mode 100644 AlphaGo/go/group_logic.pyx create mode 100644 AlphaGo/go/ladders.pxd create mode 100644 AlphaGo/go/ladders.pyx create mode 100644 AlphaGo/go/zobrist.pxd create mode 100644 AlphaGo/go/zobrist.pyx diff --git a/AlphaGo/ai.py b/AlphaGo/ai.py index b66d0abb9..b0d8e7d67 100644 --- a/AlphaGo/ai.py +++ b/AlphaGo/ai.py @@ -17,12 +17,12 @@ def __init__(self, policy_function, pass_when_offered=False, move_limit=None): def get_move(self, state): # check move limit - if self.move_limit is not None and state.get_history_size() > self.move_limit: + if self.move_limit is not None and len(state.get_history()) > self.move_limit: return go.PASS # check if pass was offered and we want to pass if self.pass_when_offered: - if state.get_history_size() > 100 and state.get_history()[-1] == go.PASS: + if len(state.get_history()) > 100 and state.get_history()[-1] == go.PASS: return go.PASS # list with sensible moves @@ -67,12 +67,12 @@ def apply_temperature(self, distribution): def get_move(self, state): # check move limit - if self.move_limit is not None and state.get_history_size() > self.move_limit: + if self.move_limit is not None and len(state.get_history()) > self.move_limit: return go.PASS # check if pass was offered and we want to pass if self.pass_when_offered: - if state.get_history_size() > 100 and state.get_history()[-1] == go.PASS: + if len(state.get_history()) > 100 and state.get_history()[-1] == go.PASS: return go.PASS # list with 'sensible' moves @@ -83,7 +83,7 @@ def get_move(self, state): move_probs = self.policy.eval_state(state, sensible_moves) - if self.greedy_start is not None and state.get_history_size() >= self.greedy_start: + if self.greedy_start is not None and len(state.get_history()) >= self.greedy_start: # greedy max_prob = max(move_probs, key=itemgetter(1)) @@ -112,11 +112,11 @@ def get_moves(self, states): all_moves_distributions = self.policy.batch_eval_state(states, sensible_move_lists) move_list = [None] * len(states) for i, move_probs in enumerate(all_moves_distributions): - if len(move_probs) == 0 or states[i].get_history_size() > self.move_limit: + if len(move_probs) == 0 or len(states[i].get_history()) > self.move_limit: move_list[i] = go.PASS else: if self.greedy_start is not None and \ - states[i].get_history_size() >= self.greedy_start: + len(states[i].get_history()) >= self.greedy_start: # greedy max_prob = max(move_probs, key=itemgetter(1)) @@ -163,12 +163,12 @@ def apply_temperature(self, distribution): def get_move(self, state): # check move limit - if self.move_limit is not None and state.get_history_size() > self.move_limit: + if self.move_limit is not None and len(state.get_history()) > self.move_limit: return go.PASS # check if pass was offered and we want to pass if self.pass_when_offered: - if state.get_history_size() > 100 and state.get_history()[-1] == go.PASS: + if len(state.get_history()) > 100 and state.get_history()[-1] == go.PASS: return go.PASS # list with 'sensible' moves @@ -180,7 +180,7 @@ def get_move(self, state): move_probs = self.policy.eval_state(state, sensible_moves) if self.greedy_start is not None and \ - state.get_history_size() >= self.greedy_start: + len(state.get_history()) >= self.greedy_start: # greedy max_prob = max(move_probs, key=itemgetter(1)) @@ -209,11 +209,11 @@ def get_moves(self, states): all_moves_distributions = self.policy.batch_eval_state(states, sensible_move_lists) move_list = [None] * len(states) for i, move_probs in enumerate(all_moves_distributions): - if len(move_probs) == 0 or states[i].get_history_size() > self.move_limit: + if len(move_probs) == 0 or len(states[i].get_history()) > self.move_limit: move_list[i] = go.PASS else: if self.greedy_start is not None and \ - states[i].get_history_size() >= self.greedy_start: + len(states[i].get_history()) >= self.greedy_start: # greedy max_prob = max(move_probs, key=itemgetter(1)) @@ -263,12 +263,12 @@ def apply_temperature(self, distribution): def get_move(self, state): # check move limit - if self.move_limit is not None and state.get_history_size() > self.move_limit: + if self.move_limit is not None and len(state.get_history()) > self.move_limit: return go.PASS # check if pass was offered and we want to pass if self.pass_when_offered: - if state.get_history_size() > 100 and state.get_history()[-1] == go.PASS: + if len(state.get_history()) > 100 and state.get_history()[-1] == go.PASS: return go.PASS # list with 'sensible' moves @@ -287,7 +287,7 @@ def get_move(self, state): # evaluate all possble states probabilities = [self.value.eval_state(next_state) for next_state in state_list] - if self.greedy_start is not None and state.get_history_size() >= self.greedy_start: + if self.greedy_start is not None and len(state.get_history()) >= self.greedy_start: # greedy play move_probs = zip(legal_moves, probabilities) diff --git a/AlphaGo/go/__init__.py b/AlphaGo/go/__init__.py new file mode 100644 index 000000000..65536d25b --- /dev/null +++ b/AlphaGo/go/__init__.py @@ -0,0 +1,9 @@ +from .game_state import GameState, IllegalMove + +# Expose constants to python (copied from AlphaGo/go/constants.pxd) +PASS = None +EMPTY = 2 +WHITE = 3 +BLACK = 4 + +__all__ = ['GameState', 'IllegalMove', 'PASS', 'BLACK', 'WHITE', 'EMPTY'] diff --git a/AlphaGo/go/constants.pxd b/AlphaGo/go/constants.pxd new file mode 100644 index 000000000..c4d6672cf --- /dev/null +++ b/AlphaGo/go/constants.pxd @@ -0,0 +1,28 @@ +############################################################################ +# Global constants # +# # +############################################################################ + +cdef enum action_t: + PASS = -1 + PLAY = +1 + + +# observe: stones > _EMPTY +# border < _EMPTY +# be aware you should NOT use != _EMPTY as this includes border locations +cdef enum stone_t: + BORDER = 1 + EMPTY = 2 + WHITE = 3 + BLACK = 4 + + +# Used for group stone, liberty locations, legal move and sensible move in feature processing. +cdef enum group_t: + STONE = 0 + LIBERTY = 1 + CAPTURE = 2 + FREE = 3 + LEGAL = 4 + EYE = 5 diff --git a/AlphaGo/go/constants.pyx b/AlphaGo/go/constants.pyx new file mode 100644 index 000000000..bf725249f --- /dev/null +++ b/AlphaGo/go/constants.pyx @@ -0,0 +1 @@ +# Nothing to do; all declarations are in constants.pxd diff --git a/AlphaGo/go/coordinates.pxd b/AlphaGo/go/coordinates.pxd new file mode 100644 index 000000000..54e1229b1 --- /dev/null +++ b/AlphaGo/go/coordinates.pxd @@ -0,0 +1,111 @@ +from AlphaGo.go.group_logic cimport Group +from libcpp cimport bool +from libcpp.vector cimport vector +from libcpp.memory cimport shared_ptr +import numpy as np +cimport numpy as np + + +############################################################################ +# Typedefs # +# # +############################################################################ + +ctypedef short location_t +ctypedef unsigned long pattern_hash_t +ctypedef vector[location_t] pattern_t # lookup of neighbor coordinates (or border) +ctypedef shared_ptr[Group] group_ptr_t # smart pointer with reference counting wrapping a 'Group' +ctypedef vector[group_ptr_t] board_group_t # type for group-lookup by board position + + +############################################################################ +# Helper functions to switch between types of coordinates # +# # +############################################################################ + +cdef location_t calculate_board_location(location_t x, location_t y, location_t size) +"""Return location on board + no checks on outside board + x = columns + y = rows +""" + +cdef location_t calculate_board_location_or_border(location_t x, location_t y, location_t size) +"""Return location on board or borderlocation + board locations = [ 0, size * size) + border location = size * size + x = columns + y = rows +""" + +cdef tuple calculate_tuple_location(location_t index, location_t size) +"""1d index to 2d tuple location. Inverse of calculate_board_location() + + No sanity checks on bounds. +""" + +############################################################################ +# Neighbor/pattern lookup table creation functions # +# # +############################################################################ + +cdef pattern_hash_t get_pattern_hash(board_group_t &board, location_t center, int pattern_size, pattern_t &pattern_lookup, int max_liberty=*) # noqa:E501 +"""Given a neighbor/pattern lookup table, computes a hash of the pattern around a location, + treating each color + liberty combination as a unique value (up to max_liberty). +""" + +cdef pattern_t get_neighbors(location_t size) +"""Create array for every board location with all 4 direct neighbor locations + neighbor order: left - right - above - below + + -1 x + x x + +1 x + + order: + -1 2 + 0 1 + +1 3 + + TODO neighbors is obsolete as neighbor3x3 contains the same values +""" + +cdef pattern_t get_3x3_neighbors(location_t size) +"""Create for every board location array with all 8 surrounding neighbor locations + neighbor order: above middle - middle left - middle right - below middle + above left - above right - below left - below right + this order is more useful as it separates neighbors and then diagonals + -1 xxx + x x + +1 xxx + + order: + -1 405 + 1 2 + +1 637 + + 0-3 contains neighbors + 4-7 contains diagonals +""" + +cdef pattern_t get_12d_neighbors(location_t size) +"""Create array for every board location with 12d star neighbor locations + neighbor order: top star tip + above left - above middle - above right + left star tip - left - right - right star tip + below left - below middle - below right + below star tip + + -2 x + -1 xxx + xx xx + +1 xxx + +2 x + + order: + -2 0 + -1 123 + 45 67 + +1 89a + +2 b +""" diff --git a/AlphaGo/go/coordinates.pyx b/AlphaGo/go/coordinates.pyx new file mode 100644 index 000000000..29e4e2838 --- /dev/null +++ b/AlphaGo/go/coordinates.pyx @@ -0,0 +1,205 @@ +from cython.operator cimport dereference as d + + +# Constant value used to generate pattern hashes +cdef int _HASHVALUE = 33 + + +############################################################################ +# Helper functions to switch between types of coordinates # +# # +############################################################################ + +cdef location_t calculate_board_location(location_t x, location_t y, location_t size): + """Return location on board + no checks on outside board + x = columns + y = rows + """ + + # Return board location + return x + (y * size) + + +cdef location_t calculate_board_location_or_border(location_t x, location_t y, location_t size): + """Return location on board or borderlocation + board locations = [0, size * size) + border location = size * size + x = columns + y = rows + """ + + # Check if x or y are outside board + if x < 0 or y < 0 or x >= size or y >= size: + # Return border location + return size * size + + # Return board location + return calculate_board_location(x, y, size) + + +cdef tuple calculate_tuple_location(location_t location, location_t size): + """1d index to 2d tuple location (x, y). Inverse of calculate_board_location() + + No sanity checks on bounds. + """ + return divmod(location, size) + +############################################################################ +# Neighbor/pattern lookup table creation functions # +# # +############################################################################ + +cdef pattern_hash_t get_pattern_hash(board_group_t &board, location_t center, int pattern_size, pattern_t &pattern_lookup, int max_liberty=3): # noqa:E501 + """Given a neighbor/pattern lookup table, computes a hash of the pattern around a location, + treating each color + liberty combination as a unique value (up to max_liberty). + """ + + cdef int i + cdef pattern_hash_t hsh = _HASHVALUE + cdef group_ptr_t group + + # Index into neighbor12d array is 12x index into board, for example. + center *= pattern_size + + # Hash color and liberty count of all locations in pattern around the center. + for i in range(pattern_size): + # Get group + group = board[pattern_lookup[center + i]] + + # Hash color + hsh += d(group).color + hsh *= _HASHVALUE + + # Hash liberty count (up to max value) + hsh += min(d(group).count_liberty, max_liberty) + hsh *= _HASHVALUE + + return hsh + +cdef pattern_t get_neighbors(location_t size): + """Create array for every board location with all 4 direct neighbor locations + neighbor order: left - right - above - below + + -1 x + x x + +1 x + + order: + -1 2 + 0 1 + +1 3 + + TODO neighbors is obsolete as neighbor3x3 contains the same values + """ + + # Initialize empty vector + cdef pattern_t neighbor = pattern_t(4 * size * size) + + cdef short location + cdef location_t x, y + + # Add all direct neighbors to every board location + for y in range(size): + for x in range(size): + location = 4 * calculate_board_location(x, y, size) + neighbor[location + 0] = calculate_board_location_or_border(x - 1, y, size) + neighbor[location + 1] = calculate_board_location_or_border(x + 1, y, size) + neighbor[location + 2] = calculate_board_location_or_border(x, y - 1, size) + neighbor[location + 3] = calculate_board_location_or_border(x, y + 1, size) + + return neighbor + + +cdef pattern_t get_3x3_neighbors(location_t size): + """Create for every board location array with all 8 surrounding neighbor locations + neighbor order: above middle - middle left - middle right - below middle + above left - above right - below left - below right + this order is more useful as it separates neighbors and then diagonals + -1 xxx + x x + +1 xxx + + order: + -1 405 + 1 2 + +1 637 + + 0-3 contains neighbors + 4-7 contains diagonals + """ + + # Initialize empty vector + cdef pattern_t neighbor3x3 = pattern_t(8 * size * size) + + cdef short location + cdef location_t x, y + + # Add all surrounding neighbors to every board location + for x in range(size): + for y in range(size): + location = 8 * calculate_board_location(x, y, size) + # Cardinal directions + neighbor3x3[location + 0] = calculate_board_location_or_border(x, y - 1, size) + neighbor3x3[location + 1] = calculate_board_location_or_border(x - 1, y, size) + neighbor3x3[location + 2] = calculate_board_location_or_border(x + 1, y, size) + neighbor3x3[location + 3] = calculate_board_location_or_border(x, y + 1, size) + # Diagonal directions + neighbor3x3[location + 4] = calculate_board_location_or_border(x - 1, y - 1, size) + neighbor3x3[location + 5] = calculate_board_location_or_border(x + 1, y - 1, size) + neighbor3x3[location + 6] = calculate_board_location_or_border(x - 1, y + 1, size) + neighbor3x3[location + 7] = calculate_board_location_or_border(x + 1, y + 1, size) + + return neighbor3x3 + + +cdef pattern_t get_12d_neighbors(location_t size): + """Create array for every board location with 12d star neighbor locations + neighbor order: top star tip + above left - above middle - above right + left star tip - left - right - right star tip + below left - below middle - below right + below star tip + + -2 x + -1 xxx + xx xx + +1 xxx + +2 x + + order: + -2 0 + -1 123 + 45 67 + +1 89a + +2 b + """ + + # Initialize empty vector + cdef pattern_t neighbor12d = pattern_t(12 * size * size) + + cdef short location + cdef location_t x, y + + # Add all 12d neighbors to every board location + for x in range(size): + for y in range(size): + location = 12 * calculate_board_location(x, y, size) + neighbor12d[location + 4] = calculate_board_location_or_border(x, y - 2, size) + + neighbor12d[location + 1] = calculate_board_location_or_border(x - 1, y - 1, size) + neighbor12d[location + 5] = calculate_board_location_or_border(x, y - 1, size) + neighbor12d[location + 8] = calculate_board_location_or_border(x + 1, y - 1, size) + + neighbor12d[location + 0] = calculate_board_location_or_border(x - 2, y, size) + neighbor12d[location + 2] = calculate_board_location_or_border(x - 1, y, size) + neighbor12d[location + 9] = calculate_board_location_or_border(x + 1, y, size) + neighbor12d[location + 11] = calculate_board_location_or_border(x + 2, y, size) + + neighbor12d[location + 3] = calculate_board_location_or_border(x - 1, y + 1, size) + neighbor12d[location + 6] = calculate_board_location_or_border(x, y + 1, size) + neighbor12d[location + 10] = calculate_board_location_or_border(x + 1, y + 1, size) + + neighbor12d[location + 7] = calculate_board_location_or_border(x, y + 2, size) + + return neighbor12d diff --git a/AlphaGo/go/game_state.pxd b/AlphaGo/go/game_state.pxd index eb93c593d..9d8dc3a6b 100644 --- a/AlphaGo/go/game_state.pxd +++ b/AlphaGo/go/game_state.pxd @@ -1,61 +1,84 @@ +from AlphaGo.go.constants cimport stone_t, group_t, action_t +from AlphaGo.go.coordinates cimport calculate_board_location, calculate_tuple_location, \ + get_pattern_hash, get_neighbors, get_3x3_neighbors, get_12d_neighbors +from AlphaGo.go.group_logic cimport Group, group_new, group_duplicate, group_add_stone, \ + group_merge, group_get_stone, group_add_liberty, group_remove_liberty +from AlphaGo.go.zobrist cimport get_zobrist_lookup, update_hash_by_location, update_hash_by_group +from libcpp cimport bool +from libcpp.vector cimport vector +from libcpp.memory cimport shared_ptr +from libcpp.unordered_set cimport unordered_set as cpp_set import numpy as np cimport numpy as np -from AlphaGo.go.go_data cimport * +############################################################################ +# Typedefs # +# # +############################################################################ + +ctypedef short location_t +ctypedef unsigned long pattern_hash_t +ctypedef unsigned long long zobrist_hash_t +ctypedef vector[location_t] pattern_t # lookup of neighbor coordinates (or border) +ctypedef shared_ptr[Group] group_ptr_t # smart pointer with reference counting wrapping a 'Group' +ctypedef cpp_set[group_ptr_t] group_set_t # type for unordered set of unique groups +ctypedef vector[group_ptr_t] board_group_t # type for group-lookup by board position + + +############################################################################ +# Class definition # +# # +############################################################################ + cdef class GameState: - ############################################################################ - # variables declarations # - # # - ############################################################################ + # Dimensions of one side of the board and total number of squares, respectively + cdef short size, board_size - # amount of locations on one side - cdef char size - # amount of locations on board, size * size - cdef short board_size + # Possible ko location + cdef location_t ko - # possible ko location - cdef short ko + # Unordered list of all groups of stones + cdef group_set_t groups_set - # list with all groups - cdef Groups_List *groups_list - # pointer to empty group - cdef Group *group_empty + # Lookup of a group from board location. Length is board size + 1 to include border + cdef board_group_t board - # list representing board locations as groups - # a Group contains all group stone locations and group liberty locations - cdef Group **board_groups + # Placeholder groups for referencing empty spaces or the border. + cdef group_ptr_t group_empty, group_border - cdef char player_current - cdef char player_opponent + # Current player and opponent, either _WHITE or _BLACK + cdef stone_t current_player, opponent_player - # amount of black stones captured - cdef short capture_black - # amount of white stones captured - cdef short capture_white + # Amount of black stones captured by white, and vice versa + cdef short capture_black, capture_white - # amount of passes by black - cdef short passes_black - # amount of passes by white - cdef short passes_white + # Amount of passes by black and by white, respectively + cdef short passes_black, passes_white - # list with move history - cdef Locations_List *moves_history + # List with move history + cdef vector[location_t] moves_history - # list with legal moves - cdef Locations_List *moves_legal + # Number of handicap stones placed by BLACK at the start of the game + cdef short num_handicap - # arrays, neighbor arrays pointers - cdef short *neighbor - cdef short *neighbor3x3 - cdef short *neighbor12d + # List with legal moves + cdef vector[location_t] legal_moves - # zobrist - cdef unsigned long long zobrist_current - cdef unsigned long long *zobrist_lookup + # Neighbors lookup tables. Pointers are to a single global instance of each variable shared by + # all instances of GameState. + cdef pattern_t* ptr_neighbor + cdef pattern_t* ptr_neighbor3x3 + cdef pattern_t* ptr_neighbor12d - cdef bint enforce_superko + # Zobrist hashing + cdef zobrist_hash_t zobrist_current + # Note: as with neighbor arrays, the zobrist lookup table is shared by all instances of + # GameState, so it is stored with a pointer to the global instance. + cdef vector[zobrist_hash_t]* ptr_zobrist_lookup + + cdef bool enforce_superko cdef set previous_hashes ############################################################################ @@ -63,11 +86,11 @@ cdef class GameState: # # ############################################################################ - cdef void initialize_new(self, char size, bint enforce_superko) + cdef void initialize_new(self, short size, bool enforce_superko) """Initialize this state as empty state """ - cdef void initialize_duplicate(self, GameState copyState) + cdef void initialize_duplicate(self, GameState copy_state) """Initialize all variables as a copy of copy_state """ @@ -76,225 +99,114 @@ cdef class GameState: # # ############################################################################ - cdef void update_hash(self, short location, char color) - """xor current hash with location + color action value - """ - - cdef bint is_positional_superko(self, short location, Group **board) + cdef bool is_positional_superko(self, location_t location) """Find all actions that the current_player has done in the past. - This takes into account the fact that history starts with BLACK when there are no handicaps or - with WHITE when there are. + This takes into account the fact that history starts with BLACK when there are no handicaps + or with WHITE when there are. """ - cdef bint is_legal_move(self, short location, Group **board, short ko) + cpdef bool is_legal_move(self, location_t location) """Check if playing at location is a legal move """ - cdef bint is_legal_move_superko(self, short location, Group **board, short ko) - """Check if playing at location is a legal move, taking superko into account - """ - - cdef bint has_liberty_after(self, short location, Group **board) + cdef bool has_liberty_after(self, location_t location) """Check if a play at location results in an alive group - True if any of the following is true: - - has liberty - - connects to group with >= 2 liberty - - captures enemy group - """ - - cdef short calculate_board_location(self, char x, char y) - """2D tuple location to 1d index. Inverse of calculate_tuple_location() - - No sanity checks on bounds. - - - x is column - - y is row + True if any of the following is true: + - has liberty + - connects to group with >= 2 liberty + - captures enemy group """ - cdef tuple calculate_tuple_location(self, short location) - """1d index to 2d tuple location. Inverse of calculate_board_location() - - No sanity checks on bounds. + cdef void update_legal_moves(self) + """Update legal_moves list """ - cdef void set_moves_legal_list(self, Locations_List *moves_legal) - """Generate moves_legal list + cdef void swap_players(self) + """Switch current_player and opponent_player """ - cdef void combine_groups(self, Group* group_keep, Group* group_remove, Group **board) - """Combine group_keep and group_remove and replace group_remove on the board - """ - - cdef void remove_group(self, Group* group_remove, Group **board, short* ko) - """Remove group from board -> set all locations to group_empty - """ - - cdef void add_to_group(self, short location, Group **board, short* ko, short* count_captures) - """Check if a stone on location is connected to a group, kills a group or is a new group on the - board + cpdef void set_current_player(self, stone_t color) + """Set current player to the given color. """ ############################################################################ - # private cdef functions used for feature generation # + # private cdef helper functions for feature generation # # # ############################################################################ - cdef long generate_12d_hash(self, short centre) - """Generate 12d hash around centre location - """ - - cdef long generate_3x3_hash(self, short centre) - """Generate 3x3 hash around centre location - """ - - cdef void get_group_after_pointer(self, short* stones, short* liberty, short* capture, char* locations, char* captures, short location) # noqa: E501 - cdef void get_group_after(self, char* groups_after, char* locations, char* captures, short location) # noqa: E501 - """Groups_after is a board_size * 3 array representing STONES, LIBERTY, CAPTURE for every location - - calculate group after a play on location and set - groups_after[location * 3 + ] to stone count - groups_after[location * 3 + 1] to liberty count - groups_after[location * 3 + 2] to capture count + cdef bool is_eyeish(self, location_t location, stone_t owner) + """Check if a location is 'eyeish'; that is, check that all 4 neighbors are either border + or the same color. """ - cdef bint is_true_eye(self, short location, Locations_List* eyes, char owner) - """Check if location is a real eye + cdef bool is_true_eye(self, location_t location, stone_t owner, list stack=*) + """Check if location is a 'real' eye; this goes beyond checking if a location is 'eyeish' by + checking that corners have the same owner or (recursively) are themselves eyes. """ ############################################################################ - # private cdef Ladder functions # + # public cdef functions used by preprocessing # # # ############################################################################ - """Ladder evaluation consumes a lot of time duplicating data, the original - version (still can be found in go_python.py) made a copy of the whole - GameState for every move played. - - This version only duplicates self.board_groups (so the list with pointers to groups) - the add_ladder_move playes a move like the add_to_group function but it - does not change the original groups and creates a list with groups removed - - with this groups removed list undo_ladder_move will return the board state to - be the same as before add_ladder_move was called - - get_removed_groups and unremove_group are being used my add/undo_ladder_move - - nb. - duplicating self.board_groups is not neccisary stricktly speaking but - it is safer to do so in a threaded environment. as soon as mcts is - implemented this duplication could be removed if the mcts ensures a - GameState is not accesed while preforming a ladder evaluation - - TODO validate no changes are being made! - - TODO self.player color is used, should become a pointer + cdef pattern_hash_t get_12d_hash(self, location_t center, bool include_player, int max_liberty=*) # noqa:E501 + """Get unique-ish hash of the 12-stone pattern centered at 'center'. Assumes 'center' + itself is EMPTY. If 'include_player' is True, hash also takes into account who is the + current player. """ - cdef Groups_List* add_ladder_move(self, short location, Group **board, short* ko) - """Create a new group for location move and add all connected groups to it - - similar to add_to_group except no groups are changed or killed and a list - with groups removed is returned so the board can be restored to original - position - """ - - cdef void remove_ladder_group(self, Group* group_remove, Group **board, short* ko) - """Remove group from board -> set all locations to group_empty - does not update zobrist hash - """ - - cdef void undo_ladder_move(self, short location, Groups_List* removed_groups, short ko, Group **board, short* ko) # noqa: E501 - """Use removed_groups list to return board state to be the same as before - add_ladder_move was used - """ - - cdef void unremove_group(self, Group* group_remove, Group **board) - """Unremove group from board - loop over all stones in this group and set board to group_unremove - remove liberty from neigbor locations - """ - - cdef dict get_capture_moves(self, Group* group, char color, Group **board) - """Create a dict with al moves that capture a group surrounding group - """ - - cdef void get_removed_groups(self, short location, Groups_List* removed_groups, Group **board, short* ko) # noqa: E501 - """Create a new group for location move and add all connected groups to it - - similar to add_to_group except no groups are changed or killed - all changes to the board are stored in removed_groups + cdef pattern_hash_t get_3x3_hash(self, short center, bool include_player, int max_liberty=*) + """Get unique-ish hash of the 8-stone pattern centered at 'center'. Assumes 'center' itself + is EMPTY. If 'include_player' is True, hash also takes into account who is the current + player. """ - cdef bint is_ladder_escape_move(self, Group **board, short* ko, Locations_List *list_ko, short location_group, dict capture, short location, int maxDepth, char color_group, char color_chase) # noqa: E501 - """Play a ladder move on location, check if group has escaped, - if the group has 2 liberty it is undetermined -> - try to capture it by playing at both liberty - """ - - cdef bint is_ladder_capture_move(self, Group **board, short* ko, Locations_List *list_ko, short location_group, dict capture, short location, int maxDepth, char color_group, char color_chase) # noqa: E501 - """Play a ladder move on location, try capture and escape moves - and see if the group is able to escape ladder + cdef vector[location_t] get_sensible_moves(self) + """'Sensible' moves are all legal moves that are not eyes of the current player. """ ############################################################################ - # public cdef functions used by preprocessing # + # Helper functions for managing groups # # # ############################################################################ - cdef char* get_groups_after(self) - """Return a short array of size board_size * 3 representing - STONES, LIBERTY, CAPTURE for every board location - - max count values are 100 + cdef group_ptr_t combine_groups(self, group_ptr_t group_keep, group_ptr_t group_remove) + """Combine group_keep and group_remove by copying all stones and liberties from + group_remove into group_keep, and update pointers on the board. - loop over all legal moves and determine stone count, liberty count and - capture count of a play on that location + Returns group_keep """ - cdef long get_hash_12d(self, short centre) - """Return hash for 12d star pattern around location - """ - - cdef long get_hash_3x3(self, short location) - """Return 3x3 pattern hash + current player - """ - - cdef char* get_ladder_escapes(self, int maxDepth) - """Return char array with size board_size - every location represents a location on the board where: - _FREE = no ladder escape - _STONE = ladder escape - """ - - cdef char* get_ladder_captures(self, int maxDepth) - """Return char array with size board_size - every location represents a location on the board where: - _FREE = no ladder capture - _STONE = ladder capture + cdef void remove_group(self, group_ptr_t group_remove) + """Remove group from everywhere in the state. """ ############################################################################ - # public cdef functions used for game play # + # public cpdef functions used for game play # # # ############################################################################ - cdef void add_move(self, short location) - """!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - Move should be legal! - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + cpdef void print_groups(self) + """Debugging helper: prints address of all groups in this state and info about each one. + """ - play move on location, move should be legal! + cpdef bool sanity_check_groups(self) + """Debugging helper: loops over every location and group on the board and checks that they are + self-consistent. + """ - update player_current, history and moves_legal + cdef location_t add_stone(self, location_t location) + """Play stone on location and update the state. MOVE MUST BE LEGAL. Returns new ko location, + or -1 if there is none. """ - cdef GameState new_state_add_move(self, short location) - """Copy this gamestate and play move at location + cpdef list get_legal_moves(self, bool include_eyes=*) + """Return a list with all legal moves (in/excluding eyes) """ - cdef float get_score(self, float komi) + cpdef float get_score(self, float komi=*) """Calculate score of board state. Uses 'Area scoring'. http://senseis.xmp.net/?Passing#1 @@ -303,18 +215,36 @@ cdef class GameState: positive value indicates white win """ - cdef char get_winner_color(self, float komi) + cpdef stone_t get_winner_color(self, float komi=*) """Calculate score of board state and return player ID (1, -1, or 0 for tie) corresponding to winner. Uses 'Area scoring'. http://senseis.xmp.net/?Passing#1 """ + cpdef void do_move(self, tuple action, stone_t color=*) + """Play stone at action=(x,y). Use action=_PASS (-1) to pass. Checks move legality first. + + If it is a legal move, current_player switches to the opposite color. If not, an + IllegalMove exception is raised + """ + + cpdef void place_handicap_stone(self, tuple action, stone_t color=*) + """Add handicap stones given by a list of tuples in list handicap + """ + + cpdef void place_handicaps(self, list handicap) + """Place list of handicap stones for BLACK (must be on empty board) + """ + ############################################################################ - # public def functions used for game play (Python) # + # Helper functions for managing groups # # # ############################################################################ - cdef Locations_List* get_sensible_moves(self) - """Only used for def get_legal_moves + cdef group_ptr_t combine_groups(self, group_ptr_t group_keep, group_ptr_t group_remove) + """Combine group_keep and group_remove by copying all stones and liberties from + group_remove into group_keep, and update pointers on the board. + + Returns group_keep """ diff --git a/AlphaGo/go/game_state.pyx b/AlphaGo/go/game_state.pyx index 597bf00d7..04e516355 100644 --- a/AlphaGo/go/game_state.pyx +++ b/AlphaGo/go/game_state.pyx @@ -2,1831 +2,638 @@ # cython: boundscheck=False # cython: initializedcheck=False # cython: nonecheck=False -cimport cython +from cython.operator cimport dereference as d import numpy as np cimport numpy as np -from libc.stdlib cimport malloc, free -from libc.string cimport memcpy, memset -# global empty group -cdef Group *group_empty -# global border group -cdef Group *group_border +############################################################################ +# Global variables shared by instances of GameState # +# # +############################################################################ -# global arrays, neighbor arrays pointers -cdef short *neighbor -cdef short *neighbor3x3 -cdef short *neighbor12d -cdef char neighbor_size +# Global arrays (lookup tables) for neighbor indices +cdef pattern_t neighbor +cdef pattern_t neighbor3x3 +cdef pattern_t neighbor12d +cdef short neighbor_size -# global array for zobrist lookup -cdef unsigned long long *zobrist_lookup +# Global array for zobrist lookup +cdef vector[zobrist_hash_t] zobrist_lookup -cdef class GameState: - - ############################################################################ - # all variables are declared in the .pxd file # - # # - ############################################################################ - - """ - # amount of locations on one side - cdef char size - # amount of locations on board, size * size - cdef short board_size - - # possible ko location - cdef short ko - - # list with all groups - cdef Groups_List *groups_list - # pointer to empty group - cdef Group *group_empty - - # list representing board locations as groups - # a Group contains all group stone locations and group liberty locations - cdef Group **board_groups - - cdef char player_current - cdef char player_opponent - - # amount of black stones captured - cdef short capture_black - # amount of white stones captured - cdef short capture_white - - # amount of passes by black - cdef short passes_black - # amount of passes by white - cdef short passes_white - - # list with move history - cdef Locations_List *moves_history - - # list with legal moves - cdef Locations_List *moves_legal - - # arrays, neighbor arrays pointers - cdef short *neighbor - cdef short *neighbor3x3 - cdef short *neighbor12d - - # zobrist - cdef unsigned long long zobrist_current - cdef unsigned long long *zobrist_lookup - - cdef bint enforce_superko - cdef set previous_hashes - """ - - ############################################################################ - # init functions # - # # - ############################################################################ - - cdef void initialize_new(self, char size, bint enforce_superko): - """Initialize this state as empty state - """ - - cdef short i - - # set pointer to neighbor locations - # neighbor, neighbor3x3, neighbor12d and zobrist_lookup are global - self.neighbor = neighbor - self.neighbor3x3 = neighbor3x3 - self.neighbor12d = neighbor12d - self.zobrist_lookup = zobrist_lookup - - # initialize size and board_size - self.size = size - self.board_size = size * size - - # create history list - self.moves_history = locations_list_new(10) - - # initialize player colors - self.player_current = _BLACK - self.player_opponent = _WHITE - - self.ko = _PASS - self.capture_black = 0 - self.capture_white = 0 - self.passes_black = 0 - self.passes_white = 0 - - # create arrays and lists - # +1 on board_size is used as an border location used for all borders - - # create Group pointer array (Group **) - # this array represent the board, every group contains color, stone-locations - # and liberty locations - # border location is included, therefore the array size is board_size +1 - self.board_groups = malloc((self.board_size + 1) * sizeof(Group*)) - if not self.board_groups: - raise MemoryError() - - # create Locations_List as legal_moves - # after every move this list will be updated to contain all legal moves - # max amount of legal moves is board_size - self.moves_legal = locations_list_new(self.board_size) - - # create groups_list as groups_list - # this list will contain all alive groups - # we do not need to set the theoretical max amount of groups as the list - # will be incremented in group_list_add - self.groups_list = groups_list_new(self.board_size) - - # initialize board, set all locations to group empty and add all - # locations as move_legal - for i in range(self.board_size): - self.board_groups[i] = group_empty - self.moves_legal.locations[i] = i - - # on an empty board board_size == amount of legal moves - # set the moves_legal count to board_size - self.moves_legal.count = self.board_size - - # initialize border location to group_border - self.board_groups[self.board_size] = group_border - - # set zobrist - self.previous_hashes = set() - self.zobrist_current = 0 - self.enforce_superko = enforce_superko - - cdef void initialize_duplicate(self, GameState copy_state): - """Initialize all variables as a copy of copy_state - """ - - cdef int i - cdef short location - cdef Group* group_pointer - cdef Group* group - - # neighbor, neighbor3x3, neighbor12d and zobrist_lookup are global - self.neighbor = neighbor - self.neighbor3x3 = neighbor3x3 - self.neighbor12d = neighbor12d - self.zobrist_lookup = zobrist_lookup - - # !!! deep copy !!! - - # set all values - self.ko = copy_state.ko - self.capture_black = copy_state.capture_black - self.capture_white = copy_state.capture_white - self.passes_black = copy_state.passes_black - self.passes_white = copy_state.passes_white - self.size = copy_state.size - self.board_size = copy_state.board_size - self.player_current = copy_state.player_current - self.player_opponent = copy_state.player_opponent - self.zobrist_current = copy_state.zobrist_current - self.enforce_superko = copy_state.enforce_superko - self.previous_hashes = copy_state.previous_hashes.copy() - - # create history list - self.moves_history = locations_list_new(copy_state.moves_history.size) - self.moves_history.count = copy_state.moves_history.count - # copy all history moves in copy_state - memcpy(self.moves_history.locations, copy_state.moves_history.locations, - copy_state.moves_history.count * sizeof(short)) - - # create Locations_List as legal_moves - # after every move this list will be updated to contain all legal moves - # max amount of legal moves is board_size - self.moves_legal = locations_list_new(self.board_size) - self.moves_legal.count = copy_state.moves_legal.count - # copy all legal moves from copy_state - memcpy(self.moves_legal.locations, copy_state.moves_legal.locations, - copy_state.moves_legal.count * sizeof(short)) - - # create groups_list as groups_list - # this list will contain all alive groups - # we do not need to set the theoretical max amount of groups as the list - # will be incremented in group_list_add - self.groups_list = groups_list_new(self.board_size) - - # create Group pointer array (Group **) - # this array represent the board, every group contains color, stone-locations - # and liberty locations - # border location is included, therefore the array size is board_size +1 - self.board_groups = malloc((self.board_size + 1) * sizeof(Group*)) - if not self.board_groups: - raise MemoryError() - - # copy all group pointers from copy_state - # all Groups will be duplicated and overwritten but all group_empty pointers stay the same - memcpy(self.board_groups, copy_state.board_groups, (self.board_size + 1) * sizeof(Group*)) - - # loop over all groups in copy_state.groups_list - # duplicate them and set all Group pointers of this groups stone-locations - # to the new group - for i in range(copy_state.groups_list.count_groups): - # get group - group = copy_state.groups_list.board_groups[i] - - # duplicate group - group_pointer = group_duplicate(group, self.board_size) - - # add new group to groups_list - groups_list_add(group_pointer, self.groups_list) - - # loop over all group locations - for location in range(self.board_size): - # if group has a stone on this location, set board_groups group pointer - if group.locations[location] == _STONE: - self.board_groups[location] = group_pointer - - def __init__(self, char size=19, GameState copy=None, enforce_superko=True): - """Create new instance of GameState - """ - - if copy is None: - if neighbor_size == 0 or neighbor_size != size: - # This is the first GameState object - initialize global variables - global neighbor - global neighbor3x3 - global neighbor12d - global zobrist_lookup - global neighbor_size - global group_empty - global group_border - - neighbor = get_neighbors(size) - neighbor3x3 = get_3x3_neighbors(size) - neighbor12d = get_12d_neighbors(size) - zobrist_lookup = get_zobrist_lookup(size) - - # Set global size - neighbor_size = size - - # initialize _EMPTY and _BORDER group - group_empty = group_new(_EMPTY, self.board_size) - group_border = group_new(_BORDER, self.board_size) - - # elif neighbor_size != size: - # raise ValueError("Due to global variables, all GameState objects must have the " - # "same board size.") - - # Initialize state of this object - self.initialize_new(size, enforce_superko) - - else: - # create copy of given state - self.initialize_duplicate(copy) - - def __dealloc__(self): - """This function is called when this object is destroyed - - Prevent memory leaks by freeing all arrays created with malloc. Global objects should not - be destroyed - """ - - cdef int i - - # free board_groups - if self.board_groups is not NULL: - - free(self.board_groups) - - # free history - locations_list_destroy(self.moves_history) - - # free moves_legal and moves_legal.locations - if self.moves_legal is not NULL: - - if self.moves_legal.locations is not NULL: - - free(self.moves_legal.locations) - - free(self.moves_legal) - - # free groups_list all groups in groups_list.board_groups and groups_list.board_groups - if self.groups_list is not NULL: - # loop over all groups and free them - for i in range(self.groups_list.count_groups): - group_destroy(self.groups_list.board_groups[i]) - - # free groups_list.board_groups - if self.groups_list.board_groups is not NULL: - - free(self.groups_list.board_groups) - - free(self.groups_list) - - ############################################################################ - # private cdef functions used for game-play # - # # - ############################################################################ - - cdef void update_hash(self, short location, char color): - """Xor current hash with location + color action value - """ - - if color == _BLACK: - location += self.board_size - - self.zobrist_current = self.zobrist_current ^ self.zobrist_lookup[location] - - cdef bint is_positional_superko(self, short location, Group **board): - """Find all actions that the current_player has done in the past, taking into - account the fact that history starts with _BLACK when there are no - handicaps or with _WHITE when there are. - """ - - cdef int i - cdef bint played - played = 0 - - # TODO correction for handicap - if self.player_current == _BLACK: - - # move zero is black move - i = 0 - else: - - # move one is white move - i = 1 - - # check all moves by player if a move - # at location was played already - while i < self.moves_history.count: - - # check if move was played aleady - if self.moves_history.locations[i] == location: - - played = 1 - - i += 2 - - # if not played, no superko - if not played: - return 0 - - # TODO inefficient!!! - # duplicate state and play move - cdef GameState copy_state - copy_state = GameState(copy=self) - copy_state.enforce_superko = 0 - - # do move - copy_state.add_move(location) - - # check if hash already exists - if copy_state.zobrist_current in self.previous_hashes: - return 1 - - return 0 - - cdef bint is_legal_move(self, short location, Group **board, short ko): - """Check if playing at location is a legal move to make - """ - - # check if it is empty - if board[location].color != _EMPTY: - return 0 - - # check ko - if location == ko: - return 0 - - # check if it has liberty after - if 0 == self.has_liberty_after(location, board): - return 0 - - if self.enforce_superko and self.is_positional_superko(location, board): - return 0 - - return 1 - - cdef bint is_legal_move_superko(self, short location, Group **board, short ko): - """Check if playing at location is a legal move to make - """ - - # check if it is empty - if board[location].color != _EMPTY: - return 0 - - # check ko - if location == ko: - return 0 - - # check if it has liberty after - if 0 == self.has_liberty_after(location, board): - return 0 - - # if we have to enforce superko, check superko - if self.enforce_superko and self.is_positional_superko(location, board): - return 0 - - return 1 - - cdef bint has_liberty_after(self, short location, Group **board): - """Check if a play at location results in an alive group - - has liberty - - conects to group with >= 2 liberty - - captures enemy group - """ - - cdef int i - cdef char board_value - cdef short count_liberty - cdef short neighbor_location - cdef Group* group_temp - - # loop over all four neighbors - for i in range(4): - - # get neighbor location - neighbor_location = self.neighbor[location * 4 + i] - board_value = board[neighbor_location].color - - # if empty location -> liberty -> legal move - if board_value == _EMPTY: - - return 1 - - # get neighbor group - # (group_border has zero libery and is wrong color) - group_temp = board[neighbor_location] - count_liberty = group_temp.count_liberty - - # if there is a player_current group - if board_value == self.player_current: - - # if it has at least 2 liberty - if count_liberty >= 2: - - # this move removes a liberty - # if group has >2 liberty -> legal move - return 1 - - # if is a player_opponent group and has only one liberty - elif board_value == self.player_opponent and count_liberty == 1: - - # group killed and thus legal - return 1 - - return 0 - - cdef short calculate_board_location(self, char x, char y): - """2D tuple location to 1d index. Inverse of calculate_tuple_location() - - No sanity checks on bounds. - - - x is column - - y is row - """ - - return x + (y * self.size) - - cdef tuple calculate_tuple_location(self, short location): - """1d index to 2d tuple location. Inverse of calculate_board_location() - - No sanity checks on bounds. - """ - - return (location / self.size, location % self.size) - - cdef void set_moves_legal_list(self, Locations_List *moves_legal): - """Generate moves_legal list - """ - - cdef short i - - # reset moves_legal count - moves_legal.count = 0 - - # TODO? keep empty locations list? - # loop over all board locations and check if a move is legal - for i in range(self.board_size): - - # check if a move is legal - if self.is_legal_move_superko(i, self.board_groups, self.ko): - - # add to moves_legal - moves_legal.locations[moves_legal.count] = i - moves_legal.count += 1 - - cdef void combine_groups(self, Group* group_keep, Group* group_remove, Group **board): - """Combine group_keep and group_remove and replace group_remove on the board - """ - - cdef int i - cdef char value - - # loop over all board locations - for i in range(self.board_size): - - value = group_remove.locations[i] - - if value == _STONE: - - # group_remove has a stone, add to group_keep - # and set board location to group_keep - group_add_stone(group_keep, i) - board[i] = group_keep - elif value == _LIBERTY: - - # add liberty - group_add_liberty(group_keep, i) - - cdef void remove_group(self, Group* group_remove, Group **board, short* ko): - """Remove group from board -> set all locations to group_empty - """ - - cdef short location - cdef short neighbor_location - cdef Group* group_temp - cdef char board_value - cdef int i - - # if groupsize == 1, possible ko - if group_remove.count_stones == 1: - - ko[0] = group_location_stone(group_remove, self.board_size) - - # loop over all group stone locations - for location in range(self.board_size): - - if group_remove.locations[location] == _STONE: - - # set location to empty group - board[location] = group_empty - - # update hash - self.update_hash(location, group_remove.color) - - # update liberty of neighbors - # loop over all four neighbors - for i in range(4): - - # get neighbor location - neighbor_location = self.neighbor[location * 4 + i] - - # only current_player groups can be next to a killed group - # check if there is a group - board_value = board[neighbor_location].color - if board_value == self.player_current: - - # add liberty - group_temp = board[neighbor_location] - group_add_liberty(group_temp, location) - - cdef void add_to_group(self, short location, Group **board, short* ko, short* count_captures): - """Check if a stone on location is connected to a group, kills a group - or is a new group on the board - """ - - cdef Group* newGroup = NULL - cdef Group* tempGroup - cdef Group* changes - cdef short neighborLocation, location_array - cdef char boardValue - cdef char group_removed = 0 - cdef int i - - self.update_hash(location, self.player_current) - - # loop over all four neighbors - for i in range(4): - - # get neighbor location and value - neighborLocation = self.neighbor[location * 4 + i] - boardValue = board[neighborLocation].color - - # check if neighbor is friendly stone - if boardValue == self.player_current: - - # check if this is the first friendly neighbor we found - if newGroup is NULL: - - # first friendly neighbor - newGroup = board[neighborLocation] - else: - - # another friendly group, if they are different combine them - tempGroup = board[neighborLocation] - if tempGroup != newGroup: - - self.combine_groups(newGroup, tempGroup, board) - - # remove temp_group from groupList and destroy it - groups_list_remove(tempGroup, self.groups_list) - group_destroy(tempGroup) - - elif boardValue == self.player_opponent: - - # remove liberty from enemy group - tempGroup = board[neighborLocation] - group_remove_liberty(tempGroup, location) - - # check liberty count and remove if 0 - if tempGroup.count_liberty == 0: - - # increment capture count - count_captures[0] += tempGroup.count_stones - - # remove group and update hashes - self.remove_group(tempGroup, board, ko) - - # TODO hashes of locations next to a group where liberty change also have to be - # updated - - # remove tempGroup from groupList and destroy - groups_list_remove(tempGroup, self.groups_list) - group_destroy(tempGroup) - - # increment group_removed count - group_removed += 1 - - # check if no connected group is found - if newGroup is NULL: - - # create new group and add to groups_list - newGroup = group_new(self.player_current, self.board_size) - groups_list_add(newGroup, self.groups_list) - else: - - # remove liberty from group - group_remove_liberty(newGroup, location) - - # add stone to group - group_add_stone(newGroup, location) - # set board location to group - board[location] = newGroup - - # calculate location in neighbor array - location_array = location * 8 - - # loop over all four neighbors - for i in range(4): - - # get neighbor location - neighborLocation = self.neighbor3x3[location_array + i] - - # if neighbor location is empty add liberty and update hash - if board[neighborLocation].color == _EMPTY: - - group_add_liberty(newGroup, neighborLocation) - - # check if there is really a ko - # if two groups died there is no ko - # if newGroup has more than 1 stone there is no ko - if group_removed >= 2 or newGroup.count_stones > 1: - ko[0] = _PASS - - ############################################################################ - # private cdef functions used for feature generation # - # # - ############################################################################ - - cdef long generate_12d_hash(self, short centre): - """Generate 12d hash around centre location - """ - - cdef int i - cdef long hash = _HASHVALUE - cdef Group* group - - # calculate location in neighbor12d array - centre *= 12 - - # hash color and liberty of all locations - for i in range(12): - - # get group - group = self.board_groups[self.neighbor12d[centre + i]] - - # hash color - hash += group.color - hash *= _HASHVALUE - - # hash liberty - hash += min(group.count_liberty, 3) - hash *= _HASHVALUE - - return hash - - cdef long generate_3x3_hash(self, short centre): - """Generate 3x3 hash around centre location - """ - - cdef int i - cdef long hash = _HASHVALUE - cdef Group* group - - # calculate location in neighbor3x3 array - centre *= 8 - - # hash color and liberty of all locations - for i in range(8): - - # get group - group = self.board_groups[self.neighbor3x3[centre + i]] - - # hash color - hash += group.color - hash *= _HASHVALUE - - # hash liberty - hash += min(group.count_liberty, 3) - hash *= _HASHVALUE - - return hash - - cdef void get_group_after(self, char* groups_after, char* locations, char* captures, short location): # noqa: E501 - """Groups_after is a board_size * 3 array representing STONES, _LIBERTY, _CAPTURE for every location - - calculate group after a play on location and set - - groups_after[location * 3 +] to stone count - - groups_after[location * 3 + 1] to liberty count - - groups_after[location * 3 + 2] to capture count - """ - - cdef short neighbor_location - cdef short temp_location - cdef char board_value - cdef Group* temp_group - cdef int i, a - cdef int location_array = location * 3 - cdef short stones, liberty, capture - - # loop over all four neighbors - for i in range(4): - - # get neighbor location and value - neighbor_location = self.neighbor[location * 4 + i] - temp_group = self.board_groups[neighbor_location] - board_value = temp_group.color - - # check if neighbor is friendly stone - if board_value == _EMPTY: - - locations[neighbor_location] = _LIBERTY - elif board_value == self.player_current: - - # found friendly group - for a in range(self.board_size): - - if temp_group.locations[a] != _FREE: - - locations[a] = temp_group.locations[a] - - elif board_value == self.player_opponent: - - # get enemy group - # if it has one liberty it wil be killed -> add potential liberty - if temp_group.count_liberty == 1: - - for a in range(self.board_size): - - if temp_group.locations[a] == _STONE: - - captures[a] = _CAPTURE - - # add stone - locations[location] = _STONE - - for neighbor_location in range(self.board_size): - - if captures[neighbor_location] == _CAPTURE: - - # loop over all four neighbors - for i in range(4): - - # get neighbor location and value - temp_location = self.neighbor[neighbor_location * 4 + i] - if temp_location < self.board_size and locations[temp_location] == _STONE: - - locations[neighbor_location] = _LIBERTY - - # remove location as liberty - locations[location] = _STONE - - stones = 0 - liberty = 0 - capture = 0 - - # count all values - for i in range(self.board_size): - - if locations[i] == _STONE: - - stones += 1 - elif locations[i] == _LIBERTY: - - liberty += 1 - if captures[i] == _CAPTURE: - - capture += 1 - - # check max - if stones > 100: - stones = 100 - - if liberty > 100: - liberty = 100 - - if capture > 100: - capture = 100 - - # set values - groups_after[location_array] = stones - groups_after[location_array + 1] = liberty - groups_after[location_array + 2] = capture - - cdef void get_group_after_pointer(self, short* stones, short* liberty, short* capture, char* locations, char* captures, short location): # noqa: E501 - """Groups_after is a board_size * 3 array representing STONES, _LIBERTY, _CAPTURE for every location - - calculate group after a play on location and set - stones[0] to stone count - liberty[0] to liberty count - capture[0] to capture count - """ - cdef short neighbor_location - cdef short temp_location - cdef char board_value - cdef Group* temp_group - cdef int i, a, b, c - cdef int location_array = location * 3 - - # loop over all four neighbors - for i in range(4): - - # get neighbor location and value - neighbor_location = self.neighbor[location * 4 + i] - temp_group = self.board_groups[neighbor_location] - board_value = temp_group.color - - # check if neighbor is friendly stone - if board_value == _EMPTY: - - locations[neighbor_location] = _LIBERTY - elif board_value == self.player_current: - - # found friendly group - for a in range(self.board_size): - - if temp_group.locations[a] != _FREE: - - locations[a] = temp_group.locations[a] - - elif board_value == self.player_opponent: - - # get enemy group - # if it has one liberty it wil be killed -> add potential liberty - if temp_group.count_liberty == 1: - - for a in range(self.board_size): - - if temp_group.locations[a] == _STONE: - - captures[a] = _CAPTURE - - # add stone - locations[location] = _STONE - - for neighbor_location in range(self.board_size): - - if captures[neighbor_location] == _CAPTURE: - - # loop over all four neighbors - for i in range(4): - - # get neighbor location and value - temp_location = self.neighbor[neighbor_location * 4 + i] - if temp_location < self.board_size and locations[temp_location] == _STONE: - - locations[neighbor_location] = _LIBERTY - - # remove location as liberty - locations[location] = _STONE - - a = 0 - b = 0 - c = 0 - - # count all values - for i in range(self.board_size): - - if locations[i] == _STONE: - - a += 1 - elif locations[i] == _LIBERTY: - - b += 1 - if captures[i] == _CAPTURE: - - c += 1 - - stones[0] = a - liberty[0] = b - capture[0] = c - - cdef bint is_true_eye(self, short location, Locations_List* eyes, char owner): - """Check if location is a real eye - """ - - cdef int i - cdef int eyes_lenght = eyes.count - cdef char board_value, max_bad_diagonal - cdef char count_bad_diagonal = 0 - cdef char count_border = 0 - cdef short location_neighbor - cdef Locations_List* empty_diag - - # TODO benchmark what is faster? first dict lookup then neighbor check or other way around - - # check if it is a known eye - for i in range(eyes.count): - - if location == eyes.locations[i]: - - return 1 - - # loop over neighbor - for i in range(4): - - location_neighbor = self.neighbor3x3[location * 8 + i] - board_value = self.board_groups[location_neighbor].color - - if board_value == _BORDER: - - count_border += 1 - elif not board_value == owner: - - # empty location or enemy stone - return 0 - - empty_diag = locations_list_new(4) - - # loop over diagonals - for i in range(4, 8): - - location_neighbor = self.neighbor3x3[location * 8 + i] - board_value = self.board_groups[location_neighbor].color - - if board_value == _EMPTY: - # locations_list_add_location(empty_diag, location_neighbor) - empty_diag.locations[empty_diag.count] = location_neighbor - empty_diag.count += 1 - count_bad_diagonal += 1 - elif board_value == _BORDER: - - count_border += 1 - elif board_value != owner: - - # enemy stone - count_bad_diagonal += 1 - - # assume location is an eye - locations_list_add_location_increment(eyes, location) - # eyes.locations[eyes.count] = location - # eyes.count += 1 - - max_bad_diagonal = 1 if count_border == 0 else 0 - - if count_bad_diagonal <= max_bad_diagonal: - - # one bad diagonal is allowed in the middle - locations_list_destroy(empty_diag) - return 1 - - for i in range(empty_diag.count): - - location_neighbor = empty_diag.locations[i] - - if self.is_true_eye(location_neighbor, eyes, owner): - - count_bad_diagonal -= 1 - - locations_list_destroy(empty_diag) - - if count_bad_diagonal <= max_bad_diagonal: - - return 1 - - # not an eye - eyes.count = eyes_lenght - return 0 - - ############################################################################ - # private cdef Ladder functions # - # # - ############################################################################ - - """ - Ladder evaluation consumes a lot of time duplicating data, the original - version (still can be found in go_python.py) made a copy of the whole - GameState for every move played. - - This version only duplicates self.board_groups (so the list with pointers to groups) - the add_ladder_move playes a move like the add_to_group function but it - does not change the original groups and creates a list with groups removed - - with this groups removed list undo_ladder_move will return the board state to - be the same as before add_ladder_move was called - - get_removed_groups and unremove_group are being used my add/undo_ladder_move - - nb. - duplicating self.board_groups is not neccisary stricktly speaking but - it is safer to do so in a threaded environment. as soon as mcts is - implemented this duplication could be removed if the mcts ensures a - GameState is not accesed while preforming a ladder evaluation - - TODO validate no changes are being made! - - TODO self.player color is used, should become a pointer - """ - - cdef Groups_List* add_ladder_move(self, short location, Group **board, short* ko): - """Create a new group for location move and add all connected groups to it - - similar to add_to_group except no groups are changed or killed and a list - with groups removed is returned so the board can be restored to original - position - """ - - # create Group_List able to hold up to 4 changed/removed groups - cdef Groups_List* removed_groups = groups_list_new(4) - - # ko is a pointer -> add [0] to acces the actual value - ko[0] = _PASS - - # play move at location and add removed groups to removed_groups list - self.get_removed_groups(location, removed_groups, board, ko) - - # change player color - self.player_current = self.player_opponent - self.player_opponent = (_BLACK if self.player_current == _WHITE else _WHITE) - - return removed_groups - - cdef void remove_ladder_group(self, Group* group_remove, Group **board, short* ko): - """Remove group from board -> set all locations to group_empty - does not update zobrist hash - """ - - cdef short location - cdef short neighbor_location - cdef Group* group_temp - cdef char board_value - cdef int i - - # if groupsize == 1, possible ko - if group_remove.count_stones == 1: - - ko[0] = group_location_stone(group_remove, self.board_size) - - # loop over all group stone locations - for location in range(self.board_size): - - if group_remove.locations[location] == _STONE: - - # set location to empty group - board[location] = group_empty - - # update liberty of neighbors - # loop over all four neighbors - for i in range(4): - - # get neighbor location - neighbor_location = self.neighbor[location * 4 + i] - - # only current_player groups can be next to a killed group - # check if there is a group - board_value = board[neighbor_location].color - if board_value == self.player_current: - - # add liberty - group_temp = board[neighbor_location] - group_add_liberty(group_temp, location) - - cdef void undo_ladder_move(self, short location, Groups_List* removed_groups, short removed_ko, Group **board, short* ko): # noqa: E501 - """Use removed_groups list to return board state to be the same as before - add_ladder_move was used - """ - - cdef short i, b, location_neighbor - cdef Group* group - cdef Group* group_remove = board[location] - - # reset ko to old value - # ko is a pointer -> add [0] to acces the actual value - ko[0] = removed_ko - - # change player color - self.player_current = self.player_opponent - self.player_opponent = (_BLACK if self.player_current == _WHITE else _WHITE) - - # undo move set location to empty group - board[location] = group_empty - - # undo group removals - for i in range(removed_groups.count_groups): - - # do group unremovals in reversed order!!! - # this is important in order to get correct liberty counts - group = removed_groups.board_groups[removed_groups.count_groups - i - 1] - - # check group color and determine what happened - # player_current -> groups have been combined, set board locations to group - # player_opponent -> groups have been removed, unremove them - if group.color == self.player_opponent: - # opponent group was removed from the board -> unremove it - self.unremove_group(group, board) - else: - # set all board_groups locations to group - # liberty have not been changed - for b in range(self.board_size): - if group.locations[b] == _STONE: - board[b] = group - - # add liberty to neighbor groups - for i in range(4): - location_neighbor = self.neighbor[location * 4 + i] - if board[location_neighbor].color > _EMPTY: - group_add_liberty(board[location_neighbor], location) - - # destroy group - group_destroy(group_remove) - - # free removed_groups - if removed_groups is not NULL: - if removed_groups.board_groups is not NULL: - free(removed_groups.board_groups) - free(removed_groups) - - cdef void unremove_group(self, Group* group_unremove, Group **board): - """Unremove group from board - loop over all stones in this group and set board to group_unremove - remove liberty from neigbor locations - """ - - cdef short location - cdef short neighbor_location - cdef Group* group_temp - cdef int i - - # loop over all group stone locations - for location in range(self.board_size): - - # check if this has a stone on location - if group_unremove.locations[location] == _STONE: - - # set location to group_unremove - board[location] = group_unremove - - # update liberty of neighbors - # loop over all four neighbors - for i in range(4): - - # get neighbor location - neighbor_location = self.neighbor[location * 4 + i] - - # only current_player groups can be next to a killed group - # check if neighbor_location does not belong to this group - if group_unremove.locations[neighbor_location] != _STONE: - - # remove liberty - group_remove_liberty(board[neighbor_location], location) - - cdef dict get_capture_moves(self, Group* group, char color, Group **board): - """Create a dict with al moves that capture a group surrounding group - """ - - cdef int i, location, location_neighbor, location_array - cdef Group* group_neighbor - cdef dict capture = {} - - # find all moves capturing an enemy group - for location in range(self.board_size): - - if group.locations[location] == _STONE: - - # calculate array location - location_array = location * 4 - - # loop over neighbor - for i in range(4): - - # calculate neighbor location - location_neighbor = self.neighbor[location_array + i] - - # if location has opponent stone - if board[location_neighbor].color == color: - - # get opponent group - group_neighbor = board[location_neighbor] - - # if liberty count == 1 - if group_neighbor.count_liberty == 1: - - # add potential capture move - location_neighbor = group_location_liberty(group_neighbor, - self.board_size) - capture[location_neighbor] = location_neighbor - - return capture - - cdef void get_removed_groups(self, short location, Groups_List* removed_groups, Group **board, short* ko): # noqa: E501 - """Create a new group for location move and add all connected groups to it - - similar to add_to_group except no groups are changed or killed - all changes to the board are stored in removed_groups - """ - - # create new group (it is not added to groups_list as in add_to_group) - cdef Group* newGroup = group_new(self.player_current, self.board_size) - cdef Group* tempGroup - cdef short neighborLocation - cdef char boardValue - cdef char group_removed = 0 - cdef int i - - # loop over all four neighbors - for i in range(4): - - # get neighbor location and value - neighborLocation = self.neighbor[location * 4 + i] - boardValue = board[neighborLocation].color - - # check if neighbor is friendly stone - if boardValue == self.player_current: - - # another friendly group, if they are different combine them - tempGroup = board[neighborLocation] - if tempGroup != newGroup: - - self.combine_groups(newGroup, tempGroup, board) - # add tempGroup to removed_groups - groups_list_add(tempGroup, removed_groups) - - elif boardValue == self.player_opponent: - - # remove liberty from enemy group - tempGroup = board[neighborLocation] - group_remove_liberty(tempGroup, location) - - # remove group - if tempGroup.count_liberty == 0: - - self.remove_ladder_group(tempGroup, board, ko) - # add tempGroup to removed_groups - groups_list_add(tempGroup, removed_groups) - - # increment group_removed count - group_removed += 1 - - # remove liberty - group_remove_liberty(newGroup, location) - - # add stone - group_add_stone(newGroup, location) - - # set location to newGroup - board[location] = newGroup - - # loop over all four neighbors - for i in range(4): - - # get neighbor location - neighborLocation = self.neighbor[location * 4 + i] - # check is neighbor is empty, add liberty if so - if board[neighborLocation].color == _EMPTY: +# Constant value used to generate pattern hashes (TODO: move elsewhere) +cdef int _HASHVALUE = 33 - group_add_liberty(newGroup, neighborLocation) - # check if there is really a ko - # if two groups died there is no ko - # if newGroup has more than 1 stone there is no ko - if group_removed >= 2 or newGroup.count_stones > 1: - ko[0] = _PASS +############################################################################ +# Class definition # +# # +############################################################################ - cdef bint is_ladder_escape_move(self, Group **board, short* ko, Locations_List *list_ko, short location_group, dict capture, short location, int maxDepth, char color_group, char color_chase): # noqa: E501 - """Play a ladder move on location, check if group has escaped, - if the group has 2 liberty it is undetermined -> - try to capture it by playing at both liberty - """ - - cdef int i - cdef short ko_value - cdef bint result - cdef Group* group - cdef Group* group_capture - cdef dict capture_copy - cdef Groups_List* removed_groups - cdef short location_neighbor, location_stone - cdef short ko_count = list_ko.count - # check if max exploration depth has been reached - if maxDepth <= 0: - return 0 - - # check if move is legal - if not self.is_legal_move(location, board, ko[0]): +cdef class GameState: + """Class representing the current state of a game of Go and its history. - return 0 + Note that the python interface passes moves as (x, y) tuples, while all cython code uses the + slightly more streamlined 1D indexing. + """ - # do ladder move and save ko location - ko_value = ko[0] - removed_groups = self.add_ladder_move(location, board, ko) + ############################################################################ + # all variables are declared in the .pxd file # + # # + ############################################################################ - # check if it is a possible ko move - if ko[0] != _PASS: - locations_list_add_location_unique(list_ko, ko[0]) + """ + # Dimensions of one side of the board and total number of squares, respectively + cdef short size, board_size - if list_ko.count >= 2: - # undo move - self.undo_ladder_move(location, removed_groups, ko_value, board, ko) + # Possible ko location + cdef location_t ko - # decrement list_ko count - list_ko.count = ko_count - return 0 + # Unordered list of all groups of stones + cdef group_set_t groups_set - # check group liberty - group = board[location_group] - i = group.count_liberty - if i < 2: + # Lookup of a group from board location. Length is board size + 1 to include border + cdef board_group_t board - # no escape - result = 0 - elif i > 2: + # Current player and opponent, either WHITE or BLACK + cdef stone_t current_player, opponent_player - # escape - result = 1 - else: + # Amount of black stones captured by white, and vice versa + cdef short capture_black, capture_white - # 2 liberty, fate undetermined + # Amount of passes by black and by white, respectively + cdef short passes_black, passes_white - # TODO now we have to walk over all locations, somehow let do_ladder_move - # do this -> saves computation time + # List with move history + cdef vector[location_t] moves_history - # find all moves capturing an enemy group - for location_stone in range(self.board_size): + # Number of handicap stones placed by BLACK at the start of the game + cdef short num_handicap - if group.locations[location_stone] == _STONE: + # List with legal moves + cdef vector[location_t] legal_moves - # loop over neighbor - for i in range(4): + # Neighbors lookup tables + cdef pattern_t* neighbor + cdef pattern_t* neighbor3x3 + cdef pattern_t* neighbor12d - # calculate neighbor location - location_neighbor = self.neighbor[location_stone * 4 + i] + # Zobrist hashing + cdef zobrist_hash_t zobrist_current + cdef vector[zobrist_hash_t] ptr_zobrist_lookup - # if location has opponent stone - if board[location_neighbor].color == color_chase: + cdef bool enforce_superko + cdef set previous_hashes + """ - # get opponent group - group_capture = board[location_neighbor] + ############################################################################ + # init functions # + # # + ############################################################################ - # if liberty count == 1 - if group_capture.count_liberty == 1: + cdef void initialize_new(self, short size, bool enforce_superko): + """Initialize as a new state. + """ - # add potential capture move - location_neighbor = group_location_liberty(group_capture, - self.board_size) - capture[location_neighbor] = location_neighbor + cdef short i - # try to catch group by playing at one of the two liberty locations - for location_neighbor in range(self.board_size): + # Initialize size and board_size + self.size = size + self.board_size = size * size - if group.locations[location_neighbor] == _LIBERTY: + # Create placeholder groups for empty spaces and border. + self.group_empty = group_new(stone_t.EMPTY) + self.group_border = group_new(stone_t.BORDER) - if self.is_ladder_capture_move(board, ko, list_ko, location_group, - capture.copy(), location_neighbor, maxDepth - 1, - color_group, color_chase): - # undo move - self.undo_ladder_move(location, removed_groups, ko_value, board, ko) - list_ko.count = ko_count - return 0 + # Create empty history list + self.moves_history = vector[location_t]() - # escaped - result = 1 + # Initialize player colors + self.current_player = stone_t.BLACK + self.opponent_player = stone_t.WHITE - # undo move - self.undo_ladder_move(location, removed_groups, ko_value, board, ko) - list_ko.count = ko_count + self.ko = -1 + self.capture_black, self.capture_white = 0, 0 + self.passes_black, self.passes_white = 0, 0 + self.num_handicap = 0 - # return result - return result + # 'board' represents all stones on the board by first pointing to all groups of stones. + # Every group contains color, stone-locations and liberty locations. Border location is + # included, therefore the array size is board_size + 1 + self.board = board_group_t(self.board_size + 1) + self.board[self.board_size] = self.group_border - cdef bint is_ladder_capture_move(self, Group **board, short* ko, Locations_List *list_ko, short location_group, dict capture, short location, int maxDepth, char color_group, char color_chase): # noqa: E501 - """Play a ladder move on location, try capture and escape moves - and see if the group is able to escape ladder - """ + # Create list of legal moves (initially everything is legal). This is updated after each + # move. + self.legal_moves = vector[location_t](self.board_size) - cdef short i - cdef short ko_value - cdef Group* group - cdef dict capture_copy - cdef short location_next - cdef Groups_List* removed_groups - cdef short ko_count = list_ko.count + # Create empty list of all current groups. + self.groups_set = group_set_t() - # if we haven't found a capture by a certain number of moves, assume it's worked. - if maxDepth <= 0: + # Initialize board, set all locations to empty and populate list of legal moves. + for i in range(self.board_size): + self.board[i] = self.group_empty + self.legal_moves[i] = i - return 1 + # Initialize zobrist hashing things. + self.previous_hashes = set() + self.zobrist_current = 0 + self.enforce_superko = enforce_superko - if not self.is_legal_move(location, board, ko[0]): + cdef void initialize_duplicate(self, GameState copy_state): + """Initialize all variables as a deep copy of copy_state + """ - return 0 + cdef int i + cdef location_t loc + cdef group_t val + cdef group_ptr_t ref_group, dup_group - ko_value = ko[0] - removed_groups = self.add_ladder_move(location, board, ko) + # Copy each numeric instance variable's value. + self.size = copy_state.size + self.board_size = copy_state.board_size + self.ko = copy_state.ko + self.current_player = copy_state.current_player + self.opponent_player = copy_state.opponent_player + self.capture_black = copy_state.capture_black + self.capture_white = copy_state.capture_white + self.passes_black = copy_state.passes_black + self.passes_white = copy_state.passes_white + self.num_handicap = copy_state.num_handicap + self.zobrist_current = copy_state.zobrist_current + self.enforce_superko = copy_state.enforce_superko - # check if it is a possible ko move - if ko[0] != _PASS: - locations_list_add_location_unique(list_ko, ko[0]) + # Copy set of hashes using built in set.copy() + self.previous_hashes = copy_state.previous_hashes.copy() - if list_ko.count >= 2: - # undo move - self.undo_ladder_move(location, removed_groups, ko_value, board, ko) + # Copy all 'simple' C++ objects (i.e. non-pointers / non-groups) using default C++ copying + # rules, which automatically does a deep-copy of containers. + self.moves_history = copy_state.moves_history + self.legal_moves = copy_state.legal_moves - # decrement list_ko count - list_ko.count = ko_count - return 1 + # Note: group_empty and group_border are constant, so duplicating the underlying object is + # unnecessary. + self.group_empty = copy_state.group_empty + self.group_border = copy_state.group_border - # check if the group at location can be captured - group = board[location] - if group.count_liberty == 1: + # Copy groups by duplicating the underlying groups objects. + self.groups_set = group_set_t() + self.board = board_group_t(self.board_size + 1) - i = group_location_liberty(group, self.board_size) - capture[i] = i + # Initialize all of self.board to the empty group, and set the border. + for i in range(self.board_size): + self.board[i] = self.group_empty + self.board[self.board_size] = self.group_border - # try a capture move - for location_next in capture: + # Loop over each unique group in copy_state and make a duplicate. + for ref_group in copy_state.groups_set: + dup_group = group_duplicate(ref_group) + self.groups_set.insert(dup_group) - capture_copy = capture.copy() - capture_copy.pop(location_next) - if self.is_ladder_escape_move(board, ko, list_ko, location_group, capture.copy(), - location_next, maxDepth - 1, color_group, color_chase): - # undo move - self.undo_ladder_move(location, removed_groups, ko_value, board, ko) - list_ko.count = ko_count - return 0 + # Set self.board[loc] for each stone in this group. + for loc, val in d(dup_group).locations: + if val == group_t.STONE: + self.board[loc] = dup_group - group = board[location_group] + def __init__(self, char size=19, GameState copy=None, enforce_superko=True): + """Create new instance of GameState. If copy is supplied, creates a deep copy of another + state. Otherwise, creates an empty state. + """ + global neighbor, neighbor3x3, neighbor12d, zobrist_lookup, neighbor_size - # try an escape move - for location_next in range(self.board_size): + if copy is not None: + size = copy.size - if group.locations[location_next] == _LIBERTY: + # Check if this is the first GameState object (of this size) and initialize globals. + if neighbor_size == 0 or neighbor_size != size: + # Initialize "neighbor" lookup tables + neighbor = get_neighbors(size) + neighbor3x3 = get_3x3_neighbors(size) + neighbor12d = get_12d_neighbors(size) + zobrist_lookup = get_zobrist_lookup(size) - capture_copy = capture.copy() - if location_next in capture_copy: - capture_copy.pop(location_next) - if self.is_ladder_escape_move(board, ko, list_ko, location_group, capture.copy(), - location_next, maxDepth - 1, color_group, - color_chase): + # Set global size to detect whether globals need to be reinitialized for other GameState + # instances with different sizes (which is unlikely). + # TODO - global map from size to lookup table + neighbor_size = size - # undo move - self.undo_ladder_move(location, removed_groups, ko_value, board, ko) - list_ko.count = ko_count - return 0 + # Regardless of 'new' or 'duplicate', set pointers to global lookup tables and set size. + self.ptr_neighbor = &neighbor + self.ptr_neighbor3x3 = &neighbor3x3 + self.ptr_neighbor12d = &neighbor12d + self.ptr_zobrist_lookup = &zobrist_lookup - # no ladder escape found -> group is captured - # undo move - self.undo_ladder_move(location, removed_groups, ko_value, board, ko) - list_ko.count = ko_count - return 1 + if copy is None: + self.initialize_new(size, enforce_superko) + else: + self.initialize_duplicate(copy) ############################################################################ - # public cdef functions used by preprocessing # + # private cdef functions used for game-play # # # ############################################################################ - cdef char* get_groups_after(self): - """Return a short array of size board_size * 3 representing - STONES, _LIBERTY, _CAPTURE for every board location - - max count values are 100 - - loop over all legal moves and determine stone count, liberty count and - capture count of a play on that location + cdef bool is_positional_superko(self, location_t location): + """Check whether the current player playing at 'location' would result in a state identical + to a previously seen state. Move must otherwise be legal. """ - cdef short i, location + cdef int i, first + cdef bool played - # initialize groups_after array - cdef char *groups_after = malloc(self.board_size * 3 * sizeof(char)) - if not groups_after: - raise MemoryError() + # Part 1: quickly check that the current player has ever played at this location; if not, no + # fancier check is needed. - # memset(groups_after, 0, self.board_size * 3 * sizeof(char)) + # Check if 'location' is one of the handicap stones placed by BLACK + if self.current_player == stone_t.BLACK and self.num_handicap > 0: + played = location in self.moves_history[:self.num_handicap] - # create locations dictionary - cdef char *locations = malloc(self.board_size * sizeof(char)) - if not locations: - raise MemoryError() + # Calculate which was the first non-handicap move made by the current player + first = self.num_handicap + (1 if self.current_player == stone_t.WHITE else 0) - # create captures dictionary - cdef char *captures = malloc(self.board_size * sizeof(char)) - if not captures: - raise MemoryError() + # Check if 'location' matches any other move made by the current player. + played = played or location in self.moves_history[first::2] + + # If player never played at 'location', superko is impossible and we're done. + if not played: + return False - # create groups for all legal moves - for location in range(self.moves_legal.count): + # Part 2: try move on a duplicate board and check hash of the result. - # initialize both dictionaries to _FREE - memset(locations, _FREE, self.board_size * sizeof(char)) - memset(captures, _FREE, self.board_size * sizeof(char)) + # TODO (?) faster hash check than duplicate full object + # Duplicate state and play move + cdef GameState copy_state = GameState(copy=self) - self.get_group_after(groups_after, locations, captures, - self.moves_legal.locations[location]) + # Do move with superko check disabled (note: move must otherwise be legal). + copy_state.enforce_superko = False + copy_state.add_stone(location) - free(locations) - free(captures) + # Check if hash already exists (hash collisions are very unlikely) + if copy_state.zobrist_current in self.previous_hashes: + return True - return groups_after + return False - cdef long get_hash_12d(self, short centre): - """Return hash for 12d star pattern around location + cdef bool has_liberty_after(self, location_t location): + """Check if a play at location results in an alive group. This is true if + - there is already a liberty next to this location, or + - connects to group with >= 2 liberties, or + - captures an enemy group """ - # generate 12d hash value and add current player color + cdef int i + cdef stone_t board_value + cdef short count_liberty + cdef location_t neighbor_loc - return (self.generate_12d_hash(centre) + self.player_current) * _HASHVALUE + # Check all four neighbors + for i in range(4): + # Get neighbor location + neighbor_loc = d(self.ptr_neighbor)[location * 4 + i] + board_value = d(self.board[neighbor_loc]).color - cdef long get_hash_3x3(self, short location): - """Return 3x3 pattern hash + current player - """ + # If neighbor is empty, we're done + if board_value == stone_t.EMPTY: + return True - # generate 3x3 hash value and add current player color + # Check neighboring group.. this location will have a liberty either if (1) the + # neighboring group is friendly and has extra liberties, or (2) the neighboring group is + # the opponent and is captured by this move. + count_liberty = d(self.board[neighbor_loc]).count_liberty - return self.generate_3x3_hash(location) + self.player_current + # Case (1): neighboring group is friendly. Since playing at location would remove one + # liberty, this group would need >= 2 liberties to still have one left after playing + # here. + if board_value == self.current_player and count_liberty >= 2: + return True - cdef char* get_ladder_escapes(self, int maxDepth): - """Return char array with size board_size - every location represents a location on the board where: - _FREE = no ladder escape - _STONE = ladder escape - """ + # Case (2): neighboring group is opponent. If the opposing group has exactly 1 liberty, + # it must be at 'location', hence playing here would capture the opposing group. + elif board_value == self.opponent_player and count_liberty == 1: + return True - cdef short i, location_group, location_move - cdef Group* group - cdef dict move_capture - cdef dict move_capture_copy - cdef Group** board = NULL - cdef short ko = self.ko - cdef Locations_List* list_ko + return False - # create char array representing the board - cdef char* escapes = malloc(self.board_size) - if not escapes: - raise MemoryError() - # set all locations to _FREE - memset(escapes, _FREE, self.board_size) + cdef void update_legal_moves(self): + """Update legal_moves list + """ - # create Locations_List list_ko able to hold all ko locations for detecting superko - list_ko = locations_list_new(3) + cdef location_t loc - # loop over all groups on board - for i in range(self.groups_list.count_groups): + # Clear previous values in self.legal_moves + self.legal_moves.clear() - group = self.groups_list.board_groups[i] + # Loop over all board locations and check if a move is legal. + # TODO (?) store smaller list of empty locations and search only those + for loc in range(self.board_size): + if self.is_legal_move(loc): + self.legal_moves.push_back(loc) - # get liberty count - if group.count_liberty == 1: + cdef void swap_players(self): + """Switch current_player and opponent_player + """ - # check if group has one liberty and is owned by current - if group.color == self.player_current: + cdef stone_t swap = self.current_player + self.current_player = self.opponent_player + self.opponent_player = swap - # the first time a possible ladder location is found, board - # is duplicated, technically this is not neccisary but it is - # safer when we start using a multi threaded mcts - if board is NULL: + cpdef void set_current_player(self, stone_t color): + """Set current player to the given color. + """ + if color <= stone_t.EMPTY: + raise ValueError("Player color must be BLACK or WHITE") + elif color != self.current_player: + self.swap_players() + self.ko = -1 + self.update_legal_moves() - # create new Group pointer array as board - board = malloc((self.board_size + 1) * sizeof(Group*)) - if not self.board_groups: - raise MemoryError() + ############################################################################ + # private cdef helper functions for feature generation # + # # + ############################################################################ - # as the ladder search does not change any excisting groups we can safely - # duplicate the whole pointer array without duplicating all groups - memcpy(board, self.board_groups, (self.board_size + 1) * sizeof(Group*)) + cdef bool is_eyeish(self, location_t location, stone_t owner): + """Check if a location is 'eyeish'; that is, check that all 4 neighbors are either border + or the same color. + """ - # get a dictionary with all possible capture groups -> surrounding groups - # the ladder group can kill in order to escape ladder - move_capture = self.get_capture_moves(group, self.player_opponent, board) - location_group = group_location_stone(group, self.board_size) + cdef group_ptr_t group + cdef int i - # check if any of the moves is an escape move - for location_move in range(self.board_size): + # First, 'location' must be empty + if d(self.board[location]).color != stone_t.EMPTY: + return False - if group.locations[location_move] == _LIBERTY and \ - escapes[location_move] == _FREE: - # check if group can escape ladder by playing move - if self.is_ladder_escape_move(board, &ko, list_ko, location_group, - move_capture.copy(), location_move, - maxDepth, self.player_current, - self.player_opponent): + # Second, all 4 neighbors must either be BORDER or the same color as owner. + for i in range(4): + group = self.board[d(self.ptr_neighbor)[4 * location + i]] + if not (d(group).color == stone_t.BORDER or d(group).color == owner): + return False + return True - escapes[location_move] = _STONE + cdef bool is_true_eye(self, location_t location, stone_t owner, list stack=[]): + """Check if location is a 'real' eye; this goes beyond checking if a location is 'eyeish' by + checking that corners have the same owner or are themselves eyes, recursively. A group + with two "true eyes" cannot be captured. + """ - # check if any of the capture moves is an escape move - for location_move in move_capture: + cdef int i + cdef stone_t board_value + cdef short max_bad_diagonal, count_bad_diagonal = 0 + cdef location_t neighbor_loc - if escapes[location_move] == _FREE: + # First, check that location is at least 'eyeish' + if not self.is_eyeish(location, owner): + return False - move_capture_copy = move_capture.copy() - move_capture_copy.pop(location_move) + # If there is any adjacent border, max 'bad' diagonals is 0, otherwise it is 1. + for i in range(4): + neighbor_loc = d(self.ptr_neighbor3x3)[location * 8 + i] + if d(self.board[neighbor_loc]).color == stone_t.BORDER: + max_bad_diagonal = 0 + break - # check if group can escape ladder by playing capture move - if self.is_ladder_escape_move(board, &ko, list_ko, location_group, - move_capture_copy, location_move, - maxDepth, self.player_current, - self.player_opponent): + # Note: 'else' on a for loop is invoked if there was no break. Here, this means there was no + # adjacent border. + else: + max_bad_diagonal = 1 - escapes[location_move] = _STONE + # Check diagonal neighbors; they are 'bad' if occupied by an opponent or if empty and not + # itself an eye (checked recursively) + for i in range(4, 8): + neighbor_loc = d(self.ptr_neighbor3x3)[location * 8 + i] + board_value = d(self.board[neighbor_loc]).color - # free temporary board - if board is not NULL: + # Check if diagonal is enemy stone + if board_value > stone_t.EMPTY and board_value != owner: + count_bad_diagonal += 1 - free(board) + # Check if diagonal is empty and is itself an eye. First check if neighbor is in the + # search stack, in which case we don't recurse, since that would be an infinite loop. + elif board_value == stone_t.EMPTY and neighbor_loc not in stack: + stack.append(location) + if not self.is_true_eye(neighbor_loc, owner, stack): + count_bad_diagonal += 1 + stack.pop() + + # Terminate search if at any point there are more "bad" diagonals than the max allowable + # for this location. + if count_bad_diagonal > max_bad_diagonal: + return False - locations_list_destroy(list_ko) + # If made it to here, location must be a eye + return True - return escapes + ############################################################################ + # public cdef functions for feature generation (used by preprocessing) # + # TODO: move all of these to preprocessing itself # + ############################################################################ - cdef char* get_ladder_captures(self, int maxDepth): - """Return char array with size board_size - every location represents a location on the board where: - _FREE = no ladder capture - _STONE = ladder capture + cdef pattern_hash_t get_12d_hash(self, location_t center, bool include_player, int max_liberty=3): # noqa:E501 + """Get unique-ish hash of the 12-stone pattern centered at 'center'. Assumes 'center' + itself is EMPTY. If 'include_player' is True, hash also takes into account who is the + current player. """ - cdef short i, location_group, location_move - cdef Group* group - cdef dict move_capture - cdef Group** board = NULL - cdef short ko = self.ko - cdef Locations_List* list_ko - - # create char array representing the board - cdef char* captures = malloc(self.board_size) - if not captures: - raise MemoryError() - # set all locations to _FREE - memset(captures, _FREE, self.board_size) + # First compute hash for all stones around 'center' + cdef pattern_hash_t hsh = \ + get_pattern_hash(self.board, center, 12, d(self.ptr_neighbor12d), max_liberty) - # create Locations_List list_ko able to hold all ko locations for detecting superko - list_ko = locations_list_new(3) + # If specified, also include the current player as part of the hash + if include_player: + hsh += self.current_player + hsh *= _HASHVALUE - # loop over all groups on board - for i in range(self.groups_list.count_groups): + return hsh - group = self.groups_list.board_groups[i] + cdef pattern_hash_t get_3x3_hash(self, short center, bool include_player, int max_liberty=3): + """Get unique-ish hash of the 8-stone pattern centered at 'center'. Assumes 'center' itself + is EMPTY. If 'include_player' is True, hash also takes into account who is the current + player. + """ + cdef pattern_hash_t hsh = \ + get_pattern_hash(self.board, center, 8, d(self.ptr_neighbor3x3), max_liberty) - # get liberty count - if group.count_liberty == 2: + # If specified, also include the current player as part of the hash + if include_player: + hsh += self.current_player + hsh *= _HASHVALUE - # check if group is owned by opponent - if group.color == self.player_opponent: + return hsh - # the first time a possible ladder location is found, board - # is duplicated, technically this is not neccisary but it is - # safer when we start using a multi threaded mcts - if board is NULL: + cdef vector[location_t] get_sensible_moves(self): + """'Sensible' moves are all legal moves that are not eyes of the current player. + """ - # create new Group pointer array as board - board = malloc((self.board_size + 1) * sizeof(Group*)) - if not self.board_groups: - raise MemoryError() + cdef location_t loc + cdef vector[location_t] sensible_moves = vector[location_t]() + cdef list eyes = [] # Keep track of all eyes found so far to speed up search - # as the ladder search does not change any excisting groups we can safely - # duplicate the whole pointer array without duplicating all groups - memcpy(board, self.board_groups, (self.board_size + 1) * sizeof(Group*)) + for loc in self.legal_moves: + if self.is_true_eye(loc, self.current_player, eyes): + eyes.append(loc) + else: + sensible_moves.push_back(loc) - # get a dictionary with all possible capture groups -> surrounding groups - # the ladder group can kill in order to escape ladder - move_capture = self.get_capture_moves(group, self.player_current, board) - location_group = group_location_stone(group, self.board_size) + return sensible_moves - # loop over all liberty - for location_move in range(self.board_size): + ############################################################################ + # public cdef functions used for game play # + # # + ############################################################################ - if group.locations[location_move] == _LIBERTY and \ - captures[location_move] == _FREE: - # check if move is ladder capture - if self.is_ladder_capture_move(board, &ko, list_ko, location_group, - move_capture.copy(), location_move, - maxDepth, self.player_opponent, - self.player_current): - captures[location_move] = _STONE + cpdef bool is_legal_move(self, location_t location): + """Check if playing at location is a legal move + """ - # free temporary board - if board is not NULL: + # Passing is always legal + if location == action_t.PASS: + return True - free(board) + # Check that location is on the board + if location < 0 or location >= self.board_size: + return False - locations_list_destroy(list_ko) + # Check if it is empty + if d(self.board[location]).color > stone_t.EMPTY: + return False - return captures + # Check ko (1 move back only) + if location == self.ko: + return False - ############################################################################ - # public cdef functions used for game play # - # # - ############################################################################ + # Check if move is suicide + if not self.has_liberty_after(location): + return False - cdef void add_move(self, short location): - """!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - Move should be legal! - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + # (Maybe) check superko state + if self.enforce_superko and self.is_positional_superko(location): + return False - play move on location, move should be legal! + # If all of the above checks pass, then this move is legal. + return True - update player_current, history and moves_legal + cdef location_t add_stone(self, location_t location): + """Play stone on location and update the state. MOVE MUST BE LEGAL. Returns new ko location, + or -1 if there is none. """ - # reset ko - self.ko = _PASS - - # detemine where captures should be added, black captures -> white stones - # white captures -> black stones - # (probably better to think of it as black stones captured, and white stones captured) - cdef short* captures = &self.capture_white if (self.player_current == _BLACK) else \ - &self.capture_black - - # add move to board - self.add_to_group(location, self.board_groups, &self.ko, captures) + cdef group_ptr_t captured_stones = group_new(self.opponent_player) + cdef group_ptr_t new_group = group_new(self.current_player) + cdef group_ptr_t neighbor_group + cdef stone_t neighbor + cdef location_t neighbor_loc, new_ko = -1 + cdef int i - # switch player color - self.player_current = self.player_opponent - self.player_opponent = (_BLACK if self.player_current == _WHITE else _WHITE) + # Start new group for this stone. + group_add_stone(new_group, location) - # add move to history - locations_list_add_location_increment(self.moves_history, location) + # Add new group to groups_set and ensure board location is updated. + self.groups_set.insert(new_group) + self.board[location] = new_group - # update zobrist + # Check neighbors: merge friendly groups and (maybe) capture opponents + for i in range(4): + neighbor_loc = d(self.ptr_neighbor)[location * 4 + i] + neighbor_group = self.board[neighbor_loc] + neighbor = d(neighbor_group).color + + # Add liberties to the new group + if neighbor == stone_t.EMPTY: + group_add_liberty(new_group, neighbor_loc) + + # Merge neighboring friendly groups (and remove 'location' as one of their liberties) + elif neighbor == self.current_player and new_group != neighbor_group: + group_remove_liberty(neighbor_group, location) + new_group = self.combine_groups(new_group, neighbor_group) + + # Handle stone placed next to opponent group: remove stone from opponent liberties and + # check for capture. + elif neighbor == self.opponent_player: + group_remove_liberty(neighbor_group, location) + + # Capture opponent if this stone covered opponent's last liberty. + if d(neighbor_group).count_liberty == 0: + group_merge(captured_stones, neighbor_group) + self.remove_group(neighbor_group) + + # Count captured stones + if self.current_player == stone_t.BLACK: + self.capture_white += d(captured_stones).count_stones + else: + self.capture_black += d(captured_stones).count_stones + + # Update ko: ko occurs when both captured group and newly created group are size 1. + if d(captured_stones).count_stones == 1 and d(new_group).count_stones == 1: + new_ko = group_get_stone(captured_stones) + + # Update zobrist hash: first, update with newly added stone + self.zobrist_current = update_hash_by_location(self.zobrist_current, + d(self.ptr_zobrist_lookup), + location, self.current_player) + # Second, update with all captured stones. + if d(captured_stones).count_stones > 0: + self.zobrist_current = update_hash_by_group(self.zobrist_current, + d(self.ptr_zobrist_lookup), + captured_stones) self.previous_hashes.add(self.zobrist_current) - # set moves_legal - self.set_moves_legal_list(self.moves_legal) + return new_ko - cdef GameState new_state_add_move(self, short location): - """Copy this gamestate and play move at location + cpdef list get_legal_moves(self, bool include_eyes=True): + """Return a list with all legal moves as tuples (in/excluding eyes) """ - # create new gamestate, copy all data of self - state = GameState(copy=self) + cdef list moves - # do move - state.add_move(location) + if include_eyes: + moves = list(self.legal_moves) + else: + moves = list(self.get_sensible_moves()) - return state + return [calculate_tuple_location(m, self.size) for m in moves] - cdef float get_score(self, float komi): + cpdef float get_score(self, float komi=7.5): """Calculate score of board state. Uses 'Area scoring'. http://senseis.xmp.net/?Passing#1 - negative value indicates black win - positive value indicates white win + Negative value indicates black win, positive value indicates white win. """ - cdef short location - cdef char board_value - cdef float score - score = -komi - - # lists to keep track of black and white eyes - cdef Locations_List* eyes_white = locations_list_new(self.board_size) - cdef Locations_List* eyes_black = locations_list_new(self.board_size) + cdef location_t location + cdef stone_t board_value - # loop over whole board - for location in range(self.board_size): + # Positive score is in favor of black, negative is in favor of white. + cdef float score = -komi - # get location color - board_value = self.board_groups[location].color + # Keep track of all eyes for both black and white to make search faster. + cdef list eyes_white = [], eyes_black = [] - if board_value == _WHITE: + # Loop over whole board + for location in range(self.board_size): + board_value = d(self.board[location]).color - # white stone + # Decrement score difference for white + if board_value == stone_t.WHITE: score -= 1 - elif board_value == _BLACK: - # black stone + # Increment score difference for black + elif board_value == stone_t.BLACK: score += 1 - else: - - # empty location, check if it is an eye for black/white - if self.is_true_eye(location, eyes_black, _BLACK): + # If empty, count as territory only if it is an eye + else: + if self.is_true_eye(location, stone_t.BLACK, eyes_black): + eyes_black.append(location) score += 1 - elif self.is_true_eye(location, eyes_white, _WHITE): + elif self.is_true_eye(location, stone_t.WHITE, eyes_white): + eyes_white.append(location) score -= 1 - # free eyes_black and eyes_white - locations_list_destroy(eyes_black) - locations_list_destroy(eyes_white) - # substract passes # http://senseis.xmp.net/?Passing#1 score -= self.passes_black @@ -1834,166 +641,156 @@ cdef class GameState: return score - cdef char get_winner_color(self, float komi): - """Calculate score of board state and return player ID (1, -1, or 0 for tie) - corresponding to winner. Uses 'Area scoring'. - - http://senseis.xmp.net/?Passing#1 + cpdef stone_t get_winner_color(self, float komi=7.5): + """Calculate score of board state and return winning player (WHITE or BLACK). Uses 'Area + scoring'. Tie goes to WHITE. """ - cdef float score - - score = self.get_score(komi) + cdef float score = self.get_score(komi) - # check if black has won, tie -> white wins + # BLACK wins for strictly positive score. 0 (tie) or negative goes to WHITE. if score > 0: + return stone_t.BLACK + else: + return stone_t.WHITE - # black wins - return _BLACK - - # white wins - return _WHITE - - ############################################################################ - # public def functions used for game play (Python) # - # # - ############################################################################ + cpdef void do_move(self, tuple action, stone_t color=stone_t.EMPTY): + """Play stone at action=(x,y). Use action=None for passing. Checks move legality first. - def do_move(self, action, color=None): - """Play stone at action=(x,y). - If it is a legal move, current_player switches to the opposite color - If not, an IllegalMove exception is raised + If it is a legal move, current_player switches to the opposite color. If not, an + IllegalMove exception is raised """ - if action == _PASS: - locations_list_add_location_increment(self.moves_history, _PASS) + cdef location_t x, y, location + cdef group_ptr_t grp - if self.player_opponent == _BLACK: + # Note: as per the python interface, 'None' is considerd a pass + if action is None: + location = action_t.PASS + + if self.current_player == stone_t.BLACK: self.passes_black += 1 else: self.passes_white += 1 - # change player color - self.player_current = self.player_opponent - self.player_opponent = (_BLACK if self.player_current == _WHITE else _WHITE) + # Reset ko since players switched. + self.ko = -1 - # legal moves have to be recalculated - self.ko = _PASS - self.set_moves_legal_list(self.moves_legal) - return + else: + if color != stone_t.EMPTY and color != self.current_player: + self.swap_players() - if color is not None: - self.player_current = color - self.player_opponent = (_BLACK if self.player_current == _WHITE else _WHITE) + # Convert from tuple (x, y) input to 1d coordinate. + (x, y) = action + location = calculate_board_location(y, x, self.size) - # legal moves have to be recalculated - self.set_moves_legal_list(self.moves_legal) + # Check if move is legal. + if not self.is_legal_move(location): + raise IllegalMove(str(action)) - cdef int x, y, i - cdef short location - (x, y) = action - location = self.calculate_board_location(y, x) + # Execute move. + self.ko = self.add_stone(location) - # check if move is legal - if not self.is_legal_move_superko(location, self.board_groups, self.ko): - raise IllegalMove(str(action)) + # Add move to history + self.moves_history.push_back(location) - # add move - self.add_move(location) + # Swap current player for next turn. + self.swap_players() - return True + # The set of legal moves must now be recomputed, since it is different for each player + # and with new ko location. + self.update_legal_moves() - def get_legal_moves(self, include_eyes=True): - """Return a list with all legal moves (in/excluding eyes) + cpdef void place_handicap_stone(self, tuple action, stone_t color=stone_t.BLACK): + """Add handicap stones given by a list of tuples in list handicap """ - cdef int i - cdef list moves = [] - cdef Locations_List* moves_list - - if include_eyes: - moves_list = self.moves_legal - else: - moves_list = self.get_sensible_moves() - - for i in range(moves_list.count): - moves.append(self.calculate_tuple_location(moves_list.locations[i])) - - if not include_eyes: - # free sensible_moves - locations_list_destroy(moves_list) + if self.moves_history.size() > self.num_handicap: + raise IllegalMove("Cannot place handicap on a started game") - return moves + self.num_handicap += 1 + self.do_move(action, color) - def get_winner(self, float komi=7.5): - """Calculate score of board state and return player ID (1, -1, or 0 for tie) - corresponding to winner. Uses 'Area scoring'. + cpdef void place_handicaps(self, list handicap): + """Place list of handicap stones for BLACK (must be on empty board) """ - return self.get_winner_color(komi) - - def get_board_count(self, float komi=7.5): - """Calculate score of board state + for action in handicap: + self.place_handicap_stone(action, stone_t.BLACK) - where negative value indicates white win - and positive value indicates black win - """ + ############################################################################ + # Helper functions for managing groups # + # # + ############################################################################ - return self.get_score(komi) + cdef group_ptr_t combine_groups(self, group_ptr_t group_keep, group_ptr_t group_remove): + """Combine group_keep and group_remove by copying all stones and liberties from + group_remove into group_keep, and update pointers on the board. Leaves group_remove + unchanged, but does remove it from groups_set. - def place_handicap_stone(self, action, color=_BLACK): - """Add handicap stones given by a list of tuples in list handicap + Returns group_keep """ - cdef short fake_capture + cdef location_t loc + cdef group_t val - self.player_current = color - self.player_opponent = (_BLACK if self.player_current == _WHITE else _WHITE) + # Loop over all location->stone pairs in group_remove. + for loc, val in d(group_remove).locations: + if val == group_t.STONE: + # group_remove has a stone at this location; add the stone to group_keep and set + # board location to group_keep. + group_add_stone(group_keep, loc) + self.board[loc] = group_keep + elif val == group_t.LIBERTY: + # group_remove has a liberty at this location; add the liberty to group_keep. + group_add_liberty(group_keep, loc) - cdef char x, y - cdef short location - (x, y) = action - location = self.calculate_board_location(y, x) + # Clear group_remove from groups_set. + self.groups_set.erase(group_remove) - # add move - self.add_to_group(location, self.board_groups, &self.ko, &fake_capture) + return group_keep - # set legal moves - self.set_moves_legal_list(self.moves_legal) - - def place_handicaps(self, list handicap): - """Todo save handicap stones list?? -> seems not usefull as we also have to copy them - add handicap stones given by a list of tuples in list handicap + cdef void remove_group(self, group_ptr_t group_remove): + """Remove group from everywhere in the state. """ - cdef char x, y - cdef short location - cdef short fake_capture - if self.moves_history.count > 0: - raise IllegalMove("Cannot place handicap on a started game") + cdef location_t loc, neighbor_loc + cdef group_ptr_t group + cdef group_t val, neighbor_value + cdef int i - for action in handicap: + # First pass: clear stones by setting board[loc] to EMPTY for each stone in this group. + for loc, val in d(group_remove).locations: + if val == group_t.STONE: + # Set board at this location to empty group + self.board[loc] = self.group_empty - (x, y) = action - location = self.calculate_board_location(y, x) - self.add_to_group(location, self.board_groups, &self.ko, &fake_capture) + # Second pass: add new liberties to neighboring groups now that stones have been cleared. + for loc, val in d(group_remove).locations: + if val == group_t.STONE: + for i in range(4): + # Get neighbor location + neighbor_loc = d(self.ptr_neighbor)[loc * 4 + i] + group = self.board[neighbor_loc] - # active player color reverses - self.player_current = _WHITE - self.player_opponent = _BLACK + # If neighbor is a stone, add 'loc' as a liberty + if d(group).color > stone_t.EMPTY: + group_add_liberty(group, loc) - # set legal moves - self.set_moves_legal_list(self.moves_legal) + # Clear this group from groups_set + self.groups_set.erase(group_remove) - def is_end_of_game(self): - """ + ############################################################################ + # Python convenience functions (not declared in .pxd) # + # # + ############################################################################ - """ - if self.moves_history.count > 1: - if self.moves_history.locations[self.moves_history.count - 1] == _PASS and \ - self.moves_history.locations[self.moves_history.count - 2] == _PASS and \ - self.player_current == _WHITE: + def is_end_of_game(self): + if self.moves_history.size() > 1: + if self.moves_history[self.moves_history.size() - 1] == action_t.PASS and \ + self.moves_history[self.moves_history.size() - 2] == action_t.PASS and \ + self.current_player == stone_t.WHITE: return True return False @@ -2001,22 +798,18 @@ cdef class GameState: """Determine if the given action (x,y tuple) is a legal move """ - cdef int i - cdef char x, y + cdef char x, y cdef short location (x, y) = action - # check outside board + # Check outside board if x < 0 or y < 0 or x >= self.size or y >= self.size: return False - # calculate location - location = self.calculate_board_location(y, x) + # Calculate 1D location + location = calculate_board_location(y, x, self.size) - if self.is_legal_move_superko(location, self.board_groups, self.ko): - return True - - return False + return self.is_legal_move(location) def copy(self): """Get a copy of this Game state @@ -2029,45 +822,131 @@ cdef class GameState: # # ############################################################################ - def get_current_player(self): - """Returns the color of the player who will make the next move. + cpdef void print_groups(self): + """Debugging helper: prints address of all groups in this state and info about each one. """ - return self.player_current + cdef location_t loc + cdef group_t val + cdef group_ptr_t group - def set_current_player(self, color): - """Change current player color - """ + print("Empty: {0:x}".format( self.group_empty.get())) + print("Border: {0:x}".format( self.group_border.get())) - self.player_current = color - self.player_opponent = (_BLACK if self.player_current == _WHITE else _WHITE) + for loc in range(self.board_size): + if self.board[loc].get() != self.group_empty.get(): + print("%03d: %x" % (loc, self.board[loc].get())) - def get_history(self): - """Return history as a list of tuples + for group in self.groups_set: + print("--- %x: %d / %d / %d ---" % ( group.get(), d(group).color, + d(group).count_stones, d(group).count_liberty)) + for loc, val in d(group).locations: + print "\t", calculate_tuple_location(loc, self.size), val + + cpdef bool sanity_check_groups(self): + """Debugging helper: loops over every location and group on the board and checks that they are + self-consistent. """ - cdef int i - cdef short location - cdef list history = [] + cdef location_t loc, neighbor_loc + cdef group_ptr_t group1, group2, neighbor_group + cdef group_t val + cdef int i, recount_liberty, recount_stones + cdef set empty_adjacent - for i in range(self.moves_history.count): + # Check 1: all groups in self.board are also in self.groups_set. + for loc in range(self.board_size): + group1 = self.board[loc] - location = self.moves_history.locations[i] + # Skip empty group. + if group1 == self.group_empty: + continue - if location != _PASS: + # Check for this group in self.groups_set. + for group2 in self.groups_set: + if group2 == group1: + break + else: + print("board[loc] points to nonexistent group!") + return False - history.append(self.calculate_tuple_location(location)) + # Check 2: vice versa + for group1 in self.groups_set: + for loc in range(self.board_size): + if group1 == self.board[loc]: + break else: + print("groups_set contains a group not on the board!") + return False + + # Check 3: all neighbors of the same color are in the same group. + for loc in range(self.board_size): + group1 = self.board[loc] + + # Only check locations with stones. + if d(group1).color > stone_t.EMPTY: + for i in range(4): + neighbor_loc = d(self.ptr_neighbor)[4 * loc + i] + neighbor_group = self.board[neighbor_loc] + + # If neighbor has stone of the same color, they must be the same group object. + if d(neighbor_group).color == d(group1).color: + if neighbor_group != group1: + print("neighbors of same color not in the same group!") + return False + + # Check 4: group's counts and locations are consistent with the board. + for group1 in self.groups_set: + recount_liberty, recount_stones = 0, 0 + empty_adjacent = set() + for loc, val in d(group1).locations: + # Check that group's STONE locations are correct in self.board + if val == group_t.STONE: + if self.board[loc] != group1: + print("group has STONE but board does not point to that group!") + return False + recount_stones += 1 + + # Check for neighboring liberties on the board + for i in range(4): + neighbor_loc = d(self.ptr_neighbor)[4 * loc + i] + if self.board[neighbor_loc] == self.group_empty: + empty_adjacent.add(neighbor_loc) + + # Check that group's LIBERTY locations are actually empty + elif val == group_t.LIBERTY: + if self.board[loc] != self.group_empty: + print("group has LIBERTY but board does not point to empty group!") + return False + recount_liberty += 1 + + # Check that all counts make sense + if recount_stones != d(group1).count_stones: + print("mismatch in stones count!") + return False + + elif recount_liberty != d(group1).count_liberty: + print("mismatch in liberties count!") + return False + + elif recount_liberty != len(empty_adjacent): + print("liberty count does not match actuall number of empty adjacent locations!") + return False + + # All checks passed + return True - history.append(_PASS) + def get_current_player(self): + """Returns the color of the player who will make the next move. + """ - return history + return self.current_player - def get_history_size(self): - """Return history size + def get_history(self): + """Return history as a list of tuples """ - return self.moves_history.count + return [calculate_tuple_location(loc, self.size) for loc in self.moves_history] def get_captures_black(self): """Return amount of black stones captures @@ -2082,22 +961,20 @@ cdef class GameState: return self.capture_white def get_ko_location(self): - """Return ko location + """Return ko location as a tuple, or None """ - if self.ko == _PASS: + if self.ko == -1: return None - return self.ko + return calculate_tuple_location(self.ko, self.size) def is_board_equal(self, GameState state): """Verify that self and state board layout are the same """ - for x in range(self.board_size): - - if self.board_groups[x].color != state.board_groups[x].color: - + for i in range(self.board_size): + if d(self.board[i]).color != d(state.board[i]).color: return False return True @@ -2106,113 +983,50 @@ cdef class GameState: """Verify that self and state liberty counts are the same """ - for x in range(self.board_size): - - if self.board_groups[x].count_liberty != state.board_groups[x].count_liberty: - + for i in range(self.board_size): + if d(self.board[i]).count_liberty != d(state.board[i]).count_liberty: return False return True - def is_ladder_escape(self, action): - """Check if playing action is a ladder escape - """ - value = False - - cdef char x, y - cdef short location - - (x, y) = action - location = self.calculate_board_location(y, x) - - cdef char* escapes = self.get_ladder_escapes(80) - - if escapes[location] != _FREE: - - value = True - - # free escapes - free(escapes) - - return value - - def is_ladder_capture(self, action): - """Check if playing action is a ladder capture - """ - value = False - - cdef char x, y - cdef short location - - (x, y) = action - location = self.calculate_board_location(y, x) - - cdef char* captures = self.get_ladder_captures(80) - - if captures[location] != _FREE: - - value = True - - # free captures - free(captures) - - return value - def is_eye(self, action, color): """Check if location action is a eye for player color """ - value = False - - cdef char x, y - cdef short location (x, y) = action - location = self.calculate_board_location(y, x) - - # checking all games in the KGS database found a max of 15eyes in one state - # 25 seems a safe bet - cdef Locations_List* eyes = locations_list_new(80) + location = calculate_board_location(y, x, self.size) - if self.is_true_eye(location, eyes, color): - - value = True - - locations_list_destroy(eyes) - - return value + return self.is_true_eye(location, color) def get_liberty(self): - """Get numpy array with all liberty counts + """Get numpy array with all liberty counts for all stones. """ liberty = np.zeros((self.size, self.size), dtype=np.int) for x in range(self.size): - for y in range(self.size): - - location = self.calculate_board_location(y, x) - - liberty[x, y] = self.board_groups[location].count_liberty + location = calculate_board_location(y, x, self.size) + liberty[x, y] = d(self.board[location]).count_liberty return liberty def get_board(self): - """Get numpy array with board locations + """Get numpy array with board locations set to stone colors """ board = np.zeros((self.size, self.size), dtype=np.int) for x in range(self.size): - for y in range(self.size): - - location = self.calculate_board_location(y, x) - - board[x, y] = self.board_groups[location].color + location = calculate_board_location(y, x, self.size) + board[x, y] = d(self.board[location]).color return board + def get_hash(self): + return self.zobrist_current + def get_size(self): """Return size """ @@ -2220,51 +1034,10 @@ cdef class GameState: return self.size def get_handicaps(self): - """Todo ? - return list with handicap stones placed - """ - - return [] - - def get_handicap(self): - """Todo ? - return list with handicap stones placed - """ - - return [] - - cdef Locations_List* get_sensible_moves(self): - """Only used for def get_legal_moves - return a list with sensible legal moves + """Return list with handicap stones placed by BLACK at beginning of the game. """ - # TODO validate usage of struct is actually faster - - # create list with at least #moves_legal.count locations - # there can never be more sensible moves - cdef Locations_List* sensible_moves = locations_list_new(self.moves_legal.count) - - # checking all games in the KGS database found a max of 17eyes in one state - # 25 seems a safe bet - cdef Locations_List* eyes = locations_list_new(80) - cdef int i - cdef short location - - for i in range(self.moves_legal.count): - - location = self.moves_legal.locations[i] - - if not self.is_true_eye(location, eyes, self.player_current): - - # TODO find out why locations_list_add_location is 2x slower - # locations_list_add_location(sensible_moves, location) - - sensible_moves.locations[sensible_moves.count] = location - sensible_moves.count += 1 - - locations_list_destroy(eyes) - - return sensible_moves + return self.moves_history[:self.num_handicap] def get_print_board_layout(self): """Print current board state @@ -2272,16 +1045,15 @@ cdef class GameState: line = "\n" for i in range(self.size): - A = str(i) + " " + row = str(i) + " " for j in range(self.size): - - B = 0 - if self.board_groups[j + i * self.size].color == _BLACK: - B = 'B' - elif self.board_groups[j + i * self.size].color == _WHITE: - B = 'W' - A += str(B) + " " - line += A + "\n" + stone = '.' + if d(self.board[j + i * self.size]).color == stone_t.BLACK: + stone = 'B' + elif d(self.board[j + i * self.size]).color == stone_t.WHITE: + stone = 'W' + row += stone + " " + line += row + "\n" return line def __repr__(self): diff --git a/AlphaGo/go/go_data.pxd b/AlphaGo/go/go_data.pxd deleted file mode 100644 index 515536e8c..000000000 --- a/AlphaGo/go/go_data.pxd +++ /dev/null @@ -1,320 +0,0 @@ -import numpy as np -cimport numpy as np - -############################################################################ -# Global constants # -# # -############################################################################ - -# Note that "enum" is the best way to create a compile-time constant in cython. - -cpdef enum GAME: - # value for _PASS move - _PASS = -1 - -cpdef enum STONES: - # observe: stones > _EMPTY - # border < _EMPTY - # be aware you should NOT use != _EMPTY as this includes border locations - _BORDER = 1 - _EMPTY = 2 - _WHITE = 3 - _BLACK = 4 - -cpdef enum PATTERNS: - # value used to generate pattern hashes - _HASHVALUE = 33 - -cpdef enum GROUP: - # used for group stone, liberty locations, legal move and sensible move - _FREE = 3 - _STONE = 0 - _LIBERTY = 1 - _CAPTURE = 2 - _LEGAL = 4 - _EYE = 5 - - -############################################################################ -# Structs # -# # -############################################################################ - -# Notes on use of structs over 'extension types': -# -# A struct has the advantage of being completely C, no python wrapper so no python overhead. -# -# compared to a cdef class a struct has some advantages: -# - C only, no python overhead -# - able to get a pointer to it -# - smaller in size -# -# drawbacks -# - have to be Malloc created and freed after use -> memory leak -# - no convenient functions available -# - no boundchecks - -""" - struct to store group stone and liberty locations - - locations is a char pointer array of size board_size and initialized - to _FREE. after adding a stone/liberty that location is set to - _STONE/_LIBERTY and count_stones/count_liberty is incremented - - note that a stone location can never be a liberty location, - if a stone is placed on a liberty location liberty_count is decremented - - it works as a dictionary so lookup time for a location is O(1) - looping over all stone/liberty location could be optimized by adding - two lists containing stone/liberty locations - - TODO check if this dictionary implementation is faster on average - use as a two list implementation -""" -cdef struct Group: - char* locations - short count_stones - short count_liberty - char color - -""" - struct to store a list of Group - - board_groups is a Group pointer array of size #size and containing - #count_groups groups - - TODO convert to c++ list? -""" -cdef struct Groups_List: - Group** board_groups - short count_groups - short size - -""" - struct to store a list of short (board locations) - - locations is a short pointer array of size #size and containing - #count locations - - TODO convert to c++ list and/or set -""" -cdef struct Locations_List: - short* locations - short count - short size - - -############################################################################ -# group functions # -# # -############################################################################ - -cdef Group* group_new(char color, short size) -""" - create new struct Group - with locations #size char long initialized to _FREE -""" - -cdef Group* group_duplicate(Group* group, short size) -""" - create new struct Group initialized as a duplicate of group -""" - -cdef void group_destroy(Group* group) -""" - free memory location of group and locations -""" - -cdef void group_add_stone(Group* group, short location) -""" - update location as _STONE - update liberty count if it was a liberty location - - n.b. stone count is not incremented if a stone was present already -""" - -cdef void group_remove_stone(Group* group, short location) -""" - update location as _FREE - update stone count if it was a stone location -""" - -cdef short group_location_stone(Group* group, short size) -""" - return first location where a _STONE is located -""" - -cdef void group_add_liberty(Group* group, short location) -""" - update location as _LIBERTY - update liberty count if it was a _FREE location - - n.b. liberty count is not incremented if a stone was present already -""" - -cdef void group_remove_liberty(Group* group, short location) -""" - update location as _FREE - update liberty count if it was a _LIBERTY location - - n.b. liberty count is not decremented if location is a _FREE location -""" - -cdef short group_location_liberty(Group* group, short size) -""" - return location where a _LIBERTY is located -""" - -############################################################################ -# Groups_List functions # -# # -############################################################################ - -cdef Groups_List* groups_list_new(short size) -""" - create new struct Groups_List - with locations #size Group* long and count_groups set to 0 -""" - -cdef void groups_list_add(Group* group, Groups_List* groups_list) -""" - add group to list and increment groups count -""" - -cdef void groups_list_add_unique(Group* group, Groups_List* groups_list) -""" - check if a group is already in the list, return if so - add group to list if not -""" - -cdef void groups_list_remove(Group* group, Groups_List* groups_list) -""" - remove group from list and decrement groups count -""" - -############################################################################ -# Locations_List functions # -# # -############################################################################ - -cdef Locations_List* locations_list_new(short size) -""" - create new struct Locations_List - with locations #size short long and count set to 0 -""" - -cdef void locations_list_destroy(Locations_List* locations_list) -""" - free memory location of locations_list and locations -""" - -cdef void locations_list_remove_location(Locations_List* locations_list, short location) -""" - remove location from list -""" - -cdef void locations_list_add_location(Locations_List* locations_list, short location) -""" - add location to list and increment count -""" - -cdef void locations_list_add_location_increment(Locations_List* locations_list, short location) -""" - check if list can hold one more location, resize list if not - add location to list and increment count -""" - -cdef void locations_list_add_location_unique(Locations_List* locations_list, short location) -""" - check if location is present in list, return if so - add location to list if not -""" - -############################################################################ -# neighbor creation functions # -# # -############################################################################ - -cdef short calculate_board_location(char x, char y, char size) -""" - return location on board - no checks on outside board - x = columns - y = rows -""" - -cdef short calculate_board_location_or_border(char x, char y, char size) -""" - return location on board or borderlocation - board locations = [ 0, size * size) - border location = size * size - x = columns - y = rows -""" - -cdef short* get_neighbors(char size) -""" - create array for every board location with all 4 direct neighbour locations - neighbor order: left - right - above - below - - -1 x - x x - +1 x - - order: - -1 2 - 0 1 - +1 3 - - TODO neighbors is obsolete as neighbor3x3 contains the same values -""" - -cdef short* get_3x3_neighbors(char size) -""" - create for every board location array with all 8 surrounding neighbour locations - neighbor order: above middle - middle left - middle right - below middle - above left - above right - below left - below right - this order is more useful as it separates neighbors and then diagonals - -1 xxx - x x - +1 xxx - - order: - -1 405 - 1 2 - +1 637 - - 0-3 contains neighbors - 4-7 contains diagonals -""" - -cdef short* get_12d_neighbors(char size) -""" - create array for every board location with 12d star neighbour locations - neighbor order: top star tip - above left - above middle - above right - left star tip - left - right - right star tip - below left - below middle - below right - below star tip - - -2 x - -1 xxx - xx xx - +1 xxx - +2 x - - order: - -2 0 - -1 123 - 45 67 - +1 89a - +2 b -""" - -############################################################################ -# zobrist creation functions # -# # -############################################################################ - - -cdef unsigned long long* get_zobrist_lookup(char size) diff --git a/AlphaGo/go/go_data.pyx b/AlphaGo/go/go_data.pyx deleted file mode 100644 index 366673551..000000000 --- a/AlphaGo/go/go_data.pyx +++ /dev/null @@ -1,611 +0,0 @@ -# cython: wraparound=False -# cython: boundscheck=False -# cython: initializedcheck=False -# cython: nonecheck=False -cimport cython -import numpy as np -cimport numpy as np -from libc.stdlib cimport malloc, free, realloc -from libc.string cimport memcpy, memset, memchr - -""" - Future speedups, right now the usage of C dicts and List is copied from original - Java implementation. not all usages have been tested for max performance. - - possible speedups could be swapping certain dicts for lists and vice versa. - more testing should be done where this might apply. - - some notes: - - using list for Group stone&liberty locations? - - do we need to consider 25*25 boards? - - dict for moves_legal instead of list? - - create mixed short+char arrays to store location+value in one array? - - implement dict+list struct to get fast lookup and fast looping over all elements - - store one liberty&stone location in group for fast lookup of group location/liberty - - implement faster loop over all elements for dict using memchr and offset pointer -""" - - -############################################################################ -# Structs defined in go_data.pxd # -# # -############################################################################ - -""" -# struct to store group stone and liberty locations -# -# locations is a char pointer array of size board_size and initialized -# to _FREE. after adding a stone/liberty that location is set to -# _STONE/_LIBERTY and count_stones/count_liberty is incremented -# -# note that a stone location can never be a liberty location, -# if a stone is placed on a liberty location liberty_count is decremented -# -# it works as a dictionary so lookup time for a location is O(1) -# looping over all stone/liberty location could be optimized by adding -# two lists containing stone/liberty locations -# -# TODO check if this dictionary implementation is faster on average -# use as a two list implementation - - -cdef struct Group: - char* locations - short count_stones - short count_liberty - char color - -# struct to store a list of Group -# -# board_groups is a Group pointer array of size #size and containing -# #count_groups groups -# -# TODO convert to c++ list? - - -cdef struct Groups_List: - Group** board_groups - short count_groups - short size - -# struct to store a list of short (board locations) -# -# locations is a short pointer array of size #size and containing -# #count locations -# -# TODO convert to c++ list and/or set - - -cdef struct Locations_List: - short* locations - short count - short size -""" - -############################################################################ -# group functions # -# # -############################################################################ - - -cdef Group* group_new(char color, short size): - """Create new struct Group with locations set to _FREE - """ - - cdef int i - - # allocate memory for Group - cdef Group *group = malloc(sizeof(Group)) - if not group: - raise MemoryError() - - # allocate memory for array locations - group.locations = malloc(size) - if not group.locations: - raise MemoryError() - - # set counts to 0 and color to color - group.count_stones = 0 - group.count_liberty = 0 - group.color = color - - # initialize locations with _FREE - memset(group.locations, _FREE, size) - - return group - - -cdef Group* group_duplicate(Group* group, short size): - """Create new struct Group initialized as a duplicate of group - """ - - cdef int i - - # allocate memory for Group - cdef Group *duplicate = malloc(sizeof(Group)) - if not duplicate: - raise MemoryError() - - # allocate memory for array locations - duplicate.locations = malloc(size) - if not duplicate.locations: - raise MemoryError() - - # set counts and color values - duplicate.count_stones = group.count_stones - duplicate.count_liberty = group.count_liberty - duplicate.color = group.color - - # duplicate locations array in memory - # memcpy is optimized to do this quickly - memcpy(duplicate.locations, group.locations, size) - - return duplicate - - -cdef void group_destroy(Group* group): - """Free memory location of group and locations - """ - - # check if group exists - if group is not NULL: - # check if locations exists - if group.locations is not NULL: - # free locations - free(group.locations) - # free group - free(group) - - -cdef void group_add_stone(Group* group, short location): - """Update location as _STONE - - update liberty count if it was a liberty location - n.b. stone count is not incremented if a stone was present already - """ - - # check if locations is a liberty - if group.locations[location] == _FREE: - # locations is _FREE, increment stone count - group.count_stones += 1 - - elif group.locations[location] == _LIBERTY: - # locations is _LIBERTY, increment stone count and decrement liberty count - group.count_stones += 1 - group.count_liberty -= 1 - - # set _STONE - group.locations[location] = _STONE - - -cdef void group_remove_stone(Group* group, short location): - """Update location as _FREE - - update stone count if it was a stone location - """ - - # check if a stone is present - if group.locations[location] == _STONE: - # stone present, decrement stone count and set location to _FREE - group.count_stones -= 1 - group.locations[location] = _FREE - - -cdef short group_location_stone(Group* group, short size): - """Return first location where a _STONE is located - """ - - # memchr is a in memory search function, it starts searching at - # pointer location #group.locations for a max of size continous bytes untill - # a location with value _STONE is found -> returns a pointer to this location - # when this pointer location is substracted with pointer #group.locations - # the location is calculated where a stone is - return (memchr(group.locations, _STONE, size) - group.locations) - - -cdef void group_add_liberty(Group* group, short location): - """Update location as _LIBERTY - - update liberty count if it was a _FREE location - n.b. liberty count is not incremented if a stone was present already - """ - - # check if location is _FREE - if group.locations[location] == _FREE: - # increment liberty count, set location to _LIBERTY - group.count_liberty += 1 - group.locations[location] = _LIBERTY - - -cdef void group_remove_liberty(Group* group, short location): - """Update location as _FREE - - update liberty count if it was a _LIBERTY location - n.b. liberty count is not decremented if location is a _FREE location - """ - - # check if location is _LIBERTY - if group.locations[location] == _LIBERTY: - # decrement liberty count, set location to _FREE - group.count_liberty -= 1 - group.locations[location] = _FREE - - -cdef short group_location_liberty(Group* group, short size): - """Return location where a _LIBERTY is located - """ - - # memchr is a in memory search function, it starts searching at - # pointer location #group.locations for a max of size continous bytes untill - # a location with value _LIBERTY is found -> returns a pointer to this location - # when this pointer location is substracted with pointer #group.locations - # the location is calculated where a liberty is - return (memchr(group.locations, _LIBERTY, size) - group.locations) - - -############################################################################ -# Groups_List functions # -# # -############################################################################ - - -cdef Groups_List* groups_list_new(short size): - """Create new empty Groups_List - """ - - cdef Groups_List* list_new - - list_new = malloc(sizeof(Groups_List)) - if not list_new: - raise MemoryError() - - list_new.board_groups = malloc(size * sizeof(Group*)) - if not list_new.board_groups: - raise MemoryError() - - list_new.count_groups = 0 - - return list_new - - -cdef void groups_list_add(Group* group, Groups_List* groups_list): - """Add group to list and increment groups count - """ - - groups_list.board_groups[groups_list.count_groups] = group - groups_list.count_groups += 1 - - -cdef void groups_list_add_unique(Group* group, Groups_List* groups_list): - """Check if a group is already in the list, return if so, add group to list if not - """ - - cdef int i - - # loop over array - for i in range(groups_list.count_groups): - # check if group is present - if group == groups_list.board_groups[i]: - # group is present, return - return - - # group is not present, add to group - groups_list.board_groups[groups_list.count_groups] = group - groups_list.count_groups += 1 - - -cdef void groups_list_remove(Group* group, Groups_List* groups_list): - """Remove group from list and decrement groups count - """ - - cdef int i - - # loop over array - for i in range(groups_list.count_groups): - # check if group is present - if groups_list.board_groups[i] == group: - # group is present, move last group to this location and decrement groups count - groups_list.count_groups -= 1 - groups_list.board_groups[i] = groups_list.board_groups[groups_list.count_groups] - return - - raise RuntimeError("Removing nonexistent group!") - - -############################################################################ -# Locations_List functions # -# # -############################################################################ - - -cdef Locations_List* locations_list_new(short size): - """Create new empty Locations_List - """ - - cdef Locations_List* list_new - - # allocate memory for Group - list_new = malloc(sizeof(Locations_List)) - if not list_new: - raise MemoryError() - - # allocate memory for locations - list_new.locations = malloc(size * sizeof(short)) - if not list_new.locations: - raise MemoryError() - - # set count to 0 - list_new.count = 0 - - # set size - list_new.size = size - - return list_new - - -cdef void locations_list_destroy(Locations_List* locations_list): - """Free memory location of locations_list and locations - """ - - # check if locations_list exists - if locations_list is not NULL: - # check if locations exists - if locations_list.locations is not NULL: - # free locations - free(locations_list.locations) - # free locations_list - free(locations_list) - - -cdef void locations_list_remove_location(Locations_List* locations_list, short location): - """Remove location from list - """ - - cdef int i - - # loop over array - for i in range(locations_list.count): - # check if [i] == location - if locations_list.locations[i] == location: - # location found, move last value to this location - # and decrement count - locations_list.count -= 1 - locations_list.locations[i] = locations_list.locations[locations_list.count] - return - - raise RuntimeError("Removing nonexistent location!") - - -cdef void locations_list_add_location(Locations_List* locations_list, short location): - """Add location to list and increment count - """ - - locations_list.locations[locations_list.count] = location - locations_list.count += 1 - - -cdef void locations_list_add_location_increment(Locations_List* locations_list, short location): - """Check if list can hold one more location, resize list if not - add location to list and increment count - """ - - if locations_list.count == locations_list.size: - - locations_list.size += 10 - locations_list.locations = realloc(locations_list.locations, - locations_list.size * sizeof(short)) - if not locations_list.locations: - raise MemoryError() - - locations_list.locations[locations_list.count] = location - locations_list.count += 1 - - -cdef void locations_list_add_location_unique(Locations_List* locations_list, short location): - """Check if location is present in list, return if so - add location to list if not - """ - - cdef int i - - # loop over array - for i in range(locations_list.count): - # check if location is present - if location == locations_list.locations[i]: - # location found, do nothing -> return - return - - # add location to list and increment count - locations_list.locations[locations_list.count] = location - locations_list.count += 1 - - -############################################################################ -# neighbor creation functions # -# # -############################################################################ - - -cdef short calculate_board_location(char x, char y, char size): - """Return location on board - no checks on outside board - x = columns - y = rows - """ - - # return board location - return x + (y * size) - - -cdef short calculate_board_location_or_border(char x, char y, char size): - """Return location on board or borderlocation - board locations = [0, size * size) - border location = size * size - x = columns - y = rows - """ - - # check if x or y are outside board - if x < 0 or y < 0 or x >= size or y >= size: - # return border location - return size * size - - # return board location - return calculate_board_location(x, y, size) - - -cdef short* get_neighbors(char size): - """Create array for every board location with all 4 direct neighbor locations - neighbor order: left - right - above - below - - -1 x - x x - +1 x - - order: - -1 2 - 0 1 - +1 3 - - TODO neighbors is obsolete as neighbor3x3 contains the same values - """ - - # create array - cdef short* neighbor = malloc(size * size * 4 * sizeof(short)) - if not neighbor: - raise MemoryError() - - cdef short location - cdef char x, y - - # add all direct neighbors to every board location - for y in range(size): - for x in range(size): - location = (x + (y * size)) * 4 - neighbor[location + 0] = calculate_board_location_or_border(x - 1, y, size) - neighbor[location + 1] = calculate_board_location_or_border(x + 1, y, size) - neighbor[location + 2] = calculate_board_location_or_border(x, y - 1, size) - neighbor[location + 3] = calculate_board_location_or_border(x, y + 1, size) - - return neighbor - - -cdef short* get_3x3_neighbors(char size): - """Create for every board location array with all 8 surrounding neighbor locations - neighbor order: above middle - middle left - middle right - below middle - above left - above right - below left - below right - this order is more useful as it separates neighbors and then diagonals - -1 xxx - x x - +1 xxx - - order: - -1 405 - 1 2 - +1 637 - - 0-3 contains neighbors - 4-7 contains diagonals - """ - - # create array - cdef short* neighbor3x3 = malloc(size * size * 8 * sizeof(short)) - if not neighbor3x3: - raise MemoryError() - - cdef short location - cdef char x, y - - # add all surrounding neighbors to every board location - for x in range(size): - for y in range(size): - location = (x + (y * size)) * 8 - # Cardinal directions - neighbor3x3[location + 0] = calculate_board_location_or_border(x, y - 1, size) - neighbor3x3[location + 1] = calculate_board_location_or_border(x - 1, y, size) - neighbor3x3[location + 2] = calculate_board_location_or_border(x + 1, y, size) - neighbor3x3[location + 3] = calculate_board_location_or_border(x, y + 1, size) - # Diagonal directions - neighbor3x3[location + 4] = calculate_board_location_or_border(x - 1, y - 1, size) - neighbor3x3[location + 5] = calculate_board_location_or_border(x + 1, y - 1, size) - neighbor3x3[location + 6] = calculate_board_location_or_border(x - 1, y + 1, size) - neighbor3x3[location + 7] = calculate_board_location_or_border(x + 1, y + 1, size) - - return neighbor3x3 - - -cdef short* get_12d_neighbors(char size): - """Create array for every board location with 12d star neighbor locations - neighbor order: top star tip - above left - above middle - above right - left star tip - left - right - right star tip - below left - below middle - below right - below star tip - - -2 x - -1 xxx - xx xx - +1 xxx - +2 x - - order: - -2 0 - -1 123 - 45 67 - +1 89a - +2 b - """ - - # create array - cdef short* neighbor12d = malloc(size * size * 12 * sizeof(short)) - if not neighbor12d: - raise MemoryError() - - cdef short location - cdef char x, y - - # add all 12d neighbors to every board location - for x in range(size): - for y in range(size): - location = (x + (y * size)) * 12 - neighbor12d[location + 4] = calculate_board_location_or_border(x, y - 2, size) - - neighbor12d[location + 1] = calculate_board_location_or_border(x - 1, y - 1, size) - neighbor12d[location + 5] = calculate_board_location_or_border(x, y - 1, size) - neighbor12d[location + 8] = calculate_board_location_or_border(x + 1, y - 1, size) - - neighbor12d[location + 0] = calculate_board_location_or_border(x - 2, y, size) - neighbor12d[location + 2] = calculate_board_location_or_border(x - 1, y, size) - neighbor12d[location + 9] = calculate_board_location_or_border(x + 1, y, size) - neighbor12d[location + 11] = calculate_board_location_or_border(x + 2, y, size) - - neighbor12d[location + 3] = calculate_board_location_or_border(x - 1, y + 1, size) - neighbor12d[location + 6] = calculate_board_location_or_border(x, y + 1, size) - neighbor12d[location + 10] = calculate_board_location_or_border(x + 1, y + 1, size) - - neighbor12d[location + 7] = calculate_board_location_or_border(x, y + 2, size) - - return neighbor12d - - -############################################################################ -# zobrist creation functions # -# # -############################################################################ - - -cdef unsigned long long* get_zobrist_lookup(char size): - """Generate zobrist lookup array for boardsize size - """ - - cdef unsigned long long* zobrist_lookup - - zobrist_lookup = malloc((size * size * 2) * sizeof(unsigned long long)) - if not zobrist_lookup: - raise MemoryError() - - # initialize all zobrist hash lookup values - for i in range(size * size * 2): - zobrist_lookup[i] = np.random.randint(np.iinfo(np.uint64).max, dtype='uint64') - - return zobrist_lookup diff --git a/AlphaGo/go/group_logic.pxd b/AlphaGo/go/group_logic.pxd new file mode 100644 index 000000000..72de1dd14 --- /dev/null +++ b/AlphaGo/go/group_logic.pxd @@ -0,0 +1,82 @@ +from AlphaGo.go.constants cimport stone_t, group_t +from libcpp.unordered_map cimport unordered_map +from libcpp.memory cimport shared_ptr +from libcpp.vector cimport vector + + +############################################################################ +# Struct definition # +# # +############################################################################ + +ctypedef short location_t + +"""Class to store stone and liberty locations of a single 'group' + + 'locations' is a map from 1d board location to a 'group_t' type. Most groups only store + STONE and LIBERTY data. Other enum values used by ladder checks. + + Both count_stones and count_liberty are tracked as stones are added and removed from the group. + + Board size should be available from the context. This is relevant for modulo-arithmetic involved + in computing locations. + + Note on use of cppclass as opposed to 'structs' or 'extension types': + - The cppclass is treated as a plain C++ object, allowing smart pointers, vectors, etc. + - Unlike 'structs', the cppclass has C++ semantics, with a constructor, copy constructor, and + destructor. Copies are 'deep' automatically. +""" +cdef cppclass Group: + stone_t color + short count_stones, count_liberty + unordered_map[location_t, group_t] locations + +ctypedef shared_ptr[Group] group_ptr_t # smart pointer with reference counting wrapping a 'Group' +ctypedef vector[group_ptr_t] board_group_t # type for group-lookup by board position + + +############################################################################ +# Simple & fast group functions # +# # +############################################################################ + +cdef group_ptr_t group_new(stone_t color) +"""Create new Group with empty set of locations. +""" + +cdef group_ptr_t group_duplicate(group_ptr_t group) +"""Create a (deep) copy of the given group-pointer. +""" + +cdef void group_add_stone(group_ptr_t group, location_t location) +"""Update location as STONE and update counts. +""" + +cdef void group_remove_stone(group_ptr_t group, location_t location) +"""Update location as FREE and update counts. +""" + +cdef void group_merge(group_ptr_t group, group_ptr_t other) +"""Merge groups by copying stones from 'other' into 'group'. Ensures that liberties are + appropriately updated. Leaves 'other' unchanged. +""" + +cdef location_t group_get_stone(group_ptr_t group) +"""Return any one location where there is a STONE. +""" + +cdef void group_add_liberty(group_ptr_t group, location_t location) +"""Update location as LIBERTY and update counts. +""" + +cdef void group_remove_liberty(group_ptr_t group, location_t location) +"""Update location as FREE and update counts. +""" + +cdef location_t group_get_liberty(group_ptr_t group) +"""Return any one location that is flagged as LIBERTY. +""" + +cdef group_t group_lookup(group_ptr_t group, location_t location) +"""Return stone type at 'location' or FREE. +""" diff --git a/AlphaGo/go/group_logic.pyx b/AlphaGo/go/group_logic.pyx new file mode 100644 index 000000000..c1a7af59a --- /dev/null +++ b/AlphaGo/go/group_logic.pyx @@ -0,0 +1,138 @@ +# cython: wraparound=False +# cython: boundscheck=False +# cython: initializedcheck=False +# cython: nonecheck=False +from cython.operator cimport dereference as d + + +############################################################################ +# Simple & fast group functions # +# # +############################################################################ + +cdef group_ptr_t group_new(stone_t color): + """Create new struct Group with empty set of locations. + """ + + # Initialize group and its members using C++ "new". Members are automatically initialized to + # defaults. + cdef group_ptr_t group = group_ptr_t(new Group()) + d(group).color = color + + return group + +cdef group_ptr_t group_duplicate(group_ptr_t group): + """Create a (deep) copy of the given group-pointer. + """ + + # Create new Group object with same color. + cdef group_ptr_t new_group = group_new(d(group).color) + + # Copy each field individually; using C++ this amounts to a 'deep copy' of group.locations. + d(new_group).count_stones = d(group).count_stones + d(new_group).count_liberty = d(group).count_liberty + d(new_group).locations = d(group).locations + + return new_group + +cdef void group_add_stone(group_ptr_t group, location_t location): + """Update location as STONE and update counts. + """ + + cdef group_t current_type = group_lookup(group, location) + + # Update counts + if current_type != group_t.STONE: + # Note: '+=' does not work with the d() operator. cython bug! + d(group).count_stones = d(group).count_stones + 1 + if current_type == group_t.LIBERTY: + d(group).count_liberty = d(group).count_liberty - 1 + + # Flag location as STONE + d(group).locations[location] = group_t.STONE + +cdef void group_remove_stone(group_ptr_t group, location_t location): + """Update location as FREE and update counts. + """ + + cdef group_t current_type = group_lookup(group, location) + + # Check if a stone is present + if current_type == group_t.STONE: + # Stone present, decrement stone count and clear location + d(group).count_stones = d(group).count_stones - 1 + d(group).locations.erase(location) + +cdef void group_merge(group_ptr_t group, group_ptr_t other): + """Merge groups by copying stones from 'other' into 'group'. Ensures that liberties are + appropriately updated. Leaves 'other' unchanged. + """ + cdef location_t loc + cdef group_t val + for loc, val in d(other).locations: + # Add all stones from 'other' into 'group' + if val == group_t.STONE: + group_add_stone(group, loc) + # Only add liberties from 'other' into 'group' if it would not overwrite something else + elif val == group_t.LIBERTY and group_lookup(group, loc) == group_t.FREE: + group_add_liberty(group, loc) + +cdef location_t group_get_stone(group_ptr_t group): + """Return any one location where there is a group_t.STONE or -1 if not found. + """ + + # Iterate until a STONE is found. + cdef location_t loc + cdef group_t val + for loc, val in d(group).locations: + if val == group_t.STONE: + return loc + return -1 + +cdef void group_add_liberty(group_ptr_t group, location_t location): + """Update location as LIBERTY and update counts. + """ + + cdef group_t current_type = group_lookup(group, location) + + # Update counts + if current_type != group_t.LIBERTY: + d(group).count_liberty = d(group).count_liberty + 1 + if current_type == group_t.STONE: + d(group).count_stones = d(group).count_stones - 1 + + # Set LIBERTY + d(group).locations[location] = group_t.LIBERTY + +cdef void group_remove_liberty(group_ptr_t group, location_t location): + """Update location as FREE and update counts. + """ + + cdef group_t current_type = group_lookup(group, location) + + if current_type == group_t.LIBERTY: + # Decrement liberty count and clear location + d(group).count_liberty = d(group).count_liberty - 1 + d(group).locations.erase(location) + +cdef location_t group_get_liberty(group_ptr_t group): + """Return any one location that is flagged as LIBERTY or -1 if not found. + """ + + # Iterate until a LIBERTY is found. + cdef location_t loc + cdef group_t val + for loc, val in d(group).locations: + if val == group_t.LIBERTY: + return loc + return -1 + +cdef group_t group_lookup(group_ptr_t group, location_t location): + """Return stone type at 'location' or FREE. + """ + + # Look up location in map using C++ iterator comparison (i.e. 'location in group.locations') + if d(group).locations.find(location) != d(group).locations.end(): + return d(group).locations[location] + else: + return group_t.FREE diff --git a/AlphaGo/go/ladders.pxd b/AlphaGo/go/ladders.pxd new file mode 100644 index 000000000..d426685ae --- /dev/null +++ b/AlphaGo/go/ladders.pxd @@ -0,0 +1,57 @@ +from AlphaGo.go.constants cimport stone_t, group_t +from AlphaGo.go.group_logic cimport Group, group_get_liberty, group_get_stone +from AlphaGo.go.game_state cimport GameState +from libcpp cimport bool +from libcpp.memory cimport shared_ptr +from libcpp.vector cimport vector + + +ctypedef short location_t +ctypedef shared_ptr[Group] group_ptr_t # smart pointer with reference counting wrapping a 'Group' +ctypedef vector[group_ptr_t] board_group_t # type for group-lookup by board position +ctypedef vector[location_t] pattern_t # lookup of neighbor coordinates (or border) + + +cdef bool is_ladder_escape_move(GameState copy_state, group_ptr_t prey, location_t move, int depth=*) # noqa:E501 +"""(Inefficiently) check whether the given move escapes ladder capture of the given group. + Returns True when escape is possible, or recursion depth limit is reached (assuming that the + opponent does not recognize ladders with greater depth as a 'capture' either) + + Preconditions: + - GameState 'copy_state' is safe to be altered + - prey group is in atari and owned by copy_state.current_player + - given move is legal + - depth >= 0 + + Note: while optimizations can be made, this version is easy to understand, and ladders are + less likely to be used as features in a production computer-go system. +""" + +cdef bool is_ladder_capture_move(GameState copy_state, group_ptr_t prey, location_t move, int depth=*) # noqa:E501 +"""(Inefficiently) check whether the given move captures the prey, or forces capture of the + prey by a ladder within 'depth' moves. + + Preconditions: + - GameState 'copy_state' is safe to be altered + - prey group has <= 2 liberties and is owned by the opponent + - given move is legal + - depth >= 0 + + Note: while optimizations can be made, this version is easy to understand, and ladders are + less likely to be used as features in a production computer-go system. +""" + +cdef set get_plausible_escape_moves(GameState state, group_ptr_t prey) +"""Get set of moves that should be checked as possible escape moves for the given prey. +""" + +cdef set get_plausible_capture_moves(GameState state, group_ptr_t prey) +"""Get set of moves that should be checked as possible escape moves for the given prey. +""" + +cdef set get_adjacent_captures(group_ptr_t group, board_group_t &board, pattern_t &neighbor_lookup) +"""Search for moves that the owner of 'group' could play that would kill opponent groups + adjacent to it. + + Note: this would (probably) consitute an escape from a ladder if 'group' is in atari. +""" diff --git a/AlphaGo/go/ladders.pyx b/AlphaGo/go/ladders.pyx new file mode 100644 index 000000000..baeb3ae19 --- /dev/null +++ b/AlphaGo/go/ladders.pyx @@ -0,0 +1,172 @@ +# cython: wraparound=False +# cython: boundscheck=False +# cython: initializedcheck=False +# cython: nonecheck=False +from AlphaGo.util import unflatten_idx +from cython.operator cimport dereference as d + + +cdef bool is_ladder_escape_move(GameState copy_state, group_ptr_t prey, location_t move, int depth=50): # noqa:E501 + """(Inefficiently) check whether the given move escapes ladder capture of the given group. + Returns True when escape is plausible, or recursion depth limit is reached (assuming that the + opponent does not recognize ladders with greater depth as a 'capture' either) + + Preconditions: + - GameState 'copy_state' is safe to be altered + - prey group is in atari and owned by copy_state.current_player + - given move is legal + - depth >= 0 + + Note: while optimizations can be made by avoiding copied states, this version is at least + easily comprehensible. + """ + + cdef location_t prey_loc = group_get_stone(prey) + + # Base case: if search depth is exhausted, assume that the ladder is escable. + if depth <= 0: + return True + + # Try move and check results. + copy_state.do_move(unflatten_idx(move, copy_state.size)) + prey = copy_state.board[prey_loc] + + # Case 1: prey has >= 3 liberties after move, in which case it escaped. + if d(prey).count_liberty >= 3: + return True + + # Case 2: prey is left in atari, in which case it did not escape. + elif d(prey).count_liberty == 1: + return False + + # Case 3: prey has 2 liberties left, in which case it may still be captured in a ladder. + # Requires recursive search. + else: + # Opponent may attempt to capture at either of the prey's two liberties. + for plausible_capture in get_plausible_capture_moves(copy_state, prey): + if is_ladder_capture_move(copy_state.copy(), prey, plausible_capture, depth - 1): + return False + + # If reached here, none of prey's liberties are ladder captures, in which case it escaped. + return True + +cdef bool is_ladder_capture_move(GameState copy_state, group_ptr_t prey, location_t move, int depth=50): # noqa:E501 + """(Inefficiently) check whether the given move captures the prey, or forces capture of the + prey by a ladder within 'depth' moves. + + Preconditions: + - GameState 'copy_state' is safe to be altered + - prey group has <= 2 liberties and is owned by the opponent + - given move is legal + - depth >= 0 + + Note: while optimizations can be made by avoiding copied states, this version is at least + easily comprehensible. + """ + + cdef location_t prey_loc = group_get_stone(prey) + + # Base case: if search depth is exhausted, assume that ladder is not capturable. + if depth <= 0: + return False + + # Try the move and check results + copy_state.do_move(unflatten_idx(move, copy_state.size)) + prey = copy_state.board[prey_loc] + + # Case 1: prey has >= 2 liberties after move, in which case it escaped. + if d(prey).count_liberty >= 2: + return False + + # Case 2: prey has 1 liberty after move, in which case it may still attempt to escape. Requires + # recursive search. + elif d(prey).count_liberty == 1: + # Try each potential escape move + for plausible_escape in get_plausible_escape_moves(copy_state, prey): + if is_ladder_escape_move(copy_state.copy(), prey, plausible_escape, depth - 1): + return False + + # If reached here, either prey was captured or no escape move was found + return True + +cdef set get_plausible_escape_moves(GameState state, group_ptr_t prey): + """Get set of moves that should be checked as plausible escape moves for the given prey. + + Preconditions: + - current_player is prey owner + - prey is in atari + """ + + cdef set to_remove = set(), plausible_escapes = set() + cdef location_t move + cdef stone_t owner = d(prey).color + cdef bool is_sensible + + # Plausible escapes 1: remaining liberty of the prey group + plausible_escapes.add(group_get_liberty(prey)) + + # Plausible escapes 2: any moves that capture a group adjacent to the prey (since this would + # free up new liberties) + plausible_escapes.update(get_adjacent_captures(prey, state.board, d(state.ptr_neighbor))) + + # Ensure that all moves are not only legal, but 'sensible'. + for move in plausible_escapes: + is_sensible = state.is_legal_move(move) and not state.is_true_eye(move, owner) + if not is_sensible: + to_remove.add(move) + plausible_escapes.difference_update(to_remove) + + return plausible_escapes + +cdef set get_plausible_capture_moves(GameState state, group_ptr_t prey): + """Get set of moves that should be checked as plausible escape moves for the given prey. + + Preconditions: + - current_player is opponent of prey + - prey has exactly 2 liberties + """ + + cdef set to_remove = set(), plausible_captures = set() + cdef location_t loc + cdef group_t val + + for loc, val in d(prey).locations: + if val == group_t.LIBERTY: + plausible_captures.add(loc) + + # Ensure that all moves are legal (note that no 'sensibility' check is needed here since a move + # cannot be both a liberty of the other player and an eye of the current player). + for move in plausible_captures: + if not state.is_legal_move(move): + to_remove.add(move) + plausible_captures.difference_update(to_remove) + + return plausible_captures + +cdef set get_adjacent_captures(group_ptr_t group, board_group_t &board, pattern_t &neighbor_lookup): + """Search for moves that the owner of 'group' could play that would kill opponent groups + adjacent to it. This would consitute an escape from a ladder if 'group' is in atari. + """ + + # 'atari_liberties' holds liberty locations of enemy groups that are adjacent to the given group + # 'and are in atari (hence could be captured by playing at the given location) + cdef set atari_liberties = set() + cdef group_ptr_t neighbor_group + cdef location_t loc, opp_loc, neighbor_loc + cdef group_t val, opp_val + cdef stone_t owner = d(group).color + cdef int i + + for loc, val in d(group).locations: + if val == group_t.STONE: + for i in range(4): + neighbor_loc = neighbor_lookup[loc * 4 + i] + neighbor_group = board[neighbor_loc] + + # Check if neighbor of this group stone is opponent group. + if d(neighbor_group).color > stone_t.EMPTY and d(neighbor_group).color != owner: + # Further check if opponent group is in atari and can be captured. + if d(neighbor_group).count_liberty == 1: + # Find the one liberty of this group and mark it as an escape move. + atari_liberties.add(group_get_liberty(neighbor_group)) + return atari_liberties diff --git a/AlphaGo/go/zobrist.pxd b/AlphaGo/go/zobrist.pxd new file mode 100644 index 000000000..dd6c4d2ec --- /dev/null +++ b/AlphaGo/go/zobrist.pxd @@ -0,0 +1,24 @@ +from AlphaGo.go.constants cimport stone_t, group_t +from AlphaGo.go.group_logic cimport Group +from libcpp.vector cimport vector +from libcpp.memory cimport shared_ptr + + +ctypedef short location_t +ctypedef unsigned long long zobrist_hash_t +ctypedef shared_ptr[Group] group_ptr_t # smart pointer with reference counting wrapping a 'Group' + + +cdef vector[zobrist_hash_t] get_zobrist_lookup(short size) +"""Generate zobrist lookup table for given board size (given as single-edge dimension) +""" + + +cdef zobrist_hash_t update_hash_by_location(zobrist_hash_t current_hash, vector[zobrist_hash_t] lut, location_t location, stone_t color) # noqa: E501 +"""Update zobrist hash for a single location and color. +""" + + +cdef zobrist_hash_t update_hash_by_group(zobrist_hash_t current_hash, vector[zobrist_hash_t] lut, group_ptr_t group) # noqa: E501 +"""Update zobrist hash for an entire group. +""" diff --git a/AlphaGo/go/zobrist.pyx b/AlphaGo/go/zobrist.pyx new file mode 100644 index 000000000..4ab92f96a --- /dev/null +++ b/AlphaGo/go/zobrist.pyx @@ -0,0 +1,46 @@ +# cython: initializedcheck=False +# cython: nonecheck=False +from cython.operator cimport dereference as d +import numpy as np +cimport numpy as np + + +cdef vector[zobrist_hash_t] get_zobrist_lookup(short size): + """Generate zobrist lookup table for given board size (given as single-edge dimension) + """ + + # One entry per board location per color + cdef int num_entries = size * size * 2 + cdef vector[zobrist_hash_t] table = vector[zobrist_hash_t](num_entries) + + # Initialize all zobrist hash lookup values + for i in range(num_entries): + table[i] = np.random.randint(np.iinfo(np.uint64).max, dtype='uint64') + + return table + + +cdef zobrist_hash_t update_hash_by_location(zobrist_hash_t current_hash, vector[zobrist_hash_t] table, location_t location, stone_t color): # noqa: E501 + """Update zobrist hash for a single location and color. This applies to both adding and removing + a stone. + """ + + # Even indices of the table used for white stones, odd used for black stones. Current hash is + # XORed with the table entry for this combination of location and color. + return current_hash ^ table[2 * location + (color == stone_t.BLACK)] + + +cdef zobrist_hash_t update_hash_by_group(zobrist_hash_t current_hash, vector[zobrist_hash_t] table, group_ptr_t group): # noqa: E501 + """Update zobrist hash for an entire group. This applies both to adding and removing groups. + """ + + cdef location_t loc + cdef group_t value + cdef stone_t color = d(group).color + + # Update the hash for every _STONE in this group + for loc, value in d(group).locations: + if value == group_t.STONE: + current_hash = update_hash_by_location(current_hash, table, loc, color) + + return current_hash diff --git a/AlphaGo/go_python.py b/AlphaGo/go_python.py index f58b8a491..0b73e9629 100644 --- a/AlphaGo/go_python.py +++ b/AlphaGo/go_python.py @@ -333,7 +333,7 @@ def is_ladder_capture(self, action, prey=None, remaining_attempts=80): If prey is None, check all adjacent groups, otherwise only the prey group is checked. In the (prey is None) case, if this move is a ladder - capture for any adjance group, it's considered a ladder capture. + capture for any adjacent group, it's considered a ladder capture. Recursion depth between is_ladder_capture() and is_ladder_escape() is controlled by the remaining_attempts argument. If it reaches 0, the diff --git a/AlphaGo/mcts.py b/AlphaGo/mcts.py index de30b0ef7..1e637b012 100644 --- a/AlphaGo/mcts.py +++ b/AlphaGo/mcts.py @@ -182,11 +182,7 @@ def _evaluate_rollout(self, state, limit): else: # If no break from the loop, issue a warning. print("WARNING: rollout reached move limit") - winner = state.get_winner() - if winner == 0: - return 0 - else: - return 1 if winner == player else -1 + return 1 if state.get_winner_color() == player else -1 def get_move(self, state): """Runs all playouts sequentially and returns the most visited action. diff --git a/AlphaGo/preprocessing/generate_value_training.py b/AlphaGo/preprocessing/generate_value_training.py index 06ca8ab4c..8e2f42af7 100644 --- a/AlphaGo/preprocessing/generate_value_training.py +++ b/AlphaGo/preprocessing/generate_value_training.py @@ -141,7 +141,7 @@ def convert(state_list, preprocessor): file_name = '0' + file_name # determine winner - winner_game = 'WHITE' if gm.get_winner() == WHITE else 'BLACK' + winner_game = 'WHITE' if gm.get_winner_color() == WHITE else 'BLACK' random_player = 'WHITE' if color == WHITE else 'BLACK' # generate file name @@ -162,7 +162,7 @@ def convert(state_list, preprocessor): # winner BLACK & color WHITE -> LOSE # winner WHITE & color Black -> LOSE actual_batch_size = len(states) - winners = np.array([WIN if st.get_winner() == color else + winners = np.array([WIN if st.get_winner_color() == color else LOSE for st in states]).reshape(actual_batch_size, 1) return training_states, winners diff --git a/AlphaGo/preprocessing/preprocessing.pxd b/AlphaGo/preprocessing/preprocessing.pxd index 1e727db40..0c4ecf910 100644 --- a/AlphaGo/preprocessing/preprocessing.pxd +++ b/AlphaGo/preprocessing/preprocessing.pxd @@ -1,63 +1,69 @@ -import ast -import time -from libc.stdlib cimport malloc, free +from AlphaGo.go.constants cimport stone_t, group_t, action_t from AlphaGo.go.game_state cimport GameState -from AlphaGo.go.go_data cimport _BLACK, _EMPTY, _STONE, _LIBERTY, _CAPTURE, \ - _FREE, _PASS, Group, Locations_List, locations_list_destroy, \ - locations_list_new, _HASHVALUE -from numpy cimport ndarray +from AlphaGo.go.group_logic cimport Group, group_new, group_add_stone, group_add_liberty, \ + group_remove_liberty, group_merge, group_lookup +from AlphaGo.go.coordinates cimport get_pattern_hash +from AlphaGo.go.ladders cimport is_ladder_escape_move, is_ladder_capture_move, \ + get_plausible_escape_moves, get_plausible_capture_moves +from libcpp cimport bool +from libcpp.memory cimport shared_ptr +from libcpp.vector cimport vector import numpy as np cimport numpy as np -# type of tensor created -# char works but float might be needed later -ctypedef char tensor_type -# type defining cdef function -ctypedef int (*preprocess_method)(Preprocess, GameState, tensor_type[:, ::1], char*, int) # noqa: E501 +############################################################################ +# Typedefs # +# # +############################################################################ +ctypedef short location_t +ctypedef shared_ptr[Group] group_ptr_t -cdef class Preprocess: - ############################################################################ - # variables declarations # - # # - ############################################################################ +# Feature tensors are 1-hot; using small 8-bit integers for tensor type. +ctypedef np.uint8_t onehot_t + +# 'Lookahead' has features ranging between 0 and board_size. Use int16 for this type. +ctypedef np.uint16_t lookahead_t + +# Type defining cdef function handle. +ctypedef int (*preprocess_method)(Preprocess, GameState, onehot_t[:, :], lookahead_t[:, :], int) + - # all feature processors - # TODO find correct type so an array can be used - cdef preprocess_method *processors +############################################################################ +# Class definition # +# # +############################################################################ - # flag whether or not any features require 'lookahead' to groups_after - cdef bint requires_groups_after +cdef class Preprocess: + + # All feature processors + cdef vector[preprocess_method] processors - # list with all features used currently - # TODO find correct type so an array can be used - cdef list feature_list + # Flag whether or not any features require 'lookahead' to groups_after + cdef bool requires_groups_after - # output tensor size - cdef int output_dim + # List with all string names of features used currently + cdef list feature_list - # board size - cdef char size - cdef short board_size + # Output tensor size + cdef int output_dim - # pattern dictionaries - cdef dict pattern_nakade - cdef dict pattern_response_12d - cdef dict pattern_non_response_3x3 + # Board size, same convention as in GameState + cdef short size, board_size - # pattern dictionary sizes - cdef int pattern_nakade_size - cdef int pattern_response_12d_size - cdef int pattern_non_response_3x3_size + # Sets of pattern hashes of the Top-N most common response patterns of each type + cdef set pattern_nakade + cdef set pattern_response_12d + cdef set pattern_non_response_3x3 ############################################################################ - # Tensor generating functions # + # Feature-generating functions # # # ############################################################################ - cdef int get_board(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + cdef int get_board(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset) # noqa: E501 """A feature encoding _WHITE _BLACK and _EMPTY on separate planes. Note: @@ -66,7 +72,7 @@ cdef class Preprocess: - plane 2 to empty locations """ - cdef int get_turns_since(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + cdef int get_turns_since(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset) # noqa: E501 """A feature encoding the age of the stone at each location up to 'maximum' Note: @@ -74,7 +80,7 @@ cdef class Preprocess: - _EMPTY locations are all-zero features """ - cdef int get_liberties(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + cdef int get_liberties(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset) # noqa: E501 """A feature encoding the number of liberties of the group connected to the stone at each location @@ -84,7 +90,7 @@ cdef class Preprocess: - _EMPTY locations are all-zero features """ - cdef int get_capture_size(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + cdef int get_capture_size(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset) # noqa: E501 """A feature encoding the number of opponent stones that would be captured by playing at each location, up to 'maximum' @@ -96,12 +102,12 @@ cdef class Preprocess: - illegal move locations are all-zero features """ - cdef int get_self_atari_size(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + cdef int get_self_atari_size(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset) # noqa: E501 """A feature encoding the size of the own-stone group that is put into atari by playing at a location """ - cdef int get_liberties_after(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + cdef int get_liberties_after(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset) # noqa: E501 """A feature encoding what the number of liberties *would be* of the group connected to the stone *if* played at a location @@ -111,59 +117,58 @@ cdef class Preprocess: - illegal move locations are all-zero features """ - cdef int get_ladder_capture(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + cdef int get_ladder_capture(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset) # noqa: E501 """A feature wrapping GameState.is_ladder_capture(). Check if an opponent group can be captured in a ladder """ - cdef int get_ladder_escape(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 - """A feature wrapping GameState.is_ladder_escape(). Check if player_current group can escape + cdef int get_ladder_escape(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset) # noqa: E501 + """A feature wrapping GameState.is_ladder_escape(). Check if current_player group can escape ladder """ - cdef int get_sensibleness(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + cdef int get_sensibleness(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset) # noqa: E501 """A move is 'sensible' if it is legal and if it does not fill the current_player's own eye """ - cdef int get_legal(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 - """Zero at all illegal moves, one at all legal moves. Unlike sensibleness, no eye check is done - not used?? + cdef int get_legal(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset) # noqa: E501 + """Zero at all illegal moves, one at all legal moves. Unlike sensibleness, no eyes check. """ - cdef int zeros(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + cdef int zeros(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset) # noqa: E501 """Plane filled with zeros """ - cdef int ones(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + cdef int ones(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset) # noqa: E501 """Plane filled with ones """ - cdef int color(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + cdef int color(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset) # noqa: E501 """Value net feature, plane with ones if active_player is black else zeros """ - cdef int ko(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 + cdef int ko(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset) # noqa: E501 """Ko positions """ - cdef int get_response(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa:E501 + cdef int get_response(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset) # noqa:E501 """Single feature plane encoding whether this location matches any of the response patterns, for now it only checks the 12d response patterns as we do not use the 3x3 response patterns. """ - cdef int get_save_atari(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa:E501 + cdef int get_save_atari(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset) # noqa:E501 """A feature wrapping GameState.is_ladder_escape(). - check if player_current group can escape atari for at least one turn + check if current_player group can escape atari for at least one turn """ - cdef int get_neighbor(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa:E501 + cdef int get_neighbor(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset) # noqa:E501 """Encode last move neighbor positions in two planes: - horizontal & vertical / direct neighbor - diagonal neighbor """ - cdef int get_nakade(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa:E501 + cdef int get_nakade(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset) # noqa:E501 """A nakade pattern is a 12d pattern on a location a stone was captured before it is unclear if a max size of the captured group has to be considered and how recent the capture event should have been @@ -174,41 +179,47 @@ cdef class Preprocess: pattern lookup value is being set instead of 1 """ - cdef int get_nakade_offset(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa:E501 - """A nakade pattern is a 12d pattern on a location a stone was captured before - it is unclear if a max size of the captured group has to be considered and - how recent the capture event should have been - - the 12d pattern can be encoded without stone color and liberty count - unclear if a border location should be considered a stone or liberty - - #pattern_id is offset - """ - - cdef int get_response_12d(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa:E501 + cdef int get_response_12d(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset) # noqa:E501 """Set 12d hash pattern for 12d shape around last move pattern lookup value is being set instead of 1 """ - cdef int get_response_12d_offset(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa:E501 - """Set 12d hash pattern for 12d shape around last move where - #pattern_id is offset - """ - - cdef int get_non_response_3x3(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa:E501 + cdef int get_non_response_3x3(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset) # noqa:E501 """Set 3x3 hash pattern for every legal location where pattern lookup value is being set instead of 1 """ - cdef int get_non_response_3x3_offset(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset) # noqa: E501 - """Set 3x3 hash pattern for every legal location where - #pattern_id is offset - """ ############################################################################ # public cdef function # # # ############################################################################ - cdef np.ndarray[tensor_type, ndim=4] generate_tensor(self, GameState state) - """Convert a GameState to a Theano-compatible tensor + cpdef np.ndarray[onehot_t, ndim=4] state_to_tensor(self, GameState state) + """Convert a GameState to a Theano-compatible tensor of one-hot features """ + +############################################################################ +# "Groups after" helper functions for lookahead without copying state # +# # +############################################################################ + +cdef np.ndarray[lookahead_t, ndim=2] get_groups_after(GameState state) +"""Without creating a copy of the state, compute features of the resulting board state + IF a stone were played at each legal location. Three features are computed and stored in + a board_size x 3 array. Values are only computed at legal move locations. + + That is, groups_after[loc, _STONE] contains the size of the group that would be formed + if the current_player were to play a stone at 'loc', hence the name groups_after + + Returns a numpy array of size (board_size, 3) + - groups_after[loc, 0] = resulting group size by playing at loc + - groups_after[loc, 1] = number of remaining liberties of group by playing at loc + - groups_after[loc, 2] = number of stones captured by playing at loc +""" + +cdef np.ndarray[lookahead_t, ndim=1] get_groups_after_at(GameState state, location_t loc) +"""Compute 'groups_after' results at a single location, which must be a legal move. + + Returns a size (3,) numpy arry with group size in index 0, liberty count in index 1, and + number of opponent stones captured in index 2 (see get_groups_after()) +""" diff --git a/AlphaGo/preprocessing/preprocessing.pyx b/AlphaGo/preprocessing/preprocessing.pyx index 131799180..f43276820 100644 --- a/AlphaGo/preprocessing/preprocessing.pyx +++ b/AlphaGo/preprocessing/preprocessing.pyx @@ -2,7 +2,7 @@ # cython: boundscheck=False # cython: initializedcheck=False # cython: nonecheck=False -cimport cython +from cython.operator cimport dereference as d import numpy as np cimport numpy as np @@ -10,35 +10,30 @@ cimport numpy as np cdef class Preprocess: ############################################################################ - # all variables are declared in the .pxd file # + # Variables are declared in the .pxd file # # # ############################################################################ """ - # all feature processors - # TODO find correct type so an array can be used - cdef list processors - - # list with all features used currently - # TODO find correct type so an array can be used - cdef list feature_list - - # output tensor size - cdef int output_dim - - # board size - cdef char size - cdef short board_size - - # pattern dictionaries - cdef dict pattern_nakade - cdef dict pattern_response_12d - cdef dict pattern_non_response_3x3 - - # pattern dictionary sizes - cdef int pattern_nakade_size - cdef int pattern_response_12d_size - cdef int pattern_non_response_3x3_size + # All feature processors + cdef vector[preprocess_method] processors + + # Flag whether or not any features require 'lookahead' to groups_after + cdef bool requires_groups_after + + # List with all string names of features used currently + cdef list feature_list + + # Output tensor size + cdef int output_dim + + # Board size, same convention as in GameState + cdef short size, board_size + + # Sets of pattern hashes of the Top-N most common response patterns of each type + cdef set pattern_nakade + cdef set pattern_response_12d + cdef set pattern_non_response_3x3 """ ############################################################################ @@ -46,8 +41,8 @@ cdef class Preprocess: # # ############################################################################ - cdef int get_board(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 - """A feature encoding _WHITE _BLACK and _EMPTY on separate planes. + cdef int get_board(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset): # noqa: E501 + """A feature encoding WHITE BLACK and EMPTY on separate planes. Note: - plane 0 always refers to the current player stones @@ -55,132 +50,125 @@ cdef class Preprocess: - plane 2 to empty locations """ - cdef short location - cdef Group* group - cdef int plane - cdef char opponent = state.player_opponent + cdef location_t location + cdef group_ptr_t group + cdef stone_t opponent = state.opponent_player + cdef int plane - # loop over all locations on board + # Loop over all locations on board for location in range(self.board_size): - # Get color of stone from its group - group = state.board_groups[location] - if group.color == _EMPTY: - plane = offset + 2 - elif group.color == opponent: - plane = offset + 1 + group = state.board[location] + if d(group).color == stone_t.EMPTY: + plane = 2 + elif d(group).color == opponent: + plane = 1 else: - plane = offset + plane = 0 - tensor[plane, location] = 1 + tensor[offset + plane, location] = 1 return offset + 3 - cdef int get_turns_since(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + cdef int get_turns_since(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset): # noqa: E501 """A feature encoding the age of the stone at each location up to 'maximum' Note: - the [maximum-1] plane is used for any stone with age greater than or equal to maximum - - _EMPTY locations are all-zero features + - EMPTY locations are all-zero features """ - cdef short location - cdef Locations_List *history = state.moves_history - cdef int i, age = offset + 7 - cdef dict agesSet = {} + cdef location_t location + cdef int age = offset + 7 + cdef set marked_locations = set() - # set all stones to max age - for i in range(history.count): - location = history.locations[i] - if location != _PASS and state.board_groups[location].color > _EMPTY: + # Set all stones to max age + for location in state.moves_history: + if location != action_t.PASS and d(state.board[location]).color > stone_t.EMPTY: tensor[age, location] = 1 - # start with newest stone - i = history.count - 1 - age = 0 - - # loop over history backwards - while age < 7 and i >= 0: - location = history.locations[i] - # if age has not been set yet - if location != _PASS and location not in agesSet and \ - state.board_groups[location].color > _EMPTY: + # Loop over state.moves_history backwards so that recently-captured stones aren't counted. + for age, location in enumerate(reversed(state.moves_history)): + # If age has not been set yet (i.e. this is not a stone that was captured and re-placed) + if location != action_t.PASS and location not in marked_locations and \ + d(state.board[location]).color > stone_t.EMPTY: tensor[offset + age, location] = 1 tensor[offset + 7, location] = 0 - agesSet[location] = location + marked_locations.add(location) - i -= 1 - age += 1 + # Break if reached maximum-1, since all other stones were set to maximum-1 already. + if age >= 6: + break return offset + 8 - cdef int get_liberties(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 - """A feature encoding the number of liberties of the group connected to the stone at - each location + cdef int get_liberties(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset): # noqa: E501 + """A feature encoding the number of liberties of the group connected to the stone at each + location Note: - there is no zero-liberties plane; the 0th plane indicates groups in atari - the [maximum-1] plane is used for any stone with liberties greater than or equal to maximum - - _EMPTY locations are all-zero features + - EMPTY locations are all-zero features """ - cdef int i, groupLiberty - cdef Group* group - cdef short location + cdef int plane + cdef group_ptr_t group + cdef location_t location for location in range(self.board_size): - # Get liberty count from group structure directly - group = state.board_groups[location] - if group.color > _EMPTY: - groupLiberty = min(group.count_liberty - 1, 7) - tensor[offset + groupLiberty, location] = 1 + group = state.board[location] + if d(group).color > stone_t.EMPTY: + plane = min(d(group).count_liberty - 1, 7) + tensor[offset + plane, location] = 1 return offset + 8 - cdef int get_capture_size(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 - """A feature encoding the number of opponent stones that would be captured by - playing at each location, up to 'maximum' + cdef int get_capture_size(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset): # noqa: E501 + """A feature encoding the number of opponent stones that would be captured by playing at + each location, up to 'maximum' Note: - - we currently *do* treat the 0th plane as "capturing zero stones" - - the [maximum-1] plane is used for any capturable group of size - greater than or equal to maximum-1 - - the 0th plane is used for legal moves that would not result in capture + - we currently *do* treat the 0th plane as "capturing zero stones" (that is, the 0th + plane is used for legal moves that would not result in capture) - illegal move locations are all-zero features """ - cdef short i, location, capture_size + cdef location_t location + cdef short capture_size # Loop over all legal moves and set get capture size - for i in range(state.moves_legal.count): - location = state.moves_legal.locations[i] - capture_size = min(groups_after[location * 3 + _CAPTURE], 7) + for location in state.legal_moves: + capture_size = min(groups_after[location, 2], 7) tensor[offset + capture_size, location] = 1 return offset + 8 - cdef int get_self_atari_size(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 - """A feature encoding the size of the own-stone group that is put into atari by - playing at a location + cdef int get_self_atari_size(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset): # noqa: E501 + """A feature encoding the size of the own-stone group that is put into atari by playing at + a location + Note: + - the 0th plane is used for groups of size 1 + - the [maximum-1] plane is used for groups of size greater than or equal to maximum """ - cdef short i, location, group_liberty + cdef location_t location + cdef short liberties, group_size # Loop over all groups on board - for i in range(state.moves_legal.count): - location = state.moves_legal.locations[i] - group_liberty = groups_after[location * 3 + _LIBERTY] + for location in state.legal_moves: + liberties = groups_after[location, 1] # This group is in atari if it has exactly 1 liberty - if group_liberty == 1: - group_liberty = min(groups_after[location * 3 + _STONE] - 1, 7) - tensor[offset + group_liberty, location] = 1 + if liberties == 1: + group_size = min(groups_after[location, 0] - 1, 7) + tensor[offset + group_size, location] = 1 return offset + 8 - cdef int get_liberties_after(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + cdef int get_liberties_after(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset): # noqa: E501 """A feature encoding what the number of liberties *would be* of the group connected to the stone *if* played at a location @@ -191,389 +179,153 @@ cdef class Preprocess: - illegal move locations are all-zero features """ - cdef short i, location, liberty + cdef location_t location + cdef short liberties # Loop over all legal moves - for i in range(state.moves_legal.count): - location = state.moves_legal.locations[i] - if liberty >= 0: - liberty = min(groups_after[location * 3 + _LIBERTY] - 1, 7) - tensor[offset + liberty, location] = 1 + for location in state.legal_moves: + liberties = min(groups_after[location, 1] - 1, 7) + tensor[offset + liberties, location] = 1 return offset + 8 - cdef int get_ladder_capture(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 - """A feature wrapping GameState.is_ladder_capture(). - check if an opponent group can be captured in a ladder + cdef int get_ladder_capture(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset): # noqa: E501 + """A feature with 1 indicating that playing at a location would play out a ladder that + results in capturing an opponent group. """ - cdef int location - cdef char* captures = state.get_ladder_captures(80) - - # Loop over all groups on board - for location in range(state.board_size): - if captures[location] != _FREE: - tensor[offset, location] = 1 + cdef vector[location_t] captures = vector[location_t]() + cdef location_t location + cdef group_ptr_t group - # free captures - free(captures) + # Search for any groups of the opponent player that have exactly 2 liberties + for group in state.groups_set: + if d(group).color != state.current_player and d(group).count_liberty == 2: + # Try each "plausible" capture move on a copy of this state + for location in get_plausible_capture_moves(state, group): + if is_ladder_capture_move(state.copy(), group, location): + tensor[offset, location] = 1 return offset + 1 - cdef int get_ladder_escape(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 - """A feature wrapping GameState.is_ladder_escape(). - check if player_current group can escape ladder + cdef int get_ladder_escape(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset): # noqa: E501 + """A feature with 1 indicating that playing at a location would play out a ladder with the + current player ultimately escaping. """ - cdef int location - cdef char* escapes = state.get_ladder_escapes(80) + cdef vector[location_t] escapes = vector[location_t]() + cdef location_t location + cdef group_ptr_t group - # Loop over all groups on board - for location in range(state.board_size): - if escapes[location] != _FREE: - tensor[offset, location] = 1 - - # free escapes - free(escapes) + # Search for any groups of the current player that are in atari + for group in state.groups_set: + if d(group).color == state.current_player and d(group).count_liberty == 1: + # Try each "plausible" escape move on a copy of this state + for location in get_plausible_escape_moves(state, group): + if is_ladder_escape_move(state.copy(), group, location): + tensor[offset, location] = 1 return offset + 1 - cdef int get_sensibleness(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + cdef int get_sensibleness(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset): # noqa: E501 """A move is 'sensible' if it is legal and if it does not fill the current_player's own eye """ - cdef int i - cdef short location - cdef Group* group - - # Set all legal moves to 1 - for i in range(state.moves_legal.count): - tensor[offset, state.moves_legal.locations[i]] = 1 - - # List can increment but a big enough starting value is important - cdef Locations_List* eyes = locations_list_new(15) - - # Loop over all board groups to unmark own-eyes - for i in range(state.groups_list.count_groups): - group = state.groups_list.board_groups[i] - - # if group is current player - if group.color == state.player_current: - - # loop over liberties because they are possible eyes - for location in range(self.board_size): - - # check liberty location as possible eye - if group.locations[location] == _LIBERTY: - - # check if location is an eye - if state.is_true_eye(location, eyes, state.player_current): - tensor[offset, location] = 0 + cdef location_t location + cdef vector[location_t] moves = state.get_sensible_moves() - locations_list_destroy(eyes) + for location in moves: + tensor[offset, location] = 1 return offset + 1 - cdef int get_legal(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 - """Zero at all illegal moves, one at all legal moves. Unlike sensibleness, no eye check is done - not used?? + cdef int get_legal(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset): # noqa: E501 + """Zero at all illegal moves, one at all legal moves. Unlike sensibleness, no eyes check. """ - cdef short location + cdef location_t location - # Loop over all legal moves and set to one - for location in range(state.moves_legal.count): - tensor[offset, state.moves_legal.locations[location]] = 1 + for location in state.legal_moves: + tensor[offset, location] = 1 return offset + 1 - cdef int get_response(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 - """single feature plane encoding whether this location matches any of the response - patterns, for now it only checks the 12d response patterns as we do not use the - 3x3 response patterns. - - TODO - - decide if we consider nakade patterns response patterns as well - - optimization? 12d response patterns are calculated twice.. - """ - - cdef short location, location_x, location_y, last_move, last_move_x, last_move_y - cdef int i, plane, id - cdef long hash_base, hash_pattern - cdef short* neighbor12d = state.neighbor12d - - # get last move - last_move = state.moves_history.locations[state.moves_history.count - 1] - - # check if last move is not _PASS - if last_move != _PASS: - # get 12d pattern hash of last move location and color - hash_base = state.get_hash_12d(last_move) - - # calculate last_move x and y - last_move_x = last_move / state.size - last_move_y = last_move % state.size - - # last_move location in neighbor12d array - last_move *= 12 - - # loop over all locations in 12d shape - for i in range(12): - # get location - location = neighbor12d[last_move + i] - - # check if location is empty - if state.board_groups[location].color == _EMPTY: - # calculate location x and y - location_x = (location / state.size) - last_move_x - location_y = (location % state.size) - last_move_y - - # calculate 12d response pattern hash - hash_pattern = hash_base + location_x - hash_pattern *= _HASHVALUE - hash_pattern += location_y - - # dictionary lookup - pattern_id = self.pattern_response_12d.get(hash_pattern) - if pattern_id >= 0: - tensor[offset, location] = 1 + cdef int get_response(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset): # noqa: E501 + # TODO - implement response pattern features as a lookup table ? return offset + 1 - cdef int get_save_atari(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 - """A feature wrapping GameState.is_ladder_escape(). - check if player_current group can escape atari for at least one turn + cdef int get_save_atari(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset): # noqa: E501 + """A feature wrapping a shallow GameState.is_ladder_escape() search. Effectively this + feature encodes whether a group in atari can be saved for at least one more turn. """ - cdef int location - cdef char* escapes = state.get_ladder_escapes(1) + # TODO - pass additional args to feature processors, redirect this function to + # get_ladder_escapes with less depth. - # loop over all groups on board - for location in range(state.board_size): - if escapes[location] != _FREE: - tensor[offset, location] = 1 + cdef vector[location_t] escapes = vector[location_t]() + cdef location_t location + cdef group_ptr_t group - # free escapes - free(escapes) + # Search for any groups of the current player that are in atari + for group in state.groups_set: + if d(group).color != state.current_player and d(group).count_liberty == 2: + # Try each "plausible" escape move on a copy of this state + for location in get_plausible_escape_moves(state, group): + if is_ladder_escape_move(state.copy(), group, location, 2): + tensor[offset, location] = 1 return offset + 1 - cdef int get_neighbor(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 - """encode last move neighbor positions in two planes: - - horizontal & vertical / direct neighbor - - diagonal neighbor - """ - - cdef short location, last_move - cdef int i, plane - cdef short *neighbor3x3 = state.neighbor3x3 - - # get last move - last_move = state.moves_history.locations[state.moves_history.count - 1] - - # check if last move is not _PASS - if last_move != _PASS: - # last_move location in neighbor3x3 array - last_move *= 8 - - # direct neighbor plane is plane offset - plane = offset - - # loop over direct neighbor - # 0,1,2,3 are direct neighbor locations - for i in range(4): - # get neighbor location - location = neighbor3x3[last_move + i] - - # check if location is empty - if state.board_groups[location].color == _EMPTY: - tensor[plane, location] = 1 + cdef int get_neighbor(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset): # noqa: E501 + # TODO - implement response pattern features as a lookup table ? - # diagonal neighbor plane is plane offset + 1 - plane = offset + 1 - - # loop over diagonal neighbor - # 4,5,6,7 are diagonal neighbor locations - for i in range(4, 8): - # get neighbor location - location = neighbor3x3[last_move + i] - - # check if location is empty - if state.board_groups[location].color == _EMPTY: - tensor[plane, location] = 1 - - return offset + 2 - - cdef int get_nakade(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 - """A nakade pattern is a 12d pattern on a location a stone was captured before - it is unclear if a max size of the captured group has to be considered and - how recent the capture event should have been - - the 12d pattern can be encoded without stone color and liberty count - unclear if a border location should be considered a stone or liberty - - pattern lookup value is being set instead of 1 - """ - - # TODO tensor type has to be float return offset + 1 - cdef int get_nakade_offset(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 - """A nakade pattern is a 12d pattern on a location a stone was captured before - it is unclear if a max size of the captured group has to be considered and - how recent the capture event should have been - - the 12d pattern can be encoded without stone color and liberty count - unclear if a border location should be considered a stone or liberty - - #pattern_id is offset - """ - - return offset + self.pattern_nakade_size - - cdef int get_response_12d(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 - """Set 12d hash pattern for 12d shape around last move - pattern lookup value is being set instead of 1 - """ - - # get last move location - # check for pass + cdef int get_nakade(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset): # noqa: E501 + # TODO - implement response pattern features as a lookup table ? return offset + 1 - cdef int get_response_12d_offset(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 - """Check all empty locations in a 12d shape around the last move for being a 12d response - pattern match - #pattern_id is offset - - base hash is 12d pattern hash of last move location + color - add relative position of every empty location in a 12d shape to get 12d response pattern - hash - - c hash x y - ... location a has: state.get_hash_12d(x), -1, 0 - .ax.. location b has: state.get_hash_12d(x), +1, -1 - ..b location c has: state.get_hash_12d(x), 0, +2 - . - - 12d response pattern hash value is calculated by: - ((hash + x) * _HASHVALUE) + y - """ - - cdef short location, location_x, location_y, last_move, last_move_x, last_move_y - cdef int i, plane, id - cdef long hash_base, hash_pattern - cdef short* neighbor12d = state.neighbor12d - - # get last move - last_move = state.moves_history.locations[state.moves_history.count - 1] - - # check if last move is not _PASS - if last_move != _PASS: - - # get 12d pattern hash of last move location and color - hash_base = state.get_hash_12d(last_move) - - # calculate last_move x and y - last_move_x = last_move / state.size - last_move_y = last_move % state.size - - # last_move location in neighbor12d array - last_move *= 12 - - # loop over all locations in 12d shape - for i in range(12): - - # get location - location = neighbor12d[last_move + i] - - # check if location is empty - if state.board_groups[location].color == _EMPTY: - - # calculate location x and y - location_x = (location / state.size) - last_move_x - location_y = (location % state.size) - last_move_y - - # calculate 12d response pattern hash - hash_pattern = hash_base + location_x - hash_pattern *= _HASHVALUE - hash_pattern += location_y - - # dictionary lookup - id = self.pattern_response_12d.get(hash_pattern) - - if id >= 0: - - tensor[offset + id, location] = 1 - - return offset + self.pattern_response_12d_size - - cdef int get_non_response_3x3(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 - """Set 3x3 hash pattern for every legal location where - pattern lookup value is being set instead of 1 - """ - - # TODO tensor type has to be float + cdef int get_response_12d(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset): # noqa: E501 + # TODO - implement response pattern features as a lookup table ? return offset + 1 - cdef int get_non_response_3x3_offset(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 - """Set 3x3 hash pattern for every legal location where - #pattern_id is offset - """ - - cdef short i, location - cdef int pattern_id - - # loop over all legal moves and set to one - for i in range(state.moves_legal.count): + cdef int get_non_response_3x3(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset): # noqa: E501 + # TODO - implement response pattern features as a lookup table ? - # get location - location = state.moves_legal.locations[i] - - # get location hash and dict lookup - pattern_id = self.pattern_non_response_3x3.get(state.get_3x3_hash(location)) - if pattern_id >= 0: - tensor[offset + pattern_id, location] = 1 - - return offset + self.pattern_non_response_3x3_size + return offset + 1 - cdef int zeros(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + cdef int zeros(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset): # noqa: E501 """Plane filled with zeros """ - cdef short location + # Nothing to do; all features begin with zeros. return offset + 1 - for location in range(0, self.board_size): - tensor[offset, location] = 0 - - return offset + 1 - - cdef int ones(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + cdef int ones(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset): # noqa: E501 """Plane filled with ones """ - cdef short location - - for location in range(0, self.board_size): - tensor[offset, location] = 1 + # Taking advantage of numpy slice indexing + tensor[offset, :] = 1 return offset + 1 - cdef int color(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 + cdef int color(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset): # noqa: E501 """Value net feature, plane with ones if active_player is black else zeros """ - if state.player_current == _BLACK: + if state.current_player == stone_t.BLACK: return self.ones(state, tensor, groups_after, offset) else: return self.zeros(state, tensor, groups_after, offset) - cdef int ko(self, GameState state, tensor_type[:, ::1] tensor, char *groups_after, int offset): # noqa: E501 - """Ko feature + cdef int ko(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset): # noqa: E501 + """Ko feature (note: only aware of one-move-back ko, not superko) """ - if state.ko is not _PASS: + if state.ko != -1: tensor[offset, state.ko] = 1 return offset + 1 @@ -587,56 +339,21 @@ cdef class Preprocess: self.size = size self.board_size = size * size - cdef int i - - # preprocess_method is a function pointer: - # ctypedef int (*preprocess_method)(Preprocess, GameState, tensor_type[:, ::1], char*, int) + # Recall that preprocess_method is the type of function pointers cdef preprocess_method processor + cdef int i - # create a list with function pointers - self.processors = malloc(len(feature_list) * sizeof(preprocess_method)) + # Create a list with function pointers + self.processors = vector[preprocess_method]() self.requires_groups_after = False - if not self.processors: - raise MemoryError() - - # load nakade patterns - self.pattern_nakade = {} - self.pattern_nakade_size = 0 - if dict_nakade is not None: - with open(dict_nakade, 'r') as f: - s = f.read() - self.pattern_nakade = ast.literal_eval(s) - self.pattern_nakade_size = max(self.pattern_nakade.values()) + 1 - - # load 12d response patterns - self.pattern_response_12d = {} - self.pattern_response_12d_size = 0 - if dict_12d is not None: - with open(dict_12d, 'r') as f: - s = f.read() - self.pattern_response_12d = ast.literal_eval(s) - self.pattern_response_12d_size = max(self.pattern_response_12d.values()) + 1 - - # load 3x3 non response patterns - self.pattern_non_response_3x3 = {} - self.pattern_non_response_3x3_size = 0 - if dict_3x3 is not None: - with open(dict_3x3, 'r') as f: - s = f.read() - self.pattern_non_response_3x3 = ast.literal_eval(s) - self.pattern_non_response_3x3_size = max(self.pattern_non_response_3x3.values()) + 1 - - if verbose: - print("loaded " + str(self.pattern_nakade_size) + " nakade patterns") - print("loaded " + str(self.pattern_response_12d_size) + " 12d patterns") - print("loaded " + str(self.pattern_non_response_3x3_size) + " 3x3 patterns") + # TODO - load response pattern files self.feature_list = feature_list self.output_dim = 0 - # loop over feature_list add the corresponding function + # Loop over feature_list add the corresponding function # and increment output_dim accordingly for i in range(len(feature_list)): feat = feature_list[i].lower() @@ -692,28 +409,23 @@ cdef class Preprocess: self.output_dim += 1 elif feat == "response": - processor = self.get_response - self.output_dim += 1 + raise NotImplementedError("response is not yet implemented; requires preprocessing and training redesign") # noqa: E501 elif feat == "save_atari": processor = self.get_save_atari self.output_dim += 1 elif feat == "neighbor": - processor = self.get_neighbor - self.output_dim += 2 + raise NotImplementedError("neighbor is not yet implemented; requires preprocessing and training redesign") # noqa: E501 elif feat == "nakade": - processor = self.get_nakade - self.output_dim += self.pattern_nakade_size + raise NotImplementedError("nakade is not yet implemented; requires preprocessing and training redesign") # noqa: E501 elif feat == "response_12d": - processor = self.get_response_12d - self.output_dim += self.pattern_response_12d_size + raise NotImplementedError("response_12d is not yet implemented; requires preprocessing and training redesign") # noqa: E501 elif feat == "non_response_3x3": - processor = self.get_non_response_3x3 - self.output_dim += self.pattern_non_response_3x3_size + raise NotImplementedError("non_response_3x3 is not yet implemented; requires preprocessing and training redesign") # noqa: E501 elif feat == "color": processor = self.color @@ -723,52 +435,42 @@ cdef class Preprocess: processor = self.ko self.output_dim += 1 else: - - # incorrect feature input + # Incorrect feature name raise ValueError("uknown feature: %s" % feat) - self.processors[i] = processor - - def __dealloc__(self): - """Prevent memory leaks by freeing all arrays created with malloc - """ - - if self.processors is not NULL: - free(self.processors) + self.processors.push_back(processor) ############################################################################ - # public cdef function # + # public cpdef function # # # ############################################################################ - cdef np.ndarray[tensor_type, ndim=4] generate_tensor(self, GameState state): - """Convert a GameState to a Theano-compatible tensor + cpdef np.ndarray[onehot_t, ndim=4] state_to_tensor(self, GameState state): + """Convert a GameState to a Theano-compatible tensor of one-hot features """ cdef int i cdef preprocess_method proc + cdef np.ndarray[lookahead_t, ndim=2] groups_after - # create complete array now instead of concatenate later - # TODO check if we can use a Malloc array somehow.. faster!! - cdef np.ndarray[tensor_type, ndim=2] np_tensor = \ - np.zeros((self.output_dim, self.board_size), dtype=np.int8) - cdef tensor_type[:, ::1] tensor = np_tensor + # Create complete array now instead of concatenate later + cdef np.ndarray[onehot_t, ndim=2] np_tensor = \ + np.zeros((self.output_dim, self.board_size), dtype=np.uint8) cdef int offset = 0 - # get char array with next move information - cdef char *groups_after = state.get_groups_after() if self.requires_groups_after else NULL - - # loop over all processors and generate tensor - for i in range(len(self.feature_list)): - proc = self.processors[i] - offset = proc(self, state, tensor, groups_after, offset) - - # free groups_after + # Get char array with next move information if self.requires_groups_after: - free(groups_after) + groups_after = get_groups_after(state) + else: + groups_after = np.zeros((1, 1), dtype=np.uint16) + + # Loop over all processors and generate tensor + for proc in self.processors: + offset = proc(self, state, np_tensor, groups_after, offset) - # create a singleton 'batch' dimension + # Reshape result from (features, board_size) to (1, features, size, size), i.e. with a + # 2D board for input to a convolutional network and a singleton 'batch' dimension. return np_tensor.reshape((1, self.output_dim, self.size, self.size)) ############################################################################ @@ -776,11 +478,6 @@ cdef class Preprocess: # # ############################################################################ - def state_to_tensor(self, GameState state): - """Convert a GameState to a Theano-compatible tensor - """ - return self.generate_tensor(state) - def get_output_dimension(self): """return output_dim, the amount of planes an output tensor will have """ @@ -790,3 +487,99 @@ cdef class Preprocess: """return feature list """ return self.feature_list + + +############################################################################ +# "Groups after" helper functions for lookahead without copying state # +# # +############################################################################ + +cdef np.ndarray[lookahead_t, ndim=2] get_groups_after(GameState state): + """Without creating a copy of the state, compute features of the resulting board state + IF a stone were played at each legal location. Three features are computed and stored in + a board_size x 3 array. Values are only computed at legal move locations. + + That is, groups_after[loc, STONE] contains the size of the group that would be formed + if the current_player were to play a stone at 'loc', hence the name groups_after + + Returns a numpy array of size (board_size, 3): + - groups_after[loc, 0] = resulting group size by playing at loc + - groups_after[loc, 1] = number of remaining liberties of group by playing at loc + - groups_after[loc, 2] = number of stones captured by playing at loc + """ + + cdef location_t loc + cdef np.ndarray[lookahead_t, ndim=2] result = np.zeros((state.board_size, 3), dtype=np.uint16) + cdef np.ndarray[lookahead_t, ndim=1] result_at + + # Call get_groups_after_at() for each legal move + for loc in state.legal_moves: + result_at = get_groups_after_at(state, loc) + result[loc, 0] = result_at[0] + result[loc, 1] = result_at[1] + result[loc, 2] = result_at[2] + + return result + + +cdef np.ndarray[lookahead_t, ndim=1] get_groups_after_at(GameState state, location_t loc): + """Compute 'groups_after' results at a single location, which must be a legal move. + + Returns a size (3,) numpy arry with group size in index 0, liberty count in index 1, and + number of opponent stones captured in index 2 (see get_groups_after()) + """ + + cdef stone_t neighbor + cdef group_ptr_t neighbor_group, capture_group, tmp_group + cdef np.ndarray[lookahead_t, ndim=1] result = np.zeros((3,), dtype=np.uint16) + cdef location_t cap_loc + cdef group_t cap_val + cdef int i + + # Initially, the new group only has a stone at 'loc' + tmp_group = group_new(state.current_player) + group_add_stone(tmp_group, loc) + + # Initially, 'capture_group' is empty + capture_group = group_new(state.opponent_player) + + # Loop over all four neighbors of this location, building a resulting 'merged' friendly + # group and checking if opponents are captured. + for i in range(4): + # Get neighbor location and value + neighbor_loc = d(state.ptr_neighbor)[loc * 4 + i] + neighbor_group = state.board[neighbor_loc] + neighbor = d(neighbor_group).color + + # Add liberties to 'tmp_group' + if neighbor == stone_t.EMPTY: + group_add_liberty(tmp_group, neighbor_loc) + + # Merge friendly group + elif neighbor == state.current_player: + group_merge(tmp_group, neighbor_group) + + # Check if opponent group is captured + elif neighbor == state.opponent_player: + if d(neighbor_group).count_liberty == 1: + group_merge(capture_group, neighbor_group) + + # Fix liberties of merged group: new stone cannot be one of them + group_remove_liberty(tmp_group, loc) + + # Resolve captures, part 1: count total stones in capture_group + result[2] = d(capture_group).count_stones + + # Resolve captures, part 2: captured stones become liberties if they are adjacent to tmp_group + for cap_loc, cap_val in d(capture_group).locations: + if cap_val == group_t.STONE: + for i in range(4): + neighbor_loc = d(state.ptr_neighbor)[cap_loc * 4 + i] + if group_lookup(tmp_group, neighbor_loc) == group_t.STONE: + group_add_liberty(tmp_group, cap_loc) + + # Compute final results: size of tmp_group and its liberties + result[0] = d(tmp_group).count_stones + result[1] = d(tmp_group).count_liberty + + return result diff --git a/AlphaGo/training/reinforcement_policy_trainer.py b/AlphaGo/training/reinforcement_policy_trainer.py index 131465560..8e07b7290 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -65,7 +65,7 @@ def run_n_games(optimizer, lr, learner, opponent, num_games, mock_states=[]): move_tensors[idx].append(mv_tensor) state.do_move(mv) if state.is_end_of_game(): - learner_won[idx] = state.get_winner() == learner_color[idx] + learner_won[idx] = state.get_winner_color() == learner_color[idx] just_finished.append(idx) # Remove games that have finished from dict. @@ -83,7 +83,7 @@ def run_n_games(optimizer, lr, learner, opponent, num_games, mock_states=[]): np.concatenate(mv_tensor, axis=0)) # Return the win ratio. - wins = sum(state.get_winner() == pc for (state, pc) in zip(states, learner_color)) + wins = sum(state.get_winner_color() == pc for (state, pc) in zip(states, learner_color)) return float(wins) / num_games diff --git a/AlphaGo/util.py b/AlphaGo/util.py index 5b535b6e1..57fddd453 100644 --- a/AlphaGo/util.py +++ b/AlphaGo/util.py @@ -41,21 +41,12 @@ def confirm(prompt=None, resp=False): def flatten_idx(position, size): - """ - - """ - (x, y) = position return x * size + y def unflatten_idx(idx, size): - """ - - """ - - x, y = divmod(idx, size) - return (x, y) + return divmod(idx, size) def _parse_sgf_move(node_value): diff --git a/setup.py b/setup.py index f67c92569..3725c5cd6 100644 --- a/setup.py +++ b/setup.py @@ -4,9 +4,17 @@ from Cython.Build import cythonize extensions = [ + Extension("AlphaGo.go.constants", ["AlphaGo/go/constants.pyx"], + include_dirs=[numpy.get_include()], language="c++"), + Extension("AlphaGo.go.ladders", ["AlphaGo/go/ladders.pyx"], + include_dirs=[numpy.get_include()], language="c++"), Extension("AlphaGo.go.game_state", ["AlphaGo/go/game_state.pyx"], include_dirs=[numpy.get_include()], language="c++"), - Extension("AlphaGo.go.go_data", ["AlphaGo/go/go_data.pyx"], + Extension("AlphaGo.go.group_logic", ["AlphaGo/go/group_logic.pyx"], + include_dirs=[numpy.get_include()], language="c++"), + Extension("AlphaGo.go.coordinates", ["AlphaGo/go/coordinates.pyx"], + include_dirs=[numpy.get_include()], language="c++"), + Extension("AlphaGo.go.zobrist", ["AlphaGo/go/zobrist.pyx"], include_dirs=[numpy.get_include()], language="c++"), Extension("AlphaGo.preprocessing.preprocessing", ["AlphaGo/preprocessing/preprocessing.pyx"], include_dirs=[numpy.get_include()], language="c++"), diff --git a/tests/test_gamestate.py b/tests/test_gamestate.py index 5a919b2d7..ca09cf4be 100644 --- a/tests/test_gamestate.py +++ b/tests/test_gamestate.py @@ -1,3 +1,4 @@ +import parseboard import unittest import AlphaGo.go as go from AlphaGo.go import GameState @@ -115,7 +116,7 @@ def test_eye_recursion(self): self.assertTrue(gs.is_eye((0, 0), go.BLACK)) -class TestCacheSets(unittest.TestCase): +class TestGroups(unittest.TestCase): def test_liberties_after_capture(self): # creates 3x3 black group in the middle, that is then all captured @@ -147,6 +148,36 @@ def test_liberties_after_capture(self): self.assertTrue(gs_reference.is_board_equal(gs_capture)) self.assertTrue(gs_reference.is_liberty_equal(gs_capture)) + def test_large_group_neighbors(self): + + gs, _ = parseboard.parse(". . B B B . .|" + ". . B B B . .|" + ". . B B B . .|" + ". . W W W . .|" + ". . W W W . .|" + ". . W W W . .|" + ". . . . . . .|") + self.assertTrue(gs.sanity_check_groups()) + + +class TestCopy(unittest.TestCase): + + def test_copy(self): + gs, _ = parseboard.parse(". B . . . . .|" + "B W W . . . .|" + ". B W . B . .|" + ". . . . . . B|" + ". . B . . . .|" + "W . . . W W .|") + + copy = gs.copy() + self.assertTrue(gs.is_board_equal(copy)) + self.assertTrue(gs.is_liberty_equal(copy)) + self.assertListEqual(gs.get_legal_moves(), copy.get_legal_moves()) + self.assertListEqual(gs.get_history(), copy.get_history()) + self.assertEqual(gs.get_captures_white(), copy.get_captures_white()) + self.assertEqual(gs.get_captures_black(), copy.get_captures_black()) + if __name__ == '__main__': unittest.main() diff --git a/tests/test_ladders.py b/tests/test_ladders.py index d358ea8f5..a2de9df8f 100644 --- a/tests/test_ladders.py +++ b/tests/test_ladders.py @@ -1,11 +1,26 @@ import unittest import parseboard from AlphaGo.go import BLACK, WHITE +from AlphaGo.preprocessing.preprocessing import Preprocess + + +def is_ladder_capture(state, move): + pp = Preprocess(["ladder_capture"], size=state.get_size()) + feature = pp.state_to_tensor(state).squeeze() + return feature[move] == 1 + + +def is_ladder_escape(state, move): + pp = Preprocess(["ladder_escape"], size=state.get_size()) + feature = pp.state_to_tensor(state).squeeze() + return feature[move] == 1 class TestLadder(unittest.TestCase): - def test_captured_1(self): + """Use interface provided by 'preprocessing' to test ladders + """ + def test_captured_1(self): st, moves = parseboard.parse("d b c . . . .|" "B W a . . . .|" ". B . . . . .|" @@ -15,20 +30,19 @@ def test_captured_1(self): st.set_current_player(BLACK) # 'a' should catch white in a ladder, but not 'b' - self.assertTrue(st.is_ladder_capture(moves['a'])) - self.assertFalse(st.is_ladder_capture(moves['b'])) + self.assertTrue(is_ladder_capture(st, moves['a'])) + self.assertFalse(is_ladder_capture(st, moves['b'])) # 'b' should not be an escape move for white after 'a' st.do_move(moves['a']) - self.assertFalse(st.is_ladder_escape(moves['b'])) + self.assertFalse(is_ladder_escape(st, moves['b'])) # W at 'b', check 'c' and 'd' st.do_move(moves['b']) - self.assertTrue(st.is_ladder_capture(moves['c'])) - self.assertFalse(st.is_ladder_capture(moves['d'])) # self-atari + self.assertTrue(is_ladder_capture(st, moves['c'])) + self.assertFalse(is_ladder_capture(st, moves['d'])) # self-atari def test_breaker_1(self): - st, moves = parseboard.parse(". B . . . . .|" "B W a . . W .|" "B b . . . . .|" @@ -39,16 +53,16 @@ def test_breaker_1(self): st.set_current_player(BLACK) # 'a' should not be a ladder capture, nor 'b' - self.assertFalse(st.is_ladder_capture(moves['a'])) - self.assertFalse(st.is_ladder_capture(moves['b'])) + self.assertFalse(is_ladder_capture(st, moves['a'])) + self.assertFalse(is_ladder_capture(st, moves['b'])) # after 'a', 'b' should be an escape st.do_move(moves['a']) - self.assertTrue(st.is_ladder_escape(moves['b'])) + self.assertTrue(is_ladder_escape(st, moves['b'])) # after 'b', 'c' should not be a capture st.do_move(moves['b']) - self.assertFalse(st.is_ladder_capture(moves['c'])) + self.assertFalse(is_ladder_capture(st, moves['c'])) def test_missing_ladder_breaker_1(self): @@ -62,13 +76,13 @@ def test_missing_ladder_breaker_1(self): st.set_current_player(WHITE) # a should not be an escape move for white - self.assertFalse(st.is_ladder_escape(moves['a'])) + self.assertFalse(is_ladder_escape(st, moves['a'])) # after 'a', 'b' should still be a capture ... st.do_move(moves['a']) - self.assertTrue(st.is_ladder_capture(moves['b'])) + self.assertTrue(is_ladder_capture(st, moves['b'])) # ... but 'c' should not - self.assertFalse(st.is_ladder_capture(moves['c'])) + self.assertFalse(is_ladder_capture(st, moves['c'])) def test_capture_to_escape_1(self): @@ -81,7 +95,7 @@ def test_capture_to_escape_1(self): st.set_current_player(BLACK) # 'a' is not a capture because of ataris - self.assertFalse(st.is_ladder_capture(moves['a'])) + self.assertFalse(is_ladder_capture(st, moves['a'])) def test_throw_in_1(self): @@ -94,12 +108,12 @@ def test_throw_in_1(self): st.set_current_player(BLACK) # 'a' or 'b' will capture - self.assertTrue(st.is_ladder_capture(moves['a'])) - self.assertTrue(st.is_ladder_capture(moves['b'])) + self.assertTrue(is_ladder_capture(st, moves['a'])) + self.assertTrue(is_ladder_capture(st, moves['b'])) # after 'a', 'b' doesn't help white escape st.do_move(moves['a']) - self.assertFalse(st.is_ladder_escape(moves['b'])) + self.assertFalse(is_ladder_escape(st, moves['b'])) def test_snapback_1(self): @@ -116,7 +130,7 @@ def test_snapback_1(self): st.set_current_player(WHITE) # 'a' is not an escape for white - self.assertFalse(st.is_ladder_escape(moves['a'])) + self.assertFalse(is_ladder_escape(st, moves['a'])) def test_two_captures(self): @@ -129,8 +143,8 @@ def test_two_captures(self): st.set_current_player(BLACK) # both 'a' and 'b' should be ladder captures - self.assertTrue(st.is_ladder_capture(moves['a'])) - self.assertTrue(st.is_ladder_capture(moves['b'])) + self.assertTrue(is_ladder_capture(st, moves['a'])) + self.assertTrue(is_ladder_capture(st, moves['b'])) def test_two_escapes(self): @@ -146,8 +160,8 @@ def test_two_escapes(self): st.set_current_player(WHITE) # both 'a' and 'b' should be considered escape moves for white after 'O' at c - self.assertTrue(st.is_ladder_escape(moves['a'])) - self.assertTrue(st.is_ladder_escape(moves['b'])) + self.assertTrue(is_ladder_escape(st, moves['a'])) + self.assertTrue(is_ladder_escape(st, moves['b'])) if __name__ == '__main__': diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index 27705cf46..bf464f7ba 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -7,10 +7,6 @@ def simple_board(): - """ - - """ - gs = GameState(size=7) # make a tiny board for the sake of testing and hand-coding expected results @@ -48,10 +44,6 @@ def simple_board(): def self_atari_board(): - """ - - """ - gs = GameState(size=7) # another tiny board for testing self-atari specifically. @@ -87,10 +79,6 @@ def self_atari_board(): def capture_board(): - """ - - """ - gs = GameState(size=7) # another small board, this one with imminent captures @@ -121,15 +109,9 @@ def capture_board(): class TestPreprocessingFeatures(unittest.TestCase): """Test the functions in preprocessing.py - - note that the hand-coded features look backwards from what is depicted - in simple_board() because of the x/y column/row transpose thing (i.e. - numpy is typically thought of as indexing rows first, but we use (x,y) - indexes, so a numpy row is like a go column and vice versa) """ def test_get_board(self): - gs = simple_board() pp = Preprocess(["board"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) @@ -159,10 +141,6 @@ def test_get_board(self): self.assertTrue(np.all(feature == np.dstack((white_pos, black_pos, empty_pos)))) def test_get_turns_since(self): - """ - - """ - gs = simple_board() pp = Preprocess(["turns_since"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) @@ -184,10 +162,6 @@ def test_get_turns_since(self): self.assertTrue(np.all(feature == one_hot_turns)) def test_get_liberties(self): - """ - - """ - gs = simple_board() pp = Preprocess(["liberties"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) @@ -221,17 +195,14 @@ def test_get_liberties(self): "bad expectation: stones with %d liberties" % (i + 1)) def test_get_capture_size(self): - """ - - """ - gs = capture_board() pp = Preprocess(["capture_size"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) score_before = gs.get_captures_white() one_hot_capture = np.zeros((gs.get_size(), gs.get_size(), 8)) - # there is no capture available; all legal moves are zero-capture + + # There is no capture available; all legal moves are zero-capture for (x, y) in gs.get_legal_moves(): copy = gs.copy() copy.do_move((x, y)) @@ -244,10 +215,6 @@ def test_get_capture_size(self): "bad expectation: capturing %d stones" % i) def test_get_self_atari_size(self): - """ - - """ - gs = self_atari_board() pp = Preprocess(["self_atari_size"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) @@ -261,10 +228,6 @@ def test_get_self_atari_size(self): self.assertTrue(np.all(feature == one_hot_self_atari)) def test_get_self_atari_size_cap(self): - """ - - """ - gs = capture_board() pp = Preprocess(["self_atari_size"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) @@ -279,10 +242,6 @@ def test_get_self_atari_size_cap(self): self.assertTrue(np.all(feature == one_hot_self_atari)) def test_get_liberties_after(self): - """ - - """ - gs = simple_board() pp = Preprocess(["liberties_after"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) @@ -333,10 +292,6 @@ def test_get_liberties_after_cap(self): "bad expectation: stones with %d liberties after move" % (i + 1)) def test_get_ladder_capture(self): - """ - - """ - gs, moves = parseboard.parse(". . . . . . .|" "B W a . . . .|" ". B . . . . .|" @@ -352,10 +307,6 @@ def test_get_ladder_capture(self): self.assertTrue(np.all(expectation == feature)) def test_get_ladder_escape(self): - """ - - """ - # On this board, playing at 'a' is ladder escape because there is a breaker on the right. gs, moves = parseboard.parse(". B B . . . .|" "B W a . . . .|" @@ -373,10 +324,6 @@ def test_get_ladder_escape(self): self.assertTrue(np.all(expectation == feature)) def test_two_escapes(self): - """ - - """ - gs, moves = parseboard.parse(". . X . . .|" ". X O a . .|" ". X c X . .|" @@ -401,27 +348,36 @@ def test_two_escapes(self): self.assertTrue(np.all(expectation == feature)) def test_get_sensibleness(self): - """ + gs, moves = parseboard.parse("x B . . W . . . .|" + "B B W . . W . . .|" + ". W B B W W . . .|" + ". B y B W W . . .|" + ". B B z B W . . .|" + ". . B B B W . . .|" + ". . . . . . . . W|" + ". . . . . . . . W|" + ". . . . . . . W s|") + gs.set_current_player(go.BLACK) + + pp = Preprocess(["sensibleness"], size=9) + feature = pp.state_to_tensor(gs)[0, 0] # 1D tensor; no need to transpose - """ + expectation = np.zeros((gs.get_size(), gs.get_size()), dtype=int) - # TODO - there are no legal eyes at the moment + for (x, y) in gs.get_legal_moves(): + expectation[x, y] = 1 - gs = simple_board() - pp = Preprocess(["sensibleness"], size=7) - feature = pp.state_to_tensor(gs)[0, 0] # 1D tensor; no need to transpose + # 'x', 'y', and 'z' are eyes - remove them from 'sensible' moves + expectation[moves['x']] = 0 + expectation[moves['y']] = 0 + expectation[moves['z']] = 0 + + # 's' is suicide - should not be legal + expectation[moves['s']] = 0 - expectation = np.zeros((gs.get_size(), gs.get_size())) - for (x, y) in gs.get_legal_moves(): - if not (gs.is_eye((x, y), go.WHITE)): - expectation[x, y] = 1 self.assertTrue(np.all(expectation == feature)) def test_get_legal(self): - """ - - """ - gs = simple_board() pp = Preprocess(["legal"], size=7) feature = pp.state_to_tensor(gs)[0, 0] # 1D tensor; no need to transpose @@ -432,10 +388,6 @@ def test_get_legal(self): self.assertTrue(np.all(expectation == feature)) def test_feature_concatenation(self): - """ - - """ - gs = simple_board() pp = Preprocess(["board", "sensibleness", "capture_size"], size=7) feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) diff --git a/tests/test_reinforcement_policy_trainer.py b/tests/test_reinforcement_policy_trainer.py index 6c4259a8d..0e214858e 100644 --- a/tests/test_reinforcement_policy_trainer.py +++ b/tests/test_reinforcement_policy_trainer.py @@ -58,7 +58,7 @@ def __init__(self, predetermined_winner, length, *args, **kwargs): self.predetermined_winner = predetermined_winner self.length = length - def get_winner(self): + def get_winner_color(self): return self.predetermined_winner def is_end_of_game(self): From 80a82ce967e4eda42d29b462f314a48fb86734fb Mon Sep 17 00:00:00 2001 From: wrongu Date: Wed, 3 Jan 2018 19:53:47 -0500 Subject: [PATCH 182/191] TemporaryMove class allowing fast do+undo using `with GameState.try_stone` --- AlphaGo/go/game_state.pxd | 47 ++++++++++- AlphaGo/go/game_state.pyx | 171 +++++++++++++++++++++++++++++++++++++- tests/test_gamestate.py | 140 +++++++++++++++++++++++++++++++ 3 files changed, 356 insertions(+), 2 deletions(-) diff --git a/AlphaGo/go/game_state.pxd b/AlphaGo/go/game_state.pxd index 9d8dc3a6b..c8b60d27d 100644 --- a/AlphaGo/go/game_state.pxd +++ b/AlphaGo/go/game_state.pxd @@ -2,7 +2,7 @@ from AlphaGo.go.constants cimport stone_t, group_t, action_t from AlphaGo.go.coordinates cimport calculate_board_location, calculate_tuple_location, \ get_pattern_hash, get_neighbors, get_3x3_neighbors, get_12d_neighbors from AlphaGo.go.group_logic cimport Group, group_new, group_duplicate, group_add_stone, \ - group_merge, group_get_stone, group_add_liberty, group_remove_liberty + group_merge, group_get_stone, group_remove_stone, group_add_liberty, group_remove_liberty from AlphaGo.go.zobrist cimport get_zobrist_lookup, update_hash_by_location, update_hash_by_group from libcpp cimport bool from libcpp.vector cimport vector @@ -202,6 +202,19 @@ cdef class GameState: or -1 if there is none. """ + cpdef TemporaryMove try_stone(self, location_t location, bool prepare_next=*) + """Analogous to add_stone() for use in a with-statement. Automatically undoes the given move + when the with-statement exits. + + For example: + + state.add_stone(loc1) + with state.try_stone(loc2): + print state.get_history()[-1] # prints loc2 + # Here, state is returned to its value to before the with statement. + print state.get_history()[-1] # prints loc1 + """ + cpdef list get_legal_moves(self, bool include_eyes=*) """Return a list with all legal moves (in/excluding eyes) """ @@ -248,3 +261,35 @@ cdef class GameState: Returns group_keep """ + + +cdef class TemporaryMove: + """Helper-class for GameState.try_stone() + + This class implements python's 'with' interface such that when the 'with' block is exited, + the move is undone. + + See https://www.python.org/dev/peps/pep-0343/ for more information about how 'with' interacts + with __enter__ and __exit__ + """ + + # Reference to the GameState being modified + cdef GameState state + + # Current player / owner of the new stone + cdef stone_t player_color + + # Where the stone is played + cdef location_t move + + # If there was a ko location previously + cdef location_t previous_ko + + # Reference to each of the up-to-4 opponent/friendly groups of the new stone before stone is + # played so they can be restored regardless of merges/captures. + cdef group_set_t neighbors_opponent, neighbors_friendly + + # Boolean flag indicating whether to prepare for another call to state.try_stone(). That is, if + # this flag is True, it update history and switches to the next player. If it is False, it + # simply adds the stone and makes no further updates. + cdef bool prepare_next diff --git a/AlphaGo/go/game_state.pyx b/AlphaGo/go/game_state.pyx index 04e516355..37a8bd257 100644 --- a/AlphaGo/go/game_state.pyx +++ b/AlphaGo/go/game_state.pyx @@ -545,8 +545,12 @@ cdef class GameState: group_add_liberty(new_group, neighbor_loc) # Merge neighboring friendly groups (and remove 'location' as one of their liberties) - elif neighbor == self.current_player and new_group != neighbor_group: + elif neighbor == self.current_player and neighbor_group != new_group: group_remove_liberty(neighbor_group, location) + # Note: order matters here; calling combine_groups(neighbor_group, new_group) would + # interfere with TemporaryMove, which does nothing to 'un-combine' the new stone + # from 'neighbor_group'. As written here, stones are copied from 'neighbor_group' + # into 'new_group', but 'neighbor_group' itself is left unchanged. new_group = self.combine_groups(new_group, neighbor_group) # Handle stone placed next to opponent group: remove stone from opponent liberties and @@ -582,6 +586,21 @@ cdef class GameState: return new_ko + cpdef TemporaryMove try_stone(self, location_t location, bool prepare_next=True): + """Analogous to add_stone() for use in a with-statement. Automatically undoes the given move + when the with-statement exits. + + For example: + + state.add_stone(loc1) + with state.try_stone(loc2): + print state.get_history()[-1] # prints loc2 + # Here, state is returned to its value to before the with statement. + print state.get_history()[-1] # prints loc1 + """ + + return TemporaryMove(self, location, prepare_next) + cpdef list get_legal_moves(self, bool include_eyes=True): """Return a list with all legal moves as tuples (in/excluding eyes) """ @@ -1069,5 +1088,155 @@ cdef class GameState: return self.get_print_board_layout() +cdef class TemporaryMove: + """Helper-class for GameState.try_stone(). Must be called with a legal move. + + This class implements python's 'with' interface such that when the 'with' block is exited, + the move is undone. + + See https://www.python.org/dev/peps/pep-0343/ for more information about how 'with' interacts + with __enter__ and __exit__ + """ + + def __init__(self, GameState state, location_t move, bool prepare_next): + self.state = state + self.move = move + self.neighbors_friendly = group_set_t() + self.neighbors_opponent = group_set_t() + self.prepare_next = prepare_next + + def __enter__(self): + """Called when entering 'with' statement. Execute the given move and record necessary + information about the state so that 'move' may be undone later. + """ + + cdef location_t neighbor_loc + cdef group_ptr_t neighbor_group + cdef int i + + # Record player color and ko. + self.player_color = self.state.current_player + self.previous_ko = self.state.ko + + # Get a reference to each of the up-to-4 neighbors of the stone about to be placed so they + # can be restored in __exit__. + for i in range(4): + neighbor_loc = d(self.state.ptr_neighbor)[self.move * 4 + i] + neighbor_group = self.state.board[neighbor_loc] + if d(neighbor_group).color == self.player_color: + self.neighbors_friendly.insert(neighbor_group) + elif d(neighbor_group).color > stone_t.EMPTY: + self.neighbors_opponent.insert(neighbor_group) + + if self.prepare_next: + # Call the same state-updating methods as do_move. + self.state.ko = self.state.add_stone(self.move) + self.state.moves_history.push_back(self.move) + self.state.swap_players() + self.state.update_legal_moves() + else: + # Only add the stone and don't perform further updates. + self.state.add_stone(self.move) + + return self.state + + def __exit__(self, type, value, traceback): + """Called at end of 'with' statement. Undo move done in __enter__. + """ + + cdef group_ptr_t old_group, neighbor_group + cdef location_t loc, neighbor_loc + cdef group_t val + cdef int i, c + + # Take away current hash from set of hashes. + self.state.previous_hashes.discard(self.state.zobrist_current) + + # Update hash: remove placed stone. + self.state.zobrist_current = update_hash_by_location(self.state.zobrist_current, + d(self.state.ptr_zobrist_lookup), + self.move, self.player_color) + + # Remove group that the new stone belongs to and set its board location to empty. + self.state.groups_set.erase(self.state.board[self.move]) + self.state.board[self.move] = self.state.group_empty + + # Remove 'new' neighbor groups to prepare to restore 'old' neighbor groups. Update to + # state.board[loc] for neighbors happens below. + for i in range(4): + neighbor_loc = d(self.state.ptr_neighbor)[self.move * 4 + i] + neighbor_group = self.state.board[neighbor_loc] + if d(neighbor_group).color > stone_t.EMPTY: + self.state.groups_set.erase(neighbor_group) + + # Restore previous FRIENDLY neighbors. It is important that this happens before restoring + # opponent so that if a captured opponent group is restored, it updates liberties of the + # correct friendly group. + for old_group in self.neighbors_friendly: + # Ensure group is in 'groups_set'. + self.state.groups_set.insert(old_group) + + # Point board[loc] to this group for each stone in the group. + for loc, val in d(old_group).locations: + if val == group_t.STONE: + self.state.board[loc] = old_group + + # Restore liberty of old group. + group_add_liberty(old_group, self.move) + + # Restore previous OPPONENT neighbors. + for old_group in self.neighbors_opponent: + # Ensure group is in 'groups_set'. + self.state.groups_set.insert(old_group) + + # Point board[loc] to this group for each stone in the group. + for loc, val in d(old_group).locations: + if val == group_t.STONE: + self.state.board[loc] = old_group + + # Restore liberty of old group. + group_add_liberty(old_group, self.move) + + # Capture had occurred if old_group has 1 liberty after having 'restored' the liberty + # (in other words, if it had 0 liberties a moment ago). + if d(old_group).count_liberty == 1: + # Restore hash for each captured stone. + self.state.zobrist_current = \ + update_hash_by_group(self.state.zobrist_current, + d(self.state.ptr_zobrist_lookup), old_group) + + # Undo 'remove_group' by replacing stones and removing liberties of other + # surrounding groups. + for loc, val in d(old_group).locations: + if val == group_t.STONE: + # 'loc' is being re-added to the board; must update neighbor liberties. + for i in range(4): + neighbor_loc = d(self.state.ptr_neighbor)[loc * 4 + i] + neighbor_group = self.state.board[neighbor_loc] + + # Remove liberty of neighbor group if it has stones. + if d(neighbor_group).color > stone_t.EMPTY: + group_remove_liberty(neighbor_group, loc) + + # Decrement captured stones. + if self.player_color == stone_t.BLACK: + self.state.capture_white -= d(old_group).count_stones + else: + self.state.capture_black -= d(old_group).count_stones + + if self.prepare_next: + # Remove stone from history. + self.state.moves_history.pop_back() + + # Restore ko. + self.state.ko = self.previous_ko + + # Switch back to other player. + self.state.swap_players() + + # Restore set of legal moves + self.state.update_legal_moves() + + class IllegalMove(Exception): pass diff --git a/tests/test_gamestate.py b/tests/test_gamestate.py index ca09cf4be..4636ce10a 100644 --- a/tests/test_gamestate.py +++ b/tests/test_gamestate.py @@ -2,6 +2,7 @@ import unittest import AlphaGo.go as go from AlphaGo.go import GameState +from AlphaGo.util import flatten_idx class TestKo(unittest.TestCase): @@ -179,5 +180,144 @@ def test_copy(self): self.assertEqual(gs.get_captures_black(), copy.get_captures_black()) +class TestTemporaryMove(unittest.TestCase): + + def test_simple_undo(self): + gs = GameState(size=7) + copy = gs.copy() + + # Baseline equality checks between gs and copy + self.assertListEqual(copy.get_history(), []) + self.assertTrue(copy.is_board_equal(gs)) + self.assertTrue(copy.is_liberty_equal(gs)) + self.assertEqual(copy.get_hash(), gs.get_hash()) + + with copy.try_stone(0): + self.assertListEqual(copy.get_history(), [(0, 0)]) + self.assertFalse(copy.is_board_equal(gs)) + self.assertFalse(copy.is_liberty_equal(gs)) + self.assertNotEqual(copy.get_hash(), gs.get_hash()) + + # Move should now be undone - retry equality checks from above + self.assertListEqual(copy.get_history(), []) + self.assertTrue(copy.is_board_equal(gs)) + self.assertTrue(copy.is_liberty_equal(gs)) + self.assertEqual(copy.get_hash(), gs.get_hash()) + + # With 'a' undone, it should be legal again + self.assertTrue(copy.is_legal((0, 0))) + + def test_ko_undo(self): + gs, moves = parseboard.parse(". B . . . . .|" + "B W B . . . .|" + "W k W . . . .|" + ". W . . . . .|" + ". . . . . . .|" + ". . . . a . .|" + ". . . . . . .|") + gs.set_current_player(go.BLACK) + + # Trigger ko at (1, 1) + gs.do_move(moves['k']) + ko = gs.get_ko_location() + self.assertIsNotNone(ko) + + copy = gs.copy() + + with copy.try_stone(flatten_idx(moves['a'], gs.get_size())): + # Doing move at 'a' clears ko + self.assertIsNone(copy.get_ko_location()) + + # Undoing move at 'a' resets ko + self.assertEqual(copy.get_ko_location(), ko) + + def test_simple_merge_undo(self): + gs, moves = parseboard.parse(". . . . . . .|" + ". . . B W . .|" + ". . . B W . .|" + ". . . a W . .|" + ". . . B W . .|" + ". . . B W . .|" + ". . . . . . .|") + gs.set_current_player(go.BLACK) + + copy = gs.copy() + + # Initial equality checks + self.assertListEqual(copy.get_history(), gs.get_history()) + self.assertTrue(copy.is_board_equal(gs)) + self.assertTrue(copy.is_liberty_equal(gs)) + self.assertEqual(copy.get_hash(), gs.get_hash()) + + with copy.try_stone(flatten_idx(moves['a'], gs.get_size())): + self.assertFalse(copy.is_board_equal(gs)) + self.assertFalse(copy.is_liberty_equal(gs)) + self.assertNotEqual(copy.get_hash(), gs.get_hash()) + + # Move should now be undone - retry equality checks from above + self.assertListEqual(copy.get_history(), gs.get_history()) + self.assertTrue(copy.is_board_equal(gs)) + self.assertTrue(copy.is_liberty_equal(gs)) + self.assertEqual(copy.get_hash(), gs.get_hash()) + + def test_simple_capture_undo(self): + gs, moves = parseboard.parse(". . . . . . .|" + ". . . . . . .|" + ". . . . B . .|" + ". . . B W c .|" + ". . . B W B .|" + ". . . . B . .|" + ". . . . . . .|") + gs.set_current_player(go.BLACK) + + copy = gs.copy() + + # Initial equality checks + self.assertListEqual(copy.get_history(), gs.get_history()) + self.assertTrue(copy.is_board_equal(gs)) + self.assertTrue(copy.is_liberty_equal(gs)) + self.assertEqual(copy.get_hash(), gs.get_hash()) + + with copy.try_stone(flatten_idx(moves['c'], gs.get_size())): + self.assertFalse(copy.is_board_equal(gs)) + self.assertFalse(copy.is_liberty_equal(gs)) + self.assertNotEqual(copy.get_hash(), gs.get_hash()) + + # Move should now be undone - retry equality checks from above + self.assertListEqual(copy.get_history(), gs.get_history()) + self.assertTrue(copy.is_board_equal(gs)) + self.assertTrue(copy.is_liberty_equal(gs)) + self.assertEqual(copy.get_hash(), gs.get_hash()) + + def test_merge_and_capture_undo(self): + gs, moves = parseboard.parse(". . B B B . .|" + ". B W W W B .|" + ". B W B W B .|" + ". B W c W B .|" + ". B W B W B .|" + ". B W W W B .|" + ". . B B B . .|") + gs.set_current_player(go.BLACK) + + copy = gs.copy() + + # Initial equality checks + self.assertListEqual(copy.get_history(), gs.get_history()) + self.assertTrue(copy.is_board_equal(gs)) + self.assertTrue(copy.is_liberty_equal(gs)) + self.assertEqual(copy.get_hash(), gs.get_hash()) + + with copy.try_stone(flatten_idx(moves['c'], gs.get_size())): + self.assertFalse(copy.is_board_equal(gs)) + self.assertFalse(copy.is_liberty_equal(gs)) + self.assertNotEqual(copy.get_hash(), gs.get_hash()) + + # Move should now be undone - retry equality checks from above + self.assertListEqual(copy.get_history(), gs.get_history()) + self.assertTrue(copy.is_board_equal(gs)) + self.assertTrue(copy.is_liberty_equal(gs)) + self.assertEqual(copy.get_hash(), gs.get_hash()) + + if __name__ == '__main__': unittest.main() From 612dcb961cd7084b2043e0ad7b65254309f17e97 Mon Sep 17 00:00:00 2001 From: wrongu Date: Thu, 4 Jan 2018 11:34:47 -0500 Subject: [PATCH 183/191] fixed GameState sanity check. Using it in more tests. --- AlphaGo/go/game_state.pyx | 7 +-- tests/test_gamestate.py | 126 ++++++++++++++++++++++---------------- 2 files changed, 77 insertions(+), 56 deletions(-) diff --git a/AlphaGo/go/game_state.pyx b/AlphaGo/go/game_state.pyx index 37a8bd257..30f0595d6 100644 --- a/AlphaGo/go/game_state.pyx +++ b/AlphaGo/go/game_state.pyx @@ -773,7 +773,6 @@ cdef class GameState: """Remove group from everywhere in the state. """ - cdef location_t loc, neighbor_loc cdef group_ptr_t group cdef group_t val, neighbor_value @@ -952,8 +951,8 @@ cdef class GameState: print("liberty count does not match actuall number of empty adjacent locations!") return False - # All checks passed - return True + # All checks passed + return True def get_current_player(self): """Returns the color of the player who will make the next move. @@ -1106,7 +1105,7 @@ cdef class TemporaryMove: self.prepare_next = prepare_next def __enter__(self): - """Called when entering 'with' statement. Execute the given move and record necessary + """Called when entering 'with' statement. Execute the given move and record necessary information about the state so that 'move' may be undone later. """ diff --git a/tests/test_gamestate.py b/tests/test_gamestate.py index 4636ce10a..2cee5892f 100644 --- a/tests/test_gamestate.py +++ b/tests/test_gamestate.py @@ -163,6 +163,16 @@ def test_large_group_neighbors(self): class TestCopy(unittest.TestCase): + def equality_checks(self, original, copy): + self.assertListEqual(copy.get_legal_moves(), original.get_legal_moves()) + self.assertListEqual(copy.get_history(), original.get_history()) + self.assertTrue(copy.is_board_equal(original)) + self.assertTrue(copy.is_liberty_equal(original)) + self.assertEqual(copy.get_hash(), original.get_hash()) + self.assertListEqual(copy.get_history(), original.get_history()) + self.assertEqual(copy.get_captures_white(), original.get_captures_white()) + self.assertEqual(copy.get_captures_black(), original.get_captures_black()) + def test_copy(self): gs, _ = parseboard.parse(". B . . . . .|" "B W W . . . .|" @@ -172,39 +182,60 @@ def test_copy(self): "W . . . W W .|") copy = gs.copy() - self.assertTrue(gs.is_board_equal(copy)) - self.assertTrue(gs.is_liberty_equal(copy)) - self.assertListEqual(gs.get_legal_moves(), copy.get_legal_moves()) - self.assertListEqual(gs.get_history(), copy.get_history()) - self.assertEqual(gs.get_captures_white(), copy.get_captures_white()) - self.assertEqual(gs.get_captures_black(), copy.get_captures_black()) + + self.assertTrue(copy.sanity_check_groups()) + self.equality_checks(gs, copy) class TestTemporaryMove(unittest.TestCase): + def listNotEqual(self, listA, listB): + if len(listA) != len(listB): + return True + else: + for (a, b) in zip(listA, listB): + if a != b: + return True + return False + + def equality_checks(self, original, copy): + self.assertEqual(copy.get_current_player(), original.get_current_player()) + self.assertListEqual(copy.get_legal_moves(), original.get_legal_moves()) + self.assertListEqual(copy.get_history(), original.get_history()) + self.assertTrue(copy.is_board_equal(original)) + self.assertTrue(copy.is_liberty_equal(original)) + self.assertEqual(copy.get_hash(), original.get_hash()) + self.assertEqual(copy.get_captures_white(), original.get_captures_white()) + self.assertEqual(copy.get_captures_black(), original.get_captures_black()) + + def inequality_checks(self, original, copy): + self.assertTrue(self.listNotEqual(copy.get_legal_moves(), original.get_legal_moves())) + self.assertTrue(self.listNotEqual(copy.get_history(), original.get_history())) + self.assertFalse(copy.is_board_equal(original)) + self.assertFalse(copy.is_liberty_equal(original)) + self.assertNotEqual(copy.get_hash(), original.get_hash()) + def test_simple_undo(self): gs = GameState(size=7) copy = gs.copy() # Baseline equality checks between gs and copy - self.assertListEqual(copy.get_history(), []) - self.assertTrue(copy.is_board_equal(gs)) - self.assertTrue(copy.is_liberty_equal(gs)) - self.assertEqual(copy.get_hash(), gs.get_hash()) + self.equality_checks(gs, copy) with copy.try_stone(0): - self.assertListEqual(copy.get_history(), [(0, 0)]) - self.assertFalse(copy.is_board_equal(gs)) - self.assertFalse(copy.is_liberty_equal(gs)) - self.assertNotEqual(copy.get_hash(), gs.get_hash()) + self.assertTrue(gs.sanity_check_groups()) + self.assertTrue(copy.sanity_check_groups()) + self.inequality_checks(gs, copy) + + # (0, 0) is occupied and should currently be illegal + self.assertFalse(copy.is_legal((0, 0))) # Move should now be undone - retry equality checks from above - self.assertListEqual(copy.get_history(), []) - self.assertTrue(copy.is_board_equal(gs)) - self.assertTrue(copy.is_liberty_equal(gs)) - self.assertEqual(copy.get_hash(), gs.get_hash()) + self.assertTrue(gs.sanity_check_groups()) + self.assertTrue(copy.sanity_check_groups()) + self.equality_checks(gs, copy) - # With 'a' undone, it should be legal again + # With move undone, it should be legal again self.assertTrue(copy.is_legal((0, 0))) def test_ko_undo(self): @@ -224,10 +255,16 @@ def test_ko_undo(self): copy = gs.copy() + self.equality_checks(gs, copy) + with copy.try_stone(flatten_idx(moves['a'], gs.get_size())): + self.inequality_checks(gs, copy) + # Doing move at 'a' clears ko self.assertIsNone(copy.get_ko_location()) + self.equality_checks(gs, copy) + # Undoing move at 'a' resets ko self.assertEqual(copy.get_ko_location(), ko) @@ -244,21 +281,16 @@ def test_simple_merge_undo(self): copy = gs.copy() # Initial equality checks - self.assertListEqual(copy.get_history(), gs.get_history()) - self.assertTrue(copy.is_board_equal(gs)) - self.assertTrue(copy.is_liberty_equal(gs)) - self.assertEqual(copy.get_hash(), gs.get_hash()) + self.assertTrue(copy.sanity_check_groups()) + self.equality_checks(gs, copy) with copy.try_stone(flatten_idx(moves['a'], gs.get_size())): - self.assertFalse(copy.is_board_equal(gs)) - self.assertFalse(copy.is_liberty_equal(gs)) - self.assertNotEqual(copy.get_hash(), gs.get_hash()) + self.assertTrue(copy.sanity_check_groups()) + self.inequality_checks(gs, copy) # Move should now be undone - retry equality checks from above - self.assertListEqual(copy.get_history(), gs.get_history()) - self.assertTrue(copy.is_board_equal(gs)) - self.assertTrue(copy.is_liberty_equal(gs)) - self.assertEqual(copy.get_hash(), gs.get_hash()) + self.assertTrue(copy.sanity_check_groups()) + self.equality_checks(gs, copy) def test_simple_capture_undo(self): gs, moves = parseboard.parse(". . . . . . .|" @@ -273,21 +305,16 @@ def test_simple_capture_undo(self): copy = gs.copy() # Initial equality checks - self.assertListEqual(copy.get_history(), gs.get_history()) - self.assertTrue(copy.is_board_equal(gs)) - self.assertTrue(copy.is_liberty_equal(gs)) - self.assertEqual(copy.get_hash(), gs.get_hash()) + self.assertTrue(copy.sanity_check_groups()) + self.equality_checks(gs, copy) with copy.try_stone(flatten_idx(moves['c'], gs.get_size())): - self.assertFalse(copy.is_board_equal(gs)) - self.assertFalse(copy.is_liberty_equal(gs)) - self.assertNotEqual(copy.get_hash(), gs.get_hash()) + self.assertTrue(copy.sanity_check_groups()) + self.inequality_checks(gs, copy) # Move should now be undone - retry equality checks from above - self.assertListEqual(copy.get_history(), gs.get_history()) - self.assertTrue(copy.is_board_equal(gs)) - self.assertTrue(copy.is_liberty_equal(gs)) - self.assertEqual(copy.get_hash(), gs.get_hash()) + self.assertTrue(copy.sanity_check_groups()) + self.equality_checks(gs, copy) def test_merge_and_capture_undo(self): gs, moves = parseboard.parse(". . B B B . .|" @@ -302,21 +329,16 @@ def test_merge_and_capture_undo(self): copy = gs.copy() # Initial equality checks - self.assertListEqual(copy.get_history(), gs.get_history()) - self.assertTrue(copy.is_board_equal(gs)) - self.assertTrue(copy.is_liberty_equal(gs)) - self.assertEqual(copy.get_hash(), gs.get_hash()) + self.assertTrue(copy.sanity_check_groups()) + self.equality_checks(gs, copy) with copy.try_stone(flatten_idx(moves['c'], gs.get_size())): - self.assertFalse(copy.is_board_equal(gs)) - self.assertFalse(copy.is_liberty_equal(gs)) - self.assertNotEqual(copy.get_hash(), gs.get_hash()) + self.assertTrue(copy.sanity_check_groups()) + self.inequality_checks(gs, copy) # Move should now be undone - retry equality checks from above - self.assertListEqual(copy.get_history(), gs.get_history()) - self.assertTrue(copy.is_board_equal(gs)) - self.assertTrue(copy.is_liberty_equal(gs)) - self.assertEqual(copy.get_hash(), gs.get_hash()) + self.assertTrue(copy.sanity_check_groups()) + self.equality_checks(gs, copy) if __name__ == '__main__': From 6cf96311c7b565b5cb04a01bdca4811f9ed1ac2c Mon Sep 17 00:00:00 2001 From: wrongu Date: Thu, 4 Jan 2018 11:35:57 -0500 Subject: [PATCH 184/191] ladders using GameState.try_stone --- AlphaGo/go/ladders.pyx | 78 +++++++++++++++++++++--------------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/AlphaGo/go/ladders.pyx b/AlphaGo/go/ladders.pyx index baeb3ae19..1a8339ee1 100644 --- a/AlphaGo/go/ladders.pyx +++ b/AlphaGo/go/ladders.pyx @@ -6,14 +6,14 @@ from AlphaGo.util import unflatten_idx from cython.operator cimport dereference as d -cdef bool is_ladder_escape_move(GameState copy_state, group_ptr_t prey, location_t move, int depth=50): # noqa:E501 +cdef bool is_ladder_escape_move(GameState state, group_ptr_t prey, location_t move, int depth=50): # noqa:E501 """(Inefficiently) check whether the given move escapes ladder capture of the given group. Returns True when escape is plausible, or recursion depth limit is reached (assuming that the opponent does not recognize ladders with greater depth as a 'capture' either) Preconditions: - - GameState 'copy_state' is safe to be altered - - prey group is in atari and owned by copy_state.current_player + - GameState 'state' is safe to be temporarily altered (not thread-safe!) + - prey group is in atari and owned by state.current_player - given move is legal - depth >= 0 @@ -28,34 +28,34 @@ cdef bool is_ladder_escape_move(GameState copy_state, group_ptr_t prey, location return True # Try move and check results. - copy_state.do_move(unflatten_idx(move, copy_state.size)) - prey = copy_state.board[prey_loc] + with state.try_stone(move): + prey = state.board[prey_loc] - # Case 1: prey has >= 3 liberties after move, in which case it escaped. - if d(prey).count_liberty >= 3: - return True + # Case 1: prey has >= 3 liberties after move, in which case it escaped. + if d(prey).count_liberty >= 3: + return True - # Case 2: prey is left in atari, in which case it did not escape. - elif d(prey).count_liberty == 1: - return False + # Case 2: prey is left in atari, in which case it did not escape. + elif d(prey).count_liberty == 1: + return False - # Case 3: prey has 2 liberties left, in which case it may still be captured in a ladder. - # Requires recursive search. - else: - # Opponent may attempt to capture at either of the prey's two liberties. - for plausible_capture in get_plausible_capture_moves(copy_state, prey): - if is_ladder_capture_move(copy_state.copy(), prey, plausible_capture, depth - 1): - return False + # Case 3: prey has 2 liberties left, in which case it may still be captured in a ladder. + # Requires recursive search. + else: + # Opponent may attempt to capture at either of the prey's two liberties. + for plausible_capture in get_plausible_capture_moves(state, prey): + if is_ladder_capture_move(state.copy(), prey, plausible_capture, depth - 1): + return False - # If reached here, none of prey's liberties are ladder captures, in which case it escaped. - return True + # If reached here, none of prey's liberties are ladder captures, in which case it escaped. + return True -cdef bool is_ladder_capture_move(GameState copy_state, group_ptr_t prey, location_t move, int depth=50): # noqa:E501 +cdef bool is_ladder_capture_move(GameState state, group_ptr_t prey, location_t move, int depth=50): # noqa:E501 """(Inefficiently) check whether the given move captures the prey, or forces capture of the prey by a ladder within 'depth' moves. Preconditions: - - GameState 'copy_state' is safe to be altered + - GameState 'state' is safe to be temporarily altered (not thread-safe!) - prey group has <= 2 liberties and is owned by the opponent - given move is legal - depth >= 0 @@ -71,23 +71,23 @@ cdef bool is_ladder_capture_move(GameState copy_state, group_ptr_t prey, locatio return False # Try the move and check results - copy_state.do_move(unflatten_idx(move, copy_state.size)) - prey = copy_state.board[prey_loc] - - # Case 1: prey has >= 2 liberties after move, in which case it escaped. - if d(prey).count_liberty >= 2: - return False - - # Case 2: prey has 1 liberty after move, in which case it may still attempt to escape. Requires - # recursive search. - elif d(prey).count_liberty == 1: - # Try each potential escape move - for plausible_escape in get_plausible_escape_moves(copy_state, prey): - if is_ladder_escape_move(copy_state.copy(), prey, plausible_escape, depth - 1): - return False - - # If reached here, either prey was captured or no escape move was found - return True + with state.try_stone(move): + prey = state.board[prey_loc] + + # Case 1: prey has >= 2 liberties after move, in which case it escaped. + if d(prey).count_liberty >= 2: + return False + + # Case 2: prey has 1 liberty after move, in which case it may still attempt to escape. Requires + # recursive search. + elif d(prey).count_liberty == 1: + # Try each potential escape move + for plausible_escape in get_plausible_escape_moves(state, prey): + if is_ladder_escape_move(state.copy(), prey, plausible_escape, depth - 1): + return False + + # If reached here, either prey was captured or no escape move was found + return True cdef set get_plausible_escape_moves(GameState state, group_ptr_t prey): """Get set of moves that should be checked as plausible escape moves for the given prey. From 1fc585e3d1a372f087d659962c3b2715a685beba Mon Sep 17 00:00:00 2001 From: wrongu Date: Thu, 4 Jan 2018 11:47:27 -0500 Subject: [PATCH 185/191] added another gamestate test: 'try_stone' hash matches real hash --- tests/test_gamestate.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/test_gamestate.py b/tests/test_gamestate.py index 2cee5892f..f11139341 100644 --- a/tests/test_gamestate.py +++ b/tests/test_gamestate.py @@ -340,6 +340,30 @@ def test_merge_and_capture_undo(self): self.assertTrue(copy.sanity_check_groups()) self.equality_checks(gs, copy) + def test_hash_update_matches_actual_hash(self): + gs = GameState(size=7) + gs, moves = parseboard.parse("a x b . . . .|" + "z c d . . . .|" + ". . . . . . .|" + ". . . y . . .|" + ". . . . . . .|" + ". . . . . . .|" + ". . . . . . .|") + + # a,b,c,d are black, x,y,z,x are white + move_order = ['a', 'x', 'b', 'y', 'c', 'z', 'd', 'x'] + for m in move_order: + move_1d = flatten_idx(moves[m], gs.get_size()) + + # 'Try' move and get hash + with gs.try_stone(move_1d): + hash1 = gs.get_hash() + + # Actually do move and get hash + gs.do_move(moves[m]) + hash2 = gs.get_hash() + + self.assertEqual(hash1, hash2) if __name__ == '__main__': unittest.main() From 3b420fa8a5943eb52df5e27dd1bfcdcd28cfd679 Mon Sep 17 00:00:00 2001 From: wrongu Date: Thu, 4 Jan 2018 12:28:02 -0500 Subject: [PATCH 186/191] simplified preprocessing helper get_groups_after_at() using state.try_stone --- AlphaGo/preprocessing/preprocessing.pyx | 63 +++++-------------------- 1 file changed, 12 insertions(+), 51 deletions(-) diff --git a/AlphaGo/preprocessing/preprocessing.pyx b/AlphaGo/preprocessing/preprocessing.pyx index f43276820..f8e9ca390 100644 --- a/AlphaGo/preprocessing/preprocessing.pyx +++ b/AlphaGo/preprocessing/preprocessing.pyx @@ -529,57 +529,18 @@ cdef np.ndarray[lookahead_t, ndim=1] get_groups_after_at(GameState state, locati number of opponent stones captured in index 2 (see get_groups_after()) """ - cdef stone_t neighbor - cdef group_ptr_t neighbor_group, capture_group, tmp_group cdef np.ndarray[lookahead_t, ndim=1] result = np.zeros((3,), dtype=np.uint16) - cdef location_t cap_loc - cdef group_t cap_val - cdef int i - - # Initially, the new group only has a stone at 'loc' - tmp_group = group_new(state.current_player) - group_add_stone(tmp_group, loc) - - # Initially, 'capture_group' is empty - capture_group = group_new(state.opponent_player) - - # Loop over all four neighbors of this location, building a resulting 'merged' friendly - # group and checking if opponents are captured. - for i in range(4): - # Get neighbor location and value - neighbor_loc = d(state.ptr_neighbor)[loc * 4 + i] - neighbor_group = state.board[neighbor_loc] - neighbor = d(neighbor_group).color - - # Add liberties to 'tmp_group' - if neighbor == stone_t.EMPTY: - group_add_liberty(tmp_group, neighbor_loc) - - # Merge friendly group - elif neighbor == state.current_player: - group_merge(tmp_group, neighbor_group) - - # Check if opponent group is captured - elif neighbor == state.opponent_player: - if d(neighbor_group).count_liberty == 1: - group_merge(capture_group, neighbor_group) - - # Fix liberties of merged group: new stone cannot be one of them - group_remove_liberty(tmp_group, loc) - - # Resolve captures, part 1: count total stones in capture_group - result[2] = d(capture_group).count_stones - - # Resolve captures, part 2: captured stones become liberties if they are adjacent to tmp_group - for cap_loc, cap_val in d(capture_group).locations: - if cap_val == group_t.STONE: - for i in range(4): - neighbor_loc = d(state.ptr_neighbor)[cap_loc * 4 + i] - if group_lookup(tmp_group, neighbor_loc) == group_t.STONE: - group_add_liberty(tmp_group, cap_loc) - - # Compute final results: size of tmp_group and its liberties - result[0] = d(tmp_group).count_stones - result[1] = d(tmp_group).count_liberty + + cdef short capture_before = \ + state.capture_black if state.current_player == stone_t.WHITE else state.capture_white + cdef short capture_after + + with state.try_stone(loc, False): + result[0] = d(state.board[loc]).count_stones + result[1] = d(state.board[loc]).count_liberty + + capture_after = \ + state.capture_black if state.current_player == stone_t.WHITE else state.capture_white + result[2] = capture_after - capture_before return result From ba171ac92b89e1864e22cf75cefafcc75bf00d6b Mon Sep 17 00:00:00 2001 From: wrongu Date: Thu, 4 Jan 2018 12:30:10 -0500 Subject: [PATCH 187/191] minor style/flake8 fixes --- AlphaGo/go/ladders.pyx | 7 ++++--- tests/test_gamestate.py | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/AlphaGo/go/ladders.pyx b/AlphaGo/go/ladders.pyx index 1a8339ee1..3d1230ec2 100644 --- a/AlphaGo/go/ladders.pyx +++ b/AlphaGo/go/ladders.pyx @@ -47,7 +47,8 @@ cdef bool is_ladder_escape_move(GameState state, group_ptr_t prey, location_t mo if is_ladder_capture_move(state.copy(), prey, plausible_capture, depth - 1): return False - # If reached here, none of prey's liberties are ladder captures, in which case it escaped. + # If reached here, none of prey's liberties are ladder captures, in which case it + # escaped. return True cdef bool is_ladder_capture_move(GameState state, group_ptr_t prey, location_t move, int depth=50): # noqa:E501 @@ -78,8 +79,8 @@ cdef bool is_ladder_capture_move(GameState state, group_ptr_t prey, location_t m if d(prey).count_liberty >= 2: return False - # Case 2: prey has 1 liberty after move, in which case it may still attempt to escape. Requires - # recursive search. + # Case 2: prey has 1 liberty after move, in which case it may still attempt to escape. + # Requires recursive search. elif d(prey).count_liberty == 1: # Try each potential escape move for plausible_escape in get_plausible_escape_moves(state, prey): diff --git a/tests/test_gamestate.py b/tests/test_gamestate.py index f11139341..d4efb4b10 100644 --- a/tests/test_gamestate.py +++ b/tests/test_gamestate.py @@ -365,5 +365,6 @@ def test_hash_update_matches_actual_hash(self): self.assertEqual(hash1, hash2) + if __name__ == '__main__': unittest.main() From 6b043818fcd3da2d8a7ec0a8be6cec04d3a74eef Mon Sep 17 00:00:00 2001 From: wrongu Date: Thu, 4 Jan 2018 14:11:47 -0500 Subject: [PATCH 188/191] requirements.txt update: Cython upgrade from 0.25 to 0.27; dependency on 'future' package --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 81bf38b5c..680bb64e0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ -Cython==0.25.2 +Cython==0.27.3 +future==0.16.0 h5py==2.7.0 Keras==2.0.4 numpy==1.13.1 From 1a91c1ea427a6bd645f9a4b5437aab2a33e9a879 Mon Sep 17 00:00:00 2001 From: wrongu Date: Thu, 4 Jan 2018 14:21:52 -0500 Subject: [PATCH 189/191] split requirements.txt into theano and tensorflow; updated package versions in .travis.yml --- .travis.yml | 12 ++++++------ requirements-tensorflow.txt | 21 +++++++++++++++++++++ requirements.txt => requirements-theano.txt | 8 ++++---- 3 files changed, 31 insertions(+), 10 deletions(-) create mode 100644 requirements-tensorflow.txt rename requirements.txt => requirements-theano.txt (61%) diff --git a/.travis.yml b/.travis.yml index bf972c3a2..1652c5d10 100644 --- a/.travis.yml +++ b/.travis.yml @@ -31,8 +31,8 @@ install: - sudo ln -s /run/shm /dev/shm # install requirements - - conda install --yes Cython=0.25.2 h5py=2.6.0 numpy=1.11.2 scipy=0.18.1 PyYAML=3.12 matplotlib pandas pytest - - pip install --user --no-deps Theano==0.8.2 sgf==0.5 keras==2.0.4 pygtp==0.3 + - conda install --yes Cython=0.27.3 h5py=2.7.0 numpy=1.13.3 scipy=1.0.0 PyYAML=3.12 matplotlib pandas pytest + - pip install --user --no-deps Theano==1.0.1 sgf==0.5 keras==2.0.4 pygtp==0.3 - pip install --user flake8 # compile cython code @@ -42,13 +42,13 @@ install: # tensorflow does not have a python3.3 wheel - if [[ "$KERAS_BACKEND" == "tensorflow" ]]; then if [[ "$TRAVIS_PYTHON_VERSION" == "2.7" ]]; then - pip install https://storage.googleapis.com/tensorflow/linux/cpu/tensorflow-1.1.0-cp27-none-linux_x86_64.whl; + pip install https://storage.googleapis.com/tensorflow/linux/cpu/tensorflow-1.2.1-cp27-none-linux_x86_64.whl; elif [[ "$TRAVIS_PYTHON_VERSION" == "3.4" ]]; then - pip install https://storage.googleapis.com/tensorflow/linux/cpu/tensorflow-1.1.0-cp34-cp34m-linux_x86_64.whl; + pip install https://storage.googleapis.com/tensorflow/linux/cpu/tensorflow-1.2.1-cp34-cp34m-linux_x86_64.whl; elif [[ "$TRAVIS_PYTHON_VERSION" == "3.5" ]]; then - pip install https://storage.googleapis.com/tensorflow/linux/cpu/tensorflow-1.1.0-cp35-cp35m-linux_x86_64.whl; + pip install https://storage.googleapis.com/tensorflow/linux/cpu/tensorflow-1.2.1-cp35-cp35m-linux_x86_64.whl; elif [[ "$TRAVIS_PYTHON_VERSION" == "3.6" ]]; then - pip install https://storage.googleapis.com/tensorflow/linux/cpu/tensorflow-1.1.0-cp36-cp36m-linux_x86_64.whl; + pip install https://storage.googleapis.com/tensorflow/linux/cpu/tensorflow-1.2.1-cp36-cp36m-linux_x86_64.whl; fi fi # run flake8 and unit tests diff --git a/requirements-tensorflow.txt b/requirements-tensorflow.txt new file mode 100644 index 000000000..a57c1f680 --- /dev/null +++ b/requirements-tensorflow.txt @@ -0,0 +1,21 @@ +backports.weakref==1.0rc1 +bleach==1.5.0 +Cython==0.27.3 +funcsigs==1.0.2 +future==0.16.0 +h5py==2.7.0 +html5lib==0.9999999 +Keras==2.0.4 +Markdown==2.6.10 +mock==2.0.0 +numpy==1.13.1 +pbr==3.1.1 +protobuf==3.5.1 +pygtp==0.3 +PyYAML==3.12 +scipy==0.19.0 +sgf==0.5 +six==1.10.0 +tensorflow==1.2.1 +Theano==1.0.1 +Werkzeug==0.14.1 diff --git a/requirements.txt b/requirements-theano.txt similarity index 61% rename from requirements.txt rename to requirements-theano.txt index 680bb64e0..1f040850f 100644 --- a/requirements.txt +++ b/requirements-theano.txt @@ -2,10 +2,10 @@ Cython==0.27.3 future==0.16.0 h5py==2.7.0 Keras==2.0.4 -numpy==1.13.1 +numpy==1.13.3 pygtp==0.3 PyYAML==3.12 -scipy==0.19.0 +scipy==1.0.0 sgf==0.5 -six==1.10.0 -Theano==0.9.0 +six==1.11.0 +Theano==1.0.1 From e018decb5a028331e6e37e05a7a24b6bd1f713f9 Mon Sep 17 00:00:00 2001 From: wrongu Date: Mon, 8 Jan 2018 10:01:59 -0500 Subject: [PATCH 190/191] travis c++ compiler --- .travis.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 1652c5d10..fced58c19 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,6 +9,14 @@ matrix: - python: 2.7 env: KERAS_BACKEND=tensorflow +# Get gcc 4.8 for compiling C++ (thanks to https://github.com/travis-ci/travis-ci/issues/5627) +addons: + apt: + sources: + - ubuntu-toolchain-r-test + packages: + - gcc-4.8 + # Install packages install: - wget https://repo.continuum.io/miniconda/Miniconda-latest-Linux-x86_64.sh -O miniconda.sh; @@ -36,7 +44,7 @@ install: - pip install --user flake8 # compile cython code - - python setup.py build_ext --inplace + - CC=gcc-4.8 CXX=g++-4.8 python setup.py build_ext --inplace # install TensorFlow if needed # tensorflow does not have a python3.3 wheel From 04bac9c9d16c16c3baf16988ac761903a687d4de Mon Sep 17 00:00:00 2001 From: wrongu Date: Sat, 9 Jun 2018 09:54:42 -0400 Subject: [PATCH 191/191] Fixed Travis-CI Theano build, updated requirements --- .travis.yml | 10 ++++++++-- requirements-tensorflow.txt | 1 - requirements-theano.txt | 4 ++-- setup.py | 21 ++++++++++++++------- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/.travis.yml b/.travis.yml index fced58c19..0625e725d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -39,8 +39,8 @@ install: - sudo ln -s /run/shm /dev/shm # install requirements - - conda install --yes Cython=0.27.3 h5py=2.7.0 numpy=1.13.3 scipy=1.0.0 PyYAML=3.12 matplotlib pandas pytest - - pip install --user --no-deps Theano==1.0.1 sgf==0.5 keras==2.0.4 pygtp==0.3 + - conda install --yes Cython=0.27.3 h5py=2.7.0 numpy=1.13.3 scipy=1.1.0 PyYAML=3.12 matplotlib pandas pytest future + - pip install --user --no-deps sgf==0.5 keras==2.0.4 pygtp==0.3 - pip install --user flake8 # compile cython code @@ -59,6 +59,12 @@ install: pip install https://storage.googleapis.com/tensorflow/linux/cpu/tensorflow-1.2.1-cp36-cp36m-linux_x86_64.whl; fi fi + + # Install Theano with dependencies + - if [[ "$KERAS_BACKEND" == "theano" ]]; then + conda install mkl-service && export MKL_THREADING_LAYER=GNU; + pip install --user Theano==1.0.2; + fi # run flake8 and unit tests script: - flake8 diff --git a/requirements-tensorflow.txt b/requirements-tensorflow.txt index a57c1f680..7346612c7 100644 --- a/requirements-tensorflow.txt +++ b/requirements-tensorflow.txt @@ -17,5 +17,4 @@ scipy==0.19.0 sgf==0.5 six==1.10.0 tensorflow==1.2.1 -Theano==1.0.1 Werkzeug==0.14.1 diff --git a/requirements-theano.txt b/requirements-theano.txt index 1f040850f..51fa6bb15 100644 --- a/requirements-theano.txt +++ b/requirements-theano.txt @@ -5,7 +5,7 @@ Keras==2.0.4 numpy==1.13.3 pygtp==0.3 PyYAML==3.12 -scipy==1.0.0 +scipy==1.1.0 sgf==0.5 six==1.11.0 -Theano==1.0.1 +Theano==1.0.2 diff --git a/setup.py b/setup.py index 3725c5cd6..c61c8bfe1 100644 --- a/setup.py +++ b/setup.py @@ -5,19 +5,26 @@ extensions = [ Extension("AlphaGo.go.constants", ["AlphaGo/go/constants.pyx"], - include_dirs=[numpy.get_include()], language="c++"), + include_dirs=[numpy.get_include()], language="c++", + extra_compile_args=["-std=c++11"], extra_link_args=["-std=c++11"]), Extension("AlphaGo.go.ladders", ["AlphaGo/go/ladders.pyx"], - include_dirs=[numpy.get_include()], language="c++"), + include_dirs=[numpy.get_include()], language="c++", + extra_compile_args=["-std=c++11"], extra_link_args=["-std=c++11"]), Extension("AlphaGo.go.game_state", ["AlphaGo/go/game_state.pyx"], - include_dirs=[numpy.get_include()], language="c++"), + include_dirs=[numpy.get_include()], language="c++", + extra_compile_args=["-std=c++11"], extra_link_args=["-std=c++11"]), Extension("AlphaGo.go.group_logic", ["AlphaGo/go/group_logic.pyx"], - include_dirs=[numpy.get_include()], language="c++"), + include_dirs=[numpy.get_include()], language="c++", + extra_compile_args=["-std=c++11"], extra_link_args=["-std=c++11"]), Extension("AlphaGo.go.coordinates", ["AlphaGo/go/coordinates.pyx"], - include_dirs=[numpy.get_include()], language="c++"), + include_dirs=[numpy.get_include()], language="c++", + extra_compile_args=["-std=c++11"], extra_link_args=["-std=c++11"]), Extension("AlphaGo.go.zobrist", ["AlphaGo/go/zobrist.pyx"], - include_dirs=[numpy.get_include()], language="c++"), + include_dirs=[numpy.get_include()], language="c++", + extra_compile_args=["-std=c++11"], extra_link_args=["-std=c++11"]), Extension("AlphaGo.preprocessing.preprocessing", ["AlphaGo/preprocessing/preprocessing.pyx"], - include_dirs=[numpy.get_include()], language="c++"), + include_dirs=[numpy.get_include()], language="c++", + extra_compile_args=["-std=c++11"], extra_link_args=["-std=c++11"]), ] setup(name="RocAlphaGo", ext_modules=cythonize(extensions))