From 0a755a01b86bec9f156ca481915aeb84ffebf2be Mon Sep 17 00:00:00 2001 From: shanyu Date: Mon, 5 Oct 2026 12:35:56 +0800 Subject: [PATCH] Scale Lemke-Howson tableau rows by per-player payoff max (fix #951) Divide each player's shifted payoff block by its maximum so the absolute pivoting tolerances act as relative ones, as proposed in the issue. Fixes convergence failures on games with payoffs of order 1e10 and above; verified with is_nash cross-checks over a 17-decade sweep. Existing behaviours, tolerances and the all-nonpositive-games error path are unchanged. --- quantecon/game_theory/lemke_howson.py | 34 +++++++++++++------ .../game_theory/tests/test_lemke_howson.py | 34 ++++++++++++++----- 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/quantecon/game_theory/lemke_howson.py b/quantecon/game_theory/lemke_howson.py index 40c7f415f..f26015e4d 100644 --- a/quantecon/game_theory/lemke_howson.py +++ b/quantecon/game_theory/lemke_howson.py @@ -247,6 +247,12 @@ def _initialize_tableaux(payoff_matrices, tableaux, bases): `abs(payoff_matrices[i].min()) + 1` (to ensure for the tableau not to have a negative entry or a column identically zero). + The shifted payoffs of each player are additionally divided by their + maximum, so that the payoff block of each tableau lies in a fixed + range and the absolute tolerances of the pivoting routine act as + tolerances relative to the payoff scale. Nash equilibria are + invariant to such per-player positive affine transformations. + Suppose that the players 0 and 1 have m and n actions, respectively. * `tableaux[0]` has n rows and m+n+1 columns, where columns 0, ..., @@ -294,12 +300,17 @@ def _initialize_tableaux(payoff_matrices, tableaux, bases): >>> bases = (np.empty(n, dtype=int), np.empty(m, dtype=int)) >>> tableaux, bases = _initialize_tableaux((A, B), tableaux, bases) >>> tableaux[0] - array([[3., 2., 3., 1., 0., 1.], - [2., 6., 1., 0., 1., 1.]]) + array([[0.5 , 0.33333333, 0.5 , 1. , 0. , + 1. ], + [0.33333333, 1. , 0.16666667, 0. , 1. , + 1. ]]) >>> tableaux[1] - array([[1., 0., 0., 4., 4., 1.], - [0., 1., 0., 3., 6., 1.], - [0., 0., 1., 1., 7., 1.]]) + array([[1. , 0. , 0. , 0.57142857, 0.57142857, + 1. ], + [0. , 1. , 0. , 0.42857143, 0.85714286, + 1. ], + [0. , 0. , 1. , 0.14285714, 1. , + 1. ]]) >>> bases (array([3, 4]), array([0, 1, 2])) @@ -307,17 +318,20 @@ def _initialize_tableaux(payoff_matrices, tableaux, bases): nums_actions = payoff_matrices[0].shape consts = np.zeros(2) # To be added to payoffs if min <= 0 + scales = np.ones(2) # To divide the shifted payoffs by their maximum for pl in range(2): min_ = payoff_matrices[pl].min() if min_ <= 0: consts[pl] = min_ * (-1) + 1 + scales[pl] = (payoff_matrices[pl] + consts[pl]).max() for pl, (py_start, sl_start) in enumerate(zip((0, nums_actions[0]), (nums_actions[0], 0))): for i in range(nums_actions[1-pl]): for j in range(nums_actions[pl]): tableaux[pl][i, py_start+j] = \ - payoff_matrices[1-pl][i, j] + consts[1-pl] + (payoff_matrices[1-pl][i, j] + consts[1-pl]) / \ + scales[1-pl] for j in range(nums_actions[1-pl]): if j == i: tableaux[pl][i, sl_start+j] = 1 @@ -381,12 +395,12 @@ def _lemke_howson_tbl(tableaux, bases, init_pivot, max_iter): >>> _lemke_howson_tbl(tableaux, bases, 1, 10) (True, 4, False) >>> tableaux[0] - array([[ 0.875 , 0. , 1. , 0.375 , -0.125 , 0.25 ], - [ 0.1875, 1. , 0. , -0.0625, 0.1875, 0.125 ]]) + array([[ 0.875 , 0. , 1. , 2.25 , -0.75 , 1.5 ], + [ 0.1875, 1. , 0. , -0.375 , 1.125 , 0.75 ]]) >>> tableaux[1] array([[ 1. , -1.6 , 0.8 , 0. , 0. , 0.2 ], - [ 0. , 0.4667, -0.4 , 1. , 0. , 0.0667], - [ 0. , -0.0667, 0.2 , 0. , 1. , 0.1333]]) + [ 0. , 3.2667, -2.8 , 1. , 0. , 0.4667], + [ 0. , -0.4667, 1.4 , 0. , 1. , 0.9333]]) >>> bases (array([2, 1]), array([0, 3, 4])) diff --git a/quantecon/game_theory/tests/test_lemke_howson.py b/quantecon/game_theory/tests/test_lemke_howson.py index cb1177fd7..205ea3e8e 100644 --- a/quantecon/game_theory/tests/test_lemke_howson.py +++ b/quantecon/game_theory/tests/test_lemke_howson.py @@ -134,21 +134,37 @@ def test_lemke_howson_invalid_init_pivot_float(): @pytest.mark.parametrize('scale', [1e15, 1e16]) -def test_lemke_howson_large_payoffs_report_breakdown(scale): - # With payoffs of this size, the ratios in the lexico-minimum ratio - # test all tie within the (absolute) `tol_ratio_diff`, and the tie - # breaking fails. The routine must then report non-convergence - # rather than return a wrong "equilibrium" with `converged=True`. +def test_lemke_howson_large_payoffs_converge(scale): + # gh-951: with the payoff blocks normalized in `_initialize_tableaux`, + # the absolute pivoting tolerances act relative to the payoff scale, + # so the routine converges to a valid equilibrium instead of + # reporting a breakdown. A = scale * np.array([[3., 1.], [1., 3.]]) B = scale * np.array([[1., 3.], [3., 1.]]) g = NormalFormGame((Player(A), Player(B))) NE, res = lemke_howson(g, full_output=True) - assert_(not res.converged) - # Without capping, no other initial pivot is tried on breakdown - assert_(res.init == 0) - assert_(res.num_iter == 0) + assert_(res.converged) + assert_(g.is_nash(NE)) + + +@pytest.mark.parametrize('scale', [1e-12, 1e-10, 1e12, 1e15]) +def test_lemke_howson_payoff_scale_invariance(scale): + # gh-951: Nash equilibria are invariant to per-player positive affine + # transformations of the payoffs. With the payoff blocks normalized + # in `_initialize_tableaux`, the routine must converge to an + # equilibrium that is valid for the unit-scale game at any scale. + # Uniform payoffs are used as in the issue: all values are positive, + # so only the rescaling (not the positivity shift) is exercised. + rng = np.random.default_rng(0) + for _ in range(10): + base = rng.random(size=(4, 4, 2)) + g_unit = NormalFormGame(base) + NE, res = lemke_howson(NormalFormGame(base * scale), + full_output=True) + assert_(res.converged) + assert_(g_unit.is_nash(tuple(NE))) def test_lemke_howson_tbl_breakdown():