Skip to content

Normalise payoffs in vertex enumeration - #244

Open
choosen23 wants to merge 2 commits into
drvinceknight:mainfrom
choosen23:fix-vertex-enumeration-payoff-scale
Open

Normalise payoffs in vertex enumeration#244
choosen23 wants to merge 2 commits into
drvinceknight:mainfrom
choosen23:fix-vertex-enumeration-payoff-scale

Conversation

@choosen23

@choosen23 choosen23 commented Aug 26, 2026

Copy link
Copy Markdown

Two commits: the first unbreaks CI on current tooling, the second is the actual fix. Details on the
first are at the bottom.

The problem

vertex_enumeration returns an empty generator — no exception, no warning — for games it solves
correctly when the payoffs are smaller.

import numpy as np
import nashpy

for name, A, B in [
    ("RPS", [[0, -1, 1], [1, 0, -1], [-1, 1, 0]], [[0, 1, -1], [-1, 0, 1], [1, -1, 0]]),
    ("BoS", [[3, 0], [0, 2]], [[2, 0], [0, 3]]),
]:
    for k in (0, 3, 6, 9, 12):
        game = nashpy.Game(np.array(A, dtype=float) * 10**k, np.array(B, dtype=float) * 10**k)
        print(name, f"x1e{k}", "->", len(list(game.vertex_enumeration())), "equilibria")
equilibria found ×1e0 ×1e3 ×1e6 ×1e9 ×1e12
RPS 1 1 1 0 0
Battle of the Sexes 3 3 3 0 0

This is not degeneracy

Flagging this up front, since #39 and #66 were both reports of empty results that turned out to be
degeneracy. This one isn't:

  • Rock-Paper-Scissors and Battle of the Sexes are both non-degenerate.
  • Both are solved correctly at ×1e6.
  • The only change between the working and failing rows is multiplying every payoff by a constant.

Degeneracy is invariant under a positive rescaling of payoffs, so it cannot explain a failure that
appears only at ×1e9. And since every finite game has at least one equilibrium, an empty return is
never a correct answer here.

Root cause

The best response polytope is {x >= 0, Mx <= 1}. Because the right hand side is fixed at 1, the
vertex coordinates scale as 1 / payoff. non_trivial_vertices discards the origin with

if not np.all(np.isclose(v, 0)) and max(v) < np.inf

and np.isclose(v, 0) reduces to its absolute tolerance atol=1e-8 when comparing against zero. So
once payoffs are large enough, every legitimate vertex falls under atol and is thrown away as the
trivial vertex:

payoffs x1e0 : raw vertex max|coord| = 5.00e-01 -> 3 kept
payoffs x1e6 : raw vertex max|coord| = 5.00e-07 -> 3 kept
payoffs x1e9 : raw vertex max|coord| = 5.00e-10 -> 0 kept

The fix

Normalise in vertex_enumeration, just after the existing shift to non-negative payoffs. Nash
equilibria are invariant under a positive rescaling of each player's payoffs, so this does not change
the result — it just keeps the polytope well conditioned so the existing tolerance is meaningful.

I first tried the smaller change, making the zero test in polytope.py scale-relative, and it is
not a good fix: it lets through vertices whose labels are then computed incorrectly, returning 49
spurious "equilibria" with a normalised regret of 1.0 at ×1e9, and crashing in find_feasible_point
at ×1e15. Fixing the conditioning at the source avoids that.

Test plan

  • New regression test test_with_large_utilities, checking all three equilibria of Battle of the
    Sexes at scales 1, 1e6, 1e9 and 1e12. It fails on main with 0 = len([]) and passes here.
  • Verified independently in exact rational arithmetic: with the fix, every returned profile has a
    normalised regret at float precision (2.8e-17 or lower) for scales up to ×1e18.

Full tox command set locally:

black --check src/ tests/ clean
mypy --ignore-missing-imports src/nashpy Success, no issues in 25 files
interrogate --fail-under 100 100%
pytest . --cov=nashpy --cov-fail-under=100 + doctests 225 passed, 100% coverage
flake8 src/ tests/ clean

The first commit: unbreaking CI

Two tox commands fail on main under current tooling releases, and both run before the tests, so CI
is currently red on every open pull request:

  • black --check src/ — recent black releases collapse these short .format calls onto a single
    line (game.py, support_enumeration.py).
  • mypyimitation_dynamics is annotated Generator[Tuple[float, float], Any, None] but yields a
    pair of arrays. Corrected to Tuple[npt.NDArray, npt.NDArray].

Formatting and one annotation, no behaviour change. Kept as a separate first commit so it can be
dropped or cherry-picked independently if you would rather handle it another way.

@choosen23
choosen23 force-pushed the fix-vertex-enumeration-payoff-scale branch from 2deb3a7 to 99ee7f1 Compare August 26, 2026 09:40
Two checks in the tox env currently fail on main, so CI stops before the
tests run. This affects every open pull request.

`black --check src/` fails because recent releases collapse these short
`.format` calls onto a single line.

`mypy` fails on the return annotation of `imitation_dynamics`, which
declares `Tuple[float, float]` but yields a pair of arrays.

No behaviour change.
@choosen23

Copy link
Copy Markdown
Author

A note on the red CI here: the run fails at the first tox command, black --check src/, on game.py and support_enumeration.py — neither of which this branch touches. It never reaches the tests.

Both of those, plus a mypy error in imitation_dynamics.py, are pre-existing on main under current tooling releases, so CI is red for every open pull request right now. I have put the fix for that in #245, which is formatting and one type annotation only.

Once #245 is in I will rebase this branch and CI should be green. Happy to reorder or combine them if you would rather.

The best response polytope is {x >= 0, Mx <= 1}, so its vertex coordinates
scale as 1 / payoff. `non_trivial_vertices` discards the origin using
`np.isclose(v, 0)`, which falls back to an absolute tolerance of 1e-8. Once
payoffs are large enough every legitimate vertex falls below that tolerance
and is discarded, so `vertex_enumeration` yields nothing at all.

Rock-Paper-Scissors and Battle of the Sexes are both affected at a payoff
scale of 1e9, having been solved correctly at 1e6. Neither game is
degenerate, and multiplying every payoff by a constant cannot change the
equilibria.

Nash equilibria are invariant under a positive rescaling of each player's
payoffs, so normalising before the polytopes are built keeps them well
conditioned without changing the result.
@choosen23
choosen23 force-pushed the fix-vertex-enumeration-payoff-scale branch from 99ee7f1 to fd0607b Compare August 26, 2026 09:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant