analysis back up

This commit is contained in:
Sander Land committed 2020-04-15 23:21:40 +02:00
1 parent 15276c9062
commit 25093fe35e
10 files changed
+128 -87

No files matched your search

+5 -3
View File
@@ -2,7 +2,8 @@
"engine": {
"command": "KataGo/katago analysis -model models/b15-1.3.2.txt.gz -config KataGo/analysis_config.cfg -analysis-threads 8",
"visits": 2000,
"visits_fast": 500
"visits_fast": 500,
"enable_ownership": true
},
"game": {
"size": 19,
@@ -26,11 +27,12 @@
"ghost_alpha": 0.5,
"undo_alpha": 0.5,
"undo_scale": 0.95,
"eval_colors": [[0.537, 0.129, 0.42], [1, 0, 0], [1, 0.95, 0], [0.5, 0.6, 0], [0.117, 0.588, 0]],
"eval_thresholds": [10,5,1.5,0.5],
"eval_colors": [[0.537, 0.129, 0.42], [1, 0, 0], [1,0.5,0], [0.95, 0.95, 0.25], [0.67, 0.95, 0.18], [0.117, 0.588, 0]],
"eval_thresholds": [12,6,3,1.5,0.5],
"board_margin": 1.5,
"starpoint_size": 0.1,
"stone_size": 0.475,
"eval_dot_max_size": 0.5,
"board_color": [0.85, 0.68, 0.40],
"line_color": [0,0,0],
"eval_off_show_last": 3
+21 -9
View File
@@ -15,6 +15,10 @@ from game_node import GameNode
class KataGoEngine:
"""Starts and communicates with the KataGO analysis engine"""
# TODO: we don't support suicide in game.py, so no "tt": "tromp-taylor", "nz": "new-zealand"
RULESETS = {"jp": "japanese", "cn": "chinese", "ko": "korean", "aga":"aga"}
RULESETS.update({v: v for v in RULESETS.values()})
def __init__(self, katrain, config):
self.command = os.path.join(config["command"])
self.katrain = katrain
@@ -26,6 +30,7 @@ class KataGoEngine:
self.fast = True
self.query_counter = 0
self.katago_process = None
try:
self.katago_process = subprocess.Popen(self.command, stdin=subprocess.PIPE, stdout=subprocess.PIPE)
self.analysis_thread = threading.Thread(target=self._analysis_read_thread, daemon=True).start()
@@ -44,31 +49,38 @@ class KataGoEngine:
else:
callback, start_time = self.queries[analysis["id"]]
time_taken = time.time() - start_time
self.katrain.log(f"[{time_taken:.1f}][{analysis['id']}] KataGo Analysis Received:", line[:80], "...")
self.katrain.log(f"[{time_taken:.1f}][{analysis['id']}] KataGo Analysis Received: {line}...") # [:80]
callback(analysis)
self.katrain.update_evaluation() # TODO: ??
self.katrain.redraw()
def request_analysis(self, analysis_node: GameNode, callback: Callable, faster=False, min_visits=0, priority=0):
def request_analysis(
self, analysis_node: GameNode, callback: Callable, faster=False, min_visits=0, priority=0, ownership=None
):
query_id = f"QUERY:{str(self.query_counter)}"
self.query_counter += 1
visits = 100 # TODO / fast = self.ai_fast.active
if faster:
visits /= 5
moves = [m for node in analysis_node.nodes_from_root for m in node.move_with_placements]
if ownership is None:
ownership = self.config["enable_ownership"]
query = {
"id": query_id,
"moves": [[m.player, m.gtp()] for m in moves],
"includeOwnership": True,
"maxVisits": max(min_visits, visits),
"rules": self.RULESETS.get( str(analysis_node.ruleset).lower(), "japanese"),
"priority": priority,
"rules": "japanese",
"analyzeTurns": [len(moves)],
"maxVisits": max(min_visits, visits),
"komi": analysis_node.komi,
"boardXSize": analysis_node.board_size,
"boardYSize": analysis_node.board_size,
"analyzeTurns": [len(moves)],
"includeOwnership": ownership,
"moves": [[m.player, m.gtp()] for m in moves],
}
self.queries[query_id] = (callback, time.time())
if self.katago_process:
self.katrain.log(f"Sending query {query_id}: {str(query)[:80]}", OUTPUT_DEBUG)
self.katrain.log(f"Sending query {query_id}: {str(query)}", OUTPUT_DEBUG) # [:80]
self.katago_process.stdin.write((json.dumps(query) + "\n").encode())
self.katago_process.stdin.flush()
+6 -3
View File
@@ -25,14 +25,17 @@ class Game:
self.katrain = katrain
self.engine = engine
self.config = config
self.board_size = board_size or self.config.get("size", 19)
self.komi = self.config.get(f"komi_{self.board_size}", 6.5)
self.game_id = datetime.strftime(datetime.now(), "%Y-%m-%d %H %M %S")
if move_tree:
self.root = move_tree
self.board_size = self.root.board_size
self.komi = self.root.komi
else:
self.root = GameNode(properties={"RU": "JP", "SZ": self.board_size, "KM": self.komi, "PC": "KaTrain: https://github.com/sanderland/katrain", "DT": self.game_id})
self.board_size = board_size or config['size']
self.komi = self.config.get(f"komi_{self.board_size}",6.5)
self.root = GameNode(properties={"RU": "JP", "SZ": self.board_size, "KM": self.komi, "AP": "[KaTrain:https://github.com/sanderland/katrain]", "DT": self.game_id})
self.current_node = self.root
for node in self.root.nodes_in_tree:
node.analyze(self.engine)
+6
View File
@@ -43,6 +43,12 @@ class GameNode(SGFNode):
score = score or self.score
return f"{'B' if score >= 0 else 'W'}+{abs(score):.1f}"
def format_win_rate(self, win_rate=None):
win_rate = win_rate or self.analysis[0]['winrate']
b_adv = win_rate-0.5
return f"{'B' if b_adv > 0 else 'W'}+{abs(b_adv):.1%}"
def comment(self, sgf=False, eval=False, hints=False):
single_move = self.single_move
if not self.parent or not single_move: # root
+17 -14
View File
@@ -65,27 +65,30 @@ class BadukPanWidget(Widget):
self.draw_board()
self.draw_board_contents()
def draw_stone(self, x, y, col, outline_col=None, innercol=None, evalcol=None, evalsize=10.0, scale=1.0):
def draw_stone(self, x, y, col, outline_col=None, innercol=None, evalcol=None, evalscale=1.0, scale=1.0):
stone_size = self.stone_size * scale
draw_circle((self.gridpos[x], self.gridpos[y]), stone_size, col)
if outline_col:
Color(*outline_col)
Line(circle=(self.gridpos[x], self.gridpos[y], stone_size), width=0.05 * stone_size)
if evalcol:
evalsize = min(self.EVAL_BOUNDS[1], max(evalsize, self.EVAL_BOUNDS[0])) / self.EVAL_BOUNDS[1]
draw_circle((self.gridpos[x], self.gridpos[y]), math.sqrt(evalsize) * stone_size * 0.5, evalcol)
evalsize = self.stone_size * evalscale * self.config['eval_dot_max_size']
draw_circle((self.gridpos[x], self.gridpos[y]), evalsize, evalcol)
# highlight_col = [ ((1-c)*0.33+e)/1.33 for c,e in zip(col,evalcol) ]
# Color(*highlight_col[:3],0.5)
# Line(circle=(self.gridpos[x], self.gridpos[y], evalsize))
if innercol:
Color(*innercol)
Line(circle=(self.gridpos[x], self.gridpos[y], stone_size * 0.45 / 0.85), width=0.125 * stone_size) # 1.75
def _eval_spectrum(self, points_lost):
EVAL_COLORS = self.config["eval_colors"]
EVAL_THRESHOLDS = self.config["eval_thresholds"]
def eval_color(self, points_lost):
colors = self.config["eval_colors"]
thresholds = self.config["eval_thresholds"]
i = 0
while points_lost < EVAL_THRESHOLDS[i] and i < len(EVAL_COLORS):
while i < len(thresholds) and points_lost < thresholds[i]:
i += 1
return EVAL_COLORS[i]
return colors[min(i,len(colors)-1)]
def draw_board(self, *args):
if not self.config:
@@ -151,7 +154,7 @@ class BadukPanWidget(Widget):
for m in node.move_with_placements:
if has_stone[m.coords]: # skip captures, draw over repeat plays
move_eval_on = full_eval_on[m.player] or i >= len(nodes) - show_n_eval
evalcol = self._eval_spectrum(eval) if move_eval_on and eval and evalsize > self.config.get("min_eval_temperature", 0.5) else None
evalcol = self.eval_color(eval) if move_eval_on and eval and evalsize > self.config.get("min_eval_temperature", 0.5) else None
inner = stone_color[m.opponent] if (m is current_node) else None
self.draw_stone(m.coords[0], m.coords[1], stone_color[m.player], outline_color[m.player], inner, evalcol, evalsize)
@@ -172,20 +175,20 @@ class BadukPanWidget(Widget):
undo_coords = set()
alpha = self.config["undo_alpha"]
for child_node in current_node.children:
eval_info = child_node.evaluation_info
points_lost = node.points_lost
m = child_node.single_move
if m and m.coords[0] is not None:
undo_coords.add(m.coords)
evalcol = (*self._eval_spectrum(eval_info[0]), alpha) if eval_info[0] else None
evalcol = (*self.eval_color(points_lost), alpha) if points_lost else None
scale = self.config.get("undo_scale", 0.95)
self.draw_stone(m.coords[0], m.coords[1], (*stone_color[m.player][:3], alpha), None, None, evalcol, self.EVAL_BOUNDS[1], scale=scale)
self.draw_stone(m.coords[0], m.coords[1], (*stone_color[m.player][:3], alpha), None, None, evalcol, evalscale=scale, scale=scale)
# hints
if katrain.controls.hints.active(next_player):
hint_moves = current_node.ai_moves
for i, d in enumerate(hint_moves):
move = Move.from_gtp(d["move"])
c = [*self._eval_spectrum(d["evaluation"]), 0.5]
c = [*self.eval_color(d["evaluation"]), 0.5]
if move.coords[0] is not None and move.coords not in undo_coords:
if i == 0:
scale = 1.0
+37 -38
View File
@@ -21,15 +21,14 @@ class Controls(GridLayout):
def set_status(self, msg):
self.info.text = msg
def show_evaluation_stats(self, move):
if move.analysis_ready:
self.score.text = move.format_score().replace("-", "\u2013")
self.temperature.text = f"{move.temperature_stats[2]:.1f}"
if move.parent and move.parent.analysis_ready:
if move.evaluation is not None:
self.evaluation.text = f"{move.evaluation:.1%}"
else:
self.evaluation.text = f"?"
def show_evaluation_stats(self, node):
if node.analysis_ready:
self.score.text = node.format_score().replace("-", "\u2013")
self.win_rate.text = node.format_win_rate()
if node.points_lost is not None:
self.points_lost.text = f"{node.points_lost:.1f}"
else:
self.points_lost.text = f"?"
# handles showing completed analysis and triggered actions like auto undo and ai move
def update_evaluation(self):
@@ -40,36 +39,36 @@ class Controls(GridLayout):
if current_player_is_human_or_both_robots and not current_node.is_root:
self.info.text = current_node.comment(eval=True, hints=self.hints.active(move.player))
self.evaluation.text = ""
self.points_lost.text = ""
if current_player_is_human_or_both_robots:
self.show_evaluation_stats(current_node)
if current_node.analysis_ready and current_node.parent and current_node.parent.analysis_ready and not current_node.children and not current_node.x_comment.get("undo"):
# handle automatic undo
if self.auto_undo.active(move.player) and not self.ai_auto.active(move.player) and not current_node.auto_undid:
ts = self.train_settings
# TODO: is this overly generous wrt low visit outdated evaluations?
evaluation = current_node.evaluation if current_node.evaluation is not None else 1 # assume move is fine if temperature is negative
move_eval = max(evaluation, current_node.outdated_evaluation or 0)
points_lost = (current_node.parent or current_node).temperature_stats[2] * (1 - move_eval)
if move_eval < ts["undo_eval_threshold"] and points_lost >= ts["undo_point_threshold"]:
if self.num_undos(current_node) == 0:
current_node.x_comment["undid"] = f"Move was below threshold, but no undo granted (probability is {ts['num_undo_prompts']:.0%}).\n"
self.update_evaluation()
if False: # TODO: UNDO AND AI MOVE
if current_node.analysis_ready and current_node.parent and current_node.parent.analysis_ready and not current_node.children and not current_node.x_comment.get("undo"):
# handle automatic undo
if self.auto_undo.active(move.player) and not self.ai_auto.active(move.player) and not current_node.auto_undid:
ts = self.train_settings
# TODO: is this overly generous wrt low visit outdated evaluations?
evaluation = current_node.evaluation if current_node.evaluation is not None else 1 # assume move is fine if temperature is negative
move_eval = max(evaluation, current_node.outdated_evaluation or 0)
points_lost = (current_node.parent or current_node).temperature_stats[2] * (1 - move_eval)
if move_eval < ts["undo_eval_threshold"] and points_lost >= ts["undo_point_threshold"]:
if self.num_undos(current_node) == 0:
current_node.x_comment["undid"] = f"Move was below threshold, but no undo granted (probability is {ts['num_undo_prompts']:.0%}).\n"
self.update_evaluation()
else:
current_node.auto_undid = True
self.parent.game.undo()
if len(current_node.parent.children) >= ts["num_undo_prompts"] + 1:
best_move = sorted([m for m in current_node.parent.children], key=lambda m: -(m.evaluation_info[0] or 0))[0]
best_move.x_comment["undo_autoplay"] = f"Automatically played as best option after max. {ts['num_undo_prompts']} undo(s).\n"
self.parent.game.play(best_move)
self.update_evaluation()
return
# ai player doesn't technically need parent ready, but don't want to override waiting for undo
current_node = self.parent.game.current_node # this effectively checks undo didn't just happen
if self.ai_auto.active(move.opponent) and not self.parent.game.game_ended:
if current_node.children:
self.info.text = "AI paused since moves were undone. Press 'AI Move' or choose a move for the AI to continue playing."
else:
current_node.auto_undid = True
self.parent.game.undo()
if len(current_node.parent.children) >= ts["num_undo_prompts"] + 1:
best_move = sorted([m for m in current_node.parent.children], key=lambda m: -(m.evaluation_info[0] or 0))[0]
best_move.x_comment["undo_autoplay"] = f"Automatically played as best option after max. {ts['num_undo_prompts']} undo(s).\n"
self.parent.game.play(best_move)
self.update_evaluation()
return
# ai player doesn't technically need parent ready, but don't want to override waiting for undo
current_node = self.parent.game.current_node # this effectively checks undo didn't just happen
if self.ai_auto.active(move.opponent) and not self.parent.game.game_ended:
if current_node.children:
self.info.text = "AI paused since moves were undone. Press 'AI Move' or choose a move for the AI to continue playing."
else:
self._do_aimove()
self.redraw(include_board=False)
self._do_aimove()
+3
View File
@@ -3,3 +3,6 @@ from kivy.uix.boxlayout import BoxLayout
class LoadSGFPopup(BoxLayout):
pass
class NewGamePopup(BoxLayout):
pass
+21 -17
View File
@@ -172,6 +172,10 @@
active: True
color: (0.95, 0.95, 0.95)
<NewGamePopup>
Label:
text: "New Game"
<BadukPanWidget>:
size: self.parent.height, self.parent.height
engine: self.parent.controls
@@ -180,8 +184,8 @@
cols: 1
rows: 9
info: info
temperature: temperature
evaluation: evaluation
win_rate: win_rate
points_lost: points_lost
score: score
hints: hints
ownership: ownership
@@ -215,17 +219,17 @@
id: eval
text: 'all eval'
default_active: False
on_active: root.redraw()
on_active: root.parent.redraw()
BWCheckBoxHint:
size_hint: 0.2, 0.5
id: hints
text: 'hints'
on_active: root.parent.board.engine.update_evaluation()
on_active: root.parent.redraw()
BWCheckBoxHint:
size_hint: 0.2, 0.5
id: auto_undo
text: 'undo'
on_active: root.redraw()
on_active: root.parent.redraw()
BWCheckBoxHint:
size_hint: 0.166, 0.5
text: 'ai'
@@ -235,7 +239,7 @@
size_hint: 0.2, 0.5
id: ownership
text: 'show\nowner'
on_active: root.redraw()
on_active: root.parent.redraw()
GridLayout:
cols: 4
rows: 1
@@ -244,7 +248,7 @@
id: ai_move
size_hint: 0.5, 0.5
text: 'AI Move'
on_press: root.action("aimove")
on_press: root.parent("aimove")
CheckBoxHint:
size_hint: 0.166, 0.5
text: 'fast'
@@ -267,26 +271,26 @@
StyledButton:
id: undo
text: 'Undo'
on_press: root.action("undo")
on_press: root.parent("undo")
default_active: True
StyledButton:
text: 'Pass'
on_press: root.action("play",(None,None))
on_press: root.parent("play",(None,None))
CensorableLabel:
id: temperature
id: win_rate
size_hint: 1, 0.025
label: 'Temperature'
text: '...'
CensorableLabel:
id: evaluation
size_hint: 1, 0.025
label: 'Evaluation'
label: 'Win rate'
text: '...'
CensorableScoreLabel:
id: score
size_hint: 1, 0.025
label: 'Score'
text: '...'
CensorableLabel:
id: points_lost
size_hint: 1, 0.025
label: 'Point loss'
text: '...'
TextInput:
id: info
size_hint: 1, 0.2
@@ -303,7 +307,7 @@
text: 'Load'
id: load
size_hint: 0.5, 1
on_press: root.action("analyze-sgf",info.text)
on_press: root.parent("analyze-sgf",info.text)
GridLayout:
size_hint: 1, 0.05
cols: 4
+3 -1
View File
@@ -140,7 +140,8 @@ class KaTrainGui(BoxLayout):
self.game.analyze_extra(mode)
def analyze_movetree(self, root, faster=False):
self._do_init(root["SZ"], root["KM"])
self.game = Game(self, self.engine, self.config("game"), move_tree=root)
self.parent.game.root = root
handicap = root["HA"]
if handicap is not None and root["AB"] is None:
@@ -171,6 +172,7 @@ class KaTrainGui(BoxLayout):
if include_board:
Clock.schedule_once(self.board_gui.draw_board, -1) # main thread needs to do this
Clock.schedule_once(self.board_gui.draw_board_contents, -1)
self.controls.update_evaluation()
def _on_keyboard_down(self, keyboard, keycode, text, modifiers):
if keycode[1] == "up":
+9 -2
View File
@@ -3,6 +3,8 @@ import re
from collections import defaultdict
from typing import Any, Dict, List, Optional, Tuple
# TODO: handle AE ?
# https://www.red-bean.com/sgf/properties.html
class ParseError(Exception):
pass
@@ -92,7 +94,7 @@ class SGFNode:
"""Get the list of values for a property."""
return self.properties.get(property, default)
def get_first(self, property, default) -> Any:
def get_first(self, property, default=None) -> Any:
"""Get the first value of the property, typically when exactly one is expected."""
return self.properties.get(property, [default])[0]
@@ -121,6 +123,7 @@ class SGFNode:
self._depth = self.parent.depth + 1
return self._depth
# root properties available on any node
@property
def board_size(self) -> int:
return int(self.root.get_first("SZ", 19))
@@ -129,6 +132,10 @@ class SGFNode:
def komi(self) -> float:
return float(self.root.get_first("KM", 6.5))
@property
def ruleset(self) -> str:
return self.root.get_first("RU")
@property
def moves(self) -> List[Move]:
"""Returns all moves in the node."""
@@ -201,7 +208,7 @@ class SGF:
if match:
encoding = match[1].decode("ascii")
else:
encoding = "utf-8" # default
encoding = "ISO-8859-1" # default
decoded = bin_contents.decode(encoding=encoding)
return SGF.parse(decoded)