123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431 |
- import pytest
- import numpy as np
- from scipy.optimize import quadratic_assignment, OptimizeWarning
- from scipy.optimize._qap import _calc_score as _score
- from numpy.testing import assert_equal, assert_, assert_warns
- ################
- # Common Tests #
- ################
- def chr12c():
- A = [
- [0, 90, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0],
- [90, 0, 0, 23, 0, 0, 0, 0, 0, 0, 0, 0],
- [10, 0, 0, 0, 43, 0, 0, 0, 0, 0, 0, 0],
- [0, 23, 0, 0, 0, 88, 0, 0, 0, 0, 0, 0],
- [0, 0, 43, 0, 0, 0, 26, 0, 0, 0, 0, 0],
- [0, 0, 0, 88, 0, 0, 0, 16, 0, 0, 0, 0],
- [0, 0, 0, 0, 26, 0, 0, 0, 1, 0, 0, 0],
- [0, 0, 0, 0, 0, 16, 0, 0, 0, 96, 0, 0],
- [0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 29, 0],
- [0, 0, 0, 0, 0, 0, 0, 96, 0, 0, 0, 37],
- [0, 0, 0, 0, 0, 0, 0, 0, 29, 0, 0, 0],
- [0, 0, 0, 0, 0, 0, 0, 0, 0, 37, 0, 0],
- ]
- B = [
- [0, 36, 54, 26, 59, 72, 9, 34, 79, 17, 46, 95],
- [36, 0, 73, 35, 90, 58, 30, 78, 35, 44, 79, 36],
- [54, 73, 0, 21, 10, 97, 58, 66, 69, 61, 54, 63],
- [26, 35, 21, 0, 93, 12, 46, 40, 37, 48, 68, 85],
- [59, 90, 10, 93, 0, 64, 5, 29, 76, 16, 5, 76],
- [72, 58, 97, 12, 64, 0, 96, 55, 38, 54, 0, 34],
- [9, 30, 58, 46, 5, 96, 0, 83, 35, 11, 56, 37],
- [34, 78, 66, 40, 29, 55, 83, 0, 44, 12, 15, 80],
- [79, 35, 69, 37, 76, 38, 35, 44, 0, 64, 39, 33],
- [17, 44, 61, 48, 16, 54, 11, 12, 64, 0, 70, 86],
- [46, 79, 54, 68, 5, 0, 56, 15, 39, 70, 0, 18],
- [95, 36, 63, 85, 76, 34, 37, 80, 33, 86, 18, 0],
- ]
- A, B = np.array(A), np.array(B)
- n = A.shape[0]
- opt_perm = np.array([7, 5, 1, 3, 10, 4, 8, 6, 9, 11, 2, 12]) - [1] * n
- return A, B, opt_perm
- class QAPCommonTests:
- """
- Base class for `quadratic_assignment` tests.
- """
- def setup_method(self):
- np.random.seed(0)
- # Test global optima of problem from Umeyama IVB
- # https://pcl.sitehost.iu.edu/rgoldsto/papers/weighted%20graph%20match2.pdf
- # Graph matching maximum is in the paper
- # QAP minimum determined by brute force
- def test_accuracy_1(self):
- # besides testing accuracy, check that A and B can be lists
- A = [[0, 3, 4, 2],
- [0, 0, 1, 2],
- [1, 0, 0, 1],
- [0, 0, 1, 0]]
- B = [[0, 4, 2, 4],
- [0, 0, 1, 0],
- [0, 2, 0, 2],
- [0, 1, 2, 0]]
- res = quadratic_assignment(A, B, method=self.method,
- options={"rng": 0, "maximize": False})
- assert_equal(res.fun, 10)
- assert_equal(res.col_ind, np.array([1, 2, 3, 0]))
- res = quadratic_assignment(A, B, method=self.method,
- options={"rng": 0, "maximize": True})
- if self.method == 'faq':
- # Global optimum is 40, but FAQ gets 37
- assert_equal(res.fun, 37)
- assert_equal(res.col_ind, np.array([0, 2, 3, 1]))
- else:
- assert_equal(res.fun, 40)
- assert_equal(res.col_ind, np.array([0, 3, 1, 2]))
- res = quadratic_assignment(A, B, method=self.method,
- options={"rng": 0, "maximize": True})
- # Test global optima of problem from Umeyama IIIB
- # https://pcl.sitehost.iu.edu/rgoldsto/papers/weighted%20graph%20match2.pdf
- # Graph matching maximum is in the paper
- # QAP minimum determined by brute force
- def test_accuracy_2(self):
- A = np.array([[0, 5, 8, 6],
- [5, 0, 5, 1],
- [8, 5, 0, 2],
- [6, 1, 2, 0]])
- B = np.array([[0, 1, 8, 4],
- [1, 0, 5, 2],
- [8, 5, 0, 5],
- [4, 2, 5, 0]])
- res = quadratic_assignment(A, B, method=self.method,
- options={"rng": 0, "maximize": False})
- if self.method == 'faq':
- # Global optimum is 176, but FAQ gets 178
- assert_equal(res.fun, 178)
- assert_equal(res.col_ind, np.array([1, 0, 3, 2]))
- else:
- assert_equal(res.fun, 176)
- assert_equal(res.col_ind, np.array([1, 2, 3, 0]))
- res = quadratic_assignment(A, B, method=self.method,
- options={"rng": 0, "maximize": True})
- assert_equal(res.fun, 286)
- assert_equal(res.col_ind, np.array([2, 3, 0, 1]))
- def test_accuracy_3(self):
- A, B, opt_perm = chr12c()
- # basic minimization
- res = quadratic_assignment(A, B, method=self.method,
- options={"rng": 0})
- assert_(11156 <= res.fun < 21000)
- assert_equal(res.fun, _score(A, B, res.col_ind))
- # basic maximization
- res = quadratic_assignment(A, B, method=self.method,
- options={"rng": 0, 'maximize': True})
- assert_(74000 <= res.fun < 85000)
- assert_equal(res.fun, _score(A, B, res.col_ind))
- # check ofv with strictly partial match
- seed_cost = np.array([4, 8, 10])
- seed = np.asarray([seed_cost, opt_perm[seed_cost]]).T
- res = quadratic_assignment(A, B, method=self.method,
- options={'partial_match': seed})
- assert_(11156 <= res.fun < 21000)
- assert_equal(res.col_ind[seed_cost], opt_perm[seed_cost])
- # check performance when partial match is the global optimum
- seed = np.asarray([np.arange(len(A)), opt_perm]).T
- res = quadratic_assignment(A, B, method=self.method,
- options={'partial_match': seed})
- assert_equal(res.col_ind, seed[:, 1].T)
- assert_equal(res.fun, 11156)
- assert_equal(res.nit, 0)
- # check performance with zero sized matrix inputs
- empty = np.empty((0, 0))
- res = quadratic_assignment(empty, empty, method=self.method,
- options={"rng": 0})
- assert_equal(res.nit, 0)
- assert_equal(res.fun, 0)
- def test_unknown_options(self):
- A, B, opt_perm = chr12c()
- def f():
- quadratic_assignment(A, B, method=self.method,
- options={"ekki-ekki": True})
- assert_warns(OptimizeWarning, f)
- class TestFAQ(QAPCommonTests):
- method = "faq"
- def test_options(self):
- # cost and distance matrices of QAPLIB instance chr12c
- A, B, opt_perm = chr12c()
- n = len(A)
- # check that max_iter is obeying with low input value
- res = quadratic_assignment(A, B,
- options={'maxiter': 5})
- assert_equal(res.nit, 5)
- # test with shuffle
- res = quadratic_assignment(A, B,
- options={'shuffle_input': True})
- assert_(11156 <= res.fun < 21000)
- # test with randomized init
- res = quadratic_assignment(A, B,
- options={'rng': 1, 'P0': "randomized"})
- assert_(11156 <= res.fun < 21000)
- # check with specified P0
- K = np.ones((n, n)) / float(n)
- K = _doubly_stochastic(K)
- res = quadratic_assignment(A, B,
- options={'P0': K})
- assert_(11156 <= res.fun < 21000)
- def test_specific_input_validation(self):
- A = np.identity(2)
- B = A
- # method is implicitly faq
- # ValueError Checks: making sure single value parameters are of
- # correct value
- with pytest.raises(ValueError, match="Invalid 'P0' parameter"):
- quadratic_assignment(A, B, options={'P0': "random"})
- with pytest.raises(
- ValueError, match="'maxiter' must be a positive integer"):
- quadratic_assignment(A, B, options={'maxiter': -1})
- with pytest.raises(ValueError, match="'tol' must be a positive float"):
- quadratic_assignment(A, B, options={'tol': -1})
- # TypeError Checks: making sure single value parameters are of
- # correct type
- with pytest.raises(TypeError):
- quadratic_assignment(A, B, options={'maxiter': 1.5})
- # test P0 matrix input
- with pytest.raises(
- ValueError,
- match="`P0` matrix must have shape m' x m', where m'=n-m"):
- quadratic_assignment(
- np.identity(4), np.identity(4),
- options={'P0': np.ones((3, 3))}
- )
- K = [[0.4, 0.2, 0.3],
- [0.3, 0.6, 0.2],
- [0.2, 0.2, 0.7]]
- # matrix that isn't quite doubly stochastic
- with pytest.raises(
- ValueError, match="`P0` matrix must be doubly stochastic"):
- quadratic_assignment(
- np.identity(3), np.identity(3), options={'P0': K}
- )
- class Test2opt(QAPCommonTests):
- method = "2opt"
- def test_deterministic(self):
- # np.random.seed(0) executes before every method
- n = 20
- A = np.random.rand(n, n)
- B = np.random.rand(n, n)
- res1 = quadratic_assignment(A, B, method=self.method)
- np.random.seed(0)
- A = np.random.rand(n, n)
- B = np.random.rand(n, n)
- res2 = quadratic_assignment(A, B, method=self.method)
- assert_equal(res1.nit, res2.nit)
- def test_partial_guess(self):
- n = 5
- A = np.random.rand(n, n)
- B = np.random.rand(n, n)
- res1 = quadratic_assignment(A, B, method=self.method,
- options={'rng': 0})
- guess = np.array([np.arange(5), res1.col_ind]).T
- res2 = quadratic_assignment(A, B, method=self.method,
- options={'rng': 0, 'partial_guess': guess})
- fix = [2, 4]
- match = np.array([np.arange(5)[fix], res1.col_ind[fix]]).T
- res3 = quadratic_assignment(A, B, method=self.method,
- options={'rng': 0, 'partial_guess': guess,
- 'partial_match': match})
- assert_(res1.nit != n*(n+1)/2)
- assert_equal(res2.nit, n*(n+1)/2) # tests each swap exactly once
- assert_equal(res3.nit, (n-2)*(n-1)/2) # tests free swaps exactly once
- def test_specific_input_validation(self):
- # can't have more seed nodes than cost/dist nodes
- _rm = _range_matrix
- with pytest.raises(
- ValueError,
- match="`partial_guess` can have only as many entries as"):
- quadratic_assignment(np.identity(3), np.identity(3),
- method=self.method,
- options={'partial_guess': _rm(5, 2)})
- # test for only two seed columns
- with pytest.raises(
- ValueError, match="`partial_guess` must have two columns"):
- quadratic_assignment(
- np.identity(3), np.identity(3), method=self.method,
- options={'partial_guess': _range_matrix(2, 3)}
- )
- # test that seed has no more than two dimensions
- with pytest.raises(
- ValueError, match="`partial_guess` must have exactly two"):
- quadratic_assignment(
- np.identity(3), np.identity(3), method=self.method,
- options={'partial_guess': np.random.rand(3, 2, 2)}
- )
- # seeds cannot be negative valued
- with pytest.raises(
- ValueError, match="`partial_guess` must contain only pos"):
- quadratic_assignment(
- np.identity(3), np.identity(3), method=self.method,
- options={'partial_guess': -1 * _range_matrix(2, 2)}
- )
- # seeds can't have values greater than number of nodes
- with pytest.raises(
- ValueError,
- match="`partial_guess` entries must be less than number"):
- quadratic_assignment(
- np.identity(5), np.identity(5), method=self.method,
- options={'partial_guess': 2 * _range_matrix(4, 2)}
- )
- # columns of seed matrix must be unique
- with pytest.raises(
- ValueError,
- match="`partial_guess` column entries must be unique"):
- quadratic_assignment(
- np.identity(3), np.identity(3), method=self.method,
- options={'partial_guess': np.ones((2, 2))}
- )
- class TestQAPOnce():
- def setup_method(self):
- np.random.seed(0)
- # these don't need to be repeated for each method
- def test_common_input_validation(self):
- # test that non square matrices return error
- with pytest.raises(ValueError, match="`A` must be square"):
- quadratic_assignment(
- np.random.random((3, 4)),
- np.random.random((3, 3)),
- )
- with pytest.raises(ValueError, match="`B` must be square"):
- quadratic_assignment(
- np.random.random((3, 3)),
- np.random.random((3, 4)),
- )
- # test that cost and dist matrices have no more than two dimensions
- with pytest.raises(
- ValueError, match="`A` and `B` must have exactly two"):
- quadratic_assignment(
- np.random.random((3, 3, 3)),
- np.random.random((3, 3, 3)),
- )
- # test that cost and dist matrices of different sizes return error
- with pytest.raises(
- ValueError,
- match="`A` and `B` matrices must be of equal size"):
- quadratic_assignment(
- np.random.random((3, 3)),
- np.random.random((4, 4)),
- )
- # can't have more seed nodes than cost/dist nodes
- _rm = _range_matrix
- with pytest.raises(
- ValueError,
- match="`partial_match` can have only as many seeds as"):
- quadratic_assignment(np.identity(3), np.identity(3),
- options={'partial_match': _rm(5, 2)})
- # test for only two seed columns
- with pytest.raises(
- ValueError, match="`partial_match` must have two columns"):
- quadratic_assignment(
- np.identity(3), np.identity(3),
- options={'partial_match': _range_matrix(2, 3)}
- )
- # test that seed has no more than two dimensions
- with pytest.raises(
- ValueError, match="`partial_match` must have exactly two"):
- quadratic_assignment(
- np.identity(3), np.identity(3),
- options={'partial_match': np.random.rand(3, 2, 2)}
- )
- # seeds cannot be negative valued
- with pytest.raises(
- ValueError, match="`partial_match` must contain only pos"):
- quadratic_assignment(
- np.identity(3), np.identity(3),
- options={'partial_match': -1 * _range_matrix(2, 2)}
- )
- # seeds can't have values greater than number of nodes
- with pytest.raises(
- ValueError,
- match="`partial_match` entries must be less than number"):
- quadratic_assignment(
- np.identity(5), np.identity(5),
- options={'partial_match': 2 * _range_matrix(4, 2)}
- )
- # columns of seed matrix must be unique
- with pytest.raises(
- ValueError,
- match="`partial_match` column entries must be unique"):
- quadratic_assignment(
- np.identity(3), np.identity(3),
- options={'partial_match': np.ones((2, 2))}
- )
- def _range_matrix(a, b):
- mat = np.zeros((a, b))
- for i in range(b):
- mat[:, i] = np.arange(a)
- return mat
- def _doubly_stochastic(P, tol=1e-3):
- # cleaner implementation of btaba/sinkhorn_knopp
- max_iter = 1000
- c = 1 / P.sum(axis=0)
- r = 1 / (P @ c)
- P_eps = P
- for it in range(max_iter):
- if ((np.abs(P_eps.sum(axis=1) - 1) < tol).all() and
- (np.abs(P_eps.sum(axis=0) - 1) < tol).all()):
- # All column/row sums ~= 1 within threshold
- break
- c = 1 / (r @ P)
- r = 1 / (P @ c)
- P_eps = r[:, None] * P * c
- return P_eps
|