diff --git a/src/lemke/bimatrix.py b/src/lemke/bimatrix.py index 4c9bb79..988fce2 100644 --- a/src/lemke/bimatrix.py +++ b/src/lemke/bimatrix.py @@ -2,21 +2,14 @@ import fractions import random # random.seed -import sys +from functools import wraps +import click import numpy as np from . import columnprint, lemke, randomstart, utils - - -# for debugging -def printglobals(): - globs = [x for x in globals() if "__" not in x] - for var in globs: - value = str(globals()[var]) - if "<" not in value: - print(" " + str(var) + "=", value) - +from .randomstart import MAX_ACCURACY +from .utils import MAXDECIMALS # file format: # @@ -27,87 +20,6 @@ def printglobals(): # defaults # MAXDIM = 2000 # largest allowed value for m and n; not used yet -gamefilename = "game" -gz0 = False -LHstring = "" # empty means LH not called -seed = -1 -trace = -1 # negative: no tracing -# accuracy = DEFAULT_accuracy = 1000 -accuracy = 1000 - - -# amends defaults -def processArguments(): - global gamefilename, gz0, LHstring, seed, trace, accuracy - arglist = sys.argv[1:] - setLH = False - settrace = False - setaccuracy = False - setseed = False - setdecimals = False - showhelp = False - for s in arglist: - if s[0] == "-": - # discard optional argument-parameters - if setLH or settrace or setseed: - setLH = settrace = setseed = False - if s == "-LH": - setLH = True - LHstring = "1-" - elif s == "-trace": - settrace = True - trace = 0 - elif s == "-seed": - setseed = True - seed = 0 - elif s == "-accuracy": - setaccuracy = True - elif s == "-decimals": - setdecimals = True - elif s == "-z0": - gz0 = True - elif s == "-?" or s == "-help": - showhelp = True - else: # any other "-" argument - showhelp = True - print("! unknown option: ", repr(s)) - else: # s not starting with "-" - if setLH: - setLH = False - LHstring = s - elif settrace: - settrace = False - trace = int(s) - elif setseed: - setseed = False - seed = int(s) - elif setdecimals: - setdecimals = False - utils.setdecimals(int(s)) - elif setaccuracy: - setaccuracy = False - accuracy = int(s) - else: - gamefilename = s - if showhelp: - helpstring = ( - """usage: bimatrix.py [options] -options: - here: """ - + repr(gamefilename) - + """, must not start with '-' - -LH [] : Lemke-Howson with missing labels, e.g. '1,3-5,7-' ('' = all) - -trace []: tracing procedure, no. of priors, 0 = centroid - -seed [] : random seed, default: None - -accuracy : accuracy prior, =denominator, here """ - + str(accuracy) - + """ - -decimals : allowed payoff digits in input after decimal point, default 4 - -?, -help: show this help and exit""" - ) - print(helpstring) - exit(0) - return # list generated from string s such as "1-3,10,4-7", all not @@ -251,8 +163,6 @@ def runLH(self, droppedlabel): return tuple(getequil(tabl)) def LH(self, LHstring): - if LHstring == "": - return m = self.A.numrows n = self.A.numcolumns lhset = {} # dict of equilibria and list by which label found @@ -278,37 +188,51 @@ def runtrace(self, xprior, yprior): tabl.runlemke() return tuple(getequil(tabl)) - def tracing(self, trace): - if trace < 0: - return + def trace_uniform_prior(self): + m = self.A.numrows + n = self.A.numcolumns + + xprior = uniform(m) + yprior = uniform(n) + eq = self.runtrace(xprior, yprior) + + self._print_trace_results( + equilibria={eq: 1}, + total_priors=1, + ) + + def trace_random_priors(self, trace, seed=None, accuracy=1000): + if trace <= 0: + raise ValueError("Number of priors must be a positive integer") m = self.A.numrows n = self.A.numcolumns trset = {} # dict of equilibria, how often found - if trace == 0: - xprior = uniform(m) - yprior = uniform(n) + + for k in range(trace): + if seed is not None: + random.seed(10 * trace * seed + k) + x = randomstart.randInSimplex(m) + xprior = randomstart.roundArray(x, accuracy) + y = randomstart.randInSimplex(n) + yprior = randomstart.roundArray(y, accuracy) + # print (f"{k=} {xprior=} {yprior=}") eq = self.runtrace(xprior, yprior) - trset[eq] = 1 - trace = 1 # for percentage - else: - for k in range(trace): - if seed >= 0: - random.seed(10 * trace * seed + k) - x = randomstart.randInSimplex(m) - xprior = randomstart.roundArray(x, accuracy) - y = randomstart.randInSimplex(n) - yprior = randomstart.roundArray(y, accuracy) - # print (f"{k=} {xprior=} {yprior=}") - eq = self.runtrace(xprior, yprior) - if eq in trset: - trset[eq] += 1 - else: - print("found eq", str_eq(eq, m, n), "index", self.eqindex(eq, m, n)) - trset[eq] = 1 + if eq in trset: + trset[eq] += 1 + else: + print("found eq", str_eq(eq, m, n), "index", self.eqindex(eq, m, n)) + trset[eq] = 1 + + self._print_trace_results(trset, trace) + + def _print_trace_results(self, equilibria, total_priors): + m = self.A.numrows + n = self.A.numcolumns + print("-------- statistics of equilibria found: --------") - for eq in trset: - print(trset[eq], "times found ", str_eq(eq, m, n)) - print(trace, "total priors,", len(trset), "equilibria found") + for eq, count in equilibria.items(): + print(count, "times found", str_eq(eq, m, n)) + print(total_priors, "total priors,", len(equilibria), "equilibria found") def eqindex(self, eq, m, n): rowset, colset = supports(eq, m, n) @@ -358,15 +282,94 @@ def submatrix(A, rowset, colset): return B +@click.group( + context_settings={"help_option_names": ["-?", "-h", "--help"]}, +) def main(): - processArguments() - printglobals() - - G = bimatrix.from_file(gamefilename) - print(G) - G.LH(LHstring) - G.tracing(trace) - - -if __name__ == "__main__": - main() + """Find Nash equilibria of a bimatrix game.""" + pass + + +def common_options(f): + @click.argument( + "filename", + type=click.Path(exists=True, readable=True, file_okay=True, dir_okay=False), + ) + @click.option( + "--decimals", + default=4, + show_default=True, + type=click.IntRange(min=0, max=MAXDECIMALS), + metavar="INTEGER", + help="Allowed payoff digits in input after decimal point", + ) + # @click.option( + # "--z0", + # is_flag=True, + # help="Show value of z0 at each step", + # ) + @wraps(f) + def wrapper(*args, decimals, **kwargs): + utils.setdecimals(decimals) + return f(*args, **kwargs) + return wrapper + + +@main.command() +@common_options +@click.option( + "--labels", + default="1-", + help="Missing labels, e.g. 1,3-5,7- " + "[default: all labels]", +) +def lh(filename, labels): + """Find equilibria using the Lemke-Howson algorithm.""" + + G = bimatrix.from_file(filename) + G.LH(labels) + + +@main.group() +def trace(): + """Find equilibria using the tracing procedure.""" + pass + + +@trace.command(name="uniform") +@common_options +def trace_uniform_cmd(filename): + """Trace using a uniform prior.""" + + G = bimatrix.from_file(filename) + G.trace_uniform_prior() + + +@trace.command(name="random") +@common_options +@click.option( + "--priors", + default=1, + show_default=True, + type=click.IntRange(min=1), + metavar="INTEGER", + help="Number of random priors", +) +@click.option( + "--seed", + type=int, + help="Random seed", +) +@click.option( + "--accuracy", + default=1000, + show_default=True, + type=click.IntRange(1, MAX_ACCURACY), + metavar="INTEGER", + help="Denominator x: each coordinate of the prior is rounded to the nearest 1/x", +) +def trace_random_cmd(filename, priors, seed, accuracy): + """Trace using random prior(s).""" + + G = bimatrix.from_file(filename) + G.trace_random_priors(priors, seed, accuracy) diff --git a/tests/test_bimatrix.py b/tests/test_bimatrix.py index b6f8f4c..01d5d74 100644 --- a/tests/test_bimatrix.py +++ b/tests/test_bimatrix.py @@ -1,5 +1,7 @@ """ Test Lemke-Howson algorithm and tracing procedure on bimatrix games. +Tracing procedure is tested with a uniform prior, as well as random priors. +Seed is fixed, so that random priors are reproducible. Conditions checked: For games with unique equilibrium: @@ -15,6 +17,7 @@ import random import typing from fractions import Fraction as Fr +from functools import partial from pathlib import Path import pygambit @@ -33,40 +36,46 @@ def lh_solver(G: bimatrix) -> list[list[Fr]]: return [list(eq_key) for eq_key in lh_eqs_dict] -def tracing_solver( - G: bimatrix, - trace: int = 10, - seed: int = 0, - accuracy: int = 1000, -) -> list[list[Fr]]: - """ Run tracing & return the list of equilibria found. """ +def trace_uniform_prior(game: bimatrix) -> list[list[Fr]]: + """Copied from bimatrix.py, but explicitly returns equilibria.""" - if trace < 0: - return [] + m = game.A.numrows + n = game.A.numcolumns - m = G.A.numrows - n = G.A.numcolumns - trset = {} - - if trace == 0: - xprior = uniform(m) - yprior = uniform(n) - eq = G.runtrace(xprior, yprior) - trset[eq] = 1 - trace = 1 - else: - for k in range(trace): - if seed >= 0: - random.seed(10 * trace * seed + k) - x = randomstart.randInSimplex(m) - xprior = randomstart.roundArray(x, accuracy) - y = randomstart.randInSimplex(n) - yprior = randomstart.roundArray(y, accuracy) - eq = G.runtrace(xprior, yprior) - if eq in trset: - trset[eq] += 1 - else: - trset[eq] = 1 + xprior = uniform(m) + yprior = uniform(n) + eq = game.runtrace(xprior, yprior) + + return [list(eq)] + + +# trace = 10, seed = 0 +def trace_random_priors( + game: bimatrix, + trace, + seed=None, + accuracy=1000, +) -> list[list[Fr]]: + """Copied from bimatrix.py, but explicitly returns equilibria.""" + + if trace <= 0: + raise ValueError("Number of priors must be a positive integer") + m = game.A.numrows + n = game.A.numcolumns + trset = {} # dict of equilibria, how often found + + for k in range(trace): + if seed is not None: + random.seed(10 * trace * seed + k) + x = randomstart.randInSimplex(m) + xprior = randomstart.roundArray(x, accuracy) + y = randomstart.randInSimplex(n) + yprior = randomstart.roundArray(y, accuracy) + eq = game.runtrace(xprior, yprior) + if eq in trset: + trset[eq] += 1 + else: + trset[eq] = 1 return [list(eq) for eq in trset] @@ -193,7 +202,8 @@ class GameTestCase: SOLVERS = [ pytest.param(lh_solver, id="LH"), - pytest.param(tracing_solver, id="tracing"), + pytest.param(trace_uniform_prior, id="trace_uniform"), + pytest.param(partial(trace_random_priors, trace=10, seed=0), id="trace_random"), ] diff --git a/tests/test_bimatrix_units.py b/tests/test_bimatrix_units.py new file mode 100644 index 0000000..130573e --- /dev/null +++ b/tests/test_bimatrix_units.py @@ -0,0 +1,336 @@ +import textwrap +from fractions import Fraction + +import pytest +from click.testing import CliRunner + +from lemke import utils +from lemke.bimatrix import ( + bimatrix, + lh, + payoffmatrix, + rangesplit, + submatrix, + supports, + trace_random_cmd, + trace_uniform_cmd, + uniform, +) +from lemke.randomstart import MAX_ACCURACY +from lemke.utils import MAXDECIMALS + + +# --- PAYOFF MATRIX -------------------------------------------------- +@pytest.fixture +def small_payoff_matrix(): + return payoffmatrix([ + [1, 2], + [3, 4], + ]) + + +def test_payoff_matrix_init(small_payoff_matrix): + pm = small_payoff_matrix + assert pm.numrows == 2 + assert pm.numcolumns == 2 + + assert all( + isinstance(pm.matrix[i][j], Fraction) + for i in range(2) for j in range(2) + ) + assert pm.matrix[1][1] == Fraction(4) + + +def test_payoff_matrix_max_min(small_payoff_matrix): + pm = small_payoff_matrix + assert pm.max == Fraction(4) + assert pm.min == Fraction(1) + + +def test_payoff_matrix_negshift_negmatrix(small_payoff_matrix): + pm = small_payoff_matrix + + assert pm.negshift == 5 # int(max) + 1 + + expected = [ + [pm.negshift - pm.matrix[0][0], pm.negshift - pm.matrix[0][1]], + [pm.negshift - pm.matrix[1][0], pm.negshift - pm.matrix[1][1]], + ] + + for i, row in enumerate(expected): + for j, value in enumerate(row): + assert pm.negmatrix[i][j] == value + + +def test_addrow_updates_shape_max_min(small_payoff_matrix): + pm = small_payoff_matrix + pm.addrow([10, -10]) + assert pm.numrows == 3 + assert pm.matrix[2][0] == Fraction(10) + assert pm.max == Fraction(10) + assert pm.min == Fraction(-10) + + +def test_addcolumn_updates_shape_max_min(small_payoff_matrix): + pm = small_payoff_matrix + pm.addcolumn([-5, 20]) + assert pm.numcolumns == 3 + assert pm.matrix[0][2] == Fraction(-5) + assert pm.max == Fraction(20) + assert pm.min == Fraction(-5) + + +# --- BIMATRIX INIT -------------------------------------------------- +@pytest.fixture +def small_game_file(tmp_path): + utils.setdecimals(4) + content = textwrap.dedent(""" + 2 2 + + # A= + 1 0.75 + -1/3 2/4 + + # B= + .4 3/1 + -2 1.0 + """) + path = tmp_path / "small_game.txt" + path.write_text(content) + return str(path) + + +def test_bimatrix_dimensions(small_game_file): + G = bimatrix.from_file(small_game_file) + assert G.A.numrows == 2 + assert G.A.numcolumns == 2 + assert G.B.numrows == 2 + assert G.B.numcolumns == 2 + + +def test_bimatrix_payoff_values(small_game_file): + G = bimatrix.from_file(small_game_file) + + expected = [ + [Fraction(1), Fraction(3, 4)], + [Fraction(-1, 3), Fraction(1, 2)] + ] + for i, row in enumerate(expected): + for j, value in enumerate(row): + assert G.A.matrix[i][j] == value + + expected = [ + [Fraction(2, 5), Fraction(3)], + [Fraction(-2), Fraction(1)] + ] + for i, row in enumerate(expected): + for j, value in enumerate(row): + assert G.B.matrix[i][j] == value + + +def test_bimatrix_invalid_file_exits(tmp_path): + bad_content = "2 2\n1 2 3\n" + path = tmp_path / "bad_game.txt" + path.write_text(bad_content) + with pytest.raises(SystemExit): + bimatrix.from_file(str(path)) + + +# --- BIMATRIX LCP ---------------------------------------------------- +def test_q_last_two_entries_are_minus_one(small_game_file): + G = bimatrix.from_file(small_game_file) + lcp = G.createLCP() + assert lcp.q[-1] == -1 + assert lcp.q[-2] == -1 + + +def test_d_defaults_to_all_ones(small_game_file): + G = bimatrix.from_file(small_game_file) + lcp = G.createLCP() + assert all(d == 1 for d in lcp.d) + + +def test_player_block_signs(small_game_file): + G = bimatrix.from_file(small_game_file) + lcp = G.createLCP() + m, n = G.A.numrows, G.A.numcolumns + lcpdim = m + n + 2 + + for i in range(m): + assert lcp.M[i][lcpdim - 2] == -1 + for i in range(m): + assert lcp.M[lcpdim - 2][i] == 1 + + for j in range(m, m + n): + assert lcp.M[j][lcpdim - 1] == -1 + for j in range(m, m + n): + assert lcp.M[lcpdim - 1][j] == 1 + + +def test_payoff_blocks_use_negmatrix(small_game_file): + G = bimatrix.from_file(small_game_file) + lcp = G.createLCP() + m, n = G.A.numrows, G.A.numcolumns + for i in range(m): + for j in range(n): + assert lcp.M[i][j + m] == G.A.negmatrix[i][j] + for j in range(n): + for i in range(m): + assert lcp.M[j + m][i] == G.B.negmatrix[i][j] + + +# --- TRACE WITH RANDOM PRIORS ------------------------------------------------- +@pytest.mark.parametrize("bad_priors", [0, -1, -100]) +def test_rejects_non_positive_num_priors(small_game_file, bad_priors): + G = bimatrix.from_file(small_game_file) + with pytest.raises(ValueError): + G.trace_random_priors(bad_priors) + + +# --- HELPER FUNCTIONS -------------------------------------------------------- +@pytest.mark.parametrize( + "input, expected", + [ + ("1,3,5", [1, 3, 5]), # single numbers + ("1, 3, 5", [1, 3, 5]), # single numbers with space + ("1-3", [1, 2, 3]), # simple range + ("1-3,10,4-7", [1, 2, 3, 10, 4, 5, 6, 7]), # mixed + ("8-", [8, 9, 10]), # open-ended range + ("1,8-", [1, 8, 9, 10]), # mixed with open-ended range + ("20-30", []), # range beyond "endrange" + ("1,,3", [1, 3]), # empty parts + ("", []), # empty input string + ] +) +def test_rangesplit(input, expected): + assert rangesplit(input, endrange=10) == expected + + +@pytest.mark.parametrize("n", [1, 2, 3, 7]) +def test_uniform(n): + result = uniform(n) + assert len(result) == n + assert sum(result) == Fraction(1, 1) + assert all(isinstance(x, Fraction) for x in result) + + +@pytest.mark.parametrize( + "eq, row_expected, col_expected", + [ + ((0, 0, 0, 0), [], []), + ((1, 1, 1, 1), [0, 1], [0, 1]), + ((1, 2, 0, 3), [0, 1], [1]), + ] +) +def test_supports(eq, row_expected, col_expected): + row_result, col_result = supports(eq, m=2, n=2) + assert row_result == row_expected + assert col_result == col_expected + + +def test_submatrix(): + result = submatrix( + A=[ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9], + ], + rowset=[0, 2], + colset=[1, 2], + ) + expected = [ + [2, 3], + [8, 9], + ] + + for i, row in enumerate(expected): + for j, value in enumerate(row): + assert result[i][j] == value + + +# --- CLI ------------------------------------------------------------ +@pytest.mark.parametrize( + "command, args", + [ + (lh, []), + (lh, [ + "--decimals", "10", + "--labels", "1-2", + ]), + + (trace_uniform_cmd, []), + (trace_uniform_cmd, [ + "--decimals", "10", + ]), + + (trace_random_cmd, []), + (trace_random_cmd, [ + "--decimals", "10", + "--priors", "10", + "--seed", "42", + "--accuracy", "100", + ]), + ] +) +def test_cli_runs_without_error(command, args, small_game_file): + runner = CliRunner() + result = runner.invoke(command, [str(small_game_file)] + args) + + assert result.exit_code == 0 + + +@pytest.mark.parametrize("command", [ + lh, + trace_uniform_cmd, + trace_random_cmd, +]) +def test_cli_rejects_missing_file(command, tmp_path): + missing_path = tmp_path / "missing" + + runner = CliRunner() + result = runner.invoke(command, [str(missing_path)]) + + assert result.exit_code == 2 + assert "does not exist" in result.output.lower() + + +@pytest.mark.parametrize("command", [ + lh, + trace_uniform_cmd, + trace_random_cmd, +]) +def test_cli_rejects_directory(command, tmp_path): + runner = CliRunner() + result = runner.invoke(command, [str(tmp_path)]) + + assert result.exit_code == 2 + assert "is a directory" in result.output.lower() + + +@pytest.mark.parametrize("command", [ + lh, + trace_uniform_cmd, + trace_random_cmd, +]) +@pytest.mark.parametrize("decimals", ["-1", f"{MAXDECIMALS + 1}"]) +def test_cli_invalid_decimals(command, decimals, tmp_path): + runner = CliRunner() + result = runner.invoke(command, [str(tmp_path), "--decimals", decimals]) + assert result.exit_code != 0 + assert "Invalid value" in result.output + + +@pytest.mark.parametrize("priors", ["-1", "0"]) +def test_trace_random_invalid_priors(priors, tmp_path): + runner = CliRunner() + result = runner.invoke(trace_random_cmd, [str(tmp_path), "--priors", priors]) + assert result.exit_code != 0 + assert "Invalid value" in result.output + + +@pytest.mark.parametrize("accuracy", ["-1", "0", f"{MAX_ACCURACY + 1}"]) +def test_trace_random_invalid_accuracy(accuracy, tmp_path): + runner = CliRunner() + result = runner.invoke(trace_random_cmd, ["--accuracy", accuracy]) + assert result.exit_code != 0 + assert "Invalid value" in result.output diff --git a/tests/test_lemke.py b/tests/test_lcp_units.py similarity index 100% rename from tests/test_lemke.py rename to tests/test_lcp_units.py