diff --git a/quantecon/game_theory/__init__.py b/quantecon/game_theory/__init__.py index 3ea5348ff..f5c781724 100644 --- a/quantecon/game_theory/__init__.py +++ b/quantecon/game_theory/__init__.py @@ -25,5 +25,6 @@ from .polymatrix_game import PolymatrixGame from .howson_lcp import polym_lcp_solver from .game_converters import ( - GAMReader, GAMWriter, from_gam, from_gam_string, from_gam_url, to_gam + GAMReader, GAMWriter, from_gam, from_gam_string, from_gam_url, to_gam, + NFGReader, NFGWriter, from_nfg, from_nfg_string, from_nfg_url, to_nfg ) diff --git a/quantecon/game_theory/game_converters.py b/quantecon/game_theory/game_converters.py index ad6ab1beb..2f2fc4b5d 100644 --- a/quantecon/game_theory/game_converters.py +++ b/quantecon/game_theory/game_converters.py @@ -2,7 +2,17 @@ Utilities for converting between representations of games. Currently supports reading and writing the GameTracer `.gam` text format -[1]_. +[1]_ and the Gambit `.nfg` text format [2]_ through the following +functions: + +from_gam, from_gam_string, from_gam_url + Read a NormalFormGame from a .gam file, string, or URL. +to_gam + Write a NormalFormGame to a .gam file, or return it as a string. +from_nfg, from_nfg_string, from_nfg_url + Read a NormalFormGame from a .nfg file, string, or URL. +to_nfg + Write a NormalFormGame to a .nfg file, or return it as a string. Examples -------- @@ -33,26 +43,51 @@ ---------- .. [1] Ben Blum, Daphne Koller, Christian Shelton, "Game Theory: GameTracer," http://dags.stanford.edu/Games/gametracer.html +.. [2] The Gambit Project, "Game representation formats," + https://gambitproject.readthedocs.io/en/latest/formats.html """ import io +import math import numbers +import re from fractions import Fraction import numpy as np from .normal_form_game import Player, NormalFormGame -class GAMPayoffVector: +_LAYOUTS = ('player-major', 'profile-major') +_LAYOUT_ERROR = "layout must be 'player-major' or 'profile-major' (got {!r})" + + +class PayoffVector: """ - Internal intermediate representation that stores payoffs in a single - flat 1-dim array. + Intermediate representation that stores the payoffs of an N-player + game in a single flat 1-dim array, in one of the two orders in which + game files list them: + + 'player-major' (as in the GameTracer .gam format) + All the payoffs to player 0, then those to player 1, ..., then + those to player N-1. Within each block, action profiles are + ordered with player 0 varying fastest, then player 1, ..., + player N-1 (i.e., column-major order). + + 'profile-major' (as in the Gambit .nfg format) + The payoffs to players 0, ..., N-1 at the first action profile, + then those at the second action profile, and so on. Action + profiles are ordered with player 0 varying fastest, then player + 1, ..., player N-1 (i.e., column-major order). - Payoff values are ordered as in the GameTracer .gam format: + Parameters + ---------- + nums_actions : array_like(int, ndim=1) + Numbers of actions, one for each player. - 1. Player-major blocks: player 0, ..., player N-1. - 2. Within each block, action profiles are ordered with player 0 - varying fastest, then player 1, ..., player N-1 (i.e., - Fortran/column-major order). + payoffs : array_like(ndim=1) + Payoffs, of length prod(nums_actions) * N, in the order `layout`. + + layout : {'player-major', 'profile-major'} + Order in which `payoffs` lists the payoffs. Attributes ---------- @@ -63,10 +98,13 @@ class GAMPayoffVector: Tuple of the numbers of actions, one for each player. payoffs : ndarray(ndim=1) - Array storing payoffs in .gam order. + Array storing the payoffs in the order `layout`. + + layout : str + Order in which `payoffs` lists the payoffs. """ - def __init__(self, nums_actions, payoffs): + def __init__(self, nums_actions, payoffs, *, layout): nums_actions = tuple(nums_actions) if len(nums_actions) == 0: raise ValueError('nums_actions must be a non-empty iterable ' + @@ -81,11 +119,15 @@ def __init__(self, nums_actions, payoffs): self.nums_actions = tuple(int(n) for n in nums_actions) self.N = len(self.nums_actions) + if layout not in _LAYOUTS: + raise ValueError(_LAYOUT_ERROR.format(layout)) + self.layout = layout + payoffs = np.ascontiguousarray(payoffs) if payoffs.ndim != 1: raise ValueError('payoffs must be a 1-dim array_like') - expected = np.prod(self.nums_actions) * self.N + expected = math.prod(self.nums_actions) * self.N # no overflow if payoffs.size != expected: raise ValueError( f'payoffs length mismatch: expected {expected}, ' + @@ -94,24 +136,38 @@ def __init__(self, nums_actions, payoffs): self.payoffs = payoffs + def _player_block(self, i): + # The payoffs to player i as an array indexed by the action + # profile; a view of `payoffs`. This is the only place where the + # layout matters. + if self.layout == 'player-major': + shape = self.nums_actions + (self.N,) + return self.payoffs.reshape(shape, order='F')[..., i] + else: + shape = (self.N,) + self.nums_actions + return self.payoffs.reshape(shape, order='F')[i, ...] + @classmethod - def from_nfg(cls, g, dtype=None): + def from_normal_form_game(cls, g, *, layout, dtype=None): """ - Construct a GAMPayoffVector from a NormalFormGame `g`. + Construct a PayoffVector from a NormalFormGame `g`. Parameters ---------- g : NormalFormGame NormalFormGame instance. + layout : {'player-major', 'profile-major'} + Order in which the payoffs are stored. + dtype : data-type, optional(default=None) Data type of the payoff array. If None, default to the `dtype` attribute of `g`. Returns ------- - GAMPayoffVector - The GAMPayoffVector representation of `g`. + PayoffVector + The PayoffVector representation of `g`. Examples -------- @@ -123,28 +179,66 @@ def from_nfg(cls, g, dtype=None): [[[ 0, 6], [ 3, 9]], [[ 1, 7], [ 4, 10]], [[ 2, 8], [ 5, 11]]] - >>> p = GAMPayoffVector.from_nfg(g) + >>> p = PayoffVector.from_normal_form_game(g, layout='player-major') >>> p.payoffs array([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) + >>> p = PayoffVector.from_normal_form_game(g, layout='profile-major') + >>> p.payoffs + array([ 0, 6, 1, 7, 2, 8, 3, 9, 4, 10, 5, 11]) """ N = g.N - nums_actions = g.nums_actions if dtype is None: dtype = g.dtype - na = np.prod(nums_actions) - payoffs = np.empty(na*N, dtype=dtype) + payoffs = np.empty(math.prod(g.nums_actions) * N, dtype=dtype) + p = cls(g.nums_actions, payoffs, layout=layout) for i, player in enumerate(g.players): - payoffs[na*i:na*(i+1)].reshape(nums_actions, order='F')[:] = \ - player.payoff_array.transpose( - (*range(N-i, g.N), *range(N-i)) - ) + p._player_block(i)[...] = player.payoff_array.transpose( + (*range(N-i, N), *range(N-i)) + ) + + return p + + def to_layout(self, layout, dtype=None): + """ + Return a new PayoffVector with the payoffs in the order `layout`. + The payoffs are copied. + + Parameters + ---------- + layout : {'player-major', 'profile-major'} + Order in which the payoffs are stored. + + dtype : data-type, optional(default=None) + Data type of the payoff array. If None, default to the data + type of the `payoffs` attribute. + + Returns + ------- + PayoffVector + The PayoffVector with the payoffs in the order `layout`. + + Examples + -------- + >>> p = PayoffVector((3, 2), np.arange(12), layout='player-major') + >>> p.to_layout('profile-major').payoffs + array([ 0, 6, 1, 7, 2, 8, 3, 9, 4, 10, 5, 11]) + + """ + if dtype is None: + dtype = self.payoffs.dtype + + payoffs = np.empty(self.payoffs.size, dtype=dtype) + p = type(self)(self.nums_actions, payoffs, layout=layout) + + for i in range(self.N): + p._player_block(i)[...] = self._player_block(i) - return cls(nums_actions, payoffs) + return p - def to_nfg(self, dtype=None): + def to_normal_form_game(self, dtype=None): """ Construct a NormalFormGame from self. @@ -163,26 +257,26 @@ def to_nfg(self, dtype=None): -------- >>> nums_actions = (3, 2) >>> payoffs = np.arange(12) - >>> p = GAMPayoffVector(nums_actions, payoffs) - >>> g = p.to_nfg() - >>> print(g) + >>> p = PayoffVector(nums_actions, payoffs, layout='player-major') + >>> print(p.to_normal_form_game()) 2-player NormalFormGame with payoff profile array: [[[ 0, 6], [ 3, 9]], [[ 1, 7], [ 4, 10]], [[ 2, 8], [ 5, 11]]] + >>> p = PayoffVector(nums_actions, payoffs, layout='profile-major') + >>> print(p.to_normal_form_game()) + 2-player NormalFormGame with payoff profile array: + [[[ 0, 1], [ 6, 7]], + [[ 2, 3], [ 8, 9]], + [[ 4, 5], [10, 11]]] """ N = self.N - nums_actions = self.nums_actions - - na = np.prod(nums_actions) - payoffs2d = self.payoffs.reshape((na, N), order='F') players = tuple( Player( - np.asarray( - payoffs2d[:, i].reshape(nums_actions, order='F').transpose( - (*range(i, N), *range(i)) - ), dtype=dtype, order='C' + np.array( # always a copy: no aliasing with `payoffs` + self._player_block(i).transpose((*range(i, N), *range(i))), + dtype=dtype, order='C' ) ) for i in range(N) ) @@ -220,25 +314,26 @@ def _str2num(s): return float(s) -class GAMReader: +class _Reader: """ - Parser for the GameTracer .gam format. + Base class of the parsers. `from_file`, `from_url`, and `from_string` + read the text and pass it to `_parse`, defined by each format. """ @classmethod def from_file(cls, file_path): """ - Read from a .gam format file. + Read from a file in the format of this reader. Parameters ---------- file_path : str - Path to the .gam file. + Path to the file. Returns ------- NormalFormGame - The game described by the .gam file. + The game described by the file. """ with open(file_path, 'r') as f: @@ -253,12 +348,12 @@ def from_url(cls, url): Parameters ---------- url : str - String containing a URL of the .gam file. + String containing a URL of the file. Returns ------- NormalFormGame - The game described by the .gam file. + The game described by the file. """ import urllib.request @@ -269,21 +364,92 @@ def from_url(cls, url): @classmethod def from_string(cls, string): """ - Read from a .gam format string. + Read from a string in the format of this reader. Parameters ---------- string : str - String in .gam format. + String in the format. Returns ------- NormalFormGame - The game described by the .gam string. + The game described by the string. """ return cls._parse(string) + +class _Writer: + """ + Base class of the serializers. `to_file` and `to_string` write the + text returned by `_dump`, defined by each format. + + """ + @classmethod + def to_file(cls, g, file_path): + """ + Write `g` to a file in the format of this writer. + + Parameters + ---------- + g : NormalFormGame + NormalFormGame instance to write. + + file_path : str + Path to the file to write to. + + """ + with open(file_path, 'w') as f: + f.write(cls._dump(g) + '\n') + + @classmethod + def to_string(cls, g): + """ + Return the string representation of `g` in the format of this + writer. + + Parameters + ---------- + g : NormalFormGame + NormalFormGame instance to convert. + + Returns + ------- + str + The string representation of `g`. + + """ + return cls._dump(g) + + +def _format_payoffs(payoffs): + """ + Return the 1-dim array `payoffs` as a string of space-separated + numbers. + + """ + if payoffs.dtype == np.bool_: + # Written as 0 and 1, not True and False + payoffs = payoffs.astype(int) + + if np.issubdtype(payoffs.dtype, np.floating): + # Shortest representation that round-trips, without exponent + def fmt(x): + return np.format_float_positional(x, trim='.') + else: + fmt = str + + return ' '.join(map(fmt, payoffs)) + + +# GameTracer .gam # + +class GAMReader(_Reader): + """ + Parser for the GameTracer .gam format. + + """ @staticmethod def _parse(string): tokens = string.split() @@ -318,76 +484,124 @@ def _parse(string): # payoffs payoffs = np.array([_str2num(tok) for tok in tokens[pos:]]) - p = GAMPayoffVector(nums_actions, payoffs) - return p.to_nfg() + p = PayoffVector(nums_actions, payoffs, layout='player-major') + return p.to_normal_form_game() -class GAMWriter: +class GAMWriter(_Writer): """ Serializer for the GameTracer .gam format. """ - @classmethod - def to_file(cls, g, file_path): - """ - Write `g` to a file in GameTracer .gam format. + @staticmethod + def _dump(g): + p = PayoffVector.from_normal_form_game(g, layout='player-major') - Parameters - ---------- - g : NormalFormGame - NormalFormGame instance to write. + buf = io.StringIO() - file_path : str - Path to the file to write to. + buf.write(str(p.N)) + buf.write('\n') + buf.write(' '.join(map(str, p.nums_actions))) + buf.write('\n\n') + buf.write(_format_payoffs(p.payoffs)) - """ - with open(file_path, 'w') as f: - f.write(cls._dump(g) + '\n') + return buf.getvalue() - @classmethod - def to_string(cls, g): - """ - Return the GameTracer .gam string representation of `g`. - Parameters - ---------- - g : NormalFormGame - NormalFormGame instance to convert. +# Gambit .nfg # - Returns - ------- - str - The .gam format string representation of `g`. +# A token is a quoted string, a brace, or a run of other characters; commas +# are separators. A quoted string ends at the first quote not preceded by a +# backslash, `\"` being the only escape that the format defines. +_NFG_TOKEN = re.compile(r'"((?:[^"\\]|\\+[^\\])*)"|([{}])|([^\s{}",]+)') - """ - return cls._dump(g) - @staticmethod - def _dump(g): - p = GAMPayoffVector.from_nfg(g) +def _read_from_tokens(tokens, pos): + """ + Return the item starting at `tokens[pos]` and the position after it: a + nested list for a braced group, the token itself otherwise. - buf = io.StringIO() + Parses the braces only and leaves the meaning to the caller, in the + manner of a Lisp reader, which parses only the parentheses. Adapted from + Norvig's `read_from_tokens`, https://norvig.com/lispy.html. + + """ + if tokens[pos] == '{': + items, pos = [], pos + 1 + while tokens[pos] != '}': + item, pos = _read_from_tokens(tokens, pos) + items.append(item) + return items, pos + 1 + return tokens[pos], pos + 1 - buf.write(str(p.N)) - buf.write('\n') - buf.write(' '.join(map(str, p.nums_actions))) - buf.write('\n\n') - payoffs = p.payoffs - if payoffs.dtype == np.bool_: - # Written as 0 and 1, not True and False - payoffs = payoffs.astype(int) +class NFGReader(_Reader): + """ + Parser for the Gambit .nfg format, in both the payoff version and the + outcome version. The title, the names of the players and of the + actions, the comment, and the names of the outcomes are ignored. - if np.issubdtype(payoffs.dtype, np.floating): - # Shortest representation that round-trips, without exponent - def fmt(x): - return np.format_float_positional(x, trim='.') + """ + @staticmethod + def _parse(string): + # Braces around the whole file, so that it reads as one list + tokens = ['{'] + tokens.extend(m.group(0) for m in _NFG_TOKEN.finditer(string)) + tokens.append('}') + if len(tokens) < 3 or tokens[1] != 'NFG': + raise ValueError('not in the .nfg format') + items, _ = _read_from_tokens(tokens, 0) + + # Prologue: NFG, version, R or D, title, players, actions (the + # numbers of actions, or the lists of their names), and an + # optional comment + _, _, _, _, _, actions, *body = items + if isinstance(body[0], str) and body[0].startswith('"'): + body = body[1:] + nums_actions = tuple( + len(a) if isinstance(a, list) else int(a) for a in actions + ) + + if isinstance(body[0], list): + # Outcome version: a list of outcomes, each a name and N + # payoffs, then the index of the outcome at each action + # profile, 0 meaning zero payoffs + outcomes, indices = body[0], body[1:] + N = len(nums_actions) + rows = [[_str2num(x) for x in o[1:]] for o in outcomes] + table = np.array(rows) if rows else np.empty((0, N), dtype=int) + # Row 0: the null outcome, in the dtype of the other rows + table = np.vstack((np.zeros((1, N), dtype=table.dtype), table)) + payoffs = table[[int(i) for i in indices]].ravel() else: - fmt = str + # Payoff version: the payoffs at each action profile + payoffs = np.array([_str2num(tok) for tok in body]) + + p = PayoffVector(nums_actions, payoffs, layout='profile-major') + return p.to_normal_form_game() + + +class NFGWriter(_Writer): + """ + Serializer for the Gambit .nfg format, in the payoff version. The + title is empty and the players are named "1", ..., "N". + + """ + @staticmethod + def _dump(g): + p = PayoffVector.from_normal_form_game(g, layout='profile-major') - buf.write(' '.join(map(fmt, payoffs))) + buf = io.StringIO() + + buf.write('NFG 1 R ""') + buf.write(' { ') + buf.write(' '.join(f'"{i}"' for i in range(1, p.N + 1))) + buf.write(' } { ') + buf.write(' '.join(map(str, p.nums_actions))) + buf.write(' }\n\n') + buf.write(_format_payoffs(p.payoffs)) - return buf.getvalue().rstrip() + return buf.getvalue() def from_gam(filename: str) -> NormalFormGame: @@ -500,3 +714,123 @@ def to_gam(g, file_path=None): if file_path is None: return GAMWriter.to_string(g) return GAMWriter.to_file(g, file_path) + + +def from_nfg(filename: str) -> NormalFormGame: + """ + Read a Gambit .nfg file and return a NormalFormGame. + + Parameters + ---------- + filename : str + Path to .nfg file. + + Returns + ------- + NormalFormGame + The game described by the .nfg file. + + Examples + -------- + Save a .nfg format string in a temporary file: + + >>> import tempfile + >>> fname = tempfile.mkstemp()[1] + >>> with open(fname, mode='w') as f: + ... _ = f.write(\"\"\"\\ + ... NFG 1 R "" { "1" "2" } { 3 2 } + ... + ... 3 2 0 6 2 1 1 3 4 0 5 4\"\"\") + + Read the file: + + >>> g = from_nfg(fname) + >>> print(g) + 2-player NormalFormGame with payoff profile array: + [[[3, 2], [1, 3]], + [[0, 6], [4, 0]], + [[2, 1], [5, 4]]] + + """ + return NFGReader.from_file(filename) + + +def from_nfg_string(string): + """ + Read a .nfg format string and return a NormalFormGame. + + Parameters + ---------- + string : str + String in .nfg format. + + Returns + ------- + NormalFormGame + The game described by the .nfg string. + + Examples + -------- + >>> string = \"\"\"\\ + ... NFG 1 R "" { "1" "2" } { 3 2 } + ... + ... 3 2 0 6 2 1 1 3 4 0 5 4\"\"\" + >>> g = from_nfg_string(string) + >>> print(g) + 2-player NormalFormGame with payoff profile array: + [[[3, 2], [1, 3]], + [[0, 6], [4, 0]], + [[2, 1], [5, 4]]] + + """ + return NFGReader.from_string(string) + + +def from_nfg_url(url): + """ + Read a Gambit .nfg file from a URL and return a NormalFormGame. + + Parameters + ---------- + url : str + String containing a URL of the .nfg file. + + Returns + ------- + NormalFormGame + The game described by the .nfg file. + + """ + return NFGReader.from_url(url) + + +def to_nfg(g, file_path=None): + """ + Write a NormalFormGame to a file in .nfg format. + + Parameters + ---------- + g : NormalFormGame + + file_path : str, optional(default=None) + Path to the file to write to. If None, the result is returned as + a string. + + Returns + ------- + None or str + + Examples + -------- + >>> g = NormalFormGame([[(3, 2), (1, 3)], + ... [(0, 6), (4, 0)], + ... [(2, 1), (5, 4)]]) + >>> print(to_nfg(g)) + NFG 1 R "" { "1" "2" } { 3 2 } + + 3 2 0 6 2 1 1 3 4 0 5 4 + + """ + if file_path is None: + return NFGWriter.to_string(g) + return NFGWriter.to_file(g, file_path) diff --git a/quantecon/game_theory/tests/game_files/3x2_outcome.nfg b/quantecon/game_theory/tests/game_files/3x2_outcome.nfg new file mode 100644 index 000000000..0b72c5d75 --- /dev/null +++ b/quantecon/game_theory/tests/game_files/3x2_outcome.nfg @@ -0,0 +1,15 @@ +NFG 1 R "3x2 game, outcome version" { "Row" "Column" } + +{ { "1" "2" "3" } +{ "1" "2" } +} +"Hand-written test file; the fifth action profile has the null outcome" + +{ +{ "a" 3, 2 } +{ "b" 0, 6 } +{ "c" 2, 1 } +{ "d" 1, 3 } +{ "e" 5, 4 } +} +1 2 3 4 0 5 diff --git a/quantecon/game_theory/tests/game_files/3x2_payoff.nfg b/quantecon/game_theory/tests/game_files/3x2_payoff.nfg new file mode 100644 index 000000000..50593e934 --- /dev/null +++ b/quantecon/game_theory/tests/game_files/3x2_payoff.nfg @@ -0,0 +1,4 @@ +NFG 1 R "3x2 game, payoff version" { "Row" "Column" } { 3 2 } "Hand-written test file" + +3 2 0 6 2 1 +1 3 4 0 5 4 diff --git a/quantecon/game_theory/tests/test_game_converters.py b/quantecon/game_theory/tests/test_game_converters.py index 7836331f4..27d998bf9 100644 --- a/quantecon/game_theory/tests/test_game_converters.py +++ b/quantecon/game_theory/tests/test_game_converters.py @@ -11,17 +11,18 @@ assert_, assert_array_equal, assert_string_equal, assert_raises ) from quantecon.game_theory import ( - Player, NormalFormGame, GAMWriter, to_gam, from_gam_string, from_gam_url + Player, NormalFormGame, GAMWriter, to_gam, from_gam_string, from_gam_url, + from_nfg, from_nfg_string, from_nfg_url, to_nfg ) from quantecon.game_theory.game_converters import ( - GAMPayoffVector, _str2num + PayoffVector, _str2num ) -# GAMPayoffVector # +# PayoffVector # -class TestGAMPayoffVector: - """Golden test for GAMPayoffVector""" +class TestPayoffVector: + """Golden test for PayoffVector""" def setup_method(self): nums_actions = (2, 3, 4) @@ -32,80 +33,160 @@ def setup_method(self): A1 = np.arange(100, 100+na).reshape(nums_actions, order='F') A2 = np.arange(200, 200+na).reshape(nums_actions, order='F') - self.payoffs1d = np.hstack([A.ravel(order='F') for A in [A0, A1, A2]]) self.payoffs4d = np.stack([A0, A1, A2], axis=N) + self.payoffs1d = { + 'player-major': + np.hstack([A.ravel(order='F') for A in [A0, A1, A2]]), + 'profile-major': + np.moveaxis(self.payoffs4d, -1, 0).ravel(order='F'), + } self.N = N self.nums_actions = nums_actions def test_init(self): - p = GAMPayoffVector(self.nums_actions, self.payoffs1d) + for layout, payoffs1d in self.payoffs1d.items(): + p = PayoffVector(self.nums_actions, payoffs1d, layout=layout) - assert_(p.N == self.N) - assert_(p.nums_actions == self.nums_actions) - assert_array_equal(p.payoffs, self.payoffs1d) + assert_(p.N == self.N) + assert_(p.nums_actions == self.nums_actions) + assert_(p.layout == layout) + assert_array_equal(p.payoffs, payoffs1d) - def test_from_nfg(self): + def test_from_normal_form_game(self): g = NormalFormGame(self.payoffs4d) - p = GAMPayoffVector.from_nfg(g) - - assert_(p.N == self.N) - assert_(p.nums_actions == self.nums_actions) - assert_array_equal(p.payoffs, self.payoffs1d) + for layout, payoffs1d in self.payoffs1d.items(): + p = PayoffVector.from_normal_form_game(g, layout=layout) + + assert_(p.N == self.N) + assert_(p.nums_actions == self.nums_actions) + assert_(p.layout == layout) + assert_array_equal(p.payoffs, payoffs1d) + + def test_to_normal_form_game(self): + for layout, payoffs1d in self.payoffs1d.items(): + p = PayoffVector(self.nums_actions, payoffs1d, layout=layout) + g = p.to_normal_form_game() + assert_array_equal(g.payoff_profile_array, self.payoffs4d) + + def test_to_layout(self): + for layout, payoffs1d in self.payoffs1d.items(): + p = PayoffVector(self.nums_actions, payoffs1d, layout=layout) + for layout_new, payoffs1d_new in self.payoffs1d.items(): + p_new = p.to_layout(layout_new) + assert_(p_new.layout == layout_new) + assert_array_equal(p_new.payoffs, payoffs1d_new) + assert_(not np.shares_memory(p_new.payoffs, p.payoffs)) + + +def test_payoffvector_2p(): + # 3x2 game with payoff profiles, in column-major order over the + # action profiles: + # (0,0): (3,2) (1,0): (0,6) (2,0): (2,1) + # (0,1): (1,3) (1,1): (4,0) (2,1): (5,4) + g = NormalFormGame((Player([[3, 1], [0, 4], [2, 5]]), + Player([[2, 6, 1], [3, 0, 4]]))) + payoffs1d = { + 'player-major': [3, 0, 2, 1, 4, 5, 2, 6, 1, 3, 0, 4], + 'profile-major': [3, 2, 0, 6, 2, 1, 1, 3, 4, 0, 5, 4], + } + + for layout, payoffs in payoffs1d.items(): + p = PayoffVector.from_normal_form_game(g, layout=layout) + assert_array_equal(p.payoffs, payoffs) - def test_to_nfg(self): - p = GAMPayoffVector(self.nums_actions, self.payoffs1d) - g = p.to_nfg() - assert_array_equal(g.payoff_profile_array, self.payoffs4d) + p = PayoffVector((3, 2), payoffs, layout=layout) + assert_array_equal(p.to_normal_form_game().payoff_profile_array, + g.payoff_profile_array) -def test_gampayoffvector_roundtrip(): +def test_payoffvector_roundtrip(): for ns in [(4, 3), (2, 2, 3, 2)]: N = len(ns) seed = 12345 rng = np.random.default_rng(seed) payoffs = rng.integers(low=0, high=100, size=(*ns, N), dtype=np.int64) g = NormalFormGame(payoffs) - p = GAMPayoffVector.from_nfg(g) - g1 = p.to_nfg() - p_32 = GAMPayoffVector.from_nfg(g, dtype=np.int32) - g2 = p_32.to_nfg() - g3 = p_32.to_nfg(dtype=np.int64) + for layout in ['player-major', 'profile-major']: + p = PayoffVector.from_normal_form_game(g, layout=layout) + g1 = p.to_normal_form_game() - assert_(p_32.payoffs.dtype == np.int32) - assert_(g2.dtype == np.int32) - assert_(g3.dtype == np.int64) + p_32 = PayoffVector.from_normal_form_game( + g, layout=layout, dtype=np.int32 + ) + g2 = p_32.to_normal_form_game() + g3 = p_32.to_normal_form_game(dtype=np.int64) - for g_new in [g1, g2, g3]: - assert_(g_new.N == g.N) - assert_(g_new.nums_actions == g.nums_actions) - for i in range(N): - assert_array_equal(g_new.players[i].payoff_array, - g.players[i].payoff_array) + assert_(p_32.payoffs.dtype == np.int32) + assert_(g2.dtype == np.int32) + assert_(g3.dtype == np.int64) + for g_new in [g1, g2, g3]: + assert_(g_new.N == g.N) + assert_(g_new.nums_actions == g.nums_actions) + for i in range(N): + assert_array_equal(g_new.players[i].payoff_array, + g.players[i].payoff_array) -def test_gampayoffvector_1p(): + +def test_payoffvector_1p(): payoffs = [1., 2., 3.] nums_actions = (3,) - p0 = GAMPayoffVector(nums_actions, payoffs) - g = NormalFormGame((Player(payoffs),)) - p1 = GAMPayoffVector.from_nfg(g) - for p in [p0, p1]: - assert_(p.N == 1) - assert_(p.nums_actions == nums_actions) - assert_array_equal(p.payoffs, payoffs) + for layout in ['player-major', 'profile-major']: + p0 = PayoffVector(nums_actions, payoffs, layout=layout) + p1 = PayoffVector.from_normal_form_game(g, layout=layout) + + for p in [p0, p1]: + assert_(p.N == 1) + assert_(p.nums_actions == nums_actions) + assert_array_equal(p.payoffs, payoffs) + assert_array_equal(p.to_normal_form_game().players[0].payoff_array, + payoffs) + + +def test_payoffvector_views(): + payoffs = np.arange(12) + + for layout in ['player-major', 'profile-major']: + p = PayoffVector((3, 2), payoffs, layout=layout) + + # `payoffs` and the player blocks are views of the input + assert_(np.shares_memory(p.payoffs, payoffs)) + assert_(np.shares_memory(p._player_block(1), payoffs)) + + # The game does not alias the input + g = p.to_normal_form_game() + assert_(not any(np.shares_memory(player.payoff_array, payoffs) + for player in g.players)) + + # Also for one player + p1 = PayoffVector((3,), np.arange(3), layout='player-major') + g = p1.to_normal_form_game() + assert_(not np.shares_memory(g.players[0].payoff_array, p1.payoffs)) def test_invalid_inputs(): - assert_raises(ValueError, GAMPayoffVector, (), np.array([], dtype=float)) - assert_raises(TypeError, GAMPayoffVector, (2, 2.0), np.zeros(8)) - assert_raises(ValueError, GAMPayoffVector, (2, 0), np.zeros(0)) - assert_raises(ValueError, GAMPayoffVector, (2, 2), np.zeros((2, 4))) - assert_raises(ValueError, GAMPayoffVector, (2, 2), np.zeros(7)) + pm = 'player-major' + assert_raises(ValueError, PayoffVector, (), np.array([]), layout=pm) + assert_raises(TypeError, PayoffVector, (2, 2.0), np.zeros(8), layout=pm) + assert_raises(ValueError, PayoffVector, (2, 0), np.zeros(0), layout=pm) + assert_raises(ValueError, PayoffVector, (2, 2), np.zeros((2, 4)), + layout=pm) + assert_raises(ValueError, PayoffVector, (2, 2), np.zeros(7), layout=pm) + # np.prod would overflow and give 0, accepting an empty array + assert_raises(ValueError, PayoffVector, (2**62, 2**62), np.zeros(0), + layout=pm) + # layout is required, and must be 'player-major' or 'profile-major' + assert_raises(TypeError, PayoffVector, (2, 2), np.zeros(8)) + for layout in ['F', 'C', 'player_major']: + assert_raises(ValueError, PayoffVector, (2, 2), np.zeros(8), + layout=layout) + p = PayoffVector((2, 2), np.zeros(8), layout=pm) + assert_raises(ValueError, p.to_layout, 'F') # GAMWriter/to_gam # @@ -316,7 +397,8 @@ def test_from_gam_string_number_formats(): assert_(g.dtype == np.float64) assert_array_equal( - GAMPayoffVector.from_nfg(g).payoffs, payoffs + PayoffVector.from_normal_form_game(g, layout='player-major').payoffs, + payoffs ) # Integers only, with signs @@ -330,7 +412,8 @@ def test_from_gam_string_number_formats(): assert_(np.issubdtype(g.dtype, np.integer)) assert_array_equal( - GAMPayoffVector.from_nfg(g).payoffs, [1, 2, -3, 4, 5, 6, 7, 8] + PayoffVector.from_normal_form_game(g, layout='player-major').payoffs, + [1, 2, -3, 4, 5, 6, 7, 8] ) assert_raises(ValueError, from_gam_string, "2\n2 2\n\n1 2 3 4 5 6 7 x") @@ -356,3 +439,206 @@ def fake_urlopen(url): g_str = from_gam_string(s) assert_array_equal(g_url.payoff_profile_array, g_str.payoff_profile_array) + + +# NFGReader/from_nfg # + +def _game_3x2(): + # 3x2 game with payoff profiles, in column-major order over the + # action profiles: + # (0,0): (3,2) (1,0): (0,6) (2,0): (2,1) + # (0,1): (1,3) (1,1): (4,0) (2,1): (5,4) + return NormalFormGame([[(3, 2), (1, 3)], + [(0, 6), (4, 0)], + [(2, 1), (5, 4)]]) + + +_NFG_PAYOFF = """\ +NFG 1 R "3x2 game" { "Row" "Column" } { 3 2 } + +3 2 0 6 2 1 1 3 4 0 5 4""" + +_NFG_OUTCOME = """\ +NFG 1 R "3x2 game" { "Row" "Column" } + +{ { "1" "2" "3" } +{ "1" "2" } +} +"" + +{ +{ "" 3, 2 } +{ "" 0, 6 } +{ "" 2, 1 } +{ "" 1, 3 } +{ "" 4, 0 } +{ "" 5, 4 } +} +1 2 3 4 5 6 +""" + + +def test_from_nfg_string(): + g = _game_3x2() + for s in [_NFG_PAYOFF, _NFG_OUTCOME]: + g_read = from_nfg_string(s) + assert_(np.issubdtype(g_read.dtype, np.integer)) + assert_array_equal(g_read.payoff_profile_array, + g.payoff_profile_array) + + +def test_from_nfg_string_variants(): + g = _game_3x2() + variants = [ + # D instead of R + _NFG_PAYOFF.replace("NFG 1 R", "NFG 1 D"), + # Names of the actions instead of their numbers + _NFG_PAYOFF.replace('{ 3 2 }', '{ { "a" "b" "c" } { "x" "y" } }'), + # Comment after the actions + _NFG_PAYOFF.replace('{ 3 2 }', '{ 3 2 } "a comment"'), + # Escaped quote, braces, and a comma in the title + _NFG_PAYOFF.replace('"3x2 game"', '"a \\"3x2\\" {game}, R"'), + # Numbers of actions in the outcome version, no comment + _NFG_OUTCOME.replace('{ { "1" "2" "3" }\n{ "1" "2" }\n}\n""', + '{ 3 2 }'), + # Commas absent + _NFG_OUTCOME.replace(',', ''), + # CRLF + _NFG_OUTCOME.replace('\n', '\r\n'), + ] + for s in variants: + assert_array_equal(from_nfg_string(s).payoff_profile_array, + g.payoff_profile_array) + + +def test_from_nfg_string_null_outcome(): + # Outcome 0 is the null outcome with zero payoffs + s = """\ +NFG 1 R "" { "1" "2" } { 2 2 } + +{ +{ "" 1, 2 } +{ "" 3/2, -4.5 } +} +1 0 2 0 +""" + g = from_nfg_string(s) + assert_(g.dtype == np.float64) + assert_array_equal(g.payoff_profile_array, + [[[1., 2.], [1.5, -4.5]], + [[0., 0.], [0., 0.]]]) + + +def test_from_nfg_string_null_outcomes_only(): + # An empty list of outcomes, with the null outcome at every profile + s = 'NFG 1 R "" { "Row" "Col" } { 2 2 } { } 0 0 0 0' + g = from_nfg_string(s) + assert_(np.issubdtype(g.dtype, np.integer)) + assert_array_equal(g.payoff_profile_array, np.zeros((2, 2, 2))) + + +def test_from_nfg_string_large_unsigned_outcome(): + # Payoffs beyond int64: the null outcome row must not turn the uint64 + # table into float64 + a, b = 2**63 + 1, 2**63 + 3 + s = f'NFG 1 R "" {{ "Row" "Col" }} {{ 1 1 }} {{ {{ "" {a}, {b} }} }} 1' + g = from_nfg_string(s) + assert_(g.dtype == np.uint64) # equality alone would promote and pass + assert_array_equal(g.payoff_profile_array, + np.array([[[a, b]]], dtype=np.uint64)) + + +def test_from_nfg_string_backslash_newline_in_title(): + s = _NFG_PAYOFF.replace('"3x2 game"', '"first\\\nsecond"') + assert_array_equal(from_nfg_string(s).payoff_profile_array, + _game_3x2().payoff_profile_array) + + +def test_from_nfg_string_quote_after_two_backslashes_in_title(): + # Only `\"` is an escape, so the quote after `\\` does not end the title + s = _NFG_PAYOFF.replace('"3x2 game"', r'"a\\"b"') + assert_array_equal(from_nfg_string(s).payoff_profile_array, + _game_3x2().payoff_profile_array) + + +def test_from_nfg_string_3p(): + # 2x2x2 game, profile-major: player 0 varies fastest + payoffs = np.arange(24).reshape((2, 2, 2, 3), order='F') + g = NormalFormGame(payoffs) + s = 'NFG 1 R "" { "1" "2" "3" } { 2 2 2 }\n\n' + \ + ' '.join(map(str, payoffs.reshape((8, 3), order='F').ravel())) + assert_array_equal(from_nfg_string(s).payoff_profile_array, + g.payoff_profile_array) + + +def test_from_nfg_files(): + path = os.path.join(os.path.dirname(__file__), 'game_files') + for fname in ['3x2_payoff.nfg', '3x2_outcome.nfg']: + g_read = from_nfg(os.path.join(path, fname)) + g_expected = _game_3x2() + if fname == '3x2_outcome.nfg': + # The profile (1, 1) has the null outcome in this file + g_expected[1, 1] = (0, 0) + assert_array_equal(g_read.payoff_profile_array, + g_expected.payoff_profile_array) + + +def test_from_nfg_url(): + def fake_urlopen(url): + return _FakeResponse(_NFG_PAYOFF.encode("utf-8")) + + with patch("urllib.request.urlopen", fake_urlopen): + g_url = from_nfg_url("http://example.com/game.nfg") + + assert_array_equal(g_url.payoff_profile_array, + _game_3x2().payoff_profile_array) + + +def test_from_nfg_string_not_nfg(): + s_gam = "2\n3 2\n\n1 2 3 4 5 6 7 8 9 10 11 12" + assert_raises(ValueError, from_nfg_string, s_gam) + + +# NFGWriter/to_nfg # + +def test_to_nfg(): + g = _game_3x2() + s_desired = 'NFG 1 R "" { "1" "2" } { 3 2 }\n\n3 2 0 6 2 1 1 3 4 0 5 4' + + assert_string_equal(to_nfg(g), s_desired) + + with NamedTemporaryFile(delete=False) as tmp_file: + temp_path = tmp_file.name + to_nfg(g, temp_path) + + with open(temp_path, 'r') as f: + s_actual = f.read() + assert_string_equal(s_actual, s_desired + '\n') + + os.remove(temp_path) + + +def test_nfg_roundtrip(): + rng = np.random.default_rng(12345) + for ns in [(4, 3), (2, 2, 3, 2)]: + N = len(ns) + for payoffs in [rng.integers(0, 100, size=(*ns, N)), + rng.random(size=(*ns, N)), + rng.random(size=(*ns, N)) < 0.5]: + g = NormalFormGame(payoffs) + g_read = from_nfg_string(to_nfg(g)) + assert_array_equal(g_read.payoff_profile_array, + g.payoff_profile_array) + + +def test_gam_nfg_same_game(): + # The two writers list the same payoffs in the two orders + rng = np.random.default_rng(0) + g = NormalFormGame(rng.integers(0, 100, size=(2, 3, 4, 3))) + g_gam = from_gam_string(to_gam(g)) + g_nfg = from_nfg_string(to_nfg(g)) + assert_array_equal(g_gam.payoff_profile_array, g_nfg.payoff_profile_array) + + payoffs_gam = np.array(to_gam(g).split()[4:], dtype=int) + payoffs_nfg = np.array(to_nfg(g).split('\n')[-1].split(), dtype=int) + assert_array_equal(payoffs_gam.reshape((3, 24)).T.ravel(), payoffs_nfg)