_qap.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724
  1. import numpy as np
  2. import operator
  3. from . import (linear_sum_assignment, OptimizeResult)
  4. from ._optimize import _check_unknown_options
  5. from scipy._lib._util import check_random_state
  6. import itertools
  7. QUADRATIC_ASSIGNMENT_METHODS = ['faq', '2opt']
  8. def quadratic_assignment(A, B, method="faq", options=None):
  9. r"""
  10. Approximates solution to the quadratic assignment problem and
  11. the graph matching problem.
  12. Quadratic assignment solves problems of the following form:
  13. .. math::
  14. \min_P & \ {\ \text{trace}(A^T P B P^T)}\\
  15. \mbox{s.t. } & {P \ \epsilon \ \mathcal{P}}\\
  16. where :math:`\mathcal{P}` is the set of all permutation matrices,
  17. and :math:`A` and :math:`B` are square matrices.
  18. Graph matching tries to *maximize* the same objective function.
  19. This algorithm can be thought of as finding the alignment of the
  20. nodes of two graphs that minimizes the number of induced edge
  21. disagreements, or, in the case of weighted graphs, the sum of squared
  22. edge weight differences.
  23. Note that the quadratic assignment problem is NP-hard. The results given
  24. here are approximations and are not guaranteed to be optimal.
  25. Parameters
  26. ----------
  27. A : 2-D array, square
  28. The square matrix :math:`A` in the objective function above.
  29. B : 2-D array, square
  30. The square matrix :math:`B` in the objective function above.
  31. method : str in {'faq', '2opt'} (default: 'faq')
  32. The algorithm used to solve the problem.
  33. :ref:`'faq' <optimize.qap-faq>` (default) and
  34. :ref:`'2opt' <optimize.qap-2opt>` are available.
  35. options : dict, optional
  36. A dictionary of solver options. All solvers support the following:
  37. maximize : bool (default: False)
  38. Maximizes the objective function if ``True``.
  39. partial_match : 2-D array of integers, optional (default: None)
  40. Fixes part of the matching. Also known as a "seed" [2]_.
  41. Each row of `partial_match` specifies a pair of matched nodes:
  42. node ``partial_match[i, 0]`` of `A` is matched to node
  43. ``partial_match[i, 1]`` of `B`. The array has shape ``(m, 2)``,
  44. where ``m`` is not greater than the number of nodes, :math:`n`.
  45. rng : {None, int, `numpy.random.Generator`,
  46. `numpy.random.RandomState`}, optional
  47. If `seed` is None (or `np.random`), the `numpy.random.RandomState`
  48. singleton is used.
  49. If `seed` is an int, a new ``RandomState`` instance is used,
  50. seeded with `seed`.
  51. If `seed` is already a ``Generator`` or ``RandomState`` instance then
  52. that instance is used.
  53. For method-specific options, see
  54. :func:`show_options('quadratic_assignment') <show_options>`.
  55. Returns
  56. -------
  57. res : OptimizeResult
  58. `OptimizeResult` containing the following fields.
  59. col_ind : 1-D array
  60. Column indices corresponding to the best permutation found of the
  61. nodes of `B`.
  62. fun : float
  63. The objective value of the solution.
  64. nit : int
  65. The number of iterations performed during optimization.
  66. Notes
  67. -----
  68. The default method :ref:`'faq' <optimize.qap-faq>` uses the Fast
  69. Approximate QAP algorithm [1]_; it typically offers the best combination of
  70. speed and accuracy.
  71. Method :ref:`'2opt' <optimize.qap-2opt>` can be computationally expensive,
  72. but may be a useful alternative, or it can be used to refine the solution
  73. returned by another method.
  74. References
  75. ----------
  76. .. [1] J.T. Vogelstein, J.M. Conroy, V. Lyzinski, L.J. Podrazik,
  77. S.G. Kratzer, E.T. Harley, D.E. Fishkind, R.J. Vogelstein, and
  78. C.E. Priebe, "Fast approximate quadratic programming for graph
  79. matching," PLOS one, vol. 10, no. 4, p. e0121002, 2015,
  80. :doi:`10.1371/journal.pone.0121002`
  81. .. [2] D. Fishkind, S. Adali, H. Patsolic, L. Meng, D. Singh, V. Lyzinski,
  82. C. Priebe, "Seeded graph matching", Pattern Recognit. 87 (2019):
  83. 203-215, :doi:`10.1016/j.patcog.2018.09.014`
  84. .. [3] "2-opt," Wikipedia.
  85. https://en.wikipedia.org/wiki/2-opt
  86. Examples
  87. --------
  88. >>> import numpy as np
  89. >>> from scipy.optimize import quadratic_assignment
  90. >>> A = np.array([[0, 80, 150, 170], [80, 0, 130, 100],
  91. ... [150, 130, 0, 120], [170, 100, 120, 0]])
  92. >>> B = np.array([[0, 5, 2, 7], [0, 0, 3, 8],
  93. ... [0, 0, 0, 3], [0, 0, 0, 0]])
  94. >>> res = quadratic_assignment(A, B)
  95. >>> print(res)
  96. fun: 3260
  97. col_ind: [0 3 2 1]
  98. nit: 9
  99. The see the relationship between the returned ``col_ind`` and ``fun``,
  100. use ``col_ind`` to form the best permutation matrix found, then evaluate
  101. the objective function :math:`f(P) = trace(A^T P B P^T )`.
  102. >>> perm = res['col_ind']
  103. >>> P = np.eye(len(A), dtype=int)[perm]
  104. >>> fun = np.trace(A.T @ P @ B @ P.T)
  105. >>> print(fun)
  106. 3260
  107. Alternatively, to avoid constructing the permutation matrix explicitly,
  108. directly permute the rows and columns of the distance matrix.
  109. >>> fun = np.trace(A.T @ B[perm][:, perm])
  110. >>> print(fun)
  111. 3260
  112. Although not guaranteed in general, ``quadratic_assignment`` happens to
  113. have found the globally optimal solution.
  114. >>> from itertools import permutations
  115. >>> perm_opt, fun_opt = None, np.inf
  116. >>> for perm in permutations([0, 1, 2, 3]):
  117. ... perm = np.array(perm)
  118. ... fun = np.trace(A.T @ B[perm][:, perm])
  119. ... if fun < fun_opt:
  120. ... fun_opt, perm_opt = fun, perm
  121. >>> print(np.array_equal(perm_opt, res['col_ind']))
  122. True
  123. Here is an example for which the default method,
  124. :ref:`'faq' <optimize.qap-faq>`, does not find the global optimum.
  125. >>> A = np.array([[0, 5, 8, 6], [5, 0, 5, 1],
  126. ... [8, 5, 0, 2], [6, 1, 2, 0]])
  127. >>> B = np.array([[0, 1, 8, 4], [1, 0, 5, 2],
  128. ... [8, 5, 0, 5], [4, 2, 5, 0]])
  129. >>> res = quadratic_assignment(A, B)
  130. >>> print(res)
  131. fun: 178
  132. col_ind: [1 0 3 2]
  133. nit: 13
  134. If accuracy is important, consider using :ref:`'2opt' <optimize.qap-2opt>`
  135. to refine the solution.
  136. >>> guess = np.array([np.arange(len(A)), res.col_ind]).T
  137. >>> res = quadratic_assignment(A, B, method="2opt",
  138. ... options = {'partial_guess': guess})
  139. >>> print(res)
  140. fun: 176
  141. col_ind: [1 2 3 0]
  142. nit: 17
  143. """
  144. if options is None:
  145. options = {}
  146. method = method.lower()
  147. methods = {"faq": _quadratic_assignment_faq,
  148. "2opt": _quadratic_assignment_2opt}
  149. if method not in methods:
  150. raise ValueError(f"method {method} must be in {methods}.")
  151. res = methods[method](A, B, **options)
  152. return res
  153. def _calc_score(A, B, perm):
  154. # equivalent to objective function but avoids matmul
  155. return np.sum(A * B[perm][:, perm])
  156. def _common_input_validation(A, B, partial_match):
  157. A = np.atleast_2d(A)
  158. B = np.atleast_2d(B)
  159. if partial_match is None:
  160. partial_match = np.array([[], []]).T
  161. partial_match = np.atleast_2d(partial_match).astype(int)
  162. msg = None
  163. if A.shape[0] != A.shape[1]:
  164. msg = "`A` must be square"
  165. elif B.shape[0] != B.shape[1]:
  166. msg = "`B` must be square"
  167. elif A.ndim != 2 or B.ndim != 2:
  168. msg = "`A` and `B` must have exactly two dimensions"
  169. elif A.shape != B.shape:
  170. msg = "`A` and `B` matrices must be of equal size"
  171. elif partial_match.shape[0] > A.shape[0]:
  172. msg = "`partial_match` can have only as many seeds as there are nodes"
  173. elif partial_match.shape[1] != 2:
  174. msg = "`partial_match` must have two columns"
  175. elif partial_match.ndim != 2:
  176. msg = "`partial_match` must have exactly two dimensions"
  177. elif (partial_match < 0).any():
  178. msg = "`partial_match` must contain only positive indices"
  179. elif (partial_match >= len(A)).any():
  180. msg = "`partial_match` entries must be less than number of nodes"
  181. elif (not len(set(partial_match[:, 0])) == len(partial_match[:, 0]) or
  182. not len(set(partial_match[:, 1])) == len(partial_match[:, 1])):
  183. msg = "`partial_match` column entries must be unique"
  184. if msg is not None:
  185. raise ValueError(msg)
  186. return A, B, partial_match
  187. def _quadratic_assignment_faq(A, B,
  188. maximize=False, partial_match=None, rng=None,
  189. P0="barycenter", shuffle_input=False, maxiter=30,
  190. tol=0.03, **unknown_options):
  191. r"""Solve the quadratic assignment problem (approximately).
  192. This function solves the Quadratic Assignment Problem (QAP) and the
  193. Graph Matching Problem (GMP) using the Fast Approximate QAP Algorithm
  194. (FAQ) [1]_.
  195. Quadratic assignment solves problems of the following form:
  196. .. math::
  197. \min_P & \ {\ \text{trace}(A^T P B P^T)}\\
  198. \mbox{s.t. } & {P \ \epsilon \ \mathcal{P}}\\
  199. where :math:`\mathcal{P}` is the set of all permutation matrices,
  200. and :math:`A` and :math:`B` are square matrices.
  201. Graph matching tries to *maximize* the same objective function.
  202. This algorithm can be thought of as finding the alignment of the
  203. nodes of two graphs that minimizes the number of induced edge
  204. disagreements, or, in the case of weighted graphs, the sum of squared
  205. edge weight differences.
  206. Note that the quadratic assignment problem is NP-hard. The results given
  207. here are approximations and are not guaranteed to be optimal.
  208. Parameters
  209. ----------
  210. A : 2-D array, square
  211. The square matrix :math:`A` in the objective function above.
  212. B : 2-D array, square
  213. The square matrix :math:`B` in the objective function above.
  214. method : str in {'faq', '2opt'} (default: 'faq')
  215. The algorithm used to solve the problem. This is the method-specific
  216. documentation for 'faq'.
  217. :ref:`'2opt' <optimize.qap-2opt>` is also available.
  218. Options
  219. -------
  220. maximize : bool (default: False)
  221. Maximizes the objective function if ``True``.
  222. partial_match : 2-D array of integers, optional (default: None)
  223. Fixes part of the matching. Also known as a "seed" [2]_.
  224. Each row of `partial_match` specifies a pair of matched nodes:
  225. node ``partial_match[i, 0]`` of `A` is matched to node
  226. ``partial_match[i, 1]`` of `B`. The array has shape ``(m, 2)``, where
  227. ``m`` is not greater than the number of nodes, :math:`n`.
  228. rng : {None, int, `numpy.random.Generator`,
  229. `numpy.random.RandomState`}, optional
  230. If `seed` is None (or `np.random`), the `numpy.random.RandomState`
  231. singleton is used.
  232. If `seed` is an int, a new ``RandomState`` instance is used,
  233. seeded with `seed`.
  234. If `seed` is already a ``Generator`` or ``RandomState`` instance then
  235. that instance is used.
  236. P0 : 2-D array, "barycenter", or "randomized" (default: "barycenter")
  237. Initial position. Must be a doubly-stochastic matrix [3]_.
  238. If the initial position is an array, it must be a doubly stochastic
  239. matrix of size :math:`m' \times m'` where :math:`m' = n - m`.
  240. If ``"barycenter"`` (default), the initial position is the barycenter
  241. of the Birkhoff polytope (the space of doubly stochastic matrices).
  242. This is a :math:`m' \times m'` matrix with all entries equal to
  243. :math:`1 / m'`.
  244. If ``"randomized"`` the initial search position is
  245. :math:`P_0 = (J + K) / 2`, where :math:`J` is the barycenter and
  246. :math:`K` is a random doubly stochastic matrix.
  247. shuffle_input : bool (default: False)
  248. Set to `True` to resolve degenerate gradients randomly. For
  249. non-degenerate gradients this option has no effect.
  250. maxiter : int, positive (default: 30)
  251. Integer specifying the max number of Frank-Wolfe iterations performed.
  252. tol : float (default: 0.03)
  253. Tolerance for termination. Frank-Wolfe iteration terminates when
  254. :math:`\frac{||P_{i}-P_{i+1}||_F}{\sqrt{m')}} \leq tol`,
  255. where :math:`i` is the iteration number.
  256. Returns
  257. -------
  258. res : OptimizeResult
  259. `OptimizeResult` containing the following fields.
  260. col_ind : 1-D array
  261. Column indices corresponding to the best permutation found of the
  262. nodes of `B`.
  263. fun : float
  264. The objective value of the solution.
  265. nit : int
  266. The number of Frank-Wolfe iterations performed.
  267. Notes
  268. -----
  269. The algorithm may be sensitive to the initial permutation matrix (or
  270. search "position") due to the possibility of several local minima
  271. within the feasible region. A barycenter initialization is more likely to
  272. result in a better solution than a single random initialization. However,
  273. calling ``quadratic_assignment`` several times with different random
  274. initializations may result in a better optimum at the cost of longer
  275. total execution time.
  276. Examples
  277. --------
  278. As mentioned above, a barycenter initialization often results in a better
  279. solution than a single random initialization.
  280. >>> from numpy.random import default_rng
  281. >>> rng = default_rng()
  282. >>> n = 15
  283. >>> A = rng.random((n, n))
  284. >>> B = rng.random((n, n))
  285. >>> res = quadratic_assignment(A, B) # FAQ is default method
  286. >>> print(res.fun)
  287. 46.871483385480545 # may vary
  288. >>> options = {"P0": "randomized"} # use randomized initialization
  289. >>> res = quadratic_assignment(A, B, options=options)
  290. >>> print(res.fun)
  291. 47.224831071310625 # may vary
  292. However, consider running from several randomized initializations and
  293. keeping the best result.
  294. >>> res = min([quadratic_assignment(A, B, options=options)
  295. ... for i in range(30)], key=lambda x: x.fun)
  296. >>> print(res.fun)
  297. 46.671852533681516 # may vary
  298. The '2-opt' method can be used to further refine the results.
  299. >>> options = {"partial_guess": np.array([np.arange(n), res.col_ind]).T}
  300. >>> res = quadratic_assignment(A, B, method="2opt", options=options)
  301. >>> print(res.fun)
  302. 46.47160735721583 # may vary
  303. References
  304. ----------
  305. .. [1] J.T. Vogelstein, J.M. Conroy, V. Lyzinski, L.J. Podrazik,
  306. S.G. Kratzer, E.T. Harley, D.E. Fishkind, R.J. Vogelstein, and
  307. C.E. Priebe, "Fast approximate quadratic programming for graph
  308. matching," PLOS one, vol. 10, no. 4, p. e0121002, 2015,
  309. :doi:`10.1371/journal.pone.0121002`
  310. .. [2] D. Fishkind, S. Adali, H. Patsolic, L. Meng, D. Singh, V. Lyzinski,
  311. C. Priebe, "Seeded graph matching", Pattern Recognit. 87 (2019):
  312. 203-215, :doi:`10.1016/j.patcog.2018.09.014`
  313. .. [3] "Doubly stochastic Matrix," Wikipedia.
  314. https://en.wikipedia.org/wiki/Doubly_stochastic_matrix
  315. """
  316. _check_unknown_options(unknown_options)
  317. maxiter = operator.index(maxiter)
  318. # ValueError check
  319. A, B, partial_match = _common_input_validation(A, B, partial_match)
  320. msg = None
  321. if isinstance(P0, str) and P0 not in {'barycenter', 'randomized'}:
  322. msg = "Invalid 'P0' parameter string"
  323. elif maxiter <= 0:
  324. msg = "'maxiter' must be a positive integer"
  325. elif tol <= 0:
  326. msg = "'tol' must be a positive float"
  327. if msg is not None:
  328. raise ValueError(msg)
  329. rng = check_random_state(rng)
  330. n = len(A) # number of vertices in graphs
  331. n_seeds = len(partial_match) # number of seeds
  332. n_unseed = n - n_seeds
  333. # [1] Algorithm 1 Line 1 - choose initialization
  334. if not isinstance(P0, str):
  335. P0 = np.atleast_2d(P0)
  336. if P0.shape != (n_unseed, n_unseed):
  337. msg = "`P0` matrix must have shape m' x m', where m'=n-m"
  338. elif ((P0 < 0).any() or not np.allclose(np.sum(P0, axis=0), 1)
  339. or not np.allclose(np.sum(P0, axis=1), 1)):
  340. msg = "`P0` matrix must be doubly stochastic"
  341. if msg is not None:
  342. raise ValueError(msg)
  343. elif P0 == 'barycenter':
  344. P0 = np.ones((n_unseed, n_unseed)) / n_unseed
  345. elif P0 == 'randomized':
  346. J = np.ones((n_unseed, n_unseed)) / n_unseed
  347. # generate a nxn matrix where each entry is a random number [0, 1]
  348. # would use rand, but Generators don't have it
  349. # would use random, but old mtrand.RandomStates don't have it
  350. K = _doubly_stochastic(rng.uniform(size=(n_unseed, n_unseed)))
  351. P0 = (J + K) / 2
  352. # check trivial cases
  353. if n == 0 or n_seeds == n:
  354. score = _calc_score(A, B, partial_match[:, 1])
  355. res = {"col_ind": partial_match[:, 1], "fun": score, "nit": 0}
  356. return OptimizeResult(res)
  357. obj_func_scalar = 1
  358. if maximize:
  359. obj_func_scalar = -1
  360. nonseed_B = np.setdiff1d(range(n), partial_match[:, 1])
  361. if shuffle_input:
  362. nonseed_B = rng.permutation(nonseed_B)
  363. nonseed_A = np.setdiff1d(range(n), partial_match[:, 0])
  364. perm_A = np.concatenate([partial_match[:, 0], nonseed_A])
  365. perm_B = np.concatenate([partial_match[:, 1], nonseed_B])
  366. # definitions according to Seeded Graph Matching [2].
  367. A11, A12, A21, A22 = _split_matrix(A[perm_A][:, perm_A], n_seeds)
  368. B11, B12, B21, B22 = _split_matrix(B[perm_B][:, perm_B], n_seeds)
  369. const_sum = A21 @ B21.T + A12.T @ B12
  370. P = P0
  371. # [1] Algorithm 1 Line 2 - loop while stopping criteria not met
  372. for n_iter in range(1, maxiter+1):
  373. # [1] Algorithm 1 Line 3 - compute the gradient of f(P) = -tr(APB^tP^t)
  374. grad_fp = (const_sum + A22 @ P @ B22.T + A22.T @ P @ B22)
  375. # [1] Algorithm 1 Line 4 - get direction Q by solving Eq. 8
  376. _, cols = linear_sum_assignment(grad_fp, maximize=maximize)
  377. Q = np.eye(n_unseed)[cols]
  378. # [1] Algorithm 1 Line 5 - compute the step size
  379. # Noting that e.g. trace(Ax) = trace(A)*x, expand and re-collect
  380. # terms as ax**2 + bx + c. c does not affect location of minimum
  381. # and can be ignored. Also, note that trace(A@B) = (A.T*B).sum();
  382. # apply where possible for efficiency.
  383. R = P - Q
  384. b21 = ((R.T @ A21) * B21).sum()
  385. b12 = ((R.T @ A12.T) * B12.T).sum()
  386. AR22 = A22.T @ R
  387. BR22 = B22 @ R.T
  388. b22a = (AR22 * B22.T[cols]).sum()
  389. b22b = (A22 * BR22[cols]).sum()
  390. a = (AR22.T * BR22).sum()
  391. b = b21 + b12 + b22a + b22b
  392. # critical point of ax^2 + bx + c is at x = -d/(2*e)
  393. # if a * obj_func_scalar > 0, it is a minimum
  394. # if minimum is not in [0, 1], only endpoints need to be considered
  395. if a*obj_func_scalar > 0 and 0 <= -b/(2*a) <= 1:
  396. alpha = -b/(2*a)
  397. else:
  398. alpha = np.argmin([0, (b + a)*obj_func_scalar])
  399. # [1] Algorithm 1 Line 6 - Update P
  400. P_i1 = alpha * P + (1 - alpha) * Q
  401. if np.linalg.norm(P - P_i1) / np.sqrt(n_unseed) < tol:
  402. P = P_i1
  403. break
  404. P = P_i1
  405. # [1] Algorithm 1 Line 7 - end main loop
  406. # [1] Algorithm 1 Line 8 - project onto the set of permutation matrices
  407. _, col = linear_sum_assignment(P, maximize=True)
  408. perm = np.concatenate((np.arange(n_seeds), col + n_seeds))
  409. unshuffled_perm = np.zeros(n, dtype=int)
  410. unshuffled_perm[perm_A] = perm_B[perm]
  411. score = _calc_score(A, B, unshuffled_perm)
  412. res = {"col_ind": unshuffled_perm, "fun": score, "nit": n_iter}
  413. return OptimizeResult(res)
  414. def _split_matrix(X, n):
  415. # definitions according to Seeded Graph Matching [2].
  416. upper, lower = X[:n], X[n:]
  417. return upper[:, :n], upper[:, n:], lower[:, :n], lower[:, n:]
  418. def _doubly_stochastic(P, tol=1e-3):
  419. # Adapted from @btaba implementation
  420. # https://github.com/btaba/sinkhorn_knopp
  421. # of Sinkhorn-Knopp algorithm
  422. # https://projecteuclid.org/euclid.pjm/1102992505
  423. max_iter = 1000
  424. c = 1 / P.sum(axis=0)
  425. r = 1 / (P @ c)
  426. P_eps = P
  427. for it in range(max_iter):
  428. if ((np.abs(P_eps.sum(axis=1) - 1) < tol).all() and
  429. (np.abs(P_eps.sum(axis=0) - 1) < tol).all()):
  430. # All column/row sums ~= 1 within threshold
  431. break
  432. c = 1 / (r @ P)
  433. r = 1 / (P @ c)
  434. P_eps = r[:, None] * P * c
  435. return P_eps
  436. def _quadratic_assignment_2opt(A, B, maximize=False, rng=None,
  437. partial_match=None,
  438. partial_guess=None,
  439. **unknown_options):
  440. r"""Solve the quadratic assignment problem (approximately).
  441. This function solves the Quadratic Assignment Problem (QAP) and the
  442. Graph Matching Problem (GMP) using the 2-opt algorithm [1]_.
  443. Quadratic assignment solves problems of the following form:
  444. .. math::
  445. \min_P & \ {\ \text{trace}(A^T P B P^T)}\\
  446. \mbox{s.t. } & {P \ \epsilon \ \mathcal{P}}\\
  447. where :math:`\mathcal{P}` is the set of all permutation matrices,
  448. and :math:`A` and :math:`B` are square matrices.
  449. Graph matching tries to *maximize* the same objective function.
  450. This algorithm can be thought of as finding the alignment of the
  451. nodes of two graphs that minimizes the number of induced edge
  452. disagreements, or, in the case of weighted graphs, the sum of squared
  453. edge weight differences.
  454. Note that the quadratic assignment problem is NP-hard. The results given
  455. here are approximations and are not guaranteed to be optimal.
  456. Parameters
  457. ----------
  458. A : 2-D array, square
  459. The square matrix :math:`A` in the objective function above.
  460. B : 2-D array, square
  461. The square matrix :math:`B` in the objective function above.
  462. method : str in {'faq', '2opt'} (default: 'faq')
  463. The algorithm used to solve the problem. This is the method-specific
  464. documentation for '2opt'.
  465. :ref:`'faq' <optimize.qap-faq>` is also available.
  466. Options
  467. -------
  468. maximize : bool (default: False)
  469. Maximizes the objective function if ``True``.
  470. rng : {None, int, `numpy.random.Generator`,
  471. `numpy.random.RandomState`}, optional
  472. If `seed` is None (or `np.random`), the `numpy.random.RandomState`
  473. singleton is used.
  474. If `seed` is an int, a new ``RandomState`` instance is used,
  475. seeded with `seed`.
  476. If `seed` is already a ``Generator`` or ``RandomState`` instance then
  477. that instance is used.
  478. partial_match : 2-D array of integers, optional (default: None)
  479. Fixes part of the matching. Also known as a "seed" [2]_.
  480. Each row of `partial_match` specifies a pair of matched nodes: node
  481. ``partial_match[i, 0]`` of `A` is matched to node
  482. ``partial_match[i, 1]`` of `B`. The array has shape ``(m, 2)``,
  483. where ``m`` is not greater than the number of nodes, :math:`n`.
  484. partial_guess : 2-D array of integers, optional (default: None)
  485. A guess for the matching between the two matrices. Unlike
  486. `partial_match`, `partial_guess` does not fix the indices; they are
  487. still free to be optimized.
  488. Each row of `partial_guess` specifies a pair of matched nodes: node
  489. ``partial_guess[i, 0]`` of `A` is matched to node
  490. ``partial_guess[i, 1]`` of `B`. The array has shape ``(m, 2)``,
  491. where ``m`` is not greater than the number of nodes, :math:`n`.
  492. Returns
  493. -------
  494. res : OptimizeResult
  495. `OptimizeResult` containing the following fields.
  496. col_ind : 1-D array
  497. Column indices corresponding to the best permutation found of the
  498. nodes of `B`.
  499. fun : float
  500. The objective value of the solution.
  501. nit : int
  502. The number of iterations performed during optimization.
  503. Notes
  504. -----
  505. This is a greedy algorithm that works similarly to bubble sort: beginning
  506. with an initial permutation, it iteratively swaps pairs of indices to
  507. improve the objective function until no such improvements are possible.
  508. References
  509. ----------
  510. .. [1] "2-opt," Wikipedia.
  511. https://en.wikipedia.org/wiki/2-opt
  512. .. [2] D. Fishkind, S. Adali, H. Patsolic, L. Meng, D. Singh, V. Lyzinski,
  513. C. Priebe, "Seeded graph matching", Pattern Recognit. 87 (2019):
  514. 203-215, https://doi.org/10.1016/j.patcog.2018.09.014
  515. """
  516. _check_unknown_options(unknown_options)
  517. rng = check_random_state(rng)
  518. A, B, partial_match = _common_input_validation(A, B, partial_match)
  519. N = len(A)
  520. # check trivial cases
  521. if N == 0 or partial_match.shape[0] == N:
  522. score = _calc_score(A, B, partial_match[:, 1])
  523. res = {"col_ind": partial_match[:, 1], "fun": score, "nit": 0}
  524. return OptimizeResult(res)
  525. if partial_guess is None:
  526. partial_guess = np.array([[], []]).T
  527. partial_guess = np.atleast_2d(partial_guess).astype(int)
  528. msg = None
  529. if partial_guess.shape[0] > A.shape[0]:
  530. msg = ("`partial_guess` can have only as "
  531. "many entries as there are nodes")
  532. elif partial_guess.shape[1] != 2:
  533. msg = "`partial_guess` must have two columns"
  534. elif partial_guess.ndim != 2:
  535. msg = "`partial_guess` must have exactly two dimensions"
  536. elif (partial_guess < 0).any():
  537. msg = "`partial_guess` must contain only positive indices"
  538. elif (partial_guess >= len(A)).any():
  539. msg = "`partial_guess` entries must be less than number of nodes"
  540. elif (not len(set(partial_guess[:, 0])) == len(partial_guess[:, 0]) or
  541. not len(set(partial_guess[:, 1])) == len(partial_guess[:, 1])):
  542. msg = "`partial_guess` column entries must be unique"
  543. if msg is not None:
  544. raise ValueError(msg)
  545. fixed_rows = None
  546. if partial_match.size or partial_guess.size:
  547. # use partial_match and partial_guess for initial permutation,
  548. # but randomly permute the rest.
  549. guess_rows = np.zeros(N, dtype=bool)
  550. guess_cols = np.zeros(N, dtype=bool)
  551. fixed_rows = np.zeros(N, dtype=bool)
  552. fixed_cols = np.zeros(N, dtype=bool)
  553. perm = np.zeros(N, dtype=int)
  554. rg, cg = partial_guess.T
  555. guess_rows[rg] = True
  556. guess_cols[cg] = True
  557. perm[guess_rows] = cg
  558. # match overrides guess
  559. rf, cf = partial_match.T
  560. fixed_rows[rf] = True
  561. fixed_cols[cf] = True
  562. perm[fixed_rows] = cf
  563. random_rows = ~fixed_rows & ~guess_rows
  564. random_cols = ~fixed_cols & ~guess_cols
  565. perm[random_rows] = rng.permutation(np.arange(N)[random_cols])
  566. else:
  567. perm = rng.permutation(np.arange(N))
  568. best_score = _calc_score(A, B, perm)
  569. i_free = np.arange(N)
  570. if fixed_rows is not None:
  571. i_free = i_free[~fixed_rows]
  572. better = operator.gt if maximize else operator.lt
  573. n_iter = 0
  574. done = False
  575. while not done:
  576. # equivalent to nested for loops i in range(N), j in range(i, N)
  577. for i, j in itertools.combinations_with_replacement(i_free, 2):
  578. n_iter += 1
  579. perm[i], perm[j] = perm[j], perm[i]
  580. score = _calc_score(A, B, perm)
  581. if better(score, best_score):
  582. best_score = score
  583. break
  584. # faster to swap back than to create a new list every time
  585. perm[i], perm[j] = perm[j], perm[i]
  586. else: # no swaps made
  587. done = True
  588. res = {"col_ind": perm, "fun": best_score, "nit": n_iter}
  589. return OptimizeResult(res)