diff --git a/ChangeLog b/ChangeLog index e9e8e9d44..4a5812b4e 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,5 +1,11 @@ # Changelog +## [unreleased] - unreleased + +### Fixed +- When an action at a chance node is deleted the probabilities for the remaining actions are + normalized. + ## [16.1.0a4] - 2023-10-13 ### Changed diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 4f55ddd35..9773d16ff 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -62,6 +62,10 @@ void GameTreeActionRep::DeleteAction() member->children[where]->DeleteTree(); member->children.Remove(where)->Invalidate(); } + + if (m_infoset->IsChanceInfoset()) { + m_infoset->m_efg->NormalizeChanceProbs(m_infoset); + } m_infoset->m_efg->ClearComputedValues(); m_infoset->m_efg->Canonicalize(); } @@ -162,12 +166,12 @@ GameAction GameTreeInfosetRep::InsertAction(GameAction p_action /* =0 */) void GameTreeInfosetRep::RemoveAction(int which) { m_actions.Remove(which)->Invalidate(); - for (; which <= m_actions.Length(); which++) - m_actions[which]->m_number = which; - if (m_player->IsChance()) { m_probs.Remove(which); } + for (; which <= m_actions.Length(); which++) { + m_actions[which]->m_number = which; + } } void GameTreeInfosetRep::RemoveMember(GameTreeNodeRep *p_node) @@ -1054,6 +1058,34 @@ Game GameTreeRep::SetChanceProbs(const GameInfoset &p_infoset, const ArrayGetGame() != this) { + throw MismatchException(); + } + if (!m_infoset->IsChanceInfoset()) { + throw UndefinedException("Action probabilities can only be normalized for chance information sets"); + } + Rational sum(0); + for (int act = 1; act <= m_infoset->NumActions(); act++) { + Rational action_prob(m_infoset->GetActionProb(act)); + sum += action_prob; + } + Array m_probs(m_infoset->NumActions()); + if (sum == Rational(0)) { + // all remaining moves have prob zero; split prob 1 equally among them + for (int act = 1; act <= m_infoset->NumActions(); act++) { + m_probs[act] = Rational(1, m_infoset->NumActions()); + } + } + else { + for (int act = 1; act <= m_infoset->NumActions(); act++) { + Rational prob(m_infoset->GetActionProb(act)); + m_probs[act] = prob / sum; + } + } + m_infoset->GetGame()->SetChanceProbs(m_infoset, m_probs); + return this; +} //------------------------------------------------------------------------ // GameTreeRep: Factory functions diff --git a/src/games/gametree.h b/src/games/gametree.h index 21826e436..bbb835b04 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -202,6 +202,8 @@ class GameTreeRep : public GameExplicitRep { /// @name Private auxiliary functions //@{ void NumberNodes(GameTreeNodeRep *, int &); + /// Normalize the probability distribution of actions at a chance node + Game NormalizeChanceProbs(const GameInfoset &); //@} /// @name Managing the representation diff --git a/src/pygambit/behav.pxi b/src/pygambit/behav.pxi index f14d00fe2..e90e9b42e 100644 --- a/src/pygambit/behav.pxi +++ b/src/pygambit/behav.pxi @@ -255,7 +255,7 @@ class MixedBehaviorProfile: """ return self._is_defined_at(self.game._resolve_infoset(infoset, 'is_defined_at')) - def belief(self, node: Node): + def belief(self, node: typing.Union[Node, str]): """Returns the conditional probability that a node is reached, given that its information set is reached. @@ -271,7 +271,7 @@ class MixedBehaviorProfile: """ if node.game != self.game: raise MismatchError("belief: node must be part of the same game as the profile") - return self._belief(node) + return self._belief(self.game._resolve_node(node, 'belief')) def payoff(self, player: typing.Union[Player, str]): """Returns the expected payoff to a player if all players play diff --git a/src/pygambit/game.pxi b/src/pygambit/game.pxi index c7199ce5c..7c318c52c 100644 --- a/src/pygambit/game.pxi +++ b/src/pygambit/game.pxi @@ -1188,6 +1188,9 @@ class Game: 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. + If the action is at a chance node then the probabilities of any remaining actions + are normalized to sum to one; if all remaining actions previously had probability zero + then this normalization gives those remaining actions all equal probability. Raises ------ diff --git a/src/pygambit/tests/test_actions.py b/src/pygambit/tests/test_actions.py index 50070db3b..7136d3bae 100644 --- a/src/pygambit/tests/test_actions.py +++ b/src/pygambit/tests/test_actions.py @@ -96,3 +96,32 @@ def test_action_delete_last(game: gbt.Game): game.delete_action(node.infoset.actions[0]) with pytest.raises(gbt.UndefinedOperationError): game.delete_action(node.infoset.actions[0]) + + +@pytest.mark.parametrize( + "game", + [gbt.Game.read_game("test_games/chance_root_3_moves_only_one_nonzero_prob.efg"), + gbt.Game.read_game("test_games/complicated_extensive_game.efg"), + gbt.Game.read_game("test_games/chance_root_5_moves_no_nonterm_player_nodes.efg")] +) +def test_action_delete_chance(game: gbt.Game): + """Test the renormalization of action probabilities when an action is deleted at a chance + node + """ + chance_iset = game.players.chance.infosets[0] + while len(chance_iset.actions) > 1: + old_probs = [a.prob for a in chance_iset.actions] + game.delete_action(chance_iset.actions[0]) + new_probs = [a.prob for a in chance_iset.actions] + assert sum(new_probs) == 1 + if sum(old_probs[1:]) == 0: + for p in new_probs: + assert p == 1/len(new_probs) + else: + for p1, p2 in zip(old_probs[1:], new_probs): + if p1 == 0: + assert p2 == 0 + else: + assert p2 == p1 / (1-old_probs[0]) + with pytest.raises(gbt.UndefinedOperationError): + game.delete_action(chance_iset.actions[0]) diff --git a/src/pygambit/tests/test_games/chance_root_3_moves_only_one_nonzero_prob.efg b/src/pygambit/tests/test_games/chance_root_3_moves_only_one_nonzero_prob.efg new file mode 100644 index 000000000..8b0380795 --- /dev/null +++ b/src/pygambit/tests/test_games/chance_root_3_moves_only_one_nonzero_prob.efg @@ -0,0 +1,9 @@ +EFG 2 R "A chance node with 3 moves, 2 with zero prob; one non-terminal player node" { "Player" } +"" + +c "" 1 "(0,1)" { "A" 1 "B" 0 "C" 0 } 0 +p "" 1 1 "(1,1)" { "LEFT" "RIGHT" } 0 +t "" 1 "Outcome 1" { 0 } +t "" 2 "Outcome 2" { 1 } +t "" 3 "Outcome 3" { 2 } +t "" 4 "Outcome 4" { 3 } diff --git a/src/pygambit/tests/test_games/chance_root_5_moves_no_nonterm_player_nodes.efg b/src/pygambit/tests/test_games/chance_root_5_moves_no_nonterm_player_nodes.efg new file mode 100644 index 000000000..545761413 --- /dev/null +++ b/src/pygambit/tests/test_games/chance_root_5_moves_no_nonterm_player_nodes.efg @@ -0,0 +1,9 @@ +EFG 2 R "A chance node with 5 moves; no non-terminal player node" { "Player" } +"" + +c "" 1 "" { "A" 1/3 "B" 0 "C" 1/3 "D" 1/3 "E" 0 } 0 +t "" 1 "A" { 0 } +t "" 2 "B" { 1 } +t "" 3 "C" { 3 } +t "" 4 "D" { 3 } +t "" 5 "E" { 4 }