test_quadratic_assignment.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431
  1. import pytest
  2. import numpy as np
  3. from scipy.optimize import quadratic_assignment, OptimizeWarning
  4. from scipy.optimize._qap import _calc_score as _score
  5. from numpy.testing import assert_equal, assert_, assert_warns
  6. ################
  7. # Common Tests #
  8. ################
  9. def chr12c():
  10. A = [
  11. [0, 90, 10, 0, 0, 0, 0, 0, 0, 0, 0, 0],
  12. [90, 0, 0, 23, 0, 0, 0, 0, 0, 0, 0, 0],
  13. [10, 0, 0, 0, 43, 0, 0, 0, 0, 0, 0, 0],
  14. [0, 23, 0, 0, 0, 88, 0, 0, 0, 0, 0, 0],
  15. [0, 0, 43, 0, 0, 0, 26, 0, 0, 0, 0, 0],
  16. [0, 0, 0, 88, 0, 0, 0, 16, 0, 0, 0, 0],
  17. [0, 0, 0, 0, 26, 0, 0, 0, 1, 0, 0, 0],
  18. [0, 0, 0, 0, 0, 16, 0, 0, 0, 96, 0, 0],
  19. [0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 29, 0],
  20. [0, 0, 0, 0, 0, 0, 0, 96, 0, 0, 0, 37],
  21. [0, 0, 0, 0, 0, 0, 0, 0, 29, 0, 0, 0],
  22. [0, 0, 0, 0, 0, 0, 0, 0, 0, 37, 0, 0],
  23. ]
  24. B = [
  25. [0, 36, 54, 26, 59, 72, 9, 34, 79, 17, 46, 95],
  26. [36, 0, 73, 35, 90, 58, 30, 78, 35, 44, 79, 36],
  27. [54, 73, 0, 21, 10, 97, 58, 66, 69, 61, 54, 63],
  28. [26, 35, 21, 0, 93, 12, 46, 40, 37, 48, 68, 85],
  29. [59, 90, 10, 93, 0, 64, 5, 29, 76, 16, 5, 76],
  30. [72, 58, 97, 12, 64, 0, 96, 55, 38, 54, 0, 34],
  31. [9, 30, 58, 46, 5, 96, 0, 83, 35, 11, 56, 37],
  32. [34, 78, 66, 40, 29, 55, 83, 0, 44, 12, 15, 80],
  33. [79, 35, 69, 37, 76, 38, 35, 44, 0, 64, 39, 33],
  34. [17, 44, 61, 48, 16, 54, 11, 12, 64, 0, 70, 86],
  35. [46, 79, 54, 68, 5, 0, 56, 15, 39, 70, 0, 18],
  36. [95, 36, 63, 85, 76, 34, 37, 80, 33, 86, 18, 0],
  37. ]
  38. A, B = np.array(A), np.array(B)
  39. n = A.shape[0]
  40. opt_perm = np.array([7, 5, 1, 3, 10, 4, 8, 6, 9, 11, 2, 12]) - [1] * n
  41. return A, B, opt_perm
  42. class QAPCommonTests:
  43. """
  44. Base class for `quadratic_assignment` tests.
  45. """
  46. def setup_method(self):
  47. np.random.seed(0)
  48. # Test global optima of problem from Umeyama IVB
  49. # https://pcl.sitehost.iu.edu/rgoldsto/papers/weighted%20graph%20match2.pdf
  50. # Graph matching maximum is in the paper
  51. # QAP minimum determined by brute force
  52. def test_accuracy_1(self):
  53. # besides testing accuracy, check that A and B can be lists
  54. A = [[0, 3, 4, 2],
  55. [0, 0, 1, 2],
  56. [1, 0, 0, 1],
  57. [0, 0, 1, 0]]
  58. B = [[0, 4, 2, 4],
  59. [0, 0, 1, 0],
  60. [0, 2, 0, 2],
  61. [0, 1, 2, 0]]
  62. res = quadratic_assignment(A, B, method=self.method,
  63. options={"rng": 0, "maximize": False})
  64. assert_equal(res.fun, 10)
  65. assert_equal(res.col_ind, np.array([1, 2, 3, 0]))
  66. res = quadratic_assignment(A, B, method=self.method,
  67. options={"rng": 0, "maximize": True})
  68. if self.method == 'faq':
  69. # Global optimum is 40, but FAQ gets 37
  70. assert_equal(res.fun, 37)
  71. assert_equal(res.col_ind, np.array([0, 2, 3, 1]))
  72. else:
  73. assert_equal(res.fun, 40)
  74. assert_equal(res.col_ind, np.array([0, 3, 1, 2]))
  75. res = quadratic_assignment(A, B, method=self.method,
  76. options={"rng": 0, "maximize": True})
  77. # Test global optima of problem from Umeyama IIIB
  78. # https://pcl.sitehost.iu.edu/rgoldsto/papers/weighted%20graph%20match2.pdf
  79. # Graph matching maximum is in the paper
  80. # QAP minimum determined by brute force
  81. def test_accuracy_2(self):
  82. A = np.array([[0, 5, 8, 6],
  83. [5, 0, 5, 1],
  84. [8, 5, 0, 2],
  85. [6, 1, 2, 0]])
  86. B = np.array([[0, 1, 8, 4],
  87. [1, 0, 5, 2],
  88. [8, 5, 0, 5],
  89. [4, 2, 5, 0]])
  90. res = quadratic_assignment(A, B, method=self.method,
  91. options={"rng": 0, "maximize": False})
  92. if self.method == 'faq':
  93. # Global optimum is 176, but FAQ gets 178
  94. assert_equal(res.fun, 178)
  95. assert_equal(res.col_ind, np.array([1, 0, 3, 2]))
  96. else:
  97. assert_equal(res.fun, 176)
  98. assert_equal(res.col_ind, np.array([1, 2, 3, 0]))
  99. res = quadratic_assignment(A, B, method=self.method,
  100. options={"rng": 0, "maximize": True})
  101. assert_equal(res.fun, 286)
  102. assert_equal(res.col_ind, np.array([2, 3, 0, 1]))
  103. def test_accuracy_3(self):
  104. A, B, opt_perm = chr12c()
  105. # basic minimization
  106. res = quadratic_assignment(A, B, method=self.method,
  107. options={"rng": 0})
  108. assert_(11156 <= res.fun < 21000)
  109. assert_equal(res.fun, _score(A, B, res.col_ind))
  110. # basic maximization
  111. res = quadratic_assignment(A, B, method=self.method,
  112. options={"rng": 0, 'maximize': True})
  113. assert_(74000 <= res.fun < 85000)
  114. assert_equal(res.fun, _score(A, B, res.col_ind))
  115. # check ofv with strictly partial match
  116. seed_cost = np.array([4, 8, 10])
  117. seed = np.asarray([seed_cost, opt_perm[seed_cost]]).T
  118. res = quadratic_assignment(A, B, method=self.method,
  119. options={'partial_match': seed})
  120. assert_(11156 <= res.fun < 21000)
  121. assert_equal(res.col_ind[seed_cost], opt_perm[seed_cost])
  122. # check performance when partial match is the global optimum
  123. seed = np.asarray([np.arange(len(A)), opt_perm]).T
  124. res = quadratic_assignment(A, B, method=self.method,
  125. options={'partial_match': seed})
  126. assert_equal(res.col_ind, seed[:, 1].T)
  127. assert_equal(res.fun, 11156)
  128. assert_equal(res.nit, 0)
  129. # check performance with zero sized matrix inputs
  130. empty = np.empty((0, 0))
  131. res = quadratic_assignment(empty, empty, method=self.method,
  132. options={"rng": 0})
  133. assert_equal(res.nit, 0)
  134. assert_equal(res.fun, 0)
  135. def test_unknown_options(self):
  136. A, B, opt_perm = chr12c()
  137. def f():
  138. quadratic_assignment(A, B, method=self.method,
  139. options={"ekki-ekki": True})
  140. assert_warns(OptimizeWarning, f)
  141. class TestFAQ(QAPCommonTests):
  142. method = "faq"
  143. def test_options(self):
  144. # cost and distance matrices of QAPLIB instance chr12c
  145. A, B, opt_perm = chr12c()
  146. n = len(A)
  147. # check that max_iter is obeying with low input value
  148. res = quadratic_assignment(A, B,
  149. options={'maxiter': 5})
  150. assert_equal(res.nit, 5)
  151. # test with shuffle
  152. res = quadratic_assignment(A, B,
  153. options={'shuffle_input': True})
  154. assert_(11156 <= res.fun < 21000)
  155. # test with randomized init
  156. res = quadratic_assignment(A, B,
  157. options={'rng': 1, 'P0': "randomized"})
  158. assert_(11156 <= res.fun < 21000)
  159. # check with specified P0
  160. K = np.ones((n, n)) / float(n)
  161. K = _doubly_stochastic(K)
  162. res = quadratic_assignment(A, B,
  163. options={'P0': K})
  164. assert_(11156 <= res.fun < 21000)
  165. def test_specific_input_validation(self):
  166. A = np.identity(2)
  167. B = A
  168. # method is implicitly faq
  169. # ValueError Checks: making sure single value parameters are of
  170. # correct value
  171. with pytest.raises(ValueError, match="Invalid 'P0' parameter"):
  172. quadratic_assignment(A, B, options={'P0': "random"})
  173. with pytest.raises(
  174. ValueError, match="'maxiter' must be a positive integer"):
  175. quadratic_assignment(A, B, options={'maxiter': -1})
  176. with pytest.raises(ValueError, match="'tol' must be a positive float"):
  177. quadratic_assignment(A, B, options={'tol': -1})
  178. # TypeError Checks: making sure single value parameters are of
  179. # correct type
  180. with pytest.raises(TypeError):
  181. quadratic_assignment(A, B, options={'maxiter': 1.5})
  182. # test P0 matrix input
  183. with pytest.raises(
  184. ValueError,
  185. match="`P0` matrix must have shape m' x m', where m'=n-m"):
  186. quadratic_assignment(
  187. np.identity(4), np.identity(4),
  188. options={'P0': np.ones((3, 3))}
  189. )
  190. K = [[0.4, 0.2, 0.3],
  191. [0.3, 0.6, 0.2],
  192. [0.2, 0.2, 0.7]]
  193. # matrix that isn't quite doubly stochastic
  194. with pytest.raises(
  195. ValueError, match="`P0` matrix must be doubly stochastic"):
  196. quadratic_assignment(
  197. np.identity(3), np.identity(3), options={'P0': K}
  198. )
  199. class Test2opt(QAPCommonTests):
  200. method = "2opt"
  201. def test_deterministic(self):
  202. # np.random.seed(0) executes before every method
  203. n = 20
  204. A = np.random.rand(n, n)
  205. B = np.random.rand(n, n)
  206. res1 = quadratic_assignment(A, B, method=self.method)
  207. np.random.seed(0)
  208. A = np.random.rand(n, n)
  209. B = np.random.rand(n, n)
  210. res2 = quadratic_assignment(A, B, method=self.method)
  211. assert_equal(res1.nit, res2.nit)
  212. def test_partial_guess(self):
  213. n = 5
  214. A = np.random.rand(n, n)
  215. B = np.random.rand(n, n)
  216. res1 = quadratic_assignment(A, B, method=self.method,
  217. options={'rng': 0})
  218. guess = np.array([np.arange(5), res1.col_ind]).T
  219. res2 = quadratic_assignment(A, B, method=self.method,
  220. options={'rng': 0, 'partial_guess': guess})
  221. fix = [2, 4]
  222. match = np.array([np.arange(5)[fix], res1.col_ind[fix]]).T
  223. res3 = quadratic_assignment(A, B, method=self.method,
  224. options={'rng': 0, 'partial_guess': guess,
  225. 'partial_match': match})
  226. assert_(res1.nit != n*(n+1)/2)
  227. assert_equal(res2.nit, n*(n+1)/2) # tests each swap exactly once
  228. assert_equal(res3.nit, (n-2)*(n-1)/2) # tests free swaps exactly once
  229. def test_specific_input_validation(self):
  230. # can't have more seed nodes than cost/dist nodes
  231. _rm = _range_matrix
  232. with pytest.raises(
  233. ValueError,
  234. match="`partial_guess` can have only as many entries as"):
  235. quadratic_assignment(np.identity(3), np.identity(3),
  236. method=self.method,
  237. options={'partial_guess': _rm(5, 2)})
  238. # test for only two seed columns
  239. with pytest.raises(
  240. ValueError, match="`partial_guess` must have two columns"):
  241. quadratic_assignment(
  242. np.identity(3), np.identity(3), method=self.method,
  243. options={'partial_guess': _range_matrix(2, 3)}
  244. )
  245. # test that seed has no more than two dimensions
  246. with pytest.raises(
  247. ValueError, match="`partial_guess` must have exactly two"):
  248. quadratic_assignment(
  249. np.identity(3), np.identity(3), method=self.method,
  250. options={'partial_guess': np.random.rand(3, 2, 2)}
  251. )
  252. # seeds cannot be negative valued
  253. with pytest.raises(
  254. ValueError, match="`partial_guess` must contain only pos"):
  255. quadratic_assignment(
  256. np.identity(3), np.identity(3), method=self.method,
  257. options={'partial_guess': -1 * _range_matrix(2, 2)}
  258. )
  259. # seeds can't have values greater than number of nodes
  260. with pytest.raises(
  261. ValueError,
  262. match="`partial_guess` entries must be less than number"):
  263. quadratic_assignment(
  264. np.identity(5), np.identity(5), method=self.method,
  265. options={'partial_guess': 2 * _range_matrix(4, 2)}
  266. )
  267. # columns of seed matrix must be unique
  268. with pytest.raises(
  269. ValueError,
  270. match="`partial_guess` column entries must be unique"):
  271. quadratic_assignment(
  272. np.identity(3), np.identity(3), method=self.method,
  273. options={'partial_guess': np.ones((2, 2))}
  274. )
  275. class TestQAPOnce():
  276. def setup_method(self):
  277. np.random.seed(0)
  278. # these don't need to be repeated for each method
  279. def test_common_input_validation(self):
  280. # test that non square matrices return error
  281. with pytest.raises(ValueError, match="`A` must be square"):
  282. quadratic_assignment(
  283. np.random.random((3, 4)),
  284. np.random.random((3, 3)),
  285. )
  286. with pytest.raises(ValueError, match="`B` must be square"):
  287. quadratic_assignment(
  288. np.random.random((3, 3)),
  289. np.random.random((3, 4)),
  290. )
  291. # test that cost and dist matrices have no more than two dimensions
  292. with pytest.raises(
  293. ValueError, match="`A` and `B` must have exactly two"):
  294. quadratic_assignment(
  295. np.random.random((3, 3, 3)),
  296. np.random.random((3, 3, 3)),
  297. )
  298. # test that cost and dist matrices of different sizes return error
  299. with pytest.raises(
  300. ValueError,
  301. match="`A` and `B` matrices must be of equal size"):
  302. quadratic_assignment(
  303. np.random.random((3, 3)),
  304. np.random.random((4, 4)),
  305. )
  306. # can't have more seed nodes than cost/dist nodes
  307. _rm = _range_matrix
  308. with pytest.raises(
  309. ValueError,
  310. match="`partial_match` can have only as many seeds as"):
  311. quadratic_assignment(np.identity(3), np.identity(3),
  312. options={'partial_match': _rm(5, 2)})
  313. # test for only two seed columns
  314. with pytest.raises(
  315. ValueError, match="`partial_match` must have two columns"):
  316. quadratic_assignment(
  317. np.identity(3), np.identity(3),
  318. options={'partial_match': _range_matrix(2, 3)}
  319. )
  320. # test that seed has no more than two dimensions
  321. with pytest.raises(
  322. ValueError, match="`partial_match` must have exactly two"):
  323. quadratic_assignment(
  324. np.identity(3), np.identity(3),
  325. options={'partial_match': np.random.rand(3, 2, 2)}
  326. )
  327. # seeds cannot be negative valued
  328. with pytest.raises(
  329. ValueError, match="`partial_match` must contain only pos"):
  330. quadratic_assignment(
  331. np.identity(3), np.identity(3),
  332. options={'partial_match': -1 * _range_matrix(2, 2)}
  333. )
  334. # seeds can't have values greater than number of nodes
  335. with pytest.raises(
  336. ValueError,
  337. match="`partial_match` entries must be less than number"):
  338. quadratic_assignment(
  339. np.identity(5), np.identity(5),
  340. options={'partial_match': 2 * _range_matrix(4, 2)}
  341. )
  342. # columns of seed matrix must be unique
  343. with pytest.raises(
  344. ValueError,
  345. match="`partial_match` column entries must be unique"):
  346. quadratic_assignment(
  347. np.identity(3), np.identity(3),
  348. options={'partial_match': np.ones((2, 2))}
  349. )
  350. def _range_matrix(a, b):
  351. mat = np.zeros((a, b))
  352. for i in range(b):
  353. mat[:, i] = np.arange(a)
  354. return mat
  355. def _doubly_stochastic(P, tol=1e-3):
  356. # cleaner implementation of btaba/sinkhorn_knopp
  357. max_iter = 1000
  358. c = 1 / P.sum(axis=0)
  359. r = 1 / (P @ c)
  360. P_eps = P
  361. for it in range(max_iter):
  362. if ((np.abs(P_eps.sum(axis=1) - 1) < tol).all() and
  363. (np.abs(P_eps.sum(axis=0) - 1) < tol).all()):
  364. # All column/row sums ~= 1 within threshold
  365. break
  366. c = 1 / (r @ P)
  367. r = 1 / (P @ c)
  368. P_eps = r[:, None] * P * c
  369. return P_eps