Skip to content

Commit

Permalink
Refine implementation of label indexing on game collection objects.
Browse files Browse the repository at this point in the history
* In the case where multiple game objects in the same scope (actions, strategies, etc.) had the same label,
  indexing on collections by label was silently returning the first match.
  This changes indexing to match conventions used elsewhere in label resolution, and further
  raises an exception on multiple matches.
* Failing to match on a string index raises a KeyError instead of an IndexError; this is more Pythonic.
  • Loading branch information
tturocy committed Jan 10, 2024
1 parent c44c595 commit 2b5dc4c
Show file tree
Hide file tree
Showing 14 changed files with 515 additions and 190 deletions.
6 changes: 6 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@
of both methods (#1)
- Fixed bug in gambit-lp which would return non-Nash output on extensive games if the game had chance nodes
other than the root node (#134)
- In pygambit, fixed indexing in mixed behavior and mixed strategy profiles, which could result
in strategies or actions belonging to other players or information sets being referenced when
indexing by string label

### Changed
- In pygambit, resolving game objects with ambiguous or duplicated labels results in a ValueError,
instead of silently returning the first matching object found.


## [16.1.0] - 2023-11-09
Expand Down
2 changes: 2 additions & 0 deletions doc/pygambit.api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ Information about the game
Player.label
Player.number
Player.game
Player.strategies
Player.infosets
Player.actions
Player.is_chance
Player.min_payoff
Player.max_payoff
Expand Down
43 changes: 37 additions & 6 deletions doc/pygambit.user.rst
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ by a collection of payoff tables, one per player. The most direct way to create
a strategic game is via :py:meth:`.Game.from_arrays`. This function takes one
n-dimensional array per player, where n is the number of players in the game.
The arrays can be any object that can be indexed like an n-times-nested Python list;
so, for example, NumPy arrays can be used directly.
so, for example, `numpy` arrays can be used directly.

For example, to create a standard prisoner's dilemma game in which the cooperative
payoff is 8, the betrayal payoff is 10, the sucker payoff is 2, and the noncooperative
Expand Down Expand Up @@ -349,25 +349,56 @@ the extensive representation. Assuming that ``g`` refers to the game
len(eqa)
The result of the calculation is a list of :py:class:`~pygambit.gambit.MixedBehaviorProfile`.
A mixed behavior profile specifies, for each information set, the probability distribution over
actions at that information set.
A mixed behavior profile is a ``dict``-like object which specifies, for each information set,
the probability distribution over actions at that information set, conditional on the
information set being reached.
Indexing a :py:class:`.MixedBehaviorProfile` by a player gives the probability distributions
over each of that player's information sets:


.. ipython:: python
eqa[0]["Alice"]
In this case, at Alice's first information set, the one at which she has the King, she always raises.
At her second information set, where she has the Queen, she sometimes bluffs, raising with
probability one-third. Looking at Bob's strategy,
probability one-third:

.. ipython:: python
[eqa[0]["Alice"][infoset]["Raise"] for infoset in g.players["Alice"].infosets]
In larger games, labels may not always be the most convenient way to refer to specific
actions. We can also index profiles directly with :py:class:`.Action` objects.
So an alternative way to extract the probabilities of playing "Raise" would be by
iterating Alice's list of actions:

.. ipython:: python
[eqa[0][action] for action in g.players["Alice"].actions if action.label == "Raise"]
Looking at Bob's strategy,

.. ipython:: python
eqa[0]["Bob"]
Bob meets Alice's raise two-thirds of the time.
Bob meets Alice's raise two-thirds of the time. The label "Raise" is used in more than one
information set for Alice, so in the above we had to specify information sets when indexing.
When there is no ambiguity, we can specify action labels directly. So for example, because
Bob has only one action named "Meet" in the game, we can extract the probability that Bob plays
"Meet" by:

.. ipython:: python
eqa[0]["Bob"]["Meet"]
Moreover, this is the only action with that label in the game, so we can index the
profile directly using the action label without any ambiguity:

.. ipython:: python
eqa[0]["Meet"]
Because this is an equilibrium, the fact that Bob randomizes at his information set must mean he
is indifferent between the two actions at his information set. :py:meth:`.MixedBehaviorProfile.action_value`
Expand Down
112 changes: 94 additions & 18 deletions src/pygambit/behav.pxi
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,30 @@ class MixedAgentStrategy:
return len(self.infoset.actions)

def __getitem__(self, action: typing.Union[Action, str]):
if isinstance(action, Action) and action.infoset != self.infoset:
raise MismatchError("action must belong to this infoset")
return self.profile[action]
if isinstance(action, Action):
if action.infoset != self.infoset:
raise MismatchError("action must belong to this infoset")
return self.profile._getprob_action(action)
if isinstance(action, str):
try:
return self.profile._getprob_action(self.infoset.actions[action])
except KeyError:
raise KeyError(f"no action with label '{index}' at infoset") from None
raise TypeError(f"strategy index must be Action or str, not {index.__class__.__name__}")

def __setitem__(self, action: typing.Union[Action, str], value: typing.Any) -> None:
if isinstance(action, Action) and action.infoset != self.infoset:
raise MismatchError("action must belong to this infoset")
self.profile[action] = value
if isinstance(action, Action):
if action.infoset != self.infoset:
raise MismatchError("action must belong to this infoset")
self.profile._setprob_action(action, value)
return
if isinstance(action, str):
try:
self.profile._setprob_action(self.infoset.actions[action], value)
return
except KeyError:
raise KeyError(f"no action with label '{index}' at infoset") from None
raise TypeError(f"strategy index must be Action or str, not {index.__class__.__name__}")


class MixedBehaviorStrategy:
Expand Down Expand Up @@ -92,20 +108,74 @@ class MixedBehaviorStrategy:
def __len__(self) -> int:
return len(self.player.infosets)

def __getitem__(self, infoset: typing.Union[Infoset, str]):
if isinstance(infoset, Infoset) and infoset.player != self.player:
raise MismatchError("infoset must belong to this player")
return self.profile[infoset]
def __getitem__(self, index: typing.Union[Infoset, Action, str]):
if isinstance(index, Infoset):
if index.player != self.player:
raise MismatchError("infoset must belong to this player")
return self.profile[index]
if isinstance(index, Action):
if index.player != self.player:
raise MismatchError("action must belong to this player")
return self.profile[index]
if isinstance(index, str):
try:
return self.profile[self.player.infosets[index]]
except KeyError:
pass
try:
return self.profile[self.player.actions[index]]
except KeyError:
raise KeyError(f"no infoset or action with label '{index}' for player") from None
raise TypeError(f"strategy index must be Infoset, Action or str, not {index.__class__.__name__}")

def __setitem__(self, infoset: typing.Union[Infoset, str], value: typing.Any) -> None:
if isinstance(infoset, Infoset) and infoset.player != self.player:
raise MismatchError("infoset must belong to this player")
self.profile[infoset] = value
def __setitem__(self, index: typing.Union[Infoset, Action, str], value: typing.Any) -> None:
if isinstance(index, Infoset):
if index.player != self.player:
raise MismatchError("infoset must belong to this player")
self.profile[index] = value
return
if isinstance(index, Action):
if index.player != self.player:
raise MismatchError("action must belong to this player")
self.profile[index] = value
return
if isinstance(index, str):
try:
self.profile[self.player.infosets[index]] = value
return
except KeyError:
pass
try:
self.profile[self.player.actions[index]] = value
except KeyError:
raise KeyError(f"no infoset or action with label '{index}' for player") from None
return
raise TypeError(f"strategy index must be Infoset, Action or str, not {index.__class__.__name__}")


@cython.cclass
class MixedBehaviorProfile:
"""A behavior strategy profile over the actions in a game."""
"""Represents a mixed behavior profile over the actions in a ``Game``.
A mixed behavior profile is a dict-like object, mapping each action at each information
set in a game to the corresponding probability with which the action is played, conditional
on that information set being reached.
Mixed behavior profiles may represent probabilities as either exact (rational)
numbers, or floating-point numbers. These may not be combined in the same mixed
behavior profile.
.. versionchanged:: 16.1.0
Profiles are accessed as dict-like objects; indexing by integer player, infoset, or
action indices is no longer supported.
See Also
--------
Game.mixed_behavior_profile
Creates a new mixed behavior profile on a game.
MixedStrategyProfile
Represents a mixed strategy profile over a ``Game``.
"""
def __repr__(self) -> str:
return str([self[player] for player in self.game.players])

Expand All @@ -118,7 +188,7 @@ class MixedBehaviorProfile:

@property
def game(self) -> Game:
"""The game on which this mixed behaviour profile is defined."""
"""The game on which this mixed behavior profile is defined."""
return self._game

def __getitem__(self, index: typing.Union[Player, Infoset, Action, str]):
Expand Down Expand Up @@ -163,7 +233,10 @@ class MixedBehaviorProfile:
return MixedAgentStrategy(self, self.game._resolve_infoset(index, '__getitem__'))
except KeyError:
pass
return self._getprob_action(self.game._resolve_action(index, '__getitem__'))
try:
return self._getprob_action(self.game._resolve_action(index, '__getitem__'))
except KeyError:
raise KeyError(f"no player, infoset, or action with label '{index}'")
raise TypeError(
f"profile index must be Player, Infoset, Action, or str, not {index.__class__.__name__}"
)
Expand Down Expand Up @@ -229,7 +302,10 @@ class MixedBehaviorProfile:
return
except KeyError:
pass
self._setprob_action(self.game._resolve_action(index, '__getitem__'), value)
try:
self._setprob_action(self.game._resolve_action(index, '__getitem__'), value)
except KeyError:
raise KeyError(f"no player, infoset, or action with label '{index}'")
return
raise TypeError(
f"profile index must be Player, Infoset, Action, or str, not {index.__class__.__name__}"
Expand Down
16 changes: 0 additions & 16 deletions src/pygambit/gambit.pyx
Original file line number Diff line number Diff line change
Expand Up @@ -76,22 +76,6 @@ def _to_number(value: typing.Any) -> c_Number:
return c_Number(value.encode('ascii'))


@cython.cclass
class Collection:
"""Represents a collection of related objects in a game."""
def __repr__(self):
return str(list(self))

def __getitem__(self, i):
if isinstance(i, str):
try:
return self[[x.label for x in self].index(i)]
except ValueError:
raise IndexError(f"no object with label '{i}'")
else:
raise TypeError(f"collection indexes must be int or str, not {i.__class__.__name__}")


######################
# Includes
######################
Expand Down
Loading

0 comments on commit 2b5dc4c

Please sign in to comment.