Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Temporarily have scipy quadratic_assignment live in graspologic #542

Merged
merged 8 commits into from
Nov 13, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions docs/reference/match.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,3 @@ Matching
Graph Matching
--------------------
.. autoclass:: GraphMatch

Sinkhorn-Knopp Algorithm
------------------------
.. autoclass:: SinkhornKnopp
3 changes: 1 addition & 2 deletions graspologic/match/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,5 @@
# Licensed under the MIT License.

from .gmp import GraphMatch
from .skp import SinkhornKnopp

__all__ = ["GraphMatch", "SinkhornKnopp"]
__all__ = ["GraphMatch"]
192 changes: 38 additions & 154 deletions graspologic/match/gmp.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from sklearn.base import BaseEstimator
from sklearn.utils import check_array
from sklearn.utils import column_or_1d
from .skp import SinkhornKnopp
from .qap import quadratic_assignment


class GraphMatch(BaseEstimator):
Expand All @@ -32,14 +32,18 @@ class GraphMatch(BaseEstimator):
the FAQ algorithm will undergo. ``n_init`` automatically set to 1 if
``init_method`` = 'barycenter'

init_method : string (default = 'barycenter')
init : string (default = 'barycenter') or 2d-array
The initial position chosen

If 2d-array, `init` must be :math:`m' x m'`, where :math:`m' = n - m`,
and it must be doubly stochastic: each of its rows and columns must
sum to 1.

"barycenter" : the non-informative “flat doubly stochastic matrix,”
:math:`J=1 \\times 1^T /n` , i.e the barycenter of the feasible region

"rand" : some random point near :math:`J, (J+K)/2`, where K is some random doubly
stochastic matrix
"rand" : some random point near :math:`J, (J+K)/2`, where K is some random
doubly stochastic matrix

max_iter : int, positive (default = 30)
Integer specifying the max number of Franke-Wolfe iterations.
Expand Down Expand Up @@ -101,24 +105,29 @@ class GraphMatch(BaseEstimator):
def __init__(
self,
n_init=1,
init_method="barycenter",
init="barycenter",
max_iter=30,
shuffle_input=True,
eps=0.1,
gmp=True,
padding="adopted",
):

if type(n_init) is int and n_init > 0:
self.n_init = n_init
else:
msg = '"n_init" must be a positive integer'
raise TypeError(msg)
if init_method == "rand":
self.init_method = "rand"
elif init_method == "barycenter":
self.init_method = "barycenter"
self.n_init = 1
if type(n_init) is int and n_init > 0:
self.n_init = n_init
else:
msg = '"n_init" must be a positive integer'
raise TypeError(msg)
if init == "rand":
self.init = "randomized"
elif init == "barycenter":
self.init = "barycenter"
elif not isinstance(init, str):
self.init = init
else:
msg = 'Invalid "init_method" parameter string'
raise ValueError(msg)
Expand Down Expand Up @@ -179,148 +188,29 @@ def fit(self, A, B, seeds_A=[], seeds_B=[]):
B = check_array(B, copy=True, ensure_2d=True)
seeds_A = column_or_1d(seeds_A)
seeds_B = column_or_1d(seeds_B)
partial_match = np.column_stack((seeds_A, seeds_B))

# pads A and B according to section 2.5 of [2]
if A.shape[0] != B.shape[0]:
A, B = _adj_pad(A, B, self.padding)

if A.shape[0] != A.shape[1] or B.shape[0] != B.shape[1]:
msg = "Adjacency matrix entries must be square"
raise ValueError(msg)
elif seeds_A.shape[0] != seeds_B.shape[0]:
msg = "Seed arrays must be of equal size"
raise ValueError(msg)
elif seeds_A.shape[0] > A.shape[0]:
msg = "There cannot be more seeds than there are nodes"
raise ValueError(msg)
elif not (seeds_A >= 0).all() or not (seeds_B >= 0).all():
msg = "Seed array entries must be greater than or equal to zero"
raise ValueError(msg)
elif (
not (seeds_A <= (A.shape[0] - 1)).all()
or not (seeds_B <= (A.shape[0] - 1)).all()
):
msg = "Seed array entries must be less than or equal to n-1"
raise ValueError(msg)

n = A.shape[0] # number of vertices in graphs
n_seeds = seeds_A.shape[0] # number of seeds
n_unseed = n - n_seeds

score = math.inf
perm_inds = np.zeros(n)

obj_func_scalar = 1
if self.gmp:
obj_func_scalar = -1
score = 0

seeds_B_c = np.setdiff1d(range(n), seeds_B)
if self.shuffle_input:
seeds_B_c = np.random.permutation(seeds_B_c)
# shuffle_input to avoid results from inputs that were already matched

seeds_A_c = np.setdiff1d(range(n), seeds_A)
permutation_A = np.concatenate([seeds_A, seeds_A_c], axis=None).astype(int)
permutation_B = np.concatenate([seeds_B, seeds_B_c], axis=None).astype(int)
A = A[np.ix_(permutation_A, permutation_A)]
B = B[np.ix_(permutation_B, permutation_B)]

# definitions according to Seeded Graph Matching [2].
A11 = A[:n_seeds, :n_seeds]
A12 = A[:n_seeds, n_seeds:]
A21 = A[n_seeds:, :n_seeds]
A22 = A[n_seeds:, n_seeds:]
B11 = B[:n_seeds, :n_seeds]
B12 = B[:n_seeds, n_seeds:]
B21 = B[n_seeds:, :n_seeds]
B22 = B[n_seeds:, n_seeds:]
A11T = np.transpose(A11)
A12T = np.transpose(A12)
A22T = np.transpose(A22)
B21T = np.transpose(B21)
B22T = np.transpose(B22)

for i in range(self.n_init):
# setting initialization matrix
if self.init_method == "rand":
sk = SinkhornKnopp()
K = np.random.rand(
n_unseed, n_unseed
) # generate a nxn matrix where each entry is a random integer [0,1]
for i in range(10): # perform 10 iterations of Sinkhorn balancing
K = sk.fit(K)
J = np.ones((n_unseed, n_unseed)) / float(
n_unseed
) # initialize J, a doubly stochastic barycenter
P = (K + J) / 2
elif self.init_method == "barycenter":
P = np.ones((n_unseed, n_unseed)) / float(n_unseed)

const_sum = A21 @ np.transpose(B21) + np.transpose(A12) @ B12
grad_P = math.inf # gradient of P
n_iter = 0 # number of FW iterations

# OPTIMIZATION WHILE LOOP BEGINS
while grad_P > self.eps and n_iter < self.max_iter:

delta_f = (
const_sum + A22 @ P @ B22T + A22T @ P @ B22
) # computing the gradient of f(P) = -tr(APB^tP^t)
rows, cols = linear_sum_assignment(
obj_func_scalar * delta_f
) # run hungarian algorithm on gradient(f(P))
Q = np.zeros((n_unseed, n_unseed))
Q[rows, cols] = 1 # initialize search direction matrix Q

def f(x): # computing the original optimization function
return obj_func_scalar * (
np.trace(A11T @ B11)
+ np.trace(np.transpose(x * P + (1 - x) * Q) @ A21 @ B21T)
+ np.trace(np.transpose(x * P + (1 - x) * Q) @ A12T @ B12)
+ np.trace(
A22T
@ (x * P + (1 - x) * Q)
@ B22
@ np.transpose(x * P + (1 - x) * Q)
)
)

alpha = minimize_scalar(
f, bounds=(0, 1), method="bounded"
).x # computing the step size
P_i1 = alpha * P + (1 - alpha) * Q # Update P
grad_P = np.linalg.norm(P - P_i1)
P = P_i1
n_iter += 1
# end of FW optimization loop

row, col = linear_sum_assignment(
-P
) # Project onto the set of permutation matrices
perm_inds_new = np.concatenate(
(np.arange(n_seeds), np.array([x + n_seeds for x in col]))
)

score_new = np.trace(
np.transpose(A) @ B[np.ix_(perm_inds_new, perm_inds_new)]
) # computing objective function value

if obj_func_scalar * score_new < obj_func_scalar * score: # minimizing
score = score_new
perm_inds = np.zeros(n, dtype=int)
perm_inds[permutation_A] = permutation_B[perm_inds_new]
best_n_iter = n_iter

permutation_A_unshuffle = _unshuffle(permutation_A, n)
A = A[np.ix_(permutation_A_unshuffle, permutation_A_unshuffle)]
permutation_B_unshuffle = _unshuffle(permutation_B, n)
B = B[np.ix_(permutation_B_unshuffle, permutation_B_unshuffle)]
score = np.trace(np.transpose(A) @ B[np.ix_(perm_inds, perm_inds)])

self.perm_inds_ = perm_inds # permutation indices
self.score_ = score # objective function value
self.n_iter_ = best_n_iter
options = {
"maximize": self.gmp,
"partial_match": partial_match,
"P0": self.init,
"shuffle_input": self.shuffle_input,
"maxiter": self.max_iter,
"tol": self.eps,
}

res = min(
[quadratic_assignment(A, B, options=options) for i in range(self.n_init)],
key=lambda x: x.fun,
)

self.perm_inds_ = res.col_ind # permutation indices
self.score_ = res.fun # objective function value
self.n_iter_ = res.nit
return self

def fit_predict(self, A, B, seeds_A=[], seeds_B=[]):
Expand Down Expand Up @@ -372,9 +262,3 @@ def pad(X, n):
B = pad(B, n)

return A, B


def _unshuffle(array, n):
unshuffle = np.array(range(n))
unshuffle[array] = np.array(range(n))
return unshuffle
Loading