Skip to content
Open
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
40 changes: 21 additions & 19 deletions src/lemke/bimatrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@

from . import columnprint, lemke, randomstart, utils
from .randomstart import MAX_ACCURACY
from .utils import MAXDECIMALS

# file format:
# <m> <n>
Expand Down Expand Up @@ -45,14 +44,13 @@ def rangesplit(s, endrange=50):
class payoffmatrix:
# create matrix from any numerical matrix
def __init__(self, A):
AA = np.array(A)
AA = np.array(A, dtype=object)
if not all(isinstance(x, fractions.Fraction) for x in AA.flat):
raise TypeError("matrix must contain only Fraction values")
m, n = AA.shape
Comment on lines +47 to 50
self.numrows = m
self.numcolumns = n
self.matrix = np.zeros((m, n), dtype=fractions.Fraction)
for i in range(m):
for j in range(n):
self.matrix[i][j] = utils.tofraction(AA[i][j])
self.matrix = AA
self.fullmaxmin()

def __str__(self):
Expand Down Expand Up @@ -83,12 +81,16 @@ def fullmaxmin(self):

# add full row, row must be of size n
def addrow(self, row):
if not all(isinstance(x, fractions.Fraction) for x in row):
raise TypeError("New row must contain only Fraction values")
self.matrix = np.vstack([self.matrix, row])
self.numrows += 1
self.updatemaxmin(self.numrows - 1, 0)

# add full column, col must be of size m
def addcolumn(self, col):
if not all(isinstance(x, fractions.Fraction) for x in col):
raise TypeError("New column must contain only Fraction values")
self.matrix = np.column_stack([self.matrix, col])
self.numcolumns += 1
self.updatemaxmin(0, self.numcolumns - 1)
Expand All @@ -101,7 +103,8 @@ def __init__(self, A, B):

# create A,B from file
@classmethod
def from_file(cls, filename):
def from_file(cls, filename, decimals=utils.DEFAULT_DECIMALS):
utils.validate_decimals(decimals)
lines = utils.stripcomments(filename)
# flatten into words
words = utils.towords(lines)
Expand All @@ -113,10 +116,10 @@ def from_file(cls, filename):
print("m=", m, ", n=", n, ", need", needfracs, "payoffs, got", len(words) - 2)
exit(1)
k = 2
C = utils.tomatrix(m, n, words, k)
C = utils.tomatrix(m, n, words, k, decimals)
A = payoffmatrix(C)
k += m * n
C = utils.tomatrix(m, n, words, k)
C = utils.tomatrix(m, n, words, k, decimals)
B = payoffmatrix(C)
return cls(A, B)

Expand Down Expand Up @@ -300,9 +303,9 @@ def common_options(f):
)
@click.option(
"--decimals",
default=4,
default=utils.DEFAULT_DECIMALS,
show_default=True,
type=click.IntRange(min=0, max=MAXDECIMALS),
type=click.IntRange(min=0, max=utils.MAXDECIMALS),
metavar="INTEGER",
help="Allowed payoff digits in input after decimal point",
)
Expand All @@ -312,8 +315,7 @@ def common_options(f):
# help="Show value of z0 at each step",
# )
@wraps(f)
def wrapper(*args, decimals, **kwargs):
utils.setdecimals(decimals)
def wrapper(*args, **kwargs):
return f(*args, **kwargs)
return wrapper

Expand All @@ -326,10 +328,10 @@ def wrapper(*args, decimals, **kwargs):
help="Missing labels, e.g. 1,3-5,7- "
"[default: all labels]",
)
def lh(filename, labels):
def lh(filename, decimals, labels):
"""Find equilibria using the Lemke-Howson algorithm."""

G = bimatrix.from_file(filename)
G = bimatrix.from_file(filename, decimals)
G.LH(labels)


Expand All @@ -341,10 +343,10 @@ def trace():

@trace.command(name="uniform")
@common_options
def trace_uniform_cmd(filename):
def trace_uniform_cmd(filename, decimals):
"""Trace using a uniform prior."""

G = bimatrix.from_file(filename)
G = bimatrix.from_file(filename, decimals)
G.trace_uniform_prior()


Expand All @@ -371,8 +373,8 @@ def trace_uniform_cmd(filename):
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):
def trace_random_cmd(filename, decimals, priors, seed, accuracy):
"""Trace using random prior(s)."""

G = bimatrix.from_file(filename)
G = bimatrix.from_file(filename, decimals)
G.trace_random_priors(priors, seed, accuracy)
21 changes: 15 additions & 6 deletions src/lemke/lemke.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ def __init__(self, M, q, d):
self.n = len(d)

@classmethod
def from_file(cls, filename):
def from_file(cls, filename, decimals=utils.DEFAULT_DECIMALS):
utils.validate_decimals(decimals)
# create LCP from file
lines = utils.stripcomments(filename)
# flatten into words
Expand All @@ -42,15 +43,15 @@ def from_file(cls, filename):
while k < len(words):
if words[k] == "M=":
k += 1
M = utils.tomatrix(n, n, words, k)
M = utils.tomatrix(n, n, words, k, decimals)
k += n * n
elif words[k] == "q=":
k += 1
q = utils.tovector(n, words, k)
q = utils.tovector(n, words, k, decimals)
k += n
elif words[k] == "d=":
k += 1
d = utils.tovector(n, words, k)
d = utils.tovector(n, words, k, decimals)
k += n
else:
raise ValueError(
Expand Down Expand Up @@ -557,18 +558,26 @@ def runlemke(*, lcp, callback=None):
is_flag=True,
help="Show value of z0 at each step",
)
@click.option(
"--decimals",
default=utils.DEFAULT_DECIMALS,
show_default=True,
type=click.IntRange(min=0, max=utils.MAXDECIMALS),
metavar="INTEGER",
help="Allowed payoff digits in input after decimal point",
)
@click.argument(
"lcpfilename",
type=click.Path(exists=True, readable=True, file_okay=True, dir_okay=False),
)
def main(verbose, z0, lcpfilename):
def main(verbose, z0, decimals, lcpfilename):
"""
Tool for solving linear complementarity problems using Lemke's algorithm.

LCPFILENAME is the path to the input file.
"""

m = lcp.from_file(lcpfilename)
m = lcp.from_file(lcpfilename, decimals)

result = runlemke(
lcp=m,
Expand Down
73 changes: 43 additions & 30 deletions src/lemke/utils.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,31 @@
# file utilities
# tofraction utilities with global decimals

import fractions
from decimal import (
ROUND_HALF_UP,
Decimal,
InvalidOperation,
)

import numpy as np

# global constants, mutable
# https://stackoverflow.com/questions/1977362/how-to-create-module-wide-variables-in-python
decimals = 4
deciDenom = 10 ** decimals
DEFAULT_DECIMALS = 4
MAXDECIMALS = 20
# roundingwarn = False


def setdecimals(n):
global decimals, deciDenom
if 0 <= n <= MAXDECIMALS:
decimals = n
deciDenom = 10 ** decimals
else:
# if roundingwarn:
print(n, "as number of decimals not in allowed range 0 to", MAXDECIMALS)
return
commentchars = "#%*" # lines starting with these are ignored


commentchars = "#%*" # lines starting with these are ignored
def validate_decimals(decimals):
if not isinstance(decimals, int):
raise TypeError("decimals must be an integer")
if decimals < 0 or decimals > MAXDECIMALS:
raise ValueError(
f"{decimals} as number of decimals not in allowed range 0 to {MAXDECIMALS}"
)


# read file into list of line-strings
Expand Down Expand Up @@ -53,35 +54,47 @@ def towords(lines):
return words


# convert s to fraction
# if s contains ".": convert to decimal fraction
# (numerator deciDenom)
def tofraction(s):
if isinstance(s, str) and "." in s:
s = float(s)
if isinstance(s, float):
num = int(abs(s) * deciDenom + 0.5) # round .5 away from zero
if s < 0:
num = -num
return fractions.Fraction(num, deciDenom)
# any other s than a float or string containing '.':
return fractions.Fraction(s)
def tofraction(s: str, decimals: int) -> fractions.Fraction:
"""Convert a string to an exact Fraction.

If `s` contains '.', it's treated as a decimal literal and
rounded to `decimals` places (half away from zero).
Otherwise, it's parsed as an integer or 'p/q' fraction string.
"""
if not isinstance(s, str):
raise TypeError(
f"tofraction expects a string, got {type(s).__name__}: {s!r}"
)

if "." in s:
try:
d = Decimal(s)
except InvalidOperation as e:
raise ValueError(f"{s!r} is not a valid decimal number") from e
denominator = 10 ** decimals
scaled = (d * denominator).to_integral_value(rounding=ROUND_HALF_UP)
return fractions.Fraction(int(scaled), denominator)

try:
return fractions.Fraction(s)
except (ValueError, ZeroDivisionError) as e:
raise ValueError(f"{s!r} is not a valid number or fraction: {e}") from e


# create n-vector of fractions from words[start,start+n)
def tovector(n, words, start):
def tovector(n, words, start, decimals):
vector = np.zeros(n, dtype=fractions.Fraction)
for i in range(n):
vector[i] = tofraction(words[start + i])
vector[i] = tofraction(words[start + i], decimals)
return vector


# create (m,n)-matrix of fractions from words[start,start+m*n)
def tomatrix(m, n, words, start):
def tomatrix(m, n, words, start, decimals):
C = np.zeros((m, n), dtype=fractions.Fraction)
k = start
for i in range(m):
for j in range(n):
C[i][j] = tofraction(words[k])
C[i][j] = tofraction(words[k], decimals)
k += 1
return C
10 changes: 4 additions & 6 deletions tests/test_bimatrix_units.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import pytest
from click.testing import CliRunner

from lemke import utils
from lemke.bimatrix import (
bimatrix,
lh,
Expand All @@ -24,8 +23,8 @@
@pytest.fixture
def small_payoff_matrix():
return payoffmatrix([
[1, 2],
[3, 4],
[Fraction(1), Fraction(2)],
[Fraction(3), Fraction(4)],
])


Expand Down Expand Up @@ -64,7 +63,7 @@ def test_payoff_matrix_negshift_negmatrix(small_payoff_matrix):

def test_addrow_updates_shape_max_min(small_payoff_matrix):
pm = small_payoff_matrix
pm.addrow([10, -10])
pm.addrow([Fraction(10), Fraction(-10)])
assert pm.numrows == 3
assert pm.matrix[2][0] == Fraction(10)
assert pm.max == Fraction(10)
Expand All @@ -73,7 +72,7 @@ def test_addrow_updates_shape_max_min(small_payoff_matrix):

def test_addcolumn_updates_shape_max_min(small_payoff_matrix):
pm = small_payoff_matrix
pm.addcolumn([-5, 20])
pm.addcolumn([Fraction(-5), Fraction(20)])
assert pm.numcolumns == 3
assert pm.matrix[0][2] == Fraction(-5)
assert pm.max == Fraction(20)
Expand All @@ -83,7 +82,6 @@ def test_addcolumn_updates_shape_max_min(small_payoff_matrix):
# --- BIMATRIX INIT --------------------------------------------------
@pytest.fixture
def small_game_file(tmp_path):
utils.setdecimals(4)
content = textwrap.dedent("""
2 2

Expand Down
18 changes: 18 additions & 0 deletions tests/test_lcp_units.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,24 @@ def test_lcp_valid_file(tmp_path, raw, expected):
assert m.M[0][0] == expected


@pytest.mark.parametrize(
"raw, expected, decimals",
[
("0.00015", Fraction(2, 10000), 4),
("0.145", Fraction(3, 20), 2),
("9999999999999991.1", Fraction(9999999999999991, 1), 0),
("9999999999999999.1", Fraction(99999999999999991, 10), 4),
]
)
def test_lcp_file_parsing_in_fractions(tmp_path, raw, expected, decimals):
content = f"n= 1\nM= {raw}\nq= 1\nd= 1\n"
file_path = tmp_path / "lcp"
file_path.write_text(content)

m = lcp.from_file(str(file_path), decimals)
assert m.M[0][0] == expected


@pytest.mark.parametrize("content", [
"M= 1 0 0 1 q= 1 1 d= 1 1\n", # missing n=
"n= 2\nM= 1 0 0 1\nq= 1 1\nd= 1\n", # wrong number of values
Expand Down
Loading