Repository: graspologic-org/graspologic

Issue:
Refactor dissimilarity function from omni to cmds
title


Discussion:

Resolving patch:
diff --git a/graspy/embed/mds.py b/graspy/embed/mds.py
--- a/graspy/embed/mds.py
+++ b/graspy/embed/mds.py
@@ -1,5 +1,4 @@
 import numpy as np
-from scipy.sparse.linalg import svds
 from sklearn.base import BaseEstimator
 
 from .svd import selectSVD
@@ -38,6 +37,16 @@ class ClassicalMDS(BaseEstimator):
     n_components : int, or None
         Number of components to keep. If None, then it will run
         ``dimselect`` to find the optimal embedding dimension.
+    
+    dissimilarity : 'euclidean' | 'precomputed', optional, default: 'euclidean'
+        Dissimilarity measure to use:
+
+        - 'euclidean':
+            Pairwise Euclidean distances between points in the dataset.
+
+        - 'precomputed':
+            Pre-computed dissimilarities are passed directly to ``fit`` and
+            ``fit_transform``.
 
     Attributes
     ----------
@@ -51,61 +60,137 @@ class ClassicalMDS(BaseEstimator):
         Equals the parameter n_components, or n_features if n_components
         is None.
 
+    dissimilarity_matrix_ : array, shape (n_features, n_features)
+        Dissimilarity matrix 
+
     See Also
     --------
     graphstats.embed.dimselect
+
+    References
+    ----------
+    Wickelmaier, Florian. "An introduction to MDS." Sound Quality Research Unit, 
+    Aalborg University, Denmark 46.5 (2003).
     """
 
-    def __init__(self, n_components):
+    def __init__(self, n_components=None, dissimilarity='euclidean'):
+        # Check inputs
+        if n_components is not None:
+            if not isinstance(n_components, int):
+                msg = "n_components must be an integer, not {}.".format(
+                    type(n_components))
+                raise TypeError(msg)
+            elif n_components <= 0:
+                msg = "n_components must be >= 1 or None."
+                raise ValueError(msg)
         self.n_components = n_components
 
+        if dissimilarity not in ['euclidean', 'precomputed']:
+            msg = "Dissimilarity measure must be either 'euclidean' or 'precomputed'."
+            raise ValueError(msg)
+        self.dissimilarity = dissimilarity
+
+    def _compute_euclidean_distances(self, X):
+        """
+        Computes pairwise distance between row vectors or matrices
+
+        Parameters
+        ----------
+        X : nd-array
+            If ``dissimilarity=='precomputed'``, the input should be the 
+            dissimilarity matrix with shape (n_samples, n_samples). If 
+            ``dissimilarity=='euclidean'``, then the input should be array 
+            with shape (n_samples, n_features) or a nd-array with shape 
+            (n_samples, n_features_1, n_features_2, ..., n_features_d).
+        
+        Returns
+        -------
+        out : 2d-array, shape (n_samples, n_samples)
+            A dissimilarity matrix based on Frobenous norms between pairs of
+            matrices or vectors.
+        """
+        shape = X.shape
+        n_samples = shape[0]
+
+        out = np.zeros((n_samples, n_samples))
+        for i in range(n_samples):
+            out[i] = np.linalg.norm(X - X[i], axis=(1, 2), ord='fro')
+
+        return out
+
     def fit(self, X):
         """
         Fit the model with X.
 
         Parameters
         ----------
-        X : array-like, shape (n_samples, n_samples)
-            Precomputed Euclidean dissimilarity matrix.
+        X : nd-array
+            If ``dissimilarity=='precomputed'``, the input should be the 
+            dissimilarity matrix with shape (n_samples, n_samples). If 
+            ``dissimilarity=='euclidean'``, then the input should be array 
+            with shape (n_samples, n_features) or a nd-array with shape 
+            (n_samples, n_features_1, n_features_2, ..., n_features_d).
         """
-        if not is_symmetric(X):
-            msg = "X must be a symmetric array."
+        # Check X type
+        if not isinstance(X, np.ndarray):
+            msg = "X must be a numpy array, not {}.".format(type(X))
             raise ValueError(msg)
 
+        n_samples = X.shape[0]
         # Handle n_components
-        if self.n_components is None:
-            # TODO: use dimselect here
-            n_components = min(X.shape) - 1
-        elif self.n_components <= 0:
-            msg = "n_components must be >= 1 or None."
+        if n_samples <= self.n_components:
+            msg = "n_components must be <= (n_samples / n_elements)."
             raise ValueError(msg)
+        elif self.n_components is None:
+            # TODO: use dimselect here
+            n_components = min(dissimilarity_matrix.shape) - 1
         else:
             n_components = self.n_components
 
-        n_vertices = X.shape[0]
-
-        J = _get_centering_matrix(n_vertices)
-        B = J.dot(X**2).dot(J) * -0.5
+        # Handle dissimilarity
+        if self.dissimilarity == 'precomputed':
+            # Handle shape of X if precomputed distance matrix
+            if len(X.shape) != 2:
+                msg = "X must be a 2d-array. Input has {} dimensions.".format(
+                    len(X.shape))
+                raise ValueError(msg)
+            # Must be symmetric
+            if not is_symmetric(X):
+                msg = "X must be a symmetric array if precomputed dissimilarity matrix."
+                raise ValueError(msg)
+            dissimilarity_matrix = X
+        elif self.dissimilarity == 'euclidean':
+            dissimilarity_matrix = self._compute_euclidean_distances(X=X)
+
+        J = _get_centering_matrix(dissimilarity_matrix.shape[0])
+        B = J.dot(dissimilarity_matrix**2).dot(J) * -0.5
 
         U, V, D = selectSVD(B, k=n_components)
 
         self.components_ = U
         self.singular_values_ = D**0.5
+        self.dissimilarity_matrix_ = dissimilarity_matrix
 
         return self
 
     def fit_transform(self, X):
         """
-        Fit the model with X and apply the dimensionality reduction on X.
+        Fit the data from X, and returns the embedded coordinates.
 
         Parameters
         ----------
-        X : array-like, shape (n_samples, n_samples)
-            Precomputed Euclidean dissimilarity matrix.
+        X : nd-array
+            If ``dissimilarity=='precomputed'``, the input should be the 
+            dissimilarity matrix with shape (n_samples, n_samples). If 
+            ``dissimilarity=='euclidean'``, then the input should be array 
+            with shape (n_samples, n_features) or a nd-array with shape 
+            (n_samples, n_features_1, n_features_2, ..., n_features_d). First
+            axis of nd-array must be ``n_samples``.
 
         Returns
         -------
-        X_new : array-like 
+        X_new : array-like, shape (n_samples, n_components)
+            Embedded input.
         """
         self.fit(X)
 
diff --git a/graspy/embed/omni.py b/graspy/embed/omni.py
--- a/graspy/embed/omni.py
+++ b/graspy/embed/omni.py
@@ -168,28 +168,3 @@ def fit_transform(self, graphs):
         out : array-like, shape (n_vertices * n_graphs, n_dimension)
         """
         return self._fit_transform(graphs)
-
-    def get_dissimilarity(self):
-        """
-        Computes test statistics based on each pairs 
-
-        Returns
-        -------
-        out : array-like, shape (n_graphs, n_graphs)
-            A dissimilarity matrix based on Frobenous norms between pairs of
-            graphs.
-        """
-        # Check if fitted
-        check_is_fitted(self, ['lpm'], all_or_any=all)
-
-        n_graphs = self.n_graphs_
-        n_vertices = self.n_vertices_
-
-        zhat = self.lpm.transform()
-        zhat = zhat.reshape(n_graphs, n_vertices, -1)
-
-        out = np.zeros((n_graphs, n_graphs))
-        for i in range(n_graphs):
-            out[i] = np.linalg.norm(zhat - zhat[i], axis=(1, 2), ord='fro')
-
-        return out
\ No newline at end of file


Test patch:
diff --git a/tests/test_mds.py b/tests/test_mds.py
--- a/tests/test_mds.py
+++ b/tests/test_mds.py
@@ -1,6 +1,6 @@
 import pytest
 import numpy as np
-from numpy.testing import assert_almost_equal
+from numpy.testing import assert_almost_equal, assert_equal
 
 from graspy.embed.mds import ClassicalMDS
 
@@ -13,10 +13,39 @@ def test_input():
         mds = ClassicalMDS(n_components=100)
         mds.fit(X)
 
+    # Invalid n_components
     with pytest.raises(ValueError):
-        mds = ClassicalMDS(n_components=2)
+        mds = ClassicalMDS(n_components=-2)
+
+    with pytest.raises(TypeError):
+        mds = ClassicalMDS(n_components='1')
+
+    # Invalid dissimilarity
+    with pytest.raises(ValueError):
+        mds = ClassicalMDS(dissimilarity='abc')
+
+    # Invalid input for fit function
+    with pytest.raises(ValueError):
+        X = 'bad_input'
+        mds = ClassicalMDS(n_components=3, dissimilarity='precomputed')
         mds.fit(X)
 
+    # Must be square and symmetric matrix if precomputed dissimilarity
+    with pytest.raises(ValueError):
+        mds = ClassicalMDS(n_components=3, dissimilarity='precomputed')
+        mds.fit(X)
+
+
+def test_tensor_input():
+    X = np.random.normal(size=(100, 5, 5))
+    mds = ClassicalMDS(n_components=3, dissimilarity='euclidean')
+    mds.fit(X)
+
+    assert_equal(mds.dissimilarity_matrix_.shape, (100, 100))
+
+    X_transformed = mds.fit_transform(X)
+    assert_equal(X_transformed.shape, (100, 3))
+
 
 def test_output():
     """
@@ -27,7 +56,6 @@ def test_output():
 
     def _compute_dissimilarity(arr):
         out = np.zeros((4, 4))
-
         for i in range(4):
             out[i] = np.linalg.norm(arr - arr[i], axis=1)
 
@@ -36,7 +64,7 @@ def _compute_dissimilarity(arr):
     def use_fit_transform():
         A = np.ones((4, 4)) - np.identity(4)
 
-        mds = ClassicalMDS(n_components=3)
+        mds = ClassicalMDS(n_components=3, dissimilarity='precomputed')
         B = mds.fit_transform(A)
 
         Ahat = _compute_dissimilarity(B)
@@ -47,7 +75,7 @@ def use_fit_transform():
     def use_fit():
         A = np.ones((4, 4)) - np.identity(4)
 
-        mds = ClassicalMDS(n_components=3)
+        mds = ClassicalMDS(n_components=3, dissimilarity='precomputed')
         mds.fit(A)
         B = np.dot(mds.components_, np.diag(mds.singular_values_))