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/.gitignore b/.gitignore index 886bc5a6c..fdd8550be 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,12 @@ *.hdf5 *.sgf *.pkl +*.cpp +*.so +build/ !tests/test_data/sgf/*.sgf !tests/test_data/hdf5/*.h5 !tests/test_data/hdf5/*.hdf5 src/ +benchmarks/data/ +/.idea diff --git a/.travis.yml b/.travis.yml index 080c73a15..0625e725d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,23 +1,74 @@ -languate: python -python: -- "2.7" -# 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 +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: + include: + - python: 2.7 + env: KERAS_BACKEND=theano + - 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; + + - 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 + - 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.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 + + # install requirements + - 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 + - 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 + - if [[ "$KERAS_BACKEND" == "tensorflow" ]]; then + if [[ "$TRAVIS_PYTHON_VERSION" == "2.7" ]]; then + 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.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.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.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 - - THEANO_FLAGS="gcc.cxxflags='-march=core2'" python -m unittest discover + - flake8 --config=.flake8.cython + + # run unit test + - python -m unittest discover diff --git a/AlphaGo/ai.py b/AlphaGo/ai.py index e69de29bb..b0d8e7d67 100644 --- a/AlphaGo/ai.py +++ b/AlphaGo/ai.py @@ -0,0 +1,324 @@ +"""Policy players""" +import numpy as np +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) + """ + + 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): + # 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) + max_prob = max(move_probs, key=itemgetter(1)) + return max_prob[0] + + # No 'sensible' moves available, so do pass move + return go.PASS + + +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) + """ + + 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.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 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): + 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 diff --git a/AlphaGo/go.py b/AlphaGo/go.py deleted file mode 100644 index dcda743e0..000000000 --- a/AlphaGo/go.py +++ /dev/null @@ -1,308 +0,0 @@ -import numpy as np - -WHITE = -1 -BLACK = +1 -EMPTY = 0 -PASS_MOVE = None - - -class GameState(object): - """State of a game of Go and some basic functions to interact with it - """ - - def __init__(self, size=19): - 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.history = [] - self.num_black_prisoners = 0 - self.num_white_prisoners = 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.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)] - - 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): - 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) - 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 _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 filter(self._on_board, [(x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)]) - - 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 _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.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] = 0 - 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)) - 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 - - def copy(self): - """get a copy of this Game state - """ - other = GameState(self.size) - other.board = self.board.copy() - other.turns_played = self.turns_played - other.current_player = self.current_player - other.ko = self.ko - other.history = self.history - other.num_black_prisoners = self.num_black_prisoners - other.num_white_prisoners = self.num_white_prisoners - - # 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_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 - 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 - - 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): - moves = [] - for x in range(self.size): - for y in range(self.size): - if self.is_legal((x, y)): - moves.append((x, y)) - return moves - - 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 - if self.is_legal(action): - # reset ko - self.ko = None - 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: - # 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) - # next turn - self.current_player = -color - self.turns_played += 1 - self.history.append(action) - else: - raise IllegalMove(str(action)) - - -class IllegalMove(Exception): - pass 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 new file mode 100644 index 000000000..c8b60d27d --- /dev/null +++ b/AlphaGo/go/game_state.pxd @@ -0,0 +1,295 @@ +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_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 +from libcpp.memory cimport shared_ptr +from libcpp.unordered_set cimport unordered_set as cpp_set +import numpy as np +cimport numpy as np + + +############################################################################ +# 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: + + # Dimensions of one side of the board and total number of squares, respectively + cdef short size, board_size + + # Possible ko location + cdef location_t ko + + # Unordered list of all groups of stones + cdef group_set_t groups_set + + # Lookup of a group from board location. Length is board size + 1 to include border + cdef board_group_t board + + # Placeholder groups for referencing empty spaces or the border. + cdef group_ptr_t group_empty, group_border + + # Current player and opponent, either _WHITE or _BLACK + cdef stone_t current_player, opponent_player + + # Amount of black stones captured by white, and vice versa + cdef short capture_black, capture_white + + # Amount of passes by black and by white, respectively + cdef short passes_black, passes_white + + # List with move history + cdef vector[location_t] moves_history + + # Number of handicap stones placed by BLACK at the start of the game + cdef short num_handicap + + # List with legal moves + cdef vector[location_t] legal_moves + + # 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 + + # 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 + + ############################################################################ + # init functions # + # # + ############################################################################ + + cdef void initialize_new(self, short size, bool enforce_superko) + """Initialize this state as empty state + """ + + cdef void initialize_duplicate(self, GameState copy_state) + """Initialize all variables as a copy of copy_state + """ + + ############################################################################ + # private cdef functions used for game-play # + # # + ############################################################################ + + 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. + """ + + cpdef bool is_legal_move(self, location_t location) + """Check if playing at location is a legal move + """ + + 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 void update_legal_moves(self) + """Update legal_moves list + """ + + cdef void swap_players(self) + """Switch current_player and opponent_player + """ + + cpdef void set_current_player(self, stone_t color) + """Set current player to the given color. + """ + + ############################################################################ + # private cdef helper functions for feature generation # + # # + ############################################################################ + + 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 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. + """ + + ############################################################################ + # public cdef functions used by preprocessing # + # # + ############################################################################ + + 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 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 vector[location_t] get_sensible_moves(self) + """'Sensible' moves are all legal moves that are not eyes of the current player. + """ + + ############################################################################ + # Helper functions for managing groups # + # # + ############################################################################ + + 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 + """ + + cdef void remove_group(self, group_ptr_t group_remove) + """Remove group from everywhere in the state. + """ + + ############################################################################ + # public cpdef functions used for game play # + # # + ############################################################################ + + cpdef void print_groups(self) + """Debugging helper: prints address of all groups in this state and info about each one. + """ + + 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 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. + """ + + 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) + """ + + cpdef 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 + """ + + 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) + """ + + ############################################################################ + # Helper functions for managing groups # + # # + ############################################################################ + + 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 + """ + + +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 new file mode 100644 index 000000000..30f0595d6 --- /dev/null +++ b/AlphaGo/go/game_state.pyx @@ -0,0 +1,1241 @@ +# cython: wraparound=False +# cython: boundscheck=False +# cython: initializedcheck=False +# cython: nonecheck=False +from cython.operator cimport dereference as d +import numpy as np +cimport numpy as np + + +############################################################################ +# Global variables shared by instances of GameState # +# # +############################################################################ + +# 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 vector[zobrist_hash_t] zobrist_lookup + + +# Constant value used to generate pattern hashes (TODO: move elsewhere) +cdef int _HASHVALUE = 33 + + +############################################################################ +# Class definition # +# # +############################################################################ + + +cdef class GameState: + """Class representing the current state of a game of Go and its history. + + Note that the python interface passes moves as (x, y) tuples, while all cython code uses the + slightly more streamlined 1D indexing. + """ + + ############################################################################ + # all variables are declared in the .pxd file # + # # + ############################################################################ + + """ + # Dimensions of one side of the board and total number of squares, respectively + cdef short size, board_size + + # Possible ko location + cdef location_t ko + + # Unordered list of all groups of stones + cdef group_set_t groups_set + + # Lookup of a group from board location. Length is board size + 1 to include border + cdef board_group_t board + + # Current player and opponent, either WHITE or BLACK + cdef stone_t current_player, opponent_player + + # Amount of black stones captured by white, and vice versa + cdef short capture_black, capture_white + + # Amount of passes by black and by white, respectively + cdef short passes_black, passes_white + + # List with move history + cdef vector[location_t] moves_history + + # Number of handicap stones placed by BLACK at the start of the game + cdef short num_handicap + + # List with legal moves + cdef vector[location_t] legal_moves + + # Neighbors lookup tables + cdef pattern_t* neighbor + cdef pattern_t* neighbor3x3 + cdef pattern_t* neighbor12d + + # Zobrist hashing + cdef zobrist_hash_t zobrist_current + cdef vector[zobrist_hash_t] ptr_zobrist_lookup + + cdef bool enforce_superko + cdef set previous_hashes + """ + + ############################################################################ + # init functions # + # # + ############################################################################ + + cdef void initialize_new(self, short size, bool enforce_superko): + """Initialize as a new state. + """ + + cdef short i + + # Initialize size and board_size + self.size = size + self.board_size = size * size + + # Create placeholder groups for empty spaces and border. + self.group_empty = group_new(stone_t.EMPTY) + self.group_border = group_new(stone_t.BORDER) + + # Create empty history list + self.moves_history = vector[location_t]() + + # Initialize player colors + self.current_player = stone_t.BLACK + self.opponent_player = stone_t.WHITE + + self.ko = -1 + self.capture_black, self.capture_white = 0, 0 + self.passes_black, self.passes_white = 0, 0 + self.num_handicap = 0 + + # '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 + + # Create list of legal moves (initially everything is legal). This is updated after each + # move. + self.legal_moves = vector[location_t](self.board_size) + + # Create empty list of all current groups. + self.groups_set = group_set_t() + + # 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 + + # Initialize zobrist hashing things. + 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 deep copy of copy_state + """ + + cdef int i + cdef location_t loc + cdef group_t val + cdef group_ptr_t ref_group, dup_group + + # 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 + + # Copy set of hashes using built in set.copy() + self.previous_hashes = copy_state.previous_hashes.copy() + + # 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 + + # 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 + + # Copy groups by duplicating the underlying groups objects. + self.groups_set = group_set_t() + self.board = board_group_t(self.board_size + 1) + + # 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 + + # 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) + + # 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 + + 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 + + if copy is not None: + size = copy.size + + # 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) + + # 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 + + # 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 + + if copy is None: + self.initialize_new(size, enforce_superko) + else: + self.initialize_duplicate(copy) + + ############################################################################ + # private cdef functions used for game-play # + # # + ############################################################################ + + 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 int i, first + cdef bool played + + # Part 1: quickly check that the current player has ever played at this location; if not, no + # fancier check is needed. + + # 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] + + # 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) + + # 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 + + # Part 2: try move on a duplicate board and check hash of the result. + + # TODO (?) faster hash check than duplicate full object + # Duplicate state and play move + cdef GameState copy_state = GameState(copy=self) + + # Do move with superko check disabled (note: move must otherwise be legal). + copy_state.enforce_superko = False + copy_state.add_stone(location) + + # Check if hash already exists (hash collisions are very unlikely) + if copy_state.zobrist_current in self.previous_hashes: + return True + + return False + + 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 + """ + + cdef int i + cdef stone_t board_value + cdef short count_liberty + cdef location_t neighbor_loc + + # 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 + + # If neighbor is empty, we're done + if board_value == stone_t.EMPTY: + return True + + # 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 + + # 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 + + # 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 + + return False + + cdef void update_legal_moves(self): + """Update legal_moves list + """ + + cdef location_t loc + + # Clear previous values in self.legal_moves + self.legal_moves.clear() + + # 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) + + cdef void swap_players(self): + """Switch current_player and opponent_player + """ + + cdef stone_t swap = self.current_player + self.current_player = self.opponent_player + self.opponent_player = swap + + 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() + + ############################################################################ + # private cdef helper functions for feature generation # + # # + ############################################################################ + + 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 group_ptr_t group + cdef int i + + # First, 'location' must be empty + if d(self.board[location]).color != stone_t.EMPTY: + return False + + # 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 + + 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. + """ + + cdef int i + cdef stone_t board_value + cdef short max_bad_diagonal, count_bad_diagonal = 0 + cdef location_t neighbor_loc + + # First, check that location is at least 'eyeish' + if not self.is_eyeish(location, owner): + return False + + # 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 + + # 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 + + # 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 + + # Check if diagonal is enemy stone + if board_value > stone_t.EMPTY and board_value != owner: + count_bad_diagonal += 1 + + # 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 + + # If made it to here, location must be a eye + return True + + ############################################################################ + # public cdef functions for feature generation (used by preprocessing) # + # TODO: move all of these to preprocessing itself # + ############################################################################ + + 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. + """ + + # 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) + + # If specified, also include the current player as part of the hash + if include_player: + hsh += self.current_player + hsh *= _HASHVALUE + + return hsh + + 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) + + # If specified, also include the current player as part of the hash + if include_player: + hsh += self.current_player + hsh *= _HASHVALUE + + return hsh + + cdef vector[location_t] get_sensible_moves(self): + """'Sensible' moves are all legal moves that are not eyes of the current player. + """ + + 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 + + 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) + + return sensible_moves + + ############################################################################ + # public cdef functions used for game play # + # # + ############################################################################ + + cpdef bool is_legal_move(self, location_t location): + """Check if playing at location is a legal move + """ + + # Passing is always legal + if location == action_t.PASS: + return True + + # Check that location is on the board + if location < 0 or location >= self.board_size: + return False + + # Check if it is empty + if d(self.board[location]).color > stone_t.EMPTY: + return False + + # Check ko (1 move back only) + if location == self.ko: + return False + + # Check if move is suicide + if not self.has_liberty_after(location): + return False + + # (Maybe) check superko state + if self.enforce_superko and self.is_positional_superko(location): + return False + + # If all of the above checks pass, then this move is legal. + return True + + 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 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 + + # Start new group for this stone. + group_add_stone(new_group, location) + + # Add new group to groups_set and ensure board location is updated. + self.groups_set.insert(new_group) + self.board[location] = new_group + + # 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 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 + # 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) + + 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) + """ + + cdef list moves + + if include_eyes: + moves = list(self.legal_moves) + else: + moves = list(self.get_sensible_moves()) + + return [calculate_tuple_location(m, self.size) for m in moves] + + 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. + """ + + cdef location_t location + cdef stone_t board_value + + # Positive score is in favor of black, negative is in favor of white. + cdef float score = -komi + + # Keep track of all eyes for both black and white to make search faster. + cdef list eyes_white = [], eyes_black = [] + + # Loop over whole board + for location in range(self.board_size): + board_value = d(self.board[location]).color + + # Decrement score difference for white + if board_value == stone_t.WHITE: + score -= 1 + + # Increment score difference for black + elif board_value == stone_t.BLACK: + score += 1 + + # 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, stone_t.WHITE, eyes_white): + eyes_white.append(location) + score -= 1 + + # substract passes + # http://senseis.xmp.net/?Passing#1 + score -= self.passes_black + score += self.passes_white + + return score + + 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 = self.get_score(komi) + + # 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 + + 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. + + If it is a legal move, current_player switches to the opposite color. If not, an + IllegalMove exception is raised + """ + + cdef location_t x, y, location + cdef group_ptr_t grp + + # 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 + + # Reset ko since players switched. + self.ko = -1 + + else: + if color != stone_t.EMPTY and color != self.current_player: + self.swap_players() + + # Convert from tuple (x, y) input to 1d coordinate. + (x, y) = action + location = calculate_board_location(y, x, self.size) + + # Check if move is legal. + if not self.is_legal_move(location): + raise IllegalMove(str(action)) + + # Execute move. + self.ko = self.add_stone(location) + + # Add move to history + self.moves_history.push_back(location) + + # Swap current player for next turn. + self.swap_players() + + # 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() + + 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 + """ + + if self.moves_history.size() > self.num_handicap: + raise IllegalMove("Cannot place handicap on a started game") + + self.num_handicap += 1 + self.do_move(action, color) + + cpdef void place_handicaps(self, list handicap): + """Place list of handicap stones for BLACK (must be on empty board) + """ + + for action in handicap: + self.place_handicap_stone(action, stone_t.BLACK) + + ############################################################################ + # Helper functions for managing groups # + # # + ############################################################################ + + 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. + + Returns group_keep + """ + + cdef location_t loc + cdef group_t val + + # 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) + + # Clear group_remove from groups_set. + self.groups_set.erase(group_remove) + + return group_keep + + cdef void remove_group(self, group_ptr_t group_remove): + """Remove group from everywhere in the state. + """ + + cdef location_t loc, neighbor_loc + cdef group_ptr_t group + cdef group_t val, neighbor_value + cdef int i + + # 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 + + # 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] + + # If neighbor is a stone, add 'loc' as a liberty + if d(group).color > stone_t.EMPTY: + group_add_liberty(group, loc) + + # Clear this group from groups_set + self.groups_set.erase(group_remove) + + ############################################################################ + # Python convenience functions (not declared in .pxd) # + # # + ############################################################################ + + 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 + + def is_legal(self, action): + """Determine if the given action (x,y tuple) is a legal move + """ + + 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 1D location + location = calculate_board_location(y, x, self.size) + + return self.is_legal_move(location) + + def copy(self): + """Get a copy of this Game state + """ + + return GameState(copy=self) + + ############################################################################ + # public def functions used for unittests # + # # + ############################################################################ + + cpdef void print_groups(self): + """Debugging helper: prints address of all groups in this state and info about each one. + """ + + cdef location_t loc + cdef group_t val + cdef group_ptr_t group + + print("Empty: {0:x}".format( self.group_empty.get())) + print("Border: {0:x}".format( self.group_border.get())) + + for loc in range(self.board_size): + if self.board[loc].get() != self.group_empty.get(): + print("%03d: %x" % (loc, self.board[loc].get())) + + 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 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 + + # Check 1: all groups in self.board are also in self.groups_set. + for loc in range(self.board_size): + group1 = self.board[loc] + + # Skip empty group. + if group1 == self.group_empty: + continue + + # 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 + + # 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 + + def get_current_player(self): + """Returns the color of the player who will make the next move. + """ + + return self.current_player + + def get_history(self): + """Return history as a list of tuples + """ + + return [calculate_tuple_location(loc, self.size) for loc in self.moves_history] + + def get_captures_black(self): + """Return amount of black stones captures + """ + + return self.capture_black + + def get_captures_white(self): + """Return amount of white stones captured + """ + + return self.capture_white + + def get_ko_location(self): + """Return ko location as a tuple, or None + """ + + if self.ko == -1: + return None + + 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 i in range(self.board_size): + if d(self.board[i]).color != d(state.board[i]).color: + return False + + return True + + def is_liberty_equal(self, GameState state): + """Verify that self and state liberty counts are the same + """ + + 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_eye(self, action, color): + """Check if location action is a eye for player color + """ + + (x, y) = action + location = calculate_board_location(y, x, self.size) + + return self.is_true_eye(location, color) + + def get_liberty(self): + """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 = 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 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 = 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 + """ + + return self.size + + def get_handicaps(self): + """Return list with handicap stones placed by BLACK at beginning of the game. + """ + + return self.moves_history[:self.num_handicap] + + def get_print_board_layout(self): + """Print current board state + """ + + line = "\n" + for i in range(self.size): + row = str(i) + " " + for j in range(self.size): + 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): + """Enable python: print GameState + """ + + return self.get_print_board_layout() + + def __str__(self): + """Enable python: str(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/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..3d1230ec2 --- /dev/null +++ b/AlphaGo/go/ladders.pyx @@ -0,0 +1,173 @@ +# 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 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 '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 + + 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. + 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 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(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 + +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 '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 + + 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 + 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. + + 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 new file mode 100644 index 000000000..0b73e9629 --- /dev/null +++ b/AlphaGo/go_python.py @@ -0,0 +1,768 @@ +import numpy as np + +WHITE = -1 +BLACK = +1 +EMPTY = 0 +PASS_MOVE = None + + +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), dtype=int) + 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=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 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 + + 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. + # + # 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] = 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 + + 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 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. + + 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 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 + 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, assume it's worked. + if remaining_attempts <= 0: + return True + + 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), + 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. + return True + + # no ladder captures were found + return False + + 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 + 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. + + 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: + # 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), + remaining_attempts=(remaining_attempts - 1)) + 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: + 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 + + 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 color + pattern_hash = 2 + pattern_hash += long(self.current_player) + pattern_hash *= 10 + + # 8 surrounding position colors + 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/mcts.py b/AlphaGo/mcts.py index d23ed32b6..1e637b012 100644 --- a/AlphaGo/mcts.py +++ b/AlphaGo/mcts.py @@ -1,6 +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 +from operator import itemgetter + + +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 act_node: act_node[1].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): - pass + """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=itemgetter(1))[0] + state.do_move(max_action) + else: + # If no break from the loop, issue a warning. + print("WARNING: rollout reached move limit") + 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. + + 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 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 + 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 new file mode 100644 index 000000000..23fe7c5f2 --- /dev/null +++ b/AlphaGo/models/nn_util.py @@ -0,0 +1,137 @@ +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. + """ + + # 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. + """ + 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 + # 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.get_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 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 dd956c386..ba8d78a30 100644 --- a/AlphaGo/models/policy.py +++ b/AlphaGo/models/policy.py @@ -1,164 +1,325 @@ -from keras.models import Sequential -from keras.models import model_from_json -from keras.layers import convolutional +from keras.models import Sequential, Model +from keras.layers import Input, BatchNormalization, Conv2D +from keras.layers.merge import add from keras.layers.core import Activation, Flatten -import keras.backend as K -from AlphaGo.preprocessing.preprocessing import Preprocess from AlphaGo.util import flatten_idx -import json - - -class CNNPolicy(object): - """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 - self.model = CNNPolicy.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], [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] - - 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. - - Returns: TBD (stream of results? that would break zip(). - streaming pairs of pre-zipped (state, result)?) - """ - raise NotImplementedError() - - 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() - 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) - - @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 featuer 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()) - # softmax makes it into a probability distribution - network.add(Activation('softmax')) - - 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) - new_policy = CNNPolicy(object_specs['feature_list']) - new_policy.model = model_from_json(object_specs['keras_model']) - new_policy.forward = new_policy._model_forward() - return new_policy - - def save_model(self, json_file): - """write the network model and preprocessing features to the specified file - """ - # 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 = { - 'keras_model': self.model.to_json(), - 'feature_list': self.preprocessor.feature_list - } - # use the json module to write object_specs to file - with open(json_file, 'w') as f: - json.dump(object_specs, f) +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 + """ + + 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].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) + # 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.get_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) + - 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. + """ + 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(Conv2D( + input_shape=(params["input_dim"], params["board"], params["board"]), + 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', + 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): + # 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(Conv2D( + filters=filter_nb, + kernel_size=(filter_width, filter_width), + kernel_initializer='uniform', + activation='relu', + 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(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 + 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 = Conv2D( + input_shape=(), + 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 + 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 + + 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 = Conv2D( + filters=params["filters_per_layer"], + kernel_size=(filter_width, filter_width), + kernel_initializer='uniform', + activation='linear', + 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 = add([block_input, path]) + 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 = 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 + network_output = Bias()(network_output) + # softmax makes it into a probability distribution + network_output = Activation('softmax')(network_output) + + return Model(inputs=[model_input], outputs=[network_output]) diff --git a/AlphaGo/models/rollout.py b/AlphaGo/models/rollout.py new file mode 100644 index 000000000..75607a41f --- /dev/null +++ b/AlphaGo/models/rollout.py @@ -0,0 +1,83 @@ +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.Conv2D( + input_shape=(params["input_dim"], params["board"], params["board"]), + filters=1, + kernel_size=(1, 1), + kernel_initializer='uniform', + activation='relu', + 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 + 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 f23f18205..47486b4d2 100644 --- a/AlphaGo/models/value.py +++ b/AlphaGo/models/value.py @@ -1,43 +1,173 @@ +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 Dense, convolutional + + +@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.Conv2D( + input_shape=(params["input_dim"], params["board"], params["board"]), + 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', + 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 + 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.Conv2D( + filters=filter_nb, + kernel_size=(filter_width, filter_width), + kernel_initializer='uniform', + activation='relu', + 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.Conv2D( + filters=1, + kernel_size=(1, 1), + kernel_initializer='uniform', + activation='relu', + 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(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/game_converter.py b/AlphaGo/preprocessing/game_converter.py index ba64d1cd1..d09ea12b3 100644 --- a/AlphaGo/preprocessing/game_converter.py +++ b/AlphaGo/preprocessing/game_converter.py @@ -1,218 +1,229 @@ #!/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.preprocessing.preprocessing import Preprocess +from AlphaGo.util import sgf_iter_states class SizeMismatchError(Exception): - 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()) - - 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", 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)) - 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) + pass + + +class GameConverter: + + def __init__(self, features): + self.feature_processor = Preprocess(features) + self.n_features = self.feature_processor.get_output_dimension() + + 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.get_size() != bd_size: + raise SizeMismatchError() + 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): + """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] + + """ + + # 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' == 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', + 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') + + # 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.get_feature_list())) + + 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') # 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() + 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 = GameConverter(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/generate_value_training.py b/AlphaGo/preprocessing/generate_value_training.py new file mode 100644 index 000000000..8e2f42af7 --- /dev/null +++ b/AlphaGo/preprocessing/generate_value_training.py @@ -0,0 +1,324 @@ +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 import GameState +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(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 = [GameState() 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_color() == 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() == color else + LOSE for st in states]).reshape(actual_batch_size, 1) + return training_states, winners + + +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).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 + 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(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 + + # 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", + "color"] + else: + features = args.features.split(",") + + # always add color 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(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..0c4ecf910 --- /dev/null +++ b/AlphaGo/preprocessing/preprocessing.pxd @@ -0,0 +1,225 @@ +from AlphaGo.go.constants cimport stone_t, group_t, action_t +from AlphaGo.go.game_state cimport GameState +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 + + +############################################################################ +# Typedefs # +# # +############################################################################ + +ctypedef short location_t +ctypedef shared_ptr[Group] group_ptr_t + + +# 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) + + +############################################################################ +# Class definition # +# # +############################################################################ + +cdef class Preprocess: + + # 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 + + ############################################################################ + # Feature-generating functions # + # # + ############################################################################ + + 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 + - plane 1 to the opponent stones + - plane 2 to empty locations + """ + + 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 + """ + + 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 + """ + + 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 + - illegal move locations are all-zero features + """ + + 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, 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 + + 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, 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, 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, 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, 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, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset) # noqa: E501 + """Plane filled with zeros + """ + + 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, 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, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset) # noqa: E501 + """Ko positions + """ + + 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, 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 atari for at least one turn + """ + + 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, 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 + + 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 + """ + + 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_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 + """ + + ############################################################################ + # public cdef function # + # # + ############################################################################ + + 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.py b/AlphaGo/preprocessing/preprocessing.py deleted file mode 100644 index 4a792f46f..000000000 --- a/AlphaGo/preprocessing/preprocessing.py +++ /dev/null @@ -1,257 +0,0 @@ -import numpy as np -import AlphaGo.go as go - -## -## individual feature functions (state --> tensor) begin here -## - - -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 - return planes - - -def get_turns_since(state, maximum=8): - """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((state.size, state.size, maximum)) - 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[x, y, depth] = 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 - 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((state.size, state.size, maximum)) - 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 - # the "maximum-or-more" case on the backmost plane - planes[state.liberty_counts >= maximum, maximum - 1] = 1 - return planes - - -def get_capture_size(state, maximum=8): - """A feature encoding the number of opponent stones that would be captured by planing 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((state.size, state.size, maximum)) - 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[x, y, min(n_captured, maximum - 1)] = 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)) - - 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)) - 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.liberty_sets[gx][gy] - 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[x, y, min(group_size - 1, maximum - 1)] = 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 - """ - feature = np.zeros((state.size, state.size, maximum)) - # 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]) - 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] - # (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)) - feature[x, y, min(maximum - 1, len(lib_set_after) - 1)] = 1 - return feature - - -def get_ladder_capture(state): - raise NotImplementedError() - - -def get_ladder_escape(state): - 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((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 - 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((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((state.size, state.size)) - } -} - -DEFAULT_FEATURES = [ - "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] - - # 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 - f, s = self.output_dim, state.size - return np.concatenate(feat_tensors).reshape((1, f, s, s)) diff --git a/AlphaGo/preprocessing/preprocessing.pyx b/AlphaGo/preprocessing/preprocessing.pyx new file mode 100644 index 000000000..f8e9ca390 --- /dev/null +++ b/AlphaGo/preprocessing/preprocessing.pyx @@ -0,0 +1,546 @@ +# cython: wraparound=False +# cython: boundscheck=False +# cython: initializedcheck=False +# cython: nonecheck=False +from cython.operator cimport dereference as d +import numpy as np +cimport numpy as np + + +cdef class Preprocess: + + ############################################################################ + # Variables are declared in the .pxd file # + # # + ############################################################################ + + """ + # 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 + """ + + ############################################################################ + # Tensor generating functions # + # # + ############################################################################ + + 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 + - plane 1 to the opponent stones + - plane 2 to empty locations + """ + + 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 + for location in range(self.board_size): + # Get color of stone from its group + group = state.board[location] + if d(group).color == stone_t.EMPTY: + plane = 2 + elif d(group).color == opponent: + plane = 1 + else: + plane = 0 + + tensor[offset + plane, location] = 1 + + return offset + 3 + + 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 + """ + + cdef location_t location + cdef int age = offset + 7 + cdef set marked_locations = set() + + # 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 + + # 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 + marked_locations.add(location) + + # 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, 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 + """ + + 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[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, 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" (that is, the 0th + plane is used for legal moves that would not result in capture) + - illegal move locations are all-zero features + """ + + cdef location_t location + cdef short capture_size + + # Loop over all legal moves and set get capture size + 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, 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 location_t location + cdef short liberties, group_size + + # Loop over all groups on board + for location in state.legal_moves: + liberties = groups_after[location, 1] + # This group is in atari if it has exactly 1 liberty + 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, 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 + + 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 location_t location + cdef short liberties + + # Loop over all legal moves + 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, 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 vector[location_t] captures = vector[location_t]() + cdef location_t location + cdef group_ptr_t group + + # 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, 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 vector[location_t] escapes = vector[location_t]() + cdef location_t location + cdef group_ptr_t group + + # 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, 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 location_t location + cdef vector[location_t] moves = state.get_sensible_moves() + + for location in moves: + tensor[offset, location] = 1 + + return offset + 1 + + 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 location_t location + + for location in state.legal_moves: + tensor[offset, location] = 1 + + return offset + 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, 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. + """ + + # TODO - pass additional args to feature processors, redirect this function to + # get_ladder_escapes with less depth. + + cdef vector[location_t] escapes = vector[location_t]() + cdef location_t location + cdef group_ptr_t group + + # 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, 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_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(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(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 zeros(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset): # noqa: E501 + """Plane filled with zeros + """ + + # Nothing to do; all features begin with zeros. return offset + 1 + + cdef int ones(self, GameState state, onehot_t[:, :] tensor, lookahead_t[:, :] groups_after, int offset): # noqa: E501 + """Plane filled with ones + """ + + # Taking advantage of numpy slice indexing + tensor[offset, :] = 1 + + return offset + 1 + + 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.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, 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 != -1: + 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): # noqa: E501 + self.size = size + self.board_size = size * size + + # 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 = vector[preprocess_method]() + + self.requires_groups_after = False + + # TODO - load response pattern files + + 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 == "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 + 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": + 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": + raise NotImplementedError("neighbor is not yet implemented; requires preprocessing and training redesign") # noqa: E501 + + elif feat == "nakade": + raise NotImplementedError("nakade is not yet implemented; requires preprocessing and training redesign") # noqa: E501 + + elif feat == "response_12d": + raise NotImplementedError("response_12d is not yet implemented; requires preprocessing and training redesign") # noqa: E501 + + elif feat == "non_response_3x3": + raise NotImplementedError("non_response_3x3 is not yet implemented; requires preprocessing and training redesign") # noqa: E501 + + elif feat == "color": + processor = self.color + self.output_dim += 1 + + elif feat == "ko": + processor = self.ko + self.output_dim += 1 + else: + # Incorrect feature name + raise ValueError("uknown feature: %s" % feat) + + self.processors.push_back(processor) + + ############################################################################ + # public cpdef function # + # # + ############################################################################ + + 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 + 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 + if self.requires_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) + + # 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)) + + ############################################################################ + # public def function (Python) # + # # + ############################################################################ + + 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 + + +############################################################################ +# "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 np.ndarray[lookahead_t, ndim=1] result = np.zeros((3,), dtype=np.uint16) + + 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 diff --git a/AlphaGo/preprocessing/preprocessing_python.py b/AlphaGo/preprocessing/preprocessing_python.py new file mode 100644 index 000000000..7391063b0 --- /dev/null +++ b/AlphaGo/preprocessing/preprocessing_python.py @@ -0,0 +1,303 @@ +import numpy as np +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 +# here makes it universal to the project. +K.set_image_dim_ordering('th') + +## +# individual feature functions (state --> tensor) begin here +## + + +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 + + +def get_turns_since(state, maximum=8): + """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 + + +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 + + +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 + + +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 + + +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 + + +def get_ladder_capture(state): + """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): + """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): + """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 + + +# 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 + }, + "color": { + "size": 1, + "function": lambda state: np.ones((1, state.size, state.size)) * + (state.current_player == go.BLACK) + } +} + +DEFAULT_FEATURES = [ + "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 + + 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 e69de29bb..8e07b7290 100644 --- a/AlphaGo/training/reinforcement_policy_trainer.py +++ b/AlphaGo/training/reinforcement_policy_trainer.py @@ -0,0 +1,225 @@ +import os +import json +import re +import numpy as np +import AlphaGo.go as go +import keras.backend as K +from shutil import copyfile +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) + 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, 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) + ''' + + 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 + 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] + moves = opponent.get_moves(odd_states) + for st, mv in zip(odd_states, moves): + st.do_move(mv) + + current = learner + other = opponent + 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 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 + if is_learnable: + (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_color() == learner_color[idx] + just_finished.append(idx) + + # 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 + + # 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): + 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)) + + # Return the win ratio. + wins = sum(state.get_winner_color() == pc for (state, pc) in zip(states, learner_color)) + 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 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())) + + +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 + 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).") # 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("--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 + 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: + 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 + 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): + 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) + iter_start = 1 + int(player_weights[8:13]) + + # 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", []) + metadata["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 = 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"]) + 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, args.learning_rate, player, opponent, args.game_batch) + metadata["win_ratio"][player_weights] = (opp_weights, win_ratio) + + # Save intermediate models. + if i_iter % args.record_every == 0: + 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() diff --git a/AlphaGo/training/reinforcement_value_trainer.py b/AlphaGo/training/reinforcement_value_trainer.py index e69de29bb..99912d172 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, + 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"])) + + +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 27b591d70..19927a98d 100644 --- a/AlphaGo/training/supervised_policy_trainer.py +++ b/AlphaGo/training/supervised_policy_trainer.py @@ -1,213 +1,823 @@ -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=[]): - """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]]) - action = transform(one_hot_action(action_dataset[data_idx], 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 on_epoch_end(self, epoch, logs={}): - self.metadata["epochs"].append(logs) - - 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 - - with open(self.file, "w") as f: - 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)) -] - - -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]) - # 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) - n_val_data = int(args.train_val_test[1] * n_total_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 loadeda: %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 - - # 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:] - - # create dataset generators - train_data_generator = shuffled_hdf5_batch_generator( - dataset["states"], - dataset["actions"], - train_indices, - args.minibatch, - BOARD_TRANSFORMATIONS) - val_data_generator = shuffled_hdf5_batch_generator( - dataset["states"], - dataset["actions"], - val_indices, - args.minibatch, - BOARD_TRANSFORMATIONS) - - 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) + """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.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_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.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) + + 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 = 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 = 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"]) + 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, + 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"])) + + +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..96e22d652 --- /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 f1b83048a..57fddd453 100644 --- a/AlphaGo/util.py +++ b/AlphaGo/util.py @@ -1,83 +1,275 @@ +import os import sgf -import string +import itertools +import numpy as np from AlphaGo import go +from AlphaGo.go import GameState + +# for board location indexing +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 + (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): - """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: - row = string.letters.index(node_value[1]) - col = string.letters.index(node_value[0]) - # GameState expects (x, y) where x is column and y is row - 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 + 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 = int(props.get('SZ', ['19'])[0]) + s_player = props.get('PL', ['B'])[0] + # init board with specified size + gs = GameState(size=s_size) + # handle 'add black' property + if 'AB' in props: + for stone in props['AB']: + gs.place_handicap_stone(_parse_sgf_move(stone), go.BLACK) + # handle 'add white' property + if 'AW' in props: + 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) + 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): - 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): - """Iterates over (GameState, move, player) tuples in the first game of the given SGF file. - - Ignores variations - only the main line is returned. - - 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 - """ - 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) + """ + 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, 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]') + 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)) + + if result is not None: + str_list.append('RE[{}]'.format(result)) + + cycle_string = 'BW' + # Handle handicaps + if len(gamestate.get_handicaps()) > 0: + cycle_string = 'WB' + str_list.append('HA[{}]'.format(len(gamestate.get_handicaps()))) + str_list.append(';AB') + 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.get_history(), itertools.cycle(cycle_string)): + # Move color prefix + str_list.append(';{}'.format(color)) + # Move coordinates + if move is go.PASS: + 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, go.PASS, 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: + 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() diff --git a/README.md b/README.md index 568961ef1..f46b9bf54 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,26 @@ -# 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) -[![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) +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/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/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. -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. +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 -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). diff --git a/benchmarks/preprocessing_benchmark.py b/benchmarks/preprocessing_benchmark.py index 922c681ce..bfc798e05 100644 --- a/benchmarks/preprocessing_benchmark.py +++ b/benchmarks/preprocessing_benchmark.py @@ -1,16 +1,25 @@ -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) +test_features = ["board", "turns_since", "liberties", "capture_size", "self_atari_size", + "liberties_after", "sensibleness", "zeros"] +gc = GameConverter(test_features) args = ('tests/test_data/sgf/Lee-Sedol-vs-AlphaGo-20160309.sgf', 19) 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 new file mode 100644 index 000000000..6573c1f62 --- /dev/null +++ b/benchmarks/reinforcement_policy_training_benchmark.py @@ -0,0 +1,29 @@ +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) diff --git a/benchmarks/supervised_policy_training_benchmark.py b/benchmarks/supervised_policy_training_benchmark.py index 0293f7af9..28d7ed301 100644 --- a/benchmarks/supervised_policy_training_benchmark.py +++ b/benchmarks/supervised_policy_training_benchmark.py @@ -14,7 +14,8 @@ 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 new file mode 100644 index 000000000..f74cca451 --- /dev/null +++ b/interface/Play.py @@ -0,0 +1,35 @@ +"""Interface for AlphaGo self-play""" +from AlphaGo.go import PASS, WHITE, 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.get_history()) > 1: + if self.state.get_history()[-1] is PASS and self.state.get_history()[-2] is PASS \ + and self.state.get_current_player() == WHITE: + 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 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..b2808cd1f --- /dev/null +++ b/interface/gtp_wrapper.py @@ -0,0 +1,162 @@ +import sys +import gtp +from AlphaGo import go +import multiprocessing +from AlphaGo.go import GameState +from AlphaGo.util import save_gamestate_to_sgf +from builtins import input + + +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): + """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 = GameState(enforce_superko=True) + self._player = player + self._komi = 0 + + def clear(self): + self._state = GameState() + + 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) + 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 = 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: + 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 = 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' + cmd_list = inpt.split("\n") + for cmd in cmd_list: + engine_reply = gtp_engine.send(cmd) + sys.stdout.write(engine_reply) + sys.stdout.flush() 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 6c8ae24a0..000000000 Binary files a/interface/server/wgo/wood1.jpg and /dev/null differ diff --git a/requirements-tensorflow.txt b/requirements-tensorflow.txt new file mode 100644 index 000000000..7346612c7 --- /dev/null +++ b/requirements-tensorflow.txt @@ -0,0 +1,20 @@ +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 +Werkzeug==0.14.1 diff --git a/requirements-theano.txt b/requirements-theano.txt new file mode 100644 index 000000000..51fa6bb15 --- /dev/null +++ b/requirements-theano.txt @@ -0,0 +1,11 @@ +Cython==0.27.3 +future==0.16.0 +h5py==2.7.0 +Keras==2.0.4 +numpy==1.13.3 +pygtp==0.3 +PyYAML==3.12 +scipy==1.1.0 +sgf==0.5 +six==1.11.0 +Theano==1.0.2 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index e1ab76373..000000000 --- a/requirements.txt +++ /dev/null @@ -1,9 +0,0 @@ -Cython==0.24 -h5py==2.6.0 -Keras==1.0.0 -numpy==1.11.0 -PyYAML==3.11 -scipy==0.17.0 -sgf==0.5 -six==1.10.0 -Theano==0.8.1 diff --git a/setup.py b/setup.py new file mode 100644 index 000000000..c61c8bfe1 --- /dev/null +++ b/setup.py @@ -0,0 +1,43 @@ +import numpy +from distutils.core import setup +from distutils.extension import Extension +from Cython.Build import cythonize + +extensions = [ + Extension("AlphaGo.go.constants", ["AlphaGo/go/constants.pyx"], + 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++", + 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++", + 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++", + 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++", + 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++", + 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++", + extra_compile_args=["-std=c++11"], extra_link_args=["-std=c++11"]), +] + +setup(name="RocAlphaGo", ext_modules=cythonize(extensions)) + +""" + install all necessary dependencies using: + pip install -r requirements.txt + + 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 +""" diff --git a/tests/parseboard.py b/tests/parseboard.py new file mode 100644 index 000000000..517817088 --- /dev/null +++ b/tests/parseboard.py @@ -0,0 +1,32 @@ +from AlphaGo.go import BLACK, WHITE, GameState + + +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 '.' + + Rows are separated by '|', spaces are ignored. + + ''' + + boardstr = boardstr.replace(' ', '') + board_size = max(boardstr.index('|'), boardstr.count('|')) + state = 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#': + state.do_move((row, col), color=BLACK) + elif c in 'WO': + 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 state, 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 000000000..8dc12fac4 Binary files /dev/null and b/tests/test_data/hdf5/policy_training_features.hdf5 differ diff --git a/tests/test_data/hdf5/random_minimodel_policy_weights.hdf5 b/tests/test_data/hdf5/random_minimodel_policy_weights.hdf5 new file mode 100644 index 000000000..0314a47e5 Binary files /dev/null and b/tests/test_data/hdf5/random_minimodel_policy_weights.hdf5 differ diff --git a/tests/test_data/hdf5/random_minimodel_value_weights.hdf5 b/tests/test_data/hdf5/random_minimodel_value_weights.hdf5 new file mode 100644 index 000000000..110cb2a8e Binary files /dev/null and b/tests/test_data/hdf5/random_minimodel_value_weights.hdf5 differ 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 000000000..3c7f2f153 Binary files /dev/null and b/tests/test_data/hdf5/value_training_features.hdf5 differ diff --git a/tests/test_data/minimodel.json b/tests/test_data/minimodel.json deleted file mode 100644 index cbaeae20e..000000000 --- a/tests/test_data/minimodel.json +++ /dev/null @@ -1 +0,0 @@ -{"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 diff --git a/tests/test_data/minimodel_policy.json b/tests/test_data/minimodel_policy.json new file mode 100644 index 000000000..cf17e8f95 --- /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\": \"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 new file mode 100644 index 000000000..4273513d1 --- /dev/null +++ b/tests/test_data/minimodel_value.json @@ -0,0 +1 @@ +{"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_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 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 diff --git a/tests/test_game_converter.py b/tests/test_game_converter.py index 0d75c11e5..8d661558c 100644 --- a/tests/test_game_converter.py +++ b/tests/test_game_converter.py @@ -1,26 +1,32 @@ -from AlphaGo.preprocessing.game_converter import run_game_converter -from AlphaGo.util import sgf_to_gamestate -import unittest import os +import unittest +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): - 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_with_handicap/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 6c644bb3e..d4efb4b10 100644 --- a/tests/test_gamestate.py +++ b/tests/test_gamestate.py @@ -1,120 +1,370 @@ -from AlphaGo.go import GameState -import AlphaGo.go as go +import parseboard import unittest +import AlphaGo.go as go +from AlphaGo.go import GameState +from AlphaGo.util import flatten_idx 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_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.get_captures_black(), 1) + self.assertEqual(gs.get_captures_white(), 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.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.get_captures_black(), 2) + self.assertEqual(gs.get_captures_white(), 1) + + def test_positional_superko(self): + + # 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), + (8, 2), (0, 1), (8, 3), (1, 0), (8, 4), (2, 0), (0, 0)] + + for move in move_list: + gs.do_move(move) + self.assertTrue(gs.is_legal((1, 0))) + + # 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))) 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_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.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) + + # 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(size=7) + + for x in range(gs.get_size()): + for y in range(gs.get_size()): + if (x + y) % 2 == 1: + gs.do_move((x, y), color=go.BLACK) + self.assertTrue(gs.is_eye((0, 0), go.BLACK)) + + +class TestGroups(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 + # capture are the same as if the group had never been there + + 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): + 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(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 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 . . . .|" + ". B W . B . .|" + ". . . . . . B|" + ". . B . . . .|" + "W . . . W W .|") + + copy = gs.copy() + + 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.equality_checks(gs, copy) + + with copy.try_stone(0): + 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.assertTrue(gs.sanity_check_groups()) + self.assertTrue(copy.sanity_check_groups()) + self.equality_checks(gs, copy) + + # With move 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() + + 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) + + 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.assertTrue(copy.sanity_check_groups()) + self.equality_checks(gs, copy) + + with copy.try_stone(flatten_idx(moves['a'], gs.get_size())): + self.assertTrue(copy.sanity_check_groups()) + self.inequality_checks(gs, copy) + + # Move should now be undone - retry equality checks from above + self.assertTrue(copy.sanity_check_groups()) + self.equality_checks(gs, copy) + + 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.assertTrue(copy.sanity_check_groups()) + self.equality_checks(gs, copy) + + with copy.try_stone(flatten_idx(moves['c'], gs.get_size())): + self.assertTrue(copy.sanity_check_groups()) + self.inequality_checks(gs, copy) + + # Move should now be undone - retry equality checks from above + 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 . .|" + ". 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.assertTrue(copy.sanity_check_groups()) + self.equality_checks(gs, copy) + + with copy.try_stone(flatten_idx(moves['c'], gs.get_size())): + self.assertTrue(copy.sanity_check_groups()) + self.inequality_checks(gs, copy) + + # Move should now be undone - retry equality checks from above + 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() + unittest.main() diff --git a/tests/test_gtp_wrapper.py b/tests/test_gtp_wrapper.py new file mode 100644 index 000000000..db2c88c90 --- /dev/null +++ b/tests/test_gtp_wrapper.py @@ -0,0 +1,30 @@ +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 + + +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() diff --git a/tests/test_ladders.py b/tests/test_ladders.py new file mode 100644 index 000000000..a2de9df8f --- /dev/null +++ b/tests/test_ladders.py @@ -0,0 +1,168 @@ +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): + """Use interface provided by 'preprocessing' to test ladders + """ + + def test_captured_1(self): + st, moves = parseboard.parse("d b c . . . .|" + "B W a . . . .|" + ". B . . . . .|" + ". . . . . . .|" + ". . . . . . .|" + ". . . . . W .|") + st.set_current_player(BLACK) + + # 'a' should catch white in a ladder, but not '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(is_ladder_escape(st, moves['b'])) + + # W at 'b', check 'c' and 'd' + st.do_move(moves['b']) + 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 . . . . .|" + ". c . . . . .|" + ". . . . . . .|" + ". . . . . W .|" + ". . . . . . .|") + st.set_current_player(BLACK) + + # 'a' should not be a ladder capture, nor '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(is_ladder_escape(st, moves['b'])) + + # after 'b', 'c' should not be a capture + st.do_move(moves['b']) + self.assertFalse(is_ladder_capture(st, moves['c'])) + + def test_missing_ladder_breaker_1(self): + + st, moves = parseboard.parse(". B . . . . .|" + "B W B . . W .|" + "B a c . . . .|" + ". b . . . . .|" + ". . . . . . .|" + ". W . . . . .|" + ". . . . . . .|") + st.set_current_player(WHITE) + + # a should not be an escape move for white + self.assertFalse(is_ladder_escape(st, moves['a'])) + + # after 'a', 'b' should still be a capture ... + st.do_move(moves['a']) + self.assertTrue(is_ladder_capture(st, moves['b'])) + # ... but 'c' should not + self.assertFalse(is_ladder_capture(st, 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) + + # 'a' is not a capture because of ataris + self.assertFalse(is_ladder_capture(st, 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) + + # 'a' or 'b' will capture + 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(is_ladder_escape(st, 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.set_current_player(WHITE) + + # 'a' is not an escape for white + self.assertFalse(is_ladder_escape(st, moves['a'])) + + def test_two_captures(self): + + st, moves = parseboard.parse(". . . . . .|" + ". . . . . .|" + ". . a b . .|" + ". X O O X .|" + ". . X X . .|" + ". . . . . .|") + st.set_current_player(BLACK) + + # both 'a' and 'b' should be ladder captures + self.assertTrue(is_ladder_capture(st, moves['a'])) + self.assertTrue(is_ladder_capture(st, 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.set_current_player(WHITE) + + # both 'a' and 'b' should be considered escape moves for white after 'O' at c + self.assertTrue(is_ladder_escape(st, moves['a'])) + self.assertTrue(is_ladder_escape(st, moves['b'])) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_liberties.py b/tests/test_liberties.py deleted file mode 100644 index 4ea0bb728..000000000 --- a/tests/test_liberties.py +++ /dev/null @@ -1,43 +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 new file mode 100644 index 000000000..7ca8e316b --- /dev/null +++ b/tests/test_mcts.py @@ -0,0 +1,135 @@ +import unittest +import numpy as np +from operator import itemgetter +from AlphaGo.go import GameState +from AlphaGo.mcts import MCTS, TreeNode + + +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()) + + +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=itemgetter(1), 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.get_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) +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) + 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 + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_players.py b/tests/test_players.py new file mode 100644 index 000000000..fadc47f9e --- /dev/null +++ b/tests/test_players.py @@ -0,0 +1,38 @@ +import unittest +import numpy as np +from AlphaGo.ai import ProbabilisticPolicyPlayer + + +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() diff --git a/tests/test_policy.py b/tests/test_policy.py index 86bd44c55..088a794cb 100644 --- a/tests/test_policy.py +++ b/tests/test_policy.py @@ -1,39 +1,164 @@ -from AlphaGo.models.policy import CNNPolicy -from AlphaGo.go import GameState -import unittest import os +import unittest +import numpy as np +from AlphaGo import go +from AlphaGo.go import GameState +from AlphaGo.models.policy import CNNPolicy, ResnetPolicy +from AlphaGo.ai import GreedyPolicyPlayer, ProbabilisticPolicyPlayer 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_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))) + self.assertEqual(output.shape, (1, 13 * 13)) + + 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' + + # 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) + + 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 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)) + + # 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) + + +class TestPlayers(unittest.TestCase): + + def test_greedy_player(self): + + gs = GameState() + policy = CNNPolicy(["board", "ones", "turns_since"]) + player = GreedyPolicyPlayer(policy) + for _ in range(20): + move = player.get_move(gs) + self.assertNotEqual(move, go.PASS) + gs.do_move(move) + + def test_probabilistic_player(self): - 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)) + gs = GameState() + policy = CNNPolicy(["board", "ones", "turns_since"]) + player = ProbabilisticPolicyPlayer(policy) + for _ in range(20): + move = player.get_move(gs) + self.assertNotEqual(move, go.PASS) + gs.do_move(move) - 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_sensible_probabilistic(self): - def test_save_load(self): - policy = CNNPolicy(["board", "liberties", "sensibleness", "capture_size"]) + 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.set_current_player(go.BLACK) + self.assertEqual(player.get_move(gs), go.PASS) - model_file = 'TESTPOLICY.json' - weights_file = 'TESTWEIGHTS.h5' + def test_sensible_greedy(self): - policy.save_model(model_file) - policy.model.save_weights(weights_file) + 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) - copypolicy = CNNPolicy.load_model(model_file) - copypolicy.model.load_weights(weights_file) + gs.set_current_player(go.BLACK) + self.assertEqual(player.get_move(gs), go.PASS) - os.remove(model_file) - os.remove(weights_file) if __name__ == '__main__': - unittest.main() + unittest.main() diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py index a96bd4cad..bf464f7ba 100644 --- a/tests/test_preprocessing.py +++ b/tests/test_preprocessing.py @@ -1,222 +1,417 @@ -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 import GameState +from AlphaGo.preprocessing.preprocessing import Preprocess 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 + gs = GameState(size=7) + + # 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) + + # 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(): + gs = GameState(size=7) + + # 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.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(): + gs = GameState(size=7) + + # 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 + + 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.set_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): - # TODO - at the moment there is no imminent capture - gs = simple_board() - pp = Preprocess(["capture_size"]) - feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) - - 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 - - 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): - # TODO - at the moment there is no imminent self-atari for white - gs = simple_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)))) - - 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_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_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 + """ + + def test_get_board(self): + gs = simple_board() + pp = Preprocess(["board"], size=7) + 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.get_size(), gs.get_size())) - (white_pos + black_pos) + + # check number of planes + 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"], size=7) + feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) + + one_hot_turns = np.zeros((gs.get_size(), gs.get_size(), 8)) + + rev_moves = list(gs.get_history()) + rev_moves = rev_moves[::-1] + + board = gs.get_board() + + 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 + + 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)) + + # todo - test liberties when > 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 + + # 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"], 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 + for (x, y) in gs.get_legal_moves(): + copy = gs.copy() + copy.do_move((x, y)) + num_captured = copy.get_captures_white() - 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"], size=7) + feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) + + 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 + 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"], size=7) + feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) + + 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 + # 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"], size=7) + feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) + + 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)) + + liberty = copy.get_liberty() + + libs = liberty[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"], size=7) + feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) + + 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)) + + liberty = copy.get_liberty() + + libs = liberty[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): + gs, moves = parseboard.parse(". . . . . . .|" + "B W a . . . .|" + ". B . . . . .|" + ". . . . . . .|" + ". . . . . . .|" + ". . . . . W .|") + pp = Preprocess(["ladder_capture"], size=7) + feature = pp.state_to_tensor(gs)[0, 0] # 1D tensor; no need to transpose + + 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): + # 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"], 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.get_size(), gs.get_size())) + expectation[moves['a']] = 1 + + self.assertTrue(np.all(expectation == feature)) + + def test_two_escapes(self): + gs, 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 + gs.do_move(moves['c'], color=go.WHITE) + gs.set_current_player(go.WHITE) + + 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 + + # 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): + 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) + + for (x, y) in gs.get_legal_moves(): + expectation[x, y] = 1 + + # '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 + + 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 + + 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"], size=7) + feature = pp.state_to_tensor(gs)[0].transpose((1, 2, 0)) + + expectation = np.zeros((gs.get_size(), gs.get_size(), 3 + 1 + 8)) + + board = gs.get_board() + + # first three planes: board + 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(): + 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 new file mode 100644 index 000000000..0e214858e --- /dev/null +++ b/tests/test_reinforcement_policy_trainer.py @@ -0,0 +1,230 @@ +import os +import unittest +import numpy as np +import AlphaGo.go as go +import numpy.testing as npt +from keras.optimizers import SGD +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/') + + +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): + + 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, 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 + + def get_moves(self, states): + 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): + + 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_color(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') + output = os.path.join('tests', 'test_data', '.tmp.rl.training/') + args = [model, init_weights, output, '--game-batch', '1', '--iterations', + '1', '--record-every', '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) + + 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.model.set_weights(init_weights) + optimizer = SGD(lr=0.001) + policy1.model.compile(loss=log_loss, optimizer=optimizer) + + learner = MockPlayer(policy1, game) + opponent = MockPlayer(policy2, game) + + # Run RL training + run_n_games(optimizer, 0.001, 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_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) + + # 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): + 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')) + learner = MockPlayer(policy1, game) + opponent = MockPlayer(policy2, game) + 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, 0.001, 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) + + for f in _list_mock_games(SGF_FOLDER): + test_game_run_N(f) + + 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')) + learner = MockPlayer(policy1, game) + opponent = MockPlayer(policy2, game) + 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 + 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, 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) + 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) + + for f in _list_mock_games(SGF_FOLDER): + test_game_increase(f) + + 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')) + learner = MockPlayer(policy1, game) + opponent = MockPlayer(policy2, game) + 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 + 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, 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) + 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__': + unittest.main() diff --git a/tests/test_reinforcement_value_trainer.py b/tests/test_reinforcement_value_trainer.py new file mode 100644 index 000000000..760a0c5d7 --- /dev/null +++ b/tests/test_reinforcement_value_trainer.py @@ -0,0 +1,124 @@ +import os +import unittest +import numpy as np +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 +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): + + gs = GameState() + + 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 fea59f361..c1d8577ac 100644 --- a/tests/test_supervised_policy_trainer.py +++ b/tests/test_supervised_policy_trainer.py @@ -1,20 +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' - 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) + def testTrain(self): + 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 = ['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_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() diff --git a/tests/test_value.py b/tests/test_value.py new file mode 100644 index 000000000..a1ba3e471 --- /dev/null +++ b/tests/test_value.py @@ -0,0 +1,124 @@ +import os +import unittest +import numpy as np +from AlphaGo import go +from AlphaGo.ai import ValuePlayer +from AlphaGo.go import GameState +from AlphaGo.models.value import CNNValue + + +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 + self.assertTrue(isinstance(results[0], np.float64)) + 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) + + 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): + + gs = GameState(size=9) + + 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): + + gs = GameState(size=9) + + 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): + + gs = GameState() + + 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): + + gs = GameState() + + 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() diff --git a/tox.ini b/tox.ini index c0c48a2db..6de08aac1 100644 --- a/tox.ini +++ b/tox.ini @@ -1,3 +1,3 @@ [flake8] -ignore = W191,E266,E501 exclude = src/keras,src/theano,interface/opponents/pachi/pachi +max-line-length=100