Repository: munichpavel/clovek-ne-jezi-se

Issue:
Make experimentable
I don't have any real setup yet for running experiments to test agent performance.

Discussion:

Patch:
diff --git a/.envrc.example b/.envrc.example
index c4e08ee..9e4abed 100644
--- a/.envrc.example
+++ b/.envrc.example
@@ -1,1 +1,3 @@
-#export LOG_DIR=
\ No newline at end of file
+#export LOG_DIR=
+# For MLflow experiment tracking
+#export RUNS_PARENT_DIR=
\ No newline at end of file
diff --git a/clovek_ne_jezi_se/agents.py b/clovek_ne_jezi_se/agents.py
index 9f70ea9..e079f75 100644
--- a/clovek_ne_jezi_se/agents.py
+++ b/clovek_ne_jezi_se/agents.py
@@ -60,7 +60,7 @@ class Player:
 
     def log(self, message):
         res = ':'.join([self.__repr__(), message])
-        logger.info(res)
+        logger.debug(res)
 
 
 @attr.s
diff --git a/clovek_ne_jezi_se/client.py b/clovek_ne_jezi_se/client.py
index 25b7172..830eba7 100644
--- a/clovek_ne_jezi_se/client.py
+++ b/clovek_ne_jezi_se/client.py
@@ -1,6 +1,6 @@
 """Client module for controlling game progression"""
 import logging
-from typing import Sequence
+from typing import Sequence, Tuple
 from itertools import cycle
 from random import randint
 
@@ -40,13 +40,17 @@ class Client:
             empty_symbol=self.empty_symbol
         )
         self._game_state.initialize()
-        self._winner = None
+        self.winner = None
+        self.play_count = 0
 
-    def play(self):
+    def play(self) -> Tuple['Player', int]:
         """Play until a player wins wins"""
 
-        while(self._winner is None):
+        while(self.winner is None):
             self.take_turn()
+            self.play_count += 1
+
+        return self.winner, self.play_count
 
     def take_turn(self):
         """Take a single player turn"""
@@ -61,10 +65,10 @@ class Client:
             self._choose_and_do_move(current_player, roll_value)
 
             counts = self._get_game_state_counts()
-            logger.debug(f'\nBoard counts: {counts}')
+            self.log(current_player, f'Board counts: {counts}')
 
             if self._game_state.is_winner(current_player.name):
-                self._winner = current_player
+                self.winner = current_player
                 self.log(current_player, 'wins')
 
             players_turn_continues = (
@@ -79,13 +83,13 @@ class Client:
 
     def log(self, player, message):
         message = f'{player}:' + message
-        logger.info(message)
+        logger.debug(message)
 
     def _choose_and_do_move(self, current_player, roll_value):
         moves = self._game_state.get_player_moves(
                 roll_value, current_player.name
             )
-        logger.debug(f'Available moves: {moves}')
+        self.log(current_player, f'Available moves: {moves}')
 
         if len(moves) > 0:
             if (
@@ -99,9 +103,10 @@ class Client:
 
             for move_component in selected_move:
                 self._game_state.do(move_component)
-                logger.debug(f'\nDo move {move_component}')
+                self.log(current_player, f'Do move {move_component}')
 
-            logger.debug(
+            self.log(
+                current_player,
                 'Game state post-move.'
                 f'\nWaiting areas: {self._game_state.waiting_areas_to_dict()}'
                 f'\nMain spaces: {self._game_state.main_spaces_to_list()}'
diff --git a/clovek_ne_jezi_se/log_handler.py b/clovek_ne_jezi_se/log_handler.py
index 0a68ed7..2193eac 100644
--- a/clovek_ne_jezi_se/log_handler.py
+++ b/clovek_ne_jezi_se/log_handler.py
@@ -2,7 +2,7 @@ import sys
 import logging
 
 handler = logging.StreamHandler(sys.stdout)
-handler.setLevel(logging.INFO)
+handler.setLevel(logging.DEBUG)
 formatter = logging.Formatter(
     '%(asctime)s:%(name)s:%(levelname)s:%(message)s'
 )
diff --git a/docs/source/EXPERIMENTS.rst b/docs/source/EXPERIMENTS.rst
new file mode 100644
index 0000000..ac2c0b6
--- /dev/null
+++ b/docs/source/EXPERIMENTS.rst
@@ -0,0 +1,6 @@
+Running experiments
+===================
+
+We use `MLflow <https://mlflow.org>`_ to run, track and query experiments.
+
+For exammple usage, see ``notebooks/evaluate-agents.ipynb``.
\ No newline at end of file
diff --git a/notebooks/evaluate-agents.ipynb b/notebooks/evaluate-agents.ipynb
new file mode 100644
index 0000000..3438699
--- /dev/null
+++ b/notebooks/evaluate-agents.ipynb
@@ -0,0 +1,203 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Evaluate agent performance across runs in an experiment\n",
+    "\n",
+    "Using the [MLflow tracking API](https://www.mlflow.org/docs/latest/tracking.html)."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "%config Completer.use_jedi = False\n",
+    "import os\n",
+    "from pathlib import Path\n",
+    "\n",
+    "import mlflow\n",
+    "\n",
+    "import matplotlib.pyplot as plt\n",
+    "\n",
+    "from clovek_ne_jezi_se.agents import RandomPlayer"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Helper function\n",
+    "\n",
+    "Convert run parameter dictionary into disjuntive (\"and\") filter query string, to be used with [mlflow.search_runs](https://mlflow.org/docs/latest/python_api/mlflow.html#mlflow.search_runs).\n",
+    "\n",
+    "TODO read in from json parameter file, rather than copy pasting dict from script?"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "def dict_to_conjunctive_filter_equality_string(a_dict, tracking_type: str):\n",
+    "    \"\"\"\n",
+    "    Convert a dictionary of a given tracking type--params, metrics, tags--\n",
+    "    to a conjunctive (i.e. 'and') filter string of equality conditions.\n",
+    "    \n",
+    "    Lists are converted to comma-separated strings.\n",
+    "    \"\"\"\n",
+    "    conditions = []\n",
+    "    for key, value in a_dict.items():\n",
+    "        if isinstance(value, list):\n",
+    "            value = ','.join(value)\n",
+    "        conditions.append(tracking_type + '.' + key + '=\"' + str(value) + '\"')\n",
+    "    return \" and \".join(conditions)\n",
+    "        \n",
+    "    return (\n",
+    "        \"params.agents = 'RandomPlayer,RandomPlayer,RandomPlayer,RandomPlayer'\"\n",
+    "        \" and params.main_board_section_length = 1\"\n",
+    "    )\n",
+    "\n",
+    "def test_dict_to_conjunctive_filter_equality_string():\n",
+    "    player_names = ['red', 'blue', 'green', 'yellow']\n",
+    "    players = [RandomPlayer(name=name, print_game_state=False) for name in player_names]\n",
+    "\n",
+    "    main_board_section_length = 1\n",
+    "    agents = [player.__class__.__name__ for player in players]\n",
+    "\n",
+    "    run_params = dict(\n",
+    "        agents=','.join(agents),\n",
+    "        main_board_section_length=main_board_section_length\n",
+    "    )\n",
+    "    \n",
+    "    filter_string = dict_to_conjunctive_filter_equality_string(run_params, tracking_type='params')\n",
+    "    expected = (\n",
+    "        'params.agents=\"RandomPlayer,RandomPlayer,RandomPlayer,RandomPlayer\"'\n",
+    "        ' and params.main_board_section_length=\"1\"'\n",
+    "    )\n",
+    "    try:\n",
+    "        assert filter_string == expected\n",
+    "    except AssertionError as err:\n",
+    "        print(err, filter_string, ' is not ', expected)\n",
+    "    \n",
+    "test_dict_to_conjunctive_filter_equality_string()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Get winner counts\n",
+    "\n",
+    "Based on runs of `scripts/play.py`.\n",
+    "\n",
+    "First we define a filter query string, then use [mlflow.search_runs](https://mlflow.org/docs/latest/python_api/mlflow.html#mlflow.search_runs) to get run results as a pandas dataframe."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "metadata": {
+    "scrolled": true
+   },
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "1    31\n",
+       "0    24\n",
+       "3    24\n",
+       "2    21\n",
+       "Name: metrics.winner_idx, dtype: int64"
+      ]
+     },
+     "execution_count": 3,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "player_names = ['red', 'blue', 'green', 'yellow']\n",
+    "players = [RandomPlayer(name=name, print_game_state=False) for name in player_names]\n",
+    "\n",
+    "main_board_section_length = 1\n",
+    "pieces_per_player = 4\n",
+    "number_of_dice_faces = 6\n",
+    "agents = [player.__class__.__name__ for player in players]\n",
+    "\n",
+    "run_params = dict(\n",
+    "    agents=','.join(agents),\n",
+    "    main_board_section_length=main_board_section_length,\n",
+    "    pieces_per_player=pieces_per_player,\n",
+    "    number_of_dice_faces=number_of_dice_faces\n",
+    ")\n",
+    "filter_string = dict_to_conjunctive_filter_equality_string(run_params, 'params')\n",
+    "\n",
+    "# mlflow.search_runs seemingly must be run from parent directory of mlruns\n",
+    "os.chdir(os.environ['RUNS_PARENT_DIR'])\n",
+    "runs = mlflow.search_runs(filter_string=filter_string)\n",
+    "\n",
+    "# Remove runs with no winner\n",
+    "mask_winner_isna = runs['metrics.winner_idx'].isna()\n",
+    "runs = runs[~mask_winner_isna]\n",
+    "winner_idxes = runs['metrics.winner_idx'].astype(int)\n",
+    "\n",
+    "# Calculate winner counts\n",
+    "winner_counts = winner_idxes.value_counts()\n",
+    "names = [agents[idx] + '_' + str(idx) for idx in winner_counts.index]\n",
+    "counts = winner_counts.values.astype(int)\n",
+    "winner_counts"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "image/png": "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\n",
+      "text/plain": [
+       "<Figure size 576x216 with 1 Axes>"
+      ]
+     },
+     "metadata": {
+      "needs_background": "light"
+     },
+     "output_type": "display_data"
+    }
+   ],
+   "source": [
+    "plt.figure(figsize=(8,3))\n",
+    "plt.bar(names, counts)\n",
+    "plt.title(f'Win count per agent after {winner_counts.sum()} games')\n",
+    "plt.xticks(names, names);"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.7.6"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/requirements.txt b/requirements.txt
index 65484af..8e3cfc2 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -4,3 +4,5 @@ scipy
 attrs
 networkx
 matplotlib
+
+mlflow
\ No newline at end of file
diff --git a/scripts/play.py b/scripts/play.py
index 4f88d64..397a750 100644
--- a/scripts/play.py
+++ b/scripts/play.py
@@ -1,27 +1,47 @@
-import logging
-import os
-from datetime import datetime
-from pathlib import Path
+import mlflow
+from mlflow.tracking import MlflowClient
 
 
 from clovek_ne_jezi_se.client import Client
 from clovek_ne_jezi_se.agents import HumanPlayer, RandomPlayer
 
-log_dir = Path(os.environ['LOG_DIR'])
-file_name = datetime.now().strftime('game-%Y-%m-%d-%H:%M.%S.log')
-logging.basicConfig(filename=log_dir / file_name, level=logging.INFO)
-
+# TODO put in config file (json)
 player_names = ['red', 'blue', 'green', 'yellow']
 
 random_players = [
     RandomPlayer(name=name, print_game_state=False) for name in player_names
 ]
+players = random_players
+
+n_runs = 96
+main_board_section_length = 1
+pieces_per_player = 4
+number_of_dice_faces = 6
+agents = [player.__class__.__name__ for player in players]
 
-client = Client(
-    players=random_players,
-    main_board_section_length=1
+run_dict = dict(
+    agents=','.join(agents),
+    main_board_section_length=main_board_section_length,
+    pieces_per_player=pieces_per_player,
+    number_of_dice_faces=number_of_dice_faces
 )
-client.initialize()
-logging.info(client)
 
-client.play()
+
+for _ in range(n_runs):
+    with mlflow.start_run():
+        mlflow.log_params(run_dict)
+
+        client = Client(
+            players=players,
+            main_board_section_length=main_board_section_length,
+            pieces_per_player=pieces_per_player,
+            number_of_dice_faces=number_of_dice_faces
+        )
+        client.initialize()
+
+        winner, n_plays = client.play()
+
+        winner_idx = players.index(winner)
+        print(f'Winner of round is {winner}')
+        mlflow.log_metric('winner_idx', winner_idx)
+        mlflow.log_metric('n_plays', n_plays)
diff --git a/setup.cfg b/setup.cfg
index ecdf80e..e93ab46 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -16,6 +16,7 @@ universal = 1
 
 [flake8]
 exclude = docs
+max-line-length = 79
 
 [aliases]
 test = pytest


Test patch:
diff --git a/tests/test_client.py b/tests/test_client.py
index f04ccd2..7184b86 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -147,3 +147,54 @@ class TestClient:
         expected_home = expected_game_state.home_areas_to_dict()
 
         assert played_home == expected_home
+
+    def test_play_finished(self, monkeypatch):
+
+        played_client = deepcopy(self.client)
+        played_game_state = played_client.get_game_state()
+
+        idx_winner = 0
+        winner_name = played_game_state.player_names[idx_winner]
+
+        # move all pieces but first to home
+        for idx in range(1, played_game_state.pieces_per_player):
+            played_game_state.do(MoveContainer(
+                from_space=BoardSpace(
+                    kind='waiting', idx=idx,
+                    occupied_by=winner_name,
+                    allowed_occupants=[winner_name, EMPTY_SYMBOL]
+                ),
+                to_space=BoardSpace(
+                    kind='home', idx=idx,
+                    occupied_by=EMPTY_SYMBOL,
+                    allowed_occupants=[winner_name, EMPTY_SYMBOL]
+                )
+            ))
+
+        # Move first piece to one before home
+        winner_enter_main_idx = played_game_state\
+            .get_main_entry_index(winner_name)
+        n_players = len(played_client.players)
+        winner_prehome_idx = (winner_enter_main_idx - 1) % \
+            played_client.main_board_section_length * n_players
+
+        print(winner_prehome_idx)
+        played_game_state.do(MoveContainer(
+                from_space=BoardSpace(
+                    kind='waiting', idx=0,
+                    occupied_by=winner_name,
+                    allowed_occupants=[winner_name, EMPTY_SYMBOL]
+                ),
+                to_space=BoardSpace(
+                    kind='main', idx=winner_prehome_idx,
+                    occupied_by=EMPTY_SYMBOL,
+                    allowed_occupants=self.player_names + [EMPTY_SYMBOL]
+                )
+            ))
+
+        monkeypatch.setattr(played_client, 'roll', lambda: 1)
+        # Only one move possible, advance 1 to last open home spot
+        monkeypatch.setattr(builtins, 'input', lambda x: 0)
+
+        winner, _ = played_client.play()
+        assert winner == played_client.players[idx_winner]
diff --git a/tests/test_state.py b/tests/test_state.py
index b5fdbc8..063339f 100644
--- a/tests/test_state.py
+++ b/tests/test_state.py
@@ -82,6 +82,7 @@ class TestGameState:
         section_length=section_length
     )
     game_state.initialize()
+    main_board_length = section_length * len(player_names)
 
     @pytest.mark.parametrize(
         'player_name,expected',
@@ -102,7 +103,7 @@ class TestGameState:
         player_enter_main_indices[player_name] \
             = game_state.get_main_entry_index(player_name)
 
-    @pytest.mark.parametrize("idx", range(len(player_names) * section_length))
+    @pytest.mark.parametrize("idx", range(main_board_length))
     def test_get_main_board_space(self, idx):
         assert self.game_state.get_board_space(kind='main', idx=idx) \
             == BoardSpace(
@@ -157,47 +158,17 @@ class TestGameState:
 
     def test_main_spaces_to_list(self):
         res = self.game_state.main_spaces_to_list()
-        expected = self.game_state.section_length \
-            * len(self.game_state.player_names) \
-            * [EMPTY_SYMBOL]
+        expected = self.main_board_length * [EMPTY_SYMBOL]
 
         assert res == expected
 
     # For tests of moves from main to player home
     player_prehome_indices = {}
     for player_name in player_names:
-        player_subgraph_paramses = [
-            GraphQueryParams(
-                graph_component='node', query_type='inclusion',
-                label='allowed_occupants', value=player_name
-            ),
-            GraphQueryParams(
-                graph_component='edge', query_type='inclusion',
-                label='allowed_traversers', value=player_name
-            )
-        ]
-
-        player_subgraph = get_filtered_subgraph_view(
-            game_state._graph, player_subgraph_paramses
-        )
-        prehome_query_paramses = [
-            GraphQueryParams(
-                graph_component='node', query_type='equality',
-                label='idx', value=0
-            ),
-            GraphQueryParams(
-                graph_component='node', query_type='equality',
-                label='kind', value='home'
-            ),
-        ]
-        first_home_node_name = get_filtered_node_names(
-            player_subgraph, prehome_query_paramses
-        )[0]
-        player_prehome_node_name = next(
-            player_subgraph.predecessors(first_home_node_name)
+        prehome_idx = (
+            (player_enter_main_indices[player_name] - 1) % main_board_length
         )
-        player_prehome_indices[player_name] = \
-            player_subgraph.nodes[player_prehome_node_name]['idx']
+        player_prehome_indices[player_name] = prehome_idx
 
     # Move tests
     @pytest.mark.parametrize(