Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
and of payoffs, beliefs and regrets of mixed behavior profiles on large game trees. Path
following (logit and hp) also spends about half as long factorizing Jacobians. (#1162)

### Fixed
- `maxregret` is now scaled by the range of feasible expected payoffs, so the tolerance no longer
depends on whether a game is represented as a tree or as a table. (#1168)

## [17.0.0-beta.1] - 2026-09-08

### Added
Expand Down
4 changes: 2 additions & 2 deletions doc/algorithms.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ deviating. Every Nash equilibrium is an :math:`\varepsilon`-equilibrium with
To provide a uniform interface across methods, where relevant Gambit provides a parameter
`maxregret`, which specifies the acceptance criterion for labeling the output of the
algorithm as an equilibrium. This parameter is interpreted *proportionally* to the range of
payoffs in the game: any profile returned as an equilibrium is guaranteed to be an
expected payoffs in the game: any profile returned as an equilibrium is guaranteed to be an
:math:`\varepsilon`-equilibrium, for :math:`\varepsilon` no more than `maxregret` times the
difference of the game's maximum and minimum payoffs. For example, with the default
difference of the game's maximum and minimum expected payoffs. For example, with the default
`maxregret` of :math:`10^{-8}` in a game whose payoffs range over 4 units, any equilibrium
returned is guaranteed to have a regret, measured directly in the game's own payoffs, of no
more than :math:`4 \times 10^{-8}`.
Expand Down
2 changes: 1 addition & 1 deletion doc/tools.liap.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ See the :ref:`algorithm description <liap>` for full details.
The criterion for accepting whether a local constrained minimizer
of the Lyanunov function is an approximate Nash equilibrium is specified
in terms of the maximum regret. This regret is interpreted as a fraction
of the difference between the maximum and minimum payoffs in the game.
of the difference between the maximum and minimum expected payoffs in the game.

.. versionchanged:: 16.5.0

Expand Down
2 changes: 1 addition & 1 deletion doc/tools.logit.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ See the :ref:`algorithm description <logit>` for full details.
The criterion for accepting whether a point is sufficiently close to a
Nash equilibrium to terminate the path-following is specified
in terms of the maximum regret. This regret is interpreted as a fraction
of the difference between the maximum and minimum payoffs in the game.
of the difference between the maximum and minimum expected payoffs in the game.

.. program:: gambit-logit

Expand Down
30 changes: 30 additions & 0 deletions src/games/game.cc
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,36 @@ GameRep::~GameRep()
m_nullOutcome->Invalidate();
}

std::pair<Rational, Rational>
GameRep::GetPlayerExpectedPayoffRange(const GamePlayer &p_player) const
{
const Rational first = NewPureStrategyProfile()->GetPayoff(p_player);
std::pair<Rational, Rational> range{first, first};
for (const auto &profile :
StrategyContingencies(std::const_pointer_cast<GameRep>(shared_from_this()))) {
const Rational payoff = profile->GetPayoff(p_player);
range.first = std::min(range.first, payoff);
range.second = std::max(range.second, payoff);
}
return range;
}

std::pair<Rational, Rational> GameRep::GetExpectedPayoffRange() const
{
const Rational first =
NewPureStrategyProfile()->GetPayoff(m_players.front()->shared_from_this());
std::pair<Rational, Rational> range{first, first};
for (const auto &profile :
StrategyContingencies(std::const_pointer_cast<GameRep>(shared_from_this()))) {
for (const auto &player : m_players) {
const Rational payoff = profile->GetPayoff(player->shared_from_this());
range.first = std::min(range.first, payoff);
range.second = std::max(range.second, payoff);
}
}
return range;
}

void GameRep::IndexStrategies() const
{
const size_t n = m_players.size();
Expand Down
16 changes: 16 additions & 0 deletions src/games/game.h
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,22 @@ class GameRep : public std::enable_shared_from_this<GameRep> {
/// Returns the largest payoff to the player in any play of the game
virtual Rational GetPlayerMaxPayoff(const GamePlayer &p_player) const = 0;

/// Returns the (minimum, maximum) expected payoff to the player over all pure strategy profiles
/// (that is, over the cells of the reduced strategic form).
/// @throws UndefinedException if the game has absent-mindedness
virtual std::pair<Rational, Rational>
GetPlayerExpectedPayoffRange(const GamePlayer &p_player) const;
/// Returns the (minimum, maximum) expected payoff to any player over all pure strategy profiles
/// @throws UndefinedException if the game has absent-mindedness
virtual std::pair<Rational, Rational> GetExpectedPayoffRange() const;
/// Returns the (minimum, maximum) expected payoff to any player over all pure behavior profiles,
/// that is, over the pure strategy profiles of the agent form. Games with no distinct agent
/// form report the same range as GetExpectedPayoffRange().
virtual std::pair<Rational, Rational> GetAgentExpectedPayoffRange() const
{
return GetExpectedPayoffRange();
}

/// Returns the set of terminal nodes which are descendants of node
virtual std::vector<GameNode> GetPlays(GameNode node) const { throw UndefinedException(); }
/// Returns the set of terminal nodes which are descendants of members of an infoset
Expand Down
105 changes: 105 additions & 0 deletions src/games/gametree.cc
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@
#include <algorithm>
#include <functional>
#include <iostream>
#include <iterator>
#include <limits>
#include <numeric>
#include <map>
#include <optional>
#include <set>
#include <stack>
#include <unordered_map>
Expand Down Expand Up @@ -1266,6 +1269,108 @@ Rational GameTreeRep::GetPlayerMaxPayoff(const GamePlayer &p_player) const
});
}

namespace {

// Enumerates pure behavior profiles lazily, branching on an information set only when the walk
// first reaches it. Profiles that agree on every information set they reach have the same
// payoffs, so each such class is visited once.
class PureBehaviorRangeScanner {
using Pending = std::vector<std::pair<GameNode, Rational>>;

std::vector<GamePlayer> m_players;
std::map<GameInfoset, GameAction> m_assigned;
std::vector<std::optional<std::pair<Rational, Rational>>> m_ranges;

void Explore(Pending p_pending, std::vector<Rational> p_totals)
{
while (!p_pending.empty()) {
const auto [node, weight] = p_pending.back();
p_pending.pop_back();
for (size_t pl = 0; pl < m_players.size(); pl++) {
p_totals[pl] += weight * node->GetOutcome()->GetPayoff<Rational>(m_players[pl]);
}
if (node->IsTerminal()) {
continue;
}
const GameInfoset infoset = node->GetInfoset();
if (infoset->IsChanceInfoset()) {
for (const auto &action : infoset->GetActions()) {
p_pending.emplace_back(
node->GetChild(action),
weight * static_cast<const Rational &>(infoset->GetActionProb(action)));
}
}
else if (const auto assigned = m_assigned.find(infoset); assigned != m_assigned.end()) {
p_pending.emplace_back(node->GetChild(assigned->second), weight);
}
else {
for (const auto &action : infoset->GetActions()) {
m_assigned[infoset] = action;
Pending branch = p_pending;
branch.emplace_back(node->GetChild(action), weight);
Explore(std::move(branch), p_totals);
}
m_assigned.erase(infoset);
return;
}
}
for (size_t pl = 0; pl < m_players.size(); pl++) {
auto &range = m_ranges[pl];
range = range ? std::pair{std::min(range->first, p_totals[pl]),
std::max(range->second, p_totals[pl])}
: std::pair{p_totals[pl], p_totals[pl]};
}
}

public:
explicit PureBehaviorRangeScanner(const Game &p_game)
: m_players(p_game->GetPlayers().begin(), p_game->GetPlayers().end()),
m_ranges(m_players.size())
{
}

std::vector<std::pair<Rational, Rational>> Scan(const GameNode &p_root)
{
Explore({{p_root, Rational(1)}}, std::vector<Rational>(m_players.size(), Rational(0)));
std::vector<std::pair<Rational, Rational>> result;
std::transform(m_ranges.begin(), m_ranges.end(), std::back_inserter(result),
[](const auto &range) { return *range; });
return result;
}
};

} // end anonymous namespace

std::vector<std::pair<Rational, Rational>> GameTreeRep::ScanExpectedPayoffRanges() const
{
if (!GetAbsentMindedReentries().empty()) {
throw UndefinedException(
"Expected payoff ranges are not defined for games with absent-mindedness.");
}
const Game game(const_cast<GameTreeRep *>(this)->shared_from_this());
return PureBehaviorRangeScanner(game).Scan(m_root);
}

std::pair<Rational, Rational>
GameTreeRep::GetPlayerExpectedPayoffRange(const GamePlayer &p_player) const
{
if (p_player->GetGame().get() != this) {
throw MismatchException();
}
return ScanExpectedPayoffRanges().at(p_player->GetNumber() - 1);
}

std::pair<Rational, Rational> GameTreeRep::GetExpectedPayoffRange() const
{
const auto ranges = ScanExpectedPayoffRanges();
return {std::min_element(ranges.begin(), ranges.end(),
[](const auto &a, const auto &b) { return a.first < b.first; })
->first,
std::max_element(ranges.begin(), ranges.end(), [](const auto &a, const auto &b) {
return a.second < b.second;
})->second};
}

bool GameTreeRep::IsPerfectRecall() const
{
EnsureOwnPriorActions();
Expand Down
3 changes: 3 additions & 0 deletions src/games/gametree.h
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,7 @@ class GameTreeRep final : public GameExplicitRep {
/// @name Private auxiliary functions
//@{
static void SortInfosets(GamePlayerRep *);
std::vector<std::pair<Rational, Rational>> ScanExpectedPayoffRanges() const;
template <class Aggregator>
Rational AggregateSubtreePayoff(const GamePlayer &p_player, Aggregator p_aggregator) const;
static void RenumberInfosets(GamePlayerRep *);
Expand Down Expand Up @@ -791,6 +792,8 @@ class GameTreeRep final : public GameExplicitRep {
Rational GetPlayerMinPayoff(const GamePlayer &) const override;
/// Returns the largest payoff to the player in any play of the game
Rational GetPlayerMaxPayoff(const GamePlayer &) const override;
std::pair<Rational, Rational> GetPlayerExpectedPayoffRange(const GamePlayer &) const override;
std::pair<Rational, Rational> GetExpectedPayoffRange() const override;
bool IsAbsentMinded(const GameInfoset &p_infoset) const override;
std::vector<std::pair<GameInfoset, GameNode>> GetAbsentMindedReentries() const override;
std::shared_ptr<const TreeLayout> GetTreeLayout() const override;
Expand Down
12 changes: 6 additions & 6 deletions src/pygambit/nash.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@ def liap_solve(
maxregret : float, default 1e-4
The acceptance criterion for approximate Nash equilibrium; the maximum
regret of any player must be no more than `maxregret` times the
difference of the maximum and minimum payoffs of the game
difference of the maximum and minimum expected payoffs of the game

.. versionadded:: 16.2.0

Expand Down Expand Up @@ -491,7 +491,7 @@ def liap_agent_solve(
maxregret : float, default 1e-4
The acceptance criterion for approximate Nash equilibrium; the maximum
regret of any player must be no more than `maxregret` times the
difference of the maximum and minimum payoffs of the game
difference of the maximum and minimum expected payoffs of the game

maxiter : int, default 1000
Maximum number of iterations in function minimization.
Expand Down Expand Up @@ -548,7 +548,7 @@ def simpdiv_solve(
maxregret : Rational, default 1e-7
The acceptance criterion for approximate Nash equilibrium; the maximum
regret of any player must be no more than `maxregret` times the
difference of the maximum and minimum payoffs of the game
difference of the maximum and minimum expected payoffs of the game

.. versionadded:: 16.2.0

Expand Down Expand Up @@ -824,7 +824,7 @@ def enumpoly_solve(
maxregret : float, default 1e-8
The acceptance criterion for approximate Nash equilibrium; the maximum
regret of any player must be no more than `maxregret` times the
difference of the maximum and minimum payoffs of the game
difference of the maximum and minimum expected payoffs of the game

max_rectangles : int, default 20000
The maximum number of rectangles to examine when searching for equilibria on a
Expand Down Expand Up @@ -947,7 +947,7 @@ def logit_solve(
maxregret : float, default 1e-8
The acceptance criterion for approximate Nash equilibrium; the maximum
regret of any player must be no more than `maxregret` times the
difference of the maximum and minimum payoffs of the game
difference of the maximum and minimum expected payoffs of the game

.. versionadded:: 16.2.0

Expand Down Expand Up @@ -1007,7 +1007,7 @@ def hp_solve(
maxregret : float, default 1e-8
The acceptance criterion for approximate Nash equilibrium; the maximum
regret of any player must be no more than `maxregret` times the
difference of the maximum and minimum payoffs of the game
difference of the maximum and minimum expected payoffs of the game

event_callback : Callable[[HPStepEvent], None], optional
If specified, called with each point traced along the homotopy path,
Expand Down
5 changes: 1 addition & 4 deletions src/solvers/enummixed/enummixed.cc
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,7 @@ EnumMixedStrategySolveDetailed(const Game &p_game, StrategyCallbackType<T> p_onE
if (p_game->NumPlayers() != 2) {
throw UndefinedException("Method only valid for two-player games.");
}
if (!p_game->IsPerfectRecall()) {
throw UndefinedException(
"Computing equilibria of games with imperfect recall is not supported.");
}
RequirePerfectRecall(p_game);
std::shared_ptr<EnumMixedStrategySolution<T>> solution(new EnumMixedStrategySolution<T>(p_game));

const PureStrategyProfile profile = p_game->NewPureStrategyProfile();
Expand Down
10 changes: 2 additions & 8 deletions src/solvers/enumpoly/efgpoly.cc
Original file line number Diff line number Diff line change
Expand Up @@ -234,15 +234,9 @@ EnumPolyBehaviorSolve(const Game &p_game, std::optional<size_t> p_stopAfter, dou
EnumPolyEventCallbackType<BehaviorSupportProfile> p_onEvent,
const CancelToken &p_cancel)
{
if (!p_game->IsPerfectRecall()) {
throw UndefinedException(
"Computing equilibria of games with imperfect recall is not supported.");
}
RequirePerfectRecall(p_game);

const double scale = p_game->GetMaxPayoff() - p_game->GetMinPayoff();
if (scale != 0.0) {
p_maxregret *= scale;
}
p_maxregret = ScaleRegret(p_game, p_maxregret);

std::list<MixedBehaviorProfile<double>> ret;
PossibleNashBehaviorSupports possible_supports(p_game);
Expand Down
10 changes: 2 additions & 8 deletions src/solvers/enumpoly/nfgpoly.cc
Original file line number Diff line number Diff line change
Expand Up @@ -146,15 +146,9 @@ EnumPolyStrategySolve(const Game &p_game, std::optional<size_t> p_stopAfter, dou
EnumPolyEventCallbackType<StrategySupportProfile> p_onEvent,
const CancelToken &p_cancel)
{
if (!p_game->IsPerfectRecall()) {
throw UndefinedException(
"Computing equilibria of games with imperfect recall is not supported.");
}
RequirePerfectRecall(p_game);

const double scale = p_game->GetMaxPayoff() - p_game->GetMinPayoff();
if (scale != 0.0) {
p_maxregret *= scale;
}
p_maxregret = ScaleRegret(p_game, p_maxregret);

std::list<MixedStrategyProfile<double>> ret;
PossibleNashStrategySupports possible_supports(p_game);
Expand Down
5 changes: 1 addition & 4 deletions src/solvers/enumpure/enumpure.h
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,7 @@ inline EnumPureStrategyResult EnumPureStrategySolve(
StrategyCallbackType<Rational> p_onEquilibrium = NullStrategyCallback<Rational>,
const CancelToken &p_cancel = CancelToken())
{
if (!p_game->IsPerfectRecall()) {
throw UndefinedException(
"Computing equilibria of games with imperfect recall is not supported.");
}
RequirePerfectRecall(p_game);
EnumPureStrategyResult result;
for (const auto &profile : StrategyContingencies(p_game)) {
p_cancel.Check();
Expand Down
10 changes: 2 additions & 8 deletions src/solvers/gnm/gnm.cc
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,7 @@ GNMStrategyResult GNMStrategySolve(const Game &p_game, double p_lambdaEnd, int p
StrategyCallbackType<double> p_onEquilibrium,
GNMEventCallbackType p_onEvent, const CancelToken &p_cancel)
{
if (!p_game->IsPerfectRecall()) {
throw UndefinedException(
"Computing equilibria of games with imperfect recall is not supported.");
}
RequirePerfectRecall(p_game);
const std::shared_ptr<gnmgame> A = BuildGame(p_game, true);
MixedStrategyProfile<double> pert = p_game->NewMixedStrategyProfile(0.0);
for (const auto &player : p_game->GetPlayers()) {
Expand All @@ -111,10 +108,7 @@ GNMStrategyResult GNMStrategySolve(const MixedStrategyProfile<double> &p_pert, d
StrategyCallbackType<double> p_onEquilibrium,
GNMEventCallbackType p_onEvent, const CancelToken &p_cancel)
{
if (!p_pert.GetGame()->IsPerfectRecall()) {
throw UndefinedException(
"Computing equilibria of games with imperfect recall is not supported.");
}
RequirePerfectRecall(p_pert.GetGame());
const std::shared_ptr<gnmgame> A = BuildGame(p_pert.GetGame(), true);
return Solve(p_pert.GetGame(), A, ToPerturbation(p_pert), p_lambdaEnd, p_steps,
p_localNewtonInterval, p_localNewtonMaxits, p_onEquilibrium, p_onEvent, p_cancel);
Expand Down
Loading
Loading