Aligning¶
align
is a module that can be used to align two different datasets. In particular, all three currently existing classes, namely SignFlips
, regular OrthogonalProcrustes
, and SeedlessProcrustes
are aimed at correcting an orthogonal transformation of the data, the exact form of which is unknown. The motivation for this are orthogonal non-identifiabilities, which are common when dealing with various embedding methods, whether in statistical graphs or other domains. Noted that if two
graphs are embedded using omnibus embedding - they don’t need to be aligned.
Recall that an orthogonal matrix is any matrix \(Q\) such that \(Q^T Q = Q Q^T = I\). For all alignment methods, the main idea is to infer an orthogonal matrix \(Q\) from the input data sets \(X\) and \(Y\), and subsequently use \(Q\) in order to align \(X\) to \(Y\), by transforming \(X\) as \(\hat{X} = X Q\). The major differences among classes in align
come from the settings in which they are applicable, as well as additional assumptions made on the
matrix \(Q\).
[1]:
import numpy as np
import matplotlib.pyplot as plt
from graspologic.plot import heatmap
from scipy.stats import special_ortho_group, ortho_group
%matplotlib inline
Simple rotation example¶
First, let’s build two simple datasets \(X\) and \(Y\), with all entries of \(X\) coming from the \(Uniform(0, 1)\) distribution, and \(Y\) is a copy of \(X\), and subsequently rotated around the origin, using the randomly generated matrix \(Q\), displayed below:
[2]:
np.random.seed(314)
X = np.random.uniform(0, 1, (15, 2))
Q = special_ortho_group.rvs(2)
Y = X @ Q
[3]:
heatmap(Q, figsize=(4,4), vmin=-1, vmax=1)
Q
[3]:
array([[-0.94924938, -0.31452442],
[ 0.31452442, -0.94924938]])
[4]:
fig, ax = plt.subplots(1, 1, figsize=(6, 6))
ax.set_title("Simple rotation example")
ax.scatter(X[:,0], X[:,1], label=r"$X$", marker='x')
ax.scatter(Y[:,0], Y[:,1], label=r"$Y$", s=100, alpha=0.5)
ax.set(xlim=(-1.20, 1.20), ylim=(-1.20, 1.20))
ax.set_xticks([0])
ax.set_yticks([0])
ax.legend()
ax.grid();
SignFlips¶
SignFlips
assumes that \(Q\) is not only orthogonal, but also diagonal, which constraints to matrices with \(\pm 1\) along the diagonal and \(0\) everywhere else. In essence, it flips the dataset along some axes such that the two datasets end up in the same orthant (generalized quadrant). Which quadrant the dataset is “located” in is defined by some criterion, which is the dimension-wise medians by default. If the dataset needs to flip along some dimension, the corresponding
entry in \(Q\) will be \(-1\), and \(1\) otherwise.
[5]:
from graspologic.align import SignFlips
aligner_SF = SignFlips()
X_prime_SF = aligner_SF.fit_transform(X, Y)
Since \(X\) is entirely in the first quadrant, its dimension-wise medians in both dimensions are greater than \(0\). Meanwhile \(Y\) is predominantly in the third quadrant, so its dimension-wise medians are all smaller than \(0\). Thus \(X\) needs to be flipped both along the x-axis and y-axis in order for two datasets to arive to the same quandrant. So, the \(Q_{SF}\) that the algorithm finds is
[6]:
heatmap(aligner_SF.Q_, figsize= (4,4), vmin=-1, vmax=1)
aligner_SF.Q_
[6]:
array([[-1, 0],
[ 0, -1]])
[7]:
fig, ax = plt.subplots(1, 1, figsize=(6, 6))
ax.set_title("Simple rotation example, Sign Flips")
ax.scatter(X[:,0], X[:,1],
label=r"$X$", marker='x')
ax.scatter(Y[:,0], Y[:,1],
label=r"$Y$", s=100, alpha=0.5)
ax.scatter(X_prime_SF[:,0],
X_prime_SF[:,1],
label=r"$X_{SF}$",
marker='$F$')
ax.set(xlim=(-1.20, 1.20), ylim=(-1.20, 1.20))
ax.set_xticks([0])
ax.set_yticks([0])
ax.legend()
ax.grid();
SignFlips
correctly brings \(X\) to the third quadrant, yet the alignment is imperfect, as it is a heuristic-based method. Note that it does not assume that there is any sort of matching between the entries of \(X\) and \(Y\). Indeed, the two datasets need not even be of the same size.
This class can also be used to bring the dataset to the first orthant, if one provides the identity matrix as the second dataset.
OrthogonalProcrustes¶
OrthogonalProcrustes
obtains \(Q_{OP}\) by solving the classic orthogonal procrustes problem.
Orthogonal Procrustes problem is a matrix approximation problem in linear algebra, as one is asked to find a orthogonal matrix \(Q\), which could most closely map the given matrix \(X\) to another given matrix \(Y\). Formally it seeks to find a matrix \(Q_{OP}\) such that \(|| X Q_{OP} - Y ||_F\) is minimized.
Since, this formulation strongly relies on the correspondence between the rows of the given matrices, this class can only be applied when the entery datasets \(X\) and \(Y\) have the equal shape (\(n\), \(d\)), and the \(i^{th}\) entry in \(X\) is assumed to be the same value as vertex \(i^{th}\) entry of \(Y\), up to some noise. In graph setting, this means that the latent positions of the two graphs are equivalent.
OrthogonalProcrustes
has a widely known closed form solution. Indeed, this class simply wraps scipy.linal.orthogonal_procrustes().
[8]:
from graspologic.align import OrthogonalProcrustes
aligner_OP = OrthogonalProcrustes()
X_prime_OP = aligner_OP.fit_transform(X, Y)
Note that that \(Q_{OP}\) is a generic orthogonal matrix, not a diagonal one.
[9]:
heatmap(aligner_OP.Q_, figsize= (4, 4), vmin=-1, vmax=1)
aligner_OP.Q_
[9]:
array([[-0.94924938, -0.31452442],
[ 0.31452442, -0.94924938]])
[10]:
fig, ax = plt.subplots(1, 1, figsize=(6, 6))
ax.set_title("Simple rotation example, OrthogonalProcrustes")
ax.scatter(X[:,0], X[:,1], label=r"$X$", marker='x')
ax.scatter(Y[:,0], Y[:,1], label=r"$Y$", s=100, alpha=0.5)
ax.scatter(X_prime_OP[:,0],
X_prime_OP[:,1],
label=r"$X_{OP}$",
marker='$O$')
ax.set(xlim=(-1.20, 1.20), ylim=(-1.20, 1.20))
ax.set_xticks([0])
ax.set_yticks([0])
ax.legend()
ax.grid();
As expected, OrthogonalProcrustes
obtains an exact solution when \(X\) is a rotated copy of \(Y\).
SeedlessProcrustes¶
SeedlessProcrustes
is an algorithm that extends regular Procrustes to a case when there is no known correspondence between the entries. In fact, it can be used even when the number of entries between the two datasest is different.
Formally, if \(X\) has \(n\) entries, and \(Y\) has \(m\) entries, it tries to solve for an orthogonal matrix \(Q_{SP}\), and an \(n \times m\) matrix \(P\), with a special condition that rows sum to \(1/m\), and columns sum to \(1/n\), such that the quantity \(|| X Q_{SP} - P Y ||_F\) is minimized.
Unlike the regular procrustes, this does not have a known closed form solution. SeedlessProcrustes
tries to solve this optimization problem via an iterative algorithm that alternates optimal transport and regular procrustes. See the Notes section of the SeedlessProcrustes
references for more details on this algorithm.
[11]:
from graspologic.align import SeedlessProcrustes
aligner_SP = SeedlessProcrustes()
X_prime_SP = aligner_SP.fit_transform(X, Y)
[12]:
heatmap(aligner_SP.Q_, figsize=(4,4), vmin=-1, vmax=1)
aligner_SP.Q_
[12]:
array([[-0.94472887, -0.32785266],
[ 0.32785266, -0.94472887]])
[13]:
fig, ax = plt.subplots(1, 1, figsize=(6, 6))
ax.set_title("SeedlessProcrustes, Simple rotation example")
ax.scatter(X[:,0], X[:,1], label=r"$X$", marker='x')
ax.scatter(Y[:,0], Y[:,1], label=r"$Y$", s=100, alpha=0.5)
ax.scatter(X_prime_SP[:,0],
X_prime_SP[:,1],
label=r"$X_{SP}$",
marker='$S$')
ax.set(xlim=(-1.20, 1.20), ylim=(-1.20, 1.20))
ax.set_xticks([0])
ax.set_yticks([0])
ax.legend()
ax.grid();
Comparison between SignFlips, OrthogonalProcrustes, SeedlessProcrustes¶
The outcomes of three classes in simple rotation example together are plotted on the same figure in order to allow for a simpler comparison.
[14]:
fig, ax = plt.subplots(1, 1, figsize=(6, 6))
ax.set_title("Aligning, Simple rotation example")
ax.scatter(X[:,0], X[:,1], label=r"$X$", marker='x')
ax.scatter(Y[:,0], Y[:,1], label=r"$Y$", s=100, alpha=0.5)
ax.scatter(X_prime_SF[:,0],
X_prime_SF[:,1],
label=r"$X_{SF}$",
marker='$F$')
ax.scatter(X_prime_OP[:,0],
X_prime_OP[:,1],
label=r"$X_{OP}$", marker='$o$')
ax.scatter(X_prime_SP[:,0],
X_prime_SP[:,1],
label=r"$X_{SP}$", marker='$S$')
ax.set(xlim=(-1.20, 1.20), ylim=(-1.20, 1.20))
ax.set_xticks([0])
ax.set_yticks([0])
ax.legend()
ax.grid();
[15]:
fig, (ax1, ax2, ax3, ax4) = plt.subplots(1, 4, figsize = (16, 4))
heatmap(Q, title=r'$Q$', vmin=-1, vmax=1, ax=ax1)
heatmap(aligner_SF.Q_, title=r'$Q_{SF}$', vmin=-1, vmax=1, ax=ax2)
heatmap(aligner_OP.Q_, title=r'$Q_{OP}$', vmin=-1, vmax=1, ax=ax3)
heatmap(aligner_SP.Q_, title=r'$Q_{SP}$', vmin=-1, vmax=1, ax=ax4);
In this simple example, OrthogonalProcrustes
and SeedlessProcrustes
align \(X\) to \(Y\) extremply well in terms of the Frobenius norm, whereas SignFlips
can only do so well as to bring them to the same quadrant. In fact, when a dataset is a rotated copy of another - OrthogonalProcrustes
aligns it up to a numerical epsilon precision.
[16]:
norm_SignFlips = np.linalg.norm(X_prime_SF - Y)
norm_Orthogonal = np.linalg.norm(X_prime_OP - Y)
norm_Seedless = np.linalg.norm(X_prime_SP - Y)
print('Simple example')
print('Difference in Frobenious norm after Sign Flips ', norm_SignFlips)
print('Difference in Frobenious norm after Orthogonal Procrustes ', norm_Orthogonal)
print('Difference in Frobenious norm after Seedless Procrustes ', norm_Seedless)
Simple example
Difference in Frobenious norm after Sign Flips 1.047909310874878
Difference in Frobenious norm after Orthogonal Procrustes 5.800542824167306e-16
Difference in Frobenious norm after Seedless Procrustes 0.046291889999320164
Shuffled Example¶
Recall that unlike the other two methods, OrthogonalProcrustes
heavily relies on a fact that the vertices of the two datasets are matched. Its performance will deteriorate even on this simple example if the vertices are shuffled, as shown in the following figure.
[17]:
from sklearn.utils import shuffle
[18]:
X_shuffled = shuffle(X)
[19]:
aligner_SF_shuffled = SignFlips()
X_prime_SF_shuffled = aligner_SF_shuffled.fit_transform(X_shuffled, Y)
aligner_OP_shuffled = OrthogonalProcrustes()
X_prime_OP_shuffled = aligner_OP_shuffled.fit_transform(X_shuffled, Y)
aligner_SP_shuffled = SeedlessProcrustes()
X_prime_SP_shuffled = aligner_SP_shuffled.fit_transform(X_shuffled, Y)
[20]:
fig, ax = plt.subplots(1, 1, figsize=(6, 6))
ax.set_title("Aligning, Shuffled example")
ax.scatter(X_shuffled[:,0], X_shuffled[:,1], label=r"$X$", marker='x')
ax.scatter(Y[:,0], Y[:,1], label=r"$Y$", s=100, alpha=0.5)
ax.scatter(X_prime_SF_shuffled[:,0],
X_prime_SF_shuffled[:,1],
label=r"$X_{SF}$", marker='$F$')
ax.scatter(X_prime_OP_shuffled[:,0],
X_prime_OP_shuffled[:,1],
label=r"$X_{OP}$", marker='$o$')
ax.scatter(X_prime_SP_shuffled[:,0],
X_prime_SP_shuffled[:,1],
label=r"$X_{SP}$", marker='$S$')
ax.set(xlim=(-1.20, 1.20), ylim=(-1.20, 1.20))
ax.set_xticks([0])
ax.set_yticks([0])
ax.legend()
ax.grid();
[21]:
fig, (ax1, ax2, ax3, ax4) = plt.subplots(1, 4, figsize = (16, 4))
heatmap(Q, title=r'$Q$', vmin=-1, vmax=1, ax=ax1)
heatmap(aligner_SF_shuffled.Q_, title=r'$Q_{SF}$', vmin=-1, vmax=1, ax=ax2)
heatmap(aligner_OP_shuffled.Q_, title=r'$Q_{OP}$', vmin=-1, vmax=1, ax=ax3)
heatmap(aligner_SP_shuffled.Q_, title=r'$Q_{SP}$', vmin=-1, vmax=1, ax=ax4);
In this shuffled example, though the dataset \(X_{shuffled}\) looks the same as dataset \(X\) in the figure, the performance of OthogonalProcrustes
is worse than the one in the simple rotation example above. This is because the value of \(i^{th}\) entry in \(X_{shuffled}\) is no longer corresponding with the \(i^{th}\) entry of \(Y\) after being shuffled from \(X\).
Unmatched example¶
As mentioned above, SeedlessProcrustes
can be applied to two datasets with different number of entries. Consider the following example: \(X\) is the same as previous, but the new \(Y\) is a different, larger dataset, generated from the same distribution, and then subsequently rotated.
[22]:
Y_unmatched = np.random.uniform(0, 1, (30, 2)) @ Q
[23]:
aligner_SF_unmatched = SignFlips()
X_prime_SF_unmatched = aligner_SF_unmatched.fit_transform(X, Y_unmatched)
aligner_SP_unmatched = SeedlessProcrustes()
X_prime_SP_unmatched = aligner_SP_unmatched.fit_transform(X, Y_unmatched)
[24]:
fig, ax = plt.subplots(1, 1, figsize=(6, 6))
ax.set_title("Unmatched input example, Seedless Procrustes")
ax.scatter(X[:,0], X[:,1], label=r"$X$", marker='x')
ax.scatter(Y_unmatched[:,0], Y_unmatched[:,1], label=r"$Y$", s=100, alpha=0.5)
ax.scatter(X_prime_SF_unmatched[:,0],
X_prime_SF_unmatched[:,1],
label=r"$X_{SF}$",
marker='$F$')
ax.scatter(X_prime_SP_unmatched[:,0],
X_prime_SP_unmatched[:,1],
label=r"$X_{SP}$",
marker='$S$')
ax.set(xlim=(-1.20, 1.20), ylim=(-1.20, 1.20))
ax.set_xticks([0])
ax.set_yticks([0])
ax.legend()
ax.grid();
[25]:
fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize = (12, 4))
heatmap(Q, title=r'$Q$', vmin=-1, vmax=1, ax=ax1)
heatmap(aligner_SF_unmatched.Q_, title=r'$Q_{SF}$', vmin=-1, vmax=1, ax=ax2)
heatmap(aligner_SP_unmatched.Q_, title=r'$Q_{SP}$', vmin=-1, vmax=1, ax=ax3);
[26]:
print("Mean of the dataset X: ", np.mean(X, axis=0))
print("Mean of the dataset Y: ", np.mean(Y, axis=0))
print("Mean of the dataset X_{SF}: ", np.mean(X_prime_SF_unmatched, axis=0))
print("Mean of the dataset X_{SP}: ", np.mean(X_prime_SP_unmatched, axis=0))
Mean of the dataset X: [0.58510607 0.48290266]
Mean of the dataset Y: [-0.4035269 -0.6424252]
Mean of the dataset X_{SF}: [-0.58510607 -0.48290266]
Mean of the dataset X_{SP}: [-0.38535747 -0.65348582]
SeedlessProcrustes
is still able to align the distributions, while the SignFlips
method is only a good heuristic. Note that OthogonalProcrustes
is simply inapplicable in this context.