diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index 63d16b4e3f..60d204b498 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -797,7 +797,13 @@ "cell_type": "markdown", "id": "dc4522b5", "metadata": {}, - "source": "Here we specified an equal-probability distribution over the three actions, using `pygambit`'s `Rational` class, which is derived from Python's `fractions.Fraction`, to represent the probabilities exactly.\n\nNumerical data can be set as rational numbers.\n`make_event` forms a collection of nodes into an event with a given distribution; applied to a single node which is already an event, it resets the probabilities.\nLet's use it to change the distribution, again with `Rational` numbers:" + "source": [ + "Here we specified an equal-probability distribution over the three actions, using `pygambit`'s `Rational` class, which is derived from Python's `fractions.Fraction`, to represent the probabilities exactly.\n", + "\n", + "Numerical data can be set as rational numbers.\n", + "`set_event_actions` sets the actions and probability distribution of an existing event; since the action labels are unchanged here, it simply resets the probabilities.\n", + "Let's use it to change the distribution, again with `Rational` numbers:" + ] }, { "cell_type": "code", @@ -806,7 +812,7 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.make_event(\n", + "small_game.set_event_actions(\n", " gbt.H.path(),\n", " {\"a\": gbt.Rational(1, 4), \"b\": gbt.Rational(1, 2), \"c\": gbt.Rational(1, 4)}\n", ")\n", @@ -828,7 +834,7 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.make_event(\n", + "small_game.set_event_actions(\n", " gbt.H.path(),\n", " {\"a\": gbt.Decimal(\".25\"), \"b\": gbt.Decimal(\".50\"), \"c\": gbt.Decimal(\".25\")}\n", ")\n", @@ -854,7 +860,7 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.make_event(gbt.H.path(), {\"a\": \"1/4\", \"b\": \"1/2\", \"c\": \"1/4\"})\n", + "small_game.set_event_actions(gbt.H.path(), {\"a\": \"1/4\", \"b\": \"1/2\", \"c\": \"1/4\"})\n", "list(small_game.get_action_probs(gbt.H.path()).values())" ] }, @@ -865,7 +871,7 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.make_event(gbt.H.path(), {\"a\": \".25\", \"b\": \".50\", \"c\": \".25\"})\n", + "small_game.set_event_actions(gbt.H.path(), {\"a\": \".25\", \"b\": \".50\", \"c\": \".25\"})\n", "list(small_game.get_action_probs(gbt.H.path()).values())" ] }, @@ -889,7 +895,7 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.make_event(gbt.H.path(), {\"a\": .25, \"b\": .50, \"c\": .25})\n", + "small_game.set_event_actions(gbt.H.path(), {\"a\": .25, \"b\": .50, \"c\": .25})\n", "list(small_game.get_action_probs(gbt.H.path()).values())" ] }, @@ -909,7 +915,7 @@ "outputs": [], "source": [ "try:\n", - " small_game.make_event(gbt.H.path(), {\"a\": 1/3, \"b\": 1/3, \"c\": 1/3})\n", + " small_game.set_event_actions(gbt.H.path(), {\"a\": 1/3, \"b\": 1/3, \"c\": 1/3})\n", "except ValueError as e:\n", " print(\"ValueError:\", e)" ] diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index a96e5a20d6..dde535cd9c 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -746,7 +746,13 @@ class Game: f"{history.__class__.__name__}" ) resolved_node = self._resolve_node(history, "get_action_probs") - infoset_handle: c_GameInfoset = cython.cast(Node, resolved_node)._infoset_handle() + return self._action_probs(cython.cast(Node, resolved_node)) + + def _action_probs(self, node: Node) -> dict: + """Return the probability of each action at `node`'s event, keyed by + label, or an empty dict if `node` does not currently belong to a + chance event.""" + infoset_handle: c_GameInfoset = node._infoset_handle() if ( infoset_handle == cython.cast(c_GameInfoset, NULL) or not infoset_handle.deref().IsChanceInfoset() @@ -1892,26 +1898,6 @@ class Game: f"(the node is terminal)" ) - def _resolve_probs(self, - probs: typing.Sequence | typing.Mapping, - action_labels: list[str], - funcname: str) -> list: - """Resolve a probability specification against an ordered list of action labels. - - `probs` may be a sequence (positional; must have exactly one entry per action) - or a mapping from action labels to values (may be sparse; omitted labels are - assigned zero). Returns a dense list of values in action order. - """ - if isinstance(probs, typing.Mapping): - unknown = [k for k in probs if k not in action_labels] - if unknown: - raise KeyError(f"{funcname}(): no action with label '{unknown[0]}'") - return [probs.get(label, 0) for label in action_labels] - probs = list(probs) - if len(probs) != len(action_labels): - raise IndexError(f"{funcname}(): must specify exactly one probability per action") - return probs - def append_move(self, nodes: Selector | GroupedSelector, player: str, actions: list[str]) -> None: @@ -2565,25 +2551,26 @@ class Game: def make_event(self, nodes: Selector | GroupedSelector, - probs: typing.Mapping, label: str | None = None) -> None: - """Form `nodes` into a single event with distribution `probs`. - - `nodes` must all be nonterminal nodes of this game with the same actions, with the same - labels in the same order. They need not be chance nodes; personal nodes are - converted, and the move is thereafter resolved by chance. Nodes are removed from - whatever information sets or events they currently belong to; any of those which - retain members survive, keeping their labels, and those left with no members are deleted. - The resulting event's members, actions, and player are accessible via + """Form `nodes` into a single event. + + `nodes` must all be nonterminal nodes of this game currently belonging to a + chance event, with the same actions, with the same labels in the same order, + and the same probability distribution over those actions. Nodes are removed + from whatever events they currently belong to; any of those which retain + members survive, keeping their labels, and those left with no members are + deleted. The resulting event's members, actions, and player are accessible via ``Node.members``/``Node.actions``/``Node.player`` for any node in `nodes`. + To pool nodes whose actions or probabilities do not yet agree, first bring + them into conformance with `set_event_actions`. + `nodes` is a `Selector` (an `H`-built expression, evaluated against this game and treated as a flat set of nodes) or a `GroupedSelector` (an `H`-built `.by(...)` expression, whose groups are pooled together into the one event). Which resolved node is treated as "first", determining the action order of - the event and the frame against which keys of `probs` are resolved, follows - `nodes`' own resolution order. + the event, follows `nodes`' own resolution order. .. versionadded:: 17.0.0 @@ -2591,11 +2578,6 @@ class Game: ---------- nodes : Selector or GroupedSelector The nonempty set of nonterminal nodes to place in the event. - probs : Mapping - The probability distribution over the actions of the event, as a mapping - from action label to probability. May be sparse; omitted actions are - assigned probability zero. Probabilities are non-negative and sum to - exactly one. label : str, optional The label of the new event. If specified, must be unique among the events of the game after the operation. A label currently held by another event @@ -2604,17 +2586,19 @@ class Game: Raises ------ TypeError - If `nodes` is not a `Selector` or `GroupedSelector`, or `probs` is not a - mapping. - KeyError - If a key of `probs` matches no action label of the event. + If `nodes` is not a `Selector` or `GroupedSelector`. UndefinedOperationError - If any of `nodes` is a terminal node, or the game is not a tree. + If any of `nodes` is a terminal node, does not currently belong to a + chance event, or the game is not a tree. ValueError If `nodes` is empty or contains a repeated node; if the nodes do not - all have the same actions in the same order; if `probs` are not - non-negative numbers summing to exactly one; or if `label` is not - unique among the game's events after the operation. + all have the same actions in the same order, or the same probability + distribution over those actions; or if `label` is not unique among the + game's events after the operation. + + See Also + -------- + set_event_actions : Adjust an event's actions and probability distribution. """ if not self.is_tree: raise UndefinedOperationError( @@ -2627,10 +2611,6 @@ class Game: f"make_event(): nodes must be a Selector or GroupedSelector, " f"not {nodes.__class__.__name__}" ) - if not isinstance(probs, typing.Mapping): - raise TypeError( - f"make_event(): probs must be a mapping, not {probs.__class__.__name__}" - ) resolved_nodes = self._resolve_nodes(nodes, "make_event") if any(cython.cast(Node, n)._is_terminal() for n in resolved_nodes): raise UndefinedOperationError( @@ -2638,19 +2618,29 @@ class Game: ) resolved_node = cython.cast(Node, resolved_nodes[0]) action_labels = list(resolved_node.actions) - if any(list(n.actions) != action_labels - for n in resolved_nodes[1:]): - raise ValueError( - "make_event(): all nodes must have the same actions, " - "with the same labels in the same order" + reference_probs = self._action_probs(resolved_node) + if not reference_probs: + raise UndefinedOperationError( + "make_event(): all nodes must currently belong to a chance event" ) - resolved_probs = self._resolve_probs(probs, action_labels, "make_event") + for n in resolved_nodes[1:]: + node = cython.cast(Node, n) + if list(node.actions) != action_labels: + raise ValueError( + "make_event(): all nodes must have the same actions, " + "with the same labels in the same order" + ) + if self._action_probs(node) != reference_probs: + raise ValueError( + "make_event(): all nodes must have the same probability " + "distribution over their actions" + ) c_nodes = stdvector[c_GameNode]() for n in resolved_nodes: c_nodes.push_back(cython.cast(Node, n).node) c_probs = stdvector[c_Number]() - for p in resolved_probs: - c_probs.push_back(_to_number(p)) + for label_ in action_labels: + c_probs.push_back(_to_number(reference_probs[label_])) self.game.deref().MakeEvent(c_nodes, c_probs, (label or "").encode("utf-8")) def relabel_actions(self, diff --git a/tests/test_tree_mutations.py b/tests/test_tree_mutations.py index 812b62fe96..e593ec33f2 100644 --- a/tests/test_tree_mutations.py +++ b/tests/test_tree_mutations.py @@ -531,91 +531,83 @@ def test_set_move_actions_add_preserves_existing_action_order(): assert game.get_actions(gbt.H.path())[1:-1] == labels -@pytest.mark.parametrize( - "inprobs,outprobs", - [ - ({"King": "1/4", "Queen": "3/4"}, [gbt.Rational("1/4"), gbt.Rational("3/4")]), - ({"King": 0.75, "Queen": 0.25}, [0.75, 0.25]), - ({"King": 1}, [1, 0]), - ], -) -def test_make_event_sets_probabilities(inprobs, outprobs): - """Probabilities are given as a mapping from action label to probability, - which may be sparse: an omitted action is assigned probability zero. - """ - game = games.read_from_file("stripped_down_poker.efg") - game.make_event(gbt.H.path(), inprobs, "Deal") - probs = game.get_action_probs(gbt.H.path()) - for action, prob in zip(game.get_actions(gbt.H.path()), outprobs, strict=True): - assert probs[action] == prob - - -@pytest.mark.parametrize("probs", [["1/4", "3/4"], [0.75, 0.25]]) -def test_make_event_probs_not_a_mapping_raises_typeerror(probs): - """A positional sequence of probabilities is no longer accepted.""" - game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(TypeError): - game.make_event(gbt.H.path(), probs, "Deal") - - -def test_make_event_pools_nodes_from_different_infosets(): - """Nodes in distinct information sets are formed into a single event.""" - game = games.read_from_file("stripped_down_poker.efg") - king, queen = ("King",), ("Queen",) - game.make_event(gbt.H.path(...), {"Bet": "1/4", "Fold": "3/4"}, "Coin") - assert king in [m.actions for m in game.get_members(gbt.H.path(*queen))] - assert list(game.get_action_probs(gbt.H.path(*king)).values()) == [ - gbt.Rational("1/4"), gbt.Rational("3/4") +def _two_matching_events(probs_a="1/2", probs_b="1/2"): + """A tree with a personal move "a"/"b", each leading to its own chance event + over actions "x"/"y", with the given probability of "x" at each.""" + game = gbt.Game.new_tree(players=["1"]) + game.append_move(gbt.H.path(), "1", ["a", "b"]) + one = gbt.Rational(1) + game.append_event(gbt.H.path("a"), {"x": probs_a, "y": one - gbt.Rational(probs_a)}) + game.append_event(gbt.H.path("b"), {"x": probs_b, "y": one - gbt.Rational(probs_b)}) + return game + + +def test_make_event_pools_nodes_from_separate_events(): + """Nodes already belonging to distinct events, with the same actions and the + same probability distribution, are formed into a single event.""" + game = _two_matching_events() + a, b = ("a",), ("b",) + game.make_event( + gbt.H.after().filter(lambda h: h[:].actions in (a, b)), "Coin" + ) + assert b in [m.actions for m in game.get_members(gbt.H.path(*a))] + assert list(game.get_action_probs(gbt.H.path(*a)).values()) == [ + gbt.Rational("1/2"), gbt.Rational("1/2") ] - assert not game.get_infosets("Alice") def test_make_event_requires_matching_action_labels(): """Nodes must have the same actions, with the same labels in the same order.""" game = games.read_from_file("stripped_down_poker.efg") - # King node has actions Bet, Fold; its own Bet-child has actions Call, Fold. + # The root's actions are King, Queen; the King child's are Bet, Fold. with pytest.raises(ValueError): game.make_event( - gbt.H.after().filter(lambda h: h[:].actions in (("King",), ("King", "Bet"))), - {"Bet": "1/2", "Fold": "1/2"} + gbt.H.after().filter(lambda h: h[:].actions in ((), ("King",))) ) -def test_make_event_converts_personal_node(): - """A personal decision node becomes a chance node carrying the probabilities given.""" +def test_make_event_requires_matching_probabilities(): + """Nodes must have the same probability distribution over their actions, + not merely the same action labels.""" + game = _two_matching_events(probs_a="1/2", probs_b="1/4") + a, b = ("a",), ("b",) + with pytest.raises(ValueError): + game.make_event(gbt.H.after().filter(lambda h: h[:].actions in (a, b))) + + +def test_make_event_requires_chance_events(): + """A personal decision node is no longer converted to chance by `make_event`; + it must already belong to a chance event.""" game = games.read_from_file("stripped_down_poker.efg") - game.make_event(gbt.H.path("King"), {"Bet": "1/4", "Fold": "3/4"}) - assert game.get_player(gbt.H.path("King")) == "Chance" - assert list(game.get_action_probs(gbt.H.path("King")).values()) == [ - gbt.Rational("1/4"), gbt.Rational("3/4") - ] + with pytest.raises(gbt.UndefinedOperationError): + game.make_event(gbt.H.path("King")) def test_make_event_terminal_node_raises(): game = games.read_from_file("stripped_down_poker.efg") with pytest.raises(gbt.UndefinedOperationError): - game.make_event(gbt.H.path("King", "Fold"), {"a": "1/2", "b": "1/2"}) + game.make_event(gbt.H.path("King", "Fold")) def test_make_event_strategic_game_raises(): game = gbt.Game.new_table([2, 2]) with pytest.raises(gbt.UndefinedOperationError): - game.make_event(gbt.H.path(), {"a": 1}) + game.make_event(gbt.H.path()) def test_make_event_empty_nodes_raises(): game = games.read_from_file("stripped_down_poker.efg") with pytest.raises(ValueError): - game.make_event(gbt.H.path(...).filter(lambda h: False), {"a": "1/2", "b": "1/2"}) + game.make_event(gbt.H.path(...).filter(lambda h: False)) def test_make_event_label_held_by_rump_raises(): """A label may be reused only if all members of the event holding it are absorbed.""" - game = games.read_from_file("stripped_down_poker.efg") - game.make_event(gbt.H.path(...), {"Bet": "1/2", "Fold": "1/2"}, "Coin") + game = _two_matching_events() + game.make_event(gbt.H.path("a"), "Coin") before = game.to_efg() with pytest.raises(ValueError): - game.make_event(gbt.H.path("King"), {"Bet": "1/2", "Fold": "1/2"}, "Coin") + game.make_event(gbt.H.path("b"), "Coin") assert game.to_efg() == before @@ -623,32 +615,11 @@ def test_make_event_label_reused_when_fully_absorbed(): """A label held by an existing event may be reused once all of that event's members are absorbed into the new one; the old event is not left behind. """ - game = games.read_from_file("stripped_down_poker.efg") - king, queen = ("King",), ("Queen",) - game.make_event(gbt.H.path(...), {"Bet": "1/2", "Fold": "1/2"}, "Coin") - game.make_event(gbt.H.path(...), {"Bet": "1/4", "Fold": "3/4"}, "Coin") - assert king in [m.actions for m in game.get_members(gbt.H.path(*queen))] - assert list(game.get_action_probs(gbt.H.path(*king)).values()) == [ - gbt.Rational("1/4"), gbt.Rational("3/4") - ] - - -@pytest.mark.parametrize( - "probs", [{"King": "3/4", "Queen": "-1/2"}, {"King": 0.75, "Queen": 0.40}, - {"King": "foo", "Queen": "bar"}] -) -def test_make_event_invalid_probs_raises(probs): - """Values must be numbers, non-negative, and sum to exactly one.""" - game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(ValueError): - game.make_event(gbt.H.path(), probs) - - -def test_make_event_malformed_probs_raises(): - """An unknown action label as a mapping key raises KeyError.""" - game = games.read_from_file("stripped_down_poker.efg") - with pytest.raises(KeyError): - game.make_event(gbt.H.path(), {"Jack": 1}) + game = _two_matching_events() + a, b = ("a",), ("b",) + game.make_event(gbt.H.path("a"), "Coin") + game.make_event(gbt.H.after().filter(lambda h: h[:].actions in (a, b)), "Coin") + assert b in [m.actions for m in game.get_members(gbt.H.path(*a))] def _bagwell_p2_histories(game: gbt.Game) -> tuple[tuple, tuple, tuple, tuple]: