diff --git a/docs/qe_apidoc.py b/docs/qe_apidoc.py index 520d962ed..4923ce60a 100644 --- a/docs/qe_apidoc.py +++ b/docs/qe_apidoc.py @@ -20,7 +20,7 @@ Examples -------- $ python qe_apidoc.py # generates the two separate directories -$ python qe_apidoc.py foo_bar # generates the two separate directories +$ python qe_apidoc.py foo_bar # generates the two separate directories $ python qe_apidoc.py single # generates the single directory @@ -49,6 +49,15 @@ :show-inheritance: """ +game_theory_module_template = """{mod_name} +{equals} + +.. automodule:: quantecon.game_theory.{mod_name} + :members: + :undoc-members: + :show-inheritance: +""" + markov_module_template = """{mod_name} {equals} @@ -101,10 +110,10 @@ ======================= The `quantecon` python library consists of a number of modules which -includes economic models (models), markov chains (markov), random -generation utilities (random), a collection of tools (tools), -and other utilities (util) which are -mainly used by developers internal to the package. +includes economic models (models), markov chains (markov), random +generation utilities (random), a collection of tools (tools), +and other utilities (util) which are +mainly used by developers internal to the package. The models section, for example, contains implementations of standard models, many of which are discussed in lectures on the website `quant- @@ -113,6 +122,7 @@ .. toctree:: :maxdepth: 2 + game_theory markov random tools @@ -173,6 +183,12 @@ def all_auto(): def model_tool(): + # list file names with game_theory + game_theory_files = glob("../quantecon/game_theory/[a-z0-9]*.py") + game_theory = list(map(lambda x: x.split('/')[-1][:-3], game_theory_files)) + # Alphabetize + game_theory.sort() + # list file names with markov markov_files = glob("../quantecon/markov/[a-z0-9]*.py") markov = list(map(lambda x: x.split('/')[-1][:-3], markov_files)) @@ -191,23 +207,30 @@ def model_tool(): # Alphabetize tools.remove("version") tools.sort() - + # list file names of utilities util_files = glob("../quantecon/util/[a-z0-9]*.py") util = list(map(lambda x: x.split('/')[-1][:-3], util_files)) # Alphabetize util.sort() - for folder in ["markov","random","tools","util"]: + for folder in ["game_theory", "markov", "random", "tools", "util"]: if not os.path.exists(source_join(folder)): os.makedirs(source_join(folder)) + # Write file for each game_theory file + for mod in game_theory: + new_path = os.path.join("source", "game_theory", mod + ".rst") + with open(new_path, "w") as f: + equals = "=" * len(mod) + f.write(game_theory_module_template.format(mod_name=mod, equals=equals)) + # Write file for each markov file for mod in markov: new_path = os.path.join("source", "markov", mod + ".rst") with open(new_path, "w") as f: equals = "=" * len(mod) - f.write(markov_module_template.format(mod_name=mod, equals=equals)) + f.write(markov_module_template.format(mod_name=mod, equals=equals)) # Write file for each random file for mod in random: @@ -234,18 +257,20 @@ def model_tool(): with open(source_join("index.rst"), "w") as index: index.write(split_index_template) + gt = "game_theory/" + "\n game_theory/".join(game_theory) mark = "markov/" + "\n markov/".join(markov) rand = "random/" + "\n random/".join(random) tlz = "tools/" + "\n tools/".join(tools) utls = "util/" + "\n util/".join(util) #-TocTree-# - toc_tree_list = {"markov":mark, + toc_tree_list = {"game_theory": gt, + "markov": mark, "tools": tlz, - "random":rand, - "util":utls, + "random": rand, + "util": utls, } - for f_name in ("markov","random","tools","util"): + for f_name in ("game_theory", "markov", "random", "tools", "util"): with open(source_join(f_name + ".rst"), "w") as f: temp = split_file_template.format(name=f_name.capitalize(), equals="="*len(f_name), diff --git a/docs/source/game_theory.rst b/docs/source/game_theory.rst new file mode 100644 index 000000000..dbd3f2541 --- /dev/null +++ b/docs/source/game_theory.rst @@ -0,0 +1,7 @@ +Game_theory +=========== + +.. toctree:: + :maxdepth: 2 + + game_theory/normal_form_game diff --git a/docs/source/game_theory/normal_form_game.rst b/docs/source/game_theory/normal_form_game.rst new file mode 100644 index 000000000..6f8d426f3 --- /dev/null +++ b/docs/source/game_theory/normal_form_game.rst @@ -0,0 +1,7 @@ +normal_form_game +================ + +.. automodule:: quantecon.game_theory.normal_form_game + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/index.rst b/docs/source/index.rst index d41452b68..6a9e03446 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -3,10 +3,10 @@ QuantEcon documentation ======================= The `quantecon` python library consists of a number of modules which -includes economic models (models), markov chains (markov), random -generation utilities (random), a collection of tools (tools), -and other utilities (util) which are -mainly used by developers internal to the package. +includes economic models (models), markov chains (markov), random +generation utilities (random), a collection of tools (tools), +and other utilities (util) which are +mainly used by developers internal to the package. The models section, for example, contains implementations of standard models, many of which are discussed in lectures on the website `quant- @@ -15,6 +15,7 @@ econ.net `_. .. toctree:: :maxdepth: 2 + game_theory markov random tools diff --git a/quantecon/__init__.py b/quantecon/__init__.py index 8394e936b..8c71c3fe9 100644 --- a/quantecon/__init__.py +++ b/quantecon/__init__.py @@ -9,6 +9,7 @@ #-Modules-# from . import distributions +from . import game_theory from . import quad from . import random @@ -17,6 +18,7 @@ from .discrete_rv import DiscreteRV from .ecdf import ECDF from .estspec import smooth, periodogram, ar_periodogram +# from .game_theory import #Place Holder if we wish to promote any general objects to the qe namespace. from .graph_tools import DiGraph from .gridtools import cartesian, mlinspace from .kalman import Kalman @@ -28,8 +30,8 @@ from .matrix_eqn import solve_discrete_lyapunov, solve_discrete_riccati from .quadsums import var_quadratic_sum, m_quadratic_sum #->Propose Delete From Top Level -from .markov import MarkovChain, random_markov_chain, random_stochastic_matrix, gth_solve, tauchen #Promote to keep current examples working -from .markov import mc_compute_stationary, mc_sample_path #Imports that Should be Deprecated with markov package +from .markov import MarkovChain, random_markov_chain, random_stochastic_matrix, gth_solve, tauchen #Promote to keep current examples working +from .markov import mc_compute_stationary, mc_sample_path #Imports that Should be Deprecated with markov package #<- from .rank_nullspace import rank_est, nullspace from .robustlq import RBLQ diff --git a/quantecon/game_theory/__init__.py b/quantecon/game_theory/__init__.py new file mode 100644 index 000000000..b7beb74cf --- /dev/null +++ b/quantecon/game_theory/__init__.py @@ -0,0 +1,6 @@ +""" +Game Theory SubPackage + +""" +from .normal_form_game import Player, NormalFormGame +from .normal_form_game import pure2mixed, best_response_2p diff --git a/quantecon/game_theory/normal_form_game.py b/quantecon/game_theory/normal_form_game.py new file mode 100644 index 000000000..2619bffa3 --- /dev/null +++ b/quantecon/game_theory/normal_form_game.py @@ -0,0 +1,671 @@ +r""" +Authors: Tomohiro Kusano, Daisuke Oyama + +Tools for normal form games. + +Definitions and Basic Concepts +------------------------------ + +An :math:`N`-player *normal form game* :math:`g = (I, (A_i)_{i \in I}, +(u_i)_{i \in I})` consists of + +- the set of *players* :math:`I = \{0, \ldots, N-1\}`, +- the set of *actions* :math:`A_i = \{0, \ldots, n_i-1\}` for each + player :math:`i \in I`, and +- the *payoff function* :math:`u_i \colon A_i \times A_{i+1} \times + \cdots \times A_{i+N-1} \to \mathbb{R}` for each player :math:`i \in + I`, + +where :math:`i+j` is understood modulo :math:`N`. Note that we adopt the +convention that the 0-th argument of the payoff function :math:`u_i` is +player :math:`i`'s own action and the :math:`j`-th argument is player +(:math:`i+j`)'s action (modulo :math:`N`). A mixed action for player +:math:`i` is a probability distribution on :math:`A_i` (while an element +of :math:`A_i` is referred to as a pure action). A pure action +:math:`a_i \in A_i` is identified with the mixed action that assigns +probability one to :math:`a_i`. Denote the set of mixed actions of +player :math:`i` by :math:`X_i`. We also denote :math:`A_{-i} = A_{i+1} +\times \cdots \times A_{i+N-1}` and :math:`X_{-i} = X_{i+1} \times +\cdots \times X_{i+N-1}`. + +The (pure-action) *best response correspondence* :math:`b_i \colon +X_{-i} \to A_i` for each player :math:`i` is defined by + +.. math:: + + b_i(x_{-i}) = \{a_i \in A_i \mid + u_i(a_i, x_{-i}) \geq u_i(a_i', x_{-i}) + \ \forall\,a_i' \in A_i\}, + +where :math:`u_i(a_i, x_{-i}) = \sum_{a_{-i} \in A_{-i}} u_i(a_i, +a_{-i}) \prod_{j=1}^{N-1} x_{i+j}(a_j)` is the expected payoff to action +:math:`a_i` against mixed actions :math:`x_{-i}`. A profile of mixed +actions :math:`x^* \in X_0 \times \cdots \times X_{N-1}` is a *Nash +equilibrium* if for all :math:`i \in I` and :math:`a_i \in A_i`, + +.. math:: + + x_i^*(a_i) > 0 \Rightarrow a_i \in b_i(x_{-i}^*), + +or equivalently, :math:`x_i^* \cdot v_i(x_{-i}^*) \geq x_i \cdot +v_i(x_{-i}^*)` for all :math:`x_i \in X_i`, where :math:`v_i(x_{-i})` is +the vector of player :math:`i`'s payoffs when the opponent players play +mixed actions :math:`x_{-i}`. + +Creating a NormalFormGame +------------------------- + +There are three ways to construct a `NormalFormGame` instance. + +The first is to pass an array of payoffs for all the players: + +>>> matching_pennies_bimatrix = [[(1, -1), (-1, 1)], [(-1, 1), (1, -1)]] +>>> g = NormalFormGame(matching_pennies_bimatrix) +>>> print(g.players[0]) +Player in a 2-player normal form game with payoff array: +[[ 1, -1], + [-1, 1]] +>>> print(g.players[1]) +Player in a 2-player normal form game with payoff array: +[[-1, 1], + [ 1, -1]] + +If a square matrix (2-dimensional array) is given, then it is considered +to be a symmetric two-player game: + +>>> coordination_game_matrix = [[4, 0], [3, 2]] +>>> g = NormalFormGame(coordination_game_matrix) +>>> print(g) +2-player NormalFormGame with payoff profile array: +[[[4, 4], [0, 3]], + [[3, 0], [2, 2]]] + +The second is to specify the sizes of the action sets of the players, +which gives a `NormalFormGame` instance filled with payoff zeros, and +then set the payoff values to each entry: + +>>> g = NormalFormGame((2, 2)) +>>> print(g) +2-player NormalFormGame with payoff profile array: +[[[ 0., 0.], [ 0., 0.]], + [[ 0., 0.], [ 0., 0.]]] +>>> g[0, 0] = 1, 1 +>>> g[0, 1] = -2, 3 +>>> g[1, 0] = 3, -2 +>>> print(g) +2-player NormalFormGame with payoff profile array: +[[[ 1., 1.], [-2., 3.]], + [[ 3., -2.], [ 0., 0.]]] + +The third is to pass an array of `Player` instances, as explained in the +next section. + +Creating a Player +----------------- + +A `Player` instance is created by passing a payoff array: + +>>> player0 = Player([[3, 1], [0, 2]]) +>>> player0.payoff_array +array([[3, 1], + [0, 2]]) + +Passing an array of `Player` instances is the third way to create a +`NormalFormGame` instance. + +>>> player1 = Player([[2, 0], [1, 3]]) +>>> player1.payoff_array +array([[2, 0], + [1, 3]]) +>>> g = NormalFormGame((player0, player1)) +>>> print(g) +2-player NormalFormGame with payoff profile array: +[[[3, 2], [1, 1]], + [[0, 0], [2, 3]]] + +Beware that in `payoff_array[h, k]`, `h` refers to the player's own +action, while `k` refers to the opponent player's action. + +""" +import re +import numbers +import numpy as np +from numba import jit + +from ..util import check_random_state + + +class Player(object): + """ + Class representing a player in an N-player normal form game. + + Parameters + ---------- + payoff_array : array_like(float) + Array representing the player's payoff function, where + `payoff_array[a_0, a_1, ..., a_{N-1}]` is the payoff to the + player when the player plays action `a_0` while his N-1 + opponents play actions `a_1`, ..., `a_{N-1}`, respectively. + + Attributes + ---------- + payoff_array : ndarray(float, ndim=N) + See Parameters. + + num_actions : scalar(int) + The number of actions available to the player. + + num_opponents : scalar(int) + The number of opponent players. + + """ + def __init__(self, payoff_array): + self.payoff_array = np.asarray(payoff_array) + + if self.payoff_array.ndim == 0: + raise ValueError('payoff_array must be an array_like') + + self.num_opponents = self.payoff_array.ndim - 1 + self.num_actions = self.payoff_array.shape[0] + + self.tol = 1e-8 + + def __repr__(self): + N = self.num_opponents + 1 + s = 'Player in a {N}-player normal form game'.format(N=N) + return s + + def __str__(self): + s = self.__repr__() + s += ' with payoff array:\n' + s += np.array2string(self.payoff_array, separator=', ') + return s + + def payoff_vector(self, opponents_actions): + """ + Return an array of payoff values, one for each own action, given + a profile of the opponents' actions. + + Parameters + ---------- + opponents_actions : see `best_response`. + + Returns + ------- + payoff_vector : ndarray(float, ndim=1) + An array representing the player's payoff vector given the + profile of the opponents' actions. + + """ + def reduce_last_player(payoff_array, action): + """ + Given `payoff_array` with ndim=M, return the payoff array + with ndim=M-1 fixing the last player's action to be `action`. + + """ + if isinstance(action, numbers.Integral): # pure action + return payoff_array.take(action, axis=-1) + else: # mixed action + return payoff_array.dot(action) + + if self.num_opponents == 1: + payoff_vector = \ + reduce_last_player(self.payoff_array, opponents_actions) + elif self.num_opponents >= 2: + payoff_vector = self.payoff_array + for i in reversed(range(self.num_opponents)): + payoff_vector = \ + reduce_last_player(payoff_vector, opponents_actions[i]) + else: # Trivial case with self.num_opponents == 0 + payoff_vector = self.payoff_array + + return payoff_vector + + def is_best_response(self, own_action, opponents_actions): + """ + Return True if `own_action` is a best response to + `opponents_actions`. + + Parameters + ---------- + own_action : scalar(int) or array_like(float, ndim=1) + An integer representing a pure action, or an array of floats + representing a mixed action. + + opponents_actions : see `best_response` + + Returns + ------- + bool + True if `own_action` is a best response to + `opponents_actions`; False otherwise. + + """ + payoff_vector = self.payoff_vector(opponents_actions) + payoff_max = payoff_vector.max() + + if isinstance(own_action, numbers.Integral): + return payoff_vector[own_action] >= payoff_max - self.tol + else: + return np.dot(own_action, payoff_vector) >= payoff_max - self.tol + + def best_response(self, opponents_actions, tie_breaking='smallest', + payoff_perturbation=None, random_state=None): + """ + Return the best response action(s) to `opponents_actions`. + + Parameters + ---------- + opponents_actions : array_like(int or array_like(float)) or + array_like(int, ndim=1) or scalar(int) + A profile of N-1 opponents' actions. If N=2, then it must be + a 1-dimensional array of floats (in which case it is treated + as the opponent's mixed action) or a scalar of integer (in + which case it is treated as the opponent's pure action). If + N>2, then it must be an array of N-1 objects, where each + object must be an integer (pure action) or an array of + floats (mixed action). + + tie_breaking : {'smallest', 'random', False}, + optional(default='smallest') + Control how, or whether, to break a tie (see Returns for + details). + + payoff_perturbation : array_like(float), optional(default=None) + Array of length equal to the number of actions of the player + containing the values ("noises") to be added to the payoffs + in determining the best response. + + random_state : scalar(int) or np.random.RandomState, + optional(default=None) + Random seed (integer) or np.random.RandomState instance to + set the initial state of the random number generator for + reproducibility. If None, a randomly initialized RandomState + is used. Relevant only when tie_breaking='random'. + + Returns + ------- + scalar(int) or ndarray(int, ndim=1) + If tie_breaking=False, returns an array containing all the + best response pure actions. If tie_breaking='smallest', + returns the best response action with the smallest index; if + tie_breaking='random', returns an action randomly chosen + from the best response actions. + + """ + payoff_vector = self.payoff_vector(opponents_actions) + if payoff_perturbation is not None: + try: + payoff_vector += payoff_perturbation + except TypeError: # type mismatch + payoff_vector = payoff_vector + payoff_perturbation + + if tie_breaking == 'smallest': + best_response = np.argmax(payoff_vector) + return best_response + else: + best_responses = \ + np.where(payoff_vector >= payoff_vector.max() - self.tol)[0] + if tie_breaking == 'random': + return self.random_choice(best_responses, + random_state=random_state) + elif tie_breaking is False: + return best_responses + else: + msg = "tie_breaking must be one of 'smallest', 'random' " + \ + "or False" + raise ValueError(msg) + + def random_choice(self, actions=None, random_state=None): + """ + Return a pure action chosen randomly from `actions`. + + Parameters + ---------- + actions : array_like(int), optional(default=None) + An array of integers representing pure actions. + + random_state : scalar(int) or np.random.RandomState, + optional(default=None) + Random seed (integer) or np.random.RandomState instance to + set the initial state of the random number generator for + reproducibility. If None, a randomly initialized RandomState + is used. + + Returns + ------- + scalar(int) + If `actions` is given, returns an integer representing a + pure action chosen randomly from `actions`; if not, an + action is chosen randomly from the player's all actions. + + """ + random_state = check_random_state(random_state) + + if actions is not None: + n = len(actions) + else: + n = self.num_actions + + if n == 1: + idx = 0 + else: + idx = random_state.randint(n) + + if actions is not None: + return actions[idx] + else: + return idx + + +class NormalFormGame(object): + """ + Class representing an N-player normal form game. + + Parameters + ---------- + data : array_like(Player) or array_like(int, ndim=1) or + array_like(float, ndim=2 or N+1) + Data to initialize a NormalFormGame. `data` may be an array of + Players, in which case the shapes of the Players' payoff arrays + must be consistent. If `data` is an array of N integers, then + these integers are treated as the numbers of actions of the N + players and a NormalFormGame is created consisting of payoffs + all 0 with `data[i]` actions for each player `i`. `data` may + also be an (N+1)-dimensional array representing payoff profiles. + If `data` is a square matrix (2-dimensional array), then the + game will be a symmetric two-player game where the payoff matrix + of each player is given by the input matrix. + + Attributes + ---------- + players : tuple(Player) + Tuple of the Player instances of the game. + + N : scalar(int) + The number of players. + + nums_actions : tuple(int) + Tuple of the numbers of actions, one for each player. + + """ + def __init__(self, data): + # data represents an array_like of Players + if hasattr(data, '__getitem__') and isinstance(data[0], Player): + N = len(data) + + # Check that the shapes of the payoff arrays are consistent + shape_0 = data[0].payoff_array.shape + for i in range(1, N): + shape = data[i].payoff_array.shape + if not ( + len(shape) == N and + shape == shape_0[i:] + shape_0[:i] + ): + raise ValueError( + 'shapes of payoff arrays must be consistent' + ) + + self.players = tuple(data) + + # data represents action sizes or a payoff array + else: + data = np.asarray(data) + + if data.ndim == 0: # data represents action size + # Trivial game consisting of one player + N = 1 + self.players = (Player(np.zeros(data)),) + + elif data.ndim == 1: # data represents action sizes + N = data.size + # N instances of Player created + # with payoff_arrays filled with zeros + # Payoff values set via __setitem__ + self.players = tuple( + Player(np.zeros(tuple(data[i:]) + tuple(data[:i]))) + for i in range(N) + ) + + elif data.ndim == 2 and data.shape[1] >= 2: + # data represents a payoff array for symmetric two-player game + # Number of actions must be >= 2 + if data.shape[0] != data.shape[1]: + raise ValueError( + 'symmetric two-player game must be represented ' + + 'by a square matrix' + ) + N = 2 + self.players = tuple(Player(data) for i in range(N)) + + else: # data represents a payoff array + # data must be of shape (n_0, ..., n_{N-1}, N), + # where n_i is the number of actions available to player i, + # and the last axis contains the payoff profile + N = data.ndim - 1 + if data.shape[-1] != N: + raise ValueError( + 'size of innermost array must be equal to ' + + 'the number of players' + ) + self.players = tuple( + Player( + data.take(i, axis=-1).transpose(list(range(i, N)) + + list(range(i))) + ) for i in range(N) + ) + + self.N = N # Number of players + self.nums_actions = tuple( + player.num_actions for player in self.players + ) + + @property + def payoff_profile_array(self): + N = self.N + dtype = \ + np.result_type(*(player.payoff_array for player in self.players)) + payoff_profile_array = \ + np.empty(self.players[0].payoff_array.shape + (N,), dtype=dtype) + for i, player in enumerate(self.players): + payoff_profile_array[..., i] = \ + player.payoff_array.transpose(list(range(N-i, N)) + + list(range(N-i))) + return payoff_profile_array + + def __repr__(self): + s = '{N}-player NormalFormGame'.format(N=self.N) + return s + + def __str__(self): + s = self.__repr__() + s += ' with payoff profile array:\n' + s += _payoff_profile_array2string(self.payoff_profile_array) + return s + + def __getitem__(self, action_profile): + if self.N == 1: # Trivial game with 1 player + if not isinstance(action_profile, numbers.Integral): + raise TypeError('index must be an integer') + return self.players[0].payoff_array[action_profile] + + # Non-trivial game with 2 or more players + try: + if len(action_profile) != self.N: + raise IndexError('index must be of length {0}'.format(self.N)) + except TypeError: + raise TypeError('index must be a tuple') + + payoff_profile = [ + player.payoff_array[ + tuple(action_profile[i:]) + tuple(action_profile[:i]) + ] + for i, player in enumerate(self.players) + ] + + return payoff_profile + + def __setitem__(self, action_profile, payoff_profile): + if self.N == 1: # Trivial game with 1 player + if not isinstance(action_profile, numbers.Integral): + raise TypeError('index must be an integer') + self.players[0].payoff_array[action_profile] = payoff_profile + return None + + # Non-trivial game with 2 or more players + try: + if len(action_profile) != self.N: + raise IndexError('index must be of length {0}'.format(self.N)) + except TypeError: + raise TypeError('index must be a tuple') + + try: + if len(payoff_profile) != self.N: + raise ValueError( + 'value must be an array_like of length {0}'.format(self.N) + ) + except TypeError: + raise TypeError('value must be a tuple') + + for i, player in enumerate(self.players): + player.payoff_array[ + tuple(action_profile[i:]) + tuple(action_profile[:i]) + ] = payoff_profile[i] + + def is_nash(self, action_profile): + """ + Return True if `action_profile` is a Nash equilibrium. + + Parameters + ---------- + action_profile : array_like(int or array_like(float)) + An array of N objects, where each object must be an integer + (pure action) or an array of floats (mixed action). + + Returns + ------- + bool + True if `action_profile` is a Nash equilibrium; False + otherwise. + + """ + if self.N == 2: + for i, player in enumerate(self.players): + own_action, opponent_action = \ + action_profile[i], action_profile[1-i] + if not player.is_best_response(own_action, opponent_action): + return False + + elif self.N >= 3: + for i, player in enumerate(self.players): + own_action = action_profile[i] + opponents_actions = \ + tuple(action_profile[i+1:]) + tuple(action_profile[:i]) + + if not player.is_best_response(own_action, opponents_actions): + return False + + else: # Trivial case with self.N == 1 + if not self.players[0].is_best_response(action_profile[0], None): + return False + + return True + + +def _payoff_array2string(payoff_array, class_name=None): + prefix, suffix = '', '' + if class_name is not None: + prefix = class_name + '(' + suffix = ')' + s = np.array2string(payoff_array, separator=', ', prefix=prefix) + return prefix + s + suffix + + +def _payoff_profile_array2string(payoff_profile_array, class_name=None): + s = np.array2string(payoff_profile_array, separator=', ') + + # Remove one linebreak + s = re.sub(r'(\n+)', lambda x: x.group(0)[0:-1], s) + + if class_name is not None: + prefix = class_name + '(' + next_line_prefix = ' ' * len(prefix) + suffix = ')' + l = s.splitlines() + l[0] = prefix + l[0] + for i in range(1, len(l)): + if l[i]: + l[i] = next_line_prefix + l[i] + l[-1] += suffix + s = '\n'.join(l) + + return s + + +def pure2mixed(num_actions, action): + """ + Convert a pure action to the corresponding mixed action. + + Parameters + ---------- + num_actions : scalar(int) + The number of the pure actions (= the length of a mixed action). + + action : scalar(int) + The pure action to convert to the corresponding mixed action. + + Returns + ------- + ndarray(float, ndim=1) + The mixed action representation of the given pure action. + + """ + mixed_action = np.zeros(num_actions) + mixed_action[action] = 1 + return mixed_action + + +# Numba jitted functions # + +@jit(nopython=True) +def best_response_2p(payoff_matrix, opponent_mixed_action): + """ + Numba-optimized version of `Player.best_response` compilied in + nopython mode, specialized for 2-player games (where there is only + one opponent). + + Return the best response action (with the smallest index if more + than one) to `opponent_mixed_action` under `payoff_matrix`. + + Parameters + ---------- + payoff_matrix : ndarray(float, ndim=2) + Payoff matrix. + + opponent_mixed_action : ndarray(float, ndim=1) + Opponent's mixed action. Its length must be equal to + `payoff_matrix.shape[1]`. + + Return + ------ + scalar(int) + Best response action. + + """ + n, m = payoff_matrix.shape + + best_response = 0 + payoff_0 = 0 + for b in range(m): + payoff_0 += payoff_matrix[0, b] * opponent_mixed_action[b] + payoff_max = payoff_0 + + for a in range(1, n): + payoff = 0 + for b in range(m): + payoff += payoff_matrix[a, b] * opponent_mixed_action[b] + if payoff > payoff_max: + payoff_max = payoff + best_response = a + + return best_response diff --git a/quantecon/game_theory/tests/test_normal_form_game.py b/quantecon/game_theory/tests/test_normal_form_game.py new file mode 100644 index 000000000..bc0b30f45 --- /dev/null +++ b/quantecon/game_theory/tests/test_normal_form_game.py @@ -0,0 +1,375 @@ +""" +Author: Daisuke Oyama + +Tests for normal_form_game.py + +""" +from __future__ import division + +import numpy as np +from numpy.testing import assert_array_equal +from nose.tools import eq_, ok_, raises + +from quantecon.game_theory import ( + Player, NormalFormGame, pure2mixed, best_response_2p +) + + +# Player # + +class TestPlayer_1opponent: + """Test the methods of Player with one opponent player""" + + def setUp(self): + """Setup a Player instance""" + coordination_game_matrix = [[4, 0], [3, 2]] + self.player = Player(coordination_game_matrix) + + def test_best_response_against_pure(self): + eq_(self.player.best_response(1), 1) + + def test_best_response_against_mixed(self): + eq_(self.player.best_response([1/2, 1/2]), 1) + + def test_best_response_list_when_tie(self): + """best_response with tie_breaking=False""" + assert_array_equal( + sorted(self.player.best_response([2/3, 1/3], tie_breaking=False)), + sorted([0, 1]) + ) + + def test_best_response_with_random_tie_breaking(self): + """best_response with tie_breaking='random'""" + ok_(self.player.best_response([2/3, 1/3], tie_breaking='random') + in [0, 1]) + + seed = 1234 + br0 = self.player.best_response([2/3, 1/3], tie_breaking='random', + random_state=seed) + br1 = self.player.best_response([2/3, 1/3], tie_breaking='random', + random_state=seed) + eq_(br0, br1) + + def test_best_response_with_smallest_tie_breaking(self): + """best_response with tie_breaking='smallest' (default)""" + eq_(self.player.best_response([2/3, 1/3]), 0) + + def test_best_response_with_payoff_perturbation(self): + """best_response with payoff_perturbation""" + eq_(self.player.best_response([2/3, 1/3], + payoff_perturbation=[0, 0.1]), + 1) + eq_(self.player.best_response([2, 1], # int + payoff_perturbation=[0, 0.1]), + 1) + + def test_is_best_response_against_pure(self): + ok_(self.player.is_best_response(0, 0)) + + def test_is_best_response_against_mixed(self): + ok_(self.player.is_best_response([1/2, 1/2], [2/3, 1/3])) + + +class TestPlayer_2opponents: + """Test the methods of Player with two opponent players""" + + def setUp(self): + """Setup a Player instance""" + payoffs_2opponents = [[[3, 6], + [4, 2]], + [[1, 0], + [5, 7]]] + self.player = Player(payoffs_2opponents) + + def test_payoff_vector_against_pure(self): + assert_array_equal(self.player.payoff_vector((0, 1)), [6, 0]) + + def test_is_best_response_against_pure(self): + ok_(not self.player.is_best_response(0, (1, 0))) + + def test_best_response_against_pure(self): + eq_(self.player.best_response((1, 1)), 1) + + def test_best_response_list_when_tie(self): + """ + best_response against a mixed action profile with + tie_breaking=False + """ + assert_array_equal( + sorted(self.player.best_response(([3/7, 4/7], [1/2, 1/2]), + tie_breaking=False)), + sorted([0, 1]) + ) + + +def test_random_choice(): + n, m = 5, 4 + payoff_matrix = np.zeros((n, m)) + player = Player(payoff_matrix) + + eq_(player.random_choice([0]), 0) + + actions = list(range(player.num_actions)) + ok_(player.random_choice() in actions) + + +# NormalFormGame # + +class TestNormalFormGame_Sym2p: + """Test the methods of NormalFormGame with symmetric two players""" + + def setUp(self): + """Setup a NormalFormGame instance""" + coordination_game_matrix = [[4, 0], [3, 2]] + self.g = NormalFormGame(coordination_game_matrix) + + def test_getitem(self): + assert_array_equal(self.g[0, 1], [0, 3]) + + def test_is_nash_pure(self): + ok_(self.g.is_nash((0, 0))) + + def test_is_nash_mixed(self): + ok_(self.g.is_nash(([2/3, 1/3], [2/3, 1/3]))) + + +class TestNormalFormGame_Asym2p: + """Test the methods of NormalFormGame with asymmetric two players""" + + def setUp(self): + """Setup a NormalFormGame instance""" + matching_pennies_bimatrix = [[(1, -1), (-1, 1)], + [(-1, 1), (1, -1)]] + self.g = NormalFormGame(matching_pennies_bimatrix) + + def test_getitem(self): + assert_array_equal(self.g[1, 0], [-1, 1]) + + def test_is_nash_against_pure(self): + ok_(not self.g.is_nash((0, 0))) + + def test_is_nash_against_mixed(self): + ok_(self.g.is_nash(([1/2, 1/2], [1/2, 1/2]))) + + +class TestNormalFormGame_3p: + """Test the methods of NormalFormGame with three players""" + + def setUp(self): + """Setup a NormalFormGame instance""" + payoffs_2opponents = [[[3, 6], + [4, 2]], + [[1, 0], + [5, 7]]] + player = Player(payoffs_2opponents) + self.g = NormalFormGame([player for i in range(3)]) + + def test_getitem(self): + assert_array_equal(self.g[0, 0, 1], [6, 4, 1]) + + def test_is_nash_pure(self): + ok_(self.g.is_nash((0, 0, 0))) + ok_(not self.g.is_nash((0, 0, 1))) + + def test_is_nash_mixed(self): + p = (1 + np.sqrt(65)) / 16 + ok_(self.g.is_nash(([1 - p, p], [1 - p, p], [1 - p, p]))) + + +def test_normalformgame_input_action_sizes(): + g = NormalFormGame((2, 3, 4)) + + eq_(g.N, 3) # Number of players + + assert_array_equal( + g.players[0].payoff_array, + np.zeros((2, 3, 4)) + ) + assert_array_equal( + g.players[1].payoff_array, + np.zeros((3, 4, 2)) + ) + assert_array_equal( + g.players[2].payoff_array, + np.zeros((4, 2, 3)) + ) + + +def test_normalformgame_setitem(): + g = NormalFormGame((2, 2)) + g[0, 0] = (0, 10) + g[0, 1] = (0, 10) + g[1, 0] = (3, 5) + g[1, 1] = (-2, 0) + + assert_array_equal( + g.players[0].payoff_array, + [[0, 0], [3, -2]] + ) + assert_array_equal( + g.players[1].payoff_array, + [[10, 5], [10, 0]] + ) + + +def test_normalformgame_constant_payoffs(): + g = NormalFormGame((2, 2)) + + ok_(g.is_nash((0, 0))) + ok_(g.is_nash((0, 1))) + ok_(g.is_nash((1, 0))) + ok_(g.is_nash((1, 1))) + + +def test_normalformgame_payoff_profile_array(): + nums_actions = (2, 3, 4) + for N in range(1, len(nums_actions)+1): + payoff_arrays = [ + np.arange(np.prod(nums_actions[0:N])).reshape(nums_actions[i:N] + + nums_actions[0:i]) + for i in range(N) + ] + players = [Player(payoff_array) for payoff_array in payoff_arrays] + g = NormalFormGame(players) + g_new = NormalFormGame(g.payoff_profile_array) + for player_new, payoff_array in zip(g_new.players, payoff_arrays): + assert_array_equal(player_new.payoff_array, payoff_array) + + +# Trivial cases with one player # + +class TestPlayer_0opponents: + """Test for trivial Player with no opponent player""" + + def setUp(self): + """Setup a Player instance""" + payoffs = [0, 1] + self.player = Player(payoffs) + + def test_payoff_vector(self): + """Trivial player: payoff_vector""" + assert_array_equal(self.player.payoff_vector(None), [0, 1]) + + def test_is_best_response(self): + """Trivial player: is_best_response""" + ok_(self.player.is_best_response(1, None)) + + def test_best_response(self): + """Trivial player: best_response""" + eq_(self.player.best_response(None), 1) + + +class TestNormalFormGame_1p: + """Test for trivial NormalFormGame with a single player""" + + def setUp(self): + """Setup a NormalFormGame instance""" + data = [[0], [1], [1]] + self.g = NormalFormGame(data) + + def test_construction(self): + """Trivial game: construction""" + ok_(self.g.N == 1) + assert_array_equal(self.g.players[0].payoff_array, [0, 1, 1]) + + def test_getitem(self): + """Trivial game: __getitem__""" + eq_(self.g[0], 0) + + def test_is_nash_pure(self): + """Trivial game: is_nash with pure action""" + ok_(self.g.is_nash((1,))) + ok_(not self.g.is_nash((0,))) + + def test_is_nash_mixed(self): + """Trivial game: is_nash with mixed action""" + ok_(self.g.is_nash(([0, 1/2, 1/2],))) + + +def test_normalformgame_input_action_sizes_1p(): + g = NormalFormGame(2) + + eq_(g.N, 1) # Number of players + + assert_array_equal( + g.players[0].payoff_array, + np.zeros(2) + ) + + +def test_normalformgame_setitem_1p(): + g = NormalFormGame(2) + + eq_(g.N, 1) # Number of players + + g[0] = 10 # Set payoff 10 for action 0 + eq_(g.players[0].payoff_array[0], 10) + + +# Invalid inputs # + +@raises(ValueError) +def test_normalformgame_invalid_input_players_shape_inconsistent(): + p0 = Player(np.zeros((2, 3))) + p1 = Player(np.zeros((2, 3))) + g = NormalFormGame([p0, p1]) + + +@raises(ValueError) +def test_normalformgame_invalid_input_players_num_inconsistent(): + p0 = Player(np.zeros((2, 2, 2))) + p1 = Player(np.zeros((2, 2, 2))) + g = NormalFormGame([p0, p1]) + + +@raises(ValueError) +def test_normalformgame_invalid_input_nosquare_matrix(): + g = NormalFormGame(np.zeros((2, 3))) + + +@raises(ValueError) +def test_normalformgame_invalid_input_payoff_profiles(): + g = NormalFormGame(np.zeros((2, 2, 1))) + + +# Utility functions # + +def test_pure2mixed(): + num_actions = 3 + pure_action = 0 + mixed_action = [1., 0., 0.] + + assert_array_equal(pure2mixed(num_actions, pure_action), mixed_action) + + +# Numba jitted functions # + +def test_best_response_2p(): + test_case0 = { + 'payoff_array': np.array([[4, 0], [3, 2], [0, 3]]), + 'mixed_actions': + [np.array([1, 0]), np.array([0.5, 0.5]), np.array([0, 1])], + 'brs_expected': [0, 1, 2] + } + test_case1 = { + 'payoff_array': np.zeros((2, 3)), + 'mixed_actions': [np.array([1, 0, 0]), np.array([1/3, 1/3, 1/3])], + 'brs_expected': [0, 0] + } + + for test_case in [test_case0, test_case1]: + for mixed_action, br_expected in zip(test_case['mixed_actions'], + test_case['brs_expected']): + br_computed = \ + best_response_2p(test_case['payoff_array'], mixed_action) + eq_(br_computed, br_expected) + + +if __name__ == '__main__': + import sys + import nose + + argv = sys.argv[:] + argv.append('--verbose') + argv.append('--nocapture') + nose.main(argv=argv, defaultTest=__file__) diff --git a/setup.py b/setup.py index 9c8168769..60b5832fd 100644 --- a/setup.py +++ b/setup.py @@ -12,10 +12,10 @@ def write_version_py(filename=None): """ doc = "\"\"\"\nThis is a VERSION file and should NOT be manually altered\n\"\"\"" doc += "\nversion = '%s'" % VERSION - + if not filename: filename = os.path.join(os.path.dirname(__file__), 'quantecon', 'version.py') - + fl = open(filename, 'w') try: fl.write(doc) @@ -30,12 +30,12 @@ def write_version_py(filename=None): DESCRIPTION = "QuantEcon is a package to support all forms of quantitative economic modelling." #'Core package of the QuantEcon library' LONG_DESCRIPTION = """ -**QuantEcon** is an organization run by economists for economists with the aim of coordinating -distributed development of high quality open source code for all forms of quantitative economic modelling. +**QuantEcon** is an organization run by economists for economists with the aim of coordinating +distributed development of high quality open source code for all forms of quantitative economic modelling. The project website is located at `http://quantecon.org/ `_. This website provides -more information with regards to the **quantecon** library, documentation, in addition to some resources -in regards to how you can use and/or contribute to the package. +more information with regards to the **quantecon** library, documentation, in addition to some resources +in regards to how you can use and/or contribute to the package. The **quantecon** Package ------------------------- @@ -68,7 +68,7 @@ def write_version_py(filename=None): Additional Links ---------------- -1. `QuantEcon Course Website `_ +1. `QuantEcon Course Website `_ """ @@ -93,7 +93,8 @@ def write_version_py(filename=None): setup(name='quantecon', packages=['quantecon', - 'quantecon.markov', + 'quantecon.game_theory', + 'quantecon.markov', 'quantecon.random', 'quantecon.tests', 'quantecon.util',