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
5 changes: 5 additions & 0 deletions ChangeLog
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
- Added `BehaviorSupportProfile` to `pygambit`.
- Added callback functions to Nash solvers, allowing calling code to take actions when an equilibrium
is found or another solver event is emitted.
- Added `MixedStrategyProfile.as_float` and `MixedBehaviorProfile.as_float`, converting a
rational-precision profile to floating-point precision. `liap_solve`, `liap_agent_solve`,
`logit_estimate`, `ipa_solve`, and `gnm_solve` now also accept a rational-precision profile
directly (as a starting point or perturbation vector), converting it internally.
(#721, #458)

### Changed
- Refreshed GUI icons and standardised dialog layout and formatting
Expand Down
2 changes: 2 additions & 0 deletions doc/pygambit.api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ Probability distributions over strategies
MixedStrategyProfile.max_regret
MixedStrategyProfile.liap_value
MixedStrategyProfile.as_behavior
MixedStrategyProfile.as_float
MixedStrategyProfile.normalize
MixedStrategyProfile.copy

Expand Down Expand Up @@ -313,6 +314,7 @@ Probability distributions over behavior
MixedBehaviorProfile.max_regret
MixedBehaviorProfile.liap_value
MixedBehaviorProfile.as_strategy
MixedBehaviorProfile.as_float
MixedBehaviorProfile.normalize
MixedBehaviorProfile.copy

Expand Down
29 changes: 29 additions & 0 deletions src/pygambit/behavmixed.pxi
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,21 @@ class MixedBehaviorProfile:
self._check_validity()
return self._as_strategy()

def as_float(self) -> MixedBehaviorProfileDouble:
"""Creates a floating-point copy of this mixed behavior profile.

If this profile is already a `MixedBehaviorProfileDouble`, returns a copy of it.

.. versionadded:: 17.0.0

Returns
-------
MixedBehaviorProfileDouble
A profile with the same probabilities, represented as floating-point numbers.
"""
self._check_validity()
return self._as_float()

def normalize(self) -> MixedBehaviorProfile:
"""Create a profile with the same action proportions as this
one, but normalised so probabilities for each infoset sum to one.
Expand Down Expand Up @@ -970,6 +985,9 @@ class MixedBehaviorProfileDouble(MixedBehaviorProfile):
deref(self.profile).ToMixedProfile()
))

def _as_float(self) -> MixedBehaviorProfileDouble:
return self._copy()

def _agent_liap_value(self) -> float:
return deref(self.profile).GetAgentLiapValue()

Expand Down Expand Up @@ -1093,6 +1111,17 @@ class MixedBehaviorProfileRational(MixedBehaviorProfile):
deref(self.profile).ToMixedProfile()
))

def _as_float(self) -> MixedBehaviorProfileDouble:
profile: MixedBehaviorProfileDouble = self.game.mixed_behavior_profile()
for player in self.game.players:
for infoset in player.infosets:
profile._setprob_infoset(
infoset,
{a.label: float(self._getprob_action(a)) for a in infoset.actions},
sparse=True,
)
return profile

def _agent_liap_value(self) -> Rational:
return rat_to_py(deref(self.profile).GetAgentLiapValue())

Expand Down
62 changes: 46 additions & 16 deletions src/pygambit/nash.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ def lp_solve(


def liap_solve(
start: libgbt.MixedStrategyProfileDouble,
start: libgbt.MixedStrategyProfile,
maxregret: float = 1.0e-4,
maxiter: int = 1000,
nash_callback: Callable[[libgbt.MixedStrategyProfileDouble], None] | None = None,
Expand All @@ -403,12 +403,18 @@ def liap_solve(
Computing agent Nash equilibria in the extensive game moved to
`liap_agent_solve` for clarity.

.. versionchanged:: 17.0.0

`start` may now also be a `MixedStrategyProfileRational`; it is converted to
floating-point via `~MixedStrategyProfile.as_float` before minimization.

Parameters
----------
start : MixedStrategyProfileDouble
start : MixedStrategyProfile
The starting profile for function minimization. Up to one equilibrium will be found
from any starting profile, and the equilibrium found may (and generally will)
depend on the initial profile chosen.
depend on the initial profile chosen. If a `MixedStrategyProfileRational` is
given, it is converted to floating-point precision first.

maxregret : float, default 1e-4
The acceptance criterion for approximate Nash equilibrium; the maximum
Expand Down Expand Up @@ -441,6 +447,7 @@ def liap_solve(
"""
if maxregret <= 0.0:
raise ValueError("liap_solve(): maxregret argument must be positive")
start = start.as_float()
equilibria = libgbt._liap_strategy_solve(
start, maxregret=maxregret, maxiter=maxiter,
nash_callback=nash_callback, event_callback=event_callback
Expand All @@ -456,7 +463,7 @@ def liap_solve(


def liap_agent_solve(
start: libgbt.MixedBehaviorProfileDouble,
start: libgbt.MixedBehaviorProfile,
maxregret: float = 1.0e-4,
maxiter: int = 1000,
nash_callback: Callable[[libgbt.MixedBehaviorProfileDouble], None] | None = None,
Expand All @@ -472,12 +479,18 @@ def liap_agent_solve(
Moved from `liap_solve` passing a `MixedBehaviorProfileDouble` for additional
clarity in the solution concept computed.

.. versionchanged:: 17.0.0

`start` may now also be a `MixedBehaviorProfileRational`; it is converted to
floating-point via `~MixedBehaviorProfile.as_float` before minimization.

Parameters
----------
start : MixedBehaviorProfileDouble
start : MixedBehaviorProfile
The starting profile for function minimization. Up to one equilibrium will be found
from any starting profile, and the equilibrium found may (and generally will)
depend on the initial profile chosen.
depend on the initial profile chosen. If a `MixedBehaviorProfileRational` is
given, it is converted to floating-point precision first.

maxregret : float, default 1e-4
The acceptance criterion for approximate Nash equilibrium; the maximum
Expand Down Expand Up @@ -506,6 +519,7 @@ def liap_agent_solve(
"""
if maxregret <= 0.0:
raise ValueError("liap_solve(): maxregret argument must be positive")
start = start.as_float()
equilibria = libgbt._liap_behavior_solve(
start, maxregret=maxregret, maxiter=maxiter,
nash_callback=nash_callback, event_callback=event_callback
Expand Down Expand Up @@ -600,7 +614,7 @@ def simpdiv_solve(


def ipa_solve(
perturbation: libgbt.Game | libgbt.MixedStrategyProfileDouble,
perturbation: libgbt.Game | libgbt.MixedStrategyProfile,
nash_callback: Callable[[libgbt.MixedStrategyProfileDouble], None] | None = None,
event_callback: Callable[
[libgbt.IPAStepEvent | libgbt.IPATerminationEvent], None
Expand All @@ -611,15 +625,22 @@ def ipa_solve(

Parameters
----------
perturbation : Game or MixedStrategyProfileDouble
perturbation : Game or MixedStrategyProfile
The perturbation vector to apply to the game. If a ``Game`` is
passed, the perturbation vector is set to be 1 for the first
strategy for each player and 0 for all other strategies.
strategy for each player and 0 for all other strategies. If a
`MixedStrategyProfileRational` is given, it is converted to
floating-point precision first.

.. versionchanged:: 16.2.0

Allow selection of the perturbation vector

.. versionchanged:: 17.0.0

Accept a `MixedStrategyProfileRational`, converted to floating-point via
`~MixedStrategyProfile.as_float`.

nash_callback : Callable[[MixedStrategyProfileDouble], None], optional
If specified, called with the equilibrium found, if any.

Expand Down Expand Up @@ -651,11 +672,12 @@ def ipa_solve(
perturbation[player.label] = {
s.label: (1.0 if s is strategies[0] else 0.0) for s in strategies
}
elif isinstance(perturbation, libgbt.MixedStrategyProfileDouble):
elif isinstance(perturbation, libgbt.MixedStrategyProfile):
game = perturbation.game
perturbation = perturbation.as_float()
else:
raise TypeError(
f"parameter must be Game or MixedStrategyProfileDouble, "
f"parameter must be Game or MixedStrategyProfile, "
f"not {perturbation.__class__.__name__}"
)
return NashComputationResult(
Expand All @@ -669,7 +691,7 @@ def ipa_solve(


def gnm_solve(
perturbation: libgbt.Game | libgbt.MixedStrategyProfileDouble,
perturbation: libgbt.Game | libgbt.MixedStrategyProfile,
end_lambda: float = -10.0,
steps: int = 100,
local_newton_interval: int = 3,
Expand All @@ -690,15 +712,22 @@ def gnm_solve(

Parameters
----------
perturbation : Game or MixedStrategyProfileDouble
perturbation : Game or MixedStrategyProfile
The perturbation vector to apply to the game. If a ``Game`` is
passed, the perturbation vector is set to be 1 for the first
strategy for each player and 0 for all other strategies.
strategy for each player and 0 for all other strategies. If a
`MixedStrategyProfileRational` is given, it is converted to
floating-point precision first.

.. versionchanged:: 16.2.0

Allow selection of the perturbation vector

.. versionchanged:: 17.0.0

Accept a `MixedStrategyProfileRational`, converted to floating-point via
`~MixedStrategyProfile.as_float`.

end_lambda : float, default -10.0
The value of the perturbation magnitude lambda at which to terminate
tracing. This must be a negative number. This sets the point at
Expand Down Expand Up @@ -762,11 +791,12 @@ def gnm_solve(
perturbation[player.label] = {
s.label: (1.0 if s is strategies[0] else 0.0) for s in strategies
}
elif isinstance(perturbation, libgbt.MixedStrategyProfileDouble):
elif isinstance(perturbation, libgbt.MixedStrategyProfile):
game = perturbation.game
perturbation = perturbation.as_float()
else:
raise TypeError(
f"parameter must be Game or MixedStrategyProfileDouble, "
f"parameter must be Game or MixedStrategyProfile, "
f"not {perturbation.__class__.__name__}"
)
if end_lambda >= 0.0:
Expand Down
23 changes: 16 additions & 7 deletions src/pygambit/qre.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,25 +162,25 @@ def __repr__(self) -> str:


def _estimate_strategy_fixedpoint(
data: libgbt.MixedStrategyProfileDouble,
data: libgbt.MixedStrategyProfile,
local_max: bool = False,
first_step: float = .03,
max_accel: float = 1.1,
) -> LogitQREMixedStrategyFitResult:
res = libgbt._logit_strategy_estimate(data, local_max=local_max,
res = libgbt._logit_strategy_estimate(data.as_float(), local_max=local_max,
first_step=first_step, max_accel=max_accel)
return LogitQREMixedStrategyFitResult(
data, "fixedpoint", res.lam, res.profile, res.log_like
)


def _estimate_behavior_fixedpoint(
data: libgbt.MixedBehaviorProfileDouble,
data: libgbt.MixedBehaviorProfile,
local_max: bool = False,
first_step: float = .03,
max_accel: float = 1.1,
) -> LogitQREMixedBehaviorFitResult:
res = libgbt._logit_behavior_estimate(data, local_max=local_max,
res = libgbt._logit_behavior_estimate(data.as_float(), local_max=local_max,
first_step=first_step, max_accel=max_accel)
return LogitQREMixedBehaviorFitResult(
data, "fixedpoint", res.lam, res.profile, res.log_like
Expand All @@ -207,7 +207,7 @@ def _empirical_log_like(lam: float, regrets: list, flattened_data: list) -> floa


def _estimate_strategy_empirical(
data: libgbt.MixedStrategyProfileDouble
data: libgbt.MixedStrategyProfile
) -> LogitQREMixedStrategyFitResult:
flattened_data = [
data[p.label][s.label] for p in data.game.players for s in p.strategies
Expand All @@ -230,7 +230,7 @@ def _estimate_strategy_empirical(


def _estimate_behavior_empirical(
data: libgbt.MixedBehaviorProfileDouble,
data: libgbt.MixedBehaviorProfile,
) -> LogitQREMixedBehaviorFitResult:
flattened_data = [
data[next(iter(s.members))][a.label]
Expand Down Expand Up @@ -269,14 +269,23 @@ def logit_estimate(

.. versionadded:: 16.3.0

.. versionchanged:: 17.0.0

`data` may now also be a rational-precision profile
(`MixedStrategyProfileRational` or `MixedBehaviorProfileRational`); it is
converted to floating-point via `~MixedStrategyProfile.as_float` or
`~MixedBehaviorProfile.as_float` where needed.

Parameters
----------
data : MixedStrategyProfile or MixedBehaviorProfile
The empirical distribution of play to which to fit the QRE.
To obtain the correct resulting log-likelihood, these should
be expressed as total counts of observations of each action
rather than probabilities. If a MixedBehaviorProfile is
specified, estimation is done using the agent QRE.
specified, estimation is done using the agent QRE. A
rational-precision profile is accepted, and converted to
floating-point precision internally.

use_empirical : bool, default = False
If specified and True, use the empirical payoff approach for
Expand Down
30 changes: 30 additions & 0 deletions src/pygambit/stratmixed.pxi
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,21 @@ class MixedStrategyProfile:
self._check_validity()
return self._as_behavior()

def as_float(self) -> MixedStrategyProfileDouble:
"""Creates a floating-point copy of this mixed strategy profile.

If this profile is already a `MixedStrategyProfileDouble`, returns a copy of it.

.. versionadded:: 17.0.0

Returns
-------
MixedStrategyProfileDouble
A profile with the same probabilities, represented as floating-point numbers.
"""
self._check_validity()
return self._as_float()

def normalize(self) -> MixedStrategyProfile:
"""Create a profile with the same strategy proportions as this
one, but normalised so probabilities for each player sum to one.
Expand Down Expand Up @@ -565,6 +580,10 @@ class MixedStrategyProfile:
"""Creates the equivalent mixed behavior profile."""
raise NotImplementedError

def _as_float(self) -> MixedStrategyProfileDouble:
"""Creates a floating-point copy of the profile."""
raise NotImplementedError

def _normalize(self) -> MixedStrategyProfile:
"""Creates a copy of the profile, normalized so each player's strategy
probabilities sum to one.
Expand Down Expand Up @@ -649,6 +668,9 @@ class MixedStrategyProfileDouble(MixedStrategyProfile):
make_shared[c_MixedBehaviorProfile[double]](deref(self.profile))
)

def _as_float(self) -> MixedStrategyProfileDouble:
return self._copy()

def _normalize(self) -> MixedStrategyProfileDouble:
return MixedStrategyProfileDouble.wrap(
make_shared[c_MixedStrategyProfile[double]](deref(self.profile).Normalize())
Expand Down Expand Up @@ -737,6 +759,14 @@ class MixedStrategyProfileRational(MixedStrategyProfile):
make_shared[c_MixedBehaviorProfile[c_Rational]](deref(self.profile))
)

def _as_float(self) -> MixedStrategyProfileDouble:
profile: MixedStrategyProfileDouble = self.game.mixed_strategy_profile()
for player in self.game.players:
profile[player.label] = {
s.label: float(self._getprob_strategy(s)) for s in player.strategies
}
return profile

def _normalize(self) -> MixedStrategyProfileRational:
return MixedStrategyProfileRational.wrap(
make_shared[c_MixedStrategyProfile[c_Rational]](deref(self.profile).Normalize())
Expand Down
Loading
Loading