From 4ffaf1f7a163e2fb53e477ed7179cdd0f3615619 Mon Sep 17 00:00:00 2001 From: Theodore Turocy Date: Thu, 27 Aug 2026 17:59:42 +0100 Subject: [PATCH] Add `.as_float` to convert mixed profiles to floating-point equivalents This adds `.as_float` to `MixedStrategyProfile`/`MixedBehaviorProfile`, which creates a copy of the profile will all probabilities cast to floating-point precision. Methods which accepted a floating-point precision profile as a starting point now accept either precision profile, with `as_float` being called automatically. --- ChangeLog | 5 +++ doc/pygambit.api.rst | 2 ++ src/pygambit/behavmixed.pxi | 29 +++++++++++++++++ src/pygambit/nash.py | 62 ++++++++++++++++++++++++++--------- src/pygambit/qre.py | 23 +++++++++---- src/pygambit/stratmixed.pxi | 30 +++++++++++++++++ tests/test_behav.py | 26 +++++++++++++++ tests/test_mixed.py | 26 +++++++++++++++ tests/test_nash.py | 65 +++++++++++++++++++++++++++++++++++++ tests/test_qre.py | 40 +++++++++++++++++++++++ 10 files changed, 285 insertions(+), 23 deletions(-) create mode 100644 tests/test_qre.py diff --git a/ChangeLog b/ChangeLog index 0c8748e36c..3b9f77bab3 100644 --- a/ChangeLog +++ b/ChangeLog @@ -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 diff --git a/doc/pygambit.api.rst b/doc/pygambit.api.rst index 8c827754e3..867ae3abf9 100644 --- a/doc/pygambit.api.rst +++ b/doc/pygambit.api.rst @@ -271,6 +271,7 @@ Probability distributions over strategies MixedStrategyProfile.max_regret MixedStrategyProfile.liap_value MixedStrategyProfile.as_behavior + MixedStrategyProfile.as_float MixedStrategyProfile.normalize MixedStrategyProfile.copy @@ -313,6 +314,7 @@ Probability distributions over behavior MixedBehaviorProfile.max_regret MixedBehaviorProfile.liap_value MixedBehaviorProfile.as_strategy + MixedBehaviorProfile.as_float MixedBehaviorProfile.normalize MixedBehaviorProfile.copy diff --git a/src/pygambit/behavmixed.pxi b/src/pygambit/behavmixed.pxi index 6330b582b5..8b7253aac0 100644 --- a/src/pygambit/behavmixed.pxi +++ b/src/pygambit/behavmixed.pxi @@ -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. @@ -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() @@ -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()) diff --git a/src/pygambit/nash.py b/src/pygambit/nash.py index 017049e962..15cdc86f20 100644 --- a/src/pygambit/nash.py +++ b/src/pygambit/nash.py @@ -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, @@ -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 @@ -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 @@ -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, @@ -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 @@ -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 @@ -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 @@ -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. @@ -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( @@ -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, @@ -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 @@ -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: diff --git a/src/pygambit/qre.py b/src/pygambit/qre.py index ced17f49e9..b9377e5f14 100644 --- a/src/pygambit/qre.py +++ b/src/pygambit/qre.py @@ -162,12 +162,12 @@ 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 @@ -175,12 +175,12 @@ def _estimate_strategy_fixedpoint( 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 @@ -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 @@ -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] @@ -269,6 +269,13 @@ 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 @@ -276,7 +283,9 @@ def logit_estimate( 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 diff --git a/src/pygambit/stratmixed.pxi b/src/pygambit/stratmixed.pxi index 3d46d0f128..34a0ce5e52 100644 --- a/src/pygambit/stratmixed.pxi +++ b/src/pygambit/stratmixed.pxi @@ -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. @@ -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. @@ -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()) @@ -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()) diff --git a/tests/test_behav.py b/tests/test_behav.py index ffd17f2606..48e0d4163c 100644 --- a/tests/test_behav.py +++ b/tests/test_behav.py @@ -609,6 +609,32 @@ def test_behavior_copy_mutating_original_does_not_affect_copy(rational_flag: boo assert dict(original[node]) == {"U1": 1, "D1": 0} +@pytest.mark.parametrize("rational_flag", [False, True]) +def test_as_float_returns_double(rational_flag: bool): + game = games.read_from_file("mixed_behavior_game.efg") + result = game.mixed_behavior_profile(rational=rational_flag).as_float() + assert isinstance(result, gbt.MixedBehaviorProfileDouble) + + +def test_as_float_converts_rational_probabilities(): + game = games.read_from_file("mixed_behavior_game.efg") + profile = game.mixed_behavior_profile(rational=True) + node = _p1_node(game) + profile[node] = {"U1": "1/3", "D1": "2/3"} + result = profile.as_float() + assert dict(result[node]) == {"U1": pytest.approx(1 / 3), "D1": pytest.approx(2 / 3)} + + +def test_as_float_is_independent_copy(): + game = games.read_from_file("mixed_behavior_game.efg") + profile = game.mixed_behavior_profile(rational=False) + node = _p1_node(game) + result = profile.as_float() + assert result == profile + result[node] = {"U1": 1.0, "D1": 0.0} + assert dict(profile[node]) != dict(result[node]) + + @pytest.mark.parametrize( "game,path,realiz_prob,rational_flag", [ diff --git a/tests/test_mixed.py b/tests/test_mixed.py index 7290c1654b..0051998450 100644 --- a/tests/test_mixed.py +++ b/tests/test_mixed.py @@ -465,6 +465,32 @@ def test_as_behavior_error(game: gbt.Game, rational_flag: bool): _ = game.mixed_strategy_profile(rational=rational_flag).as_behavior() +@pytest.mark.parametrize("rational_flag", [False, True]) +def test_as_float_returns_double(rational_flag: bool): + game = games.read_from_file("coordination_4x4_payoff.nfg") + result = game.mixed_strategy_profile(rational=rational_flag).as_float() + assert isinstance(result, gbt.MixedStrategyProfileDouble) + + +def test_as_float_converts_rational_probabilities(): + game = games.read_from_file("coordination_4x4_payoff.nfg") + profile = game.mixed_strategy_profile(rational=True) + profile[P1] = {"1": "1/3", "2": "2/3", "3": 0, "4": 0} + result = profile.as_float() + assert result[P1] == { + "1": pytest.approx(1 / 3), "2": pytest.approx(2 / 3), "3": 0.0, "4": 0.0 + } + + +def test_as_float_is_independent_copy(): + game = games.read_from_file("coordination_4x4_payoff.nfg") + profile = game.mixed_strategy_profile(rational=False) + result = profile.as_float() + assert result == profile + result[P1] = {"1": 1.0} + assert dict(profile[P1]) != dict(result[P1]) + + @pytest.mark.parametrize( "game,profile_data,rational_flag,payoffs", [ diff --git a/tests/test_nash.py b/tests/test_nash.py index 2f22f159c5..06b2f6872b 100644 --- a/tests/test_nash.py +++ b/tests/test_nash.py @@ -1624,6 +1624,47 @@ def test_nash_strategy_solver(test_case: EquilibriumTestCase, subtests) -> None: assert abs(eq_prob - exp_prob) <= test_case.prob_tol +@pytest.mark.nash +@pytest.mark.parametrize("solver", [gbt.nash.ipa_solve, gbt.nash.gnm_solve]) +def test_nash_strategy_solver_accepts_rational_perturbation(solver, subtests) -> None: + """`ipa_solve` and `gnm_solve` accept a `MixedStrategyProfileRational` perturbation + vector, converting it to floating-point precision internally, and give the same + result as passing the equivalent floating-point perturbation directly. + (gambitproject/gambit#721, #458) + """ + game = games.read_from_file("stripped_down_poker.efg") + + def _one_hot_perturbation(rational: bool) -> gbt.MixedStrategyProfile: + one = gbt.Rational(1) if rational else 1.0 + zero = gbt.Rational(0) if rational else 0.0 + perturbation = game.mixed_strategy_profile(rational=rational) + for player in game.players: + strategies = list(player.strategies) + perturbation[player.label] = { + s.label: (one if s is strategies[0] else zero) for s in strategies + } + return perturbation + + rational_result = solver(_one_hot_perturbation(rational=True)) + double_result = solver(_one_hot_perturbation(rational=False)) + with subtests.test("perturbation converted to double precision"): + assert isinstance( + rational_result.parameters["perturbation"], gbt.MixedStrategyProfileDouble + ) + with subtests.test("number of equilibria found"): + assert len(rational_result.equilibria) == len(double_result.equilibria) + for i, (rational_eq, double_eq) in enumerate( + zip(rational_result.equilibria, double_result.equilibria, strict=True) + ): + with subtests.test(eq=i, check="strategy_profile"): + for player in game.players: + for strategy in player.strategies: + assert ( + rational_eq[player.label][strategy.label] + == pytest.approx(double_eq[player.label][strategy.label]) + ) + + ################################################################################################## # NASH SOLVERS WITH START PROFILES ################################################################################################## @@ -1642,6 +1683,18 @@ def test_nash_strategy_solver(test_case: EquilibriumTestCase, subtests) -> None: marks=pytest.mark.nash_liap_strategy, id="test_liap_strategy_1", ), + pytest.param( + EquilibriumTestCaseWithStart( + factory=functools.partial(games.read_from_file, "stripped_down_poker.efg"), + solver=gbt.nash.liap_solve, + start_data=dict(data=None, rational=True), + expected=[], + regret_tol=TOL_LARGE, + prob_tol=TOL, + ), + marks=pytest.mark.nash_liap_strategy, + id="test_liap_strategy_rational_start", + ), ] @@ -3394,6 +3447,18 @@ def test_nash_agent_solver(test_case: EquilibriumTestCase, subtests) -> None: marks=pytest.mark.nash_liap_agent, id="test_liap_agent_1", ), + pytest.param( + EquilibriumTestCaseWithStart( + factory=functools.partial(games.read_from_file, "stripped_down_poker.efg"), + solver=gbt.nash.liap_agent_solve, + start_data=dict(data=None, rational=True), + expected=[[[d(1, 0), d("1/3", "2/3")], [d("2/3", "1/3")]]], + regret_tol=TOL_LARGE, + prob_tol=TOL_LARGE, + ), + marks=pytest.mark.nash_liap_agent, + id="test_liap_agent_rational_start", + ), ] AGENT_WITH_START_CASES = [] diff --git a/tests/test_qre.py b/tests/test_qre.py new file mode 100644 index 0000000000..46d2e66353 --- /dev/null +++ b/tests/test_qre.py @@ -0,0 +1,40 @@ +import pytest + +import pygambit as gbt + + +def _asymmetric_2x2() -> gbt.Game: + return gbt.Game.from_arrays([[1, 2], [3, 4]], [[4, 3], [2, 1]]) + + +@pytest.mark.parametrize("use_empirical", [False, True]) +def test_logit_estimate_strategy_accepts_rational_profile(use_empirical: bool): + """logit_estimate() accepts a MixedStrategyProfileRational, converting it to + floating-point precision internally (gambitproject/gambit#458). + """ + game = _asymmetric_2x2() + data = game.mixed_strategy_profile(rational=True) + data["1"] = {"1": "3", "2": "5"} + data["2"] = {"1": "4", "2": "4"} + result = gbt.qre.logit_estimate(data, use_empirical=use_empirical) + assert isinstance(result.profile, gbt.MixedStrategyProfileDouble) + assert result.data is data + + +def test_logit_estimate_strategy_rational_and_float_data_agree(): + game = _asymmetric_2x2() + rational_data = game.mixed_strategy_profile(rational=True) + rational_data["1"] = {"1": "3", "2": "5"} + rational_data["2"] = {"1": "4", "2": "4"} + float_data = rational_data.as_float() + + rational_result = gbt.qre.logit_estimate(rational_data) + float_result = gbt.qre.logit_estimate(float_data) + + assert rational_result.lam == pytest.approx(float_result.lam) + for player in game.players: + for strategy in player.strategies: + assert ( + rational_result.profile[player.label][strategy.label] + == pytest.approx(float_result.profile[player.label][strategy.label]) + )