Repository: graspologic-org/graspologic

Issue:
import edgelists
Make a function to import edgelists
Make a function to import multiple edge lists

- Edgelists are sparse so need to find a union of all vertices when there are multiple edgelists


Discussion:

Resolving patch:
diff --git a/graspy/utils/utils.py b/graspy/utils/utils.py
--- a/graspy/utils/utils.py
+++ b/graspy/utils/utils.py
@@ -5,10 +5,13 @@
 # Email: ebridge2@jhu.edu
 # Copyright (c) 2018. All rights reserved.
 
+import warnings
+from collections import Iterable
 from functools import reduce
+from pathlib import Path
 
-import numpy as np
 import networkx as nx
+import numpy as np
 from sklearn.utils import check_array
 
 
@@ -51,6 +54,90 @@ def import_graph(graph):
     return out
 
 
+def import_edgelist(
+    path, extension="edgelist", delimiter=None, nodetype=int, return_vertices=False
+):
+    """
+    Function for reading a single or multiple edgelists. When importing multiple 
+    edgelists, the union of vertices from all graphs is computed so that each output
+    graph have matched vertex set. The order of nodes are sorted by node values.
+
+    Parameters
+    ----------
+    path : str, Path object, or iterable
+        If ``path`` is a directory, then the importing order will be sorted in 
+        alphabetical order.
+
+    extension : str, optional
+        If ``path`` is a directory, then the function will convert all files
+        with matching extension. 
+
+    delimiter : str or None, default=None, optional
+        Delimiter of edgelist. If None, the delimiter is whitespace.
+
+    nodetype : int (default), float, str, Python type, optional
+       Convert node data from strings to specified type.
+
+    return_vertices : bool, default=False, optional
+        Returns the union of all ind
+
+    Returns
+    -------
+    out : list of array-like, or array-like, shape (n_vertices, n_vertices)
+        If ``path`` is a directory, a list of arrays is returned. If ``path`` is a file,
+        an array is returned.
+
+    vertices : array-like, shape (n_vertices, )
+        If ``return_vertices`` == True, then returns an array of all vertices that were 
+        included in the output graphs. 
+    """
+    # p = Path(path)
+    if not isinstance(path, (str, Path, Iterable)):
+        msg = "path must be a string or Iterable, not {}".format(type(path))
+        raise TypeError(msg)
+
+    # get a list of files to import
+    if isinstance(path, (str, Path)):
+        p = Path(path)
+        if p.is_dir():
+            files = sorted(p.glob("*" + extension))
+        elif p.is_file():
+            files = [p]
+        else:
+            raise ValueError("No graphs founds to import.")
+    else:  # path is an iterable
+        files = [Path(f) for f in path]
+
+    if len(files) == 0:
+        msg = "No files found with '{}' extension found.".format(extension)
+        raise ValueError(msg)
+
+    graphs = [
+        nx.read_weighted_edgelist(f, nodetype=nodetype, delimiter=delimiter)
+        for f in files
+    ]
+
+    if all(len(G.nodes) == 0 for G in graphs):
+        msg = (
+            "All graphs have 0 vertices. Please double check if proper "
+            + "'delimiter' is given."
+        )
+        warnings.warn(msg, UserWarning)
+
+    # Compute union of all vertices
+    vertices = np.sort(reduce(np.union1d, [G.nodes for G in graphs]))
+    out = [nx.to_numpy_array(G, nodelist=vertices, dtype=np.float) for G in graphs]
+
+    # only return adjacency matrix if input is only 1 graph
+    if len(out) == 1:
+        out = out[0]
+
+    if return_vertices:
+        return out, vertices
+    else:
+        return out
+
+
 def is_symmetric(X):
     return np.array_equal(X, X.T)
 


Test patch:
diff --git a/tests/test_io.py b/tests/test_io.py
--- a/tests/test_io.py
+++ b/tests/test_io.py
@@ -1,13 +1,16 @@
-import unittest
-import graspy as gs
-import numpy as np
+import sys
+from pathlib import Path
+
 import networkx as nx
-from graspy.utils import utils as gus
+import numpy as np
+import pytest
+
+import graspy as gs
 
 
-class TestInput(unittest.TestCase):
+class TestImportGraph:
     @classmethod
-    def setUpClass(cls):
+    def setup_class(cls):
         # simple ERxN graph
         n = 15
         p = 0.5
@@ -21,23 +24,98 @@ def setUpClass(cls):
 
     def test_graphin(self):
         G = nx.from_numpy_array(self.A)
-        np.testing.assert_array_equal(nx.to_numpy_array(G), gus.import_graph(G))
+        np.testing.assert_array_equal(nx.to_numpy_array(G), gs.utils.import_graph(G))
 
     def test_npin(self):
-        np.testing.assert_array_equal(self.A, gus.import_graph(self.A))
+        np.testing.assert_array_equal(self.A, gs.utils.import_graph(self.A))
 
     def test_wrongtypein(self):
         a = 5
-        with self.assertRaises(TypeError):
-            gus.import_graph(a)
-        with self.assertRaises(TypeError):
-            gus.import_graph(None)
+        with pytest.raises(TypeError):
+            gs.utils.import_graph(a)
+        with pytest.raises(TypeError):
+            gs.utils.import_graph(None)
 
     def test_nonsquare(self):
         non_square = np.hstack((self.A, self.A))
-        with self.assertRaises(ValueError):
-            gus.import_graph(non_square)
+        with pytest.raises(ValueError):
+            gs.utils.import_graph(non_square)
+
+
+class TestImportEdgelist:
+    @pytest.fixture(autouse=True)
+    def setup_class(self, tmpdir):
+        n = 10
+        p = 0.5
+        wt = np.random.exponential
+        wtargs = dict(scale=4)
+
+        np.random.seed(1)
+
+        self.A = gs.simulations.er_np(n, p)
+        self.B = gs.simulations.er_np(n, p, wt=wt, wtargs=wtargs)
+
+        G_A = nx.from_numpy_array(self.A)
+        G_B = nx.from_numpy_array(self.B)
+        G_B = nx.relabel_nodes(G_B, lambda x: x + 10)  # relabel nodes to go from 10-19.
+
+        self.A_path = str(tmpdir / "A_unweighted.edgelist")
+        self.B_path = str(tmpdir / "B.edgelist")
+        self.root = str(tmpdir)
+
+        nx.write_edgelist(G_A, self.A_path, data=False)
+        nx.write_weighted_edgelist(G_B, self.B_path)
+
+    def test_in(self):
+        A_from_edgelist = gs.utils.import_edgelist(self.A_path)
+        B_from_edgelist = gs.utils.import_edgelist(self.B_path)
+
+        assert np.allclose(A_from_edgelist, self.A)
+        assert np.allclose(B_from_edgelist, self.B)
+
+    def test_in_Path_obj(self):
+        A_from_edgelist = gs.utils.import_edgelist(Path(self.A_path))
+        B_from_edgelist = gs.utils.import_edgelist(Path(self.B_path))
+
+        assert np.allclose(A_from_edgelist, self.A)
+        assert np.allclose(B_from_edgelist, self.B)
+
+    def test_multiple_in(self):
+        graphs = gs.utils.import_edgelist(self.root)
+        A = np.zeros((20, 20))
+        A[:10, :10] = self.A
+
+        B = np.zeros((20, 20))
+        B[10:, 10:] = self.B
+
+        assert len(graphs) == 2
+        assert all(graph.shape == (20, 20) for graph in graphs)
+        assert np.allclose(graphs[0], A)
+        assert np.allclose(graphs[1], B)
+
+    def test_wrongtypein(self):
+        path = 5
+        with pytest.raises(TypeError):
+            gs.utils.import_edgelist(path)
+        with pytest.raises(TypeError):
+            gs.utils.import_edgelist(None)
+
+    def test_vertices(self):
+        expected_vertices_A = np.arange(0, 10)
+        expected_vertices_B = np.arange(10, 20)
+
+        _, A_vertices = gs.utils.import_edgelist(self.A_path, return_vertices=True)
+        _, B_vertices = gs.utils.import_edgelist(self.B_path, return_vertices=True)
+
+        assert np.allclose(expected_vertices_A, A_vertices)
+        assert np.allclose(expected_vertices_B, B_vertices)
 
+    def test_no_graphs_found(self):
+        path = str(self.root + "invalid_edgelist.edgelist")
+        with pytest.raises(ValueError):
+            gs.utils.import_edgelist(path)
 
-if __name__ == "__main__":
-    unittest.main()
+    def test_bad_delimiter(self):
+        delimiter = ","
+        with pytest.warns(UserWarning):
+            graphs = gs.utils.import_edgelist(self.root, delimiter=delimiter)