refutation PV / refactor

This commit is contained in:
Sander Land committed 2020-05-07 17:48:09 +02:00
1 parent 82ded97058
commit 0972cba8d3
10 files changed
+127 -81

No files matched your search

+4 -4
View File
@@ -173,11 +173,11 @@ If you ever need to reset to the original settings, simply re-download the `conf
* The program is running too slowly. How can I speed it up?
* Adjust the number of visits or maximum time allowed in the settings.
* KataGo crashes with out of memory errors, how can I prevent this?
* Try using a lower number for `nnMaxBatchSize` in `KataGo/analysis_config.cfg`, and avoid using the board size 29 version.
* Try using a lower number for `nnMaxBatchSize` in `KataGo/analysis_config.cfg`, and avoid using versions compiled with large board sizes.
## Contributing
* Feedback and pull requests are both very welcome.
* Feedback and pull requests are both very welcome. I would also be happy to host translations of this manual into languages where English fluency is typically lower.
* For suggestions and planned improvements, see the 'issues' tab on github.
* You can also contact me on discord (Sander#3278) or [reddit](http://reddit.com/u/sanderbaduk) to give feedback, or simply show your appreciation.
* You can also contact me on discord (Sander#3278), [KakaoTalk](https://open.kakao.com/o/gTsMJCac) or [Reddit](http://reddit.com/u/sanderbaduk) to give feedback, or simply show your appreciation.
* Some people have also asked me how to donate. Something go-related such as a book or teaching time is highly appreciated.
+2 -2
View File
@@ -75,7 +75,7 @@ def malkovich_analysis(cn):
logger.log(f"dscore {dscore} = {cn.analysis['root']['scoreLead']} {cn.parent.analysis['root']['scoreLead']} at {move}...", OUTPUT_ERROR)
if abs(dscore) > REPORT_SCORE_THRESHOLD and (cn.player == "B" and dscore < 0 or cn.player == "W" and dscore > 0): # relevant mistakes
favpl = "B" if dscore > 0 else "W"
msg = f"MALKOVICH:{cn.player} {cn.single_move.gtp()} caused a significant score change ({favpl} gained {abs(dscore):.1f} points)"
msg = f"MALKOVICH:{cn.player} {cn.move.gtp()} caused a significant score change ({favpl} gained {abs(dscore):.1f} points)"
if cn.ai_thoughts:
msg += f" -> Win Rate {cn.format_win_rate()} Score {cn.format_score()} AI Thoughts: {cn.ai_thoughts}"
else:
@@ -139,7 +139,7 @@ while True:
if not pol:
pol = ["??"]
print(f"DISCUSSION:OK, since you passed {MAX_PASS} times after the {bx+by}th move, I will pass as well [policy {pol[-1]:.3%}].", file=sys.stderr)
move = game.play(Move(None, player=game.next_player)).single_move
move = game.play(Move(None, player=game.next_player)).move
else:
move, node = ai_move(game, ai_strategy, ai_settings)
if node is None:
+2 -2
View File
@@ -99,11 +99,11 @@ def ai_move(game: Game, ai_mode: str, ai_settings: Dict) -> Tuple[Move, GameNode
ai_thoughts += f"Generated weights for {ai_mode} according to weight factor {ai_settings['line_weight']} and distance from {thr_line+1}th line. "
elif "local" in ai_mode or "tenuki" in ai_mode:
var = ai_settings["stddev"] ** 2
if not cn.single_move or cn.single_move.coords is None:
if not cn.move or cn.move.coords is None:
weighted_coords = [(1, 1, *top_policy_move.coords)] # if "pick" in ai_mode -> even
ai_thoughts += f"No previous non-pass move, faking weights to play top policy move. "
else:
mx, my = cn.single_move.coords
mx, my = cn.move.coords
weighted_coords = [
(policy_grid[y][x], math.exp(-0.5 * ((x - mx) ** 2 + (y - my) ** 2) / var), x, y) for x in range(size[0]) for y in range(size[1]) if policy_grid[y][x] > 0
]
+2 -2
View File
@@ -286,7 +286,7 @@ class Game:
cn.analyze(engine, priority, visits=visits, refine_move=move, time_limit=False) # explicitly requested so take as long as you need
def analyze_undo(self, node, train_config):
move = node.single_move
move = node.move
if node != self.current_node or node.auto_undo is not None or not node.analysis_ready or not move:
return
points_lost = node.points_lost
@@ -309,5 +309,5 @@ class Game:
node.auto_undo = undo
if undo:
self.undo(1)
self.katrain.controls.set_status(f"Undid move {move.gtp()} as it lost {points_lost:.1f} points{xmsg}")
self.katrain.controls.set_status(f"Undid move {move.gtp()} as it lost {points_lost:.1f} points{xmsg}. Hover over the move to see expected refutation.")
self.katrain.update_state()
+11 -5
View File
@@ -86,8 +86,14 @@ class GameNode(SGFNode):
if win_rate is not None:
return f"{'B' if win_rate > 0.5 else 'W'} {max(win_rate,1-win_rate):.1%}"
def comment(self, sgf=False, teach=False, hints=False):
single_move = self.single_move
def make_pv(self, player, pv, interactive):
pvtext = f"{player}{' '.join(pv)}"
# if interactive:
# pvtext = f"[u][ref={pvtext}][color=#334466]{pvtext}[/color][/ref][/u]"
return pvtext
def comment(self, sgf=False, teach=False, hints=False, interactive=False):
single_move = self.move
if not self.parent or not single_move: # root
return ""
@@ -109,7 +115,7 @@ class GameNode(SGFNode):
text += f"Move was predicted best move.\n"
if sgf:
if previous_top_move.get("pv") and (sgf or hints):
text += f"PV: {single_move.player}{' '.join(previous_top_move['pv'])}\n"
text += f"PV: {self.make_pv(single_move.player,previous_top_move['pv'],interactive)}\n"
if sgf or hints or teach:
policy_ranking = self.parent.policy_ranking
@@ -131,7 +137,7 @@ class GameNode(SGFNode):
@property
def points_lost(self) -> Optional[float]:
single_move = self.single_move
single_move = self.move
if single_move and self.parent and self.analysis_ready and self.parent.analysis_ready:
parent_score = self.parent.score
score = self.score
@@ -139,7 +145,7 @@ class GameNode(SGFNode):
@property
def parent_realized_points_lost(self) -> Optional[float]:
single_move = self.single_move
single_move = self.move
if single_move and self.parent and self.parent.parent and self.analysis_ready and self.parent.parent.analysis_ready:
parent_parent_score = self.parent.parent.score
score = self.score
+38 -13
View File
@@ -5,16 +5,19 @@ from typing import Any, Dict, List, Optional, Tuple
class ParseError(Exception):
"""Exception raised on a parse error"""
pass
class Move:
GTP_COORD = list("ABCDEFGHJKLMNOPQRSTUVWXYZ") + [xa + c for xa in "AB" for c in "ABCDEFGHJKLMNOPQRSTUVWXYZ"] # kata board size 29 support
GTP_COORD = list("ABCDEFGHJKLMNOPQRSTUVWXYZ") + [xa + c for xa in "AB" for c in "ABCDEFGHJKLMNOPQRSTUVWXYZ"] # board size 52+ support
PLAYERS = "BW"
SGF_COORD = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ".lower()) + list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
SGF_COORD = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ".lower()) + list("ABCDEFGHIJKLMNOPQRSTUVWXYZ") # sgf goes to 52
@classmethod
def from_gtp(cls, gtp_coords, player="B"):
"""Initialize a move from GTP coordinates and player"""
if "pass" in gtp_coords.lower():
return cls(coords=None, player=player)
match = re.match(r"([A-Z]+)(\d+)", gtp_coords)
@@ -22,11 +25,13 @@ class Move:
@classmethod
def from_sgf(cls, sgf_coords, board_size, player="B"):
"""Initialize a move from SGF coordinates and player"""
if sgf_coords == "" or Move.SGF_COORD.index(sgf_coords[0]) == board_size[0]: # some servers use [tt] for pass
return cls(coords=None, player=player)
return cls(coords=(Move.SGF_COORD.index(sgf_coords[0]), board_size[1] - Move.SGF_COORD.index(sgf_coords[1]) - 1), player=player)
return cls(coords=(Move.SGF_COORD.index(sgf_coords[0]), board_size[1] - Move.SGF_COORD.index(sgf_coords[1]) - 1), player=player,)
def __init__(self, coords: Optional[Tuple[int, int]] = None, player: str = "B"):
"""Initialize a move from zero-based coordinates and player"""
self.player = player
self.coords = coords
@@ -37,21 +42,25 @@ class Move:
return self.coords == other.coords and self.player == other.player
def gtp(self):
"""Returns GTP coordinates of the move"""
if self.is_pass:
return "pass"
return Move.GTP_COORD[self.coords[0]] + str(self.coords[1] + 1)
def sgf(self, board_size):
"""Returns SGF coordinates of the move"""
if self.is_pass:
return ""
return f"{Move.SGF_COORD[self.coords[0]]}{Move.SGF_COORD[board_size[1] - self.coords[1] - 1]}"
@property
def is_pass(self):
"""Returns True if the move is a pass"""
return self.coords is None
@property
def opponent(self):
"""Returns the opposing player, i.e. W <-> B"""
return "W" if self.player == "B" else "B"
@@ -114,6 +123,7 @@ class SGFNode:
@property
def parent(self) -> Optional["SGFNode"]:
"""Returns the parent node"""
return self._parent
@parent.setter
@@ -123,13 +133,15 @@ class SGFNode:
self._depth = None
@property
def root(self) -> "SGFNode": # cached root property
def root(self) -> "SGFNode":
"""Returns the root of the tree, cached for speed"""
if self._root is None:
self._root = self.parent.root if self.parent else self
return self._root
@property
def depth(self) -> int: # cached depth property
def depth(self) -> int:
"""Returns the depth of this node, where root is 0, cached for speed"""
if self._depth is None:
if self.is_root:
self._depth = 0
@@ -137,9 +149,9 @@ class SGFNode:
self._depth = self.parent.depth + 1
return self._depth
# some root properties are available on any node
@property
def board_size(self) -> Tuple[int, int]:
"""Retrieves the root's SZ property, or 19 if missing. Parses it, and returns board size as a tuple x,y"""
size = str(self.root.get_property("SZ", "19"))
if ":" in size:
x, y = map(int, size.split(":"))
@@ -150,15 +162,17 @@ class SGFNode:
@property
def komi(self) -> float:
"""Retrieves the root's KM property, or 6.5 if missing"""
return float(self.root.get_property("KM", 6.5))
@property
def ruleset(self) -> str:
return self.root.get_property("RU")
"""Retrieves the root's RU property, or 'japanese' if missing"""
return self.root.get_property("RU", "japanese")
@property
def moves(self) -> List[Move]:
"""Returns all moves in the node."""
"""Returns all moves in the node - typically 'move' will be better."""
return [Move.from_sgf(move, player=pl, board_size=self.board_size) for pl in Move.PLAYERS for move in self.get_list_property(pl, [])]
@property
@@ -172,30 +186,35 @@ class SGFNode:
return self.placements + self.moves
@property
def single_move(self) -> Optional[Move]:
def move(self) -> Optional[Move]:
"""Returns the single move for the node if one exists, or None if no moves (or multiple ones) exist."""
moves = self.moves
if len(moves) == 1: # TODO: and not placements?
if len(moves) == 1:
return moves[0]
@property
def is_root(self) -> bool:
"""Returns true if node is a root"""
return self.parent is None
@property
def is_pass(self) -> bool:
return not self.placements and self.single_move and self.single_move.is_pass
"""Returns true if associated move is pass"""
return not self.placements and self.move and self.move.is_pass
@property
def empty(self) -> bool:
"""Returns true if node has no children or properties"""
return not self.children and not self.properties
@property
def nodes_in_tree(self) -> List:
"""Returns all nodes in the tree rooted at this node"""
return [self] + sum([c.nodes_in_tree for c in self.children], [])
@property
def nodes_from_root(self) -> List:
"""Returns all nodes from the root up to this node, i.e. the moves played in the current branch of the game"""
nodes = [self]
n = self
while not n.is_root:
@@ -206,12 +225,13 @@ class SGFNode:
def play(self, move) -> "SGFNode":
"""Either find an existing child or create a new one with the given move."""
for c in self.children:
if c.single_move == move:
if c.move == move:
return c
return self.__class__(parent=self, move=move)
@property
def next_player(self):
"""Returns player to move"""
if "B" in self.properties or "AB" in self.properties: # root or black moved
return "W"
else:
@@ -219,21 +239,26 @@ class SGFNode:
@property
def player(self):
"""Returns player that moved last. nb root is considered white played if no handicap stones are placed"""
if "B" in self.properties or "AB" in self.properties:
return "B"
else:
return "W" # nb root is considered white played if no handicap stones are placed
return "W"
class SGF:
"""Class used for SGF Nodes, can change this to something that inherits from SGFNode"""
_NODE_CLASS = SGFNode
@classmethod
def parse(cls, input_str) -> SGFNode:
"""Parse a string as SGF."""
return cls(input_str).root
@classmethod
def parse_file(cls, filename, encoding=None) -> SGFNode:
"""Parse a file as SGF, encoding will be detected if not given."""
with open(filename, "rb") as f:
bin_contents = f.read()
if not encoding:
+56 -50
View File
@@ -25,7 +25,7 @@ class BadukPanWidget(Widget):
self.grid_size = 0
self.stone_size = 0
self.last_eval = 0
self.active_hints = []
self.active_pv_moves = []
self.show_pv_for = None
self.redraw_board_contents_trigger = Clock.create_trigger(self.draw_board_contents)
Window.bind(mouse_pos=self.on_mouse_pos)
@@ -60,10 +60,10 @@ class BadukPanWidget(Widget):
pos = args[1]
rel_pos = self.to_widget(*pos) # compensate for relative layout
inside = self.collide_point(*rel_pos)
if inside and self.active_hints:
if inside and self.active_pv_moves:
near_move = [
move
for move in self.active_hints
for move in self.active_pv_moves
if abs(rel_pos[0] - self.gridpos_x[move[0]]) < self.grid_size / 2 and abs(rel_pos[1] - self.gridpos_y[move[1]]) < self.grid_size / 2
]
if near_move:
@@ -81,7 +81,7 @@ class BadukPanWidget(Widget):
xd, xp = self._find_closest(touch.x, self.gridpos_x)
yd, yp = self._find_closest(touch.y, self.gridpos_y)
nodes_here = [node for node in katrain.game.current_node.nodes_from_root if node.single_move and node.single_move.coords == (xp, yp)]
nodes_here = [node for node in katrain.game.current_node.nodes_from_root if node.move and node.move.coords == (xp, yp)]
if nodes_here and max(yd, xd) < self.grid_size / 2: # load old comment
if touch.is_double_tap: # navigate to move
katrain.game.set_current_node(nodes_here[-1])
@@ -89,8 +89,7 @@ class BadukPanWidget(Widget):
else: # load comments
katrain.log(f"\nAnalysis:\n{nodes_here[-1].analysis}", OUTPUT_DEBUG)
katrain.log(f"\nParent Analysis:\n{nodes_here[-1].parent.analysis}", OUTPUT_DEBUG)
katrain.controls.info.text = nodes_here[-1].comment(sgf=True)
katrain.controls.info.text = nodes_here[-1].comment(sgf=True)
katrain.controls.info.text = nodes_here[-1].comment(sgf=True, interactive=True)
self.ghost_stone = None
self.draw_hover_contents() # remove ghost
@@ -256,23 +255,6 @@ class BadukPanWidget(Widget):
with pass_btn.canvas.after:
draw_circle((pass_btn.pos[0] + pass_btn.width / 2, pass_btn.pos[1] + pass_btn.height / 2), polsize * pass_btn.height / 2, self.ui_config["policy_color"])
# children of current moves in undo / review
undo_coords = set()
if katrain.controls.show_children.active:
alpha = self.ui_config["ghost_alpha"]
for child_node in current_node.children:
points_lost = child_node.points_lost
m = child_node.single_move
if m and m.coords is not None:
undo_coords.add(m.coords)
if points_lost is None:
evalcol = None
else:
evalcol = copy.copy(self.eval_color(points_lost))
evalcol[3] = alpha
scale = self.ui_config["child_scale"]
self.draw_stone(m.coords[0], m.coords[1], (*stone_color[m.player][:3], alpha), None, None, evalcol, evalscale=scale, scale=scale)
# pass circle
passed = len(nodes) > 1 and current_node.is_pass
if passed:
@@ -296,49 +278,73 @@ class BadukPanWidget(Widget):
current_node = katrain.game.current_node
player, next_player = current_node.player, current_node.next_player
stone_color = self.ui_config["stones"]
lock_ai = self.trainer_config.get("lock_ai", False) and katrain.controls.teaching_mode_enabled()
teaching = katrain.controls.teaching_mode_enabled()
lock_ai = self.trainer_config.get("lock_ai", False) and teaching
self.canvas.after.clear()
with self.canvas.after:
self.active_pv_moves = []
# children of current moves in undo / review
if katrain.controls.show_children.active:
alpha = self.ui_config["ghost_alpha"]
for child_node in current_node.children:
points_lost = child_node.points_lost
move = child_node.move
if move and move.coords is not None:
if points_lost is None:
evalcol = None
else:
evalcol = copy.copy(self.eval_color(points_lost))
evalcol[3] = alpha
self.active_pv_moves.append(move.coords)
if teaching and move.coords == self.show_pv_for and child_node.analysis_ready:
self.draw_pv(katrain, [move.gtp()] + child_node.candidate_moves[0].get("pv", []), [next_player, player])
else:
scale = self.ui_config["child_scale"]
self.draw_stone(move.coords[0], move.coords[1], (*stone_color[move.player][:3], alpha), None, None, evalcol, evalscale=scale, scale=scale)
# hints or PV
self.active_hints = []
if katrain.controls.hints.active and not game_ended and not lock_ai:
hint_moves = current_node.candidate_moves
for i, d in enumerate(hint_moves):
move = Move.from_gtp(d["move"])
if move.coords is not None: # and move.coords not in undo_coords:
for i, move_dict in enumerate(hint_moves):
move = Move.from_gtp(move_dict["move"])
if move.coords is not None:
alpha, scale = self.ui_config["ghost_alpha"], 1.0
if i == 0:
alpha += self.ui_config["top_move_x_alpha"]
elif d["visits"] < self.ui_config["visit_frac_small"] * hint_moves[0]["visits"]:
elif move_dict["visits"] < self.ui_config["visit_frac_small"] * hint_moves[0]["visits"]:
scale = 0.8
self.active_hints.append(move.coords)
if move.coords == self.show_pv_for: # TODO: overlapping moves
pv = d.get("pv", [move.gtp()]) # if empty, show current move at least
for i, gtpmove in enumerate(pv):
move_player = [next_player, player][i % 2]
opp_player = [next_player, player][1 - i % 2]
coords = Move.from_gtp(gtpmove).coords
if coords is None: # tee-hee
sizefac = katrain.board_controls.pass_btn.size[1] / 2 / self.stone_size
board_coords = [
katrain.board_controls.pass_btn.pos[0] + katrain.board_controls.pass_btn.size[0] + self.stone_size * sizefac,
katrain.board_controls.pass_btn.pos[1] + katrain.board_controls.pass_btn.size[1] / 2,
]
else:
board_coords = (self.gridpos_x[coords[0]], self.gridpos_y[coords[1]])
sizefac = 0.95
draw_circle(board_coords, self.stone_size * sizefac, stone_color[move_player])
Color(*stone_color[opp_player])
draw_text(pos=board_coords, text=str(i + 1), font_size=sizefac * self.grid_size / 1.45)
self.active_pv_moves.append(move.coords)
if move.coords == self.show_pv_for:
self.draw_pv(katrain, move_dict.get("pv", [move.gtp()]), [next_player, player]) # if empty, show current move at least
elif not self.show_pv_for:
self.draw_stone(move.coords[0], move.coords[1], [*self.eval_color(d["pointsLost"])[:3], alpha], scale=scale)
self.draw_stone(move.coords[0], move.coords[1], [*self.eval_color(move_dict["pointsLost"])[:3], alpha], scale=scale)
# hover next move ghost stone
if self.ghost_stone:
self.draw_stone(*self.ghost_stone, (*stone_color[next_player], ghost_alpha))
def draw_pv(self, katrain, pv, next_last_player):
# TODO: overlapping moves
stone_color = self.ui_config["stones"]
for i, gtpmove in enumerate(pv):
move_player = next_last_player[i % 2]
opp_player = next_last_player[1 - i % 2]
coords = Move.from_gtp(gtpmove).coords
if coords is None: # tee-hee
sizefac = katrain.board_controls.pass_btn.size[1] / 2 / self.stone_size
board_coords = [
katrain.board_controls.pass_btn.pos[0] + katrain.board_controls.pass_btn.size[0] + self.stone_size * sizefac,
katrain.board_controls.pass_btn.pos[1] + katrain.board_controls.pass_btn.size[1] / 2,
]
else:
board_coords = (self.gridpos_x[coords[0]], self.gridpos_y[coords[1]])
sizefac = 0.95
draw_circle(board_coords, self.stone_size * sizefac, stone_color[move_player])
Color(*stone_color[opp_player])
draw_text(pos=board_coords, text=str(i + 1), font_size=sizefac * self.grid_size / 1.45)
class BadukPanControls(BoxLayout):
pass
+3 -3
View File
@@ -54,7 +54,7 @@ class Controls(BoxLayout):
info = ""
if current_node:
move = current_node.single_move
move = current_node.move
both_players_are_robots = "ai" in self.player_mode(current_node.player) and "ai" in self.player_mode(current_node.next_player)
next_player_is_human_or_both_robots = current_node.player and ("ai" not in self.player_mode(current_node.player) or both_players_are_robots)
current_player_is_ai_playing_human = current_node.player and "ai" in self.player_mode(current_node.player) and "ai" not in self.player_mode(current_node.next_player)
@@ -73,10 +73,10 @@ class Controls(BoxLayout):
elif not current_player_is_ai_playing_human:
self.score_change.label = f"Points lost"
self.score_change.text = ""
elif current_player_is_ai_playing_human and current_node.parent and current_node.parent.single_move:
elif current_player_is_ai_playing_human and current_node.parent and current_node.parent.move:
points_lost = current_node.parent.points_lost
self.score_change.label = f"Points lost" if points_lost and points_lost > 0 else f"Points gained"
self.score_change.text = f"{current_node.parent.single_move.player}: {abs(points_lost):.1f}" if points_lost else "..."
self.score_change.text = f"{current_node.parent.move.player}: {abs(points_lost):.1f}" if points_lost else "..."
elif both_players_are_robots and current_node.parent and current_node.parent.analysis_ready:
self.score.text = current_node.parent.format_score()
self.win_rate.text = current_node.parent.format_win_rate()
+5
View File
@@ -80,9 +80,14 @@ class BackgroundLabel(Label, BackgroundColor):
class ScrollableLabel(ScrollView):
__events__ = ["on_ref_press"]
text = StringProperty("")
markup = BooleanProperty(False)
border_color = ListProperty([0, 0, 0, 1])
def on_ref_press(self, ref):
pass
class StyledButton(Button, ToolTipBehavior):
button_color = ListProperty([])
+4
View File
@@ -402,6 +402,7 @@
width:1
Label:
id: label
markup: root.markup
padding: 5, 5
font_size: dp(18)
color: BLACK
@@ -409,6 +410,7 @@
height: self.texture_size[1]
text_size: self.width, None
text: root.text
on_ref_press: root.dispatch('on_ref_press',args[1])
<Controls>:
orientation: 'vertical'
@@ -626,6 +628,8 @@
height: 0.00001 if not self.text else min(0.66*self.parent.height,self.label.texture_size[1])
ScrollableLabel:
id: info
# markup: True
# on_ref_press: root.katrain.board_gui.show_pv_from_label(args[1])
size_hint: 1, None
height: self.parent.height - status_label.height - 1
BoxLayout: