Repository: graspologic-org/graspologic

Issue:
Add out-of-sample LSE
see #161


Discussion:
I may be interested in this one
I am interested in this one as well
@PerifanosPrometheus @dtborders let me know if either of you decide to do this, I have a pretty good idea of how it should happen at this point I think, just wanted to move on from oos stuff
@dtborders It is all yours, have fun!
@bdpedigo Could I be assigned to this one?
Dod:

- Recreate Figure 1 from [this paper](https://arxiv.org/pdf/1802.06307.pdf) for this [code](https://github.com/neurodata/primitives-interfaces/tree/master/jhu_primitives/ooslse)
- Recreate the same figure with graspy with the OOS LSE I will add. If the figures have the same distributions, then the models are identical so we are good to go. 
- Modify Alex's relevant tests for OOS ASE to also test OOS LSE. Add any additional tests needed. 
- Create a tutorial notebook.

Resolving patch:
diff --git a/graspologic/embed/ase.py b/graspologic/embed/ase.py
--- a/graspologic/embed/ase.py
+++ b/graspologic/embed/ase.py
@@ -1,19 +1,8 @@
 # Copyright (c) Microsoft Corporation and contributors.
 # Licensed under the MIT License.
 
-import warnings
-import numpy as np
-from sklearn.utils.validation import check_is_fitted
-import networkx as nx
-
 from .base import BaseSpectralEmbed
-from ..utils import (
-    import_graph,
-    is_fully_connected,
-    augment_diagonal,
-    pass_to_ranks,
-    is_unweighted,
-)
+from ..utils import augment_diagonal
 
 
 class AdjacencySpectralEmbed(BaseSpectralEmbed):
@@ -70,14 +59,16 @@ class AdjacencySpectralEmbed(BaseSpectralEmbed):
         to the ground truth.
 
     concat : bool, optional (default False)
-        If graph is directed, whether to concatenate left and right (out and in) latent positions along axis 1.
+        If graph is directed, whether to concatenate left and right (out and in) latent
+        positions along axis 1.
 
 
 
     Attributes
     ----------
     n_features_in_: int
-        Number of features passed to the :func:`~graspologic.embed.AdjacencySpectralEmbed.fit` method.
+        Number of features passed to the
+        :func:`~graspologic.embed.AdjacencySpectralEmbed.fit` method.
     latent_left_ : array, shape (n_samples, n_components)
         Estimated left latent positions of the graph.
     latent_right_ : array, shape (n_samples, n_components), or None
@@ -161,93 +152,23 @@ def fit(self, graph, y=None):
 
         self._reduce_dim(A)
 
-        # for out-of-sample
-        inv_eigs = np.diag(1 / self.singular_values_)
-        self._pinv_left = self.latent_left_ @ inv_eigs
-        if self.latent_right_ is not None:
-            self._pinv_right = self.latent_right_ @ inv_eigs
-
         self.is_fitted_ = True
+
         return self
 
-    def transform(self, X):
+    def _compute_oos_prediction(self, X, directed):
         """
-        Obtain latent positions from an adjacency matrix or matrix of out-of-sample
-        vertices. For more details on transforming out-of-sample vertices, see the
-        :ref:`tutorials <embed_tutorials>`. For mathematical background, see [2].
-
+        Computes the out-of-sample latent position estimation.
         Parameters
         ----------
-        X : array-like or tuple, original shape or (n_oos_vertices, n_vertices).
-
-            The original fitted matrix ("graph" in fit) or new out-of-sample data.
-            If ``X`` is the original fitted matrix, returns a matrix close to
-            ``self.fit_transform(X)``.
-
-            If ``X`` is an out-of-sample matrix, n_oos_vertices is the number
-            of new vertices, and n_vertices is the number of vertices in the
-            original graph. If tuple, graph is directed and ``X[0]`` contains
-            edges from out-of-sample vertices to in-sample vertices.
-
+        X: np.ndarray
+            Input to do oos embedding on.
+        directed: bool
+            Indication if graph is directed or undirected
         Returns
         -------
-        array_like or tuple, shape (n_oos_vertices, n_components)
-        or (n_vertices, n_components).
-
-            Array of latent positions. Transforms the fitted matrix if it was passed
-            in.
-
-            If ``X`` is an array or tuple containing adjacency vectors corresponding to
-            new nodes, returns the estimated latent positions for the new out-of-sample
-            adjacency vectors.
-            If undirected, returns array.
-            If directed, returns ``(X_out, X_in)``, where ``X_out`` contains
-            latent positions corresponding to nodes with edges from out-of-sample
-            vertices to in-sample vertices.
-
-        Notes
-        -----
-        If the matrix was diagonally augmented (e.g., ``self.diag_aug`` was True), ``fit``
-        followed by ``transform`` will produce a slightly different matrix than
-        ``fit_transform``.
-
-        To get the original embedding, using ``fit_transform`` is recommended. In the
-        directed case, if A is the original in-sample adjacency matrix, the tuple
-        (A.T, A) will need to be passed to ``transform`` if you do not wish to use
-        ``fit_transform``.
+        out : array_like or tuple, shape
         """
-
-        # checks
-        check_is_fitted(self, "is_fitted_")
-        if isinstance(X, nx.classes.graph.Graph):
-            X = import_graph(X)
-        directed = self.latent_right_ is not None
-
-        # correct types?
-        if directed and not isinstance(X, tuple):
-            if X.shape[0] == X.shape[1]:  # in case original matrix was passed
-                msg = """A square matrix A was passed to ``transform`` in the directed case. 
-                If this was the original in-sample matrix, either use ``fit_transform`` 
-                or pass a tuple (A.T, A). If this was an out-of-sample matrix, directed
-                graphs require a tuple (X_out, X_in)."""
-                raise TypeError(msg)
-            else:
-                msg = "Directed graphs require a tuple (X_out, X_in) for out-of-sample transforms."
-                raise TypeError(msg)
-        if not directed and not isinstance(X, np.ndarray):
-            raise TypeError("Undirected graphs require array input")
-
-        # correct shape in y?
-        latent_rows = self.latent_left_.shape[0]
-        _X = X[0] if directed else X
-        X_cols = _X.shape[-1]
-        if _X.ndim > 2:
-            raise ValueError("out-of-sample vertex must be 1d or 2d")
-        if latent_rows != X_cols:
-            msg = "out-of-sample vertex must be shape (n_oos_vertices, n_vertices)"
-            raise ValueError(msg)
-
-        # workhorse code
         if not directed:
             return X @ self._pinv_left
         elif directed:
diff --git a/graspologic/embed/base.py b/graspologic/embed/base.py
--- a/graspologic/embed/base.py
+++ b/graspologic/embed/base.py
@@ -1,11 +1,13 @@
 # Copyright (c) Microsoft Corporation and contributors.
 # Licensed under the MIT License.
 
+import warnings
+
 from abc import abstractmethod
 
 import numpy as np
-import warnings
 from sklearn.base import BaseEstimator
+from sklearn.utils.validation import check_is_fitted
 
 from ..utils import (
     augment_diagonal,
@@ -15,6 +17,8 @@
 )
 from .svd import selectSVD
 
+import networkx as nx
+
 
 class BaseSpectralEmbed(BaseEstimator):
     """
@@ -49,8 +53,8 @@ class BaseSpectralEmbed(BaseEstimator):
         results if the graph is unconnected. Not checking for connectedness may
         result in faster computation.
     concat : bool, optional (default = False)
-        If graph(s) are directed, whether to concatenate each graph's left and right (out and in) latent positions
-        along axis 1.
+        If graph(s) are directed, whether to concatenate each graph's left and right
+        (out and in) latent positions along axis 1.
 
     Attributes
     ----------
@@ -197,12 +201,133 @@ def fit_transform(self, graph, y=None):
         Returns
         -------
         out : np.ndarray OR length 2 tuple of np.ndarray.
-            If undirected then returns single np.ndarray of latent position, shape(n_vertices, n_components).
-            If directed, ``concat`` is True then concatenate latent matrices on axis 1, shape(n_vertices, 2*n_components).
-            If directed, ``concat`` is False then tuple of the latent matrices. Each of shape (n_vertices, n_components).
+            If undirected then returns single np.ndarray of latent position,
+            shape(n_vertices, n_components). If directed, ``concat`` is True then
+            concatenate latent matrices on axis 1, shape(n_vertices, 2*n_components).
+            If directed, ``concat`` is False then tuple of the latent matrices. Each of
+            shape (n_vertices, n_components).
         """
         return self._fit_transform(graph)
 
+    def transform(self, X):
+        """
+        Obtain latent positions from an adjacency matrix or matrix of out-of-sample
+        vertices. For more details on transforming out-of-sample vertices, see the
+        :ref:`tutorials <embed_tutorials>`. For mathematical background, see [2].
+
+        Parameters
+        ----------
+        X : array-like or tuple, original shape or (n_oos_vertices, n_vertices).
+
+            The original fitted matrix ("graph" in fit) or new out-of-sample data.
+            If ``X`` is the original fitted matrix, returns a matrix close to
+            ``self.fit_transform(X)``.
+
+            If ``X`` is an out-of-sample matrix, n_oos_vertices is the number
+            of new vertices, and n_vertices is the number of vertices in the
+            original graph. If tuple, graph is directed and ``X[0]`` contains
+            edges from out-of-sample vertices to in-sample vertices.
+
+        Returns
+        -------
+        out : np.ndarray OR length 2 tuple of np.ndarray
+
+            Array of latent positions, shape (n_oos_vertices, n_components) or
+            (n_vertices, n_components). Transforms the fitted matrix if it was passed
+            in.
+
+            If ``X`` is an array or tuple containing adjacency vectors corresponding to
+            new nodes, returns the estimated latent positions for the new out-of-sample
+            adjacency vectors.
+            If undirected, returns array.
+            If directed, returns ``(X_out, X_in)``, where ``X_out`` contains
+            latent positions corresponding to nodes with edges from out-of-sample
+            vertices to in-sample vertices.
+
+        Notes
+        -----
+        If the matrix was diagonally augmented (e.g., ``self.diag_aug`` was True), ``fit``
+        followed by ``transform`` will produce a slightly different matrix than
+        ``fit_transform``.
+
+        To get the original embedding, using ``fit_transform`` is recommended. In the
+        directed case, if A is the original in-sample adjacency matrix, the tuple
+        (A.T, A) will need to be passed to ``transform`` if you do not wish to use
+        ``fit_transform``.
+        """
+
+        # checks
+        check_is_fitted(self, "is_fitted_")
+        if isinstance(X, nx.classes.graph.Graph):
+            X = import_graph(X)
+        directed = self.latent_right_ is not None
+
+        # correct types?
+        if directed and not isinstance(X, tuple):
+            if X.shape[0] == X.shape[1]:  # in case original matrix was passed
+                msg = """A square matrix A was passed to ``transform`` in the directed case. 
+                If this was the original in-sample matrix, either use ``fit_transform`` 
+                or pass a tuple (A.T, A). If this was an out-of-sample matrix, directed
+                graphs require a tuple (X_out, X_in)."""
+                raise TypeError(msg)
+            else:
+                msg = "Directed graphs require a tuple (X_out, X_in) for out-of-sample transforms."
+                raise TypeError(msg)
+        if not directed and not isinstance(X, np.ndarray):
+            raise TypeError("Undirected graphs require array input")
+
+        # for oos prediction
+        inv_eigs = np.diag(1 / self.singular_values_)
+
+        self._pinv_left = self.latent_left_ @ inv_eigs
+        if self.latent_right_ is not None:
+            self._pinv_right = self.latent_right_ @ inv_eigs
+
+        # correct shape in y?
+        latent_rows = self.latent_left_.shape[0]
+        _X = X[0] if directed else X
+        X_cols = _X.shape[-1]
+        if _X.ndim > 2:
+            raise ValueError("out-of-sample vertex must be 1d or 2d")
+        if latent_rows != X_cols:
+            msg = "out-of-sample vertex must be shape (n_oos_vertices, n_vertices)"
+            raise ValueError(msg)
+
+        return self._compute_oos_prediction(X, directed)
+
+    @abstractmethod
+    def _compute_oos_prediction(self, X, directed):
+        """
+        Computes the oos class specific estimation given in an input array and if the
+        graph is directed.
+
+        Parameters
+        ----------
+        X: np.ndarray
+            Input to do oos embedding on.
+
+        directed: bool
+            Indication if graph is directed or undirected
+
+        Returns
+        -------
+        array_like or tuple, shape (n_oos_vertices, n_components)
+            or (n_vertices, n_components).
+
+            Array of latent positions. Transforms the fitted matrix if it was passed
+            in.
+
+            If ``X`` is an array or tuple containing adjacency vectors corresponding to
+            new nodes, returns the estimated latent positions for the new out-of-sample
+            adjacency vectors.
+            If undirected, returns array.
+            If directed, returns ``(X_out, X_in)``, where ``X_out`` contains
+            latent positions corresponding to nodes with edges from out-of-sample
+            vertices to in-sample vertices.
+        """
+
+        pass
+
 
 class BaseEmbedMulti(BaseSpectralEmbed):
     def __init__(
diff --git a/graspologic/embed/lse.py b/graspologic/embed/lse.py
--- a/graspologic/embed/lse.py
+++ b/graspologic/embed/lse.py
@@ -1,10 +1,14 @@
 # Copyright (c) Microsoft Corporation and contributors.
 # Licensed under the MIT License.
 
-import warnings
 
 from .base import BaseSpectralEmbed
-from ..utils import import_graph, to_laplacian, is_fully_connected
+from ..utils import to_laplacian
+
+import numpy as np
+
+from typing import Optional, Union
+import networkx as nx
 
 
 class LaplacianSpectralEmbed(BaseSpectralEmbed):
@@ -59,13 +63,15 @@ class LaplacianSpectralEmbed(BaseSpectralEmbed):
         ``form`` is 'R-DAD'.
 
     concat : bool, optional (default False)
-        If graph is directed, whether to concatenate left and right (out and in) latent positions along axis 1.
+        If graph is directed, whether to concatenate left and right (out and in) latent
+        positions along axis 1.
 
 
     Attributes
     ----------
     n_features_in_: int
-        Number of features passed to the :func:`~graspologic.embed.LaplacianSpectralEmbed.fit` method.
+        Number of features passed to the
+        :func:`~graspologic.embed.LaplacianSpectralEmbed.fit` method.
 
     latent_left_ : array, shape (n_samples, n_components)
         Estimated left latent positions of the graph.
@@ -109,14 +115,14 @@ class LaplacianSpectralEmbed(BaseSpectralEmbed):
 
     def __init__(
         self,
-        form="DAD",
-        n_components=None,
-        n_elbows=2,
-        algorithm="randomized",
-        n_iter=5,
-        check_lcc=True,
-        regularizer=None,
-        concat=False,
+        form: str = "DAD",
+        n_components: Optional[int] = None,
+        n_elbows: int = 2,
+        algorithm: str = "randomized",
+        n_iter: int = 5,
+        check_lcc: bool = True,
+        regularizer: Optional[float] = None,
+        concat: bool = False,
     ):
         super().__init__(
             n_components=n_components,
@@ -129,7 +135,7 @@ def __init__(
         self.form = form
         self.regularizer = regularizer
 
-    def fit(self, graph, y=None):
+    def fit(self, graph: Union[np.ndarray, nx.Graph], y=None):
         """
         Fit LSE model to input graph
 
@@ -150,4 +156,38 @@ def fit(self, graph, y=None):
         A = self._fit(graph)
         L_norm = to_laplacian(A, form=self.form, regularizer=self.regularizer)
         self._reduce_dim(L_norm)
+
+        self.is_fitted_ = True
+
         return self
+
+    def _compute_oos_prediction(self, X, directed):
+        """
+        Computes the out-of-sample latent position estimation.
+        Parameters
+        ----------
+        X: np.ndarray
+            Input to do oos embedding on.
+        directed: bool
+            Indication if graph is directed or undirected
+        Returns
+        -------
+        out : array_like or tuple, shape
+        """
+
+        if not directed:
+            if X.ndim == 1:
+                X = np.expand_dims(X, axis=0)
+
+            return ((X @ self._pinv_left).T / np.sum(X, axis=1)).T
+        elif directed:
+            X_0 = X[0]
+            X_1 = X[1]
+
+            if X_0.ndim == 1:
+                X_0 = np.expand_dims(X_0, axis=0)
+                X_1 = np.expand_dims(X_1, axis=0)
+
+            return ((X_1 @ self._pinv_right).T / np.sum(X_1, axis=1)).T, (
+                (X_0 @ self._pinv_left).T / np.sum(X_0, axis=1)
+            ).T


Test patch:
diff --git a/tests/test_sklearn.py b/tests/test_sklearn.py
--- a/tests/test_sklearn.py
+++ b/tests/test_sklearn.py
@@ -6,5 +6,4 @@
 
 from sklearn.utils.estimator_checks import check_estimator
 
-check_estimator(graspologic.embed.LaplacianSpectralEmbed())
 check_estimator(graspologic.embed.ClassicalMDS())
diff --git a/tests/test_spectral_embed.py b/tests/test_spectral_embed.py
--- a/tests/test_spectral_embed.py
+++ b/tests/test_spectral_embed.py
@@ -288,6 +288,42 @@ def test_unconnected_warning(self):
 
 
 class TestLaplacianSpectralEmbed(unittest.TestCase):
+    def setUp(self):
+        np.random.seed(9001)
+        n = [10, 10]
+        p = np.array([[0.9, 0.1], [0.1, 0.9]])
+        wt = [[normal, poisson], [poisson, normal]]
+        wtargs = [
+            [dict(loc=3, scale=1), dict(lam=5)],
+            [dict(lam=5), dict(loc=3, scale=1)],
+        ]
+        self.testgraphs = dict(
+            Guw=sbm(n=n, p=p),
+            Gw=sbm(n=n, p=p, wt=wt, wtargs=wtargs),
+            Guwd=sbm(n=n, p=p, directed=True),
+            Gwd=sbm(n=n, p=p, wt=wt, wtargs=wtargs, directed=True),
+        )
+        self.lse = LaplacianSpectralEmbed(n_components=2)
+
+    def test_different_forms(self):
+        f = np.array([[1, 2], [2, 1]])
+        lse = LaplacianSpectralEmbed(form="I-DAD")
+
+    def test_transform_correct_types(self):
+        lse = LaplacianSpectralEmbed(n_components=2)
+        for graph in self.testgraphs.values():
+            A, a = remove_vertices(graph, 1, return_removed=True)
+            lse.fit(A)
+            directed = lse.latent_right_ is not None
+            w = lse.transform(a)
+            if directed:
+                self.assertIsInstance(w, tuple)
+                self.assertIsInstance(w[0], np.ndarray)
+                self.assertIsInstance(w[1], np.ndarray)
+            elif not directed:
+                self.assertIsInstance(w, np.ndarray)
+                self.assertEqual(np.atleast_2d(w).shape[1], 2)
+
     def test_output_dim(self):
         _test_output_dim(self, LaplacianSpectralEmbed)
 
@@ -302,10 +338,6 @@ def test_sbm_er_binary_directed(self):
         P = np.array([[0.8, 0.2], [0.2, 0.3]])
         _test_sbm_er_binary(self, LaplacianSpectralEmbed, P, directed=True)
 
-    def test_different_forms(self):
-        f = np.array([[1, 2], [2, 1]])
-        lse = LaplacianSpectralEmbed(form="I-DAD")
-
     def test_unconnected_warning(self):
         n = [50, 50]
         p = [[1, 0], [0, 1]]
@@ -314,6 +346,21 @@ def test_unconnected_warning(self):
             lse = LaplacianSpectralEmbed()
             lse.fit(A)
 
+    def test_embedding(self):
+        epsilon = 0.1
+        nodes_per_community = 100
+        P = np.array([[0.8, 0.2], [0.2, 0.8]])
+        undirected, labels_ = sbm(2 * [nodes_per_community], P, return_labels=True)
+
+        oos_idx = 0
+        A, a = remove_vertices(undirected, indices=oos_idx, return_removed=True)
+
+        lse = LaplacianSpectralEmbed(n_components=2)
+        X_hat = lse.fit_transform(A)
+        w = lse.transform(a)
+        self.assertTrue(X_hat[0][0] - epsilon < w[0][0] < X_hat[0][0] + epsilon)
+        self.assertTrue(X_hat[0][1] - epsilon < w[0][1] < X_hat[0][1] + epsilon)
+
 
 class TestLaplacianSpectralEmbedSparse(unittest.TestCase):
     def test_output_dim(self):