You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

432 lines
16 KiB
Python

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(object):
"""
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