rubykon 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +5 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.travis.yml +11 -0
- data/CHANGELOG.md +32 -0
- data/CODE_OF_CONDUCT.md +13 -0
- data/Gemfile +7 -0
- data/Guardfile +12 -0
- data/LICENSE +22 -0
- data/POSSIBLE_IMPROVEMENTS.md +25 -0
- data/README.md +36 -0
- data/Rakefile +6 -0
- data/benchmark/benchmark.sh +22 -0
- data/benchmark/full_playout.rb +17 -0
- data/benchmark/mcts_avg.rb +23 -0
- data/benchmark/playout.rb +15 -0
- data/benchmark/playout_micros.rb +188 -0
- data/benchmark/profiling/full_playout.rb +7 -0
- data/benchmark/profiling/mcts.rb +6 -0
- data/benchmark/results/HISTORY.md +541 -0
- data/benchmark/scoring.rb +20 -0
- data/benchmark/scoring_micros.rb +60 -0
- data/benchmark/support/benchmark-ips.rb +11 -0
- data/benchmark/support/benchmark-ips_shim.rb +143 -0
- data/benchmark/support/playout_help.rb +13 -0
- data/examples/mcts_laziness.rb +22 -0
- data/exe/rubykon +5 -0
- data/lib/benchmark/avg.rb +14 -0
- data/lib/benchmark/avg/benchmark_suite.rb +59 -0
- data/lib/benchmark/avg/job.rb +92 -0
- data/lib/mcts.rb +11 -0
- data/lib/mcts/examples/double_step.rb +68 -0
- data/lib/mcts/mcts.rb +13 -0
- data/lib/mcts/node.rb +88 -0
- data/lib/mcts/playout.rb +22 -0
- data/lib/mcts/root.rb +49 -0
- data/lib/rubykon.rb +13 -0
- data/lib/rubykon/board.rb +188 -0
- data/lib/rubykon/cli.rb +122 -0
- data/lib/rubykon/exceptions/exceptions.rb +1 -0
- data/lib/rubykon/exceptions/illegal_move_exception.rb +4 -0
- data/lib/rubykon/eye_detector.rb +27 -0
- data/lib/rubykon/game.rb +115 -0
- data/lib/rubykon/game_scorer.rb +62 -0
- data/lib/rubykon/game_state.rb +93 -0
- data/lib/rubykon/group.rb +99 -0
- data/lib/rubykon/group_tracker.rb +144 -0
- data/lib/rubykon/gtp_coordinate_converter.rb +25 -0
- data/lib/rubykon/move_validator.rb +55 -0
- data/lib/rubykon/version.rb +3 -0
- data/rubykon.gemspec +21 -0
- metadata +97 -0
data/lib/mcts.rb
ADDED
@@ -0,0 +1,68 @@
|
|
1
|
+
module MCTS
|
2
|
+
module Examples
|
3
|
+
class DoubleStep
|
4
|
+
|
5
|
+
FINAL_POSITION = 6
|
6
|
+
MAX_STEP = 2
|
7
|
+
|
8
|
+
attr_reader :positions
|
9
|
+
|
10
|
+
def initialize(positions = init_positions, n = 0)
|
11
|
+
@positions = positions
|
12
|
+
@move_count = n
|
13
|
+
end
|
14
|
+
|
15
|
+
def finished?
|
16
|
+
@positions.any? {|_color, position| position >= FINAL_POSITION }
|
17
|
+
end
|
18
|
+
|
19
|
+
def generate_move
|
20
|
+
rand(MAX_STEP) + 1
|
21
|
+
end
|
22
|
+
|
23
|
+
def set_move(move)
|
24
|
+
steps = move
|
25
|
+
@positions[next_turn_color] += steps
|
26
|
+
@move_count += 1
|
27
|
+
end
|
28
|
+
|
29
|
+
def dup
|
30
|
+
self.class.new @positions.dup, @move_count
|
31
|
+
end
|
32
|
+
|
33
|
+
def won?(color)
|
34
|
+
fail "Game not finished" unless finished?
|
35
|
+
@positions[color] > @positions[other_color(color)]
|
36
|
+
end
|
37
|
+
|
38
|
+
def all_valid_moves
|
39
|
+
if finished?
|
40
|
+
[]
|
41
|
+
else
|
42
|
+
[1, 2]
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def last_turn_color
|
47
|
+
@move_count.odd? ? :black : :white
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
def next_turn_color
|
52
|
+
@move_count.even? ? :black : :white
|
53
|
+
end
|
54
|
+
|
55
|
+
def init_positions
|
56
|
+
{black: 0, white: 0}
|
57
|
+
end
|
58
|
+
|
59
|
+
def other_color(color)
|
60
|
+
if color == :black
|
61
|
+
:white
|
62
|
+
else
|
63
|
+
:black
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
data/lib/mcts/mcts.rb
ADDED
data/lib/mcts/node.rb
ADDED
@@ -0,0 +1,88 @@
|
|
1
|
+
module MCTS
|
2
|
+
class Node
|
3
|
+
attr_reader :parent, :move, :wins, :visits, :children, :game_state
|
4
|
+
|
5
|
+
def initialize(game_state, move, parent)
|
6
|
+
@parent = parent
|
7
|
+
@game_state = game_state
|
8
|
+
@move = move
|
9
|
+
@wins = 0.0
|
10
|
+
@visits = 0
|
11
|
+
@children = []
|
12
|
+
@untried_moves = game_state.all_valid_moves
|
13
|
+
@leaf = game_state.finished? || @untried_moves.empty?
|
14
|
+
end
|
15
|
+
|
16
|
+
def uct_value
|
17
|
+
win_percentage + UCT_BIAS_FACTOR * Math.sqrt(Math.log(parent.visits)/@visits)
|
18
|
+
end
|
19
|
+
|
20
|
+
def win_percentage
|
21
|
+
@wins/@visits
|
22
|
+
end
|
23
|
+
|
24
|
+
def root?
|
25
|
+
false
|
26
|
+
end
|
27
|
+
|
28
|
+
def leaf?
|
29
|
+
@leaf
|
30
|
+
end
|
31
|
+
|
32
|
+
def uct_select_child
|
33
|
+
children.max_by &:uct_value
|
34
|
+
end
|
35
|
+
|
36
|
+
# maybe get a maximum depth or soemthing in
|
37
|
+
def expand
|
38
|
+
move = @untried_moves.pop
|
39
|
+
create_child(move)
|
40
|
+
end
|
41
|
+
|
42
|
+
def rollout
|
43
|
+
playout = Playout.new(@game_state)
|
44
|
+
playout.play
|
45
|
+
end
|
46
|
+
|
47
|
+
def won
|
48
|
+
@visits += 1
|
49
|
+
@wins += 1
|
50
|
+
end
|
51
|
+
|
52
|
+
def lost
|
53
|
+
@visits += 1
|
54
|
+
end
|
55
|
+
|
56
|
+
def backpropagate(won)
|
57
|
+
node = self
|
58
|
+
node.update_won won
|
59
|
+
until node.root? do
|
60
|
+
won = !won # switching players perspective
|
61
|
+
node = node.parent
|
62
|
+
node.update_won(won)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def untried_moves?
|
67
|
+
!@untried_moves.empty?
|
68
|
+
end
|
69
|
+
|
70
|
+
def update_won(won)
|
71
|
+
if won
|
72
|
+
self.won
|
73
|
+
else
|
74
|
+
self.lost
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
private
|
79
|
+
|
80
|
+
def create_child(move)
|
81
|
+
game_state = @game_state.dup
|
82
|
+
game_state.set_move(move)
|
83
|
+
child = Node.new game_state, move, self
|
84
|
+
@children << child
|
85
|
+
child
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
data/lib/mcts/playout.rb
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
module MCTS
|
2
|
+
class Playout
|
3
|
+
|
4
|
+
attr_reader :game_state
|
5
|
+
|
6
|
+
def initialize(game_state)
|
7
|
+
@game_state = game_state.dup
|
8
|
+
end
|
9
|
+
|
10
|
+
def play
|
11
|
+
my_color = @game_state.last_turn_color
|
12
|
+
playout
|
13
|
+
@game_state.won?(my_color)
|
14
|
+
end
|
15
|
+
|
16
|
+
def playout
|
17
|
+
until @game_state.finished?
|
18
|
+
@game_state.set_move @game_state.generate_move
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
data/lib/mcts/root.rb
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
module MCTS
|
2
|
+
class Root < Node
|
3
|
+
def initialize(game_state)
|
4
|
+
super game_state, nil, nil
|
5
|
+
end
|
6
|
+
|
7
|
+
def root?
|
8
|
+
true
|
9
|
+
end
|
10
|
+
|
11
|
+
def best_child
|
12
|
+
children.max_by &:win_percentage
|
13
|
+
end
|
14
|
+
|
15
|
+
def best_move
|
16
|
+
best_child.move
|
17
|
+
end
|
18
|
+
|
19
|
+
def explore_tree
|
20
|
+
selected_node = select
|
21
|
+
playout_node = if selected_node.leaf?
|
22
|
+
selected_node
|
23
|
+
else
|
24
|
+
selected_node.expand
|
25
|
+
end
|
26
|
+
won = playout_node.rollout
|
27
|
+
playout_node.backpropagate(won)
|
28
|
+
end
|
29
|
+
|
30
|
+
def update_won(won)
|
31
|
+
# logic reversed as the node accumulates its children and has no move
|
32
|
+
# of its own
|
33
|
+
if won
|
34
|
+
self.lost
|
35
|
+
else
|
36
|
+
self.won
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
def select
|
42
|
+
node = self
|
43
|
+
until node.untried_moves? || node.leaf? do
|
44
|
+
node = node.uct_select_child
|
45
|
+
end
|
46
|
+
node
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
data/lib/rubykon.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
require_relative 'mcts'
|
2
|
+
|
3
|
+
require_relative 'rubykon/group'
|
4
|
+
require_relative 'rubykon/group_tracker'
|
5
|
+
require_relative 'rubykon/game'
|
6
|
+
require_relative 'rubykon/board'
|
7
|
+
require_relative 'rubykon/move_validator'
|
8
|
+
require_relative 'rubykon/eye_detector'
|
9
|
+
require_relative 'rubykon/game_scorer'
|
10
|
+
require_relative 'rubykon/exceptions/exceptions'
|
11
|
+
require_relative 'rubykon/game_state'
|
12
|
+
require_relative 'rubykon/gtp_coordinate_converter'
|
13
|
+
require_relative 'rubykon/cli'
|
@@ -0,0 +1,188 @@
|
|
1
|
+
# Board it acts a bit like a giant 2 dimensional array - but one based
|
2
|
+
# not zero based
|
3
|
+
module Rubykon
|
4
|
+
class Board
|
5
|
+
include Enumerable
|
6
|
+
|
7
|
+
BLACK = :black
|
8
|
+
WHITE = :white
|
9
|
+
EMPTY = nil
|
10
|
+
|
11
|
+
attr_reader :size, :board
|
12
|
+
|
13
|
+
# weird constructor for dup
|
14
|
+
def initialize(size, board = create_board(size))
|
15
|
+
@size = size
|
16
|
+
@board = board
|
17
|
+
end
|
18
|
+
|
19
|
+
def each
|
20
|
+
@board.each_with_index do |color, identifier|
|
21
|
+
yield identifier, color
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def cutting_point_count
|
26
|
+
@board.size
|
27
|
+
end
|
28
|
+
|
29
|
+
def [](identifier)
|
30
|
+
@board[identifier]
|
31
|
+
end
|
32
|
+
|
33
|
+
def []=(identifier, color)
|
34
|
+
@board[identifier] = color
|
35
|
+
end
|
36
|
+
|
37
|
+
# this method is rather raw and explicit, it gets called a lot
|
38
|
+
def neighbours_of(identifier)
|
39
|
+
x = identifier % size
|
40
|
+
y = identifier / size
|
41
|
+
right = identifier + 1
|
42
|
+
below = identifier + @size
|
43
|
+
left = identifier - 1
|
44
|
+
above = identifier - @size
|
45
|
+
board_edge = @size - 1
|
46
|
+
not_on_x_edge = x > 0 && x < board_edge
|
47
|
+
not_on_y_edge = y > 0 && y < board_edge
|
48
|
+
|
49
|
+
if not_on_x_edge && not_on_y_edge
|
50
|
+
[[right, @board[right]], [below, @board[below]],
|
51
|
+
[left, @board[left]], [above, @board[above]]]
|
52
|
+
else
|
53
|
+
handle_edge_cases(x, y, above, below, left, right, board_edge,
|
54
|
+
not_on_x_edge, not_on_y_edge)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def neighbour_colors_of(identifier)
|
59
|
+
neighbours_of(identifier).map {|identifier, color| color}
|
60
|
+
end
|
61
|
+
|
62
|
+
def diagonal_colors_of(identifier)
|
63
|
+
diagonal_coordinates(identifier).inject([]) do |res, n_identifier|
|
64
|
+
res << self[n_identifier] if on_board?(n_identifier)
|
65
|
+
res
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def on_edge?(identifier)
|
70
|
+
x, y = x_y_from identifier
|
71
|
+
x == 1 || x == size || y == 1 || y == size
|
72
|
+
end
|
73
|
+
|
74
|
+
def on_board?(identifier)
|
75
|
+
identifier >= 0 && identifier < @board.size
|
76
|
+
end
|
77
|
+
|
78
|
+
COLOR_TO_CHARACTER = {BLACK => ' X', WHITE => ' O', EMPTY => ' .'}
|
79
|
+
CHARACTER_TO_COLOR = COLOR_TO_CHARACTER.invert
|
80
|
+
LEGACY_CONVERSION = {'X' => ' X', 'O' => ' O', '-' => ' .'}
|
81
|
+
CHARS_PER_GLYPH = 2
|
82
|
+
|
83
|
+
def ==(other_board)
|
84
|
+
board == other_board.board
|
85
|
+
end
|
86
|
+
|
87
|
+
def to_s
|
88
|
+
@board.each_slice(@size).map do |row|
|
89
|
+
row_chars = row.map do |color|
|
90
|
+
COLOR_TO_CHARACTER.fetch(color)
|
91
|
+
end
|
92
|
+
row_chars.join
|
93
|
+
end.join("\n") << "\n"
|
94
|
+
end
|
95
|
+
|
96
|
+
def dup
|
97
|
+
self.class.new @size, @board.dup
|
98
|
+
end
|
99
|
+
|
100
|
+
MAKE_IT_OUT_OF_BOUNDS = 1000
|
101
|
+
|
102
|
+
def identifier_for(x, y)
|
103
|
+
return nil if x.nil? || y.nil?
|
104
|
+
x = MAKE_IT_OUT_OF_BOUNDS if x > @size || x < 1
|
105
|
+
(y - 1) * @size + (x - 1)
|
106
|
+
end
|
107
|
+
|
108
|
+
def x_y_from(identifier)
|
109
|
+
x = (identifier % (@size)) + 1
|
110
|
+
y = (identifier / (@size)) + 1
|
111
|
+
[x, y]
|
112
|
+
end
|
113
|
+
|
114
|
+
def self.from(string)
|
115
|
+
new_board = new(string.index("\n") / CHARS_PER_GLYPH)
|
116
|
+
each_move_from(string) do |index, color|
|
117
|
+
new_board[index] = color
|
118
|
+
end
|
119
|
+
new_board
|
120
|
+
end
|
121
|
+
|
122
|
+
def self.each_move_from(string)
|
123
|
+
glyphs = string.tr("\n", '').chars.each_slice(CHARS_PER_GLYPH).map(&:join)
|
124
|
+
relevant_glyphs = glyphs.select do |glyph|
|
125
|
+
CHARACTER_TO_COLOR.has_key?(glyph)
|
126
|
+
end
|
127
|
+
relevant_glyphs.each_with_index do |glyph, index|
|
128
|
+
yield index, CHARACTER_TO_COLOR.fetch(glyph)
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
def self.convert(old_board_string)
|
133
|
+
old_board_string.gsub /[XO-]/, LEGACY_CONVERSION
|
134
|
+
end
|
135
|
+
|
136
|
+
private
|
137
|
+
|
138
|
+
def create_board(size)
|
139
|
+
Array.new(size * size, EMPTY)
|
140
|
+
end
|
141
|
+
|
142
|
+
def handle_edge_cases(x, y, above, below, left, right, board_edge, not_on_x_edge, not_on_y_edge)
|
143
|
+
left_edge = x == 0
|
144
|
+
right_edge = x == board_edge
|
145
|
+
top_edge = y == 0
|
146
|
+
bottom_edge = y == board_edge
|
147
|
+
if left_edge && not_on_y_edge
|
148
|
+
[[right, @board[right]], [below, @board[below]],
|
149
|
+
[above, @board[above]]]
|
150
|
+
elsif right_edge && not_on_y_edge
|
151
|
+
[[below, @board[below]],
|
152
|
+
[left, @board[left]], [above, @board[above]]]
|
153
|
+
elsif top_edge && not_on_x_edge
|
154
|
+
[[right, @board[right]], [below, @board[below]],
|
155
|
+
[left, @board[left]]]
|
156
|
+
elsif bottom_edge && not_on_x_edge
|
157
|
+
[[right, @board[right]],
|
158
|
+
[left, @board[left]], [above, @board[above]]]
|
159
|
+
else
|
160
|
+
handle_corner_case(above, below, left, right, bottom_edge, left_edge, right_edge, top_edge)
|
161
|
+
end
|
162
|
+
end
|
163
|
+
|
164
|
+
def handle_corner_case(above, below, left, right, bottom_edge, left_edge, right_edge, top_edge)
|
165
|
+
if left_edge && top_edge
|
166
|
+
[[right, @board[right]], [below, @board[below]]]
|
167
|
+
elsif left_edge && bottom_edge
|
168
|
+
[[above, @board[above]], [right, @board[right]]]
|
169
|
+
elsif right_edge && top_edge
|
170
|
+
[[left, @board[left]], [below, @board[below]]]
|
171
|
+
elsif right_edge && bottom_edge
|
172
|
+
[[left, @board[left]], [above, @board[above]]]
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
def diagonal_coordinates(identifier)
|
177
|
+
x = identifier % size
|
178
|
+
if x == 0
|
179
|
+
[identifier + 1 - @size, identifier + 1 + @size]
|
180
|
+
elsif x == size - 1
|
181
|
+
[identifier - 1 - @size, identifier - 1 + @size]
|
182
|
+
else
|
183
|
+
[identifier - 1 - @size, identifier - 1 + @size,
|
184
|
+
identifier + 1 - @size, identifier + 1 + @size]
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
end
|