From ee0513b2afd4ace236bcb863b98bd35b20ff388c Mon Sep 17 00:00:00 2001 From: Daisuke Oyama Date: Thu, 24 Sep 2026 23:51:40 +0900 Subject: [PATCH 01/12] ENH: Replace GAMPayoffVector with PayoffVector Generalize the intermediate representation between a NormalFormGame and the flat payoff list of a game file to the two orders in which game files list the payoffs: `PayoffVector` stores the payoffs in a 1-dim array in the order `layout`, 'player-major' as in .gam or 'profile-major' as in the upcoming .nfg support, with the layout read in one place, `_player_block`. `to_layout` converts between the orders. This is for the upcoming reader and writer for the Gambit .nfg format. The conversions to and from a game are renamed `from_normal_form_game` and `to_normal_form_game`. Also compute the expected number of payoffs with `math.prod`, which does not overflow, and copy the payoffs in `to_normal_form_game`, so that the game does not alias the input for a one-player game. Co-Authored-By: Claude Fable 5.1 --- quantecon/game_theory/game_converters.py | 159 ++++++++++++---- .../game_theory/tests/test_game_converters.py | 180 +++++++++++++----- 2 files changed, 251 insertions(+), 88 deletions(-) diff --git a/quantecon/game_theory/game_converters.py b/quantecon/game_theory/game_converters.py index ad6ab1be..663baddf 100644 --- a/quantecon/game_theory/game_converters.py +++ b/quantecon/game_theory/game_converters.py @@ -36,23 +36,45 @@ """ import io +import math import numbers 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). + + Parameters + ---------- + nums_actions : array_like(int, ndim=1) + Numbers of actions, one for each player. - Payoff values are ordered as in the GameTracer .gam format: + payoffs : array_like(ndim=1) + Payoffs, of length prod(nums_actions) * N, in the order `layout`. - 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). + layout : {'player-major', 'profile-major'} + Order in which `payoffs` lists the payoffs. Attributes ---------- @@ -63,10 +85,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 +106,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 +123,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 +166,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 cls(nums_actions, payoffs) + return p - def to_nfg(self, dtype=None): + 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 p + + def to_normal_form_game(self, dtype=None): """ Construct a NormalFormGame from self. @@ -163,26 +244,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) ) @@ -318,8 +399,8 @@ 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: @@ -364,7 +445,7 @@ def to_string(cls, g): @staticmethod def _dump(g): - p = GAMPayoffVector.from_nfg(g) + p = PayoffVector.from_normal_form_game(g, layout='player-major') buf = io.StringIO() diff --git a/quantecon/game_theory/tests/test_game_converters.py b/quantecon/game_theory/tests/test_game_converters.py index 7836331f..50b736a9 100644 --- a/quantecon/game_theory/tests/test_game_converters.py +++ b/quantecon/game_theory/tests/test_game_converters.py @@ -14,14 +14,14 @@ Player, NormalFormGame, GAMWriter, to_gam, from_gam_string, from_gam_url ) 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 +32,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() + + 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) - assert_(p_32.payoffs.dtype == np.int32) - assert_(g2.dtype == np.int32) - assert_(g3.dtype == np.int64) + 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) + 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 + assert_raises(ValueError, PayoffVector, (2**62, 2**62), np.zeros(2), + 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 +396,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 +411,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") From f3e34f44da93d7b8645995ed294a4ea07a3019ff Mon Sep 17 00:00:00 2001 From: Daisuke Oyama Date: Fri, 25 Sep 2026 12:04:46 +0900 Subject: [PATCH 02/12] MAINT: game_converters: Factor out the entry points of readers and writers Move `from_file`, `from_url`, and `from_string` to a base class `_Reader` and `to_file` and `to_string` to a base class `_Writer`, so that a reader or writer for another format defines only `_parse` or `_dump`; move the formatting of the payoffs of `GAMWriter` to `_format_payoffs`. This is for the upcoming support of the Gambit .nfg format. No change in behavior. The inherited entry points are listed in the documentation of `GAMReader` and `GAMWriter` with `:inherited-members:`. Co-Authored-By: Claude Fable 5.1 --- docs/source/game_theory/game_converters.rst | 1 + quantecon/game_theory/game_converters.py | 146 ++++++++++++-------- 2 files changed, 86 insertions(+), 61 deletions(-) diff --git a/docs/source/game_theory/game_converters.rst b/docs/source/game_theory/game_converters.rst index 1033b360..ec762f25 100644 --- a/docs/source/game_theory/game_converters.rst +++ b/docs/source/game_theory/game_converters.rst @@ -4,4 +4,5 @@ game_converters .. automodule:: quantecon.game_theory.game_converters :members: :undoc-members: + :inherited-members: :show-inheritance: diff --git a/quantecon/game_theory/game_converters.py b/quantecon/game_theory/game_converters.py index 663baddf..995fc543 100644 --- a/quantecon/game_theory/game_converters.py +++ b/quantecon/game_theory/game_converters.py @@ -301,25 +301,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: @@ -334,12 +335,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 @@ -350,21 +351,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() @@ -403,46 +475,11 @@ def _parse(string): 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. - - 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 GameTracer .gam string representation of `g`. - - Parameters - ---------- - g : NormalFormGame - NormalFormGame instance to convert. - - Returns - ------- - str - The .gam format string representation of `g`. - - """ - return cls._dump(g) - @staticmethod def _dump(g): p = PayoffVector.from_normal_form_game(g, layout='player-major') @@ -453,22 +490,9 @@ def _dump(g): buf.write('\n') buf.write(' '.join(map(str, p.nums_actions))) buf.write('\n\n') + buf.write(_format_payoffs(p.payoffs)) - payoffs = p.payoffs - 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 - - buf.write(' '.join(map(fmt, payoffs))) - - return buf.getvalue().rstrip() + return buf.getvalue() def from_gam(filename: str) -> NormalFormGame: From 46861efd95f690f3353397cc158f33ae1ebb46d2 Mon Sep 17 00:00:00 2001 From: Daisuke Oyama Date: Fri, 25 Sep 2026 12:04:47 +0900 Subject: [PATCH 03/12] ENH: Add a reader for the Gambit .nfg format Add `NFGReader` and `from_nfg`, `from_nfg_string`, `from_nfg_url`. The text is tokenized with a regular expression, the brace structure is read into nested lists (the Lisp reader), and the lists are interpreted by position: both the payoff version and the outcome version are read, the numbers of strategies or their names are accepted, and the title, the names, and the comment are ignored. The payoffs are in profile-major order, so the reader ends in `PayoffVector(..., layout='profile-major')`. Validation is minimal: a file not starting with `NFG` is rejected, and other malformed input fails where it is used. Co-Authored-By: Claude Fable 5.1 --- quantecon/game_theory/__init__.py | 3 +- quantecon/game_theory/game_converters.py | 160 +++++++++++++++++- .../tests/game_files/3x2_outcome.nfg | 15 ++ .../tests/game_files/3x2_payoff.nfg | 4 + .../game_theory/tests/test_game_converters.py | 129 +++++++++++++- 5 files changed, 308 insertions(+), 3 deletions(-) create mode 100644 quantecon/game_theory/tests/game_files/3x2_outcome.nfg create mode 100644 quantecon/game_theory/tests/game_files/3x2_payoff.nfg diff --git a/quantecon/game_theory/__init__.py b/quantecon/game_theory/__init__.py index 3ea5348f..c9454526 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, from_nfg, from_nfg_string, from_nfg_url ) diff --git a/quantecon/game_theory/game_converters.py b/quantecon/game_theory/game_converters.py index 995fc543..b68326b1 100644 --- a/quantecon/game_theory/game_converters.py +++ b/quantecon/game_theory/game_converters.py @@ -2,7 +2,7 @@ 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]_. Examples -------- @@ -33,11 +33,14 @@ ---------- .. [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 @@ -495,6 +498,73 @@ def _dump(g): return buf.getvalue() +# Gambit .nfg # + +# A token is a quoted string (with `\"` for a quote inside), a brace, or a +# run of other characters; commas are separators +_NFG_TOKEN = re.compile(r'"((?:[^"\\]|\\.)*)"|([{}])|([^\s{}",]+)') + + +def _read_tree(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 (the Lisp + reader). + + """ + if tokens[pos] == '{': + items, pos = [], pos + 1 + while tokens[pos] != '}': + item, pos = _read_tree(tokens, pos) + items.append(item) + return items, pos + 1 + return tokens[pos], pos + 1 + + +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. + + """ + @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') + tree, _ = _read_tree(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 = tree + 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:] + table = np.array([[_str2num(x) for x in o[1:]] for o in outcomes]) + table = np.vstack([np.zeros((1, table.shape[1]), table.dtype), + table]) + payoffs = table[[int(i) for i in indices]].ravel() + else: + # 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() + + def from_gam(filename: str) -> NormalFormGame: """ Read a GameTracer .gam file and return a NormalFormGame. @@ -605,3 +675,91 @@ 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) 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 00000000..384be1d7 --- /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 profile (2, 1) 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 00000000..50593e93 --- /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 50b736a9..e177913a 100644 --- a/quantecon/game_theory/tests/test_game_converters.py +++ b/quantecon/game_theory/tests/test_game_converters.py @@ -11,7 +11,8 @@ 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 ) from quantecon.game_theory.game_converters import ( PayoffVector, _str2num @@ -438,3 +439,129 @@ 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_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) From 4078c7d88632f92aaf048b11814510d8c2f0881a Mon Sep 17 00:00:00 2001 From: Daisuke Oyama Date: Fri, 25 Sep 2026 12:04:48 +0900 Subject: [PATCH 04/12] ENH: Add a writer for the Gambit .nfg format Add `NFGWriter` and `to_nfg`, which write the payoff version with an empty title and the players named "1", ..., "N". The payoffs are those of `PayoffVector.from_normal_form_game(g, layout='profile-major')`, formatted as in `to_gam`. Co-Authored-By: Claude Fable 5.1 --- quantecon/game_theory/__init__.py | 2 +- quantecon/game_theory/game_converters.py | 55 +++++++++++++++++++ .../game_theory/tests/test_game_converters.py | 47 +++++++++++++++- 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/quantecon/game_theory/__init__.py b/quantecon/game_theory/__init__.py index c9454526..f5c78172 100644 --- a/quantecon/game_theory/__init__.py +++ b/quantecon/game_theory/__init__.py @@ -26,5 +26,5 @@ from .howson_lcp import polym_lcp_solver from .game_converters import ( GAMReader, GAMWriter, from_gam, from_gam_string, from_gam_url, to_gam, - NFGReader, from_nfg, from_nfg_string, from_nfg_url + 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 b68326b1..511720a1 100644 --- a/quantecon/game_theory/game_converters.py +++ b/quantecon/game_theory/game_converters.py @@ -565,6 +565,29 @@ def _parse(string): 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 = 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() + + def from_gam(filename: str) -> NormalFormGame: """ Read a GameTracer .gam file and return a NormalFormGame. @@ -763,3 +786,35 @@ def from_nfg_url(url): """ 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/test_game_converters.py b/quantecon/game_theory/tests/test_game_converters.py index e177913a..98ecfd71 100644 --- a/quantecon/game_theory/tests/test_game_converters.py +++ b/quantecon/game_theory/tests/test_game_converters.py @@ -12,7 +12,7 @@ ) from quantecon.game_theory import ( Player, NormalFormGame, GAMWriter, to_gam, from_gam_string, from_gam_url, - from_nfg, from_nfg_string, from_nfg_url + from_nfg, from_nfg_string, from_nfg_url, to_nfg ) from quantecon.game_theory.game_converters import ( PayoffVector, _str2num @@ -565,3 +565,48 @@ def fake_urlopen(url): 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) From 23afb78b2a93d74c55dbb05eaa3274b28ddc1765 Mon Sep 17 00:00:00 2001 From: Daisuke Oyama Date: Fri, 25 Sep 2026 21:03:52 +0900 Subject: [PATCH 05/12] FIX: from_nfg: Read a file with an empty list of outcomes The width of the null outcome was taken from the array of the outcomes listed, which is empty for a file with the null outcome at every action profile. Take it from the number of players instead; as a side effect, an outcome with a wrong number of payoffs is rejected by NumPy when the array is built. Co-Authored-By: Claude Fable 5.1 --- quantecon/game_theory/game_converters.py | 6 +++--- quantecon/game_theory/tests/test_game_converters.py | 8 ++++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/quantecon/game_theory/game_converters.py b/quantecon/game_theory/game_converters.py index 511720a1..fbc0fdbb 100644 --- a/quantecon/game_theory/game_converters.py +++ b/quantecon/game_theory/game_converters.py @@ -553,9 +553,9 @@ def _parse(string): # payoffs, then the index of the outcome at each action # profile, 0 meaning zero payoffs outcomes, indices = body[0], body[1:] - table = np.array([[_str2num(x) for x in o[1:]] for o in outcomes]) - table = np.vstack([np.zeros((1, table.shape[1]), table.dtype), - table]) + N = len(nums_actions) + rows = [[_str2num(x) for x in o[1:]] for o in outcomes] + table = np.array([[0] * N] + rows) # row 0: the null outcome payoffs = table[[int(i) for i in indices]].ravel() else: # Payoff version: the payoffs at each action profile diff --git a/quantecon/game_theory/tests/test_game_converters.py b/quantecon/game_theory/tests/test_game_converters.py index 98ecfd71..65c033aa 100644 --- a/quantecon/game_theory/tests/test_game_converters.py +++ b/quantecon/game_theory/tests/test_game_converters.py @@ -529,6 +529,14 @@ def test_from_nfg_string_null_outcome(): [[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_3p(): # 2x2x2 game, profile-major: player 0 varies fastest payoffs = np.arange(24).reshape((2, 2, 2, 3), order='F') From c2f54b92729e34e4421951a99b2de5109ad70fd8 Mon Sep 17 00:00:00 2001 From: Daisuke Oyama Date: Fri, 25 Sep 2026 21:03:53 +0900 Subject: [PATCH 06/12] TEST: Make the overflow test of PayoffVector detect a regression With `np.prod` the expected number of payoffs wraps around to 0, so an input of length 2 was rejected by the overflowing implementation as well; an input of length 0 is accepted by it and rejected by the current one. Co-Authored-By: Claude Fable 5.1 --- quantecon/game_theory/tests/test_game_converters.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/quantecon/game_theory/tests/test_game_converters.py b/quantecon/game_theory/tests/test_game_converters.py index 65c033aa..6e27bff2 100644 --- a/quantecon/game_theory/tests/test_game_converters.py +++ b/quantecon/game_theory/tests/test_game_converters.py @@ -177,8 +177,8 @@ def test_invalid_inputs(): 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 - assert_raises(ValueError, PayoffVector, (2**62, 2**62), np.zeros(2), + # 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)) From 8114c5b0d135d39bc80c8bb83b7a7ef90ca6a14c Mon Sep 17 00:00:00 2001 From: Daisuke Oyama Date: Fri, 25 Sep 2026 22:03:30 +0900 Subject: [PATCH 07/12] TEST: Fix the comment in the .nfg test file with a null outcome The comment named the action profile with the null outcome as (2, 1), which is wrong in either indexing convention; it is the fifth profile. Say so without indices, since the file is shared with GameTheory.jl. Co-Authored-By: Claude Fable 5.1 --- quantecon/game_theory/tests/game_files/3x2_outcome.nfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quantecon/game_theory/tests/game_files/3x2_outcome.nfg b/quantecon/game_theory/tests/game_files/3x2_outcome.nfg index 384be1d7..0b72c5d7 100644 --- a/quantecon/game_theory/tests/game_files/3x2_outcome.nfg +++ b/quantecon/game_theory/tests/game_files/3x2_outcome.nfg @@ -3,7 +3,7 @@ NFG 1 R "3x2 game, outcome version" { "Row" "Column" } { { "1" "2" "3" } { "1" "2" } } -"Hand-written test file; the profile (2, 1) has the null outcome" +"Hand-written test file; the fifth action profile has the null outcome" { { "a" 3, 2 } From df730b7b8cab08b8b75506d672f5202ac96bce2e Mon Sep 17 00:00:00 2001 From: Daisuke Oyama Date: Sat, 26 Sep 2026 16:32:03 +0900 Subject: [PATCH 08/12] DOC: List the functions in the game_converters module docstring Add a definition list of the eight public reader and writer functions to the module docstring, above the examples. Rename _read_tree to _read_from_tokens, after Norvig, so that "tree" stays free for the game tree of a future .efg reader, and explain the Lisp-reader design in its docstring (parse the braces only, leave the meaning to the caller) with a reference to Norvig's read_from_tokens. Co-Authored-By: Claude Fable 5.1 --- quantecon/game_theory/game_converters.py | 27 ++++++++++++++++++------ 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/quantecon/game_theory/game_converters.py b/quantecon/game_theory/game_converters.py index fbc0fdbb..8c7cdf45 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]_ and the Gambit `.nfg` text format [2]_. +[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 -------- @@ -505,17 +515,20 @@ def _dump(g): _NFG_TOKEN = re.compile(r'"((?:[^"\\]|\\.)*)"|([{}])|([^\s{}",]+)') -def _read_tree(tokens, pos): +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 (the Lisp - reader). + nested list for a braced group, the token itself otherwise. + + 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_tree(tokens, pos) + item, pos = _read_from_tokens(tokens, pos) items.append(item) return items, pos + 1 return tokens[pos], pos + 1 @@ -536,12 +549,12 @@ def _parse(string): tokens.append('}') if len(tokens) < 3 or tokens[1] != 'NFG': raise ValueError('not in the .nfg format') - tree, _ = _read_tree(tokens, 0) + 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 = tree + _, _, _, _, _, actions, *body = items if isinstance(body[0], str) and body[0].startswith('"'): body = body[1:] nums_actions = tuple( From e7ecb09f189f3d599b7db67f7e5c81924fa71361 Mon Sep 17 00:00:00 2001 From: Daisuke Oyama Date: Thu, 1 Oct 2026 17:39:28 +0900 Subject: [PATCH 09/12] DOC: Keep game_converters.rst in sync with qe_apidoc.py Drop the hand-added `:inherited-members:` option, which the docs generator does not emit; the docs CI job requires docs/source to match the output of `python qe_apidoc.py`. Co-Authored-By: Claude Fable 5.1 --- docs/source/game_theory/game_converters.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/source/game_theory/game_converters.rst b/docs/source/game_theory/game_converters.rst index ec762f25..1033b360 100644 --- a/docs/source/game_theory/game_converters.rst +++ b/docs/source/game_theory/game_converters.rst @@ -4,5 +4,4 @@ game_converters .. automodule:: quantecon.game_theory.game_converters :members: :undoc-members: - :inherited-members: :show-inheritance: From 622b2fabbe3c0121955087285aa2ec13083f0642 Mon Sep 17 00:00:00 2001 From: Daisuke Oyama Date: Thu, 1 Oct 2026 22:34:11 +0900 Subject: [PATCH 10/12] FIX: from_nfg: Keep the outcome table dtype when adding the null outcome Building the table from a nested list with a row of Python zeros let NumPy infer the dtype from the zeros as well, so a uint64 table of payoffs beyond int64 was promoted to float64 and rounded. Infer the dtype from the explicit outcomes and add the null row in that dtype. Co-Authored-By: Claude Fable 5.1 --- quantecon/game_theory/game_converters.py | 4 +++- quantecon/game_theory/tests/test_game_converters.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/quantecon/game_theory/game_converters.py b/quantecon/game_theory/game_converters.py index 8c7cdf45..4c3b8f70 100644 --- a/quantecon/game_theory/game_converters.py +++ b/quantecon/game_theory/game_converters.py @@ -568,7 +568,9 @@ def _parse(string): 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([[0] * N] + rows) # row 0: the null outcome + 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: # Payoff version: the payoffs at each action profile diff --git a/quantecon/game_theory/tests/test_game_converters.py b/quantecon/game_theory/tests/test_game_converters.py index 6e27bff2..70f96974 100644 --- a/quantecon/game_theory/tests/test_game_converters.py +++ b/quantecon/game_theory/tests/test_game_converters.py @@ -537,6 +537,17 @@ def test_from_nfg_string_null_outcomes_only(): 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_3p(): # 2x2x2 game, profile-major: player 0 varies fastest payoffs = np.arange(24).reshape((2, 2, 2, 3), order='F') From 6047cd8c0f3a09033dbc16c458fbe3da016cb127 Mon Sep 17 00:00:00 2001 From: Daisuke Oyama Date: Thu, 1 Oct 2026 22:34:11 +0900 Subject: [PATCH 11/12] FIX: from_nfg: Accept a backslash-newline inside quoted text The escape alternative `\.` of the token pattern did not match a newline, so a quoted string containing a backslash followed by a newline broke into several tokens. Compile the pattern with DOTALL. Co-Authored-By: Claude Fable 5.1 --- quantecon/game_theory/game_converters.py | 6 ++++-- quantecon/game_theory/tests/test_game_converters.py | 6 ++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/quantecon/game_theory/game_converters.py b/quantecon/game_theory/game_converters.py index 4c3b8f70..1fe807f1 100644 --- a/quantecon/game_theory/game_converters.py +++ b/quantecon/game_theory/game_converters.py @@ -511,8 +511,10 @@ def _dump(g): # Gambit .nfg # # A token is a quoted string (with `\"` for a quote inside), a brace, or a -# run of other characters; commas are separators -_NFG_TOKEN = re.compile(r'"((?:[^"\\]|\\.)*)"|([{}])|([^\s{}",]+)') +# run of other characters; commas are separators. DOTALL lets a backslash +# escape a newline as well. +_NFG_TOKEN = re.compile(r'"((?:[^"\\]|\\.)*)"|([{}])|([^\s{}",]+)', + re.DOTALL) def _read_from_tokens(tokens, pos): diff --git a/quantecon/game_theory/tests/test_game_converters.py b/quantecon/game_theory/tests/test_game_converters.py index 70f96974..1f9317c8 100644 --- a/quantecon/game_theory/tests/test_game_converters.py +++ b/quantecon/game_theory/tests/test_game_converters.py @@ -548,6 +548,12 @@ def test_from_nfg_string_large_unsigned_outcome(): 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_3p(): # 2x2x2 game, profile-major: player 0 varies fastest payoffs = np.arange(24).reshape((2, 2, 2, 3), order='F') From 773cc791a67f0d9f84ee44b9f583581669ea34bf Mon Sep 17 00:00:00 2001 From: Daisuke Oyama Date: Sat, 3 Oct 2026 14:48:04 +0900 Subject: [PATCH 12/12] FIX: from_nfg: End a quoted string only at a quote not preceded by a backslash The token pattern paired backslashes, so that a quote after two backslashes ended the string. The format defines only `\"` as an escape, the backslash being an ordinary character otherwise: consume a run of backslashes together with the character that follows. The new pattern also covers a backslash followed by a newline, so DOTALL is dropped. Co-Authored-By: Claude Fable 5.1 --- quantecon/game_theory/game_converters.py | 9 ++++----- quantecon/game_theory/tests/test_game_converters.py | 7 +++++++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/quantecon/game_theory/game_converters.py b/quantecon/game_theory/game_converters.py index 1fe807f1..2f2fc4b5 100644 --- a/quantecon/game_theory/game_converters.py +++ b/quantecon/game_theory/game_converters.py @@ -510,11 +510,10 @@ def _dump(g): # Gambit .nfg # -# A token is a quoted string (with `\"` for a quote inside), a brace, or a -# run of other characters; commas are separators. DOTALL lets a backslash -# escape a newline as well. -_NFG_TOKEN = re.compile(r'"((?:[^"\\]|\\.)*)"|([{}])|([^\s{}",]+)', - re.DOTALL) +# 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{}",]+)') def _read_from_tokens(tokens, pos): diff --git a/quantecon/game_theory/tests/test_game_converters.py b/quantecon/game_theory/tests/test_game_converters.py index 1f9317c8..27d998bf 100644 --- a/quantecon/game_theory/tests/test_game_converters.py +++ b/quantecon/game_theory/tests/test_game_converters.py @@ -554,6 +554,13 @@ def test_from_nfg_string_backslash_newline_in_title(): _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')