Repository: graspologic-org/graspologic

Issue:
p_value_ inconsistency between latent distribution and latent position tests
[after the #336,] latent distribution test has attribute self.p_value_, whereas latent position test has self.p_ . they should be made consistent. specifically, change latent position test to p_value_.


Discussion:

Resolving patch:
diff --git a/graspy/inference/base.py b/graspy/inference/base.py
--- a/graspy/inference/base.py
+++ b/graspy/inference/base.py
@@ -12,65 +12,54 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import numpy as np
 from abc import abstractmethod
 from sklearn.base import BaseEstimator
-import numpy as np
 
 
 class BaseInference(BaseEstimator):
     """
-    Base class for inference tasks such as semiparametric and
-    nonparametric inference tasks.
+    Base class for inference tasks such as semiparametric latent position test
+    and nonparametric latent distribution test.
 
     Parameters
     ----------
-    embedding : { 'ase' (default), 'lse, 'omnibus'}
-        String describing the embedding method to use.
-        Must be one of:
-        'ase'
-            Embed each graph separately using adjacency spectral embedding
-            and use Procrustes to align the embeddings.
-        'omnibus'
-            Embed all graphs simultaneously using omnibus embedding.
-
     n_components : None (default), or int
         Number of embedding dimensions. If None, the optimal embedding
-        dimensions are found by the Zhu and Godsi algorithm.
+        dimensions are chosen via the Zhu and Godsi algorithm.
     """
 
-    def __init__(self, embedding="ase", n_components=None):
-        if type(embedding) is not str:
-            raise TypeError("embedding must be str")
+    def __init__(self, n_components=None):
         if (not isinstance(n_components, (int, np.integer))) and (
             n_components is not None
         ):
             raise TypeError("n_components must be int or np.integer")
-        if embedding not in ["ase", "omnibus"]:
-            raise ValueError("{} is not a valid embedding method.".format(embedding))
         if n_components is not None and n_components <= 0:
             raise ValueError(
                 "Cannot embed into {} dimensions, must be greater than 0".format(
                     n_components
                 )
             )
-        self.embedding = embedding
         self.n_components = n_components
 
     @abstractmethod
-    def _bootstrap(self):
-        pass
-
-    @abstractmethod
-    def _embed(self, X1, X2, n_componets):
+    def _embed(self, A1, A2, n_componets):
         """
         Computes the latent positions of input graphs
 
+        Parameters
+        ----------
+        A1 : np.ndarray, shape (n_vertices, n_vertices)
+            Adjacency matrix of the first graph
+        A2 : np.ndarray, shape (n_vertices, n_vertices)
+            Adjacency matrix of the second graph
+
         Returns
         -------
         X1_hat : array-like, shape (n_vertices, n_components)
-            Estimated latent positions of X1
+            Estimated latent positions of the vertices in the first graph
         X2_hat : array-like, shape(n_vertices, n_components)
-            Estimated latent positions of X2
+            Estimated latent positions of the vertices in the second graph
         """
 
     @abstractmethod
@@ -80,8 +69,31 @@ def fit(self, A1, A2):
 
         Parameters
         ----------
-        X1 : nx.Graph or np.ndarray
-            A graph
-        X2 : nx.Graph or np.ndarray
-            A graph
+        A1, A2 : nx.Graph, nx.DiGraph, nx.MultiDiGraph, nx.MultiGraph, np.ndarray
+            The two graphs to run a hypothesis test on.
+            If np.ndarray, shape must be ``(n_vertices, n_vertices)`` for both
+            graphs, where ``n_vertices`` is the same for both
+
+        Returns
+        ------
+        self
+        """
+        pass
+
+    def fit_predict(self, A1, A2):
+        """
+        Fits the model and returns the p-value
+        Parameters
+        ----------
+        A1, A2 : nx.Graph, nx.DiGraph, nx.MultiDiGraph, nx.MultiGraph, np.ndarray
+            The two graphs to run a hypothesis test on.
+            If np.ndarray, shape must be ``(n_vertices, n_vertices)`` for both
+            graphs, where ``n_vertices`` is the same for both
+
+        Returns
+        ------
+        p_value_ : float
+            The overall p value from the test
         """
+        self.fit(A1, A2)
+        return self.p_value_


Test patch:
diff --git a/docs/tutorials/inference/latent_distribution_test.ipynb b/docs/tutorials/inference/latent_distribution_test.ipynb
--- a/docs/tutorials/inference/latent_distribution_test.ipynb
+++ b/docs/tutorials/inference/latent_distribution_test.ipynb
@@ -205,10 +205,10 @@
    ],
    "source": [
     "ldt_dcorr = LatentDistributionTest(\"dcorr\", metric=\"euclidean\", n_bootstraps=1000)\n",
-    "p_dcorr = ldt_dcorr.fit(A1, A2)\n",
+    "ldt_dcorr.fit(A1, A2)\n",
     "ldt_mgc = LatentDistributionTest(\"mgc\", metric=\"euclidean\", n_bootstraps=1000)\n",
-    "p_mgc = ldt_mgc.fit(A1, A2)\n",
-    "print(p_dcorr, p_mgc)"
+    "ldt_mgc.fit(A1, A2)\n",
+    "print(ldt_dcorr.p_value_, ldt_mgc.p_value_)"
    ]
   },
   {
@@ -333,10 +333,10 @@
    ],
    "source": [
     "ldt_dcorr = LatentDistributionTest(\"dcorr\", metric=\"euclidean\", n_bootstraps=1000)\n",
-    "p_dcorr = ldt_dcorr.fit(A1, A2)\n",
+    "ldt_dcorr.fit(A1, A2)\n",
     "ldt_mgc = LatentDistributionTest(\"mgc\", metric=\"euclidean\", n_bootstraps=1000)\n",
-    "p_mgc = ldt_mgc.fit(A1, A2)\n",
-    "print(p_dcorr, p_mgc)"
+    "ldt_mgc.fit(A1, A2)\n",
+    "print(ldt_dcorr.p_value_, ldt_mgc.p_value_)"
    ]
   },
   {
diff --git a/docs/tutorials/inference/latent_position_test.ipynb b/docs/tutorials/inference/latent_position_test.ipynb
--- a/docs/tutorials/inference/latent_position_test.ipynb
+++ b/docs/tutorials/inference/latent_position_test.ipynb
@@ -472,7 +472,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.7.2"
+   "version": "3.7.3"
   }
  },
  "nbformat": 4,
diff --git a/graspy/inference/latent_distribution_test.py b/graspy/inference/latent_distribution_test.py
--- a/graspy/inference/latent_distribution_test.py
+++ b/graspy/inference/latent_distribution_test.py
@@ -41,20 +41,20 @@ class LatentDistributionTest(BaseInference):
     ----------
     test : str
         Backend hypothesis test to use, one of ["cca", "dcorr", "hhg", "rv", "hsic", "mgc"].
-        These tests are typically used for independence testing, but here they are used 
-        for a two-sample hypothesis test on the latent positions of two graphs. See 
-        :class:`hyppo.ksample.KSample` for more information. 
+        These tests are typically used for independence testing, but here they
+        are used for a two-sample hypothesis test on the latent positions of
+        two graphs. See :class:`hyppo.ksample.KSample` for more information.
 
     metric : str or function, (default="euclidean")
         Distance metric to use, either a callable or a valid string.
         The callable should behave similarly to :func:`sklearn.metrics.pairwise_distances`,
         if a string should be one of the keys in `sklearn.metrics.pairwise.PAIRED_DISTANCES`
-        or "gaussian" which will use a Gaussian kernel on Euclidean distances with an 
-        adaptively selected bandwidth.
+        or "gaussian" which will use a Gaussian kernel on Euclidean distances
+        with an adaptively selected bandwidth.
 
     n_components : int or None, optional (default=None)
         Number of embedding dimensions. If None, the optimal embedding
-        dimensions are found by the Zhu and Godsi algorithm. 
+        dimensions are found by the Zhu and Godsi algorithm.
         See :func:`~graspy.embed.selectSVD` for more information.
 
     n_bootstraps : int (default=200)
@@ -66,6 +66,9 @@ class LatentDistributionTest(BaseInference):
 
     Attributes
     ----------
+    null_distribution_ : ndarray, shape (n_bootstraps, )
+        The distribution of T statistics generated under the null.
+
     sample_T_statistic_ : float
         The observed difference between the embedded latent positions of the two
         input graphs.
@@ -73,9 +76,6 @@ class LatentDistributionTest(BaseInference):
     p_value_ : float
         The overall p value from the test.
 
-    null_distribution_ : ndarray, shape (n_bootstraps, )
-        The distribution of T statistics generated under the null.
-
     References
     ----------
     .. [1] Tang, M., Athreya, A., Sussman, D. L., Lyzinski, V., & Priebe, C. E. (2017).
@@ -136,7 +136,7 @@ def __init__(
             msg = "{} is invalid number of workers, must be greater than 0"
             raise ValueError(msg.format(workers))
 
-        super().__init__(embedding="ase", n_components=n_components)
+        super().__init__(n_components=n_components)
 
         if callable(metric):
             metric_func = metric
@@ -185,8 +185,7 @@ def fit(self, A1, A2):
 
         Returns
         -------
-        p_value : float
-            The p value corresponding to the specified hypothesis test
+        self
         """
         A1 = import_graph(A1)
         A2 = import_graph(A2)
@@ -197,11 +196,12 @@ def fit(self, A1, A2):
         data = self.test.test(
             X1_hat, X2_hat, reps=self.n_bootstraps, workers=self.workers, auto=False
         )
+
+        self.null_distribution_ = self.test.indep_test.null_dist
         self.sample_T_statistic_ = data[0]
         self.p_value_ = data[1]
-        self.null_distribution_ = self.test.indep_test.null_dist
 
-        return self.p_value_
+        return self
 
 
 def _medial_gaussian_kernel(X, Y=None, workers=None):
diff --git a/graspy/inference/latent_position_test.py b/graspy/inference/latent_position_test.py
--- a/graspy/inference/latent_position_test.py
+++ b/graspy/inference/latent_position_test.py
@@ -23,12 +23,12 @@
 
 class LatentPositionTest(BaseInference):
     r"""
-    Two-sample hypothesis test for the problem of determining whether two random 
+    Two-sample hypothesis test for the problem of determining whether two random
     dot product graphs have the same latent positions.
 
     This test assumes that the two input graphs are vertex aligned, that is,
     there is a known mapping between vertices in the two graphs and the input graphs
-    have their vertices sorted in the same order. Currently, the function only 
+    have their vertices sorted in the same order. Currently, the function only
     supports undirected graphs.
 
     Read more in the :ref:`tutorials <inference_tutorials>`
@@ -49,9 +49,9 @@ class LatentPositionTest(BaseInference):
         dimensions are found by the Zhu and Godsi algorithm.
 
     test_case : string, {'rotation' (default), 'scalar-rotation', 'diagonal-rotation'}
-        describes the exact form of the hypothesis to test when using 'ase' or 'lse' 
+        describes the exact form of the hypothesis to test when using 'ase' or 'lse'
         as an embedding method. Ignored if using 'omnibus'. Given two latent positions,
-        :math:`X_1` and :math:`X_2`, and an orthogonal rotation matrix :math:`R` that 
+        :math:`X_1` and :math:`X_2`, and an orthogonal rotation matrix :math:`R` that
         minimizes :math:`||X_1 - X_2 R||_F`:
 
         - 'rotation'
@@ -69,19 +69,19 @@ class LatentPositionTest(BaseInference):
     Attributes
     ----------
     null_distribution_1_, null_distribution_2_ : np.ndarray (n_bootstraps,)
-        The distribution of T statistics generated under the null, using the first and  
-        and second input graph, respectively. The latent positions of each sample graph 
-        are used independently to sample random dot product graphs, so two null 
+        The distribution of T statistics generated under the null, using the first and
+        and second input graph, respectively. The latent positions of each sample graph
+        are used independently to sample random dot product graphs, so two null
         distributions are generated
-    
+
     sample_T_statistic_ : float
         The observed difference between the embedded positions of the two input graphs
         after an alignment (the type of alignment depends on ``test_case``)
 
-    p_value_1_, p_value_2_ : float 
-        The p value estimated from the null distributions from sample 1 and sample 2. 
+    p_value_1_, p_value_2_ : float
+        The p value estimated from the null distributions from sample 1 and sample 2.
 
-    p_ : float 
+    p_value_ : float
         The overall p value from the test; this is the max of p_value_1_ and p_value_2_
 
     See also
@@ -90,9 +90,9 @@ class LatentPositionTest(BaseInference):
     graspy.embed.OmnibusEmbed
     graspy.embed.selectSVD
 
-    References  
+    References
     ----------
-    .. [1] Tang, M., A. Athreya, D. Sussman, V. Lyzinski, Y. Park, Priebe, C.E. 
+    .. [1] Tang, M., A. Athreya, D. Sussman, V. Lyzinski, Y. Park, Priebe, C.E.
        "A Semiparametric Two-Sample Hypothesis Testing Problem for Random Graphs"
        Journal of Computational and Graphical Statistics, Vol. 26(2), 2017
     """
@@ -100,6 +100,8 @@ class LatentPositionTest(BaseInference):
     def __init__(
         self, embedding="ase", n_components=None, n_bootstraps=500, test_case="rotation"
     ):
+        if type(embedding) is not str:
+            raise TypeError("embedding must be str")
         if type(n_bootstraps) is not int:
             raise TypeError()
         if type(test_case) is not str:
@@ -110,14 +112,17 @@ def __init__(
                     n_bootstraps
                 )
             )
+        if embedding not in ["ase", "omnibus"]:
+            raise ValueError("{} is not a valid embedding method.".format(embedding))
         if test_case not in ["rotation", "scalar-rotation", "diagonal-rotation"]:
             raise ValueError(
                 "test_case must be one of 'rotation', 'scalar-rotation',"
                 + "'diagonal-rotation'"
             )
 
-        super().__init__(embedding=embedding, n_components=n_components)
+        super().__init__(n_components=n_components)
 
+        self.embedding = embedding
         self.n_bootstraps = n_bootstraps
         self.test_case = test_case
         # paper uses these always, but could be kwargs eventually. need to test
@@ -180,13 +185,12 @@ def fit(self, A1, A2):
         ----------
         A1, A2 : nx.Graph, nx.DiGraph, nx.MultiDiGraph, nx.MultiGraph, np.ndarray
             The two graphs to run a hypothesis test on.
-            If np.ndarray, shape must be ``(n_vertices, n_vertices)`` for both graphs, 
+            If np.ndarray, shape must be ``(n_vertices, n_vertices)`` for both graphs,
             where ``n_vertices`` is the same for both
-        
+
         Returns
         -------
-        p : float
-            The p value corresponding to the specified hypothesis test
+        self
         """
         A1 = import_graph(A1)
         A2 = import_graph(A2)
@@ -204,18 +208,13 @@ def fit(self, A1, A2):
         null_distribution_1 = self._bootstrap(X_hats[0])
         null_distribution_2 = self._bootstrap(X_hats[1])
 
-        # Continuity correction - note that the +0.5 causes p > 1 sometimes # TODO
+        # uisng exact mc p-values (see, for example, Phipson and Smyth, 2010)
         p_value_1 = (
-            len(null_distribution_1[null_distribution_1 >= sample_T_statistic]) + 0.5
-        ) / self.n_bootstraps
+            len(null_distribution_1[null_distribution_1 >= sample_T_statistic]) + 1
+        ) / (self.n_bootstraps + 1)
         p_value_2 = (
-            len(null_distribution_2[null_distribution_2 >= sample_T_statistic]) + 0.5
-        ) / self.n_bootstraps
-
-        if p_value_1 < 1 / self.n_bootstraps:
-            p_value_1 = 1 / self.n_bootstraps
-        if p_value_2 < 1 / self.n_bootstraps:
-            p_value_2 = 1 / self.n_bootstraps
+            len(null_distribution_2[null_distribution_2 >= sample_T_statistic]) + 1
+        ) / (self.n_bootstraps + 1)
 
         p_value = max(p_value_1, p_value_2)
 
@@ -226,4 +225,4 @@ def fit(self, A1, A2):
         self.p_value_2_ = p_value_2
         self.p_value_ = p_value
 
-        return p_value
+        return self
diff --git a/tests/test_latentdistributiontest.py b/tests/test_latentdistributiontest.py
--- a/tests/test_latentdistributiontest.py
+++ b/tests/test_latentdistributiontest.py
@@ -20,11 +20,18 @@ def setUpClass(cls):
         cls.A1 = er_np(20, 0.3)
         cls.A2 = er_np(20, 0.3)
 
-    def test_fit_p_ase_works(self):
+    def test_fit_ase_works(self):
         for dist in self.dists:
             for test in self.tests:
                 ldt = LatentDistributionTest(test, dist, n_bootstraps=10)
-                p = ldt.fit(self.A1, self.A2)
+                assert ldt.fit(self.A1, self.A2) is ldt
+
+    def test_fit_predict_ase_works(self):
+        for dist in self.dists:
+            for test in self.tests:
+                ldt = LatentDistributionTest(test, dist, n_bootstraps=10)
+                p = ldt.fit_predict(self.A1, self.A2)
+                assert float(p) <= 1 and float(p) >= 0
 
     def test_workers(self):
         ldt = LatentDistributionTest("dcorr", "euclidean", n_bootstraps=4, workers=4)
@@ -77,8 +84,7 @@ def test_directed_inputs(self):
 
         ldt = LatentDistributionTest("dcorr")
         with self.assertRaises(NotImplementedError):
-            p = ldt.fit(A, B)
-        # self.assertTrue(p > 0.05)
+            ldt.fit(A, B)
 
     def test_SBM_euclidean(self):
         np.random.seed(12345678)
@@ -97,8 +103,8 @@ def test_SBM_euclidean(self):
             ldt_alt = LatentDistributionTest(
                 test, "euclidean", n_components=2, n_bootstraps=50
             )
-            p_null = ldt_null.fit(A1, A2)
-            p_alt = ldt_alt.fit(A1, A3)
+            p_null = ldt_null.fit_predict(A1, A2)
+            p_alt = ldt_alt.fit_predict(A1, A3)
             self.assertTrue(p_null > 0.05)
             self.assertTrue(p_alt <= 0.05)
 
@@ -119,8 +125,8 @@ def test_SBM_gaussian(self):
             ldt_alt = LatentDistributionTest(
                 test, "gaussian", n_components=2, n_bootstraps=50
             )
-            p_null = ldt_null.fit(A1, A2)
-            p_alt = ldt_alt.fit(A1, A3)
+            p_null = ldt_null.fit_predict(A1, A2)
+            p_alt = ldt_alt.fit_predict(A1, A3)
             self.assertTrue(p_null > 0.05)
             self.assertTrue(p_alt <= 0.05)
 
diff --git a/tests/test_latentpositiontest.py b/tests/test_latentpositiontest.py
--- a/tests/test_latentpositiontest.py
+++ b/tests/test_latentpositiontest.py
@@ -16,15 +16,18 @@ def setUpClass(cls):
         cls.A1 = er_np(20, 0.3)
         cls.A2 = er_np(20, 0.3)
 
-    def test_fit_p_ase_works(self):
+    def test_fit_ase_works(self):
         spt = LatentPositionTest()
-        p = spt.fit(self.A1, self.A2)
-        pass
+        assert spt.fit(self.A1, self.A2) is spt
 
-    def test_fit_p_omni_works(self):
+    def test_fit_omni_works(self):
         spt = LatentPositionTest(embedding="omnibus")
-        p = spt.fit(self.A1, self.A2)
-        pass
+        assert spt.fit(self.A1, self.A2) is spt
+
+    def test_fit_predict_ase_works(self):
+        spt = LatentPositionTest()
+        p = spt.fit_predict(self.A1, self.A2)
+        assert float(p) <= 1 and float(p) >= 0
 
     def test_bad_kwargs(self):
         with self.assertRaises(ValueError):
@@ -114,8 +117,8 @@ def test_SBM_epsilon(self):
 
         spt_null = LatentPositionTest(n_components=2, n_bootstraps=100)
         spt_alt = LatentPositionTest(n_components=2, n_bootstraps=100)
-        p_null = spt_null.fit(A1, A2)
-        p_alt = spt_alt.fit(A1, A3)
+        p_null = spt_null.fit_predict(A1, A2)
+        p_alt = spt_alt.fit_predict(A1, A3)
         self.assertTrue(p_null > 0.05)
         self.assertTrue(p_alt <= 0.05)