diff --git a/doc/references.bib b/doc/references.bib index 4c17e1cf8..f39c14834 100644 --- a/doc/references.bib +++ b/doc/references.bib @@ -38,6 +38,17 @@ @article{GovWil04 category = {articles_equilibria} } +@article{HalPas21, + author = {Halpern, J. Y. and Pass, R.}, + title = {Sequential equilibrium in games of imperfect recall}, + journal = {ACM Transactions on Economics and Computation}, + volume = {9}, + number = {4}, + pages = {1--26}, + year = {2021}, + category = {articles_general} +} + @article{Jiang11, author = {Jiang, A. X. and Leyton-Brown, K. and Bhat, N.}, title = {Action-graph games}, diff --git a/src/games/behavmixed.cc b/src/games/behavmixed.cc index 0887d1988..8ab01f2f5 100644 --- a/src/games/behavmixed.cc +++ b/src/games/behavmixed.cc @@ -284,8 +284,7 @@ template T MixedBehaviorProfile::GetInfosetProb(const GameInfoset & { CheckVersion(); EnsureRealizations(); - return sum_function(p_infoset->GetMembers(), - [&](const auto &node) -> T { return m_cache.m_realizProbs[node]; }); + return m_cache.m_infosetProbs[p_infoset]; } template @@ -475,6 +474,7 @@ T MixedBehaviorProfile::DiffNodeValue(const GameNode &p_node, const GamePlaye template void MixedBehaviorProfile::ComputeRealizationProbs() const { m_cache.m_realizProbs.clear(); + m_cache.m_infosetProbs.clear(); const auto &game = m_support.GetGame(); m_cache.m_realizProbs[game->GetRoot()] = static_cast(1); @@ -484,15 +484,29 @@ template void MixedBehaviorProfile::ComputeRealizationProbs() const m_cache.m_realizProbs[child] = incomingProb * GetActionProb(action); } } + + for (const auto &player : game->GetPlayersWithChance()) { + for (const auto &infoset : player->GetInfosets()) { + m_cache.m_infosetProbs[infoset] = + sum_function(infoset->GetMembers(), + [&](const auto &node) -> T { return m_cache.m_realizProbs[node]; }); + } + } + for (const auto &[infoset, node] : game->GetAbsentMindedReentries()) { + m_cache.m_infosetProbs[infoset] -= m_cache.m_realizProbs[node]; + } } template void MixedBehaviorProfile::ComputeBeliefs() const { m_cache.m_beliefs.clear(); - + // Normalise each member's realization probability by the infoset's upper-frontier probability + // (m_infosetProbs, computed in ComputeRealizationProbs), following Halpern and Pass (2021). + // For an absent-minded infoset the frontier excludes the reentry members, so the member beliefs + // may sum to above 1; for a non-absent-minded infoset the frontier is all members and this is + // the standard Selten (1975) normalization. for (const auto &infoset : m_support.GetGame()->GetInfosets()) { - const T infosetProb = sum_function( - infoset->GetMembers(), [&](const auto &node) -> T { return m_cache.m_realizProbs[node]; }); + const T infosetProb = m_cache.m_infosetProbs[infoset]; if (infosetProb == static_cast(0)) { continue; } diff --git a/src/games/behavmixed.h b/src/games/behavmixed.h index d535560c2..6827713ce 100644 --- a/src/games/behavmixed.h +++ b/src/games/behavmixed.h @@ -46,6 +46,7 @@ template class MixedBehaviorProfile { Level m_level{Level::None}; std::map m_realizProbs, m_beliefs; + std::map m_infosetProbs; std::map> m_nodeValues; std::map m_infosetValues; std::map m_actionValues; @@ -60,6 +61,7 @@ template class MixedBehaviorProfile { { m_level = Level::None; m_realizProbs.clear(); + m_infosetProbs.clear(); m_beliefs.clear(); m_nodeValues.clear(); m_infosetValues.clear(); @@ -72,10 +74,11 @@ template class MixedBehaviorProfile { /// @name Auxiliary functions for cached computation of interesting values //@{ - /// Compute the realisation probabilities of all nodes + /// Compute the realization probabilities of all nodes, and of information sets + /// (the probability a given information set is reached at least once) void ComputeRealizationProbs() const; - /// Compute the realisation probabilities of information sets, and beliefs at - /// information sets reached with positive probability + /// Compute beliefs (conditional reach probabilities) at information sets reached + /// with positive probability, normalized over each set's upper frontier void ComputeBeliefs() const; /// Compute the expected payoffs conditional on reaching each node void ComputeNodeValues() const; @@ -241,6 +244,10 @@ template class MixedBehaviorProfile { const T &GetRealizProb(const GameNode &node) const; T GetInfosetProb(const GameInfoset &p_infoset) const; + /// Returns the belief at a given non-terminal node: + /// the probability of reaching it conditional on reaching its infoset, + /// normalised over the infoset's upper frontier (Halpern and Pass, 2021). + /// Returns std::nullopt for a terminal node, or if the infoset is not reached. std::optional GetBeliefProb(const GameNode &node) const; Vector GetPayoff(const GameNode &node) const; const T &GetPayoff(const GamePlayer &p_player, const GameNode &p_node) const; diff --git a/src/games/game.h b/src/games/game.h index 5fa619e4a..7f9d5bedb 100644 --- a/src/games/game.h +++ b/src/games/game.h @@ -970,6 +970,11 @@ class GameRep : public std::enable_shared_from_this { } return false; } + /// Returns (infoset, node) pairs where the node is a reentry of an absent-minded infoset + virtual std::vector> GetAbsentMindedReentries() const + { + return {}; + } /// Returns a list of all subgame roots in the game virtual std::vector GetSubgames() const { throw UndefinedException(); } /// Returns the smallest subgame containing the information set diff --git a/src/games/gametree.cc b/src/games/gametree.cc index 149968d3e..94fad8ebb 100644 --- a/src/games/gametree.cc +++ b/src/games/gametree.cc @@ -848,6 +848,7 @@ Rational GameTreeRep::GetPlayerMaxPayoff(const GamePlayer &p_player) const return maximize_function(range, value_fn); }); } + bool GameTreeRep::IsPerfectRecall() const { if (!m_ownPriorActionInfo && !m_root->IsTerminal()) { @@ -868,11 +869,9 @@ bool GameTreeRep::IsAbsentMinded(const GameInfoset &p_infoset) const if (p_infoset->GetGame().get() != this) { throw MismatchException(); } - - if (!m_unreachableNodes && !m_root->IsTerminal()) { - BuildUnreachableNodes(); + if (!m_ownPriorActionInfo) { + BuildOwnPriorActions(); } - return contains(m_absentMindedInfosets, p_infoset.get()); } @@ -891,6 +890,23 @@ GameSubgame GameTreeRep::GetMinimalSubgame(const GameInfoset &p_infoset) const return it->second; } +std::vector> GameTreeRep::GetAbsentMindedReentries() const +{ + if (!m_ownPriorActionInfo) { + BuildOwnPriorActions(); + } + if (m_absentMindedReentries.empty()) { + return {}; + } + + std::vector> result; + result.reserve(m_absentMindedReentries.size()); + for (const auto &[infoset, node] : m_absentMindedReentries) { + result.emplace_back(infoset->shared_from_this(), node->shared_from_this()); + } + return result; +} + //------------------------------------------------------------------------ // GameTreeRep: Managing the representation //------------------------------------------------------------------------ @@ -957,6 +973,7 @@ void GameTreeRep::ClearComputedValues() const const_cast(this)->m_unreachableNodes = nullptr; m_absentMindedInfosets.clear(); m_subgameData.Invalidate(); + m_absentMindedReentries.clear(); m_computedValues = false; } @@ -1002,6 +1019,8 @@ void GameTreeRep::BuildOwnPriorActions() const { if (m_root->IsTerminal()) { m_ownPriorActionInfo = std::make_shared(); + m_absentMindedInfosets.clear(); + m_absentMindedReentries.clear(); return; } @@ -1009,6 +1028,13 @@ void GameTreeRep::BuildOwnPriorActions() const std::shared_ptr m_info; std::map> m_priorActions; + // A node is a re-entry of its information set iff an ancestor on the current + // root-to-node path shares that information set. m_pathMemberCount counts, per information + // set, how many nodes on the current path belong to it. + std::map m_pathMemberCount; + std::set m_absentMindedInfosets; + std::vector> m_absentMindedReentries; + explicit OwnPriorActionsVisitor(const GameTreeRep *p_game) : m_info(std::make_shared()) { @@ -1027,6 +1053,11 @@ void GameTreeRep::BuildOwnPriorActions() const m_info->infoset_map[infoset].insert(raw_prior); stack.emplace(nullptr); + + if (m_pathMemberCount[infoset]++ > 0) { + m_absentMindedInfosets.insert(infoset); + m_absentMindedReentries.emplace_back(infoset, p_node.get()); + } } return DFSCallbackResult::Continue; } @@ -1042,6 +1073,7 @@ void GameTreeRep::BuildOwnPriorActions() const { if (auto *infoset = p_node->m_infoset) { m_priorActions.at(infoset->m_player->shared_from_this()).pop(); + m_pathMemberCount[infoset]--; } return DFSCallbackResult::Continue; } @@ -1055,6 +1087,8 @@ void GameTreeRep::BuildOwnPriorActions() const visitor); m_ownPriorActionInfo = visitor.m_info; + m_absentMindedInfosets = std::move(visitor.m_absentMindedInfosets); + m_absentMindedReentries = std::move(visitor.m_absentMindedReentries); } GameAction GameTreeRep::GetOwnPriorAction(const GameNode &p_node) const @@ -1129,9 +1163,9 @@ void GameTreeRep::BuildUnreachableNodes() const } if (!child->IsTerminal()) { - // Check for Absent-Minded Re-entry of the infoset + // On a re-entry, a pure strategy replays the action chosen at the earlier visit, + // so only that branch is reachable; prune the rest. if (path_choices.find(child->m_infoset->shared_from_this()) != path_choices.end()) { - m_absentMindedInfosets.insert(child->m_infoset); const GameAction replay_action = path_choices.at(child->m_infoset->shared_from_this()); position.emplace(AbsentMindedEdge{replay_action, child}); diff --git a/src/games/gametree.h b/src/games/gametree.h index c94404f89..8b7279313 100644 --- a/src/games/gametree.h +++ b/src/games/gametree.h @@ -48,6 +48,7 @@ class GameTreeRep final : public GameExplicitRep { mutable std::shared_ptr m_ownPriorActionInfo; mutable std::unique_ptr> m_unreachableNodes; mutable std::set m_absentMindedInfosets; + mutable std::vector> m_absentMindedReentries; // The subgames of the game, held in two synchronized forms: // m_subgamePostorder for iteration (children before parents), // m_subgameByRoot for O(1) lookup by root node and ownership of the GameSubgameRep objects. @@ -119,6 +120,7 @@ class GameTreeRep final : public GameExplicitRep { /// Returns the largest payoff to the player in any play of the game Rational GetPlayerMaxPayoff(const GamePlayer &) const override; bool IsAbsentMinded(const GameInfoset &p_infoset) const override; + std::vector> GetAbsentMindedReentries() const override; std::vector GetSubgames() const override; GameSubgame GetMinimalSubgame(const GameInfoset &) const override; //@} diff --git a/tests/test_behav.py b/tests/test_behav.py index 54dcd1c3a..a68f87394 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -761,6 +761,55 @@ def test_infoset_prob_by_label_reference( assert profile.infoset_prob(label) == (gbt.Rational(prob) if rational_flag else prob) +@pytest.mark.parametrize( + "game,player_idx,infoset_idx,prob,rational_flag", + [ + # P1 infoset 1 is absent-minded (root + one reentry) + (games.read_from_file("noPR-AM-driver-one-player.efg"), 0, 0, 1.0, False), + (games.read_from_file("noPR-AM-driver-one-player.efg"), 0, 1, 0.5, False), + (games.read_from_file("noPR-AM-driver-one-player.efg"), 0, 2, 0.125, False), + (games.read_from_file("noPR-AM-driver-one-player.efg"), 0, 0, "1", True), + (games.read_from_file("noPR-AM-driver-one-player.efg"), 0, 1, "1/2", True), + (games.read_from_file("noPR-AM-driver-one-player.efg"), 0, 2, "1/8", True), + # P1 infoset 1 has 3 members (root + both children are reentries) + (games.read_from_file("noPR-action-AM.efg"), 0, 0, 1.0, False), + (games.read_from_file("noPR-action-AM.efg"), 1, 0, 0.25, False), + (games.read_from_file("noPR-action-AM.efg"), 1, 1, 0.25, False), + (games.read_from_file("noPR-action-AM.efg"), 1, 2, 0.25, False), + (games.read_from_file("noPR-action-AM.efg"), 1, 3, 0.25, False), + (games.read_from_file("noPR-action-AM.efg"), 0, 0, "1", True), + (games.read_from_file("noPR-action-AM.efg"), 1, 0, "1/4", True), + (games.read_from_file("noPR-action-AM.efg"), 1, 1, "1/4", True), + (games.read_from_file("noPR-action-AM.efg"), 1, 2, "1/4", True), + (games.read_from_file("noPR-action-AM.efg"), 1, 3, "1/4", True), + # P1 infoset 1 has 3 members (3-node chain with the last member being + # behavioral-strategy-reachable, but not pure-strategy-reachable) + (games.read_from_file("noPR-action-AM-three-chain.efg"), 0, 0, 1.0, False), + (games.read_from_file("noPR-action-AM-three-chain.efg"), 0, 1, 0.5, False), + (games.read_from_file("noPR-action-AM-three-chain.efg"), 1, 0, 0.0625, False), + (games.read_from_file("noPR-action-AM-three-chain.efg"), 0, 0, "1", True), + (games.read_from_file("noPR-action-AM-three-chain.efg"), 0, 1, "1/2", True), + (games.read_from_file("noPR-action-AM-three-chain.efg"), 1, 0, "1/16", True), + ], +) +def test_absent_minded_infoset_prob( + game: gbt.Game, player_idx: int, infoset_idx: int, prob: str | float, rational_flag: bool +): + profile = game.mixed_behavior_profile(rational=rational_flag) + ip = profile.infoset_prob(game.players[player_idx].infosets[infoset_idx]) + assert ip == (gbt.Rational(prob) if rational_flag else prob) + + +@pytest.mark.parametrize("rational_flag", [False, True]) +def test_nature_rooted_game_root_reached_with_certainty(rational_flag: bool): + """The chance root infoset is reached with probability one.""" + game = gbt.catalog.load("journals/geb/gilboa1997/fig2") + profile = game.mixed_behavior_profile(rational=rational_flag) + one = gbt.Rational(1) if rational_flag else 1.0 + assert profile.realiz_prob(game.root) == one + assert profile.infoset_prob(game.players.chance.infosets[0]) == one + + @pytest.mark.parametrize( "game,player_idx,infoset_idx,payoff,rational_flag", [ @@ -1475,6 +1524,75 @@ def test_agent_max_regret_versus_non_agent( "2/7", True, ), + # Information set I1 = {root, reentry_node}; where reentry_node is reached by ["1", "1"]. + # The upper frontier of I1 is {root}, so the conditioning event has + # probability realiz(root) = 1 for every profile, and beliefs are normalised by it: + # belief(root) = 1, belief(reentry_node) = realiz(reentry_node). + ( + games.read_from_file("noPR-AM-driver-one-player.efg"), + ZERO, + ["1/2", "1/2", "1/2", "1/2", "1/2", "1/2"], + 0, + 0, + "1", + True, + ), + ( + games.read_from_file("noPR-AM-driver-one-player.efg"), + ZERO, + ["1/2", "1/2", "1/2", "1/2", "1/2", "1/2"], + 0, + 1, + "1/4", + True, + ), + ( + games.read_from_file("noPR-AM-driver-one-player.efg"), + TOL, + [0.5, 0.5, 0.5, 0.5, 0.5, 0.5], + 0, + 0, + 1.0, + False, + ), + ( + games.read_from_file("noPR-AM-driver-one-player.efg"), + TOL, + [0.5, 0.5, 0.5, 0.5, 0.5, 0.5], + 0, + 1, + 0.25, + False, + ), + # asymmetric: p(I1,1)=2/3, p(I2,1)=3/4; realiz(reentry_node)=1/2 + ( + games.read_from_file("noPR-AM-driver-one-player.efg"), + ZERO, + ["2/3", "1/3", "3/4", "1/4", "1/2", "1/2"], + 0, + 0, + "1", + True, + ), + ( + games.read_from_file("noPR-AM-driver-one-player.efg"), + ZERO, + ["2/3", "1/3", "3/4", "1/4", "1/2", "1/2"], + 0, + 1, + "1/2", + True, + ), + # reentry reached with prob 1 + ( + games.read_from_file("noPR-AM-driver-one-player.efg"), + ZERO, + ["1", "0", "1", "0", "1", "0"], + 0, + 1, + "1", + True, + ), ], ) def test_node_belief_reference( diff --git a/tests/test_games/noPR-action-AM-three-chain.efg b/tests/test_games/noPR-action-AM-three-chain.efg new file mode 100644 index 000000000..1e8e43b4e --- /dev/null +++ b/tests/test_games/noPR-action-AM-three-chain.efg @@ -0,0 +1,20 @@ +EFG 2 R "Untitled Extensive Game" { "Player 1" "Player 2" } +"Test fixture for information-set reach probability under absent-mindedness. +Player 1's first information set has a member that is behavioural-strategy-reachable, +but not pure-strategy-reachable; likewise, both members of Player 2's infoset are +behavioural-strategy-reachable but not pure-strategy-reachable. +Both are missed by re-entry detection that rides on pure-strategy reachability." + +p "" 1 1 "" { "1" "2" } 0 +p "" 1 2 "" { "1" "2" } 0 +p "" 1 1 "" { "1" "2" } 0 +t "" 1 "Outcome 1" { 1, -1 } +p "" 1 1 "" { "1" "2" } 0 +t "" 2 "Outcome 2" { 2, -2 } +p "" 2 1 "" { "1" "2" } 0 +p "" 2 1 "" { "1" "2" } 0 +t "" 3 "Outcome 3" { 3, -3 } +t "" 4 "Outcome 4" { 4, -4 } +t "" 5 "Outcome 5" { 5, -5 } +t "" 6 "Outcome 6" { 6, -6 } +t "" 7 "Outcome 7" { 7, -7 }