""" Unit tests for the :class:`SymmetricLinearGame` class. """ from unittest import TestCase from dunshire.games import SymmetricLinearGame from dunshire.matrices import eigenvalues_re, inner_product from dunshire import options from .randomgen import (random_icecream_game, random_ll_icecream_game, random_ll_orthant_game, random_nn_scaling, random_orthant_game, random_positive_orthant_game, random_translation) # Tell pylint to shut up about the large number of methods. class SymmetricLinearGameTest(TestCase): # pylint: disable=R0904 """ Tests for the SymmetricLinearGame and Solution classes. """ def assert_within_tol(self, first, second, modifier=1): """ Test that ``first`` and ``second`` are equal within a multiple of our default tolerances. Parameters ---------- first : float The first number to compare. second : float The second number to compare. modifier : float A scaling factor (default: 1) applied to the default tolerance for this comparison. If you have a poorly- conditioned matrix, for example, you may want to set this greater than one. """ self.assertTrue(abs(first - second) < options.ABS_TOL*modifier) def test_condition_lower_bound(self): """ Ensure that the condition number of a game is greater than or equal to one. It should be safe to compare these floats directly: we compute the condition number as the ratio of one nonnegative real number to a smaller nonnegative real number. """ G = random_orthant_game() self.assertTrue(G.condition() >= 1.0) G = random_icecream_game() self.assertTrue(G.condition() >= 1.0) def assert_scaling_works(self, G): """ Test that scaling ``L`` by a nonnegative number scales the value of the game by the same number. """ (alpha, H) = random_nn_scaling(G) value1 = G.solution().game_value() value2 = H.solution().game_value() modifier = 4*max(abs(alpha), 1) self.assert_within_tol(alpha*value1, value2, modifier) def test_scaling_orthant(self): """ Test that scaling ``L`` by a nonnegative number scales the value of the game by the same number over the nonnegative orthant. """ G = random_orthant_game() self.assert_scaling_works(G) def test_scaling_icecream(self): """ The same test as :meth:`test_nonnegative_scaling_orthant`, except over the ice cream cone. """ G = random_icecream_game() self.assert_scaling_works(G) def assert_translation_works(self, G): """ Check that translating ``L`` by alpha*(e1*e2.trans()) increases the value of the associated game by alpha. """ # We need to use ``L`` later, so make sure we transpose it # before passing it in as a column-indexed matrix. soln1 = G.solution() value1 = soln1.game_value() x_bar = soln1.player1_optimal() y_bar = soln1.player2_optimal() # This is the "correct" representation of ``M``, but COLUMN # indexed... (alpha, H) = random_translation(G) value2 = H.solution().game_value() modifier = 4*max(abs(alpha), 1) self.assert_within_tol(value1 + alpha, value2, modifier) # Make sure the same optimal pair works. self.assert_within_tol(value2, H.payoff(x_bar, y_bar), modifier) def test_translation_orthant(self): """ Test that translation works over the nonnegative orthant. """ G = random_orthant_game() self.assert_translation_works(G) def test_translation_icecream(self): """ The same as :meth:`test_translation_orthant`, except over the ice cream cone. """ G = random_icecream_game() self.assert_translation_works(G) def assert_opposite_game_works(self, G): """ Check the value of the "opposite" game that gives rise to a value that is the negation of the original game. Comes from some corollary. """ # This is the "correct" representation of ``M``, but # COLUMN indexed... M = -G.L().trans() # so we have to transpose it when we feed it to the constructor. # Note: the condition number of ``H`` should be comparable to ``G``. H = SymmetricLinearGame(M.trans(), G.K(), G.e2(), G.e1()) soln1 = G.solution() x_bar = soln1.player1_optimal() y_bar = soln1.player2_optimal() soln2 = H.solution() # The modifier of 4 is because each could be off by 2*ABS_TOL, # which is how far apart the primal/dual objectives have been # observed being. self.assert_within_tol(-soln1.game_value(), soln2.game_value(), 4) # Make sure the switched optimal pair works. Since x_bar and # y_bar come from G, we use the same modifier. self.assert_within_tol(soln2.game_value(), H.payoff(y_bar, x_bar), 4) def test_opposite_game_orthant(self): """ Test the value of the "opposite" game over the nonnegative orthant. """ G = random_orthant_game() self.assert_opposite_game_works(G) def test_opposite_game_icecream(self): """ Like :meth:`test_opposite_game_orthant`, except over the ice-cream cone. """ G = random_icecream_game() self.assert_opposite_game_works(G) def assert_orthogonality(self, G): """ Two orthogonality relations hold at an optimal solution, and we check them here. """ soln = G.solution() x_bar = soln.player1_optimal() y_bar = soln.player2_optimal() value = soln.game_value() ip1 = inner_product(y_bar, G.L()*x_bar - value*G.e1()) ip2 = inner_product(value*G.e2() - G.L().trans()*y_bar, x_bar) # Huh.. well, y_bar and x_bar can each be epsilon away, but # x_bar is scaled by L, so that's (norm(L) + 1), and then # value could be off by epsilon, so that's another norm(e1) or # norm(e2). On the other hand, this test seems to pass most of # the time even with a modifier of one. How about.. four? self.assert_within_tol(ip1, 0, 4) self.assert_within_tol(ip2, 0, 4) def test_orthogonality_orthant(self): """ Check the orthgonality relationships that hold for a solution over the nonnegative orthant. """ G = random_orthant_game() self.assert_orthogonality(G) def test_orthogonality_icecream(self): """ Check the orthgonality relationships that hold for a solution over the ice-cream cone. """ G = random_icecream_game() self.assert_orthogonality(G) def test_positive_operator_value(self): """ Test that a positive operator on the nonnegative orthant gives rise to a a game with a nonnegative value. This test theoretically applies to the ice-cream cone as well, but we don't know how to make positive operators on that cone. """ G = random_positive_orthant_game() self.assertTrue(G.solution().game_value() >= -options.ABS_TOL) def assert_lyapunov_works(self, G): """ Check that Lyapunov games act the way we expect. """ soln = G.solution() # We only check for positive/negative stability if the game # value is not basically zero. If the value is that close to # zero, we just won't check any assertions. # # See :meth:`assert_within_tol` for an explanation of the # fudge factors. eigs = eigenvalues_re(G.L()) if soln.game_value() > options.ABS_TOL: # L should be positive stable positive_stable = all([eig > -options.ABS_TOL for eig in eigs]) self.assertTrue(positive_stable) elif soln.game_value() < -options.ABS_TOL: # L should be negative stable negative_stable = all([eig < options.ABS_TOL for eig in eigs]) self.assertTrue(negative_stable) # The dual game's value should always equal the primal's. # The modifier of 4 is because even though the games are dual, # CVXOPT doesn't know that, and each could be off by 2*ABS_TOL. dualsoln = G.dual().solution() self.assert_within_tol(dualsoln.game_value(), soln.game_value(), 4) def test_lyapunov_orthant(self): """ Test that a Lyapunov game on the nonnegative orthant works. """ G = random_ll_orthant_game() self.assert_lyapunov_works(G) def test_lyapunov_icecream(self): """ Test that a Lyapunov game on the ice-cream cone works. """ G = random_ll_icecream_game() self.assert_lyapunov_works(G)