diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f098605eb..ee5d109ae 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -74,6 +74,7 @@ jobs: cache-dependency-path: pyproject.toml - name: Install pygambit (editable, picks up catalog/games directly) run: | + sudo apt-get update && sudo apt-get install -y libcdd-dev libgmp-dev python -m pip install -v -e ".[doc]" pip install "gtdraw @ git+https://github.com/gambitproject/gtdraw.git@main" - name: Regenerate catalog/catalog.am diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index f3524db75..627b1dd4e 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -31,6 +31,7 @@ jobs: - name: Set up dependencies run: | python -m pip install --upgrade pip + sudo apt-get update && sudo apt-get install -y libcdd-dev libgmp-dev python -m pip install build - name: Build source distribution run: @@ -62,8 +63,13 @@ jobs: - name: Set up dependencies run: | python -m pip install --upgrade pip + brew install cddlib gmp - name: Build extension run: | + # pycddlib builds from source against Homebrew's (arm64-only) cddlib and GMP + export CFLAGS="-I$(brew --prefix)/include" + export LDFLAGS="-L$(brew --prefix)/lib" + export ARCHFLAGS="-arch arm64" python -m pip install -v ".[test,doc]" pip install "gtdraw @ git+https://github.com/gambitproject/gtdraw.git@main" - name: Run tests @@ -87,8 +93,13 @@ jobs: - name: Set up dependencies run: | python -m pip install --upgrade pip + brew install cddlib gmp - name: Build extension run: | + # pycddlib builds from source against Homebrew's (arm64-only) cddlib and GMP + export CFLAGS="-I$(brew --prefix)/include" + export LDFLAGS="-L$(brew --prefix)/lib" + export ARCHFLAGS="-arch arm64" python -m pip install -v ".[test,doc]" pip install "gtdraw @ git+https://github.com/gambitproject/gtdraw.git@main" - name: Run tests diff --git a/doc/developer.build.rst b/doc/developer.build.rst index 487cb3590..72e444f10 100644 --- a/doc/developer.build.rst +++ b/doc/developer.build.rst @@ -206,6 +206,14 @@ Use `pip` to install from the **root directory of the source tree**: draw game trees in the tutorials and the games catalog, is installed separately rather than as part of the "doc" extra, since it is not needed by the installed `pygambit` package itself. +.. note:: + + `pygambit` depends on `pycddlib `_ for exact + linear programming. On Linux and macOS, `pip` builds pycddlib from source, which + requires the cddlib and GMP development libraries + (e.g. ``sudo apt install libcdd-dev libgmp-dev`` on Debian/Ubuntu, or + ``brew install cddlib gmp`` on macOS). + Once installed, simply ``import pygambit`` in your Python shell or script to get started. diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 5f026f6ad..c4c57f5ad 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -115,6 +115,7 @@ Information about the game Game.is_const_sum Game.is_perfect_recall Game.has_perfect_recall + Game.is_degenerate Game.players Game.min_payoff Game.max_payoff diff --git a/pyproject.toml b/pyproject.toml index 1836ece1d..13554ea4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "scipy", "pandas", "click", + "pycddlib>=3.0", ] [project.scripts] diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 925256eae..4b7d3e241 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -986,3 +986,157 @@ class Game: for old, new in remap.items(): c_labels[old.encode("utf-8")] = new.encode("utf-8") self.game.deref().RelabelPlayers(c_labels) + + def is_degenerate(self, certificate=False): + """Check whether this two-player game is degenerate. + + A two-player game is *nondegenerate* if no mixed strategy of support + size ``k`` has more than ``k`` pure best responses. A game that + violates this is *degenerate*; for example, a pure strategy with two + pure best responses. + + The check is exact: payoffs are treated as rational numbers and each + candidate is tested with exact linear programming (pycddlib), so ties + are detected exactly rather than up to a numerical tolerance. The + search is exhaustive over supports, so its running time grows + exponentially with the number of strategies. + + Parameters + ---------- + certificate : bool, default False + If True, also return a witness when the game is degenerate. + + Returns + ------- + bool or tuple + Whether the game is degenerate. If ``certificate=True``, returns + ``(is_degenerate, witness)``. When the game is degenerate, + ``witness`` is ``(player, strategy)``, where ``player`` is 0 (row) + or 1 (column) and ``strategy`` is a numpy array of `Rational` + probabilities: a mixed strategy of that player against which the + opponent has more pure best responses than the size of the + strategy's support. + When the game is nondegenerate, ``witness`` is None. + + Raises + ------ + NotImplementedError + If the game does not have exactly two players. + + Notes + ----- + For each support size ``k``, each candidate support ``S`` (size ``k``) + of one player and each candidate set ``T`` (size ``k + 1``) of the + opponent's strategies, a linear feasibility problem asks whether some + mixed strategy on ``S`` makes every strategy in ``T`` a best response: + the opponent's payoffs are equal across ``T`` and no greater outside + ``T``. The game is degenerate if and only if one of these problems is + feasible. + + Examples + -------- + >>> import pygambit as gbt + >>> g = gbt.StrategicGame.from_arrays([[3, 3], [2, 5], [0, 6]], + ... [[3, 3], [2, 6], [3, 1]]) + >>> g.is_degenerate() + True + + >>> g = gbt.StrategicGame.from_arrays([[1, -1], [-1, 1]], [[-1, 1], [1, -1]]) + >>> g.is_degenerate() + False + + >>> g = gbt.StrategicGame.from_arrays([[2, 1], [1, 1]], [[1, 1], [1, 2]]) + >>> g.is_degenerate(certificate=True) + (True, (0, array([Rational(1, 1), Rational(0, 1)], dtype=object))) + + A degenerate game in which no pure strategy has tied best responses; + only a mixed strategy reveals it: + + >>> g = gbt.StrategicGame.from_arrays([[1, 2, 3], [6, 5, 4]], + ... [[2, 0, 1], [0, 2, 1]]) + >>> g.is_degenerate(certificate=True) + (True, (0, array([Rational(1, 2), Rational(1, 2)], dtype=object))) + """ + if len(self.players) != 2: + raise NotImplementedError( + "is_degenerate() is only implemented for two-player games" + ) + + import cdd + import cdd.gmp + + M1, M2 = self.to_arrays() + m, n = M1.shape + zero, one = Rational(0), Rational(1) + + for player, payoff_matrix in ((0, M2), (1, M1.T)): + for i, payoffs in enumerate(payoff_matrix): + if np.count_nonzero(payoffs == max(payoffs)) > 1: + if not certificate: + return True + strategy = np.full(payoff_matrix.shape[0], zero, dtype=object) + strategy[i] = one + return True, (player, strategy) + + def tied_best_response_strategy(M, S, T): + """Return a mixed strategy on rows ``S`` of ``M`` against which every + column in ``T`` is a best response, or None if there is none.""" + k = len(S) + sub = M[S, :] + ref = T[0] + others = [j for j in range(M.shape[1]) if j not in T] + + # In cdd's format, a row [b, a_1, ..., a_k] means b + a.x >= 0, + # or b + a.x == 0 if its index is in the linearity set. + rows, equalities = [], [] + for t in T[1:]: + equalities.append(len(rows)) + rows.append([zero] + list(sub[:, ref] - sub[:, t])) + equalities.append(len(rows)) + rows.append([one] + [-one] * k) + for j in others: + rows.append([zero] + list(sub[:, ref] - sub[:, j])) + for r in range(k): + rows.append([zero] + [one if c == r else zero for c in range(k)]) + + mat = cdd.gmp.matrix_from_array( + rows, + lin_set=frozenset(equalities), + rep_type=cdd.RepType.INEQUALITY, + obj_type=cdd.LPObjType.MAX, + obj_func=[zero] * (k + 1), + ) + lp = cdd.gmp.linprog_from_matrix(mat) + cdd.gmp.linprog_solve(lp) + + if lp.status == cdd.LPStatusType.OPTIMAL: + x = np.full(M.shape[0], zero, dtype=object) + x[list(S)] = [Rational(v) for v in lp.primal_solution] + return x + if lp.status in (cdd.LPStatusType.INCONSISTENT, + cdd.LPStatusType.STRUC_INCONSISTENT): + return None + # The feasible region is bounded and the objective is zero, + # so no other status should occur. + raise RuntimeError(f"unexpected LP status {lp.status!r}") + + for k in range(2, min(m, n) + 1): + # Player 0 mixes over k rows; can player 1 have k + 1 best responses? + for S in itertools.combinations(range(m), k): + for T in itertools.combinations(range(n), k + 1): + strategy = tied_best_response_strategy(M2, S, T) + if strategy is not None: + if not certificate: + return True + return True, (0, strategy) + + # Player 1 mixes over k columns; can player 0 have k + 1 best responses? + for S in itertools.combinations(range(n), k): + for T in itertools.combinations(range(m), k + 1): + strategy = tied_best_response_strategy(M1.T, S, T) + if strategy is not None: + if not certificate: + return True + return True, (1, strategy) + + return (False, None) if certificate else False diff --git a/tests/test_degeneracy.py b/tests/test_degeneracy.py new file mode 100644 index 000000000..afd6f7db2 --- /dev/null +++ b/tests/test_degeneracy.py @@ -0,0 +1,224 @@ +import dataclasses +import types + +import cdd +import cdd.gmp +import numpy as np +import pytest + +import pygambit as gbt + +from . import games + +# Payoff difference far below double precision: float(1 + EPS) == 1.0, so an +# implementation using floating point would see a tie where there is none. +EPS = gbt.Rational(1, 10**20) + + +def _assert_valid_witness(game: gbt.Game, player: int, strategy): + """Check, in exact arithmetic, that the opponent has more pure best responses + to `strategy` than the size of its support.""" + A, B = game.to_arrays() + responder = B if player == 0 else A.T + assert isinstance(strategy, np.ndarray) + assert all(isinstance(p, gbt.Rational) for p in strategy) + assert len(strategy) == responder.shape[0] + assert all(p >= 0 for p in strategy) + assert sum(strategy) == 1 + payoffs = strategy @ responder + best_responses = np.count_nonzero(payoffs == np.max(payoffs)) + support_size = np.count_nonzero(strategy > 0) + assert best_responses > support_size + + +@pytest.mark.parametrize("game,expected_result", [ + # Nondegenerate games + pytest.param(gbt.StrategicGame.from_arrays([[1, -1], [-1, 1]], [[-1, 1], [1, -1]]), + False, id="matching_pennies"), + pytest.param(gbt.StrategicGame.from_arrays([[2, 5], [0, 4]], [[2, 0], [5, 4]]), + False, id="prisoners_dilemma"), + pytest.param(gbt.StrategicGame.from_arrays([[0, -1, 1], [1, 0, -1], [-1, 1, 0]], + [[0, 1, -1], [-1, 0, 1], [1, -1, 0]]), + False, id="rock_paper_scissors"), + pytest.param(gbt.catalog.load("journals/other/shapley1974/fig2"), + False, id="shapley1974_fig2"), + pytest.param(gbt.catalog.load("journals/other/shapley1974/fig3"), + False, id="shapley1974_fig3"), + pytest.param(gbt.catalog.load("journals/dcg/vonstengel1999/6x6_game_with_75_eq"), + False, id="vonstengel1999_6x6"), + pytest.param(games.read_from_file("coordination_4x4_payoff.nfg"), + False, id="coordination_4x4"), + pytest.param(gbt.StrategicGame.from_arrays([[1, 2, 3]], [[1, 2, 3]]), + False, id="single_row_no_ties"), + # Payoffs differ by EPS, so these are not ties in exact arithmetic + pytest.param(gbt.StrategicGame.from_arrays([[1, 0], [0, 1]], [[1, 1 + EPS], [1 + EPS, 1]]), + False, id="near_tie_pure"), + pytest.param(gbt.StrategicGame.from_arrays([[1, 2, 3], [6, 5, 4]], + [[2, 0, 1 - EPS], [0, 2, 1 - EPS]]), + False, id="near_tie_mixed"), + + # Degenerate games: a pure strategy has more than one pure best response + pytest.param(gbt.StrategicGame.from_arrays([[3, 3], [2, 5], [0, 6]], + [[3, 3], [2, 6], [3, 1]]), + True, id="avis_rosenberg_savani_vonstengel2010"), + pytest.param(gbt.StrategicGame.from_arrays([[0, 6], [2, 5], [3, 3]], + [[1, 0], [0, 2], [4, 4]]), + True, id="nisan_roughgarden_tardos_vazirani2007"), + pytest.param(gbt.StrategicGame.from_arrays([[2, 1], [1, 1]], [[1, 1], [1, 2]]), + True, id="sage_2x2"), + pytest.param(gbt.StrategicGame.from_arrays([[0, -1, 1, 1, -1], [1, 0, -1, -1, 1], + [-1, 1, 0, 1, -1], [-1, 1, -1, 0, 1], + [1, -1, 1, -1, 0]], + [[0, 1, -1, -1, 1], [-1, 0, 1, 1, -1], + [1, -1, 0, -1, 1], [1, -1, 1, 0, -1], + [-1, 1, -1, 1, 0]]), + True, id="rock_paper_scissors_lizard_spock"), + pytest.param(gbt.StrategicGame.from_arrays([[0, 0, 0]] * 3, [[0, 0, 0]] * 3), + True, id="all_zero_3x3"), + pytest.param(gbt.StrategicGame.from_arrays([[-75, 18, 45, 33], [42, -8, -77, -18], + [83, 18, 11, 40], [-10, -38, 76, -9]], + [[62, 64, 87, 51], [-41, -27, -69, 52], + [-17, 25, -97, -82], [30, 31, -1, 50]]), + True, id="sage_4x4_column_ties_only"), + pytest.param(gbt.StrategicGame.from_arrays([[1, 2, 3]], [[3, 3, 1]]), + True, id="single_row_tie"), + pytest.param(gbt.catalog.load("journals/ijgt/selten1975/fig2"), + True, id="selten1975_fig2_extensive"), + # Degenerate games: only a mixed strategy has too many best responses + pytest.param(gbt.StrategicGame.from_arrays([[1, 2, 3], [6, 5, 4]], + [[2, 0, 1], [0, 2, 1]]), + True, id="mixed_row_player"), + pytest.param(gbt.StrategicGame.from_arrays([[3, 0], [0, 3], + [gbt.Rational(3, 2), gbt.Rational(3, 2)]], + [[4, 3], [2, 6], [3, 1]]), + True, id="mixed_column_player"), + pytest.param(gbt.StrategicGame.from_arrays([[1, 2, 3], [4, 6, 5], [9, 8, 7]], + [[5, 0, 1], [2, 0, 1], [0, 2, 1]]), + True, id="mixed_after_infeasible_supports"), +]) +def test_is_degenerate(game: gbt.Game, expected_result: bool): + """ + Verify is_degenerate against degenerate and nondegenerate games, with and + without a certificate, checking any witness in exact arithmetic. + """ + assert game.is_degenerate() is expected_result + result, witness = game.is_degenerate(certificate=True) + assert result is expected_result + if expected_result: + _assert_valid_witness(game, *witness) + else: + assert witness is None + + +@dataclasses.dataclass +class CertificateTestCase: + """TestCase for a game whose degeneracy has exactly one witness.""" + game: gbt.Game + expected_player: int + expected_strategy: list + + +CERTIFICATE_CASES = [ + pytest.param( + CertificateTestCase( + # Only the row player's single strategy has tied best responses, (3, 3). + game=gbt.StrategicGame.from_arrays([[1, 2, 3]], [[3, 3, 1]]), + expected_player=0, + expected_strategy=[1], + ), + id="pure_row_player" + ), + pytest.param( + CertificateTestCase( + # Only the first column gives the row player tied payoffs, (5, 5). + game=gbt.StrategicGame.from_arrays([[5, 0], [5, 2]], [[1, 2], [3, 4]]), + expected_player=1, + expected_strategy=[1, 0], + ), + id="pure_column_player" + ), + pytest.param( + CertificateTestCase( + # Mixing (x, 1 - x) gives the column player (2x, 2 - 2x, 1): all equal only + # at x = 1/2. With two rows, the column player cannot have a witness. + game=gbt.StrategicGame.from_arrays([[1, 2, 3], [6, 5, 4]], + [[2, 0, 1], [0, 2, 1]]), + expected_player=0, + expected_strategy=[gbt.Rational(1, 2), gbt.Rational(1, 2)], + ), + id="mixed_row_player" + ), + pytest.param( + CertificateTestCase( + # Mixing (y, 1 - y) gives the row player (3y, 3 - 3y, 3/2): all equal only + # at y = 1/2. With two columns, the row player cannot have a witness. + game=gbt.StrategicGame.from_arrays( + [[3, 0], [0, 3], [gbt.Rational(3, 2), gbt.Rational(3, 2)]], + [[4, 3], [2, 6], [3, 1]], + ), + expected_player=1, + expected_strategy=[gbt.Rational(1, 2), gbt.Rational(1, 2)], + ), + id="mixed_column_player" + ), + pytest.param( + CertificateTestCase( + # Rows {1, 2}: columns 2 and 3 always pay 0 and 1, never tied. + # Rows {1, 3}: (5x, 2 - 2x, 1) are never all equal. + # Rows {2, 3}: (2x, 2 - 2x, 1) are all equal at x = 1/2. + game=gbt.StrategicGame.from_arrays( + [[1, 2, 3], [4, 6, 5], [9, 8, 7]], [[5, 0, 1], [2, 0, 1], [0, 2, 1]] + ), + expected_player=0, + expected_strategy=[0, gbt.Rational(1, 2), gbt.Rational(1, 2)], + ), + id="mixed_after_infeasible_supports" + ), +] + + +@pytest.mark.parametrize("test_case", CERTIFICATE_CASES) +def test_is_degenerate_certificate(test_case: CertificateTestCase): + """ + Verify the witness returned by is_degenerate(certificate=True) for games + in which it is unique. + """ + game = test_case.game + result, (player, strategy) = game.is_degenerate(certificate=True) + assert result is True + assert player == test_case.expected_player + assert list(strategy) == test_case.expected_strategy + _assert_valid_witness(game, player, strategy) + + +@pytest.mark.parametrize("strategies", [[3], [2, 2, 2]]) +def test_is_degenerate_rejects_non_two_player(strategies: list): + """is_degenerate is only defined for two-player games.""" + game = gbt.StrategicGame(strategies) + with pytest.raises(NotImplementedError, match="two-player"): + game.is_degenerate() + + +def _fake_lp_status(monkeypatch, status): + """Make every LP solved by cdd report `status`.""" + monkeypatch.setattr(cdd.gmp, "linprog_from_matrix", + lambda mat: types.SimpleNamespace(status=status)) + monkeypatch.setattr(cdd.gmp, "linprog_solve", lambda lp: None) + + +def _rock_paper_scissors() -> gbt.Game: + return gbt.StrategicGame.from_arrays([[0, -1, 1], [1, 0, -1], [-1, 1, 0]], + [[0, 1, -1], [-1, 0, 1], [1, -1, 0]]) + + +def test_is_degenerate_struc_inconsistent_lp(monkeypatch): + """An LP that cdd reports as structurally inconsistent has no witness.""" + _fake_lp_status(monkeypatch, cdd.LPStatusType.STRUC_INCONSISTENT) + assert _rock_paper_scissors().is_degenerate(certificate=True) == (False, None) + + +def test_is_degenerate_unexpected_lp_status(monkeypatch): + """An LP status other than optimal or infeasible is an error.""" + _fake_lp_status(monkeypatch, cdd.LPStatusType.UNBOUNDED) + with pytest.raises(RuntimeError, match="unexpected LP status"): + _rock_paper_scissors().is_degenerate()