test_quadratic_assignment.py 17 KB

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