Repository: graspologic-org/graspologic

Issue:
Generate correlated RDPG graph pairs
<!--
The issue tracker is a tool to address bugs in GraSPy itself. If you'd like to report a bug in GraSPy, fill out the template below and provide any extra information that may be useful / related to your problem.
-->

## Generate correlated graph pairs based on RDPG.


Given a matrix X, the probability matrix will be calculated as P = XX^T, then generate the correlated graph pairs based on P.


Discussion:

Resolving patch:
diff --git a/graspy/simulations/__init__.py b/graspy/simulations/__init__.py
--- a/graspy/simulations/__init__.py
+++ b/graspy/simulations/__init__.py
@@ -1,2 +1,3 @@
 from .simulations import *
 from .simulations_corr import *
+from .rdpg_corr import *
diff --git a/graspy/simulations/rdpg_corr.py b/graspy/simulations/rdpg_corr.py
new file mode 100644
--- /dev/null
+++ b/graspy/simulations/rdpg_corr.py
@@ -0,0 +1,113 @@
+import numpy as np
+from graspy.simulations import p_from_latent, sample_edges_corr
+
+
+def rdpg_corr(X, Y, r, rescale=False, directed=False, loops=False):
+    r"""
+    Samples a random graph pair based on the latent positions in X (and 
+    optionally in Y)
+    If only X :math:`\in\mathbb{R}^{n\times d}` is given, the P matrix is calculated as
+    :math:`P = XX^T`. If X, Y :math:`\in\mathbb{R}^{n\times d}` is given, then 
+    :math:`P = XY^T`. These operations correspond to the dot products between a set of 
+    latent positions, so each row in X or Y represents the latent positions in  
+    :math:`\mathbb{R}^{d}` for a single vertex in the random graph 
+    Note that this function may also rescale or clip the resulting P 
+    matrix to get probabilities between 0 and 1, or remove loops.
+    A binary random graph is then sampled from the P matrix described 
+    by X (and possibly Y).
+    Read more in the :ref:`tutorials <simulations_tutorials>`
+
+    Parameters
+    ----------
+    X: np.ndarray, shape (n_vertices, n_dimensions)
+        latent position from which to generate a P matrix
+        if Y is given, interpreted as the left latent position
+
+    Y: np.ndarray, shape (n_vertices, n_dimensions) or None, optional
+        right latent position from which to generate a P matrix
+
+    r: float
+        The value of the correlation between the same vertices in two graphs.
+
+    rescale: boolean, optional (default=True)
+        when rescale is True, will subtract the minimum value in 
+        P (if it is below 0) and divide by the maximum (if it is
+        above 1) to ensure that P has entries between 0 and 1. If
+        False, elements of P outside of [0, 1] will be clipped.
+
+    directed: boolean, optional (default=False)
+        If False, output adjacency matrix will be symmetric. Otherwise, output adjacency
+        matrix will be asymmetric.
+        
+    loops: boolean, optional (default=True)
+        If False, no edges will be sampled in the diagonal. Diagonal elements in P 
+        matrix are removed prior to rescaling (see above) which may affect behavior.
+        Otherwise, edges are sampled in the diagonal.
+
+    Returns
+    -------
+    G1: ndarray (n_vertices, n_vertices)
+        A matrix representing the probabilities of connections between 
+        vertices in a random graph based on their latent positions
+        
+    G2: ndarray (n_vertices, n_vertices)
+        A matrix representing the probabilities of connections between 
+        vertices in a random graph based on their latent positions
+
+    References
+    ----------
+    .. [1] Vince Lyzinski, Donniell E Fishkind profile imageDonniell E. Fishkind, Carey E Priebe.
+       "Seeded graph matching for correlated Erdös-Rényi graphs".
+       The Journal of Machine Learning Research, January 2014
+    
+    Examples
+    --------
+    >>> np.random.seed(1234)
+    >>> X = np.random.dirichlet([1, 1], size=5)
+    >>> Y = None
+
+    Generate random latent positions using 2-dimensional Dirichlet distribution.
+    Then sample a correlated RDPG graph pair:
+
+    >>> rdpg_corr(X, Y, 0.3, rescale=False, directed=False, loops=False)
+    (array([[0., 1., 0., 1., 0.],
+           [1., 0., 0., 1., 1.],
+           [0., 0., 0., 0., 0.],
+           [1., 1., 0., 0., 0.],
+           [0., 1., 0., 0., 0.]]), array([[0., 1., 0., 1., 0.],
+           [1., 0., 0., 0., 1.],
+           [0., 0., 0., 0., 0.],
+           [1., 0., 0., 0., 0.],
+           [0., 1., 0., 0., 0.]]))
+    """
+    # check r
+    if not np.issubdtype(type(r), np.floating):
+        raise TypeError("r is not of type float.")
+    elif r < -1 or r > 1:
+        msg = "r must between -1 and 1."
+        raise ValueError(msg)
+
+    # check directed and loops
+    if type(directed) is not bool:
+        raise TypeError("directed is not of type bool.")
+    if type(loops) is not bool:
+        raise TypeError("loops is not of type bool.")
+
+    # check dimensions of X and Y
+    if Y != None:
+        if type(X) is not np.ndarray or type(Y) is not np.ndarray:
+            raise TypeError("Latent positions must be numpy.ndarray")
+        if X.ndim != 2 or Y.ndim != 2:
+            raise ValueError(
+                "Latent positions must have dimension 2 (n_vertices, n_dimensions)"
+            )
+        if X.shape != Y.shape:
+            raise ValueError("Dimensions of latent positions X and Y must be the same")
+    if Y is None:
+        Y = X
+
+    P = p_from_latent(X, Y, rescale=rescale, loops=loops)
+    n = P.shape[0]
+    R = np.full((n, n), r)
+    G1, G2 = sample_edges_corr(P, R, directed=directed, loops=loops)
+    return G1, G2


Test patch:
diff --git a/tests/test_rdpg_corr.py b/tests/test_rdpg_corr.py
new file mode 100644
--- /dev/null
+++ b/tests/test_rdpg_corr.py
@@ -0,0 +1,80 @@
+import unittest
+from graspy.simulations.simulations import sample_edges, p_from_latent
+from graspy.simulations.rdpg_corr import rdpg_corr
+import numpy as np
+import pytest
+import warnings
+
+
+class Test_RDPG_Corr(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        cls.r = 0.3
+        cls.Y = None
+        cls.X = np.random.dirichlet([20, 20], size=300)
+
+    def test_dimensions(self):
+        np.random.seed(1234)
+        A, B = rdpg_corr(
+            self.X, self.Y, self.r, rescale=False, directed=False, loops=False
+        )
+        self.assertTrue(A.shape, (300, 300))
+        self.assertTrue(B.shape, (300, 300))
+
+    def test_inputs(self):
+        x1 = np.array([[1, 1], [1, 1]])
+        x2 = np.array([[1, 1]])
+        x3 = np.zeros((2, 2, 2))
+        with self.assertRaises(TypeError):
+            p_from_latent("hi")  # wrong type
+        with self.assertRaises(ValueError):
+            p_from_latent(x1, x2)  # dimension mismatch
+        with self.assertRaises(ValueError):
+            p_from_latent(x3)  # wrong num dimensions
+        with self.assertRaises(TypeError):
+            sample_edges("XD")  # wrong type
+        with self.assertRaises(ValueError):
+            sample_edges(x3)  # wrong num dimensions
+        with self.assertRaises(ValueError):
+            sample_edges(x2)  # wrong shape for P
+
+        if any(self.X[self.X > 1]) or any(self.X[self.X < -1]):  # wrong values for P
+            raise ValueError("P values should be less than 1 and bigger than -1")
+
+    def test_rdpg_corr(self):
+        np.random.seed(123)
+        g1, g2 = rdpg_corr(
+            self.X, self.Y, self.r, rescale=False, directed=False, loops=False
+        )
+
+        # check the dimention of g1, g2
+        self.assertTrue(g1.shape == (self.X.shape[0], self.X.shape[0]))
+        self.assertTrue(g1.shape == (self.X.shape[0], self.X.shape[0]))
+
+        # check rho
+        g1 = g1[np.where(~np.eye(g1.shape[0], dtype=bool))]
+        g2 = g2[np.where(~np.eye(g2.shape[0], dtype=bool))]
+        correlation = np.corrcoef(g1, g2)[0, 1]
+        self.assertTrue(np.isclose(correlation, self.r, atol=0.01))
+
+    # check P
+    def test_p_is_close(self):
+        P = p_from_latent(self.X, self.Y, rescale=False, loops=True)
+        if any(P[P > 1]) or any(P[P < -1]):  # wrong values for P
+            raise ValueError("P values should be less than 1 and bigger than -1")
+
+        np.random.seed(8888)
+        graphs1 = []
+        graphs2 = []
+        for i in range(1000):
+            g1, g2 = rdpg_corr(
+                self.X, self.Y, self.r, rescale=False, directed=True, loops=True
+            )
+            graphs1.append(g1)
+            graphs2.append(g2)
+        graphs1 = np.stack(graphs1)
+        graphs2 = np.stack(graphs2)
+        np.testing.assert_allclose(np.mean(graphs1, axis=0), P, atol=0.1)
+        np.testing.assert_allclose(np.mean(graphs2, axis=0), P, atol=0.1)
+        np.testing.assert_allclose(np.mean(np.mean(graphs1, axis=0) - P), 0, atol=1e-4)
+        np.testing.assert_allclose(np.mean(np.mean(graphs2, axis=0) - P), 0, atol=1e-4)