432 lines
16 KiB
Python
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:
|
||
|
"""
|
||
|
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
|