From 66505ec57c8f08c70d2f4990fcbfca6f685a3b5e Mon Sep 17 00:00:00 2001 From: Konstantinos Varsos Date: Mon, 9 Oct 2023 14:40:31 +0100 Subject: [PATCH] Check shape (nested tuple player, infoset, action) --- src/pygambit/game.pxi | 297 ++++++++++++++---------------------------- 1 file changed, 98 insertions(+), 199 deletions(-) diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index 3a200a69f..f76d6ddee 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -48,11 +48,7 @@ class Outcomes(Collection): reason='Use Game.add_outcome() instead of Game.outcomes.add()', category=FutureWarning) def add(self, label="") -> Outcome: - """Add a new outcome to the game. - - .. deprecated:: 16.1.0 - Use `Game.add_outcome` instead of `Outcomes.add`. - """ + """Add a new outcome to the game.""" c = Outcome() c.outcome = self.game.deref().NewOutcome() if label != "": @@ -150,8 +146,7 @@ class GameStrategies(Collection): @cython.cclass class Game: - """A game, the fundamental unit of analysis in game theory. - + """Represents a game, the fundamental concept in game theory. Games may be represented in extensive or strategic form. """ game = cython.declare(c_Game) @@ -160,11 +155,11 @@ class Game: def new_tree(cls, players: typing.Optional[typing.List[str]] = None, title: str = "Untitled extensive game") -> Game: - """Create a new ``Game`` consisting of a trivial game tree, + """Creates a new Game consisting of a trivial game tree, with one node, which is both root and terminal. .. versionchanged:: 16.1.0 - Added the `players` and `title` parameters + Added the `players` and `title`` Parameters ---------- @@ -193,10 +188,10 @@ class Game: @classmethod def new_table(cls, dim, title: str="Untitled strategic game") -> Game: - """Create a new ``Game`` with a strategic representation. + """Creates a new Game with a strategic representation. .. versionchanged:: 16.1.0 - Added the `title` parameter. + Added the ``title`` parameter. Parameters ---------- @@ -224,15 +219,15 @@ class Game: @classmethod def from_arrays(cls, *arrays, title: str="Untitled strategic game") -> Game: - """Create a new ``Game`` with a strategic representation. + """Creates a new Game with a strategic representation. - Each entry in `arrays` gives the payoff matrix for the + Each entry in ``arrays`` gives the payoff matrix for the corresponding player. The arrays must all have the same shape, and have the same number of dimensions as the total number of players. .. versionchanged:: 16.1.0 - Added the `title` parameter. + Added the ``title`` parameter. Parameters ---------- @@ -246,10 +241,6 @@ class Game: ------- Game The newly-created strategic game. - - See Also - -------- - from_dict : Create strategic game and set player labels """ g = cython.declare(Game) arrays = [np.array(a) for a in arrays] @@ -259,16 +250,16 @@ class Game: for profile in itertools.product( *(range(arrays[0].shape[i]) for i in range(len(g.players))) ): - for pl, player in enumerate(g.players): - g[profile][player] = arrays[pl][profile] + for pl in range(len(g.players)): + g[profile][pl] = arrays[pl][profile] g.title = title return g @classmethod def from_dict(cls, payoffs, title: str="Untitled strategic game") -> Game: - """Create a new ``Game`` with a strategic representation. + """Creates a new Game with a strategic representation. - Each entry in `payoffs` is a key-value pair + Each entry in ``payoffs`` is a key-value pair giving the label and the payoff matrix for a player. The payoff matrices must all have the same shape, and have the same number of dimensions as the total number of @@ -286,10 +277,6 @@ class Game: ------- Game The newly-created strategic game. - - See Also - -------- - from_arrays : Create game from list-like of array-like """ g = cython.declare(Game) payoffs = {k: np.array(v) for k, v in payoffs.items()} @@ -310,7 +297,7 @@ class Game: @classmethod def read_game(cls, filepath: typing.Union[str, pathlib.Path]) -> Game: - """Construct a game from its serialised representation in a file. + """Constructs a game from its serialised representation in a file. Parameters ---------- @@ -345,8 +332,8 @@ class Game: @classmethod def parse_game(cls, text: str) -> Game: - """Construct a game from its serialised representation in a string - + """Constructs a game from its serialised representation in a string + . Parameters ---------- text : str @@ -389,21 +376,21 @@ class Game: def __eq__(self, other: typing.Any) -> bool: return isinstance(other, Game) and self.game.deref() == cython.cast(Game, other).game.deref() + def __ne__(self, other: typing.Any) -> bool: + return not isinstance(other, Game) or self.game.deref() != cython.cast(Game, other).game.deref() + def __hash__(self) -> int: return cython.cast(cython.long, self.game.deref()) @property def is_tree(self) -> bool: - """Return whether a game has a tree-based representation.""" + """Returns whether a game has a tree-based representation.""" return self.game.deref().IsTree() @property def title(self) -> str: - """Get or set the title of the game. - - The title of the game is an arbitrary string, generally intended - to be short. - """ + """Gets or sets the title of the game. The title of the game is + an arbitrary string, generally intended to be short.""" return self.game.deref().GetTitle().decode('ascii') @title.setter @@ -412,11 +399,8 @@ class Game: @property def comment(self) -> str: - """Get or set the comment of the game. - - A game's comment is an arbitrary string, and may be more discursive - than a title. - """ + """Gets or sets the comment of the game. A game's comment is + an arbitrary string, and may be more discursive than a title.""" return self.game.deref().GetComment().decode('ascii') @comment.setter @@ -425,7 +409,7 @@ class Game: @property def actions(self) -> GameActions: - """The set of actions available in the game. + """Return the set of actions available in the game. Raises ------ @@ -440,7 +424,7 @@ class Game: @property def infosets(self) -> GameInfosets: - """The set of information sets in the game. + """Return the set of information sets in the game. Raises ------ @@ -455,33 +439,33 @@ class Game: @property def players(self) -> Players: - """The set of players in the game.""" + """Return the set of players in the game.""" p = Players() p.game = self.game return p @property def strategies(self) -> GameStrategies: - """The set of strategies in the game.""" + """Return the set of strategies in the game.""" s = GameStrategies() s.game = self.game return s @property def outcomes(self) -> Outcomes: - """The set of outcomes in the game.""" + """Return the set of outcomes in the game.""" c = Outcomes() c.game = self.game return c @property def contingencies(self) -> pygambit.gameiter.Contingencies: - """An iterator over the contingencies in the game.""" + """Return an iterator over the contingencies in the game.""" return pygambit.gameiter.Contingencies(self) @property def root(self) -> Node: - """The root node of the game. + """Returns the root node of the game. Raises ------ @@ -489,21 +473,19 @@ class Game: If the game does not hae a tree representation. """ if not self.is_tree: - raise UndefinedOperationError( - "root: only games with a tree representation have a root node" - ) + raise UndefinedOperationError("Operation only defined for games with a tree representation") n = Node() n.node = self.game.deref().GetRoot() return n @property def is_const_sum(self) -> bool: - """Whether the game is constant sum.""" + """Returns whether the game is constant sum.""" return self.game.deref().IsConstSum() @property def is_perfect_recall(self) -> bool: - """Whether the game is perfect recall. + """Returns whether the game is perfect recall. By convention, games with a strategic representation have perfect recall as they are treated as simultaneous-move games. @@ -512,12 +494,12 @@ class Game: @property def min_payoff(self) -> typing.Union[decimal.Decimal, Rational]: - """The minimum payoff in the game.""" + """Returns the minimum payoff in the game.""" return rat_to_py(self.game.deref().GetMinPayoff(0)) @property def max_payoff(self) -> typing.Union[decimal.Decimal, Rational]: - """The maximum payoff in the game.""" + """Returns the maximum payoff in the game.""" return rat_to_py(self.game.deref().GetMaxPayoff(0)) def set_chance_probs(self, infoset: typing.Union[Infoset, str], probs: typing.Sequence): @@ -542,7 +524,7 @@ class Game: If the length of `probs` is not the same as the number of actions at the information set ValueError If any of the elements of `probs` are not interpretable as numbers, or the values of `probs` are not - non-negative numbers that sum to exactly one. + nonnegative numbers that sum to exactly one. """ infoset = self._resolve_infoset(infoset, 'set_chance_probs') if not infoset.is_chance: @@ -608,11 +590,10 @@ class Game: return self._get_contingency(*tuple(cont)) def mixed_strategy_profile(self, data=None, rational=False) -> MixedStrategyProfile: - """Create a mixed strategy profile over the game. - - If `data` is not specified, the mixed + """Returns a mixed strategy profile `MixedStrategyProfile` + over the game. If ``data`` is not specified, the mixed strategy profile is initialized to uniform randomization for each - player over their strategies. If the game has a tree + player over his strategies. If the game has a tree representation, the mixed strategy profile is defined over the reduced strategic form representation. @@ -624,7 +605,7 @@ class Game: specifying the probabilities of the strategies. rational - If True, probabilities are represented using rational numbers; + If `True`, probabilities are represented using rational numbers; otherwise double-precision floating point numbers are used. """ if not self.is_perfect_recall: @@ -675,16 +656,16 @@ class Game: def mixed_behavior_profile(self, data=None, rational=False) -> MixedBehaviorProfile: """Returns a behavior strategy profile `MixedBehaviorProfile` over the game, initialized to uniform randomization for each player over his actions at each - information set. If ``data`` is not specified, the mixed strategy profile is - initialized to uniform randomization for each player over his actions. + information set. If ``data`` is not specified, the mixed behavior profile is + initialized to uniform randomization for each player over his actions. Parameters ---------- - data + data A nested list (or compatible type) with the same dimension as the action set of the game, specifying the probabilities of the strategies. - rational + rational If `True`, probabilities are represented using rational numbers; otherwise double-precision floating point numbers are used. @@ -699,66 +680,65 @@ class Game: mbpd.profile = make_shared[c_MixedBehaviorProfileDouble](self.game) if data is None: return mbpd - if len(data) != len(self.nodes): - raise ValueError( - "Number of elements does not match number of nodess" - ) - for (n, d) in zip(self.nodes, data): - if len(n.actions) != len(d): - raise ValueError( + if len(data) != len(self.players): + raise ValueError( + "Number of elements does not match number of players" + ) + for (p, d) in zip(self.players, data): + if len(d) != len(p.infoset): + raise ValueError( f"Number of elements does not match number of " - f"actions for {n}" - ) - for (s, v) in zip(n.actions, d): - mbpd[s] = float(v) + f"information sets for {p}" + ) + for (w, u) in zip(p.infoset, d): + if len(w.actions) != len(u): + raise ValueError( + f"Number of elements does not match number of " + f"actions for player {p} in information set {w}" + ) + for (a, v) in zip(w.actions, u): + mbpd[a] = float(v) return mbpd else: mbpr = MixedBehaviorProfileRational() mbpr.profile = make_shared[c_MixedBehaviorProfileRational](self.game) + if data is None: + return mbpr + if len(data) != len(self.players): + raise ValueError( + "Number of elements does not much number of players " + ) + for (p, d) in zip(self.players, data): + if len(d) != len(p.infoset): + raise ValueError( + f"Number of elements does not match number of " + f"information sets for {p}" + ) + for (w, u) in zip(p.infoset, d): + if len(w.actions) != len(u): + raise ValueError( + f"Number of elements does not match number of " + f"actions for player {p} in information set {w}" + ) + for (a, v) in zip(w.actions, u): + mbpr[a] = Rational(v) return mbpr else: raise UndefinedOperationError( "Game must have a tree representation to create a mixed behavior profile" ) + def support_profile(self): return StrategySupportProfile(list(self.strategies), self) - def nodes( - self, - subtree: typing.Optional[typing.Union[Node, str]] = None - ) -> typing.List[Node]: - """Return a list of nodes in the game tree. If `subtree` is not None, returns - the nodes in the subtree rooted at that node. - - Nodes are returned in prefix-traversal order: a node appears prior to the list of - nodes in the subtrees rooted at the node's children. - - Parameters - ---------- - subtree : Node or str, optional - If specified, return only the nodes in the subtree rooted at `subtree`. - - Raises - ------ - MismatchError - If `node` is a `Node` from a different game. - """ - if not self.is_tree: - return [] - if subtree: - resolved_node = cython.cast(Node, self._resolve_node(subtree, 'nodes', 'subtree')) - else: - resolved_node = self.root - return ( - [resolved_node] + - [n for child in resolved_node.children for n in self.nodes(child)] - ) + def num_nodes(self): + if self.is_tree: + return self.game.deref().NumNodes() + return 0 def write(self, format='native') -> str: - """Produce a serialization of the game. - - Several output formats are + """Returns a serialization of the game. Several output formats are supported, depending on the representation of the game. * `efg`: A representation of the game in @@ -776,7 +756,7 @@ class Game: This method also supports exporting to other output formats (which cannot be used directly to re-load the game later, but are suitable for human consumption, inclusion in papers, and so - on). + on): * `html`: A rendering of the strategic form of the game as a collection of HTML tables. The first player is the row @@ -1065,7 +1045,7 @@ class Game: resolved_node.node.deref().InsertMove(resolved_infoset.infoset) def copy_tree(self, src: typing.Union[Node, str], dest: typing.Union[Node, str]) -> None: - """Copy the subtree rooted at 'src' to 'dest'. + """Copies the subtree rooted at 'src' to 'dest'. Parameters ---------- @@ -1088,7 +1068,7 @@ class Game: resolved_src.node.deref().CopyTree(resolved_dest.node) def move_tree(self, src: typing.Union[Node, str], dest: typing.Union[Node, str]) -> None: - """Move the subtree rooted at 'src' to 'dest'. + """Moves the subtree rooted at 'src' to 'dest'. Parameters ---------- @@ -1131,7 +1111,7 @@ class Game: resolved_node.node.deref().DeleteParent() def delete_tree(self, node: typing.Union[Node, str]) -> None: - """Truncate the game tree at `node`, deleting the subtree beneath it. + """Truncates the game tree at `node`, deleting the subtree beneath it. Parameters ---------- @@ -1147,58 +1127,8 @@ class Game: resolved_node = cython.cast(Node, self._resolve_node(node, 'delete_tree')) resolved_node.node.deref().DeleteTree() - def add_action(self, - infoset: typing.Union[typing.Infoset, str], - before: typing.Optional[typing.Union[Action, str]] = None) -> None: - """Add an action at the information set `infoset`. If `before` is not null, the new action - is inserted before `before`. - - Parameters - ---------- - infoset : Infoset or str - The information set at which to add an action - before : Action or str, optional - The action before which to add the new action. If `before` is not specified, - the new action is the first at the information set - - Raises - ------ - MismatchError - If `infoset` is an `Infoset` from a different game, `before` is an `Action` - from a different game, or `before` is not an action at `infoset`. - """ - resolved_infoset = cython.cast(Infoset, self._resolve_infoset(infoset, 'add_action')) - if before is None: - resolved_infoset.infoset.deref().InsertAction(cython.cast(c_GameAction, NULL)) - else: - resolved_action = cython.cast( - Action, self._resolve_action(before, 'add_action', 'before') - ) - if resolved_infoset != resolved_action.infoset: - raise MismatchError("add_action(): must specify an action from the same infoset") - resolved_infoset.infoset.deref().InsertAction(resolved_action.action) - - - def delete_action(self, action: typing.Union[Action, str]) -> None: - """Deletes `action` from its information set. The subtrees which - are rooted at nodes that follow the deleted action are also deleted. - - Raises - ------ - UndefinedOperationError - If `action` is the only action at its information set. - MismatchError - If `action` is an `Action` from a different game. - """ - resolved_action = cython.cast(Action, self._resolve_action(action, 'delete_action')) - if len(resolved_action.infoset.actions) == 1: - raise UndefinedOperationError( - "delete_action(): cannot delete the only action at an information set" - ) - resolved_action.action.deref().DeleteAction() - def leave_infoset(self, node: typing.Union[Node, str]): - """Remove this node from its information set. If this node is the only node + """Removes this node from its information set. If this node is the only node in its information set, this operation has no effect. Parameters @@ -1210,7 +1140,7 @@ class Game: resolved_node.node.deref().LeaveInfoset() def set_infoset(self, node: typing.Union[Node, str], infoset: typing.Union[Infoset, str]) -> None: - """Place `node` in the information set `infoset`. `node` must have the same + """Places `node` in the information set `infoset`. `node` must have the same number of descendants as `infoset` has actions. Parameters @@ -1234,37 +1164,8 @@ class Game: ) resolved_node.node.deref().SetInfoset(resolved_infoset.infoset) - def reveal(self, - infoset: typing.Union[Infoset, str], - player: typing.Union[Player, str]) -> None: - """Reveals the move made at `infoset` to `player`. - - Revealing the move modifies all subsequent information sets for `player` such - that any two nodes which are successors of two different actions at this - information set are placed in different information sets for `player`. - - Revelation is a one-shot operation; it is not enforced with respect to any - revisions made to the game tree subsequently. - - Parameters - ---------- - infoset : Infoset or str - The information set of the move to reveal to the player - player : Player or str - The player to which to reveal the move at this information set. - - Raises - ------ - MismatchError - If `infoset` is an `Infoset` from a different game, or - `player` is a `Player` from a different game. - """ - resolved_infoset = cython.cast(Infoset, self._resolve_infoset(infoset, 'reveal')) - resolved_player = cython.cast(Player, self._resolve_player(player, 'reveal')) - resolved_infoset.deref().Reveal(resolved_player) - def add_player(self, label: str = "") -> Player: - """Add a new player to the game. + """Adds a new player to the game. Parameters ---------- @@ -1336,13 +1237,11 @@ class Game: if str(label) != "": c.label = str(label) for player, payoff in zip(self.players, payoffs): - c[player] = payoff + c[player.number] = payoff return c def delete_outcome(self, outcome: typing.Union[Outcome, str]) -> None: - """Delete an outcome from the game. - - If this game is an extensive game, any + """Deletes an outcome from the game. If this game is an extensive game, any node at which this outcome is attached has its outcome reset to null. If this game is a strategic game, any contingency at which this outcome is attached as its outcome reset to null. @@ -1362,7 +1261,7 @@ class Game: def set_outcome(self, node: typing.Union[Node, str], outcome: typing.Optional[typing.Union[Outcome, str]]) -> None: - """Set `outcome` to be the outcome at `node`. If `outcome` is None, the + """Sets `outcome` to be the outcome at `node`. If `outcome` is None, the outcome at `node` is unset. Parameters