Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions .github/workflows/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions doc/developer.build.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://pypi.org/project/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.
1 change: 1 addition & 0 deletions doc/pygambit.api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ dependencies = [
"scipy",
"pandas",
"click",
"pycddlib>=3.0",
]

[project.scripts]
Expand Down
154 changes: 154 additions & 0 deletions src/pygambit/game.pxi
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Comment on lines +1125 to +1135
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
Loading
Loading