Repository: graspologic-org/graspologic

Issue:
UnmatchedTest tutorial should be on an unmatched example
I don't like how the exact same example I used for the MatchedTest tutorial was used for Unmatched also. We should:
- [ ] explain when one would use unmatched
- [ ] generate an example where the two graphs are not matched and use that for the tutorial
- [ ] make sure all plots have axes labels 



Discussion:
@bvarjavand 
once #222  is done we can do this
i can do this also when pr'ing size fix, if you want
@bdpedigo @bvarjavand 
That would be great!

On Mon, Apr 20, 2020 at 12:09 PM alyakin314 <notifications@github.com>
wrote:

> i can do this also when pr'ing size fix, if you want
> @bdpedigo <https://github.com/bdpedigo> @bvarjavand
> <https://github.com/bvarjavand>
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/neurodata/graspy/issues/177#issuecomment-616652748>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AGEF4H4MSG2AXUDWHOE5SK3RNRXRZANCNFSM4HOMR3XA>
> .
>

i feel like now it makes most sense to only do this after #375 ?

cc: @bdpedigo 
@alyakin314 either order makes sense to me, up to you if you wanna do it
@bdpedigo don't really mind delegating this to ndd as well (and would be happy to consult whoever will take this).
@kareef928 wanna do this one too? We finally have the tools to do so... I think you'd be able to do this issue and the other I tagged you in this semester
I'd be down to do this too
Dod: Modifications to the latent distribution test tutorial will be made so that the following will be included:

- An explanation of when to use the latent distribution test over the latent position test

- Examples of the latent distribution test being used on two unmatched graphs (m /= n) in the cases we expect the null hypothesis to be true and false - generate labeled figures of null distribution, test statistic, p-value for both cases

Resolving patch:
diff --git a/graspologic/inference/__init__.py b/graspologic/inference/__init__.py
--- a/graspologic/inference/__init__.py
+++ b/graspologic/inference/__init__.py
@@ -1,7 +1,7 @@
 # Copyright (c) Microsoft Corporation and contributors.
 # Licensed under the MIT License.
 
-from .latent_position_test import LatentPositionTest
-from .latent_distribution_test import LatentDistributionTest
+from .latent_position_test import latent_position_test
+from .latent_distribution_test import latent_distribution_test
 
-__all__ = ["LatentPositionTest", "LatentDistributionTest"]
+__all__ = ["latent_position_test", "latent_distribution_test"]
diff --git a/graspologic/inference/base.py b/graspologic/inference/base.py
deleted file mode 100644
--- a/graspologic/inference/base.py
+++ /dev/null
@@ -1,89 +0,0 @@
-# Copyright (c) Microsoft Corporation and contributors.
-# Licensed under the MIT License.
-
-import numpy as np
-from abc import abstractmethod
-from sklearn.base import BaseEstimator
-
-
-class BaseInference(BaseEstimator):
-    """
-    Base class for inference tasks such as semiparametric latent position test
-    and nonparametric latent distribution test.
-
-    Parameters
-    ----------
-    n_components : None (default), or int
-        Number of embedding dimensions. If None, the optimal embedding
-        dimensions are chosen via the Zhu and Godsi algorithm.
-    """
-
-    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 n_components is not None and n_components <= 0:
-            raise ValueError(
-                "Cannot embed into {} dimensions, must be greater than 0".format(
-                    n_components
-                )
-            )
-        self.n_components = n_components
-
-    @abstractmethod
-    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 the vertices in the first graph
-        X2_hat : array-like, shape(n_vertices, n_components)
-            Estimated latent positions of the vertices in the second graph
-        """
-
-    @abstractmethod
-    def fit(self, A1, A2):
-        """
-        Compute the test statistic and the null distribution.
-
-        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
-        ------
-        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_
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -52,7 +52,7 @@
         "gensim",
         "graspologic-native",
         "hyppo>=0.1.3",
-        "joblib>=0.17.0", # Older versions of joblib cause issue #806.  Transitive dependency of hyppo.
+        "joblib>=0.17.0",  # Older versions of joblib cause issue #806.  Transitive dependency of hyppo.
         "matplotlib>=3.0.0,<=3.3.0",
         "networkx>=2.1",
         "numpy>=1.8.1",


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
@@ -19,7 +19,7 @@
     "import matplotlib.pyplot as plt\n",
     "np.random.seed(8888)\n",
     "\n",
-    "from graspologic.inference import LatentDistributionTest\n",
+    "from graspologic.inference import latent_distribution_test\n",
     "from graspologic.embed import AdjacencySpectralEmbed\n",
     "from graspologic.simulations import sbm, rdpg\n",
     "from graspologic.utils import symmetrize\n",
@@ -57,7 +57,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "We generate a second SBM in the same way."
+    "We generate a second SBM with a different number of vertices in each block, but with the same probability matrix."
    ]
   },
   {
@@ -66,11 +66,10 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "A2 = sbm(csize, P)\n",
+    "csize_2 = [75] * 2\n",
+    "A2 = sbm(csize_2, P)\n",
     "X2 = AdjacencySpectralEmbed(n_components=n_components).fit_transform(A2)\n",
     "\n",
-    "A1 = sbm(csize, P)\n",
-    "X1 = AdjacencySpectralEmbed(n_components=n_components).fit_transform(A1)\n",
     "heatmap(A2, title='2-block SBM adjacency matrix')\n",
     "_ = pairplot(X2, title='2-block adjacency spectral embedding', height=4.5)"
    ]
@@ -82,11 +81,13 @@
     "### Latent distribution test where null is true\n",
     "We want to know whether the latent positions of the two graphs above were generated from the same latent distribution. In other words, we are testing\n",
     "\n",
-    "$$ H_0:F_{X_1} = F_{X_2} R$$$$ H_\\alpha: F_{X_1} \\neq F_{X_2} R $$ \n",
+    "$$ H_0:F_{X_1} = F_{X_2} Q$$$$ H_\\alpha: F_{X_1} \\neq F_{X_2} Q $$ \n",
     "\n",
-    "The $R$ is an orthogonal rotation matrix present due to the orthogonal non-identifiability in the random dot product graphs.\n",
+    "The $Q$ is an orthogonal rotation matrix present due to the orthogonal non-identifiability in the random dot product graphs.\n",
     "\n",
-    "We know that in this case the graphs were actually generated from the same distribution, so the test should reject no more often than the significance level $\\alpha$, and on average the $p$-value should be high (fail to reject the null)"
+    "We know that in this case the graphs were actually generated from the same distribution, so the test should reject no more often than the significance level $\\alpha$, and on average the $p$-value should be high (fail to reject the null).\n",
+    "\n",
+    "In this tutorial, we will use the latent distribution test on unmatched graphs. This means there is not an alignment between each node of the two graphs."
    ]
   },
   {
@@ -106,8 +107,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "ldt_dcorr = LatentDistributionTest(\"dcorr\", metric=\"euclidean\", n_bootstraps=100)\n",
-    "ldt_dcorr.fit(A1, A2)"
+    "ldt_dcorr = latent_distribution_test(A1, A2, test=\"dcorr\", metric=\"euclidean\", n_bootstraps=100)"
    ]
   },
   {
@@ -116,8 +116,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "ldt_mgc = LatentDistributionTest(\"mgc\", metric=\"euclidean\", n_bootstraps=100)\n",
-    "ldt_mgc.fit(A1, A2)"
+    "ldt_mgc = latent_distribution_test(A1, A2, test=\"mgc\", metric=\"euclidean\", n_bootstraps=100)"
    ]
   },
   {
@@ -128,7 +127,7 @@
    },
    "outputs": [],
    "source": [
-    "print(ldt_dcorr.p_value_, ldt_mgc.p_value_)"
+    "print(ldt_dcorr[0], ldt_mgc[0])"
    ]
   },
   {
@@ -138,13 +137,17 @@
    "outputs": [],
    "source": [
     "fig, ax = plt.subplots(1, 2, figsize=(10, 6))\n",
-    "ax[0].hist(ldt_dcorr.null_distribution_, 50)\n",
-    "ax[0].axvline(ldt_dcorr.sample_T_statistic_, color='r')\n",
-    "ax[0].set_title(\"DCorr: P-value = {}\".format(ldt_dcorr.p_value_), fontsize=20)\n",
-    "ax[1].hist(ldt_mgc.null_distribution_, 50)\n",
-    "ax[1].axvline(ldt_mgc.sample_T_statistic_, color='r')\n",
-    "ax[1].set_title(\"MGC: P-value = {}\".format(ldt_mgc.p_value_), fontsize=20)\n",
-    "_ = plt.show()"
+    "ax[0].hist(ldt_dcorr[2]['null_distribution'], 50)\n",
+    "ax[0].axvline(ldt_dcorr[1], color='r')\n",
+    "ax[0].set_title(\"DCorr: P-value = {}\".format(ldt_dcorr[0]), fontsize=20)\n",
+    "ax[0].set_xlabel(\"test static\")\n",
+    "ax[0].set_ylabel(\"frequency\")\n",
+    "ax[1].hist(ldt_mgc[2]['null_distribution'], 50)\n",
+    "ax[1].axvline(ldt_mgc[1], color='r')\n",
+    "ax[1].set_title(\"MGC: P-value = {}\".format(ldt_mgc[0]), fontsize=20)\n",
+    "ax[1].set_xlabel(\"test static\")\n",
+    "ax[1].set_ylabel(\"frequency\")\n",
+    "plt.show();"
    ]
   },
   {
@@ -172,9 +175,13 @@
     "P2 = np.array([[0.9, 0.4],\n",
     "               [0.4, 0.9]])\n",
     "\n",
-    "A3 = sbm(csize, P2)\n",
-    "heatmap(A3, title='2-block SBM adjacency matrix A3')\n",
+    "A3 = sbm(csize_2, P2)\n",
+    "\n",
+    "X1 = AdjacencySpectralEmbed(n_components=n_components).fit_transform(A1)\n",
     "X3 = AdjacencySpectralEmbed(n_components=n_components).fit_transform(A3)\n",
+    "heatmap(A1, title='2-block SBM adjacency matrix A1')\n",
+    "heatmap(A3, title='2-block SBM adjacency matrix A3')\n",
+    "pairplot(X1, title='2-block adjacency spectral embedding A1', height=4.5)\n",
     "_ = pairplot(X3, title='2-block adjacency spectral embedding A3', height=4.5)"
    ]
   },
@@ -193,8 +200,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "ldt_dcorr = LatentDistributionTest(\"dcorr\", metric=\"euclidean\", n_bootstraps=100)\n",
-    "ldt_dcorr.fit(A1, A3)"
+    "ldt_dcorr = latent_distribution_test(A1, A3, test=\"dcorr\", metric=\"euclidean\", n_bootstraps=100)"
    ]
   },
   {
@@ -203,8 +209,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "ldt_mgc = LatentDistributionTest(\"mgc\", metric=\"euclidean\", n_bootstraps=100)\n",
-    "ldt_mgc.fit(A1, A3)"
+    "ldt_mgc = latent_distribution_test(A1, A3, test=\"mgc\", metric=\"euclidean\", n_bootstraps=100)"
    ]
   },
   {
@@ -215,7 +220,7 @@
    },
    "outputs": [],
    "source": [
-    "print(ldt_dcorr.p_value_, ldt_mgc.p_value_)"
+    "print(ldt_dcorr[0], ldt_mgc[0])"
    ]
   },
   {
@@ -225,13 +230,17 @@
    "outputs": [],
    "source": [
     "fig, ax = plt.subplots(1, 2, figsize=(10, 6))\n",
-    "ax[0].hist(ldt_dcorr.null_distribution_, 50)\n",
-    "ax[0].axvline(ldt_dcorr.sample_T_statistic_, color='r')\n",
-    "ax[0].set_title(\"DCorr: P-value = {}\".format(ldt_dcorr.p_value_), fontsize=20)\n",
-    "ax[1].hist(ldt_mgc.null_distribution_, 50)\n",
-    "ax[1].axvline(ldt_mgc.sample_T_statistic_, color='r')\n",
-    "ax[1].set_title(\"MGC: P-value = {}\".format(ldt_mgc.p_value_), fontsize=20)\n",
-    "_ = plt.show()"
+    "ax[0].hist(ldt_dcorr[2]['null_distribution'], 50)\n",
+    "ax[0].axvline(ldt_dcorr[1], color='r')\n",
+    "ax[0].set_title(\"DCorr: P-value = {}\".format(ldt_dcorr[0]), fontsize=20)\n",
+    "ax[0].set_xlabel(\"test static\")\n",
+    "ax[0].set_ylabel(\"frequency\")\n",
+    "ax[1].hist(ldt_mgc[2]['null_distribution'], 50)\n",
+    "ax[1].axvline(ldt_mgc[1], color='r')\n",
+    "ax[1].set_title(\"MGC: P-value = {}\".format(ldt_mgc[0]), fontsize=20)\n",
+    "ax[1].set_xlabel(\"test static\")\n",
+    "ax[1].set_ylabel(\"frequency\")\n",
+    "plt.show();"
    ]
   }
  ],
@@ -251,7 +260,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.7.0"
+   "version": "3.8.4"
   }
  },
  "nbformat": 4,
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
@@ -16,7 +16,7 @@
     "import numpy as np\n",
     "np.random.seed(88889999)\n",
     "\n",
-    "from graspologic.inference import LatentPositionTest\n",
+    "from graspologic.inference import latent_position_test\n",
     "from graspologic.embed import AdjacencySpectralEmbed\n",
     "from graspologic.simulations import sbm, rdpg\n",
     "from graspologic.utils import symmetrize\n",
@@ -57,7 +57,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "In the adjacency matrix above, there is a clearly defined block structrure corresponding to the 4 communities in the graph that we established. On the right, we see the **adjacency spectral embedding (ASE)** of this graph. $\\text{ASE}(A)$ recovers an estimate of the **latent positions** of $A$. Latent positions refer to the idea of a **random dot product graph (RDPG)** which can be modeled as follows:\n",
+    "In the adjacency matrix above, there is a clearly defined block structrure corresponding to the 4 communities in the graph that we established. Below it, we see the **adjacency spectral embedding (ASE)** of this graph. $\\text{ASE}(A)$ recovers an estimate of the **latent positions** of $A$. Latent positions refer to the idea of a **random dot product graph (RDPG)** which can be modeled as follows:\n",
     "\n",
     "For an adjacency matrix $A \\in \\mathbb{R}^{n \\times n}$, the probability of an edge existing between node $i$ and node $j$ (aka whether or not $A_{ij}$ is a 1) is determined by the matrix $P \\in \\mathbb{R}^{n \\times n}$\n",
     "\n",
@@ -112,7 +112,7 @@
    "metadata": {},
    "source": [
     "## Latent position test where null is true\n",
-    "Now, we want to know whether the above two graphs were generated from the same latent position. We know that they were, so the test should predict that the differences between Sampled RDPG 1 and 2 (up to a rotation, see below) are no greater than those differences observed by chance. In this case, we will use the `LatentPositionTest` in `graspologic` because we know the true alignment between the vertices of the two graphs we are testing. In other words, node $i$ in graph 1 can be thought of as equivalent to node $i$ in graph 2 because of the way we generated these graphs. \n",
+    "Now, we want to know whether the above two graphs were generated from the same latent position. We know that they were, so the test should predict that the differences between Sampled RDPG 1 and 2 (up to a rotation, see below) are no greater than those differences observed by chance. In this case, we will use the `latent_position_test` in `graspologic` because we know the true alignment between the vertices of the two graphs we are testing. In other words, node $i$ in graph 1 can be thought of as equivalent to node $i$ in graph 2 because of the way we generated these graphs. \n",
     "\n",
     "In other words, we are testing $$ H_0: X_1 = X_2 R$$$$ H_a: X_1 \\neq X_2 R$$\n",
     "\n",
@@ -120,7 +120,7 @@
     "\n",
     "Here, R is an orthogonal rotation matrix found from solving the [orthogonal procrustes problem](https://docs.scipy.org/doc/scipy-0.18.1/reference/generated/scipy.linalg.orthogonal_procrustes.html) (Note: this constraint can be relaxed for other versions of semipar)\n",
     "\n",
-    "Note that ``LatentPositionTest.fit()`` may take several minutes"
+    "Note that latent_position_test() may take several minutes"
    ]
   },
   {
@@ -130,9 +130,8 @@
    "outputs": [],
    "source": [
     "# note: may want to use more bootstraps in practice, low number used here is for speed\n",
-    "lpt = LatentPositionTest(n_bootstraps=100, n_components=n_components)\n",
-    "lpt.fit(A1, A2)\n",
-    "print('p = {}'.format(lpt.p_value_))"
+    "lpt = latent_position_test(A1, A2, n_bootstraps=100, n_components=n_components)\n",
+    "print('p = {}'.format(lpt[0]))"
    ]
   },
   {
@@ -187,9 +186,8 @@
    "outputs": [],
    "source": [
     "# note: may want to use more bootstraps in practice, low number used here is for speed\n",
-    "lpt = LatentPositionTest(n_bootstraps=100, n_components=n_components)\n",
-    "lpt.fit(A3, A4)\n",
-    "print('p = {}'.format(lpt.p_value_))"
+    "lpt = latent_position_test(A3, A4, n_bootstraps=100, n_components=n_components)\n",
+    "print('p = {}'.format(lpt[0]))"
    ]
   }
  ],
@@ -209,7 +207,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.7.0"
+   "version": "3.8.4"
   }
  },
  "nbformat": 4,
diff --git a/graspologic/inference/latent_distribution_test.py b/graspologic/inference/latent_distribution_test.py
--- a/graspologic/inference/latent_distribution_test.py
+++ b/graspologic/inference/latent_distribution_test.py
@@ -10,7 +10,6 @@
 from ..utils import import_graph, fit_plug_in_variance_estimator
 from ..align import SignFlips
 from ..align import SeedlessProcrustes
-from .base import BaseInference
 from sklearn.utils import check_array
 from sklearn.metrics import pairwise_distances
 from sklearn.metrics.pairwise import pairwise_kernels
@@ -18,6 +17,7 @@
 from sklearn.metrics.pairwise import PAIRWISE_KERNEL_FUNCTIONS
 from hyppo.ksample import KSample
 from hyppo._utils import gaussian
+from collections import namedtuple
 
 _VALID_DISTANCES = list(PAIRED_DISTANCES.keys())
 _VALID_KERNELS = list(PAIRWISE_KERNEL_FUNCTIONS.keys())
@@ -26,8 +26,23 @@
 
 _VALID_TESTS = ["cca", "dcorr", "hhg", "rv", "hsic", "mgc"]
 
-
-class LatentDistributionTest(BaseInference):
+ldt_result = namedtuple("ldt_result", ("p_value", "sample_T_statistic", "misc_stats"))
+
+
+def latent_distribution_test(
+    A1,
+    A2,
+    test="dcorr",
+    metric="euclidean",
+    n_components=None,
+    n_bootstraps=500,
+    workers=1,
+    size_correction=True,
+    pooled=False,
+    align_type="sign_flips",
+    align_kws={},
+    input_graph=True,
+):
     """Two-sample hypothesis test for the problem of determining whether two random
     dot product graphs have the same distributions of latent positions.
 
@@ -39,6 +54,10 @@ class LatentDistributionTest(BaseInference):
 
     Parameters
     ----------
+    A1, A2 : variable (see description of 'input_graph')
+        The two graphs, or their embeddings to run a hypothesis test on.
+        Expected variable type and shape depends on input_graph attribute
+
     test : str (default="hsic")
         Backend hypothesis test to use, one of ["cca", "dcorr", "hhg", "rv", "hsic", "mgc"].
         These tests are typically used for independence testing, but here they
@@ -133,32 +152,34 @@ class LatentDistributionTest(BaseInference):
         Flag whether to expect two full graphs, or the embeddings.
 
         - True
-            :func:`~graspologic.inference.LatentDistributionTest.fit` and
-            :func:`~graspologic.inference.LatentDistributionTest.fit_predict`
-            expect graphs, either as NetworkX graph objects or as adjacency matrices,
-            provided as ndarrays of size (n, n) and (m, m).
-            They will be embedded using adjacency spectral embeddings.
+            This function expects graphs, either as NetworkX graph objects
+            or as adjacency matrices, provided as ndarrays of size (n, n) and
+            (m, m). They will be embedded using adjacency spectral embeddings.
         - False
-            :func:`~graspologic.inference.LatentDistributionTest.fit` and
-            :func:`~graspologic.inference.LatentDistributionTest.fit_predict`
-            expect adjacency spectral embeddings of the graphs,
+            This function expects adjacency spectral embeddings of the graphs,
             they must be ndarrays of size (n, d) and (m, d), where
             d must be same. n_components attribute is ignored in this case.
 
-    Attributes
+    Returns
     ----------
-    metric_func_ : callable
-        A callable associated with the specified metric. See ``metric``.
-
-    null_distribution_ : ndarray, shape (n_bootstraps, )
-        The distribution of T statistics generated under the null.
+    p_value : float
+        The overall p value from the test.
 
-    sample_T_statistic_ : float
+    sample_T_statistic : float
         The observed difference between the embedded latent positions of the
         two input graphs.
 
-    p_value_ : float
-        The overall p value from the test.
+    misc_stats : dictionary
+        A collection of other statistics obtained from the latent position test
+
+        - null_distribution : ndarray, shape (n_bootstraps,)
+            The distribution of T statistics generated under the null.
+
+        - n_components : int
+            Number of embedding dimensions.
+
+        - Q : array, size (d, d)
+            Final orthogonal matrix, used to modify ``X``.
 
     References
     ----------
@@ -176,317 +197,241 @@ class LatentDistributionTest(BaseInference):
 
     """
 
-    def __init__(
-        self,
-        test="dcorr",
-        metric="euclidean",
-        n_components=None,
-        n_bootstraps=200,
-        workers=1,
-        size_correction=True,
-        pooled=False,
-        align_type="sign_flips",
-        align_kws={},
-        input_graph=True,
-    ):
-        # check test argument
-        if not isinstance(test, str):
-            msg = "test must be a str, not {}".format(type(test))
-            raise TypeError(msg)
-        elif test not in _VALID_TESTS:
-            msg = "Unknown test {}. Valid tests are {}".format(test, _VALID_TESTS)
-            raise ValueError(msg)
-        # metric argument is checked when metric_func_ is instantiated
-        # check n_components argument
-        if n_components is not None:
-            if not isinstance(n_components, int):
-                msg = "n_components must be an int, not {}.".format(type(n_components))
-                raise TypeError(msg)
-        # check n_bootstraps argument
-        if not isinstance(n_bootstraps, int):
-            msg = "n_bootstraps must be an int, not {}".format(type(n_bootstraps))
-            raise TypeError(msg)
-        elif n_bootstraps < 0:
-            msg = "{} is invalid number of bootstraps, must be non-negative"
-            raise ValueError(msg.format(n_bootstraps))
-        # check workers argument
-        if not isinstance(workers, int):
-            msg = "workers must be an int, not {}".format(type(workers))
+    # check test argument
+    if not isinstance(test, str):
+        msg = "test must be a str, not {}".format(type(test))
+        raise TypeError(msg)
+    elif test not in _VALID_TESTS:
+        msg = "Unknown test {}. Valid tests are {}".format(test, _VALID_TESTS)
+        raise ValueError(msg)
+    # metric argument is checked when metric_func_ is instantiated
+    # check n_components argument
+    if n_components is not None:
+        if not isinstance(n_components, int):
+            msg = "n_components must be an int, not {}.".format(type(n_components))
             raise TypeError(msg)
-        # check size_correction argument
-        if not isinstance(size_correction, bool):
-            msg = "size_correction must be a bool, not {}".format(type(size_correction))
-            raise TypeError(msg)
-        # check pooled argument
-        if not isinstance(pooled, bool):
-            msg = "pooled must be a bool, not {}".format(type(pooled))
-            raise TypeError(msg)
-        # check align_type argument
-        if (not isinstance(align_type, str)) and (align_type is not None):
-            msg = "align_type must be a string or None, not {}".format(type(align_type))
-            raise TypeError(msg)
-        align_types_supported = ["sign_flips", "seedless_procrustes", None]
-        if align_type not in align_types_supported:
-            msg = "supported align types are {}".format(align_types_supported)
-            raise ValueError(msg)
-        # check align_kws argument
-        if not isinstance(align_kws, dict):
-            msg = "align_kws must be a dictionary of keyword arguments, not {}".format(
-                type(align_kws)
+    # check n_bootstraps argument
+    if not isinstance(n_bootstraps, int):
+        msg = "n_bootstraps must be an int, not {}".format(type(n_bootstraps))
+        raise TypeError(msg)
+    elif n_bootstraps < 0:
+        msg = "{} is invalid number of bootstraps, must be non-negative"
+        raise ValueError(msg.format(n_bootstraps))
+    # check workers argument
+    if not isinstance(workers, int):
+        msg = "workers must be an int, not {}".format(type(workers))
+        raise TypeError(msg)
+    # check size_correction argument
+    if not isinstance(size_correction, bool):
+        msg = "size_correction must be a bool, not {}".format(type(size_correction))
+        raise TypeError(msg)
+    # check pooled argument
+    if not isinstance(pooled, bool):
+        msg = "pooled must be a bool, not {}".format(type(pooled))
+        raise TypeError(msg)
+    # check align_type argument
+    if (not isinstance(align_type, str)) and (align_type is not None):
+        msg = "align_type must be a string or None, not {}".format(type(align_type))
+        raise TypeError(msg)
+    align_types_supported = ["sign_flips", "seedless_procrustes", None]
+    if align_type not in align_types_supported:
+        msg = "supported align types are {}".format(align_types_supported)
+        raise ValueError(msg)
+    # check align_kws argument
+    if not isinstance(align_kws, dict):
+        msg = "align_kws must be a dictionary of keyword arguments, not {}".format(
+            type(align_kws)
+        )
+        raise TypeError(msg)
+    # check input_graph argument
+    if not isinstance(input_graph, bool):
+        msg = "input_graph must be a bool, not {}".format(type(input_graph))
+        raise TypeError(msg)
+
+    if input_graph:
+        A1 = import_graph(A1)
+        A2 = import_graph(A2)
+
+        X1_hat, X2_hat = _embed(A1, A2, n_components)
+    else:
+        # check for nx objects, since they are castable to arrays,
+        # but we don't want that
+        if not isinstance(A1, np.ndarray):
+            msg = (
+                f"Embedding of the first graph is of type {type(A1)}, not "
+                "np.ndarray. If input_graph is False, the inputs need to be "
+                "adjacency spectral embeddings, with shapes (n, d) and "
+                "(m, d), passed as np.ndarrays."
             )
             raise TypeError(msg)
-        # check input_graph argument
-        if not isinstance(input_graph, bool):
-            msg = "input_graph must be a bool, not {}".format(type(input_graph))
+        if not isinstance(A2, np.ndarray):
+            msg = (
+                f"Embedding of the second graph is of type {type(A2)}, not an "
+                "array. If input_graph is False, the inputs need to be "
+                "adjacency spectral embeddings, with shapes (n, d) and "
+                "(m, d), passed as np.ndarrays."
+            )
             raise TypeError(msg)
 
-        super().__init__(n_components=n_components)
-
-        self.test = test
-        self.metric = metric
-        self.metric_func_ = self._instantiate_metric_func(metric, test)
-        self.n_bootstraps = n_bootstraps
-        self.workers = workers
-        self.size_correction = size_correction
-        self.pooled = pooled
-        self.input_graph = input_graph
-        self.align_type = align_type
-        self.align_kws = align_kws
-
-    def _instantiate_metric_func(self, metric, test):
-        # check metric argument
-        if not isinstance(metric, str) and not callable(metric):
-            msg = "Metric must be str or callable, not {}".format(type(metric))
-            raise TypeError(msg)
-        elif metric not in _VALID_METRICS and not callable(metric):
-            msg = "Unknown metric {}. Valid metrics are {}, or a callable".format(
-                metric, _VALID_METRICS
+        if A1.ndim != 2:
+            msg = (
+                "Embedding array of the first graph does not have two dimensions. "
+                "If input_graph is False, the inputs need to be adjacency "
+                "spectral embeddings, with shapes (n, d) and (m, d)"
             )
             raise ValueError(msg)
-        if callable(metric):
-            metric_func = metric
-        else:
-            if metric in _VALID_DISTANCES:
-                if test == "hsic":
-                    msg = (
-                        f"{test} is a kernel-based test, but {metric} "
-                        "is a distance. results may not be optimal. it is "
-                        "recomended to use either a different test or one of "
-                        f"the kernels: {_VALID_KERNELS} as a metric."
-                    )
-                    warnings.warn(msg, UserWarning)
-
-                def metric_func(X, Y=None, metric=metric, workers=None):
-                    return pairwise_distances(X, Y, metric=metric, n_jobs=workers)
-
-            elif metric == "gaussian":
-                if test != "hsic":
-                    msg = (
-                        f"{test} is a distance-based test, but {metric} "
-                        "is a kernel. results may not be optimal. it is "
-                        "recomended to use either a hisc as a test or one of "
-                        f"the distances: {_VALID_DISTANCES} as a metric."
-                    )
-                    warnings.warn(msg, UserWarning)
-                metric_func = gaussian
-            else:
-                if test != "hsic":
-                    msg = (
-                        f"{test} is a distance-based test, but {metric} "
-                        "is a kernel. results may not be optimal. it is "
-                        "recomended to use either a hisc as a test or one of "
-                        f"the distances: {_VALID_DISTANCES} as a metric."
-                    )
-                    warnings.warn(msg, UserWarning)
-
-                def metric_func(X, Y=None, metric=metric, workers=None):
-                    return pairwise_kernels(X, Y, metric=metric, n_jobs=workers)
-
-        return metric_func
-
-    def _embed(self, A1, A2):
-        if self.n_components is None:
-            num_dims1 = select_dimension(A1)[0][-1]
-            num_dims2 = select_dimension(A2)[0][-1]
-            self.n_components = max(num_dims1, num_dims2)
-
-        ase = AdjacencySpectralEmbed(n_components=self.n_components)
-        X1_hat = ase.fit_transform(A1)
-        X2_hat = ase.fit_transform(A2)
-
-        if isinstance(X1_hat, tuple) and isinstance(X2_hat, tuple):
-            X1_hat = np.concatenate(X1_hat, axis=-1)
-            X2_hat = np.concatenate(X2_hat, axis=-1)
-        elif isinstance(X1_hat, tuple) ^ isinstance(X2_hat, tuple):
+        if A2.ndim != 2:
             msg = (
-                "input graphs do not have same directedness. "
-                "consider symmetrizing the directed graph."
+                "Embedding array of the second graph does not have two dimensions. "
+                "If input_graph is False, the inputs need to be adjacency "
+                "spectral embeddings, with shapes (n, d) and (m, d)"
+            )
+            raise ValueError(msg)
+        if A1.shape[1] != A2.shape[1]:
+            msg = (
+                "Two input embeddings have different number of components. "
+                "If input_graph is False, the inputs need to be adjacency "
+                "spectral embeddings, with shapes (n, d) and (m, d)"
             )
             raise ValueError(msg)
 
-        return X1_hat, X2_hat
-
-    def _sample_modified_ase(self, X, Y, pooled=False):
-        N, M = len(X), len(Y)
-
-        # return if graphs are same order, else else ensure X the larger graph.
-        if N == M:
-            return X, Y
-        elif M > N:
-            reverse_order = True
-            X, Y = Y, X
-            N, M = M, N
-        else:
-            reverse_order = False
-
-        # estimate the central limit theorem variance
-        if pooled:
-            two_samples = np.concatenate([X, Y], axis=0)
-            get_sigma = fit_plug_in_variance_estimator(two_samples)
-        else:
-            get_sigma = fit_plug_in_variance_estimator(X)
-        X_sigmas = get_sigma(X) * (N - M) / (N * M)
-
-        # increase the variance of X by sampling from the asy dist
-        X_sampled = np.zeros(X.shape)
-        # TODO may be parallelized, but requires keeping track of random state
-        for i in range(N):
-            X_sampled[i, :] = X[i, :] + stats.multivariate_normal.rvs(cov=X_sigmas[i])
-
-        # return the embeddings in the appropriate order
-        return (Y, X_sampled) if reverse_order else (X_sampled, Y)
-
-    def fit(self, A1, A2):
-        """
-        Fits the test to the two input graphs
-
-        Parameters
-        ----------
-        A1, A2 : variable (see description)
-            The two graphs, or their embeddings to run a hypothesis test on.
-            Expected variable type and shape depends on input_graph attribute:
-
-            - input_graph=True
-                expects two unembedded graphs either as NetworkX graph objects, or as
-                two np.ndarrays, representing the adjacency matrices. In this
-                case will be embedded using adjacency spectral embedding.
-            - input_graph-False
-                expects two already embedded graphs. In this case they must be
-                arrays of shape (n, d) and (m, d), where d, the number of
-                components, must be shared.
-
-            Note that regardless of how the graphs are passed, they need not
-            have the same number of vertices.
-
-        Returns
-        -------
-        self
-
-        """
-        if self.input_graph:
-            A1 = import_graph(A1)
-            A2 = import_graph(A2)
-
-            X1_hat, X2_hat = self._embed(A1, A2)
-        else:
-            # check for nx objects, since they are castable to arrays,
-            # but we don't want that
-            if not isinstance(A1, np.ndarray):
-                msg = (
-                    f"Embedding of the first graph is of type {type(A1)}, not "
-                    "np.ndarray. If input_graph is False, the inputs need to be "
-                    "adjacency spectral embeddings, with shapes (n, d) and "
-                    "(m, d), passed as np.ndarrays."
-                )
-                raise TypeError(msg)
-            if not isinstance(A2, np.ndarray):
+        # checking for inf values
+        X1_hat = check_array(A1)
+        X2_hat = check_array(A2)
+
+    if align_type == "sign_flips":
+        aligner = SignFlips(**align_kws)
+        X1_hat = aligner.fit_transform(X1_hat, X2_hat)
+        Q = aligner.Q_
+    elif align_type == "seedless_procrustes":
+        aligner = SeedlessProcrustes(**align_kws)
+        X1_hat = aligner.fit_transform(X1_hat, X2_hat)
+        Q = aligner.Q_
+    else:
+        Q = np.identity(X1_hat.shape[0])
+
+    if size_correction:
+        X1_hat, X2_hat = _sample_modified_ase(X1_hat, X2_hat, pooled=pooled)
+
+    metric_func_ = _instantiate_metric_func(metric=metric, test=test)
+    test_obj = KSample(test, compute_distance=metric_func_)
+
+    data = test_obj.test(X1_hat, X2_hat, reps=n_bootstraps, workers=workers, auto=False)
+
+    null_distribution = test_obj.indep_test.null_dist
+
+    misc_stats = {
+        "null_distribution": null_distribution,
+        "n_components": n_components,
+        "Q": Q,
+    }
+    sample_T_statistic = data[0]
+    p_value = data[1]
+
+    return ldt_result(p_value, sample_T_statistic, misc_stats)
+
+
+def _instantiate_metric_func(metric, test):
+    # check metric argument
+    if not isinstance(metric, str) and not callable(metric):
+        msg = "Metric must be str or callable, not {}".format(type(metric))
+        raise TypeError(msg)
+    elif metric not in _VALID_METRICS and not callable(metric):
+        msg = "Unknown metric {}. Valid metrics are {}, or a callable".format(
+            metric, _VALID_METRICS
+        )
+        raise ValueError(msg)
+    if callable(metric):
+        metric_func = metric
+    else:
+        if metric in _VALID_DISTANCES:
+            if test == "hsic":
                 msg = (
-                    f"Embedding of the second graph is of type {type(A2)}, not an "
-                    "array. If input_graph is False, the inputs need to be "
-                    "adjacency spectral embeddings, with shapes (n, d) and "
-                    "(m, d), passed as np.ndarrays."
+                    f"{test} is a kernel-based test, but {metric} "
+                    "is a distance. results may not be optimal. it is "
+                    "recomended to use either a different test or one of "
+                    f"the kernels: {_VALID_KERNELS} as a metric."
                 )
-                raise TypeError(msg)
+                warnings.warn(msg, UserWarning)
 
-            if A1.ndim != 2:
-                msg = (
-                    "Embedding array of the first graph does not have two dimensions. "
-                    "If input_graph is False, the inputs need to be adjacency "
-                    "spectral embeddings, with shapes (n, d) and (m, d)"
-                )
-                raise ValueError(msg)
-            if A2.ndim != 2:
+            def metric_func(X, Y=None, metric=metric, workers=None):
+                return pairwise_distances(X, Y, metric=metric, n_jobs=workers)
+
+        elif metric == "gaussian":
+            if test != "hsic":
                 msg = (
-                    "Embedding array of the second graph does not have two dimensions. "
-                    "If input_graph is False, the inputs need to be adjacency "
-                    "spectral embeddings, with shapes (n, d) and (m, d)"
+                    f"{test} is a distance-based test, but {metric} "
+                    "is a kernel. results may not be optimal. it is "
+                    "recomended to use either a hisc as a test or one of "
+                    f"the distances: {_VALID_DISTANCES} as a metric."
                 )
-                raise ValueError(msg)
-            if A1.shape[1] != A2.shape[1]:
+                warnings.warn(msg, UserWarning)
+            metric_func = gaussian
+        else:
+            if test != "hsic":
                 msg = (
-                    "Two input embeddings have different number of components. "
-                    "If input_graph is False, the inputs need to be adjacency "
-                    "spectral embeddings, with shapes (n, d) and (m, d)"
+                    f"{test} is a distance-based test, but {metric} "
+                    "is a kernel. results may not be optimal. it is "
+                    "recomended to use either a hisc as a test or one of "
+                    f"the distances: {_VALID_DISTANCES} as a metric."
                 )
-                raise ValueError(msg)
-
-            # checking for inf values
-            X1_hat = check_array(A1)
-            X2_hat = check_array(A2)
-
-        if self.align_type == "sign_flips":
-            aligner = SignFlips(**self.align_kws)
-            X1_hat = aligner.fit_transform(X1_hat, X2_hat)
-        elif self.align_type == "seedless_procrustes":
-            aligner = SeedlessProcrustes(**self.align_kws)
-            X1_hat = aligner.fit_transform(X1_hat, X2_hat)
-
-        if self.size_correction:
-            X1_hat, X2_hat = self._sample_modified_ase(
-                X1_hat, X2_hat, pooled=self.pooled
-            )
+                warnings.warn(msg, UserWarning)
 
-        self.metric_func_ = self._instantiate_metric_func(self.metric, self.test)
-        test_obj = KSample(self.test, compute_distance=self.metric_func_)
+            def metric_func(X, Y=None, metric=metric, workers=None):
+                return pairwise_kernels(X, Y, metric=metric, n_jobs=workers)
 
-        data = test_obj.test(
-            X1_hat, X2_hat, reps=self.n_bootstraps, workers=self.workers, auto=False
-        )
+    return metric_func
+
+
+def _embed(A1, A2, n_components):
+    if n_components is None:
+        num_dims1 = select_dimension(A1)[0][-1]
+        num_dims2 = select_dimension(A2)[0][-1]
+        n_components = max(num_dims1, num_dims2)
 
-        self.null_distribution_ = test_obj.indep_test.null_dist
-        self.sample_T_statistic_ = data[0]
-        self.p_value_ = data[1]
-
-        return self
-
-    def fit_predict(self, A1, A2):
-        """
-        Fits the test to the two input graphs and returns the p-value
-
-        Parameters
-        ----------
-        A1, A2 : variable (see description)
-            The two graphs, or their embeddings to run a hypothesis test on.
-            Expected variable type and shape depends on input_graph attribute:
-
-            - input_graph=True
-                expects two unembedded graphs either as NetworkX graph objects, or as
-                two np.ndarrays, representing the adjacency matrices. In this
-                case will be embedded using adjacency spectral embedding.
-            - input_graph-False
-                expects two already embedded graphs. In this case they must be
-                arrays of shape (n, d) and (m, d), where d, the number of
-                components, must be shared.
-
-            Note that regardless of how the graphs are passed, they need not to
-            have the same number of vertices.
-
-
-        Returns
-        -------
-        p_value_ : float
-            The overall p value from the test
-        """
-        # abstract method overwritten in order to have a custom doc string
-        self.fit(A1, A2)
-        return self.p_value_
+    ase = AdjacencySpectralEmbed(n_components=n_components)
+    X1_hat = ase.fit_transform(A1)
+    X2_hat = ase.fit_transform(A2)
+
+    if isinstance(X1_hat, tuple) and isinstance(X2_hat, tuple):
+        X1_hat = np.concatenate(X1_hat, axis=-1)
+        X2_hat = np.concatenate(X2_hat, axis=-1)
+    elif isinstance(X1_hat, tuple) ^ isinstance(X2_hat, tuple):
+        msg = (
+            "input graphs do not have same directedness. "
+            "consider symmetrizing the directed graph."
+        )
+        raise ValueError(msg)
+
+    return X1_hat, X2_hat
+
+
+def _sample_modified_ase(X, Y, pooled=False):
+    N, M = len(X), len(Y)
+
+    # return if graphs are same order, else ensure X the larger graph.
+    if N == M:
+        return X, Y
+    elif M > N:
+        reverse_order = True
+        X, Y = Y, X
+        N, M = M, N
+    else:
+        reverse_order = False
+
+    # estimate the central limit theorem variance
+    if pooled:
+        two_samples = np.concatenate([X, Y], axis=0)
+        get_sigma = fit_plug_in_variance_estimator(two_samples)
+    else:
+        get_sigma = fit_plug_in_variance_estimator(X)
+    X_sigmas = get_sigma(X) * (N - M) / (N * M)
+
+    # increase the variance of X by sampling from the asy dist
+    X_sampled = np.zeros(X.shape)
+    # TODO may be parallelized, but requires keeping track of random state
+    for i in range(N):
+        X_sampled[i, :] = X[i, :] + stats.multivariate_normal.rvs(cov=X_sigmas[i])
+
+    # return the embeddings in the appropriate order
+    return (Y, X_sampled) if reverse_order else (X_sampled, Y)
diff --git a/graspologic/inference/latent_position_test.py b/graspologic/inference/latent_position_test.py
--- a/graspologic/inference/latent_position_test.py
+++ b/graspologic/inference/latent_position_test.py
@@ -8,10 +8,14 @@
 from ..embed import AdjacencySpectralEmbed, OmnibusEmbed, select_dimension
 from ..simulations import rdpg
 from ..utils import import_graph, is_symmetric
-from .base import BaseInference
+from collections import namedtuple
 
+lpt_result = namedtuple("lpt_result", ("p_value", "sample_T_statistic", "misc_stats"))
 
-class LatentPositionTest(BaseInference):
+
+def latent_position_test(
+    A1, A2, embedding="ase", n_components=None, test_case="rotation", n_bootstraps=500
+):
     r"""
     Two-sample hypothesis test for the problem of determining whether two random
     dot product graphs have the same latent positions.
@@ -25,6 +29,11 @@ class LatentPositionTest(BaseInference):
 
     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
+
     embedding : string, { 'ase' (default), 'omnibus'}
         String describing the embedding method to use:
 
@@ -58,23 +67,26 @@ class LatentPositionTest(BaseInference):
     n_bootstraps : int, optional (default 500)
         Number of bootstrap simulations to run to generate the null distribution
 
-    Attributes
+    Returns
     ----------
-    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
-        distributions are generated
+    p_value : float
+        The overall p value from the test; this is the max of 'p_value_1' and 'p_value_2'
 
-    sample_T_statistic_ : float
+    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.
+    misc_stats : dictionary
+        A collection of other statistics obtained from the latent position test
+
+        - 'p_value_1', 'p_value_2' : float
+            The p value estimate from the null distributions from sample 1 and sample 2
 
-    p_value_ : float
-        The overall p value from the test; this is the max of :attr:`p_value_1_` and :attr:`p_value_2_`
+        - '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
+            distributions are generated
 
     See also
     --------
@@ -89,129 +101,113 @@ class LatentPositionTest(BaseInference):
        Journal of Computational and Graphical Statistics, Vol. 26(2), 2017
     """
 
-    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:
-            raise TypeError()
-        if n_bootstraps < 1:
-            raise ValueError(
-                "{} is invalid number of bootstraps, must be greater than 1".format(
-                    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__(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
-        self.rescale = False
-        self.loops = False
-
-    def _bootstrap(self, X_hat):
-        t_bootstrap = np.zeros(self.n_bootstraps)
-        for i in range(self.n_bootstraps):
-            A1_simulated = rdpg(X_hat, rescale=self.rescale, loops=self.loops)
-            A2_simulated = rdpg(X_hat, rescale=self.rescale, loops=self.loops)
-            X1_hat_simulated, X2_hat_simulated = self._embed(
-                A1_simulated, A2_simulated, check_lcc=False
+    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:
+        raise TypeError()
+    if n_bootstraps < 1:
+        raise ValueError(
+            "{} is invalid number of bootstraps, must be greater than 1".format(
+                n_bootstraps
             )
-            t_bootstrap[i] = self._difference_norm(X1_hat_simulated, X2_hat_simulated)
-        return t_bootstrap
-
-    def _difference_norm(self, X1, X2):
-        if self.embedding in ["ase"]:
-            if self.test_case == "rotation":
-                pass
-            elif self.test_case == "scalar-rotation":
-                X1 = X1 / np.linalg.norm(X1, ord="fro")
-                X2 = X2 / np.linalg.norm(X2, ord="fro")
-            elif self.test_case == "diagonal-rotation":
-                normX1 = np.sum(X1 ** 2, axis=1)
-                normX2 = np.sum(X2 ** 2, axis=1)
-                normX1[normX1 <= 1e-15] = 1
-                normX2[normX2 <= 1e-15] = 1
-                X1 = X1 / np.sqrt(normX1[:, None])
-                X2 = X2 / np.sqrt(normX2[:, None])
-            aligner = OrthogonalProcrustes()
-            X1 = aligner.fit_transform(X1, X2)
-        return np.linalg.norm(X1 - X2)
-
-    def _embed(self, A1, A2, check_lcc=True):
-        if self.embedding == "ase":
-            X1_hat = AdjacencySpectralEmbed(
-                n_components=self.n_components, check_lcc=check_lcc
-            ).fit_transform(A1)
-            X2_hat = AdjacencySpectralEmbed(
-                n_components=self.n_components, check_lcc=check_lcc
-            ).fit_transform(A2)
-        elif self.embedding == "omnibus":
-            X_hat_compound = OmnibusEmbed(
-                n_components=self.n_components, check_lcc=check_lcc
-            ).fit_transform((A1, A2))
-            X1_hat = X_hat_compound[0]
-            X2_hat = X_hat_compound[1]
-        return (X1_hat, X2_hat)
-
-    def fit(self, A1, A2):
-        """
-        Fits the test to the two input graphs
-
-        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
-        -------
-        self
-        """
-        A1 = import_graph(A1)
-        A2 = import_graph(A2)
-        if not is_symmetric(A1) or not is_symmetric(A2):
-            raise NotImplementedError()  # TODO asymmetric case
-        if A1.shape != A2.shape:
-            raise ValueError("Input matrices do not have matching dimensions")
-        if self.n_components is None:
-            # get the last elbow from ZG for each and take the maximum
-            num_dims1 = select_dimension(A1)[0][-1]
-            num_dims2 = select_dimension(A2)[0][-1]
-            self.n_components = max(num_dims1, num_dims2)
-        X_hats = self._embed(A1, A2)
-        sample_T_statistic = self._difference_norm(X_hats[0], X_hats[1])
-        null_distribution_1 = self._bootstrap(X_hats[0])
-        null_distribution_2 = self._bootstrap(X_hats[1])
-
-        # 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]) + 1
-        ) / (self.n_bootstraps + 1)
-        p_value_2 = (
-            len(null_distribution_2[null_distribution_2 >= sample_T_statistic]) + 1
-        ) / (self.n_bootstraps + 1)
-
-        p_value = max(p_value_1, p_value_2)
-
-        self.null_distribution_1_ = null_distribution_1
-        self.null_distribution_2_ = null_distribution_2
-        self.sample_T_statistic_ = sample_T_statistic
-        self.p_value_1_ = p_value_1
-        self.p_value_2_ = p_value_2
-        self.p_value_ = p_value
-
-        return self
+        )
+    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'"
+        )
+
+    A1 = import_graph(A1)
+    A2 = import_graph(A2)
+    if not is_symmetric(A1) or not is_symmetric(A2):
+        raise NotImplementedError()  # TODO asymmetric case
+    if A1.shape != A2.shape:
+        raise ValueError("Input matrices do not have matching dimensions")
+    if n_components is None:
+        # get the last elbow from ZG for each and take the maximum
+        num_dims1 = select_dimension(A1)[0][-1]
+        num_dims2 = select_dimension(A2)[0][-1]
+        n_components = max(num_dims1, num_dims2)
+    X_hats = _embed(A1, A2, embedding, n_components)
+    sample_T_statistic = _difference_norm(X_hats[0], X_hats[1], embedding, test_case)
+    null_distribution_1 = _bootstrap(
+        X_hats[0], embedding, n_components, n_bootstraps, test_case
+    )
+    null_distribution_2 = _bootstrap(
+        X_hats[1], embedding, n_components, n_bootstraps, test_case
+    )
+
+    # using exact mc p-values (see, for example, Phipson and Smyth, 2010)
+    p_value_1 = (
+        len(null_distribution_1[null_distribution_1 >= sample_T_statistic]) + 1
+    ) / (n_bootstraps + 1)
+    p_value_2 = (
+        len(null_distribution_2[null_distribution_2 >= sample_T_statistic]) + 1
+    ) / (n_bootstraps + 1)
+
+    p_value = max(p_value_1, p_value_2)
+
+    misc_stats = {
+        "null_distribution_1": null_distribution_1,
+        "null_distribution_2_": null_distribution_2,
+        "p_value_1": p_value_1,
+        "p_value_2": p_value_2,
+    }
+
+    return lpt_result(p_value, sample_T_statistic, misc_stats)
+
+
+def _bootstrap(
+    X_hat, embedding, n_components, n_bootstraps, test_case, rescale=False, loops=False
+):
+    t_bootstrap = np.zeros(n_bootstraps)
+    for i in range(n_bootstraps):
+        A1_simulated = rdpg(X_hat, rescale=rescale, loops=loops)
+        A2_simulated = rdpg(X_hat, rescale=rescale, loops=loops)
+        X1_hat_simulated, X2_hat_simulated = _embed(
+            A1_simulated, A2_simulated, embedding, n_components, check_lcc=False
+        )
+        t_bootstrap[i] = _difference_norm(
+            X1_hat_simulated, X2_hat_simulated, embedding, test_case
+        )
+    return t_bootstrap
+
+
+def _difference_norm(X1, X2, embedding, test_case):
+    if embedding in ["ase"]:
+        if test_case == "rotation":
+            pass
+        elif test_case == "scalar-rotation":
+            X1 = X1 / np.linalg.norm(X1, ord="fro")
+            X2 = X2 / np.linalg.norm(X2, ord="fro")
+        elif test_case == "diagonal-rotation":
+            normX1 = np.sum(X1 ** 2, axis=1)
+            normX2 = np.sum(X2 ** 2, axis=1)
+            normX1[normX1 <= 1e-15] = 1
+            normX2[normX2 <= 1e-15] = 1
+            X1 = X1 / np.sqrt(normX1[:, None])
+            X2 = X2 / np.sqrt(normX2[:, None])
+        aligner = OrthogonalProcrustes()
+        X1 = aligner.fit_transform(X1, X2)
+    return np.linalg.norm(X1 - X2)
+
+
+def _embed(A1, A2, embedding, n_components, check_lcc=True):
+    if embedding == "ase":
+        X1_hat = AdjacencySpectralEmbed(
+            n_components=n_components, check_lcc=check_lcc
+        ).fit_transform(A1)
+        X2_hat = AdjacencySpectralEmbed(
+            n_components=n_components, check_lcc=check_lcc
+        ).fit_transform(A2)
+    elif embedding == "omnibus":
+        X_hat_compound = OmnibusEmbed(
+            n_components=n_components, check_lcc=check_lcc
+        ).fit_transform((A1, A2))
+        X1_hat = X_hat_compound[0]
+        X2_hat = X_hat_compound[1]
+    return (X1_hat, X2_hat)
diff --git a/tests/test_latentdistributiontest.py b/tests/test_latentdistributiontest.py
--- a/tests/test_latentdistributiontest.py
+++ b/tests/test_latentdistributiontest.py
@@ -8,96 +8,90 @@
 from sklearn.metrics import pairwise_distances
 
 from graspologic.embed import AdjacencySpectralEmbed
-from graspologic.inference import LatentDistributionTest
+from graspologic.inference import latent_distribution_test
 from graspologic.simulations import er_np, sbm
 
 
 class TestLatentDistributionTest(unittest.TestCase):
     @classmethod
-    def setUpClass(cls):
-        np.random.seed(123456)
-        cls.tests = {"dcorr": "euclidean", "hsic": "gaussian", "mgc": "euclidean"}
-        cls.A1 = er_np(20, 0.3)
-        cls.A2 = er_np(20, 0.3)
-
-    def test_fit_ase_works(self):
-        for test in self.tests.keys():
-            ldt = LatentDistributionTest(test, self.tests[test], n_bootstraps=10)
-            assert ldt.fit(self.A1, self.A2) is ldt
-
-    def test_fit_predict_ase_works(self):
-        for test in self.tests:
-            ldt = LatentDistributionTest(test, self.tests[test], n_bootstraps=10)
-            p = ldt.fit_predict(self.A1, self.A2)
-            assert float(p) <= 1 and float(p) >= 0
+    def test_ase_works(self):
+        np.random.seed(888)
+        A1 = er_np(20, 0.3)
+        A2 = er_np(20, 0.3)
+        tests = {"dcorr": "euclidean", "hsic": "gaussian", "mgc": "euclidean"}
+        for test in tests.keys():
+            ldt = latent_distribution_test(A1, A2, test, tests[test], n_bootstraps=10)
 
     def test_workers(self):
-        ldt = LatentDistributionTest("dcorr", "euclidean", n_bootstraps=4, workers=4)
-        ldt.fit(self.A1, self.A2)
+        np.random.seed(888)
+        A1 = er_np(20, 0.3)
+        A2 = er_np(20, 0.3)
+        ldt = latent_distribution_test(
+            A1, A2, "dcorr", "euclidean", n_bootstraps=4, workers=4
+        )
 
     def test_callable_metric(self):
+        np.random.seed(888)
+        A1 = er_np(20, 0.3)
+        A2 = er_np(20, 0.3)
+
         def metric_func(X, Y=None, workers=None):
             return pairwise_distances(X, metric="euclidean") * 0.5
 
-        ldt = LatentDistributionTest("dcorr", metric_func, n_bootstraps=10)
-        ldt.fit(self.A1, self.A2)
+        ldt = latent_distribution_test(A1, A2, "dcorr", metric_func, n_bootstraps=10)
 
     def test_bad_kwargs(self):
+        np.random.seed(888)
+        A1 = er_np(20, 0.3)
+        A2 = er_np(20, 0.3)
+
         # check test argument
-        with self.assertRaises(TypeError):
-            LatentDistributionTest(test=0)
-        with self.assertRaises(ValueError):
-            LatentDistributionTest(test="foo")
+        with pytest.raises(TypeError):
+            latent_distribution_test(A1, A2, test=0)
+        with pytest.raises(ValueError):
+            latent_distribution_test(A1, A2, test="foo")
         # check metric argument
-        with self.assertRaises(TypeError):
-            LatentDistributionTest(metric=0)
-        with self.assertRaises(ValueError):
-            LatentDistributionTest(metric="some_kind_of_kernel")
-        # check metric argument modified
-        with self.assertRaises(TypeError):
-            ldt = LatentDistributionTest()
-            ldt.metric = 0
-            ldt.fit(self.A1, self.A2)
-        with self.assertRaises(ValueError):
-            ldt = LatentDistributionTest()
-            ldt.metric = "some_kind_of_kernel"
-            ldt.fit(self.A1, self.A2)
+        with pytest.raises(TypeError):
+            latent_distribution_test(A1, A2, metric=0)
+        with pytest.raises(ValueError):
+            latent_distribution_test(A1, A2, metric="some_kind_of_kernel")
         # check n_components argument
-        with self.assertRaises(TypeError):
-            LatentDistributionTest(n_components=0.5)
-        with self.assertRaises(ValueError):
-            LatentDistributionTest(n_components=-100)
+        with pytest.raises(TypeError):
+            latent_distribution_test(A1, A2, n_components=0.5)
+        with pytest.raises(ValueError):
+            latent_distribution_test(A1, A2, n_components=-100)
         # check n_bootstraps argument
-        with self.assertRaises(TypeError):
-            LatentDistributionTest(n_bootstraps=0.5)
-        with self.assertRaises(ValueError):
-            LatentDistributionTest(n_bootstraps=-100)
+        with pytest.raises(TypeError):
+            latent_distribution_test(A1, A2, n_bootstraps=0.5)
+        with pytest.raises(ValueError):
+            latent_distribution_test(A1, A2, n_bootstraps=-100)
         # check workers argument
-        with self.assertRaises(TypeError):
-            LatentDistributionTest(workers=0.5)
+        with pytest.raises(TypeError):
+            latent_distribution_test(A1, A2, workers=0.5)
         # check size_correction argument
-        with self.assertRaises(TypeError):
-            LatentDistributionTest(size_correction=0)
+        with pytest.raises(TypeError):
+            latent_distribution_test(A1, A2, size_correction=0)
         # check pooled argument
-        with self.assertRaises(TypeError):
-            LatentDistributionTest(pooled=0)
+        with pytest.raises(TypeError):
+            latent_distribution_test(A1, A2, pooled=0)
         # check align_type argument
-        with self.assertRaises(ValueError):
-            LatentDistributionTest(align_type="foo")
-        with self.assertRaises(TypeError):
-            LatentDistributionTest(align_type={"not a": "string"})
+        with pytest.raises(ValueError):
+            latent_distribution_test(A1, A2, align_type="foo")
+        with pytest.raises(TypeError):
+            latent_distribution_test(A1, A2, align_type={"not a": "string"})
         # check align_kws argument
-        with self.assertRaises(TypeError):
-            LatentDistributionTest(align_kws="foo")
+        with pytest.raises(TypeError):
+            latent_distribution_test(A1, A2, align_kws="foo")
         # check input_graph argument
-        with self.assertRaises(TypeError):
-            LatentDistributionTest(input_graph="hello")
+        with pytest.raises(TypeError):
+            latent_distribution_test(A1, A2, input_graph="hello")
 
     def test_n_bootstraps(self):
-        for test in self.tests.keys():
-            ldt = LatentDistributionTest(test, self.tests[test], n_bootstraps=123)
-            ldt.fit(self.A1, self.A2)
-            self.assertEqual(ldt.null_distribution_.shape[0], 123)
+        A1 = er_np(20, 0.3)
+        A2 = er_np(20, 0.3)
+
+        ldt = latent_distribution_test(A1, A2, n_bootstraps=123)
+        assert ldt[2]["null_distribution"].shape[0] == 123
 
     def test_passing_networkx(self):
         np.random.seed(123)
@@ -107,17 +101,13 @@ def test_passing_networkx(self):
         A2_nx = nx.from_numpy_matrix(A2)
         # check passing nx, when exepect embeddings
         with self.assertRaises(TypeError):
-            ldt = LatentDistributionTest(input_graph=False)
-            ldt.fit_predict(A1_nx, A2)
+            latent_distribution_test(A1_nx, A1, input_graph=False)
         with self.assertRaises(TypeError):
-            ldt = LatentDistributionTest(input_graph=False)
-            ldt.fit_predict(A1, A2_nx)
+            latent_distribution_test(A1, A2_nx, input_graph=False)
         with self.assertRaises(TypeError):
-            ldt = LatentDistributionTest(input_graph=False)
-            ldt.fit_predict(A1_nx, A2_nx)
+            latent_distribution_test(A1_nx, A2_nx, input_graph=False)
         # check that the appropriate input works
-        ldt = LatentDistributionTest(input_graph=True)
-        ldt.fit_predict(A1_nx, A2_nx)
+        latent_distribution_test(A1_nx, A2_nx, input_graph=True)
 
     def test_passing_embeddings(self):
         np.random.seed(123)
@@ -131,48 +121,36 @@ def test_passing_embeddings(self):
         X3 = ase_3.fit_transform(A2)
         # check embeddings having weird ndim
         with self.assertRaises(ValueError):
-            ldt = LatentDistributionTest(input_graph=False)
-            ldt.fit_predict(X1, X2.reshape(-1, 1, 1))
+            latent_distribution_test(X1, X2.reshape(-1, 1, 1), input_graph=False)
         with self.assertRaises(ValueError):
-            ldt = LatentDistributionTest(input_graph=False)
-            ldt.fit_predict(X1.reshape(-1, 1, 1), X2)
+            latent_distribution_test(X1.reshape(-1, 1, 1), X2, input_graph=False)
         # check embeddings having mismatching number of components
         with self.assertRaises(ValueError):
-            ldt = LatentDistributionTest(input_graph=False)
-            ldt.fit_predict(X1, X3)
+            latent_distribution_test(X1, X3, input_graph=False)
         with self.assertRaises(ValueError):
-            ldt = LatentDistributionTest(input_graph=False)
-            ldt.fit_predict(X3, X1)
+            latent_distribution_test(X3, X1, input_graph=False)
         # check passing weird stuff as input (caught by us)
         with self.assertRaises(TypeError):
-            ldt = LatentDistributionTest(input_graph=False)
-            ldt.fit_predict("hello there", X1)
+            latent_distribution_test("hello there", X1, input_graph=False)
         with self.assertRaises(TypeError):
-            ldt = LatentDistributionTest(input_graph=False)
-            ldt.fit_predict(X1, "hello there")
+            latent_distribution_test(X1, "hello there", input_graph=False)
         with self.assertRaises(TypeError):
-            ldt = LatentDistributionTest(input_graph=False)
-            ldt.fit_predict({"hello": "there"}, X1)
+            latent_distribution_test({"hello": "there"}, X1, input_graph=False)
         with self.assertRaises(TypeError):
-            ldt = LatentDistributionTest(input_graph=False)
-            ldt.fit_predict(X1, {"hello": "there"})
+            latent_distribution_test(X1, {"hello": "there"}, input_graph=False)
         # check passing infinite in input (caught by check_array)
         with self.assertRaises(ValueError):
             X1_w_inf = X1.copy()
             X1_w_inf[1, 1] = np.inf
-            ldt = LatentDistributionTest(input_graph=False)
-            ldt.fit_predict(X1_w_inf, X2)
+            latent_distribution_test(X1_w_inf, X2, input_graph=False)
         # check that the appropriate input works
-        ldt = LatentDistributionTest(input_graph=False)
-        ldt.fit_predict(X1, X2)
+        latent_distribution_test(X1, X2, input_graph=False)
 
     def test_pooled(self):
         np.random.seed(123)
         A1 = er_np(20, 0.3)
         A2 = er_np(100, 0.3)
-
-        ldt = LatentDistributionTest(pooled=True)
-        ldt.fit(A1, A2)
+        latent_distribution_test(A1, A2, pooled=True)
 
     def test_distances_and_kernels(self):
         np.random.seed(123)
@@ -187,25 +165,23 @@ def test_distances_and_kernels(self):
         #     ldt = LatentDistributionTest("hsic", "rbf")
         #     ldt.fit(A1, A2)
         # assert len(record) == 0
-        for test in self.tests.keys():
-            ldt = LatentDistributionTest(test, self.tests[test])
-            ldt.fit(A1, A2)
-        ldt = LatentDistributionTest("hsic", "rbf")
-        ldt.fit(A1, A2)
+        latent_distribution_test(A1, A2, test="hsic", metric="rbf")
         # some invalid combinations of test and metric
         with pytest.warns(UserWarning):
-            ldt = LatentDistributionTest("hsic", "euclidean")
+            latent_distribution_test(A1, A2, "hsic", "euclidean")
         with pytest.warns(UserWarning):
-            ldt = LatentDistributionTest("dcorr", "gaussian")
+            latent_distribution_test(A1, A2, "dcorr", "gaussian")
         with pytest.warns(UserWarning):
-            ldt = LatentDistributionTest("dcorr", "rbf")
+            latent_distribution_test(A1, A2, "dcorr", "rbf")
 
     def test_bad_matrix_inputs(self):
-        ldt = LatentDistributionTest("dcorr")
+        np.random.seed(1234556)
+        A1 = er_np(20, 0.3)
+        A2 = er_np(20, 0.3)
 
         bad_matrix = [[1, 2]]
         with self.assertRaises(TypeError):
-            ldt.fit(bad_matrix, self.A2)
+            latent_distribution_test(bad_matrix, A2, test="dcorr")
 
     def test_directed_inputs(self):
         np.random.seed(2)
@@ -214,36 +190,27 @@ def test_directed_inputs(self):
         C = er_np(100, 0.3, directed=False)
 
         # two directed graphs is okay
-        ldt = LatentDistributionTest()
-        ldt.fit(A, B)
+        latent_distribution_test(A, B)
 
         # an undirected and a direced graph is not okay
         with self.assertRaises(ValueError):
-            ldt.fit(A, C)
+            latent_distribution_test(A, C)
         with self.assertRaises(ValueError):
-            ldt.fit(C, B)
+            latent_distribution_test(C, B)
 
     def test_SBM_dcorr(self):
-        for test in self.tests.keys():
-            np.random.seed(12345678)
-            B1 = np.array([[0.5, 0.2], [0.2, 0.5]])
-
-            B2 = np.array([[0.7, 0.2], [0.2, 0.7]])
-            b_size = 200
-            A1 = sbm(2 * [b_size], B1)
-            A2 = sbm(2 * [b_size], B1)
-            A3 = sbm(2 * [b_size], B2)
-
-            ldt_null = LatentDistributionTest(
-                test, self.tests[test], n_components=2, n_bootstraps=100
-            )
-            ldt_alt = LatentDistributionTest(
-                test, self.tests[test], n_components=2, n_bootstraps=100
-            )
-            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)
+        np.random.seed(12345678)
+        B1 = np.array([[0.5, 0.2], [0.2, 0.5]])
+
+        B2 = np.array([[0.7, 0.2], [0.2, 0.7]])
+        b_size = 200
+        A1 = sbm(2 * [b_size], B1)
+        A2 = sbm(2 * [b_size], B1)
+        A3 = sbm(2 * [b_size], B2)
+        ldt_null = latent_distribution_test(A1, A2)
+        ldt_alt = latent_distribution_test(A1, A3)
+        self.assertTrue(ldt_null[0] > 0.05)
+        self.assertTrue(ldt_alt[0] <= 0.05)
 
     def test_different_sizes_null(self):
         np.random.seed(314)
@@ -251,23 +218,37 @@ def test_different_sizes_null(self):
         A1 = er_np(100, 0.8)
         A2 = er_np(1000, 0.8)
 
-        ldt_not_corrected = LatentDistributionTest(
-            "hsic", "gaussian", n_components=2, n_bootstraps=100, size_correction=False
+        ldt_not_corrected = latent_distribution_test(
+            A1,
+            A2,
+            test="hsic",
+            metric="gaussian",
+            n_components=2,
+            n_bootstraps=100,
+            size_correction=False,
         )
-        ldt_corrected_1 = LatentDistributionTest(
-            "hsic", "gaussian", n_components=2, n_bootstraps=100, size_correction=True
+        ldt_corrected_1 = latent_distribution_test(
+            A1,
+            A2,
+            test="hsic",
+            metric="gaussian",
+            n_components=2,
+            n_bootstraps=100,
+            size_correction=True,
         )
-        ldt_corrected_2 = LatentDistributionTest(
-            "hsic", "gaussian", n_components=2, n_bootstraps=100, size_correction=True
+        ldt_corrected_2 = latent_distribution_test(
+            A2,
+            A1,
+            test="hsic",
+            metric="gaussian",
+            n_components=2,
+            n_bootstraps=100,
+            size_correction=True,
         )
 
-        p_not_corrected = ldt_not_corrected.fit_predict(A1, A2)
-        p_corrected_1 = ldt_corrected_1.fit_predict(A1, A2)
-        p_corrected_2 = ldt_corrected_2.fit_predict(A2, A1)
-
-        self.assertTrue(p_not_corrected <= 0.05)
-        self.assertTrue(p_corrected_1 > 0.05)
-        self.assertTrue(p_corrected_2 > 0.05)
+        self.assertTrue(ldt_not_corrected[0] <= 0.05)
+        self.assertTrue(ldt_corrected_1[0] > 0.05)
+        self.assertTrue(ldt_corrected_2[0] > 0.05)
 
     def test_different_sizes_null(self):
         np.random.seed(314)
@@ -275,18 +256,27 @@ def test_different_sizes_null(self):
         A1 = er_np(100, 0.8)
         A2 = er_np(1000, 0.7)
 
-        ldt_corrected_1 = LatentDistributionTest(
-            "hsic", "gaussian", n_components=2, n_bootstraps=100, size_correction=True
+        ldt_corrected_1 = latent_distribution_test(
+            A1,
+            A2,
+            test="hsic",
+            metric="gaussian",
+            n_components=2,
+            n_bootstraps=100,
+            size_correction=True,
         )
-        ldt_corrected_2 = LatentDistributionTest(
-            "hsic", "gaussian", n_components=2, n_bootstraps=100, size_correction=True
+        ldt_corrected_2 = latent_distribution_test(
+            A2,
+            A1,
+            test="hsic",
+            metric="gaussian",
+            n_components=2,
+            n_bootstraps=100,
+            size_correction=True,
         )
 
-        p_corrected_1 = ldt_corrected_1.fit_predict(A1, A2)
-        p_corrected_2 = ldt_corrected_2.fit_predict(A2, A1)
-
-        self.assertTrue(p_corrected_1 <= 0.05)
-        self.assertTrue(p_corrected_2 <= 0.05)
+        self.assertTrue(ldt_corrected_1[0] <= 0.05)
+        self.assertTrue(ldt_corrected_2[0] <= 0.05)
 
     def test_different_aligners(self):
         np.random.seed(314)
@@ -298,22 +288,23 @@ def test_different_aligners(self):
         X2 = ase_2.fit_transform(A2)
         X2 = -X2
 
-        ldt_1 = LatentDistributionTest(input_graph=False, align_type=None)
-        p_val_1 = ldt_1.fit_predict(X1, X2)
-        self.assertTrue(p_val_1 < 0.05)
+        ldt_1 = latent_distribution_test(X1, X2, input_graph=False, align_type=None)
+        self.assertTrue(ldt_1[0] < 0.05)
 
-        ldt_2 = LatentDistributionTest(input_graph=False, align_type="sign_flips")
-        p_val_2 = ldt_2.fit_predict(X1, X2)
-        self.assertTrue(p_val_2 >= 0.05)
+        ldt_2 = latent_distribution_test(
+            X1, X2, input_graph=False, align_type="sign_flips"
+        )
+        self.assertTrue(ldt_2[0] >= 0.05)
 
         # also checking that kws are passed through
-        ldt_3 = LatentDistributionTest(
+        ldt_3 = latent_distribution_test(
+            X1,
+            X2,
             input_graph=False,
             align_type="seedless_procrustes",
             align_kws={"init": "sign_flips"},
         )
-        p_val_3 = ldt_3.fit_predict(X1, X2)
-        self.assertTrue(p_val_3 >= 0.05)
+        self.assertTrue(ldt_3[0] >= 0.05)
 
 
 if __name__ == "__main__":
diff --git a/tests/test_latentpositiontest.py b/tests/test_latentpositiontest.py
--- a/tests/test_latentpositiontest.py
+++ b/tests/test_latentpositiontest.py
@@ -1,70 +1,70 @@
-# Copyright (c) Microsoft Corporation and contributors.
-# Licensed under the MIT License.
-
 import unittest
+import pytest
 import numpy as np
-from graspologic.inference import LatentPositionTest
+
+from graspologic.inference import latent_position_test
+from graspologic.inference.latent_position_test import _difference_norm
 from graspologic.simulations import er_np, sbm
 
 
 class TestLatentPositionTest(unittest.TestCase):
     @classmethod
-    def setUpClass(cls):
+    def test_ase_works(self):
         np.random.seed(1234556)
-        cls.A1 = er_np(20, 0.3)
-        cls.A2 = er_np(20, 0.3)
-
-    def test_fit_ase_works(self):
-        spt = LatentPositionTest()
-        assert spt.fit(self.A1, self.A2) is spt
+        A1 = er_np(20, 0.3)
+        A2 = er_np(20, 0.3)
+        lpt = latent_position_test(A1, A2)
 
-    def test_fit_omni_works(self):
-        spt = LatentPositionTest(embedding="omnibus")
-        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_omni_works(self):
+        np.random.seed(1234556)
+        A1 = er_np(20, 0.3)
+        A2 = er_np(20, 0.3)
+        lpt = latent_position_test(A1, A2, embedding="omnibus")
 
     def test_bad_kwargs(self):
-        with self.assertRaises(ValueError):
-            LatentPositionTest(n_components=-100)
-        with self.assertRaises(ValueError):
-            LatentPositionTest(n_components=-100)
-        with self.assertRaises(ValueError):
-            LatentPositionTest(test_case="oops")
-        with self.assertRaises(ValueError):
-            LatentPositionTest(n_bootstraps=-100)
-        with self.assertRaises(ValueError):
-            LatentPositionTest(embedding="oops")
-        with self.assertRaises(TypeError):
-            LatentPositionTest(n_bootstraps=0.5)
-        with self.assertRaises(TypeError):
-            LatentPositionTest(n_components=0.5)
-        with self.assertRaises(TypeError):
-            LatentPositionTest(embedding=6)
-        with self.assertRaises(TypeError):
-            LatentPositionTest(test_case=6)
+        np.random.seed(1234556)
+        A1 = er_np(20, 0.3)
+        A2 = er_np(20, 0.3)
+
+        with pytest.raises(ValueError):
+            latent_position_test(A1, A2, n_components=-100)
+        with pytest.raises(ValueError):
+            latent_position_test(A1, A2, test_case="oops")
+        with pytest.raises(ValueError):
+            latent_position_test(A1, A2, n_bootstraps=-100)
+        with pytest.raises(ValueError):
+            latent_position_test(A1, A2, embedding="oops")
+        with pytest.raises(TypeError):
+            latent_position_test(A1, A2, n_bootstraps=0.5)
+        with pytest.raises(TypeError):
+            latent_position_test(A1, A2, n_components=0.5)
+        with pytest.raises(TypeError):
+            latent_position_test(A1, A2, embedding=6)
+        with pytest.raises(TypeError):
+            latent_position_test(A1, A2, test_case=6)
 
     def test_n_bootstraps(self):
-        spt = LatentPositionTest(n_bootstraps=234, n_components=None)
-        spt.fit(self.A1, self.A2)
-        self.assertEqual(spt.null_distribution_1_.shape[0], 234)
+        np.random.seed(1234556)
+        A1 = er_np(20, 0.3)
+        A2 = er_np(20, 0.3)
+
+        lpt = latent_position_test(A1, A2, n_bootstraps=234, n_components=None)
+        assert lpt[2]["null_distribution_1"].shape[0] == 234
 
     def test_bad_matrix_inputs(self):
-        spt = LatentPositionTest()
-        A1 = self.A1.copy()
+        np.random.seed(1234556)
+        A1 = er_np(20, 0.3)
+        A2 = er_np(20, 0.3)
         A1[2, 0] = 1  # make asymmetric
-        with self.assertRaises(NotImplementedError):  # TODO : remove when we implement
-            spt.fit(A1, self.A2)
+        with pytest.raises(NotImplementedError):  # TODO : remove when we implement
+            latent_position_test(A1, A2)
 
         bad_matrix = [[1, 2]]
-        with self.assertRaises(TypeError):
-            spt.fit(bad_matrix, self.A2)
+        with pytest.raises(TypeError):
+            latent_position_test(bad_matrix, A2)
 
-        with self.assertRaises(ValueError):
-            spt.fit(self.A1[:2, :2], self.A2)
+        with pytest.raises(ValueError):
+            latent_position_test(A1[:2, :2], A2)
 
     def test_rotation_norm(self):
         # two triangles rotated by 90 degrees
@@ -72,8 +72,7 @@ def test_rotation_norm(self):
         rotation = np.array([[0, 1], [-1, 0]])
         points2 = np.dot(points1, rotation)
 
-        spt = LatentPositionTest(embedding="ase", test_case="rotation")
-        n = spt._difference_norm(points1, points2)
+        n = _difference_norm(points1, points2, embedding="ase", test_case="rotation")
         self.assertAlmostEqual(n, 0)
 
     def test_diagonal_rotation_norm(self):
@@ -86,8 +85,9 @@ def test_diagonal_rotation_norm(self):
         diagonal = np.array([[2, 0, 0], [0, 3, 0], [0, 0, 2]])
         points2 = np.dot(diagonal, points2)
 
-        spt = LatentPositionTest(embedding="ase", test_case="diagonal-rotation")
-        n = spt._difference_norm(points1, points2)
+        n = _difference_norm(
+            points1, points2, embedding="ase", test_case="diagonal-rotation"
+        )
         self.assertAlmostEqual(n, 0)
 
     def test_scalar_rotation_norm(self):
@@ -99,26 +99,24 @@ def test_scalar_rotation_norm(self):
         # scaled
         points2 = 2 * points2
 
-        spt = LatentPositionTest(embedding="ase", test_case="scalar-rotation")
-        n = spt._difference_norm(points1, points2)
+        n = _difference_norm(
+            points1, points2, embedding="ase", test_case="scalar-rotation"
+        )
         self.assertAlmostEqual(n, 0)
 
     def test_SBM_epsilon(self):
         np.random.seed(12345678)
         B1 = np.array([[0.5, 0.2], [0.2, 0.5]])
-
         B2 = np.array([[0.7, 0.2], [0.2, 0.7]])
         b_size = 200
         A1 = sbm(2 * [b_size], B1)
         A2 = sbm(2 * [b_size], B1)
         A3 = sbm(2 * [b_size], B2)
 
-        spt_null = LatentPositionTest(n_components=2, n_bootstraps=100)
-        spt_alt = LatentPositionTest(n_components=2, n_bootstraps=100)
-        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)
+        lpt_null = latent_position_test(A1, A2, n_components=2, n_bootstraps=100)
+        lpt_alt = latent_position_test(A1, A3, n_components=2, n_bootstraps=100)
+        self.assertTrue(lpt_null[0] > 0.05)
+        self.assertTrue(lpt_alt[0] <= 0.05)
 
 
 if __name__ == "__main__":