diff --git a/ChangeLog b/ChangeLog index 19e30ae56..bbb878fb0 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,5 +1,9 @@ ## [17.0.0] - unreleased +### Added +- Added `Game.make_chance_event`, which forms a collection of nodes into a single chance event with a + specified probability distribution over its actions. (#1009) + ### Changed - `lcp_solve` no longer silently absorbs internal exceptions if they occur, but instead these propagate out to match the behaviour of all other Nash equilibrium solvers. (#996) @@ -12,6 +16,10 @@ - Converting a behavior profile to a strategy profile, and computing pure-strategy payoffs no longer inserts spurious entries into a strategy's internal action map. +### Removed +- `Game.set_chance_probs` has been removed; use `Game.make_chance_event`, which expresses a change of + probabilities as forming the nodes of the event with the required distribution. (#1009) + ## [16.7.0] - 2026-07-11 ### Added diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index e475a689a..23e716ec8 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -71,7 +71,7 @@ Transforming game information structure Game.set_player Game.set_infoset Game.leave_infoset - Game.set_chance_probs + Game.make_chance_event Game.reveal Game.sort_infosets diff --git a/doc/tutorials/03_stripped_down_poker.ipynb b/doc/tutorials/03_stripped_down_poker.ipynb index 7f47f183f..d1c39dbdf 100644 --- a/doc/tutorials/03_stripped_down_poker.ipynb +++ b/doc/tutorials/03_stripped_down_poker.ipynb @@ -933,7 +933,7 @@ "source": [ "## Representation of numerical data of a game\n", "\n", - "Payoffs to players and probabilities of actions at chance information sets are specified as numbers.\n", + "Payoffs to players and probabilities of actions at chance events are specified as numbers.\n", "Gambit represents the numerical values in a game in exact precision, using either decimal or rational representations.\n", "\n", "To illustrate, consider a trivial game which just has one move for the chance player:" @@ -959,7 +959,9 @@ "The default when creating a new move for chance is that all actions are chosen with equal probability.\n", "These probabilities are represented as rational numbers, using `pygambit`'s `Rational` class, which is derived from Python's `fractions.Fraction`.\n", "\n", - "Numerical data can be set as rational numbers. Here we update the chance action probabilities with `Rational` numbers:" + "Numerical data can be set as rational numbers.\n", + "`make_chance_event` forms a collection of nodes into a chance event with a given distribution; applied to a single node which is already a chance move, it simply sets the probabilities.\n", + "Here we set them with `Rational` numbers:" ] }, { @@ -969,8 +971,8 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.set_chance_probs(\n", - " small_game.root.infoset,\n", + "small_game.make_chance_event(\n", + " [small_game.root],\n", " [gbt.Rational(1, 4), gbt.Rational(1, 2), gbt.Rational(1, 4)]\n", ")\n", "[act.prob for act in small_game.root.infoset.actions]" @@ -991,8 +993,8 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.set_chance_probs(\n", - " small_game.root.infoset,\n", + "small_game.make_chance_event(\n", + " [small_game.root],\n", " [gbt.Decimal(\".25\"), gbt.Decimal(\".50\"), gbt.Decimal(\".25\")]\n", ")\n", "[act.prob for act in small_game.root.infoset.actions]" @@ -1017,7 +1019,7 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.set_chance_probs(small_game.root.infoset, [\"1/4\", \"1/2\", \"1/4\"])\n", + "small_game.make_chance_event([small_game.root], [\"1/4\", \"1/2\", \"1/4\"])\n", "[act.prob for act in small_game.root.infoset.actions]" ] }, @@ -1028,7 +1030,7 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.set_chance_probs(small_game.root.infoset, [\".25\", \".50\", \".25\"])\n", + "small_game.make_chance_event([small_game.root], [\".25\", \".50\", \".25\"])\n", "[act.prob for act in small_game.root.infoset.actions]" ] }, @@ -1052,7 +1054,7 @@ "metadata": {}, "outputs": [], "source": [ - "small_game.set_chance_probs(small_game.root.infoset, [.25, .50, .25])\n", + "small_game.make_chance_event([small_game.root], [.25, .50, .25])\n", "[act.prob for act in small_game.root.infoset.actions]" ] }, @@ -1072,7 +1074,7 @@ "outputs": [], "source": [ "try:\n", - " small_game.set_chance_probs(small_game.root.infoset, [1/3, 1/3, 1/3])\n", + " small_game.make_chance_event([small_game.root], [1/3, 1/3, 1/3])\n", "except ValueError as e:\n", " print(\"ValueError:\", e)\n" ] diff --git a/src/games/game.h b/src/games/game.h index bd598b723..824aa3bf8 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -476,6 +476,11 @@ class GamePlayerRep : public std::enable_shared_from_this { GameInfoset GetInfoset(int p_index) const; /// Returns the information sets for the player Infosets GetInfosets() const; + /// Validate that p_label is a valid label for an information set of this player, + /// disregarding any information sets in p_ignore. + void CheckInfosetLabel(const std::string &p_label, + const std::set &p_ignore) const; + //@} /// @name Strategies //@{ @@ -666,7 +671,7 @@ inline GameNodeRep::Actions::iterator::iterator(GameInfosetRep::Actions::iterato inline GameNode GameNodeRep::Actions::iterator::GetOwner() const { return m_child_it.GetOwner(); } -inline void ValidateDistribution(const Array &p_probs, const bool p_normalized = true) +template void ValidateDistribution(const C &p_probs, const bool p_normalized = true) { if (std::any_of(p_probs.begin(), p_probs.end(), [](const Number &x) { return static_cast(x) < Rational(0); })) { @@ -1260,6 +1265,14 @@ class GameRep : public std::enable_shared_from_this { //@{ /// Set the probability distribution of actions at a chance node virtual Game SetChanceProbs(const GameInfoset &, const Array &) = 0; + /// Form the collection of nodes into a single chance event carrying the given + /// probability distribution over its actions. The nodes need not currently be + /// chance nodes; personal decision nodes are converted. + virtual GameInfoset MakeChanceEvent(const std::vector &, const std::vector &, + const std::string &) + { + throw UndefinedException(); + } //@} /// Build any computed values anew @@ -1338,6 +1351,22 @@ inline void GamePlayerRep::CheckStrategyLabel(const std::string &p_label) const } } +inline void +GamePlayerRep::CheckInfosetLabel(const std::string &p_label, + const std::set &p_ignore) const +{ + CheckLabel(p_label); + // Infoset labels may be empty; a nonempty label must be unique among the infosets of the player. + if (p_label.empty()) { + return; + } + for (const auto &infoset : m_infosets) { + if (p_ignore.count(infoset.get()) == 0 && infoset->GetLabel() == p_label) { + throw ValueException("Infoset label must be unique for the player"); + } + } +} + inline Game GameSequenceRep::GetGame() const { return m_player->GetGame(); } inline GamePlayer GameSequenceRep::GetPlayer() const { return m_player->shared_from_this(); } @@ -1350,16 +1379,7 @@ inline void GameInfosetRep::SetLabel(const std::string &p_label) if (p_label == m_label) { return; } - CheckLabel(p_label); - // Infoset labels may be empty, but a non-empty label must be unique among - // the infosets of the same player. - if (!p_label.empty()) { - for (const auto &infoset : GetPlayer()->GetInfosets()) { - if (infoset.get() != this && infoset->GetLabel() == p_label) { - throw ValueException("Infoset label must be unique for the player"); - } - } - } + m_player->CheckInfosetLabel(p_label, {this}); m_label = p_label; } inline void GameRep::CheckPlayerLabel(const std::string &p_label) const diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 84d6ec0fd..66e5ffef9 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -1631,6 +1631,79 @@ Game GameTreeRep::SetChanceProbs(const GameInfoset &p_infoset, const Array &p_nodes, + const std::vector &p_probs, + const std::string &p_label) +{ + if (p_nodes.empty()) { + throw ValueException("At least one node must be specified"); + } + std::set selected; + for (const auto &node : p_nodes) { + if (node->m_game != this) { + throw MismatchException(); + } + if (node->IsTerminal()) { + throw UndefinedException("All nodes must be decision nodes"); + } + if (!selected.insert(node.get()).second) { + throw ValueException("Each node may be referenced only once"); + } + } + // The first member defines the action labels and their order of the new chance event. + const auto &reference = p_nodes.front()->m_infoset->m_actions; + for (const auto &node : p_nodes) { + const auto &actions = node->m_infoset->m_actions; + if (!std::equal( + actions.begin(), actions.end(), reference.begin(), reference.end(), + [](const std::shared_ptr &a, const std::shared_ptr &b) { + return a->GetLabel() == b->GetLabel(); + })) { + throw ValueException( + "All nodes must have the same actions, with the same labels in the same order"); + } + } + if (p_probs.size() != reference.size()) { + throw DimensionException("The number of probabilities given must match the number of actions"); + } + ValidateDistribution(p_probs); + const GamePlayer chance = GetChance(); + // Destroy a chance event all of whose members are absorbed; its label can then be reused. + std::set absorbed; + if (!p_label.empty()) { + for (const auto &infoset : chance->m_infosets) { + if (infoset->GetLabel() == p_label && + std::all_of(infoset->m_members.begin(), infoset->m_members.end(), + [&selected](const std::shared_ptr &m) { + return contains(selected, m.get()); + })) { + absorbed.insert(infoset.get()); + } + } + } + chance->CheckInfosetLabel(p_label, absorbed); + + IncrementVersion(); + auto newEvent = std::make_shared(this, chance->m_infosets.size() + 1, + chance.get(), reference.size()); + auto dest = newEvent->m_actions.begin(); + for (const auto &action : reference) { + (*dest)->SetLabel(action->GetLabel()); + ++dest; + } + std::copy(p_probs.begin(), p_probs.end(), newEvent->m_probs.begin()); + chance->m_infosets.push_back(newEvent); + for (const auto &node : p_nodes) { + RemoveMember(node->m_infoset, node.get()); + newEvent->m_members.push_back(node); + node->m_infoset = newEvent.get(); + } + newEvent->SetLabel(p_label); + ClearComputedValues(); + InvalidateInfosetOrdering(); + return newEvent; +} + Game GameTreeRep::NormalizeChanceProbs(GameInfosetRep *p_infoset) { if (p_infoset->m_game != this) { diff --git a/src/games/gametree.h b/src/games/gametree.h index 1b2e15e9e..b72cc8c1d 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -190,6 +190,8 @@ class GameTreeRep final : public GameExplicitRep { void SetInfoset(GameNode, GameInfoset) override; GameInfoset LeaveInfoset(GameNode) override; Game SetChanceProbs(const GameInfoset &, const Array &) override; + GameInfoset MakeChanceEvent(const std::vector &, const std::vector &, + const std::string &) override; GameAction InsertAction(GameInfoset, GameAction p_where = nullptr) override; void DeleteAction(GameAction) override; void SetOutcome(const GameNode &p_node, const GameOutcome &p_outcome) override; diff --git a/src/pygambit/gambit.pxd b/src/pygambit/gambit.pxd index 71538a7fc..2ea3f35e7 100644 --- a/src/pygambit/gambit.pxd +++ b/src/pygambit/gambit.pxd @@ -370,6 +370,8 @@ cdef extern from "games/game.h": void DeleteAction(c_GameAction) except +ValueError void SetOutcome(c_GameNode, c_GameOutcome) except + c_Game SetChanceProbs(c_GameInfoset, Array[c_Number]) except + + c_GameInfoset MakeChanceEvent(stdvector[c_GameNode], stdvector[c_Number], + string) except +ValueError c_PureStrategyProfile NewPureStrategyProfile() # except + doesn't compile c_MixedStrategyProfile[T] NewMixedStrategyProfile[T](T) # except + doesn't compile diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index f95d47aba..97cb4c592 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -951,48 +951,6 @@ class Game: self.game.deref().GetMinimalSubgame(cython.cast(Infoset, resolved_infoset).infoset) ) - def set_chance_probs(self, infoset: Infoset | str, probs: typing.Sequence): - """Set the action probabilities at chance information set `infoset`. - - Parameters - ---------- - infoset : Infoset or str - The chance information set at which to set the action probabilities. - If a string is passed, the information set is determined by finding the chance - information set with that label, if any. - probs : array-like - The action probabilities to set - - Raises - ------ - MismatchError - If `infoset` is not an information set in this game - UndefinedOperationError - If `infoset` is not an information set of the chance player - IndexError - 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. - """ - infoset = self._resolve_infoset(infoset, "set_chance_probs") - if not infoset.is_chance: - raise UndefinedOperationError( - "set_chance_probs() first argument must be a chance infoset" - ) - if len(infoset.actions) != len(probs): - raise IndexError("set_chance_probs(): must specify exactly one probability per action") - numbers = Array[c_Number](len(probs)) - for i in range(1, len(probs)+1): - setitem_array_number(numbers, i, _to_number(probs[i-1])) - try: - self.game.deref().SetChanceProbs(cython.cast(Infoset, infoset).infoset, numbers) - except RuntimeError: - raise ValueError( - "set_chance_probs(): must specify non-negative probabilities that sum to one" - ) from None - def _get_contingency(self, *args): psp: shared_ptr[c_PureStrategyProfile] = make_shared[c_PureStrategyProfile]( self.game.deref().NewPureStrategyProfile() @@ -1740,6 +1698,26 @@ class Game: f"{funcname}(): {argname} must be Action or str, not {action.__class__.__name__}" ) + def _resolve_probs(self, + probs: typing.Any, + 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 hasattr(probs, "keys"): + 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: Node | NodeReferenceSet, player: Player | str, actions: list[str]) -> None: @@ -1972,6 +1950,77 @@ class Game: ) self.game.deref().DeleteAction(resolved_action.action) + def make_chance_event(self, + nodes: Node | NodeReferenceSet, + probs: typing.Sequence | typing.Mapping, + label: str | None = None) -> None: + """Form `nodes` into a single chance event with distribution `probs`. + + The nodes must all be decision nodes of this game with the same actions, with the same + labels in the same order. They need not be chance nodes; personal decision nodes are + converted, and the move is thereafter resolved by chance. Nodes are removed from + whatever information sets or chance events they currently belong to; any of those which + retain members survive, keeping their labels, and those left with no members are deleted. + Any ``Infoset`` object, and any of its ``Action`` objects, referring to a deleted one + becomes invalid, and subsequent use raises ``RuntimeError``. + The resulting chance event is accessible as ``node.infoset`` for any node in `nodes`. + + The first node in `nodes` determines the action order of the event, + and is the frame against which mapping keys in `probs` are resolved. + + .. versionadded:: 17.0.0 + + Parameters + ---------- + nodes : Node or NodeReferenceSet + The nonempty set of decision nodes to place in the chance event. + probs : sequence or mapping + The probability distribution over the actions of the event. A sequence must specify + one probability per action, in action order. A mapping from action labels + to probabilities 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 chance event. If specified, must be unique among the chance + events of the game after the operation. A label currently held by another chance + event may be reused only if all members of that event are among `nodes`. + + Raises + ------ + MismatchError + If any of `nodes` is from a different game. + KeyError + If a node reference matches no node, or a key of `probs` matches no + action label of the event. + IndexError + If a sequence `probs` does not have exactly one entry per action. + UndefinedOperationError + If any of `nodes` is a terminal node, 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 chance events after the operation. + """ + if not self.is_tree: + raise UndefinedOperationError( + "make_chance_event(): operation only defined for games with a tree representation" + ) + resolved_nodes = self._resolve_nodes(nodes, "make_chance_event") + if any(n.is_terminal for n in resolved_nodes): + raise UndefinedOperationError( + "make_chance_event(): all nodes must be decision nodes" + ) + resolved_node = cython.cast(Node, resolved_nodes[0]) + action_labels = [a.label for a in resolved_node.infoset.actions] + resolved_probs = self._resolve_probs(probs, action_labels, "make_chance_event") + 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)) + self.game.deref().MakeChanceEvent(c_nodes, c_probs, (label or "").encode("ascii")) + def leave_infoset(self, node: Node | str): """Remove this node from its information set. If this node is the only node in its information set, this operation has no effect. diff --git a/tests/games.py b/tests/games.py index 9e73e32d2..074eb97ab 100644 --- a/tests/games.py +++ b/tests/games.py @@ -149,7 +149,7 @@ def deals_by_infoset(player, card): return [d for d in deals if d[player_idx] == card] g.append_move(g.root, g.players.chance, deals) - g.set_chance_probs(g.root.infoset, [gbt.Rational(1, 6)] * 6) + g.make_chance_event([g.root], [gbt.Rational(1, 6)] * 6) for alice_card in cards: # Alice's first move term_nodes = [g.root.children[d] for d in deals_by_infoset("Alice", alice_card)] diff --git a/tests/test_actions.py b/tests/test_actions.py index 28b61eeee..d52d716bd 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -42,55 +42,6 @@ def test_action_label_non_ascii_rejected(label: str): action.label = label -@pytest.mark.parametrize( - "game,inprobs,outprobs", - [ - (games.create_stripped_down_poker_efg(), [0.75, 0.25], [0.75, 0.25]), - ( - games.create_stripped_down_poker_efg(), - ["16/17", "1/17"], - [gbt.Rational("16/17"), gbt.Rational("1/17")], - ), - ], -) -def test_set_chance_valid_probability(game: gbt.Game, inprobs: list, outprobs: list): - game.set_chance_probs(game.root.infoset, inprobs) - for action, prob in zip(game.root.infoset.actions, outprobs, strict=True): - assert action.prob == prob - - -@pytest.mark.parametrize( - "game,inprobs", - [ - (games.create_stripped_down_poker_efg(), [0.75, -0.10]), - (games.create_stripped_down_poker_efg(), [0.75, 0.40]), - (games.create_stripped_down_poker_efg(), ["foo", "bar"]), - ], -) -def test_set_chance_improper_probability(game: gbt.Game, inprobs: list): - with pytest.raises(ValueError): - game.set_chance_probs(game.root.infoset, inprobs) - - -@pytest.mark.parametrize( - "game,inprobs", - [ - (games.create_stripped_down_poker_efg(), [0.25, 0.75, 0.25]), - (games.create_stripped_down_poker_efg(), [1.00]), - ], -) -def test_set_chance_bad_dimension(game: gbt.Game, inprobs: list): - with pytest.raises(IndexError): - game.set_chance_probs(game.root.infoset, inprobs) - - -@pytest.mark.parametrize("game", [games.create_stripped_down_poker_efg()]) -def test_set_chance_personal(game: gbt.Game): - with pytest.raises(gbt.UndefinedOperationError): - personal_infoset = next(iter(game.players["Alice"].infosets)) - game.set_chance_probs(personal_infoset, [0.75, 0.25]) - - @pytest.mark.parametrize("game", [games.create_stripped_down_poker_efg()]) def test_action_precedes(game: gbt.Game): child = game.root.children["King"] diff --git a/tests/test_infosets.py b/tests/test_infosets.py index fb4ff58fe..338366b0d 100644 --- a/tests/test_infosets.py +++ b/tests/test_infosets.py @@ -104,6 +104,73 @@ def test_infoset_plays(): assert set(test_infoset.plays) == expected_set_of_plays +@pytest.mark.parametrize( + "inprobs,outprobs", + [ + (["1/4", "3/4"], [gbt.Rational("1/4"), gbt.Rational("3/4")]), + ([0.75, 0.25], [0.75, 0.25]), + ({"King": 1}, [1, 0]), + ], +) +def test_make_chance_event_sets_probabilities(inprobs, outprobs): + """Probabilities may be given positionally, or as a mapping in which omitted + actions are assigned zero. + """ + game = games.read_from_file("stripped_down_poker.efg") + game.make_chance_event([game.root], inprobs, "Deal") + for action, prob in zip(game.root.infoset.actions, outprobs, strict=True): + assert action.prob == prob + + +def test_make_chance_event_pools_nodes_from_different_infosets(): + """Nodes in distinct information sets are formed into a single chance event.""" + game = games.read_from_file("stripped_down_poker.efg") + nodes = [game.root.children["King"], game.root.children["Queen"]] + game.make_chance_event(nodes, ["1/4", "3/4"], "Coin") + assert nodes[0].infoset == nodes[1].infoset + assert nodes[0].infoset.is_chance + assert [a.prob for a in nodes[0].infoset.actions] == [gbt.Rational("1/4"), + gbt.Rational("3/4")] + assert not list(game.players["Alice"].infosets) + + +def test_make_chance_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") + alice_node = game.root.children["King"] # actions Bet, Fold + bob_node = alice_node.children["Bet"] # actions Call, Fold + with pytest.raises(ValueError): + game.make_chance_event([alice_node, bob_node], ["1/2", "1/2"]) + + +def test_make_chance_event_converts_personal_node(): + """A personal decision node becomes a chance node carrying the probabilities given.""" + game = games.read_from_file("stripped_down_poker.efg") + node = next(iter(game.players["Alice"].infosets["Alice has King"].members)) + game.make_chance_event([node], ["1/4", "3/4"]) + assert node.infoset.is_chance + assert [a.prob for a in node.infoset.actions] == [gbt.Rational("1/4"), + gbt.Rational("3/4")] + + +@pytest.mark.parametrize("probs", [["3/4", "-1/2"], [0.75, 0.40], ["foo", "bar"]]) +def test_make_chance_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_chance_event([game.root], probs) + + +@pytest.mark.parametrize( + "probs,error", + [(["1/2"], IndexError), (["1/3", "1/3", "1/3"], IndexError), ({"Jack": 1}, KeyError)], +) +def test_make_chance_event_malformed_probs_raises(probs, error): + game = games.read_from_file("stripped_down_poker.efg") + with pytest.raises(error): + game.make_chance_event([game.root], probs) + + @dataclasses.dataclass class PriorActionsTestCase: """TestCase for testing own_prior_actions."""